1use crate::config::SpringConfig;
4use animato_core::{
5 AnimationIntrospection, AnimationKind, Inspectable, Playable, PlaybackState, Update,
6};
7
8#[derive(Clone, Debug, PartialEq)]
10#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
11pub enum Integrator {
12 SemiImplicitEuler,
14 RungeKutta4,
16}
17
18#[derive(Clone, Debug)]
37#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
38pub struct Spring {
39 pub config: SpringConfig,
41 position: f32,
42 velocity: f32,
43 target: f32,
44 integrator: Integrator,
45 previous_displacement: f32,
46 overshoot_count: u32,
47}
48
49impl Spring {
50 pub fn new(config: SpringConfig) -> Self {
52 Self {
53 config,
54 position: 0.0,
55 velocity: 0.0,
56 target: 0.0,
57 integrator: Integrator::SemiImplicitEuler,
58 previous_displacement: 0.0,
59 overshoot_count: 0,
60 }
61 }
62
63 pub fn from_velocity(initial: f32, velocity: f32, target: f32, config: SpringConfig) -> Self {
68 let mut spring = Self::new(config);
69 spring.position = initial;
70 spring.velocity = if velocity.is_finite() { velocity } else { 0.0 };
71 spring.target = target;
72 spring.previous_displacement = initial - target;
73 spring
74 }
75
76 pub fn set_target(&mut self, target: f32) {
78 self.target = target;
79 self.previous_displacement = self.position - self.target;
80 self.overshoot_count = 0;
81 }
82
83 pub fn position(&self) -> f32 {
85 self.position
86 }
87
88 pub fn velocity(&self) -> f32 {
90 self.velocity
91 }
92
93 pub fn is_settled(&self) -> bool {
95 let eps = self.config.epsilon;
96 (self.position - self.target).abs() < eps && self.velocity.abs() < eps
97 }
98
99 pub fn energy(&self) -> f32 {
104 let displacement = self.position - self.target;
105 0.5 * self.config.mass * self.velocity * self.velocity
106 + 0.5 * self.config.stiffness * displacement * displacement
107 }
108
109 pub fn overshoot_count(&self) -> u32 {
111 self.overshoot_count
112 }
113
114 pub fn snap_to(&mut self, pos: f32) {
116 self.position = pos;
117 self.velocity = 0.0;
118 self.target = pos;
119 self.previous_displacement = 0.0;
120 self.overshoot_count = 0;
121 }
122
123 pub fn use_rk4(mut self, yes: bool) -> Self {
125 self.integrator = if yes {
126 Integrator::RungeKutta4
127 } else {
128 Integrator::SemiImplicitEuler
129 };
130 self
131 }
132
133 #[inline]
136 fn acceleration(&self, position: f32, velocity: f32) -> f32 {
137 let displacement = position - self.target;
138 let spring_force = -self.config.stiffness * displacement;
139 let damping_force = -self.config.damping * velocity;
140 (spring_force + damping_force) / self.config.mass
141 }
142
143 fn step_euler(&mut self, dt: f32) {
144 let acc = self.acceleration(self.position, self.velocity);
145 self.velocity += acc * dt;
146 self.position += self.velocity * dt;
147 }
148
149 fn step_rk4(&mut self, dt: f32) {
150 let p0 = self.position;
154 let v0 = self.velocity;
155
156 let k1v = self.acceleration(p0, v0);
157 let k1p = v0;
158
159 let k2v = self.acceleration(p0 + k1p * dt / 2.0, v0 + k1v * dt / 2.0);
160 let k2p = v0 + k1v * dt / 2.0;
161
162 let k3v = self.acceleration(p0 + k2p * dt / 2.0, v0 + k2v * dt / 2.0);
163 let k3p = v0 + k2v * dt / 2.0;
164
165 let k4v = self.acceleration(p0 + k3p * dt, v0 + k3v * dt);
166 let k4p = v0 + k3v * dt;
167
168 self.position += (dt / 6.0) * (k1p + 2.0 * k2p + 2.0 * k3p + k4p);
169 self.velocity += (dt / 6.0) * (k1v + 2.0 * k2v + 2.0 * k3v + k4v);
170 }
171
172 fn track_overshoot(&mut self) {
173 let displacement = self.position - self.target;
174 let eps = self.config.epsilon.max(0.0);
175 if self.previous_displacement.abs() > eps
176 && displacement.abs() > eps
177 && self.previous_displacement.signum() != displacement.signum()
178 {
179 self.overshoot_count = self.overshoot_count.saturating_add(1);
180 }
181 self.previous_displacement = displacement;
182 }
183}
184
185impl Update for Spring {
186 fn update(&mut self, dt: f32) -> bool {
191 let dt = dt.max(0.0);
192 if dt == 0.0 || self.is_settled() {
193 return !self.is_settled();
194 }
195 if self.config.stiffness <= 0.0 {
197 self.position = self.target;
198 self.velocity = 0.0;
199 return false;
200 }
201 match self.integrator {
202 Integrator::SemiImplicitEuler => self.step_euler(dt),
203 Integrator::RungeKutta4 => self.step_rk4(dt),
204 }
205 self.track_overshoot();
206 !self.is_settled()
207 }
208}
209
210impl Inspectable for Spring {
211 fn introspect(&self) -> AnimationIntrospection {
212 AnimationIntrospection::new(
213 AnimationKind::Spring,
214 if self.is_settled() { 1.0 } else { 0.0 },
215 0.0,
216 None,
217 if self.is_settled() {
218 PlaybackState::Complete
219 } else {
220 PlaybackState::Playing
221 },
222 None,
223 )
224 }
225}
226
227impl Playable for Spring {
228 fn duration(&self) -> f32 {
229 f32::INFINITY
231 }
232
233 fn reset(&mut self) {
234 let target = self.target;
236 self.snap_to(0.0);
237 self.target = target;
238 }
239
240 fn seek_to(&mut self, _progress: f32) {
241 if _progress >= 1.0 {
243 let target = self.target;
244 self.snap_to(target);
245 }
246 }
247
248 fn is_complete(&self) -> bool {
249 self.is_settled()
250 }
251
252 fn as_any(&self) -> &dyn core::any::Any {
253 self
254 }
255
256 fn as_any_mut(&mut self) -> &mut dyn core::any::Any {
257 self
258 }
259}
260
261#[cfg(test)]
266mod tests {
267 use super::*;
268
269 const DT: f32 = 1.0 / 60.0;
270 const MAX_STEPS: usize = 10_000;
271
272 fn run_to_settle(spring: &mut Spring) -> usize {
273 let mut steps = 0;
274 while !spring.is_settled() {
275 spring.update(DT);
276 steps += 1;
277 assert!(
278 steps < MAX_STEPS,
279 "Spring did not settle within {} steps",
280 MAX_STEPS
281 );
282 }
283 steps
284 }
285
286 #[test]
287 fn gentle_settles_to_target() {
288 let mut s = Spring::new(SpringConfig::gentle());
289 s.set_target(100.0);
290 run_to_settle(&mut s);
291 assert!((s.position() - 100.0).abs() < 0.01);
292 }
293
294 #[test]
295 fn wobbly_settles_to_target() {
296 let mut s = Spring::new(SpringConfig::wobbly());
297 s.set_target(50.0);
298 run_to_settle(&mut s);
299 assert!((s.position() - 50.0).abs() < 0.01);
300 }
301
302 #[test]
303 fn stiff_settles_to_target() {
304 let mut s = Spring::new(SpringConfig::stiff());
305 s.set_target(-30.0);
306 run_to_settle(&mut s);
307 assert!((s.position() - (-30.0)).abs() < 0.01);
308 }
309
310 #[test]
311 fn slow_settles_to_target() {
312 let mut s = Spring::new(SpringConfig::slow());
313 s.set_target(1.0);
314 run_to_settle(&mut s);
315 assert!((s.position() - 1.0).abs() < 0.01);
316 }
317
318 #[test]
319 fn snappy_settles_to_target() {
320 let mut s = Spring::new(SpringConfig::snappy());
321 s.set_target(200.0);
322 run_to_settle(&mut s);
323 assert!((s.position() - 200.0).abs() < 0.01);
324 }
325
326 #[test]
327 fn snappy_settles_faster_than_slow() {
328 let mut fast = Spring::new(SpringConfig::snappy());
329 fast.set_target(100.0);
330 let fast_steps = run_to_settle(&mut fast);
331
332 let mut slow = Spring::new(SpringConfig::slow());
333 slow.set_target(100.0);
334 let slow_steps = run_to_settle(&mut slow);
335
336 assert!(
337 fast_steps < slow_steps,
338 "snappy={} slow={}",
339 fast_steps,
340 slow_steps
341 );
342 }
343
344 #[test]
345 fn zero_damping_oscillates() {
346 let cfg = SpringConfig {
347 stiffness: 100.0,
348 damping: 0.0,
349 mass: 1.0,
350 epsilon: 0.001,
351 };
352 let mut s = Spring::new(cfg);
353 s.set_target(1.0);
354 for _ in 0..10_000 {
356 s.update(DT);
357 }
358 assert!(!s.is_settled());
359 }
360
361 #[test]
362 fn snap_to_teleports() {
363 let mut s = Spring::new(SpringConfig::default());
364 s.set_target(100.0);
365 s.snap_to(100.0);
366 assert_eq!(s.position(), 100.0);
367 assert_eq!(s.velocity(), 0.0);
368 assert!(s.is_settled());
369 }
370
371 #[test]
372 fn rk4_also_settles() {
373 let mut s = Spring::new(SpringConfig::wobbly()).use_rk4(true);
374 s.set_target(100.0);
375 run_to_settle(&mut s);
376 assert!((s.position() - 100.0).abs() < 0.01);
377 }
378
379 #[test]
380 fn zero_stiffness_snaps_immediately() {
381 let cfg = SpringConfig {
382 stiffness: 0.0,
383 ..SpringConfig::default()
384 };
385 let mut s = Spring::new(cfg);
386 s.set_target(42.0);
387 s.update(DT);
388 assert_eq!(s.position(), 42.0);
389 assert!(s.is_settled());
390 }
391
392 #[test]
393 fn negative_dt_is_noop() {
394 let mut s = Spring::new(SpringConfig::default());
395 s.set_target(100.0);
396 let pos_before = s.position();
397 s.update(-1.0);
398 assert_eq!(s.position(), pos_before);
399 }
400
401 #[test]
402 fn from_velocity_reaches_target_and_loses_energy() {
403 let mut s = Spring::from_velocity(0.0, 300.0, 100.0, SpringConfig::stiff());
404 let start_energy = s.energy();
405 run_to_settle(&mut s);
406 assert!((s.position() - 100.0).abs() < 0.01);
407 assert!(s.energy() < start_energy);
408 }
409
410 #[test]
411 fn damping_modes_order_damping() {
412 let critical = SpringConfig::critically_damped(100.0);
413 let over = SpringConfig::overdamped(100.0, 1.5);
414 let under = SpringConfig::underdamped(100.0, 0.5);
415 assert!(under.damping < critical.damping);
416 assert!(over.damping > critical.damping);
417 }
418
419 #[test]
420 fn overshoot_count_tracks_target_crossings() {
421 let mut s = Spring::from_velocity(0.0, 0.0, 1.0, SpringConfig::underdamped(120.0, 0.2));
422 for _ in 0..240 {
423 s.update(DT);
424 }
425 assert!(s.overshoot_count() > 0);
426 }
427
428 #[test]
431 fn playable_duration_is_infinite() {
432 let s = Spring::new(SpringConfig::snappy());
433 assert!(Playable::duration(&s).is_infinite());
434 }
435
436 #[test]
437 fn playable_is_complete_when_settled() {
438 let mut s = Spring::new(SpringConfig::snappy());
439 s.set_target(100.0);
440 assert!(!Playable::is_complete(&s));
441 run_to_settle(&mut s);
442 assert!(Playable::is_complete(&s));
443 }
444
445 #[test]
446 fn playable_seek_to_one_snaps_to_target() {
447 let mut s = Spring::new(SpringConfig::snappy());
448 s.set_target(100.0);
449 Playable::seek_to(&mut s, 1.0);
450 assert!(s.is_settled());
451 assert!((s.position() - 100.0).abs() < 0.01);
452 }
453
454 #[test]
455 fn playable_seek_to_zero_does_not_snap() {
456 let mut s = Spring::new(SpringConfig::snappy());
457 s.set_target(100.0);
458 Playable::seek_to(&mut s, 0.0);
459 assert!(!s.is_settled());
460 }
461
462 #[test]
463 fn playable_reset_zeroes_position() {
464 let mut s = Spring::new(SpringConfig::snappy());
465 s.set_target(100.0);
466 run_to_settle(&mut s);
467 assert!((s.position() - 100.0).abs() < 0.01);
468 Playable::reset(&mut s);
469 assert_eq!(s.position(), 0.0);
470 }
471
472 #[test]
473 fn playable_as_any_downcasts() {
474 let s = Spring::new(SpringConfig::default());
475 assert!(Playable::as_any(&s).is::<Spring>());
476 let mut s = s;
477 assert!(Playable::as_any_mut(&mut s).is::<Spring>());
478 }
479}