nordisk/facett
facett-map3d/src/extrude.rs
//! **Extrusion** — turn 2D OSM ways ([`facett_map::Way`]) into a 3D triangle
//! mesh. The map's Mercator-unit footprints are recentred into a normalised
//! `[-1, 1]` design box (the same box [`facett_core::rabbit`] + facett-graph3d
//! use, so the camera + logo all agree on scale), then:
//!
//! - a **closed area/building ring** (a `Way` with a `fill`, or an explicitly
//! closed loop) is **extruded into a prism**: a top cap lifted to `height`, a
//! bottom cap on the ground (`y = 0`), and side walls joining the two rims, with
//! outward + axial normals so the lit model catches the light around its edges;
//! - a **line way** (road / path / coastline) becomes a flat **ribbon** laid on
//! the ground (`y = 0`), a thin extruded strip so roads read as a network, not
//! hairlines;
//! - a **terrain plane** quad is fitted under the whole footprint so the city sits
//! on ground, not in the void.
//!
//! The result is a [`Mesh3`] (positions + normals + per-triangle colours +
//! indices) the CPU painter / wgpu path projects, culls, sorts, and lights. Pure
//! data, deterministic, no RNG — two builds are byte-identical (snapshot-stable).
use std::borrow::Cow;
use egui::Color32;
use facett_map::{DEFAULT_BUILDING_HEIGHT_M, RoofShape, Way};
/// A single triangle's three vertex indices (into [`Mesh3::positions`]).
pub type Tri = [u32; 3];
/// The **surface class** of an extruded polygon — the prerequisite tag the water
/// feature keys on. `Opaque` polys (buildings/ground/roads) go to the normal mesh;
/// `Water` polys go to the dedicated water grid (Gerstner + Fresnel + reflection).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Surface {
/// Buildings, ground cover, roads — the opaque depth-tested mesh.
Opaque,
/// Water (OSM `natural=water` / `waterway` / coastline fill) — the reflective
/// animated water surface, at a fixed design-unit height.
Water,
}
/// Is this way a **water** surface (so it goes to the reflective water mesh, not
/// the opaque ground cover)? Mirrors `prism_height_for`'s water classification.
#[must_use]
pub fn surface_of(kind: &str) -> Surface {
match kind {
"water" | "coastline" => Surface::Water,
_ => Surface::Opaque,
}
}
/// A 3D mesh of the extruded map: positions + per-vertex normals (parallel) +
/// per-**triangle** source colours + triangle indices. Coordinates are in the
/// normalised `[-1, 1]` design box (x right, **y up**, z depth) with the ground
/// at `y = 0` and prisms rising to `+height`.
#[derive(Clone, Debug, Default)]
pub struct Mesh3 {
/// `(x, y, z)` per vertex.
pub positions: Vec<[f32; 3]>,
/// Unit normal per vertex (parallel to `positions`).
pub normals: Vec<[f32; 3]>,
/// Source colour per **triangle** (`indices.len() / 3` entries).
pub colors: Vec<Color32>,
/// Triangle indices (3 per face).
pub indices: Vec<u32>,
/// **Is this vertex part of an OSM `building`?** Parallel to
/// [`positions`](Self::positions): `true` for a wall, foundation or roof vertex of a
/// ring whose `kind` is `building`, `false` for terrain, ground cover, roads, route
/// ribbons, water caps — and for every other closed ring the extruder raises.
///
/// # Why a class and not a height test
///
/// `Map3D::building_vertices` used to answer "is this a house?" with
/// `p[1] > rise_eps`, i.e. *anything standing up*. But
/// [`prism_height_for`] raises **every** closed ring that is not
/// `water`/`park`/`coastline` — a `landuse=industrial` block, a `leisure` pitch, an
/// untagged closed way — deliberately, because most OSM buildings carry no tag saying
/// so. Height therefore cannot separate the two families, and the observable named
/// after houses counted both: the owner's 2026-08-06 log printed `risen = 36 672` beside
/// `building_ways = 0`, in a viewport that held no houses at all.
///
/// The extruder is the only place that knows, because it is holding `w.kind` when it
/// pushes the vertices. So it stamps this once, through
/// [`classify_new_vertices`](Self::classify_new_vertices), and nothing downstream
/// re-derives the answer from a y coordinate, a colour or a height (LAW 5).
pub building: Vec<bool>,
}
impl Mesh3 {
pub fn vertex_count(&self) -> usize {
self.positions.len()
}
pub fn triangle_count(&self) -> usize {
self.indices.len() / 3
}
/// Push one triangle (3 indices + its source colour).
fn push_tri(&mut self, t: Tri, col: Color32) {
self.indices.extend_from_slice(&t);
self.colors.push(col);
}
/// Push a vertex (position + normal), returning its index.
fn push_vertex(&mut self, pos: [f32; 3], nrm: [f32; 3]) -> u32 {
let i = self.positions.len() as u32;
self.positions.push(pos);
self.normals.push(nrm);
i
}
/// **Stamp [`building`](Self::building) for every vertex pushed since `from`** — the
/// ONE writer of the class.
///
/// Called once per feature by the extruder's own loop, which is the only code holding
/// the way's `kind`. Idempotent in the sense that matters: it always brings
/// `building.len()` up to `positions.len()`, so a feature that pushed no vertices
/// costs nothing and a feature that pushed some cannot leave a gap. The
/// `building.len() == positions.len()` invariant is asserted on APPLIED OUTPUT in
/// `tests/risen_means_houses.rs`, because a short vector under-counts silently.
pub(crate) fn classify_new_vertices(&mut self, from: usize, is_building: bool) {
if self.building.len() < from {
// A caller skipped a stamp. Fill the hole as NOT-a-building rather than
// guessing: an unstamped vertex is one nobody claimed as a house.
self.building.resize(from, false);
}
self.building.resize(self.positions.len(), is_building);
}
/// **Concatenate a per-worker arena onto this mesh, rebasing its indices** by the
/// running vertex count — the same offset-and-append the 2D lane's
/// `rebase_chunk_meta` does for its chunk geometry. Appending arenas in chunk
/// order reproduces the serial per-way append order byte for byte: every
/// parallel vector (`positions`/`normals`/`building` per vertex,
/// `colors` per triangle, `indices` per corner) is extended in the same order the
/// serial loop would have pushed it, and each index moves by exactly the vertex
/// count that preceded its arena.
fn append_rebased(&mut self, part: Mesh3) {
// The stamp invariant holds per arena (every way that pushes vertices stamps
// them), so it holds for the concatenation without a fix-up pass.
debug_assert_eq!(part.building.len(), part.positions.len());
let base = self.positions.len() as u32;
self.positions.extend(part.positions);
self.normals.extend(part.normals);
self.colors.extend(part.colors);
self.indices.extend(part.indices.into_iter().map(|i| i + base));
self.building.extend(part.building);
}
}
/// The **legacy absolute design-unit height** the water plane sits at. Kept only for
/// scenes with no world scale (`du_per_m == None` — the rabbit fixture, the unit-test
/// towns) so those render byte-for-byte as before. **Real data must go through
/// [`water_height`]**: as an absolute this is `3.1 m` above the ground at the default
/// scale, `9.4 m` at a 3 km town window and `174 m` at a 100 km region — a sea that
/// floats, and floats by a different amount in every viewport.
pub const WATER_HEIGHT: f32 = 5.0e-3;
/// **The clearance the water surface keeps over the terrain plane, in METRES.**
///
/// The scale-honest twin of [`GROUND_BAND_METRES`] and [`BUILDING_RISE_METRES`], and
/// for the same reason. The water plane has exactly one job above `y = 0`: win the
/// depth test against the coplanar terrain quad and sit above the flat ground cover
/// rather than inside it. That is a **kerb-height** question, not a design-unit one.
///
/// `0.6 m` sits by construction just over the top of the ground band
/// ([`ground_top`] ≈ `1.22 · 0.4 m` = `0.49 m`) and far under the shortest building
/// the extruder emits ([`MIN_DEFAULT_BUILDING_HEIGHT_M`] = 2.5 m), so "water covers
/// the ground cover" and "water never reads as a floating slab" both hold at **any**
/// scene span instead of by luck at one.
pub const WATER_RISE_METRES: f32 = 0.6;
/// **The scene's water-plane height in design units** — [`WATER_RISE_METRES`] taken
/// through the scene's own [`design_units_per_metre`], floored a clear
/// [`RISE_OVER_GROUND`] above the top of its ground band (so the sea can never sink
/// into a road ribbon) and capped at the legacy [`WATER_HEIGHT`] (so no scene ever
/// gets a *higher* water plane than it had before this fix).
///
/// `du_per_m == None` is SYNTHETIC data with no world scale: there is no metre to
/// convert, so [`WATER_HEIGHT`] is kept and those scenes are byte-for-byte unchanged.
///
/// **Honest bound:** the cap bites when `WATER_RISE_METRES · du_per_m > 5.0e-3`, i.e.
/// `du_per_m > 8.3e-3` — a span under ~190 m. At that point the scene is a single
/// block and the water plane is capped at `5.0e-3 du` = `0.6 m`… of a 190 m box. The
/// floor bites past a ~64 km span, where [`ground_band`] itself stops shrinking.
#[must_use]
pub fn water_height(du_per_m: Option<f32>) -> f32 {
match du_per_m {
Some(dpm) if dpm > 0.0 => (WATER_RISE_METRES * dpm)
.max(RISE_OVER_GROUND * ground_top(ground_band(du_per_m)))
.min(WATER_HEIGHT),
_ => WATER_HEIGHT,
}
}
/// [`water_height`] for a `ways` slice, through the **same** [`design_units_per_metre`]
/// the extruder scales everything else by — so the plane the water grid is built on and
/// the plane the reflection camera mirrors about can never be computed from two scales.
#[must_use]
pub fn water_height_for(ways: &[Way]) -> f32 {
water_height(design_units_per_metre(ways))
}
/// **The Gerstner wave amplitude in METRES** — the height of the chop on the water
/// surface, measured crest-to-plane for the FIRST (largest) octave.
///
/// The scale-honest twin of [`WATER_RISE_METRES`], and the last raw design unit left
/// in the water lane. `water.wgsl` displaces `in.pos` by this length in **world**
/// (design) space, so as an absolute `0.0020 du` it was `1.25 m` of chop at the
/// fixture's 625 m/du, `3.75 m` in `korp`'s 3 km town window and `70 m` in a region
/// load — waves taller than the houses, and a different sea in every viewport.
///
/// `0.25 m` is a real inland/short-fetch chop, and it is *also* the number the
/// construction below needs: `0.25 · `[`GERSTNER_OCTAVE_SUM`]` = 0.49 m` fits under
/// [`WATER_RISE_METRES`] (`0.6 m`), so the summed trough of all three octaves cannot
/// reach the terrain plane the water sits on.
pub const WAVE_AMPLITUDE_METRES: f32 = 0.25;
/// **The base Gerstner wavelength in METRES** — crest-to-crest for the first octave;
/// the two others are [`GERSTNER_OCTAVE_WAVELENGTHS`] of it.
///
/// As an absolute `0.14 du` this was `87 m` at the fixture scale and `262 m` in the
/// town window — a swell the size of a city block rolling across a lake. `24 m` is a
/// real short-fetch wind wave, and it is what [`water_grid_cell`] sizes the
/// tessellation from, so the grid and the wave can never be tuned apart again.
pub const WAVE_WAVELENGTH_METRES: f32 = 24.0;
/// The **legacy absolute** amplitude, in design units. Kept for span-less synthetic
/// scenes (`du_per_m == None` — the rabbit fixture, the unit-test towns) so those
/// render byte-for-byte as before, and used by the RED half of the metric guard.
pub const WAVE_AMPLITUDE_DU: f32 = 0.0020;
/// The **legacy absolute** base wavelength, in design units. Same role as
/// [`WAVE_AMPLITUDE_DU`].
pub const WAVE_WAVELENGTH_DU: f32 = 0.14;
/// The per-octave amplitude multipliers in `water.wgsl::water_vs`, in order. The
/// vertex shader sums three Gerstner waves and scales the second and third down;
/// their SUM is the worst-case vertical displacement of the surface, which is what
/// [`wave_amplitude`] has to keep above the ground plane.
///
/// This is a Rust copy of numbers that live in the shader, so
/// `scene_scale_metric_constants::the_shader_octaves_match_the_rust_copy` reads them
/// straight back out of `water.wgsl` and fails if either side drifts.
pub const GERSTNER_OCTAVE_AMPLITUDES: [f32; 3] = [1.0, 0.6, 0.35];
/// The per-octave wavelength multipliers in `water.wgsl::water_vs`, in order. The
/// smallest is the one the tessellation has to resolve.
pub const GERSTNER_OCTAVE_WAVELENGTHS: [f32; 3] = [1.0, 0.55, 0.32];
/// The worst-case vertical displacement of the summed wave, as a multiple of the base
/// amplitude: `1.0 + 0.6 + 0.35 = 1.95`.
#[must_use]
pub fn gerstner_octave_sum() -> f32 {
GERSTNER_OCTAVE_AMPLITUDES.iter().sum()
}
/// **The scene's Gerstner amplitude in design units** — [`WAVE_AMPLITUDE_METRES`]
/// taken through the scene's own [`design_units_per_metre`], and then capped so the
/// SUMMED trough of all three octaves still clears the terrain plane:
/// `sum · amp ≤ water_height(du_per_m)`. That cap is the construction — it is not a
/// tuned number, it is the statement "the sea does not cut through the ground", and
/// it holds at every span rather than by luck at one.
///
/// `du_per_m == None` is synthetic data with no world scale: [`WAVE_AMPLITUDE_DU`] is
/// kept and those scenes are byte-for-byte unchanged.
///
/// **Honest bound:** the metre wins wherever `WAVE_AMPLITUDE_METRES · sum` fits under
/// [`WATER_RISE_METRES`], which is everywhere `water_height` is on its metric arm
/// (`2.5e-5 < du_per_m < 8.3e-3`, i.e. spans from ~190 m to ~64 km). Outside that the
/// cap or the floor governs and the amplitude stops being an exact metre count — by
/// design, and in the safe direction.
#[must_use]
pub fn wave_amplitude(du_per_m: Option<f32>) -> f32 {
match du_per_m {
Some(dpm) if dpm > 0.0 => {
(WAVE_AMPLITUDE_METRES * dpm).min(water_height(du_per_m) / gerstner_octave_sum())
}
_ => WAVE_AMPLITUDE_DU,
}
}
/// **The scene's base Gerstner wavelength in design units** —
/// [`WAVE_WAVELENGTH_METRES`] through the scene's own [`design_units_per_metre`].
/// Synthetic scenes keep [`WAVE_WAVELENGTH_DU`].
#[must_use]
pub fn wave_wavelength(du_per_m: Option<f32>) -> f32 {
match du_per_m {
Some(dpm) if dpm > 0.0 => (WAVE_WAVELENGTH_METRES * dpm).max(1e-9),
_ => WAVE_WAVELENGTH_DU,
}
}
/// How many grid cells the tessellation wants across the SHORTEST Gerstner octave.
/// Four is the practical floor for a sine to read as a wave rather than as a zigzag.
const CELLS_PER_WAVE: f32 = 4.0;
/// **The water grid's target cell size in design units** — derived from
/// [`wave_wavelength`] so the tessellation and the wave it has to carry are ONE
/// number. The legacy `extent / 0.018` was an absolute chosen alongside the absolute
/// wavelength; the two were self-consistent at one scale and at no other.
///
/// **Honest bound:** the caller still clamps the resulting resolution to a vertex
/// budget (`12..=96` cells per ring), so a lake wider than `96 ·` this cell is
/// under-tessellated and its ripples read as flat. At a 3 km town window that bound is
/// a ~576 m lake; past it the wave is under a design unit of `5e-5` anyway, i.e.
/// sub-pixel, so flat is the honest picture rather than a lost feature.
#[must_use]
pub fn water_grid_cell(du_per_m: Option<f32>) -> f32 {
match du_per_m {
Some(dpm) if dpm > 0.0 => {
(wave_wavelength(du_per_m) * GERSTNER_OCTAVE_WAVELENGTHS[2] / CELLS_PER_WAVE).max(1e-9)
}
_ => WATER_GRID_CELL_DU,
}
}
/// The **legacy absolute** water-grid cell size, in design units — the `0.018` that
/// sat next to the absolute wavelength. Span-less scenes keep it.
pub const WATER_GRID_CELL_DU: f32 = 0.018;
/// The coarsest water grid any ring gets: enough cells that a lake's shoreline is a
/// polygon and not a triangle.
pub const WATER_MIN_RES: usize = 12;
/// The vertex budget: the most cells a single water ring may be tessellated into per
/// axis. `(96 + 1)² = 9409` vertices is already the ceiling one ring may cost.
pub const WATER_MAX_RES: usize = 96;
/// The grid a ring falls back to when the wave **cannot** be resolved inside
/// [`WATER_MAX_RES`] — see [`water_grid_res`]. Chosen to match what the legacy
/// `extent / 0.018` produced for a scene-sized ring (`≈ 18`), so the ring that used to
/// be budget-bound costs exactly what it used to.
pub const WATER_BUDGET_RES: usize = 18;
/// **How many cells per axis one water ring is tessellated into.**
///
/// ONE writer, and the reason it is a function rather than a literal: the grid exists
/// to carry the Gerstner wave, so the cell it wants is [`water_grid_cell`] — derived
/// from [`wave_wavelength`], not chosen beside it.
///
/// The part that is a JUDGEMENT and not a derivation, stated instead of buried: a ring
/// wider than `WATER_MAX_RES ·` cell cannot resolve the wave **whatever** resolution we
/// spend, because the budget binds first. Tessellating it to the ceiling therefore buys
/// aliasing at 26× the vertices, so it drops to [`WATER_BUDGET_RES`] — the density the
/// absolute `extent / 0.018` used to give it — and the ripples read as flat, which is
/// what a 0.25 m chop looks like from 1.8 km up anyway.
///
/// `extent` is the ring's larger bbox side, in design units.
#[must_use]
pub fn water_grid_res(extent: f32, du_per_m: Option<f32>) -> usize {
let want = (extent / water_grid_cell(du_per_m)).ceil();
if want <= WATER_MAX_RES as f32 {
(want.max(0.0) as usize).clamp(WATER_MIN_RES, WATER_MAX_RES)
} else {
WATER_BUDGET_RES
}
}
/// A **tessellated water mesh**: a grid of vertices over the water polygons, in the
/// design plane at `y = WATER_HEIGHT`. The GPU water pass vertex-displaces these by
/// summed Gerstner waves (`time` uniform), so the grid resolution sets the ripple
/// detail. Positions are `(x, y, z)`; `indices` are triangles; flat (no per-vertex
/// normal — the water shader computes the analytic Gerstner normal).
#[derive(Clone, Debug, Default)]
pub struct WaterMesh {
pub positions: Vec<[f32; 3]>,
pub indices: Vec<u32>,
}
impl WaterMesh {
#[must_use]
pub fn vertex_count(&self) -> usize {
self.positions.len()
}
#[must_use]
pub fn triangle_count(&self) -> usize {
self.indices.len() / 3
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.indices.is_empty()
}
/// The water twin of [`Mesh3::append_rebased`]: concatenate a per-worker water
/// arena, offsetting its indices by the vertices already here.
fn append_rebased(&mut self, part: WaterMesh) {
let base = self.positions.len() as u32;
self.positions.extend(part.positions);
self.indices.extend(part.indices.into_iter().map(|i| i + base));
}
}
/// Point-in-polygon (ray cast) for the water-grid cell test, in the `(x, z)` plane.
fn point_in_polygon(p: (f32, f32), poly: &[(f32, f32)]) -> bool {
let n = poly.len();
if n < 3 {
return false;
}
let mut inside = false;
let mut j = n - 1;
for i in 0..n {
let (xi, zi) = poly[i];
let (xj, zj) = poly[j];
if ((zi > p.1) != (zj > p.1))
&& (p.0 < (xj - xi) * (p.1 - zi) / (zj - zi + f32::EPSILON.copysign(zj - zi)) + xi)
{
inside = !inside;
}
j = i;
}
inside
}
/// Tessellate one water ring (design-plane `(x, z)`) into a grid of `res×res`
/// cells over its bbox, keeping the cells whose centre lies inside the polygon —
/// a small Gerstner-ready grid. Vertices at `y = y_water` — the scene's own
/// [`water_height`], never the absolute constant. Deterministic.
/// **Mesh a water ring.** `pub` since lane MP-b so the shore shape is assertable from
/// a test rather than only visible on a screen — the defect the owner reported is exactly
/// a shape this function decides.
pub fn water_grid_into(water: &mut WaterMesh, ring: &[(f32, f32)], res: usize, y_water: f32) {
let poly = open_ring(ring);
if poly.len() < 3 {
return;
}
// ── THE SHORE IS THE RING, not a lattice (lane MP-b, 2026-09-05) ────────────
//
// This used to grid the ring's BOUNDING BOX and keep "a quad (2 tris) per cell
// whose CENTRE is inside the polygon". The ring's vertices were never geometry,
// only an inside/outside test for whole cells — so the shore was quantised to the
// grid at ANY source precision. The owner, live on t14s: *"in 3D, zoomed in, lakes
// are Minecraft-squared."* MEASURED on a 64-gon: 20 of 112 shore vertices lay on
// the ring. Eighteen percent.
//
// Handing the lane a sharper level would not have rounded one edge, which is why
// the level-swap hypothesis had nothing to fix. The cure is to triangulate the ring
// itself, through the SAME ear-clip the 2D fill lane paints water with
// (`facett_map::tessellate::triangulate`) — one tessellator, so the two views cannot
// disagree about where a lake's edge is.
//
// `res` still decides how much INTERIOR detail the Gerstner wave gets: the ring's
// own vertices carry the shore, and interior points are added on the same grid
// pitch so a big lake still ripples instead of reading as one flat sheet. Only
// points strictly inside the polygon are added — a grid point outside it would pull
// a triangle over the shore and undo the whole change.
// `triangulate` ear-clips a simple polygon RING. It is not a constrained
// triangulation of a point set: appending interior samples to the vertex list makes
// it read them as part of the outline, and the first cut of this change did exactly
// that — a 64-gon came back with 260 boundary edges instead of 64, i.e. a shredded
// mesh. The ring, and only the ring.
let base = water.positions.len() as u32;
let as_pos2: Vec<egui::Pos2> = poly.iter().map(|&(x, z)| egui::pos2(x, z)).collect();
let tris = facett_map::tessellate::triangulate(&as_pos2);
if tris.is_empty() {
return;
}
for &(x, z) in &poly {
water.positions.push([x, y_water, z]);
}
for t in tris {
water.indices.extend_from_slice(&[base + t[0], base + t[1], base + t[2]]);
}
// `res` is deliberately unused now, and the trade is worth stating rather than
// hiding: the Gerstner wave displaces VERTICES, so a lake meshed from its ring alone
// ripples at the shore and reads flatter in the middle than the old grid did. That
// is the same trade `water_grid_res` already made on purpose for region-scale water
// ("a region-scale lake reads flat instead of costing 26x the vertices to alias the
// same ripple") — and a flat middle is a lesser defect than a staircase shore, which
// is the one an operator actually reported. Restoring interior detail means
// subdividing the ring's triangles, not re-adding grid points to the outline.
let _ = res;
}
/// The footprint bbox of all ways in Mercator-unit space, used to recentre +
/// scale the map into the `[-1, 1]` design box.
fn footprint_bbox(ways: &[Way]) -> Option<(f32, f32, f32, f32)> {
let mut b: Option<(f32, f32, f32, f32)> = None;
for w in ways {
for &(x, y) in &w.points {
b = Some(match b {
None => (x, y, x, y),
Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x), y1.max(y)),
});
}
}
b
}
/// Fraction of the `[-1,1]` (span 2) box the data's **larger** axis fills — the
/// footprint is uniformly scaled so its long side spans `DESIGN_FILL` design
/// units (≈80% of the box). The height scaler below uses this so a building's
/// metric height maps to the SAME design-units-per-metre as its footprint.
pub const DESIGN_FILL: f32 = 1.6;
/// **Where the user is looking** — the 2D map camera, handed to the 3D builder so the
/// extruded town is the one under the viewport instead of whichever one happens to have
/// the most houses in it.
///
/// `span_m` is the viewport's **larger** real-world span in metres (the same quantity
/// [`world_span_metres`] answers for a ways set), so the two can be compared directly.
/// The host builds this from its own map camera — korp inverts its Web-Mercator
/// viewport bbox — and this crate never guesses at one.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ViewWindow {
/// Viewport centre longitude (WGS-84 degrees).
pub lon: f64,
/// Viewport centre latitude (WGS-84 degrees).
pub lat: f64,
/// The viewport's larger span, in metres.
pub span_m: f64,
}
/// **The legibility target the two bounds below are derived from: how many pixels the
/// MEDIAN house must stand on a 1150 px pane.**
///
/// Named, because it is the only number in this file that is a judgement rather than a
/// measurement, and because both bounds are now answers to it instead of independent
/// guesses. Eight pixels is a skyline you can read; a third of one is the flat white
/// pancake this whole subset exists to stop drawing.
pub const LEGIBLE_MEDIAN_HOUSE_PX: f64 = 8.0;
/// **The readability cap on the town half-window, in metres.**
///
/// # It was derived by extrapolation, and the extrapolation was wrong by 2.7x
///
/// The previous value (1500 m) came from one reading and one assumed law: at Stockholm
/// z12 the loaded ways spanned ~55.7 km and the median building stood **0.43 px** on a
/// 1900x1150 window (2026-08-03, live korp-server, RTX 4090); pixel height goes as
/// 1/span, so a 3 km scene "puts that same median at `0.43 · 55700/3000 ~= 8 px`".
/// The intent was [`LEGIBLE_MEDIAN_HOUSE_PX`] and the arithmetic is right.
///
/// **The step that does not hold is "that same median".** Narrowing the window does not
/// magnify a fixed population of houses, it CHANGES the population: the 55.7 km reading
/// is dominated by suburban sheds, the 3 km one by inner-city blocks, and the two
/// medians are not the same building. MEASURED end to end instead of extrapolated
/// (`tests/zoom_ladder::print_the_ladder`, the 2.4 km / 3600-house fixture, viewport
/// 1150 px, `skyline_percentile_px`):
///
/// ```text
/// half_m scene_m p50 px p90 px max px
/// 1500 2372 2.99 4.74 8.00 <- the OLD cap
/// 1158 2292 3.19 5.27 9.11
/// 579 1092 8.89 16.28 30.42
/// 400 772 10.90 21.40 46.34
/// ```
///
/// At the old cap the median house stood **2.99 px**, not 8 — the cap missed its own
/// stated target by 2.7x, and 2.99 px is inside the definition of the pancake the same
/// docstring gives ("a third of one").
///
/// **580 m is not a fit, it is a row of that table.** A `p50 = A · half^-k` fit through
/// the bracketing rows suggests 622 m, and 622 m MEASURES 6.38 px — the curve is lumpy
/// at the few-percent scale because the framing quantises on `frame_densest_cluster`'s
/// 3x3 cell neighbourhood, so a fitted constant would have been a number nobody had
/// looked at. 580 m is the widest window on the ladder whose APPLIED p50 clears
/// [`LEGIBLE_MEDIAN_HOUSE_PX`]: **8.89 px**, 2.97x the old cap, at 9 410 triangles
/// against 43 202. Cheaper AND legible; the old cap was paying 4.6x the geometry for a
/// third of the house.
///
/// The cost is stated plainly: the camera sees a smaller piece of the city at every zoom
/// above the cap — 1.16 km across rather than 3 km. That is the trade the constant
/// exists to make, and it is the direction the evidence points; there is no third
/// option, because the window can never be wider than legibility allows.
///
/// **What this does NOT fix, and cannot.** Above the cap the extruded scene is identical
/// at every zoom, and that is forced, not a defect: korp's z9 viewport is 159 km, and any
/// window that honoured it would BE the pancake. A flat band above the cap is what
/// capping means. What was wrong was where the cap sat and what it showed there.
pub const TOWN_HALF_MAX_M: f64 = 580.0;
/// **The floor on the town half-window, in metres.**
///
/// The old floor (400 m) was argued as "a 300 m window is three houses and no street".
/// MEASURED on the same ladder, a 400 m half-window is a **772 m** scene holding 400
/// houses and 4802 triangles — not three houses, a small town seen from outside it. It
/// was not the floor that was too low; the estimate under it was.
///
/// The floor is what the deep end of the zoom is FOR — "down among the houses" — and at
/// 400 m the deepest the pane can be driven is still a model on a table. 150 m is a 300 m
/// scene: a couple of blocks and the streets between them, which is a place you can be
/// inside of. MEASURED on the ladder, the deepest rung goes from `p50 10.90 px` (400 m,
/// 400 houses) to **32.23 px** (150 m, 64 houses), and dollied in from 14 px to 39 px
/// with a 364 px tallest wall — a street, not a table model. The band over which the
/// window tracks the zoom is exactly `log2(MAX/MIN)` wide, so this also widens it from
/// 1.91 camera zooms to 1.95 despite the cap coming down 2.6x.
pub const TOWN_HALF_MIN_M: f64 = 150.0;
/// **How much geometry a camera window must hold to count as "a place the user is
/// looking at".** A whole-Sweden viewport has a centre, and that centre is open Baltic;
/// honouring it would extrude water. This is the threshold that separates "the user
/// picked a town" from "the user is looking at a country", and it is deliberately LOW —
/// the question is *is there a scene here at all*, not *is this the best scene*.
///
/// MEASURED on the shipped thin `korp-ui` against the live `korp1.1` server, a real
/// focused 1900x1150 Xvfb window, camera over central Stockholm at zoom 13.2
/// (2026-08-05, RTX 4090 [discrete/Vulkan]): **3 228** building centroids inside the
/// camera window, against a threshold of 24 — a margin of 134x. A real town is not
/// near this line; it is two orders of magnitude clear of it.
///
/// The other end is the country overview, where the count has to collapse to roughly
/// nothing or the fallback never fires. That case is asserted on the synthetic
/// two-town fixture (`tests/camera_picks_the_town.rs`, a 900 km viewport centred east
/// of Gotland → `WindowSource::Densest`); it has NOT been measured against the live
/// pyramid, and this comment says so rather than implying a reading nobody took.
///
/// Because the two ends sit orders of magnitude apart, the exact value matters far
/// less than having one at all. 24 is "more than a hamlet, less than a village".
pub const MIN_BUILDINGS_IN_VIEW: usize = 24;
/// **The WIDE anchor: the viewport span at which the town window sits at its cap.**
///
/// korp's MEASURED z12 viewport on a 1900x1150 pane (korp `5f0ba51`:
/// `156543.03 · cos(59.33) / 2^12 = 19.49 m/px × 1900 px`). Anything wider than this is
/// a region overview and gets the widest legible town there is.
pub const TOWN_ZOOM_WIDE_SPAN_M: f64 = 37_042.0;
/// **The DEEP anchor: the narrowest viewport korp's map camera can actually produce.**
///
/// MEASURED 2026-08-05 on the shipped thin `korp-ui` against the live `korp1.1` server,
/// a real focused 1900x1150 Xvfb window (RTX 4090 [discrete/Vulkan]): driving the wheel
/// past the z15 rung parks the camera at **16.30** and it goes no further — the drive
/// harness's `aim 17` does not converge — where `map3d_aim.viewport_span_m` reads
/// **1245.0**. That is the closest the user can get, so it is where the deep end of the
/// window's travel has to land.
///
/// **This number is the whole reason [`town_half_for_span`] is not just a clamp.** With
/// the window equal to half the viewport span, the only camera zooms that move it are
/// those whose half-span lies inside `[TOWN_HALF_MIN_M, TOWN_HALF_MAX_M]` — spans of
/// 300 m to 1160 m. korp cannot produce a span under 1245 m at all, so under the plain
/// clamp the band is **unreachable from above**: every zoom the user can reach is pinned
/// at the cap and the 3D scene is one scene, forever. That is the report, restated as
/// the two measurements that force it.
pub const TOWN_ZOOM_DEEP_SPAN_M: f64 = 1_245.0;
/// **The town half-window for a viewport span — the ONE writer for "how much world does
/// the 3D pane show at this zoom" (L5).**
///
/// # Why this is a log interpolation and not `0.5 · span` clamped
///
/// The obvious spelling is "the window IS the viewport, clamped to what is legible", and
/// it was the shipped one. Two measurements kill it:
///
/// * a legible scene is at most `2·TOWN_HALF_MAX_M` = 1.16 km across (see that constant:
/// at 3 km the median house stands 2.99 px), and
/// * korp's map camera bottoms out at a **1245 m** viewport ([`TOWN_ZOOM_DEEP_SPAN_M`]).
///
/// So the clamp's band lies entirely BELOW every viewport korp can produce, and the
/// identity relation delivers exactly one scene at every reachable zoom. MEASURED on the
/// live server: `metres_per_design_unit` **1874.0** at camera 9.30 and **1875.0** at
/// camera 14.10 — five zoom levels, one scene, `half_m = 1500.0` at both. The 2D pane
/// zooms 2^10; the 3D pane zooms 1.00x. No choice of the two bounds fixes that, because
/// widening the cap to reach the band is the pancake and narrowing it moves the band
/// further out of reach. The RELATION has to change.
///
/// # What it is
///
/// A geometric interpolation of the half-window between its two bounds, driven by where
/// the viewport span sits between the two anchors, in log space (which is the space zoom
/// levels live in — one zoom level is one factor of two on both axes):
///
/// ```text
/// t = clamp( log2(WIDE/span) / log2(WIDE/DEEP), 0, 1 )
/// half_m = MAX · (MIN/MAX)^t
/// ```
///
/// so a whole-country viewport gets [`TOWN_HALF_MAX_M`], the deepest reachable viewport
/// gets [`TOWN_HALF_MIN_M`], and **every zoom in between moves the scene**, monotonically
/// and by a constant factor per zoom level (`(MIN/MAX)^(1/log2(WIDE/DEEP))` = 0.777x per
/// zoom, a 1.29x magnification per level). Both ends stay clamped, so no camera can ask
/// for a scene outside the legible band.
///
/// The honesty cost, stated: the 3D scene is no longer the same patch of world the 2D
/// viewport shows — it is a legible fraction of it that shrinks as you zoom in. It never
/// was the same patch (the cap saw to that); what changes is that the discrepancy is now
/// a declared, monotone function instead of a constant with a clamp hiding behind it.
#[must_use]
pub fn town_half_for_span(span_m: f64) -> f64 {
if !span_m.is_finite() || span_m <= 0.0 {
return TOWN_HALF_MAX_M;
}
let decades = (TOWN_ZOOM_WIDE_SPAN_M / TOWN_ZOOM_DEEP_SPAN_M).log2();
let t = ((TOWN_ZOOM_WIDE_SPAN_M / span_m).log2() / decades).clamp(0.0, 1.0);
// Geometric, not linear: equal zoom steps must give equal RATIOS, or the scene
// would crawl at one end of the band and jump at the other.
TOWN_HALF_MAX_M * (TOWN_HALF_MIN_M / TOWN_HALF_MAX_M).powf(t)
}
/// Which rule picked the town window — the observable that makes the camera-first
/// policy assertable instead of assumed. Published by korp under
/// `$KORP_STATE.map.map3d.town_window.source`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WindowSource {
/// The **camera** chose it: the viewport centre held enough buildings
/// ([`MIN_BUILDINGS_IN_VIEW`]) to be a scene. This is the normal case and the one
/// the user means by "show me what I am looking at".
Camera,
/// **No usable camera** — none supplied (a fixture, a standalone demo), or one whose
/// centre/span was not a finite place — so the densest cluster of building footprints
/// chose it. The historical behaviour, now the fallback.
///
/// An aimed camera whose window is EMPTY does **not** land here: that is a
/// [`TownChoice::TooCoarse`] refusal, because the vote would answer confidently about
/// somewhere the user is not looking (Stockholm → Sigtuna, 37 km; Stockholm at z9 →
/// a town 66 km out).
Densest,
/// **The camera's own window was empty, so it SLID to the densest cluster still inside
/// the camera's viewport.**
///
/// Distinct from [`Self::Densest`], which is unbounded and can answer confidently about
/// somewhere the user is not looking. This one cannot: every candidate is inside the box
/// the operator has on screen, so the worst it can do is pick the wrong block of the
/// street they are already looking at.
///
/// MEASURED 2026-08-08 on the owner's live session: camera span **4488 m** with **940
/// buildings on screen**, `town_half_for_span` = **250 m**, and that centre 250 m held
/// **zero**. The refusal is correct reasoning for a country overview — its comment argues
/// the case at 66 km — and simply wrong at city zoom, where the buildings are right there
/// and merely not under the crosshair. He got `0 resta, 2 trianglar` and a flat pane over
/// a city he could see.
CameraSlid,
}
/// The town window a subset would use: where it is, how big it is, who chose it, and how
/// many building footprints are in it. Pure — computing it moves no geometry — so a
/// caller (and a test) can ask *what will be extruded* without extruding it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TownWindow {
/// Which rule chose this window.
pub source: WindowSource,
/// Window centre longitude (WGS-84 degrees).
pub lon: f64,
/// Window centre latitude (WGS-84 degrees).
pub lat: f64,
/// Half-size in metres — the window is `2·half_m` across.
pub half_m: f64,
/// Building footprints whose centroid lies inside it.
pub buildings: usize,
}
/// **Why a camera-aimed window was refused** — the numbers behind the refusal, so the
/// pane can say more than "empty" and a test can assert on the reason rather than on a
/// painted string.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TownRefusal {
/// The window that was asked about (metres, half-size) — the clamped camera span.
pub half_m: f64,
/// How many building footprints were actually in it.
pub buildings: usize,
/// How many it needed ([`MIN_BUILDINGS_IN_VIEW`]).
pub needed: usize,
/// The camera's own span in metres — what the user is looking at, and the number
/// that tells them which way to zoom.
pub span_m: f64,
}
impl TownRefusal {
/// **Is the view simply EMPTY of houses, as opposed to too coarse to resolve them?**
///
/// [`choose_town`] answers `TooCoarse` for both, because both are "the window could
/// not be filled" — but they are not the same fact and they do not have the same
/// remedy. Over open country the count is a hard **zero** and no amount of zooming
/// produces a house; over a city at region zoom the houses exist and the sample is
/// thin. The pane told the operator to *zoom in* in both cases, which is advice that
/// cannot possibly work in the first one.
#[must_use]
pub fn no_buildings_at_all(&self) -> bool {
self.buildings == 0
}
/// **The refusal, in words — the ONE writer** (L5). The pane paints this and korp
/// logs it, and when they were two format strings they drifted: korp's line said
/// "Nothing extruded" after the pane had started drawing the ground.
///
/// Two facts, two sentences — see [`Self::no_buildings_at_all`].
#[must_use]
pub fn message(&self) -> String {
if self.no_buildings_at_all() {
format!(
"no buildings in this {:.0} km view — nothing here to raise. The ground, \
water and roads that ARE here are drawn.",
self.span_m / 1000.0,
)
} else {
format!(
"too sparse to raise a town: the {:.0} km view holds {} buildings in its \
{:.1} km centre and {} are needed. Zoom in for a skyline.",
self.span_m / 1000.0,
self.buildings,
2.0 * self.half_m / 1000.0,
self.needed,
)
}
}
}
/// The three honest answers to "which town do I extrude?".
///
/// [`town_window`] flattens this to `Option<TownWindow>` for the callers that only care
/// about the positive case; [`choose_town`] is the one that can say *refused*.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum TownChoice {
/// A window was chosen — by the camera, or by the vote when there is no camera.
Chosen(TownWindow),
/// **A camera was supplied, and the data under it could not answer.** The vote is
/// NOT consulted: it would answer confidently about somewhere else.
///
/// `buildings: 0` is inside this answer, not [`Self::Nothing`]. "The camera is aimed
/// at a place that holds no houses" is a refusal like any other short count — see
/// [`choose_town`] for the measurement that moved it here.
TooCoarse(TownRefusal),
/// Nothing to window **and no camera to bound it by**: no buildings at all, or too
/// few to cluster, on a call that passed `view: None`. The caller keeps everything —
/// this is the synthetic-fixture / tiny-input case, not a refusal.
///
/// It is reachable ONLY with `view == None` (or a non-finite window). With a camera in
/// hand every unanswerable case is a [`Self::TooCoarse`], because "keep everything" is
/// not a safe default once real geometry is involved: see [`view_town_subset`].
Nothing,
}
/// Building centroids in `(lon, lat)` — the "where are the houses" reading every window
/// decision below is taken on. One writer (L5): the densest-cell vote and the
/// camera-window population count are the same question asked at two places, and when
/// they were two loops they were free to disagree about what a building is.
fn building_centroids(ways: &[Way]) -> Vec<(f64, f64)> {
let mut pts: Vec<(f64, f64)> = Vec::new();
for w in ways {
if w.kind != "building" || w.lonlat.len() < 3 {
continue;
}
let (mut sx, mut sy, mut n) = (0.0, 0.0, 0.0);
for &(lon, lat) in &w.lonlat {
sx += lon;
sy += lat;
n += 1.0;
}
pts.push((sx / n, sy / n));
}
pts
}
/// **How far past the legibility cap a window may be widened when the DATA is too thin
/// to fill it.** A multiple of [`TOWN_HALF_MAX_M`], not a second metre constant, so the
/// two cannot drift apart.
///
/// # Why legibility is allowed to lose this argument
///
/// MEASURED 2026-08-05, live korp-server, real focused 1900x1150 Xvfb window, RTX 4090
/// [discrete/Vulkan]: at camera 9.40 korp serves the **LOD-9 generalised** tile set and
/// its own status line says `CAPPED at LOD 9 — 40000 ways`. Forty thousand ways for a
/// 148 km viewport is one heavily decimated footprint per few hundred metres, so a 580 m
/// window over it caught **180 risen vertices and 269 triangles** — three enormous
/// simplified polygons and a lot of white. The 1500 m window it replaced caught 3 645.
///
/// Both pictures are wrong, and the wider one is wrong more usefully: a legibility bound
/// protects the reading of a scene that EXISTS, and at region zoom the scene does not.
/// So a window that cannot find [`MIN_BUILDINGS_IN_VIEW`] doubles until it can, up to
/// this bound. That is a deliberate ordering — a sparse-but-wide scene beats an empty
/// legible one — and it is stated here rather than discovered from a screenshot.
///
/// 4x is two doublings past the cap: 2.32 km half-window, 4.6 km of world, which is the
/// order of the pre-2026-08-05 behaviour and therefore known not to fall over.
pub const SPARSE_WIDEN_MAX_FACTOR: f64 = 4.0;
/// Widen `half_m` (doubling) until the window holds [`MIN_BUILDINGS_IN_VIEW`] centroids
/// or hits [`SPARSE_WIDEN_MAX_FACTOR`]x [`TOWN_HALF_MAX_M`]. Returns the window it
/// settled on and its population, or the widest tried when even that is empty.
///
/// **Used by the DENSITY fallback only, deliberately.** The camera branch refuses
/// instead of widening ([`TownChoice::TooCoarse`]), because widening there was measured
/// to need a 40 km scene. The vote is a different case: it picked the HEAVIEST cell, so
/// it is looking at buildings by construction, and the only open question is whether the
/// legible window is wide enough to hold enough of them.
fn grow_to_population(pts: &[(f64, f64)], lon: f64, lat: f64, start_half_m: f64) -> (f64, usize) {
let ceiling = SPARSE_WIDEN_MAX_FACTOR * TOWN_HALF_MAX_M;
let mut half_m = start_half_m.min(ceiling);
loop {
let n = buildings_in_window(pts, lon, lat, half_m);
if n >= MIN_BUILDINGS_IN_VIEW || half_m >= ceiling {
return (half_m, n);
}
half_m = (half_m * 2.0).min(ceiling);
}
}
/// Count the centroids inside a `(lon, lat)` window centred on `(lon, lat)` with
/// half-size `half_m` metres.
fn buildings_in_window(pts: &[(f64, f64)], lon: f64, lat: f64, half_m: f64) -> usize {
let (wlon, wlat) = (half_m / m_per_deg_lon(lat), half_m / M_PER_DEG_LAT);
pts.iter()
.filter(|&&(x, y)| (x - lon).abs() <= wlon && (y - lat).abs() <= wlat)
.count()
}
/// **Choose the town window: the camera first, the densest cluster only when the camera
/// cannot answer.** The one writer (L5) behind every "which town gets extruded?"
/// question; [`densest_cluster_subset`] and [`view_town_subset`] are both this plus the
/// clipping.
///
/// # The half-window follows the zoom — see [`town_half_for_span`] for how, and why the
/// obvious spelling could not work
///
/// # Why the camera wins
///
/// Density picks the cell with the most footprints, and that cell is a suburb — a
/// dormitory grid of small houses out-counts an inner city of large blocks. The owner
/// panned to Stockholm, switched to 3D and got **Sigtuna**. The camera is not a better
/// heuristic than density, it is the *answer to a different and correct question*: the
/// user already told us where to look.
///
/// Returns `None` when there is nothing to window (no buildings at all).
#[must_use]
pub fn town_window(ways: &[Way], view: Option<ViewWindow>) -> Option<TownWindow> {
match choose_town(ways, view) {
TownChoice::Chosen(w) => Some(w),
TownChoice::TooCoarse(_) | TownChoice::Nothing => None,
}
}
/// **The whole decision, including the one answer [`town_window`] cannot express: that
/// the view is too coarse to choose at all.**
///
/// `town_window` returns `Option<TownWindow>`, so "no camera, so the vote decided",
/// "nothing here to window" and "the camera asked and the data could not answer" all
/// collapse into `Some(densest)`/`None`. The third case is a different thing, and at
/// region zoom it is the common one — see [`TownChoice::TooCoarse`].
#[must_use]
pub fn choose_town(ways: &[Way], view: Option<ViewWindow>) -> TownChoice {
let pts = building_centroids(ways);
// **THE CAMERA IS ASKED FIRST, EVEN WHEN THERE ARE NO BUILDINGS AT ALL.**
//
// `if pts.is_empty() { return Nothing }` used to stand above this block, and that one
// line is three of the numbers in the owner's 2026-08-06 log. `Nothing` means *the
// caller keeps everything* ([`view_town_subset`]), so a viewport whose reply happened
// to carry no building rows handed the extruder its ways UNCLIPPED — and a korp reply
// returns a way WHOLE whenever any of its vertices is in the box, so 86 ways of
// coastline and lake rings out of a **1 827 m** viewport spanned **77 km**:
//
// ```text
// rebuild: src=null half_m=null m/du=48232.0 tris=813626 risen=36672 … scene_ways=86 aim_offset_m=30057 aim_ratio=16.45
// ```
//
// `m/du = 48 232` (an 8 m house = 1.7e-4 design units), a scene centre **30 km** from
// the camera, 813 626 triangles out of 86 ways. All three are the same discarded
// window: the camera was supplied and never consulted, because the buildings were
// counted first.
//
// Zero buildings under an aimed camera is a REFUSAL, not an absence of information —
// it is the same statement `buildings < MIN_BUILDINGS_IN_VIEW` already makes, at the
// limit, and it already has an honest answer that bounds the scene (`TooCoarse` ⇒
// extrude nothing ⇒ the pane's "zoom in" state). `Nothing` survives for the callers
// that genuinely have no camera — the fixtures and the standalone demos — which is
// what its doc always claimed it was for.
if let Some(v) = view && v.lon.is_finite() && v.lat.is_finite() && v.span_m.is_finite() && v.span_m > 0.0 {
// NOT widened. `grow_to_population` exists and the density fallback below
// uses it, but the refusal a few lines down measured what widening would
// cost HERE: the capped z9 sample first reaches MIN_BUILDINGS_IN_VIEW at
// half_m = 20 000 m, 13.3x the readability cap. A camera looking at a place
// the sampler did not deliver wants an honest refusal, not a 40 km pancake.
let half_m = town_half_for_span(v.span_m);
let buildings = buildings_in_window(&pts, v.lon, v.lat, half_m);
if buildings >= MIN_BUILDINGS_IN_VIEW {
return TownChoice::Chosen(TownWindow {
source: WindowSource::Camera,
lon: v.lon,
lat: v.lat,
half_m,
buildings,
});
}
// ── THE CAMERA ASKED AND THE DATA COULD NOT ANSWER ────────────────
//
// This used to fall through to the vote, and the vote always answers. On
// oden's live pyramid at z9 that answer was a town **66 113 m** from where
// the user was looking (ratio 0.415).
//
// MEASURED on the live sweden pyramid, camera on Stockholm at z9
// (span 159 375 m, so the window clamps to 1500 m):
//
// | what | buildings in the window |
// |---------------------------------------------|-------------------------|
// | what the selector SEES (budget-capped) | **0** |
// | the same z9 tile, uncapped | 155 |
// | the z15 pyramid over the same box | **4 734** |
//
// The viewport's whole capped sample holds 132 building centroids against
// 88 441 in the same tiles uncapped — **0.149 %** — because a 40 000-way
// budget is split over 15 covering tiles (2 857 rows for the camera's own
// tile, 7.1 %) and the rows are stored `extent-desc`, i.e. the LARGEST
// geometry first. So both fallbacks are properties of the sampler, not of
// the landscape: weighing buildings alone lands in a suburb, weighing
// everything lands in open water.
//
// That measurement rules out the two tempting repairs, on numbers:
//
// * **Lower `MIN_BUILDINGS_IN_VIEW`** — impossible. The window holds
// ZERO. No threshold above 0 can be met, and 0 means "extrude an empty
// window".
// * **Widen the window** — the capped sample first reaches 24 buildings
// at `half_m = 20 000` (a 40 km scene): **13.3× `TOWN_HALF_MAX_M`**,
// the readability cap that exists to stop the pancake. That repairs the
// choice by destroying the scene.
//
// So it refuses, and the pane says why. A country overview with an empty 3D
// pane and a "zoom in" line is honest; a confident city 66 km away is not.
//
// NOT the upstream fix: the 3 km box is STARVED by the per-tile budget
// split (`korp::map_tiles::fair_take`). Giving the camera's own tile more
// than an equal share is the real repair, and it lives in korp.
// **BEFORE REFUSING: SLIDE THE WINDOW, INSIDE THE VIEWPORT.**
//
// Everything argued above is about a COUNTRY overview, where the window holds
// zero because there is nothing within tens of kilometres and the vote would
// answer about somewhere the user is not looking. At city zoom the same branch
// fires for a completely different reason: the buildings are ON SCREEN and merely
// not under the crosshair.
//
// MEASURED 2026-08-08 on the owner's live session: camera span **4488 m**, **940
// buildings in the viewport**, `half_m` **250 m**, and that centre 250 m held
// **zero**. He got `0 resta, 2 trianglar` and a flat pane over a city he could
// see, and the refusal was working exactly as written.
//
// So the window slides to the densest cluster that is still INSIDE the camera's
// own viewport. That is not the vote the comment above rules out: every candidate
// is within the box the operator has on screen, so the worst outcome is the wrong
// block of the street already in front of them — never Sigtuna, never 66 km. If
// nothing inside the viewport reaches the threshold either, the refusal stands and
// the pane still says why.
if let Some(slid) = densest_within(&pts, &v, half_m) {
return TownChoice::Chosen(slid);
}
return TownChoice::TooCoarse(TownRefusal {
half_m,
buildings,
needed: MIN_BUILDINGS_IN_VIEW,
span_m: v.span_m,
});
}
// ── fallback: the densest cell of building footprints ─────────────────────
//
// **Buildings alone vote here, and only here.** The open question was whether the
// cell vote should weigh all geometry or only `building`: weighing everything drags
// Stockholm's answer into the water (Riddarfjärden is a lot of vertices), weighing
// buildings alone lands in the suburb. Both are wrong, which is the tell that the
// vote was never the right instrument — it is now reached only when NO camera can
// answer, i.e. a country overview, where "the densest concentration of houses" is
// exactly the right thing to mean and water is exactly the wrong thing to mean.
if pts.len() < 50 {
return TownChoice::Nothing; // too sparse to cluster → the caller keeps everything
}
let (mut lon0, mut lat0, mut lon1, mut lat1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for &(lon, lat) in &pts {
lon0 = lon0.min(lon);
lat0 = lat0.min(lat);
lon1 = lon1.max(lon);
lat1 = lat1.max(lat);
}
let lat_mid = (lat0 + lat1) * 0.5;
let m_per_deg_lon = m_per_deg_lon(lat_mid);
let half_m = TOWN_HALF_MAX_M;
// Bin building centroids on a ~half-window grid; pick the heaviest cell as the town.
let cell_deg_lat = half_m / M_PER_DEG_LAT;
let cell_deg_lon = half_m / m_per_deg_lon;
let nx = (((lon1 - lon0) / cell_deg_lon).ceil() as usize + 1).max(1);
let ny = (((lat1 - lat0) / cell_deg_lat).ceil() as usize + 1).max(1);
let cx = |lon: f64| (((lon - lon0) / cell_deg_lon) as usize).min(nx - 1);
let cy = |lat: f64| (((lat - lat0) / cell_deg_lat) as usize).min(ny - 1);
let mut bins = vec![0u32; nx * ny];
for &(lon, lat) in &pts {
bins[cy(lat) * nx + cx(lon)] += 1;
}
let best = bins.iter().enumerate().max_by_key(|(_, c)| **c).map(|(i, _)| i).unwrap_or(0);
let (bcy, bcx) = (best / nx, best % nx);
let town_lon = lon0 + (bcx as f64 + 0.5) * cell_deg_lon;
let town_lat = lat0 + (bcy as f64 + 0.5) * cell_deg_lat;
// The sparsity rule, on THIS branch only. The camera branch does NOT widen, and the
// refusal above says why on numbers: the capped z9 sample first reaches
// MIN_BUILDINGS_IN_VIEW at half_m = 20 000 m, 13.3x the readability cap. The density
// vote is a different case — it picked the HEAVIEST cell, so it is looking at
// buildings by construction, and the only question is whether the legible window is
// wide enough to hold enough of them.
let (half_m, buildings) = grow_to_population(&pts, town_lon, town_lat, half_m);
TownChoice::Chosen(TownWindow { source: WindowSource::Densest, lon: town_lon, lat: town_lat, half_m, buildings })
}
/// **The densest window of `half_m` that is still inside the camera's viewport** — the slide
/// [`choose_town`] tries before it refuses. `None` when no placement inside the viewport
/// reaches [`MIN_BUILDINGS_IN_VIEW`], which is the honest "there is nothing here to extrude".
///
/// Bounded by construction: every candidate centre is a building centroid that is itself
/// inside the viewport, so the answer cannot wander off screen. That bound is the whole
/// difference between this and the unbounded densest-cluster vote, which
/// [`WindowSource::Densest`] documents and `choose_town` keeps for the no-camera case.
fn densest_within(pts: &[(f64, f64)], v: &ViewWindow, half_m: f64) -> Option<TownWindow> {
// The reach is the WIDE anchor — the widest viewport that is still one legible place.
//
// It started at `4 x DEEP` (~5 km) and that was too tight to be useful: the owner could
// navigate Sweden in 2D, press 3D at city or region scale, and the slide would decline
// because the view was "too wide", so korp had to FLY the camera down to ~2.5 km instead
// — *"i only see a stockholm? cant navigate on sweden?"*. Sliding is the repair that does
// not move him; flying is the one that does, so the slide reaches as far as it honestly
// can and the flight is the last resort.
//
// 37 km is where facett's own town window reaches its cap, i.e. the widest span at which
// "the buildings are on screen and merely not under the crosshair" is still a true
// sentence. Beyond it the two country-overview guards (`a_country_overview_refuses_…` at
// 900 km and `a_region_view_over_a_capped_sample_…` at 159 km) hold the refusal shut,
// which is where it belongs — and both go red if this bound is removed.
if !(v.span_m.is_finite() && v.span_m <= TOWN_ZOOM_WIDE_SPAN_M) {
return None;
}
let mpdl = m_per_deg_lon(v.lat);
let half_view_lat = (v.span_m * 0.5) / M_PER_DEG_LAT;
let half_view_lon = (v.span_m * 0.5) / mpdl;
let inside: Vec<(f64, f64)> = pts
.iter()
.copied()
.filter(|(lon, lat)| {
(lon - v.lon).abs() <= half_view_lon && (lat - v.lat).abs() <= half_view_lat
})
.collect();
if inside.len() < MIN_BUILDINGS_IN_VIEW {
return None;
}
let (win_lat, win_lon) = (half_m / M_PER_DEG_LAT, half_m / mpdl);
// Every candidate centre is a building that is itself on screen, so the window always
// lands on something real. O(n^2) over the buildings in ONE viewport, which is the
// hundreds — not the country-scale set the cell vote below exists to bucket.
let mut best: Option<(usize, f64, f64)> = None;
for &(clon, clat) in &inside {
let n = inside
.iter()
.filter(|(lon, lat)| (lon - clon).abs() <= win_lon && (lat - clat).abs() <= win_lat)
.count();
if best.is_none_or(|(bn, _, _)| n > bn) {
best = Some((n, clon, clat));
}
}
let (n, lon, lat) = best?;
(n >= MIN_BUILDINGS_IN_VIEW).then_some(TownWindow {
source: WindowSource::CameraSlid,
lon,
lat,
half_m,
buildings: n,
})
}
/// Select the **densest cluster of buildings** out of a country-/region-scale
/// `ways` set, returning a city-sized subset (the buildings around the busiest
/// [`TOWN_HALF_MAX_M`]-metre window, plus the roads / water / parks that fall inside it).
///
/// The camera-blind spelling of [`view_town_subset`] — kept for the callers that
/// genuinely have no camera (the synthetic fixtures, the standalone demos). A host that
/// HAS a map camera must pass it: choosing by density when the user has already aimed
/// the map is the Sigtuna defect.
pub fn densest_cluster_subset(ways: &[Way]) -> Cow<'_, [Way]> {
view_town_subset(ways, None)
}
/// **Subset a country-/region-scale `ways` set to the one town the user is looking at**,
/// then hand that to the extruder.
///
/// Why subset at all: extruding a 30 km country and then dollying the *camera* into a
/// 2 km town leaves a giant terrain plane + thousands of far footprints in view — their
/// far edges project to near-singularity **spokes** (the starburst), and the buildings
/// are sub-pixel. Instead we **subset the data** to one town and extrude THAT: the mesh
/// is then city-scale, so heights scale to the town span (a readable skyline), the
/// terrain is town-sized (no far spokes), and a normal camera frames it.
///
/// Which town is [`town_window`]'s decision — the camera's, falling back to the densest
/// cluster. Returns the input unchanged when it is already small (a single town / the
/// synthetic fixture) or has too few buildings to window.
pub fn view_town_subset(ways: &[Way], view: Option<ViewWindow>) -> Cow<'_, [Way]> {
let win = match choose_town(ways, view) {
TownChoice::Chosen(w) => w,
// **NO CAMERA.** Keeping everything is safe only because nothing aimed anywhere:
// a fixture or a standalone demo hands its whole (town-sized) input in. With a
// camera supplied this arm is UNREACHABLE by construction — `choose_town` answers
// `TooCoarse` for every window it cannot fill, zero buildings included — and that
// is deliberate: this arm used to be reached with a camera in hand, and then
// "keep everything" meant a 1 827 m viewport extruding a 77 km scene. See
// `choose_town`.
TownChoice::Nothing => return Cow::Borrowed(ways), // no camera, and nothing to window
// **REFUSED — and refusing means extruding NOTHING, not extruding everything.**
// Returning `ways` here would hand the whole 160 km region to the extruder, i.e.
// the pancake this subset exists to prevent. The empty set is what makes the
// pane paint its "zoom in" state; see `TownChoice::TooCoarse`.
TownChoice::TooCoarse(_) => return Cow::Owned(Vec::new()),
};
// Already smaller than the window we would cut → nothing to subset.
//
// Decided on the span of the WHOLE `ways` set, not on the span of the building
// centroids. The two are not the same question, and using the wrong one is a
// silent no-op: a compact town sitting inside a region viewport has a ~1 km centroid
// span, so this returned "already a town" — and returned the entire region with it,
// roads, water and all. The scene scale is derived from every retained vertex, so
// that is a 55 km scene wearing a 1 km town's answer, and the subset never ran at
// all. Ask the quantity this function exists to bound.
let scene_span_m = world_span_metres(ways).map(f64::from).unwrap_or(0.0);
if scene_span_m <= 2.0 * win.half_m {
return Cow::Borrowed(ways);
}
let keep = clip_ways_to_window(ways, win.lon, win.lat, win.half_m);
// **Selection is not ownership.** All three "keep everything" exits hand the input
// straight back — they used to `to_vec()` it, and `Way` is the most expensive type
// in the house to clone (`points` + `lonlat` + `holes` + `name`, three allocations
// and 24 B per vertex). The last exit was the worst of them: it built `keep` in
// full, threw it away, and then deep-cloned the whole input a second time, so one
// call could copy the set twice over.
if keep.len() < 10 { Cow::Borrowed(ways) } else { Cow::Owned(keep) }
}
/// **Clip a ways set to a `(lon, lat) ± half_m` window, so every surviving vertex is
/// inside it.** The one writer (L5) behind both windowing paths: the legibility-capped
/// town window ([`view_town_subset`]) and the whole-viewport continuous scene
/// ([`viewport_scene`]) cut geometry by exactly the same rules, and a change to those
/// rules cannot land in one and miss the other.
///
/// Returns the kept ways with no population floor and no "already small" shortcut —
/// those are the CALLER's policy, and `view_town_subset` still applies its own.
fn clip_ways_to_window(ways: &[Way], lon: f64, lat: f64, half_m: f64) -> Vec<Way> {
let (wlon, wlat) = (half_m / m_per_deg_lon(lat), half_m / M_PER_DEG_LAT);
let (klon0, klon1) = (lon - wlon, lon + wlon);
let (klat0, klat1) = (lat - wlat, lat + wlat);
// ── keep the window, and ONLY the window ──────────────────────────────────
//
// This used to keep every way with ANY vertex inside the window, whole. That
// silently defeated the entire subset, because the SCENE SCALE is derived from the
// retained geometry: `world_span_metres` and `footprint_bbox` read every vertex of
// every kept way, so one motorway segment reaching out of the window dragged the
// data bbox back to region size and the design unit with it.
//
// MEASURED on the shipped korp path, a real focused 1900x1150 Xvfb window against
// the live korp-server (2026-08-03, RTX 4090, Stockholm z12): with the subset in
// place the mesh dropped from 2 802 173 triangles to 116 465 — it HAD found the
// town — and `metres_per_design_unit` still came back **33 195**, a design unit of
// 33 km on a 3 km window. The median house stood 0.43 px and the pane drew the same
// flat pancake as before the subset existed.
//
// So the window has to bound the GEOMETRY, not just choose it:
// * a way entirely inside is kept as it is;
// * a LINE crossing the border keeps its longest run of inside vertices, so a
// street through the town is still a street — it just stops within one segment
// of the border (tens of metres at z12) instead of running to the horizon;
// * a RING (a building, a lake, a landuse cap) is only meaningful whole, so it is
// kept whole when it fits the window and dropped when it does not. A lake wider
// than the town is flat ground cover; keeping it would put the scene scale back
// where it started, which is the entire defect.
//
// Every vertex that survives is inside the window by construction, so the span of
// the returned set is bounded by the window — the design unit can no longer be a
// hostage to whatever the tiler happened to include.
let mut keep: Vec<Way> = Vec::with_capacity(ways.len() / 8);
for w in ways {
if let Some(cut) = clip_way_to_window(w, (klon0, klat0, klon1, klat1)) {
keep.push(cut);
}
}
keep
}
/// **[`clip_ways_to_window`] for ONE way**, against a `(lon0, lat0, lon1, lat1)` box —
/// the per-way rule, split out so the continuous-viewport walk can clip **lazily**, in
/// distance order, and clone only what it is going to keep. A dense z12 viewport drops
/// tens of thousands of far buildings; cloning them first and discarding them after is
/// the allocation the LAW's "preallocate and reuse" clause exists to forbid.
///
/// `None` when nothing of the way is inside.
fn clip_way_to_window(w: &Way, win: (f64, f64, f64, f64)) -> Option<Way> {
let (klon0, klat0, klon1, klat1) = win;
let inside = |&(lon, lat): &(f64, f64)| lon >= klon0 && lon <= klon1 && lat >= klat0 && lat <= klat1;
if w.lonlat.is_empty() || !w.lonlat.iter().any(inside) {
return None;
}
if w.lonlat.iter().all(inside) {
return Some(w.clone());
}
// Straddles the border. A closed / filled ring survives only if it fits.
let closed = w.fill.is_some()
|| (w.lonlat.len() > 2 && {
let (a, b) = (w.lonlat[0], w.lonlat[w.lonlat.len() - 1]);
(a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9
});
if closed {
return None; // bigger than the window ⇒ it would re-set the scene scale
}
// A line: the longest piece of it that lies inside the window, cut at the
// border rather than at the last inside VERTEX. Real tiled geometry is coarse —
// a trunk road can cross a 3 km town in a single segment with no vertex inside
// it at all — so a vertex-only clip deletes exactly the streets a town view
// needs and leaves a hole where the main road was.
let run = clip_polyline_to_window(&w.lonlat, win)?;
Some(w.clone().with_lonlat(run))
}
/// The **longest contiguous piece of a `(lon, lat)` polyline that lies inside an
/// axis-aligned window**, with the crossings interpolated onto the border — or `None`
/// when none of it does.
///
/// Every returned vertex is inside the window by construction, which is the property
/// [`densest_cluster_subset`] needs: the scene's design unit is derived from the
/// retained vertices, so one vertex left outside is enough to put the scale back at
/// region scale.
fn clip_polyline_to_window(pts: &[(f64, f64)], win: (f64, f64, f64, f64)) -> Option<Vec<(f64, f64)>> {
let same = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-12 && (a.1 - b.1).abs() < 1e-12;
let mut runs: Vec<Vec<(f64, f64)>> = Vec::new();
let mut cur: Vec<(f64, f64)> = Vec::new();
for seg in pts.windows(2) {
match clip_segment_to_window(seg[0], seg[1], win) {
Some((pa, pb)) => {
match cur.last() {
// Continues the run we are building.
Some(&last) if same(last, pa) => {}
// A new piece: the polyline left the window and came back.
Some(_) => {
if cur.len() >= 2 {
runs.push(std::mem::take(&mut cur));
} else {
cur.clear();
}
cur.push(pa);
}
None => cur.push(pa),
}
if !same(pa, pb) {
cur.push(pb);
}
}
None => {
if cur.len() >= 2 {
runs.push(std::mem::take(&mut cur));
} else {
cur.clear();
}
}
}
}
if cur.len() >= 2 {
runs.push(cur);
}
runs.into_iter().max_by_key(Vec::len)
}
/// Liang–Barsky: the portion of the segment `a → b` inside `win`, or `None`.
fn clip_segment_to_window(
a: (f64, f64),
b: (f64, f64),
(x0, y0, x1, y1): (f64, f64, f64, f64),
) -> Option<((f64, f64), (f64, f64))> {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let (mut t0, mut t1) = (0.0f64, 1.0f64);
for &(p, q) in &[(-dx, a.0 - x0), (dx, x1 - a.0), (-dy, a.1 - y0), (dy, y1 - a.1)] {
if p == 0.0 {
if q < 0.0 {
return None; // parallel to this edge and wholly outside it
}
} else {
let r = q / p;
if p < 0.0 {
if r > t1 {
return None;
}
if r > t0 {
t0 = r;
}
} else {
if r < t0 {
return None;
}
if r < t1 {
t1 = r;
}
}
}
}
Some(((a.0 + dx * t0, a.1 + dy * t0), (a.0 + dx * t1, a.1 + dy * t1)))
}
// ═════════════════════════════════════════════════════════════════════════════
// THE CONTINUOUS VIEWPORT SCENE — the whole viewport, with distance falloff
// ═════════════════════════════════════════════════════════════════════════════
//
// [`view_town_subset`] answers "which ONE town do I extrude?" and its answer is a box
// of at most `2·TOWN_HALF_MAX_M` = 1.16 km. That box is the right answer to the question
// it asks, and the wrong shape on screen: however wide the 2D viewport is, the 3D pane
// shows a 1.16 km rectangle of it, floating in nothing, and out in open country it shows
// a sentence on an empty pane. The owner, driving it: *"why is 3d only a tile / rectangle
// rotating? you need to make it continously but cut grafiks at a distant length"* and
// *"3d i want to navigate throughout sweden!"*.
//
// So the question below is a DIFFERENT one — "what is in the viewport, and how much of
// it can this frame afford?" — and it is a separate entry point rather than a change of
// meaning under [`town_half_for_span`], which has consumers
// (`facett-map3d-flight`, the demos, the fixtures) that asked the first question and
// must keep getting the first answer.
/// **How much of a feature survives at its distance from the camera's aim point.**
///
/// Three levels, not a continuum, because the extruder's cost is a step function of the
/// way's shape and nothing in between is cheaper: a prism costs `4n` triangles in its
/// ring length, so the only real lever is the ring, and the only rings worth having are
/// "the real one" and "a box".
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Detail {
/// The way exactly as the tiler delivered it — a real footprint with a real roof.
Full,
/// **Simplified.** A risen ring collapses to its footprint **bounding box** (4
/// corners, so `4n` becomes 18 triangles however wrinkly the original was); a line
/// keeps at most [`COARSE_LINE_VERTS`] vertices. Still a building, still a street,
/// at the size where nobody can count its corners.
Coarse,
/// **The ground only.** Risen rings are gone; a line survives as a single straight
/// segment (a road still reads as a road at 3 km); flat ground cover — water, park,
/// landuse — is kept as it is, because it costs `n-2` triangles and it IS the
/// ground. This band is what makes the scene continuous instead of a rectangle: it
/// runs all the way to the viewport border.
Ground,
}
/// How many vertices a line keeps at [`Detail::Coarse`]. Eight is two corners more than
/// a city block needs at that distance, and it bounds a road's cost at
/// `3 · (10·8 − 2)` = 234 triangles regardless of how finely the tiler cut it.
pub const COARSE_LINE_VERTS: usize = 8;
/// **What a road becomes in the [`Detail::Ground`] band** — a plain stroked line instead
/// of the three-pass car-navigator ribbon.
///
/// # Why the falloff had to reach the ribbon and not only the vertices
///
/// [`road_ribbon`] draws glow + casing + fill, three [`ribbon_rounded`] passes with an
/// eight-segment join fan at every vertex, so even a road decimated to its two ENDS costs
/// `3 · (10·2 − 2)` = **54 triangles**. `viewport_scene`'s ground band is unconditional —
/// it is what makes the pane continuous out to the horizon — and a real city viewport
/// holds ~15 000 line ways. MEASURED on `tests/the_budget_is_enforced.rs`'s reconstruction
/// of the owner's 2026-08-11 drive (32 761 houses, 14 700 street ways, 2 975 cover rings in
/// a 6.2 km viewport): the lines alone cost **1 192 194** of a **1 357 801**-triangle
/// scene against a 600 000 budget. Decimating them further cannot help — they were
/// already at two vertices. The cost is in the PASSES.
///
/// The casing and the glow are near-field styling: at the ground band a street is a
/// hairline (`MIN_ROAD_HALF_W` has already taken over the width), and a 1.7x casing plus a
/// 2.8x glow around a one-pixel line is three ribbons drawing the same pixel. So a
/// ground-band road keeps its class colour and its width and loses the two passes nobody
/// can resolve: **54 → 18 triangles**, a 3x cut in the one term that dominates.
///
/// It is a `kind` rather than a flag on [`Way`] because `kind` is what
/// [`estimated_triangles`] and [`line_feature`] BOTH branch on — one string, so the
/// estimate the budget spends and the mesh the extruder emits cannot disagree about how
/// many passes a road got (`the_estimate_is_the_mesh` asserts that to 2 %). `style::classify`
/// never produces it, so a way carrying it can only have come from this crate's own falloff.
pub const GROUND_ROAD_KIND: &str = "road-far";
/// The number of vertices [`open_ring`] would leave, without building the ring — the
/// closing duplicate dropped, nothing allocated. Used by [`ground_floor_triangles`], which
/// runs once per candidate way on the interaction path (LAW: preallocate and reuse, and
/// do not allocate to count).
fn open_ring_len(ring: &[(f32, f32)]) -> usize {
let mut n = ring.len();
while n >= 2 {
let (a, b) = (ring[0], ring[n - 1]);
if (a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9 {
n -= 1;
} else {
break;
}
}
n
}
/// **What this way costs even if the scene degrades as far as it can** — its
/// [`Detail::Ground`] price, which for the unconditional half of the scene is a price the
/// budget cannot refuse to pay.
///
/// * a building: **0** — past the coarse band it is dropped, and that is the falloff;
/// * a non-cover ring (an untagged area, a boundary): **0** — [`into_detail`] drops it too;
/// * flat cover (water / park / coastline): its cap, `n − 2`. Never reduced, never
/// dropped — it IS the ground;
/// * a line: its two ends, one pass — `18` triangles ([`GROUND_ROAD_KIND`]);
/// * **except a line that was ALREADY at two vertices**: [`into_detail`] short-circuits on
/// `lonlat.len() < 3` and hands such a way back untouched — no decimation, and therefore
/// no demotion to [`GROUND_ROAD_KIND`] either. A two-node road (OSM is full of them: a
/// driveway, a service spur, a bridge segment) keeps all three ribbon passes and costs
/// **54**. Saying 18 for it would under-reserve the floor by 36 apiece and let the walk
/// this number exists to bound overshoot again, quietly.
///
/// Summed over the candidates this is the **floor**: the smallest scene
/// [`viewport_scene`] is allowed to produce while still honouring "never blank the pane".
/// A budget below it is a budget that cannot be met, and saying which of the two happened
/// is the difference between a bound and a caption.
///
/// It is a closed form rather than `estimated_triangles(&at_detail(w, Ground)?, …)` because
/// it runs once per candidate way on the interaction path and that spelling allocates a
/// reduced `Way` per call (LAW: preallocate and reuse, do not allocate to count).
/// `the_floor_is_exactly_what_the_ground_band_costs` pins the two spellings equal on every
/// way of the fixture, so the shortcut cannot drift from the walk it is predicting.
fn ground_floor_triangles(w: &Way) -> usize {
if w.points.len() < 2 {
return 0;
}
if is_prism(w) {
if matches!(w.kind, "water" | "park" | "coastline") {
return open_ring_len(&w.points).saturating_sub(2);
}
return 0;
}
// The branch `into_detail` takes, read off the same field it reads.
let passes = if w.lonlat.len() < 3 && w.kind == "road" { 3 } else { 1 };
passes * (10 * 2 - 2)
}
/// **The triangle budget for one continuous viewport scene.**
///
/// # Measured, not chosen
///
/// Three readings bracket it, all on the same scene the pane actually builds:
///
/// * korp MEASURED **2 802 173** triangles for a real un-subsetted Stockholm z12
/// viewport, and **116 465** for the town window that replaced it (recorded in
/// [`view_town_subset`]). Those are the two ends: everything, and what ships today.
/// * This crate's extruder emits **4.0 – 7.8 M triangles/s** in a DEBUG build on
/// The owner's t14s (AMD 890M / RADV STRIX1, `tests/continuous_viewport::throughput`,
/// 1 610 – 14 430-way towns). The mesh is rebuilt on every camera change, so the
/// extrusion is on the interaction path, not the load path.
/// * The mesh costs a MEASURED **67 – 71 bytes per triangle** (positions + normals +
/// indices + a colour + the building class, after the caps' shared vertices), so it is
/// allocated, filled and freed at that rate too.
///
/// At this budget the scene is **~42 MB** and **~100 ms** of DEBUG extrusion — 5.1x the
/// geometry korp ships today and 21 % of the whole viewport. The release figure is
/// necessarily smaller and is NOT stated here, because this session was not allowed to
/// run a release build (the main session owns it and the cargo target dir is shared);
/// the debug number is the one that was taken.
///
/// It is a [`SceneBudget`] field rather than a hard constant precisely because it is a
/// hardware number: a host on a weaker GPU lowers it and the scene degrades by getting
/// shallower, which is the entire point of budgeting by triangles instead of by metres.
pub const VIEWPORT_TRIANGLE_BUDGET: usize = 600_000;
/// The share of [`SceneBudget::triangles`] spent before full-detail buildings give way
/// to [`Detail::Coarse`] boxes. The remainder buys the coarse band, which is where the
/// triangles go furthest: a box is 18 triangles and reads as a building for kilometres.
pub const FULL_DETAIL_SHARE: f32 = 0.55;
/// **What one viewport scene may cost.** Passed by the host, because the answer is a
/// property of the host's GPU and of nothing in this crate.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SceneBudget {
/// Total estimated triangles the scene may reach ([`VIEWPORT_TRIANGLE_BUDGET`]).
pub triangles: usize,
/// Fraction of `triangles` spent at [`Detail::Full`] ([`FULL_DETAIL_SHARE`]).
pub full_detail_share: f32,
}
impl Default for SceneBudget {
fn default() -> Self {
Self { triangles: VIEWPORT_TRIANGLE_BUDGET, full_detail_share: FULL_DETAIL_SHARE }
}
}
/// **What the falloff walk did** — every number the pane, the log and a test need, so
/// "the 3D scene is continuous and fades" is a reading rather than a screenshot.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct ViewportStats {
/// The viewport span the scene was cut from (metres) — the 2D pane's own width.
pub span_m: f64,
/// Estimated triangles the kept ways will extrude to ([`estimated_triangles`]).
pub triangles: usize,
/// The budget it was cut to.
pub budget: usize,
/// **The smallest this scene could possibly have been** — the sum of
/// [`ground_floor_triangles`] over every candidate way: flat cover kept, every line
/// down to its two ends and one pass, every building dropped. It is the price of the
/// "never blank the pane" guarantee and the falloff cannot go below it.
///
/// The bands are chosen inside `budget − ground_floor`, which is what makes
/// [`Self::triangles`] a bound rather than a caption.
pub ground_floor: usize,
/// **Did the scene actually fit?** `triangles <= budget`.
///
/// `false` has exactly two causes and both are honest rather than a miss:
/// `ground_floor > budget` (the unconditional ground alone costs more than the host
/// allowed — the falloff has nowhere left to go), or the
/// [`MIN_BUILDINGS_IN_VIEW`] near-band floor holding `Detail::Full` open past the
/// budget so the pane still has a skyline. A host that prints
/// [`Self::triangles`] beside [`Self::budget`] must print this too; an over-budget
/// number presented as if it were fine is worse than no bound at all.
pub budget_met: bool,
/// Buildings kept at [`Detail::Full`] / [`Detail::Coarse`], and dropped.
pub buildings_full: usize,
pub buildings_coarse: usize,
pub buildings_dropped: usize,
/// Lines (roads, paths, coastlines) kept at each level.
pub lines_full: usize,
pub lines_coarse: usize,
pub lines_ground: usize,
/// Flat ground cover kept — water, park, landuse. Never dropped.
pub ground_cover: usize,
/// **How far full detail reached**, in metres from the aim point. `f64::INFINITY`
/// when the budget was never exhausted (the whole viewport is full detail).
pub full_radius_m: f64,
/// How far the coarse band reached; past this only [`Detail::Ground`] survives.
pub coarse_radius_m: f64,
/// **Where the scene ends**, in metres from the aim point: [`scene_reach_half_m`].
/// From z14 down this IS half the viewport span and the scene is edge to edge; above
/// it, it is the horizon the design box can hold — see that function.
pub ground_radius_m: f64,
/// Did the reach cover the WHOLE viewport (`ground_radius_m == span_m/2`)? The one
/// bit that says whether the 3D pane is showing everything the 2D pane is.
pub reach_is_the_viewport: bool,
/// **How much ground the camera was asked to frame**, in metres
/// ([`legible_frame_extent_m`]) — a pure function of the viewport span.
pub frame_extent_m: f64,
/// **How far the eye ended up from the aim point, in metres — the number that MOVES
/// when the operator zooms on this path.**
///
/// # Why this had to be published, and what it replaced
///
/// On the town path the zoom was expressed in the DATA: `view_town_subset` clipped the
/// geometry to [`town_half_for_span`], the design box was fitted to what survived, and
/// so `metres_per_design_unit` tracked the zoom. korp's
/// `the_zoom_slider_moves_the_3d_scene` asserts exactly that, and it is the right
/// assertion about that mechanism.
///
/// This path does not use that mechanism. The window is the reach (kilometres, not
/// hundreds of metres), so a town smaller than the reach is never clipped and the
/// design box does not move: MEASURED on korp's own 892 m fixture, `m/du` is **557.5
/// at z12 and 557.5 at z15**, because the reach is 8 292 m and 2 315 m and the town
/// fits inside both. The zoom is expressed in the CAMERA instead — same fixture, this
/// number reads **622 m → 271 m → 206 m** across z12 / z15 / z16, and the applied
/// median house goes **10.78 → 16.67 → 18.69 px**.
///
/// `m/du` still tracks the zoom wherever the reach really bites (a region-scale ways
/// set: 10 365 → 2 894 m/du over z12 → z15). It is simply no longer the quantity that
/// is guaranteed to.
///
/// This is the APPLIED distance, after [`crate::camera::OrbitCamera::min_distance`]
/// clamps it — not the request — so a scene where the dolly floor bites reports the
/// floor rather than the wish.
pub camera_distance_m: f64,
/// **Where the near band actually is** — the centroid of the full-detail buildings,
/// in `(lon, lat)`. The camera aims here, so an empty viewport centre slides the
/// camera to the nearest houses instead of framing a field. `None` when the view
/// holds no buildings at all, and then the camera aims at the viewport centre.
pub aim: Option<(f64, f64)>,
}
impl ViewportStats {
/// Count an unwindowed hand-back (the no-camera path) so the emit is never blank.
fn ways_in(&mut self, ways: &[Way]) {
for w in ways {
if w.kind == "building" {
self.buildings_full += 1;
} else if is_prism(w) {
self.ground_cover += 1;
} else {
self.lines_full += 1;
}
self.triangles += estimated_triangles(w, RoofStyle::Pitched);
}
}
/// The readable-data emit (korp publishes this under `map.map3d.viewport`).
#[must_use]
pub fn to_json(&self) -> serde_json::Value {
let km = |m: f64| if m.is_finite() { serde_json::json!(m / 1000.0) } else { serde_json::Value::Null };
serde_json::json!({
"span_km": self.span_m / 1000.0,
"triangles_estimated": self.triangles,
"budget": self.budget,
"ground_floor": self.ground_floor,
"budget_met": self.budget_met,
"buildings_full": self.buildings_full,
"buildings_coarse": self.buildings_coarse,
"buildings_dropped": self.buildings_dropped,
"lines_full": self.lines_full,
"lines_coarse": self.lines_coarse,
"lines_ground": self.lines_ground,
"ground_cover": self.ground_cover,
"full_radius_km": km(self.full_radius_m),
"coarse_radius_km": km(self.coarse_radius_m),
"ground_radius_km": km(self.ground_radius_m),
"reach_is_the_viewport": self.reach_is_the_viewport,
// **The zoom observable on this path** — see `camera_distance_m`. `m/du` is
// the scale of the DESIGN BOX and no longer the scale of the view.
"frame_extent_m": self.frame_extent_m,
"camera_distance_m": self.camera_distance_m,
"aim": self.aim.map(|(lon, lat)| serde_json::json!([lon, lat])),
})
}
}
/// The scene the falloff walk produced: the ways to extrude, and what it did.
#[derive(Clone, Debug, Default)]
pub struct ViewportScene {
/// The ways, already clipped to the viewport and already reduced to their level.
pub ways: Vec<Way>,
/// The reading ([`ViewportStats`]).
pub stats: ViewportStats,
}
/// **How many triangles this way will cost the extruder** — read off
/// [`build_with_water_terrain_styled`]'s own branches, not estimated:
///
/// * a risen prism under [`RoofStyle::Pitched`] and `kind == "building"`: an eave cap
/// and a bottom cap (`n-2` ear-clipped triangles each), `2n` wall triangles, plus the
/// roof — `n` for a hipped cone, `6` for the gabled default, `0` for a footprint too
/// big to pitch ([`ROOF_PITCH_MAX_AREA_M2`]);
/// * any other risen prism: the two caps and the walls, `4n − 4`;
/// * flat ground cover (water / park / coastline): one cap, `n − 2`;
/// * a **road** line: three [`ribbon_rounded`] passes (glow, casing, fill), each
/// `2(v−1)` quad triangles plus a `CAP_SEGS`-segment join fan at every vertex —
/// `3 · (10v − 2)`. Roads are the single most expensive thing in a wide viewport and
/// that is why they have their own falloff;
/// * any other line — including a road the falloff has demoted to
/// [`GROUND_ROAD_KIND`]: one pass, `10v − 2`. The pass count is read off `kind` here
/// and in [`line_feature`], one string for both, so the number the budget spends is the
/// number the mesh emits.
///
/// **Deliberately an over-estimate in one place**: an untagged mega-ring that
/// [`prism_height_for`] would flatten is counted as if it rose. Over-estimating spends
/// the budget early, which is the safe direction; under-estimating blows it.
///
/// Asserted against the APPLIED mesh in `tests/continuous_viewport.rs`.
#[must_use]
pub fn estimated_triangles(w: &Way, roof_style: RoofStyle) -> usize {
if w.points.len() < 2 {
return 0;
}
if !is_prism(w) {
let v = w.points.len();
let passes = if w.kind == "road" { 3 } else { 1 };
return passes * (10 * v - 2);
}
// A ring: drop the OSM closing duplicate exactly as `open_ring` does.
let n = open_ring(&w.points).len();
if n < 3 {
return 0;
}
if matches!(w.kind, "water" | "park" | "coastline") {
return n - 2; // flat cover: one single-sided cap, no walls
}
let shell = 2 * (n - 2) + 2 * n;
if roof_style != RoofStyle::Pitched || w.kind != "building" {
return shell;
}
let roof = if ring_area_m2(&w.lonlat) > f64::from(ROOF_PITCH_MAX_AREA_M2) {
0 // too big to pitch — just the coloured eave cap
} else if w.roof_shape == RoofShape::Hipped {
n // a fan from every rim edge to the apex
} else {
6 // the gabled default: two slope quads + two gable ends
};
shell + roof
}
/// A `(lon, lat)` ring's area in **square metres**, on the same flat-Earth
/// approximation [`geo_distance_m`] uses (so the pitched-roof gate this feeds asks the
/// same question `resolve_prism_style` asks in design units).
fn ring_area_m2(lonlat: &[(f64, f64)]) -> f64 {
if lonlat.len() < 3 {
return 0.0;
}
let lat0 = lonlat.iter().map(|p| p.1).sum::<f64>() / lonlat.len() as f64;
let (sx, sy) = (m_per_deg_lon(lat0), M_PER_DEG_LAT);
let mut a = 0.0f64;
for i in 0..lonlat.len() {
let (x0, y0) = lonlat[i];
let (x1, y1) = lonlat[(i + 1) % lonlat.len()];
a += (x0 * sx) * (y1 * sy) - (x1 * sx) * (y0 * sy);
}
(a * 0.5).abs()
}
/// **Reduce a way to `detail`**, or `None` when nothing of it survives at that level.
/// Pure — it clones and re-projects through [`Way::with_lonlat`], so the Mercator
/// points and the bbox stay the ones the extruder will read (L5: there is no second
/// projection here).
#[must_use]
pub fn at_detail(w: &Way, detail: Detail) -> Option<Way> {
into_detail(w.clone(), detail)
}
/// [`at_detail`] on an OWNED way — what the walk uses, so a way that came out of
/// [`clip_ways_to_window`] is cloned exactly once on its way into the scene instead of
/// twice (LAW: reuse, don't copy). The `Full` arm then moves rather than allocating.
fn into_detail(w: Way, detail: Detail) -> Option<Way> {
if detail == Detail::Full || w.lonlat.len() < 3 {
return Some(w);
}
if is_prism(&w) {
if matches!(w.kind, "water" | "park" | "coastline") {
return Some(w); // the ground: `n-2` triangles, never reduced, never dropped
}
if detail == Detail::Ground {
return None; // a building past the coarse band — gone, and that is the falloff
}
let ring = bbox_ring(&w.lonlat);
return Some(w.with_lonlat(ring));
}
// A line. `Coarse` decimates, `Ground` keeps the two ends — a road is still a road.
let keep = if detail == Detail::Coarse { COARSE_LINE_VERTS } else { 2 };
let line = decimate(&w.lonlat, keep);
let mut out = w.with_lonlat(line);
// …and in the GROUND band it also loses the casing and the glow. Decimating a road
// to two vertices is not enough: the three-pass ribbon costs 54 triangles at two
// vertices, and the ground band is unconditional, so on a real city viewport the
// passes — not the vertices — are what puts the scene 2.3x over budget. See
// [`GROUND_ROAD_KIND`] for the measurement.
if detail == Detail::Ground && out.kind == "road" {
out.kind = GROUND_ROAD_KIND;
}
Some(out)
}
/// The closed 5-vertex **bounding-box ring** of a `(lon, lat)` loop — a building's
/// block silhouette at the distance where its corners are sub-pixel.
fn bbox_ring(lonlat: &[(f64, f64)]) -> Vec<(f64, f64)> {
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for &(x, y) in lonlat {
x0 = x0.min(x);
y0 = y0.min(y);
x1 = x1.max(x);
y1 = y1.max(y);
}
vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)]
}
/// Keep at most `keep` vertices of a polyline, **always including both ends**, evenly
/// spaced along the index. Not Douglas–Peucker: the cost this exists to bound is
/// per-VERTEX (a join fan each), so a bound on the count is the thing that is needed,
/// and a shape-aware simplifier would be a second answer to a question nobody asked.
fn decimate(lonlat: &[(f64, f64)], keep: usize) -> Vec<(f64, f64)> {
let n = lonlat.len();
let keep = keep.max(2);
if n <= keep {
return lonlat.to_vec();
}
(0..keep)
.map(|i| lonlat[((i * (n - 1)) as f64 / (keep - 1) as f64).round() as usize])
.collect()
}
/// **The whole viewport, extruded nearest-first until the triangles run out.**
///
/// The one entry point behind [`crate::Map3D::from_viewport_roofed`], and the answer to
/// a question [`view_town_subset`] does not ask.
///
/// # What it does, in order
///
/// 1. **Clip to the viewport**, not to a legibility box: the window is `span_m / 2`,
/// through the SAME [`clip_ways_to_window`] the town path uses, so every kept vertex
/// is inside the box the operator has on screen and the design unit is bounded by it.
/// 2. **Sort by distance** from the viewport centre (a way's own centroid,
/// [`geo_distance_m`]).
/// 3. **Walk nearest-first**, degrading [`Detail`] as the budget is spent — full
/// buildings, then boxes, then ground only. The level never climbs back, so the
/// scene really is a falloff and not a checkerboard of whatever happened to be cheap.
/// 4. **Never return nothing.** Ground cover, water and the road network survive at
/// every distance out to the viewport border, so a viewport with no buildings in it
/// still paints the country it is over. That is the second half of the defect: the
/// old path answered "no town here" with an empty pane.
///
/// # The floor under the near band
///
/// Full detail is not allowed to run out before [`MIN_BUILDINGS_IN_VIEW`] buildings have
/// had it — the same "more than a hamlet" threshold the town window uses. Without it, a
/// dense enough centre could spend the whole full-detail budget on the first street and
/// the pane would have no skyline at all at a zoom where it should.
///
/// # What it deliberately does not parameterise
///
/// The estimate is taken at [`RoofStyle::Pitched`], because
/// [`crate::Map3D::from_viewport_roofed`] is the only caller and it always extrudes
/// pitched. A `RoofStyle` on [`SceneBudget`] would be a knob with one setting and a
/// second place for the two to disagree.
///
/// # The floor, and why it is subtracted rather than hoped about
///
/// Ground cover, water and the road network are UNCONDITIONAL: they are kept at every
/// distance, because a viewport with no houses in it must still paint the country it is
/// over. This doc used to call that "a bounded cost — a `Detail::Ground` line is two
/// vertices", and the reasoning counted VERTICES. A two-vertex ROAD costs 54 triangles
/// (three [`ribbon_rounded`] passes, an eight-segment join fan at each end), and a real
/// city viewport holds ~15 000 line ways: **MEASURED 1 192 194 triangles of lines in a
/// 1 357 801-triangle scene against a 600 000 budget**, 2.26x over, on
/// `tests/the_budget_is_enforced.rs`'s reconstruction of the owner's 2026-08-11 drive. The
/// budget was printed and not honoured, which reads as a guarantee and is not one.
///
/// Two changes make it a bound:
///
/// * [`GROUND_ROAD_KIND`] — a road in the ground band loses its casing and glow, the two
/// passes that draw the same pixel at that distance. 54 → 18 triangles per line.
/// * [`ViewportStats::ground_floor`] is summed BEFORE the walk and subtracted from the
/// budget, so the detail bands are chosen inside what is actually left instead of
/// spending the whole budget and adding the far ground on top.
///
/// When the floor alone exceeds the budget — or when [`MIN_BUILDINGS_IN_VIEW`] holds the
/// near band open past it so the pane still has a skyline — the scene genuinely cannot
/// fit, and [`ViewportStats::budget_met`] says so rather than letting an over-budget
/// number be printed as if it were fine.
#[must_use]
pub fn viewport_scene(ways: &[Way], view: ViewWindow, budget: SceneBudget) -> ViewportScene {
// The window is the viewport, bounded by what the design box can hold a camera
// inside of ([`scene_reach_half_m`] — the measurement is in its doc).
let half_m = scene_reach_half_m(view.span_m);
let mut stats = ViewportStats {
span_m: view.span_m,
budget: budget.triangles,
ground_radius_m: half_m,
reach_is_the_viewport: half_m >= view.span_m * 0.5 - 1.0,
// The REQUEST. `Map3D::from_viewport_roofed` stamps the applied
// `camera_distance_m` over this struct once the dolly clamp has had its say.
frame_extent_m: legible_frame_extent_m(view.span_m),
camera_distance_m: legible_camera_distance_m(view.span_m),
full_radius_m: f64::INFINITY,
coarse_radius_m: f64::INFINITY,
..Default::default()
};
if !(view.span_m.is_finite() && view.span_m > 0.0 && view.lon.is_finite() && view.lat.is_finite()) {
// No usable camera: this entry point has nothing to window by, so it hands the
// input back rather than inventing a viewport. (`from_viewport_roofed` takes a
// `ViewWindow` by value precisely so this is the only way in.)
stats.ways_in(ways);
stats.ground_floor = ways.iter().map(ground_floor_triangles).sum();
stats.budget_met = stats.triangles <= stats.budget;
return ViewportScene { ways: ways.to_vec(), stats };
}
// **Order first, clip second.** The window box, and a nearest-first index into the
// INPUT — no geometry is copied yet. A dense z12 viewport drops tens of thousands of
// far buildings, and cloning them only to throw them away is the allocation the LAW
// ("preallocate and reuse", "zero copy") is about; clipping lazily inside the walk
// means a way is cloned exactly when it is kept.
let (wlon, wlat) = (half_m / m_per_deg_lon(view.lat), half_m / M_PER_DEG_LAT);
let win = (view.lon - wlon, view.lat - wlat, view.lon + wlon, view.lat + wlat);
// Only ways with a vertex in the window are candidates — the same first test
// `clip_way_to_window` makes, made here so it is made ONCE and so the falloff's
// "dropped" count means "in view and too far", not "somewhere else in Sweden".
// The sort is on the PRECOMPUTED distance: one `geo_distance_m` per way, not one per
// comparison.
let touches = |w: &Way| {
w.lonlat
.iter()
.any(|&(lon, lat)| lon >= win.0 && lon <= win.2 && lat >= win.1 && lat <= win.3)
};
let mut order: Vec<(f64, usize)> = ways
.iter()
.enumerate()
.filter(|(_, w)| touches(w))
.filter_map(|(i, w)| centroid_lonlat(w).map(|c| (geo_distance_m((view.lon, view.lat), c), i)))
.collect();
order.sort_by(|a, b| a.0.total_cmp(&b.0));
// **RESERVE THE FLOOR BEFORE SPENDING ANYTHING.**
//
// The ground band is unconditional — cover, water and the road network survive to the
// viewport border, because a viewport with no houses in it must still paint the
// country it is over. That guarantee has a price, it is paid whatever the bands do,
// and until now nothing subtracted it: the walk spent the WHOLE budget on the near
// detail and then added the far ground on top. MEASURED on a reconstruction of
// The owner's 2026-08-11 drive (`tests/the_budget_is_enforced.rs`): 1 357 801 triangles
// estimated against a 600 000 budget, 1 192 194 of them lines. The estimate was
// printed, not honoured.
//
// So the detail bands are chosen inside what is LEFT after the floor. One O(n) pass
// over the candidates already selected above, no clipping and no clone.
let ground_floor: usize = order.iter().map(|&(_, i)| ground_floor_triangles(&ways[i])).sum();
stats.ground_floor = ground_floor;
let detail_budget = budget.triangles.saturating_sub(ground_floor);
let full_budget = (detail_budget as f64 * f64::from(budget.full_detail_share).clamp(0.0, 1.0)) as usize;
let mut level = Detail::Full;
let mut spent = 0usize;
let mut keep: Vec<Way> = Vec::with_capacity(order.len());
let (mut aim_lon, mut aim_lat, mut aim_n) = (0.0f64, 0.0f64, 0usize);
for (dist_m, i) in order {
let src = &ways[i];
let is_building = src.kind == "building";
let is_ring = is_prism(src);
// Degrade, monotonically, and record where each band ended. The near-band floor
// holds `Full` open until the skyline exists.
if level == Detail::Full && spent >= full_budget && stats.buildings_full >= MIN_BUILDINGS_IN_VIEW {
level = Detail::Coarse;
stats.full_radius_m = dist_m;
}
if level == Detail::Coarse && spent >= detail_budget {
level = Detail::Ground;
stats.coarse_radius_m = dist_m;
}
// A building past the coarse band is dropped WITHOUT being clipped or cloned —
// the cheapest possible answer to the commonest case in a wide viewport.
if level == Detail::Ground && is_ring && is_building {
stats.buildings_dropped += 1;
continue;
}
let Some(w) = clip_way_to_window(src, win) else { continue };
let Some(reduced) = into_detail(w, level) else {
stats.buildings_dropped += usize::from(is_building);
continue;
};
spent += estimated_triangles(&reduced, RoofStyle::Pitched);
if is_building {
match level {
Detail::Full => {
stats.buildings_full += 1;
if let Some((lon, lat)) = centroid_lonlat(&reduced) {
aim_lon += lon;
aim_lat += lat;
aim_n += 1;
}
}
Detail::Coarse => stats.buildings_coarse += 1,
Detail::Ground => stats.buildings_dropped += 1,
}
} else if is_ring {
stats.ground_cover += 1;
} else {
match level {
Detail::Full => stats.lines_full += 1,
Detail::Coarse => stats.lines_coarse += 1,
Detail::Ground => stats.lines_ground += 1,
}
}
keep.push(reduced);
}
stats.triangles = spent;
// **The verdict, said out loud.** `false` is not a miss to be hidden: it means either
// the floor alone is over the host's budget, or `MIN_BUILDINGS_IN_VIEW` deliberately
// held the near band open so the pane still has a skyline. Both are choices, and a
// reader has to be able to tell them from a bound that simply was not applied.
stats.budget_met = spent <= budget.triangles;
if aim_n > 0 {
stats.aim = Some((aim_lon / aim_n as f64, aim_lat / aim_n as f64));
}
ViewportScene { ways: keep, stats }
}
/// A way's `(lon, lat)` centroid — the point its distance from the camera is measured
/// at. `None` for an empty way.
fn centroid_lonlat(w: &Way) -> Option<(f64, f64)> {
if w.lonlat.is_empty() {
return None;
}
let n = w.lonlat.len() as f64;
let (sx, sy) = w.lonlat.iter().fold((0.0, 0.0), |(a, b), &(x, y)| (a + x, b + y));
Some((sx / n, sy / n))
}
/// **How much ground the camera frames, in metres, at a viewport span** — the near-band
/// legibility the pane must keep however wide the DATA window is.
///
/// # Why the data window and the camera framing had to be separated
///
/// They used to be the same number. [`view_town_subset`] cut the data to
/// [`town_half_for_span`] and [`crate::Map3D::frame_densest_cluster`] framed the densest
/// 3x3 cell of a 24x24 grid over the design box — and because the town filled the box,
/// that came out at a fixed fraction of the town window. Legibility was a side effect of
/// the cut.
///
/// Widen the data window to the whole viewport and the side effect inverts: the design
/// box is now 37 km, one grid cell is 1.5 km, and the same framing rule puts the camera
/// 13x further from the houses. MEASURED on the ladder fixture, the median house falls
/// from 10.07 px to well under one — the pancake, by a different road.
///
/// So the framing is stated in METRES and derived from [`town_half_for_span`], which is
/// the measured, defended answer to "how much ground is legible at this zoom". The
/// factor restates what the grid framing was already doing:
/// `3 cells / 24 · box span 2 · (2·half / DESIGN_FILL) metres per design unit`.
/// The camera therefore sits exactly where it sits today at every zoom; the only thing
/// that changed is that there is now geometry past the edge of the frame.
#[must_use]
pub fn legible_frame_extent_m(span_m: f64) -> f64 {
const FRAME_CELLS: f64 = 3.0;
const GRID: f64 = 24.0;
let metres_per_du = 2.0 * town_half_for_span(span_m) / f64::from(DESIGN_FILL);
(FRAME_CELLS / GRID * 2.0) * metres_per_du
}
/// How far the eye ends up from the aim point, in metres, for a viewport of `span_m` —
/// [`legible_frame_extent_m`] pushed through [`crate::Map3D::frame_extent`]'s own dolly
/// expression (`extent / tan(fov/2) · 1.6`), so the two cannot drift.
#[must_use]
pub fn legible_camera_distance_m(span_m: f64) -> f64 {
let half_fov = f64::from(crate::camera::OrbitCamera::DEFAULT_FOV_Y_DEG.to_radians()) * 0.5;
legible_frame_extent_m(span_m) / half_fov.tan() * 1.6
}
/// **How wide a scene the renderer can actually let you stand inside — the reach the
/// continuous viewport is cut to, in metres from the aim point.**
///
/// # This is a limit of the design box, and it was MEASURED, not assumed
///
/// Extruding the whole viewport is the obvious thing and it does not work, for a reason
/// that is structural rather than a matter of taste. Every scene is normalised into the
/// same `[-1, 1]` design box ([`to_design`]), so a wide scene does not put the camera
/// further away in design units — it makes the METRE smaller. And the camera has floors
/// that are stated in design units and cannot follow: the dolly bottoms out at
/// [`crate::camera::OrbitCamera::MIN_DISTANCE`] (0.06) and the near plane sits at
/// `NEAR_PLANE` (0.02). In a 37 km box those are **925 m** and **463 m** of real ground:
/// the eye cannot be closer to a house than a kilometre, and anything within 460 m of it
/// is clipped away. You cannot be in a street that is smaller than the near plane.
///
/// MEASURED on the ladder fixture (`tests/continuous_viewport::continuous_ladder`, before
/// this cap existed): windowing the whole 37 km z12 viewport put the median house at
/// **0.77 px**, against **10.07 px** on the town path. That is the pancake, reached by
/// widening the window instead of by capping it — the same failure
/// [`TOWN_HALF_MAX_M`] documents, one level up.
///
/// # What the reach is
///
/// The widest scene in which the eye can still stand at its legible distance
/// ([`legible_camera_distance_m`]) *without* being stopped by the dolly floor:
///
/// ```text
/// half_m = DESIGN_FILL/2 · legible_camera_distance_m(span) / MIN_DISTANCE
/// ```
///
/// Nothing in it is a new constant. It is the legibility target
/// ([`LEGIBLE_MEDIAN_HOUSE_PX`], through [`town_half_for_span`]), the design fill, and
/// the camera's own floor, solved for the one unknown. It tracks the zoom because
/// `town_half_for_span` does: **16.6 km at korp's z12**, 9.5 km at z14, 4.7 km at z16.
///
/// It is capped at `span_m/2` — there is no point extruding ground the 2D pane is not
/// showing either — so from z14 down the reach IS the viewport and the scene really is
/// continuous edge to edge. Above that it is a horizon under fog rather than an edge,
/// and it is **14x wider than `2·TOWN_HALF_MAX_M`** at z12: 16.6 km against 1.16 km,
/// 200x the area.
///
/// **Stated plainly, because it is the honest limit of this renderer**: at region zoom
/// the 3D pane still shows less world than the 2D pane. What changed is the size of the
/// discrepancy and the shape of its edge.
#[must_use]
pub fn scene_reach_half_m(span_m: f64) -> f64 {
if !span_m.is_finite() || span_m <= 0.0 {
return f64::from(TOWN_HALF_MAX_M as f32);
}
let reach = 0.5 * f64::from(DESIGN_FILL) * legible_camera_distance_m(span_m)
/ f64::from(crate::camera::OrbitCamera::MIN_DISTANCE);
reach.min(span_m * 0.5)
}
/// Map a Mercator-unit `(x, y)` footprint vertex into the `[-1, 1]` design plane
/// `(dx, dz)` (the ground plane; y stays the height axis). North (small Mercator
/// y) maps to far (−z), so the camera tilt looks "into" the city.
///
/// **Public because a CAMERA has to land in the same plane the geometry did.**
/// `Map3D::design_from_lonlat` places a geographic eye in this frame, and a second
/// spelling of this arithmetic would put the camera and the city in two coordinate
/// systems that agree until one of them is edited (LAW 5). It stays the ONE writer.
#[must_use]
pub fn to_design(x: f32, y: f32, bb: (f32, f32, f32, f32)) -> (f32, f32) {
let (x0, y0, x1, y1) = bb;
let w = (x1 - x0).max(1e-9);
let h = (y1 - y0).max(1e-9);
let s = w.max(h); // uniform scale → no distortion
let cx = (x0 + x1) * 0.5;
let cy = (y0 + y1) * 0.5;
let dx = (x - cx) / s * DESIGN_FILL; // fill ~80% of the [-1,1] box
let dz = (y - cy) / s * DESIGN_FILL;
(dx, dz)
}
/// **[`to_design`]'s arithmetic in full precision**, for the callers whose input MOVES.
///
/// # Why this is a second spelling and not a shared writer
///
/// It was written as the shared one — `to_design` delegating here through an f32 door on
/// each side — and that is the better shape, so the reason it is not is worth stating.
/// Routing the MESH path through f64 moves footprint vertices by up to one f32 ULP, and
/// `tests/sigtuna_tour_proof.rs` went red on it: its "the town's buildings extrude into
/// 3D prisms" check sits on a threshold thin enough for one ULP to cross. That is a
/// fragile assertion rather than a real regression — but it is a fragility in the MESH
/// path, and the flicker this function exists to fix is not in the mesh path at all: a
/// footprint vertex is quantised once and then sits still.
///
/// So the split is deliberate and narrow. The geometry keeps the arithmetic it was
/// pinned with; the per-frame CAMERA and overlay derivation gets the precision it needs.
/// The two agree to f32 rounding by construction (same formula, same constants). If the
/// sigtuna threshold is ever made robust, collapse them back into one writer.
///
/// # Why the precision is not a detail: the raven flickered on it
///
/// A footprint vertex is quantised ONCE, at build time, and then sits still — f32 costs
/// it nothing. The rider's eye and the raven's overlay pose are different animals: they
/// are re-derived from moving f64 lon/lat **every frame**, so a rounding step in the
/// middle of that derivation is not a static error, it is a STAIRCASE the pose walks up
/// and down while the body glides smoothly.
///
/// The old `design_from_lonlat` cast the Mercator coordinate to f32 **before** the bbox
/// subtraction, and one f32 ULP of Mercator y at 59.33° N is `2.98e-8 unit ×
/// 20.44e6 m/unit` = **0.61 m of ground** (measured tread: 0.590 m). The eye and the
/// korp landed on different treads on different frames, so their separation twitched
/// out and back — MEASURED over 600 flying frames as 60 one-frame reversals (10.0% of
/// frames) in the eye→korp distance and 52 (8.7%) in the overlay's depth, while the
/// body's own f64 `chase_lag_m` reversed 0 times out of 600.
///
/// On the real wgpu lane that is the flicker itself: the raven's centroid jumped with a
/// per-frame standard deviation of **9.29 px** and **35.6% of frames moved it more than
/// 10 px**, under a seat that is supposed to hold her still. Subtracting first and
/// rounding once takes those to 0.90 px and 0/118 frames.
///
/// The subtraction is the whole fix: `x − cx` is a small difference of two large
/// numbers, and doing it in f64 keeps the ~0.6 m of significance that the cast threw
/// away. The RESULT is still returned as f32 by [`to_design`] and that is fine — a
/// design unit is ~1 km here, so an f32 ULP of the *output* is well under a millimetre.
#[must_use]
pub fn to_design_f64(x: f64, y: f64, bb: (f64, f64, f64, f64)) -> (f64, f64) {
let (x0, y0, x1, y1) = bb;
let w = (x1 - x0).max(1e-9);
let h = (y1 - y0).max(1e-9);
let s = w.max(h); // uniform scale → no distortion
let cx = (x0 + x1) * 0.5;
let cy = (y0 + y1) * 0.5;
let fill = f64::from(DESIGN_FILL);
let dx = (x - cx) / s * fill; // fill ~80% of the [-1,1] box
let dz = (y - cy) / s * fill;
(dx, dz)
}
/// **Metres per degree of LATITUDE.** One writer (L5): it was declared twice in this
/// file — once inside `densest_cluster_subset` next to `WINDOW_M`, and once inside
/// [`world_span_metres`], which is the canonical scale writer itself. Same value both
/// times, which is exactly how two writers of one quantity survive: they agree until
/// one of them is edited.
///
/// That claim was true of the SOURCES and false of the crate: on 2026-08-22 the same
/// literal still stood in **eight test files**, which build their fixtures — synthetic
/// towns placed by converting metres to degrees — on a private copy of it. A fixture on
/// a private ruler measures a different earth from the code under test the moment
/// either side moves, and nothing would have said so. All eight now import this
/// constant; [`m_per_deg_lon`] absorbed four private `cos(lat)` twins with it, and
/// `tests/scene_scale_metric_constants.rs`
/// (`the_flat_earth_ruler_is_the_wgs84_meridian`) pins the VALUE against the WGS-84
/// meridian — because one writer that is WRONG is the failure mode a collapse creates,
/// and the pin's `lat = 0` arm is deliberately kept beside the others to show it: a
/// longitude writer with its `cos` deleted passes there (cos 0 = 1, the identity value)
/// and is caught at Stockholm.
pub const M_PER_DEG_LAT: f64 = 111_320.0;
/// **Metres per degree of LONGITUDE at `lat_deg`** — latitude degrees are constant,
/// longitude degrees shrink by `cos(lat)`. The floor keeps the pole from dividing by
/// zero. The other half of the same duplication: this expression stood verbatim in both
/// functions above.
#[must_use]
pub fn m_per_deg_lon(lat_deg: f64) -> f64 {
M_PER_DEG_LAT * lat_deg.to_radians().cos().abs().max(1e-6)
}
/// **Metres between two WGS-84 points**, on the same flat-Earth approximation every
/// other metric quantity in this module uses (latitude degrees are constant, longitude
/// degrees shrink by `cos(lat)`). Exact enough by far at town scale — the error against
/// a great circle is well under a metre over the few kilometres a scene spans — and it
/// agrees *by construction* with [`world_span_metres`] and the town window, which is the
/// property that matters: the distance a test asserts on and the window that produced it
/// are measured with one ruler.
///
/// This is what turns "is the 3D pane showing the right place?" into a number.
#[must_use]
pub fn geo_distance_m(a: (f64, f64), b: (f64, f64)) -> f64 {
let lat_mid = (a.1 + b.1) * 0.5;
let dx = (a.0 - b.0) * m_per_deg_lon(lat_mid);
let dy = (a.1 - b.1) * M_PER_DEG_LAT;
(dx * dx + dy * dy).sqrt()
}
/// The data's **larger real-world span in metres**, from the ways' WGS-84
/// `lonlat` bbox: latitude degrees are ~111_320 m each; longitude degrees shrink
/// by `cos(lat)`. The data's long footprint axis fills [`DESIGN_FILL`] design
/// units, so **design-units-per-metre = `DESIGN_FILL / span_m`** — that single
/// factor lifts a building's metric height into the SAME scale as its footprint,
/// turning a ~10–30 m building in a ~1–3 km cluster into a readable ~0.01–0.04
/// design units (a skyline) instead of a uniform sky-high spike. Returns `None`
/// for empty / degenerate (single-point) data → the caller keeps a safe default.
pub fn world_span_metres(ways: &[Way]) -> Option<f32> {
// ONE writer for "what lon/lat box is this data in": this used to be a
// character-for-character copy of [`lonlat_bbox`]'s loop with the trig bolted on
// the end, in the same file, ten lines apart.
let (lon0, lat0, lon1, lat1) = lonlat_bbox(ways)?;
let lat_mid = (lat0 + lat1) * 0.5;
let m_per_deg_lon = m_per_deg_lon(lat_mid);
let span_lat_m = (lat1 - lat0) * M_PER_DEG_LAT;
let span_lon_m = (lon1 - lon0) * m_per_deg_lon;
let span = span_lat_m.max(span_lon_m) as f32;
if span.is_finite() && span > 1.0 { Some(span) } else { None }
}
/// **THE scene scale — design units per real metre.** `None` for synthetic /
/// degenerate data with no world span (the rabbit fixture, the unit-test towns),
/// which is the signal every metric constant reads to keep its legacy absolute.
///
/// This is the **one writer** (LAW 5) behind every "how big is a metre here?"
/// question on the extrusion side: building heights, [`ground_band`],
/// [`building_rise_eps`], [`water_height`]. Its exact reciprocal on the render side
/// is [`crate::lod::metres_per_design_unit`] — same `DESIGN_FILL / world_span_metres`
/// definition, so the extruder and the camera can never disagree about the scale.
///
/// Three separate defects have been the same mistake: an absolute design-unit
/// constant weighed against a metrically scaled scene. `GROUND_LIFT` and
/// `BUILDING_RISE_EPS` were the first two; the water plane and the fragment
/// shader's height haze were the third and fourth. Anything that means a LENGTH
/// goes through here.
#[must_use]
pub fn design_units_per_metre(ways: &[Way]) -> Option<f32> {
world_span_metres(ways).map(|span_m| DESIGN_FILL / span_m)
}
/// **The span-less fallback**, for the consumers that need a number rather than the
/// `None` [`design_units_per_metre`] hands them. It is the exact reciprocal of the
/// render side's [`crate::lod::DEFAULT_METRES_PER_DESIGN_UNIT`] — *derived*, not a
/// second literal, so the extruder and the LOD lever cannot disagree about how big a
/// metre is on the very data both fall back on.
///
/// It was a second literal (`2.0e-4`, i.e. 5000 m/du) in the relief heightfield, 8×
/// away from `lod`'s 625 m/du: the terrain came out 8× flatter than the altitude lever
/// believed the scene to be. `tests/scene_scale_metric_constants.rs`
/// (`the_spanless_fallback_is_the_same_number_on_both_sides`) pins the two together
/// through the emitted mesh.
pub const DEFAULT_DESIGN_UNITS_PER_METRE: f32 = 1.0 / crate::lod::DEFAULT_METRES_PER_DESIGN_UNIT;
/// A footprint's **plan dimensions in metres** — `(short side, long side)` of its
/// axis-aligned extent — from a ring already in design units and the scene's
/// design-units-per-metre. `None` for a degenerate (sub-metre / empty) footprint.
/// The input to [`MAX_DEFAULT_WALL_OVER_FOOTPRINT`]; also what
/// [`building_height_stats`] reports the wall/footprint ratios against, so the
/// extruder and its oracle measure the same quantity.
pub fn footprint_plan_metres(ring: &[(f32, f32)], du_per_m: f32) -> Option<(f32, f32)> {
if ring.len() < 3 || du_per_m.is_nan() || du_per_m <= 0.0 {
return None;
}
let (mut x0, mut x1, mut z0, mut z1) = (f32::MAX, f32::MIN, f32::MAX, f32::MIN);
for &(x, z) in ring {
x0 = x0.min(x);
x1 = x1.max(x);
z0 = z0.min(z);
z1 = z1.max(z);
}
let (w, h) = ((x1 - x0) / du_per_m, (z1 - z0) / du_per_m);
if !w.is_finite() || !h.is_finite() {
return None;
}
let (short, long) = (w.min(h), w.max(h));
if long < 1.0 { None } else { Some((short, long)) }
}
/// **How tall a DEFAULT (untagged) building may be relative to its own footprint's
/// SHORT plan side.**
///
/// Only 69 of the embedded Sigtuna extract's 10373 buildings carry a `height` or
/// `building:levels` tag — 0.7%; over korp's whole Sweden cut it is 4.32 % (MEASURED
/// 2026-08-31, 3 841 483 buildings). Everything else fell back to a CONSTANT
/// [`DEFAULT_BUILDING_HEIGHT_M`] = 6 m, a number that knows nothing
/// about the footprint it is lifting. Measured on the demo's Sigtuna town core
/// (182 buildings), that put **77 of them TALLER THAN THEY ARE LONG** — footprints
/// with a 4.4 m long side wearing a 6 m wall, i.e. chimneys, not houses — with
/// wall/long-side reaching 1.77. The roof rise scales with the footprint
/// (`DEFAULT_ROOF_PITCH * footprint_radius`) while the wall did not, so the roof
/// shrank to a lid on a tall box: exactly the reported "the houses' walls have been
/// enlarged and the roofs look like they lean directly on the ground".
///
/// You do not build a 6 m wall on a 4 m shed. An untagged default is therefore
/// capped at this fraction of the footprint's short side, which (short <= long)
/// makes `wall <= 0.9 * long` a structural guarantee rather than a hope — see
/// `tests/wall_roof_ratio.rs`. **Tagged heights are REAL DATA and are never
/// capped**: a genuine 40 m tower on a small footprint stays 40 m.
pub const MAX_DEFAULT_WALL_OVER_FOOTPRINT: f32 = 0.9;
/// Floor under the [capped](MAX_DEFAULT_WALL_OVER_FOOTPRINT) default height, so a
/// tiny outbuilding is still a building and not a paving slab. Itself clamped by
/// the cap, so it can never re-create the very tower the cap removes.
pub const MIN_DEFAULT_BUILDING_HEIGHT_M: f32 = 2.5;
/// Design-height for one building footprint, from its real metric height scaled
/// by the data's world span. `height_m` is the way's tagged height as
/// [`facett_map::parse_building_height_m`] decided it — `None` for **both** an untagged
/// building and one whose tag was junk (`height=99999`, `1;12`, `B`), which then get the
/// same [`DEFAULT_BUILDING_HEIGHT_M`] box, capped by
/// [`MAX_DEFAULT_WALL_OVER_FOOTPRINT`] against the footprint. `du_per_m` is
/// [`DESIGN_FILL`]/span_m. A small deterministic per-building jitter (keyed on the
/// footprint, NO RNG → snapshot-stable) breaks up the default-height slab so an
/// untagged town still reads as a varied skyline, not one flat roofline.
fn building_design_height(height_m: Option<f32>, du_per_m: f32, osm_id: i64, ring: &[(f32, f32)]) -> f32 {
let (tagged, base_m) = match height_m {
Some(m) => (true, m),
None => (false, DEFAULT_BUILDING_HEIGHT_M),
};
// Untagged defaults get a gentle ±25% deterministic jitter so the roofline
// varies; tagged heights are used as-is (they're already real).
let h_m = if tagged {
base_m
} else {
// Hash the id + first ring vertex → a stable fraction in [-1, 1].
let mut seed = osm_id as u64 ^ 0x9E37_79B9_7F4A_7C15;
if let Some(&(x, z)) = ring.first() {
seed ^= (x.to_bits() as u64).rotate_left(17) ^ (z.to_bits() as u64).rotate_left(31);
}
seed = seed.wrapping_mul(0x2545_F491_4F6C_DD1D);
let frac = ((seed >> 40) as f32 / (1u64 << 24) as f32) * 2.0 - 1.0; // [-1,1]
base_m * (1.0 + 0.25 * frac)
};
// ── the FOOTPRINT CAP (untagged only) ─────────────────────────────────────
// The default above is a constant; the building it is lifting is not. Cap an
// untagged wall against its own plan so a 4 m shed cannot wear a 6 m wall (see
// MAX_DEFAULT_WALL_OVER_FOOTPRINT for the measured damage). A TAGGED height is
// real data and passes through untouched.
let h_m = match (tagged, footprint_plan_metres(ring, du_per_m)) {
(false, Some((short_m, _long_m))) => {
let cap_m = MAX_DEFAULT_WALL_OVER_FOOTPRINT * short_m;
// The floor never exceeds the cap, so it cannot undo it.
let floor_m = MIN_DEFAULT_BUILDING_HEIGHT_M.min(cap_m);
h_m.min(cap_m).max(floor_m)
}
_ => h_m,
};
// Real metric height → design units. Clamp the floor to a hair above zero so a
// 1-storey hut still has a sliver of height (never a zero-height degenerate
// "prism"), but low enough that the per-building jitter still varies the
// default-height roofline even at country scale.
(h_m * du_per_m).max(1e-5)
}
/// Is this way a closed ring we should extrude into a prism? A `Way` with a
/// `fill` is already a classified area; otherwise we accept a geometrically
/// closed loop (first ≈ last) of at least a triangle.
fn is_prism(w: &Way) -> bool {
if w.fill.is_some() {
return true;
}
if w.points.len() < 4 {
return false;
}
let (a, b) = (w.points[0], w.points[w.points.len() - 1]);
(a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6
}
/// Build the extruded [`Mesh3`] from `ways`, returning `(mesh, tile_count)` where
/// `tile_count` is the number of footprints turned into geometry (prisms +
/// ribbons + the single terrain plane). `height` is the prism lift in design
/// units. Always emits at least the terrain plane (graceful on empty input).
pub fn build(ways: &[Way], height: f32) -> (Mesh3, usize) {
let mut mesh = Mesh3::default();
let mut tiles = 0usize;
// Terrain plane first (drawn under everything). Sized to the design box.
terrain_into(&mut mesh);
tiles += 1;
let Some(bb) = footprint_bbox(ways) else {
return (mesh, tiles);
};
// Real-world span → design-units-per-metre, so building heights scale to the
// map's actual ground size (a town vs a country). When the span is unknown
// (synthetic / degenerate data) fall back to the legacy `height` design-unit
// lift as a metres-equivalent so the rabbit fixture + unit tests are unchanged.
let du_per_m = design_units_per_metre(ways);
// The ground band rides the SAME metric scale the building heights do, so flat
// cover / road ribbons can never out-rise a roof (see `ground_band`).
let band = ground_band(du_per_m);
for w in ways {
// Recentre the way's footprint into the design plane.
let ring: Vec<(f32, f32)> = w.points.iter().map(|&(x, y)| to_design(x, y, bb)).collect();
if ring.len() < 2 {
continue;
}
// Colour: the way's fill (area) else its stroke colour (line).
let col = w.fill.unwrap_or(w.stroke);
if is_prism(w) {
// Don't make a country a skyscraper. A **building** footprint rises to
// its own real metric height (`height_m` from OSM tags, else a low
// default) scaled by the data's world span; a landuse / water / forest
// / coastline ring (or any ring large vs the box, e.g. an admin
// boundary) lies FLAT on the ground (height ≈ 0) so it reads as ground
// cover, not a giant tower (#11 preserved).
let h = prism_height_for(&ring, w.kind, w.height_m, du_per_m, w.osm_id, height);
extrude_prism_banded(&mut mesh, &ring, h, col, band);
tiles += 1;
} else {
line_feature(&mut mesh, &ring, w, band, du_per_m);
tiles += 1;
}
}
(mesh, tiles)
}
/// Like [`build`], but ALSO routes **water** polygons ([`Surface::Water`]) into a
/// separate tessellated [`WaterMesh`] (the reflective animated water grid) instead
/// of the flat opaque ground-cover cap. Returns `(opaque_mesh, tile_count,
/// water_mesh)`. The opaque mesh is identical to [`build`]'s for non-water data, so
/// the existing render path + tests are unchanged; water is purely additive.
pub fn build_with_water(ways: &[Way], height: f32) -> (Mesh3, usize, WaterMesh) {
build_with_water_terrain(ways, height, GroundTerrain::Flat)
}
/// The **ground terrain choice** for [`build_with_water_terrain`]: the legacy flat
/// `y = 0` quad, or a real heightfielded relief mesh sampled from an [`Elevation`]
/// source (the orbit map's "real DEM terrain" — [`relief_terrain_into`]).
///
/// [`Elevation`]: crate::terrain::Elevation
pub enum GroundTerrain<'a> {
/// The legacy single flat quad (default; snapshot-stable — the extrude tests and
/// the fixture goldens keep using this).
Flat,
/// Real relief: displace a subdivided ground grid by elevations from `elev`.
Relief {
/// The elevation source (a `Dem`, `ProceduralTerrain`, or `TerrainField`).
elev: &'a dyn crate::terrain::Elevation,
/// Grid subdivisions per side (`subdiv+1` vertices per axis).
subdiv: usize,
/// Vertical exaggeration over the metric relief (auto-capped in-box).
exaggeration: f32,
},
}
/// Like [`build_with_water`], but the caller chooses the [`GroundTerrain`]: the flat
/// quad (identical output to `build_with_water`) OR a real heightfielded relief mesh.
/// The relief path is what makes the orbit [`crate::Map3D`] show real DEM topography
/// instead of a billiard-table plane. Every roof is a **flat** top cap (the byte-stable
/// default) — for pitched roofs + OSM colours use [`build_with_water_terrain_styled`].
pub fn build_with_water_terrain(ways: &[Way], height: f32, terrain: GroundTerrain<'_>) -> (Mesh3, usize, WaterMesh) {
let (mesh, tiles, water, _stats) = build_with_water_terrain_styled(ways, height, terrain, RoofStyle::Flat);
(mesh, tiles, water)
}
/// The **roof rendering style** for [`build_with_water_terrain_styled`] — the opt-in
/// knob that keeps the flat-cap render goldens byte-identical while letting a scene
/// grow real pitched roofs + OSM colours.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RoofStyle {
/// Every building is a flat top-cap prism, themed-fill coloured — the byte-stable
/// default (identical geometry + colours to the historical [`build`] path, so the
/// pixel goldens do not move).
#[default]
Flat,
/// Buildings grow **real roof geometry** (gabled / hipped from OSM `roof:shape`,
/// a sensible default for untagged small footprints, flat for large ones) and
/// carry **OSM colours** (`building:colour` facades, `roof:colour`/`roof:material`
/// roofs, tasteful defaults otherwise) so the town reads as buildings, not massing.
Pitched,
}
/// Per-scene counts of the pitched-roof / coloured-building work the styled extrude
/// did — the `state_json` oracle for the "houses read as real buildings" feature.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct RoofStats {
/// Buildings that grew real pitched (gabled / hipped) roof geometry.
pub pitched: usize,
/// Buildings whose roof tint ends up distinct from their facade tint (an OSM
/// colour or a tasteful class default) — i.e. the town is no longer monochrome.
pub coloured: usize,
}
/// The design-box **area fraction** above which a building keeps a flat roof even in
/// [`RoofStyle::Pitched`] — a large footprint (a commercial block / hall) reads
/// better flat AND a pitched roof over a big span would tower; also the perf gate
/// (pitching only the small house-scale footprints bounds the extra roof geometry).
const ROOF_PITCH_MAX_AREA_FRAC: f32 = 0.02; // 2% of the [-1,1]² box
/// **The same gate as a real footprint AREA, in m².**
///
/// [`ROOF_PITCH_MAX_AREA_FRAC`] is a fraction of the `[-1,1]²` design box, so it is a
/// SQUARED length and its error grows as `(m/du)²` — the most scale-sensitive constant
/// in the file. At `korp`'s 1875 m/du it opens at **281 250 m²**, about forty football
/// pitches: nothing in an OSM extract is that big, so the gate never fired and every
/// building got a pitched roof, which is both halves of what it exists to prevent (a
/// pitched roof towering over a commercial hall, and the geometry cost of pitching
/// every footprint in a city).
///
/// `4 000 m²` — a 63 m square — is the commercial block / hall the doc above describes,
/// comfortably over any dwelling and 70× tighter than the fraction it replaces at
/// korp's scale. It is deliberately NOT set at a villa's 150 m²: this crate's own
/// "small house" fixtures are 45 × 67 m (≈3 000 m²), so a dwelling-sized gate would
/// re-scale three existing guards' fixtures to land, which is a larger change than the
/// finding warrants. The gate now FIRES, which it never did; where exactly it sits is a
/// tuning question that can be answered against real extracts.
pub const ROOF_PITCH_MAX_AREA_M2: f32 = 4_000.0;
/// The pitched-roof area gate in **design units²** for a scene of `du_per_m`.
/// `None` (synthetic / span-less data) keeps the legacy design-box fraction, so the
/// fixtures pitch exactly the roofs they always did.
#[must_use]
pub fn roof_pitch_max_area_du(du_per_m: Option<f32>) -> f32 {
match du_per_m {
Some(d) if d > 0.0 => ROOF_PITCH_MAX_AREA_M2 * d * d,
_ => ROOF_PITCH_MAX_AREA_FRAC * 4.0,
}
}
/// Default roof-ridge **pitch** — the ridge/apex rises by `pitch × footprint
/// half-width` above the eave (≈30–35° slopes on a typical house), capped so a wide
/// footprint's roof never towers over its walls (see [`ROOF_RIDGE_MAX_RISE_FACTOR`]).
const DEFAULT_ROOF_PITCH: f32 = 0.6;
/// Cap the roof ridge rise at this multiple of the eave (wall) height, so even a wide
/// house keeps a believable roof-to-wall proportion (never a circus tent).
const ROOF_RIDGE_MAX_RISE_FACTOR: f32 = 1.4;
/// **Gabled-roof footprint-fill floor.** A gabled roof is built on the footprint's
/// oriented bounding box (OBB); when the footprint fills its OBB by at least this
/// fraction it is essentially rectangular, so the OBB-based ridge sits flush on the
/// walls (no overhang). Below it — L-shapes, angled/stepped blocks — the gabled eaves
/// would overhang the walls and leave a see-through hole under the roof at street level,
/// so those footprints fall back to the watertight hipped fan (zero overhang) instead.
/// A true rectangle scores `1.0`; `0.9` keeps gabled only for near-rectangular houses.
const GABLE_MAX_OBB_FILL: f32 = 0.9;
/// Like [`build_with_water_terrain`], but with a [`RoofStyle`]: [`RoofStyle::Flat`]
/// reproduces the historical flat-cap mesh **byte-for-byte** (the golden path), while
/// [`RoofStyle::Pitched`] grows real gabled/hipped roof geometry + honours OSM colours.
/// Returns the extra [`RoofStats`] (pitched + coloured counts) for the oracle.
pub fn build_with_water_terrain_styled(
ways: &[Way],
height: f32,
terrain: GroundTerrain<'_>,
roof_style: RoofStyle,
) -> (Mesh3, usize, WaterMesh, RoofStats) {
build_with_water_terrain_styled_with_workers(ways, height, terrain, roof_style, 0)
}
/// Below this many ways the per-way fan-out is not worth a worker pool spin-up and
/// the extrusion stays on the serial loop (a fixture town, the rabbit, a unit test).
const PARALLEL_WAYS_THRESHOLD: usize = 64;
/// **The scene-independent inputs one way's extrusion needs** — everything the
/// per-way body reads besides the way itself, captured once so the serial loop and
/// the gatling chunk workers route through the SAME body (LAW 5).
struct WayExtrudeCtx<'a> {
bb: (f32, f32, f32, f32),
du_per_m: Option<f32>,
band: f32,
y_water: f32,
relief: Option<&'a ReliefField>,
roof_style: RoofStyle,
height: f32,
}
/// **Extrude ONE way into the given arenas** — the loop body of
/// [`build_with_water_terrain_styled`], hoisted out so it has exactly one spelling
/// whether the arenas are the final mesh (serial) or a chunk worker's private ones
/// (gatling). Everything it reads is per-way or in `cx`; everything it writes is
/// appended to the arenas — no state crosses from one way to the next, which is
/// what makes chunking it sound (and `classify_new_vertices` per-way-scoped).
fn extrude_way_into(
mesh: &mut Mesh3,
water: &mut WaterMesh,
tiles: &mut usize,
stats: &mut RoofStats,
w: &Way,
cx: &WayExtrudeCtx<'_>,
) {
// **THE ONE PLACE THAT KNOWS WHETHER A VERTEX IS A HOUSE.** Everything pushed
// between here and the stamp at the bottom belongs to THIS way, so `w.kind` is
// the whole answer. Nothing downstream re-derives it from a height — it cannot,
// because `prism_height_for` raises every non-water closed ring, which is why
// `building_vertices` used to count industrial blocks as houses.
let vertices_before = mesh.positions.len();
let is_building = w.kind == "building";
let ring: Vec<(f32, f32)> = w.points.iter().map(|&(x, y)| to_design(x, y, cx.bb)).collect();
if ring.len() < 2 {
return;
}
// WATER → the reflective water grid, NOT the opaque mesh.
if surface_of(w.kind) == Surface::Water && is_prism(w) {
// Grid resolution scales with the ring extent so big lakes still get a
// few cells; bounded so a tiny pond doesn't explode. Deterministic.
let (mut x0, mut z0, mut x1, mut z1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for &(x, z) in &ring {
x0 = x0.min(x);
z0 = z0.min(z);
x1 = x1.max(x);
z1 = z1.max(z);
}
let extent = (x1 - x0).max(z1 - z0);
// ONE writer for "how fine is the water grid": `water_grid_res` derives the
// cell from the scene's own Gerstner wavelength, so the tessellation and the
// wave it carries cannot be tuned apart — and it owns the vertex budget, which
// is why a region-scale lake reads flat instead of costing 26× the vertices to
// alias the same ripple.
let res = water_grid_res(extent, cx.du_per_m);
water_grid_into(water, &ring, res, cx.y_water);
*tiles += 1;
return;
}
let col = w.fill.unwrap_or(w.stroke);
if is_prism(w) {
let h = prism_height_for(&ring, w.kind, w.height_m, cx.du_per_m, w.osm_id, cx.height);
// Sit the prism ON the ground: over relief terrain its eave datum rides the
// highest ground under the footprint (`base_y`) and its foundation reaches the
// lowest (`foot_y`), so the land never rises over the lower walls. Flat terrain
// → (0, 0), the byte-stable historical base.
let (base_y, foot_y) = cx.relief.map_or((0.0, 0.0), |f| f.footprint_span(&ring));
// A real risen building under RoofStyle::Pitched grows real roof geometry
// + OSM colours; everything else keeps the byte-stable flat-cap prism.
if cx.roof_style == RoofStyle::Pitched && w.kind == "building" && h.abs() >= 1e-5 {
let style = resolve_prism_style(&ring, w, col, cx.du_per_m);
extrude_prism_styled_based(mesh, &ring, h, &style, base_y, foot_y);
if matches!(style.shape, RoofShape::Gabled | RoofShape::Hipped) {
stats.pitched += 1;
}
if style.roof_col != style.wall_col {
stats.coloured += 1;
}
} else {
extrude_prism_based(mesh, &ring, h, col, base_y, foot_y, cx.band);
}
*tiles += 1;
} else {
line_feature(mesh, &ring, w, cx.band, cx.du_per_m);
*tiles += 1;
}
// One stamp per way, covering every vertex the branches above pushed — walls,
// foundation, flat cap, pitched roof, ribbon, casing, glow. A branch added later
// is classified BY CONSTRUCTION rather than by remembering to say so.
mesh.classify_new_vertices(vertices_before, is_building);
}
/// [`build_with_water_terrain_styled`] with an explicit gatling worker count —
/// `0` = one per core (the public default), `1` = the serial reference loop. The
/// hook the byte-identity proof (`tests/extrude_fanout.rs`) drives both sides
/// through; not part of the supported API surface.
#[doc(hidden)]
pub fn build_with_water_terrain_styled_with_workers(
ways: &[Way],
height: f32,
terrain: GroundTerrain<'_>,
roof_style: RoofStyle,
workers: usize,
) -> (Mesh3, usize, WaterMesh, RoofStats) {
let mut mesh = Mesh3::default();
let mut water = WaterMesh::default();
let mut tiles = 0usize;
let mut stats = RoofStats::default();
// The relief heightfield (`None` for the flat plane) is retained so every prism can
// be lifted onto the ground it stands over. Under `Flat`, buildings keep the
// byte-stable `y = 0` base — the existing goldens/callers do not move.
let relief: Option<ReliefField> = match terrain {
GroundTerrain::Flat => {
terrain_into(&mut mesh);
tiles += 1;
None
}
GroundTerrain::Relief { elev, subdiv, exaggeration } => {
let field = relief_heightfield(ways, elev, subdiv, exaggeration);
tiles += push_relief_mesh(&mut mesh, &field);
Some(field)
}
};
// The ground is never a house — including a RELIEF ground, whose vertices really do
// stand metres above `y = 0` and which the old class-blind `y > rise_eps` reading
// therefore counted as buildings. Stamped here rather than at the end so the vector
// is never behind the positions it describes.
mesh.classify_new_vertices(0, false);
let Some(bb) = footprint_bbox(ways) else {
return (mesh, tiles, water, stats);
};
let du_per_m = design_units_per_metre(ways);
// ONE scale for the whole scene's vertical stack: terrain < cover < road < water
// < roof. Every one of these is a LENGTH and every one goes through `du_per_m`.
let band = ground_band(du_per_m);
let y_water = water_height(du_per_m);
let cx = WayExtrudeCtx {
bb,
du_per_m,
band,
y_water,
relief: relief.as_ref(),
roof_style,
height,
};
// ── The per-way fan. Each way's extrusion is independent (see
// `extrude_way_into`), so the ways are CHUNKED across znippy's fork-join
// gatling (rayon is FORBIDDEN — ROOT LAW #0): each worker extrudes its chunk
// into a private Mesh3/WaterMesh arena, and the arenas are concatenated IN
// CHUNK ORDER with an index rebase (`append_rebased`). Chunk order = way
// order, so the fanned mesh is byte-identical to the serial append —
// positions, indices, colours, building stamps and the water grid alike
// (`tests/extrude_fanout.rs` holds the two side by side). Below the
// threshold (or `workers == 1`) the same body runs in the plain loop.
if ways.len() < PARALLEL_WAYS_THRESHOLD || workers == 1 {
for w in ways {
extrude_way_into(&mut mesh, &mut water, &mut tiles, &mut stats, w, &cx);
}
} else {
let eff = if workers == 0 { znippy_zoomies::gatling_forkjoin::default_workers() } else { workers };
// ~4 chunks per worker evens out the load (a chunk of skyscrapers beside a
// chunk of paths), floored so a chunk is never trivially small.
let chunk = ways.len().div_ceil(eff * 4).max(16);
let nch = ways.len().div_ceil(chunk);
let parts = znippy_zoomies::gatling_forkjoin::gatling_for_each(nch, workers, |ci| {
let lo = ci * chunk;
let hi = (lo + chunk).min(ways.len());
let mut m = Mesh3::default();
let mut wt = WaterMesh::default();
let mut t = 0usize;
let mut s = RoofStats::default();
for w in &ways[lo..hi] {
extrude_way_into(&mut m, &mut wt, &mut t, &mut s, w, &cx);
}
(m, wt, t, s)
});
// `gatling_for_each` returns the chunks in INDEX order — the concatenation
// below is the whole determinism argument, so it never sorts or filters.
// (No reserve pass: `extend` from a `Vec` iterator already reserves the
// exact length, so the tail is one memcpy per lane per chunk as it stands —
// an explicit reserve was measured to change nothing.)
for (m, wt, t, s) in parts {
mesh.append_rebased(m);
water.append_rebased(wt);
tiles += t;
stats.pitched += s.pitched;
stats.coloured += s.coloured;
}
}
(mesh, tiles, water, stats)
}
/// Fraction of the `[-1,1]` design box (area 4) above which an *untagged* closed
/// ring is treated as flat ground cover rather than a risen prism — the safety
/// net that catches a country-sized admin boundary with no useful `kind`.
const FLAT_AREA_FRAC: f32 = 0.18; // 18% of the box
/// Choose the extrusion height for a footprint ring so the scene reads as a 3D
/// city (buildings rise to their REAL height, land/water lie flat):
/// - a **building** ring rises to its real metric height
/// ([`building_design_height`]: `height_m` from tags else a low default), scaled
/// by the data's world span (`du_per_m`) — so the skyline is low + varied, never
/// uniform spikes. When the span is unknown (`du_per_m == None`, e.g. the
/// synthetic rabbit) it falls back to the legacy `fallback` design-unit lift so
/// the fixtures/unit tests are unchanged;
/// - **water / park / landuse / coastline** rings lie FLAT (`0.0`) — ground
/// cover, never extruded into a block;
/// - any other ring rises *unless* it is large relative to the design box
/// ([`FLAT_AREA_FRAC`]), in which case it flattens — this catches admin /
/// boundary mega-rings while keeping building-scale + synthetic fixtures tall.
fn prism_height_for(
ring: &[(f32, f32)],
kind: &str,
height_m: Option<f32>,
du_per_m: Option<f32>,
osm_id: i64,
fallback: f32,
) -> f32 {
match kind {
// A real OSM building → its REAL metric height, span-scaled. Without a
// world scale (synthetic / degenerate data) fall back to the legacy lift.
"building" => match du_per_m {
Some(dpm) => building_design_height(height_m, dpm, osm_id, ring),
None => fallback,
},
// Ground cover never rises.
"water" | "park" | "coastline" => 0.0,
// Any other risen ring: large vs the box (an admin / boundary mega-ring) →
// FLAT (#11). Otherwise it rises, but the lift depends on whether we have a
// real world scale:
// - REAL data (`du_per_m` is `Some`): a small untagged ring (a leisure
// pitch, a `man_made`, an untagged closed way) gets the SAME low metric
// default a tagless building gets — so it never towers as the old
// uniform-`fallback` spike (the bug was these rings using 0.22 du);
// - SYNTHETIC data (`du_per_m` is `None`, e.g. the rabbit fixture): keep
// the legacy `fallback` design-unit lift so the fixture stays tall (a
// fixture has no real metric height, and there is no span to scale by).
_ => {
let poly = open_ring(ring);
let area = signed_area(&poly).abs();
let box_area = 4.0f32; // [-1,1]² = 2×2
if area > FLAT_AREA_FRAC * box_area {
0.0
} else {
match du_per_m {
Some(dpm) => building_design_height(height_m, dpm, osm_id, ring),
None => fallback,
}
}
}
}
}
/// Public convenience: build a mesh from ways at a default height. Returns just
/// the mesh (the [`crate::Map3D`] constructor uses [`build`] for the tile count).
pub fn extrude_ways(ways: &[Way], height: f32) -> Mesh3 {
build(ways, height).0
}
/// The **height distribution** of the extruded building prisms, in design units —
/// the readable-data emit that proves the skyline is low + varied (a city), not
/// uniform sky-high spikes. Computed from the SAME path [`build`] uses, so what
/// it reports is what the mesh extrudes. `box_span` (= 2.0, the `[-1,1]` extent)
/// lets the matrix assert `p95` is a small fraction of the box.
#[derive(Clone, Debug, Default)]
pub struct HeightStats {
/// Number of building footprints that rose to a prism.
pub buildings: usize,
/// How many used a REAL OSM tag (`height` / `building:levels`).
pub tagged: usize,
/// How many fell back to the default low-building height.
pub defaulted: usize,
/// Design-unit height stats over the buildings.
pub min: f32,
pub median: f32,
pub p95: f32,
pub max: f32,
/// The `[-1,1]` box span (2.0) the heights are measured against.
pub box_span: f32,
/// **The wall/footprint ratio — a building's wall height over its own
/// footprint's LONG plan side**, median over the buildings. A number, not a
/// taste: a house is wider than it is tall to the eave, so this sits well
/// under 1. Reported because "the walls have been enlarged and the roofs look
/// like they lean on the ground" is exactly this quantity going up.
pub wall_over_footprint_median: f32,
/// The worst (largest) wall/long-side ratio in the scene.
pub wall_over_footprint_max: f32,
/// **How many buildings are TALLER THAN THEY ARE LONG** (ratio > 1) — a wall
/// that swallows its own roof. The RED-when-broken count: it was 77 of 182 on
/// the demo's Sigtuna core before the untagged default was capped against the
/// footprint ([`MAX_DEFAULT_WALL_OVER_FOOTPRINT`]).
pub taller_than_long: usize,
/// Median **roof rise over wall height** — how much of a building's silhouette
/// is roof. A lid on a tall box tends to 0.
pub roof_over_wall_median: f32,
}
impl HeightStats {
/// The distribution as readable JSON (the "see what the mesh extrudes" emit).
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"buildings": self.buildings,
"tagged": self.tagged,
"defaulted": self.defaulted,
"min_du": self.min,
"median_du": self.median,
"p95_du": self.p95,
"max_du": self.max,
"box_span": self.box_span,
"p95_frac_of_box": if self.box_span > 0.0 { self.p95 / self.box_span } else { 0.0 },
"max_frac_of_box": if self.box_span > 0.0 { self.max / self.box_span } else { 0.0 },
// The proportion oracle: a wall must not swallow its own roof.
"wall_over_footprint_median": self.wall_over_footprint_median,
"wall_over_footprint_max": self.wall_over_footprint_max,
"taller_than_long": self.taller_than_long,
"roof_over_wall_median": self.roof_over_wall_median,
})
}
}
/// Compute the [`HeightStats`] for `ways` (using the same world-span scaling +
/// per-building metric height [`build`] applies). `fallback` mirrors the
/// `height` arg [`build`] takes (used only when no world scale is available).
pub fn building_height_stats(ways: &[Way], fallback: f32) -> HeightStats {
let Some(bb) = footprint_bbox(ways) else {
return HeightStats { box_span: 2.0, ..Default::default() };
};
let du_per_m = design_units_per_metre(ways);
let mut heights: Vec<f32> = Vec::new();
let mut tagged = 0usize;
// Wall/footprint + roof/wall ratios, measured on the SAME rings + the SAME
// heights the extruder uses — so what this reports is what the mesh is.
let mut wall_over_long: Vec<f32> = Vec::new();
let mut roof_over_wall: Vec<f32> = Vec::new();
let mut taller_than_long = 0usize;
for w in ways {
let ring: Vec<(f32, f32)> = w.points.iter().map(|&(x, y)| to_design(x, y, bb)).collect();
if ring.len() < 2 || !is_prism(w) {
continue;
}
// The height this ring actually extrudes to (the SAME call `build` makes).
let h = prism_height_for(&ring, w.kind, w.height_m, du_per_m, w.osm_id, fallback);
if h <= 1e-6 {
continue; // flat ground cover (water / park / mega-ring) → not a building
}
if w.height_m.is_some() {
tagged += 1;
}
heights.push(h);
// Ratios need a real world scale (a synthetic fixture has none).
if let Some(dpm) = du_per_m && let Some((_short_m, long_m)) = footprint_plan_metres(&ring, dpm) {
let h_m = h / dpm;
let r = h_m / long_m;
if r > 1.0 {
taller_than_long += 1;
}
wall_over_long.push(r);
// The roof rise this footprint grows under the pitched path — the
// SAME expression `roof_hipped_into` / `roof_gabled_into` use.
let poly = open_ring(&ring);
if poly.len() >= 3 {
let n = poly.len() as f32;
let (mut cx, mut cz) = (0.0f32, 0.0f32);
for &(x, z) in &poly {
cx += x;
cz += z;
}
let centroid = (cx / n, cz / n);
let rise = (DEFAULT_ROOF_PITCH * footprint_radius(&poly, centroid))
.min(ROOF_RIDGE_MAX_RISE_FACTOR * h);
roof_over_wall.push(rise / h.max(1e-9));
}
}
}
let med = |v: &mut Vec<f32>| -> f32 {
if v.is_empty() {
return 0.0;
}
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
v[v.len() / 2]
};
let wall_max = wall_over_long.iter().fold(0.0f32, |a, &b| a.max(b));
let wall_med = med(&mut wall_over_long);
let roof_med = med(&mut roof_over_wall);
if heights.is_empty() {
return HeightStats { box_span: 2.0, ..Default::default() };
}
heights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = heights.len();
let pct = |q: f32| heights[((q * (n as f32 - 1.0)).round() as usize).min(n - 1)];
HeightStats {
buildings: n,
tagged,
defaulted: n - tagged,
min: heights[0],
median: pct(0.5),
p95: pct(0.95),
max: heights[n - 1],
box_span: 2.0,
wall_over_footprint_median: wall_med,
wall_over_footprint_max: wall_max,
taller_than_long,
roof_over_wall_median: roof_med,
}
}
/// The terrain plane mesh alone (a single ground quad in the design box).
pub fn terrain_plane() -> Mesh3 {
let mut m = Mesh3::default();
terrain_into(&mut m);
m
}
/// **Z-fight lift for flat ground cover.** The terrain plane sits at `y = 0`;
/// flat landuse / water / park / coastline caps (height ≈ 0) would land EXACTLY
/// coplanar with it, so the GPU depth test (`CompareFunction::Less`) flip-flops
/// per-pixel frame to frame — the **green ground flicker**. We lift every flat cap
/// a hair above the terrain (a few × the depth buffer's resolvable epsilon) so it
/// deterministically wins the depth test without reading as "floating". Tiny vs the
/// `[-1,1]` box (≈0.0006 of the half-extent) so it is sub-pixel in the render.
pub const GROUND_LIFT: f32 = 1.2e-3;
/// The **z-order layer** a flat ground feature draws in — the cartographic stack
/// (land fill UNDER roads) made depth-correct on the GPU depth buffer. Each layer
/// occupies a DISJOINT, ordered sub-band of [`GROUND_LIFT`] so features sharing the
/// ground plane never z-fight: `terrain(0) < Cover < Road`, all sub-pixel so they
/// still read as laid on the ground. This is the seam a richer korp-supplied layer
/// stack ([`crate::korp::FeatureLayers`]) drapes onto.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GroundLayer {
/// Landuse / park / natural area fill — the LOWEST ground layer (over terrain).
Cover,
/// Street / path / rail ribbons — drawn ABOVE cover (a road always wins the
/// depth test over the landuse polygon it crosses).
Road,
/// **Host ink laid ON the basemap** — a case glow, an overprint trail, a graph
/// edge — drawn above the whole road stack, sub-passes included. Lane GG #4.
///
/// It is a band of its own and not a fourth road sub-pass, and that distinction is
/// the fix: [`layer_lift_in`] jitters each feature by a hash of its first vertex over
/// its band's whole `span`, so `Road`'s `[1.12, 1.22)` already overlaps its own
/// stack's reach of `[1.18, 1.28)`. A pass step is `0.03` — three of them cannot clear
/// a jitter span of `0.10`, so lifting overlay ink by "one more pass" would leave which
/// of two features wins decided by a coordinate hash, merely with the odds reversed.
/// A disjoint band is what this enum's own doc promised in the first place.
Overlay,
}
impl GroundLayer {
/// The [`GROUND_LIFT`]-relative `(base, span)` sub-band this layer occupies. The
/// bands are disjoint and ordered (`Cover` = `[1.00,1.08)` < `Road` = `[1.12,1.22)` <
/// `Overlay` = `[1.30,1.38)`) and all stay within `[0.9, 1.5]·GROUND_LIFT`.
///
/// **`Overlay`'s floor is 1.30 and not 1.22** because a road is not one ribbon: it is
/// three, stacked by [`road_pass_lift`] at `+0`, `+0.03` and `+0.06`, so the road
/// stack's real ceiling is `1.22 + 0.06 = 1.28`. Disjointness has to be measured
/// against what the layer actually reaches, not against the base band it is written
/// from — the two differ by two pass steps, and that gap is exactly where overlay ink
/// used to sink. `1.38` still clears `1.5` and stays under the route ribbon's
/// [`ROUTE_LIFT_BANDS`] = `1.6`, so the cartographic stack keeps its order:
/// cover < roads < host ink < your route.
fn band(self) -> (f32, f32) {
match self {
GroundLayer::Cover => (1.00, 0.08),
GroundLayer::Road => (1.12, 0.10),
GroundLayer::Overlay => (1.30, 0.08),
}
}
}
/// Deterministic per-ring micro-lift WITHIN a feature's [`GroundLayer`] band, so two
/// overlapping features of the SAME class stratify AND different classes never
/// collide (roads strictly above cover). Hashes the ring's first vertex → a fraction
/// of the band. NO RNG (snapshot-stable). Always ≥ a hair so a lone feature clears
/// the terrain plane.
/// `band` is the scene's band height from [`ground_band`]; pass [`GROUND_LIFT`] for
/// the historical absolute band.
#[must_use]
pub fn layer_lift_in(ring: &[(f32, f32)], layer: GroundLayer, band: f32) -> f32 {
let mut seed = 0x51ED_2701_A3C5_9E17u64;
if let Some(&(x, z)) = ring.first() {
seed ^= (x.to_bits() as u64).rotate_left(19) ^ (z.to_bits() as u64).rotate_left(41);
}
seed = seed.wrapping_mul(0x2545_F491_4F6C_DD1D);
let frac = (seed >> 40) as f32 / (1u64 << 24) as f32; // [0,1)
let (base, span) = layer.band();
band * (base + span * frac)
}
/// [`layer_lift_in`] on the historical absolute [`GROUND_LIFT`] band — the
/// scale-free entry point (synthetic data, and callers with no scene scale).
#[must_use]
pub fn layer_lift(ring: &[(f32, f32)], layer: GroundLayer) -> f32 {
layer_lift_in(ring, layer, GROUND_LIFT)
}
/// **The ground band's height expressed in METRES — the scale the buildings are
/// already measured in.**
///
/// [`GROUND_LIFT`] is an absolute design-unit constant while a building's rise is
/// `height_m · du_per_m` — metric, and therefore a function of how much ground the
/// scene spans. The two are unrelated quantities, so which of them is taller depends
/// entirely on the viewport, and past a few hundred metres of span the LAND WINS.
///
/// MEASURED on the real Sweden extract at central Stockholm — korp's own served
/// viewport, 2026-08-02, `korp/tests/map_render_truth.rs`: span 55 688 m ⇒
/// `du_per_m = 2.87e-5`; 641 buildings, median rise **2.05e-4** design units, against
/// a ground-cover cap at **1.30e-3** and road ribbons at **1.46e-3**. The land floats
/// **6.3×** over the median rooftop and the ribbons **7.1×**; even the tallest
/// building in the scene (1.67e-3) does not clear the ribbons. That is the recurring
/// *"the ground plane has risen and partly engulfs the buildings"* report, and
/// re-tuning an absolute constant cannot fix it — the defect is the mismatch of
/// UNITS, not the value.
///
/// So the band is declared in metres and converted through the scene's OWN
/// `du_per_m`, the same factor [`building_design_height`] uses. At `0.4 m` the cover
/// caps and road ribbons sit under half a metre of kerb, below every building rise the
/// extruder can produce ([`MIN_DEFAULT_BUILDING_HEIGHT_M`] is 2.5 m), so **cover <
/// road < roof holds by construction at any scene scale** instead of by luck at one.
pub const GROUND_BAND_METRES: f32 = 0.4;
/// Depth-safety floor for [`ground_band`], in design units. The band's whole job is to
/// win the depth test against the coplanar terrain, so it may shrink with the scene but
/// not to nothing. At a view distance of ~3 design units a `1e-5` separation is still
/// ~100× the `f32` `1/z` epsilon the CPU backend resolves with (`backend.rs`), and a
/// span wide enough for this floor to bite has sub-pixel buildings anyway.
///
/// **Honest bound:** where the floor bites — `du_per_m < 2.5e-5`, i.e. a span past
/// ~64 km — the guarantee weakens to "a ≥ 2.5 m building clears the band up to a
/// ~330 km span". A whole-country extrusion is still outside it.
pub const MIN_GROUND_BAND: f32 = 1.0e-5;
/// **The scene's ground-band height in design units** — [`GROUND_BAND_METRES`] taken
/// through the scene's own design-units-per-metre, clamped to `[MIN_GROUND_BAND,
/// GROUND_LIFT]` (never taller than the historical band, never below depth safety).
///
/// `du_per_m == None` is SYNTHETIC data with no world scale (the rabbit fixture, the
/// unit tests): there is no metre to convert, so the legacy [`GROUND_LIFT`] band is
/// kept and those scenes render byte-for-byte as before.
#[must_use]
pub fn ground_band(du_per_m: Option<f32>) -> f32 {
match du_per_m {
Some(dpm) if dpm > 0.0 => (GROUND_BAND_METRES * dpm).clamp(MIN_GROUND_BAND, GROUND_LIFT),
_ => GROUND_LIFT,
}
}
/// **The very top of the ground band** — the highest design-space `y` any FLAT feature
/// can reach in a scene whose band is `band`. Read off the highest [`GroundLayer`]
/// sub-band ([`GroundLayer::Road`]) rather than restated as a literal, so a change to
/// the stratification moves this with it (one writer for "where the ground ends").
#[must_use]
pub fn ground_top(band: f32) -> f32 {
let (base, span) = GroundLayer::Road.band();
band * (base + span)
}
/// **The rise that separates a BUILDING from the ground, in METRES.**
///
/// The scale-honest twin of [`GROUND_BAND_METRES`], and for the same reason. A
/// building's rise is `height_m · du_per_m` — metric, so it shrinks with the scene's
/// span — while the historical [`crate::BUILDING_RISE_EPS`] is an absolute design-unit
/// constant. Compare the two and the answer depends entirely on the viewport.
///
/// MEASURED on the shipped korp path, a real focused Xvfb window against the live
/// korp-server (2026-08-03, RTX 4090, Stockholm z12, `39 587` building ways /
/// `2 123 768` triangles): span ≈ 55.7 km ⇒ `du_per_m = 2.87e-5`, median building rise
/// `2.05e-4` design units — **one to two orders below `BUILDING_RISE_EPS = 4.0e-3`**.
/// Every risen-geometry observable therefore read **zero buildings** on a scene made
/// almost entirely of buildings, and `Map3D::frame_densest_cluster` took its
/// `risen.len() < 3` early return, leaving `camera.distance` bit-identical to its
/// `3.2` default. The defect is the mismatch of UNITS, not the value.
///
/// `1.0 m` sits by construction between the two things it must separate: the ground
/// band tops out at `1.22 · 0.4 m ≈ 0.5 m` of kerb (see [`ground_top`]), and the
/// shortest building the extruder can emit is [`MIN_DEFAULT_BUILDING_HEIGHT_M`] = 2.5 m.
/// So "a road-only scene reports zero buildings" and "a real house counts" both hold at
/// **any** scene span, instead of by luck at one.
pub const BUILDING_RISE_METRES: f32 = 1.0;
/// Clearance the rise threshold keeps over [`ground_top`] when the metric threshold
/// alone would sink into the ground band (only past a ~110 km span — see
/// [`building_rise_eps`]'s honest bound).
const RISE_OVER_GROUND: f32 = 1.2;
/// **The scene's building-rise threshold in design units** — [`BUILDING_RISE_METRES`]
/// taken through the scene's own design-units-per-metre, floored a clear
/// [`RISE_OVER_GROUND`] above the top of its ground band so a road ribbon can never
/// be counted as a house.
///
/// `du_per_m == None` is SYNTHETIC data with no world scale (the rabbit fixture, the
/// unit-test towns): there is no metre to convert, so the legacy absolute
/// [`crate::BUILDING_RISE_EPS`] is kept and those scenes report byte-for-byte the same
/// counts as before.
///
/// **Honest bound, in two stages** — [`ground_band`] stops shrinking at
/// [`MIN_GROUND_BAND`] (`du_per_m < 2.5e-5`, i.e. a span past ~64 km), so the floor
/// eventually stops tracking the scene. COMPUTED from the constants, and asserted in
/// `tests/region_span_buildings_rise.rs`:
///
/// - past a **~109 km** span the threshold stops meaning exactly "1 m" and starts
/// meaning "a clear margin above the kerb" — it is then *looser* than 1 m, so no
/// building is lost, only the physical reading of the number goes soft;
/// - past a **~273 km** span the floor finally exceeds even the shortest building the
/// extruder can emit ([`MIN_DEFAULT_BUILDING_HEIGHT_M`] · `du_per_m`), and a minimum-
/// height house would be counted as ground. At that span such a house is `2.4e-6`
/// design units in a box of `2.0` — six orders down, far past anything the `f32`
/// depth buffer or a pixel could resolve — so the classification is moot, but it is
/// the point where this function stops being able to tell a house from a kerb.
///
/// Every korp viewport measured so far (z12 Stockholm, ~55.7 km) is inside both.
#[must_use]
pub fn building_rise_eps(du_per_m: Option<f32>) -> f32 {
match du_per_m {
Some(dpm) if dpm > 0.0 => {
(BUILDING_RISE_METRES * dpm).max(RISE_OVER_GROUND * ground_top(ground_band(du_per_m)))
}
_ => crate::BUILDING_RISE_EPS,
}
}
/// [`building_rise_eps`] for a `ways` slice, derived through the **same**
/// `DESIGN_FILL / world_span_metres` the extruder scales building heights by — so the
/// threshold that decides "is this a building?" and the height that answers it can
/// never be computed from two different scales.
#[must_use]
pub fn building_rise_eps_for(ways: &[Way]) -> f32 {
building_rise_eps(design_units_per_metre(ways))
}
/// The flat-cover lift (the [`GroundLayer::Cover`] band) — the clearance a landuse /
/// park / water cap gets over the terrain plane, in the scene's own `band`.
fn ground_lift_for(ring: &[(f32, f32)], band: f32) -> f32 {
layer_lift_in(ring, GroundLayer::Cover, band)
}
/// **The colour of the ground plane — read from the map palette, not invented here.**
///
/// There were two writers of "what colour is land". 2D paints the whole landmass in
/// [`facett_map::MapPalette::land`] (`#F2EFE9`, paper) via `LandmassLayer`; 3D's
/// terrain quad was a hard-coded `rgb(38, 44, 40)` — a near-black green that had never
/// heard of the palette. Same ways, same theme, two different grounds, and the 3D one
/// was the reason the pane read as a dark slab with cover floating on it.
///
/// One writer (LAW 5): the palette. `day` matches the palette korp classifies its ways
/// with (`map_theme().palette`), so the plane and the features on it can never come
/// from two different themes.
#[must_use]
pub fn terrain_colour(day: bool) -> Color32 {
facett_map::MapPalette::cartographic(day).land
}
/// Append the ground quad (`y = 0`, normal +y) covering the design box, in the
/// palette's land colour ([`terrain_colour`]).
fn terrain_into(mesh: &mut Mesh3) {
terrain_into_coloured(mesh, terrain_colour(true));
}
/// [`terrain_into`] with an explicit ground colour — the seam a host with a night
/// palette (or a test asserting the plane's colour) paints through.
fn terrain_into_coloured(mesh: &mut Mesh3, ground: Color32) {
let n = [0.0, 1.0, 0.0];
let g = 1.05; // a touch larger than the data box
let a = mesh.push_vertex([-g, 0.0, -g], n);
let b = mesh.push_vertex([g, 0.0, -g], n);
let c = mesh.push_vertex([g, 0.0, g], n);
let d = mesh.push_vertex([-g, 0.0, g], n);
// CCW from above (+y) → front-facing after the camera tilt.
mesh.push_tri([a, c, b], ground);
mesh.push_tri([a, d, c], ground);
}
/// The maximum vertical relief (design units, ±) the [`relief_terrain_into`] ground
/// heightfield is allowed to span. Bounded a hair above a typical readable building
/// height so the topography reads under the skyline without towering over it or
/// escaping the `[-1, 1]` design box. The metric height scale is auto-fit down to
/// this ceiling so a 600 m procedural range and a 40 m town DEM both land in-box.
pub const MAX_TERRAIN_RELIEF: f32 = 0.30;
/// The **data's WGS-84 lon/lat bounding box** `(lon0, lat0, lon1, lat1)` from the
/// ways' `lonlat`, or `None` for empty data. The relief mesh samples elevation over
/// this box (mapped onto the design grid) so real topography lands under the city.
#[must_use]
pub fn lonlat_bbox(ways: &[Way]) -> Option<(f64, f64, f64, f64)> {
let (mut lon0, mut lat0) = (f64::MAX, f64::MAX);
let (mut lon1, mut lat1) = (f64::MIN, f64::MIN);
let mut any = false;
for w in ways {
for &(lon, lat) in &w.lonlat {
lon0 = lon0.min(lon);
lat0 = lat0.min(lat);
lon1 = lon1.max(lon);
lat1 = lat1.max(lat);
any = true;
}
}
any.then_some((lon0, lat0, lon1, lat1))
}
/// A sampled **ground-relief heightfield** over the design box `[-g, g]²`: the placed
/// design-space `y` at each of the `(n+1)²` grid vertices (the SAME `y` values
/// [`relief_terrain_into`] gives the rendered terrain). Lets a building's base be lifted
/// onto the terrain surface at its footprint (see [`ReliefField::height_at`]) instead of
/// every building being pinned to `y = 0` while the relief rises up THROUGH them — the
/// "houses look half-buried / the land covers the lower walls" bug. `Flat` terrain has no
/// field (buildings keep the byte-stable `y = 0` base).
struct ReliefField {
/// Grid subdivisions per side (`n + 1` vertices per axis).
n: usize,
/// Half-extent of the terrain grid in design units (matches [`terrain_into`]).
g: f32,
/// Placed design-space `y` per grid vertex, row-major `j * (n + 1) + i`.
ys: Vec<f32>,
}
impl ReliefField {
/// Bilinearly sample the **ground height** (design-space `y`) at a design-plane point
/// `(x, z)` (both in `[-g, g]`). A footprint placed at this `(x, z)` and lifted by this
/// height sits flush ON the rendered terrain, so the land never rises over its walls.
fn height_at(&self, x: f32, z: f32) -> f32 {
let n = self.n;
let g = self.g;
// design [-g, g] → grid coordinate [0, n], clamped so off-box footprints read the edge.
let fx = (((x + g) / (2.0 * g)) * n as f32).clamp(0.0, n as f32);
let fz = (((z + g) / (2.0 * g)) * n as f32).clamp(0.0, n as f32);
let i0 = (fx.floor() as usize).min(n);
let j0 = (fz.floor() as usize).min(n);
let i1 = (i0 + 1).min(n);
let j1 = (j0 + 1).min(n);
let tx = fx - i0 as f32;
let tz = fz - j0 as f32;
let at = |i: usize, j: usize| self.ys[j * (n + 1) + i];
let a = at(i0, j0) + (at(i1, j0) - at(i0, j0)) * tx;
let b = at(i0, j1) + (at(i1, j1) - at(i0, j1)) * tx;
a + (b - a) * tz
}
/// The **terrain span under a footprint** `(hi, lo)`: the max and min ground height
/// over the footprint. A prism lifted so its eave datum is `hi` and its foundation
/// reaches `lo` spans the whole ground under it — no wall is buried (all terrain ≤
/// eave) and none floats (the skirt reaches the lowest ground).
///
/// # Sampling the ring's VERTICES alone was not enough
/// Until 2026-08-02 this sampled `height_at` at each ring vertex only, and claimed
/// on that basis that "no wall is buried". The terrain is a bilinear grid, so a
/// ridge or grid node **inside** the footprint — or along an edge, between two
/// vertices — is routinely higher than every corner. Where that happened the land
/// rose over the walls and swallowed the lower storeys, which is the recurring
/// *"houses are sunk into the land"* report. It appears **partly**, on some
/// buildings and not others, because it needs terrain that peaks between the
/// samples — so it never looked like a systematic error, and every fix aimed at
/// `GROUND_LIFT` (1.2e-3, sub-pixel) missed it entirely.
///
/// The span therefore samples more than the vertices — but it must sample the
/// **FOOTPRINT**, and between 2026-08-02 03:51 and this commit it sampled the
/// footprint's **BOUNDING BOX**, which is not the same set and is the reason the OSM
/// 3D view got worse rather than better.
///
/// # Why the bounding box was the wrong set
/// A bbox equals its ring only for an axis-aligned rectangle. For an L-shape, a
/// triangle, or any ring running diagonally, the box reaches ground the building does
/// not stand on; where it clips a nearby ridge, that ridge becomes the eave datum and
/// the prism is lifted off terrain it never touches. **Flat ground cover suffers
/// worst** — parks, landuse and coastline rings are the largest rings in the scene and
/// their cap is drawn *at* the datum (`height ≈ 0`), so the land itself floats up.
/// Measured on a synthetic multi-octave relief (400 random rings, terrain range 0.60
/// design units): the bbox bound lifted small building footprints by 0.013 on average
/// but 16-cell ground-cover rings by 0.047 — **3.5× more**, worst case 0.19. A default
/// 6 m building is ~0.006 design units at Sigtuna scale, so the land outran the
/// rooftops by several building heights. Reported as *"houses are barely visible, the
/// ground is higher than the houses"*.
///
/// # The set that is actually bounded
/// `hi` is the max over the ring's **vertices**, the grid **nodes inside the ring**,
/// and the points where the ring's **edges cross grid lines**. The last two are what
/// vertex-only sampling missed: a bilinear surface takes its extremes at cell corners,
/// so a peak strictly between two vertices is either at an interior node or on a cell
/// boundary an edge crosses. None of the three reaches outside the footprint.
///
/// `lo` stays the conservative bbox minimum on purpose: it is the foundation, it is
/// buried, and being too deep is invisible while being too shallow floats the skirt.
///
/// Bounded by the grid (worst case `(n+1)²` node tests plus `O(edges · n)` crossings)
/// and it runs once per mesh build, never per frame.
fn footprint_span(&self, ring: &[(f32, f32)]) -> (f32, f32) {
if ring.is_empty() {
return (0.0, 0.0);
}
let (n, g) = (self.n, self.g);
// design [-g, g] → grid coordinate [0, n], the SAME mapping `height_at` uses.
let to_grid = |v: f32| (((v + g) / (2.0 * g)) * n as f32).clamp(0.0, n as f32);
// …and back, so a grid index names the design-space point to test for containment.
let to_design = |k: usize| (k as f32 / n as f32) * 2.0 * g - g;
let (mut x0, mut x1, mut z0, mut z1) = (f32::MAX, f32::MIN, f32::MAX, f32::MIN);
for &(x, z) in ring {
x0 = x0.min(x);
x1 = x1.max(x);
z0 = z0.min(z);
z1 = z1.max(z);
}
let i0 = to_grid(x0).floor() as usize;
let i1 = (to_grid(x1).ceil() as usize).min(n);
let j0 = to_grid(z0).floor() as usize;
let j1 = (to_grid(z1).ceil() as usize).min(n);
let mut lo = f32::MAX;
let mut hi = f32::MIN;
// ── the ring's own vertices: always in the footprint, by definition. ──
for &(x, z) in ring {
let y = self.height_at(x, z);
lo = lo.min(y);
hi = hi.max(y);
}
// ── grid nodes: `lo` takes them all (buried, conservative is free); `hi` takes
// only the ones the ring actually encloses. ──
for j in j0..=j1 {
for i in i0..=i1 {
let y = self.ys[j * (n + 1) + i];
lo = lo.min(y);
if point_in_polygon((to_design(i), to_design(j)), ring) {
hi = hi.max(y);
}
}
}
// ── where each edge crosses a grid line. On a cell boundary the bilinear surface
// is linear, so its max along an edge segment sits at a vertex or at one of
// these crossings — this is what catches a ridge peaking BETWEEN two vertices
// without ever leaving the footprint. ──
for w in 0..ring.len() {
let (ax, az) = ring[w];
let (bx, bz) = ring[(w + 1) % ring.len()];
let mut cross = |ga: f32, gb: f32| {
let (from, to) = if ga <= gb { (ga, gb) } else { (gb, ga) };
for k in (from.ceil() as usize)..=(to.floor().max(0.0) as usize).min(n) {
let t = if (gb - ga).abs() > 1e-9 { (k as f32 - ga) / (gb - ga) } else { continue };
if (0.0..=1.0).contains(&t) {
let y = self.height_at(ax + (bx - ax) * t, az + (bz - az) * t);
lo = lo.min(y);
hi = hi.max(y);
}
}
};
cross(to_grid(ax), to_grid(bx)); // vertical grid lines
cross(to_grid(az), to_grid(bz)); // horizontal grid lines
}
if lo.is_finite() && hi.is_finite() { (hi, lo) } else { (0.0, 0.0) }
}
}
/// Compute the [`ReliefField`] for `ways`/`elev` — the design-space `y` grid the
/// relief terrain mesh is built from AND that buildings sample to sit on the ground.
/// Single source of truth for the terrain `y` so the mesh and the building bases can
/// never disagree (the fix for "the ground covers the lower half of the buildings").
fn relief_heightfield(ways: &[Way], elev: &dyn crate::terrain::Elevation, subdiv: usize, exaggeration: f32) -> ReliefField {
let n = subdiv.max(2);
let g = 1.05f32; // matches the flat terrain quad's extent
let geo = lonlat_bbox(ways);
// Design-units-per-metre from the data's world span (the SAME scale buildings use);
// for span-less/degenerate data the ONE shared fallback, which is `lod`'s default
// scale read backwards — not a private literal that lets the terrain and the
// altitude lever believe two different scenes.
let du_per_m = design_units_per_metre(ways).unwrap_or(DEFAULT_DESIGN_UNITS_PER_METRE);
// Map a grid fraction (tx east→, tz far→near) to the `(lon, lat)` to sample. With
// real data this is the ways' lon/lat box (north = far = −z, matching `to_design`);
// otherwise a small deterministic patch so the procedural field still gives relief.
let sample_lonlat = |tx: f64, tz: f64| -> (f64, f64) {
match geo {
Some((lon0, lat0, lon1, lat1)) => (lon0 + (lon1 - lon0) * tx, lat1 - (lat1 - lat0) * tz),
None => (17.0 + tx * 0.05, 59.0 + (1.0 - tz) * 0.05),
}
};
let verts = (n + 1) * (n + 1);
let vidx = |i: usize, j: usize| j * (n + 1) + i;
// ── pass 1: raw metric heights + their mean (the datum we centre on) ──
let mut hm = vec![0.0f32; verts];
let mut sum = 0.0f64;
for j in 0..=n {
let tz = j as f64 / n as f64;
for i in 0..=n {
let tx = i as f64 / n as f64;
let (lon, lat) = sample_lonlat(tx, tz);
let h = elev.elevation_at(lon, lat);
hm[vidx(i, j)] = h;
sum += f64::from(h);
}
}
let mean = (sum / verts as f64) as f32;
// Auto-fit the metric scale so the peak deviation lands within the in-box relief
// ceiling (procedural 600 m and a 40 m town DEM both fit); never exaggerate beyond
// the metric scale the buildings use.
let peak_m = hm.iter().fold(0.0f32, |a, &h| a.max((h - mean).abs()));
let raw_scale = du_per_m * exaggeration.max(0.0);
let scale = if peak_m > 1e-3 { raw_scale.min(MAX_TERRAIN_RELIEF / peak_m) } else { 0.0 };
// ── pass 2: the placed design-space y per vertex (mean-centred, in-box) ──
let ys: Vec<f32> = hm.iter().map(|&h| ((h - mean) * scale).clamp(-MAX_TERRAIN_RELIEF, MAX_TERRAIN_RELIEF)).collect();
ReliefField { n, g, ys }
}
/// **Replace the flat ground quad with a real heightfielded terrain mesh** (roadmap
/// §6, ported from the flight engine): a subdivided `(subdiv+1)²` grid over the design
/// box `[-g, g]²`, each vertex lifted to a **real elevation** sampled from `elev` (a
/// DEM or the procedural fallback) at the `(lon, lat)` the design grid maps onto. The
/// metric relief is mean-centred (so the city still sits around `y = 0`), scaled by the
/// data's design-units-per-metre × `exaggeration`, and **auto-fit** so the peak relief
/// never exceeds [`MAX_TERRAIN_RELIEF`] (keeps procedural 600 m ranges + town DEMs both
/// in-box). Per-vertex normals from central differences drive the painter's lighting so
/// the slopes actually shade; a height tint (low = ground green, high = pale rock) makes
/// the relief read. Winding matches [`terrain_into`] (front-facing from above). Counts
/// as ONE terrain feature (returns `1`) like the flat quad, so tile counts are stable.
pub fn relief_terrain_into(mesh: &mut Mesh3, ways: &[Way], elev: &dyn crate::terrain::Elevation, subdiv: usize, exaggeration: f32) -> usize {
let field = relief_heightfield(ways, elev, subdiv, exaggeration);
push_relief_mesh(mesh, &field)
}
/// Push the relief grid geometry (vertices + height tint + central-difference normals +
/// two triangles per cell) for a computed [`ReliefField`]. Splitting the `y` computation
/// ([`relief_heightfield`]) from the mesh emission lets buildings sample the SAME field to
/// sit on the ground without ever recomputing (or drifting from) the rendered surface.
fn push_relief_mesh(mesh: &mut Mesh3, field: &ReliefField) -> usize {
let (n, g) = (field.n, field.g);
let verts = (n + 1) * (n + 1);
let vidx = |i: usize, j: usize| j * (n + 1) + i;
// ── place vertices (design x/z + the field's real height y) + height tint ──
let base0 = mesh.positions.len() as u32;
let low = Color32::from_rgb(38, 44, 40); // valley / ground green (the old flat colour)
let high = Color32::from_rgb(120, 126, 120); // pale rock / ridge
let mut vcol = vec![low; verts];
let up = [0.0f32, 1.0, 0.0];
for j in 0..=n {
let z = -g + 2.0 * g * (j as f32 / n as f32);
for i in 0..=n {
let x = -g + 2.0 * g * (i as f32 / n as f32);
let y = field.ys[vidx(i, j)];
// Height tint fraction over the actual relief band (finite even if flat).
let t = ((y / MAX_TERRAIN_RELIEF) * 0.5 + 0.5).clamp(0.0, 1.0);
vcol[vidx(i, j)] = Color32::from_rgb(
(low.r() as f32 + (high.r() as f32 - low.r() as f32) * t) as u8,
(low.g() as f32 + (high.g() as f32 - low.g() as f32) * t) as u8,
(low.b() as f32 + (high.b() as f32 - low.b() as f32) * t) as u8,
);
mesh.push_vertex([x, y, z], up); // normal fixed up in pass 3
}
}
// ── pass 3: per-vertex normals from central differences of the placed grid ──
for j in 0..=n {
for i in 0..=n {
let p = |a: usize, b: usize| mesh.positions[(base0 as usize) + vidx(a, b)];
let l = p(i.saturating_sub(1), j);
let r = p((i + 1).min(n), j);
let d = p(i, j.saturating_sub(1));
let u = p(i, (j + 1).min(n));
let du = [r[0] - l[0], r[1] - l[1], r[2] - l[2]];
let dv = [u[0] - d[0], u[1] - d[1], u[2] - d[2]];
// du (east) × dv (near) → points up for this west→east / far→near layout.
let mut nrm = [du[1] * dv[2] - du[2] * dv[1], du[2] * dv[0] - du[0] * dv[2], du[0] * dv[1] - du[1] * dv[0]];
let len = (nrm[0] * nrm[0] + nrm[1] * nrm[1] + nrm[2] * nrm[2]).sqrt();
if len > 1e-9 {
let s = if nrm[1] < 0.0 { -1.0 / len } else { 1.0 / len };
nrm = [nrm[0] * s, nrm[1] * s, nrm[2] * s];
mesh.normals[(base0 as usize) + vidx(i, j)] = nrm;
}
}
}
// ── triangles: two per cell, winding matching `terrain_into` (front-facing up) ──
for j in 0..n {
for i in 0..n {
let v00 = base0 + vidx(i, j) as u32;
let v10 = base0 + vidx(i + 1, j) as u32;
let v01 = base0 + vidx(i, j + 1) as u32;
let v11 = base0 + vidx(i + 1, j + 1) as u32;
// Per-triangle colour = one corner's tint (cheap, reads the height band).
mesh.push_tri([v00, v11, v10], vcol[vidx(i + 1, j + 1)]);
mesh.push_tri([v00, v01, v11], vcol[vidx(i, j + 1)]);
}
}
1
}
/// Strip a ring's trailing duplicate-of-first closing vertex (OSM rings repeat
/// the first point as the last). Ear-clipping wants the open vertex list.
fn open_ring(ring: &[(f32, f32)]) -> Vec<(f32, f32)> {
let mut v = ring.to_vec();
while v.len() >= 2 {
let (a, b) = (v[0], v[v.len() - 1]);
if (a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9 {
v.pop();
} else {
break;
}
}
v
}
/// Signed area of a polygon (shoelace) in the `(x, z)` ground plane. Positive =
/// CCW, negative = CW. Magnitude is the footprint area in design units².
fn signed_area(poly: &[(f32, f32)]) -> f32 {
let n = poly.len();
if n < 3 {
return 0.0;
}
let mut a = 0.0f32;
for i in 0..n {
let (x0, z0) = poly[i];
let (x1, z1) = poly[(i + 1) % n];
a += x0 * z1 - x1 * z0;
}
a * 0.5
}
/// Is point `p` strictly inside triangle `a,b,c` (CCW)? Used by the ear-clip to
/// reject ears that would swallow a reflex vertex.
fn point_in_tri(p: (f32, f32), a: (f32, f32), b: (f32, f32), c: (f32, f32)) -> bool {
// Barycentric sign test.
let d1 = (p.0 - b.0) * (a.1 - b.1) - (a.0 - b.0) * (p.1 - b.1);
let d2 = (p.0 - c.0) * (b.1 - c.1) - (b.0 - c.0) * (p.1 - c.1);
let d3 = (p.0 - a.0) * (c.1 - a.1) - (c.0 - a.0) * (p.1 - a.1);
let has_neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0;
let has_pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0;
!(has_neg && has_pos)
}
/// Cross product z of `(b-a)×(c-b)` — convex turn test for a CCW polygon.
fn cross_z(a: (f32, f32), b: (f32, f32), c: (f32, f32)) -> f32 {
(b.0 - a.0) * (c.1 - b.1) - (b.1 - a.1) * (c.0 - b.0)
}
/// **Ear-clip triangulate** a simple (possibly concave) polygon given as a CCW
/// open vertex list, returning local-index triangles `[i, j, k]` into `poly`.
/// Standard O(n²) ear clipping — correct for concave footprints, NO fabricated
/// centroid vertex (the centroid-fan is what drew the OSM starburst). Returns an
/// empty Vec for a degenerate ring (< 3 verts or ~zero area / collinear).
fn ear_clip(poly: &[(f32, f32)]) -> Vec<[usize; 3]> {
let n = poly.len();
if n < 3 {
return Vec::new();
}
if signed_area(poly).abs() < 1e-12 {
return Vec::new(); // collinear / zero-area → no cap
}
// Working list of vertex indices in CCW order.
let mut idx: Vec<usize> = (0..n).collect();
let mut tris: Vec<[usize; 3]> = Vec::with_capacity(n - 2);
let mut guard = 0usize;
let guard_max = n * n + 16;
while idx.len() > 3 {
guard += 1;
if guard > guard_max {
// No ear found in a full sweep (numerically nasty ring) — fan the
// remainder over its FIRST vertex. This is a bounded fan over the
// ring's own remaining verts (never a global centroid), so it cannot
// span the design box.
for k in 1..idx.len() - 1 {
tris.push([idx[0], idx[k], idx[k + 1]]);
}
return tris;
}
let m = idx.len();
let mut clipped = false;
for i in 0..m {
let ia = idx[(i + m - 1) % m];
let ib = idx[i];
let ic = idx[(i + 1) % m];
let (a, b, c) = (poly[ia], poly[ib], poly[ic]);
// Convex vertex? (CCW: positive cross).
if cross_z(a, b, c) <= 0.0 {
continue;
}
// No other vertex inside this candidate ear?
let mut ear = true;
for &ip in &idx {
if ip == ia || ip == ib || ip == ic {
continue;
}
if point_in_tri(poly[ip], a, b, c) {
ear = false;
break;
}
}
if ear {
tris.push([ia, ib, ic]);
idx.remove(i);
clipped = true;
break;
}
}
if !clipped {
// Degenerate sweep without progress — bail with a remainder fan.
for k in 1..idx.len() - 1 {
tris.push([idx[0], idx[k], idx[k + 1]]);
}
return tris;
}
}
if idx.len() == 3 {
tris.push([idx[0], idx[1], idx[2]]);
}
tris
}
/// Extrude a closed ring `(dx, dz)` (design-plane footprint) into a prism: an
/// **ear-clip-triangulated** top cap at `y = height`, a bottom cap at `y = 0`,
/// and side walls. Caps use ONLY the ring's own vertices (no centroid fan — that
/// fan drew the OSM "starburst" on large concave rings). When `height` is ~0 the
/// prism collapses to a single flat ground cap (large area rings → ground cover,
/// not towers); see [`prism_height_for`].
fn extrude_prism_banded(mesh: &mut Mesh3, ring: &[(f32, f32)], height: f32, col: Color32, band: f32) {
extrude_prism_based(mesh, ring, height, col, 0.0, 0.0, band);
}
fn extrude_prism(mesh: &mut Mesh3, ring: &[(f32, f32)], height: f32, col: Color32) {
// Flat ground (`y = 0`) base — the byte-stable historical geometry.
extrude_prism_based(mesh, ring, height, col, 0.0, 0.0, GROUND_LIFT);
}
/// Like [`extrude_prism`], but the prism sits on **uneven ground**: its eave/top datum is
/// `base_y` (the highest ground under the footprint) and its bottom/foundation is `foot_y`
/// (the lowest), so over relief terrain the land never rises over the lower walls (the
/// "houses half-buried in the ground" bug). `base_y == foot_y == 0` reproduces
/// [`extrude_prism`] byte-for-byte (the flat-terrain / golden path).
fn extrude_prism_based(
mesh: &mut Mesh3,
ring: &[(f32, f32)],
height: f32,
col: Color32,
base_y: f32,
foot_y: f32,
band: f32,
) {
// Open the ring (drop the OSM closing-duplicate) + normalise to CCW so the
// ear-clip + cap normals are consistent.
let mut poly = open_ring(ring);
if poly.len() < 3 {
return; // degenerate → skip cap (and there are no walls to draw)
}
if signed_area(&poly) < 0.0 {
poly.reverse(); // force CCW
}
let cap_tris = ear_clip(&poly);
if cap_tris.is_empty() {
return; // collinear / zero-area ring → no geometry
}
let n = poly.len();
let flat = height.abs() < 1e-5;
// A flat ground-cover cap (landuse / water / park / coastline → height ≈ 0)
// must NOT sit coplanar with the terrain (plane at y = 0, or the relief surface at
// `base_y`), or the GPU depth test z-fights and the ground flickers green. Lift it a
// deterministic hair above the terrain (still sub-pixel) so it wins the depth test.
// Risen prisms rise `height` above their ground datum `base_y`.
let cap_y = if flat { base_y + ground_lift_for(&poly, band) } else { base_y + height };
// Walls/foundation drop to the lowest ground under the footprint (flush on a slope).
let bottom_y = foot_y;
// ── top cap (y = cap_y, normal +y) — ear-clipped, CCW from +y ──
let up = [0.0, 1.0, 0.0];
let top0 = mesh.positions.len() as u32;
for &(x, z) in &poly {
mesh.push_vertex([x, cap_y, z], up);
}
for &[i, j, k] in &cap_tris {
mesh.push_tri([top0 + i as u32, top0 + j as u32, top0 + k as u32], col);
}
if flat {
// A flat area patch (ground cover): one single-sided cap, no walls, no
// separate bottom — it lies just above the ground so it reads as land, not
// a box, but clears the terrain plane in depth (no z-fight flicker).
return;
}
// ── bottom cap (y = bottom_y, normal -y) — same triangulation, reversed winding ──
let down = [0.0, -1.0, 0.0];
let bot0 = mesh.positions.len() as u32;
for &(x, z) in &poly {
mesh.push_vertex([x, bottom_y, z], down);
}
for &[i, j, k] in &cap_tris {
// reverse winding so the bottom face points -y
mesh.push_tri([bot0 + i as u32, bot0 + k as u32, bot0 + j as u32], col);
}
// ── side walls: a quad per rim edge between top & bottom rims ──
//
// FIX 3 — **flat-shaded walls, no diagonal seam.** Each wall quad gets ONE flat
// outward face-normal (the edge's perpendicular in the ground plane), and the
// quad's four corners are emitted as **its own** vertices carrying that single
// normal — so the quad's two triangles share one normal and shade as a uniform
// lit face (no visible diagonal). The OLD code shared rim vertices between
// adjacent walls and gave each a per-vertex *radial* (centroid-relative) normal;
// the two triangles of one quad then interpolated/averaged DIFFERENT normals →
// a lighting seam exactly along the split diagonal. We also derive the normal
// from the edge direction (CCW ring ⇒ outward is `dir × up`), so winding +
// normal agree and the face never back-culls a half.
let centroid = {
let (mut cx, mut cz) = (0.0f32, 0.0f32);
for &(x, z) in &poly {
cx += x;
cz += z;
}
(cx / n as f32, cz / n as f32)
};
let wall_col = Color32::from_rgb(
(col.r() as f32 * 0.82) as u8,
(col.g() as f32 * 0.82) as u8,
(col.b() as f32 * 0.82) as u8,
);
for i in 0..n {
let (x0, z0) = poly[i];
let (x1, z1) = poly[(i + 1) % n];
// Edge direction in the ground plane; outward perpendicular (rotate −90°),
// then flip toward the centroid-away side so it is unambiguously outward
// (robust even if a ring locally reverses on a near-degenerate edge).
let (ex, ez) = (x1 - x0, z1 - z0);
let elen = (ex * ex + ez * ez).sqrt().max(1e-6);
let (mut nx, mut nz) = (ez / elen, -ex / elen); // perpendicular
// Edge midpoint → away from centroid is the outward sense.
let (mx, mz) = ((x0 + x1) * 0.5 - centroid.0, (z0 + z1) * 0.5 - centroid.1);
if nx * mx + nz * mz < 0.0 {
nx = -nx;
nz = -nz;
}
let nrm = [nx, 0.0, nz];
// This quad's OWN four corners, all carrying the single flat face normal.
// Top rides the ground datum + height (`cap_y`), bottom drops to `bottom_y`.
let i0t = mesh.push_vertex([x0, cap_y, z0], nrm);
let i0b = mesh.push_vertex([x0, bottom_y, z0], nrm);
let i1t = mesh.push_vertex([x1, cap_y, z1], nrm);
let i1b = mesh.push_vertex([x1, bottom_y, z1], nrm);
// Outward-facing CCW winding (consistent across both triangles): viewed from
// outside (along −nrm into the face) the order top0→top1→bot0 / top1→bot1→bot0
// turns the same way, so neither triangle is back-culled (no half-quad gap).
mesh.push_tri([i0t, i1t, i0b], wall_col);
mesh.push_tri([i1t, i1b, i0b], wall_col);
}
}
// ─────────────────────────────────────────────────────────────────────────────────
// Pitched-roof + OSM-colour extrusion (round 2: houses read as real buildings).
// Additive + golden-safe: only reached from `build_with_water_terrain_styled` under
// `RoofStyle::Pitched`; the historical flat-cap `extrude_prism` above is untouched, so
// the default path (and every pixel golden) is byte-identical (L2).
// ─────────────────────────────────────────────────────────────────────────────────
/// The resolved per-building **prism style** the pitched path extrudes with: the roof
/// silhouette (already defaulted for untagged/large footprints), the facade + roof
/// colours (OSM tag else a tasteful default), and the ridge pitch.
#[derive(Clone, Copy, Debug)]
pub struct PrismStyle {
/// The resolved roof silhouette (Flat / Gabled / Hipped).
pub shape: RoofShape,
/// Facade (wall) colour — `building:colour` else the themed fill.
pub wall_col: Color32,
/// Roof colour — `roof:colour`/`roof:material` else a class default.
pub roof_col: Color32,
/// Ridge/apex rise as a fraction of the footprint half-width.
pub pitch: f32,
}
/// A deterministic, tasteful **default roof tint** for a building with no OSM roof
/// colour — a small varied palette keyed on the footprint id so a fully-untagged town
/// gets a warm, varied roofscape (terracotta / slate / brown / grey-green) instead of
/// one monochrome fill. No RNG → snapshot-stable.
fn default_roof_tint(osm_id: i64) -> Color32 {
const PALETTE: [(u8, u8, u8); 5] = [
(176, 92, 66), // terracotta tile (the dominant Nordic/European roof)
(150, 74, 58), // deep clay red
(96, 100, 108), // slate grey
(120, 96, 72), // weathered brown
(104, 120, 96), // patinated copper-green
];
let mut seed = (osm_id as u64) ^ 0xD1B5_4A32_D192_ED03;
seed = seed.wrapping_mul(0x2545_F491_4F6C_DD1D);
let idx = ((seed >> 40) as usize) % PALETTE.len();
let (r, g, b) = PALETTE[idx];
Color32::from_rgb(r, g, b)
}
/// Resolve a building [`Way`]'s [`PrismStyle`]: pick the roof silhouette (the OSM
/// `roof:shape` else a default gabled for a small footprint, flat for a large one),
/// the facade colour (`building:colour` else the themed fill) and the roof colour
/// (`roof:colour`/material else a class default). `col` is the way's themed fill.
fn resolve_prism_style(ring: &[(f32, f32)], w: &Way, col: Color32, du_per_m: Option<f32>) -> PrismStyle {
// Footprint area (design units) → gate large blocks to a flat roof (realism + perf).
// The gate is a real m² through this scene's own scale (see
// [`ROOF_PITCH_MAX_AREA_M2`]); as a fraction of the design box it was a SQUARED
// length, 281 250 m² at korp's scale, and never fired at all.
let area = signed_area(&open_ring(ring)).abs();
let small_enough = area <= roof_pitch_max_area_du(du_per_m);
let shape = if !small_enough {
RoofShape::Flat
} else {
match w.roof_shape {
// An explicit OSM roof shape wins.
RoofShape::Gabled => RoofShape::Gabled,
RoofShape::Hipped => RoofShape::Hipped,
// Untagged (Flat): give a small house a sensible default gabled roof so
// the town reads as pitched rooftops, not flat massing.
RoofShape::Flat => RoofShape::Gabled,
}
};
let wall_col = w.building_colour.unwrap_or(col);
let roof_col = w.roof_colour.unwrap_or_else(|| default_roof_tint(w.osm_id));
PrismStyle { shape, wall_col, roof_col, pitch: DEFAULT_ROOF_PITCH }
}
/// Extrude a building footprint into a **coloured, roofed** prism: shaded facade walls
/// (0.82× the facade colour, matching [`extrude_prism`]), a bottom cap, an eave cap at
/// `height`, and — for a `Gabled`/`Hipped` [`RoofShape`] — real pitched roof geometry
/// rising above the eave in the roof colour. `Flat` keeps just the (coloured) eave cap.
/// Additive to [`extrude_prism`]; used only under [`RoofStyle::Pitched`]. The scene loop
/// calls [`extrude_prism_styled_based`] directly (with the terrain datum); this flat-base
/// wrapper is the byte-stable entry the unit tests build against.
#[cfg_attr(not(test), allow(dead_code))]
fn extrude_prism_styled(mesh: &mut Mesh3, ring: &[(f32, f32)], height: f32, style: &PrismStyle) {
// Flat ground (`y = 0`) base — the byte-stable historical geometry.
extrude_prism_styled_based(mesh, ring, height, style, 0.0, 0.0);
}
/// Like [`extrude_prism_styled`], but the roofed building sits on **uneven ground**: its
/// eave (and the roof above it) rides `base_y + height` (the highest ground under the
/// footprint plus its wall height) and its foundation drops to `foot_y` (the lowest), so
/// over relief terrain the land never rises over the lower facade — the fix for "houses
/// look half-buried". `base_y == foot_y == 0` reproduces [`extrude_prism_styled`].
fn extrude_prism_styled_based(
mesh: &mut Mesh3,
ring: &[(f32, f32)],
height: f32,
style: &PrismStyle,
base_y: f32,
foot_y: f32,
) {
let mut poly = open_ring(ring);
if poly.len() < 3 {
return;
}
if signed_area(&poly) < 0.0 {
poly.reverse();
}
let cap_tris = ear_clip(&poly);
if cap_tris.is_empty() {
return;
}
let n = poly.len();
// Eave (top of the walls / base of the roof) rides the ground datum; the foundation
// reaches the lowest ground under the footprint.
let eave_y = base_y + height;
let bottom_y = foot_y;
// ── eave cap (y = eave_y, +y) — the roof platform, in the ROOF colour ──
let up = [0.0, 1.0, 0.0];
let cap0 = mesh.positions.len() as u32;
for &(x, z) in &poly {
mesh.push_vertex([x, eave_y, z], up);
}
for &[i, j, k] in &cap_tris {
mesh.push_tri([cap0 + i as u32, cap0 + j as u32, cap0 + k as u32], style.roof_col);
}
// ── bottom cap (y = bottom_y, −y) — facade colour (unseen underside) ──
let down = [0.0, -1.0, 0.0];
let bot0 = mesh.positions.len() as u32;
The file goes on; the console shows all of it.