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
8//!   renderer'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
18use glam::{Mat4, Vec2, Vec3};
19
20use crate::scene::bounds::Aabb;
21use crate::tree::Rect;
22
23/// Default vertical field of view (radians). Framing fits the data
24/// bounds to this fov.
25pub const DEFAULT_FOV_Y_RADIANS: f32 = std::f32::consts::FRAC_PI_4; // 45°
26
27/// Pitch is clamped just shy of the poles so the up vector never
28/// degenerates and orbit stays stable.
29const MAX_PITCH: f32 = std::f32::consts::FRAC_PI_2 - 0.087; // ~5° shy of the pole (~85°)
30/// Absolute eye-distance clamps. Wide range — small graphs sit near the
31/// bottom, but the camera is a general 3D navigator.
32const MIN_DISTANCE: f32 = 1.0e-3;
33const MAX_DISTANCE: f32 = 1.0e6;
34
35/// How the camera relates to the scene's data bounds. Decouples "where the
36/// camera is" (the absolute [`CameraState`] pose) from "should it track the
37/// data" (this policy).
38#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
39pub enum Framing {
40    /// Fit the content once, then navigate freely; re-centre on the data
41    /// when its bounds change (smoothly, once the keyed camera animates).
42    /// The default — "show me the data, then let me look around".
43    #[default]
44    Auto,
45    /// Re-fit the content every frame. For static viewers that should
46    /// always frame the data regardless of navigation.
47    Fit,
48    /// Never auto-fit; the app owns the absolute pose. For app-driven
49    /// cameras and fixed viewpoints.
50    Manual,
51}
52
53/// Pointer navigation scheme for a scene camera, matching the conventions
54/// of popular 3D apps. The app picks one on the spec; there is
55/// deliberately no built-in scheme-picker widget. The wheel always zooms,
56/// regardless of scheme.
57#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
58pub enum CameraControls {
59    /// Widget default: left-drag orbits, Shift+left or right-drag pans,
60    /// wheel zooms. Left-drag is free to use here — a chart/widget has no
61    /// selection to preserve, unlike a 3D editor.
62    #[default]
63    Orbit,
64    /// Blender / Fusion 360: middle-drag orbits, Shift+middle-drag pans.
65    Blender,
66    /// OnShape: right-drag orbits, middle-drag pans.
67    OnShape,
68    /// Maya: Alt+left orbits, Alt+middle pans, Alt+right dollies (zoom).
69    Maya,
70}
71
72/// A declarative camera focus request, set on the scene spec. Whenever it
73/// *changes*, the keyed camera animates (springs) to it — so an app can
74/// "look here" smoothly by swapping the value in its build. Orbit angles
75/// are preserved; only the look-at point and distance move.
76#[derive(Clone, Copy, Debug, PartialEq)]
77pub enum Focus {
78    /// Frame these world-space bounds (centre + fit distance).
79    Bounds(Aabb),
80    /// Look at a world point from an explicit distance.
81    Point { target: Vec3, distance: f32 },
82}
83
84/// Absolute, persistent orbit-camera pose for one scene. World-space —
85/// not re-derived from content each frame (see [`Framing`]). Defaults to a
86/// pleasant three-quarter view of a unit sphere at the origin; [`fitted`]
87/// re-frames it to data, gestures and programmatic moves mutate it.
88///
89/// [`fitted`]: CameraState::fitted
90#[derive(Clone, Copy, Debug, PartialEq)]
91pub struct CameraState {
92    /// Look-at point in world space.
93    pub target: Vec3,
94    /// Eye distance from `target`. Multiplicative [`zoom_by`](Self::zoom_by)
95    /// keeps the perceived zoom rate constant at any scale.
96    pub distance: f32,
97    /// Azimuth around +Y, radians.
98    pub yaw: f32,
99    /// Elevation, radians; clamped to ±~85° by [`orbit`](Self::orbit).
100    pub pitch: f32,
101}
102
103impl Default for CameraState {
104    fn default() -> Self {
105        Self {
106            target: Vec3::ZERO,
107            // Frames a unit-radius sphere at the default fov.
108            distance: 1.0 / (DEFAULT_FOV_Y_RADIANS * 0.5).sin(),
109            yaw: std::f32::consts::FRAC_PI_4,   // 45°
110            pitch: std::f32::consts::FRAC_PI_6, // 30°
111        }
112    }
113}
114
115impl CameraState {
116    /// Orbit by angular deltas (radians). Pitch clamps near the poles so
117    /// the up vector never degenerates.
118    pub fn orbit(&mut self, d_yaw: f32, d_pitch: f32) {
119        self.yaw += d_yaw;
120        self.pitch = (self.pitch + d_pitch).clamp(-MAX_PITCH, MAX_PITCH);
121    }
122
123    /// Multiply the eye distance by `factor`, clamped to a sane range.
124    /// `factor > 1` pulls the camera back. Multiplicative so a scroll notch
125    /// covers proportional distance whether near or far.
126    pub fn zoom_by(&mut self, factor: f32) {
127        if factor.is_finite() && factor > 0.0 {
128            self.distance = (self.distance * factor).clamp(MIN_DISTANCE, MAX_DISTANCE);
129        }
130    }
131
132    /// Translate the look-at point by a world-space delta (pan).
133    pub fn pan_by(&mut self, delta: Vec3) {
134        self.target += delta;
135    }
136
137    /// Distance at which a sphere of `radius` exactly fills the vertical fov.
138    pub fn fit_distance(radius: f32) -> f32 {
139        (radius.max(1e-4) / (DEFAULT_FOV_Y_RADIANS * 0.5).sin()).clamp(MIN_DISTANCE, MAX_DISTANCE)
140    }
141
142    /// A copy framed on `content`: `target` at the centre and `distance`
143    /// fit to the bounds, **preserving the current orbit angles**. Empty
144    /// bounds leave a unit sphere at the origin. This is the framing
145    /// operation `Framing::Fit` / `Auto` apply.
146    pub fn fitted(&self, content: Aabb) -> CameraState {
147        let (center, radius) = sphere_of(content);
148        CameraState {
149            target: center,
150            distance: Self::fit_distance(radius),
151            yaw: self.yaw,
152            pitch: self.pitch,
153        }
154    }
155
156    /// Default angles, framed on `content`. The auto-framed starting pose.
157    pub fn framing(content: Aabb) -> CameraState {
158        CameraState::default().fitted(content)
159    }
160
161    /// Point the camera at `target` from `distance`, keeping orbit angles.
162    pub fn look_at(&mut self, target: Vec3, distance: f32) {
163        self.target = target;
164        self.distance = distance.clamp(MIN_DISTANCE, MAX_DISTANCE);
165    }
166
167    /// A copy satisfying a [`Focus`] request, preserving orbit angles. The
168    /// keyed camera springs toward this when the request changes.
169    pub fn focused(&self, focus: Focus) -> CameraState {
170        match focus {
171            Focus::Bounds(b) => self.fitted(b),
172            Focus::Point { target, distance } => {
173                let mut c = *self;
174                c.look_at(target, distance);
175                c
176            }
177        }
178    }
179
180    /// World-space eye position implied by the pose.
181    pub fn eye(&self) -> Vec3 {
182        let (sy, cy) = self.yaw.sin_cos();
183        let (sp, cp) = self.pitch.sin_cos();
184        // Unit direction from target toward the eye.
185        let dir = Vec3::new(cp * sy, sp, cp * cy);
186        self.target + dir * self.distance
187    }
188
189    /// Resolve to a concrete camera. `view_bounds` is everything that
190    /// should stay inside the frustum (content **and** the reference grid /
191    /// axes) — near/far are sized from the eye's distance to that, *not*
192    /// from the content radius, so geometry larger than the data (a big
193    /// grid) is never plane-clipped. The pose is taken as-is; framing
194    /// (fitting to data) is applied by the caller before resolving.
195    pub fn resolve(&self, view_bounds: Aabb) -> ResolvedCamera {
196        let fov_y = DEFAULT_FOV_Y_RADIANS;
197        let eye = self.eye();
198        let (vc, vr) = if view_bounds.is_valid() {
199            let (c, r) = sphere_of(view_bounds);
200            (c, r)
201        } else {
202            // No geometry: a sphere around the target sized to the distance.
203            (self.target, self.distance.max(1e-4))
204        };
205        // Eye-to-view-sphere distance bounds the depth range. Near floors
206        // to a small fraction of the eye distance (so geometry right in
207        // front of the camera isn't clipped and depth precision scales),
208        // never to the content radius.
209        let d = (eye - vc).length();
210        let near = (d - vr).max(self.distance * 0.02).max(1e-3);
211        let far = (d + vr).max(near * 8.0);
212
213        ResolvedCamera {
214            eye,
215            target: self.target,
216            up: Vec3::Y,
217            fov_y,
218            near,
219            far,
220        }
221    }
222}
223
224/// Bounding sphere `(center, radius)` of an Aabb. Invalid/empty bounds
225/// yield a unit sphere at the origin so an empty scene still resolves.
226fn sphere_of(bounds: Aabb) -> (Vec3, f32) {
227    if bounds.is_valid() {
228        let r = bounds.bounding_radius();
229        (bounds.center(), if r > 1e-4 { r } else { 1.0 })
230    } else {
231        (Vec3::ZERO, 1.0)
232    }
233}
234
235/// A resolved camera: concrete framing plus the matrices and projection
236/// the backend and label layer need. Stored in `Scene3DData`.
237#[derive(Clone, Copy, Debug, PartialEq)]
238pub struct ResolvedCamera {
239    pub eye: Vec3,
240    pub target: Vec3,
241    pub up: Vec3,
242    pub fov_y: f32,
243    pub near: f32,
244    pub far: f32,
245}
246
247impl ResolvedCamera {
248    pub fn view(&self) -> Mat4 {
249        Mat4::look_at_rh(self.eye, self.target, self.up)
250    }
251
252    pub fn proj(&self, aspect: f32) -> Mat4 {
253        Mat4::perspective_rh(self.fov_y, aspect.max(1e-4), self.near, self.far)
254    }
255
256    pub fn view_proj(&self, aspect: f32) -> Mat4 {
257        self.proj(aspect) * self.view()
258    }
259
260    /// Project a world point to screen-space (logical px) within
261    /// `viewport`. Returns `None` for points at or behind the camera
262    /// plane (`w <= 0`), so label callers cull them rather than drawing a
263    /// mirrored ghost. Points in front but outside the rect still return
264    /// `Some` — clipping to the rect is the caller's choice.
265    pub fn project_to_screen(&self, world: Vec3, viewport: Rect) -> Option<Vec2> {
266        self.project_to_screen_with_depth(world, viewport)
267            .map(|(p, _)| p)
268    }
269
270    /// Like [`project_to_screen`](Self::project_to_screen) but also returns
271    /// the point's normalised device depth in `[0, 1]` (wgpu convention:
272    /// `0` near, `1` far) — the same space a `Depth32Float` buffer stores,
273    /// so callers can depth-test a projected anchor against a captured
274    /// scene depth map. `None` when the point is at/behind the camera.
275    pub fn project_to_screen_with_depth(&self, world: Vec3, viewport: Rect) -> Option<(Vec2, f32)> {
276        let aspect = viewport.w / viewport.h.max(1e-4);
277        let clip = self.view_proj(aspect) * world.extend(1.0);
278        if clip.w <= 0.0 {
279            return None;
280        }
281        let ndc = clip.truncate() / clip.w; // x, y in [-1, 1]; z in [0, 1]
282        let sx = viewport.x + (ndc.x * 0.5 + 0.5) * viewport.w;
283        let sy = viewport.y + (1.0 - (ndc.y * 0.5 + 0.5)) * viewport.h; // flip Y for screen
284        Some((Vec2::new(sx, sy), ndc.z))
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    fn unit_box() -> Aabb {
293        Aabb::from_points([Vec3::splat(-1.0), Vec3::splat(1.0)])
294    }
295
296    #[test]
297    fn fitted_frames_bounds() {
298        let cam = CameraState::framing(unit_box());
299        // Target is the box centre.
300        assert!((cam.target - Vec3::ZERO).length() < 1e-5);
301        // Eye sits the fit distance away, outside the bounding radius.
302        assert!(cam.distance > unit_box().bounding_radius());
303        let r = cam.resolve(unit_box());
304        assert!(r.near > 0.0 && r.far > r.near);
305        // fitted preserves orbit angles.
306        let mut tilted = CameraState::default();
307        tilted.orbit(0.3, -0.2);
308        let f = tilted.fitted(unit_box());
309        assert_eq!((f.yaw, f.pitch), (tilted.yaw, tilted.pitch));
310    }
311
312    #[test]
313    fn target_projects_near_viewport_centre() {
314        let cam = CameraState::framing(unit_box()).resolve(unit_box());
315        let vp = Rect::new(0.0, 0.0, 200.0, 100.0);
316        let p = cam
317            .project_to_screen(cam.target, vp)
318            .expect("target in front");
319        assert!((p.x - 100.0).abs() < 0.5, "x={}", p.x);
320        assert!((p.y - 50.0).abs() < 0.5, "y={}", p.y);
321    }
322
323    #[test]
324    fn point_behind_camera_is_culled() {
325        let cam = CameraState::framing(unit_box()).resolve(unit_box());
326        // Mirror the target across the eye → strictly behind the camera.
327        let behind = cam.eye + (cam.eye - cam.target);
328        assert!(
329            cam.project_to_screen(behind, Rect::new(0.0, 0.0, 200.0, 100.0))
330                .is_none()
331        );
332    }
333
334    #[test]
335    fn orbit_and_zoom_move_the_eye() {
336        let base = CameraState::framing(unit_box());
337        let base_eye = base.eye();
338        let mut s = base;
339        s.orbit(0.5, 0.0);
340        assert!((s.eye() - base_eye).length() > 1e-3, "orbit moved eye");
341
342        // Multiplicative zoom doubles the absolute distance.
343        let mut z = base;
344        z.zoom_by(2.0);
345        assert!((z.distance - 2.0 * base.distance).abs() < 1e-3);
346    }
347
348    #[test]
349    fn pitch_clamps_near_pole() {
350        let mut s = CameraState::default();
351        s.orbit(0.0, 100.0); // absurd up-tilt
352        assert!(s.pitch <= MAX_PITCH + 1e-6);
353    }
354
355    #[test]
356    fn near_far_track_view_bounds_not_content_radius() {
357        // Camera framed to small content (unit box) — the bug was near/far
358        // pinned to that ~1.7 radius. A much larger view extent (a big grid)
359        // must push near close to the eye and far past the grid corner, so
360        // the grid isn't plane-clipped.
361        let cam = CameraState::framing(unit_box());
362        let grid = Aabb::from_points([Vec3::splat(-10.0), Vec3::splat(10.0)]);
363        let r = cam.resolve(grid);
364
365        // Near is a small fraction of the eye distance — NOT ~distance-radius.
366        assert!(
367            r.near <= cam.distance * 0.05,
368            "near should hug the camera, got {} (distance {})",
369            r.near,
370            cam.distance
371        );
372        // Far reaches past the farthest grid corner from the eye.
373        let far_corner = Vec3::splat(10.0).max(Vec3::splat(-10.0));
374        let dist_to_far = (cam.eye() - far_corner).length();
375        assert!(
376            r.far >= dist_to_far,
377            "far {} must cover the far grid corner at {}",
378            r.far,
379            dist_to_far
380        );
381    }
382}