facett-core 0.1.17

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
//! **The shared L0 camera** — the seam where the two domain skins (map + graph)
//! meet one navigation model. It is the **superset** of the two cameras that exist
//! in the skins today:
//!
//! - **graphview's 2D pan/zoom** (`facett-graphview::model::Camera`): a world point
//!   projects as `p * zoom + pan`. The arch/dep/release boards drive this.
//! - **map3d's 3D `OrbitCamera`** view math (`view_proj` / `view_space` /
//!   `project_view` / `NEAR_PLANE`): a turntable orbit with a real perspective
//!   transform.
//!
//! Both are reproduced here **bit-for-bit** (the `camera_seam` test goldens the
//! moved math against the originals) so a future renderer can drive map and graph
//! through one camera and a shared z-ordered layer stack. The skins keep their own
//! camera types this milestone (zero behavior change); this is the additive seam
//! the render kernel will consume.
//!
//! [`InputFeel`] (the per-OS **FEEL**: orbit/pan/dolly sensitivity + damping) lives
//! here now — it was in `facett-map3d::camera`; that module re-exports it back, so
//! map3d's public API and all its tests are unchanged.

// ── FEEL (moved from facett-map3d::camera; re-exported back there) ─────────────

/// How the camera should feel for this OS / input device — derived from
/// facett-core's look presets so a macOS trackpad orbits/pinches naturally while
/// a Windows wheel zooms in discrete steps.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct InputFeel {
    /// Orbit radians per screen pixel of drag.
    pub orbit_per_px: f32,
    /// Pan world-units per screen pixel (scaled by distance at use site).
    pub pan_per_px: f32,
    /// Multiplicative dolly per wheel/scroll unit (e.g. 0.0015 ⇒ gentle).
    pub dolly_per_scroll: f32,
    /// Damping rate `k` in `1 - exp(-k·dt)`. Higher = snappier, lower = floatier.
    pub damping: f32,
    /// Trackpad two-finger scroll is "natural" (content follows fingers) — invert
    /// the pan sign so a macOS trackpad pans the right way.
    pub natural_scroll: bool,
}

impl Default for InputFeel {
    fn default() -> Self {
        // A solid mouse-and-wheel default (Windows-like).
        Self {
            orbit_per_px: 0.008,
            pan_per_px: 0.0022,
            dolly_per_scroll: 0.0015,
            damping: 16.0,
            natural_scroll: false,
        }
    }
}

impl InputFeel {
    /// The macOS trackpad feel: a touch more sensitive, natural-scroll panning,
    /// floatier damping (pinch-to-zoom reads as continuous).
    pub fn macos() -> Self {
        Self {
            orbit_per_px: 0.009,
            pan_per_px: 0.0024,
            dolly_per_scroll: 0.0020,
            damping: 13.0,
            natural_scroll: true,
        }
    }
    /// The Windows mouse feel.
    pub fn windows() -> Self {
        Self::default()
    }
}

// ── 3D math helpers (mirror facett-map3d::camera, the goldened superset half) ──

/// A 3-vector helper — **the ONE definition**; `facett_map3d::camera::V3` re-exports it
/// (it used to be a verbatim twin there, retired 2026-08-22). All the math the camera
/// API needs, kept as a plain struct so the crate-facing type stays `{x, y, z}` and
/// deterministic; the heavy algebra (`View`'s look-at / projection / inverse) runs in
/// `glam` behind the `From` impls below. `dot` / `cross` / `scale` / `len` are
/// bit-identical to glam's scalar `Vec3` (same association order); `normalized` floors
/// the length at `1e-9` where glam would return a non-finite vector.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct V3 {
    pub x: f32,
    pub y: f32,
    pub z: f32,
}

impl V3 {
    pub const fn new(x: f32, y: f32, z: f32) -> Self {
        Self { x, y, z }
    }
    pub fn add(self, o: V3) -> V3 {
        V3::new(self.x + o.x, self.y + o.y, self.z + o.z)
    }
    pub fn sub(self, o: V3) -> V3 {
        V3::new(self.x - o.x, self.y - o.y, self.z - o.z)
    }
    pub fn scale(self, s: f32) -> V3 {
        V3::new(self.x * s, self.y * s, self.z * s)
    }
    pub fn dot(self, o: V3) -> f32 {
        self.x * o.x + self.y * o.y + self.z * o.z
    }
    pub fn cross(self, o: V3) -> V3 {
        V3::new(
            self.y * o.z - self.z * o.y,
            self.z * o.x - self.x * o.z,
            self.x * o.y - self.y * o.x,
        )
    }
    pub fn len(self) -> f32 {
        self.dot(self).sqrt()
    }
    pub fn normalized(self) -> V3 {
        let l = self.len().max(1e-9);
        self.scale(1.0 / l)
    }
}

