Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-map3d-flight/src/control.rs

//! **egui → [`FlyInput`] wiring** — the "actually fly it" layer. A
//! [`FlightController`] maps a frame of keyboard/mouse state into a
//! [`camera_fly::FlyInput`] and drives a [`FlightCamera`], so a live egui app (or a
//! scripted robot-UI test) can pilot the streamed Sweden world:
//!
//! - **WASD** — forward/back + strafe (free-fly) or throttle (flight-stick).
//! - **R / F** — up / down (free-fly).
//! - **Q / E** — roll left / right (flight-stick aileron).
//! - **mouse delta** — yaw (x) + pitch (y) look.
//! - **scroll** — orbit dolly (orbit mode).
//! - **1 / 2 / 3** — select FreeFly / Orbit / FlightStick.
//! - **Shift** — speed boost.
//!
//! ## Deterministic (FC-7)
//! The mapping is a pure function of an injected [`ControlFrame`] (a key/axis fixture,
//! *not* a live clock or RNG) + the injected `dt`. A test builds a `ControlFrame`,
//! asserts the produced [`FlyInput`], and asserts the resulting camera pose — no
//! wall-clock, bit-reproducible. The `egui` feature adds a thin
//! [`ControlFrame::from_egui`] adapter over a real `egui::InputState`; the core needs
//! no egui, keeping the flight crate lean.

use crate::camera_fly::{FlightCamera, FlyInput, FlyMode};

/// One frame of raw control state — the injectable fixture the controller maps. Every
/// field is the *state this frame* (keys held, accumulated mouse/scroll delta); the
/// controller turns it into rates. Build one directly in a test, or via
/// [`ControlFrame::from_egui`] (feature `egui`) from a live `egui::InputState`.
#[derive(Clone, Copy, Debug, Default)]
pub struct ControlFrame {
    /// W held (forward / throttle up).
    pub w: bool,
    /// A held (strafe left).
    pub a: bool,
    /// S held (back / throttle down).
    pub s: bool,
    /// D held (strafe right).
    pub d: bool,
    /// R held (move up).
    pub r: bool,
    /// F held (move down).
    pub f: bool,
    /// Q held (roll left — flight-stick).
    pub q: bool,
    /// E held (roll right — flight-stick).
    pub e: bool,
    /// Shift held (speed boost).
    pub shift: bool,
    /// Mouse motion this frame in **pixels** (x right, y down) — drives look.
    pub mouse_delta: (f32, f32),
    /// Scroll this frame (points; + = zoom in) — orbit dolly.
    pub scroll: f32,
    /// `1` pressed this frame — select FreeFly.
    pub mode_free_fly: bool,
    /// `2` pressed this frame — select Orbit.
    pub mode_orbit: bool,
    /// `3` pressed this frame — select FlightStick.
    pub mode_flight_stick: bool,
}

impl ControlFrame {
    /// The mode hotkey requested this frame, if any (1/2/3). Later keys win if several
    /// are somehow set, but a real frame sets at most one.
    #[must_use]
    pub fn requested_mode(&self) -> Option<FlyMode> {
        if self.mode_flight_stick {
            Some(FlyMode::FlightStick)
        } else if self.mode_orbit {
            Some(FlyMode::Orbit)
        } else if self.mode_free_fly {
            Some(FlyMode::FreeFly)
        } else {
            None
        }
    }
}

/// Maps [`ControlFrame`]s to [`FlyInput`] and pilots a [`FlightCamera`]. Holds the
/// tuning (speeds + sensitivities); pure + deterministic given the injected frame+dt.
#[derive(Clone, Copy, Debug)]
pub struct FlightController {
    /// Free-fly translate speed, metres/second (before boost).
    pub move_speed_mps: f64,
    /// Multiplier applied to translate speed while Shift is held.
    pub boost_mult: f64,
    /// Flight-stick throttle along forward, metres/second.
    pub throttle_mps: f64,
    /// Mouse-look sensitivity, radians per pixel.
    pub look_sensitivity: f64,
    /// Flight-stick roll rate, radians/second (Q/E).
    pub roll_rate: f64,
    /// Orbit dolly rate, metres/second per scroll unit.
    pub dolly_mps: f64,
}

