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

nordisk/facett

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");
    }
}