Skip to main content

viewport_lib/camera/controllers/
orbit.rs

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