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