Skip to main content

kui_core/
line.rs

1//! Strokes: what a `line` node draws (`docs/adr/0010-a-segment-primitive.md`).
2//!
3//! A line is a run of points and a [`Stroke`]; the core flattens a curve
4//! into a polyline here, boxes the run, and emits one
5//! [`crate::QuadKind::Segment`] per straight piece. The points live in a
6//! per-frame list beside the tree — rebuilt every frame, capacities kept —
7//! and a node refers to its run by [`LineId`] the way a text node refers to
8//! the frame's text list. The previous frame's list is kept by the same
9//! gated buffer swap the text list uses, so a departing line can copy its
10//! points out for its ghost.
11
12use crate::color::Color;
13use crate::geom::{Rect, Vec2};
14use crate::retain::Kept;
15
16/// Index into the frame's line list.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub struct LineId(pub u32);
19
20/// How a `line` is drawn: a width, a colour, and whether the points are
21/// the corners of a polyline or the knots of a curve through them.
22#[derive(Clone, Copy, Debug, PartialEq)]
23pub struct Stroke {
24    /// Stroke width in logical px. Round caps at both ends of every
25    /// segment, which is also the join between two of them.
26    pub width: f32,
27    /// The stroke colour. Inside the core it rides in the node's `bg`
28    /// slot, so a `transition` eases it and `enter` / `exit` can start
29    /// or end it there.
30    pub color: Color,
31    /// Draw a smooth curve *through* the points (a centripetal Catmull-Rom
32    /// spline, flattened by [`flatten_curve`]) instead of the polyline. Two
33    /// points are a straight segment either way.
34    pub curve: bool,
35}
36
37impl Stroke {
38    pub fn new(width: f32, color: Color) -> Self {
39        Self {
40            width,
41            color,
42            curve: false,
43        }
44    }
45
46    pub fn curve(mut self) -> Self {
47        self.curve = true;
48        self
49    }
50}
51
52/// One stroke's run in the frame's point list. The points are stored
53/// relative to the node's box, so a node that eases or slides carries
54/// them along.
55#[derive(Clone, Copy, Debug, PartialEq)]
56pub(crate) struct Run {
57    pub first: u32,
58    pub len: u32,
59    /// Logical px.
60    pub width: f32,
61}
62
63/// The frame's strokes, and the previous frame's while an `exit` needs it.
64#[derive(Default)]
65pub struct LineStore {
66    runs: Kept<Run>,
67    points: Kept<Vec2>,
68}
69
70/// How far a stroke's box extends past its points: half the width, plus
71/// two logical px so a backend's edge ramp (`AA` = 0.75 physical px each
72/// side, sampled at pixel centres) is never cut by the quad, at scale 1
73/// included.
74pub(crate) fn pad(width: f32) -> f32 {
75    width.max(0.0) * 0.5 + 2.0
76}
77
78/// A curve span is flattened into one piece per this many logical px of
79/// chord, at least one and at most [`CURVE_MAX_PIECES`]. Fixed rather than
80/// tolerance-driven so the piece count is a function of the declared
81/// geometry alone — every binding gets the same segments, and the
82/// conformance corpus can pin them.
83pub const CURVE_STEP: f32 = 6.0;
84pub const CURVE_MAX_PIECES: usize = 32;
85
86impl LineStore {
87    /// Starts a frame. `keep_prev` retains the list just finished so a
88    /// departing line's ghost can copy its points out of it.
89    pub(crate) fn begin_frame(&mut self, keep_prev: bool) {
90        self.runs.begin(keep_prev);
91        self.points.begin(keep_prev);
92    }
93
94    /// Adds a stroke through `points` (parent-box coordinates): flattens a
95    /// curve, boxes the run, and stores the points relative to the box.
96    /// Returns the id and the box, or None for fewer than two points,
97    /// which draw nothing.
98    pub(crate) fn push(&mut self, points: &[Vec2], stroke: Stroke) -> Option<(LineId, Rect)> {
99        if points.len() < 2 {
100            return None;
101        }
102        let first = self.points.len();
103        if stroke.curve && points.len() > 2 {
104            flatten_curve(points, &mut self.points);
105        } else {
106            self.points.extend_from_slice(points);
107        }
108        let run = &mut self.points[first..];
109        let (mut min, mut max) = (run[0], run[0]);
110        for p in run.iter() {
111            min.x = min.x.min(p.x);
112            min.y = min.y.min(p.y);
113            max.x = max.x.max(p.x);
114            max.y = max.y.max(p.y);
115        }
116        let pad = pad(stroke.width);
117        let origin = Vec2::new(min.x - pad, min.y - pad);
118        for p in run.iter_mut() {
119            p.x -= origin.x;
120            p.y -= origin.y;
121        }
122        let rect = Rect::new(
123            origin.x,
124            origin.y,
125            max.x - min.x + 2.0 * pad,
126            max.y - min.y + 2.0 * pad,
127        );
128        let id = LineId(self.runs.len() as u32);
129        self.runs.push(Run {
130            first: first as u32,
131            len: (self.points.len() - first) as u32,
132            width: stroke.width,
133        });
134        Some((id, rect))
135    }
136
137    /// This frame's run: its width and its points, relative to the node.
138    pub(crate) fn run(&self, id: LineId) -> (Run, &[Vec2]) {
139        let run = self.runs[id.0 as usize];
140        (
141            run,
142            &self.points[run.first as usize..(run.first + run.len) as usize],
143        )
144    }
145
146    /// The same, read from the previous frame's list (a departing line's
147    /// id indexes that list, not this frame's). Empty for an id the kept
148    /// frame does not have, which cannot happen while `begin_frame` keeps
149    /// the two in step.
150    pub(crate) fn prev_run(&self, id: LineId) -> (Run, &[Vec2]) {
151        match self.runs.prev().get(id.0 as usize) {
152            Some(run) => (
153                *run,
154                &self.points.prev()[run.first as usize..(run.first + run.len) as usize],
155            ),
156            None => (
157                Run {
158                    first: 0,
159                    len: 0,
160                    width: 0.0,
161                },
162                &[],
163            ),
164        }
165    }
166
167    /// Runs this frame.
168    pub fn len(&self) -> usize {
169        self.runs.len()
170    }
171
172    pub fn is_empty(&self) -> bool {
173        self.runs.is_empty()
174    }
175}
176
177/// Flattens a **centripetal** Catmull-Rom spline through `knots` (at least
178/// three) into `out`, starting at the first knot and ending at the last.
179/// Each span is cut into `ceil(chord / CURVE_STEP)` pieces, clamped to
180/// `1..=CURVE_MAX_PIECES`.
181///
182/// Centripetal means the spline's knot parameter advances by the square
183/// root of each chord (see [`chord_and_step`]) instead of by 1. A *uniform*
184/// spline — the textbook one, and what this was until it drew a mind
185/// map — ignores how far apart its knots are, so knots that are close
186/// together get as much parameter as knots that are far apart, and the
187/// curve has to move fast through the tight ones. On an elbow (a long run,
188/// then a sharp turn) that shows up as a bow out of the wrong side of the
189/// corner; on knots spaced unevenly enough it becomes a cusp or a loop,
190/// which a centripetal span provably never has. It bows less
191/// too, though it is not overshoot-free: a spline that must pass *through*
192/// a corner has to lean into it. A shape that should only be *pulled*
193/// towards its middle points is a Bézier, and the caller samples one
194/// (`examples/rust/widgets/line.rs`).
195///
196/// The end knots are not doubled — a repeated knot is a zero-length chord,
197/// which centripetal has no parameter for. Each end gets a mirrored
198/// phantom instead (`2·p1 − p2`), which spaces evenly and leaves the first
199/// span's tangent along its own chord. A coincident pair of *real* knots
200/// takes the same branch, so a duplicated point in a caller's list stays a
201/// harmless kink rather than a division by zero.
202///
203/// The chords are rolled forward rather than recomputed, so a span costs
204/// two square roots and not six; `frame_1k_curves` is the bench that
205/// watches the rest of it.
206pub fn flatten_curve(knots: &[Vec2], out: &mut Vec<Vec2>) {
207    let n = knots.len();
208    if n < 2 {
209        return;
210    }
211    out.push(knots[0]);
212    // This span's chord and its knot step (√chord), and the span before's:
213    // a zero chord means there is no neighbour on that side, either
214    // because the run ends there or because the two knots coincide.
215    let (mut prev, mut prev_step) = (0.0, 0.0);
216    let (mut chord, mut step) = chord_and_step(knots[0], knots[1]);
217    for i in 0..n - 1 {
218        let (p1, p2) = (knots[i], knots[i + 1]);
219        let (next, next_step) = match knots.get(i + 2) {
220            Some(&p) => chord_and_step(p2, p),
221            None => (0.0, 0.0),
222        };
223        let pieces = ((chord / CURVE_STEP).ceil() as usize).clamp(1, CURVE_MAX_PIECES);
224        if chord == 0.0 {
225            // Nothing to parameterize: the span is a point.
226            out.push(p2);
227        } else {
228            let mirror = |p: Vec2, q: Vec2| Vec2::new(2.0 * p.x - q.x, 2.0 * p.y - q.y);
229            let (p0, s0) = match prev > 0.0 {
230                true => (knots[i - 1], prev_step),
231                false => (mirror(p1, p2), step),
232            };
233            let (p3, s2) = match next > 0.0 {
234                true => (knots[i + 2], next_step),
235                false => (mirror(p2, p1), step),
236            };
237            // Knot times: 0, then one step per span.
238            let span = Span::new([p0, p1, p2, p3], [0.0, s0, s0 + step, s0 + step + s2]);
239            for k in 1..pieces {
240                out.push(span.at(s0 + step * (k as f32 / pieces as f32)));
241            }
242            // The knot itself rather than the evaluation at its time, so
243            // the run passes through it bit for bit whatever the
244            // arithmetic rounds to.
245            out.push(p2);
246        }
247        (prev, prev_step) = (chord, step);
248        (chord, step) = (next, next_step);
249    }
250}
251
252/// A span's length and the parameter it is worth: `√chord` is Lee's
253/// α = ½, the centripetal one. α = 0 would be `1.0` (uniform) and α = 1
254/// the chord itself (chordal).
255fn chord_and_step(a: Vec2, b: Vec2) -> (f32, f32) {
256    let chord = ((b.x - a.x).powi(2) + (b.y - a.y).powi(2)).sqrt();
257    (chord, chord.sqrt())
258}
259
260/// One span of the spline, evaluated between `p[1]` and `p[2]` by Barry
261/// and Goldman's pyramid: three interpolations of the knots in their own
262/// times, then two of those, then one. Written this way rather than as a
263/// cubic in `t` because the times are no longer evenly spaced.
264///
265/// A span is built once and asked for every piece, because the pyramid's
266/// five distinct denominators are the same five numbers each time. They
267/// are reciprocals, so `at(t[2])` is only *nearly* `p[2]` — the caller
268/// pushes the knot itself instead of asking for it.
269struct Span {
270    p: [Vec2; 4],
271    t: [f32; 4],
272    /// `1/(t[1]-t[0])`, `1/(t[2]-t[1])`, `1/(t[3]-t[2])`, `1/(t[2]-t[0])`,
273    /// `1/(t[3]-t[1])`, in the order the pyramid needs them.
274    inv: [f32; 5],
275}
276
277impl Span {
278    fn new(p: [Vec2; 4], t: [f32; 4]) -> Self {
279        let inv = [
280            1.0 / (t[1] - t[0]),
281            1.0 / (t[2] - t[1]),
282            1.0 / (t[3] - t[2]),
283            1.0 / (t[2] - t[0]),
284            1.0 / (t[3] - t[1]),
285        ];
286        Span { p, t, inv }
287    }
288
289    fn at(&self, u: f32) -> Vec2 {
290        let (p, t) = (&self.p, &self.t);
291        let blend = |a: Vec2, b: Vec2, ta: f32, inv: f32| {
292            let w = (u - ta) * inv;
293            Vec2::new(a.x + (b.x - a.x) * w, a.y + (b.y - a.y) * w)
294        };
295        let a1 = blend(p[0], p[1], t[0], self.inv[0]);
296        let a2 = blend(p[1], p[2], t[1], self.inv[1]);
297        let a3 = blend(p[2], p[3], t[2], self.inv[2]);
298        let b1 = blend(a1, a2, t[0], self.inv[3]);
299        let b2 = blend(a2, a3, t[1], self.inv[4]);
300        blend(b1, b2, t[1], self.inv[1])
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn a_curve_passes_through_its_knots_and_ends_where_they_end() {
310        let knots = [
311            Vec2::new(0.0, 0.0),
312            Vec2::new(30.0, 40.0),
313            Vec2::new(60.0, 0.0),
314        ];
315        let mut out = Vec::new();
316        flatten_curve(&knots, &mut out);
317        assert_eq!(out[0], knots[0]);
318        assert_eq!(*out.last().unwrap(), knots[2]);
319        // Chord 50 → 9 pieces per span, 1 + 9 + 9 points.
320        assert_eq!(out.len(), 19);
321        assert_eq!(out[9], knots[1]);
322    }
323
324    /// How sharply the run doubles back, worst piece, in degrees. A
325    /// smoothly flattened curve turns a few degrees a piece; a cusp turns
326    /// most of the way round.
327    fn worst_turn(pts: &[Vec2]) -> f32 {
328        pts.windows(3).fold(0.0f32, |worst, w| {
329            let (a, b) = (
330                Vec2::new(w[1].x - w[0].x, w[1].y - w[0].y),
331                Vec2::new(w[2].x - w[1].x, w[2].y - w[1].y),
332            );
333            let len = |v: Vec2| (v.x * v.x + v.y * v.y).sqrt();
334            let (la, lb) = (len(a), len(b));
335            if la < 1e-6 || lb < 1e-6 {
336                return worst;
337            }
338            let cos = ((a.x * b.x + a.y * b.y) / (la * lb)).clamp(-1.0, 1.0);
339            worst.max(cos.acos().to_degrees())
340        })
341    }
342
343    /// Whether any two non-adjacent pieces of the run cross.
344    fn ties_a_loop(pts: &[Vec2]) -> bool {
345        let side =
346            |a: Vec2, b: Vec2, c: Vec2| (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
347        (0..pts.len() - 1).any(|i| {
348            (i + 2..pts.len() - 1).any(|j| {
349                let (a, b, c, d) = (pts[i], pts[i + 1], pts[j], pts[j + 1]);
350                side(a, b, c) * side(a, b, d) < 0.0 && side(c, d, a) * side(c, d, b) < 0.0
351            })
352        })
353    }
354
355    /// The reason the parameterization is centripetal and not uniform.
356    /// Chords 671, 36 and 328: a knot pair nineteen times tighter than its
357    /// neighbours. A uniform parameter hands that 36px chord as much curve
358    /// as the 671px one, and the middle span has to tie a loop to spend
359    /// it — at `CURVE_STEP`'s own sampling, one crossing and a piece that
360    /// turns 95°. Centripetal spends parameter by `√chord`, so the tight
361    /// pair is just a corner.
362    #[test]
363    fn a_tight_knot_between_two_long_ones_neither_loops_nor_cusps() {
364        let knots = [
365            Vec2::new(0.0, 400.0),
366            Vec2::new(600.0, 100.0),
367            Vec2::new(630.0, 80.0),
368            Vec2::new(560.0, 400.0),
369        ];
370        let mut out = Vec::new();
371        flatten_curve(&knots, &mut out);
372        assert!(!ties_a_loop(&out), "the run crosses itself");
373        assert!(worst_turn(&out) < 45.0, "cusp: {:.1}°", worst_turn(&out));
374    }
375
376    /// A caller's list may repeat a point — a mind map with two cards at
377    /// the same place, a path snapped to a grid. The mirrored phantom
378    /// covers it: a zero chord has no `√chord` to divide by, and every
379    /// point that comes back is a number.
380    #[test]
381    fn a_repeated_knot_is_a_kink_and_not_a_division_by_zero() {
382        let knots = [
383            Vec2::new(0.0, 0.0),
384            Vec2::new(40.0, 0.0),
385            Vec2::new(40.0, 0.0),
386            Vec2::new(40.0, 40.0),
387            Vec2::new(80.0, 40.0),
388        ];
389        let mut out = Vec::new();
390        flatten_curve(&knots, &mut out);
391        assert!(out.iter().all(|p| p.x.is_finite() && p.y.is_finite()));
392        assert_eq!(out[0], knots[0]);
393        assert_eq!(*out.last().unwrap(), knots[4]);
394    }
395
396    #[test]
397    fn two_points_are_one_segment_even_as_a_curve() {
398        let mut store = LineStore::default();
399        let (id, rect) = store
400            .push(
401                &[Vec2::new(10.0, 10.0), Vec2::new(50.0, 40.0)],
402                Stroke::new(2.0, Color::WHITE).curve(),
403            )
404            .unwrap();
405        let (run, pts) = store.run(id);
406        assert_eq!(run.len, 2);
407        assert_eq!(run.width, 2.0);
408        // Padded by half the width plus two: the box starts at (7, 7).
409        assert_eq!((rect.x, rect.y, rect.w, rect.h), (7.0, 7.0, 46.0, 36.0));
410        assert_eq!(pts[0], Vec2::new(3.0, 3.0));
411        assert_eq!(pts[1], Vec2::new(43.0, 33.0));
412    }
413
414    #[test]
415    fn one_point_draws_nothing() {
416        let mut store = LineStore::default();
417        assert!(
418            store
419                .push(&[Vec2::new(1.0, 1.0)], Stroke::new(1.0, Color::WHITE))
420                .is_none()
421        );
422    }
423
424    #[test]
425    fn the_previous_frame_is_kept_only_when_asked() {
426        let mut store = LineStore::default();
427        let (id, _) = store
428            .push(
429                &[Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0)],
430                Stroke::new(1.0, Color::WHITE),
431            )
432            .unwrap();
433        store.begin_frame(true);
434        assert_eq!(store.prev_run(id).0.len, 2);
435        assert!(store.is_empty());
436        store.begin_frame(false);
437        assert_eq!(store.prev_run(id).0.len, 0);
438    }
439}