Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-model3d/tests/clip_pose_sweep.rs

//! **THE CLIPPING-BUG FENCE** — a 360° yaw × zoom (pane) × tilt robot sweep over the
//! bundled glTF models, asserting the three things the reported bug broke:
//!
//! 1. **no pose leaks ink outside the viewport** (`ink_outside_rect == 0`),
//! 2. **no pose loses geometry it should keep** — measured as *pixel coverage of the
//!    model's own projected footprint*, which is the oracle that catches the defect
//!    the user actually saw, and
//! 3. **no pose is see-through** — the model paints a solid body, not a shell you
//!    look through.
//!
//! ## The bugs this fences
//!
//! **(a) Meshes were rendered INSIDE OUT.** A back-face cull tests the triangle's
//! index order; the lighting reads its authored vertex normal. When the two disagree
//! the viewer culls precisely the faces it should draw and lights the ones it keeps
//! backwards — the model renders inside out. The bundled procedural **skull** shipped
//! with **1156 of its 1288** triangles in that state, and the extruded rabbit logo
//! had **528 of 1040** (both whole ears, extruded from clockwise silhouette loops,
//! which rendered as hollow open troughs). `ink_outside_rect` is blind to this —
//! nothing leaked *out*, the wrong geometry was *kept* — which is exactly why the
//! leak oracle alone was not enough. Fixed at the source (`Rabbit::ear` winding) and
//! defensively at load ([`Mesh3d::repair_winding`](facett_model3d::Mesh3d::repair_winding));
//! [`bundled_meshes_have_no_inside_out_triangles`] is the fence.
//!
//! **(b) `epaint`'s miter join blew degenerate faces into pane-crossing spokes.**
//! `Shape::convex_polygon` feathers every filled path with a miter
//! (`epaint::tessellator::Path::add_line_loop` — `normal / length_sq`); for a
//! near-degenerate face `length_sq → 0` and the vertex is flung
//! `0.5 · feathering / sqrt(length_sq)` px away. Measured here: the raw projection
//! never left the rect at any of 1440 poses, yet the *tessellated* geometry escaped
//! at 1332 of them, worst overshoot **118510656 px**. The shipped lane rasterises a
//! z-buffer instead — no tessellator, no miter, no spoke.
//!
//! **(c) A painter's-algorithm centroid sort cannot order interpenetrating
//! surfaces.** It tore the cut Avocado's skin against its own interior into a
//! triangle-wide sawtooth. Replaced by a per-pixel software z-buffer.
//!
//! Run: `cargo test -p facett-model3d --test clip_pose_sweep -- --nocapture`

use egui::{Color32, Rect, pos2};
use facett_core::render::cpu::{Pose, ShadedTri, pose_grid, raster_zbuffer, sweep};
use facett_model3d::Model3D;

/// The zoom axis: a model viewer "zooms" by how much pane it is fitted to. A wide
/// deck pane, a tall phone pane, a square card, and a pane **smaller than the
/// viewer's own `MIN_W`/`MIN_H` floor** (the case that used to amputate the model).
fn viewports() -> Vec<Rect> {
    vec![
        Rect::from_min_size(pos2(8.0, 366.0), egui::vec2(1264.0, 444.0)), // the live deck pane
        Rect::from_min_size(pos2(0.0, 0.0), egui::vec2(320.0, 900.0)),    // tall / phone
        Rect::from_min_size(pos2(40.0, 40.0), egui::vec2(400.0, 400.0)),  // square card
        Rect::from_min_size(pos2(0.0, 0.0), egui::vec2(96.0, 64.0)),      // below the MIN floor
    ]
}

/// 72 yaws (every 5°) × 5 tilts × 4 viewports = 1440 poses per model.
fn poses() -> Vec<Pose> {
    pose_grid(72, &[-0.60, -0.30, 0.0, 0.30, 0.60], &viewports())
}

/// Non-transparent pixels a triangle list rasterises to, in a `w × h` buffer over
/// `rect`. The pixel currency both sides of the coverage oracle are counted in.
fn covered_px(tris: &[ShadedTri], rect: Rect, w: usize, h: usize) -> usize {
    raster_zbuffer(tris, w, h, rect.min, 1.0).iter().filter(|c| c.a() != 0).count()
}

/// **The "did the render lose geometry" oracle.** Rasterise what the pane actually
/// draws, and what the model's *full projected footprint* would be (every triangle,
/// culled by nothing), and return the ratio. A solid model covers its own footprint:
/// a closed shell does so with half the faces (the far half is hidden *behind*, not
/// missing), and an open shell only if it is drawn two-sided. `1.0` = nothing is
/// see-through; the culled half Avocado scored ≈ `0.5`.
fn footprint_coverage(m: &Model3D, p: &Pose) -> f32 {
    let (w, h) = (p.viewport.width().round() as usize, p.viewport.height().round() as usize);
    let drawn = covered_px(&m.project_tris(p.viewport, p.yaw, p.tilt), p.viewport, w, h);
    let all = covered_px(&m.project_footprint(p.viewport, p.yaw, p.tilt), p.viewport, w, h);
    if all == 0 { 1.0 } else { drawn as f32 / all as f32 }
}

