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/flightsim_water.rs

//! **Reflective water — PNG blob oracle (the headline).** Below ~300 m altitude a
//! lake switches to the reflective animated water surface (Gerstner + Fresnel +
//! reflection + depth tint + foam). The render is invisible from a return value,
//! so this test:
//!   1. renders a town with a lake at GROUND altitude (water ON) vs an explicit
//!      water-OFF override, reads back both, and asserts the lake REGION differs
//!      (the water surface re-coloured it), then
//!   2. proves the **altitude LOD trigger**: a low camera (< 300 m) is water-active
//!      and a high camera (> 300 m) is not (the calibrated boundary, as data), and
//!   3. proves **Fresnel**: at a grazing (low) camera the water reads more like the
//!      reflection (brighter/sky) than at a top-down camera (more see-through).
//!
//! Saves the PNGs the agent looks at.
//!
//! Run: `cargo test -p facett-map3d --features wgpu,testmatrix --test flightsim_water -- --nocapture`

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

use egui_kittest::Harness;
use egui_kittest::wgpu::{WgpuTestRenderer, create_render_state, default_wgpu_setup};
use facett_core::Facet as _;
use facett_map::Way;
use facett_map3d::{Map3D, SunSky, ViewPreset};

/// Serialize the two wgpu render tests in this binary. Running both heavy
/// reflective-water renders concurrently on the same process' software GPU
/// adapter intermittently faults inside the driver (a SIGSEGV under parallel
/// `--test-threads`); single-threaded they always pass. Each test takes this
/// guard first so the GPU work never overlaps — no assertion is weakened, the
/// renders just run one at a time. (Poison from an unrelated panic is recovered.)
fn gpu_guard() -> std::sync::MutexGuard<'static, ()> {
    static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
    LOCK.lock().unwrap_or_else(|e| e.into_inner())
}

/// A lake (natural=water) + a ring of block buildings around it, ~1.2 km town.
fn town_with_lake() -> Vec<Way> {
    let mut ways = Vec::new();
    // The lake — a big central water polygon.
    let lake = Way::from_lonlat(
        1,
        &[
            (9.503, 47.103),
            (9.510, 47.103),
            (9.511, 47.107),
            (9.508, 47.110),
            (9.503, 47.109),
            (9.502, 47.106),
            (9.503, 47.103),
        ],
        &[("natural".to_string(), "water".to_string())],
        &facett_core::Theme::default(),
    )
    .unwrap();
    ways.push(lake);
    // A few block buildings around the shore (for the reflection to mirror).
    let spots = [(9.5005, 47.1005), (9.5125, 47.1015), (9.5135, 47.1085), (9.5015, 47.1095)];
    for (i, &(lon, lat)) in spots.iter().enumerate() {
        let b = 0.0009;
        let ring = [(lon, lat), (lon + b, lat), (lon + b, lat + b), (lon, lat + b), (lon, lat)];
        let tags =
            vec![("building".to_string(), "yes".to_string()), ("height".to_string(), "60".to_string())];
        ways.push(Way::from_lonlat(10 + i as i64, &ring, &tags, &facett_core::Theme::default()).unwrap());
    }
    ways
}

/// `water`: Some(true)=force on, Some(false)=force off, None=altitude-driven.
/// `dist`: camera dolly distance (smaller = lower altitude). `elev`: elevation.
fn scene(water: Option<bool>, dist: f32, elev: f32) -> Map3D {
    let sun = SunSky::from_angles(0.7, 0.45);
    let mut m = Map3D::from_ways("water", &town_with_lake(), 0.3)
        .with_gpu(true)
        .with_sun(sun)
        .with_shadows(0.7)
        .with_water_override(water);
    m.spin = false;
    m.set_clock(0.6);
    m.view_preset(ViewPreset::Oblique);
    m.cam.goal_distance = dist;
    m.cam.goal_elevation = elev;
    m.cam.goal_target = facett_map3d::V3::new(0.0, 0.02, 0.0);
    m.cam.settle();
    m
}

fn render(view: Map3D) -> image::RgbaImage {
    let rs = create_render_state(default_wgpu_setup(), Default::default());
    facett_map3d::gpu::install_renderer(&rs);
    let mut v = view;
    let mut harness = Harness::builder()
        .with_size(egui::vec2(640.0, 640.0))
        .renderer(WgpuTestRenderer::from_render_state(rs))
        .build_ui(move |ui| {
            facett_core::set_theme(ui.ctx(), facett_core::Theme::sci_fi());
            v.ui(ui);
        });
    harness.run_steps(4);
    harness.render().expect("wgpu render")
}

