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:
192
crates/sylpheed-formats/examples/capture_verify.rs
Normal file
192
crates/sylpheed-formats/examples/capture_verify.rs
Normal file
@@ -0,0 +1,192 @@
|
||||
//! Verify static ship assembly against multi-snapshot, multi-instance F10
|
||||
//! captures: cluster every part's ship-relative transform across ALL ships in
|
||||
//! ALL snapshots, and compare the dominant clusters with `assemble_ship`.
|
||||
//!
|
||||
//! cargo run --release --example capture_verify -- <stage.xpr> <ship_id> <log> [log…]
|
||||
//!
|
||||
//! Every draw is position-validated against the ship's parts (any LOD, set
|
||||
//! based). Each `bdy_04`(-LOD) draw acts as a ship reference; every validated
|
||||
//! part draw within ship range contributes `rel = WV_ref⁻¹ ∘ WV_part`. The true
|
||||
//! per-part placements appear as tight clusters repeated once PER SHIP per
|
||||
//! snapshot; cross-ship pairings scatter and stay below the cluster threshold.
|
||||
use std::collections::HashSet;
|
||||
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
|
||||
use sylpheed_formats::ship::{assemble_ship, is_base_part, ship_id_of};
|
||||
use sylpheed_formats::ship_capture::{parse_capture, CapturedDraw};
|
||||
|
||||
type M3 = [[f64; 3]; 3];
|
||||
|
||||
fn transpose(m: &M3) -> M3 {
|
||||
[
|
||||
[m[0][0], m[1][0], m[2][0]],
|
||||
[m[0][1], m[1][1], m[2][1]],
|
||||
[m[0][2], m[1][2], m[2][2]],
|
||||
]
|
||||
}
|
||||
fn mmul(a: &M3, b: &M3) -> M3 {
|
||||
let mut o = [[0.0; 3]; 3];
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
o[i][j] = (0..3).map(|k| a[i][k] * b[k][j]).sum();
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
fn mv(m: &M3, v: [f64; 3]) -> [f64; 3] {
|
||||
[
|
||||
m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
|
||||
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
|
||||
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
|
||||
]
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
let bytes = std::fs::read(&a[1]).unwrap();
|
||||
let id = &a[2];
|
||||
|
||||
// Part position sets (base + LODs share the local frame) + per-variant vcounts.
|
||||
let names = xbg7_resource_names(&bytes);
|
||||
let base_parts: Vec<String> = names
|
||||
.iter()
|
||||
.filter(|n| is_base_part(n) && ship_id_of(n) == Some(id.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut want: HashSet<String> = base_parts.iter().cloned().collect();
|
||||
for p in &base_parts {
|
||||
for suf in ["_m", "_l", "_d"] {
|
||||
let c = format!("{p}{suf}");
|
||||
if names.contains(&c) {
|
||||
want.insert(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
|
||||
struct Part {
|
||||
base: String,
|
||||
vcounts: Vec<u32>,
|
||||
pos: Vec<[f32; 3]>, // union of variants
|
||||
}
|
||||
let mut parts: Vec<Part> = Vec::new();
|
||||
for p in &base_parts {
|
||||
let mut vcounts = Vec::new();
|
||||
let mut pos = Vec::new();
|
||||
for cand in [p.clone(), format!("{p}_m"), format!("{p}_l"), format!("{p}_d")] {
|
||||
if let Some(m) = models.iter().find(|m| m.name == cand) {
|
||||
let mp: Vec<[f32; 3]> =
|
||||
m.meshes.iter().flat_map(|s| s.positions.iter().copied()).collect();
|
||||
vcounts.push(mp.len() as u32);
|
||||
pos.extend(mp);
|
||||
}
|
||||
}
|
||||
parts.push(Part { base: p.clone(), vcounts, pos });
|
||||
}
|
||||
|
||||
// Validate a draw against a part (direct or X-mirrored), like ship_capture.
|
||||
let validate = |d: &CapturedDraw, p: &Part| -> Option<bool> {
|
||||
if !p.vcounts.contains(&d.vcount) || d.pos.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let near = |a: &[f32; 3], b: &[f32; 3]| {
|
||||
(a[0] - b[0]).abs() <= 1e-2 && (a[1] - b[1]).abs() <= 1e-2 && (a[2] - b[2]).abs() <= 1e-2
|
||||
};
|
||||
let (mut direct, mut mirror) = (0usize, 0usize);
|
||||
for q in &d.pos {
|
||||
if p.pos.iter().any(|v| near(q, v)) {
|
||||
direct += 1;
|
||||
}
|
||||
let qm = [-q[0], q[1], q[2]];
|
||||
if p.pos.iter().any(|v| near(&qm, v)) {
|
||||
mirror += 1;
|
||||
}
|
||||
}
|
||||
let need = d.pos.len().div_ceil(2);
|
||||
if direct >= need && direct >= mirror {
|
||||
Some(false)
|
||||
} else if mirror >= need {
|
||||
Some(true)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Collect rel samples per part across all logs.
|
||||
let mut samples: std::collections::BTreeMap<String, Vec<[f64; 3]>> = Default::default();
|
||||
for log in &a[3..] {
|
||||
let text = std::fs::read_to_string(log).unwrap();
|
||||
let draws = parse_capture(&text);
|
||||
// label validated draws
|
||||
let mut labeled: Vec<(usize, usize, bool)> = Vec::new(); // (draw, part, mirrored)
|
||||
for (di, d) in draws.iter().enumerate() {
|
||||
for (pi, p) in parts.iter().enumerate() {
|
||||
if let Some(mir) = validate(d, p) {
|
||||
labeled.push((di, pi, mir));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let refs: Vec<usize> = labeled
|
||||
.iter()
|
||||
.filter(|(_, pi, _)| parts[*pi].base.ends_with("bdy_04"))
|
||||
.map(|(di, ..)| *di)
|
||||
.collect();
|
||||
for &ri in &refs {
|
||||
let rf = &draws[ri];
|
||||
let rt = transpose(&rf.r);
|
||||
for &(di, pi, _mir) in &labeled {
|
||||
let d = &draws[di];
|
||||
let dt = [d.t[0] - rf.t[0], d.t[1] - rf.t[1], d.t[2] - rf.t[2]];
|
||||
let rel = mv(&rt, dt);
|
||||
if rel.iter().map(|v| v * v).sum::<f64>().sqrt() < 2600.0 {
|
||||
// also require the rel rotation be finite-sane
|
||||
let _rm = mmul(&rt, &d.r);
|
||||
samples.entry(parts[pi].base.clone()).or_default().push(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("{log}: {} validated draws, {} bdy_04 refs", labeled.len(), refs.len());
|
||||
}
|
||||
|
||||
// Cluster per part (greedy, 8-unit radius), print clusters with ≥3 samples.
|
||||
println!("\n== dominant ship-relative placements (clusters ≥3 samples) ==");
|
||||
for (part, mut v) in samples {
|
||||
v.sort_by(|x, y| x[0].partial_cmp(&y[0]).unwrap());
|
||||
let mut clusters: Vec<([f64; 3], usize)> = Vec::new();
|
||||
for s in &v {
|
||||
match clusters.iter_mut().find(|(c, _)| {
|
||||
(c[0] - s[0]).abs() < 8.0 && (c[1] - s[1]).abs() < 8.0 && (c[2] - s[2]).abs() < 8.0
|
||||
}) {
|
||||
Some((c, n)) => {
|
||||
for k in 0..3 {
|
||||
c[k] = (c[k] * *n as f64 + s[k]) / (*n as f64 + 1.0);
|
||||
}
|
||||
*n += 1;
|
||||
}
|
||||
None => clusters.push((*s, 1)),
|
||||
}
|
||||
}
|
||||
clusters.sort_by(|x, y| y.1.cmp(&x.1));
|
||||
let tops: Vec<String> = clusters
|
||||
.iter()
|
||||
.filter(|(_, n)| *n >= 3)
|
||||
.take(6)
|
||||
.map(|(c, n)| format!("[{:7.1} {:7.1} {:7.1}]×{n}", c[0], c[1], c[2]))
|
||||
.collect();
|
||||
println!(" {part:20} {}", tops.join(" "));
|
||||
}
|
||||
|
||||
// Static assembly rel bdy_04 for comparison.
|
||||
println!("\n== static assemble_ship rel bdy_04 ==");
|
||||
let placed = assemble_ship(&bytes, id, true);
|
||||
if let Some(r4) = placed.iter().find(|p| p.resource == format!("{id}_bdy_04")) {
|
||||
for p in &placed {
|
||||
println!(
|
||||
" {:20} [{:7.1} {:7.1} {:7.1}]",
|
||||
p.resource,
|
||||
p.t[0] - r4.t[0],
|
||||
p.t[1] - r4.t[1],
|
||||
p.t[2] - r4.t[2]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,22 @@ fn main() {
|
||||
p.resource, lo[0], hi[0], lo[1], hi[1], lo[2], hi[2]
|
||||
);
|
||||
}
|
||||
// Exhaust cones at the GN_Jet/GN_SJet frames (the game's visible thrusters).
|
||||
for f in sylpheed_formats::ship::exhaust_frames(&bytes, id) {
|
||||
const SEG: usize = 12;
|
||||
let (r, len) = (22.0f32, 140.0f32);
|
||||
let ring: Vec<[f32; 3]> = (0..SEG)
|
||||
.map(|s| {
|
||||
let a = s as f32 / SEG as f32 * std::f32::consts::TAU;
|
||||
f.apply([a.cos() * r, a.sin() * r, 0.0])
|
||||
})
|
||||
.collect();
|
||||
let apex = f.apply([0.0, 0.0, len]);
|
||||
for s in 0..SEG {
|
||||
tris.push([apex, ring[(s + 1) % SEG], ring[s]]);
|
||||
}
|
||||
println!(" exhaust frame {:16} T=[{:8.1}{:8.1}{:8.1}]", f.resource, f.t[0], f.t[1], f.t[2]);
|
||||
}
|
||||
println!("total {} tris from {} placements", tris.len(), placed.len());
|
||||
|
||||
// Orthographic z-buffered flat renders: top (X/Z, look down −Y) and side (Z/Y, look down −X).
|
||||
|
||||
Reference in New Issue
Block a user