ship: assemble capital ships from the composite scene graph (real placement)
The first cut drew every part at the origin, overlapping into a mess — the parts'
vertex buffers are authored around the origin, NOT pre-placed. The real world
placement lives in the composite scene-graph resource `e_rou_<id>`, exactly like
node_transforms poses the DeltaSaber's fins within one resource — but here the
nodes reference SEPARATE geometry resources.
Two tiers, matching how the game splits a ship (as the user described):
- Hull — `rou_<id>_*` scene nodes name the hull body resources and carry their
world transform (bodies that together form the hull, not individually
destructible). Composed parent∘child down the first-child/next-sibling tree.
- External hardpoints — `GN_*` frame nodes are the Vessel mounts (`GN_Bridge_01`,
`GN_ShieldG_01`, `GN_Engine_01`, matching the IDXD recipe Frame names). Each
bridge / shield-generator / engine part is placed at its frame by category+idx —
the individually-destructible parts.
- mesh: `scene_world_nodes` — walk a composite, emit every `rou_`/`GN_` node with
its composed world transform (same record layout + TRS convention as
node_transforms, cross-resource). ScenePart{resource,m,t}+apply.
- ship: `assemble_ship(bytes, id)` — resolve hull nodes to the ship's own parts
(shared cross-id turrets excluded — they need the per-mount recipe), match
external parts to GN frames, fall back to a raw draw when a prop has no
composite. is_base_part now also drops `_dNN`/`_lNN`/`_mNN` LOD copies.
- viewer: build_ship_model runs assemble_ship, decodes only referenced resources,
bakes each part's world pose into its vertices (one posed copy per placement),
then assembles via the shared prepare path. RequestShipRender carries the id.
Verified headless: parts now spread into coherent single-ship extents
(e108 185×174×683, f106 703×479×2079, f101 carrier 560×539×1712) instead of
piling at the origin. Turret placement (shared eNNN gun models on many GN_*Gun*
frames) still pending the per-mount recipe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<ShipModel> {
|
||||
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_<id>_<part>[_root|_mov|_NN]`; the geometry resource is the
|
||||
/// `<id>_<part>` stem. Structural nodes (`rou_e106_front`, `…_root`) resolve to
|
||||
/// nothing and only pose their children.
|
||||
fn resolve_hull_resource(node: &str, resources: &HashSet<String>) -> Option<String> {
|
||||
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_<id>_*` 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 (`<id>_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<ScenePart> {
|
||||
let names = xbg7_resource_names(bytes);
|
||||
let resources: HashSet<String> = names.iter().cloned().collect();
|
||||
let comps = composites_for(&names, id);
|
||||
|
||||
let mut placed: Vec<ScenePart> = Vec::new();
|
||||
let mut placed_res: HashSet<String> = HashSet::new();
|
||||
// GN hardpoint frames, world-space, from every composite.
|
||||
let mut frames: Vec<ScenePart> = 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user