diff --git a/crates/reco-cli/src/preview.rs b/crates/reco-cli/src/preview.rs index fa51a89cd..3095f00f7 100644 --- a/crates/reco-cli/src/preview.rs +++ b/crates/reco-cli/src/preview.rs @@ -7,6 +7,8 @@ //! - `[`/`]`: seek backward/forward 5 seconds, Home: restart //! - Arrows / mouse drag: pan (yaw/pitch) //! - +/- / scroll: zoom (FOV) +//! - F: toggle free-fly camera (then WASD move, E/C up/down, Shift boost, +//! mouse-drag look) - move the virtual camera through the 3D scene //! - Q / Escape: quit //! //! This module is intentionally CLI-only. The rendering is already handled by @@ -46,6 +48,10 @@ const FOV_MIN: f32 = 20.0; const FOV_MAX: f32 = 150.0; /// Default FOV at startup (degrees). const FOV_DEFAULT: f32 = 75.0; +/// Free-fly camera movement speed (scene units per second). +const FLY_SPEED: f32 = 0.6; +/// Free-fly speed multiplier while Shift is held. +const FLY_BOOST: f32 = 4.0; /// Number of frames to skip on P key press. const FRAME_SKIP_COUNT: usize = 30; /// Number of seconds to seek on `[`/`]` key press. @@ -203,6 +209,10 @@ pub fn run_preview( fps_rational, total_frames, pending_seek: None, + fly_mode: false, + keys_down: std::collections::HashSet::new(), + last_move_time: Instant::now(), + rmb_dragging: false, }; event_loop.run_app(&mut app)?; @@ -260,6 +270,16 @@ struct App { /// Coalesced seek target. Multiple rapid key presses accumulate here; /// only the final value is executed (in about_to_wait). pending_seek: Option, + // -- Free-fly camera (debug navigation) -- + /// When true, WASD/E/C move the virtual camera through 3D space and + /// mouse-drag looks around; the normal letter hotkeys are suspended. + fly_mode: bool, + /// Movement keys currently held (WASD / E / C / Shift) for continuous fly. + keys_down: std::collections::HashSet, + /// Last free-fly integration tick, for frame-rate-independent movement. + last_move_time: Instant, + /// Right mouse button held (free-look in fly mode). + rmb_dragging: bool, } impl App { @@ -568,7 +588,59 @@ impl ApplicationHandler for App { } WindowEvent::KeyboardInput { event, .. } => { use winit::keyboard::{KeyCode, PhysicalKey}; - if event.state == winit::event::ElementState::Pressed { + let pressed = event.state == winit::event::ElementState::Pressed; + + // F toggles free-fly mode (camera translation via WASD/E/C + + // mouse-drag look). Available in any mode. + if pressed && event.physical_key == PhysicalKey::Code(KeyCode::KeyF) { + self.fly_mode = !self.fly_mode; + self.keys_down.clear(); + if self.fly_mode { + self.last_move_time = Instant::now(); + println!( + "Fly mode ON - WASD move, E/C up/down, Shift boost, mouse-drag look, F to exit" + ); + } else { + if !self.playing { + event_loop.set_control_flow(ControlFlow::Wait); + } + println!("Fly mode OFF"); + } + return; + } + + // In fly mode, WASD/E/C/Shift drive camera movement (held-key + // state) and suspend their normal letter-hotkey meanings. + if self.fly_mode + && let PhysicalKey::Code(code) = event.physical_key + && matches!( + code, + KeyCode::KeyW + | KeyCode::KeyA + | KeyCode::KeyS + | KeyCode::KeyD + | KeyCode::KeyE + | KeyCode::KeyC + | KeyCode::ShiftLeft + | KeyCode::ShiftRight + ) + { + if pressed { + if self.keys_down.is_empty() { + self.last_move_time = Instant::now(); + } + self.keys_down.insert(code); + event_loop.set_control_flow(ControlFlow::Poll); + } else { + self.keys_down.remove(&code); + if self.keys_down.is_empty() && !self.playing { + event_loop.set_control_flow(ControlFlow::Wait); + } + } + return; + } + + if pressed { match event.physical_key { PhysicalKey::Code(KeyCode::Escape | KeyCode::KeyQ) => { if self.recording.is_some() { @@ -770,21 +842,32 @@ impl ApplicationHandler for App { WindowEvent::MouseInput { state, button, .. } => { use winit::event::ElementState; use winit::event::MouseButton; - if button == MouseButton::Left { + if button == MouseButton::Left || button == MouseButton::Right { let pressed = state == ElementState::Pressed; - self.mouse_dragging = pressed; + // Left drag and right drag both look around (yaw/pitch). + if button == MouseButton::Left { + self.mouse_dragging = pressed; + } else { + self.rmb_dragging = pressed; + } if pressed { // Capture start position - first CursorMoved will anchor here self.last_mouse_pos = None; } else { self.last_mouse_pos = None; - if !self.playing { + if !self.mouse_dragging + && !self.rmb_dragging + && self.keys_down.is_empty() + && !self.playing + { event_loop.set_control_flow(ControlFlow::Wait); } } } } - WindowEvent::CursorMoved { position, .. } if self.mouse_dragging => { + WindowEvent::CursorMoved { position, .. } + if self.mouse_dragging || self.rmb_dragging => + { if let Some((prev_x, prev_y)) = self.last_mouse_pos { let dx = (position.x - prev_x) as f32; let dy = (position.y - prev_y) as f32; @@ -918,7 +1001,52 @@ impl ApplicationHandler for App { self.needs_redraw = true; } - if !self.playing && !smoothing_active { + // Free-fly camera translation: integrate held movement keys into the + // virtual camera position, frame-rate independent via dt. + if self.fly_mode && !self.keys_down.is_empty() { + use winit::keyboard::KeyCode; + let dt = self.last_move_time.elapsed().as_secs_f32().min(0.05); + let k = &self.keys_down; + let mut mv = [0.0_f32; 3]; // [right, up, forward] + if k.contains(&KeyCode::KeyD) { + mv[0] += 1.0; + } + if k.contains(&KeyCode::KeyA) { + mv[0] -= 1.0; + } + if k.contains(&KeyCode::KeyE) { + mv[1] += 1.0; + } + if k.contains(&KeyCode::KeyC) { + mv[1] -= 1.0; + } + if k.contains(&KeyCode::KeyW) { + mv[2] += 1.0; + } + if k.contains(&KeyCode::KeyS) { + mv[2] -= 1.0; + } + if mv != [0.0; 3] { + let boost = if k.contains(&KeyCode::ShiftLeft) || k.contains(&KeyCode::ShiftRight) { + FLY_BOOST + } else { + 1.0 + }; + let step = FLY_SPEED * boost * dt; + let render = self.pose.render_pose(self.rig_tilt); + if let Some(r) = &mut self.renderer { + r.pipeline_mut().fly_camera( + [mv[0] * step, mv[1] * step, mv[2] * step], + render.yaw, + render.pitch, + ); + } + self.needs_redraw = true; + } + } + self.last_move_time = Instant::now(); + + if !self.playing && !smoothing_active && self.keys_down.is_empty() { return; } diff --git a/crates/reco-core/src/render/pipeline.rs b/crates/reco-core/src/render/pipeline.rs index 6f06ef722..0e0b45fd8 100644 --- a/crates/reco-core/src/render/pipeline.rs +++ b/crates/reco-core/src/render/pipeline.rs @@ -250,6 +250,51 @@ impl StitchPipeline { self.viewport.lens_correction_amount = amount.clamp(0.0, 1.0); } + /// Current virtual camera position `[x, y, z]` in scene space. + pub fn camera_position(&self) -> [f32; 3] { + self.scene.camera_position + } + + /// Override the virtual camera position (e.g. to reset free-fly). + /// + /// Takes effect on the next render (the eye is read from the scene each + /// frame). The position is never allowed to reach the scene origin, + /// where the look-toward-origin basis would be undefined. + pub fn set_camera_position(&mut self, pos: [f32; 3]) { + let norm = (pos[0] * pos[0] + pos[1] * pos[1] + pos[2] * pos[2]).sqrt(); + if norm > 1e-2 { + self.scene.camera_position = pos; + } + } + + /// Translate the virtual camera in its current view frame (free-fly). + /// + /// `local` is `[right, up, forward]` in scene-space distance units, + /// evaluated at the given `yaw`/`pitch` so movement follows where the + /// camera looks. The basis matches `view_matrix`'s yaw-around-up, + /// pitch-around-right convention (rig tilt/roll are ignored here; this + /// is a debug/preview navigation aid, not a render path). Vertical + /// (`up`) uses world up so it stays level regardless of pitch. + pub fn fly_camera(&mut self, local: [f32; 3], yaw: f32, pitch: f32) { + use crate::projection::VirtualCamera; + use nalgebra::{Unit, UnitQuaternion}; + + let cam = VirtualCamera::new(&self.scene.camera_position); + let world_up = VirtualCamera::world_up(); + let yaw_q = UnitQuaternion::from_axis_angle(&Unit::new_normalize(world_up), yaw); + let right = yaw_q * cam.base_right; + let pitch_q = UnitQuaternion::from_axis_angle(&Unit::new_normalize(right), pitch); + let forward = (pitch_q * yaw_q) * cam.base_forward; + + let delta = right * local[0] + world_up * local[1] + forward * local[2]; + let next = [ + self.scene.camera_position[0] + delta.x, + self.scene.camera_position[1] + delta.y, + self.scene.camera_position[2] + delta.z, + ]; + self.set_camera_position(next); + } + /// Update calibration parameters. Recomputes [`SceneGeometry`] from the /// new layout. Takes effect on the next render call (uniforms are rebuilt /// each frame from the stored calibration and scene). diff --git a/crates/reco-gui/src/main.rs b/crates/reco-gui/src/main.rs index a5b8aa280..e590d4478 100644 --- a/crates/reco-gui/src/main.rs +++ b/crates/reco-gui/src/main.rs @@ -83,6 +83,11 @@ const DRAG_DEG_PER_PIXEL: f32 = 0.287; /// Matches the pre-migration GUI constant `CAMERA_SMOOTHING`. const POSE_SMOOTHING: f32 = 0.25; +/// Free-fly camera movement speed (scene units per second). +const FLY_SPEED: f32 = 0.6; +/// Free-fly speed multiplier while Shift is held. +const FLY_BOOST: f32 = 4.0; + /// How long the seek-slider fraction must stay stable before we /// actually execute the seek. Debouncing is required because every /// pixel of drag emits a `changed` event, and each seek forces a @@ -145,6 +150,15 @@ struct AppState { /// through the camera-smoothing path but still need a re-render. /// Cleared by the timer after it renders. preview_dirty: bool, + /// Free-fly mode (F key): WASD/E/C translate the virtual camera through + /// the 3D scene; mouse-drag still looks around. + fly_mode: bool, + /// Held free-fly movement keys (w/a/s/d/e/c, lowercased). + keys_down: std::collections::HashSet, + /// Shift state at the last movement-key event (speed boost). + fly_shift: bool, + /// Last free-fly integration tick, for frame-rate-independent movement. + last_move_time: Instant, /// Interrupt flag for a running export. Set to true when the user /// clicks Cancel; StitchJob checks it between frames and aborts. export_interrupted: Arc, @@ -390,6 +404,10 @@ impl AppState { pending_seek: None, last_render_at: None, preview_dirty: false, + fly_mode: false, + keys_down: std::collections::HashSet::new(), + fly_shift: false, + last_move_time: Instant::now(), export_interrupted: Arc::new(AtomicBool::new(false)), export_last_progress_at: Arc::new(Mutex::new(None)), export_thread: None, @@ -742,6 +760,91 @@ impl AppState { self.preview_dirty = true; } + /// Toggle free-fly camera mode (F key). Clears any held movement keys. + fn toggle_fly(&mut self) { + self.fly_mode = !self.fly_mode; + self.keys_down.clear(); + self.last_move_time = Instant::now(); + log::info!( + "Fly mode {} - WASD move, E/C up/down, Shift boost, drag to look, F to exit", + if self.fly_mode { "ON" } else { "OFF" } + ); + } + + /// Track a held free-fly movement key (w/a/s/d/e/c); records Shift for boost. + fn set_fly_key(&mut self, text: &str, pressed: bool, shift: bool) { + self.fly_shift = shift; + let Some(c) = text.chars().next().map(|c| c.to_ascii_lowercase()) else { + return; + }; + if !matches!(c, 'w' | 'a' | 's' | 'd' | 'e' | 'c') { + return; + } + if pressed { + self.keys_down.insert(c); + } else { + self.keys_down.remove(&c); + } + } + + /// True while fly mode is active with movement keys held. + fn fly_active(&self) -> bool { + self.fly_mode && !self.keys_down.is_empty() + } + + /// Integrate held movement keys into the virtual camera position, frame-rate + /// independent via dt. Movement follows where the camera looks (yaw/pitch). + fn apply_fly(&mut self) { + if !self.fly_active() { + self.last_move_time = Instant::now(); + return; + } + let dt = self.last_move_time.elapsed().as_secs_f32().min(0.05); + self.last_move_time = Instant::now(); + + let mut mv = [0.0_f32; 3]; // [right, up, forward] + if self.keys_down.contains(&'d') { + mv[0] += 1.0; + } + if self.keys_down.contains(&'a') { + mv[0] -= 1.0; + } + if self.keys_down.contains(&'e') { + mv[1] += 1.0; + } + if self.keys_down.contains(&'c') { + mv[1] -= 1.0; + } + if self.keys_down.contains(&'w') { + mv[2] += 1.0; + } + if self.keys_down.contains(&'s') { + mv[2] -= 1.0; + } + if mv == [0.0; 3] { + return; + } + + let Some(rig_tilt) = self + .bridge + .as_ref() + .map(|b| b.renderer().pipeline().viewport().rig_tilt) + else { + return; + }; + let render = self.pose.render_pose(rig_tilt); + let boost = if self.fly_shift { FLY_BOOST } else { 1.0 }; + let step = FLY_SPEED * boost * dt; + if let Some(bridge) = self.bridge.as_mut() { + bridge.renderer_mut().pipeline_mut().fly_camera( + [mv[0] * step, mv[1] * step, mv[2] * step], + render.yaw, + render.pitch, + ); + self.preview_dirty = true; + } + } + /// Apply a FOV delta (degrees). Clamps the target; tick handles smoothing. fn apply_zoom(&mut self, delta_deg: f32) { IntentTranslator::new(&mut self.pose) @@ -2500,6 +2603,18 @@ fn main() -> anyhow::Result<()> { state_ref.borrow_mut().reset_view(); }); + let state_ref = Rc::clone(&state); + app.on_fly_toggle(move || { + state_ref.borrow_mut().toggle_fly(); + }); + + let state_ref = Rc::clone(&state); + app.on_fly_key(move |text, pressed, shift| { + state_ref + .borrow_mut() + .set_fly_key(text.as_str(), pressed, shift); + }); + let state_ref = Rc::clone(&state); app.on_changed_blend_width(move |w| { state_ref.borrow_mut().set_blend_width(w); @@ -3578,7 +3693,10 @@ fn main() -> anyhow::Result<()> { // nudge Slint to keep redrawing so BeforeRendering fires // even if nothing marked the window dirty yet. if let Some(app) = app_weak.upgrade() - && (app.get_playing() || s.pending_seek.is_some() || s.preview_dirty) + && (app.get_playing() + || s.pending_seek.is_some() + || s.preview_dirty + || s.fly_active()) { app.window().request_redraw(); } @@ -3623,6 +3741,7 @@ fn vsync_render_tick(state: &Rc>, app_weak: &slint::Weak advanced, diff --git a/crates/reco-gui/ui/main.slint b/crates/reco-gui/ui/main.slint index df7ca17ce..7bc6d49f3 100644 --- a/crates/reco-gui/ui/main.slint +++ b/crates/reco-gui/ui/main.slint @@ -457,6 +457,10 @@ export component RecoApp inherits Window { callback zoom(float); // Reset yaw/pitch/fov to defaults. callback reset-view(); + // Free-fly camera (experiment): F toggles, WASD/E/C move. Args: key text, + // pressed, shift-held. + callback fly-toggle(); + callback fly-key(string, bool, bool); // Settings-panel slider callbacks. callback changed-blend-width(float); callback changed-rig-tilt(float); @@ -588,9 +592,23 @@ export component RecoApp inherits Window { if (event.text == "]") { root.seek-relative(5); return accept; } // R : reset view if (event.text == "r" || event.text == "R") { root.reset-view(); return accept; } - // F or F11 : toggle fullscreen - if (event.text == "f" || event.text == "F" || event.text == Key.F11) { - root.full-screen = !root.full-screen; + // F : toggle free-fly camera (experiment). F11 toggles fullscreen. + if (event.text == "f" || event.text == "F") { root.fly-toggle(); return accept; } + if (event.text == Key.F11) { root.full-screen = !root.full-screen; return accept; } + // Free-fly movement (no-op in Rust unless fly mode is on). + if (event.text == "w" || event.text == "W" || event.text == "a" || event.text == "A" + || event.text == "s" || event.text == "S" || event.text == "d" || event.text == "D" + || event.text == "e" || event.text == "E" || event.text == "c" || event.text == "C") { + root.fly-key(event.text, true, event.modifiers.shift); + return accept; + } + return reject; + } + key-released(event) => { + if (event.text == "w" || event.text == "W" || event.text == "a" || event.text == "A" + || event.text == "s" || event.text == "S" || event.text == "d" || event.text == "D" + || event.text == "e" || event.text == "E" || event.text == "c" || event.text == "C") { + root.fly-key(event.text, false, event.modifiers.shift); return accept; } return reject;