facett-geomap/tests/osm_tilt.rs
//! **Robot proof (LAW #4) — the OSM 2D optional 2.5D tilt.**
//!
//! `OsmView`'s [`MercatorCamera`] carries an optional **pitch** (`pitch_deg`) that
//! leans the flat ground plane back toward the horizon — a perspective
//! foreshortening applied inside the one `to_px` every layer projects through, so
//! ways, points, pins and the graph overlay all tilt as one and stay registered
//! (no textures, no lighting — this never touches `facett-map3d`). Asserts the
//! things a beautify PR must not regress:
//!
//! 1. **state_json** reports the tilt (`pitch_deg`, `tilted`) and it moves through
//! the `Msg::SetTilt` boundary headlessly, with the `[0, TILT_MAX_DEG]` clamp.
//! 2. **pitch = 0 is identity** — `to_px` is byte-identical to the flat projection
//! (the untilted map is unchanged).
//! 3. **round-trip invertibility (<1px)** — `to_merc(to_px(p)) == p` under tilt, so
//! zoom-to-cursor + hit-testing stay truthful when leaned.
//! 4. **shared coordinate space holds under tilt** — a pin, a co-located way vertex
//! and a graph node at the same `(lon,lat)` still project to the SAME screen
//! point when tilted (the overlays ride the same `to_px`).
//! 5. **the lean is real** — a northern (upper) point is pulled toward the pane
//! centre (recedes) under tilt vs. flat.
//! 6. **non-blank render** — the tilted basemap paints through the wgpu backend.
//!
//! Run: `cargo test -p facett-geomap --test osm_tilt`.
use std::collections::HashMap;
use egui_kittest::Harness;
use facett_core::Facet;
use facett_geomap::mercator;
use facett_geomap::osm2d::{GeoGraph, MercatorCamera, Msg, OsmView, Pin, sample_dataset};
use facett_graphview::{Color, GraphModel, GraphNode, Pos};
fn content_ratio(img: &image::RgbaImage) -> f64 {
let mut hist = std::collections::HashMap::<u32, u32>::new();
for p in img.pixels() {
let key = ((p.0[0] as u32 >> 3) << 10) | ((p.0[1] as u32 >> 3) << 5) | (p.0[2] as u32 >> 3);
*hist.entry(key).or_default() += 1;
}
let (modal, _) = hist.iter().max_by_key(|(_, c)| **c).map(|(k, c)| (*k, *c)).unwrap();
let (mr, mg, mb) = (((modal >> 10) & 0x1f) as i32, ((modal >> 5) & 0x1f) as i32, (modal & 0x1f) as i32);
let mut content = 0usize;
for p in img.pixels() {
let (r, g, b) = ((p.0[0] >> 3) as i32, (p.0[1] >> 3) as i32, (p.0[2] >> 3) as i32);
if (r - mr).abs() + (g - mg).abs() + (b - mb).abs() > 3 {
content += 1;
}
}
content as f64 / (img.width() * img.height()) as f64
}
#[test]
fn tilt_is_observable_clamped_and_zero_is_identity() {
let (points, _) = sample_dataset(2_000);
let mut view = OsmView::from_geometry("OSM 2D", points, Vec::new());
// Default: top-down, no tilt.
let s = view.state_json();
assert_eq!(s["pitch_deg"], 0.0, "defaults to top-down");
assert_eq!(s["tilted"], false, "not tilted by default");
// The tilt moves through the Msg boundary (a robot / a slider reaches it here).
view.update(Msg::SetTilt(35.0));
let s = view.state_json();
assert_eq!(s["pitch_deg"], 35.0, "SetTilt is observable");
assert_eq!(s["tilted"], true, "tilted flag flips on");
// Clamp to [0, TILT_MAX_DEG]: an over-tilt saturates, a negative floors to 0.
view.update(Msg::SetTilt(90.0));
assert_eq!(view.state_json()["pitch_deg"], 60.0, "clamped to TILT_MAX_DEG");
view.update(Msg::SetTilt(-10.0));
assert_eq!(view.state_json()["pitch_deg"], 0.0, "negative floors to 0 (top-down)");
// pitch = 0 ⇒ to_px is the plain flat projection, bit-for-bit.
let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(400.0, 400.0));
let flat = MercatorCamera { center: (0.5, 0.5), zoom: 1_000.0, target_zoom: 1_000.0, pitch_deg: 0.0 };
for &(mx, my) in &[(0.5, 0.5), (0.51, 0.49), (0.48, 0.52)] {
let p = flat.to_px(mx, my, rect);
let cx = rect.center().x as f64;
let cy = rect.center().y as f64;
let expect = egui::pos2((cx + (mx - 0.5) * 1_000.0) as f32, (cy + (my - 0.5) * 1_000.0) as f32);
assert_eq!(p, expect, "pitch=0 is the untouched flat projection");
}
}
#[test]
fn tilt_to_px_round_trips_and_leans_north() {
let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(600.0, 600.0));
let cam = MercatorCamera { center: (0.5, 0.5), zoom: 2_000.0, target_zoom: 2_000.0, pitch_deg: 45.0 };
// Round-trip invertibility (<1px in Mercator→px→Mercator→px space): projecting a
// pixel back to Mercator and forward again lands within a pixel — so zoom-to-
// cursor and hit-testing stay truthful under tilt.
for &screen in &[egui::pos2(300.0, 300.0), egui::pos2(360.0, 400.0), egui::pos2(240.0, 380.0)] {
let (mx, my) = cam.to_merc(screen, rect);
let back = cam.to_px(mx, my, rect);
assert!(back.distance(screen) < 1.0, "to_px∘to_merc drifts >1px: {screen:?} -> {back:?}");
}
// The lean is real: a point NORTH of centre (smaller Mercator y) is pulled UP
// toward the pane centre under tilt (it recedes) relative to the flat map.
let flat = MercatorCamera { pitch_deg: 0.0, ..cam };
let north = (0.5, 0.5 - 0.03); // north = up = smaller y
let flat_y = flat.to_px(north.0, north.1, rect).y;
let tilt_y = cam.to_px(north.0, north.1, rect).y;
let cy = rect.center().y;
assert!(flat_y < cy && tilt_y < cy, "the northern point is above centre in both");
assert!(tilt_y > flat_y, "under tilt the northern point recedes toward the horizon (pulled up-toward-centre)");
}
#[test]
fn tilt_preserves_the_shared_coordinate_space() {
// A pin, a way vertex and a graph node all at the same (lon,lat): under tilt
// they must still project to the SAME screen point (one shared `to_px`).
let lonlat = (9.5123, 47.1456);
let (mx, my) = mercator(lonlat.0, lonlat.1);
let pin = Pin::new(lonlat.0, lonlat.1, "Rathaus");
let mut geo = HashMap::new();
geo.insert("n".to_string(), lonlat);
let model = GraphModel {
nodes: vec![GraphNode {
id: "n".to_string(),
label: "here".to_string(),
fill: Color::from(egui::Color32::RED),
stroke: Color::from(egui::Color32::WHITE),
pos: Pos::new(0.0, 0.0),
}],
edges: vec![],
};
let mut view = OsmView::from_geometry("OSM 2D", Vec::new(), Vec::new())
.with_pins(vec![pin.clone()])
.with_graph(GeoGraph::new(model, geo))
.with_tilt(50.0);
// Park off-centre + zoomed so the perspective divide is genuinely engaged.
view.camera = MercatorCamera { center: (mx - 5e-5, my - 4e-5), zoom: 5.0e6, target_zoom: 5.0e6, pitch_deg: 50.0 };
let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(500.0, 500.0));
let pin_px = view.pin_screen_pos(&pin, rect); // uses camera.to_px
let node_px = view.camera.to_px(mx, my, rect); // the graph overlay's lane
assert!(
pin_px.distance(node_px) < 1.0,
"under a 50° tilt the pin ({pin_px:?}) and co-located graph node ({node_px:?}) must share the transform",
);
assert_eq!(view.state_json()["pitch_deg"], 50.0, "the view carries the tilt");
}
#[test]
fn tilted_basemap_renders_non_blank() {
let (points, _) = sample_dataset(3_000);
let mut view = OsmView::from_geometry("OSM 2D", points, Vec::new()).with_tilt(45.0);
let mut harness = Harness::builder().with_size(egui::vec2(640.0, 640.0)).build_ui(move |ui| {
facett_core::set_theme(ui.ctx(), facett_core::Theme::default());
view.ui(ui);
assert_eq!(view.state_json()["tilted"], true, "the frame paints tilted");
});
harness.run_steps(3);
let img = harness.render().expect("wgpu render produced an image");
let ratio = content_ratio(&img);
eprintln!("[osm-tilt] content ratio {ratio:.4}");
assert!(ratio > 0.01, "the tilted basemap must paint (got {ratio:.4})");
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit_non_blank("OsmView.tilt_2p5d", ratio, 0.01, 3);
}