Skip to main content

frus_core/animation/
simulation.rs

1//! Physical simulations: **pure** `time → value` functions.
2//!
3//! A [`Simulation`] describes a motion through its position `x(t)`, its velocity
4//! `dx(t)` and a stopping test `is_done(t)` — with no mutable state and no coupling
5//! to rendering. It is the abstraction the rest of the animation layer shares: a
6//! spring, a friction fling and scroll momentum all take the same path, with a
7//! driver sampling `x(t)` every frame.
8//!
9//! The maths — a spring in its three regimes, friction in closed form — are
10//! expressed as immutable values computed once at construction: no re-entrancy and
11//! no upward borrowing, which is the shape Rust wants.
12
13/// The thresholds below which a simulation counts as "at rest".
14#[derive(Clone, Copy, Debug, PartialEq)]
15pub struct Tolerance {
16    /// Negligible distance, in position units.
17    pub distance: f32,
18    /// Negligible velocity, in position units per second.
19    pub velocity: f32,
20}
21
22impl Default for Tolerance {
23    fn default() -> Self {
24        Self {
25            distance: 1e-3,
26            velocity: 1e-3,
27        }
28    }
29}
30
31impl Tolerance {
32    /// A tolerance calibrated in **pixels**, for UI motion: a displacement of less
33    /// than half a pixel, at a velocity under 2 px/s, counts as stationary.
34    pub const PIXELS: Tolerance = Tolerance {
35        distance: 0.5,
36        velocity: 2.0,
37    };
38}
39
40/// A motion described as a pure function of time.
41///
42/// `time` is in **seconds** since the motion began.
43pub trait Simulation {
44    /// The position at instant `time`.
45    fn x(&self, time: f32) -> f32;
46    /// The velocity — the derivative of position — at instant `time`.
47    fn dx(&self, time: f32) -> f32;
48    /// `true` once the motion has finished, within the tolerances.
49    fn is_done(&self, time: f32) -> bool;
50    /// The stopping thresholds.
51    fn tolerance(&self) -> Tolerance {
52        Tolerance::default()
53    }
54}
55
56/// The parameters of a damped spring.
57#[derive(Clone, Copy, Debug, PartialEq)]
58pub struct SpringDescription {
59    /// Mass `m` (> 0).
60    pub mass: f32,
61    /// Stiffness `k` (> 0).
62    pub stiffness: f32,
63    /// Damping `c` (≥ 0).
64    pub damping: f32,
65}
66
67impl SpringDescription {
68    /// A spring given directly as `(mass, stiffness, damping)`.
69    pub fn new(mass: f32, stiffness: f32, damping: f32) -> Self {
70        Self {
71            mass,
72            stiffness,
73            damping,
74        }
75    }
76
77    /// A spring given by its **damping ratio**: `1` is critical (the fastest
78    /// settle with no overshoot), `< 1` oscillates, `> 1` is sluggish.
79    /// `c = ratio · 2·√(m·k)`.
80    pub fn with_damping_ratio(mass: f32, stiffness: f32, ratio: f32) -> Self {
81        Self {
82            mass,
83            stiffness,
84            damping: ratio * 2.0 * (mass * stiffness).sqrt(),
85        }
86    }
87}
88
89/// A spring's closed form, chosen from the discriminant `c² − 4mk`.
90#[derive(Clone, Copy, Debug)]
91enum Solution {
92    /// Critically damped (`c² − 4mk == 0`): the fastest settle, with no
93    /// oscillation.
94    Critical { r: f32, c1: f32, c2: f32 },
95    /// Overdamped (`c² − 4mk > 0`): two real exponentials, no oscillation.
96    Overdamped { r1: f32, r2: f32, c1: f32, c2: f32 },
97    /// Underdamped (`c² − 4mk < 0`): a damped oscillation.
98    Underdamped { w: f32, r: f32, c1: f32, c2: f32 },
99}
100
101impl Solution {
102    fn x(&self, t: f32) -> f32 {
103        match *self {
104            Solution::Critical { r, c1, c2 } => (c1 + c2 * t) * (r * t).exp(),
105            Solution::Overdamped { r1, r2, c1, c2 } => c1 * (r1 * t).exp() + c2 * (r2 * t).exp(),
106            Solution::Underdamped { w, r, c1, c2 } => {
107                (r * t).exp() * (c1 * (w * t).cos() + c2 * (w * t).sin())
108            }
109        }
110    }
111
112    fn dx(&self, t: f32) -> f32 {
113        match *self {
114            Solution::Critical { r, c1, c2 } => {
115                let power = (r * t).exp();
116                r * (c1 + c2 * t) * power + c2 * power
117            }
118            Solution::Overdamped { r1, r2, c1, c2 } => {
119                c1 * r1 * (r1 * t).exp() + c2 * r2 * (r2 * t).exp()
120            }
121            Solution::Underdamped { w, r, c1, c2 } => {
122                let power = (r * t).exp();
123                let cosine = (w * t).cos();
124                let sine = (w * t).sin();
125                power * (c2 * w * cosine - c1 * w * sine) + r * power * (c2 * sine + c1 * cosine)
126            }
127        }
128    }
129}
130
131/// A spring pulling a value from `start` towards `end`, started with an initial
132/// `velocity`. The reported position is `end + solution(t)`.
133#[derive(Clone, Copy, Debug)]
134pub struct SpringSimulation {
135    end: f32,
136    solution: Solution,
137    tolerance: Tolerance,
138}
139
140impl SpringSimulation {
141    /// Builds a spring reaching from `start` to `end`, at initial `velocity`
142    /// (px/s), with the given tolerance.
143    pub fn new(
144        spring: SpringDescription,
145        start: f32,
146        end: f32,
147        velocity: f32,
148        tolerance: Tolerance,
149    ) -> Self {
150        let m = spring.mass;
151        let k = spring.stiffness;
152        let c = spring.damping;
153        let x0 = start - end;
154        let v0 = velocity;
155        let cmk = c * c - 4.0 * m * k;
156
157        // We treat the regime as critical as soon as the discriminant is negligible
158        // next to `4mk`: floating-point rounding can leave `with_damping_ratio(1.0)`
159        // with a tiny non-zero `cmk` that we do not want to see oscillate.
160        let solution = if cmk.abs() <= 1e-4 * (4.0 * m * k).max(1.0) {
161            let r = -c / (2.0 * m);
162            Solution::Critical {
163                r,
164                c1: x0,
165                c2: v0 - r * x0,
166            }
167        } else if cmk > 0.0 {
168            let root = cmk.sqrt();
169            let r1 = (-c - root) / (2.0 * m);
170            let r2 = (-c + root) / (2.0 * m);
171            let c2 = (v0 - r1 * x0) / (r2 - r1);
172            Solution::Overdamped {
173                r1,
174                r2,
175                c1: x0 - c2,
176                c2,
177            }
178        } else {
179            let w = (4.0 * m * k - c * c).sqrt() / (2.0 * m);
180            let r = -c / (2.0 * m);
181            Solution::Underdamped {
182                w,
183                r,
184                c1: x0,
185                c2: (v0 - r * x0) / w,
186            }
187        };
188
189        Self {
190            end,
191            solution,
192            tolerance,
193        }
194    }
195}
196
197impl Simulation for SpringSimulation {
198    fn x(&self, time: f32) -> f32 {
199        self.end + self.solution.x(time)
200    }
201
202    fn dx(&self, time: f32) -> f32 {
203        self.solution.dx(time)
204    }
205
206    fn is_done(&self, time: f32) -> bool {
207        self.solution.x(time).abs() < self.tolerance.distance
208            && self.solution.dx(time).abs() < self.tolerance.velocity
209    }
210
211    fn tolerance(&self) -> Tolerance {
212        self.tolerance
213    }
214}
215
216/// The sign of `v`, with nought counting as positive — `f32::signum` answers `-0.0`
217/// for a negative zero and `1.0` for a positive one, and a deceleration that flipped
218/// direction on the sign of a zero velocity would be a bug nobody could reproduce.
219fn sign_of(v: f32) -> f32 {
220    if v < 0.0 {
221        -1.0
222    } else {
223        1.0
224    }
225}
226
227/// Ten steps of Newton's method towards `f(t) = target`, from `guess`.
228///
229/// Ten, not "until it converges": a fixed count cannot hang, and this runs while a
230/// finger is still on the glass. It is the reference's own iteration count.
231fn newtons_method(guess: f32, target: f32, f: impl Fn(f32) -> f32, df: impl Fn(f32) -> f32) -> f32 {
232    let mut t = guess;
233    for _ in 0..10 {
234        let slope = df(t);
235        if slope == 0.0 {
236            break;
237        }
238        t -= (f(t) - target) / slope;
239    }
240    t
241}
242
243/// Friction deceleration — scroll and fling momentum — in closed form.
244///
245/// `drag ∈ (0, 1)`: the closer to 1, the longer the motion keeps rolling.
246#[derive(Clone, Copy, Debug)]
247pub struct FrictionSimulation {
248    drag: f32,
249    drag_log: f32,
250    x0: f32,
251    v0: f32,
252    /// A **constant** deceleration on top of the drag, already signed by the
253    /// direction of travel. Nought for a finger; a real number for a trackpad.
254    ///
255    /// Drag alone never quite stops: the velocity decays geometrically and only
256    /// reaches nought at infinity, which is right for a flick that coasts. A
257    /// trackpad is not a flick — it is a hand that let go of a surface it was
258    /// touching — and a constant term is what gives the motion an actual end.
259    constant_deceleration: f32,
260    /// When the motion stops. `∞` unless there is a constant deceleration, which
261    /// is the only thing that brings the velocity to nought in finite time.
262    final_time: f32,
263    tolerance: Tolerance,
264}
265
266impl FrictionSimulation {
267    /// Friction from `position` at `velocity`, with the coefficient `drag`.
268    pub fn new(drag: f32, position: f32, velocity: f32, tolerance: Tolerance) -> Self {
269        Self::with_constant_deceleration(drag, position, velocity, tolerance, 0.0)
270    }
271
272    /// Friction with a **constant deceleration** on top of the drag.
273    ///
274    /// `constant_deceleration` is given unsigned and applied against the direction
275    /// of travel. Nought gives exactly [`Self::new`]; anything else brings the
276    /// motion to a stop in finite time, which drag alone never does.
277    ///
278    /// The closed forms stop working here. With drag alone, `dx(t) = v₀·D^t` is
279    /// invertible and the instant the motion passes a given point can be written
280    /// down; add `−c·t` and neither the stopping time nor the crossing time has a
281    /// closed form at all. Both are found by Newton's method, which is what the
282    /// reference does and for the same reason.
283    pub fn with_constant_deceleration(
284        drag: f32,
285        position: f32,
286        velocity: f32,
287        tolerance: Tolerance,
288        constant_deceleration: f32,
289    ) -> Self {
290        let drag_log = drag.ln();
291        let constant_deceleration = constant_deceleration * sign_of(velocity);
292        let mut sim = Self {
293            drag,
294            drag_log,
295            x0: position,
296            v0: velocity,
297            constant_deceleration,
298            // Infinite while the solve below reads `dx`, which is what makes that
299            // read the unbounded velocity rather than the clamped one.
300            final_time: f32::INFINITY,
301            tolerance,
302        };
303        if constant_deceleration != 0.0 {
304            sim.final_time = newtons_method(
305                0.0,
306                0.0,
307                |t| sim.dx(t),
308                |t| sim.v0 * sim.drag.powf(t) * sim.drag_log - sim.constant_deceleration,
309            );
310        }
311        sim
312    }
313
314    /// A friction calibrated to pass through `start` (at `start_velocity`) **and**
315    /// stop at `end` (at `end_velocity`), deriving `drag` from that.
316    /// `drag = e^((v₀ − v₁)/(x₀ − x₁))`.
317    pub fn through(
318        start: f32,
319        end: f32,
320        start_velocity: f32,
321        end_velocity: f32,
322        tolerance: Tolerance,
323    ) -> Self {
324        let drag = ((start_velocity - end_velocity) / (start - end)).exp();
325        Self::new(drag, start, start_velocity, tolerance)
326    }
327
328    /// The final position — the limit as `t → ∞`, or the place the motion
329    /// actually stops when a constant deceleration gives it an end.
330    pub fn final_x(&self) -> f32 {
331        if self.constant_deceleration == 0.0 {
332            self.x0 - self.v0 / self.drag_log
333        } else {
334            self.x(self.final_time)
335        }
336    }
337
338    /// The instant at which the motion passes through `x`, or `∞` when it never
339    /// does — because it starts the wrong way round, or because `x` lies beyond
340    /// [`Self::final_x`]. Inverting `x(t)` is what lets a fling hand over to a spring
341    /// exactly at the edge of the content.
342    pub fn time_at_x(&self, x: f32) -> f32 {
343        if x == self.x0 {
344            return 0.0;
345        }
346        let unreachable = if self.v0 > 0.0 {
347            x < self.x0 || x > self.final_x()
348        } else {
349            x > self.x0 || x < self.final_x()
350        };
351        if self.v0 == 0.0 || unreachable {
352            return f32::INFINITY;
353        }
354        if self.constant_deceleration == 0.0 {
355            return (self.drag_log * (x - self.x0) / self.v0 + 1.0).ln() / self.drag_log;
356        }
357        newtons_method(0.0, x, |t| self.x(t), |t| self.dx(t))
358    }
359}
360
361impl Simulation for FrictionSimulation {
362    fn x(&self, time: f32) -> f32 {
363        if time > self.final_time {
364            return self.final_x();
365        }
366        self.x0 + (self.v0 / self.drag_log) * (self.drag.powf(time) - 1.0)
367            - 0.5 * self.constant_deceleration * time * time
368    }
369
370    fn dx(&self, time: f32) -> f32 {
371        if time > self.final_time {
372            return 0.0;
373        }
374        self.v0 * self.drag.powf(time) - self.constant_deceleration * time
375    }
376
377    fn is_done(&self, time: f32) -> bool {
378        self.dx(time).abs() < self.tolerance.velocity
379    }
380
381    fn tolerance(&self) -> Tolerance {
382        self.tolerance
383    }
384}
385
386/// A fling that decelerates like Android's `OverScroller`, then stops dead.
387///
388/// The curve is the one Android's `SplineOverScroller` uses for a fling: distance
389/// and duration are derived from the release velocity, and the position follows
390/// `1 − (1 − t)^r` over that duration. It **never leaves the content**: reaching
391/// an edge is the caller's business (see the clamping scroll policy), which is
392/// what makes it the right model for a platform with no bounce.
393///
394/// Adjusted, as Flutter's is, to be *ballistic* — deceleration depends only on the
395/// current velocity, never on how long ago the motion started — so a fling can be
396/// restarted mid-flight from its own state alone.
397#[derive(Clone, Copy, Debug)]
398pub struct ClampingScrollSimulation {
399    position: f32,
400    velocity: f32,
401    /// Total run time, in seconds.
402    duration: f32,
403    /// Total **signed** distance travelled, in pixels.
404    distance: f32,
405    tolerance: Tolerance,
406}
407
408/// `ln(0.78) / ln(0.9)`, Android's `DECELERATION_RATE`.
409const DECELERATION_RATE: f32 = 2.358_201_8;
410/// Android's `INFLEXION`.
411const INFLEXION: f32 = 0.35;
412/// Android's `mPhysicalCoeff`: `g × 39.37 in/m × 160 px/in × 0.84` ("look and feel
413/// tuning"), in px·s⁻².
414const PHYSICAL_COEFF: f32 = 9.806_65 * 39.37 * 160.0 * 0.84;
415/// Android's `mFlingFriction`, the value that makes the fling travel the platform
416/// distance.
417pub const CLAMPING_FRICTION: f32 = 0.015;
418
419impl ClampingScrollSimulation {
420    /// A fling from `position` at `velocity` (px/s), with Android's friction.
421    pub fn new(position: f32, velocity: f32, tolerance: Tolerance) -> Self {
422        Self::with_friction(position, velocity, CLAMPING_FRICTION, tolerance)
423    }
424
425    /// The same fling, with the friction coefficient made explicit: higher means
426    /// it stops sooner and travels less far.
427    pub fn with_friction(
428        position: f32,
429        velocity: f32,
430        friction: f32,
431        tolerance: Tolerance,
432    ) -> Self {
433        // Below the tolerance there is no fling at all; short-circuiting also keeps
434        // the `ln`/`powf` below away from zero.
435        let duration = if velocity.abs() < tolerance.velocity.max(f32::EPSILON) {
436            0.0
437        } else {
438            let reference_velocity = friction * PHYSICAL_COEFF / INFLEXION;
439            let android_duration =
440                (velocity.abs() / reference_velocity).powf(1.0 / (DECELERATION_RATE - 1.0));
441            // We finish slightly sooner than Android does, so as to cover the same
442            // total distance.
443            DECELERATION_RATE * INFLEXION * android_duration
444        };
445        Self {
446            position,
447            velocity,
448            duration,
449            distance: velocity * duration / DECELERATION_RATE,
450            tolerance,
451        }
452    }
453
454    /// The total run time, in seconds.
455    pub fn duration(&self) -> f32 {
456        self.duration
457    }
458
459    /// Where the fling comes to rest.
460    pub fn final_x(&self) -> f32 {
461        self.position + self.distance
462    }
463
464    /// Progress through the motion, in `0..=1`.
465    fn progress(&self, time: f32) -> f32 {
466        if self.duration <= 0.0 {
467            1.0
468        } else {
469            (time / self.duration).clamp(0.0, 1.0)
470        }
471    }
472}
473
474impl Simulation for ClampingScrollSimulation {
475    fn x(&self, time: f32) -> f32 {
476        let t = self.progress(time);
477        self.position + self.distance * (1.0 - (1.0 - t).powf(DECELERATION_RATE))
478    }
479
480    fn dx(&self, time: f32) -> f32 {
481        if self.duration <= 0.0 {
482            return 0.0;
483        }
484        let t = self.progress(time);
485        self.velocity * (1.0 - t).powf(DECELERATION_RATE - 1.0)
486    }
487
488    fn is_done(&self, time: f32) -> bool {
489        time >= self.duration
490    }
491
492    fn tolerance(&self) -> Tolerance {
493        self.tolerance
494    }
495}
496
497/// A fling that rolls on friction while inside the content and springs back once
498/// past its edge — the behaviour of platforms that bounce.
499///
500/// It is two simulations end to end: [`FrictionSimulation`] up to the instant the
501/// momentum would carry the offset past an edge, then a [`SpringSimulation`] from
502/// that edge, seeded with the velocity the friction had left at the hand-over.
503/// Starting already out of range skips straight to the spring.
504///
505/// The hand-over instant is computed once, at construction, from
506/// [`FrictionSimulation::time_at_x`] — so sampling stays a pure function of time.
507#[derive(Clone, Copy, Debug)]
508pub struct BouncingScrollSimulation {
509    friction: FrictionSimulation,
510    spring: SpringSimulation,
511    /// When the spring takes over: `−∞` if it holds from the start, `+∞` if the
512    /// motion never leaves the content, otherwise the hand-over instant.
513    spring_time: f32,
514    tolerance: Tolerance,
515}
516
517/// The drag of a scroll fling: the fraction of the velocity left after one second.
518/// `0.998^1000 ≈ 0.135`, from the deceleration rate iOS scroll views use.
519pub const BOUNCING_DRAG: f32 = 0.135;
520
521/// The cap on the velocity handed from a fling's momentum to the overscroll
522/// spring: past this, the bounce would be violent rather than lively.
523pub const MAX_SPRING_TRANSFER_VELOCITY: f32 = 5000.0;
524
525impl BouncingScrollSimulation {
526    /// A fling from `position` at `velocity`, springing back to `leading` or
527    /// `trailing` — the offsets the content is allowed to rest at.
528    pub fn new(
529        position: f32,
530        velocity: f32,
531        leading: f32,
532        trailing: f32,
533        spring: SpringDescription,
534        tolerance: Tolerance,
535    ) -> Self {
536        Self::with_constant_deceleration(
537            position, velocity, leading, trailing, spring, tolerance, 0.0,
538        )
539    }
540
541    /// The same fling with a **constant deceleration** on top of the drag — the
542    /// second deceleration profile, for input that is not a finger.
543    ///
544    /// Nought gives exactly [`Self::new`]. See
545    /// [`FrictionSimulation::with_constant_deceleration`] for what the term does and
546    /// why the closed forms stop working.
547    #[allow(clippy::too_many_arguments)]
548    pub fn with_constant_deceleration(
549        position: f32,
550        velocity: f32,
551        leading: f32,
552        trailing: f32,
553        spring: SpringDescription,
554        tolerance: Tolerance,
555        constant_deceleration: f32,
556    ) -> Self {
557        debug_assert!(leading <= trailing);
558        let friction = FrictionSimulation::with_constant_deceleration(
559            BOUNCING_DRAG,
560            position,
561            velocity,
562            tolerance,
563            constant_deceleration,
564        );
565        let to =
566            |edge: f32, from: f32, v: f32| SpringSimulation::new(spring, from, edge, v, tolerance);
567
568        // Already outside: the spring holds from the first instant, and the whole
569        // motion is the return.
570        if position < leading {
571            return Self {
572                friction,
573                spring: to(leading, position, velocity),
574                spring_time: f32::NEG_INFINITY,
575                tolerance,
576            };
577        }
578        if position > trailing {
579            return Self {
580                friction,
581                spring: to(trailing, position, velocity),
582                spring_time: f32::NEG_INFINITY,
583                tolerance,
584            };
585        }
586
587        // Inside: does the momentum carry us past an edge before it runs out?
588        let final_x = friction.final_x();
589        let (spring_time, spring) = if velocity > 0.0 && final_x > trailing {
590            let t = friction.time_at_x(trailing);
591            let carried = friction.dx(t).min(MAX_SPRING_TRANSFER_VELOCITY);
592            (t, to(trailing, trailing, carried))
593        } else if velocity < 0.0 && final_x < leading {
594            let t = friction.time_at_x(leading);
595            let carried = friction.dx(t).min(MAX_SPRING_TRANSFER_VELOCITY);
596            (t, to(leading, leading, carried))
597        } else {
598            // The fling dies inside the content: the spring is never reached, and
599            // this instance of it is a placeholder that is never sampled.
600            (f32::INFINITY, to(position, position, 0.0))
601        };
602        Self {
603            friction,
604            spring,
605            spring_time,
606            tolerance,
607        }
608    }
609
610    /// The simulation in force at `time`, and the offset to subtract from `time`
611    /// before sampling it (the spring counts from its own start).
612    fn phase(&self, time: f32) -> (&dyn Simulation, f32) {
613        if time > self.spring_time {
614            let offset = if self.spring_time.is_finite() {
615                self.spring_time
616            } else {
617                0.0
618            };
619            (&self.spring, offset)
620        } else {
621            (&self.friction, 0.0)
622        }
623    }
624}
625
626impl Simulation for BouncingScrollSimulation {
627    fn x(&self, time: f32) -> f32 {
628        let (sim, offset) = self.phase(time);
629        sim.x(time - offset)
630    }
631
632    fn dx(&self, time: f32) -> f32 {
633        let (sim, offset) = self.phase(time);
634        sim.dx(time - offset)
635    }
636
637    fn is_done(&self, time: f32) -> bool {
638        let (sim, offset) = self.phase(time);
639        sim.is_done(time - offset)
640    }
641
642    fn tolerance(&self) -> Tolerance {
643        self.tolerance
644    }
645}
646
647/// Pins a simulation's **position** into `[min, max]`; the velocity keeps
648/// reporting the underlying simulation's. This is what scrolling needs: momentum
649/// is computed freely, but the offset never leaves the bounds.
650pub struct ClampedSimulation<S: Simulation> {
651    inner: S,
652    min: f32,
653    max: f32,
654}
655
656impl<S: Simulation> ClampedSimulation<S> {
657    /// Wraps `inner`, constraining its position to `[min, max]`.
658    pub fn new(inner: S, min: f32, max: f32) -> Self {
659        Self { inner, min, max }
660    }
661}
662
663impl<S: Simulation> Simulation for ClampedSimulation<S> {
664    fn x(&self, time: f32) -> f32 {
665        self.inner.x(time).clamp(self.min, self.max)
666    }
667
668    fn dx(&self, time: f32) -> f32 {
669        self.inner.dx(time)
670    }
671
672    fn is_done(&self, time: f32) -> bool {
673        self.inner.is_done(time)
674    }
675
676    fn tolerance(&self) -> Tolerance {
677        self.inner.tolerance()
678    }
679}
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684
685    /// Numerically integrates `dx` and checks it against `x`, which guarantees that
686    /// a simulation's velocity and position agree with one another.
687    fn dx_matches_x<S: Simulation>(sim: &S, t: f32) {
688        let h = 1e-4;
689        let numeric = (sim.x(t + h) - sim.x(t - h)) / (2.0 * h);
690        let analytic = sim.dx(t);
691        // A relative tolerance: the centred finite difference has a truncation error
692        // proportional to the curvature, which is large at high speed.
693        let scale = analytic.abs().max(1.0);
694        assert!(
695            (numeric - analytic).abs() < 1e-2 * scale,
696            "inconsistent dx at t={t}: numeric={numeric}, analytic={analytic}"
697        );
698    }
699
700    #[test]
701    fn critical_spring_settles_without_overshoot() {
702        let spring = SpringDescription::with_damping_ratio(1.0, 500.0, 1.0);
703        let sim = SpringSimulation::new(spring, 0.0, 1.0, 0.0, Tolerance::default());
704        assert!((sim.x(0.0) - 0.0).abs() < 1e-5, "starts at start");
705        // Monotonically increasing, never above 1 — no oscillation.
706        let mut prev = sim.x(0.0);
707        let mut t = 0.0;
708        while t < 2.0 {
709            let v = sim.x(t);
710            assert!(v <= 1.0 + 1e-4, "overshoot at t={t}: {v}");
711            assert!(v >= prev - 1e-4, "not monotonic at t={t}");
712            prev = v;
713            t += 0.01;
714        }
715        assert!((sim.x(2.0) - 1.0).abs() < 1e-2, "arrives at end");
716        assert!(sim.is_done(3.0), "at rest after 3 s");
717        dx_matches_x(&sim, 0.2);
718    }
719
720    #[test]
721    fn underdamped_spring_overshoots_then_returns() {
722        let spring = SpringDescription::with_damping_ratio(1.0, 500.0, 0.3);
723        let sim = SpringSimulation::new(spring, 0.0, 1.0, 0.0, Tolerance::default());
724        // Lightly damped: it overshoots the target at least once.
725        let mut max = f32::MIN;
726        let mut t = 0.0;
727        while t < 2.0 {
728            max = max.max(sim.x(t));
729            t += 0.005;
730        }
731        assert!(max > 1.05, "should overshoot the target, max={max}");
732        // It settles on the target in the end.
733        assert!((sim.x(3.0) - 1.0).abs() < 1e-2);
734        dx_matches_x(&sim, 0.1);
735    }
736
737    #[test]
738    fn overdamped_spring_is_slow_and_monotonic() {
739        let spring = SpringDescription::with_damping_ratio(1.0, 100.0, 2.5);
740        let sim = SpringSimulation::new(spring, 0.0, 1.0, 0.0, Tolerance::default());
741        let mut prev = sim.x(0.0);
742        let mut t = 0.0;
743        while t < 3.0 {
744            let v = sim.x(t);
745            assert!(v <= 1.0 + 1e-4, "no overshoot when overdamped, at t={t}");
746            assert!(v >= prev - 1e-4, "monotonic at t={t}");
747            prev = v;
748            t += 0.01;
749        }
750        dx_matches_x(&sim, 0.3);
751    }
752
753    #[test]
754    fn spring_honours_initial_velocity() {
755        let spring = SpringDescription::with_damping_ratio(1.0, 500.0, 1.0);
756        let sim = SpringSimulation::new(spring, 0.0, 1.0, 50.0, Tolerance::default());
757        // Initial velocity = 50 px/s towards the target.
758        assert!((sim.dx(0.0) - 50.0).abs() < 1e-2, "dx(0) = {}", sim.dx(0.0));
759    }
760
761    #[test]
762    fn friction_decelerates_to_finite_limit() {
763        let sim = FrictionSimulation::new(0.135, 0.0, 1000.0, Tolerance::PIXELS);
764        let limit = sim.final_x();
765        // The position approaches the finite limit without passing it.
766        assert!(sim.x(0.0).abs() < 1e-4);
767        assert!(sim.x(10.0) <= limit + 1e-3);
768        assert!(
769            (sim.x(10.0) - limit).abs() < 1.0,
770            "close to the limit after 10 s"
771        );
772        // The velocity decays towards 0.
773        assert!(sim.dx(0.0) > sim.dx(1.0));
774        assert!(sim.dx(5.0).abs() < sim.dx(0.0).abs());
775        dx_matches_x(&sim, 0.5);
776    }
777
778    #[test]
779    fn friction_through_hits_endpoints() {
780        // It must pass through x=0 at v=1000 and end at x=200 with v about 0.
781        let sim = FrictionSimulation::through(0.0, 200.0, 1000.0, 0.0, Tolerance::PIXELS);
782        assert!((sim.x(0.0)).abs() < 1e-3);
783        assert!(
784            (sim.final_x() - 200.0).abs() < 1e-1,
785            "final = {}",
786            sim.final_x()
787        );
788    }
789
790    #[test]
791    fn friction_time_at_x_inverts_x() {
792        let sim = FrictionSimulation::new(0.135, 0.0, 1000.0, Tolerance::PIXELS);
793        let t = sim.time_at_x(200.0);
794        assert!(t.is_finite(), "200 px is within reach of a 1000 px/s fling");
795        assert!(
796            (sim.x(t) - 200.0).abs() < 1e-2,
797            "x(time_at_x(200)) = {}",
798            sim.x(t)
799        );
800        // Behind the start, and past the limit: never reached.
801        assert!(sim.time_at_x(-10.0).is_infinite());
802        assert!(sim.time_at_x(sim.final_x() + 10.0).is_infinite());
803        assert_eq!(sim.time_at_x(0.0), 0.0);
804    }
805
806    #[test]
807    fn clamping_fling_matches_the_platform_distance() {
808        let sim = ClampingScrollSimulation::new(0.0, 1000.0, Tolerance::PIXELS);
809        // Derived by hand from the Android constants: a 1000 px/s fling runs about
810        // 0.46 s and covers about 194 px.
811        assert!(
812            (sim.duration() - 0.458).abs() < 0.01,
813            "duration = {}",
814            sim.duration()
815        );
816        assert!(
817            (sim.final_x() - 194.3).abs() < 1.0,
818            "distance = {}",
819            sim.final_x()
820        );
821        // Monotonic, decelerating, and finished at the end of its run.
822        let mut prev = sim.x(0.0);
823        let mut t = 0.0;
824        while t < sim.duration() {
825            let v = sim.x(t);
826            assert!(v >= prev - 1e-4, "not monotonic at t={t}");
827            prev = v;
828            t += 0.01;
829        }
830        assert!(
831            (sim.dx(0.0) - 1000.0).abs() < 1e-1,
832            "starts at the release velocity"
833        );
834        assert!(sim.dx(sim.duration()).abs() < 1e-3, "ends at rest");
835        assert!(!sim.is_done(sim.duration() * 0.5));
836        assert!(sim.is_done(sim.duration()));
837        dx_matches_x(&sim, 0.2);
838    }
839
840    #[test]
841    fn clamping_fling_is_ballistic() {
842        // The defining property: restarting the fling from its own mid-flight state
843        // lands in the same place. It is what lets a fling be resumed from nothing
844        // but the current offset and velocity.
845        let sim = ClampingScrollSimulation::new(0.0, 1200.0, Tolerance::PIXELS);
846        let t = 0.15;
847        let restarted = ClampingScrollSimulation::new(sim.x(t), sim.dx(t), Tolerance::PIXELS);
848        assert!(
849            (restarted.final_x() - sim.final_x()).abs() < 1.0,
850            "restarted lands at {} instead of {}",
851            restarted.final_x(),
852            sim.final_x()
853        );
854    }
855
856    #[test]
857    fn clamping_fling_below_tolerance_does_not_move() {
858        let sim = ClampingScrollSimulation::new(42.0, 0.0, Tolerance::PIXELS);
859        assert_eq!(sim.duration(), 0.0);
860        assert_eq!(sim.x(0.0), 42.0);
861        assert_eq!(sim.dx(0.0), 0.0);
862        assert!(sim.is_done(0.0));
863    }
864
865    /// The spring the scroll policies use by default.
866    fn scroll_spring() -> SpringDescription {
867        SpringDescription::with_damping_ratio(0.5, 100.0, 1.1)
868    }
869
870    #[test]
871    fn bouncing_fling_springs_back_when_it_starts_outside() {
872        // 30 px past the end, released with no velocity: pure return.
873        let sim = BouncingScrollSimulation::new(
874            130.0,
875            0.0,
876            0.0,
877            100.0,
878            scroll_spring(),
879            Tolerance::PIXELS,
880        );
881        assert!(
882            (sim.x(0.0) - 130.0).abs() < 1e-3,
883            "starts where it was let go"
884        );
885        assert!(sim.x(0.3) < 130.0, "comes back towards the edge");
886        assert!(
887            (sim.x(2.0) - 100.0).abs() < 0.5,
888            "settles on the edge, at {}",
889            sim.x(2.0)
890        );
891        assert!(sim.is_done(2.0));
892    }
893
894    #[test]
895    fn bouncing_fling_hands_friction_over_to_the_spring_at_the_edge() {
896        // Fast enough that friction alone would run far past the end.
897        let sim = BouncingScrollSimulation::new(
898            0.0,
899            2000.0,
900            0.0,
901            100.0,
902            scroll_spring(),
903            Tolerance::PIXELS,
904        );
905        let friction = FrictionSimulation::new(0.135, 0.0, 2000.0, Tolerance::PIXELS);
906        let handover = friction.time_at_x(100.0);
907        assert!(handover.is_finite() && handover > 0.0);
908        // Continuous across the hand-over: the position does not jump. The window
909        // has to be tight — at the edge the content is still moving at ~1800 px/s,
910        // so a millisecond either side is already two pixels of honest travel.
911        let before = sim.x(handover - 1e-5);
912        let after = sim.x(handover + 1e-5);
913        assert!((before - 100.0).abs() < 0.1, "before = {before}");
914        assert!((after - 100.0).abs() < 0.1, "after = {after}");
915        assert!((after - before).abs() < 0.1, "jump at the hand-over");
916        // It overshoots past the edge, then comes back and settles there.
917        let mut peak: f32 = 0.0;
918        let mut t = 0.0;
919        while t < 1.5 {
920            peak = peak.max(sim.x(t));
921            t += 0.005;
922        }
923        assert!(peak > 105.0, "the bounce should overshoot, peak = {peak}");
924        assert!(
925            (sim.x(3.0) - 100.0).abs() < 0.5,
926            "settles on the edge, at {}",
927            sim.x(3.0)
928        );
929        assert!(sim.is_done(3.0));
930    }
931
932    #[test]
933    fn bouncing_fling_that_dies_inside_never_reaches_the_spring() {
934        // 300 px/s over a 1000 px range: friction runs out well before the end.
935        let sim = BouncingScrollSimulation::new(
936            0.0,
937            300.0,
938            0.0,
939            1000.0,
940            scroll_spring(),
941            Tolerance::PIXELS,
942        );
943        let expected = FrictionSimulation::new(0.135, 0.0, 300.0, Tolerance::PIXELS);
944        assert!(
945            (sim.x(0.5) - expected.x(0.5)).abs() < 1e-3,
946            "pure friction while inside the content"
947        );
948        assert!(sim.x(5.0) < 1000.0);
949        assert!(sim.is_done(5.0));
950    }
951
952    #[test]
953    fn clamped_pins_position_but_reports_velocity() {
954        let inner = FrictionSimulation::new(0.135, 0.0, 1000.0, Tolerance::PIXELS);
955        let uncapped = inner.x(10.0);
956        assert!(uncapped > 100.0);
957        let clamped = ClampedSimulation::new(inner, 0.0, 100.0);
958        assert_eq!(clamped.x(10.0), 100.0, "position pinned to the maximum");
959        // The velocity stays that of the free motion, non-zero near the edge.
960        assert!(clamped.dx(0.1) > 0.0);
961    }
962
963    // --- Constant deceleration (milestone 499) ---
964
965    /// **Nought changes nothing.** The whole point of adding the term this way: a
966    /// finger's fling has to come out bit for bit what it was before.
967    #[test]
968    fn no_constant_deceleration_is_the_old_motion_exactly() {
969        let plain = FrictionSimulation::new(0.135, 0.0, 2000.0, Tolerance::PIXELS);
970        let with_none = FrictionSimulation::with_constant_deceleration(
971            0.135,
972            0.0,
973            2000.0,
974            Tolerance::PIXELS,
975            0.0,
976        );
977        for t in [0.0, 0.05, 0.2, 0.5, 1.0, 3.0] {
978            assert_eq!(plain.x(t), with_none.x(t), "position at {t}");
979            assert_eq!(plain.dx(t), with_none.dx(t), "velocity at {t}");
980        }
981        assert_eq!(plain.final_x(), with_none.final_x());
982    }
983
984    /// **Drag alone never stops; a constant term does.** This is the difference the
985    /// second profile exists for: a flick coasts to a halt asymptotically, and a
986    /// trackpad gesture is a hand letting go of a surface, which ends.
987    #[test]
988    fn a_constant_deceleration_brings_the_motion_to_an_actual_stop() {
989        let coasting = FrictionSimulation::new(0.135, 0.0, 2000.0, Tolerance::PIXELS);
990        let stopping = FrictionSimulation::with_constant_deceleration(
991            0.135,
992            0.0,
993            2000.0,
994            Tolerance::PIXELS,
995            1400.0,
996        );
997        // Drag alone: still moving, however slightly, however long you wait.
998        assert!(
999            coasting.dx(5.0) > 0.0,
1000            "drag alone only reaches nought at infinity"
1001        );
1002        // With the constant term: stopped, and stopped for good.
1003        assert_eq!(stopping.dx(5.0), 0.0);
1004        let rest = stopping.final_x();
1005        assert_eq!(stopping.x(5.0), rest, "and it stays where it stopped");
1006        assert_eq!(stopping.x(50.0), rest);
1007        assert!(
1008            rest < coasting.final_x(),
1009            "and it stops short of where coasting would have carried it: {rest} vs {}",
1010            coasting.final_x()
1011        );
1012    }
1013
1014    /// The stop is where the velocity reaches nought, which is what Newton was
1015    /// solving for. Checked from the other side: a hair before it the motion is
1016    /// still going, a hair after it is not.
1017    #[test]
1018    fn the_stopping_time_is_where_the_velocity_runs_out() {
1019        let sim = FrictionSimulation::with_constant_deceleration(
1020            0.135,
1021            0.0,
1022            2000.0,
1023            Tolerance::PIXELS,
1024            1400.0,
1025        );
1026        let rest = sim.final_x();
1027        // Walk forward until the motion has stopped, and check it was still moving
1028        // one step earlier — the stop is a point, not a plateau.
1029        let mut t = 0.0;
1030        while sim.dx(t) > 0.0 && t < 5.0 {
1031            t += 0.001;
1032        }
1033        assert!(t > 0.0 && t < 5.0, "it stops, and not immediately: {t}");
1034        assert!(sim.dx(t - 0.002) > 0.0, "still moving a step before");
1035        assert!((sim.x(t) - rest).abs() < 1.0, "and it is at rest there");
1036    }
1037
1038    /// **Newton still finds a crossing.** The closed form for *when the motion
1039    /// passes a given point* does not survive the constant term, and this is what
1040    /// hands a fling over to the edge spring at the right instant — so if it were
1041    /// wrong, a bounce would start early or late and nothing else would complain.
1042    #[test]
1043    fn the_crossing_instant_survives_the_constant_term() {
1044        let sim = FrictionSimulation::with_constant_deceleration(
1045            0.135,
1046            0.0,
1047            2000.0,
1048            Tolerance::PIXELS,
1049            1400.0,
1050        );
1051        let target = sim.final_x() * 0.5;
1052        let t = sim.time_at_x(target);
1053        assert!(t.is_finite() && t > 0.0, "reached, at {t}");
1054        assert!(
1055            (sim.x(t) - target).abs() < 0.5,
1056            "and x({t}) is {target}: got {}",
1057            sim.x(t)
1058        );
1059    }
1060
1061    /// It works going backwards too, the constant term being applied against the
1062    /// direction of travel rather than in a fixed one.
1063    #[test]
1064    fn a_backward_fling_decelerates_backwards() {
1065        let sim = FrictionSimulation::with_constant_deceleration(
1066            0.135,
1067            0.0,
1068            -2000.0,
1069            Tolerance::PIXELS,
1070            1400.0,
1071        );
1072        assert!(sim.final_x() < 0.0, "it travelled backwards");
1073        assert_eq!(sim.dx(5.0), 0.0, "and it stopped");
1074        let forward = FrictionSimulation::with_constant_deceleration(
1075            0.135,
1076            0.0,
1077            2000.0,
1078            Tolerance::PIXELS,
1079            1400.0,
1080        );
1081        assert!(
1082            (sim.final_x() + forward.final_x()).abs() < 0.5,
1083            "the same distance either way: {} vs {}",
1084            sim.final_x(),
1085            forward.final_x()
1086        );
1087    }
1088}