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/layer.rs

//! **The selectable Map trait — ONE trait, ALL maps** (consolidation design §3.1).
//!
//! Every map surface in the constellation draws through **one** source-agnostic
//! seam so *data-to-draw is selectable and stackable*: OSM streets **+** a
//! Trafikverket roads overlay **+** korp collectors' own layers, each a
//! [`MapLayer`] the user toggles at runtime. The renderer ([`MapFacet::ui`]) is
//! **source-agnostic** — it paints [`DrawFeature`]s, never source-specific structs
//! — and the camera ([`MapView`]) carries **`bearing_deg` + `tilt_deg`** so the
//! flat 2D map **rotates and pitches** (the car-navigator feel).
//!
//! This module is **additive**: the legacy point/ways [`crate::MapView`] facet is
//! unchanged. The new trait system reuses this crate's projection ([`crate::mercator`]),
//! styling ([`crate::style`]) and tessellation ([`crate::tessellate`]) — nothing is
//! re-implemented. A source that already produces [`crate::Way`]s (OSM via
//! `facett-osm`, or a Trafikverket road set) plugs in as a [`WaysLayer`] with **no
//! re-parse**; points plug in as a [`PointsLayer`].

use egui::Color32;
use serde::{Deserialize, Serialize};

use crate::style::{Lod, MapPalette, classify_label};
use crate::{Way, mercator, tessellate};

/// A geographic coordinate (WGS-84 degrees).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct LatLon {
    pub lat: f64,
    pub lon: f64,
}

impl LatLon {
    pub fn new(lat: f64, lon: f64) -> Self {
        Self { lat, lon }
    }
}

/// **One frame's projection constants** — see [`MapView::projector`].
///
/// Everything in [`MapView::project`] that does not depend on the vertex, hoisted out
/// of the per-vertex loop (stinky `prof-9`): the world scale (`powf`), the camera
/// centre's own Mercator (`tan`/`cos`/`ln`), the ground frame (`sincos`) and the
/// half-pane offset. What is left per vertex is one `mercator` and eight
/// multiply-adds.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Projector {
    world_px: f64,
    center_merc: (f64, f64),
    frame: facett_core::render::GroundFrame2d,
    half_px: (f32, f32),
}

impl Projector {
    /// `(lon, lat)` → screen pixel. **THE projection body** — [`MapView::project`] is
    /// this call with the setup inlined, and nothing else in the crate spells the
    /// arithmetic out a second time.
    #[inline]
    #[must_use]
    pub fn project(&self, lon: f64, lat: f64) -> (f32, f32) {
        let (px, py) = mercator(lon, lat);
        // Offset from centre in pixels (x east, y south — screen convention).
        let dx = (px - self.center_merc.0) * self.world_px;
        let dy = (py - self.center_merc.1) * self.world_px;
        // The rotate (bearing, clockwise for the MAP so content counter-rotates)
        // AND the cos(tilt) vertical foreshortening, both from the shared frame —
        // `apply` is the one writer, so no rotation arithmetic is spelled out here.
        let (rx, ry) = self.frame.apply(dx, dy);
        (self.half_px.0 + rx as f32, self.half_px.1 + ry as f32)
    }
}

/// The camera. A 2D pan/zoom **plus** `bearing_deg` (rotate the map — a compass
/// heading, clockwise) and `tilt_deg` (pitch the flat map back toward the horizon,
/// 0 = straight-down top-down, up to ~70° for the car-nav look). One shared
/// [`project`](MapView::project) maps `(lon, lat)` → screen pixels through the
/// rotated + tilted view, so **every** layer lines up in the same frame.
///
/// `zoom` is a slippy-map zoom level: the whole Web-Mercator world spans
/// `256 · 2^zoom` pixels (industry convention — a tile is 256 px), so `zoom`
/// composes cleanly with real OSM/PMTiles tiles.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct MapView {
    pub center: LatLon,
    pub zoom: f32,
    /// Compass heading in degrees (clockwise). `0` = north-up; the map rotates
    /// under a fixed screen so the driver's heading points up (car-nav feel).
    pub bearing_deg: f32,
    /// Pitch in degrees. `0` = top-down; larger tilts the flat map back toward
    /// the horizon (vertical foreshortening + a far-fade the renderer shades).
    pub tilt_deg: f32,
    /// Pane size in logical pixels `(w, h)` — the projection centres on this.
    pub size_px: (f32, f32),
}

/// **The shallowest zoom a car-nav camera may hold.** `pub` because korp's 3D pane
/// clamps its own wheel handler to the same range and had a second copy of the number
/// (lane ST, gfx3d-S10).
pub const MIN_ZOOM: f32 = 0.0;
/// **The deepest zoom a car-nav camera may hold.** `pub` for the same reason as
/// [`MIN_ZOOM`]. Note this is the WIDGET's clamp, not the pyramid's cut depth — the
/// scene's own `max_zoom` is the floor that follows the data.
pub const MAX_ZOOM: f32 = 22.0;

/// **Scroll pixels per zoom LEVEL** — the wheel's feel, and the number korp's 3D pane
/// had a different copy of.
///
/// MEASURED 2026-09-04: this pane divides scroll by `120.0`, and korp's 3D wheel handler
/// (`map_views.rs`) divided by `50.0` — under a comment claiming *"a zoom LEVEL per
/// notch is the same feel the flat pane has"*. It was **2.4× faster per notch**, so the
/// two panes of one application responded differently to the same gesture while the
/// code said they did not. `pub` so there is one number and the claim is true by
/// construction rather than by comment.
pub const PX_PER_ZOOM_LEVEL: f32 = 120.0;
const MAX_TILT: f32 = 75.0;

// The deck.gl `widthScale` both of `MapFacet`'s lanes draw with is
// `MapFacet::line_width_scale` — the live `style::line_width_scale(world_px)`. It was a
// `const GPU_LINE_WIDTH_SCALE: f32 = 1.0` here (lane GG #27); see the method.

// ── WHICH lane paints the ways — a VALUE, not a side effect ───────────────────
//
// **BOTH lanes are first-class and both must keep working.** The CPU painter is not
// a degraded mode and it is not dead code once the GPU lane exists: it is what draws
// on a host with no adapter (a refused bring-up, an Xvfb display with no DRI3, wasm
// without WebGPU), it is what draws every FILL and every point and label in both
// modes, and it is what draws the ways under a rotated or pitched camera, where the
// flat [`crate::gpu::MapTransform`] provably cannot reproduce
// [`MapView::project`]. `MapFacet::ui` therefore *decides* between them and says so,
// instead of letting "the GPU attempt quietly did nothing" be the way CPU rendering
// gets selected. That failure mode is the whole reason this module needed fixing: a
// silent no-op lane is indistinguishable from a working one.

/// **Which lane painted the way (line) geometry, and why** — [`MapFacet::way_lane`].
///
/// Published in [`MapFacet::state_json`] so the choice is observable headlessly. Fills,
/// points and labels are ALWAYS the CPU painter's and are not described here.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WayLane {
    /// The GPU line lane (`facett_map::gpu::GpuMapRenderer`) owns the unfilled ways of
    /// `layers` enabled way layers. Their line features are withheld from the CPU
    /// painter, so nothing is drawn twice.
    Gpu {
        /// How many enabled layers the lane took.
        layers: usize,
    },
    /// The CPU painter owns every way, for this reason. A first-class outcome.
    Cpu(CpuOnly),
}

/// Why the CPU painter owns the ways. Never "the GPU failed": each variant is a
/// property of the host or the camera that was checked BEFORE anything was attempted.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CpuOnly {
    /// Built **without** the `wgpu` feature — this binary has no GPU lane to reach.
    /// The default for `facett-map`, and what every host with no `RenderState` links.
    NotCompiled,
    /// No host installed `GpuMapRenderer` in this process: no adapter came up, the
    /// bring-up was refused (`facet-gpu-<n>`), or the host simply never called
    /// `gpu::install_renderer`. Handing the layer over anyway would silently drop
    /// every road, so the CPU keeps it.
    NoRenderer,
    /// The camera is rotated (`bearing_deg`) or pitched (`tilt_deg`) — including idle
    /// auto-rotate while it spins. [`crate::gpu::MapTransform`] is the 2D reduction of
    /// the unified view core at bearing 0 / tilt 0 and carries no rotation term, so the
    /// GPU lane would draw an unrotated basemap under rotated labels. The CPU painter
    /// projects through the rotating [`MapView::project`] and stays correct.
    RotatedCamera,
    /// The stack holds no unfilled ways to give a *line* pipeline — only fills, points
    /// or labels, which are the CPU painter's in both modes.
    NoLineWays,
}

/// Publish WHY the lane has no frames onto the record an About box reads, so the
/// on-screen row and `state_json` cannot disagree. Keyed through `gpu::lane_name()` — the
/// ONE name writer (LAW #5), never a re-spelled string literal.
///
/// **Every zero-frame state must have a name here**, including the one that is nobody's
/// fault: the facet chose the GPU lane and the lane has not encoded yet. MEASURED on
/// 2026-08-02 — with korp's modal infra chooser over the map pane, `MapFacet::ui` runs and
/// picks the GPU, the callback is handed to egui, and the lane still counts 0, because a
/// covered/clipped pane produces no GPU work (correct) and because egui may discard the
/// frame a payload rode on. Reporting `None` there put the row straight back to the bare
/// "installed but UNUSED" that started this whole hunt, so it reports
/// `awaiting_first_encode` instead and only falls silent once the lane is genuinely live.
///
/// Without the `wgpu` feature there is no lane in the binary to annotate, and
/// `GpuLaneUse::NotInstalled` already prints the unambiguous *"not installed — the CPU
/// painter owns every pane"*. The `not_compiled` token still rides `state_json`.
fn note_lane_idle_reason(lane: WayLane) {
    #[cfg(feature = "wgpu")]
    crate::gpu::note_idle_reason(match lane.cpu_reason() {
        Some(cpu) => Some(cpu),
        // Chose the GPU, but nothing has come out of it yet — say so rather than nothing.
        None if crate::gpu::lane_frames() == 0 => {
            Some(facett_core::render::lane::AWAITING_FIRST_ENCODE)
        }
        None => None,
    });
    #[cfg(not(feature = "wgpu"))]
    let _ = lane;
}

impl WayLane {
    /// Stable token for `state_json` (`"gpu"` / `"cpu"`).
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            WayLane::Gpu { .. } => "gpu",
            WayLane::Cpu(_) => "cpu",
        }
    }
    /// Is the GPU line lane painting the ways?
    #[must_use]
    pub fn is_gpu(self) -> bool {
        matches!(self, WayLane::Gpu { .. })
    }
    /// The CPU reason token, or `None` when the GPU lane has them.
    #[must_use]
    pub fn cpu_reason(self) -> Option<&'static str> {
        match self {
            WayLane::Gpu { .. } => None,
            WayLane::Cpu(r) => Some(r.as_str()),
        }
    }
}

impl CpuOnly {
    /// Stable token for `state_json`.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            CpuOnly::NotCompiled => "not_compiled",
            CpuOnly::NoRenderer => "no_renderer",
            CpuOnly::RotatedCamera => "rotated_camera",
            CpuOnly::NoLineWays => "no_line_ways",
        }
    }
}

impl Default for MapView {
    fn default() -> Self {
        Self {
            center: LatLon::new(0.0, 0.0),
            zoom: 3.0,
            bearing_deg: 0.0,
            tilt_deg: 0.0,
            size_px: (800.0, 600.0),
        }
    }
}

impl MapView {
    /// A view centred on `center` at `zoom`, north-up and flat (bearing/tilt 0).
    pub fn new(center: LatLon, zoom: f32, size_px: (f32, f32)) -> Self {
        Self { center, zoom: zoom.clamp(MIN_ZOOM, MAX_ZOOM), bearing_deg: 0.0, tilt_deg: 0.0, size_px }
    }

    /// Set the compass heading (degrees clockwise); wrapped to `[0, 360)`.
    pub fn with_bearing(mut self, deg: f32) -> Self {
        self.bearing_deg = deg.rem_euclid(360.0);
        self
    }

    /// Set the pitch (degrees), clamped to `[0, MAX_TILT]`.
    pub fn with_tilt(mut self, deg: f32) -> Self {
        self.tilt_deg = deg.clamp(0.0, MAX_TILT);
        self
    }

    /// Whole Web-Mercator world width in pixels at this zoom (`256 · 2^zoom`).
    #[inline]
    pub fn world_px(&self) -> f64 {
        256.0 * 2f64.powf(self.zoom.clamp(MIN_ZOOM, MAX_ZOOM) as f64)
    }

    /// **The one shared projection.** `(lon, lat)` → screen pixel, through the
    /// rotated (`bearing_deg`) + tilted (`tilt_deg`) camera centred on the pane.
    /// Pure — every layer and every test projects through this exact function, so
    /// all layers register to the pixel.
    /// **The unified view this 2D camera IS** (GFX_V2 item 6) — a
    /// [`facett_core::render::View`] in orthographic mode with this camera's compass
    /// bearing and tilt. `target` is the Mercator centre; `px_per_unit` is folded
    /// into the caller's `world_px` scaling, so it is left at 1.
    ///
    /// The tilt is worth a word, because it looks like a fake and is not: a
    /// **tilted orthographic** projection foreshortens the vertical axis by exactly
    /// `cos(tilt)` with no perspective convergence. That is what `project` computed
    /// by hand before item 6, and it now falls out of the shared frame instead —
    /// GFX_V2's "orthographic (or long focal length)" constraint, taken literally.
    #[must_use]
    pub fn view(&self) -> facett_core::render::View {
        facett_core::render::View::ortho_2d_compass([0.0, 0.0], [1.0, 1.0], self.bearing_deg, self.tilt_deg)
    }

    /// The shared **ground frame** [`project`](Self::project) applies — the unified
    /// core's rotate + foreshorten, taken at full `f64` precision from this camera's
    /// bearing and tilt in degrees (going through the `f32` angles of
    /// [`view`](Self::view) would cost an ULP).
    #[must_use]
    pub fn ground_frame(&self) -> facett_core::render::GroundFrame2d {
        facett_core::render::View::ground_frame_2d_compass_deg(self.bearing_deg as f64, self.tilt_deg as f64)
    }

    /// **Drag the map by a screen delta** — the camera movement a pointer drag means.
    ///
    /// `(dx_px, dy_px)` is the pointer's screen movement. The *content* follows the
    /// pointer, so the CENTRE moves the opposite way: drag right ⇒ look further west.
    /// Rotation and tilt are undone through the shared
    /// [`GroundFrame2d::unapply`](facett_core::render::GroundFrame2d::unapply), so a
    /// bearing-rotated map pans along the direction the user actually sees.
    ///
    /// Pure and `f64` throughout, so it is headlessly assertable and does not drift at
    /// slippy zoom 22 (one pixel there is ~1e-8 of the world).
    ///
    /// # Why this exists
    ///
    /// It did not, and that was the defect. `MapFacet` — the car-nav pane korp opens on —
    /// allocated its canvas with `Sense::hover()` and read neither drag nor scroll, so the
    /// map could **not be panned at all** and could only be zoomed by a slider in a side
    /// rail. `MapView`'s own comment in korp says "persist the camera the user drove";
    /// there was no way for the user to drive it. Measured on the shipped binary
    /// 2026-08-03: a drag across the canvas moved `centre` by exactly 0.
    pub fn pan_by_px(&mut self, dx_px: f32, dy_px: f32) {
        if dx_px == 0.0 && dy_px == 0.0 {
            return;
        }
        let ws = self.world_px();
        // Screen delta → ground delta (undo bearing + tilt), then → mercator units.
        let (gx, gy) = self.ground_frame().unapply(f64::from(dx_px), f64::from(dy_px));
        let (cx, cy) = crate::mercator(self.center.lon, self.center.lat);
        // Minus: the content follows the pointer, so the camera walks the other way.
        // `y` is clamped inside the Mercator band before inversion, so a drag past the
        // pole slides to the edge instead of wrapping the map inside out.
        let ny = (cy - gy / ws).clamp(0.0, 1.0);
        let (lon, lat) = crate::unmercator(cx - gx / ws, ny);
        self.center = LatLon::new(lat, lon);
    }

    /// **Zoom by a scroll delta**, about the pane centre, clamped to the camera's range.
    ///
    /// `scroll_px` is egui's `smooth_scroll_delta.y` — positive is scroll-up, which is
    /// zoom IN. The divisor turns one wheel notch (≈ 50 px on X11, ≈ 14 px per line
    /// elsewhere) into a fraction of a zoom level, so a notch is a visible but not jarring
    /// step and a trackpad flick is smooth rather than a jump across the LOD ladder.
    ///
    /// It moves the SAME `zoom` field the controls rail's slider writes, which is the
    /// field korp derives its server viewport + LOD from — so a wheel gesture re-queries
    /// korp-server exactly like the slider does, rather than being a second camera nobody
    /// fetches for. (That second-camera shape is real and is what the legacy flat
    /// `MapView` widget's `Msg::ZoomBy` does: it drives `camera.zoom`, a screen scale that
    /// no fetch reads.)
    pub fn zoom_by_scroll(&mut self, scroll_px: f32) {
        if scroll_px == 0.0 {
            return;
        }
        self.zoom = (self.zoom + scroll_px / PX_PER_ZOOM_LEVEL).clamp(MIN_ZOOM, MAX_ZOOM);
    }

    /// **The one shared projection.** `(lon, lat)` → screen pixel, through the
    /// rotated (`bearing_deg`) + tilted (`tilt_deg`) camera centred on the pane.
    /// Pure — every layer and every test projects through this exact function, so
    /// all layers register to the pixel.
    ///
    /// The rotate + foreshorten step is **the unified view core's frame** (GFX_V2
    /// item 6, LAW #5): `View::ground_affine_f64` evaluates the trigonometry once
    /// and this applies it in `f64`, so there is no second hand-written rotation
    /// here and no precision lost at slippy zoom 22. Bit-exact against the affine it
    /// replaced — see `the_unified_frame_is_bit_exactly_the_hand_rolled_rotation`.
    #[inline]
    pub fn project(&self, lon: f64, lat: f64) -> (f32, f32) {
        self.projector().project(lon, lat)
    }

    /// **The per-frame half of [`project`](Self::project), evaluated once.**
    ///
    /// stinky `prof-9`. `project` is called once per VERTEX and four things inside it
    /// do not depend on the vertex at all: `world_px` (a `powf`), the CENTRE's own
    /// Mercator (a `tan`, a `cos` and a `ln` — the same three the vertex pays, doubled
    /// for a value that is constant across the whole frame), the ground frame's
    /// `sincos` off bearing/tilt, and the half-pane offset. `perf record` over 40 real
    /// frames measured libm at **20.3 % of app CPU** on the CPU-painter lane, with
    /// `__tan_fma`, `__ieee754_log_fma`, `__cos_fma`, `__sincos_fma` and `exp2` the
    /// only libm symbols in the profile — and `mercator` is the only caller of the
    /// first three.
    ///
    /// A painter that projects a run of vertices takes a [`Projector`] once and calls
    /// [`Projector::project`] per vertex. `project` itself is that same call with the
    /// setup inlined, so there is ONE projection body and no chance of the two drifting
    /// (LAW #5), which `the_unified_frame_is_bit_exactly_the_hand_rolled_rotation` pins
    /// against a hand-rolled reference written out in full.
    #[must_use]
    pub fn projector(&self) -> Projector {
        let (cx, cy) = mercator(self.center.lon, self.center.lat);
        Projector {
            world_px: self.world_px(),
            center_merc: (cx, cy),
            frame: self.ground_frame(),
            half_px: (self.size_px.0 * 0.5, self.size_px.1 * 0.5),
        }
    }

    /// A conservative Web-Mercator bbox `[x0,y0,x1,y1]` (unit space) of what the
    /// pane can show, ignoring rotation (a generous axis-aligned envelope — the
    /// rotated view always fits inside the circle of this radius). Layers use it to
    /// **cull** features to the viewport (the design's "features intersecting the
    /// viewport at a zoom/LOD").
    pub fn merc_bbox(&self) -> [f64; 4] {
        let ws = self.world_px();
        let (cx, cy) = mercator(self.center.lon, self.center.lat);
        // Half-diagonal in pixels → mercator units (covers any rotation).
        let half = 0.5 * ((self.size_px.0 as f64).hypot(self.size_px.1 as f64)) / ws;
        [cx - half, cy - half, cx + half, cy + half]
    }
}

/// **THE LOD LADDER — one rule, any level list. The map's only threshold writer.**
///
/// Given an ASCENDING list of slippy-zoom `levels` and a camera `zoom`, this answers
/// the single question every level-of-detail decision on this map is a phrasing of:
/// *which step of that ladder has this camera reached?* It returns the INDEX of the
/// finest level the camera is at or past — the **floor** rule — and `0` when the camera
/// is coarser than the whole ladder.
///
/// # Why this function exists (`kalm-2`, measured 2026-08-03)
///
/// The threshold used to be written TWICE, in two different shapes, for the same
/// quantity: [`view_lod_tier`] cut the draw side at `zoom >= 12.0`, while korp's tile
/// fetch picked its pyramid level from `round(zoom)` fed to a **nearest-level** search.
/// Those are not the same function of the same number. With a `[6, 9, 12]` pyramid the
/// fetch flipped to z12 at camera **10.5** (`round(10.5) = 11`, nearest of `[6,9,12]`
/// is `12`) while the renderer stayed on the coarse tier until **12.0**. MEASURED on
/// the live Sweden pyramid: camera 11.9 drew **9 840** features out of **80 000** ways
/// the server had already sent — 12.3 % — and camera 12.0 drew **127 635**. A 12.97×
/// step for a hundredth of a zoom, with a 1.5-zoom-wide band underneath it where the
/// wire carried detail the painter threw away.
///
/// One rule, applied to two level lists ([`LOD_TIER_ZOOMS`] for the cartographic tier,
/// the recipe's `zooms` axis for the tile pyramid), cannot develop that kind of gap:
/// both sides step at level boundaries, both step by flooring, and a level list that
/// grows a step (`[6, 9, 12]` → `[6, 9, 12, 15]`) moves both sides together.
///
/// # Why FLOOR and not nearest/round
///
/// Nearest pulls detail the camera has not earned. On a `[6, 9, 12, 15]` pyramid the
/// nearest rule starts fetching **z15** tiles at camera 13.5 — a viewport ~8× wider in
/// z15 tiles than at zoom 15, off the pyramid level that carries 17.3 GB against z12's
/// 2.4 GB — and the renderer's tier does not change at 15 at all, so every one of those
/// bytes buys nothing. Floor guarantees the invariant that makes the two sides safe:
/// **geometry generalised for level `L` is never shown at a camera below `L`**.
///
/// `levels` must be ascending; an empty list yields `0`.
pub fn lod_step(levels: &[u8], zoom: f32) -> usize {
    let mut step = 0;
    for (i, &l) in levels.iter().enumerate() {
        if zoom >= f32::from(l) {
            step = i;
        } else {
            break;
        }
    }
    step
}

/// The LEVEL (not the index) of [`lod_step`] — the ladder rung this camera reads from.
///
/// `None` only for an empty ladder. A camera coarser than the whole list clamps UP to
/// the coarsest level, because that is the only data that exists out there; there is no
/// such thing as reading a level the pyramid does not hold.
pub fn lod_level(levels: &[u8], zoom: f32) -> Option<u8> {
    levels.get(lod_step(levels, zoom)).copied()
}

/// **The cartographic ladder** — the slippy zooms at which the map's [`Lod`] tier steps
/// (`0` country · `6` region · `12` city). This is a LOOK decision, not a data one: it
/// is why a `Lod::Region` way (water, secondary roads) still draws over a country-scale
/// overview at zoom 6, and why residential streets and buildings wait until 12.
///
/// It is deliberately a separate list from the tile pyramid's `zooms` axis. [`Lod`] has
/// [`LOD_COUNT`](crate::style::LOD_COUNT) steps and the pyramid has as many as it was
/// cut with; binding the tier to the pyramid's INDEX would silently redraw the map every
/// time the pyramid gained a level (`[6, 9, 12]` → `[6, 9, 12, 15]` would have dropped
/// city detail between 12 and 15). What must not drift is the RULE, and both lists are
/// read by [`lod_step`].
///
/// **The third step is 9** — the z9 rung, added 2026-08-25 with
/// [`Lod::District`](crate::style::Lod::District). It is the one rung the pyramid served
/// while the ladder ignored it: z9 tiles were cut, stored, shipped and decoded, and then
/// drew the tier z6 already drew. `tests/lod_ladder_vs_pyramid.rs` is the law (this
/// line named `lod_ladder_serves_its_pyramid.rs` until 2026-08-27; no such file has
/// ever existed -- the guard is real, the citation was not)
/// that now says a served rung must move the ladder; `Lod`'s own doc carries what moved
/// onto it and what deliberately did not.
///
/// **The fifth step is 15, not 14** (`kalm-1`). The tier a footpath is entitled to is
/// the one where a footpath is wide enough to be a footpath: at camera 14 the ground is
/// ~2.4 m/px and a 2 m path is under a pixel — the same grain, one zoom deeper. At 15
/// the Sweden extract's finest rung is 0.608 m/px and the same path is ~3 px, an object
/// with a width. 15 is also where the pyramid's deepest rung starts, so the camera that
/// earns the tier is the camera that fetches the geometry generalised for it.
pub const LOD_TIER_ZOOMS: [u8; crate::style::LOD_COUNT] = [0, 6, 9, 12, 15];

