Skip to main content

renamite_model/
lib.rs

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