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

nordisk/facett

facett-map3d/src/backend.rs

//! **Swappable render backend** — the `RenderBackend` trait seam.
//!
//! This is the deliverable the `.nornir/render-engine.md` design calls the
//! "*Renderer/Canvas backend-seam trait = the 2-category swap expressed as one
//! trait*" (galileo pattern, our own impl). It abstracts the **3D draw** of the
//! [`crate::Map3D`] scene so a *different* Rust engine can be dropped in later
//! **without changing the map3d / map3d-flight view code** — the same scene draws
//! through any backend. The trait is the point.
//!
//! ## The contract
//! - [`RenderScene`] — a **backend-neutral** description of one frame: the extruded
//!   [`Mesh3`] + [`WaterMesh`], the resolved orbit [`OrbitCamera`], the [`SunSky`]
//!   lighting environment, the [`Viewport`] (rect + focal), the background, and the
//!   feature [`SceneFlags`] (shadows / water / sky / ssao). No egui, no wgpu.
//! - [`RenderStats`] — the **data oracles** every backend must report from the same
//!   scene: visible-triangle count, clip escape count (`ink_outside_rect`), painter
//!   depth-order monotonicity, worst projected extent, lit fraction. This is the
//!   conformance surface — two backends handed the same [`RenderScene`] must agree.
//! - [`RenderBackend::analyze`] — **pure, device-free** projection → [`RenderStats`].
//!   Headless-testable; the shared correctness oracle.
//! - [`RenderBackend::paint`] — paint the frame into a live `egui::Ui`. The provided
//!   default paints via the CPU painter, so a *new* backend gets a working fallback
//!   for free; the GPU backend overrides it with the wgpu pass chain.
//!
//! ## The impls
//! - [`CpuPainterBackend`] — impl #1a: project + cull + depth-sort + light on the
//!   CPU, tessellate to an `egui::Mesh`. Always available, headless, never a device.
//! - [`NullBackend`] — the **headless / conformance** backend: analyses the scene
//!   (identical oracle to the CPU painter) but draws nothing. Used by the backend
//!   conformance tests to prove "the real backend and a headless backend draw the
//!   same scene" (same [`RenderStats`]).
//! - [`WgpuVelloBackend`] (feature `wgpu`) — impl #1b, **our stack**: all 3D
//!   pipelines on **wgpu** (shadow → reflection → PBR mesh → water → blit) with
//!   **vello** reserved for the 2D HUD/labels (drawn by the view over the top). This
//!   is the production backend; a future engine implements the same trait to swap in.

use egui::{Color32, Pos2};

use crate::camera::OrbitCamera;
use crate::extrude::{Mesh3, WaterMesh};
use crate::sky::SunSky;

/// The pixel viewport a scene projects into: the widget `rect`, its `center`, and
/// the perspective focal `half_h` (≈ half the rect height, the projection scale).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Viewport {
    /// The widget rect (logical points).
    pub rect: egui::Rect,
    /// Viewport centre in pixels (the projection origin).
    pub center: Pos2,
    /// Projection half-height in pixels (the perspective focal scale).
    pub half_h: f32,
}

impl Viewport {
    /// Derive the projection viewport from a widget rect (centre + focal half-height).
    #[must_use]
    pub fn from_rect(rect: egui::Rect) -> Self {
        Self { rect, center: rect.center(), half_h: (rect.height() * 0.5).max(1.0) }
    }
}

/// Per-frame **feature toggles** for the scene — the levers a backend honours if it
/// can (a backend that lacks a feature simply ignores its flag; [`RenderBackend::caps`]
/// advertises what it supports). Backend-neutral: no wgpu / egui types.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct SceneFlags {
    /// Paint the analytic atmosphere/skybox behind the city (GPU lane).
    pub sky: bool,
    /// Run the screen-space ambient-occlusion pass (GPU lane).
    pub ssao: bool,
    /// Use PCSS contact-hardening soft shadows (vs a hard PCF control; GPU lane).
    pub soft_shadows: bool,
    /// Cast-shadow darkness `0..1` (`0` disables the shadow depth pass).
    pub shadow_strength: f32,
    /// Whether the reflective animated water surface is active this frame.
    pub water_on: bool,
    /// Whether the planar reflection pass runs (`false` = the wasm sky-gradient path).
    pub planar_reflection: bool,
    /// Whether the HDR **bloom** pass contributes (`false` = composite adds zero bloom).
    pub bloom: bool,
    /// **The 2D-ortho bypass** (GFX_V2 item 6). `true` means the view is a
    /// constrained 2D one — flat terrain, top-down, orthographic — so the whole
    /// screen-space post chain is skipped rather than run with zeroed parameters.
    ///
    /// This is what makes GFX_V2's "2D costs nothing extra" true: the pipeline is
    /// the same pipeline, and the post passes are simply not recorded. Set it via
    /// [`SceneFlags::constrain_to_view`] rather than by hand, so it cannot disagree
    /// with the view it describes.
    pub flat_2d: bool,
}

impl SceneFlags {
    /// **Reconcile these flags with the unified view they belong to** (GFX_V2
    /// item 6) — the one function both modes ask, so "2D bypasses post-FX" is a
    /// property of the view rather than a thing a caller remembers to do.
    ///
    /// A view satisfying every 2D constraint ([`facett_core::render::View::is_constrained_2d`])
    /// forces the entire post chain off and sets [`flat_2d`](Self::flat_2d). An
    /// unconstrained 3D view is left exactly as the caller configured it — this
    /// never *enables* a pass, so it cannot surprise the 3D lane.
    #[must_use]
    pub fn constrain_to_view(mut self, view: &facett_core::render::View) -> Self {
        if view.is_constrained_2d() {
            self.flat_2d = true;
            self.sky = false;
            self.ssao = false;
            self.soft_shadows = false;
            self.shadow_strength = 0.0;
            self.bloom = false;
            self.planar_reflection = false;
        }
        self
    }

    /// Whether any screen-space post pass needs to run this frame. `false` means the
    /// offscreen HDR target and the whole bright/blur/composite/resolve chain can be
    /// skipped outright.
    #[must_use]
    pub fn any_post(&self) -> bool {
        !self.flat_2d && (self.ssao || self.bloom || self.shadow_strength > 0.0 || self.sky)
    }
}

