Skip to main content

motion_core/
path2d.rs

1//! Double-precision path values, with copy-on-write geometry.
2
3use std::sync::{Arc, Mutex, OnceLock};
4
5use kurbo::Shape as _;
6
7use crate::{Pivot, Point, Rect, Transform, ValidationError, Vector2};
8
9mod construction;
10mod editing;
11mod metrics;
12mod morph;
13#[cfg(test)]
14mod tests;
15mod validation;
16
17pub use morph::MorphPath;
18
19/// Maximum stored verbs in one path; mutations beyond it fail atomically.
20pub const MAX_PATH_VERBS: usize = 16_384;
21
22/// Maximum UTF-8 bytes accepted by one SVG path-data import.
23pub const MAX_SVG_PATH_BYTES: usize = 1_048_576;
24
25/// An absolute logical-coordinate path operation.
26#[derive(Clone, Copy, Debug, PartialEq)]
27pub enum PathVerb {
28    /// Starts a subpath at a point without joining the previous subpath.
29    MoveTo(Point),
30    /// Adds a line from the current point.
31    LineTo(Point),
32    /// Adds a quadratic Bezier with one control and an endpoint.
33    QuadraticTo(Point, Point),
34    /// Adds a cubic Bezier with two controls and an endpoint.
35    CubicTo(Point, Point, Point),
36    /// Closes to the subpath's first point; subsequent segments resume there.
37    Close,
38}
39
40impl PathVerb {
41    /// Returns endpoint/control coordinates for validation without flattening this verb.
42    /// Unused entries are `None`; Close has no coordinates.
43    pub fn points(self) -> [Option<Point>; 3] {
44        match self {
45            Self::MoveTo(point) | Self::LineTo(point) => [Some(point), None, None],
46            Self::QuadraticTo(control, end) => [Some(control), Some(end), None],
47            Self::CubicTo(first, second, end) => [Some(first), Some(second), Some(end)],
48            Self::Close => [None, None, None],
49        }
50    }
51}
52
53/// A cheaply cloned, checked f64 path with lazily cached geometric bounds.
54///
55/// Chainable mutations retain their first diagnostic for compile-time validation.
56/// The `try_` methods return failures immediately and leave the snapshot unchanged.
57/// Bounds include move positions and curve extrema, not the curve control hull.
58/// Empty paths have zero bounds at the origin. SVG path data imports through
59/// [`Self::parse_svg`]. Arc lengths use cached bounded Gaussian integration;
60/// partial curves retain their Bezier representation rather than flattening.
61/// [`crate::Shape::stroke`] expands bounded curves into fill outlines;
62/// Styled trim/dash share the same arc-length parameterization.
63///
64/// ```
65/// use motion_core::prelude::*;
66/// use motion_core::{
67///     DisplayList, RasterLimits, ResourcePool, ValidatedDisplayList, MAX_PATH_VERBS,
68///     MAX_STROKE_VERBS, MAX_SVG_PATH_BYTES, sample_offsets,
69/// };
70/// let mut path = Path2D::new();
71/// path.move_to(Point::ORIGIN)
72///     .quadratic_to(Point::new(10.0, 20.0), Point::new(20.0, 0.0))
73///     .close();
74/// assert_eq!(path.bounding_box(), Rect::new(0.0, 0.0, 20.0, 10.0));
75/// let node = path
76///     .fill(Color::from_hex(0x24a49b))
77///     .fill_rule(FillRule::EvenOdd)
78///     .at(Point::new(100.0, 80.0));
79/// assert_eq!(
80///     node.measure(ValidationLimits::default())?,
81///     Rect::new(90.0, 75.0, 110.0, 85.0)
82/// );
83/// let list = node.compile(
84///     Size::new(200.0, 160.0),
85///     PixelSize::new(200, 160),
86///     ValidationLimits::default(),
87/// )?;
88/// assert_eq!(list.commands.len(), 1);
89/// assert!(matches!(
90///     list.commands[0],
91///     DrawCommand::Fill {
92///         rule: FillRule::EvenOdd,
93///         ..
94///     }
95/// ));
96/// # Ok::<(), motion_core::ValidationError>(())
97/// ```
98#[derive(Clone, Debug, Default)]
99pub struct Path2D {
100    data: Arc<PathData>,
101}
102
103type StrokeCache = Arc<Mutex<Vec<([u64; 4], PathData)>>>;
104
105#[derive(Clone, Debug, Default)]
106struct PathData {
107    error: Option<ValidationError>,
108    verbs: Arc<Vec<PathVerb>>,
109    hull: Option<Rect>,
110    current: Option<Point>,
111    start: Option<Point>,
112    bounds: OnceLock<Rect>,
113    fingerprint: OnceLock<u64>,
114    metrics: OnceLock<Result<Arc<metrics::Metrics>, ValidationError>>,
115    strokes: OnceLock<StrokeCache>,
116}
117
118impl PartialEq for Path2D {
119    fn eq(&self, other: &Self) -> bool {
120        self.verbs() == other.verbs() && self.data.error == other.data.error
121    }
122}
123
124impl Path2D {
125    /// Creates an empty chainable path without paint.
126    pub fn new() -> Self {
127        Self::default()
128    }
129
130    /// Returns the immutable absolute f64 verbs.
131    pub fn verbs(&self) -> &[PathVerb] {
132        &self.data.verbs
133    }
134
135    /// Returns cached f64 geometric bounds, retaining zero-area geometry.
136    pub fn bounding_box(&self) -> Rect {
137        *self.data.bounds.get_or_init(|| self.compute_bounds())
138    }
139
140    /// Maps all endpoints and controls without flattening or modifying this path.
141    pub fn transformed(&self, transform: &Transform) -> Result<Self, ValidationError> {
142        self.validate()?;
143        if !transform.is_finite() {
144            return Err(invalid("transform", "Path transform must be finite"));
145        }
146        let mut result = Self::new();
147        for verb in self.verbs() {
148            let mapped = match *verb {
149                PathVerb::MoveTo(point) => PathVerb::MoveTo(transform.apply(point)),
150                PathVerb::LineTo(point) => PathVerb::LineTo(transform.apply(point)),
151                PathVerb::QuadraticTo(control, end) => {
152                    PathVerb::QuadraticTo(transform.apply(control), transform.apply(end))
153                }
154                PathVerb::CubicTo(first, second, end) => PathVerb::CubicTo(
155                    transform.apply(first),
156                    transform.apply(second),
157                    transform.apply(end),
158                ),
159                PathVerb::Close => PathVerb::Close,
160            };
161            result.push(mapped)?;
162        }
163        Ok(result)
164    }
165
166    /// Shares only the immutable verb snapshot, without retaining metric caches.
167    /// Arc::make_mut on a returned handle cannot edit this path's snapshot.
168    pub fn shared_verbs(&self) -> Arc<Vec<PathVerb>> {
169        self.data.verbs.clone()
170    }
171
172    /// Bounds of a single nondegenerate four-edge axis-aligned closed contour.
173    /// Curves, extra contours, retraced edges and nonrectangular paths return None.
174    pub fn axis_aligned_rectangle(&self) -> Option<Rect> {
175        if self.data.error.is_some() {
176            return None;
177        }
178        let [
179            PathVerb::MoveTo(first),
180            PathVerb::LineTo(second),
181            PathVerb::LineTo(third),
182            PathVerb::LineTo(fourth),
183            PathVerb::Close,
184        ] = self.verbs()
185        else {
186            return None;
187        };
188        let horizontal = first.y == second.y
189            && second.x == third.x
190            && third.y == fourth.y
191            && fourth.x == first.x;
192        let vertical = first.x == second.x
193            && second.y == third.y
194            && third.x == fourth.x
195            && fourth.y == first.y;
196        if !(horizontal || vertical) || first.x == third.x || first.y == third.y {
197            return None;
198        }
199        Some(Rect::new(
200            first.x.min(third.x),
201            first.y.min(third.y),
202            first.x.max(third.x),
203            first.y.max(third.y),
204        ))
205    }
206
207    /// Stable content fingerprint shared by clones and invalidated by successful edits.
208    /// This is a lookup hint, not a collision-free geometry identity.
209    pub fn fingerprint(&self) -> u64 {
210        *self.data.fingerprint.get_or_init(|| {
211            let mut value = 14695981039346656037_u64;
212            let mut write = |bytes: &[u8]| {
213                for byte in bytes {
214                    value = (value ^ u64::from(*byte)).wrapping_mul(1099511628211);
215                }
216            };
217            write(&(self.verbs().len() as u64).to_le_bytes());
218            for verb in self.verbs() {
219                write(&[match verb {
220                    PathVerb::MoveTo(_) => 0,
221                    PathVerb::LineTo(_) => 1,
222                    PathVerb::QuadraticTo(..) => 2,
223                    PathVerb::CubicTo(..) => 3,
224                    PathVerb::Close => 4,
225                }]);
226                for point in verb.points().into_iter().flatten() {
227                    write(&point.x.to_bits().to_le_bytes());
228                    write(&point.y.to_bits().to_le_bytes());
229                }
230            }
231            value
232        })
233    }
234
235    fn compute_bounds(&self) -> Rect {
236        let Some(hull) = self.data.hull else {
237            return Rect::default();
238        };
239        let normalize = |point: Point| {
240            (
241                if hull.width() == 0.0 {
242                    0.0
243                } else {
244                    (point.x - hull.left) / hull.width()
245                },
246                if hull.height() == 0.0 {
247                    0.0
248                } else {
249                    (point.y - hull.top) / hull.height()
250                },
251            )
252        };
253        let mut path = kurbo::BezPath::new();
254        for verb in self.verbs() {
255            match *verb {
256                PathVerb::MoveTo(point) => path.move_to(normalize(point)),
257                PathVerb::LineTo(point) => path.line_to(normalize(point)),
258                PathVerb::QuadraticTo(control, end) => {
259                    path.quad_to(normalize(control), normalize(end))
260                }
261                PathVerb::CubicTo(first, second, end) => {
262                    path.curve_to(normalize(first), normalize(second), normalize(end))
263                }
264                PathVerb::Close => path.close_path(),
265            }
266        }
267        let bounds = path.bounding_box();
268        if ![bounds.x0, bounds.y0, bounds.x1, bounds.y1]
269            .iter()
270            .all(|value| value.is_finite())
271        {
272            return hull;
273        }
274        let first = hull.anchor(Pivot::Relative(
275            bounds.x0.clamp(0.0, 1.0),
276            bounds.y0.clamp(0.0, 1.0),
277        ));
278        let last = hull.anchor(Pivot::Relative(
279            bounds.x1.clamp(0.0, 1.0),
280            bounds.y1.clamp(0.0, 1.0),
281        ));
282        let mut result = Rect::new(first.x, first.y, last.x, last.y);
283        for verb in self.verbs() {
284            if let PathVerb::MoveTo(point) = verb {
285                result = Rect::new(
286                    result.left.min(point.x),
287                    result.top.min(point.y),
288                    result.right.max(point.x),
289                    result.bottom.max(point.y),
290                );
291            }
292        }
293        result
294    }
295
296    pub(crate) fn cached_stroke(&self, key: [u64; 4]) -> Option<Path2D> {
297        let mut entries = self.data.strokes.get()?.lock().ok()?;
298        let index = entries.iter().position(|entry| entry.0 == key)?;
299        let entry = entries.remove(index);
300        let result = Self {
301            data: Arc::new(entry.1.clone()),
302        };
303        entries.push(entry);
304        Some(result)
305    }
306
307    pub(crate) fn retain_stroke(&self, key: [u64; 4], outline: &Path2D) {
308        let bytes = |data: &PathData| {
309            data.verbs.len() * std::mem::size_of::<PathVerb>()
310                + std::mem::size_of::<([u64; 4], PathData)>()
311        };
312        let cost = bytes(&outline.data);
313        if cost > 1024 * 1024 {
314            return;
315        }
316        outline.bounding_box();
317        outline.fingerprint();
318        let mut snapshot = (*outline.data).clone();
319        snapshot.metrics.take();
320        snapshot.strokes.take();
321        let Ok(mut entries) = self
322            .data
323            .strokes
324            .get_or_init(|| Arc::new(Mutex::new(Vec::new())))
325            .lock()
326        else {
327            return;
328        };
329        if entries.iter().any(|entry| entry.0 == key) {
330            return;
331        }
332        while !entries.is_empty()
333            && (entries.len() >= 4
334                || entries.iter().map(|entry| bytes(&entry.1)).sum::<usize>() + cost > 1024 * 1024)
335        {
336            entries.remove(0);
337        }
338        if entries.try_reserve(1).is_ok() {
339            entries.push((key, snapshot));
340        }
341    }
342}
343
344fn invalid(field: &str, message: &str) -> ValidationError {
345    ValidationError::invalid("path2d", field, message)
346}