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_y(0.3) * glam::Quat::from_rotation_x(-0.3),
70 fov_y: std::f32::consts::FRAC_PI_4,
71 aspect: 1.5,
72 znear: 0.01,
73 zfar: 1000.0,
74 }
75 }
76}
77
78impl Camera {
79 pub const MIN_DISTANCE: f32 = 0.01;
81
82 pub const MAX_DISTANCE: f32 = 1.0e6;
84
85 pub fn center(&self) -> glam::Vec3 {
93 self.center
94 }
95
96 pub fn set_center(&mut self, center: glam::Vec3) {
98 self.center = center;
99 }
100
101 pub fn distance(&self) -> f32 {
105 self.distance
106 }
107
108 pub fn set_distance(&mut self, d: f32) {
110 self.distance = d.clamp(Self::MIN_DISTANCE, Self::MAX_DISTANCE);
111 }
112
113 pub fn orientation(&self) -> glam::Quat {
117 self.orientation
118 }
119
120 pub fn set_orientation(&mut self, q: glam::Quat) {
122 self.orientation = q.normalize();
123 }
124
125 pub fn set_fov_y(&mut self, fov_y: f32) {
127 self.fov_y = fov_y;
128 }
129
130 pub fn set_aspect_ratio(&mut self, width: f32, height: f32) {
134 self.aspect = if height > 0.0 { width / height } else { 1.0 };
135 }
136
137 pub fn set_clip_planes(&mut self, znear: f32, zfar: f32) {
139 self.znear = znear;
140 self.zfar = zfar;
141 }
142
143 pub fn orbit(&mut self, yaw: f32, pitch: f32) {
153 self.orientation = (glam::Quat::from_rotation_y(-yaw)
154 * self.orientation
155 * glam::Quat::from_rotation_x(-pitch))
156 .normalize();
157 }
158
159 pub fn pan_world(&mut self, right_delta: f32, up_delta: f32) {
165 self.center -= self.right() * right_delta;
166 self.center += self.up() * up_delta;
167 }
168
169 pub fn pan_pixels(&mut self, delta_pixels: glam::Vec2, viewport_height: f32) {
174 let pan_scale = 2.0 * self.distance * (self.fov_y / 2.0).tan()
175 / 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!((len - 1.0).abs() < 1e-5, "orientation should be normalized, len={len}");
521 }
522
523 #[test]
524 fn test_set_aspect_ratio_normal() {
525 let mut cam = Camera::default();
526 cam.set_aspect_ratio(800.0, 600.0);
527 let expected = 800.0 / 600.0;
528 assert!((cam.aspect - expected).abs() < 1e-5);
529 }
530
531 #[test]
532 fn test_set_aspect_ratio_zero_height() {
533 let mut cam = Camera::default();
534 cam.set_aspect_ratio(800.0, 0.0);
535 assert!((cam.aspect - 1.0).abs() < 1e-5, "zero height should produce aspect=1.0");
536 }
537
538 #[test]
539 fn test_set_fov_y() {
540 let mut cam = Camera::default();
541 cam.set_fov_y(1.2);
542 assert!((cam.fov_y - 1.2).abs() < 1e-6);
543 }
544
545 #[test]
546 fn test_set_clip_planes() {
547 let mut cam = Camera::default();
548 cam.set_clip_planes(0.1, 500.0);
549 assert!((cam.znear - 0.1).abs() < 1e-6);
550 assert!((cam.zfar - 500.0).abs() < 1e-4);
551 }
552
553 #[test]
554 fn test_orbit_matches_manual() {
555 let mut cam = Camera::default();
556 let orig_orientation = cam.orientation;
557 let yaw = 0.1_f32;
558 let pitch = 0.2_f32;
559 let expected = (glam::Quat::from_rotation_y(-yaw)
560 * orig_orientation
561 * glam::Quat::from_rotation_x(-pitch))
562 .normalize();
563 cam.orbit(yaw, pitch);
564 let diff = (cam.orientation - expected).length();
565 assert!(diff < 1e-5, "orbit() result mismatch, diff={diff}");
566 }
567
568 #[test]
569 fn test_pan_world_moves_center() {
570 let mut cam = Camera::default();
571 cam.orientation = glam::Quat::IDENTITY;
572 let right = cam.right();
573 let up = cam.up();
574 let orig_center = cam.center;
575 cam.pan_world(1.0, 0.5);
576 let expected = orig_center - right * 1.0 + up * 0.5;
577 assert!(
578 (cam.center - expected).length() < 1e-5,
579 "pan_world center mismatch"
580 );
581 }
582
583 #[test]
584 fn test_pan_pixels_uses_correct_scale() {
585 let mut cam = Camera::default();
586 cam.orientation = glam::Quat::IDENTITY;
587 cam.distance = 10.0;
588 let viewport_h = 600.0_f32;
589 let pan_scale = 2.0 * cam.distance * (cam.fov_y / 2.0).tan() / viewport_h;
590 let dx = 100.0_f32;
591 let dy = 50.0_f32;
592 let orig_center = cam.center;
593 let right = cam.right();
594 let up = cam.up();
595 cam.pan_pixels(glam::vec2(dx, dy), viewport_h);
596 let expected = orig_center - right * dx * pan_scale + up * dy * pan_scale;
597 assert!(
598 (cam.center - expected).length() < 1e-4,
599 "pan_pixels center mismatch"
600 );
601 }
602
603 #[test]
604 fn test_zoom_by_factor_clamps() {
605 let mut cam = Camera::default();
606 cam.distance = 1.0;
607 cam.zoom_by_factor(0.0);
608 assert!(
609 (cam.distance - Camera::MIN_DISTANCE).abs() < 1e-7,
610 "factor=0 should clamp to MIN_DISTANCE"
611 );
612
613 cam.distance = 1.0;
614 cam.zoom_by_factor(2.0e7);
615 assert!(
616 (cam.distance - Camera::MAX_DISTANCE).abs() < 1.0,
617 "large factor should clamp to MAX_DISTANCE"
618 );
619 }
620
621 #[test]
622 fn test_zoom_by_delta() {
623 let mut cam = Camera::default();
624 cam.distance = 5.0;
625 cam.zoom_by_delta(2.0);
626 assert!((cam.distance - 7.0).abs() < 1e-5);
627 cam.zoom_by_delta(-100.0);
628 assert!(
629 cam.distance >= Camera::MIN_DISTANCE,
630 "delta clamped to MIN_DISTANCE"
631 );
632 }
633
634 #[test]
635 fn test_frame_sphere_applies_result() {
636 let mut cam = Camera::default();
637 let sphere_center = glam::Vec3::new(1.0, 2.0, 3.0);
638 let radius = 5.0;
639 let (expected_c, expected_d) = cam.fit_sphere(sphere_center, radius);
640 cam.frame_sphere(sphere_center, radius);
641 assert!((cam.center - expected_c).length() < 1e-5);
642 assert!((cam.distance - expected_d.clamp(Camera::MIN_DISTANCE, Camera::MAX_DISTANCE)).abs() < 1e-5);
643 }
644
645 #[test]
646 fn test_frame_aabb_applies_result() {
647 let mut cam = Camera::default();
648 let aabb = crate::scene::aabb::Aabb {
649 min: glam::Vec3::splat(-1.0),
650 max: glam::Vec3::splat(1.0),
651 };
652 let (expected_c, expected_d) = cam.fit_aabb(&aabb);
653 cam.frame_aabb(&aabb);
654 assert!((cam.center - expected_c).length() < 1e-5);
655 assert!((cam.distance - expected_d.clamp(Camera::MIN_DISTANCE, Camera::MAX_DISTANCE)).abs() < 1e-5);
656 }
657
658 #[test]
659 fn test_fit_sphere_target() {
660 let cam = Camera::default();
661 let sphere_center = glam::Vec3::new(0.0, 1.0, 0.0);
662 let radius = 2.0;
663 let target = cam.fit_sphere_target(sphere_center, radius);
664 let (expected_c, expected_d) = cam.fit_sphere(sphere_center, radius);
665 assert!((target.center - expected_c).length() < 1e-5);
666 assert!((target.distance - expected_d).abs() < 1e-5);
667 let diff = (target.orientation - cam.orientation).length();
669 assert!(diff < 1e-5, "fit_sphere_target should preserve orientation");
670 }
671
672 #[test]
673 fn test_fit_aabb_target() {
674 let cam = Camera::default();
675 let aabb = crate::scene::aabb::Aabb {
676 min: glam::Vec3::splat(-2.0),
677 max: glam::Vec3::splat(2.0),
678 };
679 let target = cam.fit_aabb_target(&aabb);
680 let (expected_c, expected_d) = cam.fit_aabb(&aabb);
681 assert!((target.center - expected_c).length() < 1e-5);
682 assert!((target.distance - expected_d).abs() < 1e-5);
683 let diff = (target.orientation - cam.orientation).length();
684 assert!(diff < 1e-5, "fit_aabb_target should preserve orientation");
685 }
686}