diff --git a/crates/sylpheed-formats/src/mesh.rs b/crates/sylpheed-formats/src/mesh.rs index f8ca93b..b797f68 100644 --- a/crates/sylpheed-formats/src/mesh.rs +++ b/crates/sylpheed-formats/src/mesh.rs @@ -1840,6 +1840,137 @@ pub fn node_transforms(bytes: &[u8], resource_name: &str) -> Vec out } +/// A geometry resource placed by a composite scene graph, with its world affine. +#[derive(Debug, Clone)] +pub struct ScenePart { + /// The geometry resource to draw (e.g. `e106_bdy_01`). + pub resource: String, + /// World rotation (row-major 3×3). + pub m: [[f32; 3]; 3], + /// World translation, `(X, up→Y, fore/aft→Z)`. + pub t: [f32; 3], +} + +impl ScenePart { + /// Apply the world transform to a local vertex. + pub fn apply(&self, v: [f32; 3]) -> [f32; 3] { + [ + self.m[0][0] * v[0] + self.m[0][1] * v[1] + self.m[0][2] * v[2] + self.t[0], + self.m[1][0] * v[0] + self.m[1][1] * v[1] + self.m[1][2] * v[2] + self.t[1], + self.m[2][0] * v[0] + self.m[2][1] * v[1] + self.m[2][2] * v[2] + self.t[2], + ] + } +} + +/// Walk a composite scene graph (`e_rou_eNNN`) and return **every** node with its +/// composed world transform (parent∘child down the first-child/next-sibling tree). +/// +/// A capital ship's parts are authored around the origin in their own resources; +/// the composite's node tree carries the world placement — `rou__*` nodes name +/// the hull geometry resources, and `GN_*` nodes are the Vessel hardpoint frames +/// (bridge / engine / shield-generator / turret mounts). The [`crate::ship`] +/// assembler resolves those names to resources and places the parts. Same node +/// record layout + TRS convention as [`node_transforms`], but cross-resource. +pub fn scene_world_nodes(bytes: &[u8], composite_name: &str) -> Vec { + let Some((desc, desc_end)) = xbg7_descriptor(bytes, composite_name) else { + return Vec::new(); + }; + let d = &bytes[desc..desc_end]; + let head_end = d.len(); + + let read_trs = |t44: usize| -> [f64; 8] { + let mut v = [0.0f64; 8]; + for (k, slot) in v.iter_mut().enumerate() { + let p = be32(d, t44 + k * 4) as usize; + if p != 0 && p + 16 <= d.len() { + *slot = be_f64(d, p + 8); + } + } + v + }; + + // A node record begins with a name starting `rou_` or `GN_`. + let node_name_at = |i: usize| -> Option<(String, usize)> { + let starts = i + 4 <= d.len() && &d[i..i + 4] == b"rou_" + || i + 3 <= d.len() && &d[i..i + 3] == b"GN_"; + if !starts { + return None; + } + let mut j = i; + while j < d.len() && d[j] != 0 && d[j].is_ascii_graphic() { + j += 1; + } + let name = std::str::from_utf8(&d[i..j]).ok()?.to_string(); + if name.ends_with("_col") + || name.ends_with("_spc") + || name.ends_with("_gls") + || name.ends_with("_lum") + { + return None; + } + Some((name, j)) + }; + + struct Rec { + name_start: usize, + name: String, + local_m: M3, + local_t: [f32; 3], + sib_end: Option, + } + let mut recs: Vec = Vec::new(); + let mut i = 0usize; + while i + 3 <= head_end { + let Some((name, j)) = node_name_at(i) else { + i += 1; + continue; + }; + let mut ff = i; + let scan_end = (i + 0x90).min(d.len().saturating_sub(4)); + while ff < scan_end && be32(d, ff) != 0xFFFF_FFFF { + ff += 4; + } + if ff >= scan_end || ff < 0x10 { + i = j.max(i + 1); + continue; + } + let t44 = be32(d, ff - 0x10) as usize; + let sib_ptr = be32(d, ff - 0x04) as usize; + let trs = if t44 != 0 && t44 + 32 <= d.len() { + read_trs(t44) + } else { + [0.0; 8] + }; + let local_t = [trs[0] as f32, trs[2] as f32, trs[1] as f32]; + let local_m = m3_mul(rot_z(trs[4] as f32), rot_x(trs[3] as f32)); + // Next sibling begins 4 bytes before its pointer, at a `rou_`/`GN_` name. + let sib_end = sib_ptr.checked_sub(4).filter(|&s| { + s > i && s + 4 <= head_end && (&d[s..s + 4] == b"rou_" || &d[s..s + 3] == b"GN_") + }); + recs.push(Rec { name_start: i, name, local_m, local_t, sib_end }); + i = j.max(i + 1); + } + + // Compose transforms down the first-child / next-sibling tree. + let mut out = Vec::new(); + let mut stack: Vec<(usize, M3, [f32; 3])> = Vec::new(); + for rec in &recs { + while stack.last().is_some_and(|s| s.0 <= rec.name_start) { + stack.pop(); + } + let (pm, pt) = stack.last().map(|s| (s.1, s.2)).unwrap_or((M3_ID, [0.0; 3])); + let wm = m3_mul(pm, rec.local_m); + let r = m3_vec(pm, rec.local_t); + let wt = [r[0] + pt[0], r[1] + pt[1], r[2] + pt[2]]; + out.push(ScenePart { resource: rec.name.clone(), m: wm, t: wt }); + let end = rec + .sib_end + .unwrap_or_else(|| stack.last().map(|s| s.0).unwrap_or(head_end)); + stack.push((end, wm, wt)); + } + out +} + // ── Tests ─────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/crates/sylpheed-formats/src/ship.rs b/crates/sylpheed-formats/src/ship.rs index 7463f6d..ce9de2a 100644 --- a/crates/sylpheed-formats/src/ship.rs +++ b/crates/sylpheed-formats/src/ship.rs @@ -32,7 +32,8 @@ //! does not resolve. A hull-family render therefore shows the ship body plus any //! *integral* (`_wep_`) weapons, which is the bulk of the silhouette. -use crate::mesh::xbg7_resource_names; +use crate::mesh::{scene_world_nodes, xbg7_resource_names, ScenePart}; +use std::collections::HashSet; /// Which side a ship belongs to, from the first letter of its resource id. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -106,9 +107,13 @@ pub fn is_base_part(resource: &str) -> bool { if ship_id_of(resource).is_none() { return false; } - // Level-of-detail copies of a base part. - if resource.ends_with("_l") || resource.ends_with("_m") || resource.ends_with("_d") { - return false; + // Level-of-detail copies of a base part: a trailing `_l`/`_m`/`_d` token, + // optionally with a number (`_d01`, `_l02`). + if let Some(last) = resource.rsplit('_').next() { + let mut cs = last.chars(); + if matches!(cs.next(), Some('l' | 'm' | 'd')) && cs.all(|c| c.is_ascii_digit()) { + return false; + } } // Destruction / logic geometry. if resource.contains("break") @@ -154,6 +159,135 @@ pub fn ships_in_container(bytes: &[u8]) -> Vec { ids.into_iter().map(|id| ships.remove(&id).unwrap()).collect() } +/// Map a composite scene-graph node name to the geometry resource it draws. +/// Hull nodes are `rou__[_root|_mov|_NN]`; the geometry resource is the +/// `_` stem. Structural nodes (`rou_e106_front`, `…_root`) resolve to +/// nothing and only pose their children. +fn resolve_hull_resource(node: &str, resources: &HashSet) -> Option { + let stem = node.rsplit_once("rou_").map(|(_, s)| s).unwrap_or(node); + if resources.contains(stem) { + return Some(stem.to_string()); + } + for suf in ["_root", "_mov"] { + if let Some(s) = stem.strip_suffix(suf) { + if resources.contains(s) { + return Some(s.to_string()); + } + } + } + // Trailing `_NN` instance index (`eng_01_1` → `eng_01`). + if let Some(pos) = stem.rfind('_') { + if !stem[pos + 1..].is_empty() && stem[pos + 1..].bytes().all(|c| c.is_ascii_digit()) { + let s = &stem[..pos]; + if resources.contains(s) { + return Some(s.to_string()); + } + } + } + None +} + +/// The trailing digit run of a name (`GN_Bridge_01` → `01`, `sld_02` → `02`). +fn trailing_index(name: &str) -> &str { + let bytes = name.as_bytes(); + let mut start = bytes.len(); + while start > 0 && bytes[start - 1].is_ascii_digit() { + start -= 1; + } + &name[start..] +} + +/// Which stage-container resources are the composite scene graphs for ship `id` +/// (`e_rou_e106`, `e_rou_e106_eng`, …). Excludes destruction (`_break`) scenes. +fn composites_for<'a>(names: &'a [String], id: &str) -> Vec<&'a String> { + let needle = format!("rou_{id}"); + names + .iter() + .filter(|n| { + n.contains(&needle) + && ship_id_of(n).is_none() + && !n.contains("break") + && !n.contains("dead") + }) + .collect() +} + +/// Reconstruct a whole ship as a list of geometry-resource placements in a shared +/// ship-local frame — the placement the game builds at runtime. +/// +/// Two tiers, matching how the game splits a ship: +/// 1. **Hull** — `rou__*` nodes in the composite scene graph name the hull +/// body resources and carry their world transform (bridge-less structure that +/// can't be destroyed piecemeal). +/// 2. **External hardpoints** — `GN_*` frame nodes are the Vessel mounts; each +/// unplaced bridge / shield-generator / engine part (`_brg_*`, `_sld_*`, +/// `_eng_*`) is placed at its matching frame (`GN_Bridge_*`, `GN_ShieldG_*`, +/// `GN_Engine_*`) by category + index — the individually-destructible parts. +/// +/// Returns an empty vec when the ship has no composite (caller can fall back to a +/// raw draw). Turret placement (shared `e3NN`/`e4NN` gun models mounted at many +/// `GN_*Gun*` frames) needs the per-mount Vessel recipe and is not yet resolved. +pub fn assemble_ship(bytes: &[u8], id: &str) -> Vec { + let names = xbg7_resource_names(bytes); + let resources: HashSet = names.iter().cloned().collect(); + let comps = composites_for(&names, id); + + let mut placed: Vec = Vec::new(); + let mut placed_res: HashSet = HashSet::new(); + // GN hardpoint frames, world-space, from every composite. + let mut frames: Vec = Vec::new(); + + for c in comps { + for node in scene_world_nodes(bytes, c) { + if node.resource.starts_with("GN_") { + frames.push(node); + } else if let Some(res) = resolve_hull_resource(&node.resource, &resources) { + // Only the ship's own parts. A composite also mounts shared, + // cross-id turret models (`e303_wep_*` on an `e106` hull); those + // need the per-mount Vessel recipe and are placed separately. + if ship_id_of(&res) != Some(id) { + continue; + } + if placed_res.insert(res.clone()) { + placed.push(ScenePart { resource: res, m: node.m, t: node.t }); + } + } + } + } + + // Place unplaced external parts at their Vessel hardpoint frame. + const CATS: [(&str, &str); 3] = [("brg", "Bridge"), ("sld", "ShieldG"), ("eng", "Engine")]; + for part in names.iter().filter(|n| is_base_part(n) && ship_id_of(n) == Some(id)) { + if placed_res.contains(part) { + continue; + } + let rest = &part[(id.len() + 1).min(part.len())..]; // "brg_01" + let mut toks = rest.split('_'); + let cat = toks.next().unwrap_or(""); + let idx = toks.next().unwrap_or(""); + let Some((_, gncat)) = CATS.iter().find(|(c, _)| *c == cat) else { + continue; + }; + if let Some(frame) = + frames.iter().find(|f| f.resource.contains(gncat) && trailing_index(&f.resource) == idx) + { + placed.push(ScenePart { resource: part.clone(), m: frame.m, t: frame.t }); + placed_res.insert(part.clone()); + } + } + + // Fallback for a ship with no composite scene graph (a simple prop): draw its + // base parts untransformed rather than nothing. + if placed.is_empty() { + const ID: [[f32; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + for part in names.iter().filter(|n| is_base_part(n) && ship_id_of(n) == Some(id)) { + placed.push(ScenePart { resource: part.clone(), m: ID, t: [0.0; 3] }); + } + } + + placed +} + #[cfg(test)] mod tests { use super::*; @@ -210,4 +344,33 @@ mod tests { assert!(e108.parts.iter().any(|p| p.contains("_bdy_"))); assert!(e108.parts.iter().all(|p| is_base_part(p))); } + + // Disc-gated: the scene graph must SPREAD a ship's parts, not stack them at + // the origin. Reconstruct the ADAN frigate e106 and assert its bridge sits + // clearly aft of its forward hull body (a real ship layout, not a pile). + #[test] + fn assemble_spreads_parts() { + let Ok(iso) = std::env::var("SYLPHEED_ISO") else { + eprintln!("SYLPHEED_ISO unset — skipping"); + return; + }; + let bytes = { + use crate::xiso::open_iso; + let rt = tokio::runtime::Builder::new_current_thread().build().unwrap(); + rt.block_on(async { + let mut r = open_iso(std::path::Path::new(&iso)).await.unwrap(); + r.read_file("hidden/resource3d/Stage_S02.xpr").await.unwrap() + }) + }; + let placed = assemble_ship(&bytes, "e106"); + assert!(placed.len() >= 5, "e106 should place its hull + external parts"); + // The forward hull body and the bridge must land at distinct fore/aft Z. + let z = |res: &str| placed.iter().find(|p| p.resource == res).map(|p| p.t[2]); + let bdy = z("e106_bdy_01").expect("bdy_01 placed"); + let brg = z("e106_brg_01").expect("brg_01 placed at its GN_Bridge frame"); + assert!( + (bdy - brg).abs() > 200.0, + "bridge ({brg}) and forward body ({bdy}) must be far apart, not stacked" + ); + } } diff --git a/crates/sylpheed-viewer/src/iso_loader.rs b/crates/sylpheed-viewer/src/iso_loader.rs index 3fcd98b..d00f949 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -635,8 +635,8 @@ pub struct RequestShipCatalog; pub struct RequestShipRender { /// Stage container path to read the parts from. pub file: String, - /// Base part resource names to decode + assemble. - pub parts: Vec, + /// Ship family id (`e106`) — drives the scene-graph assembly. + pub id: String, /// Display label for the model info line. pub label: String, } @@ -3904,7 +3904,7 @@ fn handle_ship_render_request( let Some(req) = events.read().last() else { return; }; - let (file, parts, label) = (req.file.clone(), req.parts.clone(), req.label.clone()); + let (file, id, label) = (req.file.clone(), req.id.clone(), req.label.clone()); // Supersede any in-flight XPR/stage decode, exactly like a file selection. xpr_load.generation = xpr_load.generation.wrapping_add(1); @@ -3919,7 +3919,7 @@ fn handle_ship_render_request( let source = iso_state.source_kind.clone(); let sender = channels.sender.clone(); std::thread::spawn(move || { - let prepared = build_ship_model(&source, &file, &parts, &label, &cancel); + let prepared = build_ship_model(&source, &file, &id, &label, &cancel); let _ = sender.send(IsoLoaderMsg::XprPrepared { generation, prepared: Box::new(prepared), @@ -3927,13 +3927,14 @@ fn handle_ship_render_request( }); } -/// Read a stage container, decode only the ship's parts, and assemble them into a -/// single world-space model (reusing the XPR model prepare path). +/// Read a stage container, resolve the ship's scene-graph placement, decode only +/// the referenced parts, transform each into its ship-local pose, and assemble +/// them into a single world-space model (reusing the XPR model prepare path). #[cfg(not(target_arch = "wasm32"))] fn build_ship_model( source: &SourceKind, file: &str, - parts: &[String], + id: &str, label: &str, cancel: &Arc, ) -> PreparedXpr { @@ -3942,15 +3943,57 @@ fn build_ship_model( Ok(b) => b, Err(e) => return PreparedXpr::Info(format!("Could not read {file}: {e}")), }; - let wanted: std::collections::HashSet = parts.iter().cloned().collect(); let should_cancel = || cancel.load(Ordering::Relaxed); - let models = Xbg7Model::models_named(&bytes, &wanted, &should_cancel); + + // Scene-graph placement: which resources to draw and where (bridge fore, + // engines aft, hardpoints on their frames). + let placed = sylpheed_formats::ship::assemble_ship(&bytes, id); + if placed.is_empty() { + return PreparedXpr::Info(format!("No parts found for {label}.")); + } + // Decode each distinct referenced resource once. + let wanted: std::collections::HashSet = + placed.iter().map(|p| p.resource.clone()).collect(); + let base = Xbg7Model::models_named(&bytes, &wanted, &should_cancel); if should_cancel() { return PreparedXpr::Cancelled; } + if base.is_empty() { + return PreparedXpr::Info(format!("No geometry decoded for {label}.")); + } + + // One transformed model per placement: bake the part's world pose into its + // vertices (positions by the full affine, normals by the rotation) so the + // shared prepare path can draw it in place. A resource placed at several + // hardpoints yields several posed copies. The name is kept so `material_groups` + // still finds the part's textures. + let rot = |m: &[[f32; 3]; 3], n: &[f32; 3]| { + [ + m[0][0] * n[0] + m[0][1] * n[1] + m[0][2] * n[2], + m[1][0] * n[0] + m[1][1] * n[1] + m[1][2] * n[2], + m[2][0] * n[0] + m[2][1] * n[1] + m[2][2] * n[2], + ] + }; + let mut models: Vec = Vec::with_capacity(placed.len()); + for p in &placed { + let Some(src) = base.iter().find(|m| m.name == p.resource) else { + continue; + }; + let mut m = src.clone(); + for sub in &mut m.meshes { + for v in &mut sub.positions { + *v = p.apply(*v); + } + for nrm in &mut sub.normals { + *nrm = rot(&p.m, nrm); + } + } + models.push(m); + } if models.is_empty() { return PreparedXpr::Info(format!("No geometry decoded for {label}.")); } + // Assemble, then relabel with the ship's display name. match prepare_ship(&bytes, &models) { PreparedXpr::Model { diff --git a/crates/sylpheed-viewer/src/ui.rs b/crates/sylpheed-viewer/src/ui.rs index 243f499..87a910c 100644 --- a/crates/sylpheed-viewer/src/ui.rs +++ b/crates/sylpheed-viewer/src/ui.rs @@ -1593,7 +1593,7 @@ fn draw_ships_ui( let ShipBrowser { rows, filter, faction, selected, .. } = &mut *ships; let needle = filter.to_lowercase(); - let mut to_render: Option<(String, String, Vec, String)> = None; + let mut to_render: Option<(String, String, String)> = None; egui::ScrollArea::vertical().show(ui, |ui| { let mut last_section = String::new(); @@ -1667,7 +1667,6 @@ fn draw_ships_ui( to_render = Some(( row.id.clone(), row.stage_file.clone(), - row.parts.clone(), format!("{} ({})", row.name, row.id), )); } @@ -1675,9 +1674,10 @@ fn draw_ships_ui( } }); - if let Some((id, file, parts, label)) = to_render { + if let Some((id, file, label)) = to_render { + let id2 = id.clone(); *selected = Some(id); - render.send(RequestShipRender { file, parts, label }); + render.send(RequestShipRender { file, id: id2, label }); } }); ships.open &= open;