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

nordisk/facett

facett-core/tests/render_callchain_matrix.rs

//! **The render call-chain test matrix (input → pixel).**
//!
//! This file TRACKS + VERIFIES every function on the facett-core CPU raster call
//! chain — instance-lowering → camera projection → z-order → SDF coverage →
//! y-bucket → raster_row → blend → frame → composite — and the backend picker +
//! (feature `wgpu`) the GPU offscreen surface.
//!
//! ## The chain it mirrors (one matrix stage per link)
//!
//! | # | fn (module)                                   | how it is verified here                         |
//! |---|-----------------------------------------------|-------------------------------------------------|
//! | 1 | `*Instance::lower` / `LineInstance::*` (prim) | return-assert: shape tag + geometry + bounds    |
//! | 2 | `Camera::project2d/view_space/project_view`   | return-assert vs golden affine / perspective    |
//! | 3 | `LayerStack::ordered` (layer)                 | return-assert: ascending-z, stable ties         |
//! | 4 | `sdf::coverage_from_sd` + shape coverages     | return-assert: monotone AA, inside/outside       |
//! | 5 | `sdf::quad_coverage` / `line_coverage`        | return-assert: per-shape dispatch + width        |
//! | 6 | `CpuCanvas::rasterize` (YBuckets+raster_row)  | EMIT: lit_px / pixel correctness (pixels)        |
//! | 7 | `CpuCanvas::frame` (blend/unpremul)           | EMIT + return-assert: byte-identical N-vs-1      |
//! | 8 | `scissor::clip_poly_to_rect/ink_outside_rect` | return-assert: clipped area + 0 ink outside      |
//! | 9 | `backend::decide`                             | return-assert: probe → Backend                  |
//! |10 | GPU `offscreen_render` (feature wgpu)         | EMIT: non-blank + CPU parity (robot/headless)    |
//!
//! Stages 6/7/10 emit a `functional_status` row because the **return type does not
//! prove the pixels came out right** — a `Frame` is just bytes; the EMIT-DOCTRINE
//! row reports `lit_px`, byte-identity, and the PNG-blob composite proof. Every
//! other stage's return value proves correctness, so it is a plain assert (also
//! mirrored into a matrix row so the green matrix ⟺ every chain link works).
//!
//! Per LAW #3 the heart of the file is a ~1000-case generated parameter sweep
//! (shapes × radii × AA × canvas sizes × instance counts × worker counts) that
//! feeds real input and asserts real output at each chain stage, emitting one
//! matrix row per case.

use facett_core::render::backend::{decide, Backend, GpuProbe};
use facett_core::render::camera::{Camera, Pos, V3};
use facett_core::render::cpu::scissor::{clip_poly_to_rect, ink_outside_rect};
use facett_core::render::cpu::sdf;
use facett_core::render::layer::{LayerKind, LayerStack};
use facett_core::render::prim::{
    cap, shape, CircleInstance, LineInstance, MarkerInstance, QuadInstance, RingInstance,
};
use facett_core::render::{Canvas, CpuCanvas, Frame};

// ── helpers ───────────────────────────────────────────────────────────────────

fn px(rgba: &[u8], w: u32, x: u32, y: u32) -> [u8; 4] {
    let i = ((y * w + x) * 4) as usize;
    [rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]]
}

/// A tiny deterministic PNG-blob proof: the FNV-1a hash of the composited frame
/// bytes. Two identical composites hash identically; a blank/wrong composite does
/// not match the expected. This is the "byte-identical-to-reference" composite emit
/// the EMIT-DOCTRINE calls for, without dragging in an image encoder.
fn frame_fingerprint(f: &Frame) -> u64 {
    let mut h: u64 = 0xcbf29ce484222325;
    for &b in &f.rgba {
        h ^= b as u64;
        h = h.wrapping_mul(0x100000001b3);
    }
    h ^= (f.width as u64) << 32 | f.height as u64;
    h
}

