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

nordisk/facett

facett-map3d/tests/no_building_spikes.rs

//! **The five measured OSM height defects, proved on the EXTRUDED MESH.**
//!
//! `facett-map/src/height.rs` decides the metres; this file proves the decision reaches
//! the geometry, because that is the thing that was visibly wrong. Until 2026-08-31 the
//! parser took the leading run of digits off `height` and believed it, so korp's 3D view
//! drew — MEASURED against korp's live Sweden cut:
//!
//! | tag as written | it drew | it means |
//! |---|---|---|
//! | `height=99999` | a **99 999 m** spike | junk |
//! | `height=1;12` | a **1 m** building | a semicolon list |
//! | `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 |
//!
//! Each case below is asserted as a rooftop Y on the mesh `extrude::build` returns, and
//! each failure message names the tag string. The comparison is against the mesh built
//! from the value's CORRECT reading — same osm id, same ring, so the extruder's
//! deterministic per-building jitter is identical on both sides and the only difference
//! left is the height.
//!
//! Run: `cargo test -p facett-map3d --test no_building_spikes`

use facett_core::Theme;
use facett_map::{DEFAULT_BUILDING_HEIGHT_M, MAX_PLAUSIBLE_HEIGHT_M, Way};
use facett_map3d::extrude;

/// A ~100 m square footprint at a FIXED place with a FIXED id, tagged as given. Both
/// are fixed on purpose: `building_design_height`'s untagged jitter is keyed on the id
/// and the first ring vertex, so two cases built this way differ only by their tags.
fn building(lon: f64, tags: &[(&str, &str)]) -> Way {
    let d = 0.0010; // ~100 m
    let pts = [(lon, 47.100), (lon + d, 47.100), (lon + d, 47.101), (lon, 47.101), (lon, 47.100)];
    let mut kv: Vec<(String, String)> = vec![("building".into(), "yes".into())];
    kv.extend(tags.iter().map(|(k, v)| (k.to_string(), v.to_string())));
    Way::from_lonlat(2, &pts, &kv, &Theme::default()).expect("valid building footprint")
}

/// Rooftop Y of the test building, in design units. The layout is a fixed 3 m
/// scale-anchor plus the test building, so the world span (and therefore
/// design-units-per-metre) is identical for every case in this file.
fn top_y(tags: &[(&str, &str)]) -> f32 {
    let anchor = {
        let d = 0.0010;
        let pts = [(9.500, 47.100), (9.500 + d, 47.100), (9.500 + d, 47.101), (9.500, 47.101), (9.500, 47.100)];
        let kv: Vec<(String, String)> =
            vec![("building".into(), "yes".into()), ("height".into(), "3".into())];
        Way::from_lonlat(1, &pts, &kv, &Theme::default()).expect("anchor")
    };
    let ways = vec![anchor, building(9.520, tags)];
    let (mesh, _) = extrude::build(&ways, 0.02);
    mesh.positions.iter().map(|p| p[1]).fold(f32::MIN, f32::max)
}

/// Design units per metre for this fixture, read off the mesh rather than guessed: two
/// tagged heights 90 m apart give the scale directly.
fn du_per_m() -> f32 {
    (top_y(&[("height", "100")]) - top_y(&[("height", "10")])) / 90.0
}

/// ★ **RED-when-broken: each of the five measured defects extrudes correctly, by name.**
///
/// Revert `facett-map/src/height.rs` and all five fail, each naming its own tag string.
#[test]
fn the_five_measured_defects_extrude_correctly() {
    // (defect tag, the tags that spell the CORRECT reading, what it means)
    type Tags = &'static [(&'static str, &'static str)];
    let cases: &[(Tags, Tags, &str)] = &[
        (
            &[("height", "99999")],
            &[],
            "junk: must fall back to the untagged default box, not a 99 999 m spike",
        ),
        (
            &[("height", "1;12")],
            &[],
            "a semicolon list is two statements about one building; taking the first drew a 1 m house",
        ),
        (&[("height", "25'")], &[("height", "7.62")], "25 FEET is 7.62 m, not 25 m"),
        (
            &[("height", "102,5")],
            &[("height", "102.5")],
            "a Swedish decimal comma is 102.5 m; the old parser stopped at the comma and drew 102 m",
        ),
        (
            &[("building:levels", "3-4")],
            &[],
            "a level RANGE is not a level count; the old parser read the leading `3`",
        ),
    ];
    // Collected, not asserted one at a time: reverting the parser breaks ALL FIVE, and
    // a failure that names only the first hides four.
    let mut wrong: Vec<String> = Vec::new();
    for (defect, correct, why) in cases {
        let (got, want) = (top_y(defect), top_y(correct));
        if (got - want).abs() >= 1e-4 {
            wrong.push(format!(
                "  `{defect:?}` extruded a rooftop at {got:.6} du, but its correct reading \
                 `{correct:?}` extrudes to {want:.6} du — {why}"
            ));
        }
    }
    assert!(
        wrong.is_empty(),
        "{} of the 5 measured OSM height defects still reach the mesh:\n{}",
        wrong.len(),
        wrong.join("\n")
    );

    // The all-`None` guard. Every "correct" arm above but two is the DEFAULT box, so a
    // parser that returned `None` for everything would pass the loop. A real tagged
    // height must still come through taller than the default.
    let tagged = top_y(&[("height", "40")]);
    let default = top_y(&[]);
    assert!(
        tagged > default * 3.0,
        "a 40 m tagged building ({tagged:.6} du) is barely above the ~6 m default \
         ({default:.6} du) — the tagged path is not reaching the mesh at all"
    );
}

