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