#[cfg(feature = "testmatrix")]
fn emit(component: &str, check: &str, ok: bool, detail: &str) {
    facett_core::testmatrix::emit(component, check, ok, detail);
}
#[cfg(not(feature = "testmatrix"))]
fn emit(_component: &str, _check: &str, _ok: bool, _detail: &str) {}

// ── STAGE 1: instance lowering (prim) ──────────────────────────────────────────

#[test]
fn stage1_lowering_carries_shape_and_geometry() {
    // Return-assert: each typed primitive lowers to the right QuadInstance shape +
    // geometry, and a line's inflated bounds enclose endpoints + stroke + AA.
    let mut ok = true;
    for &r in &[2.0f32, 7.0, 19.0, 40.0] {
        for &aa in &[0.0f32, 1.0, 3.0] {
            let c = CircleInstance { center: [5.0, 6.0], radius: r, color: [1.0; 4], aa }.lower();
            ok &= c.shape == shape::CIRCLE && c.inner == 0.0 && (c.half_extent() - (r + aa)).abs() < 1e-6;

            let ring = RingInstance { center: [0.0; 2], radius: r, inner: r * 2.0, color: [1.0; 4], aa }.lower();
            // inner is clamped into [0, radius] so a malformed ring can't invert.
            ok &= ring.shape == shape::RING && (ring.inner - r).abs() < 1e-6;

            for &sh in &[shape::SQUARE, shape::TRIANGLE, shape::DIAMOND] {
                let m = MarkerInstance { center: [1.0, 2.0], radius: r, corner: 1.5, color: [1.0; 4], aa, shape: sh }.lower();
                ok &= m.shape == sh && (m.inner - 1.5).abs() < 1e-6;
            }

            let l = LineInstance::round([10.0, 10.0], [30.0, 14.0], r, aa, [1.0; 4]);
            let (mnx, mny, mxx, mxy) = l.bounds();
            ok &= (mnx - (10.0 - r - aa)).abs() < 1e-4
                && (mny - (10.0 - r - aa)).abs() < 1e-4
                && (mxx - (30.0 + r + aa)).abs() < 1e-4
                && (mxy - (14.0 + r + aa)).abs() < 1e-4;
            ok &= LineInstance::butt([0.0; 2], [1.0, 0.0], r, aa, [0.0; 4]).cap == cap::BUTT;
            ok &= l.cap == cap::ROUND;
        }
    }
    assert!(ok, "lowering carries shape + clamped inner + inflated bounds");
    emit("render::prim", "lower_shape_geometry_bounds", ok, "swept radius×aa over circle/ring/marker/line");
}

// ── STAGE 2: camera projection ─────────────────────────────────────────────────

#[test]
fn stage2_camera_projection_matches_golden() {
    // 2D affine return-assert vs graphview's center+pan+p*zoom.
    let cam = Camera { pan_x: 30.0, pan_y: -12.0, zoom: 2.5, ..Camera::default() };
    let (sx, sy) = cam.project2d(Pos::new(10.0, 4.0));
    let affine_ok = (sx - (10.0 * 2.5 + 30.0)).abs() < 1e-6 && (sy - (4.0 * 2.5 - 12.0)).abs() < 1e-6;

    // 3D perspective return-assert: target → centre, nearer projects wider, behind
    // the eye is culled, view_proj orders depth into [0,1].
    let cam3 = Camera { distance: 5.0, fov_y: 50f32.to_radians(), ..Camera::default() };
    let center = (400.0, 300.0);
    let mid = cam3.project_view_world(V3::new(0.0, 0.0, 0.0), center, 300.0);
    let near = cam3.project_view_world(V3::new(0.5, 0.0, 2.0), center, 300.0);
    let far = cam3.project_view_world(V3::new(0.5, 0.0, -2.0), center, 300.0);
    let behind = cam3.project_view_world(cam3.eye().add(cam3.basis().0.scale(-1.0)), center, 300.0);
    let persp_ok = mid.visible
        && (mid.x - center.0).abs() < 1.0
        && (near.x - center.0).abs() > (far.x - center.0).abs()
        && !behind.visible;

    // view_space radius == distance for an on-target eye sphere.
    let r = cam3.eye().sub(cam3.target).len();
    let vs_ok = (r - 5.0).abs() < 1e-4;

    let ok = affine_ok && persp_ok && vs_ok;
    assert!(ok, "camera projection matches golden affine + perspective");
    emit("render::camera", "project2d_view_proj_golden", ok, &format!("affine={affine_ok} persp={persp_ok} viewspace={vs_ok}"));
}

