viewer: Ships browser (View ▸ Ships) — assemble & view whole capital ships
Adds a Ships window that scans the stage containers, reconstructs each capital ship from its XBG7 part family (via sylpheed_formats::ship), and renders the whole assembled model in the 3D view on click. - iso_loader: ShipBrowser resource + ShipRow (id, faction, name, parts, stage, and linked Vessel stats: HP/size/turrets/bridges/shield gens), RequestShipCatalog + RequestShipRender events, ShipCatalogLoaded message. build_ship_catalog scans Stage_S01..S30, groups resources into ships, joins Vessel recipes by id, and sorts capital ships (Vessel-matched) first. handle_ship_render_request decodes only the ship's parts (models_named) and assembles them off-thread, reusing the existing XprPrepared → apply_prepared_xpr render + camera-focus path. - prepare_models gains an `assemble` mode (prepare_ship): parts share ONE recentre so each keeps its ship-local offset (bridge fore, engines aft) instead of the per-model recentre / thumbnail grid used for a stage browse. - ui: draw_ships_ui (own system, keeps draw_viewer_ui under the 16-param limit) — faction filter chips, name/id search, collapsible per-ship stat cards with an "Assemble & view in 3D" button; colour-coded by faction. View ▸ Ships menu item. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -586,6 +586,61 @@ pub struct GameData {
|
||||
#[derive(Event, Default)]
|
||||
pub struct RequestGameData;
|
||||
|
||||
/// One assemblable ship in the Ships browser: an XBG7 part family reconstructed
|
||||
/// into a whole model, with any linked [`sylpheed_formats::game_data::Vessel`]
|
||||
/// stats.
|
||||
#[derive(Clone)]
|
||||
pub struct ShipRow {
|
||||
/// Resource-family id, e.g. `e106`.
|
||||
pub id: String,
|
||||
/// `ADAN` / `TCAF` / `Neutral` / `Other`.
|
||||
pub faction: String,
|
||||
/// Display name (from the linked Vessel, else the id).
|
||||
pub name: String,
|
||||
/// The stage container to render from, e.g. `hidden/resource3d/Stage_S02.xpr`.
|
||||
pub stage_file: String,
|
||||
/// Stage labels the ship appears in (`S02`, `S04`).
|
||||
pub stages: Vec<String>,
|
||||
/// Base part resource names to draw together.
|
||||
pub parts: Vec<String>,
|
||||
/// True when the id matched a `Vessel` recipe (a real capital ship).
|
||||
pub has_vessel: bool,
|
||||
pub hp: Option<f32>,
|
||||
pub size: Option<(f32, f32, f32)>,
|
||||
pub turrets: Option<i64>,
|
||||
pub bridges: Option<i64>,
|
||||
pub shield_gens: Option<i64>,
|
||||
}
|
||||
|
||||
/// The Ships browser state — capital ships assembled from XBG7 part families.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct ShipBrowser {
|
||||
pub open: bool,
|
||||
pub loading: bool,
|
||||
pub loaded: bool,
|
||||
pub filter: String,
|
||||
/// Faction filter: "" = all, else "ADAN"/"TCAF"/"Neutral".
|
||||
pub faction: String,
|
||||
pub rows: Vec<ShipRow>,
|
||||
/// Family id of the ship currently rendered (for row highlight).
|
||||
pub selected: Option<String>,
|
||||
}
|
||||
|
||||
/// Ask the loader to scan the stage containers and build the ship catalog.
|
||||
#[derive(Event, Default)]
|
||||
pub struct RequestShipCatalog;
|
||||
|
||||
/// Ask the loader to assemble + render one ship's part family.
|
||||
#[derive(Event)]
|
||||
pub struct RequestShipRender {
|
||||
/// Stage container path to read the parts from.
|
||||
pub file: String,
|
||||
/// Base part resource names to decode + assemble.
|
||||
pub parts: Vec<String>,
|
||||
/// Display label for the model info line.
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// The currently-open IPFB data pack, shown as a master-detail browser.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct PakView {
|
||||
@@ -676,6 +731,8 @@ enum IsoLoaderMsg {
|
||||
VoiceLibraryLoaded {
|
||||
clips: Vec<sylpheed_formats::slb::VoiceClip>,
|
||||
},
|
||||
/// The assembled-ship catalog for the Ships browser.
|
||||
ShipCatalogLoaded(Vec<ShipRow>),
|
||||
/// The decoded game-data tables for the browser.
|
||||
GameDataLoaded(Box<GameSnapshot>),
|
||||
/// User dismissed the file dialog — not an error.
|
||||
@@ -896,11 +953,14 @@ impl Plugin for IsoLoaderPlugin {
|
||||
.init_resource::<AudioPreview>()
|
||||
.init_resource::<VoiceLibrary>()
|
||||
.init_resource::<GameData>()
|
||||
.init_resource::<ShipBrowser>()
|
||||
.add_event::<RequestSubtitles>()
|
||||
.add_event::<RequestVoice>()
|
||||
.add_event::<RequestAudio>()
|
||||
.add_event::<RequestVoiceLibrary>()
|
||||
.add_event::<RequestGameData>();
|
||||
.add_event::<RequestGameData>()
|
||||
.add_event::<RequestShipCatalog>()
|
||||
.add_event::<RequestShipRender>();
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
@@ -930,6 +990,8 @@ impl Plugin for IsoLoaderPlugin {
|
||||
advance_audio_playback,
|
||||
handle_voice_library_request,
|
||||
handle_game_data_request,
|
||||
handle_ship_catalog_request,
|
||||
handle_ship_render_request,
|
||||
)
|
||||
.chain()
|
||||
.in_set(IsoLoaderSystemSet),
|
||||
@@ -1770,6 +1832,7 @@ fn poll_loader_channel(
|
||||
mut audio_preview: ResMut<AudioPreview>,
|
||||
mut voice_lib: ResMut<VoiceLibrary>,
|
||||
mut game_data: ResMut<GameData>,
|
||||
mut ships: ResMut<ShipBrowser>,
|
||||
) {
|
||||
let receiver = channels.receiver.lock().unwrap();
|
||||
loop {
|
||||
@@ -1894,6 +1957,11 @@ fn poll_loader_channel(
|
||||
game_data.loaded = true;
|
||||
}
|
||||
}
|
||||
Ok(IsoLoaderMsg::ShipCatalogLoaded(rows)) => {
|
||||
ships.loading = false;
|
||||
ships.loaded = !rows.is_empty();
|
||||
ships.rows = rows;
|
||||
}
|
||||
Ok(IsoLoaderMsg::Cancelled) => {
|
||||
iso_state.loading = false;
|
||||
browser.loading = false;
|
||||
@@ -2321,6 +2389,25 @@ fn pack_metallic_roughness(spc: Option<&Image>, gls: Option<&Image>) -> Option<I
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn prepare_models(bytes: &[u8], models: &[sylpheed_formats::mesh::Xbg7Model]) -> PreparedXpr {
|
||||
prepare_models_impl(bytes, models, false)
|
||||
}
|
||||
|
||||
/// Assemble several XBG7 resources into ONE whole model in a shared coordinate
|
||||
/// frame — the capital-ship case, where each part (`e106_bdy_01`, `e106_brg_01`,
|
||||
/// …) already carries its ship-local position. Unlike the stage path, the parts
|
||||
/// are NOT laid out in a grid and are recentred by a single shared bbox so their
|
||||
/// relative placement is preserved. See [`sylpheed_formats::ship`].
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn prepare_ship(bytes: &[u8], parts: &[sylpheed_formats::mesh::Xbg7Model]) -> PreparedXpr {
|
||||
prepare_models_impl(bytes, parts, true)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn prepare_models_impl(
|
||||
bytes: &[u8],
|
||||
models: &[sylpheed_formats::mesh::Xbg7Model],
|
||||
assemble: bool,
|
||||
) -> PreparedXpr {
|
||||
use bevy::render::mesh::{Indices, PrimitiveTopology};
|
||||
use sylpheed_formats::texture::X360Texture;
|
||||
|
||||
@@ -2328,7 +2415,9 @@ fn prepare_models(bytes: &[u8], models: &[sylpheed_formats::mesh::Xbg7Model]) ->
|
||||
const GAP: f32 = 4.0;
|
||||
const MAX_EXTENT: f32 = 20_000.0; // drop skybox planes / stray-vertex anchors
|
||||
let pitch = CELL + GAP;
|
||||
let is_stage = models.len() > 1;
|
||||
// Grid layout only for a true multi-model stage browse — an assembled ship
|
||||
// keeps its parts in one shared space.
|
||||
let is_stage = models.len() > 1 && !assemble;
|
||||
|
||||
// Keep models with real, bounded geometry.
|
||||
let drawn: Vec<&sylpheed_formats::mesh::Xbg7Model> = models
|
||||
@@ -2425,6 +2514,25 @@ fn prepare_models(bytes: &[u8], models: &[sylpheed_formats::mesh::Xbg7Model]) ->
|
||||
let mut total_v = 0usize;
|
||||
let mut total_t = 0usize;
|
||||
|
||||
// Assembled ship: one shared recentre across ALL parts so each part keeps its
|
||||
// ship-local offset (bridge fore, engines aft). Computed from the raw parts —
|
||||
// ship parts carry no node transform, so raw == placed.
|
||||
let shared_center = if assemble {
|
||||
let mut lo = Vec3::splat(f32::MAX);
|
||||
let mut hi = Vec3::splat(f32::MIN);
|
||||
for m in &drawn {
|
||||
for sub in &m.meshes {
|
||||
for p in &sub.positions {
|
||||
lo = lo.min(Vec3::from_array(*p));
|
||||
hi = hi.max(Vec3::from_array(*p));
|
||||
}
|
||||
}
|
||||
}
|
||||
(lo.x <= hi.x).then(|| (lo + hi) * 0.5)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
for (i, model) in drawn.iter().enumerate() {
|
||||
// Per-model bbox → recentre + (stage) uniform scale to the grid cell.
|
||||
let mut lo = Vec3::splat(f32::MAX);
|
||||
@@ -2438,7 +2546,9 @@ fn prepare_models(bytes: &[u8], models: &[sylpheed_formats::mesh::Xbg7Model]) ->
|
||||
if lo.x > hi.x {
|
||||
continue;
|
||||
}
|
||||
let center = (lo + hi) * 0.5;
|
||||
// An assembled ship uses the shared centre (preserve relative placement);
|
||||
// otherwise recentre each model on its own bbox.
|
||||
let center = shared_center.unwrap_or((lo + hi) * 0.5);
|
||||
let (scale, cell) = if is_stage {
|
||||
let extent = (hi - lo).max_element().max(1e-3);
|
||||
let col = i % cols;
|
||||
@@ -2601,7 +2711,17 @@ fn prepare_models(bytes: &[u8], models: &[sylpheed_formats::mesh::Xbg7Model]) ->
|
||||
} else {
|
||||
CELL
|
||||
};
|
||||
let (name, info) = if is_stage {
|
||||
let (name, info) = if assemble {
|
||||
(
|
||||
"ship".to_string(),
|
||||
format!(
|
||||
"Assembled ship · {} parts · {} verts · {} tris",
|
||||
drawn.len(),
|
||||
total_v,
|
||||
total_t
|
||||
),
|
||||
)
|
||||
} else if is_stage {
|
||||
(
|
||||
"stage".to_string(),
|
||||
format!(
|
||||
@@ -3666,6 +3786,196 @@ fn build_game_snapshot(source: &SourceKind) -> Option<GameSnapshot> {
|
||||
Some(GameSnapshot { weapons, craft, vessels, characters, missions, arsenal, flights })
|
||||
}
|
||||
|
||||
/// Handles a [`RequestShipCatalog`]: scans the stage containers and reconstructs
|
||||
/// the assemblable ship catalog off-thread.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn handle_ship_catalog_request(
|
||||
mut events: EventReader<RequestShipCatalog>,
|
||||
iso_state: Res<IsoState>,
|
||||
channels: Res<IsoChannels>,
|
||||
mut ships: ResMut<ShipBrowser>,
|
||||
) {
|
||||
if events.read().next().is_none() {
|
||||
return;
|
||||
}
|
||||
if ships.loaded || ships.loading {
|
||||
return; // already built / building
|
||||
}
|
||||
ships.loading = true;
|
||||
let source = iso_state.source_kind.clone();
|
||||
let sender = channels.sender.clone();
|
||||
std::thread::spawn(move || {
|
||||
let rows = build_ship_catalog(&source);
|
||||
let _ = sender.send(IsoLoaderMsg::ShipCatalogLoaded(rows));
|
||||
});
|
||||
}
|
||||
|
||||
/// Scan every `Stage_SNN.xpr` container, group XBG7 resources into whole ships,
|
||||
/// and join each to its [`sylpheed_formats::game_data::Vessel`] stats.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn build_ship_catalog(source: &SourceKind) -> Vec<ShipRow> {
|
||||
use sylpheed_formats::game_data::{self as gd, Vessel};
|
||||
use sylpheed_formats::ship::ships_in_container;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
// Vessel stats keyed by family id (`rou_e105` → `e105`).
|
||||
let vessels: Vec<Vessel> = read_pak_archive_blocking(source, "dat/GP_MAIN_GAME_E.pak")
|
||||
.map(|p| gd::load_vessels(&p))
|
||||
.unwrap_or_default();
|
||||
let vmap: BTreeMap<String, &Vessel> = vessels
|
||||
.iter()
|
||||
.filter_map(|v| {
|
||||
let id = v.model.as_deref()?.trim_start_matches("rou_").to_string();
|
||||
Some((id, v))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// A capital-ship-ish display name from the Vessel id (`UN_e105_ADAN_Cruiser`
|
||||
// → `Cruiser`), else the family id.
|
||||
let pretty = |id: &str, v: Option<&&Vessel>| -> String {
|
||||
if let Some(v) = v {
|
||||
if let Some(vid) = &v.id {
|
||||
// UN_<id>_<FACTION>_<Class…>
|
||||
if let Some(rest) = vid.strip_prefix("UN_") {
|
||||
let parts: Vec<&str> = rest.splitn(3, '_').collect();
|
||||
if parts.len() == 3 {
|
||||
return parts[2].replace('_', " ");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
id.to_string()
|
||||
};
|
||||
|
||||
let mut agg: BTreeMap<String, ShipRow> = BTreeMap::new();
|
||||
for n in 1..=30u32 {
|
||||
let file = format!("hidden/resource3d/Stage_S{n:02}.xpr");
|
||||
let Ok(bytes) = read_source_file(source, &file) else {
|
||||
continue;
|
||||
};
|
||||
let label = format!("S{n:02}");
|
||||
for sm in ships_in_container(&bytes) {
|
||||
// Skip single-piece props / debris — not "whole ships".
|
||||
if sm.parts.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let v = vmap.get(&sm.id);
|
||||
let entry = agg.entry(sm.id.clone()).or_insert_with(|| ShipRow {
|
||||
id: sm.id.clone(),
|
||||
faction: sm.faction.label().to_string(),
|
||||
name: pretty(&sm.id, v),
|
||||
stage_file: file.clone(),
|
||||
stages: Vec::new(),
|
||||
parts: sm.parts.clone(),
|
||||
has_vessel: v.is_some(),
|
||||
hp: v.and_then(|v| v.hp),
|
||||
size: v.and_then(|v| Some((v.size_x?, v.size_y?, v.size_z?))),
|
||||
turrets: v.and_then(|v| v.turret_count),
|
||||
bridges: v.and_then(|v| v.bridge_count),
|
||||
shield_gens: v.and_then(|v| v.shield_generator_count),
|
||||
});
|
||||
if !entry.stages.contains(&label) {
|
||||
entry.stages.push(label.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut rows: Vec<ShipRow> = agg.into_values().collect();
|
||||
// Real capital ships (Vessel-matched) first, then by faction, then id.
|
||||
rows.sort_by(|a, b| {
|
||||
b.has_vessel
|
||||
.cmp(&a.has_vessel)
|
||||
.then(a.faction.cmp(&b.faction))
|
||||
.then(a.id.cmp(&b.id))
|
||||
});
|
||||
rows
|
||||
}
|
||||
|
||||
/// Handles a [`RequestShipRender`]: assembles the ship's part family into one
|
||||
/// world-space model off-thread and posts it through the shared XPR render path.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn handle_ship_render_request(
|
||||
mut events: EventReader<RequestShipRender>,
|
||||
iso_state: Res<IsoState>,
|
||||
channels: Res<IsoChannels>,
|
||||
mut xpr_load: ResMut<XprLoadState>,
|
||||
mut browser: ResMut<FileBrowserState>,
|
||||
) {
|
||||
let Some(req) = events.read().last() else {
|
||||
return;
|
||||
};
|
||||
let (file, parts, label) = (req.file.clone(), req.parts.clone(), req.label.clone());
|
||||
|
||||
// Supersede any in-flight XPR/stage decode, exactly like a file selection.
|
||||
xpr_load.generation = xpr_load.generation.wrapping_add(1);
|
||||
if let Some(prev) = xpr_load.cancel.take() {
|
||||
prev.store(true, Ordering::Relaxed);
|
||||
}
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
xpr_load.cancel = Some(cancel.clone());
|
||||
let generation = xpr_load.generation;
|
||||
browser.loading = true;
|
||||
|
||||
let source = iso_state.source_kind.clone();
|
||||
let sender = channels.sender.clone();
|
||||
std::thread::spawn(move || {
|
||||
let prepared = build_ship_model(&source, &file, &parts, &label, &cancel);
|
||||
let _ = sender.send(IsoLoaderMsg::XprPrepared {
|
||||
generation,
|
||||
prepared: Box::new(prepared),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Read a stage container, decode only the ship's parts, and assemble them into a
|
||||
/// single world-space model (reusing the XPR model prepare path).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn build_ship_model(
|
||||
source: &SourceKind,
|
||||
file: &str,
|
||||
parts: &[String],
|
||||
label: &str,
|
||||
cancel: &Arc<AtomicBool>,
|
||||
) -> PreparedXpr {
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
let bytes = match read_source_file(source, file) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return PreparedXpr::Info(format!("Could not read {file}: {e}")),
|
||||
};
|
||||
let wanted: std::collections::HashSet<String> = parts.iter().cloned().collect();
|
||||
let should_cancel = || cancel.load(Ordering::Relaxed);
|
||||
let models = Xbg7Model::models_named(&bytes, &wanted, &should_cancel);
|
||||
if should_cancel() {
|
||||
return PreparedXpr::Cancelled;
|
||||
}
|
||||
if models.is_empty() {
|
||||
return PreparedXpr::Info(format!("No geometry decoded for {label}."));
|
||||
}
|
||||
// Assemble, then relabel with the ship's display name.
|
||||
match prepare_ship(&bytes, &models) {
|
||||
PreparedXpr::Model {
|
||||
meshes,
|
||||
materials,
|
||||
focus,
|
||||
radius,
|
||||
min_radius,
|
||||
max_radius,
|
||||
info,
|
||||
..
|
||||
} => PreparedXpr::Model {
|
||||
meshes,
|
||||
materials,
|
||||
focus,
|
||||
radius,
|
||||
min_radius,
|
||||
max_radius,
|
||||
name: label.to_string(),
|
||||
info,
|
||||
},
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a [`RequestVoiceLibrary`]: reads `tables.pak`'s `sounds.tbl` and
|
||||
/// enumerates the voice clips off-thread.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
Reference in New Issue
Block a user