nordisk/facett
facett-map/src/layer.rs
//! **The selectable Map trait — ONE trait, ALL maps** (consolidation design §3.1).
//!
//! Every map surface in the constellation draws through **one** source-agnostic
//! seam so *data-to-draw is selectable and stackable*: OSM streets **+** a
//! Trafikverket roads overlay **+** korp collectors' own layers, each a
//! [`MapLayer`] the user toggles at runtime. The renderer ([`MapFacet::ui`]) is
//! **source-agnostic** — it paints [`DrawFeature`]s, never source-specific structs
//! — and the camera ([`MapView`]) carries **`bearing_deg` + `tilt_deg`** so the
//! flat 2D map **rotates and pitches** (the car-navigator feel).
//!
//! This module is **additive**: the legacy point/ways [`crate::MapView`] facet is
//! unchanged. The new trait system reuses this crate's projection ([`crate::mercator`]),
//! styling ([`crate::style`]) and tessellation ([`crate::tessellate`]) — nothing is
//! re-implemented. A source that already produces [`crate::Way`]s (OSM via
//! `facett-osm`, or a Trafikverket road set) plugs in as a [`WaysLayer`] with **no
//! re-parse**; points plug in as a [`PointsLayer`].
use egui::Color32;
use serde::{Deserialize, Serialize};
use crate::style::{Lod, MapPalette, classify_label};
use crate::{Way, mercator, tessellate};
/// A geographic coordinate (WGS-84 degrees).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct LatLon {
pub lat: f64,
pub lon: f64,
}
impl LatLon {
pub fn new(lat: f64, lon: f64) -> Self {
Self { lat, lon }
}
}
/// **One frame's projection constants** — see [`MapView::projector`].
///
/// Everything in [`MapView::project`] that does not depend on the vertex, hoisted out
/// of the per-vertex loop (stinky `prof-9`): the world scale (`powf`), the camera
/// centre's own Mercator (`tan`/`cos`/`ln`), the ground frame (`sincos`) and the
/// half-pane offset. What is left per vertex is one `mercator` and eight
/// multiply-adds.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Projector {
world_px: f64,
center_merc: (f64, f64),
frame: facett_core::render::GroundFrame2d,
half_px: (f32, f32),
}
impl Projector {
/// `(lon, lat)` → screen pixel. **THE projection body** — [`MapView::project`] is
/// this call with the setup inlined, and nothing else in the crate spells the
/// arithmetic out a second time.
#[inline]
#[must_use]
pub fn project(&self, lon: f64, lat: f64) -> (f32, f32) {
let (px, py) = mercator(lon, lat);
// Offset from centre in pixels (x east, y south — screen convention).
let dx = (px - self.center_merc.0) * self.world_px;
let dy = (py - self.center_merc.1) * self.world_px;
// The rotate (bearing, clockwise for the MAP so content counter-rotates)
// AND the cos(tilt) vertical foreshortening, both from the shared frame —
// `apply` is the one writer, so no rotation arithmetic is spelled out here.
let (rx, ry) = self.frame.apply(dx, dy);
(self.half_px.0 + rx as f32, self.half_px.1 + ry as f32)
}
}
/// The camera. A 2D pan/zoom **plus** `bearing_deg` (rotate the map — a compass
/// heading, clockwise) and `tilt_deg` (pitch the flat map back toward the horizon,
/// 0 = straight-down top-down, up to ~70° for the car-nav look). One shared
/// [`project`](MapView::project) maps `(lon, lat)` → screen pixels through the
/// rotated + tilted view, so **every** layer lines up in the same frame.
///
/// `zoom` is a slippy-map zoom level: the whole Web-Mercator world spans
/// `256 · 2^zoom` pixels (industry convention — a tile is 256 px), so `zoom`
/// composes cleanly with real OSM/PMTiles tiles.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct MapView {
pub center: LatLon,
pub zoom: f32,
/// Compass heading in degrees (clockwise). `0` = north-up; the map rotates
/// under a fixed screen so the driver's heading points up (car-nav feel).
pub bearing_deg: f32,
/// Pitch in degrees. `0` = top-down; larger tilts the flat map back toward
/// the horizon (vertical foreshortening + a far-fade the renderer shades).
pub tilt_deg: f32,
/// Pane size in logical pixels `(w, h)` — the projection centres on this.
pub size_px: (f32, f32),
}
/// **The shallowest zoom a car-nav camera may hold.** `pub` because korp's 3D pane
/// clamps its own wheel handler to the same range and had a second copy of the number
/// (lane ST, gfx3d-S10).
pub const MIN_ZOOM: f32 = 0.0;
/// **The deepest zoom a car-nav camera may hold.** `pub` for the same reason as
/// [`MIN_ZOOM`]. Note this is the WIDGET's clamp, not the pyramid's cut depth — the
/// scene's own `max_zoom` is the floor that follows the data.
pub const MAX_ZOOM: f32 = 22.0;
/// **Scroll pixels per zoom LEVEL** — the wheel's feel, and the number korp's 3D pane
/// had a different copy of.
///
/// MEASURED 2026-09-04: this pane divides scroll by `120.0`, and korp's 3D wheel handler
/// (`map_views.rs`) divided by `50.0` — under a comment claiming *"a zoom LEVEL per
/// notch is the same feel the flat pane has"*. It was **2.4× faster per notch**, so the
/// two panes of one application responded differently to the same gesture while the
/// code said they did not. `pub` so there is one number and the claim is true by
/// construction rather than by comment.
pub const PX_PER_ZOOM_LEVEL: f32 = 120.0;
const MAX_TILT: f32 = 75.0;
// The deck.gl `widthScale` both of `MapFacet`'s lanes draw with is
// `MapFacet::line_width_scale` — the live `style::line_width_scale(world_px)`. It was a
// `const GPU_LINE_WIDTH_SCALE: f32 = 1.0` here (lane GG #27); see the method.
// ── WHICH lane paints the ways — a VALUE, not a side effect ───────────────────
//
// **BOTH lanes are first-class and both must keep working.** The CPU painter is not
// a degraded mode and it is not dead code once the GPU lane exists: it is what draws
// on a host with no adapter (a refused bring-up, an Xvfb display with no DRI3, wasm
// without WebGPU), it is what draws every FILL and every point and label in both
// modes, and it is what draws the ways under a rotated or pitched camera, where the
// flat [`crate::gpu::MapTransform`] provably cannot reproduce
// [`MapView::project`]. `MapFacet::ui` therefore *decides* between them and says so,
// instead of letting "the GPU attempt quietly did nothing" be the way CPU rendering
// gets selected. That failure mode is the whole reason this module needed fixing: a
// silent no-op lane is indistinguishable from a working one.
/// **Which lane painted the way (line) geometry, and why** — [`MapFacet::way_lane`].
///
/// Published in [`MapFacet::state_json`] so the choice is observable headlessly. Fills,
/// points and labels are ALWAYS the CPU painter's and are not described here.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WayLane {
/// The GPU line lane (`facett_map::gpu::GpuMapRenderer`) owns the unfilled ways of
/// `layers` enabled way layers. Their line features are withheld from the CPU
/// painter, so nothing is drawn twice.
Gpu {
/// How many enabled layers the lane took.
layers: usize,
},
/// The CPU painter owns every way, for this reason. A first-class outcome.
Cpu(CpuOnly),
}
/// Why the CPU painter owns the ways. Never "the GPU failed": each variant is a
/// property of the host or the camera that was checked BEFORE anything was attempted.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CpuOnly {
/// Built **without** the `wgpu` feature — this binary has no GPU lane to reach.
/// The default for `facett-map`, and what every host with no `RenderState` links.
NotCompiled,
/// No host installed `GpuMapRenderer` in this process: no adapter came up, the
/// bring-up was refused (`facet-gpu-<n>`), or the host simply never called
/// `gpu::install_renderer`. Handing the layer over anyway would silently drop
/// every road, so the CPU keeps it.
NoRenderer,
/// The camera is rotated (`bearing_deg`) or pitched (`tilt_deg`) — including idle
/// auto-rotate while it spins. [`crate::gpu::MapTransform`] is the 2D reduction of
/// the unified view core at bearing 0 / tilt 0 and carries no rotation term, so the
/// GPU lane would draw an unrotated basemap under rotated labels. The CPU painter
/// projects through the rotating [`MapView::project`] and stays correct.
RotatedCamera,
/// The stack holds no unfilled ways to give a *line* pipeline — only fills, points
/// or labels, which are the CPU painter's in both modes.
NoLineWays,
}
/// Publish WHY the lane has no frames onto the record an About box reads, so the
/// on-screen row and `state_json` cannot disagree. Keyed through `gpu::lane_name()` — the
/// ONE name writer (LAW #5), never a re-spelled string literal.
///
/// **Every zero-frame state must have a name here**, including the one that is nobody's
/// fault: the facet chose the GPU lane and the lane has not encoded yet. MEASURED on
/// 2026-08-02 — with korp's modal infra chooser over the map pane, `MapFacet::ui` runs and
/// picks the GPU, the callback is handed to egui, and the lane still counts 0, because a
/// covered/clipped pane produces no GPU work (correct) and because egui may discard the
/// frame a payload rode on. Reporting `None` there put the row straight back to the bare
/// "installed but UNUSED" that started this whole hunt, so it reports
/// `awaiting_first_encode` instead and only falls silent once the lane is genuinely live.
///
/// Without the `wgpu` feature there is no lane in the binary to annotate, and
/// `GpuLaneUse::NotInstalled` already prints the unambiguous *"not installed — the CPU
/// painter owns every pane"*. The `not_compiled` token still rides `state_json`.
fn note_lane_idle_reason(lane: WayLane) {
#[cfg(feature = "wgpu")]
crate::gpu::note_idle_reason(match lane.cpu_reason() {
Some(cpu) => Some(cpu),
// Chose the GPU, but nothing has come out of it yet — say so rather than nothing.
None if crate::gpu::lane_frames() == 0 => {
Some(facett_core::render::lane::AWAITING_FIRST_ENCODE)
}
None => None,
});
#[cfg(not(feature = "wgpu"))]
let _ = lane;
}
impl WayLane {
/// Stable token for `state_json` (`"gpu"` / `"cpu"`).
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
WayLane::Gpu { .. } => "gpu",
WayLane::Cpu(_) => "cpu",
}
}
/// Is the GPU line lane painting the ways?
#[must_use]
pub fn is_gpu(self) -> bool {
matches!(self, WayLane::Gpu { .. })
}
/// The CPU reason token, or `None` when the GPU lane has them.
#[must_use]
pub fn cpu_reason(self) -> Option<&'static str> {
match self {
WayLane::Gpu { .. } => None,
WayLane::Cpu(r) => Some(r.as_str()),
}
}
}
impl CpuOnly {
/// Stable token for `state_json`.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
CpuOnly::NotCompiled => "not_compiled",
CpuOnly::NoRenderer => "no_renderer",
CpuOnly::RotatedCamera => "rotated_camera",
CpuOnly::NoLineWays => "no_line_ways",
}
}
}
impl Default for MapView {
fn default() -> Self {
Self {
center: LatLon::new(0.0, 0.0),
zoom: 3.0,
bearing_deg: 0.0,
tilt_deg: 0.0,
size_px: (800.0, 600.0),
}
}
}
impl MapView {
/// A view centred on `center` at `zoom`, north-up and flat (bearing/tilt 0).
pub fn new(center: LatLon, zoom: f32, size_px: (f32, f32)) -> Self {
Self { center, zoom: zoom.clamp(MIN_ZOOM, MAX_ZOOM), bearing_deg: 0.0, tilt_deg: 0.0, size_px }
}
/// Set the compass heading (degrees clockwise); wrapped to `[0, 360)`.
pub fn with_bearing(mut self, deg: f32) -> Self {
self.bearing_deg = deg.rem_euclid(360.0);
self
}
/// Set the pitch (degrees), clamped to `[0, MAX_TILT]`.
pub fn with_tilt(mut self, deg: f32) -> Self {
self.tilt_deg = deg.clamp(0.0, MAX_TILT);
self
}
/// Whole Web-Mercator world width in pixels at this zoom (`256 · 2^zoom`).
#[inline]
pub fn world_px(&self) -> f64 {
256.0 * 2f64.powf(self.zoom.clamp(MIN_ZOOM, MAX_ZOOM) as f64)
}
/// **The one shared projection.** `(lon, lat)` → screen pixel, through the
/// rotated (`bearing_deg`) + tilted (`tilt_deg`) camera centred on the pane.
/// Pure — every layer and every test projects through this exact function, so
/// all layers register to the pixel.
/// **The unified view this 2D camera IS** (GFX_V2 item 6) — a
/// [`facett_core::render::View`] in orthographic mode with this camera's compass
/// bearing and tilt. `target` is the Mercator centre; `px_per_unit` is folded
/// into the caller's `world_px` scaling, so it is left at 1.
///
/// The tilt is worth a word, because it looks like a fake and is not: a
/// **tilted orthographic** projection foreshortens the vertical axis by exactly
/// `cos(tilt)` with no perspective convergence. That is what `project` computed
/// by hand before item 6, and it now falls out of the shared frame instead —
/// GFX_V2's "orthographic (or long focal length)" constraint, taken literally.
#[must_use]
pub fn view(&self) -> facett_core::render::View {
facett_core::render::View::ortho_2d_compass([0.0, 0.0], [1.0, 1.0], self.bearing_deg, self.tilt_deg)
}
/// The shared **ground frame** [`project`](Self::project) applies — the unified
/// core's rotate + foreshorten, taken at full `f64` precision from this camera's
/// bearing and tilt in degrees (going through the `f32` angles of
/// [`view`](Self::view) would cost an ULP).
#[must_use]
pub fn ground_frame(&self) -> facett_core::render::GroundFrame2d {
facett_core::render::View::ground_frame_2d_compass_deg(self.bearing_deg as f64, self.tilt_deg as f64)
}
/// **Drag the map by a screen delta** — the camera movement a pointer drag means.
///
/// `(dx_px, dy_px)` is the pointer's screen movement. The *content* follows the
/// pointer, so the CENTRE moves the opposite way: drag right ⇒ look further west.
/// Rotation and tilt are undone through the shared
/// [`GroundFrame2d::unapply`](facett_core::render::GroundFrame2d::unapply), so a
/// bearing-rotated map pans along the direction the user actually sees.
///
/// Pure and `f64` throughout, so it is headlessly assertable and does not drift at
/// slippy zoom 22 (one pixel there is ~1e-8 of the world).
///
/// # Why this exists
///
/// It did not, and that was the defect. `MapFacet` — the car-nav pane korp opens on —
/// allocated its canvas with `Sense::hover()` and read neither drag nor scroll, so the
/// map could **not be panned at all** and could only be zoomed by a slider in a side
/// rail. `MapView`'s own comment in korp says "persist the camera the user drove";
/// there was no way for the user to drive it. Measured on the shipped binary
/// 2026-08-03: a drag across the canvas moved `centre` by exactly 0.
pub fn pan_by_px(&mut self, dx_px: f32, dy_px: f32) {
if dx_px == 0.0 && dy_px == 0.0 {
return;
}
let ws = self.world_px();
// Screen delta → ground delta (undo bearing + tilt), then → mercator units.
let (gx, gy) = self.ground_frame().unapply(f64::from(dx_px), f64::from(dy_px));
let (cx, cy) = crate::mercator(self.center.lon, self.center.lat);
// Minus: the content follows the pointer, so the camera walks the other way.
// `y` is clamped inside the Mercator band before inversion, so a drag past the
// pole slides to the edge instead of wrapping the map inside out.
let ny = (cy - gy / ws).clamp(0.0, 1.0);
let (lon, lat) = crate::unmercator(cx - gx / ws, ny);
self.center = LatLon::new(lat, lon);
}
/// **Zoom by a scroll delta**, about the pane centre, clamped to the camera's range.
///
/// `scroll_px` is egui's `smooth_scroll_delta.y` — positive is scroll-up, which is
/// zoom IN. The divisor turns one wheel notch (≈ 50 px on X11, ≈ 14 px per line
/// elsewhere) into a fraction of a zoom level, so a notch is a visible but not jarring
/// step and a trackpad flick is smooth rather than a jump across the LOD ladder.
///
/// It moves the SAME `zoom` field the controls rail's slider writes, which is the
/// field korp derives its server viewport + LOD from — so a wheel gesture re-queries
/// korp-server exactly like the slider does, rather than being a second camera nobody
/// fetches for. (That second-camera shape is real and is what the legacy flat
/// `MapView` widget's `Msg::ZoomBy` does: it drives `camera.zoom`, a screen scale that
/// no fetch reads.)
pub fn zoom_by_scroll(&mut self, scroll_px: f32) {
if scroll_px == 0.0 {
return;
}
self.zoom = (self.zoom + scroll_px / PX_PER_ZOOM_LEVEL).clamp(MIN_ZOOM, MAX_ZOOM);
}
/// **The one shared projection.** `(lon, lat)` → screen pixel, through the
/// rotated (`bearing_deg`) + tilted (`tilt_deg`) camera centred on the pane.
/// Pure — every layer and every test projects through this exact function, so
/// all layers register to the pixel.
///
/// The rotate + foreshorten step is **the unified view core's frame** (GFX_V2
/// item 6, LAW #5): `View::ground_affine_f64` evaluates the trigonometry once
/// and this applies it in `f64`, so there is no second hand-written rotation
/// here and no precision lost at slippy zoom 22. Bit-exact against the affine it
/// replaced — see `the_unified_frame_is_bit_exactly_the_hand_rolled_rotation`.
#[inline]
pub fn project(&self, lon: f64, lat: f64) -> (f32, f32) {
self.projector().project(lon, lat)
}
/// **The per-frame half of [`project`](Self::project), evaluated once.**
///
/// stinky `prof-9`. `project` is called once per VERTEX and four things inside it
/// do not depend on the vertex at all: `world_px` (a `powf`), the CENTRE's own
/// Mercator (a `tan`, a `cos` and a `ln` — the same three the vertex pays, doubled
/// for a value that is constant across the whole frame), the ground frame's
/// `sincos` off bearing/tilt, and the half-pane offset. `perf record` over 40 real
/// frames measured libm at **20.3 % of app CPU** on the CPU-painter lane, with
/// `__tan_fma`, `__ieee754_log_fma`, `__cos_fma`, `__sincos_fma` and `exp2` the
/// only libm symbols in the profile — and `mercator` is the only caller of the
/// first three.
///
/// A painter that projects a run of vertices takes a [`Projector`] once and calls
/// [`Projector::project`] per vertex. `project` itself is that same call with the
/// setup inlined, so there is ONE projection body and no chance of the two drifting
/// (LAW #5), which `the_unified_frame_is_bit_exactly_the_hand_rolled_rotation` pins
/// against a hand-rolled reference written out in full.
#[must_use]
pub fn projector(&self) -> Projector {
let (cx, cy) = mercator(self.center.lon, self.center.lat);
Projector {
world_px: self.world_px(),
center_merc: (cx, cy),
frame: self.ground_frame(),
half_px: (self.size_px.0 * 0.5, self.size_px.1 * 0.5),
}
}
/// A conservative Web-Mercator bbox `[x0,y0,x1,y1]` (unit space) of what the
/// pane can show, ignoring rotation (a generous axis-aligned envelope — the
/// rotated view always fits inside the circle of this radius). Layers use it to
/// **cull** features to the viewport (the design's "features intersecting the
/// viewport at a zoom/LOD").
pub fn merc_bbox(&self) -> [f64; 4] {
let ws = self.world_px();
let (cx, cy) = mercator(self.center.lon, self.center.lat);
// Half-diagonal in pixels → mercator units (covers any rotation).
let half = 0.5 * ((self.size_px.0 as f64).hypot(self.size_px.1 as f64)) / ws;
[cx - half, cy - half, cx + half, cy + half]
}
}
/// **THE LOD LADDER — one rule, any level list. The map's only threshold writer.**
///
/// Given an ASCENDING list of slippy-zoom `levels` and a camera `zoom`, this answers
/// the single question every level-of-detail decision on this map is a phrasing of:
/// *which step of that ladder has this camera reached?* It returns the INDEX of the
/// finest level the camera is at or past — the **floor** rule — and `0` when the camera
/// is coarser than the whole ladder.
///
/// # Why this function exists (`kalm-2`, measured 2026-08-03)
///
/// The threshold used to be written TWICE, in two different shapes, for the same
/// quantity: [`view_lod_tier`] cut the draw side at `zoom >= 12.0`, while korp's tile
/// fetch picked its pyramid level from `round(zoom)` fed to a **nearest-level** search.
/// Those are not the same function of the same number. With a `[6, 9, 12]` pyramid the
/// fetch flipped to z12 at camera **10.5** (`round(10.5) = 11`, nearest of `[6,9,12]`
/// is `12`) while the renderer stayed on the coarse tier until **12.0**. MEASURED on
/// the live Sweden pyramid: camera 11.9 drew **9 840** features out of **80 000** ways
/// the server had already sent — 12.3 % — and camera 12.0 drew **127 635**. A 12.97×
/// step for a hundredth of a zoom, with a 1.5-zoom-wide band underneath it where the
/// wire carried detail the painter threw away.
///
/// One rule, applied to two level lists ([`LOD_TIER_ZOOMS`] for the cartographic tier,
/// the recipe's `zooms` axis for the tile pyramid), cannot develop that kind of gap:
/// both sides step at level boundaries, both step by flooring, and a level list that
/// grows a step (`[6, 9, 12]` → `[6, 9, 12, 15]`) moves both sides together.
///
/// # Why FLOOR and not nearest/round
///
/// Nearest pulls detail the camera has not earned. On a `[6, 9, 12, 15]` pyramid the
/// nearest rule starts fetching **z15** tiles at camera 13.5 — a viewport ~8× wider in
/// z15 tiles than at zoom 15, off the pyramid level that carries 17.3 GB against z12's
/// 2.4 GB — and the renderer's tier does not change at 15 at all, so every one of those
/// bytes buys nothing. Floor guarantees the invariant that makes the two sides safe:
/// **geometry generalised for level `L` is never shown at a camera below `L`**.
///
/// `levels` must be ascending; an empty list yields `0`.
pub fn lod_step(levels: &[u8], zoom: f32) -> usize {
let mut step = 0;
for (i, &l) in levels.iter().enumerate() {
if zoom >= f32::from(l) {
step = i;
} else {
break;
}
}
step
}
/// The LEVEL (not the index) of [`lod_step`] — the ladder rung this camera reads from.
///
/// `None` only for an empty ladder. A camera coarser than the whole list clamps UP to
/// the coarsest level, because that is the only data that exists out there; there is no
/// such thing as reading a level the pyramid does not hold.
pub fn lod_level(levels: &[u8], zoom: f32) -> Option<u8> {
levels.get(lod_step(levels, zoom)).copied()
}
/// **The cartographic ladder** — the slippy zooms at which the map's [`Lod`] tier steps
/// (`0` country · `6` region · `12` city). This is a LOOK decision, not a data one: it
/// is why a `Lod::Region` way (water, secondary roads) still draws over a country-scale
/// overview at zoom 6, and why residential streets and buildings wait until 12.
///
/// It is deliberately a separate list from the tile pyramid's `zooms` axis. [`Lod`] has
/// [`LOD_COUNT`](crate::style::LOD_COUNT) steps and the pyramid has as many as it was
/// cut with; binding the tier to the pyramid's INDEX would silently redraw the map every
/// time the pyramid gained a level (`[6, 9, 12]` → `[6, 9, 12, 15]` would have dropped
/// city detail between 12 and 15). What must not drift is the RULE, and both lists are
/// read by [`lod_step`].
///
/// **The third step is 9** — the z9 rung, added 2026-08-25 with
/// [`Lod::District`](crate::style::Lod::District). It is the one rung the pyramid served
/// while the ladder ignored it: z9 tiles were cut, stored, shipped and decoded, and then
/// drew the tier z6 already drew. `tests/lod_ladder_vs_pyramid.rs` is the law (this
/// line named `lod_ladder_serves_its_pyramid.rs` until 2026-08-27; no such file has
/// ever existed -- the guard is real, the citation was not)
/// that now says a served rung must move the ladder; `Lod`'s own doc carries what moved
/// onto it and what deliberately did not.
///
/// **The fifth step is 15, not 14** (`kalm-1`). The tier a footpath is entitled to is
/// the one where a footpath is wide enough to be a footpath: at camera 14 the ground is
/// ~2.4 m/px and a 2 m path is under a pixel — the same grain, one zoom deeper. At 15
/// the Sweden extract's finest rung is 0.608 m/px and the same path is ~3 px, an object
/// with a width. 15 is also where the pyramid's deepest rung starts, so the camera that
/// earns the tier is the camera that fetches the geometry generalised for it.
pub const LOD_TIER_ZOOMS: [u8; crate::style::LOD_COUNT] = [0, 6, 9, 12, 15];
/// **The viewport's level-of-detail tier** (`0` country · `1` region · `2` city),
/// from its slippy zoom. This is the CPU render path's half of the LOD contract the
/// GPU cull already enforces (`gpu/cull.wgsl`: a way draws only when `lod <=
/// viewport_lod`) and the [`Lod`](crate::style::Lod) style law states ("country ways
/// show at every zoom; city ways only when zoomed in").
///
/// It mirrors the SEMANTICS of `facett_geomap::osm2d`'s `way_lod()` — far out yields
/// country, zoomed in yields city — but reads a **slippy** zoom (`0..=22`, where the
/// world spans `256·2^zoom` px) rather than geomap's fit-to-data multiplier, so the
/// thresholds are slippy-zoom levels, not that path's `3_000` / `30_000` multipliers.
///
/// It is now [`lod_step`] over [`LOD_TIER_ZOOMS`] — the SAME rule korp's tile fetch
/// steps its pyramid with (`kalm-2`). The breakpoints are byte-for-byte the ones this
/// function has always had; what changed is that they are no longer a hand-written
/// ladder that a second ladder elsewhere could disagree with.
pub fn view_lod_tier(zoom: f32) -> u32 {
(lod_step(&LOD_TIER_ZOOMS, zoom) as u32).min(Lod::Detail as u32)
}
/// **THE level-of-detail gate — the one every CPU layer draws through.**
///
/// A feature tiered `lod` is drawn only once the viewport has zoomed to at least that
/// tier: country features at every zoom, city features (residential roads, buildings,
/// individual nodes) only when zoomed in. This is the CPU half of the rule
/// `gpu/cull.wgsl` enforces on the GPU (`lod <= viewport_lod`) and the
/// [`Lod`](crate::style::Lod) style law states.
///
/// It is a free function, and public, because it was being *retyped*: [`WaysLayer`]
/// had the comparison, [`LabelLayer`] had its own copy, and [`PointsLayer`] had none
/// at all — so 40 000 OSM nodes painted over a whole country at every zoom while
/// their sibling ways correctly culled to 19.6 % (that is the owner's "red dots all
/// over"). Two sibling layers in one file disagreeing about the same trait contract is
/// the failure this removes; there is now one comparison and three callers.
pub fn lod_visible(lod: Lod, view: &MapView) -> bool {
(lod as u32) <= view_lod_tier(view.zoom)
}
/// The map label point size. ONE writer: `place_map_labels` measures the galley at this
/// size and `paint_features` draws at it, so the box the placer reserved and the text
/// that lands are the same picture. They were two literal `12.0`s and a placer measured
/// against the wrong font is a placer that reserves the wrong pixels.
const LABEL_FONT_PX: f32 = 12.0;
/// Padding around each label box so neighbours do not merely *touch*.
const LABEL_PAD_PX: f32 = 3.0;
/// **The areal budget: how much pane one label is entitled to, in px².**
///
/// THE DEFECT THIS CLOSES. Rect-vs-rect collision answers "do these two share pixels?",
/// and on a dense city viewport the honest answer for a great many pairs is *no* — so
/// the placer packs names shoulder to shoulder and reports a perfect zero overlaps over
/// a pane nobody can read. MEASURED on korp-ui over Stockholm at zoom 12.7 (canvas
/// 1258×728 = 915 824 px²): **255 placed, 0 overlapping pairs** — one label per
/// 3 591 px² of map. Not one collision test could have seen that.
///
/// HONEST BOUND on the ink figure: that pane published no `ink_px` (this change is what
/// added it), so its box coverage is INFERRED, not measured — 255 × the mean box area
/// of **1 165 px²** measured on the AFTER run of the same drive = **>= 32 %**, and it
/// is a lower bound because the before set carried more long street names than the
/// after set does. What IS measured end to end is the harness fixture in
/// `label_collision::a_z12_city_pane_does_not_disappear_under_its_own_labels`:
/// **28.4 % -> 10.7 %** of the same 1258×728 pane; and the live pane after this change:
/// **7.8 %** (64 labels, `ink_px = 74 559`, drive of 2026-08-03).
///
/// The derivation, so the number is not a taste: a 12 px-font Swedish street name lays
/// a galley of roughly 72×17 px, and [`LABEL_PAD_PX`] adds 3 px on every side → about
/// **1 800 px² of reserved box per label**. Holding total label ink near 12 % of the
/// pane — the low end of ordinary cartographic practice, and where a car-nav basemap
/// sits — gives `1800 / 0.12 = 15 000 px²` per label. Rounded to 14 000, korp's canvas
/// budgets `915 824 / 14 000 =` **65 labels**; the live pane came back at **64 placed,
/// 7.8 % ink**, against 255 labels before.
///
/// It is an AREAL rule on purpose: a bigger pane earns proportionally more names and a
/// small embedded pane earns fewer, with no second constant to tune per host.
const LABEL_MIN_PX2_EACH: f32 = 14_000.0;
/// **The minimum screen distance between two printings of the SAME text.**
///
/// OSM splits one street into many ways, and [`LabelLayer::from_ways`] anchors a label
/// at each way's centroid, so "Ringvägen" arrives at the placer once per segment. Those
/// printings do not overlap — they are hundreds of px apart along the road — so the
/// collision pass keeps every one of them, and five copies of one name is five times
/// the ink for one fact.
///
/// `250` px lets a genuinely long street carry its name again at the other end of a
/// 1258×728 pane (the packing bound is 6×3 = 18 printings, and a real street reaches
/// two or three), while killing the run of fragments inside one city block.
const LABEL_REPEAT_PX: f32 = 250.0;
/// The pane's label ceiling — [`LABEL_MIN_PX2_EACH`] applied to `rect`. At least 1, so
/// a tiny inset pane still gets its single most important name.
fn label_budget(rect: egui::Rect) -> usize {
((rect.area() / LABEL_MIN_PX2_EACH).floor() as usize).max(1)
}
/// Work budget for one frame's label placement — see
/// [`facett_core::legibility::PlaceOpts::max_considered`]. A 1900×1150 pane fits on the
/// order of 10² labels; 4 000 is several multiples of the physical ceiling, so the
/// truncation only ever takes candidates that were going to be rejected anyway, and it
/// bounds the cost of a viewport holding 80 000 named ways.
const MAX_LABELS_CONSIDERED: usize = 4_000;
/// What a layer's features *are* — drives the renderer's paint choice + z default.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub enum LayerKind {
Line,
Polygon,
Point,
Raster,
Label,
}
impl LayerKind {
pub fn as_str(self) -> &'static str {
match self {
LayerKind::Line => "line",
LayerKind::Polygon => "polygon",
LayerKind::Point => "point",
LayerKind::Raster => "raster",
LayerKind::Label => "label",
}
}
/// Default paint order (small = under): polygons < lines < points < labels.
fn default_z(self) -> i32 {
match self {
LayerKind::Raster => 0,
LayerKind::Polygon => 10,
LayerKind::Line => 20,
LayerKind::Point => 30,
LayerKind::Label => 40,
}
}
}
/// The renderer-agnostic geometry of one drawable feature, in **`(lon, lat)`**
/// (the renderer projects it). Not a source struct — this is the ONLY currency
/// between a [`MapLayer`] and the shared painter.
#[derive(Clone, Debug)]
pub enum DrawGeom {
Point(LatLon),
/// The coordinate vectors ride in an [`Arc`](std::sync::Arc) — shared, not owned.
/// `DrawFeature`s are rebuilt every frame for geometry that has not changed, and
/// while the ear clip is cached ([`DrawFeature::tri`]) the coordinates themselves
/// were still `clone()`d per CPU-owned way per frame — one heap allocation and
/// 16 B per vertex of pure churn (the exact cost [`MapLayer::features_into`]'s
/// zero-copy measurement itemises). The owning layer keeps one `Arc` per ring
/// ([`WaysLayer`]'s `shared_geom` slots, the landmass layer's ring fields) and a
/// feature is a pointer bump; `tests/draw_geom_shared.rs` pins the sharing with
/// `Arc::ptr_eq` across frames. Consumers are unchanged: every reader wants
/// `&[(f64, f64)]` and the `Arc<Vec<_>>` derefs there.
Line(std::sync::Arc<Vec<(f64, f64)>>),
/// A closed **outer** ring (filled if the feature carries a `fill`) plus zero or
/// more **hole** (inner) rings that are SUBTRACTED from the fill — a lake's
/// island, a donut landuse's courtyard. Empty `holes` ⇒ a plain single-ring
/// polygon (the byte-identical common case). The renderer punches the holes via
/// [`crate::tessellate::filled_mesh_with_holes`].
Polygon {
outer: std::sync::Arc<Vec<(f64, f64)>>,
holes: std::sync::Arc<Vec<Vec<(f64, f64)>>>,
},
/// A name/`ref` to paint at `at`, with the two things the collision placer needs
/// beyond the text itself.
Label {
at: LatLon,
text: String,
/// Importance **within** the LOD tier, `0.0..=1.0` — see
/// [`crate::style::LabelClass::rank`]. The tier alone saturates at `City`, so
/// without this every street name in a z12 viewport is ranked identically and
/// the placer's tie-break is the tile decode order.
rank: f32,
/// The labelled feature's **Mercator-unit extent** (the longer side of its
/// bbox), or `0.0` for a point label like a place node. Multiplied by the
/// camera's `world_px` it is the feature's on-screen length, which is what
/// decides whether the name physically fits along the thing it names.
merc_len: f64,
},
/// **A GLOW ribbon** — a path drawn as ONE per-vertex-alpha mesh with a soft
/// cross-section, not as a stack of concentric strokes.
///
/// The stack is what a glow used to be here, and the owner named the result on
/// 2026-09-03: *"its not a soft spline its a flat band with some smoth edges"*.
/// Five source-over strokes composite to `1 − Π(1 − αᵢ)` at the centreline, and the
/// ladder's core pass is `alpha 1.0` — so the middle is OPAQUE and only the outer
/// halo is soft. Nothing shows through it, which is the opposite of what a spirit
/// trace is.
///
/// The profile comes from `facett_core::render::decor::glow_cross_section`, the same
/// `GLOW_LADDER` the stroke passes read, so this is a different projection of one
/// kernel and never a second look. `core_w` is the hot core's width in logical px;
/// the ribbon's full width is the ladder's widest multiple of it.
Glow {
path: std::sync::Arc<Vec<(f64, f64)>>,
/// The hot core's width in logical px — the ladder scales off it.
///
/// There is deliberately NO `night` field beside it. A luminous line is a
/// contrast effect, so the ground IS a required input — but the ground is a
/// property of the PANE, not of the geometry, and the painter reads it from
/// `MapTheme::night`. It briefly lived here, inferred from the way's own colour
/// (luminance sum > 383), which is a fact about the ink and says nothing about
/// what it is drawn on: a bright neon trail read as "night" and burned its core
/// WHITE on the day map's cream `#F2EFE9`.
core_w: f32,
},
}
/// One drawable primitive a layer emits for a viewport. Carries its own resolved
/// colour/width/fill/z so the renderer stays generic (the "better than Garmin"
/// crisp street hierarchy is decided here, per-feature, not in the painter).
#[derive(Clone, Debug)]
pub struct DrawFeature {
/// Stable feature id (osm id / row id) for hit-testing + selection (FC-5).
pub id: i64,
pub geom: DrawGeom,
/// Stroke / point / label colour.
pub color: Color32,
/// Stroke width (logical px at the current zoom).
pub width: f32,
/// Fill colour for a `Polygon` (`None` → outline only).
pub fill: Option<Color32>,
/// **The declared paint order** — larger draws on top; see
/// [`crate::paint_order`] for what the key is made of and for the four
/// mechanisms that used to decide the stack instead of it.
///
/// It was a bare `z: i32` taken from [`LayerKind::default_z`], i.e. one value per
/// KIND: every polygon `10`, every line `20`. The sort on it is stable, so the
/// class order (water under landuse under buildings) survived only as a tie-break
/// inside one kind, and *within* a class the winner of every overlap was whichever
/// way the tile merge appended last. This field's own doc used to promise "a wider
/// way (motorway) draws over a thinner one at the same tier" — it never could.
pub order: crate::paint_order::PaintKey,
/// **The polygon's cached ear clip** — the index triples into
/// [`tessellate::fill_vertices`]`(outer, holes)`, computed once in Mercator space
/// by the owning layer's [`tessellate::FillTessCache`] and valid at every camera
/// (the projection is affine; see that type's note).
///
/// `None` — the default for a layer that keeps no cache, and for every non-polygon
/// feature — means the painter ear-clips this ring itself, exactly as before. This
/// is the ONLY reason the field is on the renderer-agnostic currency struct at all:
/// `DrawGeom::Polygon` carries a *clone* of the ring, so by the time the painter
/// sees it the link back to the `Way` (and to any cache keyed on it) is gone.
///
/// MEASURED cost of not having it, 2026-08-03 on a real 1600×1000 window / RTX
/// 4090 / 20 000 Swedish ways: `tessellate::triangulate` was **36.22 ms of a
/// 42.45 ms frame (85.4 %)**, re-derived from scratch every frame for geometry
/// that had not moved.
pub tri: Option<tessellate::FillTris>,
/// **The feature's LOD tier**, carried through to the painter.
///
/// [`lod_visible`] already gates *whether* a feature reaches the painter, but that
/// gate saturates: [`view_lod_tier`] has three steps and at zoom ≥ 12 every
/// [`Lod::City`] feature passes it. For lines that is fine — they overlap into a
/// road network. For **labels** it is not: every named street in the viewport draws
/// its name at 12 pt and the map becomes grey static (MEASURED on the shipped
/// korp-ui over Stockholm at zoom 12.1 — see `label_collision.rs`).
///
/// So the tier has to survive as far as [`MapFacet::place_map_labels`], which ranks
/// candidates by it: a `Country` place-name outranks a `City` street name for a
/// contested pixel. Non-label layers set their own tier and nothing reads it yet.
pub lod: Lod,
/// **Is this line's core dashed along its arc length?** (HO-T3) — the way's
/// [`WayStyle::dashed`](crate::style::WayStyle::dashed), carried here because the
/// painter sees a `DrawGeom::Line` and not the `Way` it came from. `false` for every
/// point, label and polygon. [`MapFacet::paint_features`] strokes a dashed line's
/// core as one `Shape::line` per lit run ([`dash_polyline`]) at
/// [`crate::style::dash_period_px`] — the same law the GPU lane's `line.wgsl` applies —
/// over the same whole casing.
pub dash: bool,
}
/// The per-layer default style (the layer's identity colour/width/z + its kind).
#[derive(Clone, Copy, Debug)]
pub struct LayerStyle {
pub color: Color32,
pub width: f32,
pub z: i32,
pub kind: LayerKind,
}
impl LayerStyle {
pub fn new(kind: LayerKind, color: Color32, width: f32) -> Self {
Self { color, width, z: kind.default_z(), kind }
}
}
/// **What the GPU lane has taken off the CPU painter this frame**, per layer.
///
/// It was a bare `lines_on_gpu: bool` while the lane could only draw roads. The fill
/// lane makes that a two-valued question — and a bool that means "lines" while a
/// second kind of geometry is also on the device is exactly how a map ends up
/// drawing its lakes twice or not at all. Naming both makes the double-draw a type
/// error rather than a convention.
///
/// [`Self::NONE`] is the pure-CPU frame and reproduces the old `false` byte for byte.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct GpuOwns {
/// The GPU line lane drew this layer's **unfilled** ways.
pub lines: bool,
/// The GPU fill lane drew this layer's **filled** ways — mesh *and* ring outline.
pub fills: bool,
}
impl GpuOwns {
/// Nothing on the device: the CPU painter draws everything.
pub const NONE: Self = Self { lines: false, fills: false };
/// Whether the GPU already drew this geometry, so the CPU painter must not.
/// The ONE predicate — `features_into`'s default body and `WaysLayer`'s
/// zero-copy override both ask it, so the two lanes cannot disagree about who
/// owns a way.
#[must_use]
pub fn owns(self, geom: &DrawGeom) -> bool {
match geom {
DrawGeom::Line(_) => self.lines,
DrawGeom::Polygon { .. } => self.fills,
_ => false,
}
}
}
/// **The GPU lane's SHARE of one frame, in counts** — what
/// [`MapFacet::gpu_lane_share`] answers.
///
/// It exists because the question "is the lane carrying its weight?" was being answered
/// from `LaneClockStats::last_upload_bytes`, and that field is the bytes of **one**
/// upload EVENT. The tile lane calls `note_upload` once per hand-over GROUP, so on a
/// frame with several hundred groups the reported number is whichever group happened to
/// upload last — a per-group figure read as a per-frame one. korp's `FIRST-PAINT` line
/// printed `upload 25 us (1280 bytes)` for a Stockholm viewport carrying 1 264 ways and
/// it was taken as proof the lane owned almost nothing (owner, 2026-08-30, "it feels
/// like my first Turbo Pascal 1996 map"). Bytes cannot answer it; **ways on each side**
/// can, and that is what this carries.
#[cfg(feature = "wgpu")]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LaneShare {
/// The lane the live plan chose, with its reason if it declined.
pub lane: WayLane,
/// How many layers the lane took.
pub layers: usize,
/// Which KINDS it claimed.
pub owns: GpuOwns,
/// The lane's rank floor — where its one opaque slot sits in the declared stack.
pub floor: Option<i32>,
/// Unfilled ways the lane bakes.
pub lane_lines: usize,
/// Filled ways the lane bakes (mesh + ring outline).
pub lane_fills: usize,
/// **Ways still on the CPU painter** — `DrawGeom::Line`/`Polygon` features in the
/// very list [`MapFacet::ui`] paints from. This is the number that decides whether
/// moving work to the device bought anything.
pub cpu_ways: usize,
/// Every CPU feature including labels and points (which have no way and can never
/// join the lane).
pub cpu_feats: usize,
/// `(band, tile)` hand-over groups — one VRAM entry, one cull dispatch and up to
/// three `draw_indirect`s EACH, plus five per-frame uniform writes.
pub groups: usize,
/// Distinct producer-stamped tiles those groups cover.
pub tiles: usize,
}
/// **A source of drawable map data.** The renderer is source-agnostic; sources are
/// **SELECTABLE** (`enabled`) and **STACKABLE** (many in one [`MapFacet`]). This is
/// the one seam every map surface in the constellation draws through.
pub trait MapLayer {
/// Stable id — `"osm-streets"`, `"trafikverket-roads"`, `"korp-<collector>"`.
fn id(&self) -> &str;
/// What the features are (drives paint + default z).
fn kind(&self) -> LayerKind;
/// Query the features intersecting `view` at its zoom/LOD, as renderer-agnostic
/// draw primitives (NOT source structs — keeps the renderer generic).
fn features(&self, view: &MapView) -> Vec<DrawFeature>;
/// **Append this layer's CPU-painter features into `out`, skipping the line
/// features when `lines_on_gpu`** — i.e. when [`MapFacet`] handed this layer to
/// the GPU line lane for the frame.
///
/// This exists for **zero copy**, and the copy it removes is not small.
/// [`DrawGeom::Line`] owns its coordinates, so the old
/// `features(view).into_iter().filter(…)` *built* every road's `(lon, lat)` vector
/// — one heap allocation and 16 B per vertex — and then dropped it again, every
/// frame, for exactly the geometry the GPU already had in VRAM. MEASURED on 20 000
/// real Sweden ways at slippy zoom 6: **2 654 vectors / 130 236 vertices / 2.08 MB
/// allocated and freed per frame**, 42 % of the whole per-frame feature-build
/// allocation, for a payload the CPU painter was forbidden to draw.
///
/// The default body is the old filter, so **every existing implementor is
/// byte-identical** and a layer that has no line features pays nothing new.
/// [`WaysLayer`] overrides it to `continue` *before* the clone, which is where the
/// saving actually is.
fn features_into(&self, view: &MapView, on_gpu: GpuOwns, out: &mut Vec<DrawFeature>) {
out.extend(self.features(view).into_iter().filter(|f| !on_gpu.owns(&f.geom)));
}
/// **How many features this layer would draw for `view` — without BUILDING them.**
///
/// The other half of [`features_into`](Self::features_into)'s zero-copy argument, for
/// the callers that never wanted the geometry in the first place.
/// [`MapFacet::severity`] asks *"does this map draw anything at all?"* and
/// [`MapFacet::state_json`] publishes a COUNT — and both used to answer by building
/// the entire `Vec<DrawFeature>`, cloning every visible way's `(lon, lat)` vector, and
/// then reading `.len()` or `.is_empty()` off it and dropping the lot.
///
/// That is not a hypothetical cost: korp calls `severity()` on **every frame**
/// (`react_to_facet_panes`, whose own doc says the severity check exists "so a healthy
/// frame never pays for a `state_json` build" — while the check itself paid exactly
/// that) and `state_json()` **3.3 times a second** (`dump_if_due`, every 300 ms).
///
/// The default body keeps the old answer for every implementor, so nothing can drift;
/// [`WaysLayer`] and [`PointsLayer`] override it to count through the SAME per-feature
/// visibility predicate their `features_into` draws through.
fn feature_count(&self, view: &MapView) -> usize {
self.features(view).len()
}
/// The layer's identity style (the renderer falls back to it per feature).
fn style(&self) -> &LayerStyle;
/// User toggle — is this data drawn? (Selectable data-to-draw.)
fn enabled(&self) -> bool;
/// Flip the toggle (runtime selection).
fn set_enabled(&mut self, on: bool);
/// **Does this layer declare where the LAND is?** Only [`LandmassLayer`] does.
///
/// [`MapFacet::background`] reads it to pick the pane wash, which is the whole
/// point: a stack that carries a landmass silhouette has *said* where the land
/// is, so every pixel outside it is SEA. Default `false`, so no other
/// implementor has to care.
fn is_landmass(&self) -> bool {
false
}
/// **The layer's pre-decoded [`Way`]s, for the GPU lane** — `None` for a layer
/// that has none (points, labels, a landmass silhouette).
///
/// This is the seam that made [`MapFacet`]'s GPU path possible at all. The
/// renderer-agnostic [`DrawFeature`] currency is deliberately lossy — it hands
/// the painter `(lon, lat)` vectors and a resolved colour — and the GPU line
/// lane needs the `Way` itself: its precomputed Mercator bbox (the compute
/// cull's input), its [`Lod`](crate::style::Lod) tier and its themed raw width,
/// all baked once into VRAM rather than rebuilt per frame. Without this method
/// `facett_map::gpu::GpuMapRenderer` was **installed and unreachable by
/// construction** in korp: the renderer went into the `RenderState`, the About
/// box said so, and `MapFacet::ui` was a pure CPU painter that never referenced
/// it — 0 frames encoded, every pane on one core.
///
/// Returning `Some` does NOT mean the GPU draws them: [`MapFacet`] only takes a
/// layer's *unfilled* ways, only when a renderer is installed, and only for a
/// flat north-up camera (see [`MapFacet::gpu_lane_layers`]). Everything else
/// falls back to the CPU painter, which is still the one that draws these very
/// ways via [`features`](Self::features).
fn gpu_ways(&self) -> Option<&[Way]> {
None
}
/// **The layer's ways PLUS the cached Mercator ear clip for each — the GPU FILL
/// lane's input.** `None` for a layer that carries no [`tessellate::FillTessCache`].
///
/// Why it is a *pair* and not just the ways: the fill lane must not ear-clip.
/// The triangulation was 36.22 ms of a 42.45 ms frame before it was cached, and a
/// bake path that re-derived it would put that cost straight back — this time on
/// the frame that uploads. So the lane reads the SAME `OnceLock` slots the CPU
/// painter fills, keyed by the way's index in the slice, and skips a way whose
/// slot is still empty (it paints on the CPU that frame and joins the lane on the
/// next bake). The cache is per-layer and built beside its ring set, so the index
/// correspondence is structural, not a convention someone has to maintain.
///
/// Returning `Some` does NOT mean the GPU draws them — see
/// [`MapFacet::way_lane_plan`].
fn gpu_fills(&self) -> Option<(&[Way], &tessellate::FillTessCache)> {
None
}
/// **How many ear clips this layer has ever run** — `0` for a layer that carries
/// no fills (and for one that carries no [`tessellate::FillTessCache`]).
///
/// This is a MEASUREMENT seam, and it exists because the claim it proves cannot
/// be proved any other way. "The fill triangulation is cached" is a statement
/// about work that did **not** happen, and a frame that paints the right pixels
/// looks identical whether the indices came from a cache or from an O(n²) ear
/// clip run again — which is exactly how 36.22 ms of a 42.45 ms frame hid in
/// plain sight. A test can drive the camera for a hundred frames and read this:
/// cached ⇒ it stops at the number of distinct areas painted; not cached ⇒ it
/// climbs with every frame. Nothing about the rendered output distinguishes the
/// two, so nothing about the rendered output can be the guard.
fn ear_clips(&self) -> usize {
0
}
/// **A process-unique stamp for this layer's way GEOMETRY** — nonzero means "my
/// [`gpu_ways`](Self::gpu_ways)/[`gpu_fills`](Self::gpu_fills) slice is immutable
/// for my whole lifetime, and this number names it". [`WaysLayer`] stamps one per
/// construction (its `ways` are built beside it and never mutated — a new way set
/// is a new layer), which is what lets [`MapFacet::gpu_geometry_signature`] answer
/// from an `O(layers)` memo instead of re-walking every way's bbox twice per
/// frame. Default `0` = "no such promise": a `0` from any taken layer disables
/// the memo and the signature is recomputed exactly as before, so an external
/// implementor that mutates in place stays correct, just unmemoised.
fn geometry_generation(&self) -> u64 {
0
}
}
// ── MapTheme — the shared "better than Garmin" look ──────────────────────────
/// The one place the map look lives: a day/night [`MapPalette`] plus label +
/// tilt-shading knobs, so **all** maps inherit crisp street hierarchy, smooth
/// labels and a pitched-horizon fade. Derived from a [`facett_core::Theme`] so the
/// map re-themes with the rest of the deck.
#[derive(Clone, Debug)]
pub struct MapTheme {
/// The **land** ("paper") fill — the colour a [`LandmassLayer`] paints the
/// country silhouette in, and the pane wash for a stack that carries no
/// landmass at all (nothing has said where the land is, so the pane *is* paper).
pub land: Color32,
/// The **sea** — the pane wash for a stack that DOES carry a landmass silhouette.
///
/// This field exists because it was missing: `land` was both the pane wash and
/// the landmass fill, so korp's Sweden polygon was painted in exactly the colour
/// of the pane behind it. Measured on the shipped day theme: `land` and the wash
/// were the same `#1f1f26`, 95.63 % of the map canvas was that one value, and the
/// whole 20 000-way payload had nothing to read against. Land and sea must be two
/// colours or a coastline cannot exist.
pub sea: Color32,
pub palette: MapPalette,
/// Street / place label INK.
///
/// It used to be the host `Theme::text`, which put `#DCDCDC` app-chrome grey on
/// `#F2EFE9` cartographic paper — a **1.18 : 1** contrast ratio, measured on the
/// shipped korp-ui. Now [`MapPalette::label`], derived from the palette's own land,
/// so a light map gets dark ink and a dark map gets light ink by construction.
pub label: Color32,
/// The thin outline drawn around label text ([`MapPalette::label_halo`]) so a name
/// crossing a dark road casing or a lake is still readable. The paper colour — it
/// shows only where the text is not already on paper.
pub label_halo: Color32,
/// `true` when this is the night variant (dark paper, glowing streets).
pub night: bool,
}
impl MapTheme {
/// Derive the day look from a facett [`Theme`](facett_core::Theme).
/// **The CARTOGRAPHIC look** — a real paper map, independent of the app theme:
/// warm land, calm blue water, green parks, an orange→yellow→white road ramp.
///
/// This exists because [`day`](Self::day) derives its palette from the host's
/// `facett_core::Theme`, so a host whose theme is dark gets a dark "day" map — land at
/// `#1f1f26` against a `#1f1f26` pane, which is no map at all. korp rendered exactly
/// that: 30 % land, 0 % sea, and a country with no coastline. The fixed hexes in
/// [`MapPalette::cartographic`] are the point — the map looks like a map under any
/// theme, which is what a navigator has to do.
///
/// `label` comes from the PALETTE, not the host theme: a cartographic map is not
/// app chrome, and taking `Theme::text` printed korp's dark-theme `#DCDCDC` on
/// `#F2EFE9` paper at a 1.18 : 1 contrast ratio — see [`MapPalette::label`].
pub fn cartographic(_theme: &facett_core::Theme, day: bool) -> Self {
let palette = crate::style::MapPalette::cartographic(day);
Self {
palette,
land: palette.land,
sea: palette.water,
label: palette.label(),
label_halo: palette.label_halo(),
night: !day,
}
}
pub fn day(theme: &facett_core::Theme) -> Self {
let palette = MapPalette::from_theme(theme);
// The sea IS water — reuse the palette's water hue rather than mint a second
// blue, so a lake and the open sea are one colour (what a real map does) and
// a cartographic preset swaps both at once.
Self {
palette,
land: palette.land,
sea: palette.water,
label: palette.label(),
label_halo: palette.label_halo(),
night: false,
}
}
/// The night look — a darker paper with the same palette family (glowing
/// streets read better than Garmin's flat night mode).
pub fn night(theme: &facett_core::Theme) -> Self {
let palette = MapPalette::from_theme(theme);
// The one night-dim formula, applied to BOTH the land and the sea — hoisted
// into a closure so the two can never drift apart into an identity again.
let dim = |c: Color32| {
Color32::from_rgb(
(c.r() as f32 * 0.35) as u8,
(c.g() as f32 * 0.35) as u8,
(c.b() as f32 * 0.45) as u8,
)
};
let land = dim(palette.land);
Self {
palette,
land,
sea: dim(palette.water),
// The night look DIMS the land, so the ink must be resolved against the
// dimmed paper, not the palette's daytime one — a `label()` read off the
// undimmed palette is the identity-value trap in colour form.
label: MapPalette { land, ..palette }.label(),
label_halo: land,
night: true,
}
}
}
impl Default for MapTheme {
fn default() -> Self {
Self::day(&facett_core::Theme::default())
}
}
// ── Concrete reusable layers (NO re-parse; feed pre-decoded data) ────────────
/// One way's coordinates as shared `Arc`s: the outer ring/line and its holes.
type SharedGeom = (std::sync::Arc<Vec<(f64, f64)>>, std::sync::Arc<Vec<Vec<(f64, f64)>>>);
/// A **ways** layer — the OSM-streets / Trafikverket-roads source. Holds
/// pre-decoded [`Way`]s (from `facett-osm`'s GeoParquet reader, or any other
/// source that produces `Way`s) and yields one [`DrawFeature`] per way, culled to
/// the viewport. **Source-agnostic:** construct one with `id = "osm-streets"` and
/// another with `id = "trafikverket-roads"` — same type, both selectable at
/// runtime. No geometry is re-parsed; the `Way`s carry their resolved style.
pub struct WaysLayer {
id: String,
/// The ways — a SHARED, contiguous view into a slab (see [`WaySlab`]). A layer
/// built with [`WaysLayer::new`] owns the whole slab; one built with
/// [`WaysLayer::shared`] is a window onto a slab a host keeps, so a facet
/// rebuild over retained geometry copies nothing.
ways: WaySlab,
style: LayerStyle,
enabled: bool,
/// **The ear clip of every filled way in this layer**, one slot per `ways` index,
/// filled on the first frame that paints each area and never again.
///
/// Invalidated **by construction**: it is built in [`WaysLayer::new`] beside the
/// `ways` it belongs to and has exactly as many slots, so a new way set is a new
/// layer is a new cache. There is no camera in the key because there must not be
/// one — see [`tessellate::FillTessCache`].
fill_tess: tessellate::FillTessCache,
/// This layer's [`MapLayer::geometry_generation`] stamp, taken once in
/// [`WaysLayer::new`] from a process counter. Valid on the same by-construction
/// terms as `fill_tess` above: the `ways` are immutable for the layer's lifetime
/// (korp retains the facet across pans and replaces the LAYER on data change), so
/// "same generation" ⇒ "same geometry" needs no per-way walk to check.
generation: u64,
/// **The shared-coordinate slots behind [`DrawGeom`]'s `Arc`s** — one per `ways`
/// index, filled the FIRST frame that emits the way and a pointer bump ever
/// after. The one-time fill is the same single `lonlat`/`holes` clone the old
/// per-frame build paid on every frame; the slot then owns it. Invalidated on
/// the same by-construction terms as `fill_tess` above (built beside its own
/// way set, no camera in the key).
shared_geom: Vec<std::sync::OnceLock<SharedGeom>>,
}
/// **A shared, contiguous range of ways** — what a [`WaysLayer`] holds.
///
/// facett's layer boundary reads `&[Way]` (the GPU lane bakes straight off the
/// slice, the tessellation cache is indexed by position in it), which is why a
/// `WaysLayer::new` takes an owned `Vec<Way>` — and why every host that RETAINS its
/// classified ways and rebuilds the facet on each apply used to deep-copy the whole
/// set into the layers, once per class (korp: "the facet rebuild deep-clones the way
/// set x4 per apply", GFX_V2 shit list #2). A slab is the same `&[Way]` contract over
/// an `Arc<[Way]>` + a range: the host lays its ways out once, class-sorted, and each
/// class layer is a pointer bump and two indices. `Deref<Target = [Way]>`, so the
/// layer code reads it exactly as it read the `Vec`.
#[derive(Clone, Debug)]
pub struct WaySlab {
slab: std::sync::Arc<[Way]>,
range: std::ops::Range<usize>,
}
impl WaySlab {
/// A view of `slab[range]`; the range is clamped to the slab.
#[must_use]
pub fn new(slab: std::sync::Arc<[Way]>, range: std::ops::Range<usize>) -> Self {
let end = range.end.min(slab.len());
let start = range.start.min(end);
Self { slab, range: start..end }
}
/// The whole slab this view is cut from.
#[must_use]
pub fn slab(&self) -> &std::sync::Arc<[Way]> {
&self.slab
}
/// This view's range within [`Self::slab`].
#[must_use]
pub fn range(&self) -> std::ops::Range<usize> {
self.range.clone()
}
}
impl std::ops::Deref for WaySlab {
type Target = [Way];
fn deref(&self) -> &[Way] {
&self.slab[self.range.clone()]
}
}
impl From<Vec<Way>> for WaySlab {
fn from(ways: Vec<Way>) -> Self {
let n = ways.len();
Self::new(std::sync::Arc::from(ways), 0..n)
}
}
/// The [`WaysLayer::generation`] counter — starts at 1 so `0` stays the trait's
/// "no promise" default forever.
static WAYS_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
impl WaysLayer {
/// Wrap pre-decoded ways as a selectable layer (the layer owns its slab).
pub fn new(id: impl Into<String>, ways: Vec<Way>) -> Self {
Self::from_slab(id, WaySlab::from(ways))
}
/// **A layer over a SHARED slab** — `slab[range]`, no copy. The host keeps the
/// slab (class-sorted, one `Arc<[Way]>`) and hands every class layer its range;
/// the tessellation cache and the shared-geometry slots are sized to the range and
/// indexed by position in it, exactly as for an owned layer. Same by-construction
/// invalidation: a new slab is a new layer is a new cache.
pub fn shared(id: impl Into<String>, slab: std::sync::Arc<[Way]>, range: std::ops::Range<usize>) -> Self {
Self::from_slab(id, WaySlab::new(slab, range))
}
fn from_slab(id: impl Into<String>, ways: WaySlab) -> Self {
let mut shared_geom = Vec::new();
shared_geom.resize_with(ways.len(), std::sync::OnceLock::new);
Self {
id: id.into(),
fill_tess: tessellate::FillTessCache::new(ways.len()),
shared_geom,
ways,
style: LayerStyle::new(LayerKind::Line, Color32::GRAY, 1.0),
enabled: true,
generation: WAYS_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
}
}
/// Way `i`'s coordinates as the layer's SHARED `Arc`s (see `shared_geom`) —
/// cloned from the `Way` once, on the first frame that needs them.
fn shared_geom(&self, i: usize, w: &Way) -> SharedGeom {
let slot = &self.shared_geom[i];
let (outer, holes) =
slot.get_or_init(|| (std::sync::Arc::new(w.lonlat.clone()), std::sync::Arc::new(w.holes.clone())));
(outer.clone(), holes.clone())
}
/// The wrapped ways (read-only) — for hit-testing / counts.
pub fn ways(&self) -> &[Way] {
&self.ways[..]
}
/// The slab this layer's ways are a view of (and its range) — `Arc::ptr_eq` on
/// it is how a host proves a rebuild shared rather than copied.
#[must_use]
pub fn slab(&self) -> &WaySlab {
&self.ways
}
/// **How many ear clips this layer has ever run** — the applied-output fact that
/// separates "cached" from "re-tessellated every frame". Painting the same areas
/// for a thousand frames must leave this at the number of distinct areas painted;
/// a cache that quietly stopped caching moves it every frame.
#[must_use]
pub fn ear_clips(&self) -> usize {
self.fill_tess.ear_clips()
}
/// **THE per-way visibility test — the LOD gate and the viewport cull, in one
/// place.**
///
/// Both consumers go through it: [`features_into`](MapLayer::features_into), which
/// draws the way, and [`feature_count`](MapLayer::feature_count), which only counts
/// it. Written as one function rather than two loops because a counter that culled
/// differently from the painter is a `state_json` "features" number about a map
/// nobody is looking at — and it would be green in every test that reads only one of
/// them.
///
/// * the LOD gate honours the trait contract ("features intersecting the viewport at
/// a zoom/LOD"), the [`Lod`](crate::style::Lod) style law and the GPU cull
/// (`gpu/cull.wgsl`: a way draws only when `lod <= viewport_lod`). Without it the
/// CPU path drew city-LOD ways (residential/service roads, buildings) at country
/// zoom — the very culling the GPU lane already did. The comparison itself lives
/// once more, in [`lod_visible`].
/// * the bbox test skips ways whose Mercator envelope misses the viewport.
/// * the **minimum projected extent** drops an AREA smaller than
/// [`crate::style::MIN_FILL_EXTENT_PX`] on screen (stinky `kalm-3`) — 43–47 % of
/// korp's z12 way budget was building footprints ~1.7 px across. Areas only: a
/// line's bbox extent says nothing about whether the line is worth drawing, so no
/// road network can be broken by it. The threshold comes from
/// [`crate::style::min_fill_extent_merc`], the SAME function whose answer is
/// handed to `gpu/cull.wgsl` as `ViewportCull::min_fill_extent` — one rule, two
/// lanes, or the map draws its lakes twice.
fn draws(w: &Way, view: &MapView, [x0, y0, x1, y1]: [f64; 4]) -> bool {
if !lod_visible(w.style.lod, view) {
return false;
}
// The viewport, widened by the widest stroke the painter can draw
// (`seam-15`): a way's bbox is its CENTRELINE's envelope, so a motorway whose
// centreline misses the pane by a pixel would otherwise lose its whole 24 px
// ribbon. The SAME number `MapTransform::viewport_bbox` widens the GPU cull by.
let pad = crate::style::cull_pad_merc(view.world_px());
if w.bbox[2] < x0 - pad || w.bbox[0] > x1 + pad || w.bbox[3] < y0 - pad || w.bbox[1] > y1 + pad {
return false;
}
if w.fill.is_some() {
let min_ext = f64::from(crate::style::min_fill_extent_merc(view.world_px()));
let ext = (w.bbox[2] - w.bbox[0]).max(w.bbox[3] - w.bbox[1]);
if min_ext > 0.0 && ext < min_ext {
return false;
}
}
true
}
}
impl MapLayer for WaysLayer {
fn id(&self) -> &str {
&self.id
}
fn kind(&self) -> LayerKind {
LayerKind::Line
}
/// ONE body, two callers — [`features`](Self::features) is this with
/// `lines_on_gpu = false` and a fresh `Vec`, so the cull, the LOD gate and the
/// style resolution cannot drift between the pure-CPU frame and the GPU-lane
/// frame (LAW #5).
fn features(&self, view: &MapView) -> Vec<DrawFeature> {
let mut out = Vec::new();
self.features_into(view, GpuOwns::NONE, &mut out);
out
}
fn features_into(&self, view: &MapView, on_gpu: GpuOwns, out: &mut Vec<DrawFeature>) {
let bb = view.merc_bbox();
for (i, w) in self.ways.iter().enumerate() {
let filled = w.fill.is_some();
// ── ZERO COPY: the GPU lane owns this way's geometry this frame, so do
// not materialise it. An unfilled `Way` is exactly what
// `MapFacet::gpu_lane_ways` hands to `build_gpu_geometry`, and exactly
// what the old `features_for_cpu` filter threw away AFTER
// `w.lonlat.clone()` had already run. Skipping here — before the clone
// and before the bbox test — is the whole point of the method.
//
// A FILLED way is skipped on the same terms once the fill lane has it,
// and that is where the saving now is: with the ear clip cached, what
// was left of the fill frame was this clone plus one `tan` and one
// `ln` per vertex in `MapView::project` — 7.99 ms of a 10.08 ms
// fills-only frame, MEASURED with the mesh and the outline both
// suppressed. `MapFacet::gpu_lane_fills` applies the SAME predicate on
// the bake side (a cached triangulation, ≥ 3 vertices), so the two
// sides cannot disagree about which lakes are on the device.
if filled && on_gpu.fills && MapFacet::gpu_fillable(w) {
continue;
}
if on_gpu.lines && !filled {
continue;
}
if !Self::draws(w, view, bb) {
continue;
}
// The layer's SHARED ring `Arc`s — a pointer bump per frame, not a
// re-clone of every vertex (see `shared_geom` / `DrawGeom::Line`).
let (outer, holes) = self.shared_geom(i, w);
out.push(DrawFeature {
id: w.osm_id,
geom: if w.style.kind == crate::WayKind::Glow {
// A trail is a RIBBON, not a stroke — see `glow_ribbon_mesh`. The
// way's width is the hot core's; the ladder scales the halo off it.
DrawGeom::Glow {
path: outer,
core_w: w.style.width,
}
} else if filled {
// Carry the inner (hole) rings through to the renderer so a
// lake-with-island / donut landuse fills outer MINUS holes
// (they were dropped here before, filling the island over).
DrawGeom::Polygon { outer, holes }
} else {
DrawGeom::Line(outer)
},
color: w.style.color,
width: w.style.width,
fill: w.fill,
// ONE declared order, computed from the WAY — its class, its OSM
// `layer`/`bridge`/`tunnel`, its LOD tier and its area — never from
// its index in this vector. `crate::paint_order::way_paint_key` is
// the same writer the GPU lanes sort on.
order: crate::paint_order::way_paint_key(w),
// The cached ear clip, computed on THIS way's Mercator ring the first
// time it is painted. Only fills need one; a line has no mesh.
tri: if filled { self.fill_tess.tris(i, &w.lonlat, &w.holes) } else { None },
lod: w.style.lod,
dash: w.style.dashed(),
});
}
}
fn style(&self) -> &LayerStyle {
&self.style
}
fn enabled(&self) -> bool {
self.enabled
}
fn set_enabled(&mut self, on: bool) {
self.enabled = on;
}
/// Count through [`WaysLayer::draws`] — the SAME predicate
/// [`features_into`](Self::features_into) draws through — so the number
/// `state_json` publishes is the number the painter would produce, and neither
/// `w.lonlat` nor `w.holes` is cloned to obtain it.
fn feature_count(&self, view: &MapView) -> usize {
let bb = view.merc_bbox();
self.ways.iter().filter(|w| Self::draws(w, view, bb)).count()
}
/// **The one layer kind the GPU line lane can consume.** Handing the `Way`s over
/// (not the lossy [`DrawFeature`]s) is what lets [`MapFacet`] route them through
/// `facett_map::gpu::GpuMapRenderer` instead of tessellating them on one core.
fn gpu_ways(&self) -> Option<&[Way]> {
Some(&self.ways[..])
}
/// The same ways, paired with the ear-clip cache the GPU **fill** lane bakes off.
/// One slice, one cache, built together in [`WaysLayer::new`] — so the index a
/// `Way` has here is the index its triangulation has there, structurally.
fn gpu_fills(&self) -> Option<(&[Way], &tessellate::FillTessCache)> {
Some((&self.ways[..], &self.fill_tess))
}
fn ear_clips(&self) -> usize {
self.fill_tess.ear_clips()
}
fn geometry_generation(&self) -> u64 {
self.generation
}
}
/// A **points** layer — a korp-collector's geo output overlaid on the base map.
/// Holds `(lon, lat)` points and yields one point [`DrawFeature`] each (culled to the
/// viewport, and gated by the layer's [`Lod`] tier — see [`PointsLayer::with_lod`]).
pub struct PointsLayer {
id: String,
points: Vec<(f64, f64)>,
/// The tier the whole layer belongs to — the LOD discipline its sibling
/// [`WaysLayer`] always had and this layer did not.
lod: Lod,
style: LayerStyle,
enabled: bool,
}
impl PointsLayer {
/// A points layer at the [`Lod::Country`] tier — drawn at **every** zoom, which is
/// what a small set of hand-placed markers (a collector's boats, korp's own hits)
/// wants. A BASEMAP node payload wants [`with_lod`](Self::with_lod) instead.
///
/// **The payload is put in CANONICAL order at the door** — sorted by position
/// (`total_cmp`, so NaN cannot poison the order), and each point's feature id is its
/// index in that order. A bare `(lon, lat)` carries no osm id, so its position is
/// the only identity it has; imposing it here is what makes "swap the decode order,
/// get identical pixels" true for points the way [`crate::paint_order`] made it true
/// for ways. Before this every point went out as `id: 0` with the identical
/// [`PaintKey`](crate::paint_order::PaintKey) — one layer-wide tie the stable sort
/// resolved by INPUT order, i.e. by tile-fetch interleaving: the unsorted-fills
/// defect, on the point half.
pub fn new(id: impl Into<String>, mut points: Vec<(f64, f64)>, color: Color32) -> Self {
points.sort_unstable_by(|a, b| a.0.total_cmp(&b.0).then_with(|| a.1.total_cmp(&b.1)));
Self {
id: id.into(),
points,
lod: Lod::Country,
style: LayerStyle::new(LayerKind::Point, color, 2.5),
enabled: true,
}
}
/// **Tier the layer**, so it declutters by zoom like every other layer.
///
/// Points are the one layer kind with no per-feature tier — a raw `(lon, lat)`
/// carries no tags to classify — so the tier is a property of the LAYER, declared
/// by the host that knows what the payload is. `Lod::City` is the honest tier for
/// an OSM basemap node dump: 40 000 untagged nodes are city-scale detail, and
/// before this existed they painted over a whole country at every zoom while the
/// ways beside them culled to 19.6 % of their payload.
///
/// A count cap would be the wrong knob — it drops arbitrary places. This drops
/// *detail*, and gives it all back on zoom-in.
#[must_use]
pub fn with_lod(mut self, lod: Lod) -> Self {
self.lod = lod;
self
}
/// The tier this layer draws at.
pub fn lod(&self) -> Lod {
self.lod
}
/// The payload, in the layer's canonical (position-sorted) order — the order
/// feature ids index into, not the order the constructor was handed.
pub fn points(&self) -> &[(f64, f64)] {
&self.points
}
/// **THE per-point visibility test — the layer LOD tier plus the viewport cull.**
/// One writer, for the same reason [`WaysLayer::draws`] is one: the painter and the
/// counter must agree, and a `state_json` count that culled differently would be a
/// number about a different map.
fn draws(&self, (lon, lat): (f64, f64), [x0, y0, x1, y1]: [f64; 4]) -> bool {
let (x, y) = mercator(lon, lat);
x >= x0 && x <= x1 && y >= y0 && y <= y1
}
}
impl MapLayer for PointsLayer {
fn id(&self) -> &str {
&self.id
}
fn kind(&self) -> LayerKind {
LayerKind::Point
}
fn features(&self, view: &MapView) -> Vec<DrawFeature> {
// LOD gate FIRST — the same [`lod_visible`] `WaysLayer` and `LabelLayer` use.
// Without it this layer culled on the viewport bbox ONLY, so an OSM basemap
// node payload drew every one of its 40 000 dots at country zoom and
// out-covered the entire way payload on the canvas (1.93 % of pixels vs the
// ways' ≲1.8 %). Bailing before the per-point projection also makes the
// decluttered case free rather than merely invisible.
if !lod_visible(self.lod, view) {
return Vec::new();
}
let bb = view.merc_bbox();
self.points
.iter()
// Enumerated BEFORE the cull, so a point's id is its index in the layer's
// canonical order (see `new`) — a stable identity whatever the viewport,
// not a per-frame count of whatever happened to be visible. It doubles as
// the `PaintKey` tie-break, so two points with the same band and tier stack
// the same way in every frame and every process instead of by decode order.
.enumerate()
.filter(|&(_, &p)| self.draws(p, bb))
.map(|(i, &(lon, lat))| DrawFeature {
id: i as i64,
geom: DrawGeom::Point(LatLon::new(lat, lon)),
color: self.style.color,
width: self.style.width,
fill: None,
order: crate::paint_order::PaintKey::simple(
crate::paint_order::POINT_BAND,
self.lod,
i as i64,
),
// A point has no ring to tessellate.
tri: None,
lod: self.lod,
dash: false,
})
.collect()
}
fn style(&self) -> &LayerStyle {
&self.style
}
fn enabled(&self) -> bool {
self.enabled
}
fn set_enabled(&mut self, on: bool) {
self.enabled = on;
}
/// Count through [`PointsLayer::draws`], allocating nothing. A served basemap node
/// payload is 40 000 points, and `severity()` asked for all 40 000 `DrawFeature`s
/// every frame to learn whether the number was zero.
fn feature_count(&self, view: &MapView) -> usize {
if !lod_visible(self.lod, view) {
return 0;
}
let bb = view.merc_bbox();
self.points.iter().filter(|&&p| self.draws(p, bb)).count()
}
}
// ── LabelLayer — the name/ref text pass (the biggest Garmin-feel lever) ──────
/// The z-order floor for the **landmass base fill** — below every other feature
/// (raster `0`, polygon `10`, line `20`, point `30`, label `40`) so the land
/// silhouette paints *first*, under water/landuse/roads. A dedicated constant (not
/// [`LayerKind::Raster`]'s `0`) so a future aerial-raster base can still slot under
/// the land and the ordering stays unambiguous.
pub const LANDMASS_Z: i32 = crate::paint_order::LANDMASS_BAND;
/// One resolved label placement: a representative anchor point, the text, and the
/// [`Lod`] tier it first appears at (the declutter gate). Built from a feature's
/// `(anchor, tags)` via [`classify_label`], or from a decoded [`Way`]'s road name.
#[derive(Clone, Debug)]
pub struct LabelPlacement {
/// Stable feature id (osm id / row id) for hit-testing.
pub id: i64,
/// Where the label anchors (a place node, or a way's centroid).
pub at: LatLon,
/// The text painted (a route `ref` shield, else a place/street `name`).
pub text: String,
/// The zoom tier this label first appears at (country → region → city).
pub lod: Lod,
/// Importance within the tier (`0..=1`) — see [`crate::style::LabelClass::rank`].
pub rank: f32,
/// The labelled feature's Mercator-unit extent, `0.0` for a point label.
pub merc_len: f64,
/// The anchor pre-projected to Mercator-unit space (for viewport culling).
merc: (f64, f64),
}
/// **The name/ref LABEL layer — the single biggest Garmin-feel lever.** A text pass
/// that renders road refs + place/street `name`s from the OSM `name`/`ref` tags the
/// tiles carry, **LOD-gated** (country → region → city) so labels declutter by zoom
/// exactly like the way/fill layers already do (the [`Lod`] style law + the CPU
/// [`view_lod_tier`] gate). Stackable + selectable like every other [`MapLayer`];
/// paints on top ([`LayerKind::Label`], z `40`).
pub struct LabelLayer {
id: String,
labels: Vec<LabelPlacement>,
style: LayerStyle,
enabled: bool,
}
/// Centroid (arithmetic mean) of a `(lon, lat)` vertex loop — a good-enough label
/// anchor for a way (a road's midpoint, an area's middle). Empty ⇒ `(0, 0)`.
fn centroid_lonlat(pts: &[(f64, f64)]) -> LatLon {
if pts.is_empty() {
return LatLon::new(0.0, 0.0);
}
let (mut sx, mut sy) = (0.0f64, 0.0f64);
for &(lon, lat) in pts {
sx += lon;
sy += lat;
}
let n = pts.len() as f64;
LatLon::new(sy / n, sx / n)
}
impl LabelLayer {
fn label_style() -> LayerStyle {
LayerStyle { color: Color32::GRAY, width: 0.0, z: LayerKind::Label.default_z(), kind: LayerKind::Label }
}
/// Build a label layer from **tagged features**: each `(id, anchor, tags)` whose
/// tags carry a labellable `name`/`ref` ([`classify_label`]) becomes one
/// LOD-gated label; features with no label tag are skipped. This is the primary
/// path — it consumes the raw `name`/`ref`/`place` tags the tiles carry.
pub fn from_tagged(
id: impl Into<String>,
feats: impl IntoIterator<Item = (i64, LatLon, Vec<(String, String)>)>,
) -> Self {
let labels = feats
.into_iter()
.filter_map(|(fid, at, tags)| {
classify_label(&tags).map(|lc| {
let (mx, my) = mercator(at.lon, at.lat);
// A tagged feature is a POINT here (a place node, a `ref` shield):
// it has no extent of its own, so the "does the name fit along the
// thing it names" rule does not apply and `merc_len` is 0.
LabelPlacement { id: fid, at, text: lc.text, lod: lc.lod, rank: lc.rank, merc_len: 0.0, merc: (mx, my) }
})
})
.collect();
Self { id: id.into(), labels, style: Self::label_style(), enabled: true }
}
/// Convenience: harvest labels from an already-decoded [`Way`] set — each way's
/// road `name` (roads are the only kind that keep it) placed at the way's
/// centroid. Lets a host that already built a [`WaysLayer`] surface street-name
/// labels with **no re-parse** (the korp path).
///
/// # The label's tier is NOT the way's tier (`streetlabel-1`)
///
/// Every label here is a street NAME, and a street name is [`Lod::City`] by design —
/// [`classify_label`] says so for the tagged path (*"its street `name` at
/// `Lod::City` (street names only once zoomed in)"*), and this crate's own
/// `features()` doc says so for the draw (*"street names only in the city tier"*).
/// This constructor used to inherit `w.style.lod` instead — and a motorway or
/// primary road is `Lod::Country` — so "Pildammsvägen", "Skolgatan" and every
/// roundabout name on an arterial painted on a WHOLE-SWEDEN pane. MEASURED on
/// The owner's live client 2026-09-01 at camera 6.7: `labels` fed 37 492, drawn 3 304
/// — thousands of street names on a country view. So the way's tier is a FLOOR ONLY
/// (a residential street stays City); the label never rises above [`Lod::City`].
/// The way's tier still feeds [`label_rank_of_style`], so a motorway's name beats a
/// cul-de-sac's for a contested pixel once the City rung opens.
pub fn from_ways(id: impl Into<String>, ways: &[Way]) -> Self {
let labels = ways
.iter()
.filter(|w| !w.name.is_empty())
.map(|w| {
let at = centroid_lonlat(&w.lonlat);
let (mx, my) = mercator(at.lon, at.lat);
// The way's own bbox, already decoded — its longer side is the extent
// the name has to fit along. `w.bbox` is `[x0, y0, x1, y1]` in
// Mercator units, the same space `MapView::world_px` scales to pixels.
let merc_len = (w.bbox[2] - w.bbox[0]).max(w.bbox[3] - w.bbox[1]).max(0.0);
LabelPlacement {
id: w.osm_id,
at,
text: w.name.clone(),
// City-FLOORED, never the raw way tier: a motorway way is
// `Lod::Country`, its NAME is a street name (`streetlabel-1`,
// see the constructor doc). `from_u32` saturates, so a
// `Detail`-tier way keeps its own deeper tier.
lod: Lod::from_u32((w.style.lod as u32).max(Lod::City as u32)),
// The SAME rank `classify_label` gives a tagged way, resolved from
// the way's already-classified style — no re-parse, no second table.
rank: crate::style::label_rank_of_style(&w.style),
merc_len,
merc: (mx, my),
}
})
.collect();
Self { id: id.into(), labels, style: Self::label_style(), enabled: true }
}
/// Total labels carried (undecimated; before any LOD/viewport gate).
pub fn label_count(&self) -> usize {
self.labels.len()
}
}
impl MapLayer for LabelLayer {
fn id(&self) -> &str {
&self.id
}
fn kind(&self) -> LayerKind {
LayerKind::Label
}
fn features(&self, view: &MapView) -> Vec<DrawFeature> {
let [x0, y0, x1, y1] = view.merc_bbox();
self.labels
.iter()
// LOD gate (the declutter law): a label draws only once the viewport has
// zoomed to at least its tier — country labels everywhere, street names
// only in the city tier. The SAME [`lod_visible`] every other layer uses.
.filter(|l| lod_visible(l.lod, view))
// Viewport cull: drop anchors outside the pane envelope.
.filter(|l| l.merc.0 >= x0 && l.merc.0 <= x1 && l.merc.1 >= y0 && l.merc.1 <= y1)
.map(|l| DrawFeature {
id: l.id,
geom: DrawGeom::Label { at: l.at, text: l.text.clone(), rank: l.rank, merc_len: l.merc_len },
color: self.style.color,
width: 0.0,
fill: None,
order: crate::paint_order::PaintKey::simple(
crate::paint_order::LABEL_BAND,
l.lod,
l.id,
),
// A label has no ring to tessellate.
tri: None,
// The tier the collision placer ranks by: a country/region place name
// outranks a city street name for a contested pixel.
lod: l.lod,
dash: false,
})
.collect()
}
fn style(&self) -> &LayerStyle {
&self.style
}
fn enabled(&self) -> bool {
self.enabled
}
fn set_enabled(&mut self, on: bool) {
self.enabled = on;
}
}
// ── LandmassLayer — the base land fill, UNDER everything (land vs sea) ────────
/// One land polygon: an outer `(lon, lat)` ring (+ optional hole rings) with its
/// Mercator-unit bbox precomputed for viewport culling.
struct LandPoly {
/// `Arc`'d for the same reason as [`DrawGeom::Line`]: the per-frame feature
/// build shares these rings instead of re-cloning a country silhouette's
/// vertices every frame.
outer: std::sync::Arc<Vec<(f64, f64)>>,
holes: std::sync::Arc<Vec<Vec<(f64, f64)>>>,
bbox: [f64; 4],
}
/// **The landmass base fill.** Fills a country's landmass polygon(s) *under* all
/// other layers ([`LANDMASS_Z`]) so land-vs-sea reads instantly — the fix for a
/// pane that was empty (bare paper) between features. A base fill beneath
/// water/landuse/roads; shows at every zoom (it is the ground, not LOD-gated).
/// Feed it Sweden's län rings (`facett_geomap::AdminBoundaries`) or any land ring
/// set. Stackable + selectable like every other [`MapLayer`].
pub struct LandmassLayer {
id: String,
polys: Vec<LandPoly>,
fill: Color32,
style: LayerStyle,
enabled: bool,
/// The ear clip of every land ring, one slot per `polys` index — see
/// [`tessellate::FillTessCache`]. This is the layer that carries the single
/// biggest ring on the pane (a country silhouette), so it is the one polygon
/// where the O(n²) clip hurts most per feature.
fill_tess: tessellate::FillTessCache,
}
/// Mercator-unit bbox `[x0, y0, x1, y1]` of a `(lon, lat)` ring.
fn merc_bbox_of(ring: &[(f64, f64)]) -> [f64; 4] {
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for &(lon, lat) in ring {
let (x, y) = mercator(lon, lat);
x0 = x0.min(x);
y0 = y0.min(y);
x1 = x1.max(x);
y1 = y1.max(y);
}
[x0, y0, x1, y1]
}
impl LandmassLayer {
/// Build the base fill from land **rings** (each a `(lon, lat)` outer loop),
/// filled `fill`. Rings with fewer than 3 vertices (which can't be a polygon)
/// are skipped, so degenerate input never reaches the tessellator.
pub fn from_rings(
id: impl Into<String>,
rings: impl IntoIterator<Item = Vec<(f64, f64)>>,
fill: Color32,
) -> Self {
let polys = rings
.into_iter()
.filter(|r| r.len() >= 3)
.map(|outer| {
let bbox = merc_bbox_of(&outer);
LandPoly {
outer: std::sync::Arc::new(outer),
holes: std::sync::Arc::new(Vec::new()),
bbox,
}
})
.collect::<Vec<LandPoly>>();
Self {
id: id.into(),
fill_tess: tessellate::FillTessCache::new(polys.len()),
polys,
fill,
style: LayerStyle { color: fill, width: 0.0, z: LANDMASS_Z, kind: LayerKind::Polygon },
enabled: true,
}
}
/// Number of land polygons carried.
pub fn polygon_count(&self) -> usize {
self.polys.len()
}
/// **How many ear clips this layer has ever run** — see [`WaysLayer::ear_clips`].
#[must_use]
pub fn ear_clips(&self) -> usize {
self.fill_tess.ear_clips()
}
}
impl MapLayer for LandmassLayer {
fn id(&self) -> &str {
&self.id
}
fn kind(&self) -> LayerKind {
LayerKind::Polygon
}
fn features(&self, view: &MapView) -> Vec<DrawFeature> {
let [x0, y0, x1, y1] = view.merc_bbox();
self.polys
.iter()
.enumerate()
// Viewport cull: skip land polygons whose bbox misses the pane envelope.
.filter(|(_, p)| !(p.bbox[2] < x0 || p.bbox[0] > x1 || p.bbox[3] < y0 || p.bbox[1] > y1))
.map(|(i, p)| DrawFeature {
id: i as i64,
geom: DrawGeom::Polygon { outer: p.outer.clone(), holes: p.holes.clone() },
// A base fill has no outline — stroke matches the fill so the
// renderer's hairline is invisible against the land itself.
color: self.fill,
width: 0.0,
fill: Some(self.fill),
// The floor: under water/landuse/roads/labels (LANDMASS_Z ≪ all), and
// largest-land-first inside the band so two overlapping silhouettes
// do not stack by decode order.
order: crate::paint_order::PaintKey::area(
LANDMASS_Z,
((p.bbox[2] - p.bbox[0]) * (p.bbox[3] - p.bbox[1])) as f32,
i as i64,
),
tri: self.fill_tess.tris(i, &p.outer, &p.holes),
// The ground is not LOD-gated — it draws at every zoom.
lod: Lod::Country,
dash: false,
})
.collect()
}
fn style(&self) -> &LayerStyle {
&self.style
}
fn enabled(&self) -> bool {
self.enabled
}
fn set_enabled(&mut self, on: bool) {
self.enabled = on;
}
/// **This** is the layer that declares the land — the one `true` in the tree.
fn is_landmass(&self) -> bool {
true
}
fn ear_clips(&self) -> usize {
self.fill_tess.ear_clips()
}
}
// ── MapFacet — the facet: Vec<Box<dyn MapLayer>> + one shared renderer ───────
/// **Everything [`MapFacet::features_for_cpu`]'s answer depends on**, as one value.
///
/// A memo is only as good as its key, and a draw list served for the wrong camera is a
/// worse defect than any amount of work it saves — the pane would keep painting the viewport
/// the user panned away from. So the key is written as the *closure* of the build's
/// inputs, not as the subset today's four in-crate layers happen to read:
///
/// * **`epoch`** — [`MapFacet::layer_epoch`], bumped by every `&mut self` op that can
/// change the layer roster (`push_layer`, `toggle_layer`, `set_layer_enabled`). The
/// ways *inside* a layer never mutate — korp replaces the layer on data change — so
/// "same epoch" ⇒ "same layers reading the same geometry". Exactly the promise
/// `lane_plan_memo` and `gpu_sig_memo` already hang off.
/// * **`view`** — the WHOLE [`MapView`], by value (it is `Copy` + `PartialEq`).
/// [`MapLayer::features_into`] is handed the camera and nothing narrower is safe:
/// `merc_bbox` reads `center`, `zoom` and `size_px`, [`lod_visible`] reads `zoom`
/// again, and `bearing_deg`/`tilt_deg` are readable by any implementor of a PUBLIC
/// trait even though none of the four layers in this crate reads them today. Keying
/// the struct rather than three of its five fields makes the memo correct for a
/// downstream layer by construction instead of by a comment nobody will re-read.
/// (`tests/draw_list_memo.rs` pins the bearing arm with a layer that does read it.)
/// * **`gpu_layers` / `gpu_owns`** — the lane's share of the frame. Two frames at the
/// same camera and epoch hand the CPU painter *different* lists when the GPU lane
/// takes a layer, which is exactly what `way_lane_plan` re-decides per frame.
///
/// `MapView` compares `f32`/`f64` fields, so a NaN camera never equals itself and the
/// memo simply rebuilds — it fails toward correctness, never toward a stale list.
///
/// What is deliberately NOT here: [`MapTheme`]. No layer's `features_into` reads it (a
/// `Way` carries its own resolved style and a `LabelLayer` its own), so `set_theme`
/// cannot change this answer. `MapFacet::place_map_labels` *does* read `theme.label` —
/// which is one of the reasons that pass is not memoised on this key.
#[derive(Clone, PartialEq, Debug)]
struct DrawListKey {
epoch: u64,
view: MapView,
gpu_layers: Vec<usize>,
gpu_owns: GpuOwns,
}
/// The [`MapFacet::way_lane_plan`] memo entry: the layer epoch and renderer latch it
/// was computed under, then the plan itself.
#[cfg(feature = "wgpu")]
type LanePlanMemo = (u64, bool, WayLane, Vec<usize>, GpuOwns, Option<i32>);
/// The [`MapFacet::feature_counts`] memo entry: its `(epoch, view)` key and the counts.
type FeatureCountMemo = ((u64, MapView), std::sync::Arc<Vec<(String, usize)>>);
/// The facet: a **stack** of [`MapLayer`]s over one [`MapView`] camera, painted by
/// **one** source-agnostic renderer. Toggling a layer's `enabled` = **selecting**
/// which data draws; the layers are painted z-ordered into the egui frame through
/// the rotated + tilted [`MapView::project`]. Nothing renderer-side is source-aware.
pub struct MapFacet {
layers: Vec<Box<dyn MapLayer>>,
view: MapView,
theme: MapTheme,
title: String,
// ── idle auto-rotate (screensaver-style): when the user hasn't touched the
// pane for `idle_threshold_s`, spin the camera `bearing_deg` at
// `rotate_speed_deg_s`. Any interaction resets the timer + stops the spin.
// Off by default. Drivable/observable in code (tick + state_json) so a robot
// scenario proves it headlessly.
auto_rotate: bool,
idle_threshold_s: f32,
idle_elapsed_s: f32,
rotate_speed_deg_s: f32,
/// **The GPU upload-once latch**, as a *signature* of the way set last handed to
/// the GPU rather than a bare "uploaded" bool.
///
/// `MapView`'s latch is a plain `AtomicBool` because its ways never change. A
/// `MapFacet`'s do — `set_layer_enabled` / `toggle_layer` change which layers
/// contribute without rebuilding the facet — and a bool latch would leave a
/// switched-off layer's roads painted on the GPU forever, with the CPU painter
/// correctly not drawing them. So the latch stores a cheap O(layers + ways) hash
/// of the enabled way set + origin; a mismatch re-bakes. `0` = nothing sent.
///
/// Interior-mutable so the whole GPU path stays `&self` (FC-9): it is an
/// ephemeral VRAM cache, not Model state, and it is deliberately outside
/// [`MapView`] so the camera stays a pure value.
#[cfg(feature = "wgpu")]
gpu_geom_sig: std::sync::atomic::AtomicU64,
/// **The upload ACK**: `crate::gpu::lane_frames()` as it stood when
/// [`gpu_geom_sig`](Self::gpu_geom_sig) was last handed over. The payload counts as
/// landed in VRAM only once the lane's frame counter has moved PAST this — i.e.
/// once `MapPaintCallback::prepare` really ran against a device.
///
/// This is not belt-and-braces, it is the difference between working and not.
/// egui **discards** frames — a sizing pass is one — and the first cut of this GPU
/// path latched on the handover, so the geometry rode a discarded sizing frame,
/// `geom_ready` stayed false, every later callback carried `None`, and the lane
/// measured **0 frames on an RTX 4090**: the exact `installed_unused` state this
/// whole change exists to end, reproduced one level down. Latching on the ack
/// makes a discarded frame simply re-send.
#[cfg(feature = "wgpu")]
gpu_geom_sent_at: std::sync::atomic::AtomicU64,
/// **The layer-set epoch** — bumped by every `&mut self` operation that can change
/// what [`way_lane_plan`](Self::way_lane_plan) or
/// [`gpu_geometry_signature`](Self::gpu_geometry_signature) would read: a layer
/// pushed, toggled or enabled/disabled. The ways *inside* a layer never mutate
/// ([`WaysLayer`] builds them beside its caches and korp replaces the layer on
/// data change), so "same epoch" ⇒ "same layer roster reading the same
/// geometry" — the whole key both per-frame memos below hang off.
layer_epoch: u64,
/// **The [`way_lane_plan`](Self::way_lane_plan) memo** — the plan re-scanned every
/// way of every layer twice per frame (`.any()` for lines, `.any()` for fills) to
/// re-derive an answer that only changes with [`layer_epoch`](Self::layer_epoch)
/// or the renderer-installed latch. Keyed on exactly those two. Interior-mutable
/// for the same FC-9 reason as `gpu_geom_sig`: an ephemeral derivation cache, not
/// Model state.
#[cfg(feature = "wgpu")]
lane_plan_memo: std::sync::Mutex<Option<LanePlanMemo>>,
/// How many times the full lane-plan way scan actually ran — the measurement seam
/// (see [`MapLayer::ear_clips`] for why a cache claim needs one: work that did
/// NOT happen is invisible in the output). Memoised ⇒ stops at the number of
/// distinct (epoch, installed) states; broken memo ⇒ climbs every frame.
#[cfg(feature = "wgpu")]
lane_scans: std::sync::atomic::AtomicUsize,
/// **The [`gpu_geometry_signature`](Self::gpu_geometry_signature) memo**
/// (`(key, signature)`): the signature walked every taken way's bbox twice per
/// frame to answer "did VRAM's geometry change?" — an `O(ways)` hash whose inputs
/// are pinned by `(origin, gpu_layers, each layer's geometry_generation)`, all
/// `O(layers)` to key. A taken layer with generation `0` (no immutability
/// promise) bypasses the memo entirely, so the old full walk remains the
/// fallback semantics.
#[cfg(feature = "wgpu")]
gpu_sig_memo: std::sync::Mutex<Option<(u64, u64)>>,
/// **The TILE lane's per-apply GROUP PLAN** — `(memo key, the band-major ordered
/// list of `(band, tile, content key, bake origin)`)`.
///
/// It used to memoise only the content KEYS, and the frame still rebuilt the whole
/// grouping around them: `add_gpu_tiled_callback` walked every way of every taken
/// layer TWICE into a fresh `HashMap`, collected the keys into a `Vec` and sorted
/// it — **every frame**, for a still screen as much as for a pan. That is the
/// `O(ways)` per frame the owner's own words name: *"the lines must be PUT in the GPU
/// and THEN not manipulated from egui"*. Nothing in that grouping depends on the
/// camera: the group id is `(draw_band, tile_key)`, both read off the way, and the
/// bake origin is `ways_origin` over the group's own ways. Only the per-group
/// `MapTransform` and its rebased viewport are camera-dependent, and those are
/// `O(groups)`.
///
/// So the plan is pinned by the same `(epoch, layers, generations)` key as
/// [`gpu_geometry_signature`](Self::gpu_geometry_signature)'s memo and under the
/// same by-construction contract (a taken layer's ways are immutable for its
/// lifetime; korp replaces the LAYER on data change), and the frame walks
/// `O(groups)`. MEASURED on the real device (RTX 4090, Vulkan, `DiscreteGpu`) over a
/// korp-shaped 640-way / 480-group scene (`tests/gpu_lane_share.rs`): grouped way
/// walks **2 over 7 panned frames and 2 over 23** — flat, where the count used to be
/// one per frame. The RED arm in the same test toggles a layer every frame and gets
/// **9 over 7 and 25 over 23**, i.e. it still tracks the frames.
///
/// **Two, not one, and the second is honest.** The plan build is walk 1. The device
/// does not mark a key `known` until the payload has actually reached it, so the
/// first later frame that hits the plan memo can still owe bytes for some group and
/// has to gather its ways — walk 2. It is a constant of the APPLY, not of the frame
/// count, which is why the guard compares two frame counts rather than asserting a
/// single number.
///
/// A group's WAYS are re-gathered only on a frame that actually has a payload to
/// bake (`crate::gpu::tile_payloads_needed`), which after the first frames over a
/// patch of ground is no frame at all — the same "a pan over cached ground uploads
/// NOTHING" the tile cache already delivered, now with the CPU side of it too.
#[cfg(feature = "wgpu")]
tile_plan_memo: std::sync::Mutex<Option<(u64, std::sync::Arc<Vec<TileGroupPlan>>)>>,
/// How many times the tile lane's full `O(ways)` grouping walk actually ran — the
/// measurement seam for `tile_plan_memo`, same argument as `lane_scans`: the memo's
/// whole claim is about work that did NOT happen, and the pixels are identical
/// either way.
#[cfg(feature = "wgpu")]
tile_group_walks: std::sync::atomic::AtomicUsize,
/// How many times the WHOLE-LANE `ways_origin` min-fold ran — the measurement seam
/// for moving `gpu_lane_needs_grouping` in front of it in
/// [`Self::add_gpu_ways_callback`]. It is `O(ways)` and the GROUPED path discards
/// its answer (each group bakes against its own origin), so on korp's path — every
/// served way carries a producer stamp — this must now stay at 0 no matter how many
/// frames are drawn, and on the legacy unstamped path it must still count one per
/// frame. Same argument as `lane_scans`: the claim is about work that did NOT
/// happen, and the pixels are identical either way.
#[cfg(feature = "wgpu")]
lane_origin_walks: std::sync::atomic::AtomicUsize,
/// How many times the full `O(ways)` signature walk actually ran — the
/// measurement seam for the memo above, same argument as `lane_scans`.
#[cfg(feature = "wgpu")]
gpu_sig_walks: std::sync::atomic::AtomicUsize,
/// **How many times the LABEL LAYOUT actually ran** — lane GG #11's measurement
/// seam, and it exists before any fix because the audit's claim about it had never
/// been checked.
///
/// Audit #11 says `place_map_labels` *"sorts label_idx and lays out labels EVERY
/// frame, 0.96–9.45 ms — the dominant still-frame CPU cost"*. That is a claim about
/// work that DID happen, and the only honest way to hold it is to count the calls:
/// a memo's whole value is work that does not happen, and work that does not happen
/// is invisible in the output. Same argument as `lane_scans` and `gpu_sig_walks`,
/// which is why it sits beside them rather than in a new mechanism.
///
/// Not `cfg(wgpu)`: labels are laid out by the CPU painter too, and a thin
/// `korp-ui-cpu` is exactly the build where a per-frame shaping pass hurts most.
label_layouts: std::sync::atomic::AtomicUsize,
/// **The CPU draw-list memo** — `(key, the built + sorted feature list)`.
///
/// The third memo hanging off [`layer_epoch`](Self::layer_epoch), and the biggest:
/// [`features_for_cpu`](Self::features_for_cpu) walked every enabled layer, built a
/// fresh `Vec<DrawFeature>` and re-sorted the whole thing on **every frame**, for a
/// still screen as much as for a pan.
///
/// **The result is stated as WORK ELIMINATED, not as a wall-clock delta.** Absolute
/// frame times on this box are contention-dominated — lane BENCH measured the same
/// binary on the same fixture at 15.91 ms and 36.78 ms four minutes apart, with the
/// load average ranging 39–262 on 32 cores — so a millisecond figure here would be a
/// measurement of oden's queue, not of this change. The work count is deterministic:
/// over 501 identical asks of the 20 000-way fixture in `tests/draw_list_memo.rs`,
///
/// | | before | after |
/// |---|---:|---:|
/// | layer walks (`features_into` passes) | 501 | **1** |
/// | `way_paint_key` computations / features materialised | 10 020 000 | **20 000** |
/// | `sort_by_key` passes over the whole list | 501 | **1** |
///
/// — one build per *distinct* frame instead of one per frame. Timings, when quoted,
/// carry their median, their range and the box load, and say they are
/// contention-dominated (see the test's own header).
///
/// Interior-mutable for exactly the FC-9 reason `gpu_sig_memo` and `lane_plan_memo`
/// give: an ephemeral derivation cache, not Model state. Always compiled — unlike
/// its two siblings this is the pure-CPU painter's own cost and exists whether or
/// not the `wgpu` feature is on.
///
/// It holds an [`Arc`](std::sync::Arc), not a `Vec`, because the memo has to serve
/// the list without copying it: a hit must not re-allocate 20 000 `DrawFeature`s and
/// bump every ring `Arc` to hand back a copy of a list nothing mutates.
/// [`MapFacet::ui`] paints straight out of the `Arc`.
///
/// **THE KEY IS THE WHOLE ANSWER'S INPUT SET** — see [`DrawListKey`].
draw_list_memo: std::sync::Mutex<Option<(DrawListKey, std::sync::Arc<Vec<DrawFeature>>)>>,
/// How many times the full walk + sort actually ran — the measurement seam, same
/// argument as `lane_scans`: a memo is a claim about work that did NOT happen and
/// the painted frame is identical either way, so the counter is the only honest
/// oracle. Still frames ⇒ stays at 1; a broken memo ⇒ climbs once per frame.
///
/// One build = one pass over every enabled layer + one `sort_by_key` over the result,
/// so this IS the walk count and the sort count.
draw_list_builds: std::sync::atomic::AtomicUsize,
/// **How many `DrawFeature`s have ever been materialised** by that walk — one
/// `way_paint_key` (or `PaintKey::simple`/`::area`) computation, one `shared_geom`
/// pointer bump and one `Vec` push each, and one comparand in the sort.
///
/// The build count alone under-describes the saving: a facet with one tiny layer and
/// a facet with 20 000 ways both count `1`. This is the per-frame VOLUME, and like
/// its sibling it is deterministic — unlike a millisecond on a box whose load average
/// ranged 39–262 while the map bench was running.
draw_list_feats: std::sync::atomic::AtomicUsize,
/// **The applied-output count memo** — `((layer_epoch, view), per-enabled-layer counts)`.
///
/// The fourth memo on [`layer_epoch`](Self::layer_epoch). korp's flat canvas reads,
/// AFTER every paint, how many features each layer drew — its Quality table's
/// `applied.layers.*.drawn`, the number its robot drives assert on — and it read that
/// through [`MapLayer::feature_count`] per layer: a second `ways.iter().filter(draws)`
/// walk over every way on every frame, still screen or not (GG audit row 12, "the two
/// walks"). [`severity`](Self::severity) and [`feature_count`](Self::feature_count)
/// walked the same predicate again for the same frame.
///
/// The answer's inputs are the draw list's minus the lane split: the epoch (geometry,
/// enabled set) and the camera. The theme is NOT an input — no count reads ink — and
/// the GPU lane split is not either, because a layer the GPU owns still DRAWS its
/// features; ownership decides who paints them, not whether they are in view.
///
/// Interior-mutable for the FC-9 reason its siblings give: a derivation cache, not
/// Model state. `Arc` so a hit is a refcount bump. Stated as WORK ELIMINATED (see
/// [`feature_count_walk_count`](Self::feature_count_walk_count) and
/// `tests/feature_count_memo.rs`): 100 still frames over 3 layers walked **300**
/// layers before and **3** after.
feature_count_memo: std::sync::Mutex<Option<FeatureCountMemo>>,
/// How many per-layer `feature_count` walks actually ran — the oracle for the memo
/// above, one increment per layer walked. Still frames ⇒ stands still.
feature_count_walks: std::sync::atomic::AtomicUsize,
/// Last frame's label placement, published under `state_json()["labels"]`.
///
/// Atomics because `state_json` takes `&self` and the placement happens inside the
/// paint pass. These are the numbers a robot asserts on — `placed < considered` at
/// a dense camera is the whole claim, and it is DATA, not prose.
label_placed: std::sync::atomic::AtomicUsize,
label_suppressed: std::sync::atomic::AtomicUsize,
label_considered: std::sync::atomic::AtomicUsize,
/// **The px² the placed boxes actually reserve** — the clutter number a count
/// cannot give: `placed` and `overlaps` were both perfect on the pane this exists
/// to fix. MEASURED here after the change — 64 labels, 74 559 px, 7.8 % of korp's
/// 1258×728 canvas.
label_ink_px: std::sync::atomic::AtomicUsize,
/// How many DISTINCT texts are among the placed labels. `placed - distinct` is the
/// repetition — the same name printed again for no new information.
label_distinct: std::sync::atomic::AtomicUsize,
/// **The boxes the painter actually drew text in this frame**, not a re-derivation.
///
/// A count alone is a state round-trip: `placed = 40` is equally consistent with 40
/// well-separated names and with 40 names stacked on one pixel. The claim
/// "the labels no longer overplot" is a claim about RECTANGLES, so the rectangles
/// are what the guard has to see — and they must be the same values
/// `paint_features` centred its text in, which is why this is written by the paint
/// pass and not recomputed by the test.
label_boxes: std::sync::Mutex<Vec<egui::Rect>>,
}
impl MapFacet {
/// **The ONE predicate for "the GPU fill lane can draw this way"** — a filled way
/// with a ring the tessellator can use.
///
/// It is a free function on purpose: the lane plan, the bake
/// ([`Self::gpu_lane_fills`]) and the CPU painter's skip
/// ([`WaysLayer::features_into`]) all ask it, and a fill drawn twice or lost
/// entirely is exactly what two spellings of it would produce.
///
/// It deliberately does NOT consult the ear-clip cache. It could — the bake needs
/// a triangulation and the cache is where one comes from — but then the answer
/// would depend on WHEN it was asked: the CPU painter builds its features before
/// the bake runs, so on the first frame the two sides would disagree and the map
/// would draw its lakes twice. The bake warms the cache itself instead, which
/// costs the same single ear clip per ring the CPU painter would have run.
///
/// **Always compiled.** It used to live in the `#[cfg(feature = "wgpu")]` impl
/// while `WaysLayer::features_into` called it unconditionally, so `facett-map`
/// did not build at all without the `wgpu` feature (E0599 at the call site).
///
/// ## `lonlat.len() >= 3` CANNOT discriminate on the real-OSM path
///
/// Every way korp (or any tag-classified host) builds comes through
/// `Way::from_lonlat_styled`, and there `fill` is assigned **only** when
/// `Way::is_closed_ring` holds — which returns `false` below **4** vertices. So on
/// that path `fill.is_some()` already implies `lonlat.len() >= 4` and the second
/// clause is always true: it can never be the reason the lane declines a fill, and
/// reading it as a gate sends you looking in the wrong place (it did — lane WHITE,
/// 2026-08-31).
///
/// It is KEPT because there is a second door: `Way::filled` sets `fill: Some(..)`
/// with **no** length or closure check at all (the synthetic rabbit basemap and the
/// unit fixtures use it, korp never does). A one- or two-vertex `Way::filled` would
/// otherwise reach the ear clip. So the clause is a real guard for the synthetic
/// door and dead weight for the OSM one — which is the honest description, not
/// "the fill gate".
#[must_use]
fn gpu_fillable(w: &Way) -> bool {
w.fill.is_some() && w.lonlat.len() >= 3
}
pub fn new(title: impl Into<String>, view: MapView, theme: MapTheme) -> Self {
Self {
layers: Vec::new(),
view,
theme,
title: title.into(),
auto_rotate: false,
idle_threshold_s: 6.0,
idle_elapsed_s: 0.0,
rotate_speed_deg_s: 8.0,
#[cfg(feature = "wgpu")]
gpu_geom_sig: std::sync::atomic::AtomicU64::new(0),
#[cfg(feature = "wgpu")]
gpu_geom_sent_at: std::sync::atomic::AtomicU64::new(0),
layer_epoch: 0,
#[cfg(feature = "wgpu")]
lane_plan_memo: std::sync::Mutex::new(None),
#[cfg(feature = "wgpu")]
lane_scans: std::sync::atomic::AtomicUsize::new(0),
#[cfg(feature = "wgpu")]
gpu_sig_memo: std::sync::Mutex::new(None),
#[cfg(feature = "wgpu")]
tile_plan_memo: std::sync::Mutex::new(None),
#[cfg(feature = "wgpu")]
tile_group_walks: std::sync::atomic::AtomicUsize::new(0),
#[cfg(feature = "wgpu")]
lane_origin_walks: std::sync::atomic::AtomicUsize::new(0),
#[cfg(feature = "wgpu")]
gpu_sig_walks: std::sync::atomic::AtomicUsize::new(0),
label_layouts: std::sync::atomic::AtomicUsize::new(0),
draw_list_memo: std::sync::Mutex::new(None),
draw_list_builds: std::sync::atomic::AtomicUsize::new(0),
draw_list_feats: std::sync::atomic::AtomicUsize::new(0),
feature_count_memo: std::sync::Mutex::new(None),
feature_count_walks: std::sync::atomic::AtomicUsize::new(0),
label_placed: std::sync::atomic::AtomicUsize::new(0),
label_suppressed: std::sync::atomic::AtomicUsize::new(0),
label_considered: std::sync::atomic::AtomicUsize::new(0),
label_ink_px: std::sync::atomic::AtomicUsize::new(0),
label_distinct: std::sync::atomic::AtomicUsize::new(0),
label_boxes: std::sync::Mutex::new(Vec::new()),
}
}
/// The label boxes the last painted frame drew text in — the applied-output oracle
/// for the collision pass. Empty before the first paint.
pub fn painted_label_boxes(&self) -> Vec<egui::Rect> {
self.label_boxes.lock().map(|g| g.clone()).unwrap_or_default()
}
/// **Do any two painted label boxes share pixels?** `0` is the claim
/// "labels no longer overplot"; before the collision pass this was in the thousands.
pub fn label_overlaps(&self) -> usize {
let b = self.painted_label_boxes();
let mut n = 0;
for i in 0..b.len() {
for j in (i + 1)..b.len() {
if b[i].intersects(b[j]) {
n += 1;
}
}
}
n
}
/// Last frame's label placement — `(placed, suppressed, considered)`.
///
/// The oracle the guard and `state_json` both read, so the assertion and the
/// published number can never disagree. `considered == 0` means no label reached
/// the placer this frame (no label layer, or the pane has not painted yet).
pub fn label_placement(&self) -> (usize, usize, usize) {
use std::sync::atomic::Ordering::Relaxed;
(
self.label_placed.load(Relaxed),
self.label_suppressed.load(Relaxed),
self.label_considered.load(Relaxed),
)
}
/// **The fraction of the pane last frame's label boxes covered**, `0.0..=1.0` — the
/// clutter measure a placed-count cannot give.
///
/// A count is a state round-trip in the same way `label_placement` alone is: `255
/// placed, 0 overlaps` was the reading on the pane this exists to fix. Overlap is a
/// claim about PAIRS; ink is a claim about the SURFACE, and the surface is what
/// looks noisy.
///
/// Read from the same atomics `state_json` publishes, so the assertion and the
/// published number can never disagree.
pub fn label_ink_frac(&self) -> f32 {
let area = self.view.size_px.0 * self.view.size_px.1;
if area <= 0.0 {
return 0.0;
}
self.label_ink_px.load(std::sync::atomic::Ordering::Relaxed) as f32 / area
}
/// Last frame's `(distinct texts, repeats)` among the PLACED labels. `repeats > 0`
/// means one name is printed more than once — five printings of "Ringvägen" is
/// five times the ink for one fact.
pub fn label_repetition(&self) -> (usize, usize) {
use std::sync::atomic::Ordering::Relaxed;
let (placed, distinct) = (self.label_placed.load(Relaxed), self.label_distinct.load(Relaxed));
(distinct, placed.saturating_sub(distinct))
}
/// Enable idle auto-rotate (chainable).
pub fn with_auto_rotate(mut self, on: bool) -> Self {
self.auto_rotate = on;
self
}
/// Toggle idle auto-rotate at runtime; turning it off also clears the timer.
pub fn set_auto_rotate(&mut self, on: bool) {
self.auto_rotate = on;
if !on {
self.idle_elapsed_s = 0.0;
}
}
pub fn auto_rotate(&self) -> bool {
self.auto_rotate
}
/// True iff auto-rotate is on AND the pane has been idle past the threshold
/// (i.e. it is currently spinning).
pub fn is_idle_rotating(&self) -> bool {
self.auto_rotate && self.idle_elapsed_s >= self.idle_threshold_s
}
/// Advance the idle clock by `dt_s` seconds. `interacted` = the user did
/// something this frame (pointer/scroll/key) → reset the clock and don't spin.
/// Otherwise, once idle beyond the threshold, spin the camera bearing. Returns
/// true iff it rotated this tick (so the host can request a repaint). Pure +
/// deterministic — no clock read — so it is unit/robot-testable in code.
pub fn tick(&mut self, dt_s: f32, interacted: bool) -> bool {
if interacted {
self.idle_elapsed_s = 0.0;
return false;
}
if !self.auto_rotate {
return false;
}
self.idle_elapsed_s += dt_s.max(0.0);
if self.idle_elapsed_s >= self.idle_threshold_s {
self.view.bearing_deg =
(self.view.bearing_deg + self.rotate_speed_deg_s * dt_s.max(0.0)).rem_euclid(360.0);
true
} else {
false
}
}
/// Add a layer to the stack (chainable). Later layers can draw over earlier
/// ones at the same z (stable sort).
pub fn with_layer(mut self, layer: Box<dyn MapLayer>) -> Self {
self.push_layer(layer);
self
}
pub fn push_layer(&mut self, layer: Box<dyn MapLayer>) {
self.layers.push(layer);
self.layer_epoch += 1;
}
pub fn view(&self) -> &MapView {
&self.view
}
pub fn view_mut(&mut self) -> &mut MapView {
&mut self.view
}
pub fn theme(&self) -> &MapTheme {
&self.theme
}
pub fn set_theme(&mut self, theme: MapTheme) {
self.theme = theme;
}
pub fn layers(&self) -> &[Box<dyn MapLayer>] {
&self.layers
}
/// Toggle a layer by id — the runtime "select which data to draw". Returns the
/// new enabled state, or `None` if no layer has that id.
pub fn toggle_layer(&mut self, id: &str) -> Option<bool> {
let l = self.layers.iter_mut().find(|l| l.id() == id)?;
let on = !l.enabled();
l.set_enabled(on);
self.layer_epoch += 1;
Some(on)
}
/// Set a layer's enabled state by id. Returns `false` if no such layer.
pub fn set_layer_enabled(&mut self, id: &str, on: bool) -> bool {
match self.layers.iter_mut().find(|l| l.id() == id) {
Some(l) => {
l.set_enabled(on);
self.layer_epoch += 1;
true
}
None => false,
}
}
/// **The shared renderer input** — every *enabled* layer's features for the
/// current view, z-ordered (stable). Pure; the egui painter consumes this. A
/// disabled layer contributes nothing (selectable data-to-draw).
///
/// Returns an owned `Vec` — the public shape it has always had. The frame path
/// ([`ui`](Self::ui)) takes the memo's `Arc` instead and pays no copy at all; this
/// entry point is the tests' and the bench's, where a `Vec` is what the caller
/// wants and the copy is not on anyone's frame budget.
pub fn enabled_features(&self) -> Vec<DrawFeature> {
(*self.features_for_cpu(&[], GpuOwns::NONE)).clone()
}
/// **Total ear clips run by every layer in this facet** — see
/// [`MapLayer::ear_clips`]. Published in [`state_json`](Self::state_json) as
/// `fill_tess.ear_clips`, so a robot scenario (and a human reading the dump) can
/// see that a moving camera is not re-triangulating the basemap.
#[must_use]
pub fn ear_clips(&self) -> usize {
self.layers.iter().map(|l| l.ear_clips()).sum()
}
/// **How many filled polygons this facet's layers carry a cache slot for** — the
/// ceiling [`ear_clips`](Self::ear_clips) can ever reach.
#[must_use]
pub fn fill_polygon_count(&self) -> usize {
self.layers
.iter()
.map(|l| l.gpu_ways().map_or(0, |ws| ws.iter().filter(|w| w.fill.is_some()).count()))
.sum()
}
/// **Which lane paints the ways, and why** — see [`WayLane`]. Pure; safe to call
/// outside a frame (a status bar, a robot assertion, a test).
///
/// Both answers are first-class. `Cpu` is not a failure: it is the supported route
/// on a host with no adapter and the *correct* route under a rotated camera.
#[must_use]
pub fn way_lane(&self) -> WayLane {
self.way_lane_plan().0
}
/// **WHICH LANE DREW, as the three-state on-screen flag** — the answer the owner asked
/// for on 2026-08-23 ("a flag saying GPU or CPU ... so i know whats being used").
///
/// Read off the ONE decision writer ([`Self::way_lane_plan`]) plus the lane's own
/// encoded-frame counter, so the chip, the log and `state_json`'s `way_lane.flag`
/// cannot disagree (LAW 5).
///
/// # Why this is not just `way_lane().is_gpu()`
///
/// `way_lane()` is a PLAN. It says which route the frame will take, and on the GPU
/// route the callback is merely *placed* — nothing is out of the device until
/// `MapPaintCallback::prepare` has encoded a frame. So:
///
/// * plan says CPU ⇒ [`LaneFlag::Cpu`]. Definite: no callback is placed at all.
/// * plan says GPU **and** the lane has encoded a frame ⇒ [`LaneFlag::Gpu`].
/// * plan says GPU and it has not ⇒ [`LaneFlag::Pending`], the same state
/// `note_lane_idle_reason` already publishes as
/// [`facett_core::render::lane::AWAITING_FIRST_ENCODE`].
///
/// Collapsing that third case into `Gpu` would put a confident `GPU` on a pane the
/// device has never touched; collapsing it into `Cpu` is the `bool`-shaped lie the
/// flag type's own doc records. It is LIVE, not latched — the plan is re-asked every
/// frame, so a camera that starts spinning flips the chip to `CPU` on the next frame
/// (`CpuOnly::RotatedCamera`) instead of showing a stale first-frame verdict.
#[must_use]
pub fn lane_flag(&self) -> facett_core::render::lane::LaneFlag {
use facett_core::render::lane::LaneFlag;
if !self.way_lane().is_gpu() {
return LaneFlag::Cpu;
}
#[cfg(feature = "wgpu")]
{
if crate::gpu::lane_frames() > 0 {
LaneFlag::Gpu
} else {
LaneFlag::Pending
}
}
// Unreachable in practice (`way_lane` cannot answer `Gpu` without the feature),
// and it must still not answer `Cpu` here: an impossible state is not a
// measurement.
#[cfg(not(feature = "wgpu"))]
LaneFlag::Pending
}
/// The decision + the layer indices the GPU lane took. ONE decision writer, so the
/// reported lane and the painted lane cannot drift (LAW #5).
///
/// Without the `wgpu` feature there is no GPU lane in the binary at all, and this
/// says exactly that rather than pretending a GPU was tried and declined.
#[cfg(not(feature = "wgpu"))]
fn way_lane_plan(&self) -> (WayLane, Vec<usize>, GpuOwns, Option<i32>) {
(WayLane::Cpu(CpuOnly::NotCompiled), Vec::new(), GpuOwns::NONE, None)
}
/// [`enabled_features`](Self::enabled_features) **minus the line features the GPU
/// lane took this frame** — one writer for both, so the two lanes cannot double-
/// draw a road or silently drop one.
///
/// `gpu_layers` holds indices into `self.layers` (never ids: korp pushes many
/// `WaysLayer`s and an id is only unique by convention). For a taken layer the
/// GPU owns exactly its *unfilled* ways — which are exactly the ones
/// [`WaysLayer::features`] emits as [`DrawGeom::Line`] — so those are dropped
/// here and everything else (hole rings, points, labels) still paints on the CPU.
/// `&[]` reproduces the old behaviour byte for byte.
///
/// `gpu_owns` says WHICH kinds the lane took — see [`GpuOwns`]. It is the lane
/// plan's answer, not a second opinion formed here.
///
/// ## MEMOISED on [`DrawListKey`] — the whole input set, not a subset
///
/// This body ran on **every frame**: a walk of every enabled layer, a fresh
/// `Vec<DrawFeature>`, and a `sort_by_key` over the lot. Nothing invalidated it, so
/// a still screen paid exactly what a pan paid. The answer's inputs are all
/// `O(1)` or `O(layers)` to compare, so the frame that changes nothing does nothing;
/// see [`DrawListKey`] for why each one is in the key and
/// [`draw_list_build_count`](Self::draw_list_build_count) for the oracle.
///
/// It hands back an [`Arc`](std::sync::Arc) so a hit costs a refcount bump rather
/// than a fresh 20 000-element allocation — a memo that clones its answer hands a
/// slice of the win straight back before the caller sees it.
fn features_for_cpu(&self, gpu_layers: &[usize], gpu_owns: GpuOwns) -> std::sync::Arc<Vec<DrawFeature>> {
let key = DrawListKey {
epoch: self.layer_epoch,
view: self.view,
gpu_layers: gpu_layers.to_vec(),
gpu_owns,
};
if let Ok(g) = self.draw_list_memo.lock()
&& let Some((k, feats)) = g.as_ref()
&& *k == key
{
return std::sync::Arc::clone(feats);
}
let feats = std::sync::Arc::new(self.build_draw_list(gpu_layers, gpu_owns));
if let Ok(mut g) = self.draw_list_memo.lock() {
*g = Some((key, std::sync::Arc::clone(&feats)));
}
feats
}
/// **The build itself** — the un-memoised body [`features_for_cpu`](Self::features_for_cpu)
/// wraps. One writer, so the memoised and the freshly-built list cannot drift.
fn build_draw_list(&self, gpu_layers: &[usize], gpu_owns: GpuOwns) -> Vec<DrawFeature> {
self.draw_list_builds.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut feats: Vec<DrawFeature> = Vec::new();
for (i, l) in self.layers.iter().enumerate() {
if !l.enabled() {
continue;
}
// The drop is expressed as a REQUEST to the layer
// ([`MapLayer::features_into`]) rather than a post-hoc filter, so a layer
// that can avoid building the dropped geometry does — see the method's own
// measurement. The trait's default body is the old filter, so the result is
// identical for every implementor.
// The lane owns BOTH kinds of geometry for a taken layer now: its unfilled
// ways (the pretty-line lane) and its filled ways (the fill lane, mesh +
// ring outline). One value carries both, so a layer cannot be half-taken
// by accident — which would draw a lake twice or lose it entirely.
let on_gpu = if gpu_layers.contains(&i) { gpu_owns } else { GpuOwns::NONE };
l.features_into(&self.view, on_gpu, &mut feats);
}
// **Sort on the WHOLE declared key, not on a per-kind `z`.** `sort_by_key` is
// stable, so sorting on the old one-value-per-kind `z` left every same-kind
// overlap to the order the features were pushed in — i.e. to the order the
// tiles were read in. `PaintKey`'s own `Ord` carries class, OSM layer, LOD
// tier, area and finally the osm id, so no comparison can fall through to
// input order.
feats.sort_by_key(|f| f.order);
self.draw_list_feats.fetch_add(feats.len(), std::sync::atomic::Ordering::Relaxed);
feats
}
/// **Test hook** — [`Self::features_for_cpu`] with the lane ownership named
/// explicitly instead of derived from an installed renderer.
///
/// It exists because the interesting arms are unreachable otherwise: a headless
/// test has no `RenderState`, so `way_lane_plan` always answers
/// `CpuOnly::NoRenderer` and the taken-layer path — the one that must NOT hand a
/// polygon to the CPU painter — can never run. Driving the real body with the
/// real `GpuOwns` is the closest a test gets to the live frame without a window.
///
/// It hands back the memo's own [`Arc`](std::sync::Arc) — the SAME value
/// [`ui`](Self::ui) paints from — so a test that measures this path measures the
/// frame's path and not a copy of it (`&feats` still coerces to `&[DrawFeature]`
/// for every existing caller).
#[doc(hidden)]
#[must_use]
pub fn features_for_cpu_for_test(
&self,
gpu_layers: &[usize],
lines: bool,
fills: bool,
) -> std::sync::Arc<Vec<DrawFeature>> {
self.features_for_cpu(gpu_layers, GpuOwns { lines, fills })
}
/// **Measurement seam** — how many times the full draw-list walk + sort ran (see
/// `draw_list_builds`). Same argument as
/// [`way_lane_scan_count`](Self::way_lane_scan_count), and the same reason it has
/// to exist at all: the memo's whole claim is that a still frame does NOTHING, and
/// the painted pixels are identical whether it does or not.
///
/// **The deck.gl `widthScale` this facet's two lanes draw with THIS frame** —
/// [`crate::style::line_width_scale`] of the camera's `world_px`, so a way's
/// on-screen width is [`crate::style::line_width_px`]`(style.width, world_px)` in
/// BOTH lanes: [`paint_features`](Self::paint_features) strokes with it directly
/// and both GPU callbacks (legacy and tile) hand it to the line shader as the
/// `width_scale` uniform, which applies the same clamp per frame.
///
/// **It was a `const GPU_LINE_WIDTH_SCALE: f32 = 1.0`** (lane GG #27). That did keep
/// the two lanes equal to each other, but it pinned both to the formula's 1×
/// reference while `style.rs`, `gpu/line.wgsl` and `MapPaintCallback::width_scale`
/// all documented the LIVE ramp — and `facett-geomap`'s `osm2d` pane, drawing the
/// same `Way`s through the same shader, did pass `line_width_scale(cam.zoom)` in
/// both of its lanes. MEASURED (`tests/lane_width_is_one_formula.rs`) on a 3 px
/// trunk: zoom 8 drew 3 px where the law says 3.93 px (1.31×); zoom 14 drew 3 px
/// where the law says 9 px (the 3.0× clamp). One method now, both lanes read it.
#[must_use]
pub fn line_width_scale(&self) -> f32 {
crate::style::line_width_scale(self.view.world_px())
}
/// The pixel width the CPU painter strokes a way of themed `style_width` at — the
/// law's arithmetic with [`line_width_scale`](Self::line_width_scale). The one
/// writer `paint_features` and the test hook both call.
fn cpu_line_width_px(&self, style_width: f32) -> f32 {
crate::style::line_width_px_scaled(style_width, self.line_width_scale())
}
/// **Test hook** — the pixel width [`paint_features`](Self::paint_features) strokes a
/// way of themed `style_width` at, under THIS camera. The CPU lane's half of the
/// lane GG #27 equality (`tests/lane_width_is_one_formula.rs`).
#[doc(hidden)]
#[must_use]
pub fn cpu_line_width_px_for_test(&self, style_width: f32) -> f32 {
self.cpu_line_width_px(style_width)
}
/// **Test hook** — the deck.gl `widthScale` this facet hands the GPU line lane
/// (`MapPaintCallback::width_scale`, legacy and tile paths alike) under THIS camera.
/// The GPU lane's half of the lane GG #27 equality.
#[doc(hidden)]
#[must_use]
pub fn gpu_width_scale_for_test(&self) -> f32 {
self.line_width_scale()
}
/// Always compiled — the draw list is the CPU painter's, not the GPU lane's.
#[doc(hidden)]
#[must_use]
pub fn draw_list_build_count(&self) -> usize {
self.draw_list_builds.load(std::sync::atomic::Ordering::Relaxed)
}
/// **Measurement seam** — how many `DrawFeature`s those walks have materialised (see
/// `draw_list_feats`): one paint-key computation and one sort comparand each. The
/// VOLUME of the eliminated work, where
/// [`draw_list_build_count`](Self::draw_list_build_count) is its frequency.
#[doc(hidden)]
#[must_use]
pub fn draw_list_feature_count(&self) -> usize {
self.draw_list_feats.load(std::sync::atomic::Ordering::Relaxed)
}
/// **Test hook** — [`Self::gpu_geometry_signature`], so `seam-5` can be asserted
/// on the number itself rather than on a re-upload that a test cannot observe.
#[doc(hidden)]
#[cfg(feature = "wgpu")]
#[must_use]
pub fn gpu_geometry_signature_for_test(&self, gpu_layers: &[usize], origin: [f64; 2]) -> u64 {
self.gpu_geometry_signature(gpu_layers, origin)
}
/// **Measurement seam** — how many times the full lane-plan way scan ran (see
/// `lane_scans`). The memo claim is about work that did NOT happen, so the count
/// is the only honest oracle; `tests/frame_churn_memo.rs` reads it.
#[doc(hidden)]
#[cfg(feature = "wgpu")]
#[must_use]
pub fn way_lane_scan_count(&self) -> usize {
self.lane_scans.load(std::sync::atomic::Ordering::Relaxed)
}
/// **Measurement seam** — how many times the full `O(ways)` geometry-signature
/// walk ran (see `gpu_sig_walks`). Same argument as
/// [`way_lane_scan_count`](Self::way_lane_scan_count).
#[doc(hidden)]
#[cfg(feature = "wgpu")]
#[must_use]
pub fn gpu_sig_walk_count(&self) -> usize {
self.gpu_sig_walks.load(std::sync::atomic::Ordering::Relaxed)
}
/// **Measurement seam** — how many times the label layout (`place_map_labels`:
/// filter, sort, then one `layout_no_wrap` text shaping per candidate) actually ran.
/// Lane GG #11. See the `label_layouts` field for why this exists before any fix.
#[doc(hidden)]
#[must_use]
pub fn label_layout_count(&self) -> usize {
self.label_layouts.load(std::sync::atomic::Ordering::Relaxed)
}
/// FC-3 observable state: the layer roster (id/kind/enabled), the camera, and
/// how many features the current view draws — the JSON tests + the web hook read.
pub fn state_json(&self) -> serde_json::Value {
let layers: Vec<_> = self
.layers
.iter()
.map(|l| serde_json::json!({ "id": l.id(), "kind": l.kind().as_str(), "enabled": l.enabled() }))
.collect();
// COUNTED, not built (see `MapLayer::feature_count`): this runs 3.3x a second
// in korp (`dump_if_due`, every 300 ms) and used to clone every visible way's
// coordinates to read a `.len()` off them.
let features = self.feature_count();
// ONE `way_lane_plan` for the whole object — it walks every layer's ways, and
// this was calling it THREE times to answer three adjacent questions.
let lane = self.way_lane();
serde_json::json!({
"title": self.title,
"layers": layers,
"enabled_layers": self.layers.iter().filter(|l| l.enabled()).count(),
"features": features,
// The stable UI ERROR CODE for the nothing-to-plot state
// (`facett_core::errcode`, `facet-map-2`). `MapView` published this
// already; `MapFacet` — the LAYERED map a host actually mounts (korp's
// 🗺 Map / 📍 Geo panes) — was silent for the very same condition, so a
// consumer had no code to react to. Consumers branch on the CODE in this
// data, never on a matched string.
"error_code": if features == 0 {
serde_json::json!("facet-map-2")
} else {
serde_json::Value::Null
},
"view": {
"center": { "lat": self.view.center.lat, "lon": self.view.center.lon },
"zoom": self.view.zoom,
"bearing_deg": self.view.bearing_deg,
"tilt_deg": self.view.tilt_deg,
// **The tier the painter and the GPU cull are actually gating on**, from
// the one writer (`view_lod_tier` over `LOD_TIER_ZOOMS`).
//
// stinky `natt-4`. It was not published, so every robot driver that
// wanted to check "the fetched level and the drawn tier step at the same
// camera" had to RETYPE the rule — `rung_boundary.sh` still carried
// `0 if z<6 else (1 if z<12 else 2)`, a three-tier copy of a rule that
// grew a fourth rung at zoom 15 with `kalm-1`. A harness that re-derives
// the thing under test cannot fail with it, and this one had gone quietly
// wrong for every camera at or above 15.
"lod_tier": view_lod_tier(self.view.zoom),
"lod_tier_zooms": LOD_TIER_ZOOMS,
},
// **What the label collision pass did last frame.** `placed` is what a human
// can read; `suppressed` is the overplot that used to land on top of it.
// `placed + suppressed == considered` exactly (a truncated candidate was
// never considered), so a robot can assert the pass ran AND bit — a
// `considered == 0` reads as "no label reached the placer", never as a
// silent success. See `MapFacet::place_map_labels`.
// `ink_frac` / `repeats` are the CLUTTER half, and they exist because the
// counts above were all green on a pane that was unreadable: 255 placed and
// 0 overlapping pairs. A robot (and a human reading a diff) needs the
// quantity the eye reacts to, and no count is it.
"labels": {
"placed": self.label_placed.load(std::sync::atomic::Ordering::Relaxed),
"suppressed": self.label_suppressed.load(std::sync::atomic::Ordering::Relaxed),
"considered": self.label_considered.load(std::sync::atomic::Ordering::Relaxed),
"ink_px": self.label_ink_px.load(std::sync::atomic::Ordering::Relaxed),
"ink_frac": self.label_ink_frac(),
"distinct": self.label_distinct.load(std::sync::atomic::Ordering::Relaxed),
"repeats": self
.label_placed
.load(std::sync::atomic::Ordering::Relaxed)
.saturating_sub(self.label_distinct.load(std::sync::atomic::Ordering::Relaxed)),
"budget": label_budget(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(self.view.size_px.0, self.view.size_px.1),
)),
},
// WHICH LANE painted the ways, and why the CPU has them when it does — so a
// headless robot can tell "the GPU line lane is live" from "the CPU painter
// is doing all of it", which no feature count or pixel share can show. The
// process-wide frame count that CONFIRMS it lives in
// `facett_core::render::lane` (an About box reads both).
"way_lane": {
"lane": lane.as_str(),
// **The three-state on-screen FLAG's own token.** `lane` above is the
// DECISION (which route the plan chose); this is what the pane's chip
// actually shows, which is a different fact: a plan of `gpu` whose
// callback has not yet come out of the device reads `pending`, not `gpu`.
// Both are here so a robot can tell "chose the GPU" from "the GPU has
// drawn", and so the chip and the state file cannot disagree.
"flag": self.lane_flag().token(),
"cpu_reason": lane.cpu_reason(),
"gpu_layers": match lane {
WayLane::Gpu { layers } => layers,
WayLane::Cpu(_) => 0,
},
},
// THE FILL TESSELLATION CACHE, as a number a robot can watch move (or not).
// `ear_clips` is cumulative work done, `polygons` is the ceiling: once every
// visible area has been painted once, `ear_clips` must STOP — a camera move
// does not change a triangulation, because the projection is affine and ear
// clipping is affine-invariant. If this number climbs with the frame counter
// the cache is dead and 85 % of the frame is back.
"fill_tess": {
"ear_clips": self.ear_clips(),
"polygons": self.fill_polygon_count(),
},
"night": self.theme.night,
"auto_rotate": self.auto_rotate,
"idle_rotating": self.is_idle_rotating(),
"idle_elapsed_s": self.idle_elapsed_s,
})
}
/// STRUCTURAL severity (the Robot-UI gate signal, and the cheap gate a consumer's
/// per-frame reaction walk checks before building [`state_json`](Self::state_json)):
/// RED when the current view draws no features at all — the `facet-map-2`
/// nothing-to-plot state — the Info floor otherwise. Matches
/// [`MapView`](crate::MapView)'s override.
pub fn severity(&self) -> facett_core::Severity {
// COUNTED, not built. korp calls this on EVERY frame
// (`Korp::react_to_facet_panes`), whose own doc says the severity check is there
// "so a healthy frame never pays for a `state_json` build" — while this body was
// `enabled_features()`, i.e. precisely that build, coordinate clones and all.
// Read off the memo (`feature_counts`): on a still frame this walks nothing, and
// korp calls it every frame.
if self.feature_counts().iter().any(|(_, n)| *n > 0) {
facett_core::Severity::Info
} else {
facett_core::Severity::Error
}
}
/// **How many features the current view draws — without BUILDING them.**
///
/// The allocation-free twin of `enabled_features().len()`, summed over the enabled
/// layers through each one's own [`MapLayer::feature_count`]. This is what
/// [`state_json`](Self::state_json) publishes and what [`severity`](Self::severity)
/// tests against zero; see `MapLayer::feature_count` for why the count and the paint
/// share one visibility predicate rather than two. Memoised through
/// [`feature_counts`](Self::feature_counts).
#[must_use]
pub fn feature_count(&self) -> usize {
self.feature_counts().iter().map(|(_, n)| n).sum()
}
/// **Per enabled layer, `(id, how many features the current view draws)` — the
/// applied output, MEMOISED on `(layer_epoch, view)`.**
///
/// This is the seam korp's flat canvas reads after the paint (its Quality table's
/// `drawn` per layer) instead of asking every layer's [`MapLayer::feature_count`]
/// itself once per frame — the "second full walk per frame" of GG audit row 12. A
/// disabled layer has NO row: the caller reads absence as "not drawn", never a stale
/// count. Order is the stack order.
///
/// Same numbers as the un-memoised per-layer read, by construction: one writer
/// ([`build_feature_counts`](Self::build_feature_counts)) produces both the miss and
/// what the memo hands back. The oracle for the work saved is
/// [`feature_count_walk_count`](Self::feature_count_walk_count).
#[must_use]
pub fn feature_counts(&self) -> std::sync::Arc<Vec<(String, usize)>> {
let key = (self.layer_epoch, self.view);
if let Ok(g) = self.feature_count_memo.lock()
&& let Some((k, counts)) = g.as_ref()
&& *k == key
{
return std::sync::Arc::clone(counts);
}
let counts = std::sync::Arc::new(self.build_feature_counts());
if let Ok(mut g) = self.feature_count_memo.lock() {
*g = Some((key, std::sync::Arc::clone(&counts)));
}
counts
}
/// The un-memoised body [`feature_counts`](Self::feature_counts) wraps: one
/// [`MapLayer::feature_count`] walk per enabled layer, each counted in
/// `feature_count_walks`.
fn build_feature_counts(&self) -> Vec<(String, usize)> {
self.layers
.iter()
.filter(|l| l.enabled())
.map(|l| {
self.feature_count_walks.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
(l.id().to_string(), l.feature_count(&self.view))
})
.collect()
}
/// How many per-layer visibility walks [`feature_counts`](Self::feature_counts) has
/// actually run — the measurement seam, same argument as
/// [`draw_list_build_count`](Self::draw_list_build_count): a memo is a claim about
/// work that did NOT happen and the numbers are identical either way, so the counter
/// is the only honest oracle. One increment per enabled layer per rebuild; a still
/// frame adds nothing.
#[must_use]
pub fn feature_count_walk_count(&self) -> usize {
self.feature_count_walks.load(std::sync::atomic::Ordering::Relaxed)
}
/// **The pane wash — the colour of every pixel no layer paints.**
///
/// With an enabled [`LandmassLayer`] in the stack the land is *declared*, so
/// everything outside the silhouette is sea and the wash is [`MapTheme::sea`].
/// With none, nothing has said where the land is, so the whole pane is paper and
/// the wash is [`MapTheme::land`] — the historical behaviour, preserved for every
/// host that draws features on bare paper.
///
/// This is the fix for a map that was invisible BY CONSTRUCTION: the wash and the
/// landmass fill were the same `MapTheme::land`, so the country polygon painted
/// land-colour onto a land-colour pane and land-vs-sea — the stated purpose of
/// [`LandmassLayer`] — could not happen at any zoom, with any data.
pub fn background(&self) -> Color32 {
if self.layers.iter().any(|l| l.is_landmass() && l.enabled()) {
self.theme.sea
} else {
self.theme.land
}
}
/// **The shared, source-agnostic renderer.** Paints the themed land, then every
/// enabled layer's features z-ordered, each projected through the rotated +
/// tilted [`MapView`]. Reuses [`crate::tessellate`] for polygon fills. Pure w.r.t.
/// the model (draws only).
///
/// ## Where the GPU comes in (feature `wgpu`)
///
/// Before the line tier is painted, [`gpu_lane_layers`](Self::gpu_lane_layers)
/// decides whether `facett_map::gpu::GpuMapRenderer` can own the ways this frame;
/// if it can, [`add_gpu_ways_callback`](Self::add_gpu_ways_callback) inserts the
/// `egui_wgpu` paint callback *between* the CPU fill tier and the CPU
/// point/label tier, so the paint order (fills under roads under markers under
/// labels) is the same one the pure-CPU path produces. The taken layers' line
/// features are removed from the CPU list by
/// [`features_for_cpu`](Self::features_for_cpu), so no road is drawn twice.
pub fn ui(&mut self, ui: &mut egui::Ui) {
// Idle auto-rotate: spin the camera when the pane has gone untouched (any
// pointer/key/scroll event resets it). Keep animating while it spins.
let (dt, interacted) =
ui.input(|i| (i.stable_dt, i.pointer.any_down() || !i.events.is_empty()));
if self.tick(dt, interacted) {
ui.ctx().request_repaint();
}
let avail = ui.available_size();
let want = egui::vec2(avail.x.max(160.0), avail.y.max(120.0));
// ── THE CANVAS IS A CONTROL, not a picture. `Sense::hover()` is what it used to
// be, and with it the car-nav map had NO pan and NO wheel zoom: the only way to
// move the viewport was a `zoom:` slider in the controls rail, and there was no
// way to reach a different part of the country at all. korp's own call site
// says "persist the camera the user drove" — the user could not drive it.
// Measured 2026-08-03 on the shipped korp-ui: drag and scroll over the canvas
// changed `view.center`/`view.zoom` by exactly zero, so korp's viewport-driven
// `MapQuery` never re-fired and every zoom-in had to be typed into a slider.
let (rect, resp) = ui.allocate_exact_size(want, egui::Sense::click_and_drag());
// Keep the camera pane-size in sync so projection centres on the real rect.
self.view.size_px = (rect.width(), rect.height());
// ── GESTURES → the camera the HOST fetches for.
//
// Both write `self.view`, which is what `MapFacet::view()` hands back and what
// korp turns into its server viewport + LOD — so a drag or a wheel notch
// re-queries korp-server for the new viewport exactly as the rail slider does.
// Deliberately NOT the legacy `MapView` widget's `camera.pan`/`camera.zoom`,
// which are screen-space knobs no fetch ever reads.
if resp.dragged() {
let d = resp.drag_delta();
self.view.pan_by_px(d.x, d.y);
}
// Hover-gated, so a wheel over the canvas zooms the map while a wheel over the
// surrounding scroll area still scrolls the page.
if resp.hovered() {
let scroll = ui.input(|i| i.smooth_scroll_delta.y);
self.view.zoom_by_scroll(scroll);
}
let painter = ui.painter_at(rect);
painter.rect_filled(rect, egui::CornerRadius::ZERO, self.background());
let origin = rect.min.to_vec2();
// Tilt shading: pitched maps fade the far (upper) half — a subtle horizon
// so the pitched view reads as depth, not a flat skew.
let tilt_fade = (self.view.tilt_deg / MAX_TILT).clamp(0.0, 1.0);
// ── WHICH LANE, decided up front and reported (never inferred from a no-op).
// `gpu_layers` empty ⇒ the CPU painter owns every way, which is a correct,
// supported outcome — see `WayLane` / `CpuOnly`. ──
let (lane, gpu_layers, gpu_owns, gpu_floor) = self.way_lane_plan();
// SAY IT WHERE A HUMAN LOOKS. `state_json` alone was not enough: korp's About box
// said "installed but UNUSED" on an RTX 4090 and could not say whether that was a
// correct choice or a bug — a reporting defect in its own right. Published every
// frame, so the row tracks the LIVE camera rather than a stale startup verdict.
note_lane_idle_reason(lane);
// THEIRS: the GPU fill lane now decides which layers it owns, so the CPU feature
// list is built against `gpu_owns` as well as `gpu_layers`.
let feats = self.features_for_cpu(&gpu_layers, gpu_owns);
// **Labels are placed before anything is painted**, over the WHOLE frame's
// feature list rather than per z-slice, because two labels that collide may
// come from different layers. `boxes[i]` is `Some(rect)` for a label that won
// its pixels and `None` for one that lost them (or for any non-label feature).
let boxes = self.place_map_labels(&painter, origin, rect, &feats);
// ── WHERE THE GPU SLOT GOES IN THE DECLARED STACK ────────────────────
//
// The callback is ONE opaque slot: everything the lane owns lands at a single
// point in the CPU list. The split therefore has to be the lane's own rank
// FLOOR — the lowest `PaintKey::rank` it will draw this frame — so every CPU
// feature that belongs under the lane's bottom-most class paints first and
// everything else paints after.
//
// It was a hardcoded `LayerKind::Line.default_z()`, i.e. "the lane draws
// lines". That stopped being true when the fill lane landed: a lake the GPU
// took was drawn in the LINE slot, ABOVE every CPU-painted fill whatever its
// class — and `way_lane_plan_scan` decides per viewport which layers the lane
// takes, so the visible stacking changed with what had been loaded. That is
// the tangle the owner reported on 2026-08-22.
//
// `None` ⇒ the lane draws nothing this frame and the CPU list paints as one
// uninterrupted run (`split == feats.len()`, the tail call is empty).
let split = gpu_floor.map_or(feats.len(), |lo| feats.partition_point(|f| f.order.rank < lo));
self.paint_features(&painter, origin, tilt_fade, &feats[..split], &boxes[..split]);
#[cfg(feature = "wgpu")]
if !gpu_layers.is_empty() {
// The pane is this facet's response id (GG #37): stable across frames for
// a stable layout, distinct from a second facet's in the same frame.
let stamp = crate::gpu::PaneStamp::of(ui, resp.id);
self.add_gpu_ways_callback(&painter, rect, &gpu_layers, tilt_fade, stamp);
}
self.paint_features(&painter, origin, tilt_fade, &feats[split..], &boxes[split..]);
// ── WHICH LANE DREW THIS, ON SCREEN (owner, 2026-08-23) ────────────────
//
// Painted LAST so it sits over the map rather than under the tail features, and
// read from `lane_flag()` — the same value `state_json`'s `way_lane.flag` carries,
// so the chip and the state file are one answer and not two.
//
// Live per frame, deliberately not latched: `way_lane_plan` is re-asked every
// frame, so the instant the idle auto-rotate starts spinning the camera the chip
// flips to CPU (`CpuOnly::RotatedCamera`) — which is exactly the transition a
// first-frame latch would hide, and it is the most likely reason this pane is on
// the CPU painter at all.
facett_core::render::lane::paint_lane_flag(ui, rect, self.lane_flag());
}
/// **Give every label that fits its own pixels, and drop the rest.**
///
/// THE DEFECT THIS CLOSES. [`lod_visible`] is the only declutter the label path
/// had, and [`view_lod_tier`] saturates at three steps: past zoom 12 every
/// [`Lod::City`] label passes it, so **every named street in the viewport painted
/// its name**. MEASURED on the shipped korp-ui on a real 1900×1150 Xvfb window over
/// central Stockholm at zoom 12.1, 80 000 served ways: the 2D pane was a field of
/// overlapping grey text with the roads and water underneath it unreadable. The LOD
/// gate cannot fix this — there is no fourth tier to promote a street into, and
/// inventing one would only move the cliff.
///
/// The cure is screen-space, and it is **consumed, not written** (LAW 5):
/// [`facett_core::legibility::place_labels`] is the greedy grid-backed placer that
/// already existed for the graph side — same discipline, same honesty fields, one
/// implementation. This method is only the adapter: project the anchor, measure the
/// galley, rank by tier, hand it over.
///
/// Ranking: **lower [`Lod`] wins**, then the feature's own
/// [`rank`](crate::style::LabelClass::rank) within that tier (a motorway before a
/// cul-de-sac, a lake before a courtyard), then the input index — deterministic, so
/// two identical frames place identically.
///
/// ## The three declutter rules that live here, and why collision alone was not enough
///
/// MEASURED on the shipped korp-ui against the live korp-server, Xvfb 1900×1150,
/// central Stockholm at zoom 12.7 (`02-2d-z12.png`, 2026-08-03): the collision pass
/// was working — 255 placed of 3 756 considered, **zero** overlapping pairs — and
/// the pane was still a field of text. Three things a rect-vs-rect test cannot see:
///
/// 1. **Density.** 255 non-overlapping boxes on the 1258×728 canvas is one label
/// per 3 591 px². Non-overlapping is not the same as sparse; a map whose surface
/// is a third label boxes is a text field with a map behind it.
/// [`LABEL_MIN_PX2_EACH`] turns the pane's area into a hard ceiling.
/// 2. **Repetition.** OSM splits one street into many ways and `from_ways` anchors
/// a label at each way's centroid, so one name printed five times down one road
/// is five times the ink for the same information. [`LABEL_REPEAT_PX`] is the
/// minimum screen distance between two printings of the *same text*.
/// 3. **Fit.** A 30 px stub of "Sankt Eriksgatan" carrying its 110 px name says
/// nothing about where that street *is*. A candidate whose feature is shorter on
/// screen than its own text is dropped before it competes (the standard rule for
/// line-placed symbols; a point label like a place node is exempt).
///
/// The return is index-parallel to `feats` (`None` for every non-label feature and
/// for every suppressed label), so the painter needs no second lookup structure and
/// cannot paint a label the placer rejected.
///
/// ## Why this pass is NOT on the [`DrawListKey`] memo — and it was measured first
///
/// It runs immediately after [`features_for_cpu`](Self::features_for_cpu) over the
/// same `&feats`, so it is the obvious second rider on that key. Profiled on oden
/// 2026-08-31 through a real `egui_kittest` frame — 4 000 named Stockholm ways,
/// 8 000 features, 1600×1000, zoom 13, still camera, release, eight frames:
///
/// | frame | draw list | this pass |
/// |---|---:|---:|
/// | first (cold: ear clips + font atlas) | 67.1 ms | 79.2 ms |
/// | every still frame after it | 150 ns – 1.1 µs | 0.96 – 9.45 ms |
///
/// Those absolutes are contention-dominated on this box and are quoted only for the
/// ORDER between the two columns, which is stable across every frame of the run and
/// is the only thing decided from them: with the draw list memoised, this pass is now
/// the dominant per-frame CPU cost on a labelled pane, and it is worth a memo. It is not worth THIS memo: the placement is computed against **egui's
/// font state**. `painter.layout_no_wrap` shapes every candidate through
/// `Context::fonts`, and while `rect`, `origin` and `pixels_per_point` are all
/// readable here, the font *definitions* and the atlas carry no version a
/// `MapFacet` can read — so a key written today would be incomplete by
/// construction, which is the one failure mode this whole change exists to avoid.
/// And a stale answer here is not a slow frame: it is names painted into rects the
/// painter no longer measures, published as [`painted_label_boxes`](Self::painted_label_boxes)
/// — the applied-output oracle `label_collision.rs` asserts on.
///
/// Memoising it is therefore its own change, and it needs a seam first: a galley
/// generation (egui-side) or a facet-side `(text, FontId, pixels_per_point)` galley
/// cache that makes the shaping cost, not the placement, the thing that is saved.
fn place_map_labels(
&self,
painter: &egui::Painter,
origin: egui::Vec2,
rect: egui::Rect,
feats: &[DrawFeature],
) -> Vec<Option<egui::Rect>> {
use facett_core::legibility::{place_labels, LabelCandidate, PlaceOpts};
// Lane GG #11's measurement seam. Counted at the TOP, before the early return
// below: a frame with no labelled features still pays the filter walk, and a
// counter that skipped those would under-report exactly the still frames the
// audit's claim is about.
self.label_layouts.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut boxes: Vec<Option<egui::Rect>> = vec![None; feats.len()];
// **Truncate BEFORE measuring, not after.** `PlaceOpts::max_considered` bounds
// the placer's own work, but the expensive step here is `layout_no_wrap` — text
// shaping, once per candidate — and that would already have run on all of them
// by the time the placer truncates. A z12 Stockholm viewport offers thousands of
// named ways, so shaping the whole set every frame to then throw most of it away
// is a legibility cure that costs the frame rate, which is not a cure.
//
// The sort key is the SAME ranking the placer uses (tier ascending, then rank
// descending, then input index), so truncating here takes exactly the tail the
// placer would have truncated — never a place name, never an arterial.
let rank_of = |f: &DrawFeature| match f.geom {
DrawGeom::Label { rank, .. } => rank,
_ => 0.0,
};
let mut label_idx: Vec<usize> = feats
.iter()
.enumerate()
.filter(|(_, f)| matches!(f.geom, DrawGeom::Label { .. }))
.map(|(i, _)| i)
.collect();
label_idx.sort_by(|&a, &b| {
(feats[a].lod as u8)
.cmp(&(feats[b].lod as u8))
.then(rank_of(&feats[b]).total_cmp(&rank_of(&feats[a])))
.then(a.cmp(&b))
});
label_idx.truncate(MAX_LABELS_CONSIDERED);
// The camera's Mercator→px scale, so a feature's baked extent becomes the
// on-screen length rule 3 tests the name against.
let world_px = self.view.world_px();
// Rule 2's accepted anchors, keyed by the text already admitted. A `Vec` per
// name because a long street legitimately earns a second printing far away.
let mut printed: std::collections::HashMap<&str, Vec<egui::Pos2>> = std::collections::HashMap::new();
// `cands[k]` came from `feat_of[k]` — the placer indexes its own slice.
let mut cands: Vec<LabelCandidate> = Vec::new();
let mut feat_of: Vec<usize> = Vec::new();
for &i in &label_idx {
let f = &feats[i];
let DrawGeom::Label { at, text, rank, merc_len } = &f.geom else { continue };
let (x, y) = self.view.project(at.lon, at.lat);
let anchor = egui::pos2(x, y) + origin;
// ── RULE 2: the same name, again, right there. ────────────────────
// Walked in priority order, so the printing that survives is the one on
// the most important / longest-ranked way, not the first one decoded.
if let Some(prev) = printed.get(text.as_str())
&& prev.iter().any(|p| p.distance(anchor) < LABEL_REPEAT_PX)
{
continue;
}
// Measure the REAL galley the painter will draw, at the SAME font — a
// placement computed against a guessed extent is a placement against a
// different picture than the one that lands.
let galley = painter.layout_no_wrap(
text.clone(),
egui::FontId::proportional(LABEL_FONT_PX),
self.theme.label,
);
// ── RULE 3: does the name fit along the thing it names? ───────────
// `merc_len == 0` is a POINT label (a place node, a `ref` shield) — it
// names a spot, not a stretch, so the rule does not apply to it.
if *merc_len > 0.0 && (*merc_len * world_px) < galley.size().x as f64 {
continue;
}
printed.entry(text.as_str()).or_default().push(anchor);
cands.push(LabelCandidate::new(
anchor,
galley.size(),
// Lower tier ⇒ higher priority (Country 0 → 3.0, City 2 → 1.0), plus
// the feature's own rank in `[0, 1)` so a tier is ordered inside
// itself and never decided by the tile decode order.
(3.0 - f.lod as u8 as f32) + rank.clamp(0.0, 0.999),
));
feat_of.push(i);
}
let placement = place_labels(
&cands,
rect,
PlaceOpts {
pad: LABEL_PAD_PX,
// Mapbox-style variable placement: a label that loses its anchor box
// tries below/above/right/left before it is dropped, so a dense street
// grid keeps more names than a one-shot placer would.
variable: true,
// A half-drawn name at the pane edge is noise, not information.
clip_to_viewport: true,
// A screen has a hard ceiling on how many labels can physically fit;
// past a few multiples of it every further candidate is a guaranteed
// rejection that still costs a query. Walked in priority order, so the
// truncation takes the tail — never the place names.
max_considered: MAX_LABELS_CONSIDERED,
// ── RULE 1: the areal budget, from THIS pane. ─────────────────
max_placed: label_budget(rect),
},
);
for p in &placement.placed {
boxes[feat_of[p.index]] = Some(p.rect);
}
self.label_placed.store(placement.placed.len(), std::sync::atomic::Ordering::Relaxed);
self.label_suppressed.store(placement.suppressed, std::sync::atomic::Ordering::Relaxed);
self.label_considered.store(placement.considered, std::sync::atomic::Ordering::Relaxed);
// The ink the placement actually reserves, in whole px — the quantity a
// collision count cannot express (255 boxes that share no pixel is still a third
// of the pane). Published beside the counts so the "is it calm?" question has a
// number and not an impression.
let ink: f32 = placement.placed.iter().map(|p| p.rect.area()).sum();
self.label_ink_px.store(ink.max(0.0) as usize, std::sync::atomic::Ordering::Relaxed);
let distinct = placement
.placed
.iter()
.filter_map(|p| match &feats[feat_of[p.index]].geom {
DrawGeom::Label { text, .. } => Some(text.as_str()),
_ => None,
})
.collect::<std::collections::BTreeSet<_>>()
.len();
self.label_distinct.store(distinct, std::sync::atomic::Ordering::Relaxed);
if let Ok(mut g) = self.label_boxes.lock() {
*g = placement.placed.iter().map(|p| p.rect).collect();
}
boxes
}
/// Paint one z-slice of [`DrawFeature`]s. The ONE CPU painter body — `ui` calls it
/// twice (below and above the GPU lane's slot) instead of carrying two copies.
fn paint_features(
&self,
painter: &egui::Painter,
origin: egui::Vec2,
tilt_fade: f32,
feats: &[DrawFeature],
label_boxes: &[Option<egui::Rect>],
) {
// The casing ink for this pane, from the ACTIVE palette. The GPU lane's copy of
// this decision was a hardcoded near-black navy `#191C24` that no palette switch
// could reach; both lanes read `theme.palette.casing` now.
let casing_col = self.theme.palette.casing;
// ONE projector for the whole frame (stinky `prof-9`): `MapView::project` re-ran
// `world_px`'s `powf`, the CENTRE's `mercator` (`tan`+`cos`+`ln`) and the ground
// frame's `sincos` for EVERY vertex, all of them frame-constants. `perf` over 40
// real frames put libm at 20.3 % of this lane's CPU.
let proj = self.view.projector();
let to_px = |lon: f64, lat: f64| {
let (x, y) = proj.project(lon, lat);
egui::pos2(x, y) + origin
};
// The line width both lanes resolve through — see `MapFacet::line_width_scale`.
let line_w = |w: f32| self.cpu_line_width_px(w);
// ── PASS 1 of 2: THE CASING (`kalm-4`) ───────────────────────────────
//
// The GPU line lane strokes every way twice — a wider dark band, then the fill
// on top — and this painter drew a single `Shape::line` with `f.color` and no
// casing at all. One road, two looks, decided by whether the GPU lane happened
// to take the frame. And the CPU painter is not a degraded mode: it is what
// draws under a bearing, under a tilt, on a host with no adapter, and in every
// `egui_kittest` harness test in this repo — so no CPU-side test could ever see
// a casing regression, which is why the proportional-casing fix had to be
// guarded with a real headless wgpu device.
//
// ALL casings before ANY fill, which is the part a per-feature
// casing-then-fill loop gets wrong: egui draws shapes in submission order, so
// interleaving would let a later road's casing paint over an earlier road's
// bright core at every single junction. The GPU lane does two whole passes over
// one vertex buffer for exactly this reason.
//
// Only where the GPU did NOT draw: `feats` has already been filtered by
// `GpuOwns::owns`, so a frame the line lane took contains no `DrawGeom::Line`
// here and this loop is empty.
for f in feats.iter() {
let DrawGeom::Line(pts) = &f.geom else { continue };
if pts.len() < 2 {
continue;
}
// **A TRANSLUCENT stroke gets no casing.** Every road is opaque; a way that
// arrives with alpha < 255 is an overlay — a glow halo, a tracer, a
// highlight — and an OPAQUE dark outline under a translucent wash is the one
// thing that can never look like light. MEASURED 2026-09-03 (korp's
// `tests/golden_trail_pixels.rs`): the case trail's 22 px halo pass at
// alpha 0.09 was drawn over a 26 px opaque casing, so the "glow" rendered as
// a solid grey band with a coloured thread down the middle — on the day map
// and the night map alike. Skipping the casing here is what lets a glow
// ladder read as a glow on this renderer at all.
if f.color.a() < u8::MAX {
continue;
}
let w = line_w(f.width);
let sp: Vec<egui::Pos2> = pts.iter().map(|&(lo, la)| to_px(lo, la)).collect();
painter.add(egui::Shape::line(
sp,
egui::Stroke::new(w + 2.0 * crate::style::casing_extra_px(w), casing_col),
));
}
// ── PASS 2 of 2: everything else, the bright fill on top ─────────────
// The dashed cores' scratch (HO-T3): `dash_polyline` appends each lit run into
// `dashes` and assembles it in `dash_run`; both are drained/cleared and their
// capacity reused by the next rail.
let mut dashes: Vec<egui::Shape> = Vec::new();
let mut dash_run: Vec<egui::Pos2> = Vec::new();
for (i, f) in feats.iter().enumerate() {
match &f.geom {
DrawGeom::Point(p) => {
painter.circle_filled(to_px(p.lon, p.lat), f.width.max(1.0), f.color);
}
DrawGeom::Line(pts) => {
let sp: Vec<egui::Pos2> = pts.iter().map(|&(lo, la)| to_px(lo, la)).collect();
if sp.len() >= 2 {
let w = line_w(f.width);
if f.dash {
// ── THE DASH LAW, CPU side (HO-T3) ──────────────────
// The same period the GPU lane's `line.wgsl` cuts at —
// `dash_period_px(w)` of the ONE resolved width, `DASH_DUTY`
// lit — and `dash_polyline` walks the path's own arc length
// from its first point, exactly as the vertex bake does, so
// the two lanes put their dashes in the same places and
// neither crawls under a pan. The casing under it was
// stroked whole in pass 1; the gaps show it.
let period = crate::style::dash_period_px(w);
let lit = period * crate::style::DASH_DUTY;
dashes.clear();
dash_polyline(&sp, lit, period - lit, egui::Stroke::new(w, f.color), &mut dashes, &mut dash_run);
painter.extend(dashes.drain(..));
} else {
painter.add(egui::Shape::line(sp, egui::Stroke::new(w, f.color)));
}
}
}
// ONE mesh, per-vertex alpha, written once — see `glow_ribbon_mesh` for
// why this is not five strokes.
DrawGeom::Glow { path, core_w } => {
let sp: Vec<egui::Pos2> = path.iter().map(|&(lo, la)| to_px(lo, la)).collect();
if sp.len() >= 2 {
// The PAPER decides the tone, and the paper is a paint-time fact:
// `self.theme.night`. It used to be inferred from the way's own
// COLOUR (luminance sum > 383), which is a fact about the ink and
// says nothing about what it is drawn on — so a bright neon trail
// read as "night" and burned its core WHITE on the day map's cream
// `#F2EFE9`. Measured: max channel departure 90 where the line it
// replaced reached 204, i.e. a trail you cannot see, which is the
// exact failure the ground-awareness exists to prevent.
let m = tessellate::glow_ribbon_mesh(&sp, line_w(*core_w), f.color, self.theme.night);
if !m.is_empty() {
painter.add(egui::Shape::mesh(m));
}
}
}
DrawGeom::Polygon { outer, holes } => {
let sp: Vec<egui::Pos2> = outer.iter().map(|&(lo, la)| to_px(lo, la)).collect();
if sp.len() >= 3 {
// Project the hole rings too, so the fill subtracts them.
let hsp: Vec<Vec<egui::Pos2>> = holes
.iter()
.map(|h| h.iter().map(|&(lo, la)| to_px(lo, la)).collect())
.collect();
// Tilt-fade the fill (pitched-horizon shading) when present; the
// outer + hole outlines are stroked regardless (an unfilled
// polygon still shows its boundary), hence `fill: Option`. The
// mesh-select + outline recipe lives once in
// `tessellate::paint_filled_way` (dedup cluster I); MapFacet
// strokes hole outlines → `outline_holes = true`.
let fill = f.fill.map(|fill| fade(fill, tilt_fade));
// ONE writer for a filled ring's stroke, shared with the GPU
// bake (`style::fill_outline_width_px`). It was `max(0.5)` here
// and an implicit 1 px there — stinky `mapgpu-1`.
let stroke =
egui::Stroke::new(crate::style::fill_outline_width_px(f.width), f.color);
// `f.tri` is the layer's cached ear clip — the 85.4 % of the
// frame that used to be re-derived here every time the camera
// moved a pixel. `None` (a layer with no cache) falls back to
// the byte-identical per-frame clip.
tessellate::paint_filled_way(
painter,
sp,
&hsp,
fill,
stroke,
true,
f.tri.as_deref(),
);
}
}
DrawGeom::Label { text, .. } => {
// ONLY the labels that won their pixels in `place_map_labels`, and
// at the box the placer gave them — which may be an alternate slot
// beside the anchor, not the anchor itself. A `None` here is a
// label the placer suppressed; painting it anyway would put the
// overplot straight back and make the placer's numbers a lie.
let Some(Some(bx)) = label_boxes.get(i) else { continue };
let font = egui::FontId::proportional(LABEL_FONT_PX);
// ── THE HALO ──────────────────────────────────────────────
// Four 1 px offsets in the paper colour, then the ink on top. A
// name that crosses a road casing or a lake keeps its own
// background instead of dissolving into whatever it lies on —
// which is what makes it possible to draw FEWER labels and still
// read every one of them. Invisible over open land (the halo IS
// the land), so it costs nothing where it is not needed.
for d in [egui::vec2(-1.0, 0.0), egui::vec2(1.0, 0.0), egui::vec2(0.0, -1.0), egui::vec2(0.0, 1.0)] {
painter.text(
bx.center() + d,
egui::Align2::CENTER_CENTER,
text,
font.clone(),
self.theme.label_halo,
);
}
painter.text(bx.center(), egui::Align2::CENTER_CENTER, text, font, self.theme.label);
}
}
}
}
}
// ── MapFacet's GPU line lane (feature `wgpu`) ────────────────────────────────
//
// THE REGRESSION THIS CLOSES. korp installs `facett_map::gpu::GpuMapRenderer` into
// eframe's wgpu `RenderState` at startup and its About box said so — while its
// DEFAULT 2D pane (`MapFacet`, `map_carnav: true`) was a pure CPU painter with no
// GPU branch anywhere in it. The renderer was therefore *installed and unreachable
// by construction*: `facett_core::render::lane::lane_use()` reported
// `installed_unused` with 0 frames, and 60 000 shapes/frame were tessellated on one
// core on a box with an RTX 4090. `facett-core/src/render/lane.rs` even names the
// state: "**This is korp's map facet.**"
//
// The lane was already COMPATIBLE — nothing needed inventing, only wiring:
// `MapTransform::new(origin, mercator(center), world_px, world_px, rect)` reproduces
// `MapView::project` exactly at `bearing == tilt == 0`, because both reduce to
// `rect.center + (merc − center_merc) · world_px`. So there is NO second projection
// here (LAW #5); the transform, the geometry bake, the cull viewport and the paint
// callback are all the shared `crate::gpu` ones `MapView::draw_ways_gpu` and
// `facett_geomap::osm2d::OsmView` already drive.
#[cfg(feature = "wgpu")]
impl MapFacet {
/// **Can the GPU line lane own this frame, and which layers?** Indices into
/// `self.layers`; empty means "everything on the CPU painter", which is always a
/// correct answer.
///
/// Pure and side-effect free, so `ui` can ask BEFORE it starts painting (it needs
/// the answer to build the CPU feature list and to place the callback in the right
/// z slot).
///
/// ## What falls back to the CPU, explicitly
///
/// * **No renderer installed.** `crate::gpu::renderer_installed()` is false — the
/// callback would silently no-op and the pane would lose its roads entirely.
/// * **A rotated or pitched camera** (`bearing_deg != 0 || tilt_deg != 0`).
/// [`crate::gpu::MapTransform`] is the 2D reduction of the unified view core
/// with tilt 0 and bearing 0 — it carries `zoom`/`ref`/`screen_center` and *no
/// rotation term at all*, so it cannot reproduce `MapView::project`'s
/// `ground_frame().apply()`. Sending ways through it under a bearing would
/// draw an unrotated basemap under rotated labels and markers. This case
/// includes idle auto-rotate the moment it starts spinning.
/// * **Filled ways** (`Way::fill.is_some()` — water, landuse, buildings, the
/// landmass silhouette). The GPU lane is a *line* pipeline; fills stay on
/// `tessellate::paint_filled_way`, exactly as in `MapView::draw_ways_gpu`.
/// * **Points and labels**, which have no `Way` at all.
fn way_lane_plan(&self) -> (WayLane, Vec<usize>, GpuOwns, Option<i32>) {
// ── MEMOISED per (layer_epoch, renderer_installed): the scans below walk
// every way of every layer TWICE (`.any()` lines + `.any()` fills), and
// they ran per frame — plus again per `way_lane()` reader (`state_json`
// 3.3×/s, korp's severity check) — while their answer can only change
// when a layer is pushed/toggled (the epoch) or the renderer latch flips.
// `tests/frame_churn_memo.rs` holds `lane_scan_count` at 1 across a
// hundred asks (seen red with the memo bypassed).
let installed = crate::gpu::renderer_installed();
if let Some((epoch, inst, lane, layers, owns, floor)) = self.lane_plan_memo.lock().unwrap().as_ref()
&& *epoch == self.layer_epoch && *inst == installed
{
return (*lane, layers.clone(), *owns, *floor);
}
let plan = self.way_lane_plan_scan(installed);
*self.lane_plan_memo.lock().unwrap() =
Some((self.layer_epoch, installed, plan.0, plan.1.clone(), plan.2, plan.3));
plan
}
/// The full [`way_lane_plan`](Self::way_lane_plan) scan — `O(ways)` over every
/// enabled layer, counted in `lane_scans` so "the scan did not run" is a
/// measurable fact rather than a hope.
///
/// ## WHAT ACTUALLY MAKES IT DECLINE (2026-08-31, lane LANE)
///
/// There are exactly **two** live reasons, and neither is about geometry quality:
///
/// 1. **`CpuOnly::NoRenderer`** — `crate::gpu::renderer_installed()` is false. A
/// process-global latch: no adapter, a refused bring-up, or a host that never
/// called `gpu::install_renderer`. All-or-nothing for the whole process.
/// 2. **`CpuOnly::NoLineWays`** — no ENABLED layer answered `Some` to
/// [`MapLayer::gpu_ways`] or [`MapLayer::gpu_fills`] with at least one qualifying
/// way. Both default to `None` on the trait and **`WaysLayer` is the only
/// implementor that overrides them**, so a facet of points, labels or a landmass
/// silhouette declines here — correctly, since those have no `Way` at all.
///
/// `CpuOnly::RotatedCamera` is unreachable in this build (the rotation fallback was
/// deleted when `MapTransform` gained the compass) and `CpuOnly::NotCompiled` is the
/// non-`wgpu` build. Both are kept so a future decline still has a name.
///
/// So on korp's shape the lane does **not** decline: MEASURED on the real device
/// (`tests/gpu_lane_share.rs`, RTX 4090 / Vulkan), over a korp-shaped 1 200-way
/// scene at three cameras from `z6` to `z16`, the plan is `Gpu { layers: 8 }` with
/// `owns = {lines, fills}` every time and the CPU painter is left **0** ways.
fn way_lane_plan_scan(&self, installed: bool) -> (WayLane, Vec<usize>, GpuOwns, Option<i32>) {
if !installed {
return (WayLane::Cpu(CpuOnly::NoRenderer), Vec::new(), GpuOwns::NONE, None);
}
self.lane_scans.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
// NO rotation fallback any more. `MapTransform` carries the compass bearing
// and the tilt and projects through the SAME `GroundFrame2d` the CPU painter
// applies, so a turned camera is a camera the GPU lane can reproduce. What
// used to be here — `bearing_deg != 0 || tilt_deg != 0 => CpuOnly::RotatedCamera`
// — cost 0.857 ms -> 12.28 ms (14.3x) and `lane_frames` 249 -> 0 the instant
// the map started spinning, which is the default idle behaviour.
//
// `CpuOnly::RotatedCamera` is deliberately KEPT as a variant: it is still the
// honest answer for a host that builds a transform this lane cannot express,
// and deleting it would turn a future "why is this on the CPU?" back into the
// unanswerable `installed_unused` this whole seam exists to end.
//
// NO fill fallback any more either. `GpuFillGeometry` bakes the filled ways'
// CACHED Mercator ear clip into a triangle list and their rings into a line
// list, both in the same origin-local space and both culled by the same
// `cull.wgsl`, so "the GPU lane is a *line* pipeline" stopped being true.
// What it cost while it was: 20 000 real Swedish ways at slippy zoom 6, real
// 1600x1000 window on an RTX 4090 -- 0.26 ms for the lines the lane DID take
// against 9.97 ms for the fills it refused, i.e. 97.4 % of the frame sitting
// on one core beside an idle 4090.
let (mut lines, mut fills) = (false, false);
let layers: Vec<usize> = self
.layers
.iter()
.enumerate()
.filter(|(_, l)| l.enabled())
.filter(|(_, l)| {
let has_lines =
l.gpu_ways().is_some_and(|ways| ways.iter().any(|w| w.fill.is_none() && w.lonlat.len() >= 2));
// The fill lane's OWN predicate, asked once here and re-asked
// verbatim by `gpu_lane_fills` and by `WaysLayer::features_into`.
// Three call sites, one condition -- a lake the bake skips is a lake
// the CPU painter still draws, by construction rather than by review.
let has_fills = l
.gpu_fills()
.is_some_and(|(ways, _)| ways.iter().any(Self::gpu_fillable));
lines |= has_lines;
fills |= has_fills;
has_lines || has_fills
})
.map(|(i, _)| i)
.collect();
if layers.is_empty() {
return (WayLane::Cpu(CpuOnly::NoLineWays), layers, GpuOwns::NONE, None);
}
// ── THE LANE'S RANK FLOOR — where its one opaque callback slot belongs in
// the declared stack (see `MapFacet::ui`). Asked through the SAME two
// definitions of "what the lane draws" that the bake and the signature go
// through (`gpu_lane_ways` / `gpu_lane_fill_refs`), so the slot cannot be
// placed for a set the lane does not actually draw. Computed inside the
// memoised scan, so it costs nothing per frame.
let owns = GpuOwns { lines, fills };
let floor = self.gpu_lane_rank_floor(&layers, owns);
(WayLane::Gpu { layers: layers.len() }, layers, owns, floor)
}
/// **The lowest [`crate::paint_order::PaintKey::rank`] the GPU lane will draw** for
/// `gpu_layers` under `owns` — where its single opaque callback slot belongs in the
/// declared stack (see [`MapFacet::ui`]). `None` ⇒ the lane draws nothing.
///
/// Asked through the SAME two definitions of "what the lane draws" the bake and the
/// signature go through, so the slot cannot be placed for a set the lane does not
/// actually draw.
#[cfg(feature = "wgpu")]
fn gpu_lane_rank_floor(&self, gpu_layers: &[usize], owns: GpuOwns) -> Option<i32> {
let line_floor = owns
.lines
.then(|| self.gpu_lane_ways(gpu_layers).map(crate::paint_order::way_rank).min())
.flatten();
let fill_floor = owns
.fills
.then(|| self.gpu_lane_fill_refs(gpu_layers).map(|(w, _, _)| crate::paint_order::way_rank(w)).min())
.flatten();
match (line_floor, fill_floor) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
}
}
/// **Test hook** — [`Self::gpu_lane_rank_floor`] with the lane ownership named
/// explicitly, and the split index `ui` derives from it over a given feature list.
///
/// The interesting arm is unreachable otherwise: a headless test has no
/// `RenderState`, so `way_lane_plan` answers `CpuOnly::NoRenderer` and the frame
/// never places a callback at all. This is the same arithmetic the live frame runs,
/// asked of an explicit `GpuOwns` — see `tests/paint_order_pixels.rs`.
#[doc(hidden)]
#[cfg(feature = "wgpu")]
#[must_use]
pub fn gpu_slot_split_for_test(
&self,
gpu_layers: &[usize],
lines: bool,
fills: bool,
feats: &[DrawFeature],
) -> (Option<i32>, usize) {
let floor = self.gpu_lane_rank_floor(gpu_layers, GpuOwns { lines, fills });
(floor, floor.map_or(feats.len(), |lo| feats.partition_point(|f| f.order.rank < lo)))
}
/// The unfilled ways of `gpu_layers`, in layer order — the ONE definition of "what
/// the GPU lane draws", so the origin, the signature and the geometry bake cannot
/// disagree about the set (they did once, and a mismatched origin silently culls
/// every visible way).
fn gpu_lane_ways<'a>(&'a self, gpu_layers: &'a [usize]) -> impl Iterator<Item = &'a Way> + 'a {
gpu_layers
.iter()
.filter_map(|&i| self.layers.get(i))
.flat_map(|l| l.gpu_ways().unwrap_or(&[]).iter())
.filter(|w| w.fill.is_none() && w.lonlat.len() >= 2)
}
/// The **filled** ways of `gpu_layers`, each with its cached Mercator ear clip —
/// the fill lane's twin of [`Self::gpu_lane_ways`], and the ONE definition of
/// what that lane draws.
///
/// `tris` comes from the layer's own [`tessellate::FillTessCache`], keyed by the
/// way's index in the layer's slice. Calling it here WARMS a cold slot: that is
/// deliberate and it is not new work — it is the same one-off ear clip the CPU
/// painter would have run on the frame it first drew that ring, moved to the bake
/// so both sides see one triangulation. `MapFacet::ear_clips` still reaches its
/// ceiling and stops, which is what `tests/fill_tess_cache.rs` asserts.
fn gpu_lane_fills<'a>(
&'a self,
gpu_layers: &'a [usize],
) -> impl Iterator<Item = (&'a Way, tessellate::FillTris)> + 'a {
// `tris` is `None` only for an index past the cache, which cannot happen for
// a cache built beside its own ring set.
self.gpu_lane_fill_refs(gpu_layers)
.filter_map(|(w, cache, i)| cache.tris(i, &w.lonlat, &w.holes).map(|t| (w, t)))
}
/// The filled ways of `gpu_layers` with their layer's cache and slot index — the
/// SAME set [`Self::gpu_lane_fills`] yields (one predicate, `gpu_fillable`), but
/// with the ear clip NOT yet run. The tile lane wants this: a group served from
/// the baked-vertex store never needs its triangulation, so the clip is resolved
/// only inside the bake closure, for the groups that actually bake.
fn gpu_lane_fill_refs<'a>(
&'a self,
gpu_layers: &'a [usize],
) -> impl Iterator<Item = (&'a Way, &'a tessellate::FillTessCache, usize)> + 'a {
gpu_layers
.iter()
.filter_map(|&i| self.layers.get(i))
.filter_map(|l| l.gpu_fills())
.flat_map(|(ways, cache)| {
ways.iter().enumerate().filter(|(_, w)| Self::gpu_fillable(w)).map(move |(i, w)| (w, cache, i))
})
}
/// A cheap `O(layers + ways)` signature of the way set + origin currently baked
/// into VRAM. Never `0` — `0` is the "nothing uploaded yet" sentinel.
///
/// Deliberately NOT a hash of the geometry: it must be cheap enough to run every
/// frame beside `ways_origin` (which is already `O(ways)` by design). It changes
/// when a layer is toggled, when the way count changes, when the ways move, and
/// when korp streams a new viewport in.
///
/// ## Why it folds bbox bits and not `osm_id` (`seam-5`)
///
/// It folded `w.osm_id`, with a comment promising that "two different way SETS of
/// the same length differ here". **On korp's tiled path every `osm_id` is `0`**,
/// so that term was the constant `0` and the promise was empty: the signature
/// reduced to `(origin, layer id, way count, Σ vertices)`. Two viewports that
/// happen to agree on those three numbers match, `geometry: None` goes to the
/// device, and VRAM keeps the PREVIOUS view's geometry.
///
/// MEASURED 2026-08-03 against the live tiles under `~/.korp/work/sweden/tiles`:
/// 28 974 files (z6 19, z9 674, z12 28 281); 45 sampled with pyarrow, 15 per
/// zoom, ALL with the schema `geometry: binary, tags: string, bbox:
/// struct<xmin,ymin,xmax,ymax: float>` — **not one `id` column on any zoom**.
/// `osm-katana`'s `write_tile_parquet` never wrote one and korp's reader
/// (`mapquery.rs`) falls back to `0` per row, three layers away from here.
///
/// The bbox is the fix because it exists on **both** paths — the monolith way and
/// the tiled way — and it is already computed and already `O(ways)` to walk. It
/// is also a strictly better discriminator than the id was even where ids are
/// real: an id changes when the SET changes, a bbox changes when the set changes
/// **or when a way moves**. `osm-katana` is being fixed to write the id too (it
/// is load-bearing elsewhere — `MapFacet::way(id)` and label placement both
/// collapse onto way #0 without it), but this signature no longer depends on
/// whoever served the data having done so.
fn gpu_geometry_signature(&self, gpu_layers: &[usize], origin: [f64; 2]) -> u64 {
use std::hash::{Hash, Hasher};
// ── MEMOISED per (origin, epoch, taken layers, their geometry generations):
// the walk below is `O(ways)` twice over (bbox fold + vertex count, both
// lanes) and it ran every frame — while its inputs are pinned by numbers
// that are `O(layers)` to read. A `WaysLayer`'s ways are immutable for its
// lifetime (generation stamped in its constructor; korp replaces the layer
// on data change), so "same key" ⇒ "same signature" by construction. Any
// taken layer WITHOUT that promise (generation 0) bypasses the memo and
// pays the old full walk, so external implementors stay correct.
// `tests/frame_churn_memo.rs` holds `gpu_sig_walk_count` at 1 across a
// hundred asks (seen red with the memo bypassed).
let mut kh = std::collections::hash_map::DefaultHasher::new();
origin[0].to_bits().hash(&mut kh);
origin[1].to_bits().hash(&mut kh);
self.layer_epoch.hash(&mut kh);
let mut memoisable = true;
for &i in gpu_layers {
i.hash(&mut kh);
let generation = self.layers.get(i).map_or(0, |l| l.geometry_generation());
memoisable &= generation != 0;
generation.hash(&mut kh);
}
let key = kh.finish();
if memoisable
&& let Some((k, sig)) = *self.gpu_sig_memo.lock().unwrap()
&& k == key
{
return sig;
}
self.gpu_sig_walks.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut h = std::collections::hash_map::DefaultHasher::new();
origin[0].to_bits().hash(&mut h);
origin[1].to_bits().hash(&mut h);
for &i in gpu_layers {
let Some(l) = self.layers.get(i) else { continue };
l.id().hash(&mut h);
// BOTH lanes' inputs, or a fill-only layer change is invisible to the
// latch and VRAM keeps the previous viewport's lakes.
for ways in [l.gpu_ways().unwrap_or(&[]), l.gpu_fills().map_or(&[][..], |(w, _)| w)] {
ways.len().hash(&mut h);
// Fold each way's BBOX BITS and vertex count, not its coordinates:
// one pass of adds, and two different way SETS of the same length
// differ here.
let (mut geom, mut verts) = (0u64, 0usize);
for w in ways {
for b in w.bbox {
geom = geom.wrapping_mul(31).wrapping_add(b.to_bits());
}
verts += w.lonlat.len();
}
geom.hash(&mut h);
verts.hash(&mut h);
}
}
let sig = h.finish();
let sig = if sig == 0 { 1 } else { sig };
if memoisable {
*self.gpu_sig_memo.lock().unwrap() = Some((key, sig));
}
sig
}
/// **Hand the ways to the GPU** — add the `egui_wgpu` paint callback that makes
/// `GpuMapRenderer` encode a frame (and therefore bump
/// `facett_core::render::lane`'s frame counter, which is the only proof that the
/// lane is real).
///
/// Everything here is shared code: [`crate::gpu::ways_origin`],
/// [`crate::gpu::MapTransform`], [`crate::gpu::build_gpu_geometry`] and
/// [`crate::gpu::MapPaintCallback`]. The single line of new arithmetic is the
/// camera reduction, and it is a reduction rather than a projection:
///
/// ```text
/// MapView::project (bearing 0, tilt 0) = rect.center + (merc − center_merc)·world_px
/// MapTransform = rect.center + (merc − center_merc)·zoom
/// ```
///
/// so `zoom_x == zoom_y == world_px()` and `center_merc == mercator(center)` is
/// exact, not approximate. `tests/map_facet_gpu_lane.rs` asserts the two agree
/// sub-pixel over a spread of zooms, and asserts they DIVERGE under a bearing
/// (which is why the bearing falls back).
fn add_gpu_ways_callback(
&self,
painter: &egui::Painter,
rect: egui::Rect,
gpu_layers: &[usize],
tilt_fade: f32,
stamp: crate::gpu::PaneStamp,
) {
use std::sync::atomic::Ordering;
// ── THE GROUPED LANE, taken whenever the fixed-bucket order could be WRONG ──
//
// Two reasons to group, and both land here:
//
// 1. **A stamped way** — the content-keyed per-tile VRAM cache that survives
// facet rebuilds. Unchanged.
// 2. **More than one draw band** — new, 2026-08-23. The legacy whole-payload
// callback below submits THREE FIXED BUCKETS (all fills, then all outlines,
// then all lines). That is the declared order if and only if the payload
// holds a single [`crate::paint_order::way_draw_band`]: with two, `PaintKey`
// is expressing an order three fixed buckets cannot carry, and the pixels say
// so — a `tunnel=yes` road ranks a whole `LAYER_STEP` below the lake it runs
// under and still painted on top of it, because a road is a line and lines
// are bucket three (`tests/gpu_lane_paint_order.rs`, oden / RTX 4090).
//
// A single-band payload takes the legacy path and is byte-identical there, so
// every consumer that predates the lane keeps exactly the frame it had.
//
// ── IT IS ASKED FIRST, AND THAT IS THE POINT ──────────────────────────────
//
// The whole-lane `ways_origin` below used to run BEFORE this branch, so korp —
// which is always the grouped path, because every served way carries a producer
// stamp — paid an `O(ways)` min-fold over every way of every taken layer, EVERY
// FRAME, for a value the grouped path then threw away (the tile lane bakes
// against each GROUP's own origin, in `TileGroupPlan`). `gpu_lane_needs_grouping`
// short-circuits on the first stamped way, so asking it first is `O(1)` there;
// on the legacy path it walks, but the legacy path was already going to walk for
// the origin, so that arm pays the same two walks it always did.
//
// The empty case is unchanged: an empty lane set makes this `false` (the loop
// never runs), so it still falls through to the origin guard below and returns
// without placing a callback — the same frame, by the same line.
if self.gpu_lane_needs_grouping(gpu_layers) {
self.add_gpu_tiled_callback(painter, rect, gpu_layers, tilt_fade, stamp);
return;
}
// The origin spans BOTH lanes' ways. A fill-only pane would otherwise get no
// origin at all and silently draw nothing, and a mixed pane baked against a
// line-only origin would put its lakes somewhere else entirely -- the exact
// "mismatched origin silently culls every visible way" this method's own doc
// records.
// `gpu_lane_fill_refs`, not `gpu_lane_fills`: the origin needs the WAYS, not
// their ear clips — asking for the tris here ran every fill's tessellation
// before the lane knew whether it needed them at all.
self.lane_origin_walks.fetch_add(1, Ordering::Relaxed);
let Some(origin) = crate::gpu::ways_origin(
self.gpu_lane_ways(gpu_layers).chain(self.gpu_lane_fill_refs(gpu_layers).map(|(w, _, _)| w)),
) else {
return;
};
let world_px = self.view.world_px();
let (cx, cy) = mercator(self.view.center.lon, self.view.center.lat);
let transform =
crate::gpu::MapTransform::new(origin, [cx, cy], world_px as f32, world_px as f32, rect)
.with_compass(self.view.bearing_deg, self.view.tilt_deg);
let viewport_bbox = transform.viewport_bbox(rect);
// ── Upload-once, ACKNOWLEDGED. Re-bake while either (a) the enabled way set
// changed, or (b) the last hand-over has not yet been confirmed by a real
// encoded frame. `lane_frames()` is that confirmation: it only moves inside
// `MapPaintCallback::prepare`, after the upload, against a real device. ──
let sig = self.gpu_geometry_signature(gpu_layers, origin);
let frames = crate::gpu::lane_frames();
let same = self.gpu_geom_sig.load(Ordering::Relaxed) == sig;
let acknowledged = frames > self.gpu_geom_sent_at.load(Ordering::Relaxed);
let geometry = if same && acknowledged {
None
} else {
if !same {
// A NEW set: the ack must come from a frame AFTER this hand-over.
self.gpu_geom_sig.store(sig, Ordering::Relaxed);
self.gpu_geom_sent_at.store(frames, Ordering::Relaxed);
}
let mut geo = crate::gpu::build_gpu_geometry(
self.gpu_lane_ways(gpu_layers).map(|w| (w, w.style.lod as u32)),
origin,
);
geo.fills = crate::gpu::build_gpu_fill_geometry(
self.gpu_lane_fills(gpu_layers).map(|(w, t)| (w, w.style.lod as u32, t)),
origin,
);
Some(Box::new(geo))
};
painter.add(egui_wgpu::Callback::new_paint_callback(
rect,
crate::gpu::MapPaintCallback {
stamp,
geometry,
tiles: None,
viewport_bbox,
// The SAME LOD tier the CPU `lod_visible` gate uses, so the GPU cull
// (`lod <= vp.lod`) and the CPU painter declutter identically.
lod: view_lod_tier(self.view.zoom),
transform,
// The SAME live `widthScale` `paint_features` strokes with (GG #27).
width_scale: self.line_width_scale(),
// The SAME pitched-horizon fade `paint_features` applies to a CPU
// fill (`fade(fill, tilt_fade)` = a `1 - 0.5*t` RGB multiply), so a
// tilted map's areas recede identically in both lanes. Its outline is
// NOT faded -- also as on the CPU, which strokes with `f.color`.
fill_tint: 1.0 - tilt_fade * 0.5,
// The SAME casing ink `paint_features` strokes its casing pass with
// (`kalm-4`). It was a `const` in `gpu/mod.rs` -- near-black navy on a
// warm-paper day map -- and the palette's own `casing` hue measured
// 0.03-0.06 % of the canvas, i.e. it was not on the map at all.
casing_col: Some(self.theme.palette.casing.to_normalized_gamma_f32()),
},
));
}
/// **Does this payload need the grouped (per-band, per-tile) hand-over?**
///
/// `true` when a way carries a producer tile stamp (the VRAM cache case) OR when the
/// payload spans more than one [`crate::paint_order::way_draw_band`] — see
/// [`Self::add_gpu_ways_callback`] for why the second one is not optional.
///
/// ONE scan for both questions, and it short-circuits: real served ways answer on
/// the first two features, and the honest worst case (one band, no stamps) is the
/// same `O(ways)` walk the `tile_key != 0` test it replaces already paid per frame.
#[cfg(feature = "wgpu")]
fn gpu_lane_needs_grouping(&self, gpu_layers: &[usize]) -> bool {
let mut first: Option<i32> = None;
for w in self
.gpu_lane_ways(gpu_layers)
.chain(self.gpu_lane_fill_refs(gpu_layers).map(|(w, _, _)| w))
{
if w.tile_key != 0 {
return true;
}
let band = crate::paint_order::way_draw_band(w);
match first {
None => first = Some(band),
Some(f) if f != band => return true,
Some(_) => {}
}
The file goes on; the console shows all of it.