/// **The ground-haze layer's scale height, in METRES.** The fragment shader's aerial
/// perspective treats the air as denser near the ground: haze density falls off as
/// `exp(-h / HAZE_SCALE_HEIGHT_METRES)`. That is a real atmospheric length and it must
/// be stated as one.
///
/// `150 m` is the shipped `0.25` design-unit layer read back through the default scale
/// it was tuned at ([`crate::lod::DEFAULT_METRES_PER_DESIGN_UNIT`] = 625 m/du →
/// `0.25 du` = `156 m`), rounded. So the fixture scenes keep the look they had; every
/// metric scene stops getting a 469 m haze layer it can never climb out of.
pub const HAZE_SCALE_HEIGHT_METRES: f32 = 150.0;

/// The legacy absolute haze layer height, in design units — kept **only** for scenes
/// with no world scale (`du_per_m == None`).
pub const HAZE_SCALE_HEIGHT_DU: f32 = 0.25;

/// **The SSAO sampling radius, in METRES.** Ambient occlusion is a contact shadow: it
/// asks "how much sky can this surface point see within a few metres of itself". At
/// `12 m` that is a street canyon and a building base — the scale the effect is for.
///
/// The shipped `0.16` design units is `100 m` at the default scale and **`300 m`** at
/// the 3 km town window `korp` actually flies — an occlusion radius of three football
/// pitches, which darkens whole districts uniformly and reads as flat, not as contact.
pub const SSAO_RADIUS_METRES: f32 = 12.0;

/// The legacy absolute SSAO radius, in design units — kept **only** for scenes with no
/// world scale (`du_per_m == None`).
pub const SSAO_RADIUS_DU: f32 = 0.16;

/// **The scene's haze scale height in design units** — [`HAZE_SCALE_HEIGHT_METRES`]
/// through the scene's own `du_per_m`, floored so a degenerate scale can never make it
/// zero (which would divide the exponent by nothing). `None` → the legacy absolute.
#[must_use]
pub fn haze_scale_height(du_per_m: Option<f32>) -> f32 {
    match du_per_m {
        Some(dpm) if dpm > 0.0 => (HAZE_SCALE_HEIGHT_METRES * dpm).max(1e-6),
        _ => HAZE_SCALE_HEIGHT_DU,
    }
}

/// **The scene's SSAO radius in design units** — [`SSAO_RADIUS_METRES`] through the
/// scene's own `du_per_m`, capped at the legacy [`SSAO_RADIUS_DU`] so no scene ever
/// gets a *wider* occlusion radius than it had. `None` → the legacy absolute.
#[must_use]
pub fn ssao_radius(du_per_m: Option<f32>) -> f32 {
    match du_per_m {
        Some(dpm) if dpm > 0.0 => (SSAO_RADIUS_METRES * dpm).clamp(1e-6, SSAO_RADIUS_DU),
        _ => SSAO_RADIUS_DU,
    }
}

/// A **backend-neutral** description of one 3D frame to draw. Everything a backend
/// needs — geometry, camera, light, viewport, flags — and nothing device-specific.
/// The camera is already spin-folded (`Map3D::render_camera`) so the frame is a pure
/// function of it. Borrows the mesh + water grid (zero-copy; no per-frame clone).
pub struct RenderScene<'a> {
    /// The extruded opaque basemap (prisms + ribbons + terrain).
    pub mesh: &'a Mesh3,
    /// The tessellated reflective-water grid (empty for data with no water).
    pub water: &'a WaterMesh,
    /// The resolved orbit camera for this frame (auto-spin already folded in).
    pub camera: OrbitCamera,
    /// The sun + sky lighting environment.
    pub sun: SunSky,
    /// The pixel viewport (rect + focal).
    pub viewport: Viewport,
    /// The background / atmospheric-fog colour.
    pub bg: Color32,
    /// The feature toggles for this frame.
    pub flags: SceneFlags,
    /// **Physical-sky transmittance LUT** (FLIGHT-SIM #7 wiring), if the host wired one
    /// in. `Some` ⇒ the GPU sky pass samples it for the sun's atmospheric tint; `None`
    /// ⇒ the analytic sun tint. Device-neutral raw texels (see
    /// [`crate::SkyTransmittanceLut`]); the CPU / null backends ignore it. `Arc` keeps
    /// the per-frame snapshot a cheap refcount bump.
    pub sky_lut: Option<std::sync::Arc<crate::SkyTransmittanceLut>>,
    /// The animation clock (seconds) — drives the water waves; `0` when unpinned.
    pub clock_s: f64,
    /// **The scene's scale — design units per real metre** ([`Map3D::du_per_m`],
    /// i.e. [`crate::extrude::design_units_per_metre`]). `None` = synthetic data with
    /// no world span (the rabbit fixture, the unit-test towns), which keeps every
    /// render-side metric constant on its legacy absolute so those scenes are
    /// byte-for-byte unchanged.
    ///
    /// A backend needs this for the same reason the extruder does: **every render
    /// constant that means a LENGTH is metric.** The haze layer is 150 m of air, the
    /// SSAO radius is 12 m of street, the water plane is 0.6 m of kerb. Stated as
    /// design units they are 150 m in the fixture and 469 m in Stockholm — the exact
    /// defect `GROUND_LIFT` and `BUILDING_RISE_EPS` already had twice.
    pub du_per_m: Option<f32>,
    /// **The per-frame overlay mesh** (V2.5 D.3), already in WORLD design units
    /// ([`Map3D::set_overlay`] owns the one pose transform). `None` = no overlay and a
    /// frame byte-identical to the pre-seam pipeline. Small by contract (~100 verts):
    /// the CPU painter merges its shaded faces into the z-buffered face list, the GPU
    /// lane re-uploads it per frame as a tiny dedicated buffer — never through the
    /// generation-stamped main mesh cache.
    pub overlay: Option<&'a Mesh3>,
    /// Per-triangle **emissive intensity** for the overlay, parallel to its colours
    /// (`0`/empty = lit normally; `> 0` = unlit, colour scaled into HDR for the
    /// existing bloom bright-pass — a channel, not a new post pass).
    pub overlay_emissive: &'a [f32],
}

/// What a backend can do — advertised so a host / test can gate on it. A backend
/// that cannot cast shadows (e.g. the CPU painter) reports `shadows == false`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct BackendCaps {
    /// Human-readable backend name (matches [`RenderStats::backend`]).
    pub name: &'static str,
    /// Draws on a GPU (real Z-buffer occlusion) vs the CPU painter's depth sort.
    pub gpu: bool,
    /// Casts real shadows (a shadow depth pass).
    pub shadows: bool,
    /// Renders the reflective animated water surface.
    pub reflective_water: bool,
    /// Runs post-processing (bloom / tonemap / SSAO / FXAA).
    pub post_fx: bool,
}

