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

nordisk/facett

facett-map3d/tests/partstep_sweep.rs

//! **MAP-TEST-SWEEP** — drive the FULL part-step render chain (near_clip → project
//! → cull → shade → depth_sort → scissor → composite) across the WHOLE camera pose
//! space: 360° azimuth × N zoom × M elevation. NOT one value into the black box —
//! you rotate 360° and zoom in many combinations and assert the oracle at EVERY
//! pose (the owner's 3D-test LAW, 2026-06-22).
//!
//! Where `chain_sweep.rs` measures only the RAW projection chain (f1→f2→f3) to find
//! WHERE a smear is born, this sweep runs the SHIPPING pipeline end-to-end
//! (`shade_faces_clip` → `scissor_report`) and proves the clip-bug oracle —
//! **`scissor.clipped_escapes == 0`** (nothing escapes the widget rect after the
//! scissor) — holds across the entire rotation/zoom grid, on a giant near-plane-
//! straddling city. Green matrix ⟺ every sub-part proven across the sweep.
//!
//! Run:    cargo test -p facett-map3d --test partstep_sweep -- --nocapture
//! Heavy:  cargo test -p facett-map3d --test partstep_sweep --features testmatrix -- --ignored
//!         (the full 360°×100-zoom grid; the default arm runs a 36×25 grid)

use egui::{Color32, Pos2, Rect};
use facett_core::Theme;
use facett_map::Way;
use facett_map3d::{clip_poly_to_rect, scissor_report, Map3D};

const CANVAS: f32 = 600.0;

fn building(id: i64, ring: &[(f64, f64)]) -> Way {
    let tags = vec![("building".to_string(), "yes".to_string())];
    Way::from_lonlat(id, ring, &tags, &Theme::default()).expect("a building way")
}

/// A GIANT self-calculated fixture: a 20×20 block city (400 footprints) whose
/// extent is large enough to straddle the near plane at street-level poses — the
/// exact condition that births the clip smear.
fn giant_city() -> Map3D {
    let mut ways = Vec::new();
    let mut id = 1;
    for r in 0..20 {
        for c in 0..20 {
            let bx = 17.70 + c as f64 * 0.0010;
            let by = 59.61 + r as f64 * 0.0010;
            ways.push(building(
                id,
                &[(bx, by), (bx + 0.0006, by), (bx + 0.0006, by + 0.0006), (bx, by + 0.0006), (bx, by)],
            ));
            id += 1;
        }
    }
    Map3D::from_geometry("giant", &ways)
}

/// The widget rect the scissor confines to (a centred square inside the canvas).
fn widget_rect() -> Rect {
    Rect::from_min_size(Pos2::new(0.0, 0.0), egui::vec2(CANVAS, CANVAS))
}

/// One pose: set the camera, run the shipping chain, return the per-step oracle
/// readings `(n_faces, ink_outside_rect_px, clipped_escapes)`.
fn run_pose(m: &mut Map3D, az_deg: f32, elev_deg: f32, dist: f32) -> (usize, f32, usize) {
    m.cam.azimuth = az_deg.to_radians();
    m.cam.goal_azimuth = az_deg.to_radians();
    m.cam.elevation = elev_deg.to_radians();
    m.cam.goal_elevation = elev_deg.to_radians();
    m.cam.distance = dist;
    m.cam.goal_distance = dist;
    let center = Pos2::new(CANVAS * 0.5, CANVAS * 0.5);
    let scale = CANVAS * 0.5;
    // near_clip = true → the shipping pipeline (near_clip → project → cull → shade,
    // with the screen-extent bound), depth-ordered tuples back.
    let mut faces = m.shade_faces_clip(center, scale, Color32::from_rgb(40, 80, 60), true);
    // depth_sort part-step: order back-to-front by the tuple depth.
    faces.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
    let (ink, escapes) = scissor_report(&faces, widget_rect());
    (faces.len(), ink, escapes)
}

