Skip to main content

agg_gui/
touch_state.rs

1//! Multi-touch gesture recogniser.
2//!
3//! The platform shells (web JS, native winit) forward raw touch events
4//! to [`App::on_touch_start/move/end/cancel`].  [`TouchState`] maintains
5//! the set of active touches and, once two or more fingers are down,
6//! aggregates them each frame into a [`MultiTouchInfo`] describing zoom,
7//! rotation, pan, and average pressure relative to the previous frame.
8//!
9//! Widgets that want to react to gestures read the current frame's
10//! aggregate via [`current_multi_touch`], a thread-local written by
11//! `App::paint` at the start of each frame.  Single-finger touches are
12//! replayed through the regular mouse pipeline by the core-owned
13//! [`crate::touch_emulation::TouchMouseEmu`], so existing widgets keep
14//! working with no changes.
15//!
16//! The API shape deliberately mirrors egui's (`zoom_delta`,
17//! `rotation_delta`, `translation_delta`, `num_touches`, `center_pos`)
18//! so ports from egui code read cleanly.
19
20use std::cell::RefCell;
21use std::collections::BTreeMap;
22
23use crate::geometry::Point;
24
25// ---------------------------------------------------------------------------
26// Identifier newtypes
27// ---------------------------------------------------------------------------
28
29/// Stable per-device identifier.  Different physical input surfaces
30/// (e.g. a laptop's built-in touchscreen and a connected tablet) hash
31/// to different values.  The web shell always uses `0` (the browser
32/// doesn't expose multiple touch devices to pages); winit passes
33/// through its device id.
34#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
35pub struct TouchDeviceId(pub u64);
36
37/// Per-finger identifier, stable from Start through End/Cancel.  Re-
38/// used after lift — browsers and winit both guarantee identifiers
39/// are unique only for the lifetime of the touch.
40#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
41pub struct TouchId(pub u64);
42
43/// Which phase of the gesture this touch event represents.
44#[derive(Copy, Clone, Debug, PartialEq, Eq)]
45pub enum TouchPhase {
46    /// Finger first made contact.
47    Start,
48    /// Finger moved while in contact.
49    Move,
50    /// Finger lifted normally.
51    End,
52    /// Touch was cancelled by the platform (phone call, gesture
53    /// hand-off to browser, etc.).
54    Cancel,
55}
56
57// ---------------------------------------------------------------------------
58// MultiTouchInfo — the per-frame aggregate
59// ---------------------------------------------------------------------------
60
61/// Gesture aggregate for the current frame, produced when two or more
62/// fingers are on the same device.  All deltas are relative to the
63/// previous frame's positions — the widget just accumulates them into
64/// its own angle / scale / translation state (see `LionView` for the
65/// canonical consumer).
66#[derive(Copy, Clone, Debug)]
67pub struct MultiTouchInfo {
68    /// Device that owns these touches.  Useful only when the host
69    /// actually distinguishes multiple touchscreens; most apps ignore.
70    pub device_id: TouchDeviceId,
71    /// Number of fingers currently down (always ≥ 2 — a single-finger
72    /// frame produces `None` instead of a [`MultiTouchInfo`]).
73    pub num_touches: usize,
74    /// Multiplicative zoom factor since the last frame.  `1.0` means
75    /// "no pinch this frame"; `1.1` means the fingers spread by 10 %.
76    pub zoom_delta: f32,
77    /// Rotation in radians since the last frame.  Positive = CCW in
78    /// widget-local (Y-up) space, i.e. visually counter-clockwise on
79    /// screen.
80    pub rotation_delta: f32,
81    /// Translation of the centroid since the last frame, in widget-
82    /// local pixels.  Widgets that want the gesture to orbit the pinch
83    /// centre should combine this with `zoom_delta` / `rotation_delta`.
84    pub translation_delta: Point,
85    /// Average `force` across active touches, or `0.0` when the
86    /// platform doesn't report pressure.
87    pub force: f32,
88    /// Centroid of the active touches in app-local coordinates this
89    /// frame.  Widgets that want to hit-test "is the gesture over me?"
90    /// compare this against their own absolute bounds.
91    pub center_pos: Point,
92}
93
94// ---------------------------------------------------------------------------
95// TouchState — per-frame gesture recogniser
96// ---------------------------------------------------------------------------
97
98/// One finger's tracked position, updated every Move event.
99#[derive(Copy, Clone, Debug)]
100struct ActiveTouch {
101    /// Latest position reported by the platform.
102    pos: Point,
103    /// Position at the last `update_gesture` call — used as the basis
104    /// for the next delta.
105    prev_pos: Point,
106    /// Latest force (0.0 when unsupported).
107    force: f32,
108}
109
110/// Tracks every active touch across every known device.  Lives on
111/// `App`; widgets never see this directly.
112#[derive(Default)]
113pub struct TouchState {
114    active: BTreeMap<(TouchDeviceId, TouchId), ActiveTouch>,
115    /// Result of the most recent `update_gesture` call — `None` while
116    /// fewer than two fingers are down on any one device.  Published
117    /// to the thread-local so widgets can read it during paint.
118    last: Option<MultiTouchInfo>,
119    /// Set by Start / End / Cancel so `update_gesture` can reseed
120    /// `prev_pos` on the frame after a finger count change — without
121    /// this, newly-arrived fingers contribute a spurious delta equal
122    /// to their full spread on their first move.
123    topology_changed: bool,
124}
125
126/// Fold an angle (radians) into the canonical `[-pi, pi]` range.  Used to
127/// keep each finger's per-frame rotation step honest across the atan2 ±pi
128/// seam before the steps are averaged.
129fn wrap_angle(a: f32) -> f32 {
130    use std::f32::consts::PI;
131    let mut a = a;
132    while a > PI {
133        a -= 2.0 * PI;
134    }
135    while a < -PI {
136        a += 2.0 * PI;
137    }
138    a
139}
140
141impl TouchState {
142    pub fn new() -> Self {
143        Self::default()
144    }
145
146    pub fn on_start(&mut self, device: TouchDeviceId, id: TouchId, pos: Point, force: Option<f32>) {
147        self.active.insert(
148            (device, id),
149            ActiveTouch {
150                pos,
151                prev_pos: pos,
152                force: force.unwrap_or(0.0),
153            },
154        );
155        self.topology_changed = true;
156    }
157
158    pub fn on_move(&mut self, device: TouchDeviceId, id: TouchId, pos: Point, force: Option<f32>) {
159        if let Some(t) = self.active.get_mut(&(device, id)) {
160            t.pos = pos;
161            if let Some(f) = force {
162                t.force = f;
163            }
164        }
165    }
166
167    pub fn on_end_or_cancel(&mut self, device: TouchDeviceId, id: TouchId) {
168        if self.active.remove(&(device, id)).is_some() {
169            self.topology_changed = true;
170        }
171        if self.active.len() < 2 {
172            self.last = None;
173        }
174    }
175
176    /// Every active finger's current position, for the per-finger
177    /// registry ([`crate::touch_points`]) that virtual-gamepad widgets
178    /// poll. Positions are app-local (the space touch events arrive
179    /// in after the shell's screen→world conversion).
180    pub fn active_points(&self) -> Vec<crate::touch_points::TouchPoint> {
181        self.active
182            .iter()
183            .map(|((_, id), t)| crate::touch_points::TouchPoint {
184                id: id.0,
185                pos: t.pos,
186            })
187            .collect()
188    }
189
190    /// Recompute the per-frame aggregate.  Called by `App` right before
191    /// the multi-touch value is published, so every `paint` / `on_event`
192    /// in the same frame sees consistent deltas.
193    pub fn update_gesture(&mut self) {
194        // Only the most-populated device contributes — the common case
195        // is a single touchscreen, and cross-device gestures aren't a
196        // useful abstraction.
197        let device = self.active.keys().next().map(|(d, _)| *d);
198        let Some(device) = device else {
199            self.last = None;
200            return;
201        };
202        let touches: Vec<ActiveTouch> = self
203            .active
204            .iter()
205            .filter(|((d, _), _)| *d == device)
206            .map(|(_, t)| *t)
207            .collect();
208        if touches.len() < 2 {
209            self.last = None;
210            return;
211        }
212
213        // Centroid (previous vs current) drives the translation delta.
214        let n = touches.len() as f64;
215        let (mut cx, mut cy) = (0.0, 0.0);
216        let (mut pcx, mut pcy) = (0.0, 0.0);
217        for t in &touches {
218            cx += t.pos.x;
219            cy += t.pos.y;
220            pcx += t.prev_pos.x;
221            pcy += t.prev_pos.y;
222        }
223        cx /= n;
224        cy /= n;
225        pcx /= n;
226        pcy /= n;
227
228        // Average pinch + rotation across pairs.  Using every
229        // (touch, centroid) ray means the signal scales sensibly with
230        // finger count; egui does the same.
231        let mut zoom_sum = 0.0_f32;
232        let mut rotation_sum = 0.0_f32;
233        let mut force_sum = 0.0_f32;
234        let mut zoom_count = 0;
235        for t in &touches {
236            force_sum += t.force;
237            let dx = (t.pos.x - cx) as f32;
238            let dy = (t.pos.y - cy) as f32;
239            let pdx = (t.prev_pos.x - pcx) as f32;
240            let pdy = (t.prev_pos.y - pcy) as f32;
241            let r = (dx * dx + dy * dy).sqrt();
242            let pr = (pdx * pdx + pdy * pdy).sqrt();
243            if pr > 1.0 && r > 1.0 {
244                zoom_sum += r / pr;
245                // Normalise EACH finger's angular step into `[-pi, pi]`
246                // before summing.  A real two-finger twist sweeps every
247                // finger's polar angle around the centroid, so on every
248                // half-turn one finger crosses the atan2 ±pi seam: its raw
249                // `atan2(dy,dx) - atan2(pdy,pdx)` jumps by ~±2pi (a true
250                // +2° step reads as -358°).  Averaging that with the other
251                // finger's correct step yields ~-178°, and post-average
252                // normalisation cannot recover the intended small delta.
253                // Wrapping per finger keeps each contribution honest.
254                rotation_sum += wrap_angle(dy.atan2(dx) - pdy.atan2(pdx));
255                zoom_count += 1;
256            }
257        }
258        // Skip producing a frame-delta when topology just changed —
259        // the jump from "no prev_pos" to "current pos" would otherwise
260        // read as a huge one-frame zoom.  We still emit an info entry
261        // so widgets can react to finger count; just with zeroed
262        // deltas.
263        let (zoom_delta, rotation_delta) = if self.topology_changed || zoom_count == 0 {
264            (1.0, 0.0)
265        } else {
266            // Per-finger deltas are already wrapped, so their average is
267            // well-behaved; this final wrap is a cheap belt-and-braces
268            // clamp for the pathological many-finger case.
269            let rot = wrap_angle(rotation_sum / zoom_count as f32);
270            (zoom_sum / zoom_count as f32, rot)
271        };
272
273        let translation_delta = if self.topology_changed {
274            Point::new(0.0, 0.0)
275        } else {
276            Point::new(cx - pcx, cy - pcy)
277        };
278
279        self.last = Some(MultiTouchInfo {
280            device_id: device,
281            num_touches: touches.len(),
282            zoom_delta,
283            rotation_delta,
284            translation_delta,
285            force: force_sum / n as f32,
286            center_pos: Point::new(cx, cy),
287        });
288
289        // Latch current positions as the new baseline for the next
290        // frame, then clear the topology flag.
291        for t in self.active.values_mut() {
292            t.prev_pos = t.pos;
293        }
294        self.topology_changed = false;
295    }
296
297    pub fn current(&self) -> Option<MultiTouchInfo> {
298        self.last
299    }
300
301    /// Total number of fingers currently down (across all devices).
302    /// Useful as a lightweight "are we in a gesture?" probe when a
303    /// widget doesn't care about the per-delta aggregate.
304    pub fn active_count(&self) -> usize {
305        self.active.len()
306    }
307}
308
309// ---------------------------------------------------------------------------
310// Thread-local publish / read
311// ---------------------------------------------------------------------------
312
313thread_local! {
314    static CURRENT: RefCell<Option<MultiTouchInfo>> = RefCell::new(None);
315    /// Wall-clock time of the most recent touch lifecycle event
316    /// (`Start` / `Move` / `End` / `Cancel`).  Set by `App`'s touch
317    /// entry points.  Mouse events the touch shell synthesises arrive
318    /// within milliseconds of a touch event — widgets that need to
319    /// distinguish a touch tap from a desktop click read
320    /// [`last_touch_event_age`] and treat anything under a few tens
321    /// of milliseconds as touch-synthesised.
322    static LAST_TOUCH_EVENT_AT: std::cell::Cell<Option<web_time::Instant>> =
323        const { std::cell::Cell::new(None) };
324    /// Sticky "a real touch has happened this session" latch.  Unlike
325    /// [`LAST_TOUCH_EVENT_AT`] (which ages out and only distinguishes
326    /// touch-synthesised mouse events from desktop clicks), this NEVER
327    /// clears on its own: once any touch lifecycle event fires, the
328    /// process is treated as touch-driven for UI *sizing* policy (see
329    /// [`crate::input_profile::touch_ui_active`]).  Latching, rather than
330    /// aging out, means a menu that grew to a finger-friendly size the
331    /// instant the user first touched the screen doesn't shrink back to
332    /// desktop dimensions a few frames later.  Cleared only by the test
333    /// hook [`clear_last_touch_event_for_testing`].
334    static TOUCH_SEEN_THIS_SESSION: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
335}
336
337/// Publish this frame's multi-touch aggregate.  Called by
338/// `App::paint` right before painting begins.
339pub fn set_current(info: Option<MultiTouchInfo>) {
340    CURRENT.with(|c| *c.borrow_mut() = info);
341}
342
343/// Fetch the current frame's multi-touch aggregate.  Returns `None`
344/// when fewer than two fingers are down on any device, so a widget
345/// writes: `if let Some(mt) = current_multi_touch() { … }`.
346pub fn current_multi_touch() -> Option<MultiTouchInfo> {
347    CURRENT.with(|c| *c.borrow())
348}
349
350/// Record that a touch lifecycle event just fired.  Called from
351/// `App::on_touch_start/move/end/cancel`.
352pub(crate) fn note_touch_event() {
353    LAST_TOUCH_EVENT_AT.with(|c| c.set(Some(web_time::Instant::now())));
354    TOUCH_SEEN_THIS_SESSION.with(|c| c.set(true));
355}
356
357/// Whether any real touch lifecycle event has fired this session.  Sticky
358/// once set (it does not age out), so it can serve as the runtime-fallback
359/// half of the menu sizing-policy signal in
360/// [`crate::input_profile::touch_ui_active`]: a phone whose shell forgot to
361/// call `set_input_profile` still grows its menus to finger size the moment
362/// the user first touches the screen.
363pub fn touch_seen_this_session() -> bool {
364    TOUCH_SEEN_THIS_SESSION.with(|c| c.get())
365}
366
367/// Time elapsed since the most recent touch lifecycle event, or
368/// `None` if no touch event has ever fired.  Mouse events
369/// synthesised from a touchstart / touchend by the web shell arrive
370/// within a millisecond of the touch event — widgets needing to
371/// tell touch-synthesised mouse events apart from real desktop
372/// clicks check this against a small threshold.
373pub fn last_touch_event_age() -> Option<std::time::Duration> {
374    LAST_TOUCH_EVENT_AT.with(|c| c.get()).map(|t| t.elapsed())
375}
376
377/// Forget any prior touch event so the next mouse event reads as
378/// "from desktop" until [`note_touch_event`] runs again.  Tests use
379/// this to isolate desktop-mouse scenarios from a sibling test that
380/// just simulated a touch tap.
381#[doc(hidden)]
382pub fn clear_last_touch_event_for_testing() {
383    LAST_TOUCH_EVENT_AT.with(|c| c.set(None));
384    TOUCH_SEEN_THIS_SESSION.with(|c| c.set(false));
385}
386
387// ---------------------------------------------------------------------------
388// Tests
389// ---------------------------------------------------------------------------
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    const DEV: TouchDeviceId = TouchDeviceId(0);
396
397    /// Two fingers land, then `update_gesture` runs once.  This is the
398    /// baseline (topology-changed) frame: it should emit a `MultiTouchInfo`
399    /// with num_touches = 2 but zeroed deltas so newly-arrived fingers
400    /// never contribute a spurious one-frame jump.
401    #[test]
402    fn two_fingers_land_emits_zeroed_baseline_frame() {
403        let mut ts = TouchState::new();
404        ts.on_start(DEV, TouchId(0), Point::new(100.0, 100.0), None);
405        ts.on_start(DEV, TouchId(1), Point::new(200.0, 100.0), None);
406        ts.update_gesture();
407
408        let info = ts.current().expect("two fingers should produce a gesture");
409        assert_eq!(info.num_touches, 2);
410        assert_eq!(info.zoom_delta, 1.0, "topology frame must not zoom");
411        assert_eq!(info.rotation_delta, 0.0, "topology frame must not rotate");
412        assert_eq!(info.translation_delta.x, 0.0);
413        assert_eq!(info.translation_delta.y, 0.0);
414    }
415
416    /// Pinch-out: after the baseline frame, both fingers move apart
417    /// symmetrically (spread doubles), so `zoom_delta` should equal the
418    /// spread ratio and rotation should stay ~0.
419    #[test]
420    fn pinch_out_reports_spread_ratio() {
421        let mut ts = TouchState::new();
422        // Baseline: fingers 100px apart, centroid at (150,100).
423        ts.on_start(DEV, TouchId(0), Point::new(100.0, 100.0), None);
424        ts.on_start(DEV, TouchId(1), Point::new(200.0, 100.0), None);
425        ts.update_gesture(); // latches baseline, zeroed deltas
426
427        // Spread to 200px apart around the same centroid.
428        ts.on_move(DEV, TouchId(0), Point::new(50.0, 100.0), None);
429        ts.on_move(DEV, TouchId(1), Point::new(250.0, 100.0), None);
430        ts.update_gesture();
431
432        let info = ts.current().expect("gesture present");
433        // Each finger's distance from the centroid went 50 -> 100, so the
434        // per-finger ratio (and hence the average) is exactly 2.0.
435        assert!(
436            (info.zoom_delta - 2.0).abs() < 1e-3,
437            "zoom_delta = {} (expected ~2.0)",
438            info.zoom_delta
439        );
440        assert!(
441            info.rotation_delta.abs() < 1e-3,
442            "pure pinch must not rotate, got {}",
443            info.rotation_delta
444        );
445    }
446
447    /// Pure rotation: both fingers rotate 30° CCW (Y-up) around a fixed
448    /// centroid at constant radius.  The code computes
449    /// `atan2(dy,dx) - atan2(pdy,pdx)` on the raw coordinates, so a CCW
450    /// step in Y-up space yields a POSITIVE `rotation_delta` — matching the
451    /// documented "positive = CCW in Y-up" convention.  Zoom must stay ~1.
452    #[test]
453    fn pure_rotation_reports_signed_angle_ccw_positive() {
454        let mut ts = TouchState::new();
455        // Fingers on a vertical line through centroid (100,100), r = 100.
456        // Vertical orientation keeps both fingers away from the atan2 ±pi
457        // seam so no per-finger wrap corrupts the average.
458        ts.on_start(DEV, TouchId(0), Point::new(100.0, 200.0), None); // angle +90°
459        ts.on_start(DEV, TouchId(1), Point::new(100.0, 0.0), None); //  angle -90°
460        ts.update_gesture(); // baseline
461
462        // Rotate both +30° CCW (Y-up) about the centroid.
463        //   finger 0: 90° -> 120°  => (50, 186.60254)
464        //   finger 1: -90° -> -60° => (150, 13.39746)
465        ts.on_move(DEV, TouchId(0), Point::new(50.0, 186.602_54), None);
466        ts.on_move(DEV, TouchId(1), Point::new(150.0, 13.397_46), None);
467        ts.update_gesture();
468
469        let info = ts.current().expect("gesture present");
470        let expected = 30.0_f32.to_radians();
471        assert!(
472            (info.rotation_delta - expected).abs() < 1e-3,
473            "rotation_delta = {} (expected ~+{} rad, +30° CCW)",
474            info.rotation_delta,
475            expected
476        );
477        assert!(
478            (info.zoom_delta - 1.0).abs() < 1e-3,
479            "pure rotation must not zoom, got {}",
480            info.zoom_delta
481        );
482    }
483
484    /// Pure translation: both fingers shift by the same offset, so the
485    /// centroid moves by exactly that offset while the per-finger geometry
486    /// is unchanged (zoom ~1, rotation ~0).
487    #[test]
488    fn pure_translation_reports_centroid_offset() {
489        let mut ts = TouchState::new();
490        ts.on_start(DEV, TouchId(0), Point::new(100.0, 100.0), None);
491        ts.on_start(DEV, TouchId(1), Point::new(200.0, 100.0), None);
492        ts.update_gesture(); // baseline
493
494        // Shift both by (+10, +20).
495        ts.on_move(DEV, TouchId(0), Point::new(110.0, 120.0), None);
496        ts.on_move(DEV, TouchId(1), Point::new(210.0, 120.0), None);
497        ts.update_gesture();
498
499        let info = ts.current().expect("gesture present");
500        assert!(
501            (info.translation_delta.x - 10.0).abs() < 1e-3
502                && (info.translation_delta.y - 20.0).abs() < 1e-3,
503            "translation_delta = ({},{}) (expected ~(10,20))",
504            info.translation_delta.x,
505            info.translation_delta.y
506        );
507        assert!(
508            (info.zoom_delta - 1.0).abs() < 1e-3,
509            "pure translation must not zoom, got {}",
510            info.zoom_delta
511        );
512        assert!(
513            info.rotation_delta.abs() < 1e-3,
514            "pure translation must not rotate, got {}",
515            info.rotation_delta
516        );
517    }
518
519    /// Lifting one finger drops below the two-finger minimum: `current()`
520    /// must become `None` and `active_count` must reflect the remaining
521    /// finger.
522    #[test]
523    fn finger_lift_clears_gesture() {
524        let mut ts = TouchState::new();
525        ts.on_start(DEV, TouchId(0), Point::new(100.0, 100.0), None);
526        ts.on_start(DEV, TouchId(1), Point::new(200.0, 100.0), None);
527        ts.update_gesture();
528        assert!(ts.current().is_some(), "two fingers -> gesture");
529        assert_eq!(ts.active_count(), 2);
530
531        ts.on_end_or_cancel(DEV, TouchId(1));
532        assert!(
533            ts.current().is_none(),
534            "one finger left -> no multi-touch gesture"
535        );
536        assert_eq!(ts.active_count(), 1);
537    }
538
539    /// A third finger arriving mid-gesture flags a topology change, so the
540    /// very next `update_gesture` must emit zeroed deltas (no spurious jump)
541    /// even though the centroid/geometry shifted as the finger landed.
542    #[test]
543    fn third_finger_join_resets_deltas() {
544        let mut ts = TouchState::new();
545        ts.on_start(DEV, TouchId(0), Point::new(100.0, 100.0), None);
546        ts.on_start(DEV, TouchId(1), Point::new(200.0, 100.0), None);
547        ts.update_gesture(); // baseline
548
549        // Establish a real (non-zero) gesture first.
550        ts.on_move(DEV, TouchId(0), Point::new(50.0, 100.0), None);
551        ts.on_move(DEV, TouchId(1), Point::new(250.0, 100.0), None);
552        ts.update_gesture();
553        let mid = ts.current().expect("gesture present");
554        assert!(mid.zoom_delta > 1.5, "sanity: mid-gesture was a pinch-out");
555
556        // Third finger lands off-centre; next frame must be zeroed.
557        ts.on_start(DEV, TouchId(2), Point::new(150.0, 300.0), None);
558        ts.update_gesture();
559        let info = ts.current().expect("three fingers -> gesture");
560        assert_eq!(info.num_touches, 3);
561        assert_eq!(info.zoom_delta, 1.0, "topology reset must zero zoom");
562        assert_eq!(
563            info.rotation_delta, 0.0,
564            "topology reset must zero rotation"
565        );
566        assert_eq!(info.translation_delta.x, 0.0);
567        assert_eq!(info.translation_delta.y, 0.0);
568    }
569
570    /// Regression: one finger crosses the atan2 ±pi seam during a small
571    /// real two-finger twist.  Both fingers rotate +4° CCW around a fixed
572    /// centroid at (200,200), but finger A sits at 178° and steps to 182°
573    /// — crossing the seam so its raw per-frame delta reads as ≈ -356°.
574    /// Finger B (at -2° -> +2°) reads a clean +4°.  Before the fix the sum
575    /// was normalised only AFTER averaging, so the frame delta collapsed to
576    /// ≈ -176° instead of +4°.  Per-finger normalisation must repair this.
577    #[test]
578    fn seam_crossing_finger_does_not_flip_rotation() {
579        let mut ts = TouchState::new();
580        // Baseline: A at 178°, B at -2°, radius 100 about centroid (200,200).
581        ts.on_start(DEV, TouchId(0), Point::new(100.060917, 203.489950), None); // A: 178°
582        ts.on_start(DEV, TouchId(1), Point::new(299.939083, 196.510050), None); // B: -2°
583        ts.update_gesture(); // baseline (zeroed deltas)
584
585        // Both fingers step +4° CCW: A 178°->182° (crosses +pi seam),
586        // B -2°->+2°.
587        ts.on_move(DEV, TouchId(0), Point::new(100.060917, 196.510050), None); // A: 182°
588        ts.on_move(DEV, TouchId(1), Point::new(299.939083, 203.489950), None); // B: +2°
589        ts.update_gesture();
590
591        let info = ts.current().expect("gesture present");
592        let expected = 4.0_f32.to_radians();
593        assert!(
594            (info.rotation_delta - expected).abs() < 1e-3,
595            "rotation_delta = {} (expected ~+{} rad, +4° CCW); a seam-crossing \
596             finger must not flip the averaged rotation",
597            info.rotation_delta,
598            expected
599        );
600        assert!(
601            (info.zoom_delta - 1.0).abs() < 1e-3,
602            "pure rotation must not zoom, got {}",
603            info.zoom_delta
604        );
605    }
606
607    /// Two `update_gesture` calls with no movement in between: the second
608    /// frame (topology already cleared) must read as no-op deltas because
609    /// each finger's current position equals its latched baseline.
610    #[test]
611    fn no_movement_yields_identity_deltas() {
612        let mut ts = TouchState::new();
613        ts.on_start(DEV, TouchId(0), Point::new(100.0, 100.0), None);
614        ts.on_start(DEV, TouchId(1), Point::new(200.0, 100.0), None);
615        ts.update_gesture(); // baseline (topology_changed frame)
616        ts.update_gesture(); // steady state, no on_move between
617
618        let info = ts.current().expect("gesture present");
619        assert!(
620            (info.zoom_delta - 1.0).abs() < 1e-6,
621            "no movement -> zoom 1.0, got {}",
622            info.zoom_delta
623        );
624        assert!(
625            info.rotation_delta.abs() < 1e-6,
626            "no movement -> rotation 0, got {}",
627            info.rotation_delta
628        );
629        assert_eq!(info.translation_delta.x, 0.0);
630        assert_eq!(info.translation_delta.y, 0.0);
631    }
632}