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");
}