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    pub fn proj_matrix(&self) -> glam::Mat4 {
256        // Ensure the far plane always extends past the orbit center regardless
257        // of how far the user has zoomed out. zfar is a minimum; if the camera
258        // distance exceeds it the stored value is stale.
259        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    /// Combined view-projection matrix for use in the camera uniform buffer.
280    /// Note: projection * view (column-major order: proj applied after view).
281    pub fn view_proj_matrix(&self) -> glam::Mat4 {
282        self.proj_matrix() * self.view_matrix()
283    }
284
285    /// Camera right vector in world space (used for pan).
286    pub fn right(&self) -> glam::Vec3 {
287        self.orientation * glam::Vec3::X
288    }
289
290    /// Camera up vector in world space (used for pan).
291    pub fn up(&self) -> glam::Vec3 {
292        self.orientation * glam::Vec3::Y
293    }
294
295    /// Extract the view frustum from the current view-projection matrix.
296    pub fn frustum(&self) -> crate::camera::frustum::Frustum {
297        crate::camera::frustum::Frustum::from_view_proj(&self.view_proj_matrix())
298    }
299
300    /// Compute `(center, distance)` that would frame a bounding sphere in view,
301    /// preserving the current orientation.
302    ///
303    /// A 1.2× padding factor is applied so the object doesn't touch the edges.
304    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    /// Compute `(center, distance)` that would frame an AABB in view,
313    /// preserving the current orientation.
314    ///
315    /// Converts the AABB to a bounding sphere and delegates to [`fit_sphere`](Self::fit_sphere).
316    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    /// Center the camera on a domain of the given extents, adjusting distance
323    /// and clipping planes so the entire domain is visible.
324    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        // Center in view space should be near the negative Z axis.
353        let center_view = view.transform_point3(cam.center);
354        // X and Y should be near zero; Z should be negative (in front of camera).
355        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        // NDC center (0,0,0.5) should unproject to somewhere near the center.
378        let world_pt = vp_inv.project_point3(glam::Vec3::new(0.0, 0.0, 0.5));
379        // The unprojected point should lie along the camera-to-center ray.
380        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(); // perspective, fov_y = PI/4
401        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        // Half-diagonal of unit cube = sqrt(0.75) ≈ 0.866
433        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        // Without padding: radius / tan(fov/2)
450        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    // -----------------------------------------------------------------------
469    // Tests for new methods (Task 1)
470    // -----------------------------------------------------------------------
471
472    #[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        // Supply a non-unit quaternion.
513        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        // Orientation should be preserved.
680        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}