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

nordisk/facett

facett-map/tests/fill_gpu_lane.rs

//! **The GPU FILL lane really draws, and the CPU painter really stopped** (only with
//! `--features wgpu`).
//!
//! The claim under test is a negative one — "a filled way is no longer touched per
//! frame" — and a frame drawn by the GPU is *meant* to look like a frame drawn by the
//! CPU painter. So a picture cannot be the guard here (LAW 2): the two failure modes
//! that matter, "the lane uploaded geometry and culled all of it away" and "both
//! lanes drew the same lake", are both invisible in a screenshot.
//!
//! What is asserted instead:
//!
//!  1. `DrawIndirectArgs::vertex_count` for the fill triangles and for the ring
//!     outlines — read back out of the very buffers `draw_indirect` reads, after the
//!     REAL `run_cull` dispatch. Applied output of the device, not a CPU `Vec::len()`.
//!  2. The count TRACKS the viewport, exactly, against the per-way spans the bake
//!     produced — so a cull that keeps everything, or keeps the wrong ways of the
//!     same total size, is red.
//!  3. `features_for_cpu` no longer emits a `Polygon` for a taken layer — the
//!     double-draw guard, and the one that proves the 7.99 ms of per-frame
//!     `lonlat.clone()` + Mercator actually left the frame.
//!  4. The geometry signature separates two way sets whose `osm_id` is **all zero**,
//!     which is what korp's tiled path serves (`seam-5`).
//!
//! Run: `cargo test -p facett-map --features wgpu --test fill_gpu_lane -- --nocapture`
#![cfg(feature = "wgpu")]

use facett_map::layer::{LatLon, MapFacet, MapTheme, MapView, WaysLayer};
use facett_map::{Way, mercator};
use facett_map::gpu::{GpuMapRenderer, build_gpu_fill_geometry, build_gpu_geometry, ways_origin};

/// The ONE GPU gate for this crate's device-backed tests (`tests/common/mod.rs`):
/// a missing adapter becomes a SKIP row in the matrix and a panic under CI, never a
/// silent `ok`.
mod common;

/// A square ring of `n` points per side, `w` wide, at `(lon, lat)` — a fixture whose
/// triangle count is a function of `n`, so four lakes of DIFFERENT `n` cannot be
/// confused for one another by a count alone.
fn lake(id: i64, lon: f64, lat: f64, w: f64, n: usize) -> Way {
    let mut r = Vec::new();
    let step = w / n as f64;
    for i in 0..n {
        r.push((lon + step * i as f64, lat));
    }
    for i in 0..n {
        r.push((lon + w, lat + step * i as f64));
    }
    for i in 0..n {
        r.push((lon + w - step * i as f64, lat + w));
    }
    for i in 0..n {
        r.push((lon, lat + w - step * i as f64));
    }
    Way::filled(id, "water", r, egui::Color32::from_rgb(0x6E, 0x9C, 0xC4), egui::Color32::from_rgb(0x30, 0x40, 0x55), 1.0)
}

/// Four lakes in well-separated 1°-wide lon bands, with DIFFERENT vertex counts, so
/// a viewport can select any prefix by x alone and the surviving count says WHICH.
fn banded_lakes() -> Vec<Way> {
    vec![lake(0, 9.0, 47.0, 0.4, 2), lake(0, 10.0, 47.0, 0.4, 3), lake(0, 11.0, 47.0, 0.4, 5), lake(0, 12.0, 47.0, 0.4, 8)]
}

/// The bake the live `MapFacet` performs: line geometry, then the fill payload off
/// the layer's own ear-clip cache. Returns the payload plus the origin it is in.
fn baked(ways: &[Way]) -> (facett_map::gpu::GpuGeometry, [f64; 2]) {
    let origin = ways_origin(ways.iter()).expect("non-empty fixture has an origin");
    let mut geo = build_gpu_geometry(ways.iter().map(|w| (w, w.style.lod as u32)), origin);
    // One cache built beside this ring set, exactly as `WaysLayer::new` does.
    let cache = facett_map::tessellate::FillTessCache::new(ways.len());
    geo.fills = build_gpu_fill_geometry(
        ways.iter().enumerate().filter_map(|(i, w)| {
            cache.tris(i, &w.lonlat, &w.holes).map(|t| (w, w.style.lod as u32, t))
        }),
        origin,
    );
    (geo, origin)
}

/// An origin-local viewport bbox spanning `[lon0, lon1]` at latitude ~47 — the space
/// the bake rebased every per-way bbox into.
fn viewport(lon0: f64, lon1: f64, origin: [f64; 2]) -> [f32; 4] {
    let (x0, y0) = mercator(lon0, 46.0);
    let (x1, y1) = mercator(lon1, 49.0);
    [(x0 - origin[0]) as f32, (y0.min(y1) - origin[1]) as f32, (x1 - origin[0]) as f32, (y0.max(y1) - origin[1]) as f32]
}

