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 #[inline]
154 #[must_use]
155 pub fn position(&self) -> f64 {
156 self.position
157 }
158
159 #[inline]
161 #[must_use]
162 pub fn velocity(&self) -> f64 {
163 self.velocity
164 }
165
166 #[inline]
168 #[must_use]
169 pub fn target(&self) -> f64 {
170 self.target
171 }
172
173 #[inline]
175 #[must_use]
176 pub fn stiffness(&self) -> f64 {
177 self.stiffness
178 }
179
180 #[inline]
182 #[must_use]
183 pub fn damping(&self) -> f64 {
184 self.damping
185 }
186
187 pub fn set_target(&mut self, target: f64) {
189 if (self.target - target).abs() > self.rest_threshold {
190 self.target = target;
191 self.at_rest = false;
192 }
193 }
194
195 pub fn impulse(&mut self, velocity_delta: f64) {
197 self.velocity += velocity_delta;
198 self.at_rest = false;
199 }
200
201 #[inline]
203 #[must_use]
204 pub fn is_at_rest(&self) -> bool {
205 self.at_rest
206 }
207
208 #[must_use]
213 pub fn critical_damping(&self) -> f64 {
214 2.0 * self.stiffness.sqrt()
215 }
216
217 fn step(&mut self, dt: f64) {
219 let displacement = self.position - self.target;
224 let spring_force = -self.stiffness * displacement;
225 let damping_force = -self.damping * self.velocity;
226 let acceleration = spring_force + damping_force;
227
228 self.velocity += acceleration * dt;
229 self.position += self.velocity * dt;
230 }
231
232 pub fn advance(&mut self, dt: Duration) {
234 if self.at_rest {
235 return;
236 }
237
238 let total_secs = dt.as_secs_f64();
239 if total_secs <= 0.0 {
240 return;
241 }
242
243 let mut remaining = total_secs;
245 while remaining > 0.0 {
246 let step_dt = remaining.min(MAX_STEP_SECS);
247 self.step(step_dt);
248 remaining -= step_dt;
249 }
250
251 let pos_delta = (self.position - self.target).abs();
253 let vel_abs = self.velocity.abs();
254 if pos_delta < self.rest_threshold && vel_abs < self.velocity_threshold {
255 self.position = self.target;
256 self.velocity = 0.0;
257 self.at_rest = true;
258 }
259 }
260}
261
262impl Animation for Spring {
263 fn tick(&mut self, dt: Duration) {
264 self.advance(dt);
265 }
266
267 fn is_complete(&self) -> bool {
268 self.at_rest
269 }
270
271 fn value(&self) -> f32 {
276 (self.position as f32).clamp(0.0, 1.0)
277 }
278
279 fn reset(&mut self) {
280 self.position = self.initial;
281 self.velocity = 0.0;
282 self.at_rest = false;
283 }
284}
285
286pub mod presets {
292 use super::Spring;
293
294 #[must_use]
296 pub fn gentle() -> Spring {
297 Spring::normalized()
298 .with_stiffness(120.0)
299 .with_damping(20.0)
300 }
301
302 #[must_use]
304 pub fn bouncy() -> Spring {
305 Spring::normalized()
306 .with_stiffness(300.0)
307 .with_damping(10.0)
308 }
309
310 #[must_use]
312 pub fn stiff() -> Spring {
313 Spring::normalized()
314 .with_stiffness(400.0)
315 .with_damping(38.0)
316 }
317
318 #[must_use]
320 pub fn critical() -> Spring {
321 let k: f64 = 170.0;
322 let c = 2.0 * k.sqrt(); Spring::normalized().with_stiffness(k).with_damping(c)
324 }
325
326 #[must_use]
328 pub fn slow() -> Spring {
329 Spring::normalized().with_stiffness(50.0).with_damping(14.0)
330 }
331}
332
333#[cfg(test)]
338mod tests {
339 use super::*;
340
341 const MS_16: Duration = Duration::from_millis(16);
342
343 fn simulate(spring: &mut Spring, frames: usize) {
344 for _ in 0..frames {
345 spring.tick(MS_16);
346 }
347 }
348
349 #[test]
350 fn spring_reaches_target() {
351 let mut spring = Spring::new(0.0, 100.0)
352 .with_stiffness(170.0)
353 .with_damping(26.0);
354
355 simulate(&mut spring, 200);
356
357 assert!(
358 (spring.position() - 100.0).abs() < 0.1,
359 "position: {}",
360 spring.position()
361 );
362 assert!(spring.is_complete());
363 }
364
365 #[test]
366 fn spring_starts_at_initial() {
367 let spring = Spring::new(50.0, 100.0);
368 assert!((spring.position() - 50.0).abs() < f64::EPSILON);
369 }
370
371 #[test]
372 fn spring_target_change() {
373 let mut spring = Spring::new(0.0, 100.0);
374 spring.set_target(200.0);
375 assert!((spring.target() - 200.0).abs() < f64::EPSILON);
376 }
377
378 #[test]
379 fn spring_with_high_damping_minimal_overshoot() {
380 let mut spring = Spring::new(0.0, 100.0)
381 .with_stiffness(170.0)
382 .with_damping(100.0); let mut max_overshoot = 0.0_f64;
385 for _ in 0..300 {
386 spring.tick(MS_16);
387 let overshoot = spring.position() - 100.0;
388 if overshoot > max_overshoot {
389 max_overshoot = overshoot;
390 }
391 }
392
393 assert!(
394 max_overshoot < 1.0,
395 "High damping should minimize overshoot, got {max_overshoot}"
396 );
397 }
398
399 #[test]
400 fn critical_damping_no_overshoot() {
401 let mut spring = presets::critical();
402 spring.set_target(1.0);
404
405 let mut max_pos = 0.0_f64;
406 for _ in 0..300 {
407 spring.tick(MS_16);
408 if spring.position() > max_pos {
409 max_pos = spring.position();
410 }
411 }
412
413 assert!(
414 max_pos < 1.05,
415 "Critical damping should have negligible overshoot, got {max_pos}"
416 );
417 }
418
419 #[test]
420 fn bouncy_spring_overshoots() {
421 let mut spring = presets::bouncy();
422
423 let mut max_pos = 0.0_f64;
424 for _ in 0..200 {
425 spring.tick(MS_16);
426 if spring.position() > max_pos {
427 max_pos = spring.position();
428 }
429 }
430
431 assert!(
432 max_pos > 1.0,
433 "Bouncy spring should overshoot target, max was {max_pos}"
434 );
435 }
436
437 #[test]
438 fn normalized_spring_value_clamped() {
439 let mut spring = presets::bouncy();
440 for _ in 0..200 {
441 spring.tick(MS_16);
442 let v = spring.value();
443 assert!(
444 (0.0..=1.0).contains(&v),
445 "Animation::value() must be in [0,1], got {v}"
446 );
447 }
448 }
449
450 #[test]
451 fn spring_reset() {
452 let mut spring = Spring::new(0.0, 1.0);
453 simulate(&mut spring, 100);
454 assert!(spring.is_complete());
455
456 spring.reset();
457 assert!(!spring.is_complete());
458 assert!((spring.position() - 0.0).abs() < f64::EPSILON);
459 assert!((spring.velocity() - 0.0).abs() < f64::EPSILON);
460 }
461
462 #[test]
463 fn spring_impulse_wakes() {
464 let mut spring = Spring::new(0.0, 0.0);
465 simulate(&mut spring, 100);
466 assert!(spring.is_complete());
467
468 spring.impulse(50.0);
469 assert!(!spring.is_complete());
470 spring.tick(MS_16);
471 assert!(spring.position().abs() > 0.0);
472 }
473
474 #[test]
475 fn set_target_wakes_spring() {
476 let mut spring = Spring::new(0.0, 1.0);
477 simulate(&mut spring, 200);
478 assert!(spring.is_complete());
479
480 spring.set_target(2.0);
481 assert!(!spring.is_complete());
482 }
483
484 #[test]
485 fn set_target_same_value_stays_at_rest() {
486 let mut spring = Spring::new(0.0, 1.0);
487 simulate(&mut spring, 200);
488 assert!(spring.is_complete());
489
490 spring.set_target(1.0);
491 assert!(spring.is_complete());
492 }
493
494 #[test]
495 fn zero_dt_noop() {
496 let mut spring = Spring::new(0.0, 1.0);
497 let pos_before = spring.position();
498 spring.tick(Duration::ZERO);
499 assert!((spring.position() - pos_before).abs() < f64::EPSILON);
500 }
501
502 #[test]
503 fn large_dt_subdivided() {
504 let mut spring = Spring::new(0.0, 1.0)
505 .with_stiffness(170.0)
506 .with_damping(26.0);
507
508 spring.tick(Duration::from_secs(5));
510 assert!(
511 (spring.position() - 1.0).abs() < 0.01,
512 "position: {}",
513 spring.position()
514 );
515 }
516
517 #[test]
518 fn zero_stiffness_clamped() {
519 let spring = Spring::new(0.0, 1.0).with_stiffness(0.0);
520 assert!(spring.stiffness() >= MIN_STIFFNESS);
521 }
522
523 #[test]
524 fn negative_damping_clamped() {
525 let spring = Spring::new(0.0, 1.0).with_damping(-5.0);
526 assert!(spring.damping() >= 0.0);
527 }
528
529 #[test]
530 fn critical_damping_coefficient() {
531 let spring = Spring::new(0.0, 1.0).with_stiffness(100.0);
532 assert!((spring.critical_damping() - 20.0).abs() < f64::EPSILON);
533 }
534
535 #[test]
536 fn spring_negative_target() {
537 let mut spring = Spring::new(0.0, -1.0)
538 .with_stiffness(170.0)
539 .with_damping(26.0);
540
541 simulate(&mut spring, 200);
542 assert!(
543 (spring.position() - -1.0).abs() < 0.01,
544 "position: {}",
545 spring.position()
546 );
547 }
548
549 #[test]
550 fn spring_reverse_direction() {
551 let mut spring = Spring::new(1.0, 0.0)
552 .with_stiffness(170.0)
553 .with_damping(26.0);
554
555 simulate(&mut spring, 200);
556 assert!(
557 spring.position().abs() < 0.01,
558 "position: {}",
559 spring.position()
560 );
561 }
562
563 #[test]
564 fn presets_all_converge() {
565 let presets: Vec<(&str, Spring)> = vec![
566 ("gentle", presets::gentle()),
567 ("bouncy", presets::bouncy()),
568 ("stiff", presets::stiff()),
569 ("critical", presets::critical()),
570 ("slow", presets::slow()),
571 ];
572
573 for (name, mut spring) in presets {
574 simulate(&mut spring, 500);
575 assert!(
576 spring.is_complete(),
577 "preset '{name}' did not converge after 500 frames (pos: {}, vel: {})",
578 spring.position(),
579 spring.velocity()
580 );
581 }
582 }
583
584 #[test]
585 fn deterministic_across_runs() {
586 let run = || {
587 let mut spring = Spring::new(0.0, 1.0)
588 .with_stiffness(170.0)
589 .with_damping(26.0);
590 let mut positions = Vec::new();
591 for _ in 0..50 {
592 spring.tick(MS_16);
593 positions.push(spring.position());
594 }
595 positions
596 };
597
598 let run1 = run();
599 let run2 = run();
600 assert_eq!(run1, run2, "Spring should be deterministic");
601 }
602
603 #[test]
604 fn at_rest_spring_skips_computation() {
605 let mut spring = Spring::new(0.0, 1.0);
606 simulate(&mut spring, 200);
607 assert!(spring.is_complete());
608
609 let pos = spring.position();
610 spring.tick(MS_16);
611 assert!(
612 (spring.position() - pos).abs() < f64::EPSILON,
613 "At-rest spring should not change position on tick"
614 );
615 }
616
617 #[test]
618 fn animation_trait_value_for_normalized() {
619 let mut spring = Spring::normalized();
620 assert!((spring.value() - 0.0).abs() < f32::EPSILON);
621
622 simulate(&mut spring, 200);
623 assert!((spring.value() - 1.0).abs() < 0.01);
624 }
625
626 #[test]
627 fn stiff_preset_faster_than_slow() {
628 let mut stiff = presets::stiff();
629 let mut slow = presets::slow();
630
631 for _ in 0..30 {
633 stiff.tick(MS_16);
634 slow.tick(MS_16);
635 }
636
637 let stiff_delta = (stiff.position() - 1.0).abs();
638 let slow_delta = (slow.position() - 1.0).abs();
639 assert!(
640 stiff_delta < slow_delta,
641 "Stiff ({stiff_delta}) should be closer to target than slow ({slow_delta})"
642 );
643 }
644
645 #[test]
648 fn clone_independence() {
649 let mut spring = Spring::new(0.0, 1.0);
650 simulate(&mut spring, 5); let pos_after_5 = spring.position();
652 let mut clone = spring.clone();
653 simulate(&mut clone, 5);
656 assert!(
658 (clone.position() - pos_after_5).abs() > 0.01,
659 "clone should advance independently (clone: {}, original: {})",
660 clone.position(),
661 pos_after_5
662 );
663 assert!(
665 (spring.position() - pos_after_5).abs() < f64::EPSILON,
666 "original should not have changed"
667 );
668 }
669
670 #[test]
671 fn debug_format() {
672 let spring = Spring::new(0.0, 1.0);
673 let dbg = format!("{spring:?}");
674 assert!(dbg.contains("Spring"));
675 assert!(dbg.contains("position"));
676 assert!(dbg.contains("velocity"));
677 assert!(dbg.contains("target"));
678 }
679
680 #[test]
681 fn negative_stiffness_clamped() {
682 let spring = Spring::new(0.0, 1.0).with_stiffness(-100.0);
683 assert!(spring.stiffness() >= MIN_STIFFNESS);
684 }
685
686 #[test]
687 fn with_rest_threshold_builder() {
688 let spring = Spring::new(0.0, 1.0).with_rest_threshold(0.1);
689 assert!((spring.rest_threshold - 0.1).abs() < f64::EPSILON);
690 }
691
692 #[test]
693 fn with_rest_threshold_negative_takes_abs() {
694 let spring = Spring::new(0.0, 1.0).with_rest_threshold(-0.05);
695 assert!((spring.rest_threshold - 0.05).abs() < f64::EPSILON);
696 }
697
698 #[test]
699 fn with_velocity_threshold_builder() {
700 let spring = Spring::new(0.0, 1.0).with_velocity_threshold(0.5);
701 assert!((spring.velocity_threshold - 0.5).abs() < f64::EPSILON);
702 }
703
704 #[test]
705 fn with_velocity_threshold_negative_takes_abs() {
706 let spring = Spring::new(0.0, 1.0).with_velocity_threshold(-0.3);
707 assert!((spring.velocity_threshold - 0.3).abs() < f64::EPSILON);
708 }
709
710 #[test]
711 fn initial_equals_target_settles_immediately() {
712 let mut spring = Spring::new(5.0, 5.0);
713 spring.tick(MS_16);
715 assert!(spring.is_complete());
716 assert!((spring.position() - 5.0).abs() < f64::EPSILON);
717 }
718
719 #[test]
720 fn normalized_constructor() {
721 let spring = Spring::normalized();
722 assert!((spring.position() - 0.0).abs() < f64::EPSILON);
723 assert!((spring.target() - 1.0).abs() < f64::EPSILON);
724 }
725
726 #[test]
727 fn impulse_negative_velocity() {
728 let mut spring = Spring::new(0.5, 0.5);
729 spring.tick(MS_16); spring.impulse(-100.0);
732 assert!(!spring.is_complete());
733 spring.tick(MS_16);
734 assert!(
735 spring.position() < 0.5,
736 "Negative impulse should move position below target, got {}",
737 spring.position()
738 );
739 }
740
741 #[test]
742 fn impulse_on_moving_spring() {
743 let mut spring = Spring::new(0.0, 1.0);
744 spring.tick(MS_16);
745 let vel_before = spring.velocity();
746 spring.impulse(10.0);
747 assert!(
749 (spring.velocity() - (vel_before + 10.0)).abs() < f64::EPSILON,
750 "impulse should add to velocity"
751 );
752 }
753
754 #[test]
755 fn set_target_within_rest_threshold_stays_at_rest() {
756 let mut spring = Spring::new(0.0, 1.0).with_rest_threshold(0.01);
757 simulate(&mut spring, 300);
758 assert!(spring.is_complete());
759
760 spring.set_target(1.0 + 0.005);
762 assert!(
763 spring.is_complete(),
764 "set_target within rest_threshold should not wake spring"
765 );
766 }
767
768 #[test]
769 fn set_target_just_beyond_rest_threshold_wakes() {
770 let mut spring = Spring::new(0.0, 1.0).with_rest_threshold(0.01);
771 simulate(&mut spring, 300);
772 assert!(spring.is_complete());
773
774 spring.set_target(1.0 + 0.02);
776 assert!(
777 !spring.is_complete(),
778 "set_target beyond rest_threshold should wake spring"
779 );
780 }
781
782 #[test]
783 fn large_rest_threshold_settles_quickly() {
784 let mut spring = Spring::new(0.0, 1.0)
785 .with_stiffness(170.0)
786 .with_damping(26.0)
787 .with_rest_threshold(0.5)
788 .with_velocity_threshold(10.0);
789
790 simulate(&mut spring, 10);
792 assert!(
793 spring.is_complete(),
794 "Large thresholds should cause early settling (pos: {}, vel: {})",
795 spring.position(),
796 spring.velocity()
797 );
798 }
799
800 #[test]
801 fn value_clamps_negative_position() {
802 let mut spring = Spring::new(0.0, 0.0);
804 spring.impulse(-100.0);
805 spring.tick(MS_16);
806 assert!(spring.position() < 0.0);
808 assert!((spring.value() - 0.0).abs() < f32::EPSILON);
809 }
810
811 #[test]
812 fn value_clamps_above_one() {
813 let mut spring = Spring::new(0.0, 5.0);
815 simulate(&mut spring, 200);
816 assert!(spring.position() > 1.0);
817 assert!((spring.value() - 1.0).abs() < f32::EPSILON);
818 }
819
820 #[test]
821 fn zero_damping_oscillates() {
822 let mut spring = Spring::new(0.0, 1.0)
823 .with_stiffness(170.0)
824 .with_damping(0.0);
825
826 let mut crossed_target = false;
828 let mut crossed_back = false;
829 let mut above = false;
830 for _ in 0..200 {
831 spring.tick(MS_16);
832 if spring.position() > 1.0 {
833 above = true;
834 }
835 if above && spring.position() < 1.0 {
836 crossed_target = true;
837 }
838 if crossed_target && spring.position() > 1.0 {
839 crossed_back = true;
840 break;
841 }
842 }
843 assert!(crossed_back, "Zero-damping spring should oscillate");
844 }
845
846 #[test]
847 fn advance_at_rest_is_noop() {
848 let mut spring = Spring::new(0.0, 1.0);
849 simulate(&mut spring, 300);
850 assert!(spring.is_complete());
851
852 let pos = spring.position();
853 let vel = spring.velocity();
854 spring.advance(Duration::from_secs(10));
855 assert!((spring.position() - pos).abs() < f64::EPSILON);
856 assert!((spring.velocity() - vel).abs() < f64::EPSILON);
857 }
858
859 #[test]
860 fn reset_restores_initial() {
861 let mut spring = Spring::new(42.0, 100.0);
862 simulate(&mut spring, 200);
863 spring.reset();
864 assert!((spring.position() - 42.0).abs() < f64::EPSILON);
865 assert!((spring.velocity() - 0.0).abs() < f64::EPSILON);
866 assert!(!spring.is_complete());
867 }
868
869 #[test]
870 fn reset_after_impulse() {
871 let mut spring = Spring::new(0.0, 0.0);
872 spring.impulse(50.0);
873 spring.tick(MS_16);
874 spring.reset();
875 assert!((spring.position() - 0.0).abs() < f64::EPSILON);
876 assert!((spring.velocity() - 0.0).abs() < f64::EPSILON);
877 }
878
879 #[test]
880 fn multiple_set_target_chained() {
881 let mut spring = Spring::new(0.0, 1.0);
882 simulate(&mut spring, 50);
883 spring.set_target(2.0);
884 simulate(&mut spring, 50);
885 spring.set_target(0.0);
886 simulate(&mut spring, 300);
887 assert!(
888 spring.position().abs() < 0.01,
889 "Should converge to final target 0.0, got {}",
890 spring.position()
891 );
892 }
893
894 #[test]
895 fn animation_trait_overshoot_is_zero_for_spring() {
896 let mut spring = Spring::new(0.0, 1.0);
897 assert_eq!(spring.overshoot(), Duration::ZERO);
898
899 simulate(&mut spring, 300);
900 assert_eq!(spring.overshoot(), Duration::ZERO);
901 }
902
903 #[test]
904 fn preset_gentle_parameters() {
905 let s = presets::gentle();
906 assert!((s.stiffness() - 120.0).abs() < f64::EPSILON);
907 assert!((s.damping() - 20.0).abs() < f64::EPSILON);
908 }
909
910 #[test]
911 fn preset_bouncy_parameters() {
912 let s = presets::bouncy();
913 assert!((s.stiffness() - 300.0).abs() < f64::EPSILON);
914 assert!((s.damping() - 10.0).abs() < f64::EPSILON);
915 }
916
917 #[test]
918 fn preset_stiff_parameters() {
919 let s = presets::stiff();
920 assert!((s.stiffness() - 400.0).abs() < f64::EPSILON);
921 assert!((s.damping() - 38.0).abs() < f64::EPSILON);
922 }
923
924 #[test]
925 fn preset_slow_parameters() {
926 let s = presets::slow();
927 assert!((s.stiffness() - 50.0).abs() < f64::EPSILON);
928 assert!((s.damping() - 14.0).abs() < f64::EPSILON);
929 }
930
931 #[test]
932 fn preset_critical_is_critically_damped() {
933 let s = presets::critical();
934 let expected_damping = 2.0 * s.stiffness().sqrt();
935 assert!(
936 (s.damping() - expected_damping).abs() < f64::EPSILON,
937 "critical preset should have c = 2*sqrt(k)"
938 );
939 }
940}