Skip to main content

wisp/scene/path/
mod.rs

1//! Path commands + adaptive Bezier flattening (M-VEC.10 / AUT-62).
2//!
3//! Supports `move_to`, `line_to`, `quad_to`, `cubic_to`, `close`.
4//! Two consumer paths:
5//!
6//! - **Flatten to a polygon** for masking via
7//!   [`crate::scene::VectorShape::Path`].
8//! - **Render as a stroked `Graphics`** for visible arrow / freehand
9//!   geometry (joins between segments are butt-style for V1; mitered
10//!   joins are a future enhancement).
11//!
12//! Adaptive subdivision uses a flatness test on the control polygon:
13//! a Bezier curve is "flat enough" when the maximum perpendicular
14//! distance from a control point to the chord is below `tolerance`.
15//!
16//! Boolean ops on paths (`union`, `intersection`, `difference`,
17//! `xor`) live in the [`boolean`] submodule (M-BOOL.0 / AUT-161).
18
19pub mod boolean;
20
21use glam::Vec2;
22
23use crate::color::Color;
24use crate::scene::graphics::{Fill, Graphics};
25
26/// One command in a path. The path is a sequence of these; rendering
27/// / flattening walks them in order.
28#[derive(Debug, Clone, Copy, PartialEq)]
29#[non_exhaustive]
30pub enum PathCommand {
31    /// Lift the pen and move to `p`. Starts a new subpath.
32    MoveTo(Vec2),
33    /// Draw a straight line from the current point to `p`.
34    LineTo(Vec2),
35    /// Quadratic Bezier from current point through `control` to
36    /// `end`.
37    QuadTo {
38        /// Control point.
39        control: Vec2,
40        /// End point.
41        end: Vec2,
42    },
43    /// Cubic Bezier from current point through `c1` and `c2` to
44    /// `end`.
45    CubicTo {
46        /// First control point.
47        c1: Vec2,
48        /// Second control point.
49        c2: Vec2,
50        /// End point.
51        end: Vec2,
52    },
53    /// Close the current subpath with a line back to its `MoveTo`.
54    Close,
55}
56
57/// Builder for a [`Path`]. Each method returns `Self` for chaining.
58#[derive(Debug, Clone, Default)]
59pub struct PathBuilder {
60    commands: Vec<PathCommand>,
61}
62
63impl PathBuilder {
64    /// Construct an empty builder.
65    #[must_use]
66    pub fn new() -> Self {
67        Self::default()
68    }
69
70    /// Append a `MoveTo` command.
71    #[must_use]
72    pub fn move_to(mut self, p: Vec2) -> Self {
73        self.commands.push(PathCommand::MoveTo(p));
74        self
75    }
76
77    /// Append a `LineTo` command.
78    #[must_use]
79    pub fn line_to(mut self, p: Vec2) -> Self {
80        self.commands.push(PathCommand::LineTo(p));
81        self
82    }
83
84    /// Append a `QuadTo` command.
85    #[must_use]
86    pub fn quad_to(mut self, control: Vec2, end: Vec2) -> Self {
87        self.commands.push(PathCommand::QuadTo { control, end });
88        self
89    }
90
91    /// Append a `CubicTo` command.
92    #[must_use]
93    pub fn cubic_to(mut self, c1: Vec2, c2: Vec2, end: Vec2) -> Self {
94        self.commands.push(PathCommand::CubicTo { c1, c2, end });
95        self
96    }
97
98    /// Append a `Close` command.
99    #[must_use]
100    pub fn close(mut self) -> Self {
101        self.commands.push(PathCommand::Close);
102        self
103    }
104
105    /// Finish the builder.
106    #[must_use]
107    pub fn build(self) -> Path {
108        Path {
109            commands: self.commands,
110        }
111    }
112}
113
114/// Owned path data — list of [`PathCommand`]s plus consumer methods.
115#[derive(Debug, Clone, PartialEq)]
116pub struct Path {
117    commands: Vec<PathCommand>,
118}
119
120impl Path {
121    /// Construct from a raw command vector. Prefer
122    /// [`PathBuilder`] for fluent construction.
123    #[must_use]
124    pub fn from_commands(commands: Vec<PathCommand>) -> Self {
125        Self { commands }
126    }
127
128    /// Borrow the underlying commands.
129    #[must_use]
130    pub fn commands(&self) -> &[PathCommand] {
131        &self.commands
132    }
133
134    /// Flatten Beziers to line segments **per subpath**, preserving
135    /// `MoveTo` boundaries. Each `MoveTo` starts a new
136    /// `Vec<Vec2>`; subsequent `LineTo` / `QuadTo` / `CubicTo`
137    /// commands append into the current subpath; `Close` appends a
138    /// final point back to the subpath's start.
139    ///
140    /// This is the multi-subpath variant of [`Path::flatten`]
141    /// (M-BOOL.7 / AUT-168). Boolean ops, masks, and any consumer
142    /// that needs to distinguish disjoint regions of a single
143    /// `Path` (e.g. holes, multi-shape clips) should use this
144    /// instead of the flat-concatenation [`Path::flatten`].
145    ///
146    /// `tolerance` semantics match [`Path::flatten`]. Empty paths
147    /// return an empty `Vec`; paths with only a `MoveTo` return a
148    /// single one-element subpath.
149    #[must_use]
150    pub fn flatten_subpaths(&self, tolerance: f32) -> Vec<Vec<Vec2>> {
151        let mut subs: Vec<Vec<Vec2>> = Vec::new();
152        let mut current: Option<Vec<Vec2>> = None;
153        let mut subpath_start: Vec2 = Vec2::ZERO;
154        let mut last_point: Vec2 = Vec2::ZERO;
155
156        for cmd in &self.commands {
157            match *cmd {
158                PathCommand::MoveTo(p) => {
159                    if let Some(v) = current.take()
160                        && !v.is_empty()
161                    {
162                        subs.push(v);
163                    }
164                    current = Some(vec![p]);
165                    subpath_start = p;
166                    last_point = p;
167                }
168                PathCommand::LineTo(p) => {
169                    if let Some(v) = current.as_mut() {
170                        v.push(p);
171                    }
172                    last_point = p;
173                }
174                PathCommand::QuadTo { control, end } => {
175                    if let Some(v) = current.as_mut() {
176                        flatten_quad(last_point, control, end, tolerance, v);
177                    }
178                    last_point = end;
179                }
180                PathCommand::CubicTo { c1, c2, end } => {
181                    if let Some(v) = current.as_mut() {
182                        flatten_cubic(last_point, c1, c2, end, tolerance, v);
183                    }
184                    last_point = end;
185                }
186                PathCommand::Close => {
187                    if let Some(v) = current.as_mut() {
188                        v.push(subpath_start);
189                    }
190                    last_point = subpath_start;
191                }
192            }
193        }
194        if let Some(v) = current
195            && !v.is_empty()
196        {
197            subs.push(v);
198        }
199        subs
200    }
201
202    /// Flatten Beziers to line segments and return the resulting
203    /// polygon (a `Vec<Vec2>`). `tolerance` is the maximum
204    /// perpendicular distance from a control point to the chord
205    /// before the curve subdivides further. NDC units; `0.005` is a
206    /// reasonable default.
207    ///
208    /// Open subpaths (no `Close`) end at the last point. `Close`
209    /// adds a line back to the most recent `MoveTo`.
210    ///
211    /// For consumers that need to distinguish individual subpaths
212    /// (boolean ops, holes, multi-shape masks), use
213    /// [`Path::flatten_subpaths`].
214    #[must_use]
215    pub fn flatten(&self, tolerance: f32) -> Vec<Vec2> {
216        let mut out: Vec<Vec2> = Vec::new();
217        let mut current: Vec2 = Vec2::ZERO;
218        let mut subpath_start: Vec2 = Vec2::ZERO;
219
220        for cmd in &self.commands {
221            match *cmd {
222                PathCommand::MoveTo(p) => {
223                    out.push(p);
224                    current = p;
225                    subpath_start = p;
226                }
227                PathCommand::LineTo(p) => {
228                    out.push(p);
229                    current = p;
230                }
231                PathCommand::QuadTo { control, end } => {
232                    flatten_quad(current, control, end, tolerance, &mut out);
233                    current = end;
234                }
235                PathCommand::CubicTo { c1, c2, end } => {
236                    flatten_cubic(current, c1, c2, end, tolerance, &mut out);
237                    current = end;
238                }
239                PathCommand::Close => {
240                    out.push(subpath_start);
241                    current = subpath_start;
242                }
243            }
244        }
245        out
246    }
247
248    /// Rasterize this path as a stroked [`Graphics`] node. Each
249    /// flattened segment becomes a [`Graphics::draw_line`] call.
250    /// `width` is the stroke width in NDC units; `tolerance` controls
251    /// flatness of curves.
252    #[must_use]
253    pub fn stroke_to_graphics(&self, width: f32, color: Color, tolerance: f32) -> Graphics {
254        let pts = self.flatten(tolerance);
255        let mut g = Graphics::new();
256        if pts.len() < 2 {
257            return g;
258        }
259        // `draw_line` colors with the current fill (strokes apply to
260        // fillable primitives only). Set fill to the stroke color
261        // before emitting the segments.
262        g.fill(Fill::Solid(color));
263        for pair in pts.windows(2) {
264            g.draw_line(pair[0], pair[1], width);
265        }
266        g
267    }
268
269    /// Convert to a [`crate::scene::VectorShape::Path`] for use as
270    /// a mask via the path-mask machinery. Caller is responsible for
271    /// keeping the vertex count under `MAX_PATH_POINTS` (32 vertices —
272    /// see `path_clip.wgsl`). Anything beyond the cap is silently
273    /// truncated by the path-mask shader.
274    #[must_use]
275    pub fn to_mask_polygon(&self, tolerance: f32) -> Vec<Vec2> {
276        self.flatten(tolerance)
277    }
278}
279
280/// Adaptive flattening of a quadratic Bezier. Subdivides until the
281/// control point is within `tolerance` of the chord.
282fn flatten_quad(p0: Vec2, p1: Vec2, p2: Vec2, tolerance: f32, out: &mut Vec<Vec2>) {
283    if perp_distance(p1, p0, p2) <= tolerance {
284        out.push(p2);
285        return;
286    }
287    let p01 = (p0 + p1) * 0.5;
288    let p12 = (p1 + p2) * 0.5;
289    let mid = (p01 + p12) * 0.5;
290    flatten_quad(p0, p01, mid, tolerance, out);
291    flatten_quad(mid, p12, p2, tolerance, out);
292}
293
294/// Adaptive flattening of a cubic Bezier — same idea, subdivide
295/// until both control points are within `tolerance` of the chord.
296fn flatten_cubic(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, tolerance: f32, out: &mut Vec<Vec2>) {
297    let d1 = perp_distance(p1, p0, p3);
298    let d2 = perp_distance(p2, p0, p3);
299    if d1.max(d2) <= tolerance {
300        out.push(p3);
301        return;
302    }
303    let q01 = (p0 + p1) * 0.5;
304    let q12 = (p1 + p2) * 0.5;
305    let q23 = (p2 + p3) * 0.5;
306    let r012 = (q01 + q12) * 0.5;
307    let r123 = (q12 + q23) * 0.5;
308    let mid = (r012 + r123) * 0.5;
309    flatten_cubic(p0, q01, r012, mid, tolerance, out);
310    flatten_cubic(mid, r123, q23, p3, tolerance, out);
311}
312
313/// Perpendicular distance from `p` to the line through `a` and `b`.
314fn perp_distance(p: Vec2, a: Vec2, b: Vec2) -> f32 {
315    let ab = b - a;
316    let len = ab.length();
317    if len < f32::EPSILON {
318        return (p - a).length();
319    }
320    // |(p - a) × (b - a)| / |b - a|, in 2D the cross is a scalar.
321    let cross = (p.x - a.x) * ab.y - (p.y - a.y) * ab.x;
322    cross.abs() / len
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    #[test]
330    fn line_only_path_produces_control_points() {
331        let path = PathBuilder::new()
332            .move_to(Vec2::new(0.0, 0.0))
333            .line_to(Vec2::new(1.0, 0.0))
334            .line_to(Vec2::new(1.0, 1.0))
335            .build();
336        let pts = path.flatten(0.01);
337        assert_eq!(pts.len(), 3);
338        assert!((pts[2].y - 1.0).abs() < f32::EPSILON);
339    }
340
341    #[test]
342    fn close_appends_back_to_start() {
343        let path = PathBuilder::new()
344            .move_to(Vec2::new(0.0, 0.0))
345            .line_to(Vec2::new(0.5, 0.0))
346            .line_to(Vec2::new(0.5, 0.5))
347            .close()
348            .build();
349        let pts = path.flatten(0.01);
350        // 3 explicit points + close → start = 4 points.
351        assert_eq!(pts.len(), 4);
352        assert!((pts[3].x - 0.0).abs() < f32::EPSILON);
353        assert!((pts[3].y - 0.0).abs() < f32::EPSILON);
354    }
355
356    #[test]
357    fn quad_subdivides_when_control_off_chord() {
358        // Quad with control well off the chord — should produce
359        // multiple subdivision points.
360        let path = PathBuilder::new()
361            .move_to(Vec2::new(0.0, 0.0))
362            .quad_to(Vec2::new(0.5, 1.0), Vec2::new(1.0, 0.0))
363            .build();
364        let pts = path.flatten(0.01);
365        assert!(
366            pts.len() > 2,
367            "off-chord quad should subdivide, got {} pts",
368            pts.len()
369        );
370    }
371
372    #[test]
373    fn cubic_with_zero_curvature_emits_two_points() {
374        // Cubic where all control points are on the chord — flat,
375        // no subdivision.
376        let path = PathBuilder::new()
377            .move_to(Vec2::new(0.0, 0.0))
378            .cubic_to(
379                Vec2::new(0.33, 0.0),
380                Vec2::new(0.66, 0.0),
381                Vec2::new(1.0, 0.0),
382            )
383            .build();
384        let pts = path.flatten(0.01);
385        assert_eq!(pts.len(), 2, "flat cubic should not subdivide");
386    }
387
388    #[test]
389    fn perp_distance_computes_correctly() {
390        let d = perp_distance(Vec2::new(0.0, 1.0), Vec2::ZERO, Vec2::new(1.0, 0.0));
391        // Distance from (0,1) to the x-axis = 1.0.
392        assert!((d - 1.0).abs() < 1e-3);
393    }
394
395    #[test]
396    fn flatten_subpaths_separates_two_movetos() {
397        let path = PathBuilder::new()
398            .move_to(Vec2::new(0.0, 0.0))
399            .line_to(Vec2::new(0.5, 0.0))
400            .line_to(Vec2::new(0.5, 0.5))
401            .close()
402            .move_to(Vec2::new(1.0, 1.0))
403            .line_to(Vec2::new(1.5, 1.0))
404            .line_to(Vec2::new(1.5, 1.5))
405            .close()
406            .build();
407        let subs = path.flatten_subpaths(0.01);
408        assert_eq!(subs.len(), 2, "two MoveTos → two subpaths");
409        // Each subpath has 4 points (3 explicit + close-back-to-start).
410        assert_eq!(subs[0].len(), 4);
411        assert_eq!(subs[1].len(), 4);
412        // First subpath stays in its own bucket — no point from the
413        // second subpath leaked in.
414        assert!(subs[0].iter().all(|p| p.x < 0.6 && p.y < 0.6));
415        assert!(subs[1].iter().all(|p| p.x > 0.9 && p.y > 0.9));
416    }
417
418    #[test]
419    fn flatten_subpaths_preserves_bezier_curvature_per_subpath() {
420        let path = PathBuilder::new()
421            .move_to(Vec2::new(0.0, 0.0))
422            .quad_to(Vec2::new(0.5, 1.0), Vec2::new(1.0, 0.0))
423            .close()
424            .move_to(Vec2::new(2.0, 0.0))
425            .line_to(Vec2::new(3.0, 0.0))
426            .close()
427            .build();
428        let subs = path.flatten_subpaths(0.01);
429        assert_eq!(subs.len(), 2);
430        // First subpath subdivides the quad → > 3 points.
431        assert!(
432            subs[0].len() > 3,
433            "quad subpath should subdivide, got {} pts",
434            subs[0].len()
435        );
436        // Second subpath is just two lines → 3 points (2 explicit +
437        // close back to start).
438        assert_eq!(subs[1].len(), 3);
439    }
440
441    #[test]
442    fn flatten_subpaths_empty_path_is_empty() {
443        let path = Path::from_commands(Vec::new());
444        assert!(path.flatten_subpaths(0.01).is_empty());
445    }
446
447    #[test]
448    fn stroke_to_graphics_emits_line_per_segment() {
449        let path = PathBuilder::new()
450            .move_to(Vec2::new(0.0, 0.0))
451            .line_to(Vec2::new(1.0, 0.0))
452            .line_to(Vec2::new(1.0, 1.0))
453            .build();
454        let g = path.stroke_to_graphics(0.02, Color::WHITE, 0.01);
455        // 3 points → 2 segments → 2 lines.
456        assert_eq!(g.primitives.len(), 2);
457    }
458}