Skip to main content

rustmotion_core/engine/
animator.rs

1use crate::schema::{
2    Animation, AnimationEffect, AnimationPreset, BurstConfig, CharAnimPreset, EasingType,
3    GlowConfig, Keyframe, KeyframeValue, MotionPathConfig, OrbitConfig, PresetConfig, RotateOrigin,
4    SpringConfig, TextAnimDirection, TextAnimGranularity, WiggleConfig,
5};
6
7pub const DEFAULT_CHAR_BLUR_SIGMA: f32 = 14.0;
8
9#[inline]
10pub fn safe_div(num: f64, denom: f64, fallback: f64) -> f64 {
11    if denom.abs() < 1e-9 {
12        fallback
13    } else {
14        num / denom
15    }
16}
17
18#[inline]
19pub fn safe_div_f32(num: f32, denom: f32, fallback: f32) -> f32 {
20    if denom.abs() < 1e-6 {
21        fallback
22    } else {
23        num / denom
24    }
25}
26
27#[derive(Debug, Clone)]
28pub struct ResolvedCharAnimation {
29    pub preset: CharAnimPreset,
30    pub granularity: TextAnimGranularity,
31    pub stagger: f32,
32    pub duration: f32,
33    pub easing: EasingType,
34    pub delay: f32,
35    pub overshoot: f32,
36    pub blur: f32,
37    pub direction: TextAnimDirection,
38    pub distance: f32,
39    pub scale_from: Option<f32>,
40    pub jitter: f32,
41    pub seed: u32,
42    pub ink_from: Option<String>,
43    pub rotate_from: Option<f32>,
44    pub rotate_origin: RotateOrigin,
45    pub scale_jitter: f32,
46    pub baseline_jitter: f32,
47    pub reflow: bool,
48    pub reflow_easing: EasingType,
49}
50
51fn unit_rand_signed(idx: usize, seed: u32, salt: u64) -> f64 {
52    let mut h = (idx as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ (seed as u64) ^ salt;
53    h ^= h >> 30;
54    h = h.wrapping_mul(0xBF58_476D_1CE4_E5B9);
55    h ^= h >> 27;
56    h = h.wrapping_mul(0x94D0_49BB_1331_11EB);
57    h ^= h >> 31;
58    let unit = (h >> 11) as f64 / (1u64 << 53) as f64;
59    unit * 2.0 - 1.0
60}
61
62const BASELINE_JITTER_SALT: u64 = 0xB522_9E17_C2A1_0D4F;
63const SCALE_JITTER_SALT: u64 = 0x5A1E_3B7C_9F02_88D1;
64
65impl ResolvedCharAnimation {
66    pub fn unit_start(&self, idx: usize) -> f64 {
67        let even = self.delay as f64 + idx as f64 * self.stagger as f64;
68        if self.jitter.abs() < 1e-6 || self.stagger.abs() < 1e-6 {
69            return even;
70        }
71        let nudge = unit_rand_signed(idx, self.seed, 0) * self.jitter as f64 * self.stagger as f64;
72        (even + nudge).max(self.delay as f64)
73    }
74
75    fn raw_unit_progress(&self, idx: usize, time: f64) -> f32 {
76        let unit_start = self.unit_start(idx);
77        let unit_end = unit_start + self.duration as f64;
78        if time <= unit_start {
79            0.0
80        } else if time >= unit_end {
81            1.0
82        } else {
83            ((time - unit_start) / (unit_end - unit_start)) as f32
84        }
85    }
86
87    pub fn unit_progress(&self, idx: usize, time: f64) -> f32 {
88        ease(self.raw_unit_progress(idx, time) as f64, &self.easing) as f32
89    }
90
91    pub fn reflow_progress(&self, idx: usize, time: f64) -> f32 {
92        ease(
93            self.raw_unit_progress(idx, time) as f64,
94            &self.reflow_easing,
95        ) as f32
96    }
97
98    pub fn scale_jitter_delta(&self, idx: usize) -> f32 {
99        if self.scale_jitter.abs() < 1e-6 {
100            return 0.0;
101        }
102        (unit_rand_signed(idx, self.seed, SCALE_JITTER_SALT) as f32) * self.scale_jitter
103    }
104
105    pub fn baseline_jitter_delta(&self, idx: usize) -> f32 {
106        if self.baseline_jitter.abs() < 1e-6 {
107            return 0.0;
108        }
109        (unit_rand_signed(idx, self.seed, BASELINE_JITTER_SALT) as f32) * self.baseline_jitter
110    }
111}
112
113pub struct ExtractedEffects<'a> {
114    pub presets: Vec<(AnimationPreset, PresetConfig)>,
115    pub keyframe_animations: Vec<Animation>,
116    pub keyframes_loop: bool,
117    pub wiggles: Vec<&'a WiggleConfig>,
118    pub orbits: Vec<&'a OrbitConfig>,
119    pub motion_paths: Vec<&'a MotionPathConfig>,
120    pub glow: Option<&'a GlowConfig>,
121    pub motion_blur: Option<f32>,
122    pub char_animation: Option<ResolvedCharAnimation>,
123}
124
125pub fn find_glow_effect(effects: &[AnimationEffect]) -> Option<&GlowConfig> {
126    effects.iter().find_map(|e| match e {
127        AnimationEffect::Glow(cfg) => Some(cfg),
128        _ => None,
129    })
130}
131
132pub fn extract_effects(effects: &[AnimationEffect]) -> ExtractedEffects<'_> {
133    let mut result = ExtractedEffects {
134        presets: Vec::new(),
135        keyframe_animations: Vec::new(),
136        keyframes_loop: false,
137        wiggles: Vec::new(),
138        orbits: Vec::new(),
139        motion_paths: Vec::new(),
140        glow: None,
141        motion_blur: None,
142        char_animation: None,
143    };
144
145    for effect in effects {
146        if let Some((preset, timing)) = effect.as_preset() {
147            result.presets.push((preset, timing.to_preset_config()));
148        } else {
149            match effect {
150                AnimationEffect::CharScaleIn(t)
151                | AnimationEffect::CharFadeIn(t)
152                | AnimationEffect::CharWave(t)
153                | AnimationEffect::CharBounce(t)
154                | AnimationEffect::CharRotateIn(t)
155                | AnimationEffect::CharSlideUp(t)
156                | AnimationEffect::CharBlurIn(t) => {
157                    let preset = match effect {
158                        AnimationEffect::CharScaleIn(_) => CharAnimPreset::ScaleIn,
159                        AnimationEffect::CharFadeIn(_) => CharAnimPreset::FadeIn,
160                        AnimationEffect::CharWave(_) => CharAnimPreset::Wave,
161                        AnimationEffect::CharBounce(_) => CharAnimPreset::Bounce,
162                        AnimationEffect::CharRotateIn(_) => CharAnimPreset::RotateIn,
163                        AnimationEffect::CharSlideUp(_) => CharAnimPreset::SlideUp,
164                        AnimationEffect::CharBlurIn(_) => CharAnimPreset::BlurIn,
165                        _ => unreachable!(),
166                    };
167                    result.char_animation = Some(ResolvedCharAnimation {
168                        preset,
169                        granularity: t.granularity.clone(),
170                        stagger: t.stagger as f32,
171                        duration: t.duration as f32,
172                        easing: t.easing.clone(),
173                        delay: t.delay as f32,
174                        overshoot: t.overshoot.unwrap_or(0.08) as f32,
175                        blur: t.blur.map(|b| b as f32).unwrap_or(DEFAULT_CHAR_BLUR_SIGMA),
176                        direction: t.direction,
177                        distance: t.distance.unwrap_or(1.0) as f32,
178                        scale_from: t.scale_from.map(|s| s as f32),
179                        jitter: t.jitter.unwrap_or(0.0) as f32,
180                        seed: t.seed.unwrap_or(0),
181                        ink_from: t.ink_from.clone(),
182                        rotate_from: t.rotate_from.map(|r| r as f32),
183                        rotate_origin: t.rotate_origin,
184                        scale_jitter: t.scale_jitter.unwrap_or(0.0) as f32,
185                        baseline_jitter: t.baseline_jitter.unwrap_or(0.0) as f32,
186                        reflow: t.reflow,
187                        reflow_easing: t.reflow_easing.clone(),
188                    });
189                }
190                AnimationEffect::Glow(config) => {
191                    result.glow = Some(config);
192                }
193                AnimationEffect::Wiggle(config) => {
194                    result.wiggles.push(config);
195                }
196                AnimationEffect::Orbit(config) => {
197                    result.orbits.push(config);
198                }
199                AnimationEffect::Keyframes(config) => {
200                    result
201                        .keyframe_animations
202                        .extend(config.keyframes.iter().map(|anim| {
203                            let mut a = anim.clone();
204                            for kf in &mut a.keyframes {
205                                kf.time += config.delay;
206                            }
207                            a
208                        }));
209                    if config.repeat {
210                        result.keyframes_loop = true;
211                    }
212                }
213                AnimationEffect::TiltIn(config) => {
214                    let delay = config.delay;
215                    let end = delay + config.duration;
216                    let rx = config.rotate_x.unwrap_or(15.0);
217                    let ry = config.rotate_y.unwrap_or(-15.0);
218                    let persp = config.perspective.unwrap_or(1000.0);
219                    let sc = config.scale_from.unwrap_or(0.9);
220                    result.keyframe_animations.extend([
221                        kf_anim(
222                            "opacity",
223                            delay,
224                            0.0,
225                            delay + config.duration * 0.3,
226                            1.0,
227                            EasingType::EaseOut,
228                        ),
229                        kf_anim("rotate_x", delay, rx, end, 0.0, EasingType::EaseOutCubic),
230                        kf_anim("rotate_y", delay, ry, end, 0.0, EasingType::EaseOutCubic),
231                        kf_anim("perspective", delay, persp, end, persp, EasingType::Linear),
232                        kf_anim("scale", delay, sc, end, 1.0, EasingType::EaseOutCubic),
233                    ]);
234                    if config.repeat {
235                        result.keyframes_loop = true;
236                    }
237                }
238                AnimationEffect::MotionBlur(config) => {
239                    result.motion_blur = Some(config.intensity);
240                }
241                AnimationEffect::MotionPath(config) => {
242                    result.motion_paths.push(config);
243                }
244                _ => {}
245            }
246        }
247    }
248
249    result
250}
251
252pub fn ease(t: f64, easing: &EasingType) -> f64 {
253    let t = t.clamp(0.0, 1.0);
254    match easing {
255        EasingType::Linear => t,
256        EasingType::EaseIn => ease_in_cubic(t),
257        EasingType::EaseOut => ease_out_cubic(t),
258        EasingType::EaseInOut => ease_in_out_cubic(t),
259        EasingType::EaseInQuad => t * t,
260        EasingType::EaseOutQuad => 1.0 - (1.0 - t) * (1.0 - t),
261        EasingType::EaseInCubic => ease_in_cubic(t),
262        EasingType::EaseOutCubic => ease_out_cubic(t),
263        EasingType::EaseInExpo => {
264            if t == 0.0 {
265                0.0
266            } else {
267                (2.0f64).powf(10.0 * (t - 1.0))
268            }
269        }
270        EasingType::EaseOutExpo => {
271            if t == 1.0 {
272                1.0
273            } else {
274                1.0 - (2.0f64).powf(-10.0 * t)
275            }
276        }
277        EasingType::EaseInOutQuad => {
278            if t < 0.5 {
279                2.0 * t * t
280            } else {
281                1.0 - (-2.0 * t + 2.0).powi(2) / 2.0
282            }
283        }
284        EasingType::EaseInOutExpo => {
285            if t == 0.0 {
286                0.0
287            } else if t == 1.0 {
288                1.0
289            } else if t < 0.5 {
290                (2.0f64).powf(20.0 * t - 10.0) / 2.0
291            } else {
292                (2.0 - (2.0f64).powf(-20.0 * t + 10.0)) / 2.0
293            }
294        }
295        EasingType::EaseInBack => {
296            let c1 = 1.70158;
297            let c3 = c1 + 1.0;
298            c3 * t * t * t - c1 * t * t
299        }
300        EasingType::EaseOutBack => {
301            let c1 = 1.70158;
302            let c3 = c1 + 1.0;
303            1.0 + c3 * (t - 1.0).powi(3) + c1 * (t - 1.0).powi(2)
304        }
305        EasingType::EaseOutElastic => {
306            if t == 0.0 {
307                0.0
308            } else if t == 1.0 {
309                1.0
310            } else {
311                let c4 = (2.0 * std::f64::consts::PI) / 3.0;
312                (2.0f64).powf(-10.0 * t) * ((t * 10.0 - 0.75) * c4).sin() + 1.0
313            }
314        }
315        EasingType::Bounce => bounce_ease_out(t),
316        EasingType::Spring => t,
317        EasingType::CubicBezier { x1, y1, x2, y2 } => cubic_bezier_ease(t, *x1, *y1, *x2, *y2),
318        EasingType::Steps(n) => {
319            let n = (*n).max(1) as f64;
320            if t >= 1.0 {
321                1.0
322            } else {
323                (t * n).floor() / n
324            }
325        }
326    }
327}
328
329pub fn chromatic_aberration_shift(
330    cfg: &crate::schema::ChromaticAberrationConfig,
331    progress: f32,
332) -> f32 {
333    let eased = ease(progress as f64, &cfg.easing) as f32;
334    cfg.amount * (1.0 - eased)
335}
336
337#[cfg(test)]
338mod chromatic_aberration_shift_tests {
339    use super::*;
340    use crate::schema::{ChromaticAberrationConfig, EasingType};
341
342    fn cfg(amount: f32) -> ChromaticAberrationConfig {
343        ChromaticAberrationConfig {
344            delay: 0.0,
345            duration: 0.6,
346            amount,
347            easing: EasingType::Linear,
348        }
349    }
350
351    #[test]
352    fn peaks_at_the_full_amount_when_progress_is_zero() {
353        assert_eq!(chromatic_aberration_shift(&cfg(6.0), 0.0), 6.0);
354    }
355
356    #[test]
357    fn decays_to_exactly_zero_when_progress_reaches_one() {
358        assert_eq!(chromatic_aberration_shift(&cfg(6.0), 1.0), 0.0);
359    }
360
361    #[test]
362    fn is_between_zero_and_the_amount_mid_flight() {
363        let shift = chromatic_aberration_shift(&cfg(6.0), 0.5);
364        assert!(shift > 0.0 && shift < 6.0, "got {shift}");
365    }
366}
367
368pub fn shatter_progress(cfg: &crate::schema::ShatterConfig, time: f64) -> Option<f32> {
369    use crate::schema::ShatterMode;
370
371    if cfg.duration <= 0.0 {
372        return None;
373    }
374    let elapsed = time - cfg.delay;
375    if elapsed < 0.0 {
376        return None;
377    }
378    match cfg.mode {
379        ShatterMode::Hold => Some((elapsed / cfg.duration).min(1.0) as f32),
380        ShatterMode::Out => {
381            if elapsed >= cfg.duration {
382                None
383            } else {
384                Some((elapsed / cfg.duration) as f32)
385            }
386        }
387        ShatterMode::In => {
388            if elapsed >= cfg.duration {
389                None
390            } else {
391                Some((1.0 - elapsed / cfg.duration) as f32)
392            }
393        }
394    }
395}
396
397pub fn burst_progress(cfg: &BurstConfig, time: f64) -> Option<f32> {
398    if cfg.duration <= 0.0 {
399        return None;
400    }
401    let elapsed = time - cfg.delay;
402    if elapsed < 0.0 || elapsed >= cfg.duration {
403        return None;
404    }
405    Some((elapsed / cfg.duration) as f32)
406}
407
408pub fn burst_stroke_span(progress: f32, phase: f32) -> (f32, f32) {
409    let phase = phase.clamp(0.0, 0.9);
410    let local = ((progress - phase) / (1.0 - phase)).clamp(0.0, 1.0);
411    let head = ease_out_cubic((local * 2.0).min(1.0) as f64) as f32;
412    let tail = ease_in_cubic((local * 2.0 - 1.0).clamp(0.0, 1.0) as f64) as f32;
413    (tail, head)
414}
415
416#[cfg(test)]
417mod shatter_progress_tests {
418    use super::*;
419    use crate::schema::{ShatterConfig, ShatterMode};
420
421    fn cfg(mode: ShatterMode) -> ShatterConfig {
422        ShatterConfig {
423            delay: 1.0,
424            duration: 0.5,
425            mode,
426            pieces: 12,
427            seed: 3,
428            origin: Default::default(),
429            spread: 1.0,
430            spin: 90.0,
431            depth: 0.4,
432            fade: true,
433        }
434    }
435
436    #[test]
437    fn out_is_none_before_delay_and_at_or_after_the_end() {
438        let c = cfg(ShatterMode::Out);
439        assert_eq!(shatter_progress(&c, 0.0), None);
440        assert_eq!(shatter_progress(&c, 0.999), None);
441        assert_eq!(shatter_progress(&c, 1.5), None);
442        assert_eq!(shatter_progress(&c, 10.0), None);
443    }
444
445    #[test]
446    fn out_climbs_from_zero_to_just_under_one_across_the_window() {
447        let c = cfg(ShatterMode::Out);
448        assert_eq!(shatter_progress(&c, 1.0), Some(0.0));
449        let mid = shatter_progress(&c, 1.25).unwrap();
450        assert!(mid > 0.0 && mid < 1.0, "got {mid}");
451    }
452
453    #[test]
454    fn in_is_the_mirror_of_out() {
455        let c = cfg(ShatterMode::In);
456        assert_eq!(shatter_progress(&c, 0.0), None);
457        assert_eq!(shatter_progress(&c, 1.5), None);
458        assert_eq!(shatter_progress(&c, 1.0), Some(1.0));
459        let mid = shatter_progress(&c, 1.25).unwrap();
460        assert!(mid > 0.0 && mid < 1.0, "got {mid}");
461    }
462
463    #[test]
464    fn hold_freezes_at_one_past_the_window_instead_of_going_back_to_none() {
465        let c = cfg(ShatterMode::Hold);
466        assert_eq!(shatter_progress(&c, 0.0), None);
467        assert_eq!(shatter_progress(&c, 1.0), Some(0.0));
468        assert_eq!(shatter_progress(&c, 1.5), Some(1.0));
469        assert_eq!(
470            shatter_progress(&c, 100.0),
471            Some(1.0),
472            "hold must never converge back to None, unlike out/in"
473        );
474    }
475}
476
477fn cubic_bezier_ease(t: f64, x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
478    let t_curve = find_bezier_t_for_x(t, x1, x2);
479    bezier_component(t_curve, y1, y2)
480}
481
482fn bezier_component(t: f64, p1: f64, p2: f64) -> f64 {
483    let t2 = t * t;
484    let t3 = t2 * t;
485    let mt = 1.0 - t;
486    let mt2 = mt * mt;
487    3.0 * mt2 * t * p1 + 3.0 * mt * t2 * p2 + t3
488}
489
490fn bezier_component_derivative(t: f64, p1: f64, p2: f64) -> f64 {
491    let mt = 1.0 - t;
492    3.0 * mt * mt * p1 + 6.0 * mt * t * (p2 - p1) + 3.0 * t * t * (1.0 - p2)
493}
494
495fn find_bezier_t_for_x(x: f64, x1: f64, x2: f64) -> f64 {
496    let mut t = x;
497    for _ in 0..8 {
498        let current_x = bezier_component(t, x1, x2);
499        let dx = bezier_component_derivative(t, x1, x2);
500        if dx.abs() < 1e-10 {
501            break;
502        }
503        t -= (current_x - x) / dx;
504        t = t.clamp(0.0, 1.0);
505    }
506    t
507}
508
509fn bounce_ease_out(t: f64) -> f64 {
510    let n1 = 7.5625;
511    let d1 = 2.75;
512    if t < 1.0 / d1 {
513        n1 * t * t
514    } else if t < 2.0 / d1 {
515        let t = t - 1.5 / d1;
516        n1 * t * t + 0.75
517    } else if t < 2.5 / d1 {
518        let t = t - 2.25 / d1;
519        n1 * t * t + 0.9375
520    } else {
521        let t = t - 2.625 / d1;
522        n1 * t * t + 0.984375
523    }
524}
525
526fn ease_in_cubic(t: f64) -> f64 {
527    t * t * t
528}
529
530fn ease_out_cubic(t: f64) -> f64 {
531    1.0 - (1.0 - t).powi(3)
532}
533
534fn ease_in_out_cubic(t: f64) -> f64 {
535    if t < 0.5 {
536        4.0 * t * t * t
537    } else {
538        1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
539    }
540}
541
542pub const DEFAULT_SPRING_REST_THRESHOLD: f64 = 0.005;
543
544pub const MAX_SPRING_SEARCH_SECONDS: f64 = 30.0;
545
546thread_local! {
547    static SPRING_SETTLE_TIME_CACHE: std::cell::RefCell<std::collections::HashMap<(u64, u64, u64, u64), f64>> =
548        std::cell::RefCell::new(std::collections::HashMap::new());
549}
550
551fn spring_settle_time_cached(damping: f64, stiffness: f64, mass: f64, threshold: f64) -> f64 {
552    let key = (
553        damping.to_bits(),
554        stiffness.to_bits(),
555        mass.to_bits(),
556        threshold.to_bits(),
557    );
558    SPRING_SETTLE_TIME_CACHE.with(|cache| {
559        if let Some(&cached) = cache.borrow().get(&key) {
560            return cached;
561        }
562        let settled = spring_settle_time(
563            damping,
564            stiffness,
565            mass,
566            threshold,
567            MAX_SPRING_SEARCH_SECONDS,
568        );
569        cache.borrow_mut().insert(key, settled);
570        settled
571    })
572}
573
574pub fn spring_value(t: f64, config: &SpringConfig) -> f64 {
575    let damping = config.damping.max(0.0);
576    let stiffness = config.stiffness.max(1e-6);
577    let mass = config.mass.max(1e-6);
578
579    match config.duration {
580        Some(duration) if duration > 0.0 => {
581            let threshold = spring_rest_threshold(config);
582            let natural_rest = spring_settle_time_cached(damping, stiffness, mass, threshold);
583            if natural_rest < 1e-9 {
584                spring_value_raw(t, damping, stiffness, mass)
585            } else {
586                let time_scale = natural_rest / duration;
587                spring_value_raw(t * time_scale, damping, stiffness, mass)
588            }
589        }
590        _ => spring_value_raw(t, damping, stiffness, mass),
591    }
592}
593
594fn spring_value_raw(t: f64, damping: f64, stiffness: f64, mass: f64) -> f64 {
595    let omega = (stiffness / mass).sqrt();
596    let zeta = damping / (2.0 * (stiffness * mass).sqrt());
597
598    if zeta < 1.0 {
599        let omega_d = omega * (1.0 - zeta * zeta).sqrt();
600        let decay = (-zeta * omega * t).exp();
601        1.0 - decay * ((omega_d * t).sin() * (zeta * omega / omega_d) + (omega_d * t).cos())
602    } else if (zeta - 1.0).abs() < 1e-6 {
603        let decay = (-omega * t).exp();
604        1.0 - decay * (1.0 + omega * t)
605    } else {
606        let s1 = -omega * (zeta - (zeta * zeta - 1.0).sqrt());
607        let s2 = -omega * (zeta + (zeta * zeta - 1.0).sqrt());
608        let c2 = -s1 / (s2 - s1);
609        let c1 = 1.0 - c2;
610        1.0 - (c1 * (s1 * t).exp() + c2 * (s2 * t).exp())
611    }
612}
613
614const SPRING_SETTLE_MIN_SAMPLES: usize = 2_000;
615const SPRING_SETTLE_MAX_SAMPLES: usize = 20_000;
616const SPRING_SETTLE_SAMPLES_PER_PERIOD: f64 = 48.0;
617
618fn spring_settle_time(damping: f64, stiffness: f64, mass: f64, threshold: f64, max_t: f64) -> f64 {
619    let threshold = threshold.max(1e-9);
620    let omega = (stiffness / mass).sqrt();
621    let period = if omega > 1e-9 {
622        std::f64::consts::TAU / omega
623    } else {
624        max_t
625    };
626    let desired_steps = (max_t / (period / SPRING_SETTLE_SAMPLES_PER_PERIOD)).ceil() as usize;
627    let steps = desired_steps.clamp(SPRING_SETTLE_MIN_SAMPLES, SPRING_SETTLE_MAX_SAMPLES);
628    let dt = max_t / steps as f64;
629
630    let mut last_exceed_idx: usize = 0;
631    for i in 0..=steps {
632        let t = i as f64 * dt;
633        if (spring_value_raw(t, damping, stiffness, mass) - 1.0).abs() > threshold {
634            last_exceed_idx = i;
635        }
636    }
637
638    if last_exceed_idx >= steps {
639        return max_t;
640    }
641
642    let mut lo = last_exceed_idx as f64 * dt;
643    let mut hi = (lo + dt).min(max_t);
644    for _ in 0..40 {
645        let mid = 0.5 * (lo + hi);
646        if (spring_value_raw(mid, damping, stiffness, mass) - 1.0).abs() > threshold {
647            lo = mid;
648        } else {
649            hi = mid;
650        }
651    }
652    hi
653}
654
655fn spring_rest_threshold(config: &SpringConfig) -> f64 {
656    config
657        .rest_threshold
658        .unwrap_or(DEFAULT_SPRING_REST_THRESHOLD)
659        .max(1e-9)
660}
661
662pub fn spring_rest_time(config: &SpringConfig) -> f64 {
663    match config.duration {
664        Some(d) if d > 0.0 => d,
665        _ => {
666            let damping = config.damping.max(0.0);
667            let stiffness = config.stiffness.max(1e-6);
668            let mass = config.mass.max(1e-6);
669            let threshold = spring_rest_threshold(config);
670            spring_settle_time_cached(damping, stiffness, mass, threshold)
671        }
672    }
673}
674
675#[derive(Debug, Clone)]
676pub struct AnimatedProperties {
677    pub opacity: f32,
678    pub translate_x: f32,
679    pub translate_y: f32,
680    pub scale_x: f32,
681    pub scale_y: f32,
682    pub rotation: f32,
683    pub blur: f32,
684    pub blur_x: f32,
685    pub blur_y: f32,
686    pub letter_spacing: f32,
687    pub draw_start: f32,
688    pub visible_chars: i32,
689    pub visible_chars_progress: f32,
690    pub color: Option<String>,
691    pub border_radius: f32,
692    pub font_size: f32,
693    pub width: f32,
694    pub height: f32,
695    pub gap: f32,
696    pub padding: f32,
697    pub stroke_width: f32,
698    pub shadow_blur: f32,
699    pub glow_radius: f32,
700    pub glow_intensity: f32,
701    pub rotate_x: f32,
702    pub rotate_y: f32,
703    pub perspective: f32,
704    pub draw_progress: f32,
705    pub draw_offset: f32,
706    pub motion_progress: f32,
707    pub clip_path_progress: f32,
708    pub char_animation: Option<ResolvedCharAnimation>,
709}
710
711impl Default for AnimatedProperties {
712    fn default() -> Self {
713        Self {
714            opacity: 1.0,
715            translate_x: 0.0,
716            translate_y: 0.0,
717            scale_x: 1.0,
718            scale_y: 1.0,
719            rotation: 0.0,
720            blur: -1.0,
721            blur_x: -1.0,
722            letter_spacing: f32::NAN,
723            draw_start: -1.0,
724            blur_y: -1.0,
725            visible_chars: -1,
726            visible_chars_progress: -1.0,
727            color: None,
728            border_radius: -1.0,
729            font_size: -1.0,
730            width: -1.0,
731            height: -1.0,
732            gap: -1.0,
733            padding: -1.0,
734            stroke_width: -1.0,
735            shadow_blur: -1.0,
736            glow_radius: -1.0,
737            glow_intensity: -1.0,
738            rotate_x: 0.0,
739            rotate_y: 0.0,
740            perspective: -1.0,
741            draw_progress: -1.0,
742            draw_offset: 0.0,
743            motion_progress: -1.0,
744            clip_path_progress: -1.0,
745            char_animation: None,
746        }
747    }
748}
749
750impl AnimatedProperties {
751    pub fn merge(&mut self, other: &AnimatedProperties) {
752        if (other.opacity - 1.0).abs() > 0.001 {
753            self.opacity *= other.opacity;
754        }
755        if other.translate_x.abs() > 0.001 {
756            self.translate_x += other.translate_x;
757        }
758        if other.translate_y.abs() > 0.001 {
759            self.translate_y += other.translate_y;
760        }
761        if (other.scale_x - 1.0).abs() > 0.001 {
762            self.scale_x *= other.scale_x;
763        }
764        if (other.scale_y - 1.0).abs() > 0.001 {
765            self.scale_y *= other.scale_y;
766        }
767        if other.rotation.abs() > 0.01 {
768            self.rotation += other.rotation;
769        }
770        if other.blur >= 0.0 {
771            self.blur = other.blur;
772        }
773        if other.blur_x >= 0.0 {
774            self.blur_x = other.blur_x;
775        }
776
777        if other.letter_spacing.is_finite() {
778            self.letter_spacing = other.letter_spacing;
779        }
780        if other.draw_start >= 0.0 {
781            self.draw_start = other.draw_start;
782        }
783        if other.blur_y >= 0.0 {
784            self.blur_y = other.blur_y;
785        }
786        if other.visible_chars >= 0 {
787            self.visible_chars = other.visible_chars;
788        }
789        if other.visible_chars_progress >= 0.0 {
790            self.visible_chars_progress = other.visible_chars_progress;
791        }
792        if other.color.is_some() {
793            self.color = other.color.clone();
794        }
795        if other.border_radius >= 0.0 {
796            self.border_radius = other.border_radius;
797        }
798        if other.font_size >= 0.0 {
799            self.font_size = other.font_size;
800        }
801        if other.width >= 0.0 {
802            self.width = other.width;
803        }
804        if other.height >= 0.0 {
805            self.height = other.height;
806        }
807        if other.gap >= 0.0 {
808            self.gap = other.gap;
809        }
810        if other.padding >= 0.0 {
811            self.padding = other.padding;
812        }
813        if other.stroke_width >= 0.0 {
814            self.stroke_width = other.stroke_width;
815        }
816        if other.shadow_blur >= 0.0 {
817            self.shadow_blur = other.shadow_blur;
818        }
819        if other.glow_radius >= 0.0 {
820            self.glow_radius = other.glow_radius;
821        }
822        if other.glow_intensity >= 0.0 {
823            self.glow_intensity = other.glow_intensity;
824        }
825        if other.rotate_x.abs() > 0.01 {
826            self.rotate_x += other.rotate_x;
827        }
828        if other.rotate_y.abs() > 0.01 {
829            self.rotate_y += other.rotate_y;
830        }
831        if other.perspective >= 0.0 {
832            self.perspective = other.perspective;
833        }
834        if other.draw_progress >= 0.0 {
835            self.draw_progress = other.draw_progress;
836        }
837        if other.draw_offset.abs() > 0.0005 {
838            self.draw_offset += other.draw_offset;
839        }
840        if other.motion_progress >= 0.0 {
841            self.motion_progress = other.motion_progress;
842        }
843        if other.clip_path_progress >= 0.0 {
844            self.clip_path_progress = other.clip_path_progress;
845        }
846        if other.char_animation.is_some() {
847            self.char_animation = other.char_animation.clone();
848        }
849    }
850}
851
852pub fn resolve_props_for_effects(
853    effects: &[AnimationEffect],
854    time: f64,
855    scene_duration: f64,
856) -> AnimatedProperties {
857    let mut props = AnimatedProperties::default();
858    if effects.is_empty() {
859        return props;
860    }
861    let extracted = extract_effects(effects);
862
863    for (preset, preset_config) in &extracted.presets {
864        let p = resolve_animations(&[], Some(preset), Some(preset_config), time, scene_duration);
865        props.merge(&p);
866    }
867    if !extracted.keyframe_animations.is_empty() {
868        let loop_cfg = PresetConfig {
869            repeat: extracted.keyframes_loop,
870            ..Default::default()
871        };
872        let kp = resolve_animations(
873            &extracted.keyframe_animations,
874            None,
875            Some(&loop_cfg),
876            time,
877            scene_duration,
878        );
879        props.merge(&kp);
880    }
881    if !extracted.wiggles.is_empty() {
882        let wiggles: Vec<_> = extracted.wiggles.iter().copied().cloned().collect();
883        apply_wiggles(&mut props, &wiggles, time);
884    }
885    if !extracted.orbits.is_empty() {
886        let orbits: Vec<_> = extracted.orbits.iter().copied().cloned().collect();
887        apply_orbits(&mut props, &orbits, time);
888    }
889    if !extracted.motion_paths.is_empty() {
890        let motion_paths: Vec<_> = extracted.motion_paths.iter().copied().cloned().collect();
891        apply_motion_paths(&mut props, &motion_paths, time);
892    }
893    if extracted.char_animation.is_some() {
894        props.char_animation = extracted.char_animation;
895    }
896    props
897}
898
899pub fn resolve_animations(
900    animations: &[Animation],
901    preset: Option<&AnimationPreset>,
902    preset_config: Option<&PresetConfig>,
903    time: f64,
904    scene_duration: f64,
905) -> AnimatedProperties {
906    let mut props = AnimatedProperties::default();
907
908    let config = preset_config.cloned().unwrap_or_default();
909    let should_loop = config.repeat;
910
911    let preset_animations = preset.map(|p| expand_preset(p, &config, scene_duration));
912
913    let all_animations: Vec<&Animation> = preset_animations
914        .as_ref()
915        .map(|pa| pa.iter().collect::<Vec<_>>())
916        .unwrap_or_default()
917        .into_iter()
918        .chain(animations.iter())
919        .collect();
920
921    for anim in all_animations {
922        let anim_time = if should_loop {
923            cycle_time(anim, time, &config)
924        } else {
925            time
926        };
927        let resolved = resolve_animation_value_full(anim, anim_time);
928        match resolved {
929            ResolvedValue::Number(value) => apply_property(&mut props, &anim.property, value),
930            ResolvedValue::Color(color) => {
931                if anim.property == "color" {
932                    props.color = Some(color);
933                }
934            }
935        }
936    }
937
938    props
939}
940
941fn cycle_time(anim: &Animation, time: f64, config: &PresetConfig) -> f64 {
942    let keyframes = &anim.keyframes;
943    if keyframes.len() < 2 {
944        return time;
945    }
946    let start = keyframes.first().unwrap().time;
947    let end = keyframes.last().unwrap().time;
948    let duration = end - start;
949    if duration < 1e-9 || time < start {
950        return time;
951    }
952
953    let period = duration + config.repeat_delay.max(0.0);
954    if period < 1e-9 {
955        return time;
956    }
957
958    let elapsed = time - start;
959    let mut cycle_index = (elapsed / period).floor() as i64;
960    if cycle_index < 0 {
961        cycle_index = 0;
962    }
963
964    if let Some(count) = config.repeat_count {
965        let last_index = (count.max(1) - 1) as i64;
966        if cycle_index > last_index {
967            cycle_index = last_index;
968        }
969    }
970
971    let within_cycle = (elapsed - cycle_index as f64 * period)
972        .min(duration)
973        .max(0.0);
974
975    let backward = config.yoyo && cycle_index % 2 == 1;
976    if backward {
977        end - within_cycle
978    } else {
979        start + within_cycle
980    }
981}
982
983enum ResolvedValue {
984    Number(f64),
985    Color(String),
986}
987
988pub fn resolve_keyframe_track(anim: &Animation, time: f64) -> KeyframeValue {
989    match resolve_animation_value_full(anim, time) {
990        ResolvedValue::Number(n) => KeyframeValue::Number(n),
991        ResolvedValue::Color(c) => KeyframeValue::Color(c),
992    }
993}
994
995fn resolve_animation_value_full(anim: &Animation, time: f64) -> ResolvedValue {
996    let keyframes = &anim.keyframes;
997    if keyframes.is_empty() {
998        return ResolvedValue::Number(0.0);
999    }
1000    if keyframes.len() == 1 {
1001        return match &keyframes[0].value {
1002            KeyframeValue::Color(c) => ResolvedValue::Color(c.clone()),
1003            KeyframeValue::Number(n) => ResolvedValue::Number(*n),
1004        };
1005    }
1006
1007    if time <= keyframes[0].time {
1008        return match &keyframes[0].value {
1009            KeyframeValue::Color(c) => ResolvedValue::Color(c.clone()),
1010            KeyframeValue::Number(n) => ResolvedValue::Number(*n),
1011        };
1012    }
1013    if time >= keyframes.last().unwrap().time {
1014        return match &keyframes.last().unwrap().value {
1015            KeyframeValue::Color(c) => ResolvedValue::Color(c.clone()),
1016            KeyframeValue::Number(n) => ResolvedValue::Number(*n),
1017        };
1018    }
1019
1020    for i in 0..keyframes.len() - 1 {
1021        let kf0 = &keyframes[i];
1022        let kf1 = &keyframes[i + 1];
1023
1024        if time >= kf0.time && time <= kf1.time {
1025            let segment_duration = kf1.time - kf0.time;
1026            if segment_duration < 1e-9 {
1027                return match &kf1.value {
1028                    KeyframeValue::Color(c) => ResolvedValue::Color(c.clone()),
1029                    KeyframeValue::Number(n) => ResolvedValue::Number(*n),
1030                };
1031            }
1032
1033            let local_t = (time - kf0.time) / segment_duration;
1034
1035            let segment_easing = kf0.easing.as_ref().unwrap_or(&anim.easing);
1036
1037            let progress = match segment_easing {
1038                EasingType::Spring => {
1039                    let spring_config = anim.spring.clone().unwrap_or_default();
1040                    spring_value(local_t * segment_duration, &spring_config)
1041                }
1042                other => ease(local_t, other),
1043            };
1044
1045            if let (KeyframeValue::Color(c0), KeyframeValue::Color(c1)) = (&kf0.value, &kf1.value) {
1046                return ResolvedValue::Color(lerp_color(c0, c1, progress));
1047            }
1048
1049            let v0 = kf0.value.as_f64();
1050            let v1 = kf1.value.as_f64();
1051            return ResolvedValue::Number(v0 + (v1 - v0) * progress);
1052        }
1053    }
1054
1055    match &keyframes.last().unwrap().value {
1056        KeyframeValue::Color(c) => ResolvedValue::Color(c.clone()),
1057        KeyframeValue::Number(n) => ResolvedValue::Number(*n),
1058    }
1059}
1060
1061fn parse_hex_components(hex: &str) -> (f64, f64, f64, f64) {
1062    let (r, g, b, a) = super::renderer::parse_hex_color(hex);
1063    (r as f64, g as f64, b as f64, a as f64)
1064}
1065
1066pub fn lerp_color(c1: &str, c2: &str, t: f64) -> String {
1067    let (r1, g1, b1, a1) = parse_hex_components(c1);
1068    let (r2, g2, b2, a2) = parse_hex_components(c2);
1069    let r = (r1 + (r2 - r1) * t).clamp(0.0, 255.0) as u8;
1070    let g = (g1 + (g2 - g1) * t).clamp(0.0, 255.0) as u8;
1071    let b = (b1 + (b2 - b1) * t).clamp(0.0, 255.0) as u8;
1072    let a = (a1 + (a2 - a1) * t).clamp(0.0, 255.0) as u8;
1073    if a == 255 {
1074        format!("#{:02X}{:02X}{:02X}", r, g, b)
1075    } else {
1076        format!("#{:02X}{:02X}{:02X}{:02X}", r, g, b, a)
1077    }
1078}
1079
1080fn apply_property(props: &mut AnimatedProperties, property: &str, value: f64) {
1081    match property {
1082        "opacity" => props.opacity = value as f32,
1083        "position.x" | "translate_x" => props.translate_x = value as f32,
1084        "position.y" | "translate_y" => props.translate_y = value as f32,
1085        "scale" => {
1086            props.scale_x = value as f32;
1087            props.scale_y = value as f32;
1088        }
1089        "scale.x" => props.scale_x = value as f32,
1090        "scale.y" => props.scale_y = value as f32,
1091        "rotation" => props.rotation = value as f32,
1092        "blur" => props.blur = value as f32,
1093        "blur_x" => props.blur_x = value as f32,
1094        "letter_spacing" => props.letter_spacing = value as f32,
1095        "draw_start" => props.draw_start = value as f32,
1096        "blur_y" => props.blur_y = value as f32,
1097        "visible_chars" => props.visible_chars = value as i32,
1098        "visible_chars_progress" => props.visible_chars_progress = value as f32,
1099        "border_radius" => props.border_radius = value as f32,
1100        "font_size" => props.font_size = value as f32,
1101        "width" => props.width = value as f32,
1102        "height" => props.height = value as f32,
1103        "gap" => props.gap = value as f32,
1104        "padding" => props.padding = value as f32,
1105        "stroke_width" => props.stroke_width = value as f32,
1106        "shadow_blur" => props.shadow_blur = value as f32,
1107        "glow_radius" => props.glow_radius = value as f32,
1108        "glow_intensity" => props.glow_intensity = value as f32,
1109        "rotate_x" => props.rotate_x = value as f32,
1110        "rotate_y" => props.rotate_y = value as f32,
1111        "perspective" => props.perspective = value as f32,
1112        "draw_progress" => props.draw_progress = value as f32,
1113        "draw_offset" => props.draw_offset = value as f32,
1114        "motion_progress" => props.motion_progress = value as f32,
1115        "clip_path_progress" => props.clip_path_progress = value as f32,
1116        _ => {}
1117    }
1118}
1119
1120fn simplex_noise_1d(x: f64, seed: u64) -> f64 {
1121    use std::f64::consts::TAU;
1122    let s = seed as f64;
1123
1124    (x * TAU + s * 0.1234).sin() * 0.6
1125        + (x * TAU * 1.7 + s * 0.5678).sin() * 0.3
1126        + (x * TAU * 2.9 + s * 0.9012).sin() * 0.1
1127}
1128
1129fn simplex_noise_1d_ext(x: f64, seed: u64, octaves: u32) -> f64 {
1130    use std::f64::consts::TAU;
1131    let s = seed as f64;
1132    let mut value = 0.0;
1133    let mut amplitude = 0.5;
1134    let mut total_amplitude = 0.0;
1135    for i in 0..octaves {
1136        let freq = 1.0 + i as f64 * 1.3;
1137        let phase_offset = s * (0.1234 + i as f64 * 0.4444);
1138        value += (x * TAU * freq + phase_offset).sin() * amplitude;
1139        total_amplitude += amplitude;
1140        amplitude *= 0.5;
1141    }
1142    if total_amplitude > 0.0 {
1143        value / total_amplitude
1144    } else {
1145        0.0
1146    }
1147}
1148
1149pub fn apply_wiggles(props: &mut AnimatedProperties, wiggles: &[WiggleConfig], time: f64) {
1150    for wiggle in wiggles {
1151        let has_extras = wiggle.octaves.is_some()
1152            || wiggle.phase.is_some()
1153            || wiggle.decay.is_some()
1154            || wiggle.easing.is_some();
1155
1156        let phase = wiggle.phase.unwrap_or(0.0);
1157        let input = time * wiggle.frequency + phase;
1158
1159        let is_sine = wiggle.mode.as_deref() == Some("sine");
1160
1161        let mut noise_val = if is_sine {
1162            input.sin()
1163        } else if has_extras {
1164            let octaves = wiggle.octaves.unwrap_or(3);
1165            simplex_noise_1d_ext(input, wiggle.seed, octaves)
1166        } else {
1167            simplex_noise_1d(input, wiggle.seed)
1168        };
1169
1170        if let Some(ref easing) = wiggle.easing {
1171            let normalized = (noise_val + 1.0) * 0.5;
1172            let eased = ease(normalized, easing);
1173            noise_val = eased * 2.0 - 1.0;
1174        }
1175
1176        let mut amp = wiggle.amplitude;
1177
1178        if let Some(decay) = wiggle.decay {
1179            amp *= (-decay * time).exp();
1180        }
1181
1182        let offset = amp * noise_val;
1183        apply_property(
1184            props,
1185            &wiggle.property,
1186            get_property_value(props, &wiggle.property) + offset,
1187        );
1188    }
1189}
1190
1191pub fn apply_orbits(props: &mut AnimatedProperties, orbits: &[OrbitConfig], time: f64) {
1192    use std::f64::consts::{PI, TAU};
1193
1194    for orbit in orbits {
1195        let angle_offset = orbit.start_angle * PI / 180.0;
1196        let phase_offset = orbit.phase * TAU;
1197        let tilt_rad = orbit.tilt * PI / 180.0;
1198
1199        let theta = TAU * orbit.speed * time + angle_offset + phase_offset;
1200
1201        let raw_x = orbit.radius_x * theta.cos();
1202        let raw_y = orbit.radius_y * theta.sin();
1203
1204        let x_offset = raw_x;
1205        let y_offset = raw_y * tilt_rad.cos();
1206
1207        props.translate_x += x_offset as f32;
1208        props.translate_y += y_offset as f32;
1209
1210        if orbit.depth > 0.0 {
1211            let depth_sin = theta.sin();
1212            let scale_factor = 1.0 + orbit.depth * depth_sin;
1213            props.scale_x *= scale_factor as f32;
1214            props.scale_y *= scale_factor as f32;
1215        }
1216
1217        if orbit.opacity_depth > 0.0 {
1218            let depth_sin = theta.sin();
1219            let opacity_factor = 1.0 - orbit.opacity_depth * (1.0 - depth_sin) * 0.5;
1220            props.opacity *= opacity_factor as f32;
1221        }
1222    }
1223}
1224
1225pub const MOTION_PATH_MIN_LENGTH: f32 = 1e-3;
1226
1227pub fn motion_path_length(path_data: &str) -> Option<f32> {
1228    let path = skia_safe::Path::from_svg(path_data)?;
1229    if path.count_points() == 0 {
1230        return None;
1231    }
1232    let mut measure = skia_safe::PathMeasure::new(&path, false, None);
1233    Some(measure.length())
1234}
1235
1236fn motion_path_progress(cfg: &MotionPathConfig, time: f64) -> f64 {
1237    let elapsed = time - cfg.delay;
1238    if elapsed <= 0.0 {
1239        return 0.0;
1240    }
1241    let raw = safe_div(elapsed, cfg.duration, 1.0);
1242    let progress = if cfg.repeat {
1243        raw.rem_euclid(1.0)
1244    } else {
1245        raw.clamp(0.0, 1.0)
1246    };
1247    ease(progress, &cfg.easing)
1248}
1249
1250struct MotionPathSample {
1251    dx: f32,
1252    dy: f32,
1253    angle_deg: f32,
1254}
1255
1256fn motion_path_sample(cfg: &MotionPathConfig, time: f64) -> MotionPathSample {
1257    let zero = MotionPathSample {
1258        dx: 0.0,
1259        dy: 0.0,
1260        angle_deg: 0.0,
1261    };
1262    let Some(path) = skia_safe::Path::from_svg(&cfg.path) else {
1263        return zero;
1264    };
1265    if path.count_points() == 0 {
1266        return zero;
1267    }
1268
1269    let mut measure = skia_safe::PathMeasure::new(&path, false, None);
1270    let length = measure.length();
1271
1272    if length <= MOTION_PATH_MIN_LENGTH {
1273        let (x, y) = path.points().first().map_or((0.0, 0.0), |p| (p.x, p.y));
1274        return MotionPathSample {
1275            dx: x,
1276            dy: y,
1277            angle_deg: 0.0,
1278        };
1279    }
1280
1281    let progress = motion_path_progress(cfg, time) as f32;
1282    let distance = (length * progress).clamp(0.0, length);
1283
1284    match measure.pos_tan(distance) {
1285        Some((pos, tangent)) => {
1286            let angle_deg = if cfg.orient {
1287                tangent.y.atan2(tangent.x).to_degrees() + cfg.orient_offset as f32
1288            } else {
1289                0.0
1290            };
1291            MotionPathSample {
1292                dx: pos.x,
1293                dy: pos.y,
1294                angle_deg,
1295            }
1296        }
1297        None => {
1298            let (x, y) = path.points().first().map_or((0.0, 0.0), |p| (p.x, p.y));
1299            MotionPathSample {
1300                dx: x,
1301                dy: y,
1302                angle_deg: 0.0,
1303            }
1304        }
1305    }
1306}
1307
1308pub fn apply_motion_paths(props: &mut AnimatedProperties, paths: &[MotionPathConfig], time: f64) {
1309    for cfg in paths {
1310        let sample = motion_path_sample(cfg, time);
1311        props.translate_x += sample.dx;
1312        props.translate_y += sample.dy;
1313        props.rotation += sample.angle_deg;
1314    }
1315}
1316
1317fn get_property_value(props: &AnimatedProperties, property: &str) -> f64 {
1318    match property {
1319        "opacity" => props.opacity as f64,
1320        "position.x" | "translate_x" => props.translate_x as f64,
1321        "position.y" | "translate_y" => props.translate_y as f64,
1322        "scale" => props.scale_x as f64,
1323        "scale.x" => props.scale_x as f64,
1324        "scale.y" => props.scale_y as f64,
1325        "rotation" => props.rotation as f64,
1326        "blur" => props.blur as f64,
1327        "blur_x" => props.blur_x as f64,
1328        "letter_spacing" => props.letter_spacing as f64,
1329        "draw_start" => props.draw_start as f64,
1330        "blur_y" => props.blur_y as f64,
1331        "border_radius" => props.border_radius as f64,
1332        "font_size" => props.font_size as f64,
1333        "width" => props.width as f64,
1334        "height" => props.height as f64,
1335        "gap" => props.gap as f64,
1336        "padding" => props.padding as f64,
1337        "stroke_width" => props.stroke_width as f64,
1338        "shadow_blur" => props.shadow_blur as f64,
1339        "glow_radius" => props.glow_radius as f64,
1340        "glow_intensity" => props.glow_intensity as f64,
1341        "rotate_x" => props.rotate_x as f64,
1342        "rotate_y" => props.rotate_y as f64,
1343        "perspective" => props.perspective as f64,
1344        "draw_progress" => props.draw_progress as f64,
1345        "draw_offset" => props.draw_offset as f64,
1346        "motion_progress" => props.motion_progress as f64,
1347        "clip_path_progress" => props.clip_path_progress as f64,
1348        _ => 0.0,
1349    }
1350}
1351
1352fn is_motion_property(property: &str) -> bool {
1353    matches!(
1354        property,
1355        "position.x"
1356            | "position.y"
1357            | "translate_x"
1358            | "translate_y"
1359            | "scale"
1360            | "scale.x"
1361            | "scale.y"
1362            | "rotation"
1363            | "rotate_x"
1364            | "rotate_y"
1365    )
1366}
1367
1368fn apply_spring_to_motion(animations: &mut [Animation], spring: &SpringConfig) {
1369    for anim in animations.iter_mut() {
1370        if !is_motion_property(&anim.property) || anim.keyframes.len() < 2 {
1371            continue;
1372        }
1373        if anim.keyframes.len() > 2 {
1374            let first = anim.keyframes.first().unwrap().clone();
1375            let last = anim.keyframes.last().unwrap().clone();
1376            if (first.value.as_f64() - last.value.as_f64()).abs() < 1e-9 {
1377                continue;
1378            }
1379            anim.keyframes = vec![first, last];
1380        }
1381        anim.easing = EasingType::Spring;
1382        anim.spring = Some(spring.clone());
1383    }
1384}
1385
1386fn expand_preset(
1387    preset: &AnimationPreset,
1388    config: &PresetConfig,
1389    _scene_duration: f64,
1390) -> Vec<Animation> {
1391    let mut animations = expand_preset_inner(preset, config);
1392    if let Some(spring) = &config.spring {
1393        apply_spring_to_motion(&mut animations, spring);
1394    }
1395    animations
1396}
1397
1398fn expand_preset_inner(preset: &AnimationPreset, config: &PresetConfig) -> Vec<Animation> {
1399    let delay = config.delay;
1400    let dur = config.duration;
1401    let end = delay + dur;
1402
1403    match preset {
1404        AnimationPreset::FadeIn => vec![kf_anim(
1405            "opacity",
1406            delay,
1407            0.0,
1408            end,
1409            1.0,
1410            EasingType::EaseOut,
1411        )],
1412        AnimationPreset::FadeInUp => vec![
1413            kf_anim("opacity", delay, 0.0, end, 1.0, EasingType::EaseOut),
1414            kf_anim(
1415                "position.y",
1416                delay,
1417                60.0,
1418                end,
1419                0.0,
1420                EasingType::EaseOutCubic,
1421            ),
1422        ],
1423        AnimationPreset::FadeInDown => vec![
1424            kf_anim("opacity", delay, 0.0, end, 1.0, EasingType::EaseOut),
1425            kf_anim(
1426                "position.y",
1427                delay,
1428                -60.0,
1429                end,
1430                0.0,
1431                EasingType::EaseOutCubic,
1432            ),
1433        ],
1434        AnimationPreset::FadeInLeft => vec![
1435            kf_anim("opacity", delay, 0.0, end, 1.0, EasingType::EaseOut),
1436            kf_anim(
1437                "position.x",
1438                delay,
1439                -60.0,
1440                end,
1441                0.0,
1442                EasingType::EaseOutCubic,
1443            ),
1444        ],
1445        AnimationPreset::FadeInRight => vec![
1446            kf_anim("opacity", delay, 0.0, end, 1.0, EasingType::EaseOut),
1447            kf_anim(
1448                "position.x",
1449                delay,
1450                60.0,
1451                end,
1452                0.0,
1453                EasingType::EaseOutCubic,
1454            ),
1455        ],
1456        AnimationPreset::SlideInLeft => vec![
1457            kf_anim(
1458                "opacity",
1459                delay,
1460                0.0,
1461                delay + dur * 0.3,
1462                1.0,
1463                EasingType::EaseOut,
1464            ),
1465            kf_anim(
1466                "position.x",
1467                delay,
1468                -200.0,
1469                end,
1470                0.0,
1471                EasingType::EaseOutCubic,
1472            ),
1473        ],
1474        AnimationPreset::SlideInRight => vec![
1475            kf_anim(
1476                "opacity",
1477                delay,
1478                0.0,
1479                delay + dur * 0.3,
1480                1.0,
1481                EasingType::EaseOut,
1482            ),
1483            kf_anim(
1484                "position.x",
1485                delay,
1486                200.0,
1487                end,
1488                0.0,
1489                EasingType::EaseOutCubic,
1490            ),
1491        ],
1492        AnimationPreset::SlideInUp => vec![
1493            kf_anim(
1494                "opacity",
1495                delay,
1496                0.0,
1497                delay + dur * 0.3,
1498                1.0,
1499                EasingType::EaseOut,
1500            ),
1501            kf_anim(
1502                "position.y",
1503                delay,
1504                200.0,
1505                end,
1506                0.0,
1507                EasingType::EaseOutCubic,
1508            ),
1509        ],
1510        AnimationPreset::SlideInDown => vec![
1511            kf_anim(
1512                "opacity",
1513                delay,
1514                0.0,
1515                delay + dur * 0.3,
1516                1.0,
1517                EasingType::EaseOut,
1518            ),
1519            kf_anim(
1520                "position.y",
1521                delay,
1522                -200.0,
1523                end,
1524                0.0,
1525                EasingType::EaseOutCubic,
1526            ),
1527        ],
1528        AnimationPreset::ScaleIn => {
1529            let overshoot = config.overshoot.unwrap_or(0.08);
1530            vec![
1531                kf_anim(
1532                    "opacity",
1533                    delay,
1534                    0.0,
1535                    delay + dur * 0.3,
1536                    1.0,
1537                    EasingType::EaseOut,
1538                ),
1539                Animation {
1540                    property: "scale".to_string(),
1541                    keyframes: vec![
1542                        kf(delay, 0.0),
1543                        kf(delay + dur * 0.7, 1.0 + overshoot),
1544                        kf(end, 1.0),
1545                    ],
1546                    easing: EasingType::EaseOutCubic,
1547                    spring: None,
1548                },
1549            ]
1550        }
1551        AnimationPreset::BounceIn => vec![
1552            kf_anim(
1553                "opacity",
1554                delay,
1555                0.0,
1556                delay + dur * 0.2,
1557                1.0,
1558                EasingType::EaseOut,
1559            ),
1560            kf_anim_spring("scale", delay, 0.3, end, 1.0),
1561        ],
1562        AnimationPreset::BlurIn => vec![
1563            kf_anim("opacity", delay, 0.0, end, 1.0, EasingType::EaseOut),
1564            kf_anim("blur", delay, 20.0, end, 0.0, EasingType::EaseOutCubic),
1565        ],
1566        AnimationPreset::RotateIn => vec![
1567            kf_anim("opacity", delay, 0.0, end, 1.0, EasingType::EaseOut),
1568            kf_anim("rotation", delay, -90.0, end, 0.0, EasingType::EaseOutCubic),
1569            kf_anim("scale", delay, 0.5, end, 1.0, EasingType::EaseOutCubic),
1570        ],
1571        AnimationPreset::ElasticIn => {
1572            vec![kf_anim_spring_underdamped("scale", delay, 0.0, end, 1.0)]
1573        }
1574        AnimationPreset::PopIn => {
1575            let pulse = 1.0 + config.overshoot.unwrap_or(0.18);
1576            let placed = delay + dur * 0.6;
1577            let peak = delay + dur * 0.8;
1578            vec![
1579                kf_anim(
1580                    "opacity",
1581                    delay,
1582                    0.0,
1583                    delay + dur * 0.25,
1584                    1.0,
1585                    EasingType::EaseOut,
1586                ),
1587                Animation {
1588                    property: "scale".to_string(),
1589                    keyframes: vec![
1590                        Keyframe {
1591                            time: delay,
1592                            value: KeyframeValue::Number(0.0),
1593                            easing: Some(EasingType::EaseOutBack),
1594                        },
1595                        Keyframe {
1596                            time: placed,
1597                            value: KeyframeValue::Number(1.0),
1598                            easing: Some(EasingType::EaseOutQuad),
1599                        },
1600                        Keyframe {
1601                            time: peak,
1602                            value: KeyframeValue::Number(pulse),
1603                            easing: Some(EasingType::EaseOutElastic),
1604                        },
1605                        Keyframe {
1606                            time: end,
1607                            value: KeyframeValue::Number(1.0),
1608                            easing: None,
1609                        },
1610                    ],
1611                    easing: EasingType::EaseOut,
1612                    spring: None,
1613                },
1614            ]
1615        }
1616
1617        AnimationPreset::FadeOut => {
1618            vec![kf_anim("opacity", delay, 1.0, end, 0.0, EasingType::EaseIn)]
1619        }
1620        AnimationPreset::FadeOutUp => vec![
1621            kf_anim("opacity", delay, 1.0, end, 0.0, EasingType::EaseIn),
1622            kf_anim(
1623                "position.y",
1624                delay,
1625                0.0,
1626                end,
1627                -60.0,
1628                EasingType::EaseInCubic,
1629            ),
1630        ],
1631        AnimationPreset::FadeOutDown => vec![
1632            kf_anim("opacity", delay, 1.0, end, 0.0, EasingType::EaseIn),
1633            kf_anim("position.y", delay, 0.0, end, 60.0, EasingType::EaseInCubic),
1634        ],
1635        AnimationPreset::SlideOutLeft => vec![
1636            kf_anim(
1637                "opacity",
1638                delay + dur * 0.7,
1639                1.0,
1640                end,
1641                0.0,
1642                EasingType::EaseIn,
1643            ),
1644            kf_anim(
1645                "position.x",
1646                delay,
1647                0.0,
1648                end,
1649                -200.0,
1650                EasingType::EaseInCubic,
1651            ),
1652        ],
1653        AnimationPreset::SlideOutRight => vec![
1654            kf_anim(
1655                "opacity",
1656                delay + dur * 0.7,
1657                1.0,
1658                end,
1659                0.0,
1660                EasingType::EaseIn,
1661            ),
1662            kf_anim(
1663                "position.x",
1664                delay,
1665                0.0,
1666                end,
1667                200.0,
1668                EasingType::EaseInCubic,
1669            ),
1670        ],
1671        AnimationPreset::SlideOutUp => vec![
1672            kf_anim(
1673                "opacity",
1674                delay + dur * 0.7,
1675                1.0,
1676                end,
1677                0.0,
1678                EasingType::EaseIn,
1679            ),
1680            kf_anim(
1681                "position.y",
1682                delay,
1683                0.0,
1684                end,
1685                -200.0,
1686                EasingType::EaseInCubic,
1687            ),
1688        ],
1689        AnimationPreset::SlideOutDown => vec![
1690            kf_anim(
1691                "opacity",
1692                delay + dur * 0.7,
1693                1.0,
1694                end,
1695                0.0,
1696                EasingType::EaseIn,
1697            ),
1698            kf_anim(
1699                "position.y",
1700                delay,
1701                0.0,
1702                end,
1703                200.0,
1704                EasingType::EaseInCubic,
1705            ),
1706        ],
1707        AnimationPreset::ScaleOut => {
1708            let overshoot = config.overshoot.unwrap_or(0.08);
1709            vec![
1710                kf_anim(
1711                    "opacity",
1712                    delay + dur * 0.7,
1713                    1.0,
1714                    end,
1715                    0.0,
1716                    EasingType::EaseIn,
1717                ),
1718                Animation {
1719                    property: "scale".to_string(),
1720                    keyframes: vec![
1721                        kf(delay, 1.0),
1722                        kf(delay + dur * 0.2, 1.0 + overshoot),
1723                        kf(end, 0.0),
1724                    ],
1725                    easing: EasingType::EaseInCubic,
1726                    spring: None,
1727                },
1728            ]
1729        }
1730        AnimationPreset::BounceOut => vec![
1731            kf_anim(
1732                "opacity",
1733                delay + dur * 0.8,
1734                1.0,
1735                end,
1736                0.0,
1737                EasingType::EaseIn,
1738            ),
1739            kf_anim_spring("scale", delay, 1.0, end, 0.3),
1740        ],
1741        AnimationPreset::BlurOut => vec![
1742            kf_anim("opacity", delay, 1.0, end, 0.0, EasingType::EaseIn),
1743            kf_anim("blur", delay, 0.0, end, 20.0, EasingType::EaseInCubic),
1744        ],
1745        AnimationPreset::RotateOut => vec![
1746            kf_anim("opacity", delay, 1.0, end, 0.0, EasingType::EaseIn),
1747            kf_anim("rotation", delay, 0.0, end, 90.0, EasingType::EaseInCubic),
1748            kf_anim("scale", delay, 1.0, end, 0.5, EasingType::EaseInCubic),
1749        ],
1750
1751        AnimationPreset::Pulse => vec![kf_anim_3kf_over(
1752            "scale",
1753            delay,
1754            end,
1755            0.95,
1756            1.05,
1757            0.95,
1758            EasingType::EaseInOut,
1759        )],
1760        AnimationPreset::Float => vec![kf_anim_3kf_over(
1761            "position.y",
1762            delay,
1763            end,
1764            0.0,
1765            -10.0,
1766            0.0,
1767            EasingType::EaseInOut,
1768        )],
1769        AnimationPreset::Shake => vec![kf_anim_4kf_over(
1770            "position.x",
1771            delay,
1772            end,
1773            0.0,
1774            10.0,
1775            -10.0,
1776            0.0,
1777            EasingType::EaseInOut,
1778        )],
1779        AnimationPreset::Spin => vec![kf_anim(
1780            "rotation",
1781            delay,
1782            0.0,
1783            end,
1784            360.0,
1785            EasingType::Linear,
1786        )],
1787
1788        AnimationPreset::FlipInX => vec![
1789            kf_anim(
1790                "opacity",
1791                delay,
1792                0.0,
1793                delay + dur * 0.3,
1794                1.0,
1795                EasingType::EaseOut,
1796            ),
1797            kf_anim("rotate_x", delay, 90.0, end, 0.0, EasingType::EaseOutCubic),
1798            kf_anim("perspective", delay, 800.0, end, 800.0, EasingType::Linear),
1799        ],
1800        AnimationPreset::FlipInY => vec![
1801            kf_anim(
1802                "opacity",
1803                delay,
1804                0.0,
1805                delay + dur * 0.3,
1806                1.0,
1807                EasingType::EaseOut,
1808            ),
1809            kf_anim("rotate_y", delay, 90.0, end, 0.0, EasingType::EaseOutCubic),
1810            kf_anim("perspective", delay, 800.0, end, 800.0, EasingType::Linear),
1811        ],
1812        AnimationPreset::FlipOutX => vec![
1813            kf_anim(
1814                "opacity",
1815                delay + dur * 0.7,
1816                1.0,
1817                end,
1818                0.0,
1819                EasingType::EaseIn,
1820            ),
1821            kf_anim("rotate_x", delay, 0.0, end, -90.0, EasingType::EaseInCubic),
1822            kf_anim("perspective", delay, 800.0, end, 800.0, EasingType::Linear),
1823        ],
1824        AnimationPreset::FlipOutY => vec![
1825            kf_anim(
1826                "opacity",
1827                delay + dur * 0.7,
1828                1.0,
1829                end,
1830                0.0,
1831                EasingType::EaseIn,
1832            ),
1833            kf_anim("rotate_y", delay, 0.0, end, -90.0, EasingType::EaseInCubic),
1834            kf_anim("perspective", delay, 800.0, end, 800.0, EasingType::Linear),
1835        ],
1836        AnimationPreset::TiltIn => vec![
1837            kf_anim(
1838                "opacity",
1839                delay,
1840                0.0,
1841                delay + dur * 0.3,
1842                1.0,
1843                EasingType::EaseOut,
1844            ),
1845            kf_anim("rotate_x", delay, 15.0, end, 0.0, EasingType::EaseOutCubic),
1846            kf_anim("rotate_y", delay, -15.0, end, 0.0, EasingType::EaseOutCubic),
1847            kf_anim(
1848                "perspective",
1849                delay,
1850                1000.0,
1851                end,
1852                1000.0,
1853                EasingType::Linear,
1854            ),
1855            kf_anim("scale", delay, 0.9, end, 1.0, EasingType::EaseOutCubic),
1856        ],
1857
1858        AnimationPreset::Float3d => {
1859            let amp = config.amplitude.unwrap_or(12.0);
1860            let tilt = amp / 12.0;
1861            vec![
1862                kf_anim_3kf_over(
1863                    "position.y",
1864                    delay,
1865                    end,
1866                    0.0,
1867                    -amp,
1868                    0.0,
1869                    EasingType::EaseInOut,
1870                ),
1871                kf_anim_3kf_over(
1872                    "rotate_x",
1873                    delay,
1874                    end,
1875                    0.0,
1876                    5.0 * tilt,
1877                    0.0,
1878                    EasingType::EaseInOut,
1879                ),
1880                kf_anim_3kf_over(
1881                    "rotate_y",
1882                    delay,
1883                    end,
1884                    0.0,
1885                    -8.0 * tilt,
1886                    0.0,
1887                    EasingType::EaseInOut,
1888                ),
1889                kf_anim(
1890                    "perspective",
1891                    delay,
1892                    1000.0,
1893                    end,
1894                    1000.0,
1895                    EasingType::Linear,
1896                ),
1897            ]
1898        }
1899
1900        AnimationPreset::DrawIn => vec![kf_anim(
1901            "draw_progress",
1902            delay,
1903            0.0,
1904            end,
1905            1.0,
1906            EasingType::EaseInOut,
1907        )],
1908        AnimationPreset::StrokeReveal => vec![
1909            kf_anim("draw_progress", delay, 0.0, end, 1.0, EasingType::EaseOut),
1910            kf_anim(
1911                "opacity",
1912                delay,
1913                0.0,
1914                delay + dur * 0.2,
1915                1.0,
1916                EasingType::EaseOut,
1917            ),
1918        ],
1919        AnimationPreset::Typewriter => vec![kf_anim(
1920            "visible_chars_progress",
1921            delay,
1922            0.0,
1923            end,
1924            1.0,
1925            EasingType::Linear,
1926        )],
1927        AnimationPreset::WipeLeft => vec![
1928            kf_anim(
1929                "opacity",
1930                delay,
1931                0.0,
1932                delay + dur * 0.3,
1933                1.0,
1934                EasingType::EaseOut,
1935            ),
1936            kf_anim("position.x", delay, -200.0, end, 0.0, EasingType::EaseInOut),
1937        ],
1938        AnimationPreset::WipeRight => vec![
1939            kf_anim(
1940                "opacity",
1941                delay,
1942                0.0,
1943                delay + dur * 0.3,
1944                1.0,
1945                EasingType::EaseOut,
1946            ),
1947            kf_anim("position.x", delay, 200.0, end, 0.0, EasingType::EaseInOut),
1948        ],
1949    }
1950}
1951
1952fn kf(time: f64, value: f64) -> Keyframe {
1953    Keyframe {
1954        time,
1955        value: KeyframeValue::Number(value),
1956        easing: None,
1957    }
1958}
1959
1960fn kf_anim(property: &str, t0: f64, v0: f64, t1: f64, v1: f64, easing: EasingType) -> Animation {
1961    Animation {
1962        property: property.to_string(),
1963        keyframes: vec![kf(t0, v0), kf(t1, v1)],
1964        easing,
1965        spring: None,
1966    }
1967}
1968
1969fn kf_anim_spring(property: &str, t0: f64, v0: f64, t1: f64, v1: f64) -> Animation {
1970    Animation {
1971        property: property.to_string(),
1972        keyframes: vec![kf(t0, v0), kf(t1, v1)],
1973        easing: EasingType::Spring,
1974        spring: Some(SpringConfig {
1975            damping: 12.0,
1976            stiffness: 100.0,
1977            mass: 1.0,
1978            ..Default::default()
1979        }),
1980    }
1981}
1982
1983fn kf_anim_spring_underdamped(property: &str, t0: f64, v0: f64, t1: f64, v1: f64) -> Animation {
1984    Animation {
1985        property: property.to_string(),
1986        keyframes: vec![kf(t0, v0), kf(t1, v1)],
1987        easing: EasingType::Spring,
1988        spring: Some(SpringConfig {
1989            damping: 6.0,
1990            stiffness: 120.0,
1991            mass: 1.0,
1992            ..Default::default()
1993        }),
1994    }
1995}
1996
1997fn kf_anim_3kf_over(
1998    property: &str,
1999    start: f64,
2000    end: f64,
2001    v0: f64,
2002    v1: f64,
2003    v2: f64,
2004    easing: EasingType,
2005) -> Animation {
2006    Animation {
2007        property: property.to_string(),
2008        keyframes: vec![kf(start, v0), kf((start + end) / 2.0, v1), kf(end, v2)],
2009        easing,
2010        spring: None,
2011    }
2012}
2013
2014#[allow(clippy::too_many_arguments)]
2015fn kf_anim_4kf_over(
2016    property: &str,
2017    start: f64,
2018    end: f64,
2019    v0: f64,
2020    v1: f64,
2021    v2: f64,
2022    v3: f64,
2023    easing: EasingType,
2024) -> Animation {
2025    let quarter = (end - start) / 4.0;
2026    Animation {
2027        property: property.to_string(),
2028        keyframes: vec![
2029            kf(start, v0),
2030            kf(start + quarter, v1),
2031            kf(start + quarter * 2.0, v2),
2032            kf(end, v3),
2033        ],
2034        easing,
2035        spring: None,
2036    }
2037}
2038
2039#[cfg(test)]
2040mod spring_preset_tests {
2041
2042    use super::*;
2043    use crate::schema::AnimationEffect;
2044    use crate::schema::AnimationTiming;
2045
2046    fn timing(duration: f64, spring: Option<SpringConfig>) -> AnimationTiming {
2047        AnimationTiming {
2048            duration,
2049            spring,
2050            ..Default::default()
2051        }
2052    }
2053
2054    fn underdamped() -> SpringConfig {
2055        SpringConfig {
2056            damping: 8.0,
2057            stiffness: 120.0,
2058            mass: 1.0,
2059            ..Default::default()
2060        }
2061    }
2062
2063    fn sample(effects: &[AnimationEffect], duration: f64) -> Vec<(f64, f64, f64)> {
2064        let steps = 80;
2065        (0..=steps)
2066            .map(|i| {
2067                let t = duration * i as f64 / steps as f64;
2068                let p = resolve_props_for_effects(effects, t, 5.0);
2069                (t, p.translate_y as f64, p.opacity as f64)
2070            })
2071            .collect()
2072    }
2073
2074    #[test]
2075    fn fade_in_up_spring_overshoots_position() {
2076        let plain = sample(&[AnimationEffect::FadeInUp(timing(0.8, None))], 0.8);
2077        let min_plain = plain.iter().map(|(_, y, _)| *y).fold(f64::MAX, f64::min);
2078        assert!(
2079            min_plain >= -0.01,
2080            "without spring translate_y must never overshoot below 0, got min {min_plain}"
2081        );
2082
2083        let sprung = sample(
2084            &[AnimationEffect::FadeInUp(timing(0.8, Some(underdamped())))],
2085            0.8,
2086        );
2087        let min_sprung = sprung.iter().map(|(_, y, _)| *y).fold(f64::MAX, f64::min);
2088        assert!(
2089            min_sprung < -0.5,
2090            "with spring translate_y must overshoot below 0, got min {min_sprung}"
2091        );
2092
2093        let y_plain_70 = plain[56].1;
2094        let y_sprung_70 = sprung[56].1;
2095        assert!(
2096            (y_plain_70 - y_sprung_70).abs() > 0.5,
2097            "at 70% duration spring vs plain must differ: {y_plain_70} vs {y_sprung_70}"
2098        );
2099    }
2100
2101    #[test]
2102    fn fade_in_up_spring_does_not_touch_opacity() {
2103        let plain = sample(&[AnimationEffect::FadeInUp(timing(0.8, None))], 0.8);
2104        let sprung = sample(
2105            &[AnimationEffect::FadeInUp(timing(0.8, Some(underdamped())))],
2106            0.8,
2107        );
2108        for (i, ((_, _, a_plain), (_, _, a_sprung))) in plain.iter().zip(sprung.iter()).enumerate()
2109        {
2110            assert!(
2111                (a_plain - a_sprung).abs() < 1e-6,
2112                "opacity must be identical with/without spring at sample {i}: {a_plain} vs {a_sprung}"
2113            );
2114        }
2115        for w in sprung.windows(2) {
2116            assert!(
2117                w[1].2 >= w[0].2 - 1e-6,
2118                "opacity must be monotone, got {} then {}",
2119                w[0].2,
2120                w[1].2
2121            );
2122        }
2123    }
2124
2125    #[test]
2126    fn bounce_in_custom_spring_differs_from_default() {
2127        let scale_at = |spring: Option<SpringConfig>, t: f64| -> f64 {
2128            let fx = [AnimationEffect::BounceIn(timing(0.8, spring))];
2129            resolve_props_for_effects(&fx, t, 5.0).scale_x as f64
2130        };
2131        let overdamped = SpringConfig {
2132            damping: 40.0,
2133            stiffness: 100.0,
2134            mass: 1.0,
2135            ..Default::default()
2136        };
2137        let d = scale_at(None, 0.3);
2138        let c = scale_at(Some(overdamped), 0.3);
2139        assert!(
2140            (d - c).abs() > 0.01,
2141            "custom spring must change bounce_in: default {d} vs custom {c}"
2142        );
2143    }
2144
2145    #[test]
2146    fn scale_in_spring_collapses_manual_overshoot() {
2147        let fx = [AnimationEffect::ScaleIn(timing(0.8, Some(underdamped())))];
2148        let mut max_scale = f64::MIN;
2149        for i in 0..=80 {
2150            let t = 0.8 * i as f64 / 80.0;
2151            let s = resolve_props_for_effects(&fx, t, 5.0).scale_x as f64;
2152            max_scale = max_scale.max(s);
2153        }
2154        assert!(
2155            max_scale > 1.12,
2156            "spring scale_in must overshoot past the manual 1.08 peak, got max {max_scale}"
2157        );
2158        let end = resolve_props_for_effects(&fx, 0.8, 5.0).scale_x as f64;
2159        assert!(
2160            (end - 1.0).abs() < 1e-3,
2161            "scale must converge to 1.0 at window end, got {end}"
2162        );
2163    }
2164
2165    #[test]
2166    fn pulse_oscillator_ignores_spring() {
2167        let at = |spring: Option<SpringConfig>, t: f64| -> f64 {
2168            let fx = [AnimationEffect::Pulse(timing(1.0, spring))];
2169            resolve_props_for_effects(&fx, t, 5.0).scale_x as f64
2170        };
2171        for i in 0..=20 {
2172            let t = i as f64 / 20.0;
2173            let plain = at(None, t);
2174            let sprung = at(Some(underdamped()), t);
2175            assert!(
2176                (plain - sprung).abs() < 1e-9,
2177                "pulse must be unaffected by spring at t={t}: {plain} vs {sprung}"
2178            );
2179        }
2180    }
2181
2182    #[test]
2183    fn animation_timing_spring_serde_round_trip() {
2184        let json = r#"{ "name": "fade_in_up", "duration": 0.6, "spring": { "damping": 8, "stiffness": 120 } }"#;
2185        let fx: AnimationEffect = serde_json::from_str(json).unwrap();
2186        let AnimationEffect::FadeInUp(t) = &fx else {
2187            panic!("wrong variant");
2188        };
2189        let s = t.spring.as_ref().expect("spring parsed");
2190        assert_eq!(s.damping, 8.0);
2191        assert_eq!(s.stiffness, 120.0);
2192        assert_eq!(s.mass, 1.0, "mass defaults to 1");
2193
2194        let re = serde_json::to_string(&fx).unwrap();
2195        let back: AnimationEffect = serde_json::from_str(&re).unwrap();
2196        assert_eq!(fx, back);
2197
2198        let plain: AnimationEffect = serde_json::from_str(r#"{ "name": "fade_in_up" }"#).unwrap();
2199        let AnimationEffect::FadeInUp(t) = &plain else {
2200            panic!("wrong variant");
2201        };
2202        assert!(t.spring.is_none());
2203    }
2204}
2205
2206#[cfg(test)]
2207mod glow_tests {
2208
2209    use super::*;
2210    use crate::schema::{AnimationEffect, AnimationTiming, GlowConfig};
2211
2212    fn glow(color: &str, radius: f32, intensity: f32) -> AnimationEffect {
2213        AnimationEffect::Glow(GlowConfig {
2214            color: color.to_string(),
2215            radius,
2216            intensity,
2217        })
2218    }
2219
2220    #[test]
2221    fn finds_glow_among_other_effects() {
2222        let effects = vec![
2223            AnimationEffect::FadeIn(AnimationTiming::default()),
2224            glow("#5C39EE", 12.0, 1.0),
2225        ];
2226        let found = find_glow_effect(&effects).expect("glow effect present");
2227        assert_eq!(found.color, "#5C39EE");
2228        assert_eq!(found.radius, 12.0);
2229    }
2230
2231    #[test]
2232    fn returns_none_without_a_glow_effect() {
2233        let effects = vec![AnimationEffect::FadeIn(AnimationTiming::default())];
2234        assert!(find_glow_effect(&effects).is_none());
2235    }
2236
2237    #[test]
2238    fn resolve_props_for_effects_does_not_touch_glow_radius_or_intensity() {
2239        let effects = vec![glow("#5C39EE", 12.0, 1.0)];
2240        let props = resolve_props_for_effects(&effects, 0.0, 1.0);
2241        assert_eq!(
2242            props.glow_radius,
2243            AnimatedProperties::default().glow_radius,
2244            "glow_radius must stay at its sentinel; the named `glow` effect must not set it"
2245        );
2246        assert_eq!(
2247            props.glow_intensity,
2248            AnimatedProperties::default().glow_intensity
2249        );
2250    }
2251}
2252
2253#[cfg(test)]
2254mod float3d_amplitude_tests {
2255    use super::*;
2256    use crate::schema::AnimationEffect;
2257
2258    fn peak_translate_y(effects: &[AnimationEffect], window: f64) -> f64 {
2259        let mut peak = 0.0f64;
2260        let steps = 200;
2261        for i in 0..=steps {
2262            let t = window * i as f64 / steps as f64;
2263            let y = resolve_props_for_effects(effects, t, window + 1.0).translate_y as f64;
2264            if y.abs() > peak.abs() {
2265                peak = y;
2266            }
2267        }
2268        peak
2269    }
2270
2271    #[test]
2272    fn author_supplied_amplitude_reaches_the_solver() {
2273        let default_fx: AnimationEffect =
2274            serde_json::from_str(r#"{ "name": "float_3d", "duration": 1.0 }"#).unwrap();
2275        let big_fx: AnimationEffect =
2276            serde_json::from_str(r#"{ "name": "float_3d", "duration": 1.0, "amplitude": 60 }"#)
2277                .unwrap();
2278
2279        let default_peak = peak_translate_y(&[default_fx], 1.0);
2280        let big_peak = peak_translate_y(&[big_fx], 1.0);
2281
2282        assert!(
2283            (default_peak.abs() - 12.0).abs() < 0.5,
2284            "default float_3d amplitude must stay ~12px, got {default_peak}"
2285        );
2286        assert!(
2287            big_peak.abs() > 50.0,
2288            "amplitude=60 must reach the solver (peak translate_y near 60px), got {big_peak} \
2289             (default was {default_peak})"
2290        );
2291    }
2292}
2293
2294#[cfg(test)]
2295mod continuous_preset_timing_tests {
2296    use super::*;
2297    use crate::schema::AnimationEffect;
2298
2299    fn timing(delay: f64, duration: f64) -> AnimationTimingFixture {
2300        AnimationTimingFixture { delay, duration }
2301    }
2302
2303    struct AnimationTimingFixture {
2304        delay: f64,
2305        duration: f64,
2306    }
2307
2308    impl AnimationTimingFixture {
2309        fn json(&self, name: &str) -> String {
2310            format!(
2311                r#"{{ "name": "{}", "delay": {}, "duration": {} }}"#,
2312                name, self.delay, self.duration
2313            )
2314        }
2315    }
2316
2317    #[test]
2318    fn pulse_honours_delay_and_duration() {
2319        let t = timing(1.0, 2.0);
2320        let fx: AnimationEffect = serde_json::from_str(&t.json("pulse")).unwrap();
2321        let early = resolve_props_for_effects(std::slice::from_ref(&fx), 0.1, 10.0).scale_x as f64;
2322        let late = resolve_props_for_effects(std::slice::from_ref(&fx), 0.9, 10.0).scale_x as f64;
2323        assert!(
2324            (early - late).abs() < 1e-6,
2325            "pulse must be frozen before its delay=1.0 (not yet oscillating): \
2326             t=0.1 -> {early}, t=0.9 -> {late}"
2327        );
2328        assert!(
2329            (early - 0.95).abs() < 0.01,
2330            "pulse before its delay must clamp to the first keyframe (0.95), got {early}"
2331        );
2332        let mid = resolve_props_for_effects(&[fx], 2.0, 10.0).scale_x as f64;
2333        assert!(
2334            mid > 1.03,
2335            "pulse at t=2.0 (cycle midpoint) must be near peak scale ~1.05, got {mid}"
2336        );
2337    }
2338
2339    #[test]
2340    fn float_honours_delay_and_duration() {
2341        let t = timing(1.0, 2.0);
2342        let fx: AnimationEffect = serde_json::from_str(&t.json("float")).unwrap();
2343        let before =
2344            resolve_props_for_effects(std::slice::from_ref(&fx), 0.5, 10.0).translate_y as f64;
2345        assert!(
2346            before.abs() < 0.1,
2347            "float at t=0.5 (before delay=1.0) must be at rest y=0, got {before}"
2348        );
2349        let mid = resolve_props_for_effects(&[fx], 2.0, 10.0).translate_y as f64;
2350        assert!(
2351            mid < -8.0,
2352            "float at t=2.0 (cycle midpoint) must be near peak y=-10, got {mid}"
2353        );
2354    }
2355
2356    #[test]
2357    fn shake_honours_delay_and_duration() {
2358        let t = timing(1.0, 2.0);
2359        let fx: AnimationEffect = serde_json::from_str(&t.json("shake")).unwrap();
2360        let before =
2361            resolve_props_for_effects(std::slice::from_ref(&fx), 0.5, 10.0).translate_x as f64;
2362        assert!(
2363            before.abs() < 0.1,
2364            "shake at t=0.5 (before delay=1.0) must be at rest x=0, got {before}"
2365        );
2366        let quarter = resolve_props_for_effects(&[fx], 1.5, 10.0).translate_x as f64;
2367        assert!(
2368            quarter > 8.0,
2369            "shake at t=1.5 (cycle quarter) must be near peak x=+10, got {quarter}"
2370        );
2371    }
2372
2373    #[test]
2374    fn spin_honours_delay_and_duration() {
2375        let t = timing(1.0, 2.0);
2376        let fx: AnimationEffect = serde_json::from_str(&t.json("spin")).unwrap();
2377        let before =
2378            resolve_props_for_effects(std::slice::from_ref(&fx), 0.5, 10.0).rotation as f64;
2379        assert!(
2380            before.abs() < 0.1,
2381            "spin at t=0.5 (before delay=1.0) must be at rest rotation=0, got {before}"
2382        );
2383        let mid = resolve_props_for_effects(&[fx], 2.0, 10.0).rotation as f64;
2384        assert!(
2385            (mid - 180.0).abs() < 5.0,
2386            "spin at t=2.0 (cycle midpoint) must be near 180deg, got {mid}"
2387        );
2388    }
2389}
2390
2391#[cfg(test)]
2392mod keyframes_composition_tests {
2393    use super::*;
2394    use crate::schema::{Animation, AnimationEffect, Keyframe, KeyframeValue, KeyframesConfig};
2395
2396    fn ramp(property: &str, value: f64, delay: f64) -> AnimationEffect {
2397        AnimationEffect::Keyframes(KeyframesConfig {
2398            keyframes: vec![Animation {
2399                property: property.to_string(),
2400                keyframes: vec![
2401                    Keyframe {
2402                        time: 0.0,
2403                        value: KeyframeValue::Number(0.0),
2404                        easing: None,
2405                    },
2406                    Keyframe {
2407                        time: 1.0,
2408                        value: KeyframeValue::Number(value),
2409                        easing: None,
2410                    },
2411                ],
2412                easing: EasingType::Linear,
2413                spring: None,
2414            }],
2415            delay,
2416            duration: 0.8,
2417            repeat: false,
2418        })
2419    }
2420
2421    #[test]
2422    fn last_declared_effect_wins_regardless_of_which_one_carries_the_delay() {
2423        let a1 = ramp("translate_x", 100.0, 0.0);
2424        let b1 = ramp("translate_x", 40.0, 0.5);
2425        let combined_1 = resolve_props_for_effects(&[a1, b1.clone()], 1.0, 5.0).translate_x as f64;
2426        let b1_alone = resolve_props_for_effects(&[b1], 1.0, 5.0).translate_x as f64;
2427        assert!(
2428            (combined_1 - b1_alone).abs() < 1e-4,
2429            "B (declared last) must alone determine translate_x at t=1.0: combined={combined_1}, B-alone={b1_alone}"
2430        );
2431
2432        let a2 = ramp("translate_x", 100.0, 0.5);
2433        let b2 = ramp("translate_x", 40.0, 0.0);
2434        let combined_2 = resolve_props_for_effects(&[a2, b2.clone()], 1.0, 5.0).translate_x as f64;
2435        let b2_alone = resolve_props_for_effects(&[b2], 1.0, 5.0).translate_x as f64;
2436        assert!(
2437            (combined_2 - b2_alone).abs() < 1e-4,
2438            "B (declared last) must alone determine translate_x at t=1.0 even with delay swapped: \
2439             combined={combined_2}, B-alone={b2_alone}"
2440        );
2441
2442        assert!(
2443            (combined_1 - combined_2).abs() > 1.0,
2444            "sanity: the two cases must differ (B's own timing changed): {combined_1} vs {combined_2}"
2445        );
2446    }
2447}
2448
2449#[cfg(test)]
2450mod keyframes_loop_tests {
2451    use super::*;
2452    use crate::schema::{Animation, AnimationEffect, Keyframe, KeyframeValue, KeyframesConfig};
2453
2454    #[test]
2455    fn keyframes_loop_true_wraps_time_past_the_last_keyframe() {
2456        let looping = AnimationEffect::Keyframes(KeyframesConfig {
2457            keyframes: vec![Animation {
2458                property: "opacity".to_string(),
2459                keyframes: vec![
2460                    Keyframe {
2461                        time: 0.0,
2462                        value: KeyframeValue::Number(0.0),
2463                        easing: None,
2464                    },
2465                    Keyframe {
2466                        time: 1.0,
2467                        value: KeyframeValue::Number(1.0),
2468                        easing: None,
2469                    },
2470                ],
2471                easing: EasingType::Linear,
2472                spring: None,
2473            }],
2474            delay: 0.0,
2475            duration: 0.8,
2476            repeat: true,
2477        });
2478        let opacity = resolve_props_for_effects(&[looping], 2.5, 5.0).opacity as f64;
2479        assert!(
2480            (opacity - 0.5).abs() < 0.05,
2481            "looping keyframes at t=2.5 must wrap to local t=0.5 (opacity ~0.5), got {opacity}"
2482        );
2483    }
2484
2485    #[test]
2486    fn tilt_in_loop_true_keeps_tilting_past_its_settle_time() {
2487        let looping_tilt: AnimationEffect = serde_json::from_str(
2488            r#"{ "name": "tilt_in", "delay": 0.0, "duration": 0.4, "loop": true }"#,
2489        )
2490        .unwrap();
2491        let settled: AnimationEffect =
2492            serde_json::from_str(r#"{ "name": "tilt_in", "delay": 0.0, "duration": 0.4 }"#)
2493                .unwrap();
2494
2495        let settled_scale = resolve_props_for_effects(&[settled], 1.0, 5.0).scale_x as f64;
2496        let looping_scale = resolve_props_for_effects(&[looping_tilt], 1.0, 5.0).scale_x as f64;
2497
2498        assert!(
2499            (settled_scale - 1.0).abs() < 1e-3,
2500            "non-looping tilt_in at t=1.0 (past settle) must be resting at scale 1.0, got {settled_scale}"
2501        );
2502        assert!(
2503            (looping_scale - 1.0).abs() > 0.01,
2504            "looping tilt_in at t=1.0 must still be mid-cycle (scale != 1.0 rest), got {looping_scale}"
2505        );
2506    }
2507}
2508
2509#[cfg(test)]
2510mod spring_robustness_tests {
2511    use super::*;
2512
2513    #[test]
2514    fn zero_mass_does_not_produce_nan() {
2515        let config = SpringConfig {
2516            damping: 10.0,
2517            stiffness: 100.0,
2518            mass: 0.0,
2519            ..Default::default()
2520        };
2521        for i in 0..=20 {
2522            let t = i as f64 * 0.25;
2523            let v = spring_value(t, &config);
2524            assert!(
2525                v.is_finite(),
2526                "spring_value(t={t}) with mass=0 must be finite, got {v}"
2527            );
2528        }
2529    }
2530
2531    #[test]
2532    fn zero_stiffness_does_not_produce_nan() {
2533        let config = SpringConfig {
2534            damping: 10.0,
2535            stiffness: 0.0,
2536            mass: 1.0,
2537            ..Default::default()
2538        };
2539        for i in 0..=20 {
2540            let t = i as f64 * 0.25;
2541            let v = spring_value(t, &config);
2542            assert!(
2543                v.is_finite(),
2544                "spring_value(t={t}) with stiffness=0 must be finite, got {v}"
2545            );
2546        }
2547    }
2548
2549    #[test]
2550    fn negative_damping_stays_bounded_instead_of_diverging() {
2551        let config = SpringConfig {
2552            damping: -20.0,
2553            stiffness: 100.0,
2554            mass: 1.0,
2555            ..Default::default()
2556        };
2557        let v_at_5s = spring_value(5.0, &config);
2558        assert!(
2559            v_at_5s.is_finite() && v_at_5s.abs() < 100.0,
2560            "spring_value(t=5.0) with damping=-20 must stay bounded (finite and reasonably \
2561             small), got {v_at_5s} — negative damping must not diverge to +-infinity"
2562        );
2563    }
2564}
2565
2566#[cfg(test)]
2567mod spring_duration_tests {
2568    use super::*;
2569
2570    fn brute_force_settle_time(
2571        damping: f64,
2572        stiffness: f64,
2573        mass: f64,
2574        threshold: f64,
2575        max_t: f64,
2576        steps: usize,
2577    ) -> f64 {
2578        let dt = max_t / steps as f64;
2579        let mut last_exceed = 0.0;
2580        for i in 0..=steps {
2581            let t = i as f64 * dt;
2582            if (spring_value_raw(t, damping, stiffness, mass) - 1.0).abs() > threshold {
2583                last_exceed = t;
2584            }
2585        }
2586        last_exceed
2587    }
2588
2589    #[test]
2590    fn red_phase_duration_is_ignored_by_the_raw_physical_solver() {
2591        let v = spring_value_raw(0.8, 6.0, 120.0, 1.0);
2592        assert!(
2593            (v - 1.027616).abs() < 1e-5,
2594            "captured red-phase reference value drifted: got {v}, expected ~1.027616"
2595        );
2596        assert!(
2597            (v - 1.0).abs() > 0.02,
2598            "red-phase claim: at t=duration the unscaled spring must still be far from rest \
2599             (got diff {:.6}, expected > 0.02)",
2600            (v - 1.0).abs()
2601        );
2602    }
2603
2604    #[test]
2605    fn duration_makes_the_spring_settle_exactly_there() {
2606        let config = SpringConfig {
2607            damping: 6.0,
2608            stiffness: 120.0,
2609            mass: 1.0,
2610            duration: Some(0.8),
2611            rest_threshold: None,
2612        };
2613        let threshold = DEFAULT_SPRING_REST_THRESHOLD;
2614
2615        let v_at_duration = spring_value(0.8, &config);
2616        assert!(
2617            (v_at_duration - 1.0).abs() <= threshold,
2618            "spring_value(0.8, ..) with duration=Some(0.8) must be within {threshold} of rest, \
2619             got {v_at_duration} (diff {})",
2620            (v_at_duration - 1.0).abs()
2621        );
2622
2623        let v_at_half = spring_value(0.4, &config);
2624        assert!(
2625            (v_at_half - 1.0).abs() > threshold,
2626            "sanity: spring must not already be at rest at half of duration, got diff {}",
2627            (v_at_half - 1.0).abs()
2628        );
2629    }
2630
2631    #[test]
2632    fn spring_rest_time_returns_duration_verbatim_when_set() {
2633        let config = SpringConfig {
2634            damping: 6.0,
2635            stiffness: 120.0,
2636            mass: 1.0,
2637            duration: Some(0.8),
2638            rest_threshold: None,
2639        };
2640        assert_eq!(spring_rest_time(&config), 0.8);
2641    }
2642
2643    #[test]
2644    fn spring_rest_time_matches_a_brute_force_reference_without_duration() {
2645        let cases: [(f64, f64, f64, &str); 5] = [
2646            (15.0, 100.0, 1.0, "default"),
2647            (12.0, 100.0, 1.0, "kf_anim_spring"),
2648            (6.0, 120.0, 1.0, "underdamped elastic_in-like"),
2649            (
2650                20.0,
2651                100.0,
2652                1.0,
2653                "critically damped (damping = 2*sqrt(stiffness*mass))",
2654            ),
2655            (60.0, 100.0, 1.0, "overdamped"),
2656        ];
2657        for (damping, stiffness, mass, label) in cases {
2658            let config = SpringConfig {
2659                damping,
2660                stiffness,
2661                mass,
2662                duration: None,
2663                rest_threshold: None,
2664            };
2665            let threshold = DEFAULT_SPRING_REST_THRESHOLD;
2666            let got = spring_rest_time(&config);
2667            let reference = brute_force_settle_time(
2668                damping,
2669                stiffness,
2670                mass,
2671                threshold,
2672                MAX_SPRING_SEARCH_SECONDS,
2673                400_000,
2674            );
2675            let abs_err = (got - reference).abs();
2676            assert!(
2677                abs_err < 0.05,
2678                "{label}: spring_rest_time={got:.5}s vs brute-force reference={reference:.5}s \
2679                 (|err|={abs_err:.5}s, expected < 0.05s)"
2680            );
2681        }
2682    }
2683
2684    #[test]
2685    fn overdamped_spring_never_reaches_target_exactly_but_settle_time_is_found() {
2686        let config = SpringConfig {
2687            damping: 200.0,
2688            stiffness: 100.0,
2689            mass: 1.0,
2690            duration: None,
2691            rest_threshold: None,
2692        };
2693        let t = spring_rest_time(&config);
2694        assert!(
2695            t > 0.0 && t < MAX_SPRING_SEARCH_SECONDS,
2696            "expected a finite, non-degenerate settle time, got {t}"
2697        );
2698
2699        for i in 1..=200 {
2700            let sample_t = t + i as f64 * 0.1;
2701            let v = spring_value_raw(sample_t, 200.0, 100.0, 1.0);
2702            assert_ne!(
2703                v, 1.0,
2704                "an overdamped spring must never hit its target exactly (t={sample_t})"
2705            );
2706        }
2707    }
2708
2709    #[test]
2710    fn undamped_spring_is_capped_not_infinite() {
2711        let config = SpringConfig {
2712            damping: 0.0,
2713            stiffness: 100.0,
2714            mass: 1.0,
2715            duration: None,
2716            rest_threshold: None,
2717        };
2718        let t = spring_rest_time(&config);
2719        assert_eq!(
2720            t, MAX_SPRING_SEARCH_SECONDS,
2721            "an undamped spring must be reported as capped at the search bound, got {t}"
2722        );
2723    }
2724
2725    #[test]
2726    fn very_lightly_damped_spring_is_also_capped_when_beyond_the_bound() {
2727        let config = SpringConfig {
2728            damping: 0.05,
2729            stiffness: 100.0,
2730            mass: 1.0,
2731            duration: None,
2732            rest_threshold: None,
2733        };
2734        let t = spring_rest_time(&config);
2735        assert_eq!(
2736            t, MAX_SPRING_SEARCH_SECONDS,
2737            "expected the search to hit its cap, got {t}"
2738        );
2739    }
2740
2741    #[test]
2742    fn duration_remap_preserves_shape() {
2743        let damping = 6.0;
2744        let stiffness = 120.0;
2745        let mass = 1.0;
2746        let natural = SpringConfig {
2747            damping,
2748            stiffness,
2749            mass,
2750            duration: None,
2751            rest_threshold: None,
2752        };
2753        let natural_rest = spring_rest_time(&natural);
2754
2755        let pinned_duration = 2.5;
2756        let pinned = SpringConfig {
2757            damping,
2758            stiffness,
2759            mass,
2760            duration: Some(pinned_duration),
2761            rest_threshold: None,
2762        };
2763
2764        let mut natural_overshoots = 0;
2765        let mut pinned_overshoots = 0;
2766        let mut max_natural_overshoot = 0.0_f64;
2767        let mut max_pinned_overshoot = 0.0_f64;
2768        let mut prev_natural_over = false;
2769        let mut prev_pinned_over = false;
2770
2771        for i in 0..=1000 {
2772            let frac = i as f64 / 1000.0;
2773            let v_natural = spring_value(frac * natural_rest, &natural);
2774            let v_pinned = spring_value(frac * pinned_duration, &pinned);
2775
2776            assert!(
2777                (v_natural - v_pinned).abs() < 1e-9,
2778                "shape mismatch at fraction {frac}: natural={v_natural} pinned={v_pinned}"
2779            );
2780
2781            let natural_over = v_natural > 1.0;
2782            if natural_over && !prev_natural_over {
2783                natural_overshoots += 1;
2784            }
2785            prev_natural_over = natural_over;
2786            max_natural_overshoot = max_natural_overshoot.max(v_natural - 1.0);
2787
2788            let pinned_over = v_pinned > 1.0;
2789            if pinned_over && !prev_pinned_over {
2790                pinned_overshoots += 1;
2791            }
2792            prev_pinned_over = pinned_over;
2793            max_pinned_overshoot = max_pinned_overshoot.max(v_pinned - 1.0);
2794        }
2795
2796        assert!(
2797            natural_overshoots > 0,
2798            "expected this underdamped spring to overshoot at least once"
2799        );
2800        assert_eq!(
2801            natural_overshoots, pinned_overshoots,
2802            "oscillation count must be identical with/without duration"
2803        );
2804        assert!(
2805            (max_natural_overshoot - max_pinned_overshoot).abs() < 1e-9,
2806            "overshoot amplitude must be identical with/without duration: natural={max_natural_overshoot} pinned={max_pinned_overshoot}"
2807        );
2808    }
2809
2810    #[test]
2811    fn duration_does_not_change_delay_semantics() {
2812        let config = SpringConfig {
2813            damping: 6.0,
2814            stiffness: 120.0,
2815            mass: 1.0,
2816            duration: Some(0.8),
2817            rest_threshold: None,
2818        };
2819        assert_eq!(
2820            spring_value(0.0, &config),
2821            spring_value_raw(0.0, 6.0, 120.0, 1.0)
2822        );
2823    }
2824}
2825
2826#[cfg(test)]
2827mod motion_path_tests {
2828    use super::*;
2829
2830    fn cfg(path: &str) -> MotionPathConfig {
2831        MotionPathConfig {
2832            path: path.to_string(),
2833            delay: 0.0,
2834            duration: 1.0,
2835            repeat: false,
2836            orient: false,
2837            orient_offset: 0.0,
2838            easing: EasingType::Linear,
2839        }
2840    }
2841
2842    #[test]
2843    fn mid_path_progress_lands_on_the_curve_not_on_the_endpoint_chord() {
2844        let c = cfg("M0,0 L100,0 L100,100");
2845        let sample = motion_path_sample(&c, 0.5);
2846
2847        assert!(
2848            (sample.dx - 100.0).abs() < 0.5,
2849            "expected dx≈100 (on the path's corner), got {}",
2850            sample.dx
2851        );
2852        assert!(
2853            (sample.dy - 0.0).abs() < 0.5,
2854            "expected dy≈0 (on the path's corner), got {}",
2855            sample.dy
2856        );
2857
2858        let chord_x = 50.0f32;
2859        let chord_y = 50.0f32;
2860        let dist_from_chord_midpoint =
2861            ((sample.dx - chord_x).powi(2) + (sample.dy - chord_y).powi(2)).sqrt();
2862        assert!(
2863            dist_from_chord_midpoint > 40.0,
2864            "t=0.5 must not land near the endpoint-to-endpoint chord midpoint (50,50) — got \
2865             ({}, {}), which would also pass under a plain linear-interpolation bug",
2866            sample.dx,
2867            sample.dy
2868        );
2869    }
2870
2871    #[test]
2872    fn progress_zero_and_one_land_on_the_paths_own_endpoints() {
2873        let c = cfg("M10,20 L310,20 L310,220");
2874        let start = motion_path_sample(&c, 0.0);
2875        assert!((start.dx - 10.0).abs() < 0.5 && (start.dy - 20.0).abs() < 0.5);
2876
2877        let end = motion_path_sample(&c, 1.0);
2878        assert!((end.dx - 310.0).abs() < 0.5 && (end.dy - 220.0).abs() < 0.5);
2879    }
2880
2881    #[test]
2882    fn path_coordinates_are_used_literally_as_the_translate_delta() {
2883        let c = cfg("M100,50 L300,50");
2884        let sample = motion_path_sample(&c, 0.0);
2885        assert!(
2886            (sample.dx - 100.0).abs() < 0.5 && (sample.dy - 50.0).abs() < 0.5,
2887            "expected the raw path start point (100, 50) as the delta, got ({}, {})",
2888            sample.dx,
2889            sample.dy
2890        );
2891    }
2892
2893    #[test]
2894    fn apply_motion_paths_writes_translate_and_rotation_additively() {
2895        let mut props = AnimatedProperties {
2896            translate_x: 5.0,
2897            translate_y: -5.0,
2898            ..AnimatedProperties::default()
2899        };
2900        let mut c = cfg("M0,0 L100,0");
2901        c.orient = true;
2902        apply_motion_paths(&mut props, &[c], 0.0);
2903
2904        assert!((props.translate_x - 5.0).abs() < 0.5);
2905        assert!((props.translate_y - (-5.0)).abs() < 0.5);
2906        assert!(props.rotation.abs() < 0.5, "got {}", props.rotation);
2907    }
2908
2909    #[test]
2910    fn orient_false_never_touches_rotation() {
2911        let c = cfg("M0,0 L0,100");
2912        let mut props = AnimatedProperties::default();
2913        apply_motion_paths(&mut props, &[c], 0.5);
2914        assert_eq!(props.rotation, 0.0);
2915    }
2916
2917    #[test]
2918    fn orient_true_rotates_toward_the_tangent_and_offset_is_additive() {
2919        let mut vertical = cfg("M0,0 L0,100");
2920        vertical.orient = true;
2921        let sample = motion_path_sample(&vertical, 0.5);
2922        assert!(
2923            (sample.angle_deg - 90.0).abs() < 1.0,
2924            "got {}",
2925            sample.angle_deg
2926        );
2927
2928        let mut with_offset = vertical.clone();
2929        with_offset.orient_offset = 10.0;
2930        let offset_sample = motion_path_sample(&with_offset, 0.5);
2931        assert!(
2932            (offset_sample.angle_deg - 100.0).abs() < 1.0,
2933            "orient_offset must add on top of the tangent angle, got {}",
2934            offset_sample.angle_deg
2935        );
2936    }
2937
2938    #[test]
2939    fn single_point_path_holds_position_and_never_produces_nan() {
2940        let mut c = cfg("M50,50");
2941        c.orient = true;
2942        for t in [-1.0, 0.0, 0.3, 0.5, 1.0, 2.0] {
2943            let sample = motion_path_sample(&c, t);
2944            assert!(sample.dx.is_finite() && sample.dy.is_finite() && sample.angle_deg.is_finite());
2945            assert!((sample.dx - 50.0).abs() < 0.5 && (sample.dy - 50.0).abs() < 0.5);
2946            assert_eq!(
2947                sample.angle_deg, 0.0,
2948                "orientation is undefined at zero length and must default to 0, not NaN"
2949            );
2950        }
2951    }
2952
2953    #[test]
2954    fn coincident_points_zero_length_path_holds_without_nan() {
2955        let mut c = cfg("M10,10 L10,10 L10,10");
2956        c.orient = true;
2957        let sample = motion_path_sample(&c, 0.5);
2958        assert!(sample.dx.is_finite() && sample.dy.is_finite() && sample.angle_deg.is_finite());
2959        assert!((sample.dx - 10.0).abs() < 0.5 && (sample.dy - 10.0).abs() < 0.5);
2960        assert_eq!(sample.angle_deg, 0.0);
2961    }
2962
2963    #[test]
2964    fn empty_path_data_never_panics_or_produces_nan() {
2965        let c = cfg("");
2966        let sample = motion_path_sample(&c, 0.5);
2967        assert_eq!((sample.dx, sample.dy, sample.angle_deg), (0.0, 0.0, 0.0));
2968    }
2969
2970    #[test]
2971    fn unparsable_path_data_never_panics_or_produces_nan() {
2972        let c = cfg("definitely not svg path data");
2973        let sample = motion_path_sample(&c, 0.5);
2974        assert!(sample.dx.is_finite() && sample.dy.is_finite() && sample.angle_deg.is_finite());
2975    }
2976
2977    #[test]
2978    fn zero_or_negative_duration_never_produces_nan() {
2979        for duration in [0.0, -1.0, -0.5] {
2980            let mut c = cfg("M0,0 L100,0");
2981            c.duration = duration;
2982            for t in [0.0, 0.5, 1.0, 5.0] {
2983                let sample = motion_path_sample(&c, t);
2984                assert!(
2985                    sample.dx.is_finite() && sample.dy.is_finite() && sample.angle_deg.is_finite(),
2986                    "duration={duration} time={t} produced a non-finite sample: dx={} dy={}",
2987                    sample.dx,
2988                    sample.dy
2989                );
2990            }
2991        }
2992    }
2993
2994    #[test]
2995    fn motion_path_length_reports_none_for_empty_or_unparsable_input() {
2996        assert_eq!(motion_path_length(""), None);
2997        assert_eq!(motion_path_length("not a path"), None);
2998    }
2999
3000    #[test]
3001    fn motion_path_length_reports_near_zero_for_a_single_point() {
3002        let len = motion_path_length("M50,50").expect("single point is a valid, parseable path");
3003        assert!(len <= MOTION_PATH_MIN_LENGTH, "got {len}");
3004    }
3005
3006    #[test]
3007    fn motion_path_length_reports_the_real_length_for_a_real_path() {
3008        let len = motion_path_length("M0,0 L100,0").expect("valid path");
3009        assert!((len - 100.0).abs() < 0.5, "got {len}");
3010    }
3011
3012    #[test]
3013    fn looping_wraps_progress_back_toward_the_start() {
3014        let mut c = cfg("M0,0 L100,0 L100,100");
3015        c.repeat = true;
3016        c.duration = 1.0;
3017        let sample = motion_path_sample(&c, 1.5);
3018        assert!((sample.dx - 100.0).abs() < 0.5 && (sample.dy - 0.0).abs() < 0.5);
3019    }
3020
3021    #[test]
3022    fn non_looping_holds_at_the_end_past_delay_plus_duration() {
3023        let c = cfg("M0,0 L100,0 L100,100");
3024        let at_end = motion_path_sample(&c, 1.0);
3025        let past_end = motion_path_sample(&c, 5.0);
3026        assert_eq!(at_end.dx, past_end.dx);
3027        assert_eq!(at_end.dy, past_end.dy);
3028    }
3029
3030    #[test]
3031    fn sampling_is_deterministic_across_repeated_calls() {
3032        let c = cfg("M0,0 C50,-100 150,-100 200,0");
3033        let first = motion_path_sample(&c, 0.37);
3034        for _ in 0..25 {
3035            let again = motion_path_sample(&c, 0.37);
3036            assert_eq!(first.dx, again.dx);
3037            assert_eq!(first.dy, again.dy);
3038            assert_eq!(first.angle_deg, again.angle_deg);
3039        }
3040    }
3041
3042    #[test]
3043    fn resolve_props_for_effects_is_deterministic_and_reaches_translate() {
3044        let effect = AnimationEffect::MotionPath(cfg("M0,0 L400,0"));
3045        let effects = vec![effect];
3046        let a = resolve_props_for_effects(&effects, 0.5, 1.0);
3047        let b = resolve_props_for_effects(&effects, 0.5, 1.0);
3048        assert_eq!(a.translate_x, b.translate_x);
3049        assert_eq!(a.translate_y, b.translate_y);
3050        assert!(
3051            (a.translate_x - 200.0).abs() < 1.0,
3052            "expected ~halfway along a straight 400px path, got {}",
3053            a.translate_x
3054        );
3055    }
3056}
3057
3058#[cfg(test)]
3059mod char_animation_tuning_tests {
3060    use super::*;
3061
3062    fn anim(stagger: f32, jitter: f32, seed: u32) -> ResolvedCharAnimation {
3063        ResolvedCharAnimation {
3064            preset: CharAnimPreset::SlideUp,
3065            granularity: TextAnimGranularity::Word,
3066            stagger,
3067            duration: 0.4,
3068            easing: EasingType::Linear,
3069            delay: 0.5,
3070            overshoot: 0.08,
3071            blur: DEFAULT_CHAR_BLUR_SIGMA,
3072            direction: TextAnimDirection::Up,
3073            distance: 1.0,
3074            scale_from: None,
3075            jitter,
3076            seed,
3077            ink_from: None,
3078            rotate_from: None,
3079            rotate_origin: RotateOrigin::default(),
3080            scale_jitter: 0.0,
3081            baseline_jitter: 0.0,
3082            reflow: false,
3083            reflow_easing: EasingType::default(),
3084        }
3085    }
3086
3087    #[test]
3088    fn without_jitter_units_are_evenly_spaced() {
3089        let a = anim(0.2, 0.0, 0);
3090        for i in 0..6 {
3091            let expected = 0.5 + i as f64 * 0.2;
3092            assert!(
3093                (a.unit_start(i) - expected).abs() < 1e-6,
3094                "unit {i} should start at {expected}, got {}",
3095                a.unit_start(i)
3096            );
3097        }
3098    }
3099
3100    #[test]
3101    fn jitter_is_a_pure_function_of_index_and_seed() {
3102        let a = anim(0.2, 0.6, 42);
3103        let b = anim(0.2, 0.6, 42);
3104        for i in 0..32 {
3105            assert_eq!(
3106                a.unit_start(i).to_bits(),
3107                b.unit_start(i).to_bits(),
3108                "unit {i} must resolve bit-identically for the same seed"
3109            );
3110        }
3111    }
3112
3113    #[test]
3114    fn a_different_seed_reshuffles_the_rhythm() {
3115        let a = anim(0.2, 0.6, 1);
3116        let b = anim(0.2, 0.6, 2);
3117        let differing = (0..32)
3118            .filter(|&i| a.unit_start(i) != b.unit_start(i))
3119            .count();
3120        assert!(
3121            differing > 24,
3122            "changing the seed should move nearly every unit, but only {differing}/32 moved"
3123        );
3124    }
3125
3126    #[test]
3127    fn jitter_actually_perturbs_the_even_spacing() {
3128        let even = anim(0.2, 0.0, 7);
3129        let jittered = anim(0.2, 0.8, 7);
3130        let moved = (1..32)
3131            .filter(|&i| (even.unit_start(i) - jittered.unit_start(i)).abs() > 1e-6)
3132            .count();
3133        assert!(
3134            moved > 20,
3135            "jitter should visibly perturb the march, but only {moved}/31 units moved"
3136        );
3137    }
3138
3139    #[test]
3140    fn no_unit_starts_before_the_effects_own_delay() {
3141        let a = anim(0.2, 2.0, 99);
3142        for i in 0..64 {
3143            assert!(
3144                a.unit_start(i) >= 0.5 - 1e-9,
3145                "unit {i} started at {} — before the effect's own 0.5s delay",
3146                a.unit_start(i)
3147            );
3148        }
3149    }
3150
3151    #[test]
3152    fn a_zero_stagger_is_unaffected_by_jitter() {
3153        let a = anim(0.0, 1.0, 3);
3154        for i in 0..8 {
3155            assert!((a.unit_start(i) - 0.5).abs() < 1e-9);
3156        }
3157    }
3158}
3159
3160#[cfg(test)]
3161mod easing_steps_tests {
3162    use super::*;
3163
3164    #[test]
3165    fn steps_one_holds_the_start_value_until_the_very_end() {
3166        let s = EasingType::Steps(1);
3167        for t in [0.0, 0.1, 0.5, 0.9, 0.999_999] {
3168            assert_eq!(
3169                ease(t, &s),
3170                0.0,
3171                "steps(1) must hold at 0.0 for the entire segment, t={t}"
3172            );
3173        }
3174        assert_eq!(ease(1.0, &s), 1.0, "steps(1) jumps to 1.0 exactly at t=1.0");
3175    }
3176
3177    #[test]
3178    fn steps_four_holds_four_discrete_levels() {
3179        let s = EasingType::Steps(4);
3180        assert_eq!(ease(0.0, &s), 0.0);
3181        assert_eq!(ease(0.1, &s), 0.0);
3182        assert_eq!(ease(0.24, &s), 0.0);
3183        assert_eq!(ease(0.25, &s), 0.25);
3184        assert_eq!(ease(0.49, &s), 0.25);
3185        assert_eq!(ease(0.50, &s), 0.50);
3186        assert_eq!(ease(0.75, &s), 0.75);
3187        assert_eq!(ease(0.999, &s), 0.75);
3188        assert_eq!(ease(1.0, &s), 1.0);
3189    }
3190
3191    #[test]
3192    fn steps_zero_does_not_panic_or_divide_by_zero() {
3193        let s = EasingType::Steps(0);
3194        for t in [0.0, 0.5, 1.0] {
3195            assert!(ease(t, &s).is_finite());
3196        }
3197    }
3198}
3199
3200#[cfg(test)]
3201mod repeat_cycle_tests {
3202    use super::*;
3203
3204    fn ramp(duration: f64) -> Animation {
3205        kf_anim("x", 0.0, 0.0, duration, 1.0, EasingType::Linear)
3206    }
3207
3208    fn config(repeat_count: Option<u32>, yoyo: bool, repeat_delay: f64) -> PresetConfig {
3209        PresetConfig {
3210            repeat: true,
3211            repeat_count,
3212            yoyo,
3213            repeat_delay,
3214            ..Default::default()
3215        }
3216    }
3217
3218    #[test]
3219    fn repeat_true_with_no_new_fields_is_byte_identical_to_legacy_loop_time() {
3220        fn legacy_loop_time(start: f64, duration: f64, time: f64) -> f64 {
3221            if duration < 1e-9 || time < start {
3222                return time;
3223            }
3224            start + ((time - start) % duration)
3225        }
3226
3227        let duration = 2.0;
3228        let anim = ramp(duration);
3229        let cfg = config(None, false, 0.0);
3230        for t in [
3231            -1.0, 0.0, 0.3, 0.999_999, 1.0, 1.5, 1.999_999, 2.0, 2.000_001, 2.5, 3.999_999, 4.0,
3232            4.1, 10.3, 100.7,
3233        ] {
3234            let got = cycle_time(&anim, t, &cfg);
3235            let legacy = legacy_loop_time(0.0, duration, t);
3236            assert!(
3237                (got - legacy).abs() < 1e-12,
3238                "t={t}: cycle_time={got}, legacy loop_time={legacy}"
3239            );
3240        }
3241    }
3242
3243    #[test]
3244    fn a_finite_repeat_count_freezes_on_the_last_plays_resting_value() {
3245        let duration = 1.0;
3246        let anim = ramp(duration);
3247        let cfg = config(Some(3), false, 0.0);
3248        let frozen_at = cycle_time(&anim, 3.0, &cfg);
3249        for t in [3.0, 3.5, 10.0, 1_000.0] {
3250            let got = cycle_time(&anim, t, &cfg);
3251            assert!(
3252                (got - frozen_at).abs() < 1e-9,
3253                "t={t} must stay frozen at the last play's end ({frozen_at}), got {got}"
3254            );
3255        }
3256        assert!((cycle_time(&anim, 0.5, &cfg) - 0.5).abs() < 1e-9);
3257        assert!((cycle_time(&anim, 1.5, &cfg) - 0.5).abs() < 1e-9);
3258    }
3259
3260    #[test]
3261    fn repeat_count_of_one_behaves_like_a_single_play() {
3262        let duration = 1.0;
3263        let anim = ramp(duration);
3264        let looping = config(Some(1), false, 0.0);
3265        for t in [0.0, 0.5, 1.0, 2.0, 5.0] {
3266            let got = cycle_time(&anim, t, &looping);
3267            let single_play = t.min(duration);
3268            assert!((got - single_play).abs() < 1e-9, "t={t}: got {got}");
3269        }
3270    }
3271
3272    #[test]
3273    fn yoyo_reverses_every_other_cycle() {
3274        let duration = 1.0;
3275        let anim = ramp(duration);
3276        let cfg = config(None, true, 0.0);
3277        assert!((cycle_time(&anim, 0.25, &cfg) - 0.25).abs() < 1e-9);
3278        assert!((cycle_time(&anim, 1.25, &cfg) - 0.75).abs() < 1e-9);
3279        assert!((cycle_time(&anim, 1.75, &cfg) - 0.25).abs() < 1e-9);
3280        assert!((cycle_time(&anim, 2.25, &cfg) - 0.25).abs() < 1e-9);
3281    }
3282
3283    #[test]
3284    fn yoyo_produces_a_continuous_value_at_every_cycle_boundary() {
3285        let duration = 1.0;
3286        let anim = ramp(duration);
3287        let cfg = config(None, true, 0.0);
3288        for boundary in [1.0, 2.0, 3.0] {
3289            let just_before = cycle_time(&anim, boundary - 1e-6, &cfg);
3290            let at = cycle_time(&anim, boundary, &cfg);
3291            assert!(
3292                (just_before - at).abs() < 1e-3,
3293                "boundary {boundary}: just_before={just_before}, at={at}"
3294            );
3295        }
3296    }
3297
3298    #[test]
3299    fn repeat_delay_holds_the_resting_value_between_plays() {
3300        let duration = 1.0;
3301        let anim = ramp(duration);
3302        let cfg = config(None, false, 0.5);
3303        assert!((cycle_time(&anim, 0.5, &cfg) - 0.5).abs() < 1e-9);
3304        assert!((cycle_time(&anim, 1.0, &cfg) - 1.0).abs() < 1e-9);
3305        assert!((cycle_time(&anim, 1.3, &cfg) - 1.0).abs() < 1e-9);
3306        assert!((cycle_time(&anim, 1.5, &cfg) - 0.0).abs() < 1e-9);
3307        assert!((cycle_time(&anim, 2.0, &cfg) - 0.5).abs() < 1e-9);
3308    }
3309
3310    #[test]
3311    fn resolve_animations_actually_applies_yoyo_and_repeat_count_end_to_end() {
3312        let animations = vec![kf_anim(
3313            "translate_x",
3314            0.0,
3315            0.0,
3316            1.0,
3317            1.0,
3318            EasingType::Linear,
3319        )];
3320        let cfg = config(Some(2), true, 0.0);
3321        let at = |t: f64| -> f64 {
3322            resolve_animations(&animations, None, Some(&cfg), t, 10.0).translate_x as f64
3323        };
3324        assert!((at(0.25) - 0.25).abs() < 1e-4, "forward play: {}", at(0.25));
3325        assert!(
3326            (at(1.25) - 0.75).abs() < 1e-4,
3327            "yoyo'd second play: {}",
3328            at(1.25)
3329        );
3330        let frozen = at(2.0);
3331        assert!(
3332            (at(5.0) - frozen).abs() < 1e-4,
3333            "must stay frozen: {}",
3334            at(5.0)
3335        );
3336    }
3337}
3338
3339#[cfg(test)]
3340mod burst_progress_tests {
3341    use super::*;
3342
3343    fn cfg() -> BurstConfig {
3344        BurstConfig {
3345            delay: 1.0,
3346            duration: 0.5,
3347            count: 8,
3348            length: 40.0,
3349            gap: 12.0,
3350            width: 4.0,
3351            color: "#FFB020".to_string(),
3352            seed: 3,
3353            jitter: 0.2,
3354        }
3355    }
3356
3357    #[test]
3358    fn nothing_before_the_delay_and_nothing_at_or_after_the_end() {
3359        let c = cfg();
3360        assert_eq!(burst_progress(&c, c.delay - 0.01), None);
3361        assert_eq!(burst_progress(&c, c.delay + c.duration), None);
3362        assert_eq!(burst_progress(&c, 9.0), None);
3363        assert!(burst_progress(&c, c.delay).is_some());
3364        assert!(burst_progress(&c, c.delay + c.duration * 0.99).is_some());
3365    }
3366
3367    #[test]
3368    fn a_frame_landing_one_ulp_inside_the_window_still_paints_nothing() {
3369        let mut c = cfg();
3370        c.duration = 0.4;
3371        let at_end = c.delay + c.duration;
3372        let progress = burst_progress(&c, at_end)
3373            .expect("0.4 is not exactly representable: delay + duration - delay < duration");
3374        let (tail, head) = burst_stroke_span(progress, 0.0);
3375        assert_eq!(
3376            tail, head,
3377            "the window test cannot be exact for every duration, so the guarantee is \
3378             geometric: a stroke at progress 1.0 has zero length and paints nothing"
3379        );
3380    }
3381
3382    #[test]
3383    fn a_zero_or_negative_duration_disables_the_effect_entirely() {
3384        let mut c = cfg();
3385        c.duration = 0.0;
3386        assert_eq!(burst_progress(&c, 1.0), None);
3387        c.duration = -1.0;
3388        assert_eq!(burst_progress(&c, 1.0), None);
3389    }
3390
3391    #[test]
3392    fn the_stroke_has_zero_length_at_both_ends_of_its_own_window() {
3393        let (tail, head) = burst_stroke_span(0.0, 0.0);
3394        assert_eq!(tail, head, "at the start the head has not left the tail");
3395        let (tail, head) = burst_stroke_span(1.0, 0.0);
3396        assert_eq!(tail, head, "at the end the tail has caught the head");
3397    }
3398
3399    #[test]
3400    fn the_head_reaches_the_far_end_halfway_through_while_the_tail_waits() {
3401        let (tail, head) = burst_stroke_span(0.5, 0.0);
3402        assert!((head - 1.0).abs() < 1e-6, "head at the far end, got {head}");
3403        assert_eq!(tail, 0.0, "tail has not started");
3404    }
3405
3406    #[test]
3407    fn the_head_never_overtakes_the_far_end_nor_the_tail_the_head() {
3408        for step in 0..=100 {
3409            let p = step as f32 / 100.0;
3410            for phase in [0.0_f32, 0.15, 0.4] {
3411                let (tail, head) = burst_stroke_span(p, phase);
3412                assert!((0.0..=1.0).contains(&head), "head {head} at p={p}");
3413                assert!(tail <= head + 1e-6, "tail {tail} past head {head} at p={p}");
3414            }
3415        }
3416    }
3417
3418    #[test]
3419    fn a_phase_delays_the_stroke_without_letting_it_outlive_the_window() {
3420        let (tail, head) = burst_stroke_span(0.3, 0.4);
3421        assert_eq!(tail, head, "a phased stroke has not started at p=0.3");
3422        let (tail, head) = burst_stroke_span(1.0, 0.4);
3423        assert_eq!(tail, head, "a phased stroke still ends with the window");
3424    }
3425}
3426
3427#[cfg(test)]
3428mod merge_contract_tests {
3429    use super::*;
3430
3431    fn bucket() -> AnimatedProperties {
3432        AnimatedProperties::default()
3433    }
3434
3435    #[test]
3436    fn opacity_and_scale_multiply_across_buckets() {
3437        let mut props = bucket();
3438        props.opacity = 0.5;
3439        props.scale_x = 2.0;
3440        props.scale_y = 2.0;
3441
3442        let mut other = bucket();
3443        other.opacity = 0.8;
3444        other.scale_x = 1.5;
3445        other.scale_y = 1.5;
3446        props.merge(&other);
3447
3448        assert!((props.opacity - 0.4).abs() < 1e-6, "got {}", props.opacity);
3449        assert!((props.scale_x - 3.0).abs() < 1e-6, "got {}", props.scale_x);
3450        assert!((props.scale_y - 3.0).abs() < 1e-6, "got {}", props.scale_y);
3451    }
3452
3453    #[test]
3454    fn translation_and_rotation_add_across_buckets() {
3455        let mut props = bucket();
3456        props.translate_x = 10.0;
3457        props.translate_y = -4.0;
3458        props.rotation = 30.0;
3459
3460        let mut other = bucket();
3461        other.translate_x = 5.0;
3462        other.translate_y = 4.0;
3463        other.rotation = 15.0;
3464        props.merge(&other);
3465
3466        assert!((props.translate_x - 15.0).abs() < 1e-6);
3467        assert!((props.translate_y - 0.0).abs() < 1e-6);
3468        assert!((props.rotation - 45.0).abs() < 1e-6);
3469    }
3470
3471    #[test]
3472    fn draw_offset_adds_across_buckets_like_translation() {
3473        let mut props = bucket();
3474        props.draw_offset = 0.2;
3475
3476        let mut other = bucket();
3477        other.draw_offset = 0.1;
3478        props.merge(&other);
3479
3480        assert!(
3481            (props.draw_offset - 0.3).abs() < 1e-6,
3482            "got {}",
3483            props.draw_offset
3484        );
3485    }
3486
3487    #[test]
3488    fn a_neutral_value_in_a_composing_property_is_a_no_op_by_arithmetic() {
3489        let mut props = bucket();
3490        props.opacity = 0.5;
3491        props.scale_x = 2.0;
3492        props.translate_x = 10.0;
3493
3494        let mut other = bucket();
3495        other.opacity = 1.0;
3496        other.scale_x = 1.0;
3497        other.translate_x = 0.0;
3498        props.merge(&other);
3499
3500        assert!(
3501            (props.opacity - 0.5).abs() < 1e-6
3502                && (props.scale_x - 2.0).abs() < 1e-6
3503                && (props.translate_x - 10.0).abs() < 1e-6,
3504            "1 is the identity for a product and 0 for a sum, so skipping a neutral value and \
3505             applying it are the same answer — the guard here is an optimisation, not a rule"
3506        );
3507    }
3508
3509    #[test]
3510    fn a_later_bucket_can_take_blur_back_to_zero() {
3511        let mut props = bucket();
3512        props.blur = 5.0;
3513
3514        let mut other = bucket();
3515        other.blur = 0.0;
3516        props.merge(&other);
3517
3518        assert_eq!(
3519            props.blur, 0.0,
3520            "blur is last-wins, not a product, so zero is a value and not an absence — guarding \
3521             on `> 0.001` left an element blurred for the rest of the scene"
3522        );
3523    }
3524
3525    #[test]
3526    fn a_bucket_that_never_touched_blur_leaves_an_earlier_one_alone() {
3527        let mut props = bucket();
3528        props.blur = 5.0;
3529        props.blur_x = 3.0;
3530        props.blur_y = 2.0;
3531
3532        props.merge(&bucket());
3533
3534        assert_eq!(
3535            (props.blur, props.blur_x, props.blur_y),
3536            (5.0, 3.0, 2.0),
3537            "the resting value is negative precisely so that `not animated` and `animated to \
3538             zero` are two different things"
3539        );
3540    }
3541}