viewport-lib 0.13.3

3D viewport rendering library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
//! Orbit/pan/zoom camera controller.
//!
//! [`OrbitCameraController`] wraps [`super::viewport_input::ViewportInput`] and
//! applies resolved orbit / pan / zoom actions directly to a [`crate::Camera`].

use crate::Camera;

use super::action::Action;
use super::action_frame::ActionFrame;
use super::context::ViewportContext;
use super::event::ViewportEvent;
use super::mode::NavigationMode;
use super::preset::{BindingPreset, viewport_all_bindings, viewport_primitives_bindings};
use super::viewport_input::ViewportInput;

/// High-level orbit / pan / zoom camera controller.
///
/// Wraps the lower-level [`ViewportInput`] resolver and applies semantic
/// camera actions to a [`Camera`] in a single `apply_to_camera` call.
///
/// # Integration pattern (winit / single window)
///
/// ```text
/// // --- AppState construction ---
/// controller.begin_frame(ViewportContext { hovered: true, focused: true, viewport_size });
///
/// // --- window_event ---
/// controller.push_event(translated_event);
///
/// // --- RedrawRequested ---
/// controller.apply_to_camera(&mut state.camera);
/// controller.begin_frame(ViewportContext { hovered: true, focused: true, viewport_size });
/// // ... render ...
/// ```
///
/// # Integration pattern (eframe / egui)
///
/// ```text
/// // --- update() ---
/// controller.begin_frame(ViewportContext {
///     hovered: response.hovered(),
///     focused: response.has_focus(),
///     viewport_size: [rect.width(), rect.height()],
/// });
/// // push events from ui.input(|i| { ... })
/// controller.apply_to_camera(&mut self.camera);
/// ```
pub struct OrbitCameraController {
    input: ViewportInput,
    /// How drag input is interpreted by [`apply_to_camera`](Self::apply_to_camera).
    ///
    /// Defaults to [`NavigationMode::Arcball`].
    pub navigation_mode: NavigationMode,
    /// Movement speed for [`NavigationMode::FirstPerson`], in world units per frame.
    ///
    /// Defaults to `0.1`. Scale this to match your scene : a value of `0.1` is
    /// suitable for a scene with objects a few units across.
    pub fly_speed: f32,
    /// Sensitivity for drag-based orbit (radians per pixel).
    pub orbit_sensitivity: f32,
    /// Sensitivity for scroll-based zoom (scale factor per pixel).
    pub zoom_sensitivity: f32,
    /// Sensitivity applied to two-finger trackpad rotation gesture (radians per radian).
    /// Default: `1.0` (gesture angle applied directly to camera yaw).
    /// Set to `0.0` to suppress the gesture entirely.
    pub gesture_sensitivity: f32,
    /// Current viewport size (cached from the last `begin_frame`).
    viewport_size: [f32; 2],
}

impl OrbitCameraController {
    /// Default drag orbit sensitivity: 0.005 radians per pixel.
    pub const DEFAULT_ORBIT_SENSITIVITY: f32 = 0.005;
    /// Default scroll zoom sensitivity: 0.001 scale per pixel.
    pub const DEFAULT_ZOOM_SENSITIVITY: f32 = 0.001;
    /// Default gesture sensitivity: 1.0 (gesture radians applied 1:1 to camera yaw).
    pub const DEFAULT_GESTURE_SENSITIVITY: f32 = 1.0;
    /// Default first-person fly speed: 0.1 world units per frame.
    pub const DEFAULT_FLY_SPEED: f32 = 0.1;

    /// Create a controller from the given binding preset.
    pub fn new(preset: BindingPreset) -> Self {
        let bindings = match preset {
            BindingPreset::ViewportPrimitives => viewport_primitives_bindings(),
            BindingPreset::ViewportAll => viewport_all_bindings(),
        };
        Self {
            input: ViewportInput::new(bindings),
            navigation_mode: NavigationMode::Arcball,
            fly_speed: Self::DEFAULT_FLY_SPEED,
            orbit_sensitivity: Self::DEFAULT_ORBIT_SENSITIVITY,
            zoom_sensitivity: Self::DEFAULT_ZOOM_SENSITIVITY,
            gesture_sensitivity: Self::DEFAULT_GESTURE_SENSITIVITY,
            viewport_size: [1.0, 1.0],
        }
    }

