Skip to main content

oxihuman_viewer/
event_loop.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Winit event loop integration for the OxiHuman viewer.
5//!
6//! Provides `ViewerEventLoop`, `WindowState`, and `InputState` for
7//! windowed rendering, orbit-camera mouse handling, and 60 fps frame timing.
8//!
9//! Gated behind the `winit` Cargo feature.
10
11#[cfg(feature = "winit")]
12use winit::{
13    dpi::PhysicalPosition,
14    event::{ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent},
15    event_loop::EventLoop,
16    keyboard::KeyCode,
17    window::{Window, WindowAttributes, WindowId},
18};
19
20use std::time::{Duration, Instant};
21
22use crate::camera::CameraState;
23
24// ── Constants ─────────────────────────────────────────────────────────────────
25
26/// Target frame period for 60 fps.
27const TARGET_FRAME_DURATION: Duration = Duration::from_nanos(16_666_667);
28
29/// Mouse sensitivity for orbit rotation (radians per pixel).
30const ORBIT_ROTATE_SENSITIVITY: f32 = 0.005;
31
32/// Mouse sensitivity for pan (world units per pixel).
33const ORBIT_PAN_SENSITIVITY: f32 = 0.002;
34
35/// Mouse scroll sensitivity for zoom (world units per tick).
36const SCROLL_ZOOM_SENSITIVITY: f32 = 0.3;
37
38// ── InputState ────────────────────────────────────────────────────────────────
39
40/// Tracks keyboard and mouse input for a single frame.
41#[derive(Debug, Clone, Default)]
42pub struct InputState {
43    /// Whether the left mouse button is currently held.
44    pub left_button_down: bool,
45    /// Whether the right mouse button is currently held.
46    pub right_button_down: bool,
47    /// Current cursor position in physical pixels, if known.
48    pub cursor_position: Option<[f64; 2]>,
49    /// Cursor position from the previous frame (for delta computation).
50    pub prev_cursor_position: Option<[f64; 2]>,
51    /// Accumulated scroll delta this frame (positive = zoom in).
52    pub scroll_delta: f32,
53    /// Whether the Shift modifier key is held.
54    pub shift_held: bool,
55    /// Whether the Ctrl modifier key is held.
56    pub ctrl_held: bool,
57    /// Whether the Alt modifier key is held.
58    pub alt_held: bool,
59    /// Set of logical key codes pressed this frame (winit feature only).
60    #[cfg(feature = "winit")]
61    pub keys_pressed: Vec<KeyCode>,
62    /// Placeholder for non-winit builds (always empty).
63    #[cfg(not(feature = "winit"))]
64    pub keys_pressed: Vec<String>,
65}
66
67impl InputState {
68    /// Compute the cursor delta since the previous frame.
69    ///
70    /// Returns `[dx, dy]` in physical pixels, or `[0.0, 0.0]` if unavailable.
71    pub fn cursor_delta(&self) -> [f64; 2] {
72        match (self.cursor_position, self.prev_cursor_position) {
73            (Some(cur), Some(prev)) => [cur[0] - prev[0], cur[1] - prev[1]],
74            _ => [0.0, 0.0],
75        }
76    }
77
78    /// Advance frame — move current cursor to previous, reset per-frame fields.
79    pub fn advance_frame(&mut self) {
80        self.prev_cursor_position = self.cursor_position;
81        self.scroll_delta = 0.0;
82        self.keys_pressed.clear();
83    }
84
85    /// Returns `true` if any mouse button is held.
86    pub fn any_button_down(&self) -> bool {
87        self.left_button_down || self.right_button_down
88    }
89}
90
91// ── WindowState ───────────────────────────────────────────────────────────────
92
93/// CPU-side window state (surface, swapchain metadata).
94///
95/// The actual GPU surface/swapchain lives in the `webgpu` layer; this struct
96/// tracks the associated dimensions and title so the rest of the loop can
97/// query them without holding GPU handles.
98#[derive(Debug, Clone)]
99pub struct WindowState {
100    /// Logical width in physical pixels.
101    pub width: u32,
102    /// Logical height in physical pixels.
103    pub height: u32,
104    /// Window title.
105    pub title: String,
106    /// Whether the window is currently focused.
107    pub focused: bool,
108    /// Whether the window was just resized this frame.
109    pub resized: bool,
110    /// Device-pixel ratio (HiDPI scale factor).
111    pub scale_factor: f64,
112}
113
114impl WindowState {
115    /// Construct a new [`WindowState`] with the given dimensions and title.
116    pub fn new(width: u32, height: u32, title: &str) -> Self {
117        WindowState {
118            width,
119            height,
120            title: title.to_string(),
121            focused: false,
122            resized: false,
123            scale_factor: 1.0,
124        }
125    }
126
127    /// Update dimensions after a resize event.
128    pub fn handle_resize(&mut self, new_width: u32, new_height: u32) {
129        self.width = new_width.max(1);
130        self.height = new_height.max(1);
131        self.resized = true;
132    }
133
134    /// Clear the per-frame `resized` flag at the start of a frame.
135    pub fn clear_frame_flags(&mut self) {
136        self.resized = false;
137    }
138
139    /// Aspect ratio (width / height).
140    pub fn aspect_ratio(&self) -> f32 {
141        self.width as f32 / self.height.max(1) as f32
142    }
143}
144
145impl Default for WindowState {
146    fn default() -> Self {
147        WindowState::new(1280, 720, "OxiHuman Viewer")
148    }
149}
150
151// ── FrameTiming ───────────────────────────────────────────────────────────────
152
153/// Tracks frame timing and computes `dt` each frame.
154#[derive(Debug, Clone)]
155pub struct FrameTiming {
156    last_frame_start: Instant,
157    /// Delta time for the most recently completed frame, in seconds.
158    pub dt_seconds: f32,
159    /// Total elapsed seconds since the loop started.
160    pub elapsed_seconds: f64,
161    loop_start: Instant,
162}
163
164impl FrameTiming {
165    /// Create a new [`FrameTiming`] anchored to the current instant.
166    pub fn new() -> Self {
167        let now = Instant::now();
168        FrameTiming {
169            last_frame_start: now,
170            dt_seconds: 0.0,
171            elapsed_seconds: 0.0,
172            loop_start: now,
173        }
174    }
175
176    /// Record the start of a new frame and compute `dt`.
177    pub fn begin_frame(&mut self) {
178        let now = Instant::now();
179        self.dt_seconds = now
180            .duration_since(self.last_frame_start)
181            .as_secs_f32()
182            .clamp(0.0, 0.1); // clamp to avoid spiral of death after pauses
183        self.elapsed_seconds = now.duration_since(self.loop_start).as_secs_f64();
184        self.last_frame_start = now;
185    }
186
187    /// Returns the instant the current frame started.
188    pub fn frame_start(&self) -> Instant {
189        self.last_frame_start
190    }
191
192    /// Returns the remaining time until the next 60 fps deadline, if any.
193    ///
194    /// Can be used to sleep or yield to the OS.
195    pub fn remaining_frame_budget(&self) -> Option<Duration> {
196        let elapsed = self.last_frame_start.elapsed();
197        TARGET_FRAME_DURATION.checked_sub(elapsed)
198    }
199}
200
201impl Default for FrameTiming {
202    fn default() -> Self {
203        FrameTiming::new()
204    }
205}
206
207// ── OrbitCameraController ─────────────────────────────────────────────────────
208
209/// Applies orbit-camera updates from input deltas to a [`CameraState`].
210///
211/// - Left mouse drag  → rotate (yaw + pitch)
212/// - Right mouse drag → pan (translate target in the view plane)
213/// - Scroll wheel     → zoom (move along the look-at vector)
214pub struct OrbitCameraController {
215    /// Rotation sensitivity multiplier (radians per pixel).
216    pub rotate_sensitivity: f32,
217    /// Pan sensitivity multiplier (world units per pixel).
218    pub pan_sensitivity: f32,
219    /// Zoom sensitivity multiplier (world units per scroll tick).
220    pub zoom_sensitivity: f32,
221}
222
223impl Default for OrbitCameraController {
224    fn default() -> Self {
225        OrbitCameraController {
226            rotate_sensitivity: ORBIT_ROTATE_SENSITIVITY,
227            pan_sensitivity: ORBIT_PAN_SENSITIVITY,
228            zoom_sensitivity: SCROLL_ZOOM_SENSITIVITY,
229        }
230    }
231}
232
233impl OrbitCameraController {
234    /// Apply mouse-drag and scroll deltas to `camera`.
235    ///
236    /// - `dx`, `dy` are cursor pixel deltas.
237    /// - `scroll` is the scroll wheel delta (positive = zoom in).
238    /// - `left_down` / `right_down` indicate which button is held.
239    pub fn apply(
240        &self,
241        camera: &mut CameraState,
242        dx: f64,
243        dy: f64,
244        scroll: f32,
245        left_down: bool,
246        right_down: bool,
247    ) {
248        // Left drag → orbit rotation
249        if left_down && (dx.abs() > f64::EPSILON || dy.abs() > f64::EPSILON) {
250            let yaw_deg = (dx as f32) * self.rotate_sensitivity.to_degrees();
251            let pitch_deg = (dy as f32) * self.rotate_sensitivity.to_degrees();
252            camera.orbit(yaw_deg, pitch_deg);
253        }
254
255        // Right drag → pan
256        if right_down && (dx.abs() > f64::EPSILON || dy.abs() > f64::EPSILON) {
257            let pan_x = -(dx as f32) * self.pan_sensitivity;
258            let pan_y = (dy as f32) * self.pan_sensitivity;
259            apply_pan(camera, pan_x, pan_y);
260        }
261
262        // Scroll → zoom
263        if scroll.abs() > f32::EPSILON {
264            camera.zoom(scroll * self.zoom_sensitivity);
265        }
266    }
267}
268
269/// Translate the camera target in the view plane by `(pan_x, pan_y)`.
270fn apply_pan(camera: &mut CameraState, pan_x: f32, pan_y: f32) {
271    use crate::camera::{add3, cross3, normalize3, scale3, sub3};
272
273    let fwd = normalize3(sub3(camera.target, camera.position));
274    let right = normalize3(cross3(fwd, camera.up));
275    let up = cross3(right, fwd);
276
277    let delta = add3(scale3(right, pan_x), scale3(up, pan_y));
278    camera.position = add3(camera.position, delta);
279    camera.target = add3(camera.target, delta);
280}
281
282// ── ViewerEventLoop ───────────────────────────────────────────────────────────
283
284/// High-level winit event loop integration.
285///
286/// Wraps a winit [`EventLoop`] and drives frame updates, input handling, and
287/// camera orbit.
288///
289/// Enabled by the `winit` Cargo feature.
290#[cfg(feature = "winit")]
291pub struct ViewerEventLoop {
292    /// The underlying winit event loop.
293    pub event_loop: EventLoop<()>,
294    /// The winit window.
295    pub window: Window,
296}
297
298#[cfg(feature = "winit")]
299impl ViewerEventLoop {
300    /// Create a new [`ViewerEventLoop`] with the given title and dimensions.
301    ///
302    /// # Errors
303    ///
304    /// Returns an [`anyhow::Error`] if the event loop or window cannot be
305    /// created (e.g., no display server available in headless environments).
306    pub fn new(title: &str, width: u32, height: u32) -> anyhow::Result<Self> {
307        let event_loop = EventLoop::new().map_err(|e| anyhow::anyhow!("EventLoop: {e}"))?;
308        let attrs = WindowAttributes::default()
309            .with_title(title)
310            .with_inner_size(winit::dpi::LogicalSize::new(width, height));
311        #[allow(deprecated)]
312        let window = event_loop
313            .create_window(attrs)
314            .map_err(|e| anyhow::anyhow!("Window: {e}"))?;
315        Ok(ViewerEventLoop { event_loop, window })
316    }
317}
318
319/// Process a single [`WindowEvent`] and update [`WindowState`] + [`InputState`].
320///
321/// Returns `true` if the window should close.
322///
323/// This is a free function so it can be called from the `winit`-gated loop
324/// without tying the entire module to the `winit` feature.
325#[cfg(feature = "winit")]
326pub fn process_window_event(
327    event: &WindowEvent,
328    win: &mut WindowState,
329    input: &mut InputState,
330) -> bool {
331    match event {
332        WindowEvent::CloseRequested => return true,
333
334        WindowEvent::Resized(size) => {
335            win.handle_resize(size.width, size.height);
336        }
337
338        WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
339            win.scale_factor = *scale_factor;
340        }
341
342        WindowEvent::Focused(focused) => {
343            win.focused = *focused;
344        }
345
346        WindowEvent::CursorMoved { position, .. } => {
347            input.cursor_position = Some([position.x, position.y]);
348        }
349
350        WindowEvent::MouseWheel { delta, .. } => {
351            let lines = match delta {
352                MouseScrollDelta::LineDelta(_, y) => *y,
353                MouseScrollDelta::PixelDelta(PhysicalPosition { y, .. }) => *y as f32 / 30.0,
354            };
355            input.scroll_delta += lines;
356        }
357
358        WindowEvent::MouseInput { state, button, .. } => match button {
359            MouseButton::Left => {
360                input.left_button_down = *state == ElementState::Pressed;
361            }
362            MouseButton::Right => {
363                input.right_button_down = *state == ElementState::Pressed;
364            }
365            _ => {}
366        },
367
368        WindowEvent::KeyboardInput {
369            event:
370                KeyEvent {
371                    physical_key: winit::keyboard::PhysicalKey::Code(code),
372                    state: ElementState::Pressed,
373                    ..
374                },
375            ..
376        } => {
377            input.keys_pressed.push(*code);
378        }
379
380        WindowEvent::ModifiersChanged(mods) => {
381            let state = mods.state();
382            input.shift_held = state.shift_key();
383            input.ctrl_held = state.control_key();
384            input.alt_held = state.alt_key();
385        }
386
387        _ => {}
388    }
389    false
390}
391
392// ── ApplicationHandler impl for the viewer ─────────────────────────────────
393
394/// Internal app state that implements the winit 0.30 [`ApplicationHandler`].
395#[cfg(feature = "winit")]
396struct ViewerApp<F>
397where
398    F: FnMut(&CameraState, &WindowState, &FrameTiming),
399{
400    window: Option<Window>,
401    win_state: WindowState,
402    input: InputState,
403    timing: FrameTiming,
404    camera: CameraState,
405    orbit: OrbitCameraController,
406    frame_callback: F,
407    init_title: String,
408    init_width: u32,
409    init_height: u32,
410}
411
412#[cfg(feature = "winit")]
413impl<F> winit::application::ApplicationHandler for ViewerApp<F>
414where
415    F: FnMut(&CameraState, &WindowState, &FrameTiming),
416{
417    fn resumed(&mut self, event_loop: &winit::event_loop::ActiveEventLoop) {
418        if self.window.is_none() {
419            let attrs = WindowAttributes::default()
420                .with_title(self.init_title.clone())
421                .with_inner_size(winit::dpi::LogicalSize::new(
422                    self.init_width,
423                    self.init_height,
424                ));
425            match event_loop.create_window(attrs) {
426                Ok(w) => {
427                    let sz = w.inner_size();
428                    self.win_state = WindowState::new(sz.width, sz.height, &self.init_title);
429                    self.window = Some(w);
430                }
431                Err(e) => {
432                    eprintln!("OxiHuman Viewer: failed to create window: {e}");
433                    event_loop.exit();
434                }
435            }
436        }
437    }
438
439    fn window_event(
440        &mut self,
441        event_loop: &winit::event_loop::ActiveEventLoop,
442        _window_id: WindowId,
443        event: WindowEvent,
444    ) {
445        let should_close = process_window_event(&event, &mut self.win_state, &mut self.input);
446        if should_close {
447            event_loop.exit();
448            return;
449        }
450
451        if let WindowEvent::RedrawRequested = event {
452            self.timing.begin_frame();
453            self.win_state.clear_frame_flags();
454
455            let [dx, dy] = self.input.cursor_delta();
456            self.orbit.apply(
457                &mut self.camera,
458                dx,
459                dy,
460                self.input.scroll_delta,
461                self.input.left_button_down,
462                self.input.right_button_down,
463            );
464
465            (self.frame_callback)(&self.camera, &self.win_state, &self.timing);
466
467            self.input.advance_frame();
468            if let Some(w) = &self.window {
469                w.request_redraw();
470            }
471        }
472    }
473
474    fn about_to_wait(&mut self, _event_loop: &winit::event_loop::ActiveEventLoop) {
475        if let Some(w) = &self.window {
476            w.request_redraw();
477        }
478    }
479}
480
481/// Run the viewer main loop using the winit 0.30 `ApplicationHandler` API.
482///
483/// This function does **not** return.  A [`CameraState`] and a mutable frame
484/// callback are provided so callers can inject rendering logic without coupling
485/// to the concrete GPU backend.
486///
487/// This is gated behind `#[cfg(feature = "winit")]`.
488#[cfg(feature = "winit")]
489pub fn run<F>(viewer_loop: ViewerEventLoop, camera: CameraState, frame_callback: F) -> !
490where
491    F: FnMut(&CameraState, &WindowState, &FrameTiming) + 'static,
492{
493    let ViewerEventLoop {
494        event_loop, window, ..
495    } = viewer_loop;
496
497    let inner = window.inner_size();
498    let title = window.title();
499    let win_state = WindowState::new(inner.width, inner.height, &title);
500
501    let mut app = ViewerApp {
502        window: Some(window),
503        win_state,
504        input: InputState::default(),
505        timing: FrameTiming::new(),
506        camera,
507        orbit: OrbitCameraController::default(),
508        frame_callback,
509        init_title: title,
510        init_width: inner.width,
511        init_height: inner.height,
512    };
513
514    match event_loop.run_app(&mut app) {
515        Ok(()) => std::process::exit(0),
516        Err(e) => panic!("Event loop exited with error: {e}"),
517    }
518}
519
520// ── non-winit stubs ───────────────────────────────────────────────────────────
521
522/// Headless stub: create a default [`WindowState`] without an OS window.
523///
524/// Available on all platforms regardless of the `winit` feature.
525pub fn headless_window_state(width: u32, height: u32) -> WindowState {
526    WindowState::new(width, height, "OxiHuman Headless")
527}
528
529/// Simulate one headless frame, updating `timing` and applying orbit to `camera`.
530///
531/// Useful in tests and CI where no display server is available.
532pub fn tick_headless(
533    camera: &mut CameraState,
534    win: &mut WindowState,
535    input: &mut InputState,
536    timing: &mut FrameTiming,
537) {
538    timing.begin_frame();
539    win.clear_frame_flags();
540
541    let orbit = OrbitCameraController::default();
542    let [dx, dy] = input.cursor_delta();
543    orbit.apply(
544        camera,
545        dx,
546        dy,
547        input.scroll_delta,
548        input.left_button_down,
549        input.right_button_down,
550    );
551
552    input.advance_frame();
553}
554
555// ── Tests ─────────────────────────────────────────────────────────────────────
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560
561    #[test]
562    fn window_state_resize_clamps_to_one() {
563        let mut ws = WindowState::default();
564        ws.handle_resize(0, 0);
565        assert_eq!(ws.width, 1);
566        assert_eq!(ws.height, 1);
567        assert!(ws.resized);
568    }
569
570    #[test]
571    fn window_state_clear_flags() {
572        let mut ws = WindowState::default();
573        ws.handle_resize(100, 100);
574        assert!(ws.resized);
575        ws.clear_frame_flags();
576        assert!(!ws.resized);
577    }
578
579    #[test]
580    fn window_state_aspect_ratio() {
581        let ws = WindowState::new(1280, 720, "test");
582        let ar = ws.aspect_ratio();
583        assert!((ar - 16.0 / 9.0).abs() < 1e-4, "expected 16:9, got {ar}");
584    }
585
586    #[test]
587    fn input_state_cursor_delta_none_when_no_prev() {
588        let input = InputState::default();
589        assert_eq!(input.cursor_delta(), [0.0, 0.0]);
590    }
591
592    #[test]
593    #[allow(clippy::field_reassign_with_default)]
594    fn input_state_cursor_delta_computed() {
595        let mut input = InputState::default();
596        input.prev_cursor_position = Some([100.0, 200.0]);
597        input.cursor_position = Some([110.0, 190.0]);
598        let [dx, dy] = input.cursor_delta();
599        assert!((dx - 10.0).abs() < 1e-6);
600        assert!((dy + 10.0).abs() < 1e-6);
601    }
602
603    #[test]
604    #[allow(clippy::field_reassign_with_default)]
605    fn input_state_advance_frame_resets_scroll() {
606        let mut input = InputState::default();
607        input.scroll_delta = 3.0;
608        input.advance_frame();
609        assert_eq!(input.scroll_delta, 0.0);
610    }
611
612    #[test]
613    fn input_state_advance_frame_clears_keys() {
614        let mut input = InputState::default();
615        #[cfg(feature = "winit")]
616        input.keys_pressed.push(winit::keyboard::KeyCode::KeyA);
617        #[cfg(not(feature = "winit"))]
618        input.keys_pressed.push("KeyA".to_string());
619        input.advance_frame();
620        assert!(input.keys_pressed.is_empty());
621    }
622
623    #[test]
624    fn frame_timing_dt_non_negative() {
625        let mut timing = FrameTiming::new();
626        timing.begin_frame();
627        assert!(timing.dt_seconds >= 0.0);
628    }
629
630    #[test]
631    fn orbit_controller_left_drag_changes_camera() {
632        let mut cam = CameraState::default();
633        let before = cam.position;
634        let ctrl = OrbitCameraController::default();
635        ctrl.apply(&mut cam, 50.0, 0.0, 0.0, true, false);
636        assert_ne!(cam.position, before, "left drag should orbit camera");
637    }
638
639    #[test]
640    fn orbit_controller_scroll_zooms() {
641        let mut cam = CameraState::default();
642        use crate::camera::{len3, sub3};
643        let before_dist = len3(sub3(cam.position, cam.target));
644        let ctrl = OrbitCameraController::default();
645        ctrl.apply(&mut cam, 0.0, 0.0, 2.0, false, false);
646        let after_dist = len3(sub3(cam.position, cam.target));
647        assert!(
648            after_dist < before_dist,
649            "positive scroll should zoom in (decrease distance)"
650        );
651    }
652
653    #[test]
654    fn orbit_controller_right_drag_pans() {
655        let mut cam = CameraState::default();
656        let before_target = cam.target;
657        let ctrl = OrbitCameraController::default();
658        ctrl.apply(&mut cam, 100.0, 0.0, 0.0, false, true);
659        assert_ne!(cam.target, before_target, "right drag should pan target");
660    }
661
662    #[test]
663    fn headless_window_state_dimensions() {
664        let ws = headless_window_state(800, 600);
665        assert_eq!(ws.width, 800);
666        assert_eq!(ws.height, 600);
667    }
668
669    #[test]
670    fn tick_headless_updates_timing() {
671        let mut cam = CameraState::default();
672        let mut win = WindowState::default();
673        let mut input = InputState::default();
674        let mut timing = FrameTiming::new();
675        tick_headless(&mut cam, &mut win, &mut input, &mut timing);
676        assert!(timing.dt_seconds >= 0.0);
677    }
678}