MONKEY (daylight): district window split (oversized EXT batches become per-pane apertures), sky rooms include EXT-class windows, lamp occlusion scan, B1 hill scenarios

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-25 05:28:05 +01:00
co-authored by Claude Opus 5.5
parent 310bed8941
commit 1cdbf2aabf
4 changed files with 315 additions and 46 deletions
+12 -2
View File
@@ -1390,10 +1390,10 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
name: "daylight-sw-cathedral", map: Some(MAP_AZEROTH),
eye: [-8556.0, 826.0, 109.0], look: [-8515.0, 862.0, 112.0], minute: 720, ui: None,
},
// A Trade District shop (group g123, one exterior portal).
// A Trade District house (group g68 NEH02, one exterior portal).
Scenario {
name: "daylight-sw-shop", map: Some(MAP_AZEROTH),
eye: [-8776.0, 722.0, 101.5], look: [-8764.0, 711.0, 100.2], minute: 720, ui: None,
eye: [-8799.0, 696.0, 104.3], look: [-8786.0, 707.0, 103.0], minute: 720, ui: None,
},
// Ironforge: the Great Forge hall (g64, no opening to the sky) and the gate hall (g7, the
// city's one exterior portal).
@@ -1409,6 +1409,16 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
Scenario {
name: "daylight-torch-goldshire", map: Some(MAP_AZEROTH),
eye: [-9460.0, 70.0, 58.0], look: [-9450.0, 20.0, 61.0], minute: 0, ui: None,
}, // Two Elwynn road lampposts whose own light the terrain blocks most (the census's
// `lamp_terrain_occlusion_scan`: 18 % and 16 % of the ground within 25 yd), at midnight, framed
// across the lamp toward the blocked side.
Scenario {
name: "daylight-torch-hill-a", map: Some(MAP_AZEROTH),
eye: [-9314.8, 134.1, 70.0], look: [-9320.4, 165.6, 64.0], minute: 0, ui: None,
},
Scenario {
name: "daylight-torch-hill-b", map: Some(MAP_AZEROTH),
eye: [-9163.1, 181.1, 77.0], look: [-9147.1, 153.4, 71.0], minute: 0, ui: None,
},
];
+177 -32
View File
@@ -315,12 +315,20 @@ const DISTRICT_SKY_HOPS: u8 = 1;
/// pool dies half-way into a 40 yd hall.
///
/// The rule is the portal graph's, not a box test: a room is SKY-CONNECTED when it authors an
/// exterior-facing portal (the same relation the PORTAL seed stands the sun in) or sits within
/// exterior-facing portal (the relation the PORTAL seed stands the sun in) or an EXT-class batch (the
/// APERTURE population: a window), or sits within
/// [`DISTRICT_SKY_HOPS`] interior portals of such a room. That lights the shops, taverns and their
/// back rooms and leaves what the data says is deep inside alone: the Stormwind canal tunnels,
/// the Slaughtered Lamb's cellar, and all of Ironforge except the gate halls (the city's ONE
/// exterior portal is `p0`, into `g7`).
pub fn district_sky_rooms(groups: &[WmoGroupInfo], portals: PortalGraph<'_>) -> Vec<bool> {
pub fn district_sky_rooms<'a, I>(
groups: &[WmoGroupInfo],
portals: PortalGraph<'_>,
batches: I,
) -> Vec<bool>
where
I: IntoIterator<Item = (u16, bool, &'a [[f32; 3]])>,
{
if groups.iter().filter(|g| g.interior).count() <= DAYLIGHT_DISTRICT_ROOMS {
return Vec::new();
}
@@ -352,6 +360,14 @@ pub fn district_sky_rooms(groups: &[WmoGroupInfo], portals: PortalGraph<'_>) ->
_ => {}
}
}
// …and a room with an EXT-class batch — a window or an open side (the APERTURE population).
for (g, ext_class, _) in batches {
if ext_class && interior(g) {
if let Some(h) = hop.get_mut(usize::from(g)) {
*h = Some(0);
}
}
}
for level in 0..DISTRICT_SKY_HOPS {
let mut next = Vec::new();
for &(a, b) in &edges {
@@ -690,6 +706,9 @@ where
let portal_seeds = out.len();
// --- 2. APERTURE seeds: an EXT-class batch of an INTERIOR group (see the module doc).
// MONKEY (daylight: window split): on a DISTRICT placement an EXT batch wider than
// [`APERTURE_MAX_DIAG`] is split into its spatial clusters first ([`window_boxes`]).
let district = groups.iter().filter(|g| g.interior).count() > DAYLIGHT_DISTRICT_ROOMS;
if want_apertures {
for &(gi, ext_class, positions) in &batches {
if !ext_class || interior_of(gi) != Some(true) {
@@ -698,35 +717,34 @@ where
let Some(g) = groups.get(usize::from(gi)) else {
continue;
};
let Some((lo, hi)) = bounds(positions.iter().copied()) else {
continue;
};
let diag = diagonal(lo, hi);
if !(APERTURE_MIN_DIAG..=APERTURE_MAX_DIAG).contains(&diag) {
continue;
for (lo, hi) in window_boxes(positions, district) {
let diag = diagonal(lo, hi);
if !(APERTURE_MIN_DIAG..=APERTURE_MAX_DIAG).contains(&diag) {
continue;
}
let (c, hz) = seed_point(lo, hi);
let pos = nudge_inward(c, g, None);
// The authored portal wins a shared opening — see [`SEED_DEDUPE_YD`].
let dup = out[..portal_seeds].iter().any(|s| {
(s.pos[0] - pos[0]).powi(2)
+ (s.pos[1] - pos[1]).powi(2)
+ (s.pos[2] - pos[2]).powi(2)
< SEED_DEDUPE_YD * SEED_DEDUPE_YD
});
if dup {
continue;
}
out.push(DaylightSeed {
group: gi,
portal: None,
how: DaylightHow::Aperture,
pos,
diag,
area: opening_area(lo, hi),
hz,
round: 0,
});
}
let (c, hz) = seed_point(lo, hi);
let pos = nudge_inward(c, g, None);
// The authored portal wins a shared opening — see [`SEED_DEDUPE_YD`].
let dup = out[..portal_seeds].iter().any(|s| {
(s.pos[0] - pos[0]).powi(2)
+ (s.pos[1] - pos[1]).powi(2)
+ (s.pos[2] - pos[2]).powi(2)
< SEED_DEDUPE_YD * SEED_DEDUPE_YD
});
if dup {
continue;
}
out.push(DaylightSeed {
group: gi,
portal: None,
how: DaylightHow::Aperture,
pos,
diag,
area: opening_area(lo, hi),
hz,
round: 0,
});
}
}
@@ -778,6 +796,108 @@ where
out
}
/// MONKEY (daylight: window split): the openings one EXT-class batch stands for, as boxes.
///
/// MEASURED (`benilla-extract wmolights` on Stormwind, 2026-09-25): the Cathedral of Light's nave
/// authors ALL its stained-glass windows as ONE EXT batch per wall — `g135 b11` spans
/// 15 x 44 x 20 yd (diag 51), `g146 b10` 30 x 44 x 10 yd (diag 54) — so [`APERTURE_MAX_DIAG`]
/// refused the batch whole and the nave took no daylight through a single window. Its windows are
/// separate panes between pillars, so the batch's vertices fall into separate clusters at the
/// plan linkage of [`plan_clusters`], and each window-sized cluster is an aperture.
///
/// District placements only, and only for an oversized batch: every building (the inn, the abbey)
/// keeps its calibrated seed list byte for byte, and a batch that is already window-sized is one
/// box as before. At most [`WINDOW_SPLIT_MAX`] boxes per batch, largest first.
fn window_boxes(positions: &[[f32; 3]], district: bool) -> Vec<([f32; 3], [f32; 3])> {
let Some((lo, hi)) = bounds(positions.iter().copied()) else {
return Vec::new();
};
if !district || diagonal(lo, hi) <= APERTURE_MAX_DIAG {
return vec![(lo, hi)];
}
let mut boxes: Vec<([f32; 3], [f32; 3])> = plan_clusters(positions)
.into_iter()
.filter(|(l, h)| (APERTURE_MIN_DIAG..=APERTURE_MAX_DIAG).contains(&diagonal(*l, *h)))
.collect();
// Stable: `cluster_boxes` is already sorted, and ties keep that order.
boxes.sort_by(|a, b| opening_area(b.0, b.1).total_cmp(&opening_area(a.0, a.1)));
boxes.truncate(WINDOW_SPLIT_MAX);
boxes
}
/// MONKEY (daylight: window split): single-link clustering of `points` in PLAN (x, y) at
/// [`WINDOW_GAP_YD`], one 3-D `(lo, hi)` box per cluster. In plan because a pane's top and bottom
/// corners share their plan position however tall it is (a stained-glass pane is often one quad,
/// its vertices 8 yd apart vertically), while two panes of one wall are split by a pillar.
fn plan_clusters(points: &[[f32; 3]]) -> Vec<([f32; 3], [f32; 3])> {
fn find(parent: &mut [usize], mut i: usize) -> usize {
while parent[i] != i {
parent[i] = parent[parent[i]];
i = parent[i];
}
i
}
let cell = |p: [f32; 3]| {
[
(p[0] / WINDOW_GAP_YD).floor() as i32,
(p[1] / WINDOW_GAP_YD).floor() as i32,
]
};
let mut index: HashMap<[i32; 2], usize> = HashMap::new();
let mut cells: Vec<[i32; 2]> = Vec::new();
let of_point: Vec<usize> = points
.iter()
.map(|p| {
let c = cell(*p);
*index.entry(c).or_insert_with(|| {
cells.push(c);
cells.len() - 1
})
})
.collect();
let mut parent: Vec<usize> = (0..cells.len()).collect();
for (i, c) in cells.iter().enumerate() {
for dx in -1..=1 {
for dy in -1..=1 {
if let Some(&j) = index.get(&[c[0] + dx, c[1] + dy]) {
let (a, b) = (find(&mut parent, i), find(&mut parent, j));
if a != b {
parent[a] = b;
}
}
}
}
}
let mut boxes: HashMap<usize, ([f32; 3], [f32; 3])> = HashMap::new();
for (p, ci) in points.iter().zip(of_point.iter()) {
let root = find(&mut parent, *ci);
let e = boxes.entry(root).or_insert((*p, *p));
for a in 0..3 {
e.0[a] = e.0[a].min(p[a]);
e.1[a] = e.1[a].max(p[a]);
}
}
let mut out: Vec<([f32; 3], [f32; 3])> = boxes.into_values().collect();
// Deterministic: a `HashMap` walk is not.
out.sort_by(|a, b| {
a.0[0]
.total_cmp(&b.0[0])
.then(a.0[1].total_cmp(&b.0[1]))
.then(a.0[2].total_cmp(&b.0[2]))
});
out
}
/// MONKEY (daylight: window split): the plan-cell size (yd) that links one pane's vertices and no
/// more: two points in neighbouring cells are at most ~4 yd apart, and a pillar between two panes
/// is wider than the one-cell gap it takes to split them.
const WINDOW_GAP_YD: f32 = 1.5;
/// MONKEY (daylight: window split): the most apertures one EXT batch may become. A nave wall is
/// three to five panes; the cap keeps a pathological batch (a lattice of posts) from flooding the
/// placement's quota, which ranks one room's second opening after every room's first anyway.
const WINDOW_SPLIT_MAX: usize = 6;
/// MONKEY (daylight fixtures: boundary): every stitched interior<->exterior opening of one root, as
/// `(interior group, cluster lo, cluster hi, inward unit direction)` in WMO model space.
///
@@ -2147,11 +2267,36 @@ mod tests {
slices[2] = (2, 2);
slices[3] = (4, 1);
let portals = PortalGraph { vertices: &vertices, infos: &infos, refs: &refs, slices: &slices };
let sky = district_sky_rooms(&groups, portals);
let none: [(u16, bool, &[[f32; 3]]); 0] = [];
let sky = district_sky_rooms(&groups, portals, none);
assert!(sky[1] && sky[2], "the doorway room and its neighbour");
assert!(!sky[3], "two hops in stays dark");
assert!(!sky[0], "the shell is not a room");
assert!(district_sky_rooms(&groups[..5], portals).is_empty(), "a building is untouched");
assert!(district_sky_rooms(&groups[..5], portals, none).is_empty(), "a building is untouched");
// A window (EXT-class batch) makes g3 a sky room too.
let window = [[0.0f32; 3], [1.0, 1.0, 1.0]];
let sky = district_sky_rooms(&groups, portals, [(3u16, true, &window[..])]);
assert!(sky[3], "a room with a window");
}
/// MONKEY (daylight: window split): a district's wall-long EXT batch becomes its panes; a
/// building's stays one box (and is then refused as before).
#[test]
fn a_district_window_wall_splits_into_panes() {
// Three 2 x 0.2 x 4 yd panes, 10 yd apart along +Y: one batch, diag > 24.
let mut pts = Vec::new();
for i in 0..3 {
let y = i as f32 * 12.0;
for (dy, dz) in [(0.0, 0.0), (2.0, 0.0), (0.0, 4.0), (2.0, 4.0), (1.0, 2.0)] {
pts.push([0.0, y + dy, dz]);
pts.push([0.2, y + dy, dz]);
}
}
pts.push([0.0, 0.0, 0.0]);
assert_eq!(window_boxes(&pts, false).len(), 1, "a building keeps the whole batch");
let panes = window_boxes(&pts, true);
assert_eq!(panes.len(), 3, "a district splits it into its panes");
assert!(panes.iter().all(|(l, h)| diagonal(*l, *h) < 6.0));
}
/// The reach formula: linear in the opening's diagonal, floored at 6 and capped at 20.
@@ -15,7 +15,13 @@ struct Loaded {
portals: benilla_formats::WmoPortals,
slices: Vec<(u16, u16)>,
/// `(group, class, window, sidn, positions)` per render batch.
batches: Vec<(u16, benilla_formats::WmoBatchClass, bool, bool, Vec<[f32; 3]>)>,
batches: Vec<(
u16,
benilla_formats::WmoBatchClass,
bool,
bool,
Vec<[f32; 3]>,
)>,
}
fn group_names(bytes: &[u8]) -> Vec<String> {
@@ -96,7 +102,11 @@ fn census(path: &str) {
.collect();
let verts: usize = feed.iter().map(|b| b.2.len()).sum();
let rooms = l.groups.iter().filter(|g| g.interior).count();
println!("=== {path}: {} groups, {rooms} interior, {} batches, {verts} batch verts", l.groups.len(), feed.len());
println!(
"=== {path}: {} groups, {rooms} interior, {} batches, {verts} batch verts",
l.groups.len(),
feed.len()
);
let t = std::time::Instant::now();
let ranked = daylight_seeds_ranked(&l.groups, graph, feed.iter().copied());
@@ -125,10 +135,12 @@ fn census(path: &str) {
let mut day_claim: HashMap<u16, Vec<String>> = HashMap::new();
for s in &day {
for c in daylight_claims(&l.groups, graph, s) {
day_claim
.entry(c.group)
.or_default()
.push(format!("{}@g{}{:?}", s.how.tag(), s.group, c.how));
day_claim.entry(c.group).or_default().push(format!(
"{}@g{}{:?}",
s.how.tag(),
s.group,
c.how
));
}
}
let mut bleed_claim: HashSet<u16> = HashSet::new();
@@ -157,8 +169,17 @@ fn census(path: &str) {
};
let (start, count) = l.slices[gi];
let mut nb = Vec::new();
for r in l.portals.refs.iter().skip(start as usize).take(count as usize) {
let other = l.groups.get(r.group as usize).map_or('?', |o| if o.interior { 'i' } else { 'e' });
for r in l
.portals
.refs
.iter()
.skip(start as usize)
.take(count as usize)
{
let other =
l.groups
.get(r.group as usize)
.map_or('?', |o| if o.interior { 'i' } else { 'e' });
let area = benilla_formats::room_claim::portal_area(&graph, r.portal).unwrap_or(0.0);
nb.push(format!("p{}->g{}{}({:.0})", r.portal, r.group, other, area));
}
@@ -191,8 +212,10 @@ fn census(path: &str) {
day_claim.get(&gid).map(|v| v.iter().take(3).cloned().collect::<Vec<_>>().join(",")).unwrap_or_default(),
);
}
println!("interior rooms: {n_day} reached by daylight, {n_bleed} by bleed only, {n_none} by neither");
let sky = district_sky_rooms(&l.groups, graph);
println!(
"interior rooms: {n_day} reached by daylight, {n_bleed} by bleed only, {n_none} by neither"
);
let sky = district_sky_rooms(&l.groups, graph, feed.iter().copied());
let enclosed = (0..l.groups.len() as u16)
.filter(|g| benilla_formats::room_claim::enclosed_by_building_shell(&l.groups, *g))
.count();
@@ -210,12 +233,20 @@ fn census(path: &str) {
fn placement() -> Option<bevy::math::Affine3A> {
let spec = std::env::var("WOW_CENSUS_PLACE").ok()?;
let p: Vec<&str> = spec.split(',').collect();
let (map, tx, ty, uid) = (p[0], p[1].parse().ok()?, p[2].parse().ok()?, p[3].parse::<u32>().ok()?);
let (map, tx, ty, uid) = (
p[0],
p[1].parse().ok()?,
p[2].parse().ok()?,
p[3].parse::<u32>().ok()?,
);
let data = benilla_formats::wow_data()?;
let mut chain = open_chain(&data).ok()?;
let tile = benilla_formats::load_tile_mesh(&mut chain, map, tx, ty).ok()?;
let w = tile.wmos.iter().find(|w| w.unique_id == uid)?;
println!("placement uid {uid} pos {:?} rot {:?}", w.position, w.rotation);
println!(
"placement uid {uid} pos {:?} rot {:?}",
w.position, w.rotation
);
Some(bevy::math::Affine3A::from_scale_rotation_translation(
Vec3::ONE,
benilla_assets::coords::placement_rotation(w.rotation),
@@ -234,3 +265,79 @@ fn city_daylight_census() {
}
}
}
/// MONKEY (daylight: terrain torch casters): which lampposts have ground they cannot see — the
/// share of terrain points within 25 yd whose line to the lamp head (3.5 yd up) is blocked by the
/// terrain itself. Picks a vantage for the B1 capture. `WOW_LAMP_SCAN=map,x,y,radius,filter`.
#[test]
#[ignore = "instrument: needs WOW_DATA; run by hand with --ignored --nocapture"]
fn lamp_terrain_occlusion_scan() {
let spec = std::env::var("WOW_LAMP_SCAN")
.unwrap_or_else(|_| "Azeroth,-9300,150,1,lamppost".into());
let p: Vec<&str> = spec.split(',').collect();
let (map, cx, cy, r, filter) = (
p[0],
p[1].parse::<f32>().unwrap(),
p[2].parse::<f32>().unwrap(),
p[3].parse::<i32>().unwrap(),
p[4].to_ascii_lowercase(),
);
let data = benilla_formats::wow_data().expect("WOW_DATA");
let mut chain = open_chain(&data).expect("chain");
let (tx, ty) = benilla_formats::world_to_tile(cx, cy);
let mut chunks = Vec::new();
let mut lamps = Vec::new();
for x in tx as i32 - r..=tx as i32 + r {
for y in ty as i32 - r..=ty as i32 + r {
let Ok(t) = benilla_formats::load_tile_mesh(&mut chain, map, x as u32, y as u32) else {
continue;
};
for d in &t.doodads {
if d.model.to_ascii_lowercase().contains(&filter) {
lamps.push(d.position);
}
}
chunks.push(t.chunks);
}
}
let h = |x: f32, y: f32| {
chunks
.iter()
.find_map(|c| benilla_formats::terrain_height_at(c, [x, y, 1.0e4]))
};
let mut rows = Vec::new();
for l in lamps {
let head = Vec3::new(l[0], l[1], l[2] + 3.5);
let (mut total, mut blocked) = (0u32, 0u32);
let mut best = (0.0f32, 0.0f32);
for ring in 1..=10 {
let d = ring as f32 * 2.5;
for k in 0..36 {
let a = k as f32 * std::f32::consts::TAU / 36.0;
let (x, y) = (l[0] + d * a.cos(), l[1] + d * a.sin());
let Some(z) = h(x, y) else { continue };
total += 1;
let target = Vec3::new(x, y, z + 0.3);
let steps = (d / 0.5) as i32;
let hit = (1..steps).any(|s| {
let q = head.lerp(target, s as f32 / steps as f32);
h(q.x, q.y).is_some_and(|g| g > q.z + 0.05)
});
if hit {
blocked += 1;
best = (a.to_degrees(), d);
}
}
}
if total > 0 {
rows.push((blocked as f32 / total as f32, l, best));
}
}
rows.sort_by(|a, b| b.0.total_cmp(&a.0));
for (f, l, (a, d)) in rows.iter().take(12) {
println!(
"lamp ({:8.1},{:7.1},{:6.1}) terrain-blocked {:5.1}% e.g. bearing {a:5.0} deg at {d:4.1} yd",
l[0], l[1], l[2], f * 100.0
);
}
}
@@ -340,6 +340,13 @@ pub(super) fn spawn_loaded_placements(
refs: &m.portal_refs,
slices: &sky_slices,
},
m.submeshes.iter().zip(m.submesh_group.iter()).map(|(s, g)| {
(
*g,
matches!(s.wmo_batch, Some(benilla_formats::WmoBatchClass::Ext)),
&s.geometry.positions[..],
)
}),
);
let instance = (has_portals || m.wmo_id != 0).then(|| {
commands