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    #[must_use]
154    pub fn position(&self) -> f64 {
155        self.position
156    }
157
158    /// Current velocity.
159    #[must_use]
160    pub fn velocity(&self) -> f64 {
161        self.velocity
162    }
163
164    /// Current target.
165    #[must_use]
166    pub fn target(&self) -> f64 {
167        self.target
168    }
169
170    /// Stiffness parameter.
171    #[must_use]
172    pub fn stiffness(&self) -> f64 {
173        self.stiffness
174    }
175
176    /// Damping parameter.
177    #[must_use]
178    pub fn damping(&self) -> f64 {
179        self.damping
180    }
181
182    /// Change the target. Wakes the spring if it was at rest.
183    pub fn set_target(&mut self, target: f64) {
184        if (self.target - target).abs() > self.rest_threshold {
185            self.target = target;
186            self.at_rest = false;
187        }
188    }
189
190    /// Apply an impulse (add to velocity). Wakes the spring.
191    pub fn impulse(&mut self, velocity_delta: f64) {
192        self.velocity += velocity_delta;
193        self.at_rest = false;
194    }
195
196    /// Whether the spring has settled at the target.
197    #[must_use]
198    pub fn is_at_rest(&self) -> bool {
199        self.at_rest
200    }
201
202    /// Compute the critical damping coefficient for the current stiffness.
203    ///
204    /// At critical damping, the spring converges as fast as possible without
205    /// oscillating.
206    #[must_use]
207    pub fn critical_damping(&self) -> f64 {
208        2.0 * self.stiffness.sqrt()
209    }
210
211    /// Perform a single integration step of `dt` seconds.
212    fn step(&mut self, dt: f64) {
213        // Semi-implicit Euler:
214        // 1. Compute acceleration from current position.
215        // 2. Update velocity.
216        // 3. Update position from new velocity.
217        let displacement = self.position - self.target;
218        let spring_force = -self.stiffness * displacement;
219        let damping_force = -self.damping * self.velocity;
220        let acceleration = spring_force + damping_force;
221
222        self.velocity += acceleration * dt;
223        self.position += self.velocity * dt;
224    }
225
226    /// Advance the spring by `dt`, subdividing if necessary for stability.
227    pub fn advance(&mut self, dt: Duration) {
228        if self.at_rest {
229            return;
230        }
231
232        let total_secs = dt.as_secs_f64();
233        if total_secs <= 0.0 {
234            return;
235        }
236
237        // Subdivide large dt for numerical stability.
238        let mut remaining = total_secs;
239        while remaining > 0.0 {
240            let step_dt = remaining.min(MAX_STEP_SECS);
241            self.step(step_dt);
242            remaining -= step_dt;
243        }
244
245        // Check if at rest.
246        let pos_delta = (self.position - self.target).abs();
247        let vel_abs = self.velocity.abs();
248        if pos_delta < self.rest_threshold && vel_abs < self.velocity_threshold {
249            self.position = self.target;
250            self.velocity = 0.0;
251            self.at_rest = true;
252        }
253    }
254}
255
256impl Animation for Spring {
257    fn tick(&mut self, dt: Duration) {
258        self.advance(dt);
259    }
260
261    fn is_complete(&self) -> bool {
262        self.at_rest
263    }
264
265    /// Returns the spring position clamped to [0.0, 1.0].
266    ///
267    /// For springs with targets outside [0, 1], use [`position()`](Spring::position)
268    /// directly.
269    fn value(&self) -> f32 {
270        (self.position as f32).clamp(0.0, 1.0)
271    }
272
273    fn reset(&mut self) {
274        self.position = self.initial;
275        self.velocity = 0.0;
276        self.at_rest = false;
277    }
278}
279
280// ---------------------------------------------------------------------------
281// Presets
282// ---------------------------------------------------------------------------
283
284/// Common spring configurations for UI motion.
285pub mod presets {
286    use super::Spring;
287
288    /// Gentle spring: low stiffness, high damping. Smooth and slow.
289    #[must_use]
290    pub fn gentle() -> Spring {
291        Spring::normalized()
292            .with_stiffness(120.0)
293            .with_damping(20.0)
294    }
295
296    /// Bouncy spring: high stiffness, low damping. Visible oscillation.
297    #[must_use]
298    pub fn bouncy() -> Spring {
299        Spring::normalized()
300            .with_stiffness(300.0)
301            .with_damping(10.0)
302    }
303
304    /// Stiff spring: high stiffness, near-critical damping. Snappy response.
305    #[must_use]
306    pub fn stiff() -> Spring {
307        Spring::normalized()
308            .with_stiffness(400.0)
309            .with_damping(38.0)
310    }
311
312    /// Critically damped spring: fastest convergence without overshoot.
313    #[must_use]
314    pub fn critical() -> Spring {
315        let k: f64 = 170.0;
316        let c = 2.0 * k.sqrt(); // critical damping
317        Spring::normalized().with_stiffness(k).with_damping(c)
318    }
319
320    /// Slow spring: very low stiffness. Good for background transitions.
321    #[must_use]
322    pub fn slow() -> Spring {
323        Spring::normalized().with_stiffness(50.0).with_damping(14.0)
324    }
325}
326
327// ---------------------------------------------------------------------------
328// Tests
329// ---------------------------------------------------------------------------
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    const MS_16: Duration = Duration::from_millis(16);
336
337    fn simulate(spring: &mut Spring, frames: usize) {
338        for _ in 0..frames {
339            spring.tick(MS_16);
340        }
341    }
342
343    #[test]
344    fn spring_reaches_target() {
345        let mut spring = Spring::new(0.0, 100.0)
346            .with_stiffness(170.0)
347            .with_damping(26.0);
348
349        simulate(&mut spring, 200);
350
351        assert!(
352            (spring.position() - 100.0).abs() < 0.1,
353            "position: {}",
354            spring.position()
355        );
356        assert!(spring.is_complete());
357    }
358
359    #[test]
360    fn spring_starts_at_initial() {
361        let spring = Spring::new(50.0, 100.0);
362        assert!((spring.position() - 50.0).abs() < f64::EPSILON);
363    }
364
365    #[test]
366    fn spring_target_change() {
367        let mut spring = Spring::new(0.0, 100.0);
368        spring.set_target(200.0);
369        assert!((spring.target() - 200.0).abs() < f64::EPSILON);
370    }
371
372    #[test]
373    fn spring_with_high_damping_minimal_overshoot() {
374        let mut spring = Spring::new(0.0, 100.0)
375            .with_stiffness(170.0)
376            .with_damping(100.0); // Heavily overdamped
377
378        let mut max_overshoot = 0.0_f64;
379        for _ in 0..300 {
380            spring.tick(MS_16);
381            let overshoot = spring.position() - 100.0;
382            if overshoot > max_overshoot {
383                max_overshoot = overshoot;
384            }
385        }
386
387        assert!(
388            max_overshoot < 1.0,
389            "High damping should minimize overshoot, got {max_overshoot}"
390        );
391    }
392
393    #[test]
394    fn critical_damping_no_overshoot() {
395        let mut spring = presets::critical();
396        // Scale target for easier measurement
397        spring.set_target(1.0);
398
399        let mut max_pos = 0.0_f64;
400        for _ in 0..300 {
401            spring.tick(MS_16);
402            if spring.position() > max_pos {
403                max_pos = spring.position();
404            }
405        }
406
407        assert!(
408            max_pos < 1.05,
409            "Critical damping should have negligible overshoot, got {max_pos}"
410        );
411    }
412
413    #[test]
414    fn bouncy_spring_overshoots() {
415        let mut spring = presets::bouncy();
416
417        let mut max_pos = 0.0_f64;
418        for _ in 0..200 {
419            spring.tick(MS_16);
420            if spring.position() > max_pos {
421                max_pos = spring.position();
422            }
423        }
424
425        assert!(
426            max_pos > 1.0,
427            "Bouncy spring should overshoot target, max was {max_pos}"
428        );
429    }
430
431    #[test]
432    fn normalized_spring_value_clamped() {
433        let mut spring = presets::bouncy();
434        for _ in 0..200 {
435            spring.tick(MS_16);
436            let v = spring.value();
437            assert!(
438                (0.0..=1.0).contains(&v),
439                "Animation::value() must be in [0,1], got {v}"
440            );
441        }
442    }
443
444    #[test]
445    fn spring_reset() {
446        let mut spring = Spring::new(0.0, 1.0);
447        simulate(&mut spring, 100);
448        assert!(spring.is_complete());
449
450        spring.reset();
451        assert!(!spring.is_complete());
452        assert!((spring.position() - 0.0).abs() < f64::EPSILON);
453        assert!((spring.velocity() - 0.0).abs() < f64::EPSILON);
454    }
455
456    #[test]
457    fn spring_impulse_wakes() {
458        let mut spring = Spring::new(0.0, 0.0);
459        simulate(&mut spring, 100);
460        assert!(spring.is_complete());
461
462        spring.impulse(50.0);
463        assert!(!spring.is_complete());
464        spring.tick(MS_16);
465        assert!(spring.position().abs() > 0.0);
466    }
467
468    #[test]
469    fn set_target_wakes_spring() {
470        let mut spring = Spring::new(0.0, 1.0);
471        simulate(&mut spring, 200);
472        assert!(spring.is_complete());
473
474        spring.set_target(2.0);
475        assert!(!spring.is_complete());
476    }
477
478    #[test]
479    fn set_target_same_value_stays_at_rest() {
480        let mut spring = Spring::new(0.0, 1.0);
481        simulate(&mut spring, 200);
482        assert!(spring.is_complete());
483
484        spring.set_target(1.0);
485        assert!(spring.is_complete());
486    }
487
488    #[test]
489    fn zero_dt_noop() {
490        let mut spring = Spring::new(0.0, 1.0);
491        let pos_before = spring.position();
492        spring.tick(Duration::ZERO);
493        assert!((spring.position() - pos_before).abs() < f64::EPSILON);
494    }
495
496    #[test]
497    fn large_dt_subdivided() {
498        let mut spring = Spring::new(0.0, 1.0)
499            .with_stiffness(170.0)
500            .with_damping(26.0);
501
502        // Large dt should still converge (subdivided internally).
503        spring.tick(Duration::from_secs(5));
504        assert!(
505            (spring.position() - 1.0).abs() < 0.01,
506            "position: {}",
507            spring.position()
508        );
509    }
510
511    #[test]
512    fn zero_stiffness_clamped() {
513        let spring = Spring::new(0.0, 1.0).with_stiffness(0.0);
514        assert!(spring.stiffness() >= MIN_STIFFNESS);
515    }
516
517    #[test]
518    fn negative_damping_clamped() {
519        let spring = Spring::new(0.0, 1.0).with_damping(-5.0);
520        assert!(spring.damping() >= 0.0);
521    }
522
523    #[test]
524    fn critical_damping_coefficient() {
525        let spring = Spring::new(0.0, 1.0).with_stiffness(100.0);
526        assert!((spring.critical_damping() - 20.0).abs() < f64::EPSILON);
527    }
528
529    #[test]
530    fn spring_negative_target() {
531        let mut spring = Spring::new(0.0, -1.0)
532            .with_stiffness(170.0)
533            .with_damping(26.0);
534
535        simulate(&mut spring, 200);
536        assert!(
537            (spring.position() - -1.0).abs() < 0.01,
538            "position: {}",
539            spring.position()
540        );
541    }
542
543    #[test]
544    fn spring_reverse_direction() {
545        let mut spring = Spring::new(1.0, 0.0)
546            .with_stiffness(170.0)
547            .with_damping(26.0);
548
549        simulate(&mut spring, 200);
550        assert!(
551            spring.position().abs() < 0.01,
552            "position: {}",
553            spring.position()
554        );
555    }
556
557    #[test]
558    fn presets_all_converge() {
559        let presets: Vec<(&str, Spring)> = vec![
560            ("gentle", presets::gentle()),
561            ("bouncy", presets::bouncy()),
562            ("stiff", presets::stiff()),
563            ("critical", presets::critical()),
564            ("slow", presets::slow()),
565        ];
566
567        for (name, mut spring) in presets {
568            simulate(&mut spring, 500);
569            assert!(
570                spring.is_complete(),
571                "preset '{name}' did not converge after 500 frames (pos: {}, vel: {})",
572                spring.position(),
573                spring.velocity()
574            );
575        }
576    }
577
578    #[test]
579    fn deterministic_across_runs() {
580        let run = || {
581            let mut spring = Spring::new(0.0, 1.0)
582                .with_stiffness(170.0)
583                .with_damping(26.0);
584            let mut positions = Vec::new();
585            for _ in 0..50 {
586                spring.tick(MS_16);
587                positions.push(spring.position());
588            }
589            positions
590        };
591
592        let run1 = run();
593        let run2 = run();
594        assert_eq!(run1, run2, "Spring should be deterministic");
595    }
596
597    #[test]
598    fn at_rest_spring_skips_computation() {
599        let mut spring = Spring::new(0.0, 1.0);
600        simulate(&mut spring, 200);
601        assert!(spring.is_complete());
602
603        let pos = spring.position();
604        spring.tick(MS_16);
605        assert!(
606            (spring.position() - pos).abs() < f64::EPSILON,
607            "At-rest spring should not change position on tick"
608        );
609    }
610
611    #[test]
612    fn animation_trait_value_for_normalized() {
613        let mut spring = Spring::normalized();
614        assert!((spring.value() - 0.0).abs() < f32::EPSILON);
615
616        simulate(&mut spring, 200);
617        assert!((spring.value() - 1.0).abs() < 0.01);
618    }
619
620    #[test]
621    fn stiff_preset_faster_than_slow() {
622        let mut stiff = presets::stiff();
623        let mut slow = presets::slow();
624
625        // After 30 frames, stiff should be closer to target
626        for _ in 0..30 {
627            stiff.tick(MS_16);
628            slow.tick(MS_16);
629        }
630
631        let stiff_delta = (stiff.position() - 1.0).abs();
632        let slow_delta = (slow.position() - 1.0).abs();
633        assert!(
634            stiff_delta < slow_delta,
635            "Stiff ({stiff_delta}) should be closer to target than slow ({slow_delta})"
636        );
637    }
638}