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

//! **facett-map** — a geo viewer: a **point** layer plus a **vector basemap**
//! (roads / coastlines / water as lines, or closed rings drawn as filled
//! silhouettes + crisp outlines). Plots `(lon, lat)` with **Web Mercator** (the
//! projection osm-viewer's `gpu_backend::CpuBackend` uses), so it lines up with
//! real tiles.
//!
//! Two render paths share one `ui()`:
//!
//! - **CPU painter** (default) — points as dots; ways as themed line segments,
//!   or, when a way carries a `fill`, as a filled silhouette with a crisp
//!   outline on top. Always available, headless-testable, never panics.
//! - **wgpu GPU kernel** (feature `wgpu`) — the upload-once compute-cull +
//!   `draw_indirect` pipeline **ported from katana-osm's `osm-viewer`**
//!   (`render::gpu::GpuMapRenderer` / `MapPaintCallback`). All way geometry is
//!   uploaded once; pan/zoom only updates a uniform; the GPU frustum-culls every
//!   frame. (The GPU path draws the line basemap; filled silhouettes still draw
//!   on the CPU on top — the rabbit/synthetic case never needs the GPU cull.)
//!
//! Source-agnostic: the host loads points + ways (e.g. korp's map load from
//! osm-katana GeoParquet, via [`facett_osm::read_points`] / `read_ways`) and
//! hands them here. **Graceful:** no ways → just points (the original behavior —
//! the point-only [`MapView::new`] API is unchanged). When the host has no real
//! basemap it can hand a *synthetic* one; facett-demo feeds the **znippy rabbit**
//! silhouette (see `facett_core::rabbit`) as the synthetic test basemap.

use facett_core::{Elm, FacetCaps, Theme, theme};
use serde::{Deserialize, Serialize};
use std::borrow::Cow;

pub mod index;
// **The building-height decision** — the OSM `height` / `building:levels` grammar,
// its plausibility ceilings and the measured `METRES_PER_LEVEL`. Split out of this
// file because it is the second text of one decision (the first is
// `osm_katana::shared::height`) and a reader has to be able to find the whole of it
// in one place; see the module docs for the corpus that keeps the two in step.
pub mod height;
pub use height::{
    DEFAULT_BUILDING_HEIGHT_M, LengthUnit, MAX_PLAUSIBLE_HEIGHT_M, MAX_PLAUSIBLE_LEVELS,
    METRES_PER_FOOT, METRES_PER_INCH, METRES_PER_LEVEL, parse_building_height_m, parse_length,
    parse_levels,
};

// **THE declared paint order** — one key both render lanes sort on, so a feature's
// place in the stack is a property of the feature and never of when it was read.
// See the module docs for the four mechanisms that used to decide it instead.
pub mod paint_order;
pub mod project;
pub mod style;
pub mod tessellate;

// The selectable Map trait — ONE trait, ALL maps (consolidation design §3.1).
// Additive: `MapLayer` (selectable + stackable sources), the rotatable/tiltable
// `layer::MapView` camera, `MapFacet` (Vec<Box<dyn MapLayer>> + shared renderer),
// and `MapTheme`. The legacy point/ways `MapView` facet below is unchanged.
pub mod layer;

// The Google-Maps-style feature-layer toggles (LAYERS-1) — the shared, engine-
// agnostic `LayerSet` model both map engines (2D `MapView`, 3D `Map3D`) filter
// their `Way` feed through, plus the `LayerToggles` checkbox Facet. Fed by
// `facett-osm` (and any external collector) via `LayerCategory::from_osm_tags`.
pub mod layerset;

#[cfg(feature = "wgpu")]
pub mod gpu;

// CONS-CORE Phase C: the opt-in L0 SDF points/markers lane (routes the point layer
// through `facett_core`'s shared `GpuSdfRenderer`). The default `MapView::ui` point
// painter is UNCHANGED — this is an additional GPU-points path.
#[cfg(feature = "wgpu")]
pub mod l0_points;

pub use style::{
    CLASSIFIED_KEYS, CLASSIFIED_PAIRS, LINE_WIDTH_MAX_PX, LINE_WIDTH_MIN_PX, Lod, MapPalette, OVERPRINT_DAY,
    OVERPRINT_NIGHT, PaletteMode, WayStyle, classify, classify_with, has_nothing_to_draw, line_width_px,
    line_width_px_scaled, line_width_scale, untagged_source_style,
};
pub use layerset::{LayerCategory, LayerSet, LayerToggles, Symbol};
pub use paint_order::{OsmZ, PaintKey, parse_osm_z, way_draw_band, way_paint_key, way_rank};

const LAT_LIMIT: f64 = 85.05112878; // Web Mercator clamp

/// Web Mercator, normalised to `0..1` (x east, y south). Matches osm-viewer.
pub fn mercator(lon: f64, lat: f64) -> (f64, f64) {
    let lat = lat.clamp(-LAT_LIMIT, LAT_LIMIT);
    let x = (lon + 180.0) / 360.0;
    let lat_r = lat.to_radians();
    let y = (1.0 - (lat_r.tan() + 1.0 / lat_r.cos()).ln() / std::f64::consts::PI) / 2.0;
    (x, y)
}

/// **The inverse of [`mercator`]** — normalised `0..1` Web Mercator back to
/// `(lon, lat)` in degrees.
///
/// Every projection in this crate was one-way until a drag gesture needed to ask *where
/// on the ground did the pointer just move to*. Round-trips `mercator` to within f64
/// precision inside the `±LAT_LIMIT` band it clamps to; outside that band `mercator` is
/// lossy by design and this returns the clamped edge, which is the honest answer.
#[must_use]
pub fn unmercator(x: f64, y: f64) -> (f64, f64) {
    let lon = x * 360.0 - 180.0;
    let lat = (std::f64::consts::PI * (1.0 - 2.0 * y)).sinh().atan().to_degrees();
    (lon, lat.clamp(-LAT_LIMIT, LAT_LIMIT))
}

/// What an OSM way represents — drives styling and the `state_json` breakdown.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum WayKind {
    Road,
    Path,
    Coastline,
    Water,
    Park,
    Building,
    Other,
    /// **Geometry from a source with NO OSM TAGS AT ALL** — a plain WKB table the
    /// operator explicitly pointed the host at (Trafikverket, NVDB, a survey export).
    /// See [`style::untagged_source_style`].
    ///
    /// **Why it is a KIND and not just an ink** (lane TL, 2026-09-03). It was
    /// [`Other`](Self::Other) first, and that was wrong in a way only a palette swap
    /// shows: [`MapPalette::recolor_way`](style::MapPalette::recolor_way) reskins by
    /// kind, and `Other` reskins to `road_generic` — so the first
    /// `OsmView::cartographic(true)` after the read repainted the operator's whole
    /// overlay in the exact ink lane SI took off the water. MEASURED by
    /// `facett-demo`'s `a_tagless_ways_source_reaches_the_deck_as_overprint`, which
    /// read the source correctly and then reported **0 of 8** overprint ways on the
    /// deck. A kind of its own is what makes the ink survive a reskin.
    Overprint,
    /// **A GLOW trail** — a spirit-trace ribbon, painted as ONE per-vertex-alpha mesh
    /// (`DrawGeom::Glow`) rather than stroked.
    ///
    /// A KIND for the same reason [`Overprint`](Self::Overprint) is one, and the same
    /// evidence: `MapPalette::recolor_way` reskins BY KIND, so a glow that called itself
    /// `Other` would be repainted into `road_generic` by the first cartographic reskin
    /// and stop being a glow at all. It also has to survive the paint-order sort as
    /// itself — a trail belongs above the roads it crosses, not among them.
    Glow,
    /// **A RAILWAY** — `railway=rail|narrow_gauge` (the national network) and
    /// `light_rail|subway|tram|funicular|preserved|miniature` (urban rail). GG audit
    /// row 28, *"rail has no kind"*.
    ///
    /// A KIND because the paint order and the reskin both branch on the kind, and both
    /// were wrong while a rail was [`Other`](Self::Other): `paint_order::class_rank`'s
    /// catch-all put every rail at the OVERLAY offset above every road, and
    /// `MapPalette::recolor_way` reskinned it to `road_generic` on the first palette
    /// swap. See `paint_order::RAIL_CLASS` for where it now sits in the road ladder and
    /// `MapPalette::rail` for the ink.
    ///
    /// NOT a rail: `railway=abandoned|disused|razed|construction|platform|station|…` —
    /// ground truth about the ground, not the network. Those keep `Other` and the third
    /// (`Lod::City`) rung, exactly as `classify_with`'s `railway` arm had them.
    Rail,
}

impl WayKind {
    pub fn as_str(self) -> &'static str {
        match self {
            WayKind::Road => "road",
            WayKind::Path => "path",
            WayKind::Coastline => "coastline",
            WayKind::Water => "water",
            WayKind::Park => "park",
            WayKind::Building => "building",
            WayKind::Other => "other",
            WayKind::Overprint => "overprint",
            WayKind::Glow => "glow",
            WayKind::Rail => "rail",
        }
    }

    /// **The inverse of [`as_str`](Self::as_str)** — `None` for a string this vocabulary
    /// does not declare.
    ///
    /// # Why a second painter needs this (lane GG #4)
    ///
    /// [`Way::line`] stamps `style.kind = WayKind::Other` on **every** way it builds,
    /// whatever `kind` string it was handed, so `style.kind` cannot answer "what is this"
    /// for a line the host minted rather than classified — and both korp's overlay ink and
    /// this repo's own road fixtures come through `Way::line`. The free-form
    /// [`Way::kind`] string is what the 3D extruder has always branched on
    /// (`w.kind == "road"`), so that is the field that has to reach the declared order.
    ///
    /// `None` is meaningful and is NOT an error: a host's own ink (`"flight-track"`, a
    /// case link, a graph edge) is deliberately outside this vocabulary, and the caller
    /// treats it as [`Other`](Self::Other) — which the declared order already places above
    /// the road network, exactly where overlay ink belongs.
    #[must_use]
    pub fn from_kind_str(s: &str) -> Option<Self> {
        Some(match s {
            "road" => WayKind::Road,
            "path" => WayKind::Path,
            "coastline" => WayKind::Coastline,
            "water" => WayKind::Water,
            "park" => WayKind::Park,
            "building" => WayKind::Building,
            "other" => WayKind::Other,
            "overprint" => WayKind::Overprint,
            "glow" => WayKind::Glow,
            "rail" => WayKind::Rail,
            _ => return None,
        })
    }
}

/// The **roof silhouette** of a building footprint, parsed from the OSM `roof:shape`
/// tag (see [`parse_roof_shape`]). Drives the 3D extruder's roof geometry: a `Flat`
/// roof is the classic top-cap prism (the byte-stable default), while `Gabled` /
/// `Hipped` grow real pitched roof geometry above the eave. Only meaningful for
/// buildings (every other kind lies flat) and only *rendered* when the caller opts
/// into pitched roofs (`RoofStyle::Pitched` in `facett-map3d`) — the extrude default
/// keeps every footprint flat so the render goldens stay byte-identical.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Serialize, Deserialize)]
pub enum RoofShape {
    /// A flat top cap — the classic prism roof (default; also the fallback for any
    /// unrecognised / large footprint).
    #[default]
    Flat,
    /// A two-slope ridged roof (OSM `roof:shape=gabled|gambrel|round|...`).
    Gabled,
    /// A four-slope roof rising to a central apex/ridge (OSM
    /// `roof:shape=hipped|pyramidal|...`).
    Hipped,
}

impl RoofShape {
    /// The lower-case OSM-ish name (for the `state_json` breakdown / debugging).
    pub fn as_str(self) -> &'static str {
        match self {
            RoofShape::Flat => "flat",
            RoofShape::Gabled => "gabled",
            RoofShape::Hipped => "hipped",
        }
    }
}

/// **Look one OSM tag up.** The single reader of a tag list in facett-map.
///
/// There were nine hand-rolled copies of `tags.iter().find(|(t, _)| t == k)` across
/// this crate — in the roof parser, the colour parsers, the height parser, the
/// maxspeed parser, the oneway parser, `classify_with`, `classify_label` and
/// `from_osm_tags`. Each of them also pinned the list to `&[(String, String)]`, and
/// that pin is what forced every caller to OWN its tags: facett-osm's reader
/// allocated two `String`s per pair — on the Sweden extract 8 513 674 tagged rows
/// with up to 23 360 302 pairs, so roughly 55 M heap allocations per read, every one
/// a copy of bytes already sitting in the Arrow buffer (stinky `korpdep-15` /
/// `prof-2`, which measured `parse_tags` at 55.9 % of ALL allocations in a real tile
/// decode).
///
/// So the lookup is generic over anything that is a string, and lives once. A caller
/// may pass `&[(String, String)]`, `&[(&str, &str)]` or `&[(&str, Cow<str>)]` — the
/// classifier only ever reads.
pub fn tag<'a, K: AsRef<str>, V: AsRef<str>>(tags: &'a [(K, V)], key: &str) -> Option<&'a str> {
    tags.iter().find(|(k, _)| k.as_ref() == key).map(|(_, v)| v.as_ref())
}

/// **The empty tag list, typed.** A bare `&[]` no longer infers a pair type now that
/// the tag readers are generic, so this is the spelling for "this feature has no
/// tags" — one name instead of a `&[] as &[(&str, &str)]` cast at every call site.
pub const NO_TAGS: &[(&str, &str)] = &[];

/// Is this tag present at all, whatever its value? The `has` twin of [`tag`].
pub fn has_tag<K: AsRef<str>, V: AsRef<str>>(tags: &[(K, V)], key: &str) -> bool {
    tags.iter().any(|(k, _)| k.as_ref() == key)
}

