1use std::{ops::RangeInclusive, time::Duration};
2
3use crate::{Hsla, Pixels, Rems, Rgba};
4
5const CRITICAL_DAMPING_TOLERANCE: f32 = 1e-4;
6const DEFAULT_SPRING_EPSILON: f32 = 0.001;
7
8#[derive(Clone, Copy, Debug, PartialEq)]
13pub struct SpringConfig {
14 pub stiffness: f32,
16 pub damping: f32,
18 pub mass: f32,
20}
21
22impl SpringConfig {
23 pub const fn new(stiffness: f32, damping: f32, mass: f32) -> Self {
25 Self {
26 stiffness,
27 damping,
28 mass,
29 }
30 }
31
32 pub fn canonical(&self) -> (f32, f32) {
34 let natural_frequency = (self.stiffness / self.mass).sqrt();
35 let damping_ratio = self.damping / (2.0 * (self.stiffness * self.mass).sqrt());
36 (natural_frequency, damping_ratio)
37 }
38
39 pub fn step(&self, state: SpringState, target: f32, delta_time: f32) -> SpringState {
44 let propagator = self.propagator(delta_time);
45 let displacement = state.position - target;
46
47 SpringState {
48 position: target + propagator[0][0] * displacement + propagator[0][1] * state.velocity,
49 velocity: propagator[1][0] * displacement + propagator[1][1] * state.velocity,
50 }
51 }
52
53 pub fn step_ramp(
58 &self,
59 state: SpringState,
60 target: f32,
61 target_velocity: f32,
62 delta_time: f32,
63 ) -> SpringState {
64 let (natural_frequency, damping_ratio) = self.canonical();
65 let steady_state_lag = -2.0 * damping_ratio * target_velocity / natural_frequency;
66 let displacement = state.position - target - steady_state_lag;
67 let velocity = state.velocity - target_velocity;
68 let propagator = self.propagator(delta_time);
69 let target = target + target_velocity * delta_time;
70
71 SpringState {
72 position: target
73 + steady_state_lag
74 + propagator[0][0] * displacement
75 + propagator[0][1] * velocity,
76 velocity: target_velocity
77 + propagator[1][0] * displacement
78 + propagator[1][1] * velocity,
79 }
80 }
81
82 pub fn propagator(&self, delta_time: f32) -> [[f32; 2]; 2] {
88 let (natural_frequency, damping_ratio) = self.canonical();
89
90 if damping_ratio < 1.0 - CRITICAL_DAMPING_TOLERANCE {
91 let decay = damping_ratio * natural_frequency;
92 let damped_frequency = natural_frequency * (1.0 - damping_ratio * damping_ratio).sqrt();
93 let exponential = (-decay * delta_time).exp();
94 let (sine, cosine) = (damped_frequency * delta_time).sin_cos();
95 let sine_over_frequency = sine / damped_frequency;
96
97 [
98 [
99 exponential * (cosine + decay * sine_over_frequency),
100 exponential * sine_over_frequency,
101 ],
102 [
103 -exponential * natural_frequency * natural_frequency * sine_over_frequency,
104 exponential * (cosine - decay * sine_over_frequency),
105 ],
106 ]
107 } else if damping_ratio > 1.0 + CRITICAL_DAMPING_TOLERANCE {
108 let root = (damping_ratio * damping_ratio - 1.0).sqrt();
109 let root_sum = damping_ratio + root;
110 let slow_root = -natural_frequency / root_sum;
111 let fast_root = -natural_frequency * root_sum;
112 let denominator = slow_root - fast_root;
113 let slow_exponential = (slow_root * delta_time).exp();
114 let fast_exponential = (fast_root * delta_time).exp();
115
116 [
117 [
118 (-fast_root * slow_exponential + slow_root * fast_exponential) / denominator,
119 (slow_exponential - fast_exponential) / denominator,
120 ],
121 [
122 slow_root * fast_root * (fast_exponential - slow_exponential) / denominator,
123 (slow_root * slow_exponential - fast_root * fast_exponential) / denominator,
124 ],
125 ]
126 } else {
127 let exponential = (-natural_frequency * delta_time).exp();
128
129 [
130 [
131 exponential * (1.0 + natural_frequency * delta_time),
132 exponential * delta_time,
133 ],
134 [
135 -exponential * natural_frequency * natural_frequency * delta_time,
136 exponential * (1.0 - natural_frequency * delta_time),
137 ],
138 ]
139 }
140 }
141
142 pub fn is_settled(&self, state: SpringState, target: f32, epsilon: f32) -> bool {
147 let (natural_frequency, _) = self.canonical();
148 epsilon.is_finite()
149 && epsilon >= 0.0
150 && (state.position - target).abs() <= epsilon
151 && state.velocity.abs() <= epsilon * natural_frequency
152 }
153
154 pub fn settle_time(&self, state: SpringState, target: f32, epsilon: f32) -> Duration {
159 let displacement = state.position - target;
160 if displacement == 0.0 && state.velocity == 0.0 {
161 return Duration::ZERO;
162 }
163
164 let (natural_frequency, damping_ratio) = self.canonical();
165 if !natural_frequency.is_finite()
166 || natural_frequency <= 0.0
167 || !damping_ratio.is_finite()
168 || damping_ratio <= 0.0
169 || !epsilon.is_finite()
170 || epsilon <= 0.0
171 {
172 return Duration::MAX;
173 }
174
175 let velocity_threshold = epsilon * natural_frequency;
176
177 if damping_ratio < 1.0 - CRITICAL_DAMPING_TOLERANCE {
178 let decay = damping_ratio * natural_frequency;
179 let damped_frequency = natural_frequency * (1.0 - damping_ratio * damping_ratio).sqrt();
180 let sine_coefficient = (state.velocity + decay * displacement) / damped_frequency;
181 let position_envelope = displacement.hypot(sine_coefficient);
182 let velocity_cosine = damped_frequency * sine_coefficient - decay * displacement;
183 let velocity_sine = -damped_frequency * displacement - decay * sine_coefficient;
184 let velocity_envelope = velocity_cosine.hypot(velocity_sine);
185
186 find_settle_time(
187 epsilon,
188 velocity_threshold,
189 0.0,
190 natural_frequency,
191 move |time| {
192 let exponential = (-decay * time).exp();
193 (
194 position_envelope * exponential,
195 velocity_envelope * exponential,
196 )
197 },
198 )
199 } else if damping_ratio > 1.0 + CRITICAL_DAMPING_TOLERANCE {
200 let root = (damping_ratio * damping_ratio - 1.0).sqrt();
201 let root_sum = damping_ratio + root;
202 let slow_root = -natural_frequency / root_sum;
203 let fast_root = -natural_frequency * root_sum;
204 let denominator = slow_root - fast_root;
205 let slow_coefficient = (state.velocity - fast_root * displacement) / denominator;
206 let fast_coefficient = (slow_root * displacement - state.velocity) / denominator;
207
208 find_settle_time(
209 epsilon,
210 velocity_threshold,
211 0.0,
212 natural_frequency,
213 move |time| {
214 let slow_term = slow_coefficient.abs() * (slow_root * time).exp();
215 let fast_term = fast_coefficient.abs() * (fast_root * time).exp();
216 (
217 slow_term + fast_term,
218 slow_root.abs() * slow_term + fast_root.abs() * fast_term,
219 )
220 },
221 )
222 } else {
223 let linear_coefficient = state.velocity + natural_frequency * displacement;
224 let position_constant = displacement.abs();
225 let position_linear = linear_coefficient.abs();
226 let velocity_constant = (linear_coefficient - natural_frequency * displacement).abs();
227 let velocity_linear = natural_frequency * linear_coefficient.abs();
228 let position_decay_start =
229 envelope_decay_start(position_constant, position_linear, natural_frequency);
230 let velocity_decay_start =
231 envelope_decay_start(velocity_constant, velocity_linear, natural_frequency);
232
233 find_settle_time(
234 epsilon,
235 velocity_threshold,
236 position_decay_start.max(velocity_decay_start),
237 natural_frequency,
238 move |time| {
239 let exponential = (-natural_frequency * time).exp();
240 (
241 (position_constant + position_linear * time) * exponential,
242 (velocity_constant + velocity_linear * time) * exponential,
243 )
244 },
245 )
246 }
247 }
248}
249
250#[derive(Clone, Copy, Debug, Default, PartialEq)]
252pub struct SpringState {
253 pub position: f32,
255 pub velocity: f32,
257}
258
259pub trait SpringTarget: 'static {
265 type Output;
267
268 fn target(&self) -> f32;
270
271 fn resolve(&self, value: f32) -> Self::Output;
273}
274
275impl SpringTarget for f32 {
276 type Output = f32;
277
278 fn target(&self) -> f32 {
279 *self
280 }
281
282 fn resolve(&self, value: f32) -> Self::Output {
283 value
284 }
285}
286
287impl SpringTarget for Pixels {
288 type Output = Pixels;
289
290 fn target(&self) -> f32 {
291 self.as_f32()
292 }
293
294 fn resolve(&self, value: f32) -> Self::Output {
295 Pixels::from(value)
296 }
297}
298
299impl SpringTarget for Rems {
300 type Output = Rems;
301
302 fn target(&self) -> f32 {
303 self.0
304 }
305
306 fn resolve(&self, value: f32) -> Self::Output {
307 Rems(value)
308 }
309}
310
311impl SpringTarget for bool {
312 type Output = AnimationPhase;
313
314 fn target(&self) -> f32 {
315 if *self { 1.0 } else { 0.0 }
316 }
317
318 fn resolve(&self, value: f32) -> Self::Output {
319 AnimationPhase(value)
320 }
321}
322
323#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
328pub struct AnimationPhase(
329 pub f32,
331);
332
333impl AnimationPhase {
334 pub fn clamp(self, range: RangeInclusive<f32>) -> Self {
336 let (first, second) = range.into_inner();
337 Self(self.0.clamp(first.min(second), first.max(second)))
338 }
339
340 pub fn interpolate<T: Interpolate>(self, from: T, to: T) -> T {
342 T::interpolate(from, to, self.0)
343 }
344
345 pub fn interpolate_clamped<T: Interpolate>(self, from: T, to: T) -> T {
347 T::interpolate(from, to, self.0.clamp(0.0, 1.0))
348 }
349
350 pub fn interpolate_between<T: Interpolate>(
352 self,
353 range: RangeInclusive<f32>,
354 from: T,
355 to: T,
356 ) -> T {
357 let (start, end) = range.into_inner();
358 let phase = if start == end {
359 if self.0 < start { 0.0 } else { 1.0 }
360 } else {
361 (self.0 - start) / (end - start)
362 };
363 T::interpolate(from, to, phase)
364 }
365
366 pub fn interpolate_between_clamped<T: Interpolate>(
368 self,
369 range: RangeInclusive<f32>,
370 from: T,
371 to: T,
372 ) -> T {
373 let (start, end) = range.into_inner();
374 let phase = if start == end {
375 if self.0 < start { 0.0 } else { 1.0 }
376 } else {
377 ((self.0 - start) / (end - start)).clamp(0.0, 1.0)
378 };
379 T::interpolate(from, to, phase)
380 }
381}
382
383impl From<f32> for AnimationPhase {
384 fn from(value: f32) -> Self {
385 Self(value)
386 }
387}
388
389impl From<bool> for AnimationPhase {
390 fn from(value: bool) -> Self {
391 Self(if value { 1.0 } else { 0.0 })
392 }
393}
394
395impl SpringTarget for AnimationPhase {
396 type Output = AnimationPhase;
397
398 fn target(&self) -> f32 {
399 self.0
400 }
401
402 fn resolve(&self, value: f32) -> Self::Output {
403 Self(value)
404 }
405}
406
407pub trait Interpolate: Sized {
409 fn interpolate(from: Self, to: Self, phase: f32) -> Self;
411}
412
413impl Interpolate for f32 {
414 fn interpolate(from: Self, to: Self, phase: f32) -> Self {
415 from + (to - from) * phase
416 }
417}
418
419impl Interpolate for Pixels {
420 fn interpolate(from: Self, to: Self, phase: f32) -> Self {
421 from + (to - from) * phase
422 }
423}
424
425impl Interpolate for Rems {
426 fn interpolate(from: Self, to: Self, phase: f32) -> Self {
427 from + (to - from) * phase
428 }
429}
430
431impl Interpolate for Rgba {
432 fn interpolate(from: Self, to: Self, phase: f32) -> Self {
433 Self {
434 r: f32::interpolate(from.r, to.r, phase),
435 g: f32::interpolate(from.g, to.g, phase),
436 b: f32::interpolate(from.b, to.b, phase),
437 a: f32::interpolate(from.a, to.a, phase),
438 }
439 }
440}
441
442impl Interpolate for Hsla {
443 fn interpolate(from: Self, to: Self, phase: f32) -> Self {
444 let hue_delta = (to.h - from.h + 0.5).rem_euclid(1.0) - 0.5;
445 Self {
446 h: (from.h + hue_delta * phase).rem_euclid(1.0),
447 s: f32::interpolate(from.s, to.s, phase),
448 l: f32::interpolate(from.l, to.l, phase),
449 a: f32::interpolate(from.a, to.a, phase),
450 }
451 }
452}
453
454#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
456pub enum SpringPlayback {
457 #[default]
459 Running,
460 Paused,
462 Stopped,
464 Completed,
466 Cancelled,
468}
469
470#[derive(Clone, Debug)]
472pub struct SpringAnimation<T = ()> {
473 pub(crate) config: SpringConfig,
474 pub(crate) target: T,
475 pub(crate) epsilon: f32,
476 pub(crate) initial: Option<f32>,
477 pub(crate) playback: SpringPlayback,
478}
479
480impl SpringAnimation<()> {
481 pub fn new(config: SpringConfig) -> Self {
483 Self {
484 config,
485 target: (),
486 epsilon: DEFAULT_SPRING_EPSILON,
487 initial: None,
488 playback: SpringPlayback::Running,
489 }
490 }
491
492 pub fn to<T: SpringTarget>(self, target: T) -> SpringAnimation<T> {
494 let SpringAnimation {
495 config,
496 target: (),
497 epsilon,
498 initial,
499 playback,
500 } = self;
501 SpringAnimation {
502 config,
503 target,
504 epsilon,
505 initial,
506 playback,
507 }
508 }
509}
510
511impl<T> SpringAnimation<T> {
512 pub fn with_epsilon(mut self, epsilon: f32) -> Self {
514 self.epsilon = epsilon;
515 self
516 }
517
518 pub fn playback(mut self, playback: SpringPlayback) -> Self {
520 self.playback = playback;
521 self
522 }
523}
524
525impl<T: SpringTarget> SpringAnimation<T> {
526 pub fn from(mut self, initial: T) -> Self {
528 self.initial = Some(initial.target());
529 self
530 }
531}
532
533pub fn sampled_easing(config: SpringConfig, epsilon: f32) -> (Duration, impl Fn(f32) -> f32) {
539 let initial_state = SpringState {
540 position: 0.0,
541 velocity: 0.0,
542 };
543 let duration = config.settle_time(initial_state, 1.0, epsilon);
544 let duration_seconds = duration.as_secs_f32();
545
546 (duration, move |progress| {
547 if progress <= 0.0 {
548 0.0
549 } else if progress >= 1.0 {
550 1.0
551 } else {
552 config
553 .step(initial_state, 1.0, progress * duration_seconds)
554 .position
555 }
556 })
557}
558
559fn envelope_decay_start(constant: f32, linear: f32, decay: f32) -> f32 {
560 if linear == 0.0 {
561 0.0
562 } else {
563 (1.0 / decay - constant / linear).max(0.0)
564 }
565}
566
567fn find_settle_time(
568 position_threshold: f32,
569 velocity_threshold: f32,
570 decay_start: f32,
571 natural_frequency: f32,
572 envelope: impl Fn(f32) -> (f32, f32),
573) -> Duration {
574 let is_below_threshold = |time| {
575 let (position, velocity) = envelope(time);
576 position <= position_threshold && velocity <= velocity_threshold
577 };
578
579 if is_below_threshold(decay_start) {
580 return duration_from_secs(decay_start);
581 }
582
583 let mut lower_bound = decay_start;
584 let mut upper_bound = decay_start.max(natural_frequency.recip());
585 while !is_below_threshold(upper_bound) {
586 lower_bound = upper_bound;
587 upper_bound *= 2.0;
588 if !upper_bound.is_finite() {
589 return Duration::MAX;
590 }
591 }
592
593 for _ in 0..32 {
594 let midpoint = (lower_bound + upper_bound) / 2.0;
595 if is_below_threshold(midpoint) {
596 upper_bound = midpoint;
597 } else {
598 lower_bound = midpoint;
599 }
600 }
601
602 duration_from_secs(upper_bound)
603}
604
605fn duration_from_secs(seconds: f32) -> Duration {
606 if !seconds.is_finite() || seconds >= Duration::MAX.as_secs_f32() {
607 Duration::MAX
608 } else if seconds <= 0.0 {
609 Duration::ZERO
610 } else {
611 Duration::from_secs_f32(seconds)
612 }
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618
619 const EPSILON: f32 = 1e-4;
620
621 #[test]
622 fn spring_targets_resolve_typed_outputs() {
623 assert_eq!(12.0_f32.target(), 12.0);
624 assert_eq!(12.0_f32.resolve(14.0), 14.0);
625 assert_eq!(Pixels::from(12.0).target(), 12.0);
626 assert_eq!(Pixels::from(12.0).resolve(14.0), Pixels::from(14.0));
627 assert_eq!(false.target(), 0.0);
628 assert_eq!(true.target(), 1.0);
629 assert_eq!(true.resolve(1.25), AnimationPhase(1.25));
630 }
631
632 #[test]
633 fn animation_phases_interpolate_over_arbitrary_ranges() {
634 let phase = AnimationPhase(1.5);
635 assert_eq!(phase.interpolate_between(1.0..=2.0, 10.0, 20.0), 15.0);
636 assert_eq!(
637 phase.interpolate_between_clamped(2.0..=3.0, 10.0, 20.0),
638 10.0
639 );
640 assert_eq!(
641 AnimationPhase(3.5).interpolate_between(2.0..=3.0, 10.0, 20.0),
642 25.0
643 );
644 }
645
646 #[test]
647 fn hsla_interpolation_takes_the_shortest_hue_path() {
648 let from = Hsla {
649 h: 0.9,
650 s: 0.5,
651 l: 0.5,
652 a: 1.0,
653 };
654 let to = Hsla {
655 h: 0.1,
656 s: 1.0,
657 l: 0.75,
658 a: 0.5,
659 };
660 let result = AnimationPhase(0.5).interpolate(from, to);
661
662 assert!(result.h < EPSILON || (1.0 - result.h) < EPSILON);
663 assert!((result.s - 0.75).abs() < EPSILON);
664 assert!((result.l - 0.625).abs() < EPSILON);
665 assert!((result.a - 0.75).abs() < EPSILON);
666 }
667
668 #[test]
669 fn propagators_compose_and_have_expected_determinant() {
670 for damping_ratio in [0.4, 1.0, 1.5] {
671 let natural_frequency = 12.0;
672 let config = SpringConfig::new(
673 natural_frequency * natural_frequency,
674 2.0 * damping_ratio * natural_frequency,
675 1.0,
676 );
677 let first = config.propagator(0.013);
678 let second = config.propagator(0.021);
679 let combined = multiply(second, first);
680 let direct = config.propagator(0.034);
681
682 for row in 0..2 {
683 for column in 0..2 {
684 assert!(
685 (combined[row][column] - direct[row][column]).abs() < 2e-4,
686 "{damping_ratio}: {combined:?} != {direct:?}"
687 );
688 }
689 }
690
691 let determinant = direct[0][0] * direct[1][1] - direct[0][1] * direct[1][0];
692 let expected = (-2.0 * damping_ratio * natural_frequency * 0.034).exp();
693 assert!((determinant - expected).abs() < 2e-4);
694 }
695 }
696
697 #[test]
698 fn step_preserves_semigroup_for_every_damping_regime() {
699 let state = SpringState {
700 position: -3.0,
701 velocity: 5.0,
702 };
703 for damping in [4.0, 20.0, 40.0] {
704 let config = SpringConfig::new(100.0, damping, 1.0);
705 let stepped = config.step(config.step(state, 7.0, 0.013), 7.0, 0.021);
706 let direct = config.step(state, 7.0, 0.034);
707
708 assert!((stepped.position - direct.position).abs() < 2e-4);
709 assert!((stepped.velocity - direct.velocity).abs() < 2e-4);
710 }
711 }
712
713 #[test]
714 fn ramp_tracks_steady_state_lag() {
715 let natural_frequency = 10.0;
716 let damping_ratio = 0.8;
717 let target_velocity = 3.0;
718 let config = SpringConfig::new(
719 natural_frequency * natural_frequency,
720 2.0 * damping_ratio * natural_frequency,
721 1.0,
722 );
723 let lag = -2.0 * damping_ratio * target_velocity / natural_frequency;
724 let state = SpringState {
725 position: lag,
726 velocity: target_velocity,
727 };
728 let next = config.step_ramp(state, 0.0, target_velocity, 0.25);
729
730 assert!((next.position - (target_velocity * 0.25 + lag)).abs() < EPSILON);
731 assert!((next.velocity - target_velocity).abs() < EPSILON);
732 }
733
734 #[test]
735 fn settling_requires_low_velocity() {
736 let config = SpringConfig::new(100.0, 10.0, 1.0);
737 assert!(!config.is_settled(
738 SpringState {
739 position: 1.0,
740 velocity: 1.0,
741 },
742 1.0,
743 0.01,
744 ));
745 assert!(config.is_settled(
746 SpringState {
747 position: 1.005,
748 velocity: 0.05,
749 },
750 1.0,
751 0.01,
752 ));
753 }
754
755 #[test]
756 fn settle_time_is_conservative_for_every_damping_regime() {
757 let initial_state = SpringState {
758 position: -2.0,
759 velocity: 4.0,
760 };
761 for damping in [4.0, 20.0, 40.0] {
762 let config = SpringConfig::new(100.0, damping, 1.0);
763 let duration = config.settle_time(initial_state, 3.0, 0.001);
764 assert_ne!(duration, Duration::MAX);
765
766 for additional_time in [0.0, 0.1, 1.0] {
767 let state =
768 config.step(initial_state, 3.0, duration.as_secs_f32() + additional_time);
769 assert!(
770 config.is_settled(state, 3.0, 0.001),
771 "{damping}: {duration:?} produced {state:?}"
772 );
773 }
774 }
775 }
776
777 #[test]
778 fn settle_time_accounts_for_motion_outside_an_instantaneous_tolerance() {
779 let config = SpringConfig::new(100.0, 2.0, 1.0);
780 let state = SpringState {
781 position: 1.125,
782 velocity: 1.25,
783 };
784 assert!(config.is_settled(state, 1.0, 0.125));
785
786 let duration = config.settle_time(state, 1.0, 0.125);
787 assert!(duration > Duration::ZERO);
788 assert!(config.is_settled(config.step(state, 1.0, duration.as_secs_f32()), 1.0, 0.125,));
789 }
790
791 #[test]
792 fn undamped_spring_never_settles() {
793 let config = SpringConfig::new(100.0, 0.0, 1.0);
794 assert_eq!(
795 config.settle_time(
796 SpringState {
797 position: 0.0,
798 velocity: 0.0,
799 },
800 1.0,
801 0.001,
802 ),
803 Duration::MAX
804 );
805 }
806
807 #[test]
808 fn sampled_easing_has_exact_endpoints_and_can_overshoot() {
809 let config = SpringConfig::new(100.0, 6.0, 1.0);
810 let (duration, easing) = sampled_easing(config, 0.001);
811
812 assert_ne!(duration, Duration::MAX);
813 assert_eq!(easing(0.0), 0.0);
814 assert_eq!(easing(1.0), 1.0);
815 assert!((1..100).any(|step| easing(step as f32 / 100.0) > 1.0));
816 }
817
818 fn multiply(left: [[f32; 2]; 2], right: [[f32; 2]; 2]) -> [[f32; 2]; 2] {
819 [
820 [
821 left[0][0] * right[0][0] + left[0][1] * right[1][0],
822 left[0][0] * right[0][1] + left[0][1] * right[1][1],
823 ],
824 [
825 left[1][0] * right[0][0] + left[1][1] * right[1][0],
826 left[1][0] * right[0][1] + left[1][1] * right[1][1],
827 ],
828 ]
829 }
830}