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}
104
105impl Spring {
106    pub fn new(config: SpringConfig, initial: f32) -> Self {
107        Self {
108            config,
109            value: initial,
110            velocity: 0.0,
111            target: initial,
112        }
113    }
114
115    pub fn value(&self) -> f32 {
116        self.value
117    }
118
119    pub fn velocity(&self) -> f32 {
120        self.velocity
121    }
122
123    pub fn target(&self) -> f32 {
124        self.target
125    }
126
127    pub fn set_target(&mut self, target: f32) {
128        self.target = target;
129    }
130
131    /// Check if the spring has settled (within epsilon of target with minimal velocity)
132    pub fn is_settled(&self) -> bool {
133        // Use small epsilons that work for both pixel-based values (scroll)
134        // and normalized values (scale, opacity). 0.01 is imperceptible in both cases.
135        const EPSILON: f32 = 0.01;
136        const VELOCITY_EPSILON: f32 = 0.1;
137
138        (self.value - self.target).abs() < EPSILON && self.velocity.abs() < VELOCITY_EPSILON
139    }
140
141    /// Step the spring simulation using RK4 integration
142    pub fn step(&mut self, dt: f32) {
143        if self.is_settled() {
144            self.value = self.target;
145            self.velocity = 0.0;
146            return;
147        }
148
149        // RK4 integration for accurate spring physics
150        let k1_v = self.acceleration(self.value, self.velocity);
151        let k1_x = self.velocity;
152
153        let k2_v = self.acceleration(
154            self.value + k1_x * dt * 0.5,
155            self.velocity + k1_v * dt * 0.5,
156        );
157        let k2_x = self.velocity + k1_v * dt * 0.5;
158
159        let k3_v = self.acceleration(
160            self.value + k2_x * dt * 0.5,
161            self.velocity + k2_v * dt * 0.5,
162        );
163        let k3_x = self.velocity + k2_v * dt * 0.5;
164
165        let k4_v = self.acceleration(self.value + k3_x * dt, self.velocity + k3_v * dt);
166        let k4_x = self.velocity + k3_v * dt;
167
168        self.velocity += (k1_v + 2.0 * k2_v + 2.0 * k3_v + k4_v) * dt / 6.0;
169        self.value += (k1_x + 2.0 * k2_x + 2.0 * k3_x + k4_x) * dt / 6.0;
170    }
171
172    fn acceleration(&self, x: f32, v: f32) -> f32 {
173        let spring_force = -self.config.stiffness * (x - self.target);
174        let damping_force = -self.config.damping * v;
175        (spring_force + damping_force) / self.config.mass
176    }
177}
178
179// =============================================================================
180// ZRTL Plugin Exports
181// =============================================================================
182
183#[cfg(feature = "zrtl-plugin")]
184mod ffi {
185    #[no_mangle]
186    pub extern "C" fn blinc_spring_create(
187        _stiffness: f32,
188        _damping: f32,
189        _mass: f32,
190        _initial: f32,
191    ) -> *mut std::ffi::c_void {
192        // TODO: Implement
193        std::ptr::null_mut()
194    }
195
196    #[no_mangle]
197    pub extern "C" fn blinc_spring_set_target(_handle: *mut std::ffi::c_void, _target: f32) {
198        // TODO: Implement
199    }
200
201    #[no_mangle]
202    pub extern "C" fn blinc_spring_value(_handle: *mut std::ffi::c_void) -> f32 {
203        // TODO: Implement
204        0.0
205    }
206
207    #[no_mangle]
208    pub extern "C" fn blinc_spring_velocity(_handle: *mut std::ffi::c_void) -> f32 {
209        // TODO: Implement
210        0.0
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn test_spring_settles_to_target() {
220        let mut spring = Spring::new(SpringConfig::stiff(), 0.0);
221        spring.set_target(100.0);
222
223        // Simulate for 2 seconds at 60fps
224        for _ in 0..120 {
225            spring.step(1.0 / 60.0);
226        }
227
228        assert!(spring.is_settled());
229        assert!((spring.value() - 100.0).abs() < 0.01);
230    }
231
232    #[test]
233    fn test_spring_inherits_velocity() {
234        let mut spring = Spring::new(SpringConfig::wobbly(), 0.0);
235        spring.set_target(100.0);
236
237        // Let it get some velocity
238        for _ in 0..10 {
239            spring.step(1.0 / 60.0);
240        }
241
242        let velocity = spring.velocity();
243        assert!(velocity > 0.0);
244
245        // Change target mid-flight - velocity should continue
246        spring.set_target(50.0);
247        assert_eq!(spring.velocity(), velocity);
248    }
249
250    #[test]
251    fn test_spring_presets() {
252        // Test that presets are underdamped (will oscillate) for snappy feel
253        assert!(SpringConfig::wobbly().is_underdamped());
254        assert!(SpringConfig::gentle().is_underdamped());
255
256        // Stiff spring should still be slightly underdamped for natural feel
257        let stiff = SpringConfig::stiff();
258        assert!(stiff.is_underdamped());
259    }
260
261    #[test]
262    fn test_spring_rk4_stability() {
263        // Test that RK4 integration remains stable even with large time steps
264        let mut spring = Spring::new(SpringConfig::stiff(), 0.0);
265        spring.set_target(1000.0);
266
267        // Large time step that might cause instability with Euler integration
268        for _ in 0..100 {
269            spring.step(0.1);
270            // Value should never exceed target too much (stability check)
271            assert!(spring.value() < 2000.0);
272            assert!(spring.value() > -500.0);
273        }
274    }
275
276    #[test]
277    fn test_spring_different_mass() {
278        // Test with heavier mass - should still settle, just slower
279        let config = SpringConfig::new(400.0, 25.0, 2.0);
280        let mut spring = Spring::new(config, 0.0);
281        spring.set_target(100.0);
282
283        // Heavier mass needs more time to settle
284        for _ in 0..240 {
285            spring.step(1.0 / 60.0);
286        }
287
288        assert!(spring.value().is_finite());
289        assert!(spring.is_settled());
290    }
291}