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