Skip to main content

animato_spring/
spring.rs

1//! 1D [`Spring`] — damped harmonic oscillator.
2
3use crate::config::SpringConfig;
4use animato_core::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
208// ──────────────────────────────────────────────────────────────────────────────
209// Tests
210// ──────────────────────────────────────────────────────────────────────────────
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    const DT: f32 = 1.0 / 60.0;
217    const MAX_STEPS: usize = 10_000;
218
219    fn run_to_settle(spring: &mut Spring) -> usize {
220        let mut steps = 0;
221        while !spring.is_settled() {
222            spring.update(DT);
223            steps += 1;
224            assert!(
225                steps < MAX_STEPS,
226                "Spring did not settle within {} steps",
227                MAX_STEPS
228            );
229        }
230        steps
231    }
232
233    #[test]
234    fn gentle_settles_to_target() {
235        let mut s = Spring::new(SpringConfig::gentle());
236        s.set_target(100.0);
237        run_to_settle(&mut s);
238        assert!((s.position() - 100.0).abs() < 0.01);
239    }
240
241    #[test]
242    fn wobbly_settles_to_target() {
243        let mut s = Spring::new(SpringConfig::wobbly());
244        s.set_target(50.0);
245        run_to_settle(&mut s);
246        assert!((s.position() - 50.0).abs() < 0.01);
247    }
248
249    #[test]
250    fn stiff_settles_to_target() {
251        let mut s = Spring::new(SpringConfig::stiff());
252        s.set_target(-30.0);
253        run_to_settle(&mut s);
254        assert!((s.position() - (-30.0)).abs() < 0.01);
255    }
256
257    #[test]
258    fn slow_settles_to_target() {
259        let mut s = Spring::new(SpringConfig::slow());
260        s.set_target(1.0);
261        run_to_settle(&mut s);
262        assert!((s.position() - 1.0).abs() < 0.01);
263    }
264
265    #[test]
266    fn snappy_settles_to_target() {
267        let mut s = Spring::new(SpringConfig::snappy());
268        s.set_target(200.0);
269        run_to_settle(&mut s);
270        assert!((s.position() - 200.0).abs() < 0.01);
271    }
272
273    #[test]
274    fn snappy_settles_faster_than_slow() {
275        let mut fast = Spring::new(SpringConfig::snappy());
276        fast.set_target(100.0);
277        let fast_steps = run_to_settle(&mut fast);
278
279        let mut slow = Spring::new(SpringConfig::slow());
280        slow.set_target(100.0);
281        let slow_steps = run_to_settle(&mut slow);
282
283        assert!(
284            fast_steps < slow_steps,
285            "snappy={} slow={}",
286            fast_steps,
287            slow_steps
288        );
289    }
290
291    #[test]
292    fn zero_damping_oscillates() {
293        let cfg = SpringConfig {
294            stiffness: 100.0,
295            damping: 0.0,
296            mass: 1.0,
297            epsilon: 0.001,
298        };
299        let mut s = Spring::new(cfg);
300        s.set_target(1.0);
301        // Run 10,000 steps — should never settle with zero damping
302        for _ in 0..10_000 {
303            s.update(DT);
304        }
305        assert!(!s.is_settled());
306    }
307
308    #[test]
309    fn snap_to_teleports() {
310        let mut s = Spring::new(SpringConfig::default());
311        s.set_target(100.0);
312        s.snap_to(100.0);
313        assert_eq!(s.position(), 100.0);
314        assert_eq!(s.velocity(), 0.0);
315        assert!(s.is_settled());
316    }
317
318    #[test]
319    fn rk4_also_settles() {
320        let mut s = Spring::new(SpringConfig::wobbly()).use_rk4(true);
321        s.set_target(100.0);
322        run_to_settle(&mut s);
323        assert!((s.position() - 100.0).abs() < 0.01);
324    }
325
326    #[test]
327    fn zero_stiffness_snaps_immediately() {
328        let cfg = SpringConfig {
329            stiffness: 0.0,
330            ..SpringConfig::default()
331        };
332        let mut s = Spring::new(cfg);
333        s.set_target(42.0);
334        s.update(DT);
335        assert_eq!(s.position(), 42.0);
336        assert!(s.is_settled());
337    }
338
339    #[test]
340    fn negative_dt_is_noop() {
341        let mut s = Spring::new(SpringConfig::default());
342        s.set_target(100.0);
343        let pos_before = s.position();
344        s.update(-1.0);
345        assert_eq!(s.position(), pos_before);
346    }
347
348    #[test]
349    fn from_velocity_reaches_target_and_loses_energy() {
350        let mut s = Spring::from_velocity(0.0, 300.0, 100.0, SpringConfig::stiff());
351        let start_energy = s.energy();
352        run_to_settle(&mut s);
353        assert!((s.position() - 100.0).abs() < 0.01);
354        assert!(s.energy() < start_energy);
355    }
356
357    #[test]
358    fn damping_modes_order_damping() {
359        let critical = SpringConfig::critically_damped(100.0);
360        let over = SpringConfig::overdamped(100.0, 1.5);
361        let under = SpringConfig::underdamped(100.0, 0.5);
362        assert!(under.damping < critical.damping);
363        assert!(over.damping > critical.damping);
364    }
365
366    #[test]
367    fn overshoot_count_tracks_target_crossings() {
368        let mut s = Spring::from_velocity(0.0, 0.0, 1.0, SpringConfig::underdamped(120.0, 0.2));
369        for _ in 0..240 {
370            s.update(DT);
371        }
372        assert!(s.overshoot_count() > 0);
373    }
374}