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

nordisk/facett

facett-map/src/gpu/mod.rs

//! GPU render pipeline for OSM ways — **ported nearly verbatim** from
//! katana-osm's `osm-viewer/src/render/gpu/mod.rs` (`GpuMapRenderer`). The only
//! changes for facett: it imports `egui_wgpu` + `wgpu` as direct crates (the
//! source went through `eframe`'s re-exports), and `build_gpu_geometry` reads
//! the facett [`Way`]'s `style.color` instead of osm-viewer's `style.stroke`.
//!
//! Architecture:
//!  - All way geometry (all LODs) uploaded once to `geom_buf` in VRAM on the
//!    first frame.  Never touched again during pan/zoom. It is the DRAW's vertex
//!    buffer as well as the cull's input.
//!  - Each frame: update the viewport uniform → dispatch the compute cull →
//!    `draw_indexed_indirect` off `geom_buf` through `index_buf`.  Zero CPU vertex
//!    work per frame.
//!
//! Compute (cull.wgsl): one invocation per way. Frustum + LOD test; visible ways'
//! vertex INDICES are written into `index_buf` in bake order (an ordered
//! compaction — count, scan, copy). `indirect_buf` is the control block (binding 3):
//! the `DrawIndexedIndirectArgs` the draw consumes (index_count at offset 0) plus
//! the scan's scratch.
//!
//! ## VRAM is the geometry plus a sixth (GG audit row 21)
//! The compaction used to COPY vertices into a `compact_buf` allocated at the full
//! size of the chunk, so every chunk held its geometry twice: MEASURED on t14s /
//! Radeon 890M, 2026-09-06, 22 000 ways = 74.8 MB of geometry + meta → 147.8 MB
//! allocated, **1.975×**. An index is 4 B whatever the vertex is, so the compaction
//! now costs 1/6 of a 24 B stream and 1/12 of a 48 B one; `tests/vram_ratio.rs`
//! holds the whole bill at ≤ 1.2× and the arithmetic bound is in `upload_vertex_chunks`.
//!
//! ## Buffer chunking (fix #12)
//! A whole country (Sweden ≈ 314 MB of `Vertex`) exceeds wgpu's default
//! `max_buffer_size` (256 MiB), so a single `geom` buffer would fail
//! `Device::create_buffer` with a Validation Error and panic. The upload now
//! **splits the geometry into chunks**, each `geom`+`meta`+`compact` buffer kept
//! `≤ device.limits().max_buffer_size`, with **a `cull` dispatch + a
//! `draw_indirect` per chunk**. Ways are never split across a chunk boundary
//! (each way's vertices live wholly in one chunk; `vert_start` is chunk-relative),
//! so the proven per-way cull shader is unchanged. `chunk_count` /
//! `chunk_buffer_bytes()` are surfaced for the headless self-test.

#![allow(clippy::too_many_lines, reason = "verbatim port of the proven pipeline")]

use egui_wgpu::wgpu;
use egui_wgpu::wgpu::util::DeviceExt as _;

use crate::{Way, style::LOD_COUNT};

// ── GPU-side types (must match cull.wgsl and draw.wgsl layouts) ──────────────
//
// CONS-CORE Phase C: these are now **re-exported from `facett_core::render::gpu`**
// (the shared L0 plumbing) so the chunking helpers (`partition_chunks` etc.) and
// facett-map's `upload` agree on one type. The structs are byte-identical to the
// originals (same `#[repr(C)]` layout, same wgsl-matching offsets — the core
// `gpu_struct_sizes_match_wgsl_layouts` test pins this), so the cull/draw shader
// contracts are unchanged.
pub use facett_core::render::gpu::{
    DrawIndirectArgs, DrawUniforms, GpuWayMeta, Vertex, ViewportCull, rebase_vertex_origin,
};

// The pretty GPU line lane (triangle-strip quads + miter/bevel joins + SDF-AA +
// casing) — additive to the LineList pipeline below, dedicated `LineVertex` (48 B)
// so the pinned shared `Vertex` (24 B, facett-map3d) is untouched.
pub mod line;
/// The baked-vertex store hook + blob codec (the client-side NVMe tier's facett half).
pub mod bake;
pub use line::{
    LinePolyline, LineRenderer, LineVertex, MapLineUniforms, build_line_geometry,
    build_line_geometry_from_ways,
};

// ── Geometry payload (CPU → GPU, first frame only) ────────────────────────────

/// **The retained 1 px `LineList` hairline road lane — OFF.** (stinky `mapdraw-4`.)
///
/// The hairline lane was the original road draw. [`LineRenderer`]'s quad/miter/SDF-AA
/// lane superseded it and `MapPaintCallback::paint` has drawn only the quads ever
/// since — but the hairline stream was still BAKED, still uploaded to VRAM, still
/// compute-culled 60 times a second, and (since the baked-vertex tier landed) still
/// written to the on-disk blob. Its `compact_buf` and the `indirect_buf` counter the
/// cull atomically bumps were read by nothing.
///
/// MEASURED on the 20 000-way Sweden payload before this constant existed: **618 030
/// `Vertex` = 14.83 MB geom + 14.83 MB compact + 0.40 MB meta = 30.07 MB, 14.4 % of
/// the map's 208 MB of VRAM**, and 198 cull workgroups a frame whose entire output
/// was dead.
///
/// Kept as a constant rather than deleted because the fallback claim is written into
/// three doc comments in this file and they should stay honest: flipping this to
/// `true` restores the bake, the upload and the dispatch together — which is exactly
/// what "a one-line revert path" was supposed to mean, and what it did not mean while
/// a shipped frame was paying for it. `paint` still draws nothing from the lane; a
/// revert that wants pixels must also re-add the draw loop.
pub const HAIRLINE_LANE: bool = false;

/// All way geometry for GPU upload. Built once from `MapIndex`, then dropped.
pub struct GpuGeometry {
    /// The 24 B `LineList` hairline stream. **Empty unless [`HAIRLINE_LANE`]** — see
    /// that constant; nothing draws it.
    pub vertices: Vec<Vertex>,
    /// Per-way meta for [`Self::vertices`]. Empty unless [`HAIRLINE_LANE`].
    pub way_meta: Vec<GpuWayMeta>,
    /// **Pretty-line lane** geometry — triangle-list quads (48 B [`LineVertex`]) with
    /// per-vertex miter/bevel joins + screen-px width, built in the SAME pass (and
    /// with the SAME origin bake) as the raw [`Vertex`]/`way_meta` `LineList`
    /// geometry. The live paint callback draws THESE through the [`LineRenderer`]
    /// (casing + SDF-AA fill) instead of the old 1px hairlines.
    pub line_vertices: Vec<LineVertex>,
    /// Per-way metadata for the pretty-line lane, in **`LineVertex` units** — the
    /// quad-stream twin of [`Self::way_meta`]. This is what lets the GPU cull compact
    /// the quads it actually draws (`VPV = 12`) instead of the hairlines nobody
    /// draws, so the draw becomes a real `draw_indirect` off a GPU-computed count.
    /// Ordered lod-ascending, matching [`Self::line_vertices`].
    pub line_meta: Vec<GpuWayMeta>,
    /// Cumulative `line_vertices` length at the END of each LOD tier
    /// (`[country, region, city]`), because [`build_gpu_geometry`] is fed ways
    /// lod-ascending.
    ///
    /// **No longer drives the draw.** It was the CPU twin of the cull's cumulative
    /// `lod <= vp.lod` test, back when `paint` drew a CPU-computed prefix range; the
    /// cull now applies that test itself, per way, on the GPU — alongside the frustum
    /// test this lane never had. Retained as an uploaded-geometry fact (the self-test
    /// asserts the tiers tile the quad buffer).
    pub line_lod_end: [u32; LOD_COUNT],
    /// **Fill lane** geometry — the filled ways' cached ear clip, expanded to a
    /// triangle list, plus their ring outlines as a line list. Empty for every
    /// caller that does not build it (see [`build_gpu_fill_geometry`]), which is
    /// what keeps the existing line-only callers byte-identical.
    pub fills: GpuFillGeometry,
}

/// **The GPU fill lane's payload** — what a filled way costs the device, once,
/// instead of what it cost the CPU painter every frame.
///
/// MEASURED 2026-08-03 on a real 1600×1000 window against an RTX 4090
/// (Vulkan, DiscreteGpu), 20 000 real Swedish ways, median of 200 frames, fills
/// only: `MapFacet::ui` was 10.08 ms, of which **7.99 ms remained with BOTH the
/// mesh and the outline stroke suppressed**. The tessellation was already cached
/// (`FillTessCache`); what was left is `w.lonlat.clone()` per way and
/// `MapView::project` per vertex — a `tan` and a `ln` (Mercator) for every one of
/// 81 906 vertices, 60 times a second, to re-derive a number that had not changed.
///
/// That is why BOTH streams are here. Moving only the mesh leaves the projection on
/// the CPU and buys nothing: the ring still has to be projected to stroke its
/// outline. The lane is only a lane if the filled way stops being touched per frame
/// at all.
///
/// The triangle stream is 24 B [`Vertex`] culled by `cull.wgsl` at its default
/// `VPV = 6`; the outline stream is 48 B [`LineVertex`] culled by the SAME shader at
/// `VPV = 12`, exactly as the road quads are (LAW #5 — one cull, one expander, one
/// pipeline). The outline stream used to be a raw `LineList` on `draw_pipeline`; see
/// [`GpuFillGeometry::outline_vertices`] for what that cost in pixels.
///
/// ## What the ratio actually is, per payload
///
/// The numbers above come from `sweden-recut/ways.parquet`, a 20 000-way monolith
/// extract — and that is **not what korp serves**. The live tile pyramid under
/// `~/.korp/work/sweden/tiles` carries **2.0–2.3 vertices per way** and, over 120
/// sampled tiles, **zero polygons and zero hole vertices** (`stinky` `prof-6`,
/// `prof-7`): osm-katana's `tile_pixel_tol` simplifies to one pixel of a 256 px
/// tile while facett draws that tile across 1920, so Douglas-Peucker keeps little
/// more than the endpoints. A two-point "ring" yields no triangles at all, which is
/// why the *tessellation* is 0.9 % of a served frame rather than 85 %.
///
/// The lane still pays for itself there, because the cost it removes was never
/// mainly the ear clip — it is the per-frame Mercator (`tan` + `ln` per vertex) and
/// the clone in front of it, and those scale with way COUNT, which the pyramid does
/// not reduce. MEASURED 2026-08-03, the SAME release binary with the renderer
/// installed and not (`NOGPU=1`), real 1600×1000 window, RTX 4090 Vulkan
/// DiscreteGpu, median of 200 frames:
///
/// ```text
///   payload                                   layers   CPU        GPU       ratio
///   sweden-recut monolith, 20 000 ways, z6    all      67.76 ms   0.975 ms   69.5x
///                                             lines    11.28 ms   0.332 ms   34.0x
///                                             fills    10.60 ms   0.580 ms   18.3x
///   served tile z12/2253/1204 (densest)       all      15.85 ms   0.593 ms   26.7x
///                                             lines     4.54 ms   0.364 ms   12.5x
///                                             fills     2.72 ms   0.229 ms   11.9x
///   served tile z6/34/19 (densest z6)         all       1.23 ms   0.534 ms    2.3x
/// ```
///
/// So: 11.9× on the densest served tile's fills, not 17.3×, and only 2.3× whole-pane
/// on a z6 tile that has almost no geometry left to draw. The 69.5× is real for the
/// monolith path and is what a fixed `tile_pixel_tol` would move the pyramid toward
/// — the lane is built for the geometry the pyramid is *supposed* to carry, and it
/// is already the larger win on the geometry it carries today.
#[derive(Default)]
pub struct GpuFillGeometry {
    /// The fill triangles, **expanded** (3 vertices per triangle; the bake carries no
    /// index buffer). The cull's compaction IS an index list now (GG audit row 21), so
    /// the device draws these indexed — but the indices it draws through are the
    /// cull's per-frame survivors, not a baked de-duplication of shared corners. That
    /// de-duplication would be a bake-format change on top; at 20 000 Swedish ways the
    /// whole expansion is 5.6 MB, so it has not earned one.
    pub tri_vertices: Vec<Vertex>,
    /// One entry per filled way: its origin-local bbox, its span in
    /// [`Self::tri_vertices`], its LOD tier.
    pub tri_meta: Vec<GpuWayMeta>,
    /// The same ways' outer + hole rings as **closed anti-aliased quads** — the stroke
    /// the CPU painter draws with `Shape::line(ring, Stroke::new(width, colour))`,
    /// through the SAME [`LineExpander`](line::LineExpander) the road lane uses.
    ///
    /// It was a raw 24 B [`Vertex`] `LineList` (stinky `mapgpu-1`): a hard 1 px
    /// hairline with no coverage ramp, against an epaint stroke feathered over ~2 px.
    /// MEASURED on this box 2026-08-22, a 7x7 grid of small lakes rendered through both
    /// lanes on an RTX 4090: the CPU pane held **80 distinct colours** and the GPU pane
    /// **5** — the whole image, not a crop. At this zoom the outline, not the fill, is
    /// most of what a small area reads as on screen, so the ramp is not cosmetic.
    pub outline_vertices: Vec<line::LineVertex>,
    /// One entry per filled way, spanning ALL its rings, so the cull keeps a way's
    /// outer ring and its holes together (a hole outline without its outer ring is a
    /// floating island).
    pub outline_meta: Vec<GpuWayMeta>,
}

impl GpuFillGeometry {
    /// Whether this payload has anything for the device to draw.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.tri_meta.is_empty() && self.outline_meta.is_empty()
    }
}

/// Screen-px per themed `style.width` unit for the pretty-line lane. `style.width`
/// tops out ≈ 9 (motorway), so ×1.0 yields 1–9 px cased roads — the same on-screen
/// ceiling the CPU painter's `style.width * zoom_w` reaches at mid zoom. Width is
/// baked once (screen-space px), so a road keeps a constant on-screen thickness at
/// any zoom (the module's stated design).
const LINE_PX_PER_WIDTH: f32 = 1.0;

/// Dark casing ink drawn UNDER the fill (the road outline), extra half-width, and
/// AA edge softness for the live pretty-line lane. Constants here (the callback has
/// no palette handle); a wider rework could thread the palette's casing colour.
/// The FALLBACK casing ink, for a callback built without a palette. `MapFacet` always
/// supplies `theme.palette.casing`; this near-black navy is only what a bare
/// `MapPaintCallback { .. Default::default() }` in a test gets, and it is the value the
/// lane shipped with before the palette was threaded through.
///
/// Why the palette had to be threaded (`kalm-4`, second half). This constant WAS the
/// casing on every map korp draws, and on the cartographic DAY palette the casing is
/// `#B7AE9E`, a warm grey on warm paper — while what actually reached the screen was
/// `#191C24`, near-black navy. MEASURED with `korp tools/mapdrive/ink.py` on the live
/// z12/z15 panes: the palette's own casing hue covered 0.03-0.06 % of the canvas, i.e.
/// it was not on the map at all, while the pane still carried 0.85 % ink darker than
/// grey 70. A light paper map cased in navy is the one colour decision no palette
/// switch could reach.
const LINE_CASING_COL: [f32; 4] = [0.10, 0.11, 0.14, 1.0];
/// **Ceiling** on the casing's extra half-width (px) — was the flat value.
const LINE_CASING_PX: f32 = crate::style::CASING_MAX_PX;

/// The casing law, from [`crate::style`] — the ONE place the ratio, the floor and the
/// ceiling are written (`kalm-4`). They used to be three literals here and the same
/// three numbers again in `line.wgsl`'s uniform doc, with the CPU painter carrying no
/// casing at all; `style::casing_extra_px` is now what both lanes resolve through.
const LINE_CASING_RATIO: f32 = crate::style::CASING_RATIO;
const LINE_CASING_MIN_PX: f32 = crate::style::CASING_MIN_PX;
const LINE_FEATHER_PX: f32 = 1.0;

/// **`instance_count` of a ROAD chunk's draw: the casing and the fill** (GG audit
/// row 21). Instance 0 reads the casing uniform, instance 1 the fill uniform
/// (`line.wgsl` selects on `instance_index`), and the GPU rasterises instance 0's
/// primitives before instance 1's — so the casing still lands under the fill, chunk by
/// chunk, exactly as the two draws it replaces did (lane GG #26's argument is
/// unchanged; only the command count is). Rust twin of `line.wgsl`'s `array<Uniforms, 2>`.
const ROAD_INSTANCES: u32 = 2;
/// A fill-OUTLINE chunk has no casing pass (stinky `mapgpu-1`): one instance.
const OUTLINE_INSTANCES: u32 = 1;

// ── Persistent GPU resources ──────────────────────────────────────────────────

/// One chunk of way geometry: a `geom`+`meta`+`index`+`indirect` set whose
/// buffers each stay `≤ max_buffer_size`. Each holds a contiguous slice of ways
/// (no way straddles a boundary), so the per-way cull shader runs unchanged with
/// chunk-relative `vert_start`. One `cull` dispatch + one `draw_indexed_indirect`
/// per chunk: the geometry is the vertex buffer, the cull's index list the index
/// buffer, and the control block the args.
struct GeomChunk {
    /// The geometry — the cull's binding 1 AND the draw's vertex buffer
    /// (`STORAGE | VERTEX`). Uploaded once, never rewritten.
    geom_buf: wgpu::Buffer,
    #[allow(dead_code, reason = "kept alive for cull_bind")]
    meta_buf: wgpu::Buffer,
    /// **The cull's output: one `u32` vertex index per surviving vertex, in bake
    /// order** — the draw's index buffer (`INDEX | STORAGE`). Sized to one index per
    /// vertex of the chunk, which is the most a compaction can produce and 1/6 of
    /// what the vertex copy it replaced had to be sized to (GG audit row 21).
    index_buf: wgpu::Buffer,
    /// The control block: `DrawIndexedIndirectArgs` at offset 0 (the cull's scan
    /// pass STORES `index_count`) plus the ordered compaction's scratch — see
    /// [`GpuMapRenderer::ctl_bytes`] and `cull.wgsl`'s binding-3 note.
    indirect_buf: wgpu::Buffer,
    cull_bind: wgpu::BindGroup,
    way_count: u32,
    /// Byte sizes of the chunk's four buffers — surfaced to the self-tests so the
    /// chunking and the VRAM bill are provable as data.
    geom_bytes: u64,
    meta_bytes: u64,
    index_bytes: u64,
    ctl_bytes: u64,
}

impl GeomChunk {
    /// Every byte this chunk holds on the card.
    fn bytes(&self) -> u64 {
        self.geom_bytes + self.meta_bytes + self.index_bytes + self.ctl_bytes
    }
}

/// One chunk of the **pretty-line quad** stream, with its own cull + indirect set.
///
/// The mirror of [`GeomChunk`] for the 48 B `LineVertex` lane. Before this existed
/// the quad lane had no cull at all: `paint` drew a CPU-computed LOD prefix of every
/// road in the dataset, every frame, with no frustum test — while the cull compute
/// pass ran anyway and compacted the 24 B hairline stream that nothing drew.
struct LineChunk {
    /// The baked quads — the cull's binding 1 AND the draw's vertex buffer
    /// (`STORAGE | VERTEX`, plus `COPY_SRC` for the test readback that resolves the
    /// index list back into the vertices the shader pulls).
    src_buf: wgpu::Buffer,
    #[allow(dead_code, reason = "kept alive for line_cull_bind")]
    meta_buf: wgpu::Buffer,
    /// The cull's index list — the draw's index buffer. See [`GeomChunk::index_buf`].
    index_buf: wgpu::Buffer,
    /// The control block — see [`GeomChunk::indirect_buf`].
    indirect_buf: wgpu::Buffer,
    cull_bind: wgpu::BindGroup,
    way_count: u32,
    /// `instance_count` the per-frame reset writes into the args: **2** for the road
    /// stream (instance 0 is the casing, instance 1 the fill — one draw, two passes,
    /// GG audit row 21) and **1** for the fill-outline stream, which has no casing.
    instances: u32,
    src_bytes: u64,
    meta_bytes: u64,
    index_bytes: u64,
    ctl_bytes: u64,
}

impl LineChunk {
    /// Every byte this chunk holds on the card.
    fn bytes(&self) -> u64 {
        self.src_bytes + self.meta_bytes + self.index_bytes + self.ctl_bytes
    }
}

pub struct GpuMapRenderer {
    /// The `LineList` pipeline — **the retained hairline fallback, and nothing draws
    /// it.** It was the raw 1 px road draw; the pretty [`LineRenderer`] superseded that
    /// (`mapdraw-4`, [`HAIRLINE_LANE`]), and it then had a second life as the
    /// fill-OUTLINE draw until stinky `mapgpu-1` measured what a topology with no
    /// coverage ramp costs: 5 distinct colours in a GPU pane against 80 in the CPU pane
    /// it is meant to look like. The outlines now ride the quad lane too.
    ///
    /// Kept, not deleted, for the same reason `HAIRLINE_LANE` is a `const` and not a
    /// `git revert`: it is one `set_pipeline` away from being an A/B, and it costs one
    /// pipeline object at renderer construction and nothing per frame. Its twin
    /// [`Self::fill_pipeline`] is built from the SAME descriptor closure and IS drawn,
    /// so this is not an unexercised code path — only an unsubmitted topology.
    #[allow(dead_code, reason = "retained LineList A/B path; superseded by LineRenderer for BOTH streams — mapgpu-1")]
    draw_pipeline: wgpu::RenderPipeline,
    draw_uniform: wgpu::Buffer,
    #[allow(dead_code, reason = "the bind for the retained draw_pipeline above")]
    draw_bind: wgpu::BindGroup,
    /// The **fill** pipeline: byte-for-byte `draw_pipeline` with
    /// `PrimitiveTopology::TriangleList`. One shader (`draw.wgsl`), one uniform
    /// block, one vertex layout — the topology is the only difference, so a filled
    /// area and a road are projected by the same line of WGSL (LAW #5).
    fill_pipeline: wgpu::RenderPipeline,
    /// The fill draw's own copy of the uniform block — identical to
    /// [`Self::draw_uniform`] except for [`DrawUniforms::tint`], which carries the
    /// pitched-horizon fade the CPU painter applies to a FILL and not to its
    /// outline. Two buffers off one layout is what lets that stay a *draw*-scoped
    /// value without a dynamic offset.
    fill_uniform: wgpu::Buffer,
    fill_bind: wgpu::BindGroup,

    cull_pipeline: wgpu::ComputePipeline,
    /// The ordered compaction's count + scan passes (see `cull.wgsl`). Same module,
    /// same layout, no `VPV` — they move offsets, not vertices.
    cull_count_pipeline: wgpu::ComputePipeline,
    cull_scan_pipeline: wgpu::ComputePipeline,
    cull_bgl: wgpu::BindGroupLayout,
    cull_vp_buf: wgpu::Buffer,

    geom_ready: bool,
    /// All geometry, split so each buffer is `≤ max_buffer_size` (fix #12).
    chunks: Vec<GeomChunk>,
    /// The device's `max_buffer_size` captured at construction — the chunk budget.
    max_buffer_size: u64,

    /// **Pretty-line lane** — the quad/miter/SDF-AA/casing pipeline that REPLACES
    /// the raw `LineList` hairlines the `draw_pipeline` above emitted. Its own
    /// pipeline + uniform buffers (self-contained; the pinned `Vertex`/`DrawUniforms`
    /// are untouched).
    line_renderer: LineRenderer,
    /// The uploaded pretty-line chunks. Split so each buffer stays
    /// `≤ max_buffer_size` — the same fix-#12 partition `chunks` uses, over the 48 B
    /// `LineVertex` stride (`partition_chunks_stride`), so a way never straddles a
    /// boundary and the per-way cull runs unchanged with chunk-relative `vert_start`.
    /// Culled by the ONE [`Self::cull_pipeline`]: the compaction is an index list, so
    /// the 48 B stride is the vertex layout's business and never the cull's.
    line_chunks: Vec<LineChunk>,
    /// Cumulative pretty-line vertex count at the end of each LOD tier (mirrors
    /// [`GpuGeometry::line_lod_end`]). Retained as an uploaded-geometry fact; the LOD
    /// filter itself now runs per-way in the cull, not as a CPU prefix here.
    line_lod_end: [u32; LOD_COUNT],

