facett-core 0.1.15

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
//! **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 (no heavy linear-algebra dep — this is all the math the
/// camera needs and keeps the crate lean / deterministic). Mirrors
/// `facett-map3d::camera::V3`.
#[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)
    }
}

/// 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();
        V3::new(e.x, e.y, e.z)
    }

    /// 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();
        (V3::new(f.x, f.y, f.z), V3::new(r.x, r.y, r.z), V3::new(u.x, u.y, u.z))
    }

    /// 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(glam::Vec3::new(p.x, p.y, p.z));
        V3::new(c.x, c.y, c.z)
    }

    /// 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(glam::Vec3::new(c.x, c.y, c.z), 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]");
    }
}