    /// Create a controller with the [`BindingPreset::ViewportPrimitives`] preset.
    ///
    /// This is the canonical control scheme matching `examples/winit_primitives`.
    pub fn viewport_primitives() -> Self {
        Self::new(BindingPreset::ViewportPrimitives)
    }

    /// Create a controller with the [`BindingPreset::ViewportAll`] preset.
    ///
    /// Includes all camera navigation bindings plus keyboard shortcuts for
    /// normal mode, fly mode, and manipulation mode. Use this to replace
    /// [`crate::InputSystem`] entirely.
    pub fn viewport_all() -> Self {
        Self::new(BindingPreset::ViewportAll)
    }

    /// Begin a new frame.
    ///
    /// Resets per-frame accumulators and records viewport context (hover/focus
    /// state and size). Call this at the **end** of each rendered frame : after
    /// `apply_to_camera` : so the accumulator is ready for the next batch of
    /// events.
    ///
    /// Also call once immediately after construction to prime the accumulator.
    pub fn begin_frame(&mut self, ctx: ViewportContext) {
        self.viewport_size = ctx.viewport_size;
        self.input.begin_frame(ctx);
    }

    /// Push a single viewport-scoped event into the accumulator.
    ///
    /// Call this from the host's event handler whenever a relevant native event
    /// arrives, after translating it to a [`ViewportEvent`].
    pub fn push_event(&mut self, event: ViewportEvent) {
        self.input.push_event(event);
    }

    /// Resolve accumulated events into an [`ActionFrame`] without applying any
    /// camera navigation.
    ///
    /// Use this when the caller needs to inspect actions but camera movement
    /// should be suppressed : for example during gizmo manipulation or fly mode
    /// where the camera is driven by other logic.
    pub fn resolve(&self) -> ActionFrame {
        self.input.resolve()
    }

    /// Resolve accumulated events, apply camera navigation, and return the
    /// [`ActionFrame`] for this frame.
    ///
    /// Call this in the render / update step, **before** `begin_frame` for the
    /// next frame.
    ///
    /// The behavior of orbit drag depends on [`Self::navigation_mode`]:
    /// - [`NavigationMode::Arcball`]: unconstrained arcball (default).
    /// - [`NavigationMode::Turntable`]: yaw around world Z, pitch clamped to ±89°.
    /// - [`NavigationMode::Planar`]: pan only, orbit input is ignored.
    /// - [`NavigationMode::FirstPerson`]: mouselook + WASD translation. Requires
    ///   the `ViewportAll` binding preset so that movement keys are resolved.
    pub fn apply_to_camera(&mut self, camera: &mut Camera) -> ActionFrame {
        let frame = self.input.resolve();
        let nav = &frame.navigation;
        let h = self.viewport_size[1];

        match self.navigation_mode {
            NavigationMode::Arcball => {
                if nav.orbit != glam::Vec2::ZERO {
                    camera.orbit(
                        nav.orbit.x * self.orbit_sensitivity,
                        nav.orbit.y * self.orbit_sensitivity,
                    );
                }
                if nav.twist != 0.0 && self.gesture_sensitivity != 0.0 {
                    camera.orbit(nav.twist * self.gesture_sensitivity, 0.0);
                }
                if nav.pan != glam::Vec2::ZERO {
                    camera.pan_pixels(nav.pan, h);
                }
                if nav.zoom != 0.0 {
                    camera.zoom_by_factor(1.0 - nav.zoom * self.zoom_sensitivity);
                }
            }

            NavigationMode::Turntable => {
                if nav.orbit != glam::Vec2::ZERO {
                    let yaw = nav.orbit.x * self.orbit_sensitivity;
                    let pitch = nav.orbit.y * self.orbit_sensitivity;
                    apply_turntable(camera, yaw, pitch);
                }
                if nav.twist != 0.0 && self.gesture_sensitivity != 0.0 {
                    // Gesture twist is yaw-only in turntable mode.
                    apply_turntable(camera, nav.twist * self.gesture_sensitivity, 0.0);
                }
                if nav.pan != glam::Vec2::ZERO {
                    camera.pan_pixels(nav.pan, h);
                }
                if nav.zoom != 0.0 {
                    camera.zoom_by_factor(1.0 - nav.zoom * self.zoom_sensitivity);
                }
            }

            NavigationMode::Planar => {
                // Orbit input is silently ignored; pan and zoom still work.
                if nav.pan != glam::Vec2::ZERO {
                    camera.pan_pixels(nav.pan, h);
                }
                if nav.zoom != 0.0 {
                    camera.zoom_by_factor(1.0 - nav.zoom * self.zoom_sensitivity);
                }
            }

            NavigationMode::FirstPerson => {
                // Mouselook: drag rotates the view while the eye stays fixed.
                if nav.orbit != glam::Vec2::ZERO {
                    let yaw = nav.orbit.x * self.orbit_sensitivity;
                    let pitch = nav.orbit.y * self.orbit_sensitivity;
                    apply_firstperson_look(camera, yaw, pitch);
                }
                if nav.twist != 0.0 && self.gesture_sensitivity != 0.0 {
                    apply_firstperson_look(camera, nav.twist * self.gesture_sensitivity, 0.0);
                }

                // WASD / QE translation.
                let forward = -(camera.orientation * glam::Vec3::Z);
                let right = camera.orientation * glam::Vec3::X;
                let up = camera.orientation * glam::Vec3::Y;
                let speed = self.fly_speed;

                let mut move_delta = glam::Vec3::ZERO;
                if frame.is_active(Action::FlyForward) {
                    move_delta += forward * speed;
                }
                if frame.is_active(Action::FlyBackward) {
                    move_delta -= forward * speed;
                }
                if frame.is_active(Action::FlyRight) {
                    move_delta += right * speed;
                }
                if frame.is_active(Action::FlyLeft) {
                    move_delta -= right * speed;
                }
                if frame.is_active(Action::FlyUp) {
                    move_delta += up * speed;
                }
                if frame.is_active(Action::FlyDown) {
                    move_delta -= up * speed;
                }
                // Translate center (and thus eye) without changing orientation or distance.
                camera.center += move_delta;
            }
        }

        frame
    }
}