/// **The viewport's level-of-detail tier** (`0` country · `1` region · `2` city),
/// from its slippy zoom. This is the CPU render path's half of the LOD contract the
/// GPU cull already enforces (`gpu/cull.wgsl`: a way draws only when `lod <=
/// viewport_lod`) and the [`Lod`](crate::style::Lod) style law states ("country ways
/// show at every zoom; city ways only when zoomed in").
///
/// It mirrors the SEMANTICS of `facett_geomap::osm2d`'s `way_lod()` — far out yields
/// country, zoomed in yields city — but reads a **slippy** zoom (`0..=22`, where the
/// world spans `256·2^zoom` px) rather than geomap's fit-to-data multiplier, so the
/// thresholds are slippy-zoom levels, not that path's `3_000` / `30_000` multipliers.
///
/// It is now [`lod_step`] over [`LOD_TIER_ZOOMS`] — the SAME rule korp's tile fetch
/// steps its pyramid with (`kalm-2`). The breakpoints are byte-for-byte the ones this
/// function has always had; what changed is that they are no longer a hand-written
/// ladder that a second ladder elsewhere could disagree with.
pub fn view_lod_tier(zoom: f32) -> u32 {
    (lod_step(&LOD_TIER_ZOOMS, zoom) as u32).min(Lod::Detail as u32)
}

/// **THE level-of-detail gate — the one every CPU layer draws through.**
///
/// A feature tiered `lod` is drawn only once the viewport has zoomed to at least that
/// tier: country features at every zoom, city features (residential roads, buildings,
/// individual nodes) only when zoomed in. This is the CPU half of the rule
/// `gpu/cull.wgsl` enforces on the GPU (`lod <= viewport_lod`) and the
/// [`Lod`](crate::style::Lod) style law states.
///
/// It is a free function, and public, because it was being *retyped*: [`WaysLayer`]
/// had the comparison, [`LabelLayer`] had its own copy, and [`PointsLayer`] had none
/// at all — so 40 000 OSM nodes painted over a whole country at every zoom while
/// their sibling ways correctly culled to 19.6 % (that is the owner's "red dots all
/// over"). Two sibling layers in one file disagreeing about the same trait contract is
/// the failure this removes; there is now one comparison and three callers.
pub fn lod_visible(lod: Lod, view: &MapView) -> bool {
    (lod as u32) <= view_lod_tier(view.zoom)
}

/// The map label point size. ONE writer: `place_map_labels` measures the galley at this
/// size and `paint_features` draws at it, so the box the placer reserved and the text
/// that lands are the same picture. They were two literal `12.0`s and a placer measured
/// against the wrong font is a placer that reserves the wrong pixels.
const LABEL_FONT_PX: f32 = 12.0;

/// Padding around each label box so neighbours do not merely *touch*.
const LABEL_PAD_PX: f32 = 3.0;

/// **The areal budget: how much pane one label is entitled to, in px².**
///
/// THE DEFECT THIS CLOSES. Rect-vs-rect collision answers "do these two share pixels?",
/// and on a dense city viewport the honest answer for a great many pairs is *no* — so
/// the placer packs names shoulder to shoulder and reports a perfect zero overlaps over
/// a pane nobody can read. MEASURED on korp-ui over Stockholm at zoom 12.7 (canvas
/// 1258×728 = 915 824 px²): **255 placed, 0 overlapping pairs** — one label per
/// 3 591 px² of map. Not one collision test could have seen that.
///
/// HONEST BOUND on the ink figure: that pane published no `ink_px` (this change is what
/// added it), so its box coverage is INFERRED, not measured — 255 × the mean box area
/// of **1 165 px²** measured on the AFTER run of the same drive = **>= 32 %**, and it
/// is a lower bound because the before set carried more long street names than the
/// after set does. What IS measured end to end is the harness fixture in
/// `label_collision::a_z12_city_pane_does_not_disappear_under_its_own_labels`:
/// **28.4 % -> 10.7 %** of the same 1258×728 pane; and the live pane after this change:
/// **7.8 %** (64 labels, `ink_px = 74 559`, drive of 2026-08-03).
///
/// The derivation, so the number is not a taste: a 12 px-font Swedish street name lays
/// a galley of roughly 72×17 px, and [`LABEL_PAD_PX`] adds 3 px on every side → about
/// **1 800 px² of reserved box per label**. Holding total label ink near 12 % of the
/// pane — the low end of ordinary cartographic practice, and where a car-nav basemap
/// sits — gives `1800 / 0.12 = 15 000 px²` per label. Rounded to 14 000, korp's canvas
/// budgets `915 824 / 14 000 =` **65 labels**; the live pane came back at **64 placed,
/// 7.8 % ink**, against 255 labels before.
///
/// It is an AREAL rule on purpose: a bigger pane earns proportionally more names and a
/// small embedded pane earns fewer, with no second constant to tune per host.
const LABEL_MIN_PX2_EACH: f32 = 14_000.0;

/// **The minimum screen distance between two printings of the SAME text.**
///
/// OSM splits one street into many ways, and [`LabelLayer::from_ways`] anchors a label
/// at each way's centroid, so "Ringvägen" arrives at the placer once per segment. Those
/// printings do not overlap — they are hundreds of px apart along the road — so the
/// collision pass keeps every one of them, and five copies of one name is five times
/// the ink for one fact.
///
/// `250` px lets a genuinely long street carry its name again at the other end of a
/// 1258×728 pane (the packing bound is 6×3 = 18 printings, and a real street reaches
/// two or three), while killing the run of fragments inside one city block.
const LABEL_REPEAT_PX: f32 = 250.0;

/// The pane's label ceiling — [`LABEL_MIN_PX2_EACH`] applied to `rect`. At least 1, so
/// a tiny inset pane still gets its single most important name.
fn label_budget(rect: egui::Rect) -> usize {
    ((rect.area() / LABEL_MIN_PX2_EACH).floor() as usize).max(1)
}

/// Work budget for one frame's label placement — see
/// [`facett_core::legibility::PlaceOpts::max_considered`]. A 1900×1150 pane fits on the
/// order of 10² labels; 4 000 is several multiples of the physical ceiling, so the
/// truncation only ever takes candidates that were going to be rejected anyway, and it
/// bounds the cost of a viewport holding 80 000 named ways.
const MAX_LABELS_CONSIDERED: usize = 4_000;

/// What a layer's features *are* — drives the renderer's paint choice + z default.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub enum LayerKind {
    Line,
    Polygon,
    Point,
    Raster,
    Label,
}

impl LayerKind {
    pub fn as_str(self) -> &'static str {
        match self {
            LayerKind::Line => "line",
            LayerKind::Polygon => "polygon",
            LayerKind::Point => "point",
            LayerKind::Raster => "raster",
            LayerKind::Label => "label",
        }
    }
    /// Default paint order (small = under): polygons < lines < points < labels.
    fn default_z(self) -> i32 {
        match self {
            LayerKind::Raster => 0,
            LayerKind::Polygon => 10,
            LayerKind::Line => 20,
            LayerKind::Point => 30,
            LayerKind::Label => 40,
        }
    }
}

/// The renderer-agnostic geometry of one drawable feature, in **`(lon, lat)`**
/// (the renderer projects it). Not a source struct — this is the ONLY currency
/// between a [`MapLayer`] and the shared painter.
#[derive(Clone, Debug)]
pub enum DrawGeom {
    Point(LatLon),
    /// The coordinate vectors ride in an [`Arc`](std::sync::Arc) — shared, not owned.
    /// `DrawFeature`s are rebuilt every frame for geometry that has not changed, and
    /// while the ear clip is cached ([`DrawFeature::tri`]) the coordinates themselves
    /// were still `clone()`d per CPU-owned way per frame — one heap allocation and
    /// 16 B per vertex of pure churn (the exact cost [`MapLayer::features_into`]'s
    /// zero-copy measurement itemises). The owning layer keeps one `Arc` per ring
    /// ([`WaysLayer`]'s `shared_geom` slots, the landmass layer's ring fields) and a
    /// feature is a pointer bump; `tests/draw_geom_shared.rs` pins the sharing with
    /// `Arc::ptr_eq` across frames. Consumers are unchanged: every reader wants
    /// `&[(f64, f64)]` and the `Arc<Vec<_>>` derefs there.
    Line(std::sync::Arc<Vec<(f64, f64)>>),
    /// A closed **outer** ring (filled if the feature carries a `fill`) plus zero or
    /// more **hole** (inner) rings that are SUBTRACTED from the fill — a lake's
    /// island, a donut landuse's courtyard. Empty `holes` ⇒ a plain single-ring
    /// polygon (the byte-identical common case). The renderer punches the holes via
    /// [`crate::tessellate::filled_mesh_with_holes`].
    Polygon {
        outer: std::sync::Arc<Vec<(f64, f64)>>,
        holes: std::sync::Arc<Vec<Vec<(f64, f64)>>>,
    },
    /// A name/`ref` to paint at `at`, with the two things the collision placer needs
    /// beyond the text itself.
    Label {
        at: LatLon,
        text: String,
        /// Importance **within** the LOD tier, `0.0..=1.0` — see
        /// [`crate::style::LabelClass::rank`]. The tier alone saturates at `City`, so
        /// without this every street name in a z12 viewport is ranked identically and
        /// the placer's tie-break is the tile decode order.
        rank: f32,
        /// The labelled feature's **Mercator-unit extent** (the longer side of its
        /// bbox), or `0.0` for a point label like a place node. Multiplied by the
        /// camera's `world_px` it is the feature's on-screen length, which is what
        /// decides whether the name physically fits along the thing it names.
        merc_len: f64,
    },
    /// **A GLOW ribbon** — a path drawn as ONE per-vertex-alpha mesh with a soft
    /// cross-section, not as a stack of concentric strokes.
    ///
    /// The stack is what a glow used to be here, and the owner named the result on
    /// 2026-09-03: *"its not a soft spline its a flat band with some smoth edges"*.
    /// Five source-over strokes composite to `1 − Π(1 − αᵢ)` at the centreline, and the
    /// ladder's core pass is `alpha 1.0` — so the middle is OPAQUE and only the outer
    /// halo is soft. Nothing shows through it, which is the opposite of what a spirit
    /// trace is.
    ///
    /// The profile comes from `facett_core::render::decor::glow_cross_section`, the same
    /// `GLOW_LADDER` the stroke passes read, so this is a different projection of one
    /// kernel and never a second look. `core_w` is the hot core's width in logical px;
    /// the ribbon's full width is the ladder's widest multiple of it.
    Glow {
        path: std::sync::Arc<Vec<(f64, f64)>>,
        /// The hot core's width in logical px — the ladder scales off it.
        ///
        /// There is deliberately NO `night` field beside it. A luminous line is a
        /// contrast effect, so the ground IS a required input — but the ground is a
        /// property of the PANE, not of the geometry, and the painter reads it from
        /// `MapTheme::night`. It briefly lived here, inferred from the way's own colour
        /// (luminance sum > 383), which is a fact about the ink and says nothing about
        /// what it is drawn on: a bright neon trail read as "night" and burned its core
        /// WHITE on the day map's cream `#F2EFE9`.
        core_w: f32,
    },
}

/// One drawable primitive a layer emits for a viewport. Carries its own resolved
/// colour/width/fill/z so the renderer stays generic (the "better than Garmin"
/// crisp street hierarchy is decided here, per-feature, not in the painter).
#[derive(Clone, Debug)]
pub struct DrawFeature {
    /// Stable feature id (osm id / row id) for hit-testing + selection (FC-5).
    pub id: i64,
    pub geom: DrawGeom,
    /// Stroke / point / label colour.
    pub color: Color32,
    /// Stroke width (logical px at the current zoom).
    pub width: f32,
    /// Fill colour for a `Polygon` (`None` → outline only).
    pub fill: Option<Color32>,
    /// **The declared paint order** — larger draws on top; see
    /// [`crate::paint_order`] for what the key is made of and for the four
    /// mechanisms that used to decide the stack instead of it.
    ///
    /// It was a bare `z: i32` taken from [`LayerKind::default_z`], i.e. one value per
    /// KIND: every polygon `10`, every line `20`. The sort on it is stable, so the
    /// class order (water under landuse under buildings) survived only as a tie-break
    /// inside one kind, and *within* a class the winner of every overlap was whichever
    /// way the tile merge appended last. This field's own doc used to promise "a wider
    /// way (motorway) draws over a thinner one at the same tier" — it never could.
    pub order: crate::paint_order::PaintKey,
    /// **The polygon's cached ear clip** — the index triples into
    /// [`tessellate::fill_vertices`]`(outer, holes)`, computed once in Mercator space
    /// by the owning layer's [`tessellate::FillTessCache`] and valid at every camera
    /// (the projection is affine; see that type's note).
    ///
    /// `None` — the default for a layer that keeps no cache, and for every non-polygon
    /// feature — means the painter ear-clips this ring itself, exactly as before. This
    /// is the ONLY reason the field is on the renderer-agnostic currency struct at all:
    /// `DrawGeom::Polygon` carries a *clone* of the ring, so by the time the painter
    /// sees it the link back to the `Way` (and to any cache keyed on it) is gone.
    ///
    /// MEASURED cost of not having it, 2026-08-03 on a real 1600×1000 window / RTX
    /// 4090 / 20 000 Swedish ways: `tessellate::triangulate` was **36.22 ms of a
    /// 42.45 ms frame (85.4 %)**, re-derived from scratch every frame for geometry
    /// that had not moved.
    pub tri: Option<tessellate::FillTris>,
    /// **The feature's LOD tier**, carried through to the painter.
    ///
    /// [`lod_visible`] already gates *whether* a feature reaches the painter, but that
    /// gate saturates: [`view_lod_tier`] has three steps and at zoom ≥ 12 every
    /// [`Lod::City`] feature passes it. For lines that is fine — they overlap into a
    /// road network. For **labels** it is not: every named street in the viewport draws
    /// its name at 12 pt and the map becomes grey static (MEASURED on the shipped
    /// korp-ui over Stockholm at zoom 12.1 — see `label_collision.rs`).
    ///
    /// So the tier has to survive as far as [`MapFacet::place_map_labels`], which ranks
    /// candidates by it: a `Country` place-name outranks a `City` street name for a
    /// contested pixel. Non-label layers set their own tier and nothing reads it yet.
    pub lod: Lod,
    /// **Is this line's core dashed along its arc length?** (HO-T3) — the way's
    /// [`WayStyle::dashed`](crate::style::WayStyle::dashed), carried here because the
    /// painter sees a `DrawGeom::Line` and not the `Way` it came from. `false` for every
    /// point, label and polygon. [`MapFacet::paint_features`] strokes a dashed line's
    /// core as one `Shape::line` per lit run ([`dash_polyline`]) at
    /// [`crate::style::dash_period_px`] — the same law the GPU lane's `line.wgsl` applies —
    /// over the same whole casing.
    pub dash: bool,
}

/// The per-layer default style (the layer's identity colour/width/z + its kind).
#[derive(Clone, Copy, Debug)]
pub struct LayerStyle {
    pub color: Color32,
    pub width: f32,
    pub z: i32,
    pub kind: LayerKind,
}

impl LayerStyle {
    pub fn new(kind: LayerKind, color: Color32, width: f32) -> Self {
        Self { color, width, z: kind.default_z(), kind }
    }
}

/// **What the GPU lane has taken off the CPU painter this frame**, per layer.
///
/// It was a bare `lines_on_gpu: bool` while the lane could only draw roads. The fill
/// lane makes that a two-valued question — and a bool that means "lines" while a
/// second kind of geometry is also on the device is exactly how a map ends up
/// drawing its lakes twice or not at all. Naming both makes the double-draw a type
/// error rather than a convention.
///
/// [`Self::NONE`] is the pure-CPU frame and reproduces the old `false` byte for byte.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct GpuOwns {
    /// The GPU line lane drew this layer's **unfilled** ways.
    pub lines: bool,
    /// The GPU fill lane drew this layer's **filled** ways — mesh *and* ring outline.
    pub fills: bool,
}

impl GpuOwns {
    /// Nothing on the device: the CPU painter draws everything.
    pub const NONE: Self = Self { lines: false, fills: false };

    /// Whether the GPU already drew this geometry, so the CPU painter must not.
    /// The ONE predicate — `features_into`'s default body and `WaysLayer`'s
    /// zero-copy override both ask it, so the two lanes cannot disagree about who
    /// owns a way.
    #[must_use]
    pub fn owns(self, geom: &DrawGeom) -> bool {
        match geom {
            DrawGeom::Line(_) => self.lines,
            DrawGeom::Polygon { .. } => self.fills,
            _ => false,
        }
    }
}

/// **The GPU lane's SHARE of one frame, in counts** — what
/// [`MapFacet::gpu_lane_share`] answers.
///
/// It exists because the question "is the lane carrying its weight?" was being answered
/// from `LaneClockStats::last_upload_bytes`, and that field is the bytes of **one**
/// upload EVENT. The tile lane calls `note_upload` once per hand-over GROUP, so on a
/// frame with several hundred groups the reported number is whichever group happened to
/// upload last — a per-group figure read as a per-frame one. korp's `FIRST-PAINT` line
/// printed `upload 25 us (1280 bytes)` for a Stockholm viewport carrying 1 264 ways and
/// it was taken as proof the lane owned almost nothing (owner, 2026-08-30, "it feels
/// like my first Turbo Pascal 1996 map"). Bytes cannot answer it; **ways on each side**
/// can, and that is what this carries.
#[cfg(feature = "wgpu")]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LaneShare {
    /// The lane the live plan chose, with its reason if it declined.
    pub lane: WayLane,
    /// How many layers the lane took.
    pub layers: usize,
    /// Which KINDS it claimed.
    pub owns: GpuOwns,
    /// The lane's rank floor — where its one opaque slot sits in the declared stack.
    pub floor: Option<i32>,
    /// Unfilled ways the lane bakes.
    pub lane_lines: usize,
    /// Filled ways the lane bakes (mesh + ring outline).
    pub lane_fills: usize,
    /// **Ways still on the CPU painter** — `DrawGeom::Line`/`Polygon` features in the
    /// very list [`MapFacet::ui`] paints from. This is the number that decides whether
    /// moving work to the device bought anything.
    pub cpu_ways: usize,
    /// Every CPU feature including labels and points (which have no way and can never
    /// join the lane).
    pub cpu_feats: usize,
    /// `(band, tile)` hand-over groups — one VRAM entry, one cull dispatch and up to
    /// three `draw_indirect`s EACH, plus five per-frame uniform writes.
    pub groups: usize,
    /// Distinct producer-stamped tiles those groups cover.
    pub tiles: usize,
}

/// **A source of drawable map data.** The renderer is source-agnostic; sources are
/// **SELECTABLE** (`enabled`) and **STACKABLE** (many in one [`MapFacet`]). This is
/// the one seam every map surface in the constellation draws through.
pub trait MapLayer {
    /// Stable id — `"osm-streets"`, `"trafikverket-roads"`, `"korp-<collector>"`.
    fn id(&self) -> &str;
    /// What the features are (drives paint + default z).
    fn kind(&self) -> LayerKind;
    /// Query the features intersecting `view` at its zoom/LOD, as renderer-agnostic
    /// draw primitives (NOT source structs — keeps the renderer generic).
    fn features(&self, view: &MapView) -> Vec<DrawFeature>;

    /// **Append this layer's CPU-painter features into `out`, skipping the line
    /// features when `lines_on_gpu`** — i.e. when [`MapFacet`] handed this layer to
    /// the GPU line lane for the frame.
    ///
    /// This exists for **zero copy**, and the copy it removes is not small.
    /// [`DrawGeom::Line`] owns its coordinates, so the old
    /// `features(view).into_iter().filter(…)` *built* every road's `(lon, lat)` vector
    /// — one heap allocation and 16 B per vertex — and then dropped it again, every
    /// frame, for exactly the geometry the GPU already had in VRAM. MEASURED on 20 000
    /// real Sweden ways at slippy zoom 6: **2 654 vectors / 130 236 vertices / 2.08 MB
    /// allocated and freed per frame**, 42 % of the whole per-frame feature-build
    /// allocation, for a payload the CPU painter was forbidden to draw.
    ///
    /// The default body is the old filter, so **every existing implementor is
    /// byte-identical** and a layer that has no line features pays nothing new.
    /// [`WaysLayer`] overrides it to `continue` *before* the clone, which is where the
    /// saving actually is.
    fn features_into(&self, view: &MapView, on_gpu: GpuOwns, out: &mut Vec<DrawFeature>) {
        out.extend(self.features(view).into_iter().filter(|f| !on_gpu.owns(&f.geom)));
    }

    /// **How many features this layer would draw for `view` — without BUILDING them.**
    ///
    /// The other half of [`features_into`](Self::features_into)'s zero-copy argument, for
    /// the callers that never wanted the geometry in the first place.
    /// [`MapFacet::severity`] asks *"does this map draw anything at all?"* and
    /// [`MapFacet::state_json`] publishes a COUNT — and both used to answer by building
    /// the entire `Vec<DrawFeature>`, cloning every visible way's `(lon, lat)` vector, and
    /// then reading `.len()` or `.is_empty()` off it and dropping the lot.
    ///
    /// That is not a hypothetical cost: korp calls `severity()` on **every frame**
    /// (`react_to_facet_panes`, whose own doc says the severity check exists "so a healthy
    /// frame never pays for a `state_json` build" — while the check itself paid exactly
    /// that) and `state_json()` **3.3 times a second** (`dump_if_due`, every 300 ms).
    ///
    /// The default body keeps the old answer for every implementor, so nothing can drift;
    /// [`WaysLayer`] and [`PointsLayer`] override it to count through the SAME per-feature
    /// visibility predicate their `features_into` draws through.
    fn feature_count(&self, view: &MapView) -> usize {
        self.features(view).len()
    }

    /// The layer's identity style (the renderer falls back to it per feature).
    fn style(&self) -> &LayerStyle;
    /// User toggle — is this data drawn? (Selectable data-to-draw.)
    fn enabled(&self) -> bool;
    /// Flip the toggle (runtime selection).
    fn set_enabled(&mut self, on: bool);
    /// **Does this layer declare where the LAND is?** Only [`LandmassLayer`] does.
    ///
    /// [`MapFacet::background`] reads it to pick the pane wash, which is the whole
    /// point: a stack that carries a landmass silhouette has *said* where the land
    /// is, so every pixel outside it is SEA. Default `false`, so no other
    /// implementor has to care.
    fn is_landmass(&self) -> bool {
        false
    }

    /// **The layer's pre-decoded [`Way`]s, for the GPU lane** — `None` for a layer
    /// that has none (points, labels, a landmass silhouette).
    ///
    /// This is the seam that made [`MapFacet`]'s GPU path possible at all. The
    /// renderer-agnostic [`DrawFeature`] currency is deliberately lossy — it hands
    /// the painter `(lon, lat)` vectors and a resolved colour — and the GPU line
    /// lane needs the `Way` itself: its precomputed Mercator bbox (the compute
    /// cull's input), its [`Lod`](crate::style::Lod) tier and its themed raw width,
    /// all baked once into VRAM rather than rebuilt per frame. Without this method
    /// `facett_map::gpu::GpuMapRenderer` was **installed and unreachable by
    /// construction** in korp: the renderer went into the `RenderState`, the About
    /// box said so, and `MapFacet::ui` was a pure CPU painter that never referenced
    /// it — 0 frames encoded, every pane on one core.
    ///
    /// Returning `Some` does NOT mean the GPU draws them: [`MapFacet`] only takes a
    /// layer's *unfilled* ways, only when a renderer is installed, and only for a
    /// flat north-up camera (see [`MapFacet::gpu_lane_layers`]). Everything else
    /// falls back to the CPU painter, which is still the one that draws these very
    /// ways via [`features`](Self::features).
    fn gpu_ways(&self) -> Option<&[Way]> {
        None
    }

    /// **The layer's ways PLUS the cached Mercator ear clip for each — the GPU FILL
    /// lane's input.** `None` for a layer that carries no [`tessellate::FillTessCache`].
    ///
    /// Why it is a *pair* and not just the ways: the fill lane must not ear-clip.
    /// The triangulation was 36.22 ms of a 42.45 ms frame before it was cached, and a
    /// bake path that re-derived it would put that cost straight back — this time on
    /// the frame that uploads. So the lane reads the SAME `OnceLock` slots the CPU
    /// painter fills, keyed by the way's index in the slice, and skips a way whose
    /// slot is still empty (it paints on the CPU that frame and joins the lane on the
    /// next bake). The cache is per-layer and built beside its ring set, so the index
    /// correspondence is structural, not a convention someone has to maintain.
    ///
    /// Returning `Some` does NOT mean the GPU draws them — see
    /// [`MapFacet::way_lane_plan`].
    fn gpu_fills(&self) -> Option<(&[Way], &tessellate::FillTessCache)> {
        None
    }

