1#![forbid(unsafe_code)]
2
3use std::time::Duration;
46
47use super::Animation;
48
49const MAX_STEP_SECS: f64 = 0.004;
52
53const DEFAULT_REST_THRESHOLD: f64 = 0.001;
55
56const DEFAULT_VELOCITY_THRESHOLD: f64 = 0.01;
59
60const MIN_STIFFNESS: f64 = 0.1;
62
63#[derive(Debug, Clone)]
86pub struct Spring {
87 position: f64,
88 velocity: f64,
89 target: f64,
90 initial: f64,
91 stiffness: f64,
92 damping: f64,
93 rest_threshold: f64,
94 velocity_threshold: f64,
95 at_rest: bool,
96}
97
98impl Spring {
99 #[must_use]
104 pub fn new(initial: f64, target: f64) -> Self {
105 Self {
106 position: initial,
107 velocity: 0.0,
108 target,
109 initial,
110 stiffness: 170.0,
111 damping: 26.0,
112 rest_threshold: DEFAULT_REST_THRESHOLD,
113 velocity_threshold: DEFAULT_VELOCITY_THRESHOLD,
114 at_rest: false,
115 }
116 }
117
118 #[must_use]
120 pub fn normalized() -> Self {
121 Self::new(0.0, 1.0)
122 }
123
124 #[must_use]
126 pub fn with_stiffness(mut self, k: f64) -> Self {
127 self.stiffness = k.max(MIN_STIFFNESS);
128 self
129 }
130
131 #[must_use]
133 pub fn with_damping(mut self, c: f64) -> Self {
134 self.damping = c.max(0.0);
135 self
136 }
137
138 #[must_use]
140 pub fn with_rest_threshold(mut self, threshold: f64) -> Self {
141 self.rest_threshold = threshold.abs();
142 self
143 }
144
145 #[must_use]
147 pub fn with_velocity_threshold(mut self, threshold: f64) -> Self {
148 self.velocity_threshold = threshold.abs();
149 self
150 }
151
152 #[must_use]
154 pub fn position(&self) -> f64 {
155 self.position
156 }
157
158 #[must_use]
160 pub fn velocity(&self) -> f64 {
161 self.velocity
162 }
163
164 #[must_use]
166 pub fn target(&self) -> f64 {
167 self.target
168 }
169
170 #[must_use]
172 pub fn stiffness(&self) -> f64 {
173 self.stiffness
174 }
175
176 #[must_use]
178 pub fn damping(&self) -> f64 {
179 self.damping
180 }
181
182 pub fn set_target(&mut self, target: f64) {
184 if (self.target - target).abs() > self.rest_threshold {
185 self.target = target;
186 self.at_rest = false;
187 }
188 }
189
190 pub fn impulse(&mut self, velocity_delta: f64) {
192 self.velocity += velocity_delta;
193 self.at_rest = false;
194 }
195
196 #[must_use]
198 pub fn is_at_rest(&self) -> bool {
199 self.at_rest
200 }
201
202 #[must_use]
207 pub fn critical_damping(&self) -> f64 {
208 2.0 * self.stiffness.sqrt()
209 }
210
211 fn step(&mut self, dt: f64) {
213 let displacement = self.position - self.target;
218 let spring_force = -self.stiffness * displacement;
219 let damping_force = -self.damping * self.velocity;
220 let acceleration = spring_force + damping_force;
221
222 self.velocity += acceleration * dt;
223 self.position += self.velocity * dt;
224 }
225
226 pub fn advance(&mut self, dt: Duration) {
228 if self.at_rest {
229 return;
230 }
231
232 let total_secs = dt.as_secs_f64();
233 if total_secs <= 0.0 {
234 return;
235 }
236
237 let mut remaining = total_secs;
239 while remaining > 0.0 {
240 let step_dt = remaining.min(MAX_STEP_SECS);
241 self.step(step_dt);
242 remaining -= step_dt;
243 }
244
245 let pos_delta = (self.position - self.target).abs();
247 let vel_abs = self.velocity.abs();
248 if pos_delta < self.rest_threshold && vel_abs < self.velocity_threshold {
249 self.position = self.target;
250 self.velocity = 0.0;
251 self.at_rest = true;
252 }
253 }
254}
255
256impl Animation for Spring {
257 fn tick(&mut self, dt: Duration) {
258 self.advance(dt);
259 }
260
261 fn is_complete(&self) -> bool {
262 self.at_rest
263 }
264
265 fn value(&self) -> f32 {
270 (self.position as f32).clamp(0.0, 1.0)
271 }
272
273 fn reset(&mut self) {
274 self.position = self.initial;
275 self.velocity = 0.0;
276 self.at_rest = false;
277 }
278}
279
280pub mod presets {
286 use super::Spring;
287
288 #[must_use]
290 pub fn gentle() -> Spring {
291 Spring::normalized()
292 .with_stiffness(120.0)
293 .with_damping(20.0)
294 }
295
296 #[must_use]
298 pub fn bouncy() -> Spring {
299 Spring::normalized()
300 .with_stiffness(300.0)
301 .with_damping(10.0)
302 }
303
304 #[must_use]
306 pub fn stiff() -> Spring {
307 Spring::normalized()
308 .with_stiffness(400.0)
309 .with_damping(38.0)
310 }
311
312 #[must_use]
314 pub fn critical() -> Spring {
315 let k: f64 = 170.0;
316 let c = 2.0 * k.sqrt(); Spring::normalized().with_stiffness(k).with_damping(c)
318 }
319
320 #[must_use]
322 pub fn slow() -> Spring {
323 Spring::normalized().with_stiffness(50.0).with_damping(14.0)
324 }
325}
326
327#[cfg(test)]
332mod tests {
333 use super::*;
334
335 const MS_16: Duration = Duration::from_millis(16);
336
337 fn simulate(spring: &mut Spring, frames: usize) {
338 for _ in 0..frames {
339 spring.tick(MS_16);
340 }
341 }
342
343 #[test]
344 fn spring_reaches_target() {
345 let mut spring = Spring::new(0.0, 100.0)
346 .with_stiffness(170.0)
347 .with_damping(26.0);
348
349 simulate(&mut spring, 200);
350
351 assert!(
352 (spring.position() - 100.0).abs() < 0.1,
353 "position: {}",
354 spring.position()
355 );
356 assert!(spring.is_complete());
357 }
358
359 #[test]
360 fn spring_starts_at_initial() {
361 let spring = Spring::new(50.0, 100.0);
362 assert!((spring.position() - 50.0).abs() < f64::EPSILON);
363 }
364
365 #[test]
366 fn spring_target_change() {
367 let mut spring = Spring::new(0.0, 100.0);
368 spring.set_target(200.0);
369 assert!((spring.target() - 200.0).abs() < f64::EPSILON);
370 }
371
372 #[test]
373 fn spring_with_high_damping_minimal_overshoot() {
374 let mut spring = Spring::new(0.0, 100.0)
375 .with_stiffness(170.0)
376 .with_damping(100.0); let mut max_overshoot = 0.0_f64;
379 for _ in 0..300 {
380 spring.tick(MS_16);
381 let overshoot = spring.position() - 100.0;
382 if overshoot > max_overshoot {
383 max_overshoot = overshoot;
384 }
385 }
386
387 assert!(
388 max_overshoot < 1.0,
389 "High damping should minimize overshoot, got {max_overshoot}"
390 );
391 }
392
393 #[test]
394 fn critical_damping_no_overshoot() {
395 let mut spring = presets::critical();
396 spring.set_target(1.0);
398
399 let mut max_pos = 0.0_f64;
400 for _ in 0..300 {
401 spring.tick(MS_16);
402 if spring.position() > max_pos {
403 max_pos = spring.position();
404 }
405 }
406
407 assert!(
408 max_pos < 1.05,
409 "Critical damping should have negligible overshoot, got {max_pos}"
410 );
411 }
412
413 #[test]
414 fn bouncy_spring_overshoots() {
415 let mut spring = presets::bouncy();
416
417 let mut max_pos = 0.0_f64;
418 for _ in 0..200 {
419 spring.tick(MS_16);
420 if spring.position() > max_pos {
421 max_pos = spring.position();
422 }
423 }
424
425 assert!(
426 max_pos > 1.0,
427 "Bouncy spring should overshoot target, max was {max_pos}"
428 );
429 }
430
431 #[test]
432 fn normalized_spring_value_clamped() {
433 let mut spring = presets::bouncy();
434 for _ in 0..200 {
435 spring.tick(MS_16);
436 let v = spring.value();
437 assert!(
438 (0.0..=1.0).contains(&v),
439 "Animation::value() must be in [0,1], got {v}"
440 );
441 }
442 }
443
444 #[test]
445 fn spring_reset() {
446 let mut spring = Spring::new(0.0, 1.0);
447 simulate(&mut spring, 100);
448 assert!(spring.is_complete());
449
450 spring.reset();
451 assert!(!spring.is_complete());
452 assert!((spring.position() - 0.0).abs() < f64::EPSILON);
453 assert!((spring.velocity() - 0.0).abs() < f64::EPSILON);
454 }
455
456 #[test]
457 fn spring_impulse_wakes() {
458 let mut spring = Spring::new(0.0, 0.0);
459 simulate(&mut spring, 100);
460 assert!(spring.is_complete());
461
462 spring.impulse(50.0);
463 assert!(!spring.is_complete());
464 spring.tick(MS_16);
465 assert!(spring.position().abs() > 0.0);
466 }
467
468 #[test]
469 fn set_target_wakes_spring() {
470 let mut spring = Spring::new(0.0, 1.0);
471 simulate(&mut spring, 200);
472 assert!(spring.is_complete());
473
474 spring.set_target(2.0);
475 assert!(!spring.is_complete());
476 }
477
478 #[test]
479 fn set_target_same_value_stays_at_rest() {
480 let mut spring = Spring::new(0.0, 1.0);
481 simulate(&mut spring, 200);
482 assert!(spring.is_complete());
483
484 spring.set_target(1.0);
485 assert!(spring.is_complete());
486 }
487
488 #[test]
489 fn zero_dt_noop() {
490 let mut spring = Spring::new(0.0, 1.0);
491 let pos_before = spring.position();
492 spring.tick(Duration::ZERO);
493 assert!((spring.position() - pos_before).abs() < f64::EPSILON);
494 }
495
496 #[test]
497 fn large_dt_subdivided() {
498 let mut spring = Spring::new(0.0, 1.0)
499 .with_stiffness(170.0)
500 .with_damping(26.0);
501
502 spring.tick(Duration::from_secs(5));
504 assert!(
505 (spring.position() - 1.0).abs() < 0.01,
506 "position: {}",
507 spring.position()
508 );
509 }
510
511 #[test]
512 fn zero_stiffness_clamped() {
513 let spring = Spring::new(0.0, 1.0).with_stiffness(0.0);
514 assert!(spring.stiffness() >= MIN_STIFFNESS);
515 }
516
517 #[test]
518 fn negative_damping_clamped() {
519 let spring = Spring::new(0.0, 1.0).with_damping(-5.0);
520 assert!(spring.damping() >= 0.0);
521 }
522
523 #[test]
524 fn critical_damping_coefficient() {
525 let spring = Spring::new(0.0, 1.0).with_stiffness(100.0);
526 assert!((spring.critical_damping() - 20.0).abs() < f64::EPSILON);
527 }
528
529 #[test]
530 fn spring_negative_target() {
531 let mut spring = Spring::new(0.0, -1.0)
532 .with_stiffness(170.0)
533 .with_damping(26.0);
534
535 simulate(&mut spring, 200);
536 assert!(
537 (spring.position() - -1.0).abs() < 0.01,
538 "position: {}",
539 spring.position()
540 );
541 }
542
543 #[test]
544 fn spring_reverse_direction() {
545 let mut spring = Spring::new(1.0, 0.0)
546 .with_stiffness(170.0)
547 .with_damping(26.0);
548
549 simulate(&mut spring, 200);
550 assert!(
551 spring.position().abs() < 0.01,
552 "position: {}",
553 spring.position()
554 );
555 }
556
557 #[test]
558 fn presets_all_converge() {
559 let presets: Vec<(&str, Spring)> = vec![
560 ("gentle", presets::gentle()),
561 ("bouncy", presets::bouncy()),
562 ("stiff", presets::stiff()),
563 ("critical", presets::critical()),
564 ("slow", presets::slow()),
565 ];
566
567 for (name, mut spring) in presets {
568 simulate(&mut spring, 500);
569 assert!(
570 spring.is_complete(),
571 "preset '{name}' did not converge after 500 frames (pos: {}, vel: {})",
572 spring.position(),
573 spring.velocity()
574 );
575 }
576 }
577
578 #[test]
579 fn deterministic_across_runs() {
580 let run = || {
581 let mut spring = Spring::new(0.0, 1.0)
582 .with_stiffness(170.0)
583 .with_damping(26.0);
584 let mut positions = Vec::new();
585 for _ in 0..50 {
586 spring.tick(MS_16);
587 positions.push(spring.position());
588 }
589 positions
590 };
591
592 let run1 = run();
593 let run2 = run();
594 assert_eq!(run1, run2, "Spring should be deterministic");
595 }
596
597 #[test]
598 fn at_rest_spring_skips_computation() {
599 let mut spring = Spring::new(0.0, 1.0);
600 simulate(&mut spring, 200);
601 assert!(spring.is_complete());
602
603 let pos = spring.position();
604 spring.tick(MS_16);
605 assert!(
606 (spring.position() - pos).abs() < f64::EPSILON,
607 "At-rest spring should not change position on tick"
608 );
609 }
610
611 #[test]
612 fn animation_trait_value_for_normalized() {
613 let mut spring = Spring::normalized();
614 assert!((spring.value() - 0.0).abs() < f32::EPSILON);
615
616 simulate(&mut spring, 200);
617 assert!((spring.value() - 1.0).abs() < 0.01);
618 }
619
620 #[test]
621 fn stiff_preset_faster_than_slow() {
622 let mut stiff = presets::stiff();
623 let mut slow = presets::slow();
624
625 for _ in 0..30 {
627 stiff.tick(MS_16);
628 slow.tick(MS_16);
629 }
630
631 let stiff_delta = (stiff.position() - 1.0).abs();
632 let slow_delta = (slow.position() - 1.0).abs();
633 assert!(
634 stiff_delta < slow_delta,
635 "Stiff ({stiff_delta}) should be closer to target than slow ({slow_delta})"
636 );
637 }
638}