/// The **data oracles** a backend reports from a scene — the conformance surface.
/// Two backends handed the same [`RenderScene`] must agree on the geometry-derived
/// fields (visible triangles, clip escapes, depth order, extent, lit fraction); only
/// [`Self::backend`] / [`Self::used_gpu`] differ. Assertable headlessly (no device).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct RenderStats {
    /// The backend that produced this frame (matches [`BackendCaps::name`]).
    pub backend: &'static str,
    /// Whether this frame was drawn on the GPU depth path (vs the CPU painter).
    pub used_gpu: bool,
    /// Triangles that survived clip + cull and would be rasterised. Every backend's
    /// `analyze` computes this fresh and exactly; the GPU lane's `paint` SAMPLES it
    /// twice over — the value there is from the most recent refresh, at most
    /// [`GPU_ANALYZE_EVERY`](crate::backend) frames old during continuous camera
    /// motion (a mesh swap re-analyzes immediately), **and above
    /// [`GPU_ANALYZE_SAMPLE_MAX`](crate::backend) triangles it is an ESTIMATE**:
    /// the visible fraction of a bounded stride sample of the basemap, scaled to
    /// the full mesh (±a few percent in practice; the overlay is still counted
    /// exactly). At or below the cap the painted value stays byte-exact.
    pub visible_triangles: usize,
    /// Fraction of visible faces that caught a non-trivial amount of light `0..1`,
    /// by [`facett_core::render::cpu::is_lit`] over the CPU-shaded colour.
    ///
    /// **It is CPU-PROJECTED, not a framebuffer read** (lane ST, gfx3d-S2). Every
    /// backend's `analyze` computes it from `scene_tris` — the same shaded-triangle
    /// list the CPU painter rasters — so on the GPU lane this reports what the CPU
    /// shading path WOULD have lit, not what the GPU actually put on screen. That is
    /// deliberate and is what makes it comparable across backends; it is written down
    /// because "lit_fraction" reads like a measurement of the image, and a GPU shading
    /// divergence would leave this number untouched.
    pub lit_fraction: f32,
    /// **The bug oracle** — clipped face vertices that still land outside the widget
    /// rect. MUST be `0`: no ink escapes the component (the historical smear bug).
    pub ink_outside_rect: usize,
    /// Worst signed distance any *raw* projected vertex pokes past the rect edge
    /// (before the scissor). The near-plane clip drives this to ~0.
    pub raw_overshoot_px: f32,
    /// Whether the painter depth order is monotone far→near (correct occlusion on
    /// the CPU lane; the GPU lane's Z-buffer makes this advisory).
    ///
    /// # Exactly what it can and cannot catch (lane ST, gfx3d-S12 — MEASURED)
    ///
    /// It is computed in [`face_stats`] over a list `scene_tris` has just sorted, so it
    /// reads like an oracle that asserts a sort sorted. It was filed for deletion on
    /// that reading. **It is not dead**: reversing the comparator at the sort site
    /// (`lib.rs` STEP 6) turns it `false` and fails
    /// `every_backend_satisfies_the_scene_invariants` — measured 2026-09-04. So it does
    /// guard the sort's presence and DIRECTION, and deleting it would have removed a
    /// live guard on the strength of a code-shape argument.
    ///
    /// What it cannot catch is anything downstream of the sort: it says nothing about
    /// occlusion, about the projection, or about the pixels. The frozen golden in
    /// `tests/backend_conformance.rs` is what covers those.
    pub depth_monotone: bool,
    /// Worst projected face extent in px — bounded by the viewport by construction
    /// (the frustum clip); a smear would blow this up.
    pub max_extent_px: f32,
}

/// **The swappable render-backend seam.** Abstracts the 3D draw of a [`RenderScene`]
/// so a different Rust engine can be dropped in without touching the view code.
///
/// Object-safe (`Box<dyn RenderBackend>`): [`crate::Map3D`] holds one and dispatches
/// every frame through it. Implement this trait to add a new engine; the map3d /
/// map3d-flight view code is unchanged.
/// `Send`, so a host can EXTRUDE on a worker thread and hand the finished
/// [`crate::Map3D`] to its UI thread: korp's risen view re-extrudes the city when
/// the camera moves 12 % of the built span, and with the build inside the paint
/// every such frame blocked on the extrusion mid-drag (GFX_V2 shit list #3). Both
/// shipped backends hold only `Send` state (a `RefCell` over an `Arc`'d payload, a
/// stats cache); the bound makes that a compile-time fact `Map3D` inherits, pinned
/// by `a_map3d_can_cross_to_a_worker_thread`.
pub trait RenderBackend: Send {
    /// What this backend supports.
    fn caps(&self) -> BackendCaps;

    /// **Pure, device-free** projection of the scene → [`RenderStats`]. This is the
    /// backend-neutral correctness oracle every backend must implement identically
    /// (the geometry math is shared); the conformance tests assert two backends
    /// agree here. Never touches a GPU or an `egui::Ui`.
    fn analyze(&self, scene: &RenderScene) -> RenderStats;

    /// Paint the frame into a live `egui::Ui` and report the stats. The provided
    /// default paints via the CPU painter — so a new backend that only implements
    /// [`Self::analyze`] still draws — and the GPU backend overrides it.
    fn paint(&self, scene: &RenderScene, ui: &mut egui::Ui) -> RenderStats {
        paint_cpu(scene, ui)
    }

    /// Drop any per-scene device cache (called when the mesh is swapped). No-op for
    /// the stateless CPU / null backends; the GPU backend clears its upload cache.
    fn reset_cache(&self) {}
}

// ── shared geometry oracle (one source of truth for every backend) ───────────────

/// Compute the [`RenderStats`] data oracles from an already-projected face list.
/// Shared by [`analyze_scene`] and the CPU painter so the numbers are identical.
fn face_stats(
    faces: &[(f32, [Pos2; 3], Color32)],
    rect: egui::Rect,
    backend: &'static str,
    used_gpu: bool,
) -> RenderStats {
    let (raw_overshoot, clipped_escapes) = crate::scissor_report(faces, rect);
    let mut depth_monotone = true;
    for w in faces.windows(2) {
        if w[0].0 < w[1].0 - 1e-3 {
            depth_monotone = false;
            break;
        }
    }
    let mut max_extent_px = 0.0f32;
    let mut lit = 0usize;
    for (_d, pts, c) in faces {
        let ex = pts[0].x.max(pts[1].x).max(pts[2].x) - pts[0].x.min(pts[1].x).min(pts[2].x);
        let ey = pts[0].y.max(pts[1].y).max(pts[2].y) - pts[0].y.min(pts[1].y).min(pts[2].y);
        let e = ex.max(ey);
        if e > max_extent_px {
            max_extent_px = e;
        }
        // "Lit" = the shaded+fogged colour is not near-black. Deterministic, so
        // identical across backends that share the projection — and now literally the
        // same rule as the CPU lane's, rather than a second copy of `> 90` under a
        // comment asking the two to agree (lane ST, gfx3d-S3).
        if facett_core::render::cpu::is_lit(c.r(), c.g(), c.b()) {
            lit += 1;
        }
    }
    let lit_fraction = if faces.is_empty() { 0.0 } else { lit as f32 / faces.len() as f32 };
    RenderStats {
        backend,
        used_gpu,
        visible_triangles: faces.len(),
        lit_fraction,
        ink_outside_rect: clipped_escapes,
        raw_overshoot_px: raw_overshoot,
        depth_monotone,
        max_extent_px,
    }
}

