Skip to main content

ftui_core/animation/
spring.rs

1#![forbid(unsafe_code)]
2
3//! Damped harmonic oscillator (spring) animation.
4//!
5//! Provides physically-based motion for smooth, natural UI transitions.
6//! Based on the classical damped spring equation:
7//!
8//!   F = -stiffness × (position - target) - damping × velocity
9//!
10//! # Parameters
11//!
12//! - **stiffness** (k): Restoring force strength. Higher = faster response.
13//!   Typical range: 100–400 for UI motion.
14//! - **damping** (c): Velocity drag. Higher = less oscillation.
15//!   - Underdamped (c < 2√k): oscillates past target before settling
16//!   - Critically damped (c ≈ 2√k): fastest convergence without overshoot
17//!   - Overdamped (c > 2√k): slow convergence, no overshoot
18//! - **rest_threshold**: Position delta below which the spring is considered
19//!   at rest. Default: 0.001.
20//!
21//! # Integration
22//!
23//! Uses semi-implicit Euler integration for stability. The `tick()` method
24//! accepts a `Duration` and internally converts to seconds for the physics
25//! step.
26//!
27//! # Invariants
28//!
29//! 1. `value()` returns a normalized position in [0.0, 1.0] (clamped).
30//! 2. `position()` returns the raw (unclamped) position for full-range use.
31//! 3. A spring at rest (`is_complete() == true`) will not resume unless
32//!    `set_target()` or `reset()` is called.
33//! 4. `reset()` returns position to the initial value and zeroes velocity.
34//! 5. Stiffness and damping are always positive (clamped on construction).
35//!
36//! # Failure Modes
37//!
38//! - Very large dt: Integration may overshoot badly. Callers should cap dt
39//!   to a reasonable frame budget (e.g., 33ms). For dt > 100ms, the spring
40//!   subdivides into smaller steps for stability.
41//! - Zero stiffness: Spring will never converge; clamped to minimum 0.1.
42//! - Zero damping: Spring oscillates forever; not a failure mode, but
43//!   `is_complete()` may never return true.
44
45use std::time::Duration;
46
47use super::Animation;
48
49/// Maximum dt per integration step (4ms). Larger deltas are subdivided
50/// for numerical stability with high stiffness values.
51const MAX_STEP_SECS: f64 = 0.004;
52
53/// Default rest threshold: position delta below which the spring is "at rest".
54const DEFAULT_REST_THRESHOLD: f64 = 0.001;
55
56/// Default velocity threshold: velocity below which (combined with position
57/// threshold) the spring is considered at rest.
58const DEFAULT_VELOCITY_THRESHOLD: f64 = 0.01;
59
60/// Minimum stiffness to prevent degenerate springs.
61const MIN_STIFFNESS: f64 = 0.1;
62
63/// A damped harmonic oscillator producing physically-based motion.
64///
65/// The spring interpolates from an initial position toward a target,
66/// with configurable stiffness and damping.
67///
68/// # Example
69///
70/// ```ignore
71/// use std::time::Duration;
72/// use ftui_core::animation::spring::Spring;
73///
74/// let mut spring = Spring::new(0.0, 1.0)
75///     .with_stiffness(170.0)
76///     .with_damping(26.0);
77///
78/// // Simulate at 60fps
79/// for _ in 0..120 {
80///     spring.tick(Duration::from_millis(16));
81/// }
82///
83/// assert!((spring.position() - 1.0).abs() < 0.01);
84/// ```
85#[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    /// Create a spring starting at `initial` and targeting `target`.
100    ///
101    /// Default parameters: stiffness = 170.0, damping = 26.0 (slightly
102    /// underdamped, producing a subtle bounce).
103    #[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    /// Create a spring animating from 0.0 to 1.0 (normalized).
119    #[must_use]
120    pub fn normalized() -> Self {
121        Self::new(0.0, 1.0)
122    }
123
124    /// Set stiffness (builder pattern). Clamped to minimum 0.1.
125    #[must_use]
126    pub fn with_stiffness(mut self, k: f64) -> Self {
127        self.stiffness = k.max(MIN_STIFFNESS);
128        self
129    }
130
131    /// Set damping (builder pattern). Clamped to minimum 0.0.
132    #[must_use]
133    pub fn with_damping(mut self, c: f64) -> Self {
134        self.damping = c.max(0.0);
135        self
136    }
137
138    /// Set rest threshold (builder pattern).
139    #[must_use]
140    pub fn with_rest_threshold(mut self, threshold: f64) -> Self {
141        self.rest_threshold = threshold.abs();
142        self
143    }
144
145    /// Set velocity threshold (builder pattern).
146    #[must_use]
147    pub fn with_velocity_threshold(mut self, threshold: f64) -> Self {
148        self.velocity_threshold = threshold.abs();
149        self
150    }
151
152    /// Current position (unclamped).
153    #[inline]
154    #[must_use]
155    pub fn position(&self) -> f64 {
156        self.position
157    }
158
159    /// Current velocity.
160    #[inline]
161    #[must_use]
162    pub fn velocity(&self) -> f64 {
163        self.velocity
164    }
165
166    /// Current target.
167    #[inline]
168    #[must_use]
169    pub fn target(&self) -> f64 {
170        self.target
171    }
172
173    /// Stiffness parameter.
174    #[inline]
175    #[must_use]
176    pub fn stiffness(&self) -> f64 {
177        self.stiffness
178    }
179
180    /// Damping parameter.
181    #[inline]
182    #[must_use]
183    pub fn damping(&self) -> f64 {
184        self.damping
185    }
186
187    /// Change the target. Wakes the spring if it was at rest.
188    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    /// Apply an impulse (add to velocity). Wakes the spring.
196    pub fn impulse(&mut self, velocity_delta: f64) {
197        self.velocity += velocity_delta;
198        self.at_rest = false;
199    }
200
201    /// Whether the spring has settled at the target.
202    #[inline]
203    #[must_use]
204    pub fn is_at_rest(&self) -> bool {
205        self.at_rest
206    }
207
208    /// Compute the critical damping coefficient for the current stiffness.
209    ///
210    /// At critical damping, the spring converges as fast as possible without
211    /// oscillating.
212    #[must_use]
213    pub fn critical_damping(&self) -> f64 {
214        2.0 * self.stiffness.sqrt()
215    }
216
217    /// Perform a single integration step of `dt` seconds.
218    fn step(&mut self, dt: f64) {
219        // Semi-implicit Euler:
220        // 1. Compute acceleration from current position.
221        // 2. Update velocity.
222        // 3. Update position from new velocity.
223        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    /// Advance the spring by `dt`, subdividing if necessary for stability.
233    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        // Subdivide large dt for numerical stability.
244        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        // Check if at rest.
252        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    /// Returns the spring position clamped to [0.0, 1.0].
272    ///
273    /// For springs with targets outside [0, 1], use [`position()`](Spring::position)
274    /// directly.
275    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
286// ---------------------------------------------------------------------------
287// Presets
288// ---------------------------------------------------------------------------
289
290/// Common spring configurations for UI motion.
291pub mod presets {
292    use super::Spring;
293
294    /// Gentle spring: low stiffness, high damping. Smooth and slow.
295    #[must_use]
296    pub fn gentle() -> Spring {
297        Spring::normalized()
298            .with_stiffness(120.0)
299            .with_damping(20.0)
300    }
301
302    /// Bouncy spring: high stiffness, low damping. Visible oscillation.
303    #[must_use]
304    pub fn bouncy() -> Spring {
305        Spring::normalized()
306            .with_stiffness(300.0)
307            .with_damping(10.0)
308    }
309
310    /// Stiff spring: high stiffness, near-critical damping. Snappy response.
311    #[must_use]
312    pub fn stiff() -> Spring {
313        Spring::normalized()
314            .with_stiffness(400.0)
315            .with_damping(38.0)
316    }
317
318    /// Critically damped spring: fastest convergence without overshoot.
319    #[must_use]
320    pub fn critical() -> Spring {
321        let k: f64 = 170.0;
322        let c = 2.0 * k.sqrt(); // critical damping
323        Spring::normalized().with_stiffness(k).with_damping(c)
324    }
325
326    /// Slow spring: very low stiffness. Good for background transitions.
327    #[must_use]
328    pub fn slow() -> Spring {
329        Spring::normalized().with_stiffness(50.0).with_damping(14.0)
330    }
331}
332
333// ---------------------------------------------------------------------------
334// Tests
335// ---------------------------------------------------------------------------
336
337#[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); // Heavily overdamped
383
384        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        // Scale target for easier measurement
403        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        // Large dt should still converge (subdivided internally).
509        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        // After 30 frames, stiff should be closer to target
632        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    // ── Edge-case tests (bd-3r5rp) ──────────────────────────────────
646
647    #[test]
648    fn clone_independence() {
649        let mut spring = Spring::new(0.0, 1.0);
650        simulate(&mut spring, 5); // Only 5 frames — still in motion.
651        let pos_after_5 = spring.position();
652        let mut clone = spring.clone();
653        // Original doesn't advance further.
654        // Clone advances 5 more frames.
655        simulate(&mut clone, 5);
656        // Clone should have moved beyond the original's position.
657        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        // Original should not have moved.
664        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        // After one tick, should settle since position == target and velocity == 0.
714        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); // Settle at 0.5.
730        // Apply strong negative impulse.
731        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        // Velocity should be additive.
748        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        // Set target very close (within rest_threshold).
761        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        // Set target just beyond rest_threshold.
775        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        // With huge thresholds, spring should settle very quickly.
791        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        // Spring going negative due to overshoot/impulse.
803        let mut spring = Spring::new(0.0, 0.0);
804        spring.impulse(-100.0);
805        spring.tick(MS_16);
806        // Position should be negative, but value() clamped to 0.
807        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        // Spring targeting beyond 1.0.
814        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        // With zero damping, spring should oscillate.
827        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}