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        #[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    /// Returns the spring position clamped to [0.0, 1.0].
281    ///
282    /// For springs with targets outside [0, 1], use [`position()`](Spring::position)
283    /// directly.
284    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
295// ---------------------------------------------------------------------------
296// Presets
297// ---------------------------------------------------------------------------
298
299/// Common spring configurations for UI motion.
300pub mod presets {
301    use super::Spring;
302
303    /// Gentle spring: low stiffness, high damping. Smooth and slow.
304    #[must_use]
305    pub fn gentle() -> Spring {
306        Spring::normalized()
307            .with_stiffness(120.0)
308            .with_damping(20.0)
309    }
310
311    /// Bouncy spring: high stiffness, low damping. Visible oscillation.
312    #[must_use]
313    pub fn bouncy() -> Spring {
314        Spring::normalized()
315            .with_stiffness(300.0)
316            .with_damping(10.0)
317    }
318
319    /// Stiff spring: high stiffness, near-critical damping. Snappy response.
320    #[must_use]
321    pub fn stiff() -> Spring {
322        Spring::normalized()
323            .with_stiffness(400.0)
324            .with_damping(38.0)
325    }
326
327    /// Critically damped spring: fastest convergence without overshoot.
328    #[must_use]
329    pub fn critical() -> Spring {
330        let k: f64 = 170.0;
331        let c = 2.0 * k.sqrt(); // critical damping
332        Spring::normalized().with_stiffness(k).with_damping(c)
333    }
334
335    /// Slow spring: very low stiffness. Good for background transitions.
336    #[must_use]
337    pub fn slow() -> Spring {
338        Spring::normalized().with_stiffness(50.0).with_damping(14.0)
339    }
340}
341
342// ---------------------------------------------------------------------------
343// Tests
344// ---------------------------------------------------------------------------
345
346#[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); // Heavily overdamped
392
393        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        // Scale target for easier measurement
412        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        // Large dt should still converge (subdivided internally).
518        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        // After 30 frames, stiff should be closer to target
641        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    // ── Edge-case tests (bd-3r5rp) ──────────────────────────────────
655
656    #[test]
657    fn clone_independence() {
658        let mut spring = Spring::new(0.0, 1.0);
659        simulate(&mut spring, 5); // Only 5 frames — still in motion.
660        let pos_after_5 = spring.position();
661        let mut clone = spring.clone();
662        // Original doesn't advance further.
663        // Clone advances 5 more frames.
664        simulate(&mut clone, 5);
665        // Clone should have moved beyond the original's position.
666        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        // Original should not have moved.
673        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        // After one tick, should settle since position == target and velocity == 0.
723        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); // Settle at 0.5.
739        // Apply strong negative impulse.
740        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        // Velocity should be additive.
757        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        // Set target very close (within rest_threshold).
770        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        // Set target just beyond rest_threshold.
784        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        // With huge thresholds, spring should settle very quickly.
800        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        // Spring going negative due to overshoot/impulse.
812        let mut spring = Spring::new(0.0, 0.0);
813        spring.impulse(-100.0);
814        spring.tick(MS_16);
815        // Position should be negative, but value() clamped to 0.
816        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        // Spring targeting beyond 1.0.
823        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        // With zero damping, spring should oscillate.
836        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    // ── Time-step independence (bd-1lg.12) ──────────────────────────
951
952    #[test]
953    fn timestep_independence_coarse_vs_fine() {
954        // Same total duration (1s) with different step sizes should converge
955        // to the same final position within tolerance.
956        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); // matches MAX_STEP_SECS
972        let pos_16ms = run_with_step(16); // 60fps
973        let pos_33ms = run_with_step(33); // 30fps
974
975        // All should be within 0.01 of each other (the subdivision ensures this).
976        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        // One big 500ms tick vs 500 × 1ms ticks.
994        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    // ── Damping mode comparison (bd-1lg.12) ─────────────────────────
1015
1016    #[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); // well under critical
1024
1025        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); // well over critical
1032
1033        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        // Overdamped spring should approach target monotonically (no overshoot).
1069        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        // Must overshoot target at least once.
1090        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        // But must eventually settle.
1101        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    // ── Zero-displacement handling (bd-1lg.12) ──────────────────────
1113
1114    #[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// ── Tracing span assertion (bd-1lg.12) ──────────────────────────────
1127//
1128// Feature-gated to `tracing` because the span only exists when the
1129// feature is enabled. This is a unit test (not integration) to avoid
1130// the global callsite interest cache race condition with parallel tests.
1131
1132#[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            // First tick settles the spring.
1249            spring.tick(Duration::from_millis(16));
1250            assert!(spring.is_complete());
1251            // Second tick should have at_rest=true.
1252            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        // The second span should have at_rest=true.
1262        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}