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:
MechaCat02
2026-07-23 21:16:15 +02:00
parent cacb576ecd
commit 720a5fec27
2 changed files with 476 additions and 5 deletions

View File

@@ -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"))]

View File

@@ -12,7 +12,8 @@ use bevy_egui::{egui, EguiContexts};
use crate::iso_loader::{
AudioPreview, FileInfo, FileSelected, GameCategory, GameData, ImageRgba, IsoState, ModelPreview,
MovieSubtitles, MovieVoice, PakContent, PakView, RequestAudio, RequestGameData, RequestOpenDir,
RequestOpenIso, RequestSubtitles, RequestVoiceLibrary, SkyboxPreview, TextPreview, TexturePreview,
RequestOpenIso, RequestShipCatalog, RequestShipRender, RequestSubtitles, RequestVoiceLibrary,
ShipBrowser, SkyboxPreview, TextPreview, TexturePreview,
VideoPreview, VoiceLibrary, IsoLoaderSystemSet,
};
use crate::ViewerState;
@@ -26,6 +27,7 @@ impl Plugin for ViewerUiPlugin {
// Run after the iso_loader chain so we see the frame's final state.
app.add_systems(Update, draw_viewer_ui.after(IsoLoaderSystemSet));
app.add_systems(Update, draw_game_data_ui.after(IsoLoaderSystemSet));
app.add_systems(Update, draw_ships_ui.after(IsoLoaderSystemSet));
}
}
@@ -138,6 +140,7 @@ struct UiEvents<'w> {
audio: EventWriter<'w, RequestAudio>,
voice_lib: EventWriter<'w, RequestVoiceLibrary>,
game_data: EventWriter<'w, RequestGameData>,
ships: EventWriter<'w, RequestShipCatalog>,
}
fn draw_viewer_ui(
@@ -201,6 +204,12 @@ fn draw_viewer_ui(
events.game_data.send_default();
ui.close_menu();
}
if ui.button("🚀 Ships…").clicked() {
// The Ships window (its own system) reads this event to open +
// lazily scan the stage containers for assemblable ships.
events.ships.send_default();
ui.close_menu();
}
});
ui.menu_button("Help", |ui| {
@@ -1521,3 +1530,155 @@ fn draw_game_data_ui(
});
game_data.open &= open;
}
// ── Ships browser ───────────────────────────────────────────────────────────────
/// The Ships browser — a floating window over the capital ships reconstructed
/// from XBG7 part families. Picking a ship assembles its parts and renders the
/// whole model in the 3D view. Its own system (keeps `draw_viewer_ui` under the
/// parameter limit); a `RequestShipCatalog` event (View menu) opens it and lazily
/// kicks off the stage scan, while clicking a row fires `RequestShipRender`.
#[cfg(not(target_arch = "wasm32"))]
fn draw_ships_ui(
mut contexts: EguiContexts,
mut ships: ResMut<ShipBrowser>,
mut requests: EventReader<RequestShipCatalog>,
mut render: EventWriter<RequestShipRender>,
) {
if requests.read().next().is_some() {
ships.open = true;
}
if !ships.open {
return;
}
let ctx = contexts.ctx_mut().clone();
let mut open = true;
egui::Window::new("🚀 Ships")
.default_width(560.0)
.default_height(560.0)
.open(&mut open)
.show(&ctx, |ui| {
if ships.loading {
ui.horizontal(|ui| {
ui.spinner();
ui.label("Scanning stage models for ships…");
});
ctx.request_repaint();
return;
}
if !ships.loaded {
ui.label("Open a game source first (File ▸ Open…).");
return;
}
// Faction filter chips.
ui.horizontal(|ui| {
for (label, val) in
[("All", ""), ("ADAN", "ADAN"), ("TCAF", "TCAF"), ("Neutral", "Neutral")]
{
let sel = ships.faction == val;
if ui.selectable_label(sel, label).clicked() {
ships.faction = val.to_string();
}
}
});
ui.horizontal(|ui| {
ui.label("🔍");
ui.text_edit_singleline(&mut ships.filter);
if !ships.filter.is_empty() && ui.small_button("").clicked() {
ships.filter.clear();
}
});
ui.separator();
let ShipBrowser { rows, filter, faction, selected, .. } = &mut *ships;
let needle = filter.to_lowercase();
let mut to_render: Option<(String, String, Vec<String>, String)> = None;
egui::ScrollArea::vertical().show(ui, |ui| {
let mut last_section = String::new();
for row in rows.iter() {
if !faction.is_empty() && &row.faction != faction {
continue;
}
if !needle.is_empty()
&& !row.name.to_lowercase().contains(&needle)
&& !row.id.contains(&needle)
{
continue;
}
// Section header: capital ships first, then other assemblies.
let section = if row.has_vessel { "Capital ships" } else { "Other assemblies" };
if section != last_section {
ui.add_space(4.0);
ui.label(egui::RichText::new(section).strong().weak());
last_section = section.to_string();
}
let is_sel = selected.as_deref() == Some(row.id.as_str());
let color = match row.faction.as_str() {
"TCAF" => egui::Color32::from_rgb(120, 170, 235),
"ADAN" => egui::Color32::from_rgb(235, 120, 120),
_ => egui::Color32::from_rgb(180, 180, 180),
};
egui::CollapsingHeader::new(
egui::RichText::new(format!("{} · {}", row.name, row.id)).color(color),
)
.id_salt(&row.id)
.default_open(false)
.show(ui, |ui| {
egui::Grid::new(("shipstat", &row.id)).num_columns(2).show(ui, |ui| {
ui.label("Faction");
ui.strong(&row.faction);
ui.end_row();
if let Some(hp) = row.hp {
ui.label("Hull HP");
ui.strong(format!("{hp:.0}"));
ui.end_row();
}
if let Some((x, y, z)) = row.size {
ui.label("Size (m)");
ui.strong(format!("{x:.0} × {y:.0} × {z:.0}"));
ui.end_row();
}
if row.turrets.is_some() || row.shield_gens.is_some() {
ui.label("Hardpoints");
ui.strong(format!(
"{} turrets · {} bridges · {} shield gens",
fint(row.turrets),
fint(row.bridges),
fint(row.shield_gens),
));
ui.end_row();
}
ui.label("Model parts");
ui.strong(format!("{}", row.parts.len()));
ui.end_row();
ui.label("Appears in");
ui.strong(row.stages.join(", "));
ui.end_row();
});
let btn = egui::Button::new(if is_sel {
"● Showing in 3D view"
} else {
"▶ Assemble & view in 3D"
});
if ui.add(btn).clicked() {
to_render = Some((
row.id.clone(),
row.stage_file.clone(),
row.parts.clone(),
format!("{} ({})", row.name, row.id),
));
}
});
}
});
if let Some((id, file, parts, label)) = to_render {
*selected = Some(id);
render.send(RequestShipRender { file, parts, label });
}
});
ships.open &= open;
}