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:
@@ -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 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<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));
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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}."));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user