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_outline_aa.rs

//! **A filled area's ring outline must be anti-aliased on the GPU lane, exactly as it
//! is on the CPU painter** (stinky `mapgpu-1`; only with `--features wgpu`).
//!
//! # The defect this file was written against
//!
//! The GPU fill lane strokes a filled way's rings through the raw
//! `PrimitiveTopology::LineList` `draw_pipeline` — a hard 1 px hairline with no
//! coverage ramp — while `tessellate::paint_filled_way` strokes the SAME rings with
//! `egui::Shape::line`, which epaint feathers over roughly two pixels. Two writers of
//! one width (`f.width.max(0.5)` on the CPU, an implicit `1.0` on the GPU), and only
//! one of them anti-aliases. MEASURED 2026-08-03 on a real 1600x1000 window: the GPU
//! pane's non-background coverage was 19 507 px against the CPU pane's 17 419 px,
//! **-10.7 %**, and the whole loss sat in small areas whose outline is most of what
//! they are.
//!
//! # What is asserted, and why it is pixels
//!
//! A count of vertices or a chunk length cannot see this: both lanes emit the same
//! rings. Only the framebuffer knows whether an edge has a ramp. So the two claims are
//!
//!  1. **the ramp exists** — the GPU lane's edge must carry intermediate colours
//!     between the stroke and what is on either side of it. An aliased line produces
//!     the stroke colour and nothing between; a feathered one produces a spread.
//!  2. **the ink matches** — the GPU pane's non-background coverage must land within a
//!     few percent of the CPU pane's on the same fixture at the same camera, rather
//!     than the -10.7 % that was filed.
//!
//! Both panes are rendered through the same egui-wgpu rasteriser on the same device;
//! the ONLY difference is whether `GpuMapRenderer` is installed, which is precisely the
//! switch that moves a filled way from the CPU painter to the lane.
//!
//! # SEEN RED, on the shipped code, before the fix
//!
//! MEASURED on oden 2026-08-22 (RTX 4090 [discrete/Vulkan], the adapter banner is
//! printed by the run), at facett `e6084d7d`:
//!
//! ```text
//! LOOK: ink cpu 56965 / gpu 55190 of 360000 (0.969x) | distinct colours cpu 80 / gpu 5
//! ```
//!
//! **Five colours in the whole 600x600 GPU pane** — background, fill, stroke and two
//! blends off the CPU-painted furniture around the map. That is what "no coverage ramp"
//! looks like as a number, and it is why the ramp claim below is asserted on colours
//! rather than on a diff: the two panes are MEANT to look alike, so a picture is not the
//! guard (LAW 2). With the fix, on the same box and fixture:
//!
//! ```text
//! LOOK: ink cpu 56965 / gpu 58669 (1.030x) | mass cpu 11787383 / gpu 12260364 (1.040x)
//!       | distinct colours cpu 80 / gpu 343
//! ```
//!
//! The ink went from **3.1 % short** to **3.0 % over**, and the ramp exists.
#![cfg(feature = "wgpu")]

use facett_map::layer::{LatLon, MapFacet, MapTheme, MapView, WaysLayer};
use facett_map::Way;

mod common;

const CANVAS: egui::Vec2 = egui::vec2(600.0, 600.0);

// **The one-device-at-a-time turn lives in `common`** — `common/one_gpu.rs`, reached as
// `common::one_gpu` because this file already says `mod common;`. Declaring a second
// `#[path]` copy at the crate root of this test binary would compile two DIFFERENT
// statics out of one file, and two mutexes over one device serialise nothing.

/// A closed square ring at `(lon, lat)` of half-width `w`, tagged as water, so it is
/// classified `area` and lands in the fill lane with a stroke in the palette's water
/// outline hue.
fn lake(id: i64, lon: f64, lat: f64, w: f64) -> Way {
    let r = vec![
        (lon - w, lat - w),
        (lon + w, lat - w),
        (lon + w, lat + w),
        (lon - w, lat + w),
        (lon - w, lat - w),
    ];
    let tags = [("natural".to_string(), "water".to_string())];
    Way::from_lonlat_with(id, &r, &tags, &facett_map::MapPalette::cartographic(false))
        .expect("a 5-vertex closed ring is a way")
}

/// A grid of small lakes — the shape the -10.7 % was measured on: many areas whose
/// OUTLINE is most of what they are. Each is ~0.0016 deg across, which at the camera
/// below is well over the 3 px minimum-extent gate (`kalm-3`) so nothing is dropped,
/// but small enough that its edge dominates its own pixel budget.
fn lake_grid() -> Vec<Way> {
    let mut v = Vec::new();
    let mut id = 1;
    for i in 0..7 {
        for j in 0..7 {
            let lon = 18.050 + 0.0040 * f64::from(i);
            let lat = 59.320 + 0.0022 * f64::from(j);
            v.push(lake(id, lon, lat, 0.0008));
            id += 1;
        }
    }
    v
}

fn facet(ways: Vec<Way>) -> MapFacet {
    let view = MapView::new(LatLon::new(59.3272, 18.0620), 14.0, (CANVAS.x, CANVAS.y));
    MapFacet::new("fill-outline-aa", view, MapTheme::default())
        .with_layer(Box::new(WaysLayer::new("osm-ways", ways)))
}

/// Render through the plain kittest wgpu backend — **no `GpuMapRenderer` installed**,
/// so `MapFacet` paints every filled way on the CPU through `paint_filled_way`.
fn render_cpu(mut f: MapFacet) -> image::RgbaImage {
    let _turn = common::one_gpu::hold();
    let mut h = egui_kittest::Harness::builder().with_size(CANVAS).build_ui(move |ui| f.ui(ui));
    let mut img = None;
    for _ in 0..3 {
        img = Some(h.render().expect("the wgpu backend produced a frame"));
        h.step();
    }
    img.expect("at least one frame")
}

