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) * glam::Quat::from_rotation_x(self.tilt),
72        );
73    }
74}
75
76// ---------------------------------------------------------------------------
77// Helper
78// ---------------------------------------------------------------------------
79
80/// Wrap `angle` into `[-PI, PI]`.
81fn normalize_angle(angle: f32) -> f32 {
82    use std::f32::consts::TAU;
83    let a = angle % TAU;
84    if a > std::f32::consts::PI {
85        a - TAU
86    } else if a < -std::f32::consts::PI {
87        a + TAU
88    } else {
89        a
90    }
91}
92
93// ---------------------------------------------------------------------------
94// Tests
95// ---------------------------------------------------------------------------
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    fn default_camera() -> Camera {
102        Camera::default()
103    }
104
105    #[test]
106    fn test_turntable_advances_azimuth() {
107        let mut cam = default_camera();
108        let mut tt = TurntableController::new(1.0, 1.0);
109        let start_az = tt.azimuth;
110        tt.update(0.1, &mut cam);
111        assert!((tt.azimuth - (start_az + 0.1)).abs() < 1e-5);
112    }
113
114    #[test]
115    fn test_turntable_does_not_change_distance() {
116        let mut cam = default_camera();
117        cam.set_distance(7.5);
118        let mut tt = TurntableController::new(1.0, 1.0);
119        tt.update(1.0, &mut cam);
120        assert!((cam.distance() - 7.5).abs() < 1e-5);
121    }
122
123    #[test]
124    fn test_turntable_does_not_change_center() {
125        let mut cam = default_camera();
126        cam.set_center(glam::Vec3::new(1.0, 2.0, 3.0));
127        let mut tt = TurntableController::new(1.0, 1.0);
128        tt.update(1.0, &mut cam);
129        assert!((cam.center() - glam::Vec3::new(1.0, 2.0, 3.0)).length() < 1e-5);
130    }
131
132    #[test]
133    fn test_from_camera_round_trip() {
134        let cam = default_camera();
135        let tt = TurntableController::from_camera(&cam, 0.5);
136        // Apply to a fresh camera and check orientation matches original.
137        let mut cam2 = default_camera();
138        cam2.set_orientation(
139            glam::Quat::from_rotation_z(tt.azimuth) * glam::Quat::from_rotation_x(tt.tilt),
140        );
141        let orig_eye = cam.orientation() * glam::Vec3::Z;
142        let new_eye = cam2.orientation() * glam::Vec3::Z;
143        let diff = (orig_eye - new_eye).length();
144        assert!(diff < 1e-4, "round-trip eye direction diff={diff}");
145    }
146
147    #[test]
148    fn test_azimuth_normalization() {
149        let mut cam = default_camera();
150        // Spin fast for many frames -- azimuth should stay bounded.
151        let mut tt = TurntableController::new(10.0, 1.0);
152        for _ in 0..1000 {
153            tt.update(0.1, &mut cam);
154        }
155        assert!(
156            tt.azimuth.abs() <= std::f32::consts::PI + 1e-4,
157            "azimuth out of range: {}",
158            tt.azimuth
159        );
160    }
161
162    #[test]
163    fn test_negative_velocity_reverses() {
164        let mut cam = default_camera();
165        let mut tt_fwd = TurntableController::new(1.0, 1.0);
166        let mut tt_rev = TurntableController::new(-1.0, 1.0);
167        tt_fwd.update(0.5, &mut cam);
168        tt_rev.update(0.5, &mut cam);
169        assert!(tt_fwd.azimuth > 0.0, "forward azimuth should be positive");
170        assert!(tt_rev.azimuth < 0.0, "reverse azimuth should be negative");
171    }
172}