1#[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
24const TARGET_FRAME_DURATION: Duration = Duration::from_nanos(16_666_667);
28
29const ORBIT_ROTATE_SENSITIVITY: f32 = 0.005;
31
32const ORBIT_PAN_SENSITIVITY: f32 = 0.002;
34
35const SCROLL_ZOOM_SENSITIVITY: f32 = 0.3;
37
38#[derive(Debug, Clone, Default)]
42pub struct InputState {
43 pub left_button_down: bool,
45 pub right_button_down: bool,
47 pub cursor_position: Option<[f64; 2]>,
49 pub prev_cursor_position: Option<[f64; 2]>,
51 pub scroll_delta: f32,
53 pub shift_held: bool,
55 pub ctrl_held: bool,
57 pub alt_held: bool,
59 #[cfg(feature = "winit")]
61 pub keys_pressed: Vec<KeyCode>,
62 #[cfg(not(feature = "winit"))]
64 pub keys_pressed: Vec<String>,
65}
66
67impl InputState {
68 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 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 pub fn any_button_down(&self) -> bool {
87 self.left_button_down || self.right_button_down
88 }
89}
90
91#[derive(Debug, Clone)]
99pub struct WindowState {
100 pub width: u32,
102 pub height: u32,
104 pub title: String,
106 pub focused: bool,
108 pub resized: bool,
110 pub scale_factor: f64,
112}
113
114impl WindowState {
115 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 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 pub fn clear_frame_flags(&mut self) {
136 self.resized = false;
137 }
138
139 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#[derive(Debug, Clone)]
155pub struct FrameTiming {
156 last_frame_start: Instant,
157 pub dt_seconds: f32,
159 pub elapsed_seconds: f64,
161 loop_start: Instant,
162}
163
164impl FrameTiming {
165 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 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); self.elapsed_seconds = now.duration_since(self.loop_start).as_secs_f64();
184 self.last_frame_start = now;
185 }
186
187 pub fn frame_start(&self) -> Instant {
189 self.last_frame_start
190 }
191
192 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
207pub struct OrbitCameraController {
215 pub rotate_sensitivity: f32,
217 pub pan_sensitivity: f32,
219 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 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 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 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 if scroll.abs() > f32::EPSILON {
264 camera.zoom(scroll * self.zoom_sensitivity);
265 }
266 }
267}
268
269fn 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#[cfg(feature = "winit")]
291pub struct ViewerEventLoop {
292 pub event_loop: EventLoop<()>,
294 pub window: Window,
296}
297
298#[cfg(feature = "winit")]
299impl ViewerEventLoop {
300 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#[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#[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#[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
520pub fn headless_window_state(width: u32, height: u32) -> WindowState {
526 WindowState::new(width, height, "OxiHuman Headless")
527}
528
529pub 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#[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}