/// ★ **RED-when-broken: no tag value can put a spike through the sky.**
///
/// The bound is on the APPLIED output — the rooftop Y of the mesh — expressed in metres
/// via the fixture's own scale, so it cannot be satisfied by a parser that merely
/// returns a small number and an extruder that then multiplies it back up.
#[test]
fn no_tag_value_extrudes_above_the_plausibility_ceiling() {
    let scale = du_per_m();
    assert!(scale > 0.0, "the fixture's design-units-per-metre came out {scale}");
    let ceiling_du = MAX_PLAUSIBLE_HEIGHT_M * scale;

    for tags in [
        vec![("height", "99999")],
        vec![("height", "999999999")],
        vec![("height", "12000")],
        vec![("height", "50000 m")],
        vec![("height", "1e9")],
        vec![("height", "99999'")],
        vec![("building:levels", "9999")],
        vec![("building:levels", "99999")],
    ] {
        let y = top_y(&tags);
        assert!(
            y <= ceiling_du,
            "`{tags:?}` extruded a rooftop at {:.1} m ({y:.4} du), above the \
             {MAX_PLAUSIBLE_HEIGHT_M} m ceiling — that is the 99 999 m spike this bound \
             exists to stop",
            y / scale
        );
    }
    // The ceiling is not vacuous: a real 828 m tower must still reach the mesh.
    let burj = top_y(&[("height", "828")]) / scale;
    assert!(
        (burj - 828.0).abs() < 1.0,
        "the Burj Khalifa extruded to {burj:.1} m — the ceiling must refuse junk, not \
         real buildings"
    );
}

/// ★ **RED-when-broken: the 95.68 % path is EXACTLY the box it was.**
///
/// 3 675 499 of korp's 3 841 483 Swedish buildings (95.6792 %, MEASURED 2026-08-31) have
/// no height tag at all, so this is the branch nearly the whole 3D view is drawn on.
/// Correcting `METRES_PER_LEVEL` from 3.0 to the measured 3.2 must NOT drag it from 6.0 m
/// to 6.4 m as a side effect — which is why the default is now its own constant.
///
/// Change `DEFAULT_BUILDING_HEIGHT_M` and this names the number in metres.
#[test]
fn the_untagged_default_box_did_not_move() {
    let scale = du_per_m();
    let default_m = top_y(&[]) / scale;
    // PINNED to the metre, as a LITERAL. The untagged path is 6.0 m carrying a
    // DETERMINISTIC ±25 % jitter keyed on the osm id + first ring vertex, both fixed by
    // this fixture — so this building has exactly one height, MEASURED at 5.6119 m on
    // origin/main before this branch touched anything. A band would have tolerated the
    // 6.4 m that correcting METRES_PER_LEVEL to 3.2 would have caused; a literal does
    // not. Change the default, the jitter or the footprint cap and this names the metres.
    assert!(
        (default_m - 5.6119).abs() < 5e-3,
        "an untagged building now extrudes to {default_m:.4} m; on origin/main it was \
         5.6119 m (6.0 m x the fixture's fixed jitter). That branch is 95.68 % of every \
         town in the 3D view — 3 675 499 of korp's 3 841 483 Swedish buildings — and this \
         fix is about junk tags, not about the look."
    );
    assert!(
        (DEFAULT_BUILDING_HEIGHT_M - 6.0).abs() < 1e-6,
        "DEFAULT_BUILDING_HEIGHT_M is {DEFAULT_BUILDING_HEIGHT_M} m, not 6.0"
    );
    // A building whose height tag was REJECTED gets that same box — not a guess, and
    // not a special case either.
    for junk in ["99999", "1;12", "B", "0", "Österbybruks hembygdsgård"] {
        let y = top_y(&[("height", junk)]);
        assert!(
            (y - top_y(&[])).abs() < 1e-6,
            "`height={junk}` extruded to {y:.6} du instead of the untagged default box"
        );
    }
}