ship: apply scale + gate approximate external parts behind a toggle

Two changes after user feedback that externals sit near-but-wrong (shield inset
into the hull, thruster floating close):

- Scale: the joint table's slots 5–7 are per-axis scale (a GN_ShieldG frame ships
  at 0.5, GN_Jet FX at 2.4–6.5) and were being ignored, rendering scaled parts at
  the wrong size. ScenePart now carries `s` and applies R·(S·v)+T.
- External placement is fundamentally approximate: a GN_* frame is the mount
  PIVOT (f105's two GN_ShieldG frames are both on the centreline), while the
  part's outboard/rotational offset lives in a detail sub-rig / runtime code this
  static pass can't recover. So assemble_ship gains `include_external`: the hull
  tier (rou_ nodes) is exact and always drawn; the external tier (bridge / shield
  / engine at GN_ frames) is opt-in via a "Show external parts" checkbox in the
  Ships browser, off by default so the clean, correct hull is the default view.

The module doc is corrected to the scene-graph mechanism (the old "draw parts
untransformed" claim was wrong).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-23 22:11:47 +02:00
parent 8c8e2dbeb4
commit 79c78cc7e1
4 changed files with 72 additions and 46 deletions

View File

@@ -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<ScenePart> {
name: String,
local_m: M3,
local_t: [f32; 3],
local_s: [f32; 3],
sib_end: Option<usize>,
}
let mut recs: Vec<Rec> = Vec::new();
@@ -1939,15 +1944,18 @@ pub fn scene_world_nodes(bytes: &[u8], composite_name: &str) -> Vec<ScenePart> {
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 57 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<ScenePart> {
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));

View File

@@ -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_<id>` (see [`assemble_ship`]).
//! Its node hierarchy (`rou_<id>_root`→`…_front`/`…_rear`→`rou_<id>_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):
//! - `<id>` 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 `<id>_<name>` (`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_<id>` / `_rou_<id>_…` 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_<id>` is the primary composite; `e_rou_<id>_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_<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).
/// 1. **Hull** — `rou_<id>_*` 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 (`<id>_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<ScenePart> {
/// 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<ScenePart> {
let names = xbg7_resource_names(bytes);
let resources: HashSet<String> = names.iter().cloned().collect();
@@ -245,7 +242,7 @@ pub fn assemble_ship(bytes: &[u8], id: &str) -> Vec<ScenePart> {
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<ScenePart> {
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<ScenePart> {
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<ScenePart> {
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]);

View File

@@ -624,6 +624,9 @@ pub struct ShipBrowser {
pub rows: Vec<ShipRow>,
/// Family id of the ship currently rendered (for row highlight).
pub selected: Option<String>,
/// 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<AtomicBool>,
) -> 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}."));
}

View File

@@ -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;