viewer,ship: external parts ON by default + Ships catalog = composite ships only
Fixes the two things the user saw in the Ships browser: - "Can't find the thrusters": show_external defaulted OFF, hiding the engine rig / bridge / turrets entirely. Externals are EXACT since the node-TRS fix, so the toggle now defaults ON (relabelled; off = bare hull bodies). - "Some parts very far away": families without a composite scene graph (fighter morph sets like f002 whose detached parts are authored far from the origin, shared turret/prop part families) fell into the stack-at-origin fallback and rendered as piles with stray pieces. ShipModel now carries has_composite and the catalog lists only real assembled ships. Offline audit across ALL stages (ship_audit example): the only far-away outliers were composite-less f002 morph parts (now filtered); every capital ship assembles coherently (e108 places its 7 turret instances, f105 its mirrored shield generators). All primary composites are single-key tracks — multikey exists only in animation composites (e901 attacks, missile-open), which assemble_ship does not select. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
141
crates/sylpheed-formats/examples/ship_audit.rs
Normal file
141
crates/sylpheed-formats/examples/ship_audit.rs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
//! Audit static ship assembly across ALL stages: flag parts placed far outside
|
||||||
|
//! their ship's cluster (placement bugs) and joint tracks with more than one
|
||||||
|
//! keyframe (which the single-key TRS read does not model).
|
||||||
|
//! cargo run --release --example ship_audit -- <resource3d dir>
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use sylpheed_formats::mesh::{xbg7_descriptor_range, xbg7_resource_names, Xbg7Model};
|
||||||
|
use sylpheed_formats::ship::{assemble_ship, ships_in_container};
|
||||||
|
|
||||||
|
fn be32(d: &[u8], o: usize) -> u32 {
|
||||||
|
if o + 4 > d.len() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
u32::from_be_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count joint-track records whose key count != 1 in a composite's descriptor.
|
||||||
|
fn multikey_tracks(bytes: &[u8], comp: &str) -> usize {
|
||||||
|
let Some((desc, desc_end)) = xbg7_descriptor_range(bytes, comp) else { return 0 };
|
||||||
|
let d = &bytes[desc..desc_end];
|
||||||
|
let mut n = 0usize;
|
||||||
|
let mut i = 0usize;
|
||||||
|
while i + 4 <= d.len() {
|
||||||
|
let starts = &d[i..i.min(d.len() - 4) + 4] == b"rou_"
|
||||||
|
|| (i + 3 <= d.len() && &d[i..i + 3] == b"GN_");
|
||||||
|
if !starts {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut j = i;
|
||||||
|
while j < d.len() && d[j] != 0 && d[j].is_ascii_graphic() {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
let mut ff = i;
|
||||||
|
let scan_end = (i + 0x90).min(d.len().saturating_sub(4));
|
||||||
|
while ff < scan_end && be32(d, ff) != 0xFFFF_FFFF {
|
||||||
|
ff += 4;
|
||||||
|
}
|
||||||
|
if ff < scan_end && ff >= 0x10 {
|
||||||
|
let t44 = be32(d, ff - 0x10) as usize;
|
||||||
|
if t44 != 0 && t44 + 32 <= d.len() {
|
||||||
|
for k in 0..8 {
|
||||||
|
let p = be32(d, t44 + k * 4) as usize;
|
||||||
|
if p >= 0x50 && p + 16 <= d.len() {
|
||||||
|
let head = be32(d, p - 0x50);
|
||||||
|
if head != 1 && head != 0 {
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i = j.max(i + 1);
|
||||||
|
}
|
||||||
|
n
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let dir = std::env::args().nth(1).unwrap();
|
||||||
|
let mut entries: Vec<_> = std::fs::read_dir(&dir)
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.map(|e| e.path())
|
||||||
|
.filter(|p| {
|
||||||
|
let n = p.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||||
|
n.starts_with("Stage_") && n.ends_with(".xpr")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
entries.sort();
|
||||||
|
|
||||||
|
for path in entries {
|
||||||
|
let stage = path.file_name().unwrap().to_string_lossy().to_string();
|
||||||
|
let bytes = std::fs::read(&path).unwrap();
|
||||||
|
let names = xbg7_resource_names(&bytes);
|
||||||
|
|
||||||
|
// Multi-key animation tracks per composite (breaks the single-key read).
|
||||||
|
for n in names.iter().filter(|n| n.contains("rou_") && !n.contains("break")) {
|
||||||
|
let mk = multikey_tracks(&bytes, n);
|
||||||
|
if mk > 0 {
|
||||||
|
println!("MULTIKEY {stage} {n}: {mk} tracks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for ship in ships_in_container(&bytes) {
|
||||||
|
if ship.parts.len() < 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let placed = assemble_ship(&bytes, &ship.id, true);
|
||||||
|
if placed.len() < 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let want: HashSet<String> = placed.iter().map(|p| p.resource.clone()).collect();
|
||||||
|
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
|
||||||
|
// Per-placement world centroid.
|
||||||
|
let mut cents: Vec<(String, [f32; 3])> = Vec::new();
|
||||||
|
for p in &placed {
|
||||||
|
let Some(m) = models.iter().find(|m| m.name == p.resource) else { continue };
|
||||||
|
let (mut c, mut n) = ([0.0f32; 3], 0f32);
|
||||||
|
for sub in &m.meshes {
|
||||||
|
for v in &sub.positions {
|
||||||
|
let w = p.apply(*v);
|
||||||
|
c[0] += w[0];
|
||||||
|
c[1] += w[1];
|
||||||
|
c[2] += w[2];
|
||||||
|
n += 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n > 0.0 {
|
||||||
|
cents.push((p.resource.clone(), [c[0] / n, c[1] / n, c[2] / n]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cents.len() < 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Ship cluster = median centroid; flag parts > 3× the median spread.
|
||||||
|
let med = |k: usize| -> f32 {
|
||||||
|
let mut v: Vec<f32> = cents.iter().map(|(_, c)| c[k]).collect();
|
||||||
|
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||||
|
v[v.len() / 2]
|
||||||
|
};
|
||||||
|
let m = [med(0), med(1), med(2)];
|
||||||
|
let dists: Vec<f32> = cents
|
||||||
|
.iter()
|
||||||
|
.map(|(_, c)| {
|
||||||
|
((c[0] - m[0]).powi(2) + (c[1] - m[1]).powi(2) + (c[2] - m[2]).powi(2)).sqrt()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut sd: Vec<f32> = dists.clone();
|
||||||
|
sd.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||||
|
let spread = sd[sd.len() / 2].max(50.0);
|
||||||
|
for ((res, c), dist) in cents.iter().zip(&dists) {
|
||||||
|
if *dist > 6.0 * spread && *dist > 800.0 {
|
||||||
|
println!(
|
||||||
|
"OUTLIER {stage} {}: {res} centroid=[{:.0} {:.0} {:.0}] dist={:.0} (spread {:.0})",
|
||||||
|
ship.id, c[0], c[1], c[2], dist, spread
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("audit done");
|
||||||
|
}
|
||||||
@@ -74,6 +74,10 @@ pub struct ShipModel {
|
|||||||
pub faction: Faction,
|
pub faction: Faction,
|
||||||
/// Base geometry resource names to draw together, in directory order.
|
/// Base geometry resource names to draw together, in directory order.
|
||||||
pub parts: Vec<String>,
|
pub parts: Vec<String>,
|
||||||
|
/// Whether a composite scene graph (`e_rou_<id>`) exists — i.e. this is a
|
||||||
|
/// real assembled ship. Families without one (fighter morph sets, shared
|
||||||
|
/// turret/prop parts) have no placement data and would stack at the origin.
|
||||||
|
pub has_composite: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `[a-z]NNN` id prefix of a resource name, if it has one (`e106_bdy_01`
|
/// The `[a-z]NNN` id prefix of a resource name, if it has one (`e106_bdy_01`
|
||||||
@@ -146,7 +150,13 @@ pub fn ships_in_container(bytes: &[u8]) -> Vec<ShipModel> {
|
|||||||
let id = ship_id_of(n).unwrap().to_string();
|
let id = ship_id_of(n).unwrap().to_string();
|
||||||
let entry = ships.entry(id.clone()).or_insert_with(|| {
|
let entry = ships.entry(id.clone()).or_insert_with(|| {
|
||||||
ids.push(id.clone());
|
ids.push(id.clone());
|
||||||
ShipModel { id: id.clone(), faction: Faction::from_id(&id), parts: Vec::new() }
|
let has_composite = !composites_for(&names, &id).is_empty();
|
||||||
|
ShipModel {
|
||||||
|
id: id.clone(),
|
||||||
|
faction: Faction::from_id(&id),
|
||||||
|
parts: Vec::new(),
|
||||||
|
has_composite,
|
||||||
|
}
|
||||||
});
|
});
|
||||||
entry.parts.push(n.clone());
|
entry.parts.push(n.clone());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -613,7 +613,7 @@ pub struct ShipRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The Ships browser state — capital ships assembled from XBG7 part families.
|
/// The Ships browser state — capital ships assembled from XBG7 part families.
|
||||||
#[derive(Resource, Default)]
|
#[derive(Resource)]
|
||||||
pub struct ShipBrowser {
|
pub struct ShipBrowser {
|
||||||
pub open: bool,
|
pub open: bool,
|
||||||
pub loading: bool,
|
pub loading: bool,
|
||||||
@@ -624,11 +624,27 @@ pub struct ShipBrowser {
|
|||||||
pub rows: Vec<ShipRow>,
|
pub rows: Vec<ShipRow>,
|
||||||
/// Family id of the ship currently rendered (for row highlight).
|
/// Family id of the ship currently rendered (for row highlight).
|
||||||
pub selected: Option<String>,
|
pub selected: Option<String>,
|
||||||
/// Also place the approximate external parts (bridge / shield gen / engines at
|
/// Also place the external parts: bridge / shield generators / the engine
|
||||||
/// their `GN_*` hardpoint frames). Off by default → the exact hull only.
|
/// cluster rig / cross-mounted turrets. EXACT (capture-validated) since the
|
||||||
|
/// node-TRS fix, so ON by default; off = bare hull bodies only.
|
||||||
pub show_external: bool,
|
pub show_external: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for ShipBrowser {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
open: false,
|
||||||
|
loading: false,
|
||||||
|
loaded: false,
|
||||||
|
filter: String::new(),
|
||||||
|
faction: String::new(),
|
||||||
|
rows: Vec::new(),
|
||||||
|
selected: None,
|
||||||
|
show_external: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Ask the loader to scan the stage containers and build the ship catalog.
|
/// Ask the loader to scan the stage containers and build the ship catalog.
|
||||||
#[derive(Event, Default)]
|
#[derive(Event, Default)]
|
||||||
pub struct RequestShipCatalog;
|
pub struct RequestShipCatalog;
|
||||||
@@ -3860,8 +3876,11 @@ fn build_ship_catalog(source: &SourceKind) -> Vec<ShipRow> {
|
|||||||
};
|
};
|
||||||
let label = format!("S{n:02}");
|
let label = format!("S{n:02}");
|
||||||
for sm in ships_in_container(&bytes) {
|
for sm in ships_in_container(&bytes) {
|
||||||
// Skip single-piece props / debris — not "whole ships".
|
// Skip single-piece props / debris — not "whole ships" — and any
|
||||||
if sm.parts.len() < 2 {
|
// family with no composite scene graph (fighter morph sets, shared
|
||||||
|
// turret parts): those have no placement data and would render as a
|
||||||
|
// pile at the origin with stray far-away pieces.
|
||||||
|
if sm.parts.len() < 2 || !sm.has_composite {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let v = vmap.get(&sm.id);
|
let v = vmap.get(&sm.id);
|
||||||
|
|||||||
@@ -1590,7 +1590,7 @@ fn draw_ships_ui(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.checkbox(&mut ships.show_external, "Show external parts");
|
ui.checkbox(&mut ships.show_external, "External parts (bridge/engines/turrets)");
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new("(bridge / shield gens / engines — approximate placement)")
|
egui::RichText::new("(bridge / shield gens / engines — approximate placement)")
|
||||||
.weak()
|
.weak()
|
||||||
|
|||||||
Reference in New Issue
Block a user