/// Parse the OSM `roof:shape` tag into a [`RoofShape`]. Recognises the common
/// pitched spellings and maps the many long-tail values onto the two supported
/// pitched geometries (gabled = two-slope ridge, hipped = four-slope apex);
/// anything else (or absent) is [`RoofShape::Flat`]. Case-insensitive.
pub fn parse_roof_shape<K: AsRef<str>, V: AsRef<str>>(tags: &[(K, V)]) -> RoofShape {
    let Some(v) = tag(tags, "roof:shape").map(|v| v.trim().to_ascii_lowercase()) else {
        return RoofShape::Flat;
    };
    match v.as_str() {
        // Ridge-with-two-slopes family.
        "gabled" | "gambrel" | "round" | "saltbox" | "half-hipped" | "gabled_row" | "sawtooth" => RoofShape::Gabled,
        // Apex / four-slope family.
        "hipped" | "pyramidal" | "pyramidal_directional" | "dome" | "cone" | "conical" | "onion" | "mansard" => {
            RoofShape::Hipped
        }
        // "flat", "skillion", "" and anything unknown → flat top cap.
        _ => RoofShape::Flat,
    }
}

/// Parse a colour STRING from an OSM `*:colour` tag value: a `#rrggbb` / `#rgb` hex,
/// or one of the common CSS / OSM colour names. Returns `None` for an unparsable /
/// empty value (the caller then falls back to a class default). Case-insensitive.
pub fn parse_osm_colour(value: &str) -> Option<egui::Color32> {
    let s = value.trim().to_ascii_lowercase();
    if s.is_empty() {
        return None;
    }
    // Hex: #rgb or #rrggbb.
    if let Some(hex) = s.strip_prefix('#') {
        let bytes = hex.as_bytes();
        let parse2 = |a: u8, b: u8| -> Option<u8> {
            let hv = |c: u8| (c as char).to_digit(16).map(|d| d as u8);
            Some(hv(a)? * 16 + hv(b)?)
        };
        match bytes.len() {
            6 => {
                let r = parse2(bytes[0], bytes[1])?;
                let g = parse2(bytes[2], bytes[3])?;
                let b = parse2(bytes[4], bytes[5])?;
                return Some(egui::Color32::from_rgb(r, g, b));
            }
            3 => {
                // #rgb → #rrggbb (each nibble doubled).
                let r = parse2(bytes[0], bytes[0])?;
                let g = parse2(bytes[1], bytes[1])?;
                let b = parse2(bytes[2], bytes[2])?;
                return Some(egui::Color32::from_rgb(r, g, b));
            }
            _ => return None,
        }
    }
    // A small palette of the CSS / OSM colour names buildings actually carry.
    let rgb = match s.as_str() {
        "white" => (245, 245, 245),
        "black" => (30, 30, 30),
        "grey" | "gray" | "silver" => (160, 160, 160),
        "darkgrey" | "darkgray" | "dark_grey" | "dark_gray" => (90, 90, 96),
        "lightgrey" | "lightgray" => (205, 205, 205),
        "red" | "darkred" => (168, 62, 50),
        "brown" => (128, 88, 60),
        "maroon" => (120, 55, 45),
        "orange" => (210, 130, 60),
        "yellow" | "gold" => (216, 190, 110),
        "beige" | "cream" => (226, 214, 180),
        "green" | "darkgreen" => (86, 120, 78),
        "blue" | "navy" => (76, 100, 140),
        "pink" => (214, 168, 172),
        "tan" | "sand" => (208, 186, 150),
        "cream_white" | "offwhite" | "off_white" => (238, 232, 214),
        _ => return None,
    };
    Some(egui::Color32::from_rgb(rgb.0, rgb.1, rgb.2))
}

/// Resolve a building's **facade colour** from OSM tags: `building:colour` (British
/// or American spelling) parsed via [`parse_osm_colour`]. `None` when absent /
/// unparsable — the extruder keeps the themed fill so the default look is unchanged.
pub fn parse_building_colour<K: AsRef<str>, V: AsRef<str>>(tags: &[(K, V)]) -> Option<egui::Color32> {
    tag(tags, "building:colour").or_else(|| tag(tags, "building:color")).and_then(parse_osm_colour)
}

/// Resolve a building's **roof colour** from OSM tags: `roof:colour` wins; otherwise
/// map a `roof:material` onto a sensible tint (tile → terracotta, metal → zinc grey,
/// slate → charcoal, …). `None` when neither is present (the extruder then applies a
/// tasteful class default so the roofs still read distinct from the facades).
pub fn parse_roof_colour<K: AsRef<str>, V: AsRef<str>>(tags: &[(K, V)]) -> Option<egui::Color32> {
    let get = |k: &str| tag(tags, k);
    if let Some(c) = get("roof:colour").or_else(|| get("roof:color")).and_then(parse_osm_colour) {
        return Some(c);
    }
    let mat = get("roof:material")?.trim().to_ascii_lowercase();
    let rgb = match mat.as_str() {
        "roof_tiles" | "tile" | "tiles" | "clay" | "terracotta" => (176, 92, 66),
        "slate" => (74, 78, 86),
        "metal" | "metal_sheet" | "zinc" | "steel" | "tin" => (150, 154, 160),
        "copper" => (108, 150, 128),
        "concrete" | "cement" => (156, 152, 146),
        "gravel" | "tar_paper" | "asphalt" | "bitumen" => (78, 76, 74),
        "grass" | "green" => (96, 124, 82),
        "glass" => (150, 178, 196),
        "wood" | "shingle" | "wood_shingle" => (140, 104, 72),
        "thatch" => (176, 150, 96),
        _ => return None,
    };
    Some(egui::Color32::from_rgb(rgb.0, rgb.1, rgb.2))
}

/// One vector-basemap way. Carries **both** representations so the two render
/// paths coexist:
///
/// - `points`/`bbox` — vertices in **Mercator unit** space (`[0,1]²`, `f32`) +
///   the precomputed Mercator bbox. This is what the GPU geometry builder + the
///   GPU/CPU frustum cull consume (the ported osm-viewer line basemap).
/// - `lonlat` — the source `(lon, lat)` WGS-84 vertices, kept so the CPU painter
///   can fit + draw **filled silhouettes** (the synthetic-rabbit closed-ring
///   look) without re-deriving them.
///
/// `style` is the themed classification (colour/width/LOD/kind) used by the GPU
/// path; `fill`, `stroke`, `width`, `kind` drive the CPU silhouette/outline.
#[derive(Clone, Debug)]
pub struct Way {
    /// `[x0, y0, x1, y1]` Mercator-unit bounding box (for the GPU/CPU cull), in
    /// **f64** — see [`Way::project`] for why it is not derived from the f32
    /// [`points`](Self::points).
    pub bbox: [f64; 4],
    /// Polyline vertices in **Mercator unit** space (already projected).
    pub points: Vec<(f32, f32)>,
    /// `(lon, lat)` WGS-84 source vertices (a polyline; closed if first ≈ last).
    pub lonlat: Vec<(f64, f64)>,
    /// Resolved style (colour / width / LOD / kind) — drives the GPU line layer.
    pub style: WayStyle,
    /// Crisp outline stroke colour (CPU painter).
    pub stroke: egui::Color32,
    /// Stroke width in logical px (CPU painter).
    pub width: f32,
    /// Optional fill colour — set → the ring is painted as a filled silhouette.
    pub fill: Option<egui::Color32>,
    /// **Hole (inner) rings** in `(lon, lat)`, subtracted from the [`fill`](Self::fill)
    /// when the area is painted — a lake's islands, a landuse donut's courtyard.
    /// Empty for the common single-ring area (and every line), so a plain footprint
    /// tessellates exactly as before. A WKB **polygon-with-holes** decodes its outer
    /// ring into [`lonlat`](Self::lonlat) and its inner rings here; a
    /// **multipolygon** decodes into one [`Way`] per member (each with its own
    /// holes). The CPU fill painter punches these via
    /// [`tessellate::filled_mesh_with_holes`](crate::tessellate::filled_mesh_with_holes).
    pub holes: Vec<Vec<(f64, f64)>>,
    /// Source OSM/id (kept for hit-testing / debugging; 0 if unknown).
    pub osm_id: i64,
    /// Free-form kind tag for the `state_json` breakdown (e.g. "body", "ear",
    /// or the [`WayKind`] string for real OSM ways).
    pub kind: &'static str,
    /// Real-world building height in **metres**, parsed from the OSM tags by
    /// [`parse_building_height_m`] (`height=<m>`/`<ft>` wins, else
    /// `(building:levels + roof:levels)` × [`METRES_PER_LEVEL`]). `None` when the way
    /// carries no usable height tag — **including when it carries a junk one**, which is
    /// the point: the 3D extruder then draws its [`DEFAULT_BUILDING_HEIGHT_M`] box
    /// instead of a 99 999 m spike. Only buildings carry this; roads / water / landuse
    /// leave it `None` (they lie flat in 3D anyway).
    pub height_m: Option<f32>,
    /// The OSM `name` tag, kept **only for roads** (`""` for every other kind, and
    /// for an untagged road) — the car-navigator street-label source. Other `Way`
    /// kinds don't carry a name today (nothing currently reads it); scoping this to
    /// roads keeps the common case (a country's worth of buildings) from allocating
    /// a `String` per footprint for a value nothing uses.
    pub name: String,
    /// The OSM `maxspeed` tag in **km/h**, parsed for roads only (`mph` converted).
    /// `None` when untagged / unparsable. Drives the 3D street-glow's "how fast /
    /// how important" tint alongside the road class.
    pub maxspeed_kmh: Option<f32>,
    /// The OSM `oneway` direction, roads only: `1` = one-way **along** the vertex
    /// order, `-1` = one-way **against** it (`oneway=-1`), `0` = two-way (or a
    /// non-road). `junction=roundabout`/`circular` implies `1` (roundabouts are
    /// one-way by convention). The 2D car-navigator draws direction chevrons along
    /// one-way roads; `0` draws none. Kept only for roads (every other kind is `0`),
    /// mirroring [`name`](Self::name) / [`maxspeed_kmh`](Self::maxspeed_kmh).
    pub oneway: i8,
    /// The building's **roof silhouette** from OSM `roof:shape` ([`parse_roof_shape`]);
    /// [`RoofShape::Flat`] for non-buildings and untagged/flat roofs. The 3D extruder
    /// grows real pitched geometry for `Gabled`/`Hipped` **only** when the caller opts
    /// into pitched roofs; the default extrude keeps every roof flat (golden-stable).
    pub roof_shape: RoofShape,
    /// The building's **facade colour** from OSM `building:colour` ([`parse_building_colour`]).
    /// `None` (the common case) → the extruder keeps the themed fill. Buildings only.
    pub building_colour: Option<egui::Color32>,
    /// The building's **roof colour** from OSM `roof:colour` / `roof:material`
    /// ([`parse_roof_colour`]). `None` → a tasteful class default in the pitched path.
    pub roof_colour: Option<egui::Color32>,
    /// **The GROUP this way uploads with on the GPU tile lane** — an opaque grouping
    /// id the PRODUCER stamps (korp stamps the covering tile of the way's bbox-min,
    /// packed `z|x|y`, a pure way property so it is stable across viewports). `0` =
    /// unstamped: the way rides the REST bucket of the tiled hand-over, or — when NO
    /// enabled way in the facet carries a stamp — the legacy whole-payload path,
    /// byte-identical to before this field existed. Never persisted by facett;
    /// grouping only. The VRAM cache keys on CONTENT (see
    /// `layer::tile_content_key`), not on this id.
    pub tile_key: u64,
    /// **The OSM z-order tags** — `layer`, `bridge`, `tunnel` — parsed off this way's
    /// own tags and fed to [`crate::paint_order::way_rank`].
    ///
    /// They were read NOWHERE before 2026-08-22 (grep over `facett-map`, `facett-osm`
    /// and `korp/src/map_paint.rs` returned nothing), so a motorway viaduct and the
    /// river under it had no order to go on beyond their classes — and a bridge sank
    /// under the water whenever the water happened to decode later. Default (all
    /// zero) is ground level, which every synthetic and untagged way keeps.
    pub z_order: crate::paint_order::OsmZ,
    /// **A paint rank the PRODUCER already computed**, when there is one — lane GG #17.
    ///
    /// `None` is the normal case and means [`crate::way_rank`] derives the rank from this
    /// way as it always has. `Some` means a producer that had already done the work sent
    /// the answer along, and `way_rank` returns it instead of re-deriving.
    ///
    /// It is not a second opinion about the paint order: `way_rank` is still the only
    /// function that answers the question, and the only way to fill this field is to have
    /// called it. korp fills it server-side (proto `MapPolyline.rank`, additive fields
    /// 6+7) because every client was re-deriving it per way per landing — `parse_osm_z`
    /// walking the tag list, then `class_rank` and `tier_rank` — on the UI thread.
    /// MEASURED on oden: 9.77 ms of serial tag parse per 20 000-way landing.
    ///
    /// `Option` and not a sentinel, because `0` is a REAL rank (the `effective_layer = 0`
    /// band boundary), so "absent" and "zero" have to be distinguishable or every way from
    /// a producer that sends nothing sorts at the area floor.
    pub rank: Option<i32>,
}

/// Parse an OSM `maxspeed` tag into **km/h**: a bare number is already km/h; a
/// `"<n> mph"` suffix converts. Tolerant of the common zone/implicit spellings
/// (`"DE:zone30"`, `"walk"`, `"none"`, `"signals"`) by simply yielding `None` for
/// anything with no leading digit — the 3D glow then falls back to class alone.
pub fn parse_maxspeed_kmh<K: AsRef<str>, V: AsRef<str>>(tags: &[(K, V)]) -> Option<f32> {
    let v = tag(tags, "maxspeed")?;
    let trimmed = v.trim();
    let num: String = trimmed.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();
    let n: f32 = num.parse().ok().filter(|m: &f32| m.is_finite() && *m > 0.0)?;
    if trimmed.to_ascii_lowercase().ends_with("mph") { Some(n * 1.609_34) } else { Some(n) }
}

/// Parse an OSM `oneway` direction for a road into `{-1, 0, 1}`: `1` = one-way
/// along the vertex order (`yes`/`true`/`1`), `-1` = against it (`-1`/`reverse`),
/// `0` = two-way (`no`/`false`/`0`/absent). A `junction=roundabout`/`circular`
/// with no explicit `oneway` is treated as one-way (`1`) — roundabouts flow one
/// direction by convention. Drives the 2D one-way direction chevrons.
pub fn parse_oneway<K: AsRef<str>, V: AsRef<str>>(tags: &[(K, V)]) -> i8 {
    let get = |k: &str| tag(tags, k).map(|v| v.trim().to_ascii_lowercase());
    if let Some(v) = get("oneway") {
        return match v.as_str() {
            "yes" | "true" | "1" => 1,
            "-1" | "reverse" => -1,
            _ => 0,
        };
    }
    // A roundabout / circular junction is implicitly one-way.
    match get("junction").as_deref() {
        Some("roundabout") | Some("circular") => 1,
        _ => 0,
    }
}

