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),
);
}
}