facett-core 0.1.12

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
//! **L0 geometry + state coverage** — additive tests over the public L0 kernel API
//! that pin behaviour the per-module unit tests don't already cover:
//!
//! - the [`quad_coverage`] shape dispatch for **every** [`shape`] tag (the inline
//!   test exercises circle/ring/diamond; here square + triangle + the unknown-tag
//!   `_ => 0.0` arm),
//! - [`point_segment`] — the distance-to-segment + clamped `t` helper the thick-line
//!   raster and any edge **hit-test** funnel through (only exercised indirectly by
//!   `line_coverage` today),
//! - the [`camera::V3`] vector algebra (`cross`/`dot`/`len`/`normalized`) that
//!   `Camera::basis`/`eye` are built from — pinned directly, not just via the seam,
//! - the 2D pan/zoom affine + its analytic inverse (screen→world **picking** math),
//! - [`Frame::lit_px`] — the "did it draw" oracle — on transparent vs mixed frames,
//! - the [`decor`] node/interaction geometry helpers (`glow_halo`, `ao_shadow`,
//!   `shockwave_ring`, `lerp_rgba`) whose shapes/clamps the inline tests reach only
//!   through the batch helpers.
//!
//! All pure CPU math — no GPU, no `wgpu`/`l1-vello` feature — so this runs headless
//! on the default build (the L0-stands-alone promise).

use facett_core::render::camera::{self, Camera, Pos, V3};
use facett_core::render::cpu::sdf::{point_segment, quad_coverage};
use facett_core::render::decor;
use facett_core::render::prim::{shape, MarkerInstance};
use facett_core::render::Frame;

// ── SDF dispatch: the two shapes + the unknown arm the inline test skips ───────

#[test]
fn quad_coverage_dispatches_square_triangle_and_rejects_unknown() {
    let mk = |s: u32| MarkerInstance {
        center: [0.0; 2],
        radius: 10.0,
        corner: 0.0,
        color: [1.0; 4],
        aa: 0.5,
        shape: s,
    }
    .lower();

    // SQUARE fills its corner (|8|,|8| is inside a half=10 axis-aligned box) …
    let sq = mk(shape::SQUARE);
    assert!(quad_coverage(&sq, 0.0, 0.0) > 0.99, "square centre lit");
    assert!(quad_coverage(&sq, 8.0, 8.0) > 0.9, "square fills its corner");
    assert!(quad_coverage(&sq, 14.0, 0.0) < 0.01, "square dark past the edge+aa");

    // … TRIANGLE (apex up) is lit low-centre, dark at the top corners.
    let tri = mk(shape::TRIANGLE);
    assert!(quad_coverage(&tri, 0.0, 7.0) > 0.9, "triangle base-centre lit");
    assert!(quad_coverage(&tri, 9.0, -9.0) < 0.1, "triangle top-corner dark");

    // An unknown shape tag hits the `_ => 0.0` arm → never draws (defensive default).
    let mut bogus = sq;
    bogus.shape = 999;
    assert_eq!(quad_coverage(&bogus, 0.0, 0.0), 0.0, "unknown shape draws nothing");
}

// ── point_segment: the edge hit-test / distance kernel ─────────────────────────

#[test]
fn point_segment_gives_distance_and_clamped_parameter() {
    let a = [0.0, 0.0];
    let b = [10.0, 0.0];

    // A point above the middle: nearest point is the foot of the perpendicular at
    // t=0.5, squared distance = 3^2 = 9.
    let (d2, t) = point_segment([5.0, 3.0], a, b);
    assert!((d2 - 9.0).abs() < 1e-4, "squared distance to the segment, got {d2}");
    assert!((t - 0.5).abs() < 1e-6, "foot at the segment midpoint, got {t}");

    // On the segment → zero distance.
    let (d2on, ton) = point_segment([7.0, 0.0], a, b);
    assert!(d2on < 1e-6 && (ton - 0.7).abs() < 1e-6, "on-segment: d=0, t=0.7");

    // Past an endpoint: t clamps to [0,1] (a butt cap reads t==1 to reject the
    // overshoot); distance is measured to the endpoint, not the infinite line.
    let (d2past, tpast) = point_segment([20.0, 0.0], a, b);
    assert_eq!(tpast, 1.0, "t clamps at the far endpoint");
    assert!((d2past - 100.0).abs() < 1e-4, "distance measured to endpoint b (10 px away)");
    let (_, tbefore) = point_segment([-5.0, 0.0], a, b);
    assert_eq!(tbefore, 0.0, "t clamps at the near endpoint");

    // Degenerate zero-length segment: t is defined (0), distance is to the point.
    let (d2deg, tdeg) = point_segment([3.0, 4.0], [1.0, 1.0], [1.0, 1.0]);
    assert_eq!(tdeg, 0.0, "zero-length segment → t=0 (no divide-by-zero)");
    // (3-1)^2 + (4-1)^2 = 4 + 9 = 13.
    assert!((d2deg - 13.0).abs() < 1e-4, "distance to the degenerate point, got {d2deg}");
}

