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