Skip to main content

spatialrust_viz/
camera.rs

1use core::f32::consts::PI;
2
3use spatialrust_math::Vec3;
4
5use crate::{VizError, VizResult};
6
7/// Projection used to render a visual scene.
8#[derive(Clone, Copy, Debug, PartialEq)]
9#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
10pub enum Projection {
11    /// Perspective projection with a vertical field of view in radians.
12    Perspective {
13        /// Vertical field of view in radians.
14        vertical_fov_radians: f32,
15        /// Positive near clipping distance.
16        near: f32,
17        /// Far clipping distance greater than `near`.
18        far: f32,
19    },
20    /// Orthographic projection with a positive vertical span.
21    Orthographic {
22        /// Visible vertical extent in world units.
23        vertical_span: f32,
24        /// Near clipping distance.
25        near: f32,
26        /// Far clipping distance greater than `near`.
27        far: f32,
28    },
29}
30
31impl Projection {
32    /// Validates projection ranges.
33    pub fn validate(self) -> VizResult<()> {
34        match self {
35            Self::Perspective { vertical_fov_radians, near, far } => {
36                if !vertical_fov_radians.is_finite()
37                    || vertical_fov_radians <= 0.0
38                    || vertical_fov_radians >= PI
39                    || !valid_clip_range(near, far)
40                {
41                    return Err(VizError::InvalidCamera(
42                        "perspective FOV must be in (0, pi) and 0 < near < far".into(),
43                    ));
44                }
45            }
46            Self::Orthographic { vertical_span, near, far } => {
47                if !vertical_span.is_finite()
48                    || vertical_span <= 0.0
49                    || !valid_clip_range(near, far)
50                {
51                    return Err(VizError::InvalidCamera(
52                        "orthographic span must be positive and 0 < near < far".into(),
53                    ));
54                }
55            }
56        }
57        Ok(())
58    }
59}
60
61fn valid_clip_range(near: f32, far: f32) -> bool {
62    near.is_finite() && far.is_finite() && near > 0.0 && far > near
63}
64
65/// Validated look-at camera.
66#[derive(Clone, Copy, Debug, PartialEq)]
67#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
68pub struct Camera {
69    /// Camera position in world coordinates.
70    pub eye: Vec3<f32>,
71    /// Look-at target in world coordinates.
72    pub target: Vec3<f32>,
73    /// Approximate world-space up direction.
74    pub up: Vec3<f32>,
75    /// Camera projection.
76    pub projection: Projection,
77}
78
79impl Camera {
80    /// Creates a validated look-at camera.
81    pub fn try_new(
82        eye: Vec3<f32>,
83        target: Vec3<f32>,
84        up: Vec3<f32>,
85        projection: Projection,
86    ) -> VizResult<Self> {
87        projection.validate()?;
88        if !finite_vec(eye) || !finite_vec(target) || !finite_vec(up) {
89            return Err(VizError::InvalidCamera("view vectors must be finite".into()));
90        }
91        let forward = target - eye;
92        if forward.length() <= f32::EPSILON {
93            return Err(VizError::InvalidCamera("eye and target must differ".into()));
94        }
95        if up.length() <= f32::EPSILON || forward.cross(up).length() <= f32::EPSILON {
96            return Err(VizError::InvalidCamera(
97                "up must be non-zero and not parallel to the view direction".into(),
98            ));
99        }
100        Ok(Self { eye, target, up: up.normalize(), projection })
101    }
102
103    /// Fits a perspective camera around finite axis-aligned bounds.
104    ///
105    /// `view_direction` points from the eye toward the bounds center. `padding`
106    /// must be at least one and expands the bounding sphere used for clipping.
107    pub fn fit_perspective_bounds(
108        min: Vec3<f32>,
109        max: Vec3<f32>,
110        view_direction: Vec3<f32>,
111        up: Vec3<f32>,
112        vertical_fov_radians: f32,
113        aspect: f32,
114        padding: f32,
115    ) -> VizResult<Self> {
116        if !finite_vec(min) || !finite_vec(max) || min.x > max.x || min.y > max.y || min.z > max.z {
117            return Err(VizError::InvalidCamera(
118                "fit bounds must be finite and component-wise ordered".into(),
119            ));
120        }
121        if !aspect.is_finite() || aspect <= 0.0 || !padding.is_finite() || padding < 1.0 {
122            return Err(VizError::InvalidCamera(
123                "fit aspect must be positive and padding must be at least one".into(),
124            ));
125        }
126        if !vertical_fov_radians.is_finite()
127            || vertical_fov_radians <= 0.0
128            || vertical_fov_radians >= PI
129        {
130            return Err(VizError::InvalidCamera("fit FOV must be in (0, pi)".into()));
131        }
132        if !finite_vec(view_direction) || view_direction.length() <= f32::EPSILON {
133            return Err(VizError::InvalidCamera(
134                "fit view direction must be finite and non-zero".into(),
135            ));
136        }
137        let center = Vec3::new((min.x + max.x) * 0.5, (min.y + max.y) * 0.5, (min.z + max.z) * 0.5);
138        let half = Vec3::new((max.x - min.x) * 0.5, (max.y - min.y) * 0.5, (max.z - min.z) * 0.5);
139        let radius = half.length().max(1.0e-4);
140        let vertical_half_angle = vertical_fov_radians * 0.5;
141        let horizontal_half_angle = (vertical_half_angle.tan() * aspect).atan();
142        let limiting_half_angle = vertical_half_angle.min(horizontal_half_angle);
143        let padded_radius = radius * padding;
144        let distance = padded_radius / limiting_half_angle.sin();
145        let forward = view_direction.normalize();
146        let eye = Vec3::new(
147            center.x - forward.x * distance,
148            center.y - forward.y * distance,
149            center.z - forward.z * distance,
150        );
151        let near = (distance - padded_radius).max(distance * 1.0e-4).max(1.0e-6);
152        let far = (distance + padded_radius).max(near + 1.0e-5);
153        Self::try_new(eye, center, up, Projection::Perspective { vertical_fov_radians, near, far })
154    }
155}
156
157fn finite_vec(value: Vec3<f32>) -> bool {
158    value.x.is_finite() && value.y.is_finite() && value.z.is_finite()
159}
160
161#[cfg(test)]
162mod tests {
163    use core::f32::consts::FRAC_PI_3;
164
165    use spatialrust_math::Vec3;
166
167    use super::{Camera, Projection};
168
169    fn projection() -> Projection {
170        Projection::Perspective { vertical_fov_radians: FRAC_PI_3, near: 0.1, far: 1_000.0 }
171    }
172
173    #[test]
174    fn validates_look_at_basis() {
175        let camera = Camera::try_new(
176            Vec3::new(0.0, 0.0, 5.0),
177            Vec3::new(0.0, 0.0, 0.0),
178            Vec3::new(0.0, 1.0, 0.0),
179            projection(),
180        )
181        .unwrap();
182        assert_eq!(camera.up, Vec3::new(0.0, 1.0, 0.0));
183
184        assert!(Camera::try_new(
185            Vec3::new(0.0, 0.0, 0.0),
186            Vec3::new(0.0, 0.0, 0.0),
187            Vec3::new(0.0, 1.0, 0.0),
188            projection(),
189        )
190        .is_err());
191    }
192
193    #[test]
194    fn rejects_invalid_projection() {
195        assert!(Projection::Perspective { vertical_fov_radians: 0.0, near: 0.1, far: 10.0 }
196            .validate()
197            .is_err());
198        assert!(Projection::Orthographic { vertical_span: 0.0, near: 0.1, far: 10.0 }
199            .validate()
200            .is_err());
201        assert!(Projection::Perspective { vertical_fov_radians: FRAC_PI_3, near: 10.0, far: 1.0 }
202            .validate()
203            .is_err());
204    }
205
206    #[test]
207    fn fits_perspective_camera_to_bounds() {
208        let camera = Camera::fit_perspective_bounds(
209            Vec3::new(-1.0, -2.0, -0.5),
210            Vec3::new(1.0, 2.0, 0.5),
211            Vec3::new(0.0, 0.0, -1.0),
212            Vec3::new(0.0, 1.0, 0.0),
213            FRAC_PI_3,
214            16.0 / 9.0,
215            1.1,
216        )
217        .unwrap();
218        assert_eq!(camera.target, Vec3::new(0.0, 0.0, 0.0));
219        assert!(camera.eye.z > 0.0);
220        let Projection::Perspective { near, far, .. } = camera.projection else {
221            panic!("fit must create perspective projection");
222        };
223        assert!(near > 0.0);
224        assert!(far > near);
225
226        assert!(Camera::fit_perspective_bounds(
227            Vec3::new(1.0, 0.0, 0.0),
228            Vec3::new(-1.0, 0.0, 0.0),
229            Vec3::new(0.0, 0.0, -1.0),
230            Vec3::new(0.0, 1.0, 0.0),
231            FRAC_PI_3,
232            1.0,
233            1.0,
234        )
235        .is_err());
236    }
237}