impl Way {
    /// **Replace this way's `(lon, lat)` vertices**, re-deriving the Mercator `points`
    /// and the `bbox` from them.
    ///
    /// The three are one quantity in three representations, and a caller that edits the
    /// geometry must not be able to update one and forget the others: a stale `bbox`
    /// culls a way that is on screen, and stale `points` draw it in the wrong place.
    /// This is the seam for geometry edits — clipping a polyline to a window
    /// (`facett_map3d::extrude::densest_cluster_subset`) is the first caller — so the
    /// re-projection happens in exactly one place, the same one the constructors use.
    #[must_use]
    pub fn with_lonlat(mut self, lonlat: Vec<(f64, f64)>) -> Self {
        let (points, bbox) = Self::project(&lonlat);
        self.points = points;
        self.bbox = bbox;
        self.lonlat = lonlat;
        self
    }

    /// Project `(lon, lat)` vertices to Mercator-unit space + compute the bbox.
    /// Project to Mercator and take the envelope.
    ///
    /// **The envelope is computed in f64, from `lonlat`, NOT from the f32 `points`**
    /// (stinky `seam-15`). The two are not the same number and the difference is not
    /// small: `points` goes through `to_mercator_f32(lon as f32, lat as f32)`, which
    /// quantises twice — once on the input degrees and once on the f32 output — and
    /// this crate already knows that stream is too coarse to draw from (see `points`'s
    /// own doc: "snap to a ~5px grid at ZOOM_MAX"), which is why the painter and the
    /// GPU bake both reproject out of `lonlat` in f64. The bbox never got the same
    /// treatment, and the bbox is what EVERY cull rests on: `ways_origin`, the
    /// `GpuWayMeta` the compute cull tests, the `MapIndex` grid, and
    /// `WaysLayer::draws`, which compares it against a viewport derived in f64.
    ///
    /// An f32 ULP near Mercator y ≈ 0.30 (Stockholm) is ~3·10⁻⁸ units, which at
    /// `MAX_ZOOM = 22` (`world_px ≈ 1.07·10⁹`) is ~32 screen px: an edge way could be
    /// culled while visible, or survive while not. Computing it in f64 costs one extra
    /// `mercator()` per vertex on a path that already runs one per vertex for the
    /// points, and removes the whole class.
    fn project(lonlat: &[(f64, f64)]) -> (Vec<(f32, f32)>, [f64; 4]) {
        let points: Vec<(f32, f32)> =
            lonlat.iter().map(|&(lon, lat)| project::to_mercator_f32(lon as f32, lat as f32)).collect();
        let (mut x0, mut y0) = (f64::MAX, f64::MAX);
        let (mut x1, mut y1) = (f64::MIN, f64::MIN);
        for &(lon, lat) in lonlat {
            let (x, y) = crate::mercator(lon, lat);
            x0 = x0.min(x);
            y0 = y0.min(y);
            x1 = x1.max(x);
            y1 = y1.max(y);
        }
        (points, [x0, y0, x1, y1])
    }

    /// Is this vertex loop a (near-)closed ring? OSM area ways usually repeat the
    /// first vertex as the last; we also accept a loop that comes back close to
    /// where it started (tolerance in lon/lat degrees), so an area drawn without
    /// the explicit closing vertex still fills.
    fn is_closed_ring(lonlat: &[(f64, f64)]) -> bool {
        if lonlat.len() < 4 {
            return false;
        }
        let (a, b) = (lonlat[0], lonlat[lonlat.len() - 1]);
        let tol = 1e-7;
        (a.0 - b.0).abs() < tol && (a.1 - b.1).abs() < tol
    }

    /// Build a way from `(lon, lat)` WGS-84 vertices, projecting to Mercator unit
    /// space and computing the bbox. `tags` classify it through `theme`. This is
    /// the **real-OSM** constructor: an **area** way (water / park / landuse /
    /// building) whose geometry is a closed ring becomes a **filled polygon**
    /// (themed fill + a crisp outline); a **line** way (road / path / coastline),
    /// or an unclosed area, strokes only.
    pub fn from_lonlat<K: AsRef<str>, V: AsRef<str>>(
        osm_id: i64,
        lonlat: &[(f64, f64)],
        tags: &[(K, V)],
        theme: &Theme,
    ) -> Option<Self> {
        if style::has_nothing_to_draw(tags) {
            return None;
        }
        Self::from_lonlat_styled(osm_id, Cow::Borrowed(lonlat), tags, classify(tags, theme)).ok()
    }

    /// **The fewest vertices that can BE a way** — the one gate every constructor
    /// refuses on, exposed so a caller that must decide *before* handing its `Vec`
    /// over asks the same question the constructor will ask.
    ///
    /// It exists because [`from_lonlat_owned_with`](Self::from_lonlat_owned_with)
    /// hands a refused run back rather than dropping it, and the reader's own
    /// "is this run paintable" filter must not be a second copy of this rule that
    /// can drift from it (LAW 5). One predicate, both readers.
    #[must_use]
    pub fn is_paintable_run(lonlat: &[(f64, f64)]) -> bool {
        lonlat.len() >= 2
    }

    /// Like [`from_lonlat`](Self::from_lonlat) but classified through an explicit
    /// [`MapPalette`] rather than a [`Theme`] — the entry point a host uses to
    /// build a way set in the concrete-hex [`cartographic`](MapPalette::cartographic)
    /// look (day/night) instead of the theme-derived one, selectable *alongside* it.
    pub fn from_lonlat_with<K: AsRef<str>, V: AsRef<str>>(
        osm_id: i64,
        lonlat: &[(f64, f64)],
        tags: &[(K, V)],
        pal: &MapPalette,
    ) -> Option<Self> {
        if style::has_nothing_to_draw(tags) {
            return None;
        }
        Self::from_lonlat_styled(osm_id, Cow::Borrowed(lonlat), tags, style::classify_with(tags, pal)).ok()
    }

    /// **[`from_lonlat_with`](Self::from_lonlat_with) that MOVES the vertex run in
    /// rather than copying it** — the door for the readers, which own the `Vec` the
    /// WKB walk just filled and drop it on the next line.
    ///
    /// The `&`-taking constructors above cannot avoid the copy: they end in
    /// `to_vec()`, because a slice is all they were given. Every call site in
    /// `facett_osm::ways_from_geometry` had already paid for that `Vec` — the outer
    /// ring of a `RingPolygon` dropped in the same loop, a member of the `lines` list
    /// dropped two lines later, the flattened last-resort `coords` — so the copy was
    /// bought purely by the signature. MEASURED with facett-osm's counting allocator
    /// (`decoding_a_way_moves_its_vertex_run_instead_of_copying_it`): the read path
    /// wrote **40.0 B per way vertex**, of which the copy is exactly **16.0 B — 40 %**
    /// (the rest is 16 B for the decode's own buffer and 8 B for the `(f32, f32)`
    /// Mercator twin). Priced over a whole Sweden read that is ≈ 1.97 GB and ≈ 9.2 M
    /// allocations (stinky `korpdep-13`).
    ///
    /// # Refusal hands the run BACK
    ///
    /// `Err(lonlat)` returns the caller's vertices intact, so a reader that keeps
    /// refused geometry for a fallback path (facett-osm's last resort does exactly
    /// that) loses nothing by moving. There are TWO refusal rules and they are both
    /// one-liners at the top of this door: the geometry rule
    /// [`is_paintable_run`](Self::is_paintable_run) — fewer vertices than a line — and
    /// the TAG rule [`has_nothing_to_draw`](style::has_nothing_to_draw), which refuses a
    /// way whose keys name nothing [`classify_with`](style::classify_with) reads. The tag
    /// rule lives on the four CLASSIFYING doors only, never on
    /// `from_lonlat_styled`: a caller that hands in its own [`WayStyle`] has already
    /// decided, and a graph edge or a synthetic overlay carries no OSM tags by
    /// construction.
    pub fn from_lonlat_owned_with<K: AsRef<str>, V: AsRef<str>>(
        osm_id: i64,
        lonlat: Vec<(f64, f64)>,
        tags: &[(K, V)],
        pal: &MapPalette,
    ) -> Result<Self, Vec<(f64, f64)>> {
        if style::has_nothing_to_draw(tags) {
            return Err(lonlat);
        }
        let style = style::classify_with(tags, pal);
        match Self::from_lonlat_styled(osm_id, Cow::Owned(lonlat), tags, style) {
            Ok(w) => Ok(w),
            Err(back) => Err(back.into_owned()),
        }
    }

    /// [`from_lonlat_owned_with`](Self::from_lonlat_owned_with) classified through a
    /// [`Theme`] rather than an explicit [`MapPalette`] — the moving twin of
    /// [`from_lonlat`](Self::from_lonlat).
    pub fn from_lonlat_owned<K: AsRef<str>, V: AsRef<str>>(
        osm_id: i64,
        lonlat: Vec<(f64, f64)>,
        tags: &[(K, V)],
        theme: &Theme,
    ) -> Result<Self, Vec<(f64, f64)>> {
        if style::has_nothing_to_draw(tags) {
            return Err(lonlat);
        }
        let style = classify(tags, theme);
        match Self::from_lonlat_styled(osm_id, Cow::Owned(lonlat), tags, style) {
            Ok(w) => Ok(w),
            Err(back) => Err(back.into_owned()),
        }
    }

    /// **The one PUBLIC non-classifying moving door** — build a way from an explicit
    /// [`WayStyle`], with no tag gate at all.
    ///
    /// This is the door lane SI left open on purpose and named in
    /// [`from_lonlat_owned_with`](Self::from_lonlat_owned_with)'s doc: the tag rule
    /// [`has_nothing_to_draw`](style::has_nothing_to_draw) sits on the four CLASSIFYING
    /// constructors and never on `from_lonlat_styled`, "because a caller that hands in
    /// its own `WayStyle` has already decided". It stayed private, so the only callers
    /// that could use it were inside this crate — and `facett-osm`, which is the reader
    /// that knows a source carries no `tags` column at all, could not.
    ///
    /// `Err(lonlat)` gives the run back, exactly like the classifying twin. The GEOMETRY
    /// rule [`is_paintable_run`](Self::is_paintable_run) still applies: a style is a
    /// decision about ink, not a licence to paint a single point.
    ///
    /// `tags` is still read — for `name`/`height`/`layer` metadata — and is allowed to
    /// be [`NO_TAGS`]. It just no longer DECIDES anything.
    pub fn from_lonlat_owned_styled<K: AsRef<str>, V: AsRef<str>>(
        osm_id: i64,
        lonlat: Vec<(f64, f64)>,
        tags: &[(K, V)],
        style: WayStyle,
    ) -> Result<Self, Vec<(f64, f64)>> {
        match Self::from_lonlat_styled(osm_id, Cow::Owned(lonlat), tags, style) {
            Ok(w) => Ok(w),
            Err(back) => Err(back.into_owned()),
        }
    }

