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 ! Pixel box in which a repeat of the same tick text is treated as the same ! label rather than a second one. Narrow on purpose: it exists to catch a ! label drawn twice at a shared corner, not to deduplicate across axes that ! legitimately share tick values. real(wp), parameter :: DUP_TEXT_DX_PX = 12.0_wp real(wp), parameter :: DUP_TEXT_DY_PX = 2.5_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 function projected_edge_length_px(ctx, corners_2d, corner1, corner2) result(edge_px) !! Length of a projected box edge in backend pixels. class(plot_context), intent(in) :: ctx real(wp), intent(in) :: corners_2d(2, 8) integer, intent(in) :: corner1, corner2 real(wp) :: edge_px, dx_px, dy_px dx_px = (corners_2d(1, corner2) - corners_2d(1, corner1))*ctx%get_width_scale() dy_px = (corners_2d(2, corner2) - corners_2d(2, corner1))*ctx%get_height_scale() edge_px = sqrt(dx_px**2 + dy_px**2) end function projected_edge_length_px 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, edge_px integer :: n_ticks, decimals, corner1, corner2, target_ticks ! 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 ! Ask for as many ticks as this edge can actually carry once projected. ! mplot3d places markedly more ticks than the 2D default, and a target of ! nine reproduces matplotlib's 0.25 spacing on a unit range; but on a ! foreshortened edge nine candidates all fall inside the label spacing ! minimum and every one is rejected, which left that axis with no numbers ! at all. Scaling the request by projected length keeps a short edge ! sparsely but legibly labelled instead of blank. edge_px = projected_edge_length_px(ctx, corners_2d, corner1, corner2) target_ticks = int(edge_px/(2.0_wp*max(1.0_wp, label_gap_px))) + 1 target_ticks = max(3, min(9, target_ticks)) call find_nice_tick_locations(axis_min, axis_max, target_ticks, nice_min, & nice_max, step_size, tick_values, n_ticks) decimals = determine_decimal_places_from_step(step_size) 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) real(wp) :: cand_tick_pos(2, MAX_TICKS_PER_AXIS) real(wp) :: cand_tick_end(2, MAX_TICKS_PER_AXIS) logical :: cand_valid(MAX_TICKS_PER_AXIS) logical :: cand_endpoint(MAX_TICKS_PER_AXIS) logical :: cand_mark(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_mark = .false. cand_label_pos = 0.0_wp cand_tick_pos = 0.0_wp cand_tick_end = 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) cand_valid(i) = .true. cand_mark(i) = .true. cand_label_pos(:, i) = label_pos cand_tick_pos(:, i) = tick_pos cand_tick_end(:, i) = tick_end 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) ! The X and Y edges meet at a shared corner. Suppress the duplicate ! label there but keep the tick mark, which belongs to both axes. 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, & cand_tick_pos, cand_tick_end, cand_mark, & width_scale, height_scale, label_gap_px, ctx, & drawn_px, drawn_text, n_drawn) ! Phase 5: tick marks for corner-suppressed labels. Every other mark is ! drawn by the selection above, so an axis crowded enough to lose a label ! loses its mark too rather than leaving an unexplained stub. Before ! this, a foreshortened Y edge drew a full row of marks and no numbers. do i = 1, n_ticks if (cand_mark(i)) then call ctx%line(cand_tick_pos(1, i), cand_tick_pos(2, i), & cand_tick_end(1, i), cand_tick_end(2, i)) end if end do 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, & cand_tick_pos, cand_tick_end, cand_mark, & 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). !! !! Each accepted label's tick mark is drawn here and its ``cand_mark`` !! flag cleared; a rejected label clears the flag without drawing, so a !! dropped label never leaves a mark behind. Flags left set belong to the !! shared-corner case and are drawn by the caller. 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) :: cand_tick_pos(2, MAX_TICKS_PER_AXIS) real(wp), intent(in) :: cand_tick_end(2, MAX_TICKS_PER_AXIS) logical, intent(inout) :: cand_mark(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 ! Both operands are already in pixels. Scaling the thresholds by ! width_scale/height_scale again measured them in data units, so ! the suppression radius covered the whole canvas and an axis ! sharing its tick values with an earlier axis (x and y both ! spanning [-1,1], say) lost every label it had. if (trim(adjustl(cand_text(order(i)))) == trim(adjustl(drawn_text(k)))) then if (abs(cand_px(1) - drawn_px(1, k)) < DUP_TEXT_DX_PX .and. & abs(cand_px(2) - drawn_px(2, k)) < DUP_TEXT_DY_PX) then clear = .false. exit end if end if end do if (.not. clear) then cand_mark(order(i)) = .false. cycle end if call ctx%line(cand_tick_pos(1, order(i)), cand_tick_pos(2, order(i)), & cand_tick_end(1, order(i)), cand_tick_end(2, order(i))) cand_mark(order(i)) = .false. 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