rustial-renderer-bevy 1.0.0

Bevy Engine renderer for the rustial 2.5D map engine
//! # Built-in viewport sync and input handling systems
//!
//! These two systems bridge Bevy's window and input events to the
//! engine's framework-agnostic [`InputEvent`] protocol, so the host
//! application gets working pan / rotate / zoom out of the box.
//!
//! ## Data flow
//!
//! ```text
//! Bevy Window              Bevy Input Events         Engine
//! -----------              -----------------         ------
//! Window.resolution -----> sync_viewport ----------> Camera.viewport_*
//!
//! CursorMoved (left)  --+
//! MouseMotion (right) --+--> handle_default_input --> MapState.handle_input()
//! MouseWheel         ---+                               |
//! KeyCode::Space     ---+                               v
//!                                                   CameraController
//! ```
//!
//! ## Input mapping
//!
//! | Bevy input | Engine event | Effect |
//! |------------|--------------|--------|
//! | Left-drag (`CursorMoved` delta) | `InputEvent::Pan` | Translate the map |
//! | Right-drag (`MouseMotion`) | `InputEvent::Rotate` | Yaw + pitch the camera |
//! | Mouse wheel (`MouseWheel`) | `InputEvent::Zoom` | Zoom in / out |
//! | Space (just pressed) | direct `camera.mode` toggle | Perspective / orthographic |
//!
//! ## Coordinate and unit conventions
//!
//! Viewport dimensions and pan deltas both use **logical** pixels.
//! This makes the pipeline DPI-independent:
//!
//! - `sync_viewport` reads `Window::resolution.width()` / `.height()`
//!   (logical size).
//! - Pan deltas are derived from `CursorMoved.position` differences,
//!   which Bevy reports in logical pixels.
//! - `Camera::meters_per_pixel()` divides the visible ground-plane
//!   height by the logical viewport height, giving logical meters per
//!   logical pixel.
//!
//! Rotation uses `MouseMotion.delta` (raw device motion) because
//! rotation sensitivity should be the same regardless of DPI -- one
//! physical centimetre of mouse travel should produce the same bearing
//! change on any display.
//!
//! ## Sensitivity constants
//!
//! The rotation and zoom sensitivities are tuned for a standard
//! desktop mouse.  They are exposed as module-level constants so
//! they can be adjusted centrally if the mapping needs recalibrating.
//!
//! ## Scheduling
//!
//! Both systems run in [`PreUpdate`](bevy::prelude::PreUpdate), before
//! `update_map_state`.  This ensures that input from the current frame
//! is applied before the engine ticks and before any `Update`-phase
//! sync system reads derived state.
// ---------------------------------------------------------------------------

use crate::plugin::MapStateResource;
use bevy::ecs::message::MessageReader;
use bevy::input::mouse::{MouseMotion, MouseWheel};
use bevy::input::touch::TouchInput;
use bevy::prelude::*;
use rustial_engine::{CameraMode, InputEvent, TouchPhase as EngineTouchPhase};

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

/// Radians per physical pixel of mouse drag for rotation.
///
/// At 0.005 rad/px a full 1280 px sweep rotates the camera ~6.4 rad
/// (roughly one full turn), giving fine-grained control while still
/// allowing fast rotation with a flick.
const ROTATE_SENSITIVITY: f64 = 0.005;

/// Zoom speed factor per scroll-wheel notch.
///
/// A single notch (`wheel.y = 1.0`) produces a 10 % zoom step.
/// The formula is asymmetric by design:
///
/// - Scroll up:   `factor = 1 + y * ZOOM_SENSITIVITY`   (linear growth)
/// - Scroll down: `factor = 1 / (1 + |y| * ZOOM_SENSITIVITY)`   (reciprocal)
///
/// The reciprocal ensures that scrolling up and then down by the same
/// amount returns to *approximately* the same zoom level, while
/// preventing the factor from reaching zero or going negative.
const ZOOM_SENSITIVITY: f64 = 0.1;

/// Lower clamp for the computed zoom factor.
///
/// Prevents extreme zoom-out from a single high-velocity scroll event.
const ZOOM_FACTOR_MIN: f64 = 0.1;

/// Upper clamp for the computed zoom factor.
///
/// Prevents extreme zoom-in from a single high-velocity scroll event.
const ZOOM_FACTOR_MAX: f64 = 10.0;

