facett-core 0.1.7

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.
    #[inline]
    pub fn project2d(&self, p: Pos) -> (f32, f32) {
        (p.x * self.zoom + self.pan_x, p.y * self.zoom + self.pan_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 eye position, derived from `target` + spherical offset.
    pub fn eye(&self) -> V3 {
        let (se, ce) = self.elevation.sin_cos();
        let (sa, ca) = self.azimuth.sin_cos();
        let offset = V3::new(ce * sa, se, ce * ca).scale(self.distance);
        self.target.add(offset)
    }

    /// Forward (eye → target), right, and up basis vectors of the view.
    pub fn basis(&self) -> (V3, V3, V3) {
        let fwd = self.target.sub(self.eye()).normalized();
        let world_up = V3::new(0.0, 1.0, 0.0);
        let right = fwd.cross(world_up).normalized();
        let up = right.cross(fwd).normalized();
        (fwd, right, up)
    }

    /// 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 (fwd, right, up) = self.basis();
        let rel = p.sub(self.eye());
        V3::new(rel.dot(right), rel.dot(up), rel.dot(fwd))
    }

    /// 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 {
        if c.z <= Self::NEAR_PLANE || c.z >= self.far_plane() {
            return Projected { x: center.0, y: center.1, depth: c.z, visible: false };
        }
        let focal = half_h / (self.fov_y * 0.5).tan();
        let sx = center.0 + (c.x / c.z) * focal;
        let sy = center.1 - (c.y / c.z) * focal;
        Projected { x: sx, y: sy, depth: c.z, visible: true }
    }

    /// 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 — the same
    /// look-at + perspective as `view_space`/`project_view`, as a column-major
    /// `[f32; 16]` for a wgpu uniform. Mirrors `OrbitCamera::view_proj`.
    pub fn view_proj(&self, aspect: f32) -> [f32; 16] {
        let (fwd, right, up) = self.basis();
        let eye = self.eye();
        let tx = -right.dot(eye);
        let ty = -up.dot(eye);
        let tz = -fwd.dot(eye);

        let near = (self.distance - 2.0).max(0.05);
        let far = self.distance + 4.0;
        let f = 1.0 / (self.fov_y * 0.5).tan();
        let sx = f / aspect.max(1e-6);
        let sy = f;
        let a = far / (far - near);
        let b = -far * near / (far - near);

        let mut m = [0.0f32; 16];
        let set = |m: &mut [f32; 16], row: usize, col: usize, v: f32| m[col * 4 + row] = v;
        set(&mut m, 0, 0, sx * right.x);
        set(&mut m, 0, 1, sx * right.y);
        set(&mut m, 0, 2, sx * right.z);
        set(&mut m, 0, 3, sx * tx);
        set(&mut m, 1, 0, sy * up.x);
        set(&mut m, 1, 1, sy * up.y);
        set(&mut m, 1, 2, sy * up.z);
        set(&mut m, 1, 3, sy * ty);
        set(&mut m, 2, 0, a * fwd.x);
        set(&mut m, 2, 1, a * fwd.y);
        set(&mut m, 2, 2, a * fwd.z);
        set(&mut m, 2, 3, a * tz + b);
        set(&mut m, 3, 0, fwd.x);
        set(&mut m, 3, 1, fwd.y);
        set(&mut m, 3, 2, fwd.z);
        set(&mut m, 3, 3, tz);
        m
    }
}

#[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): the moved orbit math reproduces map3d's
    /// `OrbitCamera` outputs. We compute the same eye/view_space/project_view/
    /// view_proj here with a hand-rolled reference (the exact OrbitCamera formulas)
    /// and assert bit-equality, so the seam is a faithful superset.
    #[test]
    fn camera_seam_3d_matches_orbit_camera_math() {
        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]");
    }
}