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(
337        &mut self,
338        a: FingerState,
339        b: FingerState,
340        moved_id: u64,
341    ) -> Option<InputEvent> {
342        let (id_a, id_b) = self.two_finger_ids?;
343
344        // Only evaluate pitch when one of the two locked fingers moved.
345        if moved_id != id_a && moved_id != id_b {
346            return None;
347        }
348
349        let last_points = self.pitch_last_points?;
350        let vec_a = [a.x - last_points.0[0], a.y - last_points.0[1]];
351        let vec_b = [b.x - last_points.1[0], b.y - last_points.1[1]];
352
353        // Determine pitch validity on first significant move.
354        // Wait until both fingers have moved at least 2px before deciding,
355        // so we can accurately evaluate the direction of both vectors.
356        if self.pitch_valid.is_none() {
357            let a_mag = (vec_a[0] * vec_a[0] + vec_a[1] * vec_a[1]).sqrt();
358            let b_mag = (vec_b[0] * vec_b[0] + vec_b[1] * vec_b[1]).sqrt();
359            if a_mag > 2.0 && b_mag > 2.0 {
360                let a_vert = vec_a[1].abs() > vec_a[0].abs();
361                let b_vert = vec_b[1].abs() > vec_b[0].abs();
362                // Same vertical direction: both up or both down.
363                let same_dir = vec_a[1] * vec_b[1] > 0.0;
364                self.pitch_valid = Some(a_vert && b_vert && same_dir);
365            }
366        }
367
368        if self.pitch_valid != Some(true) {
369            // Don't update last_points until pitch is validated,
370            // so the accumulated movement from both fingers is preserved.
371            return None;
372        }
373
374        // Update last_points only after pitch is validated.
375        self.pitch_last_points = Some(([a.x, a.y], [b.x, b.y]));
376
377        // Average vertical delta of both fingers.
378        let y_avg = (vec_a[1] + vec_b[1]) * 0.5;
379        if y_avg.abs() < f64::EPSILON {
380            return None;
381        }
382
383        let pitch_delta_rad = (y_avg * PITCH_DEGREES_PER_PX).to_radians();
384        Some(InputEvent::Rotate {
385            delta_yaw: 0.0,
386            delta_pitch: pitch_delta_rad,
387        })
388    }
389
390    // -- Helpers ----------------------------------------------------------
391
392    fn pan_anchor(&self) -> (f64, f64) {
393        if self.fingers.is_empty() {
394            return (0.0, 0.0);
395        }
396        let (mut sx, mut sy) = (0.0, 0.0);
397        for f in self.fingers.values() {
398            sx += f.x;
399            sy += f.y;
400        }
401        let n = self.fingers.len() as f64;
402        (sx / n, sy / n)
403    }
404
405    fn reset_two_finger(&mut self) {
406        self.two_finger_ids = None;
407        self.start_distance = 0.0;
408        self.last_distance = 0.0;
409        self.start_vector = None;
410        self.last_vector = None;
411        self.min_diameter = 0.0;
412        self.zoom_active = false;
413        self.rotate_active = false;
414        self.pitch_valid = None;
415        self.pitch_last_points = None;
416    }
417}
418
419// ---------------------------------------------------------------------------
420// Geometry helpers
421// ---------------------------------------------------------------------------
422
423/// Euclidean distance between two points.
424#[inline]
425fn distance(x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
426    let dx = x2 - x1;
427    let dy = y2 - y1;
428    (dx * dx + dy * dy).sqrt()
429}
430
431/// Signed angle between two 2-D vectors, in degrees.
432///
433/// Positive = counter-clockwise from `a` to `b`.
434fn angle_between(a: [f64; 2], b: [f64; 2]) -> f64 {
435    let cross = b[0] * a[1] - b[1] * a[0];
436    let dot = a[0] * b[0] + a[1] * b[1];
437    cross.atan2(dot).to_degrees()
438}
439
440// ---------------------------------------------------------------------------
441// Tests
442// ---------------------------------------------------------------------------
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use crate::input::TouchPhase;
448
449    fn tc(id: u64, phase: TouchPhase, x: f64, y: f64) -> TouchContact {
450        TouchContact { id, phase, x, y }
451    }
452
453    // -- Single finger pan ------------------------------------------------
454
455    #[test]
456    fn single_finger_pan() {
457        let mut g = GestureRecognizer::new();
458        let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
459        let events = g.process(tc(0, TouchPhase::Moved, 110.0, 205.0));
460        assert_eq!(events.len(), 1);
461        match events[0] {
462            InputEvent::Pan { dx, dy, .. } => {
463                assert!((dx - 10.0).abs() < 1e-9);
464                assert!((dy - 5.0).abs() < 1e-9);
465            }
466            _ => panic!("expected Pan"),
467        }
468    }
469
470    #[test]
471    fn finger_end_clears_state() {
472        let mut g = GestureRecognizer::new();
473        let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
474        let _ = g.process(tc(0, TouchPhase::Ended, 100.0, 200.0));
475        assert_eq!(g.finger_count(), 0);
476    }
477
478    // -- Two-finger pinch-zoom -------------------------------------------
479
480    #[test]
481    fn pinch_zoom_produces_zoom_event() {
482        let mut g = GestureRecognizer::new();
483        // Two fingers start 100px apart horizontally.
484        let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
485        let _ = g.process(tc(1, TouchPhase::Started, 200.0, 200.0));
486
487        // Move fingers apart to 150px each side (distance 200 → well above threshold).
488        let _ = g.process(tc(0, TouchPhase::Moved, 50.0, 200.0));
489        let events = g.process(tc(1, TouchPhase::Moved, 250.0, 200.0));
490
491        let has_zoom = events.iter().any(|e| e.is_zoom());
492        assert!(has_zoom, "expected zoom event from pinch: {events:?}");
493    }
494
495    #[test]
496    fn pinch_zoom_below_threshold_does_not_activate() {
497        let mut g = GestureRecognizer::new();
498        let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
499        let _ = g.process(tc(1, TouchPhase::Started, 200.0, 200.0));
500
501        // Tiny pinch (distance changes from 100 to ~101) — below threshold.
502        let events = g.process(tc(1, TouchPhase::Moved, 201.0, 200.0));
503        let has_zoom = events.iter().any(|e| e.is_zoom());
504        assert!(!has_zoom, "should not zoom below threshold");
505    }
506
507    // -- Two-finger rotation ----------------------------------------------
508
509    #[test]
510    fn rotation_produces_rotate_event() {
511        let mut g = GestureRecognizer::new();
512        // Fingers 100px apart horizontally.
513        let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
514        let _ = g.process(tc(1, TouchPhase::Started, 200.0, 200.0));
515
516        // Rotate by moving finger 1 up and finger 0 down (large twist).
517        let _ = g.process(tc(0, TouchPhase::Moved, 100.0, 250.0));
518        let events = g.process(tc(1, TouchPhase::Moved, 200.0, 150.0));
519
520        let has_rotate = events.iter().any(|e| matches!(e,
521            InputEvent::Rotate { delta_yaw, .. } if delta_yaw.abs() > 1e-6
522        ));
523        assert!(has_rotate, "expected rotation event: {events:?}");
524    }
525
526    // -- Two-finger pitch ------------------------------------------------
527
528    #[test]
529    fn vertical_drag_produces_pitch() {
530        let mut g = GestureRecognizer::new();
531        let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
532        let _ = g.process(tc(1, TouchPhase::Started, 200.0, 200.0));
533
534        // Both fingers drag down significantly.
535        let _ = g.process(tc(0, TouchPhase::Moved, 100.0, 230.0));
536        let events = g.process(tc(1, TouchPhase::Moved, 200.0, 230.0));
537
538        let has_pitch = events.iter().any(|e| matches!(e,
539            InputEvent::Rotate { delta_pitch, .. } if delta_pitch.abs() > 1e-6
540        ));
541        assert!(has_pitch, "expected pitch event: {events:?}");
542    }
543
544    // -- Gesture lifecycle ------------------------------------------------
545
546    #[test]
547    fn lifting_one_finger_resets_two_finger_state() {
548        let mut g = GestureRecognizer::new();
549        let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
550        let _ = g.process(tc(1, TouchPhase::Started, 200.0, 200.0));
551        assert!(g.two_finger_ids.is_some());
552
553        let _ = g.process(tc(1, TouchPhase::Ended, 200.0, 200.0));
554        assert!(g.two_finger_ids.is_none());
555    }
556
557    #[test]
558    fn cancel_resets_everything() {
559        let mut g = GestureRecognizer::new();
560        let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
561        g.reset();
562        assert_eq!(g.finger_count(), 0);
563        assert!(g.two_finger_ids.is_none());
564    }
565
566    #[test]
567    fn third_finger_ignored_for_two_finger_gesture() {
568        let mut g = GestureRecognizer::new();
569        let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
570        let _ = g.process(tc(1, TouchPhase::Started, 200.0, 200.0));
571        let ids_before = g.two_finger_ids;
572        let _ = g.process(tc(2, TouchPhase::Started, 300.0, 200.0));
573        // Third finger should not change the locked pair.
574        assert_eq!(g.two_finger_ids, ids_before);
575    }
576
577    // -- Pan anchor -------------------------------------------------------
578
579    #[test]
580    fn pan_anchor_is_centroid() {
581        let mut g = GestureRecognizer::new();
582        let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
583        let _ = g.process(tc(1, TouchPhase::Started, 200.0, 200.0));
584        let (ax, ay) = g.pan_anchor();
585        assert!((ax - 150.0).abs() < 1e-9);
586        assert!((ay - 200.0).abs() < 1e-9);
587    }
588
589    // -- Angle helper -----------------------------------------------------
590
591    #[test]
592    fn angle_between_90_degrees() {
593        let a = [1.0, 0.0];
594        let b = [0.0, 1.0];
595        let angle = angle_between(a, b);
596        assert!((angle.abs() - 90.0).abs() < 0.1, "got {angle}");
597    }
598
599    #[test]
600    fn angle_between_opposite_is_180() {
601        let a = [1.0, 0.0];
602        let b = [-1.0, 0.0];
603        let angle = angle_between(a, b);
604        assert!((angle.abs() - 180.0).abs() < 0.1, "got {angle}");
605    }
606
607    // -- Zoom factor direction -------------------------------------------
608
609    #[test]
610    fn pinch_out_zooms_in() {
611        let mut g = GestureRecognizer::new();
612        let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
613        let _ = g.process(tc(1, TouchPhase::Started, 200.0, 200.0));
614
615        // Large spread: distance goes from 100 to 300.
616        let _ = g.process(tc(0, TouchPhase::Moved, 0.0, 200.0));
617        let events = g.process(tc(1, TouchPhase::Moved, 300.0, 200.0));
618
619        let zoom_event = events.iter().find(|e| e.is_zoom());
620        if let Some(InputEvent::Zoom { factor, .. }) = zoom_event {
621            assert!(*factor > 1.0, "spreading fingers should zoom in, got factor={factor}");
622        } else {
623            panic!("expected zoom event: {events:?}");
624        }
625    }
626
627    #[test]
628    fn pinch_in_zooms_out() {
629        let mut g = GestureRecognizer::new();
630        let _ = g.process(tc(0, TouchPhase::Started, 0.0, 200.0));
631        let _ = g.process(tc(1, TouchPhase::Started, 300.0, 200.0));
632
633        // Large squeeze: distance goes from 300 to 100.
634        let _ = g.process(tc(0, TouchPhase::Moved, 100.0, 200.0));
635        let events = g.process(tc(1, TouchPhase::Moved, 200.0, 200.0));
636
637        let zoom_event = events.iter().find(|e| e.is_zoom());
638        if let Some(InputEvent::Zoom { factor, .. }) = zoom_event {
639            assert!(*factor < 1.0, "squeezing fingers should zoom out, got factor={factor}");
640        } else {
641            panic!("expected zoom event: {events:?}");
642        }
643    }
644}