    /// **The map's own colour+depth targets, ONE PER PANE** (lane MP-a; per pane since
    /// GG #37). Empty until the first `prepare`. A pane's target is allocated when its
    /// key is first seen and again when the surface size changes — never per frame —
    /// and freed once the pane has gone a whole frame without a `prepare`. See
    /// [`PaneTarget`] for why the blit uniform lives there too.
    panes: Vec<PaneTarget>,
    /// The surface format the offscreen colour target must match.
    target_format: wgpu::TextureFormat,
    /// The pipeline that blits a pane's [`OffscreenTarget::resolved`] into egui's pass.
    blit_pipeline: wgpu::RenderPipeline,
    blit_bgl: wgpu::BindGroupLayout,
    blit_sampler: wgpu::Sampler,
    /// The queue, kept from `prepare` so `paint` can write the blit's UV rect — the
    /// pane rect is only knowable from `PaintCallbackInfo`, which `prepare` never sees.
    queue: Option<wgpu::Queue>,

    /// **The in-GPU pass clock** for this lane (perf/v2.3): TIMESTAMP_QUERY around
    /// the cull compute pass, resolved non-blocking into
    /// `facett_core::render::lane`'s clock stats. Lazily probed on first `prepare`
    /// (the queue's timestamp period is not available at construction). What it
    /// times is the CULL pass — the one pass this lane records into its own
    /// encoder; the fill/line draws ride egui's shared render pass in `paint`,
    /// which a callback cannot bracket (no encoder there, and the pass belongs to
    /// egui). The stats doc says so rather than pretending to cover the draws.
    pass_clock: facett_core::render::gpu::PassClockSlot,

    /// **Fill lane** — the filled ways' cached ear clip as a triangle list, culled by
    /// the same `cull.wgsl` at `VPV = 6` and drawn by [`Self::fill_pipeline`].
    fill_chunks: Vec<GeomChunk>,
    /// **Fill-outline lane** — the same ways' closed rings as a line list, drawn by
    /// [`Self::draw_pipeline`]. A separate chunk set rather than a second stream in
    /// `fill_chunks`, because a `LineList` and a `TriangleList` cannot share one draw
    /// and the cull compacts per buffer.
    outline_chunks: Vec<LineChunk>,

    /// The draw/fill uniform bind-group LAYOUT, kept so the TILE lane can mint
    /// per-tile uniform binds against the same pipelines.
    draw_bgl: wgpu::BindGroupLayout,
    /// **The TILE lane's VRAM cache** — content-keyed chunk sets that SURVIVE facet
    /// rebuilds. The whole point: korp rebuilds its facet on every viewport apply,
    /// and the legacy path re-uploaded the entire payload each time; here a pan
    /// re-uploads only the tiles that are genuinely new, and a zoom back onto data
    /// the cache still holds uploads NOTHING.
    tile_gpu: std::collections::HashMap<u128, TileGpu>,
    /// This frame's draw set, in hand-over order: `(key, transform, cull bbox, draw
    /// band)`. Written by `prepare`, read by `paint` — the same one-frame contract
    /// `geom_ready` carries for the legacy path.
    ///
    /// **Band-major.** The facet sorts its hand-over on `(band, tile_key)`, so equal
    /// bands are contiguous here and `paint` can walk one run per band without sorting
    /// anything itself.
    tile_current: Vec<(u128, MapTransform, [f32; 4], i32)>,
    /// Frame counter for the tile LRU (bumped once per tiled prepare).
    tile_frame: u64,
    /// Test override for the tile VRAM budget (None = the env/default budget).
    tile_budget_override: Option<u64>,
    /// **How many cull compute passes [`Self::dispatch_cull`] has recorded**, over
    /// both lanes. The still-frame contract's observable (lane GG #13): 100 frames
    /// with the camera at rest must leave this where the first frame put it. A
    /// count and not a stopwatch, for the reason `tests/still_frame_work.rs` gives —
    /// work that does not happen is invisible in the output, and *0 dispatches over
    /// 100 still frames* is the same answer on any box. Atomic because
    /// `dispatch_cull` is `&self` (it records into a borrowed pass).
    cull_dispatches: std::sync::atomic::AtomicU64,
    /// Bumped by every [`Self::upload_tile`] and every eviction — the "nothing
    /// uploaded" half of the still-frame gate in [`Self::run_cull_tiles_clocked`].
    tile_upload_gen: u64,
    /// **The still-frame gate's memory, ONE ENTRY PER PANE** — lane HO-T4. See
    /// [`TileCullGate`]. Two or three entries at the very most (a main map plus an
    /// inset), walked linearly; the same shape and the same eviction rule as
    /// [`Self::panes`], for the same reason.
    tile_gates: Vec<TileCullGate>,
}

/// **What one PANE's last tile cull was computed from** — lane HO-T4.
///
/// GG #13's still-frame gate held this on the RENDERER, and GG #37 then gave every pane
/// its own [`PaneTarget`]: with a main map and an inset up, the two callbacks alternated
/// through the one gate, neither ever matched its own previous frame, and #13's
/// zero-dispatch rest was lost for as long as the inset was on screen — MEASURED on t14s
/// / Radeon 890M, `tests/gpu_lane_two_panes.rs`: 0 cull dispatches over 16 still frames
/// with one pane, **32** with two (2.00 per frame, one per pane).
///
/// So the memory is keyed by [`PaneStamp::pane`], exactly as the target is, and evicted
/// by exactly the same `retain(last_frame + 1 >= frame_nr)` rule — a pane that stops
/// being drawn leaks neither its target nor its gate.
struct TileCullGate {
    /// [`PaneStamp::pane`] — which pane's gate this is.
    key: u64,
    /// The last [`PaneStamp::frame_nr`] this gate was consulted in (the eviction rule).
    last_frame: u64,
    /// **What the last tile cull FOR THIS PANE was computed from** — the scalar inputs
    /// plus the upload generation they were seen under. `None` until this pane's first
    /// cull. (`tile_upload_gen` stays per renderer on purpose: the tile VRAM cache is
    /// shared, so an upload made under one pane really does invalidate the other's
    /// indirect counters.)
    last: Option<(TileFrameInputs, u64)>,
    /// The draw set that cull ran over — the retained twin of `tile_current`, compared
    /// element-wise (no hash, so no collision can ever skip a cull that was due).
    /// Cleared and refilled in place, never reallocated per frame.
    current: Vec<(u128, MapTransform, [f32; 4], i32)>,
    /// How many times this pane's gate let the body through. The per-pane half of
    /// How many times this pane's gate let the body through — the per-pane half of
    /// `cull_dispatches`, read by [`GpuMapRenderer::tile_cull_dispatches_for_pane`].
    dispatches: u64,
}

/// **Everything a tile frame's per-tile writes are a function of, besides the draw
/// set itself** — the cull viewport's scalars (`lod`, `min_fill_extent`) and the five
/// draw uniforms' inputs (pane size, fill tint, casing colour, width scale). Two
/// frames with equal inputs AND an equal `tile_current` AND no upload between them
/// write identical bytes into identical buffers and dispatch an identical cull, which
/// is why [`GpuMapRenderer::run_cull_tiles_clocked`] may skip the second one
/// outright. `f32` compares with `==`, so a NaN input can never compare equal and
/// never wrongly skips — it only ever redoes.
#[derive(Clone, Copy, Debug, PartialEq)]
struct TileFrameInputs {
    lod: u32,
    min_fill_extent: f32,
    logical_w: f32,
    logical_h: f32,
    fill_tint: f32,
    casing_col: [f32; 4],
    width_scale: f32,
}

