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}