facett-map3d/src/garmin.rs
//! **Garmin-nav HUD overlays — the pure, headless-testable data** behind the
//! car-navigator chrome that rides on top of the 3D-VECTOR view: a **north rose /
//! compass** ([`CompassRose`]) and a **dynamic scale bar** ([`ScaleBar`]).
//!
//! Both are the same discipline as [`crate::minimap`]: a **pure function of the live
//! camera** (and, for the scale bar, the viewport + the map's metres-per-design-unit
//! calibration) with NO egui and NO GPU, so the whole thing is inject-assert-testable
//! — rotate the camera bearing and the north needle swings by the same angle the
//! *other* way; dolly out and the scale bar's "nice" round distance grows. [`crate::Map3D`]
//! then paints them as light HUD overlays (the north rose top-left, the scale bar
//! bottom-left), exactly like the street-name labels + the minimap inset: screen-space,
//! backend-agnostic (they read the same numbers whichever [`crate::backend::RenderBackend`]
//! drew the city this frame).
//!
//! ## Why *pure* structs (not painter calls)
//! A real Garmin's headline chrome is the **compass** and the **scale bar**. Their
//! correctness is a **data** property — "north points the right way for this bearing",
//! "the bar reads 500 m at this zoom" — provable without a framebuffer. Keeping the
//! projection here (not inline in `view`) is what lets the headless proof assert on the
//! screen angles / the chosen distance directly. See
//! [`.nornir/garmin-navig-overlays-design.md`].
use crate::camera::OrbitCamera;
/// The **north rose / compass** for one frame — a pure function of the camera
/// **bearing** (the compass direction the camera looks, `[0, 360)` clockwise from
/// north). A Garmin pins a rose in a corner whose **N** marker always points at true
/// north *relative to the view*: look north (bearing 0) and N is straight up; turn to
/// face east (bearing 90) and N swings to the **left**. The rose rotates opposite the
/// camera — the world stays put while you turn.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CompassRose {
/// The camera **bearing** in degrees `[0, 360)` — clockwise from north, the number
/// a car-nav shows ("heading 137°"). Echoed for the painter + the `state_json`.
pub bearing_deg: f32,
}
impl CompassRose {
/// The compass for the live camera pose. Reads [`OrbitCamera::bearing_deg`] — the
/// SAME bearing the minimap heading cone + the route follow use, so all the nav
/// chrome agrees on which way is forward.
#[must_use]
pub fn from_camera(cam: &OrbitCamera) -> CompassRose {
CompassRose { bearing_deg: cam.bearing_deg().rem_euclid(360.0) }
}
/// **Screen angle (radians) of a cardinal** `deg_from_north` (0 = N, 90 = E, …),
/// measured **clockwise from screen-up**. A cardinal sits at `deg_from_north −
/// bearing`: when the camera looks along that cardinal the marker is straight up (0).
/// This is the one rotation the rose applies — everything on it (the N/E/S/W ticks,
/// the needle) derives from it, so the whole rose swings rigidly with the bearing.
#[must_use]
pub fn cardinal_screen_angle(&self, deg_from_north: f32) -> f32 {
(deg_from_north - self.bearing_deg).to_radians()
}
/// **Screen angle (radians) of NORTH**, clockwise from up — the north needle's
/// direction. Look north ⇒ 0 (up); rotating the camera bearing by +θ rotates this
/// by −θ (the rose counter-rotates). The property the headless proof asserts.
#[must_use]
pub fn north_screen_angle(&self) -> f32 {
self.cardinal_screen_angle(0.0)
}
/// The unit **screen vector** (`x` right, `y` DOWN — egui convention) pointing at
/// `deg_from_north`. Up is `(0, −1)`; a clockwise screen angle θ maps to
/// `(sin θ, −cos θ)`. Handy for the painter (place the N tick / draw the needle).
#[must_use]
pub fn cardinal_screen_dir(&self, deg_from_north: f32) -> [f32; 2] {
let a = self.cardinal_screen_angle(deg_from_north);
[a.sin(), -a.cos()]
}
}
/// A **dynamic map scale bar** for one frame: a "nice" round ground distance
/// ([`Self::metres`] — always a `1 / 2 / 5 × 10ⁿ` value), the on-screen bar width in
/// pixels that distance spans ([`Self::px_width`]), and the human label
/// ([`Self::label`], e.g. `"500 m"` / `"2 km"`). Recomputed each frame from the
/// camera dolly + altitude, so it grows as you zoom out — exactly like a Garmin's bar.
#[derive(Clone, Debug, PartialEq)]
pub struct ScaleBar {
/// The chosen **round ground distance in metres** the bar represents (a
/// `1 / 2 / 5 × 10ⁿ` value).
pub metres: f32,
/// The bar's **on-screen width in logical pixels** for [`Self::metres`] at the
/// current zoom (`metres / metres_per_pixel`).
pub px_width: f32,
/// The human-readable label (`"500 m"`, `"2 km"`).
pub label: String,
}
impl ScaleBar {
/// Build a scale bar from the **ground metres-per-pixel** at the view centre and a
/// **target bar length** in pixels (the bar aims for ≈`target_px` wide, then snaps
/// its distance to the nearest nice `1 / 2 / 5 × 10ⁿ` value ≤ that, so the printed
/// number is always round). Pure + deterministic.
#[must_use]
pub fn compute(metres_per_pixel: f32, target_px: f32) -> ScaleBar {
let mpp = metres_per_pixel.max(1e-6);
let target = target_px.max(8.0);
// The metres a `target`-wide bar would span, snapped to a nice round value.
let metres = nice_125(mpp * target);
let px_width = metres / mpp;
ScaleBar { metres, px_width, label: fmt_distance(metres) }
}
/// Build the scale bar for the live camera + viewport. The **ground sampling
/// distance** at the orbit focus is `distance · tan(fov_y/2) / half_h` design units
/// per pixel (the perspective the [`OrbitCamera`] projects with — one design unit at
/// depth = `distance` covers `focal / distance` pixels, and `focal = half_h /
/// tan(fov_y/2)`); multiply by `metres_per_du` for real metres. So dollying out
/// (bigger `distance`) or a wider FOV grows the bar's distance — the Garmin zoom feel.
#[must_use]
pub fn from_camera(cam: &OrbitCamera, viewport_h_px: f32, metres_per_du: f32, target_px: f32) -> ScaleBar {
let half_h = (viewport_h_px * 0.5).max(1.0);
let du_per_px = cam.distance * (cam.fov_y * 0.5).tan() / half_h;
let metres_per_pixel = du_per_px.max(1e-9) * metres_per_du.max(1e-6);
Self::compute(metres_per_pixel, target_px)
}
}
/// Snap a positive length to the nearest **nice** `1 / 2 / 5 × 10ⁿ` value at or below
/// it (the classic map-scale rounding). Non-positive / non-finite input → `1.0`.
#[must_use]
pub fn nice_125(x: f32) -> f32 {
if !x.is_finite() || x <= 0.0 {
return 1.0;
}
let exp = x.log10().floor();
let base = 10f32.powf(exp);
let f = x / base; // f ∈ [1, 10)
let nice = if f < 1.5 {
1.0
} else if f < 3.5 {
2.0
} else if f < 7.5 {
5.0
} else {
10.0
};
nice * base
}
/// Format a ground distance for the scale-bar label: metres below a kilometre,
/// kilometres above (dropping a trailing `.0`). `"500 m"`, `"1 km"`, `"2.5 km"`.
#[must_use]
pub fn fmt_distance(metres: f32) -> String {
if metres >= 1000.0 {
let km = metres / 1000.0;
if (km - km.round()).abs() < 1e-3 {
format!("{km:.0} km")
} else {
format!("{km:.1} km")
}
} else {
format!("{metres:.0} m")
}
}