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/wall_roof_ratio.rs

//! **WALL vs FOOTPRINT — a building whose walls swallow its roof is a NUMBER.**
//!
//! Reported: *"the houses' walls have been enlarged and the roofs look like they
//! lean directly on the ground, but I believe it's the walls that have been
//! enlarged, so it looks like ground."*
//!
//! That is a proportion, and a proportion is measurable. A building's wall (its
//! eave height) is bounded by its own plan: you do not build a 6 m wall on a 4 m
//! shed. When the wall grows past the footprint, two things happen at once and they
//! are exactly what was reported — the facade dominates every oblique view (a broad
//! flat surface that reads as ground), and the roof, whose rise scales with the
//! footprint and not with the wall, shrinks to a lid perched on top.
//!
//! ## What was actually wrong
//!
//! Only **69 of the embedded Sigtuna extract's 10373 buildings** carry a `height` or
//! `building:levels` tag — 0.7%. The other 99.3% fell back to a CONSTANT
//! `DEFAULT_BUILDING_HEIGHT_M` = 6 m (spelled `DEFAULT_BUILDING_LEVELS ×
//! METRES_PER_LEVEL` until 2026-08-31, when the level converter was corrected to the
//! measured 3.2 m and the default was pinned as its own literal so it did NOT move with
//! it), a number with no knowledge of the footprint it was lifting. On the demo's Sigtuna town core (182 buildings,
//! footprint long side: p05 4.41 m, median 7.52 m) that produced:
//!
//! | quantity | before | after |
//! |---|---:|---:|
//! | buildings TALLER than they are LONG | **77 of 182** | **0** |
//! | worst wall / long side | 1.77 | 0.90 |
//! | median wall / long side | 0.83 | 0.72 |
//! | median roof rise / wall | 0.36 | 0.41 |
//!
//! The fix caps an **untagged** default against the footprint's short side
//! ([`MAX_DEFAULT_WALL_OVER_FOOTPRINT`]). A TAGGED height is real data and is never
//! capped — a genuine tower on a small plot stays a tower, asserted below.
//!
//! ## Why the assertion is shaped like this
//!
//! `taller_than_long == 0` is a *structural* consequence of the cap (`wall ≤ 0.9 ×
//! short ≤ 0.9 × long`), not a threshold tuned to today's dataset — which is what
//! makes it a regression guard rather than a golden. Remove the cap and it fails by
//! 77 counts on real data; weaken the cap and it fails continuously.

use facett_map3d::extrude::{
    building_height_stats, footprint_plan_metres, DESIGN_FILL, MAX_DEFAULT_WALL_OVER_FOOTPRINT,
};
use facett_map::Way;
use facett_map3d::extrude::M_PER_DEG_LAT;

/// A square-ish building footprint of `side_m` metres near Sigtuna, optionally with
/// a real `height` tag. Two of them `sep_m` apart give the scene a world span, so
/// the extruder has a metric scale to work in.
fn building(id: i64, lon: f64, lat: f64, side_m: f64, height_m: Option<&str>) -> Way {
    let dlat = side_m / M_PER_DEG_LAT;
    let dlon = side_m / (M_PER_DEG_LAT * lat.to_radians().cos());
    let ring = [
        (lon, lat),
        (lon + dlon, lat),
        (lon + dlon, lat + dlat),
        (lon, lat + dlat),
        (lon, lat),
    ];
    let mut tags: Vec<(String, String)> = vec![("building".into(), "yes".into())];
    if let Some(h) = height_m {
        tags.push(("height".into(), h.into()));
    }
    Way::from_lonlat(id, &ring, &tags, &facett_core::Theme::default()).expect("a building way")
}

/// A town of `n` small untagged footprints spread over ~`span_m`, the shape of a
/// real OSM extract: almost nothing carries a height tag. Deterministic.
fn untagged_town(n: usize, span_m: f64) -> Vec<Way> {
    let (lon0, lat0) = (17.72_f64, 59.62_f64);
    let cols = (n as f64).sqrt().ceil() as usize;
    (0..n)
        .map(|i| {
            let (gx, gy) = (i % cols, i / cols);
            let step = span_m / cols as f64;
            let lon = lon0 + (gx as f64 * step) / (M_PER_DEG_LAT * lat0.to_radians().cos());
            let lat = lat0 + (gy as f64 * step) / M_PER_DEG_LAT;
            // Footprints 3.5–11 m — the measured Sigtuna distribution, where the
            // constant 6 m default did its damage.
            let side = 3.5 + (i % 8) as f64;
            building(i as i64 + 1, lon, lat, side, None)
        })
        .collect()
}