impl GpuMapRenderer {
    pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
        // ── Draw pipeline ─────────────────────────────────────────────────────
        let draw_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("facett_map_draw"),
            // THE draw shader — one copy, owned by facett-core. This crate used to
            // carry a byte-identical `src/gpu/draw.wgsl` next to core's, kept honest
            // by a guard test; the twin is gone (LAW #5: fix by construction).
            source: wgpu::ShaderSource::Wgsl(
                facett_core::render::gpu::wgsl(facett_core::render::gpu::DRAW_WGSL).into(),
            ),
        });

        let draw_uniform = alloc_buffer(device, &wgpu::BufferDescriptor {
            label: Some("draw_uniforms"),
            size: std::mem::size_of::<DrawUniforms>() as u64,
            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        });

        let fill_uniform = alloc_buffer(device, &wgpu::BufferDescriptor {
            label: Some("fill_uniforms"),
            size: std::mem::size_of::<DrawUniforms>() as u64,
            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        });

        let draw_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("draw_bgl"),
            entries: &[wgpu::BindGroupLayoutEntry {
                binding: 0,
                // FRAGMENT too, since `DrawUniforms::tint` is read there. A block the
                // fragment stage cannot see makes `create_render_pipeline` fail loudly
                // at bring-up rather than drawing untinted, which is the failure mode
                // that would have looked like "the tilt fade just does not work".
                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
                ty: wgpu::BindingType::Buffer {
                    ty: wgpu::BufferBindingType::Uniform,
                    has_dynamic_offset: false,
                    min_binding_size: None,
                },
                count: None,
            }],
        });

        let bind_of = |label: &str, buf: &wgpu::Buffer| {
            device.create_bind_group(&wgpu::BindGroupDescriptor {
                label: Some(label),
                layout: &draw_bgl,
                entries: &[wgpu::BindGroupEntry { binding: 0, resource: buf.as_entire_binding() }],
            })
        };
        let draw_bind = bind_of("draw_bg", &draw_uniform);
        let fill_bind = bind_of("fill_bg", &fill_uniform);

        // ONE descriptor body, two topologies — a `LineList` for the hairline /
        // fill-outline stream and a `TriangleList` for the fill stream. Written as a
        // closure rather than twice, so the shader, the vertex layout, the blend and
        // the bind-group layout cannot drift between the two (LAW #5); the topology
        // is the only thing that varies and it is the only argument.
        let draw_pll = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
            label: Some("draw_pll"),
            bind_group_layouts: &[Some(&draw_bgl)],
            immediate_size: 0,
        });
        let make_draw_pipeline = |label: &str, topology: wgpu::PrimitiveTopology| {
            device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
                label: Some(label),
                layout: Some(&draw_pll),
                vertex: wgpu::VertexState {
                    module: &draw_shader,
                    entry_point: Some("vs_main"),
                    compilation_options: wgpu::PipelineCompilationOptions::default(),
                    buffers: &[Some(wgpu::VertexBufferLayout {
                        array_stride: std::mem::size_of::<Vertex>() as u64,
                        step_mode: wgpu::VertexStepMode::Vertex,
                        attributes: &[
                            wgpu::VertexAttribute {
                                format: wgpu::VertexFormat::Float32x2,
                                offset: 0,
                                shader_location: 0,
                            },
                            wgpu::VertexAttribute {
                                format: wgpu::VertexFormat::Float32x4,
                                offset: 8,
                                shader_location: 1,
                            },
                        ],
                    })],
                },
                fragment: Some(wgpu::FragmentState {
                    module: &draw_shader,
                    entry_point: Some("fs_main"),
                    compilation_options: wgpu::PipelineCompilationOptions::default(),
                    targets: &[Some(wgpu::ColorTargetState {
                        format: target_format,
                        blend: Some(wgpu::BlendState::ALPHA_BLENDING),
                        write_mask: wgpu::ColorWrites::ALL,
                    })],
                }),
                primitive: wgpu::PrimitiveState { topology, ..Default::default() },
                // Lane MP-a: the map draws into its OWN colour+depth target, so the rank
                // ordering survives being split across tiles and chunks.
                depth_stencil: Some(map_depth_state()),
                // 4×, because the target is 4× — see `map_msaa_state`. A pipeline left on
                // the shared no-MSAA state here is a wgpu validation failure at draw time.
                multisample: map_msaa_state(),
                multiview_mask: None,
                cache: None,
            })
        };
        let draw_pipeline = make_draw_pipeline("draw_pipeline", wgpu::PrimitiveTopology::LineList);
        let fill_pipeline = make_draw_pipeline("fill_pipeline", wgpu::PrimitiveTopology::TriangleList);

        // ── Cull compute pipeline ─────────────────────────────────────────────
        let cull_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("facett_map_cull"),
            // THE cull shader — one copy, owned by facett-core (see the draw shader
            // above). No prelude: the compute kernel needs none of the shared maths.
            source: wgpu::ShaderSource::Wgsl(facett_core::render::gpu::CULL_WGSL.into()),
        });

        let cull_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("cull_bgl"),
            entries: &[
                bgl_storage(0, false),
                bgl_storage(1, false),
                bgl_storage(2, true),
                bgl_storage(3, true),
                bgl_uniform(4),
            ],
        });

        let cull_pll = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
            label: Some("cull_pll"),
            bind_group_layouts: &[Some(&cull_bgl)],
            immediate_size: 0,
        });

        // The copy pass writes INDICES, so ONE pipeline compacts the 24 B `Vertex`
        // streams and the 48 B `LineVertex` streams alike (GG audit row 21). It used to
        // be two pipelines off one module, specialised by a `VPV` words-per-vertex
        // override; the override is gone because nothing in the cull knows a vertex
        // any more.
        let cull_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
            label: Some("cull_pipeline"),
            layout: Some(&cull_pll),
            module: &cull_shader,
            entry_point: Some("main"),
            compilation_options: wgpu::PipelineCompilationOptions::default(),
            cache: None,
        });

        // ── The ordered compaction's first two passes: ONE pipeline each, serving
        //    every stream (LAW #5).
        let cull_count_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
            label: Some("cull_count_pipeline"),
            layout: Some(&cull_pll),
            module: &cull_shader,
            entry_point: Some("count_pass"),
            compilation_options: wgpu::PipelineCompilationOptions::default(),
            cache: None,
        });
        let cull_scan_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
            label: Some("cull_scan_pipeline"),
            layout: Some(&cull_pll),
            module: &cull_shader,
            entry_point: Some("scan_pass"),
            compilation_options: wgpu::PipelineCompilationOptions::default(),
            cache: None,
        });

        let cull_vp_buf = alloc_buffer(device, &wgpu::BufferDescriptor {
            label: Some("cull_vp"),
            size: std::mem::size_of::<ViewportCull>() as u64,
            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        });

        // ── Pretty-line pipeline (quads + miter joins + SDF-AA + casing) ──────
        let line_renderer = LineRenderer::new(device, target_format);

        // ── the BLIT: the map's offscreen colour into egui's pass (lane MP-a) ──
        let blit_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("facett_map_blit"),
            source: wgpu::ShaderSource::Wgsl(include_str!("blit.wgsl").into()),
        });
        let blit_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("facett_map_blit_bgl"),
            entries: &[
                wgpu::BindGroupLayoutEntry {
                    binding: 0,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Texture {
                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
                        view_dimension: wgpu::TextureViewDimension::D2,
                        multisampled: false,
                    },
                    count: None,
                },
                wgpu::BindGroupLayoutEntry {
                    binding: 1,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
                    count: None,
                },
                // NOT `bgl_uniform(2)`: that helper declares COMPUTE visibility, which
                // is right for the cull's bindings and wrong here — the blit reads the
                // UV rect in the VERTEX stage.
                wgpu::BindGroupLayoutEntry {
                    binding: 2,
                    visibility: wgpu::ShaderStages::VERTEX,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Uniform,
                        has_dynamic_offset: false,
                        min_binding_size: None,
                    },
                    count: None,
                },
            ],
        });
        let blit_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
            label: Some("facett_map_blit"),
            layout: Some(&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
                label: Some("facett_map_blit_layout"),
                bind_group_layouts: &[Some(&blit_bgl)],
                immediate_size: 0,
            })),
            vertex: wgpu::VertexState {
                module: &blit_shader,
                entry_point: Some("vs_main"),
                compilation_options: wgpu::PipelineCompilationOptions::default(),
                buffers: &[],
            },
            fragment: Some(wgpu::FragmentState {
                module: &blit_shader,
                entry_point: Some("fs_main"),
                compilation_options: wgpu::PipelineCompilationOptions::default(),
                targets: &[Some(wgpu::ColorTargetState {
                    format: target_format,
                    // **PREMULTIPLIED, and that is a correctness fix, not a preference.**
                    //
                    // The offscreen target clears to transparent black and every draw into
                    // it uses `ALPHA_BLENDING`, whose colour equation is
                    // `src·srcA + dst·(1−srcA)`. Against a zero destination that leaves
                    // `α·ink` in the texture, and it stays consistent through every
                    // subsequent draw: what the resolve produces is PREMULTIPLIED colour
                    // with its own correct alpha beside it.
                    //
                    // Blitting that with `ALPHA_BLENDING` multiplies by the alpha a SECOND
                    // time, so everywhere the map is not fully opaque — which is every
                    // road edge, every AA ramp, every partial fill — the ink lands at
                    // `α²` of its strength. Opaque interiors hide it completely (`α = 1`),
                    // which is why it survived MP-a.
                    //
                    // MEASURED on oden / RTX 4090, 2026-09-05: at the motorway's ⅜-covered
                    // edge pixel it cost the residential underneath about two thirds of
                    // the contribution the 4× resolve had correctly computed for it.
                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
                    write_mask: wgpu::ColorWrites::ALL,
                })],
            }),
            primitive: wgpu::PrimitiveState::default(),
            // The BLIT itself needs no depth: the ordering was already resolved in the
            // offscreen pass, and egui's target has no depth attachment to test against.
            depth_stencil: None,
            // **And no MSAA either.** This is the one map pipeline that does NOT draw into
            // `OffscreenTarget` — it draws into EGUI's pass, which is single-sample. It
            // reads the already-resolved texture, so the coverage work is behind it.
            multisample: facett_core::render::gpu::msaa_state(),
            multiview_mask: None,
            cache: None,
        });
        let blit_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
            label: Some("facett_map_blit_sampler"),
            mag_filter: wgpu::FilterMode::Nearest,
            min_filter: wgpu::FilterMode::Nearest,
            ..Default::default()
        });
        // The blit's UV uniform is PER PANE (`PaneTarget::blit_uniform`, GG #37) and is
        // created with the pane's target, not here.

        Self {
            draw_pipeline,
            draw_uniform,
            draw_bind,
            fill_pipeline,
            fill_uniform,
            fill_bind,
            fill_chunks: Vec::new(),
            outline_chunks: Vec::new(),
            cull_pipeline,
            cull_count_pipeline,
            cull_scan_pipeline,
            cull_bgl,
            cull_vp_buf,
            draw_bgl,
            tile_gpu: std::collections::HashMap::new(),
            tile_current: Vec::new(),
            tile_frame: 0,
            tile_budget_override: None,
            cull_dispatches: std::sync::atomic::AtomicU64::new(0),
            tile_upload_gen: 0,
            tile_gates: Vec::new(),
            geom_ready: false,
            chunks: Vec::new(),
            // **A storage BINDING has a lower ceiling than a buffer, and every chunk
            // here is bound as one.** `max_buffer_size` (256 MiB by default) governs
            // `create_buffer`; the geom/meta/compact/src buffers below are then handed
            // to `create_bind_group` as STORAGE bindings, which wgpu governs by the
            // separate and SMALLER `max_storage_buffer_binding_size` (128 MiB by
            // default). Budgeting the partition against the larger of the two produced
            // chunks that `create_buffer` happily accepted and `create_bind_group` then
            // refused — a panic one call after the allocation that looked fine.
            //
            // MEASURED 2026-08-04, oden, RTX 4090, korp-ui thin on the live server, the
            // moment the camera stepped onto the new z15 rung of the [6, 9, 12, 15]
            // pyramid:
            //
            //   wgpu error: Validation Error
            //     In Device::create_bind_group, label = 'line_cull_bg'
            //       Buffer binding 1 range 198672192 exceeds
            //       `max_*_buffer_binding_size` limit 134217728
            //
            // 198 672 192 B is 189.5 MiB: comfortably under the 256 MiB the chunker was
            // aiming at, and 1.48x over the 128 MiB it is actually allowed to bind. The
            // older [6, 9, 12] pyramid never produced a chunk that large, which is why
            // this sat unfired — the defect was always here, the data grew into it.
            //
            // The `min` is the whole fix, and it is the right shape: one budget, taken
            // at construction, that every stream (hairlines, fill triangles, fill
            // outlines, pretty lines) already reads through `self.max_buffer_size`.
            // Note that `chunk_buffer_bytes()`'s "provably <= max_buffer_size" and
            // `gpu_chunk.rs`'s assertion were both TRUE throughout the crash: they
            // check the limit this line used to read, not the limit the device
            // enforces, which is why a green suite watched it happen.
            max_buffer_size: device
                .limits()
                .max_buffer_size
                .min(device.limits().max_storage_buffer_binding_size),
            line_renderer,
            line_chunks: Vec::new(),
            line_lod_end: [0; LOD_COUNT],
            panes: Vec::new(),
            target_format,
            blit_pipeline,
            blit_bgl,
            blit_sampler,
            queue: None,
            pass_clock: facett_core::render::gpu::PassClockSlot::default(),
        }
    }

    /// How many geometry chunks the upload split the ways into (fix #12 emit).
    pub fn chunk_count(&self) -> usize {
        self.chunks.len()
    }

    /// `(geom_bytes, meta_bytes, index_bytes)` per chunk — every entry is
    /// provably `≤ max_buffer_size`. Surfaced so the headless self-test asserts
    /// the chunking on data, not pixels. The third figure was the vertex-copy
    /// `compact` buffer's size until GG audit row 21 made the compaction an index list.
    pub fn chunk_buffer_bytes(&self) -> Vec<(u64, u64, u64)> {
        self.chunks.iter().map(|c| (c.geom_bytes, c.meta_bytes, c.index_bytes)).collect()
    }

    /// The device buffer-size budget each chunk respects.
    pub fn max_buffer_size(&self) -> u64 {
        self.max_buffer_size
    }

    /// `(src_bytes, meta_bytes, index_bytes)` per **pretty-line** chunk — the lane
    /// that actually panicked at z15.
    ///
    /// [`Self::chunk_buffer_bytes`] reports `self.chunks`, the 24 B hairline `Vertex`
    /// lane. The binding wgpu refused on 2026-08-04 was
    /// `label = 'line_cull_bg' … Buffer binding 1 range 198672192`, i.e. a 48 B
    /// `LineVertex` buffer out of `self.line_chunks` — 4 138 996 quad vertices — and
    /// nothing could read those bytes, so no assertion could be written over the stream
    /// that crashed. Test hook only; the live path never asks.
    #[doc(hidden)]
    pub fn line_chunk_buffer_bytes(&self) -> Vec<(u64, u64, u64)> {
        self.line_chunks
            .iter()
            .map(|c| (c.src_buf.size(), c.meta_buf.size(), c.index_buf.size()))
            .collect()
    }

    /// Test/host hook: run the chunked VRAM upload directly (the same path the
    /// first-frame paint callback uses). Exposed so the headless self-test can
    /// drive a country-scale upload on a real device and assert `chunk_count` +
    /// per-buffer bytes WITHOUT a panic — the fix-#12 proof, as data.
    #[doc(hidden)]
    pub fn upload_for_test(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, geo: &GpuGeometry) {
        self.upload(device, queue, geo);
    }

    /// Test hook: force a tiny chunk budget so a small fixture still splits into
    /// several chunks (proves the partition logic without a 256 MiB upload).
    #[doc(hidden)]
    pub fn set_max_buffer_size_for_test(&mut self, bytes: u64) {
        self.max_buffer_size = bytes;
    }

    /// **The cull pass** — reset every chunk's indirect counter, publish this
    /// frame's viewport/LOD, and dispatch the per-way frustum + LOD test for BOTH
    /// lanes.
    ///
    /// Lifted out of [`MapPaintCallback::prepare`] so the proof
    /// (`gpu_cull_drives_the_indirect_draw_count`) drives the REAL dispatch rather
    /// than a re-spelled copy of it. A test that rebuilt this loop would prove only
    /// that the copy works — exactly the twin-watcher shape LAW #5 rejects, and the
    /// reason this path went untested for as long as it did.
    #[doc(hidden)]
    /// **THE cull dispatch — count, scan, copy — for one lane's chunks.**
    ///
    /// ONE writer, because there are TWO lanes: the legacy whole-payload
    /// [`Self::run_cull`] and the tile lane's [`Self::run_cull_tiles_clocked`]. While
    /// the cull was a single dispatch they were two three-line loops and the twinning
    /// was invisible. The ordered compaction made it a three-pass sequence, and the
    /// first version of that change updated `run_cull` and left the tile lane on the
    /// bare copy pass — a tile lane whose `main` reads offsets `count_pass` never
    /// wrote and whose `draw_count` stays at the CPU's reset, i.e. **a map that draws
    /// nothing**. The whole suite stayed green, because nothing drives the tile cull
    /// and reads back what it produced (`tests/tile_gpu_cache.rs` asserts residency and
    /// bytes, not pixels). That is the reason this is a function and not a pattern.
    ///
    /// `v24`/`v48` are `(bind group, way count)` for the 24 B `Vertex` streams
    /// (hairlines, fill triangles) and the 48 B `LineVertex` streams (roads, fill
    /// outlines). Since the compaction became an index list (GG audit row 21) no pass
    /// differs between them; the two lists are kept apart only because the two lanes
    /// hand them over as two lists.
    fn dispatch_cull(
        &self,
        cpass: &mut wgpu::ComputePass<'_>,
        v24: &[(&wgpu::BindGroup, u32)],
        v48: &[(&wgpu::BindGroup, u32)],
    ) {
        self.cull_dispatches.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
        let groups = |n: u32| n.div_ceil(64);
        // 1. count + in-workgroup scan, both strides.
        cpass.set_pipeline(&self.cull_count_pipeline);
        for (bind, n) in v24.iter().chain(v48) {
            cpass.set_bind_group(0, *bind, &[]);
            cpass.dispatch_workgroups(groups(*n), 1, 1);
        }
        // 2. one invocation per chunk: the exclusive scan over its workgroup totals,
        //    and the grand total into `draw_indirect`'s `vertex_count`.
        cpass.set_pipeline(&self.cull_scan_pipeline);
        for (bind, _) in v24.iter().chain(v48) {
            cpass.set_bind_group(0, *bind, &[]);
            cpass.dispatch_workgroups(1, 1, 1);
        }
        // 3. the copy — of INDICES, so one pipeline for every stride.
        cpass.set_pipeline(&self.cull_pipeline);
        for (bind, n) in v24.iter().chain(v48) {
            cpass.set_bind_group(0, *bind, &[]);
            cpass.dispatch_workgroups(groups(*n), 1, 1);
        }
    }

    /// **The per-frame reset of one chunk's indirect args** — the ONE writer both cull
    /// lanes go through. `index_count` is zeroed for the scan pass to STORE into;
    /// `instance_count` is what the draw will multiply the index list by (2 for the
    /// road stream: casing then fill, as instance 0 then 1 of one draw — GG audit
    /// row 21; 1 for everything else); the three offsets are zero because a chunk's
    /// index list and geometry both start at their buffers' start.
    fn reset_ctl(queue: &wgpu::Queue, ctl: &wgpu::Buffer, instances: u32) {
        let reset = wgpu::util::DrawIndexedIndirectArgs {
            index_count: 0,
            instance_count: instances,
            first_index: 0,
            base_vertex: 0,
            first_instance: 0,
        };
        queue.write_buffer(ctl, 0, reset.as_bytes());
    }

    pub fn run_cull(
        &self,
        queue: &wgpu::Queue,
        encoder: &mut wgpu::CommandEncoder,
        viewport_bbox: [f32; 4],
        lod: u32,
        min_fill_extent: f32,
    ) {
        let [x0, y0, x1, y1] = viewport_bbox;
        // `index_count: 0` is what the cull's scan pass STORES over; it is the field
        // `draw_indexed_indirect` reads back out.
        for chunk in self.chunks.iter().chain(&self.fill_chunks) {
            Self::reset_ctl(queue, &chunk.indirect_buf, 1);
        }
        for chunk in self.line_chunks.iter().chain(&self.outline_chunks) {
            Self::reset_ctl(queue, &chunk.indirect_buf, chunk.instances);
        }
        queue.write_buffer(
            &self.cull_vp_buf,
            0,
            bytemuck::bytes_of(&ViewportCull { min: [x0, y0], max: [x1, y1], lod, min_fill_extent, _pad: [0; 2] }),
        );

        let mut cpass =
            encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("cull"), timestamp_writes: None });

        // The ordered compaction — count, scan, copy — through the ONE dispatcher both
        // lanes go through (see `dispatch_cull`). The 24 B `Vertex` streams (hairlines,
        // fill triangles) and the 48 B quad streams (roads, fill outlines) share the
        // count and the scan; only the copy knows a stride.
        let v24: Vec<(&wgpu::BindGroup, u32)> =
            self.chunks.iter().chain(&self.fill_chunks).map(|c| (&c.cull_bind, c.way_count)).collect();
        let v48: Vec<(&wgpu::BindGroup, u32)> =
            self.line_chunks.iter().chain(&self.outline_chunks).map(|c| (&c.cull_bind, c.way_count)).collect();
        self.dispatch_cull(&mut cpass, &v24, &v48);
    }

    /// [`Self::run_cull`] bracketed by the lane's **pass clock** — the body
    /// `MapPaintCallback::prepare` calls, and the body the device test drives, so
    /// the timing cannot be measured on a re-spelled copy of the dispatch (LAW #5;
    /// the same one-writer reasoning that lifted `run_cull` itself out of
    /// `prepare`).
    ///
    /// On a device without TIMESTAMP_QUERY the clock slot probes once, records the
    /// honest `gpu_timestamps = Some(false)` verdict, and this is `run_cull` plus
    /// one enum check. `begin`/`end` bracket only the encoder commands, so what is
    /// measured is the cull compute pass — see the `pass_clock` field doc for why
    /// the draws (egui's shared pass) are out of reach.
    ///
    /// `frame` is the host frame this cull belongs to ([`PaneStamp::frame_nr`]): the
    /// clock needs it to tell a second pane in the same frame from a new frame (GG #37).
    #[doc(hidden)]
    #[allow(clippy::too_many_arguments)] // public signature hosts call: kept as is
    pub fn run_cull_clocked(
        &mut self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        encoder: &mut wgpu::CommandEncoder,
        viewport_bbox: [f32; 4],
        lod: u32,
        min_fill_extent: f32,
        frame: u64,
    ) {
        if let Some(clock) = self.pass_clock.get_or_init(device, queue, lane_name()) {
            clock.begin(device, encoder, frame);
        }
        self.run_cull(queue, encoder, viewport_bbox, lod, min_fill_extent);
        if let Some(clock) = self.pass_clock.ready_mut() {
            clock.end(encoder);
        }
    }

    /// **How many cull compute passes this renderer has recorded so far** — see the
    /// `cull_dispatches` field. Test hook; the live path never reads it.
    #[doc(hidden)]
    #[must_use]
    pub fn cull_dispatch_count(&self) -> u64 {
        self.cull_dispatches.load(std::sync::atomic::Ordering::Relaxed)
    }

    /// **How many PANES hold retained still-frame gate state** (lane HO-T4).
    ///
    /// The gate of lane GG #13 skips a tile frame's uniform writes, indirect resets and
    /// cull dispatch when nothing changed. What it compares against lives here, and this
    /// says how many panes' worth of it the renderer is holding — the number that must be
    /// 2 while a main map and an inset are both up, and must fall back to 1 the frame
    /// after the inset stops being drawn (the same `retain(last_frame + 1 >= frame_nr)`
    /// eviction [`Self::pane_target`] applies, so a gone pane leaks nothing).
    #[doc(hidden)]
    #[must_use]
    pub fn tile_cull_gate_len(&self) -> usize {
        self.tile_gates.len()
    }

    /// **How many times EACH pane's still-frame gate let the cull body through**, as
    /// `(pane key, dispatches)` — the per-pane split of [`Self::cull_dispatch_count`]
    /// (lane HO-T4). The renderer's total says the gate holds; this says it holds *for
    /// every pane* rather than for one of them twice as often. Test hook — it allocates,
    /// and the live path never reads it.
    #[doc(hidden)]
    #[must_use]
    pub fn tile_cull_dispatches_per_pane(&self) -> Vec<(u64, u64)> {
        self.tile_gates.iter().map(|g| (g.key, g.dispatches)).collect()
    }

    /// How many pretty-line chunks the upload produced.
    #[doc(hidden)]
    pub fn line_chunk_count(&self) -> usize {
        self.line_chunks.len()
    }

    /// How many **fill-triangle** chunks the upload produced, and how many
    /// **fill-outline** chunks. Uploaded-geometry facts, so a guard can say the lane
    /// exists before it says the lane drew.
    #[doc(hidden)]
    pub fn fill_chunk_count(&self) -> usize {
        self.fill_chunks.len()
    }
    #[doc(hidden)]
    pub fn outline_chunk_count(&self) -> usize {
        self.outline_chunks.len()
    }

    /// The **fill lane's** GPU-computed vertex counts — `(triangle_verts,
    /// outline_verts)` summed over chunks, read straight out of the buffers
    /// `draw_indirect` consumes.
    ///
    /// **This is the applied output of the fill cull**, and it is the only number
    /// that can tell a working fill lane from a lane that uploaded geometry and then
    /// culled all of it away. A frame drawn by the GPU and a frame drawn by the CPU
    /// painter are meant to look the same — which is precisely why a picture cannot
    /// be the guard here (LAW 2). Test hook only: the live path never reads these
    /// back.
    #[doc(hidden)]
    pub fn fill_draw_counts(&self, device: &wgpu::Device, queue: &wgpu::Queue) -> (u32, u32) {
        let tris: u32 =
            self.fill_chunks.iter().map(|c| Self::read_indirect_count(device, queue, &c.indirect_buf)).sum();
        let outlines: u32 =
            self.outline_chunks.iter().map(|c| Self::read_indirect_count(device, queue, &c.indirect_buf)).sum();
        (tris, outlines)
    }

    /// **Copy the first `bytes` of a device buffer to the CPU** — the ONE readback body
    /// every test hook in this module shares (the count hooks, the compaction readback,
    /// the tile lane's). Blocking; test hooks only, the live path never reads back.
    /// `bytes` must be a multiple of `COPY_BUFFER_ALIGNMENT` (4).
    fn read_back(device: &wgpu::Device, queue: &wgpu::Queue, src: &wgpu::Buffer, bytes: u64) -> Vec<u8> {
        if bytes == 0 {
            return Vec::new();
        }
        let staging = alloc_buffer(device, &wgpu::BufferDescriptor {
            label: Some("readback"),
            size: bytes,
            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
            mapped_at_creation: false,
        });
        let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
        enc.copy_buffer_to_buffer(src, 0, &staging, 0, bytes);
        queue.submit(Some(enc.finish()));
        staging.slice(..).map_async(wgpu::MapMode::Read, |_| {});
        device.poll(wgpu::PollType::wait_indefinitely()).ok();
        let out = staging.slice(..).get_mapped_range().expect("wgpu: get_mapped_range on a mapped buffer").to_vec();
        staging.unmap();
        out
    }

    /// Copy one control block's `index_count` — word 0 of its `DrawIndexedIndirectArgs`,
    /// the number the GPU's scan pass stored and the draw consumes — off the device.
    fn read_indirect_count(device: &wgpu::Device, queue: &wgpu::Queue, indirect: &wgpu::Buffer) -> u32 {
        let words = Self::read_back(device, queue, indirect, 4);
        u32::from_le_bytes([words[0], words[1], words[2], words[3]])
    }

    /// Read back each pretty-line chunk's `index_count` — the number the GPU itself
    /// computed and that `draw_indexed_indirect` consumes.
    ///
    /// **Test hook only.** The live path never does this: the whole point of item 2
    /// is that the count is produced and consumed on the device with no readback.
    /// Copying it to the CPU here is how the proof observes what the draw saw.
    #[doc(hidden)]
    pub fn line_draw_counts(&self, device: &wgpu::Device, queue: &wgpu::Queue) -> Vec<u32> {
        self.line_chunks.iter().map(|c| Self::read_indirect_count(device, queue, &c.indirect_buf)).collect()
    }

    /// Read back the first `verts` `LineVertex` of a chunk's compacted stream — the
    /// vertices the vertex shader will actually pull, in the order it pulls them.
    ///
    /// **Test hook only.** The compaction is an index list (GG audit row 21), so this
    /// reads the first `verts` indices and resolves each through the chunk's geometry
    /// buffer — which is exactly what the indexed draw does on the device. An index the
    /// cull never wrote reads as 0 and resolves to the chunk's first vertex, which is
    /// as wrong a vertex as any other for the order proofs in `tests/cull_order.rs` and
    /// `tests/cull_indirect.rs`; an index past the geometry is reported as a panic here
    /// rather than the out-of-range fetch it would be on the device.
    #[doc(hidden)]
    pub fn line_compact_readback(
        &self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        chunk: usize,
        verts: u32,
    ) -> Vec<LineVertex> {
        let Some(c) = self.line_chunks.get(chunk) else { return Vec::new() };
        if verts == 0 {
            return Vec::new();
        }
        let idx_bytes = Self::read_back(device, queue, &c.index_buf, u64::from(verts) * 4);
        let indices: &[u32] = bytemuck::cast_slice(&idx_bytes);
        let src_bytes = Self::read_back(device, queue, &c.src_buf, c.src_bytes);
        let src: &[LineVertex] = bytemuck::cast_slice(&src_bytes);
        indices.iter().map(|&i| src[i as usize]).collect()
    }

    /// **The ONE chunk builder** for a 24 B [`Vertex`] stream: partition it under
    /// `max_buffer_size` (fix #12), upload each span's geometry + per-way meta, and
    /// wire the cull bind group that will compact it.
    ///
    /// Three streams route through this — the retained hairlines, the fill
    /// triangles and the fill outlines. They differ only in the topology the DRAW
    /// uses; the partition, the rebase, the buffer usages and the cull binding are
    /// identical, so they are written once (LAW #5). An empty stream yields no
    /// chunks, which is exactly what a lines-only or fills-only pane needs.
    /// **The ordered compaction's scratch for one chunk**, and the cull bind group
    /// that carries it. ONE writer: the 24 B chunk uploader, the 48 B quad uploader
    /// and the tile lane all mint their cull bind group here, so a chunk cannot end
    /// up with a `way_off` sized for a different way count than its own meta.
    ///
    /// `way_off` is one `u32` per way; `wg_sum` is one per 64-way workgroup. Both are
    /// written before they are read within the same compute pass, so neither needs an
    /// initial value — but a zero-way chunk would ask for a zero-sized buffer, which
    /// wgpu refuses, hence the `max(1)`.
    #[allow(clippy::too_many_arguments)] // one argument per binding of the bind group it builds
    fn cull_bind_group(
        device: &wgpu::Device,
        layout: &wgpu::BindGroupLayout,
        label: &str,
        meta_buf: &wgpu::Buffer,
        geom_buf: &wgpu::Buffer,
        index_buf: &wgpu::Buffer,
        ctl_buf: &wgpu::Buffer,
        vp_buf: &wgpu::Buffer,
    ) -> wgpu::BindGroup {
        device.create_bind_group(&wgpu::BindGroupDescriptor {
            label: Some(label),
            layout,
            entries: &[
                wgpu::BindGroupEntry { binding: 0, resource: meta_buf.as_entire_binding() },
                wgpu::BindGroupEntry { binding: 1, resource: geom_buf.as_entire_binding() },
                wgpu::BindGroupEntry { binding: 2, resource: index_buf.as_entire_binding() },
                wgpu::BindGroupEntry { binding: 3, resource: ctl_buf.as_entire_binding() },
                wgpu::BindGroupEntry { binding: 4, resource: vp_buf.as_entire_binding() },
            ],
        })
    }

    /// **The size of one chunk's control block** — the `DrawIndexedIndirectArgs` header
    /// plus the ordered compaction's scan scratch, in ONE buffer because a compute stage
    /// is guaranteed only FOUR storage bindings (see `cull.wgsl`'s binding-3 note).
    ///
    /// `5` words of indexed indirect args (the shader's `HEADER_WORDS`; the twin is
    /// asserted below against wgpu's own struct) + one word per 64-way workgroup + one
    /// word per way. ONE writer, asked by both chunk uploaders and by the tile lane, so
    /// a buffer can never be minted too small for the way count its meta carries.
    fn ctl_bytes(way_count: u32) -> u64 {
        const HEADER_WORDS: u64 = (std::mem::size_of::<wgpu::util::DrawIndexedIndirectArgs>() / 4) as u64;
        const _: () = assert!(HEADER_WORDS == 5);
        (HEADER_WORDS + u64::from(way_count.div_ceil(64)) + u64::from(way_count)) * 4
    }

    fn upload_vertex_chunks(
        &self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        verts: &[Vertex],
        metas: &[GpuWayMeta],
        label: &str,
        // WHICH viewport-cull uniform binding 4 points at: the renderer's shared one
        // (legacy whole-payload path) or a TILE's own (per-tile origins mean per-tile
        // rebased cull viewports).
        vp_buf: &wgpu::Buffer,
    ) -> Vec<GeomChunk> {
        if verts.is_empty() || metas.is_empty() {
            return Vec::new();
        }
        let budget = facett_core::render::gpu::chunk_budget(self.max_buffer_size);
        let mut out = Vec::new();
        for span in facett_core::render::gpu::partition_chunks(metas, budget) {
            let (lo, hi, vlo, vhi) = (span.way_lo, span.way_hi, span.vert_lo, span.vert_hi);
            let chunk_verts = &verts[vlo as usize..vhi as usize];
            // Rebase per-way meta to this chunk's geom buffer (vert_start - vlo),
            // via the shared core helper.
            let chunk_metas: Vec<GpuWayMeta> = facett_core::render::gpu::rebase_chunk_meta(metas, span);
            let vert_bytes = bytemuck::cast_slice::<Vertex, u8>(chunk_verts);
            let meta_bytes = bytemuck::cast_slice::<GpuWayMeta, u8>(&chunk_metas);

            // The geometry is BOTH the cull's storage input and the draw's vertex buffer:
            // the compaction refers into it by index and nothing copies it again.
            let geom_buf = alloc_buffer(device, &wgpu::BufferDescriptor {
                label: Some(label),
                size: vert_bytes.len() as u64,
                usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
                mapped_at_creation: false,
            });
            queue.write_buffer(&geom_buf, 0, vert_bytes);

            let meta_buf = alloc_buffer(device, &wgpu::BufferDescriptor {
                label: Some("way_meta"),
                size: meta_bytes.len() as u64,
                usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
                mapped_at_creation: false,
            });
            queue.write_buffer(&meta_buf, 0, meta_bytes);

            // ── THE INDEX LIST, one u32 per vertex of the chunk (GG audit row 21). ──
            // This is the bound the cull's output needs and the bound the VRAM claim
            // rests on. A compaction can emit at most every vertex once, so V indices
            // always suffice; and V indices cost 4·V bytes against the 24·V (or 48·V)
            // the vertex copy it replaces had to reserve. Per chunk of V vertices and
            // n ways the whole bill is then
            //
            //     geom + meta + index + ctl = 24V + 32n + 4V + 4(5 + ⌈n/64⌉ + n)
            //
            // whose overhead over the geometry+meta payload is 4V/(24V+32n) + (4n +
            // 4⌈n/64⌉ + 20)/(24V+32n) < 1/6 + 1/30 for any chunk of ≥ 16 ways — under
            // the 1.2× `tests/vram_ratio.rs` holds, by arithmetic and not by fixture.
            // Preallocated once with the chunk and reused every frame: the cull
            // rewrites its prefix, the draw reads `index_count` of it, nothing grows.
            let index_buf = alloc_buffer(device, &wgpu::BufferDescriptor {
                label: Some("compact_idx"),
                size: (chunk_verts.len() * 4) as u64,
                usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
                mapped_at_creation: false,
            });

            let way_count = (hi - lo) as u32;
            // The indirect args AND the ordered compaction's scan scratch — see
            // `Self::ctl_bytes` and `cull.wgsl`'s binding-3 note.
            let indirect_buf = alloc_buffer(device, &wgpu::BufferDescriptor {
                label: Some("indirect"),
                size: Self::ctl_bytes(way_count),
                usage: wgpu::BufferUsages::INDIRECT
                    | wgpu::BufferUsages::STORAGE
                    | wgpu::BufferUsages::COPY_DST
                    | wgpu::BufferUsages::COPY_SRC,
                mapped_at_creation: false,
            });

            let cull_bind = Self::cull_bind_group(
                device,
                &self.cull_bgl,
                "cull_bg",
                &meta_buf,
                &geom_buf,
                &index_buf,
                &indirect_buf,
                vp_buf,
            );

            out.push(GeomChunk {
                geom_bytes: geom_buf.size(),
                meta_bytes: meta_buf.size(),
                index_bytes: index_buf.size(),
                ctl_bytes: indirect_buf.size(),
                geom_buf,
                meta_buf,
                index_buf,
                indirect_buf,
                cull_bind,
                way_count,
            });
        }
        out
    }

    /// Upload all geometry to VRAM, **split into chunks** (fix #12). Called once on
    /// first frame. Partitions ways so each chunk's `geom`/`compact` buffer stays
    /// `≤ max_buffer_size`; a way never straddles a boundary (its vertices live in
    /// one chunk, with chunk-relative `vert_start`). egui's own colour pass also
    /// caps the per-buffer index/vertex sizes, so respecting `max_buffer_size`
    /// keeps the whole submission valid.
    fn upload(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, geo: &GpuGeometry) {
        // ── THE UPLOAD WALL CLOCK (perf/v2.3). ────────────────────────────────
        // Started before the clear so the drop of the previous payload's VRAM is
        // inside the measured window — it is real per-event cost of a re-bake.
        // Noted at the bottom, HERE and only here, so the live `prepare` path and
        // `upload_for_test` count the same event through the same writer (LAW #5).
        let upload_t0 = web_time::Instant::now();
        // ── REPLACE, do not append. ────────────────────────────────────────────
        // This is not "called once on first frame" any more and has not been since
        // `MapFacet` got a *signature* latch instead of a bool one. Its whole point is
        // that a toggled layer or a freshly streamed viewport RE-BAKES — and `push`ing
        // the new chunks onto the old left the previous payload in VRAM, still
        // dispatched by `run_cull` and still drawn by `paint`. A layer switched off kept
        // its roads on screen (the exact failure `gpu_geom_sig`'s doc says a bool latch
        // would cause), and every pan/fetch leaked a payload's worth of VRAM.
        // MEASURED on a real device before this line existed: two uploads left **6**
        // chunks where a lone upload of the second payload leaves 1.
        // Dropping the `Buffer`s here releases their VRAM; the bind groups that
        // referenced them go with the chunks.
        self.chunks.clear();
        self.line_chunks.clear();
        self.fill_chunks.clear();
        self.outline_chunks.clear();
        self.geom_ready = false;
        // Every stream may legitimately be absent on its own: a fills-only pane has
        // no quads, a lines-only pane has no `fills`. Bailing on the FIRST one (which
        // this did) left `geom_ready` false for a fills-only pane, so the fill lane
        // could never encode a frame no matter what was in the payload.
        //
        // The test is over the streams that DRAW. It used to name `vertices`/`way_meta`
        // — the hairline stream — which nothing draws and which is now empty by
        // construction (`HAIRLINE_LANE`); reading the gate off a dead stream would have
        // made a lines-only pane bail and taken the whole quad lane down with it.
        if geo.line_meta.is_empty() && geo.fills.is_empty() {
            return; // graceful: no ways → no GPU buffers, nothing draws
        }
        // CONS-CORE Phase C: the load-bearing fix-#12 partition logic now lives once
        // in `facett_core::render::gpu::buffer` (extracted verbatim). We call it here
        // so the chunking is a single source of truth — the bytes produced are
        // identical (the selftest's chunk_count / chunk_buffer_bytes are unchanged),
        // and `cull.wgsl` / `draw.wgsl` semantics are untouched.
        //
        // TWO streams share this one body (hairlines, fill triangles) — both 24 B
        // `Vertex` culled at the default `VPV = 6`, so a second copy of the
        // chunk-building would be the twin LAW #5 forbids. The fill OUTLINES left this
        // body with `mapgpu-1`: they are 48 B `LineVertex` now and go through
        // `upload_line_chunks` beside the roads.
        let vp = self.cull_vp_buf.clone();
        self.chunks = self.upload_vertex_chunks(device, queue, &geo.vertices, &geo.way_meta, "geom", &vp);
        self.fill_chunks =
            self.upload_vertex_chunks(device, queue, &geo.fills.tri_vertices, &geo.fills.tri_meta, "fill_tri", &vp);
        let (outline_chunks, outline_payload_bytes) =
            self.upload_line_chunks(device, &geo.fills.outline_vertices, &geo.fills.outline_meta, &vp, OUTLINE_INSTANCES);
        self.outline_chunks = outline_chunks;

        // ── Pretty-line quad geometry → chunked cull + indirect sets ──────────
        // The quads now go through the SAME per-way cull the hairlines do, so each
        // chunk gets the full geom/meta/index/indirect set. Partitioned by the
        // shared fix-#12 chunker over the 48 B `LineVertex` stride, so a way never
        // straddles a boundary and `vert_start` stays chunk-relative.
        self.line_lod_end = geo.line_lod_end;
        // Payload bytes the pretty-line loop below hands the device, summed at the ONE
        // place the buffers are filled.
        let (line_chunks, line_payload_bytes) =
            self.upload_line_chunks(device, &geo.line_vertices, &geo.line_meta, &vp, ROAD_INSTANCES);
        self.line_chunks = line_chunks;

        self.geom_ready = true;

        // ── NOTE THE UPLOAD EVENT: wall µs + the exact payload bytes handed over. ─
        // Bytes are what the device was actually GIVEN — every `write_buffer` /
        // `create_buffer_init` payload: the three 24 B `Vertex` streams' geometry +
        // per-way meta (the chunk partition preserves totals: every span's rebased
        // meta has the span's way count, and the spans tile the input) plus the
        // pretty-line quads + their meta. Index/indirect buffers are ALLOCATED but
        // never written here, so they are capacity, not upload, and are excluded —
        // the device test asserts EQUALITY against the payload it fed, which is what
        // keeps this accounting from silently dropping a stream (LAW 2).
        let uploaded_bytes = self
            .chunks
            .iter()
            .chain(&self.fill_chunks)
            .map(|c| c.geom_bytes + c.meta_bytes)
            .sum::<u64>()
            + line_payload_bytes
            + outline_payload_bytes;
        facett_core::render::lane::note_upload(
            lane_name(),
            upload_t0.elapsed().as_micros() as u64,
            uploaded_bytes,
        );
    }

    /// **Build the pretty-line chunk set for one payload** — the ONE body the legacy
    /// whole-payload `upload` and the tile lane's `upload_tile` both go through
    /// (LAW #5). `vp_buf` is which viewport-cull uniform binding 4 points at;
    /// `instances` is the draw's instance count — [`ROAD_INSTANCES`] for the road stream
    /// (casing + fill as two instances of one draw), [`OUTLINE_INSTANCES`] for the
    /// fill outlines. Returns the chunks plus the payload bytes actually handed to the
    /// device.
    fn upload_line_chunks(
        &self,
        device: &wgpu::Device,
        line_vertices: &[LineVertex],
        line_meta: &[GpuWayMeta],
        vp_buf: &wgpu::Buffer,
        instances: u32,
    ) -> (Vec<LineChunk>, u64) {
        let mut chunks = Vec::new();
        let mut payload_bytes: u64 = 0;
        if line_vertices.is_empty() || line_meta.is_empty() {
            return (chunks, payload_bytes);
        }
        let stride = std::mem::size_of::<LineVertex>() as u64;
        let budget = facett_core::render::gpu::chunk_budget_stride(self.max_buffer_size, stride);
        let spans = facett_core::render::gpu::partition_chunks_stride(line_meta, budget, stride);
        for span in spans {
            let verts = &line_vertices[span.vert_lo as usize..span.vert_hi as usize];
            let metas: Vec<GpuWayMeta> = facett_core::render::gpu::rebase_chunk_meta(line_meta, span);
            let vert_bytes = bytemuck::cast_slice::<LineVertex, u8>(verts);
            let meta_bytes = bytemuck::cast_slice::<GpuWayMeta, u8>(&metas);
            payload_bytes += (vert_bytes.len() + meta_bytes.len()) as u64;

            // The quads: the cull's storage input AND the draw's vertex buffer (see
            // `upload_vertex_chunks`). `COPY_SRC` is for `line_compact_readback`, which
            // resolves the index list back into vertices the way the draw does.
            let src_buf = alloc_buffer_init(device, &wgpu::util::BufferInitDescriptor {
                label: Some("line_src"),
                contents: vert_bytes,
                usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_SRC,
            });
            let meta_buf = alloc_buffer_init(device, &wgpu::util::BufferInitDescriptor {
                label: Some("line_meta"),
                contents: meta_bytes,
                usage: wgpu::BufferUsages::STORAGE,
            });
            // One index per vertex of the chunk — the cull's output bound and the VRAM
            // bound, argued at `upload_vertex_chunks`; at 48 B a vertex the index list
            // is 1/12 of the geometry it replaces a copy of.
            let index_buf = alloc_buffer(device, &wgpu::BufferDescriptor {
                label: Some("line_compact_idx"),
                size: (verts.len() * 4) as u64,
                usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
                mapped_at_creation: false,
            });
            let way_count = (span.way_hi - span.way_lo) as u32;
            let indirect_buf = alloc_buffer(device, &wgpu::BufferDescriptor {
                label: Some("line_indirect"),
                size: Self::ctl_bytes(way_count),
                usage: wgpu::BufferUsages::INDIRECT
                    | wgpu::BufferUsages::STORAGE
                    | wgpu::BufferUsages::COPY_DST
                    | wgpu::BufferUsages::COPY_SRC,
                mapped_at_creation: false,
            });
            let cull_bind = Self::cull_bind_group(
                device,
                &self.cull_bgl,
                "line_cull_bg",
                &meta_buf,
                &src_buf,
                &index_buf,
                &indirect_buf,
                vp_buf,
            );
            chunks.push(LineChunk {
                src_bytes: src_buf.size(),
                meta_bytes: meta_buf.size(),
                index_bytes: index_buf.size(),
                ctl_bytes: indirect_buf.size(),
                src_buf,
                meta_buf,
                index_buf,
                indirect_buf,
                cull_bind,
                way_count,
                instances,
            });
        }
        (chunks, payload_bytes)
    }

    /// **Upload ONE tile's payload into the VRAM cache** — keyed by the CONTENT key
    /// the facet computed, so an identical tile can never be uploaded twice and a
    /// changed one can never be served stale. Everything a tile needs to cull and
    /// draw against its OWN origin lives on the entry: its viewport-cull uniform,
    /// its draw/fill uniform pair and its pretty-line uniform pair.
    fn upload_tile(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, key: u128, geo: &GpuGeometry) {
        let upload_t0 = web_time::Instant::now();
        let cull_vp_buf = alloc_buffer(device, &wgpu::BufferDescriptor {
            label: Some("tile_cull_vp"),
            size: std::mem::size_of::<ViewportCull>() as u64,
            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        });
        let chunks = self.upload_vertex_chunks(device, queue, &geo.vertices, &geo.way_meta, "tile_geom", &cull_vp_buf);
        let fill_chunks =
            self.upload_vertex_chunks(device, queue, &geo.fills.tri_vertices, &geo.fills.tri_meta, "tile_fill", &cull_vp_buf);
        let (outline_chunks, outline_bytes) = self.upload_line_chunks(
            device,
            &geo.fills.outline_vertices,
            &geo.fills.outline_meta,
            &cull_vp_buf,
            OUTLINE_INSTANCES,
        );
        let (line_chunks, line_bytes) =
            self.upload_line_chunks(device, &geo.line_vertices, &geo.line_meta, &cull_vp_buf, ROAD_INSTANCES);

        let mk_uniform = |label: &str| {
            alloc_buffer(device, &wgpu::BufferDescriptor {
                label: Some(label),
                size: std::mem::size_of::<DrawUniforms>() as u64,
                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
                mapped_at_creation: false,
            })
        };
        let draw_uniform = mk_uniform("tile_draw_uniforms");
        let fill_uniform = mk_uniform("tile_fill_uniforms");
        let bind_of = |label: &str, buf: &wgpu::Buffer| {
            device.create_bind_group(&wgpu::BindGroupDescriptor {
                label: Some(label),
                layout: &self.draw_bgl,
                entries: &[wgpu::BindGroupEntry { binding: 0, resource: buf.as_entire_binding() }],
            })
        };
        let draw_bind = bind_of("tile_draw_bg", &draw_uniform);
        let fill_bind = bind_of("tile_fill_bg", &fill_uniform);
        let line_u = self.line_renderer.tile_uniforms(device);

        // What this tile HOLDS in VRAM — every buffer minted above, by its real size.
        // The budget is enforced against this number, so it is the card's bill and not
        // an estimate of it: `tests/vram_ratio.rs` pins the cache census to the
        // allocator's own count. (It used to be `(line + outline payload) * 2`, an
        // estimate that overcounted the meta and omitted the control blocks and the
        // line uniforms — 1.1 MB off on eight tiles.)
        let bytes: u64 = chunks.iter().chain(&fill_chunks).map(GeomChunk::bytes).sum::<u64>()
            + outline_chunks.iter().chain(&line_chunks).map(LineChunk::bytes).sum::<u64>()
            + draw_uniform.size()
            + fill_uniform.size()
            + cull_vp_buf.size()
            + line_u.bytes();
        let uploaded: u64 = chunks
            .iter()
            .chain(&fill_chunks)
            .map(|c| c.geom_bytes + c.meta_bytes)
            .sum::<u64>()
            + line_bytes
            + outline_bytes;

        self.tile_gpu.insert(
            key,
            TileGpu {
                chunks,
                fill_chunks,
                outline_chunks,
                line_chunks,
                draw_uniform,
                draw_bind,
                fill_uniform,
                fill_bind,
                line_u,
                cull_vp_buf,
                bytes,
                last_used: self.tile_frame,
            },
        );
        tile_registry_mark_known(key);
        // A fresh tile's indirect counters have never been written: the still-frame
        // gate in `run_cull_tiles_clocked` must see a new generation and cull.
        self.tile_upload_gen += 1;
        facett_core::render::lane::note_upload(lane_name(), upload_t0.elapsed().as_micros() as u64, uploaded);
    }

    /// Evict least-recently-DRAWN tiles until the cache fits the VRAM budget. The
    /// current frame's set (`self.tile_current`, read in place — it used to arrive as
    /// a clone of itself) is never a candidate (it is about to be drawn). Evicted
    /// keys leave the known-registry, so the facet rebuilds and resends them if they
    /// are ever wanted again — the miss is a re-upload, never a blank tile.
    fn evict_tiles_over_budget(&mut self) {
        let budget = self.tile_budget_override.unwrap_or_else(tile_vram_budget_bytes);
        let mut total: u64 = self.tile_gpu.values().map(|t| t.bytes).sum();
        while total > budget {
            let victim = self
                .tile_gpu
                .iter()
                .filter(|(k, _)| !self.tile_current.iter().any(|(c, _, _, _)| c == *k))
                .min_by_key(|(_, t)| t.last_used)
                .map(|(k, _)| *k);
            let Some(k) = victim else { break };
            if let Some(t) = self.tile_gpu.remove(&k) {
                total -= t.bytes;
            }
            // Residency changed: whatever the still-frame gate saw last is no longer
            // what it would see. (Belt and braces — a current tile is never evicted.)
            self.tile_upload_gen += 1;
            tile_registry_mark_unknown(k);
        }
    }

    /// **The tile lane's per-frame GPU writes, and the cull they feed — or nothing,
    /// when the frame is still.** For every current tile: its five draw uniforms
    /// (draw, fill, casing, line fill, outline), the reset of every chunk's indirect
    /// counter, its OWN rebased cull viewport (per-tile origins ⇒ per-tile cull
    /// spaces), then one compute pass dispatching every current tile's chunks under
    /// both cull pipelines. The clock brackets the pass exactly as `run_cull_clocked`
    /// does the legacy body.
    ///
    /// # The still-frame gate (lane GG #13)
    ///
    /// Every byte this writes is a function of `self.tile_current`, `inputs`, and
    /// which tiles are resident (`tile_upload_gen`). When all three equal what the
    /// last call saw, the writes would be identical and the cull would land on the
    /// identical `vertex_count`s — so the whole body is skipped, and the indirect and
    /// compact buffers the previous frame left behind ARE this frame's draw. That is
    /// only sound because the reset is skipped TOGETHER with the dispatch: a reset
    /// without a cull would hand `draw_indirect` a zero. Measured on t14s / Radeon
    /// 890M (`tests/tile_gpu_cache.rs::a_still_frame_dispatches_no_cull_..`): 100
    /// dispatches over 100 still frames before, 0 after.
    ///
    /// # …and it is PER PANE (lane HO-T4)
    ///
    /// `self.tile_current` is refilled by every callback's `prepare`, and GG #37 made two
    /// callbacks in one frame the shipped case (korp's main map plus an inset). With the
    /// gate's memory on the RENDERER the two panes alternated through it and neither ever
    /// matched its own previous frame — 0 dispatches over 16 still frames with one pane,
    /// **32** with two (t14s / Radeon 890M, `tests/gpu_lane_two_panes.rs`). So the memory
    /// is a [`TileCullGate`] keyed by `pane` ([`PaneStamp::pane`]), evicted on the same
    /// rule [`Self::pane_target`] evicts a target on.
    ///
    /// The key has to be the pane and not the renderer for CORRECTNESS as well as cost: a
    /// per-renderer key that happened to match would skip pane B's writes while the tile
    /// uniforms and indirect counters still held pane A's camera, and B would draw A's
    /// culled set. The two-pane pixel oracle asserts against exactly that.
    fn run_cull_tiles_clocked(
        &mut self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        encoder: &mut wgpu::CommandEncoder,
        pane: u64,
        inputs: &TileFrameInputs,
        frame: u64,
    ) {
        // A pane not prepared in this frame or the one before is gone: drop its gate, as
        // `pane_target` drops its target. `retain` over two or three entries, moving
        // nothing when nothing goes.
        self.tile_gates.retain(|g| g.last_frame + 1 >= frame);
        let i = match self.tile_gates.iter().position(|g| g.key == pane) {
            Some(i) => i,
            None => {
                self.tile_gates.push(TileCullGate {
                    key: pane,
                    last_frame: 0,
                    last: None,
                    current: Vec::new(),
                    dispatches: 0,
                });
                self.tile_gates.len() - 1
            }
        };
        self.tile_gates[i].last_frame = frame;

        let seen = (*inputs, self.tile_upload_gen);
        if self.tile_gates[i].last == Some(seen) && self.tile_current == self.tile_gates[i].current {
            return;
        }
        // Disjoint fields: the gate is borrowed mutably while `tile_current` is read.
        let gate = &mut self.tile_gates[i];
        gate.last = Some(seen);
        gate.dispatches += 1;
        gate.current.clear();
        gate.current.extend_from_slice(&self.tile_current);

        if let Some(clock) = self.pass_clock.get_or_init(device, queue, lane_name()) {
            clock.begin(device, encoder, frame);
        }
        let TileFrameInputs { lod, min_fill_extent, logical_w, logical_h, fill_tint, casing_col, width_scale } = *inputs;
        for (key, transform, bbox, _) in &self.tile_current {
            let Some(t) = self.tile_gpu.get(key) else { continue };
            queue.write_buffer(&t.draw_uniform, 0, bytemuck::bytes_of(&transform.uniforms(logical_w, logical_h)));
            queue.write_buffer(
                &t.fill_uniform,
                0,
                bytemuck::bytes_of(&transform.uniforms_tinted(logical_w, logical_h, fill_tint)),
            );
            self.line_renderer.write_uniforms_to(
                queue,
                &t.line_u.u_road,
                &t.line_u.u_outline,
                transform,
                logical_w,
                logical_h,
                LINE_CASING_PX,
                casing_col,
                LINE_FEATHER_PX,
                width_scale,
            );
            for chunk in t.chunks.iter().chain(&t.fill_chunks) {
                Self::reset_ctl(queue, &chunk.indirect_buf, 1);
            }
            for chunk in t.line_chunks.iter().chain(&t.outline_chunks) {
                Self::reset_ctl(queue, &chunk.indirect_buf, chunk.instances);
            }
            let [x0, y0, x1, y1] = *bbox;
            queue.write_buffer(
                &t.cull_vp_buf,
                0,
                bytemuck::bytes_of(&ViewportCull { min: [x0, y0], max: [x1, y1], lod, min_fill_extent, _pad: [0; 2] }),
            );
        }
        let mut cpass =
            encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("tile_cull"), timestamp_writes: None });
        // The SAME three-pass dispatcher the legacy path runs. It has to be the same
        // one: while it was two hand-written loops, the ordered compaction landed on
        // one of them and left this lane drawing nothing, green.
        let mut v24: Vec<(&wgpu::BindGroup, u32)> = Vec::new();
        let mut v48: Vec<(&wgpu::BindGroup, u32)> = Vec::new();
        for (key, _, _, _) in &self.tile_current {
            let Some(t) = self.tile_gpu.get(key) else { continue };
            for chunk in t.chunks.iter().chain(&t.fill_chunks) {
                v24.push((&chunk.cull_bind, chunk.way_count));
            }
            for chunk in t.line_chunks.iter().chain(&t.outline_chunks) {
                v48.push((&chunk.cull_bind, chunk.way_count));
            }
        }
        self.dispatch_cull(&mut cpass, &v24, &v48);
        drop(cpass);
        if let Some(clock) = self.pass_clock.ready_mut() {
            clock.end(encoder);
        }
    }

    /// **Test hook — DRIVE THE TILE LANE'S CULL and read back what it produced.**
    ///
    /// This did not exist, and its absence is why the ordered compaction could land on
    /// the legacy cull, leave the tile lane on the bare copy pass, and pass the whole
    /// suite: `tile_gpu_cache.rs` asserts residency, bytes and the wanted-registry, all
    /// of which are true of a lane that draws nothing. The applied output of a cull is
    /// the `vertex_count` `draw_indirect` will read, and nothing was reading it for a
    /// tile.
    ///
    /// `tiles` is `(content key, cull bbox in that tile's OWN rebased space)`.
    #[doc(hidden)]
    pub fn cull_tiles_for_test(
        &mut self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        tiles: &[(u128, [f32; 4])],
        lod: u32,
        min_fill_extent: f32,
    ) {
        self.tile_current = tiles
            .iter()
            .map(|(k, bb)| (*k, MapTransform { zoom_x: 1.0, zoom_y: 1.0, ref_x: 0.0, ref_y: 0.0, screen_center_x: 0.0, screen_center_y: 0.0, bearing_deg: 0.0, tilt_deg: 0.0 }, *bb, 0))
            .collect();
        let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("tile_cull_test") });
        // The uniform half of the inputs is inert here — the hook reads back indirect
        // counts, never pixels — but it goes through the ONE gated writer all the same.
        let inputs = TileFrameInputs {
            lod,
            min_fill_extent,
            logical_w: 1.0,
            logical_h: 1.0,
            fill_tint: 0.0,
            casing_col: LINE_CASING_COL,
            width_scale: 1.0,
        };
        // One hook call is one submitted frame, so the tile-lane prepare counter
        // stands in for the host's frame number here.
        self.tile_frame += 1;
        let frame = self.tile_frame;
        // One hook, one pane: the default `PaneStamp::pane` a callback built without a
        // `Ui` carries, so the gate this drives is the same one a single-pane host uses.
        self.run_cull_tiles_clocked(device, queue, &mut enc, PaneStamp::default().pane, &inputs, frame);
        queue.submit(Some(enc.finish()));
        device.poll(wgpu::PollType::wait_indefinitely()).ok();
    }

    /// **Test hook** — the `vertex_count` every chunk of one resident tile would draw,
    /// as `(24 B streams, 48 B quad stream)`. Read straight out of the buffers
    /// `draw_indirect` reads.
    #[doc(hidden)]
    #[must_use]
    pub fn tile_draw_counts(&self, device: &wgpu::Device, queue: &wgpu::Queue, key: u128) -> (Vec<u32>, Vec<u32>) {
        let Some(t) = self.tile_gpu.get(&key) else { return (Vec::new(), Vec::new()) };
        let read = |b: &wgpu::Buffer| -> u32 { Self::read_indirect_count(device, queue, b) };
        (
            t.chunks.iter().chain(&t.fill_chunks).map(|c| read(&c.indirect_buf)).collect(),
            t.line_chunks.iter().chain(&t.outline_chunks).map(|c| read(&c.indirect_buf)).collect(),
        )
    }

    /// How many tiles the VRAM cache currently holds (test/observability hook).
    #[doc(hidden)]
    #[must_use]
    pub fn tile_cache_len(&self) -> usize {
        self.tile_gpu.len()
    }

    /// Total bytes the tile cache accounts for (test/observability hook).
    #[doc(hidden)]
    #[must_use]
    pub fn tile_cache_bytes(&self) -> u64 {
        self.tile_gpu.values().map(|t| t.bytes).sum()
    }

    /// Drive [`Self::upload_tile`] from a test (the live caller is `prepare`).
    #[doc(hidden)]
    pub fn upload_tile_for_test(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, key: u128, geo: &GpuGeometry) {
        self.upload_tile(device, queue, key, geo);
    }

    /// Is this key resident in the tile cache? (test hook)
    #[doc(hidden)]
    #[must_use]
    pub fn tile_resident(&self, key: u128) -> bool {
        self.tile_gpu.contains_key(&key)
    }

    /// Pin the tile VRAM budget for a test (bytes).
    #[doc(hidden)]
    pub fn set_tile_vram_budget_for_test(&mut self, bytes: u64) {
        self.tile_budget_override = Some(bytes);
    }

    /// Drive the LRU eviction from a test with an explicit current set.
    #[doc(hidden)]
    pub fn evict_tiles_for_test(&mut self, current_keys: &[u128]) {
        self.tile_frame += 1;
        let frame = self.tile_frame;
        for k in current_keys {
            if let Some(t) = self.tile_gpu.get_mut(k) {
                t.last_used = frame;
            }
        }
        let cur: Vec<(u128, MapTransform, [f32; 4], i32)> = current_keys
            .iter()
            .map(|k| {
                (
                    *k,
                    MapTransform::new([0.0, 0.0], [0.0, 0.0], 1.0, 1.0, egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1.0, 1.0))),
                    [0.0; 4],
                    0,
                )
            })
            .collect();
        self.tile_current = cur;
        self.evict_tiles_over_budget();
    }
}

