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}