    /// **How many ear clips this layer has ever run** — `0` for a layer that carries
    /// no fills (and for one that carries no [`tessellate::FillTessCache`]).
    ///
    /// This is a MEASUREMENT seam, and it exists because the claim it proves cannot
    /// be proved any other way. "The fill triangulation is cached" is a statement
    /// about work that did **not** happen, and a frame that paints the right pixels
    /// looks identical whether the indices came from a cache or from an O(n²) ear
    /// clip run again — which is exactly how 36.22 ms of a 42.45 ms frame hid in
    /// plain sight. A test can drive the camera for a hundred frames and read this:
    /// cached ⇒ it stops at the number of distinct areas painted; not cached ⇒ it
    /// climbs with every frame. Nothing about the rendered output distinguishes the
    /// two, so nothing about the rendered output can be the guard.
    fn ear_clips(&self) -> usize {
        0
    }

    /// **A process-unique stamp for this layer's way GEOMETRY** — nonzero means "my
    /// [`gpu_ways`](Self::gpu_ways)/[`gpu_fills`](Self::gpu_fills) slice is immutable
    /// for my whole lifetime, and this number names it". [`WaysLayer`] stamps one per
    /// construction (its `ways` are built beside it and never mutated — a new way set
    /// is a new layer), which is what lets [`MapFacet::gpu_geometry_signature`] answer
    /// from an `O(layers)` memo instead of re-walking every way's bbox twice per
    /// frame. Default `0` = "no such promise": a `0` from any taken layer disables
    /// the memo and the signature is recomputed exactly as before, so an external
    /// implementor that mutates in place stays correct, just unmemoised.
    fn geometry_generation(&self) -> u64 {
        0
    }
}

// ── MapTheme — the shared "better than Garmin" look ──────────────────────────

/// The one place the map look lives: a day/night [`MapPalette`] plus label +
/// tilt-shading knobs, so **all** maps inherit crisp street hierarchy, smooth
/// labels and a pitched-horizon fade. Derived from a [`facett_core::Theme`] so the
/// map re-themes with the rest of the deck.
#[derive(Clone, Debug)]
pub struct MapTheme {
    /// The **land** ("paper") fill — the colour a [`LandmassLayer`] paints the
    /// country silhouette in, and the pane wash for a stack that carries no
    /// landmass at all (nothing has said where the land is, so the pane *is* paper).
    pub land: Color32,
    /// The **sea** — the pane wash for a stack that DOES carry a landmass silhouette.
    ///
    /// This field exists because it was missing: `land` was both the pane wash and
    /// the landmass fill, so korp's Sweden polygon was painted in exactly the colour
    /// of the pane behind it. Measured on the shipped day theme: `land` and the wash
    /// were the same `#1f1f26`, 95.63 % of the map canvas was that one value, and the
    /// whole 20 000-way payload had nothing to read against. Land and sea must be two
    /// colours or a coastline cannot exist.
    pub sea: Color32,
    pub palette: MapPalette,
    /// Street / place label INK.
    ///
    /// It used to be the host `Theme::text`, which put `#DCDCDC` app-chrome grey on
    /// `#F2EFE9` cartographic paper — a **1.18 : 1** contrast ratio, measured on the
    /// shipped korp-ui. Now [`MapPalette::label`], derived from the palette's own land,
    /// so a light map gets dark ink and a dark map gets light ink by construction.
    pub label: Color32,
    /// The thin outline drawn around label text ([`MapPalette::label_halo`]) so a name
    /// crossing a dark road casing or a lake is still readable. The paper colour — it
    /// shows only where the text is not already on paper.
    pub label_halo: Color32,
    /// `true` when this is the night variant (dark paper, glowing streets).
    pub night: bool,
}

impl MapTheme {
    /// Derive the day look from a facett [`Theme`](facett_core::Theme).
    /// **The CARTOGRAPHIC look** — a real paper map, independent of the app theme:
    /// warm land, calm blue water, green parks, an orange→yellow→white road ramp.
    ///
    /// This exists because [`day`](Self::day) derives its palette from the host's
    /// `facett_core::Theme`, so a host whose theme is dark gets a dark "day" map — land at
    /// `#1f1f26` against a `#1f1f26` pane, which is no map at all. korp rendered exactly
    /// that: 30 % land, 0 % sea, and a country with no coastline. The fixed hexes in
    /// [`MapPalette::cartographic`] are the point — the map looks like a map under any
    /// theme, which is what a navigator has to do.
    ///
    /// `label` comes from the PALETTE, not the host theme: a cartographic map is not
    /// app chrome, and taking `Theme::text` printed korp's dark-theme `#DCDCDC` on
    /// `#F2EFE9` paper at a 1.18 : 1 contrast ratio — see [`MapPalette::label`].
    pub fn cartographic(_theme: &facett_core::Theme, day: bool) -> Self {
        let palette = crate::style::MapPalette::cartographic(day);
        Self {
            palette,
            land: palette.land,
            sea: palette.water,
            label: palette.label(),
            label_halo: palette.label_halo(),
            night: !day,
        }
    }

    pub fn day(theme: &facett_core::Theme) -> Self {
        let palette = MapPalette::from_theme(theme);
        // The sea IS water — reuse the palette's water hue rather than mint a second
        // blue, so a lake and the open sea are one colour (what a real map does) and
        // a cartographic preset swaps both at once.
        Self {
            palette,
            land: palette.land,
            sea: palette.water,
            label: palette.label(),
            label_halo: palette.label_halo(),
            night: false,
        }
    }
    /// The night look — a darker paper with the same palette family (glowing
    /// streets read better than Garmin's flat night mode).
    pub fn night(theme: &facett_core::Theme) -> Self {
        let palette = MapPalette::from_theme(theme);
        // The one night-dim formula, applied to BOTH the land and the sea — hoisted
        // into a closure so the two can never drift apart into an identity again.
        let dim = |c: Color32| {
            Color32::from_rgb(
                (c.r() as f32 * 0.35) as u8,
                (c.g() as f32 * 0.35) as u8,
                (c.b() as f32 * 0.45) as u8,
            )
        };
        let land = dim(palette.land);
        Self {
            palette,
            land,
            sea: dim(palette.water),
            // The night look DIMS the land, so the ink must be resolved against the
            // dimmed paper, not the palette's daytime one — a `label()` read off the
            // undimmed palette is the identity-value trap in colour form.
            label: MapPalette { land, ..palette }.label(),
            label_halo: land,
            night: true,
        }
    }
}

impl Default for MapTheme {
    fn default() -> Self {
        Self::day(&facett_core::Theme::default())
    }
}

// ── Concrete reusable layers (NO re-parse; feed pre-decoded data) ────────────

/// One way's coordinates as shared `Arc`s: the outer ring/line and its holes.
type SharedGeom = (std::sync::Arc<Vec<(f64, f64)>>, std::sync::Arc<Vec<Vec<(f64, f64)>>>);

/// A **ways** layer — the OSM-streets / Trafikverket-roads source. Holds
/// pre-decoded [`Way`]s (from `facett-osm`'s GeoParquet reader, or any other
/// source that produces `Way`s) and yields one [`DrawFeature`] per way, culled to
/// the viewport. **Source-agnostic:** construct one with `id = "osm-streets"` and
/// another with `id = "trafikverket-roads"` — same type, both selectable at
/// runtime. No geometry is re-parsed; the `Way`s carry their resolved style.
pub struct WaysLayer {
    id: String,
    /// The ways — a SHARED, contiguous view into a slab (see [`WaySlab`]). A layer
    /// built with [`WaysLayer::new`] owns the whole slab; one built with
    /// [`WaysLayer::shared`] is a window onto a slab a host keeps, so a facet
    /// rebuild over retained geometry copies nothing.
    ways: WaySlab,
    style: LayerStyle,
    enabled: bool,
    /// **The ear clip of every filled way in this layer**, one slot per `ways` index,
    /// filled on the first frame that paints each area and never again.
    ///
    /// Invalidated **by construction**: it is built in [`WaysLayer::new`] beside the
    /// `ways` it belongs to and has exactly as many slots, so a new way set is a new
    /// layer is a new cache. There is no camera in the key because there must not be
    /// one — see [`tessellate::FillTessCache`].
    fill_tess: tessellate::FillTessCache,
    /// This layer's [`MapLayer::geometry_generation`] stamp, taken once in
    /// [`WaysLayer::new`] from a process counter. Valid on the same by-construction
    /// terms as `fill_tess` above: the `ways` are immutable for the layer's lifetime
    /// (korp retains the facet across pans and replaces the LAYER on data change), so
    /// "same generation" ⇒ "same geometry" needs no per-way walk to check.
    generation: u64,
    /// **The shared-coordinate slots behind [`DrawGeom`]'s `Arc`s** — one per `ways`
    /// index, filled the FIRST frame that emits the way and a pointer bump ever
    /// after. The one-time fill is the same single `lonlat`/`holes` clone the old
    /// per-frame build paid on every frame; the slot then owns it. Invalidated on
    /// the same by-construction terms as `fill_tess` above (built beside its own
    /// way set, no camera in the key).
    shared_geom: Vec<std::sync::OnceLock<SharedGeom>>,
}

/// **A shared, contiguous range of ways** — what a [`WaysLayer`] holds.
///
/// facett's layer boundary reads `&[Way]` (the GPU lane bakes straight off the
/// slice, the tessellation cache is indexed by position in it), which is why a
/// `WaysLayer::new` takes an owned `Vec<Way>` — and why every host that RETAINS its
/// classified ways and rebuilds the facet on each apply used to deep-copy the whole
/// set into the layers, once per class (korp: "the facet rebuild deep-clones the way
/// set x4 per apply", GFX_V2 shit list #2). A slab is the same `&[Way]` contract over
/// an `Arc<[Way]>` + a range: the host lays its ways out once, class-sorted, and each
/// class layer is a pointer bump and two indices. `Deref<Target = [Way]>`, so the
/// layer code reads it exactly as it read the `Vec`.
#[derive(Clone, Debug)]
pub struct WaySlab {
    slab: std::sync::Arc<[Way]>,
    range: std::ops::Range<usize>,
}

impl WaySlab {
    /// A view of `slab[range]`; the range is clamped to the slab.
    #[must_use]
    pub fn new(slab: std::sync::Arc<[Way]>, range: std::ops::Range<usize>) -> Self {
        let end = range.end.min(slab.len());
        let start = range.start.min(end);
        Self { slab, range: start..end }
    }

    /// The whole slab this view is cut from.
    #[must_use]
    pub fn slab(&self) -> &std::sync::Arc<[Way]> {
        &self.slab
    }

    /// This view's range within [`Self::slab`].
    #[must_use]
    pub fn range(&self) -> std::ops::Range<usize> {
        self.range.clone()
    }
}

impl std::ops::Deref for WaySlab {
    type Target = [Way];
    fn deref(&self) -> &[Way] {
        &self.slab[self.range.clone()]
    }
}

impl From<Vec<Way>> for WaySlab {
    fn from(ways: Vec<Way>) -> Self {
        let n = ways.len();
        Self::new(std::sync::Arc::from(ways), 0..n)
    }
}

/// The [`WaysLayer::generation`] counter — starts at 1 so `0` stays the trait's
/// "no promise" default forever.
static WAYS_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);

impl WaysLayer {
    /// Wrap pre-decoded ways as a selectable layer (the layer owns its slab).
    pub fn new(id: impl Into<String>, ways: Vec<Way>) -> Self {
        Self::from_slab(id, WaySlab::from(ways))
    }

    /// **A layer over a SHARED slab** — `slab[range]`, no copy. The host keeps the
    /// slab (class-sorted, one `Arc<[Way]>`) and hands every class layer its range;
    /// the tessellation cache and the shared-geometry slots are sized to the range and
    /// indexed by position in it, exactly as for an owned layer. Same by-construction
    /// invalidation: a new slab is a new layer is a new cache.
    pub fn shared(id: impl Into<String>, slab: std::sync::Arc<[Way]>, range: std::ops::Range<usize>) -> Self {
        Self::from_slab(id, WaySlab::new(slab, range))
    }

    fn from_slab(id: impl Into<String>, ways: WaySlab) -> Self {
        let mut shared_geom = Vec::new();
        shared_geom.resize_with(ways.len(), std::sync::OnceLock::new);
        Self {
            id: id.into(),
            fill_tess: tessellate::FillTessCache::new(ways.len()),
            shared_geom,
            ways,
            style: LayerStyle::new(LayerKind::Line, Color32::GRAY, 1.0),
            enabled: true,
            generation: WAYS_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
        }
    }

    /// Way `i`'s coordinates as the layer's SHARED `Arc`s (see `shared_geom`) —
    /// cloned from the `Way` once, on the first frame that needs them.
    fn shared_geom(&self, i: usize, w: &Way) -> SharedGeom {
        let slot = &self.shared_geom[i];
        let (outer, holes) =
            slot.get_or_init(|| (std::sync::Arc::new(w.lonlat.clone()), std::sync::Arc::new(w.holes.clone())));
        (outer.clone(), holes.clone())
    }
    /// The wrapped ways (read-only) — for hit-testing / counts.
    pub fn ways(&self) -> &[Way] {
        &self.ways[..]
    }

    /// The slab this layer's ways are a view of (and its range) — `Arc::ptr_eq` on
    /// it is how a host proves a rebuild shared rather than copied.
    #[must_use]
    pub fn slab(&self) -> &WaySlab {
        &self.ways
    }

    /// **How many ear clips this layer has ever run** — the applied-output fact that
    /// separates "cached" from "re-tessellated every frame". Painting the same areas
    /// for a thousand frames must leave this at the number of distinct areas painted;
    /// a cache that quietly stopped caching moves it every frame.
    #[must_use]
    pub fn ear_clips(&self) -> usize {
        self.fill_tess.ear_clips()
    }

    /// **THE per-way visibility test — the LOD gate and the viewport cull, in one
    /// place.**
    ///
    /// Both consumers go through it: [`features_into`](MapLayer::features_into), which
    /// draws the way, and [`feature_count`](MapLayer::feature_count), which only counts
    /// it. Written as one function rather than two loops because a counter that culled
    /// differently from the painter is a `state_json` "features" number about a map
    /// nobody is looking at — and it would be green in every test that reads only one of
    /// them.
    ///
    /// * the LOD gate honours the trait contract ("features intersecting the viewport at
    ///   a zoom/LOD"), the [`Lod`](crate::style::Lod) style law and the GPU cull
    ///   (`gpu/cull.wgsl`: a way draws only when `lod <= viewport_lod`). Without it the
    ///   CPU path drew city-LOD ways (residential/service roads, buildings) at country
    ///   zoom — the very culling the GPU lane already did. The comparison itself lives
    ///   once more, in [`lod_visible`].
    /// * the bbox test skips ways whose Mercator envelope misses the viewport.
    /// * the **minimum projected extent** drops an AREA smaller than
    ///   [`crate::style::MIN_FILL_EXTENT_PX`] on screen (stinky `kalm-3`) — 43–47 % of
    ///   korp's z12 way budget was building footprints ~1.7 px across. Areas only: a
    ///   line's bbox extent says nothing about whether the line is worth drawing, so no
    ///   road network can be broken by it. The threshold comes from
    ///   [`crate::style::min_fill_extent_merc`], the SAME function whose answer is
    ///   handed to `gpu/cull.wgsl` as `ViewportCull::min_fill_extent` — one rule, two
    ///   lanes, or the map draws its lakes twice.
    fn draws(w: &Way, view: &MapView, [x0, y0, x1, y1]: [f64; 4]) -> bool {
        if !lod_visible(w.style.lod, view) {
            return false;
        }
        // The viewport, widened by the widest stroke the painter can draw
        // (`seam-15`): a way's bbox is its CENTRELINE's envelope, so a motorway whose
        // centreline misses the pane by a pixel would otherwise lose its whole 24 px
        // ribbon. The SAME number `MapTransform::viewport_bbox` widens the GPU cull by.
        let pad = crate::style::cull_pad_merc(view.world_px());
        if w.bbox[2] < x0 - pad || w.bbox[0] > x1 + pad || w.bbox[3] < y0 - pad || w.bbox[1] > y1 + pad {
            return false;
        }
        if w.fill.is_some() {
            let min_ext = f64::from(crate::style::min_fill_extent_merc(view.world_px()));
            let ext = (w.bbox[2] - w.bbox[0]).max(w.bbox[3] - w.bbox[1]);
            if min_ext > 0.0 && ext < min_ext {
                return false;
            }
        }
        true
    }
}

impl MapLayer for WaysLayer {
    fn id(&self) -> &str {
        &self.id
    }
    fn kind(&self) -> LayerKind {
        LayerKind::Line
    }
    /// ONE body, two callers — [`features`](Self::features) is this with
    /// `lines_on_gpu = false` and a fresh `Vec`, so the cull, the LOD gate and the
    /// style resolution cannot drift between the pure-CPU frame and the GPU-lane
    /// frame (LAW #5).
    fn features(&self, view: &MapView) -> Vec<DrawFeature> {
        let mut out = Vec::new();
        self.features_into(view, GpuOwns::NONE, &mut out);
        out
    }
    fn features_into(&self, view: &MapView, on_gpu: GpuOwns, out: &mut Vec<DrawFeature>) {
        let bb = view.merc_bbox();
        for (i, w) in self.ways.iter().enumerate() {
            let filled = w.fill.is_some();
            // ── ZERO COPY: the GPU lane owns this way's geometry this frame, so do
            //    not materialise it. An unfilled `Way` is exactly what
            //    `MapFacet::gpu_lane_ways` hands to `build_gpu_geometry`, and exactly
            //    what the old `features_for_cpu` filter threw away AFTER
            //    `w.lonlat.clone()` had already run. Skipping here — before the clone
            //    and before the bbox test — is the whole point of the method.
            //
            //    A FILLED way is skipped on the same terms once the fill lane has it,
            //    and that is where the saving now is: with the ear clip cached, what
            //    was left of the fill frame was this clone plus one `tan` and one
            //    `ln` per vertex in `MapView::project` — 7.99 ms of a 10.08 ms
            //    fills-only frame, MEASURED with the mesh and the outline both
            //    suppressed. `MapFacet::gpu_lane_fills` applies the SAME predicate on
            //    the bake side (a cached triangulation, ≥ 3 vertices), so the two
            //    sides cannot disagree about which lakes are on the device.
            if filled && on_gpu.fills && MapFacet::gpu_fillable(w) {
                continue;
            }
            if on_gpu.lines && !filled {
                continue;
            }
            if !Self::draws(w, view, bb) {
                continue;
            }
            // The layer's SHARED ring `Arc`s — a pointer bump per frame, not a
            // re-clone of every vertex (see `shared_geom` / `DrawGeom::Line`).
            let (outer, holes) = self.shared_geom(i, w);
            out.push(DrawFeature {
                id: w.osm_id,
                geom: if w.style.kind == crate::WayKind::Glow {
                    // A trail is a RIBBON, not a stroke — see `glow_ribbon_mesh`. The
                    // way's width is the hot core's; the ladder scales the halo off it.
                    DrawGeom::Glow {
                        path: outer,
                        core_w: w.style.width,
                    }
                } else if filled {
                    // Carry the inner (hole) rings through to the renderer so a
                    // lake-with-island / donut landuse fills outer MINUS holes
                    // (they were dropped here before, filling the island over).
                    DrawGeom::Polygon { outer, holes }
                } else {
                    DrawGeom::Line(outer)
                },
                color: w.style.color,
                width: w.style.width,
                fill: w.fill,
                // ONE declared order, computed from the WAY — its class, its OSM
                // `layer`/`bridge`/`tunnel`, its LOD tier and its area — never from
                // its index in this vector. `crate::paint_order::way_paint_key` is
                // the same writer the GPU lanes sort on.
                order: crate::paint_order::way_paint_key(w),
                // The cached ear clip, computed on THIS way's Mercator ring the first
                // time it is painted. Only fills need one; a line has no mesh.
                tri: if filled { self.fill_tess.tris(i, &w.lonlat, &w.holes) } else { None },
                lod: w.style.lod,
                dash: w.style.dashed(),
            });
        }
    }
    fn style(&self) -> &LayerStyle {
        &self.style
    }
    fn enabled(&self) -> bool {
        self.enabled
    }
    fn set_enabled(&mut self, on: bool) {
        self.enabled = on;
    }
    /// Count through [`WaysLayer::draws`] — the SAME predicate
    /// [`features_into`](Self::features_into) draws through — so the number
    /// `state_json` publishes is the number the painter would produce, and neither
    /// `w.lonlat` nor `w.holes` is cloned to obtain it.
    fn feature_count(&self, view: &MapView) -> usize {
        let bb = view.merc_bbox();
        self.ways.iter().filter(|w| Self::draws(w, view, bb)).count()
    }
    /// **The one layer kind the GPU line lane can consume.** Handing the `Way`s over
    /// (not the lossy [`DrawFeature`]s) is what lets [`MapFacet`] route them through
    /// `facett_map::gpu::GpuMapRenderer` instead of tessellating them on one core.
    fn gpu_ways(&self) -> Option<&[Way]> {
        Some(&self.ways[..])
    }
    /// The same ways, paired with the ear-clip cache the GPU **fill** lane bakes off.
    /// One slice, one cache, built together in [`WaysLayer::new`] — so the index a
    /// `Way` has here is the index its triangulation has there, structurally.
    fn gpu_fills(&self) -> Option<(&[Way], &tessellate::FillTessCache)> {
        Some((&self.ways[..], &self.fill_tess))
    }
    fn ear_clips(&self) -> usize {
        self.fill_tess.ear_clips()
    }
    fn geometry_generation(&self) -> u64 {
        self.generation
    }
}

/// A **points** layer — a korp-collector's geo output overlaid on the base map.
/// Holds `(lon, lat)` points and yields one point [`DrawFeature`] each (culled to the
/// viewport, and gated by the layer's [`Lod`] tier — see [`PointsLayer::with_lod`]).
pub struct PointsLayer {
    id: String,
    points: Vec<(f64, f64)>,
    /// The tier the whole layer belongs to — the LOD discipline its sibling
    /// [`WaysLayer`] always had and this layer did not.
    lod: Lod,
    style: LayerStyle,
    enabled: bool,
}

impl PointsLayer {
    /// A points layer at the [`Lod::Country`] tier — drawn at **every** zoom, which is
    /// what a small set of hand-placed markers (a collector's boats, korp's own hits)
    /// wants. A BASEMAP node payload wants [`with_lod`](Self::with_lod) instead.
    ///
    /// **The payload is put in CANONICAL order at the door** — sorted by position
    /// (`total_cmp`, so NaN cannot poison the order), and each point's feature id is its
    /// index in that order. A bare `(lon, lat)` carries no osm id, so its position is
    /// the only identity it has; imposing it here is what makes "swap the decode order,
    /// get identical pixels" true for points the way [`crate::paint_order`] made it true
    /// for ways. Before this every point went out as `id: 0` with the identical
    /// [`PaintKey`](crate::paint_order::PaintKey) — one layer-wide tie the stable sort
    /// resolved by INPUT order, i.e. by tile-fetch interleaving: the unsorted-fills
    /// defect, on the point half.
    pub fn new(id: impl Into<String>, mut points: Vec<(f64, f64)>, color: Color32) -> Self {
        points.sort_unstable_by(|a, b| a.0.total_cmp(&b.0).then_with(|| a.1.total_cmp(&b.1)));
        Self {
            id: id.into(),
            points,
            lod: Lod::Country,
            style: LayerStyle::new(LayerKind::Point, color, 2.5),
            enabled: true,
        }
    }
    /// **Tier the layer**, so it declutters by zoom like every other layer.
    ///
    /// Points are the one layer kind with no per-feature tier — a raw `(lon, lat)`
    /// carries no tags to classify — so the tier is a property of the LAYER, declared
    /// by the host that knows what the payload is. `Lod::City` is the honest tier for
    /// an OSM basemap node dump: 40 000 untagged nodes are city-scale detail, and
    /// before this existed they painted over a whole country at every zoom while the
    /// ways beside them culled to 19.6 % of their payload.
    ///
    /// A count cap would be the wrong knob — it drops arbitrary places. This drops
    /// *detail*, and gives it all back on zoom-in.
    #[must_use]
    pub fn with_lod(mut self, lod: Lod) -> Self {
        self.lod = lod;
        self
    }
    /// The tier this layer draws at.
    pub fn lod(&self) -> Lod {
        self.lod
    }
    /// The payload, in the layer's canonical (position-sorted) order — the order
    /// feature ids index into, not the order the constructor was handed.
    pub fn points(&self) -> &[(f64, f64)] {
        &self.points
    }

    /// **THE per-point visibility test — the layer LOD tier plus the viewport cull.**
    /// One writer, for the same reason [`WaysLayer::draws`] is one: the painter and the
    /// counter must agree, and a `state_json` count that culled differently would be a
    /// number about a different map.
    fn draws(&self, (lon, lat): (f64, f64), [x0, y0, x1, y1]: [f64; 4]) -> bool {
        let (x, y) = mercator(lon, lat);
        x >= x0 && x <= x1 && y >= y0 && y <= y1
    }
}

