Skip to main content

gpui/
spring.rs

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/// The physical parameters of a damped harmonic oscillator.
9///
10/// `stiffness` and `mass` must be finite and positive. `damping` must be finite
11/// and non-negative.
12#[derive(Clone, Copy, Debug, PartialEq)]
13pub struct SpringConfig {
14    /// The spring stiffness, conventionally written as $k$.
15    pub stiffness: f32,
16    /// The viscous damping coefficient, conventionally written as $c$.
17    pub damping: f32,
18    /// The moving mass, conventionally written as $m$.
19    pub mass: f32,
20}
21
22impl SpringConfig {
23    /// Creates a spring from its physical parameters.
24    pub const fn new(stiffness: f32, damping: f32, mass: f32) -> Self {
25        Self {
26            stiffness,
27            damping,
28            mass,
29        }
30    }
31
32    /// Returns the natural angular frequency and damping ratio $(\omega_0, \zeta)$.
33    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    /// Advances a spring toward a target that remains fixed for `delta_time`.
40    ///
41    /// This analytic step is independent of frame rate and preserves velocity,
42    /// allowing an interrupted spring to be retargeted without restarting it.
43    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    /// Advances a spring toward a target moving at a constant velocity.
54    ///
55    /// A first-order hold avoids the frame-rate-dependent lag introduced by
56    /// treating a dragged target as stationary between frames.
57    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    /// Returns the exact state-transition matrix for a constant target.
83    ///
84    /// Materializing this matrix is useful when many springs share the same
85    /// configuration and frame delta. A matrix must not be reused when the
86    /// frame delta changes.
87    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    /// Tests both displacement and velocity against a positional tolerance.
143    ///
144    /// Velocity is compared with `epsilon * natural_frequency`, giving it the
145    /// corresponding animated-units-per-second scale.
146    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    /// Returns a conservative time after which the spring remains settled.
155    ///
156    /// An undamped spring has no finite settling time and returns
157    /// [`Duration::MAX`].
158    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/// The instantaneous position and velocity of a spring.
251#[derive(Clone, Copy, Debug, Default, PartialEq)]
252pub struct SpringState {
253    /// The current value in the animated unit.
254    pub position: f32,
255    /// The current value's change per second.
256    pub velocity: f32,
257}
258
259/// A value that can be targeted by a one-dimensional spring.
260///
261/// Implementations may project the spring coordinate into a richer output,
262/// allowing a discrete state or a path through a multidimensional value to be
263/// driven by one spring.
264pub trait SpringTarget: 'static {
265    /// The value supplied to the spring animator.
266    type Output;
267
268    /// Returns the target in the spring's coordinate space.
269    fn target(&self) -> f32;
270
271    /// Projects a spring coordinate into the animated output.
272    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/// A potentially overshooting coordinate within an animation.
324///
325/// Phases are not restricted to 0..1. Multi-stage animations can assign each
326/// stage its own coordinate and interpolate over ranges such as 1..=2.
327#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
328pub struct AnimationPhase(
329    /// The unbounded phase coordinate.
330    pub f32,
331);
332
333impl AnimationPhase {
334    /// Restricts this phase to the bounds of a range.
335    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    /// Interpolates between values using 0 and 1 as their phase coordinates.
341    pub fn interpolate<T: Interpolate>(self, from: T, to: T) -> T {
342        T::interpolate(from, to, self.0)
343    }
344
345    /// Interpolates between values without extrapolating beyond 0 and 1.
346    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    /// Interpolates between values assigned to arbitrary phase coordinates.
351    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    /// Interpolates over arbitrary phase coordinates without extrapolating.
367    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
407/// A value that supports linear interpolation and extrapolation.
408pub trait Interpolate: Sized {
409    /// Resolves the value at `phase`, where 0 is `from` and 1 is `to`.
410    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/// Controls how a spring advances and resolves its presentation value.
455#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
456pub enum SpringPlayback {
457    /// Advances toward the latest target, preserving velocity across retargets.
458    #[default]
459    Running,
460    /// Holds the current position and velocity until playback resumes.
461    Paused,
462    /// Holds the current position and discards velocity.
463    Stopped,
464    /// Snaps to the latest target and discards velocity.
465    Completed,
466    /// Returns to the initial value and discards velocity.
467    Cancelled,
468}
469
470/// A stateful spring animation targeting a value or projected path.
471#[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    /// Creates a spring animation builder.
482    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    /// Sets the value or path targeted by this spring.
493    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    /// Sets the settling tolerance in the target's scalar coordinate space.
513    pub fn with_epsilon(mut self, epsilon: f32) -> Self {
514        self.epsilon = epsilon;
515        self
516    }
517
518    /// Sets how the spring advances or resolves its current value.
519    pub fn playback(mut self, playback: SpringPlayback) -> Self {
520        self.playback = playback;
521        self
522    }
523}
524
525impl<T: SpringTarget> SpringAnimation<T> {
526    /// Sets the coordinate used when this element has no prior spring state.
527    pub fn from(mut self, initial: T) -> Self {
528        self.initial = Some(initial.target());
529        self
530    }
531}
532
533/// Adapts a spring starting at zero with no velocity to GPUI's duration-based easing API.
534///
535/// The returned easing can overshoot the normalized 0..1 output range. Retargeting
536/// this duration-based form restarts the spring; use [`SpringConfig::step`] when
537/// preserving velocity matters.
538pub 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}