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