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);
}
}