1use glam::{DMat4, DVec3};
7
8#[derive(Debug, Clone, Copy, PartialEq)]
12#[non_exhaustive]
13pub enum Projection {
14 Perspective {
16 fov_y_deg: f64,
18 },
19 Orthographic {
21 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#[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 #[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 #[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 #[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 #[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 #[must_use]
105 pub fn with_projection(mut self, projection: Projection) -> Self {
106 self.projection = projection;
107 self
108 }
109
110 #[must_use]
114 pub fn position(&self) -> DVec3 {
115 self.position
116 }
117
118 #[must_use]
120 pub fn focal_point(&self) -> DVec3 {
121 self.focal_point
122 }
123
124 #[must_use]
126 pub fn forward(&self) -> DVec3 {
127 (self.focal_point - self.position).normalize_or(DVec3::NEG_Z)
128 }
129
130 #[must_use]
132 pub fn direction(&self) -> DVec3 {
133 self.forward()
134 }
135
136 #[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 #[must_use]
146 pub fn right_vector(&self) -> DVec3 {
147 self.right()
148 }
149
150 #[must_use]
152 pub fn view_up_ortho(&self) -> DVec3 {
153 let fwd = self.forward();
154 let up = self.view_up.normalize_or(DVec3::Y);
156 (up - fwd * up.dot(fwd)).normalize_or(DVec3::Y)
157 }
158
159 #[must_use]
161 pub fn clip_range(&self) -> (f64, f64) {
162 self.clip_range
163 }
164
165 #[must_use]
167 pub fn projection(&self) -> &Projection {
168 &self.projection
169 }
170
171 #[must_use]
173 pub fn distance(&self) -> f64 {
174 (self.focal_point - self.position).length()
175 }
176
177 #[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 #[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 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 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 pub fn pan(&mut self, delta: DVec3) {
232 self.position += delta;
233 self.focal_point += delta;
234 }
235
236 pub fn pan_view(&mut self, dx: f64, dy: f64) {
240 self.pan(self.right() * dx + self.view_up_ortho() * dy);
241 }
242
243 pub fn azimuth(&mut self, degrees: f64) {
247 self.orbit(degrees.to_radians(), 0.0);
248 }
249
250 pub fn elevation(&mut self, degrees: f64) {
254 self.orbit(0.0, degrees.to_radians());
255 }
256
257 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 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; let rot_h = glam::DQuat::from_axis_angle(up, -angle_h);
275 let to_eye = rot_h * to_eye;
276
277 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 self.view_up = rot_v * self.view_up;
284 }
285
286 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#[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 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 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}