osm-katana/src/shared/height.rs
//! **Building height — the ONE place OSM's height tagging is turned into a number
//! (LAW 5).**
//!
//! # There IS a consumer already, and this corrects the brief that said there was not
//!
//! The lane brief for this work said a grep across facett's 3D map sources for a
//! height/levels consumer "returns nothing". **That is wrong, and the correction matters
//! more than the module.** SURVEYED 2026-08-31:
//!
//! * `facett_map::parse_building_height_m` (`facett-map/src/lib.rs:398`) reads `height`
//! and `building:levels` out of the tag list;
//! * it is called from `Way::from_lonlat_styled` (`:633`) for every building way korp
//! builds, via `korp::map_paint::classify_ways` → `facett_osm::parse_tags_into`;
//! * `facett_map3d::extrude::building_design_height` (`facett-map3d/src/extrude.rs:2366`)
//! uses the result, and falls back to `DEFAULT_BUILDING_LEVELS × METRES_PER_LEVEL`
//! = 2.0 × 3.0 = **6 m** with ±25 % deterministic jitter, capped at 0.9 × the
//! footprint's short side.
//!
//! So buildings are not all one box today; they are 6 m boxes with jitter, plus a real
//! height wherever a tag survived. What is wrong is WHERE the work happens and HOW
//! forgiving it is:
//!
//! 1. **It runs per frame, off the JSON.** `optimize::slim_tags` keeps `height` and
//! `building:levels` in the allowlist, so the strings do reach the tile — as TEXT
//! inside the `tags` blob, which is the one place a renderer cannot afford to look.
//! korp's `parse_tags` (`serde_json` over that blob) is **55.9 % of all allocations
//! on a tile read**, measured 2026-08-03. Every building's height is re-derived from
//! that string on every rebuild.
//! 2. **It guesses where it should refuse.** `parse_building_height_m` takes the leading
//! run of digits and dots, so `height=1;12` becomes a **1 m** building, `height=25'`
//! becomes a **25 m** building (it is 7.62 m), `height=102,5` becomes 102 m, and
//! `building:levels=3-4` becomes 3 levels — silently, every one of them. There is no
//! plausibility ceiling either, so `height=99999` is a 99 999 m spike.
//! 3. **A wrong number is indistinguishable from a right one.** `Option<f32>` cannot say
//! "this was surveyed" versus "this was assumed" versus "somebody tagged nonsense".
//!
//! So the decision is made HERE instead: once, at build time, into a typed column, with
//! a stamp saying which of those three it is. This module is the only writer of it.
//!
//! # What OSM actually says, measured — not assumed
//!
//! Every rule below was written against the value zoo of korp's live Sweden cut
//! (`/home/korp/.korp/work/sweden/`, cut 2026-08-31), over the **3 841 511** rows in
//! `ways.parquet` + `relations.resolved.parquet` that carry `building` or
//! `building:part`. The distinct non-plain-numeric `height` values in that population
//! number **86**, over **566** rows; the whole zoo is listed here so a later reader can
//! see what the parser was built to survive rather than guess:
//!
//! | form | example | rows (Sweden) | verdict |
//! |------|---------|---------------|---------|
//! | bare number | `height=12`, `height=12.5` | 9 019 | metres |
//! | number + `m` | `height=20 m`, `height=10m` | 556 | metres |
//! | number + spelled unit | `height=1 meter` | 1 | metres |
//! | comma decimal | `height=102,5`, `21,5`, `8,5` | 3 | metres (see below) |
//! | feet, feet+inches | `height=25'`, `39'6"` | 1 | feet → metres |
//! | semicolon list | `height=1;12`, `45 m;37 m`, `32;81` | 3 | **rejected** |
//! | free text | `height=B`, `height=Österbybruks hembygdsgård` | 2 | **rejected** |
//!
//! Cut end to end by `osm-katana buildings` on that cut (2026-08-31), the parser's own
//! verdict over all 3 841 483 building rows was: **3 675 499 absent (95.6792 %)**,
//! 156 167 derived from levels (4.0653 %), 9 551 tagged metres (0.2486 %), 265 rejected
//! (0.0069 %) and **1** tagged in feet. Those are the figures every claim in this file
//! rests on.
//!
//! `building:levels` is a smaller zoo and a dirtier one: **24** rows out of 159 589 carry
//! something that is not a number, and they are ranges (`3-4`), lists (`2;1`, `4;4.5`),
//! roof shapes filed under the wrong key (`gabled`), and building classifications
//! (`K1, 0`, `F6`). All rejected. `roof:levels` is worse still — nine of its distinct
//! non-numeric values in Sweden are **HTML colours** (`#ffb5b5`), which is a mapper's
//! editor writing `roof:colour` into the wrong key.
//!
//! ## Feet are real, and Sweden is the wrong place to prove it
//!
//! `height=39'6"` is a documented and common tagging in the US and UK. In Sweden it
//! fires **once** in 3.8 M buildings (`25'`). The branch is here because this crate cuts
//! the PLANET, not because Swedish data needs it — and that is stated rather than
//! implied, because a branch measured at 1 row is a branch that unit tests, not field
//! data, must hold up. See [`parse_length`]'s tests.
//!
//! ## The comma
//!
//! `102,5` is 102.5 m in Swedish and 1025 nowhere, but `1,500` is 1500 in `en_US` and
//! 1.5 in `sv_SE` and no amount of staring resolves it. The rule is therefore
//! syntactic and total: a single comma followed by **one or two** digits at end of
//! string is a decimal separator; a comma followed by exactly three is a thousands
//! group and the value is **rejected**, not guessed. Three Swedish rows take the first
//! branch; none take the second. The alternative — dropping all comma forms — was
//! rejected because it would silently lose a 102.5 m building, and silent loss is the
//! defect this whole module exists to avoid.
//!
//! # Metres per level: 3.2, and the measurement that chose it
//!
//! `building:levels` is a count, and turning it into metres needs an assumption. That
//! assumption is [`METRES_PER_LEVEL`] and it is **3.2 m**, chosen by measuring, not by
//! copying the 3.0 m that OSM Buildings and the Simple 3D Buildings wiki use.
//!
//! MEASURED on the same Sweden cut: **3 205** buildings carry BOTH a parseable `height`
//! and a parseable `building:levels`, which makes the constant an empirical question
//! with an answer. Sweeping the constant and scoring it by relative error against the
//! tagged height:
//!
//! | c (m/level) | median rel. err | mean rel. err | median, buildings ≥ 3 levels |
//! |------|------|------|------|
//! | 2.8 | 0.300 | 0.277 | 0.160 |
//! | 3.0 | 0.250 | **0.246** | 0.115 |
//! | **3.2** | 0.200 | 0.251 | **0.097** |
//! | 3.4 | **0.150** | 0.262 | 0.133 |
//! | 3.6 | 0.200 | 0.281 | 0.152 |
//! | 4.0 | 0.308 | 0.331 | — |
//!
//! (levels counted as `building:levels + roof:levels`, see below.)
//!
//! No constant wins on all three. 3.4 minimises the median, 3.0 minimises the mean, 3.2
//! minimises the error on buildings of three levels or more — which is where an
//! extrusion is actually visible on a map, and where the raw per-level medians converge
//! anyway (levels=7 → 3.14 m, levels=8 → 3.12, levels=9 → 3.11, levels=10 → 3.00; the
//! big per-level numbers all come from single-storey barns and industrial halls, median
//! 4.00 m at levels=1). 3.2 is the compromise that is best where it matters and within
//! 0.005 of the best elsewhere. **It is an assumption with a ±20 % median error and the
//! column says so** — a row derived this way is stamped
//! [`HeightSource::DerivedFromLevels`], and a consumer that wants only surveyed heights
//! can filter on the stamp instead of trusting all f32s equally.
//!
//! ## `roof:levels` is counted; `roof:shape` and `roof:direction` are not
//!
//! Per the OSM wiki, `height` includes the roof while `building:levels` excludes roof
//! levels, so the level total is `building:levels + roof:levels`. That is not a style
//! preference, it is measurable: at c = 3.2 including `roof:levels` moves the mean
//! relative error from 0.273 to **0.251**. 67 427 Swedish buildings carry `roof:levels`,
//! so it is not a rounding error either.
//!
//! `roof:shape` (107 484 rows — gabled 78 022, flat 10 641, hipped 7 337, …),
//! `roof:height` (11 824 rows) and `roof:direction` are **out of scope**, deliberately:
//!
//! * They describe a MESH, not a prism. This module's whole output contract is
//! "min_height_m → height_m", a vertical extrusion; a gabled roof cannot be expressed
//! in it, and half-expressing it would make the number mean two things.
//! * `roof:height` in particular would be a column nothing could consume: without a
//! roof-shape generator in the renderer there is no geometry for it to modify. That is
//! exactly the `changeset` defect `shared::schema::nodes_schema` documents — a column
//! declared because the fact exists rather than because a writer and a reader both
//! want it.
//!
//! If a roof generator ever lands, `roof:shape`/`roof:height`/`roof:direction` come back
//! as their own columns beside these, and `height_m` keeps meaning what it means today:
//! the top of the whole thing.
use std::borrow::Cow;
use crate::network::tag_value;
/// **Metres per building level** — the one assumption in this module, named so it can be
/// argued with. See the module docs for the measurement that chose 3.2 over 3.0.
///
/// # There is a second constant with this name, and it is not a duplicate
///
/// `facett_map::METRES_PER_LEVEL` is **3.0** (`facett-map/src/lib.rs:387`). Two constants
/// spelled the same is normally the LAW 5 defect, and here it is not — yet — because they
/// answer different questions:
///
/// * THIS one converts a MAPPED `building:levels` into metres, and 3.2 is the value that
/// minimises the error against 3 205 Swedish buildings that carry both tags.
/// * facett's is half of `DEFAULT_BUILDING_LEVELS × METRES_PER_LEVEL` = 6 m, the height of
/// a building nobody mapped at all. That is a LOOK decision about a fallback, and
/// facett's own `wall_roof_ratio` test is the measurement behind it.
///
/// They DO become one decision the moment facett's `parse_building_height_m` stops
/// deriving heights and reads `height_m` off this table instead — which is the hand-off
/// in `.nornir/osm-katana-v2-design.md`. After that change facett's constant is a
/// fallback-only number and this one is the only converter, which is the by-construction
/// close LAW 5 asks for. Until then, they overlap on every building carrying
/// `building:levels`, and a way built from a tile gets 3.0 while a row of
/// `buildings.parquet` gets 3.2. **That is a real, live discrepancy and it is stated
/// here rather than hidden.**
pub const METRES_PER_LEVEL: f32 = 3.2;
/// One international foot, exactly. `height=39'` is feet, and feet are 0.3048 m by
/// definition since 1959.
pub const METRES_PER_FOOT: f32 = 0.3048;
/// Metres per inch — `39'6"` is thirty-nine feet and six INCHES, not six of anything
/// else.
pub const METRES_PER_INCH: f32 = METRES_PER_FOOT / 12.0;
/// The tallest building this module will believe. The Burj Khalifa is 828 m; anything
/// over a kilometre in an OSM `height` tag is a units error or a typo, and letting one
/// through puts a 12 000 m spike through a city.
pub const MAX_PLAUSIBLE_HEIGHT_M: f32 = 1_000.0;
/// The most levels this module will believe, before the level → metre conversion. 163
/// is the Burj Khalifa; the tallest thing in the Sweden cut has 18.
pub const MAX_PLAUSIBLE_LEVELS: f32 = 200.0;
// ── where a number came from ────────────────────────────────────────────────────
/// **Where a building's height came from** — the column that lets a consumer and an
/// operator tell a MEASURED building from an ASSUMED one.
///
/// The wire form is a `u8` (see [`Self::code`]) because it lands in a parquet column
/// that is read per row, and because a `Utf8` here would be five distinct strings
/// dictionary-encoded to no purpose.
///
/// # Why [`Self::Rejected`] exists, and why it is not folded into [`Self::Absent`]
///
/// "Nobody tagged this building" and "somebody tagged it and the parser threw the value
/// away" are different facts with different owners. Folded together, the 566 Swedish
/// `height=20 m` rows and the 2 `height=Österbybruks hembygdsgård` rows would be
/// indistinguishable from the 3.67 M rows with no height tag at all — and a parser
/// regression that started rejecting every `N m` form would move a counter nobody
/// watches by 0.015 % and be invisible. Split, the same regression moves
/// `Rejected` from 8 rows to 566 and shows up in the manifest histogram
/// `BuildingStats::height_sources` writes.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[repr(u8)]
pub enum HeightSource {
/// No `height`, no `building:levels`. The building has no height in OSM and this
/// crate did not invent one: `height_m` is NULL, not a default.
Absent,
/// `height` parsed as metres (bare, `12 m`, `12m`, `1 meter`, `102,5`).
TaggedMetres,
/// `height` parsed as feet / feet + inches (`25'`, `39'6"`).
TaggedFeet,
/// No usable `height`; derived from `building:levels` (+ `roof:levels`) ×
/// [`METRES_PER_LEVEL`]. **An assumption, ±20 % median** — see the module docs.
DerivedFromLevels,
/// A `height` or `building:levels` tag WAS present and could not be used: a
/// semicolon list, free text, a non-positive or implausible number. Counted, never
/// silently merged into [`Self::Absent`].
Rejected,
}
impl HeightSource {
/// Every variant, for exhaustive tests and histograms. A `match` in [`Self::code`]
/// plus this array is what makes a forgotten variant a compile error in one place
/// and a test failure in the other.
pub const ALL: [HeightSource; 5] = [
HeightSource::Absent,
HeightSource::TaggedMetres,
HeightSource::TaggedFeet,
HeightSource::DerivedFromLevels,
HeightSource::Rejected,
];
/// The on-disk code. **Spelled as literals, not as `self as u8`**, so the wire
/// numbering is a thing the test file can pin against a hand-written table rather
/// than a thing that silently follows declaration order. Reorder the enum and
/// `height_source_codes_are_pinned_to_the_wire` goes red instead of every parquet
/// on disk quietly changing meaning.
pub fn code(self) -> u8 {
match self {
HeightSource::Absent => 0,
HeightSource::TaggedMetres => 1,
HeightSource::TaggedFeet => 2,
HeightSource::DerivedFromLevels => 3,
HeightSource::Rejected => 4,
}
}
/// The inverse of [`Self::code`] — what a READER does with the column.
pub fn from_code(c: u8) -> Option<Self> {
match c {
0 => Some(HeightSource::Absent),
1 => Some(HeightSource::TaggedMetres),
2 => Some(HeightSource::TaggedFeet),
3 => Some(HeightSource::DerivedFromLevels),
4 => Some(HeightSource::Rejected),
_ => None,
}
}
/// The operator-facing spelling, for the manifest histogram.
pub fn name(self) -> &'static str {
match self {
HeightSource::Absent => "absent",
HeightSource::TaggedMetres => "tagged_metres",
HeightSource::TaggedFeet => "tagged_feet",
HeightSource::DerivedFromLevels => "derived_from_levels",
HeightSource::Rejected => "rejected",
}
}
/// Did a human actually state this height? True for the two tagged forms only —
/// the question a consumer asks when it wants to shade surveyed buildings
/// differently from assumed ones.
pub fn is_surveyed(self) -> bool {
matches!(self, HeightSource::TaggedMetres | HeightSource::TaggedFeet)
}
}
/// The unit a length value was written in.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LengthUnit {
Metres,
Feet,
}
// ── the parser ──────────────────────────────────────────────────────────────────
/// **Parse one OSM length value into metres.** Returns the metres AND the unit it was
/// written in, because the caller needs the unit for [`HeightSource`] and the metres for
/// the column — deriving one from the other after the fact would be a second decision.
///
/// `None` means "this string is not a length": empty, a semicolon list, free text, or a
/// number outside `0 < v <= `[`MAX_PLAUSIBLE_HEIGHT_M`]. `None` is never a default —
/// the caller turns it into [`HeightSource::Rejected`] and it gets counted.
///
/// Accepted, in the order tried:
///
/// 1. **feet / inches** — `39'`, `39'6"`, `39' 6"`, `39 ft`, `39ft`, `39 feet`
/// 2. **metres** — `12`, `12.5`, `12 m`, `12m`, `12 metres`, `1 meter`, `102,5`
///
/// Feet are tried first because `'` and `"` are unambiguous; nothing in the metre
/// grammar contains them.
pub fn parse_length(raw: &str) -> Option<(f32, LengthUnit)> {
let s = raw.trim();
if s.is_empty() {
return None;
}
// A semicolon list is TWO statements about one building. Picking the first is the
// silent fallback this module refuses to make; `45 m;37 m` on a multipolygon means
// its parts differ, and the parts carry their own tags.
if s.contains(';') {
return None;
}
let (metres, unit) = parse_feet(s)
.map(|f| (f, LengthUnit::Feet))
.or_else(|| parse_metres(s).map(|m| (m, LengthUnit::Metres)))?;
if !metres.is_finite() || metres <= 0.0 || metres > MAX_PLAUSIBLE_HEIGHT_M {
return None;
}
Some((metres, unit))
}
/// `39'`, `39'6"`, `39' 6"`, `39ft`, `39 feet` → metres. `None` if there is no foot
/// marker at all (so a bare `39` falls through to the metre branch, which is correct:
/// OSM's default unit is metres).
fn parse_feet(s: &str) -> Option<f32> {
// The `'` / `"` form.
if let Some(q) = s.find('\'') {
let feet = parse_decimal(&s[..q])?;
let rest = s[q + 1..].trim();
let inches = if rest.is_empty() {
0.0
} else {
// `6"` — the inch marker is required. `39'6` is not a documented form and
// guessing it would be inventing data.
let r = rest.strip_suffix('"').or_else(|| rest.strip_suffix('\u{201d}'))?;
parse_decimal(r)?
};
if !(0.0..12.0).contains(&inches) {
return None;
}
return Some(feet * METRES_PER_FOOT + inches * METRES_PER_INCH);
}
// The spelled form.
for suffix in ["feet", "foot", "ft"] {
if let Some(head) = strip_suffix_ci(s, suffix) {
return Some(parse_decimal(head)? * METRES_PER_FOOT);
}
}
None
}
/// A bare number, or a number with a metre unit spelled any of the ways Sweden spells
/// it. The suffixes are tried longest-first so `metres` is not eaten as `m` + `etres`.
fn parse_metres(s: &str) -> Option<f32> {
for suffix in ["metres", "meters", "metre", "meter", "m"] {
if let Some(head) = strip_suffix_ci(s, suffix) {
// `strip_suffix_ci` on `"m"` would also strip the `m` off a bare word, so
// the head still has to parse as a number; `height=B` fails here.
return parse_decimal(head);
}
}
parse_decimal(s)
}
/// Case-insensitive suffix strip that also eats the space before the unit. Returns the
/// head, or `None` when the suffix is not there.
///
/// Compares BYTES before it cuts. `str::split_at` panics on a non-char-boundary index,
/// and `height=Österbybruks hembygdsgård` is a real Swedish tag value — a value ending
/// in a multi-byte character would have taken the converter down on a tag nobody can
/// control. Every suffix here is ASCII, so a matching tail proves the cut point is a
/// char boundary and the slice below cannot panic.
fn strip_suffix_ci<'a>(s: &'a str, suffix: &str) -> Option<&'a str> {
debug_assert!(suffix.is_ascii(), "the byte compare below assumes ASCII units");
let (b, sb) = (s.as_bytes(), suffix.as_bytes());
if b.len() < sb.len() {
return None;
}
let cut = b.len() - sb.len();
if !b[cut..].eq_ignore_ascii_case(sb) {
return None;
}
Some(s[..cut].trim_end())
}
/// A decimal number, with the comma rule from the module docs: `,` followed by one or
/// two digits at end of string is a decimal separator; `,` followed by three is a
/// thousands group and the value is rejected rather than guessed.
fn parse_decimal(s: &str) -> Option<f32> {
let s = s.trim();
if s.is_empty() {
return None;
}
let normalised: Cow<'_, str> = if let Some(c) = s.find(',') {
// Exactly one comma, or it is not a decimal separator in any locale.
if s[c + 1..].contains(',') {
return None;
}
let frac = &s[c + 1..];
if frac.is_empty() || !frac.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
if frac.len() >= 3 {
// `1,500` — 1500 in en_US, 1.5 in sv_SE. Unresolvable, so refused.
return None;
}
Cow::Owned(s.replacen(',', ".", 1))
} else {
Cow::Borrowed(s)
};
// `str::parse::<f32>` accepts `inf`, `NaN`, `1e9` and a leading `+`. Only the last
// of those is wanted here, so the shape is checked before the parse.
let b = normalised.as_bytes();
let mut i = 0usize;
if b[i] == b'+' || b[i] == b'-' {
i += 1;
}
let mut digits = 0usize;
let mut dots = 0usize;
while i < b.len() {
match b[i] {
b'0'..=b'9' => digits += 1,
b'.' => dots += 1,
_ => return None,
}
i += 1;
}
if digits == 0 || dots > 1 {
return None;
}
normalised.parse::<f32>().ok().filter(|v| v.is_finite())
}
/// A level COUNT: a plain non-negative decimal, no units. `building:levels=3.5` is a
/// real and common tagging (294 Swedish rows) so this is an `f32`, not an integer —
/// rounding to a `u8` happens once, at the column, and never inside the arithmetic.
pub fn parse_levels(raw: &str) -> Option<f32> {
let s = raw.trim();
if s.is_empty() || s.contains(';') {
return None;
}
let v = parse_decimal(s)?;
if !(0.0..=MAX_PLAUSIBLE_LEVELS).contains(&v) {
return None;
}
Some(v)
}
// ── the decision ────────────────────────────────────────────────────────────────
/// Everything this module decides about one building, and the stamp saying how.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BuildingHeight {
/// Height of the top above ground, in metres. `None` when OSM does not say and this
/// module refuses to invent one — read [`Self::source`] to learn which kind of
/// silence it is.
pub height_m: Option<f32>,
/// Height of the BOTTOM above ground, for a `building:part` that starts partway up.
/// Without it such a part floats or sinks. `None` means "on the ground", which for
/// the 99.98 % of Swedish buildings with no `min_height` is also the truth.
pub min_height_m: Option<f32>,
/// `building:levels`, rounded to a whole floor for display. **Reported, not the
/// derivation**: the metres in [`Self::height_m`] come from the unrounded value, so
/// `building:levels=3.5` gives `levels = 4` and `height_m = 11.2`, and the two are
/// consistent with the tag rather than with each other.
pub levels: Option<u8>,
/// Where [`Self::height_m`] came from.
pub source: HeightSource,
}
impl BuildingHeight {
/// The nothing-known answer.
pub const ABSENT: BuildingHeight = BuildingHeight {
height_m: None,
min_height_m: None,
levels: None,
source: HeightSource::Absent,
};
}
/// **The height decision for one feature, from its `tags` JSON.** The ONE caller-facing
/// entry point of this module.
///
/// Reads the tags with [`crate::network::tag_value`] — the flat-object scanner — and not
/// with `serde_json`, for the reason in the module docs. Seven scans of a ~200-byte
/// object per building is ~6 GB of byte-walking over a Swedish cut and costs nothing
/// measurable at build time; a `serde_json::Value` per building would allocate a map and
/// a String per pair for 3.8 M rows.
///
/// Precedence, and it is deliberate: a TAGGED height always beats a derived one, even
/// when both are present. `height` is what a human stated about this building;
/// `building:levels × 3.2` is what this module assumed about it.
pub fn of_tags(tags: &str) -> BuildingHeight {
if tags.is_empty() || tags == "{}" {
return BuildingHeight::ABSENT;
}
let raw_height = tag_value(tags, "height");
let raw_levels = tag_value(tags, "building:levels");
// The reported level count, independent of whether it fed the height: a building
// with `height=12` and `building:levels=4` reports both, and a UI that wants to say
// "4 floors" does not have to re-derive it.
let levels_f = raw_levels.as_deref().and_then(parse_levels);
let levels = levels_f.map(|v| v.round().clamp(0.0, 255.0) as u8);
// 1. a tagged height.
if let Some(raw) = raw_height.as_deref() {
if let Some((m, unit)) = parse_length(raw) {
return BuildingHeight {
height_m: Some(m),
min_height_m: min_height_of(tags, Some(m)),
levels,
source: match unit {
LengthUnit::Metres => HeightSource::TaggedMetres,
LengthUnit::Feet => HeightSource::TaggedFeet,
},
};
}
}
// 2. derived from levels. `roof:levels` is added because `height` includes the roof
// and `building:levels` does not (module docs; worth 0.022 of mean relative
// error).
if let Some(l) = levels_f {
let roof = tag_value(tags, "roof:levels")
.as_deref()
.and_then(parse_levels)
.unwrap_or(0.0);
let m = (l + roof) * METRES_PER_LEVEL;
if m > 0.0 && m <= MAX_PLAUSIBLE_HEIGHT_M {
return BuildingHeight {
height_m: Some(m),
min_height_m: min_height_of(tags, Some(m)),
levels,
source: HeightSource::DerivedFromLevels,
};
}
// `building:levels=0` — 196 Swedish rows. A zero-height prism is not a
// building, and the tag IS present, so this is Rejected and not Absent.
return BuildingHeight {
height_m: None,
min_height_m: min_height_of(tags, None),
levels,
source: HeightSource::Rejected,
};
}
// 3. nothing usable. Which silence was it?
let source = if raw_height.is_some() || raw_levels.is_some() {
HeightSource::Rejected
} else {
HeightSource::Absent
};
BuildingHeight {
height_m: None,
min_height_m: min_height_of(tags, None),
levels,
source,
}
}
/// The bottom of the prism: `min_height` in metres, else `building:min_level` ×
/// [`METRES_PER_LEVEL`].
///
/// `top` is the height already decided, and it is a CONSTRAINT: a `min_height` at or
/// above the top describes an inverted prism, which is a tagging error rather than a
/// building, and is dropped. The drop is silent in the sense that no separate counter
/// exists for it — 355 Swedish rows carry `min_height` at all, so a counter would be
/// noise; the row still carries its full `tags` and an operator can go and look.
fn min_height_of(tags: &str, top: Option<f32>) -> Option<f32> {
let m = match tag_value(tags, "min_height").as_deref().and_then(|s| {
parse_length(s).map(|(v, _)| v)
}) {
Some(v) => v,
None => {
tag_value(tags, "building:min_level")
.as_deref()
.and_then(parse_levels)
.map(|l| l * METRES_PER_LEVEL)?
}
};
if !m.is_finite() || m < 0.0 {
return None;
}
if m == 0.0 {
// `min_height=0` says "on the ground", which is what NULL already says. Two
// spellings of one fact is the ambiguity `min_zoom_field`'s doc argues against.
return None;
}
match top {
Some(t) if m >= t => None,
_ => Some(m),
}
}
// ── tests ───────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// ★ **RED-when-broken: the whole tag zoo, one row per form.**
///
/// Table-driven over every documented and every MEASURED form (see the module docs
/// for where each row's example came from), with the expected metres written by
/// hand beside it. Break the feet branch — return `None` from [`parse_feet`], or
/// swap [`METRES_PER_FOOT`] for 1.0 — and the failure names the input string, not
/// "assertion failed".
///
/// A parser that returns `None` for everything is the fastest parser there is, so
/// the accepted and rejected arms are counted separately and both are pinned: a
/// table that had drifted to all-`None` would fail the accepted count before it
/// failed any single row.
#[test]
fn parse_length_survives_the_whole_osm_height_zoo() {
// (input, expected metres, expected unit)
let accepted: &[(&str, f32, LengthUnit)] = &[
// plain metres — 9 019 Swedish rows
("12", 12.0, LengthUnit::Metres),
("12.5", 12.5, LengthUnit::Metres),
("0.7", 0.7, LengthUnit::Metres),
("118.7", 118.7, LengthUnit::Metres),
// metres with a unit — 556 Swedish rows
("12 m", 12.0, LengthUnit::Metres),
("12m", 12.0, LengthUnit::Metres),
("20 M", 20.0, LengthUnit::Metres),
("14.5 m", 14.5, LengthUnit::Metres),
("1 meter", 1.0, LengthUnit::Metres),
("3 metres", 3.0, LengthUnit::Metres),
("3 meters", 3.0, LengthUnit::Metres),
("3 metre", 3.0, LengthUnit::Metres),
(" 8.0 m ", 8.0, LengthUnit::Metres),
// comma decimal — 3 Swedish rows
("102,5", 102.5, LengthUnit::Metres),
("8,5", 8.5, LengthUnit::Metres),
("21,5", 21.5, LengthUnit::Metres),
// FEET. One Swedish row (`25'`); the rest is what the planet cut needs.
("25'", 25.0 * 0.3048, LengthUnit::Feet),
("39'6\"", 39.0 * 0.3048 + 6.0 * 0.0254, LengthUnit::Feet),
("39' 6\"", 39.0 * 0.3048 + 6.0 * 0.0254, LengthUnit::Feet),
("6'11\"", 6.0 * 0.3048 + 11.0 * 0.0254, LengthUnit::Feet),
("39 ft", 39.0 * 0.3048, LengthUnit::Feet),
("39ft", 39.0 * 0.3048, LengthUnit::Feet),
("39 feet", 39.0 * 0.3048, LengthUnit::Feet),
("100 FT", 100.0 * 0.3048, LengthUnit::Feet),
];
for (input, want, unit) in accepted {
let got = parse_length(input).unwrap_or_else(|| {
panic!("`height={input}` must parse — it is a form that occurs in OSM")
});
assert!(
(got.0 - want).abs() < 1e-3,
"`height={input}` parsed to {} m, expected {want} m",
got.0
);
assert_eq!(
got.1, *unit,
"`height={input}` was read as {:?}, expected {unit:?} — the unit is what \
stamps HeightSource, so getting it wrong mislabels a surveyed building",
got.1
);
}
let rejected: &[&str] = &[
"",
" ",
// free text — 2 Swedish rows
"B",
"Österbybruks hembygdsgård",
// a value ENDING in a multi-byte character: `split_at` on a byte index
// would panic here, and a converter that panics on a tag value is a
// converter a single mapper can stop.
"5 må",
"höjdä",
"ö",
// semicolon lists — 3 Swedish rows
"1;12",
"45 m;37 m",
"32;81",
// non-positive and implausible
"0",
"-4",
"0 m",
"12000",
// the ambiguous comma
"1,500",
"1,2,3",
"12,",
"12,x",
// shapes `f32::from_str` would happily take and OSM never means
"1e9",
"inf",
"NaN",
"12.5.5",
// a foot form with no number, and inches out of range
"'6\"",
"39'13\"",
"39'6", // the inch marker is not optional
];
for input in rejected {
assert_eq!(
parse_length(input),
None,
"`height={input}` must be REJECTED — accepting it puts a wrong number in \
a column whose whole point is that its numbers are trustworthy"
);
}
// The all-`None` guard: this table has to keep proving it accepts things.
assert!(
accepted.len() >= 20 && rejected.len() >= 15,
"the zoo table shrank to {} accepted / {} rejected — a table this small \
cannot hold a parser to anything",
accepted.len(),
rejected.len()
);
}
/// ★ **RED-when-broken: feet, on its own, against metres.**
///
/// The zoo test above would still pass if [`parse_feet`] were deleted AND the
/// expectations were regenerated from the code. This one cannot: `25'` and `25` are
/// the same digits and must give numbers that differ by a factor of 3.28, and the
/// constant is written out here rather than referenced.
#[test]
fn feet_are_not_metres() {
let (feet, u1) = parse_length("25'").expect("25' is feet");
let (metres, u2) = parse_length("25").expect("25 is metres");
assert_eq!(u1, LengthUnit::Feet);
assert_eq!(u2, LengthUnit::Metres);
assert!(
(feet - 7.62).abs() < 1e-3,
"25 feet is 7.62 m, got {feet} — the foot conversion is wrong"
);
assert!(
(metres - 25.0).abs() < 1e-6,
"a bare `25` is 25 metres (OSM's default unit), got {metres}"
);
let ratio = metres / feet;
assert!(
(ratio - 3.2808).abs() < 1e-2,
"`25` and `25'` came out {ratio:.4}x apart; feet and metres are 3.2808x \
apart, so the feet branch is not converting"
);
// inches are inches: 39'6" must sit exactly half a foot above 39'.
let (a, _) = parse_length("39'").unwrap();
let (b, _) = parse_length("39'6\"").unwrap();
assert!(
(b - a - 0.1524).abs() < 1e-3,
"39'6\" - 39' should be six inches (0.1524 m), got {:.4} m",
b - a
);
}
/// ★ **RED-when-broken: the wire codes are pinned to hand-written literals.**
///
/// This is the guard the brief warns about — `x as u8 == x as u8` can never fail,
/// and neither can `code() == code()`. The table below is written by hand and is
/// the SPECIFICATION of the column: reorder the enum, insert a variant in the
/// middle, or renumber `code()`, and this goes red rather than every
/// `buildings.parquet` on disk silently changing meaning.
#[test]
fn height_source_codes_are_pinned_to_the_wire() {
let wire: [(HeightSource, u8, &str); 5] = [
(HeightSource::Absent, 0, "absent"),
(HeightSource::TaggedMetres, 1, "tagged_metres"),
(HeightSource::TaggedFeet, 2, "tagged_feet"),
(HeightSource::DerivedFromLevels, 3, "derived_from_levels"),
(HeightSource::Rejected, 4, "rejected"),
];
assert_eq!(
wire.len(),
HeightSource::ALL.len(),
"a variant was added to HeightSource without a wire code — the parquet \
column would gain a value no reader has a name for"
);
for (v, code, name) in wire {
assert_eq!(v.code(), code, "{name}: wire code moved");
assert_eq!(v.name(), name, "{v:?}: operator-facing name moved");
assert_eq!(
HeightSource::from_code(code),
Some(v),
"code {code} does not read back as {v:?} — from_code is not the inverse \
of code()"
);
}
assert_eq!(HeightSource::from_code(5), None, "5 is not a source");
assert_eq!(HeightSource::from_code(255), None, "255 is not a source");
// and the codes are distinct, which `as u8` would give for free but a
// hand-written match would not.
let mut seen: Vec<u8> = HeightSource::ALL.iter().map(|s| s.code()).collect();
seen.sort_unstable();
seen.dedup();
assert_eq!(seen.len(), 5, "two variants share a wire code");
// only the two TAGGED forms are surveyed — this is what a consumer filters on.
assert_eq!(
HeightSource::ALL
.iter()
.filter(|s| s.is_surveyed())
.count(),
2,
"is_surveyed must name exactly the two tagged forms; a derived height is an \
assumption and must not pass for a measurement"
);
assert!(!HeightSource::DerivedFromLevels.is_surveyed());
}
/// ★ **RED-when-broken: the decision, end to end, on real tag objects.**
///
/// Each row is a `tags` JSON of the shape `crate::tags::serialize_tags` writes, with
/// the expected height, level count and SOURCE beside it. The source column is what
/// makes this test capable of catching a silent fallback: a change that made an
/// untagged building default to 10 m would keep every height assertion happy on the
/// tagged rows and fail on `Absent` immediately.
#[test]
fn of_tags_decides_and_says_how() {
// (tags, height_m, levels, source)
let cases: &[(&str, Option<f32>, Option<u8>, HeightSource)] = &[
// nothing — 95.7 % of Swedish buildings
(r#"{"building":"yes"}"#, None, None, HeightSource::Absent),
(r#"{}"#, None, None, HeightSource::Absent),
// tagged metres
(
r#"{"building":"yes","height":"12"}"#,
Some(12.0),
None,
HeightSource::TaggedMetres,
),
(
r#"{"building":"yes","height":"20 m"}"#,
Some(20.0),
None,
HeightSource::TaggedMetres,
),
// tagged feet
(
r#"{"building":"yes","height":"25'"}"#,
Some(7.62),
None,
HeightSource::TaggedFeet,
),
// derived from levels
(
r#"{"building":"apartments","building:levels":"4"}"#,
Some(12.8),
Some(4),
HeightSource::DerivedFromLevels,
),
// roof levels are counted on top
(
r#"{"building":"house","building:levels":"2","roof:levels":"1"}"#,
Some(9.6),
Some(2),
HeightSource::DerivedFromLevels,
),
// fractional levels: the metres are exact, the reported count is rounded
(
r#"{"building":"house","building:levels":"3.5"}"#,
Some(11.2),
Some(4),
HeightSource::DerivedFromLevels,
),
// a tagged height WINS over levels, and levels are still reported
(
r#"{"building":"yes","building:levels":"4","height":"9"}"#,
Some(9.0),
Some(4),
HeightSource::TaggedMetres,
),
// present but unusable — NOT the same as absent
(
r#"{"building":"yes","height":"1;12"}"#,
None,
None,
HeightSource::Rejected,
),
(
r#"{"building":"yes","height":"B"}"#,
None,
None,
HeightSource::Rejected,
),
(
r#"{"building":"yes","building:levels":"0"}"#,
None,
Some(0),
HeightSource::Rejected,
),
(
r#"{"building":"yes","building:levels":"3-4"}"#,
None,
None,
HeightSource::Rejected,
),
// a height tag that fails but levels that work: levels carry it
(
r#"{"building":"yes","building:levels":"5","height":"gabled"}"#,
Some(16.0),
Some(5),
HeightSource::DerivedFromLevels,
),
];
for (tags, want_h, want_l, want_s) in cases {
let got = of_tags(tags);
assert_eq!(
got.source, *want_s,
"{tags}: source was {:?}, expected {want_s:?}",
got.source
);
match (got.height_m, want_h) {
(None, None) => {}
(Some(a), Some(b)) => assert!(
(a - b).abs() < 1e-3,
"{tags}: height {a} m, expected {b} m"
),
(a, b) => panic!("{tags}: height {a:?}, expected {b:?}"),
}
assert_eq!(got.levels, *want_l, "{tags}: levels");
}
// The distribution of the table is itself the guard against a degenerate
// implementation: `of_tags` returning ABSENT for everything would satisfy three
// rows here and this count.
let sources: Vec<HeightSource> = cases.iter().map(|c| of_tags(c.0).source).collect();
for s in HeightSource::ALL {
assert!(
sources.contains(&s),
"no case in this table produces {s:?}; a variant nothing exercises is a \
variant nothing tests"
);
}
}
/// ★ **RED-when-broken: `min_height` puts the bottom of a part off the ground.**
///
/// Without it a `building:part` that starts on the fourth floor is drawn from
/// ground level and swallows the four floors below it. The inverted case
/// (`min_height >= height`) must be refused, not clamped: clamping produces a
/// zero-height prism that looks like a rendering bug rather than a data one.
#[test]
fn min_height_lifts_a_part_and_refuses_to_invert() {
let p = of_tags(r#"{"building:part":"yes","height":"30","min_height":"12"}"#);
assert_eq!(p.height_m, Some(30.0));
assert_eq!(p.min_height_m, Some(12.0), "min_height must survive in metres");
// `building:min_level` is the level spelling of the same fact.
let q = of_tags(r#"{"building:part":"yes","height":"30","building:min_level":"4"}"#);
assert!(
(q.min_height_m.unwrap() - 4.0 * METRES_PER_LEVEL).abs() < 1e-3,
"building:min_level=4 must lift the part by 4 x METRES_PER_LEVEL, got {:?}",
q.min_height_m
);
// `min_height` beats `building:min_level` — metres beat an assumption, the same
// precedence `of_tags` uses for the top.
let r = of_tags(
r#"{"building:part":"yes","height":"30","min_height":"12","building:min_level":"9"}"#,
);
assert_eq!(r.min_height_m, Some(12.0));
// Inverted and degenerate cases are refused.
for tags in [
r#"{"building:part":"yes","height":"10","min_height":"10"}"#,
r#"{"building:part":"yes","height":"10","min_height":"14"}"#,
r#"{"building:part":"yes","height":"10","min_height":"0"}"#,
r#"{"building:part":"yes","height":"10","min_height":"junk"}"#,
] {
assert_eq!(
of_tags(tags).min_height_m,
None,
"{tags}: a min_height at or above the top is an inverted prism and must \
be dropped, not clamped"
);
}
// A building with no min_height at all sits on the ground, and that is NULL and
// not 0.0 — one spelling per fact.
assert_eq!(
of_tags(r#"{"building":"yes","height":"10"}"#).min_height_m,
None
);
}
/// ★ **RED-when-broken: the level constant is used, and it is the named one.**
///
/// The trap here is the identity value: with `METRES_PER_LEVEL = 1.0` a test that
/// asserted `4 levels -> 4.0` would pass and mean nothing. So the assertion is that
/// the derived height is a MULTIPLE of the constant AND that the constant is in the
/// band the measurement supports — change it to 1.0 or 10.0 and this goes red with
/// the number in the message.
#[test]
fn metres_per_level_is_the_measured_constant_and_is_actually_applied() {
assert!(
(2.5..=4.0).contains(&METRES_PER_LEVEL),
"METRES_PER_LEVEL is {METRES_PER_LEVEL} m. The Sweden cut's 3 205 buildings \
carrying both height and levels put the honest band at 2.8-3.6 m/level \
(median 3.60, mean-optimal 3.0, best-for-tall 3.2). A value outside 2.5-4.0 \
is not a floor height."
);
for n in 1u8..=12 {
let tags = format!(r#"{{"building":"yes","building:levels":"{n}"}}"#);
let h = of_tags(&tags).height_m.unwrap();
assert!(
(h - f32::from(n) * METRES_PER_LEVEL).abs() < 1e-3,
"{n} levels gave {h} m, not {n} x {METRES_PER_LEVEL}"
);
}
// and a 10-storey block is taller than a 2-storey house by the right amount —
// the check an identity constant cannot pass.
let tall = of_tags(r#"{"building":"yes","building:levels":"10"}"#)
.height_m
.unwrap();
let short = of_tags(r#"{"building":"yes","building:levels":"2"}"#)
.height_m
.unwrap();
assert!(
(tall - short - 8.0 * METRES_PER_LEVEL).abs() < 1e-3,
"the 8-storey difference came out {} m",
tall - short
);
assert!(
tall > 25.0 && tall < 40.0,
"a 10-storey building came out {tall} m, which is not a 10-storey building"
);
}
/// A `building:levels` value beyond belief must not become a kilometre of prism.
#[test]
fn implausible_values_are_refused_rather_than_scaled() {
assert_eq!(parse_levels("9999"), None);
assert_eq!(
of_tags(r#"{"building":"yes","building:levels":"9999"}"#).source,
HeightSource::Rejected
);
assert_eq!(
of_tags(r#"{"building":"yes","height":"99999"}"#).source,
HeightSource::Rejected
);
// 163 levels is the Burj Khalifa and must still work.
let burj = of_tags(r#"{"building":"yes","building:levels":"163"}"#);
assert_eq!(burj.source, HeightSource::DerivedFromLevels);
assert_eq!(burj.levels, Some(163));
}
}