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>
193 lines
7.1 KiB
Rust
193 lines
7.1 KiB
Rust
//! 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]
|
||
);
|
||
}
|
||
}
|
||
}
|