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}