module fortplot_3d_axes !! 3D axes rendering module for projecting 3D axis frames to 2D !! !! Provides clean, efficient routines for rendering 3D coordinate frames !! with proper tick marks and labels aligned to visible axis segments. use, intrinsic :: iso_fortran_env, only: wp => real64 use fortplot_context, only: plot_context use fortplot_tick_calculation, only: find_nice_tick_locations, & format_tick_value_consistent, & determine_decimal_places_from_step use fortplot_projection, only: project_3d_to_2d, projected_axes_map_t, & projected_box_metrics, map_projected_to_axes use fortplot_3d_box, only: draw_back_panes, draw_pane_gridlines, & draw_back_spines, draw_front_spines, & CORNER_MIN_MIN_MIN, CORNER_MAX_MIN_MIN, & CORNER_MAX_MAX_MIN, CORNER_MIN_MAX_MIN, & CORNER_MIN_MIN_MAX, CORNER_MAX_MIN_MAX, & CORNER_MAX_MAX_MAX, CORNER_MIN_MAX_MAX implicit none private public :: draw_3d_axes public :: draw_3d_front_frame ! Constants for 3D visualization - percentage of axis length for true consistency integer, parameter :: MAX_TICKS_PER_AXIS = 10 ! Constants for visually consistent tick appearance (percentages of rendered axis length) real(wp), parameter :: VISUAL_TICK_PERCENT = 0.04_wp ! Preferred tick length as 4% of rendered axis length real(wp), parameter :: VISUAL_PADDING_PERCENT = 0.06_wp ! Preferred label padding as 6% of rendered axis length real(wp), parameter :: VISUAL_Z_EXTRA_PERCENT = 0.03_wp ! Preferred extra Z-axis spacing as 3% of rendered axis length ! Hard clamps to avoid extremes (fractions of edge length) real(wp), parameter :: VISUAL_TICK_MIN = 0.01_wp real(wp), parameter :: VISUAL_TICK_MAX = 0.06_wp real(wp), parameter :: VISUAL_PADDING_MIN = 0.03_wp real(wp), parameter :: VISUAL_PADDING_MAX = 0.12_wp real(wp), parameter :: EPSILON = 1.0e-12_wp ! Numerical epsilon for divisions ! Minimum center-to-center label spacing in backend pixels used by the greedy ! tick-label selection. Raster/PDF pixels are fine-grained; the ASCII backend ! passes a smaller value matched to its coarse character grid (refs #2053). real(wp), parameter :: DEFAULT_LABEL_GAP_PX = 22.0_wp ! Axis identification integer, parameter :: X_AXIS = 1, Y_AXIS = 2, Z_AXIS = 3 contains subroutine draw_3d_axes(ctx, x_min, x_max, y_min, y_max, z_min, z_max, & fill_panes, label_gap_px) !! Draw complete 3D axes frame with ticks and labels !! !! This is the main entry point that handles all 3D axis rendering: !! - Projects 3D bounding box to 2D coordinates !! - Draws visible axis segments !! - Places tick marks and labels at appropriate positions !! !! ``fill_panes`` defaults to true (raster/PDF). The ASCII backend passes !! false so the coarse character grid renders a clean wireframe box !! instead of solid pane blocks that would bury the data (refs #2054). class(plot_context), intent(inout) :: ctx real(wp), intent(in) :: x_min, x_max, y_min, y_max, z_min, z_max logical, intent(in), optional :: fill_panes real(wp), intent(in), optional :: label_gap_px real(wp) :: corners_2d(2, 8), corners_depth(8) real(wp) :: frac(MAX_TICKS_PER_AXIS, 3) integer :: n_frac(3) logical :: panes real(wp) :: gap_px panes = .true. if (present(fill_panes)) panes = fill_panes gap_px = DEFAULT_LABEL_GAP_PX if (present(label_gap_px)) gap_px = label_gap_px ! Validate input ranges if (x_max <= x_min .or. y_max <= y_min .or. z_max <= z_min) return call project_box_corners(ctx, x_min, x_max, y_min, y_max, & corners_2d, corners_depth) ! Tick fractions per axis drive the per-tick pane gridlines, matching ! matplotlib mplot3d (one gridline at every tick, not a fixed count). call compute_axis_tick_fractions(x_min, x_max, frac(:, X_AXIS), n_frac(X_AXIS)) call compute_axis_tick_fractions(y_min, y_max, frac(:, Y_AXIS), n_frac(Y_AXIS)) call compute_axis_tick_fractions(z_min, z_max, frac(:, Z_AXIS), n_frac(Z_AXIS)) ! Draw back panes, gridlines, and the back spines first so they sit ! behind the data (rendered after this routine). The front spines are ! deferred to draw_3d_front_frame, emitted after the data, so they ! occlude it (global painter ordering, matplotlib mplot3d). if (panes) then call draw_back_panes(ctx, corners_2d, corners_depth) call draw_pane_gridlines(ctx, corners_2d, corners_depth, frac, n_frac) end if call draw_back_spines(ctx, corners_2d, corners_depth) ! Draw ticks and labels on each axis call draw_all_axis_ticks(ctx, corners_2d, x_min, x_max, y_min, y_max, & z_min, z_max, gap_px) end subroutine draw_3d_axes subroutine draw_3d_front_frame(ctx, x_min, x_max, y_min, y_max, z_min, z_max) !! Draw the box spines that lie in front of the data. Called after the !! data is rendered so near spines occlude curves and surfaces, while the !! far spines drawn by draw_3d_axes stay behind the data. class(plot_context), intent(inout) :: ctx real(wp), intent(in) :: x_min, x_max, y_min, y_max, z_min, z_max real(wp) :: corners_2d(2, 8), corners_depth(8) if (x_max <= x_min .or. y_max <= y_min .or. z_max <= z_min) return call project_box_corners(ctx, x_min, x_max, y_min, y_max, & corners_2d, corners_depth) call draw_front_spines(ctx, corners_2d, corners_depth) end subroutine draw_3d_front_frame subroutine project_box_corners(ctx, x_min, x_max, y_min, y_max, & corners_2d, corners_depth) !! Project the unit cube to scaled 2D coordinates and per-corner depth !! using the backend's stored view angles. Shared by the back and front !! frame passes so both use identical geometry. class(plot_context), intent(in) :: ctx real(wp), intent(in) :: x_min, x_max, y_min, y_max real(wp), intent(out) :: corners_2d(2, 8), corners_depth(8) real(wp) :: corners_3d(3, 8) real(wp) :: azim, elev, dist type(projected_axes_map_t) :: map azim = ctx%view_azim elev = ctx%view_elev dist = ctx%view_dist call create_unit_cube(corners_3d) call project_to_2d(corners_3d, azim, elev, dist, corners_2d, corners_depth) ! Aspect-preserving map shared with the data and surface renderers so ! the frame, gridlines, ticks, and data stay registered. call projected_box_metrics(azim, elev, dist, x_min, x_max, y_min, y_max, & ctx%get_width_scale(), ctx%get_height_scale(), & map) call scale_to_data_range(corners_2d, map) end subroutine project_box_corners subroutine compute_axis_tick_fractions(axis_min, axis_max, frac, n_frac) !! Nice tick positions for one axis expressed as fractions in [0,1] of the !! axis range. Shared by the pane gridlines so gridlines coincide with the !! drawn ticks, matching matplotlib mplot3d. real(wp), intent(in) :: axis_min, axis_max real(wp), intent(out) :: frac(:) integer, intent(out) :: n_frac real(wp) :: tick_values(MAX_TICKS_PER_AXIS), step_size real(wp) :: nice_min, nice_max, span integer :: n_ticks, i n_frac = 0 frac = 0.0_wp if (axis_max <= axis_min) return call find_nice_tick_locations(axis_min, axis_max, 9, nice_min, nice_max, & step_size, tick_values, n_ticks) span = max(EPSILON, axis_max - axis_min) do i = 1, n_ticks if (tick_values(i) < axis_min .or. tick_values(i) > axis_max) cycle n_frac = n_frac + 1 frac(n_frac) = (tick_values(i) - axis_min)/span end do end subroutine compute_axis_tick_fractions subroutine create_unit_cube(corners_3d) !! Create unit cube vertices in normalized [0,1]³ space real(wp), intent(out) :: corners_3d(3, 8) ! Define all 8 corners of unit cube systematically corners_3d(:, CORNER_MIN_MIN_MIN) = [0.0_wp, 0.0_wp, 0.0_wp] corners_3d(:, CORNER_MAX_MIN_MIN) = [1.0_wp, 0.0_wp, 0.0_wp] corners_3d(:, CORNER_MAX_MAX_MIN) = [1.0_wp, 1.0_wp, 0.0_wp] corners_3d(:, CORNER_MIN_MAX_MIN) = [0.0_wp, 1.0_wp, 0.0_wp] corners_3d(:, CORNER_MIN_MIN_MAX) = [0.0_wp, 0.0_wp, 1.0_wp] corners_3d(:, CORNER_MAX_MIN_MAX) = [1.0_wp, 0.0_wp, 1.0_wp] corners_3d(:, CORNER_MAX_MAX_MAX) = [1.0_wp, 1.0_wp, 1.0_wp] corners_3d(:, CORNER_MIN_MAX_MAX) = [0.0_wp, 1.0_wp, 1.0_wp] end subroutine create_unit_cube subroutine project_to_2d(corners_3d, azim, elev, dist, corners_2d, corners_depth) !! Project 3D corners to 2D and report camera depth per corner real(wp), intent(in) :: corners_3d(3, 8), azim, elev, dist real(wp), intent(out) :: corners_2d(2, 8) real(wp), intent(out) :: corners_depth(8) real(wp) :: x3d(8), y3d(8), z3d(8), x2d(8), y2d(8) ! Extract coordinates for projection x3d = corners_3d(1, :) y3d = corners_3d(2, :) z3d = corners_3d(3, :) call project_3d_to_2d(x3d, y3d, z3d, azim, elev, dist, x2d, y2d, & depth=corners_depth) corners_2d(1, :) = x2d corners_2d(2, :) = y2d end subroutine project_to_2d subroutine scale_to_data_range(corners_2d, map) !! Map projected corners into the data window with the shared !! aspect-preserving projection map (no independent x/y stretch). real(wp), intent(inout) :: corners_2d(2, 8) type(projected_axes_map_t), intent(in) :: map real(wp) :: x_out, y_out integer :: i do i = 1, 8 call map_projected_to_axes(map, corners_2d(1, i), corners_2d(2, i), & x_out, y_out) corners_2d(1, i) = x_out corners_2d(2, i) = y_out end do end subroutine scale_to_data_range subroutine draw_all_axis_ticks(ctx, corners_2d, x_min, x_max, y_min, y_max, & z_min, z_max, label_gap_px) !! Draw ticks and labels for all three axes class(plot_context), intent(inout) :: ctx real(wp), intent(in) :: corners_2d(2, 8) real(wp), intent(in) :: x_min, x_max, y_min, y_max, z_min, z_max real(wp), intent(in) :: label_gap_px real(wp) :: drawn_px(2, 3*MAX_TICKS_PER_AXIS) character(len=32) :: drawn_text(3*MAX_TICKS_PER_AXIS) integer :: n_drawn ! Tick marks and labels are black, regardless of the gray left set by the ! box spines drawn just before this routine. call ctx%color(0.0_wp, 0.0_wp, 0.0_wp) ! Shared set of drawn label positions so collisions are resolved across ! all three axes, not just within each axis (refs #2055). n_drawn = 0 drawn_px = 0.0_wp drawn_text = '' ! Draw each axis independently using the same pattern call draw_single_axis_ticks(ctx, corners_2d, X_AXIS, x_min, x_max, x_min, & x_max, y_min, y_max, z_min, z_max, label_gap_px, & drawn_px, drawn_text, n_drawn) call draw_single_axis_ticks(ctx, corners_2d, Y_AXIS, y_min, y_max, x_min, & x_max, y_min, y_max, z_min, z_max, label_gap_px, & drawn_px, drawn_text, n_drawn) call draw_single_axis_ticks(ctx, corners_2d, Z_AXIS, z_min, z_max, x_min, & x_max, y_min, y_max, z_min, z_max, label_gap_px, & drawn_px, drawn_text, n_drawn) end subroutine draw_all_axis_ticks subroutine draw_single_axis_ticks(ctx, corners_2d, axis_id, axis_min, axis_max, & x_min, x_max, y_min, y_max, z_min, z_max, & label_gap_px, drawn_px, drawn_text, n_drawn) !! Draw ticks and labels for a single axis class(plot_context), intent(inout) :: ctx real(wp), intent(in) :: corners_2d(2, 8) integer, intent(in) :: axis_id real(wp), intent(in) :: axis_min, axis_max, x_min, x_max, y_min, y_max, z_min, z_max real(wp), intent(in) :: label_gap_px real(wp), intent(inout) :: drawn_px(:, :) character(len=32), intent(inout) :: drawn_text(:) integer, intent(inout) :: n_drawn real(wp) :: tick_values(MAX_TICKS_PER_AXIS), step_size real(wp) :: nice_min, nice_max integer :: n_ticks, decimals, corner1, corner2 ! Get nice tick locations. mplot3d places markedly more ticks than the ! 2D default; a target of nine reproduces matplotlib's 0.25 spacing on a ! unit range and unit spacing on a 0..5 range. call find_nice_tick_locations(axis_min, axis_max, 9, nice_min, nice_max, & step_size, tick_values, n_ticks) decimals = determine_decimal_places_from_step(step_size) ! Determine corner indices for this axis select case (axis_id) case (X_AXIS) corner1 = CORNER_MIN_MIN_MIN; corner2 = CORNER_MAX_MIN_MIN case (Y_AXIS) corner1 = CORNER_MAX_MIN_MIN; corner2 = CORNER_MAX_MAX_MIN case (Z_AXIS) corner1 = CORNER_MIN_MIN_MIN; corner2 = CORNER_MIN_MIN_MAX end select call draw_ticks_on_edge(ctx, corners_2d, corner1, corner2, tick_values, & n_ticks, axis_min, axis_max, x_min, x_max, y_min, y_max, z_min, z_max, & decimals, axis_id, label_gap_px, drawn_px, drawn_text, n_drawn) end subroutine draw_single_axis_ticks subroutine draw_ticks_on_edge(ctx, corners_2d, corner1, corner2, tick_values, n_ticks, & axis_min, axis_max, x_min, x_max, y_min, y_max, z_min, z_max, decimals, & axis_id, label_gap_px, drawn_px, drawn_text, n_drawn) !! Draw tick marks and labels along a specific edge with visually consistent lengths class(plot_context), intent(inout) :: ctx real(wp), intent(in) :: corners_2d(2, 8) integer, intent(in) :: corner1, corner2, n_ticks, decimals, axis_id real(wp), intent(in) :: label_gap_px real(wp), intent(inout) :: drawn_px(:, :) character(len=32), intent(inout) :: drawn_text(:) integer, intent(inout) :: n_drawn real(wp), contiguous, intent(in) :: tick_values(:) real(wp), intent(in) :: axis_min, axis_max real(wp), intent(in) :: x_min, x_max, y_min, y_max, z_min, z_max real(wp) :: tick_pos(2), tick_end(2), label_pos(2) real(wp) :: range_factor real(wp) :: edge_vec(2), edge_len, normal_vec(2), edge_mid(2), plot_center(2) real(wp) :: width_scale, height_scale, canvas_w_px, canvas_h_px real(wp) :: tick_px, pad_px, extra_px real(wp) :: tol integer :: i, j ! Buffers for two-pass layout real(wp) :: cand_label_pos(2, MAX_TICKS_PER_AXIS) logical :: cand_valid(MAX_TICKS_PER_AXIS) logical :: cand_endpoint(MAX_TICKS_PER_AXIS) character(len=32) :: cand_text(MAX_TICKS_PER_AXIS) integer :: order(MAX_TICKS_PER_AXIS) ! Phase 1: compute edge geometry call compute_edge_geometry(ctx, corners_2d, corner1, corner2, axis_id, & x_min, x_max, y_min, y_max, & edge_vec, edge_len, normal_vec, edge_mid, plot_center, & width_scale, height_scale, canvas_w_px, canvas_h_px, & tick_px, pad_px, extra_px) if (edge_len <= EPSILON) return ! Phase 2: collect tick candidates. Initialise the candidate buffers so ! ticks skipped by the range filter below leave no stale stack memory ! that could later be drawn as a tofu label at a garbage position. cand_valid = .false. cand_endpoint = .false. cand_label_pos = 0.0_wp cand_text = '' tol = 1.0e-9_wp*max(1.0_wp, abs(axis_max - axis_min)) do i = 1, n_ticks if (tick_values(i) < axis_min .or. tick_values(i) > axis_max) cycle range_factor = (tick_values(i) - axis_min)/max(EPSILON, axis_max - axis_min) tick_pos(1) = corners_2d(1,corner1) + range_factor * (corners_2d(1,corner2) - corners_2d(1,corner1)) tick_pos(2) = corners_2d(2,corner1) + range_factor * (corners_2d(2,corner2) - corners_2d(2,corner1)) call compute_tick_positions(axis_id, tick_pos, normal_vec, tick_px, pad_px, extra_px, & width_scale, height_scale, tick_end, label_pos) call ctx%line(tick_pos(1), tick_pos(2), tick_end(1), tick_end(2)) cand_valid(i) = .true. cand_label_pos(:, i) = label_pos cand_text(i) = format_tick_value_consistent(tick_values(i), decimals) cand_endpoint(i) = (abs(tick_values(i) - axis_min) <= tol) .or. & (abs(tick_values(i) - axis_max) <= tol) if (axis_id == X_AXIS .and. i == n_ticks) cand_valid(i) = .false. if (axis_id == Y_AXIS .and. i == 1) cand_valid(i) = .false. end do ! Phase 3: order candidates (endpoints first) call order_tick_candidates(n_ticks, cand_valid, cand_endpoint, order, j) ! Phase 4: greedy selection with spacing constraint call select_and_draw_labels(j, order, cand_valid, cand_label_pos, cand_text, & width_scale, height_scale, label_gap_px, ctx, & drawn_px, drawn_text, n_drawn) end subroutine draw_ticks_on_edge subroutine compute_edge_geometry(ctx, corners_2d, corner1, corner2, axis_id, & x_min, x_max, y_min, y_max, & edge_vec, edge_len, normal_vec, edge_mid, plot_center, & width_scale, height_scale, canvas_w_px, canvas_h_px, & tick_px, pad_px, extra_px) !! Compute edge direction, normal, and pixel-scale dimensions class(plot_context), intent(in) :: ctx real(wp), intent(in) :: corners_2d(2, 8) integer, intent(in) :: corner1, corner2, axis_id real(wp), intent(in) :: x_min, x_max, y_min, y_max real(wp), intent(out) :: edge_vec(2), edge_len real(wp), intent(out) :: normal_vec(2), edge_mid(2), plot_center(2) real(wp), intent(out) :: width_scale, height_scale, canvas_w_px, canvas_h_px real(wp), intent(out) :: tick_px, pad_px, extra_px edge_vec(1) = corners_2d(1, corner2) - corners_2d(1, corner1) edge_vec(2) = corners_2d(2, corner2) - corners_2d(2, corner1) edge_len = sqrt(edge_vec(1)**2 + edge_vec(2)**2) normal_vec = [-edge_vec(2)/edge_len, edge_vec(1)/edge_len] edge_mid = 0.5_wp*[corners_2d(1, corner1) + corners_2d(1, corner2), & corners_2d(2, corner1) + corners_2d(2, corner2)] plot_center = [sum(corners_2d(1, :))/8.0_wp, sum(corners_2d(2, :))/8.0_wp] if ( (normal_vec(1)*(edge_mid(1)-plot_center(1)) + normal_vec(2)*(edge_mid(2)-plot_center(2))) < 0.0_wp ) then normal_vec = -normal_vec end if width_scale = ctx%get_width_scale() height_scale = ctx%get_height_scale() canvas_w_px = width_scale*(x_max - x_min) canvas_h_px = height_scale*(y_max - y_min) tick_px = max(4.0_wp, min(12.0_wp, VISUAL_TICK_PERCENT*min(canvas_w_px, canvas_h_px))) pad_px = max(6.0_wp, min(24.0_wp, VISUAL_PADDING_PERCENT*min(canvas_w_px, canvas_h_px))) extra_px = merge(max(0.0_wp, VISUAL_Z_EXTRA_PERCENT) * min(canvas_w_px, canvas_h_px), 0.0_wp, axis_id == Z_AXIS) end subroutine compute_edge_geometry subroutine compute_tick_positions(axis_id, tick_pos, normal_vec, tick_px, pad_px, extra_px, & width_scale, height_scale, tick_end, label_pos) !! Compute tick end point and label position for a single tick integer, intent(in) :: axis_id real(wp), intent(in) :: tick_pos(2), normal_vec(2) real(wp), intent(in) :: tick_px, pad_px, extra_px real(wp), intent(in) :: width_scale, height_scale real(wp), intent(out) :: tick_end(2), label_pos(2) if (axis_id == Z_AXIS) then tick_end(1) = tick_pos(1) + sign(1.0_wp, normal_vec(1)) * (tick_px / max(EPSILON, width_scale)) tick_end(2) = tick_pos(2) label_pos(1) = tick_end(1) + sign(1.0_wp, normal_vec(1)) * ((pad_px + extra_px) / max(EPSILON, width_scale)) label_pos(2) = tick_pos(2) else tick_end(1) = tick_pos(1) tick_end(2) = tick_pos(2) + sign(1.0_wp, normal_vec(2)) * (tick_px / max(EPSILON, height_scale)) label_pos(1) = tick_pos(1) label_pos(2) = tick_end(2) + sign(1.0_wp, normal_vec(2)) * ((pad_px + extra_px) / max(EPSILON, height_scale)) end if end subroutine compute_tick_positions subroutine order_tick_candidates(n_ticks, cand_valid, cand_endpoint, order, n_ordered) !! Order tick candidates: endpoints first, then others integer, intent(in) :: n_ticks logical, intent(in) :: cand_valid(:), cand_endpoint(:) integer, intent(out) :: order(:) integer, intent(out) :: n_ordered integer :: i, j j = 0 do i = 1, n_ticks if (cand_valid(i) .and. cand_endpoint(i)) then j = j + 1 order(j) = i end if end do do i = 1, n_ticks if (cand_valid(i) .and. .not. cand_endpoint(i)) then j = j + 1 order(j) = i end if end do n_ordered = j end subroutine order_tick_candidates subroutine select_and_draw_labels(n_ordered, order, cand_valid, cand_label_pos, cand_text, & width_scale, height_scale, min_gap_px, ctx, & drawn_px, drawn_text, n_drawn) !! Greedy label selection with a pixel-spacing constraint. ``drawn_px`` !! accumulates the pixel positions of labels already drawn on this frame !! across all three axes, so a candidate is rejected when it lands within !! ``min_gap_px`` of ANY earlier label. This resolves shared-corner !! collisions between different axes, matching mplot3d (refs #2055). integer, intent(in) :: n_ordered integer, intent(in) :: order(MAX_TICKS_PER_AXIS) logical, intent(in) :: cand_valid(MAX_TICKS_PER_AXIS) real(wp), intent(in) :: cand_label_pos(2, MAX_TICKS_PER_AXIS) character(len=32), intent(in) :: cand_text(MAX_TICKS_PER_AXIS) real(wp), intent(in) :: width_scale, height_scale, min_gap_px class(plot_context), intent(inout) :: ctx real(wp), intent(inout) :: drawn_px(:, :) character(len=32), intent(inout) :: drawn_text(:) integer, intent(inout) :: n_drawn real(wp) :: cand_px(2) logical :: clear integer :: i, k do i = 1, n_ordered if (.not. cand_valid(order(i))) cycle cand_px = [cand_label_pos(1, order(i))*width_scale, & cand_label_pos(2, order(i))*height_scale] clear = .true. do k = 1, n_drawn if (sqrt((cand_px(1) - drawn_px(1, k))**2 + & (cand_px(2) - drawn_px(2, k))**2) < min_gap_px) then clear = .false. exit end if if (trim(adjustl(cand_text(order(i)))) == trim(adjustl(drawn_text(k)))) then if (abs(cand_px(1) - drawn_px(1, k)) < 12.0_wp*width_scale .and. & abs(cand_px(2) - drawn_px(2, k)) < 2.5_wp*height_scale) then clear = .false. exit end if end if end do if (.not. clear) cycle call ctx%text(cand_label_pos(1, order(i)), cand_label_pos(2, order(i)), & trim(adjustl(cand_text(order(i))))) if (n_drawn < size(drawn_px, 2)) then n_drawn = n_drawn + 1 drawn_px(:, n_drawn) = cand_px drawn_text(n_drawn) = cand_text(order(i)) end if end do end subroutine select_and_draw_labels ! ...existing code... end module fortplot_3d_axes