Skip to main content

blinc_animation/
spring.rs

1//! Spring physics animation
2//!
3//! RK4-integrated spring physics for smooth, natural animations.
4//! Supports preset configurations and custom spring parameters.
5
6/// Configuration for a spring animation
7#[derive(Clone, Copy, Debug)]
8pub struct SpringConfig {
9    pub stiffness: f32,
10    pub damping: f32,
11    pub mass: f32,
12}
13
14impl SpringConfig {
15    /// Create a new spring configuration
16    pub fn new(stiffness: f32, damping: f32, mass: f32) -> Self {
17        Self {
18            stiffness,
19            damping,
20            mass,
21        }
22    }
23
24    /// A gentle, slow spring (good for page transitions)
25    pub fn gentle() -> Self {
26        Self {
27            stiffness: 120.0,
28            damping: 14.0,
29            mass: 1.0,
30        }
31    }
32
33    /// A wobbly spring with overshoot (good for playful UI)
34    pub fn wobbly() -> Self {
35        Self {
36            stiffness: 180.0,
37            damping: 12.0,
38            mass: 1.0,
39        }
40    }
41
42    /// A stiff, snappy spring (good for buttons)
43    pub fn stiff() -> Self {
44        Self {
45            stiffness: 400.0,
46            damping: 30.0,
47            mass: 1.0,
48        }
49    }
50
51    /// A very stiff spring with minimal oscillation (good for quick responses)
52    pub fn snappy() -> Self {
53        Self {
54            stiffness: 600.0,
55            damping: 40.0,
56            mass: 1.0,
57        }
58    }
59
60    /// A slow spring with no overshoot (critically damped)
61    pub fn molasses() -> Self {
62        Self {
63            stiffness: 100.0,
64            damping: 20.0,
65            mass: 1.0,
66        }
67    }
68
69    /// Calculate critical damping for this spring's stiffness and mass
70    pub fn critical_damping(&self) -> f32 {
71        2.0 * (self.stiffness * self.mass).sqrt()
72    }
73
74    /// Check if the spring is underdamped (will oscillate)
75    pub fn is_underdamped(&self) -> bool {
76        self.damping < self.critical_damping()
77    }
78
79    /// Check if the spring is critically damped (no oscillation, fastest settling)
80    pub fn is_critically_damped(&self) -> bool {
81        (self.damping - self.critical_damping()).abs() < 0.01
82    }
83
84    /// Check if the spring is overdamped (slow settling, no oscillation)
85    pub fn is_overdamped(&self) -> bool {
86        self.damping > self.critical_damping()
87    }
88}
89
90impl Default for SpringConfig {
91    fn default() -> Self {
92        Self::stiff()
93    }
94}
95
96/// A spring-based animator
97#[derive(Clone, Copy, Debug)]
98pub struct Spring {
99    config: SpringConfig,
100    value: f32,
101    velocity: f32,
102    target: f32,
103    /// When true, the spring is paused — step() is a no-op
104    paused: bool,
105}
106
107impl Spring {
108    pub fn new(config: SpringConfig, initial: f32) -> Self {
109        Self {
110            config,
111            value: initial,
112            velocity: 0.0,
113            target: initial,
114            paused: false,
115        }
116    }
117
118    pub fn value(&self) -> f32 {
119        self.value
120    }
121
122    pub fn velocity(&self) -> f32 {
123        self.velocity
124    }
125
126    pub fn target(&self) -> f32 {
127        self.target
128    }
129
130    pub fn set_target(&mut self, target: f32) {
131        self.target = target;
132    }
133
134    /// Check if the spring has settled (within epsilon of target with minimal velocity)
135    pub fn is_settled(&self) -> bool {
136        const EPSILON: f32 = 0.01;
137        const VELOCITY_EPSILON: f32 = 0.1;
138
139        self.paused
140            || ((self.value - self.target).abs() < EPSILON
141                && self.velocity.abs() < VELOCITY_EPSILON)
142    }
143
144    /// Pause the spring — step() becomes a no-op, preserving current state
145    pub fn pause(&mut self) {
146        self.paused = true;
147    }
148
149    /// Resume the spring from where it was paused
150    pub fn resume(&mut self) {
151        self.paused = false;
152    }
153
154    /// Step the spring simulation using RK4 integration
155    pub fn step(&mut self, dt: f32) {
156        if self.paused {
157            return;
158        }
159        if self.is_settled() {
160            self.value = self.target;
161            self.velocity = 0.0;
162            return;
163        }
164
165        // RK4 integration for accurate spring physics
166        let k1_v = self.acceleration(self.value, self.velocity);
167        let k1_x = self.velocity;
168
169        let k2_v = self.acceleration(
170            self.value + k1_x * dt * 0.5,
171            self.velocity + k1_v * dt * 0.5,
172        );
173        let k2_x = self.velocity + k1_v * dt * 0.5;
174
175        let k3_v = self.acceleration(
176            self.value + k2_x * dt * 0.5,
177            self.velocity + k2_v * dt * 0.5,
178        );
179        let k3_x = self.velocity + k2_v * dt * 0.5;
180
181        let k4_v = self.acceleration(self.value + k3_x * dt, self.velocity + k3_v * dt);
182        let k4_x = self.velocity + k3_v * dt;
183
184        self.velocity += (k1_v + 2.0 * k2_v + 2.0 * k3_v + k4_v) * dt / 6.0;
185        self.value += (k1_x + 2.0 * k2_x + 2.0 * k3_x + k4_x) * dt / 6.0;
186    }
187
188    fn acceleration(&self, x: f32, v: f32) -> f32 {
189        let spring_force = -self.config.stiffness * (x - self.target);
190        let damping_force = -self.config.damping * v;
191        (spring_force + damping_force) / self.config.mass
192    }
193}
194
195// =============================================================================
196// ZRTL Plugin Exports
197// =============================================================================
198
199#[cfg(feature = "zrtl-plugin")]
200mod ffi {
201    #[no_mangle]
202    pub extern "C" fn blinc_spring_create(
203        _stiffness: f32,
204        _damping: f32,
205        _mass: f32,
206        _initial: f32,
207    ) -> *mut std::ffi::c_void {
208        // TODO: Implement
209        std::ptr::null_mut()
210    }
211
212    #[no_mangle]
213    pub extern "C" fn blinc_spring_set_target(_handle: *mut std::ffi::c_void, _target: f32) {
214        // TODO: Implement
215    }
216
217    #[no_mangle]
218    pub extern "C" fn blinc_spring_value(_handle: *mut std::ffi::c_void) -> f32 {
219        // TODO: Implement
220        0.0
221    }
222
223    #[no_mangle]
224    pub extern "C" fn blinc_spring_velocity(_handle: *mut std::ffi::c_void) -> f32 {
225        // TODO: Implement
226        0.0
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn test_spring_settles_to_target() {
236        let mut spring = Spring::new(SpringConfig::stiff(), 0.0);
237        spring.set_target(100.0);
238
239        // Simulate for 2 seconds at 60fps
240        for _ in 0..120 {
241            spring.step(1.0 / 60.0);
242        }
243
244        assert!(spring.is_settled());
245        assert!((spring.value() - 100.0).abs() < 0.01);
246    }
247
248    #[test]
249    fn test_spring_inherits_velocity() {
250        let mut spring = Spring::new(SpringConfig::wobbly(), 0.0);
251        spring.set_target(100.0);
252
253        // Let it get some velocity
254        for _ in 0..10 {
255            spring.step(1.0 / 60.0);
256        }
257
258        let velocity = spring.velocity();
259        assert!(velocity > 0.0);
260
261        // Change target mid-flight - velocity should continue
262        spring.set_target(50.0);
263        assert_eq!(spring.velocity(), velocity);
264    }
265
266    #[test]
267    fn test_spring_presets() {
268        // Test that presets are underdamped (will oscillate) for snappy feel
269        assert!(SpringConfig::wobbly().is_underdamped());
270        assert!(SpringConfig::gentle().is_underdamped());
271
272        // Stiff spring should still be slightly underdamped for natural feel
273        let stiff = SpringConfig::stiff();
274        assert!(stiff.is_underdamped());
275    }
276
277    #[test]
278    fn test_spring_rk4_stability() {
279        // Test that RK4 integration remains stable even with large time steps
280        let mut spring = Spring::new(SpringConfig::stiff(), 0.0);
281        spring.set_target(1000.0);
282
283        // Large time step that might cause instability with Euler integration
284        for _ in 0..100 {
285            spring.step(0.1);
286            // Value should never exceed target too much (stability check)
287            assert!(spring.value() < 2000.0);
288            assert!(spring.value() > -500.0);
289        }
290    }
291
292    #[test]
293    fn test_spring_different_mass() {
294        // Test with heavier mass - should still settle, just slower
295        let config = SpringConfig::new(400.0, 25.0, 2.0);
296        let mut spring = Spring::new(config, 0.0);
297        spring.set_target(100.0);
298
299        // Heavier mass needs more time to settle
300        for _ in 0..240 {
301            spring.step(1.0 / 60.0);
302        }
303
304        assert!(spring.value().is_finite());
305        assert!(spring.is_settled());
306    }
307}