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

nordisk/facett

facett-demo/tests/osm2d_deckgl_quality.rs

//! **OSM 2D render quality — the deck.gl-parity evidence.**
//!
//! Renders the REAL OSM 2D tab (embedded GeoParquet, the same `osm2d_view` the demo
//! runs) through the GPU line lane at several zooms and saves PNGs, then asserts the
//! two quality properties we adopted from deck.gl's `PathLayer` / `TextLayer`:
//!
//! 1. **One width formula, pixel-clamped** (`widthScale` + `widthMinPixels` /
//!    `widthMaxPixels`): the CPU basemap painter and the GPU line lane resolve a
//!    road's on-screen width through the SAME `facett_map::style::line_width_px`, so
//!    the land and the roads are never at two different scales, and the width stays
//!    inside the pixel band at every zoom.
//! 2. **Label repeat-distance declutter** (deck.gl `CollisionFilterExtension`): one
//!    street is lettered once per neighbourhood, not once per OSM `Way`.
//!
//! Run: `cargo test -p facett-demo --release --features map-wgpu --test osm2d_deckgl_quality -- --nocapture`

#![cfg(feature = "map-wgpu")]

use egui_kittest::Harness;
use egui_kittest::wgpu::{WgpuTestRenderer, create_render_state, default_wgpu_setup};
use facett::Facet;
use facett::geomap::osm2d::Msg;
use facett_demo::osm_tabs;

/// Render the real OSM 2D tab framed at `factor` × the fitted zoom; returns the frame
/// and the pane's `state_json` (for the label counts).
fn render_at(geo: &osm_tabs::OsmGeometry, factor: f64, tag: &str) -> (image::RgbaImage, serde_json::Value) {
    let mut view = osm_tabs::osm2d_view(geo);
    let rect = egui::Rect::from_min_size(egui::pos2(0.0, 26.0), egui::vec2(900.0, 874.0));
    view.update(Msg::Fit(rect));
    let base = view.state().zoom;
    view.update(Msg::SetZoom(base * factor));

    let rs = create_render_state(default_wgpu_setup(), Default::default());
    facett::map::gpu::install_renderer(&rs);
    let state = std::rc::Rc::new(std::cell::RefCell::new(serde_json::Value::Null));
    let sref = state.clone();
    let mut h = Harness::builder()
        .with_size(egui::vec2(900.0, 900.0))
        .renderer(WgpuTestRenderer::from_render_state(rs))
        .build_ui(move |ui| {
            facett::core::set_theme(ui.ctx(), facett::core::Theme::sci_fi());
            view.ui(ui);
            *sref.borrow_mut() = view.state_json();
        });
    h.run_steps(6);
    let img = h.render().expect("wgpu render");
    let path = format!("/tmp/osm2d_quality_{tag}.png");
    image::DynamicImage::ImageRgba8(img.clone()).save(&path).ok();
    let st = state.borrow().clone();
    eprintln!("osm2d_quality[{tag}]: saved {path}  visible_labels={}", st["visible_labels"]);
    (img, st)
}

