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}
44
45impl Spring {
46    /// Create a new spring at position `0.0` with target `0.0`.
47    pub fn new(config: SpringConfig) -> Self {
48        Self {
49            config,
50            position: 0.0,
51            velocity: 0.0,
52            target: 0.0,
53            integrator: Integrator::SemiImplicitEuler,
54        }
55    }
56
57    /// Set the target position the spring moves toward.
58    pub fn set_target(&mut self, target: f32) {
59        self.target = target;
60    }
61
62    /// Current position of the spring.
63    pub fn position(&self) -> f32 {
64        self.position
65    }
66
67    /// Current velocity of the spring.
68    pub fn velocity(&self) -> f32 {
69        self.velocity
70    }
71
72    /// `true` when the spring has come to rest within `epsilon` of the target.
73    pub fn is_settled(&self) -> bool {
74        let eps = self.config.epsilon;
75        (self.position - self.target).abs() < eps && self.velocity.abs() < eps
76    }
77
78    /// Teleport to `pos` instantly — no animation, velocity zeroed.
79    pub fn snap_to(&mut self, pos: f32) {
80        self.position = pos;
81        self.velocity = 0.0;
82    }
83
84    /// Switch to RK4 integration (more accurate for high-stiffness springs).
85    pub fn use_rk4(mut self, yes: bool) -> Self {
86        self.integrator = if yes {
87            Integrator::RungeKutta4
88        } else {
89            Integrator::SemiImplicitEuler
90        };
91        self
92    }
93
94    // ── Integration ──────────────────────────────────────────────────────────
95
96    #[inline]
97    fn acceleration(&self, position: f32, velocity: f32) -> f32 {
98        let displacement = position - self.target;
99        let spring_force = -self.config.stiffness * displacement;
100        let damping_force = -self.config.damping * velocity;
101        (spring_force + damping_force) / self.config.mass
102    }
103
104    fn step_euler(&mut self, dt: f32) {
105        let acc = self.acceleration(self.position, self.velocity);
106        self.velocity += acc * dt;
107        self.position += self.velocity * dt;
108    }
109
110    fn step_rk4(&mut self, dt: f32) {
111        // Classic RK4 for the coupled ODEs:
112        //   dx/dt = v
113        //   dv/dt = acceleration(x, v)
114        let p0 = self.position;
115        let v0 = self.velocity;
116
117        let k1v = self.acceleration(p0, v0);
118        let k1p = v0;
119
120        let k2v = self.acceleration(p0 + k1p * dt / 2.0, v0 + k1v * dt / 2.0);
121        let k2p = v0 + k1v * dt / 2.0;
122
123        let k3v = self.acceleration(p0 + k2p * dt / 2.0, v0 + k2v * dt / 2.0);
124        let k3p = v0 + k2v * dt / 2.0;
125
126        let k4v = self.acceleration(p0 + k3p * dt, v0 + k3v * dt);
127        let k4p = v0 + k3v * dt;
128
129        self.position += (dt / 6.0) * (k1p + 2.0 * k2p + 2.0 * k3p + k4p);
130        self.velocity += (dt / 6.0) * (k1v + 2.0 * k2v + 2.0 * k3v + k4v);
131    }
132}
133
134impl Update for Spring {
135    /// Advance the spring by `dt` seconds.
136    ///
137    /// Returns `false` when settled, `true` while still moving.
138    /// Negative `dt` is treated as `0.0`.
139    fn update(&mut self, dt: f32) -> bool {
140        let dt = dt.max(0.0);
141        if dt == 0.0 || self.is_settled() {
142            return !self.is_settled();
143        }
144        // Guard against degenerate config
145        if self.config.stiffness <= 0.0 {
146            self.position = self.target;
147            self.velocity = 0.0;
148            return false;
149        }
150        match self.integrator {
151            Integrator::SemiImplicitEuler => self.step_euler(dt),
152            Integrator::RungeKutta4 => self.step_rk4(dt),
153        }
154        !self.is_settled()
155    }
156}
157
158// ──────────────────────────────────────────────────────────────────────────────
159// Tests
160// ──────────────────────────────────────────────────────────────────────────────
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    const DT: f32 = 1.0 / 60.0;
167    const MAX_STEPS: usize = 10_000;
168
169    fn run_to_settle(spring: &mut Spring) -> usize {
170        let mut steps = 0;
171        while !spring.is_settled() {
172            spring.update(DT);
173            steps += 1;
174            assert!(
175                steps < MAX_STEPS,
176                "Spring did not settle within {} steps",
177                MAX_STEPS
178            );
179        }
180        steps
181    }
182
183    #[test]
184    fn gentle_settles_to_target() {
185        let mut s = Spring::new(SpringConfig::gentle());
186        s.set_target(100.0);
187        run_to_settle(&mut s);
188        assert!((s.position() - 100.0).abs() < 0.01);
189    }
190
191    #[test]
192    fn wobbly_settles_to_target() {
193        let mut s = Spring::new(SpringConfig::wobbly());
194        s.set_target(50.0);
195        run_to_settle(&mut s);
196        assert!((s.position() - 50.0).abs() < 0.01);
197    }
198
199    #[test]
200    fn stiff_settles_to_target() {
201        let mut s = Spring::new(SpringConfig::stiff());
202        s.set_target(-30.0);
203        run_to_settle(&mut s);
204        assert!((s.position() - (-30.0)).abs() < 0.01);
205    }
206
207    #[test]
208    fn slow_settles_to_target() {
209        let mut s = Spring::new(SpringConfig::slow());
210        s.set_target(1.0);
211        run_to_settle(&mut s);
212        assert!((s.position() - 1.0).abs() < 0.01);
213    }
214
215    #[test]
216    fn snappy_settles_to_target() {
217        let mut s = Spring::new(SpringConfig::snappy());
218        s.set_target(200.0);
219        run_to_settle(&mut s);
220        assert!((s.position() - 200.0).abs() < 0.01);
221    }
222
223    #[test]
224    fn snappy_settles_faster_than_slow() {
225        let mut fast = Spring::new(SpringConfig::snappy());
226        fast.set_target(100.0);
227        let fast_steps = run_to_settle(&mut fast);
228
229        let mut slow = Spring::new(SpringConfig::slow());
230        slow.set_target(100.0);
231        let slow_steps = run_to_settle(&mut slow);
232
233        assert!(
234            fast_steps < slow_steps,
235            "snappy={} slow={}",
236            fast_steps,
237            slow_steps
238        );
239    }
240
241    #[test]
242    fn zero_damping_oscillates() {
243        let cfg = SpringConfig {
244            stiffness: 100.0,
245            damping: 0.0,
246            mass: 1.0,
247            epsilon: 0.001,
248        };
249        let mut s = Spring::new(cfg);
250        s.set_target(1.0);
251        // Run 10,000 steps — should never settle with zero damping
252        for _ in 0..10_000 {
253            s.update(DT);
254        }
255        assert!(!s.is_settled());
256    }
257
258    #[test]
259    fn snap_to_teleports() {
260        let mut s = Spring::new(SpringConfig::default());
261        s.set_target(100.0);
262        s.snap_to(100.0);
263        assert_eq!(s.position(), 100.0);
264        assert_eq!(s.velocity(), 0.0);
265        assert!(s.is_settled());
266    }
267
268    #[test]
269    fn rk4_also_settles() {
270        let mut s = Spring::new(SpringConfig::wobbly()).use_rk4(true);
271        s.set_target(100.0);
272        run_to_settle(&mut s);
273        assert!((s.position() - 100.0).abs() < 0.01);
274    }
275
276    #[test]
277    fn zero_stiffness_snaps_immediately() {
278        let cfg = SpringConfig {
279            stiffness: 0.0,
280            ..SpringConfig::default()
281        };
282        let mut s = Spring::new(cfg);
283        s.set_target(42.0);
284        s.update(DT);
285        assert_eq!(s.position(), 42.0);
286        assert!(s.is_settled());
287    }
288
289    #[test]
290    fn negative_dt_is_noop() {
291        let mut s = Spring::new(SpringConfig::default());
292        s.set_target(100.0);
293        let pos_before = s.position();
294        s.update(-1.0);
295        assert_eq!(s.position(), pos_before);
296    }
297}