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

//! **Minimap** — the car-navigator / flight-sim **inset overview map**.
//!
//! A garmin navigator and a flight-sim both pin a small top-down *"where am I in
//! the world"* inset in a corner of the main 3D view: the city footprint drawn
//! flat, a **"you are here" marker** at the camera's focus, and a **heading cone**
//! that swings as you turn. This module is the **pure, headless-testable data**
//! behind that inset — [`MinimapView::compute`] projects the live [`OrbitCamera`]
//! and the scene's ground bounds into a normalised `[0, 1]²` overview space, with
//! NO egui and NO GPU, so the whole thing is inject-assert-testable (rotate the
//! bearing → the heading vector rotates by the same angle; pan the focus → the
//! marker slides). [`Map3D::view`](crate::Map3D) then paints it as a light HUD
//! inset, exactly like the street-name labels — a screen-space overlay, backend
//! agnostic (it reads the same numbers whichever [`crate::backend::RenderBackend`]
//! drew the 3D city this frame).
//!
//! ## Why a *pure* view struct (not a painter call)
//! The minimap is the "picking / route overlay / minimap" family from the OSM-3D
//! gap list ([`.nornir/osm-3d-gaps.md`]). Those overlays only *read* the camera +
//! scene, so their correctness is a **data** property, provable without a
//! framebuffer — the same discipline as [`crate::camera`]'s garmin bearing/tilt
//! proof. Keeping the projection here (not inline in `view`) is what lets the
//! headless A/B test assert on `heading` / `focus_uv` directly.

use crate::camera::OrbitCamera;

/// The **ground XZ extent** a minimap projects into, `[min_x, min_z, max_x, max_z]`
/// in design-plane world units (the same units the extruded [`crate::extrude::Mesh3`]
/// positions live in). Built from the scene's terrain/footprint span.
pub type WorldBounds = [f32; 4];

/// The minimap's projected geometry for one frame — a pure function of the scene
/// ground [`WorldBounds`] and the live [`OrbitCamera`]. Everything is in the inset's
/// normalised **overview space**: `u` runs `0..1` left→right across the ground
/// `x`-extent, `v` runs `0..1` top→bottom across the ground `z`-extent (screen
/// convention: `+v` is *down*, matching how the inset paints top-down).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MinimapView {
    /// The scene ground extent this view was projected from (echoed for the painter).
    pub bounds: WorldBounds,
    /// The camera **focus** (orbit target) in overview `[u, v]` — the "you are here"
    /// marker. Inside `[0, 1]²` when the camera looks at the mapped ground; a value
    /// outside means the focus has been panned off the extent.
    pub focus_uv: [f32; 2],
    /// The camera **eye** ground-position in overview `[u, v]` (the eye projected
    /// straight down). Where the "camera" icon sits; the cone fans from the focus
    /// toward it is *not* implied — the cone uses [`Self::heading`].
    pub eye_uv: [f32; 2],
    /// The horizontal **heading** unit vector in overview space (`[du, dv]`, `+dv`
    /// down): which way the view looks, flattened to the ground. Rotating the
    /// camera bearing by θ rotates this by θ — the property the A/B proof asserts.
    pub heading: [f32; 2],
    /// Half-angle (radians) of the **view cone** drawn from the focus along
    /// [`Self::heading`], derived from the camera field-of-view. Always `> 0`.
    pub cone_half: f32,
}

impl MinimapView {
    /// Project the live camera + scene ground bounds into the normalised overview
    /// space. Pure: same inputs ⇒ bit-identical output (no clock, no RNG). Degenerate
    /// bounds (a zero-span axis) fall back to a unit span so `u`/`v` stay finite.
    #[must_use]
    pub fn compute(bounds: WorldBounds, cam: &OrbitCamera) -> MinimapView {
        let [min_x, min_z, max_x, max_z] = bounds;
        let span_x = (max_x - min_x).abs().max(1e-4);
        let span_z = (max_z - min_z).abs().max(1e-4);
        let to_uv = |x: f32, z: f32| [(x - min_x) / span_x, (z - min_z) / span_z];

        let focus_uv = to_uv(cam.target.x, cam.target.z);
        let eye = cam.eye();
        let eye_uv = to_uv(eye.x, eye.z);

        // Ground-flattened look direction. `basis().0` is the forward (look) vector;
        // its (x, z) is the compass heading. Normalise in overview space — because
        // u∝x and v∝z share a monotone linear map, a world-heading rotation shows up
        // as the SAME rotation here (up to the axes' independent scale, which the
        // proof neutralises by using a square scene).
        let fwd = cam.basis().0;
        let (hx, hz) = (fwd.x, fwd.z);
        let hlen = (hx * hx + hz * hz).sqrt().max(1e-9);
        let heading = [hx / hlen, hz / hlen];

        // Cone half-angle from the vertical FOV (a stable, > 0 HUD wedge).
        let cone_half = (cam.fov_y * 0.5).clamp(0.05, std::f32::consts::FRAC_PI_2);

        MinimapView { bounds, focus_uv, eye_uv, heading, cone_half }
    }

    /// Whether the camera **focus** falls inside the mapped ground extent — the
    /// marker is on the inset (not panned off the edge).
    #[must_use]
    pub fn focus_in_bounds(&self) -> bool {
        let [u, v] = self.focus_uv;
        (0.0..=1.0).contains(&u) && (0.0..=1.0).contains(&v)
    }

    /// The heading as a **compass bearing** in degrees `[0, 360)`, measured
    /// clockwise from `+z` (the overview "down"/south axis) — the number a
    /// car-nav shows. Purely derived from [`Self::heading`].
    #[must_use]
    pub fn heading_deg(&self) -> f32 {
        let [du, dv] = self.heading;
        du.atan2(dv).to_degrees().rem_euclid(360.0)
    }
}

/// The XZ bounding box `[min_x, min_z, max_x, max_z]` of a slice of `[x, y, z]`
/// world positions (e.g. [`crate::extrude::Mesh3::positions`]) — the scene ground
/// extent a [`MinimapView`] projects into. `None` for an empty slice.
#[must_use]
pub fn xz_bounds(positions: &[[f32; 3]]) -> Option<WorldBounds> {
    if positions.is_empty() {
        return None;
    }
    let (mut min_x, mut min_z, mut max_x, mut max_z) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
    for p in positions {
        min_x = min_x.min(p[0]);
        max_x = max_x.max(p[0]);
        min_z = min_z.min(p[2]);
        max_z = max_z.max(p[2]);
    }
    Some([min_x, min_z, max_x, max_z])
}