facett-map/src/height.rs
//! **Building height — turning OSM's height tagging into metres, for the 3D extruder.**
//!
//! # What was here before, and what it drew
//!
//! Until this module existed, `parse_building_height_m` took the **leading run of digits
//! and dots** off `height` (else off `building:levels`) and believed whatever fell out.
//! It is a live, shipped path: `korp::map_paint::classify_ways` → [`Way::height_m`] →
//! `facett_map3d::extrude::building_design_height`. MEASURED against the Swedish tag zoo
//! (see the provenance section), that parser rendered:
//!
//! | tag as written | it drew | it means |
//! |---|---|---|
//! | `height=99999` | a **99 999 m** spike | junk, and there was no ceiling |
//! | `height=1;12` | a **1 m** building | a semicolon list of two statements |
//! | `height=25'` | **25 m** | 25 **feet** = 7.62 m |
//! | `height=102,5` | **102 m** | 102.5 m, Swedish decimal comma |
//! | `building:levels=3-4` | 3 levels | a range |
//!
//! Every one of them silent. This module refuses instead of guessing, and a refusal
//! returns `None`, which lands the building on the extruder's untagged default box —
//! the same box 95.68 % of Swedish buildings already get. A wrong number is worse than
//! no number **here specifically**, because a renderer's fallback is good and its spike
//! is a hole through the sky.
//!
//! # This is the SECOND text of ONE decision, and that is stated rather than hidden
//!
//! The first text is **`osm_katana::shared::height`** (`znippy-zoomies`, commit
//! `04248be`), which is the one writer of `buildings.parquet`'s `height_m` /
//! `height_source` columns. Every rule below — the accepted grammar, the comma rule, the
//! plausibility ceilings, `roof:levels`, [`METRES_PER_LEVEL`] — is **its** decision,
//! restated here.
//!
//! **facett cannot call it.** facett is a UI component library; a dependency edge from a
//! widget crate to a planet-scale OSM cutter is the wrong edge, and it would drag arrow,
//! parquet and the whole warehouse stack into every consumer of a map widget. So the
//! duplication is real and LAW 5 is not satisfied by construction here. What is done
//! instead is to make the duplication **detectable**:
//!
//! * `facett-map/tests/data/building_height_corpus.jsonl` is a corpus of tag objects
//! with the expected metres beside each, and **its expected column was produced by
//! running `shared::height::of_tags`**, not transcribed by hand.
//! * `facett-map/tests/building_height_corpus.rs` replays it through this module and
//! fails **naming the tag value** that disagrees.
//! * That corpus's data lines are digested (FNV-1a 64) against a pinned constant in the
//! same test, so an edit to one arm's copy cannot pass quietly.
//! * The regeneration command, and the osm-katana-side arm that has to be kept in step,
//! are named in the corpus header and in `.nornir/building-height.md`.
//!
//! This is the residual lane MINZOOM declared for the tag→rung map: two texts, one
//! decision, a corpus in the middle. It closes for real the day facett stops deriving
//! heights and reads the `height_m` column off `buildings.parquet`.
//!
//! # Where the rules come from — MEASURED, not from the wiki
//!
//! `shared::height` wrote its grammar against the value distribution of korp's live
//! Sweden cut (2026-08-31), 3 841 511 rows carrying `building` / `building:part`; the
//! `height` tag has **86** distinct non-plain-numeric values over 566 rows there. The
//! verdict over all 3 841 483 building rows:
//!
//! | outcome | rows | share |
//! |---|---|---|
//! | no height tag at all | 3 675 499 | **95.6792 %** |
//! | derived from `building:levels` | 156 167 | 4.0653 % |
//! | tagged in metres | 9 551 | 0.2486 % |
//! | **rejected** (junk that used to render) | 265 | 0.0069 % |
//! | tagged in feet | **1** | 0.000026 % |
//!
//! Two things follow, and they shaped this module more than the grammar did:
//!
//! 1. **A height tag does not fill Sweden with skyscrapers — it fills Stockholm,
//! Göteborg and Malmö.** 95.68 % of the view is the extruder's default box, and this
//! module deliberately does not touch it (see [`DEFAULT_BUILDING_HEIGHT_M`]).
//! 2. The **feet** branch fires exactly once in 3.8 M Swedish buildings. It is here
//! because `25'` is common in the US and UK and facett draws whatever korp cuts, and
//! it is held up by unit tests rather than by field data. Said, not implied.
//!
//! # `METRES_PER_LEVEL` is 3.2 — and this crate used to say 3.0
//!
//! This was a **live discrepancy**: the same building extruded to a different height
//! depending on whether it arrived as a tile way (facett, 3.0) or as a `buildings.parquet`
//! row (osm-katana, 3.2). It is settled at **3.2**, because 3.2 is the measured number
//! and 3.0 was the inherited one.
//!
//! MEASURED by `shared::height` on that same cut: **3 205** Swedish buildings carry BOTH
//! a parseable `height` and a parseable `building:levels`, which makes the constant an
//! empirical question with an answer. Sweeping it and scoring relative error against the
//! tagged height (levels counted as `building:levels + roof:levels`):
//!
//! | 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 |
//!
//! No constant wins on all three. 3.4 minimises the median, 3.0 the mean, **3.2 the
//! error on buildings of three levels or more — which is where an extrusion is actually
//! visible on a map**, and it is within 0.005 of the best on the mean. For a 3D view
//! that is the column that matters, so 3.2 it is.
//!
//! ## The 6 m default box did NOT move, and is now its own constant
//!
//! The old 6 m untagged default was spelled `DEFAULT_BUILDING_LEVELS (2.0) ×
//! METRES_PER_LEVEL (3.0)`. Correcting the converter to 3.2 would have dragged the
//! default to 6.4 m as a **side effect**, moving 95.68 % of every town in the view to fix
//! 0.0069 % of it. They are different questions — one converts a MAPPED level count, the
//! other is a LOOK decision about a building nobody mapped — so they are now different
//! constants, and [`DEFAULT_BUILDING_HEIGHT_M`] is pinned at **exactly 6.0** with a test
//! that says so.
//!
//! ## `roof:levels` is counted; `roof:shape` / `roof:height` 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 measurable, not
//! a preference: at c = 3.2 including `roof:levels` moved the mean relative error from
//! 0.273 to **0.251** over those 3 205 buildings, and 67 427 Swedish buildings carry the
//! tag. `roof:shape` is already parsed separately by [`crate::parse_roof_shape`] because
//! it describes a MESH; `roof:height` has no consumer in the extruder and is therefore
//! not read here.
/// **Metres per building level** — the converter for a MAPPED `building:levels`.
///
/// 3.2, measured; see the module docs for the 3 205-building sweep that chose it over
/// the 3.0 this crate used to carry. Kept numerically equal to
/// `osm_katana::shared::height::METRES_PER_LEVEL` on purpose: the two are one decision
/// and the corpus test is what keeps them equal.
///
/// This is **not** the untagged default — that is [`DEFAULT_BUILDING_HEIGHT_M`], and the
/// whole point of splitting them is that this constant can be corrected without moving
/// the 95.68 % of buildings that have no level count either.
pub const METRES_PER_LEVEL: f32 = 3.2;
/// **The untagged building's height in metres**, before the extruder's ±25 % jitter and
/// its footprint cap. Exactly 6.0 — the number facett has always drawn, pinned as a
/// literal rather than derived from [`METRES_PER_LEVEL`] so a correction to the
/// converter cannot move the look of 95.68 % of the map as a side effect.
///
/// See `facett_map3d::extrude::MAX_DEFAULT_WALL_OVER_FOOTPRINT` for the cap that keeps
/// this from putting a 6 m wall on a 4 m shed.
pub const DEFAULT_BUILDING_HEIGHT_M: f32 = 6.0;
/// One international foot, exactly, since 1959. `height=39'` is feet.
pub const METRES_PER_FOOT: f32 = 0.3048;
/// Metres per inch — `39'6"` is thirty-nine feet and six INCHES.
pub const METRES_PER_INCH: f32 = METRES_PER_FOOT / 12.0;
/// **The tallest building this parser will believe.** The Burj Khalifa is 828 m; an OSM
/// `height` over a kilometre is a units error or a typo, and the old parser had no
/// ceiling at all — which is how `height=99999` became a 99 999 m spike through the sky.
/// Over the ceiling the value is refused and the building takes the default box.
pub const MAX_PLAUSIBLE_HEIGHT_M: f32 = 1_000.0;
/// The most levels this parser will believe. 163 is the Burj Khalifa; the tallest thing
/// in the Sweden cut has 18.
pub const MAX_PLAUSIBLE_LEVELS: f32 = 200.0;
/// The unit a length value was written in — needed because the caller must know whether
/// `25` and `25'` are the same building.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LengthUnit {
Metres,
Feet,
}
/// **Parse one OSM length value into metres**, with the unit it was written in.
///
/// `None` means "this string is not a length this parser will act on": empty, a
/// semicolon list, free text, or a number outside `0 < v <= `[`MAX_PLAUSIBLE_HEIGHT_M`].
/// `None` is never a guess — the caller falls back to the untagged default box.
///
/// 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. Taking the first is what
// turned `height=1;12` into a 1 m building; on a multipolygon `45 m;37 m` 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` when 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> {
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 {
// 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);
}
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 OSM spells it.
/// Suffixes are tried longest-first so `metres` is not eaten as `m` + a leftover.
fn parse_metres(s: &str) -> Option<f32> {
for suffix in ["metres", "meters", "metre", "meter", "m"] {
if let Some(head) = strip_suffix_ci(s, suffix) {
// Stripping `m` off a bare word leaves a head that still has to parse as a
// number; `height=B` fails here rather than becoming a building.
return parse_decimal(head);
}
}
parse_decimal(s)
}
/// Case-insensitive suffix strip that also eats the space before the unit.
///
/// 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 take the caller down on a tag any mapper can write. Every
/// suffix here is ASCII, so a matching tail proves the cut point is a char boundary.
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: a single `,` followed by **one or two** digits
/// at end of string is a decimal separator (`102,5` → 102.5, three real Swedish rows); a
/// `,` followed by three is a thousands group and the value is **refused**, because
/// `1,500` is 1500 in `en_US` and 1.5 in `sv_SE` and no rule resolves it.
///
/// `str::parse::<f32>` would also accept `inf`, `NaN` and `1e9`; OSM never means any of
/// them, so the shape is checked before the parse.
fn parse_decimal(s: &str) -> Option<f32> {
let s = s.trim();
if s.is_empty() {
return None;
}
let normalised: std::borrow::Cow<'_, str> = if let Some(c) = s.find(',') {
if s[c + 1..].contains(',') {
return None; // `1,2,3` is not a number in any locale
}
let frac = &s[c + 1..];
if frac.is_empty() || !frac.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
if frac.len() >= 3 {
return None; // `1,500` — unresolvable, so refused rather than guessed
}
std::borrow::Cow::Owned(s.replacen(',', ".", 1))
} else {
std::borrow::Cow::Borrowed(s)
};
let b = normalised.as_bytes();
let mut i = 0usize;
if b[i] == b'+' || b[i] == b'-' {
i += 1;
}
let (mut digits, mut dots) = (0usize, 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 real
/// and common tagging (294 Swedish rows), so this is an `f32`. Ranges (`3-4`), lists
/// (`2;1`), roof shapes filed under the wrong key (`gabled`) and building classifications
/// (`K1, 0`) are all refused — 24 of 159 589 Swedish rows are one of those.
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)
}
/// **Parse an OSM building height in metres from its tags.** `height` (an explicit
/// length, metres or feet) wins; otherwise `(building:levels + roof:levels) ×`
/// [`METRES_PER_LEVEL`].
///
/// `None` means **the extruder should draw its default box** — and it covers three
/// different silences that facett deliberately does not distinguish, because its one
/// consumer treats them identically:
///
/// * no `height` and no `building:levels` (95.68 % of Swedish buildings),
/// * a `height` that is junk (`99999`, `1;12`, `B`) — refused, not guessed,
/// * `building:levels=0` (196 Swedish rows) — a zero-height prism is not a building.
///
/// `osm_katana::shared::height` DOES distinguish them, as `HeightSource::Absent` vs
/// `Rejected`, because a parquet column has an operator reading its histogram. If facett
/// ever wants that stamp the corpus already carries the source column; today the extruder
/// would do the same thing with it, so it is not invented here.
///
/// Precedence is deliberate and matches the reference: a TAGGED height beats a derived
/// one even when both are present. `height` is what a human stated about this building;
/// `levels × 3.2` is what this parser assumed about it.
pub fn parse_building_height_m<K: AsRef<str>, V: AsRef<str>>(tags: &[(K, V)]) -> Option<f32> {
let get = |k: &str| crate::tag(tags, k);
// 1. an explicit `height`.
if let Some(raw) = get("height") && let Some((m, _unit)) = parse_length(raw) {
return Some(m);
}
// Fall through: a broken `height` does not veto a good `building:levels`
// (`height=gabled` + `building:levels=5` is a real pair).
// 2. derived from levels. `roof:levels` is added because `height` includes the roof
// and `building:levels` does not — worth 0.022 of mean relative error (module docs).
let levels = get("building:levels").and_then(parse_levels)?;
let roof = get("roof:levels").and_then(parse_levels).unwrap_or(0.0);
let m = (levels + roof) * METRES_PER_LEVEL;
// The same ceiling the metre branch has: no path out of this function may return a
// number that puts a spike through the sky.
(m > 0.0 && m <= MAX_PLAUSIBLE_HEIGHT_M).then_some(m)
}
#[cfg(test)]
mod tests {
use super::*;
fn kv(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs.iter().map(|(a, b)| (a.to_string(), b.to_string())).collect()
}
/// ★ **RED-when-broken: the five inputs from the brief, each named in its own
/// failure message.**
///
/// These are the five forms that were measured rendering wrong. Revert this module
/// and every one of them fails, each saying which tag string it was.
#[test]
fn the_five_measured_defects_render_correctly() {
// (tag key, tag value, expected metres or None, what it means)
let cases: &[(&str, &str, Option<f32>, &str)] = &[
("height", "99999", None, "junk far above any building; must not be drawn"),
("height", "1;12", None, "a semicolon list is two statements about one building"),
("height", "25'", Some(7.62), "25 FEET is 7.62 m, not 25 m"),
("height", "102,5", Some(102.5), "a Swedish decimal comma is 102.5 m, not 102 m"),
("building:levels", "3-4", None, "a level RANGE is not a level count"),
];
// Collected, not asserted one at a time: a revert of this module breaks ALL
// FIVE, and a failure that names only the first is a failure that hides four.
let mut wrong: Vec<String> = Vec::new();
for (k, v, want, why) in cases {
let got = parse_building_height_m(&kv(&[("building", "yes"), (k, v)]));
let ok = match (got, want) {
(None, None) => true,
(Some(a), Some(b)) => (a - b).abs() < 1e-2,
_ => false,
};
if !ok {
wrong.push(format!(" `{k}={v}` parsed to {got:?} m, expected {want:?} m — {why}"));
}
}
assert!(
wrong.is_empty(),
"{} of the 5 measured OSM height defects still render wrong:\n{}",
wrong.len(),
wrong.join("\n")
);
}
/// ★ **RED-when-broken: NO tag value can produce a spike.**
///
/// The old parser had no ceiling, so `height=99999` was a 99 999 m tower. The bound
/// is asserted over the whole zoo, not just the one input, so a new accepting branch
/// cannot re-open the hole.
#[test]
fn no_tag_value_survives_as_a_spike() {
let spikes = [
("height", "99999"),
("height", "999999999"),
("height", "1e9"),
("height", "12000"),
("height", "1001"),
("height", "50000 m"),
("height", "99999'"),
("building:levels", "9999"),
("building:levels", "500"),
("building:levels", "99999"),
];
for (k, v) in spikes {
if let Some(m) = parse_building_height_m(&kv(&[("building", "yes"), (k, v)])) {
assert!(
m <= MAX_PLAUSIBLE_HEIGHT_M,
"`{k}={v}` produced {m} m, above the {MAX_PLAUSIBLE_HEIGHT_M} m \
ceiling — that is a spike through the sky, which is exactly the \
defect this bound exists to stop"
);
}
}
// and the two that matter are refused outright, so they take the default box.
assert_eq!(parse_building_height_m(&kv(&[("height", "99999")])), None);
assert_eq!(parse_building_height_m(&kv(&[("building:levels", "9999")])), None);
// The all-`None` guard: a parser that refused everything would pass the loop
// above trivially, so a real tall building must still get through.
let burj = parse_building_height_m(&kv(&[("height", "828")])).expect("828 m is a building");
assert!((burj - 828.0).abs() < 1e-3, "the Burj Khalifa came out {burj} m");
}
/// ★ **RED-when-broken: the 95.68 % path is EXACTLY the box it was.**
///
/// An untagged building must still yield `None`, and the constant the extruder then
/// uses must still be exactly 6.0 m. Written as a literal, not as
/// `DEFAULT_BUILDING_HEIGHT_M == DEFAULT_BUILDING_HEIGHT_M` — change the constant to
/// 6.4 and this names the number.
#[test]
fn the_untagged_default_box_did_not_move() {
assert_eq!(
parse_building_height_m(&kv(&[("building", "yes")])),
None,
"a building with no height tag must leave the height to the extruder default"
);
// 95.6792 % of Swedish buildings take this branch. It is a LOOK decision and it
// is not part of this fix.
assert!(
(DEFAULT_BUILDING_HEIGHT_M - 6.0).abs() < f32::EPSILON,
"DEFAULT_BUILDING_HEIGHT_M is {DEFAULT_BUILDING_HEIGHT_M} m and it must be \
exactly 6.0 — it is what 95.68 % of every town in the 3D view is drawn at, \
and correcting METRES_PER_LEVEL must not move it as a side effect"
);
// The DECOUPLING itself — that moving METRES_PER_LEVEL leaves this box where it
// is — is proved on the applied output, not here:
// `facett-map3d/tests/no_building_spikes.rs::the_untagged_default_box_did_not_move`
// pins the untagged rooftop to 5.6119 m as a literal. A constant-vs-constant
// check here could only ever spot the coincidence, not the coupling.
// Rejected junk lands on the SAME default, not on a leading-digit guess.
for v in ["99999", "1;12", "B", "Österbybruks hembygdsgård", "0"] {
assert_eq!(
parse_building_height_m(&kv(&[("building", "yes"), ("height", v)])),
None,
"`height={v}` must fall back to the default box, not to a guess"
);
}
}
/// ★ **RED-when-broken: METRES_PER_LEVEL is 3.2 and is actually applied.**
///
/// The identity trap: with the constant at 1.0 a test asserting `4 levels → 4 ×
/// METRES_PER_LEVEL` passes and means nothing. So the expected metres are written as
/// LITERALS here, and the constant itself is pinned to the measured value.
#[test]
fn metres_per_level_is_the_measured_3_2() {
assert!(
(METRES_PER_LEVEL - 3.2).abs() < 1e-6,
"METRES_PER_LEVEL is {METRES_PER_LEVEL}; the Sweden cut's 3 205 buildings \
carrying BOTH height and levels put it at 3.2 (best for buildings of 3+ \
levels, 0.097 median relative error). It was 3.0 here and 3.2 in \
osm_katana::shared::height, which made one building two heights."
);
// Literal expectations — these do not follow the constant.
for (levels, want_m) in [("1", 3.2f32), ("2", 6.4), ("4", 12.8), ("10", 32.0)] {
let got = parse_building_height_m(&kv(&[("building", "yes"), ("building:levels", levels)]))
.unwrap_or_else(|| panic!("building:levels={levels} must derive a height"));
assert!(
(got - want_m).abs() < 1e-3,
"building:levels={levels} gave {got} m, expected {want_m} m"
);
}
// roof:levels is counted on top (0.273 → 0.251 mean relative error).
let with_roof = parse_building_height_m(&kv(&[
("building", "house"),
("building:levels", "2"),
("roof:levels", "1"),
]));
assert_eq!(with_roof, Some(9.6), "building:levels=2 + roof:levels=1 is 3 levels");
}
/// ★ **RED-when-broken: feet are not metres.**
///
/// `25` and `25'` are the same digits and must come out 3.2808× apart. The constant
/// is written out here rather than referenced, so swapping `METRES_PER_FOOT` for 1.0
/// cannot regenerate the expectation.
#[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}");
assert!((metres - 25.0).abs() < 1e-6, "a bare `25` is 25 metres, 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"
);
let (a, _) = parse_length("39'").unwrap();
let (b, _) = parse_length("39'6\"").unwrap();
assert!(
(b - a - 0.1524).abs() < 1e-3,
"39'6\" - 39' must be six inches (0.1524 m), got {:.4} m",
b - a
);
}
/// ★ **RED-when-broken: the whole measured tag zoo, one row per form.**
///
/// The accepted and rejected arms are counted separately and both pinned, because a
/// parser that returns `None` for everything would otherwise sail through the
/// rejected half.
#[test]
fn parse_length_survives_the_osm_height_zoo() {
let accepted: &[(&str, f32, LengthUnit)] = &[
("12", 12.0, LengthUnit::Metres),
("12.5", 12.5, LengthUnit::Metres),
("0.7", 0.7, LengthUnit::Metres),
("118.7", 118.7, LengthUnit::Metres),
("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),
("102,5", 102.5, LengthUnit::Metres),
("8,5", 8.5, LengthUnit::Metres),
("21,5", 21.5, LengthUnit::Metres),
("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 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 the wrong unit");
}
let rejected: &[&str] = &[
"",
" ",
"B",
"Österbybruks hembygdsgård",
// values ENDING in a multi-byte char: a byte-index `split_at` would panic
// here, and a parser a single mapper can crash is not a parser.
"5 må",
"höjdä",
"ö",
"1;12",
"45 m;37 m",
"32;81",
"0",
"-4",
"0 m",
"12000",
"1,500",
"1,2,3",
"12,",
"12,x",
"1e9",
"inf",
"NaN",
"12.5.5",
"'6\"",
"39'13\"",
"39'6",
];
for input in rejected {
assert_eq!(
parse_length(input),
None,
"`height={input}` must be REFUSED — accepting it draws a wrong building"
);
}
assert!(
accepted.len() >= 20 && rejected.len() >= 15,
"the zoo table shrank to {} accepted / {} rejected — too small to hold a \
parser to anything",
accepted.len(),
rejected.len()
);
}
/// A tagged `height` beats a derived one, and a BROKEN `height` does not veto good
/// levels. Both are real Swedish pairs.
#[test]
fn precedence_height_then_levels() {
assert_eq!(
parse_building_height_m(&kv(&[("building:levels", "4"), ("height", "9")])),
Some(9.0),
"a stated height beats an assumed one"
);
assert_eq!(
parse_building_height_m(&kv(&[("building:levels", "5"), ("height", "gabled")])),
Some(16.0),
"a junk height must not veto a good level count"
);
assert_eq!(
parse_building_height_m(&kv(&[("building:levels", "0")])),
None,
"building:levels=0 (196 Swedish rows) is not a zero-height prism"
);
}
}