// ── V3 algebra: the building blocks of Camera::basis/eye ───────────────────────

#[test]
fn v3_cross_is_right_handed_and_dot_len_normalize_agree() {
    let x = V3::new(1.0, 0.0, 0.0);
    let y = V3::new(0.0, 1.0, 0.0);
    let z = V3::new(0.0, 0.0, 1.0);

    // Right-hand rule: x × y = +z (the handedness Camera::basis relies on so
    // `right = fwd × up` points the expected way).
    let c = x.cross(y);
    assert_eq!((c.x, c.y, c.z), (0.0, 0.0, 1.0), "x × y = z (right-handed)");
    let c2 = y.cross(x);
    assert_eq!((c2.x, c2.y, c2.z), (0.0, 0.0, -1.0), "y × x = -z (anticommutes)");

    // Orthonormal basis: pairwise dots are 0, self-dot is 1.
    assert_eq!(x.dot(y), 0.0);
    assert_eq!(z.dot(z), 1.0);

    // len + normalized: a (3,4,0) vector has length 5 and normalises to a unit.
    let v = V3::new(3.0, 4.0, 0.0);
    assert!((v.len() - 5.0).abs() < 1e-6, "3-4-5 length");
    let u = v.normalized();
    assert!((u.len() - 1.0).abs() < 1e-6, "normalized is unit length");
    assert!((u.x - 0.6).abs() < 1e-6 && (u.y - 0.8).abs() < 1e-6, "direction preserved");

    // A zero vector normalises without NaN (the 1e-9 guard) — a degenerate basis
    // input must not poison the camera.
    let zn = V3::new(0.0, 0.0, 0.0).normalized();
    assert!(zn.x.is_finite() && zn.y.is_finite() && zn.z.is_finite(), "no NaN from zero vec");

    // add/sub/scale round-trip.
    let a = V3::new(1.0, 2.0, 3.0);
    let b = V3::new(4.0, -1.0, 0.5);
    let back = a.add(b).sub(b);
    assert_eq!((back.x, back.y, back.z), (a.x, a.y, a.z), "add then sub is identity");
    let s = a.scale(2.0);
    assert_eq!((s.x, s.y, s.z), (2.0, 4.0, 6.0));
}

// ── 2D pan/zoom projection + its inverse (screen→world picking) ────────────────

#[test]
fn camera_2d_affine_is_invertible_for_picking() {
    let cam = Camera { pan_x: 30.0, pan_y: -12.0, zoom: 2.5, ..Camera::default() };
    let world = Pos::new(10.0, 4.0);
    let (sx, sy) = cam.project2d(world);

    // The analytic inverse a click→world picker uses: (screen - pan) / zoom. Round
    // a projected point back and it lands on the original world coordinate.
    let (wx, wy) = ((sx - cam.pan_x) / cam.zoom, (sy - cam.pan_y) / cam.zoom);
    assert!((wx - world.x).abs() < 1e-4 && (wy - world.y).abs() < 1e-4, "project→unproject round-trips");

    // A fractional zoom (zoomed out) still round-trips — the picker holds at any scale.
    let cam2 = Camera { pan_x: -5.0, pan_y: 100.0, zoom: 0.25, ..Camera::default() };
    let (sx2, sy2) = cam2.project2d(Pos::new(400.0, -80.0));
    let (wx2, wy2) = ((sx2 - cam2.pan_x) / cam2.zoom, (sy2 - cam2.pan_y) / cam2.zoom);
    assert!((wx2 - 400.0).abs() < 1e-3 && (wy2 + 80.0).abs() < 1e-3, "fractional-zoom round-trip");
}

// ── 3D orbit invariants (public superset math) ─────────────────────────────────

