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

    ! 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