/// **One tile's resident VRAM** — its chunk sets plus the per-tile uniforms that
/// let it cull and draw against its OWN bake origin. Lives in
/// [`GpuMapRenderer::tile_gpu`], which SURVIVES facet rebuilds — that survival is
/// the entire point of the tile lane.
struct TileGpu {
    chunks: Vec<GeomChunk>,
    fill_chunks: Vec<GeomChunk>,
    outline_chunks: Vec<LineChunk>,
    line_chunks: Vec<LineChunk>,
    draw_uniform: wgpu::Buffer,
    /// The bind for the retained `LineList` pipeline — see
    /// [`GpuMapRenderer::draw_pipeline`]. Unsubmitted since `mapgpu-1`.
    #[allow(dead_code, reason = "retained LineList A/B path — mapgpu-1")]
    draw_bind: wgpu::BindGroup,
    fill_uniform: wgpu::Buffer,
    fill_bind: wgpu::BindGroup,
    line_u: line::LineTileUniforms,
    cull_vp_buf: wgpu::Buffer,
    /// What this tile costs the card (payload + compact capacity + uniforms).
    bytes: u64,
    /// The last [`GpuMapRenderer::tile_frame`] this tile was in the draw set.
    last_used: u64,
}

/// **One tile's hand-over from the facet to the renderer** — the per-frame unit of
/// the tile lane. `geometry` is `Some` only when the facet believes the renderer
/// does not hold this key (first sight, or the renderer reported it wanted); every
/// other frame the key alone rides over, which is what makes a pan over cached
/// ground upload nothing.
pub struct TileHandover {
    /// The CONTENT key — a hash of everything the payload is a function of (the
    /// ways' geometry + style + the tile's own origin). Computed by the facet
    /// (`layer::tile_content_key`); the renderer treats it as opaque.
    pub key: u128,
    /// **The group's DRAW BAND** — `crate::paint_order::way_draw_band` of every way in
    /// it (a group is single-band by construction; see
    /// `MapFacet::add_gpu_tiled_callback`). `paint` walks the hand-over list band-major
    /// and submits fills → outlines → lines *per band*, which is what makes the declared
    /// order hold ACROSS tiles instead of only inside one.
    pub band: i32,
    /// This tile's transform for THIS frame (its own origin, this camera).
    pub transform: MapTransform,
    /// This tile's cull viewport, rebased against ITS origin.
    pub viewport_bbox: [f32; 4],
    /// The payload, when the facet believes the renderer needs it.
    pub geometry: Option<Box<GpuGeometry>>,
}

