Skip to main content

frust_widgets/physics/
simulation.rs

1//! Concrete [`Simulation`] curves — the ballistic motion a
2//! [`ScrollPhysics`](super::ScrollPhysics) hands back once a gesture releases.
3//!
4//! Four ports of Flutter's scroll math, formulas and tuning constants carried
5//! over unchanged so a frust fling lands where the platform's own would:
6//! `physics/friction_simulation.dart` ([`FrictionSimulation`]),
7//! `physics/spring_simulation.dart` ([`SpringSimulation`],
8//! [`ScrollSpringSimulation`]) and `widgets/scroll_simulation.dart`
9//! ([`ClampingScrollSimulation`], [`BouncingScrollSimulation`]) — the last two
10//! being in turn ports of Android's `SplineOverScroller` curve and iOS's
11//! `UIScrollView` friction-then-rubber-band behavior. Every tuning constant is
12//! stated as the expression its source states it as (never a transcribed
13//! decimal) and pinned by a test in this file.
14//!
15//! Time is in **seconds** from each simulation's own start, per the
16//! [`Simulation`] contract; a simulation carries the [`Tolerance`] it settles
17//! within as a constructor argument rather than reading one per call.
18
19use super::{Simulation, SpringDescription, Tolerance};
20
21/// Flutter's `Tolerance.defaultTolerance` distance term — the fallback for a
22/// simulation constructed without a device-derived [`Tolerance`], i.e.
23/// [`FrictionSimulation::through`], which pins the velocity term to its own
24/// requested end velocity and has no device metrics to derive the rest from.
25const DEFAULT_DISTANCE_TOLERANCE: f64 = 1e-3;
26
27/// Dart's `double.sign`, which [`f64::signum`] is not: `signum` reports `1.0`
28/// for `0.0`, which would give a resting simulation a deceleration direction.
29fn signum_or_zero(value: f64) -> f64 {
30    if value == 0.0 { 0.0 } else { value.signum() }
31}
32
33/// Exponential drag decay: the iOS-style fling curve, and the friction phase of
34/// [`BouncingScrollSimulation`].
35///
36/// `drag` is the fraction of velocity surviving one second and must sit in
37/// `(0, 1)`. An optional constant deceleration subtracts a linear velocity term
38/// on top of the drag, for a caller that wants a fling to stop sooner than pure
39/// drag would; at `0.0` the curve is pure drag and only ever *approaches*
40/// [`FrictionSimulation::final_x`].
41#[derive(Debug, Clone)]
42pub struct FrictionSimulation {
43    drag: f64,
44    drag_log: f64,
45    position: f64,
46    velocity: f64,
47    /// Sign-corrected at construction so it always opposes `velocity`; `0.0`
48    /// when either input is zero.
49    constant_deceleration: f64,
50    /// When the constant-deceleration term cancels the exponential one and the
51    /// motion is over — [`f64::INFINITY`] under pure drag.
52    final_time: f64,
53    tolerance: Tolerance,
54}
55
56impl FrictionSimulation {
57    /// A drag curve from `position` at `velocity` px/s.
58    ///
59    /// `constant_deceleration` is `0.0` for pure drag; a nonzero value is
60    /// re-signed against `velocity` internally, so callers pass a magnitude.
61    pub fn new(
62        drag: f64,
63        position: f64,
64        velocity: f64,
65        tolerance: Tolerance,
66        constant_deceleration: f64,
67    ) -> Self {
68        let mut simulation = FrictionSimulation {
69            drag,
70            drag_log: drag.ln(),
71            position,
72            velocity,
73            constant_deceleration: constant_deceleration * signum_or_zero(velocity),
74            final_time: f64::INFINITY,
75            tolerance,
76        };
77        simulation.final_time = simulation.solve_final_time();
78        simulation
79    }
80
81    /// The drag curve that passes through both endpoints: at `start_x` moving
82    /// `start_velocity`, at `end_x` moving `end_velocity` (Flutter's
83    /// `FrictionSimulation.through`, whose `_dragFor` is the exponential
84    /// below). Settles within `end_velocity`, the speed the caller asked to
85    /// arrive at.
86    pub fn through(start_x: f64, end_x: f64, start_velocity: f64, end_velocity: f64) -> Self {
87        let drag = ((start_velocity - end_velocity) / (start_x - end_x)).exp();
88        FrictionSimulation::new(
89            drag,
90            start_x,
91            start_velocity,
92            Tolerance {
93                velocity: end_velocity.abs(),
94                distance: DEFAULT_DISTANCE_TOLERANCE,
95            },
96            0.0,
97        )
98    }
99
100    /// Where the curve ends up: the drag asymptote under pure drag, the
101    /// position at the final time once a constant deceleration stops it early.
102    pub fn final_x(&self) -> f64 {
103        if self.constant_deceleration == 0.0 {
104            self.position - self.velocity / self.drag_log
105        } else {
106            self.position_at(self.final_time)
107        }
108    }
109
110    /// The time at which the curve reaches `x`, or [`f64::INFINITY`] if it
111    /// never does (wrong side of the start, or beyond [`Self::final_x`]).
112    ///
113    /// Inverts the pure-drag position, so with a nonzero constant deceleration
114    /// the answer is an over-estimate of how far the curve actually gets — the
115    /// same approximation Flutter's `timeAtX` makes, and harmless for its one
116    /// caller ([`BouncingScrollSimulation`] finding its edge crossing).
117    pub fn time_at_x(&self, x: f64) -> f64 {
118        if x == self.position {
119            return 0.0;
120        }
121        let final_x = self.final_x();
122        let out_of_reach = if self.velocity > 0.0 {
123            x < self.position || x > final_x
124        } else {
125            x > self.position || x < final_x
126        };
127        if self.velocity == 0.0 || out_of_reach {
128            return f64::INFINITY;
129        }
130        ((x - self.position + self.velocity / self.drag_log) * self.drag_log / self.velocity).ln()
131            / self.drag_log
132    }
133
134    /// Position before the final-time clamp.
135    fn position_at(&self, time: f64) -> f64 {
136        let drag_term = self.position + self.velocity * self.drag.powf(time) / self.drag_log
137            - self.velocity / self.drag_log;
138        if self.constant_deceleration == 0.0 {
139            // Skipping the term rather than adding a zero: `time` is
140            // `INFINITY` under pure drag, and `0.0 * INFINITY` is NaN.
141            drag_term
142        } else {
143            drag_term - self.constant_deceleration / 2.0 * time * time
144        }
145    }
146
147    /// Velocity before the final-time clamp.
148    fn velocity_at(&self, time: f64) -> f64 {
149        let drag_term = self.velocity * self.drag.powf(time);
150        if self.constant_deceleration == 0.0 {
151            drag_term
152        } else {
153            drag_term - self.constant_deceleration * time
154        }
155    }
156
157    /// Bisect for the instant the constant-deceleration term cancels the
158    /// exponential one (velocity zero, motion over). `|v| / |a|` brackets that
159    /// root from above for any `drag < 1`: the linear term has reached `|v|`
160    /// there while the exponential one has decayed below it. A `drag` outside
161    /// `(0, 1)` never cancels, and reports no final time rather than looping.
162    fn solve_final_time(&self) -> f64 {
163        if self.constant_deceleration == 0.0 {
164            return f64::INFINITY;
165        }
166        let toward = signum_or_zero(self.velocity);
167        let mut lower = 0.0;
168        let mut upper = self.velocity.abs() / self.constant_deceleration.abs();
169        if self.velocity_at(upper) * toward > 0.0 {
170            return f64::INFINITY;
171        }
172        for _ in 0..64 {
173            let middle = 0.5 * (lower + upper);
174            if self.velocity_at(middle) * toward > 0.0 {
175                lower = middle;
176            } else {
177                upper = middle;
178            }
179        }
180        upper
181    }
182}
183
184impl Simulation for FrictionSimulation {
185    fn x(&self, time: f64) -> f64 {
186        self.position_at(time.min(self.final_time))
187    }
188
189    fn dx(&self, time: f64) -> f64 {
190        if time > self.final_time {
191            0.0
192        } else {
193            self.velocity_at(time)
194        }
195    }
196
197    /// Settled once the velocity is within tolerance — or, with a constant
198    /// deceleration, once that term has cancelled the drag term outright,
199    /// which a zero velocity tolerance (a [`Self::through`] curve asked to
200    /// arrive stopped) would otherwise never report.
201    fn is_done(&self, time: f64) -> bool {
202        time >= self.final_time || self.dx(time).abs() < self.tolerance.velocity
203    }
204}
205
206/// Which closed-form solution a [`SpringSimulation`] resolved to, decided by
207/// `damping² − 4·mass·stiffness`.
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub enum SpringType {
210    /// `damping² == 4·mass·stiffness` — returns in the shortest time that
211    /// still never crosses the end position.
212    Critical,
213    /// `damping² > 4·mass·stiffness` — slower than critical, no oscillation.
214    Overdamped,
215    /// `damping² < 4·mass·stiffness` — oscillates about the end position while
216    /// decaying.
217    Underdamped,
218}
219
220/// The solved coefficients of `m·ẍ + c·ẋ + k·x = 0` on the *displacement* from
221/// the end position, one variant per [`SpringType`].
222#[derive(Debug, Clone, Copy)]
223enum SpringSolution {
224    Critical { r: f64, c1: f64, c2: f64 },
225    Overdamped { r1: f64, r2: f64, c1: f64, c2: f64 },
226    Underdamped { w: f64, r: f64, c1: f64, c2: f64 },
227}
228
229impl SpringSolution {
230    fn new(spring: SpringDescription, distance: f64, velocity: f64) -> Self {
231        let discriminant = spring.damping * spring.damping - 4.0 * spring.mass * spring.stiffness;
232        if discriminant == 0.0 {
233            let r = -spring.damping / (2.0 * spring.mass);
234            SpringSolution::Critical {
235                r,
236                c1: distance,
237                c2: velocity - r * distance,
238            }
239        } else if discriminant > 0.0 {
240            let root = discriminant.sqrt();
241            let r1 = (-spring.damping - root) / (2.0 * spring.mass);
242            let r2 = (-spring.damping + root) / (2.0 * spring.mass);
243            let c2 = (velocity - r1 * distance) / (r2 - r1);
244            SpringSolution::Overdamped {
245                r1,
246                r2,
247                c1: distance - c2,
248                c2,
249            }
250        } else {
251            let w = (4.0 * spring.mass * spring.stiffness - spring.damping * spring.damping).sqrt()
252                / (2.0 * spring.mass);
253            // The analytically correct decay rate, `−c / 2m`.
254            let r = -spring.damping / (2.0 * spring.mass);
255            SpringSolution::Underdamped {
256                w,
257                r,
258                c1: distance,
259                c2: (velocity - r * distance) / w,
260            }
261        }
262    }
263
264    fn spring_type(&self) -> SpringType {
265        match self {
266            SpringSolution::Critical { .. } => SpringType::Critical,
267            SpringSolution::Overdamped { .. } => SpringType::Overdamped,
268            SpringSolution::Underdamped { .. } => SpringType::Underdamped,
269        }
270    }
271
272    /// Displacement from the end position at `time`.
273    fn x(&self, time: f64) -> f64 {
274        match *self {
275            SpringSolution::Critical { r, c1, c2 } => (c1 + c2 * time) * (r * time).exp(),
276            SpringSolution::Overdamped { r1, r2, c1, c2 } => {
277                c1 * (r1 * time).exp() + c2 * (r2 * time).exp()
278            }
279            SpringSolution::Underdamped { w, r, c1, c2 } => {
280                (r * time).exp() * (c1 * (w * time).cos() + c2 * (w * time).sin())
281            }
282        }
283    }
284
285    fn dx(&self, time: f64) -> f64 {
286        match *self {
287            SpringSolution::Critical { r, c1, c2 } => {
288                let decay = (r * time).exp();
289                r * (c1 + c2 * time) * decay + c2 * decay
290            }
291            SpringSolution::Overdamped { r1, r2, c1, c2 } => {
292                c1 * r1 * (r1 * time).exp() + c2 * r2 * (r2 * time).exp()
293            }
294            SpringSolution::Underdamped { w, r, c1, c2 } => {
295                let decay = (r * time).exp();
296                let cosine = (w * time).cos();
297                let sine = (w * time).sin();
298                decay * (c2 * w * cosine - c1 * w * sine) + r * decay * (c2 * sine + c1 * cosine)
299            }
300        }
301    }
302}
303
304/// A damped spring settling from `start` onto `end`, solving
305/// `m·ẍ + c·ẋ + k·x = 0` on the displacement between them.
306#[derive(Debug, Clone)]
307pub struct SpringSimulation {
308    end: f64,
309    solution: SpringSolution,
310    tolerance: Tolerance,
311}
312
313impl SpringSimulation {
314    /// A spring released at `start` moving `velocity` px/s, pulling toward
315    /// `end`.
316    pub fn new(
317        spring: SpringDescription,
318        start: f64,
319        end: f64,
320        velocity: f64,
321        tolerance: Tolerance,
322    ) -> Self {
323        SpringSimulation {
324            end,
325            solution: SpringSolution::new(spring, start - end, velocity),
326            tolerance,
327        }
328    }
329
330    /// The position this spring settles onto.
331    pub fn end(&self) -> f64 {
332        self.end
333    }
334
335    /// Which solution class the spring's parameters resolved to.
336    pub fn spring_type(&self) -> SpringType {
337        self.solution.spring_type()
338    }
339}
340
341impl Simulation for SpringSimulation {
342    fn x(&self, time: f64) -> f64 {
343        self.end + self.solution.x(time)
344    }
345
346    fn dx(&self, time: f64) -> f64 {
347        self.solution.dx(time)
348    }
349
350    fn is_done(&self, time: f64) -> bool {
351        self.solution.x(time).abs() < self.tolerance.distance
352            && self.solution.dx(time).abs() < self.tolerance.velocity
353    }
354}
355
356/// A [`SpringSimulation`] that reports *exactly* `end` once settled, rather
357/// than the residual sub-tolerance displacement the closed form still carries.
358///
359/// The scroll surfaces' spring: a bounce-back must leave the position on the
360/// edge value it was springing to, not a fraction of a pixel off it, or the
361/// next gesture starts from a technically-overscrolled position.
362#[derive(Debug, Clone)]
363pub struct ScrollSpringSimulation {
364    inner: SpringSimulation,
365}
366
367impl ScrollSpringSimulation {
368    /// A spring released at `start` moving `velocity` px/s, pulling toward
369    /// `end`, snapping onto `end` once settled.
370    pub fn new(
371        spring: SpringDescription,
372        start: f64,
373        end: f64,
374        velocity: f64,
375        tolerance: Tolerance,
376    ) -> Self {
377        ScrollSpringSimulation {
378            inner: SpringSimulation::new(spring, start, end, velocity, tolerance),
379        }
380    }
381
382    /// The position this spring settles onto.
383    pub fn end(&self) -> f64 {
384        self.inner.end()
385    }
386
387    /// Which solution class the spring's parameters resolved to.
388    pub fn spring_type(&self) -> SpringType {
389        self.inner.spring_type()
390    }
391}
392
393impl Simulation for ScrollSpringSimulation {
394    fn x(&self, time: f64) -> f64 {
395        if self.inner.is_done(time) {
396            self.inner.end()
397        } else {
398            self.inner.x(time)
399        }
400    }
401
402    fn dx(&self, time: f64) -> f64 {
403        self.inner.dx(time)
404    }
405
406    fn is_done(&self, time: f64) -> bool {
407        self.inner.is_done(time)
408    }
409}
410
411/// Android's fling curve — the `SplineOverScroller` deceleration a
412/// clamping scroll surface runs after a release, which decays to a hard stop
413/// at a known duration and distance rather than an asymptote.
414#[derive(Debug, Clone)]
415pub struct ClampingScrollSimulation {
416    position: f64,
417    velocity: f64,
418    /// Seconds until the curve stops.
419    duration: f64,
420    /// Total signed travel over `duration`.
421    distance: f64,
422    tolerance: Tolerance,
423}
424
425impl ClampingScrollSimulation {
426    /// Android `ViewConfiguration`'s default scroll friction.
427    pub const DEFAULT_FRICTION: f64 = 0.015;
428
429    /// Android `SplineOverScroller.INFLEXION`, where the fling spline's two
430    /// tension segments cross, and the velocity scale in the deceleration term
431    /// below.
432    pub const INFLEXION: f64 = 0.35;
433
434    /// Android's `mPhysicalCoeff`: gravity (m/s²) × inches per meter × dpi ×
435    /// an empirical tuning factor. The dpi term is fixed at 160 — one Android
436    /// density-independent pixel, which is what this crate's logical pixel is.
437    pub const PHYSICAL_COEFF: f64 = 9.80665 * 39.37 * 160.0 * 0.84;
438
439    /// Android's `SplineOverScroller.DECELERATION_RATE` (≈ 2.358202).
440    ///
441    /// Computed from Android's own expression rather than transcribed as a
442    /// decimal; a function rather than a `const` because [`f64::ln`] is not
443    /// `const`.
444    #[inline]
445    pub fn deceleration_rate() -> f64 {
446        0.78_f64.ln() / 0.9_f64.ln()
447    }
448
449    /// A fling from `position` at `velocity` px/s under `friction`
450    /// ([`Self::DEFAULT_FRICTION`] for the platform default).
451    pub fn new(position: f64, velocity: f64, friction: f64, tolerance: Tolerance) -> Self {
452        let deceleration =
453            (Self::INFLEXION * velocity.abs() / (friction * Self::PHYSICAL_COEFF)).ln();
454        let duration = (deceleration / (Self::deceleration_rate() - 1.0)).exp();
455        ClampingScrollSimulation {
456            position,
457            velocity,
458            duration,
459            distance: velocity * duration / Self::deceleration_rate(),
460            tolerance,
461        }
462    }
463
464    /// Seconds until the fling stops.
465    pub fn duration(&self) -> f64 {
466        self.duration
467    }
468
469    /// Where the fling stops.
470    pub fn final_x(&self) -> f64 {
471        self.position + self.distance
472    }
473
474    /// Fraction of the curve elapsed, clamped into `[0, 1]`. A zero-velocity
475    /// fling has no duration at all and reads as already over, rather than
476    /// dividing by zero.
477    fn progress(&self, time: f64) -> f64 {
478        if self.duration > 0.0 {
479            (time / self.duration).clamp(0.0, 1.0)
480        } else {
481            1.0
482        }
483    }
484}
485
486impl Simulation for ClampingScrollSimulation {
487    fn x(&self, time: f64) -> f64 {
488        let remaining = 1.0 - self.progress(time);
489        self.position + self.distance * (1.0 - remaining.powf(Self::deceleration_rate()))
490    }
491
492    fn dx(&self, time: f64) -> f64 {
493        let remaining = 1.0 - self.progress(time);
494        self.velocity * remaining.powf(Self::deceleration_rate() - 1.0)
495    }
496
497    fn is_done(&self, time: f64) -> bool {
498        time >= self.duration || self.dx(time).abs() < self.tolerance.velocity
499    }
500}
501
502/// Which curve a [`BouncingScrollSimulation`] is running, and when it hands
503/// over.
504#[derive(Debug, Clone)]
505enum BouncingPhase {
506    /// Released already outside the range: the edge spring owns the whole
507    /// motion.
508    Spring(ScrollSpringSimulation),
509    /// Released in range, decaying to a stop before either edge.
510    Friction(FrictionSimulation),
511    /// Released in range on course to hit an edge: friction until
512    /// `spring_time`, the edge spring (started at that instant) after it.
513    FrictionThenSpring {
514        friction: FrictionSimulation,
515        spring: ScrollSpringSimulation,
516        spring_time: f64,
517    },
518}
519
520/// iOS's fling: exponential friction while the position is in range, handing
521/// over to a rubber-band spring at whichever edge the fling reaches.
522#[derive(Debug, Clone)]
523pub struct BouncingScrollSimulation {
524    phase: BouncingPhase,
525}
526
527impl BouncingScrollSimulation {
528    /// The fastest velocity handed to an edge spring. A fling arriving faster
529    /// transfers this instead, so an arbitrarily hard flick cannot turn into an
530    /// arbitrarily deep bounce.
531    pub const MAX_SPRING_TRANSFER_VELOCITY: f64 = 5000.0;
532
533    /// The friction phase's drag: `UIScrollView`'s normal deceleration rate is
534    /// 0.998 per millisecond, and `0.998^1000 ≈ 0.135` per second.
535    pub const FRICTION_DRAG: f64 = 0.135;
536
537    /// A fling from `position` at `velocity` px/s within
538    /// `leading_extent..=trailing_extent`, bouncing off whichever edge it
539    /// reaches (or starting mid-bounce if `position` is already outside).
540    pub fn new(
541        position: f64,
542        velocity: f64,
543        leading_extent: f64,
544        trailing_extent: f64,
545        spring: SpringDescription,
546        tolerance: Tolerance,
547        constant_deceleration: f64,
548    ) -> Self {
549        debug_assert!(
550            leading_extent <= trailing_extent,
551            "leading extent {leading_extent} must not exceed trailing extent {trailing_extent}"
552        );
553        let phase = if position < leading_extent {
554            BouncingPhase::Spring(Self::edge_spring(
555                spring,
556                position,
557                leading_extent,
558                velocity,
559                tolerance,
560            ))
561        } else if position > trailing_extent {
562            BouncingPhase::Spring(Self::edge_spring(
563                spring,
564                position,
565                trailing_extent,
566                velocity,
567                tolerance,
568            ))
569        } else {
570            let friction = FrictionSimulation::new(
571                Self::FRICTION_DRAG,
572                position,
573                velocity,
574                tolerance,
575                constant_deceleration,
576            );
577            let heading_for = if velocity > 0.0 {
578                trailing_extent
579            } else if velocity < 0.0 {
580                leading_extent
581            } else {
582                // A release with no velocity in range never reaches an edge.
583                return BouncingScrollSimulation {
584                    phase: BouncingPhase::Friction(friction),
585                };
586            };
587            // Infinite means the friction curve stops short of that edge.
588            let spring_time = friction.time_at_x(heading_for);
589            if spring_time.is_finite() {
590                let transfer = friction.dx(spring_time);
591                BouncingPhase::FrictionThenSpring {
592                    spring: Self::edge_spring(
593                        spring,
594                        heading_for,
595                        heading_for,
596                        transfer,
597                        tolerance,
598                    ),
599                    friction,
600                    spring_time,
601                }
602            } else {
603                BouncingPhase::Friction(friction)
604            }
605        };
606        BouncingScrollSimulation { phase }
607    }
608
609    /// The rubber-band spring back onto `extent`, its seed velocity clamped to
610    /// ±[`Self::MAX_SPRING_TRANSFER_VELOCITY`].
611    fn edge_spring(
612        spring: SpringDescription,
613        start: f64,
614        extent: f64,
615        velocity: f64,
616        tolerance: Tolerance,
617    ) -> ScrollSpringSimulation {
618        ScrollSpringSimulation::new(
619            spring,
620            start,
621            extent,
622            velocity.clamp(
623                -Self::MAX_SPRING_TRANSFER_VELOCITY,
624                Self::MAX_SPRING_TRANSFER_VELOCITY,
625            ),
626            tolerance,
627        )
628    }
629
630    /// The curve owning `time`, plus the offset to shift `time` by before
631    /// querying it (a spring handed over mid-fling starts its own clock at the
632    /// handover).
633    fn active(&self, time: f64) -> (&dyn Simulation, f64) {
634        match &self.phase {
635            BouncingPhase::Spring(spring) => (spring, 0.0),
636            BouncingPhase::Friction(friction) => (friction, 0.0),
637            BouncingPhase::FrictionThenSpring {
638                friction,
639                spring,
640                spring_time,
641            } => {
642                if time >= *spring_time {
643                    (spring, *spring_time)
644                } else {
645                    (friction, 0.0)
646                }
647            }
648        }
649    }
650}
651
652impl Simulation for BouncingScrollSimulation {
653    fn x(&self, time: f64) -> f64 {
654        let (simulation, offset) = self.active(time);
655        simulation.x(time - offset)
656    }
657
658    fn dx(&self, time: f64) -> f64 {
659        let (simulation, offset) = self.active(time);
660        simulation.dx(time - offset)
661    }
662
663    fn is_done(&self, time: f64) -> bool {
664        let (simulation, offset) = self.active(time);
665        simulation.is_done(time - offset)
666    }
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672
673    /// A 1.0-dpr device tolerance: 20 px/s, 1 px.
674    fn tol() -> Tolerance {
675        Tolerance::for_device_pixel_ratio(1.0)
676    }
677
678    fn assert_close(actual: f64, expected: f64, epsilon: f64, what: &str) {
679        assert!(
680            (actual - expected).abs() < epsilon,
681            "{what}: {actual} is not within {epsilon} of {expected}"
682        );
683    }
684
685    #[test]
686    fn friction_final_x_matches_closed_form() {
687        let simulation = FrictionSimulation::new(0.135, 20.0, 1000.0, tol(), 0.0);
688        let expected = 20.0 - 1000.0 / 0.135_f64.ln();
689        assert_close(simulation.final_x(), expected, 1e-9, "final_x");
690        assert_close(simulation.x(f64::INFINITY), expected, 1e-9, "x at infinity");
691        assert_close(simulation.x(20.0), expected, 1e-6, "x long after release");
692        assert!(!simulation.is_done(0.0), "a live fling reported settled");
693        assert!(simulation.is_done(20.0), "a decayed fling never settled");
694    }
695
696    #[test]
697    fn friction_through_passes_endpoints() {
698        let simulation = FrictionSimulation::through(0.0, 100.0, 500.0, 100.0);
699        let arrival = simulation.time_at_x(100.0);
700        assert!(arrival.is_finite(), "end position unreachable at {arrival}");
701        assert_close(simulation.x(0.0), 0.0, 1e-9, "start position");
702        assert_close(simulation.dx(0.0), 500.0, 1e-9, "start velocity");
703        assert_close(simulation.x(arrival), 100.0, 1e-9, "end position");
704        assert_close(simulation.dx(arrival), 100.0, 1e-9, "end velocity");
705
706        // Arriving stopped is the asymptote case: the curve only approaches the
707        // end position, so `through` puts it exactly at `final_x`.
708        let asymptotic = FrictionSimulation::through(0.0, 100.0, 500.0, 0.0);
709        assert_close(asymptotic.final_x(), 100.0, 1e-9, "asymptotic final_x");
710        assert!(
711            asymptotic.time_at_x(200.0).is_infinite(),
712            "a position past final_x must be unreachable"
713        );
714        assert!(
715            asymptotic.time_at_x(-10.0).is_infinite(),
716            "a position behind the start must be unreachable"
717        );
718    }
719
720    #[test]
721    fn friction_velocity_decays_exponentially() {
722        let simulation = FrictionSimulation::new(0.135, 0.0, 800.0, tol(), 0.0);
723        let ratio = simulation.dx(0.5) / simulation.dx(0.0);
724        assert_close(ratio, 0.135_f64.powf(0.5), 1e-9, "half-second decay ratio");
725    }
726
727    #[test]
728    fn friction_constant_deceleration_ends_the_motion() {
729        let plain = FrictionSimulation::new(0.135, 0.0, 1000.0, tol(), 0.0);
730        let decelerated = FrictionSimulation::new(0.135, 0.0, 1000.0, tol(), 3000.0);
731        assert!(
732            decelerated.final_x() < plain.final_x(),
733            "constant deceleration travelled {}, no further than pure drag's {}",
734            decelerated.final_x(),
735            plain.final_x()
736        );
737        assert!(decelerated.is_done(2.0), "the motion never ended");
738        assert_eq!(decelerated.dx(5.0), 0.0, "velocity past the end");
739        assert_close(
740            decelerated.x(5.0),
741            decelerated.final_x(),
742            1e-9,
743            "position past the end",
744        );
745    }
746
747    #[test]
748    fn spring_critical_over_under_damped_all_converge_to_end() {
749        // `damping² − 4·mass·stiffness` picks the class: 400 − 400 == 0,
750        // 900 − 400 > 0, 25 − 400 < 0.
751        let cases = [
752            (20.0, SpringType::Critical),
753            (30.0, SpringType::Overdamped),
754            (5.0, SpringType::Underdamped),
755        ];
756        for (damping, expected_type) in cases {
757            let spring = SpringDescription {
758                mass: 1.0,
759                stiffness: 100.0,
760                damping,
761            };
762            let simulation = SpringSimulation::new(spring, 100.0, 0.0, 0.0, tol());
763            assert_eq!(simulation.spring_type(), expected_type);
764            assert_close(simulation.x(0.0), 100.0, 1e-9, "start position");
765            assert_close(simulation.dx(0.0), 0.0, 1e-9, "start velocity");
766            assert_close(simulation.x(10.0), 0.0, 1e-6, "converged position");
767            assert!(
768                !simulation.is_done(0.0),
769                "{expected_type:?} spring reported settled at release"
770            );
771            assert!(
772                simulation.is_done(10.0),
773                "{expected_type:?} spring never settled"
774            );
775        }
776    }
777
778    #[test]
779    fn spring_with_damping_ratio_1_1_is_overdamped_no_oscillation() {
780        let simulation = SpringSimulation::new(
781            SpringDescription::default_scroll_spring(),
782            100.0,
783            0.0,
784            0.0,
785            tol(),
786        );
787        assert_eq!(simulation.spring_type(), SpringType::Overdamped);
788        for step in 0..=2000 {
789            let time = f64::from(step) / 1000.0;
790            let displacement = simulation.x(time) - simulation.end();
791            assert!(
792                displacement >= 0.0,
793                "crossed the end position at t={time}s (displacement {displacement})"
794            );
795        }
796    }
797
798    #[test]
799    fn scroll_spring_snaps_to_end_once_settled() {
800        let spring = SpringDescription::default_scroll_spring();
801        let plain = SpringSimulation::new(spring, -50.0, 0.0, 0.0, tol());
802        let snapping = ScrollSpringSimulation::new(spring, -50.0, 0.0, 0.0, tol());
803
804        // Live: the two agree exactly.
805        assert_eq!(snapping.x(0.05), plain.x(0.05));
806        assert_eq!(snapping.dx(0.05), plain.dx(0.05));
807
808        // Settled: the plain spring still carries a sub-tolerance residual, the
809        // scroll spring reports the edge itself.
810        assert!(snapping.is_done(3.0));
811        assert_ne!(plain.x(3.0), 0.0);
812        assert_eq!(snapping.x(3.0), snapping.end());
813    }
814
815    #[test]
816    fn clamping_deceleration_rate_value() {
817        assert_close(
818            ClampingScrollSimulation::deceleration_rate(),
819            2.358_202,
820            1e-5,
821            "DECELERATION_RATE",
822        );
823        assert_close(
824            ClampingScrollSimulation::PHYSICAL_COEFF,
825            51_890.2,
826            0.1,
827            "PHYSICAL_COEFF",
828        );
829        assert_eq!(ClampingScrollSimulation::INFLEXION, 0.35);
830        assert_eq!(ClampingScrollSimulation::DEFAULT_FRICTION, 0.015);
831    }
832
833    #[test]
834    fn clamping_dx_at_zero_equals_initial_velocity() {
835        for velocity in [2000.0, -2000.0, 350.0] {
836            let simulation = ClampingScrollSimulation::new(
837                0.0,
838                velocity,
839                ClampingScrollSimulation::DEFAULT_FRICTION,
840                tol(),
841            );
842            assert_close(simulation.dx(0.0), velocity, 1e-9, "dx at release");
843        }
844    }
845
846    #[test]
847    fn clamping_stops_at_duration() {
848        let simulation = ClampingScrollSimulation::new(
849            10.0,
850            2000.0,
851            ClampingScrollSimulation::DEFAULT_FRICTION,
852            tol(),
853        );
854        let duration = simulation.duration();
855        let target = simulation.final_x();
856        assert!(
857            duration > 0.0 && duration.is_finite(),
858            "implausible duration {duration}"
859        );
860        assert!(target > 10.0, "a positive fling must travel forward");
861        assert!(!simulation.is_done(0.0), "a live fling reported settled");
862        assert!(simulation.is_done(duration), "the fling never stopped");
863        assert_close(simulation.x(duration), target, 1e-9, "position at duration");
864        assert_close(
865            simulation.x(duration * 2.0),
866            target,
867            1e-9,
868            "position past duration",
869        );
870        assert_close(simulation.dx(duration), 0.0, 1e-9, "velocity at duration");
871
872        let mut previous = simulation.x(0.0);
873        for step in 1..=100 {
874            let position = simulation.x(duration * f64::from(step) / 100.0);
875            assert!(position >= previous, "backtracked to {position}");
876            assert!(position <= target + 1e-9, "overshot the target: {position}");
877            previous = position;
878        }
879    }
880
881    #[test]
882    fn bouncing_switches_friction_to_spring_at_extent() {
883        let trailing = 100.0;
884        let simulation = BouncingScrollSimulation::new(
885            0.0,
886            2000.0,
887            0.0,
888            trailing,
889            SpringDescription::default_scroll_spring(),
890            tol(),
891            0.0,
892        );
893        let friction = FrictionSimulation::new(
894            BouncingScrollSimulation::FRICTION_DRAG,
895            0.0,
896            2000.0,
897            tol(),
898            0.0,
899        );
900        let handover = friction.time_at_x(trailing);
901        assert!(handover.is_finite(), "the fling must reach the extent");
902
903        // Friction phase: identical to the standalone friction curve.
904        let early = handover / 2.0;
905        assert_eq!(simulation.x(early), friction.x(early));
906        assert_eq!(simulation.dx(early), friction.dx(early));
907
908        // Handover: the spring starts at the extent carrying the friction
909        // curve's velocity.
910        assert_close(
911            simulation.x(handover),
912            trailing,
913            1e-9,
914            "position at handover",
915        );
916        assert_close(
917            simulation.dx(handover),
918            friction.dx(handover),
919            1e-9,
920            "velocity at handover",
921        );
922
923        // Spring phase: bounded overshoot, then back onto the extent.
924        let mut furthest = f64::MIN;
925        for step in 0..=500 {
926            furthest = furthest.max(simulation.x(handover + f64::from(step) / 100.0));
927        }
928        assert!(
929            furthest > trailing,
930            "the bounce never passed the extent (peaked at {furthest})"
931        );
932        assert!(
933            furthest < trailing + 60.0,
934            "the bounce ran away past the extent (peaked at {furthest})"
935        );
936        assert_close(
937            simulation.x(handover + 5.0),
938            trailing,
939            1e-9,
940            "settled position",
941        );
942        assert!(
943            simulation.is_done(handover + 5.0),
944            "the bounce never settled"
945        );
946    }
947
948    #[test]
949    fn bouncing_stays_friction_when_no_extent_is_reached() {
950        let simulation = BouncingScrollSimulation::new(
951            0.0,
952            100.0,
953            0.0,
954            10_000.0,
955            SpringDescription::default_scroll_spring(),
956            tol(),
957            0.0,
958        );
959        let friction = FrictionSimulation::new(
960            BouncingScrollSimulation::FRICTION_DRAG,
961            0.0,
962            100.0,
963            tol(),
964            0.0,
965        );
966        assert!(
967            friction.time_at_x(10_000.0).is_infinite(),
968            "this fling must stop short of the extent for the test to mean anything"
969        );
970        for step in 0..=100 {
971            let time = f64::from(step) / 10.0;
972            assert_eq!(simulation.x(time), friction.x(time));
973            assert_eq!(simulation.dx(time), friction.dx(time));
974        }
975    }
976
977    #[test]
978    fn bouncing_underscroll_starts_as_spring() {
979        let leading = 0.0;
980        let simulation = BouncingScrollSimulation::new(
981            -50.0,
982            0.0,
983            leading,
984            100.0,
985            SpringDescription::default_scroll_spring(),
986            tol(),
987            0.0,
988        );
989        assert_close(simulation.x(0.0), -50.0, 1e-9, "starts where released");
990        assert!(
991            simulation.x(0.1) > simulation.x(0.0),
992            "must travel back toward the leading extent"
993        );
994        assert!(!simulation.is_done(0.0), "an overscrolled rest is not done");
995        assert_close(simulation.x(3.0), leading, 1e-9, "settled position");
996        assert!(simulation.is_done(3.0), "the bounce-back never settled");
997    }
998
999    #[test]
1000    fn bouncing_spring_transfer_velocity_capped() {
1001        let spring = SpringDescription::default_scroll_spring();
1002        let cap = BouncingScrollSimulation::MAX_SPRING_TRANSFER_VELOCITY;
1003
1004        // Released already past the leading edge, absurdly fast.
1005        let underscroll =
1006            BouncingScrollSimulation::new(-50.0, -20_000.0, 0.0, 100.0, spring, tol(), 0.0);
1007        assert_close(underscroll.dx(0.0), -cap, 1e-9, "clamped underscroll seed");
1008
1009        // Reaching the trailing edge under friction faster than the cap.
1010        let fling = BouncingScrollSimulation::new(0.0, 50_000.0, 0.0, 100.0, spring, tol(), 0.0);
1011        let friction = FrictionSimulation::new(
1012            BouncingScrollSimulation::FRICTION_DRAG,
1013            0.0,
1014            50_000.0,
1015            tol(),
1016            0.0,
1017        );
1018        let handover = friction.time_at_x(100.0);
1019        assert!(
1020            friction.dx(handover) > cap,
1021            "the fling must arrive above the cap for the test to mean anything"
1022        );
1023        assert_close(fling.dx(handover), cap, 1e-9, "clamped handover seed");
1024    }
1025}