diff --git a/crates/sylpheed-formats/src/mesh.rs b/crates/sylpheed-formats/src/mesh.rs index b797f68..062bd6d 100644 --- a/crates/sylpheed-formats/src/mesh.rs +++ b/crates/sylpheed-formats/src/mesh.rs @@ -1849,15 +1849,19 @@ pub struct ScenePart { pub m: [[f32; 3]; 3], /// World translation, `(X, up→Y, fore/aft→Z)`. pub t: [f32; 3], + /// Per-axis local scale (a shield generator ships at 0.5, a jet FX at 2.4). + pub s: [f32; 3], } impl ScenePart { - /// Apply the world transform to a local vertex. + /// Apply the world transform to a local vertex: `R·(S·v) + T` (scale is a + /// leaf-local factor; the composite's structural nodes are all unit-scale). pub fn apply(&self, v: [f32; 3]) -> [f32; 3] { + let sv = [v[0] * self.s[0], v[1] * self.s[1], v[2] * self.s[2]]; [ - 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], + self.m[0][0] * sv[0] + self.m[0][1] * sv[1] + self.m[0][2] * sv[2] + self.t[0], + self.m[1][0] * sv[0] + self.m[1][1] * sv[1] + self.m[1][2] * sv[2] + self.t[1], + self.m[2][0] * sv[0] + self.m[2][1] * sv[1] + self.m[2][2] * sv[2] + self.t[2], ] } } @@ -1916,6 +1920,7 @@ pub fn scene_world_nodes(bytes: &[u8], composite_name: &str) -> Vec { name: String, local_m: M3, local_t: [f32; 3], + local_s: [f32; 3], sib_end: Option, } let mut recs: Vec = Vec::new(); @@ -1939,15 +1944,18 @@ pub fn scene_world_nodes(bytes: &[u8], composite_name: &str) -> Vec { let trs = if t44 != 0 && t44 + 32 <= d.len() { read_trs(t44) } else { - [0.0; 8] + [0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0] }; 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)); + // Slots 5–7 are per-axis scale (a hardpoint part ships at e.g. 0.5). + let sc = |v: f64| if v.abs() < 1e-6 { 1.0 } else { v as f32 }; + let local_s = [sc(trs[5]), sc(trs[6]), sc(trs[7])]; // 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 }); + recs.push(Rec { name_start: i, name, local_m, local_t, local_s, sib_end }); i = j.max(i + 1); } @@ -1962,7 +1970,7 @@ pub fn scene_world_nodes(bytes: &[u8], composite_name: &str) -> Vec { 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 }); + out.push(ScenePart { resource: rec.name.clone(), m: wm, t: wt, s: rec.local_s }); let end = rec .sib_end .unwrap_or_else(|| stack.last().map(|s| s.0).unwrap_or(head_end)); diff --git a/crates/sylpheed-formats/src/ship.rs b/crates/sylpheed-formats/src/ship.rs index e66cf92..96d64a5 100644 --- a/crates/sylpheed-formats/src/ship.rs +++ b/crates/sylpheed-formats/src/ship.rs @@ -7,30 +7,25 @@ //! part when the player destroys it (bridge, shield generator, thruster) and swap //! in a `_break` variant. //! -//! Crucially, **each part keeps the world position it had in the whole ship**: -//! the modeller carved the assembled hull into pieces, so `e106_brg_01` (bridge) -//! already sits forward at z≈+291 and `e106_eng_01` (engine) already sits aft at -//! z≈−532 in the shared ship-local frame. There is no per-part joint transform -//! (`mesh::node_transforms` returns nothing for them). So reconstructing the whole -//! ship is simply: **draw every base part of one id together, untransformed.** +//! Each part's vertices are authored around the origin; the world placement lives +//! in the **primary composite scene graph** `e_rou_` (see [`assemble_ship`]). +//! Its node hierarchy (`rou__root`→`…_front`/`…_rear`→`rou__bdy_NN`) names +//! the separate hull resources and carries their world TRS — the cross-resource +//! analogue of how [`mesh::node_transforms`] poses one model's sub-parts. The +//! `GN_*` nodes in the same composite are the Vessel hardpoint frames (bridge / +//! shield / engine / turret mounts, matching the IDXD recipe `Frame` names). //! //! Resource naming (learned from the retail stage containers): //! - `` is `[a-z]NNN` (`e106`, `f105`, `n050`). The leading letter is the //! faction: `e` = ADAN (enemy), `f` = TCAF (player side), `n` = neutral / //! structures / debris. //! - A base part is `_` (`e106_bdy_01`, `e106_brg_01`, `f105_sld_02`). -//! - `_l` / `_m` / `_d` suffixes are lower level-of-detail copies of a base part; -//! `_bNN` / `_dead` / `_break` are destruction geometry; `_joint` / `_open` / -//! `_Near` are logic/animation nodes. All are skipped for a clean whole-ship -//! render (the base part always exists alongside them). -//! - `e_rou_` / `_rou__…` are the scene-graph / logic resources; they -//! carry no drawable geometry and are skipped. -//! -//! Modular turrets (the shared `e3NN` / `e4NN` gun models a [`crate::game_data`] -//! `Vessel` mounts at named hull frames) are their *own* ids here, not folded into -//! the hull family — placing them needs the hull's frame table, which this pass -//! does not resolve. A hull-family render therefore shows the ship body plus any -//! *integral* (`_wep_`) weapons, which is the bulk of the silhouette. +//! - `_l` / `_m` / `_d`(`NN`) suffixes are lower level-of-detail copies; `_bNN` / +//! `_dead` / `_break` are destruction geometry; `_joint` / `_open` / `_Near` are +//! logic/animation nodes. All are skipped. +//! - `e_rou_` is the primary composite; `e_rou__eng` / `_wep_*` are LOCAL +//! detail rigs (their nodes are in their own frame, not ship-space) and are NOT +//! used for placement. use crate::mesh::{scene_world_nodes, xbg7_resource_names, ScenePart}; use std::collections::HashSet; @@ -216,18 +211,20 @@ fn composites_for<'a>(names: &'a [String], id: &str) -> Vec<&'a String> { /// 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). +/// 1. **Hull** — `rou__*` nodes in the primary composite scene graph name the +/// hull body resources and carry their world transform. This tier is exact: the +/// bodies (and any engine posed by a `rou_` node) land where the game puts them. /// 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. +/// `_eng_*`) is placed at its matching frame by category + index. This tier is +/// APPROXIMATE: a `GN_*` frame is the mount *pivot* (often on the centreline), +/// while the part's outboard/rotational offset lives in a detail sub-rig or in +/// runtime code that this static pass does not recover — so external parts land +/// near, not exactly, where they belong. Gated by `include_external`. /// -/// 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 { +/// Turret placement (shared `e3NN`/`e4NN` gun models mounted at many `GN_*Gun*` +/// frames) needs the per-mount Vessel recipe and is not resolved at all. +pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> Vec { let names = xbg7_resource_names(bytes); let resources: HashSet = names.iter().cloned().collect(); @@ -245,7 +242,7 @@ pub fn assemble_ship(bytes: &[u8], id: &str) -> Vec { let mut placed = Vec::new(); 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.push(ScenePart { resource: part.clone(), m: ID, t: [0.0; 3], s: [1.0; 3] }); } return placed; }; @@ -266,14 +263,19 @@ pub fn assemble_ship(bytes: &[u8], id: &str) -> Vec { continue; } if placed_res.insert(res.clone()) { - placed.push(ScenePart { resource: res, m: node.m, t: node.t }); + placed.push(ScenePart { resource: res, m: node.m, t: node.t, s: node.s }); } } } - // Place unplaced external parts at their Vessel hardpoint frame. + // Place unplaced external parts at their Vessel hardpoint frame (APPROXIMATE — + // see the doc comment). Skipped for a clean, exact hull-only render. 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)) { + for part in names + .iter() + .filter(|_| include_external) + .filter(|n| is_base_part(n) && ship_id_of(n) == Some(id)) + { if placed_res.contains(part) { continue; } @@ -287,7 +289,7 @@ pub fn assemble_ship(bytes: &[u8], id: &str) -> Vec { 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.push(ScenePart { resource: part.clone(), m: frame.m, t: frame.t, s: frame.s }); placed_res.insert(part.clone()); } } @@ -297,7 +299,7 @@ pub fn assemble_ship(bytes: &[u8], id: &str) -> Vec { 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.push(ScenePart { resource: part.clone(), m: ID, t: [0.0; 3], s: [1.0; 3] }); } } @@ -378,7 +380,7 @@ mod tests { r.read_file("hidden/resource3d/Stage_S02.xpr").await.unwrap() }) }; - let placed = assemble_ship(&bytes, "e106"); + let placed = assemble_ship(&bytes, "e106", true); 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]); diff --git a/crates/sylpheed-viewer/src/iso_loader.rs b/crates/sylpheed-viewer/src/iso_loader.rs index d00f949..90508ff 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -624,6 +624,9 @@ pub struct ShipBrowser { pub rows: Vec, /// Family id of the ship currently rendered (for row highlight). pub selected: Option, + /// Also place the approximate external parts (bridge / shield gen / engines at + /// their `GN_*` hardpoint frames). Off by default → the exact hull only. + pub show_external: bool, } /// Ask the loader to scan the stage containers and build the ship catalog. @@ -637,6 +640,8 @@ pub struct RequestShipRender { pub file: String, /// Ship family id (`e106`) — drives the scene-graph assembly. pub id: String, + /// Also place the approximate external hardpoint parts. + pub external: bool, /// Display label for the model info line. pub label: String, } @@ -3904,7 +3909,8 @@ fn handle_ship_render_request( let Some(req) = events.read().last() else { return; }; - let (file, id, label) = (req.file.clone(), req.id.clone(), req.label.clone()); + let (file, id, external, label) = + (req.file.clone(), req.id.clone(), req.external, 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 +3925,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, &id, &label, &cancel); + let prepared = build_ship_model(&source, &file, &id, external, &label, &cancel); let _ = sender.send(IsoLoaderMsg::XprPrepared { generation, prepared: Box::new(prepared), @@ -3935,6 +3941,7 @@ fn build_ship_model( source: &SourceKind, file: &str, id: &str, + external: bool, label: &str, cancel: &Arc, ) -> PreparedXpr { @@ -3947,7 +3954,7 @@ fn build_ship_model( // 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); + let placed = sylpheed_formats::ship::assemble_ship(&bytes, id, external); if placed.is_empty() { return PreparedXpr::Info(format!("No parts found for {label}.")); } diff --git a/crates/sylpheed-viewer/src/ui.rs b/crates/sylpheed-viewer/src/ui.rs index 87a910c..c5e560d 100644 --- a/crates/sylpheed-viewer/src/ui.rs +++ b/crates/sylpheed-viewer/src/ui.rs @@ -1589,9 +1589,18 @@ fn draw_ships_ui( ships.filter.clear(); } }); + ui.horizontal(|ui| { + ui.checkbox(&mut ships.show_external, "Show external parts"); + ui.label( + egui::RichText::new("(bridge / shield gens / engines — approximate placement)") + .weak() + .small(), + ); + }); ui.separator(); - let ShipBrowser { rows, filter, faction, selected, .. } = &mut *ships; + let ShipBrowser { rows, filter, faction, selected, show_external, .. } = &mut *ships; + let show_external = *show_external; let needle = filter.to_lowercase(); let mut to_render: Option<(String, String, String)> = None; @@ -1677,7 +1686,7 @@ fn draw_ships_ui( if let Some((id, file, label)) = to_render { let id2 = id.clone(); *selected = Some(id); - render.send(RequestShipRender { file, id: id2, label }); + render.send(RequestShipRender { file, id: id2, external: show_external, label }); } }); ships.open &= open;