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