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}