/// Render with `GpuMapRenderer` installed on facett's own adapter policy — the lane.
fn render_gpu(mut f: MapFacet) -> image::RgbaImage {
    let _turn = common::one_gpu::hold();
    let setup = facett_core::render::gpu::adapter_wgpu::facett_wgpu_options().wgpu_setup;
    let rs = egui_kittest::wgpu::create_render_state(setup, Default::default());
    facett_map::gpu::install_renderer(&rs);
    let mut h = egui_kittest::Harness::builder()
        .with_size(CANVAS)
        .renderer(egui_kittest::wgpu::WgpuTestRenderer::from_render_state(rs))
        .build_ui(move |ui| f.ui(ui));
    let mut img = None;
    for _ in 0..3 {
        img = Some(h.render().expect("the wgpu backend produced a frame"));
        h.step();
    }
    img.expect("at least one frame")
}

/// The modal (background) colour, quantised to 5 bits a channel.
fn modal(img: &image::RgbaImage) -> (i32, i32, i32) {
    let mut hist = std::collections::HashMap::<u32, u32>::new();
    for p in img.pixels() {
        let k = ((u32::from(p.0[0]) >> 3) << 10) | ((u32::from(p.0[1]) >> 3) << 5) | (u32::from(p.0[2]) >> 3);
        *hist.entry(k).or_default() += 1;
    }
    let m = *hist.iter().max_by_key(|(_, c)| **c).map(|(k, _)| k).expect("a non-empty image");
    ((((m >> 10) & 0x1f) as i32), (((m >> 5) & 0x1f) as i32), ((m & 0x1f) as i32))
}

/// Pixels that are not the background — the same measure `render_proof.rs` uses.
fn ink(img: &image::RgbaImage) -> usize {
    let (mr, mg, mb) = modal(img);
    img.pixels()
        .filter(|p| {
            let (r, g, b) = ((p.0[0] >> 3) as i32, (p.0[1] >> 3) as i32, (p.0[2] >> 3) as i32);
            (r - mr).abs() + (g - mg).abs() + (b - mb).abs() > 3
        })
        .count()
}

/// **Ink MASS** — the summed per-pixel distance from the background, not the count of
/// pixels that differ from it. A count treats a faint AA tail as full ink, so two
/// anti-aliasers with different ramp WIDTHS read as different amounts of drawing even
/// when they lay down the same total coverage. The mass is what a viewer sees.
fn ink_mass(img: &image::RgbaImage) -> u64 {
    let (mr, mg, mb) = modal(img);
    let (mr, mg, mb) = (mr << 3, mg << 3, mb << 3);
    img.pixels()
        .map(|p| {
            let d = (i32::from(p.0[0]) - mr).abs() + (i32::from(p.0[1]) - mg).abs() + (i32::from(p.0[2]) - mb).abs();
            d as u64
        })
        .sum()
}

/// **The coverage ramp.** How many DISTINCT full-precision colours the image holds.
/// An anti-aliased edge blends stroke into fill and stroke into background, so it
/// manufactures a spread of intermediate colours; an aliased `LineList` edge has
/// exactly three colours in the whole pane (background, fill, stroke).
fn distinct_colours(img: &image::RgbaImage) -> usize {
    img.pixels().map(|p| [p.0[0], p.0[1], p.0[2]]).collect::<std::collections::HashSet<_>>().len()
}

fn save(img: &image::RgbaImage, name: &str) {
    let path = format!("{}/scratch/cargo/fill_outline_aa_{name}.png", std::env::var("HOME").unwrap_or_default());
    let _ = image::DynamicImage::ImageRgba8(img.clone()).save(&path);
    eprintln!("LOOK: saved {path} ({}x{})", img.width(), img.height());
}

#[test]
fn the_gpu_fill_outline_is_anti_aliased_like_the_cpu_stroke() {
    if common::headless_device("fill_outline_aa").is_none() {
        return;
    }

    let cpu = render_cpu(facet(lake_grid()));
    let gpu = render_gpu(facet(lake_grid()));
    save(&cpu, "cpu");
    save(&gpu, "gpu");

    let (ic, ig) = (ink(&cpu), ink(&gpu));
    let (cc, cg) = (distinct_colours(&cpu), distinct_colours(&gpu));
    let (mc, mg) = (ink_mass(&cpu), ink_mass(&gpu));
    let total = (cpu.width() * cpu.height()) as usize;
    eprintln!(
        "LOOK: ink cpu {ic} / gpu {ig} of {total} ({:.3}x) | mass cpu {mc} / gpu {mg} ({:.3}x) | \
         distinct colours cpu {cc} / gpu {cg}",
        ig as f64 / ic as f64,
        mg as f64 / mc as f64
    );

    // NON-VACUOUS: both panes must actually have drawn the grid, or every ratio below
    // is a comparison of two empty images.
    assert!(ic > total / 100, "the CPU pane drew almost nothing: {ic} of {total} px");
    assert!(ig > total / 100, "the GPU pane drew almost nothing: {ig} of {total} px");

    assert!(
        cg >= cc / 2,
        "the GPU lane's edges carry {cg} distinct colours against the CPU painter's {cc} — \
         an aliased hairline has no coverage ramp, which is stinky mapgpu-1"
    );
    let ratio = ig as f64 / ic as f64;
    assert!(
        (0.93..=1.07).contains(&ratio),
        "the GPU pane's ink is {ratio:.3}x the CPU pane's ({ig} vs {ic} of {total} px); \
         the filed defect was -10.7 %"
    );
}