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)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14pub struct CameraTarget {
15    /// Orbit target point in world space.
16    pub center: glam::Vec3,
17    /// Distance from center to eye.
18    pub distance: f32,
19    /// Camera orientation as a unit quaternion.
20    pub orientation: glam::Quat,
21}
22
23/// Projection mode for the viewport camera.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
26#[non_exhaustive]
27pub enum Projection {
28    /// Perspective projection : objects farther away appear smaller.
29    Perspective,
30    /// Orthographic projection : no foreshortening, parallel lines stay parallel.
31    Orthographic,
32}
33
34/// Arcball camera for the 3D viewport.
35///
36/// The camera orbits around a center point. `orientation` is a quaternion that
37/// maps camera-space axes to world-space: eye = center + orientation * (Z * distance).
38/// Pan translates the center in camera-space. Zoom adjusts the distance.
39///
40/// Using a quaternion avoids gimbal lock and allows full 360° orbit in any direction.
41/// All matrices are computed right-handed (wgpu NDC convention).
42#[derive(Clone)]
43#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
44pub struct Camera {
45    /// Projection mode (perspective or orthographic).
46    pub projection: Projection,
47    /// Orbit target point in world space.
48    pub center: glam::Vec3,
49    /// Distance from center to eye (zoom).
50    pub distance: f32,
51    /// Camera orientation as a unit quaternion.
52    /// eye = center + orientation * (Vec3::Z * distance).
53    /// Identity = looking along -Z from +Z position (top view in a Z-up world).
54    pub orientation: glam::Quat,
55    /// Vertical field of view in radians.
56    pub fov_y: f32,
57    /// Viewport width / height ratio : updated each frame from viewport rect.
58    pub aspect: f32,
59    /// Near clipping plane distance. `None` (the default) lets the library
60    /// manage depth precision automatically. Set to `Some(value)` only when
61    /// you need exact control over the near plane, e.g. to avoid clipping
62    /// geometry that is very close to the camera.
63    pub znear: Option<f32>,
64    /// Far clipping plane distance.
65    pub zfar: f32,
66}
67
68impl Default for Camera {
69    fn default() -> Self {
70        Self {
71            projection: Projection::Perspective,
72            center: glam::Vec3::ZERO,
73            distance: 5.0,
74            // Default: eye slightly above the x-y plane (Z-up world), ~27° elevation.
75            // from_rotation_x(1.1) ≈ 63° from top = 27° above horizontal.
76            orientation: glam::Quat::from_rotation_z(0.6) * glam::Quat::from_rotation_x(1.1),
77            fov_y: std::f32::consts::FRAC_PI_4,
78            aspect: 1.5,
79            znear: None,
80            zfar: 1000.0,
81        }
82    }
83}
84
85impl Camera {
86    /// Minimum allowed camera distance (zoom limit).
87    pub const MIN_DISTANCE: f32 = 0.01;
88
89    /// Maximum allowed camera distance (zoom limit).
90    pub const MAX_DISTANCE: f32 = 1.0e6;
91
92    // -----------------------------------------------------------------------
93    // Getters and setters
94    // -----------------------------------------------------------------------
95
96    /// Return the orbit target center in world space.
97    ///
98    /// Forward-compatible accessor : equivalent to reading `self.center`.
99    pub fn center(&self) -> glam::Vec3 {
100        self.center
101    }
102
103    /// Set the orbit target center in world space.
104    pub fn set_center(&mut self, center: glam::Vec3) {
105        self.center = center;
106    }
107
108    /// Return the camera distance (zoom).
109    ///
110    /// Forward-compatible accessor : equivalent to reading `self.distance`.
111    pub fn distance(&self) -> f32 {
112        self.distance
113    }
114
115    /// Set the camera distance, clamped to `[MIN_DISTANCE, MAX_DISTANCE]`.
116    pub fn set_distance(&mut self, d: f32) {
117        self.distance = d.clamp(Self::MIN_DISTANCE, Self::MAX_DISTANCE);
118    }
119
120    /// Return the camera orientation quaternion.
121    ///
122    /// Forward-compatible accessor : equivalent to reading `self.orientation`.
123    pub fn orientation(&self) -> glam::Quat {
124        self.orientation
125    }
126
127    /// Set the camera orientation, normalizing the quaternion.
128    pub fn set_orientation(&mut self, q: glam::Quat) {
129        self.orientation = q.normalize();
130    }
131
132    /// Set the vertical field of view in radians.
133    pub fn set_fov_y(&mut self, fov_y: f32) {
134        self.fov_y = fov_y;
135    }
136
137    /// Set the aspect ratio from pixel dimensions.
138    ///
139    /// If `height` is zero or negative, aspect is set to `1.0`.
140    pub fn set_aspect_ratio(&mut self, width: f32, height: f32) {
141        self.aspect = if height > 0.0 { width / height } else { 1.0 };
142    }
143
144    /// Set the near and far clipping plane distances.
145    pub fn set_clip_planes(&mut self, znear: f32, zfar: f32) {
146        self.znear = Some(znear);
147        self.zfar = zfar;
148    }
149
150    // -----------------------------------------------------------------------
151    // Operation methods
152    // -----------------------------------------------------------------------
153
154    /// Orbit the camera by `yaw` and `pitch` radians.
155    ///
156    /// Applies `Quat::from_rotation_z(-yaw) * orientation * Quat::from_rotation_x(-pitch)`.
157    /// The sign convention matches all examples: positive yaw rotates counter-clockwise
158    /// when viewed from above (Z-up), positive pitch tilts up.
159    pub fn orbit(&mut self, yaw: f32, pitch: f32) {
160        self.orientation = (glam::Quat::from_rotation_z(-yaw)
161            * self.orientation
162            * glam::Quat::from_rotation_x(-pitch))
163        .normalize();
164    }
165
166    /// Pan the camera by world-space deltas.
167    ///
168    /// `right_delta` subtracts from center along the camera right vector;
169    /// `up_delta` adds to center along the camera up vector. This sign
170    /// convention matches mouse pan: dragging right moves the scene left.
171    pub fn pan_world(&mut self, right_delta: f32, up_delta: f32) {
172        self.center -= self.right() * right_delta;
173        self.center += self.up() * up_delta;
174    }
175
176    /// Pan the camera by pixel-space deltas.
177    ///
178    /// Computes `pan_scale = 2 * distance * tan(fov_y/2) / viewport_height`
179    /// then delegates to [`pan_world`](Self::pan_world).
180    pub fn pan_pixels(&mut self, delta_pixels: glam::Vec2, viewport_height: f32) {
181        let pan_scale = 2.0 * self.distance * (self.fov_y / 2.0).tan() / viewport_height.max(1.0);
182        self.pan_world(delta_pixels.x * pan_scale, delta_pixels.y * pan_scale);
183    }
184
185    /// Zoom by multiplying the distance by `factor`, clamped to `[MIN_DISTANCE, MAX_DISTANCE]`.
186    pub fn zoom_by_factor(&mut self, factor: f32) {
187        self.distance = (self.distance * factor).clamp(Self::MIN_DISTANCE, Self::MAX_DISTANCE);
188    }
189
190    /// Zoom by adding `delta` to the distance, clamped to `[MIN_DISTANCE, MAX_DISTANCE]`.
191    pub fn zoom_by_delta(&mut self, delta: f32) {
192        self.distance = (self.distance + delta).clamp(Self::MIN_DISTANCE, Self::MAX_DISTANCE);
193    }
194
195    // -----------------------------------------------------------------------
196    // Framing helpers
197    // -----------------------------------------------------------------------
198
199    /// Frame the camera to contain a bounding sphere, applying the result directly.
200    pub fn frame_sphere(&mut self, center: glam::Vec3, radius: f32) {
201        let (c, d) = self.fit_sphere(center, radius);
202        self.center = c;
203        self.set_distance(d);
204    }
205
206    /// Frame the camera to contain an AABB, applying the result directly.
207    pub fn frame_aabb(&mut self, aabb: &crate::scene::aabb::Aabb) {
208        let (c, d) = self.fit_aabb(aabb);
209        self.center = c;
210        self.set_distance(d);
211    }
212
213    /// Compute a [`CameraTarget`] that would frame a bounding sphere,
214    /// preserving the current orientation.
215    pub fn fit_sphere_target(&self, center: glam::Vec3, radius: f32) -> CameraTarget {
216        let (c, d) = self.fit_sphere(center, radius);
217        CameraTarget {
218            center: c,
219            distance: d,
220            orientation: self.orientation,
221        }
222    }
223
224    /// Compute a [`CameraTarget`] that would frame an AABB,
225    /// preserving the current orientation.
226    pub fn fit_aabb_target(&self, aabb: &crate::scene::aabb::Aabb) -> CameraTarget {
227        let (c, d) = self.fit_aabb(aabb);
228        CameraTarget {
229            center: c,
230            distance: d,
231            orientation: self.orientation,
232        }
233    }
234
235    // -----------------------------------------------------------------------
236    // Internal helpers
237    // -----------------------------------------------------------------------
238
239    /// Compute the eye offset from the orbit center in world space.
240    fn eye_offset(&self) -> glam::Vec3 {
241        self.orientation * (glam::Vec3::Z * self.distance)
242    }
243
244    /// Eye (camera) position in world space.
245    pub fn eye_position(&self) -> glam::Vec3 {
246        self.center + self.eye_offset()
247    }
248
249    /// Right-handed view matrix (world -> camera space).
250    pub fn view_matrix(&self) -> glam::Mat4 {
251        let eye = self.eye_position();
252        let up = self.orientation * glam::Vec3::Y;
253        glam::Mat4::look_at_rh(eye, self.center, up)
254    }
255
256    /// Right-handed projection matrix (wgpu depth 0..1).
257    ///
258    /// In orthographic mode the viewing volume is derived from `distance` and
259    /// `fov_y` so that switching between perspective and orthographic at the
260    /// same distance produces a similar framing.
261    /// Effective far-plane distance : `zfar` floored to `distance * 3` so the
262    /// far plane always extends past the orbit centre regardless of zoom level.
263    pub fn effective_zfar(&self) -> f32 {
264        self.zfar.max(self.distance * 3.0)
265    }
266
267    /// Effective near-plane distance.
268    ///
269    /// Keeps the near/far ratio at most 10,000:1 regardless of how far the
270    /// camera has zoomed out. At close range the value is `znear` unchanged.
271    /// At large distances it grows automatically so that f32 depth precision
272    /// does not collapse and cause objects behind opaque surfaces to bleed
273    /// through. Consumers do not need to adjust znear themselves.
274    pub fn effective_znear(&self) -> f32 {
275        self.znear
276            .unwrap_or_else(|| self.effective_zfar() / 10_000.0)
277    }
278
279    /// Returns the projection matrix for the current camera state.
280    pub fn proj_matrix(&self) -> glam::Mat4 {
281        let effective_znear = self.effective_znear();
282        let effective_zfar = self.effective_zfar();
283        match self.projection {
284            Projection::Perspective => {
285                glam::Mat4::perspective_rh(self.fov_y, self.aspect, effective_znear, effective_zfar)
286            }
287            Projection::Orthographic => {
288                let half_h = self.distance * (self.fov_y / 2.0).tan();
289                let half_w = half_h * self.aspect;
290                glam::Mat4::orthographic_rh(
291                    -half_w,
292                    half_w,
293                    -half_h,
294                    half_h,
295                    effective_znear,
296                    effective_zfar,
297                )
298            }
299        }
300    }
301
302    /// Combined view-projection matrix for use in the camera uniform buffer.
303    /// Note: projection * view (column-major order: proj applied after view).
304    pub fn view_proj_matrix(&self) -> glam::Mat4 {
305        self.proj_matrix() * self.view_matrix()
306    }
307
308    /// Camera right vector in world space (used for pan).
309    pub fn right(&self) -> glam::Vec3 {
310        self.orientation * glam::Vec3::X
311    }
312
313    /// Camera up vector in world space (used for pan).
314    pub fn up(&self) -> glam::Vec3 {
315        self.orientation * glam::Vec3::Y
316    }
317
318    /// Extract the view frustum from the current view-projection matrix.
319    pub fn frustum(&self) -> crate::camera::frustum::Frustum {
320        crate::camera::frustum::Frustum::from_view_proj(&self.view_proj_matrix())
321    }
322
323    /// Compute `(center, distance)` that would frame a bounding sphere in view,
324    /// preserving the current orientation.
325    ///
326    /// A 1.2× padding factor is applied so the object doesn't touch the edges.
327    pub fn fit_sphere(&self, center: glam::Vec3, radius: f32) -> (glam::Vec3, f32) {
328        let distance = match self.projection {
329            Projection::Perspective => radius / (self.fov_y / 2.0).tan() * 1.2,
330            Projection::Orthographic => radius * 1.2,
331        };
332        (center, distance)
333    }
334
335    /// Compute `(center, distance)` that would frame an AABB in view,
336    /// preserving the current orientation.
337    ///
338    /// Converts the AABB to a bounding sphere and delegates to [`fit_sphere`](Self::fit_sphere).
339    pub fn fit_aabb(&self, aabb: &crate::scene::aabb::Aabb) -> (glam::Vec3, f32) {
340        let center = aabb.center();
341        let radius = aabb.half_extents().length();
342        self.fit_sphere(center, radius)
343    }
344
345    /// Center the camera on a domain of the given extents, adjusting distance
346    /// and clipping planes so the entire domain is visible.
347    pub fn center_on_domain(&mut self, nx: f32, ny: f32, nz: f32) {
348        self.center = glam::Vec3::new(nx / 2.0, ny / 2.0, nz / 2.0);
349        let diagonal = (nx * nx + ny * ny + nz * nz).sqrt();
350        self.distance = (diagonal / 2.0) / (self.fov_y / 2.0).tan() * 1.2;
351        self.znear = Some((diagonal * 0.0001).max(0.01));
352        self.zfar = (diagonal * 10.0).max(1000.0);
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn test_default_eye_position() {
362        let cam = Camera::default();
363        let expected = cam.center + cam.orientation * (glam::Vec3::Z * cam.distance);
364        let eye = cam.eye_position();
365        assert!(
366            (eye - expected).length() < 1e-5,
367            "eye={eye:?} expected={expected:?}"
368        );
369    }
370
371    #[test]
372    fn test_view_matrix_looks_at_center() {
373        let cam = Camera::default();
374        let view = cam.view_matrix();
375        // Center in view space should be near the negative Z axis.
376        let center_view = view.transform_point3(cam.center);
377        // X and Y should be near zero; Z should be negative (in front of camera).
378        assert!(
379            center_view.x.abs() < 1e-4,
380            "center_view.x={}",
381            center_view.x
382        );
383        assert!(
384            center_view.y.abs() < 1e-4,
385            "center_view.y={}",
386            center_view.y
387        );
388        assert!(
389            center_view.z < 0.0,
390            "center should be in front of camera, z={}",
391            center_view.z
392        );
393    }
394
395    #[test]
396    fn test_view_proj_roundtrip() {
397        let cam = Camera::default();
398        let vp = cam.view_proj_matrix();
399        let vp_inv = vp.inverse();
400        // NDC center (0,0,0.5) should unproject to somewhere near the center.
401        let world_pt = vp_inv.project_point3(glam::Vec3::new(0.0, 0.0, 0.5));
402        // The unprojected point should lie along the camera-to-center ray.
403        let eye = cam.eye_position();
404        let to_center = (cam.center - eye).normalize();
405        let to_pt = (world_pt - eye).normalize();
406        let dot = to_center.dot(to_pt);
407        assert!(
408            dot > 0.99,
409            "dot={dot}, point should be along camera-to-center ray"
410        );
411    }
412
413    #[test]
414    fn test_center_on_domain() {
415        let mut cam = Camera::default();
416        cam.center_on_domain(10.0, 10.0, 10.0);
417        assert!((cam.center - glam::Vec3::splat(5.0)).length() < 1e-5);
418        assert!(cam.distance > 0.0);
419    }
420
421    #[test]
422    fn test_fit_sphere_perspective() {
423        let cam = Camera::default(); // perspective, fov_y = PI/4
424        let (center, dist) = cam.fit_sphere(glam::Vec3::ZERO, 5.0);
425        assert!((center - glam::Vec3::ZERO).length() < 1e-5);
426        let expected = 5.0 / (cam.fov_y / 2.0).tan() * 1.2;
427        assert!(
428            (dist - expected).abs() < 1e-4,
429            "dist={dist}, expected={expected}"
430        );
431    }
432
433    #[test]
434    fn test_fit_sphere_orthographic() {
435        let mut cam = Camera::default();
436        cam.projection = Projection::Orthographic;
437        let (_, dist) = cam.fit_sphere(glam::Vec3::ZERO, 5.0);
438        let expected = 5.0 * 1.2;
439        assert!(
440            (dist - expected).abs() < 1e-4,
441            "dist={dist}, expected={expected}"
442        );
443    }
444
445    #[test]
446    fn test_fit_aabb_unit_cube() {
447        let cam = Camera::default();
448        let aabb = crate::scene::aabb::Aabb {
449            min: glam::Vec3::splat(-0.5),
450            max: glam::Vec3::splat(0.5),
451        };
452        let (center, dist) = cam.fit_aabb(&aabb);
453        assert!(center.length() < 1e-5, "center should be origin");
454        assert!(dist > 0.0, "distance should be positive");
455        // Half-diagonal of unit cube = sqrt(0.75) ≈ 0.866
456        let radius = aabb.half_extents().length();
457        let expected = radius / (cam.fov_y / 2.0).tan() * 1.2;
458        assert!(
459            (dist - expected).abs() < 1e-4,
460            "dist={dist}, expected={expected}"
461        );
462    }
463
464    #[test]
465    fn test_fit_aabb_preserves_padding() {
466        let cam = Camera::default();
467        let aabb = crate::scene::aabb::Aabb {
468            min: glam::Vec3::splat(-2.0),
469            max: glam::Vec3::splat(2.0),
470        };
471        let (_, dist) = cam.fit_aabb(&aabb);
472        // Without padding: radius / tan(fov/2)
473        let radius = aabb.half_extents().length();
474        let no_pad = radius / (cam.fov_y / 2.0).tan();
475        assert!(
476            dist > no_pad,
477            "padded distance ({dist}) should exceed unpadded ({no_pad})"
478        );
479    }
480
481    #[test]
482    fn test_right_up_orthogonal() {
483        let cam = Camera::default();
484        let dot = cam.right().dot(cam.up());
485        assert!(
486            dot.abs() < 1e-5,
487            "right and up should be orthogonal, dot={dot}"
488        );
489    }
490
491    // -----------------------------------------------------------------------
492    // Tests for new methods (Task 1)
493    // -----------------------------------------------------------------------
494
495    #[test]
496    fn test_constants() {
497        assert!((Camera::MIN_DISTANCE - 0.01).abs() < 1e-7);
498        assert!((Camera::MAX_DISTANCE - 1.0e6).abs() < 1.0);
499    }
500
501    #[test]
502    fn test_set_distance_clamps() {
503        let mut cam = Camera::default();
504        cam.set_distance(-1.0);
505        assert!(
506            (cam.distance - Camera::MIN_DISTANCE).abs() < 1e-7,
507            "negative distance should clamp to MIN_DISTANCE"
508        );
509        cam.set_distance(2.0e6);
510        assert!(
511            (cam.distance - Camera::MAX_DISTANCE).abs() < 1.0,
512            "too-large distance should clamp to MAX_DISTANCE"
513        );
514    }
515
516    #[test]
517    fn test_set_center_and_getter() {
518        let mut cam = Camera::default();
519        let target = glam::Vec3::new(1.0, 2.0, 3.0);
520        cam.set_center(target);
521        assert_eq!(cam.center(), target);
522        assert_eq!(cam.center, target);
523    }
524
525    #[test]
526    fn test_set_distance_getter() {
527        let mut cam = Camera::default();
528        cam.set_distance(7.5);
529        assert!((cam.distance() - 7.5).abs() < 1e-6);
530    }
531
532    #[test]
533    fn test_set_orientation_normalizes() {
534        let mut cam = Camera::default();
535        // Supply a non-unit quaternion.
536        let q = glam::Quat::from_xyzw(0.0, 0.707, 0.0, 0.707) * 2.0;
537        cam.set_orientation(q);
538        let len = (cam.orientation.x * cam.orientation.x
539            + cam.orientation.y * cam.orientation.y
540            + cam.orientation.z * cam.orientation.z
541            + cam.orientation.w * cam.orientation.w)
542            .sqrt();
543        assert!(
544            (len - 1.0).abs() < 1e-5,
545            "orientation should be normalized, len={len}"
546        );
547    }
548
549    #[test]
550    fn test_set_aspect_ratio_normal() {
551        let mut cam = Camera::default();
552        cam.set_aspect_ratio(800.0, 600.0);
553        let expected = 800.0 / 600.0;
554        assert!((cam.aspect - expected).abs() < 1e-5);
555    }
556
557    #[test]
558    fn test_set_aspect_ratio_zero_height() {
559        let mut cam = Camera::default();
560        cam.set_aspect_ratio(800.0, 0.0);
561        assert!(
562            (cam.aspect - 1.0).abs() < 1e-5,
563            "zero height should produce aspect=1.0"
564        );
565    }
566
567    #[test]
568    fn test_set_fov_y() {
569        let mut cam = Camera::default();
570        cam.set_fov_y(1.2);
571        assert!((cam.fov_y - 1.2).abs() < 1e-6);
572    }
573
574    #[test]
575    fn test_set_clip_planes() {
576        let mut cam = Camera::default();
577        cam.set_clip_planes(0.1, 500.0);
578        assert!((cam.znear.unwrap() - 0.1).abs() < 1e-6);
579        assert!((cam.zfar - 500.0).abs() < 1e-4);
580    }
581
582    #[test]
583    fn test_orbit_matches_manual() {
584        let mut cam = Camera::default();
585        let orig_orientation = cam.orientation;
586        let yaw = 0.1_f32;
587        let pitch = 0.2_f32;
588        let expected = (glam::Quat::from_rotation_z(-yaw)
589            * orig_orientation
590            * glam::Quat::from_rotation_x(-pitch))
591        .normalize();
592        cam.orbit(yaw, pitch);
593        let diff = (cam.orientation - expected).length();
594        assert!(diff < 1e-5, "orbit() result mismatch, diff={diff}");
595    }
596
597    #[test]
598    fn test_pan_world_moves_center() {
599        let mut cam = Camera::default();
600        cam.orientation = glam::Quat::IDENTITY;
601        let right = cam.right();
602        let up = cam.up();
603        let orig_center = cam.center;
604        cam.pan_world(1.0, 0.5);
605        let expected = orig_center - right * 1.0 + up * 0.5;
606        assert!(
607            (cam.center - expected).length() < 1e-5,
608            "pan_world center mismatch"
609        );
610    }
611
612    #[test]
613    fn test_pan_pixels_uses_correct_scale() {
614        let mut cam = Camera::default();
615        cam.orientation = glam::Quat::IDENTITY;
616        cam.distance = 10.0;
617        let viewport_h = 600.0_f32;
618        let pan_scale = 2.0 * cam.distance * (cam.fov_y / 2.0).tan() / viewport_h;
619        let dx = 100.0_f32;
620        let dy = 50.0_f32;
621        let orig_center = cam.center;
622        let right = cam.right();
623        let up = cam.up();
624        cam.pan_pixels(glam::vec2(dx, dy), viewport_h);
625        let expected = orig_center - right * dx * pan_scale + up * dy * pan_scale;
626        assert!(
627            (cam.center - expected).length() < 1e-4,
628            "pan_pixels center mismatch"
629        );
630    }
631
632    #[test]
633    fn test_zoom_by_factor_clamps() {
634        let mut cam = Camera::default();
635        cam.distance = 1.0;
636        cam.zoom_by_factor(0.0);
637        assert!(
638            (cam.distance - Camera::MIN_DISTANCE).abs() < 1e-7,
639            "factor=0 should clamp to MIN_DISTANCE"
640        );
641
642        cam.distance = 1.0;
643        cam.zoom_by_factor(2.0e7);
644        assert!(
645            (cam.distance - Camera::MAX_DISTANCE).abs() < 1.0,
646            "large factor should clamp to MAX_DISTANCE"
647        );
648    }
649
650    #[test]
651    fn test_zoom_by_delta() {
652        let mut cam = Camera::default();
653        cam.distance = 5.0;
654        cam.zoom_by_delta(2.0);
655        assert!((cam.distance - 7.0).abs() < 1e-5);
656        cam.zoom_by_delta(-100.0);
657        assert!(
658            cam.distance >= Camera::MIN_DISTANCE,
659            "delta clamped to MIN_DISTANCE"
660        );
661    }
662
663    #[test]
664    fn test_frame_sphere_applies_result() {
665        let mut cam = Camera::default();
666        let sphere_center = glam::Vec3::new(1.0, 2.0, 3.0);
667        let radius = 5.0;
668        let (expected_c, expected_d) = cam.fit_sphere(sphere_center, radius);
669        cam.frame_sphere(sphere_center, radius);
670        assert!((cam.center - expected_c).length() < 1e-5);
671        assert!(
672            (cam.distance - expected_d.clamp(Camera::MIN_DISTANCE, Camera::MAX_DISTANCE)).abs()
673                < 1e-5
674        );
675    }
676
677    #[test]
678    fn test_frame_aabb_applies_result() {
679        let mut cam = Camera::default();
680        let aabb = crate::scene::aabb::Aabb {
681            min: glam::Vec3::splat(-1.0),
682            max: glam::Vec3::splat(1.0),
683        };
684        let (expected_c, expected_d) = cam.fit_aabb(&aabb);
685        cam.frame_aabb(&aabb);
686        assert!((cam.center - expected_c).length() < 1e-5);
687        assert!(
688            (cam.distance - expected_d.clamp(Camera::MIN_DISTANCE, Camera::MAX_DISTANCE)).abs()
689                < 1e-5
690        );
691    }
692
693    #[test]
694    fn test_fit_sphere_target() {
695        let cam = Camera::default();
696        let sphere_center = glam::Vec3::new(0.0, 1.0, 0.0);
697        let radius = 2.0;
698        let target = cam.fit_sphere_target(sphere_center, radius);
699        let (expected_c, expected_d) = cam.fit_sphere(sphere_center, radius);
700        assert!((target.center - expected_c).length() < 1e-5);
701        assert!((target.distance - expected_d).abs() < 1e-5);
702        // Orientation should be preserved.
703        let diff = (target.orientation - cam.orientation).length();
704        assert!(diff < 1e-5, "fit_sphere_target should preserve orientation");
705    }
706
707    #[test]
708    fn test_fit_aabb_target() {
709        let cam = Camera::default();
710        let aabb = crate::scene::aabb::Aabb {
711            min: glam::Vec3::splat(-2.0),
712            max: glam::Vec3::splat(2.0),
713        };
714        let target = cam.fit_aabb_target(&aabb);
715        let (expected_c, expected_d) = cam.fit_aabb(&aabb);
716        assert!((target.center - expected_c).length() < 1e-5);
717        assert!((target.distance - expected_d).abs() < 1e-5);
718        let diff = (target.orientation - cam.orientation).length();
719        assert!(diff < 1e-5, "fit_aabb_target should preserve orientation");
720    }
721}