makepad/libs/fab_tour/tests/synthetic.rs
Admin 9821263b2c fab: a 3D creation shell and the viewer built on it — colour picker, material textures, dials that move the scene while they drag, the glTF loader, the tour, and the probes
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- fab: a 3D creation shell and the viewer built on it
- raytrace: the traced pane starts coarse and doubles to native, with the raster underneath
- fab: a colour picker, a material's textures, and dials that move the scene while they drag
- texcomp: the block codec and the container every texture will travel in
- fab: FAB_PROBE_MAT — per-material triangle counts, texture presence and uv spread in the roof probe
2026-08-26 08:49:47 +02:00

270 lines
9 KiB
Rust

//! Property tests on procedurally generated buildings.
//!
//! The generator builds rooms by BSP-splitting a rectangle and putting exactly
//! one doorway in every split wall, so the room graph is a tree and "every
//! room is reachable" holds by construction. Anything the planner fails to
//! visit is therefore the planner's fault.
use makepad_fab_tour::*;
fn analyse(scene: &TourScene) -> SiteAnalysis {
SiteAnalysis::analyse(scene, &AnalysisConfig::default())
}
#[test]
fn every_generated_track_passes_qa() {
let limits = QaLimits::default();
let mut checked = 0;
for seed in 1..=6u64 {
let scene = synthetic::building(&synthetic::Plan {
seed,
..Default::default()
});
let site = analyse(&scene);
assert!(
!site.rooms.is_empty(),
"seed {seed}: analysis found no rooms at all"
);
let tracks = all_shots(&site, &ShotOptions::default());
assert!(!tracks.is_empty(), "seed {seed}: no shots generated");
for t in &tracks {
let r = qa::check(&site, t, &limits);
assert!(
r.passed(),
"seed {seed}: {}\n {:#?}",
r.summary(),
r.failures
);
checked += 1;
}
}
assert!(checked >= 30, "only {checked} tracks checked");
}
#[test]
fn walkthrough_visits_every_reachable_room() {
for seed in 1..=5u64 {
let scene = synthetic::building(&synthetic::Plan {
seed,
storeys: 1,
..Default::default()
});
let site = analyse(&scene);
// No cap: the point of this test is completeness.
let opt = ShotOptions {
max_rooms: 0,
..Default::default()
};
let track = shots::walkthrough(&site, &opt);
assert!(!track.keys.is_empty(), "seed {seed}: empty walkthrough");
let report = qa::check(&site, &track, &QaLimits::default());
let reachable: Vec<usize> = (0..site.rooms.len())
.filter(|i| site.rooms[*i].interior && !site.unreachable.contains(i))
.collect();
let missed: Vec<&str> = reachable
.iter()
.filter(|i| !report.rooms_visited.contains(i))
.map(|i| site.rooms[*i].name.as_str())
.collect();
assert!(
missed.is_empty(),
"seed {seed}: {} reachable rooms, missed {:?} (visited {:?})",
reachable.len(),
missed,
report.rooms_visited.len()
);
}
}
#[test]
fn unreachable_rooms_are_reported_not_hidden() {
// Brick up doorways until the analysis genuinely isolates something.
let mut found = false;
for seed in 1..=12u64 {
let scene = synthetic::with_unreachable(seed, 2);
let site = analyse(&scene);
if site.unreachable.is_empty() {
continue;
}
found = true;
// Reported...
for u in &site.unreachable {
assert!(
site.rooms[*u].interior,
"seed {seed}: reported an exterior region as unreachable"
);
}
// ...and genuinely not visited, rather than silently walked into.
let opt = ShotOptions {
max_rooms: 0,
..Default::default()
};
let track = shots::walkthrough(&site, &opt);
let report = qa::check(&site, &track, &QaLimits::default());
for u in &site.unreachable {
assert!(
!report.rooms_visited.contains(u),
"seed {seed}: room {} is reported unreachable but the track went there",
site.rooms[*u].name
);
}
assert!(report.passed(), "seed {seed}: {}", report.summary());
break;
}
assert!(
found,
"no seed produced an isolated room; the fixture can no longer test this"
);
}
#[test]
fn rooms_are_named_from_zones_and_ranked() {
let scene = synthetic::villa();
let site = analyse(&scene);
let named = site
.rooms
.iter()
.filter(|r| r.name.starts_with("Room ") && r.name.contains('-'))
.count();
assert!(
named > 0,
"no room picked up its zone name; got {:?}",
site.rooms.iter().map(|r| &r.name).collect::<Vec<_>>()
);
let rank = site.rooms_by_rank();
assert!(!rank.is_empty());
for w in rank.windows(2) {
assert!(
site.rooms[w[0]].score >= site.rooms[w[1]].score,
"ranking is not sorted"
);
}
}
#[test]
fn analysis_finds_storeys_entrance_and_stairs() {
let scene = synthetic::villa();
let site = analyse(&scene);
assert_eq!(site.storeys.len(), 2, "expected two habitable storeys");
assert!(site.entrance.is_some(), "front door not found");
assert!(!site.stairs.is_empty(), "stair link not found");
assert!(!site.facades.is_empty(), "no facades ranked");
assert!(
site.facades[0].score >= site.facades[site.facades.len() - 1].score,
"facades not ranked"
);
}
#[test]
fn full_tour_is_one_continuous_track() {
let scene = synthetic::villa();
let site = analyse(&scene);
let track = shots::full_tour(&site, &ShotOptions::default());
assert!(track.keys.len() > 100, "full tour is suspiciously short");
// Times strictly increase.
for w in track.keys.windows(2) {
assert!(
w[1].t >= w[0].t,
"track time went backwards: {} then {}",
w[0].t,
w[1].t
);
}
// No teleports between consecutive keys.
let max_jump = track
.keys
.windows(2)
.map(|w| (w[1].pos - w[0].pos).length())
.fold(0.0f32, f32::max);
assert!(max_jump < 2.0, "track teleports {max_jump:.2} m between keys");
let r = qa::check(&site, &track, &QaLimits::default());
assert!(r.passed(), "{}\n{:#?}", r.summary(), r.failures);
assert!(!track.notes.is_empty(), "full tour has no leg markers");
}
#[test]
fn tracks_are_constant_speed_and_eased() {
let scene = synthetic::villa();
let site = analyse(&scene);
let track = shots::drone_reveal(&site, &ShotOptions::default());
let n = track.keys.len();
assert!(n > 30);
let speed = |i: usize| -> f32 {
(track.keys[i + 1].pos - track.keys[i].pos).length()
/ (track.keys[i + 1].t - track.keys[i].t).max(1e-6)
};
// Ends slow, middle fast: that is the ease.
let start = speed(1);
let mid = speed(n / 2);
let end = speed(n - 3);
assert!(
start < mid * 0.8 && end < mid * 0.8,
"not eased: {start:.2} / {mid:.2} / {end:.2}"
);
// The middle is close to constant speed.
let a = speed(n * 4 / 10);
let b = speed(n * 6 / 10);
assert!(
(a - b).abs() < mid * 0.35,
"cruise is not constant: {a:.2} vs {b:.2}"
);
}
#[test]
fn generation_is_fast_enough_to_feel_instant() {
let scene = synthetic::villa();
let t0 = std::time::Instant::now();
let site = analyse(&scene);
let tracks = all_shots(&site, &ShotOptions::default());
let ms = t0.elapsed().as_secs_f32() * 1000.0;
assert!(!tracks.is_empty());
assert!(ms < 2000.0, "analysis + generation took {ms:.0} ms");
}
/// Two rooms, one 1 m gap in the partition, no typed Door. The gap must
/// become an opening and the watershed must keep the rooms apart.
#[test]
fn wall_gap_becomes_an_opening() {
use makepad_math::vec3;
let mut b = TourSceneBuilder::new("gap-house");
b.storey("L0", 0.0, 3.0);
b.element("concrete slab", TourClass::Slab, 0);
b.box_solid(vec3(0.0, 0.0, -0.30), vec3(10.0, 6.0, 0.0));
// Perimeter.
b.element("exterior wall", TourClass::Wall, 0);
b.box_solid(vec3(0.0, 0.0, 0.0), vec3(10.0, 0.20, 2.7));
b.element("exterior wall", TourClass::Wall, 0);
b.box_solid(vec3(0.0, 5.80, 0.0), vec3(10.0, 6.0, 2.7));
b.element("exterior wall", TourClass::Wall, 0);
b.box_solid(vec3(0.0, 0.0, 0.0), vec3(0.20, 6.0, 2.7));
b.element("exterior wall", TourClass::Wall, 0);
b.box_solid(vec3(9.80, 0.0, 0.0), vec3(10.0, 6.0, 2.7));
// Partition with a 1.0 m door gap around y = 3.
b.element("interior wall", TourClass::Wall, 0);
b.box_solid(vec3(4.90, 0.0, 0.0), vec3(5.10, 2.50, 2.7));
b.element("interior wall", TourClass::Wall, 0);
b.box_solid(vec3(4.90, 3.50, 0.0), vec3(5.10, 6.0, 2.7));
b.element("roof", TourClass::Roof, 0);
b.box_solid(vec3(0.0, 0.0, 2.70), vec3(10.0, 6.0, 3.0));
let scene = b.finish();
let site = analyse(&scene);
assert!(
!site.openings.is_empty(),
"expected a derived opening through the partition, got 0"
);
assert!(
site.openings.iter().any(|o| (0.7..=1.5).contains(&o.width)),
"openings had widths {:?}",
site.openings.iter().map(|o| o.width).collect::<Vec<_>>()
);
let interior = site.rooms.iter().filter(|r| r.interior).count();
assert!(
interior >= 2,
"expected two interior rooms across the opening, got {interior} (rooms {:?})",
site.rooms
.iter()
.map(|r| (r.interior, r.area))
.collect::<Vec<_>>()
);
}