Skip to main content

viewport_lib/interaction/input/
controller.rs

1//! High-level orbit/pan/zoom camera controller.
2//!
3//! [`OrbitCameraController`] is the ergonomic entry point for standard viewport
4//! camera navigation. It wraps [`super::viewport_input::ViewportInput`] and
5//! applies resolved orbit / pan / zoom actions directly to a [`crate::Camera`].
6
7use crate::Camera;
8
9use super::action::Action;
10use super::action_frame::ActionFrame;
11use super::context::ViewportContext;
12use super::event::ViewportEvent;
13use super::mode::NavigationMode;
14use super::preset::{BindingPreset, viewport_all_bindings, viewport_primitives_bindings};
15use super::viewport_input::ViewportInput;
16
17/// High-level orbit / pan / zoom camera controller.
18///
19/// Wraps the lower-level [`ViewportInput`] resolver and applies semantic
20/// camera actions to a [`Camera`] in a single `apply_to_camera` call.
21///
22/// # Integration pattern (winit / single window)
23///
24/// ```text
25/// // --- AppState construction ---
26/// controller.begin_frame(ViewportContext { hovered: true, focused: true, viewport_size });
27///
28/// // --- window_event ---
29/// controller.push_event(translated_event);
30///
31/// // --- RedrawRequested ---
32/// controller.apply_to_camera(&mut state.camera);
33/// controller.begin_frame(ViewportContext { hovered: true, focused: true, viewport_size });
34/// // ... render ...
35/// ```
36///
37/// # Integration pattern (eframe / egui)
38///
39/// ```text
40/// // --- update() ---
41/// controller.begin_frame(ViewportContext {
42///     hovered: response.hovered(),
43///     focused: response.has_focus(),
44///     viewport_size: [rect.width(), rect.height()],
45/// });
46/// // push events from ui.input(|i| { ... })
47/// controller.apply_to_camera(&mut self.camera);
48/// ```
49pub struct OrbitCameraController {
50    input: ViewportInput,
51    /// How drag input is interpreted by [`apply_to_camera`](Self::apply_to_camera).
52    ///
53    /// Defaults to [`NavigationMode::Arcball`].
54    pub navigation_mode: NavigationMode,
55    /// Movement speed for [`NavigationMode::FirstPerson`], in world units per frame.
56    ///
57    /// Defaults to `0.1`. Scale this to match your scene : a value of `0.1` is
58    /// suitable for a scene with objects a few units across.
59    pub fly_speed: f32,
60    /// Sensitivity for drag-based orbit (radians per pixel).
61    pub orbit_sensitivity: f32,
62    /// Sensitivity for scroll-based zoom (scale factor per pixel).
63    pub zoom_sensitivity: f32,
64    /// Sensitivity applied to two-finger trackpad rotation gesture (radians per radian).
65    /// Default: `1.0` (gesture angle applied directly to camera yaw).
66    /// Set to `0.0` to suppress the gesture entirely.
67    pub gesture_sensitivity: f32,
68    /// Current viewport size (cached from the last `begin_frame`).
69    viewport_size: [f32; 2],
70}
71
72impl OrbitCameraController {
73    /// Default drag orbit sensitivity: 0.005 radians per pixel.
74    pub const DEFAULT_ORBIT_SENSITIVITY: f32 = 0.005;
75    /// Default scroll zoom sensitivity: 0.001 scale per pixel.
76    pub const DEFAULT_ZOOM_SENSITIVITY: f32 = 0.001;
77    /// Default gesture sensitivity: 1.0 (gesture radians applied 1:1 to camera yaw).
78    pub const DEFAULT_GESTURE_SENSITIVITY: f32 = 1.0;
79    /// Default first-person fly speed: 0.1 world units per frame.
80    pub const DEFAULT_FLY_SPEED: f32 = 0.1;
81
82    /// Create a controller from the given binding preset.
83    pub fn new(preset: BindingPreset) -> Self {
84        let bindings = match preset {
85            BindingPreset::ViewportPrimitives => viewport_primitives_bindings(),
86            BindingPreset::ViewportAll => viewport_all_bindings(),
87        };
88        Self {
89            input: ViewportInput::new(bindings),
90            navigation_mode: NavigationMode::Arcball,
91            fly_speed: Self::DEFAULT_FLY_SPEED,
92            orbit_sensitivity: Self::DEFAULT_ORBIT_SENSITIVITY,
93            zoom_sensitivity: Self::DEFAULT_ZOOM_SENSITIVITY,
94            gesture_sensitivity: Self::DEFAULT_GESTURE_SENSITIVITY,
95            viewport_size: [1.0, 1.0],
96        }
97    }
98
99    /// Create a controller with the [`BindingPreset::ViewportPrimitives`] preset.
100    ///
101    /// This is the canonical control scheme matching `examples/winit_primitives`.
102    pub fn viewport_primitives() -> Self {
103        Self::new(BindingPreset::ViewportPrimitives)
104    }
105
106    /// Create a controller with the [`BindingPreset::ViewportAll`] preset.
107    ///
108    /// Includes all camera navigation bindings plus keyboard shortcuts for
109    /// normal mode, fly mode, and manipulation mode. Use this to replace
110    /// [`crate::InputSystem`] entirely.
111    pub fn viewport_all() -> Self {
112        Self::new(BindingPreset::ViewportAll)
113    }
114
115    /// Begin a new frame.
116    ///
117    /// Resets per-frame accumulators and records viewport context (hover/focus
118    /// state and size). Call this at the **end** of each rendered frame : after
119    /// `apply_to_camera` : so the accumulator is ready for the next batch of
120    /// events.
121    ///
122    /// Also call once immediately after construction to prime the accumulator.
123    pub fn begin_frame(&mut self, ctx: ViewportContext) {
124        self.viewport_size = ctx.viewport_size;
125        self.input.begin_frame(ctx);
126    }
127
128    /// Push a single viewport-scoped event into the accumulator.
129    ///
130    /// Call this from the host's event handler whenever a relevant native event
131    /// arrives, after translating it to a [`ViewportEvent`].
132    pub fn push_event(&mut self, event: ViewportEvent) {
133        self.input.push_event(event);
134    }
135
136    /// Resolve accumulated events into an [`ActionFrame`] without applying any
137    /// camera navigation.
138    ///
139    /// Use this when the caller needs to inspect actions but camera movement
140    /// should be suppressed : for example during gizmo manipulation or fly mode
141    /// where the camera is driven by other logic.
142    pub fn resolve(&self) -> ActionFrame {
143        self.input.resolve()
144    }
145
146    /// Resolve accumulated events, apply camera navigation, and return the
147    /// [`ActionFrame`] for this frame.
148    ///
149    /// Call this in the render / update step, **before** `begin_frame` for the
150    /// next frame.
151    ///
152    /// The behavior of orbit drag depends on [`Self::navigation_mode`]:
153    /// - [`NavigationMode::Arcball`]: unconstrained arcball (default).
154    /// - [`NavigationMode::Turntable`]: yaw around world Z, pitch clamped to ±89°.
155    /// - [`NavigationMode::Planar`]: pan only, orbit input is ignored.
156    /// - [`NavigationMode::FirstPerson`]: mouselook + WASD translation. Requires
157    ///   the `ViewportAll` binding preset so that movement keys are resolved.
158    pub fn apply_to_camera(&mut self, camera: &mut Camera) -> ActionFrame {
159        let frame = self.input.resolve();
160        let nav = &frame.navigation;
161        let h = self.viewport_size[1];
162
163        match self.navigation_mode {
164            NavigationMode::Arcball => {
165                if nav.orbit != glam::Vec2::ZERO {
166                    camera.orbit(
167                        nav.orbit.x * self.orbit_sensitivity,
168                        nav.orbit.y * self.orbit_sensitivity,
169                    );
170                }
171                if nav.twist != 0.0 && self.gesture_sensitivity != 0.0 {
172                    camera.orbit(nav.twist * self.gesture_sensitivity, 0.0);
173                }
174                if nav.pan != glam::Vec2::ZERO {
175                    camera.pan_pixels(nav.pan, h);
176                }
177                if nav.zoom != 0.0 {
178                    camera.zoom_by_factor(1.0 - nav.zoom * self.zoom_sensitivity);
179                }
180            }
181
182            NavigationMode::Turntable => {
183                if nav.orbit != glam::Vec2::ZERO {
184                    let yaw = nav.orbit.x * self.orbit_sensitivity;
185                    let pitch = nav.orbit.y * self.orbit_sensitivity;
186                    apply_turntable(camera, yaw, pitch);
187                }
188                if nav.twist != 0.0 && self.gesture_sensitivity != 0.0 {
189                    // Gesture twist is yaw-only in turntable mode.
190                    apply_turntable(camera, nav.twist * self.gesture_sensitivity, 0.0);
191                }
192                if nav.pan != glam::Vec2::ZERO {
193                    camera.pan_pixels(nav.pan, h);
194                }
195                if nav.zoom != 0.0 {
196                    camera.zoom_by_factor(1.0 - nav.zoom * self.zoom_sensitivity);
197                }
198            }
199
200            NavigationMode::Planar => {
201                // Orbit input is silently ignored; pan and zoom still work.
202                if nav.pan != glam::Vec2::ZERO {
203                    camera.pan_pixels(nav.pan, h);
204                }
205                if nav.zoom != 0.0 {
206                    camera.zoom_by_factor(1.0 - nav.zoom * self.zoom_sensitivity);
207                }
208            }
209
210            NavigationMode::FirstPerson => {
211                // Mouselook: drag rotates the view while the eye stays fixed.
212                if nav.orbit != glam::Vec2::ZERO {
213                    let yaw = nav.orbit.x * self.orbit_sensitivity;
214                    let pitch = nav.orbit.y * self.orbit_sensitivity;
215                    apply_firstperson_look(camera, yaw, pitch);
216                }
217                if nav.twist != 0.0 && self.gesture_sensitivity != 0.0 {
218                    apply_firstperson_look(camera, nav.twist * self.gesture_sensitivity, 0.0);
219                }
220
221                // WASD / QE translation.
222                let forward = -(camera.orientation * glam::Vec3::Z);
223                let right = camera.orientation * glam::Vec3::X;
224                let up = camera.orientation * glam::Vec3::Y;
225                let speed = self.fly_speed;
226
227                let mut move_delta = glam::Vec3::ZERO;
228                if frame.is_active(Action::FlyForward) {
229                    move_delta += forward * speed;
230                }
231                if frame.is_active(Action::FlyBackward) {
232                    move_delta -= forward * speed;
233                }
234                if frame.is_active(Action::FlyRight) {
235                    move_delta += right * speed;
236                }
237                if frame.is_active(Action::FlyLeft) {
238                    move_delta -= right * speed;
239                }
240                if frame.is_active(Action::FlyUp) {
241                    move_delta += up * speed;
242                }
243                if frame.is_active(Action::FlyDown) {
244                    move_delta -= up * speed;
245                }
246                // Translate center (and thus eye) without changing orientation or distance.
247                camera.center += move_delta;
248            }
249        }
250
251        frame
252    }
253}
254
255// ---------------------------------------------------------------------------
256// Navigation mode helpers
257// ---------------------------------------------------------------------------
258
259/// Apply a turntable yaw + clamped pitch to the camera.
260///
261/// Yaw rotates around world Z. Pitch changes elevation in camera-local space,
262/// but is blocked when the eye would reach ±89° above/below the XY plane.
263fn apply_turntable(camera: &mut Camera, yaw: f32, pitch: f32) {
264    // Yaw: pre-multiply by a world-Z rotation (same as the yaw component of Camera::orbit).
265    if yaw != 0.0 {
266        camera.orientation = (glam::Quat::from_rotation_z(-yaw) * camera.orientation).normalize();
267    }
268
269    if pitch != 0.0 {
270        // Proposed orientation after applying pitch in camera-local space.
271        let proposed = (camera.orientation * glam::Quat::from_rotation_x(-pitch)).normalize();
272
273        // The eye direction is `orientation * Z`. Its Z-component equals
274        // sin(elevation_angle), so clamping it to sin(±89°) keeps the camera
275        // away from the poles.
276        let max_sin_el = 89.0_f32.to_radians().sin(); // ≈ 0.9998
277        let eye_z = (proposed * glam::Vec3::Z).z;
278
279        if eye_z.abs() <= max_sin_el {
280            camera.orientation = proposed;
281        }
282        // At the pole limit: silently discard the pitch to avoid jitter.
283    }
284}
285
286/// Apply mouselook (yaw + pitch) while keeping the camera eye position fixed.
287///
288/// The orbit center is adjusted so that `eye = center + orientation * Z * distance`
289/// remains constant after the rotation.
290fn apply_firstperson_look(camera: &mut Camera, yaw: f32, pitch: f32) {
291    let eye = camera.eye_position();
292    camera.orientation = (glam::Quat::from_rotation_z(-yaw)
293        * camera.orientation
294        * glam::Quat::from_rotation_x(-pitch))
295    .normalize();
296    // Re-derive center so the eye does not shift.
297    camera.center = eye - camera.orientation * (glam::Vec3::Z * camera.distance);
298}