    /// Shared core of [`from_lonlat`](Self::from_lonlat) /
    /// [`from_lonlat_with`](Self::from_lonlat_with) **and their moving twins**: given
    /// an already-resolved [`WayStyle`], project + attach the tag-derived
    /// height/roof/name metadata.
    ///
    /// **The `Cow` is the whole zero-copy seam** (stinky `korpdep-13`). It is the ONE
    /// place a way's vertex run enters the struct, so `Cow::Owned` is a move and
    /// `Cow::Borrowed` is the single `to_vec()` a slice-holding caller genuinely owes
    /// — and neither door can drift from the other's projection, closure test or
    /// metadata, because there is only one body. `Err` gives the run back rather than
    /// dropping it, which is what lets the readers move in the first place.
    fn from_lonlat_styled<'a, K: AsRef<str>, V: AsRef<str>>(
        osm_id: i64,
        lonlat: Cow<'a, [(f64, f64)]>,
        tags: &[(K, V)],
        style: WayStyle,
    ) -> Result<Self, Cow<'a, [(f64, f64)]>> {
        if !Self::is_paintable_run(&lonlat) {
            return Err(lonlat);
        }
        let (points, bbox) = Self::project(&lonlat);
        // An area way fills only when it is actually a ring — an open `landuse`
        // way still strokes gracefully rather than fanning into a bad triangle.
        let fill = if style.is_area && Self::is_closed_ring(&lonlat) { style.fill } else { None };
        // Carry a real building height (metres) from the OSM tags so the 3D
        // extruder gives varied, realistic heights — only for actual buildings
        // (a road / water / landuse footprint lies flat in 3D, so its height is
        // irrelevant; keep it `None`).
        let height_m = if style.kind == WayKind::Building { parse_building_height_m(tags) } else { None };
        // Roof silhouette + facade/roof colours — buildings only (every other kind
        // lies flat with no roof, so parsing them would just allocate for nothing).
        let (roof_shape, building_colour, roof_colour) = if style.kind == WayKind::Building {
            (parse_roof_shape(tags), parse_building_colour(tags), parse_roof_colour(tags))
        } else {
            (RoofShape::Flat, None, None)
        };
        // Name + maxspeed are only meaningful (and only kept) for roads — the
        // street-label / speed-glow source the 3D car-navigator streets use.
        let (name, maxspeed_kmh, oneway) = if style.kind == WayKind::Road {
            let name = tag(tags, "name").unwrap_or_default().to_string();
            (name, parse_maxspeed_kmh(tags), parse_oneway(tags))
        } else {
            (String::new(), None, 0)
        };
        Ok(Self {
            bbox,
            points,
            // `into_owned()` is the seam: a MOVE for a reader that handed its `Vec`
            // over, and the one honest copy for a caller that only had a slice.
            lonlat: lonlat.into_owned(),
            style,
            stroke: style.color,
            width: style.width,
            fill,
            holes: Vec::new(),
            osm_id,
            kind: style.kind.as_str(),
            height_m,
            name,
            maxspeed_kmh,
            oneway,
            roof_shape,
            building_colour,
            roof_colour,
            tile_key: 0,
            // The OSM z-order tags, read here on the ONE constructor every real-OSM
            // host goes through (korp's served + tiled paths both call
            // `from_lonlat_with`), so a bridge / tunnel / `layer` way carries its
            // order with no change on the host side at all.
            z_order: crate::paint_order::parse_osm_z(tags),
            rank: None,
        })
    }

    /// A stroked-only polyline way with an explicit colour/width (the synthetic
    /// basemap path; no tag classification). Carried at `Lod::Country` so it
    /// always shows when the index/GPU cull is involved.
    pub fn line(id: i64, kind: &'static str, lonlat: Vec<(f64, f64)>, stroke: egui::Color32, width: f32) -> Self {
        let (points, bbox) = Self::project(&lonlat);
        let style = WayStyle { color: stroke, width, lod: Lod::Country, kind: WayKind::Other, is_area: false, fill: None };
        Self {
            bbox,
            points,
            lonlat,
            style,
            stroke,
            width,
            fill: None,
            holes: Vec::new(),
            osm_id: id,
            kind,
            height_m: None,
            name: String::new(),
            maxspeed_kmh: None,
            oneway: 0,
            roof_shape: RoofShape::Flat,
            building_colour: None,
            roof_colour: None,
            tile_key: 0,
            z_order: crate::paint_order::OsmZ::default(),
            rank: None,
        }
    }

    /// A filled-silhouette ring (closed) with a crisp outline stroke on top —
    /// the solid-mascot look the synthetic rabbit basemap uses.
    pub fn filled(
        id: i64,
        kind: &'static str,
        lonlat: Vec<(f64, f64)>,
        fill: egui::Color32,
        stroke: egui::Color32,
        width: f32,
    ) -> Self {
        let (points, bbox) = Self::project(&lonlat);
        let style =
            WayStyle { color: stroke, width, lod: Lod::Country, kind: WayKind::Other, is_area: true, fill: Some(fill) };
        Self {
            bbox,
            points,
            lonlat,
            style,
            stroke,
            width,
            fill: Some(fill),
            holes: Vec::new(),
            osm_id: id,
            kind,
            height_m: None,
            name: String::new(),
            maxspeed_kmh: None,
            oneway: 0,
            roof_shape: RoofShape::Flat,
            building_colour: None,
            roof_colour: None,
            tile_key: 0,
            z_order: crate::paint_order::OsmZ::default(),
            rank: None,
        }
    }

    /// Attach **hole (inner) rings** to a filled area (builder). Each hole is a
    /// `(lon, lat)` loop subtracted from this way's fill — a lake's islands, a
    /// landuse donut's courtyard. No-op on a way with no fill (a line never carries
    /// holes). This is how the OSM reader attaches a WKB polygon-with-holes' inner
    /// rings after building the outer ring through [`from_lonlat`](Self::from_lonlat).
    pub fn with_holes(mut self, holes: Vec<Vec<(f64, f64)>>) -> Self {
        if self.fill.is_some() {
            self.holes = holes;
        }
        self
    }
}

/// Zoom multiplier clamp for the [`MapCamera`] (1.0 = fit-to-data).
const MIN_ZOOM: f32 = 0.25;
const MAX_ZOOM: f32 = 8.0;

/// **The complete observable control state (FC-1 / FC-3)** — the [`facett_core::Elm`]
/// Model for [`MapView`]. It is *only* the navigation / selection / layer-visibility
/// state a driver mutates, and it round-trips through serde: the pan/zoom camera
/// (applied ON TOP of the fit-to-data projection), the selected way's **stable osm
/// id** (FC-5), and the per-layer visibility toggles.
///
/// The raw geometry (`points` / `ways`) and the GPU buffers/upload latch are held on
/// [`MapView`] OUTSIDE this Model — they are the render *input*, not the observable
/// control state a headless driver drives, and their `egui::Color32` fills / GPU
/// handles aren't serde-friendly (mirrors how facett-grid keeps its Arrow data out).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MapCamera {
    /// Screen-space pan offset (logical px) applied on top of the fit-to-data
    /// projection. `[0, 0]` = the plain fitted view (today's behaviour).
    pub pan: [f32; 2],
    /// Zoom multiplier over the fit-to-data scale (`1.0` = fit). Clamped to
    /// [`MIN_ZOOM`]..[`MAX_ZOOM`]; a `<= 0` value falls back to `1.0`.
    pub zoom: f32,
    /// The selected way by its **stable osm id** (FC-5), or `None`. Invariant
    /// across pan/zoom — a driver selects the same feature regardless of camera.
    pub selected: Option<i64>,
    /// Layer toggle: draw the point layer (default `true`).
    pub show_points: bool,
    /// Layer toggle: draw the vector-basemap ways layer (default `true`).
    pub show_ways: bool,
}

impl Default for MapCamera {
    fn default() -> Self {
        Self { pan: [0.0, 0.0], zoom: 1.0, selected: None, show_points: true, show_ways: true }
    }
}

impl MapCamera {
    /// Reset pan + zoom to the plain fit-to-data view (the ⊙ fit button). The
    /// selection + layer toggles are kept.
    pub fn fit(&mut self) {
        self.pan = [0.0, 0.0];
        self.zoom = 1.0;
    }
    /// Clamp a raw zoom multiplier to the valid range, guarding non-positive input.
    fn clamp_zoom(z: f32) -> f32 {
        if z <= 0.0 || !z.is_finite() { 1.0 } else { z.clamp(MIN_ZOOM, MAX_ZOOM) }
    }
}

/// A robot-/CLI-addressable control message (FC-2) — the named boundary a headless
/// driver (or a host) drives the [`MapCamera`] through, the same effect a canvas
/// gesture or toolbar control produces. Applied by [`MapView::update`], the **only**
/// mutation path for the [`MapCamera`] Model.
#[derive(Clone, Debug, PartialEq)]
pub enum Msg {
    /// Pan the viewport by a screen-space delta (a drag gesture).
    PanBy([f32; 2]),
    /// Set the pan offset directly (programmatic / a pasted portable state).
    SetPan([f32; 2]),
    /// Scroll-zoom by a wheel delta: `zoom *= 1 + delta·0.001`, clamped.
    ZoomBy(f32),
    /// Set the zoom directly (programmatic / a pasted portable state), clamped.
    SetZoom(f32),
    /// Reset pan + zoom to the fit-to-data view (⊙ fit); the selection is kept.
    Fit,
    /// Select a way by its **stable osm id** (FC-5).
    Select(i64),
    /// Clear the selection (an empty-canvas click, or a programmatic clear).
    ClearSelection,
    /// Show / hide the point layer.
    SetPoints(bool),
    /// Show / hide the vector-basemap ways layer.
    SetWays(bool),
}

/// Side work as data (FC-8). The viewer does no async tile I/O — every [`Msg`]
/// mutates only the in-memory [`MapCamera`] — so this is uninhabited on purpose:
/// the type-checked statement that [`MapView::update`] never asks the host to do
/// anything. (Real streaming-tile I/O would turn this into an honest variant.)
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}

/// A geo viewer: a point layer + an optional vector-basemap (ways) layer.
pub struct MapView {
    pub title: String,
    /// `(lon, lat)` points (WGS-84) — drawn on top of the ways. **Render input**,
    /// kept OUTSIDE the [`MapCamera`] Model.
    pub points: Vec<(f64, f64)>,
    /// Vector basemap ways (line or filled), pre-projected to Mercator. **Render
    /// input**, kept OUTSIDE the [`MapCamera`] Model.
    pub ways: Vec<Way>,
    /// All observable control state (FC-1 / FC-3) — the [`facett_core::Elm`] Model.
    camera: MapCamera,
    /// Upload-once latch for the GPU path: the geometry is built+uploaded on the
    /// first paint, then only uniforms change. `#[cfg]`'d to the `wgpu` feature.
    /// An interior-mutable ephemeral cache (FC-7), OUTSIDE the Model.
    #[cfg(feature = "wgpu")]
    geom_uploaded: std::sync::atomic::AtomicBool,
    /// Host opt-in to the GPU ways layer. The host installs a [`gpu::GpuMapRenderer`]
    /// into its `egui_wgpu::RenderState` (via [`gpu::install_renderer`]) and sets
    /// this; otherwise the CPU line painter draws the basemap. `#[cfg]`'d to `wgpu`.
    #[cfg(feature = "wgpu")]
    use_gpu: bool,
}

impl MapView {
    /// Point-only map — the original API; `ways` is empty (today's behavior).
    pub fn new(title: impl Into<String>, points: Vec<(f64, f64)>) -> Self {
        Self {
            title: title.into(),
            points,
            ways: Vec::new(),
            camera: MapCamera::default(),
            #[cfg(feature = "wgpu")]
            geom_uploaded: std::sync::atomic::AtomicBool::new(false),
            #[cfg(feature = "wgpu")]
            use_gpu: false,
        }
    }

    /// **wgpu feature.** Opt the ways layer into the GPU paint callback. The host
    /// must have installed a [`gpu::GpuMapRenderer`] into its `RenderState`
    /// ([`gpu::install_renderer`]); if it hasn't, the callback gracefully no-ops
    /// and the CPU line painter still draws the basemap. (Filled silhouettes are
    /// always drawn on the CPU on top regardless of `use_gpu`.)
    #[cfg(feature = "wgpu")]
    pub fn with_gpu(mut self, on: bool) -> Self {
        self.use_gpu = on;
        self
    }

    /// Attach a vector basemap (chainable). `no ways → just points`.
    pub fn with_ways(mut self, ways: Vec<Way>) -> Self {
        self.ways = ways;
        self
    }

    /// Lon/lat bounding box `(min_lon, min_lat, max_lon, max_lat)` over points ∪ ways.
    pub fn bbox(&self) -> Option<(f64, f64, f64, f64)> {
        let mut b: Option<(f64, f64, f64, f64)> = None;
        let mut acc = |lon: f64, lat: f64| {
            b = Some(match b {
                None => (lon, lat, lon, lat),
                Some((a, c, d, e)) => (a.min(lon), c.min(lat), d.max(lon), e.max(lat)),
            });
        };
        for &(lon, lat) in &self.points {
            acc(lon, lat);
        }
        for w in &self.ways {
            for &(lon, lat) in &w.lonlat {
                acc(lon, lat);
            }
        }
        b
    }

    /// Mercator-unit bbox over both layers (points ∪ ways) — used to fit the view.
    /// **The padding the fit-to-data projection leaves around the pane**, px. One
    /// writer: [`Self::view`], `draw_ways_gpu` and [`Self::fit_for_test`] all read it,
    /// and they must agree or the two lanes fit to different rectangles.
    const FIT_PAD: f32 = 8.0;

    /// **The fit-to-data numbers for a pane of `rect`**, as
    /// `(inner_w, inner_h, merc_w, merc_h, camera_zoom)` — the padded pane in px, the
    /// data bbox's Mercator extents, and the live camera zoom clamped. `None` with no
    /// geometry to fit.
    ///
    /// This re-walks the data bbox, which the live painters already hold — it exists for
    /// the width hooks below (`world_px` is a function of the fit and of nothing else)
    /// and is not on the frame path.
    #[must_use]
    fn fit_of(&self, rect: egui::Rect) -> Option<(f32, f32, f64, f64, f32)> {
        let (mnx, mny, mxx, mxy) = self.merc_bbox()?;
        Some((
            (rect.width() - 2.0 * Self::FIT_PAD).max(1.0),
            (rect.height() - 2.0 * Self::FIT_PAD).max(1.0),
            (mxx - mnx).max(1e-12),
            (mxy - mny).max(1e-12),
            MapCamera::clamp_zoom(self.camera.zoom),
        ))
    }

    /// **THE widget's fit expressed as the ONE camera number the width law takes** —
    /// lane HO-T7.
    ///
    /// [`crate::style::line_width_scale`] takes a *`world_px`*: pixels per whole Web
    /// Mercator world. This widget has no slippy zoom to read one off — it fits the
    /// data's bbox into the pane — but it does not need one, because [`mercator`]
    /// returns **normalised 0..1** coordinates. So pixels-per-Mercator-unit IS
    /// pixels-per-world, and that is exactly what `layer::MapView::world_px()`
    /// (`256 · 2^zoom`) is too: `layer::MapView::project` multiplies the same 0..1
    /// Mercator delta by it.
    ///
    /// `draw_ways_gpu` already resolves this fit to `zoom_x = inner_w/merc_w · cz` and
    /// `zoom_y = inner_h/merc_h · cz` — those two numbers, in those units. The fit is
    /// **non-uniform** and the law takes one scalar, so this is the SMALLER axis: the
    /// axis that limits how much ground a pixel covers, and the one the CPU painter's
    /// own `zoom_w` already picked with its `.min()`.
    #[inline]
    #[must_use]
    fn fit_world_px(inner_w: f32, inner_h: f32, merc_w: f64, merc_h: f64, cz: f32) -> f64 {
        (f64::from(inner_w) / merc_w).min(f64::from(inner_h) / merc_h) * f64::from(cz)
    }

    /// **The widget's own non-uniform fit multiplier** — the ONE writer of what was an
    /// inline expression in [`Self::view`]. It scales AREA strokes and the selection
    /// halo; the line-way lanes read the width law instead (HO-T7).
    #[inline]
    #[must_use]
    fn fit_zoom_w(inner_w: f32, inner_h: f32, merc_w: f64, merc_h: f64, cz: f32) -> f32 {
        ((inner_w / merc_w as f32).min(inner_h / merc_h as f32) / 4000.0).clamp(0.45, 2.2) * cz.clamp(0.5, 3.0)
    }