// ── STAGE 3: layer z-order ─────────────────────────────────────────────────────

#[test]
fn stage3_layer_stack_orders_back_to_front() {
    let mut s = LayerStack::new();
    s.add(10.0, LayerKind::Graph);
    s.add(0.0, LayerKind::Map3d);
    s.add(5.0, LayerKind::Map2d);
    let o = s.ordered();
    let order_ok = o[0].kind == LayerKind::Map3d && o[1].kind == LayerKind::Map2d && o[2].kind == LayerKind::Graph;

    // Stable tie-break: equal-z keeps insertion order.
    let mut t = LayerStack::new();
    t.add(1.0, LayerKind::Map2d);
    t.add(2.0, LayerKind::Overlay);
    t.add(2.0, LayerKind::Graph);
    let to = t.ordered();
    let tie_ok = to[1].kind == LayerKind::Overlay && to[2].kind == LayerKind::Graph;

    let ok = order_ok && tie_ok && s.top().unwrap().kind == LayerKind::Graph;
    assert!(ok, "layers ascending-z with stable ties");
    emit("render::layer", "ordered_back_to_front_stable", ok, &format!("order={order_ok} tie={tie_ok}"));
}

// ── STAGES 4+5: SDF coverage kernel + dispatch ─────────────────────────────────

#[test]
fn stage4_5_sdf_coverage_kernel_swept() {
    // coverage_from_sd is monotone non-increasing across the AA band, 1 inside, 0
    // outside; quad_coverage dispatches per shape; line_coverage matches width.
    let mut ok = true;
    for &aa in &[0.5f32, 1.0, 2.0, 4.0] {
        ok &= coverage_from_sd_monotone(aa);
    }
    // aa=0 ⇒ hard step.
    ok &= sdf::coverage_from_sd(-0.01, 0.0) == 1.0 && sdf::coverage_from_sd(0.01, 0.0) == 0.0;

    // Per-shape dispatch (return-assert).
    for &r in &[5.0f32, 12.0, 30.0] {
        let circle = CircleInstance { center: [0.0; 2], radius: r, color: [1.0; 4], aa: 1.0 }.lower();
        ok &= sdf::quad_coverage(&circle, 0.0, 0.0) > 0.99 && sdf::quad_coverage(&circle, r * 2.0, 0.0) < 0.01;

        let ring = RingInstance { center: [0.0; 2], radius: r, inner: r * 0.5, color: [1.0; 4], aa: 1.0 }.lower();
        ok &= sdf::quad_coverage(&ring, 0.0, 0.0) < 0.01 && sdf::quad_coverage(&ring, r * 0.75, 0.0) > 0.9;

        // marker geometry: square fills its corner, diamond excludes it.
        ok &= sdf::square_coverage(r * 0.8, r * 0.8, r, 0.0, 0.5) > 0.9;
        ok &= sdf::diamond_coverage(r * 0.8, r * 0.8, r, 0.5) < 0.1;
        ok &= sdf::triangle_coverage(0.0, r * 0.7, r, 0.5) > 0.9;

        // line width: lit on the axis, dark beyond width+aa.
        let hw = 3.0;
        let l = LineInstance::round([10.0, 50.0], [90.0, 50.0], hw, 1.0, [1.0; 4]);
        ok &= sdf::line_coverage(&l, [50.0, 50.0]) > 0.99 && sdf::line_coverage(&l, [50.0, 50.0 + hw + 4.0]) < 0.05;
    }
    assert!(ok, "SDF coverage kernel + dispatch correct across the sweep");
    emit("render::cpu::sdf", "coverage_kernel_and_dispatch", ok, "monotone AA + per-shape dispatch + line width");
}

