fortplot_3d_axes.f90 Source File


Source Code

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