Skip to main content

animato_spring/
spring.rs

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