fn culled(
    r: &GpuMapRenderer,
    device: &wgpu::Device,
    queue: &wgpu::Queue,
    bbox: [f32; 4],
    lod: u32,
) -> (u32, u32) {
    let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("fill_cull_test") });
    r.run_cull(queue, &mut enc, bbox, lod, 0.0);
    queue.submit(Some(enc.finish()));
    device.poll(wgpu::PollType::wait_indefinitely()).ok();
    r.fill_draw_counts(device, queue)
}

/// **The proof that the fill lane is a lane.** The vertex counts the two fill draws
/// consume are computed on the device, track the viewport, and equal — exactly — the
/// spans of the lakes that should have survived.
#[test]
fn the_fill_cull_drives_both_indirect_draw_counts() {
    let ways = banded_lakes();
    let (geo, origin) = baked(&ways);

    assert_eq!(geo.fills.tri_meta.len(), 4, "one triangle span per lake");
    assert_eq!(geo.fills.outline_meta.len(), 4, "one outline span per lake");
    let tri_total: u32 = geo.fills.tri_meta.iter().map(|m| m.vert_count).sum();
    let out_total: u32 = geo.fills.outline_meta.iter().map(|m| m.vert_count).sum();
    assert_eq!(
        tri_total as usize,
        geo.fills.tri_vertices.len(),
        "the per-way triangle spans must tile the whole buffer with no gap or overlap"
    );
    assert_eq!(out_total as usize, geo.fills.outline_vertices.len(), "same for the outline spans");
    assert_eq!(tri_total % 3, 0, "a triangle list is a multiple of 3 vertices");
    // The outline stream became a QUAD triangle list with `mapgpu-1` (six vertices per
    // ring segment through the shared `LineExpander`), where it used to be a `LineList`
    // of two vertices per segment. Six is the number that separates a quad expansion
    // from both the old hairline pair and a bare triangle.
    assert_eq!(out_total % 6, 0, "a quad-expanded outline is a multiple of 6 vertices");

    // Non-vacuous: the lakes differ in size, so a cull that keeps the WRONG subset
    // cannot land on the right total by accident.
    let sizes: Vec<u32> = geo.fills.tri_meta.iter().map(|m| m.vert_count).collect();
    assert!(
        sizes.iter().collect::<std::collections::HashSet<_>>().len() == 4,
        "every lake must have a DIFFERENT triangle count or the totals prove nothing: {sizes:?}"
    );

    let Some((device, queue)) = common::device_or_skip("the_fill_cull_drives_both_indirect_draw_counts", "the fill lane's two indirect draw counts")
    else {
        return;
    };
    let mut renderer = GpuMapRenderer::new(&device, wgpu::TextureFormat::Rgba8Unorm);
    renderer.upload_for_test(&device, &queue, &geo);
    assert!(renderer.fill_chunk_count() >= 1, "the fill lane uploaded at least one cull chunk");
    assert!(renderer.outline_chunk_count() >= 1, "the outline lane uploaded at least one cull chunk");

    // Everything in view: both counts must be the WHOLE bake.
    let all = culled(&renderer, &device, &queue, viewport(8.0, 13.5, origin), 2);
    assert_eq!(all, (tri_total, out_total), "a viewport over every lake must draw every baked vertex");

    // A prefix: the first two lakes only. Exact against their own spans.
    let want_tri: u32 = geo.fills.tri_meta[..2].iter().map(|m| m.vert_count).sum();
    let want_out: u32 = geo.fills.outline_meta[..2].iter().map(|m| m.vert_count).sum();
    let two = culled(&renderer, &device, &queue, viewport(8.0, 10.9, origin), 2);
    assert_eq!(
        two,
        (want_tri, want_out),
        "the cull must keep exactly lakes 0..2 — got {two:?}, whole bake is {tri_total} tri / {out_total} outline"
    );
    assert!(two.0 < all.0 && two.1 < all.1, "the prefix must be a REAL subset, or the frustum test does nothing");

    // Nothing in view: both counts fall to zero. Without this arm a cull that keeps
    // everything passes the two above.
    let none = culled(&renderer, &device, &queue, viewport(20.0, 21.0, origin), 2);
    assert_eq!(none, (0, 0), "a viewport over open sea must draw nothing");
}

