rustial-engine 1.0.0

Framework-agnostic 2.5D map engine for rustial
Documentation
//! Canonical interaction event and target types for Rustial.
//!
//! This module defines the stable engine-owned interaction surface that later
//! runtime systems and renderer integrations build on top of. The types here do
//! not perform hit-testing by themselves; instead they normalize user-facing
//! interaction concepts around existing pick/query results such as [`PickHit`].
//!
//! # Scope of the `v1.0` interaction types
//!
//! The types in this module provide:
//!
//! - event kinds (`click`, `mouseenter`, `mouseleave`, ...)
//! - pointer metadata (mouse, touch, pen)
//! - keyboard-modifier snapshots
//! - stable target identity derived from [`PickHit`]
//! - event payloads that carry screen position, resolved geo position, and the
//!   top hit at dispatch time
//!
//! Later roadmap phases can add an interaction manager and subscription model on
//! top of these types without changing their core semantics.

use crate::camera_projection::CameraProjection;
use crate::picking::{HitCategory, HitProvenance, PickHit};
use rustial_math::{GeoCoord, TileId};

/// Logical screen-space position in pixels relative to the viewport origin.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct ScreenPoint {
    /// X coordinate in logical pixels (`0 = left`).
    pub x: f64,
    /// Y coordinate in logical pixels (`0 = top`).
    pub y: f64,
}

impl ScreenPoint {
    /// Create a new screen-space point.
    pub const fn new(x: f64, y: f64) -> Self {
        Self { x, y }
    }
}

/// Input device class that produced an interaction.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum PointerKind {
    /// Mouse or trackpad cursor interaction.
    #[default]
    Mouse,
    /// Touch interaction.
    Touch,
    /// Stylus or tablet pen interaction.
    Pen,
    /// Unknown or host-defined pointer class.
    Unknown,
}

/// Pointer button snapshot for button-aware interaction events.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum InteractionButton {
    /// Primary / left button.
    Primary,
    /// Secondary / right button.
    Secondary,
    /// Auxiliary / middle button.
    Auxiliary,
    /// Any other host-defined button index.
    Other(u16),
}

/// Keyboard-modifier snapshot carried with an interaction event.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct InteractionModifiers {
    /// Whether shift was pressed.
    pub shift: bool,
    /// Whether control was pressed.
    pub ctrl: bool,
    /// Whether alt was pressed.
    pub alt: bool,
    /// Whether meta / command / windows key was pressed.
    pub meta: bool,
}

impl InteractionModifiers {
    /// Create a new modifier snapshot.
    pub const fn new(shift: bool, ctrl: bool, alt: bool, meta: bool) -> Self {
        Self {
            shift,
            ctrl,
            alt,
            meta,
        }
    }

    /// Whether any modifier key is active.
    pub const fn any(self) -> bool {
        self.shift || self.ctrl || self.alt || self.meta
    }
}

/// Canonical interaction event kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum InteractionEventKind {
    /// Cursor entered a target.
    MouseEnter,
    /// Cursor left a target.
    MouseLeave,
    /// Cursor moved within the viewport.
    MouseMove,
    /// Cursor moved over a target.
    MouseOver,
    /// Cursor moved out of a target.
    MouseOut,
    /// Pointer or mouse button pressed.
    MouseDown,
    /// Pointer or mouse button released.
    MouseUp,
    /// Click or tap activation.
    Click,
    /// Double-click activation.
    DoubleClick,
    /// Secondary-click / context-menu activation.
    ContextMenu,
    /// Touch sequence started.
    TouchStart,
    /// Touch sequence moved.
    TouchMove,
    /// Touch sequence ended.
    TouchEnd,
    /// Touch sequence was canceled.
    TouchCancel,
}