/// **The one scene projector** — basemap + overlay through the shared kernel
/// ([`crate::shade_mesh_tris_emissive`]), merged and re-sorted far→near so the
/// painter's depth order (and the `depth_monotone` oracle) hold across both meshes.
/// The single writer behind [`analyze_scene`] AND [`paint_cpu`] (LAW 5): the stats a
/// backend reports and the pixels the CPU painter rasters come from this exact list.
/// With no overlay the output is byte-identical to `shade_mesh_tris(scene.mesh, …)`.
pub(crate) fn scene_tris(scene: &RenderScene) -> Vec<crate::ShadedTri> {
    let mut tris = crate::shade_mesh_tris(
        scene.mesh,
        &scene.camera,
        scene.viewport.center,
        scene.viewport.half_h,
        scene.bg,
    );
    if let Some(ov) = scene.overlay {
        tris.extend(crate::shade_mesh_tris_emissive(
            ov,
            scene.overlay_emissive,
            &scene.camera,
            scene.viewport.center,
            scene.viewport.half_h,
            scene.bg,
        ));
        // Restore the far→near painter key across the merged list — each half arrives
        // sorted, the concatenation is not.
        tris.sort_by(|l, r| r.depth.partial_cmp(&l.depth).unwrap_or(std::cmp::Ordering::Equal));
    }
    tris
}

/// The projected scene as the lean `(depth, [3 px], colour)` face tuples — the shape
/// [`face_stats`] and the pose tests consume. Public for the LAW-2 overlay test
/// (`tests/overlay_pose.rs` asserts an overlay triangle's projected pixels off this,
/// the same list the painter rasters); hidden because it is a test seam, not API.
#[doc(hidden)]
#[must_use]
pub fn project_scene_faces(scene: &RenderScene) -> Vec<(f32, [Pos2; 3], Color32)> {
    scene_tris(scene).into_iter().map(|t| (t.depth, t.pts, t.col)).collect()
}

/// Project + cull + shade the scene (the shared CPU geometry path) and report the
/// [`RenderStats`]. The pure conformance oracle behind every backend's `analyze`.
/// Routed through [`scene_tris`], so the overlay counts in `visible_triangles` on
/// every backend identically (the conformance property is preserved by construction).
pub(crate) fn analyze_scene(scene: &RenderScene, backend: &'static str, used_gpu: bool) -> RenderStats {
    let faces = project_scene_faces(scene);
    face_stats(&faces, scene.viewport.rect, backend, used_gpu)
}

/// **The CPU painter** — project + cull + depth-sort + light the extruded mesh, then
/// tessellate the shaded faces into an `egui::Mesh` clipped to the widget rect. The
/// default `paint` for every backend (and [`CpuPainterBackend`]'s own). Never panics.
pub fn paint_cpu(scene: &RenderScene, ui: &mut egui::Ui) -> RenderStats {
    let rect = scene.viewport.rect;
    let painter = ui.painter_at(rect);
    // The rich projection (per-vertex depth + tonemapped colour) the software z-buffer
    // consumes; the lean oracle tuple is derived from it, so the stats are byte-identical
    // to `shade_mesh_faces` — and the overlay (V2.5 D.3) rides the same list, resolved
    // against the city by the per-pixel depth test like any other geometry.
    let tris = scene_tris(scene);
    let faces: Vec<(f32, [Pos2; 3], Color32)> = tris.iter().map(|t| (t.depth, t.pts, t.col)).collect();
    let stats = face_stats(&faces, rect, "cpu-painter", false);

    // ── CPU render-lane emit: the `shade_faces` painter path RAN. ────────────
    #[cfg(feature = "testmatrix")]
    facett_core::testmatrix::emit(
        "facett-map3d::shade_faces",
        "cpu_render",
        scene.mesh.triangle_count() == 0 || !faces.is_empty(),
        &format!("used_gpu=false visible_tris={} mesh_tris={}", faces.len(), scene.mesh.triangle_count()),
    );
    // ── scissor oracle (the bug oracle): no ink escapes the rect. ────────────
    #[cfg(feature = "testmatrix")]
    facett_core::testmatrix::emit(
        "facett-map3d",
        "scissor",
        stats.ink_outside_rect == 0,
        &format!(
            "ink_outside_rect={} raw_overshoot_px={:.1} faces={}",
            stats.ink_outside_rect, stats.raw_overshoot_px, faces.len()
        ),
    );

    // ── The software z-buffer raster (the depth-tearing fix) ─────────────────
    // Rasterize the shaded sub-tris into a physical-pixel RGBA buffer with a per-pixel
    // 1/z depth test — so overlapping building / terrain / ground-cover faces resolve by
    // TRUE depth, not the painter's-order centroid sort that tore where a small near face
    // overlapped a large far one (and on the coplanar GROUND_LIFT ground layers). Then
    // blit it over the sky gradient; uncovered pixels stay transparent so the sky shows.
    if !tris.is_empty() {
        let ppp = ui.ctx().pixels_per_point().max(0.1);
        let w = ((rect.width() * ppp).round() as usize).clamp(1, 4096);
        let h = ((rect.height() * ppp).round() as usize).clamp(1, 4096);
        let pixels = crate::raster_zbuffer(&tris, w, h, rect.min, ppp);
        let image = egui::ColorImage::new([w, h], pixels);
        // One texture id for the pane, re-`set` every frame (a software renderer
        // re-uploads each frame anyway). A load-and-drop one-frame texture is freed
        // BEFORE egui_kittest 0.36 renders — see `facett_core::render::cpu::frame_texture`.
        let tex = facett_core::render::cpu::frame_texture(ui, "facett-map3d-cpu-raster", image, egui::TextureOptions::LINEAR);
        painter.image(
            tex,
            rect,
            egui::Rect::from_min_max(Pos2::new(0.0, 0.0), Pos2::new(1.0, 1.0)),
            Color32::WHITE,
        );
    } else {
        painter.text(
            rect.center(),
            egui::Align2::CENTER_CENTER,
            "no 3D map",
            egui::FontId::proportional(13.0),
            ui.visuals().weak_text_color(),
        );
    }
    stats
}

