Skip to main content

aura_anim_core/
spring.rs

1//! Spring-based animations with independently configured physical channels.
2
3use std::{fmt, sync::Arc};
4
5use crate::{
6    timing::Duration,
7    traits::{Animatable, Animation, AnimationState},
8};
9
10/// Physical parameters used by a [`Spring`] channel.
11///
12/// Invalid parameters are sanitized when a spring is created: stiffness and
13/// damping are clamped to non-negative finite values, mass is clamped to a
14/// positive finite value, and epsilon falls back to the default threshold.
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub struct SpringConfig {
17    /// Restoring force applied toward the target.
18    pub stiffness: f32,
19    /// Resistance applied against the current velocity.
20    pub damping: f32,
21    /// Inertial mass used by the spring simulation.
22    pub mass: f32,
23    /// Position and velocity threshold used to detect completion.
24    pub epsilon: f32,
25}
26
27impl SpringConfig {
28    /// Creates a spring configuration with default mass and completion epsilon.
29    #[must_use]
30    pub const fn new(stiffness: f32, damping: f32) -> Self {
31        Self {
32            stiffness,
33            damping,
34            mass: 1.0,
35            epsilon: 0.001,
36        }
37    }
38
39    /// Sets the inertial mass.
40    #[must_use]
41    pub const fn with_mass(mut self, mass: f32) -> Self {
42        self.mass = mass;
43        self
44    }
45
46    /// Sets the position and velocity completion threshold.
47    #[must_use]
48    pub const fn with_epsilon(mut self, epsilon: f32) -> Self {
49        self.epsilon = epsilon;
50        self
51    }
52
53    /// Returns a responsive preset suited to direct manipulation and controls.
54    #[must_use]
55    pub const fn snappy() -> Self {
56        Self::new(420.0, 28.0)
57    }
58
59    fn sanitized(self) -> Self {
60        let defaults = Self::default();
61        Self {
62            stiffness: finite_non_negative(self.stiffness),
63            damping: finite_non_negative(self.damping),
64            mass: if self.mass.is_finite() && self.mass > 0.0 {
65                self.mass
66            } else {
67                f32::EPSILON
68            },
69            epsilon: if self.epsilon.is_finite() && self.epsilon > 0.0 {
70                self.epsilon
71            } else {
72                defaults.epsilon
73            },
74        }
75    }
76}
77
78impl Default for SpringConfig {
79    fn default() -> Self {
80        Self::new(220.0, 24.0)
81    }
82}
83
84#[derive(Debug, Clone, Copy)]
85struct SpringChannel {
86    position: f32,
87    velocity: f32,
88    config: SpringConfig,
89}
90
91impl SpringChannel {
92    fn new(config: SpringConfig) -> Self {
93        Self {
94            position: 0.0,
95            velocity: 0.0,
96            config: config.sanitized(),
97        }
98    }
99
100    fn advance(&mut self, seconds: f64) {
101        if seconds <= 0.0 {
102            return;
103        }
104
105        let position = f64::from(self.position);
106        let velocity = f64::from(self.velocity);
107        let stiffness = f64::from(self.config.stiffness);
108        let damping = f64::from(self.config.damping);
109        let mass = f64::from(self.config.mass);
110        let displacement = position - 1.0;
111        let decay = damping / (2.0 * mass);
112        let natural_frequency_squared = stiffness / mass;
113        let discriminant = decay.mul_add(decay, -natural_frequency_squared);
114        let tolerance = natural_frequency_squared.max(1.0) * 1.0e-10;
115
116        let (next_displacement, next_velocity) = if discriminant < -tolerance {
117            underdamped(displacement, velocity, decay, -discriminant, seconds)
118        } else if discriminant > tolerance {
119            overdamped(displacement, velocity, decay, discriminant, seconds)
120        } else {
121            critically_damped(displacement, velocity, decay, seconds)
122        };
123
124        #[allow(
125            clippy::cast_possible_truncation,
126            reason = "Spring state is stored as f32 to match interpolation progress."
127        )]
128        {
129            self.position = (next_displacement + 1.0) as f32;
130            self.velocity = next_velocity as f32;
131        }
132    }
133
134    fn is_settled(self) -> bool {
135        (1.0 - self.position).abs() <= self.config.epsilon
136            && self.velocity.abs() <= self.config.epsilon
137    }
138
139    fn reset_position(&mut self, position: f32) {
140        self.position = position;
141        self.velocity = 0.0;
142    }
143}
144
145type Compositor<T> = Arc<dyn Fn(&[T]) -> T>;
146
147/// An animation driven by one or more damped spring channels.
148///
149/// [`Spring::new`] creates one channel and preserves the original behavior in
150/// which every field shares the same physical progress. Use
151/// [`Spring::with_channels`] when fields need different stiffness, damping, or
152/// mass. Each channel produces a complete `T`; the compositor selects the
153/// fields owned by each channel.
154///
155/// Channels use the analytic solution of the damped harmonic oscillator. Time
156/// is therefore not discarded for frame intervals greater than 100 ms, and
157/// simulation results are stable across different frame subdivisions.
158///
159/// # Examples
160///
161/// ```
162/// use aura_anim_core::{
163///     spring::{Spring, SpringConfig},
164///     timing::Duration,
165///     traits::Animation,
166/// };
167///
168/// let soft = SpringConfig::new(80.0, 18.0);
169/// let snappy = SpringConfig::new(420.0, 28.0);
170/// let mut spring = Spring::with_channels(
171///     (0.0_f32, 0.0_f32),
172///     (100.0, 1.0),
173///     [soft, snappy],
174///     |outputs| (outputs[0].0, outputs[1].1),
175/// );
176///
177/// spring.tick(Duration::from_millis(100.0));
178/// assert!(spring.value().1 > spring.value().0 / 100.0);
179/// ```
180#[derive(Clone)]
181pub struct Spring<T: Animatable> {
182    from: T,
183    to: T,
184    current: T,
185    outputs: Vec<T>,
186    channels: Vec<SpringChannel>,
187    compose: Compositor<T>,
188    state: AnimationState,
189}
190
191impl<T: Animatable + fmt::Debug> fmt::Debug for Spring<T> {
192    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
193        formatter
194            .debug_struct("Spring")
195            .field("from", &self.from)
196            .field("to", &self.to)
197            .field("current", &self.current)
198            .field("channels", &self.channels)
199            .field("state", &self.state)
200            .finish_non_exhaustive()
201    }
202}
203
204impl<T: Animatable> Spring<T> {
205    /// Creates a running spring animation with one physical channel.
206    ///
207    /// All fields in `T` share this channel. For independently configured
208    /// fields, use [`Spring::with_channels`].
209    #[must_use]
210    pub fn new(from: T, to: T, config: SpringConfig) -> Self {
211        Self::with_channels(from, to, [config], |outputs| outputs[0].clone())
212    }
213
214    /// Creates a running spring with independently configured channels.
215    ///
216    /// Every channel evaluates the full transition from `from` to `to` with
217    /// its own physical state. `compose` must build the final value by
218    /// selecting the fields owned by each channel, in the same order as
219    /// `configs`.
220    ///
221    /// An empty configuration iterator falls back to one default channel.
222    #[must_use]
223    pub fn with_channels(
224        from: T,
225        to: T,
226        configs: impl IntoIterator<Item = SpringConfig>,
227        compose: impl Fn(&[T]) -> T + 'static,
228    ) -> Self {
229        let mut channels: Vec<_> = configs.into_iter().map(SpringChannel::new).collect();
230        if channels.is_empty() {
231            channels.push(SpringChannel::new(SpringConfig::default()));
232        }
233        let outputs = vec![from.clone(); channels.len()];
234
235        Self {
236            current: from.clone(),
237            from,
238            to,
239            outputs,
240            channels,
241            compose: Arc::new(compose),
242            state: AnimationState::Running,
243        }
244    }
245
246    /// Returns the number of independently simulated physical channels.
247    #[must_use]
248    pub fn channel_count(&self) -> usize {
249        self.channels.len()
250    }
251
252    /// Restarts every channel from the current value toward `target`.
253    ///
254    /// Each channel keeps its normalized velocity so interrupted motion
255    /// retains momentum while its position is rebased to the current value.
256    pub fn retarget(&mut self, target: T) {
257        self.from = self.current.clone();
258        self.to = target;
259        for (channel, output) in self.channels.iter_mut().zip(&mut self.outputs) {
260            channel.position = 0.0;
261            *output = self.from.clone();
262        }
263        self.state = AnimationState::Running;
264    }
265
266    fn sample(&mut self) {
267        for (channel, output) in self.channels.iter().zip(&mut self.outputs) {
268            *output = T::extrapolate(&self.from, &self.to, channel.position);
269        }
270        self.current = (self.compose)(&self.outputs);
271    }
272
273    fn integrate(&mut self, delta: Duration) {
274        let seconds = delta.as_secs();
275        for channel in &mut self.channels {
276            channel.advance(seconds);
277        }
278        self.sample();
279
280        if self.channels.iter().copied().all(SpringChannel::is_settled) {
281            self.finish();
282        }
283    }
284}
285
286impl<T: Animatable> Animation<T> for Spring<T> {
287    fn value(&self) -> &T {
288        &self.current
289    }
290
291    fn state(&self) -> AnimationState {
292        self.state
293    }
294
295    fn tick(&mut self, delta: Duration) {
296        if self.state == AnimationState::Running {
297            self.integrate(delta);
298        }
299    }
300
301    fn pause(&mut self) {
302        if self.state == AnimationState::Running {
303            self.state = AnimationState::Paused;
304        }
305    }
306
307    fn resume(&mut self) {
308        if self.state == AnimationState::Paused {
309            self.state = AnimationState::Running;
310        }
311    }
312
313    fn cancel(&mut self) {
314        if matches!(self.state, AnimationState::Running | AnimationState::Paused) {
315            self.state = AnimationState::Canceled;
316        }
317    }
318
319    fn seek(&mut self, progress: f32) {
320        let progress = if progress.is_nan() {
321            0.0
322        } else {
323            progress.clamp(0.0, 1.0)
324        };
325        for channel in &mut self.channels {
326            channel.reset_position(progress);
327        }
328        self.sample();
329    }
330
331    fn finish(&mut self) {
332        for (channel, output) in self.channels.iter_mut().zip(&mut self.outputs) {
333            channel.reset_position(1.0);
334            *output = self.to.clone();
335        }
336        self.current = self.to.clone();
337        self.state = AnimationState::Completed;
338    }
339
340    fn retarget(&mut self, target: &T) -> bool {
341        self.retarget(target.clone());
342        true
343    }
344
345    fn into_value(self: Box<Self>) -> T {
346        self.current
347    }
348}
349
350fn finite_non_negative(value: f32) -> f32 {
351    if value.is_finite() {
352        value.max(0.0)
353    } else {
354        0.0
355    }
356}
357
358fn underdamped(
359    displacement: f64,
360    velocity: f64,
361    decay: f64,
362    negative_discriminant: f64,
363    seconds: f64,
364) -> (f64, f64) {
365    let damped_frequency = negative_discriminant.sqrt();
366    let angle = damped_frequency * seconds;
367    let (sin, cos) = angle.sin_cos();
368    let exponential = (-decay * seconds).exp();
369    let sine_coefficient = (velocity + decay * displacement) / damped_frequency;
370    let next_displacement = exponential * displacement.mul_add(cos, sine_coefficient * sin);
371    let next_velocity = exponential
372        * ((-decay * displacement + damped_frequency * sine_coefficient) * cos
373            + (-decay * sine_coefficient - damped_frequency * displacement) * sin);
374
375    (next_displacement, next_velocity)
376}
377
378fn critically_damped(displacement: f64, velocity: f64, decay: f64, seconds: f64) -> (f64, f64) {
379    let linear_coefficient = velocity + decay * displacement;
380    let exponential = (-decay * seconds).exp();
381    let value = displacement + linear_coefficient * seconds;
382
383    (
384        value * exponential,
385        (linear_coefficient - decay * value) * exponential,
386    )
387}
388
389fn overdamped(
390    displacement: f64,
391    velocity: f64,
392    decay: f64,
393    discriminant: f64,
394    seconds: f64,
395) -> (f64, f64) {
396    let root = discriminant.sqrt();
397    let first_rate = -decay + root;
398    let second_rate = -decay - root;
399    let first_coefficient = (velocity - second_rate * displacement) / (first_rate - second_rate);
400    let second_coefficient = displacement - first_coefficient;
401    let first_term = first_coefficient * (first_rate * seconds).exp();
402    let second_term = second_coefficient * (second_rate * seconds).exp();
403
404    (
405        first_term + second_term,
406        first_rate * first_term + second_rate * second_term,
407    )
408}
409
410#[cfg(test)]
411mod tests {
412    use super::{Spring, SpringConfig};
413    use crate::{
414        timing::Duration,
415        traits::{Animation, AnimationState},
416    };
417    use float_cmp::assert_approx_eq;
418
419    #[test]
420    fn channels_apply_independent_physics_to_selected_fields() {
421        let slow = SpringConfig {
422            stiffness: 40.0,
423            damping: 14.0,
424            ..SpringConfig::default()
425        };
426        let fast = SpringConfig {
427            stiffness: 420.0,
428            damping: 28.0,
429            ..SpringConfig::default()
430        };
431        let mut spring = Spring::with_channels(
432            (0.0_f32, 0.0_f32),
433            (100.0, 100.0),
434            [slow, fast],
435            |outputs| (outputs[0].0, outputs[1].1),
436        );
437
438        spring.tick(Duration::from_millis(100.0));
439
440        assert_eq!(spring.channel_count(), 2);
441        assert!(spring.value().1 > spring.value().0 * 2.0);
442    }
443
444    #[test]
445    fn analytic_solution_preserves_full_delta() {
446        let config = SpringConfig::default();
447        let mut single_tick = Spring::new(0.0_f32, 1.0, config);
448        let mut divided_ticks = Spring::new(0.0_f32, 1.0, config);
449
450        single_tick.tick(Duration::from_millis(500.0));
451        for _ in 0..5 {
452            divided_ticks.tick(Duration::from_millis(100.0));
453        }
454
455        assert_approx_eq!(
456            f32,
457            *single_tick.value(),
458            *divided_ticks.value(),
459            epsilon = 0.000_01
460        );
461        assert!(*single_tick.value() > 0.9);
462    }
463
464    #[test]
465    fn completion_waits_for_every_channel() {
466        let fast = SpringConfig {
467            stiffness: 500.0,
468            damping: 50.0,
469            epsilon: 0.01,
470            ..SpringConfig::default()
471        };
472        let slow = SpringConfig {
473            stiffness: 20.0,
474            damping: 8.0,
475            epsilon: 0.000_1,
476            ..SpringConfig::default()
477        };
478        let mut spring =
479            Spring::with_channels((0.0_f32, 0.0_f32), (1.0, 1.0), [fast, slow], |outputs| {
480                (outputs[0].0, outputs[1].1)
481            });
482
483        spring.tick(Duration::from_millis(500.0));
484
485        assert_eq!(spring.state(), AnimationState::Running);
486        assert!((1.0 - spring.value().0).abs() < (1.0 - spring.value().1).abs());
487    }
488
489    #[test]
490    fn empty_channel_list_uses_default_channel() {
491        let mut spring =
492            Spring::with_channels(0.0_f32, 1.0, std::iter::empty(), |outputs| outputs[0]);
493
494        spring.tick(Duration::from_millis(16.0));
495
496        assert_eq!(spring.channel_count(), 1);
497        assert!(*spring.value() > 0.0);
498    }
499
500    #[test]
501    fn invalid_config_values_are_sanitized() {
502        let mut spring = Spring::new(
503            0.0_f32,
504            1.0,
505            SpringConfig {
506                stiffness: f32::NAN,
507                damping: f32::NEG_INFINITY,
508                mass: 0.0,
509                epsilon: f32::NAN,
510            },
511        );
512
513        spring.tick(Duration::from_millis(16.0));
514
515        assert!(spring.value().is_finite());
516    }
517
518    #[test]
519    fn snappy_preset_is_stiffer_than_default() {
520        let default = SpringConfig::default();
521        let snappy = SpringConfig::snappy();
522
523        assert!(snappy.stiffness > default.stiffness);
524        assert!(snappy.damping > default.damping);
525        assert_approx_eq!(f32, snappy.mass, default.mass);
526        assert_approx_eq!(f32, snappy.epsilon, default.epsilon);
527    }
528
529    #[test]
530    fn config_builders_set_physical_parameters() {
531        let config = SpringConfig::new(300.0, 29.0)
532            .with_mass(2.0)
533            .with_epsilon(0.01);
534
535        assert_approx_eq!(f32, config.stiffness, 300.0);
536        assert_approx_eq!(f32, config.damping, 29.0);
537        assert_approx_eq!(f32, config.mass, 2.0);
538        assert_approx_eq!(f32, config.epsilon, 0.01);
539    }
540
541    #[test]
542    fn finish_sets_all_channels_to_the_exact_target() {
543        let mut spring = Spring::with_channels(
544            (0.0_f32, 0.0_f32),
545            (10.0, 20.0),
546            [SpringConfig::default(), SpringConfig::default()],
547            |outputs| (outputs[0].0, outputs[1].1),
548        );
549
550        spring.finish();
551
552        assert_eq!(spring.state(), AnimationState::Completed);
553        assert_eq!(*spring.value(), (10.0, 20.0));
554    }
555}