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

nordisk/facett

facett-map/src/project.rs

//! Web-Mercator projection helpers, ported from katana-osm's
//! `osm-viewer/src/render/project.rs`. Mercator unit space is `[0,1]²`
//! (x: lon −180→+180, y: north→south), the space the GPU shader expects.

use egui::{Pos2, Rect, Vec2};

/// WGS-84 `(lon, lat)` → Web-Mercator unit `[0,1]²` as `f32` (GPU vertex space).
/// Matches [`crate::mercator`] (the `f64` viewer projection) bit-for-bit in shape.
#[inline]
pub fn to_mercator_f32(lon: f32, lat: f32) -> (f32, f32) {
    let x = (lon + 180.0) / 360.0;
    let lat_r = lat.to_radians();
    let y = (1.0 - (lat_r.tan() + 1.0 / lat_r.cos()).ln() / std::f32::consts::PI) / 2.0;
    (x, y)
}

/// The **unified view** these helpers are a façade over (GFX_V2 item 6): a
/// [`facett_core::render::View`] under the 2D constraints, with the Mercator origin
/// as `target` and `zoom` as a uniform per-axis `px_per_unit`. `offset` is where the
/// origin lands in pixels.
#[inline]
fn view_for(zoom: f32) -> facett_core::render::View {
    facett_core::render::View::ortho_2d_map([0.0, 0.0], [zoom, zoom])
}

/// Mercator unit point → screen pixel (logical), given zoom + pan offset.
///
/// Delegates to the unified view core, so this is not a second 2D projection
/// writer (LAW #5) — it is the shared one under the 2D constraint. Bit-exact
/// against the affine it replaces.
#[inline]
pub fn to_screen(mx: f32, my: f32, zoom: f32, offset: Vec2) -> Pos2 {
    let p = view_for(zoom).project_ground([mx, my], (offset.x, offset.y), 1.0);
    Pos2::new(p.x, p.y)
}

/// Screen pixel → Mercator unit coordinate. The exact inverse of [`to_screen`],
/// via the unified core's ground unprojection.
#[inline]
pub fn from_screen(pos: Pos2, zoom: f32, offset: Vec2) -> (f32, f32) {
    let [x, y] = view_for(zoom)
        .unproject_ground((pos.x, pos.y), (offset.x, offset.y), 1.0)
        .expect("the 2D constraint is orthographic, so the ground is always hit");
    (x, y)
}

/// Mercator bbox of the current viewport rect as `[x0, y0, x1, y1]`.
pub fn viewport_bbox(rect: Rect, zoom: f32, offset: Vec2) -> [f32; 4] {
    let (ax, ay) = from_screen(rect.min, zoom, offset);
    let (bx, by) = from_screen(rect.max, zoom, offset);
    [ax.min(bx), ay.min(by), ax.max(bx), ay.max(by)]
}

#[cfg(test)]
mod tests {
    use super::*;

    /// GFX_V2 item 6, non-regression: routing these helpers through the unified
    /// view core did **not** move a pixel. The pre-unification affines are written
    /// out verbatim as the reference and bit-equality is demanded — at NON-identity
    /// zoom and a NON-zero offset, because at `zoom = 1, offset = 0` a dead
    /// transform and a working one are indistinguishable.
    #[test]
    fn the_unified_2d_facade_is_bit_exactly_the_affine_it_replaced() {
        let zoom = 4096.0f32;
        let offset = Vec2::new(-517.5, 388.25);
        for (mx, my) in [(0.361_25f32, 0.284_75f32), (0.0, 0.0), (1.0, -0.5), (0.75, 0.125)] {
            // The reference: exactly what `to_screen` was before item 6.
            let want = Pos2::new(mx * zoom + offset.x, my * zoom + offset.y);
            let got = to_screen(mx, my, zoom, offset);
            assert_eq!(got.x.to_bits(), want.x.to_bits(), "x bit-exact for ({mx}, {my})");
            assert_eq!(got.y.to_bits(), want.y.to_bits(), "y bit-exact for ({mx}, {my})");

            // ...and the same for the inverse.
            let (wx, wy) = ((got.x - offset.x) / zoom, (got.y - offset.y) / zoom);
            let (gx, gy) = from_screen(got, zoom, offset);
            assert_eq!(gx.to_bits(), wx.to_bits(), "inverse x bit-exact");
            assert_eq!(gy.to_bits(), wy.to_bits(), "inverse y bit-exact");
        }
        // NON-IDENTITY GUARD: the transform must actually be applying the zoom. A
        // no-op would satisfy the loop above only against an equally dead
        // reference, so pin the applied magnitude.
        let a = to_screen(0.5, 0.5, zoom, offset);
        let b = to_screen(0.5 + 0.001, 0.5, zoom, offset);
        assert!(
            (b.x - a.x - 4.096).abs() < 1e-3,
            "0.001 Mercator at zoom 4096 must move 4.096 px, moved {}",
            b.x - a.x
        );
    }

    /// The viewport bbox is derived from the same inverse, so it must bracket the
    /// rect's own corners — with a real rect, a real zoom and a real pan.
    #[test]
    fn viewport_bbox_brackets_the_rect_corners() {
        let zoom = 2048.0f32;
        let offset = Vec2::new(120.0, -64.0);
        let rect = Rect::from_min_max(Pos2::new(10.0, 20.0), Pos2::new(910.0, 660.0));
        let [x0, y0, x1, y1] = viewport_bbox(rect, zoom, offset);
        assert!(x0 < x1 && y0 < y1, "bbox must be non-degenerate: {x0}..{x1}, {y0}..{y1}");
        let (cx, cy) = from_screen(rect.center(), zoom, offset);
        assert!(x0 <= cx && cx <= x1 && y0 <= cy && cy <= y1, "the centre must be inside");
        // The span must be the pixel span divided by the zoom — not some constant.
        assert!(
            ((x1 - x0) - rect.width() / zoom).abs() < 1e-6,
            "bbox width {} vs expected {}",
            x1 - x0,
            rect.width() / zoom
        );
    }
}