Skip to main content

alpha2_model/
shapes.rs

1//! The content tree of a shapes generator: geometry, paints and modifiers in the order an
2//! After Effects or Lottie shape layer lists them.
3//!
4//! Positions and sizes are normalized to the generator's picture, `(0, 0)` top-left to
5//! `(1, 1)` bottom-right; lengths (radii, stroke widths, dashes) are fractions of the
6//! picture's shorter side. Rotation and scale act in pixel space, so a rotated square stays
7//! square on a wide canvas.
8
9use crate::animation::{opaque, unit_scale, valid_keyframes, visit_keyed, visit_keyed_point};
10use crate::checks::{
11    ValueIssue, ValueProblem, any, non_negative, point, scalar, unit, validate_paint, vec2,
12};
13use crate::{Animated, AnimatedPoint, Paint, Vec2};
14use serde::{Deserialize, Serialize};
15use std::fmt;
16
17/// Most items one shapes generator holds, including nested groups and their items.
18pub const MAX_SHAPE_ITEMS: usize = 1_024;
19/// Deepest group nesting in one shapes generator.
20pub const MAX_SHAPE_DEPTH: usize = 32;
21/// Most vertices one path holds, over all its subpaths.
22pub const MAX_PATH_VERTICES: usize = 4_096;
23/// Most subpaths one path holds.
24pub const MAX_PATH_SUBPATHS: usize = 256;
25/// Most entries in a stroke's dash pattern.
26pub const MAX_DASH_ENTRIES: usize = 16;
27/// Most points a polystar has. A fractional count is valid; the fraction is dropped when
28/// drawn.
29pub const MAX_POLYSTAR_POINTS: f32 = 100.0;
30/// Fewest points a polystar has.
31pub const MIN_POLYSTAR_POINTS: f32 = 3.0;
32
33/// One entry of a shapes generator's content tree.
34///
35/// A group is read front to back. Geometry accumulates in its group; a fill or stroke paints
36/// the geometry listed before it in the same group (nested groups included), and a trim cuts
37/// and replaces that geometry. Earlier paints draw on top of later ones, as in After Effects
38/// and Lottie.
39#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
40#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
41#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
42pub enum ShapeItem {
43    /// Items drawn together through one transform and opacity. Their geometry also counts as the
44    /// enclosing group's for paints and trims after the group.
45    Group {
46        /// The group's items, front first.
47        items: Vec<ShapeItem>,
48        /// Where the group lies, and how opaque it is.
49        #[serde(default)]
50        transform: ShapeTransform,
51    },
52    /// A rectangle, optionally with rounded corners.
53    Rectangle(RectangleGeometry),
54    /// An ellipse.
55    Ellipse(EllipseGeometry),
56    /// A regular polygon or star.
57    Polystar(PolystarGeometry),
58    /// A Bezier path.
59    Path(PathGeometry),
60    /// Paints the inside of the geometry listed before it in its group, as one compound path
61    /// under `rule`. Open paths are closed for it.
62    Fill {
63        /// What covers it.
64        paint: Animated<Paint>,
65        /// Opacity in `0..=1`.
66        #[serde(default = "opaque")]
67        opacity: Animated<f32>,
68        /// Which regions of the compound path are inside.
69        #[serde(default)]
70        rule: FillRule,
71    },
72    /// Strokes the geometry listed before it in its group.
73    Stroke(StrokeItem),
74    /// Keeps part of the geometry listed before it in its group, by length.
75    Trim(TrimItem),
76}
77
78fn zero() -> Animated<f32> {
79    Animated::Constant { value: 0.0 }
80}
81
82/// A geometry alone, as a mask's outline is.
83#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
84#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
85#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
86pub enum ShapeGeometry {
87    /// A rectangle, optionally with rounded corners.
88    Rectangle(RectangleGeometry),
89    /// An ellipse.
90    Ellipse(EllipseGeometry),
91    /// A regular polygon or star.
92    Polystar(PolystarGeometry),
93    /// A Bezier path.
94    Path(PathGeometry),
95}
96
97/// A rectangle about its centre, turned clockwise about it. Its outline starts at the
98/// top-right corner and runs clockwise.
99#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
100#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
101#[serde(deny_unknown_fields)]
102pub struct RectangleGeometry {
103    /// Centre.
104    pub center: AnimatedPoint,
105    /// Full width and height before rotation: x a fraction of the picture's width, y of its
106    /// height. Non-negative.
107    pub size: Animated<Vec2>,
108    /// Corner radius, as a fraction of the shorter side. Non-negative; capped at half the
109    /// rectangle.
110    #[serde(default = "zero")]
111    pub corner_radius: Animated<f32>,
112    /// Clockwise rotation about the centre, in degrees.
113    #[serde(default = "zero")]
114    pub rotation_degrees: Animated<f32>,
115}
116
117/// The ellipse inscribed in a size, turned clockwise about its centre. Its outline starts at
118/// the top and runs clockwise.
119#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
120#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
121#[serde(deny_unknown_fields)]
122pub struct EllipseGeometry {
123    /// Centre.
124    pub center: AnimatedPoint,
125    /// Full width and height before rotation: x a fraction of the picture's width, y of its
126    /// height. Non-negative.
127    pub size: Animated<Vec2>,
128    /// Clockwise rotation about the centre, in degrees.
129    #[serde(default = "zero")]
130    pub rotation_degrees: Animated<f32>,
131}
132
133/// A regular polygon or star, its first point straight up from the centre before rotation,
134/// the rest clockwise.
135#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
136#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
137#[serde(deny_unknown_fields)]
138pub struct PolystarGeometry {
139    /// A polygon or a star.
140    pub kind: PolystarKind,
141    /// Centre.
142    pub center: AnimatedPoint,
143    /// Number of points (polygon corners or star tips), from [`MIN_POLYSTAR_POINTS`] to
144    /// [`MAX_POLYSTAR_POINTS`]. A fraction is dropped when drawn, as Lottie does, so an animated
145    /// count changes one whole point at a time.
146    pub points: Animated<f32>,
147    /// Distance of the points from the centre, as a fraction of the shorter side. Non-negative.
148    pub outer_radius: Animated<f32>,
149    /// A star's distance of its inner corners from the centre. Non-negative.
150    #[serde(default = "zero")]
151    pub inner_radius: Animated<f32>,
152    /// How round the outer corners are, in `0..=1`: 0 sharp, 1 a smooth curve through them.
153    #[serde(default = "zero")]
154    pub outer_roundness: Animated<f32>,
155    /// How round a star's inner corners are, in `0..=1`.
156    #[serde(default = "zero")]
157    pub inner_roundness: Animated<f32>,
158    /// Clockwise rotation about the centre, in degrees.
159    #[serde(default = "zero")]
160    pub rotation_degrees: Animated<f32>,
161}
162
163/// What kind of figure a polystar is.
164#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
165#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
166#[serde(rename_all = "snake_case")]
167pub enum PolystarKind {
168    /// Points joined by inner corners.
169    #[default]
170    Star,
171    /// Points joined by straight sides.
172    Polygon,
173}
174
175/// A path, animated as one value.
176///
177/// Keyframes must agree in structure: the same subpath count, each closed or open alike with
178/// the same vertex count. Between keyframes every point and tangent moves linearly.
179#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
180#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
181#[serde(deny_unknown_fields)]
182pub struct PathGeometry {
183    /// The path.
184    pub path: Animated<Path>,
185}
186
187/// One or more subpaths.
188#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
189#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
190#[serde(deny_unknown_fields)]
191pub struct Path {
192    /// The subpaths, at most [`MAX_PATH_SUBPATHS`], with at most [`MAX_PATH_VERTICES`] vertices
193    /// in total.
194    pub subpaths: Vec<Subpath>,
195}
196
197/// A run of vertices joined by cubic Bezier segments.
198#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
199#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
200#[serde(deny_unknown_fields)]
201pub struct Subpath {
202    /// Whether a segment joins the last vertex back to the first.
203    #[serde(default)]
204    pub closed: bool,
205    /// The vertices, in order.
206    pub vertices: Vec<PathVertex>,
207}
208
209/// A path vertex and its tangents. The segment from `a` to `b` is the cubic Bezier through
210/// `a.point`, `a.point + a.out_tangent`, `b.point + b.in_tangent` and `b.point`; zero tangents
211/// make it straight.
212#[derive(Copy, Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
213#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
214#[serde(deny_unknown_fields)]
215pub struct PathVertex {
216    /// Where the vertex lies.
217    pub point: Vec2,
218    /// The control point of the segment ending here, relative to `point`.
219    #[serde(default)]
220    pub in_tangent: Vec2,
221    /// The control point of the segment starting here, relative to `point`.
222    #[serde(default)]
223    pub out_tangent: Vec2,
224}
225
226/// A group's placement. In pixel space: translate by `position`, rotate, scale, then
227/// translate by minus `anchor`, so `anchor` lands on `position`. The default is identity.
228#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
229#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
230#[serde(deny_unknown_fields)]
231pub struct ShapeTransform {
232    /// The point of the items that is placed at `position`.
233    #[serde(default = "origin")]
234    pub anchor: Animated<Vec2>,
235    /// Where `anchor` is placed.
236    #[serde(default)]
237    pub position: AnimatedPoint,
238    /// Per-axis scale; 1 leaves the items unscaled. Stroke widths scale with it.
239    #[serde(default = "unit_scale")]
240    pub scale: Animated<Vec2>,
241    /// Clockwise rotation, in degrees.
242    #[serde(default = "zero")]
243    pub rotation_degrees: Animated<f32>,
244    /// Opacity of the group as a whole, in `0..=1`; overlapping items are composited first and
245    /// faded together.
246    #[serde(default = "opaque")]
247    pub opacity: Animated<f32>,
248}
249
250fn origin() -> Animated<Vec2> {
251    Animated::Constant {
252        value: Vec2::new(0.0, 0.0),
253    }
254}
255
256impl Default for ShapeTransform {
257    fn default() -> Self {
258        Self {
259            anchor: origin(),
260            position: AnimatedPoint::default(),
261            scale: unit_scale(),
262            rotation_degrees: zero(),
263            opacity: opaque(),
264        }
265    }
266}
267
268/// A stroke along geometry, centred on its outline.
269#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
270#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
271#[serde(deny_unknown_fields)]
272pub struct StrokeItem {
273    /// What covers the stroke.
274    pub paint: Animated<Paint>,
275    /// Full width, as a fraction of the shorter side. Non-negative; zero draws nothing.
276    pub width: Animated<f32>,
277    /// Opacity in `0..=1`.
278    #[serde(default = "opaque")]
279    pub opacity: Animated<f32>,
280    /// How segments meet.
281    #[serde(default)]
282    pub join: LineJoin,
283    /// Longest a miter join reaches, as a multiple of half the width, before it is beveled. At
284    /// least 1.
285    #[serde(default = "four")]
286    pub miter_limit: f32,
287    /// How open ends look.
288    #[serde(default)]
289    pub cap: LineCap,
290    /// Dashes, or a solid stroke when absent.
291    #[serde(default)]
292    pub dash: Option<Dash>,
293}
294
295const fn four() -> f32 {
296    4.0
297}
298
299/// How stroke segments meet at a vertex.
300#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
301#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
302#[serde(rename_all = "snake_case")]
303pub enum LineJoin {
304    /// Extended edges meeting at a point, up to the miter limit.
305    #[default]
306    Miter,
307    /// A circular arc.
308    Round,
309    /// A straight cut across the corner.
310    Bevel,
311}
312
313/// How an open stroke ends.
314#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
315#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
316#[serde(rename_all = "snake_case")]
317pub enum LineCap {
318    /// Flat at the end point.
319    #[default]
320    Butt,
321    /// A half circle past the end point.
322    Round,
323    /// A half square past the end point.
324    Square,
325}
326
327/// A dash pattern.
328#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
329#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
330#[serde(deny_unknown_fields)]
331pub struct Dash {
332    /// Alternating dash and gap lengths, as fractions of the shorter side: one to
333    /// [`MAX_DASH_ENTRIES`] non-negative entries, not all zero. An odd count repeats once more to
334    /// pair up.
335    pub pattern: Vec<Animated<f32>>,
336    /// How far into the pattern the stroke starts.
337    #[serde(default = "zero")]
338    pub offset: Animated<f32>,
339}
340
341/// Keeps a span of geometry's length.
342#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
343#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
344#[serde(deny_unknown_fields)]
345pub struct TrimItem {
346    /// Where the kept span starts, as a fraction of the length, in `0..=1`.
347    pub start: Animated<f32>,
348    /// Where it ends, in `0..=1`. Nothing is kept unless it is after `start`.
349    pub end: Animated<f32>,
350    /// Turns the span is shifted by; it wraps around a closed path.
351    #[serde(default = "zero")]
352    pub offset: Animated<f32>,
353    /// Whether the span is of all the geometry together or of each part.
354    #[serde(default)]
355    pub mode: TrimMode,
356}
357
358/// What a trim measures.
359#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
360#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
361#[serde(rename_all = "snake_case")]
362pub enum TrimMode {
363    /// One span over the length of every subpath end to end, in order.
364    #[default]
365    Together,
366    /// A span of each subpath alone.
367    Individually,
368}
369
370/// Which regions of a path are inside it.
371#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, Hash, PartialEq, Serialize)]
372#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
373#[serde(rename_all = "snake_case")]
374pub enum FillRule {
375    /// Where the outline winds around a point at all.
376    #[default]
377    Nonzero,
378    /// Where it winds around a point an odd number of times.
379    Evenodd,
380}
381
382/// Why a content tree or a mask outline is invalid.
383#[derive(Copy, Clone, Debug, Eq, PartialEq)]
384#[non_exhaustive]
385pub enum ShapeProblem {
386    /// More than [`MAX_SHAPE_ITEMS`] items.
387    TooManyItems,
388    /// Groups nested deeper than [`MAX_SHAPE_DEPTH`].
389    TooDeep,
390    /// A path with more than [`MAX_PATH_SUBPATHS`] subpaths or [`MAX_PATH_VERTICES`] vertices.
391    PathTooLarge,
392    /// A dash pattern with no entries, more than [`MAX_DASH_ENTRIES`], or only zeros.
393    InvalidDash,
394    /// Keyframes that are empty, out of order, or have an unusable curve.
395    InvalidKeyframes,
396    /// A value that is not finite or outside its range.
397    OutOfRange,
398    /// Path keyframes that differ in subpath count, closing, or vertex count.
399    PathKeyframesDiffer,
400    /// Paint keyframes that differ in kind, stop count, or spread.
401    PaintKeyframesDiffer,
402}
403
404impl fmt::Display for ShapeProblem {
405    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
406        formatter.write_str(match self {
407            Self::TooManyItems => "has more items than a shapes generator may hold",
408            Self::TooDeep => "nests groups too deeply",
409            Self::PathTooLarge => "has more subpaths or vertices than a path may hold",
410            Self::InvalidDash => "has an invalid dash pattern",
411            Self::InvalidKeyframes => "has invalid keyframes",
412            Self::OutOfRange => "has a value out of range",
413            Self::PathKeyframesDiffer => "has path keyframes that differ in structure",
414            Self::PaintKeyframesDiffer => "has paint keyframes that differ in kind or stops",
415        })
416    }
417}
418
419/// Where in a content tree a problem lies, and what it is.
420#[derive(Clone, Debug, Eq, PartialEq)]
421pub struct ShapeIssue {
422    /// The item's path from the tree's root, like `items[2].items[0].path`, or a mask's field,
423    /// like `shape.size`.
424    pub at: String,
425    /// What is wrong there.
426    pub problem: ShapeProblem,
427}
428
429impl From<ValueIssue> for ShapeIssue {
430    fn from(issue: ValueIssue) -> Self {
431        Self::new(
432            issue.at,
433            match issue.problem {
434                ValueProblem::InvalidKeyframes => ShapeProblem::InvalidKeyframes,
435                ValueProblem::OutOfRange => ShapeProblem::OutOfRange,
436                ValueProblem::PaintKeyframesDiffer => ShapeProblem::PaintKeyframesDiffer,
437            },
438        )
439    }
440}
441
442impl ShapeIssue {
443    fn new(at: impl Into<String>, problem: ShapeProblem) -> Self {
444        Self {
445            at: at.into(),
446            problem,
447        }
448    }
449}
450
451type Checked = Result<(), ShapeIssue>;
452
453/// Checks a content tree: its limits, every value's range, and that animated paths and paints
454/// agree in structure between keyframes.
455///
456/// # Errors
457/// Returns the first problem in document order.
458pub fn validate_shape_items(items: &[ShapeItem]) -> Result<(), ShapeIssue> {
459    let mut count = 0;
460    validate_items(items, "items", 1, &mut count)
461}
462
463pub(crate) fn validate_items(
464    items: &[ShapeItem],
465    at: &str,
466    depth: usize,
467    count: &mut usize,
468) -> Checked {
469    if depth > MAX_SHAPE_DEPTH {
470        return Err(ShapeIssue::new(at, ShapeProblem::TooDeep));
471    }
472    for (index, item) in items.iter().enumerate() {
473        let at = format!("{at}[{index}]");
474        *count += 1;
475        if *count > MAX_SHAPE_ITEMS {
476            return Err(ShapeIssue::new(at, ShapeProblem::TooManyItems));
477        }
478        match item {
479            ShapeItem::Group { items, transform } => {
480                validate_transform(transform, &format!("{at}.transform"))?;
481                validate_items(items, &format!("{at}.items"), depth + 1, count)?;
482            }
483            ShapeItem::Rectangle(geometry) => validate_rectangle(geometry, &at)?,
484            ShapeItem::Ellipse(geometry) => validate_ellipse(geometry, &at)?,
485            ShapeItem::Polystar(geometry) => validate_polystar(geometry, &at)?,
486            ShapeItem::Path(geometry) => validate_path(&geometry.path, &format!("{at}.path"))?,
487            ShapeItem::Fill { paint, opacity, .. } => {
488                validate_paint(paint, &format!("{at}.paint"))?;
489                scalar(opacity, &format!("{at}.opacity"), unit)?;
490            }
491            ShapeItem::Stroke(stroke) => validate_stroke(stroke, &at)?,
492            ShapeItem::Trim(trim) => {
493                scalar(&trim.start, &format!("{at}.start"), unit)?;
494                scalar(&trim.end, &format!("{at}.end"), unit)?;
495                scalar(&trim.offset, &format!("{at}.offset"), any)?;
496            }
497        }
498    }
499    Ok(())
500}
501
502impl ShapeGeometry {
503    /// Checks the outline as a content tree's geometry is checked; `at` names it in the issue.
504    ///
505    /// # Errors
506    /// Returns the first problem in document order.
507    pub fn validate(&self, at: &str) -> Result<(), ShapeIssue> {
508        match self {
509            Self::Rectangle(geometry) => validate_rectangle(geometry, at),
510            Self::Ellipse(geometry) => validate_ellipse(geometry, at),
511            Self::Polystar(geometry) => validate_polystar(geometry, at),
512            Self::Path(geometry) => validate_path(&geometry.path, &format!("{at}.path")),
513        }
514    }
515}
516
517fn sizes(value: Vec2) -> bool {
518    value.x >= 0.0 && value.y >= 0.0
519}
520
521fn validate_rectangle(geometry: &RectangleGeometry, at: &str) -> Checked {
522    point(&geometry.center, &format!("{at}.center"))?;
523    vec2(&geometry.size, &format!("{at}.size"), sizes)?;
524    scalar(
525        &geometry.corner_radius,
526        &format!("{at}.corner_radius"),
527        non_negative,
528    )?;
529    scalar(
530        &geometry.rotation_degrees,
531        &format!("{at}.rotation_degrees"),
532        any,
533    )?;
534    Ok(())
535}
536
537fn validate_ellipse(geometry: &EllipseGeometry, at: &str) -> Checked {
538    point(&geometry.center, &format!("{at}.center"))?;
539    vec2(&geometry.size, &format!("{at}.size"), sizes)?;
540    scalar(
541        &geometry.rotation_degrees,
542        &format!("{at}.rotation_degrees"),
543        any,
544    )?;
545    Ok(())
546}
547
548fn validate_polystar(geometry: &PolystarGeometry, at: &str) -> Checked {
549    point(&geometry.center, &format!("{at}.center"))?;
550    scalar(&geometry.points, &format!("{at}.points"), |points| {
551        (MIN_POLYSTAR_POINTS..=MAX_POLYSTAR_POINTS).contains(&points)
552    })?;
553    scalar(
554        &geometry.outer_radius,
555        &format!("{at}.outer_radius"),
556        non_negative,
557    )?;
558    scalar(
559        &geometry.inner_radius,
560        &format!("{at}.inner_radius"),
561        non_negative,
562    )?;
563    scalar(
564        &geometry.outer_roundness,
565        &format!("{at}.outer_roundness"),
566        unit,
567    )?;
568    scalar(
569        &geometry.inner_roundness,
570        &format!("{at}.inner_roundness"),
571        unit,
572    )?;
573    scalar(
574        &geometry.rotation_degrees,
575        &format!("{at}.rotation_degrees"),
576        any,
577    )?;
578    Ok(())
579}
580
581fn validate_transform(transform: &ShapeTransform, at: &str) -> Checked {
582    vec2(&transform.anchor, &format!("{at}.anchor"), |_| true)?;
583    point(&transform.position, &format!("{at}.position"))?;
584    vec2(&transform.scale, &format!("{at}.scale"), |_| true)?;
585    scalar(
586        &transform.rotation_degrees,
587        &format!("{at}.rotation_degrees"),
588        any,
589    )?;
590    scalar(&transform.opacity, &format!("{at}.opacity"), unit)?;
591    Ok(())
592}
593
594fn validate_stroke(stroke: &StrokeItem, at: &str) -> Checked {
595    validate_paint(&stroke.paint, &format!("{at}.paint"))?;
596    scalar(&stroke.width, &format!("{at}.width"), non_negative)?;
597    scalar(&stroke.opacity, &format!("{at}.opacity"), unit)?;
598    if !stroke.miter_limit.is_finite() || stroke.miter_limit < 1.0 {
599        return Err(ShapeIssue::new(
600            format!("{at}.miter_limit"),
601            ShapeProblem::OutOfRange,
602        ));
603    }
604    if let Some(dash) = &stroke.dash {
605        let at = format!("{at}.dash");
606        if dash.pattern.is_empty() || dash.pattern.len() > MAX_DASH_ENTRIES {
607            return Err(ShapeIssue::new(
608                format!("{at}.pattern"),
609                ShapeProblem::InvalidDash,
610            ));
611        }
612        for (index, entry) in dash.pattern.iter().enumerate() {
613            scalar(entry, &format!("{at}.pattern[{index}]"), non_negative)?;
614        }
615        // A pattern of only zeros has no length to repeat over. Animated entries need some positive
616        // keyframe.
617        let positive = dash.pattern.iter().any(|entry| match entry {
618            Animated::Constant { value } => *value > 0.0,
619            Animated::Keyframes { keyframes } => keyframes.iter().any(|key| key.value > 0.0),
620        });
621        if !positive {
622            return Err(ShapeIssue::new(
623                format!("{at}.pattern"),
624                ShapeProblem::InvalidDash,
625            ));
626        }
627        scalar(&dash.offset, &format!("{at}.offset"), any)?;
628    }
629    Ok(())
630}
631
632fn validate_path(path: &Animated<Path>, at: &str) -> Checked {
633    let check_one = |path: &Path| -> Checked {
634        let vertices: usize = path
635            .subpaths
636            .iter()
637            .map(|subpath| subpath.vertices.len())
638            .sum();
639        if path.subpaths.len() > MAX_PATH_SUBPATHS || vertices > MAX_PATH_VERTICES {
640            return Err(ShapeIssue::new(at, ShapeProblem::PathTooLarge));
641        }
642        let finite = |value: Vec2| value.x.is_finite() && value.y.is_finite();
643        let all_finite = path.subpaths.iter().all(|subpath| {
644            subpath.vertices.iter().all(|vertex| {
645                finite(vertex.point) && finite(vertex.in_tangent) && finite(vertex.out_tangent)
646            })
647        });
648        all_finite
649            .then_some(())
650            .ok_or_else(|| ShapeIssue::new(at, ShapeProblem::OutOfRange))
651    };
652    match path {
653        Animated::Constant { value } => check_one(value),
654        Animated::Keyframes { keyframes } => {
655            if !valid_keyframes(keyframes) {
656                return Err(ShapeIssue::new(at, ShapeProblem::InvalidKeyframes));
657            }
658            for keyframe in keyframes {
659                check_one(&keyframe.value)?;
660            }
661            let structure = |path: &Path| {
662                path.subpaths
663                    .iter()
664                    .map(|subpath| (subpath.closed, subpath.vertices.len()))
665                    .collect::<Vec<_>>()
666            };
667            let first = structure(&keyframes[0].value);
668            if keyframes[1..]
669                .iter()
670                .any(|keyframe| structure(&keyframe.value) != first)
671            {
672                return Err(ShapeIssue::new(at, ShapeProblem::PathKeyframesDiffer));
673            }
674            Ok(())
675        }
676    }
677}
678
679impl Path {
680    /// Number of vertices over all subpaths.
681    #[must_use]
682    pub fn vertex_count(&self) -> usize {
683        self.subpaths
684            .iter()
685            .map(|subpath| subpath.vertices.len())
686            .sum()
687    }
688}
689
690impl ShapeItem {
691    /// Whether the item or anything in it changes over time.
692    #[must_use]
693    pub fn is_animated(&self) -> bool {
694        match self {
695            Self::Group { items, transform } => {
696                transform.is_animated() || items.iter().any(Self::is_animated)
697            }
698            Self::Rectangle(geometry) => geometry.is_animated(),
699            Self::Ellipse(geometry) => geometry.is_animated(),
700            Self::Polystar(geometry) => geometry.is_animated(),
701            Self::Path(geometry) => keyed(&geometry.path),
702            Self::Fill { paint, opacity, .. } => keyed(paint) || keyed(opacity),
703            Self::Stroke(stroke) => {
704                keyed(&stroke.paint)
705                    || keyed(&stroke.width)
706                    || keyed(&stroke.opacity)
707                    || stroke
708                        .dash
709                        .as_ref()
710                        .is_some_and(|dash| dash.pattern.iter().any(keyed) || keyed(&dash.offset))
711            }
712            Self::Trim(trim) => keyed(&trim.start) || keyed(&trim.end) || keyed(&trim.offset),
713        }
714    }
715}
716
717/// Calls `visit` with each keyframed value of `items`, by its path below `at`
718/// (`items[0].transform.opacity` for `at` "items"), and its keyframe times, in document order.
719pub(crate) fn for_each_keyed_item(
720    items: &[ShapeItem],
721    at: &str,
722    visit: &mut impl FnMut(&str, &[alpha2_time::MediaTime]),
723) {
724    for (index, item) in items.iter().enumerate() {
725        item.for_each_keyed(&format!("{at}[{index}]"), visit);
726    }
727}
728
729impl ShapeItem {
730    /// Calls `visit` with each of its keyframed values, by path below `at`, and their times.
731    pub(crate) fn for_each_keyed(
732        &self,
733        at: &str,
734        visit: &mut impl FnMut(&str, &[alpha2_time::MediaTime]),
735    ) {
736        match self {
737            Self::Group { items, transform } => {
738                let transform_at = format!("{at}.transform");
739                visit_keyed(&format!("{transform_at}.anchor"), &transform.anchor, visit);
740                visit_keyed_point(
741                    &format!("{transform_at}.position"),
742                    &transform.position,
743                    visit,
744                );
745                visit_keyed(&format!("{transform_at}.scale"), &transform.scale, visit);
746                visit_keyed(
747                    &format!("{transform_at}.rotation_degrees"),
748                    &transform.rotation_degrees,
749                    visit,
750                );
751                visit_keyed(
752                    &format!("{transform_at}.opacity"),
753                    &transform.opacity,
754                    visit,
755                );
756                for_each_keyed_item(items, &format!("{at}.items"), visit);
757            }
758            Self::Rectangle(geometry) => geometry.for_each_keyed(at, visit),
759            Self::Ellipse(geometry) => geometry.for_each_keyed(at, visit),
760            Self::Polystar(geometry) => geometry.for_each_keyed(at, visit),
761            Self::Path(geometry) => visit_keyed(&format!("{at}.path"), &geometry.path, visit),
762            Self::Fill { paint, opacity, .. } => {
763                visit_keyed(&format!("{at}.paint"), paint, visit);
764                visit_keyed(&format!("{at}.opacity"), opacity, visit);
765            }
766            Self::Stroke(stroke) => {
767                visit_keyed(&format!("{at}.paint"), &stroke.paint, visit);
768                visit_keyed(&format!("{at}.width"), &stroke.width, visit);
769                visit_keyed(&format!("{at}.opacity"), &stroke.opacity, visit);
770                if let Some(dash) = &stroke.dash {
771                    for (index, entry) in dash.pattern.iter().enumerate() {
772                        visit_keyed(&format!("{at}.dash.pattern[{index}]"), entry, visit);
773                    }
774                    visit_keyed(&format!("{at}.dash.offset"), &dash.offset, visit);
775                }
776            }
777            Self::Trim(trim) => {
778                visit_keyed(&format!("{at}.start"), &trim.start, visit);
779                visit_keyed(&format!("{at}.end"), &trim.end, visit);
780                visit_keyed(&format!("{at}.offset"), &trim.offset, visit);
781            }
782        }
783    }
784}
785
786impl ShapeGeometry {
787    /// Calls `visit` with each of its keyframed values, by path below `at`, and their times.
788    pub(crate) fn for_each_keyed(
789        &self,
790        at: &str,
791        visit: &mut impl FnMut(&str, &[alpha2_time::MediaTime]),
792    ) {
793        match self {
794            Self::Rectangle(geometry) => geometry.for_each_keyed(at, visit),
795            Self::Ellipse(geometry) => geometry.for_each_keyed(at, visit),
796            Self::Polystar(geometry) => geometry.for_each_keyed(at, visit),
797            Self::Path(geometry) => visit_keyed(&format!("{at}.path"), &geometry.path, visit),
798        }
799    }
800
801    /// Whether the outline changes over time.
802    #[must_use]
803    pub fn is_animated(&self) -> bool {
804        match self {
805            Self::Rectangle(geometry) => geometry.is_animated(),
806            Self::Ellipse(geometry) => geometry.is_animated(),
807            Self::Polystar(geometry) => geometry.is_animated(),
808            Self::Path(geometry) => keyed(&geometry.path),
809        }
810    }
811}
812
813impl RectangleGeometry {
814    fn is_animated(&self) -> bool {
815        self.center.is_animated()
816            || keyed(&self.size)
817            || keyed(&self.corner_radius)
818            || keyed(&self.rotation_degrees)
819    }
820}
821
822impl EllipseGeometry {
823    fn is_animated(&self) -> bool {
824        self.center.is_animated() || keyed(&self.size) || keyed(&self.rotation_degrees)
825    }
826}
827
828impl RectangleGeometry {
829    fn for_each_keyed(&self, at: &str, visit: &mut impl FnMut(&str, &[alpha2_time::MediaTime])) {
830        visit_keyed_point(&format!("{at}.center"), &self.center, visit);
831        visit_keyed(&format!("{at}.size"), &self.size, visit);
832        visit_keyed(&format!("{at}.corner_radius"), &self.corner_radius, visit);
833        visit_keyed(
834            &format!("{at}.rotation_degrees"),
835            &self.rotation_degrees,
836            visit,
837        );
838    }
839}
840
841impl EllipseGeometry {
842    fn for_each_keyed(&self, at: &str, visit: &mut impl FnMut(&str, &[alpha2_time::MediaTime])) {
843        visit_keyed_point(&format!("{at}.center"), &self.center, visit);
844        visit_keyed(&format!("{at}.size"), &self.size, visit);
845        visit_keyed(
846            &format!("{at}.rotation_degrees"),
847            &self.rotation_degrees,
848            visit,
849        );
850    }
851}
852
853impl PolystarGeometry {
854    fn for_each_keyed(&self, at: &str, visit: &mut impl FnMut(&str, &[alpha2_time::MediaTime])) {
855        visit_keyed_point(&format!("{at}.center"), &self.center, visit);
856        visit_keyed(&format!("{at}.points"), &self.points, visit);
857        visit_keyed(&format!("{at}.outer_radius"), &self.outer_radius, visit);
858        visit_keyed(&format!("{at}.inner_radius"), &self.inner_radius, visit);
859        visit_keyed(
860            &format!("{at}.outer_roundness"),
861            &self.outer_roundness,
862            visit,
863        );
864        visit_keyed(
865            &format!("{at}.inner_roundness"),
866            &self.inner_roundness,
867            visit,
868        );
869        visit_keyed(
870            &format!("{at}.rotation_degrees"),
871            &self.rotation_degrees,
872            visit,
873        );
874    }
875
876    fn is_animated(&self) -> bool {
877        self.center.is_animated()
878            || keyed(&self.points)
879            || keyed(&self.outer_radius)
880            || keyed(&self.inner_radius)
881            || keyed(&self.outer_roundness)
882            || keyed(&self.inner_roundness)
883            || keyed(&self.rotation_degrees)
884    }
885}
886
887impl ShapeTransform {
888    fn is_animated(&self) -> bool {
889        keyed(&self.anchor)
890            || self.position.is_animated()
891            || keyed(&self.scale)
892            || keyed(&self.rotation_degrees)
893            || keyed(&self.opacity)
894    }
895}
896
897const fn keyed<T>(animation: &Animated<T>) -> bool {
898    matches!(animation, Animated::Keyframes { .. })
899}