/// **The deck.gl width contract, on HAND-COMPUTED pixel widths.**
/// `facett_map::style::line_width_px` is the ONE formula both lanes use; it must
/// behave like `PathLayer`'s `clamp(width × widthScale, widthMinPixels,
/// widthMaxPixels)` with `widthScale = clamp(zoom / 50 000, 0.5, 3.0)`.
///
/// Every expected number in the tables below is written out as a **literal**, derived
/// by hand from that specification — NOT by calling the functions under test. That is
/// the whole point of this rewrite: until 2026-08-01 the central assert here was
/// `line_width_px(w, z) == line_width_px_scaled(w, line_width_scale(z))`, and since
/// `line_width_px` is *defined* as exactly that call, the two sides were the same Rust
/// expression. It could not go red for any implementation of anything. It survived
/// `resolve_line_width_px` in `facett-core`'s `common.wgsl` being reverted to the
/// historical bug (ignore `width_scale`, bake `width × 1`) without a murmur.
///
/// **LAW 2 — the identity-value trap is deliberately avoided.** Only ONE row of the
/// tables sits on `scale == 1.0`; the rest are 0.5 / 0.75 / 1.2 / 1.5 / 2.0 / 2.8 /
/// 3.0, so a dead multiply (`× 1`) is caught, and both the scale-ramp clamps AND both
/// pixel clamps are exercised from both sides.
///
/// **SCOPE — this test still does NOT reach the GPU.** It pins the CPU formula and its
/// scale/clamp decomposition (the split the shader is written against: the GPU lane is
/// handed `line_width_scale(zoom)` as a uniform and applies the pixel clamp itself).
/// The guard that measures the SHADER is
/// `facett-map/tests/line_gpu.rs::shader_width_matches_the_cpu_width_formula_on_real_pixels`,
/// which renders on a real device and counts drawn pixels.
#[test]
fn line_width_is_pixel_clamped_and_shared_by_both_lanes() {
    use facett::map::style::{
        LINE_WIDTH_MAX_PX, LINE_WIDTH_MIN_PX, LINE_WIDTH_SCALE_MAX, LINE_WIDTH_SCALE_MIN, LINE_WIDTH_ZOOM_REF,
        line_width_px, line_width_px_scaled, line_width_scale,
    };

    // The published band/ramp constants are what the literals below were computed
    // from; if someone retunes them the tables are stale and must be recomputed.
    assert_eq!(
        (LINE_WIDTH_ZOOM_REF, LINE_WIDTH_SCALE_MIN, LINE_WIDTH_SCALE_MAX, LINE_WIDTH_MIN_PX, LINE_WIDTH_MAX_PX),
        (50_000.0, 0.5, 3.0, 1.0, 24.0),
        "the hand-computed expectations below assume ref=50000, scale∈[0.5,3.0], px∈[1,24]"
    );

    // ── 1. the zoom → widthScale ramp, against literals ──────────────────────────
    // clamp(zoom / 50 000, 0.5, 3.0). Both ramp clamps hit, plus five live values.
    let scale_cases: [(f64, f32); 12] = [
        (64.0, 0.5),           // far below ref → ramp FLOOR
        (1_000.0, 0.5),        // still floored
        (25_000.0, 0.5),       // exactly ON the ramp floor (0.5 raw)
        (37_500.0, 0.75),      // live, below 1
        (50_000.0, 1.0),       // the reference zoom — the ONE identity row
        (60_000.0, 1.2),       // live, just above 1
        (75_000.0, 1.5),       // live
        (100_000.0, 2.0),      // live — a dead `× 1` shows up here as 1.0 ≠ 2.0
        (140_000.0, 2.8),      // live, just under the ceiling
        (150_000.0, 3.0),      // exactly ON the ramp ceiling (3.0 raw)
        (500_000.0, 3.0),      // clamped ceiling
        (80_000_000.0, 3.0),   // absurd zoom, still ceiling
    ];
    for (zoom, want) in scale_cases {
        let got = line_width_scale(zoom);
        assert!(
            (got - want).abs() <= 1e-5,
            "line_width_scale({zoom}) = {got}, expected {want} (clamp(zoom/50000, 0.5, 3.0))"
        );
    }

    // ── 2. themed width × scale → PIXELS, against literals ───────────────────────
    // clamp(style_width × scale, 1.0, 24.0), the arithmetic the shader mirrors.
    let px_scaled_cases: [(f32, f32, f32); 10] = [
        (3.0, 2.0, 6.0),    //  6.0 — live multiply, no clamp
        (1.2, 1.5, 1.8),    //  1.8 — live, no clamp
        (9.0, 0.75, 6.75),  //  6.75 — live shrink
        (3.0, 2.8, 8.4),    //  8.4 — live, just under the ceiling
        (0.6, 0.75, 1.0),   //  0.45 raw → pixel FLOOR
        (1.5, 0.5, 1.0),    //  0.75 raw → pixel FLOOR
        (2.0, 0.5, 1.0),    //  1.0 exactly ON the floor, unclamped
        (8.0, 3.0, 24.0),   // 24.0 exactly ON the ceiling, unclamped
        (9.0, 3.0, 24.0),   // 27.0 raw → pixel CEILING
        (12.0, 2.0, 24.0),  // 24.0 raw → the ceiling from the other side
    ];
    for (w, scale, want) in px_scaled_cases {
        let got = line_width_px_scaled(w, scale);
        assert!(
            (got - want).abs() <= 1e-5,
            "line_width_px_scaled({w}, {scale}) = {got} px, expected {want} px \
             (clamp(w×scale, {LINE_WIDTH_MIN_PX}, {LINE_WIDTH_MAX_PX}))"
        );
    }

    // ── 3. the composed front door: themed width @ zoom → PIXELS ─────────────────
    // The values a road ACTUALLY gets on screen. Independently hand-computed; this is
    // what the old tautology was supposed to be checking and never did.
    let px_cases: [(f32, f64, f32); 14] = [
        (3.0, 100_000.0, 6.0),        // scale 2.0 → 6.0 px
        (1.2, 75_000.0, 1.8),         // scale 1.5 → 1.8 px
        (9.0, 37_500.0, 6.75),        // scale 0.75 → 6.75 px
        (3.0, 140_000.0, 8.4),        // scale 2.8 → 8.4 px
        (2.0, 60_000.0, 2.4),         // scale 1.2 → 2.4 px
        (9.0, 50_000.0, 9.0),         // the reference zoom — the ONE identity row
        (3.0, 25_000.0, 1.5),         // ramp floor → 1.5 px
        (3.0, 64.0, 1.5),             // world view, ramp floor → 1.5 px
        (0.6, 37_500.0, 1.0),         // 0.45 raw → pixel FLOOR
        (1.2, 1_000.0, 1.0),          // 0.6 raw → pixel FLOOR
        (0.6, 80_000_000.0, 1.8),     // ramp ceiling, 1.8 px — no clamp
        (9.0, 150_000.0, 24.0),       // ramp ceiling, 27 raw → pixel CEILING
        (9.0, 500_000.0, 24.0),       // still the ceiling
        (9.0, 80_000_000.0, 24.0),    // still the ceiling
    ];
    for (w, zoom, want) in px_cases {
        let got = line_width_px(w, zoom);
        assert!(
            (got - want).abs() <= 1e-5,
            "line_width_px({w}, {zoom}) = {got} px, expected {want} px — the deck.gl width \
             formula clamp(w × clamp(zoom/{LINE_WIDTH_ZOOM_REF}, {LINE_WIDTH_SCALE_MIN}, \
             {LINE_WIDTH_SCALE_MAX}), {LINE_WIDTH_MIN_PX}, {LINE_WIDTH_MAX_PX}) has drifted"
        );
    }

    // ── 4. the properties the tables imply, checked on the whole sweep ───────────
    for &style_w in &[0.6f32, 1.2, 3.0, 9.0] {
        let mut prev = 0.0f32;
        for &zoom in &[64.0f64, 1_000.0, 25_000.0, 37_500.0, 50_000.0, 75_000.0, 100_000.0, 150_000.0, 5_000_000.0, 80_000_000.0]
        {
            let px = line_width_px(style_w, zoom);
            assert!(
                (LINE_WIDTH_MIN_PX..=LINE_WIDTH_MAX_PX).contains(&px),
                "width {px} px for style {style_w} at zoom {zoom} is outside the deck.gl pixel band \
                 [{LINE_WIDTH_MIN_PX}, {LINE_WIDTH_MAX_PX}]"
            );
            assert!(px >= prev - 1e-6, "width is monotone non-decreasing in zoom ({prev} → {px})");
            prev = px;
        }
        // …and it must genuinely MOVE across that sweep, not sit frozen at one value
        // (the historical `width × 1` bug drew every zoom the same).
        let (world, street) = (line_width_px(style_w, 64.0), line_width_px(style_w, 500_000.0));
        assert!(
            street > world * 1.5,
            "style {style_w}: the width must actually respond to zoom — world view {world} px vs \
             street view {street} px (a frozen width is the 'two scales' bug)"
        );
    }

    // A wide road at street zoom is genuinely thicker than a minor one (the hierarchy
    // survives the clamp — it isn't flattened to one width).
    let z = 500_000.0;
    assert!(line_width_px(9.0, z) > line_width_px(0.6, z), "the road hierarchy still reads after clamping");
}