impl MapLayer for PointsLayer {
    fn id(&self) -> &str {
        &self.id
    }
    fn kind(&self) -> LayerKind {
        LayerKind::Point
    }
    fn features(&self, view: &MapView) -> Vec<DrawFeature> {
        // LOD gate FIRST — the same [`lod_visible`] `WaysLayer` and `LabelLayer` use.
        // Without it this layer culled on the viewport bbox ONLY, so an OSM basemap
        // node payload drew every one of its 40 000 dots at country zoom and
        // out-covered the entire way payload on the canvas (1.93 % of pixels vs the
        // ways' ≲1.8 %). Bailing before the per-point projection also makes the
        // decluttered case free rather than merely invisible.
        if !lod_visible(self.lod, view) {
            return Vec::new();
        }
        let bb = view.merc_bbox();
        self.points
            .iter()
            // Enumerated BEFORE the cull, so a point's id is its index in the layer's
            // canonical order (see `new`) — a stable identity whatever the viewport,
            // not a per-frame count of whatever happened to be visible. It doubles as
            // the `PaintKey` tie-break, so two points with the same band and tier stack
            // the same way in every frame and every process instead of by decode order.
            .enumerate()
            .filter(|&(_, &p)| self.draws(p, bb))
            .map(|(i, &(lon, lat))| DrawFeature {
                id: i as i64,
                geom: DrawGeom::Point(LatLon::new(lat, lon)),
                color: self.style.color,
                width: self.style.width,
                fill: None,
                order: crate::paint_order::PaintKey::simple(
                    crate::paint_order::POINT_BAND,
                    self.lod,
                    i as i64,
                ),
                // A point has no ring to tessellate.
                tri: None,
                lod: self.lod,
                dash: false,
            })
            .collect()
    }
    fn style(&self) -> &LayerStyle {
        &self.style
    }
    fn enabled(&self) -> bool {
        self.enabled
    }
    fn set_enabled(&mut self, on: bool) {
        self.enabled = on;
    }
    /// Count through [`PointsLayer::draws`], allocating nothing. A served basemap node
    /// payload is 40 000 points, and `severity()` asked for all 40 000 `DrawFeature`s
    /// every frame to learn whether the number was zero.
    fn feature_count(&self, view: &MapView) -> usize {
        if !lod_visible(self.lod, view) {
            return 0;
        }
        let bb = view.merc_bbox();
        self.points.iter().filter(|&&p| self.draws(p, bb)).count()
    }
}

// ── LabelLayer — the name/ref text pass (the biggest Garmin-feel lever) ──────

/// The z-order floor for the **landmass base fill** — below every other feature
/// (raster `0`, polygon `10`, line `20`, point `30`, label `40`) so the land
/// silhouette paints *first*, under water/landuse/roads. A dedicated constant (not
/// [`LayerKind::Raster`]'s `0`) so a future aerial-raster base can still slot under
/// the land and the ordering stays unambiguous.
pub const LANDMASS_Z: i32 = crate::paint_order::LANDMASS_BAND;

/// One resolved label placement: a representative anchor point, the text, and the
/// [`Lod`] tier it first appears at (the declutter gate). Built from a feature's
/// `(anchor, tags)` via [`classify_label`], or from a decoded [`Way`]'s road name.
#[derive(Clone, Debug)]
pub struct LabelPlacement {
    /// Stable feature id (osm id / row id) for hit-testing.
    pub id: i64,
    /// Where the label anchors (a place node, or a way's centroid).
    pub at: LatLon,
    /// The text painted (a route `ref` shield, else a place/street `name`).
    pub text: String,
    /// The zoom tier this label first appears at (country → region → city).
    pub lod: Lod,
    /// Importance within the tier (`0..=1`) — see [`crate::style::LabelClass::rank`].
    pub rank: f32,
    /// The labelled feature's Mercator-unit extent, `0.0` for a point label.
    pub merc_len: f64,
    /// The anchor pre-projected to Mercator-unit space (for viewport culling).
    merc: (f64, f64),
}

/// **The name/ref LABEL layer — the single biggest Garmin-feel lever.** A text pass
/// that renders road refs + place/street `name`s from the OSM `name`/`ref` tags the
/// tiles carry, **LOD-gated** (country → region → city) so labels declutter by zoom
/// exactly like the way/fill layers already do (the [`Lod`] style law + the CPU
/// [`view_lod_tier`] gate). Stackable + selectable like every other [`MapLayer`];
/// paints on top ([`LayerKind::Label`], z `40`).
pub struct LabelLayer {
    id: String,
    labels: Vec<LabelPlacement>,
    style: LayerStyle,
    enabled: bool,
}

/// Centroid (arithmetic mean) of a `(lon, lat)` vertex loop — a good-enough label
/// anchor for a way (a road's midpoint, an area's middle). Empty ⇒ `(0, 0)`.
fn centroid_lonlat(pts: &[(f64, f64)]) -> LatLon {
    if pts.is_empty() {
        return LatLon::new(0.0, 0.0);
    }
    let (mut sx, mut sy) = (0.0f64, 0.0f64);
    for &(lon, lat) in pts {
        sx += lon;
        sy += lat;
    }
    let n = pts.len() as f64;
    LatLon::new(sy / n, sx / n)
}

impl LabelLayer {
    fn label_style() -> LayerStyle {
        LayerStyle { color: Color32::GRAY, width: 0.0, z: LayerKind::Label.default_z(), kind: LayerKind::Label }
    }

    /// Build a label layer from **tagged features**: each `(id, anchor, tags)` whose
    /// tags carry a labellable `name`/`ref` ([`classify_label`]) becomes one
    /// LOD-gated label; features with no label tag are skipped. This is the primary
    /// path — it consumes the raw `name`/`ref`/`place` tags the tiles carry.
    pub fn from_tagged(
        id: impl Into<String>,
        feats: impl IntoIterator<Item = (i64, LatLon, Vec<(String, String)>)>,
    ) -> Self {
        let labels = feats
            .into_iter()
            .filter_map(|(fid, at, tags)| {
                classify_label(&tags).map(|lc| {
                    let (mx, my) = mercator(at.lon, at.lat);
                    // A tagged feature is a POINT here (a place node, a `ref` shield):
                    // it has no extent of its own, so the "does the name fit along the
                    // thing it names" rule does not apply and `merc_len` is 0.
                    LabelPlacement { id: fid, at, text: lc.text, lod: lc.lod, rank: lc.rank, merc_len: 0.0, merc: (mx, my) }
                })
            })
            .collect();
        Self { id: id.into(), labels, style: Self::label_style(), enabled: true }
    }

    /// Convenience: harvest labels from an already-decoded [`Way`] set — each way's
    /// road `name` (roads are the only kind that keep it) placed at the way's
    /// centroid. Lets a host that already built a [`WaysLayer`] surface street-name
    /// labels with **no re-parse** (the korp path).
    ///
    /// # The label's tier is NOT the way's tier (`streetlabel-1`)
    ///
    /// Every label here is a street NAME, and a street name is [`Lod::City`] by design —
    /// [`classify_label`] says so for the tagged path (*"its street `name` at
    /// `Lod::City` (street names only once zoomed in)"*), and this crate's own
    /// `features()` doc says so for the draw (*"street names only in the city tier"*).
    /// This constructor used to inherit `w.style.lod` instead — and a motorway or
    /// primary road is `Lod::Country` — so "Pildammsvägen", "Skolgatan" and every
    /// roundabout name on an arterial painted on a WHOLE-SWEDEN pane. MEASURED on
    /// The owner's live client 2026-09-01 at camera 6.7: `labels` fed 37 492, drawn 3 304
    /// — thousands of street names on a country view. So the way's tier is a FLOOR ONLY
    /// (a residential street stays City); the label never rises above [`Lod::City`].
    /// The way's tier still feeds [`label_rank_of_style`], so a motorway's name beats a
    /// cul-de-sac's for a contested pixel once the City rung opens.
    pub fn from_ways(id: impl Into<String>, ways: &[Way]) -> Self {
        let labels = ways
            .iter()
            .filter(|w| !w.name.is_empty())
            .map(|w| {
                let at = centroid_lonlat(&w.lonlat);
                let (mx, my) = mercator(at.lon, at.lat);
                // The way's own bbox, already decoded — its longer side is the extent
                // the name has to fit along. `w.bbox` is `[x0, y0, x1, y1]` in
                // Mercator units, the same space `MapView::world_px` scales to pixels.
                let merc_len = (w.bbox[2] - w.bbox[0]).max(w.bbox[3] - w.bbox[1]).max(0.0);
                LabelPlacement {
                    id: w.osm_id,
                    at,
                    text: w.name.clone(),
                    // City-FLOORED, never the raw way tier: a motorway way is
                    // `Lod::Country`, its NAME is a street name (`streetlabel-1`,
                    // see the constructor doc). `from_u32` saturates, so a
                    // `Detail`-tier way keeps its own deeper tier.
                    lod: Lod::from_u32((w.style.lod as u32).max(Lod::City as u32)),
                    // The SAME rank `classify_label` gives a tagged way, resolved from
                    // the way's already-classified style — no re-parse, no second table.
                    rank: crate::style::label_rank_of_style(&w.style),
                    merc_len,
                    merc: (mx, my),
                }
            })
            .collect();
        Self { id: id.into(), labels, style: Self::label_style(), enabled: true }
    }

    /// Total labels carried (undecimated; before any LOD/viewport gate).
    pub fn label_count(&self) -> usize {
        self.labels.len()
    }
}

impl MapLayer for LabelLayer {
    fn id(&self) -> &str {
        &self.id
    }
    fn kind(&self) -> LayerKind {
        LayerKind::Label
    }
    fn features(&self, view: &MapView) -> Vec<DrawFeature> {
        let [x0, y0, x1, y1] = view.merc_bbox();
        self.labels
            .iter()
            // LOD gate (the declutter law): a label draws only once the viewport has
            // zoomed to at least its tier — country labels everywhere, street names
            // only in the city tier. The SAME [`lod_visible`] every other layer uses.
            .filter(|l| lod_visible(l.lod, view))
            // Viewport cull: drop anchors outside the pane envelope.
            .filter(|l| l.merc.0 >= x0 && l.merc.0 <= x1 && l.merc.1 >= y0 && l.merc.1 <= y1)
            .map(|l| DrawFeature {
                id: l.id,
                geom: DrawGeom::Label { at: l.at, text: l.text.clone(), rank: l.rank, merc_len: l.merc_len },
                color: self.style.color,
                width: 0.0,
                fill: None,
                order: crate::paint_order::PaintKey::simple(
                    crate::paint_order::LABEL_BAND,
                    l.lod,
                    l.id,
                ),
                // A label has no ring to tessellate.
                tri: None,
                // The tier the collision placer ranks by: a country/region place name
                // outranks a city street name for a contested pixel.
                lod: l.lod,
                dash: false,
            })
            .collect()
    }
    fn style(&self) -> &LayerStyle {
        &self.style
    }
    fn enabled(&self) -> bool {
        self.enabled
    }
    fn set_enabled(&mut self, on: bool) {
        self.enabled = on;
    }
}

// ── LandmassLayer — the base land fill, UNDER everything (land vs sea) ────────

/// One land polygon: an outer `(lon, lat)` ring (+ optional hole rings) with its
/// Mercator-unit bbox precomputed for viewport culling.
struct LandPoly {
    /// `Arc`'d for the same reason as [`DrawGeom::Line`]: the per-frame feature
    /// build shares these rings instead of re-cloning a country silhouette's
    /// vertices every frame.
    outer: std::sync::Arc<Vec<(f64, f64)>>,
    holes: std::sync::Arc<Vec<Vec<(f64, f64)>>>,
    bbox: [f64; 4],
}

/// **The landmass base fill.** Fills a country's landmass polygon(s) *under* all
/// other layers ([`LANDMASS_Z`]) so land-vs-sea reads instantly — the fix for a
/// pane that was empty (bare paper) between features. A base fill beneath
/// water/landuse/roads; shows at every zoom (it is the ground, not LOD-gated).
/// Feed it Sweden's län rings (`facett_geomap::AdminBoundaries`) or any land ring
/// set. Stackable + selectable like every other [`MapLayer`].
pub struct LandmassLayer {
    id: String,
    polys: Vec<LandPoly>,
    fill: Color32,
    style: LayerStyle,
    enabled: bool,
    /// The ear clip of every land ring, one slot per `polys` index — see
    /// [`tessellate::FillTessCache`]. This is the layer that carries the single
    /// biggest ring on the pane (a country silhouette), so it is the one polygon
    /// where the O(n²) clip hurts most per feature.
    fill_tess: tessellate::FillTessCache,
}

/// Mercator-unit bbox `[x0, y0, x1, y1]` of a `(lon, lat)` ring.
fn merc_bbox_of(ring: &[(f64, f64)]) -> [f64; 4] {
    let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
    for &(lon, lat) in ring {
        let (x, y) = mercator(lon, lat);
        x0 = x0.min(x);
        y0 = y0.min(y);
        x1 = x1.max(x);
        y1 = y1.max(y);
    }
    [x0, y0, x1, y1]
}

impl LandmassLayer {
    /// Build the base fill from land **rings** (each a `(lon, lat)` outer loop),
    /// filled `fill`. Rings with fewer than 3 vertices (which can't be a polygon)
    /// are skipped, so degenerate input never reaches the tessellator.
    pub fn from_rings(
        id: impl Into<String>,
        rings: impl IntoIterator<Item = Vec<(f64, f64)>>,
        fill: Color32,
    ) -> Self {
        let polys = rings
            .into_iter()
            .filter(|r| r.len() >= 3)
            .map(|outer| {
                let bbox = merc_bbox_of(&outer);
                LandPoly {
                    outer: std::sync::Arc::new(outer),
                    holes: std::sync::Arc::new(Vec::new()),
                    bbox,
                }
            })
            .collect::<Vec<LandPoly>>();
        Self {
            id: id.into(),
            fill_tess: tessellate::FillTessCache::new(polys.len()),
            polys,
            fill,
            style: LayerStyle { color: fill, width: 0.0, z: LANDMASS_Z, kind: LayerKind::Polygon },
            enabled: true,
        }
    }

    /// Number of land polygons carried.
    pub fn polygon_count(&self) -> usize {
        self.polys.len()
    }

    /// **How many ear clips this layer has ever run** — see [`WaysLayer::ear_clips`].
    #[must_use]
    pub fn ear_clips(&self) -> usize {
        self.fill_tess.ear_clips()
    }
}

impl MapLayer for LandmassLayer {
    fn id(&self) -> &str {
        &self.id
    }
    fn kind(&self) -> LayerKind {
        LayerKind::Polygon
    }
    fn features(&self, view: &MapView) -> Vec<DrawFeature> {
        let [x0, y0, x1, y1] = view.merc_bbox();
        self.polys
            .iter()
            .enumerate()
            // Viewport cull: skip land polygons whose bbox misses the pane envelope.
            .filter(|(_, p)| !(p.bbox[2] < x0 || p.bbox[0] > x1 || p.bbox[3] < y0 || p.bbox[1] > y1))
            .map(|(i, p)| DrawFeature {
                id: i as i64,
                geom: DrawGeom::Polygon { outer: p.outer.clone(), holes: p.holes.clone() },
                // A base fill has no outline — stroke matches the fill so the
                // renderer's hairline is invisible against the land itself.
                color: self.fill,
                width: 0.0,
                fill: Some(self.fill),
                // The floor: under water/landuse/roads/labels (LANDMASS_Z ≪ all), and
                // largest-land-first inside the band so two overlapping silhouettes
                // do not stack by decode order.
                order: crate::paint_order::PaintKey::area(
                    LANDMASS_Z,
                    ((p.bbox[2] - p.bbox[0]) * (p.bbox[3] - p.bbox[1])) as f32,
                    i as i64,
                ),
                tri: self.fill_tess.tris(i, &p.outer, &p.holes),
                // The ground is not LOD-gated — it draws at every zoom.
                lod: Lod::Country,
                dash: false,
            })
            .collect()
    }
    fn style(&self) -> &LayerStyle {
        &self.style
    }
    fn enabled(&self) -> bool {
        self.enabled
    }
    fn set_enabled(&mut self, on: bool) {
        self.enabled = on;
    }
    /// **This** is the layer that declares the land — the one `true` in the tree.
    fn is_landmass(&self) -> bool {
        true
    }
    fn ear_clips(&self) -> usize {
        self.fill_tess.ear_clips()
    }
}

// ── MapFacet — the facet: Vec<Box<dyn MapLayer>> + one shared renderer ───────

/// **Everything [`MapFacet::features_for_cpu`]'s answer depends on**, as one value.
///
/// A memo is only as good as its key, and a draw list served for the wrong camera is a
/// worse defect than any amount of work it saves — the pane would keep painting the viewport
/// the user panned away from. So the key is written as the *closure* of the build's
/// inputs, not as the subset today's four in-crate layers happen to read:
///
/// * **`epoch`** — [`MapFacet::layer_epoch`], bumped by every `&mut self` op that can
///   change the layer roster (`push_layer`, `toggle_layer`, `set_layer_enabled`). The
///   ways *inside* a layer never mutate — korp replaces the layer on data change — so
///   "same epoch" ⇒ "same layers reading the same geometry". Exactly the promise
///   `lane_plan_memo` and `gpu_sig_memo` already hang off.
/// * **`view`** — the WHOLE [`MapView`], by value (it is `Copy` + `PartialEq`).
///   [`MapLayer::features_into`] is handed the camera and nothing narrower is safe:
///   `merc_bbox` reads `center`, `zoom` and `size_px`, [`lod_visible`] reads `zoom`
///   again, and `bearing_deg`/`tilt_deg` are readable by any implementor of a PUBLIC
///   trait even though none of the four layers in this crate reads them today. Keying
///   the struct rather than three of its five fields makes the memo correct for a
///   downstream layer by construction instead of by a comment nobody will re-read.
///   (`tests/draw_list_memo.rs` pins the bearing arm with a layer that does read it.)
/// * **`gpu_layers` / `gpu_owns`** — the lane's share of the frame. Two frames at the
///   same camera and epoch hand the CPU painter *different* lists when the GPU lane
///   takes a layer, which is exactly what `way_lane_plan` re-decides per frame.
///
/// `MapView` compares `f32`/`f64` fields, so a NaN camera never equals itself and the
/// memo simply rebuilds — it fails toward correctness, never toward a stale list.
///
/// What is deliberately NOT here: [`MapTheme`]. No layer's `features_into` reads it (a
/// `Way` carries its own resolved style and a `LabelLayer` its own), so `set_theme`
/// cannot change this answer. `MapFacet::place_map_labels` *does* read `theme.label` —
/// which is one of the reasons that pass is not memoised on this key.
#[derive(Clone, PartialEq, Debug)]
struct DrawListKey {
    epoch: u64,
    view: MapView,
    gpu_layers: Vec<usize>,
    gpu_owns: GpuOwns,
}

/// The [`MapFacet::way_lane_plan`] memo entry: the layer epoch and renderer latch it
/// was computed under, then the plan itself.
#[cfg(feature = "wgpu")]
type LanePlanMemo = (u64, bool, WayLane, Vec<usize>, GpuOwns, Option<i32>);

/// The [`MapFacet::feature_counts`] memo entry: its `(epoch, view)` key and the counts.
type FeatureCountMemo = ((u64, MapView), std::sync::Arc<Vec<(String, usize)>>);