/// The tile lane's **known/wanted registry** — how the facet (UI side) and the
/// renderer (device side) agree about which keys are resident without the facet
/// ever holding a device handle.
///
/// * `known`: keys the renderer HOLDS. Written on upload, erased on evict.
/// * `wanted`: keys a `prepare` saw in its draw set, without payload, that the
///   renderer does NOT hold (an evicted key still in some facet's roster, or a
///   second window's renderer). The facet drains its keys from here and resends —
///   a self-healing loop that converges in one frame.
///
/// Content-addressed keys are what make ONE process-wide registry sound: the same
/// key IS the same bytes, so two facets naming one key genuinely share one upload.
struct TileRegistry {
    known: std::collections::HashSet<u128>,
    wanted: std::collections::HashSet<u128>,
}

fn tile_registry() -> &'static std::sync::Mutex<TileRegistry> {
    static CELL: std::sync::OnceLock<std::sync::Mutex<TileRegistry>> = std::sync::OnceLock::new();
    CELL.get_or_init(|| {
        std::sync::Mutex::new(TileRegistry {
            known: std::collections::HashSet::new(),
            wanted: std::collections::HashSet::new(),
        })
    })
}

fn tile_registry_mark_known(key: u128) {
    if let Ok(mut r) = tile_registry().lock() {
        r.known.insert(key);
        r.wanted.remove(&key);
    }
}

fn tile_registry_mark_unknown(key: u128) {
    if let Ok(mut r) = tile_registry().lock() {
        r.known.remove(&key);
    }
}

/// **A renderer in a draw set needs this key's payload and does not hold it.**
///
/// Marked UNCONDITIONALLY, and that is the whole point. It used to be
/// `if !r.known.contains(&key) { … }` — a guard that reads as "don't ask for what
/// somebody already has", and `known` is **process-global while residency is
/// per-renderer**. So the only caller (`MapPaintCallback::prepare`, which has already
/// established that *this* renderer does not hold the key) was silently ignored for
/// exactly the keys that most needed resending, and the tile stayed **blank forever**:
///
/// * a second window's renderer, on every key the first window uploaded;
/// * a renderer re-created after a device loss;
/// * a fresh `install_renderer` in the same process — which is how this was caught
///   (`tests/gpu_lane_paint_order.rs`, oden / RTX 4090, 2026-08-23: the lake simply did
///   not paint, and the non-vacuity assertion said so instead of the ordering claim
///   passing on an empty pane).
///
/// The doc on [`TileRegistry`] promised "a self-healing loop that converges in one
/// frame". It could not converge at all while `known` was non-empty from another
/// renderer. `tile_payload_needed` takes the key back out of `wanted` as it answers, so
/// an unconditional mark is still exactly ONE resend per miss.
fn tile_registry_mark_wanted(key: u128) {
    if let Ok(mut r) = tile_registry().lock() {
        r.wanted.insert(key);
    }
}

/// **Does the facet need to BUILD and SEND this key's payload this frame?**
/// True when the renderer does not hold it, or has explicitly asked for it again.
/// A poisoned lock answers `true` — degraded to re-sending, never to a blank tile.
#[must_use]
pub fn tile_payload_needed(key: u128) -> bool {
    match tile_registry().lock() {
        Ok(mut r) => !r.known.contains(&key) || r.wanted.remove(&key),
        Err(_) => true,
    }
}

/// **[`tile_payload_needed`] for a whole frame's worth of keys, under ONE lock.**
///
/// The per-key form takes the registry mutex once per call, and a real korp viewport is
/// hundreds of groups (MEASURED on the real device, `tests/gpu_lane_share.rs`: **900
/// groups over 150 producer-stamped tiles for 1 200 ways**, 6.00 groups per tile) — so
/// asking it per group is hundreds of lock/unlock pairs per frame for an answer that is
/// almost always "no" for every one of them. One lock, one pass, same semantics per key
/// including the `wanted` one-shot take.
///
/// Answers `true` for every key on a poisoned lock, exactly as the per-key form does:
/// degraded to re-sending, never to a blank tile.
#[must_use]
pub fn tile_payloads_needed(keys: impl Iterator<Item = u128>) -> Vec<bool> {
    match tile_registry().lock() {
        Ok(mut r) => keys.map(|k| !r.known.contains(&k) || r.wanted.remove(&k)).collect(),
        Err(_) => keys.map(|_| true).collect(),
    }
}

/// The tile cache's VRAM budget: `FACETT_MAP_TILE_VRAM_MB` (default **2048**).
/// Sized for the box this lane was built on (an RTX 4090 idling at 7 % memory
/// use); a smaller card sets the env. Floor 64 MB so a typo cannot thrash.
fn tile_vram_budget_bytes() -> u64 {
    let mb = std::env::var("FACETT_MAP_TILE_VRAM_MB")
        .ok()
        .and_then(|v| v.trim().parse::<u64>().ok())
        .unwrap_or(2048)
        .max(64);
    mb * 1024 * 1024
}

// ── Per-frame paint callback ──────────────────────────────────────────────────

/// **Which pane, in which frame** — lane GG #37.
///
/// A [`MapPaintCallback`] carries one so the renderer can keep ONE offscreen target per
/// pane and free the target of a pane that stopped being drawn. korp draws a main map
/// and can draw an inset (a second `MapFacet`) in the same frame; egui-wgpu runs every
/// callback's `prepare` and then every callback's `paint`, so anything the renderer
/// held per RENDERER — the cleared target, the blit's UV uniform, the pass clock's ring
/// — was overwritten by the second callback before the first had painted. MEASURED on
/// t14s / Radeon 890M, `tests/gpu_lane_two_panes.rs`, 2026-09-05.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct PaneStamp {
    /// A stable id for the pane: the facet's response `egui::Id`. egui keeps it the
    /// same across frames for a stable layout and distinct between two facets in one
    /// frame — which is exactly what a VRAM cache key wants. NOT the pane rect: a rect
    /// changes on every layout animation and would reallocate ~52 MB per frame.
    pub pane: u64,
    /// egui's `cumulative_frame_nr` when the callback was placed. The pass clock
    /// counts frames by it, and a pane unseen for a whole frame is freed by it.
    pub frame_nr: u64,
}

impl PaneStamp {
    /// The stamp for the pane `id` names, placed in the `ui`'s current frame.
    #[must_use]
    pub fn of(ui: &egui::Ui, id: egui::Id) -> Self {
        Self { pane: id.value(), frame_nr: ui.ctx().cumulative_frame_nr() }
    }
}

pub struct MapPaintCallback {
    /// Which pane this callback paints, in which frame — see [`PaneStamp`].
    pub stamp: PaneStamp,
    /// First-frame geometry payload — `Some` once, then `None` every later frame.
    pub geometry: Option<Box<GpuGeometry>>,
    /// **The TILE lane's hand-over** — `Some` switches this callback to the
    /// content-keyed VRAM cache (per-tile origins, per-tile uniforms, survives
    /// facet rebuilds); `None` is the legacy whole-payload path, byte-identical
    /// to before the lane existed.
    pub tiles: Option<Vec<TileHandover>>,
    /// Current viewport in **origin-local** Mercator space [x0, y0, x1, y1] (same
    /// space as the rebased per-way meta bboxes — [`MapTransform::viewport_bbox`]).
    pub viewport_bbox: [f32; 4],
    /// Current LOD level: 0=country 1=region 2=city.
    pub lod: u32,
    /// The origin-local per-axis line transform (see [`MapTransform`]). The viewport
    /// size is folded in during `prepare` (only known from the `ScreenDescriptor`).
    pub transform: MapTransform,
    /// deck.gl `widthScale` for this frame's zoom
    /// ([`crate::style::line_width_scale`]) — the line shader multiplies the baked raw
    /// width by this and clamps into the pixel band, so widths track zoom live and
    /// match the CPU painter exactly.
    pub width_scale: f32,
    /// The **fill lane's** RGB multiplier for this frame — `1.0` on a flat map,
    /// `1 − 0.5·(tilt/MAX_TILT)` on a pitched one. See [`DrawUniforms::tint`]; it is
    /// the CPU painter's `layer::fade`, moved to where a static VRAM bake can still
    /// obey it.
    pub fill_tint: f32,
    /// The casing ink for this frame, straight off the active palette
    /// (`MapTheme::palette.casing`) — linear-ish sRGB f32 as the shader wants it.
    /// `None` falls back to [`LINE_CASING_COL`], which is what a callback built in a
    /// test without a palette gets.
    pub casing_col: Option<[f32; 4]>,
}

impl egui_wgpu::CallbackTrait for MapPaintCallback {
    fn prepare(
        &self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        sd: &egui_wgpu::ScreenDescriptor,
        encoder: &mut wgpu::CommandEncoder,
        resources: &mut egui_wgpu::CallbackResources,
    ) -> Vec<wgpu::CommandBuffer> {
        let Some(renderer) = resources.get_mut::<GpuMapRenderer>() else {
            return vec![];
        };

        // ── THE TILE LANE ────────────────────────────────────────────────────
        // Content-keyed chunk sets that survive facet rebuilds: upload only the
        // hand-overs that carry a payload, note the ones the cache is missing
        // (self-healing via the wanted-registry), evict past the VRAM budget,
        // write every current tile's per-frame uniforms, cull, done. The legacy
        // whole-payload body below never runs for a tiled callback.
        if let Some(tiles) = &self.tiles {
            renderer.tile_frame += 1;
            let frame = renderer.tile_frame;
            for h in tiles {
                if let Some(geo) = &h.geometry {
                    if !renderer.tile_gpu.contains_key(&h.key) {
                        renderer.upload_tile(device, queue, h.key, geo);
                    }
                } else if !renderer.tile_gpu.contains_key(&h.key) {
                    // In the draw set, no payload, not resident: an evicted key
                    // (or another renderer's). Ask the facet to resend.
                    tile_registry_mark_wanted(h.key);
                }
                if let Some(t) = renderer.tile_gpu.get_mut(&h.key) {
                    t.last_used = frame;
                }
            }
            // Refilled in place: the draw set is the same length frame after frame,
            // so this allocates only when a frame carries more tiles than any before.
            renderer.tile_current.clear();
            renderer.tile_current.extend(tiles.iter().map(|h| (h.key, h.transform, h.viewport_bbox, h.band)));
            renderer.evict_tiles_over_budget();

            // Per-tile uniforms + indirect resets + cull, through the ONE gated writer
            // (`run_cull_tiles_clocked`), which skips all of it when this frame is
            // byte-identical to the last. The minimum-extent gate is computed from
            // THIS frame's camera by the one Rust writer and handed to the shader as
            // a number (stinky `kalm-3`). `zoom_x` IS `MapView::world_px` — see
            // `MapTransform::new`'s call site.
            let inputs = TileFrameInputs {
                lod: self.lod,
                min_fill_extent: crate::style::min_fill_extent_merc(f64::from(self.transform.zoom_x)),
                logical_w: sd.size_in_pixels[0] as f32 / sd.pixels_per_point,
                logical_h: sd.size_in_pixels[1] as f32 / sd.pixels_per_point,
                fill_tint: self.fill_tint,
                casing_col: self.casing_col.unwrap_or(LINE_CASING_COL),
                width_scale: self.width_scale,
            };
            // THIS pane's gate (HO-T4) — the same `PaneStamp` key `pane_target` uses.
            renderer.run_cull_tiles_clocked(device, queue, encoder, self.stamp.pane, &inputs, self.stamp.frame_nr);
            facett_core::render::lane::note_frame(lane_name());
            self.record_offscreen(device, queue, encoder, sd, renderer);
            return vec![];
        }
        renderer.tile_current.clear();

        if let Some(geo) = &self.geometry {
            renderer.upload(device, queue, geo);
        }

        if !renderer.geom_ready {
            return vec![];
        }

        let logical_w = sd.size_in_pixels[0] as f32 / sd.pixels_per_point;
        let logical_h = sd.size_in_pixels[1] as f32 / sd.pixels_per_point;
        queue.write_buffer(
            &renderer.draw_uniform,
            0,
            bytemuck::bytes_of(&self.transform.uniforms(logical_w, logical_h)),
        );
        // The fill draw's copy: same camera, plus the pitched-horizon fade the CPU
        // painter applies to a FILL and not to its outline. `fill_tint == 1.0` (a flat
        // map) makes this buffer byte-identical to the one above.
        queue.write_buffer(
            &renderer.fill_uniform,
            0,
            bytemuck::bytes_of(&self.transform.uniforms_tinted(logical_w, logical_h, self.fill_tint)),
        );
        // Pretty-line lane: refresh the casing + fill uniforms for THIS frame's
        // camera (same origin-local transform as the draw uniform above). The quad
        // vertex buffer is uploaded once; only these per-frame uniforms change.
        renderer.line_renderer.write_uniforms(
            queue,
            &self.transform,
            logical_w,
            logical_h,
            LINE_CASING_PX,
            self.casing_col.unwrap_or(LINE_CASING_COL),
            LINE_FEATHER_PX,
            self.width_scale,
        );

        // The clocked body (perf/v2.3): `run_cull` bracketed by the lane's pass
        // clock — TIMESTAMP_QUERY where the device has it, an honest recorded
        // "unavailable" where it does not. One writer; the device test drives the
        // same method.
        renderer.run_cull_clocked(
            device,
            queue,
            encoder,
            self.viewport_bbox,
            self.lod,
            crate::style::min_fill_extent_merc(f64::from(self.transform.zoom_x)),
            self.stamp.frame_nr,
        );

        // LANE ATTRIBUTION (`facett_core::render::lane`): a frame of real GPU work is
        // now encoded against a real device — uniforms written, cull dispatched, the
        // geometry ready. Counted HERE, after every fail-safe early-return above, so
        // the count can never claim a frame the GPU did not see. This is what lets an
        // About box say "GPU lane: IN USE" instead of inferring it from
        // `renderer_installed()`, which is true even when nothing ever paints.
        facett_core::render::lane::note_frame(lane_name());

        self.record_offscreen(device, queue, encoder, sd, renderer);
        vec![]
    }

    fn paint(
        &self,
        info: egui::PaintCallbackInfo,
        render_pass: &mut wgpu::RenderPass<'static>,
        resources: &egui_wgpu::CallbackResources,
    ) {
        let Some(renderer) = resources.get::<GpuMapRenderer>() else { return };
        // `geom_ready` is the LEGACY latch — the tile lane's readiness is the
        // resident cache itself, consulted per key below.
        if self.tiles.is_none() && !renderer.geom_ready {
            return;
        }
        // ── THE VIEWPORT MUST BE THE WHOLE SURFACE, because the vertices are in
        //    WHOLE-SURFACE coordinates. ────────────────────────────────────────
        //
        // `prepare` writes `DrawUniforms::viewport_{w,h}` from the ScreenDescriptor —
        // the whole logical surface — and the shader's `px_to_ndc` divides the
        // vertex's screen-space px by exactly that. So the vertex stream lives in the
        // WINDOW's coordinate space, and NDC ±1 must therefore mean the WINDOW's
        // edges.
        //
        // egui-wgpu does not leave it that way. `Renderer::render` sets the render
        // pass viewport to the CALLBACK'S OWN RECT before invoking us ("a default
        // viewport … as a courtesy … the user still has the possibility of setting
        // their own custom viewport during the paint callback, effectively overriding
        // this one" — egui-wgpu 0.35 `renderer.rs`). NDC ±1 then means the PANE's
        // edges, and the whole window coordinate space is squeezed into the pane:
        //
        //     drawn_px = pane_origin + window_px · (pane_size / window_size)
        //
        // MEASURED on the shipped korp-ui on a real Xvfb window, 2026-08-03, at two
        // different window sizes, all four corners within 2 px of that formula: at
        // 1900×1150 with the map pane at (36,382) 1258×728, Sweden's north-west
        // corner belongs at screen (563.0, 491.5) and was drawn at (407, 691); at
        // 1300×900 with the pane at (36,400) 850×460 it belongs at (359.0, 375.5)
        // and was drawn at (269, 593) — predicted (270.7, 591.9). The landmass fill
        // and every label, which the CPU painter projects through `MapView::project`,
        // landed on the correct pixel in the same frame. So the map showed its
        // coastline and its roads as a second, smaller, displaced Sweden beside the
        // right one.
        //
        // Why no test saw it: `tests/map_facet_gpu_lane.rs` asserts `MapTransform`
        // agrees with `MapView::project` — a CPU-side comparison that never converts
        // to NDC and never learns what viewport the pass will carry. And a pane that
        // IS the whole surface (every offscreen `Harness` render) has
        // `pane_size == window_size` and `pane_origin == 0`, which makes the wrong
        // formula the identity. The bug is invisible in exactly the posture the
        // tests render in, and present in exactly the posture a human lands in.
        //
        // The scissor is NOT touched: egui-wgpu sets `set_scissor_rect(clip_rect)`
        // before the callback and that still clips us to the pane. Only the
        // coordinate mapping changes.
        // **THE MAP IS ALREADY DRAWN.** Since lane MP-a the geometry goes into this
        // lane's OWN colour+depth target in `prepare`, so the rank ordering survives
        // being split across tiles and chunks — `PaintKey` cannot reach across two
        // submissions and depth can. What is left here is one textured triangle.
        //
        // egui has already set the pass viewport to this callback's pane rect, so the
        // triangle covers that viewport and samples the PANE'S sub-rectangle of the
        // whole-surface texture. Getting the two the wrong way round draws the whole
        // window shrunk into the pane — the same failure this lane measured on 2026-08-03.
        // THIS pane's target and blit uniform (GG #37) — `prepare` placed it under the
        // stamp this callback carries; a pane `prepare` never reached has nothing to blit.
        let Some(pane) = renderer.panes.iter().find(|p| p.key == self.stamp.pane) else { return };
        let t0 = web_time::Instant::now();
        // The pane's sub-rectangle of the whole-surface texture, in 0..1. Computed HERE
        // because `PaintCallbackInfo` is what knows the callback's rect, and written
        // through the queue the renderer kept from `prepare` — a uniform write is the
        // only thing `paint` needs a queue for. Into the PANE's uniform: a
        // `write_buffer` lands before the submit, not between two callbacks, so one
        // shared uniform gave every pane the LAST pane's rect.
        let vp = info.viewport_in_pixels();
        let (sw, sh) = pane.target.size;
        let uv = [
            vp.left_px as f32 / sw as f32,
            vp.top_px as f32 / sh as f32,
            (vp.left_px + vp.width_px) as f32 / sw as f32,
            (vp.top_px + vp.height_px) as f32 / sh as f32,
        ];
        if let Some(q) = renderer.queue.as_ref() {
            q.write_buffer(&pane.blit_uniform, 0, bytemuck::cast_slice(&uv));
        }
        render_pass.set_pipeline(&renderer.blit_pipeline);
        render_pass.set_bind_group(0, &pane.blit_bind, &[]);
        render_pass.draw(0..3, 0..1);
        note_blit_us(t0.elapsed().as_micros() as u64);
    }
}