/// **Render evidence + the declutter contract.** Draws the real city at four zooms and
/// asserts a street is not lettered many times over in one view: with the deck.gl-style
/// repeat-distance filter, the number of labels drawn stays modest and (the real
/// property) no single name is repeated more than a couple of times per frame — proven
/// via `visible_labels` staying under the pane budget while the map still letters roads.
#[test]
fn real_city_renders_decluttered_labels_at_every_zoom() {
    let geo = osm_tabs::load();
    eprintln!("osm2d_quality: source={:?} ways_2d={}", geo.source, geo.ways_2d.len());
    if geo.ways_2d.is_empty() {
        eprintln!("osm2d_quality: no ways → skip (no real data on this machine)");
        return;
    }
    let mut any_labelled = false;
    for (factor, tag) in [(1.0, "fit"), (4.0, "z4"), (16.0, "z16"), (64.0, "z64")] {
        let (img, st) = render_at(&geo, factor, tag);
        assert!(img.width() > 0 && img.height() > 0, "frame rendered");
        let labels = st["visible_labels"].as_u64().unwrap_or(0);
        // The pane's own cap is 48; the declutter must keep us well inside it rather
        // than saturating with one street's name repeated across the view.
        assert!(labels <= 48, "labels stay within the pane budget at {tag} (got {labels})");
        any_labelled |= labels > 0;
    }
    assert!(any_labelled, "the city letters at least some roads (the pass is not vacuously empty)");
}