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:
@@ -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<String>,
|
||||
/// 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<AtomicBool>,
|
||||
) -> 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<String> = 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<String> =
|
||||
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<Xbg7Model> = 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 {
|
||||
|
||||
@@ -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>, 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;
|
||||
|
||||
Reference in New Issue
Block a user