/// **The double-draw guard.** With the fill lane on, `features_for_cpu` must stop
/// emitting the polygon — that omission IS the 7.99 ms (of a 10.08 ms fills-only
/// frame) that was `w.lonlat.clone()` plus a `tan` and a `ln` per vertex.
///
/// It reads the REAL widget through its real lane plan, so it is red both ways: a
/// gate that never fires (the CPU keeps drawing, and the map draws every lake twice)
/// and a gate that fires when the GPU did NOT take the layer (the lakes vanish).
#[test]
fn a_taken_layer_stops_handing_its_fills_to_the_cpu_painter() {
    let ways = banded_lakes();
    let view = MapView::new(LatLon::new(47.2, 10.5), 6.0, (800.0, 600.0));
    let mut f = MapFacet::new("m", view, MapTheme::cartographic(&facett_core::Theme::default(), true));
    f.push_layer(Box::new(WaysLayer::new("water", ways.clone())));

    // WITHOUT a renderer installed the lane plan is `CpuOnly::NoRenderer`, so every
    // lake must still reach the painter. This is the arm that makes the assertion
    // below mean something: it pins that the fixture DOES produce polygons.
    let polys = f.enabled_features().iter().filter(|x| matches!(x.geom, facett_map::layer::DrawGeom::Polygon { .. })).count();
    assert_eq!(polys, 4, "the CPU painter draws all four lakes when no GPU lane took them");

    // Now the taken-layer path, driven through the SAME entry point `ui` uses.
    let cpu = f.features_for_cpu_for_test(&[0], true, true);
    let taken = cpu.iter().filter(|x| matches!(x.geom, facett_map::layer::DrawGeom::Polygon { .. })).count();
    assert_eq!(taken, 0, "a layer the fill lane took must hand ZERO polygons to the CPU painter");

    // …and a layer taken for its LINES only must still hand its fills over, or a
    // pane with an uncached ring loses its lakes entirely.
    let lines_only = f.features_for_cpu_for_test(&[0], true, false);
    let kept = lines_only.iter().filter(|x| matches!(x.geom, facett_map::layer::DrawGeom::Polygon { .. })).count();
    assert_eq!(kept, 4, "a lane that took only the LINES must leave every fill on the CPU");
}

/// **`seam-5`: the signature must separate two way sets whose ids are all zero.**
///
/// It folded `w.osm_id`, promising in its own comment that "two different way SETS of
/// the same length differ here". MEASURED 2026-08-03: every tile under
/// `~/.korp/work/sweden/tiles` (28 974 files, 45 sampled across all three zooms) has
/// the schema `geometry / tags / bbox` and **no `id` column at all**, so korp's
/// reader fills `0` for every row and that term was the constant `0`.
///
/// The two sets below are that failure exactly: same layer id, same way count, same
/// total vertex count, all ids `0` — and different geometry. Under the old fold they
/// hash identically, `geometry: None` goes to the device and VRAM keeps the previous
/// viewport's lakes.
#[test]
fn the_geometry_signature_separates_two_zero_id_way_sets() {
    fn facet_of(ways: Vec<Way>) -> MapFacet {
        let view = MapView::new(LatLon::new(47.2, 10.5), 6.0, (800.0, 600.0));
        let mut f = MapFacet::new("m", view, MapTheme::cartographic(&facett_core::Theme::default(), true));
        f.push_layer(Box::new(WaysLayer::new("water", ways)));
        f
    }
    // Same count, same per-way vertex counts, ALL ids zero — only the coordinates
    // differ, which is what a pan across a tiled basemap produces.
    let a = vec![lake(0, 9.0, 47.0, 0.4, 4), lake(0, 10.0, 47.0, 0.4, 6)];
    let b = vec![lake(0, 14.0, 55.0, 0.4, 4), lake(0, 15.0, 55.0, 0.4, 6)];
    assert_eq!(a.len(), b.len());
    assert_eq!(
        a.iter().map(|w| w.lonlat.len()).sum::<usize>(),
        b.iter().map(|w| w.lonlat.len()).sum::<usize>(),
        "the sets MUST agree on count and Σvertices, or this proves nothing about the id term"
    );
    assert!(a.iter().chain(&b).all(|w| w.osm_id == 0), "the fixture must reproduce the tiled path's all-zero ids");

    // A shared origin, so the origin term cannot be what separates them — the
    // signature folds `origin` too, and on the real tiled path the two viewports
    // usually DO differ there. Holding it fixed isolates the way-set term, which is
    // the term that claimed to discriminate and did not.
    let origin = [0.0_f64, 0.0];
    let (fa, fb) = (facet_of(a), facet_of(b));
    let (sa, sb) = (fa.gpu_geometry_signature_for_test(&[0], origin), fb.gpu_geometry_signature_for_test(&[0], origin));
    assert_ne!(sa, sb, "two different zero-id way sets of equal length and Σvertices must NOT share a signature");
    assert_ne!(sa, 0, "0 is the nothing-uploaded sentinel and must never be a real signature");
    assert_ne!(sb, 0);

    // …and the same set must be stable, or every frame re-uploads the country.
    let again = facet_of(vec![lake(0, 9.0, 47.0, 0.4, 4), lake(0, 10.0, 47.0, 0.4, 6)]);
    assert_eq!(sa, again.gpu_geometry_signature_for_test(&[0], origin), "an unchanged way set keeps its signature");
}