facett-map3d/tests/flightsim_phase1.rs
//! **Phase 1 flight-sim lighting — PNG blob oracle.** The Phase-1 quality jump is
//! sun + PBR + sky + height-fog in the GPU `map3d.wgsl` shader. The render is
//! invisible from a return value, so this test:
//! 1. renders the SAME extruded block town through the GPU PBR path under two
//! different **sun angles** (noon vs golden hour), reads back both frames, and
//! 2. asserts the frames DIFFER (the sun angle actually re-shades the faces) and
//! that each frame has a real lit gradient (directional sun: bright sun-facing
//! faces + darker faces, not one flat colour), then
//! 3. saves both PNGs the agent looks at.
//!
//! Run: `cargo test -p facett-map3d --features wgpu,testmatrix --test flightsim_phase1 -- --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};
/// A small grid of block buildings (the HARD CONSTRAINT: buildings are blocks; the
/// quality is light, not geometry). ~1.5 km town so the calibration is realistic.
fn town() -> Vec<Way> {
let (lon0, lat0) = (9.50_f64, 47.10_f64);
let step = 0.0025;
let bsz = 0.0011;
let mut ways = Vec::new();
let mut id = 1i64;
for r in 0..4 {
for c in 0..4 {
let bx = lon0 + c as f64 * step;
let by = lat0 + r as f64 * step;
let ring = [(bx, by), (bx + bsz, by), (bx + bsz, by + bsz), (bx, by + bsz), (bx, by)];
let tags = vec![
("building".to_string(), "yes".to_string()),
("height".to_string(), format!("{}", 12 + (r * 4 + c) % 5 * 8)),
];
ways.push(Way::from_lonlat(id, &ring, &tags, &facett_core::Theme::default()).unwrap());
id += 1;
}
}
ways
}
fn scene(sun: SunSky) -> Map3D {
// This oracle isolates the PBR **sun + sky-ambient + height-fog** shading ON THE
// GEOMETRY (its mean-luma metric averages the lit faces). The T3.2 skybox is a
// separate concern (its own proof: `sky_proof.rs`); leaving it on would fill the
// formerly near-black background with a bright analytic sky that dominates the
// average and masks the sun-angle luma delta. Disable it so this stays a clean
// geometry-shading oracle.
let mut m =
Map3D::from_ways("phase1", &town(), 0.3).with_gpu(true).with_sun(sun).with_sky(false);
m.spin = false;
m.set_clock(0.0);
m.view_preset(ViewPreset::Oblique);
m.cam.goal_distance = 2.6;
m.cam.goal_target = facett_map3d::V3::new(0.0, 0.05, 0.0);
m.cam.settle();
m
}
fn render(sun: SunSky) -> image::RgbaImage {
let rs = create_render_state(default_wgpu_setup(), Default::default());
facett_map3d::gpu::install_renderer(&rs);
let mut view = scene(sun);
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());
view.ui(ui);
});
harness.run_steps(4);
harness.render().expect("wgpu render")
}
/// Count distinct non-background shade buckets + mean luminance.
fn shades_and_luma(img: &image::RgbaImage) -> (usize, f64) {
let mut set = std::collections::HashSet::<u32>::new();
let mut sum = 0.0;
let mut n = 0u64;
for p in img.pixels() {
let (r, g, b) = (p.0[0], p.0[1], p.0[2]);
// Skip near-black background.
if (r as u32 + g as u32 + b as u32) < 24 {
continue;
}
set.insert(((r as u32 >> 3) << 10) | ((g as u32 >> 3) << 5) | (b as u32 >> 3));
sum += 0.2126 * r as f64 + 0.7152 * g as f64 + 0.0722 * b as f64;
n += 1;
}
(set.len(), if n > 0 { sum / n as f64 } else { 0.0 })
}
/// Fraction of pixels that differ between two frames (any channel > thresh).
fn frame_diff_fraction(a: &image::RgbaImage, b: &image::RgbaImage) -> f64 {
let mut diff = 0usize;
let total = (a.width() * a.height()) as usize;
for (pa, pb) in a.pixels().zip(b.pixels()) {
let d = (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 d > 18 {
diff += 1;
}
}
diff as f64 / total as f64
}
#[test]
fn phase1_sun_pbr_sky_fog_shades_and_reacts_to_sun_angle() {
let noon = render(SunSky::noon());
let gold = render(SunSky::golden_hour());
image::DynamicImage::ImageRgba8(noon.clone()).save("/tmp/map3d_phase1_noon.png").ok();
image::DynamicImage::ImageRgba8(gold.clone()).save("/tmp/map3d_phase1_golden.png").ok();
let (noon_shades, noon_luma) = shades_and_luma(&noon);
let (gold_shades, gold_luma) = shades_and_luma(&gold);
let diff = frame_diff_fraction(&noon, &gold);
eprintln!(
"EMIT-PHASE1 noon_shades={noon_shades} noon_luma={noon_luma:.1} gold_shades={gold_shades} gold_luma={gold_luma:.1} sun_diff={diff:.4}"
);
// (1) Directional sun + PBR: a real lit GRADIENT — many distinct shades, not one
// flat-shaded colour. The old single-pass `abs(dot)` gave few; PBR + sky ambient
// + height fog spreads luminance across faces.
assert!(noon_shades >= 12, "PBR/sun yields a lit gradient (got {noon_shades} shades)");
assert!(noon_luma > 5.0, "the scene is actually lit (mean luma {noon_luma:.1})");
// (2) The sun ANGLE matters: noon vs golden-hour re-shade the same geometry, so a
// healthy fraction of pixels differ. A scene that ignored the sun would be ~identical.
assert!(diff > 0.02, "changing the sun angle re-shades the city (diff {diff:.4})");
// Golden hour (warm, low) is overall warmer/different luma than noon.
assert!((noon_luma - gold_luma).abs() > 1.0, "the two suns produce different overall light");
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-map3d",
"phase1_sun_pbr_sky_fog",
noon_shades >= 12 && noon_luma > 5.0 && diff > 0.02,
&format!(
"blob=/tmp/map3d_phase1_noon.png noon_shades={noon_shades} noon_luma={noon_luma:.1} sun_diff={diff:.4}"
),
);
}