Skip to main content

mittens_engine/engine/ecs/system/
gesture_system.rs

1use crate::engine::ecs::component::PointerEvents;
2use crate::engine::ecs::system::BvhSystem;
3use crate::engine::ecs::system::pointer_system::{PointerActivations, PointerSystem};
4use crate::engine::ecs::{ComponentId, EventSignal, RxWorld, SignalKind};
5use crate::engine::graphics::VisualWorld;
6use crate::engine::user_input::InputState;
7use crate::utils::math;
8use std::collections::HashMap;
9use std::sync::OnceLock;
10use std::sync::{Arc, Mutex};
11
12#[derive(Debug, Copy, Clone, PartialEq, Eq)]
13pub enum DragUpdatePolicy {
14    /// Only emit drag moves while the pointer still intersects the original target.
15    RequireTargetContact,
16
17    /// After `DragStart`, continue producing deltas by projecting the current pointer ray onto a
18    /// stable plane captured at drag start.
19    ///
20    /// Used for editor gizmos where losing intersection with thin handle geometry is common.
21    StartPlaneProjection,
22}
23
24/// Pixel displacement below which a DragEnd is also emitted as a Click.
25const CLICK_THRESHOLD_PX: f32 = 8.0;
26/// World-space displacement below which a DragEnd is also emitted as a Click (non-screen pointers).
27const CLICK_THRESHOLD_WORLD: f32 = 0.02;
28
29#[derive(Debug, Default, Clone)]
30pub struct GestureState {
31    pub dragging: bool,
32    pub drag_raycaster: Option<ComponentId>,
33    pub drag_renderable: Option<ComponentId>,
34    /// First click-capable hit at DragStart. Click is dispatched here, not to `drag_renderable`,
35    /// so a DragOnly plane in front of rows doesn't swallow clicks.
36    pub click_renderable: Option<ComponentId>,
37    pub last_hit_point: Option<[f32; 3]>,
38
39    // Start-plane projection drag mode state.
40    pub last_cursor_pos: Option<(f32, f32)>,
41    pub drag_plane_point_world: Option<[f32; 3]>,
42    pub drag_plane_normal_world: Option<[f32; 3]>,
43
44    // Click detection: position at DragStart.
45    pub drag_start_screen_pos: Option<(f32, f32)>,
46    pub drag_start_hit_point: Option<[f32; 3]>,
47}
48
49#[derive(Debug)]
50pub struct GestureSystem {
51    /// Per-pointer gesture state, keyed by PointerComponent id.
52    states: HashMap<ComponentId, GestureState>,
53    pub drag_update_policy: DragUpdatePolicy,
54
55    /// All ray hits this frame, sorted by interaction priority first, then front-to-back by t.
56    /// Each entry: (priority, t, raycaster, renderable, origin, dir, pointer_events).
57    ray_hits_sorted: Arc<
58        Mutex<
59            Vec<(
60                u8,
61                f32,
62                ComponentId,
63                ComponentId,
64                [f32; 3],
65                [f32; 3],
66                PointerEvents,
67            )>,
68        >,
69    >,
70    immediate_handlers_installed: bool,
71}
72
73impl GestureSystem {
74    fn debug_gesture_enabled() -> bool {
75        static ENABLED: OnceLock<bool> = OnceLock::new();
76        *ENABLED.get_or_init(|| {
77            let v = std::env::var("CAT_DEBUG_GESTURE").unwrap_or_default();
78            matches!(
79                v.trim().to_ascii_lowercase().as_str(),
80                "1" | "true" | "yes" | "on"
81            )
82        })
83    }
84
85    pub fn begin_frame(&mut self) {
86        if let Ok(mut hits) = self.ray_hits_sorted.lock() {
87            hits.clear();
88        }
89    }
90
91    /// Install drain-point handlers into `RxWorld`.
92    pub fn install_handlers(&mut self, rx: &mut RxWorld) {
93        if self.immediate_handlers_installed {
94            return;
95        }
96
97        let hits_ref = self.ray_hits_sorted.clone();
98        rx.add_global_handler_closure(SignalKind::RayIntersected, move |world, _emit, env| {
99            let Some(EventSignal::RayIntersected {
100                raycaster,
101                renderable,
102                t,
103                origin,
104                dir,
105            }) = env.event.as_ref()
106            else {
107                return;
108            };
109
110            if !t.is_finite() || *t < 0.0 {
111                return;
112            }
113
114            let (priority, pe) = BvhSystem::find_raycastable_for_renderable(world, *renderable)
115                .map(|rc| (rc.interaction_priority, rc.pointer_events))
116                .unwrap_or((0, PointerEvents::All));
117
118            let Ok(mut hits) = hits_ref.lock() else {
119                return;
120            };
121            let entry = (priority, *t, *raycaster, *renderable, *origin, *dir, pe);
122            let pos = hits.partition_point(|h| h.0 > priority || (h.0 == priority && h.1 < *t));
123            hits.insert(pos, entry);
124        });
125
126        self.immediate_handlers_installed = true;
127    }
128
129    /// Returns the gesture state for the first active pointer, for callers that only care about
130    /// a single pointer (e.g. editor gizmos, cursor 3D).
131    pub fn state(&self) -> &GestureState {
132        // Return the first dragging state if any, otherwise any state, otherwise a default.
133        self.states
134            .values()
135            .find(|s| s.dragging)
136            .or_else(|| self.states.values().next())
137            .unwrap_or(&EMPTY_GESTURE_STATE)
138    }
139
140    pub fn set_drag_update_policy(&mut self, policy: DragUpdatePolicy) {
141        self.drag_update_policy = policy;
142    }
143
144    fn mat4_mul(a: [[f32; 4]; 4], b: [[f32; 4]; 4]) -> [[f32; 4]; 4] {
145        let mut out = [[0.0f32; 4]; 4];
146        for c in 0..4 {
147            for r in 0..4 {
148                out[c][r] =
149                    a[0][r] * b[c][0] + a[1][r] * b[c][1] + a[2][r] * b[c][2] + a[3][r] * b[c][3];
150            }
151        }
152        out
153    }
154
155    fn mat4_mul_vec4(m: [[f32; 4]; 4], v: [f32; 4]) -> [f32; 4] {
156        [
157            m[0][0] * v[0] + m[1][0] * v[1] + m[2][0] * v[2] + m[3][0] * v[3],
158            m[0][1] * v[0] + m[1][1] * v[1] + m[2][1] * v[2] + m[3][1] * v[3],
159            m[0][2] * v[0] + m[1][2] * v[1] + m[2][2] * v[2] + m[3][2] * v[3],
160            m[0][3] * v[0] + m[1][3] * v[1] + m[2][3] * v[2] + m[3][3] * v[3],
161        ]
162    }
163
164    fn vec3_dot(a: [f32; 3], b: [f32; 3]) -> f32 {
165        a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
166    }
167
168    fn ray_from_cursor(visuals: &VisualWorld, input: &InputState) -> Option<([f32; 3], [f32; 3])> {
169        let vp = visuals.viewport();
170        let w = vp[0];
171        let h = vp[1];
172        if w <= 0.0 || h <= 0.0 {
173            return None;
174        }
175
176        let (cx, cy) = input.cursor_pos.unwrap_or((w * 0.5, h * 0.5));
177
178        let x_ndc = (2.0 * (cx / w)) - 1.0;
179        let y_ndc = 1.0 - (2.0 * (cy / h));
180
181        let view = visuals.camera_view();
182        let proj = visuals.camera_proj();
183        let vp_mat = Self::mat4_mul(proj, view);
184        let inv_vp = math::mat4_inverse(vp_mat)?;
185
186        let near_clip = [x_ndc, y_ndc, 0.0, 1.0];
187        let far_clip = [x_ndc, y_ndc, 1.0, 1.0];
188
189        let near_world4 = Self::mat4_mul_vec4(inv_vp, near_clip);
190        let far_world4 = Self::mat4_mul_vec4(inv_vp, far_clip);
191
192        let near_w = near_world4[3];
193        let far_w = far_world4[3];
194        if near_w == 0.0 || far_w == 0.0 {
195            return None;
196        }
197
198        let near = [
199            near_world4[0] / near_w,
200            near_world4[1] / near_w,
201            near_world4[2] / near_w,
202        ];
203        let far = [
204            far_world4[0] / far_w,
205            far_world4[1] / far_w,
206            far_world4[2] / far_w,
207        ];
208
209        let dir = math::vec3_normalize([far[0] - near[0], far[1] - near[1], far[2] - near[2]]);
210        Some((near, dir))
211    }
212
213    fn ray_plane_intersect(
214        origin: [f32; 3],
215        dir: [f32; 3],
216        plane_point: [f32; 3],
217        plane_normal: [f32; 3],
218    ) -> Option<[f32; 3]> {
219        let denom = Self::vec3_dot(plane_normal, dir);
220        if denom.abs() < 1e-6 {
221            return None;
222        }
223        let op = [
224            plane_point[0] - origin[0],
225            plane_point[1] - origin[1],
226            plane_point[2] - origin[2],
227        ];
228        let t = Self::vec3_dot(plane_normal, op) / denom;
229        if !t.is_finite() {
230            return None;
231        }
232        Some([
233            origin[0] + dir[0] * t,
234            origin[1] + dir[1] * t,
235            origin[2] + dir[2] * t,
236        ])
237    }
238
239    /// Consume RayIntersected signals and PointerActivations to emit DragStart/DragMove/DragEnd/Click.
240    ///
241    /// `input` is still passed for `cursor_pos` (screen-space fields on desktop pointer events).
242    /// `activations` drives press/down/release for each pointer regardless of input source.
243    pub fn tick_with_rx(
244        &mut self,
245        visuals: &VisualWorld,
246        input: &InputState,
247        activations: &PointerActivations,
248        pointer_system: &PointerSystem,
249        rx: &mut RxWorld,
250    ) {
251        let hits: Vec<(
252            u8,
253            f32,
254            ComponentId,
255            ComponentId,
256            [f32; 3],
257            [f32; 3],
258            PointerEvents,
259        )> = self
260            .ray_hits_sorted
261            .lock()
262            .ok()
263            .map(|g| g.clone())
264            .unwrap_or_default();
265
266        // --- Press: start a new drag per activated pointer ---
267        for &pointer_cid in &activations.pressed {
268            // Only start a gesture if this pointer isn't already dragging.
269            if self
270                .states
271                .get(&pointer_cid)
272                .map(|s| s.dragging)
273                .unwrap_or(false)
274            {
275                continue;
276            }
277
278            let Some(raycaster_cid) = pointer_system.raycast_for_pointer(pointer_cid) else {
279                continue;
280            };
281
282            // Hits from this pointer's raycaster only.
283            let pointer_hits: Vec<_> = hits.iter().filter(|h| h.2 == raycaster_cid).collect();
284
285            let drag_hit = pointer_hits.iter().find(|h| h.6.captures_drag());
286            let click_hit = pointer_hits.iter().find(|h| h.6.captures_click());
287            if Self::debug_gesture_enabled() {
288                let summary: Vec<String> = pointer_hits
289                    .iter()
290                    .take(8)
291                    .map(|h| format!("{:?} t={:.3} pri={} pe={:?}", h.3, h.1, h.0, h.6))
292                    .collect();
293                eprintln!(
294                    "[gesture] press pointer={:?} raycaster={:?} drag_hit={:?} click_hit={:?} hits={}",
295                    pointer_cid,
296                    raycaster_cid,
297                    drag_hit.map(|h| h.3),
298                    click_hit.map(|h| h.3),
299                    if summary.is_empty() {
300                        "<none>".to_string()
301                    } else {
302                        summary.join(" | ")
303                    }
304                );
305            }
306
307            let Some(&&(_priority, t, raycaster, renderable, origin, dir, _pe)) = drag_hit else {
308                continue;
309            };
310
311            let drag_hit_point = Some([
312                origin[0] + dir[0] * t,
313                origin[1] + dir[1] * t,
314                origin[2] + dir[2] * t,
315            ]);
316
317            // Determine if this is a screen-space pointer (has cursor_pos).
318            let screen_pos = input.cursor_pos;
319            let is_screen_pointer = screen_pos.is_some();
320
321            let state = self.states.entry(pointer_cid).or_default();
322            state.dragging = true;
323            state.drag_raycaster = Some(raycaster);
324            state.drag_renderable = Some(renderable);
325            state.click_renderable = click_hit.map(|h| h.3);
326            state.last_hit_point = drag_hit_point;
327            state.last_cursor_pos = if is_screen_pointer { screen_pos } else { None };
328            state.drag_start_screen_pos = if is_screen_pointer { screen_pos } else { None };
329            state.drag_start_hit_point = drag_hit_point;
330
331            // StartPlaneProjection only makes sense for screen-space pointers; XR uses RequireTargetContact.
332            if self.drag_update_policy == DragUpdatePolicy::StartPlaneProjection
333                && is_screen_pointer
334            {
335                let n = math::vec3_normalize(dir);
336                state.drag_plane_point_world = drag_hit_point;
337                state.drag_plane_normal_world = Some(n);
338                if let Some(p0) = drag_hit_point {
339                    state.last_hit_point = Some(p0);
340                }
341            }
342
343            if let Some(p) = drag_hit_point {
344                rx.push_event(
345                    renderable,
346                    EventSignal::DragStart {
347                        raycaster,
348                        renderable,
349                        hit_point: p,
350                        ray_dir_world: dir,
351                        screen_pos_px: if is_screen_pointer { screen_pos } else { None },
352                    },
353                );
354            }
355        }
356
357        // --- Down: continue active drags ---
358        let active_pointers: Vec<ComponentId> = self.states.keys().copied().collect();
359        for pointer_cid in active_pointers {
360            let is_down = activations.down.contains(&pointer_cid);
361            let is_released = activations.released.contains(&pointer_cid);
362
363            // Move drag.
364            if is_down {
365                let (Some(active_rc), Some(active_renderable)) = ({
366                    let s = self.states.get(&pointer_cid).unwrap();
367                    (s.drag_raycaster, s.drag_renderable)
368                }) else {
369                    self.states.remove(&pointer_cid);
370                    continue;
371                };
372
373                if !self
374                    .states
375                    .get(&pointer_cid)
376                    .map(|s| s.dragging)
377                    .unwrap_or(false)
378                {
379                    continue;
380                }
381
382                let pointer_hits: Vec<_> = hits.iter().filter(|h| h.2 == active_rc).collect();
383                let is_screen_pointer = self
384                    .states
385                    .get(&pointer_cid)
386                    .and_then(|s| s.drag_start_screen_pos)
387                    .is_some();
388
389                let effective_policy = if is_screen_pointer {
390                    self.drag_update_policy
391                } else {
392                    DragUpdatePolicy::RequireTargetContact
393                };
394
395                match effective_policy {
396                    DragUpdatePolicy::RequireTargetContact => {
397                        let target_hit = pointer_hits
398                            .iter()
399                            .find(|h| h.2 == active_rc && h.3 == active_renderable);
400                        if let Some(&(_priority, t, _rc, _r, origin, dir, _pe)) =
401                            target_hit.copied()
402                        {
403                            let cur = [
404                                origin[0] + dir[0] * t,
405                                origin[1] + dir[1] * t,
406                                origin[2] + dir[2] * t,
407                            ];
408                            let state = self.states.get_mut(&pointer_cid).unwrap();
409                            if let Some(prev) = state.last_hit_point {
410                                let delta = [cur[0] - prev[0], cur[1] - prev[1], cur[2] - prev[2]];
411                                if delta[0] != 0.0 || delta[1] != 0.0 || delta[2] != 0.0 {
412                                    let screen_pos_px = if is_screen_pointer {
413                                        input.cursor_pos
414                                    } else {
415                                        None
416                                    };
417                                    let screen_delta_px = if is_screen_pointer {
418                                        match (state.last_cursor_pos, screen_pos_px) {
419                                            (Some((px, py)), Some((cx, cy))) => {
420                                                Some((cx - px, cy - py))
421                                            }
422                                            _ => None,
423                                        }
424                                    } else {
425                                        None
426                                    };
427                                    rx.push_event(
428                                        active_renderable,
429                                        EventSignal::DragMove {
430                                            raycaster: active_rc,
431                                            renderable: active_renderable,
432                                            hit_point: cur,
433                                            delta_world: delta,
434                                            screen_pos_px,
435                                            screen_delta_px,
436                                        },
437                                    );
438                                }
439                            }
440                            let state = self.states.get_mut(&pointer_cid).unwrap();
441                            state.last_hit_point = Some(cur);
442                            state.last_cursor_pos = input.cursor_pos;
443                        }
444                    }
445
446                    DragUpdatePolicy::StartPlaneProjection => {
447                        let Some((o, d)) = Self::ray_from_cursor(visuals, input) else {
448                            if let Some(s) = self.states.get_mut(&pointer_cid) {
449                                s.last_cursor_pos = input.cursor_pos;
450                            }
451                            continue;
452                        };
453
454                        let (pp, pn) = {
455                            let s = self.states.get(&pointer_cid).unwrap();
456                            (s.drag_plane_point_world, s.drag_plane_normal_world)
457                        };
458                        let (Some(pp), Some(pn)) = (pp, pn) else {
459                            if let Some(s) = self.states.get_mut(&pointer_cid) {
460                                s.last_cursor_pos = input.cursor_pos;
461                            }
462                            continue;
463                        };
464
465                        let Some(cur) = Self::ray_plane_intersect(o, d, pp, pn) else {
466                            if let Some(s) = self.states.get_mut(&pointer_cid) {
467                                s.last_cursor_pos = input.cursor_pos;
468                            }
469                            continue;
470                        };
471
472                        let state = self.states.get_mut(&pointer_cid).unwrap();
473                        if let Some(prev) = state.last_hit_point {
474                            let delta = [cur[0] - prev[0], cur[1] - prev[1], cur[2] - prev[2]];
475                            if delta[0] != 0.0 || delta[1] != 0.0 || delta[2] != 0.0 {
476                                let screen_pos_px = input.cursor_pos;
477                                let screen_delta_px = match (state.last_cursor_pos, screen_pos_px) {
478                                    (Some((px, py)), Some((cx, cy))) => Some((cx - px, cy - py)),
479                                    _ => None,
480                                };
481                                rx.push_event(
482                                    active_renderable,
483                                    EventSignal::DragMove {
484                                        raycaster: active_rc,
485                                        renderable: active_renderable,
486                                        hit_point: cur,
487                                        delta_world: delta,
488                                        screen_pos_px,
489                                        screen_delta_px,
490                                    },
491                                );
492                            }
493                        }
494                        state.last_hit_point = Some(cur);
495                        state.last_cursor_pos = input.cursor_pos;
496                    }
497                }
498            }
499
500            // End drag.
501            if is_released {
502                if let Some(state) = self.states.get(&pointer_cid) {
503                    if state.dragging {
504                        if let (Some(active_rc), Some(active_renderable)) =
505                            (state.drag_raycaster, state.drag_renderable)
506                        {
507                            rx.push_event(
508                                active_renderable,
509                                EventSignal::DragEnd {
510                                    raycaster: active_rc,
511                                    renderable: active_renderable,
512                                    hit_point: state.last_hit_point,
513                                },
514                            );
515
516                            let is_click = match (state.drag_start_screen_pos, input.cursor_pos) {
517                                (Some((sx, sy)), Some((ex, ey))) => {
518                                    let dx = ex - sx;
519                                    let dy = ey - sy;
520                                    (dx * dx + dy * dy).sqrt() < CLICK_THRESHOLD_PX
521                                }
522                                _ => match (state.drag_start_hit_point, state.last_hit_point) {
523                                    (Some(s), Some(e)) => {
524                                        let d = [e[0] - s[0], e[1] - s[1], e[2] - s[2]];
525                                        (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
526                                            < CLICK_THRESHOLD_WORLD
527                                    }
528                                    _ => false,
529                                },
530                            };
531
532                            if is_click {
533                                let click_target =
534                                    state.click_renderable.unwrap_or(active_renderable);
535                                if Self::debug_gesture_enabled() {
536                                    eprintln!(
537                                        "[gesture] click pointer={:?} raycaster={:?} drag_renderable={:?} click_target={:?}",
538                                        pointer_cid, active_rc, active_renderable, click_target,
539                                    );
540                                }
541                                if let Some(start_hit) = state.drag_start_hit_point {
542                                    rx.push_event(
543                                        click_target,
544                                        EventSignal::Click {
545                                            raycaster: active_rc,
546                                            renderable: click_target,
547                                            hit_point: start_hit,
548                                            screen_pos_px: state.drag_start_screen_pos,
549                                        },
550                                    );
551                                }
552                            }
553                        }
554                    }
555                }
556                self.states.remove(&pointer_cid);
557            }
558        }
559    }
560}
561
562static EMPTY_GESTURE_STATE: GestureState = GestureState {
563    dragging: false,
564    drag_raycaster: None,
565    drag_renderable: None,
566    click_renderable: None,
567    last_hit_point: None,
568    last_cursor_pos: None,
569    drag_plane_point_world: None,
570    drag_plane_normal_world: None,
571    drag_start_screen_pos: None,
572    drag_start_hit_point: None,
573};
574
575impl Default for GestureSystem {
576    fn default() -> Self {
577        Self {
578            states: HashMap::new(),
579            drag_update_policy: DragUpdatePolicy::StartPlaneProjection,
580            ray_hits_sorted: Arc::new(Mutex::new(Vec::new())),
581            immediate_handlers_installed: false,
582        }
583    }
584}