fn sweep_model(name: &str, model: &Model3D) -> facett_core::render::cpu::SweepReport {
    let ps = poses();
    let mesh_tris = model.mesh().triangle_count();
    let report = sweep(&ps, |p| model.clip_stats(p.viewport, p.yaw, p.tilt));
    eprintln!(
        "[clip-sweep] {name} ({mesh_tris} mesh tris, watertight={}, boundary_edges={}) {}",
        model.mesh().is_watertight(),
        model.mesh().boundary_edges(),
        report.detail()
    );

    // ── 1. THE LEAK ORACLE: not one pose put ink outside its viewport. ────────
    assert_eq!(
        report.worst_ink.0, 0,
        "{name}: ink escaped the viewport at pose `{}` — the clipping bug is back",
        report.worst_ink.1
    );

    // ── 2. NO POSE LOSES GEOMETRY IT SHOULD KEEP. ─────────────────────────────
    let floor = mesh_tris / 5;
    assert!(
        report.min_visible.0 >= floor,
        "{name}: pose `{}` drew only {} of {mesh_tris} triangles (floor {floor}) — the clip \
         stage is eating real geometry",
        report.min_visible.1,
        report.min_visible.0
    );

    // ── 3. NO SPOKE: the worst face extent stays inside the viewport diagonal. ─
    let worst_diag = viewports()
        .iter()
        .map(|r| (r.width() * r.width() + r.height() * r.height()).sqrt())
        .fold(0.0f32, f32::max);
    assert!(
        report.worst_extent.0 <= worst_diag + 1.0,
        "{name}: a face spanned {:.1} px at pose `{}` — larger than the largest viewport \
         diagonal ({worst_diag:.1} px). That is a spoke.",
        report.worst_extent.0,
        report.worst_extent.1
    );

    // ── 4. THE FIT IS POSE-INVARIANT (bounding sphere, not a magic 0.42). ─────
    assert!(
        report.worst_raw_overshoot.0 <= 0.5,
        "{name}: the raw projection overshot the viewport by {:.1} px at pose `{}` — the \
         bounding-sphere fit is wrong",
        report.worst_raw_overshoot.0,
        report.worst_raw_overshoot.1
    );

    // ── 5. Every pose is lit. ─────────────────────────────────────────────────
    assert!(
        report.min_lit_fraction.0 > 0.5,
        "{name}: pose `{}` went dark (lit_fraction {:.2})",
        report.min_lit_fraction.1,
        report.min_lit_fraction.0
    );

    #[cfg(feature = "testmatrix")]
    facett_core::testmatrix::emit(
        "facett-model3d::Model3D",
        "clip_pose_sweep",
        report.worst_ink.0 == 0 && report.min_visible.0 >= floor,
        &format!("model={name} {}", report.detail()),
    );
    report
}

#[test]
fn avocado_leaks_no_ink_and_loses_no_geometry_across_360x_zoom_x_tilt() {
    let m = Model3D::avocado("avocado3d");
    let r = sweep_model("avocado", &m);
    assert_eq!(r.poses, 1440, "72 yaws × 5 tilts × 4 viewports");
}

#[test]
fn skull_leaks_no_ink_and_loses_no_geometry_across_360x_zoom_x_tilt() {
    let m = Model3D::skull("skull3d");
    sweep_model("skull", &m);
}

/// **THE ORACLE FOR "GEOMETRY WENT MISSING".** Sweep every pose and assert the render
/// covers the model's own projected footprint — i.e. the model paints as a *solid
/// body*, never a shell with part of it deleted. This is the measurement
/// `ink_outside_rect` cannot make: it catches faces that were wrongly *culled*, not
/// ink that escaped.
#[test]
fn no_pose_is_see_through() {
    // Every 15°, all five tilts, on the square card (a full pixel sweep is the
    // expensive axis — the defect is pose-wide, not viewport-specific).
    let vps = vec![Rect::from_min_size(pos2(40.0, 40.0), egui::vec2(400.0, 400.0))];
    let ps = pose_grid(24, &[-0.60, -0.30, 0.0, 0.30, 0.60], &vps);

    for (name, m) in [("avocado", Model3D::avocado("a")), ("skull", Model3D::skull("s"))] {
        let mut worst = (f32::MAX, String::new());
        for p in &ps {
            let c = footprint_coverage(&m, p);
            if c < worst.0 {
                worst = (c, p.label.clone());
            }
        }
        eprintln!(
            "[clip-sweep] {name}: worst footprint coverage {:.3} at `{}` (watertight={})",
            worst.0,
            worst.1,
            m.mesh().is_watertight()
        );
        assert!(
            worst.0 > 0.98,
            "{name}: pose `{}` covered only {:.1}% of the model's own projected footprint — you \
             can see through the model",
            worst.1,
            worst.0 * 100.0
        );
    }
}

