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])
}