Skip to main content

volren_core/
camera.rs

1//! Camera model: perspective and orthographic projections, arcball manipulation.
2//!
3//! # VTK Equivalent
4//! `vtkCamera` — position, focal point, view-up, clipping range, view angle.
5
6use glam::{DMat4, DVec3};
7
8// ── Projection ────────────────────────────────────────────────────────────────
9
10/// Camera projection type.
11#[derive(Debug, Clone, Copy, PartialEq)]
12#[non_exhaustive]
13pub enum Projection {
14    /// Perspective projection (frustum).
15    Perspective {
16        /// Vertical field of view in degrees.
17        fov_y_deg: f64,
18    },
19    /// Orthographic (parallel) projection.
20    Orthographic {
21        /// Total vertical extent of the visible region.
22        parallel_scale: f64,
23    },
24}
25
26impl Default for Projection {
27    fn default() -> Self {
28        Self::Perspective { fov_y_deg: 30.0 }
29    }
30}
31
32// ── Camera ────────────────────────────────────────────────────────────────────
33
34/// Perspective or orthographic camera for volume rendering.
35///
36/// The camera is defined by:
37/// - **position** — the eye point in world space
38/// - **focal_point** — the point the camera looks at
39/// - **view_up** — the "up" direction hint (orthogonalised internally)
40/// - **clip_range** — `(near, far)` clipping distances
41/// - **projection** — [`Projection::Perspective`] or [`Projection::Orthographic`]
42#[derive(Debug, Clone)]
43pub struct Camera {
44    position: DVec3,
45    focal_point: DVec3,
46    view_up: DVec3,
47    clip_range: (f64, f64),
48    projection: Projection,
49}
50
51impl Default for Camera {
52    fn default() -> Self {
53        Self {
54            position: DVec3::new(0.0, 0.0, 1.0),
55            focal_point: DVec3::ZERO,
56            view_up: DVec3::Y,
57            clip_range: (0.01, 1000.0),
58            projection: Projection::default(),
59        }
60    }
61}
62
63impl Camera {
64    // ── Construction ──────────────────────────────────────────────────────────
65
66    /// Create a perspective camera with a conventional Y-up configuration.
67    #[must_use]
68    pub fn new_perspective(position: DVec3, focal_point: DVec3, fov_y_deg: f64) -> Self {
69        Self::new(position, focal_point, DVec3::Y)
70            .with_projection(Projection::Perspective { fov_y_deg })
71    }
72
73    /// Create an orthographic camera with a conventional Y-up configuration.
74    #[must_use]
75    pub fn new_orthographic(position: DVec3, focal_point: DVec3, parallel_scale: f64) -> Self {
76        Self::new(position, focal_point, DVec3::Y)
77            .with_projection(Projection::Orthographic { parallel_scale })
78    }
79
80    /// Build a new camera pointed at `focal_point` from `position` with view-up `view_up`.
81    ///
82    /// The clip range defaults to `(0.01, 1000.0)` and can be adjusted with
83    /// [`Camera::with_clip_range`].
84    #[must_use]
85    pub fn new(position: DVec3, focal_point: DVec3, view_up: DVec3) -> Self {
86        Self {
87            position,
88            focal_point,
89            view_up: view_up.normalize_or(DVec3::Y),
90            clip_range: (0.01, 1000.0),
91            projection: Projection::default(),
92        }
93    }
94
95    /// Override the clip range.
96    #[must_use]
97    pub fn with_clip_range(mut self, near: f64, far: f64) -> Self {
98        debug_assert!(near > 0.0 && far > near, "invalid clip range");
99        self.clip_range = (near, far);
100        self
101    }
102
103    /// Override the projection type.
104    #[must_use]
105    pub fn with_projection(mut self, projection: Projection) -> Self {
106        self.projection = projection;
107        self
108    }
109
110    // ── Accessors ─────────────────────────────────────────────────────────────
111
112    /// Camera eye position in world space.
113    #[must_use]
114    pub fn position(&self) -> DVec3 {
115        self.position
116    }
117
118    /// The point the camera looks at.
119    #[must_use]
120    pub fn focal_point(&self) -> DVec3 {
121        self.focal_point
122    }
123
124    /// World-space vector pointing from position to focal point (normalised).
125    #[must_use]
126    pub fn forward(&self) -> DVec3 {
127        (self.focal_point - self.position).normalize_or(DVec3::NEG_Z)
128    }
129
130    /// Alias for [`Self::forward`], matching the plan terminology.
131    #[must_use]
132    pub fn direction(&self) -> DVec3 {
133        self.forward()
134    }
135
136    /// Orthogonalised right vector (forward × up).
137    #[must_use]
138    pub fn right(&self) -> DVec3 {
139        self.forward()
140            .cross(self.view_up_ortho())
141            .normalize_or(DVec3::X)
142    }
143
144    /// Alias for [`Self::right`], matching VTK-style naming.
145    #[must_use]
146    pub fn right_vector(&self) -> DVec3 {
147        self.right()
148    }
149
150    /// Orthogonalised view-up vector.
151    #[must_use]
152    pub fn view_up_ortho(&self) -> DVec3 {
153        let fwd = self.forward();
154        // Gram-Schmidt: up_ortho = up - (up·fwd)fwd
155        let up = self.view_up.normalize_or(DVec3::Y);
156        (up - fwd * up.dot(fwd)).normalize_or(DVec3::Y)
157    }
158
159    /// The (near, far) clip distances.
160    #[must_use]
161    pub fn clip_range(&self) -> (f64, f64) {
162        self.clip_range
163    }
164
165    /// The current projection type.
166    #[must_use]
167    pub fn projection(&self) -> &Projection {
168        &self.projection
169    }
170
171    /// Distance from position to focal point.
172    #[must_use]
173    pub fn distance(&self) -> f64 {
174        (self.focal_point - self.position).length()
175    }
176
177    // ── Matrices ──────────────────────────────────────────────────────────────
178
179    /// View (world-to-camera) matrix.
180    #[must_use]
181    pub fn view_matrix(&self) -> DMat4 {
182        DMat4::look_at_rh(self.position, self.focal_point, self.view_up_ortho())
183    }
184
185    /// Projection matrix for the given viewport aspect ratio.
186    ///
187    /// `aspect` = viewport_width / viewport_height.
188    #[must_use]
189    pub fn projection_matrix(&self, aspect: f64) -> DMat4 {
190        let (near, far) = self.clip_range;
191        match self.projection {
192            Projection::Perspective { fov_y_deg } => {
193                let fov_rad = fov_y_deg.to_radians();
194                DMat4::perspective_rh(fov_rad, aspect, near, far)
195            }
196            Projection::Orthographic { parallel_scale } => {
197                let half_h = parallel_scale;
198                let half_w = half_h * aspect;
199                DMat4::orthographic_rh(-half_w, half_w, -half_h, half_h, near, far)
200            }
201        }
202    }
203
204    // ── Manipulation ──────────────────────────────────────────────────────────
205
206    /// Move the camera along its forward axis (dolly).
207    ///
208    /// Positive `delta` moves toward the focal point; negative moves away.
209    /// The focal point stays fixed.
210    pub fn dolly(&mut self, delta: f64) {
211        let dist = self.distance();
212        let fwd = self.forward();
213        let new_dist = (dist - delta).max(1e-3);
214        self.position = self.focal_point - fwd * new_dist;
215    }
216
217    /// Multiply the distance to the focal point by `factor` (zoom).
218    ///
219    /// Values < 1.0 move closer; values > 1.0 move farther.
220    pub fn zoom(&mut self, factor: f64) {
221        debug_assert!(factor > 0.0, "zoom factor must be positive");
222        let fwd = self.forward();
223        let new_dist = self.distance() * factor;
224        self.position = self.focal_point - fwd * new_dist.max(1e-3);
225    }
226
227    /// Translate both position and focal point in screen-space (pan).
228    ///
229    /// `delta` is in world-space units; use `right()` and `up_ortho()` to
230    /// convert from screen pixels.
231    pub fn pan(&mut self, delta: DVec3) {
232        self.position += delta;
233        self.focal_point += delta;
234    }
235
236    /// Translate the camera in its view plane.
237    ///
238    /// `dx` moves along the camera right vector, `dy` along the orthogonal up vector.
239    pub fn pan_view(&mut self, dx: f64, dy: f64) {
240        self.pan(self.right() * dx + self.view_up_ortho() * dy);
241    }
242
243    /// Rotate the camera position around the focal point about the world Y axis.
244    ///
245    /// This is a convenience wrapper around [`Camera::orbit`].
246    pub fn azimuth(&mut self, degrees: f64) {
247        self.orbit(degrees.to_radians(), 0.0);
248    }
249
250    /// Rotate the camera position around the focal point about the camera's right axis.
251    ///
252    /// This is a convenience wrapper around [`Camera::orbit`].
253    pub fn elevation(&mut self, degrees: f64) {
254        self.orbit(0.0, degrees.to_radians());
255    }
256
257    /// Rotate the view-up vector around the forward axis by `degrees`.
258    pub fn roll(&mut self, degrees: f64) {
259        let fwd = self.forward();
260        let rot = glam::DQuat::from_axis_angle(fwd, degrees.to_radians());
261        self.view_up = rot * self.view_up;
262    }
263
264    /// Orbit position around the focal point by rotating `angle_h` about the
265    /// world-space `up_axis` and `angle_v` about the camera's right axis.
266    ///
267    /// Implements an **arcball-style** rotation matching VTK's trackball style.
268    pub fn orbit(&mut self, angle_h: f64, angle_v: f64) {
269        let to_eye = self.position - self.focal_point;
270        let right = self.right();
271        let up = DVec3::Y; // orbit around world Y to avoid roll
272
273        // Horizontal rotation around world up
274        let rot_h = glam::DQuat::from_axis_angle(up, -angle_h);
275        let to_eye = rot_h * to_eye;
276
277        // Vertical rotation around camera right
278        let rot_v = glam::DQuat::from_axis_angle(right, -angle_v);
279        let to_eye = rot_v * to_eye;
280
281        self.position = self.focal_point + to_eye;
282        // Re-project view_up after vertical rotation to avoid roll accumulation
283        self.view_up = rot_v * self.view_up;
284    }
285
286    /// Auto-fit the camera so the given world-space bounding box is fully visible.
287    ///
288    /// Sets `clip_range` conservatively around the box.
289    pub fn reset_to_bounds(&mut self, bounds_min: DVec3, bounds_max: DVec3, _aspect: f64) {
290        let center = (bounds_min + bounds_max) * 0.5;
291        let half_diag = (bounds_max - bounds_min).length() * 0.5;
292
293        let fov_rad = match self.projection {
294            Projection::Perspective { fov_y_deg } => fov_y_deg.to_radians(),
295            Projection::Orthographic { .. } => std::f64::consts::FRAC_PI_4,
296        };
297        let dist = half_diag / (fov_rad * 0.5).tan();
298
299        let fwd = self.forward();
300        self.focal_point = center;
301        self.position = center - fwd * dist;
302        self.clip_range = ((dist - half_diag * 1.5).max(1e-3), dist + half_diag * 1.5);
303
304        if let Projection::Orthographic {
305            ref mut parallel_scale,
306        } = self.projection
307        {
308            *parallel_scale = half_diag;
309        }
310    }
311}
312
313// ── Tests ─────────────────────────────────────────────────────────────────────
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use approx::assert_abs_diff_eq;
319
320    #[test]
321    fn view_matrix_look_at_negative_z() {
322        let cam = Camera::new(DVec3::new(0.0, 0.0, 5.0), DVec3::ZERO, DVec3::Y);
323        let vm = cam.view_matrix();
324        // A point at the focal point should map to camera-space origin (roughly).
325        let cam_space = vm.transform_point3(cam.focal_point().as_vec3().as_dvec3());
326        assert_abs_diff_eq!(cam_space.x, 0.0, epsilon = 1e-10);
327        assert_abs_diff_eq!(cam_space.y, 0.0, epsilon = 1e-10);
328    }
329
330    #[test]
331    fn orbit_preserves_distance() {
332        let mut cam = Camera::new(DVec3::new(0.0, 0.0, 5.0), DVec3::ZERO, DVec3::Y);
333        let d0 = cam.distance();
334        cam.orbit(0.3, 0.2);
335        let d1 = cam.distance();
336        assert_abs_diff_eq!(d0, d1, epsilon = 1e-8);
337    }
338
339    #[test]
340    fn dolly_changes_distance() {
341        let mut cam = Camera::new(DVec3::new(0.0, 0.0, 10.0), DVec3::ZERO, DVec3::Y);
342        cam.dolly(2.0);
343        assert_abs_diff_eq!(cam.distance(), 8.0, epsilon = 1e-8);
344    }
345
346    #[test]
347    fn zoom_changes_distance() {
348        let mut cam = Camera::new(DVec3::new(0.0, 0.0, 10.0), DVec3::ZERO, DVec3::Y);
349        cam.zoom(0.5);
350        assert_abs_diff_eq!(cam.distance(), 5.0, epsilon = 1e-8);
351    }
352
353    #[test]
354    fn view_up_orthogonal_to_forward() {
355        let cam = Camera::new(DVec3::new(1.0, 2.0, 3.0), DVec3::ZERO, DVec3::Y);
356        let dot = cam.forward().dot(cam.view_up_ortho());
357        assert_abs_diff_eq!(dot, 0.0, epsilon = 1e-10);
358    }
359
360    #[test]
361    fn right_is_normalised() {
362        let cam = Camera::new(DVec3::new(0.0, 0.0, 5.0), DVec3::ZERO, DVec3::Y);
363        assert_abs_diff_eq!(cam.right().length(), 1.0, epsilon = 1e-10);
364    }
365
366    #[test]
367    fn pan_moves_focal_point() {
368        let mut cam = Camera::new(DVec3::new(0.0, 0.0, 5.0), DVec3::ZERO, DVec3::Y);
369        cam.pan(DVec3::new(1.0, 0.0, 0.0));
370        assert_abs_diff_eq!(cam.focal_point().x, 1.0, epsilon = 1e-10);
371        assert_abs_diff_eq!(cam.position().x, 1.0, epsilon = 1e-10);
372    }
373
374    #[test]
375    fn azimuth_preserves_distance() {
376        let mut cam = Camera::new(DVec3::new(0.0, 0.0, 5.0), DVec3::ZERO, DVec3::Y);
377        let d0 = cam.distance();
378        cam.azimuth(45.0);
379        assert_abs_diff_eq!(cam.distance(), d0, epsilon = 1e-8);
380    }
381
382    #[test]
383    fn elevation_changes_position() {
384        let mut cam = Camera::new(DVec3::new(0.0, 0.0, 5.0), DVec3::ZERO, DVec3::Y);
385        let y0 = cam.position().y;
386        cam.elevation(30.0);
387        assert!(
388            (cam.position().y - y0).abs() > 0.1,
389            "elevation should move camera vertically"
390        );
391    }
392
393    #[test]
394    fn roll_changes_right_vector() {
395        let mut cam = Camera::new(DVec3::new(0.0, 0.0, 5.0), DVec3::ZERO, DVec3::Y);
396        let r0 = cam.right();
397        cam.roll(45.0);
398        let r1 = cam.right();
399        assert!(
400            (r1 - r0).length() > 0.1,
401            "roll should change the right vector"
402        );
403    }
404}
405
406#[cfg(test)]
407mod proptests {
408    use super::*;
409    use proptest::prelude::*;
410
411    proptest! {
412        #[test]
413        fn view_matrix_is_orthonormal(
414            x in -10.0f64..10.0,
415            y in -10.0f64..10.0,
416            z in 1.0f64..20.0,
417        ) {
418            let cam = Camera::new(DVec3::new(x, y, z), DVec3::ZERO, DVec3::Y);
419            let vm = cam.view_matrix();
420            // Extract 3×3 upper-left; columns should be orthonormal → det ≈ 1
421            let m3 = glam::DMat3::from_cols(
422                vm.col(0).truncate(),
423                vm.col(1).truncate(),
424                vm.col(2).truncate(),
425            );
426            let det = m3.determinant();
427            prop_assert!((det - 1.0).abs() < 1e-6, "det = {det}");
428        }
429    }
430}