impl InteractionEventKind {
    /// Return the canonical string name used by web-map interaction APIs.
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::MouseEnter => "mouseenter",
            Self::MouseLeave => "mouseleave",
            Self::MouseMove => "mousemove",
            Self::MouseOver => "mouseover",
            Self::MouseOut => "mouseout",
            Self::MouseDown => "mousedown",
            Self::MouseUp => "mouseup",
            Self::Click => "click",
            Self::DoubleClick => "dblclick",
            Self::ContextMenu => "contextmenu",
            Self::TouchStart => "touchstart",
            Self::TouchMove => "touchmove",
            Self::TouchEnd => "touchend",
            Self::TouchCancel => "touchcancel",
        }
    }

    /// Return the canonical internal event kind used for hover transitions.
    ///
    /// `mouseover` and `mouseout` map to the stricter `mouseenter` and
    /// `mouseleave` forms so later runtime code can normalize alias handling.
    pub const fn canonical(self) -> Self {
        match self {
            Self::MouseOver => Self::MouseEnter,
            Self::MouseOut => Self::MouseLeave,
            other => other,
        }
    }

    /// Whether this event kind represents hover-related pointer movement.
    pub const fn is_hover_event(self) -> bool {
        matches!(
            self,
            Self::MouseEnter | Self::MouseLeave | Self::MouseMove | Self::MouseOver | Self::MouseOut
        )
    }
}

/// Broad interaction target class.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum InteractionTargetKind {
    /// Terrain surface target.
    Terrain,
    /// Vector feature target.
    Feature,
    /// Placed symbol target.
    Symbol,
    /// 3D model target.
    Model,
}

/// Stable identity and provenance for an interaction target.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct InteractionTarget {
    /// Broad target class.
    pub kind: InteractionTargetKind,
    /// How the hit was resolved.
    pub provenance: HitProvenance,
    /// Style layer id or runtime layer name that produced the target.
    pub layer_id: Option<String>,
    /// Style source id, when known.
    pub source_id: Option<String>,
    /// Style source-layer id, when known.
    pub source_layer: Option<String>,
    /// Tile that supplied the feature, when known.
    pub source_tile: Option<TileId>,
    /// Stable feature id within the source.
    pub feature_id: Option<String>,
    /// Source-local feature index.
    pub feature_index: Option<usize>,
    /// Whether the hit came from a placed symbol collision box.
    pub from_symbol: bool,
}

impl InteractionTarget {
    /// Build an interaction target identity from a pick hit.
    pub fn from_pick_hit(hit: &PickHit) -> Self {
        Self {
            kind: InteractionTargetKind::from_hit_category(hit.category),
            provenance: hit.provenance,
            layer_id: hit.layer_id.clone(),
            source_id: hit.source_id.clone(),
            source_layer: hit.source_layer.clone(),
            source_tile: hit.source_tile,
            feature_id: hit.feature_id.clone(),
            feature_index: hit.feature_index,
            from_symbol: hit.from_symbol,
        }
    }

    /// Whether the target resolves to a source feature identity.
    pub fn is_feature_backed(&self) -> bool {
        self.source_id.is_some() && self.feature_id.is_some()
    }
}

impl InteractionTargetKind {
    /// Derive an interaction target kind from a pick-hit category.
    pub const fn from_hit_category(category: HitCategory) -> Self {
        match category {
            HitCategory::Terrain => Self::Terrain,
            HitCategory::Feature => Self::Feature,
            HitCategory::Symbol => Self::Symbol,
            HitCategory::Model => Self::Model,
        }
    }
}

/// Canonical interaction event payload.
#[derive(Debug, Clone)]
pub struct InteractionEvent {
    /// Event kind.
    pub kind: InteractionEventKind,
    /// Input device class that produced the event.
    pub pointer_kind: PointerKind,
    /// Logical screen-space event location.
    pub screen_point: ScreenPoint,
    /// Resolved geographic coordinate, when available.
    pub query_coord: Option<GeoCoord>,
    /// Camera projection active at dispatch time.
    pub projection: Option<CameraProjection>,
    /// Pressed button for button-aware events.
    pub button: Option<InteractionButton>,
    /// Keyboard-modifier snapshot at dispatch time.
    pub modifiers: InteractionModifiers,
    /// Current target of the interaction, when any.
    pub target: Option<InteractionTarget>,
    /// Related target for enter/leave-style transitions, when any.
    pub related_target: Option<InteractionTarget>,
    /// Top-priority hit associated with the interaction, when any.
    pub hit: Option<PickHit>,
}