/// `V3` ↔ `glam::Vec3`, component-wise — a pure relabel, no arithmetic, so a value
/// round-trips bit-for-bit. This is the seam between the crate-API vector and the
/// algebra engine; the view core (`render::view`) takes and returns glam and the
/// cameras hand it `V3`.
impl From<glam::Vec3> for V3 {
    #[inline]
    fn from(v: glam::Vec3) -> Self {
        V3::new(v.x, v.y, v.z)
    }
}

impl From<V3> for glam::Vec3 {
    #[inline]
    fn from(v: V3) -> Self {
        glam::Vec3::new(v.x, v.y, v.z)
    }
}

/// A point projected into screen space + its camera-space depth (for sorting +
/// near-plane clipping). Mirrors `facett-map3d::camera::Projected`.
#[derive(Clone, Copy, Debug)]
pub struct Projected {
    /// Pixel x.
    pub x: f32,
    /// Pixel y.
    pub y: f32,
    /// Camera-space depth (distance in front of the eye, +ve = visible).
    pub depth: f32,
    /// Whether the point is in front of the near plane (visible).
    pub visible: bool,
}

// ── 2D pan/zoom half (mirrors facett-graphview::model::Camera) ────────────────

/// A 2D point in world space (caller-owned layout coordinates). Mirrors
/// `facett-graphview::model::Pos`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Pos {
    pub x: f32,
    pub y: f32,
}

impl Pos {
    pub const fn new(x: f32, y: f32) -> Self {
        Self { x, y }
    }
}

/// The shared camera — the **superset** of the 2D pan/zoom and 3D orbit cameras.
///
/// In **2D mode** (the graph board) a world point projects as
/// `center + pan + p * zoom`, exactly as `facett-graphview::model::Camera`.
///
/// In **3D mode** (the orbit map) the eye sits on a sphere of `distance` around
/// `target` at `(azimuth, elevation)`, and `view_space` / `project_view` /
/// `view_proj` reproduce `facett-map3d::camera::OrbitCamera`'s math (the
/// `camera_seam` test goldens this).
///
/// The skins keep their own concrete camera types this milestone; `Camera` is the
/// additive seam a future renderer drives both domains through.
#[derive(Clone, Copy, Debug)]
pub struct Camera {
    // ── 2D pan/zoom ──
    pub pan_x: f32,
    pub pan_y: f32,
    pub zoom: f32,

    // ── 3D orbit ──
    /// Point the camera orbits / looks at.
    pub target: V3,
    /// Turntable yaw about +Y.
    pub azimuth: f32,
    /// Polar lift; `+PI/2` looks straight down, `0` is level.
    pub elevation: f32,
    /// Eye-to-target distance (the dolly radius).
    pub distance: f32,
    /// Vertical field of view (radians).
    pub fov_y: f32,

    /// Per-OS input feel (shared FEEL).
    pub feel: InputFeel,
}

impl Default for Camera {
    fn default() -> Self {
        Self {
            pan_x: 0.0,
            pan_y: 0.0,
            zoom: 1.0,
            target: V3::new(0.0, 0.0, 0.0),
            azimuth: 0.0,
            elevation: 0.0,
            distance: 3.2,
            fov_y: 50f32.to_radians(),
            feel: InputFeel::default(),
        }
    }
}

impl Camera {
    /// The **view-space near plane** (camera-space depth, design units). Identical
    /// to `facett-map3d::camera::OrbitCamera::NEAR_PLANE` — geometry with
    /// `cz <= NEAR_PLANE` sits on or behind the eye and must be clipped before the
    /// perspective divide.
    pub const NEAR_PLANE: f32 = 0.02;

    // ── 2D pan/zoom projection (mirrors graphview::model::Camera::project) ─────

