ship: engine placement CONFIRMED by 43-instance capture; exhaust markers added

capture_verify example: clusters every part's ship-relative transform across
the new multi-snapshot, multi-instance F10 captures (5 snapshots, 43 e106
instances, all angles). Verdict: the dominant clusters match the static
assembly to ~1 unit on EVERY part — including BOTH engine nacelles at
(+-131, -133, -131) — so the "engines inside the hull" appearance is the
game's own placement: the engine geometry sits recessed in the aft hull, and
the visible "thrusters" in-game are exhaust FX drawn at the GN_Jet/GN_SJet
frames (Z ~ -570, past the stern).

To close that perception gap the viewer now draws simple exhaust cones at the
game's own jet frames (ship::exhaust_frames; part of the external-parts
toggle). The jet frames flip Z, so the cone apex is authored at +Z and lands
trailing aft — verified in the offline render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-26 19:44:12 +02:00
parent 31f2637e6c
commit ea32e77e45
4 changed files with 285 additions and 0 deletions

View File

@@ -3646,6 +3646,40 @@ fn handle_voice_request(
});
}
/// A simple exhaust-plume cone: base ring at the frame origin opening toward
/// **Z** (the jet frames face aft), apex trailing behind. Stands in for the
/// game's engine-exhaust FX so assembled ships visually read as powered.
#[cfg(not(target_arch = "wasm32"))]
fn exhaust_cone_mesh() -> sylpheed_formats::mesh::GameMesh {
const SEG: usize = 12;
const R: f32 = 22.0;
const LEN: f32 = 140.0;
let mut positions = Vec::with_capacity(SEG + 2);
let mut normals = Vec::with_capacity(SEG + 2);
positions.push([0.0, 0.0, LEN]); // apex — the jet frame flips Z, sending it aft
normals.push([0.0, 0.0, -1.0]);
for s in 0..SEG {
let a = s as f32 / SEG as f32 * std::f32::consts::TAU;
positions.push([a.cos() * R, a.sin() * R, 0.0]);
normals.push([a.cos(), a.sin(), 0.3]);
}
positions.push([0.0, 0.0, 0.0]); // base centre (cap)
normals.push([0.0, 0.0, 1.0]);
let mut indices = Vec::new();
for s in 0..SEG as u32 {
let (a, b) = (1 + s, 1 + (s + 1) % SEG as u32);
indices.extend_from_slice(&[0, b, a]); // side
indices.extend_from_slice(&[(SEG + 1) as u32, a, b]); // cap
}
sylpheed_formats::mesh::GameMesh {
positions,
normals,
uvs: Vec::new(),
indices,
name: Some("exhaust".to_string()),
}
}
/// Duration (seconds) of a PCM WAV from its `fmt`/`data` chunks.
#[cfg(not(target_arch = "wasm32"))]
fn wav_duration(path: &Path) -> Option<f32> {
@@ -4033,6 +4067,26 @@ fn build_ship_model(
return PreparedXpr::Info(format!("No geometry decoded for {label}."));
}
// Exhaust markers: the game's visible "thrusters" are FX drawn at the
// GN_Jet/GN_SJet frames (the engine GEOMETRY is recessed in the hull —
// capture-verified). Draw a simple cone at each frame so the assembled ship
// reads correctly.
if external {
for (i, f) in sylpheed_formats::ship::exhaust_frames(&bytes, id).iter().enumerate() {
let mut cone = exhaust_cone_mesh();
for v in &mut cone.positions {
*v = f.apply(*v);
}
for nrm in &mut cone.normals {
*nrm = rot(&f.m, nrm);
}
models.push(Xbg7Model {
name: format!("__exhaust_{i}"),
meshes: vec![cone],
});
}
}
// Assemble, then relabel with the ship's display name.
match prepare_ship(&bytes, &models) {
PreparedXpr::Model {