1use core::f32::consts::PI;
2
3use spatialrust_math::Vec3;
4
5use crate::{VizError, VizResult};
6
7#[derive(Clone, Copy, Debug, PartialEq)]
9#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
10pub enum Projection {
11 Perspective {
13 vertical_fov_radians: f32,
15 near: f32,
17 far: f32,
19 },
20 Orthographic {
22 vertical_span: f32,
24 near: f32,
26 far: f32,
28 },
29}
30
31impl Projection {
32 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#[derive(Clone, Copy, Debug, PartialEq)]
67#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
68pub struct Camera {
69 pub eye: Vec3<f32>,
71 pub target: Vec3<f32>,
73 pub up: Vec3<f32>,
75 pub projection: Projection,
77}
78
79impl Camera {
80 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 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}