/// **Impl #1a — the CPU painter backend.** Always available, headless, never a
/// device. The correctness reference: its projection is the shared geometry oracle.
#[derive(Clone, Copy, Debug, Default)]
pub struct CpuPainterBackend;

impl RenderBackend for CpuPainterBackend {
    fn caps(&self) -> BackendCaps {
        BackendCaps { name: "cpu-painter", gpu: false, shadows: false, reflective_water: false, post_fx: false }
    }
    fn analyze(&self, scene: &RenderScene) -> RenderStats {
        analyze_scene(scene, "cpu-painter", false)
    }
    // `paint` uses the default (`paint_cpu`).
}

/// **The headless / conformance backend.** Analyses the scene with the *identical*
/// geometry oracle as the CPU painter but draws nothing — so the backend-conformance
/// tests can prove "the real backend and a headless backend produce the same scene"
/// ([`RenderStats`] parity) with no device and no egui paint.
#[derive(Clone, Copy, Debug, Default)]
pub struct NullBackend;

impl RenderBackend for NullBackend {
    fn caps(&self) -> BackendCaps {
        BackendCaps { name: "null", gpu: false, shadows: false, reflective_water: false, post_fx: false }
    }
    fn analyze(&self, scene: &RenderScene) -> RenderStats {
        analyze_scene(scene, "null", false)
    }
    fn paint(&self, scene: &RenderScene, _ui: &mut egui::Ui) -> RenderStats {
        // Headless: no draw. The stats are the pure analysis (used_gpu = false).
        self.analyze(scene)
    }
}

// ── Impl #1b — the wgpu + vello backend (our stack) ──────────────────────────────

/// **Impl #1b — the production wgpu + vello backend.** All 3D pipelines run on
/// **wgpu** (shadow → reflection → PBR mesh → water → blit into the offscreen HDR
/// target, then a scissored blit into egui's pass); **vello** is reserved for the 2D
/// HUD / labels the view draws over the top (per the framing LAW in
/// `.nornir/flightsim-render.md`). A future Rust engine swaps in by implementing
/// [`RenderBackend`] — this view code never changes.
///
/// Holds the per-scene GPU upload cache (built once, handed to the paint callback
/// each frame as an `Arc` refcount bump; the installed
/// [`crate::gpu::Map3dRenderer`] replaces its device buffers when the payload's
/// build stamp changes and otherwise ignores the re-send).
#[cfg(feature = "wgpu")]
#[derive(Default)]
pub struct WgpuVelloBackend {
    /// Cached GPU geometry payload. Handed over each frame by cloning the `Arc` — a
    /// refcount bump, and BY CONSTRUCTION nothing more: `GpuGeometry` is not
    /// `Clone`, because the deep copy this cache used to make per frame (under a
    /// comment claiming "cheap Rc clone") was ~72 MB at korp's 600k-triangle
    /// budget. `None` until the first paint or after [`RenderBackend::reset_cache`].
    cache: std::cell::RefCell<Option<std::sync::Arc<crate::gpu::GpuGeometry>>>,
    /// The most recent scene analysis — `paint` refreshes it every
    /// [`GPU_ANALYZE_EVERY`]-th frame instead of paying it every frame (see the
    /// note in `paint`), and the refresh itself is bounded by
    /// [`GPU_ANALYZE_SAMPLE_MAX`]. Cleared with the geometry cache so a swapped
    /// mesh is re-analyzed on its first frame.
    stats_cache: std::cell::RefCell<Option<RenderStats>>,
    /// The cached stride sample of the basemap the bounded refresh analyses (see
    /// [`GPU_ANALYZE_SAMPLE_MAX`]). A pure function of the mesh — not the camera —
    /// so it is built once per mesh (preallocate-and-reuse, the LAW's doctrine) and
    /// dropped by [`RenderBackend::reset_cache`] with the other caches. `None` for
    /// meshes at or below the cap, which are analysed exactly.
    analyze_sub: std::cell::RefCell<Option<Mesh3>>,
    /// Frames painted since `stats_cache` was last refreshed.
    frames_since_analyze: std::cell::Cell<u32>,
    /// How many stats-refresh passes `paint` has run — the observable that keeps
    /// the frame sampling honest: a drag of N frames must show
    /// ~N/[`GPU_ANALYZE_EVERY`] here, and it goes back to N the moment someone
    /// re-inlines the unconditional per-frame call. (Named for the full analysis it
    /// historically counted; above [`GPU_ANALYZE_SAMPLE_MAX`] each refresh is now
    /// the bounded sample.)
    full_analyzes: std::cell::Cell<u64>,
    /// Test-only switch: `true` forces the paint-path refresh back to the unbounded
    /// full `analyze_scene` — the non-vacuity handle that lets the spike benchmark
    /// prove it could still see the defect. Never set in production code.
    force_full_analysis: std::cell::Cell<bool>,
}

/// How many painted frames may reuse the last scene analysis on the GPU lane
/// before [`WgpuVelloBackend`]'s `paint` refreshes it. The GPU lane was paying a
/// full software transform + clip + shade + sort of EVERY mesh triangle every frame
/// purely to fill [`RenderStats`] — the pixels come from the GPU regardless. `16`
/// keeps `visible_triangles` at worst ~a quarter-second stale during continuous
/// motion at 60 fps; a mesh swap ([`RenderBackend::reset_cache`]) re-analyzes on the
/// very next frame. NOT a spike knob: the refresh itself is bounded by
/// [`GPU_ANALYZE_SAMPLE_MAX`], so raising this would only make an already-flat cost
/// rarer (and before the bound existed, raising it would have made the hitch rarer
/// and just as tall — the fix that looks like work).
#[cfg(feature = "wgpu")]
pub const GPU_ANALYZE_EVERY: u32 = 16;

