Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

.nornir/building-height-design.md

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:

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:

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):

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.