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

nordisk/facett

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