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