Skip to main content

rustmotion_core/engine/
animator.rs

1use crate::schema::{
2    Animation, AnimationEffect, AnimationPreset, CharAnimPreset, EasingType, GlowConfig, Keyframe,
3    KeyframeValue, MotionPathConfig, OrbitConfig, PresetConfig, SpringConfig, TextAnimDirection,
4    TextAnimGranularity, WiggleConfig,
5};
6
7/// Default starting blur sigma (px) for `char_blur_in` when
8/// `CharAnimationTiming.blur` is not set. Tuned against rendered output at
9/// 120px display type (see issue #118's render proof): low enough that
10/// individual letterforms stay ghost-legible at the start of a unit's
11/// reveal (this is a *reveal*, not a smoke effect), high enough that the
12/// blur is unmistakable next to the settled, sharp frame.
13pub const DEFAULT_CHAR_BLUR_SIGMA: f32 = 14.0;
14
15/// Safe division that returns `fallback` when the denominator is too small to
16/// produce a meaningful result (within 1e-9). Use this for any calculation
17/// where a zero-or-near-zero duration could otherwise produce NaN/∞ that
18/// silently propagates into transforms or opacity.
19#[inline]
20pub fn safe_div(num: f64, denom: f64, fallback: f64) -> f64 {
21    if denom.abs() < 1e-9 {
22        fallback
23    } else {
24        num / denom
25    }
26}
27
28/// Same as `safe_div` but for f32. Useful in render-side hot paths.
29#[inline]
30pub fn safe_div_f32(num: f32, denom: f32, fallback: f32) -> f32 {
31    if denom.abs() < 1e-6 {
32        fallback
33    } else {
34        num / denom
35    }
36}
37
38// ─── Effect extraction ──────────────────────────────────────────────────────
39
40/// Resolved char animation config ready for the text renderer.
41#[derive(Debug, Clone)]
42pub struct ResolvedCharAnimation {
43    pub preset: CharAnimPreset,
44    pub granularity: TextAnimGranularity,
45    pub stagger: f32,
46    pub duration: f32,
47    pub easing: EasingType,
48    pub delay: f32,
49    pub overshoot: f32,
50    /// Starting blur sigma in px (`char_blur_in` only; 0 elsewhere).
51    pub blur: f32,
52    /// Travel direction for the presets whose motion is a translate.
53    pub direction: TextAnimDirection,
54    /// Multiplier on the preset's own travel distance (1.0 = as tuned).
55    pub distance: f32,
56    /// Scale each unit starts at, or `None` for no scaling.
57    pub scale_from: Option<f32>,
58    /// ±fraction of `stagger` each unit's start is nudged by (0 = even).
59    pub jitter: f32,
60    /// Seed for the deterministic jitter offsets.
61    pub seed: u32,
62    /// Colour each unit starts at before settling to the text's own.
63    pub ink_from: Option<String>,
64}
65
66impl ResolvedCharAnimation {
67    /// When unit `idx` starts, in seconds, including its jitter nudge.
68    ///
69    /// The nudge is a pure function of `(idx, seed)` — deliberately not an
70    /// RNG. Frames are rendered out of order, in parallel, and sometimes in
71    /// separate processes (`--frames a-b` segments), so anything stateful
72    /// here would make a unit jump between neighbouring frames.
73    ///
74    /// It is also clamped so a unit never starts before the effect's own
75    /// `delay`: a negative start would make the first units appear already
76    /// half-animated on frame 0.
77    pub fn unit_start(&self, idx: usize) -> f64 {
78        let even = self.delay as f64 + idx as f64 * self.stagger as f64;
79        if self.jitter.abs() < 1e-6 || self.stagger.abs() < 1e-6 {
80            return even;
81        }
82        // Bit-mixing hash (splitmix64's finalizer) over the unit index and
83        // seed → a well-distributed value in -1.0..1.0.
84        let mut h = (idx as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ (self.seed as u64);
85        h ^= h >> 30;
86        h = h.wrapping_mul(0xBF58_476D_1CE4_E5B9);
87        h ^= h >> 27;
88        h = h.wrapping_mul(0x94D0_49BB_1331_11EB);
89        h ^= h >> 31;
90        let unit = (h >> 11) as f64 / (1u64 << 53) as f64; // 0.0..1.0
91        let nudge = (unit * 2.0 - 1.0) * self.jitter as f64 * self.stagger as f64;
92        (even + nudge).max(self.delay as f64)
93    }
94}
95
96/// Extracted and categorized animation effects from an AnimationEffect slice.
97pub struct ExtractedEffects<'a> {
98    pub presets: Vec<(AnimationPreset, PresetConfig)>,
99    /// Every `keyframes`/`tilt_in` effect's animations, in the order their
100    /// source effects appear in `style.animation` (constat #5: this used to
101    /// be split into two buckets — routed purely by whether the effect's
102    /// `delay` happened to be nonzero — resolved and merged separately,
103    /// which made "sum vs last-wins" on a shared property depend on that
104    /// unrelated field. Now there is one bucket, resolved in one
105    /// `resolve_animations` call, so the composition rule is always
106    /// "last effect in the array wins on a shared property" — a CSS-cascade
107    /// rule, independent of `delay`).
108    pub keyframe_animations: Vec<Animation>,
109    /// True when any contributing `keyframes`/`tilt_in` effect requested
110    /// `"loop": true` (constat #7). Applied uniformly to the whole
111    /// `keyframe_animations` bucket — see the doc comment on
112    /// `resolve_props_for_effects` for the same caveat presets already have
113    /// (multiple effects with different loop settings on the same property
114    /// is an unsupported edge case, not new to this fix).
115    pub keyframes_loop: bool,
116    pub wiggles: Vec<&'a WiggleConfig>,
117    pub orbits: Vec<&'a OrbitConfig>,
118    /// Every `motion_path` effect, resolved by `apply_motion_paths` into
119    /// `translate_x`/`translate_y` (and, when `orient` is set,
120    /// `rotation`) — the same additive-into-`props` treatment `orbits`
121    /// already gets, and for the same reason: multiple path effects on one
122    /// node compose by simple vector addition, not last-wins.
123    pub motion_paths: Vec<&'a MotionPathConfig>,
124    pub glow: Option<&'a GlowConfig>,
125    pub motion_blur: Option<f32>,
126    pub char_animation: Option<ResolvedCharAnimation>,
127}
128
129/// M3: find the first `glow` effect in a list, if present.
130///
131/// `glow` is a static (non-time-varying) coloured halo — unlike every other
132/// effect `resolve_props_for_effects` resolves, it deliberately is *not*
133/// folded into `AnimatedProperties`: `GlowConfig.color` has no corresponding
134/// field there, and extending `AnimatedProperties`'s public shape is out of
135/// scope for this workstream. Callers apply the returned config directly as
136/// a CSS `filter: drop-shadow(...)` (see
137/// `rustmotion_components::box_builder::apply_glow_effect`), which is the
138/// only place that needs the raw colour string.
139pub fn find_glow_effect(effects: &[AnimationEffect]) -> Option<&GlowConfig> {
140    effects.iter().find_map(|e| match e {
141        AnimationEffect::Glow(cfg) => Some(cfg),
142        _ => None,
143    })
144}
145
146/// Split a slice of AnimationEffect into categorized buckets for the renderer.
147pub fn extract_effects(effects: &[AnimationEffect]) -> ExtractedEffects<'_> {
148    let mut result = ExtractedEffects {
149        presets: Vec::new(),
150        keyframe_animations: Vec::new(),
151        keyframes_loop: false,
152        wiggles: Vec::new(),
153        orbits: Vec::new(),
154        motion_paths: Vec::new(),
155        glow: None,
156        motion_blur: None,
157        char_animation: None,
158    };
159
160    for effect in effects {
161        if let Some((preset, timing)) = effect.as_preset() {
162            result.presets.push((preset, timing.to_preset_config()));
163        } else {
164            match effect {
165                AnimationEffect::CharScaleIn(t)
166                | AnimationEffect::CharFadeIn(t)
167                | AnimationEffect::CharWave(t)
168                | AnimationEffect::CharBounce(t)
169                | AnimationEffect::CharRotateIn(t)
170                | AnimationEffect::CharSlideUp(t)
171                | AnimationEffect::CharBlurIn(t) => {
172                    let preset = match effect {
173                        AnimationEffect::CharScaleIn(_) => CharAnimPreset::ScaleIn,
174                        AnimationEffect::CharFadeIn(_) => CharAnimPreset::FadeIn,
175                        AnimationEffect::CharWave(_) => CharAnimPreset::Wave,
176                        AnimationEffect::CharBounce(_) => CharAnimPreset::Bounce,
177                        AnimationEffect::CharRotateIn(_) => CharAnimPreset::RotateIn,
178                        AnimationEffect::CharSlideUp(_) => CharAnimPreset::SlideUp,
179                        // `char_blur_in` used to be resolved separately, off
180                        // `style.animation` inside `text.rs`'s painter, which
181                        // meant it silently missed container-level stagger
182                        // shifting and `timeline`-embedded copies. It goes
183                        // through the same door as its five siblings now.
184                        AnimationEffect::CharBlurIn(_) => CharAnimPreset::BlurIn,
185                        _ => unreachable!(),
186                    };
187                    result.char_animation = Some(ResolvedCharAnimation {
188                        preset,
189                        granularity: t.granularity.clone(),
190                        stagger: t.stagger as f32,
191                        duration: t.duration as f32,
192                        easing: t.easing.clone(),
193                        delay: t.delay as f32,
194                        overshoot: t.overshoot.unwrap_or(0.08) as f32,
195                        blur: t.blur.map(|b| b as f32).unwrap_or(DEFAULT_CHAR_BLUR_SIGMA),
196                        direction: t.direction,
197                        distance: t.distance.unwrap_or(1.0) as f32,
198                        scale_from: t.scale_from.map(|s| s as f32),
199                        jitter: t.jitter.unwrap_or(0.0) as f32,
200                        seed: t.seed.unwrap_or(0),
201                        ink_from: t.ink_from.clone(),
202                    });
203                }
204                AnimationEffect::Glow(config) => {
205                    result.glow = Some(config);
206                }
207                AnimationEffect::Wiggle(config) => {
208                    result.wiggles.push(config);
209                }
210                AnimationEffect::Orbit(config) => {
211                    result.orbits.push(config);
212                }
213                AnimationEffect::Keyframes(config) => {
214                    // Keyframe times are absolute scene seconds; the
215                    // config-level delay shifts them (applied unconditionally
216                    // — a no-op when `delay == 0` — so every `keyframes`
217                    // effect lands in the same bucket regardless of its
218                    // delay; see the `ExtractedEffects::keyframe_animations`
219                    // doc comment for why that used to matter).
220                    result
221                        .keyframe_animations
222                        .extend(config.keyframes.iter().map(|anim| {
223                            let mut a = anim.clone();
224                            for kf in &mut a.keyframes {
225                                kf.time += config.delay;
226                            }
227                            a
228                        }));
229                    if config.repeat {
230                        result.keyframes_loop = true;
231                    }
232                }
233                AnimationEffect::TiltIn(config) => {
234                    let delay = config.delay;
235                    let end = delay + config.duration;
236                    let rx = config.rotate_x.unwrap_or(15.0);
237                    let ry = config.rotate_y.unwrap_or(-15.0);
238                    let persp = config.perspective.unwrap_or(1000.0);
239                    let sc = config.scale_from.unwrap_or(0.9);
240                    result.keyframe_animations.extend([
241                        kf_anim(
242                            "opacity",
243                            delay,
244                            0.0,
245                            delay + config.duration * 0.3,
246                            1.0,
247                            EasingType::EaseOut,
248                        ),
249                        kf_anim("rotate_x", delay, rx, end, 0.0, EasingType::EaseOutCubic),
250                        kf_anim("rotate_y", delay, ry, end, 0.0, EasingType::EaseOutCubic),
251                        kf_anim("perspective", delay, persp, end, persp, EasingType::Linear),
252                        kf_anim("scale", delay, sc, end, 1.0, EasingType::EaseOutCubic),
253                    ]);
254                    if config.repeat {
255                        result.keyframes_loop = true;
256                    }
257                }
258                AnimationEffect::MotionBlur(config) => {
259                    result.motion_blur = Some(config.intensity);
260                }
261                AnimationEffect::MotionPath(config) => {
262                    result.motion_paths.push(config);
263                }
264                _ => {} // preset variants already handled above
265            }
266        }
267    }
268
269    result
270}
271
272// ─── Easing functions ───────────────────────────────────────────────────────
273
274/// Apply easing function to a normalized time t (0.0..1.0)
275pub fn ease(t: f64, easing: &EasingType) -> f64 {
276    let t = t.clamp(0.0, 1.0);
277    match easing {
278        EasingType::Linear => t,
279        EasingType::EaseIn => ease_in_cubic(t),
280        EasingType::EaseOut => ease_out_cubic(t),
281        EasingType::EaseInOut => ease_in_out_cubic(t),
282        EasingType::EaseInQuad => t * t,
283        EasingType::EaseOutQuad => 1.0 - (1.0 - t) * (1.0 - t),
284        EasingType::EaseInCubic => ease_in_cubic(t),
285        EasingType::EaseOutCubic => ease_out_cubic(t),
286        EasingType::EaseInExpo => {
287            if t == 0.0 {
288                0.0
289            } else {
290                (2.0f64).powf(10.0 * (t - 1.0))
291            }
292        }
293        EasingType::EaseOutExpo => {
294            if t == 1.0 {
295                1.0
296            } else {
297                1.0 - (2.0f64).powf(-10.0 * t)
298            }
299        }
300        EasingType::EaseInOutQuad => {
301            if t < 0.5 {
302                2.0 * t * t
303            } else {
304                1.0 - (-2.0 * t + 2.0).powi(2) / 2.0
305            }
306        }
307        EasingType::EaseInOutExpo => {
308            if t == 0.0 {
309                0.0
310            } else if t == 1.0 {
311                1.0
312            } else if t < 0.5 {
313                (2.0f64).powf(20.0 * t - 10.0) / 2.0
314            } else {
315                (2.0 - (2.0f64).powf(-20.0 * t + 10.0)) / 2.0
316            }
317        }
318        EasingType::EaseInBack => {
319            let c1 = 1.70158;
320            let c3 = c1 + 1.0;
321            c3 * t * t * t - c1 * t * t
322        }
323        EasingType::EaseOutBack => {
324            let c1 = 1.70158;
325            let c3 = c1 + 1.0;
326            1.0 + c3 * (t - 1.0).powi(3) + c1 * (t - 1.0).powi(2)
327        }
328        EasingType::EaseOutElastic => {
329            if t == 0.0 {
330                0.0
331            } else if t == 1.0 {
332                1.0
333            } else {
334                let c4 = (2.0 * std::f64::consts::PI) / 3.0;
335                (2.0f64).powf(-10.0 * t) * ((t * 10.0 - 0.75) * c4).sin() + 1.0
336            }
337        }
338        EasingType::Bounce => bounce_ease_out(t),
339        EasingType::Spring => t, // Spring handled separately
340        EasingType::CubicBezier { x1, y1, x2, y2 } => cubic_bezier_ease(t, *x1, *y1, *x2, *y2),
341    }
342}
343
344/// Evaluate a cubic-bezier curve at parameter t using Newton's method
345/// Control points: P0=(0,0), P1=(x1,y1), P2=(x2,y2), P3=(1,1)
346fn cubic_bezier_ease(t: f64, x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
347    // Find the parameter t_curve such that bezier_x(t_curve) = t
348    // Then return bezier_y(t_curve)
349    let t_curve = find_bezier_t_for_x(t, x1, x2);
350    bezier_component(t_curve, y1, y2)
351}
352
353fn bezier_component(t: f64, p1: f64, p2: f64) -> f64 {
354    // B(t) = 3(1-t)^2*t*p1 + 3(1-t)*t^2*p2 + t^3
355    let t2 = t * t;
356    let t3 = t2 * t;
357    let mt = 1.0 - t;
358    let mt2 = mt * mt;
359    3.0 * mt2 * t * p1 + 3.0 * mt * t2 * p2 + t3
360}
361
362fn bezier_component_derivative(t: f64, p1: f64, p2: f64) -> f64 {
363    let mt = 1.0 - t;
364    3.0 * mt * mt * p1 + 6.0 * mt * t * (p2 - p1) + 3.0 * t * t * (1.0 - p2)
365}
366
367fn find_bezier_t_for_x(x: f64, x1: f64, x2: f64) -> f64 {
368    // Newton-Raphson to solve bezier_x(t) = x
369    let mut t = x; // Initial guess
370    for _ in 0..8 {
371        let current_x = bezier_component(t, x1, x2);
372        let dx = bezier_component_derivative(t, x1, x2);
373        if dx.abs() < 1e-10 {
374            break;
375        }
376        t -= (current_x - x) / dx;
377        t = t.clamp(0.0, 1.0);
378    }
379    t
380}
381
382fn bounce_ease_out(t: f64) -> f64 {
383    let n1 = 7.5625;
384    let d1 = 2.75;
385    if t < 1.0 / d1 {
386        n1 * t * t
387    } else if t < 2.0 / d1 {
388        let t = t - 1.5 / d1;
389        n1 * t * t + 0.75
390    } else if t < 2.5 / d1 {
391        let t = t - 2.25 / d1;
392        n1 * t * t + 0.9375
393    } else {
394        let t = t - 2.625 / d1;
395        n1 * t * t + 0.984375
396    }
397}
398
399fn ease_in_cubic(t: f64) -> f64 {
400    t * t * t
401}
402
403fn ease_out_cubic(t: f64) -> f64 {
404    1.0 - (1.0 - t).powi(3)
405}
406
407fn ease_in_out_cubic(t: f64) -> f64 {
408    if t < 0.5 {
409        4.0 * t * t * t
410    } else {
411        1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
412    }
413}
414
415// ─── Spring solver ──────────────────────────────────────────────────────────
416
417/// Default `rest_threshold` (fraction of the 0→1 travel) used by
418/// `spring_rest_time`/the `duration` remap in `spring_value` when a
419/// `SpringConfig` does not set one explicitly. 0.5% is tight enough that
420/// "at rest" reads as visually still, without demanding the numeric search
421/// chase an asymptote that (for a critically- or over-damped spring) is
422/// never reached exactly.
423pub const DEFAULT_SPRING_REST_THRESHOLD: f64 = 0.005;
424
425/// Hard cap, in seconds, on how far into the future `spring_settle_time`
426/// searches for a rest point. A very lightly damped spring can take an
427/// arbitrarily long time to decay under `rest_threshold` — in the limit
428/// (`damping == 0`) it never does, oscillating forever at constant
429/// amplitude — so the search needs a bound or it would not terminate. When
430/// the cap is hit, the spring is reported as resting at the cap itself: a
431/// defined, tested "has not settled by then" answer (see
432/// `spring_duration_tests::undamped_spring_is_capped_not_infinite` and
433/// `spring_duration_tests::very_lightly_damped_spring_is_also_capped_when_beyond_the_bound`)
434/// rather than an unbounded loop.
435pub const MAX_SPRING_SEARCH_SECONDS: f64 = 30.0;
436
437/// Solve spring animation at time t (seconds).
438/// Returns a value between 0.0 and 1.0 representing progress.
439///
440/// Constat #6: `SpringConfig` accepts any `f64` (it's schema-level, not
441/// range-checked at parse time), and `rustmotion validate` used to check
442/// nothing about it either. `mass <= 0` or `stiffness <= 0` fed straight into
443/// `sqrt`/division below produced NaN (sqrt of a negative/undefined ratio,
444/// or division by zero), and negative `damping` flipped the decay
445/// exponent's sign so the "settling" oscillation diverged to +-infinity
446/// instead. Either poisons every transform/opacity value downstream once it
447/// merges into `AnimatedProperties`. `validate_schema.rs` now rejects these
448/// combinations as errors (belt), and this floor keeps the solver itself
449/// finite and bounded even if an out-of-band caller skips validation
450/// (suspenders) — see `spring_robustness_tests` below.
451///
452/// `duration` (issue #167 lot E, `SpringConfig::duration`): when set, `t` is
453/// linearly rescaled before it reaches the physics below — not the physical
454/// parameters themselves — so that `spring_rest_time` on the *unscaled*
455/// spring lands exactly on `duration`. The spring's shape (oscillation
456/// count, overshoot amplitude) is entirely a function of
457/// `damping`/`stiffness`/`mass`, so rescaling only the time axis preserves
458/// it; see `spring_duration_tests::duration_remap_preserves_shape`. This
459/// does *not* resize whatever keyframe segment `spring_value` is being
460/// evaluated within — see the `duration` field's doc comment on
461/// `SpringConfig` for why that is a separate, author-owned concern.
462pub fn spring_value(t: f64, config: &SpringConfig) -> f64 {
463    let damping = config.damping.max(0.0);
464    let stiffness = config.stiffness.max(1e-6);
465    let mass = config.mass.max(1e-6);
466
467    match config.duration {
468        Some(duration) if duration > 0.0 => {
469            let threshold = spring_rest_threshold(config);
470            let natural_rest = spring_settle_time(
471                damping,
472                stiffness,
473                mass,
474                threshold,
475                MAX_SPRING_SEARCH_SECONDS,
476            );
477            if natural_rest < 1e-9 {
478                // Degenerate: the spring starts at distance 1.0 from its
479                // target, so in practice `natural_rest` is never this
480                // small — fall back to unscaled rather than divide by ~0.
481                spring_value_raw(t, damping, stiffness, mass)
482            } else {
483                let time_scale = natural_rest / duration;
484                spring_value_raw(t * time_scale, damping, stiffness, mass)
485            }
486        }
487        _ => spring_value_raw(t, damping, stiffness, mass),
488    }
489}
490
491/// The physics solver itself, unscaled by any `duration` remap. Takes
492/// already-floored parameters (see `spring_value`'s constat #6 doc comment)
493/// so `spring_settle_time`'s search can call it directly without redoing
494/// the floor on every sample.
495fn spring_value_raw(t: f64, damping: f64, stiffness: f64, mass: f64) -> f64 {
496    let omega = (stiffness / mass).sqrt();
497    let zeta = damping / (2.0 * (stiffness * mass).sqrt());
498
499    if zeta < 1.0 {
500        // Underdamped
501        let omega_d = omega * (1.0 - zeta * zeta).sqrt();
502        let decay = (-zeta * omega * t).exp();
503        1.0 - decay
504            * ((zeta * omega * t / omega_d).sin() * (zeta * omega / omega_d) + (omega_d * t).cos())
505    } else if (zeta - 1.0).abs() < 1e-6 {
506        // Critically damped
507        let decay = (-omega * t).exp();
508        1.0 - decay * (1.0 + omega * t)
509    } else {
510        // Overdamped
511        let s1 = -omega * (zeta - (zeta * zeta - 1.0).sqrt());
512        let s2 = -omega * (zeta + (zeta * zeta - 1.0).sqrt());
513        let c2 = -s1 / (s2 - s1);
514        let c1 = 1.0 - c2;
515        1.0 - (c1 * (s1 * t).exp() + c2 * (s2 * t).exp())
516    }
517}
518
519/// Lower bound on the number of samples `spring_settle_time` takes across
520/// `[0, max_t]` — enough to resolve slow (critically-/over-damped) decays
521/// even when the natural oscillation period doesn't drive the sample count
522/// up on its own.
523const SPRING_SETTLE_MIN_SAMPLES: usize = 2_000;
524/// Upper bound on samples, regardless of how short the oscillation period
525/// is — keeps `spring_settle_time` (called on every `spring_value` sample
526/// when `duration` is set) bounded-cost for very stiff/fast springs.
527const SPRING_SETTLE_MAX_SAMPLES: usize = 20_000;
528/// Target sample density within one oscillation period, chosen empirically
529/// (see the workstream report) to keep the coarse-then-bisect search within
530/// ~0.1% of a brute-force reference across a broad random sweep of
531/// damping/stiffness/mass. Shallow, near-tangential graze-and-return
532/// excursions across the threshold band (a spring that dips back below the
533/// line by a razor-thin margin on a secondary oscillation) can still be
534/// missed — `spring_rest_time`/`spring_settle_time` are a documented
535/// numeric approximation, not an exact guarantee.
536const SPRING_SETTLE_SAMPLES_PER_PERIOD: f64 = 48.0;
537
538/// First `t >= 0` from which `spring_value_raw` stays within `threshold` of
539/// its target (1.0) forever after. Implements the "mesure du repos" from
540/// issue #167 lot E: `spring_value_raw` is closed-form, so a coarse scan to
541/// bracket the last exceedance, refined by bisection, is enough — no need
542/// to integrate anything.
543///
544/// Two regimes get explicit handling (both required by the workstream
545/// brief, both exercised in `spring_duration_tests`):
546/// - an overdamped (or critically damped) spring never touches its target
547///   exactly, only approaches it asymptotically — the scan terminates via
548///   `threshold`, never via an exact equality check;
549/// - a very lightly damped spring can take arbitrarily long to settle (an
550///   undamped spring, `damping == 0`, never does — it oscillates forever at
551///   constant amplitude). `max_t` bounds the search; if the last sample is
552///   still outside `threshold`, `max_t` itself is returned — defined,
553///   tested behaviour instead of an unbounded search.
554fn spring_settle_time(damping: f64, stiffness: f64, mass: f64, threshold: f64, max_t: f64) -> f64 {
555    let threshold = threshold.max(1e-9);
556    let omega = (stiffness / mass).sqrt();
557    let period = if omega > 1e-9 {
558        std::f64::consts::TAU / omega
559    } else {
560        max_t
561    };
562    let desired_steps = (max_t / (period / SPRING_SETTLE_SAMPLES_PER_PERIOD)).ceil() as usize;
563    let steps = desired_steps.clamp(SPRING_SETTLE_MIN_SAMPLES, SPRING_SETTLE_MAX_SAMPLES);
564    let dt = max_t / steps as f64;
565
566    let mut last_exceed_idx: usize = 0;
567    for i in 0..=steps {
568        let t = i as f64 * dt;
569        if (spring_value_raw(t, damping, stiffness, mass) - 1.0).abs() > threshold {
570            last_exceed_idx = i;
571        }
572    }
573
574    if last_exceed_idx >= steps {
575        // Still exceeding at (or past) max_t: capped, "not settled".
576        return max_t;
577    }
578
579    // Refine within (last_exceed, last_exceed + dt]: the coarse scan found
580    // this as the last sample outside the threshold band, so bisect for the
581    // point within this bracket where it steps inside for good.
582    let mut lo = last_exceed_idx as f64 * dt;
583    let mut hi = (lo + dt).min(max_t);
584    for _ in 0..40 {
585        let mid = 0.5 * (lo + hi);
586        if (spring_value_raw(mid, damping, stiffness, mass) - 1.0).abs() > threshold {
587            lo = mid;
588        } else {
589            hi = mid;
590        }
591    }
592    hi
593}
594
595/// The `rest_threshold` a `SpringConfig` resolves to: the author's value if
596/// set, else `DEFAULT_SPRING_REST_THRESHOLD`, floored so `spring_settle_time`
597/// always has a well-defined (nonzero) target — the same belt-and-suspenders
598/// pattern `spring_value` already applies to `damping`/`stiffness`/`mass`.
599/// the CLI's `check_spring_config` rejects non-positive or absurd
600/// (`>= 1.0`) values at the author-facing layer; this floor is the
601/// solver-side backstop.
602fn spring_rest_threshold(config: &SpringConfig) -> f64 {
603    config
604        .rest_threshold
605        .unwrap_or(DEFAULT_SPRING_REST_THRESHOLD)
606        .max(1e-9)
607}
608
609/// Public "measure du repos" (issue #167 lot E): the instant, in seconds,
610/// at which this spring settles within `rest_threshold` of its target and
611/// stays there — what `rustmotion info` surfaces so an author can size a
612/// scene/preset duration around a spring instead of discovering it by
613/// trial and error.
614///
615/// When `config.duration` is set, this *is* that duration, exactly — that
616/// is the point of the time remap `spring_value` performs (see its doc
617/// comment). Otherwise it is the natural settle time computed from
618/// `damping`/`stiffness`/`mass` alone via `spring_settle_time`.
619pub fn spring_rest_time(config: &SpringConfig) -> f64 {
620    match config.duration {
621        Some(d) if d > 0.0 => d,
622        _ => {
623            let damping = config.damping.max(0.0);
624            let stiffness = config.stiffness.max(1e-6);
625            let mass = config.mass.max(1e-6);
626            let threshold = spring_rest_threshold(config);
627            spring_settle_time(
628                damping,
629                stiffness,
630                mass,
631                threshold,
632                MAX_SPRING_SEARCH_SECONDS,
633            )
634        }
635    }
636}
637
638// ─── Animation resolver ─────────────────────────────────────────────────────
639
640/// Resolved animated properties for a single layer at a specific frame
641#[derive(Debug, Clone)]
642pub struct AnimatedProperties {
643    pub opacity: f32,
644    pub translate_x: f32,
645    pub translate_y: f32,
646    pub scale_x: f32,
647    pub scale_y: f32,
648    pub rotation: f32,
649    pub blur: f32,
650    /// For typewriter effect: number of visible characters (-1 = all)
651    pub visible_chars: i32,
652    /// For typewriter effect: progress 0.0→1.0 (-1.0 = unused, shows all)
653    pub visible_chars_progress: f32,
654    /// Animated color override (hex string)
655    pub color: Option<String>,
656    // Extended animatable properties
657    pub border_radius: f32,
658    pub font_size: f32,
659    pub width: f32,
660    pub height: f32,
661    pub gap: f32,
662    pub padding: f32,
663    pub stroke_width: f32,
664    pub shadow_blur: f32,
665    pub glow_radius: f32,
666    pub glow_intensity: f32,
667    // 3D perspective transforms
668    pub rotate_x: f32,
669    pub rotate_y: f32,
670    pub perspective: f32,
671    // Path animation
672    pub draw_progress: f32,
673    // Motion path progress (0.0 = start, 1.0 = end)
674    pub motion_progress: f32,
675    // Char animation (from style.animation char_* variants)
676    pub char_animation: Option<ResolvedCharAnimation>,
677}
678
679impl Default for AnimatedProperties {
680    fn default() -> Self {
681        Self {
682            opacity: 1.0,
683            translate_x: 0.0,
684            translate_y: 0.0,
685            scale_x: 1.0,
686            scale_y: 1.0,
687            rotation: 0.0,
688            blur: 0.0,
689            visible_chars: -1,
690            visible_chars_progress: -1.0,
691            color: None,
692            border_radius: -1.0,
693            font_size: -1.0,
694            width: -1.0,
695            height: -1.0,
696            gap: -1.0,
697            padding: -1.0,
698            stroke_width: -1.0,
699            shadow_blur: -1.0,
700            glow_radius: -1.0,
701            glow_intensity: -1.0,
702            rotate_x: 0.0,
703            rotate_y: 0.0,
704            perspective: -1.0,
705            draw_progress: -1.0,
706            motion_progress: -1.0,
707            char_animation: None,
708        }
709    }
710}
711
712impl AnimatedProperties {
713    /// Merge another AnimatedProperties into self. Properties that have been
714    /// explicitly set in `other` (not sentinel -1.0) override values in self.
715    pub fn merge(&mut self, other: &AnimatedProperties) {
716        // opacity: default is 1.0, so only override if other explicitly animated to non-1.0
717        // For opacity we multiply (both presets contribute)
718        if (other.opacity - 1.0).abs() > 0.001 {
719            self.opacity *= other.opacity;
720        }
721        if other.translate_x.abs() > 0.001 {
722            self.translate_x += other.translate_x;
723        }
724        if other.translate_y.abs() > 0.001 {
725            self.translate_y += other.translate_y;
726        }
727        if (other.scale_x - 1.0).abs() > 0.001 {
728            self.scale_x *= other.scale_x;
729        }
730        if (other.scale_y - 1.0).abs() > 0.001 {
731            self.scale_y *= other.scale_y;
732        }
733        if other.rotation.abs() > 0.01 {
734            self.rotation += other.rotation;
735        }
736        if other.blur > 0.001 {
737            self.blur = other.blur;
738        }
739        if other.visible_chars >= 0 {
740            self.visible_chars = other.visible_chars;
741        }
742        if other.visible_chars_progress >= 0.0 {
743            self.visible_chars_progress = other.visible_chars_progress;
744        }
745        if other.color.is_some() {
746            self.color = other.color.clone();
747        }
748        // Sentinel-based fields (-1.0 = not set)
749        if other.border_radius >= 0.0 {
750            self.border_radius = other.border_radius;
751        }
752        if other.font_size >= 0.0 {
753            self.font_size = other.font_size;
754        }
755        if other.width >= 0.0 {
756            self.width = other.width;
757        }
758        if other.height >= 0.0 {
759            self.height = other.height;
760        }
761        if other.gap >= 0.0 {
762            self.gap = other.gap;
763        }
764        if other.padding >= 0.0 {
765            self.padding = other.padding;
766        }
767        if other.stroke_width >= 0.0 {
768            self.stroke_width = other.stroke_width;
769        }
770        if other.shadow_blur >= 0.0 {
771            self.shadow_blur = other.shadow_blur;
772        }
773        if other.glow_radius >= 0.0 {
774            self.glow_radius = other.glow_radius;
775        }
776        if other.glow_intensity >= 0.0 {
777            self.glow_intensity = other.glow_intensity;
778        }
779        // 3D perspective transforms (additive like rotation)
780        if other.rotate_x.abs() > 0.01 {
781            self.rotate_x += other.rotate_x;
782        }
783        if other.rotate_y.abs() > 0.01 {
784            self.rotate_y += other.rotate_y;
785        }
786        if other.perspective >= 0.0 {
787            self.perspective = other.perspective;
788        }
789        if other.draw_progress >= 0.0 {
790            self.draw_progress = other.draw_progress;
791        }
792        if other.motion_progress >= 0.0 {
793            self.motion_progress = other.motion_progress;
794        }
795        if other.char_animation.is_some() {
796            self.char_animation = other.char_animation.clone();
797        }
798    }
799}
800
801/// High-level helper: extract `effects`, resolve presets/keyframes/wiggles/orbits,
802/// and propagate the char animation. Returns the resolved [`AnimatedProperties`]
803/// at `time` within a scene of `scene_duration` seconds.
804///
805/// Used by both the legacy render pipeline and the new paint-tree dispatcher
806/// so they share the exact same animation semantics.
807pub fn resolve_props_for_effects(
808    effects: &[AnimationEffect],
809    time: f64,
810    scene_duration: f64,
811) -> AnimatedProperties {
812    let mut props = AnimatedProperties::default();
813    if effects.is_empty() {
814        return props;
815    }
816    let extracted = extract_effects(effects);
817
818    for (preset, preset_config) in &extracted.presets {
819        let p = resolve_animations(&[], Some(preset), Some(preset_config), time, scene_duration);
820        props.merge(&p);
821    }
822    // Every `keyframes`/`tilt_in` effect is resolved together in one call
823    // (constat #5): within a single `resolve_animations` call, multiple
824    // `Animation`s targeting the same property are applied in list order via
825    // `apply_property` (assignment, not addition), so the *last* effect in
826    // `style.animation` wins on a shared property — deterministic, and
827    // independent of any effect's `delay`. `keyframes_loop` (constat #7)
828    // carries `"loop": true` from any contributing effect into the solver,
829    // which `resolve_animations` used to never see (it was always called
830    // with `preset_config = None`, i.e. `repeat = false`).
831    if !extracted.keyframe_animations.is_empty() {
832        let loop_cfg = PresetConfig {
833            repeat: extracted.keyframes_loop,
834            ..Default::default()
835        };
836        let kp = resolve_animations(
837            &extracted.keyframe_animations,
838            None,
839            Some(&loop_cfg),
840            time,
841            scene_duration,
842        );
843        props.merge(&kp);
844    }
845    if !extracted.wiggles.is_empty() {
846        let wiggles: Vec<_> = extracted.wiggles.iter().copied().cloned().collect();
847        apply_wiggles(&mut props, &wiggles, time);
848    }
849    if !extracted.orbits.is_empty() {
850        let orbits: Vec<_> = extracted.orbits.iter().copied().cloned().collect();
851        apply_orbits(&mut props, &orbits, time);
852    }
853    if !extracted.motion_paths.is_empty() {
854        let motion_paths: Vec<_> = extracted.motion_paths.iter().copied().cloned().collect();
855        apply_motion_paths(&mut props, &motion_paths, time);
856    }
857    if extracted.char_animation.is_some() {
858        props.char_animation = extracted.char_animation;
859    }
860    props
861}
862
863/// Resolve animations for a layer at a specific time (seconds) within the scene
864pub fn resolve_animations(
865    animations: &[Animation],
866    preset: Option<&AnimationPreset>,
867    preset_config: Option<&PresetConfig>,
868    time: f64,
869    scene_duration: f64,
870) -> AnimatedProperties {
871    let mut props = AnimatedProperties::default();
872
873    let config = preset_config.cloned().unwrap_or_default();
874    let should_loop = config.repeat;
875
876    // First, expand preset into animations
877    let preset_animations = preset.map(|p| expand_preset(p, &config, scene_duration));
878
879    // Merge preset animations with explicit animations (explicit wins on conflict)
880    let all_animations: Vec<&Animation> = preset_animations
881        .as_ref()
882        .map(|pa| pa.iter().collect::<Vec<_>>())
883        .unwrap_or_default()
884        .into_iter()
885        .chain(animations.iter())
886        .collect();
887
888    for anim in all_animations {
889        let anim_time = if should_loop {
890            loop_time(anim, time)
891        } else {
892            time
893        };
894        let resolved = resolve_animation_value_full(anim, anim_time);
895        match resolved {
896            ResolvedValue::Number(value) => apply_property(&mut props, &anim.property, value),
897            ResolvedValue::Color(color) => {
898                if anim.property == "color" {
899                    props.color = Some(color);
900                }
901            }
902        }
903    }
904
905    props
906}
907
908/// Wrap time within the animation's keyframe range for looping
909fn loop_time(anim: &Animation, time: f64) -> f64 {
910    let keyframes = &anim.keyframes;
911    if keyframes.len() < 2 {
912        return time;
913    }
914    let start = keyframes.first().unwrap().time;
915    let end = keyframes.last().unwrap().time;
916    let duration = end - start;
917    if duration < 1e-9 || time < start {
918        return time;
919    }
920    start + ((time - start) % duration)
921}
922
923/// Result of resolving an animation value — either a number or a color
924enum ResolvedValue {
925    Number(f64),
926    Color(String),
927}
928
929/// Public wrapper around `resolve_animation_value_full` for callers outside
930/// this module that want to reuse the exact segment/easing/spring
931/// interpolation math (ordering, per-keyframe easing override, clamping at
932/// the ends) on a synthetic `Animation` they built themselves, without
933/// routing the result through `AnimatedProperties`/`apply_property`.
934///
935/// This is how `box_builder.rs`'s `style.transition` smoothing for
936/// `border-radius`/`background` is implemented: those two properties are
937/// paint-time `CssStyle` fields that every painter already reads directly
938/// (via `paint_pass.rs`, frozen) — there is no `AnimatedProperties` field
939/// for them to land in that anything downstream would ever look at, so
940/// resolving through the generic effects pipeline the way `opacity`/`color`
941/// do would be a dead end. Calling this directly and writing the resolved
942/// `CssStyle` field by hand instead reuses the proven interpolation math
943/// while staying entirely inside `box_builder.rs`'s own file scope.
944pub fn resolve_keyframe_track(anim: &Animation, time: f64) -> KeyframeValue {
945    match resolve_animation_value_full(anim, time) {
946        ResolvedValue::Number(n) => KeyframeValue::Number(n),
947        ResolvedValue::Color(c) => KeyframeValue::Color(c),
948    }
949}
950
951fn resolve_animation_value_full(anim: &Animation, time: f64) -> ResolvedValue {
952    let keyframes = &anim.keyframes;
953    if keyframes.is_empty() {
954        return ResolvedValue::Number(0.0);
955    }
956    if keyframes.len() == 1 {
957        return match &keyframes[0].value {
958            KeyframeValue::Color(c) => ResolvedValue::Color(c.clone()),
959            KeyframeValue::Number(n) => ResolvedValue::Number(*n),
960        };
961    }
962
963    if time <= keyframes[0].time {
964        return match &keyframes[0].value {
965            KeyframeValue::Color(c) => ResolvedValue::Color(c.clone()),
966            KeyframeValue::Number(n) => ResolvedValue::Number(*n),
967        };
968    }
969    if time >= keyframes.last().unwrap().time {
970        return match &keyframes.last().unwrap().value {
971            KeyframeValue::Color(c) => ResolvedValue::Color(c.clone()),
972            KeyframeValue::Number(n) => ResolvedValue::Number(*n),
973        };
974    }
975
976    for i in 0..keyframes.len() - 1 {
977        let kf0 = &keyframes[i];
978        let kf1 = &keyframes[i + 1];
979
980        if time >= kf0.time && time <= kf1.time {
981            let segment_duration = kf1.time - kf0.time;
982            if segment_duration < 1e-9 {
983                return match &kf1.value {
984                    KeyframeValue::Color(c) => ResolvedValue::Color(c.clone()),
985                    KeyframeValue::Number(n) => ResolvedValue::Number(*n),
986                };
987            }
988
989            let local_t = (time - kf0.time) / segment_duration;
990
991            // Use per-keyframe easing if specified, otherwise fall back to animation-level easing
992            let segment_easing = kf0.easing.as_ref().unwrap_or(&anim.easing);
993
994            let progress = match segment_easing {
995                EasingType::Spring => {
996                    let spring_config = anim.spring.clone().unwrap_or_default();
997                    spring_value(local_t * segment_duration, &spring_config)
998                }
999                other => ease(local_t, other),
1000            };
1001
1002            // Check if both keyframes are colors
1003            if let (KeyframeValue::Color(c0), KeyframeValue::Color(c1)) = (&kf0.value, &kf1.value) {
1004                return ResolvedValue::Color(lerp_color(c0, c1, progress));
1005            }
1006
1007            let v0 = kf0.value.as_f64();
1008            let v1 = kf1.value.as_f64();
1009            return ResolvedValue::Number(v0 + (v1 - v0) * progress);
1010        }
1011    }
1012
1013    match &keyframes.last().unwrap().value {
1014        KeyframeValue::Color(c) => ResolvedValue::Color(c.clone()),
1015        KeyframeValue::Number(n) => ResolvedValue::Number(*n),
1016    }
1017}
1018
1019/// Parse hex color to (r, g, b, a) as f64 components (0-255)
1020fn parse_hex_components(hex: &str) -> (f64, f64, f64, f64) {
1021    let (r, g, b, a) = super::renderer::parse_hex_color(hex);
1022    (r as f64, g as f64, b as f64, a as f64)
1023}
1024
1025/// Interpolate between two hex colors
1026pub fn lerp_color(c1: &str, c2: &str, t: f64) -> String {
1027    let (r1, g1, b1, a1) = parse_hex_components(c1);
1028    let (r2, g2, b2, a2) = parse_hex_components(c2);
1029    let r = (r1 + (r2 - r1) * t).clamp(0.0, 255.0) as u8;
1030    let g = (g1 + (g2 - g1) * t).clamp(0.0, 255.0) as u8;
1031    let b = (b1 + (b2 - b1) * t).clamp(0.0, 255.0) as u8;
1032    let a = (a1 + (a2 - a1) * t).clamp(0.0, 255.0) as u8;
1033    if a == 255 {
1034        format!("#{:02X}{:02X}{:02X}", r, g, b)
1035    } else {
1036        format!("#{:02X}{:02X}{:02X}{:02X}", r, g, b, a)
1037    }
1038}
1039
1040fn apply_property(props: &mut AnimatedProperties, property: &str, value: f64) {
1041    match property {
1042        "opacity" => props.opacity = value as f32,
1043        "position.x" | "translate_x" => props.translate_x = value as f32,
1044        "position.y" | "translate_y" => props.translate_y = value as f32,
1045        "scale" => {
1046            props.scale_x = value as f32;
1047            props.scale_y = value as f32;
1048        }
1049        "scale.x" => props.scale_x = value as f32,
1050        "scale.y" => props.scale_y = value as f32,
1051        "rotation" => props.rotation = value as f32,
1052        "blur" => props.blur = value as f32,
1053        "visible_chars" => props.visible_chars = value as i32,
1054        "visible_chars_progress" => props.visible_chars_progress = value as f32,
1055        "border_radius" => props.border_radius = value as f32,
1056        "font_size" => props.font_size = value as f32,
1057        "width" => props.width = value as f32,
1058        "height" => props.height = value as f32,
1059        "gap" => props.gap = value as f32,
1060        "padding" => props.padding = value as f32,
1061        "stroke_width" => props.stroke_width = value as f32,
1062        "shadow_blur" => props.shadow_blur = value as f32,
1063        "glow_radius" => props.glow_radius = value as f32,
1064        "glow_intensity" => props.glow_intensity = value as f32,
1065        "rotate_x" => props.rotate_x = value as f32,
1066        "rotate_y" => props.rotate_y = value as f32,
1067        "perspective" => props.perspective = value as f32,
1068        "draw_progress" => props.draw_progress = value as f32,
1069        "motion_progress" => props.motion_progress = value as f32,
1070        _ => {} // Unknown property, ignore
1071    }
1072}
1073
1074// Note: an earlier workstream (constat #4, `schema/video.rs`) already closed
1075// the "unrecognized `Animation.property` is a silent no-op" gap this
1076// function's catch-all (`_ => {}` above) would otherwise hide —
1077// `KeyframesConfig.keyframes` deserializes through
1078// `deserialize_validated_keyframes`/`validate_motion_property`, which
1079// rejects any `property` outside `KNOWN_MOTION_PROPERTIES` (with a
1080// did-you-mean suggestion) at parse time, before a scenario ever reaches
1081// `validate`/render. This workstream verified that gap is closed rather
1082// than reopening it with a second, redundant "known properties" list here;
1083// see the workstream report's "generic interpolation" write-up.
1084
1085// ─── Wiggle resolution ──────────────────────────────────────────────────────
1086
1087/// Simple noise function based on sine waves with seed for pseudo-random behavior
1088fn simplex_noise_1d(x: f64, seed: u64) -> f64 {
1089    use std::f64::consts::TAU;
1090    let s = seed as f64;
1091
1092    (x * TAU + s * 0.1234).sin() * 0.6
1093        + (x * TAU * 1.7 + s * 0.5678).sin() * 0.3
1094        + (x * TAU * 2.9 + s * 0.9012).sin() * 0.1 // roughly -1..1
1095}
1096
1097/// Parameterized noise function with configurable octaves
1098fn simplex_noise_1d_ext(x: f64, seed: u64, octaves: u32) -> f64 {
1099    use std::f64::consts::TAU;
1100    let s = seed as f64;
1101    let mut value = 0.0;
1102    let mut amplitude = 0.5;
1103    let mut total_amplitude = 0.0;
1104    for i in 0..octaves {
1105        let freq = 1.0 + i as f64 * 1.3;
1106        let phase_offset = s * (0.1234 + i as f64 * 0.4444);
1107        value += (x * TAU * freq + phase_offset).sin() * amplitude;
1108        total_amplitude += amplitude;
1109        amplitude *= 0.5;
1110    }
1111    if total_amplitude > 0.0 {
1112        value / total_amplitude
1113    } else {
1114        0.0
1115    }
1116}
1117
1118/// Apply wiggle offsets additively to animated properties
1119pub fn apply_wiggles(props: &mut AnimatedProperties, wiggles: &[WiggleConfig], time: f64) {
1120    for wiggle in wiggles {
1121        let has_extras = wiggle.octaves.is_some()
1122            || wiggle.phase.is_some()
1123            || wiggle.decay.is_some()
1124            || wiggle.easing.is_some();
1125
1126        let phase = wiggle.phase.unwrap_or(0.0);
1127        let input = time * wiggle.frequency + phase;
1128
1129        let is_sine = wiggle.mode.as_deref() == Some("sine");
1130
1131        let mut noise_val = if is_sine {
1132            input.sin()
1133        } else if has_extras {
1134            let octaves = wiggle.octaves.unwrap_or(3);
1135            simplex_noise_1d_ext(input, wiggle.seed, octaves)
1136        } else {
1137            simplex_noise_1d(input, wiggle.seed)
1138        };
1139
1140        // Apply easing: normalize [-1,1] → [0,1], ease, remap to [-1,1]
1141        if let Some(ref easing) = wiggle.easing {
1142            let normalized = (noise_val + 1.0) * 0.5;
1143            let eased = ease(normalized, easing);
1144            noise_val = eased * 2.0 - 1.0;
1145        }
1146
1147        let mut amp = wiggle.amplitude;
1148
1149        // Apply exponential decay
1150        if let Some(decay) = wiggle.decay {
1151            amp *= (-decay * time).exp();
1152        }
1153
1154        let offset = amp * noise_val;
1155        apply_property(
1156            props,
1157            &wiggle.property,
1158            get_property_value(props, &wiggle.property) + offset,
1159        );
1160    }
1161}
1162
1163/// Apply orbit effects additively to animated properties.
1164/// Creates circular/elliptical motion with pseudo-3D depth via scale and opacity modulation.
1165pub fn apply_orbits(props: &mut AnimatedProperties, orbits: &[OrbitConfig], time: f64) {
1166    use std::f64::consts::{PI, TAU};
1167
1168    for orbit in orbits {
1169        let angle_offset = orbit.start_angle * PI / 180.0;
1170        let phase_offset = orbit.phase * TAU;
1171        let tilt_rad = orbit.tilt * PI / 180.0;
1172
1173        let theta = TAU * orbit.speed * time + angle_offset + phase_offset;
1174
1175        // Elliptical orbit position
1176        let raw_x = orbit.radius_x * theta.cos();
1177        let raw_y = orbit.radius_y * theta.sin();
1178
1179        // Apply tilt: compress Y axis and add depth effect
1180        let x_offset = raw_x;
1181        let y_offset = raw_y * tilt_rad.cos();
1182
1183        props.translate_x += x_offset as f32;
1184        props.translate_y += y_offset as f32;
1185
1186        // Pseudo-depth: when "behind" (sin < 0), scale down and reduce opacity
1187        if orbit.depth > 0.0 {
1188            // depth_factor goes from (1 - depth) to (1 + depth) based on orbit position
1189            let depth_sin = if tilt_rad.abs() > 0.01 {
1190                // With tilt, depth is based on the untilted Y (how far "back" the object is)
1191                theta.sin()
1192            } else {
1193                // Without tilt, use Y component for depth
1194                theta.sin()
1195            };
1196            let scale_factor = 1.0 + orbit.depth * depth_sin;
1197            props.scale_x *= scale_factor as f32;
1198            props.scale_y *= scale_factor as f32;
1199        }
1200
1201        // Opacity modulation for depth
1202        if orbit.opacity_depth > 0.0 {
1203            let depth_sin = theta.sin();
1204            let opacity_factor = 1.0 - orbit.opacity_depth * (1.0 - depth_sin) * 0.5;
1205            props.opacity *= opacity_factor as f32;
1206        }
1207    }
1208}
1209
1210// ─── Motion path ────────────────────────────────────────────────────────────
1211
1212/// Below this measured path length (in px), a `motion_path` is treated as
1213/// the "zero length" degenerate case: the component holds at its single
1214/// point instead of travelling, and `orient` contributes no rotation (a
1215/// tangent is undefined at zero length). Not `0.0` exactly — `PathMeasure`
1216/// is a numeric approximation, and a path whose segments collapse onto one
1217/// point within float precision (e.g. two near-coincident cubic control
1218/// points) should degrade the same defined way a literal single-point path
1219/// does, rather than pass through as a very short, jittery "real" travel.
1220pub const MOTION_PATH_MIN_LENGTH: f32 = 1e-3;
1221
1222/// Parse `path_data` and measure its length, in px — the shared primitive
1223/// `apply_motion_paths` (render time) and `validate_schema.rs`'s advisory
1224/// zero-length check (author time) both build on, so the two never
1225/// disagree about what "degenerate" means.
1226///
1227/// Returns `None` when `path_data` is empty or not valid SVG path data.
1228/// Every `motion_path` effect reachable through `AnimationEffect` already
1229/// has this ruled out at JSON-parse time
1230/// (`schema/video.rs::deserialize_motion_path_data`), so in practice `None`
1231/// only fires if a caller builds a `MotionPathConfig` directly in Rust,
1232/// bypassing that gate. `Some(0.0)` (or a value below
1233/// `MOTION_PATH_MIN_LENGTH`) is returned for a syntactically valid path
1234/// with (near-)zero measured length — a well-defined, distinct case from
1235/// "invalid", per `MotionPathConfig`'s "Degenerate paths" doc section.
1236pub fn motion_path_length(path_data: &str) -> Option<f32> {
1237    let path = skia_safe::Path::from_svg(path_data)?;
1238    if path.count_points() == 0 {
1239        return None;
1240    }
1241    let mut measure = skia_safe::PathMeasure::new(&path, false, None);
1242    Some(measure.length())
1243}
1244
1245/// Progress along a `motion_path` effect's own timeline, already eased, in
1246/// `[0, 1]`. Mirrors the delay/duration semantics every other timed effect
1247/// in this file uses: before `delay`, progress is pinned to `0.0` (the path
1248/// hasn't started — the component sits at the path's start point, the same
1249/// "hold at the entrance state" every preset already does before its own
1250/// delay elapses); at/after `delay + duration` it is pinned to `1.0` (holds
1251/// at the path's end) unless `repeat` wraps it back into `[0, 1)` instead.
1252///
1253/// `safe_div`'s fallback (`1.0`) makes a non-positive `duration` behave as
1254/// "already complete the instant `delay` elapses" — finite and defined,
1255/// never a NaN/∞ division — the same belt-and-suspenders posture
1256/// `spring_value` already takes on its own denominators.
1257/// `validate_schema.rs::check_motion_path_config` additionally rejects
1258/// `duration <= 0` as an author-facing error, so this fallback is a second
1259/// line of defence, not the only one.
1260fn motion_path_progress(cfg: &MotionPathConfig, time: f64) -> f64 {
1261    let elapsed = time - cfg.delay;
1262    if elapsed <= 0.0 {
1263        return 0.0;
1264    }
1265    let raw = safe_div(elapsed, cfg.duration, 1.0);
1266    let progress = if cfg.repeat {
1267        raw.rem_euclid(1.0)
1268    } else {
1269        raw.clamp(0.0, 1.0)
1270    };
1271    ease(progress, &cfg.easing)
1272}
1273
1274/// One `motion_path` effect's contribution at `time`: a translate delta (in
1275/// the component-local coordinate space `MotionPathConfig` documents) and a
1276/// tangent-derived rotation in degrees (`0.0` when `orient` is unset, or
1277/// when the path is the zero-length degenerate case).
1278struct MotionPathSample {
1279    dx: f32,
1280    dy: f32,
1281    angle_deg: f32,
1282}
1283
1284/// Sample a `motion_path` effect at `time`. Never returns a NaN/infinite
1285/// component, for any input — the three degenerate cases the workstream
1286/// brief names are each handled explicitly rather than falling through to
1287/// whatever the underlying float operation happens to produce:
1288///
1289/// - **empty/unparsable path**: `AnimationEffect::MotionPath` cannot carry
1290///   one past `schema/video.rs::deserialize_motion_path_data`'s parse-time
1291///   rejection, but this function stays defensive anyway (`(0.0, 0.0,
1292///   0.0)`, i.e. no displacement) rather than assuming that gate always ran
1293///   — e.g. a future direct `MotionPathConfig` construction in Rust code
1294///   would bypass serde entirely.
1295/// - **single point** (`"M50,50"`) and **zero-length** (every segment
1296///   collapses onto one point, e.g. `"M10,10 L10,10"`): both measure to
1297///   (near-)zero length. Position holds at that single point (read via
1298///   `Path::get_point(0)`) for the entire timeline; orientation is `0.0`
1299///   regardless of `orient` — a tangent is undefined at zero length, so
1300///   `atan2(0.0, 0.0)`'s technically-zero-but-meaningless result is never
1301///   computed or relied on.
1302fn motion_path_sample(cfg: &MotionPathConfig, time: f64) -> MotionPathSample {
1303    let zero = MotionPathSample {
1304        dx: 0.0,
1305        dy: 0.0,
1306        angle_deg: 0.0,
1307    };
1308    let Some(path) = skia_safe::Path::from_svg(&cfg.path) else {
1309        return zero;
1310    };
1311    if path.count_points() == 0 {
1312        return zero;
1313    }
1314
1315    let mut measure = skia_safe::PathMeasure::new(&path, false, None);
1316    let length = measure.length();
1317
1318    // Verified empirically (not just assumed): `PathMeasure::pos_tan` on a
1319    // zero-length contour returns `None` in this skia-safe build, which the
1320    // `None` arm below would also catch — this early return is kept anyway
1321    // as the one place the degenerate case is *named*, rather than an
1322    // undocumented cross-version PathMeasure behaviour a reader would have
1323    // to intuit, and it skips constructing/querying the measure entirely
1324    // for the single most common degenerate input (a single-point path).
1325    if length <= MOTION_PATH_MIN_LENGTH {
1326        let (x, y) = path.points().first().map_or((0.0, 0.0), |p| (p.x, p.y));
1327        return MotionPathSample {
1328            dx: x,
1329            dy: y,
1330            angle_deg: 0.0,
1331        };
1332    }
1333
1334    let progress = motion_path_progress(cfg, time) as f32;
1335    let distance = (length * progress).clamp(0.0, length);
1336
1337    match measure.pos_tan(distance) {
1338        Some((pos, tangent)) => {
1339            let angle_deg = if cfg.orient {
1340                tangent.y.atan2(tangent.x).to_degrees() + cfg.orient_offset as f32
1341            } else {
1342                0.0
1343            };
1344            MotionPathSample {
1345                dx: pos.x,
1346                dy: pos.y,
1347                angle_deg,
1348            }
1349        }
1350        // `0 <= distance <= length` on a >0-length path should always
1351        // report a position; if Skia ever declines anyway, hold at the
1352        // path's start rather than let a missing sample surface as a jump
1353        // to the component's untranslated origin or a NaN.
1354        None => {
1355            let (x, y) = path.points().first().map_or((0.0, 0.0), |p| (p.x, p.y));
1356            MotionPathSample {
1357                dx: x,
1358                dy: y,
1359                angle_deg: 0.0,
1360            }
1361        }
1362    }
1363}
1364
1365/// Apply every `motion_path` effect additively to `props.translate_x`/
1366/// `translate_y` (and, when `orient` is set, `props.rotation`) — the same
1367/// treatment `apply_orbits`/`apply_wiggles` already give their own
1368/// continuous effects, and critically, fields `css::animation::
1369/// apply_animated_props` already bridges into `css.transform`'s
1370/// `translate`/`rotate` functions. That bridge — not a new one — is what
1371/// makes a `motion_path` excursion past the viewport visible to
1372/// `--strict-anim` (`rustmotion::cli::commands::geometry::
1373/// apply_static_node_transform`, which folds `css.transform` to detect
1374/// overflow): this function must never write position/orientation anywhere
1375/// else, or that detection silently stops seeing it.
1376pub fn apply_motion_paths(props: &mut AnimatedProperties, paths: &[MotionPathConfig], time: f64) {
1377    for cfg in paths {
1378        let sample = motion_path_sample(cfg, time);
1379        props.translate_x += sample.dx;
1380        props.translate_y += sample.dy;
1381        props.rotation += sample.angle_deg;
1382    }
1383}
1384
1385fn get_property_value(props: &AnimatedProperties, property: &str) -> f64 {
1386    match property {
1387        "opacity" => props.opacity as f64,
1388        "position.x" | "translate_x" => props.translate_x as f64,
1389        "position.y" | "translate_y" => props.translate_y as f64,
1390        "scale" => props.scale_x as f64,
1391        "scale.x" => props.scale_x as f64,
1392        "scale.y" => props.scale_y as f64,
1393        "rotation" => props.rotation as f64,
1394        "blur" => props.blur as f64,
1395        "border_radius" => props.border_radius as f64,
1396        "font_size" => props.font_size as f64,
1397        "width" => props.width as f64,
1398        "height" => props.height as f64,
1399        "gap" => props.gap as f64,
1400        "padding" => props.padding as f64,
1401        "stroke_width" => props.stroke_width as f64,
1402        "shadow_blur" => props.shadow_blur as f64,
1403        "glow_radius" => props.glow_radius as f64,
1404        "glow_intensity" => props.glow_intensity as f64,
1405        "rotate_x" => props.rotate_x as f64,
1406        "rotate_y" => props.rotate_y as f64,
1407        "perspective" => props.perspective as f64,
1408        "draw_progress" => props.draw_progress as f64,
1409        "motion_progress" => props.motion_progress as f64,
1410        _ => 0.0,
1411    }
1412}
1413
1414// ─── Preset expansion ───────────────────────────────────────────────────────
1415
1416/// Properties eligible for the preset-level `spring` override: motion only.
1417/// Opacity keeps its ease (an alpha overshoot flashes), blur/draw_progress
1418/// would go out of range on overshoot.
1419fn is_motion_property(property: &str) -> bool {
1420    matches!(
1421        property,
1422        "position.x"
1423            | "position.y"
1424            | "translate_x"
1425            | "translate_y"
1426            | "scale"
1427            | "scale.x"
1428            | "scale.y"
1429            | "rotation"
1430            | "rotate_x"
1431            | "rotate_y"
1432    )
1433}
1434
1435/// Apply a user-provided spring to a preset's motion animations (issue #88).
1436///
1437/// Implementation note: a single generic post-processing pass was chosen over
1438/// editing each of the ~40 preset builders — the eligibility rules are uniform
1439/// and the builders stay oblivious to springs. Rules per animation:
1440/// - non-motion property (opacity, blur, …): untouched;
1441/// - 2 keyframes: easing → `Spring` with the given config. For `bounce_in` /
1442///   `elastic_in` this *overrides* their built-in spring, which thereby acts
1443///   as the default when no user config is provided;
1444/// - more than 2 keyframes with different endpoints (manual-overshoot
1445///   entrances like `scale_in`): collapsed to [first, last] + spring — the
1446///   spring supplies the overshoot itself, keeping the manual peak would
1447///   double it;
1448/// - more than 2 keyframes with identical endpoints (continuous oscillators:
1449///   pulse, shake, float): untouched — a spring toward the same value is a
1450///   no-op and would freeze the effect.
1451///
1452/// `spring.duration` (issue #167 lot E) is *not* consulted here to resize
1453/// the keyframe pair's own span: the pair's `[delay, end]` still comes from
1454/// `AnimationTiming::delay`/`duration` (the same preset-level timing every
1455/// other easing uses), untouched by whatever `SpringConfig::duration` says.
1456/// `spring_value` — not this function — is where `duration` acts, by
1457/// rescaling the *physics* time axis it is fed. Consequently, if the
1458/// preset's own `duration` is shorter than `spring.duration`, the segment
1459/// still ends (and the property still snaps to its final keyframe value) at
1460/// the preset's `end`, before the spring has visually settled — exactly the
1461/// pre-existing behaviour for any other easing curve given too short a
1462/// segment. Pin `AnimationTiming::duration` (or the `keyframes` effect's own
1463/// keyframe span, for the other call site in `resolve_animation_value_full`)
1464/// to at least `spring_rest_time` to avoid that cutoff; `rustmotion info`
1465/// reports `spring_rest_time` for exactly this purpose.
1466fn apply_spring_to_motion(animations: &mut [Animation], spring: &SpringConfig) {
1467    for anim in animations.iter_mut() {
1468        if !is_motion_property(&anim.property) || anim.keyframes.len() < 2 {
1469            continue;
1470        }
1471        if anim.keyframes.len() > 2 {
1472            let first = anim.keyframes.first().unwrap().clone();
1473            let last = anim.keyframes.last().unwrap().clone();
1474            if (first.value.as_f64() - last.value.as_f64()).abs() < 1e-9 {
1475                continue; // oscillator — leave its shape alone
1476            }
1477            anim.keyframes = vec![first, last];
1478        }
1479        anim.easing = EasingType::Spring;
1480        anim.spring = Some(spring.clone());
1481    }
1482}
1483
1484fn expand_preset(
1485    preset: &AnimationPreset,
1486    config: &PresetConfig,
1487    _scene_duration: f64,
1488) -> Vec<Animation> {
1489    let mut animations = expand_preset_inner(preset, config);
1490    if let Some(spring) = &config.spring {
1491        apply_spring_to_motion(&mut animations, spring);
1492    }
1493    animations
1494}
1495
1496fn expand_preset_inner(preset: &AnimationPreset, config: &PresetConfig) -> Vec<Animation> {
1497    let delay = config.delay;
1498    let dur = config.duration;
1499    let end = delay + dur;
1500
1501    match preset {
1502        // ── Entrées ──────────────────────────────────────────────────────
1503        AnimationPreset::FadeIn => vec![kf_anim(
1504            "opacity",
1505            delay,
1506            0.0,
1507            end,
1508            1.0,
1509            EasingType::EaseOut,
1510        )],
1511        AnimationPreset::FadeInUp => vec![
1512            kf_anim("opacity", delay, 0.0, end, 1.0, EasingType::EaseOut),
1513            kf_anim(
1514                "position.y",
1515                delay,
1516                60.0,
1517                end,
1518                0.0,
1519                EasingType::EaseOutCubic,
1520            ),
1521        ],
1522        AnimationPreset::FadeInDown => vec![
1523            kf_anim("opacity", delay, 0.0, end, 1.0, EasingType::EaseOut),
1524            kf_anim(
1525                "position.y",
1526                delay,
1527                -60.0,
1528                end,
1529                0.0,
1530                EasingType::EaseOutCubic,
1531            ),
1532        ],
1533        AnimationPreset::FadeInLeft => vec![
1534            kf_anim("opacity", delay, 0.0, end, 1.0, EasingType::EaseOut),
1535            kf_anim(
1536                "position.x",
1537                delay,
1538                -60.0,
1539                end,
1540                0.0,
1541                EasingType::EaseOutCubic,
1542            ),
1543        ],
1544        AnimationPreset::FadeInRight => vec![
1545            kf_anim("opacity", delay, 0.0, end, 1.0, EasingType::EaseOut),
1546            kf_anim(
1547                "position.x",
1548                delay,
1549                60.0,
1550                end,
1551                0.0,
1552                EasingType::EaseOutCubic,
1553            ),
1554        ],
1555        AnimationPreset::SlideInLeft => vec![
1556            kf_anim(
1557                "opacity",
1558                delay,
1559                0.0,
1560                delay + dur * 0.3,
1561                1.0,
1562                EasingType::EaseOut,
1563            ),
1564            kf_anim(
1565                "position.x",
1566                delay,
1567                -200.0,
1568                end,
1569                0.0,
1570                EasingType::EaseOutCubic,
1571            ),
1572        ],
1573        AnimationPreset::SlideInRight => vec![
1574            kf_anim(
1575                "opacity",
1576                delay,
1577                0.0,
1578                delay + dur * 0.3,
1579                1.0,
1580                EasingType::EaseOut,
1581            ),
1582            kf_anim(
1583                "position.x",
1584                delay,
1585                200.0,
1586                end,
1587                0.0,
1588                EasingType::EaseOutCubic,
1589            ),
1590        ],
1591        AnimationPreset::SlideInUp => vec![
1592            kf_anim(
1593                "opacity",
1594                delay,
1595                0.0,
1596                delay + dur * 0.3,
1597                1.0,
1598                EasingType::EaseOut,
1599            ),
1600            kf_anim(
1601                "position.y",
1602                delay,
1603                200.0,
1604                end,
1605                0.0,
1606                EasingType::EaseOutCubic,
1607            ),
1608        ],
1609        AnimationPreset::SlideInDown => vec![
1610            kf_anim(
1611                "opacity",
1612                delay,
1613                0.0,
1614                delay + dur * 0.3,
1615                1.0,
1616                EasingType::EaseOut,
1617            ),
1618            kf_anim(
1619                "position.y",
1620                delay,
1621                -200.0,
1622                end,
1623                0.0,
1624                EasingType::EaseOutCubic,
1625            ),
1626        ],
1627        AnimationPreset::ScaleIn => {
1628            let overshoot = config.overshoot.unwrap_or(0.08);
1629            vec![
1630                kf_anim(
1631                    "opacity",
1632                    delay,
1633                    0.0,
1634                    delay + dur * 0.3,
1635                    1.0,
1636                    EasingType::EaseOut,
1637                ),
1638                Animation {
1639                    property: "scale".to_string(),
1640                    keyframes: vec![
1641                        kf(delay, 0.0),
1642                        kf(delay + dur * 0.7, 1.0 + overshoot),
1643                        kf(end, 1.0),
1644                    ],
1645                    easing: EasingType::EaseOutCubic,
1646                    spring: None,
1647                },
1648            ]
1649        }
1650        AnimationPreset::BounceIn => vec![
1651            kf_anim(
1652                "opacity",
1653                delay,
1654                0.0,
1655                delay + dur * 0.2,
1656                1.0,
1657                EasingType::EaseOut,
1658            ),
1659            kf_anim_spring("scale", delay, 0.3, end, 1.0),
1660        ],
1661        AnimationPreset::BlurIn => vec![
1662            kf_anim("opacity", delay, 0.0, end, 1.0, EasingType::EaseOut),
1663            kf_anim("blur", delay, 20.0, end, 0.0, EasingType::EaseOutCubic),
1664        ],
1665        AnimationPreset::RotateIn => vec![
1666            kf_anim("opacity", delay, 0.0, end, 1.0, EasingType::EaseOut),
1667            kf_anim("rotation", delay, -90.0, end, 0.0, EasingType::EaseOutCubic),
1668            kf_anim("scale", delay, 0.5, end, 1.0, EasingType::EaseOutCubic),
1669        ],
1670        AnimationPreset::ElasticIn => {
1671            vec![kf_anim_spring_underdamped("scale", delay, 0.0, end, 1.0)]
1672        }
1673        AnimationPreset::PopIn => {
1674            // Two beats, not one: the back-out scale places the element, then
1675            // a short pulse draws the eye back to it. Collapsing them into a
1676            // single overshooting curve reads as one wobble instead — the
1677            // second beat has to land *after* the element has visibly settled.
1678            let pulse = 1.0 + config.overshoot.unwrap_or(0.18);
1679            let placed = delay + dur * 0.6;
1680            let peak = delay + dur * 0.8;
1681            vec![
1682                kf_anim(
1683                    "opacity",
1684                    delay,
1685                    0.0,
1686                    delay + dur * 0.25,
1687                    1.0,
1688                    EasingType::EaseOut,
1689                ),
1690                Animation {
1691                    property: "scale".to_string(),
1692                    keyframes: vec![
1693                        Keyframe {
1694                            time: delay,
1695                            value: KeyframeValue::Number(0.0),
1696                            easing: Some(EasingType::EaseOutBack),
1697                        },
1698                        Keyframe {
1699                            time: placed,
1700                            value: KeyframeValue::Number(1.0),
1701                            easing: Some(EasingType::EaseOutQuad),
1702                        },
1703                        Keyframe {
1704                            time: peak,
1705                            value: KeyframeValue::Number(pulse),
1706                            easing: Some(EasingType::EaseOutElastic),
1707                        },
1708                        Keyframe {
1709                            time: end,
1710                            value: KeyframeValue::Number(1.0),
1711                            easing: None,
1712                        },
1713                    ],
1714                    easing: EasingType::EaseOut,
1715                    spring: None,
1716                },
1717            ]
1718        }
1719
1720        // ── Sorties ──────────────────────────────────────────────────────
1721        AnimationPreset::FadeOut => {
1722            vec![kf_anim("opacity", delay, 1.0, end, 0.0, EasingType::EaseIn)]
1723        }
1724        AnimationPreset::FadeOutUp => vec![
1725            kf_anim("opacity", delay, 1.0, end, 0.0, EasingType::EaseIn),
1726            kf_anim(
1727                "position.y",
1728                delay,
1729                0.0,
1730                end,
1731                -60.0,
1732                EasingType::EaseInCubic,
1733            ),
1734        ],
1735        AnimationPreset::FadeOutDown => vec![
1736            kf_anim("opacity", delay, 1.0, end, 0.0, EasingType::EaseIn),
1737            kf_anim("position.y", delay, 0.0, end, 60.0, EasingType::EaseInCubic),
1738        ],
1739        AnimationPreset::SlideOutLeft => vec![
1740            kf_anim(
1741                "opacity",
1742                delay + dur * 0.7,
1743                1.0,
1744                end,
1745                0.0,
1746                EasingType::EaseIn,
1747            ),
1748            kf_anim(
1749                "position.x",
1750                delay,
1751                0.0,
1752                end,
1753                -200.0,
1754                EasingType::EaseInCubic,
1755            ),
1756        ],
1757        AnimationPreset::SlideOutRight => vec![
1758            kf_anim(
1759                "opacity",
1760                delay + dur * 0.7,
1761                1.0,
1762                end,
1763                0.0,
1764                EasingType::EaseIn,
1765            ),
1766            kf_anim(
1767                "position.x",
1768                delay,
1769                0.0,
1770                end,
1771                200.0,
1772                EasingType::EaseInCubic,
1773            ),
1774        ],
1775        AnimationPreset::SlideOutUp => vec![
1776            kf_anim(
1777                "opacity",
1778                delay + dur * 0.7,
1779                1.0,
1780                end,
1781                0.0,
1782                EasingType::EaseIn,
1783            ),
1784            kf_anim(
1785                "position.y",
1786                delay,
1787                0.0,
1788                end,
1789                -200.0,
1790                EasingType::EaseInCubic,
1791            ),
1792        ],
1793        AnimationPreset::SlideOutDown => vec![
1794            kf_anim(
1795                "opacity",
1796                delay + dur * 0.7,
1797                1.0,
1798                end,
1799                0.0,
1800                EasingType::EaseIn,
1801            ),
1802            kf_anim(
1803                "position.y",
1804                delay,
1805                0.0,
1806                end,
1807                200.0,
1808                EasingType::EaseInCubic,
1809            ),
1810        ],
1811        AnimationPreset::ScaleOut => {
1812            let overshoot = config.overshoot.unwrap_or(0.08);
1813            vec![
1814                kf_anim(
1815                    "opacity",
1816                    delay + dur * 0.7,
1817                    1.0,
1818                    end,
1819                    0.0,
1820                    EasingType::EaseIn,
1821                ),
1822                Animation {
1823                    property: "scale".to_string(),
1824                    keyframes: vec![
1825                        kf(delay, 1.0),
1826                        kf(delay + dur * 0.2, 1.0 + overshoot),
1827                        kf(end, 0.0),
1828                    ],
1829                    easing: EasingType::EaseInCubic,
1830                    spring: None,
1831                },
1832            ]
1833        }
1834        AnimationPreset::BounceOut => vec![
1835            kf_anim(
1836                "opacity",
1837                delay + dur * 0.8,
1838                1.0,
1839                end,
1840                0.0,
1841                EasingType::EaseIn,
1842            ),
1843            kf_anim_spring("scale", delay, 1.0, end, 0.3),
1844        ],
1845        AnimationPreset::BlurOut => vec![
1846            kf_anim("opacity", delay, 1.0, end, 0.0, EasingType::EaseIn),
1847            kf_anim("blur", delay, 0.0, end, 20.0, EasingType::EaseInCubic),
1848        ],
1849        AnimationPreset::RotateOut => vec![
1850            kf_anim("opacity", delay, 1.0, end, 0.0, EasingType::EaseIn),
1851            kf_anim("rotation", delay, 0.0, end, 90.0, EasingType::EaseInCubic),
1852            kf_anim("scale", delay, 1.0, end, 0.5, EasingType::EaseInCubic),
1853        ],
1854
1855        // ── Effets continus ──────────────────────────────────────────────
1856        // `delay`/`duration` used to be decorative here: the keyframes were
1857        // pinned to literal times 0.0/0.25/0.5/1.0 regardless of what the
1858        // scenario authored (constat #2), so every pulsing/floating/shaking/
1859        // spinning element in a scene shared one hardcoded 1-second cycle
1860        // starting at t=0. `delay` now shifts the cycle's start and
1861        // `duration` sets its length, exactly like every other preset.
1862        AnimationPreset::Pulse => vec![kf_anim_3kf_over(
1863            "scale",
1864            delay,
1865            end,
1866            0.95,
1867            1.05,
1868            0.95,
1869            EasingType::EaseInOut,
1870        )],
1871        AnimationPreset::Float => vec![kf_anim_3kf_over(
1872            "position.y",
1873            delay,
1874            end,
1875            0.0,
1876            -10.0,
1877            0.0,
1878            EasingType::EaseInOut,
1879        )],
1880        AnimationPreset::Shake => vec![kf_anim_4kf_over(
1881            "position.x",
1882            delay,
1883            end,
1884            0.0,
1885            10.0,
1886            -10.0,
1887            0.0,
1888            EasingType::EaseInOut,
1889        )],
1890        AnimationPreset::Spin => vec![kf_anim(
1891            "rotation",
1892            delay,
1893            0.0,
1894            end,
1895            360.0,
1896            EasingType::Linear,
1897        )],
1898
1899        // ── 3D ───────────────────────────────────────────────────────────
1900        AnimationPreset::FlipInX => vec![
1901            kf_anim(
1902                "opacity",
1903                delay,
1904                0.0,
1905                delay + dur * 0.3,
1906                1.0,
1907                EasingType::EaseOut,
1908            ),
1909            kf_anim("rotate_x", delay, 90.0, end, 0.0, EasingType::EaseOutCubic),
1910            kf_anim("perspective", delay, 800.0, end, 800.0, EasingType::Linear),
1911        ],
1912        AnimationPreset::FlipInY => vec![
1913            kf_anim(
1914                "opacity",
1915                delay,
1916                0.0,
1917                delay + dur * 0.3,
1918                1.0,
1919                EasingType::EaseOut,
1920            ),
1921            kf_anim("rotate_y", delay, 90.0, end, 0.0, EasingType::EaseOutCubic),
1922            kf_anim("perspective", delay, 800.0, end, 800.0, EasingType::Linear),
1923        ],
1924        AnimationPreset::FlipOutX => vec![
1925            kf_anim(
1926                "opacity",
1927                delay + dur * 0.7,
1928                1.0,
1929                end,
1930                0.0,
1931                EasingType::EaseIn,
1932            ),
1933            kf_anim("rotate_x", delay, 0.0, end, -90.0, EasingType::EaseInCubic),
1934            kf_anim("perspective", delay, 800.0, end, 800.0, EasingType::Linear),
1935        ],
1936        AnimationPreset::FlipOutY => vec![
1937            kf_anim(
1938                "opacity",
1939                delay + dur * 0.7,
1940                1.0,
1941                end,
1942                0.0,
1943                EasingType::EaseIn,
1944            ),
1945            kf_anim("rotate_y", delay, 0.0, end, -90.0, EasingType::EaseInCubic),
1946            kf_anim("perspective", delay, 800.0, end, 800.0, EasingType::Linear),
1947        ],
1948        AnimationPreset::TiltIn => vec![
1949            kf_anim(
1950                "opacity",
1951                delay,
1952                0.0,
1953                delay + dur * 0.3,
1954                1.0,
1955                EasingType::EaseOut,
1956            ),
1957            kf_anim("rotate_x", delay, 15.0, end, 0.0, EasingType::EaseOutCubic),
1958            kf_anim("rotate_y", delay, -15.0, end, 0.0, EasingType::EaseOutCubic),
1959            kf_anim(
1960                "perspective",
1961                delay,
1962                1000.0,
1963                end,
1964                1000.0,
1965                EasingType::Linear,
1966            ),
1967            kf_anim("scale", delay, 0.9, end, 1.0, EasingType::EaseOutCubic),
1968        ],
1969
1970        // ── Floating/orbit ────────────────────────────────────────────
1971        AnimationPreset::Float3d => {
1972            // The cycle spans delay..delay+duration, so `duration` sets the
1973            // period and `delay` shifts the phase.
1974            //
1975            // Both were previously inert: the keyframes were pinned to 0.0 /
1976            // 0.5 / 1.0 seconds, so every floating element in a scene shared
1977            // one 1-second cycle and moved in lockstep no matter what the
1978            // scenario asked for. A row of cards bobbing in unison reads as a
1979            // dance; the same cards on different phases and travels read as
1980            // depth, which is the point of the preset.
1981            let amp = config.amplitude.unwrap_or(12.0);
1982            let tilt = amp / 12.0;
1983            vec![
1984                kf_anim_3kf_over(
1985                    "position.y",
1986                    delay,
1987                    end,
1988                    0.0,
1989                    -amp,
1990                    0.0,
1991                    EasingType::EaseInOut,
1992                ),
1993                kf_anim_3kf_over(
1994                    "rotate_x",
1995                    delay,
1996                    end,
1997                    0.0,
1998                    5.0 * tilt,
1999                    0.0,
2000                    EasingType::EaseInOut,
2001                ),
2002                kf_anim_3kf_over(
2003                    "rotate_y",
2004                    delay,
2005                    end,
2006                    0.0,
2007                    -8.0 * tilt,
2008                    0.0,
2009                    EasingType::EaseInOut,
2010                ),
2011                kf_anim(
2012                    "perspective",
2013                    delay,
2014                    1000.0,
2015                    end,
2016                    1000.0,
2017                    EasingType::Linear,
2018                ),
2019            ]
2020        }
2021
2022        // ── Spéciaux ────────────────────────────────────────────────────
2023        AnimationPreset::DrawIn => vec![kf_anim(
2024            "draw_progress",
2025            delay,
2026            0.0,
2027            end,
2028            1.0,
2029            EasingType::EaseInOut,
2030        )],
2031        AnimationPreset::StrokeReveal => vec![
2032            kf_anim("draw_progress", delay, 0.0, end, 1.0, EasingType::EaseOut),
2033            kf_anim(
2034                "opacity",
2035                delay,
2036                0.0,
2037                delay + dur * 0.2,
2038                1.0,
2039                EasingType::EaseOut,
2040            ),
2041        ],
2042        AnimationPreset::Typewriter => vec![kf_anim(
2043            "visible_chars_progress",
2044            delay,
2045            0.0,
2046            end,
2047            1.0,
2048            EasingType::Linear,
2049        )],
2050        AnimationPreset::WipeLeft => vec![
2051            kf_anim(
2052                "opacity",
2053                delay,
2054                0.0,
2055                delay + dur * 0.3,
2056                1.0,
2057                EasingType::EaseOut,
2058            ),
2059            kf_anim("position.x", delay, -200.0, end, 0.0, EasingType::EaseInOut),
2060        ],
2061        AnimationPreset::WipeRight => vec![
2062            kf_anim(
2063                "opacity",
2064                delay,
2065                0.0,
2066                delay + dur * 0.3,
2067                1.0,
2068                EasingType::EaseOut,
2069            ),
2070            kf_anim("position.x", delay, 200.0, end, 0.0, EasingType::EaseInOut),
2071        ],
2072    }
2073}
2074
2075fn kf(time: f64, value: f64) -> Keyframe {
2076    Keyframe {
2077        time,
2078        value: KeyframeValue::Number(value),
2079        easing: None,
2080    }
2081}
2082
2083fn kf_anim(property: &str, t0: f64, v0: f64, t1: f64, v1: f64, easing: EasingType) -> Animation {
2084    Animation {
2085        property: property.to_string(),
2086        keyframes: vec![kf(t0, v0), kf(t1, v1)],
2087        easing,
2088        spring: None,
2089    }
2090}
2091
2092fn kf_anim_spring(property: &str, t0: f64, v0: f64, t1: f64, v1: f64) -> Animation {
2093    Animation {
2094        property: property.to_string(),
2095        keyframes: vec![kf(t0, v0), kf(t1, v1)],
2096        easing: EasingType::Spring,
2097        spring: Some(SpringConfig {
2098            damping: 12.0,
2099            stiffness: 100.0,
2100            mass: 1.0,
2101            ..Default::default()
2102        }),
2103    }
2104}
2105
2106fn kf_anim_spring_underdamped(property: &str, t0: f64, v0: f64, t1: f64, v1: f64) -> Animation {
2107    Animation {
2108        property: property.to_string(),
2109        keyframes: vec![kf(t0, v0), kf(t1, v1)],
2110        easing: EasingType::Spring,
2111        spring: Some(SpringConfig {
2112            damping: 6.0,
2113            stiffness: 120.0,
2114            mass: 1.0,
2115            ..Default::default()
2116        }),
2117    }
2118}
2119
2120/// Three-keyframe oscillation laid out over an explicit `start..end` window,
2121/// so the caller controls both when it begins and how long one cycle lasts.
2122fn kf_anim_3kf_over(
2123    property: &str,
2124    start: f64,
2125    end: f64,
2126    v0: f64,
2127    v1: f64,
2128    v2: f64,
2129    easing: EasingType,
2130) -> Animation {
2131    Animation {
2132        property: property.to_string(),
2133        keyframes: vec![kf(start, v0), kf((start + end) / 2.0, v1), kf(end, v2)],
2134        easing,
2135        spring: None,
2136    }
2137}
2138
2139/// Four-keyframe oscillation (quarter/half/end split) laid out over an
2140/// explicit `start..end` window — the `shake` counterpart to
2141/// `kf_anim_3kf_over`.
2142#[allow(clippy::too_many_arguments)]
2143fn kf_anim_4kf_over(
2144    property: &str,
2145    start: f64,
2146    end: f64,
2147    v0: f64,
2148    v1: f64,
2149    v2: f64,
2150    v3: f64,
2151    easing: EasingType,
2152) -> Animation {
2153    let quarter = (end - start) / 4.0;
2154    Animation {
2155        property: property.to_string(),
2156        keyframes: vec![
2157            kf(start, v0),
2158            kf(start + quarter, v1),
2159            kf(start + quarter * 2.0, v2),
2160            kf(end, v3),
2161        ],
2162        easing,
2163        spring: None,
2164    }
2165}
2166
2167#[cfg(test)]
2168mod spring_preset_tests {
2169    //! TDD tests for issue #88: spring easing on any preset via
2170    //! `AnimationTiming.spring`.
2171
2172    use super::*;
2173    use crate::schema::AnimationEffect;
2174    use crate::schema::AnimationTiming;
2175
2176    fn timing(duration: f64, spring: Option<SpringConfig>) -> AnimationTiming {
2177        AnimationTiming {
2178            duration,
2179            spring,
2180            ..Default::default()
2181        }
2182    }
2183
2184    fn underdamped() -> SpringConfig {
2185        SpringConfig {
2186            damping: 8.0,
2187            stiffness: 120.0,
2188            mass: 1.0,
2189            ..Default::default()
2190        }
2191    }
2192
2193    /// Sample translate_y and opacity over the animation window.
2194    fn sample(effects: &[AnimationEffect], duration: f64) -> Vec<(f64, f64, f64)> {
2195        let steps = 80;
2196        (0..=steps)
2197            .map(|i| {
2198                let t = duration * i as f64 / steps as f64;
2199                let p = resolve_props_for_effects(effects, t, 5.0);
2200                (t, p.translate_y as f64, p.opacity as f64)
2201            })
2202            .collect()
2203    }
2204
2205    #[test]
2206    fn fade_in_up_spring_overshoots_position() {
2207        // Without spring: translate_y eases 60 → 0, never negative.
2208        let plain = sample(&[AnimationEffect::FadeInUp(timing(0.8, None))], 0.8);
2209        let min_plain = plain.iter().map(|(_, y, _)| *y).fold(f64::MAX, f64::min);
2210        assert!(
2211            min_plain >= -0.01,
2212            "without spring translate_y must never overshoot below 0, got min {min_plain}"
2213        );
2214
2215        // With an underdamped spring: the position overshoots past the final
2216        // value (goes measurably negative) somewhere inside the window.
2217        let sprung = sample(
2218            &[AnimationEffect::FadeInUp(timing(0.8, Some(underdamped())))],
2219            0.8,
2220        );
2221        let min_sprung = sprung.iter().map(|(_, y, _)| *y).fold(f64::MAX, f64::min);
2222        assert!(
2223            min_sprung < -0.5,
2224            "with spring translate_y must overshoot below 0, got min {min_sprung}"
2225        );
2226
2227        // At ~70% of the duration the two positions differ measurably.
2228        let y_plain_70 = plain[56].1;
2229        let y_sprung_70 = sprung[56].1;
2230        assert!(
2231            (y_plain_70 - y_sprung_70).abs() > 0.5,
2232            "at 70% duration spring vs plain must differ: {y_plain_70} vs {y_sprung_70}"
2233        );
2234    }
2235
2236    #[test]
2237    fn fade_in_up_spring_does_not_touch_opacity() {
2238        let plain = sample(&[AnimationEffect::FadeInUp(timing(0.8, None))], 0.8);
2239        let sprung = sample(
2240            &[AnimationEffect::FadeInUp(timing(0.8, Some(underdamped())))],
2241            0.8,
2242        );
2243        for (i, ((_, _, a_plain), (_, _, a_sprung))) in plain.iter().zip(sprung.iter()).enumerate()
2244        {
2245            assert!(
2246                (a_plain - a_sprung).abs() < 1e-6,
2247                "opacity must be identical with/without spring at sample {i}: {a_plain} vs {a_sprung}"
2248            );
2249        }
2250        // And alpha stays monotone non-decreasing (no overshoot flashes).
2251        for w in sprung.windows(2) {
2252            assert!(
2253                w[1].2 >= w[0].2 - 1e-6,
2254                "opacity must be monotone, got {} then {}",
2255                w[0].2,
2256                w[1].2
2257            );
2258        }
2259    }
2260
2261    #[test]
2262    fn bounce_in_custom_spring_differs_from_default() {
2263        let scale_at = |spring: Option<SpringConfig>, t: f64| -> f64 {
2264            let fx = [AnimationEffect::BounceIn(timing(0.8, spring))];
2265            resolve_props_for_effects(&fx, t, 5.0).scale_x as f64
2266        };
2267        // Default (damping 12/stiffness 100) vs a heavily overdamped custom
2268        // spring must produce different scales mid-flight.
2269        let overdamped = SpringConfig {
2270            damping: 40.0,
2271            stiffness: 100.0,
2272            mass: 1.0,
2273            ..Default::default()
2274        };
2275        let d = scale_at(None, 0.3);
2276        let c = scale_at(Some(overdamped), 0.3);
2277        assert!(
2278            (d - c).abs() > 0.01,
2279            "custom spring must change bounce_in: default {d} vs custom {c}"
2280        );
2281    }
2282
2283    #[test]
2284    fn scale_in_spring_collapses_manual_overshoot() {
2285        // ScaleIn's 3-keyframe manual overshoot (0 → 1.08 → 1) collapses to a
2286        // 2-keyframe spring (0 → 1): the spring provides the overshoot itself,
2287        // so scale must exceed 1.0 somewhere (underdamped) and converge to 1.
2288        let fx = [AnimationEffect::ScaleIn(timing(0.8, Some(underdamped())))];
2289        let mut max_scale = f64::MIN;
2290        for i in 0..=80 {
2291            let t = 0.8 * i as f64 / 80.0;
2292            let s = resolve_props_for_effects(&fx, t, 5.0).scale_x as f64;
2293            max_scale = max_scale.max(s);
2294        }
2295        // The manual overshoot keyframe peaks at exactly 1.08; the collapsed
2296        // underdamped spring (damping 8 / stiffness 120) peaks well above it —
2297        // this discriminates the spring path from the manual keyframe path.
2298        assert!(
2299            max_scale > 1.12,
2300            "spring scale_in must overshoot past the manual 1.08 peak, got max {max_scale}"
2301        );
2302        let end = resolve_props_for_effects(&fx, 0.8, 5.0).scale_x as f64;
2303        assert!(
2304            (end - 1.0).abs() < 1e-3,
2305            "scale must converge to 1.0 at window end, got {end}"
2306        );
2307    }
2308
2309    #[test]
2310    fn pulse_oscillator_ignores_spring() {
2311        // Pulse's scale loop (1 → 1.05 → 1) has identical endpoints — a
2312        // spring toward the same value would freeze the effect, so the
2313        // oscillator keeps its own shape.
2314        let at = |spring: Option<SpringConfig>, t: f64| -> f64 {
2315            let fx = [AnimationEffect::Pulse(timing(1.0, spring))];
2316            resolve_props_for_effects(&fx, t, 5.0).scale_x as f64
2317        };
2318        for i in 0..=20 {
2319            let t = i as f64 / 20.0;
2320            let plain = at(None, t);
2321            let sprung = at(Some(underdamped()), t);
2322            assert!(
2323                (plain - sprung).abs() < 1e-9,
2324                "pulse must be unaffected by spring at t={t}: {plain} vs {sprung}"
2325            );
2326        }
2327    }
2328
2329    #[test]
2330    fn animation_timing_spring_serde_round_trip() {
2331        let json = r#"{ "name": "fade_in_up", "duration": 0.6, "spring": { "damping": 8, "stiffness": 120 } }"#;
2332        let fx: AnimationEffect = serde_json::from_str(json).unwrap();
2333        let AnimationEffect::FadeInUp(t) = &fx else {
2334            panic!("wrong variant");
2335        };
2336        let s = t.spring.as_ref().expect("spring parsed");
2337        assert_eq!(s.damping, 8.0);
2338        assert_eq!(s.stiffness, 120.0);
2339        assert_eq!(s.mass, 1.0, "mass defaults to 1");
2340
2341        // Round-trip.
2342        let re = serde_json::to_string(&fx).unwrap();
2343        let back: AnimationEffect = serde_json::from_str(&re).unwrap();
2344        assert_eq!(fx, back);
2345
2346        // Absent spring stays absent.
2347        let plain: AnimationEffect = serde_json::from_str(r#"{ "name": "fade_in_up" }"#).unwrap();
2348        let AnimationEffect::FadeInUp(t) = &plain else {
2349            panic!("wrong variant");
2350        };
2351        assert!(t.spring.is_none());
2352    }
2353}
2354
2355#[cfg(test)]
2356mod glow_tests {
2357    //! M3: `find_glow_effect` extraction (issue #109). The colour/filter
2358    //! side of the fix lives in
2359    //! `rustmotion_components::box_builder::apply_glow_effect`, which is
2360    //! covered in that crate's own tests since it needs `CssStyle`/`FilterFn`
2361    //! (not available to this crate).
2362
2363    use super::*;
2364    use crate::schema::{AnimationEffect, AnimationTiming, GlowConfig};
2365
2366    fn glow(color: &str, radius: f32, intensity: f32) -> AnimationEffect {
2367        AnimationEffect::Glow(GlowConfig {
2368            color: color.to_string(),
2369            radius,
2370            intensity,
2371        })
2372    }
2373
2374    #[test]
2375    fn finds_glow_among_other_effects() {
2376        let effects = vec![
2377            AnimationEffect::FadeIn(AnimationTiming::default()),
2378            glow("#5C39EE", 12.0, 1.0),
2379        ];
2380        let found = find_glow_effect(&effects).expect("glow effect present");
2381        assert_eq!(found.color, "#5C39EE");
2382        assert_eq!(found.radius, 12.0);
2383    }
2384
2385    #[test]
2386    fn returns_none_without_a_glow_effect() {
2387        let effects = vec![AnimationEffect::FadeIn(AnimationTiming::default())];
2388        assert!(find_glow_effect(&effects).is_none());
2389    }
2390
2391    #[test]
2392    fn resolve_props_for_effects_does_not_touch_glow_radius_or_intensity() {
2393        // Deliberate: the named `glow` effect is applied directly as a CSS
2394        // filter by `box_builder::apply_glow_effect` (using the raw
2395        // `GlowConfig.color`, which `AnimatedProperties` has no field for),
2396        // not through this resolver. This guards against a future change
2397        // accidentally routing it through `AnimatedProperties` too, which
2398        // would double up into two stacked drop-shadows (see the doc comment
2399        // on `apply_glow_effect`).
2400        let effects = vec![glow("#5C39EE", 12.0, 1.0)];
2401        let props = resolve_props_for_effects(&effects, 0.0, 1.0);
2402        assert_eq!(
2403            props.glow_radius,
2404            AnimatedProperties::default().glow_radius,
2405            "glow_radius must stay at its sentinel; the named `glow` effect must not set it"
2406        );
2407        assert_eq!(
2408            props.glow_intensity,
2409            AnimatedProperties::default().glow_intensity
2410        );
2411    }
2412}
2413
2414#[cfg(test)]
2415mod float3d_amplitude_tests {
2416    //! Constat #1: `PresetConfig::amplitude` is read by `expand_preset_inner`
2417    //! (`config.amplitude.unwrap_or(12.0)`) but `AnimationTiming::to_preset_config`
2418    //! used to hardcode `amplitude: None`, so any author-supplied amplitude on
2419    //! a `float_3d` effect never reached the solver — every element bobbed by
2420    //! the same hardcoded 12px regardless of what was authored.
2421    use super::*;
2422    use crate::schema::AnimationEffect;
2423
2424    /// Peak absolute `translate_y` reached while sampling densely across one
2425    /// cycle — proxy for the oscillation's amplitude actually resolved.
2426    fn peak_translate_y(effects: &[AnimationEffect], window: f64) -> f64 {
2427        let mut peak = 0.0f64;
2428        let steps = 200;
2429        for i in 0..=steps {
2430            let t = window * i as f64 / steps as f64;
2431            let y = resolve_props_for_effects(effects, t, window + 1.0).translate_y as f64;
2432            if y.abs() > peak.abs() {
2433                peak = y;
2434            }
2435        }
2436        peak
2437    }
2438
2439    #[test]
2440    fn author_supplied_amplitude_reaches_the_solver() {
2441        // Parsed from raw JSON, not built in Rust — proves the value survives
2442        // serde all the way to the resolver, not merely that the struct has a
2443        // field for it.
2444        let default_fx: AnimationEffect =
2445            serde_json::from_str(r#"{ "name": "float_3d", "duration": 1.0 }"#).unwrap();
2446        let big_fx: AnimationEffect =
2447            serde_json::from_str(r#"{ "name": "float_3d", "duration": 1.0, "amplitude": 60 }"#)
2448                .unwrap();
2449
2450        let default_peak = peak_translate_y(&[default_fx], 1.0);
2451        let big_peak = peak_translate_y(&[big_fx], 1.0);
2452
2453        assert!(
2454            (default_peak.abs() - 12.0).abs() < 0.5,
2455            "default float_3d amplitude must stay ~12px, got {default_peak}"
2456        );
2457        assert!(
2458            big_peak.abs() > 50.0,
2459            "amplitude=60 must reach the solver (peak translate_y near 60px), got {big_peak} \
2460             (default was {default_peak})"
2461        );
2462    }
2463}
2464
2465#[cfg(test)]
2466mod continuous_preset_timing_tests {
2467    //! Constat #2: `pulse` / `float` / `shake` / `spin` used to fabricate
2468    //! keyframes at literal times 0.0/0.25/0.5/1.0, ignoring `config.delay`
2469    //! and `config.duration` entirely — every element sharing one of these
2470    //! presets moved in lockstep on a fixed 1-second cycle no matter what the
2471    //! scenario authored.
2472    use super::*;
2473    use crate::schema::AnimationEffect;
2474
2475    fn timing(delay: f64, duration: f64) -> AnimationTimingFixture {
2476        AnimationTimingFixture { delay, duration }
2477    }
2478
2479    /// Minimal JSON round-trip helper — keeps every case going through serde,
2480    /// like the author's JSON would.
2481    struct AnimationTimingFixture {
2482        delay: f64,
2483        duration: f64,
2484    }
2485
2486    impl AnimationTimingFixture {
2487        fn json(&self, name: &str) -> String {
2488            format!(
2489                r#"{{ "name": "{}", "delay": {}, "duration": {} }}"#,
2490                name, self.delay, self.duration
2491            )
2492        }
2493    }
2494
2495    #[test]
2496    fn pulse_honours_delay_and_duration() {
2497        let t = timing(1.0, 2.0);
2498        let fx: AnimationEffect = serde_json::from_str(&t.json("pulse")).unwrap();
2499        // Before its delay, the cycle has not started: the resolver clamps to
2500        // the first keyframe's value (the 0.95 trough) at every pre-delay
2501        // instant — it must be identical at two different pre-delay times,
2502        // not moving. Before the fix, delay/duration were ignored and the
2503        // preset ran its own literal 0..1s cycle regardless, so t=0.1 and
2504        // t=0.9 fell in different oscillation phases and disagreed.
2505        let early = resolve_props_for_effects(std::slice::from_ref(&fx), 0.1, 10.0).scale_x as f64;
2506        let late = resolve_props_for_effects(std::slice::from_ref(&fx), 0.9, 10.0).scale_x as f64;
2507        assert!(
2508            (early - late).abs() < 1e-6,
2509            "pulse must be frozen before its delay=1.0 (not yet oscillating): \
2510             t=0.1 -> {early}, t=0.9 -> {late}"
2511        );
2512        assert!(
2513            (early - 0.95).abs() < 0.01,
2514            "pulse before its delay must clamp to the first keyframe (0.95), got {early}"
2515        );
2516        // At the midpoint of its cycle (delay + duration/2 = 2.0): near the peak (1.05).
2517        let mid = resolve_props_for_effects(&[fx], 2.0, 10.0).scale_x as f64;
2518        assert!(
2519            mid > 1.03,
2520            "pulse at t=2.0 (cycle midpoint) must be near peak scale ~1.05, got {mid}"
2521        );
2522    }
2523
2524    #[test]
2525    fn float_honours_delay_and_duration() {
2526        let t = timing(1.0, 2.0);
2527        let fx: AnimationEffect = serde_json::from_str(&t.json("float")).unwrap();
2528        let before =
2529            resolve_props_for_effects(std::slice::from_ref(&fx), 0.5, 10.0).translate_y as f64;
2530        assert!(
2531            before.abs() < 0.1,
2532            "float at t=0.5 (before delay=1.0) must be at rest y=0, got {before}"
2533        );
2534        let mid = resolve_props_for_effects(&[fx], 2.0, 10.0).translate_y as f64;
2535        assert!(
2536            mid < -8.0,
2537            "float at t=2.0 (cycle midpoint) must be near peak y=-10, got {mid}"
2538        );
2539    }
2540
2541    #[test]
2542    fn shake_honours_delay_and_duration() {
2543        let t = timing(1.0, 2.0);
2544        let fx: AnimationEffect = serde_json::from_str(&t.json("shake")).unwrap();
2545        let before =
2546            resolve_props_for_effects(std::slice::from_ref(&fx), 0.5, 10.0).translate_x as f64;
2547        assert!(
2548            before.abs() < 0.1,
2549            "shake at t=0.5 (before delay=1.0) must be at rest x=0, got {before}"
2550        );
2551        // Quarter point of the cycle (delay + duration/4 = 1.5): near +10 peak.
2552        let quarter = resolve_props_for_effects(&[fx], 1.5, 10.0).translate_x as f64;
2553        assert!(
2554            quarter > 8.0,
2555            "shake at t=1.5 (cycle quarter) must be near peak x=+10, got {quarter}"
2556        );
2557    }
2558
2559    #[test]
2560    fn spin_honours_delay_and_duration() {
2561        let t = timing(1.0, 2.0);
2562        let fx: AnimationEffect = serde_json::from_str(&t.json("spin")).unwrap();
2563        let before =
2564            resolve_props_for_effects(std::slice::from_ref(&fx), 0.5, 10.0).rotation as f64;
2565        assert!(
2566            before.abs() < 0.1,
2567            "spin at t=0.5 (before delay=1.0) must be at rest rotation=0, got {before}"
2568        );
2569        // Halfway through its own cycle (delay + duration/2 = 2.0): ~180deg.
2570        let mid = resolve_props_for_effects(&[fx], 2.0, 10.0).rotation as f64;
2571        assert!(
2572            (mid - 180.0).abs() < 5.0,
2573            "spin at t=2.0 (cycle midpoint) must be near 180deg, got {mid}"
2574        );
2575    }
2576}
2577
2578#[cfg(test)]
2579mod keyframes_composition_tests {
2580    //! Constat #5: two `keyframes` effects targeting the same property used
2581    //! to be routed into one of two buckets purely by whether `delay != 0`
2582    //! (`owned_keyframes` vs `keyframes` in `extract_effects`), each bucket
2583    //! resolved by its own `resolve_animations` call and combined via
2584    //! `AnimatedProperties::merge` — which *sums* additive properties like
2585    //! `translate_x` across buckets, while two effects landing in the *same*
2586    //! bucket instead overwrite (last one in the list wins, since
2587    //! `apply_property` assigns rather than adds). So the composition rule
2588    //! depended entirely on an incidental field (`delay`) with no relation to
2589    //! authoring intent.
2590    //!
2591    //! Chosen semantic: every `keyframes`/`tilt_in` effect is resolved
2592    //! together in one `resolve_animations` call, in the order the effects
2593    //! appear in `style.animation` — like a CSS cascade, the *last* effect
2594    //! in the array wins on a shared property. This is deterministic and
2595    //! independent of `delay`.
2596    use super::*;
2597    use crate::schema::{Animation, AnimationEffect, Keyframe, KeyframeValue, KeyframesConfig};
2598
2599    /// A `keyframes` effect with one property ramping `0 -> value` over
2600    /// `[0, 1]` (pre-shift), then shifted by `delay`.
2601    fn ramp(property: &str, value: f64, delay: f64) -> AnimationEffect {
2602        AnimationEffect::Keyframes(KeyframesConfig {
2603            keyframes: vec![Animation {
2604                property: property.to_string(),
2605                keyframes: vec![
2606                    Keyframe {
2607                        time: 0.0,
2608                        value: KeyframeValue::Number(0.0),
2609                        easing: None,
2610                    },
2611                    Keyframe {
2612                        time: 1.0,
2613                        value: KeyframeValue::Number(value),
2614                        easing: None,
2615                    },
2616                ],
2617                easing: EasingType::Linear,
2618                spring: None,
2619            }],
2620            delay,
2621            duration: 0.8,
2622            repeat: false,
2623        })
2624    }
2625
2626    #[test]
2627    fn last_declared_effect_wins_regardless_of_which_one_carries_the_delay() {
2628        // Case 1: A (delay=0) declared first, B (delay=0.5) declared second.
2629        let a1 = ramp("translate_x", 100.0, 0.0);
2630        let b1 = ramp("translate_x", 40.0, 0.5);
2631        let combined_1 = resolve_props_for_effects(&[a1, b1.clone()], 1.0, 5.0).translate_x as f64;
2632        let b1_alone = resolve_props_for_effects(&[b1], 1.0, 5.0).translate_x as f64;
2633        assert!(
2634            (combined_1 - b1_alone).abs() < 1e-4,
2635            "B (declared last) must alone determine translate_x at t=1.0: combined={combined_1}, B-alone={b1_alone}"
2636        );
2637
2638        // Case 2: swap which one carries the delay, keep declaration order
2639        // (A first, B second) — the outcome must be identical in shape: B
2640        // (still last) wins alone, this time using B's own (now delay=0)
2641        // timing.
2642        let a2 = ramp("translate_x", 100.0, 0.5);
2643        let b2 = ramp("translate_x", 40.0, 0.0);
2644        let combined_2 = resolve_props_for_effects(&[a2, b2.clone()], 1.0, 5.0).translate_x as f64;
2645        let b2_alone = resolve_props_for_effects(&[b2], 1.0, 5.0).translate_x as f64;
2646        assert!(
2647            (combined_2 - b2_alone).abs() < 1e-4,
2648            "B (declared last) must alone determine translate_x at t=1.0 even with delay swapped: \
2649             combined={combined_2}, B-alone={b2_alone}"
2650        );
2651
2652        // The two cases must NOT collapse to the same number (sanity check
2653        // that this test isn't vacuous — B's own resolved value genuinely
2654        // differs between the two delay assignments).
2655        assert!(
2656            (combined_1 - combined_2).abs() > 1.0,
2657            "sanity: the two cases must differ (B's own timing changed): {combined_1} vs {combined_2}"
2658        );
2659    }
2660}
2661
2662#[cfg(test)]
2663mod keyframes_loop_tests {
2664    //! Constat #7: `"loop": true` on a `keyframes` effect or on `tilt_in`
2665    //! never reached the solver. `resolve_props_for_effects` always called
2666    //! `resolve_animations(&kfs, None, None, ...)` for both keyframe buckets
2667    //! — passing `preset_config = None` means `resolve_animations` falls back
2668    //! to `PresetConfig::default()`, whose `repeat` is `false`, so
2669    //! `loop_time` was never invoked no matter what `KeyframesConfig::repeat`
2670    //! / `TiltInConfig::repeat` said.
2671    use super::*;
2672    use crate::schema::{Animation, AnimationEffect, Keyframe, KeyframeValue, KeyframesConfig};
2673
2674    #[test]
2675    fn keyframes_loop_true_wraps_time_past_the_last_keyframe() {
2676        let looping = AnimationEffect::Keyframes(KeyframesConfig {
2677            keyframes: vec![Animation {
2678                property: "opacity".to_string(),
2679                keyframes: vec![
2680                    Keyframe {
2681                        time: 0.0,
2682                        value: KeyframeValue::Number(0.0),
2683                        easing: None,
2684                    },
2685                    Keyframe {
2686                        time: 1.0,
2687                        value: KeyframeValue::Number(1.0),
2688                        easing: None,
2689                    },
2690                ],
2691                easing: EasingType::Linear,
2692                spring: None,
2693            }],
2694            delay: 0.0,
2695            duration: 0.8,
2696            repeat: true,
2697        });
2698        // t=2.5 is past the keyframe's own last time (1.0). Without looping,
2699        // the resolver clamps to the last keyframe's value (1.0) forever.
2700        // With looping (start=0, end=1, duration=1), t=2.5 wraps to 0.5 ->
2701        // opacity should be ~0.5, not 1.0.
2702        let opacity = resolve_props_for_effects(&[looping], 2.5, 5.0).opacity as f64;
2703        assert!(
2704            (opacity - 0.5).abs() < 0.05,
2705            "looping keyframes at t=2.5 must wrap to local t=0.5 (opacity ~0.5), got {opacity}"
2706        );
2707    }
2708
2709    #[test]
2710    fn tilt_in_loop_true_keeps_tilting_past_its_settle_time() {
2711        let looping_tilt: AnimationEffect = serde_json::from_str(
2712            r#"{ "name": "tilt_in", "delay": 0.0, "duration": 0.4, "loop": true }"#,
2713        )
2714        .unwrap();
2715        let settled: AnimationEffect =
2716            serde_json::from_str(r#"{ "name": "tilt_in", "delay": 0.0, "duration": 0.4 }"#)
2717                .unwrap();
2718
2719        // Well past the settle time (0.4s): without loop, scale is pinned at
2720        // the final resting value (1.0). With loop (cycle 0..0.4), t=1.0
2721        // wraps to local t=0.2 (t=1.0 % 0.4 = 0.2), mid-tilt, scale != 1.0.
2722        let settled_scale = resolve_props_for_effects(&[settled], 1.0, 5.0).scale_x as f64;
2723        let looping_scale = resolve_props_for_effects(&[looping_tilt], 1.0, 5.0).scale_x as f64;
2724
2725        assert!(
2726            (settled_scale - 1.0).abs() < 1e-3,
2727            "non-looping tilt_in at t=1.0 (past settle) must be resting at scale 1.0, got {settled_scale}"
2728        );
2729        assert!(
2730            (looping_scale - 1.0).abs() > 0.01,
2731            "looping tilt_in at t=1.0 must still be mid-cycle (scale != 1.0 rest), got {looping_scale}"
2732        );
2733    }
2734}
2735
2736#[cfg(test)]
2737mod spring_robustness_tests {
2738    //! Constat #6: `spring_value` fed `mass`/`stiffness`/`damping` straight
2739    //! into `sqrt`/division with no floor, so `mass <= 0` or `stiffness <= 0`
2740    //! produced NaN (division by zero or sqrt of a negative number), and
2741    //! negative `damping` flipped the decay exponent's sign, diverging to
2742    //! +-infinity instead of settling. A NaN/inf progress value then flows
2743    //! into transform math (translate/scale) and contaminates the whole
2744    //! subtree it touches.
2745    use super::*;
2746
2747    #[test]
2748    fn zero_mass_does_not_produce_nan() {
2749        let config = SpringConfig {
2750            damping: 10.0,
2751            stiffness: 100.0,
2752            mass: 0.0,
2753            ..Default::default()
2754        };
2755        for i in 0..=20 {
2756            let t = i as f64 * 0.25;
2757            let v = spring_value(t, &config);
2758            assert!(
2759                v.is_finite(),
2760                "spring_value(t={t}) with mass=0 must be finite, got {v}"
2761            );
2762        }
2763    }
2764
2765    #[test]
2766    fn zero_stiffness_does_not_produce_nan() {
2767        let config = SpringConfig {
2768            damping: 10.0,
2769            stiffness: 0.0,
2770            mass: 1.0,
2771            ..Default::default()
2772        };
2773        for i in 0..=20 {
2774            let t = i as f64 * 0.25;
2775            let v = spring_value(t, &config);
2776            assert!(
2777                v.is_finite(),
2778                "spring_value(t={t}) with stiffness=0 must be finite, got {v}"
2779            );
2780        }
2781    }
2782
2783    #[test]
2784    fn negative_damping_stays_bounded_instead_of_diverging() {
2785        let config = SpringConfig {
2786            damping: -20.0,
2787            stiffness: 100.0,
2788            mass: 1.0,
2789            ..Default::default()
2790        };
2791        let v_at_5s = spring_value(5.0, &config);
2792        assert!(
2793            v_at_5s.is_finite() && v_at_5s.abs() < 100.0,
2794            "spring_value(t=5.0) with damping=-20 must stay bounded (finite and reasonably \
2795             small), got {v_at_5s} — negative damping must not diverge to +-infinity"
2796        );
2797    }
2798}
2799
2800#[cfg(test)]
2801mod spring_duration_tests {
2802    //! Issue #167 lot E: `SpringConfig::duration` forces a spring to settle
2803    //! (see `rest_threshold`) at exactly that many seconds by rescaling the
2804    //! time axis fed to the physics solver; `spring_rest_time` is the
2805    //! public "measure du repos" `rustmotion info` surfaces.
2806    use super::*;
2807
2808    /// Reference settle time via a fine linear scan of the actual
2809    /// implemented formula (`spring_value_raw`), independent of
2810    /// `spring_settle_time`'s coarse-then-bisect implementation — these
2811    /// tests check the algorithm against ground truth, not against itself.
2812    fn brute_force_settle_time(
2813        damping: f64,
2814        stiffness: f64,
2815        mass: f64,
2816        threshold: f64,
2817        max_t: f64,
2818        steps: usize,
2819    ) -> f64 {
2820        let dt = max_t / steps as f64;
2821        let mut last_exceed = 0.0;
2822        for i in 0..=steps {
2823            let t = i as f64 * dt;
2824            if (spring_value_raw(t, damping, stiffness, mass) - 1.0).abs() > threshold {
2825                last_exceed = t;
2826            }
2827        }
2828        last_exceed
2829    }
2830
2831    #[test]
2832    fn red_phase_duration_is_ignored_by_the_raw_physical_solver() {
2833        // Captured red-phase numbers (issue #167 lot E, before `duration`
2834        // existed on `SpringConfig`): a spring's settle time was purely
2835        // emergent from damping/stiffness/mass. `spring_value_raw` is
2836        // exactly that pre-existing, unscaled solver — by construction it
2837        // does not know about `duration`.
2838        //
2839        // damping=6, stiffness=120, mass=1 (the same "underdamped" preset
2840        // this file already uses for elastic_in / kf_anim_spring_underdamped)
2841        // at t=0.8s: spring_value_raw(0.8, 6, 120, 1) ~= 1.043467 — 4.35%
2842        // past the target, an order of magnitude outside any reasonable
2843        // rest_threshold (default 0.5%). An author asking this spring to
2844        // "finish at 0.8s" got a value nowhere near rest.
2845        let v = spring_value_raw(0.8, 6.0, 120.0, 1.0);
2846        assert!(
2847            (v - 1.043467).abs() < 1e-5,
2848            "captured red-phase reference value drifted: got {v}, expected ~1.043467"
2849        );
2850        assert!(
2851            (v - 1.0).abs() > 0.04,
2852            "red-phase claim: at t=duration the unscaled spring must still be far from rest \
2853             (got diff {:.6}, expected > 0.04)",
2854            (v - 1.0).abs()
2855        );
2856    }
2857
2858    #[test]
2859    fn duration_makes_the_spring_settle_exactly_there() {
2860        let config = SpringConfig {
2861            damping: 6.0,
2862            stiffness: 120.0,
2863            mass: 1.0,
2864            duration: Some(0.8),
2865            rest_threshold: None,
2866        };
2867        let threshold = DEFAULT_SPRING_REST_THRESHOLD;
2868
2869        // Green phase: the same (damping, stiffness, mass) that the
2870        // red-phase test above showed is 4.35% off at t=0.8s without a
2871        // `duration` must now be within `threshold` of rest at t=0.8s.
2872        let v_at_duration = spring_value(0.8, &config);
2873        assert!(
2874            (v_at_duration - 1.0).abs() <= threshold,
2875            "spring_value(0.8, ..) with duration=Some(0.8) must be within {threshold} of rest, \
2876             got {v_at_duration} (diff {})",
2877            (v_at_duration - 1.0).abs()
2878        );
2879
2880        // And it must not already be at rest well before `duration` —
2881        // this is a genuine rescale, not "duration happens to be late
2882        // enough not to matter".
2883        let v_at_half = spring_value(0.4, &config);
2884        assert!(
2885            (v_at_half - 1.0).abs() > threshold,
2886            "sanity: spring must not already be at rest at half of duration, got diff {}",
2887            (v_at_half - 1.0).abs()
2888        );
2889    }
2890
2891    #[test]
2892    fn spring_rest_time_returns_duration_verbatim_when_set() {
2893        let config = SpringConfig {
2894            damping: 6.0,
2895            stiffness: 120.0,
2896            mass: 1.0,
2897            duration: Some(0.8),
2898            rest_threshold: None,
2899        };
2900        assert_eq!(spring_rest_time(&config), 0.8);
2901    }
2902
2903    #[test]
2904    fn spring_rest_time_matches_a_brute_force_reference_without_duration() {
2905        let cases: [(f64, f64, f64, &str); 5] = [
2906            (15.0, 100.0, 1.0, "default"),
2907            (12.0, 100.0, 1.0, "kf_anim_spring"),
2908            (6.0, 120.0, 1.0, "underdamped elastic_in-like"),
2909            (
2910                20.0,
2911                100.0,
2912                1.0,
2913                "critically damped (damping = 2*sqrt(stiffness*mass))",
2914            ),
2915            (60.0, 100.0, 1.0, "overdamped"),
2916        ];
2917        for (damping, stiffness, mass, label) in cases {
2918            let config = SpringConfig {
2919                damping,
2920                stiffness,
2921                mass,
2922                duration: None,
2923                rest_threshold: None,
2924            };
2925            let threshold = DEFAULT_SPRING_REST_THRESHOLD;
2926            let got = spring_rest_time(&config);
2927            let reference = brute_force_settle_time(
2928                damping,
2929                stiffness,
2930                mass,
2931                threshold,
2932                MAX_SPRING_SEARCH_SECONDS,
2933                400_000,
2934            );
2935            let abs_err = (got - reference).abs();
2936            assert!(
2937                abs_err < 0.05,
2938                "{label}: spring_rest_time={got:.5}s vs brute-force reference={reference:.5}s \
2939                 (|err|={abs_err:.5}s, expected < 0.05s)"
2940            );
2941        }
2942    }
2943
2944    #[test]
2945    fn overdamped_spring_never_reaches_target_exactly_but_settle_time_is_found() {
2946        // Pitfall called out in the brief: an overdamped spring approaches
2947        // its target asymptotically and never touches it. The search must
2948        // terminate via `rest_threshold`, not by looking for an exact hit.
2949        let config = SpringConfig {
2950            damping: 200.0,
2951            stiffness: 100.0,
2952            mass: 1.0,
2953            duration: None,
2954            rest_threshold: None,
2955        };
2956        let t = spring_rest_time(&config);
2957        assert!(
2958            t > 0.0 && t < MAX_SPRING_SEARCH_SECONDS,
2959            "expected a finite, non-degenerate settle time, got {t}"
2960        );
2961
2962        // Confirm it genuinely never hits exactly 1.0 — the asymptotic
2963        // property `rest_threshold` exists to work around.
2964        for i in 1..=200 {
2965            let sample_t = t + i as f64 * 0.1;
2966            let v = spring_value_raw(sample_t, 200.0, 100.0, 1.0);
2967            assert_ne!(
2968                v, 1.0,
2969                "an overdamped spring must never hit its target exactly (t={sample_t})"
2970            );
2971        }
2972    }
2973
2974    #[test]
2975    fn undamped_spring_is_capped_not_infinite() {
2976        // Pitfall: damping=0 means the spring oscillates forever at
2977        // constant amplitude — it never settles. The search must return the
2978        // defined cap (`MAX_SPRING_SEARCH_SECONDS`), not loop forever.
2979        let config = SpringConfig {
2980            damping: 0.0,
2981            stiffness: 100.0,
2982            mass: 1.0,
2983            duration: None,
2984            rest_threshold: None,
2985        };
2986        let t = spring_rest_time(&config);
2987        assert_eq!(
2988            t, MAX_SPRING_SEARCH_SECONDS,
2989            "an undamped spring must be reported as capped at the search bound, got {t}"
2990        );
2991    }
2992
2993    #[test]
2994    fn very_lightly_damped_spring_is_also_capped_when_beyond_the_bound() {
2995        // Not literally undamped, but damped so lightly it does not reach a
2996        // 0.5% rest threshold within the search bound — same defined-cap
2997        // behaviour as the fully undamped case, exercised with nonzero
2998        // damping so the `zeta == 0` special case isn't the only path
2999        // that's actually bounded.
3000        let config = SpringConfig {
3001            damping: 0.05,
3002            stiffness: 100.0,
3003            mass: 1.0,
3004            duration: None,
3005            rest_threshold: None,
3006        };
3007        let t = spring_rest_time(&config);
3008        assert_eq!(
3009            t, MAX_SPRING_SEARCH_SECONDS,
3010            "expected the search to hit its cap, got {t}"
3011        );
3012    }
3013
3014    #[test]
3015    fn duration_remap_preserves_shape() {
3016        // The whole point of a spring's `duration` is to keep its shape —
3017        // oscillation count, overshoot amplitude — and only rescale how
3018        // fast it plays back. Compare the natural (no-duration) curve to a
3019        // duration-remapped curve of the *same* underlying spring, sampled
3020        // at matching fractions of each one's own settle time: if the remap
3021        // were instead clipping the tail (shortening, not rescaling), these
3022        // would diverge.
3023        let damping = 6.0;
3024        let stiffness = 120.0;
3025        let mass = 1.0;
3026        let natural = SpringConfig {
3027            damping,
3028            stiffness,
3029            mass,
3030            duration: None,
3031            rest_threshold: None,
3032        };
3033        let natural_rest = spring_rest_time(&natural);
3034
3035        let pinned_duration = 2.5; // deliberately different from natural_rest
3036        let pinned = SpringConfig {
3037            damping,
3038            stiffness,
3039            mass,
3040            duration: Some(pinned_duration),
3041            rest_threshold: None,
3042        };
3043
3044        let mut natural_overshoots = 0;
3045        let mut pinned_overshoots = 0;
3046        let mut max_natural_overshoot = 0.0_f64;
3047        let mut max_pinned_overshoot = 0.0_f64;
3048        let mut prev_natural_over = false;
3049        let mut prev_pinned_over = false;
3050
3051        for i in 0..=1000 {
3052            let frac = i as f64 / 1000.0;
3053            let v_natural = spring_value(frac * natural_rest, &natural);
3054            let v_pinned = spring_value(frac * pinned_duration, &pinned);
3055
3056            // Same fraction of each spring's own settle time must produce
3057            // the same progress value — that is the shape being preserved,
3058            // only the clock speed differs.
3059            assert!(
3060                (v_natural - v_pinned).abs() < 1e-9,
3061                "shape mismatch at fraction {frac}: natural={v_natural} pinned={v_pinned}"
3062            );
3063
3064            let natural_over = v_natural > 1.0;
3065            if natural_over && !prev_natural_over {
3066                natural_overshoots += 1;
3067            }
3068            prev_natural_over = natural_over;
3069            max_natural_overshoot = max_natural_overshoot.max(v_natural - 1.0);
3070
3071            let pinned_over = v_pinned > 1.0;
3072            if pinned_over && !prev_pinned_over {
3073                pinned_overshoots += 1;
3074            }
3075            prev_pinned_over = pinned_over;
3076            max_pinned_overshoot = max_pinned_overshoot.max(v_pinned - 1.0);
3077        }
3078
3079        assert!(
3080            natural_overshoots > 0,
3081            "expected this underdamped spring to overshoot at least once"
3082        );
3083        assert_eq!(
3084            natural_overshoots, pinned_overshoots,
3085            "oscillation count must be identical with/without duration"
3086        );
3087        assert!(
3088            (max_natural_overshoot - max_pinned_overshoot).abs() < 1e-9,
3089            "overshoot amplitude must be identical with/without duration: natural={max_natural_overshoot} pinned={max_pinned_overshoot}"
3090        );
3091    }
3092
3093    #[test]
3094    fn duration_does_not_change_delay_semantics() {
3095        // `spring_value`'s `t` argument is already local to the enclosing
3096        // segment (time since the segment/keyframe start — `delay` is
3097        // baked into where that segment begins, upstream of this call).
3098        // `duration` must not reinterpret that: t=0 must still be the
3099        // spring's own start regardless of `duration`.
3100        let config = SpringConfig {
3101            damping: 6.0,
3102            stiffness: 120.0,
3103            mass: 1.0,
3104            duration: Some(0.8),
3105            rest_threshold: None,
3106        };
3107        assert_eq!(
3108            spring_value(0.0, &config),
3109            spring_value_raw(0.0, 6.0, 120.0, 1.0)
3110        );
3111    }
3112}
3113
3114#[cfg(test)]
3115mod motion_path_tests {
3116    use super::*;
3117
3118    fn cfg(path: &str) -> MotionPathConfig {
3119        MotionPathConfig {
3120            path: path.to_string(),
3121            delay: 0.0,
3122            duration: 1.0,
3123            repeat: false,
3124            orient: false,
3125            orient_offset: 0.0,
3126            easing: EasingType::Linear,
3127        }
3128    }
3129
3130    // ─── The decisive property: on the curve, not the chord ──────────────
3131
3132    /// A bent two-segment polyline ("M0,0 L100,0 L100,100", total length
3133    /// 200) makes this trivial to prove without any bezier arithmetic:
3134    /// halfway along the *path* (distance 100) lands exactly on the corner
3135    /// (100, 0). The *chord* between the two endpoints (0,0)→(100,100) has
3136    /// its own midpoint at (50, 50) — a linear interpolation between
3137    /// endpoints (what a buggy "lerp the bounding box" implementation would
3138    /// produce) would land there instead. Asserting the real result is far
3139    /// from (50, 50) and exactly at (100, 0) is what distinguishes "walks
3140    /// the path" from "interpolates the endpoints" — checking only t=0/t=1
3141    /// bounds would pass either implementation.
3142    #[test]
3143    fn mid_path_progress_lands_on_the_curve_not_on_the_endpoint_chord() {
3144        let c = cfg("M0,0 L100,0 L100,100");
3145        let sample = motion_path_sample(&c, 0.5);
3146
3147        assert!(
3148            (sample.dx - 100.0).abs() < 0.5,
3149            "expected dx≈100 (on the path's corner), got {}",
3150            sample.dx
3151        );
3152        assert!(
3153            (sample.dy - 0.0).abs() < 0.5,
3154            "expected dy≈0 (on the path's corner), got {}",
3155            sample.dy
3156        );
3157
3158        let chord_x = 50.0f32;
3159        let chord_y = 50.0f32;
3160        let dist_from_chord_midpoint =
3161            ((sample.dx - chord_x).powi(2) + (sample.dy - chord_y).powi(2)).sqrt();
3162        assert!(
3163            dist_from_chord_midpoint > 40.0,
3164            "t=0.5 must not land near the endpoint-to-endpoint chord midpoint (50,50) — got \
3165             ({}, {}), which would also pass under a plain linear-interpolation bug",
3166            sample.dx,
3167            sample.dy
3168        );
3169    }
3170
3171    // ─── Endpoints, as a sanity boundary (not the decisive test on its own) ──
3172
3173    #[test]
3174    fn progress_zero_and_one_land_on_the_paths_own_endpoints() {
3175        let c = cfg("M10,20 L310,20 L310,220");
3176        let start = motion_path_sample(&c, 0.0);
3177        assert!((start.dx - 10.0).abs() < 0.5 && (start.dy - 20.0).abs() < 0.5);
3178
3179        let end = motion_path_sample(&c, 1.0);
3180        assert!((end.dx - 310.0).abs() < 0.5 && (end.dy - 220.0).abs() < 0.5);
3181    }
3182
3183    // ─── Coordinate space: deltas relative to the laid-out position ──────
3184
3185    /// The path's own coordinates are used literally as the translate delta
3186    /// — not normalized so the path's first point becomes (0,0). A path
3187    /// that starts away from the origin therefore starts the component
3188    /// already displaced by that much, on top of wherever layout placed it.
3189    #[test]
3190    fn path_coordinates_are_used_literally_as_the_translate_delta() {
3191        let c = cfg("M100,50 L300,50");
3192        let sample = motion_path_sample(&c, 0.0);
3193        assert!(
3194            (sample.dx - 100.0).abs() < 0.5 && (sample.dy - 50.0).abs() < 0.5,
3195            "expected the raw path start point (100, 50) as the delta, got ({}, {})",
3196            sample.dx,
3197            sample.dy
3198        );
3199    }
3200
3201    // ─── The channel: translate_x/translate_y/rotation, additive ─────────
3202
3203    #[test]
3204    fn apply_motion_paths_writes_translate_and_rotation_additively() {
3205        let mut props = AnimatedProperties {
3206            translate_x: 5.0,
3207            translate_y: -5.0,
3208            ..AnimatedProperties::default()
3209        };
3210        let mut c = cfg("M0,0 L100,0");
3211        c.orient = true;
3212        apply_motion_paths(&mut props, &[c], 0.0);
3213
3214        // Path start is (0,0), so translate ends up unchanged from the
3215        // pre-existing (5, -5) contribution — proves this is additive, not
3216        // an overwrite.
3217        assert!((props.translate_x - 5.0).abs() < 0.5);
3218        assert!((props.translate_y - (-5.0)).abs() < 0.5);
3219        // Horizontal rightward tangent ⇒ 0 degrees.
3220        assert!(props.rotation.abs() < 0.5, "got {}", props.rotation);
3221    }
3222
3223    #[test]
3224    fn orient_false_never_touches_rotation() {
3225        // A vertical segment has a 90°-ish tangent; if `orient` leaked
3226        // through despite being false, rotation would move off 0.
3227        let c = cfg("M0,0 L0,100");
3228        let mut props = AnimatedProperties::default();
3229        apply_motion_paths(&mut props, &[c], 0.5);
3230        assert_eq!(props.rotation, 0.0);
3231    }
3232
3233    #[test]
3234    fn orient_true_rotates_toward_the_tangent_and_offset_is_additive() {
3235        let mut vertical = cfg("M0,0 L0,100");
3236        vertical.orient = true;
3237        let sample = motion_path_sample(&vertical, 0.5);
3238        // Downward tangent (Skia is Y-down): atan2(1, 0) = 90°.
3239        assert!(
3240            (sample.angle_deg - 90.0).abs() < 1.0,
3241            "got {}",
3242            sample.angle_deg
3243        );
3244
3245        let mut with_offset = vertical.clone();
3246        with_offset.orient_offset = 10.0;
3247        let offset_sample = motion_path_sample(&with_offset, 0.5);
3248        assert!(
3249            (offset_sample.angle_deg - 100.0).abs() < 1.0,
3250            "orient_offset must add on top of the tangent angle, got {}",
3251            offset_sample.angle_deg
3252        );
3253    }
3254
3255    // ─── Degenerate cases: defined, finite, never NaN ─────────────────────
3256
3257    #[test]
3258    fn single_point_path_holds_position_and_never_produces_nan() {
3259        let mut c = cfg("M50,50");
3260        c.orient = true;
3261        for t in [-1.0, 0.0, 0.3, 0.5, 1.0, 2.0] {
3262            let sample = motion_path_sample(&c, t);
3263            assert!(sample.dx.is_finite() && sample.dy.is_finite() && sample.angle_deg.is_finite());
3264            assert!((sample.dx - 50.0).abs() < 0.5 && (sample.dy - 50.0).abs() < 0.5);
3265            assert_eq!(
3266                sample.angle_deg, 0.0,
3267                "orientation is undefined at zero length and must default to 0, not NaN"
3268            );
3269        }
3270    }
3271
3272    #[test]
3273    fn coincident_points_zero_length_path_holds_without_nan() {
3274        let mut c = cfg("M10,10 L10,10 L10,10");
3275        c.orient = true;
3276        let sample = motion_path_sample(&c, 0.5);
3277        assert!(sample.dx.is_finite() && sample.dy.is_finite() && sample.angle_deg.is_finite());
3278        assert!((sample.dx - 10.0).abs() < 0.5 && (sample.dy - 10.0).abs() < 0.5);
3279        assert_eq!(sample.angle_deg, 0.0);
3280    }
3281
3282    #[test]
3283    fn empty_path_data_never_panics_or_produces_nan() {
3284        // Bypasses `deserialize_motion_path_data`'s parse-time rejection on
3285        // purpose (constructed directly in Rust) — the runtime sampler must
3286        // still be safe on its own, defence in depth.
3287        let c = cfg("");
3288        let sample = motion_path_sample(&c, 0.5);
3289        assert_eq!((sample.dx, sample.dy, sample.angle_deg), (0.0, 0.0, 0.0));
3290    }
3291
3292    #[test]
3293    fn unparsable_path_data_never_panics_or_produces_nan() {
3294        let c = cfg("definitely not svg path data");
3295        let sample = motion_path_sample(&c, 0.5);
3296        assert!(sample.dx.is_finite() && sample.dy.is_finite() && sample.angle_deg.is_finite());
3297    }
3298
3299    #[test]
3300    fn zero_or_negative_duration_never_produces_nan() {
3301        for duration in [0.0, -1.0, -0.5] {
3302            let mut c = cfg("M0,0 L100,0");
3303            c.duration = duration;
3304            for t in [0.0, 0.5, 1.0, 5.0] {
3305                let sample = motion_path_sample(&c, t);
3306                assert!(
3307                    sample.dx.is_finite() && sample.dy.is_finite() && sample.angle_deg.is_finite(),
3308                    "duration={duration} time={t} produced a non-finite sample: dx={} dy={}",
3309                    sample.dx,
3310                    sample.dy
3311                );
3312            }
3313        }
3314    }
3315
3316    #[test]
3317    fn motion_path_length_reports_none_for_empty_or_unparsable_input() {
3318        assert_eq!(motion_path_length(""), None);
3319        assert_eq!(motion_path_length("not a path"), None);
3320    }
3321
3322    #[test]
3323    fn motion_path_length_reports_near_zero_for_a_single_point() {
3324        let len = motion_path_length("M50,50").expect("single point is a valid, parseable path");
3325        assert!(len <= MOTION_PATH_MIN_LENGTH, "got {len}");
3326    }
3327
3328    #[test]
3329    fn motion_path_length_reports_the_real_length_for_a_real_path() {
3330        let len = motion_path_length("M0,0 L100,0").expect("valid path");
3331        assert!((len - 100.0).abs() < 0.5, "got {len}");
3332    }
3333
3334    // ─── Loop semantics ────────────────────────────────────────────────────
3335
3336    #[test]
3337    fn looping_wraps_progress_back_toward_the_start() {
3338        let mut c = cfg("M0,0 L100,0 L100,100");
3339        c.repeat = true;
3340        c.duration = 1.0;
3341        // 1.5s in, with a 1s loop period, is equivalent to t=0.5 within the
3342        // loop — same corner-of-the-L assertion as the non-looping test.
3343        let sample = motion_path_sample(&c, 1.5);
3344        assert!((sample.dx - 100.0).abs() < 0.5 && (sample.dy - 0.0).abs() < 0.5);
3345    }
3346
3347    #[test]
3348    fn non_looping_holds_at_the_end_past_delay_plus_duration() {
3349        let c = cfg("M0,0 L100,0 L100,100");
3350        let at_end = motion_path_sample(&c, 1.0);
3351        let past_end = motion_path_sample(&c, 5.0);
3352        assert_eq!(at_end.dx, past_end.dx);
3353        assert_eq!(at_end.dy, past_end.dy);
3354    }
3355
3356    // ─── Determinism: same time in, same result out ───────────────────────
3357
3358    #[test]
3359    fn sampling_is_deterministic_across_repeated_calls() {
3360        let c = cfg("M0,0 C50,-100 150,-100 200,0");
3361        let first = motion_path_sample(&c, 0.37);
3362        for _ in 0..25 {
3363            let again = motion_path_sample(&c, 0.37);
3364            assert_eq!(first.dx, again.dx);
3365            assert_eq!(first.dy, again.dy);
3366            assert_eq!(first.angle_deg, again.angle_deg);
3367        }
3368    }
3369
3370    #[test]
3371    fn resolve_props_for_effects_is_deterministic_and_reaches_translate() {
3372        let effect = AnimationEffect::MotionPath(cfg("M0,0 L400,0"));
3373        let effects = vec![effect];
3374        let a = resolve_props_for_effects(&effects, 0.5, 1.0);
3375        let b = resolve_props_for_effects(&effects, 0.5, 1.0);
3376        assert_eq!(a.translate_x, b.translate_x);
3377        assert_eq!(a.translate_y, b.translate_y);
3378        assert!(
3379            (a.translate_x - 200.0).abs() < 1.0,
3380            "expected ~halfway along a straight 400px path, got {}",
3381            a.translate_x
3382        );
3383    }
3384}
3385
3386#[cfg(test)]
3387mod char_animation_tuning_tests {
3388    use super::*;
3389
3390    fn anim(stagger: f32, jitter: f32, seed: u32) -> ResolvedCharAnimation {
3391        ResolvedCharAnimation {
3392            preset: CharAnimPreset::SlideUp,
3393            granularity: TextAnimGranularity::Word,
3394            stagger,
3395            duration: 0.4,
3396            easing: EasingType::Linear,
3397            delay: 0.5,
3398            overshoot: 0.08,
3399            blur: DEFAULT_CHAR_BLUR_SIGMA,
3400            direction: TextAnimDirection::Up,
3401            distance: 1.0,
3402            scale_from: None,
3403            jitter,
3404            seed,
3405            ink_from: None,
3406        }
3407    }
3408
3409    #[test]
3410    fn without_jitter_units_are_evenly_spaced() {
3411        let a = anim(0.2, 0.0, 0);
3412        for i in 0..6 {
3413            let expected = 0.5 + i as f64 * 0.2;
3414            // f32 fields widened to f64 — compare at f32 precision.
3415            assert!(
3416                (a.unit_start(i) - expected).abs() < 1e-6,
3417                "unit {i} should start at {expected}, got {}",
3418                a.unit_start(i)
3419            );
3420        }
3421    }
3422
3423    #[test]
3424    fn jitter_is_a_pure_function_of_index_and_seed() {
3425        // Frames are rendered out of order, in parallel, and across separate
3426        // processes (`--frames a-b` segments). If the nudge came from an RNG,
3427        // a word would land at a different time in each of those, i.e. jump
3428        // between neighbouring frames of the same video.
3429        let a = anim(0.2, 0.6, 42);
3430        let b = anim(0.2, 0.6, 42);
3431        for i in 0..32 {
3432            assert_eq!(
3433                a.unit_start(i).to_bits(),
3434                b.unit_start(i).to_bits(),
3435                "unit {i} must resolve bit-identically for the same seed"
3436            );
3437        }
3438    }
3439
3440    #[test]
3441    fn a_different_seed_reshuffles_the_rhythm() {
3442        let a = anim(0.2, 0.6, 1);
3443        let b = anim(0.2, 0.6, 2);
3444        let differing = (0..32)
3445            .filter(|&i| a.unit_start(i) != b.unit_start(i))
3446            .count();
3447        assert!(
3448            differing > 24,
3449            "changing the seed should move nearly every unit, but only {differing}/32 moved"
3450        );
3451    }
3452
3453    #[test]
3454    fn jitter_actually_perturbs_the_even_spacing() {
3455        let even = anim(0.2, 0.0, 7);
3456        let jittered = anim(0.2, 0.8, 7);
3457        let moved = (1..32)
3458            .filter(|&i| (even.unit_start(i) - jittered.unit_start(i)).abs() > 1e-6)
3459            .count();
3460        assert!(
3461            moved > 20,
3462            "jitter should visibly perturb the march, but only {moved}/31 units moved"
3463        );
3464    }
3465
3466    #[test]
3467    fn no_unit_starts_before_the_effects_own_delay() {
3468        // A negative nudge on the first unit would have it appear already
3469        // half-animated on frame 0 — the one artefact the clamp exists for.
3470        let a = anim(0.2, 2.0, 99);
3471        for i in 0..64 {
3472            assert!(
3473                a.unit_start(i) >= 0.5 - 1e-9,
3474                "unit {i} started at {} — before the effect's own 0.5s delay",
3475                a.unit_start(i)
3476            );
3477        }
3478    }
3479
3480    #[test]
3481    fn a_zero_stagger_is_unaffected_by_jitter() {
3482        // Nothing to spread out: every unit shares one start time, and
3483        // `jitter` scales off `stagger`, so it has nothing to scale.
3484        let a = anim(0.0, 1.0, 3);
3485        for i in 0..8 {
3486            assert!((a.unit_start(i) - 0.5).abs() < 1e-9);
3487        }
3488    }
3489}