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 #[cfg(feature = "tracing")]
265 let _span = tracing::debug_span!(
266 "animation.tick",
267 animation_type = "spring",
268 dt_us = dt.as_micros() as u64,
269 at_rest = self.at_rest,
270 )
271 .entered();
272
273 self.advance(dt);
274 }
275
276 fn is_complete(&self) -> bool {
277 self.at_rest
278 }
279
280 fn value(&self) -> f32 {
285 (self.position as f32).clamp(0.0, 1.0)
286 }
287
288 fn reset(&mut self) {
289 self.position = self.initial;
290 self.velocity = 0.0;
291 self.at_rest = false;
292 }
293}
294
295pub mod presets {
301 use super::Spring;
302
303 #[must_use]
305 pub fn gentle() -> Spring {
306 Spring::normalized()
307 .with_stiffness(120.0)
308 .with_damping(20.0)
309 }
310
311 #[must_use]
313 pub fn bouncy() -> Spring {
314 Spring::normalized()
315 .with_stiffness(300.0)
316 .with_damping(10.0)
317 }
318
319 #[must_use]
321 pub fn stiff() -> Spring {
322 Spring::normalized()
323 .with_stiffness(400.0)
324 .with_damping(38.0)
325 }
326
327 #[must_use]
329 pub fn critical() -> Spring {
330 let k: f64 = 170.0;
331 let c = 2.0 * k.sqrt(); Spring::normalized().with_stiffness(k).with_damping(c)
333 }
334
335 #[must_use]
337 pub fn slow() -> Spring {
338 Spring::normalized().with_stiffness(50.0).with_damping(14.0)
339 }
340}
341
342#[cfg(test)]
347mod tests {
348 use super::*;
349
350 const MS_16: Duration = Duration::from_millis(16);
351
352 fn simulate(spring: &mut Spring, frames: usize) {
353 for _ in 0..frames {
354 spring.tick(MS_16);
355 }
356 }
357
358 #[test]
359 fn spring_reaches_target() {
360 let mut spring = Spring::new(0.0, 100.0)
361 .with_stiffness(170.0)
362 .with_damping(26.0);
363
364 simulate(&mut spring, 200);
365
366 assert!(
367 (spring.position() - 100.0).abs() < 0.1,
368 "position: {}",
369 spring.position()
370 );
371 assert!(spring.is_complete());
372 }
373
374 #[test]
375 fn spring_starts_at_initial() {
376 let spring = Spring::new(50.0, 100.0);
377 assert!((spring.position() - 50.0).abs() < f64::EPSILON);
378 }
379
380 #[test]
381 fn spring_target_change() {
382 let mut spring = Spring::new(0.0, 100.0);
383 spring.set_target(200.0);
384 assert!((spring.target() - 200.0).abs() < f64::EPSILON);
385 }
386
387 #[test]
388 fn spring_with_high_damping_minimal_overshoot() {
389 let mut spring = Spring::new(0.0, 100.0)
390 .with_stiffness(170.0)
391 .with_damping(100.0); let mut max_overshoot = 0.0_f64;
394 for _ in 0..300 {
395 spring.tick(MS_16);
396 let overshoot = spring.position() - 100.0;
397 if overshoot > max_overshoot {
398 max_overshoot = overshoot;
399 }
400 }
401
402 assert!(
403 max_overshoot < 1.0,
404 "High damping should minimize overshoot, got {max_overshoot}"
405 );
406 }
407
408 #[test]
409 fn critical_damping_no_overshoot() {
410 let mut spring = presets::critical();
411 spring.set_target(1.0);
413
414 let mut max_pos = 0.0_f64;
415 for _ in 0..300 {
416 spring.tick(MS_16);
417 if spring.position() > max_pos {
418 max_pos = spring.position();
419 }
420 }
421
422 assert!(
423 max_pos < 1.05,
424 "Critical damping should have negligible overshoot, got {max_pos}"
425 );
426 }
427
428 #[test]
429 fn bouncy_spring_overshoots() {
430 let mut spring = presets::bouncy();
431
432 let mut max_pos = 0.0_f64;
433 for _ in 0..200 {
434 spring.tick(MS_16);
435 if spring.position() > max_pos {
436 max_pos = spring.position();
437 }
438 }
439
440 assert!(
441 max_pos > 1.0,
442 "Bouncy spring should overshoot target, max was {max_pos}"
443 );
444 }
445
446 #[test]
447 fn normalized_spring_value_clamped() {
448 let mut spring = presets::bouncy();
449 for _ in 0..200 {
450 spring.tick(MS_16);
451 let v = spring.value();
452 assert!(
453 (0.0..=1.0).contains(&v),
454 "Animation::value() must be in [0,1], got {v}"
455 );
456 }
457 }
458
459 #[test]
460 fn spring_reset() {
461 let mut spring = Spring::new(0.0, 1.0);
462 simulate(&mut spring, 100);
463 assert!(spring.is_complete());
464
465 spring.reset();
466 assert!(!spring.is_complete());
467 assert!((spring.position() - 0.0).abs() < f64::EPSILON);
468 assert!((spring.velocity() - 0.0).abs() < f64::EPSILON);
469 }
470
471 #[test]
472 fn spring_impulse_wakes() {
473 let mut spring = Spring::new(0.0, 0.0);
474 simulate(&mut spring, 100);
475 assert!(spring.is_complete());
476
477 spring.impulse(50.0);
478 assert!(!spring.is_complete());
479 spring.tick(MS_16);
480 assert!(spring.position().abs() > 0.0);
481 }
482
483 #[test]
484 fn set_target_wakes_spring() {
485 let mut spring = Spring::new(0.0, 1.0);
486 simulate(&mut spring, 200);
487 assert!(spring.is_complete());
488
489 spring.set_target(2.0);
490 assert!(!spring.is_complete());
491 }
492
493 #[test]
494 fn set_target_same_value_stays_at_rest() {
495 let mut spring = Spring::new(0.0, 1.0);
496 simulate(&mut spring, 200);
497 assert!(spring.is_complete());
498
499 spring.set_target(1.0);
500 assert!(spring.is_complete());
501 }
502
503 #[test]
504 fn zero_dt_noop() {
505 let mut spring = Spring::new(0.0, 1.0);
506 let pos_before = spring.position();
507 spring.tick(Duration::ZERO);
508 assert!((spring.position() - pos_before).abs() < f64::EPSILON);
509 }
510
511 #[test]
512 fn large_dt_subdivided() {
513 let mut spring = Spring::new(0.0, 1.0)
514 .with_stiffness(170.0)
515 .with_damping(26.0);
516
517 spring.tick(Duration::from_secs(5));
519 assert!(
520 (spring.position() - 1.0).abs() < 0.01,
521 "position: {}",
522 spring.position()
523 );
524 }
525
526 #[test]
527 fn zero_stiffness_clamped() {
528 let spring = Spring::new(0.0, 1.0).with_stiffness(0.0);
529 assert!(spring.stiffness() >= MIN_STIFFNESS);
530 }
531
532 #[test]
533 fn negative_damping_clamped() {
534 let spring = Spring::new(0.0, 1.0).with_damping(-5.0);
535 assert!(spring.damping() >= 0.0);
536 }
537
538 #[test]
539 fn critical_damping_coefficient() {
540 let spring = Spring::new(0.0, 1.0).with_stiffness(100.0);
541 assert!((spring.critical_damping() - 20.0).abs() < f64::EPSILON);
542 }
543
544 #[test]
545 fn spring_negative_target() {
546 let mut spring = Spring::new(0.0, -1.0)
547 .with_stiffness(170.0)
548 .with_damping(26.0);
549
550 simulate(&mut spring, 200);
551 assert!(
552 (spring.position() - -1.0).abs() < 0.01,
553 "position: {}",
554 spring.position()
555 );
556 }
557
558 #[test]
559 fn spring_reverse_direction() {
560 let mut spring = Spring::new(1.0, 0.0)
561 .with_stiffness(170.0)
562 .with_damping(26.0);
563
564 simulate(&mut spring, 200);
565 assert!(
566 spring.position().abs() < 0.01,
567 "position: {}",
568 spring.position()
569 );
570 }
571
572 #[test]
573 fn presets_all_converge() {
574 let presets: Vec<(&str, Spring)> = vec![
575 ("gentle", presets::gentle()),
576 ("bouncy", presets::bouncy()),
577 ("stiff", presets::stiff()),
578 ("critical", presets::critical()),
579 ("slow", presets::slow()),
580 ];
581
582 for (name, mut spring) in presets {
583 simulate(&mut spring, 500);
584 assert!(
585 spring.is_complete(),
586 "preset '{name}' did not converge after 500 frames (pos: {}, vel: {})",
587 spring.position(),
588 spring.velocity()
589 );
590 }
591 }
592
593 #[test]
594 fn deterministic_across_runs() {
595 let run = || {
596 let mut spring = Spring::new(0.0, 1.0)
597 .with_stiffness(170.0)
598 .with_damping(26.0);
599 let mut positions = Vec::new();
600 for _ in 0..50 {
601 spring.tick(MS_16);
602 positions.push(spring.position());
603 }
604 positions
605 };
606
607 let run1 = run();
608 let run2 = run();
609 assert_eq!(run1, run2, "Spring should be deterministic");
610 }
611
612 #[test]
613 fn at_rest_spring_skips_computation() {
614 let mut spring = Spring::new(0.0, 1.0);
615 simulate(&mut spring, 200);
616 assert!(spring.is_complete());
617
618 let pos = spring.position();
619 spring.tick(MS_16);
620 assert!(
621 (spring.position() - pos).abs() < f64::EPSILON,
622 "At-rest spring should not change position on tick"
623 );
624 }
625
626 #[test]
627 fn animation_trait_value_for_normalized() {
628 let mut spring = Spring::normalized();
629 assert!((spring.value() - 0.0).abs() < f32::EPSILON);
630
631 simulate(&mut spring, 200);
632 assert!((spring.value() - 1.0).abs() < 0.01);
633 }
634
635 #[test]
636 fn stiff_preset_faster_than_slow() {
637 let mut stiff = presets::stiff();
638 let mut slow = presets::slow();
639
640 for _ in 0..30 {
642 stiff.tick(MS_16);
643 slow.tick(MS_16);
644 }
645
646 let stiff_delta = (stiff.position() - 1.0).abs();
647 let slow_delta = (slow.position() - 1.0).abs();
648 assert!(
649 stiff_delta < slow_delta,
650 "Stiff ({stiff_delta}) should be closer to target than slow ({slow_delta})"
651 );
652 }
653
654 #[test]
657 fn clone_independence() {
658 let mut spring = Spring::new(0.0, 1.0);
659 simulate(&mut spring, 5); let pos_after_5 = spring.position();
661 let mut clone = spring.clone();
662 simulate(&mut clone, 5);
665 assert!(
667 (clone.position() - pos_after_5).abs() > 0.01,
668 "clone should advance independently (clone: {}, original: {})",
669 clone.position(),
670 pos_after_5
671 );
672 assert!(
674 (spring.position() - pos_after_5).abs() < f64::EPSILON,
675 "original should not have changed"
676 );
677 }
678
679 #[test]
680 fn debug_format() {
681 let spring = Spring::new(0.0, 1.0);
682 let dbg = format!("{spring:?}");
683 assert!(dbg.contains("Spring"));
684 assert!(dbg.contains("position"));
685 assert!(dbg.contains("velocity"));
686 assert!(dbg.contains("target"));
687 }
688
689 #[test]
690 fn negative_stiffness_clamped() {
691 let spring = Spring::new(0.0, 1.0).with_stiffness(-100.0);
692 assert!(spring.stiffness() >= MIN_STIFFNESS);
693 }
694
695 #[test]
696 fn with_rest_threshold_builder() {
697 let spring = Spring::new(0.0, 1.0).with_rest_threshold(0.1);
698 assert!((spring.rest_threshold - 0.1).abs() < f64::EPSILON);
699 }
700
701 #[test]
702 fn with_rest_threshold_negative_takes_abs() {
703 let spring = Spring::new(0.0, 1.0).with_rest_threshold(-0.05);
704 assert!((spring.rest_threshold - 0.05).abs() < f64::EPSILON);
705 }
706
707 #[test]
708 fn with_velocity_threshold_builder() {
709 let spring = Spring::new(0.0, 1.0).with_velocity_threshold(0.5);
710 assert!((spring.velocity_threshold - 0.5).abs() < f64::EPSILON);
711 }
712
713 #[test]
714 fn with_velocity_threshold_negative_takes_abs() {
715 let spring = Spring::new(0.0, 1.0).with_velocity_threshold(-0.3);
716 assert!((spring.velocity_threshold - 0.3).abs() < f64::EPSILON);
717 }
718
719 #[test]
720 fn initial_equals_target_settles_immediately() {
721 let mut spring = Spring::new(5.0, 5.0);
722 spring.tick(MS_16);
724 assert!(spring.is_complete());
725 assert!((spring.position() - 5.0).abs() < f64::EPSILON);
726 }
727
728 #[test]
729 fn normalized_constructor() {
730 let spring = Spring::normalized();
731 assert!((spring.position() - 0.0).abs() < f64::EPSILON);
732 assert!((spring.target() - 1.0).abs() < f64::EPSILON);
733 }
734
735 #[test]
736 fn impulse_negative_velocity() {
737 let mut spring = Spring::new(0.5, 0.5);
738 spring.tick(MS_16); spring.impulse(-100.0);
741 assert!(!spring.is_complete());
742 spring.tick(MS_16);
743 assert!(
744 spring.position() < 0.5,
745 "Negative impulse should move position below target, got {}",
746 spring.position()
747 );
748 }
749
750 #[test]
751 fn impulse_on_moving_spring() {
752 let mut spring = Spring::new(0.0, 1.0);
753 spring.tick(MS_16);
754 let vel_before = spring.velocity();
755 spring.impulse(10.0);
756 assert!(
758 (spring.velocity() - (vel_before + 10.0)).abs() < f64::EPSILON,
759 "impulse should add to velocity"
760 );
761 }
762
763 #[test]
764 fn set_target_within_rest_threshold_stays_at_rest() {
765 let mut spring = Spring::new(0.0, 1.0).with_rest_threshold(0.01);
766 simulate(&mut spring, 300);
767 assert!(spring.is_complete());
768
769 spring.set_target(1.0 + 0.005);
771 assert!(
772 spring.is_complete(),
773 "set_target within rest_threshold should not wake spring"
774 );
775 }
776
777 #[test]
778 fn set_target_just_beyond_rest_threshold_wakes() {
779 let mut spring = Spring::new(0.0, 1.0).with_rest_threshold(0.01);
780 simulate(&mut spring, 300);
781 assert!(spring.is_complete());
782
783 spring.set_target(1.0 + 0.02);
785 assert!(
786 !spring.is_complete(),
787 "set_target beyond rest_threshold should wake spring"
788 );
789 }
790
791 #[test]
792 fn large_rest_threshold_settles_quickly() {
793 let mut spring = Spring::new(0.0, 1.0)
794 .with_stiffness(170.0)
795 .with_damping(26.0)
796 .with_rest_threshold(0.5)
797 .with_velocity_threshold(10.0);
798
799 simulate(&mut spring, 10);
801 assert!(
802 spring.is_complete(),
803 "Large thresholds should cause early settling (pos: {}, vel: {})",
804 spring.position(),
805 spring.velocity()
806 );
807 }
808
809 #[test]
810 fn value_clamps_negative_position() {
811 let mut spring = Spring::new(0.0, 0.0);
813 spring.impulse(-100.0);
814 spring.tick(MS_16);
815 assert!(spring.position() < 0.0);
817 assert!((spring.value() - 0.0).abs() < f32::EPSILON);
818 }
819
820 #[test]
821 fn value_clamps_above_one() {
822 let mut spring = Spring::new(0.0, 5.0);
824 simulate(&mut spring, 200);
825 assert!(spring.position() > 1.0);
826 assert!((spring.value() - 1.0).abs() < f32::EPSILON);
827 }
828
829 #[test]
830 fn zero_damping_oscillates() {
831 let mut spring = Spring::new(0.0, 1.0)
832 .with_stiffness(170.0)
833 .with_damping(0.0);
834
835 let mut crossed_target = false;
837 let mut crossed_back = false;
838 let mut above = false;
839 for _ in 0..200 {
840 spring.tick(MS_16);
841 if spring.position() > 1.0 {
842 above = true;
843 }
844 if above && spring.position() < 1.0 {
845 crossed_target = true;
846 }
847 if crossed_target && spring.position() > 1.0 {
848 crossed_back = true;
849 break;
850 }
851 }
852 assert!(crossed_back, "Zero-damping spring should oscillate");
853 }
854
855 #[test]
856 fn advance_at_rest_is_noop() {
857 let mut spring = Spring::new(0.0, 1.0);
858 simulate(&mut spring, 300);
859 assert!(spring.is_complete());
860
861 let pos = spring.position();
862 let vel = spring.velocity();
863 spring.advance(Duration::from_secs(10));
864 assert!((spring.position() - pos).abs() < f64::EPSILON);
865 assert!((spring.velocity() - vel).abs() < f64::EPSILON);
866 }
867
868 #[test]
869 fn reset_restores_initial() {
870 let mut spring = Spring::new(42.0, 100.0);
871 simulate(&mut spring, 200);
872 spring.reset();
873 assert!((spring.position() - 42.0).abs() < f64::EPSILON);
874 assert!((spring.velocity() - 0.0).abs() < f64::EPSILON);
875 assert!(!spring.is_complete());
876 }
877
878 #[test]
879 fn reset_after_impulse() {
880 let mut spring = Spring::new(0.0, 0.0);
881 spring.impulse(50.0);
882 spring.tick(MS_16);
883 spring.reset();
884 assert!((spring.position() - 0.0).abs() < f64::EPSILON);
885 assert!((spring.velocity() - 0.0).abs() < f64::EPSILON);
886 }
887
888 #[test]
889 fn multiple_set_target_chained() {
890 let mut spring = Spring::new(0.0, 1.0);
891 simulate(&mut spring, 50);
892 spring.set_target(2.0);
893 simulate(&mut spring, 50);
894 spring.set_target(0.0);
895 simulate(&mut spring, 300);
896 assert!(
897 spring.position().abs() < 0.01,
898 "Should converge to final target 0.0, got {}",
899 spring.position()
900 );
901 }
902
903 #[test]
904 fn animation_trait_overshoot_is_zero_for_spring() {
905 let mut spring = Spring::new(0.0, 1.0);
906 assert_eq!(spring.overshoot(), Duration::ZERO);
907
908 simulate(&mut spring, 300);
909 assert_eq!(spring.overshoot(), Duration::ZERO);
910 }
911
912 #[test]
913 fn preset_gentle_parameters() {
914 let s = presets::gentle();
915 assert!((s.stiffness() - 120.0).abs() < f64::EPSILON);
916 assert!((s.damping() - 20.0).abs() < f64::EPSILON);
917 }
918
919 #[test]
920 fn preset_bouncy_parameters() {
921 let s = presets::bouncy();
922 assert!((s.stiffness() - 300.0).abs() < f64::EPSILON);
923 assert!((s.damping() - 10.0).abs() < f64::EPSILON);
924 }
925
926 #[test]
927 fn preset_stiff_parameters() {
928 let s = presets::stiff();
929 assert!((s.stiffness() - 400.0).abs() < f64::EPSILON);
930 assert!((s.damping() - 38.0).abs() < f64::EPSILON);
931 }
932
933 #[test]
934 fn preset_slow_parameters() {
935 let s = presets::slow();
936 assert!((s.stiffness() - 50.0).abs() < f64::EPSILON);
937 assert!((s.damping() - 14.0).abs() < f64::EPSILON);
938 }
939
940 #[test]
941 fn preset_critical_is_critically_damped() {
942 let s = presets::critical();
943 let expected_damping = 2.0 * s.stiffness().sqrt();
944 assert!(
945 (s.damping() - expected_damping).abs() < f64::EPSILON,
946 "critical preset should have c = 2*sqrt(k)"
947 );
948 }
949
950 #[test]
953 fn timestep_independence_coarse_vs_fine() {
954 let total_ms = 1000u64;
957
958 let run_with_step = |step_ms: u64| -> f64 {
959 let mut spring = Spring::new(0.0, 1.0)
960 .with_stiffness(170.0)
961 .with_damping(26.0);
962 let steps = total_ms / step_ms;
963 let dt = Duration::from_millis(step_ms);
964 for _ in 0..steps {
965 spring.tick(dt);
966 }
967 spring.position()
968 };
969
970 let pos_1ms = run_with_step(1);
971 let pos_4ms = run_with_step(4); let pos_16ms = run_with_step(16); let pos_33ms = run_with_step(33); let tolerance = 0.01;
977 assert!(
978 (pos_1ms - pos_4ms).abs() < tolerance,
979 "1ms vs 4ms: {pos_1ms} vs {pos_4ms}"
980 );
981 assert!(
982 (pos_1ms - pos_16ms).abs() < tolerance,
983 "1ms vs 16ms: {pos_1ms} vs {pos_16ms}"
984 );
985 assert!(
986 (pos_1ms - pos_33ms).abs() < tolerance,
987 "1ms vs 33ms: {pos_1ms} vs {pos_33ms}"
988 );
989 }
990
991 #[test]
992 fn timestep_independence_single_vs_many() {
993 let mut single = Spring::new(0.0, 1.0)
995 .with_stiffness(170.0)
996 .with_damping(26.0);
997 single.tick(Duration::from_millis(500));
998
999 let mut many = Spring::new(0.0, 1.0)
1000 .with_stiffness(170.0)
1001 .with_damping(26.0);
1002 for _ in 0..500 {
1003 many.tick(Duration::from_millis(1));
1004 }
1005
1006 assert!(
1007 (single.position() - many.position()).abs() < 0.02,
1008 "single 500ms ({}) vs 500×1ms ({})",
1009 single.position(),
1010 many.position()
1011 );
1012 }
1013
1014 #[test]
1017 fn critically_damped_settles_fastest() {
1018 let k: f64 = 170.0;
1019 let c_critical = 2.0 * k.sqrt();
1020
1021 let mut underdamped = Spring::new(0.0, 1.0)
1022 .with_stiffness(k)
1023 .with_damping(c_critical * 0.3); let mut critical = Spring::new(0.0, 1.0)
1026 .with_stiffness(k)
1027 .with_damping(c_critical);
1028
1029 let mut overdamped = Spring::new(0.0, 1.0)
1030 .with_stiffness(k)
1031 .with_damping(c_critical * 3.0); let threshold = 0.01;
1034 let settle_frame = |spring: &mut Spring| -> usize {
1035 for frame in 0..1000 {
1036 spring.tick(MS_16);
1037 if (spring.position() - 1.0).abs() < threshold
1038 && spring.velocity().abs() < threshold
1039 {
1040 return frame;
1041 }
1042 }
1043 1000
1044 };
1045
1046 let ud_frames = settle_frame(&mut underdamped);
1047 let cd_frames = settle_frame(&mut critical);
1048 let od_frames = settle_frame(&mut overdamped);
1049
1050 assert!(
1051 cd_frames <= ud_frames,
1052 "critical ({cd_frames}) should settle no later than underdamped ({ud_frames})"
1053 );
1054 assert!(
1055 cd_frames <= od_frames,
1056 "critical ({cd_frames}) should settle no later than overdamped ({od_frames})"
1057 );
1058 }
1059
1060 #[test]
1061 fn overdamped_no_oscillation() {
1062 let k: f64 = 170.0;
1063 let c_critical = 2.0 * k.sqrt();
1064 let mut spring = Spring::new(0.0, 1.0)
1065 .with_stiffness(k)
1066 .with_damping(c_critical * 3.0);
1067
1068 let mut prev_pos = 0.0;
1070 for _ in 0..500 {
1071 spring.tick(MS_16);
1072 let pos = spring.position();
1073 assert!(
1074 pos >= prev_pos - f64::EPSILON,
1075 "overdamped spring should not oscillate: prev={prev_pos}, cur={pos}"
1076 );
1077 prev_pos = pos;
1078 }
1079 }
1080
1081 #[test]
1082 fn underdamped_oscillates_then_settles() {
1083 let k: f64 = 170.0;
1084 let c_critical = 2.0 * k.sqrt();
1085 let mut spring = Spring::new(0.0, 1.0)
1086 .with_stiffness(k)
1087 .with_damping(c_critical * 0.2);
1088
1089 let mut overshot = false;
1091 for _ in 0..200 {
1092 spring.tick(MS_16);
1093 if spring.position() > 1.0 {
1094 overshot = true;
1095 break;
1096 }
1097 }
1098 assert!(overshot, "underdamped spring should overshoot target");
1099
1100 for _ in 0..2000 {
1102 spring.tick(MS_16);
1103 }
1104 assert!(
1105 spring.is_complete(),
1106 "underdamped spring should eventually settle (pos: {}, vel: {})",
1107 spring.position(),
1108 spring.velocity()
1109 );
1110 }
1111
1112 #[test]
1115 fn zero_displacement_spring_completes_immediately() {
1116 let mut spring = Spring::new(1.0, 1.0);
1117 spring.tick(MS_16);
1118 assert!(
1119 spring.is_complete(),
1120 "spring with initial == target should settle after one tick"
1121 );
1122 assert!((spring.position() - 1.0).abs() < f64::EPSILON);
1123 }
1124}
1125
1126#[cfg(all(test, feature = "tracing"))]
1133mod tracing_tests {
1134 use super::*;
1135 use std::collections::HashMap;
1136 use std::sync::{Arc, Mutex};
1137 use tracing_subscriber::layer::SubscriberExt;
1138 use tracing_subscriber::registry::LookupSpan;
1139
1140 #[derive(Debug, Clone)]
1141 struct CapturedSpan {
1142 name: String,
1143 fields: HashMap<String, String>,
1144 }
1145
1146 struct SpanCapture {
1147 spans: Arc<Mutex<Vec<CapturedSpan>>>,
1148 }
1149
1150 impl SpanCapture {
1151 fn new() -> (Self, Arc<Mutex<Vec<CapturedSpan>>>) {
1152 let spans = Arc::new(Mutex::new(Vec::new()));
1153 (
1154 Self {
1155 spans: spans.clone(),
1156 },
1157 spans,
1158 )
1159 }
1160 }
1161
1162 struct FieldVisitor(Vec<(String, String)>);
1163
1164 impl tracing::field::Visit for FieldVisitor {
1165 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
1166 self.0
1167 .push((field.name().to_string(), format!("{value:?}")));
1168 }
1169 fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
1170 self.0.push((field.name().to_string(), value.to_string()));
1171 }
1172 fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
1173 self.0.push((field.name().to_string(), value.to_string()));
1174 }
1175 fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
1176 self.0.push((field.name().to_string(), value.to_string()));
1177 }
1178 }
1179
1180 impl<S> tracing_subscriber::Layer<S> for SpanCapture
1181 where
1182 S: tracing::Subscriber + for<'a> LookupSpan<'a>,
1183 {
1184 fn on_new_span(
1185 &self,
1186 attrs: &tracing::span::Attributes<'_>,
1187 _id: &tracing::span::Id,
1188 _ctx: tracing_subscriber::layer::Context<'_, S>,
1189 ) {
1190 let mut visitor = FieldVisitor(Vec::new());
1191 attrs.record(&mut visitor);
1192 let mut fields: HashMap<String, String> = visitor.0.into_iter().collect();
1193 for field in attrs.metadata().fields() {
1194 fields.entry(field.name().to_string()).or_default();
1195 }
1196 self.spans.lock().unwrap().push(CapturedSpan {
1197 name: attrs.metadata().name().to_string(),
1198 fields,
1199 });
1200 }
1201 }
1202
1203 #[test]
1204 fn tick_emits_animation_tick_span() {
1205 let (layer, spans) = SpanCapture::new();
1206 let subscriber = tracing_subscriber::registry().with(layer);
1207 tracing::subscriber::with_default(subscriber, || {
1208 let mut spring = Spring::new(0.0, 1.0)
1209 .with_stiffness(170.0)
1210 .with_damping(26.0);
1211 spring.tick(Duration::from_millis(16));
1212 });
1213
1214 let captured = spans.lock().unwrap();
1215 let tick_spans: Vec<_> = captured
1216 .iter()
1217 .filter(|s| s.name == "animation.tick")
1218 .collect();
1219
1220 assert!(
1221 !tick_spans.is_empty(),
1222 "Spring::tick() should emit an animation.tick span"
1223 );
1224 assert_eq!(
1225 tick_spans[0]
1226 .fields
1227 .get("animation_type")
1228 .map(String::as_str),
1229 Some("spring"),
1230 "animation.tick span must have animation_type=spring"
1231 );
1232 assert!(
1233 tick_spans[0].fields.contains_key("dt_us"),
1234 "animation.tick span must have dt_us field"
1235 );
1236 assert!(
1237 tick_spans[0].fields.contains_key("at_rest"),
1238 "animation.tick span must have at_rest field"
1239 );
1240 }
1241
1242 #[test]
1243 fn tick_at_rest_records_at_rest_true() {
1244 let (layer, spans) = SpanCapture::new();
1245 let subscriber = tracing_subscriber::registry().with(layer);
1246 tracing::subscriber::with_default(subscriber, || {
1247 let mut spring = Spring::new(1.0, 1.0);
1248 spring.tick(Duration::from_millis(16));
1250 assert!(spring.is_complete());
1251 spring.tick(Duration::from_millis(16));
1253 });
1254
1255 let captured = spans.lock().unwrap();
1256 let tick_spans: Vec<_> = captured
1257 .iter()
1258 .filter(|s| s.name == "animation.tick")
1259 .collect();
1260
1261 assert!(
1263 tick_spans.len() >= 2,
1264 "expected at least 2 animation.tick spans"
1265 );
1266 assert_eq!(
1267 tick_spans[1].fields.get("at_rest").map(String::as_str),
1268 Some("true"),
1269 "second tick should have at_rest=true"
1270 );
1271 }
1272}