    /// **THE pixel width the CPU painter strokes a LINE way of themed `style_width`
    /// at** — the CPU half of the HO-T7 equality, and the ONE writer [`Self::view`]
    /// calls.
    ///
    /// [`crate::style::line_width_px`], the law, at this fit's [`Self::fit_world_px`] —
    /// the same arithmetic `gpu/line.wgsl` performs on the `width_scale`
    /// [`Self::gpu_line_width_scale`] hands it, so the two lanes agree bit-for-bit.
    ///
    /// **It was `fill_outline_width_px(style_width · zoom_w)`** — the AREA-outline law
    /// (`max(w, 0.5)`: no zoom ramp, no `[1, 24]` pixel band) applied to a ROAD, against
    /// this widget's own `zoom_w` fit, while the GPU lane beside it drew
    /// `clamp(w · 1.0, 1, 24)`. MEASURED (`tests/lane_width_is_one_formula.rs`) on a 3 px
    /// trunk in a 1200×800 pane: CPU **6.6000004 px**, GPU **3 px**, law **2.3030922 px**
    /// — three numbers for one road. `zoom_w` keeps its two real jobs (area strokes and
    /// the selection halo) and is no longer a second width law for lines.
    #[inline]
    #[must_use]
    fn cpu_line_width_px(style_width: f32, world_px: f64) -> f32 {
        crate::style::line_width_px(style_width, world_px)
    }

    /// **Test hook** — the fit numbers for a pane of `rect` ([`Self::fit_of`]).
    #[doc(hidden)]
    #[must_use]
    pub fn fit_for_test(&self, rect: egui::Rect) -> Option<(f32, f32, f64, f64, f32)> {
        self.fit_of(rect)
    }

    /// **Test hook** — this widget's fit as a `world_px` ([`Self::fit_world_px`]).
    #[doc(hidden)]
    #[must_use]
    pub fn fit_world_px_for_test(&self, rect: egui::Rect) -> Option<f64> {
        let (iw, ih, w, h, cz) = self.fit_of(rect)?;
        Some(Self::fit_world_px(iw, ih, w, h, cz))
    }

    /// **Test hook** — the width the CPU painter strokes a line way at, in a pane of
    /// `rect`. The CPU half of lane HO-T7.
    #[doc(hidden)]
    #[must_use]
    pub fn cpu_line_width_px_for_test(&self, style_width: f32, rect: egui::Rect) -> Option<f32> {
        let (iw, ih, w, h, cz) = self.fit_of(rect)?;
        Some(Self::cpu_line_width_px(style_width, Self::fit_world_px(iw, ih, w, h, cz)))
    }

    fn merc_bbox(&self) -> Option<(f64, f64, f64, f64)> {
        let mut b: Option<(f64, f64, f64, f64)> = None;
        let mut acc = |x: f64, y: f64| {
            b = Some(match b {
                None => (x, y, x, y),
                Some((a, c, d, e)) => (a.min(x), c.min(y), d.max(x), e.max(y)),
            });
        };
        for &(lon, lat) in &self.points {
            let (x, y) = mercator(lon, lat);
            acc(x, y);
        }
        for w in &self.ways {
            acc(w.bbox[0], w.bbox[1]);
            acc(w.bbox[2], w.bbox[3]);
        }
        b
    }

    /// Way-kind histogram (`kind → count`) for `state_json` / tests.
    fn way_kinds(&self) -> std::collections::BTreeMap<&'static str, usize> {
        let mut m = std::collections::BTreeMap::new();
        for w in &self.ways {
            *m.entry(w.kind).or_insert(0) += 1;
        }
        m
    }

    // ── FC-2 / FC-3 / FC-8 / FC-9 Elm surface ────────────────────────────────

    /// **FC-3** — read the complete observable control state (camera / selection /
    /// layer toggles) at any frame boundary.
    pub fn camera(&self) -> &MapCamera {
        &self.camera
    }

    /// The currently-selected way (by its stable osm id), if one is selected AND
    /// still present in the ways layer.
    pub fn selected_way(&self) -> Option<&Way> {
        let id = self.camera.selected?;
        self.ways.iter().find(|w| w.osm_id == id)
    }

    /// Reset pan + zoom to the fit-to-data view (the ⊙ fit affordance). Routes
    /// through the FC-2 [`update`](Self::update) mutation path.
    pub fn fit(&mut self) {
        let _ = self.update(Msg::Fit);
    }

    /// Select a way by its stable osm id (lights it), or `None` to clear. A thin
    /// wrapper over the FC-2 [`update`](Self::update) mutation path.
    pub fn select(&mut self, id: Option<i64>) {
        let _ = match id {
            Some(id) => self.update(Msg::Select(id)),
            None => self.update(Msg::ClearSelection),
        };
    }

    /// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
    /// (always empty) [`Effect`]s the host should run. Every canvas gesture / toolbar
    /// control routes its [`Msg`] here too, so live == headless.
    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
        match msg {
            Msg::PanBy([dx, dy]) => {
                self.camera.pan[0] += dx;
                self.camera.pan[1] += dy;
            }
            Msg::SetPan(p) => self.camera.pan = p,
            Msg::ZoomBy(scroll) => {
                let z = MapCamera::clamp_zoom(self.camera.zoom);
                self.camera.zoom = MapCamera::clamp_zoom(z * (1.0 + scroll * 0.001));
            }
            Msg::SetZoom(z) => self.camera.zoom = MapCamera::clamp_zoom(z),
            Msg::Fit => self.camera.fit(),
            Msg::Select(id) => self.camera.selected = Some(id),
            Msg::ClearSelection => self.camera.selected = None,
            Msg::SetPoints(on) => self.camera.show_points = on,
            Msg::SetWays(on) => self.camera.show_ways = on,
        }
        Vec::new()
    }

    /// Hit-test a `click` against the projected way vertices, returning the nearest
    /// way's **stable osm id** within a small pixel threshold (or `None` for an
    /// empty-space click). **Pure** — it decides nothing about the selection; the
    /// caller emits the [`Msg`]. A hidden ways layer is not hit-testable.
    fn hit_test_way(&self, click: egui::Pos2, to_px: &impl Fn(f64, f64) -> egui::Pos2) -> Option<i64> {
        if !self.camera.show_ways {
            return None;
        }
        const HIT_PX: f32 = 16.0;
        let mut best: Option<(f32, i64)> = None;
        for way in &self.ways {
            for &(x, y) in &way.points {
                let d = (to_px(x as f64, y as f64) - click).length();
                if d <= HIT_PX && best.map(|(bd, _)| d < bd).unwrap_or(true) {
                    best = Some((d, way.osm_id));
                }
            }
        }
        best.map(|(_, id)| id)
    }

    /// **FC-9 render** — a **pure** function of `&self`: it paints the toolbar + the
    /// fitted map (area fills, line ways, points) under the **live camera** (pan/zoom
    /// on top of the fit-to-data projection + selection highlight + layer toggles),
    /// and *returns* the [`Msg`]s the interactions produced (pan drag, scroll-zoom,
    /// click-select, layer toggles, ⊙ fit). It MUST NOT mutate the [`MapCamera`]
    /// Model; the [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge
    /// applies the returned messages through [`update`](Self::update). The only
    /// `&self`-interior mutation is the ephemeral wgpu upload latch (FC-7), which
    /// lives outside the Model.
    pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        let mut msgs: Vec<Msg> = Vec::new();
        let th = theme(ui);

        // ── toolbar: layer counts + zoom readout + fit + per-layer toggles ──
        ui.horizontal_wrapped(|ui| {
            ui.label(
                egui::RichText::new(format!("{} points · {} ways", self.points.len(), self.ways.len()))
                    .color(th.text)
                    .strong(),
            );
            ui.separator();
            ui.label(egui::RichText::new(format!("zoom {:.2}×", self.camera.zoom)).color(th.text_dim));
            if ui.button("⊙ fit").clicked() {
                msgs.push(Msg::Fit);
            }
            let mut sp = self.camera.show_points;
            if ui.checkbox(&mut sp, "points").changed() {
                msgs.push(Msg::SetPoints(sp));
            }
            let mut sw = self.camera.show_ways;
            if ui.checkbox(&mut sw, "ways").changed() {
                msgs.push(Msg::SetWays(sw));
            }
        });

        // Guard a minimum drawable rect so a collapsed column never yields a
        // degenerate (black) pane.
        const MIN_W: f32 = 160.0;
        const MIN_H: f32 = 120.0;
        let avail = ui.available_size();
        let want = egui::vec2(avail.x.max(MIN_W), avail.y.max(MIN_H));
        // `click_and_drag` so pan / scroll-zoom / click-select gestures reach the
        // pane; the AccessKit summary below still pins it as an Image-role node.
        let (rect, resp) = ui.allocate_exact_size(want, egui::Sense::click_and_drag());
        let np = self.points.len();
        let nw = self.ways.len();
        // The empty-data state carries the stable UI error code facet-map-2 (see
        // facett_core::errcode) so tests + consumers react to the CODE, not a string.
        let empty = np == 0 && nw == 0;
        // The AccessKit summary always carries both layer counts (points · ways)
        // so a robot driver / screen reader reads what the pane shows; when empty it
        // also carries the Error severity + the facet-map-2 code.
        resp.widget_info(|| {
            let s = facett_core::Semantics::image(format!("{} — {np} points · {nw} ways", self.title))
                .value(np as f64);
            let s = if empty {
                s.severity(facett_core::Severity::Error).error_code("facet-map-2")
            } else {
                s
            };
            s.widget_info()
        });
        // ── THE CAMERA GESTURES, READ BEFORE ANY EARLY RETURN (stinky `seam-17`).
        //
        // They used to sit below the `empty` and `merc_bbox() == None` returns, so the
        // pane allocated `Sense::click_and_drag()` and then, in exactly the two states
        // where a user most wants to move — nothing loaded, or a degenerate envelope —
        // dropped every drag and every wheel notch on the floor. A pane you cannot pan
        // out of an empty view is the same defect `MapFacet::ui`'s own comment records
        // ("korp's call site says 'persist the camera the user drove' — the user could
        // not drive it"), one widget over.
        //
        // Pan and zoom need no projection: they are screen-space deltas the host folds
        // into the camera. Only CLICK-SELECT needs `to_px`, and it stays below with it.
        if resp.dragged() {
            let d = resp.drag_delta();
            if d != egui::Vec2::ZERO {
                msgs.push(Msg::PanBy([d.x, d.y]));
            }
        }
        if resp.hovered() {
            let scroll = ui.input(|i| i.smooth_scroll_delta.y);
            if scroll != 0.0 {
                msgs.push(Msg::ZoomBy(scroll));
            }
        }

        let pal = MapPalette::from_theme(&th);
        let painter = ui.painter_at(rect);
        // Land background — when there is a vector basemap, paint the whole pane
        // as "paper" (the themed land fill) so areas + roads sit on a map, not the
        // void. A point-only map keeps the plain themed background.
        let bg = if self.ways.is_empty() { th.bg } else { pal.land };
        painter.rect_filled(rect, egui::CornerRadius::ZERO, bg);
        if empty {
            painter.text(rect.center(), egui::Align2::CENTER_CENTER, "no geo data", egui::FontId::proportional(13.0), th.text_dim);
            // The stable error CODE in clear pink (debug) beneath the human hint.
            facett_core::errcode::paint_code(&painter, rect.center() + egui::vec2(0.0, 16.0), "facet-map-2");
            #[cfg(feature = "testmatrix")]
            facett_core::testmatrix::emit("facett-map::MapView::view", "ui_render", true, "points=0 ways=0 drew=empty_hint code=facet-map-2");
            return msgs;
        }

        // Fit the projected (Mercator) bbox of BOTH layers into the rect.
        let Some((mnx, mny, mxx, mxy)) = self.merc_bbox() else {
            #[cfg(feature = "testmatrix")]
            facett_core::testmatrix::emit("facett-map::MapView::view", "ui_render", false, "no_merc_bbox drew=nothing");
            return msgs;
        };
        let (w, h) = ((mxx - mnx).max(1e-12), (mxy - mny).max(1e-12));
        let pad = Self::FIT_PAD;
        let iw = (rect.width() - 2.0 * pad).max(1.0);
        let ih = (rect.height() - 2.0 * pad).max(1.0);
        // The fit-to-data projection, then the **live camera** (zoom about the pane
        // centre + pan) applied ON TOP. Default camera (zoom 1, pan 0) reproduces the
        // plain fitted view byte-for-byte (behaviour preserved).
        let center = rect.center();
        let cz = MapCamera::clamp_zoom(self.camera.zoom);
        let cpan = egui::vec2(self.camera.pan[0], self.camera.pan[1]);
        let to_px = |x: f64, y: f64| {
            let base = egui::pos2(
                rect.left() + pad + ((x - mnx) / w) as f32 * iw,
                rect.top() + pad + ((y - mny) / h) as f32 * ih,
            );
            center + (base - center) * cz + cpan
        };

        // ── the one gesture that DOES need the projection (FC-9: read input, RETURN
        //    messages; no mutation). Pan and zoom were read above, before the early
        //    returns — see `seam-17` there.
        if resp.clicked() {
            match resp.interact_pointer_pos().and_then(|click| self.hit_test_way(click, &to_px)) {
                Some(id) => msgs.push(Msg::Select(id)),
                None => msgs.push(Msg::ClearSelection),
            }
        }

        // ── ways layer (vector basemap) ──────────────────────────────────────
        // GPU path: when the `wgpu` feature is on AND a wgpu RenderState is
        // available + the host opted in, draw the *line* ways via the ported
        // egui_wgpu paint callback (upload-once + GPU cull). Otherwise (always,
        // under the headless CPU harness) draw the ways on the CPU — filled rings
        // as silhouettes + outlines, plain ways as themed line segments.
        #[cfg(feature = "wgpu")]
        let drew_lines_on_gpu = self.camera.show_ways && self.draw_ways_gpu(ui, rect, resp.id);
        #[cfg(not(feature = "wgpu"))]
        let drew_lines_on_gpu = false;
        // A line's logical width scales with how zoomed-in we are (so roads thin
        // out when the whole country is in view and thicken when zoomed to a city).
        let zoom_w = Self::fit_zoom_w(iw, ih, w, h, cz);
        // The same fit, as the ONE camera number `style::line_width_scale` takes
        // (HO-T7). The GPU lane beside this painter resolves the very same fit.
        let world_px = Self::fit_world_px(iw, ih, w, h, cz);
        // DRAW ORDER (a real flat map): area fills first (water/landuse/buildings),
        // then the area outlines, then line ways (roads/coastline) on top, so roads
        // are never hidden under a polygon. The GPU path (when it took the lines)
        // still draws line ways itself; filled areas are always CPU.
        let project_way = |way: &Way| -> Vec<egui::Pos2> {
            // Reproject from the source `lonlat` in f64 (not the f32 `points`) so the
            // CPU fills/outlines stay pixel-crisp and aligned with the origin-local
            // f64 GPU line lane at high zoom (the f32 `points` snap to a ~5px grid at
            // ZOOM_MAX, which would drift the fills back off the lines).
            way.lonlat
                .iter()
                .map(|&(lon, lat)| {
                    let (x, y) = mercator(lon, lat);
                    to_px(x, y)
                })
                .collect()
        };
        if self.camera.show_ways {
            // 1+2 — filled areas (mesh fill + crisp outline). Concave-safe tessellation.
            for way in self.ways.iter().filter(|w| w.fill.is_some()) {
                let pts = project_way(way);
                if pts.len() < 3 {
                    continue;
                }
                if let Some(fill) = way.fill {
                    // Project any inner (hole) rings so the fill subtracts them
                    // (lake-with-island / donut landuse); empty otherwise. The
                    // mesh-select + outline recipe lives once in
                    // `tessellate::paint_filled_way` (dedup cluster I). MapView does
                    // NOT stroke hole outlines → `outline_holes = false`.
                    let holes: Vec<Vec<egui::Pos2>> = way
                        .holes
                        .iter()
                        .map(|h| {
                            h.iter()
                                .map(|&(lon, lat)| {
                                    let (x, y) = mercator(lon, lat);
                                    to_px(x, y)
                                })
                                .collect()
                        })
                        .collect();
                    let stroke = egui::Stroke::new((way.width * zoom_w).max(0.6), way.stroke);
                    tessellate::paint_filled_way(&painter, pts, &holes, Some(fill), stroke, false, None);
                }
            }
            // 3 — line ways (roads / coastline / paths) on top of the fills.
            if !drew_lines_on_gpu {
                for way in self.ways.iter().filter(|w| w.fill.is_none()) {
                    let pts = project_way(way);
                    if pts.len() < 2 {
                        continue;
                    }
                    // ONE writer, shared with the GPU lane's `width_scale` (HO-T7).
                    let stroke =
                        egui::Stroke::new(Self::cpu_line_width_px(way.style.width, world_px), way.style.color);
                    painter.add(egui::Shape::line(pts, stroke));
                }
            }
            // Selection emphasis — the selected way (FC-5, by stable osm id) outlined
            // in the accent colour on top so it reads as picked.
            if let Some(sel) = self.selected_way() {
                let mut outline = project_way(sel);
                if outline.len() >= 2 {
                    if sel.fill.is_some()
                        && let Some(&first) = outline.first()
                    {
                        outline.push(first);
                    }
                    let stroke = egui::Stroke::new((sel.width * zoom_w).max(2.0) + 1.5, th.accent);
                    painter.add(egui::Shape::line(outline, stroke));
                }
            }
        }

        // ── point layer (always CPU, drawn ON TOP of the basemap) ─────────────
        if self.camera.show_points {
            let col = th.point;
            for &(lon, lat) in &self.points {
                let (x, y) = mercator(lon, lat);
                painter.circle_filled(to_px(x, y), 1.3, col);
            }
        }

        // ── render-lane emit: records WHICH lane drew the line-ways this frame ──
        // The smear bug class hid in the uninstrumented GPU branch; emit a row
        // tagged gpu/cpu so the matrix proves BOTH the `draw_ways_gpu` fast-path
        // and the CPU line painter are exercised + drew real geometry.
        #[cfg(feature = "testmatrix")]
        {
            let geom = self.points.len() + self.ways.len();
            facett_core::testmatrix::emit(
                "facett-map::MapView::view",
                if drew_lines_on_gpu { "gpu_render" } else { "cpu_render" },
                geom > 0,
                &format!(
                    "used_gpu={drew_lines_on_gpu} points={} ways={} zoom={:.2}",
                    self.points.len(),
                    self.ways.len(),
                    self.camera.zoom
                ),
            );
        }
        msgs
    }
}

// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
impl Elm for MapView {
    type Model = MapCamera;
    type Msg = Msg;
    type Effect = Effect;

    fn title(&self) -> &str {
        &self.title
    }
    fn state(&self) -> &MapCamera {
        &self.camera
    }
    fn update(&mut self, msg: Msg) -> Vec<Effect> {
        MapView::update(self, msg)
    }
    fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        MapView::view(self, ui)
    }
}

// The bridge macro writes `impl Facet for MapView` from the `Elm` impl: `title`, the
// FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the extra overrides below.
// **Form 3** (`custom_state_json`) because the map publishes a RICHER `state_json`
// than a plain `serde(state())`: the layer breakdown (points / ways / way_kinds /
// way_vertices / filled_ways / bbox) that facett-demo + `mega_matrix` read is
// derived from the geometry, NOT the raw `MapCamera` — so the default `state_json`
// is suppressed and this one supplied, with the observable camera/selection/layer
// state folded in additively.
facett_core::impl_facet_via_elm!(MapView, custom_state_json, {
    fn state_json(&self) -> serde_json::Value {
        let mut kinds = serde_json::Map::new();
        for (k, v) in self.way_kinds() {
            kinds.insert(k.to_string(), serde_json::json!(v));
        }
        serde_json::json!({
            "points": self.points.len(),
            "ways": self.ways.len(),
            "way_kinds": kinds,
            "way_vertices": self.ways.iter().map(|w| w.lonlat.len()).sum::<usize>(),
            "filled_ways": self.ways.iter().filter(|w| w.fill.is_some()).count(),
            "bbox": self.bbox().map(|(a, b, c, d)| serde_json::json!([a, b, c, d])),
            // ── additive: the observable camera / selection / layer control state ──
            "selected": self.camera.selected,
            "camera": { "pan": [self.camera.pan[0], self.camera.pan[1]], "zoom": self.camera.zoom },
            "show_points": self.camera.show_points,
            "show_ways": self.camera.show_ways,
            // The stable UI ERROR CODE for the empty-data state (facett_core::errcode):
            // present so tests + consumers react to the CODE, not a matched string.
            "error_code": if self.points.is_empty() && self.ways.is_empty() {
                serde_json::json!("facet-map-2")
            } else {
                serde_json::Value::Null
            },
        })
    }

    /// STRUCTURAL severity (the Robot-UI gate signal): RED when the pane has no geo
    /// data to plot (the facet-map-2 error state), the green Info floor otherwise.
    fn severity(&self) -> facett_core::Severity {
        if self.points.is_empty() && self.ways.is_empty() {
            facett_core::Severity::Error
        } else {
            facett_core::Severity::Info
        }
    }

    fn selection_json(&self) -> serde_json::Value {
        match self.selected_way() {
            Some(w) => serde_json::json!({ "way": w.osm_id, "kind": w.kind, "vertices": w.lonlat.len() }),
            None => serde_json::Value::Null,
        }
    }

    /// Themed (land / fills / points follow the palette) + resizable (refits to any
    /// `available_size`) + selectable (a way drill-in, FC-5) + navigable (pan/drag +
    /// scroll-zoom + fit).
    fn caps(&self) -> FacetCaps {
        FacetCaps::NONE.themeable().resizable().selectable().navigable()
    }

    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
        Some(self)
    }

    // ── cross-instance clone (copy/paste BETWEEN two MapViews) ────────────────

    fn kind(&self) -> &'static str {
        "map"
    }

    /// The portable camera: pan, zoom, the selected way id, and the layer toggles —
    /// the fields a sibling `map` adopts to mirror this view's pose. (The geometry
    /// itself is not portable; this clones the control state onto whatever data the
    /// sibling holds.)
    fn portable_state(&self) -> Option<serde_json::Value> {
        Some(serde_json::json!({
            "pan": [self.camera.pan[0], self.camera.pan[1]],
            "zoom": self.camera.zoom,
            "selected": self.camera.selected,
            "show_points": self.camera.show_points,
            "show_ways": self.camera.show_ways,
        }))
    }

    fn load_state(&mut self, state: &serde_json::Value) -> bool {
        let obj = match state.as_object() {
            Some(o) => o,
            None => return false,
        };
        // Route every field through the FC-2 `update` mutation path.
        if let Some(pan) = obj.get("pan").and_then(|p| p.as_array())
            && let (Some(x), Some(y)) = (pan.first().and_then(|v| v.as_f64()), pan.get(1).and_then(|v| v.as_f64()))
        {
            let _ = self.update(Msg::SetPan([x as f32, y as f32]));
        }
        if let Some(z) = obj.get("zoom").and_then(|v| v.as_f64()) {
            let _ = self.update(Msg::SetZoom(z as f32));
        }
        // `selected` may be a way id (number) or JSON null (clear).
        match obj.get("selected") {
            Some(serde_json::Value::Number(n)) => {
                if let Some(id) = n.as_i64() {
                    let _ = self.update(Msg::Select(id));
                }
            }
            Some(serde_json::Value::Null) => {
                let _ = self.update(Msg::ClearSelection);
            }
            _ => {}
        }
        if let Some(b) = obj.get("show_points").and_then(|v| v.as_bool()) {
            let _ = self.update(Msg::SetPoints(b));
        }
        if let Some(b) = obj.get("show_ways").and_then(|v| v.as_bool()) {
            let _ = self.update(Msg::SetWays(b));
        }
        true
    }
});

#[cfg(feature = "wgpu")]
impl MapView {
    /// **THE `width_scale` uniform this widget's GPU line lane draws with** — the GPU
    /// half of the HO-T7 equality, and the ONE writer `draw_ways_gpu` calls.
    #[inline]
    #[must_use]
    fn gpu_line_width_scale(world_px: f64) -> f32 {
        // **It was the literal `1.0`** (lane HO-T7), called "a fit-to-rect static surface
        // … the shared formula's 1× reference" — while the CPU painter beside it strokes
        // against this widget's own `zoom_w` fit. The fit is not the formula's 1×
        // reference and never was; it is a camera, and `fit_world_px` says which one.
        crate::style::line_width_scale(world_px)
    }

    /// **Test hook** — the `width_scale` the GPU callback carries in a pane of `rect`.
    /// The GPU half of lane HO-T7.
    #[doc(hidden)]
    #[must_use]
    pub fn gpu_width_scale_for_test(&self, rect: egui::Rect) -> Option<f32> {
        let (iw, ih, w, h, cz) = self.fit_of(rect)?;
        Some(Self::gpu_line_width_scale(Self::fit_world_px(iw, ih, w, h, cz)))
    }