/// Count bright-teal reflective-water pixels + their mean **teal saturation** (g−r).
/// The count isolates the water surface from the fogged scene (see the gate below);
/// the g−r mean is the Fresnel cue: near top-down the water shows its teal refraction
/// colour (g≫r), at grazing angles the sky reflection neutralises it (g−r collapses).
fn watery_px(img: &image::RgbaImage) -> (usize, f64) {
    let mut n = 0;
    let mut sum = 0.0;
    for p in img.pixels() {
        let (r, g, b) = (p.0[0] as f64, p.0[1] as f64, p.0[2] as f64);
        // Bright TEAL reflective water: green & blue both high (sky reflection), blue
        // at least as high as green, clearly above red. (Recalibrated for the ACES
        // tonemap + aerial-perspective fog, which tinted the WHOLE scene bluish so the
        // old loose "b dominant" test caught the fogged ground too — the reflective
        // water surface is distinctly BRIGHTER/teal, so gate on brightness.)
        if b > 165.0 && g > 150.0 && b >= g && b > r + 45.0 {
            n += 1;
            sum += g - r; // teal saturation — the Fresnel-sensitive water-colour metric
        }
    }
    (n, if n > 0 { sum / n as f64 } else { 0.0 })
}

/// Fraction of pixels differing between two frames.
fn diff_fraction(a: &image::RgbaImage, b: &image::RgbaImage) -> f64 {
    let mut d = 0usize;
    for (pa, pb) in a.pixels().zip(b.pixels()) {
        let dd = (pa.0[0] as i32 - pb.0[0] as i32).abs()
            + (pa.0[1] as i32 - pb.0[1] as i32).abs()
            + (pa.0[2] as i32 - pb.0[2] as i32).abs();
        if dd > 20 {
            d += 1;
        }
    }
    d as f64 / (a.width() * a.height()) as f64
}

#[test]
fn reflective_water_renders_below_300m_and_differs_from_no_water() {
    let _gpu = gpu_guard();
    // Water ON (forced) vs OFF (forced), same low camera.
    let on = render(scene(Some(true), 2.4, 0.35));
    let off = render(scene(Some(false), 2.4, 0.35));
    image::DynamicImage::ImageRgba8(on.clone()).save("/tmp/map3d_water_on.png").ok();
    image::DynamicImage::ImageRgba8(off.clone()).save("/tmp/map3d_water_off.png").ok();

    let (on_blue, on_b) = watery_px(&on);
    let (off_blue, _off_b) = watery_px(&off);
    let diff = diff_fraction(&on, &off);
    eprintln!("EMIT-WATER on_blue={on_blue} off_blue={off_blue} on_teal={on_b:.1} diff={diff:.4}");

    // (1) The reflective water surface re-colours the lake region: markedly MORE
    // blue/teal water pixels with water on, and the frame differs over the lake.
    assert!(on_blue > off_blue + 3_000, "water ON paints the reflective lake ({on_blue} vs {off_blue})");
    assert!(diff > 0.02, "the water surface changes the frame vs no-water ({diff:.4})");

    // (2) ALTITUDE LOD TRIGGER (the 300 m boundary, as data). A low camera is
    // water-active; a high one is not — driven by the calibrated metre altitude.
    let low = scene(None, 1.0, 0.4); // close → low altitude → Ground tier
    let high = scene(None, 18.0, 0.7); // far → high altitude → above 300 m
    let low_alt = low.altitude_m();
    let high_alt = high.altitude_m();
    eprintln!(
        "EMIT-WATER-LOD low_alt_m={low_alt:.1} low_reflect={} low_active={} \
         high_alt_m={high_alt:.1} high_reflect={} high_active={}",
        low.reflective_water_active(),
        low.water_active(),
        high.reflective_water_active(),
        high.water_active()
    );
    // **THE 300 m BOUNDARY GATES THE REFLECTION PASS, NOT THE LAKE.**
    //
    // This leg used to read `assert!(!high.water_active())`, and `SceneFlags::water_on`
    // — which `gpu.rs` gates the whole water DRAW on — is fed from `water_active()`. So
    // the assertion pinned the defect as the contract: above 300 m the lakes were not
    // degraded, they were gone. MEASURED on the shipped korp-ui against the live server
    // (2026-08-04, Stockholm z12, ~1.6 km up): `water_triangles = 1084` beside
    // `water_active = false` in `$KORP_STATE`, and 0.00 % green and 0.00 % warm pixels
    // over the 3D pane's whole ground band.
    //
    // `LodTier`'s own docs say what the ladder meant — `Low` "animated normal-map (no
    // planar reflection pass)", `Mid` "flat Fresnel tint", `High` "flat colour". All
    // three describe water that is DRAWN. So the surface stays on at every altitude and
    // the expensive planar pass is what the boundary switches, which is what this now
    // asserts on both sides.
    assert!(low.reflective_water_active(), "below the boundary ({low_alt:.0} m) the REFLECTION pass runs");
    assert!(!high.reflective_water_active(), "above the boundary ({high_alt:.0} m) the reflection pass falls back");
    assert!(low.water_active(), "the lake is drawn below the boundary ({low_alt:.0} m)");
    assert!(
        high.water_active(),
        "the lake VANISHED at {high_alt:.0} m — the altitude tier is a quality ladder, not an \
         on/off switch for the water surface"
    );
    assert!(low_alt < 300.0 && high_alt > 300.0, "the trigger straddles the calibrated 300 m");

    #[cfg(feature = "testmatrix")]
    facett_core::testmatrix::emit(
        "facett-map3d",
        "reflective_water_below_300m",
        on_blue > off_blue + 3_000 && diff > 0.03 && low.water_active() && !high.water_active(),
        &format!(
            "blob=/tmp/map3d_water_on.png on_blue={on_blue} off_blue={off_blue} diff={diff:.4} low_alt_m={low_alt:.0} high_alt_m={high_alt:.0}"
        ),
    );
}

