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
276 .unwrap_or_else(|| self.effective_zfar() / 10_000.0)
277 }
278
279 pub fn proj_matrix(&self) -> glam::Mat4 {
281 let effective_znear = self.effective_znear();
282 let effective_zfar = self.effective_zfar();
283 match self.projection {
284 Projection::Perspective => {
285 glam::Mat4::perspective_rh(self.fov_y, self.aspect, effective_znear, effective_zfar)
286 }
287 Projection::Orthographic => {
288 let half_h = self.distance * (self.fov_y / 2.0).tan();
289 let half_w = half_h * self.aspect;
290 glam::Mat4::orthographic_rh(
291 -half_w,
292 half_w,
293 -half_h,
294 half_h,
295 effective_znear,
296 effective_zfar,
297 )
298 }
299 }
300 }
301
302 pub fn view_proj_matrix(&self) -> glam::Mat4 {
305 self.proj_matrix() * self.view_matrix()
306 }
307
308 pub fn right(&self) -> glam::Vec3 {
310 self.orientation * glam::Vec3::X
311 }
312
313 pub fn up(&self) -> glam::Vec3 {
315 self.orientation * glam::Vec3::Y
316 }
317
318 pub fn frustum(&self) -> crate::camera::frustum::Frustum {
320 crate::camera::frustum::Frustum::from_view_proj(&self.view_proj_matrix())
321 }
322
323 pub fn fit_sphere(&self, center: glam::Vec3, radius: f32) -> (glam::Vec3, f32) {
328 let distance = match self.projection {
329 Projection::Perspective => radius / (self.fov_y / 2.0).tan() * 1.2,
330 Projection::Orthographic => radius * 1.2,
331 };
332 (center, distance)
333 }
334
335 pub fn fit_aabb(&self, aabb: &crate::scene::aabb::Aabb) -> (glam::Vec3, f32) {
340 let center = aabb.center();
341 let radius = aabb.half_extents().length();
342 self.fit_sphere(center, radius)
343 }
344
345 pub fn center_on_domain(&mut self, nx: f32, ny: f32, nz: f32) {
348 self.center = glam::Vec3::new(nx / 2.0, ny / 2.0, nz / 2.0);
349 let diagonal = (nx * nx + ny * ny + nz * nz).sqrt();
350 self.distance = (diagonal / 2.0) / (self.fov_y / 2.0).tan() * 1.2;
351 self.znear = Some((diagonal * 0.0001).max(0.01));
352 self.zfar = (diagonal * 10.0).max(1000.0);
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 #[test]
361 fn test_default_eye_position() {
362 let cam = Camera::default();
363 let expected = cam.center + cam.orientation * (glam::Vec3::Z * cam.distance);
364 let eye = cam.eye_position();
365 assert!(
366 (eye - expected).length() < 1e-5,
367 "eye={eye:?} expected={expected:?}"
368 );
369 }
370
371 #[test]
372 fn test_view_matrix_looks_at_center() {
373 let cam = Camera::default();
374 let view = cam.view_matrix();
375 let center_view = view.transform_point3(cam.center);
377 assert!(
379 center_view.x.abs() < 1e-4,
380 "center_view.x={}",
381 center_view.x
382 );
383 assert!(
384 center_view.y.abs() < 1e-4,
385 "center_view.y={}",
386 center_view.y
387 );
388 assert!(
389 center_view.z < 0.0,
390 "center should be in front of camera, z={}",
391 center_view.z
392 );
393 }
394
395 #[test]
396 fn test_view_proj_roundtrip() {
397 let cam = Camera::default();
398 let vp = cam.view_proj_matrix();
399 let vp_inv = vp.inverse();
400 let world_pt = vp_inv.project_point3(glam::Vec3::new(0.0, 0.0, 0.5));
402 let eye = cam.eye_position();
404 let to_center = (cam.center - eye).normalize();
405 let to_pt = (world_pt - eye).normalize();
406 let dot = to_center.dot(to_pt);
407 assert!(
408 dot > 0.99,
409 "dot={dot}, point should be along camera-to-center ray"
410 );
411 }
412
413 #[test]
414 fn test_center_on_domain() {
415 let mut cam = Camera::default();
416 cam.center_on_domain(10.0, 10.0, 10.0);
417 assert!((cam.center - glam::Vec3::splat(5.0)).length() < 1e-5);
418 assert!(cam.distance > 0.0);
419 }
420
421 #[test]
422 fn test_fit_sphere_perspective() {
423 let cam = Camera::default(); let (center, dist) = cam.fit_sphere(glam::Vec3::ZERO, 5.0);
425 assert!((center - glam::Vec3::ZERO).length() < 1e-5);
426 let expected = 5.0 / (cam.fov_y / 2.0).tan() * 1.2;
427 assert!(
428 (dist - expected).abs() < 1e-4,
429 "dist={dist}, expected={expected}"
430 );
431 }
432
433 #[test]
434 fn test_fit_sphere_orthographic() {
435 let mut cam = Camera::default();
436 cam.projection = Projection::Orthographic;
437 let (_, dist) = cam.fit_sphere(glam::Vec3::ZERO, 5.0);
438 let expected = 5.0 * 1.2;
439 assert!(
440 (dist - expected).abs() < 1e-4,
441 "dist={dist}, expected={expected}"
442 );
443 }
444
445 #[test]
446 fn test_fit_aabb_unit_cube() {
447 let cam = Camera::default();
448 let aabb = crate::scene::aabb::Aabb {
449 min: glam::Vec3::splat(-0.5),
450 max: glam::Vec3::splat(0.5),
451 };
452 let (center, dist) = cam.fit_aabb(&aabb);
453 assert!(center.length() < 1e-5, "center should be origin");
454 assert!(dist > 0.0, "distance should be positive");
455 let radius = aabb.half_extents().length();
457 let expected = radius / (cam.fov_y / 2.0).tan() * 1.2;
458 assert!(
459 (dist - expected).abs() < 1e-4,
460 "dist={dist}, expected={expected}"
461 );
462 }
463
464 #[test]
465 fn test_fit_aabb_preserves_padding() {
466 let cam = Camera::default();
467 let aabb = crate::scene::aabb::Aabb {
468 min: glam::Vec3::splat(-2.0),
469 max: glam::Vec3::splat(2.0),
470 };
471 let (_, dist) = cam.fit_aabb(&aabb);
472 let radius = aabb.half_extents().length();
474 let no_pad = radius / (cam.fov_y / 2.0).tan();
475 assert!(
476 dist > no_pad,
477 "padded distance ({dist}) should exceed unpadded ({no_pad})"
478 );
479 }
480
481 #[test]
482 fn test_right_up_orthogonal() {
483 let cam = Camera::default();
484 let dot = cam.right().dot(cam.up());
485 assert!(
486 dot.abs() < 1e-5,
487 "right and up should be orthogonal, dot={dot}"
488 );
489 }
490
491 #[test]
496 fn test_constants() {
497 assert!((Camera::MIN_DISTANCE - 0.01).abs() < 1e-7);
498 assert!((Camera::MAX_DISTANCE - 1.0e6).abs() < 1.0);
499 }
500
501 #[test]
502 fn test_set_distance_clamps() {
503 let mut cam = Camera::default();
504 cam.set_distance(-1.0);
505 assert!(
506 (cam.distance - Camera::MIN_DISTANCE).abs() < 1e-7,
507 "negative distance should clamp to MIN_DISTANCE"
508 );
509 cam.set_distance(2.0e6);
510 assert!(
511 (cam.distance - Camera::MAX_DISTANCE).abs() < 1.0,
512 "too-large distance should clamp to MAX_DISTANCE"
513 );
514 }
515
516 #[test]
517 fn test_set_center_and_getter() {
518 let mut cam = Camera::default();
519 let target = glam::Vec3::new(1.0, 2.0, 3.0);
520 cam.set_center(target);
521 assert_eq!(cam.center(), target);
522 assert_eq!(cam.center, target);
523 }
524
525 #[test]
526 fn test_set_distance_getter() {
527 let mut cam = Camera::default();
528 cam.set_distance(7.5);
529 assert!((cam.distance() - 7.5).abs() < 1e-6);
530 }
531
532 #[test]
533 fn test_set_orientation_normalizes() {
534 let mut cam = Camera::default();
535 let q = glam::Quat::from_xyzw(0.0, 0.707, 0.0, 0.707) * 2.0;
537 cam.set_orientation(q);
538 let len = (cam.orientation.x * cam.orientation.x
539 + cam.orientation.y * cam.orientation.y
540 + cam.orientation.z * cam.orientation.z
541 + cam.orientation.w * cam.orientation.w)
542 .sqrt();
543 assert!(
544 (len - 1.0).abs() < 1e-5,
545 "orientation should be normalized, len={len}"
546 );
547 }
548
549 #[test]
550 fn test_set_aspect_ratio_normal() {
551 let mut cam = Camera::default();
552 cam.set_aspect_ratio(800.0, 600.0);
553 let expected = 800.0 / 600.0;
554 assert!((cam.aspect - expected).abs() < 1e-5);
555 }
556
557 #[test]
558 fn test_set_aspect_ratio_zero_height() {
559 let mut cam = Camera::default();
560 cam.set_aspect_ratio(800.0, 0.0);
561 assert!(
562 (cam.aspect - 1.0).abs() < 1e-5,
563 "zero height should produce aspect=1.0"
564 );
565 }
566
567 #[test]
568 fn test_set_fov_y() {
569 let mut cam = Camera::default();
570 cam.set_fov_y(1.2);
571 assert!((cam.fov_y - 1.2).abs() < 1e-6);
572 }
573
574 #[test]
575 fn test_set_clip_planes() {
576 let mut cam = Camera::default();
577 cam.set_clip_planes(0.1, 500.0);
578 assert!((cam.znear.unwrap() - 0.1).abs() < 1e-6);
579 assert!((cam.zfar - 500.0).abs() < 1e-4);
580 }
581
582 #[test]
583 fn test_orbit_matches_manual() {
584 let mut cam = Camera::default();
585 let orig_orientation = cam.orientation;
586 let yaw = 0.1_f32;
587 let pitch = 0.2_f32;
588 let expected = (glam::Quat::from_rotation_z(-yaw)
589 * orig_orientation
590 * glam::Quat::from_rotation_x(-pitch))
591 .normalize();
592 cam.orbit(yaw, pitch);
593 let diff = (cam.orientation - expected).length();
594 assert!(diff < 1e-5, "orbit() result mismatch, diff={diff}");
595 }
596
597 #[test]
598 fn test_pan_world_moves_center() {
599 let mut cam = Camera::default();
600 cam.orientation = glam::Quat::IDENTITY;
601 let right = cam.right();
602 let up = cam.up();
603 let orig_center = cam.center;
604 cam.pan_world(1.0, 0.5);
605 let expected = orig_center - right * 1.0 + up * 0.5;
606 assert!(
607 (cam.center - expected).length() < 1e-5,
608 "pan_world center mismatch"
609 );
610 }
611
612 #[test]
613 fn test_pan_pixels_uses_correct_scale() {
614 let mut cam = Camera::default();
615 cam.orientation = glam::Quat::IDENTITY;
616 cam.distance = 10.0;
617 let viewport_h = 600.0_f32;
618 let pan_scale = 2.0 * cam.distance * (cam.fov_y / 2.0).tan() / viewport_h;
619 let dx = 100.0_f32;
620 let dy = 50.0_f32;
621 let orig_center = cam.center;
622 let right = cam.right();
623 let up = cam.up();
624 cam.pan_pixels(glam::vec2(dx, dy), viewport_h);
625 let expected = orig_center - right * dx * pan_scale + up * dy * pan_scale;
626 assert!(
627 (cam.center - expected).length() < 1e-4,
628 "pan_pixels center mismatch"
629 );
630 }
631
632 #[test]
633 fn test_zoom_by_factor_clamps() {
634 let mut cam = Camera::default();
635 cam.distance = 1.0;
636 cam.zoom_by_factor(0.0);
637 assert!(
638 (cam.distance - Camera::MIN_DISTANCE).abs() < 1e-7,
639 "factor=0 should clamp to MIN_DISTANCE"
640 );
641
642 cam.distance = 1.0;
643 cam.zoom_by_factor(2.0e7);
644 assert!(
645 (cam.distance - Camera::MAX_DISTANCE).abs() < 1.0,
646 "large factor should clamp to MAX_DISTANCE"
647 );
648 }
649
650 #[test]
651 fn test_zoom_by_delta() {
652 let mut cam = Camera::default();
653 cam.distance = 5.0;
654 cam.zoom_by_delta(2.0);
655 assert!((cam.distance - 7.0).abs() < 1e-5);
656 cam.zoom_by_delta(-100.0);
657 assert!(
658 cam.distance >= Camera::MIN_DISTANCE,
659 "delta clamped to MIN_DISTANCE"
660 );
661 }
662
663 #[test]
664 fn test_frame_sphere_applies_result() {
665 let mut cam = Camera::default();
666 let sphere_center = glam::Vec3::new(1.0, 2.0, 3.0);
667 let radius = 5.0;
668 let (expected_c, expected_d) = cam.fit_sphere(sphere_center, radius);
669 cam.frame_sphere(sphere_center, radius);
670 assert!((cam.center - expected_c).length() < 1e-5);
671 assert!(
672 (cam.distance - expected_d.clamp(Camera::MIN_DISTANCE, Camera::MAX_DISTANCE)).abs()
673 < 1e-5
674 );
675 }
676
677 #[test]
678 fn test_frame_aabb_applies_result() {
679 let mut cam = Camera::default();
680 let aabb = crate::scene::aabb::Aabb {
681 min: glam::Vec3::splat(-1.0),
682 max: glam::Vec3::splat(1.0),
683 };
684 let (expected_c, expected_d) = cam.fit_aabb(&aabb);
685 cam.frame_aabb(&aabb);
686 assert!((cam.center - expected_c).length() < 1e-5);
687 assert!(
688 (cam.distance - expected_d.clamp(Camera::MIN_DISTANCE, Camera::MAX_DISTANCE)).abs()
689 < 1e-5
690 );
691 }
692
693 #[test]
694 fn test_fit_sphere_target() {
695 let cam = Camera::default();
696 let sphere_center = glam::Vec3::new(0.0, 1.0, 0.0);
697 let radius = 2.0;
698 let target = cam.fit_sphere_target(sphere_center, radius);
699 let (expected_c, expected_d) = cam.fit_sphere(sphere_center, radius);
700 assert!((target.center - expected_c).length() < 1e-5);
701 assert!((target.distance - expected_d).abs() < 1e-5);
702 let diff = (target.orientation - cam.orientation).length();
704 assert!(diff < 1e-5, "fit_sphere_target should preserve orientation");
705 }
706
707 #[test]
708 fn test_fit_aabb_target() {
709 let cam = Camera::default();
710 let aabb = crate::scene::aabb::Aabb {
711 min: glam::Vec3::splat(-2.0),
712 max: glam::Vec3::splat(2.0),
713 };
714 let target = cam.fit_aabb_target(&aabb);
715 let (expected_c, expected_d) = cam.fit_aabb(&aabb);
716 assert!((target.center - expected_c).length() < 1e-5);
717 assert!((target.distance - expected_d).abs() < 1e-5);
718 let diff = (target.orientation - cam.orientation).length();
719 assert!(diff < 1e-5, "fit_aabb_target should preserve orientation");
720 }
721}