    /// Project a 2D world point to screen pixels (pan/zoom affine), the graph
    /// board's transform. `p * zoom + pan` — `center` is folded into `pan` by the
    /// caller (matching graphview), so this is the raw affine.
    ///
    /// GFX_V2 item 6: delegates to [`view_2d`](Self::view_2d), i.e. to the *same*
    /// [`super::view::View`] the 3D half uses, under the 2D constraints. Bit-exact
    /// against the affine it replaced — the graph board and the terrain camera are
    /// now one projection writer, not two.
    #[inline]
    pub fn project2d(&self, p: Pos) -> (f32, f32) {
        let s = self.view_2d().project_ground([p.x, p.y], (self.pan_x, self.pan_y), 1.0);
        (s.x, s.y)
    }

    // ── 3D orbit view math (mirrors OrbitCamera::eye/basis/view_*/project_*) ───

    /// The bounded **far plane** — mirrors `OrbitCamera::far_plane` (`distance + 4`).
    pub fn far_plane(&self) -> f32 {
        const FAR_PAD: f32 = 4.0;
        self.distance + FAR_PAD
    }

    /// **The unified view this camera's 3D half IS** (GFX_V2 item 6) — a
    /// [`super::view::View`] in perspective mode. Every 3D method below delegates
    /// to it, so this struct no longer carries a second copy of the projection.
    #[must_use]
    pub fn view(&self) -> super::view::View {
        super::view::View::from_orbit(self.target, self.azimuth, self.elevation, self.distance, self.fov_y)
    }

    /// **The unified view this camera's 2D half IS** — the same type under the 2D
    /// constraints. `project2d`'s `p*zoom + pan` affine is exactly this view's
    /// orthographic projection with `pan` as the pixel centre, which is the whole
    /// claim of GFX_V2 item 6 stated for the graph board.
    #[must_use]
    pub fn view_2d(&self) -> super::view::View {
        super::view::View::ortho_2d_map([0.0, 0.0], [self.zoom, self.zoom])
    }

    /// The eye position, derived from `target` + spherical offset.
    pub fn eye(&self) -> V3 {
        let e = self.view().eye();
        e.into()
    }

    /// Forward (eye → target), right, and up basis vectors of the view. The unified
    /// core's closed form — an exact algebraic identity with the
    /// `normalize(fwd × world_up)` look-at, but well-conditioned looking straight
    /// down, where that cross product vanishes and silently mirrors east/west.
    pub fn basis(&self) -> (V3, V3, V3) {
        let (f, r, u) = self.view().basis();
        (f.into(), r.into(), u.into())
    }

    /// A world point transformed into **view space** (`x=right, y=up, z=forward`,
    /// relative to the eye). The near-plane clip operates here, before projection.
    pub fn view_space(&self, p: V3) -> V3 {
        let c = self.view().view_space(p.into());
        c.into()
    }

    /// Project an already-**view-space** point to screen pixels with perspective.
    pub fn project_view(&self, c: V3, center: (f32, f32), half_h: f32) -> Projected {
        self.view().project_px(c.into(), center, half_h)
    }

    /// Project a world point to screen pixels with perspective (3D orbit).
    pub fn project_view_world(&self, p: V3, center: (f32, f32), half_h: f32) -> Projected {
        self.project_view(self.view_space(p), center, half_h)
    }

