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