/// The facet: a **stack** of [`MapLayer`]s over one [`MapView`] camera, painted by
/// **one** source-agnostic renderer. Toggling a layer's `enabled` = **selecting**
/// which data draws; the layers are painted z-ordered into the egui frame through
/// the rotated + tilted [`MapView::project`]. Nothing renderer-side is source-aware.
pub struct MapFacet {
    layers: Vec<Box<dyn MapLayer>>,
    view: MapView,
    theme: MapTheme,
    title: String,
    // ── idle auto-rotate (screensaver-style): when the user hasn't touched the
    // pane for `idle_threshold_s`, spin the camera `bearing_deg` at
    // `rotate_speed_deg_s`. Any interaction resets the timer + stops the spin.
    // Off by default. Drivable/observable in code (tick + state_json) so a robot
    // scenario proves it headlessly.
    auto_rotate: bool,
    idle_threshold_s: f32,
    idle_elapsed_s: f32,
    rotate_speed_deg_s: f32,
    /// **The GPU upload-once latch**, as a *signature* of the way set last handed to
    /// the GPU rather than a bare "uploaded" bool.
    ///
    /// `MapView`'s latch is a plain `AtomicBool` because its ways never change. A
    /// `MapFacet`'s do — `set_layer_enabled` / `toggle_layer` change which layers
    /// contribute without rebuilding the facet — and a bool latch would leave a
    /// switched-off layer's roads painted on the GPU forever, with the CPU painter
    /// correctly not drawing them. So the latch stores a cheap O(layers + ways) hash
    /// of the enabled way set + origin; a mismatch re-bakes. `0` = nothing sent.
    ///
    /// Interior-mutable so the whole GPU path stays `&self` (FC-9): it is an
    /// ephemeral VRAM cache, not Model state, and it is deliberately outside
    /// [`MapView`] so the camera stays a pure value.
    #[cfg(feature = "wgpu")]
    gpu_geom_sig: std::sync::atomic::AtomicU64,
    /// **The upload ACK**: `crate::gpu::lane_frames()` as it stood when
    /// [`gpu_geom_sig`](Self::gpu_geom_sig) was last handed over. The payload counts as
    /// landed in VRAM only once the lane's frame counter has moved PAST this — i.e.
    /// once `MapPaintCallback::prepare` really ran against a device.
    ///
    /// This is not belt-and-braces, it is the difference between working and not.
    /// egui **discards** frames — a sizing pass is one — and the first cut of this GPU
    /// path latched on the handover, so the geometry rode a discarded sizing frame,
    /// `geom_ready` stayed false, every later callback carried `None`, and the lane
    /// measured **0 frames on an RTX 4090**: the exact `installed_unused` state this
    /// whole change exists to end, reproduced one level down. Latching on the ack
    /// makes a discarded frame simply re-send.
    #[cfg(feature = "wgpu")]
    gpu_geom_sent_at: std::sync::atomic::AtomicU64,
    /// **The layer-set epoch** — bumped by every `&mut self` operation that can change
    /// what [`way_lane_plan`](Self::way_lane_plan) or
    /// [`gpu_geometry_signature`](Self::gpu_geometry_signature) would read: a layer
    /// pushed, toggled or enabled/disabled. The ways *inside* a layer never mutate
    /// ([`WaysLayer`] builds them beside its caches and korp replaces the layer on
    /// data change), so "same epoch" ⇒ "same layer roster reading the same
    /// geometry" — the whole key both per-frame memos below hang off.
    layer_epoch: u64,
    /// **The [`way_lane_plan`](Self::way_lane_plan) memo** — the plan re-scanned every
    /// way of every layer twice per frame (`.any()` for lines, `.any()` for fills) to
    /// re-derive an answer that only changes with [`layer_epoch`](Self::layer_epoch)
    /// or the renderer-installed latch. Keyed on exactly those two. Interior-mutable
    /// for the same FC-9 reason as `gpu_geom_sig`: an ephemeral derivation cache, not
    /// Model state.
    #[cfg(feature = "wgpu")]
    lane_plan_memo: std::sync::Mutex<Option<LanePlanMemo>>,
    /// How many times the full lane-plan way scan actually ran — the measurement seam
    /// (see [`MapLayer::ear_clips`] for why a cache claim needs one: work that did
    /// NOT happen is invisible in the output). Memoised ⇒ stops at the number of
    /// distinct (epoch, installed) states; broken memo ⇒ climbs every frame.
    #[cfg(feature = "wgpu")]
    lane_scans: std::sync::atomic::AtomicUsize,
    /// **The [`gpu_geometry_signature`](Self::gpu_geometry_signature) memo**
    /// (`(key, signature)`): the signature walked every taken way's bbox twice per
    /// frame to answer "did VRAM's geometry change?" — an `O(ways)` hash whose inputs
    /// are pinned by `(origin, gpu_layers, each layer's geometry_generation)`, all
    /// `O(layers)` to key. A taken layer with generation `0` (no immutability
    /// promise) bypasses the memo entirely, so the old full walk remains the
    /// fallback semantics.
    #[cfg(feature = "wgpu")]
    gpu_sig_memo: std::sync::Mutex<Option<(u64, u64)>>,
    /// **The TILE lane's per-apply GROUP PLAN** — `(memo key, the band-major ordered
    /// list of `(band, tile, content key, bake origin)`)`.
    ///
    /// It used to memoise only the content KEYS, and the frame still rebuilt the whole
    /// grouping around them: `add_gpu_tiled_callback` walked every way of every taken
    /// layer TWICE into a fresh `HashMap`, collected the keys into a `Vec` and sorted
    /// it — **every frame**, for a still screen as much as for a pan. That is the
    /// `O(ways)` per frame the owner's own words name: *"the lines must be PUT in the GPU
    /// and THEN not manipulated from egui"*. Nothing in that grouping depends on the
    /// camera: the group id is `(draw_band, tile_key)`, both read off the way, and the
    /// bake origin is `ways_origin` over the group's own ways. Only the per-group
    /// `MapTransform` and its rebased viewport are camera-dependent, and those are
    /// `O(groups)`.
    ///
    /// So the plan is pinned by the same `(epoch, layers, generations)` key as
    /// [`gpu_geometry_signature`](Self::gpu_geometry_signature)'s memo and under the
    /// same by-construction contract (a taken layer's ways are immutable for its
    /// lifetime; korp replaces the LAYER on data change), and the frame walks
    /// `O(groups)`. MEASURED on the real device (RTX 4090, Vulkan, `DiscreteGpu`) over a
    /// korp-shaped 640-way / 480-group scene (`tests/gpu_lane_share.rs`): grouped way
    /// walks **2 over 7 panned frames and 2 over 23** — flat, where the count used to be
    /// one per frame. The RED arm in the same test toggles a layer every frame and gets
    /// **9 over 7 and 25 over 23**, i.e. it still tracks the frames.
    ///
    /// **Two, not one, and the second is honest.** The plan build is walk 1. The device
    /// does not mark a key `known` until the payload has actually reached it, so the
    /// first later frame that hits the plan memo can still owe bytes for some group and
    /// has to gather its ways — walk 2. It is a constant of the APPLY, not of the frame
    /// count, which is why the guard compares two frame counts rather than asserting a
    /// single number.
    ///
    /// A group's WAYS are re-gathered only on a frame that actually has a payload to
    /// bake (`crate::gpu::tile_payloads_needed`), which after the first frames over a
    /// patch of ground is no frame at all — the same "a pan over cached ground uploads
    /// NOTHING" the tile cache already delivered, now with the CPU side of it too.
    #[cfg(feature = "wgpu")]
    tile_plan_memo: std::sync::Mutex<Option<(u64, std::sync::Arc<Vec<TileGroupPlan>>)>>,
    /// How many times the tile lane's full `O(ways)` grouping walk actually ran — the
    /// measurement seam for `tile_plan_memo`, same argument as `lane_scans`: the memo's
    /// whole claim is about work that did NOT happen, and the pixels are identical
    /// either way.
    #[cfg(feature = "wgpu")]
    tile_group_walks: std::sync::atomic::AtomicUsize,
    /// How many times the WHOLE-LANE `ways_origin` min-fold ran — the measurement seam
    /// for moving `gpu_lane_needs_grouping` in front of it in
    /// [`Self::add_gpu_ways_callback`]. It is `O(ways)` and the GROUPED path discards
    /// its answer (each group bakes against its own origin), so on korp's path — every
    /// served way carries a producer stamp — this must now stay at 0 no matter how many
    /// frames are drawn, and on the legacy unstamped path it must still count one per
    /// frame. Same argument as `lane_scans`: the claim is about work that did NOT
    /// happen, and the pixels are identical either way.
    #[cfg(feature = "wgpu")]
    lane_origin_walks: std::sync::atomic::AtomicUsize,
    /// How many times the full `O(ways)` signature walk actually ran — the
    /// measurement seam for the memo above, same argument as `lane_scans`.
    #[cfg(feature = "wgpu")]
    gpu_sig_walks: std::sync::atomic::AtomicUsize,
    /// **How many times the LABEL LAYOUT actually ran** — lane GG #11's measurement
    /// seam, and it exists before any fix because the audit's claim about it had never
    /// been checked.
    ///
    /// Audit #11 says `place_map_labels` *"sorts label_idx and lays out labels EVERY
    /// frame, 0.96–9.45 ms — the dominant still-frame CPU cost"*. That is a claim about
    /// work that DID happen, and the only honest way to hold it is to count the calls:
    /// a memo's whole value is work that does not happen, and work that does not happen
    /// is invisible in the output. Same argument as `lane_scans` and `gpu_sig_walks`,
    /// which is why it sits beside them rather than in a new mechanism.
    ///
    /// Not `cfg(wgpu)`: labels are laid out by the CPU painter too, and a thin
    /// `korp-ui-cpu` is exactly the build where a per-frame shaping pass hurts most.
    label_layouts: std::sync::atomic::AtomicUsize,
    /// **The CPU draw-list memo** — `(key, the built + sorted feature list)`.
    ///
    /// The third memo hanging off [`layer_epoch`](Self::layer_epoch), and the biggest:
    /// [`features_for_cpu`](Self::features_for_cpu) walked every enabled layer, built a
    /// fresh `Vec<DrawFeature>` and re-sorted the whole thing on **every frame**, for a
    /// still screen as much as for a pan.
    ///
    /// **The result is stated as WORK ELIMINATED, not as a wall-clock delta.** Absolute
    /// frame times on this box are contention-dominated — lane BENCH measured the same
    /// binary on the same fixture at 15.91 ms and 36.78 ms four minutes apart, with the
    /// load average ranging 39–262 on 32 cores — so a millisecond figure here would be a
    /// measurement of oden's queue, not of this change. The work count is deterministic:
    /// over 501 identical asks of the 20 000-way fixture in `tests/draw_list_memo.rs`,
    ///
    /// | | before | after |
    /// |---|---:|---:|
    /// | layer walks (`features_into` passes) | 501 | **1** |
    /// | `way_paint_key` computations / features materialised | 10 020 000 | **20 000** |
    /// | `sort_by_key` passes over the whole list | 501 | **1** |
    ///
    /// — one build per *distinct* frame instead of one per frame. Timings, when quoted,
    /// carry their median, their range and the box load, and say they are
    /// contention-dominated (see the test's own header).
    ///
    /// Interior-mutable for exactly the FC-9 reason `gpu_sig_memo` and `lane_plan_memo`
    /// give: an ephemeral derivation cache, not Model state. Always compiled — unlike
    /// its two siblings this is the pure-CPU painter's own cost and exists whether or
    /// not the `wgpu` feature is on.
    ///
    /// It holds an [`Arc`](std::sync::Arc), not a `Vec`, because the memo has to serve
    /// the list without copying it: a hit must not re-allocate 20 000 `DrawFeature`s and
    /// bump every ring `Arc` to hand back a copy of a list nothing mutates.
    /// [`MapFacet::ui`] paints straight out of the `Arc`.
    ///
    /// **THE KEY IS THE WHOLE ANSWER'S INPUT SET** — see [`DrawListKey`].
    draw_list_memo: std::sync::Mutex<Option<(DrawListKey, std::sync::Arc<Vec<DrawFeature>>)>>,
    /// How many times the full walk + sort actually ran — the measurement seam, same
    /// argument as `lane_scans`: a memo is a claim about work that did NOT happen and
    /// the painted frame is identical either way, so the counter is the only honest
    /// oracle. Still frames ⇒ stays at 1; a broken memo ⇒ climbs once per frame.
    ///
    /// One build = one pass over every enabled layer + one `sort_by_key` over the result,
    /// so this IS the walk count and the sort count.
    draw_list_builds: std::sync::atomic::AtomicUsize,
    /// **How many `DrawFeature`s have ever been materialised** by that walk — one
    /// `way_paint_key` (or `PaintKey::simple`/`::area`) computation, one `shared_geom`
    /// pointer bump and one `Vec` push each, and one comparand in the sort.
    ///
    /// The build count alone under-describes the saving: a facet with one tiny layer and
    /// a facet with 20 000 ways both count `1`. This is the per-frame VOLUME, and like
    /// its sibling it is deterministic — unlike a millisecond on a box whose load average
    /// ranged 39–262 while the map bench was running.
    draw_list_feats: std::sync::atomic::AtomicUsize,
    /// **The applied-output count memo** — `((layer_epoch, view), per-enabled-layer counts)`.
    ///
    /// The fourth memo on [`layer_epoch`](Self::layer_epoch). korp's flat canvas reads,
    /// AFTER every paint, how many features each layer drew — its Quality table's
    /// `applied.layers.*.drawn`, the number its robot drives assert on — and it read that
    /// through [`MapLayer::feature_count`] per layer: a second `ways.iter().filter(draws)`
    /// walk over every way on every frame, still screen or not (GG audit row 12, "the two
    /// walks"). [`severity`](Self::severity) and [`feature_count`](Self::feature_count)
    /// walked the same predicate again for the same frame.
    ///
    /// The answer's inputs are the draw list's minus the lane split: the epoch (geometry,
    /// enabled set) and the camera. The theme is NOT an input — no count reads ink — and
    /// the GPU lane split is not either, because a layer the GPU owns still DRAWS its
    /// features; ownership decides who paints them, not whether they are in view.
    ///
    /// Interior-mutable for the FC-9 reason its siblings give: a derivation cache, not
    /// Model state. `Arc` so a hit is a refcount bump. Stated as WORK ELIMINATED (see
    /// [`feature_count_walk_count`](Self::feature_count_walk_count) and
    /// `tests/feature_count_memo.rs`): 100 still frames over 3 layers walked **300**
    /// layers before and **3** after.
    feature_count_memo: std::sync::Mutex<Option<FeatureCountMemo>>,
    /// How many per-layer `feature_count` walks actually ran — the oracle for the memo
    /// above, one increment per layer walked. Still frames ⇒ stands still.
    feature_count_walks: std::sync::atomic::AtomicUsize,
    /// Last frame's label placement, published under `state_json()["labels"]`.
    ///
    /// Atomics because `state_json` takes `&self` and the placement happens inside the
    /// paint pass. These are the numbers a robot asserts on — `placed < considered` at
    /// a dense camera is the whole claim, and it is DATA, not prose.
    label_placed: std::sync::atomic::AtomicUsize,
    label_suppressed: std::sync::atomic::AtomicUsize,
    label_considered: std::sync::atomic::AtomicUsize,
    /// **The px² the placed boxes actually reserve** — the clutter number a count
    /// cannot give: `placed` and `overlaps` were both perfect on the pane this exists
    /// to fix. MEASURED here after the change — 64 labels, 74 559 px, 7.8 % of korp's
    /// 1258×728 canvas.
    label_ink_px: std::sync::atomic::AtomicUsize,
    /// How many DISTINCT texts are among the placed labels. `placed - distinct` is the
    /// repetition — the same name printed again for no new information.
    label_distinct: std::sync::atomic::AtomicUsize,
    /// **The boxes the painter actually drew text in this frame**, not a re-derivation.
    ///
    /// A count alone is a state round-trip: `placed = 40` is equally consistent with 40
    /// well-separated names and with 40 names stacked on one pixel. The claim
    /// "the labels no longer overplot" is a claim about RECTANGLES, so the rectangles
    /// are what the guard has to see — and they must be the same values
    /// `paint_features` centred its text in, which is why this is written by the paint
    /// pass and not recomputed by the test.
    label_boxes: std::sync::Mutex<Vec<egui::Rect>>,
}

impl MapFacet {
    /// **The ONE predicate for "the GPU fill lane can draw this way"** — a filled way
    /// with a ring the tessellator can use.
    ///
    /// It is a free function on purpose: the lane plan, the bake
    /// ([`Self::gpu_lane_fills`]) and the CPU painter's skip
    /// ([`WaysLayer::features_into`]) all ask it, and a fill drawn twice or lost
    /// entirely is exactly what two spellings of it would produce.
    ///
    /// It deliberately does NOT consult the ear-clip cache. It could — the bake needs
    /// a triangulation and the cache is where one comes from — but then the answer
    /// would depend on WHEN it was asked: the CPU painter builds its features before
    /// the bake runs, so on the first frame the two sides would disagree and the map
    /// would draw its lakes twice. The bake warms the cache itself instead, which
    /// costs the same single ear clip per ring the CPU painter would have run.
    ///
    /// **Always compiled.** It used to live in the `#[cfg(feature = "wgpu")]` impl
    /// while `WaysLayer::features_into` called it unconditionally, so `facett-map`
    /// did not build at all without the `wgpu` feature (E0599 at the call site).
    ///
    /// ## `lonlat.len() >= 3` CANNOT discriminate on the real-OSM path
    ///
    /// Every way korp (or any tag-classified host) builds comes through
    /// `Way::from_lonlat_styled`, and there `fill` is assigned **only** when
    /// `Way::is_closed_ring` holds — which returns `false` below **4** vertices. So on
    /// that path `fill.is_some()` already implies `lonlat.len() >= 4` and the second
    /// clause is always true: it can never be the reason the lane declines a fill, and
    /// reading it as a gate sends you looking in the wrong place (it did — lane WHITE,
    /// 2026-08-31).
    ///
    /// It is KEPT because there is a second door: `Way::filled` sets `fill: Some(..)`
    /// with **no** length or closure check at all (the synthetic rabbit basemap and the
    /// unit fixtures use it, korp never does). A one- or two-vertex `Way::filled` would
    /// otherwise reach the ear clip. So the clause is a real guard for the synthetic
    /// door and dead weight for the OSM one — which is the honest description, not
    /// "the fill gate".
    #[must_use]
    fn gpu_fillable(w: &Way) -> bool {
        w.fill.is_some() && w.lonlat.len() >= 3
    }

    pub fn new(title: impl Into<String>, view: MapView, theme: MapTheme) -> Self {
        Self {
            layers: Vec::new(),
            view,
            theme,
            title: title.into(),
            auto_rotate: false,
            idle_threshold_s: 6.0,
            idle_elapsed_s: 0.0,
            rotate_speed_deg_s: 8.0,
            #[cfg(feature = "wgpu")]
            gpu_geom_sig: std::sync::atomic::AtomicU64::new(0),
            #[cfg(feature = "wgpu")]
            gpu_geom_sent_at: std::sync::atomic::AtomicU64::new(0),
            layer_epoch: 0,
            #[cfg(feature = "wgpu")]
            lane_plan_memo: std::sync::Mutex::new(None),
            #[cfg(feature = "wgpu")]
            lane_scans: std::sync::atomic::AtomicUsize::new(0),
            #[cfg(feature = "wgpu")]
            gpu_sig_memo: std::sync::Mutex::new(None),
            #[cfg(feature = "wgpu")]
            tile_plan_memo: std::sync::Mutex::new(None),
            #[cfg(feature = "wgpu")]
            tile_group_walks: std::sync::atomic::AtomicUsize::new(0),
            #[cfg(feature = "wgpu")]
            lane_origin_walks: std::sync::atomic::AtomicUsize::new(0),
            #[cfg(feature = "wgpu")]
            gpu_sig_walks: std::sync::atomic::AtomicUsize::new(0),
            label_layouts: std::sync::atomic::AtomicUsize::new(0),
            draw_list_memo: std::sync::Mutex::new(None),
            draw_list_builds: std::sync::atomic::AtomicUsize::new(0),
            draw_list_feats: std::sync::atomic::AtomicUsize::new(0),
            feature_count_memo: std::sync::Mutex::new(None),
            feature_count_walks: std::sync::atomic::AtomicUsize::new(0),
            label_placed: std::sync::atomic::AtomicUsize::new(0),
            label_suppressed: std::sync::atomic::AtomicUsize::new(0),
            label_considered: std::sync::atomic::AtomicUsize::new(0),
            label_ink_px: std::sync::atomic::AtomicUsize::new(0),
            label_distinct: std::sync::atomic::AtomicUsize::new(0),
            label_boxes: std::sync::Mutex::new(Vec::new()),
        }
    }

    /// The label boxes the last painted frame drew text in — the applied-output oracle
    /// for the collision pass. Empty before the first paint.
    pub fn painted_label_boxes(&self) -> Vec<egui::Rect> {
        self.label_boxes.lock().map(|g| g.clone()).unwrap_or_default()
    }

    /// **Do any two painted label boxes share pixels?** `0` is the claim
    /// "labels no longer overplot"; before the collision pass this was in the thousands.
    pub fn label_overlaps(&self) -> usize {
        let b = self.painted_label_boxes();
        let mut n = 0;
        for i in 0..b.len() {
            for j in (i + 1)..b.len() {
                if b[i].intersects(b[j]) {
                    n += 1;
                }
            }
        }
        n
    }

    /// Last frame's label placement — `(placed, suppressed, considered)`.
    ///
    /// The oracle the guard and `state_json` both read, so the assertion and the
    /// published number can never disagree. `considered == 0` means no label reached
    /// the placer this frame (no label layer, or the pane has not painted yet).
    pub fn label_placement(&self) -> (usize, usize, usize) {
        use std::sync::atomic::Ordering::Relaxed;
        (
            self.label_placed.load(Relaxed),
            self.label_suppressed.load(Relaxed),
            self.label_considered.load(Relaxed),
        )
    }

    /// **The fraction of the pane last frame's label boxes covered**, `0.0..=1.0` — the
    /// clutter measure a placed-count cannot give.
    ///
    /// A count is a state round-trip in the same way `label_placement` alone is: `255
    /// placed, 0 overlaps` was the reading on the pane this exists to fix. Overlap is a
    /// claim about PAIRS; ink is a claim about the SURFACE, and the surface is what
    /// looks noisy.
    ///
    /// Read from the same atomics `state_json` publishes, so the assertion and the
    /// published number can never disagree.
    pub fn label_ink_frac(&self) -> f32 {
        let area = self.view.size_px.0 * self.view.size_px.1;
        if area <= 0.0 {
            return 0.0;
        }
        self.label_ink_px.load(std::sync::atomic::Ordering::Relaxed) as f32 / area
    }

    /// Last frame's `(distinct texts, repeats)` among the PLACED labels. `repeats > 0`
    /// means one name is printed more than once — five printings of "Ringvägen" is
    /// five times the ink for one fact.
    pub fn label_repetition(&self) -> (usize, usize) {
        use std::sync::atomic::Ordering::Relaxed;
        let (placed, distinct) = (self.label_placed.load(Relaxed), self.label_distinct.load(Relaxed));
        (distinct, placed.saturating_sub(distinct))
    }

    /// Enable idle auto-rotate (chainable).
    pub fn with_auto_rotate(mut self, on: bool) -> Self {
        self.auto_rotate = on;
        self
    }
    /// Toggle idle auto-rotate at runtime; turning it off also clears the timer.
    pub fn set_auto_rotate(&mut self, on: bool) {
        self.auto_rotate = on;
        if !on {
            self.idle_elapsed_s = 0.0;
        }
    }
    pub fn auto_rotate(&self) -> bool {
        self.auto_rotate
    }
    /// True iff auto-rotate is on AND the pane has been idle past the threshold
    /// (i.e. it is currently spinning).
    pub fn is_idle_rotating(&self) -> bool {
        self.auto_rotate && self.idle_elapsed_s >= self.idle_threshold_s
    }

    /// Advance the idle clock by `dt_s` seconds. `interacted` = the user did
    /// something this frame (pointer/scroll/key) → reset the clock and don't spin.
    /// Otherwise, once idle beyond the threshold, spin the camera bearing. Returns
    /// true iff it rotated this tick (so the host can request a repaint). Pure +
    /// deterministic — no clock read — so it is unit/robot-testable in code.
    pub fn tick(&mut self, dt_s: f32, interacted: bool) -> bool {
        if interacted {
            self.idle_elapsed_s = 0.0;
            return false;
        }
        if !self.auto_rotate {
            return false;
        }
        self.idle_elapsed_s += dt_s.max(0.0);
        if self.idle_elapsed_s >= self.idle_threshold_s {
            self.view.bearing_deg =
                (self.view.bearing_deg + self.rotate_speed_deg_s * dt_s.max(0.0)).rem_euclid(360.0);
            true
        } else {
            false
        }
    }

    /// Add a layer to the stack (chainable). Later layers can draw over earlier
    /// ones at the same z (stable sort).
    pub fn with_layer(mut self, layer: Box<dyn MapLayer>) -> Self {
        self.push_layer(layer);
        self
    }
    pub fn push_layer(&mut self, layer: Box<dyn MapLayer>) {
        self.layers.push(layer);
        self.layer_epoch += 1;
    }

    pub fn view(&self) -> &MapView {
        &self.view
    }
    pub fn view_mut(&mut self) -> &mut MapView {
        &mut self.view
    }
    pub fn theme(&self) -> &MapTheme {
        &self.theme
    }
    pub fn set_theme(&mut self, theme: MapTheme) {
        self.theme = theme;
    }
    pub fn layers(&self) -> &[Box<dyn MapLayer>] {
        &self.layers
    }

    /// Toggle a layer by id — the runtime "select which data to draw". Returns the
    /// new enabled state, or `None` if no layer has that id.
    pub fn toggle_layer(&mut self, id: &str) -> Option<bool> {
        let l = self.layers.iter_mut().find(|l| l.id() == id)?;
        let on = !l.enabled();
        l.set_enabled(on);
        self.layer_epoch += 1;
        Some(on)
    }
    /// Set a layer's enabled state by id. Returns `false` if no such layer.
    pub fn set_layer_enabled(&mut self, id: &str, on: bool) -> bool {
        match self.layers.iter_mut().find(|l| l.id() == id) {
            Some(l) => {
                l.set_enabled(on);
                self.layer_epoch += 1;
                true
            }
            None => false,
        }
    }

    /// **The shared renderer input** — every *enabled* layer's features for the
    /// current view, z-ordered (stable). Pure; the egui painter consumes this. A
    /// disabled layer contributes nothing (selectable data-to-draw).
    ///
    /// Returns an owned `Vec` — the public shape it has always had. The frame path
    /// ([`ui`](Self::ui)) takes the memo's `Arc` instead and pays no copy at all; this
    /// entry point is the tests' and the bench's, where a `Vec` is what the caller
    /// wants and the copy is not on anyone's frame budget.
    pub fn enabled_features(&self) -> Vec<DrawFeature> {
        (*self.features_for_cpu(&[], GpuOwns::NONE)).clone()
    }

    /// **Total ear clips run by every layer in this facet** — see
    /// [`MapLayer::ear_clips`]. Published in [`state_json`](Self::state_json) as
    /// `fill_tess.ear_clips`, so a robot scenario (and a human reading the dump) can
    /// see that a moving camera is not re-triangulating the basemap.
    #[must_use]
    pub fn ear_clips(&self) -> usize {
        self.layers.iter().map(|l| l.ear_clips()).sum()
    }

    /// **How many filled polygons this facet's layers carry a cache slot for** — the
    /// ceiling [`ear_clips`](Self::ear_clips) can ever reach.
    #[must_use]
    pub fn fill_polygon_count(&self) -> usize {
        self.layers
            .iter()
            .map(|l| l.gpu_ways().map_or(0, |ws| ws.iter().filter(|w| w.fill.is_some()).count()))
            .sum()
    }

    /// **Which lane paints the ways, and why** — see [`WayLane`]. Pure; safe to call
    /// outside a frame (a status bar, a robot assertion, a test).
    ///
    /// Both answers are first-class. `Cpu` is not a failure: it is the supported route
    /// on a host with no adapter and the *correct* route under a rotated camera.
    #[must_use]
    pub fn way_lane(&self) -> WayLane {
        self.way_lane_plan().0
    }

    /// **WHICH LANE DREW, as the three-state on-screen flag** — the answer the owner asked
    /// for on 2026-08-23 ("a flag saying GPU or CPU ... so i know whats being used").
    ///
    /// Read off the ONE decision writer ([`Self::way_lane_plan`]) plus the lane's own
    /// encoded-frame counter, so the chip, the log and `state_json`'s `way_lane.flag`
    /// cannot disagree (LAW 5).
    ///
    /// # Why this is not just `way_lane().is_gpu()`
    ///
    /// `way_lane()` is a PLAN. It says which route the frame will take, and on the GPU
    /// route the callback is merely *placed* — nothing is out of the device until
    /// `MapPaintCallback::prepare` has encoded a frame. So:
    ///
    /// * plan says CPU ⇒ [`LaneFlag::Cpu`]. Definite: no callback is placed at all.
    /// * plan says GPU **and** the lane has encoded a frame ⇒ [`LaneFlag::Gpu`].
    /// * plan says GPU and it has not ⇒ [`LaneFlag::Pending`], the same state
    ///   `note_lane_idle_reason` already publishes as
    ///   [`facett_core::render::lane::AWAITING_FIRST_ENCODE`].
    ///
    /// Collapsing that third case into `Gpu` would put a confident `GPU` on a pane the
    /// device has never touched; collapsing it into `Cpu` is the `bool`-shaped lie the
    /// flag type's own doc records. It is LIVE, not latched — the plan is re-asked every
    /// frame, so a camera that starts spinning flips the chip to `CPU` on the next frame
    /// (`CpuOnly::RotatedCamera`) instead of showing a stale first-frame verdict.
    #[must_use]
    pub fn lane_flag(&self) -> facett_core::render::lane::LaneFlag {
        use facett_core::render::lane::LaneFlag;
        if !self.way_lane().is_gpu() {
            return LaneFlag::Cpu;
        }
        #[cfg(feature = "wgpu")]
        {
            if crate::gpu::lane_frames() > 0 {
                LaneFlag::Gpu
            } else {
                LaneFlag::Pending
            }
        }
        // Unreachable in practice (`way_lane` cannot answer `Gpu` without the feature),
        // and it must still not answer `Cpu` here: an impossible state is not a
        // measurement.
        #[cfg(not(feature = "wgpu"))]
        LaneFlag::Pending
    }

    /// The decision + the layer indices the GPU lane took. ONE decision writer, so the
    /// reported lane and the painted lane cannot drift (LAW #5).
    ///
    /// Without the `wgpu` feature there is no GPU lane in the binary at all, and this
    /// says exactly that rather than pretending a GPU was tried and declined.
    #[cfg(not(feature = "wgpu"))]
    fn way_lane_plan(&self) -> (WayLane, Vec<usize>, GpuOwns, Option<i32>) {
        (WayLane::Cpu(CpuOnly::NotCompiled), Vec::new(), GpuOwns::NONE, None)
    }

