From 720a5fec27a2e296d794d884c4dfc92062b8963c Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 23 Jul 2026 21:16:15 +0200 Subject: [PATCH] =?UTF-8?q?viewer:=20Ships=20browser=20(View=20=E2=96=B8?= =?UTF-8?q?=20Ships)=20=E2=80=94=20assemble=20&=20view=20whole=20capital?= =?UTF-8?q?=20ships?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/sylpheed-viewer/src/iso_loader.rs | 318 ++++++++++++++++++++++- crates/sylpheed-viewer/src/ui.rs | 163 +++++++++++- 2 files changed, 476 insertions(+), 5 deletions(-) diff --git a/crates/sylpheed-viewer/src/iso_loader.rs b/crates/sylpheed-viewer/src/iso_loader.rs index a0db337..3fcd98b 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -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, + /// Base part resource names to draw together. + pub parts: Vec, + /// True when the id matched a `Vessel` recipe (a real capital ship). + pub has_vessel: bool, + pub hp: Option, + pub size: Option<(f32, f32, f32)>, + pub turrets: Option, + pub bridges: Option, + pub shield_gens: Option, +} + +/// 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, + /// Family id of the ship currently rendered (for row highlight). + pub selected: Option, +} + +/// 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, + /// 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, }, + /// The assembled-ship catalog for the Ships browser. + ShipCatalogLoaded(Vec), /// The decoded game-data tables for the browser. GameDataLoaded(Box), /// User dismissed the file dialog — not an error. @@ -896,11 +953,14 @@ impl Plugin for IsoLoaderPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_event::() .add_event::() .add_event::() .add_event::() - .add_event::(); + .add_event::() + .add_event::() + .add_event::(); #[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, mut voice_lib: ResMut, mut game_data: ResMut, + mut ships: ResMut, ) { 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 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 { 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, + iso_state: Res, + channels: Res, + mut ships: ResMut, +) { + 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 { + 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 = read_pak_archive_blocking(source, "dat/GP_MAIN_GAME_E.pak") + .map(|p| gd::load_vessels(&p)) + .unwrap_or_default(); + let vmap: BTreeMap = 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___ + 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 = 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 = 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, + iso_state: Res, + channels: Res, + mut xpr_load: ResMut, + mut browser: ResMut, +) { + 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, +) -> 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 = 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"))] diff --git a/crates/sylpheed-viewer/src/ui.rs b/crates/sylpheed-viewer/src/ui.rs index 2cf9a77..243f499 100644 --- a/crates/sylpheed-viewer/src/ui.rs +++ b/crates/sylpheed-viewer/src/ui.rs @@ -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, + mut requests: EventReader, + mut render: EventWriter, +) { + 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)> = 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; +}