#[test]
fn camera_eye_orbits_at_distance_and_far_plane_pads_it() {
    // The eye sits on a sphere of radius `distance` around the target, at any
    // azimuth/elevation (the turntable invariant).
    for &(az, el) in &[(0.0f32, 0.0f32), (0.7, 0.3), (-1.2, -0.9), (3.0, 1.4)] {
        let cam = Camera {
            azimuth: az,
            elevation: el,
            distance: 5.0,
            target: V3::new(1.0, -2.0, 0.5),
            ..Camera::default()
        };
        let r = cam.eye().sub(cam.target).len();
        assert!((r - 5.0).abs() < 1e-3, "eye radius == distance at ({az},{el}), got {r}");
    }
    // far_plane pads the dolly radius (mirrors OrbitCamera::far_plane = distance+4).
    let cam = Camera { distance: 6.0, ..Camera::default() };
    assert!((cam.far_plane() - 10.0).abs() < 1e-6, "far plane = distance + 4");
    assert_eq!(Camera::NEAR_PLANE, 0.02);
    let _ = camera::InputFeel::default(); // the FEEL type is part of the public seam.
}

// ── Frame::lit_px oracle ───────────────────────────────────────────────────────

#[test]
fn frame_lit_px_counts_only_nonzero_alpha() {
    // A fully transparent frame lit nothing.
    let clear = Frame { width: 2, height: 2, rgba: vec![0; 2 * 2 * 4] };
    assert_eq!(clear.lit_px(), 0, "transparent frame → 0 lit");

    // Mixed: 2 of 4 pixels carry alpha (colour with a==0 does NOT count — the oracle
    // keys off alpha, the "did the compositor put ink here" signal).
    let mixed = Frame {
        width: 2,
        height: 2,
        rgba: vec![
            255, 0, 0, 255, // lit
            9, 9, 9, 0, // opaque-looking colour but a==0 → not lit
            0, 0, 0, 1, // barely lit
            0, 0, 0, 0, // clear
        ],
    };
    assert_eq!(mixed.lit_px(), 2, "only the two non-zero-alpha pixels count");
}

// ── decor geometry helpers (shapes + clamps) ───────────────────────────────────

#[test]
fn decor_node_helpers_have_the_right_geometry() {
    // glow_halo: a ring wider than the marker, hole just outside it, alpha clamped
    // into [0,1], hue carried from the node colour.
    let halo = decor::glow_halo([40.0, 40.0], 8.0, [0.2, 0.8, 1.0], 1.7 /*>1 → clamps*/, 2.1);
    assert!(halo.radius > 8.0 && halo.inner >= 8.0, "halo blooms outside the marker");
    assert!(halo.inner < halo.radius, "valid annulus");
    assert_eq!(halo.color[3], 1.0, "intensity clamped to 1.0");
    assert!((halo.color[2] - 1.0).abs() < 1e-6, "carries the node blue");

    // ao_shadow: a dark disc offset down-right (drop-shadow), larger than the marker,
    // alpha (depth) clamped.
    let ao = decor::ao_shadow([40.0, 40.0], 10.0, 1.5 /*>1 → clamps*/);
    assert!(ao.center[0] > 40.0 && ao.center[1] > 40.0, "shadow offset down-right");
    assert!(ao.radius > 10.0, "shadow spreads past the marker");
    assert!(ao.color[0] < 0.05 && ao.color[3] == 1.0, "black, depth clamped to 1.0");
}

#[test]
fn decor_ramps_and_shockwave_clamp() {
    // lerp_rgba: endpoints exact, midpoint blends, out-of-range t clamps.
    let a = [0.0, 0.0, 0.0, 1.0];
    let b = [1.0, 0.5, 0.25, 0.0];
    assert_eq!(decor::lerp_rgba(a, b, 0.0), a);
    assert_eq!(decor::lerp_rgba(a, b, 1.0), b);
    assert_eq!(decor::lerp_rgba(a, b, 2.0), b, "t clamps above 1");
    assert_eq!(decor::lerp_rgba(a, b, -1.0), a, "t clamps below 0");
    let mid = decor::lerp_rgba(a, b, 0.5);
    assert!((mid[0] - 0.5).abs() < 1e-6 && (mid[3] - 0.5).abs() < 1e-6, "midpoint blends");

    // shockwave_ring: the inner radius never goes negative even for a tiny start ring
    // whose thickness exceeds it (the `.max(0.0)` clamp), and alpha fades to ~0 at t=1.
    let tiny = decor::shockwave_ring([50.0, 50.0], 0.0, 5.0, [1.0, 0.4, 0.9], 0.0);
    assert!(tiny.inner >= 0.0, "inner radius clamped to >= 0 (valid annulus)");
    let gone = decor::shockwave_ring([50.0, 50.0], 10.0, 100.0, [1.0, 0.4, 0.9], 1.0);
    assert!(gone.color[3] < 1e-3, "faded out at t=1");
    assert!(gone.radius > 100.0, "expanded to r0 + reach at t=1");
}