Skip to main content

viewport_lib/camera/
camera.rs

1/// A snapshot of camera position state (center, distance, orientation).
2///
3/// Returned by framing helpers such as [`Camera::fit_sphere_target`] and
4/// [`Camera::fit_aabb_target`]. Useful for presets and animation targets: pass
5/// `target.center` / `target.distance` / `target.orientation` to
6/// [`CameraAnimator::fly_to`](crate::camera::animator::CameraAnimator::fly_to).
7///
8/// The getters/setters on [`Camera`] (`center()`, `distance()`,
9/// `orientation()`) are forward-compatible accessors intended for eventual
10/// field privatization (Phase 4). Field access (`camera.center` etc.) still
11/// works and is not deprecated.
12#[derive(Clone, Copy, Debug)]
13pub struct CameraTarget {
14    /// Orbit target point in world space.
15    pub center: glam::Vec3,
16    /// Distance from center to eye.
17    pub distance: f32,
18    /// Camera orientation as a unit quaternion.
19    pub orientation: glam::Quat,
20}
21
22/// Projection mode for the viewport camera.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24#[non_exhaustive]
25pub enum Projection {
26    /// Perspective projection : objects farther away appear smaller.
27    Perspective,
28    /// Orthographic projection : no foreshortening, parallel lines stay parallel.
29    Orthographic,
30}
31
32/// Arcball camera for the 3D viewport.
33///
34/// The camera orbits around a center point. `orientation` is a quaternion that
35/// maps camera-space axes to world-space: eye = center + orientation * (Z * distance).
36/// Pan translates the center in camera-space. Zoom adjusts the distance.
37///
38/// Using a quaternion avoids gimbal lock and allows full 360° orbit in any direction.
39/// All matrices are computed right-handed (wgpu NDC convention).
40#[derive(Clone)]
41pub struct Camera {
42    /// Projection mode (perspective or orthographic).
43    pub projection: Projection,
44    /// Orbit target point in world space.
45    pub center: glam::Vec3,
46    /// Distance from center to eye (zoom).
47    pub distance: f32,
48    /// Camera orientation as a unit quaternion.
49    /// eye = center + orientation * (Vec3::Z * distance).
50    /// Identity = looking along -Z from +Z position (top view in a Z-up world).
51    pub orientation: glam::Quat,
52    /// Vertical field of view in radians.
53    pub fov_y: f32,
54    /// Viewport width / height ratio : updated each frame from viewport rect.
55    pub aspect: f32,
56    /// Near clipping plane distance.
57    pub znear: f32,
58    /// Far clipping plane distance.
59    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            // Default: eye slightly above the x-y plane (Z-up world), ~27° elevation.
69            // from_rotation_x(1.1) ≈ 63° from top = 27° above horizontal.
70            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    /// Minimum allowed camera distance (zoom limit).
81    pub const MIN_DISTANCE: f32 = 0.01;
82
83    /// Maximum allowed camera distance (zoom limit).
84    pub const MAX_DISTANCE: f32 = 1.0e6;
85
86    // -----------------------------------------------------------------------
87    // Getters and setters
88    // -----------------------------------------------------------------------
89
90    /// Return the orbit target center in world space.
91    ///
92    /// Forward-compatible accessor : equivalent to reading `self.center`.
93    pub fn center(&self) -> glam::Vec3 {
94        self.center
95    }
96
97    /// Set the orbit target center in world space.
98    pub fn set_center(&mut self, center: glam::Vec3) {
99        self.center = center;
100    }
101
102    /// Return the camera distance (zoom).
103    ///
104    /// Forward-compatible accessor : equivalent to reading `self.distance`.
105    pub fn distance(&self) -> f32 {
106        self.distance
107    }
108
109    /// Set the camera distance, clamped to `[MIN_DISTANCE, MAX_DISTANCE]`.
110    pub fn set_distance(&mut self, d: f32) {
111        self.distance = d.clamp(Self::MIN_DISTANCE, Self::MAX_DISTANCE);
112    }
113
114    /// Return the camera orientation quaternion.
115    ///
116    /// Forward-compatible accessor : equivalent to reading `self.orientation`.
117    pub fn orientation(&self) -> glam::Quat {
118        self.orientation
119    }
120
121    /// Set the camera orientation, normalizing the quaternion.
122    pub fn set_orientation(&mut self, q: glam::Quat) {
123        self.orientation = q.normalize();
124    }
125
126    /// Set the vertical field of view in radians.
127    pub fn set_fov_y(&mut self, fov_y: f32) {
128        self.fov_y = fov_y;
129    }
130
131    /// Set the aspect ratio from pixel dimensions.
132    ///
133    /// If `height` is zero or negative, aspect is set to `1.0`.
134    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    /// Set the near and far clipping plane distances.
139    pub fn set_clip_planes(&mut self, znear: f32, zfar: f32) {
140        self.znear = znear;
141        self.zfar = zfar;
142    }
143
144    // -----------------------------------------------------------------------
145    // Operation methods
146    // -----------------------------------------------------------------------
147
148    /// Orbit the camera by `yaw` and `pitch` radians.
149    ///
150    /// Applies `Quat::from_rotation_z(-yaw) * orientation * Quat::from_rotation_x(-pitch)`.
151    /// The sign convention matches all examples: positive yaw rotates counter-clockwise
152    /// when viewed from above (Z-up), positive pitch tilts up.
153    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    /// Pan the camera by world-space deltas.
161    ///
162    /// `right_delta` subtracts from center along the camera right vector;
163    /// `up_delta` adds to center along the camera up vector. This sign
164    /// convention matches mouse pan: dragging right moves the scene left.
165    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    /// Pan the camera by pixel-space deltas.
171    ///
172    /// Computes `pan_scale = 2 * distance * tan(fov_y/2) / viewport_height`
173    /// then delegates to [`pan_world`](Self::pan_world).
174    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    /// Zoom by multiplying the distance by `factor`, clamped to `[MIN_DISTANCE, MAX_DISTANCE]`.
180    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    /// Zoom by adding `delta` to the distance, clamped to `[MIN_DISTANCE, MAX_DISTANCE]`.
185    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    // -----------------------------------------------------------------------
190    // Framing helpers
191    // -----------------------------------------------------------------------
192
193    /// Frame the camera to contain a bounding sphere, applying the result directly.
194    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    /// Frame the camera to contain an AABB, applying the result directly.
201    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    /// Compute a [`CameraTarget`] that would frame a bounding sphere,
208    /// preserving the current orientation.
209    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    /// Compute a [`CameraTarget`] that would frame an AABB,
219    /// preserving the current orientation.
220    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    // -----------------------------------------------------------------------
230    // Internal helpers
231    // -----------------------------------------------------------------------
232
233    /// Compute the eye offset from the orbit center in world space.
234    fn eye_offset(&self) -> glam::Vec3 {
235        self.orientation * (glam::Vec3::Z * self.distance)
236    }
237
238    /// Eye (camera) position in world space.
239    pub fn eye_position(&self) -> glam::Vec3 {
240        self.center + self.eye_offset()
241    }
242
243    /// Right-handed view matrix (world -> camera space).
244    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    /// Right-handed projection matrix (wgpu depth 0..1).
251    ///
252    /// In orthographic mode the viewing volume is derived from `distance` and
253    /// `fov_y` so that switching between perspective and orthographic at the
254    /// same distance produces a similar framing.
255    /// Effective far-plane distance : `zfar` floored to `distance * 3` so the
256    /// far plane always extends past the orbit centre regardless of zoom level.
257    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        // Ensure the far plane always extends past the orbit center regardless
263        // of how far the user has zoomed out. zfar is a minimum; if the camera
264        // distance exceeds it the stored value is stale.
265        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    /// Combined view-projection matrix for use in the camera uniform buffer.
286    /// Note: projection * view (column-major order: proj applied after view).
287    pub fn view_proj_matrix(&self) -> glam::Mat4 {
288        self.proj_matrix() * self.view_matrix()
289    }
290
291    /// Camera right vector in world space (used for pan).
292    pub fn right(&self) -> glam::Vec3 {
293        self.orientation * glam::Vec3::X
294    }
295
296    /// Camera up vector in world space (used for pan).
297    pub fn up(&self) -> glam::Vec3 {
298        self.orientation * glam::Vec3::Y
299    }
300
301    /// Extract the view frustum from the current view-projection matrix.
302    pub fn frustum(&self) -> crate::camera::frustum::Frustum {
303        crate::camera::frustum::Frustum::from_view_proj(&self.view_proj_matrix())
304    }
305
306    /// Compute `(center, distance)` that would frame a bounding sphere in view,
307    /// preserving the current orientation.
308    ///
309    /// A 1.2× padding factor is applied so the object doesn't touch the edges.
310    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    /// Compute `(center, distance)` that would frame an AABB in view,
319    /// preserving the current orientation.
320    ///
321    /// Converts the AABB to a bounding sphere and delegates to [`fit_sphere`](Self::fit_sphere).
322    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    /// Center the camera on a domain of the given extents, adjusting distance
329    /// and clipping planes so the entire domain is visible.
330    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        // Center in view space should be near the negative Z axis.
359        let center_view = view.transform_point3(cam.center);
360        // X and Y should be near zero; Z should be negative (in front of camera).
361        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        // NDC center (0,0,0.5) should unproject to somewhere near the center.
384        let world_pt = vp_inv.project_point3(glam::Vec3::new(0.0, 0.0, 0.5));
385        // The unprojected point should lie along the camera-to-center ray.
386        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(); // perspective, fov_y = PI/4
407        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        // Half-diagonal of unit cube = sqrt(0.75) ≈ 0.866
439        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        // Without padding: radius / tan(fov/2)
456        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    // -----------------------------------------------------------------------
475    // Tests for new methods (Task 1)
476    // -----------------------------------------------------------------------
477
478    #[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        // Supply a non-unit quaternion.
519        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        // Orientation should be preserved.
686        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}