Skip to main content

rustial_engine/
interaction.rs

1//! Canonical interaction event and target types for Rustial.
2//!
3//! This module defines the stable engine-owned interaction surface that later
4//! runtime systems and renderer integrations build on top of. The types here do
5//! not perform hit-testing by themselves; instead they normalize user-facing
6//! interaction concepts around existing pick/query results such as [`PickHit`].
7//!
8//! # Scope of the `v1.0` interaction types
9//!
10//! The types in this module provide:
11//!
12//! - event kinds (`click`, `mouseenter`, `mouseleave`, ...)
13//! - pointer metadata (mouse, touch, pen)
14//! - keyboard-modifier snapshots
15//! - stable target identity derived from [`PickHit`]
16//! - event payloads that carry screen position, resolved geo position, and the
17//!   top hit at dispatch time
18//!
19//! Later roadmap phases can add an interaction manager and subscription model on
20//! top of these types without changing their core semantics.
21
22use crate::camera_projection::CameraProjection;
23use crate::picking::{HitCategory, HitProvenance, PickHit};
24use rustial_math::{GeoCoord, TileId};
25
26/// Logical screen-space position in pixels relative to the viewport origin.
27#[derive(Debug, Clone, Copy, PartialEq, Default)]
28pub struct ScreenPoint {
29    /// X coordinate in logical pixels (`0 = left`).
30    pub x: f64,
31    /// Y coordinate in logical pixels (`0 = top`).
32    pub y: f64,
33}
34
35impl ScreenPoint {
36    /// Create a new screen-space point.
37    pub const fn new(x: f64, y: f64) -> Self {
38        Self { x, y }
39    }
40}
41
42/// Input device class that produced an interaction.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
44pub enum PointerKind {
45    /// Mouse or trackpad cursor interaction.
46    #[default]
47    Mouse,
48    /// Touch interaction.
49    Touch,
50    /// Stylus or tablet pen interaction.
51    Pen,
52    /// Unknown or host-defined pointer class.
53    Unknown,
54}
55
56/// Pointer button snapshot for button-aware interaction events.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
58pub enum InteractionButton {
59    /// Primary / left button.
60    Primary,
61    /// Secondary / right button.
62    Secondary,
63    /// Auxiliary / middle button.
64    Auxiliary,
65    /// Any other host-defined button index.
66    Other(u16),
67}
68
69/// Keyboard-modifier snapshot carried with an interaction event.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
71pub struct InteractionModifiers {
72    /// Whether shift was pressed.
73    pub shift: bool,
74    /// Whether control was pressed.
75    pub ctrl: bool,
76    /// Whether alt was pressed.
77    pub alt: bool,
78    /// Whether meta / command / windows key was pressed.
79    pub meta: bool,
80}
81
82impl InteractionModifiers {
83    /// Create a new modifier snapshot.
84    pub const fn new(shift: bool, ctrl: bool, alt: bool, meta: bool) -> Self {
85        Self {
86            shift,
87            ctrl,
88            alt,
89            meta,
90        }
91    }
92
93    /// Whether any modifier key is active.
94    pub const fn any(self) -> bool {
95        self.shift || self.ctrl || self.alt || self.meta
96    }
97}
98
99/// Canonical interaction event kind.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
101pub enum InteractionEventKind {
102    /// Cursor entered a target.
103    MouseEnter,
104    /// Cursor left a target.
105    MouseLeave,
106    /// Cursor moved within the viewport.
107    MouseMove,
108    /// Cursor moved over a target.
109    MouseOver,
110    /// Cursor moved out of a target.
111    MouseOut,
112    /// Pointer or mouse button pressed.
113    MouseDown,
114    /// Pointer or mouse button released.
115    MouseUp,
116    /// Click or tap activation.
117    Click,
118    /// Double-click activation.
119    DoubleClick,
120    /// Secondary-click / context-menu activation.
121    ContextMenu,
122    /// Touch sequence started.
123    TouchStart,
124    /// Touch sequence moved.
125    TouchMove,
126    /// Touch sequence ended.
127    TouchEnd,
128    /// Touch sequence was canceled.
129    TouchCancel,
130}
131
132impl InteractionEventKind {
133    /// Return the canonical string name used by web-map interaction APIs.
134    pub const fn as_str(self) -> &'static str {
135        match self {
136            Self::MouseEnter => "mouseenter",
137            Self::MouseLeave => "mouseleave",
138            Self::MouseMove => "mousemove",
139            Self::MouseOver => "mouseover",
140            Self::MouseOut => "mouseout",
141            Self::MouseDown => "mousedown",
142            Self::MouseUp => "mouseup",
143            Self::Click => "click",
144            Self::DoubleClick => "dblclick",
145            Self::ContextMenu => "contextmenu",
146            Self::TouchStart => "touchstart",
147            Self::TouchMove => "touchmove",
148            Self::TouchEnd => "touchend",
149            Self::TouchCancel => "touchcancel",
150        }
151    }
152
153    /// Return the canonical internal event kind used for hover transitions.
154    ///
155    /// `mouseover` and `mouseout` map to the stricter `mouseenter` and
156    /// `mouseleave` forms so later runtime code can normalize alias handling.
157    pub const fn canonical(self) -> Self {
158        match self {
159            Self::MouseOver => Self::MouseEnter,
160            Self::MouseOut => Self::MouseLeave,
161            other => other,
162        }
163    }
164
165    /// Whether this event kind represents hover-related pointer movement.
166    pub const fn is_hover_event(self) -> bool {
167        matches!(
168            self,
169            Self::MouseEnter
170                | Self::MouseLeave
171                | Self::MouseMove
172                | Self::MouseOver
173                | Self::MouseOut
174        )
175    }
176}
177
178/// Broad interaction target class.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
180pub enum InteractionTargetKind {
181    /// Terrain surface target.
182    Terrain,
183    /// Vector feature target.
184    Feature,
185    /// Placed symbol target.
186    Symbol,
187    /// 3D model target.
188    Model,
189}
190
191/// Stable identity and provenance for an interaction target.
192#[derive(Debug, Clone, PartialEq, Eq, Hash)]
193pub struct InteractionTarget {
194    /// Broad target class.
195    pub kind: InteractionTargetKind,
196    /// How the hit was resolved.
197    pub provenance: HitProvenance,
198    /// Style layer id or runtime layer name that produced the target.
199    pub layer_id: Option<String>,
200    /// Style source id, when known.
201    pub source_id: Option<String>,
202    /// Style source-layer id, when known.
203    pub source_layer: Option<String>,
204    /// Tile that supplied the feature, when known.
205    pub source_tile: Option<TileId>,
206    /// Stable feature id within the source.
207    pub feature_id: Option<String>,
208    /// Source-local feature index.
209    pub feature_index: Option<usize>,
210    /// Whether the hit came from a placed symbol collision box.
211    pub from_symbol: bool,
212}
213
214impl InteractionTarget {
215    /// Build an interaction target identity from a pick hit.
216    pub fn from_pick_hit(hit: &PickHit) -> Self {
217        Self {
218            kind: InteractionTargetKind::from_hit_category(hit.category),
219            provenance: hit.provenance,
220            layer_id: hit.layer_id.clone(),
221            source_id: hit.source_id.clone(),
222            source_layer: hit.source_layer.clone(),
223            source_tile: hit.source_tile,
224            feature_id: hit.feature_id.clone(),
225            feature_index: hit.feature_index,
226            from_symbol: hit.from_symbol,
227        }
228    }
229
230    /// Whether the target resolves to a source feature identity.
231    pub fn is_feature_backed(&self) -> bool {
232        self.source_id.is_some() && self.feature_id.is_some()
233    }
234}
235
236impl InteractionTargetKind {
237    /// Derive an interaction target kind from a pick-hit category.
238    pub const fn from_hit_category(category: HitCategory) -> Self {
239        match category {
240            HitCategory::Terrain => Self::Terrain,
241            HitCategory::Feature => Self::Feature,
242            HitCategory::Symbol => Self::Symbol,
243            HitCategory::Model => Self::Model,
244        }
245    }
246}
247
248/// Canonical interaction event payload.
249#[derive(Debug, Clone)]
250pub struct InteractionEvent {
251    /// Event kind.
252    pub kind: InteractionEventKind,
253    /// Input device class that produced the event.
254    pub pointer_kind: PointerKind,
255    /// Logical screen-space event location.
256    pub screen_point: ScreenPoint,
257    /// Resolved geographic coordinate, when available.
258    pub query_coord: Option<GeoCoord>,
259    /// Camera projection active at dispatch time.
260    pub projection: Option<CameraProjection>,
261    /// Pressed button for button-aware events.
262    pub button: Option<InteractionButton>,
263    /// Keyboard-modifier snapshot at dispatch time.
264    pub modifiers: InteractionModifiers,
265    /// Current target of the interaction, when any.
266    pub target: Option<InteractionTarget>,
267    /// Related target for enter/leave-style transitions, when any.
268    pub related_target: Option<InteractionTarget>,
269    /// Top-priority hit associated with the interaction, when any.
270    pub hit: Option<PickHit>,
271}
272
273impl InteractionEvent {
274    /// Create a new interaction event with no target or resolved hit attached.
275    pub fn new(
276        kind: InteractionEventKind,
277        pointer_kind: PointerKind,
278        screen_point: ScreenPoint,
279    ) -> Self {
280        Self {
281            kind,
282            pointer_kind,
283            screen_point,
284            query_coord: None,
285            projection: None,
286            button: None,
287            modifiers: InteractionModifiers::default(),
288            target: None,
289            related_target: None,
290            hit: None,
291        }
292    }
293
294    /// Attach a resolved geographic query coordinate.
295    pub fn with_query_coord(mut self, query_coord: GeoCoord) -> Self {
296        self.query_coord = Some(query_coord);
297        self
298    }
299
300    /// Attach the active camera projection.
301    pub fn with_projection(mut self, projection: CameraProjection) -> Self {
302        self.projection = Some(projection);
303        self
304    }
305
306    /// Attach button metadata.
307    pub fn with_button(mut self, button: InteractionButton) -> Self {
308        self.button = Some(button);
309        self
310    }
311
312    /// Attach keyboard modifiers.
313    pub fn with_modifiers(mut self, modifiers: InteractionModifiers) -> Self {
314        self.modifiers = modifiers;
315        self
316    }
317
318    /// Attach a top-priority pick hit and derive the current interaction target.
319    pub fn with_hit(mut self, hit: PickHit) -> Self {
320        self.target = Some(InteractionTarget::from_pick_hit(&hit));
321        self.hit = Some(hit);
322        self
323    }
324
325    /// Attach a related target for enter/leave-style transitions.
326    pub fn with_related_target(mut self, related_target: InteractionTarget) -> Self {
327        self.related_target = Some(related_target);
328        self
329    }
330
331    /// Whether this event currently targets something queryable.
332    pub fn has_target(&self) -> bool {
333        self.target.is_some()
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use crate::picking::PickHit;
341    use std::collections::HashMap;
342
343    #[test]
344    fn mouseover_and_mouseout_canonicalize_to_enter_leave() {
345        assert_eq!(
346            InteractionEventKind::MouseOver.canonical(),
347            InteractionEventKind::MouseEnter
348        );
349        assert_eq!(
350            InteractionEventKind::MouseOut.canonical(),
351            InteractionEventKind::MouseLeave
352        );
353    }
354
355    #[test]
356    fn interaction_target_kind_tracks_pick_hit_category() {
357        let hit = PickHit {
358            category: HitCategory::Symbol,
359            provenance: HitProvenance::GeometricApproximation,
360            layer_id: Some("places".into()),
361            source_id: Some("composite".into()),
362            source_layer: Some("place_label".into()),
363            source_tile: None,
364            feature_id: Some("42".into()),
365            feature_index: Some(3),
366            geometry: None,
367            properties: HashMap::new(),
368            state: HashMap::new(),
369            distance_meters: 0.0,
370            hit_coord: None,
371            layer_priority: 0,
372            from_symbol: true,
373        };
374
375        let target = InteractionTarget::from_pick_hit(&hit);
376        assert_eq!(target.kind, InteractionTargetKind::Symbol);
377        assert!(target.is_feature_backed());
378        assert!(target.from_symbol);
379    }
380
381    #[test]
382    fn interaction_event_with_hit_populates_target() {
383        let hit = PickHit::terrain_surface(GeoCoord::from_lat_lon(10.0, 20.0), Some(25.0));
384        let event = InteractionEvent::new(
385            InteractionEventKind::MouseMove,
386            PointerKind::Mouse,
387            ScreenPoint::new(10.0, 20.0),
388        )
389        .with_hit(hit);
390
391        assert!(event.has_target());
392        assert_eq!(
393            event.target.as_ref().map(|target| target.kind),
394            Some(InteractionTargetKind::Terrain)
395        );
396        assert_eq!(event.kind.as_str(), "mousemove");
397    }
398
399    #[test]
400    fn modifiers_any_detects_active_modifier() {
401        assert!(!InteractionModifiers::default().any());
402        assert!(InteractionModifiers::new(false, true, false, false).any());
403    }
404}