Skip to main content

renamite_model/
lib.rs

1//! Serializable document model + pure evaluator.
2//!
3//! Group evaluation: pass 1 accumulates shape paths and applies modifiers in
4//! document order; pass 2 recurses/emits styles bottom-first so `Scene.items`
5//! is in painter's order. Nodes live in a slotmap arena. Tree membership is
6//! attach/detach so undo/redo never changes a NodeId.
7
8use kurbo::{Affine, BezPath, ParamCurveNearest, Point, Shape as KurboShape};
9use renamite_animation::{
10    Angle, Animated, AnimatedTransform, EasingHandle, Frame, Interpolation, Tween,
11};
12use renamite_geometry::{VectorPath, dash_bez_path, offset_bez_path};
13pub use renamite_text::TextAlign;
14use serde::de::{Deserializer, Error as DeError, Visitor};
15use serde::{Deserialize, Serialize};
16use slotmap::{SlotMap, new_key_type};
17
18new_key_type! {
19    pub struct NodeId;
20    pub struct CompId;
21    pub struct AssetId;
22}
23
24pub type NodeMap = SlotMap<NodeId, Node>;
25pub type CompMap = SlotMap<CompId, Composition>;
26pub type AssetMap = SlotMap<AssetId, Asset>;
27
28#[derive(Clone, Serialize, Deserialize)]
29pub struct Document {
30    pub format_version: u32,
31    pub compositions: CompMap,
32    pub nodes: NodeMap,
33    pub assets: AssetMap,
34
35    /// Live/attached assets in UI order.
36    #[serde(default)]
37    pub asset_order: Vec<AssetId>,
38
39    pub main: CompId,
40}
41
42#[derive(Clone, Serialize, Deserialize)]
43pub struct Composition {
44    pub name: String,
45    pub size: (u32, u32),
46    pub rate: renamite_animation::FrameRate,
47    pub range: (Frame, Frame),
48    /// z-order: index 0 = top of stack.
49    pub children: Vec<NodeId>,
50}
51
52#[derive(Clone, Debug, Serialize, Deserialize)]
53pub struct Node {
54    pub name: String,
55    pub parent: Option<NodeId>,
56    pub children: Vec<NodeId>,
57    pub visible: bool,
58    pub locked: bool,
59    pub transform: AnimatedTransform,
60    pub opacity: Animated<f64>,
61    pub kind: NodeKind,
62}
63
64impl Node {
65    pub fn new(name: impl Into<String>, kind: NodeKind) -> Self {
66        Self {
67            name: name.into(),
68            parent: None,
69            children: Vec::new(),
70            visible: true,
71            locked: false,
72            transform: AnimatedTransform::identity(),
73            opacity: Animated::new(1.0),
74            kind,
75        }
76    }
77}
78
79#[derive(Clone, Debug, Serialize, Deserialize)]
80pub enum NodeKind {
81    Group,
82    Layer(LayerProps),
83    Shape(ShapeKind),
84    Style(StyleKind),
85    Modifier(ModifierKind),
86    Text(TextNode),
87    Image(AssetId),
88    Precomp { comp: CompId, time_map: TimeMap },
89    Mask(MaskProps),
90}
91
92#[derive(Clone, Debug, Serialize, Deserialize)]
93pub struct LayerProps {
94    pub in_frame: Frame,
95    pub out_frame: Frame,
96    pub time_stretch: f64,
97    pub blend: BlendMode,
98}
99
100impl Default for LayerProps {
101    fn default() -> Self {
102        Self {
103            in_frame: Frame(0),
104            out_frame: Frame(i64::MAX / 2),
105            time_stretch: 1.0,
106            blend: BlendMode::Normal,
107        }
108    }
109}
110
111/// Multi-contour geometry: boolean results and stroke expansions produce
112/// outer contours plus holes, which one `VectorPath` cannot represent.
113#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
114pub struct CompoundPath {
115    /// Every entry is one contour. Linesweeper's orientation guarantees that
116    /// holes work under both NonZero and EvenOdd filling.
117    pub contours: Vec<Animated<VectorPath>>,
118}
119
120#[derive(Clone, Debug, Serialize, Deserialize)]
121pub enum ShapeKind {
122    Path(Animated<VectorPath>),
123    Rect {
124        pos: Animated<glam::DVec2>,
125        size: Animated<glam::DVec2>,
126        rounded: Animated<f64>,
127    },
128    Ellipse {
129        pos: Animated<glam::DVec2>,
130        size: Animated<glam::DVec2>,
131    },
132    Star {
133        pos: Animated<glam::DVec2>,
134        points: Animated<f64>,
135        inner_r: Animated<f64>,
136        outer_r: Animated<f64>,
137        roundness: Animated<f64>,
138        kind: StarKind,
139    },
140    Polygon {
141        pos: Animated<glam::DVec2>,
142        points: Animated<f64>,
143        outer_r: Animated<f64>,
144        roundness: Animated<f64>,
145    },
146
147    // Appended last to keep postcard enum indices of existing variants stable.
148    CompoundPath(CompoundPath),
149}
150
151impl CompoundPath {
152    /// All contours flattened into one multi-subpath `BezPath` at `frame`.
153    pub fn to_bez_path(&self, frame: f64) -> BezPath {
154        let mut result = BezPath::new();
155        for contour in &self.contours {
156            result.extend(
157                contour
158                    .value_at(frame)
159                    .to_bez_path()
160                    .elements()
161                    .iter()
162                    .copied(),
163            );
164        }
165        result
166    }
167}
168
169/// One color anchor along a gradient axis. `offset` is in 0..=1.
170#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
171pub struct GradientStop {
172    pub offset: f64,
173    pub color: Color,
174}
175
176/// Ordered gradient stops, sampled by `sample`. Kept small (usually 2-4).
177#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
178pub struct GradientStops(pub Vec<GradientStop>);
179
180impl Default for GradientStops {
181    fn default() -> Self {
182        Self(vec![
183            GradientStop {
184                offset: 0.0,
185                color: Color::rgba(1.0, 1.0, 1.0, 1.0),
186            },
187            GradientStop {
188                offset: 1.0,
189                color: Color::rgba(0.0, 0.0, 0.0, 1.0),
190            },
191        ])
192    }
193}
194
195impl GradientStops {
196    /// Sample the gradient at normalized position `t` (clamped to 0..=1).
197    pub fn sample(&self, t: f64) -> Color {
198        let t = t.clamp(0.0, 1.0);
199        let stops = &self.0;
200        if stops.is_empty() {
201            return Color::BLACK;
202        }
203        if t <= stops[0].offset {
204            return stops[0].color;
205        }
206        for w in stops.windows(2) {
207            let (a, b) = (&w[0], &w[1]);
208            if t <= b.offset {
209                let span = (b.offset - a.offset).max(1e-9);
210                let u = ((t - a.offset) / span).clamp(0.0, 1.0);
211                return Color::rgba(
212                    a.color.r + (b.color.r - a.color.r) * u,
213                    a.color.g + (b.color.g - a.color.g) * u,
214                    a.color.b + (b.color.b - a.color.b) * u,
215                    a.color.a + (b.color.a - a.color.a) * u,
216                );
217            }
218        }
219        stops.last().unwrap().color
220    }
221}
222
223impl Tween for GradientStops {
224    fn tween(a: &Self, b: &Self, t: f64) -> Self {
225        if a.0.len() != b.0.len() {
226            return if t < 1.0 { a.clone() } else { b.clone() };
227        }
228        Self(
229            a.0.iter()
230                .zip(&b.0)
231                .map(|(x, y)| GradientStop {
232                    offset: x.offset + (y.offset - x.offset) * t,
233                    color: Color::rgba(
234                        x.color.r + (y.color.r - x.color.r) * t,
235                        x.color.g + (y.color.g - x.color.g) * t,
236                        x.color.b + (y.color.b - x.color.b) * t,
237                        x.color.a + (y.color.a - x.color.a) * t,
238                    ),
239                })
240                .collect(),
241        )
242    }
243}
244
245#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
246pub enum GradientKind {
247    Linear,
248    Radial,
249}
250
251/// A gradient in the *node's* local space. The evaluator folds the owning
252/// shape's world transform into `start`/`end` before baking vertex colors.
253#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
254pub struct Gradient {
255    pub kind: GradientKind,
256    /// Linear: start point. Radial: center.
257    pub start: Animated<glam::DVec2>,
258    /// Linear: end point. Radial: circumference point (radius = |end-start|).
259    pub end: Animated<glam::DVec2>,
260    pub stops: Animated<GradientStops>,
261}
262
263/// Paint on a style node: solid color or gradient. Animated so whole-list
264/// keyframes (e.g. stop-color morphs) work through the existing machinery.
265#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
266pub enum StylePaint {
267    Solid { color: Animated<Color> },
268    Gradient(Gradient),
269}
270
271impl StylePaint {
272    pub fn solid(color: Color) -> Self {
273        Self::Solid {
274            color: Animated::new(color),
275        }
276    }
277
278    pub fn linear(start: glam::DVec2, end: glam::DVec2, stops: GradientStops) -> Self {
279        Self::Gradient(Gradient {
280            kind: GradientKind::Linear,
281            start: Animated::new(start),
282            end: Animated::new(end),
283            stops: Animated::new(stops),
284        })
285    }
286
287    pub fn radial(center: glam::DVec2, end: glam::DVec2, stops: GradientStops) -> Self {
288        Self::Gradient(Gradient {
289            kind: GradientKind::Radial,
290            start: Animated::new(center),
291            end: Animated::new(end),
292            stops: Animated::new(stops),
293        })
294    }
295
296    /// Sample the paint into a `ScenePaint` at `frame`.
297    pub fn sample(&self, frame: f64) -> ScenePaint {
298        match self {
299            StylePaint::Solid { color } => ScenePaint::Solid(color.value_at(frame)),
300            StylePaint::Gradient(g) => {
301                let start = g.start.value_at(frame);
302                let end = g.end.value_at(frame);
303                let stops = g.stops.value_at(frame);
304                match g.kind {
305                    GradientKind::Linear => ScenePaint::LinearGradient { start, end, stops },
306                    GradientKind::Radial => ScenePaint::RadialGradient {
307                        center: start,
308                        end,
309                        stops,
310                    },
311                }
312            }
313        }
314    }
315
316    /// Produce a static paint snapshot at `frame`.
317    ///
318    /// Current paint is tool state, not an animation track, so copying paint
319    /// from a document should sample it rather than copying its keyframes.
320    pub fn snapshot(&self, frame: f64) -> Self {
321        match self {
322            StylePaint::Solid { color } => StylePaint::solid(color.value_at(frame)),
323            StylePaint::Gradient(gradient) => StylePaint::Gradient(Gradient {
324                kind: gradient.kind,
325                start: Animated::new(gradient.start.value_at(frame)),
326                end: Animated::new(gradient.end.value_at(frame)),
327                stops: Animated::new(gradient.stops.value_at(frame)),
328            }),
329        }
330    }
331
332    /// Change the representative color while preserving paint type.
333    ///
334    /// For a gradient, this updates its first stop rather than destroying the
335    /// gradient and converting it to a solid.
336    pub fn set_base_color(&mut self, color: Color) {
337        match self {
338            StylePaint::Solid { color: animated } => {
339                animated.base = color;
340                animated.keyframes.clear();
341            }
342            StylePaint::Gradient(gradient) => {
343                gradient.start.keyframes.clear();
344                gradient.end.keyframes.clear();
345                gradient.stops.keyframes.clear();
346
347                if let Some(first) = gradient.stops.base.0.first_mut() {
348                    first.color = color;
349                } else {
350                    gradient
351                        .stops
352                        .base
353                        .0
354                        .push(GradientStop { offset: 0.0, color });
355                }
356            }
357        }
358    }
359}
360
361impl Tween for StylePaint {
362    fn tween(a: &Self, b: &Self, t: f64) -> Self {
363        match (a, b) {
364            (StylePaint::Solid { color: ca }, StylePaint::Solid { color: cb }) => {
365                StylePaint::Solid {
366                    color: Animated::new(Tween::tween(&ca.base, &cb.base, t)),
367                }
368            }
369            (StylePaint::Gradient(ga), StylePaint::Gradient(gb)) => {
370                StylePaint::Gradient(Gradient {
371                    kind: ga.kind,
372                    start: Animated::new(Tween::tween(&ga.start.base, &gb.start.base, t)),
373                    end: Animated::new(Tween::tween(&ga.end.base, &gb.end.base, t)),
374                    stops: Animated::new(Tween::tween(&ga.stops.base, &gb.stops.base, t)),
375                })
376            }
377            _ => {
378                if t < 1.0 {
379                    a.clone()
380                } else {
381                    b.clone()
382                }
383            }
384        }
385    }
386}
387
388impl StyleKind {
389    /// Replace the paint (fill or stroke), returning the previous one (undo).
390    pub fn swap_paint(&mut self, paint: StylePaint) -> StylePaint {
391        match self {
392            StyleKind::Fill { paint: p, .. } | StyleKind::Stroke { paint: p, .. } => {
393                std::mem::replace(p, paint)
394            }
395        }
396    }
397
398    pub fn paint(&self) -> &StylePaint {
399        match self {
400            StyleKind::Fill { paint, .. } | StyleKind::Stroke { paint, .. } => paint,
401        }
402    }
403}
404
405impl StylePaint {
406    /// For a solid paint: the (possibly keyed) base color. For a gradient:
407    /// the first stop's color (stable handle for conversions).
408    pub fn base_color(&self) -> Color {
409        match self {
410            StylePaint::Solid { color } => color.base,
411            StylePaint::Gradient(g) => g
412                .stops
413                .base
414                .0
415                .first()
416                .map(|s| s.color)
417                .unwrap_or(Color::BLACK),
418        }
419    }
420}
421
422#[derive(Clone, Debug, PartialEq, Serialize)]
423pub enum StyleKind {
424    Fill {
425        paint: StylePaint,
426        rule: FillRule,
427    },
428    Stroke {
429        paint: StylePaint,
430        width: Animated<f64>,
431        cap: StrokeCap,
432        join: StrokeJoin,
433        dash: Option<AnimatedDash>,
434    },
435}
436
437#[derive(Default)]
438struct StyleCompatContent {
439    paint: Option<StylePaint>,
440    color: Option<Animated<Color>>,
441    width: Option<Animated<f64>>,
442    cap: Option<StrokeCap>,
443    join: Option<StrokeJoin>,
444    dash: Option<AnimatedDash>,
445    rule: Option<FillRule>,
446}
447
448impl<'de> Deserialize<'de> for StyleKind {
449    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
450    where
451        D: Deserializer<'de>,
452    {
453        #[derive(Deserialize)]
454        enum StyleTag {
455            Fill,
456            Stroke,
457        }
458
459        struct StyleKindVisitor;
460        impl<'de> Visitor<'de> for StyleKindVisitor {
461            type Value = StyleKind;
462
463            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
464                f.write_str("a Fill or Stroke style")
465            }
466
467            fn visit_enum<A>(self, data: A) -> Result<Self::Value, A::Error>
468            where
469                A: serde::de::EnumAccess<'de>,
470            {
471                use serde::de::VariantAccess as _;
472                struct ContentVisitor {
473                    fill: bool,
474                }
475                impl<'de> Visitor<'de> for ContentVisitor {
476                    type Value = StyleCompatContent;
477                    fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
478                        f.write_str("style variant fields")
479                    }
480                    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
481                    where
482                        A: serde::de::MapAccess<'de>,
483                    {
484                        use serde::de::Error as _;
485                        let mut content = StyleCompatContent::default();
486                        while let Some(key) = map.next_key::<String>()? {
487                            match key.as_str() {
488                                "paint" => content.paint = Some(map.next_value()?),
489                                "color" => content.color = Some(map.next_value()?),
490                                "width" => content.width = Some(map.next_value()?),
491                                "cap" => content.cap = Some(map.next_value()?),
492                                "join" => content.join = Some(map.next_value()?),
493                                "dash" => {
494                                    content.dash = map.next_value::<Option<AnimatedDash>>()?
495                                }
496                                "rule" => content.rule = Some(map.next_value()?),
497                                other => {
498                                    return Err(A::Error::unknown_field(
499                                        other,
500                                        &["paint", "color", "width", "cap", "join", "dash", "rule"],
501                                    ));
502                                }
503                            }
504                        }
505                        Ok(content)
506                    }
507                    fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
508                    where
509                        A: serde::de::SeqAccess<'de>,
510                    {
511                        use serde::de::Error as _;
512                        // Postcard encodes struct-variant content positionally
513                        // (no keys), in derived declaration order.
514                        let mut content = StyleCompatContent {
515                            paint: Some(
516                                seq.next_element()?
517                                    .ok_or_else(|| A::Error::invalid_length(0, &"paint"))?,
518                            ),
519                            ..Default::default()
520                        };
521                        if self.fill {
522                            content.rule = Some(
523                                seq.next_element()?
524                                    .ok_or_else(|| A::Error::invalid_length(1, &"rule"))?,
525                            );
526                        } else {
527                            content.width = Some(
528                                seq.next_element()?
529                                    .ok_or_else(|| A::Error::invalid_length(1, &"width"))?,
530                            );
531                            content.cap = Some(
532                                seq.next_element()?
533                                    .ok_or_else(|| A::Error::invalid_length(2, &"cap"))?,
534                            );
535                            content.join = Some(
536                                seq.next_element()?
537                                    .ok_or_else(|| A::Error::invalid_length(3, &"join"))?,
538                            );
539                            content.dash = seq
540                                .next_element::<Option<AnimatedDash>>()?
541                                .ok_or_else(|| A::Error::invalid_length(4, &"dash"))?;
542                        }
543                        Ok(content)
544                    }
545                }
546                let (tag, content) = data.variant::<StyleTag>()?;
547                let content = match tag {
548                    StyleTag::Fill => {
549                        content.struct_variant(&["paint", "rule"], ContentVisitor { fill: true })?
550                    }
551                    StyleTag::Stroke => content.struct_variant(
552                        &["paint", "width", "cap", "join", "dash"],
553                        ContentVisitor { fill: false },
554                    )?,
555                };
556                let paint = match content.paint {
557                    Some(p) => p,
558                    None => match content.color {
559                        Some(color) => StylePaint::Solid { color },
560                        None => return Err(A::Error::missing_field("paint")),
561                    },
562                };
563                match tag {
564                    StyleTag::Fill => Ok(StyleKind::Fill {
565                        paint,
566                        rule: content
567                            .rule
568                            .ok_or_else(|| A::Error::missing_field("rule"))?,
569                    }),
570                    StyleTag::Stroke => Ok(StyleKind::Stroke {
571                        paint,
572                        width: content
573                            .width
574                            .ok_or_else(|| A::Error::missing_field("width"))?,
575                        cap: content.cap.ok_or_else(|| A::Error::missing_field("cap"))?,
576                        join: content
577                            .join
578                            .ok_or_else(|| A::Error::missing_field("join"))?,
579                        dash: content.dash,
580                    }),
581                }
582            }
583        }
584
585        deserializer.deserialize_enum("StyleKind", &["Fill", "Stroke"], StyleKindVisitor)
586    }
587}
588
589/// Serde default for a scalar animation pinned to a constant `1.0`.
590fn animated_one() -> Animated<f64> {
591    Animated::new(1.0)
592}
593
594#[derive(Clone, Debug, Serialize, Deserialize)]
595pub enum ModifierKind {
596    TrimPath {
597        start: Animated<f64>,
598        end: Animated<f64>,
599        offset: Animated<f64>,
600        #[serde(default)]
601        mode: TrimMode,
602    },
603    Repeater {
604        copies: Animated<f64>,
605        offset: Animated<f64>,
606        transform: AnimatedTransform,
607        /// Opacity of the first copy (0..=1). Lottie `so` / 100.
608        #[serde(default = "animated_one")]
609        start_opacity: Animated<f64>,
610        /// Opacity of the last copy (0..=1). Lottie `eo` / 100.
611        #[serde(default = "animated_one")]
612        end_opacity: Animated<f64>,
613    },
614    RoundCorners {
615        radius: Animated<f64>,
616    },
617    OffsetPath {
618        amount: Animated<f64>,
619    },
620    ZigZag {
621        amplitude: Animated<f64>,
622        frequency: Animated<f64>,
623        /// false = corner zig-zag; true = smooth wave (cubic).
624        #[serde(default)]
625        smooth: bool,
626    },
627    PuckerBloat {
628        /// Percent. Positive = bloat, negative = pucker.
629        amount: Animated<f64>,
630    },
631}
632
633/// How Trim distributes [start, end] across multiple accumulated paths.
634#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
635pub enum TrimMode {
636    /// Each path is trimmed to [start, end] of its own perimeter.
637    #[default]
638    Individually,
639    /// The concatenation of all paths is treated as one arc-length domain.
640    Simultaneously,
641}
642
643#[derive(Clone, Debug, Serialize, Deserialize)]
644pub struct TimeMap {
645    pub offset: Frame,
646    pub stretch: f64,
647}
648
649fn default_text_size() -> Animated<f64> {
650    Animated::new(48.0)
651}
652
653#[derive(Clone, Debug, Serialize, Deserialize)]
654pub struct TextNode {
655    pub text: String,
656    /// Em size in document units. The one animatable text property (strings
657    /// aren't tweenable, so content/align/font are whole-field structural).
658    #[serde(default = "default_text_size")]
659    pub size: Animated<f64>,
660    #[serde(default)]
661    pub align: TextAlign,
662    /// Reserved for document-embedded fonts; `None` = bundled default.
663    #[serde(default)]
664    pub font: Option<String>,
665}
666
667#[derive(Clone, Debug, Serialize, Deserialize)]
668pub struct MaskProps {
669    pub inverted: bool,
670
671    /// The actual vector geometry of the mask.
672    ///
673    /// Defaults to an empty path so legacy documents deserialize safely.
674    #[serde(default)]
675    pub shape: ShapeKind,
676}
677
678impl Default for ShapeKind {
679    fn default() -> Self {
680        ShapeKind::Path(Animated::new(renamite_geometry::VectorPath::default()))
681    }
682}
683
684#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
685pub struct Color {
686    pub r: f64,
687    pub g: f64,
688    pub b: f64,
689    pub a: f64,
690}
691
692impl Color {
693    pub const BLACK: Self = Self {
694        r: 0.0,
695        g: 0.0,
696        b: 0.0,
697        a: 1.0,
698    };
699    pub const WHITE: Self = Self {
700        r: 1.0,
701        g: 1.0,
702        b: 1.0,
703        a: 1.0,
704    };
705    pub fn rgba(r: f64, g: f64, b: f64, a: f64) -> Self {
706        Self { r, g, b, a }
707    }
708}
709
710impl Tween for Color {
711    fn tween(a: &Self, b: &Self, t: f64) -> Self {
712        Self {
713            r: a.r + (b.r - a.r) * t,
714            g: a.g + (b.g - a.g) * t,
715            b: a.b + (b.b - a.b) * t,
716            a: a.a + (b.a - a.a) * t,
717        }
718    }
719}
720
721#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
722pub enum FillRule {
723    #[default]
724    NonZero,
725    EvenOdd,
726}
727#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
728pub enum StrokeCap {
729    Butt,
730    Round,
731    Square,
732}
733#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
734pub enum StrokeJoin {
735    Miter,
736    Round,
737    Bevel,
738}
739#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
740pub struct AnimatedDash {
741    pub dashes: Vec<Animated<f64>>,
742    pub offset: Animated<f64>,
743}
744#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
745pub enum StarKind {
746    Star,
747    Burst,
748}
749#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
750pub enum BlendMode {
751    Normal,
752    Multiply,
753    Screen,
754}
755#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
756pub enum Asset {
757    Image(ImageAsset),
758    Font(FontAsset),
759}
760
761fn default_true() -> bool {
762    true
763}
764
765/// An embedded image asset. Stores the original encoded PNG/JPEG/WebP bytes
766/// and the decoded pixel dimensions established at import time.
767#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
768pub struct ImageAsset {
769    pub name: String,
770    pub mime: String,
771
772    /// Original encoded PNG/JPEG/WebP bytes.
773    pub bytes: Vec<u8>,
774
775    /// Decoded pixel dimensions, established during import.
776    pub width: u32,
777    pub height: u32,
778
779    /// Decode/upload using an sRGB texture.
780    #[serde(default = "default_true")]
781    pub srgb: bool,
782}
783
784/// A project font: the user-visible name, the logical family key text nodes
785/// reference, and the raw TTF/OTF bytes (saved/loaded inside the project).
786#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
787pub struct FontAsset {
788    /// Display name in the UI (e.g. "Inter-Regular.ttf").
789    pub name: String,
790    /// Logical family key that `TextNode.font` references.
791    pub family: String,
792    /// Raw TTF/OTF bytes.
793    pub bytes: Vec<u8>,
794}
795
796#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
797pub struct Scene {
798    pub items: Vec<SceneItem>,
799    pub clips: Vec<ClipPath>,
800}
801
802#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
803pub struct SceneItem {
804    /// World space, transforms folded in.
805    pub path: BezPath,
806    /// The *shape* node that produced this geometry (used for picking).
807    pub node: NodeId,
808    /// The style node (Fill/Stroke) whose paint produced this item. Lets the
809    /// gradient tool / inspector target the exact style to edit.
810    pub style: NodeId,
811    /// Resolved paint. Gradient coordinates are in world space (the owning
812    /// shape's local transform is folded in during evaluation), matching the
813    /// vertex positions the renderer bakes colors from.
814    pub paint: ScenePaint,
815    pub kind: PaintKind,
816    pub opacity: f64,
817
818    /// Clip stack applied to this item, outermost → innermost.
819    #[serde(default)]
820    pub clips: Vec<u32>,
821
822    pub blend: BlendMode,
823}
824
825#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
826pub enum PaintKind {
827    Fill(FillRule),
828    Stroke(StrokeSample),
829}
830
831#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
832pub struct StrokeSample {
833    pub width: f64,
834    pub cap: StrokeCap,
835    pub join: StrokeJoin,
836    pub dash: Option<DashSample>,
837}
838#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
839pub struct DashSample {
840    pub dashes: Vec<f64>,
841    pub offset: f64,
842}
843
844/// Resolved, per-frame paint attached to a scene item. The renderer bakes
845/// this into mesh vertex colors at tessellation time.
846#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
847pub enum ScenePaint {
848    Solid(Color),
849    LinearGradient {
850        start: glam::DVec2,
851        end: glam::DVec2,
852        stops: GradientStops,
853    },
854    RadialGradient {
855        center: glam::DVec2,
856        end: glam::DVec2,
857        stops: GradientStops,
858    },
859    Image {
860        asset: AssetId,
861
862        /// Local image rectangle dimensions.
863        width: u32,
864        height: u32,
865
866        /// Full local-image → world affine.
867        affine: [f64; 6],
868
869        /// Multiplicative tint. WHITE means unchanged.
870        tint: Color,
871    },
872}
873
874impl ScenePaint {
875    /// Color at world-space position `p` (used by vertex baking).
876    pub fn color_at(&self, p: glam::DVec2) -> Color {
877        match self {
878            ScenePaint::Solid(c) => *c,
879            ScenePaint::Image { tint, .. } => *tint,
880            ScenePaint::LinearGradient { start, end, stops } => {
881                let d = *end - *start;
882                let len2 = d.length_squared().max(1e-12);
883                let t = ((p - *start).dot(d) / len2).clamp(0.0, 1.0);
884                stops.sample(t)
885            }
886            ScenePaint::RadialGradient { center, end, stops } => {
887                let r = (*end - *center).length().max(1e-12);
888                let t = ((p - *center).length() / r).clamp(0.0, 1.0);
889                stops.sample(t)
890            }
891        }
892    }
893}
894
895#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
896pub struct ClipPath {
897    pub path: BezPath,
898    #[serde(default)]
899    pub rule: FillRule,
900}
901
902/// Per-frame property patch. Produced by clip/state-machine playback,
903/// consumed by `evaluate_with`. Never touches the document.
904#[derive(Clone, Debug, Default, PartialEq)]
905pub struct Overrides {
906    pub values: std::collections::HashMap<(NodeId, PropPath), Value>,
907}
908
909impl Overrides {
910    pub fn set(&mut self, id: NodeId, prop: PropPath, v: Value) {
911        self.values.insert((id, prop), v);
912    }
913    /// TODO(perf): intern PropPath (u16 ids) to kill this per-lookup alloc.
914    pub fn get(&self, id: NodeId, prop: &str) -> Option<&Value> {
915        self.values.get(&(id, PropPath::new(prop)))
916    }
917    pub fn is_empty(&self) -> bool {
918        self.values.is_empty()
919    }
920    pub fn clear(&mut self) {
921        self.values.clear();
922    }
923}
924
925fn ov_f64(ov: &Overrides, id: NodeId, prop: &str, dflt: f64) -> f64 {
926    match ov.get(id, prop) {
927        Some(Value::F64(x)) => *x,
928        _ => dflt,
929    }
930}
931fn ov_vec2(ov: &Overrides, id: NodeId, prop: &str, dflt: glam::DVec2) -> glam::DVec2 {
932    match ov.get(id, prop) {
933        Some(Value::DVec2(x)) => *x,
934        _ => dflt,
935    }
936}
937fn ov_angle(ov: &Overrides, id: NodeId, prop: &str, dflt: f64) -> f64 {
938    match ov.get(id, prop) {
939        Some(Value::Angle(a)) => a.0,
940        Some(Value::F64(x)) => *x,
941        _ => dflt,
942    }
943}
944fn ov_color(ov: &Overrides, id: NodeId, prop: &str, dflt: Color) -> Color {
945    match ov.get(id, prop) {
946        Some(Value::Color(c)) => *c,
947        _ => dflt,
948    }
949}
950
951fn sample_transform(
952    n: &Node,
953    id: NodeId,
954    frame: f64,
955    ov: &Overrides,
956) -> renamite_animation::TransformSample {
957    let mut ts = n.transform.sample(frame);
958    if !ov.is_empty() {
959        ts.anchor = ov_vec2(ov, id, "transform.anchor", ts.anchor);
960        ts.position = ov_vec2(ov, id, "transform.position", ts.position);
961        ts.scale = ov_vec2(ov, id, "transform.scale", ts.scale);
962        ts.rotation_deg = ov_angle(ov, id, "transform.rotation", ts.rotation_deg);
963        ts.skew = ov_f64(ov, id, "transform.skew", ts.skew);
964        ts.skew_axis = ov_f64(ov, id, "transform.skew_axis", ts.skew_axis);
965    }
966    ts
967}
968
969/// The topmost fill style that paints `shape`: the last Fill style sibling
970/// in the closest ancestor scope that has one. Used by the inspector to edit
971/// a shape's fill (the tool instead tracks the exact style via `SceneItem`).
972pub fn fill_style_for(doc: &Document, shape: NodeId) -> Option<NodeId> {
973    let mut scope = doc.locate(shape).map(|(p, _)| p)?;
974    loop {
975        let children: Vec<NodeId> = match scope {
976            Parent::Comp(c) => doc.compositions.get(c)?.children.clone(),
977            Parent::Node(p) => doc.nodes.get(p)?.children.clone(),
978        };
979        if let Some(fill) = children.iter().rev().find(|id| {
980            matches!(
981                doc.nodes.get(**id).map(|n| &n.kind),
982                Some(NodeKind::Style(StyleKind::Fill { .. }))
983            )
984        }) {
985            return Some(*fill);
986        }
987        match scope {
988            Parent::Comp(_) => return None,
989            Parent::Node(p) => scope = doc.locate(p).map(|(parent, _)| parent)?,
990        }
991    }
992}
993
994/// Topmost stroke style in the closest ancestor scope that has one.
995pub fn stroke_style_for(doc: &Document, shape: NodeId) -> Option<NodeId> {
996    let mut scope = doc.locate(shape).map(|(p, _)| p)?;
997    loop {
998        let children: Vec<NodeId> = match scope {
999            Parent::Comp(c) => doc.compositions.get(c)?.children.clone(),
1000            Parent::Node(p) => doc.nodes.get(p)?.children.clone(),
1001        };
1002        if let Some(stroke) = children.iter().rev().find(|id| {
1003            matches!(
1004                doc.nodes.get(**id).map(|n| &n.kind),
1005                Some(NodeKind::Style(StyleKind::Stroke { .. }))
1006            )
1007        }) {
1008            return Some(*stroke);
1009        }
1010        match scope {
1011            Parent::Comp(_) => return None,
1012            Parent::Node(p) => scope = doc.locate(p).map(|(parent, _)| parent)?,
1013        }
1014    }
1015}
1016
1017/// The node's own transform as an affine, ignoring group/accumulated
1018/// transforms. Gradient handles are authored in this space and folded into
1019/// world space with this same affine during evaluation, so the inverse maps
1020/// world gradient handles back to local coordinates for editing.
1021pub fn node_affine(doc: &Document, id: NodeId, frame: f64) -> Affine {
1022    let Some(n) = doc.nodes.get(id) else {
1023        return Affine::IDENTITY;
1024    };
1025    affine_of(&sample_transform(n, id, frame, &Overrides::default()))
1026}
1027
1028fn linear_affine_of(sample: &renamite_animation::TransformSample) -> Affine {
1029    let axis = sample.skew_axis.to_radians();
1030    let skew = Affine::rotate(axis)
1031        * Affine::skew(sample.skew.to_radians().tan(), 0.0)
1032        * Affine::rotate(-axis);
1033    Affine::rotate(sample.rotation_deg.to_radians())
1034        * skew
1035        * Affine::scale_non_uniform(sample.scale.x / 100.0, sample.scale.y / 100.0)
1036}
1037
1038fn affine_of(sample: &renamite_animation::TransformSample) -> Affine {
1039    Affine::translate((sample.position.x, sample.position.y))
1040        * linear_affine_of(sample)
1041        * Affine::translate((-sample.anchor.x, -sample.anchor.y))
1042}
1043
1044/// Resolved transform information for one node at an editor frame.
1045#[derive(Clone, Copy, Debug)]
1046pub struct NodeTransformContext {
1047    /// Transform from the node's parent coordinate space into world space.
1048    pub parent_world: Affine,
1049
1050    /// Linear part of this node's transform: rotation * skew * scale.
1051    /// Does not contain position or anchor translations.
1052    pub linear: Affine,
1053
1054    /// Full node-local to parent transform.
1055    pub local: Affine,
1056
1057    /// Full node-local to world transform.
1058    pub world: Affine,
1059
1060    /// Effective frame after ancestor layer time-stretch mappings.
1061    pub frame: f64,
1062
1063    /// Node position in parent coordinates.
1064    pub position: glam::DVec2,
1065
1066    /// Node anchor/pivot in local coordinates.
1067    pub anchor: glam::DVec2,
1068
1069    /// Pivot location in world coordinates.
1070    pub pivot_world: glam::DVec2,
1071}
1072
1073fn node_effective_frame(node: &Node, incoming_frame: f64) -> f64 {
1074    match &node.kind {
1075        NodeKind::Layer(layer) => {
1076            (incoming_frame - layer.in_frame.0 as f64) / layer.time_stretch.max(1e-9)
1077                + layer.in_frame.0 as f64
1078        }
1079
1080        _ => incoming_frame,
1081    }
1082}
1083
1084/// Resolve one attached node's parent/world transforms.
1085///
1086/// This follows the node's actual parent chain and applies the same Layer
1087/// time-stretch convention used by the evaluator. It intentionally does not
1088/// traverse through `Precomp` references because precomposition contents live
1089/// in a separate composition tree.
1090pub fn node_transform_context(
1091    doc: &Document,
1092    id: NodeId,
1093    root_frame: f64,
1094) -> Option<NodeTransformContext> {
1095    let mut chain = Vec::new();
1096    let mut current = id;
1097
1098    loop {
1099        chain.push(current);
1100
1101        let node = doc.nodes.get(current)?;
1102
1103        let Some(parent) = node.parent else {
1104            break;
1105        };
1106
1107        current = parent;
1108    }
1109
1110    chain.reverse();
1111
1112    let mut parent_world = Affine::IDENTITY;
1113    let mut frame = root_frame;
1114
1115    for current in chain {
1116        let node = doc.nodes.get(current)?;
1117        let effective = node_effective_frame(node, frame);
1118        let sample = node.transform.sample(effective);
1119        let linear = linear_affine_of(&sample);
1120        let local = affine_of(&sample);
1121
1122        if current == id {
1123            let pivot = parent_world * Point::new(sample.position.x, sample.position.y);
1124
1125            return Some(NodeTransformContext {
1126                parent_world,
1127                linear,
1128                local,
1129                world: parent_world * local,
1130                frame: effective,
1131                position: sample.position,
1132                anchor: sample.anchor,
1133                pivot_world: glam::DVec2::new(pivot.x, pivot.y),
1134            });
1135        }
1136
1137        parent_world *= local;
1138        frame = effective;
1139    }
1140
1141    None
1142}
1143
1144const SHAPE_TOL: f64 = 0.1;
1145
1146/// The vector outline of a shape (or mask shape) node's geometry in its own
1147/// local coordinate space at `frame`, honoring any `shape.*` overrides.
1148pub fn shape_path(kind: &ShapeKind, id: NodeId, frame: f64, ov: &Overrides) -> BezPath {
1149    match kind {
1150        ShapeKind::Path(p) => {
1151            if let Some(Value::Path(p)) = ov.get(id, "shape.path") {
1152                return p.to_bez_path();
1153            }
1154            p.value_at(frame).to_bez_path()
1155        }
1156        ShapeKind::Rect { pos, size, rounded } => {
1157            let c = ov_vec2(ov, id, "shape.pos", pos.value_at(frame));
1158            let s = ov_vec2(ov, id, "shape.size", size.value_at(frame));
1159            let r = kurbo::Rect::from_center_size((c.x, c.y), (s.x.abs(), s.y.abs()));
1160            let radius = ov_f64(ov, id, "shape.rounded", rounded.value_at(frame));
1161            if radius > 1e-9 {
1162                kurbo::RoundedRect::from_rect(r, radius).to_path(SHAPE_TOL)
1163            } else {
1164                r.to_path(SHAPE_TOL)
1165            }
1166        }
1167        ShapeKind::Ellipse { pos, size } => {
1168            let c = ov_vec2(ov, id, "shape.pos", pos.value_at(frame));
1169            let s = ov_vec2(ov, id, "shape.size", size.value_at(frame));
1170            kurbo::Ellipse::new((c.x, c.y), (s.x.abs() / 2.0, s.y.abs() / 2.0), 0.0)
1171                .to_path(SHAPE_TOL)
1172        }
1173        ShapeKind::Star {
1174            pos,
1175            points,
1176            inner_r,
1177            outer_r,
1178            roundness,
1179            kind,
1180        } => {
1181            let pts = ov_f64(ov, id, "shape.points", points.value_at(frame))
1182                .round()
1183                .max(3.0) as usize;
1184            let outer = ov_f64(ov, id, "shape.outer_r", outer_r.value_at(frame));
1185            let inner = match kind {
1186                // Burst ≈ polygon on the star node: outer ring only (Lottie sy=2).
1187                StarKind::Burst => None,
1188                StarKind::Star => Some(ov_f64(ov, id, "shape.inner_r", inner_r.value_at(frame))),
1189            };
1190            star_path(
1191                ov_vec2(ov, id, "shape.pos", pos.value_at(frame)),
1192                pts,
1193                inner,
1194                outer,
1195                ov_f64(ov, id, "shape.roundness", roundness.value_at(frame)).max(0.0),
1196            )
1197        }
1198        ShapeKind::Polygon {
1199            pos,
1200            points,
1201            outer_r,
1202            roundness,
1203        } => star_path(
1204            ov_vec2(ov, id, "shape.pos", pos.value_at(frame)),
1205            ov_f64(ov, id, "shape.points", points.value_at(frame))
1206                .round()
1207                .max(3.0) as usize,
1208            None,
1209            ov_f64(ov, id, "shape.outer_r", outer_r.value_at(frame)),
1210            ov_f64(ov, id, "shape.roundness", roundness.value_at(frame)).max(0.0),
1211        ),
1212        ShapeKind::CompoundPath(compound) => compound.to_bez_path(frame),
1213    }
1214}
1215
1216/// Star/polygon outline. `roundness` is corner radius in local units (0 = sharp).
1217/// Matches RoundCorners modifier semantics (not Lottie % outer-roundness).
1218fn star_path(
1219    center: glam::DVec2,
1220    points: usize,
1221    inner: Option<f64>,
1222    outer: f64,
1223    roundness: f64,
1224) -> BezPath {
1225    let n = if inner.is_some() { points * 2 } else { points };
1226    let mut anchors = Vec::with_capacity(n);
1227    for k in 0..n {
1228        let ang = -std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU * k as f64 / n as f64;
1229        let r = match inner {
1230            Some(ir) if k % 2 == 1 => ir,
1231            _ => outer,
1232        };
1233        anchors.push(renamite_geometry::Anchor::corner(glam::DVec2::new(
1234            center.x + r * ang.cos(),
1235            center.y + r * ang.sin(),
1236        )));
1237    }
1238    let sharp = renamite_geometry::VectorPath {
1239        closed: true,
1240        anchors,
1241    };
1242    if roundness <= 1e-9 {
1243        return sharp.to_bez_path();
1244    }
1245    sharp.round_corners(roundness).to_bez_path()
1246}
1247
1248pub fn evaluate(doc: &Document, comp: CompId, frame: f64) -> Scene {
1249    evaluate_with(doc, comp, frame, &Overrides::default())
1250}
1251
1252fn mask_shape_path(shape: &ShapeKind, id: NodeId, frame: f64, ov: &Overrides) -> BezPath {
1253    shape_path(shape, id, frame, ov)
1254}
1255
1256fn inverted_clip_path(scope_world: &BezPath, mask_world: &BezPath) -> ClipPath {
1257    let mut path = scope_world.clone();
1258    path.extend(mask_world.clone());
1259    ClipPath {
1260        path,
1261        rule: FillRule::EvenOdd,
1262    }
1263}
1264
1265pub fn evaluate_with(doc: &Document, comp: CompId, frame: f64, ov: &Overrides) -> Scene {
1266    let mut scene = Scene::default();
1267    if let Some(c) = doc.compositions.get(comp) {
1268        let scope = kurbo::Rect::new(0.0, 0.0, c.size.0 as f64, c.size.1 as f64);
1269        eval_group(
1270            doc,
1271            &c.children,
1272            frame,
1273            Affine::IDENTITY,
1274            1.0,
1275            BlendMode::Normal,
1276            &mut scene,
1277            0,
1278            ov,
1279            scope,
1280            &[],
1281            &[],
1282        );
1283    }
1284    scene
1285}
1286
1287const MAX_DEPTH: u32 = 32; // precomp cycle guard
1288
1289fn eval_group(
1290    doc: &Document,
1291    children: &[NodeId],
1292    frame: f64,
1293    tf: Affine,
1294    opacity: f64,
1295    blend: BlendMode,
1296    scene: &mut Scene,
1297    depth: u32,
1298    ov: &Overrides,
1299    scope_rect: kurbo::Rect,
1300    inherited_clips: &[u32],
1301    seed_paths: &[ShapeEntry],
1302) {
1303    if depth > MAX_DEPTH {
1304        return;
1305    }
1306
1307    // Pass 1: accumulate shape paths + modifiers, in document order.
1308    let mut paths: Vec<ShapeEntry> = seed_paths.to_vec();
1309    for &id in children {
1310        let Some(n) = doc.nodes.get(id) else { continue };
1311        if !n.visible {
1312            continue;
1313        }
1314        match &n.kind {
1315            NodeKind::Shape(s) => {
1316                let ntf = affine_of(&sample_transform(n, id, frame, ov));
1317                paths.push(ShapeEntry {
1318                    node: id,
1319                    affine: ntf,
1320                    opacity: 1.0,
1321                    path: tf * ntf * shape_path(s, id, frame, ov),
1322                });
1323            }
1324            NodeKind::Text(t) => {
1325                let ntf = affine_of(&sample_transform(n, id, frame, ov));
1326                let size = ov_f64(ov, id, "text.size", t.size.value_at(frame)).max(0.1);
1327                // Prefer an embedded project font by family.
1328                let outline = if let Some((_, font)) =
1329                    t.font.as_deref().and_then(|f| doc.font_asset_for_family(f))
1330                {
1331                    renamite_text::shape_text_from_bytes(&font.bytes, &t.text, size, t.align)
1332                        .unwrap_or_else(|_| {
1333                            renamite_text::shape_text_default(&t.text, size, t.align)
1334                        })
1335                } else {
1336                    renamite_text::shape_text_default(&t.text, size, t.align)
1337                };
1338                paths.push(ShapeEntry {
1339                    node: id,
1340                    affine: ntf,
1341                    opacity: 1.0,
1342                    path: tf * ntf * outline,
1343                });
1344            }
1345            NodeKind::Modifier(m) => apply_modifier(m, id, frame, ov, &mut paths),
1346            NodeKind::Mask(_) => {}
1347            _ => {}
1348        }
1349    }
1350
1351    // Pass 2: resolve the clip stack for every sibling.
1352    let mut active: Vec<Vec<u32>> = Vec::with_capacity(children.len());
1353    let mut acc = inherited_clips.to_vec();
1354    for &id in children {
1355        active.push(acc.clone());
1356        let Some(n) = doc.nodes.get(id) else { continue };
1357        if !n.visible {
1358            continue;
1359        }
1360        if let NodeKind::Mask(mask) = &n.kind {
1361            let local = affine_of(&sample_transform(n, id, frame, ov));
1362            let world_mask = tf * local * mask_shape_path(&mask.shape, id, frame, ov);
1363            let clip = if mask.inverted {
1364                inverted_clip_path(&(tf * scope_rect.to_path(0.1)), &world_mask)
1365            } else {
1366                ClipPath {
1367                    path: world_mask,
1368                    rule: FillRule::NonZero,
1369                }
1370            };
1371            scene.clips.push(clip);
1372            acc.push((scene.clips.len() - 1) as u32);
1373        }
1374    }
1375
1376    // Pass 3: emit bottom-first (painter's order), carrying each child's
1377    // active clip stack down into recursion and style emission.
1378    for (i, &id) in children.iter().enumerate().rev() {
1379        let Some(n) = doc.nodes.get(id) else { continue };
1380        if !n.visible {
1381            continue;
1382        }
1383        let node_op =
1384            opacity * ov_f64(ov, id, "opacity", n.opacity.value_at(frame)).clamp(0.0, 1.0);
1385        let clips = &active[i];
1386        match &n.kind {
1387            NodeKind::Mask(_) => {}
1388            NodeKind::Group => {
1389                let ntf = tf * affine_of(&sample_transform(n, id, frame, ov));
1390                eval_group(
1391                    doc,
1392                    &n.children,
1393                    frame,
1394                    ntf,
1395                    node_op,
1396                    blend,
1397                    scene,
1398                    depth + 1,
1399                    ov,
1400                    scope_rect,
1401                    clips,
1402                    &[],
1403                );
1404            }
1405            NodeKind::Layer(lp) => {
1406                if frame < lp.in_frame.0 as f64 || frame > lp.out_frame.0 as f64 {
1407                    continue;
1408                }
1409                let lf = (frame - lp.in_frame.0 as f64) / lp.time_stretch.max(1e-9)
1410                    + lp.in_frame.0 as f64;
1411                let ntf = tf * affine_of(&sample_transform(n, id, lf, ov));
1412                eval_group(
1413                    doc,
1414                    &n.children,
1415                    lf,
1416                    ntf,
1417                    node_op,
1418                    lp.blend,
1419                    scene,
1420                    depth + 1,
1421                    ov,
1422                    scope_rect,
1423                    clips,
1424                    &[],
1425                );
1426            }
1427            NodeKind::Image(asset_id) => {
1428                let Some(asset) = doc.image_asset(*asset_id) else {
1429                    continue;
1430                };
1431
1432                let node_transform = affine_of(&sample_transform(n, id, frame, ov));
1433                let full_transform = tf * node_transform;
1434
1435                let local_rect =
1436                    kurbo::Rect::new(0.0, 0.0, asset.width as f64, asset.height as f64);
1437
1438                let world_path = full_transform * local_rect.to_path(0.1);
1439
1440                scene.items.push(SceneItem {
1441                    path: world_path,
1442                    node: id,
1443                    style: id,
1444                    paint: ScenePaint::Image {
1445                        asset: *asset_id,
1446                        width: asset.width,
1447                        height: asset.height,
1448                        affine: full_transform.as_coeffs(),
1449                        tint: Color::WHITE,
1450                    },
1451                    kind: PaintKind::Fill(FillRule::NonZero),
1452                    opacity: node_op,
1453                    clips: clips.to_vec(),
1454                    blend,
1455                });
1456            }
1457            NodeKind::Precomp { comp, time_map } => {
1458                let ntf = tf * affine_of(&sample_transform(n, id, frame, ov));
1459                let cf = (frame - time_map.offset.0 as f64) / time_map.stretch.max(1e-9);
1460                if let Some(c) = doc.compositions.get(*comp) {
1461                    let pre_scope = kurbo::Rect::new(0.0, 0.0, c.size.0 as f64, c.size.1 as f64);
1462                    eval_group(
1463                        doc,
1464                        &c.children,
1465                        cf,
1466                        ntf,
1467                        node_op,
1468                        blend,
1469                        scene,
1470                        depth + 1,
1471                        ov,
1472                        pre_scope,
1473                        clips,
1474                        &[],
1475                    );
1476                }
1477            }
1478            NodeKind::Style(st) => emit_style(st, id, frame, ov, &paths, node_op, blend, clips, scene),
1479            NodeKind::Shape(_) | NodeKind::Text(_) if !n.children.is_empty() => {
1480                let seeds: Vec<ShapeEntry> =
1481                    paths.iter().filter(|e| e.node == id).cloned().collect();
1482                eval_group(
1483                    doc,
1484                    &n.children,
1485                    frame,
1486                    tf,
1487                    node_op,
1488                    blend,
1489                    scene,
1490                    depth + 1,
1491                    ov,
1492                    scope_rect,
1493                    clips,
1494                    &seeds,
1495                );
1496            }
1497            _ => {}
1498        }
1499    }
1500}
1501
1502/// One accumulated shape path in pass 1 of group evaluation, carrying the
1503/// shape's local affine (gradient folding) and a per-copy opacity factor
1504/// (repeater falloff) that rides along until style emission.
1505#[derive(Clone)]
1506struct ShapeEntry {
1507    node: NodeId,
1508    affine: Affine,
1509    opacity: f64,
1510    path: BezPath,
1511}
1512
1513fn apply_modifier(
1514    m: &ModifierKind,
1515    id: NodeId,
1516    frame: f64,
1517    ov: &Overrides,
1518    paths: &mut Vec<ShapeEntry>,
1519) {
1520    match m {
1521        ModifierKind::Repeater {
1522            copies,
1523            offset,
1524            transform,
1525            start_opacity,
1526            end_opacity,
1527        } => {
1528            let count = ov_f64(ov, id, "repeater.copies", copies.value_at(frame))
1529                .round()
1530                .max(0.0) as usize;
1531            let off = ov_f64(ov, id, "repeater.offset", offset.value_at(frame));
1532            let so = ov_f64(
1533                ov,
1534                id,
1535                "repeater.start_opacity",
1536                start_opacity.value_at(frame),
1537            )
1538            .clamp(0.0, 1.0);
1539            let eo =
1540                ov_f64(ov, id, "repeater.end_opacity", end_opacity.value_at(frame)).clamp(0.0, 1.0);
1541            let mut ts = transform.sample(frame);
1542            ts.position = ov_vec2(ov, id, "repeater.transform.position", ts.position);
1543            ts.scale = ov_vec2(ov, id, "repeater.transform.scale", ts.scale);
1544            ts.rotation_deg = ov_angle(ov, id, "repeater.transform.rotation", ts.rotation_deg);
1545            ts.anchor = ov_vec2(ov, id, "repeater.transform.anchor", ts.anchor);
1546            ts.skew = ov_f64(ov, id, "repeater.transform.skew", ts.skew);
1547            ts.skew_axis = ov_f64(ov, id, "repeater.transform.skew_axis", ts.skew_axis);
1548            let step = affine_of(&ts);
1549            let original = std::mem::take(paths);
1550            let n = count.max(1);
1551            for i in 0..n {
1552                // Linear falloff: first copy = so, last copy = eo.
1553                let t = if n <= 1 {
1554                    0.0
1555                } else {
1556                    i as f64 / (n - 1) as f64
1557                };
1558                let copy_opacity = so + (eo - so) * t;
1559
1560                let mut a = Affine::IDENTITY;
1561                let reps = (i as f64 + off).max(0.0) as usize;
1562                for _ in 0..reps {
1563                    a *= step;
1564                }
1565                for e in &original {
1566                    paths.push(ShapeEntry {
1567                        node: e.node,
1568                        affine: e.affine,
1569                        opacity: e.opacity * copy_opacity,
1570                        path: a * e.path.clone(),
1571                    });
1572                }
1573            }
1574        }
1575        ModifierKind::TrimPath {
1576            start,
1577            end,
1578            offset,
1579            mode,
1580        } => {
1581            let mut s = ov_f64(ov, id, "trim.start", start.value_at(frame)).clamp(0.0, 1.0);
1582            let mut e = ov_f64(ov, id, "trim.end", end.value_at(frame)).clamp(0.0, 1.0);
1583            if s > e {
1584                std::mem::swap(&mut s, &mut e);
1585            }
1586            let o = ov_f64(ov, id, "trim.offset", offset.value_at(frame)).rem_euclid(1.0);
1587
1588            if (e - s).abs() < 1e-9 {
1589                paths.clear();
1590                return;
1591            }
1592
1593            let originals = std::mem::take(paths);
1594            match mode {
1595                TrimMode::Individually => {
1596                    for entry in originals {
1597                        if let Some(trimmed) = trim_path(&entry.path, s, e, o) {
1598                            paths.push(ShapeEntry {
1599                                path: trimmed,
1600                                ..entry
1601                            });
1602                        }
1603                    }
1604                }
1605                TrimMode::Simultaneously => {
1606                    let lengths: Vec<f64> = originals
1607                        .iter()
1608                        .map(|entry| entry.path.perimeter(1e-3))
1609                        .collect();
1610                    let total: f64 = lengths.iter().sum();
1611                    if total <= 1e-9 {
1612                        return;
1613                    }
1614                    let mut cursor = 0.0;
1615                    for (entry, len) in originals.into_iter().zip(lengths) {
1616                        let frac = len / total;
1617                        if frac > 1e-12 {
1618                            let ps = ((s - cursor) / frac).clamp(0.0, 1.0);
1619                            let pe = ((e - cursor) / frac).clamp(0.0, 1.0);
1620                            if pe > ps + 1e-9
1621                                && let Some(t) = trim_path(&entry.path, ps, pe, o)
1622                            {
1623                                paths.push(ShapeEntry { path: t, ..entry });
1624                            }
1625                        }
1626                        cursor += frac;
1627                    }
1628                }
1629            }
1630        }
1631        ModifierKind::RoundCorners { radius } => {
1632            let r = ov_f64(ov, id, "round.radius", radius.value_at(frame)).max(0.0);
1633            if r > 1e-9 {
1634                // RoundCorners needs anchor-level data, so round-trip each
1635                // flattened path back through `VectorPath` before rounding.
1636                // `from_bez_path` re-detects tangent modes from the flattened
1637                // geometry: already-curved (Smooth) paths pass through untouched,
1638                // while hard cuts (e.g. from a preceding Trim) detect as Corner
1639                // and get rounded - Lottie modifier-order semantics.
1640                for entry in paths.iter_mut() {
1641                    let vp = renamite_geometry::VectorPath::from_bez_path(&entry.path);
1642                    entry.path = vp.round_corners(r).to_bez_path();
1643                }
1644            }
1645        }
1646        ModifierKind::OffsetPath { amount } => {
1647            let amount = ov_f64(ov, id, "offset.amount", amount.value_at(frame));
1648            if amount.abs() > 1e-9 {
1649                for entry in paths.iter_mut() {
1650                    if let Some(offset) = offset_bez_path(&entry.path, amount, SHAPE_TOL) {
1651                        entry.path = offset;
1652                    }
1653                }
1654            }
1655        }
1656        ModifierKind::ZigZag {
1657            amplitude,
1658            frequency,
1659            smooth,
1660        } => {
1661            let amp = ov_f64(ov, id, "zigzag.amplitude", amplitude.value_at(frame));
1662            let freq = ov_f64(ov, id, "zigzag.frequency", frequency.value_at(frame));
1663            if amp.abs() > 1e-9 && freq.abs() > 1e-9 {
1664                for entry in paths.iter_mut() {
1665                    entry.path = renamite_geometry::zigzag_path(&entry.path, amp, freq, *smooth);
1666                }
1667            }
1668        }
1669        ModifierKind::PuckerBloat { amount } => {
1670            let amt = ov_f64(ov, id, "pucker.amount", amount.value_at(frame));
1671            if amt.abs() > 1e-9 {
1672                for entry in paths.iter_mut() {
1673                    let vp = renamite_geometry::VectorPath::from_bez_path(&entry.path);
1674                    entry.path =
1675                        renamite_geometry::pucker_bloat_vector_path(&vp, amt).to_bez_path();
1676                }
1677            }
1678        }
1679    }
1680}
1681
1682fn trim_path(path: &BezPath, s: f64, e: f64, offset: f64) -> Option<BezPath> {
1683    use kurbo::ParamCurveArclen;
1684
1685    if (e - s).abs() < 1e-9 {
1686        return None;
1687    }
1688
1689    let segments: Vec<kurbo::PathSeg> = path.segments().collect();
1690    if segments.is_empty() {
1691        return None;
1692    }
1693    let lengths: Vec<f64> = segments.iter().map(|seg| seg.arclen(1e-3)).collect();
1694    let total: f64 = lengths.iter().sum();
1695    if total <= 1e-9 {
1696        return None;
1697    }
1698
1699    let s_offset = s + offset;
1700    let e_offset = e + offset;
1701    // Wrap decision in the PRE-modulo domain: the interval touches/crosses 1.0.
1702    // (Comparing the rem_euclid'd `a` vs `b` is unreliable near the seam where
1703    // FP noise flips `<` and silently empties the span.)
1704    let wraps = s_offset < 1.0 && e_offset >= 1.0;
1705    let a = s_offset.rem_euclid(1.0);
1706    let b = e_offset.rem_euclid(1.0);
1707
1708    let mut out = BezPath::new();
1709    let mut last_end: Option<Point> = None;
1710    if wraps {
1711        // Wraps: [a, 1] then [0, b]. (a == b arises when e - s covers the
1712        // whole domain after offset; both halves together emit the full path.)
1713        emit_range(&segments, &lengths, total, a, 1.0, &mut out, &mut last_end);
1714        emit_range(&segments, &lengths, total, 0.0, b, &mut out, &mut last_end);
1715    } else {
1716        emit_range(&segments, &lengths, total, a, b, &mut out, &mut last_end);
1717    }
1718
1719    if out.elements().is_empty() {
1720        None
1721    } else {
1722        Some(out)
1723    }
1724}
1725
1726fn emit_range(
1727    segments: &[kurbo::PathSeg],
1728    lengths: &[f64],
1729    total: f64,
1730    a: f64,
1731    b: f64,
1732    out: &mut BezPath,
1733    last_end: &mut Option<Point>,
1734) {
1735    use kurbo::ParamCurve;
1736
1737    let a_len = a * total;
1738    let b_len = b * total;
1739    let mut cursor = 0.0;
1740
1741    for (seg, &len) in segments.iter().zip(lengths) {
1742        let seg_start = cursor;
1743        let seg_end = cursor + len;
1744        cursor = seg_end;
1745
1746        if seg_end <= a_len {
1747            continue;
1748        }
1749        if seg_start >= b_len {
1750            break;
1751        }
1752
1753        let t0 = if seg_start < a_len {
1754            arclen_to_t(seg, a_len - seg_start)
1755        } else {
1756            0.0
1757        };
1758        let t1 = if seg_end > b_len {
1759            arclen_to_t(seg, b_len - seg_start)
1760        } else {
1761            1.0
1762        };
1763        if t1 <= t0 + 1e-9 {
1764            continue;
1765        }
1766
1767        let sub = seg.subsegment(t0..t1);
1768        let start_pt = sub.start();
1769        // Continuity check: new subpath (MoveTo break / wrap seam) → move_to.
1770        let connected = last_end
1771            .map(|p| (p - start_pt).hypot() < 1e-6)
1772            .unwrap_or(false);
1773        if !connected {
1774            out.move_to(start_pt);
1775        }
1776        append_seg(out, &sub);
1777        *last_end = Some(sub.end());
1778    }
1779}
1780
1781fn arclen_to_t(seg: &kurbo::PathSeg, target: f64) -> f64 {
1782    use kurbo::{ParamCurve, ParamCurveArclen};
1783    let (mut lo, mut hi) = (0.0_f64, 1.0_f64);
1784    for _ in 0..24 {
1785        let mid = 0.5 * (lo + hi);
1786        if seg.subsegment(0.0..mid).arclen(1e-3) < target {
1787            lo = mid;
1788        } else {
1789            hi = mid;
1790        }
1791    }
1792    0.5 * (lo + hi)
1793}
1794
1795fn append_seg(out: &mut BezPath, seg: &kurbo::PathSeg) {
1796    match seg {
1797        kurbo::PathSeg::Line(l) => out.line_to(l.p1),
1798        kurbo::PathSeg::Quad(q) => out.quad_to(q.p1, q.p2),
1799        kurbo::PathSeg::Cubic(c) => out.curve_to(c.p1, c.p2, c.p3),
1800    }
1801}
1802
1803fn fold_gradient_point(affine: &Affine, local: glam::DVec2) -> glam::DVec2 {
1804    let p = *affine * Point::new(local.x, local.y);
1805    glam::DVec2::new(p.x, p.y)
1806}
1807
1808fn emit_style(
1809    st: &StyleKind,
1810    style_id: NodeId,
1811    frame: f64,
1812    ov: &Overrides,
1813    paths: &[ShapeEntry],
1814    opacity: f64,
1815    blend: BlendMode,
1816    active_clips: &[u32],
1817    scene: &mut Scene,
1818) {
1819    for e in paths {
1820        let (paint, kind, is_stroke) = match st {
1821            StyleKind::Fill { paint, rule } => (paint, PaintKind::Fill(*rule), false),
1822            StyleKind::Stroke {
1823                paint,
1824                width,
1825                cap,
1826                join,
1827                dash,
1828            } => (
1829                paint,
1830                PaintKind::Stroke(StrokeSample {
1831                    width: ov_f64(ov, style_id, "stroke.width", width.value_at(frame)).max(0.0),
1832                    cap: *cap,
1833                    join: *join,
1834                    dash: dash.as_ref().map(|d| DashSample {
1835                        dashes: d.dashes.iter().map(|x| x.value_at(frame)).collect(),
1836                        offset: d.offset.value_at(frame),
1837                    }),
1838                }),
1839                true,
1840            ),
1841        };
1842
1843        let paint = sample_paint_world(paint, frame, &e.affine, ov, style_id, is_stroke);
1844
1845        scene.items.push(SceneItem {
1846            path: e.path.clone(),
1847            node: e.node,
1848            style: style_id,
1849            paint,
1850            kind,
1851            opacity: opacity * e.opacity,
1852            clips: active_clips.to_vec(),
1853            blend,
1854        });
1855    }
1856}
1857
1858fn sample_paint_world(
1859    paint: &StylePaint,
1860    frame: f64,
1861    affine: &Affine,
1862    ov: &Overrides,
1863    style_id: NodeId,
1864    is_stroke: bool,
1865) -> ScenePaint {
1866    match paint {
1867        StylePaint::Solid { color } => {
1868            let path = if is_stroke {
1869                "stroke.color"
1870            } else {
1871                "fill.color"
1872            };
1873            ScenePaint::Solid(ov_color(ov, style_id, path, color.value_at(frame)))
1874        }
1875        StylePaint::Gradient(g) => {
1876            let kind = g.kind;
1877            let start_local = ov_vec2(ov, style_id, "grad.start", g.start.value_at(frame));
1878            let end_local = ov_vec2(ov, style_id, "grad.end", g.end.value_at(frame));
1879            let stops = ov_stops(ov, style_id, "grad.stops", &g.stops.value_at(frame));
1880            match kind {
1881                GradientKind::Linear => ScenePaint::LinearGradient {
1882                    start: fold_gradient_point(affine, start_local),
1883                    end: fold_gradient_point(affine, end_local),
1884                    stops,
1885                },
1886                GradientKind::Radial => ScenePaint::RadialGradient {
1887                    center: fold_gradient_point(affine, start_local),
1888                    end: fold_gradient_point(affine, end_local),
1889                    stops,
1890                },
1891            }
1892        }
1893    }
1894}
1895
1896fn ov_stops(ov: &Overrides, id: NodeId, prop: &str, dflt: &GradientStops) -> GradientStops {
1897    match ov.get(id, prop) {
1898        Some(Value::Stops(s)) => s.clone(),
1899        _ => dflt.clone(),
1900    }
1901}
1902
1903#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
1904pub enum Parent {
1905    Node(NodeId),
1906    Comp(CompId),
1907}
1908
1909#[derive(Clone, Debug, thiserror::Error)]
1910pub enum ModelError {
1911    #[error("node not found")]
1912    MissingNode,
1913    #[error("node kind mismatch (expected {0})")]
1914    WrongNodeKind(&'static str),
1915    #[error("composition not found")]
1916    MissingComp,
1917    #[error("no property at path {0}")]
1918    MissingProp(String),
1919    #[error("value type mismatch for {0}")]
1920    TypeMismatch(String),
1921    #[error("no keyframe at frame {0}")]
1922    NoKeyframe(i64),
1923    #[error("keyframe already exists at frame {0}")]
1924    KeyframeExists(i64),
1925    #[error("node is not attached")]
1926    NotAttached,
1927    #[error("asset not found")]
1928    MissingAsset,
1929}
1930
1931impl Document {
1932    pub fn empty() -> Self {
1933        let mut compositions = CompMap::default();
1934        let main = compositions.insert(Composition {
1935            name: "Main".into(),
1936            size: (512, 512),
1937            rate: renamite_animation::FrameRate { num: 60, den: 1 },
1938            range: (Frame(0), Frame(180)),
1939            children: Vec::new(),
1940        });
1941        Self {
1942            format_version: 1,
1943            compositions,
1944            nodes: NodeMap::default(),
1945            assets: AssetMap::default(),
1946            asset_order: Vec::new(),
1947            main,
1948        }
1949    }
1950
1951    pub fn create_node(&mut self, node: Node) -> NodeId {
1952        self.nodes.insert(node)
1953    }
1954
1955    pub fn attach(&mut self, id: NodeId, parent: Parent, index: usize) -> Result<(), ModelError> {
1956        if !self.nodes.contains_key(id) {
1957            return Err(ModelError::MissingNode);
1958        }
1959        match parent {
1960            Parent::Node(p) => {
1961                let pn = self.nodes.get_mut(p).ok_or(ModelError::MissingNode)?;
1962                let i = index.min(pn.children.len());
1963                pn.children.insert(i, id);
1964                self.nodes[id].parent = Some(p);
1965            }
1966            Parent::Comp(c) => {
1967                let comp = self
1968                    .compositions
1969                    .get_mut(c)
1970                    .ok_or(ModelError::MissingComp)?;
1971                let i = index.min(comp.children.len());
1972                comp.children.insert(i, id);
1973                self.nodes[id].parent = None;
1974            }
1975        }
1976        Ok(())
1977    }
1978
1979    pub fn detach(&mut self, id: NodeId) -> Result<(Parent, usize), ModelError> {
1980        let (parent, index) = self.locate(id).ok_or(ModelError::NotAttached)?;
1981        match parent {
1982            Parent::Node(p) => {
1983                self.nodes[p].children.remove(index);
1984            }
1985            Parent::Comp(c) => {
1986                self.compositions[c].children.remove(index);
1987            }
1988        }
1989        if let Some(n) = self.nodes.get_mut(id) {
1990            n.parent = None;
1991        }
1992        Ok((parent, index))
1993    }
1994
1995    pub fn locate(&self, id: NodeId) -> Option<(Parent, usize)> {
1996        let n = self.nodes.get(id)?;
1997        if let Some(p) = n.parent {
1998            let i = self.nodes.get(p)?.children.iter().position(|&c| c == id)?;
1999            return Some((Parent::Node(p), i));
2000        }
2001        for (cid, comp) in &self.compositions {
2002            if let Some(i) = comp.children.iter().position(|&c| c == id) {
2003                return Some((Parent::Comp(cid), i));
2004            }
2005        }
2006        None
2007    }
2008
2009    /// Drop arena nodes not reachable from any composition (call before save).
2010    pub fn garbage_collect(&mut self) {
2011        let mut live = std::collections::HashSet::new();
2012        fn mark(doc: &Document, id: NodeId, live: &mut std::collections::HashSet<NodeId>) {
2013            if !live.insert(id) {
2014                return;
2015            }
2016            if let Some(n) = doc.nodes.get(id) {
2017                for &c in &n.children {
2018                    mark(doc, c, live);
2019                }
2020            }
2021        }
2022        let roots: Vec<NodeId> = self
2023            .compositions
2024            .values()
2025            .flat_map(|c| c.children.clone())
2026            .collect();
2027        for r in roots {
2028            mark(self, r, &mut live);
2029        }
2030        self.nodes.retain(|id, _| live.contains(&id));
2031
2032        // Retain attached or node-referenced assets.
2033        let mut live_assets: std::collections::HashSet<AssetId> =
2034            self.asset_order.iter().copied().collect();
2035        for node in self.nodes.values() {
2036            if let NodeKind::Image(asset) = node.kind {
2037                live_assets.insert(asset);
2038            }
2039        }
2040        self.assets.retain(|id, _| live_assets.contains(&id));
2041        self.asset_order.retain(|id| self.assets.contains_key(*id));
2042    }
2043
2044    /// Rebuild `asset_order` to match the arena: every attached id must exist
2045    /// and be unique. Any arena asset missing from the order gets appended.
2046    /// (Call after loading legacy projects that predate `asset_order`.)
2047    pub fn normalize_assets(&mut self) {
2048        let mut seen = std::collections::HashSet::new();
2049
2050        self.asset_order
2051            .retain(|id| self.assets.contains_key(*id) && seen.insert(*id));
2052
2053        for id in self.assets.keys() {
2054            if seen.insert(id) {
2055                self.asset_order.push(id);
2056            }
2057        }
2058    }
2059
2060    /// The embedded image asset behind `id`, if it is an image.
2061    pub fn image_asset(&self, id: AssetId) -> Option<&ImageAsset> {
2062        match self.assets.get(id)? {
2063            Asset::Image(image) => Some(image),
2064            _ => None,
2065        }
2066    }
2067
2068    /// Number of image-layer nodes referencing `asset`.
2069    pub fn image_usage_count(&self, asset: AssetId) -> usize {
2070        self.nodes
2071            .values()
2072            .filter(|node| matches!(node.kind, NodeKind::Image(id) if id == asset))
2073            .count()
2074    }
2075
2076    /// The font asset whose family matches `family`, if the project has one.
2077    /// Surfaces the `AssetId` (for removal) alongside the asset.
2078    pub fn font_asset_for_family(&self, family: &str) -> Option<(AssetId, &FontAsset)> {
2079        self.assets.iter().find_map(|(id, asset)| match asset {
2080            Asset::Font(font) if font.family == family => Some((id, font)),
2081            _ => None,
2082        })
2083    }
2084
2085    /// Sorted, deduplicated family keys of every font asset in the project.
2086    pub fn font_families(&self) -> Vec<String> {
2087        let mut out: Vec<String> = self
2088            .asset_order
2089            .iter()
2090            .filter_map(|id| match self.assets.get(*id) {
2091                Some(Asset::Font(font)) => Some(font.family.clone()),
2092                _ => None,
2093            })
2094            .collect();
2095        out.sort();
2096        out.dedup();
2097        out
2098    }
2099}
2100
2101#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
2102pub struct PropPath(pub String);
2103
2104impl PropPath {
2105    pub fn new(s: impl Into<String>) -> Self {
2106        Self(s.into())
2107    }
2108    pub fn as_str(&self) -> &str {
2109        &self.0
2110    }
2111}
2112
2113#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
2114pub enum Value {
2115    F64(f64),
2116    DVec2(glam::DVec2),
2117    Angle(Angle),
2118    Color(Color),
2119    Path(VectorPath),
2120    Bool(bool),
2121    I64(i64),
2122    /// Whole gradient stop list (animatable as one unit; v1).
2123    Stops(GradientStops),
2124    /// Whole style paint (structural swaps like solid<->gradient).
2125    Paint(StylePaint),
2126}
2127
2128/// Topmost pickable item under `pt` (world space).
2129pub fn pick(scene: &Scene, pt: glam::DVec2) -> Option<NodeId> {
2130    let q = Point::new(pt.x, pt.y);
2131    for item in scene.items.iter().rev() {
2132        if item.opacity <= 0.0 {
2133            continue;
2134        }
2135
2136        let dashed_path = match &item.kind {
2137            PaintKind::Stroke(stroke) => stroke
2138                .dash
2139                .as_ref()
2140                .and_then(|dash| dash_bez_path(&item.path, &dash.dashes, dash.offset)),
2141            PaintKind::Fill(_) => None,
2142        };
2143
2144        let hit_path = dashed_path.as_ref().unwrap_or(&item.path);
2145
2146        let padding = match &item.kind {
2147            PaintKind::Stroke(stroke) => (stroke.width * 0.5).max(1.0),
2148            PaintKind::Fill(_) => 0.0,
2149        };
2150
2151        if !hit_path
2152            .bounding_box()
2153            .inflate(padding, padding)
2154            .contains(q)
2155        {
2156            continue;
2157        }
2158        let clips_ok = item.clips.iter().all(|&ci| {
2159            let Some(c) = scene.clips.get(ci as usize) else {
2160                return false; // dangling index: not pickable
2161            };
2162            match c.rule {
2163                FillRule::NonZero => c.path.winding(q) != 0,
2164                FillRule::EvenOdd => c.path.winding(q) % 2 != 0,
2165            }
2166        });
2167        if !clips_ok {
2168            continue;
2169        }
2170        let hit = match &item.kind {
2171            PaintKind::Fill(rule) => match rule {
2172                FillRule::NonZero => hit_path.winding(q) != 0,
2173                FillRule::EvenOdd => hit_path.winding(q) % 2 != 0,
2174            },
2175            PaintKind::Stroke(_) => nearest_dist(hit_path, q) <= padding,
2176        };
2177        if hit {
2178            return Some(item.node);
2179        }
2180    }
2181    None
2182}
2183
2184fn nearest_dist(path: &BezPath, q: Point) -> f64 {
2185    let mut best = f64::MAX;
2186    for seg in path.segments() {
2187        best = best.min(seg.nearest(q, 1e-6).distance_sq);
2188    }
2189    best.sqrt()
2190}
2191
2192/// Nodes whose geometry is FULLY CONTAINED in the box (rubber-band semantics).
2193pub fn pick_box(scene: &Scene, min: glam::DVec2, max: glam::DVec2) -> Vec<NodeId> {
2194    let mut out = Vec::new();
2195    for item in &scene.items {
2196        if item.opacity <= 0.0 {
2197            continue;
2198        }
2199        let bb = item.path.bounding_box();
2200        if bb.x0 >= min.x
2201            && bb.x1 <= max.x
2202            && bb.y0 >= min.y
2203            && bb.y1 <= max.y
2204            && !out.contains(&item.node)
2205        {
2206            out.push(item.node);
2207        }
2208    }
2209    out
2210}
2211
2212/// Union bbox of all items belonging to `nodes` (selection bounds).
2213pub fn nodes_bounds(scene: &Scene, nodes: &[NodeId]) -> Option<(glam::DVec2, glam::DVec2)> {
2214    let mut acc: Option<kurbo::Rect> = None;
2215    for item in &scene.items {
2216        if !nodes.contains(&item.node) {
2217            continue;
2218        }
2219        let bb = item.path.bounding_box();
2220        acc = Some(acc.map_or(bb, |a| a.union(bb)));
2221    }
2222    acc.map(|r| (glam::DVec2::new(r.x0, r.y0), glam::DVec2::new(r.x1, r.y1)))
2223}
2224
2225fn transform_vector(affine: Affine, value: glam::DVec2) -> glam::DVec2 {
2226    let [a, b, c, d, _, _] = affine.as_coeffs();
2227
2228    glam::DVec2::new(a * value.x + c * value.y, b * value.x + d * value.y)
2229}
2230
2231/// Convert a world-space drag delta to a node's parent coordinate system.
2232pub fn world_delta_to_parent(
2233    doc: &Document,
2234    id: NodeId,
2235    frame: f64,
2236    delta: glam::DVec2,
2237) -> Option<glam::DVec2> {
2238    let context = node_transform_context(doc, id, frame)?;
2239    let inverse = context.parent_world.inverse();
2240
2241    let result = transform_vector(inverse, delta);
2242
2243    result.is_finite().then_some(result)
2244}
2245
2246pub fn node_is_ancestor(doc: &Document, ancestor: NodeId, mut node: NodeId) -> bool {
2247    while let Some(current) = doc.nodes.get(node) {
2248        let Some(parent) = current.parent else {
2249            return false;
2250        };
2251
2252        if parent == ancestor {
2253            return true;
2254        }
2255
2256        node = parent;
2257    }
2258
2259    false
2260}
2261
2262/// If `picked` belongs to an already-selected group/layer, return that selected
2263/// ancestor instead of replacing it with the leaf shape.
2264pub fn selected_ancestor_for_pick(
2265    doc: &Document,
2266    picked: NodeId,
2267    selection: &[NodeId],
2268) -> Option<NodeId> {
2269    selection
2270        .iter()
2271        .copied()
2272        .find(|selected| *selected == picked || node_is_ancestor(doc, *selected, picked))
2273}
2274
2275/// Return the immediate child of `ancestor` that contains `descendant`.
2276pub fn immediate_child_below(
2277    doc: &Document,
2278    ancestor: NodeId,
2279    descendant: NodeId,
2280) -> Option<NodeId> {
2281    if ancestor == descendant {
2282        return None;
2283    }
2284
2285    let mut current = descendant;
2286
2287    loop {
2288        let parent = doc.nodes.get(current)?.parent?;
2289
2290        if parent == ancestor {
2291            return Some(current);
2292        }
2293
2294        current = parent;
2295    }
2296}
2297
2298/// Union bounds of selected leaf nodes and all rendered descendants of selected
2299/// groups/layers.
2300pub fn selection_bounds(
2301    doc: &Document,
2302    scene: &Scene,
2303    selection: &[NodeId],
2304) -> Option<(glam::DVec2, glam::DVec2)> {
2305    let mut bounds: Option<kurbo::Rect> = None;
2306
2307    for item in &scene.items {
2308        let included = selection
2309            .iter()
2310            .copied()
2311            .any(|selected| selected == item.node || node_is_ancestor(doc, selected, item.node));
2312
2313        if !included {
2314            continue;
2315        }
2316
2317        let item_bounds = item.path.bounding_box();
2318
2319        bounds = Some(match bounds {
2320            Some(existing) => existing.union(item_bounds),
2321            None => item_bounds,
2322        });
2323    }
2324
2325    bounds.map(|rect| {
2326        (
2327            glam::DVec2::new(rect.x0, rect.y0),
2328            glam::DVec2::new(rect.x1, rect.y1),
2329        )
2330    })
2331}
2332
2333/// Serialized keyframe (for RestoreKeyframe / undo).
2334#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
2335pub struct KeyframeData {
2336    pub frame: Frame,
2337    pub value: Value,
2338    pub interpolation: Interpolation,
2339    pub ease_out: EasingHandle,
2340    pub ease_in: EasingHandle,
2341}
2342
2343pub trait PropValue: Tween + Clone {
2344    fn into_value(self) -> Value;
2345    fn from_value(v: &Value) -> Option<Self>;
2346}
2347impl PropValue for f64 {
2348    fn into_value(self) -> Value {
2349        Value::F64(self)
2350    }
2351    fn from_value(v: &Value) -> Option<Self> {
2352        if let Value::F64(x) = v {
2353            Some(*x)
2354        } else {
2355            None
2356        }
2357    }
2358}
2359impl PropValue for glam::DVec2 {
2360    fn into_value(self) -> Value {
2361        Value::DVec2(self)
2362    }
2363    fn from_value(v: &Value) -> Option<Self> {
2364        if let Value::DVec2(x) = v {
2365            Some(*x)
2366        } else {
2367            None
2368        }
2369    }
2370}
2371impl PropValue for Angle {
2372    fn into_value(self) -> Value {
2373        Value::Angle(self)
2374    }
2375    fn from_value(v: &Value) -> Option<Self> {
2376        match v {
2377            Value::Angle(a) => Some(*a),
2378            Value::F64(x) => Some(Angle(*x)),
2379            _ => None,
2380        }
2381    }
2382}
2383impl PropValue for Color {
2384    fn into_value(self) -> Value {
2385        Value::Color(self)
2386    }
2387    fn from_value(v: &Value) -> Option<Self> {
2388        if let Value::Color(c) = v {
2389            Some(*c)
2390        } else {
2391            None
2392        }
2393    }
2394}
2395impl PropValue for VectorPath {
2396    fn into_value(self) -> Value {
2397        Value::Path(self)
2398    }
2399    fn from_value(v: &Value) -> Option<Self> {
2400        if let Value::Path(p) = v {
2401            Some(p.clone())
2402        } else {
2403            None
2404        }
2405    }
2406}
2407impl PropValue for GradientStops {
2408    fn into_value(self) -> Value {
2409        Value::Stops(self)
2410    }
2411    fn from_value(v: &Value) -> Option<Self> {
2412        if let Value::Stops(s) = v {
2413            Some(s.clone())
2414        } else {
2415            None
2416        }
2417    }
2418}
2419impl PropValue for StylePaint {
2420    fn into_value(self) -> Value {
2421        Value::Paint(self)
2422    }
2423    fn from_value(v: &Value) -> Option<Self> {
2424        if let Value::Paint(p) = v {
2425            Some(p.clone())
2426        } else {
2427            None
2428        }
2429    }
2430}
2431
2432pub enum PropMut<'a> {
2433    F64(&'a mut Animated<f64>),
2434    Vec2(&'a mut Animated<glam::DVec2>),
2435    Angle(&'a mut Animated<Angle>),
2436    Color(&'a mut Animated<Color>),
2437    Path(&'a mut Animated<VectorPath>),
2438    Stops(&'a mut Animated<GradientStops>),
2439}
2440pub enum PropRef<'a> {
2441    F64(&'a Animated<f64>),
2442    Vec2(&'a Animated<glam::DVec2>),
2443    Angle(&'a Animated<Angle>),
2444    Color(&'a Animated<Color>),
2445    Path(&'a Animated<VectorPath>),
2446    Stops(&'a Animated<GradientStops>),
2447}
2448
2449pub trait PropVisitor {
2450    type Out;
2451    fn visit<T: PropValue>(self, a: &mut Animated<T>) -> Self::Out;
2452}
2453pub trait PropReader {
2454    type Out;
2455    fn read<T: PropValue>(self, a: &Animated<T>) -> Self::Out;
2456}
2457
2458pub fn visit_prop<V: PropVisitor>(p: PropMut<'_>, v: V) -> V::Out {
2459    match p {
2460        PropMut::F64(a) => v.visit(a),
2461        PropMut::Vec2(a) => v.visit(a),
2462        PropMut::Angle(a) => v.visit(a),
2463        PropMut::Color(a) => v.visit(a),
2464        PropMut::Path(a) => v.visit(a),
2465        PropMut::Stops(a) => v.visit(a),
2466    }
2467}
2468pub fn read_prop<V: PropReader>(p: PropRef<'_>, v: V) -> V::Out {
2469    match p {
2470        PropRef::F64(a) => v.read(a),
2471        PropRef::Vec2(a) => v.read(a),
2472        PropRef::Angle(a) => v.read(a),
2473        PropRef::Color(a) => v.read(a),
2474        PropRef::Path(a) => v.read(a),
2475        PropRef::Stops(a) => v.read(a),
2476    }
2477}
2478
2479fn dash_index(path: &str) -> Option<usize> {
2480    path.strip_prefix("stroke.dash.")?.parse().ok()
2481}
2482
2483impl Node {
2484    pub fn prop_mut(&mut self, prop: &PropPath) -> Option<PropMut<'_>> {
2485        use PropMut::*;
2486        let s = prop.as_str();
2487        if s == "stroke.dash.offset" || dash_index(s).is_some() {
2488            if let NodeKind::Style(StyleKind::Stroke {
2489                dash: Some(dash), ..
2490            }) = &mut self.kind
2491            {
2492                if s == "stroke.dash.offset" {
2493                    return Some(F64(&mut dash.offset));
2494                }
2495                if let Some(index) = dash_index(s) {
2496                    return dash.dashes.get_mut(index).map(PropMut::F64);
2497                }
2498            }
2499            return None;
2500        }
2501
2502        match (s, &mut self.kind) {
2503            ("opacity", _) => Some(F64(&mut self.opacity)),
2504            ("transform.anchor", _) => Some(Vec2(&mut self.transform.anchor)),
2505            ("transform.position", _) => Some(Vec2(&mut self.transform.position)),
2506            ("transform.scale", _) => Some(Vec2(&mut self.transform.scale)),
2507            ("transform.rotation", _) => Some(Angle(&mut self.transform.rotation)),
2508            ("transform.skew", _) => Some(F64(&mut self.transform.skew)),
2509            ("transform.skew_axis", _) => Some(F64(&mut self.transform.skew_axis)),
2510            ("shape.path", NodeKind::Shape(ShapeKind::Path(p))) => Some(Path(p)),
2511            ("shape.pos", NodeKind::Shape(ShapeKind::Rect { pos, .. }))
2512            | ("shape.pos", NodeKind::Shape(ShapeKind::Ellipse { pos, .. }))
2513            | ("shape.pos", NodeKind::Shape(ShapeKind::Star { pos, .. }))
2514            | ("shape.pos", NodeKind::Shape(ShapeKind::Polygon { pos, .. })) => Some(Vec2(pos)),
2515            ("shape.size", NodeKind::Shape(ShapeKind::Rect { size, .. }))
2516            | ("shape.size", NodeKind::Shape(ShapeKind::Ellipse { size, .. })) => Some(Vec2(size)),
2517            ("shape.rounded", NodeKind::Shape(ShapeKind::Rect { rounded, .. })) => {
2518                Some(F64(rounded))
2519            }
2520            ("shape.points", NodeKind::Shape(ShapeKind::Star { points, .. }))
2521            | ("shape.points", NodeKind::Shape(ShapeKind::Polygon { points, .. })) => {
2522                Some(F64(points))
2523            }
2524            ("shape.inner_r", NodeKind::Shape(ShapeKind::Star { inner_r, .. })) => {
2525                Some(F64(inner_r))
2526            }
2527            ("shape.outer_r", NodeKind::Shape(ShapeKind::Star { outer_r, .. }))
2528            | ("shape.outer_r", NodeKind::Shape(ShapeKind::Polygon { outer_r, .. })) => {
2529                Some(F64(outer_r))
2530            }
2531            ("shape.roundness", NodeKind::Shape(ShapeKind::Star { roundness, .. }))
2532            | ("shape.roundness", NodeKind::Shape(ShapeKind::Polygon { roundness, .. })) => {
2533                Some(F64(roundness))
2534            }
2535            ("text.size", NodeKind::Text(t)) => Some(F64(&mut t.size)),
2536            (
2537                "shape.path",
2538                NodeKind::Mask(MaskProps {
2539                    shape: ShapeKind::Path(p),
2540                    ..
2541                }),
2542            ) => Some(Path(p)),
2543            (
2544                "shape.pos",
2545                NodeKind::Mask(MaskProps {
2546                    shape: ShapeKind::Rect { pos, .. },
2547                    ..
2548                }),
2549            )
2550            | (
2551                "shape.pos",
2552                NodeKind::Mask(MaskProps {
2553                    shape: ShapeKind::Ellipse { pos, .. },
2554                    ..
2555                }),
2556            )
2557            | (
2558                "shape.pos",
2559                NodeKind::Mask(MaskProps {
2560                    shape: ShapeKind::Star { pos, .. },
2561                    ..
2562                }),
2563            )
2564            | (
2565                "shape.pos",
2566                NodeKind::Mask(MaskProps {
2567                    shape: ShapeKind::Polygon { pos, .. },
2568                    ..
2569                }),
2570            ) => Some(Vec2(pos)),
2571            (
2572                "shape.size",
2573                NodeKind::Mask(MaskProps {
2574                    shape: ShapeKind::Rect { size, .. },
2575                    ..
2576                }),
2577            )
2578            | (
2579                "shape.size",
2580                NodeKind::Mask(MaskProps {
2581                    shape: ShapeKind::Ellipse { size, .. },
2582                    ..
2583                }),
2584            ) => Some(Vec2(size)),
2585            (
2586                "shape.rounded",
2587                NodeKind::Mask(MaskProps {
2588                    shape: ShapeKind::Rect { rounded, .. },
2589                    ..
2590                }),
2591            ) => Some(F64(rounded)),
2592            (
2593                "shape.points",
2594                NodeKind::Mask(MaskProps {
2595                    shape: ShapeKind::Star { points, .. } | ShapeKind::Polygon { points, .. },
2596                    ..
2597                }),
2598            ) => Some(F64(points)),
2599            (
2600                "shape.inner_r",
2601                NodeKind::Mask(MaskProps {
2602                    shape: ShapeKind::Star { inner_r, .. },
2603                    ..
2604                }),
2605            ) => Some(F64(inner_r)),
2606            (
2607                "shape.outer_r",
2608                NodeKind::Mask(MaskProps {
2609                    shape: ShapeKind::Star { outer_r, .. } | ShapeKind::Polygon { outer_r, .. },
2610                    ..
2611                }),
2612            ) => Some(F64(outer_r)),
2613            (
2614                "shape.roundness",
2615                NodeKind::Mask(MaskProps {
2616                    shape: ShapeKind::Star { roundness, .. } | ShapeKind::Polygon { roundness, .. },
2617                    ..
2618                }),
2619            ) => Some(F64(roundness)),
2620            (
2621                "fill.color",
2622                NodeKind::Style(StyleKind::Fill {
2623                    paint: StylePaint::Solid { color },
2624                    ..
2625                }),
2626            ) => Some(Color(color)),
2627            (
2628                "stroke.color",
2629                NodeKind::Style(StyleKind::Stroke {
2630                    paint: StylePaint::Solid { color },
2631                    ..
2632                }),
2633            ) => Some(Color(color)),
2634            ("stroke.width", NodeKind::Style(StyleKind::Stroke { width, .. })) => Some(F64(width)),
2635            (
2636                "grad.start",
2637                NodeKind::Style(StyleKind::Fill {
2638                    paint: StylePaint::Gradient(g),
2639                    ..
2640                }),
2641            )
2642            | (
2643                "grad.start",
2644                NodeKind::Style(StyleKind::Stroke {
2645                    paint: StylePaint::Gradient(g),
2646                    ..
2647                }),
2648            ) => Some(Vec2(&mut g.start)),
2649            (
2650                "grad.end",
2651                NodeKind::Style(StyleKind::Fill {
2652                    paint: StylePaint::Gradient(g),
2653                    ..
2654                }),
2655            )
2656            | (
2657                "grad.end",
2658                NodeKind::Style(StyleKind::Stroke {
2659                    paint: StylePaint::Gradient(g),
2660                    ..
2661                }),
2662            ) => Some(Vec2(&mut g.end)),
2663            (
2664                "grad.stops",
2665                NodeKind::Style(StyleKind::Fill {
2666                    paint: StylePaint::Gradient(g),
2667                    ..
2668                }),
2669            )
2670            | (
2671                "grad.stops",
2672                NodeKind::Style(StyleKind::Stroke {
2673                    paint: StylePaint::Gradient(g),
2674                    ..
2675                }),
2676            ) => Some(Stops(&mut g.stops)),
2677            ("trim.start", NodeKind::Modifier(ModifierKind::TrimPath { start, .. })) => {
2678                Some(F64(start))
2679            }
2680            ("trim.end", NodeKind::Modifier(ModifierKind::TrimPath { end, .. })) => Some(F64(end)),
2681            ("trim.offset", NodeKind::Modifier(ModifierKind::TrimPath { offset, .. })) => {
2682                Some(F64(offset))
2683            }
2684            ("repeater.copies", NodeKind::Modifier(ModifierKind::Repeater { copies, .. })) => {
2685                Some(F64(copies))
2686            }
2687            ("repeater.offset", NodeKind::Modifier(ModifierKind::Repeater { offset, .. })) => {
2688                Some(F64(offset))
2689            }
2690            (
2691                "repeater.start_opacity",
2692                NodeKind::Modifier(ModifierKind::Repeater { start_opacity, .. }),
2693            ) => Some(F64(start_opacity)),
2694            (
2695                "repeater.end_opacity",
2696                NodeKind::Modifier(ModifierKind::Repeater { end_opacity, .. }),
2697            ) => Some(F64(end_opacity)),
2698            (
2699                "repeater.transform.position",
2700                NodeKind::Modifier(ModifierKind::Repeater { transform, .. }),
2701            ) => Some(Vec2(&mut transform.position)),
2702            (
2703                "repeater.transform.scale",
2704                NodeKind::Modifier(ModifierKind::Repeater { transform, .. }),
2705            ) => Some(Vec2(&mut transform.scale)),
2706            (
2707                "repeater.transform.rotation",
2708                NodeKind::Modifier(ModifierKind::Repeater { transform, .. }),
2709            ) => Some(Angle(&mut transform.rotation)),
2710            (
2711                "repeater.transform.anchor",
2712                NodeKind::Modifier(ModifierKind::Repeater { transform, .. }),
2713            ) => Some(Vec2(&mut transform.anchor)),
2714            (
2715                "repeater.transform.skew",
2716                NodeKind::Modifier(ModifierKind::Repeater { transform, .. }),
2717            ) => Some(F64(&mut transform.skew)),
2718            (
2719                "repeater.transform.skew_axis",
2720                NodeKind::Modifier(ModifierKind::Repeater { transform, .. }),
2721            ) => Some(F64(&mut transform.skew_axis)),
2722            ("round.radius", NodeKind::Modifier(ModifierKind::RoundCorners { radius })) => {
2723                Some(F64(radius))
2724            }
2725            ("offset.amount", NodeKind::Modifier(ModifierKind::OffsetPath { amount })) => {
2726                Some(F64(amount))
2727            }
2728            ("zigzag.amplitude", NodeKind::Modifier(ModifierKind::ZigZag { amplitude, .. })) => {
2729                Some(F64(amplitude))
2730            }
2731            ("zigzag.frequency", NodeKind::Modifier(ModifierKind::ZigZag { frequency, .. })) => {
2732                Some(F64(frequency))
2733            }
2734            ("pucker.amount", NodeKind::Modifier(ModifierKind::PuckerBloat { amount })) => {
2735                Some(F64(amount))
2736            }
2737            _ => None,
2738        }
2739    }
2740
2741    pub fn prop_ref(&self, prop: &PropPath) -> Option<PropRef<'_>> {
2742        use PropRef::*;
2743        let s = prop.as_str();
2744        if s == "stroke.dash.offset" || dash_index(s).is_some() {
2745            if let NodeKind::Style(StyleKind::Stroke {
2746                dash: Some(dash), ..
2747            }) = &self.kind
2748            {
2749                if s == "stroke.dash.offset" {
2750                    return Some(F64(&dash.offset));
2751                }
2752                if let Some(index) = dash_index(s) {
2753                    return dash.dashes.get(index).map(PropRef::F64);
2754                }
2755            }
2756            return None;
2757        }
2758
2759        match (s, &self.kind) {
2760            ("opacity", _) => Some(F64(&self.opacity)),
2761            ("transform.anchor", _) => Some(Vec2(&self.transform.anchor)),
2762            ("transform.position", _) => Some(Vec2(&self.transform.position)),
2763            ("transform.scale", _) => Some(Vec2(&self.transform.scale)),
2764            ("transform.rotation", _) => Some(Angle(&self.transform.rotation)),
2765            ("transform.skew", _) => Some(F64(&self.transform.skew)),
2766            ("transform.skew_axis", _) => Some(F64(&self.transform.skew_axis)),
2767            ("shape.path", NodeKind::Shape(ShapeKind::Path(p))) => Some(Path(p)),
2768            ("shape.pos", NodeKind::Shape(ShapeKind::Rect { pos, .. }))
2769            | ("shape.pos", NodeKind::Shape(ShapeKind::Ellipse { pos, .. }))
2770            | ("shape.pos", NodeKind::Shape(ShapeKind::Star { pos, .. }))
2771            | ("shape.pos", NodeKind::Shape(ShapeKind::Polygon { pos, .. })) => Some(Vec2(pos)),
2772            ("shape.size", NodeKind::Shape(ShapeKind::Rect { size, .. }))
2773            | ("shape.size", NodeKind::Shape(ShapeKind::Ellipse { size, .. })) => Some(Vec2(size)),
2774            ("shape.rounded", NodeKind::Shape(ShapeKind::Rect { rounded, .. })) => {
2775                Some(F64(rounded))
2776            }
2777            ("shape.points", NodeKind::Shape(ShapeKind::Star { points, .. }))
2778            | ("shape.points", NodeKind::Shape(ShapeKind::Polygon { points, .. })) => {
2779                Some(F64(points))
2780            }
2781            ("shape.inner_r", NodeKind::Shape(ShapeKind::Star { inner_r, .. })) => {
2782                Some(F64(inner_r))
2783            }
2784            ("shape.outer_r", NodeKind::Shape(ShapeKind::Star { outer_r, .. }))
2785            | ("shape.outer_r", NodeKind::Shape(ShapeKind::Polygon { outer_r, .. })) => {
2786                Some(F64(outer_r))
2787            }
2788            ("shape.roundness", NodeKind::Shape(ShapeKind::Star { roundness, .. }))
2789            | ("shape.roundness", NodeKind::Shape(ShapeKind::Polygon { roundness, .. })) => {
2790                Some(F64(roundness))
2791            }
2792            ("text.size", NodeKind::Text(t)) => Some(F64(&t.size)),
2793            (
2794                "shape.path",
2795                NodeKind::Mask(MaskProps {
2796                    shape: ShapeKind::Path(p),
2797                    ..
2798                }),
2799            ) => Some(Path(p)),
2800            (
2801                "shape.pos",
2802                NodeKind::Mask(MaskProps {
2803                    shape: ShapeKind::Rect { pos, .. },
2804                    ..
2805                }),
2806            )
2807            | (
2808                "shape.pos",
2809                NodeKind::Mask(MaskProps {
2810                    shape: ShapeKind::Ellipse { pos, .. },
2811                    ..
2812                }),
2813            )
2814            | (
2815                "shape.pos",
2816                NodeKind::Mask(MaskProps {
2817                    shape: ShapeKind::Star { pos, .. },
2818                    ..
2819                }),
2820            )
2821            | (
2822                "shape.pos",
2823                NodeKind::Mask(MaskProps {
2824                    shape: ShapeKind::Polygon { pos, .. },
2825                    ..
2826                }),
2827            ) => Some(Vec2(pos)),
2828            (
2829                "shape.size",
2830                NodeKind::Mask(MaskProps {
2831                    shape: ShapeKind::Rect { size, .. },
2832                    ..
2833                }),
2834            )
2835            | (
2836                "shape.size",
2837                NodeKind::Mask(MaskProps {
2838                    shape: ShapeKind::Ellipse { size, .. },
2839                    ..
2840                }),
2841            ) => Some(Vec2(size)),
2842            (
2843                "shape.rounded",
2844                NodeKind::Mask(MaskProps {
2845                    shape: ShapeKind::Rect { rounded, .. },
2846                    ..
2847                }),
2848            ) => Some(F64(rounded)),
2849            (
2850                "shape.points",
2851                NodeKind::Mask(MaskProps {
2852                    shape: ShapeKind::Star { points, .. } | ShapeKind::Polygon { points, .. },
2853                    ..
2854                }),
2855            ) => Some(F64(points)),
2856            (
2857                "shape.inner_r",
2858                NodeKind::Mask(MaskProps {
2859                    shape: ShapeKind::Star { inner_r, .. },
2860                    ..
2861                }),
2862            ) => Some(F64(inner_r)),
2863            (
2864                "shape.outer_r",
2865                NodeKind::Mask(MaskProps {
2866                    shape: ShapeKind::Star { outer_r, .. } | ShapeKind::Polygon { outer_r, .. },
2867                    ..
2868                }),
2869            ) => Some(F64(outer_r)),
2870            (
2871                "shape.roundness",
2872                NodeKind::Mask(MaskProps {
2873                    shape: ShapeKind::Star { roundness, .. } | ShapeKind::Polygon { roundness, .. },
2874                    ..
2875                }),
2876            ) => Some(F64(roundness)),
2877            (
2878                "fill.color",
2879                NodeKind::Style(StyleKind::Fill {
2880                    paint: StylePaint::Solid { color },
2881                    ..
2882                }),
2883            ) => Some(Color(color)),
2884            (
2885                "stroke.color",
2886                NodeKind::Style(StyleKind::Stroke {
2887                    paint: StylePaint::Solid { color },
2888                    ..
2889                }),
2890            ) => Some(Color(color)),
2891            ("stroke.width", NodeKind::Style(StyleKind::Stroke { width, .. })) => Some(F64(width)),
2892            (
2893                "grad.start",
2894                NodeKind::Style(StyleKind::Fill {
2895                    paint: StylePaint::Gradient(g),
2896                    ..
2897                }),
2898            )
2899            | (
2900                "grad.start",
2901                NodeKind::Style(StyleKind::Stroke {
2902                    paint: StylePaint::Gradient(g),
2903                    ..
2904                }),
2905            ) => Some(Vec2(&g.start)),
2906            (
2907                "grad.end",
2908                NodeKind::Style(StyleKind::Fill {
2909                    paint: StylePaint::Gradient(g),
2910                    ..
2911                }),
2912            )
2913            | (
2914                "grad.end",
2915                NodeKind::Style(StyleKind::Stroke {
2916                    paint: StylePaint::Gradient(g),
2917                    ..
2918                }),
2919            ) => Some(Vec2(&g.end)),
2920            (
2921                "grad.stops",
2922                NodeKind::Style(StyleKind::Fill {
2923                    paint: StylePaint::Gradient(g),
2924                    ..
2925                }),
2926            )
2927            | (
2928                "grad.stops",
2929                NodeKind::Style(StyleKind::Stroke {
2930                    paint: StylePaint::Gradient(g),
2931                    ..
2932                }),
2933            ) => Some(Stops(&g.stops)),
2934            ("trim.start", NodeKind::Modifier(ModifierKind::TrimPath { start, .. })) => {
2935                Some(F64(start))
2936            }
2937            ("trim.end", NodeKind::Modifier(ModifierKind::TrimPath { end, .. })) => Some(F64(end)),
2938            ("trim.offset", NodeKind::Modifier(ModifierKind::TrimPath { offset, .. })) => {
2939                Some(F64(offset))
2940            }
2941            ("repeater.copies", NodeKind::Modifier(ModifierKind::Repeater { copies, .. })) => {
2942                Some(F64(copies))
2943            }
2944            ("repeater.offset", NodeKind::Modifier(ModifierKind::Repeater { offset, .. })) => {
2945                Some(F64(offset))
2946            }
2947            (
2948                "repeater.start_opacity",
2949                NodeKind::Modifier(ModifierKind::Repeater { start_opacity, .. }),
2950            ) => Some(F64(start_opacity)),
2951            (
2952                "repeater.end_opacity",
2953                NodeKind::Modifier(ModifierKind::Repeater { end_opacity, .. }),
2954            ) => Some(F64(end_opacity)),
2955            (
2956                "repeater.transform.position",
2957                NodeKind::Modifier(ModifierKind::Repeater { transform, .. }),
2958            ) => Some(Vec2(&transform.position)),
2959            (
2960                "repeater.transform.scale",
2961                NodeKind::Modifier(ModifierKind::Repeater { transform, .. }),
2962            ) => Some(Vec2(&transform.scale)),
2963            (
2964                "repeater.transform.rotation",
2965                NodeKind::Modifier(ModifierKind::Repeater { transform, .. }),
2966            ) => Some(Angle(&transform.rotation)),
2967            (
2968                "repeater.transform.anchor",
2969                NodeKind::Modifier(ModifierKind::Repeater { transform, .. }),
2970            ) => Some(Vec2(&transform.anchor)),
2971            (
2972                "repeater.transform.skew",
2973                NodeKind::Modifier(ModifierKind::Repeater { transform, .. }),
2974            ) => Some(F64(&transform.skew)),
2975            (
2976                "repeater.transform.skew_axis",
2977                NodeKind::Modifier(ModifierKind::Repeater { transform, .. }),
2978            ) => Some(F64(&transform.skew_axis)),
2979            ("round.radius", NodeKind::Modifier(ModifierKind::RoundCorners { radius })) => {
2980                Some(F64(radius))
2981            }
2982            ("offset.amount", NodeKind::Modifier(ModifierKind::OffsetPath { amount })) => {
2983                Some(F64(amount))
2984            }
2985            ("zigzag.amplitude", NodeKind::Modifier(ModifierKind::ZigZag { amplitude, .. })) => {
2986                Some(F64(amplitude))
2987            }
2988            ("zigzag.frequency", NodeKind::Modifier(ModifierKind::ZigZag { frequency, .. })) => {
2989                Some(F64(frequency))
2990            }
2991            ("pucker.amount", NodeKind::Modifier(ModifierKind::PuckerBloat { amount })) => {
2992                Some(F64(amount))
2993            }
2994            _ => None,
2995        }
2996    }
2997}
2998
2999struct SetStaticOp<'a>(&'a Value, &'a str);
3000impl PropVisitor for SetStaticOp<'_> {
3001    type Out = Result<Value, ModelError>;
3002    fn visit<T: PropValue>(self, a: &mut Animated<T>) -> Self::Out {
3003        let new = T::from_value(self.0).ok_or_else(|| ModelError::TypeMismatch(self.1.into()))?;
3004        Ok(std::mem::replace(&mut a.base, new).into_value())
3005    }
3006}
3007
3008struct AddKeyOp<'a> {
3009    frame: Frame,
3010    value: &'a Value,
3011    prop: &'a str,
3012}
3013impl PropVisitor for AddKeyOp<'_> {
3014    type Out = Result<Option<KeyframeData>, ModelError>;
3015    fn visit<T: PropValue>(self, a: &mut Animated<T>) -> Self::Out {
3016        let new =
3017            T::from_value(self.value).ok_or_else(|| ModelError::TypeMismatch(self.prop.into()))?;
3018        let old = a.key_at(self.frame).map(|k| KeyframeData {
3019            frame: k.frame,
3020            value: k.value.clone().into_value(),
3021            interpolation: k.interpolation,
3022            ease_out: k.ease_out,
3023            ease_in: k.ease_in,
3024        });
3025        a.set_key(self.frame, new);
3026        Ok(old)
3027    }
3028}
3029
3030struct RemoveKeyOp(Frame);
3031impl PropVisitor for RemoveKeyOp {
3032    type Out = Result<KeyframeData, ModelError>;
3033    fn visit<T: PropValue>(self, a: &mut Animated<T>) -> Self::Out {
3034        let k = a
3035            .remove_key(self.0)
3036            .ok_or(ModelError::NoKeyframe(self.0.0))?;
3037        Ok(KeyframeData {
3038            frame: k.frame,
3039            value: k.value.into_value(),
3040            interpolation: k.interpolation,
3041            ease_out: k.ease_out,
3042            ease_in: k.ease_in,
3043        })
3044    }
3045}
3046
3047struct RestoreKeyOp<'a>(&'a KeyframeData, &'a str);
3048impl PropVisitor for RestoreKeyOp<'_> {
3049    type Out = Result<(), ModelError>;
3050    fn visit<T: PropValue>(self, a: &mut Animated<T>) -> Self::Out {
3051        let v =
3052            T::from_value(&self.0.value).ok_or_else(|| ModelError::TypeMismatch(self.1.into()))?;
3053        a.set_key(self.0.frame, v);
3054        a.set_easing(
3055            self.0.frame,
3056            self.0.interpolation,
3057            self.0.ease_out,
3058            self.0.ease_in,
3059        );
3060        Ok(())
3061    }
3062}
3063
3064struct MoveKeyOp {
3065    from: Frame,
3066    to: Frame,
3067}
3068impl PropVisitor for MoveKeyOp {
3069    type Out = Result<(), ModelError>;
3070    fn visit<T: PropValue>(self, a: &mut Animated<T>) -> Self::Out {
3071        if self.from == self.to {
3072            return Ok(());
3073        }
3074        if a.key_at(self.to).is_some() {
3075            return Err(ModelError::KeyframeExists(self.to.0));
3076        }
3077        if a.move_key(self.from, self.to) {
3078            Ok(())
3079        } else {
3080            Err(ModelError::NoKeyframe(self.from.0))
3081        }
3082    }
3083}
3084
3085struct SetEasingOp {
3086    frame: Frame,
3087    i: Interpolation,
3088    o: EasingHandle,
3089    e: EasingHandle,
3090}
3091impl PropVisitor for SetEasingOp {
3092    type Out = Result<(Interpolation, EasingHandle, EasingHandle), ModelError>;
3093    fn visit<T: PropValue>(self, a: &mut Animated<T>) -> Self::Out {
3094        a.set_easing(self.frame, self.i, self.o, self.e)
3095            .ok_or(ModelError::NoKeyframe(self.frame.0))
3096    }
3097}
3098
3099struct IsAnimatedOp;
3100impl PropReader for IsAnimatedOp {
3101    type Out = bool;
3102    fn read<T: PropValue>(self, a: &Animated<T>) -> bool {
3103        a.has_keys()
3104    }
3105}
3106
3107struct ValueAtOp(f64);
3108impl PropReader for ValueAtOp {
3109    type Out = Value;
3110    fn read<T: PropValue>(self, a: &Animated<T>) -> Value {
3111        a.value_at(self.0).into_value()
3112    }
3113}
3114
3115struct GetStaticOp;
3116impl PropReader for GetStaticOp {
3117    type Out = Value;
3118    fn read<T: PropValue>(self, a: &Animated<T>) -> Value {
3119        a.base.clone().into_value()
3120    }
3121}
3122
3123struct GetKeyOp(Frame);
3124impl PropReader for GetKeyOp {
3125    type Out = Option<KeyframeData>;
3126    fn read<T: PropValue>(self, a: &Animated<T>) -> Option<KeyframeData> {
3127        a.key_at(self.0).map(|k| KeyframeData {
3128            frame: k.frame,
3129            value: k.value.clone().into_value(),
3130            interpolation: k.interpolation,
3131            ease_out: k.ease_out,
3132            ease_in: k.ease_in,
3133        })
3134    }
3135}
3136
3137/// Enumeration of keyframe frames (sorted; the source is sorted by invariant).
3138struct KeyFramesOp;
3139impl PropReader for KeyFramesOp {
3140    type Out = Vec<Frame>;
3141    fn read<T: PropValue>(self, a: &Animated<T>) -> Vec<Frame> {
3142        a.keyframes.iter().map(|k| k.frame).collect()
3143    }
3144}
3145
3146impl Document {
3147    /// All live node ids whose name equals `name`. Lets hosts (games) find
3148    /// nodes by name without tracking `NodeId`s across document loads.
3149    pub fn find_nodes_by_name<'a>(&'a self, name: &'a str) -> impl Iterator<Item = NodeId> + 'a {
3150        self.nodes
3151            .iter()
3152            .filter(move |(_, n)| n.name == name)
3153            .map(|(id, _)| id)
3154    }
3155
3156    fn pm<'a>(&'a mut self, id: NodeId, prop: &PropPath) -> Result<PropMut<'a>, ModelError> {
3157        self.nodes
3158            .get_mut(id)
3159            .ok_or(ModelError::MissingNode)?
3160            .prop_mut(prop)
3161            .ok_or_else(|| ModelError::MissingProp(prop.0.clone()))
3162    }
3163    fn pr<'a>(&'a self, id: NodeId, prop: &PropPath) -> Result<PropRef<'a>, ModelError> {
3164        self.nodes
3165            .get(id)
3166            .ok_or(ModelError::MissingNode)?
3167            .prop_ref(prop)
3168            .ok_or_else(|| ModelError::MissingProp(prop.0.clone()))
3169    }
3170
3171    /// Set the base value; returns previous base (for undo).
3172    pub fn set_static(
3173        &mut self,
3174        id: NodeId,
3175        prop: &PropPath,
3176        v: &Value,
3177    ) -> Result<Value, ModelError> {
3178        let name = prop.0.clone();
3179        visit_prop(self.pm(id, prop)?, SetStaticOp(v, &name))
3180    }
3181    /// Insert/update key at frame; returns replaced key if any (for undo).
3182    pub fn add_keyframe(
3183        &mut self,
3184        id: NodeId,
3185        prop: &PropPath,
3186        frame: Frame,
3187        v: &Value,
3188    ) -> Result<Option<KeyframeData>, ModelError> {
3189        let name = prop.0.clone();
3190        visit_prop(
3191            self.pm(id, prop)?,
3192            AddKeyOp {
3193                frame,
3194                value: v,
3195                prop: &name,
3196            },
3197        )
3198    }
3199    pub fn remove_keyframe(
3200        &mut self,
3201        id: NodeId,
3202        prop: &PropPath,
3203        frame: Frame,
3204    ) -> Result<KeyframeData, ModelError> {
3205        visit_prop(self.pm(id, prop)?, RemoveKeyOp(frame))
3206    }
3207    pub fn restore_keyframe(
3208        &mut self,
3209        id: NodeId,
3210        prop: &PropPath,
3211        key: &KeyframeData,
3212    ) -> Result<(), ModelError> {
3213        let name = prop.0.clone();
3214        visit_prop(self.pm(id, prop)?, RestoreKeyOp(key, &name))
3215    }
3216    pub fn move_keyframe(
3217        &mut self,
3218        id: NodeId,
3219        prop: &PropPath,
3220        from: Frame,
3221        to: Frame,
3222    ) -> Result<(), ModelError> {
3223        visit_prop(self.pm(id, prop)?, MoveKeyOp { from, to })
3224    }
3225    /// Returns previous easing (for undo).
3226    pub fn set_easing(
3227        &mut self,
3228        id: NodeId,
3229        prop: &PropPath,
3230        frame: Frame,
3231        i: Interpolation,
3232        o: EasingHandle,
3233        e: EasingHandle,
3234    ) -> Result<(Interpolation, EasingHandle, EasingHandle), ModelError> {
3235        visit_prop(self.pm(id, prop)?, SetEasingOp { frame, i, o, e })
3236    }
3237
3238    pub fn property_is_animated(&self, id: NodeId, prop: &PropPath) -> bool {
3239        self.pr(id, prop)
3240            .map(|p| read_prop(p, IsAnimatedOp))
3241            .unwrap_or(false)
3242    }
3243    pub fn value_at(&self, id: NodeId, prop: &PropPath, frame: f64) -> Result<Value, ModelError> {
3244        Ok(read_prop(self.pr(id, prop)?, ValueAtOp(frame)))
3245    }
3246    pub fn get_static(&self, id: NodeId, prop: &PropPath) -> Result<Value, ModelError> {
3247        Ok(read_prop(self.pr(id, prop)?, GetStaticOp))
3248    }
3249    pub fn keyframe_data(&self, id: NodeId, prop: &PropPath, frame: Frame) -> Option<KeyframeData> {
3250        self.pr(id, prop)
3251            .ok()
3252            .and_then(|p| read_prop(p, GetKeyOp(frame)))
3253    }
3254    /// All keyframe frames on a property, sorted (empty if missing).
3255    pub fn key_frames(&self, id: NodeId, prop: &PropPath) -> Vec<Frame> {
3256        self.pr(id, prop)
3257            .map(|p| read_prop(p, KeyFramesOp))
3258            .unwrap_or_default()
3259    }
3260}
3261
3262#[cfg(test)]
3263mod tests {
3264    use super::*;
3265    use glam::DVec2;
3266
3267    fn doc_with_ellipse_and_fill() -> (Document, NodeId) {
3268        let mut doc = Document::empty();
3269        let shape = doc.create_node(Node::new(
3270            "e",
3271            NodeKind::Shape(ShapeKind::Ellipse {
3272                pos: Animated::new(DVec2::new(0.0, 0.0)),
3273                size: Animated::new(DVec2::new(100.0, 100.0)),
3274            }),
3275        ));
3276        let fill = doc.create_node(Node::new(
3277            "f",
3278            NodeKind::Style(StyleKind::Fill {
3279                paint: StylePaint::solid(Color::BLACK),
3280                rule: FillRule::NonZero,
3281            }),
3282        ));
3283        doc.attach(shape, Parent::Comp(doc.main), 0).unwrap();
3284        doc.attach(fill, Parent::Comp(doc.main), 1).unwrap();
3285        (doc, shape)
3286    }
3287
3288    #[test]
3289    fn override_beats_keyframes() {
3290        let (doc, shape_id) = doc_with_ellipse_and_fill();
3291        let mut ov = Overrides::default();
3292        ov.set(
3293            shape_id,
3294            PropPath::new("shape.pos"),
3295            Value::DVec2(DVec2::new(99.0, 0.0)),
3296        );
3297        let s = evaluate_with(&doc, doc.main, 0.0, &ov);
3298        assert!(s.items[0].path.bounding_box().center().x > 90.0);
3299    }
3300
3301    #[test]
3302    fn no_overrides_matches_evaluate() {
3303        let (doc, _) = doc_with_ellipse_and_fill();
3304        let a = evaluate(&doc, doc.main, 0.0);
3305        let b = evaluate_with(&doc, doc.main, 0.0, &Overrides::default());
3306        assert_eq!(a, b);
3307    }
3308
3309    #[test]
3310    fn five_point_star_is_closed_with_10_corners() {
3311        let p = star_path(DVec2::ZERO, 5, Some(20.0), 50.0, 0.0);
3312        assert!(matches!(
3313            p.elements().last(),
3314            Some(kurbo::PathEl::ClosePath)
3315        ));
3316        // VectorPath emits CurveTo per edge (even for sharp corners: zero tangents).
3317        let curves = p
3318            .elements()
3319            .iter()
3320            .filter(|e| matches!(e, kurbo::PathEl::CurveTo(_, _, _)))
3321            .count();
3322        assert_eq!(curves, 10);
3323    }
3324
3325    #[test]
3326    fn polygon_six_points() {
3327        let p = star_path(DVec2::ZERO, 6, None, 40.0, 0.0);
3328        let curves = p
3329            .elements()
3330            .iter()
3331            .filter(|e| matches!(e, kurbo::PathEl::CurveTo(_, _, _)))
3332            .count();
3333        assert_eq!(curves, 6);
3334    }
3335
3336    #[test]
3337    fn star_roundness_changes_path() {
3338        let sharp = star_path(DVec2::ZERO, 5, Some(20.0), 50.0, 0.0);
3339        let rounded = star_path(DVec2::ZERO, 5, Some(20.0), 50.0, 8.0);
3340        assert_ne!(
3341            sharp.elements().len(),
3342            rounded.elements().len(),
3343            "roundness must add fillet segments"
3344        );
3345        // Rounded via VectorPath::round_corners doubles anchor count => more curves.
3346        assert!(rounded.elements().len() > sharp.elements().len());
3347    }
3348
3349    #[test]
3350    fn star_burst_ignores_inner_radius() {
3351        let star = star_path(DVec2::ZERO, 5, Some(20.0), 50.0, 0.0);
3352        let burst = star_path(DVec2::ZERO, 5, None, 50.0, 0.0);
3353        let star_verts = star
3354            .elements()
3355            .iter()
3356            .filter(|e| matches!(e, kurbo::PathEl::CurveTo(_, _, _)))
3357            .count();
3358        let burst_verts = burst
3359            .elements()
3360            .iter()
3361            .filter(|e| matches!(e, kurbo::PathEl::CurveTo(_, _, _)))
3362            .count();
3363        assert_eq!(star_verts, 10);
3364        assert_eq!(burst_verts, 5);
3365    }
3366
3367    #[test]
3368    fn shape_path_honors_roundness_override() {
3369        let mut doc = Document::empty();
3370        let star = doc.create_node(Node::new(
3371            "star",
3372            NodeKind::Shape(ShapeKind::Star {
3373                pos: Animated::new(DVec2::ZERO),
3374                points: Animated::new(5.0),
3375                inner_r: Animated::new(20.0),
3376                outer_r: Animated::new(50.0),
3377                roundness: Animated::new(0.0),
3378                kind: StarKind::Star,
3379            }),
3380        ));
3381        doc.attach(star, Parent::Comp(doc.main), 0).unwrap();
3382        let shape_kind = match &doc.nodes.get(star).unwrap().kind {
3383            NodeKind::Shape(s) => s.clone(),
3384            _ => unreachable!(),
3385        };
3386        let sharp = shape_path(&shape_kind, star, 0.0, &Overrides::default());
3387        let mut ov = Overrides::default();
3388        ov.set(star, PropPath::new("shape.roundness"), Value::F64(12.0));
3389        let rounded = shape_path(&shape_kind, star, 0.0, &ov);
3390        assert_ne!(
3391            sharp.elements().len(),
3392            rounded.elements().len(),
3393            "roundness override must affect path"
3394        );
3395    }
3396
3397    #[test]
3398    fn stroke_solid_override_uses_stroke_color_path() {
3399        let mut doc = Document::empty();
3400        let comp = doc.main;
3401        let shape = doc.create_node(Node::new(
3402            "s",
3403            NodeKind::Shape(ShapeKind::Ellipse {
3404                pos: Animated::new(DVec2::ZERO),
3405                size: Animated::new(DVec2::splat(100.0)),
3406            }),
3407        ));
3408        let stroke = doc.create_node(Node::new(
3409            "st",
3410            NodeKind::Style(StyleKind::Stroke {
3411                paint: StylePaint::solid(Color::BLACK),
3412                width: Animated::new(4.0),
3413                cap: StrokeCap::Butt,
3414                join: StrokeJoin::Miter,
3415                dash: None,
3416            }),
3417        ));
3418        doc.attach(shape, Parent::Comp(comp), 0).unwrap();
3419        doc.attach(stroke, Parent::Comp(comp), 1).unwrap();
3420        // Override stroke.color to red.
3421        let mut ov = Overrides::default();
3422        ov.set(
3423            stroke,
3424            PropPath::new("stroke.color"),
3425            Value::Color(Color::rgba(1.0, 0.0, 0.0, 1.0)),
3426        );
3427        let scene = evaluate_with(&doc, comp, 0.0, &ov);
3428        assert_eq!(scene.items.len(), 1);
3429        assert_eq!(
3430            scene.items[0].paint,
3431            ScenePaint::Solid(Color::rgba(1.0, 0.0, 0.0, 1.0))
3432        );
3433        // Ensure fill.color override does NOT recolor the stroke.
3434        let mut ov2 = Overrides::default();
3435        ov2.set(
3436            stroke,
3437            PropPath::new("fill.color"),
3438            Value::Color(Color::rgba(0.0, 1.0, 0.0, 1.0)),
3439        );
3440        let scene2 = evaluate_with(&doc, comp, 0.0, &ov2);
3441        assert_eq!(scene2.items[0].paint, ScenePaint::Solid(Color::BLACK));
3442    }
3443
3444    fn find_fill(doc: &Document) -> NodeId {
3445        let mut found = None;
3446        for (id, n) in doc.nodes.iter() {
3447            if let NodeKind::Style(StyleKind::Fill { .. }) = n.kind {
3448                found = Some(id);
3449            }
3450        }
3451        found.unwrap()
3452    }
3453
3454    #[test]
3455    fn text_node_evaluates_to_scene_items() {
3456        let mut doc = Document::empty();
3457        let comp = doc.main;
3458        let text = doc.create_node(Node::new(
3459            "t",
3460            NodeKind::Text(TextNode {
3461                text: "Hi".into(),
3462                size: Animated::new(64.0),
3463                align: TextAlign::Left,
3464                font: None,
3465            }),
3466        ));
3467        let fill = doc.create_node(Node::new(
3468            "f",
3469            NodeKind::Style(StyleKind::Fill {
3470                paint: StylePaint::solid(Color::BLACK),
3471                rule: FillRule::NonZero,
3472            }),
3473        ));
3474        doc.attach(text, Parent::Comp(comp), 0).unwrap();
3475        doc.attach(fill, Parent::Comp(comp), 1).unwrap();
3476        let scene = evaluate(&doc, comp, 0.0);
3477        assert_eq!(scene.items.len(), 1);
3478        assert!(!scene.items[0].path.elements().is_empty());
3479        assert_eq!(scene.items[0].node, text);
3480    }
3481
3482    #[test]
3483    fn font_asset_lookup_finds_family() {
3484        let mut doc = Document::empty();
3485        let id = doc.assets.insert(Asset::Font(FontAsset {
3486            name: "Test".into(),
3487            family: "test".into(),
3488            bytes: vec![1, 2, 3],
3489        }));
3490
3491        let found = doc.font_asset_for_family("test").unwrap();
3492        assert_eq!(found.0, id);
3493        assert_eq!(found.1.family, "test");
3494        assert!(doc.font_asset_for_family("missing").is_none());
3495    }
3496
3497    #[test]
3498    fn font_families_sorted_and_deduped() {
3499        let mut doc = Document::empty();
3500        let a = doc.assets.insert(Asset::Font(FontAsset {
3501            name: "B".into(),
3502            family: "zeta".into(),
3503            bytes: vec![],
3504        }));
3505        let b = doc.assets.insert(Asset::Font(FontAsset {
3506            name: "A".into(),
3507            family: "alpha".into(),
3508            bytes: vec![],
3509        }));
3510        let c = doc.assets.insert(Asset::Font(FontAsset {
3511            name: "dup".into(),
3512            family: "alpha".into(),
3513            bytes: vec![],
3514        }));
3515        doc.asset_order.extend([a, b, c]);
3516        assert_eq!(doc.font_families(), vec!["alpha", "zeta"]);
3517    }
3518
3519    #[test]
3520    fn text_prefers_project_font_family() {
3521        let mut doc = Document::empty();
3522        let comp = doc.main;
3523
3524        let font_bytes = include_bytes!("../../renamite-text/assets/default.ttf").to_vec();
3525        doc.assets.insert(Asset::Font(FontAsset {
3526            name: "Default Test Font".into(),
3527            family: "testfont".into(),
3528            bytes: font_bytes,
3529        }));
3530
3531        let text = doc.create_node(Node::new(
3532            "t",
3533            NodeKind::Text(TextNode {
3534                text: "Hello".into(),
3535                size: Animated::new(48.0),
3536                align: TextAlign::Left,
3537                font: Some("testfont".into()),
3538            }),
3539        ));
3540        let fill = doc.create_node(Node::new(
3541            "f",
3542            NodeKind::Style(StyleKind::Fill {
3543                paint: StylePaint::solid(Color::BLACK),
3544                rule: FillRule::NonZero,
3545            }),
3546        ));
3547        doc.attach(text, Parent::Comp(comp), 0).unwrap();
3548        doc.attach(fill, Parent::Comp(comp), 1).unwrap();
3549
3550        let scene = evaluate(&doc, comp, 0.0);
3551        assert_eq!(scene.items.len(), 1);
3552        assert!(!scene.items[0].path.elements().is_empty());
3553    }
3554
3555    #[test]
3556    fn text_with_unknown_family_falls_back_to_default() {
3557        let mut doc = Document::empty();
3558        let comp = doc.main;
3559        let text = doc.create_node(Node::new(
3560            "t",
3561            NodeKind::Text(TextNode {
3562                text: "Hello".into(),
3563                size: Animated::new(48.0),
3564                align: TextAlign::Left,
3565                font: Some("not-a-font-family".into()),
3566            }),
3567        ));
3568        let fill = doc.create_node(Node::new(
3569            "f",
3570            NodeKind::Style(StyleKind::Fill {
3571                paint: StylePaint::solid(Color::BLACK),
3572                rule: FillRule::NonZero,
3573            }),
3574        ));
3575        doc.attach(text, Parent::Comp(comp), 0).unwrap();
3576        doc.attach(fill, Parent::Comp(comp), 1).unwrap();
3577
3578        let scene = evaluate(&doc, comp, 0.0);
3579        assert_eq!(scene.items.len(), 1);
3580        assert!(!scene.items[0].path.elements().is_empty());
3581    }
3582
3583    #[test]
3584    fn image_layer_emits_ordered_scene_item() {
3585        let mut doc = Document::empty();
3586        let comp = doc.main;
3587
3588        let asset = doc.assets.insert(Asset::Image(ImageAsset {
3589            name: "test.png".into(),
3590            mime: "image/png".into(),
3591            bytes: vec![1, 2, 3],
3592            width: 64,
3593            height: 32,
3594            srgb: true,
3595        }));
3596        doc.asset_order.push(asset);
3597
3598        let mut node = Node::new("Image", NodeKind::Image(asset));
3599        node.transform.anchor = Animated::new(glam::DVec2::new(32.0, 16.0));
3600        node.transform.position = Animated::new(glam::DVec2::new(100.0, 100.0));
3601
3602        let image = doc.create_node(node);
3603        doc.attach(image, Parent::Comp(comp), 0).unwrap();
3604
3605        let scene = evaluate(&doc, comp, 0.0);
3606
3607        assert_eq!(scene.items.len(), 1);
3608        assert_eq!(scene.items[0].node, image);
3609
3610        assert!(matches!(
3611            scene.items[0].paint,
3612            ScenePaint::Image { asset: id, .. } if id == asset
3613        ));
3614    }
3615
3616    #[test]
3617    fn garbage_collect_keeps_referenced_images() {
3618        let mut doc = Document::empty();
3619        let comp = doc.main;
3620
3621        let used = doc.assets.insert(Asset::Image(ImageAsset {
3622            name: "used.png".into(),
3623            mime: "image/png".into(),
3624            bytes: vec![],
3625            width: 1,
3626            height: 1,
3627            srgb: true,
3628        }));
3629        let orphan = doc.assets.insert(Asset::Image(ImageAsset {
3630            name: "orphan.png".into(),
3631            mime: "image/png".into(),
3632            bytes: vec![],
3633            width: 1,
3634            height: 1,
3635            srgb: true,
3636        }));
3637        doc.asset_order.push(used);
3638
3639        let image = doc.create_node(Node::new("Image", NodeKind::Image(used)));
3640        doc.attach(image, Parent::Comp(comp), 0).unwrap();
3641
3642        doc.garbage_collect();
3643
3644        assert!(doc.assets.contains_key(used));
3645        assert!(!doc.assets.contains_key(orphan));
3646        assert_eq!(doc.asset_order, vec![used]);
3647    }
3648
3649    #[test]
3650    fn normalize_assets_repairs_order() {
3651        let mut doc = Document::empty();
3652        let a = doc.assets.insert(Asset::Font(FontAsset {
3653            name: "A".into(),
3654            family: "a".into(),
3655            bytes: vec![],
3656        }));
3657        let b = doc.assets.insert(Asset::Font(FontAsset {
3658            name: "B".into(),
3659            family: "b".into(),
3660            bytes: vec![],
3661        }));
3662        // Legacy-style malformed order: missing id, duplicate, dangling id.
3663        let dangling = doc.assets.insert(Asset::Font(FontAsset {
3664            name: "d".into(),
3665            family: "d".into(),
3666            bytes: vec![],
3667        }));
3668        doc.assets.remove(dangling);
3669        doc.asset_order = vec![a, a, dangling];
3670
3671        doc.normalize_assets();
3672
3673        assert_eq!(doc.asset_order, vec![a, b]);
3674    }
3675
3676    #[test]
3677    fn pick_hits_center_and_misses_outside() {
3678        let (doc, shape) = doc_with_ellipse_and_fill();
3679        let scene = evaluate(&doc, doc.main, 0.0);
3680        assert_eq!(pick(&scene, DVec2::ZERO), Some(shape));
3681        assert_eq!(pick(&scene, DVec2::new(500.0, 500.0)), None);
3682    }
3683
3684    #[test]
3685    fn pick_box_contains_fully() {
3686        let (doc, shape) = doc_with_ellipse_and_fill();
3687        let scene = evaluate(&doc, doc.main, 0.0);
3688        let picked = pick_box(&scene, DVec2::splat(-200.0), DVec2::splat(200.0));
3689        assert_eq!(picked, vec![shape]);
3690    }
3691
3692    #[test]
3693    fn dashed_stroke_gaps_are_not_pickable() {
3694        let mut path = BezPath::new();
3695        path.move_to((0.0, 0.0));
3696        path.line_to((40.0, 0.0));
3697
3698        let shape = NodeId::default();
3699
3700        let scene = Scene {
3701            clips: vec![],
3702            items: vec![SceneItem {
3703                path,
3704                node: shape,
3705                style: NodeId::default(),
3706                paint: ScenePaint::Solid(Color::BLACK),
3707                kind: PaintKind::Stroke(StrokeSample {
3708                    width: 4.0,
3709                    cap: StrokeCap::Butt,
3710                    join: StrokeJoin::Miter,
3711                    dash: Some(DashSample {
3712                        dashes: vec![10.0, 10.0],
3713                        offset: 0.0,
3714                    }),
3715                }),
3716                opacity: 1.0,
3717                clips: vec![],
3718                blend: BlendMode::Normal,
3719            }],
3720        };
3721
3722        assert_eq!(pick(&scene, DVec2::new(5.0, 0.0)), Some(shape),);
3723
3724        assert_eq!(
3725            pick(&scene, DVec2::new(15.0, 0.0)),
3726            None,
3727            "point lies inside an off-gap",
3728        );
3729
3730        assert_eq!(pick(&scene, DVec2::new(25.0, 0.0)), Some(shape),);
3731    }
3732
3733    #[test]
3734    fn gradient_fill_emits_linear_paint() {
3735        let (mut doc, _) = doc_with_ellipse_and_fill();
3736        let fill = find_fill(&doc);
3737        let NodeKind::Style(st) = &mut doc.nodes[fill].kind else {
3738            panic!("fill node missing");
3739        };
3740        st.swap_paint(StylePaint::linear(
3741            DVec2::new(0.0, 0.0),
3742            DVec2::new(100.0, 0.0),
3743            GradientStops(vec![
3744                GradientStop {
3745                    offset: 0.0,
3746                    color: Color::BLACK,
3747                },
3748                GradientStop {
3749                    offset: 1.0,
3750                    color: Color::WHITE,
3751                },
3752            ]),
3753        ));
3754        let scene = evaluate(&doc, doc.main, 0.0);
3755        let item = &scene.items[0];
3756        match &item.paint {
3757            ScenePaint::LinearGradient { start, end, .. } => {
3758                assert!((start.x - 0.0).abs() < 1e-9);
3759                assert!((end.x - 100.0).abs() < 1e-9);
3760            }
3761            other => panic!("expected linear gradient, got {other:?}"),
3762        }
3763    }
3764
3765    #[test]
3766    fn radial_gradient_keeps_radius_for_identity_transform() {
3767        let (mut doc, _) = doc_with_ellipse_and_fill();
3768        let fill = find_fill(&doc);
3769        let NodeKind::Style(st) = &mut doc.nodes[fill].kind else {
3770            panic!("fill node missing");
3771        };
3772        st.swap_paint(StylePaint::radial(
3773            DVec2::new(0.0, 0.0),
3774            DVec2::new(120.0, 0.0),
3775            GradientStops(vec![GradientStop {
3776                offset: 0.0,
3777                color: Color::WHITE,
3778            }]),
3779        ));
3780        let scene = evaluate(&doc, doc.main, 0.0);
3781        let item = &scene.items[0];
3782        match &item.paint {
3783            ScenePaint::RadialGradient { center, end, .. } => {
3784                assert!((center.x - 0.0).abs() < 1e-9 && (center.y - 0.0).abs() < 1e-9);
3785                assert!((end.x - 120.0).abs() < 1e-9, "radius must not degenerate");
3786            }
3787            other => panic!("expected radial gradient, got {other:?}"),
3788        }
3789    }
3790
3791    #[test]
3792    fn scene_items_carry_the_painting_style_and_fill_style_for_resolves() {
3793        let (doc, shape) = doc_with_ellipse_and_fill();
3794        let fill = find_fill(&doc);
3795        assert_eq!(fill_style_for(&doc, shape), Some(fill));
3796        let scene = evaluate(&doc, doc.main, 0.0);
3797        assert_eq!(scene.items.len(), 1);
3798        assert_eq!(scene.items[0].node, shape);
3799        assert_eq!(scene.items[0].style, fill);
3800    }
3801
3802    #[test]
3803    fn nodes_bounds_unions_selected() {
3804        let (doc, shape) = doc_with_ellipse_and_fill();
3805        let scene = evaluate(&doc, doc.main, 0.0);
3806        let (min, max) = nodes_bounds(&scene, &[shape]).unwrap();
3807        assert!(
3808            min.x <= 0.0 && max.x >= 0.0,
3809            "bounds must cover the ellipse center"
3810        );
3811    }
3812
3813    #[test]
3814    fn dash_entries_are_addressable_properties() {
3815        let mut doc = Document::empty();
3816
3817        let stroke = doc.create_node(Node::new(
3818            "Stroke",
3819            NodeKind::Style(StyleKind::Stroke {
3820                paint: StylePaint::solid(Color::BLACK),
3821                width: Animated::new(4.0),
3822                cap: StrokeCap::Round,
3823                join: StrokeJoin::Round,
3824                dash: Some(AnimatedDash {
3825                    dashes: vec![Animated::new(12.0), Animated::new(8.0)],
3826                    offset: Animated::new(2.0),
3827                }),
3828            }),
3829        ));
3830
3831        doc.attach(stroke, Parent::Comp(doc.main), 0).unwrap();
3832
3833        assert_eq!(
3834            doc.value_at(stroke, &PropPath::new("stroke.dash.0"), 0.0,)
3835                .unwrap(),
3836            Value::F64(12.0),
3837        );
3838
3839        assert_eq!(
3840            doc.value_at(stroke, &PropPath::new("stroke.dash.offset"), 0.0,)
3841                .unwrap(),
3842            Value::F64(2.0),
3843        );
3844
3845        doc.set_static(stroke, &PropPath::new("stroke.dash.1"), &Value::F64(4.0))
3846            .unwrap();
3847
3848        assert_eq!(
3849            doc.value_at(stroke, &PropPath::new("stroke.dash.1"), 0.0,)
3850                .unwrap(),
3851            Value::F64(4.0),
3852        );
3853    }
3854
3855    #[test]
3856    fn repeater_falloff_fades_copies_linearly() {
3857        let mut doc = Document::empty();
3858        let comp = doc.main;
3859        let group = doc.create_node(Node::new("g", NodeKind::Group));
3860        let shape = doc.create_node(Node::new(
3861            "r",
3862            NodeKind::Shape(ShapeKind::Rect {
3863                pos: Animated::new(DVec2::new(30.0, 30.0)),
3864                size: Animated::new(DVec2::splat(40.0)),
3865                rounded: Animated::new(0.0),
3866            }),
3867        ));
3868        let mut step = AnimatedTransform::identity();
3869        step.position = Animated::new(DVec2::new(60.0, 0.0));
3870        let rep = doc.create_node(Node::new(
3871            "rp",
3872            NodeKind::Modifier(ModifierKind::Repeater {
3873                copies: Animated::new(3.0),
3874                offset: Animated::new(0.0),
3875                transform: step,
3876                start_opacity: Animated::new(1.0),
3877                end_opacity: Animated::new(0.2),
3878            }),
3879        ));
3880        let fill = doc.create_node(Node::new(
3881            "f",
3882            NodeKind::Style(StyleKind::Fill {
3883                paint: StylePaint::solid(Color::BLACK),
3884                rule: FillRule::NonZero,
3885            }),
3886        ));
3887        doc.attach(shape, Parent::Node(group), 0).unwrap();
3888        doc.attach(rep, Parent::Node(group), 1).unwrap();
3889        doc.attach(fill, Parent::Node(group), 2).unwrap();
3890        doc.attach(group, Parent::Comp(comp), 0).unwrap();
3891
3892        let scene = evaluate(&doc, comp, 0.0);
3893        assert_eq!(scene.items.len(), 3);
3894        let ops: Vec<f64> = scene.items.iter().map(|i| i.opacity).collect();
3895        assert!((ops[0] - 1.0).abs() < 1e-9);
3896        assert!(
3897            (ops[1] - 0.6).abs() < 1e-9,
3898            "midpoint of 1.0..0.2, got {}",
3899            ops[1]
3900        );
3901        assert!((ops[2] - 0.2).abs() < 1e-9);
3902    }
3903
3904    #[test]
3905    fn repeater_opacity_props_are_addressable() {
3906        let mut doc = Document::empty();
3907        let id = doc.create_node(Node::new(
3908            "rp",
3909            NodeKind::Modifier(ModifierKind::Repeater {
3910                copies: Animated::new(2.0),
3911                offset: Animated::new(0.0),
3912                transform: AnimatedTransform::identity(),
3913                start_opacity: Animated::new(1.0),
3914                end_opacity: Animated::new(0.25),
3915            }),
3916        ));
3917
3918        doc.attach(id, Parent::Comp(doc.main), 0).unwrap();
3919
3920        assert_eq!(
3921            doc.value_at(id, &PropPath::new("repeater.start_opacity"), 0.0)
3922                .unwrap(),
3923            Value::F64(1.0),
3924        );
3925
3926        assert_eq!(
3927            doc.value_at(id, &PropPath::new("repeater.end_opacity"), 0.0)
3928                .unwrap(),
3929            Value::F64(0.25),
3930        );
3931
3932        doc.set_static(id, &PropPath::new("repeater.end_opacity"), &Value::F64(0.5))
3933            .unwrap();
3934
3935        assert_eq!(
3936            doc.value_at(id, &PropPath::new("repeater.end_opacity"), 0.0)
3937                .unwrap(),
3938            Value::F64(0.5),
3939        );
3940    }
3941
3942    #[test]
3943    fn offset_path_expands_rect_bounds() {
3944        let mut doc = Document::empty();
3945        let comp = doc.main;
3946        let group = doc.create_node(Node::new("g", NodeKind::Group));
3947
3948        let rect = doc.create_node(Node::new(
3949            "r",
3950            NodeKind::Shape(ShapeKind::Rect {
3951                pos: Animated::new(DVec2::new(100.0, 100.0)),
3952                size: Animated::new(DVec2::new(100.0, 100.0)),
3953                rounded: Animated::new(0.0),
3954            }),
3955        ));
3956
3957        let offset = doc.create_node(Node::new(
3958            "op",
3959            NodeKind::Modifier(ModifierKind::OffsetPath {
3960                amount: Animated::new(10.0),
3961            }),
3962        ));
3963
3964        let fill = doc.create_node(Node::new(
3965            "f",
3966            NodeKind::Style(StyleKind::Fill {
3967                paint: StylePaint::solid(Color::WHITE),
3968                rule: FillRule::NonZero,
3969            }),
3970        ));
3971
3972        doc.attach(rect, Parent::Node(group), 0).unwrap();
3973        doc.attach(offset, Parent::Node(group), 1).unwrap();
3974        doc.attach(fill, Parent::Node(group), 2).unwrap();
3975        doc.attach(group, Parent::Comp(comp), 0).unwrap();
3976
3977        let scene = evaluate(&doc, comp, 0.0);
3978        let bb = scene.items[0].path.bounding_box();
3979
3980        assert!(bb.width() > 118.0, "bb = {:?}", bb);
3981        assert!(bb.height() > 118.0, "bb = {:?}", bb);
3982    }
3983
3984    #[test]
3985    fn offset_amount_property_is_addressable() {
3986        let mut doc = Document::empty();
3987
3988        let id = doc.create_node(Node::new(
3989            "op",
3990            NodeKind::Modifier(ModifierKind::OffsetPath {
3991                amount: Animated::new(5.0),
3992            }),
3993        ));
3994
3995        doc.attach(id, Parent::Comp(doc.main), 0).unwrap();
3996
3997        assert_eq!(
3998            doc.value_at(id, &PropPath::new("offset.amount"), 0.0)
3999                .unwrap(),
4000            Value::F64(5.0),
4001        );
4002
4003        doc.set_static(id, &PropPath::new("offset.amount"), &Value::F64(12.0))
4004            .unwrap();
4005
4006        assert_eq!(
4007            doc.value_at(id, &PropPath::new("offset.amount"), 0.0)
4008                .unwrap(),
4009            Value::F64(12.0),
4010        );
4011    }
4012
4013    #[test]
4014    fn zigzag_modifier_perturbs_rect_edge() {
4015        let mut doc = Document::empty();
4016        let comp = doc.main;
4017        let group = doc.create_node(Node::new("g", NodeKind::Group));
4018
4019        let rect = doc.create_node(Node::new(
4020            "r",
4021            NodeKind::Shape(ShapeKind::Rect {
4022                pos: Animated::new(DVec2::new(100.0, 100.0)),
4023                size: Animated::new(DVec2::new(100.0, 100.0)),
4024                rounded: Animated::new(0.0),
4025            }),
4026        ));
4027
4028        let zz = doc.create_node(Node::new(
4029            "zz",
4030            NodeKind::Modifier(ModifierKind::ZigZag {
4031                amplitude: Animated::new(10.0),
4032                frequency: Animated::new(4.0),
4033                smooth: false,
4034            }),
4035        ));
4036
4037        let fill = doc.create_node(Node::new(
4038            "f",
4039            NodeKind::Style(StyleKind::Fill {
4040                paint: StylePaint::solid(Color::WHITE),
4041                rule: FillRule::NonZero,
4042            }),
4043        ));
4044
4045        doc.attach(rect, Parent::Node(group), 0).unwrap();
4046        doc.attach(zz, Parent::Node(group), 1).unwrap();
4047        doc.attach(fill, Parent::Node(group), 2).unwrap();
4048        doc.attach(group, Parent::Comp(comp), 0).unwrap();
4049
4050        let scene = evaluate(&doc, comp, 0.0);
4051        let path = &scene.items[0].path;
4052        // Zig-zag adds extra vertices along the rect edges.
4053        let verts = path
4054            .elements()
4055            .iter()
4056            .filter(|e| matches!(e, kurbo::PathEl::LineTo(_) | kurbo::PathEl::MoveTo(_)))
4057            .count();
4058        assert!(verts > 4, "got {} vertices", verts);
4059    }
4060
4061    #[test]
4062    fn pucker_bloat_expands_and_contracts_bounds() {
4063        let mut doc = Document::empty();
4064        let comp = doc.main;
4065        let group = doc.create_node(Node::new("g", NodeKind::Group));
4066
4067        let rect = doc.create_node(Node::new(
4068            "r",
4069            NodeKind::Shape(ShapeKind::Rect {
4070                pos: Animated::new(DVec2::new(100.0, 100.0)),
4071                size: Animated::new(DVec2::new(100.0, 100.0)),
4072                rounded: Animated::new(0.0),
4073            }),
4074        ));
4075
4076        let fill = doc.create_node(Node::new(
4077            "f",
4078            NodeKind::Style(StyleKind::Fill {
4079                paint: StylePaint::solid(Color::WHITE),
4080                rule: FillRule::NonZero,
4081            }),
4082        ));
4083
4084        doc.attach(rect, Parent::Node(group), 0).unwrap();
4085        doc.attach(fill, Parent::Node(group), 2).unwrap();
4086        doc.attach(group, Parent::Comp(comp), 0).unwrap();
4087
4088        let base = evaluate(&doc, comp, 0.0);
4089        let base_bb = base.items[0].path.bounding_box();
4090
4091        let bloat = doc.create_node(Node::new(
4092            "pb",
4093            NodeKind::Modifier(ModifierKind::PuckerBloat {
4094                amount: Animated::new(50.0),
4095            }),
4096        ));
4097        doc.attach(bloat, Parent::Node(group), 1).unwrap();
4098
4099        let bloated = evaluate(&doc, comp, 0.0);
4100        let bloat_bb = bloated.items[0].path.bounding_box();
4101        assert!(
4102            bloat_bb.width() > base_bb.width(),
4103            "w {} vs {}",
4104            bloat_bb.width(),
4105            base_bb.width()
4106        );
4107        assert!(bloat_bb.height() > base_bb.height());
4108
4109        doc.set_static(bloat, &PropPath::new("pucker.amount"), &Value::F64(-50.0))
4110            .unwrap();
4111        let puckered = evaluate(&doc, comp, 0.0);
4112        let pucker_bb = puckered.items[0].path.bounding_box();
4113        // Vertices move toward the centroid for +amount (toward center) and
4114        // away for -amount, so negative amount must be strictly wider.
4115        assert!(
4116            pucker_bb.width() > bloat_bb.width(),
4117            "pucker {} vs bloat {}",
4118            pucker_bb.width(),
4119            bloat_bb.width()
4120        );
4121    }
4122
4123    #[test]
4124    fn zigzag_props_are_addressable() {
4125        let mut doc = Document::empty();
4126        let id = doc.create_node(Node::new(
4127            "zz",
4128            NodeKind::Modifier(ModifierKind::ZigZag {
4129                amplitude: Animated::new(5.0),
4130                frequency: Animated::new(3.0),
4131                smooth: true,
4132            }),
4133        ));
4134        doc.attach(id, Parent::Comp(doc.main), 0).unwrap();
4135
4136        assert_eq!(
4137            doc.value_at(id, &PropPath::new("zigzag.amplitude"), 0.0)
4138                .unwrap(),
4139            Value::F64(5.0),
4140        );
4141        assert_eq!(
4142            doc.value_at(id, &PropPath::new("zigzag.frequency"), 0.0)
4143                .unwrap(),
4144            Value::F64(3.0),
4145        );
4146
4147        doc.set_static(id, &PropPath::new("zigzag.amplitude"), &Value::F64(12.0))
4148            .unwrap();
4149        assert_eq!(
4150            doc.value_at(id, &PropPath::new("zigzag.amplitude"), 0.0)
4151                .unwrap(),
4152            Value::F64(12.0),
4153        );
4154    }
4155
4156    #[test]
4157    fn pucker_amount_property_is_addressable() {
4158        let mut doc = Document::empty();
4159        let id = doc.create_node(Node::new(
4160            "pb",
4161            NodeKind::Modifier(ModifierKind::PuckerBloat {
4162                amount: Animated::new(20.0),
4163            }),
4164        ));
4165        doc.attach(id, Parent::Comp(doc.main), 0).unwrap();
4166
4167        assert_eq!(
4168            doc.value_at(id, &PropPath::new("pucker.amount"), 0.0)
4169                .unwrap(),
4170            Value::F64(20.0),
4171        );
4172
4173        doc.set_static(id, &PropPath::new("pucker.amount"), &Value::F64(-30.0))
4174            .unwrap();
4175        assert_eq!(
4176            doc.value_at(id, &PropPath::new("pucker.amount"), 0.0)
4177                .unwrap(),
4178            Value::F64(-30.0),
4179        );
4180    }
4181}
4182
4183#[cfg(test)]
4184mod trim_tests {
4185    use super::*;
4186    use kurbo::Shape;
4187
4188    fn line() -> BezPath {
4189        let mut p = BezPath::new();
4190        p.move_to((0.0, 0.0));
4191        p.line_to((100.0, 0.0));
4192        p
4193    }
4194
4195    #[test]
4196    fn trim_first_quarter_of_line() {
4197        let out = trim_path(&line(), 0.0, 0.25, 0.0).unwrap();
4198        let bb = out.bounding_box();
4199        assert!((bb.x1 - 25.0).abs() < 0.5, "x1={}", bb.x1);
4200        assert!(bb.x0.abs() < 0.5);
4201    }
4202
4203    #[test]
4204    fn trim_second_half_of_line() {
4205        let out = trim_path(&line(), 0.5, 1.0, 0.0).unwrap();
4206        let bb = out.bounding_box();
4207        assert!((bb.x0 - 50.0).abs() < 0.5 && (bb.x1 - 100.0).abs() < 0.5);
4208    }
4209
4210    #[test]
4211    fn trim_zero_length_returns_none() {
4212        assert!(trim_path(&line(), 0.5, 0.5, 0.0).is_none());
4213    }
4214
4215    #[test]
4216    fn trim_offset_shifts_range() {
4217        let a = trim_path(&line(), 0.0, 0.5, 0.0).unwrap();
4218        let b = trim_path(&line(), 0.0, 0.5, 0.5).unwrap();
4219        assert!((a.bounding_box().x1 - 50.0).abs() < 0.5);
4220        assert!(
4221            (b.bounding_box().x0 - 50.0).abs() < 0.5,
4222            "offset must shift to second half"
4223        );
4224    }
4225
4226    #[test]
4227    fn trim_wraps_when_offset_pushes_past_end() {
4228        // [0, 0.5] + offset 0.75 → [0.75, 1] ∪ [0, 0.25]: both ends, gap in middle.
4229        let out = trim_path(&line(), 0.0, 0.5, 0.75).unwrap();
4230        let bb = out.bounding_box();
4231        assert!(bb.x0 < 1.0 && bb.x1 > 99.0, "both ends present");
4232        // Two disconnected subpaths → two MoveTo elements.
4233        let moves = out
4234            .elements()
4235            .iter()
4236            .filter(|el| matches!(el, kurbo::PathEl::MoveTo(_)))
4237            .count();
4238        assert_eq!(moves, 2);
4239    }
4240
4241    #[test]
4242    fn trim_quarter_of_closed_square_is_one_side() {
4243        let sq = kurbo::Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1);
4244        let out = trim_path(&sq, 0.0, 0.25, 0.0).unwrap();
4245        let bb = out.bounding_box();
4246        // One side of the square: long in one axis, ~zero in the other.
4247        assert!(bb.width().min(bb.height()) < 1.0);
4248        assert!((bb.width().max(bb.height()) - 100.0).abs() < 1.0);
4249    }
4250
4251    #[test]
4252    fn full_range_with_offset_emits_whole_path() {
4253        let out = trim_path(&line(), 0.0, 1.0, 0.3).unwrap();
4254        let bb = out.bounding_box();
4255        assert!(bb.x0 < 0.5 && bb.x1 > 99.5);
4256    }
4257}
4258
4259#[cfg(test)]
4260mod style_paint_tests {
4261    use super::*;
4262    use glam::DVec2;
4263
4264    #[test]
4265    fn paint_snapshot_samples_without_copying_keys() {
4266        let mut color = Animated::new(Color::BLACK);
4267        color.set_key(Frame(0), Color::BLACK);
4268        color.set_key(Frame(10), Color::WHITE);
4269
4270        let sampled = StylePaint::Solid { color }.snapshot(10.0);
4271        let StylePaint::Solid { color } = sampled else {
4272            panic!("expected solid");
4273        };
4274
4275        assert_eq!(color.base, Color::WHITE);
4276        assert!(color.keyframes.is_empty());
4277    }
4278
4279    #[test]
4280    fn set_base_color_preserves_gradient() {
4281        let mut paint =
4282            StylePaint::linear(glam::DVec2::ZERO, glam::DVec2::X, GradientStops::default());
4283
4284        let red = Color::rgba(1.0, 0.0, 0.0, 1.0);
4285        paint.set_base_color(red);
4286
4287        let StylePaint::Gradient(gradient) = paint else {
4288            panic!("must remain a gradient");
4289        };
4290        assert_eq!(gradient.stops.base.0[0].color, red);
4291    }
4292
4293    fn doc_with_square_and_fill() -> (Document, NodeId) {
4294        let mut doc = Document::empty();
4295        let rect = doc.create_node(Node::new(
4296            "r",
4297            NodeKind::Shape(ShapeKind::Rect {
4298                pos: Animated::new(DVec2::new(0.0, 0.0)),
4299                size: Animated::new(DVec2::new(200.0, 200.0)),
4300                rounded: Animated::new(0.0),
4301            }),
4302        ));
4303        let fill = doc.create_node(Node::new(
4304            "f",
4305            NodeKind::Style(StyleKind::Fill {
4306                paint: StylePaint::solid(Color::WHITE),
4307                rule: FillRule::NonZero,
4308            }),
4309        ));
4310        doc.attach(rect, Parent::Comp(doc.main), 0).unwrap();
4311        doc.attach(fill, Parent::Comp(doc.main), 1).unwrap();
4312        (doc, rect)
4313    }
4314
4315    #[test]
4316    fn mask_clips_subsequent_siblings() {
4317        let (mut doc, square) = doc_with_square_and_fill();
4318        let mask = doc.create_node(Node::new(
4319            "m",
4320            NodeKind::Mask(MaskProps {
4321                inverted: false,
4322                shape: ShapeKind::Ellipse {
4323                    pos: Animated::new(DVec2::new(100.0, 100.0)),
4324                    size: Animated::new(DVec2::new(50.0, 50.0)),
4325                },
4326            }),
4327        ));
4328        let comp = doc.main;
4329        // Detach the already-attached square, reattach it after the mask.
4330        let (_, _) = doc.detach(square).unwrap();
4331        doc.attach(mask, Parent::Comp(comp), 0).unwrap();
4332        doc.attach(square, Parent::Comp(comp), 1).unwrap();
4333
4334        let scene = evaluate(&doc, comp, 0.0);
4335        assert_eq!(scene.items.len(), 1);
4336        let item = &scene.items[0];
4337        assert_eq!(item.node, square);
4338        assert_eq!(item.clips.len(), 1);
4339        assert_eq!(scene.clips.len(), 1);
4340        assert_eq!(scene.clips[0].rule, FillRule::NonZero);
4341    }
4342
4343    #[test]
4344    fn inverted_mask_uses_evenodd_rule() {
4345        let (mut doc, square) = doc_with_square_and_fill();
4346        let mask = doc.create_node(Node::new(
4347            "m",
4348            NodeKind::Mask(MaskProps {
4349                inverted: true,
4350                shape: ShapeKind::Rect {
4351                    pos: Animated::new(DVec2::new(100.0, 100.0)),
4352                    size: Animated::new(DVec2::new(50.0, 50.0)),
4353                    rounded: Animated::new(0.0),
4354                },
4355            }),
4356        ));
4357        let comp = doc.main;
4358        let (_, _) = doc.detach(square).unwrap();
4359        doc.attach(mask, Parent::Comp(comp), 0).unwrap();
4360        doc.attach(square, Parent::Comp(comp), 1).unwrap();
4361
4362        let scene = evaluate(&doc, comp, 0.0);
4363        assert_eq!(scene.items.len(), 1);
4364        assert_eq!(scene.items[0].node, square);
4365        assert_eq!(scene.clips.len(), 1);
4366        assert_eq!(scene.clips[0].rule, FillRule::EvenOdd);
4367    }
4368
4369    #[test]
4370    fn mask_clips_image_siblings() {
4371        let mut doc = Document::empty();
4372        let comp = doc.main;
4373        let mask = doc.create_node(Node::new(
4374            "m",
4375            NodeKind::Mask(MaskProps {
4376                inverted: false,
4377                shape: ShapeKind::Rect {
4378                    pos: Animated::new(DVec2::new(0.0, 0.0)),
4379                    size: Animated::new(DVec2::new(10.0, 10.0)),
4380                    rounded: Animated::new(0.0),
4381                },
4382            }),
4383        ));
4384        let img_asset = doc.assets.insert(Asset::Image(ImageAsset {
4385            name: "img".into(),
4386            mime: "image/png".into(),
4387            bytes: Vec::new(),
4388            width: 64,
4389            height: 64,
4390            srgb: true,
4391        }));
4392        let img = doc.create_node(Node::new("i", NodeKind::Image(img_asset)));
4393        doc.attach(mask, Parent::Comp(comp), 0).unwrap();
4394        doc.attach(img, Parent::Comp(comp), 1).unwrap();
4395
4396        let scene = evaluate(&doc, comp, 0.0);
4397        assert_eq!(scene.items.len(), 1);
4398        assert_eq!(scene.items[0].clips.len(), 1);
4399    }
4400
4401    #[test]
4402    fn mask_param_paths_edit_mask_geometry() {
4403        let mut doc = Document::empty();
4404        let mask = doc.create_node(Node::new(
4405            "m",
4406            NodeKind::Mask(MaskProps {
4407                inverted: false,
4408                shape: ShapeKind::Rect {
4409                    pos: Animated::new(DVec2::new(0.0, 0.0)),
4410                    size: Animated::new(DVec2::new(10.0, 20.0)),
4411                    rounded: Animated::new(0.0),
4412                },
4413            }),
4414        ));
4415        let mut node = doc.nodes.get_mut(mask).unwrap();
4416        let Some(PropMut::Vec2(v)) = node.prop_mut(&PropPath::new("shape.pos")) else {
4417            panic!("mask shape.pos not addressable");
4418        };
4419        v.base = DVec2::new(5.0, 5.0);
4420        drop(node);
4421        let n = doc.nodes.get_mut(mask).unwrap();
4422        let Some(PropRef::Vec2(v)) = n.prop_ref(&PropPath::new("shape.pos")) else {
4423            panic!("mask shape.pos not readable");
4424        };
4425        assert_eq!(v.base, DVec2::new(5.0, 5.0));
4426    }
4427}
4428
4429#[cfg(test)]
4430mod group_transform_tests {
4431    use super::*;
4432
4433    fn grouped_rect() -> (Document, NodeId, NodeId) {
4434        let mut doc = Document::empty();
4435        let comp = doc.main;
4436
4437        let group = doc.create_node(Node::new("Group", NodeKind::Group));
4438
4439        let shape = doc.create_node(Node::new(
4440            "Rect",
4441            NodeKind::Shape(ShapeKind::Rect {
4442                pos: Animated::new(glam::DVec2::new(100.0, 80.0)),
4443                size: Animated::new(glam::DVec2::new(60.0, 40.0)),
4444                rounded: Animated::new(0.0),
4445            }),
4446        ));
4447
4448        let fill = doc.create_node(Node::new(
4449            "Fill",
4450            NodeKind::Style(StyleKind::Fill {
4451                paint: StylePaint::solid(Color::BLACK),
4452                rule: FillRule::NonZero,
4453            }),
4454        ));
4455
4456        doc.attach(shape, Parent::Node(group), 0).unwrap();
4457        doc.attach(fill, Parent::Node(group), 1).unwrap();
4458        doc.attach(group, Parent::Comp(comp), 0).unwrap();
4459
4460        (doc, group, shape)
4461    }
4462
4463    #[test]
4464    fn group_selection_bounds_include_descendants() {
4465        let (doc, group, _) = grouped_rect();
4466        let scene = evaluate(&doc, doc.main, 0.0);
4467
4468        let bounds = selection_bounds(&doc, &scene, &[group]);
4469
4470        assert!(bounds.is_some());
4471
4472        let (min, max) = bounds.unwrap();
4473        assert!(max.x > min.x);
4474        assert!(max.y > min.y);
4475    }
4476
4477    #[test]
4478    fn selected_group_is_resolved_from_child_pick() {
4479        let (doc, group, shape) = grouped_rect();
4480
4481        assert_eq!(
4482            selected_ancestor_for_pick(&doc, shape, &[group]),
4483            Some(group),
4484        );
4485    }
4486
4487    #[test]
4488    fn immediate_child_resolution_descends_one_level() {
4489        let mut doc = Document::empty();
4490
4491        let outer = doc.create_node(Node::new("Outer", NodeKind::Group));
4492        let inner = doc.create_node(Node::new("Inner", NodeKind::Group));
4493        let shape = doc.create_node(Node::new(
4494            "Shape",
4495            NodeKind::Shape(ShapeKind::Ellipse {
4496                pos: Animated::new(glam::DVec2::ZERO),
4497                size: Animated::new(glam::DVec2::ONE),
4498            }),
4499        ));
4500
4501        doc.attach(shape, Parent::Node(inner), 0).unwrap();
4502        doc.attach(inner, Parent::Node(outer), 0).unwrap();
4503        doc.attach(outer, Parent::Comp(doc.main), 0).unwrap();
4504
4505        assert_eq!(immediate_child_below(&doc, outer, shape), Some(inner));
4506    }
4507
4508    #[test]
4509    fn nested_parent_delta_conversion_respects_scale() {
4510        let (mut doc, group, shape) = grouped_rect();
4511
4512        doc.nodes[group].transform.scale = Animated::new(glam::DVec2::splat(200.0));
4513
4514        let local = world_delta_to_parent(&doc, shape, 0.0, glam::DVec2::new(20.0, 10.0)).unwrap();
4515
4516        assert!((local - glam::DVec2::new(10.0, 5.0)).length() < 1e-9);
4517    }
4518}