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::FirstPerson`], 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    /// Push a single viewport-scoped event into the accumulator.
128    ///
129    /// Call this from the host's event handler whenever a relevant native event
130    /// arrives, after translating it to a [`ViewportEvent`].
131    pub fn push_event(&mut self, event: ViewportEvent) {
132        self.input.push_event(event);
133    }
134
135    /// Resolve accumulated events into an [`ActionFrame`] without applying any
136    /// camera navigation.
137    ///
138    /// Use this when the caller needs to inspect actions but camera movement
139    /// should be suppressed : for example during gizmo manipulation or fly mode
140    /// where the camera is driven by other logic.
141    pub fn resolve(&self) -> ActionFrame {
142        self.input.resolve()
143    }
144
145    /// Resolve accumulated events, apply camera navigation, and return the
146    /// [`ActionFrame`] for this frame.
147    ///
148    /// Call this in the render / update step, **before** `begin_frame` for the
149    /// next frame.
150    ///
151    /// The behavior of orbit drag depends on [`Self::navigation_mode`]:
152    /// - [`NavigationMode::Arcball`]: unconstrained arcball (default).
153    /// - [`NavigationMode::Turntable`]: yaw around world Z, pitch clamped to ±89°.
154    /// - [`NavigationMode::Planar`]: pan only, orbit input is ignored.
155    /// - [`NavigationMode::FirstPerson`]: mouselook + WASD translation. Requires
156    ///   the `ViewportAll` binding preset so that movement keys are resolved.
157    pub fn apply_to_camera(&mut self, camera: &mut Camera) -> ActionFrame {
158        let frame = self.input.resolve();
159        let nav = &frame.navigation;
160        let h = self.viewport_size[1];
161
162        match self.navigation_mode {
163            NavigationMode::Arcball => {
164                if nav.orbit != glam::Vec2::ZERO {
165                    camera.orbit(
166                        nav.orbit.x * self.orbit_sensitivity,
167                        nav.orbit.y * self.orbit_sensitivity,
168                    );
169                }
170                if nav.twist != 0.0 && self.gesture_sensitivity != 0.0 {
171                    camera.orbit(nav.twist * self.gesture_sensitivity, 0.0);
172                }
173                if nav.pan != glam::Vec2::ZERO {
174                    camera.pan_pixels(nav.pan, h);
175                }
176                if nav.zoom != 0.0 {
177                    camera.zoom_by_factor(1.0 - nav.zoom * self.zoom_sensitivity);
178                }
179            }
180
181            NavigationMode::Turntable => {
182                if nav.orbit != glam::Vec2::ZERO {
183                    let yaw = nav.orbit.x * self.orbit_sensitivity;
184                    let pitch = nav.orbit.y * self.orbit_sensitivity;
185                    apply_turntable(camera, yaw, pitch);
186                }
187                if nav.twist != 0.0 && self.gesture_sensitivity != 0.0 {
188                    // Gesture twist is yaw-only in turntable mode.
189                    apply_turntable(camera, nav.twist * self.gesture_sensitivity, 0.0);
190                }
191                if nav.pan != glam::Vec2::ZERO {
192                    camera.pan_pixels(nav.pan, h);
193                }
194                if nav.zoom != 0.0 {
195                    camera.zoom_by_factor(1.0 - nav.zoom * self.zoom_sensitivity);
196                }
197            }
198
199            NavigationMode::Planar => {
200                // Orbit input is silently ignored; pan and zoom still work.
201                if nav.pan != glam::Vec2::ZERO {
202                    camera.pan_pixels(nav.pan, h);
203                }
204                if nav.zoom != 0.0 {
205                    camera.zoom_by_factor(1.0 - nav.zoom * self.zoom_sensitivity);
206                }
207            }
208
209            NavigationMode::FirstPerson => {
210                // Mouselook: drag rotates the view while the eye stays fixed.
211                if nav.orbit != glam::Vec2::ZERO {
212                    let yaw = nav.orbit.x * self.orbit_sensitivity;
213                    let pitch = nav.orbit.y * self.orbit_sensitivity;
214                    apply_firstperson_look(camera, yaw, pitch);
215                }
216                if nav.twist != 0.0 && self.gesture_sensitivity != 0.0 {
217                    apply_firstperson_look(camera, nav.twist * self.gesture_sensitivity, 0.0);
218                }
219
220                // WASD / QE translation.
221                let forward = -(camera.orientation * glam::Vec3::Z);
222                let right = camera.orientation * glam::Vec3::X;
223                let up = camera.orientation * glam::Vec3::Y;
224                let speed = self.fly_speed;
225
226                let mut move_delta = glam::Vec3::ZERO;
227                if frame.is_active(Action::FlyForward) {
228                    move_delta += forward * speed;
229                }
230                if frame.is_active(Action::FlyBackward) {
231                    move_delta -= forward * speed;
232                }
233                if frame.is_active(Action::FlyRight) {
234                    move_delta += right * speed;
235                }
236                if frame.is_active(Action::FlyLeft) {
237                    move_delta -= right * speed;
238                }
239                if frame.is_active(Action::FlyUp) {
240                    move_delta += up * speed;
241                }
242                if frame.is_active(Action::FlyDown) {
243                    move_delta -= up * speed;
244                }
245                // Translate center (and thus eye) without changing orientation or distance.
246                camera.center += move_delta;
247            }
248        }
249
250        frame
251    }
252}
253
254// ---------------------------------------------------------------------------
255// Navigation mode helpers
256// ---------------------------------------------------------------------------
257
258/// Apply a turntable yaw + clamped pitch to the camera.
259///
260/// Yaw rotates around world Z. Pitch changes elevation in camera-local space,
261/// but is blocked when the eye would reach ±89° above/below the XY plane.
262fn apply_turntable(camera: &mut Camera, yaw: f32, pitch: f32) {
263    // Yaw: pre-multiply by a world-Z rotation (same as the yaw component of Camera::orbit).
264    if yaw != 0.0 {
265        camera.orientation = (glam::Quat::from_rotation_z(-yaw) * camera.orientation).normalize();
266    }
267
268    if pitch != 0.0 {
269        // Proposed orientation after applying pitch in camera-local space.
270        let proposed = (camera.orientation * glam::Quat::from_rotation_x(-pitch)).normalize();
271
272        // The eye direction is `orientation * Z`. Its Z-component equals
273        // sin(elevation_angle), so clamping it to sin(±89°) keeps the camera
274        // away from the poles.
275        let max_sin_el = 89.0_f32.to_radians().sin(); // ≈ 0.9998
276        let eye_z = (proposed * glam::Vec3::Z).z;
277
278        if eye_z.abs() <= max_sin_el {
279            camera.orientation = proposed;
280        }
281        // At the pole limit: silently discard the pitch to avoid jitter.
282    }
283}
284
285/// Apply mouselook (yaw + pitch) while keeping the camera eye position fixed.
286///
287/// The orbit center is adjusted so that `eye = center + orientation * Z * distance`
288/// remains constant after the rotation.
289fn apply_firstperson_look(camera: &mut Camera, yaw: f32, pitch: f32) {
290    let eye = camera.eye_position();
291    camera.orientation = (glam::Quat::from_rotation_z(-yaw)
292        * camera.orientation
293        * glam::Quat::from_rotation_x(-pitch))
294    .normalize();
295    // Re-derive center so the eye does not shift.
296    camera.center = eye - camera.orientation * (glam::Vec3::Z * camera.distance);
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use crate::interaction::input::binding::KeyCode;
303    use crate::interaction::input::event::{ButtonState, ScrollUnits, ViewportEvent};
304
305    fn make_ctx() -> ViewportContext {
306        ViewportContext {
307            hovered: true,
308            focused: true,
309            viewport_size: [800.0, 600.0],
310        }
311    }
312
313    #[test]
314    fn new_defaults() {
315        let ctrl = OrbitCameraController::viewport_primitives();
316        assert_eq!(ctrl.navigation_mode, NavigationMode::Arcball);
317        assert!((ctrl.fly_speed - OrbitCameraController::DEFAULT_FLY_SPEED).abs() < 1e-6);
318        assert!(
319            (ctrl.orbit_sensitivity - OrbitCameraController::DEFAULT_ORBIT_SENSITIVITY).abs()
320                < 1e-6
321        );
322    }
323
324    #[test]
325    fn resolve_no_events_zero_nav() {
326        let mut ctrl = OrbitCameraController::viewport_all();
327        ctrl.begin_frame(make_ctx());
328        let frame = ctrl.resolve();
329        assert_eq!(frame.navigation.orbit, glam::Vec2::ZERO);
330        assert_eq!(frame.navigation.pan, glam::Vec2::ZERO);
331        assert_eq!(frame.navigation.zoom, 0.0);
332    }
333
334    #[test]
335    fn apply_zoom_changes_distance() {
336        let mut ctrl = OrbitCameraController::viewport_primitives();
337        ctrl.begin_frame(make_ctx());
338        let mut cam = Camera::default();
339        let d0 = cam.distance;
340        ctrl.push_event(ViewportEvent::Wheel {
341            delta: glam::Vec2::new(0.0, 100.0),
342            units: ScrollUnits::Pixels,
343        });
344        ctrl.apply_to_camera(&mut cam);
345        assert!(
346            (cam.distance - d0).abs() > 1e-4,
347            "zoom should change camera distance"
348        );
349    }
350
351    #[test]
352    fn planar_mode_ignores_orbit() {
353        let mut ctrl = OrbitCameraController::viewport_primitives();
354        ctrl.navigation_mode = NavigationMode::Planar;
355        ctrl.begin_frame(make_ctx());
356        let mut cam = Camera::default();
357        let orient_before = cam.orientation;
358        // Simulate a left-drag (orbit gesture in primitives preset)
359        ctrl.push_event(ViewportEvent::PointerMoved {
360            position: glam::Vec2::new(100.0, 100.0),
361        });
362        ctrl.push_event(ViewportEvent::MouseButton {
363            button: crate::interaction::input::binding::MouseButton::Left,
364            state: ButtonState::Pressed,
365        });
366        ctrl.push_event(ViewportEvent::PointerMoved {
367            position: glam::Vec2::new(200.0, 200.0),
368        });
369        ctrl.apply_to_camera(&mut cam);
370        assert!(
371            (cam.orientation.x - orient_before.x).abs() < 1e-6
372                && (cam.orientation.y - orient_before.y).abs() < 1e-6
373                && (cam.orientation.z - orient_before.z).abs() < 1e-6
374                && (cam.orientation.w - orient_before.w).abs() < 1e-6,
375            "planar mode should not change orientation"
376        );
377    }
378
379    #[test]
380    fn turntable_pitch_clamped() {
381        let mut cam = Camera::default();
382        // Try to apply extreme pitch
383        for _ in 0..1000 {
384            apply_turntable(&mut cam, 0.0, 0.1);
385        }
386        // Check eye direction Z component stays within sin(89°)
387        let eye_z = (cam.orientation * glam::Vec3::Z).z;
388        let max_sin = 89.0_f32.to_radians().sin();
389        assert!(
390            eye_z.abs() <= max_sin + 1e-4,
391            "turntable pitch should be clamped: eye_z={eye_z}"
392        );
393    }
394
395    #[test]
396    fn firstperson_look_preserves_eye() {
397        let mut cam = Camera::default();
398        let eye_before = cam.eye_position();
399        apply_firstperson_look(&mut cam, 0.3, 0.2);
400        let eye_after = cam.eye_position();
401        let diff = (eye_after - eye_before).length();
402        assert!(
403            diff < 1e-3,
404            "firstperson look should preserve eye position, diff={diff}"
405        );
406    }
407
408    #[test]
409    fn firstperson_fly_moves_camera() {
410        let mut ctrl = OrbitCameraController::viewport_all();
411        ctrl.navigation_mode = NavigationMode::FirstPerson;
412        ctrl.fly_speed = 1.0;
413        ctrl.begin_frame(make_ctx());
414        let mut cam = Camera::default();
415        cam.center = glam::Vec3::ZERO;
416        cam.orientation = glam::Quat::IDENTITY;
417        let center_before = cam.center;
418        ctrl.push_event(ViewportEvent::Key {
419            key: KeyCode::W,
420            state: ButtonState::Pressed,
421            repeat: false,
422        });
423        ctrl.apply_to_camera(&mut cam);
424        assert!(
425            (cam.center - center_before).length() > 0.5,
426            "FlyForward should move camera center"
427        );
428    }
429}