Skip to main content

animato_spring/
spring.rs

1//! 1D [`Spring`] — damped harmonic oscillator.
2
3use crate::config::SpringConfig;
4use animato_core::{AnimationIntrospection, AnimationKind, Inspectable, PlaybackState, Update};
5
6/// Integration method for the spring ODE.
7#[derive(Clone, Debug, PartialEq)]
8#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
9pub enum Integrator {
10    /// Semi-implicit Euler — fast, stable, default choice for animation.
11    SemiImplicitEuler,
12    /// 4th-order Runge-Kutta — more accurate for high-stiffness springs.
13    RungeKutta4,
14}
15
16/// A 1D damped harmonic oscillator spring.
17///
18/// Stack-allocated and `no_std`-compatible. Use [`SpringN<T>`](crate::SpringN)
19/// for multi-dimensional animation.
20///
21/// # Example
22///
23/// ```rust
24/// use animato_spring::{Spring, SpringConfig};
25/// use animato_core::Update;
26///
27/// let mut s = Spring::new(SpringConfig::stiff());
28/// s.set_target(100.0);
29/// for _ in 0..300 {
30///     s.update(1.0 / 60.0);
31/// }
32/// assert!((s.position() - 100.0).abs() < 0.01);
33/// ```
34#[derive(Clone, Debug)]
35#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
36pub struct Spring {
37    /// The spring configuration (stiffness, damping, mass, epsilon).
38    pub config: SpringConfig,
39    position: f32,
40    velocity: f32,
41    target: f32,
42    integrator: Integrator,
43    previous_displacement: f32,
44    overshoot_count: u32,
45}
46
47impl Spring {
48    /// Create a new spring at position `0.0` with target `0.0`.
49    pub fn new(config: SpringConfig) -> Self {
50        Self {
51            config,
52            position: 0.0,
53            velocity: 0.0,
54            target: 0.0,
55            integrator: Integrator::SemiImplicitEuler,
56            previous_displacement: 0.0,
57            overshoot_count: 0,
58        }
59    }
60
61    /// Create a spring at `initial`, moving with `velocity`, toward `target`.
62    ///
63    /// This is useful for fling-to-snap gestures where the release velocity
64    /// should carry into the settling animation.
65    pub fn from_velocity(initial: f32, velocity: f32, target: f32, config: SpringConfig) -> Self {
66        let mut spring = Self::new(config);
67        spring.position = initial;
68        spring.velocity = if velocity.is_finite() { velocity } else { 0.0 };
69        spring.target = target;
70        spring.previous_displacement = initial - target;
71        spring
72    }
73
74    /// Set the target position the spring moves toward.
75    pub fn set_target(&mut self, target: f32) {
76        self.target = target;
77        self.previous_displacement = self.position - self.target;
78        self.overshoot_count = 0;
79    }
80
81    /// Current position of the spring.
82    pub fn position(&self) -> f32 {
83        self.position
84    }
85
86    /// Current velocity of the spring.
87    pub fn velocity(&self) -> f32 {
88        self.velocity
89    }
90
91    /// `true` when the spring has come to rest within `epsilon` of the target.
92    pub fn is_settled(&self) -> bool {
93        let eps = self.config.epsilon;
94        (self.position - self.target).abs() < eps && self.velocity.abs() < eps
95    }
96
97    /// Current kinetic plus potential energy.
98    ///
99    /// This is useful for diagnostics and settle visualization. The units are
100    /// simulation-relative rather than renderer-specific.
101    pub fn energy(&self) -> f32 {
102        let displacement = self.position - self.target;
103        0.5 * self.config.mass * self.velocity * self.velocity
104            + 0.5 * self.config.stiffness * displacement * displacement
105    }
106
107    /// Number of times the spring has crossed its target since the last target change.
108    pub fn overshoot_count(&self) -> u32 {
109        self.overshoot_count
110    }
111
112    /// Teleport to `pos` instantly — no animation, velocity zeroed.
113    pub fn snap_to(&mut self, pos: f32) {
114        self.position = pos;
115        self.velocity = 0.0;
116        self.target = pos;
117        self.previous_displacement = 0.0;
118        self.overshoot_count = 0;
119    }
120
121    /// Switch to RK4 integration (more accurate for high-stiffness springs).
122    pub fn use_rk4(mut self, yes: bool) -> Self {
123        self.integrator = if yes {
124            Integrator::RungeKutta4
125        } else {
126            Integrator::SemiImplicitEuler
127        };
128        self
129    }
130
131    // ── Integration ──────────────────────────────────────────────────────────
132
133    #[inline]
134    fn acceleration(&self, position: f32, velocity: f32) -> f32 {
135        let displacement = position - self.target;
136        let spring_force = -self.config.stiffness * displacement;
137        let damping_force = -self.config.damping * velocity;
138        (spring_force + damping_force) / self.config.mass
139    }
140
141    fn step_euler(&mut self, dt: f32) {
142        let acc = self.acceleration(self.position, self.velocity);
143        self.velocity += acc * dt;
144        self.position += self.velocity * dt;
145    }
146
147    fn step_rk4(&mut self, dt: f32) {
148        // Classic RK4 for the coupled ODEs:
149        //   dx/dt = v
150        //   dv/dt = acceleration(x, v)
151        let p0 = self.position;
152        let v0 = self.velocity;
153
154        let k1v = self.acceleration(p0, v0);
155        let k1p = v0;
156
157        let k2v = self.acceleration(p0 + k1p * dt / 2.0, v0 + k1v * dt / 2.0);
158        let k2p = v0 + k1v * dt / 2.0;
159
160        let k3v = self.acceleration(p0 + k2p * dt / 2.0, v0 + k2v * dt / 2.0);
161        let k3p = v0 + k2v * dt / 2.0;
162
163        let k4v = self.acceleration(p0 + k3p * dt, v0 + k3v * dt);
164        let k4p = v0 + k3v * dt;
165
166        self.position += (dt / 6.0) * (k1p + 2.0 * k2p + 2.0 * k3p + k4p);
167        self.velocity += (dt / 6.0) * (k1v + 2.0 * k2v + 2.0 * k3v + k4v);
168    }
169
170    fn track_overshoot(&mut self) {
171        let displacement = self.position - self.target;
172        let eps = self.config.epsilon.max(0.0);
173        if self.previous_displacement.abs() > eps
174            && displacement.abs() > eps
175            && self.previous_displacement.signum() != displacement.signum()
176        {
177            self.overshoot_count = self.overshoot_count.saturating_add(1);
178        }
179        self.previous_displacement = displacement;
180    }
181}
182
183impl Update for Spring {
184    /// Advance the spring by `dt` seconds.
185    ///
186    /// Returns `false` when settled, `true` while still moving.
187    /// Negative `dt` is treated as `0.0`.
188    fn update(&mut self, dt: f32) -> bool {
189        let dt = dt.max(0.0);
190        if dt == 0.0 || self.is_settled() {
191            return !self.is_settled();
192        }
193        // Guard against degenerate config
194        if self.config.stiffness <= 0.0 {
195            self.position = self.target;
196            self.velocity = 0.0;
197            return false;
198        }
199        match self.integrator {
200            Integrator::SemiImplicitEuler => self.step_euler(dt),
201            Integrator::RungeKutta4 => self.step_rk4(dt),
202        }
203        self.track_overshoot();
204        !self.is_settled()
205    }
206}
207
208impl Inspectable for Spring {
209    fn introspect(&self) -> AnimationIntrospection {
210        AnimationIntrospection::new(
211            AnimationKind::Spring,
212            if self.is_settled() { 1.0 } else { 0.0 },
213            0.0,
214            None,
215            if self.is_settled() {
216                PlaybackState::Complete
217            } else {
218                PlaybackState::Playing
219            },
220            None,
221        )
222    }
223}
224
225// ──────────────────────────────────────────────────────────────────────────────
226// Tests
227// ──────────────────────────────────────────────────────────────────────────────
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    const DT: f32 = 1.0 / 60.0;
234    const MAX_STEPS: usize = 10_000;
235
236    fn run_to_settle(spring: &mut Spring) -> usize {
237        let mut steps = 0;
238        while !spring.is_settled() {
239            spring.update(DT);
240            steps += 1;
241            assert!(
242                steps < MAX_STEPS,
243                "Spring did not settle within {} steps",
244                MAX_STEPS
245            );
246        }
247        steps
248    }
249
250    #[test]
251    fn gentle_settles_to_target() {
252        let mut s = Spring::new(SpringConfig::gentle());
253        s.set_target(100.0);
254        run_to_settle(&mut s);
255        assert!((s.position() - 100.0).abs() < 0.01);
256    }
257
258    #[test]
259    fn wobbly_settles_to_target() {
260        let mut s = Spring::new(SpringConfig::wobbly());
261        s.set_target(50.0);
262        run_to_settle(&mut s);
263        assert!((s.position() - 50.0).abs() < 0.01);
264    }
265
266    #[test]
267    fn stiff_settles_to_target() {
268        let mut s = Spring::new(SpringConfig::stiff());
269        s.set_target(-30.0);
270        run_to_settle(&mut s);
271        assert!((s.position() - (-30.0)).abs() < 0.01);
272    }
273
274    #[test]
275    fn slow_settles_to_target() {
276        let mut s = Spring::new(SpringConfig::slow());
277        s.set_target(1.0);
278        run_to_settle(&mut s);
279        assert!((s.position() - 1.0).abs() < 0.01);
280    }
281
282    #[test]
283    fn snappy_settles_to_target() {
284        let mut s = Spring::new(SpringConfig::snappy());
285        s.set_target(200.0);
286        run_to_settle(&mut s);
287        assert!((s.position() - 200.0).abs() < 0.01);
288    }
289
290    #[test]
291    fn snappy_settles_faster_than_slow() {
292        let mut fast = Spring::new(SpringConfig::snappy());
293        fast.set_target(100.0);
294        let fast_steps = run_to_settle(&mut fast);
295
296        let mut slow = Spring::new(SpringConfig::slow());
297        slow.set_target(100.0);
298        let slow_steps = run_to_settle(&mut slow);
299
300        assert!(
301            fast_steps < slow_steps,
302            "snappy={} slow={}",
303            fast_steps,
304            slow_steps
305        );
306    }
307
308    #[test]
309    fn zero_damping_oscillates() {
310        let cfg = SpringConfig {
311            stiffness: 100.0,
312            damping: 0.0,
313            mass: 1.0,
314            epsilon: 0.001,
315        };
316        let mut s = Spring::new(cfg);
317        s.set_target(1.0);
318        // Run 10,000 steps — should never settle with zero damping
319        for _ in 0..10_000 {
320            s.update(DT);
321        }
322        assert!(!s.is_settled());
323    }
324
325    #[test]
326    fn snap_to_teleports() {
327        let mut s = Spring::new(SpringConfig::default());
328        s.set_target(100.0);
329        s.snap_to(100.0);
330        assert_eq!(s.position(), 100.0);
331        assert_eq!(s.velocity(), 0.0);
332        assert!(s.is_settled());
333    }
334
335    #[test]
336    fn rk4_also_settles() {
337        let mut s = Spring::new(SpringConfig::wobbly()).use_rk4(true);
338        s.set_target(100.0);
339        run_to_settle(&mut s);
340        assert!((s.position() - 100.0).abs() < 0.01);
341    }
342
343    #[test]
344    fn zero_stiffness_snaps_immediately() {
345        let cfg = SpringConfig {
346            stiffness: 0.0,
347            ..SpringConfig::default()
348        };
349        let mut s = Spring::new(cfg);
350        s.set_target(42.0);
351        s.update(DT);
352        assert_eq!(s.position(), 42.0);
353        assert!(s.is_settled());
354    }
355
356    #[test]
357    fn negative_dt_is_noop() {
358        let mut s = Spring::new(SpringConfig::default());
359        s.set_target(100.0);
360        let pos_before = s.position();
361        s.update(-1.0);
362        assert_eq!(s.position(), pos_before);
363    }
364
365    #[test]
366    fn from_velocity_reaches_target_and_loses_energy() {
367        let mut s = Spring::from_velocity(0.0, 300.0, 100.0, SpringConfig::stiff());
368        let start_energy = s.energy();
369        run_to_settle(&mut s);
370        assert!((s.position() - 100.0).abs() < 0.01);
371        assert!(s.energy() < start_energy);
372    }
373
374    #[test]
375    fn damping_modes_order_damping() {
376        let critical = SpringConfig::critically_damped(100.0);
377        let over = SpringConfig::overdamped(100.0, 1.5);
378        let under = SpringConfig::underdamped(100.0, 0.5);
379        assert!(under.damping < critical.damping);
380        assert!(over.damping > critical.damping);
381    }
382
383    #[test]
384    fn overshoot_count_tracks_target_crossings() {
385        let mut s = Spring::from_velocity(0.0, 0.0, 1.0, SpringConfig::underdamped(120.0, 0.2));
386        for _ in 0..240 {
387            s.update(DT);
388        }
389        assert!(s.overshoot_count() > 0);
390    }
391}