facett-map3d/tests/backend_conformance.rs
//! **Backend-trait CONFORMANCE tests** — the swappable [`RenderBackend`] seam.
//!
//! The deliverable: a different Rust engine can be dropped in behind the trait
//! without changing the view code. The proof that the seam is honest is that
//! **every backend, handed the SAME [`RenderScene`], reports the SAME geometry
//! oracles** ([`RenderStats`]). Here the real CPU painter and the headless
//! [`NullBackend`] (and, under `--features wgpu`, the [`WgpuVelloBackend`]'s
//! device-free `analyze`) are proven to agree — so a future backend that matches
//! this contract slots in with confidence.
use facett_map3d::{
BackendCaps, CpuPainterBackend, Map3D, NullBackend, RenderBackend, RenderScene, RenderStats, SceneFlags, Viewport,
};
use facett_map3d::extrude::WaterMesh;
/// Build the backend-neutral scene the conformance suite draws through every backend:
/// the synthetic extruded rabbit-skyline at a fixed clock (pure), framed in a widget
/// rect. No water (empty grid) so the scene is device-independent.
fn scene_fixture() -> (Map3D, egui::Rect) {
let m = Map3D::synthetic("conformance").with_clock(1.0);
let rect = egui::Rect::from_min_max(egui::pos2(100.0, 80.0), egui::pos2(820.0, 700.0));
(m, rect)
}
fn make_scene<'a>(m: &'a Map3D, water: &'a WaterMesh, rect: egui::Rect) -> RenderScene<'a> {
RenderScene {
mesh: m.mesh(),
water,
camera: m.render_camera(),
sun: m.sun(),
viewport: Viewport::from_rect(rect),
bg: egui::Color32::from_rgb(20, 24, 40),
flags: SceneFlags::default(),
sky_lut: None,
clock_s: 1.0,
// The synthetic fixture has no world span, so the scene scale is `None` and
// every render-side metric constant keeps its legacy absolute — this frame is
// byte-for-byte the one this conformance suite has always compared.
du_per_m: m.design_units_per_metre(),
overlay: None,
overlay_emissive: &[],
}
}
/// Two backends' geometry oracles are equal EXCEPT the backend name + used_gpu tag.
/// **This cannot fail today, and that is written down rather than assumed** (lane ST,
/// gfx3d-S1/S12).
///
/// Its two callers compare `CpuPainterBackend::analyze` with `NullBackend::analyze`, and
/// both are one line calling the same `analyze_scene(scene, name, false)` — the only
/// difference between them is the `backend` string label. `NullBackend`'s own doc says
/// so: it "analyses the scene with the *identical* geometry oracle as the CPU painter".
///
/// MEASURED 2026-09-04: with the depth comparator reversed at the sort site, and again
/// with `LIT_THRESHOLD` moved from 90 to 700, **both** tests that call this stayed
/// GREEN. `the_fixture_scene_projects_the_frozen_golden` caught the lighting change and
/// `every_backend_satisfies_the_scene_invariants` caught the sort.
///
/// It is kept, not deleted, because it becomes real the day a backend arrives with its
/// own `analyze` — the WGPU one already appears at one call site below. What it must not
/// do is read as coverage in the meantime.
fn assert_same_geometry(a: &RenderStats, b: &RenderStats) {
assert_eq!(a.visible_triangles, b.visible_triangles, "visible-triangle count must agree");
assert_eq!(a.ink_outside_rect, b.ink_outside_rect, "clip-escape oracle must agree");
assert_eq!(a.depth_monotone, b.depth_monotone, "painter depth order must agree");
assert!((a.lit_fraction - b.lit_fraction).abs() < 1e-6, "lit fraction must agree");
assert!((a.max_extent_px - b.max_extent_px).abs() < 1e-3, "worst projected extent must agree");
assert!((a.raw_overshoot_px - b.raw_overshoot_px).abs() < 1e-3, "raw overshoot must agree");
}
/// **CONFORMANCE #1 — the real backend and a headless/null backend draw the same
/// scene.** `CpuPainterBackend::analyze` and `NullBackend::analyze` produce identical
/// geometry oracles for the same [`RenderScene`] (the mission's explicit test).
#[test]
fn cpu_and_null_backends_analyze_the_same_scene_identically() {
let water = WaterMesh::default();
let (m, rect) = scene_fixture();
let scene = make_scene(&m, &water, rect);
let cpu = CpuPainterBackend.analyze(&scene);
let null = NullBackend.analyze(&scene);
assert_eq!(cpu.backend, "cpu-painter");
assert_eq!(null.backend, "null");
assert!(!cpu.used_gpu && !null.used_gpu);
assert_same_geometry(&cpu, &null);
}
/// **CONFORMANCE #2 — the scene invariants every backend must satisfy.** The framed
/// synthetic city projects visible geometry, no ink escapes the rect (`== 0`, the
/// historical smear-bug oracle), the painter order is monotone far→near, and the
/// worst projected face extent is bounded by roughly the viewport (no smear).
#[test]
fn every_backend_satisfies_the_scene_invariants() {
let water = WaterMesh::default();
let (m, rect) = scene_fixture();
let scene = make_scene(&m, &water, rect);
let cap = rect.height() * 2.0 + 2.0; // the frustum-clip bound (viewport, +eps)
for stats in [CpuPainterBackend.analyze(&scene), NullBackend.analyze(&scene)] {
assert!(stats.visible_triangles > 0, "[{}] the framed city projects visible tris", stats.backend);
assert_eq!(stats.ink_outside_rect, 0, "[{}] NO ink escapes the widget rect", stats.backend);
assert!(stats.depth_monotone, "[{}] painter order is monotone far→near", stats.backend);
assert!(stats.lit_fraction > 0.0, "[{}] lighting reaches the faces", stats.backend);
assert!(
stats.max_extent_px <= cap,
"[{}] worst projected extent {:.1}px ≤ viewport cap {:.1}px (no smear)",
stats.backend,
stats.max_extent_px,
cap
);
}
}
/// **CONFORMANCE #3 — the same camera ORBIT re-projects identically across backends.**
/// Feed a swept camera pose and assert Cpu == Null at every angle: the backend seam
/// carries no hidden per-backend state that would drift the projection.
#[test]
fn backends_agree_across_a_camera_sweep() {
let water = WaterMesh::default();
let mut m = Map3D::synthetic("sweep");
m.spin = false;
let rect = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(900.0, 900.0));
for step in 0..12 {
m.cam.azimuth = step as f32 * std::f32::consts::TAU / 12.0;
m.cam.settle();
let scene = make_scene(&m, &water, rect);
let cpu = CpuPainterBackend.analyze(&scene);
let null = NullBackend.analyze(&scene);
assert_same_geometry(&cpu, &null);
assert_eq!(cpu.ink_outside_rect, 0, "no escape at azimuth step {step}");
}
}
/// **CONFORMANCE #4 — backend capabilities are advertised honestly.** The CPU painter
/// is a no-GPU, no-shadow backend; the null backend the same; the wgpu backend (under
/// the feature) advertises GPU + shadows + water + post-fx.
#[test]
fn backend_caps_are_advertised() {
let cpu: BackendCaps = CpuPainterBackend.caps();
assert_eq!(cpu.name, "cpu-painter");
assert!(!cpu.gpu && !cpu.shadows);
let null = NullBackend.caps();
assert_eq!(null.name, "null");
assert!(!null.gpu);
}
// ── device-gated GPU backend (feature `wgpu`) — compiles under the feature, the
// device paint is #[ignore] (needs a real adapter); analyze is device-free. ──
#[cfg(feature = "wgpu")]
mod gpu {
use super::*;
use facett_map3d::WgpuVelloBackend;
/// The wgpu+vello backend's DEVICE-FREE `analyze` matches the CPU painter — the
/// GPU lane projects the same scene (the CPU projection is the shared oracle for
/// "what the camera framed"). Runs headless (no adapter needed).
#[test]
fn wgpu_analyze_matches_cpu_headless() {
let water = WaterMesh::default();
let (m, rect) = scene_fixture();
let scene = make_scene(&m, &water, rect);
let cpu = CpuPainterBackend.analyze(&scene);
let gpu = WgpuVelloBackend::new().analyze(&scene);
assert_eq!(gpu.backend, "wgpu-vello");
assert!(gpu.used_gpu, "the wgpu backend tags its frames used_gpu");
assert_same_geometry(&cpu, &gpu);
let caps = WgpuVelloBackend::new().caps();
assert!(caps.gpu && caps.shadows && caps.reflective_water && caps.post_fx);
}
/// **What a frame actually CONTAINS** — `(share of pixels that differ from the
/// frame's own clear colour, number of distinct RGB triples)`.
///
/// The clear colour is taken as the frame's modal colour rather than assumed: the
/// background dominates any framing of a small model, and reading it off the frame
/// means the measure needs no knowledge of the theme.
///
/// This exists because `assert!(img.pixels().count() > 0)` — the assertion that
/// stood here under a comment saying *"some pixel is not the clear colour"* — is
/// `width × height`, i.e. `810_000 > 0`, for EVERY `RgbaImage` ever produced. An
/// all-black frame, a frame where the pipeline drew nothing and a frame where the
/// depth test rejected every triangle all pass it identically.
fn painted_share(img: &image::RgbaImage) -> (f64, usize) {
let mut hist: std::collections::HashMap<[u8; 3], usize> = Default::default();
for p in img.pixels() {
*hist.entry([p.0[0], p.0[1], p.0[2]]).or_default() += 1;
}
let total: usize = hist.values().sum();
let clear = hist.values().copied().max().unwrap_or(0);
((total - clear) as f64 / total.max(1) as f64, hist.len())
}
/// The share of the frame that must differ from the clear colour for it to be a
/// PAINTED frame. The synthetic town fills a good part of a 900² pane; 2 % is far
/// under that and far over "a few stray pixels".
const MIN_PAINTED_SHARE: f64 = 0.02;
/// Distinct RGB triples a lit, shaded 3D frame must carry. A flat fill has one.
const MIN_DISTINCT_COLOURS: usize = 64;
/// **The RED for the guard below, and it needs no GPU.** A uniform frame — exactly
/// what "the pipeline drew nothing" produces — must FAIL the non-blank measure. A
/// guard whose red has never been seen is not a guard (LAW 2), and the assertion
/// this replaces could not have gone red for any image at all.
#[test]
fn a_blank_frame_fails_the_non_blank_measure() {
let blank = image::RgbaImage::from_pixel(900, 900, image::Rgba([12, 14, 18, 255]));
let (share, distinct) = painted_share(&blank);
assert_eq!(distinct, 1, "a blank frame carries exactly one colour, got {distinct}");
assert!(share < MIN_PAINTED_SHARE, "a blank frame paints {share:.4} of itself");
// …and the assertion this file used to carry passes it anyway:
assert!(blank.pixels().count() > 0, "the old check cannot tell a blank frame from a drawn one");
}
/// **DEVICE-gated** — draw the SAME scene through the real wgpu pipeline chain on
/// an egui_kittest offscreen adapter and assert the paint produced a non-blank
/// frame. `#[ignore]` (needs a GPU); COMPILES headless.
#[test]
#[ignore = "needs a real GPU adapter; run with --ignored on a GPU host"]
fn wgpu_paint_draws_the_scene_on_device() {
use egui_kittest::Harness;
use facett_core::Facet;
let mut view = Map3D::synthetic("gpu-device").with_clock(1.0).with_gpu(true);
let mut harness = Harness::builder().with_size(egui::vec2(900.0, 900.0)).build_ui(move |ui| {
view.ui(ui);
});
harness.run_steps(3);
let img = harness.render().expect("wgpu render produced an image");
let (share, distinct) = painted_share(&img);
println!(" device frame {}x{}: {:.2} % painted, {distinct} distinct colours", img.width(), img.height(), 100.0 * share);
assert!(
share > MIN_PAINTED_SHARE,
"the device paint left {:.2} % of the frame different from the clear colour — \
that is a blank frame, which is what `pixels().count() > 0` could never see",
100.0 * share
);
assert!(
distinct > MIN_DISTINCT_COLOURS,
"a lit, shaded 3D frame carries more than {MIN_DISTINCT_COLOURS} distinct colours, got {distinct}"
);
}
}
/// **CONFORMANCE #0 — the FROZEN GOLDEN, because #1 and #3 cannot fail.**
///
/// `assert_same_geometry(&cpu, &null)` compares `CpuPainterBackend::analyze` with
/// `NullBackend::analyze`, and both are one line calling the same
/// `analyze_scene(scene, name, false)`. The only difference between them is the
/// `backend` string label. So three of the four conformance tests above rest on
/// comparing a pure function's output **with itself** — unfalsifiable by construction,
/// and no change to the projection, the culling, the lighting or the sort could ever
/// make them red (lane ST, gfx3d-S1/S12). `NullBackend`'s own doc says as much: it
/// "analyses the scene with the *identical* geometry oracle as the CPU painter".
///
/// They still earn their place the day a backend arrives with its own `analyze` — but
/// until then something has to be able to fail. This is that something: the numbers the
/// fixture scene actually produces, frozen. Any change to the geometry path moves at
/// least one of them.
///
/// **A moved number here is not automatically a bug** — it is a change to the render
/// that nobody wrote down. Re-measure, decide, and update the constant WITH the reason,
/// which is the whole point of a golden over an inequality.
#[test]
fn the_fixture_scene_projects_the_frozen_golden() {
// MEASURED 2026-09-04 on the `scene_fixture()` city at the 900×900 rect.
const GOLDEN_VISIBLE_TRIANGLES: usize = 524;
const GOLDEN_INK_OUTSIDE_RECT: usize = 0;
const GOLDEN_LIT_FRACTION: f32 = 1.0;
const GOLDEN_MAX_EXTENT_PX: f32 = 448.954_04;
const GOLDEN_RAW_OVERSHOOT_PX: f32 = 0.0;
let water = WaterMesh::default();
let (m, rect) = scene_fixture();
let scene = make_scene(&m, &water, rect);
let s = CpuPainterBackend.analyze(&scene);
// Print before asserting, so a re-measure needs one run and not two.
eprintln!(
"[gfx3d-S1] golden: visible_triangles={} ink_outside_rect={} lit_fraction={} \
max_extent_px={} raw_overshoot_px={}",
s.visible_triangles, s.ink_outside_rect, s.lit_fraction, s.max_extent_px,
s.raw_overshoot_px,
);
assert_eq!(s.visible_triangles, GOLDEN_VISIBLE_TRIANGLES, "visible triangles moved");
assert_eq!(s.ink_outside_rect, GOLDEN_INK_OUTSIDE_RECT, "ink escaped the rect");
assert!(
(s.lit_fraction - GOLDEN_LIT_FRACTION).abs() < 1e-6,
"lit fraction moved: {} vs {GOLDEN_LIT_FRACTION}",
s.lit_fraction
);
assert!(
(s.max_extent_px - GOLDEN_MAX_EXTENT_PX).abs() < 1e-3,
"worst projected extent moved: {} vs {GOLDEN_MAX_EXTENT_PX}",
s.max_extent_px
);
assert!(
(s.raw_overshoot_px - GOLDEN_RAW_OVERSHOOT_PX).abs() < 1e-3,
"raw overshoot moved: {} vs {GOLDEN_RAW_OVERSHOOT_PX}",
s.raw_overshoot_px
);
}