fn coverage_from_sd_monotone(aa: f32) -> bool {
    if sdf::coverage_from_sd(-aa - 1.0, aa) < 0.999 {
        return false;
    }
    if sdf::coverage_from_sd(aa + 1.0, aa) > 0.001 {
        return false;
    }
    let mut prev = 1.0f32;
    let mut d = -aa;
    while d <= aa {
        let c = sdf::coverage_from_sd(d, aa);
        if c > prev + 1e-6 {
            return false;
        }
        prev = c;
        d += aa / 16.0;
    }
    true
}

// ── STAGE 8: scissor (composite gate) ──────────────────────────────────────────

#[test]
fn stage8_scissor_confines_and_leaves_no_ink_outside() {
    let rect = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(100.0, 100.0));
    // A sprawling triangle clips to a polygon entirely inside the rect; its area is
    // bounded by the rect area (return-assert on the clipped geometry).
    let sprawl = [egui::pos2(-500.0, 50.0), egui::pos2(50.0, -500.0), egui::pos2(600.0, 600.0)];
    let poly = clip_poly_to_rect(&sprawl, rect);
    let mut inside = poly.len() >= 3;
    for p in &poly {
        inside &= p.x >= -1e-3 && p.x <= 100.0 + 1e-3 && p.y >= -1e-3 && p.y <= 100.0 + 1e-3;
    }
    let area = shoelace(&poly);
    let area_ok = area <= 100.0 * 100.0 + 1.0 && area > 0.0;
    // The oracle reads 0 ink outside after scissor; fully-outside face dropped.
    let oracle_ok = ink_outside_rect(&[sprawl], rect) == 0
        && clip_poly_to_rect(&[egui::pos2(200.0, 200.0), egui::pos2(300.0, 200.0), egui::pos2(250.0, 300.0)], rect).len() < 3;

    let ok = inside && area_ok && oracle_ok;
    assert!(ok, "scissor confines geometry + leaves no ink outside the rect");
    emit("render::cpu::scissor", "clip_area_and_zero_ink_outside", ok, &format!("inside={inside} area={area:.0} oracle={oracle_ok}"));
}

fn shoelace(poly: &[egui::Pos2]) -> f32 {
    if poly.len() < 3 {
        return 0.0;
    }
    let mut a = 0.0f32;
    for i in 0..poly.len() {
        let p = poly[i];
        let q = poly[(i + 1) % poly.len()];
        a += p.x * q.y - q.x * p.y;
    }
    (a * 0.5).abs()
}

// ── STAGE 9: backend picker ────────────────────────────────────────────────────

#[test]
fn stage9_backend_decide_maps_probe_to_backend() {
    let cpu_only = decide(GpuProbe::cpu_only()) == Backend::VelloCpu;
    let forced = decide(GpuProbe { usable_gpu: true, force_cpu: true }) == Backend::VelloCpu;
    let gpu = decide(GpuProbe::gpu());
    let gpu_ok = if cfg!(feature = "wgpu") { gpu == Backend::Wgpu } else { gpu == Backend::VelloCpu };
    let ok = cpu_only && forced && gpu_ok;
    assert!(ok, "backend::decide maps probe → backend");
    emit("render::backend", "decide_probe_to_backend", ok, &format!("cpu_only={cpu_only} forced={forced} gpu={gpu_ok}"));
}

// ── STAGES 6+7: the ~1000-case raster matrix (EMIT-DOCTRINE: pixels) ────────────

