Skip to main content

damascene_core/plot/
lower.rs

1//! Lowering plot samples to scene geometry.
2//!
3//! A plot's data marks render by reusing the [`scene`](crate::scene) GPU
4//! pipelines (the plan's decision 1), so a mark's `f64` samples must become
5//! the scene's logical geometry: [`LineData`] (segments) and [`PointData`]
6//! (markers / join discs), positioned in **scale space** at `z = 0`.
7//!
8//! ## Coordinates
9//!
10//! Each sample `(x, y)` maps through the axis [`Scale`]s, relative to a
11//! per-axis `origin` subtracted before the cast to `f32`, so large absolute
12//! coordinates — epoch timestamps especially — keep precision on the GPU
13//! (the plan's decision 7). The orthographic plot camera then maps the
14//! visible scale-space window to the data rect.
15//!
16//! ## Line joins (the reuse-the-pipelines decision)
17//!
18//! The scene line pipeline draws each segment as an anti-aliased quad with
19//! *butt* caps, which leave wedge-gaps at angled joins. [`lower_line`]
20//! fills those by also emitting a round disc (the scene point pipeline draws
21//! anti-aliased circles) at **every** vertex, so joins and end-caps read as
22//! clean rounds with no new GPU pipeline. The disc diameter equals the line
23//! width and is applied as the [`PointStyle`](crate::scene::PointStyle) size
24//! by the caller (see `docs/PLOT2D_PLAN.md`, the resolved polyline risk).
25
26#![warn(missing_docs)]
27
28use glam::Vec3;
29
30use crate::color::{Color, ColorSpace};
31use crate::plot::scale::Scale;
32use crate::plot::series::Sample;
33use crate::scene::geometry::{LineData, LineSegment, PointData, ScenePoint};
34
35/// The lowered geometry of a line mark: the connecting segments plus the
36/// round join/cap discs that clean up the butt-cap joins.
37#[derive(Clone, Debug, Default, PartialEq)]
38pub struct LoweredLine {
39    /// One segment per consecutive sample pair.
40    pub segments: LineData,
41    /// One disc per vertex (sized to the line width by the caller).
42    pub joins: PointData,
43}
44
45/// Convert a `Color` to the authoring-space sRGBA `[f32; 4]` the scene
46/// geometry stores (the backend converts to working-linear at upload).
47fn srgba(color: Color) -> [f32; 4] {
48    let c = color.convert_to(ColorSpace::SRGB);
49    [c.r, c.g, c.b, c.a]
50}
51
52/// Map a sample to a scale-space position at `z = 0`.
53fn position(s: Sample, x: Scale, y: Scale, origin: (f64, f64)) -> Vec3 {
54    Vec3::new(x.map(s.x, origin.0), y.map(s.y, origin.1), 0.0)
55}
56
57/// Lower a line mark's `samples` to connecting [`LineData`] plus round
58/// join/cap discs ([`PointData`]), all in scale space relative to `origin`.
59/// `color` is applied to every segment and disc.
60///
61/// Degenerate inputs: zero samples yield empty geometry; a single sample
62/// yields no segments and one disc (a dot).
63pub fn lower_line(
64    samples: &[Sample],
65    x: Scale,
66    y: Scale,
67    origin: (f64, f64),
68    color: Color,
69) -> LoweredLine {
70    let rgba = srgba(color);
71    let positions: Vec<Vec3> = samples.iter().map(|&s| position(s, x, y, origin)).collect();
72
73    let segments = positions
74        .windows(2)
75        .map(|w| LineSegment {
76            start: w[0],
77            end: w[1],
78            color: rgba,
79        })
80        .collect();
81
82    let joins = positions
83        .iter()
84        .map(|&p| ScenePoint {
85            position: p,
86            color: rgba,
87        })
88        .collect();
89
90    LoweredLine {
91        segments: LineData { segments },
92        joins: PointData { points: joins },
93    }
94}
95
96/// Lower a scatter mark's `samples` to [`PointData`] in scale space relative
97/// to `origin`, each marker coloured `color`.
98pub fn lower_scatter(
99    samples: &[Sample],
100    x: Scale,
101    y: Scale,
102    origin: (f64, f64),
103    color: Color,
104) -> PointData {
105    let rgba = srgba(color);
106    PointData {
107        points: samples
108            .iter()
109            .map(|&s| ScenePoint {
110                position: position(s, x, y, origin),
111                color: rgba,
112            })
113            .collect(),
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    fn s(x: f64, y: f64) -> Sample {
122        Sample::new(x, y)
123    }
124
125    #[test]
126    fn line_segment_and_join_counts() {
127        let pts = [s(0.0, 0.0), s(1.0, 1.0), s(2.0, 0.0)];
128        let l = lower_line(
129            &pts,
130            Scale::linear(),
131            Scale::linear(),
132            (0.0, 0.0),
133            Color::srgb_u8(255, 255, 255),
134        );
135        assert_eq!(l.segments.segments.len(), 2); // n-1 segments
136        assert_eq!(l.joins.points.len(), 3); // one disc per vertex (round caps+joins)
137    }
138
139    #[test]
140    fn line_positions_are_origin_relative_scale_space() {
141        let pts = [s(100.0, 10.0), s(101.0, 12.0)];
142        let l = lower_line(
143            &pts,
144            Scale::linear(),
145            Scale::linear(),
146            (100.0, 10.0),
147            Color::srgb_u8(255, 255, 255),
148        );
149        // first vertex sits at the origin → (0,0,0)
150        assert_eq!(l.segments.segments[0].start, Vec3::new(0.0, 0.0, 0.0));
151        // second vertex is +1 in x, +2 in y
152        assert_eq!(l.segments.segments[0].end, Vec3::new(1.0, 2.0, 0.0));
153    }
154
155    #[test]
156    fn time_origin_keeps_f32_precision() {
157        // epoch seconds ~1.78e9: absolute value would lose sub-second
158        // precision in f32, but origin-relative stays exact.
159        let base = 1_780_000_000.0_f64;
160        let pts = [s(base, 1.0), s(base + 0.5, 2.0)];
161        let l = lower_line(
162            &pts,
163            Scale::time(),
164            Scale::linear(),
165            (base, 0.0),
166            Color::srgb_u8(255, 255, 255),
167        );
168        assert_eq!(l.segments.segments[0].start.x, 0.0);
169        assert_eq!(l.segments.segments[0].end.x, 0.5);
170    }
171
172    #[test]
173    fn single_sample_is_a_dot() {
174        let l = lower_line(
175            &[s(5.0, 5.0)],
176            Scale::linear(),
177            Scale::linear(),
178            (0.0, 0.0),
179            Color::srgb_u8(255, 255, 255),
180        );
181        assert!(l.segments.segments.is_empty());
182        assert_eq!(l.joins.points.len(), 1);
183    }
184
185    #[test]
186    fn empty_is_empty() {
187        let l = lower_line(
188            &[],
189            Scale::linear(),
190            Scale::linear(),
191            (0.0, 0.0),
192            Color::srgb_u8(255, 255, 255),
193        );
194        assert!(l.segments.segments.is_empty());
195        assert!(l.joins.points.is_empty());
196    }
197
198    #[test]
199    fn log_x_maps_through_warp() {
200        // x = 1000 with a log10 axis, origin at 1.0 → log10(1000) - log10(1) = 3
201        let l = lower_line(
202            &[s(1.0, 0.0), s(1000.0, 0.0)],
203            Scale::log(),
204            Scale::linear(),
205            (1.0, 0.0),
206            Color::srgb_u8(255, 255, 255),
207        );
208        assert!((l.segments.segments[0].end.x - 3.0).abs() < 1e-5);
209    }
210
211    #[test]
212    fn scatter_maps_each_sample() {
213        let pts = [s(0.0, 0.0), s(2.0, 4.0)];
214        let p = lower_scatter(
215            &pts,
216            Scale::linear(),
217            Scale::linear(),
218            (0.0, 0.0),
219            Color::srgb_u8(255, 255, 255),
220        );
221        assert_eq!(p.points.len(), 2);
222        assert_eq!(p.points[1].position, Vec3::new(2.0, 4.0, 0.0));
223    }
224}