/// **THE RED-WHEN-BROKEN ASSERTION.** No building may be taller than its own
/// footprint is long. Under the uncapped constant default this fails by dozens of
/// counts on a realistic untagged town.
#[test]
fn no_building_is_taller_than_its_own_footprint_is_long() {
    let ways = untagged_town(200, 800.0);
    let s = building_height_stats(&ways, 3.0);

    assert!(s.buildings >= 150, "the town extruded, got {} buildings", s.buildings);
    assert_eq!(s.tagged, 0, "this town is untagged — the case the default height serves");
    assert_eq!(
        s.taller_than_long, 0,
        "{} of {} buildings are TALLER THAN THEY ARE LONG — walls that swallow their roofs \
         (worst ratio {:.2})",
        s.taller_than_long, s.buildings, s.wall_over_footprint_max
    );
    // The cap is the structural bound, so the worst case lands exactly on it and
    // never past it.
    assert!(
        s.wall_over_footprint_max <= MAX_DEFAULT_WALL_OVER_FOOTPRINT + 1e-4,
        "worst wall/long-side {:.3} exceeds the cap {MAX_DEFAULT_WALL_OVER_FOOTPRINT}",
        s.wall_over_footprint_max
    );
    // …and the TYPICAL building is comfortably wider than it is tall, which is what
    // "a house, not a chimney" means.
    assert!(
        s.wall_over_footprint_median < 0.85,
        "the median house is wider than it is tall, got {:.3}",
        s.wall_over_footprint_median
    );
}

/// The roof must remain a real part of the silhouette. When the wall runs away the
/// roof rise (which scales with the FOOTPRINT) becomes a vanishing fraction of the
/// building — the "roofs look like they lean directly on the ground" half of the
/// report. Pin a floor under it.
#[test]
fn the_roof_is_a_real_share_of_the_building_not_a_lid() {
    let ways = untagged_town(200, 800.0);
    let s = building_height_stats(&ways, 3.0);
    assert!(
        s.roof_over_wall_median > 0.30,
        "the median roof rise is {:.3} of its wall — a lid on a box, not a roof",
        s.roof_over_wall_median
    );
}

/// **Tagged heights are REAL DATA and are never capped.** A 40 m tower on a 12 m
/// plot is a legitimate OSM building; the footprint cap exists to bound a *guess*,
/// not to overrule a measurement. This is the guard that stops the fix becoming a
/// new bug in the other direction.
#[test]
fn a_tagged_height_is_never_capped_by_its_footprint() {
    // One tagged tower + one untagged shed of the same footprint, in one scene so
    // they share a world scale.
    let ways = vec![
        building(1, 17.7200, 59.6200, 12.0, Some("40")),
        building(2, 17.7250, 59.6200, 12.0, None),
        // A third, far away, so the scene has a real span to scale by.
        building(3, 17.7400, 59.6260, 10.0, None),
    ];
    let s = building_height_stats(&ways, 3.0);
    assert_eq!(s.tagged, 1, "one building carries a real height tag");
    assert!(
        s.wall_over_footprint_max > 3.0,
        "the TAGGED 40 m tower on a 12 m plot survives uncapped (max ratio {:.2})",
        s.wall_over_footprint_max
    );
    assert_eq!(s.taller_than_long, 1, "…and it is the only building over the line");
}

/// `footprint_plan_metres` is the one measurement both the cap and the oracle read,
/// so pin it directly: a ring of a known metric size must measure back to that size.
#[test]
fn footprint_plan_metres_measures_the_real_plan() {
    let ways = vec![
        building(1, 17.7200, 59.6200, 10.0, None),
        building(2, 17.7400, 59.6260, 10.0, None),
    ];
    let span = facett_map3d::extrude::world_span_metres(&ways).expect("a metric span");
    let du_per_m = DESIGN_FILL / span;
    // Rebuild the first ring in design units exactly as `build` does.
    let stats = building_height_stats(&ways, 3.0);
    assert!(stats.buildings >= 2);

    // A 10 m square footprint in design units.
    let side_du = 10.0 * du_per_m;
    let ring = [(0.0, 0.0), (side_du, 0.0), (side_du, side_du), (0.0, side_du), (0.0, 0.0)];
    let (short, long) = footprint_plan_metres(&ring, du_per_m).expect("a measurable plan");
    assert!((short - 10.0).abs() < 0.1, "short side measures 10 m, got {short}");
    assert!((long - 10.0).abs() < 0.1, "long side measures 10 m, got {long}");
    // Degenerate rings are rejected rather than producing a bogus cap.
    assert!(footprint_plan_metres(&[], du_per_m).is_none());
    assert!(footprint_plan_metres(&ring, 0.0).is_none());
}