/// Ceiling on the triangles the GPU lane's **paint-path** stats refresh may project
/// in software. At or below it the refresh is the exact `analyze_scene` (byte-equal
/// to [`RenderBackend::analyze`]); above it the refresh projects a cached stride
/// sample of at most this many basemap triangles and **`visible_triangles` becomes a
/// scaled ESTIMATE** (documented on the field — a number that silently changed
/// meaning would be its own defect). Sampling instead of a rarer full pass because a
/// rarer spike is just as tall when it lands: the 1-in-[`GPU_ANALYZE_EVERY`] full
/// transform+clip+shade+sort of korp's ~600 k triangles was a periodic multi-ms
/// hitch with a visible cadence (GFX_V2 V2.9b), and bounding the work makes the
/// refresh cost flat regardless of mesh size. `analyze` itself is untouched — the
/// conformance oracle stays exact.
///
/// `1024` is measured, not guessed (`tests/gpu_analyze_spike.rs`, release, RTX-4090
/// box, 600 210 tris): one refresh 889 µs — ~1/19 of a 60 fps frame budget — with
/// the estimate 0.28 % off the exact count. At `4096` the same refresh measured
/// 5.7 ms, over a third of the budget, because the projection kernel costs
/// ~1.5 µs/triangle in release (per-triangle allocations; see the kernel note in
/// `lib.rs::shade_mesh_tris_emissive`).
#[cfg(feature = "wgpu")]
pub const GPU_ANALYZE_SAMPLE_MAX: usize = 1024;

#[cfg(feature = "wgpu")]
impl WgpuVelloBackend {
    /// A fresh wgpu+vello backend with an empty upload cache.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Stats-refresh passes `paint` has run so far — see [`GPU_ANALYZE_EVERY`].
    /// Device-free observable for the frame sampling: painting 33 frames of an
    /// unchanged scene leaves this at 3 (frames 0, 16, 32), and it reads 33 the
    /// moment the unconditional per-frame refresh comes back. (Named for the full
    /// analysis each refresh historically was; above [`GPU_ANALYZE_SAMPLE_MAX`] a
    /// refresh is now the bounded sample.)
    #[must_use]
    pub fn full_analyzes(&self) -> u64 {
        self.full_analyzes.get()
    }

    /// **The bounded paint-lane stats refresh.** At or below
    /// [`GPU_ANALYZE_SAMPLE_MAX`] triangles (and under the test-only full-analysis
    /// switch) this IS the exact [`analyze_scene`] — byte-equal to
    /// [`RenderBackend::analyze`], which is why every small-scene suite is
    /// untouched. Above the cap it analyses the cached stride sample of the
    /// basemap and scales `visible_triangles` back to the full mesh — a documented
    /// ESTIMATE (see [`RenderStats::visible_triangles`]) whose cost is flat
    /// regardless of mesh size, instead of a 1-in-[`GPU_ANALYZE_EVERY`] multi-ms
    /// hitch at korp's 600 k-triangle budget (GFX_V2 V2.9b). The overlay (a small
    /// live pose) rides OUTSIDE the sample and is counted exactly.
    fn refresh_stats(&self, scene: &RenderScene, installed: bool) -> RenderStats {
        let n = scene.mesh.triangle_count();
        if self.force_full_analysis.get() || n <= GPU_ANALYZE_SAMPLE_MAX {
            return analyze_scene(scene, "wgpu-vello", installed);
        }
        if self.analyze_sub.borrow().is_none() {
            *self.analyze_sub.borrow_mut() =
                Some(stride_sub_mesh(scene.mesh, GPU_ANALYZE_SAMPLE_MAX));
        }
        let sub_slot = self.analyze_sub.borrow();
        let sub = sub_slot.as_ref().expect("filled above");
        let ns = sub.triangle_count().max(1);
        // The sampled basemap, alone — the estimate's source. `sky_lut` never
        // reaches the pure geometry analysis, so it is not cloned into the probe.
        let base = analyze_scene(
            &RenderScene {
                mesh: sub,
                water: scene.water,
                camera: scene.camera.clone(),
                sun: scene.sun,
                viewport: scene.viewport,
                bg: scene.bg,
                flags: scene.flags,
                sky_lut: None,
                clock_s: scene.clock_s,
                du_per_m: scene.du_per_m,
                overlay: None,
                overlay_emissive: &[],
            },
            "wgpu-vello",
            installed,
        );
        // Scale the sampled visible count back to the full mesh, round-to-nearest.
        // `base.visible_triangles ≤ ns`, so the estimate never exceeds `n`.
        let scaled =
            ((base.visible_triangles as u64 * n as u64) + (ns as u64 / 2)) / ns as u64;
        if scene.overlay.is_some() {
            // Re-analyse the SAME sample with the overlay riding on it: the delta
            // between the two counts is the overlay's true visible count (exact,
            // not estimated — it is the live pose the user is watching), and the
            // combined pass carries the overlay's effect on the other oracles.
            let all = analyze_scene(
                &RenderScene {
                    mesh: sub,
                    water: scene.water,
                    camera: scene.camera.clone(),
                    sun: scene.sun,
                    viewport: scene.viewport,
                    bg: scene.bg,
                    flags: scene.flags,
                    sky_lut: None,
                    clock_s: scene.clock_s,
                    du_per_m: scene.du_per_m,
                    overlay: scene.overlay,
                    overlay_emissive: scene.overlay_emissive,
                },
                "wgpu-vello",
                installed,
            );
            let overlay_vis = all.visible_triangles.saturating_sub(base.visible_triangles);
            let mut s = all;
            s.visible_triangles = scaled as usize + overlay_vis;
            s
        } else {
            let mut s = base;
            s.visible_triangles = scaled as usize;
            s
        }
    }

    /// Test hook: force the paint-path stats refresh back to the UNBOUNDED full
    /// analysis (every triangle, every refresh) — the non-vacuity switch for the
    /// spike benchmark in `tests/gpu_analyze_spike.rs`, which must be able to show
    /// the defect it guards against.
    #[doc(hidden)]
    pub fn force_full_analysis_for_test(&self, on: bool) {
        self.force_full_analysis.set(on);
    }

    /// Test hook: the cached upload payload, compared by `Arc::ptr_eq` to prove the
    /// per-frame handoff re-uses ONE build across frames rather than rebuilding.
    #[doc(hidden)]
    pub fn cached_geometry_for_test(&self) -> Option<std::sync::Arc<crate::gpu::GpuGeometry>> {
        self.cache.borrow().clone()
    }
}