/// **A measured NEGATIVE result, kept as a fence.** The bundled Avocado is an *open*
/// shell (a half avocado, 41 unpaired rim edges), and the textbook move for an open
/// shell is to draw it two-sided so you cannot see through it. That was implemented,
/// measured — and **it changed nothing**: from the open side the shell's own inner
/// surface is front-facing, so a single-sided cull already covers 100% of the
/// footprint at every yaw. The two-sided path was therefore removed rather than
/// shipped on a plausible-sounding but false rationale (it would have doubled the
/// raster work for no pixel).
///
/// This test pins that finding. If a future mesh (or a change to the cull) ever makes
/// single-sided culling actually lose footprint, this goes red and the two-sided path
/// becomes justified — with a number behind it.
#[test]
fn back_face_culling_an_open_shell_is_not_what_broke_it() {
    let m = Model3D::avocado("avocado3d");
    assert!(!m.mesh().is_watertight(), "the bundled Avocado is a HALF avocado — an open shell");
    assert_eq!(m.mesh().boundary_edges(), 41, "its flat cut rim is 41 unpaired (position-welded) edges");

    let rect = Rect::from_min_size(pos2(0.0, 0.0), egui::vec2(400.0, 400.0));
    let (w, h) = (400usize, 400usize);
    let mut worst_culled = 1.0f32;
    let mut worst_two_sided = 1.0f32;
    for i in 0..24 {
        let yaw = i as f32 * std::f32::consts::TAU / 24.0;
        let all = m.project_footprint(rect, yaw, 0.30);
        let total = covered_px(&all, rect, w, h);
        // The BUG lane: keep only the front faces, exactly as a single-sided cull would.
        let culled: Vec<ShadedTri> = all
            .iter()
            .copied()
            .filter(|t| {
                let p = t.pts;
                (p[1].x - p[0].x) * (p[2].y - p[0].y) - (p[1].y - p[0].y) * (p[2].x - p[0].x) < 0.0
            })
            .collect();
        let cc = covered_px(&culled, rect, w, h) as f32 / total as f32;
        let shipped = m.project_tris(rect, yaw, 0.30);
        let cs = covered_px(&shipped, rect, w, h) as f32 / total as f32;
        worst_culled = worst_culled.min(cc);
        worst_two_sided = worst_two_sided.min(cs);
    }
    eprintln!(
        "[clip-sweep] MEASURED: on this OPEN shell a single-sided cull still covers {:.1}% of the \
         footprint at its worst yaw (shipped lane {:.1}%) — two-sided drawing would buy nothing",
        worst_culled * 100.0,
        worst_two_sided * 100.0
    );
    assert!(
        worst_culled > 0.98,
        "single-sided culling does NOT lose this open shell's footprint (worst {:.1}%); if this \
         ever drops, the two-sided path is justified and should come back",
        worst_culled * 100.0
    );
    assert!(worst_two_sided > 0.98, "the shipped lane keeps the model solid");

    #[cfg(feature = "testmatrix")]
    facett_core::testmatrix::emit(
        "facett-model3d::Model3D",
        "open_shell_cull_coverage",
        worst_culled > 0.98,
        &format!(
            "boundary_edges=41 single_sided_worst_coverage={:.3} shipped_worst_coverage={:.3}",
            worst_culled, worst_two_sided
        ),
    );
}