impl Default for FlightController {
    fn default() -> Self {
        Self {
            move_speed_mps: 120.0,
            boost_mult: 8.0,
            throttle_mps: 260.0,
            look_sensitivity: 0.0032,
            roll_rate: 1.4,
            dolly_mps: 40.0,
        }
    }
}

impl FlightController {
    /// Map a control frame into a [`FlyInput`] for `mode`. Pure — the axes each mode
    /// reads are filled from the frame's keys/mouse/scroll and this controller's
    /// tuning; the caller integrates it over `dt`.
    #[must_use]
    pub fn to_fly_input(&self, frame: &ControlFrame, mode: FlyMode) -> FlyInput {
        let axis = |pos: bool, neg: bool| (pos as i32 - neg as i32) as f64;
        let speed = self.move_speed_mps * if frame.shift { self.boost_mult } else { 1.0 };
        // Mouse-look: x → yaw (right +), screen-y down → nose up, so pitch = -dy.
        let yaw_rate = frame.mouse_delta.0 as f64 * self.look_sensitivity;
        let pitch_rate = -(frame.mouse_delta.1 as f64) * self.look_sensitivity;

        match mode {
            FlyMode::FreeFly => FlyInput {
                move_right: axis(frame.d, frame.a) * speed,
                move_up: axis(frame.r, frame.f) * speed,
                move_forward: axis(frame.w, frame.s) * speed,
                yaw_rate,
                pitch_rate,
                ..Default::default()
            },
            FlyMode::FlightStick => FlyInput {
                // The stick reads no strafe/vertical; W/S is throttle along forward.
                move_forward: axis(frame.w, frame.s) * self.throttle_mps,
                yaw_rate,
                pitch_rate,
                roll_rate: axis(frame.e, frame.q) * self.roll_rate,
                ..Default::default()
            },
            FlyMode::Orbit => FlyInput {
                yaw_rate,
                pitch_rate,
                orbit_dolly: frame.scroll as f64 * self.dolly_mps,
                ..Default::default()
            },
        }
    }

    /// Apply one control frame to `cam` over `dt`: switch mode if a hotkey was pressed,
    /// then map + integrate. Returns the [`FlyInput`] that was applied (for assertions
    /// / HUD). Deterministic: same cam+frame+dt → same next pose.
    pub fn apply(&self, cam: &mut FlightCamera, frame: &ControlFrame, dt: f64) -> FlyInput {
        if let Some(m) = frame.requested_mode() {
            cam.set_mode(m);
        }
        let input = self.to_fly_input(frame, cam.mode);
        cam.integrate(&input, dt);
        input
    }
}