/// The bounded stride sample of `mesh` the paint-lane refresh analyses above
/// [`GPU_ANALYZE_SAMPLE_MAX`]: every `k`-th triangle (`k = ⌈n / cap⌉`) with its
/// vertices compacted into a fresh vertex pool, so the projection kernel's
/// per-VERTEX transform (`O(positions)`) is bounded along with the per-triangle
/// work — sampling only the index list would still transform every vertex of the
/// full mesh. Deterministic and a pure function of the mesh, so the result is
/// cached per mesh and the systematic stride keeps the estimate stable frame to
/// frame (no sampling flicker in a HUD readout).
#[cfg(feature = "wgpu")]
fn stride_sub_mesh(mesh: &Mesh3, cap: usize) -> Mesh3 {
    let n = mesh.triangle_count();
    let k = n.div_ceil(cap).max(1);
    let take = n.div_ceil(k);
    let mut remap: Vec<u32> = vec![u32::MAX; mesh.positions.len()];
    let mut sub = Mesh3::default();
    sub.positions.reserve(take * 3);
    sub.normals.reserve(take * 3);
    sub.building.reserve(take * 3);
    sub.colors.reserve(take);
    sub.indices.reserve(take * 3);
    for t in (0..n).step_by(k) {
        for j in 0..3 {
            let i = mesh.indices[t * 3 + j] as usize;
            if remap[i] == u32::MAX {
                remap[i] = sub.positions.len() as u32;
                sub.positions.push(mesh.positions[i]);
                sub.normals.push(mesh.normals[i]);
                sub.building.push(mesh.building.get(i).copied().unwrap_or(false));
            }
            sub.indices.push(remap[i]);
        }
        sub.colors.push(mesh.colors[t]);
    }
    sub
}

#[cfg(feature = "wgpu")]
impl RenderBackend for WgpuVelloBackend {
    fn caps(&self) -> BackendCaps {
        BackendCaps { name: "wgpu-vello", gpu: true, shadows: true, reflective_water: true, post_fx: true }
    }

    fn analyze(&self, scene: &RenderScene) -> RenderStats {
        // Same pure geometry oracle as every backend, tagged used_gpu = true: the
        // GPU lane projects the SAME scene (the CPU projection IS the oracle for
        // "what the camera framed"). Device-free, so it runs in the headless
        // conformance test alongside the CPU / null backends.
        analyze_scene(scene, "wgpu-vello", true)
    }

    fn reset_cache(&self) {
        *self.cache.borrow_mut() = None;
        // The stats belong to the mesh that was just dropped: a swapped scene gets a
        // fresh full analysis on its next frame, not up to GPU_ANALYZE_EVERY frames
        // of the old town's numbers.
        *self.stats_cache.borrow_mut() = None;
        // …and the stride sample belongs to that mesh too.
        *self.analyze_sub.borrow_mut() = None;
        self.frames_since_analyze.set(0);
    }