/// The sweep core, parameterised by grid density so the default (fast) and heavy
/// (full 360×100) arms share one body.
fn sweep(az_step_deg: usize, zoom_steps: usize, label: &str) {
    let elevations = [5.0f32, 25.0, 55.0, 80.0];
    let dmin = 0.05f32;
    let dmax = 8.0f32;
    let rect = widget_rect();

    type Fixture = (&'static str, fn() -> Map3D);
    let fixtures: &[Fixture] =
        &[("giant_city", giant_city), ("rabbit", || Map3D::synthetic("rabbit"))];

    let mut poses = 0usize;
    let mut poses_with_faces = 0usize;
    let mut worst_ink = 0.0f32;
    let mut worst_pose = (String::new(), 0f32, 0f32, 0f32);
    let mut total_escapes = 0usize;

    for (fname, build) in fixtures {
        let mut m = build();
        m.spin = false;
        for ai in (0..360).step_by(az_step_deg) {
            let az = ai as f32;
            for zi in 0..zoom_steps {
                let t = zi as f32 / (zoom_steps.max(2) - 1) as f32;
                let dist = dmin + (dmax - dmin) * t;
                for &elev in &elevations {
                    let (n_faces, ink, escapes) = run_pose(&mut m, az, elev, dist);
                    poses += 1;
                    if n_faces > 0 {
                        poses_with_faces += 1;
                    }
                    total_escapes += escapes;
                    if ink > worst_ink {
                        worst_ink = ink;
                        worst_pose = ((*fname).to_string(), az, elev, dist);
                    }
                    // THE ORACLE: after the scissor, NO vertex escapes the rect — at
                    // EVERY pose in the rotation/zoom grid. This is the clip bug,
                    // proven across the whole space, not one camera.
                    assert_eq!(
                        escapes, 0,
                        "[{label}/{fname}] {escapes} vertices escaped the rect after scissor \
                         at az={az}° elev={elev}° dist={dist:.3} (raw ink_outside_rect={ink:.1}px)"
                    );
                    // Projection stays finite across the whole grid (no smear-to-eternity).
                    assert!(
                        ink.is_finite(),
                        "[{label}/{fname}] non-finite overshoot at az={az}° elev={elev}° dist={dist:.3}"
                    );
                }
            }
        }
    }

    // Coverage assertion: the sweep must actually EXERCISE the renderer — a large
    // fraction of poses must produce visible geometry (else the "green" is vacuous).
    let frac_lit = poses_with_faces as f64 / poses.max(1) as f64;
    eprintln!(
        "[{label}] poses={poses} with_faces={poses_with_faces} ({:.1}%) total_escapes={total_escapes} \
         worst_ink={worst_ink:.1}px @ {worst_pose:?}",
        frac_lit * 100.0
    );
    assert!(poses > 1000, "[{label}] the sweep must cover a real grid (poses={poses})");
    assert!(
        frac_lit > 0.5,
        "[{label}] over half the poses must render geometry (got {:.1}%)",
        frac_lit * 100.0
    );

    // Per-step aggregate emitters → one functional_status row per part-step, so the
    // matrix carries the sweep verdict per stage. (EMIT-DOCTRINE: the scissor stage
    // is the can't-observe-from-the-return-value pixel oracle; the rest assert above.)
    #[cfg(feature = "testmatrix")]
    {
        let ok = total_escapes == 0;
        facett_core::testmatrix::emit(
            "facett-map3d",
            "scissor",
            ok,
            &format!(
                "sweep={label} poses={poses} clipped_escapes={total_escapes} worst_ink_outside_rect={worst_ink:.1}px"
            ),
        );
        facett_core::testmatrix::emit(
            "facett-map3d",
            "composite",
            frac_lit > 0.5,
            &format!("sweep={label} poses_with_faces={poses_with_faces}/{poses}"),
        );
    }

    // FINAL-PICTURE PNG blob at the WORST-overshoot pose: rasterise the scissored
    // faces (CPU, no wgpu) so an agent can Read the picture and confirm the city is
    // confined to the rect even at the most stressful camera.
    let (fname, az, elev, dist) = worst_pose;
    if !fname.is_empty() {
        let mut m = if fname == "rabbit" { Map3D::synthetic("rabbit") } else { giant_city() };
        m.spin = false;
        m.cam.azimuth = az.to_radians();
        m.cam.goal_azimuth = az.to_radians();
        m.cam.elevation = elev.to_radians();
        m.cam.goal_elevation = elev.to_radians();
        m.cam.distance = dist;
        m.cam.goal_distance = dist;
        let center = Pos2::new(CANVAS * 0.5, CANVAS * 0.5);
        let faces = m.shade_faces_clip(center, CANVAS * 0.5, Color32::from_rgb(40, 80, 60), true);
        let (img, lit, outside) = rasterise(&faces, rect);
        let dir = warehouse_blob_dir();
        std::fs::create_dir_all(&dir).expect("blob dir");
        let png = dir.join(format!("map3d_partstep_sweep_worst_{label}.png"));
        img.save(&png).expect("write worst-pose PNG blob");
        eprintln!(
            "[{label}] worst-pose blob: {} (lit_px={lit}, px_outside_rect={outside})",
            png.display()
        );
        // The blob is non-blank AND wholly inside the rect — the picture proves it,
        // not just the counter.
        assert!(lit > 0, "[{label}] worst-pose picture is blank (lit_px=0)");
        assert_eq!(outside, 0, "[{label}] worst-pose picture leaked {outside}px outside the rect");
        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-map3d",
            "composite_blob",
            lit > 0 && outside == 0,
            &format!("blob={} lit_px={lit} outside={outside}", png.display()),
        );
    }
}

