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