/// The generated parameter-sweep grid. Each case rasterizes a real scene and
/// asserts real output at the raster + frame + composite stages, then emits one
/// matrix row. This is the heart of LAW #3 — a green matrix ⟺ every (shape, radius,
/// AA, canvas, instance-count, worker-count) point produces correct pixels.
#[test]
fn raster_matrix_sweep_emits_one_row_per_case() {
    // Grid axes. Product is tuned to clear ~1000 cases.
    let shapes = [shape::CIRCLE, shape::RING, shape::SQUARE, shape::TRIANGLE, shape::DIAMOND];
    let radii = [4.0f32, 8.0, 16.0, 24.0, 32.0];
    let aas = [0.0f32, 0.75, 1.5, 3.0];
    let canvases = [(48u32, 48u32), (128, 96), (300, 200), (320, 320)];
    let inst_counts = [1usize, 8, 64];
    let workers = [1usize, 0]; // 1 = sequential reference, 0 = gatling all-cores
    // 5 shapes × 5 radii × 4 AA × 4 canvases × 3 instance-counts = 1200 cases, each
    // run at both worker counts (sequential ref + gatling) = 2400 raster renders —
    // well over the ~1000-case target.

    let mut cases = 0usize;
    let mut passed = 0usize;

    for &sh in &shapes {
        for &radius in &radii {
            for &aa in &aas {
                for &(w, h) in &canvases {
                    for &n in &inst_counts {
                        // Sequential vs gatling must be byte-identical for the SAME
                        // scene → run both worker counts on a freshly-built canvas
                        // and compare the composite fingerprints.
                        let mut fps = [0u64; 2];
                        let mut lit = [0usize; 2];
                        let mut pixel_ok = true;
                        for (wi, &wk) in workers.iter().enumerate() {
                            let frame = render_case(sh, radius, aa, w, h, n, wk);
                            fps[wi] = frame_fingerprint(&frame);
                            lit[wi] = frame.lit_px();
                            // Pixel correctness: a shape centred in the canvas lights
                            // its centre pixel (unless it's a ring/empty — handle).
                            pixel_ok &= centre_pixel_correct(&frame, w, h, sh);
                            // Frame is the right size.
                            pixel_ok &= frame.rgba.len() == (w * h * 4) as usize;
                        }
                        let parity_ok = fps[0] == fps[1] && lit[0] == lit[1];
                        let drew = lit[0] > 0; // the case actually drew ink
                        cases += 1;
                        let case_ok = parity_ok && drew && pixel_ok;
                        if case_ok {
                            passed += 1;
                        }
                        // EMIT-DOCTRINE: the return type (bytes) can't prove the
                        // pixels are right — emit lit_px, the N-vs-1 byte-identity,
                        // and the composite fingerprint (PNG-blob proof surrogate).
                        emit(
                            "render::cpu::raster",
                            "matrix_case",
                            case_ok,
                            &format!(
                                "shape={sh} r={radius} aa={aa} canvas={w}x{h} n={n} lit={} fp={:#018x} parity={parity_ok}",
                                lit[0], fps[0]
                            ),
                        );
                        assert!(
                            case_ok,
                            "raster case failed: shape={sh} r={radius} aa={aa} canvas={w}x{h} n={n} parity={parity_ok} drew={drew} pixel_ok={pixel_ok}"
                        );
                    }
                }
            }
        }
    }

    assert!(cases >= 1000, "matrix has at least ~1000 cases, got {cases}");
    assert_eq!(passed, cases, "every matrix case green");
    // One summary row for the whole sweep.
    emit(
        "render::cpu::raster",
        "matrix_sweep_complete",
        passed == cases,
        &format!("cases={cases} passed={passed} (gatling N-vs-1 byte-identical, pixels correct)"),
    );
    eprintln!("[render_callchain_matrix] raster matrix: {passed}/{cases} cases green");
}