    /// GPU path: add the egui_wgpu paint callback for the line ways layer.
    /// Returns `true` if the callback was added (a wgpu RenderState was present +
    /// a renderer is installed), `false` to fall back to the CPU line painter.
    ///
    /// Upload-once: builds the GPU geometry on the first paint where ways exist;
    /// pan/zoom is a uniform update inside the callback, not a per-frame rebuild.
    ///
    /// **FC-9 / FC-7**: `&self` (the FC-9 pure view calls it); the only mutation is
    /// the interior-mutable `geom_uploaded` upload latch, an ephemeral cache OUTSIDE
    /// the [`MapCamera`] Model. The live camera (pan/zoom) is folded into the shader
    /// uniforms so the GPU basemap tracks the same transform as the CPU lanes.
    fn draw_ways_gpu(&self, ui: &mut egui::Ui, rect: egui::Rect, pane: egui::Id) -> bool {
        use std::sync::atomic::Ordering;

        if self.ways.is_empty() || !self.use_gpu {
            return false; // CPU painter draws the basemap unless the host opted in
        }

        // Fit the ways' Mercator bbox into the rect, then fold the live camera on top.
        // This reproduces the CPU `to_px` (lines 825-831) EXACTLY — including its
        // **non-uniform** x/y fit (separate iw/w and ih/h scales) — but the GPU lane
        // is **origin-local** (`pos = mercator_f64(lonlat) − origin`, shader does
        // `(pos − ref)·zoom + screen_center`) so it no longer suffers f32 catastrophic
        // cancellation at high zoom. `origin` is shared by the vertex bake, the shader
        // `ref` and the cull viewport (single source of truth).
        let Some((mnx, mny, mxx, mxy)) = self.merc_bbox() else { return false };
        let Some(origin) = gpu::ways_origin(self.ways.iter()) else { return false };
        let (w, h) = ((mxx - mnx).max(1e-12), (mxy - mny).max(1e-12));
        let pad = Self::FIT_PAD;
        let iw = (rect.width() - 2.0 * pad).max(1.0);
        let ih = (rect.height() - 2.0 * pad).max(1.0);
        let cz = f64::from(MapCamera::clamp_zoom(self.camera.zoom));
        let center = rect.center();
        let (panx, pany) = (f64::from(self.camera.pan[0]), f64::from(self.camera.pan[1]));
        // Per-axis zoom (CPU fit is non-uniform), each with the live camera folded in.
        let zoom_x = f64::from(iw) / w * cz;
        let zoom_y = f64::from(ih) / h * cz;
        // The same two numbers reduced to the ONE the width law takes (HO-T7). Asserted
        // against `fit_world_px` — this lane and the CPU painter must fit identically.
        debug_assert_eq!(
            zoom_x.min(zoom_y),
            Self::fit_world_px(iw, ih, w, h, MapCamera::clamp_zoom(self.camera.zoom)),
            "the GPU lane's fit and `fit_world_px` disagree"
        );
        let world_px = zoom_x.min(zoom_y);
        // The Mercator point that lands on the pane centre (so `ref = center_merc −
        // origin` stays small). Solved from the CPU composed transform in f64.
        let center_merc = [
            mnx - (panx + (f64::from(rect.left()) + f64::from(pad) - f64::from(center.x)) * cz) / zoom_x,
            mny - (pany + (f64::from(rect.top()) + f64::from(pad) - f64::from(center.y)) * cz) / zoom_y,
        ];
        let transform = gpu::MapTransform::new(origin, center_merc, zoom_x as f32, zoom_y as f32, rect);
        let viewport_bbox = transform.viewport_bbox(rect);

        // Build the geometry exactly once across the MapView's lifetime. Filled
        // silhouettes are skipped here — they draw on the CPU (egui polygon fill).
        let geometry = if !self.geom_uploaded.swap(true, Ordering::Relaxed) {
            Some(Box::new(gpu::build_gpu_geometry(
                self.ways.iter().filter(|w| w.fill.is_none()).map(|w| {
                    let lod = w.style.lod as u32;
                    (w, lod)
                }),
                origin,
            )))
        } else {
            None
        };

        // ── `painter_at(rect)`, NOT `ui.painter()` (stinky `seam-14`).
        //
        // `egui_wgpu` sets `set_scissor_rect(clip_rect)` before invoking a paint
        // callback, and the clip comes from the PAINTER the callback was added to.
        // `ui.painter()` clips to the whole `Ui`, so this lane could paint outside its
        // own pane — over the controls rail beside it, over whatever else shares the
        // Ui — while `MapPaintCallback::paint` deliberately sets the viewport to the
        // WHOLE SURFACE and leans on that scissor to hold it in (its own comment says
        // so: "The scissor is NOT touched … Only the coordinate mapping changes").
        // `MapFacet::ui` already threads `painter_at(rect)`; this widget did not.
        ui.painter_at(rect).add(egui_wgpu::Callback::new_paint_callback(
            rect,
            gpu::MapPaintCallback {
                // This widget's response id names the pane (GG #37).
                stamp: gpu::PaneStamp::of(ui, pane),
                geometry,
                tiles: None,
                viewport_bbox,
                // Show ALL tiers when fit to the data. It was the literal `2`, which
                // meant "all" only for as long as there were three tiers — `kalm-1`
                // added a fourth and this line would have dropped every footpath from
                // the fit-to-data view without a word. The deepest tier, by name.
                lod: crate::style::Lod::Detail as u32,
                transform,
                // ONE writer, shared with the CPU painter's stroke (HO-T7).
                width_scale: Self::gpu_line_width_scale(world_px),
                // No fill lane here: `MapView::draw_ways_gpu` bakes only unfilled
                // ways, so nothing this callback draws is tintable. The identity.
                fill_tint: facett_core::render::gpu::DrawUniforms::NO_TINT,
                // This static fit-to-data surface carries no `MapTheme`, so it takes
                // the lane's fallback ink rather than inventing a palette here.
                casing_col: None,
            },
        ));
        true
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    // `Facet` for `state_json`/`selection_json`/`title`/`ui`; `camera()`/`update`/
    // `select` are inherent.
    use facett_core::Facet;

    fn way(tags: &[(&str, &str)], lonlat: &[(f64, f64)]) -> Way {
        let tags: Vec<(String, String)> = tags.iter().map(|(a, b)| (a.to_string(), b.to_string())).collect();
        Way::from_lonlat(1, lonlat, &tags, &Theme::default()).unwrap()
    }

    /// INJECT-ASSERT: a building way carries a real OSM height (metres) onto
    /// [`Way::height_m`] — `height=<m>`/`<ft>` wins; else `(building:levels +
    /// roof:levels)` x 3.2 m; else `None` and the extruder's default box. Only
    /// buildings carry it (roads/water leave it `None`).
    #[test]
    fn building_height_is_parsed_from_tags() {
        // height=<m> (with a unit suffix tolerated).
        assert_eq!(parse_building_height_m(&tagvec(&[("height", "30")])), Some(30.0));
        assert_eq!(parse_building_height_m(&tagvec(&[("height", "12.5")])), Some(12.5));
        assert_eq!(parse_building_height_m(&tagvec(&[("height", "25 m")])), Some(25.0));
        // (building:levels + roof:levels) x METRES_PER_LEVEL. The expected metres are
        // LITERALS, not `4.0 * METRES_PER_LEVEL` — the old spelling tracked the constant
        // and so could not tell 3.0 from 3.2 from 1.0. See `height::tests`.
        assert_eq!(parse_building_height_m(&tagvec(&[("building:levels", "4")])), Some(12.8));
        // height wins over levels.
        assert_eq!(
            parse_building_height_m(&tagvec(&[("building:levels", "4"), ("height", "15")])),
            Some(15.0)
        );
        // No height tag -> None (the extruder defaults).
        assert_eq!(parse_building_height_m(&tagvec(&[("building", "yes")])), None);
        // A JUNK height tag -> None as well, so it takes the same default box rather
        // than the leading-digit guess this parser used to make.
        assert_eq!(parse_building_height_m(&tagvec(&[("height", "99999")])), None, "no spike");
        assert_eq!(parse_building_height_m(&tagvec(&[("height", "1;12")])), None, "not a 1 m building");

        // On the Way: a tagged building carries height_m; a road does not.
        let b = way(&[("building", "yes"), ("building:levels", "3")], &square(9.5, 47.1));
        assert_eq!(b.height_m, Some(9.6), "building:levels=3 -> 9.6 m onto height_m");
        let road = way(&[("highway", "primary")], &[(9.5, 47.1), (9.6, 47.2)]);
        assert_eq!(road.height_m, None, "a road carries no building height");
        let untagged_b = way(&[("building", "yes")], &square(9.5, 47.1));
        assert_eq!(untagged_b.height_m, None, "an untagged building leaves height to the extruder default");
        // The whole reason this fix exists: a junk tag must reach `Way::height_m` as
        // `None`, or the extruder puts 99 999 m of prism through the sky.
        let junk_b = way(&[("building", "yes"), ("height", "99999")], &square(9.5, 47.1));
        assert_eq!(junk_b.height_m, None, "height=99999 must not reach the extruder as a height");
        let feet_b = way(&[("building", "yes"), ("height", "25'")], &square(9.5, 47.1));
        assert!(
            (feet_b.height_m.unwrap() - 7.62).abs() < 1e-2,
            "height=25' is 25 FEET = 7.62 m, got {:?}",
            feet_b.height_m
        );
    }

    /// INJECT-ASSERT: a road way carries its OSM `name` + `maxspeed` (km/h) onto
    /// [`Way::name`] / [`Way::maxspeed_kmh`] — the 3D car-navigator street-label /
    /// glow source. Only roads carry them; a building leaves both empty/`None`
    /// (nothing reads a building's name today).
    #[test]
    fn road_name_and_maxspeed_are_parsed_from_tags() {
        assert_eq!(parse_maxspeed_kmh(&tagvec(&[("maxspeed", "50")])), Some(50.0));
        assert_eq!(parse_maxspeed_kmh(&tagvec(&[("maxspeed", "30 mph")])), Some(30.0 * 1.609_34));
        assert_eq!(parse_maxspeed_kmh(&tagvec(&[("maxspeed", "DE:zone30")])), None, "no leading digit → None");
        assert_eq!(parse_maxspeed_kmh(&tagvec(&[("maxspeed", "walk")])), None);
        assert_eq!(parse_maxspeed_kmh(&tagvec(&[])), None, "untagged → None");

        let road = way(
            &[("highway", "motorway"), ("name", "E4"), ("maxspeed", "120")],
            &[(9.5, 47.1), (9.6, 47.2)],
        );
        assert_eq!(road.name, "E4");
        assert_eq!(road.maxspeed_kmh, Some(120.0));

        let unnamed_road = way(&[("highway", "residential")], &[(9.5, 47.1), (9.6, 47.2)]);
        assert_eq!(unnamed_road.name, "", "an untagged road has an empty (not missing) name");
        assert_eq!(unnamed_road.maxspeed_kmh, None);

        let b = way(&[("building", "yes"), ("name", "Town Hall")], &square(9.5, 47.1));
        assert_eq!(b.name, "", "non-road ways don't carry a name (nothing reads it yet)");
        assert_eq!(b.maxspeed_kmh, None);
    }

    fn tagvec(kv: &[(&str, &str)]) -> Vec<(String, String)> {
        kv.iter().map(|(a, b)| (a.to_string(), b.to_string())).collect()
    }
    fn square(lon: f64, lat: f64) -> Vec<(f64, f64)> {
        let d = 0.001;
        vec![(lon, lat), (lon + d, lat), (lon + d, lat + d), (lon, lat + d), (lon, lat)]
    }

    #[test]
    fn mercator_centre() {
        let (x, y) = mercator(0.0, 0.0);
        assert!((x - 0.5).abs() < 1e-9);
        assert!((y - 0.5).abs() < 1e-9);
        // west edge → x≈0, and higher latitude → smaller y (further north/up).
        assert!(mercator(-180.0, 0.0).0.abs() < 1e-9);
        assert!(mercator(0.0, 60.0).1 < 0.5);
    }

    #[test]
    fn point_only_map_still_works() {
        let v = MapView::new("map", vec![(11.0, 57.0), (18.0, 59.0), (13.0, 55.0)]);
        assert_eq!(v.bbox(), Some((11.0, 55.0, 18.0, 59.0)));
        let j = v.state_json();
        assert_eq!(j["points"], 3);
        assert_eq!(j["ways"], 0, "point-only map reports zero ways (graceful)");
    }

    #[test]
    fn way_from_lonlat_projects_and_classifies() {
        let w = way(&[("highway", "motorway")], &[(9.5, 47.1), (9.6, 47.2)]);
        assert_eq!(w.points.len(), 2);
        assert_eq!(w.lonlat.len(), 2, "source lon/lat kept for the CPU painter");
        assert_eq!(w.style.kind, WayKind::Road);
        assert_eq!(w.style.lod, Lod::Country, "motorway is a country-LOD road");
        assert!(w.fill.is_none(), "a classified OSM way is a line, not a silhouette");
        // Mercator x for ~9.5°E ≈ 0.526 (just east of centre).
        assert!((w.points[0].0 - 0.526).abs() < 0.01, "projected x near 0.526, got {}", w.points[0].0);
        // Degenerate (single-point) ways are rejected.
        assert!(Way::from_lonlat(1, &[(9.5, 47.1)], NO_TAGS, &Theme::default()).is_none());
    }

    #[test]
    fn state_json_breaks_down_the_ways_layer() {
        let ways = vec![
            way(&[("highway", "motorway")], &[(9.5, 47.1), (9.6, 47.2)]),
            way(&[("natural", "water")], &[(9.4, 47.0), (9.5, 47.0), (9.5, 47.1)]),
            way(&[("natural", "coastline")], &[(9.3, 46.9), (9.4, 47.0)]),
        ];
        let v = MapView::new("li", vec![(9.5, 47.1)]).with_ways(ways);
        let j = v.state_json();
        assert_eq!(j["points"], 1);
        assert_eq!(j["ways"], 3, "all three ways present in the layer");
        assert_eq!(j["way_kinds"]["road"], 1);
        assert_eq!(j["way_kinds"]["water"], 1);
        assert_eq!(j["way_kinds"]["coastline"], 1);
        assert_eq!(j["way_vertices"], 7, "2 + 3 + 2 vertices");
        assert_eq!(j["filled_ways"], 0, "OSM line ways are not filled");
    }

    #[test]
    fn filled_silhouette_ways_state_json() {
        // The synthetic-rabbit path: filled rings + a line way, free-form kinds.
        let ways = vec![
            Way::filled(
                1,
                "body",
                vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)],
                egui::Color32::WHITE,
                egui::Color32::BLACK,
                1.0,
            ),
            Way::line(2, "ear", vec![(0.0, 0.0), (0.2, 0.5)], egui::Color32::BLACK, 1.0),
        ];
        let v = MapView::new("li", vec![(0.5, 0.5)]).with_ways(ways);
        let j = v.state_json();
        assert_eq!(j["points"], 1);
        assert_eq!(j["ways"], 2);
        assert_eq!(j["way_kinds"]["body"], 1);
        assert_eq!(j["way_kinds"]["ear"], 1);
        assert_eq!(j["filled_ways"], 1, "the body ring is a filled silhouette");
        assert_eq!(j["way_vertices"], 6, "4 + 2 vertices");
    }

    #[test]
    fn headless_render_draws_points_and_ways() {
        use facett_core::harness;
        // Inject a real basemap (3 ways) + a point, render offscreen, assert it
        // both tessellated vertices AND reported the ways layer — inject/assert.
        let ways = vec![
            way(&[("highway", "motorway")], &[(9.5, 47.1), (9.55, 47.15), (9.6, 47.2)]),
            way(&[("natural", "water")], &[(9.4, 47.0), (9.5, 47.0), (9.5, 47.1)]),
            way(&[("natural", "coastline")], &[(9.3, 46.9), (9.4, 47.0)]),
        ];
        let mut v = MapView::new("li", vec![(9.5, 47.1)]).with_ways(ways);
        let r = harness::headless_render(&mut v);
        assert_eq!(r.title, "li");
        assert!(r.drew(), "basemap line segments + the point tessellate to vertices");
        assert_eq!(r.state["points"], 1);
        assert_eq!(r.state["ways"], 3, "the ways layer is present in state_json");
        assert_eq!(r.state["way_kinds"]["road"], 1);
        assert!(r.state["way_vertices"].as_u64().unwrap() >= 7);
    }

    #[test]
    fn headless_render_draws_points_and_filled_ways() {
        use facett_core::harness;
        let ways = vec![Way::filled(
            1,
            "body",
            vec![(9.5, 47.1), (9.6, 47.1), (9.6, 47.2), (9.5, 47.2)],
            egui::Color32::from_rgb(200, 200, 220),
            egui::Color32::BLACK,
            1.2,
        )];
        let mut v = MapView::new("li", vec![(9.55, 47.15)]).with_ways(ways);
        let r = harness::headless_render(&mut v);
        assert_eq!(r.title, "li");
        assert!(r.drew(), "a filled silhouette + outline + point tessellate to vertices");
        assert_eq!(r.state["ways"], 1);
        assert_eq!(r.state["filled_ways"], 1);
    }

    #[test]
    fn empty_map_renders_a_hint_without_panicking() {
        use facett_core::harness;
        let mut v = MapView::new("empty", vec![]);
        let r = harness::headless_render(&mut v);
        assert_eq!(r.state["points"], 0);
        assert_eq!(r.state["ways"], 0);
        assert!(r.drew(), "the 'no geo data' hint still draws text");
    }

    #[test]
    fn index_partitions_by_lod() {
        let ways = vec![
            way(&[("highway", "motorway")], &[(9.5, 47.1), (9.6, 47.2)]), // country
            way(&[("natural", "water")], &[(9.4, 47.0), (9.5, 47.0)]),    // region
            way(&[("highway", "tertiary")], &[(9.45, 47.05), (9.46, 47.06)]), // DISTRICT
            way(&[("building", "yes")], &[(9.4, 47.0), (9.41, 47.01)]),   // city
            way(&[("highway", "footway")], &[(9.4, 47.0), (9.401, 47.001)]), // DETAIL
        ];
        let idx = index::MapIndex::build(ways);
        assert_eq!(idx.lod_sizes(), [1, 1, 1, 1, 1]);
        assert_eq!(idx.gpu_ways().count(), 5, "gpu_ways walks every tier");
        let vp = [0.0, 0.0, 1.0, 1.0];
        assert_eq!(idx.query_visible(vp, 4).len(), 5);
        assert_eq!(idx.query_visible(vp, 0).len(), 1, "country LOD shows only the motorway");
        // **THE z9 CLAIM.** A tertiary road must be absent at the region tier and present
        // one tier deeper — the split that made `Lod::District` worth a variant. Put the
        // `tertiary` arm back on `Lod::Region` and this reads 3, not 2.
        assert_eq!(
            idx.query_visible(vp, 1).len(),
            2,
            "a tertiary road must NOT draw at the region tier — that is what Lod::District is for"
        );
        // **THE `kalm-1` CLAIM, AS A FAILING-WHEN-BROKEN ASSERT.** The footpath must be
        // absent at the city tier and present one tier deeper. Revert `classify_with`'s
        // path arm to `Lod::City` and this line reads 5, not 4 — which is the whole
        // defect: the footpath network drawing with the same right as an arterial.
        assert_eq!(
            idx.query_visible(vp, 3).len(),
            4,
            "a footway must NOT draw at the city tier — that is what Lod::Detail is for"
        );
    }

    // ── FC-2 → FC-3 as a *headless* property: feed a `Vec<Msg>` through the core
    //    harness and assert the resulting `MapCamera` — no egui, no GPU. ──────────

    /// A small mixed basemap (a road line + a water ring) with stable osm ids, so a
    /// driver can select a known feature id headlessly.
    fn basemap() -> Vec<Way> {
        vec![
            Way::from_lonlat(11, &[(9.5, 47.1), (9.6, 47.2)], &tagvec(&[("highway", "motorway")]), &Theme::default()).unwrap(),
            Way::from_lonlat(22, &square(9.4, 47.0), &tagvec(&[("natural", "water")]), &Theme::default()).unwrap(),
        ]
    }

    #[test]
    fn harness_snapshot_drives_pan_zoom_selection_and_layers() {
        use facett_core::harness;
        let mut v = MapView::new("li", vec![(9.5, 47.1)]).with_ways(basemap());

        // Pan deltas accumulate; a way selection (stable osm id) is observable.
        let snap = harness::snapshot(
            &mut v,
            [Msg::PanBy([10.0, -4.0]), Msg::PanBy([2.0, 1.0]), Msg::Select(22)],
        );
        assert_eq!(snap.pan, [12.0, -3.0], "pan deltas accumulate");
        assert_eq!(snap.selected, Some(22), "selection is observable headlessly");
        assert_eq!(&snap, v.camera(), "the snapshot is a clone of the live camera");

        // Scroll-zoom applies the `1 + delta·0.001` factor, clamped to 0.25..8.0.
        let snap = harness::snapshot(&mut v, [Msg::SetZoom(1.0), Msg::ZoomBy(500.0)]);
        assert!((snap.zoom - 1.5).abs() < 1e-6, "zoom *= 1 + 500·0.001 = 1.5, got {}", snap.zoom);
        let snap = harness::snapshot(&mut v, [Msg::SetZoom(8.0), Msg::ZoomBy(100000.0)]);
        assert_eq!(snap.zoom, MAX_ZOOM, "zoom clamps at the ceiling");

        // Layer toggles are observable; Fit resets pan+zoom but keeps selection.
        let snap = harness::snapshot(&mut v, [Msg::SetPoints(false), Msg::SetWays(false)]);
        assert!(!snap.show_points && !snap.show_ways, "layer toggles are observable");
        let snap = harness::snapshot(&mut v, [Msg::Fit]);
        assert_eq!(snap.pan, [0.0, 0.0]);
        assert_eq!(snap.zoom, 1.0);
        assert_eq!(snap.selected, Some(22), "fit keeps the selection");
        let snap = harness::snapshot(&mut v, [Msg::ClearSelection]);
        assert_eq!(snap.selected, None, "ClearSelection empties the selection");
    }

    #[test]
    fn drive_reports_no_effects_and_camera_round_trips() {
        use facett_core::harness;
        let mut v = MapView::new("li", vec![(9.5, 47.1)]).with_ways(basemap());
        // FC-8: the viewer does no I/O, so the effect stream is always empty.
        let effects = harness::drive(&mut v, [Msg::PanBy([3.0, 3.0]), Msg::Select(11)]);
        assert!(effects.is_empty(), "FC-8: map emits no Effects");
        // FC-3: the observable Model (the MapCamera) round-trips through serde.
        let json = serde_json::to_value(v.camera()).unwrap();
        assert_eq!(json["pan"], serde_json::json!([3.0, 3.0]));
        assert_eq!(json["zoom"], 1.0);
        assert_eq!(json["selected"], 11);
        let back: MapCamera = serde_json::from_value(json).unwrap();
        assert_eq!(&back, v.camera(), "serde(camera) -> camera round-trips");
    }

    /// FC-3: the `MapCamera` Model round-trips byte-for-byte through serde JSON.
    #[test]
    fn mapcamera_model_serde_round_trips() {
        let c = MapCamera { pan: [-7.5, 3.25], zoom: 1.75, selected: Some(42), show_points: false, show_ways: true };
        let json = serde_json::to_value(&c).unwrap();
        assert_eq!(json["pan"], serde_json::json!([-7.5, 3.25]));
        assert_eq!(json["zoom"], 1.75);
        assert_eq!(json["selected"], 42);
        assert_eq!(json["show_points"], false);
        let back: MapCamera = serde_json::from_value(json).unwrap();
        assert_eq!(back, c, "serde(MapCamera) -> MapCamera round-trips");
    }

    /// The state_json layer breakdown keys facett-demo + mega_matrix read are
    /// preserved EXACTLY, and the observable camera/selection/layer state is folded
    /// in additively — proven through a real headless render.
    #[test]
    fn state_json_preserves_layer_keys_and_adds_camera() {
        use facett_core::harness;
        let mut v = MapView::new("li", vec![(9.5, 47.1), (9.55, 47.16)]).with_ways(basemap());
        let r = harness::headless_render(&mut v);
        assert!(r.drew(), "the basemap + points tessellate");
        // Preserved keys (byte-for-byte semantics).
        assert_eq!(r.state["points"], 2);
        assert_eq!(r.state["ways"], 2);
        assert_eq!(r.state["way_kinds"]["road"], 1);
        assert_eq!(r.state["way_kinds"]["water"], 1);
        assert_eq!(r.state["filled_ways"], 1, "the water ring fills");
        assert!(r.state["way_vertices"].as_u64().unwrap() >= 6);
        assert!(r.state["bbox"].is_array());
        // Additive control state.
        assert_eq!(r.state["selected"], serde_json::Value::Null);
        assert_eq!(r.state["camera"]["zoom"], 1.0);
        assert_eq!(r.state["camera"]["pan"], serde_json::json!([0.0, 0.0]));
        assert_eq!(r.state["show_points"], true);
        assert_eq!(r.state["show_ways"], true);

        // Selecting a way (stable osm id) surfaces in state_json + selection_json.
        v.select(Some(22));
        let j = Facet::state_json(&v);
        assert_eq!(j["selected"], 22);
        assert_eq!(v.selection_json()["way"], 22);
        assert_eq!(v.selection_json()["kind"], "water");
    }

    #[test]
    fn portable_state_round_trips_between_instances() {
        use facett_core::clipboard;
        // A is configured: panned, zoomed, a way selected, points hidden.
        let mut a = MapView::new("a", vec![(9.5, 47.1)]).with_ways(basemap());
        let _ = a.update(Msg::SetPan([37.0, -11.0]));
        let _ = a.update(Msg::SetZoom(2.5));
        a.select(Some(11));
        let _ = a.update(Msg::SetPoints(false));

        let env = clipboard::encode_component(a.kind(), &a.portable_state().unwrap());
        let (kind, state) = clipboard::decode_component(&env).unwrap();
        assert_eq!(kind, "map");

        // B starts at the default pose; adopting A's state makes them equal.
        let mut b = MapView::new("b", vec![(0.0, 0.0)]).with_ways(basemap());
        assert_ne!(b.portable_state(), a.portable_state(), "they differ before the paste");
        assert!(b.load_state(&state), "same-kind load is accepted");
        assert_eq!(b.portable_state(), a.portable_state(), "B mirrors A's camera/selection/layers");
        assert_eq!(b.camera().zoom, 2.5);
        assert_eq!(b.camera().selected, Some(11));
        assert!(!b.camera().show_points);
    }

    #[test]
    fn public_fit_select_and_toggles_route_through_update() {
        let mut v = MapView::new("li", vec![(9.5, 47.1)]).with_ways(basemap());
        let _ = v.update(Msg::PanBy([9.0, 9.0]));
        let _ = v.update(Msg::SetZoom(3.0));
        v.select(Some(22));
        assert_eq!(v.camera().selected, Some(22));
        v.fit();
        assert_eq!(v.camera().pan, [0.0, 0.0], "fit resets pan via update");
        assert_eq!(v.camera().zoom, 1.0, "fit resets zoom via update");
        assert_eq!(v.camera().selected, Some(22), "fit keeps selection");
        v.select(None);
        assert_eq!(v.camera().selected, None, "select(None) clears via update");
    }

    /// **INJECT-ASSERT (round-2 — roof shape + colours parse off the tags):** the OSM
    /// `roof:shape` maps to a `RoofShape`, `building:colour`/`roof:colour` parse hex +
    /// names, and `roof:material` maps to a tint. RED-when-broken: a tag change flips
    /// the parsed result, and an unknown/absent tag yields the flat/None fallback.
    #[test]
    fn roof_shape_and_colours_parse_from_tags() {
        let t = |kv: &[(&str, &str)]| -> Vec<(String, String)> {
            kv.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
        };
        // roof:shape.
        assert_eq!(parse_roof_shape(&t(&[("roof:shape", "gabled")])), RoofShape::Gabled);
        assert_eq!(parse_roof_shape(&t(&[("roof:shape", "hipped")])), RoofShape::Hipped);
        assert_eq!(parse_roof_shape(&t(&[("roof:shape", "pyramidal")])), RoofShape::Hipped);
        assert_eq!(parse_roof_shape(&t(&[("roof:shape", "flat")])), RoofShape::Flat);
        assert_eq!(parse_roof_shape(&t(&[("roof:shape", "wibble")])), RoofShape::Flat, "unknown → flat");
        assert_eq!(parse_roof_shape(&t(&[])), RoofShape::Flat, "absent → flat");

        // Hex + named colours.
        assert_eq!(parse_osm_colour("#ff0000"), Some(egui::Color32::from_rgb(255, 0, 0)));
        assert_eq!(parse_osm_colour("#f00"), Some(egui::Color32::from_rgb(255, 0, 0)), "#rgb expands");
        assert_eq!(parse_osm_colour("white"), Some(egui::Color32::from_rgb(245, 245, 245)));
        assert_eq!(parse_osm_colour("not-a-colour"), None);

        // building:colour + roof:colour/material resolution.
        assert_eq!(parse_building_colour(&t(&[("building:colour", "#123456")])), Some(egui::Color32::from_rgb(0x12, 0x34, 0x56)));
        assert_eq!(parse_building_colour(&t(&[])), None, "absent facade colour → None");
        assert_eq!(parse_roof_colour(&t(&[("roof:colour", "brown")])), Some(egui::Color32::from_rgb(128, 88, 60)));
        // material fallback maps to a tint; explicit colour wins over material.
        assert!(parse_roof_colour(&t(&[("roof:material", "slate")])).is_some(), "slate → a tint");
        assert_eq!(
            parse_roof_colour(&t(&[("roof:colour", "#010203"), ("roof:material", "slate")])),
            Some(egui::Color32::from_rgb(1, 2, 3)),
            "explicit roof:colour wins over material"
        );
        assert_eq!(parse_roof_colour(&t(&[("roof:material", "unobtainium")])), None, "unknown material → None");
        assert_eq!(parse_roof_colour(&t(&[])), None, "absent roof colour/material → None");
    }

    /// The parsed roof/colour fields actually land on a real building `Way` (and stay
    /// default for a non-building), so the extruder downstream sees them.
    #[test]
    fn way_from_lonlat_carries_roof_fields_for_buildings_only() {
        let theme = theme::Theme::default();
        let sq = [(9.5, 47.1), (9.501, 47.1), (9.501, 47.101), (9.5, 47.101), (9.5, 47.1)];
        let b = Way::from_lonlat(
            1,
            &sq,
            &[
                ("building", "yes"),
                ("roof:shape", "gabled"),
                ("building:colour", "white"),
                ("roof:colour", "#a83e32"),
            ],
            &theme,
        )
        .unwrap();
        assert_eq!(b.roof_shape, RoofShape::Gabled);
        assert_eq!(b.building_colour, Some(egui::Color32::from_rgb(245, 245, 245)));
        assert_eq!(b.roof_colour, Some(egui::Color32::from_rgb(0xa8, 0x3e, 0x32)));

        // A road carries the roof tags on the RAW OSM object but the parser scopes
        // roof/colour to buildings, so a non-building stays flat/None.
        let road = Way::from_lonlat(2, &[(9.5, 47.1), (9.51, 47.11)], &[("highway", "primary")], &theme).unwrap();
        assert_eq!(road.roof_shape, RoofShape::Flat);
        assert_eq!(road.building_colour, None);
        assert_eq!(road.roof_colour, None);
    }
}