    /// [`enabled_features`](Self::enabled_features) **minus the line features the GPU
    /// lane took this frame** — one writer for both, so the two lanes cannot double-
    /// draw a road or silently drop one.
    ///
    /// `gpu_layers` holds indices into `self.layers` (never ids: korp pushes many
    /// `WaysLayer`s and an id is only unique by convention). For a taken layer the
    /// GPU owns exactly its *unfilled* ways — which are exactly the ones
    /// [`WaysLayer::features`] emits as [`DrawGeom::Line`] — so those are dropped
    /// here and everything else (hole rings, points, labels) still paints on the CPU.
    /// `&[]` reproduces the old behaviour byte for byte.
    ///
    /// `gpu_owns` says WHICH kinds the lane took — see [`GpuOwns`]. It is the lane
    /// plan's answer, not a second opinion formed here.
    ///
    /// ## MEMOISED on [`DrawListKey`] — the whole input set, not a subset
    ///
    /// This body ran on **every frame**: a walk of every enabled layer, a fresh
    /// `Vec<DrawFeature>`, and a `sort_by_key` over the lot. Nothing invalidated it, so
    /// a still screen paid exactly what a pan paid. The answer's inputs are all
    /// `O(1)` or `O(layers)` to compare, so the frame that changes nothing does nothing;
    /// see [`DrawListKey`] for why each one is in the key and
    /// [`draw_list_build_count`](Self::draw_list_build_count) for the oracle.
    ///
    /// It hands back an [`Arc`](std::sync::Arc) so a hit costs a refcount bump rather
    /// than a fresh 20 000-element allocation — a memo that clones its answer hands a
    /// slice of the win straight back before the caller sees it.
    fn features_for_cpu(&self, gpu_layers: &[usize], gpu_owns: GpuOwns) -> std::sync::Arc<Vec<DrawFeature>> {
        let key = DrawListKey {
            epoch: self.layer_epoch,
            view: self.view,
            gpu_layers: gpu_layers.to_vec(),
            gpu_owns,
        };
        if let Ok(g) = self.draw_list_memo.lock()
            && let Some((k, feats)) = g.as_ref()
            && *k == key
        {
            return std::sync::Arc::clone(feats);
        }
        let feats = std::sync::Arc::new(self.build_draw_list(gpu_layers, gpu_owns));
        if let Ok(mut g) = self.draw_list_memo.lock() {
            *g = Some((key, std::sync::Arc::clone(&feats)));
        }
        feats
    }

    /// **The build itself** — the un-memoised body [`features_for_cpu`](Self::features_for_cpu)
    /// wraps. One writer, so the memoised and the freshly-built list cannot drift.
    fn build_draw_list(&self, gpu_layers: &[usize], gpu_owns: GpuOwns) -> Vec<DrawFeature> {
        self.draw_list_builds.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
        let mut feats: Vec<DrawFeature> = Vec::new();
        for (i, l) in self.layers.iter().enumerate() {
            if !l.enabled() {
                continue;
            }
            // The drop is expressed as a REQUEST to the layer
            // ([`MapLayer::features_into`]) rather than a post-hoc filter, so a layer
            // that can avoid building the dropped geometry does — see the method's own
            // measurement. The trait's default body is the old filter, so the result is
            // identical for every implementor.
            // The lane owns BOTH kinds of geometry for a taken layer now: its unfilled
            // ways (the pretty-line lane) and its filled ways (the fill lane, mesh +
            // ring outline). One value carries both, so a layer cannot be half-taken
            // by accident — which would draw a lake twice or lose it entirely.
            let on_gpu = if gpu_layers.contains(&i) { gpu_owns } else { GpuOwns::NONE };
            l.features_into(&self.view, on_gpu, &mut feats);
        }
        // **Sort on the WHOLE declared key, not on a per-kind `z`.** `sort_by_key` is
        // stable, so sorting on the old one-value-per-kind `z` left every same-kind
        // overlap to the order the features were pushed in — i.e. to the order the
        // tiles were read in. `PaintKey`'s own `Ord` carries class, OSM layer, LOD
        // tier, area and finally the osm id, so no comparison can fall through to
        // input order.
        feats.sort_by_key(|f| f.order);
        self.draw_list_feats.fetch_add(feats.len(), std::sync::atomic::Ordering::Relaxed);
        feats
    }

    /// **Test hook** — [`Self::features_for_cpu`] with the lane ownership named
    /// explicitly instead of derived from an installed renderer.
    ///
    /// It exists because the interesting arms are unreachable otherwise: a headless
    /// test has no `RenderState`, so `way_lane_plan` always answers
    /// `CpuOnly::NoRenderer` and the taken-layer path — the one that must NOT hand a
    /// polygon to the CPU painter — can never run. Driving the real body with the
    /// real `GpuOwns` is the closest a test gets to the live frame without a window.
    ///
    /// It hands back the memo's own [`Arc`](std::sync::Arc) — the SAME value
    /// [`ui`](Self::ui) paints from — so a test that measures this path measures the
    /// frame's path and not a copy of it (`&feats` still coerces to `&[DrawFeature]`
    /// for every existing caller).
    #[doc(hidden)]
    #[must_use]
    pub fn features_for_cpu_for_test(
        &self,
        gpu_layers: &[usize],
        lines: bool,
        fills: bool,
    ) -> std::sync::Arc<Vec<DrawFeature>> {
        self.features_for_cpu(gpu_layers, GpuOwns { lines, fills })
    }

    /// **Measurement seam** — how many times the full draw-list walk + sort ran (see
    /// `draw_list_builds`). Same argument as
    /// [`way_lane_scan_count`](Self::way_lane_scan_count), and the same reason it has
    /// to exist at all: the memo's whole claim is that a still frame does NOTHING, and
    /// the painted pixels are identical whether it does or not.
    ///
    /// **The deck.gl `widthScale` this facet's two lanes draw with THIS frame** —
    /// [`crate::style::line_width_scale`] of the camera's `world_px`, so a way's
    /// on-screen width is [`crate::style::line_width_px`]`(style.width, world_px)` in
    /// BOTH lanes: [`paint_features`](Self::paint_features) strokes with it directly
    /// and both GPU callbacks (legacy and tile) hand it to the line shader as the
    /// `width_scale` uniform, which applies the same clamp per frame.
    ///
    /// **It was a `const GPU_LINE_WIDTH_SCALE: f32 = 1.0`** (lane GG #27). That did keep
    /// the two lanes equal to each other, but it pinned both to the formula's 1×
    /// reference while `style.rs`, `gpu/line.wgsl` and `MapPaintCallback::width_scale`
    /// all documented the LIVE ramp — and `facett-geomap`'s `osm2d` pane, drawing the
    /// same `Way`s through the same shader, did pass `line_width_scale(cam.zoom)` in
    /// both of its lanes. MEASURED (`tests/lane_width_is_one_formula.rs`) on a 3 px
    /// trunk: zoom 8 drew 3 px where the law says 3.93 px (1.31×); zoom 14 drew 3 px
    /// where the law says 9 px (the 3.0× clamp). One method now, both lanes read it.
    #[must_use]
    pub fn line_width_scale(&self) -> f32 {
        crate::style::line_width_scale(self.view.world_px())
    }

    /// The pixel width the CPU painter strokes a way of themed `style_width` at — the
    /// law's arithmetic with [`line_width_scale`](Self::line_width_scale). The one
    /// writer `paint_features` and the test hook both call.
    fn cpu_line_width_px(&self, style_width: f32) -> f32 {
        crate::style::line_width_px_scaled(style_width, self.line_width_scale())
    }

    /// **Test hook** — the pixel width [`paint_features`](Self::paint_features) strokes a
    /// way of themed `style_width` at, under THIS camera. The CPU lane's half of the
    /// lane GG #27 equality (`tests/lane_width_is_one_formula.rs`).
    #[doc(hidden)]
    #[must_use]
    pub fn cpu_line_width_px_for_test(&self, style_width: f32) -> f32 {
        self.cpu_line_width_px(style_width)
    }

    /// **Test hook** — the deck.gl `widthScale` this facet hands the GPU line lane
    /// (`MapPaintCallback::width_scale`, legacy and tile paths alike) under THIS camera.
    /// The GPU lane's half of the lane GG #27 equality.
    #[doc(hidden)]
    #[must_use]
    pub fn gpu_width_scale_for_test(&self) -> f32 {
        self.line_width_scale()
    }

    /// Always compiled — the draw list is the CPU painter's, not the GPU lane's.
    #[doc(hidden)]
    #[must_use]
    pub fn draw_list_build_count(&self) -> usize {
        self.draw_list_builds.load(std::sync::atomic::Ordering::Relaxed)
    }

    /// **Measurement seam** — how many `DrawFeature`s those walks have materialised (see
    /// `draw_list_feats`): one paint-key computation and one sort comparand each. The
    /// VOLUME of the eliminated work, where
    /// [`draw_list_build_count`](Self::draw_list_build_count) is its frequency.
    #[doc(hidden)]
    #[must_use]
    pub fn draw_list_feature_count(&self) -> usize {
        self.draw_list_feats.load(std::sync::atomic::Ordering::Relaxed)
    }

    /// **Test hook** — [`Self::gpu_geometry_signature`], so `seam-5` can be asserted
    /// on the number itself rather than on a re-upload that a test cannot observe.
    #[doc(hidden)]
    #[cfg(feature = "wgpu")]
    #[must_use]
    pub fn gpu_geometry_signature_for_test(&self, gpu_layers: &[usize], origin: [f64; 2]) -> u64 {
        self.gpu_geometry_signature(gpu_layers, origin)
    }

    /// **Measurement seam** — how many times the full lane-plan way scan ran (see
    /// `lane_scans`). The memo claim is about work that did NOT happen, so the count
    /// is the only honest oracle; `tests/frame_churn_memo.rs` reads it.
    #[doc(hidden)]
    #[cfg(feature = "wgpu")]
    #[must_use]
    pub fn way_lane_scan_count(&self) -> usize {
        self.lane_scans.load(std::sync::atomic::Ordering::Relaxed)
    }

    /// **Measurement seam** — how many times the full `O(ways)` geometry-signature
    /// walk ran (see `gpu_sig_walks`). Same argument as
    /// [`way_lane_scan_count`](Self::way_lane_scan_count).
    #[doc(hidden)]
    #[cfg(feature = "wgpu")]
    #[must_use]
    pub fn gpu_sig_walk_count(&self) -> usize {
        self.gpu_sig_walks.load(std::sync::atomic::Ordering::Relaxed)
    }

    /// **Measurement seam** — how many times the label layout (`place_map_labels`:
    /// filter, sort, then one `layout_no_wrap` text shaping per candidate) actually ran.
    /// Lane GG #11. See the `label_layouts` field for why this exists before any fix.
    #[doc(hidden)]
    #[must_use]
    pub fn label_layout_count(&self) -> usize {
        self.label_layouts.load(std::sync::atomic::Ordering::Relaxed)
    }

    /// FC-3 observable state: the layer roster (id/kind/enabled), the camera, and
    /// how many features the current view draws — the JSON tests + the web hook read.
    pub fn state_json(&self) -> serde_json::Value {
        let layers: Vec<_> = self
            .layers
            .iter()
            .map(|l| serde_json::json!({ "id": l.id(), "kind": l.kind().as_str(), "enabled": l.enabled() }))
            .collect();
        // COUNTED, not built (see `MapLayer::feature_count`): this runs 3.3x a second
        // in korp (`dump_if_due`, every 300 ms) and used to clone every visible way's
        // coordinates to read a `.len()` off them.
        let features = self.feature_count();
        // ONE `way_lane_plan` for the whole object — it walks every layer's ways, and
        // this was calling it THREE times to answer three adjacent questions.
        let lane = self.way_lane();
        serde_json::json!({
            "title": self.title,
            "layers": layers,
            "enabled_layers": self.layers.iter().filter(|l| l.enabled()).count(),
            "features": features,
            // The stable UI ERROR CODE for the nothing-to-plot state
            // (`facett_core::errcode`, `facet-map-2`). `MapView` published this
            // already; `MapFacet` — the LAYERED map a host actually mounts (korp's
            // 🗺 Map / 📍 Geo panes) — was silent for the very same condition, so a
            // consumer had no code to react to. Consumers branch on the CODE in this
            // data, never on a matched string.
            "error_code": if features == 0 {
                serde_json::json!("facet-map-2")
            } else {
                serde_json::Value::Null
            },
            "view": {
                "center": { "lat": self.view.center.lat, "lon": self.view.center.lon },
                "zoom": self.view.zoom,
                "bearing_deg": self.view.bearing_deg,
                "tilt_deg": self.view.tilt_deg,
                // **The tier the painter and the GPU cull are actually gating on**, from
                // the one writer (`view_lod_tier` over `LOD_TIER_ZOOMS`).
                //
                // stinky `natt-4`. It was not published, so every robot driver that
                // wanted to check "the fetched level and the drawn tier step at the same
                // camera" had to RETYPE the rule — `rung_boundary.sh` still carried
                // `0 if z<6 else (1 if z<12 else 2)`, a three-tier copy of a rule that
                // grew a fourth rung at zoom 15 with `kalm-1`. A harness that re-derives
                // the thing under test cannot fail with it, and this one had gone quietly
                // wrong for every camera at or above 15.
                "lod_tier": view_lod_tier(self.view.zoom),
                "lod_tier_zooms": LOD_TIER_ZOOMS,
            },
            // **What the label collision pass did last frame.** `placed` is what a human
            // can read; `suppressed` is the overplot that used to land on top of it.
            // `placed + suppressed == considered` exactly (a truncated candidate was
            // never considered), so a robot can assert the pass ran AND bit — a
            // `considered == 0` reads as "no label reached the placer", never as a
            // silent success. See `MapFacet::place_map_labels`.
            // `ink_frac` / `repeats` are the CLUTTER half, and they exist because the
            // counts above were all green on a pane that was unreadable: 255 placed and
            // 0 overlapping pairs. A robot (and a human reading a diff) needs the
            // quantity the eye reacts to, and no count is it.
            "labels": {
                "placed": self.label_placed.load(std::sync::atomic::Ordering::Relaxed),
                "suppressed": self.label_suppressed.load(std::sync::atomic::Ordering::Relaxed),
                "considered": self.label_considered.load(std::sync::atomic::Ordering::Relaxed),
                "ink_px": self.label_ink_px.load(std::sync::atomic::Ordering::Relaxed),
                "ink_frac": self.label_ink_frac(),
                "distinct": self.label_distinct.load(std::sync::atomic::Ordering::Relaxed),
                "repeats": self
                    .label_placed
                    .load(std::sync::atomic::Ordering::Relaxed)
                    .saturating_sub(self.label_distinct.load(std::sync::atomic::Ordering::Relaxed)),
                "budget": label_budget(egui::Rect::from_min_size(
                    egui::Pos2::ZERO,
                    egui::vec2(self.view.size_px.0, self.view.size_px.1),
                )),
            },
            // WHICH LANE painted the ways, and why the CPU has them when it does — so a
            // headless robot can tell "the GPU line lane is live" from "the CPU painter
            // is doing all of it", which no feature count or pixel share can show. The
            // process-wide frame count that CONFIRMS it lives in
            // `facett_core::render::lane` (an About box reads both).
            "way_lane": {
                "lane": lane.as_str(),
                // **The three-state on-screen FLAG's own token.** `lane` above is the
                // DECISION (which route the plan chose); this is what the pane's chip
                // actually shows, which is a different fact: a plan of `gpu` whose
                // callback has not yet come out of the device reads `pending`, not `gpu`.
                // Both are here so a robot can tell "chose the GPU" from "the GPU has
                // drawn", and so the chip and the state file cannot disagree.
                "flag": self.lane_flag().token(),
                "cpu_reason": lane.cpu_reason(),
                "gpu_layers": match lane {
                    WayLane::Gpu { layers } => layers,
                    WayLane::Cpu(_) => 0,
                },
            },
            // THE FILL TESSELLATION CACHE, as a number a robot can watch move (or not).
            // `ear_clips` is cumulative work done, `polygons` is the ceiling: once every
            // visible area has been painted once, `ear_clips` must STOP — a camera move
            // does not change a triangulation, because the projection is affine and ear
            // clipping is affine-invariant. If this number climbs with the frame counter
            // the cache is dead and 85 % of the frame is back.
            "fill_tess": {
                "ear_clips": self.ear_clips(),
                "polygons": self.fill_polygon_count(),
            },
            "night": self.theme.night,
            "auto_rotate": self.auto_rotate,
            "idle_rotating": self.is_idle_rotating(),
            "idle_elapsed_s": self.idle_elapsed_s,
        })
    }

    /// STRUCTURAL severity (the Robot-UI gate signal, and the cheap gate a consumer's
    /// per-frame reaction walk checks before building [`state_json`](Self::state_json)):
    /// RED when the current view draws no features at all — the `facet-map-2`
    /// nothing-to-plot state — the Info floor otherwise. Matches
    /// [`MapView`](crate::MapView)'s override.
    pub fn severity(&self) -> facett_core::Severity {
        // COUNTED, not built. korp calls this on EVERY frame
        // (`Korp::react_to_facet_panes`), whose own doc says the severity check is there
        // "so a healthy frame never pays for a `state_json` build" — while this body was
        // `enabled_features()`, i.e. precisely that build, coordinate clones and all.
        // Read off the memo (`feature_counts`): on a still frame this walks nothing, and
        // korp calls it every frame.
        if self.feature_counts().iter().any(|(_, n)| *n > 0) {
            facett_core::Severity::Info
        } else {
            facett_core::Severity::Error
        }
    }

    /// **How many features the current view draws — without BUILDING them.**
    ///
    /// The allocation-free twin of `enabled_features().len()`, summed over the enabled
    /// layers through each one's own [`MapLayer::feature_count`]. This is what
    /// [`state_json`](Self::state_json) publishes and what [`severity`](Self::severity)
    /// tests against zero; see `MapLayer::feature_count` for why the count and the paint
    /// share one visibility predicate rather than two. Memoised through
    /// [`feature_counts`](Self::feature_counts).
    #[must_use]
    pub fn feature_count(&self) -> usize {
        self.feature_counts().iter().map(|(_, n)| n).sum()
    }

    /// **Per enabled layer, `(id, how many features the current view draws)` — the
    /// applied output, MEMOISED on `(layer_epoch, view)`.**
    ///
    /// This is the seam korp's flat canvas reads after the paint (its Quality table's
    /// `drawn` per layer) instead of asking every layer's [`MapLayer::feature_count`]
    /// itself once per frame — the "second full walk per frame" of GG audit row 12. A
    /// disabled layer has NO row: the caller reads absence as "not drawn", never a stale
    /// count. Order is the stack order.
    ///
    /// Same numbers as the un-memoised per-layer read, by construction: one writer
    /// ([`build_feature_counts`](Self::build_feature_counts)) produces both the miss and
    /// what the memo hands back. The oracle for the work saved is
    /// [`feature_count_walk_count`](Self::feature_count_walk_count).
    #[must_use]
    pub fn feature_counts(&self) -> std::sync::Arc<Vec<(String, usize)>> {
        let key = (self.layer_epoch, self.view);
        if let Ok(g) = self.feature_count_memo.lock()
            && let Some((k, counts)) = g.as_ref()
            && *k == key
        {
            return std::sync::Arc::clone(counts);
        }
        let counts = std::sync::Arc::new(self.build_feature_counts());
        if let Ok(mut g) = self.feature_count_memo.lock() {
            *g = Some((key, std::sync::Arc::clone(&counts)));
        }
        counts
    }

    /// The un-memoised body [`feature_counts`](Self::feature_counts) wraps: one
    /// [`MapLayer::feature_count`] walk per enabled layer, each counted in
    /// `feature_count_walks`.
    fn build_feature_counts(&self) -> Vec<(String, usize)> {
        self.layers
            .iter()
            .filter(|l| l.enabled())
            .map(|l| {
                self.feature_count_walks.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
                (l.id().to_string(), l.feature_count(&self.view))
            })
            .collect()
    }

    /// How many per-layer visibility walks [`feature_counts`](Self::feature_counts) has
    /// actually run — the measurement seam, same argument as
    /// [`draw_list_build_count`](Self::draw_list_build_count): a memo is a claim about
    /// work that did NOT happen and the numbers are identical either way, so the counter
    /// is the only honest oracle. One increment per enabled layer per rebuild; a still
    /// frame adds nothing.
    #[must_use]
    pub fn feature_count_walk_count(&self) -> usize {
        self.feature_count_walks.load(std::sync::atomic::Ordering::Relaxed)
    }

    /// **The pane wash — the colour of every pixel no layer paints.**
    ///
    /// With an enabled [`LandmassLayer`] in the stack the land is *declared*, so
    /// everything outside the silhouette is sea and the wash is [`MapTheme::sea`].
    /// With none, nothing has said where the land is, so the whole pane is paper and
    /// the wash is [`MapTheme::land`] — the historical behaviour, preserved for every
    /// host that draws features on bare paper.
    ///
    /// This is the fix for a map that was invisible BY CONSTRUCTION: the wash and the
    /// landmass fill were the same `MapTheme::land`, so the country polygon painted
    /// land-colour onto a land-colour pane and land-vs-sea — the stated purpose of
    /// [`LandmassLayer`] — could not happen at any zoom, with any data.
    pub fn background(&self) -> Color32 {
        if self.layers.iter().any(|l| l.is_landmass() && l.enabled()) {
            self.theme.sea
        } else {
            self.theme.land
        }
    }

    /// **The shared, source-agnostic renderer.** Paints the themed land, then every
    /// enabled layer's features z-ordered, each projected through the rotated +
    /// tilted [`MapView`]. Reuses [`crate::tessellate`] for polygon fills. Pure w.r.t.
    /// the model (draws only).
    ///
    /// ## Where the GPU comes in (feature `wgpu`)
    ///
    /// Before the line tier is painted, [`gpu_lane_layers`](Self::gpu_lane_layers)
    /// decides whether `facett_map::gpu::GpuMapRenderer` can own the ways this frame;
    /// if it can, [`add_gpu_ways_callback`](Self::add_gpu_ways_callback) inserts the
    /// `egui_wgpu` paint callback *between* the CPU fill tier and the CPU
    /// point/label tier, so the paint order (fills under roads under markers under
    /// labels) is the same one the pure-CPU path produces. The taken layers' line
    /// features are removed from the CPU list by
    /// [`features_for_cpu`](Self::features_for_cpu), so no road is drawn twice.
    pub fn ui(&mut self, ui: &mut egui::Ui) {
        // Idle auto-rotate: spin the camera when the pane has gone untouched (any
        // pointer/key/scroll event resets it). Keep animating while it spins.
        let (dt, interacted) =
            ui.input(|i| (i.stable_dt, i.pointer.any_down() || !i.events.is_empty()));
        if self.tick(dt, interacted) {
            ui.ctx().request_repaint();
        }
        let avail = ui.available_size();
        let want = egui::vec2(avail.x.max(160.0), avail.y.max(120.0));
        // ── THE CANVAS IS A CONTROL, not a picture. `Sense::hover()` is what it used to
        //    be, and with it the car-nav map had NO pan and NO wheel zoom: the only way to
        //    move the viewport was a `zoom:` slider in the controls rail, and there was no
        //    way to reach a different part of the country at all. korp's own call site
        //    says "persist the camera the user drove" — the user could not drive it.
        //    Measured 2026-08-03 on the shipped korp-ui: drag and scroll over the canvas
        //    changed `view.center`/`view.zoom` by exactly zero, so korp's viewport-driven
        //    `MapQuery` never re-fired and every zoom-in had to be typed into a slider.
        let (rect, resp) = ui.allocate_exact_size(want, egui::Sense::click_and_drag());
        // Keep the camera pane-size in sync so projection centres on the real rect.
        self.view.size_px = (rect.width(), rect.height());
        // ── GESTURES → the camera the HOST fetches for.
        //
        //    Both write `self.view`, which is what `MapFacet::view()` hands back and what
        //    korp turns into its server viewport + LOD — so a drag or a wheel notch
        //    re-queries korp-server for the new viewport exactly as the rail slider does.
        //    Deliberately NOT the legacy `MapView` widget's `camera.pan`/`camera.zoom`,
        //    which are screen-space knobs no fetch ever reads.
        if resp.dragged() {
            let d = resp.drag_delta();
            self.view.pan_by_px(d.x, d.y);
        }
        // Hover-gated, so a wheel over the canvas zooms the map while a wheel over the
        // surrounding scroll area still scrolls the page.
        if resp.hovered() {
            let scroll = ui.input(|i| i.smooth_scroll_delta.y);
            self.view.zoom_by_scroll(scroll);
        }
        let painter = ui.painter_at(rect);
        painter.rect_filled(rect, egui::CornerRadius::ZERO, self.background());
        let origin = rect.min.to_vec2();
        // Tilt shading: pitched maps fade the far (upper) half — a subtle horizon
        // so the pitched view reads as depth, not a flat skew.
        let tilt_fade = (self.view.tilt_deg / MAX_TILT).clamp(0.0, 1.0);

        // ── WHICH LANE, decided up front and reported (never inferred from a no-op).
        //    `gpu_layers` empty ⇒ the CPU painter owns every way, which is a correct,
        //    supported outcome — see `WayLane` / `CpuOnly`. ──
        let (lane, gpu_layers, gpu_owns, gpu_floor) = self.way_lane_plan();
        // SAY IT WHERE A HUMAN LOOKS. `state_json` alone was not enough: korp's About box
        // said "installed but UNUSED" on an RTX 4090 and could not say whether that was a
        // correct choice or a bug — a reporting defect in its own right. Published every
        // frame, so the row tracks the LIVE camera rather than a stale startup verdict.
        note_lane_idle_reason(lane);

        // THEIRS: the GPU fill lane now decides which layers it owns, so the CPU feature
        // list is built against `gpu_owns` as well as `gpu_layers`.
        let feats = self.features_for_cpu(&gpu_layers, gpu_owns);
        // **Labels are placed before anything is painted**, over the WHOLE frame's
        // feature list rather than per z-slice, because two labels that collide may
        // come from different layers. `boxes[i]` is `Some(rect)` for a label that won
        // its pixels and `None` for one that lost them (or for any non-label feature).
        let boxes = self.place_map_labels(&painter, origin, rect, &feats);
        // ── WHERE THE GPU SLOT GOES IN THE DECLARED STACK ────────────────────
        //
        // The callback is ONE opaque slot: everything the lane owns lands at a single
        // point in the CPU list. The split therefore has to be the lane's own rank
        // FLOOR — the lowest `PaintKey::rank` it will draw this frame — so every CPU
        // feature that belongs under the lane's bottom-most class paints first and
        // everything else paints after.
        //
        // It was a hardcoded `LayerKind::Line.default_z()`, i.e. "the lane draws
        // lines". That stopped being true when the fill lane landed: a lake the GPU
        // took was drawn in the LINE slot, ABOVE every CPU-painted fill whatever its
        // class — and `way_lane_plan_scan` decides per viewport which layers the lane
        // takes, so the visible stacking changed with what had been loaded. That is
        // the tangle the owner reported on 2026-08-22.
        //
        // `None` ⇒ the lane draws nothing this frame and the CPU list paints as one
        // uninterrupted run (`split == feats.len()`, the tail call is empty).
        let split = gpu_floor.map_or(feats.len(), |lo| feats.partition_point(|f| f.order.rank < lo));
        self.paint_features(&painter, origin, tilt_fade, &feats[..split], &boxes[..split]);
        #[cfg(feature = "wgpu")]
        if !gpu_layers.is_empty() {
            // The pane is this facet's response id (GG #37): stable across frames for
            // a stable layout, distinct from a second facet's in the same frame.
            let stamp = crate::gpu::PaneStamp::of(ui, resp.id);
            self.add_gpu_ways_callback(&painter, rect, &gpu_layers, tilt_fade, stamp);
        }
        self.paint_features(&painter, origin, tilt_fade, &feats[split..], &boxes[split..]);

        // ── WHICH LANE DREW THIS, ON SCREEN (owner, 2026-08-23) ────────────────
        //
        // Painted LAST so it sits over the map rather than under the tail features, and
        // read from `lane_flag()` — the same value `state_json`'s `way_lane.flag` carries,
        // so the chip and the state file are one answer and not two.
        //
        // Live per frame, deliberately not latched: `way_lane_plan` is re-asked every
        // frame, so the instant the idle auto-rotate starts spinning the camera the chip
        // flips to CPU (`CpuOnly::RotatedCamera`) — which is exactly the transition a
        // first-frame latch would hide, and it is the most likely reason this pane is on
        // the CPU painter at all.
        facett_core::render::lane::paint_lane_flag(ui, rect, self.lane_flag());
    }