#[test]
fn water_fresnel_changes_balance_and_wasm_fallback_renders() {
    let _gpu = gpu_guard();
    // Top-down (Fresnel low → refraction/water-colour dominant) vs grazing (Fresnel
    // high → reflection dominant). The Schlick term arbitrates the blend, so the
    // water's mean COLOUR must measurably DIFFER between the two view angles — that
    // is the "looks like water" cue (mirror at glancing, see-through from above).
    let topdown = render(scene(Some(true), 2.2, 1.3)); // near top-down
    let grazing = render(scene(Some(true), 2.2, 0.12)); // low, grazing
    image::DynamicImage::ImageRgba8(grazing.clone()).save("/tmp/map3d_water_grazing.png").ok();
    image::DynamicImage::ImageRgba8(topdown.clone()).save("/tmp/map3d_water_topdown.png").ok();

    let (t_n, t_teal) = watery_px(&topdown);
    let (g_n, g_teal) = watery_px(&grazing);
    eprintln!("EMIT-WATER-FRESNEL topdown_teal={t_teal:.1} (n={t_n}) grazing_teal={g_teal:.1} (n={g_n})");
    assert!(t_n > 1_000 && g_n > 1_000, "both views show a real water region");
    // The Fresnel-arbitrated water colour differs by view angle: top-down shows the
    // teal refraction colour (g≫r), grazing shows the neutral sky reflection (g−r
    // collapses). A constant-tint plane would read identical. (Recalibrated for the
    // ACES/fog look: the teal-saturation shift is ~36 pts, far more robust than the
    // old ±2 luma; a real Fresnel term clears +8 easily.)
    assert!(
        (t_teal - g_teal).abs() > 8.0,
        "Fresnel shifts the water colour with view angle (teal {t_teal:.1} vs {g_teal:.1})"
    );

    // WASM-FALLBACK: planar reflection OFF (no second scene render) — water still
    // renders (Gerstner + Fresnel + depth + foam) with a SKY-gradient reflection.
    let mut wv = scene(Some(true), 2.4, 0.35).with_planar_reflection(false);
    wv.set_clock(0.6);
    let wimg = render(wv);
    image::DynamicImage::ImageRgba8(wimg.clone()).save("/tmp/map3d_water_wasm.png").ok();
    let (wblue, _wl) = watery_px(&wimg);
    eprintln!("EMIT-WATER-WASM blue={wblue}");
    assert!(wblue > 5_000, "sky-fallback water still paints the lake on the wasm path ({wblue} px)");

    #[cfg(feature = "testmatrix")]
    facett_core::testmatrix::emit(
        "facett-map3d",
        "water_fresnel_and_wasm_fallback",
        (t_teal - g_teal).abs() > 8.0 && wblue > 5_000,
        &format!("topdown_teal={t_teal:.1} grazing_teal={g_teal:.1} wasm_blue={wblue}"),
    );
}