From 1cdbf2aabfb395eb213eec8987f0ba3a9825a7b3 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 05:28:05 +0100
Subject: [PATCH] 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
---
crates/benilla-app/src/capture/scenarios.rs | 14 +-
crates/benilla-world/src/lighting/daylight.rs | 209 +++++++++++++++---
.../src/lighting/daylight/census.rs | 131 ++++++++++-
.../src/terrain_stream/spawn/mod.rs | 7 +
4 files changed, 315 insertions(+), 46 deletions(-)
diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs
index 84f27f1a..252ee188 100644
--- a/crates/benilla-app/src/capture/scenarios.rs
+++ b/crates/benilla-app/src/capture/scenarios.rs
@@ -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,
},
];
diff --git a/crates/benilla-world/src/lighting/daylight.rs b/crates/benilla-world/src/lighting/daylight.rs
index b7db527c..2fed0fbf 100644
--- a/crates/benilla-world/src/lighting/daylight.rs
+++ b/crates/benilla-world/src/lighting/daylight.rs
@@ -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 {
+pub fn district_sky_rooms<'a, I>(
+ groups: &[WmoGroupInfo],
+ portals: PortalGraph<'_>,
+ batches: I,
+) -> Vec
+where
+ I: IntoIterator- ,
+{
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 = 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 = (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 = 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.
diff --git a/crates/benilla-world/src/lighting/daylight/census.rs b/crates/benilla-world/src/lighting/daylight/census.rs
index b81f29fd..71101c25 100644
--- a/crates/benilla-world/src/lighting/daylight/census.rs
+++ b/crates/benilla-world/src/lighting/daylight/census.rs
@@ -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 {
@@ -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> = 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 = 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::>().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 {
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::().ok()?);
+ let (map, tx, ty, uid) = (
+ p[0],
+ p[1].parse().ok()?,
+ p[2].parse().ok()?,
+ p[3].parse::().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::().unwrap(),
+ p[2].parse::().unwrap(),
+ p[3].parse::().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
+ );
+ }
+}
diff --git a/crates/benilla-world/src/terrain_stream/spawn/mod.rs b/crates/benilla-world/src/terrain_stream/spawn/mod.rs
index aa3abb96..eeb37b04 100644
--- a/crates/benilla-world/src/terrain_stream/spawn/mod.rs
+++ b/crates/benilla-world/src/terrain_stream/spawn/mod.rs
@@ -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