// ---------------------------------------------------------------------------
// MapInputEnabled -- public gate for host applications
// ---------------------------------------------------------------------------

/// When set to `false`, [`handle_default_input`] is a no-op for that frame.
///
/// Host applications that own their own input routing (e.g. via an egui
/// scene widget) should insert this resource as `MapInputEnabled(false)` at
/// startup. The renderer plugin initialises it to `true` so existing
/// stand-alone users are unaffected.
#[derive(Resource)]
pub struct MapInputEnabled(pub bool);

impl Default for MapInputEnabled {
    fn default() -> Self {
        Self(true)
    }
}

// ---------------------------------------------------------------------------
// PrevCursorPos -- local resource for tracking cursor delta
// ---------------------------------------------------------------------------

/// Local resource that tracks the previous frame's cursor position in
/// logical pixels.
///
/// `CursorMoved` gives absolute positions, so we derive a delta by
/// subtracting the previous position.  Stored as an `Option` to handle
/// the first frame (and re-entry after the cursor leaves the window).
#[derive(Resource, Default)]
pub(crate) struct PrevCursorPos(pub Option<Vec2>);

// ---------------------------------------------------------------------------
// sync_viewport
// ---------------------------------------------------------------------------

/// Sync the engine viewport size to the Bevy primary window each frame.
///
/// Reads the window's **logical** resolution and writes it to
/// [`Camera::viewport_width`](rustial_engine::Camera::viewport_width) /
/// [`Camera::viewport_height`](rustial_engine::Camera::viewport_height).
///
/// Logical pixels are used because:
///
/// - `CursorMoved.position` is in logical pixels.
/// - Bevy's `PerspectiveProjection::update()` internally handles the
///   physical-to-logical mapping for the actual GPU projection.
/// - Keeping viewport and pan deltas in the same unit system makes
///   `meters_per_pixel()` correct regardless of DPI scale factor.
///
/// The write is skipped when the values have not changed, avoiding a
/// needless mutation of the `Res<MapStateResource>` (which would
/// trigger Bevy's change-detection).
pub(crate) fn sync_viewport(windows: Query<&Window>, mut state: ResMut<MapStateResource>) {
    if let Ok(window) = windows.single() {
        let w = window.resolution.width() as u32;
        let h = window.resolution.height() as u32;
        if w > 0
            && h > 0
            && (state.0.camera().viewport_width() != w || state.0.camera().viewport_height() != h)
        {
            state.0.set_viewport(w, h);
        }
    }
}

// ---------------------------------------------------------------------------
// handle_default_input
// ---------------------------------------------------------------------------

