Skip to main content

viewport_lib/camera/
turntable.rs

1//! Turntable (continuous orbit) camera controller.
2
3use crate::camera::camera::Camera;
4
5// ---------------------------------------------------------------------------
6// TurntableController
7// ---------------------------------------------------------------------------
8
9/// Continuously orbits the camera around the Z axis at a fixed elevation.
10///
11/// Each frame, advance the azimuth by `angular_velocity * dt` and apply the
12/// result to the camera. The orbit distance and center are left unchanged.
13///
14/// ```rust,ignore
15/// let mut turntable = TurntableController::from_camera(&camera, 0.5);
16/// // in the render loop:
17/// turntable.update(dt, &mut camera);
18/// ```
19#[derive(Clone, Debug)]
20pub struct TurntableController {
21    /// Angular velocity in radians per second. Positive rotates counter-clockwise
22    /// when viewed from above (Z-up world).
23    pub angular_velocity: f32,
24    /// Pitch angle in radians (angle from the vertical axis). A value of PI/4
25    /// places the eye 45 degrees from the top. Matches the convention used by
26    /// `Camera::orbit` where `Quat::from_rotation_x(tilt)` is the pitch.
27    pub tilt: f32,
28    /// Current azimuth angle in radians.
29    pub azimuth: f32,
30}
31
32impl TurntableController {
33    /// Create a turntable that starts at azimuth zero.
34    ///
35    /// `tilt` is the pitch angle in radians: `PI/2` looks from the side
36    /// (horizontal), smaller values look more from above.
37    pub fn new(angular_velocity: f32, tilt: f32) -> Self {
38        Self {
39            angular_velocity,
40            tilt,
41            azimuth: 0.0,
42        }
43    }
44
45    /// Create a turntable that continues from the camera's current orientation.
46    ///
47    /// The azimuth and tilt are extracted from the camera's current orientation
48    /// so the orbit continues smoothly from wherever the user left off.
49    pub fn from_camera(camera: &Camera, angular_velocity: f32) -> Self {
50        let eye_dir = camera.orientation() * glam::Vec3::Z;
51        // Tilt (colatitude): angle from +Z. cos(tilt) = eye_dir.z.
52        let tilt = eye_dir.z.clamp(-1.0, 1.0).acos();
53        // Azimuth: atan2 of the horizontal projection.
54        // From the camera model: eye_dir = [sin(az)*sin(tilt), -cos(az)*sin(tilt), cos(tilt)]
55        // So az = atan2(eye_dir.x, -eye_dir.y).
56        let azimuth = eye_dir.x.atan2(-eye_dir.y);
57        Self {
58            angular_velocity,
59            tilt,
60            azimuth,
61        }
62    }
63
64    /// Advance the turntable by `dt` seconds and write the new orientation into
65    /// `camera`. Distance and center are unchanged.
66    pub fn update(&mut self, dt: f32, camera: &mut Camera) {
67        self.azimuth += self.angular_velocity * dt;
68        // Normalize azimuth to [-PI, PI] to avoid float drift over time.
69        self.azimuth = normalize_angle(self.azimuth);
70        camera.set_orientation(
71            glam::Quat::from_rotation_z(self.azimuth)
72                * glam::Quat::from_rotation_x(self.tilt),
73        );
74    }
75}
76
77// ---------------------------------------------------------------------------
78// Helper
79// ---------------------------------------------------------------------------
80
81/// Wrap `angle` into `[-PI, PI]`.
82fn normalize_angle(angle: f32) -> f32 {
83    use std::f32::consts::TAU;
84    let a = angle % TAU;
85    if a > std::f32::consts::PI {
86        a - TAU
87    } else if a < -std::f32::consts::PI {
88        a + TAU
89    } else {
90        a
91    }
92}
93
94// ---------------------------------------------------------------------------
95// Tests
96// ---------------------------------------------------------------------------
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    fn default_camera() -> Camera {
103        Camera::default()
104    }
105
106    #[test]
107    fn test_turntable_advances_azimuth() {
108        let mut cam = default_camera();
109        let mut tt = TurntableController::new(1.0, 1.0);
110        let start_az = tt.azimuth;
111        tt.update(0.1, &mut cam);
112        assert!((tt.azimuth - (start_az + 0.1)).abs() < 1e-5);
113    }
114
115    #[test]
116    fn test_turntable_does_not_change_distance() {
117        let mut cam = default_camera();
118        cam.set_distance(7.5);
119        let mut tt = TurntableController::new(1.0, 1.0);
120        tt.update(1.0, &mut cam);
121        assert!((cam.distance() - 7.5).abs() < 1e-5);
122    }
123
124    #[test]
125    fn test_turntable_does_not_change_center() {
126        let mut cam = default_camera();
127        cam.set_center(glam::Vec3::new(1.0, 2.0, 3.0));
128        let mut tt = TurntableController::new(1.0, 1.0);
129        tt.update(1.0, &mut cam);
130        assert!((cam.center() - glam::Vec3::new(1.0, 2.0, 3.0)).length() < 1e-5);
131    }
132
133    #[test]
134    fn test_from_camera_round_trip() {
135        let cam = default_camera();
136        let tt = TurntableController::from_camera(&cam, 0.5);
137        // Apply to a fresh camera and check orientation matches original.
138        let mut cam2 = default_camera();
139        cam2.set_orientation(
140            glam::Quat::from_rotation_z(tt.azimuth) * glam::Quat::from_rotation_x(tt.tilt),
141        );
142        let orig_eye = cam.orientation() * glam::Vec3::Z;
143        let new_eye = cam2.orientation() * glam::Vec3::Z;
144        let diff = (orig_eye - new_eye).length();
145        assert!(diff < 1e-4, "round-trip eye direction diff={diff}");
146    }
147
148    #[test]
149    fn test_azimuth_normalization() {
150        let mut cam = default_camera();
151        // Spin fast for many frames -- azimuth should stay bounded.
152        let mut tt = TurntableController::new(10.0, 1.0);
153        for _ in 0..1000 {
154            tt.update(0.1, &mut cam);
155        }
156        assert!(
157            tt.azimuth.abs() <= std::f32::consts::PI + 1e-4,
158            "azimuth out of range: {}",
159            tt.azimuth
160        );
161    }
162
163    #[test]
164    fn test_negative_velocity_reverses() {
165        let mut cam = default_camera();
166        let mut tt_fwd = TurntableController::new(1.0, 1.0);
167        let mut tt_rev = TurntableController::new(-1.0, 1.0);
168        tt_fwd.update(0.5, &mut cam);
169        tt_rev.update(0.5, &mut cam);
170        assert!(tt_fwd.azimuth > 0.0, "forward azimuth should be positive");
171        assert!(tt_rev.azimuth < 0.0, "reverse azimuth should be negative");
172    }
173}