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