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

nordisk/facett

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