Skip to main content

viewport_lib/interaction/input/
controller.rs

1//! Orbit/pan/zoom camera controller.
2//!
3//! [`OrbitCameraController`] wraps [`super::viewport_input::ViewportInput`] and
4//! applies resolved orbit / pan / zoom actions directly to a [`crate::Camera`].
5
6use crate::Camera;
7
8use super::action::Action;
9use super::action_frame::ActionFrame;
10use super::context::ViewportContext;
11use super::event::ViewportEvent;
12use super::mode::NavigationMode;
13use super::preset::{BindingPreset, viewport_all_bindings, viewport_primitives_bindings};
14use super::viewport_input::ViewportInput;
15
16/// High-level orbit / pan / zoom camera controller.
17///
18/// Wraps the lower-level [`ViewportInput`] resolver and applies semantic
19/// camera actions to a [`Camera`] in a single `apply_to_camera` call.
20///
21/// # Integration pattern (winit / single window)
22///
23/// ```text
24/// // --- AppState construction ---
25/// controller.begin_frame(ViewportContext { hovered: true, focused: true, viewport_size });
26///
27/// // --- window_event ---
28/// controller.push_event(translated_event);
29///
30/// // --- RedrawRequested ---
31/// controller.apply_to_camera(&mut state.camera);
32/// controller.begin_frame(ViewportContext { hovered: true, focused: true, viewport_size });
33/// // ... render ...
34/// ```
35///
36/// # Integration pattern (eframe / egui)
37///
38/// ```text
39/// // --- update() ---
40/// controller.begin_frame(ViewportContext {
41///     hovered: response.hovered(),
42///     focused: response.has_focus(),
43///     viewport_size: [rect.width(), rect.height()],
44/// });
45/// // push events from ui.input(|i| { ... })
46/// controller.apply_to_camera(&mut self.camera);
47/// ```
48pub struct OrbitCameraController {
49    input: ViewportInput,
50    /// How drag input is interpreted by [`apply_to_camera`](Self::apply_to_camera).
51    ///
52    /// Defaults to [`NavigationMode::Arcball`].
53    pub navigation_mode: NavigationMode,
54    /// Movement speed for [`NavigationMode::Fly`], in world units per frame.
55    ///
56    /// Defaults to `0.1`. Scale this to match your scene : a value of `0.1` is
57    /// suitable for a scene with objects a few units across.
58    pub fly_speed: f32,
59    /// Sensitivity for drag-based orbit (radians per pixel).
60    pub orbit_sensitivity: f32,
61    /// Sensitivity for scroll-based zoom (scale factor per pixel).
62    pub zoom_sensitivity: f32,
63    /// Sensitivity applied to two-finger trackpad rotation gesture (radians per radian).
64    /// Default: `1.0` (gesture angle applied directly to camera yaw).
65    /// Set to `0.0` to suppress the gesture entirely.
66    pub gesture_sensitivity: f32,
67    /// Current viewport size (cached from the last `begin_frame`).
68    viewport_size: [f32; 2],
69}
70
71impl OrbitCameraController {
72    /// Default drag orbit sensitivity: 0.005 radians per pixel.
73    pub const DEFAULT_ORBIT_SENSITIVITY: f32 = 0.005;
74    /// Default scroll zoom sensitivity: 0.001 scale per pixel.
75    pub const DEFAULT_ZOOM_SENSITIVITY: f32 = 0.001;
76    /// Default gesture sensitivity: 1.0 (gesture radians applied 1:1 to camera yaw).
77    pub const DEFAULT_GESTURE_SENSITIVITY: f32 = 1.0;
78    /// Default first-person fly speed: 0.1 world units per frame.
79    pub const DEFAULT_FLY_SPEED: f32 = 0.1;
80
81    /// Create a controller from the given binding preset.
82    pub fn new(preset: BindingPreset) -> Self {
83        let bindings = match preset {
84            BindingPreset::ViewportPrimitives => viewport_primitives_bindings(),
85            BindingPreset::ViewportAll => viewport_all_bindings(),
86        };
87        Self {
88            input: ViewportInput::new(bindings),
89            navigation_mode: NavigationMode::Arcball,
90            fly_speed: Self::DEFAULT_FLY_SPEED,
91            orbit_sensitivity: Self::DEFAULT_ORBIT_SENSITIVITY,
92            zoom_sensitivity: Self::DEFAULT_ZOOM_SENSITIVITY,
93            gesture_sensitivity: Self::DEFAULT_GESTURE_SENSITIVITY,
94            viewport_size: [1.0, 1.0],
95        }
96    }
97
98    /// Create a controller with the [`BindingPreset::ViewportPrimitives`] preset.
99    ///
100    /// This is the canonical control scheme matching `examples/winit_primitives`.
101    pub fn viewport_primitives() -> Self {
102        Self::new(BindingPreset::ViewportPrimitives)
103    }
104
105    /// Create a controller with the [`BindingPreset::ViewportAll`] preset.
106    ///
107    /// Includes all camera navigation bindings plus keyboard shortcuts for
108    /// normal mode, fly mode, and manipulation mode. Use this to replace
109    /// [`crate::InputSystem`] entirely.
110    pub fn viewport_all() -> Self {
111        Self::new(BindingPreset::ViewportAll)
112    }
113
114    /// Begin a new frame.
115    ///
116    /// Resets per-frame accumulators and records viewport context (hover/focus
117    /// state and size). Call this at the **end** of each rendered frame : after
118    /// `apply_to_camera` : so the accumulator is ready for the next batch of
119    /// events.
120    ///
121    /// Also call once immediately after construction to prime the accumulator.
122    pub fn begin_frame(&mut self, ctx: ViewportContext) {
123        self.viewport_size = ctx.viewport_size;
124        self.input.begin_frame(ctx);
125    }
126
127    /// Record the viewport size used for pan when applying a frame through
128    /// [`apply`](Self::apply) without going through
129    /// [`begin_frame`](Self::begin_frame).
130    pub fn set_viewport_size(&mut self, viewport_size: [f32; 2]) {
131        self.viewport_size = viewport_size;
132    }
133
134    /// Push a single viewport-scoped event into the accumulator.
135    ///
136    /// Call this from the host's event handler whenever a relevant native event
137    /// arrives, after translating it to a [`ViewportEvent`].
138    pub fn push_event(&mut self, event: ViewportEvent) {
139        self.input.push_event(event);
140    }
141
142    /// Resolve accumulated events into an [`ActionFrame`] without applying any
143    /// camera navigation.
144    ///
145    /// Use this when the caller needs to inspect actions but camera movement
146    /// should be suppressed : for example during gizmo manipulation or fly mode
147    /// where the camera is driven by other logic.
148    pub fn resolve(&self) -> ActionFrame {
149        self.input.resolve()
150    }
151
152    /// Resolve accumulated events, apply camera navigation, and return the
153    /// [`ActionFrame`] for this frame.
154    ///
155    /// Call this in the render / update step, **before** `begin_frame` for the
156    /// next frame.
157    ///
158    /// The behavior of orbit drag depends on [`Self::navigation_mode`]:
159    /// - [`NavigationMode::Arcball`]: unconstrained arcball (default).
160    /// - [`NavigationMode::Turntable`]: yaw around world Z, pitch clamped to +/-89 deg.
161    /// - [`NavigationMode::Planar`]: pan only, orbit input is ignored.
162    /// - [`NavigationMode::Fly`]: mouselook + WASD translation. Requires
163    ///   the `ViewportAll` binding preset so that movement keys are resolved.
164    pub fn apply_to_camera(&mut self, camera: &mut Camera) -> ActionFrame {
165        let frame = self.input.resolve();
166        self.apply(camera, &frame);
167        frame
168    }
169
170    /// Apply an already-resolved [`ActionFrame`] to the camera without draining
171    /// the event queue.
172    ///
173    /// Use this when the host owns a single [`ViewportInput`] resolver shared
174    /// across several controllers (orbit, first-person, third-person) and feeds
175    /// the same frame to whichever one is active.
176    /// [`apply_to_camera`](Self::apply_to_camera) is the convenience wrapper
177    /// that resolves and applies in one call.
178    ///
179    /// Pan needs the viewport height, read from the size recorded by the last
180    /// [`begin_frame`](Self::begin_frame) or
181    /// [`set_viewport_size`](Self::set_viewport_size).
182    pub fn apply(&self, camera: &mut Camera, frame: &ActionFrame) {
183        let nav = &frame.navigation;
184        let h = self.viewport_size[1];
185
186        match self.navigation_mode {
187            NavigationMode::Arcball => {
188                if nav.orbit != glam::Vec2::ZERO {
189                    camera.orbit(
190                        nav.orbit.x * self.orbit_sensitivity,
191                        nav.orbit.y * self.orbit_sensitivity,
192                    );
193                }
194                if nav.twist != 0.0 && self.gesture_sensitivity != 0.0 {
195                    camera.orbit(nav.twist * self.gesture_sensitivity, 0.0);
196                }
197                if nav.pan != glam::Vec2::ZERO {
198                    camera.pan_pixels(nav.pan, h);
199                }
200                if nav.zoom != 0.0 {
201                    camera.zoom_by_factor(1.0 - nav.zoom * self.zoom_sensitivity);
202                }
203            }
204
205            NavigationMode::Turntable => {
206                if nav.orbit != glam::Vec2::ZERO {
207                    let yaw = nav.orbit.x * self.orbit_sensitivity;
208                    let pitch = nav.orbit.y * self.orbit_sensitivity;
209                    apply_turntable(camera, yaw, pitch);
210                }
211                if nav.twist != 0.0 && self.gesture_sensitivity != 0.0 {
212                    // Gesture twist is yaw-only in turntable mode.
213                    apply_turntable(camera, nav.twist * self.gesture_sensitivity, 0.0);
214                }
215                if nav.pan != glam::Vec2::ZERO {
216                    camera.pan_pixels(nav.pan, h);
217                }
218                if nav.zoom != 0.0 {
219                    camera.zoom_by_factor(1.0 - nav.zoom * self.zoom_sensitivity);
220                }
221            }
222
223            NavigationMode::Planar => {
224                // Orbit input is silently ignored; pan and zoom still work.
225                if nav.pan != glam::Vec2::ZERO {
226                    camera.pan_pixels(nav.pan, h);
227                }
228                if nav.zoom != 0.0 {
229                    camera.zoom_by_factor(1.0 - nav.zoom * self.zoom_sensitivity);
230                }
231            }
232
233            NavigationMode::Fly => {
234                // Mouselook: drag rotates the view while the eye stays fixed.
235                if nav.orbit != glam::Vec2::ZERO {
236                    let yaw = nav.orbit.x * self.orbit_sensitivity;
237                    let pitch = nav.orbit.y * self.orbit_sensitivity;
238                    apply_fly_look(camera, yaw, pitch);
239                }
240                if nav.twist != 0.0 && self.gesture_sensitivity != 0.0 {
241                    apply_fly_look(camera, nav.twist * self.gesture_sensitivity, 0.0);
242                }
243
244                // WASD / QE translation.
245                let forward = -(camera.orientation * glam::Vec3::Z);
246                let right = camera.orientation * glam::Vec3::X;
247                let up = camera.orientation * glam::Vec3::Y;
248                let speed = self.fly_speed;
249
250                let mut move_delta = glam::Vec3::ZERO;
251                if frame.is_active(Action::FlyForward) {
252                    move_delta += forward * speed;
253                }
254                if frame.is_active(Action::FlyBackward) {
255                    move_delta -= forward * speed;
256                }
257                if frame.is_active(Action::FlyRight) {
258                    move_delta += right * speed;
259                }
260                if frame.is_active(Action::FlyLeft) {
261                    move_delta -= right * speed;
262                }
263                if frame.is_active(Action::FlyUp) {
264                    move_delta += up * speed;
265                }
266                if frame.is_active(Action::FlyDown) {
267                    move_delta -= up * speed;
268                }
269                // Translate center (and thus eye) without changing orientation or distance.
270                camera.center += move_delta;
271            }
272        }
273    }
274}
275
276// ---------------------------------------------------------------------------
277// Navigation mode helpers
278// ---------------------------------------------------------------------------
279
280/// Apply a turntable yaw + clamped pitch to the camera.
281///
282/// Yaw rotates around world Z. Pitch changes elevation in camera-local space,
283/// but is blocked when the eye would reach +/-89 deg above/below the XY plane.
284fn apply_turntable(camera: &mut Camera, yaw: f32, pitch: f32) {
285    // Yaw: pre-multiply by a world-Z rotation (same as the yaw component of Camera::orbit).
286    if yaw != 0.0 {
287        camera.orientation = (glam::Quat::from_rotation_z(-yaw) * camera.orientation).normalize();
288    }
289
290    if pitch != 0.0 {
291        // Proposed orientation after applying pitch in camera-local space.
292        let proposed = (camera.orientation * glam::Quat::from_rotation_x(-pitch)).normalize();
293
294        // The eye direction is `orientation * Z`. Its Z-component equals
295        // sin(elevation_angle), so clamping it to sin(+/-89 deg) keeps the camera
296        // away from the poles.
297        let max_sin_el = 89.0_f32.to_radians().sin(); // ~ 0.9998
298        let eye_z = (proposed * glam::Vec3::Z).z;
299
300        if eye_z.abs() <= max_sin_el {
301            camera.orientation = proposed;
302        }
303        // At the pole limit: silently discard the pitch to avoid jitter.
304    }
305}
306
307/// Apply mouselook (yaw + pitch) while keeping the camera eye position fixed.
308///
309/// The orbit center is adjusted so that `eye = center + orientation * Z * distance`
310/// remains constant after the rotation.
311fn apply_fly_look(camera: &mut Camera, yaw: f32, pitch: f32) {
312    let eye = camera.eye_position();
313    camera.orientation = (glam::Quat::from_rotation_z(-yaw)
314        * camera.orientation
315        * glam::Quat::from_rotation_x(-pitch))
316    .normalize();
317    // Re-derive center so the eye does not shift.
318    camera.center = eye - camera.orientation * (glam::Vec3::Z * camera.distance);
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::interaction::input::binding::KeyCode;
325    use crate::interaction::input::event::{ButtonState, ScrollUnits, ViewportEvent};
326
327    fn make_ctx() -> ViewportContext {
328        ViewportContext {
329            hovered: true,
330            focused: true,
331            viewport_size: [800.0, 600.0],
332        }
333    }
334
335    #[test]
336    fn new_defaults() {
337        let ctrl = OrbitCameraController::viewport_primitives();
338        assert_eq!(ctrl.navigation_mode, NavigationMode::Arcball);
339        assert!((ctrl.fly_speed - OrbitCameraController::DEFAULT_FLY_SPEED).abs() < 1e-6);
340        assert!(
341            (ctrl.orbit_sensitivity - OrbitCameraController::DEFAULT_ORBIT_SENSITIVITY).abs()
342                < 1e-6
343        );
344    }
345
346    #[test]
347    fn resolve_no_events_zero_nav() {
348        let mut ctrl = OrbitCameraController::viewport_all();
349        ctrl.begin_frame(make_ctx());
350        let frame = ctrl.resolve();
351        assert_eq!(frame.navigation.orbit, glam::Vec2::ZERO);
352        assert_eq!(frame.navigation.pan, glam::Vec2::ZERO);
353        assert_eq!(frame.navigation.zoom, 0.0);
354    }
355
356    #[test]
357    fn apply_zoom_changes_distance() {
358        let mut ctrl = OrbitCameraController::viewport_primitives();
359        ctrl.begin_frame(make_ctx());
360        let mut cam = Camera::default();
361        let d0 = cam.distance;
362        ctrl.push_event(ViewportEvent::Wheel {
363            delta: glam::Vec2::new(0.0, 100.0),
364            units: ScrollUnits::Pixels,
365        });
366        ctrl.apply_to_camera(&mut cam);
367        assert!(
368            (cam.distance - d0).abs() > 1e-4,
369            "zoom should change camera distance"
370        );
371    }
372
373    #[test]
374    fn planar_mode_ignores_orbit() {
375        let mut ctrl = OrbitCameraController::viewport_primitives();
376        ctrl.navigation_mode = NavigationMode::Planar;
377        ctrl.begin_frame(make_ctx());
378        let mut cam = Camera::default();
379        let orient_before = cam.orientation;
380        // Simulate a left-drag (orbit gesture in primitives preset)
381        ctrl.push_event(ViewportEvent::PointerMoved {
382            position: glam::Vec2::new(100.0, 100.0),
383        });
384        ctrl.push_event(ViewportEvent::MouseButton {
385            button: crate::interaction::input::binding::MouseButton::Left,
386            state: ButtonState::Pressed,
387        });
388        ctrl.push_event(ViewportEvent::PointerMoved {
389            position: glam::Vec2::new(200.0, 200.0),
390        });
391        ctrl.apply_to_camera(&mut cam);
392        assert!(
393            (cam.orientation.x - orient_before.x).abs() < 1e-6
394                && (cam.orientation.y - orient_before.y).abs() < 1e-6
395                && (cam.orientation.z - orient_before.z).abs() < 1e-6
396                && (cam.orientation.w - orient_before.w).abs() < 1e-6,
397            "planar mode should not change orientation"
398        );
399    }
400
401    #[test]
402    fn turntable_pitch_clamped() {
403        let mut cam = Camera::default();
404        // Try to apply extreme pitch
405        for _ in 0..1000 {
406            apply_turntable(&mut cam, 0.0, 0.1);
407        }
408        // Check eye direction Z component stays within sin(89 deg)
409        let eye_z = (cam.orientation * glam::Vec3::Z).z;
410        let max_sin = 89.0_f32.to_radians().sin();
411        assert!(
412            eye_z.abs() <= max_sin + 1e-4,
413            "turntable pitch should be clamped: eye_z={eye_z}"
414        );
415    }
416
417    #[test]
418    fn fly_look_preserves_eye() {
419        let mut cam = Camera::default();
420        let eye_before = cam.eye_position();
421        apply_fly_look(&mut cam, 0.3, 0.2);
422        let eye_after = cam.eye_position();
423        let diff = (eye_after - eye_before).length();
424        assert!(
425            diff < 1e-3,
426            "firstperson look should preserve eye position, diff={diff}"
427        );
428    }
429
430    #[test]
431    fn fly_moves_camera() {
432        let mut ctrl = OrbitCameraController::viewport_all();
433        ctrl.navigation_mode = NavigationMode::Fly;
434        ctrl.fly_speed = 1.0;
435        ctrl.begin_frame(make_ctx());
436        let mut cam = Camera::default();
437        cam.center = glam::Vec3::ZERO;
438        cam.orientation = glam::Quat::IDENTITY;
439        let center_before = cam.center;
440        ctrl.push_event(ViewportEvent::Key {
441            key: KeyCode::W,
442            state: ButtonState::Pressed,
443            repeat: false,
444        });
445        ctrl.apply_to_camera(&mut cam);
446        assert!(
447            (cam.center - center_before).length() > 0.5,
448            "FlyForward should move camera center"
449        );
450    }
451
452    #[test]
453    fn apply_consumes_external_frame() {
454        // The frame-consumer path applies an already-resolved ActionFrame
455        // without draining events, the basis for sharing one resolver across
456        // the controller family.
457        let ctrl = OrbitCameraController::viewport_primitives();
458        let mut cam = Camera::default();
459        let before = cam.orientation;
460        let mut frame = ActionFrame::default();
461        frame.navigation.orbit = glam::Vec2::new(20.0, 0.0);
462        ctrl.apply(&mut cam, &frame);
463        assert_ne!(
464            cam.orientation, before,
465            "apply(&frame) should orbit from the frame's nav delta"
466        );
467    }
468}