// ---------------------------------------------------------------------------
// Navigation mode helpers
// ---------------------------------------------------------------------------

/// Apply a turntable yaw + clamped pitch to the camera.
///
/// Yaw rotates around world Z. Pitch changes elevation in camera-local space,
/// but is blocked when the eye would reach ±89° above/below the XY plane.
fn apply_turntable(camera: &mut Camera, yaw: f32, pitch: f32) {
    // Yaw: pre-multiply by a world-Z rotation (same as the yaw component of Camera::orbit).
    if yaw != 0.0 {
        camera.orientation = (glam::Quat::from_rotation_z(-yaw) * camera.orientation).normalize();
    }

    if pitch != 0.0 {
        // Proposed orientation after applying pitch in camera-local space.
        let proposed = (camera.orientation * glam::Quat::from_rotation_x(-pitch)).normalize();

        // The eye direction is `orientation * Z`. Its Z-component equals
        // sin(elevation_angle), so clamping it to sin(±89°) keeps the camera
        // away from the poles.
        let max_sin_el = 89.0_f32.to_radians().sin(); // ≈ 0.9998
        let eye_z = (proposed * glam::Vec3::Z).z;

        if eye_z.abs() <= max_sin_el {
            camera.orientation = proposed;
        }
        // At the pole limit: silently discard the pitch to avoid jitter.
    }
}

