facett-map3d/src/lod.rs
//! **Altitude / LOD lever + the metre calibration** — the single CPU lever that
//! turns the camera's design-unit dolly distance into a real-world **altitude in
//! metres**, then resolves a [`LodTier`] from it. The headline `< 300 m →
//! reflective water` rule is just one threshold of this lever.
//!
//! ## The calibration risk (de-risked here first)
//! The scene lives in a normalised `[-1, 1]³` **design box**, NOT metres — so
//! `camera.distance_to_surface()` is a *design-unit* number, and "300 m" means
//! nothing until we know how many metres one design unit spans. The extruder
//! already computes that: it fits the data's **larger real-world span** (from the
//! ways' WGS-84 lon/lat bbox, [`crate::extrude::world_span_metres`]) so the long
//! footprint axis fills [`crate::extrude::DESIGN_FILL`] design units. Therefore:
//!
//! ```text
//! design_units_per_metre = DESIGN_FILL / world_span_metres
//! METRES_PER_DESIGN_UNIT = world_span_metres / DESIGN_FILL
//! ```
//!
//! This is the same scalar the extruder uses to scale building heights, so the
//! water trigger fires at the SAME scale the skyline is drawn at — and it is cheap
//! to inject-assert (feed a footprint span → assert the altitude→tier boundary).
use facett_map::Way;
/// The default metres-per-design-unit when no real world span is known (synthetic
/// fixtures / degenerate single-point data). The design box is `[-1,1]` (span 2),
/// `DESIGN_FILL` ≈ 1.6 of it; a sensible default treats the whole framed scene as
/// a ~1 km town, so one design unit ≈ 625 m. Only used when calibration is absent;
/// real OSM data always derives the true value via [`metres_per_design_unit`].
pub const DEFAULT_METRES_PER_DESIGN_UNIT: f32 = 625.0;
/// The altitude (metres) at/below which the reflective animated water tier
/// switches ON. Above it, water degrades to cheaper tiers (flat Fresnel tint /
/// flat colour). The spec's headline `< 300 m`.
pub const WATER_ON_ALTITUDE_M: f32 = 300.0;
/// Hysteresis dead-band: once the reflective tier is ON (you dollied below 300 m),
/// it stays on until you climb back above this higher mark — so a camera hovering
/// near the boundary never sees the water pass flicker on/off frame to frame.
pub const WATER_OFF_ALTITUDE_M: f32 = 360.0;
/// **Derive `METRES_PER_DESIGN_UNIT` from the extruder's footprint span.** The
/// long footprint axis fills [`crate::extrude::DESIGN_FILL`] design units and
/// spans `world_span_metres` real metres, so one design unit = span / DESIGN_FILL
/// metres. `None` (synthetic / degenerate data) → [`DEFAULT_METRES_PER_DESIGN_UNIT`].
#[must_use]
pub fn metres_per_design_unit(ways: &[Way]) -> f32 {
match crate::extrude::world_span_metres(ways) {
Some(span_m) if span_m.is_finite() && span_m > 1.0 => span_m / crate::extrude::DESIGN_FILL,
_ => DEFAULT_METRES_PER_DESIGN_UNIT,
}
}
/// Turn a camera **design-unit** distance-to-surface into a real-world **altitude
/// in metres**, using the calibrated [`metres_per_design_unit`].
#[must_use]
pub fn altitude_metres(distance_to_surface_du: f32, metres_per_du: f32) -> f32 {
(distance_to_surface_du.max(0.0)) * metres_per_du
}
/// A camera-altitude detail tier. Gates which passes run + uniform flags +
/// resource sizes. The `< 300 m → reflective water` rule is the `Ground` tier.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LodTier {
/// `< 300 m`: reflective animated water ON, full PBR, hi-res shadows.
Ground,
/// `300 m .. 2 km`: water = animated normal-map (no planar reflection pass).
Low,
/// `2 km .. 15 km`: water = flat Fresnel tint, shadows off, fog up.
Mid,
/// `> 15 km`: water = flat colour, atmosphere-dominated.
High,
}
impl LodTier {
/// The stable string name (for `state_json` + the robot-UI matrix).
#[must_use]
pub fn name(self) -> &'static str {
match self {
LodTier::Ground => "ground",
LodTier::Low => "low",
LodTier::Mid => "mid",
LodTier::High => "high",
}
}
/// Whether this tier runs the **planar reflection pass + animated reflective
/// water** (only `Ground`). The single gate the water feature keys on.
#[must_use]
pub fn reflective_water(self) -> bool {
matches!(self, LodTier::Ground)
}
/// **Per-tier feature budget** — the cap on how many ways an out-of-core tile
/// keeps at this detail tier. Near tiles (`Ground`) render the full `base`
/// budget; far tiles (`Mid`/`High`) keep progressively fewer / merged features
/// so the streamer stays inside a memory + draw-call envelope (the §4 HLOD
/// budget wired into the §5 streamer). Monotonically non-increasing with
/// distance; never drops below 1 so a far tile is a marker, not empty.
#[must_use]
pub fn feature_budget(self, base: usize) -> usize {
let scaled = match self {
LodTier::Ground => base,
LodTier::Low => base / 2,
LodTier::Mid => base / 8,
LodTier::High => base / 32,
};
scaled.max(1)
}
}
/// **Where the `Low` tier ends** — above this the scene drops to `Mid` detail.
///
/// 2 km: high enough that a whole town fits the viewport, so per-building detail is
/// below a pixel and the shadow/PBR passes are paying for nothing. It was a bare
/// `2_000.0` inside [`lod_tier`] (lane ST, gfx3d-S15) — and the two tier ceilings are
/// the numbers an operator asks about when the scene changes detail somewhere they did
/// not expect, so they need names to be findable at all.
pub const LOW_TIER_CEILING_M: f32 = 2_000.0;
/// **Where the `Mid` tier ends** — above this the scene is `High` (country scale).
///
/// 15 km: the altitude at which a Swedish län fits the viewport. See
/// [`LOW_TIER_CEILING_M`] for why these are named.
pub const MID_TIER_CEILING_M: f32 = 15_000.0;
/// Resolve the [`LodTier`] from an altitude in metres (the pure tier match, no
/// hysteresis — see [`resolve_tier`] for the hysteretic frame-to-frame version).
#[must_use]
pub fn lod_tier(altitude_m: f32) -> LodTier {
if altitude_m < WATER_ON_ALTITUDE_M {
LodTier::Ground
} else if altitude_m < LOW_TIER_CEILING_M {
LodTier::Low
} else if altitude_m < MID_TIER_CEILING_M {
LodTier::Mid
} else {
LodTier::High
}
}
/// Resolve the tier **with hysteresis** around the water boundary so the
/// reflective pass never flickers when the camera hovers near 300 m. `was_ground`
/// is the previous frame's "is the reflective water tier active" flag.
///
/// - if it was Ground, stay Ground until altitude climbs past [`WATER_OFF_ALTITUDE_M`];
/// - otherwise switch to Ground only once altitude drops below [`WATER_ON_ALTITUDE_M`].
#[must_use]
pub fn resolve_tier(altitude_m: f32, was_ground: bool) -> LodTier {
if was_ground {
if altitude_m <= WATER_OFF_ALTITUDE_M {
return LodTier::Ground;
}
} else if altitude_m < WATER_ON_ALTITUDE_M {
return LodTier::Ground;
}
lod_tier(altitude_m)
}
#[cfg(test)]
mod tests {
use super::*;
use facett_core::Theme;
/// Build a building ring `span_deg` degrees across at `lat`, so the data's
/// world span is known (the calibration input).
fn town(span_deg: f64, lat: f64) -> Vec<Way> {
let lon0 = 9.5;
let ring = [
(lon0, lat),
(lon0 + span_deg, lat),
(lon0 + span_deg, lat + span_deg),
(lon0, lat + span_deg),
(lon0, lat), // close
];
let tags = vec![("building".to_string(), "yes".to_string())];
vec![Way::from_lonlat(1, &ring, &tags, &Theme::default()).unwrap()]
}
/// **INJECT-ASSERT (the calibration de-risk):** a footprint of a KNOWN metric
/// span yields the documented `METRES_PER_DESIGN_UNIT = span_m / DESIGN_FILL`.
/// A 0.01°-lat town (≈1113 m) over DESIGN_FILL=1.6 → ≈695.8 m/du. Get this
/// wrong and the 300 m water trigger fires at the wrong zoom.
#[test]
fn metres_per_design_unit_is_span_over_design_fill() {
let ways = town(0.01, 47.10); // lat span dominates → ≈1113 m
let m_per_du = metres_per_design_unit(&ways);
let span_m = 0.01_f32 * 111_320.0;
let expect = span_m / crate::extrude::DESIGN_FILL;
assert!(
(m_per_du - expect).abs() < 1.0,
"calibration = span/DESIGN_FILL ≈ {expect:.1} m/du, got {m_per_du:.1}"
);
}
/// INJECT-ASSERT: synthetic / degenerate data (no world span) falls back to the
/// safe default so the rabbit fixture still has a finite altitude scale.
#[test]
fn calibration_falls_back_without_a_world_span() {
let m_per_du = metres_per_design_unit(&[]);
assert_eq!(m_per_du, DEFAULT_METRES_PER_DESIGN_UNIT);
}
/// **INJECT-ASSERT (the 300 m boundary on REAL calibration):** with the
/// 1113 m-town calibration (≈695.8 m/du), the design-unit distance that maps to
/// exactly 300 m is `300 / 695.8 ≈ 0.431 du`. Just below it → `Ground` (water
/// ON); just above → `Low` (water OFF). This is the whole headline trigger
/// expressed as data.
#[test]
fn altitude_and_tier_cross_at_the_300m_boundary() {
let ways = town(0.01, 47.10);
let m_per_du = metres_per_design_unit(&ways);
let du_at_300 = WATER_ON_ALTITUDE_M / m_per_du;
// Just inside (closer) → 299 m → Ground (reflective water on).
let near = altitude_metres(du_at_300 * 0.99, m_per_du);
assert!(near < WATER_ON_ALTITUDE_M, "below boundary, got {near:.1} m");
assert_eq!(lod_tier(near), LodTier::Ground);
assert!(lod_tier(near).reflective_water(), "water pass runs below 300 m");
// Just outside (higher) → ~303 m → Low (reflective water off).
let far = altitude_metres(du_at_300 * 1.01, m_per_du);
assert!(far > WATER_ON_ALTITUDE_M, "above boundary, got {far:.1} m");
assert_eq!(lod_tier(far), LodTier::Low);
assert!(!lod_tier(far).reflective_water(), "no reflective water above 300 m");
}
/// INJECT-ASSERT: the full tier ladder — feed a spread of altitudes, assert each
/// lands in the documented band (multi-value, not a single point).
#[test]
fn tier_ladder_buckets_altitudes() {
assert_eq!(lod_tier(50.0), LodTier::Ground);
assert_eq!(lod_tier(299.9), LodTier::Ground);
assert_eq!(lod_tier(300.1), LodTier::Low);
assert_eq!(lod_tier(1_999.0), LodTier::Low);
assert_eq!(lod_tier(2_001.0), LodTier::Mid);
assert_eq!(lod_tier(14_000.0), LodTier::Mid);
assert_eq!(lod_tier(20_000.0), LodTier::High);
assert!(LodTier::Ground.reflective_water() && !LodTier::Mid.reflective_water());
}
/// **INJECT-ASSERT (§4 per-tier feature budget):** the streamer's per-tile way
/// budget shrinks monotonically as the tier coarsens (near = full, far = few),
/// and never collapses to zero — so far tiles are cheap markers, not empty. A
/// far `High` tile keeps far fewer ways than a near `Ground` tile from the same
/// base budget.
#[test]
fn feature_budget_shrinks_with_distance() {
let base = 4096;
let g = LodTier::Ground.feature_budget(base);
let l = LodTier::Low.feature_budget(base);
let m = LodTier::Mid.feature_budget(base);
let h = LodTier::High.feature_budget(base);
assert_eq!(g, base, "ground tile keeps the full budget");
assert!(g >= l && l >= m && m >= h, "budget is monotonically non-increasing: {g} {l} {m} {h}");
assert!(h * 8 <= g, "the far tier is far cheaper than the near tier ({h} vs {g})");
assert!(h >= 1, "even the farthest tier keeps at least one feature (a marker)");
// Never zero even for a tiny base.
assert_eq!(LodTier::High.feature_budget(1), 1, "budget floors at 1, never 0");
}
/// **INJECT-ASSERT (hysteresis):** in the 300–360 m dead-band the tier depends
/// on history — once Ground (water on), it sticks until you climb past 360 m;
/// once Low, it stays Low until you drop below 300 m. No frame-to-frame flicker.
#[test]
fn hysteresis_holds_water_across_the_dead_band() {
// Sitting at 330 m: if it was already Ground → still Ground (sticky on).
assert_eq!(resolve_tier(330.0, true), LodTier::Ground, "sticky ON in the band");
// Same 330 m but was NOT Ground → stays Low (won't switch on until <300).
assert_eq!(resolve_tier(330.0, false), LodTier::Low, "won't toggle on in the band");
// Climb out past 360 → Low even if it was Ground.
assert_eq!(resolve_tier(370.0, true), LodTier::Low, "releases above 360 m");
// Drop below 300 → Ground regardless of history.
assert_eq!(resolve_tier(280.0, false), LodTier::Ground, "engages below 300 m");
}
}