    /// **Give every label that fits its own pixels, and drop the rest.**
    ///
    /// THE DEFECT THIS CLOSES. [`lod_visible`] is the only declutter the label path
    /// had, and [`view_lod_tier`] saturates at three steps: past zoom 12 every
    /// [`Lod::City`] label passes it, so **every named street in the viewport painted
    /// its name**. MEASURED on the shipped korp-ui on a real 1900×1150 Xvfb window over
    /// central Stockholm at zoom 12.1, 80 000 served ways: the 2D pane was a field of
    /// overlapping grey text with the roads and water underneath it unreadable. The LOD
    /// gate cannot fix this — there is no fourth tier to promote a street into, and
    /// inventing one would only move the cliff.
    ///
    /// The cure is screen-space, and it is **consumed, not written** (LAW 5):
    /// [`facett_core::legibility::place_labels`] is the greedy grid-backed placer that
    /// already existed for the graph side — same discipline, same honesty fields, one
    /// implementation. This method is only the adapter: project the anchor, measure the
    /// galley, rank by tier, hand it over.
    ///
    /// Ranking: **lower [`Lod`] wins**, then the feature's own
    /// [`rank`](crate::style::LabelClass::rank) within that tier (a motorway before a
    /// cul-de-sac, a lake before a courtyard), then the input index — deterministic, so
    /// two identical frames place identically.
    ///
    /// ## The three declutter rules that live here, and why collision alone was not enough
    ///
    /// MEASURED on the shipped korp-ui against the live korp-server, Xvfb 1900×1150,
    /// central Stockholm at zoom 12.7 (`02-2d-z12.png`, 2026-08-03): the collision pass
    /// was working — 255 placed of 3 756 considered, **zero** overlapping pairs — and
    /// the pane was still a field of text. Three things a rect-vs-rect test cannot see:
    ///
    /// 1. **Density.** 255 non-overlapping boxes on the 1258×728 canvas is one label
    ///    per 3 591 px². Non-overlapping is not the same as sparse; a map whose surface
    ///    is a third label boxes is a text field with a map behind it.
    ///    [`LABEL_MIN_PX2_EACH`] turns the pane's area into a hard ceiling.
    /// 2. **Repetition.** OSM splits one street into many ways and `from_ways` anchors
    ///    a label at each way's centroid, so one name printed five times down one road
    ///    is five times the ink for the same information. [`LABEL_REPEAT_PX`] is the
    ///    minimum screen distance between two printings of the *same text*.
    /// 3. **Fit.** A 30 px stub of "Sankt Eriksgatan" carrying its 110 px name says
    ///    nothing about where that street *is*. A candidate whose feature is shorter on
    ///    screen than its own text is dropped before it competes (the standard rule for
    ///    line-placed symbols; a point label like a place node is exempt).
    ///
    /// The return is index-parallel to `feats` (`None` for every non-label feature and
    /// for every suppressed label), so the painter needs no second lookup structure and
    /// cannot paint a label the placer rejected.
    ///
    /// ## Why this pass is NOT on the [`DrawListKey`] memo — and it was measured first
    ///
    /// It runs immediately after [`features_for_cpu`](Self::features_for_cpu) over the
    /// same `&feats`, so it is the obvious second rider on that key. Profiled on oden
    /// 2026-08-31 through a real `egui_kittest` frame — 4 000 named Stockholm ways,
    /// 8 000 features, 1600×1000, zoom 13, still camera, release, eight frames:
    ///
    /// | frame | draw list | this pass |
    /// |---|---:|---:|
    /// | first (cold: ear clips + font atlas) | 67.1 ms | 79.2 ms |
    /// | every still frame after it | 150 ns – 1.1 µs | 0.96 – 9.45 ms |
    ///
    /// Those absolutes are contention-dominated on this box and are quoted only for the
    /// ORDER between the two columns, which is stable across every frame of the run and
    /// is the only thing decided from them: with the draw list memoised, this pass is now
    /// the dominant per-frame CPU cost on a labelled pane, and it is worth a memo. It is not worth THIS memo: the placement is computed against **egui's
    /// font state**. `painter.layout_no_wrap` shapes every candidate through
    /// `Context::fonts`, and while `rect`, `origin` and `pixels_per_point` are all
    /// readable here, the font *definitions* and the atlas carry no version a
    /// `MapFacet` can read — so a key written today would be incomplete by
    /// construction, which is the one failure mode this whole change exists to avoid.
    /// And a stale answer here is not a slow frame: it is names painted into rects the
    /// painter no longer measures, published as [`painted_label_boxes`](Self::painted_label_boxes)
    /// — the applied-output oracle `label_collision.rs` asserts on.
    ///
    /// Memoising it is therefore its own change, and it needs a seam first: a galley
    /// generation (egui-side) or a facet-side `(text, FontId, pixels_per_point)` galley
    /// cache that makes the shaping cost, not the placement, the thing that is saved.
    fn place_map_labels(
        &self,
        painter: &egui::Painter,
        origin: egui::Vec2,
        rect: egui::Rect,
        feats: &[DrawFeature],
    ) -> Vec<Option<egui::Rect>> {
        use facett_core::legibility::{place_labels, LabelCandidate, PlaceOpts};

        // Lane GG #11's measurement seam. Counted at the TOP, before the early return
        // below: a frame with no labelled features still pays the filter walk, and a
        // counter that skipped those would under-report exactly the still frames the
        // audit's claim is about.
        self.label_layouts.fetch_add(1, std::sync::atomic::Ordering::Relaxed);

        let mut boxes: Vec<Option<egui::Rect>> = vec![None; feats.len()];
        // **Truncate BEFORE measuring, not after.** `PlaceOpts::max_considered` bounds
        // the placer's own work, but the expensive step here is `layout_no_wrap` — text
        // shaping, once per candidate — and that would already have run on all of them
        // by the time the placer truncates. A z12 Stockholm viewport offers thousands of
        // named ways, so shaping the whole set every frame to then throw most of it away
        // is a legibility cure that costs the frame rate, which is not a cure.
        //
        // The sort key is the SAME ranking the placer uses (tier ascending, then rank
        // descending, then input index), so truncating here takes exactly the tail the
        // placer would have truncated — never a place name, never an arterial.
        let rank_of = |f: &DrawFeature| match f.geom {
            DrawGeom::Label { rank, .. } => rank,
            _ => 0.0,
        };
        let mut label_idx: Vec<usize> = feats
            .iter()
            .enumerate()
            .filter(|(_, f)| matches!(f.geom, DrawGeom::Label { .. }))
            .map(|(i, _)| i)
            .collect();
        label_idx.sort_by(|&a, &b| {
            (feats[a].lod as u8)
                .cmp(&(feats[b].lod as u8))
                .then(rank_of(&feats[b]).total_cmp(&rank_of(&feats[a])))
                .then(a.cmp(&b))
        });
        label_idx.truncate(MAX_LABELS_CONSIDERED);

        // The camera's Mercator→px scale, so a feature's baked extent becomes the
        // on-screen length rule 3 tests the name against.
        let world_px = self.view.world_px();
        // Rule 2's accepted anchors, keyed by the text already admitted. A `Vec` per
        // name because a long street legitimately earns a second printing far away.
        let mut printed: std::collections::HashMap<&str, Vec<egui::Pos2>> = std::collections::HashMap::new();

        // `cands[k]` came from `feat_of[k]` — the placer indexes its own slice.
        let mut cands: Vec<LabelCandidate> = Vec::new();
        let mut feat_of: Vec<usize> = Vec::new();
        for &i in &label_idx {
            let f = &feats[i];
            let DrawGeom::Label { at, text, rank, merc_len } = &f.geom else { continue };
            let (x, y) = self.view.project(at.lon, at.lat);
            let anchor = egui::pos2(x, y) + origin;
            // ── RULE 2: the same name, again, right there. ────────────────────
            // Walked in priority order, so the printing that survives is the one on
            // the most important / longest-ranked way, not the first one decoded.
            if let Some(prev) = printed.get(text.as_str())
                && prev.iter().any(|p| p.distance(anchor) < LABEL_REPEAT_PX)
            {
                continue;
            }
            // Measure the REAL galley the painter will draw, at the SAME font — a
            // placement computed against a guessed extent is a placement against a
            // different picture than the one that lands.
            let galley = painter.layout_no_wrap(
                text.clone(),
                egui::FontId::proportional(LABEL_FONT_PX),
                self.theme.label,
            );
            // ── RULE 3: does the name fit along the thing it names? ───────────
            // `merc_len == 0` is a POINT label (a place node, a `ref` shield) — it
            // names a spot, not a stretch, so the rule does not apply to it.
            if *merc_len > 0.0 && (*merc_len * world_px) < galley.size().x as f64 {
                continue;
            }
            printed.entry(text.as_str()).or_default().push(anchor);
            cands.push(LabelCandidate::new(
                anchor,
                galley.size(),
                // Lower tier ⇒ higher priority (Country 0 → 3.0, City 2 → 1.0), plus
                // the feature's own rank in `[0, 1)` so a tier is ordered inside
                // itself and never decided by the tile decode order.
                (3.0 - f.lod as u8 as f32) + rank.clamp(0.0, 0.999),
            ));
            feat_of.push(i);
        }
        let placement = place_labels(
            &cands,
            rect,
            PlaceOpts {
                pad: LABEL_PAD_PX,
                // Mapbox-style variable placement: a label that loses its anchor box
                // tries below/above/right/left before it is dropped, so a dense street
                // grid keeps more names than a one-shot placer would.
                variable: true,
                // A half-drawn name at the pane edge is noise, not information.
                clip_to_viewport: true,
                // A screen has a hard ceiling on how many labels can physically fit;
                // past a few multiples of it every further candidate is a guaranteed
                // rejection that still costs a query. Walked in priority order, so the
                // truncation takes the tail — never the place names.
                max_considered: MAX_LABELS_CONSIDERED,
                // ── RULE 1: the areal budget, from THIS pane. ─────────────────
                max_placed: label_budget(rect),
            },
        );
        for p in &placement.placed {
            boxes[feat_of[p.index]] = Some(p.rect);
        }
        self.label_placed.store(placement.placed.len(), std::sync::atomic::Ordering::Relaxed);
        self.label_suppressed.store(placement.suppressed, std::sync::atomic::Ordering::Relaxed);
        self.label_considered.store(placement.considered, std::sync::atomic::Ordering::Relaxed);
        // The ink the placement actually reserves, in whole px — the quantity a
        // collision count cannot express (255 boxes that share no pixel is still a third
        // of the pane). Published beside the counts so the "is it calm?" question has a
        // number and not an impression.
        let ink: f32 = placement.placed.iter().map(|p| p.rect.area()).sum();
        self.label_ink_px.store(ink.max(0.0) as usize, std::sync::atomic::Ordering::Relaxed);
        let distinct = placement
            .placed
            .iter()
            .filter_map(|p| match &feats[feat_of[p.index]].geom {
                DrawGeom::Label { text, .. } => Some(text.as_str()),
                _ => None,
            })
            .collect::<std::collections::BTreeSet<_>>()
            .len();
        self.label_distinct.store(distinct, std::sync::atomic::Ordering::Relaxed);
        if let Ok(mut g) = self.label_boxes.lock() {
            *g = placement.placed.iter().map(|p| p.rect).collect();
        }
        boxes
    }

    /// Paint one z-slice of [`DrawFeature`]s. The ONE CPU painter body — `ui` calls it
    /// twice (below and above the GPU lane's slot) instead of carrying two copies.
    fn paint_features(
        &self,
        painter: &egui::Painter,
        origin: egui::Vec2,
        tilt_fade: f32,
        feats: &[DrawFeature],
        label_boxes: &[Option<egui::Rect>],
    ) {
        // The casing ink for this pane, from the ACTIVE palette. The GPU lane's copy of
        // this decision was a hardcoded near-black navy `#191C24` that no palette switch
        // could reach; both lanes read `theme.palette.casing` now.
        let casing_col = self.theme.palette.casing;
        // ONE projector for the whole frame (stinky `prof-9`): `MapView::project` re-ran
        // `world_px`'s `powf`, the CENTRE's `mercator` (`tan`+`cos`+`ln`) and the ground
        // frame's `sincos` for EVERY vertex, all of them frame-constants. `perf` over 40
        // real frames put libm at 20.3 % of this lane's CPU.
        let proj = self.view.projector();
        let to_px = |lon: f64, lat: f64| {
            let (x, y) = proj.project(lon, lat);
            egui::pos2(x, y) + origin
        };
        // The line width both lanes resolve through — see `MapFacet::line_width_scale`.
        let line_w = |w: f32| self.cpu_line_width_px(w);

        // ── PASS 1 of 2: THE CASING (`kalm-4`) ───────────────────────────────
        //
        // The GPU line lane strokes every way twice — a wider dark band, then the fill
        // on top — and this painter drew a single `Shape::line` with `f.color` and no
        // casing at all. One road, two looks, decided by whether the GPU lane happened
        // to take the frame. And the CPU painter is not a degraded mode: it is what
        // draws under a bearing, under a tilt, on a host with no adapter, and in every
        // `egui_kittest` harness test in this repo — so no CPU-side test could ever see
        // a casing regression, which is why the proportional-casing fix had to be
        // guarded with a real headless wgpu device.
        //
        // ALL casings before ANY fill, which is the part a per-feature
        // casing-then-fill loop gets wrong: egui draws shapes in submission order, so
        // interleaving would let a later road's casing paint over an earlier road's
        // bright core at every single junction. The GPU lane does two whole passes over
        // one vertex buffer for exactly this reason.
        //
        // Only where the GPU did NOT draw: `feats` has already been filtered by
        // `GpuOwns::owns`, so a frame the line lane took contains no `DrawGeom::Line`
        // here and this loop is empty.
        for f in feats.iter() {
            let DrawGeom::Line(pts) = &f.geom else { continue };
            if pts.len() < 2 {
                continue;
            }
            // **A TRANSLUCENT stroke gets no casing.** Every road is opaque; a way that
            // arrives with alpha < 255 is an overlay — a glow halo, a tracer, a
            // highlight — and an OPAQUE dark outline under a translucent wash is the one
            // thing that can never look like light. MEASURED 2026-09-03 (korp's
            // `tests/golden_trail_pixels.rs`): the case trail's 22 px halo pass at
            // alpha 0.09 was drawn over a 26 px opaque casing, so the "glow" rendered as
            // a solid grey band with a coloured thread down the middle — on the day map
            // and the night map alike. Skipping the casing here is what lets a glow
            // ladder read as a glow on this renderer at all.
            if f.color.a() < u8::MAX {
                continue;
            }
            let w = line_w(f.width);
            let sp: Vec<egui::Pos2> = pts.iter().map(|&(lo, la)| to_px(lo, la)).collect();
            painter.add(egui::Shape::line(
                sp,
                egui::Stroke::new(w + 2.0 * crate::style::casing_extra_px(w), casing_col),
            ));
        }

        // ── PASS 2 of 2: everything else, the bright fill on top ─────────────
        // The dashed cores' scratch (HO-T3): `dash_polyline` appends each lit run into
        // `dashes` and assembles it in `dash_run`; both are drained/cleared and their
        // capacity reused by the next rail.
        let mut dashes: Vec<egui::Shape> = Vec::new();
        let mut dash_run: Vec<egui::Pos2> = Vec::new();
        for (i, f) in feats.iter().enumerate() {
            match &f.geom {
                DrawGeom::Point(p) => {
                    painter.circle_filled(to_px(p.lon, p.lat), f.width.max(1.0), f.color);
                }
                DrawGeom::Line(pts) => {
                    let sp: Vec<egui::Pos2> = pts.iter().map(|&(lo, la)| to_px(lo, la)).collect();
                    if sp.len() >= 2 {
                        let w = line_w(f.width);
                        if f.dash {
                            // ── THE DASH LAW, CPU side (HO-T3) ──────────────────
                            // The same period the GPU lane's `line.wgsl` cuts at —
                            // `dash_period_px(w)` of the ONE resolved width, `DASH_DUTY`
                            // lit — and `dash_polyline` walks the path's own arc length
                            // from its first point, exactly as the vertex bake does, so
                            // the two lanes put their dashes in the same places and
                            // neither crawls under a pan. The casing under it was
                            // stroked whole in pass 1; the gaps show it.
                            let period = crate::style::dash_period_px(w);
                            let lit = period * crate::style::DASH_DUTY;
                            dashes.clear();
                            dash_polyline(&sp, lit, period - lit, egui::Stroke::new(w, f.color), &mut dashes, &mut dash_run);
                            painter.extend(dashes.drain(..));
                        } else {
                            painter.add(egui::Shape::line(sp, egui::Stroke::new(w, f.color)));
                        }
                    }
                }
                // ONE mesh, per-vertex alpha, written once — see `glow_ribbon_mesh` for
                // why this is not five strokes.
                DrawGeom::Glow { path, core_w } => {
                    let sp: Vec<egui::Pos2> = path.iter().map(|&(lo, la)| to_px(lo, la)).collect();
                    if sp.len() >= 2 {
                        // The PAPER decides the tone, and the paper is a paint-time fact:
                        // `self.theme.night`. It used to be inferred from the way's own
                        // COLOUR (luminance sum > 383), which is a fact about the ink and
                        // says nothing about what it is drawn on — so a bright neon trail
                        // read as "night" and burned its core WHITE on the day map's cream
                        // `#F2EFE9`. Measured: max channel departure 90 where the line it
                        // replaced reached 204, i.e. a trail you cannot see, which is the
                        // exact failure the ground-awareness exists to prevent.
                        let m = tessellate::glow_ribbon_mesh(&sp, line_w(*core_w), f.color, self.theme.night);
                        if !m.is_empty() {
                            painter.add(egui::Shape::mesh(m));
                        }
                    }
                }
                DrawGeom::Polygon { outer, holes } => {
                    let sp: Vec<egui::Pos2> = outer.iter().map(|&(lo, la)| to_px(lo, la)).collect();
                    if sp.len() >= 3 {
                        // Project the hole rings too, so the fill subtracts them.
                        let hsp: Vec<Vec<egui::Pos2>> = holes
                            .iter()
                            .map(|h| h.iter().map(|&(lo, la)| to_px(lo, la)).collect())
                            .collect();
                        // Tilt-fade the fill (pitched-horizon shading) when present; the
                        // outer + hole outlines are stroked regardless (an unfilled
                        // polygon still shows its boundary), hence `fill: Option`. The
                        // mesh-select + outline recipe lives once in
                        // `tessellate::paint_filled_way` (dedup cluster I); MapFacet
                        // strokes hole outlines → `outline_holes = true`.
                        let fill = f.fill.map(|fill| fade(fill, tilt_fade));
                        // ONE writer for a filled ring's stroke, shared with the GPU
                        // bake (`style::fill_outline_width_px`). It was `max(0.5)` here
                        // and an implicit 1 px there — stinky `mapgpu-1`.
                        let stroke =
                            egui::Stroke::new(crate::style::fill_outline_width_px(f.width), f.color);
                        // `f.tri` is the layer's cached ear clip — the 85.4 % of the
                        // frame that used to be re-derived here every time the camera
                        // moved a pixel. `None` (a layer with no cache) falls back to
                        // the byte-identical per-frame clip.
                        tessellate::paint_filled_way(
                            painter,
                            sp,
                            &hsp,
                            fill,
                            stroke,
                            true,
                            f.tri.as_deref(),
                        );
                    }
                }
                DrawGeom::Label { text, .. } => {
                    // ONLY the labels that won their pixels in `place_map_labels`, and
                    // at the box the placer gave them — which may be an alternate slot
                    // beside the anchor, not the anchor itself. A `None` here is a
                    // label the placer suppressed; painting it anyway would put the
                    // overplot straight back and make the placer's numbers a lie.
                    let Some(Some(bx)) = label_boxes.get(i) else { continue };
                    let font = egui::FontId::proportional(LABEL_FONT_PX);
                    // ── THE HALO ──────────────────────────────────────────────
                    // Four 1 px offsets in the paper colour, then the ink on top. A
                    // name that crosses a road casing or a lake keeps its own
                    // background instead of dissolving into whatever it lies on —
                    // which is what makes it possible to draw FEWER labels and still
                    // read every one of them. Invisible over open land (the halo IS
                    // the land), so it costs nothing where it is not needed.
                    for d in [egui::vec2(-1.0, 0.0), egui::vec2(1.0, 0.0), egui::vec2(0.0, -1.0), egui::vec2(0.0, 1.0)] {
                        painter.text(
                            bx.center() + d,
                            egui::Align2::CENTER_CENTER,
                            text,
                            font.clone(),
                            self.theme.label_halo,
                        );
                    }
                    painter.text(bx.center(), egui::Align2::CENTER_CENTER, text, font, self.theme.label);
                }
            }
        }
    }
}