/// egui adapter (feature `egui`): read a live `egui::InputState` into a
/// [`ControlFrame`]. Mouse-look uses the pointer delta only while `mouse_captured`
/// (so dragging vs. hovering is the app's call).
#[cfg(feature = "egui")]
impl ControlFrame {
    /// Build a [`ControlFrame`] from an `egui::InputState`. `mouse_captured` gates
    /// whether pointer motion drives look (typically true while a flight viewport has
    /// pointer lock / is being dragged).
    #[must_use]
    pub fn from_egui(input: &egui::InputState, mouse_captured: bool) -> Self {
        use egui::Key;
        let down = |k: Key| input.key_down(k);
        let pressed = |k: Key| input.key_pressed(k);
        let delta = if mouse_captured {
            let d = input.pointer.delta();
            (d.x, d.y)
        } else {
            (0.0, 0.0)
        };
        Self {
            w: down(Key::W),
            a: down(Key::A),
            s: down(Key::S),
            d: down(Key::D),
            r: down(Key::R),
            f: down(Key::F),
            q: down(Key::Q),
            e: down(Key::E),
            shift: input.modifiers.shift,
            mouse_delta: delta,
            scroll: input.smooth_scroll_delta.y,
            mode_free_fly: pressed(Key::Num1),
            mode_orbit: pressed(Key::Num2),
            mode_flight_stick: pressed(Key::Num3),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::origin::WorldPos;

    /// **INJECT-ASSERT (WASD + mouse-look map to FlyInput, and the camera obeys):** a
    /// control frame with W + D + mouse-right produces the right free-fly axes, and
    /// applying it flies the camera forward+right and yaws it — the "actually fly"
    /// contract, deterministic under an injected dt.
    #[test]
    fn flightcontroller_maps_wasd_and_look() {
        let ctl = FlightController::default();
        let mut cam = FlightCamera::free_fly(WorldPos::new(0.0, 0.0, 100.0));
        cam.yaw = 0.0; // looking +X
        let frame = ControlFrame { w: true, d: true, mouse_delta: (10.0, -4.0), ..Default::default() };

        let input = ctl.to_fly_input(&frame, FlyMode::FreeFly);
        assert!((input.move_forward - ctl.move_speed_mps).abs() < 1e-9, "W → +forward at move speed");
        assert!((input.move_right - ctl.move_speed_mps).abs() < 1e-9, "D → +right at move speed");
        assert!((input.yaw_rate - 10.0 * ctl.look_sensitivity).abs() < 1e-12, "mouse-x → yaw");
        assert!((input.pitch_rate - 4.0 * ctl.look_sensitivity).abs() < 1e-12, "mouse-up (−dy) → +pitch");

        // Apply → the pose actually moves forward + right and the heading turns.
        let start = cam.position;
        let applied = ctl.apply(&mut cam, &frame, 0.5);
        assert_eq!(applied.move_forward, input.move_forward, "apply uses the same mapping");
        assert!(cam.position.x > start.x, "flew +X (forward+strafe both have +X here)");
        assert!(cam.yaw > 0.0, "mouse-look yawed right");
        // Determinism: replay from the same state → identical pose.
        let mut a = FlightCamera::free_fly(WorldPos::new(0.0, 0.0, 0.0));
        let mut b = a.clone();
        ctl.apply(&mut a, &frame, 0.016);
        ctl.apply(&mut b, &frame, 0.016);
        assert_eq!([a.position.x, a.position.y, a.position.z], [b.position.x, b.position.y, b.position.z]);
    }

    /// **INJECT-ASSERT (mode hotkeys + boost + stick throttle):** pressing `3` switches
    /// to FlightStick (so W becomes throttle along forward and Q/E roll), and Shift
    /// boosts the free-fly speed by `boost_mult`.
    #[test]
    fn flightcontroller_mode_hotkeys_and_boost() {
        let ctl = FlightController::default();

        // Boost: free-fly forward speed scales by boost_mult under Shift.
        let boosted = ctl.to_fly_input(&ControlFrame { w: true, shift: true, ..Default::default() }, FlyMode::FreeFly);
        assert!((boosted.move_forward - ctl.move_speed_mps * ctl.boost_mult).abs() < 1e-9, "Shift boosts speed");

        // Hotkey 3 → FlightStick; then W is throttle (not free-fly forward) + E rolls.
        let mut cam = FlightCamera::free_fly(WorldPos::new(0.0, 1000.0, 0.0));
        let frame = ControlFrame { mode_flight_stick: true, w: true, e: true, ..Default::default() };
        assert_eq!(frame.requested_mode(), Some(FlyMode::FlightStick));
        let f = cam.forward();
        let applied = ctl.apply(&mut cam, &frame, 1.0);
        assert_eq!(cam.mode, FlyMode::FlightStick, "hotkey 3 selected the stick");
        assert!((applied.move_forward - ctl.throttle_mps).abs() < 1e-9, "W is throttle in the stick");
        assert!((applied.roll_rate - ctl.roll_rate).abs() < 1e-9, "E rolls right");
        assert!(cam.roll > 0.0, "the stick banked");
        // Threw the eye forward along the (banked) forward axis.
        let d = [cam.position.x - 0.0, cam.position.y - 1000.0, cam.position.z - 0.0];
        assert!(d.iter().zip(f).map(|(a, b)| a * b).sum::<f64>() > 0.0, "throttle flew forward");

        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-osm3d-sweden",
            "flightcontroller_maps_input",
            true,
            &format!("boost_fwd={:.1} throttle={:.1} rolled={}", boosted.move_forward, applied.move_forward, cam.roll),
        );
    }
}