Skip to main content

damascene_core/scene/
camera.rs

1//! Orbit camera: absolute pose, framing policy, resolved view/projection,
2//! and the 3D→screen projection core uses to place axis/data labels.
3//!
4//! Two types, split along the controlled-widget seam:
5//!
6//! - [`CameraState`] is an *absolute, persistent orbit pose* — world-space
7//!   `target` / `distance` / `yaw` / `pitch`, like the volumetric CAD
8//!   project's camera. It is not re-derived from content each frame;
9//!   gestures and programmatic moves mutate it, and (once keyed in
10//!   `UiState`) it persists across frames. Whether it auto-frames the data
11//!   is the separate, configurable [`Framing`] policy.
12//! - [`ResolvedCamera`] is the *resolved result* — concrete eye / target
13//!   / up / fov / near / far — produced by [`CameraState::resolve`] from
14//!   the pose plus the scene's full view bounds (for near/far). It carries
15//!   the glam matrices the backend uploads and the projection core uses for
16//!   labels, so the camera math has one home.
17
18// Lock in full per-item documentation for this module (issue #73).
19#![warn(missing_docs)]
20
21use glam::{Mat4, Vec2, Vec3};
22
23use crate::scene::bounds::Aabb;
24use crate::tree::Rect;
25
26/// Default vertical field of view (radians). Framing fits the data
27/// bounds to this fov.
28pub const DEFAULT_FOV_Y_RADIANS: f32 = std::f32::consts::FRAC_PI_4; // 45°
29
30/// Pitch is clamped just shy of the poles so the up vector never
31/// degenerates and orbit stays stable.
32const MAX_PITCH: f32 = std::f32::consts::FRAC_PI_2 - 0.087; // ~5° shy of the pole (~85°)
33/// Absolute eye-distance clamps. Wide range — small graphs sit near the
34/// bottom, but the camera is a general 3D navigator.
35const MIN_DISTANCE: f32 = 1.0e-3;
36const MAX_DISTANCE: f32 = 1.0e6;
37
38/// How the camera relates to the scene's data bounds. Decouples "where the
39/// camera is" (the absolute [`CameraState`] pose) from "should it track the
40/// data" (this policy).
41#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
42pub enum Framing {
43    /// Fit the content once, then navigate freely; re-centre on the data
44    /// when its bounds change (smoothly, once the keyed camera animates).
45    /// The default — "show me the data, then let me look around".
46    #[default]
47    Auto,
48    /// Re-fit the content every frame. For static viewers that should
49    /// always frame the data regardless of navigation.
50    Fit,
51    /// Never auto-fit; the app owns the absolute pose. For app-driven
52    /// cameras and fixed viewpoints.
53    Manual,
54}
55
56/// Pointer navigation scheme for a scene camera, matching the conventions
57/// of popular 3D apps. The app picks one on the spec; there is
58/// deliberately no built-in scheme-picker widget. The wheel always zooms,
59/// regardless of scheme.
60#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
61pub enum CameraControls {
62    /// Widget default: left-drag orbits, Shift+left or right-drag pans,
63    /// wheel zooms. Left-drag is free to use here — a chart/widget has no
64    /// selection to preserve, unlike a 3D editor.
65    #[default]
66    Orbit,
67    /// Blender / Fusion 360: middle-drag orbits, Shift+middle-drag pans.
68    Blender,
69    /// OnShape: right-drag orbits, middle-drag pans.
70    OnShape,
71    /// Maya: Alt+left orbits, Alt+middle pans, Alt+right dollies (zoom).
72    Maya,
73}
74
75/// A declarative camera focus request, set on the scene spec. Whenever it
76/// *changes*, the keyed camera animates (springs) to it — so an app can
77/// "look here" smoothly by swapping the value in its build. Orbit angles
78/// are preserved; only the look-at point and distance move.
79#[derive(Clone, Copy, Debug, PartialEq)]
80pub enum Focus {
81    /// Frame these world-space bounds (centre + fit distance).
82    Bounds(Aabb),
83    /// Look at a world point from an explicit distance.
84    Point {
85        /// World-space look-at point.
86        target: Vec3,
87        /// Eye distance from `target`, world units.
88        distance: f32,
89    },
90}
91
92/// Absolute, persistent orbit-camera pose for one scene. World-space —
93/// not re-derived from content each frame (see [`Framing`]). Defaults to a
94/// pleasant three-quarter view of a unit sphere at the origin; [`fitted`]
95/// re-frames it to data, gestures and programmatic moves mutate it.
96///
97/// [`fitted`]: CameraState::fitted
98#[derive(Clone, Copy, Debug, PartialEq)]
99pub struct CameraState {
100    /// Look-at point in world space.
101    pub target: Vec3,
102    /// Eye distance from `target`. Multiplicative [`zoom_by`](Self::zoom_by)
103    /// keeps the perceived zoom rate constant at any scale.
104    pub distance: f32,
105    /// Azimuth around +Y, radians.
106    pub yaw: f32,
107    /// Elevation, radians; clamped to ±~85° by [`orbit`](Self::orbit).
108    pub pitch: f32,
109}
110
111impl Default for CameraState {
112    fn default() -> Self {
113        Self {
114            target: Vec3::ZERO,
115            // Frames a unit-radius sphere at the default fov.
116            distance: 1.0 / (DEFAULT_FOV_Y_RADIANS * 0.5).sin(),
117            yaw: std::f32::consts::FRAC_PI_4,   // 45°
118            pitch: std::f32::consts::FRAC_PI_6, // 30°
119        }
120    }
121}
122
123impl CameraState {
124    /// Orbit by angular deltas (radians). Pitch clamps near the poles so
125    /// the up vector never degenerates.
126    pub fn orbit(&mut self, d_yaw: f32, d_pitch: f32) {
127        self.yaw += d_yaw;
128        self.pitch = (self.pitch + d_pitch).clamp(-MAX_PITCH, MAX_PITCH);
129    }
130
131    /// Multiply the eye distance by `factor`, clamped to a sane range.
132    /// `factor > 1` pulls the camera back. Multiplicative so a scroll notch
133    /// covers proportional distance whether near or far.
134    pub fn zoom_by(&mut self, factor: f32) {
135        if factor.is_finite() && factor > 0.0 {
136            self.distance = (self.distance * factor).clamp(MIN_DISTANCE, MAX_DISTANCE);
137        }
138    }
139
140    /// Translate the look-at point by a world-space delta (pan).
141    pub fn pan_by(&mut self, delta: Vec3) {
142        self.target += delta;
143    }
144
145    /// Distance at which a sphere of `radius` exactly fills the vertical fov.
146    pub fn fit_distance(radius: f32) -> f32 {
147        (radius.max(1e-4) / (DEFAULT_FOV_Y_RADIANS * 0.5).sin()).clamp(MIN_DISTANCE, MAX_DISTANCE)
148    }
149
150    /// A copy framed on `content`: `target` at the centre and `distance`
151    /// fit to the bounds, **preserving the current orbit angles**. Empty
152    /// bounds leave a unit sphere at the origin. This is the framing
153    /// operation `Framing::Fit` / `Auto` apply.
154    pub fn fitted(&self, content: Aabb) -> CameraState {
155        let (center, radius) = sphere_of(content);
156        CameraState {
157            target: center,
158            distance: Self::fit_distance(radius),
159            yaw: self.yaw,
160            pitch: self.pitch,
161        }
162    }
163
164    /// Default angles, framed on `content`. The auto-framed starting pose.
165    pub fn framing(content: Aabb) -> CameraState {
166        CameraState::default().fitted(content)
167    }
168
169    /// Point the camera at `target` from `distance`, keeping orbit angles.
170    pub fn look_at(&mut self, target: Vec3, distance: f32) {
171        self.target = target;
172        self.distance = distance.clamp(MIN_DISTANCE, MAX_DISTANCE);
173    }
174
175    /// A copy satisfying a [`Focus`] request, preserving orbit angles. The
176    /// keyed camera springs toward this when the request changes.
177    pub fn focused(&self, focus: Focus) -> CameraState {
178        match focus {
179            Focus::Bounds(b) => self.fitted(b),
180            Focus::Point { target, distance } => {
181                let mut c = *self;
182                c.look_at(target, distance);
183                c
184            }
185        }
186    }
187
188    /// World-space eye position implied by the pose.
189    pub fn eye(&self) -> Vec3 {
190        let (sy, cy) = self.yaw.sin_cos();
191        let (sp, cp) = self.pitch.sin_cos();
192        // Unit direction from target toward the eye.
193        let dir = Vec3::new(cp * sy, sp, cp * cy);
194        self.target + dir * self.distance
195    }
196
197    /// Resolve to a concrete camera. `view_bounds` is everything that
198    /// should stay inside the frustum (content **and** the reference grid /
199    /// axes) — near/far are sized from the eye's distance to that, *not*
200    /// from the content radius, so geometry larger than the data (a big
201    /// grid) is never plane-clipped. The pose is taken as-is; framing
202    /// (fitting to data) is applied by the caller before resolving.
203    pub fn resolve(&self, view_bounds: Aabb) -> ResolvedCamera {
204        let fov_y = DEFAULT_FOV_Y_RADIANS;
205        let eye = self.eye();
206        let (vc, vr) = if view_bounds.is_valid() {
207            let (c, r) = sphere_of(view_bounds);
208            (c, r)
209        } else {
210            // No geometry: a sphere around the target sized to the distance.
211            (self.target, self.distance.max(1e-4))
212        };
213        // Eye-to-view-sphere distance bounds the depth range. Near floors
214        // to a small fraction of the eye distance (so geometry right in
215        // front of the camera isn't clipped and depth precision scales),
216        // never to the content radius.
217        let d = (eye - vc).length();
218        let near = (d - vr).max(self.distance * 0.02).max(1e-3);
219        let far = (d + vr).max(near * 8.0);
220
221        ResolvedCamera {
222            eye,
223            target: self.target,
224            up: Vec3::Y,
225            projection: Projection::Perspective { fov_y },
226            near,
227            far,
228        }
229    }
230}
231
232/// Bounding sphere `(center, radius)` of an Aabb. Invalid/empty bounds
233/// yield a unit sphere at the origin so an empty scene still resolves.
234fn sphere_of(bounds: Aabb) -> (Vec3, f32) {
235    if bounds.is_valid() {
236        let r = bounds.bounding_radius();
237        (bounds.center(), if r > 1e-4 { r } else { 1.0 })
238    } else {
239        (Vec3::ZERO, 1.0)
240    }
241}
242
243/// How a [`ResolvedCamera`] projects. Perspective is the 3D-scene default;
244/// orthographic drives 2D plots (and the 2D-lock camera) — it maps a fixed
245/// scale-space window to the rect with no foreshortening, so gridlines stay
246/// aligned with the data.
247#[derive(Clone, Copy, Debug, PartialEq)]
248pub enum Projection {
249    /// Perspective projection with vertical field of view `fov_y` (radians),
250    /// fit to the viewport aspect.
251    Perspective {
252        /// Vertical field of view, radians.
253        fov_y: f32,
254    },
255    /// Orthographic projection of the window centred on the camera's
256    /// `target`, with the given half-extents in world (scale-space) units.
257    /// **Aspect is ignored** — a 2D plot scales its axes independently, so
258    /// the half-extents alone map the window to the full rect.
259    Orthographic {
260        /// Half the window width, world units.
261        half_w: f32,
262        /// Half the window height, world units.
263        half_h: f32,
264    },
265}
266
267/// A resolved camera: concrete framing plus the matrices and projection
268/// the backend and label layer need. Stored in `Scene3DData`.
269#[derive(Clone, Copy, Debug, PartialEq)]
270pub struct ResolvedCamera {
271    /// World-space eye position.
272    pub eye: Vec3,
273    /// World-space look-at point.
274    pub target: Vec3,
275    /// Up vector — always `Vec3::Y` from [`CameraState::resolve`]
276    /// (pitch clamping keeps it from degenerating).
277    pub up: Vec3,
278    /// How the camera projects (perspective for 3D, orthographic for 2D).
279    pub projection: Projection,
280    /// Near clip-plane distance from the eye, world units.
281    pub near: f32,
282    /// Far clip-plane distance from the eye, world units.
283    pub far: f32,
284}
285
286impl ResolvedCamera {
287    /// An orthographic camera that maps the scale-space window centred at
288    /// `center` with half-extents `(half_w, half_h)` to the full rect —
289    /// the 2D-plot data-layer camera. Looks straight down `-Z` at the
290    /// `z = 0` plane the 2D geometry lives on; aspect is ignored (the
291    /// half-extents already encode the window's shape).
292    pub fn orthographic(center: Vec2, half_w: f32, half_h: f32) -> ResolvedCamera {
293        ResolvedCamera {
294            eye: Vec3::new(center.x, center.y, 1.0),
295            target: Vec3::new(center.x, center.y, 0.0),
296            up: Vec3::Y,
297            projection: Projection::Orthographic {
298                half_w: half_w.max(1e-6),
299                half_h: half_h.max(1e-6),
300            },
301            near: 0.0,
302            far: 2.0,
303        }
304    }
305
306    /// Right-handed look-at view matrix.
307    pub fn view(&self) -> Mat4 {
308        glam::camera::rh::view::look_at_mat4(self.eye, self.target, self.up)
309    }
310
311    /// Projection matrix for `aspect` (width/height), 0..1 depth range
312    /// (wgpu convention). Perspective uses `aspect`; orthographic ignores it.
313    pub fn proj(&self, aspect: f32) -> Mat4 {
314        match self.projection {
315            Projection::Perspective { fov_y } => glam::camera::rh::proj::directx::perspective(
316                fov_y,
317                aspect.max(1e-4),
318                self.near,
319                self.far,
320            ),
321            Projection::Orthographic { half_w, half_h } => {
322                glam::camera::rh::proj::directx::orthographic(
323                    -half_w, half_w, -half_h, half_h, self.near, self.far,
324                )
325            }
326        }
327    }
328
329    /// `proj(aspect) * view()` — the matrix backends upload.
330    pub fn view_proj(&self, aspect: f32) -> Mat4 {
331        self.proj(aspect) * self.view()
332    }
333
334    /// Project a world point to screen-space (logical px) within
335    /// `viewport`. Returns `None` for points at or behind the camera
336    /// plane (`w <= 0`), so label callers cull them rather than drawing a
337    /// mirrored ghost. Points in front but outside the rect still return
338    /// `Some` — clipping to the rect is the caller's choice.
339    pub fn project_to_screen(&self, world: Vec3, viewport: Rect) -> Option<Vec2> {
340        self.project_to_screen_with_depth(world, viewport)
341            .map(|(p, _)| p)
342    }
343
344    /// Like [`project_to_screen`](Self::project_to_screen) but also returns
345    /// the point's normalised device depth in `[0, 1]` (wgpu convention:
346    /// `0` near, `1` far) — the same space a `Depth32Float` buffer stores,
347    /// so callers can depth-test a projected anchor against a captured
348    /// scene depth map. `None` when the point is at/behind the camera.
349    pub fn project_to_screen_with_depth(&self, world: Vec3, viewport: Rect) -> Option<(Vec2, f32)> {
350        let aspect = viewport.w / viewport.h.max(1e-4);
351        let clip = self.view_proj(aspect) * world.extend(1.0);
352        if clip.w <= 0.0 || !clip.w.is_finite() {
353            return None;
354        }
355        let ndc = clip.truncate() / clip.w; // x, y in [-1, 1]; z in [0, 1]
356        if !ndc.is_finite() {
357            // Non-finite world positions (a NaN sample in a plot series)
358            // must not leak NaN coordinates to callers — SVG output and
359            // label anchors both choke on them.
360            return None;
361        }
362        let sx = viewport.x + (ndc.x * 0.5 + 0.5) * viewport.w;
363        let sy = viewport.y + (1.0 - (ndc.y * 0.5 + 0.5)) * viewport.h; // flip Y for screen
364        Some((Vec2::new(sx, sy), ndc.z))
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    fn unit_box() -> Aabb {
373        Aabb::from_points([Vec3::splat(-1.0), Vec3::splat(1.0)])
374    }
375
376    #[test]
377    fn fitted_frames_bounds() {
378        let cam = CameraState::framing(unit_box());
379        // Target is the box centre.
380        assert!((cam.target - Vec3::ZERO).length() < 1e-5);
381        // Eye sits the fit distance away, outside the bounding radius.
382        assert!(cam.distance > unit_box().bounding_radius());
383        let r = cam.resolve(unit_box());
384        assert!(r.near > 0.0 && r.far > r.near);
385        // fitted preserves orbit angles.
386        let mut tilted = CameraState::default();
387        tilted.orbit(0.3, -0.2);
388        let f = tilted.fitted(unit_box());
389        assert_eq!((f.yaw, f.pitch), (tilted.yaw, tilted.pitch));
390    }
391
392    #[test]
393    fn target_projects_near_viewport_centre() {
394        let cam = CameraState::framing(unit_box()).resolve(unit_box());
395        let vp = Rect::new(0.0, 0.0, 200.0, 100.0);
396        let p = cam
397            .project_to_screen(cam.target, vp)
398            .expect("target in front");
399        assert!((p.x - 100.0).abs() < 0.5, "x={}", p.x);
400        assert!((p.y - 50.0).abs() < 0.5, "y={}", p.y);
401    }
402
403    #[test]
404    fn orthographic_maps_window_to_rect() {
405        // A window centred at (10, 20) spanning ±5 in x, ±2.5 in y.
406        let cam = ResolvedCamera::orthographic(Vec2::new(10.0, 20.0), 5.0, 2.5);
407        let vp = Rect::new(0.0, 0.0, 200.0, 100.0);
408        // Centre → rect centre.
409        let c = cam
410            .project_to_screen(Vec3::new(10.0, 20.0, 0.0), vp)
411            .expect("center in front");
412        assert!((c.x - 100.0).abs() < 1e-3, "x={}", c.x);
413        assert!((c.y - 50.0).abs() < 1e-3, "y={}", c.y);
414        // Max corner (x right, y up) → top-right pixel (screen Y is flipped).
415        let tr = cam
416            .project_to_screen(Vec3::new(15.0, 22.5, 0.0), vp)
417            .expect("corner in front");
418        assert!((tr.x - 200.0).abs() < 1e-3, "x={}", tr.x);
419        assert!((tr.y - 0.0).abs() < 1e-3, "y={}", tr.y);
420        // Min corner → bottom-left.
421        let bl = cam
422            .project_to_screen(Vec3::new(5.0, 17.5, 0.0), vp)
423            .expect("corner in front");
424        assert!((bl.x - 0.0).abs() < 1e-3, "x={}", bl.x);
425        assert!((bl.y - 100.0).abs() < 1e-3, "y={}", bl.y);
426    }
427
428    #[test]
429    fn orthographic_ignores_aspect() {
430        // Same window projected through two very different aspect ratios
431        // must land at the same place — ortho ignores aspect by design.
432        let cam = ResolvedCamera::orthographic(Vec2::ZERO, 1.0, 1.0);
433        let a = cam.project_to_screen(Vec3::new(1.0, 0.0, 0.0), Rect::new(0.0, 0.0, 200.0, 100.0));
434        let b = cam.project_to_screen(Vec3::new(1.0, 0.0, 0.0), Rect::new(0.0, 0.0, 50.0, 400.0));
435        // Right edge maps to the right edge of each rect regardless of shape.
436        assert!((a.unwrap().x - 200.0).abs() < 1e-3);
437        assert!((b.unwrap().x - 50.0).abs() < 1e-3);
438    }
439
440    #[test]
441    fn point_behind_camera_is_culled() {
442        let cam = CameraState::framing(unit_box()).resolve(unit_box());
443        // Mirror the target across the eye → strictly behind the camera.
444        let behind = cam.eye + (cam.eye - cam.target);
445        assert!(
446            cam.project_to_screen(behind, Rect::new(0.0, 0.0, 200.0, 100.0))
447                .is_none()
448        );
449    }
450
451    #[test]
452    fn orbit_and_zoom_move_the_eye() {
453        let base = CameraState::framing(unit_box());
454        let base_eye = base.eye();
455        let mut s = base;
456        s.orbit(0.5, 0.0);
457        assert!((s.eye() - base_eye).length() > 1e-3, "orbit moved eye");
458
459        // Multiplicative zoom doubles the absolute distance.
460        let mut z = base;
461        z.zoom_by(2.0);
462        assert!((z.distance - 2.0 * base.distance).abs() < 1e-3);
463    }
464
465    #[test]
466    fn pitch_clamps_near_pole() {
467        let mut s = CameraState::default();
468        s.orbit(0.0, 100.0); // absurd up-tilt
469        assert!(s.pitch <= MAX_PITCH + 1e-6);
470    }
471
472    #[test]
473    fn near_far_track_view_bounds_not_content_radius() {
474        // Camera framed to small content (unit box) — the bug was near/far
475        // pinned to that ~1.7 radius. A much larger view extent (a big grid)
476        // must push near close to the eye and far past the grid corner, so
477        // the grid isn't plane-clipped.
478        let cam = CameraState::framing(unit_box());
479        let grid = Aabb::from_points([Vec3::splat(-10.0), Vec3::splat(10.0)]);
480        let r = cam.resolve(grid);
481
482        // Near is a small fraction of the eye distance — NOT ~distance-radius.
483        assert!(
484            r.near <= cam.distance * 0.05,
485            "near should hug the camera, got {} (distance {})",
486            r.near,
487            cam.distance
488        );
489        // Far reaches past the farthest grid corner from the eye.
490        let far_corner = Vec3::splat(10.0).max(Vec3::splat(-10.0));
491        let dist_to_far = (cam.eye() - far_corner).length();
492        assert!(
493            r.far >= dist_to_far,
494            "far {} must cover the far grid corner at {}",
495            r.far,
496            dist_to_far
497        );
498    }
499}