Building height from OSM tags — the parser, METRES_PER_LEVEL, and the corpus
Landed 2026-08-31, lane SPIKE, branch lane/building-height-parse. Code: facett-map/src/height.rs (new), facett-map3d/src/extrude.rs, facett-map/tests/building_height_corpus.rs + tests/data/building_height_corpus.jsonl, facett-map3d/tests/no_building_spikes.rs.
The defect: it was shipped, it rendered, and it was silent
facett_map::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 path — korp::map_paint::classify_ways → Way::height_m → extrude::building_design_height — so it is exactly what korp's 3D map view draws. Measured against the real Swedish tag zoo (lane HEIGHT, korp's live Sweden cut, 2026-08-31):
| tag as written | it drew | it means |
height=99999 |
a 99 999 m spike | junk data |
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 |
All five now parse correctly or are refused. A refusal returns None, which puts the building on the extruder's untagged default box — the same box 95.68 % of Swedish buildings already get. That choice is deliberate: in a renderer a wrong number is worse than no number, because the fallback looks like a building and the spike is a hole through the sky.
The context that shaped the fix: 95.68 % have no height at all
MEASURED by osm-katana buildings over korp's Sweden cut, 2026-08-31, 3 841 483 rows:
| outcome | rows | share |
| no height tag at all | 3 675 499 | 95.6792 % |
derived from building:levels |
156 167 | 4.0653 % |
| tagged metres | 9 551 | 0.2486 % |
| rejected (junk that used to render) | 265 | 0.0069 % |
| tagged feet | 1 | 0.000026 % |
A height column does not fill Sweden with skyscrapers; it fills Stockholm, Göteborg and Malmö. The 95.68 % path was therefore not touched — an untagged building still gets 6.0 m with the same ±25 % deterministic jitter and the same 0.9 × short-side footprint cap, pinned to the metre by a test (below). This change is about junk tags, not about the look.
The feet branch fires exactly once in 3.8 M Swedish buildings. It is in the parser because korp cuts more than Sweden, and it is held up by unit tests rather than by field data. Said, not implied.
METRES_PER_LEVEL: the discrepancy is settled at 3.2, and the 6 m box did not move
Before this branch the same building extruded to a different height depending on where it came from: a tile way went through facett at 3.0 m/level, a buildings.parquet row through osm_katana::shared::height at 3.2.
3.2 is measured, 3.0 was inherited. 3 205 Swedish buildings carry BOTH a parseable height and a parseable building:levels, which makes the constant an empirical question. Sweeping it (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 — and 3.2 is within 0.005 of the best on the mean. For a 3D view that is the column that matters. roof:levels is added to the level count because height includes the roof and building:levels does not: worth 0.273 → 0.251 of mean relative error, over the 67 427 Swedish buildings that carry it.
Why the default is now its own constant
The 6 m untagged default used to be 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 in order to fix 0.0069 % of it.
They are different questions. One converts a MAPPED level count (an empirical constant, ±20 % median error, owned by the data). The other is a LOOK decision about a building nobody mapped (owned by wall_roof_ratio's footprint measurement). So:
DEFAULT_BUILDING_LEVELSis gone;facett_map::DEFAULT_BUILDING_HEIGHT_M = 6.0is the untagged default, a literal;facett_map::METRES_PER_LEVEL = 3.2is the converter only.
Proved, not asserted: setting METRES_PER_LEVEL back to 3.0 leaves no_building_spikes::the_untagged_default_box_did_not_move green (5.6119 m unchanged) while the corpus check goes red on all 15 level-derived rows. That is the decoupling.
Two texts of one decision — and the corpus that catches them drifting
osm_katana::shared::height (znippy-zoomies 04248be) is the first text and the one writer of buildings.parquet's height_m / height_source columns. facett_map::height is the second. facett must not depend on osm-katana — facett is a UI component library, and an edge from a widget crate to a planet-scale OSM cutter would drag arrow/parquet into every consumer of a map widget.
So LAW 5 is not closed by construction here, and this document says so rather than implying otherwise. It is the same residual lane MINZOOM declared for the tag→rung map. What is done instead is to make the duplication detectable:
facett-map/tests/data/building_height_corpus.jsonl— 95 tag objects, 48 accepted / 47 refused, covering all fiveHeightSourcebranches. Its expected column was produced by runningshared::height::of_tagsverbatim, not transcribed by hand.facett-map/tests/building_height_corpus.rsreplays it through facett's parser and fails naming every disagreeing tag value and both numbers.- The corpus's data lines are digested (FNV-1a 64 — a drift detector, not a cryptographic claim) against
CORPUS_DIGEST, so a one-sided edit is loud. Comment and blank lines are excluded so each repo may word its own header.
The regeneration command, and the arm that is NOT landed
facett cargo test -p facett-map --test building_height_corpus
To change the decision: change it in shared::height first, re-run it over the corpus's tags column, put the same data lines in both repos, and paste the new CORPUS_DIGEST the failure message prints.
RESIDUAL, stated plainly: only the facett arm is enforced by a landed test. The osm-katana-side arm — a test in osm-katana that reads the same data lines and asserts of_tags reproduces them — is a hand-off to lane HEIGHT and was not landed by lane SPIKE (shared::height lives on lane/building-height, not on znippy-zoomies main). Until that arm lands, a change made only in osm-katana is caught by facett's corpus test going red the next time the corpus is copied across — not at the moment it is made.
roof:levels does not reach the tile today — a hand-off, not a claim
osm-katana's optimize::slim_tags allowlist (optimize.rs:88-103) keeps height and building:levels and not roof:levels. So on korp's live tile path the roof:levels term in facett's parser is currently always 0 and the code is dead there. It is in the parser anyway because it is part of the decision shared::height makes on buildings.parquet (where the tag IS read, and where it is worth 0.273 → 0.251 of mean relative error), and because agreement is checked by corpus. Adding "roof:levels" to that allowlist is a one-line osm-katana change lane SPIKE did not make — it is another repo and it changes every tile's payload. VERIFIED by reading the allowlist, 2026-08-31.
min_height / building:min_level are in shared::height and deliberately not in facett: Way has no bottom-of-prism field and the extruder lifts from ground. If a building:part renderer lands, that is when they come across.
facett also does not model HeightSource. Its one consumer — the extruder default — does the same thing for Absent and Rejected, and inventing a distinction with no reader is the all-null-column defect. The corpus carries the source column anyway, so a future facett that wants the stamp has it.
Gates — every one seen RED
| gate | red arm, and what it printed |
| the five defects, parsed | restore the old leading-digit body → height=99999 parsed to Some(99999.0), expected None and 4 more, all five named in one message |
| the five defects, on the mesh | same revert → no_building_spikes prints all five rooftop Ys in design units against their correct readings |
| no spike survives | same revert → extruded a rooftop at 99999.0 m (100.5445 du), above the 1000 m ceiling |
| the 95.68 % box is unchanged | DEFAULT_BUILDING_HEIGHT_M 6.0 → 6.4 → the untagged rooftop test names the metres |
agreement with shared::height |
old parser → 38 of 95 rows named; METRES_PER_LEVEL 3.2 → 3.0 → 15 of 95 named |
| corpus not edited on one side | one height_m field changed → digest test prints the new constant to paste |
Traps avoided: the in-lib test's 4.0 * METRES_PER_LEVEL expectation tracked the constant and so could not tell 3.0 from 3.2 from 1.0 — the expectations are literals now. The untagged-box gate was a ±25 % band at first, which tolerated 6.4 m; it is a 5.6119 m literal now. A DEFAULT_BUILDING_HEIGHT_M != 2 × METRES_PER_LEVEL guard was written and then removed — it can only spot the coincidence, never the coupling, and it went red for the wrong reason during the 3.0 decoupling run.
Verification, and what it does NOT cover
facett-map + facett-map3d, both feature configs, serial (--test-threads=1):
- default features — 124 suites, 0 failures.
--features wgpu— 122 suites ok, 2 failures, both reproduced onorigin/main(9a349ad3) with identical numbers and neither on the height path:gfx_v2_item6_parity::the_gpu_2d_writer_is_on_the_unified_path(cull span 0.27018228 vs 0.26041666, aMapTransform::viewport_bboxinverse) andpostfx_bypass::a_2d_ortho_view_records_zero_post_passes…(a luma bound the test's own message calls "a shading/clipping defect … GFX_V2 §V2.11").- Dependents
cargo check --all-targets: facett-demo, facett-geomap, facett-osm, facett, facett-app, facett-map3d-flight, facett-graph3d, facett-helix, facett-about — clean. korp was not compiled against this branch (itspathdep points at the main checkout);DEFAULT_BUILDING_LEVELSandMETRES_PER_LEVELappear nowhere in korp's sources, so the removal cannot break it, but that is a grep, not a build.
The adapter, said plainly: the GPU suites on this box selected llvmpipe (LLVM 21.1.8, Vulkan, Cpu) — the software rasteriser, printed by postfx_bypass itself — and facett-map3d's gpu::wgpu_paint_draws_the_scene_on_device reports "needs a real GPU adapter". No gate in this branch asserts on pixels; every gate here is on parsed metres or on Mesh3 vertex positions, both CPU and adapter-independent. Nothing about the 4090 was proved or needed.
cargo test --workspace still cannot compile on facett main (xtask/tests/silenced_tests_guard.rs → nornir_testmatrix::, E0433). Lane GREEN owns that; nothing here touches xtask. Both new test files are ungated, so neither needs an arm in .nornir/testmatrix-arms.json.