/// Apply mouselook (yaw + pitch) while keeping the camera eye position fixed.
///
/// The orbit center is adjusted so that `eye = center + orientation * Z * distance`
/// remains constant after the rotation.
fn apply_firstperson_look(camera: &mut Camera, yaw: f32, pitch: f32) {
    let eye = camera.eye_position();
    camera.orientation = (glam::Quat::from_rotation_z(-yaw)
        * camera.orientation
        * glam::Quat::from_rotation_x(-pitch))
    .normalize();
    // Re-derive center so the eye does not shift.
    camera.center = eye - camera.orientation * (glam::Vec3::Z * camera.distance);
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::interaction::input::binding::KeyCode;
    use crate::interaction::input::event::{ButtonState, ScrollUnits, ViewportEvent};

    fn make_ctx() -> ViewportContext {
        ViewportContext {
            hovered: true,
            focused: true,
            viewport_size: [800.0, 600.0],
        }
    }

    #[test]
    fn new_defaults() {
        let ctrl = OrbitCameraController::viewport_primitives();
        assert_eq!(ctrl.navigation_mode, NavigationMode::Arcball);
        assert!((ctrl.fly_speed - OrbitCameraController::DEFAULT_FLY_SPEED).abs() < 1e-6);
        assert!(
            (ctrl.orbit_sensitivity - OrbitCameraController::DEFAULT_ORBIT_SENSITIVITY).abs()
                < 1e-6
        );
    }

    #[test]
    fn resolve_no_events_zero_nav() {
        let mut ctrl = OrbitCameraController::viewport_all();
        ctrl.begin_frame(make_ctx());
        let frame = ctrl.resolve();
        assert_eq!(frame.navigation.orbit, glam::Vec2::ZERO);
        assert_eq!(frame.navigation.pan, glam::Vec2::ZERO);
        assert_eq!(frame.navigation.zoom, 0.0);
    }

    #[test]
    fn apply_zoom_changes_distance() {
        let mut ctrl = OrbitCameraController::viewport_primitives();
        ctrl.begin_frame(make_ctx());
        let mut cam = Camera::default();
        let d0 = cam.distance;
        ctrl.push_event(ViewportEvent::Wheel {
            delta: glam::Vec2::new(0.0, 100.0),
            units: ScrollUnits::Pixels,
        });
        ctrl.apply_to_camera(&mut cam);
        assert!(
            (cam.distance - d0).abs() > 1e-4,
            "zoom should change camera distance"
        );
    }

    #[test]
    fn planar_mode_ignores_orbit() {
        let mut ctrl = OrbitCameraController::viewport_primitives();
        ctrl.navigation_mode = NavigationMode::Planar;
        ctrl.begin_frame(make_ctx());
        let mut cam = Camera::default();
        let orient_before = cam.orientation;
        // Simulate a left-drag (orbit gesture in primitives preset)
        ctrl.push_event(ViewportEvent::PointerMoved {
            position: glam::Vec2::new(100.0, 100.0),
        });
        ctrl.push_event(ViewportEvent::MouseButton {
            button: crate::interaction::input::binding::MouseButton::Left,
            state: ButtonState::Pressed,
        });
        ctrl.push_event(ViewportEvent::PointerMoved {
            position: glam::Vec2::new(200.0, 200.0),
        });
        ctrl.apply_to_camera(&mut cam);
        assert!(
            (cam.orientation.x - orient_before.x).abs() < 1e-6
                && (cam.orientation.y - orient_before.y).abs() < 1e-6
                && (cam.orientation.z - orient_before.z).abs() < 1e-6
                && (cam.orientation.w - orient_before.w).abs() < 1e-6,
            "planar mode should not change orientation"
        );
    }

    #[test]
    fn turntable_pitch_clamped() {
        let mut cam = Camera::default();
        // Try to apply extreme pitch
        for _ in 0..1000 {
            apply_turntable(&mut cam, 0.0, 0.1);
        }
        // Check eye direction Z component stays within sin(89°)
        let eye_z = (cam.orientation * glam::Vec3::Z).z;
        let max_sin = 89.0_f32.to_radians().sin();
        assert!(
            eye_z.abs() <= max_sin + 1e-4,
            "turntable pitch should be clamped: eye_z={eye_z}"
        );
    }

    #[test]
    fn firstperson_look_preserves_eye() {
        let mut cam = Camera::default();
        let eye_before = cam.eye_position();
        apply_firstperson_look(&mut cam, 0.3, 0.2);
        let eye_after = cam.eye_position();
        let diff = (eye_after - eye_before).length();
        assert!(
            diff < 1e-3,
            "firstperson look should preserve eye position, diff={diff}"
        );
    }

    #[test]
    fn firstperson_fly_moves_camera() {
        let mut ctrl = OrbitCameraController::viewport_all();
        ctrl.navigation_mode = NavigationMode::FirstPerson;
        ctrl.fly_speed = 1.0;
        ctrl.begin_frame(make_ctx());
        let mut cam = Camera::default();
        cam.center = glam::Vec3::ZERO;
        cam.orientation = glam::Quat::IDENTITY;
        let center_before = cam.center;
        ctrl.push_event(ViewportEvent::Key {
            key: KeyCode::W,
            state: ButtonState::Pressed,
            repeat: false,
        });
        ctrl.apply_to_camera(&mut cam);
        assert!(
            (cam.center - center_before).length() > 0.5,
            "FlyForward should move camera center"
        );
    }
}