Skip to main content

alpha2_model/
shapes.rs

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