/// **RED-when-broken control for the spoke bug.** The old `Shape::convex_polygon`
/// lane leaked at most poses by an absurd amount; the shipped z-buffer lane cannot
/// leak at all (every write is bounds-clamped to the rect).
#[test]
fn the_old_convex_polygon_lane_really_did_leak() {
    let m = Model3D::avocado("avocado3d");
    let ps = poses();
    let mut leaking_poses = 0usize;
    let mut worst_old = 0.0f32;
    let mut worst_old_where = String::new();
    let mut worst_new = 0usize;
    for p in &ps {
        let tris = m.project_tris(p.viewport, p.yaw, p.tilt);
        let mut tess =
            epaint::Tessellator::new(1.0, epaint::TessellationOptions::default(), [16, 16], vec![]);
        let mut mesh = epaint::Mesh::default();
        for t in &tris {
            tess.tessellate_shape(
                epaint::Shape::convex_polygon(t.pts.to_vec(), t.col, epaint::Stroke::NONE),
                &mut mesh,
            );
        }
        let mut leaked = false;
        for v in &mesh.vertices {
            let over = (p.viewport.left() - v.pos.x)
                .max(v.pos.x - p.viewport.right())
                .max(p.viewport.top() - v.pos.y)
                .max(v.pos.y - p.viewport.bottom());
            if over > 1e-2 {
                leaked = true;
            }
            if over > worst_old {
                worst_old = over;
                worst_old_where = p.label.clone();
            }
        }
        if leaked {
            leaking_poses += 1;
        }
        worst_new = worst_new.max(m.clip_stats(p.viewport, p.yaw, p.tilt).ink_outside_rect);
    }
    eprintln!(
        "[clip-sweep] SENSITIVITY (spokes): old convex_polygon lane leaked at {leaking_poses}/{} \
         poses, worst overshoot {worst_old:.1} px at `{worst_old_where}`; shipped lane worst ink {worst_new}",
        ps.len()
    );
    assert!(
        leaking_poses > ps.len() / 10,
        "the bug lane must leak at many poses (it leaked at {leaking_poses}/{})",
        ps.len()
    );
    assert!(worst_old > 100.0, "the bug lane's worst spoke must be a real blow-up ({worst_old:.1} px)");
    assert_eq!(worst_new, 0, "the shipped lane leaks nothing at the very poses that used to leak");

    #[cfg(feature = "testmatrix")]
    facett_core::testmatrix::emit(
        "facett-model3d::Model3D",
        "clip_sensitivity",
        leaking_poses > 0 && worst_new == 0,
        &format!(
            "bug_lane_leaking_poses={leaking_poses}/{} bug_lane_worst_overshoot_px={worst_old:.1} \
             fixed_lane_worst_ink=0",
            ps.len()
        ),
    );
}

/// The loaded meshes are sound: no triangle's geometric winding contradicts its own
/// authored normal (that is the defect that made the rabbit's ears hollow).
#[test]
fn bundled_meshes_have_no_inside_out_triangles() {
    for (name, m) in [("avocado", Model3D::avocado("a")), ("skull", Model3D::skull("s"))] {
        let bad = m.mesh().inside_out_triangles();
        eprintln!(
            "[clip-sweep] {name}: inside_out_triangles={bad}/{} boundary_edges={}",
            m.mesh().triangle_count(),
            m.mesh().boundary_edges()
        );
        assert_eq!(bad, 0, "{name}: {bad} triangles are wound inside-out after load-time repair");
    }
}

/// A pane **smaller than the viewer's `MIN_W`/`MIN_H` floor** used to allocate a rect
/// bigger than the host left it, so egui cropped the paint and the model was
/// amputated. Fitting on `rect ∩ clip_rect` keeps the whole solid inside the sliver
/// that is actually on screen.
#[test]
fn a_pane_below_the_min_floor_still_frames_the_whole_model() {
    let m = Model3D::avocado("avocado3d");
    let tiny = Rect::from_min_size(pos2(0.0, 0.0), egui::vec2(96.0, 64.0));
    let s = m.clip_stats(tiny, 1.1, 0.3);
    assert_eq!(s.ink_outside_rect, 0, "nothing escapes a below-minimum pane");
    assert!(s.raw_overshoot_px <= 0.5, "the model is fitted INSIDE it, not cropped: {s:?}");
    assert!(s.visible_triangles > 100, "and it is still a whole model: {}", s.visible_triangles);
    let big = Rect::from_min_size(pos2(0.0, 0.0), egui::vec2(960.0, 640.0));
    let sb = m.clip_stats(big, 1.1, 0.3);
    assert!(
        sb.max_extent_px > s.max_extent_px * 5.0,
        "the fit scales with the viewport: tiny {:.1} px vs big {:.1} px",
        s.max_extent_px,
        sb.max_extent_px
    );
}

/// Sanity that the sweep drives real colour: the status tint reaches the drawn
/// triangles (green at healthy, red at critical), so a pose sweep that went all-black
/// could not pass the lit assertions by accident.
#[test]
fn the_status_tint_reaches_the_drawn_triangles() {
    let rect = Rect::from_min_size(pos2(0.0, 0.0), egui::vec2(400.0, 400.0));
    let green = Model3D::avocado("a").with_status(1.0).project_tris(rect, 1.1, 0.3);
    let red = Model3D::avocado("a").with_status(0.0).project_tris(rect, 1.1, 0.3);
    let mean = |ts: &[ShadedTri], ch: fn(&Color32) -> u8| {
        ts.iter().map(|t| u32::from(ch(&t.col))).sum::<u32>() as f32 / ts.len().max(1) as f32
    };
    assert!(mean(&green, |c| c.g()) > mean(&green, |c| c.r()), "healthy tints the fruit green");
    assert!(mean(&red, |c| c.r()) > mean(&red, |c| c.g()), "critical tints the fruit red");
}