    fn paint(&self, scene: &RenderScene, ui: &mut egui::Ui) -> RenderStats {
        // Empty mesh → nothing for the GPU pass; the CPU painter draws the fallback.
        if scene.mesh.indices.is_empty() {
            return paint_cpu(scene, ui);
        }
        let rect = scene.viewport.rect;
        let cam = &scene.camera;
        let aspect = (rect.width() / rect.height().max(1e-3)).max(1e-3);
        let view_proj = cam.view_proj(aspect);
        let inv_view_proj = crate::sky::invert(&view_proj).unwrap_or({
            let mut id = [0.0f32; 16];
            id[0] = 1.0;
            id[5] = 1.0;
            id[10] = 1.0;
            id[15] = 1.0;
            id
        });

        let light = scene.sun.light_uniform();
        let sun_color = scene.sun.sun_uniform();
        let sky_color = scene.sun.sky_uniform();
        // `eye.w` carries the scene's **haze scale height in design units** — the one
        // render-side length that has to reach the fragment shader. See
        // `haze_scale_height`: 150 m of air, not 0.25 design units of nothing.
        let haze_h = haze_scale_height(scene.du_per_m);
        let eye = {
            let e = cam.eye();
            [e.x, e.y, e.z, haze_h]
        };
        let time = scene.clock_s as f32;
        let pbr = [0.62f32, 0.0, time, 1.4];
        let centre = [0.0, 0.4, 0.0];
        let light_vp = [
            scene.sun.light_view_proj(centre, 0.75),
            scene.sun.light_view_proj(centre, 1.35),
            scene.sun.light_view_proj(centre, 2.40),
        ];
        let cascade_splits = [cam.distance * 0.85, cam.distance * 1.6];
        let light_size = 0.45_f32;
        let shadow_strength = scene.flags.shadow_strength;
        let soft_shadows = scene.flags.soft_shadows;
        let ssao_on = scene.flags.ssao;
        let bloom_on = scene.flags.bloom;
        let ssao_radius = ssao_radius(scene.du_per_m);
        let ssao_intensity = 2.4_f32;
        let water_on = scene.flags.water_on;
        let reflect_on = water_on && scene.flags.planar_reflection;
        // The reflection camera mirrors about the plane the water grid was BUILT on —
        // `extrude::water_height` of this scene's scale, not the absolute constant. The
        // two used to be the same literal read from two places; a metric water plane
        // and an absolute mirror plane would put the reflection under the surface.
        let refl_vp = cam.view_proj_mirrored(aspect, crate::extrude::water_height(scene.du_per_m));
        // `shallow.w` is the Gerstner AMPLITUDE and `params.w` the base WAVELENGTH —
        // both are world lengths the vertex shader displaces `in.pos` by, so both go
        // through the scene's own scale exactly like `haze_scale_height` and
        // `ssao_radius` four lines up. As raw design units (`0.0020` / `0.14`) they
        // were 3.75 m of chop on a 262 m swell in korp's 3 km town window, and the
        // troughs cut clean through the terrain the water sits on.
        let wave_amp = crate::extrude::wave_amplitude(scene.du_per_m);
        let wave_len = crate::extrude::wave_wavelength(scene.du_per_m);
        let water_shallow = [0.18f32, 0.42, 0.52, wave_amp];
        let water_deep = [0.02f32, 0.10, 0.20, 0.28];
        let water_params = [0.45f32, 0.004, wave_len, 0.0];
        let clear = {
            let [r, g, b, a] = scene.bg.to_array();
            [f32::from(r) / 255.0, f32::from(g) / 255.0, f32::from(b) / 255.0, f32::from(a) / 255.0]
        };
        // ONE writer (`crate::fog`) — the CPU painter evaluates `FogRamp::t` from the
        // SAME struct, so the 4090 and the software lane cannot drift apart on haze.
        let fog = crate::fog::FogRamp::from_camera_distance(cam.distance).as_uniform();

        let ppp = ui.ctx().pixels_per_point();
        let px_w = (rect.width() * ppp).round().max(1.0) as u32;
        let px_h = (rect.height() * ppp).round().max(1.0) as u32;

        // Build + cache the GPU payload once; hand the SAME `Arc` to the callback
        // each frame — a refcount bump, and nothing more can happen here because
        // `GpuGeometry` is not `Clone` (the line this replaces deep-copied ~72 MB
        // per frame under a comment claiming "cheap Rc clone"). Re-sending every
        // frame is what lands the upload whichever frame the host's wgpu backend
        // `prepare`s first; the installed renderer's `upload` compares the payload's
        // build stamp and touches the device only when this cache was rebuilt.
        if self.cache.borrow().is_none() {
            *self.cache.borrow_mut() =
                Some(std::sync::Arc::new(crate::gpu::build_gpu_geometry_with_water(scene.mesh, scene.water)));
        }
        let geometry = self.cache.borrow().clone();

        // ── the per-frame overlay (V2.5 D.3): built FRESH each frame, deliberately ──
        // The overlay is a live pose (~100 verts), so caching would only add a staleness
        // seam; the payload is ~4 KB and the renderer writes it to a tiny dedicated
        // buffer — the main mesh cache above is untouched and its generation never
        // bumps for an overlay move.
        let overlay = scene
            .overlay
            .map(|m| std::sync::Arc::new(crate::gpu::build_overlay_geometry(m, scene.overlay_emissive)));

        ui.painter_at(rect).add(egui_wgpu::Callback::new_paint_callback(
            rect,
            crate::gpu::Map3dPaintCallback {
                geometry,
                overlay,
                view_proj,
                inv_view_proj,
                sky_on: scene.flags.sky,
                light,
                clear,
                fog,
                sun_color,
                sky_color,
                eye,
                pbr,
                light_vp,
                cascade_splits,
                light_size,
                soft_shadows,
                shadow_strength,
                // GFX_V2 item 7: derived from the view by `constrain_to_view`, passed
                // through verbatim. The GPU lane consults `any_post()`'s inverse here
                // rather than each pass deciding for itself.
                flat_2d: scene.flags.flat_2d,
                ssao_on,
                bloom_on,
                ssao_radius,
                ssao_intensity,
                refl_vp,
                water_on,
                reflect_on,
                water_shallow,
                water_deep,
                water_params,
                px_w,
                px_h,
                // Physical-sky transmittance LUT (FLIGHT-SIM #7): re-send the cheap Arc
                // each frame; the renderer only re-uploads the texels when it changes.
                lut: scene.sky_lut.clone(),
            },
        ));

        // **`used_gpu` is "the GPU lane DISPATCHED", not "this backend ran".** It was the
        // literal `true`, and this method runs whether or not a `Map3dRenderer` exists for
        // the callback above to dispatch to — the callback simply no-ops. Measured
        // 2026-08-06 on an RTX 4090: `with_gpu(true)` with no `install_renderer` reported
        // `used_gpu = Some(true)`, so the one observable that exists to separate the two
        // halves of the GPU path could not see either half missing. See
        // `crate::gpu::renderer_installed` for the honest bound.
        let installed = crate::gpu::renderer_installed();

        // ── SAMPLED scene analysis (the stats, not the pixels) ────────────────────
        // The full `analyze_scene` is a software transform+clip+shade+sort of every
        // mesh triangle, and this lane once ran it every frame purely to fill
        // `RenderStats` — during a drag, most of the frame's CPU went to computing a
        // statistic about pixels the GPU draws regardless. Two bounds keep it off
        // the frame: refresh only on the first frame after a mesh swap
        // (`reset_cache` clears `stats_cache`) and every `GPU_ANALYZE_EVERY`-th
        // frame — and the refresh itself is BOUNDED by `GPU_ANALYZE_SAMPLE_MAX`
        // (`refresh_stats`), because the frame sampling alone left a 1-in-16
        // multi-ms hitch at korp's 600 k-triangle budget, and a rhythmic stutter
        // reads as broken where random jitter reads as load (GFX_V2 V2.9b). In
        // between, re-report the last analysis. Only `used_gpu` is re-observed per
        // frame — it is per-process state that can change under us (an install
        // landing between frames). So on THIS lane `visible_triangles` means "as of
        // the most recent sampled analysis, at most GPU_ANALYZE_EVERY frames old —
        // and an ESTIMATE above the sample cap"; the field doc says so, and
        // `full_analyzes` keeps the skip observable.
        let refresh = self.stats_cache.borrow().is_none()
            || self.frames_since_analyze.get() + 1 >= GPU_ANALYZE_EVERY;
        let stats = if refresh {
            let s = self.refresh_stats(scene, installed);
            self.full_analyzes.set(self.full_analyzes.get() + 1);
            self.frames_since_analyze.set(0);
            *self.stats_cache.borrow_mut() = Some(s.clone());
            s
        } else {
            self.frames_since_analyze.set(self.frames_since_analyze.get() + 1);
            let mut s = self.stats_cache.borrow().clone().expect("refresh gate checked is_none");
            s.used_gpu = installed;
            s
        };

        // ── STEP 7 emit (GPU lane): the scissor oracle (ink confined by the GPU
        // scissor = viewport ∩ clip_rect, so ink_outside_rect is 0 by construction).
        #[cfg(feature = "testmatrix")]
        {
            let ppp_f = ui.ctx().pixels_per_point();
            let screen = {
                let s = ui.ctx().content_rect();
                [(s.width() * ppp_f).round().max(1.0) as u32, (s.height() * ppp_f).round().max(1.0) as u32]
            };
            let info = egui::PaintCallbackInfo {
                viewport: rect,
                clip_rect: rect,
                pixels_per_point: ppp_f,
                screen_size_px: screen,
            };
            let sc = crate::gpu::gpu_scissor_px(&info);
            let rx = (rect.left() * ppp_f).floor() as i64;
            let ry = (rect.top() * ppp_f).floor() as i64;
            let rr = (rect.right() * ppp_f).ceil() as i64;
            let rb = (rect.bottom() * ppp_f).ceil() as i64;
            let inside = i64::from(sc.x) >= rx
                && i64::from(sc.y) >= ry
                && i64::from(sc.x) + i64::from(sc.w) <= rr
                && i64::from(sc.y) + i64::from(sc.h) <= rb;
            facett_core::testmatrix::emit(
                "facett-map3d",
                "scissor",
                inside,
                &format!(
                    "lane=gpu ink_outside_rect=0 scissor=[{},{} {}x{}] rect_px=[{},{} {}x{}]",
                    sc.x,
                    sc.y,
                    sc.w,
                    sc.h,
                    rx,
                    ry,
                    (rr - rx).max(0),
                    (rb - ry).max(0)
                ),
            );
        }
        // ── GPU render-lane emit: this exact path RAN and framed real geometry. ──
        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-map3d::draw_map_gpu",
            "gpu_render",
            stats.visible_triangles > 0,
            &format!(
                "used_gpu=true visible_tris={} mesh_tris={}",
                stats.visible_triangles,
                scene.mesh.triangle_count()
            ),
        );
        stats
    }
}