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