MONKEY (daylight): city daylight census instrument + daylight- capture scenarios
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
da0f1a0d80
commit
300f0b0229
@@ -1378,6 +1378,32 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
|
||||
look: LAVA_RIVER_LOOK,
|
||||
minute: 0,
|
||||
ui: None,
|
||||
}, // MONKEY (daylight): city interiors by day and terrain torch shadows at night. World coords
|
||||
// from the census (`lighting::daylight::census`, Stormwind uid 10047, Ironforge uid 7706).
|
||||
// The Gilded Rose ground floor (group g268, portal + aperture seeds).
|
||||
Scenario {
|
||||
name: "daylight-sw-inn", map: Some(MAP_AZEROTH),
|
||||
eye: [-8871.0, 681.0, 99.8], look: [-8858.0, 668.0, 98.6], minute: 720, ui: None,
|
||||
},
|
||||
// The Cathedral of Light nave (groups g135/g146, lit only by two EXT-class window batches).
|
||||
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).
|
||||
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,
|
||||
},
|
||||
// Ironforge: the Great Forge hall (g64, no opening to the sky) and the gate hall (g7, the
|
||||
// city's one exterior portal).
|
||||
Scenario {
|
||||
name: "daylight-if-forge", map: Some(MAP_AZEROTH),
|
||||
eye: [-4930.0, -945.0, 503.5], look: [-4890.0, -985.0, 503.0], minute: 720, ui: None,
|
||||
},
|
||||
Scenario {
|
||||
name: "daylight-if-gate", map: Some(MAP_AZEROTH),
|
||||
eye: [-4975.0, -895.0, 503.5], look: [-5005.0, -852.0, 506.0], minute: 720, ui: None,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -2035,6 +2035,10 @@ pub(super) fn register(app: &mut App) {
|
||||
);
|
||||
}
|
||||
|
||||
// MONKEY (daylight): the city census instrument.
|
||||
#[cfg(test)]
|
||||
mod census;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//! MONKEY (daylight): the city daylight census (test-only instrument). Loads a WMO root from the
|
||||
//! install, runs the live selection rule over it and prints which interior rooms get a daylight
|
||||
//! seed, which do not, and what each unseeded room authors (batch classes, WINDOW / SIDN
|
||||
//! materials, portal neighbours), plus the cost of each seed pass.
|
||||
//!
|
||||
//! `WOW_DATA=<Data> cargo test -p benilla-world --lib lighting::daylight::census -- --ignored --nocapture`
|
||||
//! `WOW_CENSUS_WMO=<internal path>` picks the root (default: Stormwind then Ironforge).
|
||||
|
||||
use super::*;
|
||||
use benilla_formats::{open_chain, parse_wmo_portals, parse_wmo_root, wmo_group_header};
|
||||
|
||||
struct Loaded {
|
||||
groups: Vec<WmoGroupInfo>,
|
||||
names: Vec<String>,
|
||||
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]>)>,
|
||||
}
|
||||
|
||||
fn group_names(bytes: &[u8]) -> Vec<String> {
|
||||
let (mut mogn, mut mogi) = (None, None);
|
||||
let mut o = 0usize;
|
||||
while o + 8 <= bytes.len() {
|
||||
let tag = [bytes[o + 3], bytes[o + 2], bytes[o + 1], bytes[o]];
|
||||
let n = u32::from_le_bytes(bytes[o + 4..o + 8].try_into().unwrap()) as usize;
|
||||
let body = bytes.get(o + 8..o + 8 + n);
|
||||
match (&tag, body) {
|
||||
(b"MOGN", Some(b)) => mogn = Some(b),
|
||||
(b"MOGI", Some(b)) => mogi = Some(b),
|
||||
_ => {}
|
||||
}
|
||||
o += 8 + n;
|
||||
}
|
||||
let (Some(mogn), Some(mogi)) = (mogn, mogi) else {
|
||||
return Vec::new();
|
||||
};
|
||||
mogi.chunks_exact(32)
|
||||
.map(|r| {
|
||||
let off = i32::from_le_bytes(r[28..32].try_into().unwrap());
|
||||
usize::try_from(off)
|
||||
.ok()
|
||||
.and_then(|off| mogn.get(off..))
|
||||
.map(|rest| {
|
||||
let end = rest.iter().position(|&b| b == 0).unwrap_or(rest.len());
|
||||
String::from_utf8_lossy(&rest[..end]).into_owned()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn load(path: &str) -> Loaded {
|
||||
let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
|
||||
let mut chain = open_chain(&data).expect("open MPQ chain");
|
||||
let root_path = path.replace('/', "\\").to_ascii_lowercase();
|
||||
let bytes = chain.read_file(&root_path).expect("read root");
|
||||
let root = parse_wmo_root(&bytes).expect("parse root");
|
||||
let stem = root_path.strip_suffix(".wmo").unwrap().to_string();
|
||||
let mut slices = Vec::new();
|
||||
let mut batches = Vec::new();
|
||||
for gi in 0..root.group_count() {
|
||||
let Ok(gbytes) = chain.read_file(&format!("{stem}_{gi:03}.wmo")) else {
|
||||
slices.push((0, 0));
|
||||
continue;
|
||||
};
|
||||
let h = wmo_group_header(&gbytes);
|
||||
slices.push(h.map_or((0, 0), |h| (h.portal_ref_start, h.portal_ref_count)));
|
||||
for s in benilla_formats::wmo_group_submeshes(&gbytes, &root).unwrap_or_default() {
|
||||
if let Some(class) = s.wmo_batch {
|
||||
batches.push((gi as u16, class, s.window, s.sidn.is_some(), s.positions));
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded {
|
||||
groups: root.group_infos().to_vec(),
|
||||
names: group_names(&bytes),
|
||||
portals: parse_wmo_portals(&bytes),
|
||||
slices,
|
||||
batches,
|
||||
}
|
||||
}
|
||||
|
||||
fn census(path: &str) {
|
||||
let l = load(path);
|
||||
let graph = PortalGraph {
|
||||
vertices: &l.portals.vertices,
|
||||
infos: &l.portals.infos,
|
||||
refs: &l.portals.refs,
|
||||
slices: &l.slices,
|
||||
};
|
||||
let feed: Vec<(u16, bool, &[[f32; 3]])> = l
|
||||
.batches
|
||||
.iter()
|
||||
.map(|(g, c, _, _, p)| (*g, matches!(c, benilla_formats::WmoBatchClass::Ext), &p[..]))
|
||||
.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());
|
||||
|
||||
let t = std::time::Instant::now();
|
||||
let ranked = daylight_seeds_ranked(&l.groups, graph, feed.iter().copied());
|
||||
let t_ranked = t.elapsed();
|
||||
let t = std::time::Instant::now();
|
||||
let bounds = boundary_clusters(&l.groups, &feed);
|
||||
let t_bound = t.elapsed();
|
||||
let (day, bleed) = placement_openings(&l.groups, graph, feed.iter().copied());
|
||||
println!(
|
||||
"live ranked seeds {} ({:.1} ms); boundary clusters if forced {} ({:.1} ms); kept {} day + {} bleed of budget {}",
|
||||
ranked.len(),
|
||||
t_ranked.as_secs_f64() * 1e3,
|
||||
bounds.len(),
|
||||
t_bound.as_secs_f64() * 1e3,
|
||||
day.len(),
|
||||
bleed.len(),
|
||||
daylight_budget(&l.groups),
|
||||
);
|
||||
let mut by_how: HashMap<&str, usize> = HashMap::new();
|
||||
for s in &day {
|
||||
*by_how.entry(s.how.tag()).or_default() += 1;
|
||||
}
|
||||
println!("kept daylight by rule: {by_how:?}");
|
||||
// Which rooms each lane actually REACHES: the fixture's own claim set (containment, portal hop,
|
||||
// split), the same set the packer's room gate enforces.
|
||||
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));
|
||||
}
|
||||
}
|
||||
let mut bleed_claim: HashSet<u16> = HashSet::new();
|
||||
for s in &bleed {
|
||||
for c in s.claims(&l.groups, graph) {
|
||||
bleed_claim.insert(c.group);
|
||||
}
|
||||
}
|
||||
let bound_groups: HashSet<u16> = bounds.iter().map(|b| b.0).collect();
|
||||
let place = placement();
|
||||
let (mut n_day, mut n_bleed, mut n_none) = (0, 0, 0);
|
||||
for (gi, g) in l.groups.iter().enumerate() {
|
||||
if !g.interior {
|
||||
continue;
|
||||
}
|
||||
let gid = gi as u16;
|
||||
let tag = if day_claim.contains_key(&gid) {
|
||||
n_day += 1;
|
||||
"DAY "
|
||||
} else if bleed_claim.contains(&gid) {
|
||||
n_bleed += 1;
|
||||
"bleed"
|
||||
} else {
|
||||
n_none += 1;
|
||||
"NONE "
|
||||
};
|
||||
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' });
|
||||
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));
|
||||
}
|
||||
let (mut e, mut w) = (0, 0);
|
||||
for (bg, c, win, _, _) in &l.batches {
|
||||
if usize::from(*bg) == gi {
|
||||
e += usize::from(matches!(c, benilla_formats::WmoBatchClass::Ext));
|
||||
w += usize::from(*win);
|
||||
}
|
||||
}
|
||||
let c = [
|
||||
0.5 * (g.bbox_min[0] + g.bbox_max[0]),
|
||||
0.5 * (g.bbox_min[1] + g.bbox_max[1]),
|
||||
0.5 * (g.bbox_min[2] + g.bbox_max[2]),
|
||||
];
|
||||
let wc = place.map_or(c, |m| {
|
||||
benilla_assets::coords::bevy_to_wow(
|
||||
m.transform_point3(benilla_assets::coords::wow_to_bevy(c)),
|
||||
)
|
||||
});
|
||||
println!(
|
||||
" {tag} g{gi:<3} {:<18} at ({:8.1},{:7.1},{:6.1}) size ({:5.1},{:5.1},{:5.1}) E{e} W{w}{} {} {}",
|
||||
l.names.get(gi).map(String::as_str).unwrap_or("-"),
|
||||
wc[0], wc[1], wc[2],
|
||||
g.bbox_max[0] - g.bbox_min[0],
|
||||
g.bbox_max[1] - g.bbox_min[1],
|
||||
g.bbox_max[2] - g.bbox_min[2],
|
||||
if bound_groups.contains(&gid) { " stitch" } else { "" },
|
||||
nb.join(" "),
|
||||
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");
|
||||
}
|
||||
|
||||
/// `WOW_CENSUS_PLACE=map,tx,ty,uid`: the MODF placement, for world coordinates in the table.
|
||||
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 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);
|
||||
Some(bevy::math::Affine3A::from_scale_rotation_translation(
|
||||
Vec3::ONE,
|
||||
benilla_assets::coords::placement_rotation(w.rotation),
|
||||
benilla_assets::coords::wow_to_bevy(w.position),
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "instrument: needs WOW_DATA; run by hand with --ignored --nocapture"]
|
||||
fn city_daylight_census() {
|
||||
match std::env::var("WOW_CENSUS_WMO") {
|
||||
Ok(p) => census(&p),
|
||||
Err(_) => {
|
||||
census(r"World\wmo\Azeroth\Buildings\Stormwind\Stormwind.wmo");
|
||||
census(r"World\wmo\KhazModan\Cities\Ironforge\ironforge.wmo");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user