impl MapPaintCallback {
    /// **Draw the map into its own colour+depth target** — lane MP-a.
    ///
    /// Called from BOTH of `prepare`'s exits, which is the whole reason it is a method:
    /// the tile lane returns early (it has its own uniform-writing loop and then
    /// `note_frame`), so an offscreen block written only at the end of `prepare` never
    /// ran for the path every GPU oracle uses — six of seven went dark and the failure
    /// read as "the lake did not paint", not as "the pass was never recorded".
    fn record_offscreen(
        &self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        encoder: &mut wgpu::CommandEncoder,
        sd: &egui_wgpu::ScreenDescriptor,
        renderer: &mut GpuMapRenderer,
    ) {
        //
        // egui's pass has no depth attachment, so the map draws into its own and
        // `paint` blits the result. The target is the WHOLE SURFACE and the viewport is
        // left at the full texture: the vertices are already in whole-surface
        // coordinates (`DrawUniforms::viewport_*` is the ScreenDescriptor's size and the
        // shader divides by exactly that), so there are none of the pane-rect gymnastics
        // egui's pass forces on `paint`.
        //
        // Depth clears to 1.0 and the comparison is `Less` (`map_depth_state`), which is
        // what `paint_order::depth_of` was built against: higher rank, smaller depth,
        // closer — and it holds across tiles and chunks, which is the whole point.
        //
        // **ONE TARGET PER PANE** (GG #37). korp's main map and its inset are two
        // callbacks in one frame, and egui-wgpu runs both `prepare`s before either
        // `paint`: with one target the second clear wiped the first pane's map before
        // it was blitted. The target is looked up by the pane key the callback carries
        // and allocated on a new key or a size change only.
        if renderer.queue.is_none() {
            renderer.queue = Some(queue.clone());
        }
        let size = (sd.size_in_pixels[0].max(1), sd.size_in_pixels[1].max(1));
        let pane = renderer.pane_target(device, self.stamp, size);
        {
            let t = &renderer.panes[pane].target;
            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
                label: Some("facett_map_offscreen"),
                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                    view: &t.colour,
                    depth_slice: None,
                    // **The resolve** (lane GG #2). The 4× samples are averaged into the
                    // single-sample texture the blit reads, at the end of the pass, by the
                    // hardware. `StoreOp::Discard` on the 4× attachment itself: nothing
                    // reads it after the resolve, so there is no reason to write 4× a
                    // full surface back to memory.
                    resolve_target: Some(&t.resolved),
                    ops: wgpu::Operations {
                        load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
                        store: wgpu::StoreOp::Discard,
                    },
                })],
                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
                    view: &t.depth,
                    depth_ops: Some(wgpu::Operations {
                        load: wgpu::LoadOp::Clear(1.0),
                        store: wgpu::StoreOp::Discard,
                    }),
                    stencil_ops: None,
                }),
                timestamp_writes: None,
                occlusion_query_set: None,
                multiview_mask: None,
            });
            self.record_draws(&mut pass, renderer, size.0, size.1);
        }

    }

    /// **Record the map's draws into a pass that HAS a depth attachment** — lane MP-a.
    ///
    /// Lifted verbatim out of `paint`, which now blits instead. The body is unchanged:
    /// the band-major walk, the per-tile bind groups, the three topologies in painter's
    /// order. What changed is the pass it records into — one with depth, so everything
    /// finer than a band (the tier, the area fallback, the osm id) is decided by Z
    /// rather than by which tile happened to be submitted first.
    fn record_draws(
        &self,
        render_pass: &mut wgpu::RenderPass<'_>,
        renderer: &GpuMapRenderer,
        sw: u32,
        sh: u32,
    ) {
        // ── THE TILE LANE's draw: **BAND-MAJOR**. One run per declared draw band,
        //    ascending, and inside a run the three topologies in painter's order
        //    (fills → their outlines → lines). Per-tile bind groups carry each tile's
        //    own origin transform.
        //
        //    What this replaced: fills across ALL tiles, then outlines across all, then
        //    lines across all. Its comment claimed that held the class order across
        //    tiles; it held it only for the SAME class. Each tile's stream is sorted on
        //    `PaintKey` independently, so a building in tile 1 and the lake overlapping
        //    it in tile 2 stacked by `tile_key` — MEASURED on oden / RTX 4090,
        //    2026-08-23: moving that pair between the two tiles changed 9 660 of 208 000
        //    pixels (4.64 %) and in one arrangement the building was not on screen at
        //    all (`tests/gpu_lane_paint_order.rs`).
        //
        //    A group is single-band by construction (`add_gpu_tiled_callback` keys on
        //    `(draw_band, tile_key)`), and the hand-over list is sorted on that pair —
        //    so equal bands are already contiguous and this walks runs, sorting
        //    nothing. Everything finer than a band (the `Lod` tier, the area fallback,
        //    the osm id) is still decided inside the run by the ordered compaction.
        // The draw census (GG audit row 21): commands issued, `(group, stream, chunk)`
        // units walked, band runs walked — tallied here and published once at the end.
        let (mut draws, mut units, mut runs) = (0u64, 0u64, 0u64);
        if self.tiles.is_some() {
            let cur = &renderer.tile_current;
            let mut i = 0usize;
            while i < cur.len() {
                let band = cur[i].3;
                let mut j = i;
                while j < cur.len() && cur[j].3 == band {
                    j += 1;
                }
                let run = &cur[i..j];
                runs += 1;
                // **The BAND's depth, through the viewport's depth range** (lane MP-a).
                //
                // The line lane packs a per-WAY depth into its vertex, because inside one
                // band the tier still separates a motorway from a residential. A FILL has
                // no such problem: `way_draw_band` for a fill IS its class (water 100,
                // park 200, building 300), so one depth per run is exactly right — and
                // `set_viewport`'s min/max depth maps the shader's `z = 0` onto it with
                // no change to the 24 B fill `Vertex`, which is as load-bearing as the
                // line one.
                //
                // Without this every fill sat at z = 0, the closest possible value, and
                // painted over every line — which is what two of the seven oracles caught
                // the moment depth testing came on.
                let d = crate::paint_order::depth_of(band, false);
                render_pass.set_viewport(0.0, 0.0, sw as f32, sh as f32, d, d);
                render_pass.set_pipeline(&renderer.fill_pipeline);
                for (key, _, _, _) in run {
                    let Some(t) = renderer.tile_gpu.get(key) else { continue };
                    render_pass.set_bind_group(0, &t.fill_bind, &[]);
                    for chunk in &t.fill_chunks {
                        draws += draw_chunk(render_pass, &chunk.geom_buf, &chunk.index_buf, &chunk.indirect_buf);
                        units += 1;
                    }
                }
                // **Back to the full depth range for the LINES.** The fills above are
                // collapsed onto one band depth, which is right for them; a line carries
                // its OWN per-way depth in the vertex, and leaving the range pinned would
                // flatten every road in the run onto the band and undo exactly the
                // cross-tile ordering this lane exists to fix. Measured: with the range
                // left pinned, six oracles pass and the cross-tile ROAD one still fails.
                render_pass.set_viewport(0.0, 0.0, sw as f32, sh as f32, 0.0, 1.0);
                for (key, _, _, _) in run {
                    let Some(t) = renderer.tile_gpu.get(key) else { continue };
                    for chunk in &t.outline_chunks {
                        draws += renderer.line_renderer.draw_indirect_outline(
                            render_pass,
                            &chunk.src_buf,
                            &chunk.index_buf,
                            &chunk.indirect_buf,
                            &t.line_u.bind_outline,
                        );
                        units += 1;
                    }
                }
                for (key, _, _, _) in run {
                    let Some(t) = renderer.tile_gpu.get(key) else { continue };
                    for chunk in &t.line_chunks {
                        draws += renderer.line_renderer.draw_indirect_bound(
                            render_pass,
                            &chunk.src_buf,
                            &chunk.index_buf,
                            &chunk.indirect_buf,
                            &t.line_u,
                        );
                        units += 1;
                    }
                }
                i = j;
            }
            note_draws(draws, units, runs);
            return;
        }
        // ── Pretty GPU line lane, drawn INDIRECTLY (GFX_V2 item 2) ────────────
        // The index count comes from the GPU. `prepare`'s cull pass frustum- and
        // LOD-tested every way and compacted the survivors' vertex indices into
        // `index_buf`, storing `indirect_buf.index_count`; the draw below reads that
        // count straight out of the buffer without the CPU ever learning it. No
        // readback, no round-trip, no CPU-side visible count.
        //
        // What this replaced: `0..line_lod_end[lod]`, a CPU-computed prefix of the
        // WHOLE dataset with no frustum test at all — every road in the region was
        // submitted every frame and clipped by the rasteriser. Meanwhile the cull
        // pass ran anyway and compacted the 24 B hairline stream that nothing drew.
        //
        // Each road draw is two INSTANCES (dark casing UNDER, bright fill on top) of
        // one command, both reading the same count from the same buffer.
        //
        // ── NO WHOLE-CLASS STENCIL CASING PASS HERE, and it is measured (lane GG #26) ─
        // The audit asks this function for "a casing pass for the whole class before
        // fills (stencil Replace)", on the premise that casings of one class do not
        // merge at intersections. They do. The casing/fill split is per CHUNK, not per
        // way, and across chunks and tiles the merge rides on the rank: a casing sits one
        // `CASING_DEPTH_BIAS` behind its fill, `LessEqual` accepts an equal rank and
        // rejects a farther one, and two ways of one class share a rank. Pixel evidence
        // and the reason a stencil pass would be a regression against GG #2's sample-mask
        // nesting: `line::LineRenderer::draw_indirect`.
        // ── FILLS FIRST, then their outlines, then the roads ──────────────────
        // The CPU painter got this ordering from `DrawFeature::z` and a
        // `partition_point` in `MapFacet::ui`: areas (z = Polygon) under lines
        // (z = Line). Inside one callback there is no z, so the order is the
        // submission order — which is why the two fill draws are here and not after
        // the roads. A lake drawn last would paint over every road crossing it.
        render_pass.set_pipeline(&renderer.fill_pipeline);
        render_pass.set_bind_group(0, &renderer.fill_bind, &[]);
        for chunk in &renderer.fill_chunks {
            draws += draw_chunk(render_pass, &chunk.geom_buf, &chunk.index_buf, &chunk.indirect_buf);
            units += 1;
        }
        for chunk in &renderer.outline_chunks {
            draws += renderer.line_renderer.draw_indirect_outline(
                render_pass,
                &chunk.src_buf,
                &chunk.index_buf,
                &chunk.indirect_buf,
                renderer.line_renderer.outline_bind(),
            );
            units += 1;
        }
        for chunk in &renderer.line_chunks {
            draws +=
                renderer.line_renderer.draw_indirect(render_pass, &chunk.src_buf, &chunk.index_buf, &chunk.indirect_buf);
            units += 1;
        }
        // The whole payload is one implicit band run.
        note_draws(draws, units, 1);
    }
}

/// **The ONE indexed indirect draw of a chunk** — geometry as the vertex buffer, the
/// cull's index list as the index buffer, the control block as the args. Returns the
/// number of draw commands it issued (one), so the draw census is tallied where the
/// command is recorded and nowhere else. Pipeline and bind group are the caller's:
/// they are set once per run, not once per chunk.
pub(crate) fn draw_chunk(
    pass: &mut wgpu::RenderPass<'_>,
    vertices: &wgpu::Buffer,
    indices: &wgpu::Buffer,
    ctl: &wgpu::Buffer,
) -> u64 {
    pass.set_vertex_buffer(0, vertices.slice(..));
    pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
    pass.draw_indexed_indirect(ctl, 0);
    1
}

/// Publish one `record_draws` walk's census — see [`DRAW_CALLS`].
fn note_draws(draws: u64, units: u64, runs: u64) {
    DRAW_CALLS.fetch_add(draws, std::sync::atomic::Ordering::Relaxed);
    DRAW_UNITS.fetch_add(units, std::sync::atomic::Ordering::Relaxed);
    DRAW_BAND_RUNS.fetch_add(runs, std::sync::atomic::Ordering::Relaxed);
}

// ── Bind-group layout helpers ─────────────────────────────────────────────────

fn bgl_storage(binding: u32, read_write: bool) -> wgpu::BindGroupLayoutEntry {
    wgpu::BindGroupLayoutEntry {
        binding,
        visibility: wgpu::ShaderStages::COMPUTE,
        ty: wgpu::BindingType::Buffer {
            ty: wgpu::BufferBindingType::Storage { read_only: !read_write },
            has_dynamic_offset: false,
            min_binding_size: None,
        },
        count: None,
    }
}

fn bgl_uniform(binding: u32) -> wgpu::BindGroupLayoutEntry {
    wgpu::BindGroupLayoutEntry {
        binding,
        visibility: wgpu::ShaderStages::COMPUTE,
        ty: wgpu::BindingType::Buffer {
            ty: wgpu::BufferBindingType::Uniform,
            has_dynamic_offset: false,
            min_binding_size: None,
        },
        count: None,
    }
}

// ── CPU helpers ───────────────────────────────────────────────────────────────

// CONS-CORE Phase C: re-exported from the shared L0 plumbing (byte-identical to the
// old facett-map fn — the core `color32_to_f32_maps_channels` test pins it).
pub use facett_core::render::gpu::color32_to_f32;

// ── Origin-local transform (the high-zoom f32-cancellation fix) ───────────────
//
// Both 2D GPU line callers (`facett_geomap::osm2d::OsmView` and
// `facett_map::MapView`) used to hand-roll `screen = mercator*zoom + offset`, which
// added two ~4e7 f32 whose true pixel was their tiny difference → catastrophic
// cancellation and multi-pixel drift at street-level zoom. The fix (shared here per
// the REUSE law) bakes vertices relative to a **data-local origin** and projects
// `screen = (pos − ref)*zoom + screen_center`, mirroring the CPU `to_px`'s
// subtract-before-scale. See `draw.wgsl`.

/// The dataset's Mercator **bbox-min**, computed once in f64 — the data-local origin
/// every vertex, the shader `ref`, the cull viewport and the per-way meta bboxes are
/// rebased against. Camera-independent, so it is stable across frames (the
/// upload-once vertex latch is preserved). Uses each way's precomputed Mercator bbox
/// (O(ways), not O(vertices)), so it is cheap to call every frame. Callers MUST pass
/// the SAME origin to [`build_gpu_geometry`] and [`MapTransform::new`] (single source
/// of truth) or the frustum cull silently drops visible ways.
pub fn ways_origin<'a>(ways: impl Iterator<Item = &'a Way>) -> Option<[f64; 2]> {
    let mut o: Option<[f64; 2]> = None;
    for w in ways {
        let (x, y) = (w.bbox[0], w.bbox[1]);
        o = Some(match o {
            None => [x, y],
            Some([a, b]) => [a.min(x), b.min(y)],
        });
    }
    o
}

/// The origin-local, per-axis line transform the GPU draw shader consumes. Both 2D
/// callers build one via [`MapTransform::new`] (single shared constructor), so the
/// `DrawUniforms` packing + the rebased cull viewport live in ONE place.
///
/// Fields are already SMALL (relative to `origin`): `ref = camera_center − origin`
/// and `screen_center` is a pixel coordinate, so the shader's `(pos − ref)` forms a
/// precise small difference before scaling by `zoom`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MapTransform {
    pub zoom_x: f32,
    pub zoom_y: f32,
    pub ref_x: f32,
    pub ref_y: f32,
    pub screen_center_x: f32,
    pub screen_center_y: f32,
    /// **The camera's compass bearing in degrees** — 0 is north-up.
    ///
    /// This field is the whole of `mapdraw-3`. Without it `MapTransform` was the 2D
    /// view core *with the rotation deleted*, so `MapFacet::way_lane_plan` had no
    /// choice but to answer `CpuOnly::RotatedCamera` and hand every way back to the
    /// CPU painter the moment the bearing left zero. MEASURED 2026-08-03, real
    /// 1600×1000 window on an RTX 4090, line ways only: **0.857 ms → 12.28 ms, a
    /// 14.3× regression, with `lane_frames` 249 → 0**. The control that made it a
    /// diagnosis rather than a guess: fills-only moved +0.3 % across the same change,
    /// because fills were never on the GPU to lose.
    pub bearing_deg: f32,
    /// The camera's tilt in degrees — 0 is flat. A *tilted orthographic* view
    /// foreshortens the vertical axis by exactly `cos(tilt)` with no perspective
    /// convergence, which is a real projection and not a fake; see
    /// [`facett_core::render::View::ortho_2d_compass`].
    pub tilt_deg: f32,
}

impl MapTransform {
    /// Build from the data-local `origin`, the Mercator point the camera centres on
    /// (`center_merc`, in absolute Mercator so `ref` is formed in f64), the per-axis
    /// `zoom` (equal for a uniform camera; different for a non-uniform fit), and the
    /// pane `rect` (whose centre is where `center_merc` lands). Mirrors the CPU
    /// `to_px = rect.center + (merc − center)·zoom`.
    pub fn new(origin: [f64; 2], center_merc: [f64; 2], zoom_x: f32, zoom_y: f32, rect: egui::Rect) -> Self {
        let sc = rect.center();
        Self {
            zoom_x,
            zoom_y,
            ref_x: (center_merc[0] - origin[0]) as f32,
            ref_y: (center_merc[1] - origin[1]) as f32,
            screen_center_x: sc.x,
            screen_center_y: sc.y,
            bearing_deg: 0.0,
            tilt_deg: 0.0,
        }
    }

    /// **Turn the camera** — the compass bearing (degrees clockwise, 0 = north-up)
    /// and tilt this transform projects through.
    ///
    /// A builder rather than a `new` parameter so every existing caller keeps the
    /// north-up flat camera it already asked for, and only a caller that has a
    /// bearing has to say so.
    #[must_use]
    pub fn with_compass(mut self, bearing_deg: f32, tilt_deg: f32) -> Self {
        self.bearing_deg = bearing_deg;
        self.tilt_deg = tilt_deg;
        self
    }

    /// The **ground frame** this transform applies, at `f64` — rotate then
    /// foreshorten, evaluated by the shared
    /// [`facett_core::render::GroundFrame2d`]. Taken from the DEGREES (not from
    /// [`view`](Self::view)'s `f32` radians), which is what makes it bit-identical to
    /// the CPU painter's `MapView::ground_frame`.
    #[must_use]
    pub fn ground_frame(&self) -> facett_core::render::GroundFrame2d {
        facett_core::render::View::ground_frame_2d_compass_deg(
            f64::from(self.bearing_deg),
            f64::from(self.tilt_deg),
        )
    }

    /// The ground rotation as the shader sees it (`f32`). `[1, 0, 0, 1]` north-up.
    #[must_use]
    pub fn rot_f32(&self) -> [f32; 4] {
        let r = self.ground_frame().rot;
        [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]
    }

    /// `cos(tilt)` as the shader sees it (`f32`). `1.0` flat.
    #[must_use]
    pub fn foreshorten_f32(&self) -> f32 {
        self.ground_frame().foreshorten as f32
    }

    /// Pack into the draw-shader uniform (viewport size is only known in `prepare`),
    /// with no colour tint — what every stream but the map's fills wants.
    pub fn uniforms(&self, viewport_w: f32, viewport_h: f32) -> DrawUniforms {
        self.uniforms_tinted(viewport_w, viewport_h, DrawUniforms::NO_TINT)
    }

    /// [`Self::uniforms`] with the fill lane's pitched-horizon `tint` — see
    /// [`DrawUniforms::tint`]. ONE packer; the tint is the only argument, so the two
    /// uniform buffers cannot drift on anything else.
    pub fn uniforms_tinted(&self, viewport_w: f32, viewport_h: f32, tint: f32) -> DrawUniforms {
        DrawUniforms {
            zoom_x: self.zoom_x,
            zoom_y: self.zoom_y,
            screen_center_x: self.screen_center_x,
            screen_center_y: self.screen_center_y,
            ref_x: self.ref_x,
            ref_y: self.ref_y,
            viewport_w,
            viewport_h,
            rot: self.rot_f32(),
            foreshorten: self.foreshorten_f32(),
            tint,
            _pad: [0.0; 2],
        }
    }

    /// **The unified view this 2D transform IS** — GFX_V2 item 6. A `MapTransform`
    /// is not a separate kind of camera from the 3D one; it is
    /// [`facett_core::render::View`] under the 2D constraints (tilt 0, bearing 0,
    /// orthographic, `Z(x,y) ≡ 0`, post-FX bypassed), with `target` at the
    /// origin-local `ref` and `px_per_unit` the per-axis `zoom`.
    ///
    /// [`project`](Self::project) and [`viewport_bbox`](Self::viewport_bbox)
    /// **delegate to it**, so the 2D lane has no projection writer of its own and
    /// an AA / miter / precision fix in the core lands in 2D and 3D at once (LAW
    /// #5). The reduction is bit-exact, not approximate — see
    /// `facett-map3d/tests/gfx_v2_item6_parity.rs`.
    #[must_use]
    pub fn view(&self) -> facett_core::render::View {
        facett_core::render::View::ortho_2d_compass(
            [self.ref_x, self.ref_y],
            [self.zoom_x, self.zoom_y],
            self.bearing_deg,
            self.tilt_deg,
        )
    }

    /// Where [`view`](Self::view)'s `target` lands in pixels (the pane centre).
    #[inline]
    fn centre_px(&self) -> (f32, f32) {
        (self.screen_center_x, self.screen_center_y)
    }

    /// Project an **origin-local** vertex `pos` to a logical screen pixel — the exact
    /// CPU twin of `draw.wgsl`'s `vs_main`, so tests can assert the GPU projection
    /// against an f64 reference without a GPU.
    ///
    /// Delegates to the unified view core (ONE writer). `half_h` is unused because
    /// the 2D constraint is orthographic — nothing here divides by depth.
    #[must_use]
    pub fn project(&self, pos: [f32; 2]) -> [f32; 2] {
        let p = self.view().project_ground(pos, self.centre_px(), 1.0);
        [p.x, p.y]
    }

    /// The cull viewport bbox `[x0, y0, x1, y1]` in **origin-local** Mercator space —
    /// the same space [`build_gpu_geometry`] rebases the per-way meta bboxes into, so
    /// the compute cull compares like-for-like. (Inverse of [`project`](Self::project)
    /// on the pane corners.)
    /// Delegates to the unified view core's ground unprojection, so the cull space
    /// and the draw space are **exact inverses by construction** rather than two
    /// hand-written affines that have to be kept agreeing. If they ever disagreed
    /// the compute cull would silently drop ways that are on screen.
    /// # All FOUR corners, because two is only right at bearing 0
    ///
    /// This used to unproject `left_top` and `right_bottom` alone and take their
    /// min/max. Under a north-up camera the ground image of the pane is an
    /// axis-aligned rectangle and those two corners are its extremes, so the answer
    /// was right — and the bug was invisible for exactly as long as the GPU lane
    /// refused to run at any other bearing.
    ///
    /// Rotate the camera and the pane's ground image is a rotated rectangle whose
    /// axis-aligned envelope is set by all four corners, and the diagonal pair
    /// under-covers it. At 45° the two-corner box has **half** the area of the true
    /// envelope, so the compute cull would drop ways that are plainly on screen —
    /// roads winking out along two opposite edges as the map turns. It would have
    /// landed the same day the rotation started working, looking like a bug in the
    /// rotation.
    ///
    /// At bearing 0 / tilt 0 the two extra corners are interior to the same box and
    /// the result is bit-identical to the old one, so this cannot move a north-up
    /// frame.
    #[must_use]
    pub fn viewport_bbox(&self, rect: egui::Rect) -> [f32; 4] {
        let v = self.view();
        let c = self.centre_px();
        // Ortho never misses the plane, so the `expect` is unreachable by
        // construction (`View::unproject_ground` only returns `None` for a
        // perspective ray that cannot reach the ground).
        let un = |p: egui::Pos2| v.unproject_ground((p.x, p.y), c, 1.0).expect("ortho always hits the plane");
        let corners =
            [rect.left_top(), rect.right_top(), rect.right_bottom(), rect.left_bottom()].map(un);
        let (mut x0, mut y0) = (f32::MAX, f32::MAX);
        let (mut x1, mut y1) = (f32::MIN, f32::MIN);
        for [x, y] in corners {
            x0 = x0.min(x);
            y0 = y0.min(y);
            x1 = x1.max(x);
            y1 = y1.max(y);
        }
        // ── DILATED BY THE WIDEST STROKE THE PAINTER CAN DRAW (stinky `seam-15`).
        //
        // The corners give the pane's exact envelope, and a way's meta bbox is its
        // CENTRELINE's. A motorway whose centreline misses the pane by a pixel is
        // culled, and with it goes its whole 24 px ribbon, its casing and its feather —
        // the road stops in mid-air at the edge of the canvas and the next pan snaps it
        // back. `zoom_x` IS `MapView::world_px`, so one call to the shared writer
        // converts the px pad into this space; the CPU cull widens by the same number.
        let pad = crate::style::cull_pad_merc(f64::from(self.zoom_x)) as f32;
        [x0 - pad, y0 - pad, x1 + pad, y1 + pad]
    }
}

/// One pretty-line polyline of [`build_gpu_geometry`]: its declared paint key, LOD,
/// the polyline, and its origin-local bbox min/max.
type LinePolyEntry = (crate::paint_order::PaintKey, u32, LinePolyline, [f32; 2], [f32; 2]);