    /// The **view-projection matrix** for the GPU depth-tested path, column-major
    /// `[f32; 16]` for a wgpu uniform.
    ///
    /// GFX_V2 item 6: this was 35 lines of hand-assembled `P * V`, duplicated
    /// verbatim in `facett-map3d::camera::OrbitCamera::view_proj`. Both copies are
    /// gone — `super::view::View` is the one writer (LAW #5).
    pub fn view_proj(&self, aspect: f32) -> [f32; 16] {
        self.view().view_proj(aspect).to_cols_array()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// INJECT-ASSERT: the moved FEEL presets keep the exact constants the skins
    /// rely on (a `with_os_feel` host expects these values verbatim).
    #[test]
    fn input_feel_presets_keep_their_constants() {
        let w = InputFeel::windows();
        assert_eq!(w, InputFeel::default());
        assert!(!w.natural_scroll);
        let m = InputFeel::macos();
        assert!(m.natural_scroll, "macOS trackpad pans natural");
        assert!(m.damping < w.damping, "macOS floatier");
        assert!((m.orbit_per_px - 0.009).abs() < 1e-9);
        assert!((w.orbit_per_px - 0.008).abs() < 1e-9);
    }

    /// INJECT-ASSERT (camera_seam, 2D): the shared camera's pan/zoom projection
    /// matches graphview's `center + pan + p*zoom` affine exactly.
    #[test]
    fn camera_seam_2d_matches_graphview_affine() {
        let cam = Camera { pan_x: 30.0, pan_y: -12.0, zoom: 2.5, ..Camera::default() };
        let p = Pos::new(10.0, 4.0);
        let (sx, sy) = cam.project2d(p);
        // Reference: the graphview model affine.
        let rx = p.x * 2.5 + 30.0;
        let ry = p.y * 2.5 - 12.0;
        assert!((sx - rx).abs() < 1e-6 && (sy - ry).abs() < 1e-6, "2D affine matches graphview");
    }

    /// INJECT-ASSERT (camera_seam, 3D): properties of the shared orbit math.
    ///
    /// ## This test used to claim something it could not check — read before editing
    /// Its doc comment said it computed the OrbitCamera formulas "with a hand-rolled
    /// reference and assert[ed] bit-equality, so the seam is a faithful superset",
    /// and GFX_V2 item 3's camera phase 2 was **deferred on the grounds that this
    /// bit-equality was too brittle to touch**. Measured on 2026-08-01: it never
    /// referenced `OrbitCamera` at all, and it could not — `facett-core` is *below*
    /// `facett-map3d` in the dependency graph, so the type is not visible from here.
    /// There is no hand-rolled reference in the body and no `assert_eq` on any
    /// projection output; the assertions are properties (radius, centring,
    /// foreshortening order, depth range), every one of which a *different* correct
    /// camera would also satisfy. It could not have failed on drift, and the pose it
    /// used (`azimuth = 0, elevation = 0`) is near-identity besides.
    ///
    /// The real cross-crate guard now lives where both types ARE visible:
    /// `facett-map3d/tests/gfx_v2_item6_parity.rs`, which asserts bit-equality
    /// between `OrbitCamera` and this module at non-trivial poses. The properties
    /// below are kept — they are worth having — but they are no longer *claimed* to
    /// be a drift guard. Do not re-add that claim here; it cannot be honoured from
    /// this crate.
    #[test]
    fn camera_seam_3d_holds_the_orbit_projection_properties() {
        let cam = Camera {
            azimuth: 0.0,
            elevation: 0.0,
            distance: 5.0,
            target: V3::new(0.0, 0.0, 0.0),
            fov_y: 50f32.to_radians(),
            ..Camera::default()
        };
        // eye on a sphere of radius distance.
        let r = cam.eye().sub(cam.target).len();
        assert!((r - 5.0).abs() < 1e-4, "eye radius == distance");

        // project the target → screen centre; near point projects wider (perspective).
        let center = (400.0, 300.0);
        let mid = cam.project_view_world(V3::new(0.0, 0.0, 0.0), center, 300.0);
        assert!(mid.visible);
        assert!((mid.x - center.0).abs() < 1.0 && (mid.y - center.1).abs() < 1.0, "target → centre");
        let near_pt = cam.project_view_world(V3::new(0.5, 0.0, 2.0), center, 300.0);
        let far_pt = cam.project_view_world(V3::new(0.5, 0.0, -2.0), center, 300.0);
        assert!(
            (near_pt.x - center.0).abs() > (far_pt.x - center.0).abs(),
            "nearer projects wider (perspective, matching OrbitCamera)"
        );

        // a point behind the eye is culled (NEAR_PLANE matches OrbitCamera's).
        let behind = cam.project_view_world(cam.eye().add(cam.basis().0.scale(-1.0)), center, 300.0);
        assert!(!behind.visible, "behind-eye culled");
        assert_eq!(Camera::NEAR_PLANE, 0.02);

        // view_proj orders depth (nearer = smaller normalised clip-z) in [0,1].
        let m = cam.view_proj(800.0 / 600.0);
        let apply = |p: V3| {
            let v = [p.x, p.y, p.z, 1.0];
            let mut o = [0.0f32; 4];
            for row in 0..4 {
                let mut s = 0.0;
                for col in 0..4 {
                    s += m[col * 4 + row] * v[col];
                }
                o[row] = s;
            }
            o
        };
        let nr = apply(V3::new(0.0, 0.0, 2.0));
        let fr = apply(V3::new(0.0, 0.0, -2.0));
        assert!(nr[3] > 0.0 && fr[3] > 0.0, "both in front");
        let zn = nr[2] / nr[3];
        let zf = fr[2] / fr[3];
        assert!(zn < zf, "nearer smaller depth");
        assert!((0.0..=1.0).contains(&zn) && (0.0..=1.0).contains(&zf), "depths in [0,1]");
    }
}