//! Correlate a capture **per frame** and cross-check the frames against each //! other — the placement is only believable if independent frames agree. //! //! `correlate_capture` treats one capture log as one set of draws. It is not: //! an F10 press dumps ~14 frames with no delimiter, and `WV_ref⁻¹ · WV_p` only //! cancels the camera within a single frame (see //! [`sylpheed_formats::ship_capture::segment_frames`]). With a moving camera the //! mixed-frame answer is wrong, and — worse — it is wrong *silently*. //! //! So: segment, correlate each frame independently, then report per part the //! median translation and the spread across frames. A part whose spread is a //! few units is measured; a part that swings by hundreds is not, whatever the //! single-shot number said. //! //! Usage: //! SYLPHEED_ISO=... cargo run --release --example correlate_frames -- \ //! [ref_part_substr] [--min-parts N] use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model}; use sylpheed_formats::ship::{is_base_part, ship_id_of}; use sylpheed_formats::ship_capture::{ correlate, parse_capture, parse_drawlog, segment_frames, PartKey, }; use sylpheed_formats::xiso::open_iso; use std::collections::{BTreeMap, HashSet}; use std::path::Path; fn median(mut v: Vec) -> f32 { v.sort_by(|a, b| a.partial_cmp(b).unwrap()); let n = v.len(); if n % 2 == 1 { v[n / 2] } else { 0.5 * (v[n / 2 - 1] + v[n / 2]) } } fn main() { let args: Vec = std::env::args().collect(); let positional: Vec<&String> = args[1..].iter().filter(|a| !a.starts_with("--")).collect(); if positional.len() < 3 { eprintln!("usage: correlate_frames [ref_part] [--min-parts N]"); std::process::exit(2); } let (log, stage, id) = (positional[0], positional[1], positional[2]); let ref_sub = positional.get(3).map(|s| s.as_str()).unwrap_or("bdy_01"); let min_parts: usize = args .iter() .position(|a| a == "--min-parts") .and_then(|i| args.get(i + 1)) .and_then(|s| s.parse().ok()) .unwrap_or(3); let iso = std::env::var("SYLPHEED_ISO").expect("SYLPHEED_ISO"); let text = std::fs::read_to_string(log).expect("read log"); let mut draws = parse_capture(&text); if draws.is_empty() { draws = parse_drawlog(&text); } let frames = segment_frames(&draws); println!("{} draws → {} camera-consistent blocks", draws.len(), frames.len()); let bytes = { let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap(); rt.block_on(async { let mut r = open_iso(Path::new(&iso)).await.unwrap(); r.read_file(&format!("hidden/resource3d/{stage}.xpr")).await.unwrap() }) }; let names = xbg7_resource_names(&bytes); let base_parts: Vec = names .iter() .filter(|n| is_base_part(n) && ship_id_of(n) == Some(id.as_str())) .cloned() .collect(); let mut want: HashSet = 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); let positions_of = |name: &str| -> Option> { let m = models.iter().find(|m| m.name == name)?; Some(m.meshes.iter().flat_map(|s| s.positions.iter().copied()).collect()) }; // part -> [T per frame], and how many frames placed it at all. let mut samples: BTreeMap> = BTreeMap::new(); let mut rots: BTreeMap> = BTreeMap::new(); let mut used_frames = 0usize; for (fi, fr) in frames.iter().enumerate() { let mut keys: Vec = Vec::new(); for part in &base_parts { let variants = [part.clone(), format!("{part}_m"), format!("{part}_l"), format!("{part}_d")]; let union: Vec<[f32; 3]> = variants.iter().filter_map(|v| positions_of(v)).flatten().collect(); for cand in &variants { if let Some(pos) = positions_of(cand) { let vcount = pos.len() as u32; if fr.iter().any(|d| d.vcount == vcount) { keys.push(PartKey { part: part.clone(), vcount, ref_pos: union.clone() }); } } } } let Some(ship) = correlate(id, fr, &keys, ref_sub) else { continue }; if ship.parts.len() < min_parts { continue; } // Placements are expressed in the REFERENCE part's frame, so blocks that // fell back to a different reference (because the requested one was not // drawn in that block) are in a different coordinate system entirely. // Averaging them together is what makes an otherwise clean result look // like it disagrees by exactly the distance between the two references. if !ship.reference.contains(ref_sub) { println!(" block {fi:2}: skipped — reference fell back to {}", ship.reference); continue; } used_frames += 1; println!( " block {fi:2} ({:4} draws): ref={} parts={}", fr.len(), ship.reference, ship.parts.len() ); for p in &ship.parts { samples.entry(p.part.clone()).or_default().push(p.t); rots.entry(p.part.clone()).or_default().push(p.m); } } if used_frames == 0 { println!("\nno block placed {min_parts}+ parts — the ship is not drawn close enough"); return; } // Aggregate by CONSENSUS, not by average. A stage holds several ships of the // same class, they share vertex buffers, and a block can therefore contain // one instance's full-LOD part next to another instance's `_m` copy — two // different buffers, so nothing splits them, and the recovered translation // then belongs to whichever instance the correlator happened to pick. Those // are outliers by thousands of units, so a mean or a median over all blocks // is meaningless; the largest cluster of blocks that agree with each other // is the placement, and the rest are honestly reported as other instances. const TOL: f32 = 25.0; // float noise in the WV products, measured ≤0.4 let cluster = |ts: &Vec<[f32; 3]>| -> (Vec, usize) { let mut best: Vec = Vec::new(); for seed in ts { let near: Vec = ts .iter() .enumerate() .filter(|(_, t)| (0..3).all(|i| (t[i] - seed[i]).abs() < TOL)) .map(|(i, _)| i) .collect(); if near.len() > best.len() { best = near; } } let out = ts.len() - best.len(); (best, out) }; println!("\nacross {used_frames} blocks — consensus T (largest agreeing cluster):"); let mut agree = 0usize; let mut consensus: BTreeMap = BTreeMap::new(); for (part, ts) in &samples { let (cl_idx, outliers) = cluster(ts); let cl: Vec<[f32; 3]> = cl_idx.iter().map(|&i| ts[i]).collect(); let med = [ median(cl.iter().map(|t| t[0]).collect()), median(cl.iter().map(|t| t[1]).collect()), median(cl.iter().map(|t| t[2]).collect()), ]; let spread: Vec = (0..3) .map(|a| { let v: Vec = cl.iter().map(|t| t[a]).collect(); v.iter().cloned().fold(f32::MIN, f32::max) - v.iter().cloned().fold(f32::MAX, f32::min) }) .collect(); let verdict = if cl.len() < 2 { "single block — unverified" } else { agree += 1; "AGREES" }; // How much the ROTATION varies between blocks that agree on position. // A part bolted to the hull reads 0 here; a part that is articulating // (turret aiming, engine gimballing) does not — which is what separates // "the assembler has the rotation wrong" from "the part moved". let all_ms = rots.get(part).cloned().unwrap_or_default(); let ms: Vec<[[f32; 3]; 3]> = cl_idx.iter().filter_map(|&i| all_ms.get(i).copied()).collect(); // Keep a rotation from INSIDE the cluster: the first sample overall can // belong to another instance, and diffing static against that reads as a // rotation error that is really an instance mix-up. consensus.insert( part.clone(), (med, ms.first().copied().unwrap_or([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])), ); let rot_var = ms .iter() .flat_map(|a| ms.iter().map(move |b| (a, b))) .map(|(a, b)| { (0..3) .flat_map(|i| (0..3).map(move |j| (i, j))) .map(|(i, j)| (a[i][j] - b[i][j]).abs()) .fold(0.0f32, f32::max) }) .fold(0.0f32, f32::max); println!( " {part:18} {:2}/{:2} blocks T=[{:9.1}{:9.1}{:9.1}] spread=[{:6.2}{:6.2}{:6.2}] rotVar={rot_var:5.3} {verdict}{}", cl.len(), ts.len(), med[0], med[1], med[2], spread[0], spread[1], spread[2], if outliers > 0 { format!(" (+{outliers} other-instance)") } else { String::new() } ); } println!("\n{agree}/{} parts reproduce across blocks", samples.len()); // `--static `: diff the offline assembler against this // ground truth. Static placements are in ship space, so both sides are // re-expressed in the reference part's frame before comparing — and the // rotation is compared too, because "wrong orientation" is half of the // reported viewer symptom and a translation-only check cannot see it. let Some(si) = args.iter().position(|a| a == "--static") else { return }; let Some(spath) = args.get(si + 1) else { return }; let sbytes = std::fs::read(spath).expect("read stage container"); // `include_external = true` — the engine cluster, the bridge and cross-id // turrets live in SEPARATE composites (`e_rou_e106_eng`, 3 nodes) that the // primary-composite pass does not reach. With `false` an e106 assembles as // 5 parts and the runtime capture's bridge/nacelles read as "not produced by // assemble_ship", which is a property of the caller, not of the format. let scene = sylpheed_formats::ship::assemble_ship(&sbytes, id, true); let Some(sref) = scene.iter().find(|p| p.resource.contains(ref_sub)) else { println!("\nstatic: no part matching '{ref_sub}' — cannot align frames"); return; }; // The reference is placed axis-aligned in every ship seen so far; if that // ever stops holding, the rotation would have to be unwound here too. println!("\nstatic vs runtime (both relative to {}):", sref.resource); let mut worst_t = 0.0f32; let mut worst_r = 0.0f32; for (part, (med, rm)) in &consensus { let (med, rm) = (*med, *rm); // A part may be instanced (mirrored twins share a resource name); take // the static copy that lands nearest the captured one. let cands: Vec<&sylpheed_formats::mesh::ScenePart> = scene.iter().filter(|p| &p.resource == part).collect(); if cands.is_empty() { println!(" {part:18} — not produced by assemble_ship"); continue; } let rel = |p: &sylpheed_formats::mesh::ScenePart| { [p.t[0] - sref.t[0], p.t[1] - sref.t[1], p.t[2] - sref.t[2]] }; let best = cands .iter() .min_by(|a, b| { let d = |p: &sylpheed_formats::mesh::ScenePart| { let r = rel(p); (0..3).map(|i| (r[i] - med[i]).powi(2)).sum::() }; d(a).partial_cmp(&d(b)).unwrap() }) .unwrap(); let r = rel(best); let dt: Vec = (0..3).map(|i| r[i] - med[i]).collect(); let dtm = dt.iter().map(|v| v.abs()).fold(0.0f32, f32::max); let drm = (0..3) .flat_map(|i| (0..3).map(move |j| (i, j))) .map(|(i, j)| (best.m[i][j] - rm[i][j]).abs()) .fold(0.0f32, f32::max); worst_t = worst_t.max(dtm); worst_r = worst_r.max(drm); let mark = if dtm < 1.0 && drm < 0.02 { "MATCH" } else { "DIFFERS" }; println!( " {part:18} static=[{:9.1}{:9.1}{:9.1}] dT={dtm:7.2} dR={drm:6.3} {mark}", r[0], r[1], r[2] ); } println!("\nworst dT={worst_t:.2} worst dR={worst_r:.3} ({} static parts, {} captured)", scene.len(), samples.len()); }