Skip to main content

frus_core/animation/
velocity.rs

1//! Estimating how fast a pointer was moving when it let go.
2//!
3//! This is the **input** to every ballistic motion in the framework: a fling's
4//! distance, whether a swipe dismisses a route, how far a pan coasts. Everything
5//! downstream — the scroll physics, the navigation spring — is only as good as
6//! this number.
7//!
8//! The naive answer, and the one frus used until now, is to smooth successive
9//! deltas: `v = v/2 + instant/2`. It is cheap and it is wrong in the way that
10//! matters. A finger does not move at a constant speed; it accelerates, wobbles,
11//! and — the case that ruins the estimate — often **slows down just before
12//! lifting**. An exponential average weights the last sample heavily, so it reads
13//! that deceleration as the gesture's speed and the content barely moves. The
14//! user's thumb says "throw"; the framework hears "nudge".
15//!
16//! The answer here is the one mature toolkits converged on: keep a short history
17//! of positions and **fit a curve through it**, taking the fit's derivative at the
18//! release as the velocity. A quadratic least-squares regression over the last
19//! 100 ms sees the acceleration, so a last-instant slowdown moves the estimate a
20//! little instead of dominating it.
21//!
22//! Some platforms do something different for scroll flings — a weighted average of
23//! the last three sample-to-sample velocities, which deliberately leans on the
24//! *older* samples. [`VelocityStrategy`] carries both; which one a scroll reads its
25//! flings with is chosen by the scroll behaviour in force, from the platform it
26//! follows, beside the scroll physics.
27//!
28//! Everything here is pure: samples come in stamped with a time, and the estimate
29//! is a function of the history. No clock is read.
30
31use crate::geometry::Point;
32
33/// A pointer speed, in logical pixels per second.
34#[derive(Clone, Copy, Debug, Default, PartialEq)]
35pub struct Velocity {
36    /// The horizontal component, in logical pixels per second.
37    pub x: f32,
38    /// The vertical component, in logical pixels per second.
39    pub y: f32,
40}
41
42impl Velocity {
43    /// Not moving.
44    pub const ZERO: Velocity = Velocity { x: 0.0, y: 0.0 };
45
46    /// A velocity with the given components, in logical pixels per second.
47    pub fn new(x: f32, y: f32) -> Self {
48        Self { x, y }
49    }
50
51    /// The speed, regardless of direction.
52    pub fn magnitude(self) -> f32 {
53        self.x.hypot(self.y)
54    }
55
56    /// The same direction, with the speed brought into `[min, max]`.
57    ///
58    /// A gesture below `min` is *sped up* to it rather than dropped: callers that
59    /// want to drop it test the magnitude first.
60    pub fn clamp_magnitude(self, min: f32, max: f32) -> Velocity {
61        debug_assert!(min >= 0.0 && max >= min);
62        let magnitude = self.magnitude();
63        if magnitude == 0.0 {
64            return self;
65        }
66        let target = magnitude.clamp(min, max);
67        if target == magnitude {
68            return self;
69        }
70        let scale = target / magnitude;
71        Velocity::new(self.x * scale, self.y * scale)
72    }
73}
74
75/// A velocity, with what the tracker knows about how much to trust it.
76#[derive(Clone, Copy, Debug, PartialEq)]
77pub struct VelocityEstimate {
78    /// The estimated speed at the last sample, in px/s.
79    pub velocity: Velocity,
80    /// How much of the samples' variance the fit explains, in `0..=1` (an
81    /// r-squared). `1.0` for the strategies that do not fit anything.
82    pub confidence: f32,
83    /// The time the samples used span, in seconds.
84    pub duration: f32,
85    /// The total displacement over those samples, in px. A gesture that was fast
86    /// but covered no ground is a twitch, not a fling — which is why this travels
87    /// with the velocity.
88    pub offset: (f32, f32),
89}
90
91impl VelocityEstimate {
92    /// An estimate that says "not moving", with nothing to doubt about it.
93    pub const STILL: VelocityEstimate = VelocityEstimate {
94        velocity: Velocity::ZERO,
95        confidence: 1.0,
96        duration: 0.0,
97        offset: (0.0, 0.0),
98    };
99}
100
101/// How many samples are kept. At a 120 Hz sampling rate this is ~166 ms of
102/// history, comfortably more than the horizon below.
103const HISTORY: usize = 20;
104/// Samples older than this (in ms) are not part of the same motion.
105const HORIZON_MS: f32 = 100.0;
106/// A gap longer than this (in ms) means the finger stopped: the history before it
107/// belongs to a different gesture, and a release after it is not a fling.
108const STOPPED_MS: f32 = 40.0;
109/// How many of the newest gaps between samples say what pace they are arriving at.
110const DELIVERY_GAPS: usize = 3;
111/// The most that pace excuses, in ms — past it, a pause is a pause however slowly the
112/// application is running.
113///
114/// Samples are stamped when an event is *handled*, not when the finger moved: no event time
115/// reaches the shell on every platform. An application that renders slowly is handed its
116/// events once per frame, so the release of a finger still moving arrives up to a frame after
117/// its last movement — and with frames of ~37 ms that alone crossed `STOPPED_MS`, and a throw
118/// on a phone was read as a finger that had stopped (milestone 520).
119const MAX_DELIVERY_MS: f32 = 50.0;
120/// Below this many samples there is nothing to regress.
121const MIN_SAMPLES: usize = 3;
122
123/// The weights a platform that bounces applies to the last three sample-to-sample
124/// velocities, oldest first.
125pub const BOUNCING_FLING_WEIGHTS: [f32; 3] = [0.6, 0.35, 0.05];
126/// The same, for the desktop variant of that platform.
127pub const DESKTOP_FLING_WEIGHTS: [f32; 3] = [0.15, 0.65, 0.2];
128
129/// How a [`VelocityTracker`] turns a history of positions into a velocity.
130#[derive(Clone, Copy, Debug, PartialEq)]
131pub enum VelocityStrategy {
132    /// A quadratic least-squares regression over the samples inside the horizon,
133    /// with the velocity read off as the fit's first-order coefficient. Sees
134    /// acceleration; robust to a noisy or decelerating tail.
135    Regression,
136    /// A weighted average of the last three sample-to-sample velocities, oldest
137    /// first. Cheap, and deliberately biased towards the *older* samples — which
138    /// is how the platforms that bounce read a fling.
139    RecentAverage([f32; 3]),
140}
141
142#[derive(Clone, Copy, Debug)]
143struct Sample {
144    point: Point,
145    /// Seconds, on whatever clock the caller is stamping with.
146    time: f32,
147}
148
149/// A rolling history of pointer positions that can be asked for a velocity.
150///
151/// Adding a position is cheap; asking for the estimate is not free (the
152/// regression strategy solves a small system), so ask once, at the release.
153#[derive(Clone, Debug)]
154pub struct VelocityTracker {
155    strategy: VelocityStrategy,
156    samples: [Option<Sample>; HISTORY],
157    /// Where the newest sample sits in the ring.
158    index: usize,
159}
160
161impl Default for VelocityTracker {
162    /// The base tracker, a fitted curve ([`VelocityStrategy::Regression`]). Which one a
163    /// scroll reads its flings with is its scroll behaviour's choice (milestone 616).
164    fn default() -> Self {
165        Self::new(VelocityStrategy::Regression)
166    }
167}
168
169impl VelocityTracker {
170    /// A tracker using `strategy`.
171    pub fn new(strategy: VelocityStrategy) -> Self {
172        Self {
173            strategy,
174            samples: [None; HISTORY],
175            index: 0,
176        }
177    }
178
179    /// The strategy in force.
180    pub fn strategy(&self) -> VelocityStrategy {
181        self.strategy
182    }
183
184    /// Records where the pointer was, and when (in seconds).
185    pub fn add_position(&mut self, time: f32, point: Point) {
186        self.index = (self.index + 1) % HISTORY;
187        self.samples[self.index] = Some(Sample { point, time });
188    }
189
190    /// Forgets everything — a new gesture starts from nothing.
191    pub fn clear(&mut self) {
192        self.samples = [None; HISTORY];
193        self.index = 0;
194    }
195
196    /// The estimate as of `now` (seconds, same clock as the samples), or `None`
197    /// when not a single position has been recorded.
198    pub fn estimate(&self, now: f32) -> Option<VelocityEstimate> {
199        let newest = self.samples[self.index]?;
200        // The finger came to rest before letting go. Whatever it was doing 100 ms
201        // ago, it is not throwing anything now — once the pace the samples arrived at is
202        // allowed for: a release handled one frame after the last movement is not a pause.
203        if (now - newest.time) * 1000.0 - self.delivery_ms() > STOPPED_MS {
204            return Some(VelocityEstimate::STILL);
205        }
206        match self.strategy {
207            VelocityStrategy::Regression => Some(self.regress(newest)),
208            VelocityStrategy::RecentAverage(weights) => Some(self.recent_average(newest, weights)),
209        }
210    }
211
212    /// The estimated velocity as of `now`, or [`Velocity::ZERO`] if there is
213    /// nothing to go on.
214    pub fn velocity(&self, now: f32) -> Velocity {
215        self.estimate(now)
216            .map(|e| e.velocity)
217            .unwrap_or(Velocity::ZERO)
218    }
219
220    /// Fits a quadratic through the samples that belong to the current motion and
221    /// reads the velocity off its first-order term.
222    fn regress(&self, newest: Sample) -> VelocityEstimate {
223        // Ages are in **milliseconds**, negative and increasing backwards: keeping
224        // the abscissae small and centred on zero is what keeps the fit stable.
225        let mut ages = [0.0f64; HISTORY];
226        let mut xs = [0.0f64; HISTORY];
227        let mut ys = [0.0f64; HISTORY];
228        let mut count = 0;
229        let mut oldest = newest;
230        let mut previous = newest;
231        let mut index = self.index;
232        // Samples arriving a frame apart are as far apart as the frames: the horizon keeps
233        // enough of them to fit, and a gap is a pause only beyond that pace.
234        let delivery = self.delivery_ms();
235
236        while count < HISTORY {
237            let Some(sample) = self.samples[index] else {
238                break;
239            };
240            let age = (newest.time - sample.time) * 1000.0;
241            let gap = ((sample.time - previous.time) * 1000.0).abs();
242            previous = sample;
243            // Too old to be this motion, or separated from it by a pause.
244            if age > HORIZON_MS + delivery || gap - delivery > STOPPED_MS {
245                break;
246            }
247            oldest = sample;
248            ages[count] = -age as f64;
249            xs[count] = sample.point.x as f64;
250            ys[count] = sample.point.y as f64;
251            count += 1;
252            index = if index == 0 { HISTORY } else { index } - 1;
253        }
254
255        let duration = newest.time - oldest.time;
256        let offset = (
257            newest.point.x - oldest.point.x,
258            newest.point.y - oldest.point.y,
259        );
260        if count >= MIN_SAMPLES {
261            let x_fit = PolynomialFit::solve(&ages[..count], &xs[..count], 2);
262            let y_fit = PolynomialFit::solve(&ages[..count], &ys[..count], 2);
263            if let (Some(x_fit), Some(y_fit)) = (x_fit, y_fit) {
264                return VelocityEstimate {
265                    // px/ms → px/s.
266                    velocity: Velocity::new(
267                        (x_fit.coefficients[1] * 1000.0) as f32,
268                        (y_fit.coefficients[1] * 1000.0) as f32,
269                    ),
270                    confidence: (x_fit.confidence * y_fit.confidence) as f32,
271                    duration,
272                    offset,
273                };
274            }
275        }
276        // Not enough to fit, but we did see the pointer: report the travel, no
277        // speed. A caller gating on distance can still tell what happened.
278        VelocityEstimate {
279            velocity: Velocity::ZERO,
280            confidence: 1.0,
281            duration,
282            offset,
283        }
284    }
285
286    /// The pace the newest samples arrived at, in ms, capped at `MAX_DELIVERY_MS`: the median
287    /// of the last `DELIVERY_GAPS` gaps between them, or the shortest when there are fewer.
288    ///
289    /// The median, so that neither kind of odd gap moves it — one real pause among them does
290    /// not excuse itself, and two events handled back to back do not take the allowance away.
291    fn delivery_ms(&self) -> f32 {
292        let mut gaps = [0.0f32; DELIVERY_GAPS];
293        let mut count = 0;
294        let mut index = self.index;
295        while count < DELIVERY_GAPS {
296            let before = if index == 0 { HISTORY } else { index } - 1;
297            let (Some(newer), Some(older)) = (self.samples[index], self.samples[before]) else {
298                break;
299            };
300            gaps[count] = ((newer.time - older.time) * 1000.0).max(0.0);
301            count += 1;
302            index = before;
303        }
304        let gaps = &mut gaps[..count];
305        gaps.sort_by(f32::total_cmp);
306        let pace = match gaps.len() {
307            0 => 0.0,
308            DELIVERY_GAPS => gaps[DELIVERY_GAPS / 2],
309            _ => gaps[0],
310        };
311        pace.min(MAX_DELIVERY_MS)
312    }
313
314    /// The velocity between the two samples `offset` steps back from the newest.
315    fn pair_velocity(&self, offset: isize) -> Velocity {
316        let size = HISTORY as isize;
317        let end = self.samples[((self.index as isize + offset).rem_euclid(size)) as usize];
318        let start = self.samples[((self.index as isize + offset - 1).rem_euclid(size)) as usize];
319        let (Some(end), Some(start)) = (end, start) else {
320            return Velocity::ZERO;
321        };
322        let dt = end.time - start.time;
323        if dt <= 0.0 {
324            return Velocity::ZERO;
325        }
326        Velocity::new(
327            (end.point.x - start.point.x) / dt,
328            (end.point.y - start.point.y) / dt,
329        )
330    }
331
332    /// A weighted average of the last three pair velocities, oldest first.
333    fn recent_average(&self, newest: Sample, weights: [f32; 3]) -> VelocityEstimate {
334        let mut velocity = Velocity::ZERO;
335        for (i, weight) in weights.iter().enumerate() {
336            let pair = self.pair_velocity(i as isize - (weights.len() as isize - 1));
337            velocity.x += pair.x * weight;
338            velocity.y += pair.y * weight;
339        }
340        // The span is measured over the whole history, not the three pairs: it is
341        // there to tell a fling from a twitch, and needs the real travel.
342        let mut oldest = newest;
343        for i in 1..=HISTORY {
344            if let Some(sample) = self.samples[(self.index + i) % HISTORY] {
345                oldest = sample;
346                break;
347            }
348        }
349        VelocityEstimate {
350            velocity,
351            confidence: 1.0,
352            duration: newest.time - oldest.time,
353            offset: (
354                newest.point.x - oldest.point.x,
355                newest.point.y - oldest.point.y,
356            ),
357        }
358    }
359}
360
361/// A polynomial fitted to a set of weighted points, and how well it fits.
362///
363/// Only what the velocity estimate needs: degree ≤ 2, so three coefficients.
364#[derive(Clone, Copy, Debug, PartialEq)]
365pub struct PolynomialFit {
366    /// `coefficients[i]` multiplies `xⁱ`.
367    pub coefficients: [f64; 3],
368    /// The fraction of the data's variance the fit explains (an r-squared), in
369    /// `0..=1`. Higher is better.
370    pub confidence: f64,
371}
372
373/// Below this, a basis vector counts as degenerate and the system has no solution.
374const PRECISION_TOLERANCE: f64 = 1e-10;
375
376impl PolynomialFit {
377    /// Fits a polynomial of `degree` (≤ 2) to `(x, y)` by least squares, all points
378    /// weighted equally. `None` when there is not enough data, or when the points
379    /// are degenerate (all at the same instant, say).
380    ///
381    /// The method is a QR decomposition by Gram–Schmidt, then back-substitution —
382    /// the standard approach, and numerically far better behaved than forming and
383    /// inverting the normal equations. It runs in `f64` even though frus is `f32`
384    /// throughout: orthogonalising powers of the abscissae loses precision quickly,
385    /// and this is the number every fling depends on.
386    pub fn solve(x: &[f64], y: &[f64], degree: usize) -> Option<PolynomialFit> {
387        debug_assert_eq!(x.len(), y.len());
388        debug_assert!(degree <= 2);
389        let m = x.len();
390        let n = degree + 1;
391        if degree > m {
392            return None; // not enough data to fit a curve
393        }
394
395        // A: the Vandermonde matrix of the abscissae, n rows of m columns.
396        let mut a = vec![0.0f64; n * m];
397        for h in 0..m {
398            a[h] = 1.0;
399            for i in 1..n {
400                a[i * m + h] = a[(i - 1) * m + h] * x[h];
401            }
402        }
403
404        // Gram–Schmidt: Q is the orthonormal basis, R the upper triangle.
405        let mut q = vec![0.0f64; n * m];
406        let mut r = vec![0.0f64; n * n];
407        for j in 0..n {
408            for h in 0..m {
409                q[j * m + h] = a[j * m + h];
410            }
411            for i in 0..j {
412                let dot: f64 = (0..m).map(|h| q[j * m + h] * q[i * m + h]).sum();
413                for h in 0..m {
414                    q[j * m + h] -= dot * q[i * m + h];
415                }
416            }
417            let norm = (0..m)
418                .map(|h| q[j * m + h] * q[j * m + h])
419                .sum::<f64>()
420                .sqrt();
421            if norm < PRECISION_TOLERANCE {
422                return None; // linearly dependent or zero: no solution
423            }
424            let inverse = 1.0 / norm;
425            for h in 0..m {
426                q[j * m + h] *= inverse;
427            }
428            for i in 0..n {
429                r[j * n + i] = if i < j {
430                    0.0
431                } else {
432                    (0..m).map(|h| q[j * m + h] * a[i * m + h]).sum()
433                };
434            }
435        }
436
437        // R·B = Qᵀ·Y, solved bottom-right to top-left since R is upper triangular.
438        let mut coefficients = [0.0f64; 3];
439        for i in (0..n).rev() {
440            let mut c: f64 = (0..m).map(|h| q[i * m + h] * y[h]).sum();
441            for j in (i + 1..n).rev() {
442                c -= r[i * n + j] * coefficients[j];
443            }
444            coefficients[i] = c / r[i * n + i];
445        }
446
447        // The r-squared: how much of the data's spread the fit accounts for.
448        let mean = y.iter().sum::<f64>() / m as f64;
449        let mut squared_error = 0.0;
450        let mut squared_total = 0.0;
451        for h in 0..m {
452            let mut term = 1.0;
453            let mut error = y[h] - coefficients[0];
454            for c in coefficients.iter().take(n).skip(1) {
455                term *= x[h];
456                error -= term * c;
457            }
458            squared_error += error * error;
459            let deviation = y[h] - mean;
460            squared_total += deviation * deviation;
461        }
462        let confidence = if squared_total <= PRECISION_TOLERANCE {
463            1.0
464        } else {
465            1.0 - (squared_error / squared_total)
466        };
467
468        Some(PolynomialFit {
469            coefficients,
470            confidence,
471        })
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    /// Feeds a straight line at `speed` px/s, one sample every 8 ms.
480    fn steady(tracker: &mut VelocityTracker, speed: f32, samples: usize) -> f32 {
481        let step = 0.008;
482        let mut t = 0.0;
483        for i in 0..samples {
484            t = i as f32 * step;
485            tracker.add_position(t, Point::new(speed * t, 2.0 * speed * t));
486        }
487        t
488    }
489
490    /// Feeds one sample every 8 ms, moving by each of `deltas` in turn (plus the
491    /// resting sample the first delta moves away from). Returns the last sample's
492    /// time.
493    ///
494    /// Stating a gesture as its per-step deltas leaves no room for the off-by-one
495    /// that hides when positions are accumulated in the loop that adds them — and
496    /// it makes each step's speed readable: 8 px per 8 ms is 1000 px/s.
497    fn feed(tracker: &mut VelocityTracker, deltas: &[f32]) -> f32 {
498        let step = 0.008;
499        let mut t = 0.0;
500        let mut x = 0.0;
501        tracker.add_position(t, Point::new(x, 0.0));
502        for delta in deltas {
503            t += step;
504            x += delta;
505            tracker.add_position(t, Point::new(x, 0.0));
506        }
507        t
508    }
509
510    /// A throw at 1000 px/s, then `slow` steps at 125 px/s — the finger easing off
511    /// as it lifts. The gesture that an exponential average reads as a nudge.
512    fn throw_then_lift(tracker: &mut VelocityTracker, slow: usize) -> f32 {
513        let mut deltas = vec![8.0f32; 7];
514        deltas.extend(std::iter::repeat_n(1.0f32, slow));
515        feed(tracker, &deltas)
516    }
517
518    #[test]
519    fn a_steady_drag_is_read_exactly() {
520        let mut tracker = VelocityTracker::new(VelocityStrategy::Regression);
521        let now = steady(&mut tracker, 600.0, 10);
522        let estimate = tracker.estimate(now).expect("samples were added");
523        assert!(
524            (estimate.velocity.x - 600.0).abs() < 1.0,
525            "x = {}",
526            estimate.velocity.x
527        );
528        assert!(
529            (estimate.velocity.y - 1200.0).abs() < 2.0,
530            "y = {}",
531            estimate.velocity.y
532        );
533        assert!(estimate.confidence > 0.99, "a line fits a line");
534        assert!(estimate.offset.0 > 0.0 && estimate.duration > 0.0);
535    }
536
537    #[test]
538    fn the_regression_survives_a_last_instant_slowdown() {
539        let mut tracker = VelocityTracker::new(VelocityStrategy::Regression);
540        let now = throw_then_lift(&mut tracker, 2);
541        let estimate = tracker.estimate(now).expect("samples");
542        // The old exponential average read ~190 px/s here (½·125 + ½·250 after two
543        // slow steps) and the content barely moved. The regression sees the throw.
544        assert!(
545            estimate.velocity.x > 400.0,
546            "the throw should survive the lift-off, got {}",
547            estimate.velocity.x
548        );
549    }
550
551    #[test]
552    fn a_finger_that_stopped_before_lifting_flings_nothing() {
553        let mut tracker = VelocityTracker::new(VelocityStrategy::Regression);
554        let last = steady(&mut tracker, 900.0, 8);
555        // Held still for 100 ms, well past the 40 ms threshold, then released.
556        let estimate = tracker.estimate(last + 0.1).expect("samples");
557        assert_eq!(estimate.velocity, Velocity::ZERO);
558        assert_eq!(estimate, VelocityEstimate::STILL);
559    }
560
561    /// Feeds a straight line at `speed` px/s along x, one sample every `step` seconds.
562    /// Returns the last sample's time.
563    fn paced(tracker: &mut VelocityTracker, speed: f32, step: f32, samples: usize) -> f32 {
564        let mut t = 0.0;
565        for i in 0..samples {
566            t = i as f32 * step;
567            tracker.add_position(t, Point::new(speed * t, 0.0));
568        }
569        t
570    }
571
572    /// **A throw handed over a frame at a time is still a throw.** Seen on a phone: with the
573    /// application rendering at ~27 frames a second, moves arrived 37 ms apart and the release
574    /// 41 ms after the last one — past the 40 ms stop, so a flick read as a finger at rest.
575    ///
576    /// Slower still, frames 45 ms apart put every gap between samples past the stop too, and
577    /// at 51 ms the 100 ms horizon alone would leave two samples, which is too few to fit.
578    #[test]
579    fn a_throw_delivered_a_frame_at_a_time_is_still_a_throw() {
580        for strategy in [
581            VelocityStrategy::Regression,
582            VelocityStrategy::RecentAverage(BOUNCING_FLING_WEIGHTS),
583        ] {
584            for step in [0.037, 0.045, 0.051] {
585                let mut tracker = VelocityTracker::new(strategy);
586                let last = paced(&mut tracker, 2000.0, step, 6);
587                let estimate = tracker.estimate(last + 0.041).expect("samples");
588                assert!(
589                    (estimate.velocity.x - 2000.0).abs() < 40.0,
590                    "{strategy:?} every {step} s: got {}",
591                    estimate.velocity.x
592                );
593            }
594        }
595    }
596
597    /// A throw only three samples long, at the same slow pace: two gaps are all there is to
598    /// read the pace from, and they are enough.
599    #[test]
600    fn a_short_throw_at_slow_frames_is_still_a_throw() {
601        let mut tracker = VelocityTracker::new(VelocityStrategy::Regression);
602        let last = paced(&mut tracker, 2000.0, 0.037, 3);
603        let estimate = tracker.estimate(last + 0.041).expect("samples");
604        assert!(
605            (estimate.velocity.x - 2000.0).abs() < 40.0,
606            "got {}",
607            estimate.velocity.x
608        );
609    }
610
611    /// **And a finger that stopped still stopped**, however slowly the frames came: the
612    /// allowance is one frame's worth, not a licence.
613    #[test]
614    fn slow_frames_do_not_hide_a_finger_that_stopped() {
615        let mut tracker = VelocityTracker::new(VelocityStrategy::Regression);
616        let last = paced(&mut tracker, 2000.0, 0.037, 6);
617        let estimate = tracker.estimate(last + 0.12).expect("samples");
618        assert_eq!(estimate, VelocityEstimate::STILL);
619        // Nor does an application slower than any frame buy an unlimited allowance.
620        let mut crawling = VelocityTracker::new(VelocityStrategy::Regression);
621        let last = paced(&mut crawling, 2000.0, 0.2, 6);
622        assert_eq!(
623            crawling.estimate(last + 0.1).expect("samples"),
624            VelocityEstimate::STILL
625        );
626    }
627
628    /// Two events handled back to back do not take the allowance away: the pace is the
629    /// median of the last gaps, not the shortest.
630    #[test]
631    fn one_short_gap_does_not_change_the_pace() {
632        let mut tracker = VelocityTracker::new(VelocityStrategy::Regression);
633        for (t, x) in [
634            (0.0, 0.0),
635            (0.037, 74.0),
636            (0.074, 148.0),
637            (0.111, 222.0),
638            (0.116, 232.0),
639            (0.153, 306.0),
640        ] {
641            tracker.add_position(t, Point::new(x, 0.0));
642        }
643        // 50 ms after the last move: a frame's lateness at a 37 ms pace, and past the stop by
644        // the 5 ms gap's count.
645        let estimate = tracker.estimate(0.153 + 0.05).expect("samples");
646        assert!(estimate.velocity.x > 1500.0, "got {}", estimate.velocity.x);
647    }
648
649    /// And one real pause does not excuse itself. A finger sampled every 8 ms throws, stops for
650    /// 60 ms, reports one last position where it stopped and lifts: the pause is among the
651    /// newest gaps, and taking the pace from it would let it excuse the stop it is.
652    #[test]
653    fn a_pause_just_before_the_release_excuses_nothing() {
654        let mut tracker = VelocityTracker::new(VelocityStrategy::Regression);
655        let mut t = 0.0;
656        for i in 0..6 {
657            t = i as f32 * 0.008;
658            tracker.add_position(t, Point::new(i as f32 * 16.0, 0.0));
659        }
660        let stopped = t + 0.06;
661        tracker.add_position(stopped, Point::new(80.0, 0.0));
662        let estimate = tracker.estimate(stopped + 0.005).expect("samples");
663        assert!(
664            estimate.velocity.x < 400.0,
665            "the throw before the pause leaked across it: {}",
666            estimate.velocity.x
667        );
668        // And the motion is only what came after the pause: none of the throw's travel.
669        assert!(
670            estimate.offset.0.abs() < 1.0 && estimate.duration < 0.01,
671            "the history ran back across the pause: {estimate:?}"
672        );
673    }
674
675    #[test]
676    fn samples_older_than_the_horizon_are_not_part_of_the_motion() {
677        let mut tracker = VelocityTracker::new(VelocityStrategy::Regression);
678        // A first gesture, then a long pause, then a slow one. Only the slow one
679        // may contribute: the pause exceeds the stop threshold.
680        for i in 0..6 {
681            tracker.add_position(i as f32 * 0.008, Point::new(i as f32 * 16.0, 0.0));
682        }
683        let base = 1.0;
684        for i in 0..6 {
685            tracker.add_position(
686                base + i as f32 * 0.008,
687                Point::new(1000.0 + i as f32 * 1.6, 0.0),
688            );
689        }
690        let now = base + 5.0 * 0.008;
691        let estimate = tracker.estimate(now).expect("samples");
692        assert!(
693            estimate.velocity.x < 400.0,
694            "the old fast gesture leaked in: {}",
695            estimate.velocity.x
696        );
697        assert!(estimate.duration < 0.1, "span = {}", estimate.duration);
698    }
699
700    #[test]
701    fn too_few_samples_give_travel_but_no_speed() {
702        let mut tracker = VelocityTracker::new(VelocityStrategy::Regression);
703        tracker.add_position(0.0, Point::new(0.0, 0.0));
704        tracker.add_position(0.008, Point::new(10.0, 0.0));
705        let estimate = tracker.estimate(0.008).expect("samples");
706        assert_eq!(
707            estimate.velocity,
708            Velocity::ZERO,
709            "2 samples cannot regress"
710        );
711        assert_eq!(estimate.offset.0, 10.0, "but the travel is still known");
712    }
713
714    #[test]
715    fn an_empty_tracker_has_nothing_to_say() {
716        let tracker = VelocityTracker::new(VelocityStrategy::Regression);
717        assert!(tracker.estimate(0.0).is_none());
718        assert_eq!(tracker.velocity(0.0), Velocity::ZERO);
719    }
720
721    #[test]
722    fn clearing_starts_the_gesture_over() {
723        let mut tracker = VelocityTracker::new(VelocityStrategy::Regression);
724        let now = steady(&mut tracker, 600.0, 10);
725        tracker.clear();
726        assert!(tracker.estimate(now).is_none());
727    }
728
729    #[test]
730    fn the_recent_average_leans_on_the_older_samples() {
731        // Same lift-off gesture. The weighted average is meant to read the throw,
732        // not the lift, because most of the weight sits on the older pairs.
733        let mut tracker =
734            VelocityTracker::new(VelocityStrategy::RecentAverage(BOUNCING_FLING_WEIGHTS));
735        let now = throw_then_lift(&mut tracker, 1);
736        let estimate = tracker.estimate(now).expect("samples");
737        // The newest pair is the lift and carries only 0.05:
738        // 0.6·1000 + 0.35·1000 + 0.05·125 ≈ 956.
739        assert!(
740            (estimate.velocity.x - 956.0).abs() < 20.0,
741            "got {}",
742            estimate.velocity.x
743        );
744    }
745
746    #[test]
747    fn the_recent_average_needs_no_history_to_answer() {
748        let mut tracker =
749            VelocityTracker::new(VelocityStrategy::RecentAverage(DESKTOP_FLING_WEIGHTS));
750        tracker.add_position(0.0, Point::new(0.0, 0.0));
751        tracker.add_position(0.008, Point::new(8.0, 0.0));
752        let estimate = tracker.estimate(0.008).expect("samples");
753        // Only the newest pair exists; it carries the 0.2 weight. 1000·0.2 = 200.
754        assert!(
755            (estimate.velocity.x - 200.0).abs() < 1.0,
756            "got {}",
757            estimate.velocity.x
758        );
759    }
760
761    #[test]
762    fn the_ring_buffer_wraps_without_losing_the_motion() {
763        let mut tracker = VelocityTracker::new(VelocityStrategy::Regression);
764        // Far more samples than the history holds, all at the same speed.
765        let now = steady(&mut tracker, 500.0, HISTORY * 3);
766        let estimate = tracker.estimate(now).expect("samples");
767        assert!(
768            (estimate.velocity.x - 500.0).abs() < 2.0,
769            "got {}",
770            estimate.velocity.x
771        );
772    }
773
774    #[test]
775    fn a_quadratic_is_recovered_exactly() {
776        // y = 3 + 2x + 4x², sampled: the fit must find those coefficients back.
777        let x: Vec<f64> = (0..8).map(|i| i as f64 * 0.5).collect();
778        let y: Vec<f64> = x.iter().map(|x| 3.0 + 2.0 * x + 4.0 * x * x).collect();
779        let fit = PolynomialFit::solve(&x, &y, 2).expect("a well-posed system");
780        assert!((fit.coefficients[0] - 3.0).abs() < 1e-6, "{:?}", fit);
781        assert!((fit.coefficients[1] - 2.0).abs() < 1e-6, "{:?}", fit);
782        assert!((fit.coefficients[2] - 4.0).abs() < 1e-6, "{:?}", fit);
783        assert!((fit.confidence - 1.0).abs() < 1e-9);
784    }
785
786    #[test]
787    fn a_degenerate_system_has_no_fit() {
788        // Every point at the same abscissa: the basis vectors collapse.
789        let x = [1.0, 1.0, 1.0, 1.0];
790        let y = [0.0, 1.0, 2.0, 3.0];
791        assert!(PolynomialFit::solve(&x, &y, 2).is_none());
792    }
793
794    #[test]
795    fn confidence_falls_when_the_points_do_not_follow_the_curve() {
796        let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
797        let clean: Vec<f64> = x.iter().map(|x| 2.0 * x).collect();
798        let noisy: Vec<f64> = x
799            .iter()
800            .enumerate()
801            .map(|(i, x)| 2.0 * x + if i % 2 == 0 { 40.0 } else { -40.0 })
802            .collect();
803        let a = PolynomialFit::solve(&x, &clean, 2).unwrap();
804        let b = PolynomialFit::solve(&x, &noisy, 2).unwrap();
805        assert!(
806            a.confidence > b.confidence,
807            "{} vs {}",
808            a.confidence,
809            b.confidence
810        );
811        assert!(b.confidence < 0.9);
812    }
813
814    #[test]
815    fn a_velocity_can_be_brought_into_range() {
816        let v = Velocity::new(3000.0, 4000.0); // magnitude 5000
817        assert!((v.magnitude() - 5000.0).abs() < 1e-3);
818        let capped = v.clamp_magnitude(0.0, 1000.0);
819        assert!((capped.magnitude() - 1000.0).abs() < 1e-2);
820        // Direction preserved.
821        assert!((capped.x / capped.y - v.x / v.y).abs() < 1e-4);
822        // Inside the range, untouched.
823        assert_eq!(v.clamp_magnitude(0.0, 8000.0), v);
824        // Zero has no direction to preserve.
825        assert_eq!(Velocity::ZERO.clamp_magnitude(50.0, 100.0), Velocity::ZERO);
826    }
827}