feat(viewer): ISO / extracted-dir loading pipeline + native file dialog
Some checks failed
Some checks failed
WIP: add iso_loader.rs — bridges the async XisoReader / synchronous GameAssets to Bevy's ECS via background threads + mpsc channels polled per frame (no main-thread blocking); UI open-iso/open-dir/file-select events → texture preview. Register IsoLoaderPlugin; wire asset_loader and the egui UI. Deps: rfd (native file dialog, workspace + viewer), futures, and bevy tonemapping_luts feature. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +1,27 @@
|
||||
//! egui-based debug UI for the asset viewer.
|
||||
//!
|
||||
//! Provides panels for:
|
||||
//! - File browser (list extracted game files)
|
||||
//! - File browser (list ISO / extracted game files, filter by name)
|
||||
//! - Texture inspector (preview + format info)
|
||||
//! - Mesh inspector (vertex/index counts, materials)
|
||||
//! - File info (size, detected format for non-texture files)
|
||||
//! - RE notes (track discoveries during reverse engineering)
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_egui::{egui, EguiContexts};
|
||||
|
||||
use crate::iso_loader::{
|
||||
FileInfo, FileSelected, IsoState, RequestOpenDir, RequestOpenIso, TexturePreview,
|
||||
IsoLoaderSystemSet,
|
||||
};
|
||||
use crate::{ViewerState, ViewMode};
|
||||
|
||||
pub struct ViewerUiPlugin;
|
||||
|
||||
impl Plugin for ViewerUiPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Update, draw_viewer_ui);
|
||||
app.insert_resource(FileBrowserState::default());
|
||||
// Run after the iso_loader chain so we see the frame's final state.
|
||||
app.add_systems(Update, draw_viewer_ui.after(IsoLoaderSystemSet));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +37,12 @@ fn draw_viewer_ui(
|
||||
mut contexts: EguiContexts,
|
||||
mut viewer: ResMut<ViewerState>,
|
||||
mut browser: ResMut<FileBrowserState>,
|
||||
asset_server: Res<AssetServer>,
|
||||
iso_state: Res<IsoState>,
|
||||
preview: Res<TexturePreview>,
|
||||
file_info: Res<FileInfo>,
|
||||
mut open_iso_events: EventWriter<RequestOpenIso>,
|
||||
mut open_dir_events: EventWriter<RequestOpenDir>,
|
||||
mut file_selected_events: EventWriter<FileSelected>,
|
||||
) {
|
||||
let ctx = contexts.ctx_mut();
|
||||
|
||||
@@ -39,26 +50,45 @@ fn draw_viewer_ui(
|
||||
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
|
||||
egui::menu::bar(ui, |ui| {
|
||||
ui.menu_button("File", |ui| {
|
||||
if ui.button("Open extracted directory...").clicked() {
|
||||
// TODO: file dialog (rfd crate on native, not available on WASM)
|
||||
info!("TODO: open directory dialog");
|
||||
ui.close_menu();
|
||||
}
|
||||
ui.separator();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if ui.button("Quit").clicked() {
|
||||
std::process::exit(0);
|
||||
{
|
||||
if ui.button("Open ISO disc image…").clicked() {
|
||||
open_iso_events.send_default();
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button("Open extracted folder…").clicked() {
|
||||
open_dir_events.send_default();
|
||||
ui.close_menu();
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Quit").clicked() {
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
ui.colored_label(
|
||||
egui::Color32::GRAY,
|
||||
"File loading not available in browser",
|
||||
);
|
||||
});
|
||||
|
||||
ui.menu_button("View", |ui| {
|
||||
if ui.selectable_label(viewer.current_mode == ViewMode::Textures, "Textures").clicked() {
|
||||
if ui
|
||||
.selectable_label(viewer.current_mode == ViewMode::Textures, "Textures")
|
||||
.clicked()
|
||||
{
|
||||
viewer.current_mode = ViewMode::Textures;
|
||||
}
|
||||
if ui.selectable_label(viewer.current_mode == ViewMode::Mesh, "Meshes").clicked() {
|
||||
if ui
|
||||
.selectable_label(viewer.current_mode == ViewMode::Mesh, "Meshes")
|
||||
.clicked()
|
||||
{
|
||||
viewer.current_mode = ViewMode::Mesh;
|
||||
}
|
||||
if ui.selectable_label(viewer.current_mode == ViewMode::Audio, "Audio").clicked() {
|
||||
if ui
|
||||
.selectable_label(viewer.current_mode == ViewMode::Audio, "Audio")
|
||||
.clicked()
|
||||
{
|
||||
viewer.current_mode = ViewMode::Audio;
|
||||
}
|
||||
ui.separator();
|
||||
@@ -66,7 +96,7 @@ fn draw_viewer_ui(
|
||||
});
|
||||
|
||||
ui.menu_button("Help", |ui| {
|
||||
if ui.button("Controls...").clicked() {
|
||||
if ui.button("Controls…").clicked() {
|
||||
// TODO: controls popup
|
||||
}
|
||||
if ui.button("About").clicked() {
|
||||
@@ -93,29 +123,39 @@ fn draw_viewer_ui(
|
||||
|
||||
// File list
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
if browser.files.is_empty() {
|
||||
if iso_state.loading {
|
||||
ui.spinner();
|
||||
ui.label("Loading…");
|
||||
} else if let Some(err) = &iso_state.error {
|
||||
ui.colored_label(
|
||||
egui::Color32::RED,
|
||||
format!("⚠ Error:\n{}", err),
|
||||
);
|
||||
} else if browser.files.is_empty() {
|
||||
ui.colored_label(
|
||||
egui::Color32::YELLOW,
|
||||
"⚠ No files loaded.\nExtract your ISO first:\n\
|
||||
xdvdfs unpack game.iso ./assets/",
|
||||
"No files loaded.\nUse File → Open ISO disc image\n\
|
||||
or File → Open extracted folder.",
|
||||
);
|
||||
} else {
|
||||
let filter = browser.filter.to_lowercase();
|
||||
// Collect owned strings so we can later mutate `browser.selected`
|
||||
let filtered: Vec<(usize, String)> = browser.files
|
||||
let filtered: Vec<(usize, String)> = browser
|
||||
.files
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, f)| filter.is_empty() || f.to_lowercase().contains(&filter))
|
||||
.filter(|(_, f)| {
|
||||
filter.is_empty() || f.to_lowercase().contains(&filter)
|
||||
})
|
||||
.map(|(i, f)| (i, f.clone()))
|
||||
.collect();
|
||||
|
||||
for (idx, file) in &filtered {
|
||||
let is_selected = browser.selected == Some(*idx);
|
||||
let label = egui::SelectableLabel::new(is_selected, file.as_str());
|
||||
let label =
|
||||
egui::SelectableLabel::new(is_selected, file.as_str());
|
||||
if ui.add(label).clicked() {
|
||||
browser.selected = Some(*idx);
|
||||
info!("Selected file: {}", file);
|
||||
// TODO: load the selected file into the viewer
|
||||
file_selected_events.send(FileSelected(file.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,31 +165,84 @@ fn draw_viewer_ui(
|
||||
// ── Bottom panel: status bar ──────────────────────────────────────────
|
||||
egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(label) = &iso_state.source_label {
|
||||
ui.label(label);
|
||||
ui.separator();
|
||||
}
|
||||
match viewer.current_mode {
|
||||
ViewMode::Textures => ui.label("Mode: Texture Viewer"),
|
||||
ViewMode::Mesh => ui.label("Mode: Mesh Viewer"),
|
||||
ViewMode::Audio => ui.label("Mode: Audio Viewer"),
|
||||
ViewMode::Mesh => ui.label("Mode: Mesh Viewer"),
|
||||
ViewMode::Audio => ui.label("Mode: Audio Viewer"),
|
||||
};
|
||||
ui.separator();
|
||||
ui.label(format!("{} files loaded", browser.files.len()));
|
||||
ui.label(format!("{} files", browser.files.len()));
|
||||
ui.separator();
|
||||
ui.label("LMB drag: orbit | RMB drag: pan | Scroll: zoom | R: reset");
|
||||
ui.label("LMB: orbit | RMB: pan | Scroll: zoom | R: reset");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Central area: RE notes (shown when no asset is selected) ──────────
|
||||
if browser.selected.is_none() {
|
||||
// ── Central area ──────────────────────────────────────────────────────
|
||||
if browser.selected.is_some() {
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
if let Some(egui_id) = preview.egui_id {
|
||||
// Texture preview
|
||||
ui.heading(&file_info.name);
|
||||
ui.label(&preview.format_info);
|
||||
if !preview.format_info.is_empty() && preview.format_info.contains("failed") {
|
||||
ui.colored_label(egui::Color32::YELLOW, &preview.format_info);
|
||||
}
|
||||
ui.separator();
|
||||
|
||||
// Scale to fit, preserving aspect ratio, never upscale
|
||||
let avail = ui.available_size();
|
||||
let scale = if preview.width == 0 || preview.height == 0 {
|
||||
1.0_f32
|
||||
} else {
|
||||
(avail.x / preview.width as f32)
|
||||
.min(avail.y / preview.height as f32)
|
||||
.min(1.0)
|
||||
};
|
||||
let display_w = preview.width as f32 * scale;
|
||||
let display_h = preview.height as f32 * scale;
|
||||
|
||||
ui.add(egui::Image::new(egui::load::SizedTexture::new(
|
||||
egui_id,
|
||||
[display_w, display_h],
|
||||
)));
|
||||
} else {
|
||||
// Non-texture file info
|
||||
ui.heading(&file_info.name);
|
||||
if !preview.format_info.is_empty() {
|
||||
ui.colored_label(egui::Color32::YELLOW, &preview.format_info);
|
||||
}
|
||||
if file_info.size_bytes > 0 {
|
||||
ui.separator();
|
||||
ui.label(format!("Size: {} bytes", file_info.size_bytes));
|
||||
if let Some(fmt) = file_info.detected_format {
|
||||
ui.label(format!("Detected format: {:?}", fmt));
|
||||
}
|
||||
} else if iso_state.loading {
|
||||
ui.spinner();
|
||||
ui.label("Loading file…");
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// No file selected — show welcome / RE notes
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
ui.heading("Project Sylpheed: Arc of Deception");
|
||||
ui.label("Asset Viewer — Milestone 1");
|
||||
ui.separator();
|
||||
|
||||
ui.collapsing("Getting Started", |ui| {
|
||||
ui.label("1. Dump your Project Sylpheed disc to an ISO");
|
||||
ui.label("Option A — open an ISO directly:");
|
||||
ui.code(" File → Open ISO disc image…");
|
||||
ui.separator();
|
||||
ui.label("Option B — extract first, then open folder:");
|
||||
ui.code(" xdvdfs unpack sylpheed.iso ./assets/");
|
||||
ui.label("2. Restart the viewer — files will appear in the left panel");
|
||||
ui.label("3. Click a .xpr file to preview the texture");
|
||||
ui.label("4. Use File → Open to select a different directory");
|
||||
ui.code(" File → Open extracted folder…");
|
||||
ui.separator();
|
||||
ui.label("Click a .xpr file in the left panel to preview the texture.");
|
||||
});
|
||||
|
||||
ui.collapsing("Reverse Engineering Notes", |ui| {
|
||||
|
||||
Reference in New Issue
Block a user