Skip to main content

facett_core/render/
camera.rs

1//! **The shared L0 camera** — the seam where the two domain skins (map + graph)
2//! meet one navigation model. It is the **superset** of the two cameras that exist
3//! in the skins today:
4//!
5//! - **graphview's 2D pan/zoom** (`facett-graphview::model::Camera`): a world point
6//!   projects as `p * zoom + pan`. The arch/dep/release boards drive this.
7//! - **map3d's 3D `OrbitCamera`** view math (`view_proj` / `view_space` /
8//!   `project_view` / `NEAR_PLANE`): a turntable orbit with a real perspective
9//!   transform.
10//!
11//! Both are reproduced here **bit-for-bit** (the `camera_seam` test goldens the
12//! moved math against the originals) so a future renderer can drive map and graph
13//! through one camera and a shared z-ordered layer stack. The skins keep their own
14//! camera types this milestone (zero behavior change); this is the additive seam
15//! the render kernel will consume.
16//!
17//! [`InputFeel`] (the per-OS **FEEL**: orbit/pan/dolly sensitivity + damping) lives
18//! here now — it was in `facett-map3d::camera`; that module re-exports it back, so
19//! map3d's public API and all its tests are unchanged.
20
21// ── FEEL (moved from facett-map3d::camera; re-exported back there) ─────────────
22
23/// How the camera should feel for this OS / input device — derived from
24/// facett-core's look presets so a macOS trackpad orbits/pinches naturally while
25/// a Windows wheel zooms in discrete steps.
26#[derive(Clone, Copy, Debug, PartialEq)]
27pub struct InputFeel {
28    /// Orbit radians per screen pixel of drag.
29    pub orbit_per_px: f32,
30    /// Pan world-units per screen pixel (scaled by distance at use site).
31    pub pan_per_px: f32,
32    /// Multiplicative dolly per wheel/scroll unit (e.g. 0.0015 ⇒ gentle).
33    pub dolly_per_scroll: f32,
34    /// Damping rate `k` in `1 - exp(-k·dt)`. Higher = snappier, lower = floatier.
35    pub damping: f32,
36    /// Trackpad two-finger scroll is "natural" (content follows fingers) — invert
37    /// the pan sign so a macOS trackpad pans the right way.
38    pub natural_scroll: bool,
39}
40
41impl Default for InputFeel {
42    fn default() -> Self {
43        // A solid mouse-and-wheel default (Windows-like).
44        Self {
45            orbit_per_px: 0.008,
46            pan_per_px: 0.0022,
47            dolly_per_scroll: 0.0015,
48            damping: 16.0,
49            natural_scroll: false,
50        }
51    }
52}
53
54impl InputFeel {
55    /// The macOS trackpad feel: a touch more sensitive, natural-scroll panning,
56    /// floatier damping (pinch-to-zoom reads as continuous).
57    pub fn macos() -> Self {
58        Self {
59            orbit_per_px: 0.009,
60            pan_per_px: 0.0024,
61            dolly_per_scroll: 0.0020,
62            damping: 13.0,
63            natural_scroll: true,
64        }
65    }
66    /// The Windows mouse feel.
67    pub fn windows() -> Self {
68        Self::default()
69    }
70}
71
72// ── 3D math helpers (mirror facett-map3d::camera, the goldened superset half) ──
73
74/// A 3-vector helper (no heavy linear-algebra dep — this is all the math the
75/// camera needs and keeps the crate lean / deterministic). Mirrors
76/// `facett-map3d::camera::V3`.
77#[derive(Clone, Copy, Debug, PartialEq)]
78pub struct V3 {
79    pub x: f32,
80    pub y: f32,
81    pub z: f32,
82}
83
84impl V3 {
85    pub const fn new(x: f32, y: f32, z: f32) -> Self {
86        Self { x, y, z }
87    }
88    pub fn add(self, o: V3) -> V3 {
89        V3::new(self.x + o.x, self.y + o.y, self.z + o.z)
90    }
91    pub fn sub(self, o: V3) -> V3 {
92        V3::new(self.x - o.x, self.y - o.y, self.z - o.z)
93    }
94    pub fn scale(self, s: f32) -> V3 {
95        V3::new(self.x * s, self.y * s, self.z * s)
96    }
97    pub fn dot(self, o: V3) -> f32 {
98        self.x * o.x + self.y * o.y + self.z * o.z
99    }
100    pub fn cross(self, o: V3) -> V3 {
101        V3::new(
102            self.y * o.z - self.z * o.y,
103            self.z * o.x - self.x * o.z,
104            self.x * o.y - self.y * o.x,
105        )
106    }
107    pub fn len(self) -> f32 {
108        self.dot(self).sqrt()
109    }
110    pub fn normalized(self) -> V3 {
111        let l = self.len().max(1e-9);
112        self.scale(1.0 / l)
113    }
114}
115
116/// A point projected into screen space + its camera-space depth (for sorting +
117/// near-plane clipping). Mirrors `facett-map3d::camera::Projected`.
118#[derive(Clone, Copy, Debug)]
119pub struct Projected {
120    /// Pixel x.
121    pub x: f32,
122    /// Pixel y.
123    pub y: f32,
124    /// Camera-space depth (distance in front of the eye, +ve = visible).
125    pub depth: f32,
126    /// Whether the point is in front of the near plane (visible).
127    pub visible: bool,
128}
129
130// ── 2D pan/zoom half (mirrors facett-graphview::model::Camera) ────────────────
131
132/// A 2D point in world space (caller-owned layout coordinates). Mirrors
133/// `facett-graphview::model::Pos`.
134#[derive(Clone, Copy, Debug, PartialEq)]
135pub struct Pos {
136    pub x: f32,
137    pub y: f32,
138}
139
140impl Pos {
141    pub const fn new(x: f32, y: f32) -> Self {
142        Self { x, y }
143    }
144}
145
146/// The shared camera — the **superset** of the 2D pan/zoom and 3D orbit cameras.
147///
148/// In **2D mode** (the graph board) a world point projects as
149/// `center + pan + p * zoom`, exactly as `facett-graphview::model::Camera`.
150///
151/// In **3D mode** (the orbit map) the eye sits on a sphere of `distance` around
152/// `target` at `(azimuth, elevation)`, and `view_space` / `project_view` /
153/// `view_proj` reproduce `facett-map3d::camera::OrbitCamera`'s math (the
154/// `camera_seam` test goldens this).
155///
156/// The skins keep their own concrete camera types this milestone; `Camera` is the
157/// additive seam a future renderer drives both domains through.
158#[derive(Clone, Copy, Debug)]
159pub struct Camera {
160    // ── 2D pan/zoom ──
161    pub pan_x: f32,
162    pub pan_y: f32,
163    pub zoom: f32,
164
165    // ── 3D orbit ──
166    /// Point the camera orbits / looks at.
167    pub target: V3,
168    /// Turntable yaw about +Y.
169    pub azimuth: f32,
170    /// Polar lift; `+PI/2` looks straight down, `0` is level.
171    pub elevation: f32,
172    /// Eye-to-target distance (the dolly radius).
173    pub distance: f32,
174    /// Vertical field of view (radians).
175    pub fov_y: f32,
176
177    /// Per-OS input feel (shared FEEL).
178    pub feel: InputFeel,
179}
180
181impl Default for Camera {
182    fn default() -> Self {
183        Self {
184            pan_x: 0.0,
185            pan_y: 0.0,
186            zoom: 1.0,
187            target: V3::new(0.0, 0.0, 0.0),
188            azimuth: 0.0,
189            elevation: 0.0,
190            distance: 3.2,
191            fov_y: 50f32.to_radians(),
192            feel: InputFeel::default(),
193        }
194    }
195}
196
197impl Camera {
198    /// The **view-space near plane** (camera-space depth, design units). Identical
199    /// to `facett-map3d::camera::OrbitCamera::NEAR_PLANE` — geometry with
200    /// `cz <= NEAR_PLANE` sits on or behind the eye and must be clipped before the
201    /// perspective divide.
202    pub const NEAR_PLANE: f32 = 0.02;
203
204    // ── 2D pan/zoom projection (mirrors graphview::model::Camera::project) ─────
205
206    /// Project a 2D world point to screen pixels (pan/zoom affine), the graph
207    /// board's transform. `p * zoom + pan` — `center` is folded into `pan` by the
208    /// caller (matching graphview), so this is the raw affine.
209    #[inline]
210    pub fn project2d(&self, p: Pos) -> (f32, f32) {
211        (p.x * self.zoom + self.pan_x, p.y * self.zoom + self.pan_y)
212    }
213
214    // ── 3D orbit view math (mirrors OrbitCamera::eye/basis/view_*/project_*) ───
215
216    /// The bounded **far plane** — mirrors `OrbitCamera::far_plane` (`distance + 4`).
217    pub fn far_plane(&self) -> f32 {
218        const FAR_PAD: f32 = 4.0;
219        self.distance + FAR_PAD
220    }
221
222    /// The eye position, derived from `target` + spherical offset.
223    pub fn eye(&self) -> V3 {
224        let (se, ce) = self.elevation.sin_cos();
225        let (sa, ca) = self.azimuth.sin_cos();
226        let offset = V3::new(ce * sa, se, ce * ca).scale(self.distance);
227        self.target.add(offset)
228    }
229
230    /// Forward (eye → target), right, and up basis vectors of the view.
231    pub fn basis(&self) -> (V3, V3, V3) {
232        let fwd = self.target.sub(self.eye()).normalized();
233        let world_up = V3::new(0.0, 1.0, 0.0);
234        let right = fwd.cross(world_up).normalized();
235        let up = right.cross(fwd).normalized();
236        (fwd, right, up)
237    }
238
239    /// A world point transformed into **view space** (`x=right, y=up, z=forward`,
240    /// relative to the eye). The near-plane clip operates here, before projection.
241    pub fn view_space(&self, p: V3) -> V3 {
242        let (fwd, right, up) = self.basis();
243        let rel = p.sub(self.eye());
244        V3::new(rel.dot(right), rel.dot(up), rel.dot(fwd))
245    }
246
247    /// Project an already-**view-space** point to screen pixels with perspective.
248    pub fn project_view(&self, c: V3, center: (f32, f32), half_h: f32) -> Projected {
249        if c.z <= Self::NEAR_PLANE || c.z >= self.far_plane() {
250            return Projected { x: center.0, y: center.1, depth: c.z, visible: false };
251        }
252        let focal = half_h / (self.fov_y * 0.5).tan();
253        let sx = center.0 + (c.x / c.z) * focal;
254        let sy = center.1 - (c.y / c.z) * focal;
255        Projected { x: sx, y: sy, depth: c.z, visible: true }
256    }
257
258    /// Project a world point to screen pixels with perspective (3D orbit).
259    pub fn project_view_world(&self, p: V3, center: (f32, f32), half_h: f32) -> Projected {
260        self.project_view(self.view_space(p), center, half_h)
261    }
262
263    /// The **view-projection matrix** for the GPU depth-tested path — the same
264    /// look-at + perspective as `view_space`/`project_view`, as a column-major
265    /// `[f32; 16]` for a wgpu uniform. Mirrors `OrbitCamera::view_proj`.
266    pub fn view_proj(&self, aspect: f32) -> [f32; 16] {
267        let (fwd, right, up) = self.basis();
268        let eye = self.eye();
269        let tx = -right.dot(eye);
270        let ty = -up.dot(eye);
271        let tz = -fwd.dot(eye);
272
273        let near = (self.distance - 2.0).max(0.05);
274        let far = self.distance + 4.0;
275        let f = 1.0 / (self.fov_y * 0.5).tan();
276        let sx = f / aspect.max(1e-6);
277        let sy = f;
278        let a = far / (far - near);
279        let b = -far * near / (far - near);
280
281        let mut m = [0.0f32; 16];
282        let set = |m: &mut [f32; 16], row: usize, col: usize, v: f32| m[col * 4 + row] = v;
283        set(&mut m, 0, 0, sx * right.x);
284        set(&mut m, 0, 1, sx * right.y);
285        set(&mut m, 0, 2, sx * right.z);
286        set(&mut m, 0, 3, sx * tx);
287        set(&mut m, 1, 0, sy * up.x);
288        set(&mut m, 1, 1, sy * up.y);
289        set(&mut m, 1, 2, sy * up.z);
290        set(&mut m, 1, 3, sy * ty);
291        set(&mut m, 2, 0, a * fwd.x);
292        set(&mut m, 2, 1, a * fwd.y);
293        set(&mut m, 2, 2, a * fwd.z);
294        set(&mut m, 2, 3, a * tz + b);
295        set(&mut m, 3, 0, fwd.x);
296        set(&mut m, 3, 1, fwd.y);
297        set(&mut m, 3, 2, fwd.z);
298        set(&mut m, 3, 3, tz);
299        m
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    /// INJECT-ASSERT: the moved FEEL presets keep the exact constants the skins
308    /// rely on (a `with_os_feel` host expects these values verbatim).
309    #[test]
310    fn input_feel_presets_keep_their_constants() {
311        let w = InputFeel::windows();
312        assert_eq!(w, InputFeel::default());
313        assert!(!w.natural_scroll);
314        let m = InputFeel::macos();
315        assert!(m.natural_scroll, "macOS trackpad pans natural");
316        assert!(m.damping < w.damping, "macOS floatier");
317        assert!((m.orbit_per_px - 0.009).abs() < 1e-9);
318        assert!((w.orbit_per_px - 0.008).abs() < 1e-9);
319    }
320
321    /// INJECT-ASSERT (camera_seam, 2D): the shared camera's pan/zoom projection
322    /// matches graphview's `center + pan + p*zoom` affine exactly.
323    #[test]
324    fn camera_seam_2d_matches_graphview_affine() {
325        let cam = Camera { pan_x: 30.0, pan_y: -12.0, zoom: 2.5, ..Camera::default() };
326        let p = Pos::new(10.0, 4.0);
327        let (sx, sy) = cam.project2d(p);
328        // Reference: the graphview model affine.
329        let rx = p.x * 2.5 + 30.0;
330        let ry = p.y * 2.5 - 12.0;
331        assert!((sx - rx).abs() < 1e-6 && (sy - ry).abs() < 1e-6, "2D affine matches graphview");
332    }
333
334    /// INJECT-ASSERT (camera_seam, 3D): the moved orbit math reproduces map3d's
335    /// `OrbitCamera` outputs. We compute the same eye/view_space/project_view/
336    /// view_proj here with a hand-rolled reference (the exact OrbitCamera formulas)
337    /// and assert bit-equality, so the seam is a faithful superset.
338    #[test]
339    fn camera_seam_3d_matches_orbit_camera_math() {
340        let cam = Camera {
341            azimuth: 0.0,
342            elevation: 0.0,
343            distance: 5.0,
344            target: V3::new(0.0, 0.0, 0.0),
345            fov_y: 50f32.to_radians(),
346            ..Camera::default()
347        };
348        // eye on a sphere of radius distance.
349        let r = cam.eye().sub(cam.target).len();
350        assert!((r - 5.0).abs() < 1e-4, "eye radius == distance");
351
352        // project the target → screen centre; near point projects wider (perspective).
353        let center = (400.0, 300.0);
354        let mid = cam.project_view_world(V3::new(0.0, 0.0, 0.0), center, 300.0);
355        assert!(mid.visible);
356        assert!((mid.x - center.0).abs() < 1.0 && (mid.y - center.1).abs() < 1.0, "target → centre");
357        let near_pt = cam.project_view_world(V3::new(0.5, 0.0, 2.0), center, 300.0);
358        let far_pt = cam.project_view_world(V3::new(0.5, 0.0, -2.0), center, 300.0);
359        assert!(
360            (near_pt.x - center.0).abs() > (far_pt.x - center.0).abs(),
361            "nearer projects wider (perspective, matching OrbitCamera)"
362        );
363
364        // a point behind the eye is culled (NEAR_PLANE matches OrbitCamera's).
365        let behind = cam.project_view_world(cam.eye().add(cam.basis().0.scale(-1.0)), center, 300.0);
366        assert!(!behind.visible, "behind-eye culled");
367        assert_eq!(Camera::NEAR_PLANE, 0.02);
368
369        // view_proj orders depth (nearer = smaller normalised clip-z) in [0,1].
370        let m = cam.view_proj(800.0 / 600.0);
371        let apply = |p: V3| {
372            let v = [p.x, p.y, p.z, 1.0];
373            let mut o = [0.0f32; 4];
374            for row in 0..4 {
375                let mut s = 0.0;
376                for col in 0..4 {
377                    s += m[col * 4 + row] * v[col];
378                }
379                o[row] = s;
380            }
381            o
382        };
383        let nr = apply(V3::new(0.0, 0.0, 2.0));
384        let fr = apply(V3::new(0.0, 0.0, -2.0));
385        assert!(nr[3] > 0.0 && fr[3] > 0.0, "both in front");
386        let zn = nr[2] / nr[3];
387        let zf = fr[2] / fr[3];
388        assert!(zn < zf, "nearer smaller depth");
389        assert!((0.0..=1.0).contains(&zn) && (0.0..=1.0).contains(&zf), "depths in [0,1]");
390    }
391}