// ── MapFacet's GPU line lane (feature `wgpu`) ────────────────────────────────
//
// THE REGRESSION THIS CLOSES. korp installs `facett_map::gpu::GpuMapRenderer` into
// eframe's wgpu `RenderState` at startup and its About box said so — while its
// DEFAULT 2D pane (`MapFacet`, `map_carnav: true`) was a pure CPU painter with no
// GPU branch anywhere in it. The renderer was therefore *installed and unreachable
// by construction*: `facett_core::render::lane::lane_use()` reported
// `installed_unused` with 0 frames, and 60 000 shapes/frame were tessellated on one
// core on a box with an RTX 4090. `facett-core/src/render/lane.rs` even names the
// state: "**This is korp's map facet.**"
//
// The lane was already COMPATIBLE — nothing needed inventing, only wiring:
// `MapTransform::new(origin, mercator(center), world_px, world_px, rect)` reproduces
// `MapView::project` exactly at `bearing == tilt == 0`, because both reduce to
// `rect.center + (merc − center_merc) · world_px`. So there is NO second projection
// here (LAW #5); the transform, the geometry bake, the cull viewport and the paint
// callback are all the shared `crate::gpu` ones `MapView::draw_ways_gpu` and
// `facett_geomap::osm2d::OsmView` already drive.
#[cfg(feature = "wgpu")]
impl MapFacet {
    /// **Can the GPU line lane own this frame, and which layers?** Indices into
    /// `self.layers`; empty means "everything on the CPU painter", which is always a
    /// correct answer.
    ///
    /// Pure and side-effect free, so `ui` can ask BEFORE it starts painting (it needs
    /// the answer to build the CPU feature list and to place the callback in the right
    /// z slot).
    ///
    /// ## What falls back to the CPU, explicitly
    ///
    /// * **No renderer installed.** `crate::gpu::renderer_installed()` is false — the
    ///   callback would silently no-op and the pane would lose its roads entirely.
    /// * **A rotated or pitched camera** (`bearing_deg != 0 || tilt_deg != 0`).
    ///   [`crate::gpu::MapTransform`] is the 2D reduction of the unified view core
    ///   with tilt 0 and bearing 0 — it carries `zoom`/`ref`/`screen_center` and *no
    ///   rotation term at all*, so it cannot reproduce `MapView::project`'s
    ///   `ground_frame().apply()`. Sending ways through it under a bearing would
    ///   draw an unrotated basemap under rotated labels and markers. This case
    ///   includes idle auto-rotate the moment it starts spinning.
    /// * **Filled ways** (`Way::fill.is_some()` — water, landuse, buildings, the
    ///   landmass silhouette). The GPU lane is a *line* pipeline; fills stay on
    ///   `tessellate::paint_filled_way`, exactly as in `MapView::draw_ways_gpu`.
    /// * **Points and labels**, which have no `Way` at all.
    fn way_lane_plan(&self) -> (WayLane, Vec<usize>, GpuOwns, Option<i32>) {
        // ── MEMOISED per (layer_epoch, renderer_installed): the scans below walk
        //    every way of every layer TWICE (`.any()` lines + `.any()` fills), and
        //    they ran per frame — plus again per `way_lane()` reader (`state_json`
        //    3.3×/s, korp's severity check) — while their answer can only change
        //    when a layer is pushed/toggled (the epoch) or the renderer latch flips.
        //    `tests/frame_churn_memo.rs` holds `lane_scan_count` at 1 across a
        //    hundred asks (seen red with the memo bypassed).
        let installed = crate::gpu::renderer_installed();
        if let Some((epoch, inst, lane, layers, owns, floor)) = self.lane_plan_memo.lock().unwrap().as_ref()
            && *epoch == self.layer_epoch && *inst == installed
        {
            return (*lane, layers.clone(), *owns, *floor);
        }
        let plan = self.way_lane_plan_scan(installed);
        *self.lane_plan_memo.lock().unwrap() =
            Some((self.layer_epoch, installed, plan.0, plan.1.clone(), plan.2, plan.3));
        plan
    }

    /// The full [`way_lane_plan`](Self::way_lane_plan) scan — `O(ways)` over every
    /// enabled layer, counted in `lane_scans` so "the scan did not run" is a
    /// measurable fact rather than a hope.
    ///
    /// ## WHAT ACTUALLY MAKES IT DECLINE (2026-08-31, lane LANE)
    ///
    /// There are exactly **two** live reasons, and neither is about geometry quality:
    ///
    /// 1. **`CpuOnly::NoRenderer`** — `crate::gpu::renderer_installed()` is false. A
    ///    process-global latch: no adapter, a refused bring-up, or a host that never
    ///    called `gpu::install_renderer`. All-or-nothing for the whole process.
    /// 2. **`CpuOnly::NoLineWays`** — no ENABLED layer answered `Some` to
    ///    [`MapLayer::gpu_ways`] or [`MapLayer::gpu_fills`] with at least one qualifying
    ///    way. Both default to `None` on the trait and **`WaysLayer` is the only
    ///    implementor that overrides them**, so a facet of points, labels or a landmass
    ///    silhouette declines here — correctly, since those have no `Way` at all.
    ///
    /// `CpuOnly::RotatedCamera` is unreachable in this build (the rotation fallback was
    /// deleted when `MapTransform` gained the compass) and `CpuOnly::NotCompiled` is the
    /// non-`wgpu` build. Both are kept so a future decline still has a name.
    ///
    /// So on korp's shape the lane does **not** decline: MEASURED on the real device
    /// (`tests/gpu_lane_share.rs`, RTX 4090 / Vulkan), over a korp-shaped 1 200-way
    /// scene at three cameras from `z6` to `z16`, the plan is `Gpu { layers: 8 }` with
    /// `owns = {lines, fills}` every time and the CPU painter is left **0** ways.
    fn way_lane_plan_scan(&self, installed: bool) -> (WayLane, Vec<usize>, GpuOwns, Option<i32>) {
        if !installed {
            return (WayLane::Cpu(CpuOnly::NoRenderer), Vec::new(), GpuOwns::NONE, None);
        }
        self.lane_scans.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
        // NO rotation fallback any more. `MapTransform` carries the compass bearing
        // and the tilt and projects through the SAME `GroundFrame2d` the CPU painter
        // applies, so a turned camera is a camera the GPU lane can reproduce. What
        // used to be here — `bearing_deg != 0 || tilt_deg != 0 => CpuOnly::RotatedCamera`
        // — cost 0.857 ms -> 12.28 ms (14.3x) and `lane_frames` 249 -> 0 the instant
        // the map started spinning, which is the default idle behaviour.
        //
        // `CpuOnly::RotatedCamera` is deliberately KEPT as a variant: it is still the
        // honest answer for a host that builds a transform this lane cannot express,
        // and deleting it would turn a future "why is this on the CPU?" back into the
        // unanswerable `installed_unused` this whole seam exists to end.
        //
        // NO fill fallback any more either. `GpuFillGeometry` bakes the filled ways'
        // CACHED Mercator ear clip into a triangle list and their rings into a line
        // list, both in the same origin-local space and both culled by the same
        // `cull.wgsl`, so "the GPU lane is a *line* pipeline" stopped being true.
        // What it cost while it was: 20 000 real Swedish ways at slippy zoom 6, real
        // 1600x1000 window on an RTX 4090 -- 0.26 ms for the lines the lane DID take
        // against 9.97 ms for the fills it refused, i.e. 97.4 % of the frame sitting
        // on one core beside an idle 4090.
        let (mut lines, mut fills) = (false, false);
        let layers: Vec<usize> = self
            .layers
            .iter()
            .enumerate()
            .filter(|(_, l)| l.enabled())
            .filter(|(_, l)| {
                let has_lines =
                    l.gpu_ways().is_some_and(|ways| ways.iter().any(|w| w.fill.is_none() && w.lonlat.len() >= 2));
                // The fill lane's OWN predicate, asked once here and re-asked
                // verbatim by `gpu_lane_fills` and by `WaysLayer::features_into`.
                // Three call sites, one condition -- a lake the bake skips is a lake
                // the CPU painter still draws, by construction rather than by review.
                let has_fills = l
                    .gpu_fills()
                    .is_some_and(|(ways, _)| ways.iter().any(Self::gpu_fillable));
                lines |= has_lines;
                fills |= has_fills;
                has_lines || has_fills
            })
            .map(|(i, _)| i)
            .collect();
        if layers.is_empty() {
            return (WayLane::Cpu(CpuOnly::NoLineWays), layers, GpuOwns::NONE, None);
        }
        // ── THE LANE'S RANK FLOOR — where its one opaque callback slot belongs in
        //    the declared stack (see `MapFacet::ui`). Asked through the SAME two
        //    definitions of "what the lane draws" that the bake and the signature go
        //    through (`gpu_lane_ways` / `gpu_lane_fill_refs`), so the slot cannot be
        //    placed for a set the lane does not actually draw. Computed inside the
        //    memoised scan, so it costs nothing per frame.
        let owns = GpuOwns { lines, fills };
        let floor = self.gpu_lane_rank_floor(&layers, owns);
        (WayLane::Gpu { layers: layers.len() }, layers, owns, floor)
    }

    /// **The lowest [`crate::paint_order::PaintKey::rank`] the GPU lane will draw** for
    /// `gpu_layers` under `owns` — where its single opaque callback slot belongs in the
    /// declared stack (see [`MapFacet::ui`]). `None` ⇒ the lane draws nothing.
    ///
    /// Asked through the SAME two definitions of "what the lane draws" the bake and the
    /// signature go through, so the slot cannot be placed for a set the lane does not
    /// actually draw.
    #[cfg(feature = "wgpu")]
    fn gpu_lane_rank_floor(&self, gpu_layers: &[usize], owns: GpuOwns) -> Option<i32> {
        let line_floor = owns
            .lines
            .then(|| self.gpu_lane_ways(gpu_layers).map(crate::paint_order::way_rank).min())
            .flatten();
        let fill_floor = owns
            .fills
            .then(|| self.gpu_lane_fill_refs(gpu_layers).map(|(w, _, _)| crate::paint_order::way_rank(w)).min())
            .flatten();
        match (line_floor, fill_floor) {
            (Some(a), Some(b)) => Some(a.min(b)),
            (a, b) => a.or(b),
        }
    }

    /// **Test hook** — [`Self::gpu_lane_rank_floor`] with the lane ownership named
    /// explicitly, and the split index `ui` derives from it over a given feature list.
    ///
    /// The interesting arm is unreachable otherwise: a headless test has no
    /// `RenderState`, so `way_lane_plan` answers `CpuOnly::NoRenderer` and the frame
    /// never places a callback at all. This is the same arithmetic the live frame runs,
    /// asked of an explicit `GpuOwns` — see `tests/paint_order_pixels.rs`.
    #[doc(hidden)]
    #[cfg(feature = "wgpu")]
    #[must_use]
    pub fn gpu_slot_split_for_test(
        &self,
        gpu_layers: &[usize],
        lines: bool,
        fills: bool,
        feats: &[DrawFeature],
    ) -> (Option<i32>, usize) {
        let floor = self.gpu_lane_rank_floor(gpu_layers, GpuOwns { lines, fills });
        (floor, floor.map_or(feats.len(), |lo| feats.partition_point(|f| f.order.rank < lo)))
    }

    /// The unfilled ways of `gpu_layers`, in layer order — the ONE definition of "what
    /// the GPU lane draws", so the origin, the signature and the geometry bake cannot
    /// disagree about the set (they did once, and a mismatched origin silently culls
    /// every visible way).
    fn gpu_lane_ways<'a>(&'a self, gpu_layers: &'a [usize]) -> impl Iterator<Item = &'a Way> + 'a {
        gpu_layers
            .iter()
            .filter_map(|&i| self.layers.get(i))
            .flat_map(|l| l.gpu_ways().unwrap_or(&[]).iter())
            .filter(|w| w.fill.is_none() && w.lonlat.len() >= 2)
    }

    /// The **filled** ways of `gpu_layers`, each with its cached Mercator ear clip —
    /// the fill lane's twin of [`Self::gpu_lane_ways`], and the ONE definition of
    /// what that lane draws.
    ///
    /// `tris` comes from the layer's own [`tessellate::FillTessCache`], keyed by the
    /// way's index in the layer's slice. Calling it here WARMS a cold slot: that is
    /// deliberate and it is not new work — it is the same one-off ear clip the CPU
    /// painter would have run on the frame it first drew that ring, moved to the bake
    /// so both sides see one triangulation. `MapFacet::ear_clips` still reaches its
    /// ceiling and stops, which is what `tests/fill_tess_cache.rs` asserts.
    fn gpu_lane_fills<'a>(
        &'a self,
        gpu_layers: &'a [usize],
    ) -> impl Iterator<Item = (&'a Way, tessellate::FillTris)> + 'a {
        // `tris` is `None` only for an index past the cache, which cannot happen for
        // a cache built beside its own ring set.
        self.gpu_lane_fill_refs(gpu_layers)
            .filter_map(|(w, cache, i)| cache.tris(i, &w.lonlat, &w.holes).map(|t| (w, t)))
    }

    /// The filled ways of `gpu_layers` with their layer's cache and slot index — the
    /// SAME set [`Self::gpu_lane_fills`] yields (one predicate, `gpu_fillable`), but
    /// with the ear clip NOT yet run. The tile lane wants this: a group served from
    /// the baked-vertex store never needs its triangulation, so the clip is resolved
    /// only inside the bake closure, for the groups that actually bake.
    fn gpu_lane_fill_refs<'a>(
        &'a self,
        gpu_layers: &'a [usize],
    ) -> impl Iterator<Item = (&'a Way, &'a tessellate::FillTessCache, usize)> + 'a {
        gpu_layers
            .iter()
            .filter_map(|&i| self.layers.get(i))
            .filter_map(|l| l.gpu_fills())
            .flat_map(|(ways, cache)| {
                ways.iter().enumerate().filter(|(_, w)| Self::gpu_fillable(w)).map(move |(i, w)| (w, cache, i))
            })
    }

    /// A cheap `O(layers + ways)` signature of the way set + origin currently baked
    /// into VRAM. Never `0` — `0` is the "nothing uploaded yet" sentinel.
    ///
    /// Deliberately NOT a hash of the geometry: it must be cheap enough to run every
    /// frame beside `ways_origin` (which is already `O(ways)` by design). It changes
    /// when a layer is toggled, when the way count changes, when the ways move, and
    /// when korp streams a new viewport in.
    ///
    /// ## Why it folds bbox bits and not `osm_id` (`seam-5`)
    ///
    /// It folded `w.osm_id`, with a comment promising that "two different way SETS of
    /// the same length differ here". **On korp's tiled path every `osm_id` is `0`**,
    /// so that term was the constant `0` and the promise was empty: the signature
    /// reduced to `(origin, layer id, way count, Σ vertices)`. Two viewports that
    /// happen to agree on those three numbers match, `geometry: None` goes to the
    /// device, and VRAM keeps the PREVIOUS view's geometry.
    ///
    /// MEASURED 2026-08-03 against the live tiles under `~/.korp/work/sweden/tiles`:
    /// 28 974 files (z6 19, z9 674, z12 28 281); 45 sampled with pyarrow, 15 per
    /// zoom, ALL with the schema `geometry: binary, tags: string, bbox:
    /// struct<xmin,ymin,xmax,ymax: float>` — **not one `id` column on any zoom**.
    /// `osm-katana`'s `write_tile_parquet` never wrote one and korp's reader
    /// (`mapquery.rs`) falls back to `0` per row, three layers away from here.
    ///
    /// The bbox is the fix because it exists on **both** paths — the monolith way and
    /// the tiled way — and it is already computed and already `O(ways)` to walk. It
    /// is also a strictly better discriminator than the id was even where ids are
    /// real: an id changes when the SET changes, a bbox changes when the set changes
    /// **or when a way moves**. `osm-katana` is being fixed to write the id too (it
    /// is load-bearing elsewhere — `MapFacet::way(id)` and label placement both
    /// collapse onto way #0 without it), but this signature no longer depends on
    /// whoever served the data having done so.
    fn gpu_geometry_signature(&self, gpu_layers: &[usize], origin: [f64; 2]) -> u64 {
        use std::hash::{Hash, Hasher};
        // ── MEMOISED per (origin, epoch, taken layers, their geometry generations):
        //    the walk below is `O(ways)` twice over (bbox fold + vertex count, both
        //    lanes) and it ran every frame — while its inputs are pinned by numbers
        //    that are `O(layers)` to read. A `WaysLayer`'s ways are immutable for its
        //    lifetime (generation stamped in its constructor; korp replaces the layer
        //    on data change), so "same key" ⇒ "same signature" by construction. Any
        //    taken layer WITHOUT that promise (generation 0) bypasses the memo and
        //    pays the old full walk, so external implementors stay correct.
        //    `tests/frame_churn_memo.rs` holds `gpu_sig_walk_count` at 1 across a
        //    hundred asks (seen red with the memo bypassed).
        let mut kh = std::collections::hash_map::DefaultHasher::new();
        origin[0].to_bits().hash(&mut kh);
        origin[1].to_bits().hash(&mut kh);
        self.layer_epoch.hash(&mut kh);
        let mut memoisable = true;
        for &i in gpu_layers {
            i.hash(&mut kh);
            let generation = self.layers.get(i).map_or(0, |l| l.geometry_generation());
            memoisable &= generation != 0;
            generation.hash(&mut kh);
        }
        let key = kh.finish();
        if memoisable
            && let Some((k, sig)) = *self.gpu_sig_memo.lock().unwrap()
            && k == key
        {
            return sig;
        }
        self.gpu_sig_walks.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
        let mut h = std::collections::hash_map::DefaultHasher::new();
        origin[0].to_bits().hash(&mut h);
        origin[1].to_bits().hash(&mut h);
        for &i in gpu_layers {
            let Some(l) = self.layers.get(i) else { continue };
            l.id().hash(&mut h);
            // BOTH lanes' inputs, or a fill-only layer change is invisible to the
            // latch and VRAM keeps the previous viewport's lakes.
            for ways in [l.gpu_ways().unwrap_or(&[]), l.gpu_fills().map_or(&[][..], |(w, _)| w)] {
                ways.len().hash(&mut h);
                // Fold each way's BBOX BITS and vertex count, not its coordinates:
                // one pass of adds, and two different way SETS of the same length
                // differ here.
                let (mut geom, mut verts) = (0u64, 0usize);
                for w in ways {
                    for b in w.bbox {
                        geom = geom.wrapping_mul(31).wrapping_add(b.to_bits());
                    }
                    verts += w.lonlat.len();
                }
                geom.hash(&mut h);
                verts.hash(&mut h);
            }
        }
        let sig = h.finish();
        let sig = if sig == 0 { 1 } else { sig };
        if memoisable {
            *self.gpu_sig_memo.lock().unwrap() = Some((key, sig));
        }
        sig
    }

    /// **Hand the ways to the GPU** — add the `egui_wgpu` paint callback that makes
    /// `GpuMapRenderer` encode a frame (and therefore bump
    /// `facett_core::render::lane`'s frame counter, which is the only proof that the
    /// lane is real).
    ///
    /// Everything here is shared code: [`crate::gpu::ways_origin`],
    /// [`crate::gpu::MapTransform`], [`crate::gpu::build_gpu_geometry`] and
    /// [`crate::gpu::MapPaintCallback`]. The single line of new arithmetic is the
    /// camera reduction, and it is a reduction rather than a projection:
    ///
    /// ```text
    /// MapView::project (bearing 0, tilt 0) = rect.center + (merc − center_merc)·world_px
    /// MapTransform                          = rect.center + (merc − center_merc)·zoom
    /// ```
    ///
    /// so `zoom_x == zoom_y == world_px()` and `center_merc == mercator(center)` is
    /// exact, not approximate. `tests/map_facet_gpu_lane.rs` asserts the two agree
    /// sub-pixel over a spread of zooms, and asserts they DIVERGE under a bearing
    /// (which is why the bearing falls back).
    fn add_gpu_ways_callback(
        &self,
        painter: &egui::Painter,
        rect: egui::Rect,
        gpu_layers: &[usize],
        tilt_fade: f32,
        stamp: crate::gpu::PaneStamp,
    ) {
        use std::sync::atomic::Ordering;

        // ── THE GROUPED LANE, taken whenever the fixed-bucket order could be WRONG ──
        //
        // Two reasons to group, and both land here:
        //
        // 1. **A stamped way** — the content-keyed per-tile VRAM cache that survives
        //    facet rebuilds. Unchanged.
        // 2. **More than one draw band** — new, 2026-08-23. The legacy whole-payload
        //    callback below submits THREE FIXED BUCKETS (all fills, then all outlines,
        //    then all lines). That is the declared order if and only if the payload
        //    holds a single [`crate::paint_order::way_draw_band`]: with two, `PaintKey`
        //    is expressing an order three fixed buckets cannot carry, and the pixels say
        //    so — a `tunnel=yes` road ranks a whole `LAYER_STEP` below the lake it runs
        //    under and still painted on top of it, because a road is a line and lines
        //    are bucket three (`tests/gpu_lane_paint_order.rs`, oden / RTX 4090).
        //
        // A single-band payload takes the legacy path and is byte-identical there, so
        // every consumer that predates the lane keeps exactly the frame it had.
        //
        // ── IT IS ASKED FIRST, AND THAT IS THE POINT ──────────────────────────────
        //
        // The whole-lane `ways_origin` below used to run BEFORE this branch, so korp —
        // which is always the grouped path, because every served way carries a producer
        // stamp — paid an `O(ways)` min-fold over every way of every taken layer, EVERY
        // FRAME, for a value the grouped path then threw away (the tile lane bakes
        // against each GROUP's own origin, in `TileGroupPlan`). `gpu_lane_needs_grouping`
        // short-circuits on the first stamped way, so asking it first is `O(1)` there;
        // on the legacy path it walks, but the legacy path was already going to walk for
        // the origin, so that arm pays the same two walks it always did.
        //
        // The empty case is unchanged: an empty lane set makes this `false` (the loop
        // never runs), so it still falls through to the origin guard below and returns
        // without placing a callback — the same frame, by the same line.
        if self.gpu_lane_needs_grouping(gpu_layers) {
            self.add_gpu_tiled_callback(painter, rect, gpu_layers, tilt_fade, stamp);
            return;
        }

        // The origin spans BOTH lanes' ways. A fill-only pane would otherwise get no
        // origin at all and silently draw nothing, and a mixed pane baked against a
        // line-only origin would put its lakes somewhere else entirely -- the exact
        // "mismatched origin silently culls every visible way" this method's own doc
        // records.
        // `gpu_lane_fill_refs`, not `gpu_lane_fills`: the origin needs the WAYS, not
        // their ear clips — asking for the tris here ran every fill's tessellation
        // before the lane knew whether it needed them at all.
        self.lane_origin_walks.fetch_add(1, Ordering::Relaxed);
        let Some(origin) = crate::gpu::ways_origin(
            self.gpu_lane_ways(gpu_layers).chain(self.gpu_lane_fill_refs(gpu_layers).map(|(w, _, _)| w)),
        ) else {
            return;
        };

        let world_px = self.view.world_px();
        let (cx, cy) = mercator(self.view.center.lon, self.view.center.lat);
        let transform =
            crate::gpu::MapTransform::new(origin, [cx, cy], world_px as f32, world_px as f32, rect)
                .with_compass(self.view.bearing_deg, self.view.tilt_deg);
        let viewport_bbox = transform.viewport_bbox(rect);

        // ── Upload-once, ACKNOWLEDGED. Re-bake while either (a) the enabled way set
        //    changed, or (b) the last hand-over has not yet been confirmed by a real
        //    encoded frame. `lane_frames()` is that confirmation: it only moves inside
        //    `MapPaintCallback::prepare`, after the upload, against a real device. ──
        let sig = self.gpu_geometry_signature(gpu_layers, origin);
        let frames = crate::gpu::lane_frames();
        let same = self.gpu_geom_sig.load(Ordering::Relaxed) == sig;
        let acknowledged = frames > self.gpu_geom_sent_at.load(Ordering::Relaxed);
        let geometry = if same && acknowledged {
            None
        } else {
            if !same {
                // A NEW set: the ack must come from a frame AFTER this hand-over.
                self.gpu_geom_sig.store(sig, Ordering::Relaxed);
                self.gpu_geom_sent_at.store(frames, Ordering::Relaxed);
            }
            let mut geo = crate::gpu::build_gpu_geometry(
                self.gpu_lane_ways(gpu_layers).map(|w| (w, w.style.lod as u32)),
                origin,
            );
            geo.fills = crate::gpu::build_gpu_fill_geometry(
                self.gpu_lane_fills(gpu_layers).map(|(w, t)| (w, w.style.lod as u32, t)),
                origin,
            );
            Some(Box::new(geo))
        };

        painter.add(egui_wgpu::Callback::new_paint_callback(
            rect,
            crate::gpu::MapPaintCallback {
                stamp,
                geometry,
                tiles: None,
                viewport_bbox,
                // The SAME LOD tier the CPU `lod_visible` gate uses, so the GPU cull
                // (`lod <= vp.lod`) and the CPU painter declutter identically.
                lod: view_lod_tier(self.view.zoom),
                transform,
                // The SAME live `widthScale` `paint_features` strokes with (GG #27).
                width_scale: self.line_width_scale(),
                // The SAME pitched-horizon fade `paint_features` applies to a CPU
                // fill (`fade(fill, tilt_fade)` = a `1 - 0.5*t` RGB multiply), so a
                // tilted map's areas recede identically in both lanes. Its outline is
                // NOT faded -- also as on the CPU, which strokes with `f.color`.
                fill_tint: 1.0 - tilt_fade * 0.5,
                // The SAME casing ink `paint_features` strokes its casing pass with
                // (`kalm-4`). It was a `const` in `gpu/mod.rs` -- near-black navy on a
                // warm-paper day map -- and the palette's own `casing` hue measured
                // 0.03-0.06 % of the canvas, i.e. it was not on the map at all.
                casing_col: Some(self.theme.palette.casing.to_normalized_gamma_f32()),
            },
        ));
    }

    /// **Does this payload need the grouped (per-band, per-tile) hand-over?**
    ///
    /// `true` when a way carries a producer tile stamp (the VRAM cache case) OR when the
    /// payload spans more than one [`crate::paint_order::way_draw_band`] — see
    /// [`Self::add_gpu_ways_callback`] for why the second one is not optional.
    ///
    /// ONE scan for both questions, and it short-circuits: real served ways answer on
    /// the first two features, and the honest worst case (one band, no stamps) is the
    /// same `O(ways)` walk the `tile_key != 0` test it replaces already paid per frame.
    #[cfg(feature = "wgpu")]
    fn gpu_lane_needs_grouping(&self, gpu_layers: &[usize]) -> bool {
        let mut first: Option<i32> = None;
        for w in self
            .gpu_lane_ways(gpu_layers)
            .chain(self.gpu_lane_fill_refs(gpu_layers).map(|(w, _, _)| w))
        {
            if w.tile_key != 0 {
                return true;
            }
            let band = crate::paint_order::way_draw_band(w);
            match first {
                None => first = Some(band),
                Some(f) if f != band => return true,
                Some(_) => {}
            }

The file goes on; the console shows all of it.