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