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///     Animation, Spring, SpringConfig,
164///     timing::Duration,
165/// };
166///
167/// let soft = SpringConfig::new(80.0, 18.0);
168/// let snappy = SpringConfig::new(420.0, 28.0);
169/// let mut spring = Spring::with_channels(
170///     (0.0_f32, 0.0_f32),
171///     (100.0, 1.0),
172///     [soft, snappy],
173///     |outputs| (outputs[0].0, outputs[1].1),
174/// );
175///
176/// spring.tick(Duration::from_millis(100.0));
177/// assert!(spring.value().1 > spring.value().0 / 100.0);
178/// ```
179#[derive(Clone)]
180pub struct Spring<T: Animatable> {
181    from: T,
182    to: T,
183    current: T,
184    outputs: Vec<T>,
185    channels: Vec<SpringChannel>,
186    compose: Compositor<T>,
187    state: AnimationState,
188}
189
190impl<T: Animatable + fmt::Debug> fmt::Debug for Spring<T> {
191    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
192        formatter
193            .debug_struct("Spring")
194            .field("from", &self.from)
195            .field("to", &self.to)
196            .field("current", &self.current)
197            .field("channels", &self.channels)
198            .field("state", &self.state)
199            .finish_non_exhaustive()
200    }
201}
202
203impl<T: Animatable> Spring<T> {
204    /// Creates a running spring animation with one physical channel.
205    ///
206    /// All fields in `T` share this channel. For independently configured
207    /// fields, use [`Spring::with_channels`].
208    #[must_use]
209    pub fn new(from: T, to: T, config: SpringConfig) -> Self {
210        Self::with_channels(from, to, [config], |outputs| outputs[0].clone())
211    }
212
213    /// Creates a running spring with independently configured channels.
214    ///
215    /// Every channel evaluates the full transition from `from` to `to` with
216    /// its own physical state. `compose` must build the final value by
217    /// selecting the fields owned by each channel, in the same order as
218    /// `configs`.
219    ///
220    /// An empty configuration iterator falls back to one default channel.
221    #[must_use]
222    pub fn with_channels(
223        from: T,
224        to: T,
225        configs: impl IntoIterator<Item = SpringConfig>,
226        compose: impl Fn(&[T]) -> T + 'static,
227    ) -> Self {
228        let mut channels: Vec<_> = configs.into_iter().map(SpringChannel::new).collect();
229        if channels.is_empty() {
230            channels.push(SpringChannel::new(SpringConfig::default()));
231        }
232        let outputs = vec![from.clone(); channels.len()];
233
234        Self {
235            current: from.clone(),
236            from,
237            to,
238            outputs,
239            channels,
240            compose: Arc::new(compose),
241            state: AnimationState::Running,
242        }
243    }
244
245    /// Returns the number of independently simulated physical channels.
246    #[must_use]
247    pub fn channel_count(&self) -> usize {
248        self.channels.len()
249    }
250
251    /// Restarts every channel from the current value toward `target`.
252    ///
253    /// Each channel keeps its normalized velocity so interrupted motion
254    /// retains momentum while its position is rebased to the current value.
255    pub fn retarget(&mut self, target: T) {
256        self.from = self.current.clone();
257        self.to = target;
258        for (channel, output) in self.channels.iter_mut().zip(&mut self.outputs) {
259            channel.position = 0.0;
260            *output = self.from.clone();
261        }
262        self.state = AnimationState::Running;
263    }
264
265    fn sample(&mut self) {
266        for (channel, output) in self.channels.iter().zip(&mut self.outputs) {
267            *output = T::extrapolate(&self.from, &self.to, channel.position);
268        }
269        self.current = (self.compose)(&self.outputs);
270    }
271
272    fn integrate(&mut self, delta: Duration) {
273        let seconds = delta.as_secs();
274        for channel in &mut self.channels {
275            channel.advance(seconds);
276        }
277        self.sample();
278
279        if self.channels.iter().copied().all(SpringChannel::is_settled) {
280            self.finish();
281        }
282    }
283}
284
285impl<T: Animatable> Animation<T> for Spring<T> {
286    fn value(&self) -> &T {
287        &self.current
288    }
289
290    fn state(&self) -> AnimationState {
291        self.state
292    }
293
294    fn tick(&mut self, delta: Duration) {
295        if self.state == AnimationState::Running {
296            self.integrate(delta);
297        }
298    }
299
300    fn pause(&mut self) {
301        if self.state == AnimationState::Running {
302            self.state = AnimationState::Paused;
303        }
304    }
305
306    fn resume(&mut self) {
307        if self.state == AnimationState::Paused {
308            self.state = AnimationState::Running;
309        }
310    }
311
312    fn cancel(&mut self) {
313        if matches!(self.state, AnimationState::Running | AnimationState::Paused) {
314            self.state = AnimationState::Canceled;
315        }
316    }
317
318    fn seek(&mut self, progress: f32) {
319        let progress = if progress.is_nan() {
320            0.0
321        } else {
322            progress.clamp(0.0, 1.0)
323        };
324        for channel in &mut self.channels {
325            channel.reset_position(progress);
326        }
327        self.sample();
328    }
329
330    fn finish(&mut self) {
331        for (channel, output) in self.channels.iter_mut().zip(&mut self.outputs) {
332            channel.reset_position(1.0);
333            *output = self.to.clone();
334        }
335        self.current = self.to.clone();
336        self.state = AnimationState::Completed;
337    }
338
339    fn retarget(&mut self, target: &T) -> bool {
340        self.retarget(target.clone());
341        true
342    }
343
344    fn into_value(self: Box<Self>) -> T {
345        self.current
346    }
347}
348
349fn finite_non_negative(value: f32) -> f32 {
350    if value.is_finite() {
351        value.max(0.0)
352    } else {
353        0.0
354    }
355}
356
357fn underdamped(
358    displacement: f64,
359    velocity: f64,
360    decay: f64,
361    negative_discriminant: f64,
362    seconds: f64,
363) -> (f64, f64) {
364    let damped_frequency = negative_discriminant.sqrt();
365    let angle = damped_frequency * seconds;
366    let (sin, cos) = angle.sin_cos();
367    let exponential = (-decay * seconds).exp();
368    let sine_coefficient = (velocity + decay * displacement) / damped_frequency;
369    let next_displacement = exponential * displacement.mul_add(cos, sine_coefficient * sin);
370    let next_velocity = exponential
371        * ((-decay * displacement + damped_frequency * sine_coefficient) * cos
372            + (-decay * sine_coefficient - damped_frequency * displacement) * sin);
373
374    (next_displacement, next_velocity)
375}
376
377fn critically_damped(displacement: f64, velocity: f64, decay: f64, seconds: f64) -> (f64, f64) {
378    let linear_coefficient = velocity + decay * displacement;
379    let exponential = (-decay * seconds).exp();
380    let value = displacement + linear_coefficient * seconds;
381
382    (
383        value * exponential,
384        (linear_coefficient - decay * value) * exponential,
385    )
386}
387
388fn overdamped(
389    displacement: f64,
390    velocity: f64,
391    decay: f64,
392    discriminant: f64,
393    seconds: f64,
394) -> (f64, f64) {
395    let root = discriminant.sqrt();
396    let first_rate = -decay + root;
397    let second_rate = -decay - root;
398    let first_coefficient = (velocity - second_rate * displacement) / (first_rate - second_rate);
399    let second_coefficient = displacement - first_coefficient;
400    let first_term = first_coefficient * (first_rate * seconds).exp();
401    let second_term = second_coefficient * (second_rate * seconds).exp();
402
403    (
404        first_term + second_term,
405        first_rate * first_term + second_rate * second_term,
406    )
407}
408
409#[cfg(test)]
410mod tests {
411    use super::{Spring, SpringConfig};
412    use crate::{Animation, AnimationState, timing::Duration};
413    use float_cmp::assert_approx_eq;
414
415    #[test]
416    fn channels_apply_independent_physics_to_selected_fields() {
417        let slow = SpringConfig {
418            stiffness: 40.0,
419            damping: 14.0,
420            ..SpringConfig::default()
421        };
422        let fast = SpringConfig {
423            stiffness: 420.0,
424            damping: 28.0,
425            ..SpringConfig::default()
426        };
427        let mut spring = Spring::with_channels(
428            (0.0_f32, 0.0_f32),
429            (100.0, 100.0),
430            [slow, fast],
431            |outputs| (outputs[0].0, outputs[1].1),
432        );
433
434        spring.tick(Duration::from_millis(100.0));
435
436        assert_eq!(spring.channel_count(), 2);
437        assert!(spring.value().1 > spring.value().0 * 2.0);
438    }
439
440    #[test]
441    fn analytic_solution_preserves_full_delta() {
442        let config = SpringConfig::default();
443        let mut single_tick = Spring::new(0.0_f32, 1.0, config);
444        let mut divided_ticks = Spring::new(0.0_f32, 1.0, config);
445
446        single_tick.tick(Duration::from_millis(500.0));
447        for _ in 0..5 {
448            divided_ticks.tick(Duration::from_millis(100.0));
449        }
450
451        assert_approx_eq!(
452            f32,
453            *single_tick.value(),
454            *divided_ticks.value(),
455            epsilon = 0.000_01
456        );
457        assert!(*single_tick.value() > 0.9);
458    }
459
460    #[test]
461    fn completion_waits_for_every_channel() {
462        let fast = SpringConfig {
463            stiffness: 500.0,
464            damping: 50.0,
465            epsilon: 0.01,
466            ..SpringConfig::default()
467        };
468        let slow = SpringConfig {
469            stiffness: 20.0,
470            damping: 8.0,
471            epsilon: 0.000_1,
472            ..SpringConfig::default()
473        };
474        let mut spring =
475            Spring::with_channels((0.0_f32, 0.0_f32), (1.0, 1.0), [fast, slow], |outputs| {
476                (outputs[0].0, outputs[1].1)
477            });
478
479        spring.tick(Duration::from_millis(500.0));
480
481        assert_eq!(spring.state(), AnimationState::Running);
482        assert!((1.0 - spring.value().0).abs() < (1.0 - spring.value().1).abs());
483    }
484
485    #[test]
486    fn empty_channel_list_uses_default_channel() {
487        let mut spring =
488            Spring::with_channels(0.0_f32, 1.0, std::iter::empty(), |outputs| outputs[0]);
489
490        spring.tick(Duration::from_millis(16.0));
491
492        assert_eq!(spring.channel_count(), 1);
493        assert!(*spring.value() > 0.0);
494    }
495
496    #[test]
497    fn invalid_config_values_are_sanitized() {
498        let mut spring = Spring::new(
499            0.0_f32,
500            1.0,
501            SpringConfig {
502                stiffness: f32::NAN,
503                damping: f32::NEG_INFINITY,
504                mass: 0.0,
505                epsilon: f32::NAN,
506            },
507        );
508
509        spring.tick(Duration::from_millis(16.0));
510
511        assert!(spring.value().is_finite());
512    }
513
514    #[test]
515    fn snappy_preset_is_stiffer_than_default() {
516        let default = SpringConfig::default();
517        let snappy = SpringConfig::snappy();
518
519        assert!(snappy.stiffness > default.stiffness);
520        assert!(snappy.damping > default.damping);
521        assert_approx_eq!(f32, snappy.mass, default.mass);
522        assert_approx_eq!(f32, snappy.epsilon, default.epsilon);
523    }
524
525    #[test]
526    fn config_builders_set_physical_parameters() {
527        let config = SpringConfig::new(300.0, 29.0)
528            .with_mass(2.0)
529            .with_epsilon(0.01);
530
531        assert_approx_eq!(f32, config.stiffness, 300.0);
532        assert_approx_eq!(f32, config.damping, 29.0);
533        assert_approx_eq!(f32, config.mass, 2.0);
534        assert_approx_eq!(f32, config.epsilon, 0.01);
535    }
536
537    #[test]
538    fn finish_sets_all_channels_to_the_exact_target() {
539        let mut spring = Spring::with_channels(
540            (0.0_f32, 0.0_f32),
541            (10.0, 20.0),
542            [SpringConfig::default(), SpringConfig::default()],
543            |outputs| (outputs[0].0, outputs[1].1),
544        );
545
546        spring.finish();
547
548        assert_eq!(spring.state(), AnimationState::Completed);
549        assert_eq!(*spring.value(), (10.0, 20.0));
550    }
551}