Skip to main content

motion_core/drawing/
stroke.rs

1//! Stroke styles and expansion of stroked paths into fill geometry.
2
3use crate::{Path2D, PathVerb, Point, Rect, ValidationError};
4
5/// Maximum input verbs in one path/curve stroke expansion.
6pub const MAX_STROKE_VERBS: usize = 256;
7
8/// Shape of the ends of an open stroke.
9#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
10pub enum LineCap {
11    /// End at the endpoint without an extension.
12    #[default]
13    Butt,
14    /// Add a semicircular end with radius half the width.
15    Round,
16    /// Extend the end by half the width.
17    Square,
18}
19
20/// Connection between adjacent stroke segments.
21#[derive(Clone, Copy, Debug, PartialEq)]
22pub enum LineJoin {
23    /// Extend edges to their intersection, beveling beyond the radius ratio.
24    Miter {
25        /// Finite miter-length to half-width ratio in 1..=1024.
26        limit: f64,
27    },
28    /// Connect outer edges with a circular arc.
29    Round,
30    /// Connect outer edges with a straight segment.
31    Bevel,
32}
33
34/// logical-unit stroke style, with explicit caps, joins and dash geometry.
35///
36/// Defaults to width one, butt caps and a miter join with limit four. Width must
37/// be finite and nonnegative; zero emits no outline. This bounded first slice
38/// accepts paths of at most MAX_STROKE_VERBS verbs. Curves expand directly into
39/// fill outlines; Styled trim/dash use exact source-curve splitting first.
40/// Nonzero widths and segment lengths require 1e-30..=1e30; coordinates require
41/// absolute magnitude at most 1e30, and the width must exceed local rounding noise.
42/// Round outlines use kurbo's cubic circular approximation, not exact circles.
43/// Move-only and zero-length subpaths emit no dot, including with round caps.
44///
45/// ```
46/// use motion_core::prelude::*;
47/// use motion_core::{
48///     DisplayList, RasterLimits, ResourcePool, ValidatedDisplayList, MAX_PATH_VERBS,
49///     MAX_STROKE_VERBS, MAX_SVG_PATH_BYTES, sample_offsets,
50/// };
51/// let outline = Rect::new(1.0, 1.0, 7.0, 7.0)
52///     .fill(Color::WHITE)
53///     .stroke(2.0, Color::BLACK)
54///     .stroke_style(StrokeStyle {
55///         width: 2.0,
56///         cap: LineCap::Round,
57///         join: LineJoin::Round,
58///     });
59/// let list = Node::from(outline).opacity(0.5).compile(
60///     Size::new(8.0, 8.0),
61///     PixelSize::new(8, 8),
62///     ValidationLimits::default(),
63/// )?;
64/// let frame = list;
65/// # Ok::<(), motion_core::ValidationError>(())
66/// ```
67#[derive(Clone, Copy, Debug, PartialEq)]
68pub struct StrokeStyle {
69    /// Full logical width; zero preserves layout but draws no stroke.
70    pub width: f64,
71    /// The same cap for both ends of each open subpath.
72    pub cap: LineCap,
73    /// Connection at each nondegenerate corner, including closed seams.
74    pub join: LineJoin,
75}
76
77impl Default for StrokeStyle {
78    fn default() -> Self {
79        Self {
80            width: 1.0,
81            cap: LineCap::Butt,
82            join: LineJoin::Miter { limit: 4.0 },
83        }
84    }
85}
86
87impl StrokeStyle {
88    /// Validates the finite width and supported miter limit.
89    pub fn validate(&self, path: &str) -> Result<(), ValidationError> {
90        if !self.width.is_finite() || self.width < 0.0 {
91            return Err(ValidationError::invalid(
92                path,
93                "width",
94                "Stroke width must be finite and nonnegative",
95            ));
96        }
97        if let LineJoin::Miter { limit } = self.join
98            && (!limit.is_finite() || !(1.0..=1024.0).contains(&limit))
99        {
100            return Err(ValidationError::invalid(
101                path,
102                "miter_limit",
103                "Require a finite miter limit in 1..=1024",
104            ));
105        }
106        Ok(())
107    }
108
109    pub(crate) fn bounds(&self, bounds: Rect) -> Rect {
110        let join = match self.join {
111            LineJoin::Miter { limit } => limit,
112            _ => 1.0,
113        };
114        let cap = if self.cap == LineCap::Square {
115            std::f64::consts::SQRT_2
116        } else {
117            1.0
118        };
119        let margin = self.width * 0.5 * join.max(cap);
120        Rect::new(
121            bounds.left - margin,
122            bounds.top - margin,
123            bounds.right + margin,
124            bounds.bottom + margin,
125        )
126    }
127
128    /// Expands checked centerline geometry into a bounded filled outline.
129    pub fn outline(&self, source: &Path2D, path: &str) -> Result<Path2D, ValidationError> {
130        self.validate(path)?;
131        source.validate()?;
132        if source.verbs().len() > MAX_STROKE_VERBS {
133            return Err(ValidationError::limit(
134                path,
135                "stroke_verbs",
136                "stroke input exceeds 256 verbs",
137            ));
138        }
139        let (join, limit) = match self.join {
140            LineJoin::Miter { limit } => (0, limit.to_bits()),
141            LineJoin::Round => (1, 0),
142            LineJoin::Bevel => (2, 0),
143        };
144        let key = [self.width.to_bits(), self.cap as u64, join, limit];
145        if let Some(outline) = source.cached_stroke(key) {
146            return Ok(outline);
147        }
148        let unsupported = |message| ValidationError::unsupported(path, "stroke_geometry", message);
149        let mut current = None;
150        let mut start = None;
151        let mut scale = self.width;
152        let mut has_length = false;
153        for verb in source.verbs() {
154            for point in verb.points().into_iter().flatten() {
155                scale = scale.max(point.x.abs()).max(point.y.abs());
156                if point.x.abs() > 1e30 || point.y.abs() > 1e30 {
157                    return Err(unsupported(
158                        "Stroke coordinates exceed the supported numerical range",
159                    ));
160                }
161            }
162            let endpoint = match *verb {
163                PathVerb::MoveTo(point) => {
164                    start = Some(point);
165                    current = None;
166                    point
167                }
168                PathVerb::LineTo(point) => point,
169                PathVerb::Close => start.expect("checked subpath"),
170                PathVerb::QuadraticTo(_, end) | PathVerb::CubicTo(_, _, end) => end,
171            };
172            scale = scale.max(endpoint.x.abs()).max(endpoint.y.abs());
173            if endpoint.x.abs() > 1e30 || endpoint.y.abs() > 1e30 {
174                return Err(unsupported(
175                    "Stroke coordinates exceed the supported numerical range",
176                ));
177            }
178            if let Some(previous) = current {
179                let previous: Point = previous;
180                has_length |= endpoint != previous
181                    || verb
182                        .points()
183                        .into_iter()
184                        .flatten()
185                        .any(|point| point != previous);
186                let length = libm::hypot(endpoint.x - previous.x, endpoint.y - previous.y);
187                if length != 0.0 && !(1e-30..=1e30).contains(&length) {
188                    return Err(unsupported(
189                        "Stroke segment length exceeds the supported numerical range",
190                    ));
191                }
192            }
193            current = Some(endpoint);
194        }
195        if self.width == 0.0 {
196            return Ok(Path2D::new());
197        }
198        if !(1e-30..=1e30).contains(&self.width) || self.width < scale * (128.0 * f64::EPSILON) {
199            return Err(unsupported(
200                "Stroke width exceeds the supported numerical range or local precision",
201            ));
202        }
203        if !has_length {
204            return Ok(Path2D::new());
205        }
206        let cap = match self.cap {
207            LineCap::Butt => kurbo::Cap::Butt,
208            LineCap::Round => kurbo::Cap::Round,
209            LineCap::Square => kurbo::Cap::Square,
210        };
211        let (join, limit) = match self.join {
212            LineJoin::Miter { limit } => (kurbo::Join::Miter, limit),
213            LineJoin::Round => (kurbo::Join::Round, 4.0),
214            LineJoin::Bevel => (kurbo::Join::Bevel, 4.0),
215        };
216        let style = kurbo::Stroke::new(self.width)
217            .with_caps(cap)
218            .with_join(join)
219            .with_miter_limit(limit);
220        let point = |point: Point| kurbo::Point::new(point.x, point.y);
221        let mut current = Point::ORIGIN;
222        let mut start = current;
223        let elements = source.verbs().iter().filter_map(|verb| {
224            Some(match *verb {
225                PathVerb::MoveTo(value) => {
226                    current = value;
227                    start = value;
228                    kurbo::PathEl::MoveTo(point(value))
229                }
230                PathVerb::LineTo(value) => {
231                    if value == current {
232                        return None;
233                    }
234                    current = value;
235                    kurbo::PathEl::LineTo(point(value))
236                }
237                PathVerb::QuadraticTo(control, end) => {
238                    if control == current && end == current {
239                        return None;
240                    }
241                    current = end;
242                    kurbo::PathEl::QuadTo(point(control), point(end))
243                }
244                PathVerb::CubicTo(first, second, end) => {
245                    if first == current && second == current && end == current {
246                        return None;
247                    }
248                    current = end;
249                    kurbo::PathEl::CurveTo(point(first), point(second), point(end))
250                }
251                PathVerb::Close => {
252                    current = start;
253                    kurbo::PathEl::ClosePath
254                }
255            })
256        });
257        let tolerance = 1e-3_f64
258            .min(self.width * 1e-3)
259            .max(scale * (128.0 * f64::EPSILON));
260        let expanded = kurbo::stroke(elements, &style, &kurbo::StrokeOpts::default(), tolerance);
261        let mut outline = Path2D::new();
262        let point = |point: kurbo::Point| Point::new(point.x, point.y);
263        for element in expanded.elements() {
264            match *element {
265                kurbo::PathEl::MoveTo(value) => outline.try_move_to(point(value))?,
266                kurbo::PathEl::LineTo(value) => outline.try_line_to(point(value))?,
267                kurbo::PathEl::QuadTo(control, end) => {
268                    outline.try_quadratic_to(point(control), point(end))?
269                }
270                kurbo::PathEl::CurveTo(first, second, end) => {
271                    outline.try_cubic_to(point(first), point(second), point(end))?
272                }
273                kurbo::PathEl::ClosePath => outline.try_close()?,
274            }
275        }
276        source.retain_stroke(key, &outline);
277        Ok(outline)
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use crate::ValidationErrorKind;
285
286    #[test]
287    fn static_stroke_cache_reuses_verbs_and_invalidates_only_edited_snapshot()
288    -> Result<(), ValidationError> {
289        use std::sync::Arc;
290        let mut source = Path2D::parse_svg("M0 0C5 12 15 -4 20 4")?;
291        let style = StrokeStyle {
292            width: 2.0,
293            cap: LineCap::Round,
294            join: LineJoin::Round,
295        };
296        let first = style.outline(&source, "first")?;
297        let again = style.outline(&source.clone(), "again")?;
298        assert!(Arc::ptr_eq(&first.shared_verbs(), &again.shared_verbs()));
299        let original = source.clone();
300        assert!(source.try_line_to(Point::new(f64::NAN, 1.0)).is_err());
301        assert!(Arc::ptr_eq(
302            &first.shared_verbs(),
303            &style.outline(&source, "failed edit")?.shared_verbs()
304        ));
305        source.try_line_to(Point::new(25.0, 3.0))?;
306        let edited = style.outline(&source, "edited")?;
307        assert_ne!(first, edited);
308        assert!(Arc::ptr_eq(
309            &first.shared_verbs(),
310            &style.outline(&original, "old snapshot")?.shared_verbs()
311        ));
312        let different = StrokeStyle {
313            width: 3.0,
314            ..style
315        }
316        .outline(&original, "style")?;
317        assert_ne!(first, different);
318        for width in 4..12 {
319            StrokeStyle {
320                width: f64::from(width),
321                ..style
322            }
323            .outline(&original, "evict")?;
324        }
325        assert_eq!(style.outline(&original, "retry")?, first);
326        assert!(
327            StrokeStyle {
328                width: f64::NAN,
329                ..style
330            }
331            .outline(&original, "invalid")
332            .is_err()
333        );
334        Ok(())
335    }
336
337    use crate::{
338        Canvas, Color, DrawCommand, DrawContext, Drawable, Node, NodeModifiers, PixelSize, Shape,
339        Size, ValidationLimits,
340    };
341
342    fn line() -> Path2D {
343        let mut path = Path2D::new();
344        path.try_move_to(Point::new(1.0, 1.0)).unwrap();
345        path.try_line_to(Point::new(5.0, 1.0)).unwrap();
346        path
347    }
348
349    #[test]
350    fn source_and_outline_charge_exact_shared_structural_limits() {
351        let drawable = line().stroke(2.0, Color::from_rgb_float(2.0, -0.1, 0.5));
352        let recorded = DrawContext::default()
353            .record(&drawable, ValidationLimits::default())
354            .unwrap();
355        let DrawCommand::Fill { path: geometry, .. } = &recorded[0] else {
356            panic!("expected fill")
357        };
358        let limits = ValidationLimits {
359            max_nodes: 1 + line().verbs().len() + 1 + geometry.verbs().len(),
360            max_depth: 2,
361        };
362        assert_eq!(
363            DrawContext::default().record(&drawable, limits).unwrap(),
364            recorded
365        );
366        let error = DrawContext::default()
367            .record(
368                &drawable,
369                ValidationLimits {
370                    max_nodes: limits.max_nodes - 1,
371                    ..limits
372                },
373            )
374            .unwrap_err();
375        assert_eq!(error.field, "max_nodes");
376        let mut source = Path2D::new();
377        source.try_move_to(Point::ORIGIN).unwrap();
378        for index in 1..MAX_STROKE_VERBS {
379            source.try_line_to(Point::new(index as f64, 0.0)).unwrap();
380        }
381        let style = StrokeStyle::default();
382        assert!(style.outline(&source, "test").is_ok());
383        let retained = source.clone();
384        source.try_line_to(Point::new(256.0, 0.0)).unwrap();
385        assert_eq!(
386            style.outline(&source, "test").unwrap_err().kind,
387            ValidationErrorKind::StructuralLimit
388        );
389        assert_eq!(retained.verbs().len(), MAX_STROKE_VERBS);
390    }
391
392    #[test]
393    fn zero_width_and_degenerate_subpaths_are_empty_but_invalid_input_is_not_skipped() {
394        for width in [0.0, 1.0] {
395            let mut source = Path2D::new();
396            source.try_move_to(Point::ORIGIN).unwrap();
397            source.try_line_to(Point::ORIGIN).unwrap();
398            source.try_close().unwrap();
399            assert!(
400                StrokeStyle {
401                    width,
402                    cap: LineCap::Round,
403                    ..StrokeStyle::default()
404                }
405                .outline(&source, "test")
406                .unwrap()
407                .verbs()
408                .is_empty()
409            );
410        }
411        assert!(
412            DrawContext::default()
413                .record(
414                    &line().stroke(0.0, Color::WHITE),
415                    ValidationLimits::default()
416                )
417                .unwrap()
418                .is_empty()
419        );
420        let invalid = Rect::new(4.0, 0.0, 2.0, 2.0).stroke(10.0, Color::WHITE);
421        assert!(
422            Node::from(invalid)
423                .measure(ValidationLimits::default())
424                .is_err()
425        );
426        for width in [-1.0, f64::NAN, f64::INFINITY] {
427            assert_eq!(
428                StrokeStyle {
429                    width,
430                    ..StrokeStyle::default()
431                }
432                .outline(&line(), "test")
433                .unwrap_err()
434                .kind,
435                ValidationErrorKind::InvalidValue
436            );
437        }
438        for limit in [0.0, 1025.0, f64::NAN, f64::INFINITY] {
439            assert!(
440                StrokeStyle {
441                    width: 0.0,
442                    join: LineJoin::Miter { limit },
443                    ..StrokeStyle::default()
444                }
445                .outline(&line(), "test")
446                .is_err()
447            );
448        }
449        let mut curve = line();
450        curve
451            .try_quadratic_to(Point::new(6.0, 0.0), Point::new(7.0, 1.0))
452            .unwrap();
453        for width in [0.0, 2.0] {
454            let node = Node::from(curve.clone().stroke(width, Color::WHITE)).opacity(0.0);
455            assert!(
456                node.compile(
457                    Size::new(8.0, 8.0),
458                    PixelSize::new(8, 8),
459                    ValidationLimits::default()
460                )
461                .is_ok()
462            );
463        }
464    }
465
466    #[test]
467    fn ignored_canvas_stroke_failures_poison_the_whole_recording() {
468        struct Ignores;
469
470        impl Drawable for Ignores {
471            fn bounds(&self, _: &DrawContext) -> Rect {
472                Rect::new(0.0, 0.0, 8.0, 8.0)
473            }
474
475            fn draw(
476                &self,
477                _: &DrawContext,
478                canvas: &mut Canvas<'_>,
479            ) -> Result<(), ValidationError> {
480                let _ = canvas.stroke(
481                    &line(),
482                    &StrokeStyle {
483                        width: -1.0,
484                        ..StrokeStyle::default()
485                    },
486                    Color::WHITE,
487                );
488                let _ = canvas.fill_rect(Rect::new(0.0, 0.0, 1.0, 1.0), Color::WHITE);
489                Ok(())
490            }
491        }
492        assert_eq!(
493            DrawContext::default()
494                .record(&Ignores, ValidationLimits::default())
495                .unwrap_err()
496                .field,
497            "width"
498        );
499    }
500
501    #[test]
502    fn close_resumption_and_multiple_subpaths_keep_their_own_origins() {
503        let mut source = Path2D::from_rect(Rect::new(1.0, 1.0, 3.0, 3.0)).unwrap();
504        source.try_line_to(Point::new(6.0, 1.0)).unwrap();
505        let mut explicit = Path2D::from_rect(Rect::new(1.0, 1.0, 3.0, 3.0)).unwrap();
506        explicit.try_move_to(Point::new(1.0, 1.0)).unwrap();
507        explicit.try_line_to(Point::new(6.0, 1.0)).unwrap();
508        let style = StrokeStyle::default();
509        assert_eq!(
510            style.outline(&source, "test").unwrap(),
511            style.outline(&explicit, "test").unwrap()
512        );
513    }
514
515    #[test]
516    fn unsupported_numeric_ranges_fail_before_expansion_and_leave_source_reusable() {
517        let style = StrokeStyle::default();
518        for (start, end) in [
519            (Point::ORIGIN, Point::new(1e-31, 0.0)),
520            (Point::new(1e31, 0.0), Point::new(1e31, 1.0)),
521            (Point::new(1e20, 0.0), Point::new(1e20, 1.0)),
522        ] {
523            let mut source = Path2D::new();
524            source.try_move_to(start).unwrap();
525            source.try_line_to(end).unwrap();
526            let snapshot = source.clone();
527            assert_eq!(
528                style.outline(&source, "test").unwrap_err().kind,
529                ValidationErrorKind::Unsupported
530            );
531            assert_eq!(source, snapshot);
532        }
533        for width in [1e-31, 1e31] {
534            assert_eq!(
535                StrokeStyle { width, ..style }
536                    .outline(&line(), "test")
537                    .unwrap_err()
538                    .kind,
539                ValidationErrorKind::Unsupported
540            );
541        }
542        assert!(style.outline(&line(), "test").is_ok());
543    }
544}