Skip to main content

rustial_engine/
gesture.rs

1//! # Multi-touch gesture recognizer
2//!
3//! Converts raw [`TouchContact`] events into high-level camera
4//! [`InputEvent`]s (pan, pinch-zoom, two-finger rotate, two-finger
5//! pitch).
6//!
7//! ## Design
8//!
9//! The recognizer follows the same architecture as MapLibre GL JS /
10//! Mapbox GL JS:
11//!
12//! - **Single touch** → pan (averaged delta when multiple fingers are
13//!   down).
14//! - **Two-finger pinch** → zoom, using `log₂(distance / last_distance)`
15//!   for smooth proportional zoom.
16//! - **Two-finger rotation** → yaw, with an adaptive threshold that
17//!   scales with finger distance (smaller circle = higher threshold in
18//!   degrees).
19//! - **Two-finger vertical drag** → pitch, detected when both fingers
20//!   move predominantly vertically in the same direction.
21//!
22//! ## Gesture disambiguation
23//!
24//! Pinch-zoom activates after `|log₂(distance / start_distance)| ≥ 0.1`.
25//! Rotation activates after the bearing delta exceeds a threshold
26//! derived from `ROTATION_THRESHOLD_PX / circumference × 360°`.
27//! Pitch requires both finger vectors to be vertical and same-direction.
28//! All three can be active simultaneously (zoom+rotate is common during
29//! a two-finger gesture).
30//!
31//! ## Usage
32//!
33//! ```
34//! use rustial_engine::gesture::GestureRecognizer;
35//! use rustial_engine::{InputEvent, TouchContact, TouchPhase};
36//!
37//! let mut gesture = GestureRecognizer::new();
38//!
39//! // Finger 0 touches down at (100, 200).
40//! let events = gesture.process(TouchContact {
41//!     id: 0, phase: TouchPhase::Started, x: 100.0, y: 200.0,
42//! });
43//! assert!(events.is_empty()); // no gesture from a single touch-down
44//!
45//! // Finger 0 drags to (110, 200) → pan.
46//! let events = gesture.process(TouchContact {
47//!     id: 0, phase: TouchPhase::Moved, x: 110.0, y: 200.0,
48//! });
49//! assert_eq!(events.len(), 1);
50//! assert!(events[0].is_pan());
51//! ```
52
53use crate::input::{InputEvent, TouchContact, TouchPhase};
54use std::collections::HashMap;
55
56// ---------------------------------------------------------------------------
57// Constants (tuned to match MapLibre / Mapbox GL JS)
58// ---------------------------------------------------------------------------
59
60/// Minimum `|log₂(distance / start_distance)|` before pinch-zoom
61/// activates.
62const ZOOM_THRESHOLD: f64 = 0.1;
63
64/// Pixels along the circumference of the circle formed by two fingers
65/// before rotation activates.  Larger values require a more deliberate
66/// twist.  (MapBox uses 25.)
67const ROTATION_THRESHOLD_PX: f64 = 25.0;
68
69/// Degrees per logical pixel of vertical finger movement for pitch.
70/// Negative because dragging down (positive pixel Y) should decrease
71/// pitch (tilt toward nadir).
72const PITCH_DEGREES_PER_PX: f64 = -0.5;
73
74// ---------------------------------------------------------------------------
75// GestureRecognizer
76// ---------------------------------------------------------------------------
77
78/// Stateful multi-touch gesture recognizer.
79///
80/// Feed it [`TouchContact`] events via [`process`](Self::process) and
81/// it returns zero or more [`InputEvent`]s that can be forwarded to
82/// [`MapState::handle_input`](crate::MapState::handle_input).
83pub struct GestureRecognizer {
84    /// Active touch contacts keyed by finger id.
85    fingers: HashMap<u64, FingerState>,
86
87    // -- Two-finger gesture state -----------------------------------------
88    /// The two finger ids locked for the current two-finger gesture.
89    two_finger_ids: Option<(u64, u64)>,
90    /// Distance between the two locked fingers at gesture start.
91    start_distance: f64,
92    /// Distance between the two locked fingers last frame.
93    last_distance: f64,
94    /// Vector from finger A to finger B at gesture start.
95    start_vector: Option<[f64; 2]>,
96    /// Vector from finger A to finger B last frame.
97    last_vector: Option<[f64; 2]>,
98    /// Minimum distance between fingers during the gesture (for adaptive
99    /// rotation threshold).
100    min_diameter: f64,
101    /// Whether zoom has passed the activation threshold.
102    zoom_active: bool,
103    /// Whether rotation has passed the activation threshold.
104    rotate_active: bool,
105    /// Whether pitch has been validated for this gesture.
106    pitch_valid: Option<bool>,
107    /// Last finger positions for pitch delta calculation.
108    pitch_last_points: Option<([f64; 2], [f64; 2])>,
109}
110
111#[derive(Debug, Clone, Copy)]
112struct FingerState {
113    x: f64,
114    y: f64,
115}
116
117impl Default for GestureRecognizer {
118    fn default() -> Self {
119        Self::new()
120    }
121}
122
123impl GestureRecognizer {
124    /// Create a new gesture recognizer with no active touches.
125    pub fn new() -> Self {
126        Self {
127            fingers: HashMap::new(),
128            two_finger_ids: None,
129            start_distance: 0.0,
130            last_distance: 0.0,
131            start_vector: None,
132            last_vector: None,
133            min_diameter: 0.0,
134            zoom_active: false,
135            rotate_active: false,
136            pitch_valid: None,
137            pitch_last_points: None,
138        }
139    }
140
141    /// Reset all gesture state (e.g. when the window loses focus).
142    pub fn reset(&mut self) {
143        self.fingers.clear();
144        self.reset_two_finger();
145    }
146
147    /// Number of fingers currently tracked.
148    #[inline]
149    pub fn finger_count(&self) -> usize {
150        self.fingers.len()
151    }
152
153    /// Process a single touch contact and return any resulting input
154    /// events.
155    ///
156    /// Typically returns 0–3 events (pan, zoom, rotate may fire
157    /// simultaneously from a single two-finger move).
158    pub fn process(&mut self, contact: TouchContact) -> Vec<InputEvent> {
159        match contact.phase {
160            TouchPhase::Started => self.on_start(contact),
161            TouchPhase::Moved => self.on_move(contact),
162            TouchPhase::Ended | TouchPhase::Cancelled => self.on_end(contact),
163        }
164    }
165
166    // -- Phase handlers ---------------------------------------------------
167
168    fn on_start(&mut self, c: TouchContact) -> Vec<InputEvent> {
169        self.fingers.insert(c.id, FingerState { x: c.x, y: c.y });
170
171        // Lock the first two fingers for two-finger gestures.
172        if self.two_finger_ids.is_none() && self.fingers.len() >= 2 {
173            let mut ids: Vec<u64> = self.fingers.keys().copied().collect();
174            ids.sort_unstable();
175            let (id_a, id_b) = (ids[0], ids[1]);
176            let a = self.fingers[&id_a];
177            let b = self.fingers[&id_b];
178            let dist = distance(a.x, a.y, b.x, b.y);
179            let vec = [b.x - a.x, b.y - a.y];
180
181            self.two_finger_ids = Some((id_a, id_b));
182            self.start_distance = dist;
183            self.last_distance = dist;
184            self.start_vector = Some(vec);
185            self.last_vector = Some(vec);
186            self.min_diameter = dist;
187            self.zoom_active = false;
188            self.rotate_active = false;
189            self.pitch_valid = None;
190            self.pitch_last_points = Some(([a.x, a.y], [b.x, b.y]));
191        }
192
193        Vec::new()
194    }
195
196    fn on_move(&mut self, c: TouchContact) -> Vec<InputEvent> {
197        // Compute per-finger delta before updating stored position.
198        let prev = match self.fingers.get(&c.id) {
199            Some(f) => *f,
200            None => return Vec::new(), // unknown finger
201        };
202        let dx = c.x - prev.x;
203        let dy = c.y - prev.y;
204
205        // Update stored position.
206        self.fingers.insert(c.id, FingerState { x: c.x, y: c.y });
207
208        let mut events = Vec::new();
209
210        // -- Two-finger gestures ------------------------------------------
211        if let Some((id_a, id_b)) = self.two_finger_ids {
212            if let (Some(a), Some(b)) = (self.fingers.get(&id_a), self.fingers.get(&id_b)) {
213                let a = *a;
214                let b = *b;
215
216                // Pinch-zoom
217                events.extend(self.check_zoom(a, b));
218
219                // Rotation
220                events.extend(self.check_rotation(a, b));
221
222                // Pitch
223                events.extend(self.check_pitch(a, b, c.id));
224            }
225        }
226
227        // -- Pan (averaged across all active fingers) ---------------------
228        // Only pan when at least one finger is moving.
229        // With two-finger gestures active, pan uses the midpoint of the
230        // locked fingers as anchor.
231        if dx.abs() > f64::EPSILON || dy.abs() > f64::EPSILON {
232            let (anchor_x, anchor_y) = self.pan_anchor();
233            events.push(InputEvent::Pan {
234                dx,
235                dy,
236                x: Some(anchor_x),
237                y: Some(anchor_y),
238            });
239        }
240
241        events
242    }
243
244    fn on_end(&mut self, c: TouchContact) -> Vec<InputEvent> {
245        self.fingers.remove(&c.id);
246
247        // If one of the locked two-finger pair lifted, reset the
248        // two-finger gesture.
249        if let Some((id_a, id_b)) = self.two_finger_ids {
250            if c.id == id_a || c.id == id_b {
251                self.reset_two_finger();
252            }
253        }
254
255        Vec::new()
256    }
257
258    // -- Two-finger sub-recognizers ---------------------------------------
259
260    fn check_zoom(&mut self, a: FingerState, b: FingerState) -> Option<InputEvent> {
261        let dist = distance(a.x, a.y, b.x, b.y);
262        if dist < 1.0 {
263            return None; // fingers on top of each other
264        }
265
266        if !self.zoom_active {
267            let delta = (dist / self.start_distance).ln() / std::f64::consts::LN_2;
268            if delta.abs() < ZOOM_THRESHOLD {
269                return None;
270            }
271            self.zoom_active = true;
272        }
273
274        let zoom_delta = (dist / self.last_distance).ln() / std::f64::consts::LN_2;
275        self.last_distance = dist;
276
277        // Convert log₂ delta to multiplicative factor: 2^delta.
278        let factor = 2.0_f64.powf(zoom_delta);
279
280        // Anchor at midpoint between fingers.
281        let mx = (a.x + b.x) * 0.5;
282        let my = (a.y + b.y) * 0.5;
283
284        Some(InputEvent::Zoom {
285            factor,
286            x: Some(mx),
287            y: Some(my),
288        })
289    }
290
291    fn check_rotation(&mut self, a: FingerState, b: FingerState) -> Option<InputEvent> {
292        let vec = [b.x - a.x, b.y - a.y];
293        let mag = (vec[0] * vec[0] + vec[1] * vec[1]).sqrt();
294        if mag < 1.0 {
295            return None;
296        }
297
298        self.min_diameter = self.min_diameter.min(mag);
299
300        if !self.rotate_active {
301            if let Some(start) = self.start_vector {
302                let bearing = angle_between(vec, start);
303                let circumference = std::f64::consts::PI * self.min_diameter;
304                let threshold_deg = if circumference > 0.0 {
305                    ROTATION_THRESHOLD_PX / circumference * 360.0
306                } else {
307                    360.0
308                };
309                if bearing.abs() < threshold_deg {
310                    self.last_vector = Some(vec);
311                    return None;
312                }
313            }
314            self.rotate_active = true;
315        }
316
317        let bearing_delta = if let Some(last) = self.last_vector {
318            angle_between(vec, last)
319        } else {
320            0.0
321        };
322        self.last_vector = Some(vec);
323
324        if bearing_delta.abs() < f64::EPSILON {
325            return None;
326        }
327
328        // Convert degrees to radians for InputEvent::Rotate.
329        let delta_yaw = bearing_delta.to_radians();
330        Some(InputEvent::Rotate {
331            delta_yaw,
332            delta_pitch: 0.0,
333        })
334    }
335
336    fn check_pitch(&mut self, a: FingerState, b: FingerState, moved_id: u64) -> Option<InputEvent> {
337        let (id_a, id_b) = self.two_finger_ids?;
338
339        // Only evaluate pitch when one of the two locked fingers moved.
340        if moved_id != id_a && moved_id != id_b {
341            return None;
342        }
343
344        let last_points = self.pitch_last_points?;
345        let vec_a = [a.x - last_points.0[0], a.y - last_points.0[1]];
346        let vec_b = [b.x - last_points.1[0], b.y - last_points.1[1]];
347
348        // Determine pitch validity on first significant move.
349        // Wait until both fingers have moved at least 2px before deciding,
350        // so we can accurately evaluate the direction of both vectors.
351        if self.pitch_valid.is_none() {
352            let a_mag = (vec_a[0] * vec_a[0] + vec_a[1] * vec_a[1]).sqrt();
353            let b_mag = (vec_b[0] * vec_b[0] + vec_b[1] * vec_b[1]).sqrt();
354            if a_mag > 2.0 && b_mag > 2.0 {
355                let a_vert = vec_a[1].abs() > vec_a[0].abs();
356                let b_vert = vec_b[1].abs() > vec_b[0].abs();
357                // Same vertical direction: both up or both down.
358                let same_dir = vec_a[1] * vec_b[1] > 0.0;
359                self.pitch_valid = Some(a_vert && b_vert && same_dir);
360            }
361        }
362
363        if self.pitch_valid != Some(true) {
364            // Don't update last_points until pitch is validated,
365            // so the accumulated movement from both fingers is preserved.
366            return None;
367        }
368
369        // Update last_points only after pitch is validated.
370        self.pitch_last_points = Some(([a.x, a.y], [b.x, b.y]));
371
372        // Average vertical delta of both fingers.
373        let y_avg = (vec_a[1] + vec_b[1]) * 0.5;
374        if y_avg.abs() < f64::EPSILON {
375            return None;
376        }
377
378        let pitch_delta_rad = (y_avg * PITCH_DEGREES_PER_PX).to_radians();
379        Some(InputEvent::Rotate {
380            delta_yaw: 0.0,
381            delta_pitch: pitch_delta_rad,
382        })
383    }
384
385    // -- Helpers ----------------------------------------------------------
386
387    fn pan_anchor(&self) -> (f64, f64) {
388        if self.fingers.is_empty() {
389            return (0.0, 0.0);
390        }
391        let (mut sx, mut sy) = (0.0, 0.0);
392        for f in self.fingers.values() {
393            sx += f.x;
394            sy += f.y;
395        }
396        let n = self.fingers.len() as f64;
397        (sx / n, sy / n)
398    }
399
400    fn reset_two_finger(&mut self) {
401        self.two_finger_ids = None;
402        self.start_distance = 0.0;
403        self.last_distance = 0.0;
404        self.start_vector = None;
405        self.last_vector = None;
406        self.min_diameter = 0.0;
407        self.zoom_active = false;
408        self.rotate_active = false;
409        self.pitch_valid = None;
410        self.pitch_last_points = None;
411    }
412}
413
414// ---------------------------------------------------------------------------
415// Geometry helpers
416// ---------------------------------------------------------------------------
417
418/// Euclidean distance between two points.
419#[inline]
420fn distance(x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
421    let dx = x2 - x1;
422    let dy = y2 - y1;
423    (dx * dx + dy * dy).sqrt()
424}
425
426/// Signed angle between two 2-D vectors, in degrees.
427///
428/// Positive = counter-clockwise from `a` to `b`.
429fn angle_between(a: [f64; 2], b: [f64; 2]) -> f64 {
430    let cross = b[0] * a[1] - b[1] * a[0];
431    let dot = a[0] * b[0] + a[1] * b[1];
432    cross.atan2(dot).to_degrees()
433}
434
435// ---------------------------------------------------------------------------
436// Tests
437// ---------------------------------------------------------------------------
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use crate::input::TouchPhase;
443
444    fn tc(id: u64, phase: TouchPhase, x: f64, y: f64) -> TouchContact {
445        TouchContact { id, phase, x, y }
446    }
447
448    // -- Single finger pan ------------------------------------------------
449
450    #[test]
451    fn single_finger_pan() {
452        let mut g = GestureRecognizer::new();
453        let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
454        let events = g.process(tc(0, TouchPhase::Moved, 110.0, 205.0));
455        assert_eq!(events.len(), 1);
456        match events[0] {
457            InputEvent::Pan { dx, dy, .. } => {
458                assert!((dx - 10.0).abs() < 1e-9);
459                assert!((dy - 5.0).abs() < 1e-9);
460            }
461            _ => panic!("expected Pan"),
462        }
463    }
464
465    #[test]
466    fn finger_end_clears_state() {
467        let mut g = GestureRecognizer::new();
468        let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
469        let _ = g.process(tc(0, TouchPhase::Ended, 100.0, 200.0));
470        assert_eq!(g.finger_count(), 0);
471    }
472
473    // -- Two-finger pinch-zoom -------------------------------------------
474
475    #[test]
476    fn pinch_zoom_produces_zoom_event() {
477        let mut g = GestureRecognizer::new();
478        // Two fingers start 100px apart horizontally.
479        let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
480        let _ = g.process(tc(1, TouchPhase::Started, 200.0, 200.0));
481
482        // Move fingers apart to 150px each side (distance 200 → well above threshold).
483        let _ = g.process(tc(0, TouchPhase::Moved, 50.0, 200.0));
484        let events = g.process(tc(1, TouchPhase::Moved, 250.0, 200.0));
485
486        let has_zoom = events.iter().any(|e| e.is_zoom());
487        assert!(has_zoom, "expected zoom event from pinch: {events:?}");
488    }
489
490    #[test]
491    fn pinch_zoom_below_threshold_does_not_activate() {
492        let mut g = GestureRecognizer::new();
493        let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
494        let _ = g.process(tc(1, TouchPhase::Started, 200.0, 200.0));
495
496        // Tiny pinch (distance changes from 100 to ~101) — below threshold.
497        let events = g.process(tc(1, TouchPhase::Moved, 201.0, 200.0));
498        let has_zoom = events.iter().any(|e| e.is_zoom());
499        assert!(!has_zoom, "should not zoom below threshold");
500    }
501
502    // -- Two-finger rotation ----------------------------------------------
503
504    #[test]
505    fn rotation_produces_rotate_event() {
506        let mut g = GestureRecognizer::new();
507        // Fingers 100px apart horizontally.
508        let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
509        let _ = g.process(tc(1, TouchPhase::Started, 200.0, 200.0));
510
511        // Rotate by moving finger 1 up and finger 0 down (large twist).
512        let _ = g.process(tc(0, TouchPhase::Moved, 100.0, 250.0));
513        let events = g.process(tc(1, TouchPhase::Moved, 200.0, 150.0));
514
515        let has_rotate = events.iter().any(|e| {
516            matches!(e,
517                InputEvent::Rotate { delta_yaw, .. } if delta_yaw.abs() > 1e-6
518            )
519        });
520        assert!(has_rotate, "expected rotation event: {events:?}");
521    }
522
523    // -- Two-finger pitch ------------------------------------------------
524
525    #[test]
526    fn vertical_drag_produces_pitch() {
527        let mut g = GestureRecognizer::new();
528        let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
529        let _ = g.process(tc(1, TouchPhase::Started, 200.0, 200.0));
530
531        // Both fingers drag down significantly.
532        let _ = g.process(tc(0, TouchPhase::Moved, 100.0, 230.0));
533        let events = g.process(tc(1, TouchPhase::Moved, 200.0, 230.0));
534
535        let has_pitch = events.iter().any(|e| {
536            matches!(e,
537                InputEvent::Rotate { delta_pitch, .. } if delta_pitch.abs() > 1e-6
538            )
539        });
540        assert!(has_pitch, "expected pitch event: {events:?}");
541    }
542
543    // -- Gesture lifecycle ------------------------------------------------
544
545    #[test]
546    fn lifting_one_finger_resets_two_finger_state() {
547        let mut g = GestureRecognizer::new();
548        let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
549        let _ = g.process(tc(1, TouchPhase::Started, 200.0, 200.0));
550        assert!(g.two_finger_ids.is_some());
551
552        let _ = g.process(tc(1, TouchPhase::Ended, 200.0, 200.0));
553        assert!(g.two_finger_ids.is_none());
554    }
555
556    #[test]
557    fn cancel_resets_everything() {
558        let mut g = GestureRecognizer::new();
559        let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
560        g.reset();
561        assert_eq!(g.finger_count(), 0);
562        assert!(g.two_finger_ids.is_none());
563    }
564
565    #[test]
566    fn third_finger_ignored_for_two_finger_gesture() {
567        let mut g = GestureRecognizer::new();
568        let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
569        let _ = g.process(tc(1, TouchPhase::Started, 200.0, 200.0));
570        let ids_before = g.two_finger_ids;
571        let _ = g.process(tc(2, TouchPhase::Started, 300.0, 200.0));
572        // Third finger should not change the locked pair.
573        assert_eq!(g.two_finger_ids, ids_before);
574    }
575
576    // -- Pan anchor -------------------------------------------------------
577
578    #[test]
579    fn pan_anchor_is_centroid() {
580        let mut g = GestureRecognizer::new();
581        let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
582        let _ = g.process(tc(1, TouchPhase::Started, 200.0, 200.0));
583        let (ax, ay) = g.pan_anchor();
584        assert!((ax - 150.0).abs() < 1e-9);
585        assert!((ay - 200.0).abs() < 1e-9);
586    }
587
588    // -- Angle helper -----------------------------------------------------
589
590    #[test]
591    fn angle_between_90_degrees() {
592        let a = [1.0, 0.0];
593        let b = [0.0, 1.0];
594        let angle = angle_between(a, b);
595        assert!((angle.abs() - 90.0).abs() < 0.1, "got {angle}");
596    }
597
598    #[test]
599    fn angle_between_opposite_is_180() {
600        let a = [1.0, 0.0];
601        let b = [-1.0, 0.0];
602        let angle = angle_between(a, b);
603        assert!((angle.abs() - 180.0).abs() < 0.1, "got {angle}");
604    }
605
606    // -- Zoom factor direction -------------------------------------------
607
608    #[test]
609    fn pinch_out_zooms_in() {
610        let mut g = GestureRecognizer::new();
611        let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
612        let _ = g.process(tc(1, TouchPhase::Started, 200.0, 200.0));
613
614        // Large spread: distance goes from 100 to 300.
615        let _ = g.process(tc(0, TouchPhase::Moved, 0.0, 200.0));
616        let events = g.process(tc(1, TouchPhase::Moved, 300.0, 200.0));
617
618        let zoom_event = events.iter().find(|e| e.is_zoom());
619        if let Some(InputEvent::Zoom { factor, .. }) = zoom_event {
620            assert!(
621                *factor > 1.0,
622                "spreading fingers should zoom in, got factor={factor}"
623            );
624        } else {
625            panic!("expected zoom event: {events:?}");
626        }
627    }
628
629    #[test]
630    fn pinch_in_zooms_out() {
631        let mut g = GestureRecognizer::new();
632        let _ = g.process(tc(0, TouchPhase::Started, 0.0, 200.0));
633        let _ = g.process(tc(1, TouchPhase::Started, 300.0, 200.0));
634
635        // Large squeeze: distance goes from 300 to 100.
636        let _ = g.process(tc(0, TouchPhase::Moved, 100.0, 200.0));
637        let events = g.process(tc(1, TouchPhase::Moved, 200.0, 200.0));
638
639        let zoom_event = events.iter().find(|e| e.is_zoom());
640        if let Some(InputEvent::Zoom { factor, .. }) = zoom_event {
641            assert!(
642                *factor < 1.0,
643                "squeezing fingers should zoom out, got factor={factor}"
644            );
645        } else {
646            panic!("expected zoom event: {events:?}");
647        }
648    }
649}