1use crate::config::SpringConfig;
4use animato_core::{AnimationIntrospection, AnimationKind, Inspectable, PlaybackState, Update};
5
6#[derive(Clone, Debug, PartialEq)]
8#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
9pub enum Integrator {
10 SemiImplicitEuler,
12 RungeKutta4,
14}
15
16#[derive(Clone, Debug)]
35#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
36pub struct Spring {
37 pub config: SpringConfig,
39 position: f32,
40 velocity: f32,
41 target: f32,
42 integrator: Integrator,
43 previous_displacement: f32,
44 overshoot_count: u32,
45}
46
47impl Spring {
48 pub fn new(config: SpringConfig) -> Self {
50 Self {
51 config,
52 position: 0.0,
53 velocity: 0.0,
54 target: 0.0,
55 integrator: Integrator::SemiImplicitEuler,
56 previous_displacement: 0.0,
57 overshoot_count: 0,
58 }
59 }
60
61 pub fn from_velocity(initial: f32, velocity: f32, target: f32, config: SpringConfig) -> Self {
66 let mut spring = Self::new(config);
67 spring.position = initial;
68 spring.velocity = if velocity.is_finite() { velocity } else { 0.0 };
69 spring.target = target;
70 spring.previous_displacement = initial - target;
71 spring
72 }
73
74 pub fn set_target(&mut self, target: f32) {
76 self.target = target;
77 self.previous_displacement = self.position - self.target;
78 self.overshoot_count = 0;
79 }
80
81 pub fn position(&self) -> f32 {
83 self.position
84 }
85
86 pub fn velocity(&self) -> f32 {
88 self.velocity
89 }
90
91 pub fn is_settled(&self) -> bool {
93 let eps = self.config.epsilon;
94 (self.position - self.target).abs() < eps && self.velocity.abs() < eps
95 }
96
97 pub fn energy(&self) -> f32 {
102 let displacement = self.position - self.target;
103 0.5 * self.config.mass * self.velocity * self.velocity
104 + 0.5 * self.config.stiffness * displacement * displacement
105 }
106
107 pub fn overshoot_count(&self) -> u32 {
109 self.overshoot_count
110 }
111
112 pub fn snap_to(&mut self, pos: f32) {
114 self.position = pos;
115 self.velocity = 0.0;
116 self.target = pos;
117 self.previous_displacement = 0.0;
118 self.overshoot_count = 0;
119 }
120
121 pub fn use_rk4(mut self, yes: bool) -> Self {
123 self.integrator = if yes {
124 Integrator::RungeKutta4
125 } else {
126 Integrator::SemiImplicitEuler
127 };
128 self
129 }
130
131 #[inline]
134 fn acceleration(&self, position: f32, velocity: f32) -> f32 {
135 let displacement = position - self.target;
136 let spring_force = -self.config.stiffness * displacement;
137 let damping_force = -self.config.damping * velocity;
138 (spring_force + damping_force) / self.config.mass
139 }
140
141 fn step_euler(&mut self, dt: f32) {
142 let acc = self.acceleration(self.position, self.velocity);
143 self.velocity += acc * dt;
144 self.position += self.velocity * dt;
145 }
146
147 fn step_rk4(&mut self, dt: f32) {
148 let p0 = self.position;
152 let v0 = self.velocity;
153
154 let k1v = self.acceleration(p0, v0);
155 let k1p = v0;
156
157 let k2v = self.acceleration(p0 + k1p * dt / 2.0, v0 + k1v * dt / 2.0);
158 let k2p = v0 + k1v * dt / 2.0;
159
160 let k3v = self.acceleration(p0 + k2p * dt / 2.0, v0 + k2v * dt / 2.0);
161 let k3p = v0 + k2v * dt / 2.0;
162
163 let k4v = self.acceleration(p0 + k3p * dt, v0 + k3v * dt);
164 let k4p = v0 + k3v * dt;
165
166 self.position += (dt / 6.0) * (k1p + 2.0 * k2p + 2.0 * k3p + k4p);
167 self.velocity += (dt / 6.0) * (k1v + 2.0 * k2v + 2.0 * k3v + k4v);
168 }
169
170 fn track_overshoot(&mut self) {
171 let displacement = self.position - self.target;
172 let eps = self.config.epsilon.max(0.0);
173 if self.previous_displacement.abs() > eps
174 && displacement.abs() > eps
175 && self.previous_displacement.signum() != displacement.signum()
176 {
177 self.overshoot_count = self.overshoot_count.saturating_add(1);
178 }
179 self.previous_displacement = displacement;
180 }
181}
182
183impl Update for Spring {
184 fn update(&mut self, dt: f32) -> bool {
189 let dt = dt.max(0.0);
190 if dt == 0.0 || self.is_settled() {
191 return !self.is_settled();
192 }
193 if self.config.stiffness <= 0.0 {
195 self.position = self.target;
196 self.velocity = 0.0;
197 return false;
198 }
199 match self.integrator {
200 Integrator::SemiImplicitEuler => self.step_euler(dt),
201 Integrator::RungeKutta4 => self.step_rk4(dt),
202 }
203 self.track_overshoot();
204 !self.is_settled()
205 }
206}
207
208impl Inspectable for Spring {
209 fn introspect(&self) -> AnimationIntrospection {
210 AnimationIntrospection::new(
211 AnimationKind::Spring,
212 if self.is_settled() { 1.0 } else { 0.0 },
213 0.0,
214 None,
215 if self.is_settled() {
216 PlaybackState::Complete
217 } else {
218 PlaybackState::Playing
219 },
220 None,
221 )
222 }
223}
224
225#[cfg(test)]
230mod tests {
231 use super::*;
232
233 const DT: f32 = 1.0 / 60.0;
234 const MAX_STEPS: usize = 10_000;
235
236 fn run_to_settle(spring: &mut Spring) -> usize {
237 let mut steps = 0;
238 while !spring.is_settled() {
239 spring.update(DT);
240 steps += 1;
241 assert!(
242 steps < MAX_STEPS,
243 "Spring did not settle within {} steps",
244 MAX_STEPS
245 );
246 }
247 steps
248 }
249
250 #[test]
251 fn gentle_settles_to_target() {
252 let mut s = Spring::new(SpringConfig::gentle());
253 s.set_target(100.0);
254 run_to_settle(&mut s);
255 assert!((s.position() - 100.0).abs() < 0.01);
256 }
257
258 #[test]
259 fn wobbly_settles_to_target() {
260 let mut s = Spring::new(SpringConfig::wobbly());
261 s.set_target(50.0);
262 run_to_settle(&mut s);
263 assert!((s.position() - 50.0).abs() < 0.01);
264 }
265
266 #[test]
267 fn stiff_settles_to_target() {
268 let mut s = Spring::new(SpringConfig::stiff());
269 s.set_target(-30.0);
270 run_to_settle(&mut s);
271 assert!((s.position() - (-30.0)).abs() < 0.01);
272 }
273
274 #[test]
275 fn slow_settles_to_target() {
276 let mut s = Spring::new(SpringConfig::slow());
277 s.set_target(1.0);
278 run_to_settle(&mut s);
279 assert!((s.position() - 1.0).abs() < 0.01);
280 }
281
282 #[test]
283 fn snappy_settles_to_target() {
284 let mut s = Spring::new(SpringConfig::snappy());
285 s.set_target(200.0);
286 run_to_settle(&mut s);
287 assert!((s.position() - 200.0).abs() < 0.01);
288 }
289
290 #[test]
291 fn snappy_settles_faster_than_slow() {
292 let mut fast = Spring::new(SpringConfig::snappy());
293 fast.set_target(100.0);
294 let fast_steps = run_to_settle(&mut fast);
295
296 let mut slow = Spring::new(SpringConfig::slow());
297 slow.set_target(100.0);
298 let slow_steps = run_to_settle(&mut slow);
299
300 assert!(
301 fast_steps < slow_steps,
302 "snappy={} slow={}",
303 fast_steps,
304 slow_steps
305 );
306 }
307
308 #[test]
309 fn zero_damping_oscillates() {
310 let cfg = SpringConfig {
311 stiffness: 100.0,
312 damping: 0.0,
313 mass: 1.0,
314 epsilon: 0.001,
315 };
316 let mut s = Spring::new(cfg);
317 s.set_target(1.0);
318 for _ in 0..10_000 {
320 s.update(DT);
321 }
322 assert!(!s.is_settled());
323 }
324
325 #[test]
326 fn snap_to_teleports() {
327 let mut s = Spring::new(SpringConfig::default());
328 s.set_target(100.0);
329 s.snap_to(100.0);
330 assert_eq!(s.position(), 100.0);
331 assert_eq!(s.velocity(), 0.0);
332 assert!(s.is_settled());
333 }
334
335 #[test]
336 fn rk4_also_settles() {
337 let mut s = Spring::new(SpringConfig::wobbly()).use_rk4(true);
338 s.set_target(100.0);
339 run_to_settle(&mut s);
340 assert!((s.position() - 100.0).abs() < 0.01);
341 }
342
343 #[test]
344 fn zero_stiffness_snaps_immediately() {
345 let cfg = SpringConfig {
346 stiffness: 0.0,
347 ..SpringConfig::default()
348 };
349 let mut s = Spring::new(cfg);
350 s.set_target(42.0);
351 s.update(DT);
352 assert_eq!(s.position(), 42.0);
353 assert!(s.is_settled());
354 }
355
356 #[test]
357 fn negative_dt_is_noop() {
358 let mut s = Spring::new(SpringConfig::default());
359 s.set_target(100.0);
360 let pos_before = s.position();
361 s.update(-1.0);
362 assert_eq!(s.position(), pos_before);
363 }
364
365 #[test]
366 fn from_velocity_reaches_target_and_loses_energy() {
367 let mut s = Spring::from_velocity(0.0, 300.0, 100.0, SpringConfig::stiff());
368 let start_energy = s.energy();
369 run_to_settle(&mut s);
370 assert!((s.position() - 100.0).abs() < 0.01);
371 assert!(s.energy() < start_energy);
372 }
373
374 #[test]
375 fn damping_modes_order_damping() {
376 let critical = SpringConfig::critically_damped(100.0);
377 let over = SpringConfig::overdamped(100.0, 1.5);
378 let under = SpringConfig::underdamped(100.0, 0.5);
379 assert!(under.damping < critical.damping);
380 assert!(over.damping > critical.damping);
381 }
382
383 #[test]
384 fn overshoot_count_tracks_target_crossings() {
385 let mut s = Spring::from_velocity(0.0, 0.0, 1.0, SpringConfig::underdamped(120.0, 0.2));
386 for _ in 0..240 {
387 s.update(DT);
388 }
389 assert!(s.overshoot_count() > 0);
390 }
391}