1#[derive(Clone, Copy, Debug)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14pub struct CameraTarget {
15 pub center: glam::Vec3,
17 pub distance: f32,
19 pub orientation: glam::Quat,
21}
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
26#[non_exhaustive]
27pub enum Projection {
28 Perspective,
30 Orthographic,
32}
33
34#[derive(Clone)]
43#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
44pub struct Camera {
45 pub projection: Projection,
47 pub center: glam::Vec3,
49 pub distance: f32,
51 pub orientation: glam::Quat,
55 pub fov_y: f32,
57 pub aspect: f32,
59 pub znear: Option<f32>,
64 pub zfar: f32,
66}
67
68impl Default for Camera {
69 fn default() -> Self {
70 Self {
71 projection: Projection::Perspective,
72 center: glam::Vec3::ZERO,
73 distance: 5.0,
74 orientation: glam::Quat::from_rotation_z(0.6) * glam::Quat::from_rotation_x(1.1),
77 fov_y: std::f32::consts::FRAC_PI_4,
78 aspect: 1.5,
79 znear: None,
80 zfar: 1000.0,
81 }
82 }
83}
84
85impl Camera {
86 pub const MIN_DISTANCE: f32 = 0.01;
88
89 pub const MAX_DISTANCE: f32 = 1.0e6;
91
92 pub fn center(&self) -> glam::Vec3 {
100 self.center
101 }
102
103 pub fn set_center(&mut self, center: glam::Vec3) {
105 self.center = center;
106 }
107
108 pub fn distance(&self) -> f32 {
112 self.distance
113 }
114
115 pub fn set_distance(&mut self, d: f32) {
117 self.distance = d.clamp(Self::MIN_DISTANCE, Self::MAX_DISTANCE);
118 }
119
120 pub fn orientation(&self) -> glam::Quat {
124 self.orientation
125 }
126
127 pub fn set_orientation(&mut self, q: glam::Quat) {
129 self.orientation = q.normalize();
130 }
131
132 pub fn set_fov_y(&mut self, fov_y: f32) {
134 self.fov_y = fov_y;
135 }
136
137 pub fn set_aspect_ratio(&mut self, width: f32, height: f32) {
141 self.aspect = if height > 0.0 { width / height } else { 1.0 };
142 }
143
144 pub fn set_clip_planes(&mut self, znear: f32, zfar: f32) {
146 self.znear = Some(znear);
147 self.zfar = zfar;
148 }
149
150 pub fn orbit(&mut self, yaw: f32, pitch: f32) {
160 self.orientation = (glam::Quat::from_rotation_z(-yaw)
161 * self.orientation
162 * glam::Quat::from_rotation_x(-pitch))
163 .normalize();
164 }
165
166 pub fn pan_world(&mut self, right_delta: f32, up_delta: f32) {
172 self.center -= self.right() * right_delta;
173 self.center += self.up() * up_delta;
174 }
175
176 pub fn pan_pixels(&mut self, delta_pixels: glam::Vec2, viewport_height: f32) {
181 let pan_scale = 2.0 * self.distance * (self.fov_y / 2.0).tan() / viewport_height.max(1.0);
182 self.pan_world(delta_pixels.x * pan_scale, delta_pixels.y * pan_scale);
183 }
184
185 pub fn zoom_by_factor(&mut self, factor: f32) {
187 self.distance = (self.distance * factor).clamp(Self::MIN_DISTANCE, Self::MAX_DISTANCE);
188 }
189
190 pub fn zoom_by_delta(&mut self, delta: f32) {
192 self.distance = (self.distance + delta).clamp(Self::MIN_DISTANCE, Self::MAX_DISTANCE);
193 }
194
195 pub fn frame_sphere(&mut self, center: glam::Vec3, radius: f32) {
201 let (c, d) = self.fit_sphere(center, radius);
202 self.center = c;
203 self.set_distance(d);
204 }
205
206 pub fn frame_aabb(&mut self, aabb: &crate::scene::aabb::Aabb) {
208 let (c, d) = self.fit_aabb(aabb);
209 self.center = c;
210 self.set_distance(d);
211 }
212
213 pub fn fit_sphere_target(&self, center: glam::Vec3, radius: f32) -> CameraTarget {
216 let (c, d) = self.fit_sphere(center, radius);
217 CameraTarget {
218 center: c,
219 distance: d,
220 orientation: self.orientation,
221 }
222 }
223
224 pub fn fit_aabb_target(&self, aabb: &crate::scene::aabb::Aabb) -> CameraTarget {
227 let (c, d) = self.fit_aabb(aabb);
228 CameraTarget {
229 center: c,
230 distance: d,
231 orientation: self.orientation,
232 }
233 }
234
235 fn eye_offset(&self) -> glam::Vec3 {
241 self.orientation * (glam::Vec3::Z * self.distance)
242 }
243
244 pub fn eye_position(&self) -> glam::Vec3 {
246 self.center + self.eye_offset()
247 }
248
249 pub fn view_matrix(&self) -> glam::Mat4 {
251 let eye = self.eye_position();
252 let up = self.orientation * glam::Vec3::Y;
253 glam::Mat4::look_at_rh(eye, self.center, up)
254 }
255
256 pub fn effective_zfar(&self) -> f32 {
264 self.zfar.max(self.distance * 3.0)
265 }
266
267 pub fn effective_znear(&self) -> f32 {
275 self.znear.unwrap_or_else(|| self.effective_zfar() / 10_000.0)
276 }
277
278 pub fn proj_matrix(&self) -> glam::Mat4 {
280 let effective_znear = self.effective_znear();
281 let effective_zfar = self.effective_zfar();
282 match self.projection {
283 Projection::Perspective => {
284 glam::Mat4::perspective_rh(self.fov_y, self.aspect, effective_znear, effective_zfar)
285 }
286 Projection::Orthographic => {
287 let half_h = self.distance * (self.fov_y / 2.0).tan();
288 let half_w = half_h * self.aspect;
289 glam::Mat4::orthographic_rh(
290 -half_w,
291 half_w,
292 -half_h,
293 half_h,
294 effective_znear,
295 effective_zfar,
296 )
297 }
298 }
299 }
300
301 pub fn view_proj_matrix(&self) -> glam::Mat4 {
304 self.proj_matrix() * self.view_matrix()
305 }
306
307 pub fn right(&self) -> glam::Vec3 {
309 self.orientation * glam::Vec3::X
310 }
311
312 pub fn up(&self) -> glam::Vec3 {
314 self.orientation * glam::Vec3::Y
315 }
316
317 pub fn frustum(&self) -> crate::camera::frustum::Frustum {
319 crate::camera::frustum::Frustum::from_view_proj(&self.view_proj_matrix())
320 }
321
322 pub fn fit_sphere(&self, center: glam::Vec3, radius: f32) -> (glam::Vec3, f32) {
327 let distance = match self.projection {
328 Projection::Perspective => radius / (self.fov_y / 2.0).tan() * 1.2,
329 Projection::Orthographic => radius * 1.2,
330 };
331 (center, distance)
332 }
333
334 pub fn fit_aabb(&self, aabb: &crate::scene::aabb::Aabb) -> (glam::Vec3, f32) {
339 let center = aabb.center();
340 let radius = aabb.half_extents().length();
341 self.fit_sphere(center, radius)
342 }
343
344 pub fn center_on_domain(&mut self, nx: f32, ny: f32, nz: f32) {
347 self.center = glam::Vec3::new(nx / 2.0, ny / 2.0, nz / 2.0);
348 let diagonal = (nx * nx + ny * ny + nz * nz).sqrt();
349 self.distance = (diagonal / 2.0) / (self.fov_y / 2.0).tan() * 1.2;
350 self.znear = Some((diagonal * 0.0001).max(0.01));
351 self.zfar = (diagonal * 10.0).max(1000.0);
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
360 fn test_default_eye_position() {
361 let cam = Camera::default();
362 let expected = cam.center + cam.orientation * (glam::Vec3::Z * cam.distance);
363 let eye = cam.eye_position();
364 assert!(
365 (eye - expected).length() < 1e-5,
366 "eye={eye:?} expected={expected:?}"
367 );
368 }
369
370 #[test]
371 fn test_view_matrix_looks_at_center() {
372 let cam = Camera::default();
373 let view = cam.view_matrix();
374 let center_view = view.transform_point3(cam.center);
376 assert!(
378 center_view.x.abs() < 1e-4,
379 "center_view.x={}",
380 center_view.x
381 );
382 assert!(
383 center_view.y.abs() < 1e-4,
384 "center_view.y={}",
385 center_view.y
386 );
387 assert!(
388 center_view.z < 0.0,
389 "center should be in front of camera, z={}",
390 center_view.z
391 );
392 }
393
394 #[test]
395 fn test_view_proj_roundtrip() {
396 let cam = Camera::default();
397 let vp = cam.view_proj_matrix();
398 let vp_inv = vp.inverse();
399 let world_pt = vp_inv.project_point3(glam::Vec3::new(0.0, 0.0, 0.5));
401 let eye = cam.eye_position();
403 let to_center = (cam.center - eye).normalize();
404 let to_pt = (world_pt - eye).normalize();
405 let dot = to_center.dot(to_pt);
406 assert!(
407 dot > 0.99,
408 "dot={dot}, point should be along camera-to-center ray"
409 );
410 }
411
412 #[test]
413 fn test_center_on_domain() {
414 let mut cam = Camera::default();
415 cam.center_on_domain(10.0, 10.0, 10.0);
416 assert!((cam.center - glam::Vec3::splat(5.0)).length() < 1e-5);
417 assert!(cam.distance > 0.0);
418 }
419
420 #[test]
421 fn test_fit_sphere_perspective() {
422 let cam = Camera::default(); let (center, dist) = cam.fit_sphere(glam::Vec3::ZERO, 5.0);
424 assert!((center - glam::Vec3::ZERO).length() < 1e-5);
425 let expected = 5.0 / (cam.fov_y / 2.0).tan() * 1.2;
426 assert!(
427 (dist - expected).abs() < 1e-4,
428 "dist={dist}, expected={expected}"
429 );
430 }
431
432 #[test]
433 fn test_fit_sphere_orthographic() {
434 let mut cam = Camera::default();
435 cam.projection = Projection::Orthographic;
436 let (_, dist) = cam.fit_sphere(glam::Vec3::ZERO, 5.0);
437 let expected = 5.0 * 1.2;
438 assert!(
439 (dist - expected).abs() < 1e-4,
440 "dist={dist}, expected={expected}"
441 );
442 }
443
444 #[test]
445 fn test_fit_aabb_unit_cube() {
446 let cam = Camera::default();
447 let aabb = crate::scene::aabb::Aabb {
448 min: glam::Vec3::splat(-0.5),
449 max: glam::Vec3::splat(0.5),
450 };
451 let (center, dist) = cam.fit_aabb(&aabb);
452 assert!(center.length() < 1e-5, "center should be origin");
453 assert!(dist > 0.0, "distance should be positive");
454 let radius = aabb.half_extents().length();
456 let expected = radius / (cam.fov_y / 2.0).tan() * 1.2;
457 assert!(
458 (dist - expected).abs() < 1e-4,
459 "dist={dist}, expected={expected}"
460 );
461 }
462
463 #[test]
464 fn test_fit_aabb_preserves_padding() {
465 let cam = Camera::default();
466 let aabb = crate::scene::aabb::Aabb {
467 min: glam::Vec3::splat(-2.0),
468 max: glam::Vec3::splat(2.0),
469 };
470 let (_, dist) = cam.fit_aabb(&aabb);
471 let radius = aabb.half_extents().length();
473 let no_pad = radius / (cam.fov_y / 2.0).tan();
474 assert!(
475 dist > no_pad,
476 "padded distance ({dist}) should exceed unpadded ({no_pad})"
477 );
478 }
479
480 #[test]
481 fn test_right_up_orthogonal() {
482 let cam = Camera::default();
483 let dot = cam.right().dot(cam.up());
484 assert!(
485 dot.abs() < 1e-5,
486 "right and up should be orthogonal, dot={dot}"
487 );
488 }
489
490 #[test]
495 fn test_constants() {
496 assert!((Camera::MIN_DISTANCE - 0.01).abs() < 1e-7);
497 assert!((Camera::MAX_DISTANCE - 1.0e6).abs() < 1.0);
498 }
499
500 #[test]
501 fn test_set_distance_clamps() {
502 let mut cam = Camera::default();
503 cam.set_distance(-1.0);
504 assert!(
505 (cam.distance - Camera::MIN_DISTANCE).abs() < 1e-7,
506 "negative distance should clamp to MIN_DISTANCE"
507 );
508 cam.set_distance(2.0e6);
509 assert!(
510 (cam.distance - Camera::MAX_DISTANCE).abs() < 1.0,
511 "too-large distance should clamp to MAX_DISTANCE"
512 );
513 }
514
515 #[test]
516 fn test_set_center_and_getter() {
517 let mut cam = Camera::default();
518 let target = glam::Vec3::new(1.0, 2.0, 3.0);
519 cam.set_center(target);
520 assert_eq!(cam.center(), target);
521 assert_eq!(cam.center, target);
522 }
523
524 #[test]
525 fn test_set_distance_getter() {
526 let mut cam = Camera::default();
527 cam.set_distance(7.5);
528 assert!((cam.distance() - 7.5).abs() < 1e-6);
529 }
530
531 #[test]
532 fn test_set_orientation_normalizes() {
533 let mut cam = Camera::default();
534 let q = glam::Quat::from_xyzw(0.0, 0.707, 0.0, 0.707) * 2.0;
536 cam.set_orientation(q);
537 let len = (cam.orientation.x * cam.orientation.x
538 + cam.orientation.y * cam.orientation.y
539 + cam.orientation.z * cam.orientation.z
540 + cam.orientation.w * cam.orientation.w)
541 .sqrt();
542 assert!(
543 (len - 1.0).abs() < 1e-5,
544 "orientation should be normalized, len={len}"
545 );
546 }
547
548 #[test]
549 fn test_set_aspect_ratio_normal() {
550 let mut cam = Camera::default();
551 cam.set_aspect_ratio(800.0, 600.0);
552 let expected = 800.0 / 600.0;
553 assert!((cam.aspect - expected).abs() < 1e-5);
554 }
555
556 #[test]
557 fn test_set_aspect_ratio_zero_height() {
558 let mut cam = Camera::default();
559 cam.set_aspect_ratio(800.0, 0.0);
560 assert!(
561 (cam.aspect - 1.0).abs() < 1e-5,
562 "zero height should produce aspect=1.0"
563 );
564 }
565
566 #[test]
567 fn test_set_fov_y() {
568 let mut cam = Camera::default();
569 cam.set_fov_y(1.2);
570 assert!((cam.fov_y - 1.2).abs() < 1e-6);
571 }
572
573 #[test]
574 fn test_set_clip_planes() {
575 let mut cam = Camera::default();
576 cam.set_clip_planes(0.1, 500.0);
577 assert!((cam.znear.unwrap() - 0.1).abs() < 1e-6);
578 assert!((cam.zfar - 500.0).abs() < 1e-4);
579 }
580
581 #[test]
582 fn test_orbit_matches_manual() {
583 let mut cam = Camera::default();
584 let orig_orientation = cam.orientation;
585 let yaw = 0.1_f32;
586 let pitch = 0.2_f32;
587 let expected = (glam::Quat::from_rotation_z(-yaw)
588 * orig_orientation
589 * glam::Quat::from_rotation_x(-pitch))
590 .normalize();
591 cam.orbit(yaw, pitch);
592 let diff = (cam.orientation - expected).length();
593 assert!(diff < 1e-5, "orbit() result mismatch, diff={diff}");
594 }
595
596 #[test]
597 fn test_pan_world_moves_center() {
598 let mut cam = Camera::default();
599 cam.orientation = glam::Quat::IDENTITY;
600 let right = cam.right();
601 let up = cam.up();
602 let orig_center = cam.center;
603 cam.pan_world(1.0, 0.5);
604 let expected = orig_center - right * 1.0 + up * 0.5;
605 assert!(
606 (cam.center - expected).length() < 1e-5,
607 "pan_world center mismatch"
608 );
609 }
610
611 #[test]
612 fn test_pan_pixels_uses_correct_scale() {
613 let mut cam = Camera::default();
614 cam.orientation = glam::Quat::IDENTITY;
615 cam.distance = 10.0;
616 let viewport_h = 600.0_f32;
617 let pan_scale = 2.0 * cam.distance * (cam.fov_y / 2.0).tan() / viewport_h;
618 let dx = 100.0_f32;
619 let dy = 50.0_f32;
620 let orig_center = cam.center;
621 let right = cam.right();
622 let up = cam.up();
623 cam.pan_pixels(glam::vec2(dx, dy), viewport_h);
624 let expected = orig_center - right * dx * pan_scale + up * dy * pan_scale;
625 assert!(
626 (cam.center - expected).length() < 1e-4,
627 "pan_pixels center mismatch"
628 );
629 }
630
631 #[test]
632 fn test_zoom_by_factor_clamps() {
633 let mut cam = Camera::default();
634 cam.distance = 1.0;
635 cam.zoom_by_factor(0.0);
636 assert!(
637 (cam.distance - Camera::MIN_DISTANCE).abs() < 1e-7,
638 "factor=0 should clamp to MIN_DISTANCE"
639 );
640
641 cam.distance = 1.0;
642 cam.zoom_by_factor(2.0e7);
643 assert!(
644 (cam.distance - Camera::MAX_DISTANCE).abs() < 1.0,
645 "large factor should clamp to MAX_DISTANCE"
646 );
647 }
648
649 #[test]
650 fn test_zoom_by_delta() {
651 let mut cam = Camera::default();
652 cam.distance = 5.0;
653 cam.zoom_by_delta(2.0);
654 assert!((cam.distance - 7.0).abs() < 1e-5);
655 cam.zoom_by_delta(-100.0);
656 assert!(
657 cam.distance >= Camera::MIN_DISTANCE,
658 "delta clamped to MIN_DISTANCE"
659 );
660 }
661
662 #[test]
663 fn test_frame_sphere_applies_result() {
664 let mut cam = Camera::default();
665 let sphere_center = glam::Vec3::new(1.0, 2.0, 3.0);
666 let radius = 5.0;
667 let (expected_c, expected_d) = cam.fit_sphere(sphere_center, radius);
668 cam.frame_sphere(sphere_center, radius);
669 assert!((cam.center - expected_c).length() < 1e-5);
670 assert!(
671 (cam.distance - expected_d.clamp(Camera::MIN_DISTANCE, Camera::MAX_DISTANCE)).abs()
672 < 1e-5
673 );
674 }
675
676 #[test]
677 fn test_frame_aabb_applies_result() {
678 let mut cam = Camera::default();
679 let aabb = crate::scene::aabb::Aabb {
680 min: glam::Vec3::splat(-1.0),
681 max: glam::Vec3::splat(1.0),
682 };
683 let (expected_c, expected_d) = cam.fit_aabb(&aabb);
684 cam.frame_aabb(&aabb);
685 assert!((cam.center - expected_c).length() < 1e-5);
686 assert!(
687 (cam.distance - expected_d.clamp(Camera::MIN_DISTANCE, Camera::MAX_DISTANCE)).abs()
688 < 1e-5
689 );
690 }
691
692 #[test]
693 fn test_fit_sphere_target() {
694 let cam = Camera::default();
695 let sphere_center = glam::Vec3::new(0.0, 1.0, 0.0);
696 let radius = 2.0;
697 let target = cam.fit_sphere_target(sphere_center, radius);
698 let (expected_c, expected_d) = cam.fit_sphere(sphere_center, radius);
699 assert!((target.center - expected_c).length() < 1e-5);
700 assert!((target.distance - expected_d).abs() < 1e-5);
701 let diff = (target.orientation - cam.orientation).length();
703 assert!(diff < 1e-5, "fit_sphere_target should preserve orientation");
704 }
705
706 #[test]
707 fn test_fit_aabb_target() {
708 let cam = Camera::default();
709 let aabb = crate::scene::aabb::Aabb {
710 min: glam::Vec3::splat(-2.0),
711 max: glam::Vec3::splat(2.0),
712 };
713 let target = cam.fit_aabb_target(&aabb);
714 let (expected_c, expected_d) = cam.fit_aabb(&aabb);
715 assert!((target.center - expected_c).length() < 1e-5);
716 assert!((target.distance - expected_d).abs() < 1e-5);
717 let diff = (target.orientation - cam.orientation).length();
718 assert!(diff < 1e-5, "fit_aabb_target should preserve orientation");
719 }
720}