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:
@@ -1840,6 +1840,137 @@ pub fn node_transforms(bytes: &[u8], resource_name: &str) -> Vec<NodePlacement>
|
|||||||
out
|
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_<id>_*` 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<ScenePart> {
|
||||||
|
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<usize>,
|
||||||
|
}
|
||||||
|
let mut recs: Vec<Rec> = 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 ───────────────────────────────────────────────────────────────────
|
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -32,7 +32,8 @@
|
|||||||
//! does not resolve. A hull-family render therefore shows the ship body plus any
|
//! does not resolve. A hull-family render therefore shows the ship body plus any
|
||||||
//! *integral* (`_wep_`) weapons, which is the bulk of the silhouette.
|
//! *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.
|
/// Which side a ship belongs to, from the first letter of its resource id.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[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() {
|
if ship_id_of(resource).is_none() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Level-of-detail copies of a base part.
|
// Level-of-detail copies of a base part: a trailing `_l`/`_m`/`_d` token,
|
||||||
if resource.ends_with("_l") || resource.ends_with("_m") || resource.ends_with("_d") {
|
// optionally with a number (`_d01`, `_l02`).
|
||||||
return false;
|
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.
|
// Destruction / logic geometry.
|
||||||
if resource.contains("break")
|
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()
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -210,4 +344,33 @@ mod tests {
|
|||||||
assert!(e108.parts.iter().any(|p| p.contains("_bdy_")));
|
assert!(e108.parts.iter().any(|p| p.contains("_bdy_")));
|
||||||
assert!(e108.parts.iter().all(|p| is_base_part(p)));
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -635,8 +635,8 @@ pub struct RequestShipCatalog;
|
|||||||
pub struct RequestShipRender {
|
pub struct RequestShipRender {
|
||||||
/// Stage container path to read the parts from.
|
/// Stage container path to read the parts from.
|
||||||
pub file: String,
|
pub file: String,
|
||||||
/// Base part resource names to decode + assemble.
|
/// Ship family id (`e106`) — drives the scene-graph assembly.
|
||||||
pub parts: Vec<String>,
|
pub id: String,
|
||||||
/// Display label for the model info line.
|
/// Display label for the model info line.
|
||||||
pub label: String,
|
pub label: String,
|
||||||
}
|
}
|
||||||
@@ -3904,7 +3904,7 @@ fn handle_ship_render_request(
|
|||||||
let Some(req) = events.read().last() else {
|
let Some(req) = events.read().last() else {
|
||||||
return;
|
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.
|
// Supersede any in-flight XPR/stage decode, exactly like a file selection.
|
||||||
xpr_load.generation = xpr_load.generation.wrapping_add(1);
|
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 source = iso_state.source_kind.clone();
|
||||||
let sender = channels.sender.clone();
|
let sender = channels.sender.clone();
|
||||||
std::thread::spawn(move || {
|
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 {
|
let _ = sender.send(IsoLoaderMsg::XprPrepared {
|
||||||
generation,
|
generation,
|
||||||
prepared: Box::new(prepared),
|
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
|
/// Read a stage container, resolve the ship's scene-graph placement, decode only
|
||||||
/// single world-space model (reusing the XPR model prepare path).
|
/// 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"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn build_ship_model(
|
fn build_ship_model(
|
||||||
source: &SourceKind,
|
source: &SourceKind,
|
||||||
file: &str,
|
file: &str,
|
||||||
parts: &[String],
|
id: &str,
|
||||||
label: &str,
|
label: &str,
|
||||||
cancel: &Arc<AtomicBool>,
|
cancel: &Arc<AtomicBool>,
|
||||||
) -> PreparedXpr {
|
) -> PreparedXpr {
|
||||||
@@ -3942,15 +3943,57 @@ fn build_ship_model(
|
|||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => return PreparedXpr::Info(format!("Could not read {file}: {e}")),
|
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 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() {
|
if should_cancel() {
|
||||||
return PreparedXpr::Cancelled;
|
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() {
|
if models.is_empty() {
|
||||||
return PreparedXpr::Info(format!("No geometry decoded for {label}."));
|
return PreparedXpr::Info(format!("No geometry decoded for {label}."));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assemble, then relabel with the ship's display name.
|
// Assemble, then relabel with the ship's display name.
|
||||||
match prepare_ship(&bytes, &models) {
|
match prepare_ship(&bytes, &models) {
|
||||||
PreparedXpr::Model {
|
PreparedXpr::Model {
|
||||||
|
|||||||
@@ -1593,7 +1593,7 @@ fn draw_ships_ui(
|
|||||||
|
|
||||||
let ShipBrowser { rows, filter, faction, selected, .. } = &mut *ships;
|
let ShipBrowser { rows, filter, faction, selected, .. } = &mut *ships;
|
||||||
let needle = filter.to_lowercase();
|
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| {
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
let mut last_section = String::new();
|
let mut last_section = String::new();
|
||||||
@@ -1667,7 +1667,6 @@ fn draw_ships_ui(
|
|||||||
to_render = Some((
|
to_render = Some((
|
||||||
row.id.clone(),
|
row.id.clone(),
|
||||||
row.stage_file.clone(),
|
row.stage_file.clone(),
|
||||||
row.parts.clone(),
|
|
||||||
format!("{} ({})", row.name, row.id),
|
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);
|
*selected = Some(id);
|
||||||
render.send(RequestShipRender { file, parts, label });
|
render.send(RequestShipRender { file, id: id2, label });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ships.open &= open;
|
ships.open &= open;
|
||||||
|
|||||||
Reference in New Issue
Block a user