/// Build `GpuGeometry` from all ways across all LOD tiers. Runs once at first
/// paint; the result is uploaded to VRAM then dropped. Reads the facett [`Way`]'s
/// `style.color` (instead of osm-viewer's `style.stroke`).
///
/// **Origin-local vertices** (high-zoom precision fix): each vertex is reprojected
/// from the source `lonlat` (f64) via [`crate::mercator`], then rebased against the
/// data-local `origin` — `pos = mercator_f64(lonlat) − origin` — so the GPU never
/// sees an absolute Mercator coordinate. The per-way bbox is rebased against the same
/// `origin` so the compute cull matches [`MapTransform::viewport_bbox`]. `origin` MUST
/// be the SAME value the per-frame [`MapTransform`] uses.
pub fn build_gpu_geometry<'a>(ways: impl Iterator<Item = (&'a Way, u32)>, origin: [f64; 2]) -> GpuGeometry {
    let mut vertices: Vec<Vertex> = Vec::new();
    let mut way_meta: Vec<GpuWayMeta> = Vec::new();
    // ── Pretty-line polylines in ONE list, carrying the DECLARED paint key.
    //
    // They were bucketed into four LOD tiers here and expanded tier-ascending, which
    // made the on-screen stacking of the road network the LOD ladder RUN BACKWARDS: a
    // `Country` motorway went into bucket 0 and was therefore drawn FIRST, i.e. under
    // every `City` cul-de-sac that happened to be in the viewport. Inside one bucket
    // the order was the input order — the order the tiles were read in.
    //
    // The tier buckets are not needed for the filter any more and have not been since
    // the cull went per-way (`meta.lod <= vp.lod` in `cull.wgsl`); `line_lod_end` is a
    // reported fact, rebuilt below from the sorted stream so it stays monotone and
    // still counts "vertices at or under tier t".
    //
    // Each entry carries its origin-local bbox so the quad lane gets per-way meta
    // for the GPU cull — the SAME bbox `way_meta` uses, so both lanes cull alike.
    let mut line_polys: Vec<LinePolyEntry> = Vec::new();

    for (way, lod) in ways {
        if way.lonlat.len() < 2 {
            continue;
        }
        let col = color32_to_f32(way.style.color);
        let vert_start = vertices.len() as u32;

        // Reproject each source (lon,lat) to Mercator in f64, rebased to `origin`.
        // A per-way f64 Mercator vec avoids projecting shared endpoints twice.
        let merc: Vec<[f32; 2]> = way
            .lonlat
            .iter()
            .map(|&(lon, lat)| {
                let (mx, my) = crate::mercator(lon, lat);
                rebase_vertex_origin(mx, my, origin)
            })
            .collect();

        // The hairline stream — baked only when the lane is on (stinky `mapdraw-4`).
        // Expanding it is roughly half of this function's work and nothing draws it.
        if HAIRLINE_LANE {
            for (a, b) in merc.iter().zip(merc.iter().skip(1)) {
                vertices.push(Vertex { pos: *a, col });
                vertices.push(Vertex { pos: *b, col });
            }
        }

        let vert_count = vertices.len() as u32 - vert_start;
        // Rebase the bbox to the same origin-local space as the vertices + the cull
        // viewport (else the frustum cull drops visible ways). Computed ONCE and used
        // by both lanes' meta, so the hairline and quad culls can never disagree
        // about where a way is.
        let bbox_min = [(way.bbox[0] - origin[0]) as f32, (way.bbox[1] - origin[1]) as f32];
        let bbox_max = [(way.bbox[2] - origin[0]) as f32, (way.bbox[3] - origin[1]) as f32];
        if HAIRLINE_LANE {
            way_meta.push(GpuWayMeta { bbox_min, bbox_max, vert_start, vert_count, lod, flags: 0 });
        }

        // Pretty-line source: the SAME origin-local centreline, with the themed
        // width converted to screen px. `merc` is moved in (its last use).
        line_polys.push((
            crate::paint_order::way_paint_key(way),
            lod,
            LinePolyline {
                pts: merc,
                // RAW themed width — the deck.gl width resolution (scale + pixel clamp)
                // runs per frame in the vertex shader, not at bake time.
                width_px: way.style.width * LINE_PX_PER_WIDTH,
                col,
                depth: crate::paint_order::depth_of(crate::paint_order::way_rank(way), false),
                // The dash gate, from the style (HO-T3): a rail's core is cut along its
                // arc length, a road's is not. Rides the vertex — see `pack_dash_half_px`.
                dash: way.style.dashed(),
            },
            bbox_min,
            bbox_max,
        ));
    }

    // Expand the quads lod-ascending, recording a per-way span as we go so the GPU
    // cull can frustum+LOD filter the quad stream. Expanding one polyline at a time
    // is a pure regrouping of the old whole-tier call — `LineExpander::push` treats
    // polylines independently and simply concatenates, which
    // `per_way_quad_expansion_concatenates_to_the_whole_tier_expansion` pins.
    //
    // **Straight into the shared buffer** (stinky `mapdraw-5`). The regrouping first
    // shipped as `extend_from_slice(&build_line_geometry(from_ref(poly)))`, i.e. one
    // heap allocation and one full memcpy of every way's quads — MEASURED at 9.01
    // allocations a way (108 073 for 12 000 ways, 352 MB of transient heap). The
    // expander appends onto `line_vertices` itself and carries its own join scratch,
    // so a way costs zero allocations here whatever its length.
    let mut line_vertices: Vec<LineVertex> = Vec::new();
    let mut line_meta: Vec<GpuWayMeta> = Vec::new();
    // **THE declared order** — the same `PaintKey` the CPU painter sorts its
    // `DrawFeature`s by, so a road that is above a road on one lane is above it on the
    // other. `sort_unstable_by_key` is safe here precisely because the key ends in the
    // osm id: there are no ties left for stability to decide.
    line_polys.sort_unstable_by_key(|(k, ..)| *k);
    let mut per_tier = [0u32; LOD_COUNT];
    let mut expander = crate::gpu::line::LineExpander::new();
    for (key, lod, poly, bbox_min, bbox_max) in &line_polys {
        let vert_start = line_vertices.len() as u32;
        // **The way's rank rides into the vertex as a depth** (lane MP-a). `PaintKey`
        // orders the stream inside ONE submission and cannot reach across two; the tile
        // lane submits per (band, tile) and `way_draw_band` drops the tier, so two roads
        // of one band in two tiles are ordered by tile. Depth is what crosses that.
        // The depth rides on the polyline (see `LinePolyline::depth`), so the per-way
        // bake here and the whole-tier `build_line_geometry` produce the same bytes.
        let _ = key;
        expander.push(&mut line_vertices, poly);
        let vert_count = line_vertices.len() as u32 - vert_start;
        let tier = (*lod as usize).min(LOD_COUNT - 1);
        per_tier[tier] += vert_count;
        line_meta.push(GpuWayMeta {
            bbox_min: *bbox_min,
            bbox_max: *bbox_max,
            vert_start,
            vert_count,
            lod: tier as u32,
            flags: 0,
        });
    }
    // Cumulative "vertices belonging to a way at or under tier t". Monotone by
    // construction and `last() == line_vertices.len()`, which is what `decode_baked`
    // validates — it is no longer a contiguous span, and nothing draws off it.
    let mut line_lod_end = [0u32; LOD_COUNT];
    let mut run = 0u32;
    for (t, c) in per_tier.iter().enumerate() {
        run += c;
        line_lod_end[t] = run;
    }

    GpuGeometry { vertices, way_meta, line_vertices, line_meta, line_lod_end, fills: GpuFillGeometry::default() }
}

/// **Bake the filled ways for the GPU fill lane** — the fill triangles and the ring
/// outlines, in the SAME origin-local Mercator space `build_gpu_geometry` bakes the
/// lines into, so both lanes cull against one viewport box and project through one
/// uniform block.
///
/// `tris` is the way's **cached** ear clip
/// ([`crate::tessellate::FillTessCache::tris`]), indexing into
/// [`crate::tessellate::fill_vertices`]`(outer, holes)` — outer ring first, then each
/// hole ring with ≥ 3 vertices, concatenated. That is exactly the vertex order built
/// below, so the CPU mesh and the GPU mesh are the same triangles off the same
/// indices; a way whose cache slot is empty is skipped rather than re-clipped here
/// (the ear clip must stay off the frame path — it was 85.4 % of it).
///
/// **Why Mercator and not screen space.** The triangulation is affine-invariant, so
/// the indices computed on the Mercator ring are valid at any camera — which is the
/// whole reason the cache works, and it is what lets the triangles be baked ONCE
/// into VRAM instead of re-derived per frame. See the [`crate::tessellate`] note.
///
/// A way is dropped from the lane if it has no cached triangulation or fewer than 3
/// vertices — it then keeps painting on the CPU, because [`MapFacet`] asks the same
/// predicate (`crate::layer::MapFacet::gpu_lane_fills`) rather than a second one.
///
/// [`MapFacet`]: crate::layer::MapFacet
pub fn build_gpu_fill_geometry<'a>(
    fills: impl Iterator<Item = (&'a Way, u32, crate::tessellate::FillTris)>,
    origin: [f64; 2],
) -> GpuFillGeometry {
    let mut g = GpuFillGeometry::default();
    // ONE quad expander and ONE closed-ring scratch for the whole bake — both reused
    // across every way and every ring, so a ring costs zero allocations of its own
    // (the rule `mapdraw-5` established for the road lane, applied to the outline
    // stream it did not reach).
    let mut expander = line::LineExpander::new();
    let mut ring_pts: Vec<[f32; 2]> = Vec::new();
    // **THE declared order**, the same key the line lane and the CPU painter sort on.
    // Without it the fill stream was the input order — a lake decoded after a park
    // painted over it, and which of two overlapping buildings won changed with the
    // tile merge. Collected once (an `O(log n)` growth, not the per-way allocation
    // `mapdraw-5` removed) because the caller hands an iterator.
    let mut fills: Vec<(&'a Way, u32, crate::tessellate::FillTris)> = fills.collect();
    fills.sort_unstable_by_key(|(w, _, _)| crate::paint_order::way_paint_key(w));
    for (way, lod, tris) in fills {
        if way.lonlat.len() < 3 || tris.is_empty() {
            continue;
        }
        let fill_col = color32_to_f32(way.fill.unwrap_or(way.style.color));
        let line_col = color32_to_f32(way.style.color);
        // The plane this way's outline rides on — its own fill's band, one bias in front
        // (lane GG #1). Computed per way, beside the colours it belongs with.
        let outline_depth =
            crate::paint_order::fill_outline_depth(crate::paint_order::way_draw_band(way));

        // The concatenated ring vertex list the cached indices address, rebased to
        // `origin`. Built once per way and read by BOTH streams below, so the fill
        // and its outline cannot land on different coordinates.
        let mut ring_spans: Vec<(usize, usize)> = Vec::with_capacity(1 + way.holes.len());
        let mut merc: Vec<[f32; 2]> = Vec::with_capacity(way.lonlat.len());
        let push_ring = |merc: &mut Vec<[f32; 2]>, r: &[(f64, f64)]| {
            let lo = merc.len();
            merc.extend(r.iter().map(|&(lon, lat)| {
                let (mx, my) = crate::mercator(lon, lat);
                rebase_vertex_origin(mx, my, origin)
            }));
            (lo, merc.len())
        };
        ring_spans.push(push_ring(&mut merc, &way.lonlat));
        for h in &way.holes {
            // `fill_vertices` skips hole rings with < 3 vertices; skip the same ones
            // here or every cached index past the first hole is off by that ring.
            if h.len() >= 3 {
                ring_spans.push(push_ring(&mut merc, h));
            }
        }

        let bbox_min = [(way.bbox[0] - origin[0]) as f32, (way.bbox[1] - origin[1]) as f32];
        let bbox_max = [(way.bbox[2] - origin[0]) as f32, (way.bbox[3] - origin[1]) as f32];

        // ── Fill triangles ────────────────────────────────────────────────────
        let tri_start = g.tri_vertices.len() as u32;
        let n = merc.len() as u32;
        for &[a, b, c] in tris.iter() {
            // The same bounds test `mesh_from_tris` applies: a cache built for a
            // different ring is a bug, but it must not take a frame down.
            if a < n && b < n && c < n {
                for i in [a, b, c] {
                    g.tri_vertices.push(Vertex { pos: merc[i as usize], col: fill_col });
                }
            }
        }
        let tri_count = g.tri_vertices.len() as u32 - tri_start;
        if tri_count > 0 {
            g.tri_meta.push(GpuWayMeta {
                bbox_min,
                bbox_max,
                vert_start: tri_start,
                vert_count: tri_count,
                lod,
                // AREA geometry: the minimum-extent gate applies (stinky `kalm-3`).
                flags: facett_core::render::gpu::GPU_WAY_FLAG_FILL,
            });
        } else {
            g.tri_vertices.truncate(tri_start as usize);
        }

        // ── Ring outlines, CLOSED (last → first), as ANTI-ALIASED QUADS ───────
        //
        // stinky `mapgpu-1`. These were a raw `LineList` — two `Vertex` per segment,
        // drawn by `draw_pipeline` as a 1 px aliased hairline with no coverage ramp,
        // while the CPU painter feathered the same ring over ~2 px. They now go
        // through the SAME `LineExpander` the road lane uses, so the ring gets miter
        // joins, the inner-miter clamp and the SDF edge for free, and there is one
        // quad expander in this crate instead of a quad expander and a hairline
        // emitter (LAW #5).
        //
        // The width is resolved by `style::fill_outline_width_px` — the ONE writer the
        // CPU painter also reads. It used to be `f.width.max(0.5)` there and an
        // implicit `1.0` here, which is the second half of the same defect.
        let out_start = g.outline_vertices.len() as u32;
        let ring_width = crate::style::fill_outline_width_px(way.style.width);
        for &(lo, hi) in &ring_spans {
            if hi - lo < 2 {
                continue;
            }
            // Closed: the expander joins consecutive points, so the ring is its own
            // vertices plus a repeat of the first. `pts` is reused across rings so a
            // way with holes still costs one allocation for the longest ring, not one
            // per ring (`mapdraw-5`'s rule, applied to the stream it did not reach).
            ring_pts.clear();
            ring_pts.extend_from_slice(&merc[lo..hi]);
            ring_pts.push(merc[lo]);
            // **At its own fill's plane, one bias in front** — lane GG #1. Not `0.0`,
            // which is the closest plane there is and made every ring outline beat every
            // road drawn after it; and not the rank, which carries a tier the fill's own
            // pinned band depth does not. See `paint_order::fill_outline_depth`.
            expander.push_pts_at_depth(
                &mut g.outline_vertices,
                &ring_pts,
                ring_width,
                line_col,
                outline_depth,
            );
        }
        let out_count = g.outline_vertices.len() as u32 - out_start;
        if out_count > 0 {
            g.outline_meta.push(GpuWayMeta {
                bbox_min,
                bbox_max,
                vert_start: out_start,
                vert_count: out_count,
                lod,
                // The fill's own ring outline — it must vanish with its fill, not
                // survive as a wireframe of a building nobody can see.
                flags: facett_core::render::gpu::GPU_WAY_FLAG_FILL,
            });
        }
    }
    g
}

/// Install a [`GpuMapRenderer`] into an egui-wgpu `RenderState`'s callback
/// resources. Hosts call this **once** (e.g. in `eframe`'s creation closure with
/// `cc.wgpu_render_state`) so the paint callback can find the renderer. Mirrors
/// osm-viewer's `setup_gpu`.
///
/// CONS-CORE Phase C: the install-once lifecycle is delegated to the shared
/// `facett_core::render::gpu::install_renderer` (the same get-or-insert pattern all
/// skin kernels hand-rolled), so there is one source of truth. `GpuMapRenderer`
/// itself (the cull + `draw_indirect` pipeline) is unchanged.
pub fn install_renderer(render_state: &egui_wgpu::RenderState) {
    facett_core::render::gpu::install_renderer::<GpuMapRenderer, _>(render_state, |device, fmt| {
        GpuMapRenderer::new(device, fmt)
    });
    // This lane COUNTS its frames (see `MapPaintCallback::prepare`), so a zero means
    // "installed and never painted" rather than "did not say" — which is what lets
    // `facett_core::render::lane::lane_use()` report `installed_unused` for this lane
    // instead of degrading to `unknown`.
    facett_core::render::lane::note_reports_frames(lane_name());
    // ── The reason a freshly installed lane has not painted: NOBODY HAS DRAWN THE PANE.
    //
    // MEASURED on 2026-08-02, and it is the state a user is most likely to be looking at.
    // korp installs this lane at startup and opens on the 🧊 Tables tab behind a modal
    // infra chooser, so the map pane has not been painted when the About card is first
    // read. The card said "installed but UNUSED … drew 0 frames (the CPU painter is doing
    // the work)" on an RTX 4090 — every word true, and it reads like a fault. It cost
    // The owner and a coordinating agent real time chasing a bug that was not there.
    //
    // `MapFacet::ui` overwrites this the first time it runs (with `None` when it takes
    // the lane), so the row only says this while it is actually TRUE.
    note_idle_reason(Some(facett_core::render::lane::PANE_NOT_PAINTED_YET));
    RENDERER_INSTALLED.store(true, std::sync::atomic::Ordering::Relaxed);
}

/// Has a host installed the [`GpuMapRenderer`] in this process?
///
/// A pane that opts into the GPU line lane **stops drawing those lines on the
/// CPU** — the GPU owns the layer for that frame. If no renderer was ever
/// installed the paint callback silently does nothing, so an opted-in pane on a
/// host that forgot [`install_renderer`] renders a map with **no line ways at
/// all**. (The headless `egui_kittest` harness is exactly that host: it has a
/// `RenderState`, so "is there a RenderState" is not the question to ask.)
///
/// The callback resources are only reachable from inside the callback, so the
/// install is latched here instead and a pane consults this before handing the
/// layer over. Fail-safe by construction: unknown ⇒ draw on the CPU.
pub fn renderer_installed() -> bool {
    RENDERER_INSTALLED.load(std::sync::atomic::Ordering::Relaxed)
}

/// **THE name this lane is registered under** in `facett_core::render::lane` — one
/// writer, so the frame counter, the upload ACK, the idle reason and the About row
/// cannot end up keyed differently (LAW #5). Derived from the type, so it cannot drift
/// from the renderer it names.
#[must_use]
pub fn lane_name() -> &'static str {
    std::any::type_name::<GpuMapRenderer>()
}

/// **Say why the map lane is not painting** — forwarded to
/// [`facett_core::render::lane::note_idle_reason`] under [`lane_name`].
///
/// `facett_map::layer::MapFacet` calls this every frame with its `CpuOnly` token, so the
/// About box can print *"installed but UNUSED … because GpuMapRenderer:
/// rotated_camera"* instead of a sentence that a correct choice and a broken lane share.
pub fn note_idle_reason(reason: Option<&str>) {
    facett_core::render::lane::note_idle_reason(lane_name(), reason);
}

/// **How many frames this lane has encoded** (`facett_core::render::lane`) — the number
/// an About box shows, read here by name once so no caller has to spell the
/// `type_name::<GpuMapRenderer>()` key.
///
/// A host uses it as the *upload ACK*: a frame counted means `MapPaintCallback::prepare`
/// really ran against a device, so any geometry payload that rode on that callback is in
/// VRAM. Handing a payload to `painter.add` proves nothing — egui can discard the frame.
#[must_use]
pub fn lane_frames() -> u64 {
    facett_core::render::lane::frames_of(lane_name())
}

/// **This lane's GPU clocks** (perf/v2.3) — the tail of a host's mousewheel→GPU
/// TRACE line: `{last_upload_us, last_upload_bytes, uploads_total}` from the
/// re-bake/upload path (which only fires on geometry change), and
/// `{last_gpu_pass_us, gpu_timestamps}` from the TIMESTAMP_QUERY pass clock around
/// the cull compute pass.
///
/// Honesty notes a caller must not paper over:
/// * `last_gpu_pass_us == None` with `gpu_timestamps == Some(false)` means the
///   device cannot measure — print "n/a", never `0`;
/// * the pass time covers the CULL pass only: the fill/line draws execute inside
///   egui's shared render pass, which a paint callback cannot bracket;
/// * a sample resolves a few frames after the pass ran (non-blocking ring).
#[must_use]
pub fn lane_clock_stats() -> facett_core::render::lane::LaneClockStats {
    facett_core::render::lane::clock_stats_of(lane_name())
}

static RENDERER_INSTALLED: std::sync::atomic::AtomicBool =
    std::sync::atomic::AtomicBool::new(false);

/// **Test hook** — force the installed latch, so a headless test can drive the
/// GPU-lane *plan* path (`MapFacet::way_lane_plan`'s scan + memo) without a device.
/// Process-global: only a test binary that owns its whole process state may use it.
#[doc(hidden)]
pub fn set_renderer_installed_for_test(on: bool) {
    RENDERER_INSTALLED.store(on, std::sync::atomic::Ordering::Relaxed);
}

// ═════════════════════════════════════════════════════════════════════════════
// THE OFFSCREEN COLOUR+DEPTH TARGET — lane MP-a, 2026-09-05.
//
// egui's render pass carries no depth attachment, and giving it one means setting
// `WgpuConfiguration::depth_stencil_format` for the whole app — which would put 22
// pipelines across 9 crates on a matching state, one wrong and that pane stops
// drawing. The owner's call, 06:08: take the offscreen pass, confined to this crate.
//
// The map draws into its own colour+depth textures in `prepare` (which owns an
// encoder), and `paint` blits the result into egui's pass. MSAA is
// `NO_MSAA_SAMPLES == 1`, so there is no resolve step.
//
// The vertices are already in WHOLE-SURFACE coordinates — `DrawUniforms::viewport_*`
// is the ScreenDescriptor's size and the shader's `px_to_ndc` divides by exactly that
// — so the offscreen pass wants the whole surface and NO viewport games. That is the
// half of this that is simpler offscreen than in egui's pass, where the callback rect
// squeezes the window's coordinate space into the pane (see `paint`'s note).
// ═════════════════════════════════════════════════════════════════════════════

/// **The last blit's wall time, in microseconds.**
///
/// A process-global rather than a renderer field because the renderer lives inside
/// egui's `CallbackResources` and a test cannot reach it — the same reason
/// `facett_core::render::lane` counts frames globally. It is a measurement hook, so a
/// last-value-wins store is the honest shape: there is no aggregate here pretending to
/// be one.
static BLIT_US: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);

fn note_blit_us(us: u64) {
    BLIT_US.store(us, std::sync::atomic::Ordering::Relaxed);
}

/// The last blit's wall time in microseconds — see [`BLIT_US`].
#[must_use]
pub fn last_blit_us() -> u64 {
    BLIT_US.load(std::sync::atomic::Ordering::Relaxed)
}

/// How many [`OffscreenTarget`]s have EVER been allocated in this process, and how many
/// are live right now. Process-wide like [`BLIT_US`], for the same reason: a test drives
/// the renderer through egui's `CallbackResources` and has no handle on it. A still
/// frame must not move the first number (`tests/gpu_lane_two_panes.rs`); a pane that
/// stops being drawn must lower the second.
static OFFSCREEN_ALLOCS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
static OFFSCREEN_LIVE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);

/// Offscreen targets allocated so far, process-wide — see [`OFFSCREEN_ALLOCS`].
#[must_use]
pub fn offscreen_target_allocs() -> u64 {
    OFFSCREEN_ALLOCS.load(std::sync::atomic::Ordering::Relaxed)
}

/// Offscreen targets live right now, process-wide — see [`OFFSCREEN_LIVE`].
#[must_use]
pub fn offscreen_targets_live() -> u64 {
    OFFSCREEN_LIVE.load(std::sync::atomic::Ordering::Relaxed)
}

// ── GG audit row 21: the card's bill and the frame's draw count, as data ─────────

