Skip to main content

poincare_lib/
axis.rs

1//! Axis box, tick stubs, and label generation for 3D graphs.
2//!
3//! # Overview
4//!
5//! The axis system consists of three visual layers:
6//!
7//! 1. **Box edges** — 12 line segments forming the wireframe bounding box of the domain.
8//! 2. **Tick stubs** — short perpendicular stubs at nice-number positions along each axis.
9//! 3. **Labels** — projected 2D text: one per tick + one axis-name label per axis.
10//!
11//! All polyline geometry is returned as [`PolylineItem`] values that the caller appends
12//! to [`FrameData::polylines`] before submission.
13
14use glam::{Mat4, Vec2, Vec3};
15use serde::{Deserialize, Serialize};
16use viewport_lib::{LabelItem, PolylineItem};
17
18use crate::domain::Domain;
19
20// ---------------------------------------------------------------------------
21// Axis3 — axis discriminant
22// ---------------------------------------------------------------------------
23
24/// Identifies one of the three Cartesian axes.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Axis3 {
27    X,
28    Y,
29    Z,
30}
31
32// ---------------------------------------------------------------------------
33// AxisConfig
34// ---------------------------------------------------------------------------
35
36/// Configuration for the labelled axis box rendered around every graph.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct AxisConfig {
39    /// Draw the 12-edge wireframe bounding box.
40    pub show_box: bool,
41
42    /// Draw projected axis/tick labels.
43    pub show_labels: bool,
44
45    /// Draw short perpendicular tick stubs along each axis.
46    pub show_ticks: bool,
47
48    /// Draw grid planes (not yet implemented — reserved for future use).
49    pub show_grid: bool,
50
51    /// Axis name labels: `[x_label, y_label, z_label]`.
52    /// `None` suppresses the label for that axis.
53    pub labels: [Option<String>; 3],
54
55    /// Target number of ticks per axis: `[nx, ny, nz]`.
56    pub tick_count: [u32; 3],
57
58    /// Per-axis RGBA colours (linear float): `[x_colour, y_colour, z_colour]`.
59    /// Used for both axis lines and their tick stubs.
60    pub axis_colours: [[f32; 4]; 3],
61
62    /// RGBA colour for tick labels (linear float).
63    pub tick_colour: [f32; 4],
64}
65
66impl Default for AxisConfig {
67    fn default() -> Self {
68        let dim = [0.7, 0.7, 0.7, 1.0];
69        Self {
70            show_box: true,
71            show_labels: true,
72            show_ticks: true,
73            show_grid: false,
74            labels: [
75                Some("x".to_string()),
76                Some("y".to_string()),
77                Some("z".to_string()),
78            ],
79            tick_count: [5, 5, 5],
80            axis_colours: [
81                [0.9, 0.2, 0.2, 1.0], // X — red
82                [0.2, 0.9, 0.2, 1.0], // Y — green
83                [0.2, 0.2, 0.9, 1.0], // Z — blue
84            ],
85            tick_colour: dim,
86        }
87    }
88}
89
90// ---------------------------------------------------------------------------
91// Axis line generation
92// ---------------------------------------------------------------------------
93
94/// Returns the 3 axis lines through the origin as `(start, end)` pairs.
95///
96/// Each line spans the full domain extent along its axis at the other two
97/// coordinates equal to zero (i.e. the lines cross at the origin).
98pub fn build_axis_lines(domain: &Domain) -> [([f32; 3], [f32; 3]); 3] {
99    let x0 = *domain.x.start() as f32;
100    let x1 = *domain.x.end() as f32;
101    let y0 = *domain.y.start() as f32;
102    let y1 = *domain.y.end() as f32;
103    let z0 = *domain.z.start() as f32;
104    let z1 = *domain.z.end() as f32;
105
106    [
107        ([x0, 0.0, 0.0], [x1, 0.0, 0.0]), // X axis
108        ([0.0, y0, 0.0], [0.0, y1, 0.0]), // Y axis
109        ([0.0, 0.0, z0], [0.0, 0.0, z1]), // Z axis
110    ]
111}
112
113// ---------------------------------------------------------------------------
114// Tick stub generation
115// ---------------------------------------------------------------------------
116
117/// Maximum stub length in world units.
118const MAX_STUB_LENGTH: f32 = 0.3;
119/// Minimum stub length in world units.
120const MIN_STUB_LENGTH: f32 = 1e-4;
121/// Maximum outward label offset in world units.
122const MAX_LABEL_OFFSET: f32 = 0.6;
123/// Minimum outward label offset in world units.
124const MIN_LABEL_OFFSET: f32 = 5e-4;
125/// Desired projected tick length in normalized device coordinates.
126const TARGET_TICK_NDC: f32 = 0.025;
127/// Desired projected label offset in normalized device coordinates.
128const TARGET_LABEL_OFFSET_NDC: f32 = 0.035;
129
130fn axis_span(domain: &Domain, axis: Axis3) -> f32 {
131    match axis {
132        Axis3::X => (*domain.x.end() - *domain.x.start()) as f32,
133        Axis3::Y => (*domain.y.end() - *domain.y.start()) as f32,
134        Axis3::Z => (*domain.z.end() - *domain.z.start()) as f32,
135    }
136    .abs()
137}
138
139fn tick_step_hint(domain: &Domain, axis: Axis3, tick_positions: &[(f64, String)]) -> f32 {
140    let mut deltas: Vec<f32> = tick_positions
141        .windows(2)
142        .map(|pair| (pair[1].0 - pair[0].0).abs() as f32)
143        .filter(|d| *d > 1e-6)
144        .collect();
145    if deltas.is_empty() {
146        let span = axis_span(domain, axis);
147        return (span / 5.0).max(MIN_STUB_LENGTH);
148    }
149    deltas.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
150    deltas[deltas.len() / 2]
151}
152
153fn stub_length_for_axis(domain: &Domain, axis: Axis3, tick_positions: &[(f64, String)]) -> f32 {
154    (tick_step_hint(domain, axis, tick_positions) * 0.18).clamp(MIN_STUB_LENGTH, MAX_STUB_LENGTH)
155}
156
157fn label_offset_for_axis(domain: &Domain, axis: Axis3, tick_positions: &[(f64, String)]) -> f32 {
158    (stub_length_for_axis(domain, axis, tick_positions) * 1.4)
159        .clamp(MIN_LABEL_OFFSET, MAX_LABEL_OFFSET)
160}
161
162fn stub_direction(axis: Axis3) -> Vec3 {
163    match axis {
164        Axis3::X => Vec3::NEG_Y,
165        Axis3::Y => Vec3::NEG_X,
166        Axis3::Z => Vec3::NEG_Y,
167    }
168}
169
170fn axis_point(axis: Axis3, pos: f64) -> Vec3 {
171    let p = pos as f32;
172    match axis {
173        Axis3::X => Vec3::new(p, 0.0, 0.0),
174        Axis3::Y => Vec3::new(0.0, p, 0.0),
175        Axis3::Z => Vec3::new(0.0, 0.0, p),
176    }
177}
178
179fn project_to_ndc_xy(vp: &Mat4, world: Vec3) -> Option<Vec2> {
180    let clip = *vp * world.extend(1.0);
181    if clip.w.abs() < 1e-6 || clip.z < -clip.w || clip.z > clip.w {
182        return None;
183    }
184    let inv_w = 1.0 / clip.w;
185    Some(Vec2::new(clip.x * inv_w, clip.y * inv_w))
186}
187
188fn projected_world_extent(
189    vp: &Mat4,
190    origin: Vec3,
191    direction: Vec3,
192    target_ndc: f32,
193) -> Option<f32> {
194    let base = project_to_ndc_xy(vp, origin)?;
195    let mut lo = MIN_STUB_LENGTH;
196    let mut hi = MAX_STUB_LENGTH;
197    let end_hi = project_to_ndc_xy(vp, origin + direction * hi)?;
198    if (end_hi - base).length() <= target_ndc {
199        return Some(hi);
200    }
201    for _ in 0..20 {
202        let mid = 0.5 * (lo + hi);
203        let end = project_to_ndc_xy(vp, origin + direction * mid)?;
204        if (end - base).length() < target_ndc {
205            lo = mid;
206        } else {
207            hi = mid;
208        }
209    }
210    Some(hi)
211}
212
213fn projected_stub_length(
214    domain: &Domain,
215    vp: Option<&Mat4>,
216    axis: Axis3,
217    tick_positions: &[(f64, String)],
218    pos: f64,
219) -> f32 {
220    let fallback = stub_length_for_axis(domain, axis, tick_positions);
221    let Some(vp) = vp else { return fallback };
222    projected_world_extent(
223        vp,
224        axis_point(axis, pos),
225        stub_direction(axis),
226        TARGET_TICK_NDC,
227    )
228    .unwrap_or(fallback)
229    .clamp(MIN_STUB_LENGTH, MAX_STUB_LENGTH)
230}
231
232fn projected_label_offset(
233    domain: &Domain,
234    vp: Option<&Mat4>,
235    axis: Axis3,
236    tick_positions: &[(f64, String)],
237    pos: f64,
238) -> f32 {
239    let fallback = label_offset_for_axis(domain, axis, tick_positions);
240    let Some(vp) = vp else { return fallback };
241    projected_world_extent(
242        vp,
243        axis_point(axis, pos),
244        stub_direction(axis),
245        TARGET_LABEL_OFFSET_NDC,
246    )
247    .unwrap_or(fallback)
248    .clamp(MIN_LABEL_OFFSET, MAX_LABEL_OFFSET)
249}
250
251/// Returns short perpendicular line stubs at each tick position along `axis`.
252///
253/// Stubs are centred on the axis line through the origin:
254///
255/// * **X-axis**: stub at `(tick_x, 0, 0)` extending in the −Y direction.
256/// * **Y-axis**: stub at `(0, tick_y, 0)` extending in the −X direction.
257/// * **Z-axis**: stub at `(0, 0, tick_z)` extending in the −Y direction.
258pub fn build_tick_stubs(
259    domain: &Domain,
260    axis: Axis3,
261    tick_positions: &[(f64, String)],
262) -> Vec<([f32; 3], [f32; 3])> {
263    build_tick_stubs_projected(domain, None, axis, tick_positions)
264}
265
266pub fn build_tick_stubs_projected(
267    domain: &Domain,
268    vp: Option<&Mat4>,
269    axis: Axis3,
270    tick_positions: &[(f64, String)],
271) -> Vec<([f32; 3], [f32; 3])> {
272    tick_positions
273        .iter()
274        .map(|(pos, _)| {
275            let p = *pos as f32;
276            let stub_length = projected_stub_length(domain, vp, axis, tick_positions, *pos);
277            match axis {
278                Axis3::X => ([p, 0.0, 0.0], [p, -stub_length, 0.0]),
279                Axis3::Y => ([0.0, p, 0.0], [-stub_length, p, 0.0]),
280                Axis3::Z => ([0.0, 0.0, p], [0.0, -stub_length, p]),
281            }
282        })
283        .collect()
284}
285
286// ---------------------------------------------------------------------------
287// Combined polyline assembly
288// ---------------------------------------------------------------------------
289
290/// Assemble the axis lines and tick stubs into [`PolylineItem`] values.
291///
292/// Returns:
293/// * One `PolylineItem` for the 3 axis lines through the origin.
294/// * One `PolylineItem` for all tick stubs across all three axes.
295///
296/// If `config.show_box` is false, the axis lines item is omitted.
297/// If `config.show_ticks` is false, the tick-stub items are omitted.
298pub fn build_axis_polyline(
299    domain: &Domain,
300    config: &AxisConfig,
301    ticks_per_axis: &[Vec<(f64, String)>; 3],
302) -> Vec<PolylineItem> {
303    build_axis_polyline_projected(domain, config, ticks_per_axis, None)
304}
305
306pub fn build_axis_polyline_projected(
307    domain: &Domain,
308    config: &AxisConfig,
309    ticks_per_axis: &[Vec<(f64, String)>; 3],
310    vp: Option<&Mat4>,
311) -> Vec<PolylineItem> {
312    let mut items = Vec::new();
313    let axes = [Axis3::X, Axis3::Y, Axis3::Z];
314    let lines = build_axis_lines(domain);
315
316    for (i, axis) in axes.iter().enumerate() {
317        let colour = config.axis_colours[i];
318
319        // --- Axis line through origin ---
320        if config.show_box {
321            let (a, b) = lines[i];
322            let mut item = PolylineItem::default();
323            item.positions = vec![a, b];
324            item.scalars = Vec::new();
325            item.strip_lengths = vec![2];
326            item.scalar_range = None;
327            item.colourmap_id = None;
328            item.default_colour = colour;
329            item.line_width = 1.5;
330            items.push(item);
331        }
332
333        // --- Tick stubs for this axis ---
334        if config.show_ticks {
335            let stubs = build_tick_stubs_projected(domain, vp, *axis, &ticks_per_axis[i]);
336            if !stubs.is_empty() {
337                let mut positions: Vec<[f32; 3]> = Vec::with_capacity(stubs.len() * 2);
338                let mut strip_lengths: Vec<u32> = Vec::with_capacity(stubs.len());
339                for (a, b) in stubs {
340                    positions.push(a);
341                    positions.push(b);
342                    strip_lengths.push(2);
343                }
344                let mut item = PolylineItem::default();
345                item.positions = positions;
346                item.scalars = Vec::new();
347                item.strip_lengths = strip_lengths;
348                item.scalar_range = None;
349                item.colourmap_id = None;
350                item.default_colour = colour;
351                item.line_width = 1.0;
352                items.push(item);
353            }
354        }
355    }
356
357    items
358}
359
360// ---------------------------------------------------------------------------
361// Label generation
362// ---------------------------------------------------------------------------
363
364pub(crate) fn tick_label_anchor(
365    domain: &Domain,
366    vp: Option<&Mat4>,
367    axis: Axis3,
368    tick_positions: &[(f64, String)],
369    pos: f64,
370) -> Vec3 {
371    let offset = projected_label_offset(domain, vp, axis, tick_positions, pos);
372    let p = pos as f32;
373    match axis {
374        Axis3::X => Vec3::new(p, -offset, 0.0),
375        Axis3::Y => Vec3::new(-offset, p, 0.0),
376        Axis3::Z => Vec3::new(-offset, 0.0, p),
377    }
378}
379
380/// Build [`LabelItem`] entries for tick values and axis names.
381///
382/// Tick labels are positioned at the tick stub start point, offset outward from the
383/// box face.  Axis name labels sit at the midpoint of the bottom edge of each axis.
384pub fn build_axis_labels(
385    domain: &Domain,
386    config: &AxisConfig,
387    ticks_per_axis: &[Vec<(f64, String)>; 3],
388) -> Vec<LabelItem> {
389    build_axis_labels_projected(domain, config, ticks_per_axis, None)
390}
391
392pub fn build_axis_labels_projected(
393    domain: &Domain,
394    config: &AxisConfig,
395    ticks_per_axis: &[Vec<(f64, String)>; 3],
396    vp: Option<&Mat4>,
397) -> Vec<LabelItem> {
398    let x1 = *domain.x.end() as f32;
399    let y1 = *domain.y.end() as f32;
400    let z1 = *domain.z.end() as f32;
401
402    let tc = config.tick_colour;
403    let mut labels: Vec<LabelItem> = Vec::new();
404
405    // --- Tick labels ---
406    // Labels sit just below/beside the tick stub, offset from the origin axis.
407    // The origin (pos == 0) is shared by all three axes, so we emit its label only once.
408    if config.show_labels {
409        let mut origin_labelled = false;
410
411        for (pos, text) in &ticks_per_axis[0] {
412            if *pos == 0.0 {
413                if origin_labelled {
414                    continue;
415                }
416                origin_labelled = true;
417            }
418            let mut lbl = LabelItem::default();
419            lbl.world_anchor =
420                Some(tick_label_anchor(domain, vp, Axis3::X, &ticks_per_axis[0], *pos).to_array());
421            lbl.text = text.clone();
422            lbl.colour = tc;
423            lbl.font_size = 11.0;
424            labels.push(lbl);
425        }
426        for (pos, text) in &ticks_per_axis[1] {
427            if *pos == 0.0 {
428                if origin_labelled {
429                    continue;
430                }
431                origin_labelled = true;
432            }
433            let mut lbl = LabelItem::default();
434            lbl.world_anchor =
435                Some(tick_label_anchor(domain, vp, Axis3::Y, &ticks_per_axis[1], *pos).to_array());
436            lbl.text = text.clone();
437            lbl.colour = tc;
438            lbl.font_size = 11.0;
439            labels.push(lbl);
440        }
441        for (pos, text) in &ticks_per_axis[2] {
442            if *pos == 0.0 {
443                if origin_labelled {
444                    continue;
445                }
446                origin_labelled = true;
447            }
448            let mut lbl = LabelItem::default();
449            lbl.world_anchor =
450                Some(tick_label_anchor(domain, vp, Axis3::Z, &ticks_per_axis[2], *pos).to_array());
451            lbl.text = text.clone();
452            lbl.colour = tc;
453            lbl.font_size = 11.0;
454            labels.push(lbl);
455        }
456    }
457
458    // --- Axis name labels — placed just beyond the positive end of each axis ---
459    let name_positions = [
460        (
461            config.labels[0].as_deref(),
462            Vec3::new(
463                x1 + projected_label_offset(
464                    domain,
465                    vp,
466                    Axis3::X,
467                    &ticks_per_axis[0],
468                    *domain.x.end(),
469                ),
470                0.0,
471                0.0,
472            ),
473        ),
474        (
475            config.labels[1].as_deref(),
476            Vec3::new(
477                0.0,
478                y1 + projected_label_offset(
479                    domain,
480                    vp,
481                    Axis3::Y,
482                    &ticks_per_axis[1],
483                    *domain.y.end(),
484                ),
485                0.0,
486            ),
487        ),
488        (
489            config.labels[2].as_deref(),
490            Vec3::new(
491                0.0,
492                0.0,
493                z1 + projected_label_offset(
494                    domain,
495                    vp,
496                    Axis3::Z,
497                    &ticks_per_axis[2],
498                    *domain.z.end(),
499                ),
500            ),
501        ),
502    ];
503
504    if config.show_labels {
505        for (name_opt, world_pos) in &name_positions {
506            let Some(name) = name_opt else { continue };
507            let mut lbl = LabelItem::default();
508            lbl.world_anchor = Some(world_pos.to_array());
509            lbl.text = name.to_string();
510            lbl.colour = tc;
511            lbl.font_size = 13.0;
512            labels.push(lbl);
513        }
514    }
515
516    labels
517}
518
519// ---------------------------------------------------------------------------
520// Tests
521// ---------------------------------------------------------------------------
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526    use crate::domain::Domain;
527
528    fn default_domain() -> Domain {
529        Domain::default() // [-10, 10] x [-10, 10] x [-10, 10]
530    }
531
532    #[test]
533    fn axis_lines_has_three() {
534        let lines = build_axis_lines(&default_domain());
535        assert_eq!(lines.len(), 3, "must produce exactly 3 axis lines");
536        // X axis passes through origin (y=0, z=0)
537        assert_eq!(lines[0].0[1], 0.0);
538        assert_eq!(lines[0].0[2], 0.0);
539    }
540
541    #[test]
542    fn tick_stubs_match_count() {
543        let domain = default_domain();
544        let ticks: Vec<(f64, String)> = vec![
545            (-10.0, "-10".to_string()),
546            (-5.0, "-5".to_string()),
547            (0.0, "0".to_string()),
548            (5.0, "5".to_string()),
549            (10.0, "10".to_string()),
550        ];
551        let stubs = build_tick_stubs(&domain, Axis3::X, &ticks);
552        assert_eq!(stubs.len(), ticks.len());
553    }
554
555    #[test]
556    fn tick_stubs_x_direction() {
557        let domain = default_domain();
558        let ticks = vec![(5.0_f64, "5".to_string())];
559        let stubs = build_tick_stubs(&domain, Axis3::X, &ticks);
560        // X stub: start = (5, 0, 0), end = (5, -0.3, 0)
561        let (start, end) = stubs[0];
562        assert!((start[0] - 5.0).abs() < 1e-6);
563        assert!((start[1] - 0.0).abs() < 1e-6);
564        assert!((end[1] - (-0.3)).abs() < 1e-4);
565    }
566
567    #[test]
568    fn axis_config_defaults() {
569        let cfg = AxisConfig::default();
570        assert!(cfg.show_box);
571        assert!(cfg.show_labels);
572        assert!(cfg.show_ticks);
573        assert!(!cfg.show_grid);
574        assert_eq!(cfg.labels[0].as_deref(), Some("x"));
575        assert_eq!(cfg.labels[1].as_deref(), Some("y"));
576        assert_eq!(cfg.labels[2].as_deref(), Some("z"));
577        assert_eq!(cfg.tick_count, [5, 5, 5]);
578        // X=red, Y=green, Z=blue
579        assert!(cfg.axis_colours[0][0] > cfg.axis_colours[0][1]); // red dominates X
580        assert!(cfg.axis_colours[1][1] > cfg.axis_colours[1][0]); // green dominates Y
581        assert!(cfg.axis_colours[2][2] > cfg.axis_colours[2][0]); // blue dominates Z
582    }
583
584    #[test]
585    fn build_axis_polyline_returns_six_items_when_both_enabled() {
586        let domain = default_domain();
587        let cfg = AxisConfig::default();
588        let ticks: Vec<(f64, String)> = vec![(0.0, "0".to_string())];
589        let ticks_per_axis = [ticks.clone(), ticks.clone(), ticks.clone()];
590        let items = build_axis_polyline(&domain, &cfg, &ticks_per_axis);
591        assert_eq!(items.len(), 6, "should produce 3 axis lines + 3 tick sets");
592    }
593
594    #[test]
595    fn build_axis_labels_returns_ticks_plus_axis_names() {
596        let domain = default_domain();
597        let cfg = AxisConfig::default();
598        let ticks: Vec<(f64, String)> = vec![
599            (-10.0, "-10".to_string()),
600            (0.0, "0".to_string()),
601            (10.0, "10".to_string()),
602        ];
603        let ticks_per_axis = [ticks.clone(), ticks.clone(), ticks.clone()];
604        let labels = build_axis_labels(&domain, &cfg, &ticks_per_axis);
605        // 3 ticks * 3 axes = 9, minus 2 duplicate origin labels + 3 axis names = 10
606        assert_eq!(
607            labels.len(),
608            10,
609            "expected 7 tick + 3 name labels (origin deduplicated)"
610        );
611        // Check axis name labels are present.
612        let texts: Vec<&str> = labels.iter().map(|l| l.text.as_str()).collect();
613        assert!(texts.contains(&"x"), "missing x label");
614        assert!(texts.contains(&"y"), "missing y label");
615        assert!(texts.contains(&"z"), "missing z label");
616    }
617}