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}