/// Minimal CPU triangle rasteriser: fills each face's rect-clipped polygon and
/// returns `(image, lit_px, px_outside_rect)`. Enough to prove non-blank +
/// contained without a GPU.
fn rasterise(faces: &[(f32, [Pos2; 3], Color32)], rect: Rect) -> (image::RgbaImage, usize, usize) {
    let w = CANVAS as u32;
    let h = CANVAS as u32;
    let mut img = image::RgbaImage::from_pixel(w, h, image::Rgba([10, 12, 16, 255]));
    for (_d, tri, col) in faces {
        let poly = clip_poly_to_rect(tri, rect);
        if poly.len() < 3 {
            continue;
        }
        // Fan-triangulate the convex clipped polygon and half-plane fill each tri.
        for k in 1..poly.len() - 1 {
            fill_tri(&mut img, [poly[0], poly[k], poly[k + 1]], *col);
        }
    }
    let mut lit = 0usize;
    let mut outside = 0usize;
    for y in 0..h {
        for x in 0..w {
            let p = img.get_pixel(x, y);
            if p.0 != [10, 12, 16, 255] {
                lit += 1;
                let fx = x as f32 + 0.5;
                let fy = y as f32 + 0.5;
                if fx < rect.left() - 0.5 || fx > rect.right() + 0.5 || fy < rect.top() - 0.5 || fy > rect.bottom() + 0.5 {
                    outside += 1;
                }
            }
        }
    }
    (img, lit, outside)
}

fn fill_tri(img: &mut image::RgbaImage, p: [Pos2; 3], col: Color32) {
    let (w, h) = (img.width() as f32, img.height() as f32);
    let minx = p.iter().map(|v| v.x).fold(f32::MAX, f32::min).max(0.0).floor() as u32;
    let maxx = p.iter().map(|v| v.x).fold(f32::MIN, f32::max).min(w - 1.0).ceil() as u32;
    let miny = p.iter().map(|v| v.y).fold(f32::MAX, f32::min).max(0.0).floor() as u32;
    let maxy = p.iter().map(|v| v.y).fold(f32::MIN, f32::max).min(h - 1.0).ceil() as u32;
    let area = edge(p[0], p[1], p[2]);
    if area.abs() < 1e-6 {
        return;
    }
    let rgba = image::Rgba([col.r(), col.g(), col.b(), 255]);
    for y in miny..=maxy.min(img.height() - 1) {
        for x in minx..=maxx.min(img.width() - 1) {
            let pt = Pos2::new(x as f32 + 0.5, y as f32 + 0.5);
            let w0 = edge(p[1], p[2], pt);
            let w1 = edge(p[2], p[0], pt);
            let w2 = edge(p[0], p[1], pt);
            let inside = (w0 >= 0.0 && w1 >= 0.0 && w2 >= 0.0) || (w0 <= 0.0 && w1 <= 0.0 && w2 <= 0.0);
            if inside {
                img.put_pixel(x, y, rgba);
            }
        }
    }
}

fn edge(a: Pos2, b: Pos2, c: Pos2) -> f32 {
    (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)
}

/// `$NORNIR_WAREHOUSE_ROOT/facett-map3d` if set (LAW: persist to warehouse on a fast
/// disk), else the system temp dir.
fn warehouse_blob_dir() -> std::path::PathBuf {
    if let Ok(root) = std::env::var("NORNIR_WAREHOUSE_ROOT") {
        std::path::Path::new(&root).join("facett-map3d")
    } else {
        std::env::temp_dir().join("facett-map3d")
    }
}

// ── the default arm: a 36° × 25-zoom × 4-elev grid (3600 poses/fixture) ──────────
#[test]
fn partstep_sweep_scissor_oracle_across_rotation_and_zoom() {
    sweep(10, 25, "default_36x25x4");
}

// ── the heavy arm: the FULL 360° × 100-zoom × 4-elev grid (28 800 poses/fixture) ─
// Heavy (#[ignore]) per the heavy-test LAW — opt in with `--ignored` / `--heavy`.
#[test]
#[ignore = "heavy: full 360x100 pose grid, run with --ignored or --heavy"]
fn partstep_sweep_full_360x100_heavy() {
    sweep(5, 100, "heavy_72x100x4");
}

// ── degenerate / adversarial geometry: must not panic, must not escape ───────────
#[test]
fn partstep_sweep_degenerate_geometry_no_escape_no_panic() {
    let rect = widget_rect();
    // Zero-area footprints (a degenerate "building" collapsed to a line) + a normal
    // one, so the mesh is non-empty but carries degenerate tris.
    let mut ways = vec![building(1, &[(17.70, 59.61), (17.7006, 59.61), (17.7006, 59.6106), (17.70, 59.6106), (17.70, 59.61)])];
    // a degenerate ring (all points identical) — exercises zero-area handling.
    ways.push(building(2, &[(17.71, 59.62), (17.71, 59.62), (17.71, 59.62), (17.71, 59.62)]));
    let mut m = Map3D::from_geometry("degen", &ways);
    m.spin = false;
    // Sweep including the pathological behind-the-eye / grazing poses.
    let mut checked = 0;
    for ai in (0..360).step_by(15) {
        for &elev in &[0.5f32, 5.0, 89.5] {
            for &dist in &[0.01f32, 0.05, 1.0, 50.0] {
                let (_n, ink, escapes) = run_pose(&mut m, ai as f32, elev, dist);
                assert_eq!(escapes, 0, "degenerate: escape at az={ai}° elev={elev}° dist={dist}");
                assert!(ink.is_finite(), "degenerate: non-finite ink at az={ai}° elev={elev}° dist={dist}");
                checked += 1;
            }
        }
    }
    assert!(checked > 200, "degenerate sweep ran (checked={checked})");
    let _ = rect;
}