facett-map/src/style.rs
//! Per-OSM-tag styling for the vector basemap, ported from katana-osm's
//! `osm-viewer/src/render/style.rs` and re-keyed onto the facett [`Theme`].
//!
//! The source bakes literal RGB into each style; facett instead derives the
//! stroke + fill colours from the active [`Theme`] palette so a way layer
//! re-themes with the rest of the deck. Two big additions over the original
//! line-only port make the basemap **look like a real map** instead of bare
//! coloured lines:
//!
//! 1. **area-vs-line** ([`WayStyle::is_area`]) — a closed ring tagged
//! `natural=water`/`waterway`/`landuse`/`leisure=park`/`natural=wood`/
//! `building`/`amenity` is an **area** (polygon → fill); everything else
//! (roads, paths, coastline) is a **line** (stroke). The reader
//! ([`crate::Way::from_lonlat`]) only turns an area into a filled silhouette
//! when its geometry actually closes (a ring), so a `landuse` drawn as an
//! open way still strokes gracefully.
//! 2. **fill colour** ([`WayStyle::fill`]) — themed blue (water), green
//! (parks / forest / landuse) or a subtle grey (buildings), so the areas
//! paint as filled polygons under the roads.
//!
//! The **level-of-detail** tiers (country / region / city) are carried verbatim
//! — they drive the GPU cull's cumulative LOD test.
use egui::Color32;
use facett_core::Theme;
use crate::WayKind;
/// Level-of-detail tier. Country ways show at every zoom; city ways only when zoomed
/// in; **detail** ways only at street level. The GPU cull compares `lod <= viewport_lod`.
///
/// # Why there is a fourth tier (`kalm-1`)
///
/// The three-tier ladder came verbatim from osm-viewer, and it had no room for the
/// footpath network — `highway=footway|cycleway|path|pedestrian|track|steps` sat at
/// [`Lod::City`], **the same tier as a residential street**, so from zoom 12 upward
/// every footpath in the viewport drew with the same right as an arterial.
///
/// The measurement that says it is a defect and not a taste: on the day palette the
/// path brown `#B99873` is **2.35:1** against the paper (`tools/mapdrive/ink.py`,
/// WCAG), the highest-contrast class on the whole map after the coastline — against
/// 1.20:1 for a building and 1.02:1 for a residential street. At camera 12.7 the pane
/// is ~1.7 px per 20 m, so a 2 m path is a **third of a pixel** wide. The loudest ink
/// on the map, drawn at a width where it can only be grain.
///
/// `Detail` is the room to put it in, and it costs nothing on the GPU: `cull.wgsl`
/// tests `m.lod > vp.lod`, a scalar compare. The three-entry `line_lod_end` prefix
/// table that a fourth tier was thought to need a shader change for is a **retained
/// fact** — the indirect draw stopped reading it when the compute cull landed
/// (`gpu/mod.rs`, "What this replaced: `0..line_lod_end[lod]`").
///
/// # Why there is a fifth tier (`District`, the z9 rung)
///
/// The served pyramid is `[6, 9, 12, 15]` and the ladder was `[0, 6, 12, 15]`. **z9 was
/// cut, stored, shipped over the wire, decoded — and then drew the same tier z6 draws.**
/// A camera anywhere in `9..12` paid for a level that changed nothing on screen.
///
/// korp's cross-seam law (`korp/tests/lod_one_threshold.rs`,
/// `fetch_level_and_draw_tier_step_together`) could not see it: it asserts that fetch
/// and draw never step without each other, and here they agreed — about ignoring the
/// rung. `facett-map/tests/lod_ladder_vs_pyramid.rs` is the other half of that
/// law and is where this is now measured. (Both citations here said
/// `lod_ladder_serves_its_pyramid.rs` until 2026-08-27 -- a filename the repo has
/// never had, so a reader who followed the law to its guard found nothing. The
/// guard itself is real and always was: `the_two_ladders_step_together_at_every_rung`
/// and `every_tier_step_lands_on_a_rung_the_pyramid_serves`.)
///
/// **z9 and not z8/z10/z13**: the rung has to be one the pyramid already serves, or the
/// tier renders the coarser data and buys nothing all over again. z9 is free; the others
/// each need a 44 GB re-cut first.
///
/// **What moved, and the accepted cost.** `Lod::Region` held `secondary` AND `tertiary`
/// roads and the canal; tertiary and canal move here. The owner's decision, taken with the
/// consequence stated: **tertiary roads no longer draw between camera 6 and camera 9**,
/// where they draw today. That is the point rather than a side effect — a tier that
/// renders the same set as the tier above it is the "exists in the type and nowhere on
/// screen" defect [`LOD_COUNT`] warns about, reached from the other side. `secondary`
/// deliberately stays at `Region`.
///
/// # The ladder gates DRAWING ONLY — measured, not assumed
///
/// A tier that withholds a road class would be a **correctness** change rather than a
/// look change if anything traversed the ways as a graph, because connectivity spans
/// tile boundaries and a per-zoom filter cannot see the seam it cuts. Nothing does, and
/// the evidence is specific:
///
/// * `Map3D::set_route` (`facett-map3d/src/lib.rs`) takes its `waypoints` from the
/// CALLER. `extrude::project_route` maps each one through `to_design` independently —
/// no adjacency, no snapping, no search. Its `ways` argument feeds only
/// `footprint_bbox` (the coordinate frame) and `design_units_per_metre` (the scale),
/// both LOD-independent aggregates.
/// * `extrude::street_label_anchors` iterates **every** way, skipping only non-roads,
/// unnamed roads and degenerate geometry. It STORES `lod: w.style.lod` per anchor; the
/// value is read at HUD paint time and nowhere else. Turn-by-turn naming
/// (`maneuver::road_names_along`) reads that full, unfiltered set.
/// * `MapIndex` PARTITIONS ways into per-tier vectors and retains all of them
/// (`lod_sizes`); `layer::lod_visible` is a paint-time predicate, not a filter that
/// drops.
///
/// So the tier is a display gate. A future lane that adds a traversing consumer — a real
/// router, a connectivity check — must read the untiered set, and this paragraph is the
/// record that the question was asked rather than assumed.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Lod {
Country = 0,
Region = 1,
/// **The z9 rung** — the sub-regional network: `highway=tertiary` and
/// `waterway=canal`. See "Why there is a fifth tier" above.
District = 2,
City = 3,
/// Street-level furniture: the footpath network. Drawn only once the camera stands
/// on the deepest pyramid rung, where a path is metres wide in pixels.
Detail = 4,
}
/// How many [`Lod`] tiers there are — **the one writer** for every per-tier array in
/// this crate (`MapIndex`'s tier vectors and grids, the GPU bake's `line_polys_by_lod`
/// and `line_lod_end`). Those were three independent literal `3`s and a `.min(2)`, and
/// a fourth variant added to the enum alone would have folded silently into the third
/// at every one of them — a tier that exists in the type and nowhere on screen.
pub const LOD_COUNT: usize = 5;
impl Lod {
/// Every tier, ascending.
pub const ALL: [Lod; LOD_COUNT] =
[Lod::Country, Lod::Region, Lod::District, Lod::City, Lod::Detail];
/// The tier a `u32` from the wire/GPU names, saturating at the deepest tier.
#[must_use]
pub fn from_u32(v: u32) -> Lod {
Self::ALL[(v as usize).min(LOD_COUNT - 1)]
}
}
/// A resolved way style: stroke colour + width + LOD tier + classification, plus
/// the **area** flag and optional **fill** colour that make closed rings paint as
/// filled polygons (a real map) rather than bare outlines.
#[derive(Clone, Copy, Debug)]
pub struct WayStyle {
/// Stroke (outline / line) colour.
pub color: Color32,
/// Stroke width in logical px (at fit-zoom; the painter scales with zoom).
pub width: f32,
pub lod: Lod,
pub kind: WayKind,
/// `true` → this way represents an **area** (water body, park, building,
/// landuse): when its geometry is a closed ring it is painted as a filled
/// polygon. `false` → a **line** (road, path, coastline): stroked only.
pub is_area: bool,
/// Themed fill colour for an area way (`None` for lines). Set ⇔ `is_area`.
pub fill: Option<Color32>,
}
/// The map's cartographic palette — one concrete colour per feature class. Two
/// sources feed it:
///
/// * [`MapPalette::from_theme`] derives every colour off the active [`Theme`] so
/// the basemap re-themes with the rest of the deck (the historical behaviour;
/// selected when the view is `themeable`).
/// * [`MapPalette::cartographic`] returns a **concrete-hex** preset (a real map's
/// paper/water/green/road-tier hues, day + night) — a fixed cartographic look
/// selectable *alongside* the theme-derived one, not blended off the theme.
///
/// The road-tier + `casing` fields let a map paint each `highway` class its own
/// hue and draw a dark **casing** (outline) under the bright road fill.
#[derive(Clone, Copy, Debug)]
pub struct MapPalette {
/// Whole-pane land background (the "paper").
pub land: Color32,
/// Water-body / waterway / lake fill.
pub water: Color32,
/// Park / forest / landuse fill.
pub green: Color32,
/// Building footprint fill.
pub building: Color32,
/// Coastline stroke (a touch darker than the water fill).
pub coast: Color32,
/// Motorway / trunk fill (the top road tier — the warm accent in theme mode).
pub road_motorway: Color32,
/// Primary-road fill.
pub road_primary: Color32,
/// Secondary-road fill.
pub road_secondary: Color32,
/// Tertiary-road fill.
pub road_tertiary: Color32,
/// Residential / service / minor-road fill.
pub road_residential: Color32,
/// Footway / cycleway / path fill.
pub path: Color32,
/// Untagged / generic line fill.
pub road_generic: Color32,
/// **The OVERPRINT ink** — geometry from a source that carries no OSM tags AT ALL
/// (a plain WKB table the operator explicitly pointed the host at: Trafikverket,
/// NVDB, a survey export). See [`untagged_source_style`].
///
/// It is deliberately NOT a road tier and NOT [`road_generic`](Self::road_generic).
/// `road_generic` is the *guess* [`has_nothing_to_draw`] exists to stop painting
/// across open water; reusing it here would put that exact ink back on screen for a
/// whole file. And a road tier would be a lie: the reader knows the geometry's
/// SOURCE, never its class.
///
/// Magenta, because that is what an overprint is in cartography — the revision
/// colour a survey adds on top of a printed base sheet, chosen precisely because no
/// base-map feature owns it. Nothing else in either preset is in the magenta family,
/// so an operator can tell at a glance which lines came out of the file they named.
pub overlay: Color32,
/// Road **casing** — a dark outline drawn wider than, and under, the road
/// fill so bright roads read as ribbons on the paper (two-pass stroke).
pub casing: Color32,
}
/// Which palette a map surface paints with. `Theme` derives every colour off the
/// active [`Theme`] (the re-themeable look); the `Cartographic*` variants select
/// the fixed concrete-hex [`MapPalette::cartographic`] preset (day = light paper,
/// night = dark skade basemap) *alongside* the themed one. Shared by both 2D
/// surfaces so the selector reads + serialises identically (REUSE).
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum PaletteMode {
/// Colours derived from the active theme (the historical, re-themeable look).
#[default]
Theme,
/// The light-paper cartographic preset.
CartographicDay,
/// The dark skade-style cartographic preset.
CartographicNight,
}
impl PaletteMode {
/// A stable label for `state_json` / a legend (`"theme"`, `"cartographic-day"`,
/// `"cartographic-night"`).
pub fn label(self) -> &'static str {
match self {
PaletteMode::Theme => "theme",
PaletteMode::CartographicDay => "cartographic-day",
PaletteMode::CartographicNight => "cartographic-night",
}
}
/// `true` for the fixed cartographic presets (not the theme-derived look).
pub fn is_cartographic(self) -> bool {
!matches!(self, PaletteMode::Theme)
}
/// Resolve to a concrete [`MapPalette`]: the theme-derived palette for
/// [`Theme`](PaletteMode::Theme), else the matching cartographic preset (the
/// `theme` argument is ignored for the fixed presets).
pub fn palette(self, theme: &Theme) -> MapPalette {
match self {
PaletteMode::Theme => MapPalette::from_theme(theme),
PaletteMode::CartographicDay => MapPalette::cartographic(true),
PaletteMode::CartographicNight => MapPalette::cartographic(false),
}
}
}
/// **The OVERPRINT ink, day** — `#B5197A`. See [`MapPalette::overlay`].
///
/// MEASURED (WCAG contrast, [`contrast_ratio`], against the cartographic DAY preset):
/// `4.37` against `road_generic` `#DED8CB`, `5.41` against the paper `#F2EFE9`, `3.79`
/// against the water `#A8D0E6`. The ink it replaces is at `1.24` against that same
/// paper — `road_generic` was never visible on land at all; it only ever showed up
/// because it was being drawn across BLUE, which is the defect lane SI removed.
pub const OVERPRINT_DAY: Color32 = Color32::from_rgb(0xB5, 0x19, 0x7A);
/// **The OVERPRINT ink, night** — `#FF5FC8`. MEASURED: `7.04` against the night
/// ground `#0B0F1A`, `4.34` against the night water `#123A5A`, `3.73` against the
/// night `road_generic` `#3A424F`.
pub const OVERPRINT_NIGHT: Color32 = Color32::from_rgb(0xFF, 0x5F, 0xC8);
/// WCAG relative luminance of an sRGB colour, `0.0..=1.0` — the one place the
/// "is this paper light or dark?" question is answered, so a palette cannot end up
/// with dark ink on dark paper by having two spellings of it.
#[must_use]
pub fn relative_luminance(c: Color32) -> f32 {
let lin = |v: u8| {
let s = v as f32 / 255.0;
if s <= 0.040_45 {
s / 12.92
} else {
((s + 0.055) / 1.055).powf(2.4)
}
};
0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b())
}
/// The WCAG contrast ratio between two sRGB colours, `1.0..=21.0`. Body text wants
/// `>= 4.5`; the map label ink is held well above it.
#[must_use]
pub fn contrast_ratio(a: Color32, b: Color32) -> f32 {
let (x, y) = (relative_luminance(a), relative_luminance(b));
let (hi, lo) = if x > y { (x, y) } else { (y, x) };
(hi + 0.05) / (lo + 0.05)
}
/// Linearly blend two colours (`t=0 → a`, `t=1 → b`).
fn mix(a: Color32, b: Color32, t: f32) -> Color32 {
let l = |x: u8, y: u8| (x as f32 * (1.0 - t) + y as f32 * t).round().clamp(0.0, 255.0) as u8;
Color32::from_rgb(l(a.r(), b.r()), l(a.g(), b.g()), l(a.b(), b.b()))
}
impl MapPalette {
/// Derive the map fill palette from a [`Theme`]. The land background is a
/// gentle lift off the theme background (so the pane reads as paper, not the
/// void); water/green/building are tinted toward canonical map hues but kept
/// in the theme's family via a blend with the accent / point colours.
pub fn from_theme(theme: &Theme) -> Self {
// Land: lift the bg toward a neutral grey so areas + roads sit on a
// readable "paper", but keep it dark enough that a dark theme stays dark.
let land = mix(theme.bg, Color32::from_rgb(60, 62, 72), 0.30);
// Water: blend the accent toward a map blue, then darken to a fill (an
// OSM map's water is a calm mid-blue, not the bright stroke colour).
let water = mix(mix(theme.accent, Color32::from_rgb(70, 120, 185), 0.6), land, 0.30);
// Green: blend the point colour toward a muted park/forest green.
let green = mix(mix(theme.point, Color32::from_rgb(96, 150, 96), 0.6), land, 0.25);
// Building: a subtle grey just off the land, a touch warmer/lighter.
let building = mix(land, Color32::from_rgb(120, 118, 124), 0.45);
// Coastline: darker than the water fill so the shore reads as a line.
let coast = mix(water, Color32::BLACK, 0.35);
// Road tiers — the exact colours the theme-mode classifier has always
// used, hoisted onto the palette so the classifier is a pure colour
// lookup and a cartographic preset can swap them wholesale.
let road_motorway = theme.accent;
let road_primary = mix(theme.accent, theme.text, 0.5);
let road_secondary = theme.text;
let road_tertiary = mix(theme.text, theme.text_dim, 0.4);
let road_residential = theme.text_dim;
let path = mix(green, theme.text_dim, 0.4);
let road_generic = theme.text_dim;
// The overprint ink is NOT theme-derived on purpose: every road tier here is
// `accent`/`text`-derived, so a derived overlay would collide with one of them
// under some theme and the "which lines came from my file" question would stop
// having an answer. A fixed magenta, blended 15 % toward `land` so it sits in
// the pane's value range without losing its identity.
let overlay = mix(OVERPRINT_DAY, land, 0.15);
// Casing: a dark line just off the land (only used when a view opts into
// casing; theme mode historically drew no casing, so this is inert there
// until requested).
let casing = mix(land, Color32::BLACK, 0.55);
Self {
land,
water,
green,
building,
coast,
road_motorway,
road_primary,
road_secondary,
road_tertiary,
road_residential,
path,
road_generic,
overlay,
casing,
}
}
/// A **concrete-hex cartographic preset** — a real map's palette, independent
/// of the theme. `day = true` is a light paper map (warm land, calm blue
/// water, an orange→yellow→white road ramp); `day = false` is a dark
/// skade-style basemap (near-black navy land, cyan water + top roads). Every
/// colour is a fixed sRGB hex so the map looks identical under any theme —
/// selected when the host wants the cartographic look rather than the themed
/// one. Casing is a near-background dark so bright roads read as ribbons.
pub fn cartographic(day: bool) -> Self {
if day {
Self {
land: Color32::from_rgb(0xF2, 0xEF, 0xE9),
water: Color32::from_rgb(0xA8, 0xD0, 0xE6),
green: Color32::from_rgb(0xC8, 0xE6, 0xC9),
building: Color32::from_rgb(0xE2, 0xDB, 0xCF),
coast: Color32::from_rgb(0x5A, 0x8F, 0xB0),
road_motorway: Color32::from_rgb(0xE9, 0xA2, 0x3B),
road_primary: Color32::from_rgb(0xF6, 0xD3, 0x65),
road_secondary: Color32::from_rgb(0xFF, 0xFF, 0xFF),
road_tertiary: Color32::from_rgb(0xFB, 0xFB, 0xF7),
road_residential: Color32::from_rgb(0xF3, 0xF1, 0xEA),
path: Color32::from_rgb(0xB9, 0x98, 0x73),
road_generic: Color32::from_rgb(0xDE, 0xD8, 0xCB),
overlay: OVERPRINT_DAY,
casing: Color32::from_rgb(0xB7, 0xAE, 0x9E),
}
} else {
Self {
land: Color32::from_rgb(0x0B, 0x0F, 0x1A),
water: Color32::from_rgb(0x12, 0x3A, 0x5A),
green: Color32::from_rgb(0x14, 0x36, 0x1F),
building: Color32::from_rgb(0x1A, 0x22, 0x33),
coast: Color32::from_rgb(0x2E, 0x7F, 0xA6),
road_motorway: Color32::from_rgb(0x46, 0xC6, 0xE0),
road_primary: Color32::from_rgb(0x3A, 0xA6, 0xC8),
road_secondary: Color32::from_rgb(0x8A, 0x9C, 0xB2),
road_tertiary: Color32::from_rgb(0x6E, 0x7E, 0x92),
road_residential: Color32::from_rgb(0x55, 0x62, 0x75),
path: Color32::from_rgb(0x3E, 0x5A, 0x46),
road_generic: Color32::from_rgb(0x3A, 0x42, 0x4F),
overlay: OVERPRINT_NIGHT,
casing: Color32::from_rgb(0x05, 0x07, 0x0C),
}
}
}
/// **The LABEL ink for this palette** — dark on light paper, light on dark.
///
/// THE DEFECT THIS CLOSES. `MapTheme::cartographic` took its label colour from the
/// HOST's `facett_core::Theme::text`, with a comment claiming that kept text
/// "legible against the app's own chrome". The map is not chrome: korp's app theme
/// is dark, so `theme.text` is `#DCDCDC`, and the cartographic day map printed
/// `#DCDCDC` on `#F2EFE9` paper. MEASURED on the shipped korp-ui over Stockholm at
/// zoom 12.7: `#DCDCDC` covered **9 387 px (1.03 %)** of the map canvas at a
/// contrast ratio of **1.18 : 1** against the land. WCAG asks 4.5 : 1 for body
/// text. Text at 1.18 : 1 is not read — it is *seen*, as grain. That is the worst
/// of both outcomes: all of the clutter, none of the information.
///
/// Derived from the palette's own `land` rather than pinned per preset, so a
/// theme-derived palette (`from_theme`, which can be light or dark) gets a correct
/// answer by construction instead of by review. MEASURED by
/// `label_collision::the_day_map_prints_its_labels_at_readable_contrast`: day
/// `#F2EFE9` land → `#181817` ink at **15.48 : 1**; night `#0B0F1A` land →
/// `#EBECED` ink at **16.18 : 1**.
#[must_use]
pub fn label(&self) -> Color32 {
if relative_luminance(self.land) > 0.5 {
mix(self.land, Color32::BLACK, 0.9)
} else {
mix(self.land, Color32::WHITE, 0.92)
}
}
/// **The label HALO** — the thin outline drawn around label text so a name stays
/// readable where it crosses a dark road casing or a water fill, instead of being
/// swallowed by whatever it happens to lie on. It is the paper itself, which is why
/// it is invisible over open land and only appears where it is needed.
#[must_use]
pub fn label_halo(&self) -> Color32 {
self.land
}
/// **The RAIL ink** — the hue a `railway=*` line is drawn in.
///
/// It exists because `railway=*` had no branch in [`classify_with`] at all: a rail
/// fell through to the generic arm and painted in `road_generic`, the same ink as an
/// untagged fence. A national rail network is not an untagged anything, and a look
/// that cannot tell the two apart cannot be corrected by a tier alone.
///
/// Derived from the palette's own `land` rather than pinned per preset — the same
/// construction [`label`](Self::label) uses and for the same reason: `from_theme`
/// can be light or dark, and a hard-coded slate would be invisible on the dark
/// preset. Day `#F2EFE9` land → `#5C5B59` (contrast 5.91 : 1); night `#0B0F1A`
/// land → `#919398` (contrast 6.22 : 1). Both well past the 4.5 : 1 a reader needs,
/// because a rail is a line you follow rather than grain you tolerate.
///
/// **korp keeps its own `map_paint::RAIL_INK` and that is not a twin.** korp inks by
/// NETWORK identity — the `network` column of `rails.parquet`, known before any tag
/// is read — and can therefore ink a rail whose tags it never looked at. This inks by
/// TAG, which is the only thing available for a railway that arrives inside a
/// monolith/basemap tile carrying no network id. Two different questions, two
/// answers; the tier below is what they now agree about.
///
/// # This is the CORE of a two-tone line (GG #28)
///
/// A rail is cased like every other opaque line — [`casing`](Self::casing) under it on
/// both painters — so what this returns is the tone INSIDE that casing, and the two
/// have to be told apart or the rail is one dark stroke. They were not: on a light
/// host theme through [`from_theme`](Self::from_theme) the casing is `land` 55 % toward
/// black and this was `land` 62 % toward black — 17 sRGB units apart, one tone.
///
/// So the core is chosen against BOTH the paper and the casing: of three candidates
/// anchored on the paper — dark (62 % toward black), light (55 % toward white) and the
/// midpoint between casing and paper — the one that is farthest from whichever of the
/// two it is closest to. No threshold to tune, and on every preset the answer is the
/// one a reader wants:
///
/// * `cartographic(day)` — light casing `#B7AE9E` on `#F2EFE9` paper: the DARK core,
/// `#5C5B59` (5.91 : 1 against the paper, unchanged from before);
/// * `cartographic(night)` / `from_theme` on a dark host — near-black casing on dark
/// paper: the LIGHT core (unchanged);
/// * `from_theme` on a light host — dark casing on light paper: the midpoint, a grey
/// the casing frames on both sides. The Garmin reading — dark edges, light body —
/// and, since HO-T3, the dashes: this core is cut along the way's arc length at
/// [`dash_period_px`] on BOTH lanes (the arc length rides `LineVertex::col.a` under
/// the flag in `half_px`'s sign; the CPU painter strokes one `Shape::line` per lit run),
/// so the gaps show this casing and the one-road-two-looks defect `kalm-4` closed
/// stays closed.
#[must_use]
pub fn rail(&self) -> Color32 {
let dist = |a: Color32, b: Color32| {
(i32::from(a.r()) - i32::from(b.r()))
.abs()
.max((i32::from(a.g()) - i32::from(b.g())).abs())
.max((i32::from(a.b()) - i32::from(b.b())).abs())
};
let candidates = [
mix(self.land, Color32::BLACK, 0.62),
mix(self.land, Color32::WHITE, 0.55),
mix(self.casing, self.land, 0.5),
];
// The candidate whose NEAREST neighbour (paper or casing) is farthest away. The
// first wins a tie, so the historical dark core is the default.
let mut best = candidates[0];
let mut best_sep = i32::MIN;
for c in candidates {
let sep = dist(c, self.land).min(dist(c, self.casing));
if sep > best_sep {
best = c;
best_sep = sep;
}
}
best
}
/// The **building OUTLINE stroke** — the hue a building footprint's edge is
/// drawn in (a shade of the [`building`](Self::building) fill). THE single
/// source of truth: [`recolor_way`](Self::recolor_way) uses it for every
/// `WayKind::Building` way, and the 2D viewer's building-vertex (corner) layer
/// uses it so a corner dot is the same ink as the wall it sits on — rather than
/// the theme's unrelated `point` hue, which on the cartographic-day paper reads
/// as a *blue sea* of dots (the reported bug).
#[must_use]
pub fn building_stroke(&self) -> Color32 {
mix(self.building, Color32::BLACK, 0.3)
}
/// Recolour a single [`Way`](crate::Way) in place to this palette, keyed on
/// its [`WayKind`] (+ its width, which encodes the road tier — the `Way`
/// keeps no raw tags). Lets a host swap an already-built way set from the
/// theme-derived look to a [`cartographic`](Self::cartographic) preset at
/// runtime without re-reading the source geometry. Areas keep a fill + a
/// slightly darker outline; lines take the matching road-tier hue.
pub fn recolor_way(&self, way: &mut crate::Way) {
// `stroke` = the line / outline hue; `fill_col` = the area fill hue
// (`None` for a line class).
let (stroke, fill_col) = match way.style.kind {
WayKind::Water => (self.coast, Some(self.water)),
WayKind::Park => (mix(self.green, Color32::BLACK, 0.2), Some(self.green)),
WayKind::Building => (self.building_stroke(), Some(self.building)),
WayKind::Coastline => (self.coast, None),
WayKind::Path => (self.path, None),
WayKind::Road => {
// Width encodes the tier (motorway 2.5 … residential 1.0): pick the hue
// by the ONE width ladder, `road_rank`, so hue, casing and z-order cannot
// drift apart. (They did: this was a second copy of the thresholds, and
// both kept motorway at `>= 2.6` after 7640848 moved motorway to 2.5.)
let c = match road_rank(way.width) {
4 => self.road_motorway,
3 => self.road_primary,
2 => self.road_secondary,
1 => self.road_tertiary,
_ => self.road_residential,
};
(c, None)
}
WayKind::Other => (self.road_generic, None),
// A railway reskins to THIS palette's rail ink — the ONE writer. While it was
// `Other` the first `cartographic()` swap put every rail in `road_generic`.
WayKind::Rail => (self.rail(), None),
// The overprint keeps its own ink across a palette swap. `Other` would
// reskin it to `road_generic` — the ink `has_nothing_to_draw` exists to
// keep off the water — so the whole operator overlay would go back to
// being an invisible hairline on the first `cartographic()` call.
WayKind::Overprint => (self.overlay, None),
// **A GLOW KEEPS ITS OWN INK, exactly as the overprint does.** The trail's
// colour is the case's, chosen by korp from the neon family; reskinning it
// here would repaint every trail in one road hue on the first
// `cartographic()` call and it would stop being a trail at all. This arm is
// why `Glow` is a KIND and not just a wide `Other` — the same evidence the
// overprint arm above records, one overlay later.
WayKind::Glow => (way.style.color, None),
};
way.stroke = stroke;
way.style.color = stroke;
// Class fill colour rides `style.fill` for an area (so a later relayout
// still knows the hue); the *painted* `way.fill` recolours only if the
// geometry was already a filled ring.
way.style.fill = if way.style.is_area { fill_col } else { None };
way.fill = way.fill.and(fill_col);
}
}
/// The **road-class rank** encoded by a road [`Way`](crate::Way)'s width — `4` =
/// motorway/trunk (widest) down to `0` = residential/service (narrowest); paths /
/// non-roads are `0`. The single source of truth for the road hierarchy's
/// **paint z-order** (a car-navigator draws minor roads first and major roads on
/// top, so junctions read as clean continuous major roads) and its **per-class
/// casing width**. Keyed on the same width thresholds [`MapPalette::recolor_way`]
/// uses to pick the tier hue, so colour + width + z-order stay in lockstep (REUSE:
/// no twinned tier table).
pub fn road_rank(width: f32) -> u8 {
// 2.35 sits between primary's 2.2 and motorway/trunk's 2.5 (7640848 moved motorway
// from 3.0 to 2.5 — "too fat" on the 890M — and this threshold stayed at 2.6, so a
// motorway ranked and was hued as a primary).
if width >= 2.35 {
4 // motorway / trunk
} else if width >= 1.9 {
3 // primary
} else if width >= 1.5 {
2 // secondary
} else if width >= 1.15 {
1 // tertiary
} else {
0 // residential / service / minor
}
}
// ── Line width: ONE formula, both lanes (the deck.gl `widthMinPixels` lesson) ──
//
// Studied from deck.gl's `PathLayer` vertex shader, which resolves a stroke width as
//
// vec2 widthPixels = vec2(clamp(
// project_size_to_pixel(instanceStrokeWidths * path.widthScale, path.widthUnits),
// path.widthMinPixels, path.widthMaxPixels) / 2.0);
//
// i.e. a world-ish width scaled for the current view and then **clamped into a pixel
// band**, recomputed in the shader every frame. Two properties fall out of that and
// both were missing here:
//
// 1. a road never vanishes when zoomed out (the `widthMinPixels` floor) and never
// bloats into a slab when zoomed in (the `widthMaxPixels` ceiling); and
// 2. because it is ONE formula, every renderer of the same data agrees on width.
//
// Ours disagreed: the CPU basemap painter scaled `style.width` by a zoom factor in
// `[0.5, 3.0]`, while the GPU line lane baked `style.width × 1.0` **once** into the
// vertex buffer — a constant that could never respond to zoom. The two lanes drew the
// same road 2–3× apart depending on zoom (and the GPU roads stayed frozen while the
// CPU land outlines grew), which reads exactly as "the land and the lines are at two
// different scales". The functions below are that single source of truth; the GPU
// applies them in the vertex shader (per frame, via a uniform) and the CPU painter
// calls them directly.
/// Reference zoom (px per Mercator unit) at which a way's themed `style.width` is
/// drawn at 1× — the midpoint of the scale ramp below.
pub const LINE_WIDTH_ZOOM_REF: f64 = 50_000.0;
/// Lower / upper bound on the zoom-derived width multiplier (the ramp is clamped so a
/// world view keeps hairlines legible and a street view does not explode).
pub const LINE_WIDTH_SCALE_MIN: f32 = 0.5;
pub const LINE_WIDTH_SCALE_MAX: f32 = 3.0;
/// deck.gl's `widthMinPixels` / `widthMaxPixels` — the pixel band every line width is
/// clamped into, so lines stay crisp and bounded at ANY zoom.
pub const LINE_WIDTH_MIN_PX: f32 = 1.0;
pub const LINE_WIDTH_MAX_PX: f32 = 24.0;
/// The zoom-derived width **multiplier** (deck.gl's `widthScale` for the current
/// view). Split out from [`line_width_px`] because the GPU lane passes exactly this
/// one number to the vertex shader as a uniform, which then applies the same clamp —
/// so the baked geometry stays zoom-independent while the drawn width tracks zoom.
#[must_use]
pub fn line_width_scale(zoom: f64) -> f32 {
((zoom / LINE_WIDTH_ZOOM_REF) as f32).clamp(LINE_WIDTH_SCALE_MIN, LINE_WIDTH_SCALE_MAX)
}
/// Resolve a themed `style.width` to its **on-screen pixel width** at `zoom` —
/// deck.gl's `clamp(width × widthScale, widthMinPixels, widthMaxPixels)`. The single
/// formula both the CPU painter and the GPU line lane use, so the two never disagree.
#[must_use]
pub fn line_width_px(style_width: f32, zoom: f64) -> f32 {
line_width_px_scaled(style_width, line_width_scale(zoom))
}
/// [`line_width_px`] with the multiplier supplied directly — the exact arithmetic the
/// line shader performs, so a CPU test can assert the two lanes agree bit-for-bit.
#[must_use]
pub fn line_width_px_scaled(style_width: f32, scale: f32) -> f32 {
(style_width * scale).clamp(LINE_WIDTH_MIN_PX, LINE_WIDTH_MAX_PX)
}
/// **THE FILL-OUTLINE WIDTH LAW — the stroke on a filled area's rings.**
///
/// `mapgpu-1`. A filled way's outer + hole rings are stroked by both lanes, and the
/// width was written **twice, differently**: `f.width.max(0.5)` in
/// `layer::MapFacet::ui` / `MapView::draw_ways` on the CPU, and an implicit `1.0` on
/// the GPU because `PrimitiveTopology::LineList` has no width at all. So the same
/// ring came out 0.5 px feathered on one lane and 1 px aliased on the other, and the
/// smallest areas lost coverage: MEASURED 2026-08-03 on a real 1600x1000 window,
/// non-background coverage 19 507 px (CPU) -> 17 419 px (GPU), **-10.7 %**.
///
/// This is deliberately NOT [`line_width_px_scaled`]: an area's edge is a hairline
/// that marks a boundary, not a road whose thickness carries meaning, so it does not
/// track zoom and it is allowed below the road band's [`LINE_WIDTH_MIN_PX`] floor.
/// [`FILL_OUTLINE_MIN_PX`] is what the CPU painter's `max(0.5)` always was; naming it
/// is what lets the GPU lane read the same number instead of inventing one.
#[must_use]
pub fn fill_outline_width_px(style_width: f32) -> f32 {
style_width.max(FILL_OUTLINE_MIN_PX)
}
/// **AA softness (px) on a filled area's ring stroke.**
///
/// Half the road lane's, because the mark is a fraction of a road's width and a feather
/// that is twice the stroke is a smear, not an edge. MEASURED on this box 2026-08-22
/// against the CPU painter it must match (7x7 lakes, 600x600, RTX 4090), as the ratio
/// of GPU ink MASS to CPU ink mass: at the road lane's 1.0 px feather **1.052x**, at
/// 0.5 px **1.040x**. The COUNT of lit pixels moves further than the mass does
/// (1.090x -> 1.030x), which is the tell that what a wider feather adds is ramp WIDTH
/// rather than ink — a pixel-count metric reads a faint tail as a full mark.
pub const FILL_OUTLINE_FEATHER_PX: f32 = 0.5;
/// Floor on a filled area's ring stroke (px) — see [`fill_outline_width_px`].
///
/// It is BELOW [`LINE_WIDTH_MIN_PX`] on purpose. The road band's floor exists so a
/// distant motorway stays legible; a lake's edge has no such duty and a 1 px floor on
/// it would thicken every building footprint in a city viewport.
pub const FILL_OUTLINE_MIN_PX: f32 = 0.5;
/// **THE CASING LAW — the extra half-width (px) a road's dark outline adds per side.**
///
/// `kalm-4`. This arithmetic existed once, in `line.wgsl`, as
/// `min(max(w_px * 0.5 * casing_ratio, casing_min_px), width_add)` — and the CPU
/// painter, which is not a degraded mode but what draws under a bearing, under a tilt,
/// on a host with no adapter and in every `egui_kittest` harness test in this repo,
/// drew **no casing at all**. One road, two looks, and no CPU-side test could see a
/// casing regression because there was no CPU-side casing.
///
/// It is a function and not three constants because the ratio, the floor and the
/// ceiling only mean anything together: `RATIO` alone makes a motorway's casing 1.5 px
/// and a footpath's 0.25 px, and it is `CASING_MIN_PX` that stops the second from
/// vanishing. A second copy of two of the three is how the shader and the painter would
/// drift while both looked right in isolation.
///
/// `width_px` is the **resolved** on-screen width from [`line_width_px_scaled`], not
/// the raw `style.width` — the shader reads it after the same clamp.
#[must_use]
pub fn casing_extra_px(width_px: f32) -> f32 {
(width_px * 0.5 * CASING_RATIO).clamp(CASING_MIN_PX, CASING_MAX_PX)
}
/// **The casing's extra half-width as a fraction of the way's own half-width.**
///
/// It was a flat `+2 px` for every way regardless of the road's width, so a residential
/// street (1 px core) drew as a 5 px band of near-black with a 1 px light core — four
/// fifths casing — and there are tens of thousands of them in a city viewport. MEASURED
/// before the fix on the shipped korp-ui at zoom 12.7 over Stockholm: the casing colour
/// alone covered 2.81 % of the canvas and 15.67 % of the canvas was ink darker than
/// grey 70; after, 0.83 %. The dark grain the map was accused of was the road casing.
pub const CASING_RATIO: f32 = 0.5;
/// Floor (px) under [`CASING_RATIO`], so the thinnest way keeps a visible edge instead
/// of losing its outline entirely.
pub const CASING_MIN_PX: f32 = 0.35;
/// Ceiling (px) on the casing's extra half-width — this is all a very wide motorway is
/// allowed to grow by.
pub const CASING_MAX_PX: f32 = 2.0;
/// **THE DASH LAW — the period (px) of a dashed way's core, along its ARC LENGTH.**
///
/// HO-T3 (GG #28 found the gap: *"no dash machinery exists"*). A Garmin rail is a dark
/// casing with a light core cut into dashes; the dash is a property of the way's
/// LENGTH, not of the screen, so it must not crawl when the map pans and a diagonal
/// rail must carry exactly as many dashes as a horizontal one of the same length.
///
/// The period follows the way's own resolved width, because the width is what the eye
/// measures a dash against:
///
/// ```text
/// period_px = DASH_PERIOD_RATIO · w_px (w_px = line_width_px(style.width, zoom))
/// dash = DASH_DUTY · period_px lit, the rest shows the casing
/// ```
///
/// One function, both lanes — the CPU painter hands it to `Shape::dashed_line`, and
/// `line.wgsl` carries the two constants as WGSL twins (`DASH_PERIOD_RATIO`,
/// `DASH_DUTY`) that `tests/rail_dash_pixels.rs` reads out of the shader text and
/// asserts equal. `width_px` is the RESOLVED on-screen width from
/// [`line_width_px_scaled`], exactly as [`casing_extra_px`] takes it.
#[must_use]
pub fn dash_period_px(width_px: f32) -> f32 {
width_px * DASH_PERIOD_RATIO
}
/// Dash period as a multiple of the way's resolved width — see [`dash_period_px`].
/// `4`: a 2 px trunk rail at 1× is 4 px lit / 4 px dark, the Garmin proportion.
pub const DASH_PERIOD_RATIO: f32 = 4.0;
/// The lit fraction of one dash period — see [`dash_period_px`].
pub const DASH_DUTY: f32 = 0.5;
impl WayStyle {
/// **Does this way's core carry dashes?** — the ONE gate both lanes read (HO-T3).
///
/// Derived from the kind rather than stored, so a way built by any of the
/// constructors — `classify_with`, `Way::line`, a host's own style literal — answers
/// the same way for the same kind and no constructor has to remember a flag. Today
/// exactly [`WayKind::Rail`] dashes; a road is unaffected.
#[must_use]
pub fn dashed(&self) -> bool {
self.kind == WayKind::Rail
}
}
/// Classify an OSM way by its tags into a themed [`WayStyle`]. Mirrors the
/// branch structure of osm-viewer's `classify`, but the colours are pulled from
/// `theme` (so the basemap follows the palette) and area kinds carry a `fill` +
/// `is_area`. `tags` is the decoded JSON tag map (`{"highway":"motorway", ...}`);
/// an empty map yields the generic (line) style.
pub fn classify<K: AsRef<str>, V: AsRef<str>>(tags: &[(K, V)], theme: &Theme) -> WayStyle {
classify_with(tags, &MapPalette::from_theme(theme))
}
/// The palette-driven core of [`classify`]: classify an OSM way's `tags` into a
/// [`WayStyle`] whose colours are pulled from an explicit [`MapPalette`]. Passing
/// [`MapPalette::from_theme`] reproduces [`classify`] byte-for-byte (the themed
/// look); passing [`MapPalette::cartographic`] paints the concrete-hex preset.
/// This is the single classification path — the two looks differ only in which
/// palette feeds it (REUSE: no twinned tag-branch logic).
pub fn classify_with<K: AsRef<str>, V: AsRef<str>>(tags: &[(K, V)], pal: &MapPalette) -> WayStyle {
let get = |k: &str| crate::tag(tags, k);
let has = |k: &str| crate::has_tag(tags, k);
// ── A FERRY IS A SEA ROUTE, NOT A TRAIL ────────────────────────────────────────
//
// **Why this arm is before the marked-route arm.** The arm below opens with a bare
// `has("route")`, written for hiking/ski/mtb, and `route=ferry` matches it. Every
// ferry line in the cut was therefore returned as `trail(Lod::District)` — `pal.path`,
// the brown track ink, drawn from camera 9 up — so an international ferry was painted
// across open sea as a long straight brown chord. The owner photographed one over Skåne.
//
// MEASURED on the served cut (`~/.korp/work/sweden/ways.parquet`, 2026-09-02,
// 9 325 142 rows): **876** ways carry `route=ferry` or `ferry=*`, **772 of them at
// `min_zoom` 9** — the server hands them over at exactly the rung the trail arm drew
// them at. The longest, way **299847757** *"København (DK) – Oslo (N)"*, spans
// **2.656° of latitude × 2.037° of longitude** in one straight line across the
// Kattegat. Six of the ten longest carry `ferry=trunk|primary|secondary|tertiary`,
// i.e. a HIGHWAY CLASS as the value of the `ferry` key — which is the second half of
// why it reads as a road to anyone looking at the tags.
//
// **What it becomes.** `WayKind::Water` in the coastline ink, a LINE (a ferry route
// never closes into a filled ring), from `Lod::City`. The tier is the judgement:
// a ferry link is information when the operator is looking at a harbour, and a
// four-hundred-kilometre chord over a regional view is the clutter that was reported.
// Drawing LATER than the server sends is the benign direction (see the marked-route
// arm below for the same argument); the dangerous direction is a client wanting what
// the server withholds, and this is not that.
//
// **The `ferry`-only shape is gated on `highway`** so this can never demote a road:
// a `highway=*` way that merely mentions a ferry keeps its road class and falls
// through untouched. `route=ferry` is unambiguous and needs no gate.
// osm-katana already knows this — `relgeom.rs` excludes `route=ferry` from the trails
// extract — but that exclusion lived only on the extract side and was never mirrored
// into the classifier, so the client re-created the mistake from the raw tags.
if get("route") == Some("ferry") || (has("ferry") && get("highway").is_none()) {
return WayStyle {
color: pal.coast,
width: 0.7,
lod: Lod::City,
kind: WayKind::Water,
is_area: false,
fill: None,
};
}
// ── MARKED ROUTES — leder (`railtier-1`, the trail half).
//
// # Why this arm is BEFORE `highway` and why it has to be
//
// A marked route arrives tagged as the way it runs along: 19 664 of the 21 658 hiking
// rows in the Swedish cut carry `highway=path`, so the arm below tiers them
// [`Lod::Detail`] — camera 15, the footpath rung, argued for street furniture in a
// city. A hiking route is not street furniture. MEASURED on the live thin client at
// camera 9.33: the `trails` layer was fed **6 719** ways and drew **32**, and the 32
// were the handful whose tags happened to name an arterial.
//
// # Why it reads TAGS and not a class
//
// Because the class does not reach here. korp cuts `trails.parquet` with a `class`
// COLUMN (`hiking` / `cycling` / `ski` / `mtb` / `snowmobile`) and filters its server
// read on it, but `class` is not on the wire — `MapWay`/`MapPolyline` carry `tags`,
// `osm_id` and `network`, nothing else — so a client has only the tags. What it does
// have is enough, MEASURED over all **75 694** rows of
// `~/.korp/work/sweden/networks/trails.parquet` (2026-08-27 cut), each rule scored
// against the file's own `class` column:
//
// | rule | rows it claims | recall of its class | what it steals |
// |---|---|---|---|
// | `piste:type` | 9 529 | **91.1 %** of `ski` | 305 `hiking`, 152 `mtb` |
// | `snowmobile` | 32 040 | **99.2 %** of `snowmobile` | 713 `hiking`, 139 `ski`, 102 `mtb` |
// | `route` | 268 | **100 %** of `cycling` (all 18 rows) | 147 `hiking`, 76 `mtb`, 27 `ski` |
// | `sac_scale` \| `trail_visibility` | 20 437 | **94.4 %** of `hiking` | nothing |
// | `mtb:scale` | 12 356 | **97.1 %** of `mtb` | nothing |
//
// 721 rows (0.95 %) match nothing and fall through to the `highway` arm exactly as
// before. The rules are ordered by precision, not by class size: `mtb:scale` is on
// 100 % of `mtb` AND on 25 % of `hiking`, so it must be asked LAST or it takes a
// quarter of the hiking network with it.
//
// # The tiers, and the one that is deliberately not korp's
//
// korp's `map_networks::Network::tier_floors` gives `Trails` the floors `[0, 9, 12]`:
// hiking from camera 0, cycling+ski from 9, mtb+snowmobile from 12. Ski, cycling, mtb
// and snowmobile land on their floor exactly. **Hiking does not** — it gets
// [`Lod::District`] (9) rather than [`Lod::Country`] (0), and that is a choice with a
// reason: 21 658 hiking paths drawn over a whole-Sweden view is the clutter defect
// `kalm-1` names, reached from the other side, and the 84 %-recall half of the hiking
// rule (`trail_visibility`) is not precise enough to earn the top tier. Drawing LATER
// than the server sends is the benign direction — one rung of bytes that arrive before
// anything paints them; the dangerous direction is a client wanting what the server
// withholds, and this is not that.
//
// The result on the whole file: **30 234 of 75 694 (39.9 %)** trail ways draw at
// camera 9.33, against the **41.8 %** korp's own ladder serves at that rung
// (hiking+cycling+ski, 31 630 rows on this cut). The two ladders now agree to within
// 1.9 percentage points where they used to disagree by 100 %.
//
// # A route along an arterial is still an arterial
//
// The `highway` gate below is why this arm cannot silently demote a road: a cycling
// route signposted along a primary road carries the relation's `route=bicycle` onto
// the way, and letting the route win would drop an arterial from [`Lod::Country`] to
// [`Lod::District`]. 343 rows in the trails cut are such ways. The route is a property
// of the road; the road is the object.
if !matches!(
get("highway"),
Some(
"motorway" | "motorway_link" | "trunk" | "trunk_link" | "primary" | "primary_link"
| "secondary" | "secondary_link" | "tertiary" | "tertiary_link"
)
) {
// Ordered by the precision measured above. `is_area: false` throughout: a marked
// route is a line even when it closes into a loop.
let trail = |lod| WayStyle {
color: pal.path,
width: 0.9,
lod,
kind: WayKind::Path,
is_area: false,
fill: None,
};
if has("piste:type") {
return trail(Lod::District);
}
if has("snowmobile") {
return trail(Lod::City);
}
if has("route") {
return trail(Lod::District);
}
if has("sac_scale") || has("trail_visibility") {
return trail(Lod::District);
}
if has("mtb:scale") {
return trail(Lod::City);
}
}
// Roads + paths are LINES (strokes), styled by `highway` class. Widths are
// sensible logical-px at fit-zoom (the painter scales them with zoom); the
// colours ride the palette so motorways pop and minor roads recede.
if let Some(hw) = get("highway") {
let line = |color, width, lod, kind| WayStyle { color, width, lod, kind, is_area: false, fill: None };
return match hw {
// **2.5, not 3.0 — the owner's eye on the 890M, 2026-09-06.** With GG #27 both
// lanes finally read `style::line_width_px`, and at the ramp's 3.0× ceiling
// (slippy zoom ≳ 9.2) a 3.0 style width lands at **9 px** against `primary`'s
// **6.6** — *"the orange ones are a bit too fat, the yellow ones are perfect"*.
// The ramp is NOT the defect (the yellow it produces is the one he approved);
// the CLASS width was, and motorway/trunk is the only band he called out. 2.5
// lands at **7.5 px**, so the ladder keeps its order and its gap to `primary`.
"motorway" | "trunk" => line(pal.road_motorway, 2.5, Lod::Country, WayKind::Road),
"primary" => line(pal.road_primary, 2.2, Lod::Country, WayKind::Road),
// ── THE RAMPS ARE NOT THE ROAD (`railtier-2`).
//
// `motorway_link` / `trunk_link` / `primary_link` shared `Lod::Country` with the
// roads they ramp off, and korp does not send any of them until camera **7**
// (`map_networks::Network::tiers`, the z7-8 rung: *"+ the links that make the
// skeleton connected"* — its z<=6 tier is `motorway`, `trunk`, `primary` and
// nothing else). So for SEVEN zooms the tier claimed a visibility the wire
// could not deliver, and korp is the side that is right: an interchange ramp is
// not a country-scale object, and at camera 6 one painted at the motorway's
// 3.0 width is a blob competing with the trunk line it belongs to.
//
// `Lod::Region`, camera 6, is the closest rung the five-tier ladder has to
// korp's 7 — the residual one-zoom gap is the granularity artefact named in
// korp's `tests/net_ladder_vs_draw_tier.rs`. **This changes no pixel**, and that
// is now MEASURED there rather than argued here, on two facts taken from the
// live 2026-08-27 Sweden cut:
//
// * the band `6.0 <= camera < 7.0` floors onto the SAME tile-pyramid rung as
// camera 6 (`[6, 9, 12, 15]`), so it reads the very cut the class ladder has
// already filtered — asserted, so a future residue that straddles a rung is
// red;
// * the one reader with NO class filter — the basemap tile read — carries none
// of them: 19 z6 tiles, 493 072 rows, 80 331 non-point geometries, **64** with
// any `highway` tag (46 construction, 17 proposed, 1 abandoned) and **0** of
// these four. Roads live in `networks/roads.parquet`, where the ladder governs.
//
// What it changes is the promise. Also refused, deliberately: a sixth `Lod` at
// camera 7. Camera 7 is korp's BUDGET rung, not a cartographic one, and putting
// a `LOD_TIER_ZOOMS` step where nothing about the LOOK changes would move every
// `Lod as u32` index in this file, `cull.wgsl` and `lod_ladder_vs_pyramid.rs` to
// encode a row count.
//
// Ink and width stay the road's: a ramp off a motorway IS motorway asphalt,
// and inventing a third colour here would be an unargued taste call.
"motorway_link" | "trunk_link" => {
line(pal.road_motorway, 3.0, Lod::Region, WayKind::Road)
}
"primary_link" => line(pal.road_primary, 2.2, Lod::Region, WayKind::Road),
"secondary" => line(pal.road_secondary, 1.7, Lod::Region, WayKind::Road),
// `secondary_link` splits off `secondary` for the same reason and lands EXACTLY
// on korp's floor: korp sends `secondary` from 7 but `secondary_link` from 9,
// in the same rung as `tertiary`. Painting it from 6 was a three-zoom promise
// gap — the widest of the six this pass found — and `Lod::District` closes it
// to zero rather than merely narrowing it.
"secondary_link" => line(pal.road_secondary, 1.7, Lod::District, WayKind::Road),
// `Lod::District`, not `Lod::Region` — the z9 rung. A tertiary road is the
// sub-regional network: too fine to read on a country overview, too coarse
// to wait for the city tier. It shared `Region` with `secondary` only
// because there was no rung between 6 and 12 to put it on.
"tertiary" | "tertiary_link" => line(pal.road_tertiary, 1.3, Lod::District, WayKind::Road),
"residential" | "unclassified" | "living_street" => {
line(pal.road_residential, 1.0, Lod::City, WayKind::Road)
}
// ── `service` JOINS `track` ON THE DETAIL RUNG (`railtier-2`).
//
// It sat with `residential` at `Lod::City`, camera 12, while korp holds it —
// together with `track` — to its own z14 rung, *"the minor VEHICULAR tail, and
// nothing pedestrian"*. Two zooms in which the tier said driveways and parking
// aisles were eligible and the wire carried none: 599 416 rows, the biggest
// single class in `roads.parquet`, promised and absent.
//
// `track` — korp's OTHER member of that same rung — has been `Lod::Detail`
// since `kalm-1`, and the arm below says in so many words that it is styled AS
// `service` because korp treats them as the same class of thing. They were
// identical in ink, width and kind and differed only in tier, which is the
// inconsistency rather than the design. Now they are the same in all four.
//
// **This changes no pixel either**: korp sends neither before 14, so nothing
// was drawn at 12-13 to lose. It turns a dangerous-direction gap (the painter
// wanting what the wire withholds) into the benign one the `track` arm already
// argues for: one zoom of bytes that arrive before anything paints them.
"service" => line(pal.road_residential, 1.0, Lod::Detail, WayKind::Road),
// **A FOREST ROAD YOU CAN DRIVE DOWN IS NOT A FOOTPATH.** `track` used to sit
// in the footpath arm below, painted in `pal.path` at 0.7 and classified
// `WayKind::Path`. That arm justifies itself with `kalm-1`, a RENDERING-
// DENSITY argument about sub-pixel footpaths; `track` was swept in with them
// and never argued for on its own. It is a vehicular road by OSM definition
// — agricultural and forestry roads you can drive — and korp's histogram of
// the Sweden extract names it exactly that ("`track` (316 960 — forest and
// field roads)"), filing it with `service` in the minor VEHICULAR tail.
//
// Styled AS `service`, deliberately: korp treats them as the same class of
// thing, and any width or ink difference invented here would be the same
// unargued taste call that created the defect.
//
// **THE TIER DOES NOT MOVE — `Lod::Detail`, camera 15 — and this is the half
// a future reader will get wrong.** korp sends tracks from z14. The client
// drawing LATER than the server sends is the benign direction: one zoom of
// bytes that arrive before anything paints them. The dangerous direction is
// the opposite, and this is not it. The two ladders have different
// granularity BY DESIGN (five cartographic tiers against six wire floors), so
// "helpfully" aligning this to 14 would introduce a mismatch across two
// ladders that are meant to be independent. See
// `tests::a_track_is_a_vehicular_road_not_a_footpath`.
"track" => line(pal.road_residential, 1.0, Lod::Detail, WayKind::Road),
// `Lod::Detail`, not `Lod::City` — `kalm-1`. A footpath is the finest thing
// the extract carries and it was tiered with the residential street, so the
// entire footpath network of a city drew at zoom 12, where it is sub-pixel.
"footway" | "cycleway" | "path" | "pedestrian" | "steps" => {
line(pal.path, 0.7, Lod::Detail, WayKind::Path)
}
// ── THE ROSTER RECONCILIATION. korp enumerates 22 `highway` classes and
// admits each from a floor LOD; this file enumerated 20, and the six it did
// not name fell through to `_`. A fallback is not a tier policy, it is the
// absence of one, and it landed three of them three zooms late.
//
// `road` is OSM's "class not surveyed"; `busway` and `bus_guideway` are
// bus-only carriageways. korp files all three in its z9-11 tier, the county
// road net. **Only expressible since `98e4a316`** — `Lod::District` is the
// z9 rung and did not exist before it.
//
// The INK stays `road_generic` deliberately. The defect is the tier; giving
// an unsurveyed class or a bus-only carriageway a tertiary road's colour
// would assert a classification nobody has made, which is the same unargued
// taste call that put `track` in the footpath arm.
"road" | "busway" | "bus_guideway" => {
line(pal.road_generic, 0.9, Lod::District, WayKind::Road)
}
// korp's z12-13 tier. These already landed on `Lod::City` — but only because
// the fallback happened to point at the right tier, and correct by accident
// is not correct: nothing recorded that it had to stay there. Naming them
// changes no pixel today and makes `_` mean what it says.
"raceway" | "rest_area" | "services" => {
line(pal.road_generic, 0.9, Lod::City, WayKind::Road)
}
// Reached only by a value NEITHER roster names. korp admits those at its
// z >= 15 escape hatch (`&[]`, no filter), so nothing arrives before 15
// whatever this says. See `tests::every_class_korp_sends_is_drawn_at_the_
// zoom_korp_sends_it`.
_ => line(pal.road_generic, 0.9, Lod::City, WayKind::Road),
};
}
// ── RAILWAYS — the class that had NO branch here at all (`railtier-1`).
//
// # What it cost, measured on the owner's own run
//
// `railway=*` matched nothing above and fell through to the generic arm at the
// bottom of this function, which stamps [`Lod::City`] — a tier `LOD_TIER_ZOOMS`
// does not enter until camera **12**. `layer::lod_visible` is `lod <= view_lod_tier`,
// so EVERY rail way was culled at EVERY camera below 12, whatever ink it carried.
//
// MEASURED 2026-08-27 on the live thin client (`korp BUILD 7a8733e91f` against oden
// `:50051`), `$KORP_STATE.map.scene.applied.layers` at camera zoom **9.33** (tier
// `District`): the `rails` layer was fed **7 217** ways and drew **0**. Not "drew
// faintly" — zero, at the zoom a national rail network is exactly the thing you are
// looking for. The fed count is what made it invisible for so long: every counter on
// the pane read healthy.
//
// # Why THESE three tiers
//
// They are not a taste call. korp's `map_networks::Network::tier_floors` already
// gives `Rails` the floors `[0, 9, 12]` and enumerates which classes ride each rung,
// and those floors are EXACTLY `LOD_TIER_ZOOMS[Country] = 0`,
// `[District] = 9`, `[City] = 12`. The server was already shipping the national net
// from camera 0 while this file threw 100 % of it away until 12; the two ladders
// disagreed, and the honest fix is for the painter to draw the rung the server
// sends rather than to invent a fourth policy.
//
// * `rail` / `narrow_gauge` — 82.3 % of the Swedish rail file and the whole extent of
// the network. [`Lod::Country`], beside the motorway, because that is what it is:
// the E4 and the Västra stambanan are the same class of object at the same zoom.
// * urban rail (`light_rail`, `subway`, `tram`, `funicular`, `preserved`,
// `miniature`) — [`Lod::District`], the z9 rung. A tram network is a city-region
// fact, legible once the camera is over a city and meaningless over a country.
// * everything else — platforms, sidings, yards, turntables, signal boxes, and every
// `railway=` value nobody enumerated — [`Lod::City`], camera 12. This arm is also
// where `abandoned` / `disused` / `razed` / `construction` land, which is the right
// depth for them: they are ground truth about the ground, not the network.
//
// # Position in this function, which is load-bearing
//
// AFTER the `highway` arm on purpose. A `highway=footway` carrying `railway=crossing`
// is a footpath that crosses a railway, not a railway; letting rail win there would
// reclassify level crossings into the rail network and move a footway three tiers up.
// Rails proper carry no `highway` tag — VERIFIED on
// `~/.korp/work/sweden/networks/rails.parquet` (29 683 rows, 2026-08-27 cut): every
// sampled row carries `railway=`, none carries `highway=`.
//
// BEFORE the `landuse` arm, which is the other trap: `has("landuse")` is an
// unqualified catch-all that paints anything carrying the key as a green park, and
// `landuse=railway` is a real and common tag on rail corridors.
//
// # The KIND (GG #28, *"rail has no kind"*)
//
// The two network rungs are `WayKind::Rail`; the third rung stays `WayKind::Other`.
// `Rail` is what puts a railway INSIDE the road ladder (`paint_order::RAIL_CLASS`:
// trunk rail between primary and secondary, urban rail between tertiary and
// residential) instead of at `Other`'s overlay offset above every road, and what lets
// `recolor_way` keep it in the rail ink across a palette swap. A platform, a disused
// bed, a construction site or a yard turntable is NOT the network — it is ground truth
// about the ground — so it keeps the catch-all kind and paints where it always did.
//
// # The WIDTHS, which are the casing
//
// A rail is two-tone by casing + core (and dashed along that core since HO-T3 — see
// `WayStyle::dashed` and `dash_period_px`), and
// the casing law (`casing_extra_px`) is proportional: at 1.5 px the casing was
// 0.375 px a side, under the antialias feather — no second tone to see. Trunk rail
// is 2.0 (between `secondary` 1.7 and `primary` 2.2, where it also ranks) and urban
// rail 1.2 (between `residential` 1.0 and `tertiary` 1.3, likewise).
if let Some(rw) = get("railway") {
let rline = |width, lod, kind| WayStyle { color: pal.rail(), width, lod, kind, is_area: false, fill: None };
return match rw {
"rail" | "narrow_gauge" => rline(2.0, Lod::Country, WayKind::Rail),
"light_rail" | "subway" | "tram" | "funicular" | "preserved" | "miniature" => {
rline(1.2, Lod::District, WayKind::Rail)
}
_ => rline(0.8, Lod::City, WayKind::Other),
};
}
// Coastline is a LINE (the shore), distinct + a touch heavier.
if get("natural") == Some("coastline") {
return WayStyle { color: pal.coast, width: 1.6, lod: Lod::Country, kind: WayKind::Coastline, is_area: false, fill: None };
}
let area = |color: Color32, fill: Color32, width, lod, kind| WayStyle {
color,
width,
lod,
kind,
is_area: true,
fill: Some(fill),
};
// Water AREAS: natural=water/lake/bay, any waterway polygon → blue fill.
if matches!(get("natural"), Some("water") | Some("lake") | Some("bay") | Some("wetland")) || has("water") {
return area(pal.coast, pal.water, 0.8, Lod::Region, WayKind::Water);
}
// ── WATERWAYS, tiered by VALUE — the same ladder the `highway` classes above get.
//
// THE DEFECT THIS CLOSES. This was ONE branch, `if has("waterway")`, and it gave a
// river, a stream, a ditch, a drain, a canal, a weir and a dam the identical style:
// width 1.0, one blue, `is_area: true`, and `Lod::Region`. `Lod::Region` starts at
// camera **6** ([`crate::layer::LOD_TIER_ZOOMS`] = `[0, 6, 12, 15]`), so every
// agricultural ditch and field drain in the extract was drawn from a regional
// overview outwards — at a zoom where a 1 m ditch is far under a pixel. That is the
// blue hairball the owner reported alongside the paint-order tangle: not too much
// water, water with no hierarchy. Meanwhile the roads directly above have had a
// four-rung ladder all along (motorway → Country, secondary → Region, residential →
// City, footway → Detail, `kalm-1`), so the fix is to stop being the one class that
// opted out of it.
//
// `is_area` is now true only for the values that ARE polygons (`riverbank`, `dock`,
// `basin`). It rode on every waterway before, including the linear ones. The
// *painted* fill was already safe — `Way::from_lonlat_styled` drops the fill unless
// the ring is closed, and both render lanes key on the resolved `Way::fill`
// (`features_into`'s `let filled = w.fill.is_some()`, `MapFacet::gpu_fillable`), so
// no stream was ever filled. But `MapPalette::retheme_way` writes `style.fill` from
// `is_area` alone, and `paint_order` asks "is this an area?" per feature; a linear
// way claiming to be an area is a lie waiting for its second reader.
if let Some(ww) = get("waterway") {
let line_blue = mix(pal.water, Color32::WHITE, 0.1);
let wline = |width, lod| WayStyle {
color: line_blue,
width,
lod,
kind: WayKind::Water,
is_area: false,
fill: None,
};
return match ww {
// A river is a country-scale feature — the Klarälven belongs on a national
// overview exactly as the E4 does.
"river" => wline(2.0, Lod::Country),
// The mapped POLYGON of a river/dock/basin: an area, and a big one.
"riverbank" | "dock" | "basin" => {
area(pal.coast, pal.water, 0.8, Lod::Country, WayKind::Water)
}
// A canal is an infrastructure watercourse at the scale a tertiary road is
// an infrastructure road — the same z9 rung, for the same reason. The river
// above it (Country) and the stream below it (City) do not move.
"canal" => wline(1.4, Lod::District),
"stream" => wline(0.9, Lod::City),
// The finest rung, with the footpaths: a ditch or a field drain is the
// finest thing the extract carries and there are hundreds of thousands.
"ditch" | "drain" => wline(0.6, Lod::Detail),
// Structures ON the water, not the water: they are point-like at any zoom a
// human would look at them from.
"dam" | "weir" | "lock_gate" | "fish_pass" | "waterfall" | "rapids" => wline(0.8, Lod::City),
_ => wline(0.8, Lod::City),
};
}
// Green AREAS: forest/wood, parks, generic landuse, leisure greens → green fill.
if matches!(get("natural"), Some("wood") | Some("forest") | Some("scrub") | Some("grassland") | Some("heath")) {
return area(mix(pal.green, Color32::BLACK, 0.2), pal.green, 0.6, Lod::Region, WayKind::Park);
}
if matches!(get("leisure"), Some("park") | Some("garden") | Some("nature_reserve") | Some("recreation_ground")) {
return area(mix(pal.green, Color32::BLACK, 0.2), pal.green, 0.6, Lod::Region, WayKind::Park);
}
if matches!(
get("landuse"),
Some("forest") | Some("grass") | Some("meadow") | Some("recreation_ground") | Some("village_green")
| Some("farmland") | Some("orchard") | Some("vineyard") | Some("cemetery") | Some("allotments")
) || has("landuse")
{
return area(mix(pal.green, Color32::BLACK, 0.2), pal.green, 0.5, Lod::Region, WayKind::Park);
}
// ── A PIER IS GROUND, A PIPELINE IS NOT (lane TL, 2026-09-03) ─────────────────
//
// The third of lane SI's three named-but-undecided classes, and the only one of the
// three that earns ink. `man_made` is a key whose values split cleanly in two:
//
// * things you can STAND ON, which a harbour map is largely made of — `pier`,
// `breakwater`, `groyne`, `embankment`, `causeway`. They are land pushed out
// into water, and that is exactly how they are drawn: in [`MapPalette::land`],
// the paper ink, so a pier reads as a cream finger off the quay and is
// invisible where it crosses actual land, which is correct — a pier over land
// is not information.
// * things that are NOT on the surface at all — `pipeline`, `cable`. Two of the
// five sea-crossing chords lane SI measured through Riddarfjärden were exactly
// these. They keep drawing NOTHING.
//
// **Why this needed [`CLASSIFIED_PAIRS`] and could not just add `man_made` to
// [`CLASSIFIED_KEYS`].** The key roster admits a way on its KEY alone, and an
// admitted way whose VALUE matches no arm falls to the generic hairline at the
// bottom of this function. Putting `man_made` in the key roster would therefore
// have put `man_made=pipeline` straight back on the water in `road_generic` —
// re-opening the very defect, through the door built to close it. The pair roster
// admits `man_made=pier` and leaves `man_made=pipeline` refused.
if matches!(
get("man_made"),
Some("pier") | Some("breakwater") | Some("groyne") | Some("embankment") | Some("causeway")
) {
// `Lod::City`: a pier is a metres-wide structure and is noise at any camera
// from which a whole harbour fits. `WayKind::Path` because that is what it is
// to anyone using it, and it keeps piers out of the road-network re-ink korp
// runs over `WayKind::Road`.
return WayStyle { color: pal.land, width: 1.2, lod: Lod::City, kind: WayKind::Path, is_area: false, fill: None };
}
// Building AREAS → subtle grey fill.
if has("building") || get("amenity").is_some() {
return area(mix(pal.building, Color32::BLACK, 0.3), pal.building, 0.5, Lod::City, WayKind::Building);
}
// Generic / untagged way → a faint thin line (no fill).
//
// **This arm is a GUESS, not a classification** — see [`CLASSIFIED_KEYS`] and
// [`has_nothing_to_draw`]. A way whose tags name none of the keys the arms above
// read never reaches a painter: the classifying `Way` constructors refuse it, so
// this style is what a caller that *asked for a style anyway* gets, and what a way
// carrying a read key but an unmatched VALUE (`man_made=pier`, `place=islet`)
// still paints in.
WayStyle { color: pal.road_generic, width: 0.6, lod: Lod::City, kind: WayKind::Other, is_area: false, fill: None }
}
/// **Every tag key [`classify_with`] actually reads** — the allowlist behind
/// [`has_nothing_to_draw`], and the reason it is a const and not a comment.
///
/// Derived from the function body, and held to it by
/// `tests::the_classified_key_roster_is_the_keys_classify_with_reads`, which scans this
/// file's own text for `get("…")` / `has("…")` inside `classify_with` and refuses any
/// literal missing here. So the roster cannot drift from the arms: adding a branch that
/// reads a new key fails the guard until the key is named here.
pub const CLASSIFIED_KEYS: [&str; 16] = [
"amenity",
"building",
"ferry",
"highway",
"landuse",
"leisure",
"mtb:scale",
"natural",
"piste:type",
"railway",
"route",
"sac_scale",
"snowmobile",
"trail_visibility",
"water",
"waterway",
];
/// **Do these tags say NOTHING a map can draw?** True when not one key is in
/// [`CLASSIFIED_KEYS`] — i.e. every arm of [`classify_with`] would fall through to the
/// generic hairline at the bottom, which is a guess and not a classification.
///
/// # The defect this closes (lane SI, 2026-09-03)
///
/// The v2.1 sweep's **E sea-ink** oracle reads `RED(2275…4405)` at deep Stockholm
/// (z13–z16) on the live cut. MEASURED on the served cut
/// (`~/.korp/work/sweden/ways.parquet`) over the z14 Stockholm viewport: **16 565** ways
/// intersect it and **496 of them carry NO TAGS AT ALL** — the member geometry of
/// boundary and multipolygon relations, which OSM leaves untagged on purpose. Another
/// few hundred carry only metadata (`source=historical`, `source_ref=ekonomiska kartan`,
/// `maritime=yes`, `seamark:type=cable_submarine`). Every one of them fell to the generic
/// arm, was stroked `road_generic` **#DED8CB** at `Lod::City`, and — because the land
/// silhouette now washes the pane with `theme.sea` — was painted as a long straight
/// **road-coloured chord across open water**. 31 of them cross the open water of that one
/// frame; **90 %** of the oracle's own foreign-ink pixels lie within 3 px of one.
///
/// It is the same shape as the `route=ferry` defect the arm at the top of
/// [`classify_with`] closes, from the other end: there the tags said "ferry" and the
/// classifier read "trail"; here the tags say nothing and the classifier answers "road".
///
/// **The rung is not the bug.** `osm_katana::shared::class::UNCLASSIFIED_MIN_ZOOM` is
/// 12 *because* this arm is `Lod::City`, deliberately, so the two ladders agree; the
/// server hands the geometry over and the client is right to receive it. What is wrong
/// is painting it as a road. Refusing it here needs no re-cut.
///
/// **Why an allowlist and not "has no tags".** MEASURED on the same frame: dropping only
/// the zero-tag ways leaves 82–95 % of the sea ink standing, because half the chords
/// carry a `source` or a `maritime` key and nothing else. The allowlist is not a taste
/// call either — it is [`classify_with`]'s own reading list, guarded against drift.
/// **The VALUE-selective half of the allowlist** — `(key, value)` pairs that
/// [`classify_with`] draws where the KEY alone is not enough to admit a way.
///
/// [`CLASSIFIED_KEYS`] is a key roster, and a key roster can only say "any value under
/// this key draws". For `man_made` that is false in the dangerous direction:
/// `man_made=pier` is ground and `man_made=pipeline` is one of the submarine chords
/// lane SI measured across Riddarfjärden. Admitting the KEY would have sent
/// `man_made=pipeline` to the generic `road_generic` hairline at the bottom of
/// [`classify_with`] — the defect re-opened by the fix.
///
/// So a pair-level roster, held to the function's own text by the same source scan that
/// guards [`CLASSIFIED_KEYS`]: a key read by a VALUE-selective arm must be named here
/// pair by pair, and may not appear in the key roster.
pub const CLASSIFIED_PAIRS: [(&str, &str); 5] = [
("man_made", "breakwater"),
("man_made", "causeway"),
("man_made", "embankment"),
("man_made", "groyne"),
("man_made", "pier"),
];
/// **EVERY OSM TAG KEY ANY PAINTER IN THIS CRATE READS** — the roster a *server* needs
/// in order to stop shipping tag text no client will ever look at (korp lane GG #19
/// row 2).
///
/// # What this is, and how it differs from [`CLASSIFIED_KEYS`]
///
/// [`CLASSIFIED_KEYS`] answers *"would [`classify_with`] paint this way at all?"* — it
/// is a refusal rule, and it names only the keys that DECIDE a class. This roster is
/// wider on purpose: it is the union of every key that changes ANY painted output, so a
/// filter built from it cannot alter a pixel. Beyond the classifier that is
///
/// * [`classify_label`] — `name`, `place`, `ref`, `highway`;
/// * [`crate::LayerCategory::from_osm_tags`] — `amenity`, `shop`, `building` (the POI
/// buckets);
/// * [`crate::paint_order::parse_osm_z`] — `layer`, `bridge`, `tunnel`, which decide the
/// paint RANK whenever a server did not send one (korp GG #17's `has_rank == false`
/// fallback, and every local file read);
/// * the building extras on [`crate::Way`] — `height`, `building:levels`, `roof:levels`,
/// `roof:shape`, `roof:material`, and the two spellings each of `building:colour` and
/// `roof:colour`;
/// * the road extras — `name`, `maxspeed`, `oneway`, `junction`.
///
/// # Why it is a const with a guard and not a comment
///
/// A server filtering its wire against a STALE copy of this list does not fail loudly:
/// it silently stops sending a key, and a building loses its height or a motorway its
/// shield, on some cameras, for some features. So the roster is held to the source by
/// `tests::the_painted_key_roster_is_every_key_this_crate_reads`, which scans this
/// crate's own text for tag reads — `tag(tags, "…")`, `has_tag(tags, "…")`, and
/// `get("…")` / `has("…")` through the tag closures — and refuses in BOTH directions: a
/// key read but not listed, and a key listed but never read.
///
/// # It is a floor, not a ceiling
///
/// A consumer of this crate may read keys of its own — korp's map SENTINEL rides on
/// `korp:sentinel`, which exists in no OSM schema and which nothing here has heard of.
/// A filter must union this roster with its own, never take it as the whole truth.
pub const PAINTED_KEYS: [&str; 36] = [
"amenity",
"bridge",
"building",
"building:color",
"building:colour",
"building:levels",
"ferry",
"height",
"highway",
"junction",
"landuse",
"layer",
"leisure",
"man_made",
"maxspeed",
"mtb:scale",
"name",
"natural",
"oneway",
"piste:type",
"place",
"railway",
"ref",
"roof:color",
"roof:colour",
"roof:levels",
"roof:material",
"roof:shape",
"route",
"sac_scale",
"shop",
"snowmobile",
"trail_visibility",
"tunnel",
"water",
"waterway",
];
/// Does any painter in this crate read this tag key? See [`PAINTED_KEYS`].
#[must_use]
pub fn is_painted_key(key: &str) -> bool {
PAINTED_KEYS.contains(&key)
}
/// **Do these tags say NOTHING a map can draw?** True when not one key is in
/// [`CLASSIFIED_KEYS`] and not one `(key, value)` is in [`CLASSIFIED_PAIRS`].
///
/// The pair half only ever ADMITS: a way refused before this const existed is refused
/// still unless it carries one of the five exact pairs named there.
#[must_use]
pub fn has_nothing_to_draw<K: AsRef<str>, V: AsRef<str>>(tags: &[(K, V)]) -> bool {
!tags.iter().any(|(k, v)| {
CLASSIFIED_KEYS.contains(&k.as_ref())
|| CLASSIFIED_PAIRS.contains(&(k.as_ref(), v.as_ref()))
})
}
/// **What a geometry source that carries NO OSM TAGS AT ALL draws in.**
///
/// # The distinction this draws (lane TL, 2026-09-03)
///
/// [`has_nothing_to_draw`] is right and is not weakened by this. But it answers a
/// question about ONE WAY's tags, and there are two different things that look the same
/// from inside it:
///
/// | | what it is | verdict |
/// |---|---|---|
/// | an untagged way inside a TAGGED OSM cut | a boundary relation's member geometry — OSM leaves it untagged on purpose | **draw nothing.** Lane SI, unchanged. |
/// | every way in a source with NO `tags` COLUMN | a plain WKB table an operator explicitly pointed the host at — Trafikverket, NVDB, a survey export | **draw.** It is not OSM and was never going to have OSM tags. |
///
/// The seam is the parquet SCHEMA, not the row: `facett_osm::read_ways_with` already
/// computes `tags_col: Option<&StringArray>` once per batch, and `None` there means the
/// file has no readable `tags` column at ALL. Lane SI left this exact door open on
/// purpose — the tag gate sits on the four CLASSIFYING `Way` constructors and never on
/// `from_lonlat_styled`, because "a caller with its own [`WayStyle`] has already
/// decided". A tagless SOURCE is such a caller.
///
/// # The ink, and why it is not a road tier
///
/// [`MapPalette::overlay`] — magenta, the cartographic overprint colour (see that
/// field). Explicitly NOT `road_generic`: that is the ink lane SI just took off the
/// water, and putting a whole national roads file back into it would undo the fix at
/// scale. Explicitly not a road TIER either — the reader knows this geometry's SOURCE
/// and nothing about its class, and painting an unknown class as `motorway` is the same
/// species of guess the generic arm was.
///
/// # The rung
///
/// [`Lod::Region`] and not [`Lod::City`]. The failure being fixed is INVISIBILITY, and a
/// national roads overlay that first appears at camera 12 is invisible at every camera
/// an operator would load one for. Not [`Lod::Country`] either: that rung carries the
/// classified trunk skeleton, and a whole untagged table smeared over it at continent
/// zoom is the clutter the ferry arm above was written to stop.
///
/// Width `1.6` — the `secondary` weight, twice `road_generic`'s `0.6`. A layer the
/// operator asked for by name reads as a layer, not as a hairline.
#[must_use]
pub fn untagged_source_style(pal: &MapPalette) -> WayStyle {
WayStyle { color: pal.overlay, width: 1.6, lod: Lod::Region, kind: WayKind::Overprint, is_area: false, fill: None }
}
/// **How far outside the pane a way's CENTRELINE can sit and still paint into it**, in
/// logical px (stinky `seam-15`).
///
/// The cull compares a way's Mercator *envelope* against the viewport, and that envelope
/// is the centreline's — it is never dilated by anything the painter actually draws. A
/// motorway whose centreline misses the pane by one pixel loses its whole 24 px ribbon,
/// its casing and its antialias feather, so the road ends in mid-air at the edge of the
/// canvas and the next pan snaps it back.
///
/// The pad is the widest half-stroke this crate can paint: half of
/// [`LINE_WIDTH_MAX_PX`], plus the casing that rides outside it
/// ([`CASING_MAX_PX`]), plus one pixel of feather. It is deliberately the MAXIMUM
/// rather than per-way: one number in one uniform, asked by both lanes, and the cost of
/// being generous is a handful of extra ways just off-screen — while the cost of being
/// exact per way is a second width resolution living in the cull.
pub const CULL_PAD_PX: f32 = LINE_WIDTH_MAX_PX * 0.5 + CASING_MAX_PX + 1.0;
/// [`CULL_PAD_PX`] in Mercator units at this camera — the ONE writer both the CPU cull
/// ([`crate::layer::WaysLayer::draws`]) and the GPU cull (`cull.wgsl`, via
/// `ViewportCull::min`/`max`) widen their viewport by.
///
/// `0.0` for a degenerate camera: a division that went wrong must widen nothing rather
/// than swallow the world.
#[must_use]
pub fn cull_pad_merc(world_px: f64) -> f64 {
if !world_px.is_finite() || world_px <= 0.0 {
return 0.0;
}
f64::from(CULL_PAD_PX) / world_px
}
/// **The minimum on-screen extent, in logical px, a FILLED way must reach to be worth
/// drawing** (stinky `kalm-3`).
///
/// MEASURED off `$KORP_STATE` on two settled z12 panes over Stockholm: `building_ways`
/// **37 378** and **34 708** against an `osm_ways` cap of **80 000** — 43–47 % of
/// everything the server is allowed to send, and every one of them a polygon the fill
/// lane tessellates. At camera 12.7 the pane's Mercator scale is 1.70 Mpx for the world,
/// so a 20 m house spans **1.67 px**. They are not the dark grain (`#E2DBCF` fill at
/// 12.08 % of the canvas, `#9E9991` outline at 0.72 %) — they are half the budget spent
/// on shapes with no shape.
///
/// The discrete [`Lod`] ladder cannot fix this: there is no tier that means "when it is
/// big enough", and a building's tier is a property of the building while its size on
/// screen is a property of the camera. This gate is continuous, and it is the ONE number
/// both lanes ask for — see [`min_fill_extent_merc`].
///
/// **3 px, not 4.** A 3 px square is the smallest mark that still reads as a mark rather
/// than as noise, and at the Sweden pyramid's z15 rung (0.608 m/px) a 20 m house is
/// ~33 px, so nothing a human is looking AT is ever near the threshold.
pub const MIN_FILL_EXTENT_PX: f32 = 3.0;
/// **The Mercator-unit extent below which a filled way is not drawn at this camera.**
///
/// ONE writer for a rule that has to hold on TWO lanes or the map draws its lakes twice:
/// [`crate::layer::WaysLayer::draws`] applies it on the CPU, and the GPU cull applies
/// the very same number, handed to `cull.wgsl` as a uniform (`ViewportCull::min_fill_extent`)
/// rather than re-derived in WGSL. A shader cannot call a Rust function; it can be given
/// its answer, and that is the closest a cross-language pair gets to one writer.
///
/// `world_px` is [`crate::layer::MapView::world_px`] — the whole world's width in logical
/// pixels at this zoom — so `extent_merc * world_px` is the way's on-screen extent.
///
/// Returns `0.0` for a degenerate camera, i.e. the gate opens rather than closing: a
/// division that went wrong must never blank the map.
#[must_use]
pub fn min_fill_extent_merc(world_px: f64) -> f32 {
if !world_px.is_finite() || world_px <= 0.0 {
return 0.0;
}
(f64::from(MIN_FILL_EXTENT_PX) / world_px) as f32
}
/// A **map label** resolved from a feature's OSM tags: the display [`text`](Self::text)
/// (a road `ref` shield like `E4`, else a `name`) plus the [`Lod`] tier at which it
/// first appears. The tier is the declutter law — country/region places show from a
/// country overview, towns at a regional zoom, street names + minor places only once
/// zoomed into a city — so labels thin out with zoom instead of stacking into mush.
/// This is the text half of the "so-perfect Garmin map" (the biggest Garmin-feel
/// lever); [`crate::layer::LabelLayer`] turns these into LOD-gated draw features.
#[derive(Clone, Debug, PartialEq)]
pub struct LabelClass {
/// The label text to paint (a route `ref`, else a place/street `name`).
pub text: String,
/// The zoom tier this label first appears at (the declutter gate).
pub lod: Lod,
/// **How much this name is worth WITHIN its tier**, `0.0..=1.0` — the second
/// ranking key the collision placer needs and did not have.
///
/// [`Lod`] has three steps and `view_lod_tier` saturates at `City`, so past zoom 12
/// *every* street, park, lake and named building arrives at the placer with the
/// identical priority and the tie breaks to the input index — i.e. to whatever
/// order the tiles happened to decode in. A motorway and a cul-de-sac then compete
/// as equals for the same pixels, and on a pane that can only fit ~65 names the
/// draw is decided by luck. This is the key that makes the ranking mean something.
///
/// See [`label_rank_of_style`] for the road/area scale; place nodes are ranked by
/// settlement size in [`classify_label`].
pub rank: f32,
}
/// **How much a labelled feature is worth within its LOD tier**, `0.0..=1.0`.
///
/// Roads and paths rank by [`classify_with`]'s **own width**, deliberately: that table
/// already encodes the highway hierarchy (motorway `3.0` → footway `0.7`), and a second
/// `match hw { "motorway" => … }` here would be a copy free to drift from it (LAW #5).
/// So motorway `3.0` → `0.80`, primary `2.2` → `0.59`, secondary `1.7` → `0.45`,
/// residential `1.0` → `0.27`, footway `0.7` → `0.19`.
///
/// The non-road kinds are ranked flat, because their sizes are not a hierarchy: a lake
/// name is worth more than a residential street and less than an arterial, a park less
/// than a lake, a named building least of all.
#[must_use]
pub fn label_rank_of_style(s: &WayStyle) -> f32 {
match s.kind {
WayKind::Road | WayKind::Path => (s.width / 3.0).clamp(0.0, 1.0) * 0.8,
WayKind::Coastline => 0.60,
WayKind::Water => 0.55,
WayKind::Park => 0.45,
WayKind::Building => 0.15,
WayKind::Other => 0.20,
// A named railway line (Västra stambanan, Malmbanan) is worth what a lake name is:
// more than a residential street, less than the arterial it runs beside.
WayKind::Rail => 0.55,
// A tagless source carries no `name` to label with, by construction.
WayKind::Overprint => 0.0,
// A trail is not a named feature — it is a connection drawn between two that are.
WayKind::Glow => 0.0,
}
}
/// **A road number is not a road number** — the `(tier, rank)` a route `ref` earns.
///
/// Every `ref` used to be one thing: `Lod::Region`, rank `0.90`, above every street
/// name in the country. That is right for `E4` and wrong for `10061`. Sweden numbers
/// its *secondary county roads* with four- and five-digit refs, OSM carries them, and
/// the first calm z12 render of Stockholm came back labelled `35002 · 69013 · 10357 ·
/// 10060 · 10042 · 21001 · 10982 · 13506 · 40107` — the pane's whole label budget spent
/// on numbers no driver navigates by, because they outranked "Sveavägen" by
/// construction. Worse, `Lod::Region` meant they were eligible from **zoom 6**.
///
/// The ladder is the Swedish road hierarchy, read off the ref itself (there is no tag
/// that states it):
///
/// | ref | example | tier | rank |
/// |----------------|----------|----------|------|
/// | European | `E4` | Region | 0.95 |
/// | 1–2 digits | `73` | Region | 0.88 |
/// | 3 digits | `222` | City | 0.70 |
/// | 4+ digits | `10061` | City | 0.22 |
///
/// `0.22` sits deliberately **below** a residential street (`0.27`): a five-digit
/// county road number carries less for a reader than the name of the street it is on.
fn ref_class(r: &str) -> (Lod, f32) {
let digits = r.chars().filter(char::is_ascii_digit).count();
if r.trim_start().starts_with(['E', 'e']) && digits > 0 {
return (Lod::Region, 0.95);
}
match digits {
0 => (Lod::City, 0.30),
1..=2 => (Lod::Region, 0.88),
3 => (Lod::City, 0.70),
_ => (Lod::City, 0.22),
}
}
/// Extract a [`LabelClass`] from an OSM way/node's `tags`, or `None` when the
/// feature carries no `name`/`ref` worth labelling. Priority + LOD tiering:
///
/// * a `place=` node → the settlement `name`, tiered by rank (country/state →
/// [`Lod::Country`], city/town → [`Lod::Region`], everything smaller →
/// [`Lod::City`]) — the country→region→city declutter backbone.
/// * a `highway` with a route `ref` (`E4`, `Rv40`) → the shield ref, tiered + ranked
/// by the ref's own class ([`ref_class`]: an `E`-road reads from a regional
/// overview, a five-digit county number does not); else its street `name` at
/// [`Lod::City`] (street names only once zoomed in).
/// * any other **named** feature (a lake, a park) → its `name` at [`Lod::City`].
///
/// Mirrors [`classify`]'s tag-priority discipline so the label pass and the fill
/// pass agree on what a feature *is* (REUSE: one tag vocabulary, no twinned rules).
pub fn classify_label<K: AsRef<str>, V: AsRef<str>>(tags: &[(K, V)]) -> Option<LabelClass> {
let get = |k: &str| crate::tag(tags, k);
let named = |lod: Lod, rank: f32| {
get("name").filter(|s| !s.is_empty()).map(|n| LabelClass { text: n.to_string(), lod, rank })
};
// Settlement labels — the country→region→city declutter backbone.
if let Some(place) = get("place") {
let (lod, rank) = match place {
"country" | "state" | "province" | "region" => (Lod::Country, 1.00),
// ── A COUNTRY VIEW WITH NO NAMES IS NOT A MAP (`label-city-country-1`) ────
//
// `city` and `town` used to share `Lod::Region`, whose rung opens at camera
// **6**. A whole-Sweden pane sits at about camera 5, so it opened NO rung but
// Country — and the only Country-tier node in the entire cut is the single
// `place=country` for Sverige. MEASURED on the live client 2026-08-31 at
// camera 5.6: `labels` **15 797 fed / 0 drawn**. Not one name on the country
// view.
//
// Splitting the pair is safe because the counts are not close. Named `place=*`
// over all nineteen z6 tiles of the 2026-08-31 Sweden cut: `country` **1**,
// `region` **5**, **`city` 6**, `municipality` 132, `town` 67. Six city names
// on a pane showing a whole country is the minimum a map carries, not clutter
// — and the areal budget (~65 names) is nowhere near reached by six.
//
// `town` stays at Region: 67 of them is right for a regional pane and wrong
// for a continental one.
"city" => (Lod::Country, 0.97),
"town" => (Lod::Region, 0.95),
// ── THE RUNG NOTHING STOOD ON (`label-district-1`) ───────────────────────
//
// `LOD_TIER_ZOOMS` is `[0, 6, 9, 12, 15]` — Country · Region · **District** ·
// City · Detail. Every settlement class used to resolve to Country, Region or
// City, so **District was occupied by no settlement at all** and the ladder
// had a six-level dead band: a town appeared at camera 6 and then nothing new
// was named until camera 12. That band, roughly 300 km down to 40 km of pane,
// is the band a person navigates in.
//
// MEASURED on the live client 2026-08-31 (korp-ui b937078 against oden) at
// camera 11.3: `labels` **5 319 fed / 1 drawn**, against a pane budget of
// about 65 ([`LABEL_MIN_PX2_EACH`]). The placer was not overloaded, it was
// starved — there was simply nothing in the viewport whose tier had opened.
//
// WHICH class belongs on the rung is a question about DENSITY, and it was
// measured rather than chosen. Named `place=*` nodes over all nineteen z6
// tiles of the 2026-08-31 Sweden cut:
//
// | class | named nodes |
// |---|---:|
// | `isolated_dwelling` | 25 221 |
// | `hamlet` | 14 348 |
// | `locality` | 3 687 |
// | **`village`** | **996** |
// | `municipality` | 132 |
// | `town` | 67 |
//
// 996 villages over a whole country is regional density — a handful on a
// 100 km pane, which the areal budget then ranks. 14 348 hamlets and 25 221
// isolated dwellings are NOT, and they stay at City where they were.
//
// WHY NOT POPULATION, which would be the obvious refinement: because the data
// does not carry it. Measured on the same tiles, `population` is present on
// **97 % of towns** (which already draw at Region and gain nothing) and on
// **27 % of villages, 2 % of hamlets and 0 % of isolated dwellings**. A tier
// computed from a field that is absent for three quarters of the class it
// decides is a tier decided by its own fallback.
//
// `municipality` rides with it: 132 nodes, and a kommun name is a regional
// fact by definition. `suburb` deliberately does NOT — a suburb sits inside a
// city whose name is already drawn two rungs earlier, so promoting it buys a
// second name for one place.
"village" => (Lod::District, 0.80),
"municipality" | "borough" => (Lod::District, 0.85),
// suburb / hamlet / neighbourhood / locality / isolated_dwelling / farm / islet …
_ => (Lod::City, 0.65),
};
return named(lod, rank);
}
// Roads — a route ref is a shield (shows regionally); a street name is city-LOD.
// The rank comes from `classify_with`'s own width table via `label_rank_of_style`
// (ONE highway hierarchy, not a second `match hw` free to drift from it); the
// palette only decides colours, which a rank does not read.
if get("highway").is_some() {
if let Some(r) = get("ref").filter(|s| !s.is_empty()) {
let (lod, rank) = ref_class(r);
return Some(LabelClass { text: r.to_string(), lod, rank });
}
return named(Lod::City, label_rank_of_style(&classify_with(tags, &MapPalette::cartographic(true))));
}
// Any other named feature (a water body, a park) → its name, zoomed-in only.
named(Lod::City, label_rank_of_style(&classify_with(tags, &MapPalette::cartographic(true))))
}
#[cfg(test)]
mod tests {
use super::*;
fn tags(kv: &[(&str, &str)]) -> Vec<(String, String)> {
kv.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
/// **THE TWO ROSTERS MUST NAME THE SAME CLASSES AT THE SAME ZOOMS.**
///
/// korp's `map_networks.rs::tiers` enumerates **22** `highway` classes and admits
/// each from a floor LOD; this file enumerated **20**. The six korp names and facett
/// did not all fell through to `_ => Lod::City`, and the fallback is not a tier
/// policy — it is the absence of one:
///
/// | class | korp admits from | facett drew from |
/// |---|---|---|
/// | `road`, `busway`, `bus_guideway` | **9** | 12 — three zooms LATE |
/// | `raceway`, `rest_area`, `services` | **12** | 12 — right, by accident |
///
/// The first three are the defect: korp cuts, stores, ships and decodes them from
/// camera 9 and the painter withheld them until 12. The second three were already
/// on the correct tier, but only because the fallback happened to point at it —
/// correct by accident is not correct, and it is why they are pinned here too.
///
/// **This is only expressible because of the z9 rung.** `Lod::District` did not
/// exist before `98e4a316`; there was no tier at camera 9 for the first three to be
/// aligned to, and the honest fix would have been unavailable.
///
/// The oracle is korp's floor, not a restated tier name, so this fails if either
/// side moves. What it CANNOT see is korp editing its own roster — that copy is
/// guarded the same way `lod_ladder_vs_pyramid`'s pyramid copy is, by korp's own
/// cross-seam test reading both originals.
#[test]
fn every_class_korp_sends_is_drawn_at_the_zoom_korp_sends_it() {
let pal = MapPalette::cartographic(true);
let draws_from =
|hw: &str| crate::layer::LOD_TIER_ZOOMS[classify_with(&tags(&[("highway", hw)]), &pal).lod as usize];
// (class, the floor korp admits it from — `map_networks.rs::tiers`, Network::Roads)
let roster: [(&str, u8); 6] = [
("road", 9),
("busway", 9),
("bus_guideway", 9),
("raceway", 12),
("rest_area", 12),
("services", 12),
];
let mut late = Vec::new();
for (hw, korp_floor) in roster {
let facett_from = draws_from(hw);
if facett_from != korp_floor {
late.push(format!(
"highway={hw}: korp sends from camera {korp_floor}, facett draws from \
{facett_from}"
));
}
}
assert!(
late.is_empty(),
"{} of {} korp-enumerated classes are drawn at a camera other than the one \
korp sends them from:\n {}\n\
Drawing LATER than the server sends wastes the bytes for those zooms; \
drawing EARLIER claims a visibility that does not exist. Both are the \
absence of a tier policy showing through `_`.",
late.len(),
roster.len(),
late.join("\n "),
);
// NON-VACUITY: the oracle must be able to distinguish tiers at all, or "they all
// match" is a statement about one number.
assert_ne!(draws_from("road"), draws_from("raceway"), "the two floors are different tiers");
assert_eq!(draws_from("motorway"), 0, "…and the ladder still starts at 0");
}
/// **A NATIONAL RAIL NETWORK MUST BE ON SCREEN AT THE ZOOM A COUNTRY IS** (`railtier-1`).
///
/// The assertion is the DRAWN predicate — `layer::lod_visible`, the exact `lod as u32
/// <= view_lod_tier(zoom)` compare all three CPU layers and `cull.wgsl` draw through —
/// and NOT the tier name and NOT a fed count. That distinction is the whole defect:
/// on the owner's run at camera 9.33 the `rails` layer reported **7 217 fed** and drew
/// **0**, so every count on the pane was healthy while the network was absent. A test
/// asserting "the tier is District" would also pass on a build where `lod_visible`
/// stopped being called at all.
///
/// **THE RED IT WAS WRITTEN AGAINST.** Before the `railway` arm existed, `railway=rail`
/// fell through to the generic arm's `Lod::City`, and this test failed on the first
/// four cameras it checks — 9.0, 9.33, 10.0 and 11.0 — reporting `railway=rail is not
/// drawn at camera 9 (tier City, the view is at tier 2)`. It passes after because the
/// arm puts trunk rail on `Lod::Country`, and Country draws at every camera there is.
#[test]
fn a_national_railway_is_drawn_at_the_camera_a_country_is_looked_at_from() {
let pal = MapPalette::cartographic(true);
let sty = |kv: &[(&str, &str)]| classify_with(&tags(kv), &pal);
let drawn = |s: &WayStyle, zoom: f32| {
crate::layer::lod_visible(
s.lod,
&crate::layer::MapView::new(crate::layer::LatLon::new(59.33, 18.07), zoom, (900.0, 620.0)),
)
};
let rail = sty(&[("railway", "rail")]);
let tram = sty(&[("railway", "tram")]);
let siding = sty(&[("railway", "platform")]);
// ── THE CAMERA HE WAS ON. 9.33 is the reading off `$KORP_STATE`; the neighbours
// are here so the pass is a RANGE and not one lucky rung.
for z in [9.0_f32, 9.33, 10.0, 11.0, 6.0, 0.0] {
assert!(
drawn(&rail, z),
"railway=rail is not drawn at camera {z} (tier {:?}, the view is at tier {}) \
— the server ships the whole national net from camera 0 \
(`map_networks::Network::tier_floors` gives Rails floor 0) and the painter \
is throwing it away",
rail.lod,
crate::layer::view_lod_tier(z),
);
}
// ── THE TIERS ARE korp's OWN FLOORS, read through the ladder rather than named.
// `[0, 9, 12]` is `tier_floors(Rails)`; if either side moves, this falls.
let floor = |s: &WayStyle| crate::layer::LOD_TIER_ZOOMS[s.lod as usize];
assert_eq!(floor(&rail), 0, "trunk rail rides korp's first Rails rung (floor 0)");
assert_eq!(floor(&tram), 9, "urban rail rides korp's second Rails rung (floor 9)");
assert_eq!(floor(&siding), 12, "yards and platforms ride korp's third rung (floor 12)");
// ── NON-VACUITY, both directions. A ladder that draws everything at every zoom
// would satisfy every assertion above, so the tiers must still DECLUTTER: a
// platform must be absent at the camera the trunk line is present at.
assert!(!drawn(&siding, 9.33), "a platform must NOT draw at camera 9.33");
assert!(!drawn(&tram, 6.0), "a tram line must NOT draw at camera 6");
assert!(drawn(&siding, 12.0), "…but the platform arrives at 12, or the tier is a wall");
// ── AND IT IS NO LONGER THE GENERIC ARM. The tell that the fall-through is gone:
// an untagged way and a railway used to be byte-identical styles.
let generic = sty(&[("barrier", "fence")]);
assert_ne!(rail.color, generic.color, "a rail must not paint in the untagged-way ink");
assert_ne!(rail.lod, generic.lod, "…nor sit on the untagged-way tier");
assert_eq!(generic.lod, Lod::City, "the generic arm itself has not moved");
}
/// **A RAILWAY IS A CASED TWO-TONE LINE IN ITS OWN KIND AND ITS OWN INK** — GG #28.
///
/// # What "two-tone" can mean on this renderer, honestly
///
/// The Garmin rail is a dark casing with light dashes. When this test was written the
/// GPU line lane had NO dash machinery — `LineVertex` carried no arc length (it is
/// exactly 48 B and that size is load-bearing), `line.wgsl` had no dash term, and the
/// CPU painter stroked one `Shape::line` — so this test pinned the half both lanes
/// could draw identically: the palette's casing under a core that is visibly a
/// different tone from it. HO-T3 added the dashes on both lanes without growing the
/// vertex (`gpu::line::pack_dash_half_px`, `tests/rail_dash_pixels.rs`); the tone
/// contract here is unchanged and still what the gaps are cut out of.
///
/// # THE RED
///
/// * `railway=rail` classified `WayKind::Other` — there was no `Rail` (compile error).
/// * On a LIGHT host theme through `from_theme`, the casing is `land` 55 % toward black
/// and the rail ink was `land` 62 % toward black: two greys 17 sRGB units apart, one
/// dark line, no second tone.
#[test]
fn a_railway_is_a_cased_two_tone_line_in_its_own_ink() {
// A light "paper" host theme through `from_theme`, the branch where the core and the
// casing were the same grey. Every theme in `Theme::ALL` is dark, so it is built here.
let paper = Theme { bg: Color32::from_rgb(250, 250, 250), ..Theme::default() };
let palettes = [
("from_theme(light)", MapPalette::from_theme(&paper)),
("from_theme(dark)", MapPalette::from_theme(&Theme::default())),
("cartographic(day)", MapPalette::cartographic(true)),
("cartographic(night)", MapPalette::cartographic(false)),
];
let dist = |a: Color32, b: Color32| {
(i32::from(a.r()) - i32::from(b.r()))
.abs()
.max((i32::from(a.g()) - i32::from(b.g())).abs())
.max((i32::from(a.b()) - i32::from(b.b())).abs())
};
for (name, pal) in &palettes {
let sty = |kv: &[(&str, &str)]| classify_with(&tags(kv), pal);
let rail = sty(&[("railway", "rail")]);
let tram = sty(&[("railway", "tram")]);
let other = sty(&[("barrier", "fence")]);
// ── ITS OWN KIND. The network proper, both rungs.
for (v, s) in [("rail", &rail), ("narrow_gauge", &sty(&[("railway", "narrow_gauge")])), ("tram", &tram)] {
assert_eq!(s.kind, WayKind::Rail, "{name}: railway={v} must be WayKind::Rail");
}
for v in ["light_rail", "subway", "funicular", "preserved", "miniature"] {
assert_eq!(sty(&[("railway", v)]).kind, WayKind::Rail, "{name}: railway={v} is urban rail");
}
// …and the ground truth about the ground is NOT the network: a platform is not a
// line you can ride, a disused bed carries no train, a construction site is a
// promise. They keep the third rung and the catch-all kind they had.
for v in ["abandoned", "disused", "razed", "construction", "platform", "station", "turntable"] {
let s = sty(&[("railway", v)]);
assert_ne!(s.kind, WayKind::Rail, "{name}: railway={v} must NOT become Rail");
assert_eq!(s.lod, Lod::City, "{name}: railway={v} keeps the third rung");
}
// ── CASED. Both painters case every OPAQUE line (`paint_features` pass 1 and
// the GPU casing pass) and skip a translucent one — so an opaque, non-area line
// IS a cased line, and the rail must be that.
assert_eq!(rail.color.a(), u8::MAX, "{name}: a translucent stroke gets no casing");
assert!(!rail.is_area && rail.fill.is_none(), "{name}: a rail is a LINE, not a ring");
// ── ITS OWN INK, from the ONE writer. Not `Other`'s, not a twin of `rail()`.
assert_eq!(rail.color, pal.rail(), "{name}: the rail core is `MapPalette::rail()`");
assert_eq!(tram.color, pal.rail(), "{name}: urban rail shares the rail ink");
assert_ne!(rail.color, other.color, "{name}: a rail must not paint in Other's ink");
assert_ne!(rail.color, pal.road_generic, "{name}: …nor in `road_generic`");
// ── TWO TONES. The core must be told apart from the casing it sits in, and
// from the paper it sits on — or it is one dark line and the casing is invisible.
assert!(
dist(rail.color, pal.casing) >= 40,
"{name}: rail core {:?} and casing {:?} are one tone — no second tone to see",
rail.color,
pal.casing
);
assert!(
dist(rail.color, pal.land) >= 20,
"{name}: rail core {:?} vanishes into the paper {:?}",
rail.color,
pal.land
);
// Where the casing is dark, the core is the LIGHTER of the two: dark edges, light
// body — the Garmin reading (the dashes are HO-T3's, cut out of this core).
if relative_luminance(pal.casing) < 0.25 {
assert!(
relative_luminance(rail.color) > relative_luminance(pal.casing),
"{name}: a dark casing wants a lighter core, got {:?} inside {:?}",
rail.color,
pal.casing
);
}
}
// ── AND A RESKIN KEEPS IT. `recolor_way` reskins BY KIND; an `Other` rail would come
// back from the first `cartographic()` swap in `road_generic`.
let night = MapPalette::cartographic(false);
let day = MapPalette::cartographic(true);
let mut w = crate::Way::from_lonlat_with(1, &[(18.0, 59.0), (18.01, 59.0)], &tags(&[("railway", "rail")]), &night)
.expect("a 2-vertex line is a way");
assert_eq!(w.stroke, night.rail());
day.recolor_way(&mut w);
assert_eq!(w.stroke, day.rail(), "a reskinned rail is still in the rail ink");
assert_ne!(w.stroke, day.road_generic, "…and not in `Other`'s");
}
/// **A MARKED ROUTE IS NOT STREET FURNITURE** (`railtier-1`, the trail half).
///
/// 19 664 of the 21 658 hiking rows in korp's Swedish cut carry `highway=path`, so the
/// `highway` arm tiered them [`Lod::Detail`] — camera 15, the rung argued for footpaths
/// in a city. MEASURED on the live client at camera 9.33: `trails` fed **6 719**, drew
/// **32**.
///
/// **THE RED.** Before the route arm existed, the four `drawn_at_his_camera` assertions
/// failed, reporting e.g. `a hiking path (sac_scale) is not drawn at camera 9.33 — tier
/// Detail, view tier 2`. Every one of them is asserted through `layer::lod_visible`, the
/// same DRAWN predicate the painter uses, so a build that stopped calling the ladder
/// cannot pass this by having the right tier name.
///
/// The rule ORDER is the other half of the test, and it is not cosmetic: `mtb:scale`
/// rides 100 % of the `mtb` class and 25 % of `hiking`, so asking it before the hiking
/// tags takes a quarter of the hiking network down to camera 12. The two
/// `hiking_beats_mtb_scale` cases pin that ordering.
#[test]
fn a_marked_route_is_drawn_before_the_footpath_rung() {
let pal = MapPalette::cartographic(true);
let sty = |kv: &[(&str, &str)]| classify_with(&tags(kv), &pal);
let drawn = |s: &WayStyle, zoom: f32| {
crate::layer::lod_visible(
s.lod,
&crate::layer::MapView::new(crate::layer::LatLon::new(59.33, 18.07), zoom, (900.0, 620.0)),
)
};
let floor = |s: &WayStyle| crate::layer::LOD_TIER_ZOOMS[s.lod as usize];
// The four rows a marked route really arrives as — tags off the real cut, never
// invented: a hiking path scored on the SAC scale, one with only its visibility
// tagged, a piste, and a cycling route carrying the relation's own `route`.
let sac = sty(&[("highway", "path"), ("sac_scale", "mountain_hiking")]);
let vis = sty(&[("highway", "path"), ("trail_visibility", "excellent")]);
let piste = sty(&[("piste:type", "nordic"), ("name", "Vasaloppsspåret")]);
let cycle = sty(&[("highway", "cycleway"), ("route", "bicycle")]);
for (what, s) in [("sac_scale", &sac), ("trail_visibility", &vis), ("piste:type", &piste), ("route", &cycle)] {
assert!(
drawn(s, 9.33),
"a trail row identified by {what} is not drawn at camera 9.33 — tier {:?}, \
view tier {}. korp serves 41.8 % of the trail file at this rung; the \
painter was drawing 0.5 % of what it was handed.",
s.lod,
crate::layer::view_lod_tier(9.33),
);
assert_eq!(floor(s), 9, "…and it rides korp's own second Trails rung (floor 9)");
}
// ── LOCAL DETAIL STAYS LOCAL, which is korp's third rung and the non-vacuity.
// 31 340 snowmobile ways and 12 724 mtb ways are the biggest classes in the file
// and korp itself calls them local detail; a rule that lifted everything would
// satisfy every assertion above.
let sled = sty(&[("snowmobile", "designated"), ("ice_road", "yes")]);
let mtb = sty(&[("highway", "path"), ("mtb:scale", "2")]);
assert!(!drawn(&sled, 9.33), "a snowmobile track must NOT draw at camera 9.33");
assert!(!drawn(&mtb, 9.33), "an mtb trail must NOT draw at camera 9.33");
assert_eq!(floor(&sled), 12, "snowmobile rides korp's third Trails rung (floor 12)");
assert_eq!(floor(&mtb), 12, "…as does mtb");
assert!(drawn(&sled, 12.0), "…and both arrive at 12, or the tier is a wall");
// ── ORDER: the hiking tags are asked BEFORE `mtb:scale`, because `mtb:scale` is on
// a quarter of the hiking rows (5 374 of 21 658). Reverse the two `if`s and these
// two fall.
let hiking_also_biked = sty(&[("highway", "path"), ("sac_scale", "hiking"), ("mtb:scale", "1")]);
assert_eq!(hiking_also_biked.lod, Lod::District, "sac_scale must win over mtb:scale");
let vis_also_biked = sty(&[("highway", "path"), ("trail_visibility", "good"), ("mtb:scale", "3")]);
assert_eq!(vis_also_biked.lod, Lod::District, "trail_visibility must win over mtb:scale");
// ── A ROUTE ALONG AN ARTERIAL IS STILL AN ARTERIAL. 343 rows in the trails cut
// are major roads carrying a route relation's tags; demoting them would move a
// primary road two tiers down and take it off the country view.
let primary_route = sty(&[("highway", "primary"), ("route", "bicycle"), ("ref", "E4")]);
let primary = sty(&[("highway", "primary")]);
assert_eq!(primary_route.lod, primary.lod, "a route along a primary must stay a primary");
assert_eq!(primary_route.color, primary.color, "…in road ink, not trail ink");
assert_eq!(floor(&primary), 0, "…and the primary tier itself has not moved");
// ── AND A PLAIN FOOTPATH IS UNTOUCHED. The arm must not have swallowed the rung
// it sits above: 721 of 75 694 trail rows match nothing and fall through here,
// and so does every footpath in a city.
let footway = sty(&[("highway", "footway")]);
assert_eq!(footway.lod, Lod::Detail, "a plain footway keeps the Detail rung");
assert!(!drawn(&footway, 9.33), "…and is still absent at camera 9.33");
}
/// **A FOREST ROAD YOU CAN DRIVE DOWN IS NOT A FOOTPATH.**
///
/// `highway=track` sat in the `footway | cycleway | path | pedestrian | steps` arm,
/// so it was painted in `pal.path` at the footpath's 0.7 width and classified
/// `WayKind::Path`. The arm's own comment justifies itself with `kalm-1` — *"the
/// entire footpath network of a city drew at zoom 12, where it is sub-pixel"* — which
/// is a RENDERING-DENSITY argument, and a good one, for footpaths. `track` was swept
/// in with them and never argued for on its own.
///
/// It is a vehicular road by OSM definition: agricultural and forestry roads you can
/// drive. korp's own histogram of the Sweden extract names it exactly that —
/// *"`track` (316 960 — forest and field roads)"* — and files it with `service` in
/// the minor VEHICULAR tail, in a tier whose comment says *"and nothing pedestrian"*.
///
/// So: road colour, road width, `WayKind::Road`, matching `service` because korp
/// treats them as the same class of thing and any difference here would be the same
/// unargued taste call that created the defect.
///
/// **THE TIER DOES NOT MOVE, and this is the half a future reader will get wrong.**
/// `Lod::Detail` stays. facett draws `track` from camera 15; korp sends it from 14.
/// The client drawing LATER than the server sends is the benign direction — one zoom
/// of bytes that arrive before anything paints them. The dangerous direction is the
/// opposite, a client wanting to draw what the server withholds, and that is not what
/// this is. The two ladders have different granularity BY DESIGN (five cartographic
/// tiers against korp's six wire floors), so exact alignment is neither possible nor
/// desirable; "helpfully" moving this tier to 14 to match korp would introduce a
/// mismatch across two ladders that are meant to be independent.
/// **A FERRY IS NOT A FOOTPATH.** `route=ferry` used to fall into the marked-route
/// arm below `has("route")` and be stroked in `pal.path` — the brown track ink —
/// from `Lod::District` (camera 9) upward, so an international ferry line was
/// painted across open sea as a long straight brown chord. The owner photographed it
/// over Skåne.
///
/// MEASURED on the served Swedish cut (`~/.korp/work/sweden/ways.parquet`,
/// 2026-09-02, 9 325 142 rows): **876** ways carry `route=ferry` or `ferry=*`, and
/// **772 of them sit at `min_zoom` 9**, so the server hands them over at exactly the
/// rung the trail arm drew them at. The longest is way **299847757**, *"København
/// (DK) – Oslo (N)"*, whose bbox spans **2.656° of latitude** (55.929 → 58.585) and
/// **2.037° of longitude** (10.656 → 12.693) — one straight line right across the
/// Kattegat, four hundred kilometres of brown track ink over water.
///
/// The tags below are that way's own, copied from the file.
#[test]
fn a_ferry_route_is_a_sea_route_not_a_footpath() {
let pal = MapPalette::cartographic(true);
let sty = |kv: &[(&str, &str)]| classify_with(&tags(kv), &pal);
// Way 299847757 exactly as the cut carries it.
let cruise = sty(&[
("duration", "17:30"),
("ferry", "cruiseferry"),
("name", "København (DK) – Oslo (N)"),
("network", "international"),
("route", "ferry"),
]);
// The other shapes the file really uses: `ferry` carrying a HIGHWAY CLASS as its
// value (this is why it reads as a road — `ferry=trunk` on 707334351, Travemünde
// – Helsinki), and a bare `route=ferry` with no `ferry` key (1139737055,
// Trelleborg – Karlshamn).
let trunk_ferry = sty(&[("ferry", "trunk"), ("route", "ferry"), ("name", "Travemünde (D) – Helsinki (FIN)")]);
let bare = sty(&[("route", "ferry"), ("name", "Trelleborg - Karlshamn")]);
// …and a ferry tagged only on the `ferry` key, no `route` at all.
let ferry_only = sty(&[("ferry", "yes")]);
for (what, s) in [
("route=ferry + ferry=cruiseferry", &cruise),
("ferry=trunk", &trunk_ferry),
("bare route=ferry", &bare),
("ferry=yes", &ferry_only),
] {
assert_ne!(
s.color, pal.path,
"{what} is painted in the FOOTPATH ink (#{:02X}{:02X}{:02X}) — that is the \
brown chord across the Kattegat",
pal.path.r(), pal.path.g(), pal.path.b(),
);
assert_eq!(s.color, pal.coast, "{what} is a sea route and takes the marine ink");
assert_eq!(s.kind, WayKind::Water, "{what} is water, not a path you can walk");
assert!(!s.is_area, "{what} is a LINE — a ferry route never closes into a filled ring");
assert_eq!(
s.lod,
Lod::City,
"{what} draws from the city rung. A ferry link is information when the \
operator is looking at a harbour; a 2.66°-long chord over a regional view \
is the clutter that was reported.",
);
}
// ── THE NON-VACUITY. A marked LAND route is untouched: it still takes the trail
// arm, the path ink and the District rung. A rule that demoted every `route` to
// water would satisfy every assertion above.
let hike = sty(&[("highway", "path"), ("route", "hiking"), ("sac_scale", "hiking")]);
assert_eq!(hike.color, pal.path, "a hiking route is still a path");
assert_eq!(hike.kind, WayKind::Path, "…and still a Path");
assert_eq!(hike.lod, Lod::District, "…on the trail rung it has always had");
}
/// **The painted-key roster is exactly the keys this crate reads** — the guard that
/// makes [`PAINTED_KEYS`] safe for a server to filter a wire against (korp lane
/// GG #19 row 2).
///
/// A stale roster does not fail loudly on a server: it silently stops sending a key,
/// and a building loses its height or a motorway its shield, on some cameras, for
/// some features. So the roster is scanned out of this crate's own source rather
/// than trusted, in BOTH directions.
///
/// **What counts as a tag read**, and why the rule is written this way:
///
/// * `tag(tags, "…")` / `has_tag(tags, "…")` — the two door functions, unambiguous;
/// * `get("…")` / `has("…")` — the closures every painter opens with
/// (`let get = |k: &str| crate::tag(tags, k);`). A leading `.` is REQUIRED to be
/// absent, because `obj.get("pan")` on a `serde_json::Value` is not a tag read and
/// this file is full of them.
///
/// Test code is cut at the first `#[cfg(test)]` and comment lines are dropped — a
/// key named in prose is not a key the crate reads, and the arms here quote plenty.
#[test]
fn the_painted_key_roster_is_every_key_this_crate_reads() {
// Every module of this crate that could read a tag. Named explicitly: a glob
// would silently stop covering a module added later, which is the same failure
// this test exists to prevent.
const SOURCES: [(&str, &str); 10] = [
("lib.rs", include_str!("lib.rs")),
("style.rs", include_str!("style.rs")),
("height.rs", include_str!("height.rs")),
("layerset.rs", include_str!("layerset.rs")),
("paint_order.rs", include_str!("paint_order.rs")),
("layer.rs", include_str!("layer.rs")),
("index.rs", include_str!("index.rs")),
("project.rs", include_str!("project.rs")),
("tessellate.rs", include_str!("tessellate.rs")),
("l0_points.rs", include_str!("l0_points.rs")),
];
/// Collect the tag-key literals of one source file.
fn keys_of(src: &str) -> Vec<String> {
// Cut the tests off: a fixture's `("highway", "motorway")` is not a read,
// and neither is a roster assertion.
let code = src.split("#[cfg(test)]").next().unwrap_or(src);
let code: String = code
.lines()
.filter(|l| !l.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n");
let b = code.as_bytes();
let mut out = Vec::new();
// `opener` is the text before the quoted key; `needs_free_start` marks the
// forms where a preceding `.` would mean something else entirely.
for (opener, needs_free_start) in [
("tag(tags, \"", false),
("has_tag(tags, \"", false),
("get(\"", true),
("has(\"", true),
] {
let mut at = 0usize;
while let Some(i) = code[at..].find(opener) {
let start = at + i;
let from = start + opener.len();
let Some(len) = code[from..].find('"') else { break };
let key = &code[from..from + len];
// `.get("pan")` on a serde_json Value is not a tag read.
let free = !needs_free_start
|| start == 0
|| !matches!(b[start - 1], b'.' | b'_' | b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9');
// A key is lowercase ASCII with `:` and `_`; anything else is some
// other `get`.
let plausible = !key.is_empty()
&& key.bytes().all(|c| c.is_ascii_lowercase() || matches!(c, b':' | b'_'));
if free && plausible {
out.push(key.to_string());
}
at = from + len;
}
}
out
}
let mut read: Vec<String> = SOURCES.iter().flat_map(|(_, s)| keys_of(s)).collect();
read.sort();
read.dedup();
// NON-VACUITY: the scan must have reached real bodies and found the keys nobody
// would ever delete. Without this the test passes on an empty scan.
assert!(
read.len() >= 30,
"the scan found only {} tag keys across the crate: {read:?} — it is not \
reading the painters",
read.len(),
);
for must in ["highway", "name", "place", "layer", "height", "amenity", "ref"] {
assert!(
read.iter().any(|k| k == must),
"the scan missed `{must}`, which is read in plain sight — the scan rule \
is broken, and every conclusion below it is worthless",
);
}
// Every CLASSIFIED key must be a PAINTED key: the classifier is one of the
// painters, so a key that decides a class the wider roster does not name would
// let a filter delete the class itself.
for k in CLASSIFIED_KEYS {
assert!(PAINTED_KEYS.contains(&k), "`{k}` classifies but is not in PAINTED_KEYS");
}
for (k, _) in CLASSIFIED_PAIRS {
assert!(PAINTED_KEYS.contains(&k), "`{k}` classifies by value but is not in PAINTED_KEYS");
}
let missing: Vec<&String> = read.iter().filter(|k| !PAINTED_KEYS.contains(&k.as_str())).collect();
assert!(
missing.is_empty(),
"these keys are READ by a painter and are NOT in PAINTED_KEYS: {missing:?} — a \
server filtering its wire against this roster would silently stop sending \
them, and the feature would lose that ink with nothing going red",
);
let spare: Vec<&&str> = PAINTED_KEYS.iter().filter(|k| !read.iter().any(|r| r == *k)).collect();
assert!(
spare.is_empty(),
"PAINTED_KEYS names keys no painter reads: {spare:?} — a roster with dead \
entries costs wire bytes for nothing and hides which reads are real",
);
// The roster is searched by `contains` on every tag of every way of every reply,
// so keep it sorted: it is the property that makes a future binary search or a
// human diff trustworthy, and a duplicate would be invisible otherwise.
let mut sorted = PAINTED_KEYS.to_vec();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.as_slice(), &PAINTED_KEYS[..], "PAINTED_KEYS must be sorted and unique");
}
/// ★ **RED-when-broken: the [`CLASSIFIED_KEYS`] roster IS the keys
/// [`classify_with`] reads.**
///
/// A hand-written allowlist beside a 450-line match is a second reader waiting to
/// drift, and the drift is silent in the dangerous direction: a new arm that reads a
/// key missing from the roster makes [`has_nothing_to_draw`] refuse a way the
/// classifier would have painted. So the roster is checked against the FUNCTION TEXT,
/// not against a copy of itself (the identity-value trap LAW 2 names).
///
/// **RED ARM** (proved before this was trusted): drop `"waterway"` from
/// `CLASSIFIED_KEYS` and this fails with
/// `classify_with reads keys the roster does not name: ["waterway"]`. Put it back:
/// green. Adding a key the function never reads fails the other assertion.
#[test]
fn the_classified_key_roster_is_the_keys_classify_with_reads() {
let src = include_str!("style.rs");
let body_start = src.find("pub fn classify_with<K: AsRef<str>, V: AsRef<str>>").expect(
"classify_with must be findable in this file's own text — if it was renamed, \
this guard is scanning nothing and its silence proves nothing",
);
// The function ends where the generic fallback's closing brace does; the roster
// const is declared immediately after it, so stop there.
let body_end = body_start
+ src[body_start..].find("pub const CLASSIFIED_KEYS").expect(
"CLASSIFIED_KEYS must follow classify_with in this file",
);
// CODE ONLY — the arms carry long prose that quotes tag keys, and a key named in
// a comment is not a key the function reads.
let body: String = src[body_start..body_end]
.lines()
.filter(|l| !l.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n");
let body = body.as_str();
// Every `get("…")` / `has("…")` literal in the body, in source order.
let mut read: Vec<String> = Vec::new();
for opener in ["get(\"", "has(\""] {
let mut at = 0usize;
while let Some(i) = body[at..].find(opener) {
let from = at + i + opener.len();
let Some(len) = body[from..].find('"') else { break };
read.push(body[from..from + len].to_string());
at = from + len;
}
}
read.sort();
read.dedup();
// NON-VACUITY, three ways: the scan reached a real body, it found a plausible
// number of keys, and it found the ones nobody would ever delete.
assert!(
body.len() > 3_000,
"the scan captured only {} bytes of classify_with — it is not looking at the \
function",
body.len(),
);
assert!(read.len() >= 12, "found only {} tag keys in classify_with: {read:?}", read.len());
for must in ["highway", "natural", "building", "railway"] {
assert!(read.iter().any(|k| k == must), "the scan missed `{must}` — it is not reading the arms");
}
// A key is admitted EITHER wholesale (`CLASSIFIED_KEYS`) or value by value
// (`CLASSIFIED_PAIRS`) — never both, or the pair roster would be decoration
// while the key roster silently let every value through (lane TL).
let pair_keys: Vec<&str> = {
let mut v: Vec<&str> = CLASSIFIED_PAIRS.iter().map(|(k, _)| *k).collect();
v.sort();
v.dedup();
v
};
for k in &pair_keys {
assert!(
!CLASSIFIED_KEYS.contains(k),
"`{k}` is in BOTH rosters — the key roster admits every value under it, so \
the pair roster is doing nothing and `{k}=<anything>` reaches the generic \
hairline",
);
}
let missing: Vec<&String> = read
.iter()
.filter(|k| !CLASSIFIED_KEYS.contains(&k.as_str()) && !pair_keys.contains(&k.as_str()))
.collect();
assert!(
missing.is_empty(),
"classify_with reads keys neither roster names: {missing:?} — \
`has_nothing_to_draw` would refuse a way the classifier can paint",
);
let spare: Vec<&&str> = CLASSIFIED_KEYS.iter().filter(|k| !read.iter().any(|r| r == *k)).collect();
assert!(spare.is_empty(), "the roster names keys classify_with never reads: {spare:?}");
let spare_pairs: Vec<&&str> = pair_keys.iter().filter(|k| !read.iter().any(|r| r == **k)).collect();
assert!(spare_pairs.is_empty(), "CLASSIFIED_PAIRS names keys classify_with never reads: {spare_pairs:?}");
// Every pair's VALUE must appear as a literal in the arm that reads its key, or
// the pair roster admits a way the classifier then drops to the generic hairline
// — the exact defect the pair roster exists to prevent.
for (k, v) in CLASSIFIED_PAIRS {
assert!(
body.contains(&format!("Some(\"{v}\")")),
"`{k}={v}` is admitted by CLASSIFIED_PAIRS but `{v}` is not matched anywhere in \
classify_with — it would fall to the generic `road_generic` hairline",
);
}
}
/// ★ **RED-when-broken: a way whose tags say NOTHING is not a road.**
///
/// The E sea-ink defect, from the cut that produced it. Each `tags` block below is
/// copied from a way in `~/.korp/work/sweden/ways.parquet` whose DRAWN LINE crosses
/// the open water of the z14 Stockholm frame (`sweep_oracles.py` oracle E,
/// `RED(4129)` on the 2026-09-03 live-cut sweep), stroked `road_generic` #DED8CB.
///
/// **RED ARM**: revert either `has_nothing_to_draw` or the four constructor gates and
/// the `is_none()` assertions fail — every one of these becomes a hairline road again.
/// The second half is the non-vacuity: a way that carries a key the classifier reads
/// still builds, so a gate that refused everything would not pass.
#[test]
fn a_way_with_nothing_to_draw_is_not_a_road() {
let pal = MapPalette::cartographic(true);
let line = [(18.05, 59.32), (18.07, 59.33)];
// The real sea-crossers, by way id.
let refused: [(&str, &[(&str, &str)]); 5] = [
("382768363 — a boundary relation's untagged member way", &[]),
("382768356 — kommungräns in Riddarfjärden", &[("source", "historical"), ("source_ref", "ekonomiska kartan")]),
("382769100 — the maritime boundary segment", &[("maritime", "yes")]),
("1207835868 — a submarine pipeline", &[("seamark:type", "pipeline_submarine")]),
("512933635 — a submarine power cable", &[("seamark:cable_submarine:category", "power"), ("seamark:type", "cable_submarine")]),
];
for (what, kv) in refused {
assert!(
has_nothing_to_draw(&tags(kv)),
"{what}: these tags name nothing classify_with reads",
);
assert!(
crate::Way::from_lonlat_with(1, &line, &tags(kv), &pal).is_none(),
"{what} still built a Way — it will be stroked #{:02X}{:02X}{:02X} across \
the water",
pal.road_generic.r(), pal.road_generic.g(), pal.road_generic.b(),
);
assert!(
crate::Way::from_lonlat_owned_with(1, line.to_vec(), &tags(kv), &pal).is_err(),
"{what} still built a Way through the MOVING door",
);
}
// ── NON-VACUITY. Everything the map is actually made of still builds, including
// the classes that reach the generic arm through a key that IS read
// (`railway=*` has no WayKind of its own and korp re-inks it from the network
// column — refusing it here would delete the rail network).
let kept: [(&str, &[(&str, &str)]); 6] = [
("a street", &[("highway", "residential")]),
("a rail way", &[("railway", "rail")]),
("a building", &[("building", "yes")]),
("a lake", &[("natural", "water")]),
("a coastline", &[("natural", "coastline")]),
("a ferry route", &[("route", "ferry")]),
];
for (what, kv) in kept {
assert!(!has_nothing_to_draw(&tags(kv)), "{what} has something to draw");
assert!(
crate::Way::from_lonlat_with(1, &line, &tags(kv), &pal).is_some(),
"{what} must still build — the gate is about tags that say nothing, not \
about tags the arms do not MATCH",
);
}
}
/// ★ **RED-when-broken: the three classes lane SI named and did not decide.**
///
/// SI's report: `man_made` / `place` / `boundary` "now draw nothing… each deserves
/// its own arm and ink; inventing one silently would be a taste call". This is that
/// call, made with frames (korp `tests/map_render_truth.rs`,
/// `the_three_undecided_classes_get_their_verdicts_in_pixels`), and this guard holds
/// it:
///
/// | class | verdict | why |
/// |---|---|---|
/// | `boundary=*` | **correctly nothing** | it IS the defect — the kommungräns through Riddarfjärden. And facett already draws administrative units, from `facett-geomap`'s `sweden_admin`; a second boundary painter in the way path would be a twin (LAW 5) fed by member geometry the relation path already owns. |
/// | `place=*` | **correctly nothing** | `place` on a way is a LABEL anchor, not a stroke. `place=islet` has no outline anyone wants; it has a name. The label/POI path (`LayerCategory::from_osm_tags`, `facett_osm::read_pois`) is where a place belongs and it already reads the key. |
/// | `man_made=pier` | **its own ink** | ground you stand on. See the arm in [`classify_with`] and [`CLASSIFIED_PAIRS`]. |
///
/// And the split INSIDE `man_made` is the whole reason it needed a pair roster:
/// `pier` draws, `pipeline` does not, and two of lane SI's five measured sea-crossing
/// chords were `seamark`/pipeline geometry.
///
/// **RED ARM** (all seen 2026-09-03): give `man_made` a key-level admission (move it
/// into [`CLASSIFIED_KEYS`]) and the `pipeline` assertion fails — it becomes a
/// `road_generic` hairline again. Delete the `man_made` arm from [`classify_with`]
/// and the pier assertions fail. Add `boundary` or `place` to either roster and the
/// first two rows fail.
#[test]
fn the_three_undecided_classes_are_decided() {
let pal = MapPalette::cartographic(true);
let line = [(18.05, 59.32), (18.07, 59.33)];
// ── boundary + place: correctly NOTHING. ─────────────────────────────────
let nothing: [(&str, &[(&str, &str)]); 4] = [
("boundary=administrative (the kommungräns SI measured)", &[("boundary", "administrative")]),
("boundary=maritime", &[("boundary", "maritime")]),
("place=islet", &[("place", "islet")]),
("place=locality", &[("place", "locality")]),
];
for (what, kv) in nothing {
assert!(has_nothing_to_draw(&tags(kv)), "{what} draws nothing in the WAY path");
assert!(
crate::Way::from_lonlat_with(1, &line, &tags(kv), &pal).is_none(),
"{what} still built a Way — it would stroke #{:02X}{:02X}{:02X} across the water",
pal.road_generic.r(), pal.road_generic.g(), pal.road_generic.b(),
);
}
// ── man_made: the values that are GROUND draw, in the paper ink. ──────────
for v in ["pier", "breakwater", "groyne", "embankment", "causeway"] {
let kv = [("man_made", v)];
assert!(!has_nothing_to_draw(&tags(&kv)), "man_made={v} is ground and draws");
let st = classify_with(&tags(&kv), &pal);
assert_eq!(
st.color, pal.land,
"man_made={v} is land pushed into water and is inked as the paper, so it \
reads as a finger off the quay and vanishes over land — got \
#{:02X}{:02X}{:02X}",
st.color.r(), st.color.g(), st.color.b(),
);
assert_eq!(st.lod, Lod::City, "…at the rung a metres-wide structure is seen from");
assert_eq!(st.kind, WayKind::Path, "…and not a Road, or korp's network re-ink would claim it");
assert!(crate::Way::from_lonlat_with(1, &line, &tags(&kv), &pal).is_some(), "man_made={v} builds");
}
// ── man_made: the values that are NOT on the surface still draw NOTHING. ──
// The other half of the sea chords. THIS is why the admission had to be
// value-level: a key-level one puts every one of these back on the water.
for v in ["pipeline", "cable", "storage_tank", "silo", "surveillance"] {
let kv = [("man_made", v)];
assert!(
has_nothing_to_draw(&tags(&kv)),
"man_made={v} is not ground — a submarine pipeline was one of the five \
chords lane SI measured across Riddarfjärden, and it must stay refused",
);
assert!(crate::Way::from_lonlat_with(1, &line, &tags(&kv), &pal).is_none(), "man_made={v} is refused");
}
}
/// ★ **RED-when-broken: the overprint ink is legible on the ground and is not
/// mistakable for any basemap ink.**
///
/// [`untagged_source_style`] exists because a tagless SOURCE must draw and must not
/// draw as `road_generic` — the ink lane SI took off the water. Two DIFFERENT
/// questions, and the first version of this guard used one metric for both and was
/// wrong for it:
///
/// * **legible ON the ground** (paper, water, green) is a LUMINANCE question, and
/// WCAG [`contrast_ratio`] is the tool. Held `>= 3.0`. The number to keep in view:
/// `road_generic` against day paper is **1.24** — the ink lane SI removed was
/// never visible on land at all; it only ever showed up because it was being drawn
/// across blue.
/// * **tellable apart from another INK** is a HUE question, and contrast ratio
/// cannot answer it: the magenta `#B5197A` and the motorway orange `#E9A23B` sit
/// at `2.86:1` because they have nearly the same luminance, while being about as
/// confusable as two colours get, i.e. not at all. So per-channel distance, held
/// `>= 120` of a possible 765. MEASURED: the nearest day ink is `path` `#B99873`
/// at **138**, the nearest night ink is `road_secondary` `#8A9CB2` at **200**.
///
/// **RED ARM** (both seen 2026-09-03): set `OVERPRINT_DAY` to `road_generic`'s
/// `#DED8CB` and the distance row fails at `0`; set it to the building fill
/// `#E2DBCF` and the paper row fails at `1.13:1`.
#[test]
fn the_overprint_ink_is_nobody_elses_ink() {
/// Per-channel distance, `0..=765`. A luminance ratio cannot see hue, and
/// "which lines came out of my file?" is a hue question.
fn channel_distance(a: Color32, b: Color32) -> i32 {
let d = |x: u8, y: u8| (i32::from(x) - i32::from(y)).abs();
d(a.r(), b.r()) + d(a.g(), b.g()) + d(a.b(), b.b())
}
for day in [true, false] {
let pal = MapPalette::cartographic(day);
let st = untagged_source_style(&pal);
assert_eq!(st.color, pal.overlay, "the overlay style paints the overlay ink");
assert_eq!(st.lod, Lod::Region, "a national overlay is looked at from a national camera");
assert!(st.width > 1.0, "an operator-loaded layer is a layer, not a hairline: {}", st.width);
assert!(!st.is_area && st.fill.is_none(), "an overprint is strokes; a fill would hide the base sheet");
// LEGIBILITY — against the three GROUNDS an overlay is drawn on top of.
for (what, c) in [("the paper", pal.land), ("water", pal.water), ("green", pal.green)] {
let r = contrast_ratio(pal.overlay, c);
assert!(
r >= 3.0,
"day={day}: the overprint #{:02X}{:02X}{:02X} is only {r:.2}:1 against \
{what} #{:02X}{:02X}{:02X} — road_generic against day paper is 1.24:1 \
and that ink was invisible, which is half of how this defect hid",
pal.overlay.r(), pal.overlay.g(), pal.overlay.b(), c.r(), c.g(), c.b(),
);
}
// DISTINGUISHABILITY — against EVERY other ink in the palette, named, so a
// future ink added near the overprint fails here rather than on a screen.
let inks: [(&str, Color32); 12] = [
("land", pal.land),
("water", pal.water),
("green", pal.green),
("building", pal.building),
("coast", pal.coast),
("road_motorway", pal.road_motorway),
("road_primary", pal.road_primary),
("road_secondary", pal.road_secondary),
("road_tertiary", pal.road_tertiary),
("road_residential", pal.road_residential),
("path", pal.path),
("road_generic (the ink lane SI removed)", pal.road_generic),
];
for (what, c) in inks {
let d = channel_distance(pal.overlay, c);
assert!(
d >= 120,
"day={day}: the overprint #{:02X}{:02X}{:02X} is only {d} of 765 from \
{what} #{:02X}{:02X}{:02X} — an overlay you cannot tell from the \
basemap answers no question",
pal.overlay.r(), pal.overlay.g(), pal.overlay.b(), c.r(), c.g(), c.b(),
);
}
}
// ── THE RESKIN. A palette swap must not repaint the operator's overlay.
//
// FOUND BY `facett-demo`'s deck guard, 2026-09-03: the overprint style was
// `WayKind::Other` at first, `recolor_way` maps `Other` to `road_generic`, and
// `OsmView::cartographic(true)` calls `recolor` on the way in — so a correctly
// read overlay was repainted into the exact ink lane SI took off the water,
// before anything drew. `WayKind::Overprint` is what makes the ink survive.
let ring = [(9.50, 47.10), (9.52, 47.10), (9.52, 47.12)];
let day = MapPalette::cartographic(true);
let night = MapPalette::cartographic(false);
let mut w = crate::Way::from_lonlat_owned_styled(1, ring.to_vec(), crate::NO_TAGS, untagged_source_style(&day))
.expect("the non-classifying door builds it");
assert_eq!(w.stroke, day.overlay);
night.recolor_way(&mut w);
assert_eq!(
w.stroke, night.overlay,
"a palette swap must carry the overprint to the NEW palette's overprint, not \
to #{:02X}{:02X}{:02X} (road_generic)",
night.road_generic.r(), night.road_generic.g(), night.road_generic.b(),
);
assert_ne!(w.stroke, night.road_generic, "…and above all NEVER to road_generic");
// …and the theme-derived palette carries it too, so a themed host is not left
// with an overlay that is one of its own road tiers.
let th = MapPalette::from_theme(&Theme::default());
for (what, c) in [
("road_generic", th.road_generic),
("road_motorway", th.road_motorway),
("road_secondary", th.road_secondary),
("land", th.land),
] {
assert!(
channel_distance(th.overlay, c) >= 120,
"the THEMED overlay ink #{:02X}{:02X}{:02X} is not tellable from {what} \
#{:02X}{:02X}{:02X}",
th.overlay.r(), th.overlay.g(), th.overlay.b(), c.r(), c.g(), c.b(),
);
}
}
#[test]
fn a_track_is_a_vehicular_road_not_a_footpath() {
let pal = MapPalette::cartographic(true);
let sty = |kv: &[(&str, &str)]| classify_with(&tags(kv), &pal);
let track = sty(&[("highway", "track")]);
let service = sty(&[("highway", "service")]);
let footway = sty(&[("highway", "footway")]);
// ── IT IS A ROAD, and specifically the same kind of road `service` is.
assert_eq!(track.kind, WayKind::Road, "a track is a road you can drive");
assert_eq!(track.color, service.color, "a track paints in road ink, like `service`");
assert_eq!(track.width, service.width, "…at road width, like `service`");
// …and since `railtier-2`, on the same TIER as well. The two are korp's own z14
// rung ("the minor VEHICULAR tail") and were identical in ink, width and kind
// while differing only in tier — `service` at City, `track` at Detail — which was
// the inconsistency rather than the design. `service` promised two zooms korp
// does not serve; it joined `track` here instead.
assert_eq!(track.lod, service.lod, "…and on the same rung: one korp tier, one tier here");
// ── AND IT IS NOT A FOOTPATH. Stated against the arm it came out of, so the
// revert is what fails rather than something downstream of it.
assert_ne!(track.kind, footway.kind, "a track must not classify as WayKind::Path");
assert_ne!(track.color, footway.color, "a track must not paint in the footpath ink");
assert_ne!(track.width, footway.width, "…nor at the footpath's width");
// ── AND THE TIER DID NOT MOVE. The fix is a classification change, not a
// ladder change; see the doc above for why 15 and not korp's 14.
assert_eq!(track.lod, Lod::Detail, "the tier stays at Detail — facett draws from 15");
assert_eq!(footway.lod, Lod::Detail, "…the same tier the footpath keeps");
assert_eq!(
crate::layer::LOD_TIER_ZOOMS[Lod::Detail as usize],
15,
"…which is camera 15, one zoom after korp starts sending tracks at 14"
);
}
/// The label classifier extracts a `ref`/`name` and tiers its LOD so labels
/// declutter by zoom (country places far out, street names only zoomed in) —
/// the text half of the Garmin-feel lever. RED if a tier ever collapses or a
/// `ref` stops winning over a co-tagged `name`.
#[test]
fn classify_label_extracts_name_ref_and_tiers_lod() {
// Settlement ranks, one per rung of LOD_TIER_ZOOMS = [0, 6, 9, 12, 15]:
// country → Country(0) · city/town → Region(6) · village → District(9)
// · hamlet and the rest → City(12)
// The District line CHANGED in `label-district-1` and this assertion changed with
// it deliberately: no settlement class used to resolve to District at all, so the
// ladder skipped from 6 straight to 12 and named nothing new across six zoom
// levels — measured on the live client as 5 319 labels fed and 1 drawn at camera
// 11.3, against a pane budget of ~65. `classify_label`'s own doc carries the
// density measurement that decides which class earns the rung.
let country = classify_label(&tags(&[("place", "country"), ("name", "Sverige")])).unwrap();
assert_eq!(country, LabelClass { text: "Sverige".into(), lod: Lod::Country, rank: 1.0 });
// `city` is COUNTRY tier, not Region (`label-city-country-1`): there are six
// named `place=city` in the whole 2026-08-31 Sweden cut against one
// `place=country`, and a whole-country pane that opened no rung but Country drew
// no names at all (measured live at camera 5.6: 15 797 fed / 0 drawn).
let city = classify_label(&tags(&[("place", "city"), ("name", "Stockholm")])).unwrap();
assert_eq!(city.lod, Lod::Country, "a city carries the country view");
// `town` is the class Region is FOR — 67 of them nationally.
let town = classify_label(&tags(&[("place", "town"), ("name", "Sigtuna")])).unwrap();
assert_eq!(town.lod, Lod::Region, "a town shows at the regional tier");
let village = classify_label(&tags(&[("place", "village"), ("name", "Grönhögen")])).unwrap();
assert_eq!(
village.lod,
Lod::District,
"a village stands on the District rung — 996 named villages in the whole \
country is regional density"
);
// The OTHER side of the same decision, asserted here so the two cannot drift: the
// dense classes stay at City. 14 348 named hamlets and 25 221 isolated dwellings
// against those 996 villages, measured on the 2026-08-31 Sweden cut.
let hamlet = classify_label(&tags(&[("place", "hamlet"), ("name", "Vreta")])).unwrap();
assert_eq!(hamlet.lod, Lod::City, "a hamlet is not a regional fact");
let dwelling =
classify_label(&tags(&[("place", "isolated_dwelling"), ("name", "Norrgården")])).unwrap();
assert_eq!(dwelling.lod, Lod::City, "nor is a farmstead");
assert!(
village.rank > hamlet.rank && city.rank > village.rank,
"the ranks must order the rungs too — city {} > village {} > hamlet {}",
city.rank,
village.rank,
hamlet.rank,
);
// A road ref (shield) wins over its street name and shows regionally.
let ref_road = classify_label(&tags(&[("highway", "motorway"), ("ref", "E4"), ("name", "Essingeleden")])).unwrap();
assert_eq!(ref_road, LabelClass { text: "E4".into(), lod: Lod::Region, rank: 0.95 }, "the E4 shield wins over the street name");
// A named street with no ref → its name at city LOD.
let street = classify_label(&tags(&[("highway", "residential"), ("name", "Drottninggatan")])).unwrap();
assert_eq!(street, LabelClass { text: "Drottninggatan".into(), lod: Lod::City, rank: label_rank_of_style(&classify_with(&tags(&[("highway", "residential")]), &MapPalette::cartographic(true))) });
// A named lake → its name, zoomed-in only. Unnamed / untagged → no label.
let lake = classify_label(&tags(&[("natural", "water"), ("name", "Mälaren")])).unwrap();
assert_eq!(lake, LabelClass { text: "Mälaren".into(), lod: Lod::City, rank: 0.55 });
assert!(classify_label(&tags(&[("highway", "service")])).is_none(), "an unnamed road has no label");
assert!(classify_label(&tags(&[("building", "yes")])).is_none(), "an untagged building has no label");
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-map",
"classify_label",
country.lod == Lod::Country && city.lod == Lod::Region && ref_road.text == "E4",
"label classifier extracts name/ref and tiers country→region→city LOD",
);
}
#[test]
fn water_and_landuse_are_areas_with_fill_roads_are_lines() {
let th = Theme::default();
let water = classify(&tags(&[("natural", "water")]), &th);
assert!(water.is_area, "natural=water is an area");
assert!(water.fill.is_some(), "an area carries a fill colour");
assert_eq!(water.kind, WayKind::Water);
let park = classify(&tags(&[("leisure", "park")]), &th);
assert!(park.is_area && park.fill.is_some(), "leisure=park is a filled area");
assert_eq!(park.kind, WayKind::Park);
let landuse = classify(&tags(&[("landuse", "forest")]), &th);
assert!(landuse.is_area && landuse.fill.is_some(), "landuse=forest is a filled area");
let building = classify(&tags(&[("building", "yes")]), &th);
assert!(building.is_area && building.fill.is_some(), "a building is a filled area");
assert_eq!(building.kind, WayKind::Building);
// Roads + coastline are LINES (stroke only, no fill).
let road = classify(&tags(&[("highway", "motorway")]), &th);
assert!(!road.is_area && road.fill.is_none(), "a road is a line, never filled");
assert_eq!(road.kind, WayKind::Road);
let coast = classify(&tags(&[("natural", "coastline")]), &th);
assert!(!coast.is_area && coast.fill.is_none(), "the coastline is a line");
assert_eq!(coast.kind, WayKind::Coastline);
}
#[test]
fn road_class_drives_width_so_motorways_are_thicker_than_residential() {
let th = Theme::default();
let motorway = classify(&tags(&[("highway", "motorway")]), &th);
let primary = classify(&tags(&[("highway", "primary")]), &th);
let residential = classify(&tags(&[("highway", "residential")]), &th);
assert!(motorway.width > primary.width, "motorway wider than primary");
assert!(primary.width > residential.width, "primary wider than residential");
assert_eq!(motorway.color, th.accent, "motorways ride the warm accent");
}
#[test]
fn map_palette_distinguishes_water_green_land() {
let pal = MapPalette::from_theme(&Theme::default());
assert_ne!(pal.water, pal.green, "water and green fills differ");
assert_ne!(pal.land, pal.water, "land background differs from water");
// water is bluer than it is green; green is greener than it is blue.
assert!(pal.water.b() > pal.water.g(), "water leans blue");
assert!(pal.green.g() >= pal.green.b(), "green leans green");
}
// ── cartographic preset (the beautify delta) ────────────────────────────
/// The palette-swappable classifier reproduces the themed `classify` exactly
/// when fed `from_theme` — the refactor is a pure hoist, no visual drift
/// (red if a road/area colour or width ever diverges).
#[test]
fn classify_with_from_theme_matches_classify_byte_for_byte() {
let th = Theme::default();
let pal = MapPalette::from_theme(&th);
for kv in [
vec![("highway", "motorway")],
vec![("highway", "primary")],
vec![("highway", "secondary")],
vec![("highway", "tertiary")],
vec![("highway", "residential")],
vec![("highway", "footway")],
vec![("highway", "raceway")], // the `_` generic arm
vec![("natural", "water")],
vec![("waterway", "riverbank")],
vec![("leisure", "park")],
vec![("landuse", "forest")],
vec![("building", "yes")],
vec![("natural", "coastline")],
vec![("boundary", "administrative")], // untagged-for-us generic line
] {
let a = classify(&tags(&kv), &th);
let b = classify_with(&tags(&kv), &pal);
assert_eq!(a.color, b.color, "stroke colour matches for {kv:?}");
assert_eq!(a.fill, b.fill, "fill matches for {kv:?}");
assert_eq!(a.width, b.width, "width matches for {kv:?}");
assert_eq!(a.kind, b.kind, "kind matches for {kv:?}");
assert_eq!(a.is_area, b.is_area, "is_area matches for {kv:?}");
}
}
/// The cartographic preset is concrete hex (independent of the theme) and
/// distinguishes day from night + water/green/land — red if a preset ever
/// collapses two feature classes to the same colour.
#[test]
fn cartographic_preset_is_concrete_and_day_differs_from_night() {
let day = MapPalette::cartographic(true);
let night = MapPalette::cartographic(false);
// A theme swap must NOT move the cartographic colours.
assert_eq!(day.water, MapPalette::cartographic(true).water, "cartographic is a fixed preset");
// Day is a light paper; night is a dark basemap.
let sum = |c: Color32| c.r() as u16 + c.g() as u16 + c.b() as u16;
assert!(sum(day.land) > 600, "day land is light");
assert!(sum(night.land) < 150, "night land is dark");
// Feature classes stay distinct in both.
for p in [day, night] {
assert_ne!(p.water, p.green, "water ≠ green");
assert_ne!(p.land, p.water, "land ≠ water");
assert_ne!(p.road_motorway, p.road_residential, "top road tier ≠ minor");
assert!(p.water.b() > p.water.g(), "water leans blue");
}
// Road tiers ramp from a hot/bright top to a recessed minor.
assert_ne!(day.road_motorway, night.road_motorway, "day ≠ night motorway hue");
}
/// The road-class rank ramps monotonically with the classifier's widths, so a
/// motorway ranks above a primary above a residential — the z-order the
/// Garmin-nav painter sorts on. Red if a tier's width threshold ever drifts out
/// of step with [`road_rank`].
#[test]
fn road_rank_ramps_with_class_width() {
let th = Theme::default();
let r = |hw: &str| road_rank(classify(&tags(&[("highway", hw)]), &th).width);
assert!(r("motorway") > r("primary"), "motorway ranks above primary");
assert!(r("primary") > r("secondary"), "primary above secondary");
assert!(r("secondary") > r("tertiary"), "secondary above tertiary");
assert!(r("tertiary") > r("residential"), "tertiary above residential");
assert_eq!(r("motorway"), 4, "motorway is the top rank");
assert_eq!(r("residential"), 0, "residential is the bottom rank");
}
/// `recolor_way` swaps an already-built way to a new palette keyed on kind +
/// width, keeping a filled ring filled and a line unfilled — red if a
/// runtime palette switch ever loses a fill or mis-hues a road tier.
#[test]
fn recolor_way_reskins_by_kind_and_keeps_fill_state() {
use crate::Way;
let th = Theme::default();
// A filled water ring.
let ring = [(9.50, 47.10), (9.52, 47.10), (9.52, 47.12), (9.50, 47.12), (9.50, 47.10)];
let mut water = Way::from_lonlat(1, &ring, &tags(&[("natural", "water")]), &th).unwrap();
assert!(water.fill.is_some(), "water ring starts filled");
// A motorway line.
let mut motorway =
Way::from_lonlat(2, &[(9.50, 47.10), (9.60, 47.20)], &tags(&[("highway", "motorway")]), &th).unwrap();
let pal = MapPalette::cartographic(false);
pal.recolor_way(&mut water);
pal.recolor_way(&mut motorway);
assert_eq!(water.fill, Some(pal.water), "water recoloured to the preset water, still filled");
assert!(motorway.fill.is_none(), "the motorway line stays unfilled");
assert_eq!(motorway.stroke, pal.road_motorway, "the widest road takes the top tier hue");
}
}
/// **THE CROSS-REPO TAXONOMY GUARD — `facett` vs `osm-katana`, on real Swedish tags.**
///
/// # The defect this exists for
///
/// `osm_katana::shared::class::RULES` is an **arm-for-arm transcription** of the two
/// functions above — `classify_with` for the geometry classes, [`classify_label`] for
/// the `place=*` settlement backbone. Its own module doc says so. But *a transcription
/// is not a construction*: nothing makes the two texts stay in step, they live in two
/// repos, and the drift is silent in both directions — facett paints a feature at a rung
/// the server does not send it at, or the server sends a rung facett will not paint.
///
/// This constellation has paid for that shape twice already. `facett-geomap`'s
/// `osm2d.rs` was a complete SECOND 2D OSM painter over the same `Way` type, with its
/// own band ladder, and it took days to find. korp `b937078` fixed a bug whose entire
/// cause was points and ways going through different tag → class maps: a sub-pixel lake
/// became an off-by-default grey dot in a layer that was off by default.
///
/// **The by-construction fix is out of this guard's scope** — lift the kind+tier half of
/// `classify_with` into `edda` and have both sides call the one function. That spans
/// three repos and a crates.io release cascade and is recorded as DELTA 1's migration
/// target in `znippy-zoomies/.nornir/osm-katana-v2-design.md`. Until it happens this is
/// the detector, and it is written so the drift is impossible to MISS rather than
/// impossible to make.
///
/// # How facett reaches osm-katana's rules without depending on osm-katana
///
/// It cannot, and it must not: `facett-map` is an `egui` component crate and
/// `osm-katana` is a headless PBF converter that pulls in `parquet` and `gatling`. So
/// the ladder arrives as a **checked-in fixture**, `facett-map/osm-katana-ladder.txt`,
/// which is the verbatim stdout of one named command:
///
/// ```text
/// cargo run -p osm-katana --example ladder_spelling \
/// > ../facett/facett-map/osm-katana-ladder.txt
/// ```
///
/// Saying that out loud matters, because a fixture is a copy and a copy can go stale.
/// **Staleness is detectable, and it is detectable on the side that causes it**:
/// `osm-katana/tests/facett_ladder_fixture.rs` pins that command's output against a
/// literal, so editing `RULES` turns a test red *in the repo where the edit happened*,
/// with this file's path and the regeneration command in the failure message. facett
/// cannot see into another repo; that test is what makes the other side notice.
///
/// What this fixture is NOT: a constant copied out of osm-katana by hand. The parser
/// below reads the rule TESTS out of the file and evaluates them, so the fixture is
/// executable text, not a table of expected answers. An assertion against a table of
/// expected answers would be red for a facett change and blind to an osm-katana one.
///
/// # The corpus is real, and it is weighted
///
/// [`CORPUS`] is the 110 most frequent distinct tag signatures of the live Sweden cut
/// (`~/.korp/work/sweden`, 2026-08-31), over the keys either classifier reads, taken
/// from `ways.parquet` (9 325 142 rows) and `nodes.parquet` (103 488 583 rows), plus
/// (2026-09-01) the seven `boundary` / `admin_level` way signatures with rows. The
/// counts are that census. A hand-written corpus would have found the arms someone
/// thought of; this one found the arms Sweden actually has, and that is how the
/// `place=islet` coastline entry below was discovered rather than imagined.
///
/// # Two halves, because facett has two functions
///
/// | half | facett | what it decides |
/// |---|---|---|
/// | way | `classify_with(tags).lod` | the rung a WAY is painted at |
/// | label | `classify_label(tags)?.lod` | the rung a settlement NAME is placed at |
///
/// `RULES` is the union of the two, so both are checked against it. The way half is
/// where the known `place` hole shows: `classify_with` has **no `place` arm at all**.
#[cfg(test)]
mod osm_katana_ladder {
use super::*;
/// The generated ladder. `include_str!` and not a `const` transcription: the
/// bytes in the file are the bytes the example printed.
const LADDER: &str = include_str!("../osm-katana-ladder.txt");
/// One rule out of the fixture: a tag test and the rung it grants.
#[derive(Debug)]
struct Rule {
key: String,
kind: Kind,
rung: u8,
}
#[derive(Debug)]
enum Kind {
/// `key∈[a|b|c]`
ValueIn(Vec<String>),
/// `key?`
Present,
/// `key?!guard∈[a|b]`
PresentUnless { guard: String, values: Vec<String> },
}
impl Rule {
fn matches<K: AsRef<str>, V: AsRef<str>>(&self, tags: &[(K, V)]) -> bool {
let v = crate::tag(tags, &self.key);
match &self.kind {
Kind::ValueIn(vals) => v.is_some_and(|v| vals.iter().any(|x| x == v)),
Kind::Present => v.is_some(),
Kind::PresentUnless { guard, values } => {
v.is_some()
&& !crate::tag(tags, guard).is_some_and(|g| values.iter().any(|x| x == g))
}
}
}
}
/// The parsed fixture: the rungs, the unclassified rung, and the rules in order.
struct Ladder {
rungs: Vec<u8>,
unclassified: u8,
rules: Vec<Rule>,
}
fn parse() -> Ladder {
let mut rungs = Vec::new();
let mut unclassified = None;
let mut rules = Vec::new();
let mut in_rules = false;
for line in LADDER.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some(r) = line.strip_prefix("# rungs:") {
rungs = r
.trim()
.trim_start_matches('[')
.trim_end_matches(']')
.split(',')
.map(|s| s.trim().parse::<u8>().expect("a rung is a number"))
.collect();
continue;
}
if let Some(u) = line.strip_prefix("# unclassified:") {
unclassified = Some(u.trim().parse::<u8>().expect("a rung is a number"));
continue;
}
if line == "# rules" {
in_rules = true;
continue;
}
if line.starts_with('#') || !in_rules {
continue;
}
let (test, rung) = line.rsplit_once('>').unwrap_or_else(|| {
panic!("fixture rule `{line}` has no `>rung` — regenerate it")
});
let rung: u8 = rung.parse().expect("a rung is a number");
let list = |s: &str| -> Vec<String> {
s.trim_start_matches('[')
.trim_end_matches(']')
.split('|')
.map(str::to_string)
.collect()
};
let r = if let Some((key, rest)) = test.split_once("?!") {
let (guard, vals) = rest.split_once('∈').expect("a guard has a value list");
Rule {
key: key.to_string(),
kind: Kind::PresentUnless { guard: guard.to_string(), values: list(vals) },
rung,
}
} else if let Some((key, vals)) = test.split_once('∈') {
Rule { key: key.to_string(), kind: Kind::ValueIn(list(vals)), rung }
} else {
Rule { key: test.trim_end_matches('?').to_string(), kind: Kind::Present, rung }
};
rules.push(r);
}
Ladder {
rungs,
unclassified: unclassified.expect("the fixture must name its unclassified rung"),
rules,
}
}
impl Ladder {
/// The rung osm-katana would write for `tags` — first matching rule wins.
fn rung_of<K: AsRef<str>, V: AsRef<str>>(&self, tags: &[(K, V)]) -> u8 {
self.rules
.iter()
.find(|r| r.matches(tags))
.map_or(self.unclassified, |r| r.rung)
}
/// The coarsest rung at or above `camera` — osm-katana's `class::project`,
/// which is the projection of `LOD_TIER_ZOOMS` onto the fixture's own rungs.
fn project(&self, camera: u8) -> u8 {
self.rungs
.iter()
.copied()
.find(|&r| r >= camera)
.or_else(|| self.rungs.last().copied())
.unwrap_or(camera)
}
fn rung_of_lod(&self, lod: Lod) -> u8 {
self.project(crate::layer::LOD_TIER_ZOOMS[lod as usize])
}
}
fn owned(kv: &[(&str, &str)]) -> Vec<(String, String)> {
kv.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
/// The rung facett PAINTS a way of these tags at.
fn facett_way_rung(l: &Ladder, kv: &[(&str, &str)]) -> u8 {
l.rung_of_lod(classify_with(&owned(kv), &MapPalette::cartographic(true)).lod)
}
/// The rung facett places the NAME of a feature of these tags at, or `None` when
/// it would not label it at all.
fn facett_label_rung(l: &Ladder, kv: &[(&str, &str)]) -> Option<u8> {
let mut with_name = owned(kv);
with_name.push((String::from("name"), String::from("Namnet")));
classify_label(&with_name).map(|c| l.rung_of_lod(c.lod))
}
// ── the corpus ──────────────────────────────────────────────────────────────
/// **The 110 most frequent distinct tag signatures of the live Sweden cut**, with
/// the row count each was measured at (`~/.korp/work/sweden`, 2026-08-31; 55 from
/// `ways.parquet` / 9 325 142 rows, 55 from `nodes.parquet` / 103 488 583 rows),
/// restricted to the keys either classifier reads. Plus the tiering-relevant tags
/// the census does not reach in volume but the ladder is *about* — the arterials,
/// the settlement classes above `village`, the marked routes.
///
/// The count is carried so a divergence can be reported in ROWS and not only in
/// arms: "facett and the server disagree" is an argument, "they disagree about
/// 2 603 484 buildings" is a fact.
const CORPUS: &[(&[(&str, &str)], u64)] = &[
(&[("building", "yes")], 2603484),
(&[("highway", "service")], 604496),
(&[("building", "house")], 425590),
(&[("highway", "track")], 309149),
(&[("landuse", "forest")], 286345),
(&[("highway", "path")], 276399),
(&[("highway", "footway")], 269590),
(&[("highway", "residential")], 231580),
(&[("highway", "unclassified")], 200071),
(&[("amenity", "parking_space")], 186010),
(&[("natural", "wetland")], 176094),
(&[("building", "detached")], 166146),
(&[("landuse", "farmland")], 148175),
(&[("building", "garage")], 133589),
(&[("waterway", "stream")], 124643),
(&[("building", "residential")], 115782),
(&[("landuse", "residential")], 97238),
(&[("highway", "tertiary")], 92622),
(&[("amenity", "parking")], 90086),
(&[("natural", "water"), ("water", "lake")], 87196),
(&[("landuse", "grass")], 86649),
(&[("highway", "cycleway")], 79401),
(&[("natural", "grassland")], 75953),
(&[("natural", "heath")], 74384),
(&[("building", "apartments")], 74293),
(&[("landuse", "meadow")], 69686),
(&[("building", "shed")], 64104),
(&[("landuse", "forest"), ("natural", "wetland")], 63659),
(&[("barrier", "hedge")], 60408),
(&[("natural", "coastline")], 46926),
(&[("natural", "wood")], 45384),
(&[("natural", "scrub")], 45264),
(&[("landuse", "forest"), ("natural", "scrub")], 44998),
(&[("highway", "secondary")], 44033),
(&[("man_made", "pier")], 40032),
(&[("building", "terrace")], 39588),
(&[("building", "roof")], 33839),
(&[("natural", "water")], 32997),
(&[("barrier", "fence")], 32746),
(&[("natural", "coastline"), ("place", "islet")], 31112),
(&[("waterway", "ditch")], 30363),
(&[("landuse", "farmyard")], 27959),
(&[("railway", "rail")], 23426),
(&[("highway", "platform")], 21712),
(&[("highway", "steps")], 21112),
(&[("building", "garages")], 20955),
(&[("leisure", "pitch")], 20370),
(&[("natural", "bare_rock")], 19092),
(&[("building", "industrial")], 18823),
(&[("highway", "trunk")], 18434),
(&[("power", "generator")], 17648),
(&[("barrier", "wall")], 17201),
(&[("highway", "primary")], 17112),
(&[("waterway", "river")], 16299),
(&[("leisure", "playground")], 15380),
(&[("natural", "tree")], 414512),
(&[("highway", "street_lamp")], 213315),
(&[("power", "tower")], 113341),
(&[("place", "isolated_dwelling")], 98938),
(&[("highway", "turning_circle")], 92756),
(&[("highway", "crossing")], 83805),
(&[("power", "pole")], 65605),
(&[("highway", "bus_stop")], 57425),
(&[("place", "hamlet")], 39603),
(&[("barrier", "gate")], 35724),
(&[("amenity", "bench")], 33141),
(&[("highway", "give_way")], 28044),
(&[("railway", "signal")], 18884),
(&[("railway", "milestone")], 16180),
(&[("amenity", "waste_basket")], 16008),
(&[("natural", "peak")], 15188),
(&[("place", "locality")], 15029),
(&[("man_made", "flagpole")], 13047),
(&[("leisure", "picnic_table")], 11226),
(&[("amenity", "shelter")], 10324),
(&[("barrier", "block")], 10091),
(&[("amenity", "restaurant")], 10016),
(&[("railway", "level_crossing")], 9197),
(&[("railway", "switch")], 9174),
(&[("highway", "stop")], 9108),
(&[("amenity", "bicycle_parking")], 9072),
(&[("place", "farm")], 9011),
(&[("highway", "traffic_signals")], 6937),
(&[("barrier", "lift_gate")], 6613),
(&[("amenity", "toilets")], 5608),
(&[("man_made", "mast")], 5416),
(&[("natural", "bay")], 5198),
(&[("amenity", "post_box")], 5078),
(&[("power", "catenary_mast")], 4681),
(&[("natural", "rock")], 4508),
(&[("leisure", "firepit")], 4435),
(&[("amenity", "recycling")], 4074),
(&[("amenity", "charging_station")], 3699),
(&[("barrier", "swing_gate")], 3673),
(&[("amenity", "cafe")], 3632),
(&[("railway", "crossing")], 3554),
(&[("place", "neighbourhood")], 3400),
(&[("man_made", "manhole")], 3298),
(&[("amenity", "fast_food")], 3167),
(&[("railway", "buffer_stop")], 2824),
(&[("barrier", "cycle_barrier")], 2813),
(&[("barrier", "bollard")], 2761),
(&[("amenity", "bbq")], 2677),
(&[("man_made", "surveillance")], 2651),
(&[("place", "village")], 2570),
(&[("amenity", "fuel")], 2514),
(&[("leisure", "bathing_place")], 2486),
(&[("highway", "speed_camera")], 2465),
(&[("natural", "stone")], 2442),
(&[("power", "switch")], 2341),
// ── the tiering backbone the frequency census does not reach ──────────────
(&[("highway", "motorway")], 0),
(&[("highway", "motorway_link")], 0),
(&[("highway", "primary_link")], 0),
(&[("highway", "secondary_link")], 0),
(&[("highway", "tertiary_link")], 0),
(&[("highway", "living_street")], 0),
(&[("highway", "pedestrian")], 0),
(&[("highway", "road")], 0),
(&[("highway", "busway")], 0),
(&[("highway", "raceway")], 0),
(&[("railway", "narrow_gauge")], 0),
(&[("railway", "tram")], 0),
(&[("railway", "subway")], 0),
(&[("waterway", "canal")], 0),
(&[("waterway", "riverbank")], 0),
(&[("waterway", "drain")], 0),
(&[("natural", "forest")], 0),
(&[("leisure", "park")], 0),
(&[("leisure", "nature_reserve")], 0),
(&[("landuse", "industrial")], 0),
(&[("place", "country")], 0),
(&[("place", "region")], 0),
(&[("place", "city")], 0),
(&[("place", "town")], 0),
(&[("place", "municipality")], 0),
(&[("place", "suburb")], 0),
// ── the `boundary` block (osm-katana `0ac5dfa`, 2026-09-01) ──────────────
//
// MEASURED on `~/.korp/work/sweden/unpacked-backup/ways.parquet` (2026-09-01,
// 9 325 142 rows): 4 601 ways carry `boundary` or `admin_level` at all, and
// these are the seven signatures with more than a handful of rows. The
// nodes table has 57 such rows in 103 488 583 and is not sampled. The
// volume is in RELATIONS (717 019 `boundary` rows, per osm-katana's census),
// which neither classifier is asked about here — this corpus is ways.
(&[("boundary", "administrative"), ("admin_level", "7")], 1893),
(&[("boundary", "protected_area"), ("leisure", "nature_reserve")], 1384),
(&[("boundary", "protected_area")], 464),
(&[("boundary", "administrative"), ("admin_level", "4")], 353),
(&[("boundary", "administrative"), ("admin_level", "2")], 113),
(&[("boundary", "administrative"), ("admin_level", "9")], 54),
(&[("boundary", "national_park")], 9),
// Marked routes, and the arterial guard that must survive them.
(&[("route", "hiking"), ("highway", "path")], 0),
(&[("route", "bicycle"), ("highway", "primary")], 0),
(&[("piste:type", "downhill")], 0),
(&[("sac_scale", "hiking"), ("highway", "path")], 0),
(&[("mtb:scale", "2"), ("highway", "path")], 0),
(&[("snowmobile", "designated"), ("highway", "track")], 0),
];
/// One [`KNOWN_DIVERGENCES`] entry: `(tags, facett rung, osm-katana rung, half, why)`.
type Divergence = (&'static [(&'static str, &'static str)], u8, u8, &'static str, &'static str);
/// **The divergences that are LIVE, named, and deliberately not fixed here.**
///
/// `(tags, facett rung, osm-katana rung, half, why)`. This is not a mute
/// suppression list: [`the_two_taxonomies_agree_on_real_swedish_tags`] fails when a
/// listed pair no longer diverges, or diverges by different rungs, exactly as it
/// fails on an unlisted one. Closing a hole therefore forces its entry out, and a
/// rung that shifts underneath one is reported rather than absorbed.
///
/// Every entry here is a REPORT, not a decision. Changing what facett draws changes
/// pixels and belongs to a lane that can measure them.
const KNOWN_DIVERGENCES: &[Divergence] = &[
// ── THE `place` HOLE — six of the seven, and all one defect ───────────────
//
// `classify_with` has **no `place` arm at all**. A `place=city` NODE goes
// through `classify_label` and earns `Lod::Country`; a `place=city` closed WAY
// goes through `classify_with`, matches nothing, and lands on the
// `_ => Lod::City` fallthrough. Same tags, two geometries, two rungs — the
// exact shape of `korp-map-pt-1` (2026-08-31), where points and ways went
// through different tag → class maps and a sub-pixel lake became an
// off-by-default grey dot in a layer that was off by default.
//
// osm-katana closed it by putting the `place=*` block in ONE table both
// geometries read. facett still has the hole, and closing it here would change
// what the painter draws — which belongs to a lane that can measure pixels, not
// to the lane that built the detector.
//
// How much it costs TODAY, measured on the 2026-08-31 Sweden cut: almost
// nothing, and that is worth writing down rather than leaving as an assumption.
// Swedish settlements are mapped as NODES and as boundary relations, not as
// closed ways: over the 400 most frequent way signatures there is not one
// `place=city`, `place=town` or `place=village` way. The hole is real, it is
// one rung to six rungs deep, and Sweden happens not to walk into it. Another
// country's cut is not promised to be as lucky.
(&[("place", "country")], 12, 6, "way", "classify_with has no place arm"),
(&[("place", "region")], 12, 6, "way", "classify_with has no place arm"),
(&[("place", "city")], 12, 6, "way", "classify_with has no place arm"),
(&[("place", "town")], 12, 6, "way", "classify_with has no place arm"),
(&[("place", "municipality")], 12, 9, "way", "classify_with has no place arm"),
(&[("place", "village")], 12, 9, "way", "classify_with has no place arm"),
// ── THE ISLET, FROM THE OTHER SIDE — closed on the way half, open on the label ─
//
// Until osm-katana `islet-1` (2026-09-01) this list carried a WAY entry here:
// `RULES` asked its `place?` catch-all before `natural=coastline`, so an islet's
// shoreline (`natural=coastline` + `place=islet`, **31 112** Swedish ways) was
// written `min_zoom = 12` while facett paints coastline at rung 6. osm-katana
// moved `natural=coastline` to the head of the ladder; the regenerated fixture
// carries that order and the two sides now AGREE on 6 — the guard reported the
// closed hole by name and the entry is gone, exactly as its doc promised.
//
// What the reorder exposed is the LABEL half of the same rows: `classify_label`
// places the NAME of any `place=islet` at `Lod::City` (rung 12) — every islet,
// island and locality name is City there by design, they are not settlements —
// while the FEATURE is now served from rung 6. A camera at 6–11 gets the
// shoreline and no name, which is the harmless direction (a client withholding
// what the server sends). Reported, not fixed: where an islet's name appears is
// a label-placement decision with pixels behind it.
(
&[("natural", "coastline"), ("place", "islet")],
12,
6,
"label",
"classify_label places every place=islet name at City; the feature itself \
now rides the coastline's rung 6",
),
// ── THE `boundary` BLOCK — osm-katana grew arms facett does not have ───────
//
// osm-katana `0ac5dfa` (2026-09-01) gave a `boundary=*` line a rung of its own:
// `admin_level` 1–2 and 3–4 at 6, 5–7 at 9, any other `admin_level` at 12,
// `boundary=national_park|protected_area` at 6, any other `boundary` at 12.
// `classify_with` has **no `boundary` arm at all** — a boundary way is the
// "untagged-for-us generic line" its own byte-for-byte test names, and lands on
// the `_ => Lod::City` fallthrough (rung 12). Two signatures agree by accident
// and are NOT listed: `admin_level=9` (12 on both sides, the ladder's
// `admin_level?` catch-all) and `boundary=protected_area` + `leisure=nature_reserve`
// (6 on both — facett's `leisure` arm reaches it first, osm-katana's `boundary`
// arm does).
//
// How much it costs, MEASURED on the 2026-09-01 Sweden cut: 2 832 way rows in
// these five signatures, out of 4 601 boundary-tagged ways in 9 325 142. The
// direction is the dangerous one (the server serves a national border at 6,
// the client paints it at 12 — a camera at 6–11 is sent bytes it will not
// draw), and the volume is small because Swedish boundaries live in RELATIONS
// (717 019 rows), which this corpus does not sample. Reported, not fixed:
// painting a border at the country tier changes pixels.
(&[("boundary", "administrative"), ("admin_level", "7")], 12, 9, "way", "classify_with has no boundary arm"),
(&[("boundary", "protected_area")], 12, 6, "way", "classify_with has no boundary arm"),
(&[("boundary", "administrative"), ("admin_level", "4")], 12, 6, "way", "classify_with has no boundary arm"),
(&[("boundary", "administrative"), ("admin_level", "2")], 12, 6, "way", "classify_with has no boundary arm"),
(&[("boundary", "national_park")], 12, 6, "way", "classify_with has no boundary arm"),
];
fn spell(kv: &[(&str, &str)]) -> String {
kv.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>().join(" ")
}
fn known(kv: &[(&str, &str)], half: &str) -> Option<(u8, u8)> {
KNOWN_DIVERGENCES
.iter()
.find(|(t, _, _, h, _)| *h == half && spell(t) == spell(kv))
.map(|(_, f, k, _, _)| (*f, *k))
}
// ── the guard ───────────────────────────────────────────────────────────────
/// ★ **RED-when-broken: facett's tiering drifting from osm-katana's ladder.**
///
/// For every tag set in [`CORPUS`], the rung facett would paint (and, for a
/// settlement, label) at must equal the rung the shared ladder puts it on — with the
/// exceptions in [`KNOWN_DIVERGENCES`], each of which is checked to still be exactly
/// the divergence it claims to be.
///
/// The failure NAMES the tag set, both rungs, which half disagreed, and how many
/// Swedish rows carry it. A guard that reports "differs" without saying which tag is
/// a guard nobody will act on.
///
/// **RED ARMS, run and seen:**
///
/// * `"tertiary" | "tertiary_link" => Lod::District` in [`classify_with`] changed to
/// `Lod::City` ⇒ *"way: `highway=tertiary` (92 622 rows) — facett rung 12, the
/// osm-katana ladder rung 9"*.
/// * the `place=city` entry deleted from [`KNOWN_DIVERGENCES`] ⇒ the live `place`
/// hole is reported by name, which is what gate 4 asked for.
/// * a listed divergence "fixed" in the list but not in the code ⇒ *"is listed as
/// diverging 12 vs 6 but actually diverges 12 vs 9"*.
#[test]
fn the_two_taxonomies_agree_on_real_swedish_tags() {
let l = parse();
let mut bad: Vec<String> = Vec::new();
let mut compared = 0usize;
let mut diverging_rows = 0u64;
let mut hit: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for (kv, rows) in CORPUS {
let katana = l.rung_of(&owned(kv));
// ── the WAY half: what the painter draws this at ──────────────────────
let f = facett_way_rung(&l, kv);
compared += 1;
match (f == katana, known(kv, "way")) {
(true, None) => {}
(true, Some((wf, wk))) => bad.push(format!(
"way: `{}` is listed in KNOWN_DIVERGENCES as {wf} vs {wk} but the two \
sides now AGREE on {f}. The hole is closed — delete the entry.",
spell(kv)
)),
(false, Some((wf, wk))) if wf == f && wk == katana => {
diverging_rows += rows;
hit.insert(format!("way:{}", spell(kv)));
}
(false, Some((wf, wk))) => bad.push(format!(
"way: `{}` is listed as diverging {wf} vs {wk} but actually diverges \
{f} vs {katana} — a rung moved underneath a known hole",
spell(kv)
)),
(false, None) => bad.push(format!(
"way: `{}` ({rows} Swedish rows) — facett paints it at rung {f}, the \
osm-katana ladder puts it on {katana}. One of the two texts moved. \
Fix the taxonomy, or record it in KNOWN_DIVERGENCES with its reason.",
spell(kv)
)),
}
// ── the LABEL half ───────────────────────────────────────────────────
//
// Only for `place=*`. `RULES` is the union of TWO functions and it is
// explicit about which arm came from which: the `place=*` block is
// `classify_label`'s settlement backbone, everything else is
// `classify_with`. `classify_label`'s other branches decide where a NAME
// is placed, not what tier a feature IS — every named lake, park and
// street resolves to `Lod::City` there by design — so comparing them
// against the ladder would compare two different questions and report 30
// divergences that are not drift.
if crate::has_tag(&owned(kv), "place")
&& let Some(f) = facett_label_rung(&l, kv)
{
compared += 1;
match (f == katana, known(kv, "label")) {
(true, None) => {}
(true, Some((wf, wk))) => bad.push(format!(
"label: `{}` is listed in KNOWN_DIVERGENCES as {wf} vs {wk} but \
the two sides now AGREE on {f} — delete the entry.",
spell(kv)
)),
(false, Some((wf, wk))) if wf == f && wk == katana => {
hit.insert(format!("label:{}", spell(kv)));
}
(false, Some((wf, wk))) => bad.push(format!(
"label: `{}` is listed as diverging {wf} vs {wk} but actually \
diverges {f} vs {katana}",
spell(kv)
)),
(false, None) => bad.push(format!(
"label: `{}` ({rows} Swedish rows) — facett places the name at \
rung {f}, the osm-katana ladder puts the feature on {katana}.",
spell(kv)
)),
}
}
}
// Every listed divergence must have been reached, or the list is describing tag
// sets the corpus no longer holds and is quietly suppressing nothing.
for (kv, _, _, half, _) in KNOWN_DIVERGENCES {
let key = format!("{half}:{}", spell(kv));
if !hit.contains(&key) {
bad.push(format!(
"KNOWN_DIVERGENCES lists `{key}`, but the corpus never produced that \
comparison — the entry suppresses nothing and must go"
));
}
}
assert!(
bad.is_empty(),
"facett and osm-katana are two TEXTS of one taxonomy and they have drifted \
({} of {compared} comparisons):\n {}\n\nThe ladder facett is checked \
against is `facett-map/osm-katana-ladder.txt`; regenerate it with `cargo \
run -p osm-katana --example ladder_spelling`.",
bad.len(),
bad.join("\n ")
);
println!(
"{compared} comparisons over {} real Swedish tag signatures; {} known \
divergences covering {diverging_rows} way rows",
CORPUS.len(),
KNOWN_DIVERGENCES.len()
);
}
/// ★ **RED-when-broken: a guard that compares a value with a copy of itself.**
///
/// The trap this codebase keeps hitting (`korp_tile_options().zooms ==
/// DEFAULT_TILE_ZOOMS`, where the function returns that constant) has an exact shape
/// here: if the fixture were parsed into *expected answers* rather than into rule
/// TESTS, or if `rung_of` ignored its rules, every comparison above would be a
/// constant compared with itself.
///
/// Three arms, each of which a hollow instrument fails:
///
/// 1. the fixture parses into a real ladder — rungs, an unclassified rung, and
/// enough rules to be the ladder rather than a fragment of it;
/// 2. `rung_of` READS its rules: the same tags against a ladder whose rules have
/// been emptied must give a different answer;
/// 3. the corpus lands on more than one rung on BOTH sides, so a collapsed ladder
/// on either side cannot look like agreement.
#[test]
fn the_guard_is_not_comparing_a_value_with_itself() {
let l = parse();
assert_eq!(l.rungs, vec![6u8, 9, 12, 15], "the fixture's rungs");
assert_eq!(l.unclassified, 12, "the fixture's unclassified rung");
assert!(
l.rules.len() >= 30,
"only {} rules parsed out of the fixture — it is a fragment, not the ladder",
l.rules.len()
);
// Arm 2: the rules are actually consulted.
let motorway = owned(&[("highway", "motorway")]);
let empty = Ladder { rungs: l.rungs.clone(), unclassified: l.unclassified, rules: vec![] };
assert_eq!(l.rung_of(&motorway), 6, "the real ladder tiers a motorway at 6");
assert_ne!(
l.rung_of(&motorway),
empty.rung_of(&motorway),
"`rung_of` gives the same answer with NO rules as with all of them — it is \
not reading the fixture, and every comparison in this module is vacuous"
);
// Arm 3: both sides span rungs over the corpus.
let mut katana: std::collections::BTreeSet<u8> = std::collections::BTreeSet::new();
let mut facett: std::collections::BTreeSet<u8> = std::collections::BTreeSet::new();
for (kv, _) in CORPUS {
katana.insert(l.rung_of(&owned(kv)));
facett.insert(facett_way_rung(&l, kv));
}
assert!(
katana.len() >= 4 && facett.len() >= 4,
"the corpus lands on {katana:?} in osm-katana and {facett:?} in facett — a \
side collapsed onto one rung would agree with itself everywhere"
);
}
/// ★ **RED-when-broken: a fixture that is not the generated one.**
///
/// The fixture is a copy, so the one thing that must be true of it is that it came
/// out of the generator. Hand-editing it — the way a copy rots — loses the header,
/// and a header-less fixture is a hand-written table of answers wearing a fixture's
/// name.
#[test]
fn the_ladder_fixture_is_generated_and_says_so() {
assert!(
LADDER.contains("GENERATED, do not hand-edit"),
"osm-katana-ladder.txt has lost its generated-file header"
);
assert!(
LADDER.contains("cargo run -p osm-katana --example ladder_spelling"),
"the fixture must carry the command that regenerates it, or the next person \
hand-edits it"
);
assert!(
LADDER.contains("# crate: osm-katana "),
"the fixture must name the crate version it was taken from"
);
}
}