1#[derive(Clone, Copy, Debug)]
13pub struct CameraTarget {
14 pub center: glam::Vec3,
16 pub distance: f32,
18 pub orientation: glam::Quat,
20}
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24#[non_exhaustive]
25pub enum Projection {
26 Perspective,
28 Orthographic,
30}
31
32#[derive(Clone)]
41pub struct Camera {
42 pub projection: Projection,
44 pub center: glam::Vec3,
46 pub distance: f32,
48 pub orientation: glam::Quat,
52 pub fov_y: f32,
54 pub aspect: f32,
56 pub znear: f32,
58 pub zfar: f32,
60}
61
62impl Default for Camera {
63 fn default() -> Self {
64 Self {
65 projection: Projection::Perspective,
66 center: glam::Vec3::ZERO,
67 distance: 5.0,
68 orientation: glam::Quat::from_rotation_z(0.6) * glam::Quat::from_rotation_x(1.1),
71 fov_y: std::f32::consts::FRAC_PI_4,
72 aspect: 1.5,
73 znear: 0.01,
74 zfar: 1000.0,
75 }
76 }
77}
78
79impl Camera {
80 pub const MIN_DISTANCE: f32 = 0.01;
82
83 pub const MAX_DISTANCE: f32 = 1.0e6;
85
86 pub fn center(&self) -> glam::Vec3 {
94 self.center
95 }
96
97 pub fn set_center(&mut self, center: glam::Vec3) {
99 self.center = center;
100 }
101
102 pub fn distance(&self) -> f32 {
106 self.distance
107 }
108
109 pub fn set_distance(&mut self, d: f32) {
111 self.distance = d.clamp(Self::MIN_DISTANCE, Self::MAX_DISTANCE);
112 }
113
114 pub fn orientation(&self) -> glam::Quat {
118 self.orientation
119 }
120
121 pub fn set_orientation(&mut self, q: glam::Quat) {
123 self.orientation = q.normalize();
124 }
125
126 pub fn set_fov_y(&mut self, fov_y: f32) {
128 self.fov_y = fov_y;
129 }
130
131 pub fn set_aspect_ratio(&mut self, width: f32, height: f32) {
135 self.aspect = if height > 0.0 { width / height } else { 1.0 };
136 }
137
138 pub fn set_clip_planes(&mut self, znear: f32, zfar: f32) {
140 self.znear = znear;
141 self.zfar = zfar;
142 }
143
144 pub fn orbit(&mut self, yaw: f32, pitch: f32) {
154 self.orientation = (glam::Quat::from_rotation_z(-yaw)
155 * self.orientation
156 * glam::Quat::from_rotation_x(-pitch))
157 .normalize();
158 }
159
160 pub fn pan_world(&mut self, right_delta: f32, up_delta: f32) {
166 self.center -= self.right() * right_delta;
167 self.center += self.up() * up_delta;
168 }
169
170 pub fn pan_pixels(&mut self, delta_pixels: glam::Vec2, viewport_height: f32) {
175 let pan_scale = 2.0 * self.distance * (self.fov_y / 2.0).tan() / viewport_height.max(1.0);
176 self.pan_world(delta_pixels.x * pan_scale, delta_pixels.y * pan_scale);
177 }
178
179 pub fn zoom_by_factor(&mut self, factor: f32) {
181 self.distance = (self.distance * factor).clamp(Self::MIN_DISTANCE, Self::MAX_DISTANCE);
182 }
183
184 pub fn zoom_by_delta(&mut self, delta: f32) {
186 self.distance = (self.distance + delta).clamp(Self::MIN_DISTANCE, Self::MAX_DISTANCE);
187 }
188
189 pub fn frame_sphere(&mut self, center: glam::Vec3, radius: f32) {
195 let (c, d) = self.fit_sphere(center, radius);
196 self.center = c;
197 self.set_distance(d);
198 }
199
200 pub fn frame_aabb(&mut self, aabb: &crate::scene::aabb::Aabb) {
202 let (c, d) = self.fit_aabb(aabb);
203 self.center = c;
204 self.set_distance(d);
205 }
206
207 pub fn fit_sphere_target(&self, center: glam::Vec3, radius: f32) -> CameraTarget {
210 let (c, d) = self.fit_sphere(center, radius);
211 CameraTarget {
212 center: c,
213 distance: d,
214 orientation: self.orientation,
215 }
216 }
217
218 pub fn fit_aabb_target(&self, aabb: &crate::scene::aabb::Aabb) -> CameraTarget {
221 let (c, d) = self.fit_aabb(aabb);
222 CameraTarget {
223 center: c,
224 distance: d,
225 orientation: self.orientation,
226 }
227 }
228
229 fn eye_offset(&self) -> glam::Vec3 {
235 self.orientation * (glam::Vec3::Z * self.distance)
236 }
237
238 pub fn eye_position(&self) -> glam::Vec3 {
240 self.center + self.eye_offset()
241 }
242
243 pub fn view_matrix(&self) -> glam::Mat4 {
245 let eye = self.eye_position();
246 let up = self.orientation * glam::Vec3::Y;
247 glam::Mat4::look_at_rh(eye, self.center, up)
248 }
249
250 pub fn effective_zfar(&self) -> f32 {
258 self.zfar.max(self.distance * 3.0)
259 }
260
261 pub fn proj_matrix(&self) -> glam::Mat4 {
262 let effective_zfar = self.effective_zfar();
266 match self.projection {
267 Projection::Perspective => {
268 glam::Mat4::perspective_rh(self.fov_y, self.aspect, self.znear, effective_zfar)
269 }
270 Projection::Orthographic => {
271 let half_h = self.distance * (self.fov_y / 2.0).tan();
272 let half_w = half_h * self.aspect;
273 glam::Mat4::orthographic_rh(
274 -half_w,
275 half_w,
276 -half_h,
277 half_h,
278 self.znear,
279 effective_zfar,
280 )
281 }
282 }
283 }
284
285 pub fn view_proj_matrix(&self) -> glam::Mat4 {
288 self.proj_matrix() * self.view_matrix()
289 }
290
291 pub fn right(&self) -> glam::Vec3 {
293 self.orientation * glam::Vec3::X
294 }
295
296 pub fn up(&self) -> glam::Vec3 {
298 self.orientation * glam::Vec3::Y
299 }
300
301 pub fn frustum(&self) -> crate::camera::frustum::Frustum {
303 crate::camera::frustum::Frustum::from_view_proj(&self.view_proj_matrix())
304 }
305
306 pub fn fit_sphere(&self, center: glam::Vec3, radius: f32) -> (glam::Vec3, f32) {
311 let distance = match self.projection {
312 Projection::Perspective => radius / (self.fov_y / 2.0).tan() * 1.2,
313 Projection::Orthographic => radius * 1.2,
314 };
315 (center, distance)
316 }
317
318 pub fn fit_aabb(&self, aabb: &crate::scene::aabb::Aabb) -> (glam::Vec3, f32) {
323 let center = aabb.center();
324 let radius = aabb.half_extents().length();
325 self.fit_sphere(center, radius)
326 }
327
328 pub fn center_on_domain(&mut self, nx: f32, ny: f32, nz: f32) {
331 self.center = glam::Vec3::new(nx / 2.0, ny / 2.0, nz / 2.0);
332 let diagonal = (nx * nx + ny * ny + nz * nz).sqrt();
333 self.distance = (diagonal / 2.0) / (self.fov_y / 2.0).tan() * 1.2;
334 self.znear = (diagonal * 0.0001).max(0.01);
335 self.zfar = (diagonal * 10.0).max(1000.0);
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 #[test]
344 fn test_default_eye_position() {
345 let cam = Camera::default();
346 let expected = cam.center + cam.orientation * (glam::Vec3::Z * cam.distance);
347 let eye = cam.eye_position();
348 assert!(
349 (eye - expected).length() < 1e-5,
350 "eye={eye:?} expected={expected:?}"
351 );
352 }
353
354 #[test]
355 fn test_view_matrix_looks_at_center() {
356 let cam = Camera::default();
357 let view = cam.view_matrix();
358 let center_view = view.transform_point3(cam.center);
360 assert!(
362 center_view.x.abs() < 1e-4,
363 "center_view.x={}",
364 center_view.x
365 );
366 assert!(
367 center_view.y.abs() < 1e-4,
368 "center_view.y={}",
369 center_view.y
370 );
371 assert!(
372 center_view.z < 0.0,
373 "center should be in front of camera, z={}",
374 center_view.z
375 );
376 }
377
378 #[test]
379 fn test_view_proj_roundtrip() {
380 let cam = Camera::default();
381 let vp = cam.view_proj_matrix();
382 let vp_inv = vp.inverse();
383 let world_pt = vp_inv.project_point3(glam::Vec3::new(0.0, 0.0, 0.5));
385 let eye = cam.eye_position();
387 let to_center = (cam.center - eye).normalize();
388 let to_pt = (world_pt - eye).normalize();
389 let dot = to_center.dot(to_pt);
390 assert!(
391 dot > 0.99,
392 "dot={dot}, point should be along camera-to-center ray"
393 );
394 }
395
396 #[test]
397 fn test_center_on_domain() {
398 let mut cam = Camera::default();
399 cam.center_on_domain(10.0, 10.0, 10.0);
400 assert!((cam.center - glam::Vec3::splat(5.0)).length() < 1e-5);
401 assert!(cam.distance > 0.0);
402 }
403
404 #[test]
405 fn test_fit_sphere_perspective() {
406 let cam = Camera::default(); let (center, dist) = cam.fit_sphere(glam::Vec3::ZERO, 5.0);
408 assert!((center - glam::Vec3::ZERO).length() < 1e-5);
409 let expected = 5.0 / (cam.fov_y / 2.0).tan() * 1.2;
410 assert!(
411 (dist - expected).abs() < 1e-4,
412 "dist={dist}, expected={expected}"
413 );
414 }
415
416 #[test]
417 fn test_fit_sphere_orthographic() {
418 let mut cam = Camera::default();
419 cam.projection = Projection::Orthographic;
420 let (_, dist) = cam.fit_sphere(glam::Vec3::ZERO, 5.0);
421 let expected = 5.0 * 1.2;
422 assert!(
423 (dist - expected).abs() < 1e-4,
424 "dist={dist}, expected={expected}"
425 );
426 }
427
428 #[test]
429 fn test_fit_aabb_unit_cube() {
430 let cam = Camera::default();
431 let aabb = crate::scene::aabb::Aabb {
432 min: glam::Vec3::splat(-0.5),
433 max: glam::Vec3::splat(0.5),
434 };
435 let (center, dist) = cam.fit_aabb(&aabb);
436 assert!(center.length() < 1e-5, "center should be origin");
437 assert!(dist > 0.0, "distance should be positive");
438 let radius = aabb.half_extents().length();
440 let expected = radius / (cam.fov_y / 2.0).tan() * 1.2;
441 assert!(
442 (dist - expected).abs() < 1e-4,
443 "dist={dist}, expected={expected}"
444 );
445 }
446
447 #[test]
448 fn test_fit_aabb_preserves_padding() {
449 let cam = Camera::default();
450 let aabb = crate::scene::aabb::Aabb {
451 min: glam::Vec3::splat(-2.0),
452 max: glam::Vec3::splat(2.0),
453 };
454 let (_, dist) = cam.fit_aabb(&aabb);
455 let radius = aabb.half_extents().length();
457 let no_pad = radius / (cam.fov_y / 2.0).tan();
458 assert!(
459 dist > no_pad,
460 "padded distance ({dist}) should exceed unpadded ({no_pad})"
461 );
462 }
463
464 #[test]
465 fn test_right_up_orthogonal() {
466 let cam = Camera::default();
467 let dot = cam.right().dot(cam.up());
468 assert!(
469 dot.abs() < 1e-5,
470 "right and up should be orthogonal, dot={dot}"
471 );
472 }
473
474 #[test]
479 fn test_constants() {
480 assert!((Camera::MIN_DISTANCE - 0.01).abs() < 1e-7);
481 assert!((Camera::MAX_DISTANCE - 1.0e6).abs() < 1.0);
482 }
483
484 #[test]
485 fn test_set_distance_clamps() {
486 let mut cam = Camera::default();
487 cam.set_distance(-1.0);
488 assert!(
489 (cam.distance - Camera::MIN_DISTANCE).abs() < 1e-7,
490 "negative distance should clamp to MIN_DISTANCE"
491 );
492 cam.set_distance(2.0e6);
493 assert!(
494 (cam.distance - Camera::MAX_DISTANCE).abs() < 1.0,
495 "too-large distance should clamp to MAX_DISTANCE"
496 );
497 }
498
499 #[test]
500 fn test_set_center_and_getter() {
501 let mut cam = Camera::default();
502 let target = glam::Vec3::new(1.0, 2.0, 3.0);
503 cam.set_center(target);
504 assert_eq!(cam.center(), target);
505 assert_eq!(cam.center, target);
506 }
507
508 #[test]
509 fn test_set_distance_getter() {
510 let mut cam = Camera::default();
511 cam.set_distance(7.5);
512 assert!((cam.distance() - 7.5).abs() < 1e-6);
513 }
514
515 #[test]
516 fn test_set_orientation_normalizes() {
517 let mut cam = Camera::default();
518 let q = glam::Quat::from_xyzw(0.0, 0.707, 0.0, 0.707) * 2.0;
520 cam.set_orientation(q);
521 let len = (cam.orientation.x * cam.orientation.x
522 + cam.orientation.y * cam.orientation.y
523 + cam.orientation.z * cam.orientation.z
524 + cam.orientation.w * cam.orientation.w)
525 .sqrt();
526 assert!(
527 (len - 1.0).abs() < 1e-5,
528 "orientation should be normalized, len={len}"
529 );
530 }
531
532 #[test]
533 fn test_set_aspect_ratio_normal() {
534 let mut cam = Camera::default();
535 cam.set_aspect_ratio(800.0, 600.0);
536 let expected = 800.0 / 600.0;
537 assert!((cam.aspect - expected).abs() < 1e-5);
538 }
539
540 #[test]
541 fn test_set_aspect_ratio_zero_height() {
542 let mut cam = Camera::default();
543 cam.set_aspect_ratio(800.0, 0.0);
544 assert!(
545 (cam.aspect - 1.0).abs() < 1e-5,
546 "zero height should produce aspect=1.0"
547 );
548 }
549
550 #[test]
551 fn test_set_fov_y() {
552 let mut cam = Camera::default();
553 cam.set_fov_y(1.2);
554 assert!((cam.fov_y - 1.2).abs() < 1e-6);
555 }
556
557 #[test]
558 fn test_set_clip_planes() {
559 let mut cam = Camera::default();
560 cam.set_clip_planes(0.1, 500.0);
561 assert!((cam.znear - 0.1).abs() < 1e-6);
562 assert!((cam.zfar - 500.0).abs() < 1e-4);
563 }
564
565 #[test]
566 fn test_orbit_matches_manual() {
567 let mut cam = Camera::default();
568 let orig_orientation = cam.orientation;
569 let yaw = 0.1_f32;
570 let pitch = 0.2_f32;
571 let expected = (glam::Quat::from_rotation_z(-yaw)
572 * orig_orientation
573 * glam::Quat::from_rotation_x(-pitch))
574 .normalize();
575 cam.orbit(yaw, pitch);
576 let diff = (cam.orientation - expected).length();
577 assert!(diff < 1e-5, "orbit() result mismatch, diff={diff}");
578 }
579
580 #[test]
581 fn test_pan_world_moves_center() {
582 let mut cam = Camera::default();
583 cam.orientation = glam::Quat::IDENTITY;
584 let right = cam.right();
585 let up = cam.up();
586 let orig_center = cam.center;
587 cam.pan_world(1.0, 0.5);
588 let expected = orig_center - right * 1.0 + up * 0.5;
589 assert!(
590 (cam.center - expected).length() < 1e-5,
591 "pan_world center mismatch"
592 );
593 }
594
595 #[test]
596 fn test_pan_pixels_uses_correct_scale() {
597 let mut cam = Camera::default();
598 cam.orientation = glam::Quat::IDENTITY;
599 cam.distance = 10.0;
600 let viewport_h = 600.0_f32;
601 let pan_scale = 2.0 * cam.distance * (cam.fov_y / 2.0).tan() / viewport_h;
602 let dx = 100.0_f32;
603 let dy = 50.0_f32;
604 let orig_center = cam.center;
605 let right = cam.right();
606 let up = cam.up();
607 cam.pan_pixels(glam::vec2(dx, dy), viewport_h);
608 let expected = orig_center - right * dx * pan_scale + up * dy * pan_scale;
609 assert!(
610 (cam.center - expected).length() < 1e-4,
611 "pan_pixels center mismatch"
612 );
613 }
614
615 #[test]
616 fn test_zoom_by_factor_clamps() {
617 let mut cam = Camera::default();
618 cam.distance = 1.0;
619 cam.zoom_by_factor(0.0);
620 assert!(
621 (cam.distance - Camera::MIN_DISTANCE).abs() < 1e-7,
622 "factor=0 should clamp to MIN_DISTANCE"
623 );
624
625 cam.distance = 1.0;
626 cam.zoom_by_factor(2.0e7);
627 assert!(
628 (cam.distance - Camera::MAX_DISTANCE).abs() < 1.0,
629 "large factor should clamp to MAX_DISTANCE"
630 );
631 }
632
633 #[test]
634 fn test_zoom_by_delta() {
635 let mut cam = Camera::default();
636 cam.distance = 5.0;
637 cam.zoom_by_delta(2.0);
638 assert!((cam.distance - 7.0).abs() < 1e-5);
639 cam.zoom_by_delta(-100.0);
640 assert!(
641 cam.distance >= Camera::MIN_DISTANCE,
642 "delta clamped to MIN_DISTANCE"
643 );
644 }
645
646 #[test]
647 fn test_frame_sphere_applies_result() {
648 let mut cam = Camera::default();
649 let sphere_center = glam::Vec3::new(1.0, 2.0, 3.0);
650 let radius = 5.0;
651 let (expected_c, expected_d) = cam.fit_sphere(sphere_center, radius);
652 cam.frame_sphere(sphere_center, radius);
653 assert!((cam.center - expected_c).length() < 1e-5);
654 assert!(
655 (cam.distance - expected_d.clamp(Camera::MIN_DISTANCE, Camera::MAX_DISTANCE)).abs()
656 < 1e-5
657 );
658 }
659
660 #[test]
661 fn test_frame_aabb_applies_result() {
662 let mut cam = Camera::default();
663 let aabb = crate::scene::aabb::Aabb {
664 min: glam::Vec3::splat(-1.0),
665 max: glam::Vec3::splat(1.0),
666 };
667 let (expected_c, expected_d) = cam.fit_aabb(&aabb);
668 cam.frame_aabb(&aabb);
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_fit_sphere_target() {
678 let cam = Camera::default();
679 let sphere_center = glam::Vec3::new(0.0, 1.0, 0.0);
680 let radius = 2.0;
681 let target = cam.fit_sphere_target(sphere_center, radius);
682 let (expected_c, expected_d) = cam.fit_sphere(sphere_center, radius);
683 assert!((target.center - expected_c).length() < 1e-5);
684 assert!((target.distance - expected_d).abs() < 1e-5);
685 let diff = (target.orientation - cam.orientation).length();
687 assert!(diff < 1e-5, "fit_sphere_target should preserve orientation");
688 }
689
690 #[test]
691 fn test_fit_aabb_target() {
692 let cam = Camera::default();
693 let aabb = crate::scene::aabb::Aabb {
694 min: glam::Vec3::splat(-2.0),
695 max: glam::Vec3::splat(2.0),
696 };
697 let target = cam.fit_aabb_target(&aabb);
698 let (expected_c, expected_d) = cam.fit_aabb(&aabb);
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();
702 assert!(diff < 1e-5, "fit_aabb_target should preserve orientation");
703 }
704}