/// Build a scene of `n` instances of `sh` (radius `radius`, AA `aa`) on a `w×h`
/// canvas, one centred + the rest scattered, plus a couple of lines so the line +
/// quad chain links both fire, and rasterize at `workers`.
fn render_case(sh: u32, radius: f32, aa: f32, w: u32, h: u32, n: usize, workers: usize) -> Frame {
    let mut canvas = CpuCanvas::new(w, h, Camera::default(), [8, 10, 16, 255]);
    let cx = w as f32 / 2.0;
    let cy = h as f32 / 2.0;
    let mut quads: Vec<QuadInstance> = Vec::with_capacity(n);
    for i in 0..n {
        let (px, py) = if i == 0 {
            (cx, cy)
        } else {
            (((i * 37) % (w as usize).max(1)) as f32, ((i * 53) % (h as usize).max(1)) as f32)
        };
        let col = [
            (i % 7) as f32 / 7.0 + 0.2,
            (i % 5) as f32 / 5.0 + 0.2,
            (i % 3) as f32 / 3.0 + 0.2,
            0.9,
        ];
        let q = match sh {
            shape::CIRCLE => CircleInstance { center: [px, py], radius, color: col, aa }.lower(),
            shape::RING => RingInstance { center: [px, py], radius, inner: radius * 0.5, color: col, aa }.lower(),
            _ => MarkerInstance { center: [px, py], radius, corner: radius * 0.2, color: col, aa, shape: sh }.lower(),
        };
        quads.push(q);
    }
    // A few lines so the line raster link is exercised in every case.
    let lines = vec![
        LineInstance::round([2.0, 2.0], [w as f32 - 2.0, h as f32 - 2.0], 2.0, aa.max(0.5), [0.9, 0.9, 0.3, 0.8]),
        LineInstance::butt([2.0, h as f32 - 2.0], [w as f32 - 2.0, 2.0], 1.5, aa.max(0.5), [0.3, 0.8, 0.9, 0.8]),
    ];
    canvas.push_lines(&lines);
    canvas.push_quads(&quads);
    canvas.rasterize_with_workers(workers)
}

/// A shape centred on the canvas must light a pixel that is far from the background.
/// For a RING the centre is a hole, so we sample a point on its band instead.
fn centre_pixel_correct(frame: &Frame, w: u32, h: u32, sh: u32) -> bool {
    let bg = [8i32, 10, 16];
    let far_from_bg = |p: [u8; 4]| {
        (p[0] as i32 - bg[0]).abs() + (p[1] as i32 - bg[1]).abs() + (p[2] as i32 - bg[2]).abs() > 24
    };
    let cx = w / 2;
    let cy = h / 2;
    if sh == shape::RING {
        // Sample a band pixel: a quarter-radius-ish offset that lands in the annulus
        // for the radii in the sweep (≥4px → ≥3px band). Probe a small ring of
        // candidate offsets and accept if any is lit (robust to small radii).
        for d in [3u32, 4, 5, 6, 8, 10] {
            if cx + d < w && far_from_bg(px(&frame.rgba, w, cx + d, cy)) {
                return true;
            }
        }
        // tiny ring may not light a sampled band pixel — fall back to "drew anything".
        return frame.lit_px() > 0;
    }
    far_from_bg(px(&frame.rgba, w, cx, cy))
}

// ── STAGE 10: robot/headless GPU surface (feature wgpu) ─────────────────────────