/// **Every byte this renderer has asked the device for, cumulative** — bumped by
/// [`alloc_buffer`] / [`alloc_buffer_init`], the ONE door every `create_buffer` in this
/// module and in [`line`] goes through. The delta across an upload is what that upload
/// cost the card, and `tests/vram_ratio.rs` holds it to ≤ 1.2× the geometry handed over.
/// Cumulative and never decremented: it counts requests, not residency — the tile
/// cache's residency is [`GpuMapRenderer::tile_cache_bytes`], which the same test pins
/// to this counter's delta so the two cannot disagree about what a tile costs.
static GPU_BUFFER_BYTES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);

/// The one `create_buffer` — see [`GPU_BUFFER_BYTES`].
pub(crate) fn alloc_buffer(device: &wgpu::Device, desc: &wgpu::BufferDescriptor<'_>) -> wgpu::Buffer {
    GPU_BUFFER_BYTES.fetch_add(desc.size, std::sync::atomic::Ordering::Relaxed);
    device.create_buffer(desc)
}

/// The one `create_buffer_init` — see [`GPU_BUFFER_BYTES`]. Counts the buffer's real
/// size (`create_buffer_init` pads a payload up to `COPY_BUFFER_ALIGNMENT`).
pub(crate) fn alloc_buffer_init(device: &wgpu::Device, desc: &wgpu::util::BufferInitDescriptor<'_>) -> wgpu::Buffer {
    let buf = device.create_buffer_init(desc);
    GPU_BUFFER_BYTES.fetch_add(buf.size(), std::sync::atomic::Ordering::Relaxed);
    buf
}

/// Bytes of GPU buffers this renderer has allocated so far, process-wide — see
/// [`GPU_BUFFER_BYTES`].
#[must_use]
pub fn gpu_buffer_bytes_allocated() -> u64 {
    GPU_BUFFER_BYTES.load(std::sync::atomic::Ordering::Relaxed)
}

/// **The draw census** — three cumulative counters written by
/// `MapPaintCallback::record_draws`, the one function that records the map's draws:
/// how many indirect draw commands it issued, how many `(group, stream, chunk)` units it
/// walked, and how many band runs it walked. Per frame, `tests/draw_count.rs` holds
/// draws ≤ units (one draw per chunk per stream kind) and FILES draws vs band runs — the
/// row's ideal, which needs the tile slab arena before it can be asserted.
static DRAW_CALLS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
static DRAW_UNITS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
static DRAW_BAND_RUNS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);

/// Indirect draw commands the map has recorded so far, process-wide — see [`DRAW_CALLS`].
#[must_use]
pub fn draw_calls_total() -> u64 {
    DRAW_CALLS.load(std::sync::atomic::Ordering::Relaxed)
}

/// `(group, stream, chunk)` units the draw walk has visited so far — see [`DRAW_UNITS`].
#[must_use]
pub fn draw_units_total() -> u64 {
    DRAW_UNITS.load(std::sync::atomic::Ordering::Relaxed)
}

/// Band runs the draw walk has visited so far — see [`DRAW_BAND_RUNS`].
#[must_use]
pub fn draw_band_runs_total() -> u64 {
    DRAW_BAND_RUNS.load(std::sync::atomic::Ordering::Relaxed)
}

/// The depth format the map's offscreen pass uses. `Depth32Float` because the rank
/// ladder needs the range: [`crate::paint_order::CASING_DEPTH_BIAS`] is `1e-6`, and a
/// 24-bit normalised depth resolves `6e-8` — enough, but with no headroom if the ladder
/// ever grows a tier. Float32 costs nothing here (one full-surface texture) and removes
/// the question.
pub const MAP_DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;

/// **The sample count the map's OFFSCREEN pair is allocated at — the one deliberate
/// exception to [`facett_core::render::gpu::msaa_state`]'s no-MSAA ruling** (lane GG #2,
/// audit #32).
///
/// The ruling is right about everything it was written about: TAA is the 3D scheme, the
/// analytic feather in `line.wgsl` is the 2D one, and MSAA does not compose with a
/// deferred G-buffer. None of that is what this buys. This buys **per-sample DEPTH**, and
/// nothing else in the crate can supply it.
///
/// # Why the offscreen pair needs it and no other target does
///
/// Lane MP-a turned depth testing on so the declared rank could survive being split
/// across tiles and chunks. With one depth value per PIXEL, an edge fragment of a
/// higher-ranked way has to choose: composite (and let the way beneath draw over it) or
/// own the pixel. It owns it. The way beneath is depth-REJECTED, and a rejected fragment
/// is not blended away — it never runs — so what is left is the winner over bare paper.
///
/// MEASURED on oden / RTX 4090, 2026-09-05, before this constant existed
/// (`tests/gpu_lane_paint_order.rs::a_partially_covered_road_edge_composites_with_the_road_beneath_it`):
/// a residential spanning the pane under a diagonal motorway in another tile, pane row
/// 200. Across the motorway's whole 7 px footprint (x 256..=262) the frame with both roads
/// was **bit-identical** to the frame with the motorway alone — the residential
/// contributed nothing anywhere, not merely at the fringe. At x = 256 the casing covers
/// about ⅜ of the pixel and the frame read `[28, 28, 34]`, casing over PAPER
/// `[31, 31, 38]`, with the residential simply gone. After: `[36, 40, 48]`, the casing
/// over a residential that reads `[52, 58, 70]` on its own.
///
/// # What this is NOT, and it was measured
///
/// **4× on its own changes nothing here.** With the target and every pipeline at 4× and
/// the shader untouched, the crossing frame came back byte-for-byte identical to the
/// single-sample one. `line.wgsl` rasterises its quad one `feather_px` WIDER than the
/// road, so at exactly the pixels this defect lives on the rasteriser sees FULL coverage
/// and hands the fragment all four samples; the partial coverage is in the fragment's
/// alpha, where the rasteriser cannot see it. This constant is the substrate that lets
/// `line.wgsl` hand it back through `@builtin(sample_mask)` — the mask is the cure, and
/// audit #32's "4× MSAA on the offscreen target" is half of the answer, not the answer.
///
/// # Why 4 and not 2 or 8
///
/// A sample mask can only express coverage in `1/samples`, and the sole thing that
/// quantisation costs is how much of a pixel's DEPTH a way claims — `line.wgsl`'s
/// `coverage_alpha` cancels it out of the colour exactly, so there is no anti-aliasing
/// error to trade. 2× would let an edge over-claim half a pixel, which is the same order
/// as the defect being fixed. 8× doubles the bandwidth of a full-surface
/// `Depth32Float` + colour pair to take the over-claim from 25 % of a pixel to 12 %,
/// invisible at the widths a road is drawn at. 4× is also the count every desktop GPU
/// implements in fast hardware.
///
/// The cost is memory, and it is not nothing: at 1600×900 the offscreen set goes from one
/// colour + one depth (~11 MB) to a 4× pair plus the single-sample resolve (~52 MB) —
/// **per pane** since GG #37 (main map + inset = ~104 MB at that size), and given back
/// the frame after a pane stops being drawn.
///
/// # What it does for a FILL, which is not nothing either (audit #32)
///
/// Audit #32 says *"fill triangles have NO AA; the 0.5 px outline IS the AA"*. That was
/// true of the single-sample target and stopped being true here: `fill_pipeline` draws
/// into this pair through `map_msaa_state()`, and a fill triangle's edge IS its geometric
/// edge — nothing is rasterised wider than the shape, so the rasteriser sees the partial
/// coverage directly and no `sample_mask` is needed (the road case above is the opposite,
/// which is why 4× alone did nothing THERE). MEASURED on t14s / Radeon 890M, 2026-09-06,
/// with the ring outline fully transparent: a diagonal shoreline's 415 shore pixels sit
/// at exactly ¼, ½ and ¾ water (103 / 207 / 105) — three levels where a hard edge has
/// none. `tests/fill_edge_aa.rs` pins that floor; a per-vertex edge distance would lift
/// it to 256 levels at the price of a boundary-aware vertex format, and the frame did not
/// ask for it.
pub const MAP_MSAA_SAMPLES: u32 = 4;

/// **The multisample state every pipeline that draws into [`OffscreenTarget`] shares** —
/// see [`MAP_MSAA_SAMPLES`] for why this lane is the exception to the no-MSAA ruling.
///
/// Built FROM `facett_core`'s descriptor rather than as a fresh literal, so the mask and
/// the alpha-to-coverage flag still have exactly one writer and this function states only
/// the single field the map's offscreen pair deviates in. A pipeline left on
/// `msaa_state()` while the target is 4× is a wgpu **validation failure**, not a soft
/// downgrade — which is precisely why it is one call and not a number typed at four
/// pipelines.
#[must_use]
pub fn map_msaa_state() -> wgpu::MultisampleState {
    let mut ms = facett_core::render::gpu::msaa_state();
    ms.count = MAP_MSAA_SAMPLES;
    // **`alpha_to_coverage` stays OFF, and that is a measurement, not a default.**
    //
    // The sample count alone fixes nothing for a road, which is the counter-intuitive
    // part: `line.wgsl` rasterises its quad one `feather_px` WIDER than the road, so at a
    // pixel straddling the road's true edge the quad covers the pixel COMPLETELY and the
    // rasteriser hands the fragment all four samples. The partial coverage lives in the
    // fragment's alpha, where the rasteriser cannot see it — so it has to be handed back
    // to the sample mask by the shader.
    //
    // Alpha-to-coverage is the one-line way to do that, and it was tried first and is
    // WRONG here, twice over. MEASURED on oden / RTX 4090, 2026-09-05, with A2C on and
    // the pipelines otherwise unchanged:
    //
    // * It multiplies coverage in TWICE. The masked samples still alpha-blend by the same
    //   alpha, so a road edge composites at `a²`. The residential's own mid-row colour
    //   went `[46, 51, 62]` → `[29, 30, 37]` against paper `[31, 31, 38]` — the road very
    //   nearly vanished.
    // * It cannot tell the two edges of a road apart. A road's fill meets its own CASING
    //   with the same alpha ramp, and there the analytic feather was already exactly
    //   right and needs no per-sample anything. A2C punches the fill's ramp into a
    //   4-level dither over the casing, so the pixel resolves mostly casing.
    //
    // What `line.wgsl` does instead is write `@builtin(sample_mask)` itself, from the
    // GEOMETRIC coverage only, and leave the material alpha to the blend — the two were
    // one number in `fs_main` and had to be separated. A2C would fight that mask, and it
    // would be equally wrong on the fill and draw pipelines, whose alpha is a material
    // property and not a coverage at all.
    ms
}

/// The map's own colour+depth render target, sized to the whole surface.
struct OffscreenTarget {
    /// The 4× colour attachment the map actually draws into.
    colour: wgpu::TextureView,
    /// The single-sample texture [`Self::colour`] RESOLVES into, and the only one the
    /// blit ever samples. Kept as a separate texture rather than resolving in a shader:
    /// the resolve is a render-pass store operation the hardware does for free at the end
    /// of the pass it already had to write out.
    resolved: wgpu::TextureView,
    /// The 4× depth attachment — the point of the whole exercise. Depth is per SAMPLE
    /// here, so an edge fragment stops owning the pixels it only partly covers.
    depth: wgpu::TextureView,
    /// What it was allocated for, so a resize is a comparison rather than a guess.
    size: (u32, u32),
}

impl OffscreenTarget {
    /// Allocate for `size`: a 4× colour+depth pair to draw into ([`MAP_MSAA_SAMPLES`])
    /// and the single-sample texture the colour resolves into for the blit.
    fn new(device: &wgpu::Device, format: wgpu::TextureFormat, size: (u32, u32)) -> Self {
        // The three descriptors are struct-updates off ONE base rather than three calls to
        // a `make(label, format, samples, usage)` helper, and that is for the scanner's
        // sake as much as the reader's: `facett-core/tests/one_msaa_writer.rs` requires
        // every `sample_count:` in the workspace to name a CONSTANT, so that grepping the
        // tree still answers "what is multisampled here" truthfully. A helper taking the
        // count as a parameter hides the answer behind a local and puts this file's one
        // sanctioned exception out of the scanner's reach — which is exactly the shape the
        // scanner exists to refuse.
        let colour = wgpu::TextureDescriptor {
            label: Some("facett_map_offscreen_colour"),
            size: wgpu::Extent3d {
                width: size.0.max(1),
                height: size.1.max(1),
                depth_or_array_layers: 1,
            },
            mip_level_count: 1,
            sample_count: MAP_MSAA_SAMPLES,
            dimension: wgpu::TextureDimension::D2,
            format,
            // No `TEXTURE_BINDING`: a 4× texture cannot be read by the blit's filtering
            // sampler, and it is not meant to be — `resolved` is.
            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
            view_formats: &[],
        };
        let depth = wgpu::TextureDescriptor {
            label: Some("facett_map_offscreen_depth"),
            format: MAP_DEPTH_FORMAT,
            ..colour.clone()
        };
        let resolved = wgpu::TextureDescriptor {
            label: Some("facett_map_offscreen_resolved"),
            sample_count: facett_core::render::gpu::NO_MSAA_SAMPLES,
            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
            ..colour.clone()
        };
        let view = |d: &wgpu::TextureDescriptor| {
            device.create_texture(d).create_view(&wgpu::TextureViewDescriptor::default())
        };
        OFFSCREEN_ALLOCS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
        OFFSCREEN_LIVE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
        Self { colour: view(&colour), resolved: view(&resolved), depth: view(&depth), size }
    }
}

impl Drop for OffscreenTarget {
    fn drop(&mut self) {
        OFFSCREEN_LIVE.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
    }
}

/// **One pane's share of the offscreen lane** — lane GG #37.
///
/// Keyed by [`PaneStamp::pane`]. The target is the WHOLE surface whatever the pane's
/// rect, because the vertex stream is in whole-surface coordinates and a wgpu viewport
/// cannot sit outside its attachment (see `record_offscreen`) — so a pane costs a full
/// [`OffscreenTarget`] (~52 MB at 1600×900, [`MAP_MSAA_SAMPLES`]). That is the price of
/// keeping the blit a single textured triangle and the coordinate space untouched; a
/// scissored share of one target was the other option and it cannot hold an inset that
/// OVERLAPS the main map, which is what an inset is.
///
/// The blit UV uniform and its bind group are per pane too: `paint` writes the UV with
/// `queue.write_buffer`, which lands before the submit and not between two callbacks,
/// so one shared uniform handed every pane the LAST pane's rect.
struct PaneTarget {
    key: u64,
    target: OffscreenTarget,
    /// `uv_min`/`uv_max` — which sub-rectangle of the whole-surface texture this pane is.
    blit_uniform: wgpu::Buffer,
    /// Names `target.resolved`, so it is rebuilt with the target.
    blit_bind: wgpu::BindGroup,
    /// The last [`PaneStamp::frame_nr`] this pane was prepared in. A pane unseen for a
    /// whole frame is freed — its VRAM comes back the frame after it stops being drawn.
    last_frame: u64,
}

/// The blit's bind group for one pane: **`resolved`, never `colour`** — the 4× attachment
/// is not sampleable and the blit must read what the resolve produced (lane GG #2).
fn blit_bind_for(
    device: &wgpu::Device,
    layout: &wgpu::BindGroupLayout,
    sampler: &wgpu::Sampler,
    target: &OffscreenTarget,
    uniform: &wgpu::Buffer,
) -> wgpu::BindGroup {
    device.create_bind_group(&wgpu::BindGroupDescriptor {
        label: Some("facett_map_blit_bind"),
        layout,
        entries: &[
            wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&target.resolved) },
            wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(sampler) },
            wgpu::BindGroupEntry { binding: 2, resource: uniform.as_entire_binding() },
        ],
    })
}

impl GpuMapRenderer {
    /// **The pane's slot in [`Self::panes`]**, for the pane and frame `stamp` names.
    ///
    /// Allocates on a NEW key or a size change only — a still frame allocates nothing
    /// (`tests/gpu_lane_two_panes.rs` counts). On the way in, every pane that was not
    /// prepared in this frame or the one before is dropped, which frees its target: a
    /// pane that disappears gives its VRAM back one frame later. `retain` over two or
    /// three entries, on every prepare, moving nothing when nothing goes.
    fn pane_target(&mut self, device: &wgpu::Device, stamp: PaneStamp, size: (u32, u32)) -> usize {
        self.panes.retain(|p| p.last_frame + 1 >= stamp.frame_nr);
        let i = match self.panes.iter().position(|p| p.key == stamp.pane) {
            Some(i) => i,
            None => {
                let blit_uniform = alloc_buffer(device, &wgpu::BufferDescriptor {
                    label: Some("facett_map_blit_uniform"),
                    size: 16,
                    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
                    mapped_at_creation: false,
                });
                let target = OffscreenTarget::new(device, self.target_format, size);
                let blit_bind = blit_bind_for(device, &self.blit_bgl, &self.blit_sampler, &target, &blit_uniform);
                self.panes.push(PaneTarget { key: stamp.pane, target, blit_uniform, blit_bind, last_frame: 0 });
                self.panes.len() - 1
            }
        };
        let p = &mut self.panes[i];
        p.last_frame = stamp.frame_nr;
        if p.target.size != size {
            p.target = OffscreenTarget::new(device, self.target_format, size);
            p.blit_bind = blit_bind_for(device, &self.blit_bgl, &self.blit_sampler, &p.target, &p.blit_uniform);
        }
        i
    }
}

/// **The depth state every map pipeline shares** — `Less` against a buffer cleared to
/// `1.0`, writing. One writer, so a pipeline cannot drift from the clear value or the
/// comparison the depth packing was designed around.
#[must_use]
pub fn map_depth_state() -> wgpu::DepthStencilState {
    wgpu::DepthStencilState {
        format: MAP_DEPTH_FORMAT,
        depth_write_enabled: Some(true),
        // **`LessEqual`, not `Less`** — lane GG #3.
        //
        // The tile lane pins a whole band RUN of fills to ONE depth (`set_viewport`,
        // :2221), which is right: `way_draw_band` for a fill IS its class, so a run has
        // nothing left to separate. Inside the run the stream is still ordered by
        // `PaintKey`, whose second term is area, largest first — a courtyard inside a
        // block, a pond inside a park, an island in a lake are emitted LATER precisely so
        // they land on top.
        //
        // Under `Less` the first fragment of the run writes the depth and every later one
        // is rejected for being EQUAL, so that order is not merely lost, it is exactly
        // INVERTED: the biggest polygon wins every containment it is part of. Measured on
        // oden / RTX 4090: a building inside a building read as the outer one's ink at the
        // centre.
        //
        // `LessEqual` restores it and takes nothing away, because "equal depth" means
        // "same band" and inside a band the submission order IS the declared order — the
        // stream is sorted on the whole `PaintKey` and the compaction preserves it
        // (`tests/cull_order.rs`). Across bands the depths differ and the comparison is
        // unchanged.
        depth_compare: Some(wgpu::CompareFunction::LessEqual),
        stencil: wgpu::StencilState::default(),
        bias: wgpu::DepthBiasState::default(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The f64 reference projection a uniform Mercator camera's `to_px` computes
    /// (`rect.center + (merc − center)·zoom`) — the ground truth the GPU line lane
    /// must track pixel-for-pixel.
    fn ref_to_px(merc: [f64; 2], center: [f64; 2], zoom: f64, rect: egui::Rect) -> [f64; 2] {
        [
            f64::from(rect.center().x) + (merc[0] - center[0]) * zoom,
            f64::from(rect.center().y) + (merc[1] - center[1]) * zoom,
        ]
    }

    /// RED-WHEN-BROKEN (the mandated correctness proof): at OsmView street-level max
    /// zoom, every GPU line vertex — baked origin-local (`mercator_f64(lonlat) −
    /// origin`) and projected by [`MapTransform::project`], the exact CPU twin of
    /// `draw.wgsl`'s `vs_main` — lands within 1px of the f64 reference `to_px`. The
    /// OLD absolute path (`mercator_f32*zoom + offset`, two ~4e7 f32 differenced)
    /// drifts several pixels, which the second assert pins so a regression to
    /// absolute coordinates fails loudly.
    #[test]
    fn gpu_line_vertex_within_1px_of_f64_reference_at_max_zoom() {
        let zoom = 8.0e7_f64; // OsmView ZOOM_MAX
        let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1200.0, 800.0));
        // A street-level cluster near lon 9.52, lat 47.14 (Liechtenstein-ish).
        let mercs: Vec<[f64; 2]> = (0..32)
            .map(|i| {
                let (lon, lat) = (9.52 + f64::from(i) * 3.1e-4, 47.14 + f64::from(i) * 2.3e-4);
                let (x, y) = crate::mercator(lon, lat);
                [x, y]
            })
            .collect();

        // Origin = Mercator bbox-min over the cluster (f64); camera centred on it.
        let origin = mercs.iter().fold([f64::MAX; 2], |a, m| [a[0].min(m[0]), a[1].min(m[1])]);
        let center = mercs[mercs.len() / 2];
        let transform = MapTransform::new(origin, center, zoom as f32, zoom as f32, rect);

        // OLD absolute path's offset: screen = merc_f32*zoom + offset (all f32).
        let off_x = rect.center().x - center[0] as f32 * zoom as f32;
        let off_y = rect.center().y - center[1] as f32 * zoom as f32;

        let (mut worst_rebased, mut worst_naive) = (0.0_f64, 0.0_f64);
        for m in &mercs {
            let want = ref_to_px(*m, center, zoom, rect);

            // NEW origin-local GPU path (vertex bake + shader twin).
            let pos = rebase_vertex_origin(m[0], m[1], origin);
            let got = transform.project(pos);
            worst_rebased = worst_rebased
                .max((f64::from(got[0]) - want[0]).abs())
                .max((f64::from(got[1]) - want[1]).abs());

            // OLD absolute-f32 path (the bug).
            let naive_x = (m[0] as f32) * zoom as f32 + off_x;
            let naive_y = (m[1] as f32) * zoom as f32 + off_y;
            worst_naive = worst_naive
                .max((f64::from(naive_x) - want[0]).abs())
                .max((f64::from(naive_y) - want[1]).abs());
        }
        assert!(worst_rebased < 1.0, "origin-local GPU vertices land <1px of f64 ref at max zoom: {worst_rebased}px");
        assert!(worst_naive > 1.0, "old absolute-f32 path drifts >1px (the bug this fixes): {worst_naive}px");
    }

    /// INJECT-ASSERT: the cull viewport bbox [`MapTransform::viewport_bbox`] is
    /// expressed in the SAME origin-local space as [`build_gpu_geometry`]'s rebased
    /// per-way meta bboxes — a way whose absolute Mercator bbox is inside the pane
    /// stays inside the rebased viewport. Guards against the two sides diverging
    /// (which would silently cull visible ways).
    #[test]
    fn cull_viewport_and_meta_share_origin_space() {
        let zoom = 5.0e5_f64;
        let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1000.0, 700.0));
        let (lon, lat) = (9.52_f64, 47.14_f64);
        let (cx, cy) = crate::mercator(lon, lat);
        let origin = [cx - 1e-4, cy - 1e-4];
        let center = [cx, cy];
        let transform = MapTransform::new(origin, center, zoom as f32, zoom as f32, rect);

        // A tiny way straddling the pane centre → its rebased bbox must overlap the
        // rebased viewport bbox.
        let (mnx, mny) = (cx - 2e-6, cy - 2e-6);
        let (mxx, mxy) = (cx + 2e-6, cy + 2e-6);
        let meta_min = [(mnx - origin[0]) as f32, (mny - origin[1]) as f32];
        let meta_max = [(mxx - origin[0]) as f32, (mxy - origin[1]) as f32];
        let [vx0, vy0, vx1, vy1] = transform.viewport_bbox(rect);
        let overlaps = !(meta_max[0] < vx0 || meta_min[0] > vx1 || meta_max[1] < vy0 || meta_min[1] > vy1);
        assert!(overlaps, "centred way's rebased bbox overlaps the rebased cull viewport");
    }
}