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