#[cfg(feature = "wgpu")]
#[test]
fn stage10_gpu_offscreen_nonblank_and_cpu_parity() {
    use facett_core::render::gpu::offscreen_render;
    let (w, h) = (256u32, 256u32);
    let bg = [0.05f32, 0.06, 0.08, 1.0];
    let quads = vec![
        CircleInstance { center: [60.0, 60.0], radius: 24.0, color: [1.0, 0.2, 0.1, 1.0], aa: 1.5 }.lower(),
        RingInstance { center: [180.0, 60.0], radius: 26.0, inner: 14.0, color: [0.1, 0.9, 0.3, 1.0], aa: 1.5 }.lower(),
        MarkerInstance { center: [60.0, 180.0], radius: 22.0, corner: 0.0, color: [0.3, 0.5, 1.0, 1.0], aa: 1.0, shape: shape::DIAMOND }.lower(),
    ];
    let lines = vec![LineInstance::round([120.0, 130.0], [230.0, 220.0], 5.0, 1.5, [1.0, 1.0, 0.2, 1.0])];

    let Some(gpu) = offscreen_render(w, h, bg, &quads, &lines) else {
        // The GPU row is filed as a SKIP under its OWN name. Without this the matrix
        // simply lost `render::gpu::offscreen/nonblank_and_cpu_parity` — an ABSENT row,
        // which reads exactly like a row nobody ever wrote, while libtest recorded `ok`
        // for the whole `#[test]`. The CPU pixmap below is a *different* surface and
        // does not stand in for the GPU one.
        facett_core::testmatrix::gpu_skip(
            "render::gpu::offscreen",
            "nonblank_and_cpu_parity",
            "no wgpu adapter",
            "the offscreen GPU surface draws real ink AND agrees with the CPU rasteriser \
             within 4 % of the pane (the CPU pixmap row below is a different surface)",
        );
        // Robot/headless fallback: assert the CPU pixmap is non-blank (the always-
        // available surface) and emit its row.
        let mut canvas = CpuCanvas::new(w, h, Camera::default(), [13, 15, 20, 255]);
        canvas.push_lines(&lines);
        canvas.push_quads(&quads);
        let cpu = canvas.rasterize();
        let lit = cpu.lit_px();
        assert!(lit > 1000, "CPU pixmap non-blank ({lit})");
        emit("render::cpu::surface", "headless_nonblank", lit > 1000, &format!("lit_px={lit} (no gpu adapter)"));
        return;
    };

    // GPU non-blank: ink footprint far from the clear colour.
    let bg8 = [(bg[0] * 255.0) as u8, (bg[1] * 255.0) as u8, (bg[2] * 255.0) as u8];
    let ink = |rgba: &[u8]| -> usize {
        rgba.as_chunks::<4>().0.iter()
            .filter(|c| (c[0] as i32 - bg8[0] as i32).abs() + (c[1] as i32 - bg8[1] as i32).abs() + (c[2] as i32 - bg8[2] as i32).abs() > 24)
            .count()
    };
    let gpu_ink = ink(&gpu.rgba);
    let nonblank = gpu_ink > 1000;
    assert!(nonblank, "GPU offscreen non-blank ({gpu_ink})");

    // Parity with the CPU lane (same coverage math).
    let mut canvas = CpuCanvas::new(w, h, Camera::default(), [bg8[0], bg8[1], bg8[2], 255]);
    canvas.push_lines(&lines);
    canvas.push_quads(&quads);
    let cpu = canvas.rasterize();
    let mut disagree = 0usize;
    for y in 0..h {
        for x in 0..w {
            let gi = !near_bg(&gpu.rgba, w, x, y, bg8);
            let ci = !near_bg(&cpu.rgba, w, x, y, bg8);
            if gi != ci {
                disagree += 1;
            }
        }
    }
    let frac = disagree as f64 / (w * h) as f64;
    let parity = frac < 0.04;
    assert!(parity, "GPU/CPU footprints agree (disagreement {frac:.4})");
    emit("render::gpu::offscreen", "nonblank_and_cpu_parity", nonblank && parity, &format!("gpu_ink={gpu_ink} disagreement={frac:.4}"));
}

#[cfg(feature = "wgpu")]
fn near_bg(rgba: &[u8], w: u32, x: u32, y: u32, bg8: [u8; 3]) -> bool {
    let p = px(rgba, w, x, y);
    (p[0] as i32 - bg8[0] as i32).abs() + (p[1] as i32 - bg8[1] as i32).abs() + (p[2] as i32 - bg8[2] as i32).abs() < 24
}