impl InteractionEvent {
    /// Create a new interaction event with no target or resolved hit attached.
    pub fn new(kind: InteractionEventKind, pointer_kind: PointerKind, screen_point: ScreenPoint) -> Self {
        Self {
            kind,
            pointer_kind,
            screen_point,
            query_coord: None,
            projection: None,
            button: None,
            modifiers: InteractionModifiers::default(),
            target: None,
            related_target: None,
            hit: None,
        }
    }

    /// Attach a resolved geographic query coordinate.
    pub fn with_query_coord(mut self, query_coord: GeoCoord) -> Self {
        self.query_coord = Some(query_coord);
        self
    }

    /// Attach the active camera projection.
    pub fn with_projection(mut self, projection: CameraProjection) -> Self {
        self.projection = Some(projection);
        self
    }

    /// Attach button metadata.
    pub fn with_button(mut self, button: InteractionButton) -> Self {
        self.button = Some(button);
        self
    }

    /// Attach keyboard modifiers.
    pub fn with_modifiers(mut self, modifiers: InteractionModifiers) -> Self {
        self.modifiers = modifiers;
        self
    }

    /// Attach a top-priority pick hit and derive the current interaction target.
    pub fn with_hit(mut self, hit: PickHit) -> Self {
        self.target = Some(InteractionTarget::from_pick_hit(&hit));
        self.hit = Some(hit);
        self
    }

    /// Attach a related target for enter/leave-style transitions.
    pub fn with_related_target(mut self, related_target: InteractionTarget) -> Self {
        self.related_target = Some(related_target);
        self
    }

    /// Whether this event currently targets something queryable.
    pub fn has_target(&self) -> bool {
        self.target.is_some()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::picking::PickHit;
    use std::collections::HashMap;

    #[test]
    fn mouseover_and_mouseout_canonicalize_to_enter_leave() {
        assert_eq!(
            InteractionEventKind::MouseOver.canonical(),
            InteractionEventKind::MouseEnter
        );
        assert_eq!(
            InteractionEventKind::MouseOut.canonical(),
            InteractionEventKind::MouseLeave
        );
    }

    #[test]
    fn interaction_target_kind_tracks_pick_hit_category() {
        let hit = PickHit {
            category: HitCategory::Symbol,
            provenance: HitProvenance::GeometricApproximation,
            layer_id: Some("places".into()),
            source_id: Some("composite".into()),
            source_layer: Some("place_label".into()),
            source_tile: None,
            feature_id: Some("42".into()),
            feature_index: Some(3),
            geometry: None,
            properties: HashMap::new(),
            state: HashMap::new(),
            distance_meters: 0.0,
            hit_coord: None,
            layer_priority: 0,
            from_symbol: true,
        };

        let target = InteractionTarget::from_pick_hit(&hit);
        assert_eq!(target.kind, InteractionTargetKind::Symbol);
        assert!(target.is_feature_backed());
        assert!(target.from_symbol);
    }

    #[test]
    fn interaction_event_with_hit_populates_target() {
        let hit = PickHit::terrain_surface(GeoCoord::from_lat_lon(10.0, 20.0), Some(25.0));
        let event = InteractionEvent::new(
            InteractionEventKind::MouseMove,
            PointerKind::Mouse,
            ScreenPoint::new(10.0, 20.0),
        )
        .with_hit(hit);

        assert!(event.has_target());
        assert_eq!(
            event.target.as_ref().map(|target| target.kind),
            Some(InteractionTargetKind::Terrain)
        );
        assert_eq!(event.kind.as_str(), "mousemove");
    }

    #[test]
    fn modifiers_any_detects_active_modifier() {
        assert!(!InteractionModifiers::default().any());
        assert!(InteractionModifiers::new(false, true, false, false).any());
    }
}