/// Default map input handling: left-drag pan, right-drag rotate, wheel
/// zoom, Space toggles projection mode.
///
/// ## Pan (left-drag)
///
/// Pan deltas are derived from `CursorMoved` position differences
/// (logical pixels), not from `MouseMotion`.  `CursorMoved` is
/// reported in logical pixels on every platform, which matches the
/// logical viewport stored by [`sync_viewport`].  This makes
/// `meters_per_pixel * delta` produce the exact screen-space
/// displacement regardless of DPI, keeping the cursor locked to the
/// map point under it during drag.
///
pub(crate) fn handle_default_input(
    enabled: Res<MapInputEnabled>,
    mouse_buttons: Res<ButtonInput<MouseButton>>,
    keys: Res<ButtonInput<KeyCode>>,
    mut cursor_events: MessageReader<CursorMoved>,
    mut mouse_motion: MessageReader<MouseMotion>,
    mut mouse_wheel: MessageReader<MouseWheel>,
    touch_events: Option<MessageReader<TouchInput>>,
    windows: Query<&Window>,
    mut state: ResMut<MapStateResource>,
    mut prev_cursor: ResMut<PrevCursorPos>,
) {
    if !enabled.0 {
        // Drain all readers so message cursors stay current.
        for _ in cursor_events.read() {}
        for _ in mouse_motion.read() {}
        for _ in mouse_wheel.read() {}
        if let Some(mut touch) = touch_events {
            for _ in touch.read() {}
        }
        return;
    }

    // -- Pan: derive delta from CursorMoved (logical pixels) --------------
    let mut pan_delta = Vec2::ZERO;
    let mut current_prev = prev_cursor.0;

    for event in cursor_events.read() {
        let current = event.position;
        if mouse_buttons.pressed(MouseButton::Left) {
            if let Some(prev) = current_prev {
                pan_delta += current - prev;
            }
        }
        current_prev = Some(current);
    }

    // Update the stored cursor position for the next frame.
    // If no CursorMoved events arrived this frame, keep the previous
    // value so the delta is correct on the next move.
    if let Some(pos) = current_prev {
        prev_cursor.0 = Some(pos);
    }

    // -- Rotation: accumulate from MouseMotion (raw device units) ---------
    let mut rotate_delta = Vec2::ZERO;

    for motion in mouse_motion.read() {
        if mouse_buttons.pressed(MouseButton::Right) {
            rotate_delta += motion.delta;
        }
    }

    // -- Dispatch pan ------------------------------------------------------
    if pan_delta.length_squared() > 0.0 {
        if let Some(pos) = current_prev {
            // Note: we evaluate the pan origin at the *start* of the frame's drag
            // so `pos - pan_delta` is exactly the px position before the drag.
            let x = (pos.x - pan_delta.x) as f64;
            let y = (pos.y - pan_delta.y) as f64;
            state.0.handle_input(InputEvent::pan_at(
                pan_delta.x as f64,
                pan_delta.y as f64,
                x,
                y,
            ));
        } else {
            state.0.handle_input(InputEvent::pan(
                pan_delta.x as f64,
                pan_delta.y as f64,
            ));
        }
    }

    // -- Dispatch rotation ------------------------------------------------
    if rotate_delta.length_squared() > 0.0 {
        state.0.handle_input(InputEvent::Rotate {
            delta_yaw: -(rotate_delta.x as f64) * ROTATE_SENSITIVITY,
            // Dragging down (+Y) decreases pitch (moves towards top-down), 
            // dragging up (-Y) increases pitch (moves towards horizon)
            // This is the standard MapBox/Google Earth behavior.
            delta_pitch: -(rotate_delta.y as f64) * ROTATE_SENSITIVITY,
        });
    }

    // -- Zoom (mouse wheel) -----------------------------------------------
    let zoom_cursor = windows
        .single()
        .ok()
        .and_then(Window::cursor_position)
        .or(current_prev)
        .or(prev_cursor.0);

    for wheel in mouse_wheel.read() {
        let y = wheel.y as f64;
        if y.abs() > f64::EPSILON {
            let raw_factor = if y > 0.0 {
                1.0 + y * ZOOM_SENSITIVITY
            } else {
                1.0 / (1.0 + (-y) * ZOOM_SENSITIVITY)
            };
            if raw_factor.is_finite() && raw_factor > 0.0 {
                let factor = raw_factor.clamp(ZOOM_FACTOR_MIN, ZOOM_FACTOR_MAX);
                if let Some(pos) = zoom_cursor {
                    state.0.handle_input(InputEvent::Zoom {
                        factor,
                        x: Some(pos.x as f64),
                        y: Some(pos.y as f64),
                    });
                } else {
                    state.0.handle_input(InputEvent::Zoom {
                        factor,
                        x: None,
                        y: None,
                    });
                }
            }
        }
    }

    // -- Touch input → engine gesture recognizer --------------------------
    if let Some(mut touch_reader) = touch_events {
        for touch in touch_reader.read() {
            let phase = match touch.phase {
                bevy::input::touch::TouchPhase::Started => EngineTouchPhase::Started,
                bevy::input::touch::TouchPhase::Moved => EngineTouchPhase::Moved,
                bevy::input::touch::TouchPhase::Ended => EngineTouchPhase::Ended,
                bevy::input::touch::TouchPhase::Canceled => EngineTouchPhase::Cancelled,
            };
            state.0.handle_input(InputEvent::touch(
                touch.id,
                phase,
                touch.position.x as f64,
                touch.position.y as f64,
            ));
        }
    }

    // -- Projection toggle (Space) ----------------------------------------
    if keys.just_pressed(KeyCode::Space) {
        let new_mode = match state.0.camera().mode() {
            CameraMode::Perspective => CameraMode::Orthographic,
            CameraMode::Orthographic => CameraMode::Perspective,
        };
        state.0.set_camera_mode(new_mode);
    }
}