From 646cff9115aff1d3b3f594e32af2f3537171696e Mon Sep 17 00:00:00 2001 From: Sieluna Date: Tue, 6 Jan 2026 22:44:37 +0200 Subject: [PATCH 1/8] Wrap asset loader with task executor --- .../bevy_asset_browser/Cargo.toml | 1 + .../bevy_asset_browser/src/lib.rs | 3 + .../bevy_asset_browser/src/ui/nodes.rs | 3 +- crates/bevy_asset_preview/Cargo.toml | 4 + crates/bevy_asset_preview/src/asset/loader.rs | 371 ++++++++++++++++++ crates/bevy_asset_preview/src/asset/mod.rs | 15 + .../bevy_asset_preview/src/asset/priority.rs | 54 +++ crates/bevy_asset_preview/src/asset/saver.rs | 175 +++++++++ crates/bevy_asset_preview/src/asset/task.rs | 72 ++++ crates/bevy_asset_preview/src/lib.rs | 11 +- crates/bevy_asset_preview/src/ui/mod.rs | 24 ++ 11 files changed, 731 insertions(+), 2 deletions(-) create mode 100644 crates/bevy_asset_preview/src/asset/loader.rs create mode 100644 crates/bevy_asset_preview/src/asset/mod.rs create mode 100644 crates/bevy_asset_preview/src/asset/priority.rs create mode 100644 crates/bevy_asset_preview/src/asset/saver.rs create mode 100644 crates/bevy_asset_preview/src/asset/task.rs create mode 100644 crates/bevy_asset_preview/src/ui/mod.rs diff --git a/bevy_editor_panes/bevy_asset_browser/Cargo.toml b/bevy_editor_panes/bevy_asset_browser/Cargo.toml index 872a3dfe..33d227da 100644 --- a/bevy_editor_panes/bevy_asset_browser/Cargo.toml +++ b/bevy_editor_panes/bevy_asset_browser/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" [dependencies] bevy.workspace = true +bevy_asset_preview.workspace = true bevy_editor_styles.workspace = true bevy_pane_layout.workspace = true bevy_scroll_box.workspace = true diff --git a/bevy_editor_panes/bevy_asset_browser/src/lib.rs b/bevy_editor_panes/bevy_asset_browser/src/lib.rs index ebb770c6..d7083954 100644 --- a/bevy_editor_panes/bevy_asset_browser/src/lib.rs +++ b/bevy_editor_panes/bevy_asset_browser/src/lib.rs @@ -10,6 +10,7 @@ use bevy::{ }, prelude::*, }; +use bevy_asset_preview::AssetPreviewPlugin; use bevy_pane_layout::prelude::*; use bevy_scroll_box::ScrollBoxPlugin; use ui::top_bar::location_as_changed; @@ -69,6 +70,8 @@ impl Plugin for AssetBrowserPanePlugin { ) .run_if(location_as_changed), ); + + app.add_plugins(AssetPreviewPlugin); } } diff --git a/bevy_editor_panes/bevy_asset_browser/src/ui/nodes.rs b/bevy_editor_panes/bevy_asset_browser/src/ui/nodes.rs index 3fc82fc2..207cc5d6 100644 --- a/bevy_editor_panes/bevy_asset_browser/src/ui/nodes.rs +++ b/bevy_editor_panes/bevy_asset_browser/src/ui/nodes.rs @@ -7,6 +7,7 @@ use bevy::{ prelude::*, window::SystemCursorIcon, }; +use bevy_asset_preview::PreviewAsset; use bevy_context_menu::{ContextMenu, ContextMenuOption}; use bevy_editor_styles::Theme; @@ -177,7 +178,7 @@ pub(crate) fn spawn_file_node<'a>( // Icon commands.spawn(( - ImageNode::new(asset_server.load("embedded://bevy_asset_browser/assets/file_icon.png")), + PreviewAsset(location.path.join(&file_name)), Node { height: Val::Px(50.0), ..default() diff --git a/crates/bevy_asset_preview/Cargo.toml b/crates/bevy_asset_preview/Cargo.toml index 03d0e3fc..4a90c69a 100644 --- a/crates/bevy_asset_preview/Cargo.toml +++ b/crates/bevy_asset_preview/Cargo.toml @@ -5,3 +5,7 @@ edition = "2024" [dependencies] bevy.workspace = true +image = "0.25" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/bevy_asset_preview/src/asset/loader.rs b/crates/bevy_asset_preview/src/asset/loader.rs new file mode 100644 index 00000000..91d558b2 --- /dev/null +++ b/crates/bevy_asset_preview/src/asset/loader.rs @@ -0,0 +1,371 @@ +use std::collections::BinaryHeap; + +use bevy::{ + asset::{AssetEvent, AssetId, AssetPath, AssetServer, Handle, LoadState}, + ecs::event::{BufferedEvent, Event}, + platform::collections::HashMap, + prelude::*, +}; + +use crate::asset::{LoadPriority, LoadTask}; + +/// Active asynchronous loading task. +#[derive(Component)] +pub struct ActiveLoadTask { + pub task_id: u64, + pub path: AssetPath<'static>, + pub handle: Handle, + pub priority: LoadPriority, +} + +/// Event emitted when an asset finishes loading. +#[derive(Event, BufferedEvent, Debug, Clone)] +pub struct AssetLoadCompleted { + pub task_id: u64, + pub path: AssetPath<'static>, + pub handle: Handle, + pub priority: LoadPriority, +} + +/// Event emitted when an asset fails to load. +#[derive(Event, BufferedEvent, Debug, Clone)] +pub struct AssetLoadFailed { + pub task_id: u64, + pub path: AssetPath<'static>, + pub error: String, +} + +/// Event emitted when an asset is hot-reloaded. +#[derive(Event, BufferedEvent, Debug, Clone)] +pub struct AssetHotReloaded { + pub task_id: u64, + pub path: AssetPath<'static>, + pub handle: Handle, +} + +/// Asynchronous asset loader with priority queue. +#[derive(Resource)] +pub struct AssetLoader { + queue: BinaryHeap, + next_task_id: u64, + max_concurrent: usize, + active_tasks: usize, + task_paths: HashMap>, + handle_to_entity: HashMap, Entity>, + task_to_handle: HashMap>, +} + +impl Default for AssetLoader { + fn default() -> Self { + Self { + queue: BinaryHeap::new(), + next_task_id: 0, + max_concurrent: 4, + active_tasks: 0, + task_paths: HashMap::new(), + handle_to_entity: HashMap::new(), + task_to_handle: HashMap::new(), + } + } +} + +impl AssetLoader { + /// Creates a new loader with specified max concurrent tasks. + pub fn new(max_concurrent: usize) -> Self { + Self { + queue: BinaryHeap::new(), + next_task_id: 0, + max_concurrent, + active_tasks: 0, + task_paths: HashMap::new(), + handle_to_entity: HashMap::new(), + task_to_handle: HashMap::new(), + } + } + + /// Submits a load task to the priority queue. + pub fn submit<'a>(&mut self, path: impl Into>, priority: LoadPriority) -> u64 { + let task_id = self.next_task_id; + self.next_task_id += 1; + let asset_path: AssetPath<'static> = path.into().into_owned(); + let task = LoadTask::new(asset_path.clone(), priority, task_id); + self.queue.push(task); + self.task_paths.insert(task_id, asset_path.clone()); + task_id + } + + /// Gets the path for a task ID. + pub fn get_task_path(&self, task_id: u64) -> Option<&AssetPath<'static>> { + self.task_paths.get(&task_id) + } + + /// Removes task path mapping (called when task completes). + pub fn remove_task_path(&mut self, task_id: u64) { + self.task_paths.remove(&task_id); + } + + /// Returns the number of tasks in the queue. + pub fn queue_len(&self) -> usize { + self.queue.len() + } + + /// Peeks at the next task to process without removing it. + pub fn peek_next(&self) -> Option<&LoadTask> { + self.queue.peek() + } + + /// Pops the next task to process from the queue. + pub fn pop_next(&mut self) -> Option { + self.queue.pop() + } + + /// Checks if a new task can be started. + pub fn can_start_task(&self) -> bool { + self.active_tasks < self.max_concurrent + } + + /// Increments the active task count. + pub fn start_task(&mut self) { + self.active_tasks += 1; + } + + /// Decrements the active task count. + pub fn finish_task(&mut self) { + if self.active_tasks > 0 { + self.active_tasks -= 1; + } + } + + /// Returns the current number of active tasks. + pub fn active_tasks(&self) -> usize { + self.active_tasks + } + + /// Spawns an asynchronous load task using AssetServer. + pub fn spawn_load_task( + &mut self, + task: LoadTask, + asset_server: &AssetServer, + ) -> ActiveLoadTask { + let task_id = task.task_id; + let path = task.path.clone(); + let priority = task.priority; + + let handle = asset_server.load(&path); + + ActiveLoadTask { + task_id, + path, + handle: handle.clone(), + priority, + } + } + + /// Registers task entity and handle mapping. + pub fn register_task(&mut self, task_id: u64, entity: Entity, handle: Handle) { + let handle_id = handle.id(); + self.handle_to_entity.insert(handle_id, entity); + self.task_to_handle.insert(task_id, handle_id); + } + + /// Gets task entity by handle ID. + pub fn get_entity_by_handle(&self, handle_id: AssetId) -> Option { + self.handle_to_entity.get(&handle_id).copied() + } + + /// Gets handle ID by task ID. + pub fn get_handle_id_by_task(&self, task_id: u64) -> Option> { + self.task_to_handle.get(&task_id).copied() + } + + /// Cleans up task mappings. + pub fn cleanup_task(&mut self, task_id: u64, handle_id: AssetId) { + self.task_paths.remove(&task_id); + self.handle_to_entity.remove(&handle_id); + self.task_to_handle.remove(&task_id); + } +} + +/// Processes the load queue and starts new tasks. +pub fn process_load_queue( + mut commands: Commands, + mut loader: ResMut, + asset_server: Res, +) { + while loader.can_start_task() { + if let Some(task) = loader.pop_next() { + let active_task = loader.spawn_load_task(task, &asset_server); + let task_id = active_task.task_id; + let handle = active_task.handle.clone(); + + loader.start_task(); + let entity = commands.spawn(active_task).id(); + loader.register_task(task_id, entity, handle); + } else { + break; + } + } +} + +/// Handles asset events (event-driven approach). +pub fn handle_asset_events( + mut commands: Commands, + mut loader: ResMut, + mut asset_events: EventReader>, + mut load_completed_events: EventWriter, + mut load_failed_events: EventWriter, + mut hot_reload_events: EventWriter, + task_query: Query<&ActiveLoadTask>, +) { + for event in asset_events.read() { + match event { + AssetEvent::LoadedWithDependencies { id } => { + if let Some(entity) = loader.get_entity_by_handle(*id) { + if let Ok(active_task) = task_query.get(entity) { + load_completed_events.write(AssetLoadCompleted { + task_id: active_task.task_id, + path: active_task.path.clone(), + handle: active_task.handle.clone(), + priority: active_task.priority, + }); + + loader.finish_task(); + loader.cleanup_task(active_task.task_id, *id); + commands.entity(entity).despawn(); + } + } + } + AssetEvent::Removed { id } => { + if let Some(entity) = loader.get_entity_by_handle(*id) { + if let Ok(active_task) = task_query.get(entity) { + load_failed_events.write(AssetLoadFailed { + task_id: active_task.task_id, + path: active_task.path.clone(), + error: "Asset was removed (possibly failed to load)".to_string(), + }); + + loader.finish_task(); + loader.cleanup_task(active_task.task_id, *id); + commands.entity(entity).despawn(); + } + } + } + AssetEvent::Modified { id } => { + if let Some(entity) = loader.get_entity_by_handle(*id) { + if let Ok(active_task) = task_query.get(entity) { + hot_reload_events.write(AssetHotReloaded { + task_id: active_task.task_id, + path: active_task.path.clone(), + handle: active_task.handle.clone(), + }); + } + } + } + _ => {} + } + } +} + +/// Polls active tasks and handles completed ones (fallback approach). +#[allow(dead_code)] +pub fn poll_load_tasks( + mut commands: Commands, + mut loader: ResMut, + asset_server: Res, + mut task_query: Query<(Entity, &ActiveLoadTask)>, +) { + for (entity, active_task) in task_query.iter_mut() { + if asset_server.is_loaded_with_dependencies(&active_task.handle) { + bevy::log::info!("Asset loaded successfully: {:?}", active_task.path); + + loader.finish_task(); + let handle_id = active_task.handle.id(); + loader.cleanup_task(active_task.task_id, handle_id); + commands.entity(entity).despawn(); + } else { + let load_state = asset_server.load_state(&active_task.handle); + if let LoadState::Failed(_) = load_state { + bevy::log::warn!("Asset load failed: {:?}", active_task.path); + + loader.finish_task(); + let handle_id = active_task.handle.id(); + loader.cleanup_task(active_task.task_id, handle_id); + commands.entity(entity).despawn(); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_submit_and_queue() { + let mut loader = AssetLoader::new(4); + assert_eq!(loader.queue_len(), 0); + + loader.submit("test1.png", LoadPriority::Preload); + assert_eq!(loader.queue_len(), 1); + + loader.submit("test2.png", LoadPriority::CurrentAccess); + assert_eq!(loader.queue_len(), 2); + } + + #[test] + fn test_priority_ordering() { + let mut loader = AssetLoader::new(4); + + let id1 = loader.submit("preload.png", LoadPriority::Preload); + let id2 = loader.submit("current.png", LoadPriority::CurrentAccess); + let id3 = loader.submit("hotreload.png", LoadPriority::HotReload); + + let task1 = loader.pop_next().unwrap(); + assert_eq!(task1.priority, LoadPriority::CurrentAccess); + assert_eq!(task1.task_id, id2); + + let task2 = loader.pop_next().unwrap(); + assert_eq!(task2.priority, LoadPriority::HotReload); + assert_eq!(task2.task_id, id3); + + let task3 = loader.pop_next().unwrap(); + assert_eq!(task3.priority, LoadPriority::Preload); + assert_eq!(task3.task_id, id1); + } + + #[test] + fn test_concurrent_limit() { + let mut loader = AssetLoader::new(2); + assert!(loader.can_start_task()); + + loader.start_task(); + assert_eq!(loader.active_tasks(), 1); + assert!(loader.can_start_task()); + + loader.start_task(); + assert_eq!(loader.active_tasks(), 2); + assert!(!loader.can_start_task()); + + loader.finish_task(); + assert_eq!(loader.active_tasks(), 1); + assert!(loader.can_start_task()); + } + + #[test] + fn test_task_registration_and_cleanup() { + let mut loader = AssetLoader::new(4); + let task_id = loader.submit("test.png", LoadPriority::CurrentAccess); + + let handle_id: AssetId = AssetId::default(); + let entity = Entity::PLACEHOLDER; + loader.register_task(task_id, entity, Handle::::default()); + + assert_eq!(loader.get_entity_by_handle(handle_id), Some(entity)); + assert_eq!(loader.get_handle_id_by_task(task_id), Some(handle_id)); + + loader.cleanup_task(task_id, handle_id); + + assert_eq!(loader.get_entity_by_handle(handle_id), None); + assert_eq!(loader.get_handle_id_by_task(task_id), None); + } +} diff --git a/crates/bevy_asset_preview/src/asset/mod.rs b/crates/bevy_asset_preview/src/asset/mod.rs new file mode 100644 index 00000000..831add14 --- /dev/null +++ b/crates/bevy_asset_preview/src/asset/mod.rs @@ -0,0 +1,15 @@ +mod loader; +mod priority; +mod saver; +mod task; + +pub use loader::{ + ActiveLoadTask, AssetHotReloaded, AssetLoadCompleted, AssetLoadFailed, AssetLoader, + handle_asset_events, process_load_queue, +}; +pub use priority::LoadPriority; +pub use saver::{ + ActiveSaveTask, SaveCompleted, SaveTaskTracker, handle_save_completed, monitor_save_completion, + save_image, +}; +pub use task::LoadTask; diff --git a/crates/bevy_asset_preview/src/asset/priority.rs b/crates/bevy_asset_preview/src/asset/priority.rs new file mode 100644 index 00000000..a52d7888 --- /dev/null +++ b/crates/bevy_asset_preview/src/asset/priority.rs @@ -0,0 +1,54 @@ +use core::cmp::Ordering; + +/// Load task priority levels. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum LoadPriority { + /// Current access - highest priority. + CurrentAccess, + /// Hot reload - second priority. + HotReload, + /// Preload - third priority. + Preload, +} + +impl LoadPriority { + /// Returns the priority value. Higher value means higher priority. + pub fn value(self) -> u8 { + match self { + LoadPriority::CurrentAccess => 3, + LoadPriority::HotReload => 2, + LoadPriority::Preload => 1, + } + } +} + +impl PartialOrd for LoadPriority { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for LoadPriority { + fn cmp(&self, other: &Self) -> Ordering { + self.value().cmp(&other.value()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_priority_ordering() { + assert!(LoadPriority::CurrentAccess > LoadPriority::HotReload); + assert!(LoadPriority::HotReload > LoadPriority::Preload); + assert!(LoadPriority::CurrentAccess > LoadPriority::Preload); + } + + #[test] + fn test_priority_values() { + assert_eq!(LoadPriority::CurrentAccess.value(), 3); + assert_eq!(LoadPriority::HotReload.value(), 2); + assert_eq!(LoadPriority::Preload.value(), 1); + } +} diff --git a/crates/bevy_asset_preview/src/asset/saver.rs b/crates/bevy_asset_preview/src/asset/saver.rs new file mode 100644 index 00000000..112a08c4 --- /dev/null +++ b/crates/bevy_asset_preview/src/asset/saver.rs @@ -0,0 +1,175 @@ +use std::io::Cursor; + +use bevy::{ + asset::{AssetPath, io::ErasedAssetWriter}, + ecs::event::{BufferedEvent, Event}, + image::Image, + platform::collections::HashMap, + prelude::{ + Assets, Commands, Component, Entity, EventReader, EventWriter, Handle, Query, ResMut, + Resource, + }, + tasks::{IoTaskPool, Task}, +}; + +/// Active save task tracking component. +#[derive(Component)] +pub struct ActiveSaveTask { + pub task_id: u64, + pub path: AssetPath<'static>, + pub target_path: AssetPath<'static>, + pub task: Task>, +} + +/// Event emitted when a save task completes. +#[derive(Event, BufferedEvent, Debug, Clone)] +pub struct SaveCompleted { + pub task_id: u64, + pub path: AssetPath<'static>, + pub result: Result<(), String>, +} + +/// Resource for save task tracking. +#[derive(Resource, Default)] +pub struct SaveTaskTracker { + next_task_id: u64, + pending_saves: HashMap>, +} + +impl SaveTaskTracker { + /// Creates a new save task ID. + pub fn create_task_id(&mut self) -> u64 { + let id = self.next_task_id; + self.next_task_id += 1; + id + } + + /// Registers a pending save. + pub fn register_pending(&mut self, task_id: u64, path: AssetPath<'static>) { + self.pending_saves.insert(task_id, path); + } + + /// Marks a save as completed. + pub fn mark_completed(&mut self, task_id: u64) { + self.pending_saves.remove(&task_id); + } +} + +/// Saves an image asset to the specified path asynchronously using AssetWriter abstraction. +pub fn save_image<'a>( + image: Handle, + target_path: impl Into>, + images: &Assets, + writer: impl ErasedAssetWriter, +) -> Task> { + let target_path: AssetPath<'static> = target_path.into().into_owned(); + + let Some(image_data) = images.get(&image) else { + let error = format!("Image not found: {:?}", image); + return IoTaskPool::get().spawn(async move { Err(error) }); + }; + + // Convert to dynamic image + let dynamic_image = match image_data.clone().try_into_dynamic() { + Ok(img) => img, + Err(e) => { + let error = format!("Failed to convert image: {:?}", e); + return IoTaskPool::get().spawn(async move { Err(error) }); + } + }; + + // Convert to RGBA8 format + let rgba_image = dynamic_image.into_rgba8(); + + let task_pool = IoTaskPool::get(); + let target_path_clone = target_path.clone(); + let target_path_for_writer = target_path.path().to_path_buf(); + + task_pool.spawn(async move { + // Create directory first + if let Some(parent) = target_path_for_writer.parent() { + if let Err(e) = writer.create_directory(parent).await { + let error = format!("Failed to create directory {:?}: {:?}", parent, e); + bevy::log::error!("{}", error); + return Err(error); + } + } + + // Encode PNG directly to memory + let mut cursor = Cursor::new(Vec::new()); + match rgba_image.write_to(&mut cursor, image::ImageFormat::Png) { + Ok(_) => { + let png_bytes = cursor.into_inner(); + // Write via AssetWriter (atomic operation) + match writer + .write_bytes(&target_path_for_writer, &png_bytes) + .await + { + Ok(_) => { + bevy::log::info!("Image saved successfully to {:?}", target_path_clone); + Ok(()) + } + Err(e) => { + let error = + format!("Failed to save image to {:?}: {:?}", target_path_clone, e); + bevy::log::error!("{}", error); + Err(error) + } + } + } + Err(e) => { + let error = format!("Failed to encode image to PNG: {:?}", e); + bevy::log::error!("{}", error); + Err(error) + } + } + }) +} + +/// System that monitors save completion and cleans up tasks. +pub fn monitor_save_completion( + mut save_completed_events: EventWriter, + mut tracker: ResMut, + mut commands: Commands, + mut save_task_query: Query<(Entity, &mut ActiveSaveTask)>, +) { + for (entity, mut active_task) in save_task_query.iter_mut() { + // Poll the async task + if let Some(result) = bevy::tasks::block_on(bevy::tasks::futures_lite::future::poll_once( + &mut active_task.task, + )) { + // Task completed, send event + save_completed_events.write(SaveCompleted { + task_id: active_task.task_id, + path: active_task.path.clone(), + result, + }); + + tracker.mark_completed(active_task.task_id); + commands.entity(entity).despawn(); + } + } +} + +/// System that handles save completion events. +pub fn handle_save_completed(mut save_completed_events: EventReader) { + for event in save_completed_events.read() { + match &event.result { + Ok(_) => { + bevy::log::debug!( + "Save task {} completed successfully for {:?}", + event.task_id, + event.path + ); + } + Err(e) => { + bevy::log::warn!( + "Save task {} failed for {:?}: {}", + event.task_id, + event.path, + e + ); + } + } + } +} diff --git a/crates/bevy_asset_preview/src/asset/task.rs b/crates/bevy_asset_preview/src/asset/task.rs new file mode 100644 index 00000000..fa17db07 --- /dev/null +++ b/crates/bevy_asset_preview/src/asset/task.rs @@ -0,0 +1,72 @@ +use bevy::asset::AssetPath; + +use crate::asset::LoadPriority; + +/// Asset loading task. +#[derive(Debug, Clone)] +pub struct LoadTask { + /// Asset path. + pub path: AssetPath<'static>, + /// Load priority. + pub priority: LoadPriority, + /// Task ID for tracking. + pub task_id: u64, +} + +impl LoadTask { + /// Creates a new load task. + pub fn new<'a>(path: impl Into>, priority: LoadPriority, task_id: u64) -> Self { + Self { + path: path.into().into_owned(), + priority, + task_id, + } + } +} + +impl PartialEq for LoadTask { + fn eq(&self, other: &Self) -> bool { + self.task_id == other.task_id + } +} + +impl Eq for LoadTask {} + +impl PartialOrd for LoadTask { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for LoadTask { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + // Sort by priority first (higher priority first, BinaryHeap is max-heap) + match self.priority.cmp(&other.priority) { + std::cmp::Ordering::Equal => { + // Same priority: sort by task ID (earlier tasks first) + // BinaryHeap is max-heap, so reverse ID comparison + other.task_id.cmp(&self.task_id) + } + other => other, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_task_ordering_by_priority() { + let task1 = LoadTask::new("test1.png", LoadPriority::Preload, 1); + let task2 = LoadTask::new("test2.png", LoadPriority::CurrentAccess, 2); + assert!(task2 > task1); + } + + #[test] + fn test_task_ordering_by_id_when_same_priority() { + let task1 = LoadTask::new("test1.png", LoadPriority::Preload, 1); + let task2 = LoadTask::new("test2.png", LoadPriority::Preload, 2); + assert!(task1 > task2); + } +} diff --git a/crates/bevy_asset_preview/src/lib.rs b/crates/bevy_asset_preview/src/lib.rs index 1a13890e..946ad779 100644 --- a/crates/bevy_asset_preview/src/lib.rs +++ b/crates/bevy_asset_preview/src/lib.rs @@ -1,3 +1,9 @@ +mod asset; +mod ui; + +pub use asset::*; +pub use ui::*; + use bevy::prelude::*; /// This crate is a work in progress and is not yet ready for use. @@ -6,8 +12,11 @@ use bevy::prelude::*; /// This code may be reused for the Bevy Marketplace Viewer to provide previews of assets and plugins. /// So long as the assets are unchanged, the previews will be cached and will not need to be re-rendered. /// In theory this can be done passively in the background, and the previews will be ready when the user needs them. + pub struct AssetPreviewPlugin; impl Plugin for AssetPreviewPlugin { - fn build(&self, _app: &mut App) {} + fn build(&self, _app: &mut App) { + + } } diff --git a/crates/bevy_asset_preview/src/ui/mod.rs b/crates/bevy_asset_preview/src/ui/mod.rs new file mode 100644 index 00000000..9cd4398b --- /dev/null +++ b/crates/bevy_asset_preview/src/ui/mod.rs @@ -0,0 +1,24 @@ +use std::path::PathBuf; + +use bevy::{ + asset::AssetServer, + prelude::*, +}; + +#[derive(Component, Deref)] +pub struct PreviewAsset(pub PathBuf); + +const FILE_PLACEHOLDER: &'static str = "embedded://bevy_asset_browser/assets/file_icon.png"; + +pub fn preview_handler( + mut commands: Commands, + mut requests_query: Query<(Entity, &PreviewAsset)>, + asset_server: Res, +) { + for (entity, preview) in &mut requests_query { + let preview = asset_server.load(FILE_PLACEHOLDER); + + // TODO: sprite atlas. + commands.entity(entity).insert(ImageNode::new(preview)); + } +} From d065663e7c4de1d7f7a3196c65b7a2f4f7fc5ba8 Mon Sep 17 00:00:00 2001 From: Sieluna Date: Wed, 7 Jan 2026 01:42:19 +0200 Subject: [PATCH 2/8] Add asset loader integration tests --- crates/bevy_asset_preview/src/ui/mod.rs | 5 +- .../tests/asset_loader_test.rs | 482 ++++++++++++++++++ 2 files changed, 483 insertions(+), 4 deletions(-) create mode 100644 crates/bevy_asset_preview/tests/asset_loader_test.rs diff --git a/crates/bevy_asset_preview/src/ui/mod.rs b/crates/bevy_asset_preview/src/ui/mod.rs index 9cd4398b..5727d4f1 100644 --- a/crates/bevy_asset_preview/src/ui/mod.rs +++ b/crates/bevy_asset_preview/src/ui/mod.rs @@ -1,9 +1,6 @@ use std::path::PathBuf; -use bevy::{ - asset::AssetServer, - prelude::*, -}; +use bevy::{asset::AssetServer, prelude::*}; #[derive(Component, Deref)] pub struct PreviewAsset(pub PathBuf); diff --git a/crates/bevy_asset_preview/tests/asset_loader_test.rs b/crates/bevy_asset_preview/tests/asset_loader_test.rs new file mode 100644 index 00000000..fee0ba8a --- /dev/null +++ b/crates/bevy_asset_preview/tests/asset_loader_test.rs @@ -0,0 +1,482 @@ +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +use bevy::{ + asset::{AssetPath, AssetPlugin, io::file::FileAssetWriter}, + image::{CompressedImageFormats, ImageLoader}, + prelude::*, + render::{ + render_asset::RenderAssetUsages, + render_resource::{Extent3d, TextureDimension, TextureFormat}, + }, +}; +use bevy_asset_preview::{ + ActiveSaveTask, AssetHotReloaded, AssetLoadCompleted, AssetLoadFailed, AssetLoader, + LoadPriority, SaveCompleted, SaveTaskTracker, handle_asset_events, monitor_save_completion, + process_load_queue, save_image, +}; +use tempfile::TempDir; + +/// Helper function to create a test image with a specific color. +fn create_test_image( + images: &mut Assets, + width: u32, + height: u32, + color: [u8; 4], +) -> Handle { + let pixel_data: Vec = (0..(width * height)) + .flat_map(|_| color.iter().copied()) + .collect(); + + let image = Image::new_fill( + Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + &pixel_data, + TextureFormat::Rgba8UnormSrgb, + RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD, + ); + + images.add(image) +} + +pub(crate) fn get_base_path() -> PathBuf { + if let Ok(manifest_dir) = std::env::var("BEVY_ASSET_ROOT") { + PathBuf::from(manifest_dir) + } else if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") { + PathBuf::from(manifest_dir) + } else { + std::env::current_exe() + .map(|path| path.parent().map(ToOwned::to_owned).unwrap()) + .unwrap() + } +} + +/// Integration test: Complete file system workflow with batch operations. +#[test] +fn test_complete_filesystem_workflow() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let assets_dir = temp_dir.path().join("assets"); + let cache_dir = temp_dir.path().join("cache"); + fs::create_dir_all(&assets_dir).expect("Failed to create assets directory"); + fs::create_dir_all(&cache_dir).expect("Failed to create cache directory"); + + unsafe { + std::env::set_var( + "BEVY_ASSET_ROOT", + temp_dir + .path() + .to_str() + .expect("Failed to convert path to string"), + ); + } + + println!("BEVY_ASSET_ROOT: {}", get_base_path().display()); + + let mut app = App::new(); + + let cache_dir = temp_dir.path().join("cache").join("asset_preview"); + app.register_asset_source( + "thumbnail_cache", + bevy::asset::io::AssetSourceBuilder::platform_default( + cache_dir.to_str().expect("Cache dir path should be valid"), + None, + ), + ); + + app.add_plugins(MinimalPlugins) + .add_plugins(AssetPlugin { + file_path: assets_dir.display().to_string(), + ..Default::default() + }) + .init_asset::() + .register_asset_loader(ImageLoader::new(CompressedImageFormats::NONE)) + .add_event::() + .add_event::() + .add_event::() + .add_event::() + .init_resource::() + .init_resource::() + .add_systems( + Update, + ( + process_load_queue, + handle_asset_events, + monitor_save_completion, + ), + ); + + let test_images: Vec<(Handle, PathBuf, [u8; 4])> = { + let mut images = app.world_mut().resource_mut::>(); + vec![ + ( + create_test_image(&mut images, 64, 64, [255, 0, 0, 255]), + PathBuf::from("test_red.png"), + [255, 0, 0, 255], + ), + ( + create_test_image(&mut images, 64, 64, [0, 255, 0, 255]), + PathBuf::from("test_green.png"), + [0, 255, 0, 255], + ), + ( + create_test_image(&mut images, 64, 64, [0, 0, 255, 255]), + PathBuf::from("test_blue.png"), + [0, 0, 255, 255], + ), + ( + create_test_image(&mut images, 64, 64, [255, 255, 0, 255]), + PathBuf::from("test_yellow.png"), + [255, 255, 0, 255], + ), + ( + create_test_image(&mut images, 64, 64, [255, 0, 255, 255]), + PathBuf::from("test_magenta.png"), + [255, 0, 255, 255], + ), + ] + }; + + { + let save_tasks = + app.world_mut() + .resource_scope(|world, mut tracker: Mut| { + let images = world.get_resource::>().unwrap(); + + let mut tasks = Vec::new(); + + for (handle, path, _) in &test_images { + let writer = FileAssetWriter::new("", true); + let target_path = AssetPath::from_path_buf( + PathBuf::from("cache/asset_preview").join(path), + ) + .into_owned(); + let task = save_image(handle.clone(), target_path.clone(), images, writer); + let task_id = tracker.create_task_id(); + let path_asset: AssetPath<'static> = + AssetPath::from_path_buf(path.clone()).into_owned(); + tracker.register_pending(task_id, path_asset.clone()); + tasks.push((task_id, path_asset, target_path, task)); + } + + tasks + }); + + let mut commands = app.world_mut().commands(); + for (task_id, path, target_path, task) in save_tasks { + commands.spawn(ActiveSaveTask { + task_id, + path, + target_path, + task, + }); + } + } + + let mut save_completed_count = 0; + let mut save_failed_count = 0; + let mut max_iterations = 1000; + let mut processed_task_ids = std::collections::HashSet::new(); + + while save_completed_count < test_images.len() && max_iterations > 0 { + app.update(); + max_iterations -= 1; + + let save_events = app + .world() + .resource::>(); + let mut cursor = save_events.get_cursor(); + for event in cursor.read(save_events) { + if processed_task_ids.insert(event.task_id) { + match &event.result { + Ok(_) => { + save_completed_count += 1; + } + Err(e) => { + save_failed_count += 1; + eprintln!( + "Save task {} failed for {:?}: {}", + event.task_id, event.path, e + ); + } + } + } + } + } + + if save_failed_count > 0 { + panic!( + "{} save tasks failed. Completed: {}, Failed: {}", + save_failed_count, save_completed_count, save_failed_count + ); + } + + assert_eq!( + save_completed_count, + test_images.len(), + "All images should be saved successfully. Completed: {}, Expected: {}", + save_completed_count, + test_images.len() + ); + + std::thread::sleep(std::time::Duration::from_millis(100)); + + for (_, path, _) in &test_images { + let saved_path = temp_dir + .path() + .join("cache") + .join("asset_preview") + .join(path); + + let saved_path = saved_path + .canonicalize() + .unwrap_or_else(|_| saved_path.clone()); + + assert!( + saved_path.exists(), + "Saved file should exist: {:?}", + saved_path + ); + } + + for (_, path, _) in &test_images { + let src = temp_dir + .path() + .join("cache") + .join("asset_preview") + .join(path); + let dst = assets_dir.join(path); + if let Some(parent) = dst.parent() { + fs::create_dir_all(parent).expect("Failed to create assets subdirectory"); + } + fs::copy(&src, &dst).expect("Failed to copy file for loading test"); + } + + for (_, path, _) in &test_images { + let asset_file = assets_dir.join(path); + assert!( + asset_file.exists(), + "Asset file should exist before loading: {:?}", + asset_file + ); + } + + let asset_paths: Vec<(bevy::asset::AssetPath<'static>, LoadPriority)> = vec![ + ("test_red.png".into(), LoadPriority::Preload), + ("test_green.png".into(), LoadPriority::HotReload), + ("test_blue.png".into(), LoadPriority::CurrentAccess), + ("test_yellow.png".into(), LoadPriority::CurrentAccess), + ("test_magenta.png".into(), LoadPriority::HotReload), + ]; + + let mut loader = app.world_mut().resource_mut::(); + let mut load_task_ids = HashMap::new(); + + for (path, priority) in &asset_paths { + let task_id = loader.submit(path.clone(), *priority); + load_task_ids.insert(task_id, (path.clone(), *priority)); + } + + assert_eq!(loader.queue_len(), asset_paths.len()); + + let mut load_completed_count = 0; + let mut load_completed_paths = Vec::new(); + max_iterations = 1000; + let mut processed_load_task_ids = std::collections::HashSet::new(); + + while load_completed_count < asset_paths.len() && max_iterations > 0 { + app.update(); + max_iterations -= 1; + + let load_events = app + .world() + .resource::>(); + let mut cursor = load_events.get_cursor(); + for event in cursor.read(load_events) { + if processed_load_task_ids.insert(event.task_id) { + load_completed_count += 1; + load_completed_paths.push((event.path.clone(), event.priority)); + + if let Some((expected_path, expected_priority)) = load_task_ids.get(&event.task_id) + { + assert_eq!( + &event.path, expected_path, + "Loaded path should match submitted path" + ); + assert_eq!( + event.priority, *expected_priority, + "Loaded priority should match submitted priority" + ); + } + } + } + + if max_iterations % 100 == 0 { + let loader = app.world().resource::(); + let active_tasks = loader.active_tasks(); + let queue_len = loader.queue_len(); + eprintln!( + "Load progress: completed={}, active={}, queue={}, iterations={}", + load_completed_count, active_tasks, queue_len, max_iterations + ); + + let failed_events = app + .world() + .resource::>(); + let mut failed_cursor = failed_events.get_cursor(); + for event in failed_cursor.read(failed_events) { + eprintln!( + "Load failed: task_id={}, path={:?}, error={}", + event.task_id, event.path, event.error + ); + } + } + } + + assert_eq!( + load_completed_count, + asset_paths.len(), + "All images should be loaded successfully" + ); + + let current_access_indices: Vec = load_completed_paths + .iter() + .enumerate() + .filter_map(|(i, (_, p))| { + if *p == LoadPriority::CurrentAccess { + Some(i) + } else { + None + } + }) + .collect(); + + let hot_reload_indices: Vec = load_completed_paths + .iter() + .enumerate() + .filter_map(|(i, (_, p))| { + if *p == LoadPriority::HotReload { + Some(i) + } else { + None + } + }) + .collect(); + + let preload_indices: Vec = load_completed_paths + .iter() + .enumerate() + .filter_map(|(i, (_, p))| { + if *p == LoadPriority::Preload { + Some(i) + } else { + None + } + }) + .collect(); + + if let (Some(&max_current), Some(&min_preload)) = ( + current_access_indices.iter().max(), + preload_indices.iter().min(), + ) { + assert!( + max_current < min_preload, + "CurrentAccess tasks should complete before Preload tasks" + ); + } + + if let (Some(&max_hot_reload), Some(&min_preload)) = ( + hot_reload_indices.iter().max(), + preload_indices.iter().min(), + ) { + assert!( + max_hot_reload < min_preload, + "HotReload tasks should complete before Preload tasks" + ); + } +} + +/// Functional test: Test priority queue ordering. +#[test] +fn test_asset_loader_priority_queue() { + let mut app = App::new(); + app.init_resource::(); + + app.world_mut() + .resource_mut::() + .submit("preload1.png", LoadPriority::Preload); + app.world_mut() + .resource_mut::() + .submit("current1.png", LoadPriority::CurrentAccess); + app.world_mut() + .resource_mut::() + .submit("hotreload1.png", LoadPriority::HotReload); + app.world_mut() + .resource_mut::() + .submit("preload2.png", LoadPriority::Preload); + app.world_mut() + .resource_mut::() + .submit("current2.png", LoadPriority::CurrentAccess); + + let loader = app.world().resource::(); + assert_eq!(loader.queue_len(), 5); + + // Verify priority ordering: CurrentAccess > HotReload > Preload + let mut loader_mut = app.world_mut().resource_mut::(); + + let task1 = loader_mut.pop_next().unwrap(); + assert_eq!(task1.priority, LoadPriority::CurrentAccess); + assert_eq!(task1.path, "current1.png".into()); + + let task2 = loader_mut.pop_next().unwrap(); + assert_eq!(task2.priority, LoadPriority::CurrentAccess); + assert_eq!(task2.path, "current2.png".into()); + + let task3 = loader_mut.pop_next().unwrap(); + assert_eq!(task3.priority, LoadPriority::HotReload); + assert_eq!(task3.path, "hotreload1.png".into()); + + let task4 = loader_mut.pop_next().unwrap(); + assert_eq!(task4.priority, LoadPriority::Preload); + assert_eq!(task4.path, "preload1.png".into()); + + let task5 = loader_mut.pop_next().unwrap(); + assert_eq!(task5.priority, LoadPriority::Preload); + assert_eq!(task5.path, "preload2.png".into()); + + assert_eq!(loader_mut.queue_len(), 0); +} + +/// Functional test: Test concurrent control. +#[test] +fn test_asset_loader_concurrent_control() { + let mut app = App::new(); + app.init_resource::(); + + let mut loader = app.world_mut().resource_mut::(); + + // Default max concurrent is 4 + assert_eq!(loader.active_tasks(), 0); + assert!(loader.can_start_task()); + + // Start tasks up to max concurrent limit + loader.start_task(); + loader.start_task(); + loader.start_task(); + assert_eq!(loader.active_tasks(), 3); + assert!(loader.can_start_task()); + + // Reach max concurrent limit + loader.start_task(); + assert_eq!(loader.active_tasks(), 4); + assert!(!loader.can_start_task()); + + // Finish one task, should allow starting new tasks again + loader.finish_task(); + assert_eq!(loader.active_tasks(), 3); + assert!(loader.can_start_task()); +} From 71c4987a788f7cab179d7c1c699e6f0180f291ff Mon Sep 17 00:00:00 2001 From: Sieluna Date: Wed, 7 Jan 2026 15:48:18 +0200 Subject: [PATCH 3/8] Implement preview workflow --- crates/bevy_asset_preview/Cargo.toml | 4 +- crates/bevy_asset_preview/src/asset/saver.rs | 18 +- crates/bevy_asset_preview/src/lib.rs | 35 +- .../bevy_asset_preview/src/preview/cache.rs | 105 +++ crates/bevy_asset_preview/src/preview/mod.rs | 54 ++ .../bevy_asset_preview/src/preview/systems.rs | 230 ++++++ crates/bevy_asset_preview/src/ui/mod.rs | 153 +++- .../tests/asset_loader_test.rs | 46 +- .../bevy_asset_preview/tests/preview_test.rs | 760 ++++++++++++++++++ 9 files changed, 1370 insertions(+), 35 deletions(-) create mode 100644 crates/bevy_asset_preview/src/preview/cache.rs create mode 100644 crates/bevy_asset_preview/src/preview/mod.rs create mode 100644 crates/bevy_asset_preview/src/preview/systems.rs create mode 100644 crates/bevy_asset_preview/tests/preview_test.rs diff --git a/crates/bevy_asset_preview/Cargo.toml b/crates/bevy_asset_preview/Cargo.toml index 4a90c69a..e6344117 100644 --- a/crates/bevy_asset_preview/Cargo.toml +++ b/crates/bevy_asset_preview/Cargo.toml @@ -4,8 +4,8 @@ version = "0.1.0" edition = "2024" [dependencies] -bevy.workspace = true -image = "0.25" +bevy = { workspace = true, features = ["webp"] } +image = { version = "0.25", features = ["webp"] } [dev-dependencies] tempfile = "3" diff --git a/crates/bevy_asset_preview/src/asset/saver.rs b/crates/bevy_asset_preview/src/asset/saver.rs index 112a08c4..c71e50af 100644 --- a/crates/bevy_asset_preview/src/asset/saver.rs +++ b/crates/bevy_asset_preview/src/asset/saver.rs @@ -83,7 +83,15 @@ pub fn save_image<'a>( let task_pool = IoTaskPool::get(); let target_path_clone = target_path.clone(); - let target_path_for_writer = target_path.path().to_path_buf(); + // Ensure the file extension is .webp for WebP format + let mut target_path_for_writer = target_path.path().to_path_buf(); + if let Some(ext) = target_path_for_writer.extension() { + if ext.to_str() != Some("webp") { + target_path_for_writer.set_extension("webp"); + } + } else { + target_path_for_writer.set_extension("webp"); + } task_pool.spawn(async move { // Create directory first @@ -97,12 +105,12 @@ pub fn save_image<'a>( // Encode PNG directly to memory let mut cursor = Cursor::new(Vec::new()); - match rgba_image.write_to(&mut cursor, image::ImageFormat::Png) { + match rgba_image.write_to(&mut cursor, image::ImageFormat::WebP) { Ok(_) => { - let png_bytes = cursor.into_inner(); + let webp_bytes = cursor.into_inner(); // Write via AssetWriter (atomic operation) match writer - .write_bytes(&target_path_for_writer, &png_bytes) + .write_bytes(&target_path_for_writer, &webp_bytes) .await { Ok(_) => { @@ -118,7 +126,7 @@ pub fn save_image<'a>( } } Err(e) => { - let error = format!("Failed to encode image to PNG: {:?}", e); + let error = format!("Failed to encode image to WebP: {:?}", e); bevy::log::error!("{}", error); Err(error) } diff --git a/crates/bevy_asset_preview/src/lib.rs b/crates/bevy_asset_preview/src/lib.rs index 946ad779..0c5eb65e 100644 --- a/crates/bevy_asset_preview/src/lib.rs +++ b/crates/bevy_asset_preview/src/lib.rs @@ -1,7 +1,9 @@ mod asset; +mod preview; mod ui; pub use asset::*; +pub use preview::*; pub use ui::*; use bevy::prelude::*; @@ -13,10 +15,41 @@ use bevy::prelude::*; /// So long as the assets are unchanged, the previews will be cached and will not need to be re-rendered. /// In theory this can be done passively in the background, and the previews will be ready when the user needs them. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AssetPreviewType { + Image, + GLTF, + Scene, + Other, +} + pub struct AssetPreviewPlugin; impl Plugin for AssetPreviewPlugin { - fn build(&self, _app: &mut App) { + fn build(&self, app: &mut App) { + // Initialize resources + app.init_resource::(); + app.init_resource::(); + + // Register events + app.add_event::(); + app.add_event::(); + app.add_event::(); + + // Register systems + // Process preview requests and submit to loader + app.add_systems(Update, ui::preview_handler); + + // Process load queue (starts new load tasks) + app.add_systems(Update, asset::process_load_queue); + + // Handle asset events (completion, failures, hot reloads) + app.add_systems(Update, asset::handle_asset_events); + // Handle preview load completion and update UI + app.add_systems( + Update, + ui::handle_preview_load_completed.after(asset::handle_asset_events), + ); } } diff --git a/crates/bevy_asset_preview/src/preview/cache.rs b/crates/bevy_asset_preview/src/preview/cache.rs new file mode 100644 index 00000000..0981e833 --- /dev/null +++ b/crates/bevy_asset_preview/src/preview/cache.rs @@ -0,0 +1,105 @@ +use bevy::{ + asset::{AssetId, AssetPath}, + image::Image, + platform::collections::HashMap, + prelude::{Handle, Resource}, +}; + +/// Cache entry for a preview image. +#[derive(Clone, Debug)] +pub struct PreviewCacheEntry { + /// The preview image handle. + pub image_handle: Handle, + /// The asset ID that this preview is for. + pub asset_id: AssetId, + /// Timestamp when the preview was generated (for cache invalidation). + pub timestamp: u64, +} + +/// Cache for preview images to avoid re-rendering unchanged assets. +#[derive(Resource, Default)] +pub struct PreviewCache { + /// Maps asset path to cache entry. + path_cache: HashMap, PreviewCacheEntry>, + /// Maps asset ID to cache entry. + id_cache: HashMap, PreviewCacheEntry>, +} + +impl PreviewCache { + /// Creates a new empty cache. + pub fn new() -> Self { + Self { + path_cache: HashMap::new(), + id_cache: HashMap::new(), + } + } + + /// Gets a cached preview by asset path. + pub fn get_by_path<'a>(&self, path: &AssetPath<'a>) -> Option<&PreviewCacheEntry> { + // Convert to owned path for lookup + let owned_path: AssetPath<'static> = path.clone().into_owned(); + self.path_cache.get(&owned_path) + } + + /// Gets a cached preview by asset ID. + pub fn get_by_id(&self, asset_id: AssetId) -> Option<&PreviewCacheEntry> { + self.id_cache.get(&asset_id) + } + + /// Inserts a preview into the cache. + pub fn insert<'a>( + &mut self, + path: impl Into>, + asset_id: AssetId, + image_handle: Handle, + timestamp: u64, + ) { + let path: AssetPath<'static> = path.into().into_owned(); + let entry = PreviewCacheEntry { + image_handle, + asset_id, + timestamp, + }; + self.path_cache.insert(path.clone(), entry.clone()); + self.id_cache.insert(asset_id, entry); + } + + /// Removes a preview from the cache by path. + pub fn remove_by_path<'a>(&mut self, path: &AssetPath<'a>) -> Option { + // Convert to owned path for lookup + let owned_path: AssetPath<'static> = path.clone().into_owned(); + if let Some(entry) = self.path_cache.remove(&owned_path) { + self.id_cache.remove(&entry.asset_id); + Some(entry) + } else { + None + } + } + + /// Removes a preview from the cache by asset ID. + pub fn remove_by_id(&mut self, asset_id: AssetId) -> Option { + if let Some(entry) = self.id_cache.remove(&asset_id) { + // Find and remove from path cache + self.path_cache.retain(|_, e| e.asset_id != asset_id); + Some(entry) + } else { + None + } + } + + /// Clears all cached previews. + pub fn clear(&mut self) { + self.path_cache.clear(); + self.id_cache.clear(); + } + + /// Returns the number of cached previews. + pub fn len(&self) -> usize { + self.path_cache.len() + } + + /// Checks if the cache is empty. + pub fn is_empty(&self) -> bool { + self.path_cache.is_empty() + } +} diff --git a/crates/bevy_asset_preview/src/preview/mod.rs b/crates/bevy_asset_preview/src/preview/mod.rs new file mode 100644 index 00000000..d48e3182 --- /dev/null +++ b/crates/bevy_asset_preview/src/preview/mod.rs @@ -0,0 +1,54 @@ +mod cache; +mod systems; + +use bevy::{asset::RenderAssetUsages, image::Image}; +pub use cache::{PreviewCache, PreviewCacheEntry}; +pub use systems::{ + PendingPreviewRequest, PreviewFailed, PreviewReady, PreviewTaskManager, + handle_image_preview_events, request_image_preview, +}; + +/// Maximum preview size for 2D images (256x256). +const MAX_PREVIEW_SIZE: u32 = 256; + +/// Resizes an image to preview size if it's larger than the maximum. +/// Returns a new compressed image, or None if the image is already small enough. +pub fn compress_image_for_preview(image: &Image) -> Option { + let width = image.width(); + let height = image.height(); + + // If image is already small enough, return None (use original) + if width <= MAX_PREVIEW_SIZE && height <= MAX_PREVIEW_SIZE { + return None; + } + + // Calculate new size maintaining aspect ratio + let (new_width, new_height) = if width > height { + ( + MAX_PREVIEW_SIZE, + (height as f32 * MAX_PREVIEW_SIZE as f32 / width as f32) as u32, + ) + } else { + ( + (width as f32 * MAX_PREVIEW_SIZE as f32 / height as f32) as u32, + MAX_PREVIEW_SIZE, + ) + }; + + // Convert to dynamic image for resizing + let dynamic_image = match image.clone().try_into_dynamic() { + Ok(img) => img, + Err(_) => return None, + }; + + // Resize using high-quality filter + let resized = + dynamic_image.resize_exact(new_width, new_height, image::imageops::FilterType::Lanczos3); + + // Convert back to Image + Some(Image::from_dynamic( + resized, + true, // is_srgb + RenderAssetUsages::RENDER_WORLD, + )) +} diff --git a/crates/bevy_asset_preview/src/preview/systems.rs b/crates/bevy_asset_preview/src/preview/systems.rs new file mode 100644 index 00000000..3cbaa0df --- /dev/null +++ b/crates/bevy_asset_preview/src/preview/systems.rs @@ -0,0 +1,230 @@ +use bevy::{ + asset::{AssetEvent, AssetPath, AssetServer}, + ecs::event::{BufferedEvent, EventReader, EventWriter}, + image::Image, + platform::collections::HashMap, + prelude::*, +}; + +use crate::preview::{PreviewCache, compress_image_for_preview}; + +/// Event emitted when a preview is ready. +#[derive(Event, BufferedEvent, Debug, Clone)] +pub struct PreviewReady { + /// Task ID of the preview request. + pub task_id: u64, + /// Asset path. + pub path: AssetPath<'static>, + /// Preview image handle. + pub image_handle: Handle, +} + +/// Event emitted when a preview generation fails. +#[derive(Event, BufferedEvent, Debug, Clone)] +pub struct PreviewFailed { + /// Task ID of the preview request. + pub task_id: u64, + /// Asset path. + pub path: AssetPath<'static>, + /// Error message. + pub error: String, +} + +/// Component that tracks a pending preview request. +#[derive(Component, Debug)] +pub struct PendingPreviewRequest { + /// Task ID. + pub task_id: u64, + /// Asset path. + pub path: AssetPath<'static>, +} + +/// Resource that manages preview generation tasks. +#[derive(Resource, Default)] +pub struct PreviewTaskManager { + /// Next task ID. + next_task_id: u64, + /// Maps task ID to request entity. + task_to_entity: HashMap, +} + +impl PreviewTaskManager { + /// Creates a new task manager. + pub fn new() -> Self { + Self { + next_task_id: 0, + task_to_entity: HashMap::new(), + } + } + + /// Creates a new task ID. + pub fn create_task_id(&mut self) -> u64 { + let id = self.next_task_id; + self.next_task_id += 1; + id + } + + /// Registers a task entity. + pub fn register_task(&mut self, task_id: u64, entity: Entity) { + self.task_to_entity.insert(task_id, entity); + } + + /// Gets entity for a task ID. + pub fn get_entity(&self, task_id: u64) -> Option { + self.task_to_entity.get(&task_id).copied() + } + + /// Removes task registration. + pub fn remove_task(&mut self, task_id: u64) { + self.task_to_entity.remove(&task_id); + } +} + +/// Requests a preview for an image asset path. +/// This is a helper that can be used in systems or other contexts. +/// Returns the task ID for tracking. +pub fn request_image_preview<'a>( + mut commands: Commands, + mut task_manager: ResMut, + cache: Res, + asset_server: Res, + images: Res>, + mut images_mut: ResMut>, + mut preview_ready_events: EventWriter, + path: impl Into>, +) -> u64 { + let path: AssetPath<'static> = path.into().into_owned(); + + // Check cache first + if let Some(cache_entry) = cache.get_by_path(&path) { + // Cache hit - send ready event immediately + let task_id = task_manager.create_task_id(); + preview_ready_events.write(PreviewReady { + task_id, + path: path.clone(), + image_handle: cache_entry.image_handle.clone(), + }); + return task_id; + } + + // Cache miss - create request + let task_id = task_manager.create_task_id(); + let handle: Handle = asset_server.load(&path); + + // Check if image is already loaded + if let Some(image) = images.get(&handle) { + // Image is already loaded, compress if needed and cache + let preview_image = if let Some(compressed) = compress_image_for_preview(image) { + images_mut.add(compressed) + } else { + handle.clone() + }; + + // Cache will be handled in handle_image_preview_events when the event is processed + + preview_ready_events.write(PreviewReady { + task_id, + path: path.clone(), + image_handle: preview_image, + }); + return task_id; + } + + // Image not loaded yet - create pending request + let entity = commands + .spawn(PendingPreviewRequest { + task_id, + path: path.clone(), + }) + .id(); + task_manager.register_task(task_id, entity); + task_id +} + +/// System that handles image asset events for previews and caches ready previews. +pub fn handle_image_preview_events( + mut commands: Commands, + mut cache: ResMut, + asset_server: Res, + mut preview_ready_events: EventWriter, + mut preview_failed_events: EventWriter, + mut asset_events: EventReader>, + mut images: ResMut>, + requests: Query<(Entity, &PendingPreviewRequest)>, + mut task_manager: ResMut, + mut ready_events: EventReader, +) { + // Cache previews from ready events + for event in ready_events.read() { + // Cache the preview if not already cached + if cache.get_by_path(&event.path).is_none() { + let asset_id = event.image_handle.id(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + cache.insert(&event.path, asset_id, event.image_handle.clone(), timestamp); + } + } + for event in asset_events.read() { + match event { + AssetEvent::LoadedWithDependencies { id } => { + // Find requests waiting for this image + for (entity, request) in requests.iter() { + let handle: Handle = asset_server.load(&request.path); + if handle.id() == *id { + if let Some(image) = images.get(&handle) { + // Compress if needed + let preview_image = + if let Some(compressed) = compress_image_for_preview(image) { + images.add(compressed) + } else { + handle.clone() + }; + + // Cache the preview + let preview_id = preview_image.id(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + cache.insert( + &request.path, + preview_id, + preview_image.clone(), + timestamp, + ); + + // Send ready event + preview_ready_events.write(PreviewReady { + task_id: request.task_id, + path: request.path.clone(), + image_handle: preview_image, + }); + + // Cleanup + task_manager.remove_task(request.task_id); + commands.entity(entity).despawn(); + } + } + } + } + AssetEvent::Removed { id } => { + // Find requests for removed image + for (entity, request) in requests.iter() { + let handle: Handle = asset_server.load(&request.path); + if handle.id() == *id { + preview_failed_events.write(PreviewFailed { + task_id: request.task_id, + path: request.path.clone(), + error: "Image asset was removed".to_string(), + }); + task_manager.remove_task(request.task_id); + commands.entity(entity).despawn(); + } + } + } + _ => {} + } + } +} diff --git a/crates/bevy_asset_preview/src/ui/mod.rs b/crates/bevy_asset_preview/src/ui/mod.rs index 5727d4f1..8570163c 100644 --- a/crates/bevy_asset_preview/src/ui/mod.rs +++ b/crates/bevy_asset_preview/src/ui/mod.rs @@ -1,21 +1,160 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; -use bevy::{asset::AssetServer, prelude::*}; +use bevy::{ + asset::{AssetPath, AssetServer}, + image::Image, + prelude::*, +}; + +use crate::{ + asset::{AssetLoader, LoadPriority}, + preview::{PreviewCache, compress_image_for_preview}, +}; #[derive(Component, Deref)] pub struct PreviewAsset(pub PathBuf); +/// Component to track pending preview load requests +#[derive(Component, Debug)] +pub struct PendingPreviewLoad { + pub task_id: u64, + pub asset_path: AssetPath<'static>, +} + const FILE_PLACEHOLDER: &'static str = "embedded://bevy_asset_browser/assets/file_icon.png"; +/// Checks if a file path represents an image file based on its extension. +pub fn is_image_file(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| { + matches!( + ext.to_lowercase().as_str(), + "png" + | "jpg" + | "jpeg" + | "bmp" + | "gif" + | "ico" + | "pnm" + | "pam" + | "pbm" + | "pgm" + | "ppm" + | "tga" + | "webp" + | "tiff" + | "tif" + | "dds" + | "exr" + | "hdr" + | "ktx2" + | "basis" + | "qoi" + ) + }) + .unwrap_or(false) +} + +/// System that handles PreviewAsset components and initiates preview loading. pub fn preview_handler( mut commands: Commands, - mut requests_query: Query<(Entity, &PreviewAsset)>, + mut requests_query: Query< + (Entity, &PreviewAsset), + (Without, Without), + >, asset_server: Res, + mut loader: ResMut, + cache: Res, +) { + for (entity, preview_asset) in &mut requests_query { + let path = &preview_asset.0; + + // Check if it's an image file + if !is_image_file(path) { + // Not an image, use placeholder + let placeholder = asset_server.load(FILE_PLACEHOLDER); + commands.entity(entity).insert(ImageNode::new(placeholder)); + commands.entity(entity).remove::(); + continue; + } + + // Convert PathBuf to AssetPath + let asset_path: AssetPath<'static> = path.clone().into(); + + // Check cache first + if let Some(cache_entry) = cache.get_by_path(&asset_path) { + // Cache hit - use cached preview immediately + commands + .entity(entity) + .insert(ImageNode::new(cache_entry.image_handle.clone())); + commands.entity(entity).remove::(); + continue; + } + + // Cache miss - submit to AssetLoader with Preload priority + // (can be upgraded to CurrentAccess based on viewport visibility later) + let task_id = loader.submit(&asset_path, LoadPriority::Preload); + + // Mark as pending + commands.entity(entity).insert(PendingPreviewLoad { + task_id, + asset_path: asset_path.clone(), + }); + + // Insert placeholder temporarily + let placeholder = asset_server.load(FILE_PLACEHOLDER); + commands.entity(entity).insert(ImageNode::new(placeholder)); + } +} + +/// System that handles completed asset loads and updates previews. +pub fn handle_preview_load_completed( + mut commands: Commands, + mut cache: ResMut, + mut images: ResMut>, + mut load_completed_events: EventReader, + pending_query: Query<(Entity, &PendingPreviewLoad)>, + mut image_node_query: Query<&mut ImageNode>, ) { - for (entity, preview) in &mut requests_query { - let preview = asset_server.load(FILE_PLACEHOLDER); + for event in load_completed_events.read() { + // Find entities waiting for this task + for (entity, pending) in pending_query.iter() { + if pending.task_id == event.task_id { + // Check if image is loaded + if let Some(image) = images.get(&event.handle) { + // Compress if needed + let preview_image = if let Some(compressed) = compress_image_for_preview(image) + { + images.add(compressed) + } else { + event.handle.clone() + }; + + // Cache the preview + let preview_id = preview_image.id(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + cache.insert( + &pending.asset_path, + preview_id, + preview_image.clone(), + timestamp, + ); + + // Update ImageNode + if let Ok(mut image_node) = image_node_query.get_mut(entity) { + image_node.image = preview_image; + } - // TODO: sprite atlas. - commands.entity(entity).insert(ImageNode::new(preview)); + // Cleanup + commands.entity(entity).remove::(); + commands.entity(entity).remove::(); + } + break; + } + } } } diff --git a/crates/bevy_asset_preview/tests/asset_loader_test.rs b/crates/bevy_asset_preview/tests/asset_loader_test.rs index fee0ba8a..283a8643 100644 --- a/crates/bevy_asset_preview/tests/asset_loader_test.rs +++ b/crates/bevy_asset_preview/tests/asset_loader_test.rs @@ -226,12 +226,14 @@ fn test_complete_filesystem_workflow() { std::thread::sleep(std::time::Duration::from_millis(100)); for (_, path, _) in &test_images { - let saved_path = temp_dir + let mut saved_path = temp_dir .path() .join("cache") .join("asset_preview") .join(path); + saved_path.set_extension("webp"); + let saved_path = saved_path .canonicalize() .unwrap_or_else(|_| saved_path.clone()); @@ -244,12 +246,15 @@ fn test_complete_filesystem_workflow() { } for (_, path, _) in &test_images { - let src = temp_dir + let mut src = temp_dir .path() .join("cache") .join("asset_preview") .join(path); - let dst = assets_dir.join(path); + src.set_extension("webp"); + + let mut dst = assets_dir.join(path); + dst.set_extension("webp"); if let Some(parent) = dst.parent() { fs::create_dir_all(parent).expect("Failed to create assets subdirectory"); } @@ -257,7 +262,8 @@ fn test_complete_filesystem_workflow() { } for (_, path, _) in &test_images { - let asset_file = assets_dir.join(path); + let mut asset_file = assets_dir.join(path); + asset_file.set_extension("webp"); assert!( asset_file.exists(), "Asset file should exist before loading: {:?}", @@ -266,11 +272,11 @@ fn test_complete_filesystem_workflow() { } let asset_paths: Vec<(bevy::asset::AssetPath<'static>, LoadPriority)> = vec![ - ("test_red.png".into(), LoadPriority::Preload), - ("test_green.png".into(), LoadPriority::HotReload), - ("test_blue.png".into(), LoadPriority::CurrentAccess), - ("test_yellow.png".into(), LoadPriority::CurrentAccess), - ("test_magenta.png".into(), LoadPriority::HotReload), + ("test_red.webp".into(), LoadPriority::Preload), + ("test_green.webp".into(), LoadPriority::HotReload), + ("test_blue.webp".into(), LoadPriority::CurrentAccess), + ("test_yellow.webp".into(), LoadPriority::CurrentAccess), + ("test_magenta.webp".into(), LoadPriority::HotReload), ]; let mut loader = app.world_mut().resource_mut::(); @@ -315,6 +321,17 @@ fn test_complete_filesystem_workflow() { } } + let failed_events = app + .world() + .resource::>(); + let mut failed_cursor = failed_events.get_cursor(); + for event in failed_cursor.read(failed_events) { + eprintln!( + "Load failed: task_id={}, path={:?}, error={}", + event.task_id, event.path, event.error + ); + } + if max_iterations % 100 == 0 { let loader = app.world().resource::(); let active_tasks = loader.active_tasks(); @@ -323,17 +340,6 @@ fn test_complete_filesystem_workflow() { "Load progress: completed={}, active={}, queue={}, iterations={}", load_completed_count, active_tasks, queue_len, max_iterations ); - - let failed_events = app - .world() - .resource::>(); - let mut failed_cursor = failed_events.get_cursor(); - for event in failed_cursor.read(failed_events) { - eprintln!( - "Load failed: task_id={}, path={:?}, error={}", - event.task_id, event.path, event.error - ); - } } } diff --git a/crates/bevy_asset_preview/tests/preview_test.rs b/crates/bevy_asset_preview/tests/preview_test.rs new file mode 100644 index 00000000..a8bf4d71 --- /dev/null +++ b/crates/bevy_asset_preview/tests/preview_test.rs @@ -0,0 +1,760 @@ +use std::fs; +use std::path::PathBuf; + +use bevy::{ + asset::{AssetPath, AssetPlugin}, + image::{CompressedImageFormats, ImageLoader}, + prelude::*, + render::{ + render_asset::RenderAssetUsages, + render_resource::{Extent3d, TextureDimension, TextureFormat}, + }, +}; +use bevy_asset_preview::{ + AssetLoadCompleted, AssetLoadFailed, AssetLoader, AssetPreviewPlugin, PreviewAsset, + PreviewCache, is_image_file, +}; +use tempfile::TempDir; + +/// Helper function to create a test image with a specific color. +fn create_test_image( + images: &mut Assets, + width: u32, + height: u32, + color: [u8; 4], +) -> Handle { + let pixel_data: Vec = (0..(width * height)) + .flat_map(|_| color.iter().copied()) + .collect(); + + let image = Image::new_fill( + Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + &pixel_data, + TextureFormat::Rgba8UnormSrgb, + RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD, + ); + + images.add(image) +} + +/// Helper function to save an image to disk, handling format-specific requirements. +fn save_test_image( + image: &Image, + path: &std::path::Path, +) -> Result<(), Box> { + let dynamic_image = image + .clone() + .try_into_dynamic() + .map_err(|e| format!("Failed to convert image: {:?}", e))?; + + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("png") + .to_lowercase(); + + match ext.as_str() { + "jpg" | "jpeg" => { + // JPEG doesn't support transparency, convert to RGB + let rgb_image = dynamic_image.into_rgb8(); + rgb_image.save(path)?; + } + _ => { + // PNG and other formats support RGBA + let rgba_image = dynamic_image.into_rgba8(); + rgba_image.save(path)?; + } + } + Ok(()) +} + +/// Test image file detection function. +#[test] +fn test_is_image_file() { + assert!(is_image_file(PathBuf::from("test.png").as_path())); + assert!(is_image_file(PathBuf::from("test.jpg").as_path())); + assert!(is_image_file(PathBuf::from("test.jpeg").as_path())); + assert!(is_image_file(PathBuf::from("test.bmp").as_path())); + assert!(is_image_file(PathBuf::from("test.gif").as_path())); + assert!(is_image_file(PathBuf::from("test.webp").as_path())); + assert!(is_image_file(PathBuf::from("test.tga").as_path())); + assert!(is_image_file(PathBuf::from("test.TGA").as_path())); // Case insensitive + + assert!(!is_image_file(PathBuf::from("test.txt").as_path())); + assert!(!is_image_file(PathBuf::from("test.rs").as_path())); + assert!(!is_image_file(PathBuf::from("test").as_path())); // No extension +} + +/// Test complete preview workflow with image loading and compression. +#[test] +fn test_complete_preview_workflow() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let assets_dir = temp_dir.path().join("assets"); + fs::create_dir_all(&assets_dir).expect("Failed to create assets directory"); + + unsafe { + std::env::set_var( + "BEVY_ASSET_ROOT", + temp_dir + .path() + .to_str() + .expect("Failed to convert path to string"), + ); + } + + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(AssetPlugin { + file_path: assets_dir.display().to_string(), + ..Default::default() + }) + .add_plugins(AssetPreviewPlugin) + .init_asset::() + .register_asset_loader(ImageLoader::new(CompressedImageFormats::NONE)); + + // Create a large test image (512x512) that should be compressed + { + let mut images = app.world_mut().resource_mut::>(); + let handle = create_test_image(&mut images, 512, 512, [128, 128, 128, 255]); + let image = images.get(&handle).unwrap(); + + let path = assets_dir.join("test_large.png"); + save_test_image(image, &path).expect("Failed to save test image"); + } + + // Spawn entity with PreviewAsset + let entity = app + .world_mut() + .spawn(PreviewAsset(PathBuf::from("test_large.png"))) + .id(); + + // Run systems until preview is ready + let mut max_iterations = 1000; + let mut preview_ready = false; + while !preview_ready && max_iterations > 0 { + app.update(); + max_iterations -= 1; + + let world = app.world(); + // Check if PreviewAsset and PendingPreviewLoad are removed (preview is ready) + if !world.entity(entity).contains::() + && !world + .entity(entity) + .contains::() + { + preview_ready = true; + } + } + + assert!(preview_ready, "Preview should be ready after loading"); + + // Check that ImageNode was updated with preview + let world = app.world(); + assert!( + world.entity(entity).contains::(), + "ImageNode should be present after preview is ready" + ); + + // Check that preview was cached + let cache = app.world().resource::(); + let asset_path: AssetPath<'static> = "test_large.png".into(); + assert!( + cache.get_by_path(&asset_path).is_some(), + "Large image preview should be cached" + ); + + // Verify the cached preview is compressed (256x256 max) + let cache_entry = cache.get_by_path(&asset_path).unwrap(); + let images = app.world().resource::>(); + if let Some(preview_image) = images.get(&cache_entry.image_handle) { + assert!( + preview_image.width() <= 256 && preview_image.height() <= 256, + "Cached preview should be compressed to max 256x256, got {}x{}", + preview_image.width(), + preview_image.height() + ); + } +} + +/// Simulate the complete frontend workflow: DirectoryContent -> spawn_file_node -> PreviewAsset -> preview loading +#[test] +fn test_frontend_workflow_simulation() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let assets_dir = temp_dir.path().join("assets"); + fs::create_dir_all(&assets_dir).expect("Failed to create assets directory"); + + unsafe { + std::env::set_var( + "BEVY_ASSET_ROOT", + temp_dir + .path() + .to_str() + .expect("Failed to convert path to string"), + ); + } + + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(AssetPlugin { + file_path: assets_dir.display().to_string(), + ..Default::default() + }) + .add_plugins(AssetPreviewPlugin) + .init_asset::() + .register_asset_loader(ImageLoader::new(CompressedImageFormats::NONE)); + + // Step 1: Create test files (simulating files in a directory) + let test_files = vec![ + ("icon.png", [255, 0, 0, 255], true), // Image file + ("sprite.png", [0, 255, 0, 255], true), // Image file + ("readme.txt", [0, 0, 0, 0], false), // Non-image file + ("texture.png", [0, 0, 255, 255], true), // Image file + ]; + + { + let mut images = app.world_mut().resource_mut::>(); + for (filename, color, is_image) in &test_files { + if *is_image { + let handle = create_test_image(&mut images, 128, 128, *color); + let image = images.get(&handle).unwrap(); + + let path = assets_dir.join(filename); + save_test_image(image, &path).expect("Failed to save test image"); + } else { + // Create a text file + let path = assets_dir.join(filename); + fs::write(&path, "Test file content").expect("Failed to write test file"); + } + } + } + + // Step 2: Simulate DirectoryContent (like refresh_ui system would do) + // This simulates what happens when DirectoryContent changes and populate_directory_content is called + let mut file_entities = Vec::new(); + let location_path = PathBuf::from(""); // Root directory + + for (filename, _, _) in &test_files { + // Simulate spawn_file_node creating PreviewAsset + let file_entity = app + .world_mut() + .spawn(PreviewAsset(location_path.join(filename))) + .id(); + file_entities.push(( + file_entity, + filename, + is_image_file(&PathBuf::from(filename)), + )); + } + + // Step 3: Run preview_handler (simulates what happens in Update schedule) + app.update(); + + // Step 4: Verify initial state + let world = app.world(); + for (entity, filename, is_image) in &file_entities { + assert!( + world.entity(*entity).contains::(), + "ImageNode should be added for file: {}", + filename + ); + + if *is_image { + // Image files should have PendingPreviewLoad (submitted to loader) + assert!( + world + .entity(*entity) + .contains::(), + "Image file {} should have PendingPreviewLoad", + filename + ); + } else { + // Non-image files should have PreviewAsset removed (placeholder used) + assert!( + !world.entity(*entity).contains::(), + "Non-image file {} should have PreviewAsset removed", + filename + ); + } + } + + // Step 5: Wait for all image previews to load (simulate async loading) + let image_entities: Vec<_> = file_entities + .iter() + .filter(|(_, _, is_image)| *is_image) + .map(|(entity, filename, _)| (*entity, *filename)) + .collect(); + + let mut max_iterations = 3000; + while max_iterations > 0 { + app.update(); + max_iterations -= 1; + + // Check if all image previews are ready (no PreviewAsset and no PendingPreviewLoad) + let world = app.world(); + let all_ready = image_entities.iter().all(|(entity, _)| { + !world.entity(*entity).contains::() + && !world + .entity(*entity) + .contains::() + }); + + if all_ready { + break; + } + } + + // Verify all image previews are ready + let world = app.world(); + for (entity, filename) in &image_entities { + if world.entity(*entity).contains::() { + // Check if there are any load failed events + let failed_events = app + .world() + .resource::>(); + let mut cursor = failed_events.get_cursor(); + let failures: Vec<_> = cursor.read(failed_events).collect(); + if !failures.is_empty() { + panic!( + "Image {} failed to load. Failures: {:?}", + filename, failures + ); + } + } + assert!( + !world.entity(*entity).contains::(), + "PreviewAsset should be removed after loading for file: {}", + filename + ); + assert!( + !world + .entity(*entity) + .contains::(), + "PendingPreviewLoad should be removed after loading for file: {}", + filename + ); + } + + // Step 6: Verify final state - all previews should be ready + let world = app.world(); + for (entity, filename, is_image) in &file_entities { + if *is_image { + // Image files: PreviewAsset and PendingPreviewLoad should be removed, ImageNode should have preview + assert!( + !world.entity(*entity).contains::(), + "Image file {} should have PreviewAsset removed after loading", + filename + ); + assert!( + !world + .entity(*entity) + .contains::(), + "Image file {} should have PendingPreviewLoad removed after loading", + filename + ); + assert!( + world.entity(*entity).contains::(), + "Image file {} should still have ImageNode after loading", + filename + ); + } + } + + // Step 7: Verify cache was populated + let cache = app.world().resource::(); + for (_, filename, is_image) in &file_entities { + if *is_image { + let asset_path: AssetPath<'static> = filename.to_string().into(); + assert!( + cache.get_by_path(&asset_path).is_some(), + "Image file {} should be cached", + filename + ); + } + } +} + +/// Test batch preview loading with mixed priorities (simulating viewport scrolling) +#[test] +fn test_batch_preview_loading_with_priorities() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let assets_dir = temp_dir.path().join("assets"); + fs::create_dir_all(&assets_dir).expect("Failed to create assets directory"); + + unsafe { + std::env::set_var( + "BEVY_ASSET_ROOT", + temp_dir + .path() + .to_str() + .expect("Failed to convert path to string"), + ); + } + + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(AssetPlugin { + file_path: assets_dir.display().to_string(), + ..Default::default() + }) + .add_plugins(AssetPreviewPlugin) + .init_asset::() + .register_asset_loader(ImageLoader::new(CompressedImageFormats::NONE)); + + // Create many test images (simulating a folder with many files) + let num_images: usize = 20; + let mut test_files = Vec::new(); + + { + let mut images = app.world_mut().resource_mut::>(); + for i in 0..num_images { + let filename = format!("image_{:03}.png", i); + let color: [u8; 4] = [ + ((i * 13) % 256) as u8, + ((i * 17) % 256) as u8, + ((i * 19) % 256) as u8, + 255, + ]; + let handle = create_test_image(&mut images, 64, 64, color); + let image = images.get(&handle).unwrap(); + + let path = assets_dir.join(&filename); + save_test_image(image, &path).expect("Failed to save test image"); + test_files.push(filename); + } + } + + // Simulate frontend: spawn all file nodes at once (like opening a folder) + let mut file_entities = Vec::new(); + for filename in &test_files { + let entity = app + .world_mut() + .spawn(PreviewAsset(PathBuf::from(filename))) + .id(); + file_entities.push(entity); + } + + // Run preview_handler - should submit all to loader + app.update(); + + // Verify all were submitted + let loader = app.world().resource::(); + let initial_queue_size = loader.queue_len() + loader.active_tasks(); + assert!( + initial_queue_size > 0, + "Preview requests should be submitted to loader" + ); + + // Wait for all previews to load + let mut max_iterations = 5000; + let mut loaded_count = 0; + let mut processed_task_ids = std::collections::HashSet::new(); + let mut load_order = Vec::new(); + + while loaded_count < num_images && max_iterations > 0 { + app.update(); + max_iterations -= 1; + + let load_events = app + .world() + .resource::>(); + let mut cursor = load_events.get_cursor(); + for event in cursor.read(load_events) { + if processed_task_ids.insert(event.task_id) { + loaded_count += 1; + load_order.push((event.path.clone(), event.priority)); + } + } + } + + assert_eq!( + loaded_count, num_images, + "All images should be loaded. Loaded: {}, Expected: {}", + loaded_count, num_images + ); + + // Verify all entities have previews ready + let world = app.world(); + for entity in &file_entities { + assert!( + !world.entity(*entity).contains::(), + "PreviewAsset should be removed after loading" + ); + assert!( + !world + .entity(*entity) + .contains::(), + "PendingPreviewLoad should be removed after loading" + ); + assert!( + world.entity(*entity).contains::(), + "ImageNode should be present after loading" + ); + } + + // Verify cache contains all previews + let cache = app.world().resource::(); + assert_eq!( + cache.len(), + num_images, + "Cache should contain all {} previews", + num_images + ); +} + +/// Test cache hit scenario (simulating re-opening a folder or scrolling back) +#[test] +fn test_cache_hit_scenario() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let assets_dir = temp_dir.path().join("assets"); + fs::create_dir_all(&assets_dir).expect("Failed to create assets directory"); + + unsafe { + std::env::set_var( + "BEVY_ASSET_ROOT", + temp_dir + .path() + .to_str() + .expect("Failed to convert path to string"), + ); + } + + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(AssetPlugin { + file_path: assets_dir.display().to_string(), + ..Default::default() + }) + .add_plugins(AssetPreviewPlugin) + .init_asset::() + .register_asset_loader(ImageLoader::new(CompressedImageFormats::NONE)); + + // Create test image + { + let mut images = app.world_mut().resource_mut::>(); + let handle = create_test_image(&mut images, 128, 128, [255, 128, 64, 255]); + let image = images.get(&handle).unwrap(); + + let path = assets_dir.join("cached_image.png"); + save_test_image(image, &path).expect("Failed to save test image"); + } + + // First request - should load and cache + let _entity1 = app + .world_mut() + .spawn(PreviewAsset(PathBuf::from("cached_image.png"))) + .id(); + + // Wait for first load + let mut max_iterations = 1000; + let mut loaded = false; + while !loaded && max_iterations > 0 { + app.update(); + max_iterations -= 1; + + let load_events = app + .world() + .resource::>(); + let mut cursor = load_events.get_cursor(); + for _event in cursor.read(load_events) { + loaded = true; + } + } + + // Verify cache + let cache = app.world().resource::(); + let asset_path: AssetPath<'static> = "cached_image.png".into(); + assert!( + cache.get_by_path(&asset_path).is_some(), + "Preview should be cached after first load" + ); + + // Simulate re-opening folder or scrolling back - second request + let entity2 = app + .world_mut() + .spawn(PreviewAsset(PathBuf::from("cached_image.png"))) + .id(); + + // Run preview_handler - should use cache immediately + app.update(); + + // Verify cache hit: entity2 should immediately have ImageNode, no PendingPreviewLoad + let world = app.world(); + assert!( + !world.entity(entity2).contains::(), + "PreviewAsset should be removed immediately on cache hit" + ); + assert!( + !world + .entity(entity2) + .contains::(), + "PendingPreviewLoad should not be added on cache hit" + ); + assert!( + world.entity(entity2).contains::(), + "ImageNode should be added immediately on cache hit" + ); + + // Verify loader queue didn't grow (cache hit means no new load request) + // Note: queue might have other tasks, but we verify cache was used by checking entity state +} + +/// Test mixed file types in directory (images and non-images) +#[test] +fn test_mixed_file_types_in_directory() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let assets_dir = temp_dir.path().join("assets"); + fs::create_dir_all(&assets_dir).expect("Failed to create assets directory"); + + unsafe { + std::env::set_var( + "BEVY_ASSET_ROOT", + temp_dir + .path() + .to_str() + .expect("Failed to convert path to string"), + ); + } + + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(AssetPlugin { + file_path: assets_dir.display().to_string(), + ..Default::default() + }) + .add_plugins(AssetPreviewPlugin) + .init_asset::() + .register_asset_loader(ImageLoader::new(CompressedImageFormats::NONE)); + + // Create mixed files + let mixed_files = vec![ + ("sprite.png", true), + ("script.rs", false), + ("texture.png", true), + ("readme.md", false), + ("icon.png", true), + ("config.toml", false), + ]; + + { + let mut images = app.world_mut().resource_mut::>(); + for (filename, is_image) in &mixed_files { + if *is_image { + let handle = create_test_image(&mut images, 64, 64, [128, 128, 128, 255]); + let image = images.get(&handle).unwrap(); + + let path = assets_dir.join(filename); + save_test_image(image, &path).expect("Failed to save test image"); + } else { + let path = assets_dir.join(filename); + fs::write(&path, format!("Content of {}", filename)) + .expect("Failed to write test file"); + } + } + } + + // Simulate DirectoryContent with mixed files + let mut file_entities = Vec::new(); + for (filename, is_image) in &mixed_files { + let entity = app + .world_mut() + .spawn(PreviewAsset(PathBuf::from(filename))) + .id(); + file_entities.push((entity, filename, *is_image)); + } + + // Run preview_handler + app.update(); + + // Verify: images should have PendingPreviewLoad, non-images should have placeholder + let world = app.world(); + for (entity, filename, is_image) in &file_entities { + assert!( + world.entity(*entity).contains::(), + "All files should have ImageNode: {}", + filename + ); + + if *is_image { + assert!( + world + .entity(*entity) + .contains::(), + "Image file {} should have PendingPreviewLoad", + filename + ); + } else { + assert!( + !world.entity(*entity).contains::(), + "Non-image file {} should have PreviewAsset removed (placeholder used)", + filename + ); + assert!( + !world + .entity(*entity) + .contains::(), + "Non-image file {} should not have PendingPreviewLoad", + filename + ); + } + } + + // Wait for image previews to load + let image_entities: Vec<_> = file_entities + .iter() + .filter(|(_, _, is_image)| *is_image) + .map(|(entity, filename, _)| (*entity, *filename)) + .collect(); + + let mut max_iterations = 3000; + while max_iterations > 0 { + app.update(); + max_iterations -= 1; + + // Check if all image previews are ready + let world = app.world(); + let all_ready = image_entities.iter().all(|(entity, _)| { + !world.entity(*entity).contains::() + && !world + .entity(*entity) + .contains::() + }); + + if all_ready { + break; + } + } + + // Check for any load failures + let failed_events = app + .world() + .resource::>(); + let mut cursor = failed_events.get_cursor(); + let failures: Vec<_> = cursor.read(failed_events).collect(); + if !failures.is_empty() { + panic!("Some images failed to load: {:?}", failures); + } + + // Final verification + let world = app.world(); + for (entity, filename, is_image) in &file_entities { + if *is_image { + assert!( + !world.entity(*entity).contains::(), + "Image file {} should have PreviewAsset removed after loading", + filename + ); + assert!( + !world + .entity(*entity) + .contains::(), + "Image file {} should have PendingPreviewLoad removed after loading", + filename + ); + } + } +} From 27d40173a7852ce91f9a20ca9b4294b7290f54d4 Mon Sep 17 00:00:00 2001 From: Sieluna Date: Wed, 7 Jan 2026 19:13:08 +0200 Subject: [PATCH 4/8] Replace timestamp by bevy time and few refactors --- crates/bevy_asset_preview/Cargo.toml | 2 +- crates/bevy_asset_preview/src/asset/saver.rs | 7 +- crates/bevy_asset_preview/src/preview/mod.rs | 4 +- .../bevy_asset_preview/src/preview/systems.rs | 24 ++++--- crates/bevy_asset_preview/src/ui/mod.rs | 12 ++-- .../{asset_loader_test.rs => asset_test.rs} | 33 +--------- crates/bevy_asset_preview/tests/common/mod.rs | 64 +++++++++++++++++++ .../bevy_asset_preview/tests/preview_test.rs | 64 +------------------ 8 files changed, 93 insertions(+), 117 deletions(-) rename crates/bevy_asset_preview/tests/{asset_loader_test.rs => asset_test.rs} (95%) create mode 100644 crates/bevy_asset_preview/tests/common/mod.rs diff --git a/crates/bevy_asset_preview/Cargo.toml b/crates/bevy_asset_preview/Cargo.toml index e6344117..99901e55 100644 --- a/crates/bevy_asset_preview/Cargo.toml +++ b/crates/bevy_asset_preview/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" [dependencies] bevy = { workspace = true, features = ["webp"] } -image = { version = "0.25", features = ["webp"] } +image = "0.25" [dev-dependencies] tempfile = "3" diff --git a/crates/bevy_asset_preview/src/asset/saver.rs b/crates/bevy_asset_preview/src/asset/saver.rs index c71e50af..0f2dbff3 100644 --- a/crates/bevy_asset_preview/src/asset/saver.rs +++ b/crates/bevy_asset_preview/src/asset/saver.rs @@ -3,7 +3,7 @@ use std::io::Cursor; use bevy::{ asset::{AssetPath, io::ErasedAssetWriter}, ecs::event::{BufferedEvent, Event}, - image::Image, + image::{Image, ImageFormat}, platform::collections::HashMap, prelude::{ Assets, Commands, Component, Entity, EventReader, EventWriter, Handle, Query, ResMut, @@ -105,7 +105,10 @@ pub fn save_image<'a>( // Encode PNG directly to memory let mut cursor = Cursor::new(Vec::new()); - match rgba_image.write_to(&mut cursor, image::ImageFormat::WebP) { + match rgba_image.write_to( + &mut cursor, + ImageFormat::WebP.as_image_crate_format().unwrap(), // unwrap is safe because we enable bevy webp feature + ) { Ok(_) => { let webp_bytes = cursor.into_inner(); // Write via AssetWriter (atomic operation) diff --git a/crates/bevy_asset_preview/src/preview/mod.rs b/crates/bevy_asset_preview/src/preview/mod.rs index d48e3182..47378576 100644 --- a/crates/bevy_asset_preview/src/preview/mod.rs +++ b/crates/bevy_asset_preview/src/preview/mod.rs @@ -12,8 +12,8 @@ pub use systems::{ const MAX_PREVIEW_SIZE: u32 = 256; /// Resizes an image to preview size if it's larger than the maximum. -/// Returns a new compressed image, or None if the image is already small enough. -pub fn compress_image_for_preview(image: &Image) -> Option { +/// Returns a new resized image, or None if the image is already small enough. +pub fn resize_image_for_preview(image: &Image) -> Option { let width = image.width(); let height = image.height(); diff --git a/crates/bevy_asset_preview/src/preview/systems.rs b/crates/bevy_asset_preview/src/preview/systems.rs index 3cbaa0df..a848e398 100644 --- a/crates/bevy_asset_preview/src/preview/systems.rs +++ b/crates/bevy_asset_preview/src/preview/systems.rs @@ -6,7 +6,7 @@ use bevy::{ prelude::*, }; -use crate::preview::{PreviewCache, compress_image_for_preview}; +use crate::preview::{PreviewCache, resize_image_for_preview}; /// Event emitted when a preview is ready. #[derive(Event, BufferedEvent, Debug, Clone)] @@ -114,7 +114,7 @@ pub fn request_image_preview<'a>( // Check if image is already loaded if let Some(image) = images.get(&handle) { // Image is already loaded, compress if needed and cache - let preview_image = if let Some(compressed) = compress_image_for_preview(image) { + let preview_image = if let Some(compressed) = resize_image_for_preview(image) { images_mut.add(compressed) } else { handle.clone() @@ -153,17 +153,19 @@ pub fn handle_image_preview_events( requests: Query<(Entity, &PendingPreviewRequest)>, mut task_manager: ResMut, mut ready_events: EventReader, + time: Res>, ) { // Cache previews from ready events for event in ready_events.read() { // Cache the preview if not already cached if cache.get_by_path(&event.path).is_none() { let asset_id = event.image_handle.id(); - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - cache.insert(&event.path, asset_id, event.image_handle.clone(), timestamp); + cache.insert( + &event.path, + asset_id, + event.image_handle.clone(), + time.elapsed().as_secs(), + ); } } for event in asset_events.read() { @@ -176,7 +178,7 @@ pub fn handle_image_preview_events( if let Some(image) = images.get(&handle) { // Compress if needed let preview_image = - if let Some(compressed) = compress_image_for_preview(image) { + if let Some(compressed) = resize_image_for_preview(image) { images.add(compressed) } else { handle.clone() @@ -184,15 +186,11 @@ pub fn handle_image_preview_events( // Cache the preview let preview_id = preview_image.id(); - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); cache.insert( &request.path, preview_id, preview_image.clone(), - timestamp, + time.elapsed().as_secs(), ); // Send ready event diff --git a/crates/bevy_asset_preview/src/ui/mod.rs b/crates/bevy_asset_preview/src/ui/mod.rs index 8570163c..c96be3c1 100644 --- a/crates/bevy_asset_preview/src/ui/mod.rs +++ b/crates/bevy_asset_preview/src/ui/mod.rs @@ -8,7 +8,7 @@ use bevy::{ use crate::{ asset::{AssetLoader, LoadPriority}, - preview::{PreviewCache, compress_image_for_preview}, + preview::{PreviewCache, resize_image_for_preview}, }; #[derive(Component, Deref)] @@ -116,6 +116,7 @@ pub fn handle_preview_load_completed( mut load_completed_events: EventReader, pending_query: Query<(Entity, &PendingPreviewLoad)>, mut image_node_query: Query<&mut ImageNode>, + time: Res>, ) { for event in load_completed_events.read() { // Find entities waiting for this task @@ -124,8 +125,7 @@ pub fn handle_preview_load_completed( // Check if image is loaded if let Some(image) = images.get(&event.handle) { // Compress if needed - let preview_image = if let Some(compressed) = compress_image_for_preview(image) - { + let preview_image = if let Some(compressed) = resize_image_for_preview(image) { images.add(compressed) } else { event.handle.clone() @@ -133,15 +133,11 @@ pub fn handle_preview_load_completed( // Cache the preview let preview_id = preview_image.id(); - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); cache.insert( &pending.asset_path, preview_id, preview_image.clone(), - timestamp, + time.elapsed().as_secs(), ); // Update ImageNode diff --git a/crates/bevy_asset_preview/tests/asset_loader_test.rs b/crates/bevy_asset_preview/tests/asset_test.rs similarity index 95% rename from crates/bevy_asset_preview/tests/asset_loader_test.rs rename to crates/bevy_asset_preview/tests/asset_test.rs index 283a8643..8d4c728e 100644 --- a/crates/bevy_asset_preview/tests/asset_loader_test.rs +++ b/crates/bevy_asset_preview/tests/asset_test.rs @@ -1,3 +1,5 @@ +mod common; + use std::collections::HashMap; use std::fs; use std::path::PathBuf; @@ -6,44 +8,15 @@ use bevy::{ asset::{AssetPath, AssetPlugin, io::file::FileAssetWriter}, image::{CompressedImageFormats, ImageLoader}, prelude::*, - render::{ - render_asset::RenderAssetUsages, - render_resource::{Extent3d, TextureDimension, TextureFormat}, - }, }; use bevy_asset_preview::{ ActiveSaveTask, AssetHotReloaded, AssetLoadCompleted, AssetLoadFailed, AssetLoader, LoadPriority, SaveCompleted, SaveTaskTracker, handle_asset_events, monitor_save_completion, process_load_queue, save_image, }; +use common::create_test_image; use tempfile::TempDir; -/// Helper function to create a test image with a specific color. -fn create_test_image( - images: &mut Assets, - width: u32, - height: u32, - color: [u8; 4], -) -> Handle { - let pixel_data: Vec = (0..(width * height)) - .flat_map(|_| color.iter().copied()) - .collect(); - - let image = Image::new_fill( - Extent3d { - width, - height, - depth_or_array_layers: 1, - }, - TextureDimension::D2, - &pixel_data, - TextureFormat::Rgba8UnormSrgb, - RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD, - ); - - images.add(image) -} - pub(crate) fn get_base_path() -> PathBuf { if let Ok(manifest_dir) = std::env::var("BEVY_ASSET_ROOT") { PathBuf::from(manifest_dir) diff --git a/crates/bevy_asset_preview/tests/common/mod.rs b/crates/bevy_asset_preview/tests/common/mod.rs new file mode 100644 index 00000000..5e242970 --- /dev/null +++ b/crates/bevy_asset_preview/tests/common/mod.rs @@ -0,0 +1,64 @@ +use bevy::{ + prelude::*, + render::{ + render_asset::RenderAssetUsages, + render_resource::{Extent3d, TextureDimension, TextureFormat}, + }, +}; + +/// Helper function to create a test image with a specific color. +pub fn create_test_image( + images: &mut Assets, + width: u32, + height: u32, + color: [u8; 4], +) -> Handle { + let pixel_data: Vec = (0..(width * height)) + .flat_map(|_| color.iter().copied()) + .collect(); + + let image = Image::new_fill( + Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + &pixel_data, + TextureFormat::Rgba8UnormSrgb, + RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD, + ); + + images.add(image) +} + +/// Helper function to save an image to disk, handling format-specific requirements. +pub fn save_test_image( + image: &Image, + path: &std::path::Path, +) -> Result<(), Box> { + let dynamic_image = image + .clone() + .try_into_dynamic() + .map_err(|e| format!("Failed to convert image: {:?}", e))?; + + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("png") + .to_lowercase(); + + match ext.as_str() { + "jpg" | "jpeg" => { + // JPEG doesn't support transparency, convert to RGB + let rgb_image = dynamic_image.into_rgb8(); + rgb_image.save(path)?; + } + _ => { + // PNG and other formats support RGBA + let rgba_image = dynamic_image.into_rgba8(); + rgba_image.save(path)?; + } + } + Ok(()) +} diff --git a/crates/bevy_asset_preview/tests/preview_test.rs b/crates/bevy_asset_preview/tests/preview_test.rs index a8bf4d71..5fc2fbb8 100644 --- a/crates/bevy_asset_preview/tests/preview_test.rs +++ b/crates/bevy_asset_preview/tests/preview_test.rs @@ -1,3 +1,5 @@ +mod common; + use std::fs; use std::path::PathBuf; @@ -5,74 +7,14 @@ use bevy::{ asset::{AssetPath, AssetPlugin}, image::{CompressedImageFormats, ImageLoader}, prelude::*, - render::{ - render_asset::RenderAssetUsages, - render_resource::{Extent3d, TextureDimension, TextureFormat}, - }, }; use bevy_asset_preview::{ AssetLoadCompleted, AssetLoadFailed, AssetLoader, AssetPreviewPlugin, PreviewAsset, PreviewCache, is_image_file, }; +use common::{create_test_image, save_test_image}; use tempfile::TempDir; -/// Helper function to create a test image with a specific color. -fn create_test_image( - images: &mut Assets, - width: u32, - height: u32, - color: [u8; 4], -) -> Handle { - let pixel_data: Vec = (0..(width * height)) - .flat_map(|_| color.iter().copied()) - .collect(); - - let image = Image::new_fill( - Extent3d { - width, - height, - depth_or_array_layers: 1, - }, - TextureDimension::D2, - &pixel_data, - TextureFormat::Rgba8UnormSrgb, - RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD, - ); - - images.add(image) -} - -/// Helper function to save an image to disk, handling format-specific requirements. -fn save_test_image( - image: &Image, - path: &std::path::Path, -) -> Result<(), Box> { - let dynamic_image = image - .clone() - .try_into_dynamic() - .map_err(|e| format!("Failed to convert image: {:?}", e))?; - - let ext = path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("png") - .to_lowercase(); - - match ext.as_str() { - "jpg" | "jpeg" => { - // JPEG doesn't support transparency, convert to RGB - let rgb_image = dynamic_image.into_rgb8(); - rgb_image.save(path)?; - } - _ => { - // PNG and other formats support RGBA - let rgba_image = dynamic_image.into_rgba8(); - rgba_image.save(path)?; - } - } - Ok(()) -} - /// Test image file detection function. #[test] fn test_is_image_file() { From 613f232649a849c2b99a922f1deefca99292a8ba Mon Sep 17 00:00:00 2001 From: Sieluna Date: Thu, 8 Jan 2026 02:07:22 +0200 Subject: [PATCH 5/8] Optimize and refactor tests --- crates/bevy_asset_preview/src/asset/loader.rs | 42 +- crates/bevy_asset_preview/src/lib.rs | 7 +- .../bevy_asset_preview/src/preview/cache.rs | 150 +++- crates/bevy_asset_preview/src/preview/mod.rs | 119 +++ .../bevy_asset_preview/src/preview/systems.rs | 4 +- crates/bevy_asset_preview/src/ui/mod.rs | 86 +- crates/bevy_asset_preview/tests/asset_test.rs | 461 ----------- crates/bevy_asset_preview/tests/common/mod.rs | 64 -- .../bevy_asset_preview/tests/preview_test.rs | 702 ---------------- .../bevy_asset_preview/tests/workflow_test.rs | 759 ++++++++++++++++++ 10 files changed, 1160 insertions(+), 1234 deletions(-) delete mode 100644 crates/bevy_asset_preview/tests/asset_test.rs delete mode 100644 crates/bevy_asset_preview/tests/common/mod.rs delete mode 100644 crates/bevy_asset_preview/tests/preview_test.rs create mode 100644 crates/bevy_asset_preview/tests/workflow_test.rs diff --git a/crates/bevy_asset_preview/src/asset/loader.rs b/crates/bevy_asset_preview/src/asset/loader.rs index 91d558b2..64cde2fb 100644 --- a/crates/bevy_asset_preview/src/asset/loader.rs +++ b/crates/bevy_asset_preview/src/asset/loader.rs @@ -356,9 +356,19 @@ mod tests { let mut loader = AssetLoader::new(4); let task_id = loader.submit("test.png", LoadPriority::CurrentAccess); + // Verify task path is stored + assert_eq!( + loader.get_task_path(task_id), + Some(&AssetPath::from("test.png")) + ); + + // Use a mock handle ID for testing + // Note: In a real scenario, we'd use actual asset handles, but for unit testing + // we can use default values to test the mapping logic let handle_id: AssetId = AssetId::default(); let entity = Entity::PLACEHOLDER; - loader.register_task(task_id, entity, Handle::::default()); + let handle = Handle::::default(); + loader.register_task(task_id, entity, handle); assert_eq!(loader.get_entity_by_handle(handle_id), Some(entity)); assert_eq!(loader.get_handle_id_by_task(task_id), Some(handle_id)); @@ -367,5 +377,35 @@ mod tests { assert_eq!(loader.get_entity_by_handle(handle_id), None); assert_eq!(loader.get_handle_id_by_task(task_id), None); + // Verify task path is also removed + assert_eq!(loader.get_task_path(task_id), None); + } + + #[test] + fn test_get_task_path() { + let mut loader = AssetLoader::new(4); + + // Test with non-existent task + assert_eq!(loader.get_task_path(999), None); + + // Test with existing task + let task_id = loader.submit("test.png", LoadPriority::Preload); + assert_eq!( + loader.get_task_path(task_id), + Some(&AssetPath::from("test.png")) + ); + + // Test with multiple tasks + let task_id2 = loader.submit("test2.png", LoadPriority::CurrentAccess); + assert_eq!( + loader.get_task_path(task_id2), + Some(&AssetPath::from("test2.png")) + ); + + // Verify both paths are stored + assert_eq!( + loader.get_task_path(task_id), + Some(&AssetPath::from("test.png")) + ); } } diff --git a/crates/bevy_asset_preview/src/lib.rs b/crates/bevy_asset_preview/src/lib.rs index 0c5eb65e..e82a8937 100644 --- a/crates/bevy_asset_preview/src/lib.rs +++ b/crates/bevy_asset_preview/src/lib.rs @@ -49,7 +49,12 @@ impl Plugin for AssetPreviewPlugin { // Handle preview load completion and update UI app.add_systems( Update, - ui::handle_preview_load_completed.after(asset::handle_asset_events), + ( + ui::handle_preview_load_completed, + ui::handle_preview_load_failed, + ui::check_failed_loads, + ) + .after(asset::handle_asset_events), ); } } diff --git a/crates/bevy_asset_preview/src/preview/cache.rs b/crates/bevy_asset_preview/src/preview/cache.rs index 0981e833..b850a941 100644 --- a/crates/bevy_asset_preview/src/preview/cache.rs +++ b/crates/bevy_asset_preview/src/preview/cache.rs @@ -1,3 +1,5 @@ +use core::time::Duration; + use bevy::{ asset::{AssetId, AssetPath}, image::Image, @@ -13,7 +15,7 @@ pub struct PreviewCacheEntry { /// The asset ID that this preview is for. pub asset_id: AssetId, /// Timestamp when the preview was generated (for cache invalidation). - pub timestamp: u64, + pub timestamp: Duration, } /// Cache for preview images to avoid re-rendering unchanged assets. @@ -52,7 +54,7 @@ impl PreviewCache { path: impl Into>, asset_id: AssetId, image_handle: Handle, - timestamp: u64, + timestamp: Duration, ) { let path: AssetPath<'static> = path.into().into_owned(); let entry = PreviewCacheEntry { @@ -103,3 +105,147 @@ impl PreviewCache { self.path_cache.is_empty() } } + +#[cfg(test)] +mod tests { + use std::fs; + + use bevy::{ + asset::AssetPlugin, + image::Image, + prelude::*, + render::{ + render_asset::RenderAssetUsages, + render_resource::{Extent3d, TextureDimension, TextureFormat}, + }, + }; + use tempfile::TempDir; + + use super::*; + + fn create_test_image( + images: &mut Assets, + width: u32, + height: u32, + color: [u8; 4], + ) -> Handle { + let pixel_data: Vec = (0..(width * height)) + .flat_map(|_| color.iter().copied()) + .collect(); + + let image = Image::new_fill( + Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + &pixel_data, + TextureFormat::Rgba8UnormSrgb, + RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD, + ); + + images.add(image) + } + + #[test] + fn test_preview_cache_operations() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let assets_dir = temp_dir.path().join("assets"); + fs::create_dir_all(&assets_dir).expect("Failed to create assets directory"); + + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(AssetPlugin { + file_path: assets_dir.display().to_string(), + ..Default::default() + }) + .init_resource::() + .init_asset::(); + + // Create test images + let (handle1, handle2, asset_id1, asset_id2) = + app.world_mut() + .resource_scope(|_world, mut images: Mut>| { + let h1 = create_test_image(&mut images, 64, 64, [255, 0, 0, 255]); + let h2 = create_test_image(&mut images, 64, 64, [0, 255, 0, 255]); + let id1 = h1.id(); + let id2 = h2.id(); + (h1, h2, id1, id2) + }); + + let path1: AssetPath<'static> = "test1.png".into(); + let path2: AssetPath<'static> = "test2.png".into(); + + let mut cache = app.world_mut().resource_mut::(); + + // Test insertion and query by path + cache.insert(&path1, asset_id1, handle1.clone(), Duration::from_secs(100)); + cache.insert(&path2, asset_id2, handle2.clone(), Duration::from_secs(200)); + + assert_eq!(cache.len(), 2, "Cache should contain 2 entries"); + assert!(!cache.is_empty(), "Cache should not be empty"); + + let entry1 = cache.get_by_path(&path1).unwrap(); + assert_eq!(entry1.asset_id, asset_id1, "Entry should match asset ID"); + assert_eq!( + entry1.timestamp, + Duration::from_secs(100), + "Entry should match timestamp" + ); + + // Test query by ID + let entry1_by_id = cache.get_by_id(asset_id1).unwrap(); + assert_eq!( + entry1_by_id.image_handle.id(), + handle1.id(), + "Entry by ID should match" + ); + + // Test consistency between path and ID queries + let entry2_by_path = cache.get_by_path(&path2).unwrap(); + let entry2_by_id = cache.get_by_id(asset_id2).unwrap(); + assert_eq!( + entry2_by_path.image_handle.id(), + entry2_by_id.image_handle.id(), + "Path and ID queries should return same entry" + ); + + // Test removal by path + let removed = cache.remove_by_path(&path1).unwrap(); + assert_eq!(removed.asset_id, asset_id1, "Removed entry should match"); + assert_eq!(cache.len(), 1, "Cache should have 1 entry after removal"); + assert!( + cache.get_by_path(&path1).is_none(), + "Path should be removed" + ); + assert!(cache.get_by_id(asset_id1).is_none(), "ID should be removed"); + + // Test removal by ID + let removed2 = cache.remove_by_id(asset_id2).unwrap(); + assert_eq!(removed2.asset_id, asset_id2, "Removed entry should match"); + assert_eq!(cache.len(), 0, "Cache should be empty"); + assert!(cache.is_empty(), "Cache should be empty"); + + // Test duplicate insertion (should overwrite) + let handle1_clone = handle1.clone(); + let handle1_clone2 = handle1.clone(); + cache.insert(&path1, asset_id1, handle1_clone, Duration::from_secs(100)); + cache.insert(&path1, asset_id1, handle1_clone2, Duration::from_secs(300)); // Overwrite + assert_eq!( + cache.len(), + 1, + "Cache should have 1 entry after duplicate insert" + ); + assert_eq!( + cache.get_by_path(&path1).unwrap().timestamp, + Duration::from_secs(300), + "Entry should be updated" + ); + + // Test clearing + cache.clear(); + assert_eq!(cache.len(), 0, "Cache should be empty after clear"); + assert!(cache.is_empty(), "Cache should be empty after clear"); + } +} diff --git a/crates/bevy_asset_preview/src/preview/mod.rs b/crates/bevy_asset_preview/src/preview/mod.rs index 47378576..44d93cd1 100644 --- a/crates/bevy_asset_preview/src/preview/mod.rs +++ b/crates/bevy_asset_preview/src/preview/mod.rs @@ -52,3 +52,122 @@ pub fn resize_image_for_preview(image: &Image) -> Option { RenderAssetUsages::RENDER_WORLD, )) } + +#[cfg(test)] +mod tests { + use std::fs; + + use bevy::{ + asset::AssetPlugin, + prelude::*, + render::{ + render_asset::RenderAssetUsages, + render_resource::{Extent3d, TextureDimension, TextureFormat}, + }, + }; + use tempfile::TempDir; + + use super::*; + + fn create_test_image( + images: &mut Assets, + width: u32, + height: u32, + color: [u8; 4], + ) -> Handle { + let pixel_data: Vec = (0..(width * height)) + .flat_map(|_| color.iter().copied()) + .collect(); + + let image = Image::new_fill( + Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + &pixel_data, + TextureFormat::Rgba8UnormSrgb, + RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD, + ); + + images.add(image) + } + + #[test] + fn test_image_compression() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let assets_dir = temp_dir.path().join("assets"); + fs::create_dir_all(&assets_dir).expect("Failed to create assets directory"); + + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(AssetPlugin { + file_path: assets_dir.display().to_string(), + ..Default::default() + }) + .init_asset::(); + + let mut images = app.world_mut().resource_mut::>(); + + // Test small image (should not be compressed) + let small_handle = create_test_image(&mut images, 64, 64, [128, 128, 128, 255]); + let small_image = images.get(&small_handle).unwrap(); + let compressed_small = resize_image_for_preview(small_image); + assert!( + compressed_small.is_none(), + "Small image should not be compressed" + ); + + // Test large image (should be compressed) + let large_handle = create_test_image(&mut images, 512, 512, [128, 128, 128, 255]); + let large_image = images.get(&large_handle).unwrap(); + let compressed_large = resize_image_for_preview(large_image); + assert!( + compressed_large.is_some(), + "Large image should be compressed" + ); + let compressed = compressed_large.unwrap(); + assert!( + compressed.width() <= 256 && compressed.height() <= 256, + "Compressed image should be <= 256x256, got {}x{}", + compressed.width(), + compressed.height() + ); + + // Test wide image (maintain aspect ratio) + let wide_handle = create_test_image(&mut images, 800, 200, [128, 128, 128, 255]); + let wide_image = images.get(&wide_handle).unwrap(); + let compressed_wide = resize_image_for_preview(wide_image); + assert!(compressed_wide.is_some(), "Wide image should be compressed"); + let compressed = compressed_wide.unwrap(); + assert_eq!(compressed.width(), 256, "Wide image width should be 256"); + assert!( + compressed.height() < 256, + "Wide image height should be < 256" + ); + // Verify aspect ratio: 800/200 = 4:1, after compression should be 256:64 + let expected_height = (200.0 * 256.0 / 800.0) as u32; + assert_eq!( + compressed.height(), + expected_height, + "Wide image should maintain aspect ratio" + ); + + // Test tall image (maintain aspect ratio) + let tall_handle = create_test_image(&mut images, 200, 800, [128, 128, 128, 255]); + let tall_image = images.get(&tall_handle).unwrap(); + let compressed_tall = resize_image_for_preview(tall_image); + assert!(compressed_tall.is_some(), "Tall image should be compressed"); + let compressed = compressed_tall.unwrap(); + assert_eq!(compressed.height(), 256, "Tall image height should be 256"); + assert!(compressed.width() < 256, "Tall image width should be < 256"); + // Verify aspect ratio: 200/800 = 1:4, after compression should be 64:256 + let expected_width = (200.0 * 256.0 / 800.0) as u32; + assert_eq!( + compressed.width(), + expected_width, + "Tall image should maintain aspect ratio" + ); + } +} diff --git a/crates/bevy_asset_preview/src/preview/systems.rs b/crates/bevy_asset_preview/src/preview/systems.rs index a848e398..03e5ee58 100644 --- a/crates/bevy_asset_preview/src/preview/systems.rs +++ b/crates/bevy_asset_preview/src/preview/systems.rs @@ -164,7 +164,7 @@ pub fn handle_image_preview_events( &event.path, asset_id, event.image_handle.clone(), - time.elapsed().as_secs(), + time.elapsed(), ); } } @@ -190,7 +190,7 @@ pub fn handle_image_preview_events( &request.path, preview_id, preview_image.clone(), - time.elapsed().as_secs(), + time.elapsed(), ); // Send ready event diff --git a/crates/bevy_asset_preview/src/ui/mod.rs b/crates/bevy_asset_preview/src/ui/mod.rs index c96be3c1..d74ec215 100644 --- a/crates/bevy_asset_preview/src/ui/mod.rs +++ b/crates/bevy_asset_preview/src/ui/mod.rs @@ -137,7 +137,7 @@ pub fn handle_preview_load_completed( &pending.asset_path, preview_id, preview_image.clone(), - time.elapsed().as_secs(), + time.elapsed(), ); // Update ImageNode @@ -154,3 +154,87 @@ pub fn handle_preview_load_completed( } } } + +/// System that handles failed asset loads and cleans up pending requests. +pub fn handle_preview_load_failed( + mut commands: Commands, + mut load_failed_events: EventReader, + pending_query: Query<(Entity, &PendingPreviewLoad)>, +) { + for event in load_failed_events.read() { + // Find entities waiting for this task + for (entity, pending) in pending_query.iter() { + if pending.task_id == event.task_id { + // Cleanup - remove PendingPreviewLoad, keep placeholder image + commands.entity(entity).remove::(); + commands.entity(entity).remove::(); + break; + } + } + } +} + +/// System that periodically checks for failed asset loads and cleans them up. +/// This is a fallback for cases where AssetEvent::Removed is not emitted. +/// It checks both active tasks and directly loads the asset to check its state. +pub fn check_failed_loads( + mut commands: Commands, + mut loader: ResMut, + asset_server: Res, + pending_query: Query<(Entity, &PendingPreviewLoad)>, + task_query: Query<(Entity, &crate::asset::ActiveLoadTask)>, +) { + use bevy::asset::LoadState; + + for (entity, pending) in pending_query.iter() { + // Try to find the active task for this pending load to get the correct handle + let mut active_task_entity_opt = None; + let mut handle_opt = None; + for (task_entity, active_task) in task_query.iter() { + if active_task.task_id == pending.task_id { + handle_opt = Some(active_task.handle.clone()); + active_task_entity_opt = Some((task_entity, active_task)); + break; + } + } + + // Get handle - use active task's handle if available, otherwise load directly + // Note: asset_server.load() is idempotent - calling it multiple times returns the same handle + let handle: Handle = + handle_opt.unwrap_or_else(|| asset_server.load(&pending.asset_path)); + let load_state = asset_server.load_state(&handle); + + // Check if the asset load has failed + if let LoadState::Failed(_) = load_state { + // Load failed - cleanup PendingPreviewLoad + commands.entity(entity).remove::(); + commands.entity(entity).remove::(); + + // Also cleanup ActiveLoadTask if it exists + if let Some((task_entity, active_task)) = active_task_entity_opt { + loader.finish_task(); + loader.cleanup_task(active_task.task_id, active_task.handle.id()); + commands.entity(task_entity).despawn(); + } + } + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + #[test] + fn test_is_image_file() { + assert!(is_image_file(PathBuf::from("test.png").as_path())); + assert!(is_image_file(PathBuf::from("test.jpg").as_path())); + assert!(is_image_file(PathBuf::from("test.webp").as_path())); + assert!(is_image_file(PathBuf::from("test.TGA").as_path())); // Case insensitive + + assert!(!is_image_file(PathBuf::from("test.txt").as_path())); + assert!(!is_image_file(PathBuf::from("test.rs").as_path())); + assert!(!is_image_file(PathBuf::from("test").as_path())); // No extension + } +} diff --git a/crates/bevy_asset_preview/tests/asset_test.rs b/crates/bevy_asset_preview/tests/asset_test.rs deleted file mode 100644 index 8d4c728e..00000000 --- a/crates/bevy_asset_preview/tests/asset_test.rs +++ /dev/null @@ -1,461 +0,0 @@ -mod common; - -use std::collections::HashMap; -use std::fs; -use std::path::PathBuf; - -use bevy::{ - asset::{AssetPath, AssetPlugin, io::file::FileAssetWriter}, - image::{CompressedImageFormats, ImageLoader}, - prelude::*, -}; -use bevy_asset_preview::{ - ActiveSaveTask, AssetHotReloaded, AssetLoadCompleted, AssetLoadFailed, AssetLoader, - LoadPriority, SaveCompleted, SaveTaskTracker, handle_asset_events, monitor_save_completion, - process_load_queue, save_image, -}; -use common::create_test_image; -use tempfile::TempDir; - -pub(crate) fn get_base_path() -> PathBuf { - if let Ok(manifest_dir) = std::env::var("BEVY_ASSET_ROOT") { - PathBuf::from(manifest_dir) - } else if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") { - PathBuf::from(manifest_dir) - } else { - std::env::current_exe() - .map(|path| path.parent().map(ToOwned::to_owned).unwrap()) - .unwrap() - } -} - -/// Integration test: Complete file system workflow with batch operations. -#[test] -fn test_complete_filesystem_workflow() { - let temp_dir = TempDir::new().expect("Failed to create temp directory"); - let assets_dir = temp_dir.path().join("assets"); - let cache_dir = temp_dir.path().join("cache"); - fs::create_dir_all(&assets_dir).expect("Failed to create assets directory"); - fs::create_dir_all(&cache_dir).expect("Failed to create cache directory"); - - unsafe { - std::env::set_var( - "BEVY_ASSET_ROOT", - temp_dir - .path() - .to_str() - .expect("Failed to convert path to string"), - ); - } - - println!("BEVY_ASSET_ROOT: {}", get_base_path().display()); - - let mut app = App::new(); - - let cache_dir = temp_dir.path().join("cache").join("asset_preview"); - app.register_asset_source( - "thumbnail_cache", - bevy::asset::io::AssetSourceBuilder::platform_default( - cache_dir.to_str().expect("Cache dir path should be valid"), - None, - ), - ); - - app.add_plugins(MinimalPlugins) - .add_plugins(AssetPlugin { - file_path: assets_dir.display().to_string(), - ..Default::default() - }) - .init_asset::() - .register_asset_loader(ImageLoader::new(CompressedImageFormats::NONE)) - .add_event::() - .add_event::() - .add_event::() - .add_event::() - .init_resource::() - .init_resource::() - .add_systems( - Update, - ( - process_load_queue, - handle_asset_events, - monitor_save_completion, - ), - ); - - let test_images: Vec<(Handle, PathBuf, [u8; 4])> = { - let mut images = app.world_mut().resource_mut::>(); - vec![ - ( - create_test_image(&mut images, 64, 64, [255, 0, 0, 255]), - PathBuf::from("test_red.png"), - [255, 0, 0, 255], - ), - ( - create_test_image(&mut images, 64, 64, [0, 255, 0, 255]), - PathBuf::from("test_green.png"), - [0, 255, 0, 255], - ), - ( - create_test_image(&mut images, 64, 64, [0, 0, 255, 255]), - PathBuf::from("test_blue.png"), - [0, 0, 255, 255], - ), - ( - create_test_image(&mut images, 64, 64, [255, 255, 0, 255]), - PathBuf::from("test_yellow.png"), - [255, 255, 0, 255], - ), - ( - create_test_image(&mut images, 64, 64, [255, 0, 255, 255]), - PathBuf::from("test_magenta.png"), - [255, 0, 255, 255], - ), - ] - }; - - { - let save_tasks = - app.world_mut() - .resource_scope(|world, mut tracker: Mut| { - let images = world.get_resource::>().unwrap(); - - let mut tasks = Vec::new(); - - for (handle, path, _) in &test_images { - let writer = FileAssetWriter::new("", true); - let target_path = AssetPath::from_path_buf( - PathBuf::from("cache/asset_preview").join(path), - ) - .into_owned(); - let task = save_image(handle.clone(), target_path.clone(), images, writer); - let task_id = tracker.create_task_id(); - let path_asset: AssetPath<'static> = - AssetPath::from_path_buf(path.clone()).into_owned(); - tracker.register_pending(task_id, path_asset.clone()); - tasks.push((task_id, path_asset, target_path, task)); - } - - tasks - }); - - let mut commands = app.world_mut().commands(); - for (task_id, path, target_path, task) in save_tasks { - commands.spawn(ActiveSaveTask { - task_id, - path, - target_path, - task, - }); - } - } - - let mut save_completed_count = 0; - let mut save_failed_count = 0; - let mut max_iterations = 1000; - let mut processed_task_ids = std::collections::HashSet::new(); - - while save_completed_count < test_images.len() && max_iterations > 0 { - app.update(); - max_iterations -= 1; - - let save_events = app - .world() - .resource::>(); - let mut cursor = save_events.get_cursor(); - for event in cursor.read(save_events) { - if processed_task_ids.insert(event.task_id) { - match &event.result { - Ok(_) => { - save_completed_count += 1; - } - Err(e) => { - save_failed_count += 1; - eprintln!( - "Save task {} failed for {:?}: {}", - event.task_id, event.path, e - ); - } - } - } - } - } - - if save_failed_count > 0 { - panic!( - "{} save tasks failed. Completed: {}, Failed: {}", - save_failed_count, save_completed_count, save_failed_count - ); - } - - assert_eq!( - save_completed_count, - test_images.len(), - "All images should be saved successfully. Completed: {}, Expected: {}", - save_completed_count, - test_images.len() - ); - - std::thread::sleep(std::time::Duration::from_millis(100)); - - for (_, path, _) in &test_images { - let mut saved_path = temp_dir - .path() - .join("cache") - .join("asset_preview") - .join(path); - - saved_path.set_extension("webp"); - - let saved_path = saved_path - .canonicalize() - .unwrap_or_else(|_| saved_path.clone()); - - assert!( - saved_path.exists(), - "Saved file should exist: {:?}", - saved_path - ); - } - - for (_, path, _) in &test_images { - let mut src = temp_dir - .path() - .join("cache") - .join("asset_preview") - .join(path); - src.set_extension("webp"); - - let mut dst = assets_dir.join(path); - dst.set_extension("webp"); - if let Some(parent) = dst.parent() { - fs::create_dir_all(parent).expect("Failed to create assets subdirectory"); - } - fs::copy(&src, &dst).expect("Failed to copy file for loading test"); - } - - for (_, path, _) in &test_images { - let mut asset_file = assets_dir.join(path); - asset_file.set_extension("webp"); - assert!( - asset_file.exists(), - "Asset file should exist before loading: {:?}", - asset_file - ); - } - - let asset_paths: Vec<(bevy::asset::AssetPath<'static>, LoadPriority)> = vec![ - ("test_red.webp".into(), LoadPriority::Preload), - ("test_green.webp".into(), LoadPriority::HotReload), - ("test_blue.webp".into(), LoadPriority::CurrentAccess), - ("test_yellow.webp".into(), LoadPriority::CurrentAccess), - ("test_magenta.webp".into(), LoadPriority::HotReload), - ]; - - let mut loader = app.world_mut().resource_mut::(); - let mut load_task_ids = HashMap::new(); - - for (path, priority) in &asset_paths { - let task_id = loader.submit(path.clone(), *priority); - load_task_ids.insert(task_id, (path.clone(), *priority)); - } - - assert_eq!(loader.queue_len(), asset_paths.len()); - - let mut load_completed_count = 0; - let mut load_completed_paths = Vec::new(); - max_iterations = 1000; - let mut processed_load_task_ids = std::collections::HashSet::new(); - - while load_completed_count < asset_paths.len() && max_iterations > 0 { - app.update(); - max_iterations -= 1; - - let load_events = app - .world() - .resource::>(); - let mut cursor = load_events.get_cursor(); - for event in cursor.read(load_events) { - if processed_load_task_ids.insert(event.task_id) { - load_completed_count += 1; - load_completed_paths.push((event.path.clone(), event.priority)); - - if let Some((expected_path, expected_priority)) = load_task_ids.get(&event.task_id) - { - assert_eq!( - &event.path, expected_path, - "Loaded path should match submitted path" - ); - assert_eq!( - event.priority, *expected_priority, - "Loaded priority should match submitted priority" - ); - } - } - } - - let failed_events = app - .world() - .resource::>(); - let mut failed_cursor = failed_events.get_cursor(); - for event in failed_cursor.read(failed_events) { - eprintln!( - "Load failed: task_id={}, path={:?}, error={}", - event.task_id, event.path, event.error - ); - } - - if max_iterations % 100 == 0 { - let loader = app.world().resource::(); - let active_tasks = loader.active_tasks(); - let queue_len = loader.queue_len(); - eprintln!( - "Load progress: completed={}, active={}, queue={}, iterations={}", - load_completed_count, active_tasks, queue_len, max_iterations - ); - } - } - - assert_eq!( - load_completed_count, - asset_paths.len(), - "All images should be loaded successfully" - ); - - let current_access_indices: Vec = load_completed_paths - .iter() - .enumerate() - .filter_map(|(i, (_, p))| { - if *p == LoadPriority::CurrentAccess { - Some(i) - } else { - None - } - }) - .collect(); - - let hot_reload_indices: Vec = load_completed_paths - .iter() - .enumerate() - .filter_map(|(i, (_, p))| { - if *p == LoadPriority::HotReload { - Some(i) - } else { - None - } - }) - .collect(); - - let preload_indices: Vec = load_completed_paths - .iter() - .enumerate() - .filter_map(|(i, (_, p))| { - if *p == LoadPriority::Preload { - Some(i) - } else { - None - } - }) - .collect(); - - if let (Some(&max_current), Some(&min_preload)) = ( - current_access_indices.iter().max(), - preload_indices.iter().min(), - ) { - assert!( - max_current < min_preload, - "CurrentAccess tasks should complete before Preload tasks" - ); - } - - if let (Some(&max_hot_reload), Some(&min_preload)) = ( - hot_reload_indices.iter().max(), - preload_indices.iter().min(), - ) { - assert!( - max_hot_reload < min_preload, - "HotReload tasks should complete before Preload tasks" - ); - } -} - -/// Functional test: Test priority queue ordering. -#[test] -fn test_asset_loader_priority_queue() { - let mut app = App::new(); - app.init_resource::(); - - app.world_mut() - .resource_mut::() - .submit("preload1.png", LoadPriority::Preload); - app.world_mut() - .resource_mut::() - .submit("current1.png", LoadPriority::CurrentAccess); - app.world_mut() - .resource_mut::() - .submit("hotreload1.png", LoadPriority::HotReload); - app.world_mut() - .resource_mut::() - .submit("preload2.png", LoadPriority::Preload); - app.world_mut() - .resource_mut::() - .submit("current2.png", LoadPriority::CurrentAccess); - - let loader = app.world().resource::(); - assert_eq!(loader.queue_len(), 5); - - // Verify priority ordering: CurrentAccess > HotReload > Preload - let mut loader_mut = app.world_mut().resource_mut::(); - - let task1 = loader_mut.pop_next().unwrap(); - assert_eq!(task1.priority, LoadPriority::CurrentAccess); - assert_eq!(task1.path, "current1.png".into()); - - let task2 = loader_mut.pop_next().unwrap(); - assert_eq!(task2.priority, LoadPriority::CurrentAccess); - assert_eq!(task2.path, "current2.png".into()); - - let task3 = loader_mut.pop_next().unwrap(); - assert_eq!(task3.priority, LoadPriority::HotReload); - assert_eq!(task3.path, "hotreload1.png".into()); - - let task4 = loader_mut.pop_next().unwrap(); - assert_eq!(task4.priority, LoadPriority::Preload); - assert_eq!(task4.path, "preload1.png".into()); - - let task5 = loader_mut.pop_next().unwrap(); - assert_eq!(task5.priority, LoadPriority::Preload); - assert_eq!(task5.path, "preload2.png".into()); - - assert_eq!(loader_mut.queue_len(), 0); -} - -/// Functional test: Test concurrent control. -#[test] -fn test_asset_loader_concurrent_control() { - let mut app = App::new(); - app.init_resource::(); - - let mut loader = app.world_mut().resource_mut::(); - - // Default max concurrent is 4 - assert_eq!(loader.active_tasks(), 0); - assert!(loader.can_start_task()); - - // Start tasks up to max concurrent limit - loader.start_task(); - loader.start_task(); - loader.start_task(); - assert_eq!(loader.active_tasks(), 3); - assert!(loader.can_start_task()); - - // Reach max concurrent limit - loader.start_task(); - assert_eq!(loader.active_tasks(), 4); - assert!(!loader.can_start_task()); - - // Finish one task, should allow starting new tasks again - loader.finish_task(); - assert_eq!(loader.active_tasks(), 3); - assert!(loader.can_start_task()); -} diff --git a/crates/bevy_asset_preview/tests/common/mod.rs b/crates/bevy_asset_preview/tests/common/mod.rs deleted file mode 100644 index 5e242970..00000000 --- a/crates/bevy_asset_preview/tests/common/mod.rs +++ /dev/null @@ -1,64 +0,0 @@ -use bevy::{ - prelude::*, - render::{ - render_asset::RenderAssetUsages, - render_resource::{Extent3d, TextureDimension, TextureFormat}, - }, -}; - -/// Helper function to create a test image with a specific color. -pub fn create_test_image( - images: &mut Assets, - width: u32, - height: u32, - color: [u8; 4], -) -> Handle { - let pixel_data: Vec = (0..(width * height)) - .flat_map(|_| color.iter().copied()) - .collect(); - - let image = Image::new_fill( - Extent3d { - width, - height, - depth_or_array_layers: 1, - }, - TextureDimension::D2, - &pixel_data, - TextureFormat::Rgba8UnormSrgb, - RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD, - ); - - images.add(image) -} - -/// Helper function to save an image to disk, handling format-specific requirements. -pub fn save_test_image( - image: &Image, - path: &std::path::Path, -) -> Result<(), Box> { - let dynamic_image = image - .clone() - .try_into_dynamic() - .map_err(|e| format!("Failed to convert image: {:?}", e))?; - - let ext = path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("png") - .to_lowercase(); - - match ext.as_str() { - "jpg" | "jpeg" => { - // JPEG doesn't support transparency, convert to RGB - let rgb_image = dynamic_image.into_rgb8(); - rgb_image.save(path)?; - } - _ => { - // PNG and other formats support RGBA - let rgba_image = dynamic_image.into_rgba8(); - rgba_image.save(path)?; - } - } - Ok(()) -} diff --git a/crates/bevy_asset_preview/tests/preview_test.rs b/crates/bevy_asset_preview/tests/preview_test.rs deleted file mode 100644 index 5fc2fbb8..00000000 --- a/crates/bevy_asset_preview/tests/preview_test.rs +++ /dev/null @@ -1,702 +0,0 @@ -mod common; - -use std::fs; -use std::path::PathBuf; - -use bevy::{ - asset::{AssetPath, AssetPlugin}, - image::{CompressedImageFormats, ImageLoader}, - prelude::*, -}; -use bevy_asset_preview::{ - AssetLoadCompleted, AssetLoadFailed, AssetLoader, AssetPreviewPlugin, PreviewAsset, - PreviewCache, is_image_file, -}; -use common::{create_test_image, save_test_image}; -use tempfile::TempDir; - -/// Test image file detection function. -#[test] -fn test_is_image_file() { - assert!(is_image_file(PathBuf::from("test.png").as_path())); - assert!(is_image_file(PathBuf::from("test.jpg").as_path())); - assert!(is_image_file(PathBuf::from("test.jpeg").as_path())); - assert!(is_image_file(PathBuf::from("test.bmp").as_path())); - assert!(is_image_file(PathBuf::from("test.gif").as_path())); - assert!(is_image_file(PathBuf::from("test.webp").as_path())); - assert!(is_image_file(PathBuf::from("test.tga").as_path())); - assert!(is_image_file(PathBuf::from("test.TGA").as_path())); // Case insensitive - - assert!(!is_image_file(PathBuf::from("test.txt").as_path())); - assert!(!is_image_file(PathBuf::from("test.rs").as_path())); - assert!(!is_image_file(PathBuf::from("test").as_path())); // No extension -} - -/// Test complete preview workflow with image loading and compression. -#[test] -fn test_complete_preview_workflow() { - let temp_dir = TempDir::new().expect("Failed to create temp directory"); - let assets_dir = temp_dir.path().join("assets"); - fs::create_dir_all(&assets_dir).expect("Failed to create assets directory"); - - unsafe { - std::env::set_var( - "BEVY_ASSET_ROOT", - temp_dir - .path() - .to_str() - .expect("Failed to convert path to string"), - ); - } - - let mut app = App::new(); - app.add_plugins(MinimalPlugins) - .add_plugins(AssetPlugin { - file_path: assets_dir.display().to_string(), - ..Default::default() - }) - .add_plugins(AssetPreviewPlugin) - .init_asset::() - .register_asset_loader(ImageLoader::new(CompressedImageFormats::NONE)); - - // Create a large test image (512x512) that should be compressed - { - let mut images = app.world_mut().resource_mut::>(); - let handle = create_test_image(&mut images, 512, 512, [128, 128, 128, 255]); - let image = images.get(&handle).unwrap(); - - let path = assets_dir.join("test_large.png"); - save_test_image(image, &path).expect("Failed to save test image"); - } - - // Spawn entity with PreviewAsset - let entity = app - .world_mut() - .spawn(PreviewAsset(PathBuf::from("test_large.png"))) - .id(); - - // Run systems until preview is ready - let mut max_iterations = 1000; - let mut preview_ready = false; - while !preview_ready && max_iterations > 0 { - app.update(); - max_iterations -= 1; - - let world = app.world(); - // Check if PreviewAsset and PendingPreviewLoad are removed (preview is ready) - if !world.entity(entity).contains::() - && !world - .entity(entity) - .contains::() - { - preview_ready = true; - } - } - - assert!(preview_ready, "Preview should be ready after loading"); - - // Check that ImageNode was updated with preview - let world = app.world(); - assert!( - world.entity(entity).contains::(), - "ImageNode should be present after preview is ready" - ); - - // Check that preview was cached - let cache = app.world().resource::(); - let asset_path: AssetPath<'static> = "test_large.png".into(); - assert!( - cache.get_by_path(&asset_path).is_some(), - "Large image preview should be cached" - ); - - // Verify the cached preview is compressed (256x256 max) - let cache_entry = cache.get_by_path(&asset_path).unwrap(); - let images = app.world().resource::>(); - if let Some(preview_image) = images.get(&cache_entry.image_handle) { - assert!( - preview_image.width() <= 256 && preview_image.height() <= 256, - "Cached preview should be compressed to max 256x256, got {}x{}", - preview_image.width(), - preview_image.height() - ); - } -} - -/// Simulate the complete frontend workflow: DirectoryContent -> spawn_file_node -> PreviewAsset -> preview loading -#[test] -fn test_frontend_workflow_simulation() { - let temp_dir = TempDir::new().expect("Failed to create temp directory"); - let assets_dir = temp_dir.path().join("assets"); - fs::create_dir_all(&assets_dir).expect("Failed to create assets directory"); - - unsafe { - std::env::set_var( - "BEVY_ASSET_ROOT", - temp_dir - .path() - .to_str() - .expect("Failed to convert path to string"), - ); - } - - let mut app = App::new(); - app.add_plugins(MinimalPlugins) - .add_plugins(AssetPlugin { - file_path: assets_dir.display().to_string(), - ..Default::default() - }) - .add_plugins(AssetPreviewPlugin) - .init_asset::() - .register_asset_loader(ImageLoader::new(CompressedImageFormats::NONE)); - - // Step 1: Create test files (simulating files in a directory) - let test_files = vec![ - ("icon.png", [255, 0, 0, 255], true), // Image file - ("sprite.png", [0, 255, 0, 255], true), // Image file - ("readme.txt", [0, 0, 0, 0], false), // Non-image file - ("texture.png", [0, 0, 255, 255], true), // Image file - ]; - - { - let mut images = app.world_mut().resource_mut::>(); - for (filename, color, is_image) in &test_files { - if *is_image { - let handle = create_test_image(&mut images, 128, 128, *color); - let image = images.get(&handle).unwrap(); - - let path = assets_dir.join(filename); - save_test_image(image, &path).expect("Failed to save test image"); - } else { - // Create a text file - let path = assets_dir.join(filename); - fs::write(&path, "Test file content").expect("Failed to write test file"); - } - } - } - - // Step 2: Simulate DirectoryContent (like refresh_ui system would do) - // This simulates what happens when DirectoryContent changes and populate_directory_content is called - let mut file_entities = Vec::new(); - let location_path = PathBuf::from(""); // Root directory - - for (filename, _, _) in &test_files { - // Simulate spawn_file_node creating PreviewAsset - let file_entity = app - .world_mut() - .spawn(PreviewAsset(location_path.join(filename))) - .id(); - file_entities.push(( - file_entity, - filename, - is_image_file(&PathBuf::from(filename)), - )); - } - - // Step 3: Run preview_handler (simulates what happens in Update schedule) - app.update(); - - // Step 4: Verify initial state - let world = app.world(); - for (entity, filename, is_image) in &file_entities { - assert!( - world.entity(*entity).contains::(), - "ImageNode should be added for file: {}", - filename - ); - - if *is_image { - // Image files should have PendingPreviewLoad (submitted to loader) - assert!( - world - .entity(*entity) - .contains::(), - "Image file {} should have PendingPreviewLoad", - filename - ); - } else { - // Non-image files should have PreviewAsset removed (placeholder used) - assert!( - !world.entity(*entity).contains::(), - "Non-image file {} should have PreviewAsset removed", - filename - ); - } - } - - // Step 5: Wait for all image previews to load (simulate async loading) - let image_entities: Vec<_> = file_entities - .iter() - .filter(|(_, _, is_image)| *is_image) - .map(|(entity, filename, _)| (*entity, *filename)) - .collect(); - - let mut max_iterations = 3000; - while max_iterations > 0 { - app.update(); - max_iterations -= 1; - - // Check if all image previews are ready (no PreviewAsset and no PendingPreviewLoad) - let world = app.world(); - let all_ready = image_entities.iter().all(|(entity, _)| { - !world.entity(*entity).contains::() - && !world - .entity(*entity) - .contains::() - }); - - if all_ready { - break; - } - } - - // Verify all image previews are ready - let world = app.world(); - for (entity, filename) in &image_entities { - if world.entity(*entity).contains::() { - // Check if there are any load failed events - let failed_events = app - .world() - .resource::>(); - let mut cursor = failed_events.get_cursor(); - let failures: Vec<_> = cursor.read(failed_events).collect(); - if !failures.is_empty() { - panic!( - "Image {} failed to load. Failures: {:?}", - filename, failures - ); - } - } - assert!( - !world.entity(*entity).contains::(), - "PreviewAsset should be removed after loading for file: {}", - filename - ); - assert!( - !world - .entity(*entity) - .contains::(), - "PendingPreviewLoad should be removed after loading for file: {}", - filename - ); - } - - // Step 6: Verify final state - all previews should be ready - let world = app.world(); - for (entity, filename, is_image) in &file_entities { - if *is_image { - // Image files: PreviewAsset and PendingPreviewLoad should be removed, ImageNode should have preview - assert!( - !world.entity(*entity).contains::(), - "Image file {} should have PreviewAsset removed after loading", - filename - ); - assert!( - !world - .entity(*entity) - .contains::(), - "Image file {} should have PendingPreviewLoad removed after loading", - filename - ); - assert!( - world.entity(*entity).contains::(), - "Image file {} should still have ImageNode after loading", - filename - ); - } - } - - // Step 7: Verify cache was populated - let cache = app.world().resource::(); - for (_, filename, is_image) in &file_entities { - if *is_image { - let asset_path: AssetPath<'static> = filename.to_string().into(); - assert!( - cache.get_by_path(&asset_path).is_some(), - "Image file {} should be cached", - filename - ); - } - } -} - -/// Test batch preview loading with mixed priorities (simulating viewport scrolling) -#[test] -fn test_batch_preview_loading_with_priorities() { - let temp_dir = TempDir::new().expect("Failed to create temp directory"); - let assets_dir = temp_dir.path().join("assets"); - fs::create_dir_all(&assets_dir).expect("Failed to create assets directory"); - - unsafe { - std::env::set_var( - "BEVY_ASSET_ROOT", - temp_dir - .path() - .to_str() - .expect("Failed to convert path to string"), - ); - } - - let mut app = App::new(); - app.add_plugins(MinimalPlugins) - .add_plugins(AssetPlugin { - file_path: assets_dir.display().to_string(), - ..Default::default() - }) - .add_plugins(AssetPreviewPlugin) - .init_asset::() - .register_asset_loader(ImageLoader::new(CompressedImageFormats::NONE)); - - // Create many test images (simulating a folder with many files) - let num_images: usize = 20; - let mut test_files = Vec::new(); - - { - let mut images = app.world_mut().resource_mut::>(); - for i in 0..num_images { - let filename = format!("image_{:03}.png", i); - let color: [u8; 4] = [ - ((i * 13) % 256) as u8, - ((i * 17) % 256) as u8, - ((i * 19) % 256) as u8, - 255, - ]; - let handle = create_test_image(&mut images, 64, 64, color); - let image = images.get(&handle).unwrap(); - - let path = assets_dir.join(&filename); - save_test_image(image, &path).expect("Failed to save test image"); - test_files.push(filename); - } - } - - // Simulate frontend: spawn all file nodes at once (like opening a folder) - let mut file_entities = Vec::new(); - for filename in &test_files { - let entity = app - .world_mut() - .spawn(PreviewAsset(PathBuf::from(filename))) - .id(); - file_entities.push(entity); - } - - // Run preview_handler - should submit all to loader - app.update(); - - // Verify all were submitted - let loader = app.world().resource::(); - let initial_queue_size = loader.queue_len() + loader.active_tasks(); - assert!( - initial_queue_size > 0, - "Preview requests should be submitted to loader" - ); - - // Wait for all previews to load - let mut max_iterations = 5000; - let mut loaded_count = 0; - let mut processed_task_ids = std::collections::HashSet::new(); - let mut load_order = Vec::new(); - - while loaded_count < num_images && max_iterations > 0 { - app.update(); - max_iterations -= 1; - - let load_events = app - .world() - .resource::>(); - let mut cursor = load_events.get_cursor(); - for event in cursor.read(load_events) { - if processed_task_ids.insert(event.task_id) { - loaded_count += 1; - load_order.push((event.path.clone(), event.priority)); - } - } - } - - assert_eq!( - loaded_count, num_images, - "All images should be loaded. Loaded: {}, Expected: {}", - loaded_count, num_images - ); - - // Verify all entities have previews ready - let world = app.world(); - for entity in &file_entities { - assert!( - !world.entity(*entity).contains::(), - "PreviewAsset should be removed after loading" - ); - assert!( - !world - .entity(*entity) - .contains::(), - "PendingPreviewLoad should be removed after loading" - ); - assert!( - world.entity(*entity).contains::(), - "ImageNode should be present after loading" - ); - } - - // Verify cache contains all previews - let cache = app.world().resource::(); - assert_eq!( - cache.len(), - num_images, - "Cache should contain all {} previews", - num_images - ); -} - -/// Test cache hit scenario (simulating re-opening a folder or scrolling back) -#[test] -fn test_cache_hit_scenario() { - let temp_dir = TempDir::new().expect("Failed to create temp directory"); - let assets_dir = temp_dir.path().join("assets"); - fs::create_dir_all(&assets_dir).expect("Failed to create assets directory"); - - unsafe { - std::env::set_var( - "BEVY_ASSET_ROOT", - temp_dir - .path() - .to_str() - .expect("Failed to convert path to string"), - ); - } - - let mut app = App::new(); - app.add_plugins(MinimalPlugins) - .add_plugins(AssetPlugin { - file_path: assets_dir.display().to_string(), - ..Default::default() - }) - .add_plugins(AssetPreviewPlugin) - .init_asset::() - .register_asset_loader(ImageLoader::new(CompressedImageFormats::NONE)); - - // Create test image - { - let mut images = app.world_mut().resource_mut::>(); - let handle = create_test_image(&mut images, 128, 128, [255, 128, 64, 255]); - let image = images.get(&handle).unwrap(); - - let path = assets_dir.join("cached_image.png"); - save_test_image(image, &path).expect("Failed to save test image"); - } - - // First request - should load and cache - let _entity1 = app - .world_mut() - .spawn(PreviewAsset(PathBuf::from("cached_image.png"))) - .id(); - - // Wait for first load - let mut max_iterations = 1000; - let mut loaded = false; - while !loaded && max_iterations > 0 { - app.update(); - max_iterations -= 1; - - let load_events = app - .world() - .resource::>(); - let mut cursor = load_events.get_cursor(); - for _event in cursor.read(load_events) { - loaded = true; - } - } - - // Verify cache - let cache = app.world().resource::(); - let asset_path: AssetPath<'static> = "cached_image.png".into(); - assert!( - cache.get_by_path(&asset_path).is_some(), - "Preview should be cached after first load" - ); - - // Simulate re-opening folder or scrolling back - second request - let entity2 = app - .world_mut() - .spawn(PreviewAsset(PathBuf::from("cached_image.png"))) - .id(); - - // Run preview_handler - should use cache immediately - app.update(); - - // Verify cache hit: entity2 should immediately have ImageNode, no PendingPreviewLoad - let world = app.world(); - assert!( - !world.entity(entity2).contains::(), - "PreviewAsset should be removed immediately on cache hit" - ); - assert!( - !world - .entity(entity2) - .contains::(), - "PendingPreviewLoad should not be added on cache hit" - ); - assert!( - world.entity(entity2).contains::(), - "ImageNode should be added immediately on cache hit" - ); - - // Verify loader queue didn't grow (cache hit means no new load request) - // Note: queue might have other tasks, but we verify cache was used by checking entity state -} - -/// Test mixed file types in directory (images and non-images) -#[test] -fn test_mixed_file_types_in_directory() { - let temp_dir = TempDir::new().expect("Failed to create temp directory"); - let assets_dir = temp_dir.path().join("assets"); - fs::create_dir_all(&assets_dir).expect("Failed to create assets directory"); - - unsafe { - std::env::set_var( - "BEVY_ASSET_ROOT", - temp_dir - .path() - .to_str() - .expect("Failed to convert path to string"), - ); - } - - let mut app = App::new(); - app.add_plugins(MinimalPlugins) - .add_plugins(AssetPlugin { - file_path: assets_dir.display().to_string(), - ..Default::default() - }) - .add_plugins(AssetPreviewPlugin) - .init_asset::() - .register_asset_loader(ImageLoader::new(CompressedImageFormats::NONE)); - - // Create mixed files - let mixed_files = vec![ - ("sprite.png", true), - ("script.rs", false), - ("texture.png", true), - ("readme.md", false), - ("icon.png", true), - ("config.toml", false), - ]; - - { - let mut images = app.world_mut().resource_mut::>(); - for (filename, is_image) in &mixed_files { - if *is_image { - let handle = create_test_image(&mut images, 64, 64, [128, 128, 128, 255]); - let image = images.get(&handle).unwrap(); - - let path = assets_dir.join(filename); - save_test_image(image, &path).expect("Failed to save test image"); - } else { - let path = assets_dir.join(filename); - fs::write(&path, format!("Content of {}", filename)) - .expect("Failed to write test file"); - } - } - } - - // Simulate DirectoryContent with mixed files - let mut file_entities = Vec::new(); - for (filename, is_image) in &mixed_files { - let entity = app - .world_mut() - .spawn(PreviewAsset(PathBuf::from(filename))) - .id(); - file_entities.push((entity, filename, *is_image)); - } - - // Run preview_handler - app.update(); - - // Verify: images should have PendingPreviewLoad, non-images should have placeholder - let world = app.world(); - for (entity, filename, is_image) in &file_entities { - assert!( - world.entity(*entity).contains::(), - "All files should have ImageNode: {}", - filename - ); - - if *is_image { - assert!( - world - .entity(*entity) - .contains::(), - "Image file {} should have PendingPreviewLoad", - filename - ); - } else { - assert!( - !world.entity(*entity).contains::(), - "Non-image file {} should have PreviewAsset removed (placeholder used)", - filename - ); - assert!( - !world - .entity(*entity) - .contains::(), - "Non-image file {} should not have PendingPreviewLoad", - filename - ); - } - } - - // Wait for image previews to load - let image_entities: Vec<_> = file_entities - .iter() - .filter(|(_, _, is_image)| *is_image) - .map(|(entity, filename, _)| (*entity, *filename)) - .collect(); - - let mut max_iterations = 3000; - while max_iterations > 0 { - app.update(); - max_iterations -= 1; - - // Check if all image previews are ready - let world = app.world(); - let all_ready = image_entities.iter().all(|(entity, _)| { - !world.entity(*entity).contains::() - && !world - .entity(*entity) - .contains::() - }); - - if all_ready { - break; - } - } - - // Check for any load failures - let failed_events = app - .world() - .resource::>(); - let mut cursor = failed_events.get_cursor(); - let failures: Vec<_> = cursor.read(failed_events).collect(); - if !failures.is_empty() { - panic!("Some images failed to load: {:?}", failures); - } - - // Final verification - let world = app.world(); - for (entity, filename, is_image) in &file_entities { - if *is_image { - assert!( - !world.entity(*entity).contains::(), - "Image file {} should have PreviewAsset removed after loading", - filename - ); - assert!( - !world - .entity(*entity) - .contains::(), - "Image file {} should have PendingPreviewLoad removed after loading", - filename - ); - } - } -} diff --git a/crates/bevy_asset_preview/tests/workflow_test.rs b/crates/bevy_asset_preview/tests/workflow_test.rs new file mode 100644 index 00000000..8984381a --- /dev/null +++ b/crates/bevy_asset_preview/tests/workflow_test.rs @@ -0,0 +1,759 @@ +use std::fs; +use std::path::PathBuf; + +use bevy::{ + asset::{AssetPath, AssetPlugin, io::file::FileAssetWriter}, + image::{CompressedImageFormats, ImageLoader}, + prelude::*, + render::{ + render_asset::RenderAssetUsages, + render_resource::{Extent3d, TextureDimension, TextureFormat}, + }, +}; +use bevy_asset_preview::{ + ActiveSaveTask, AssetHotReloaded, AssetLoadCompleted, AssetLoadFailed, AssetLoader, + PreviewCache, SaveCompleted, SaveTaskTracker, monitor_save_completion, save_image, +}; +use tempfile::TempDir; + +// ========== Helper functions ========== + +fn create_test_image( + images: &mut Assets, + width: u32, + height: u32, + color: [u8; 4], +) -> Handle { + let pixel_data: Vec = (0..(width * height)) + .flat_map(|_| color.iter().copied()) + .collect(); + + let image = Image::new_fill( + Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + &pixel_data, + TextureFormat::Rgba8UnormSrgb, + RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD, + ); + + images.add(image) +} + +fn save_test_image( + image: &Image, + path: &std::path::Path, +) -> Result<(), Box> { + let dynamic_image = image + .clone() + .try_into_dynamic() + .map_err(|e| format!("Failed to convert image: {:?}", e))?; + + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("png") + .to_lowercase(); + + match ext.as_str() { + "jpg" | "jpeg" => { + let rgb_image = dynamic_image.into_rgb8(); + rgb_image.save(path)?; + } + _ => { + let rgba_image = dynamic_image.into_rgba8(); + rgba_image.save(path)?; + } + } + Ok(()) +} + +fn wait_for_save_completion( + app: &mut App, + expected_count: usize, + max_iterations: usize, +) -> Vec { + let mut save_completed_count = 0; + let mut processed_task_ids = std::collections::HashSet::new(); + let mut completed_events = Vec::new(); + let mut iterations = 0; + + while save_completed_count < expected_count && iterations < max_iterations { + app.update(); + iterations += 1; + + let world = app.world(); + let save_events = world.resource::>(); + let mut cursor = save_events.get_cursor(); + for event in cursor.read(save_events) { + if processed_task_ids.insert(event.task_id) { + match &event.result { + Ok(_) => { + save_completed_count += 1; + completed_events.push(event.clone()); + } + Err(e) => panic!("Save task {} failed: {}", event.task_id, e), + } + } + } + } + + assert_eq!( + save_completed_count, expected_count, + "All save tasks should complete" + ); + + completed_events +} + +fn wait_for_load_completion( + app: &mut App, + expected_count: usize, + max_iterations: usize, +) -> (Vec, usize, usize) { + let mut loaded_count = 0; + let mut processed_task_ids = std::collections::HashSet::new(); + let mut completed_events = Vec::new(); + let mut max_active_tasks = 0; + let mut initial_queue_len = 0; + let mut queue_len_checked = false; + let mut iterations = 0; + + while loaded_count < expected_count && iterations < max_iterations { + app.update(); + iterations += 1; + + let world = app.world(); + let loader = world.resource::(); + let active_tasks = loader.active_tasks(); + let queue_len = loader.queue_len(); + + if !queue_len_checked && queue_len > 0 { + initial_queue_len = queue_len; + queue_len_checked = true; + } + + if active_tasks > max_active_tasks { + max_active_tasks = active_tasks; + } + + assert!( + active_tasks <= 4, + "Active tasks should not exceed max_concurrent (4), got {}", + active_tasks + ); + + let load_events = world.resource::>(); + let mut cursor = load_events.get_cursor(); + for event in cursor.read(load_events) { + if processed_task_ids.insert(event.task_id) { + loaded_count += 1; + completed_events.push(event.clone()); + } + } + } + + (completed_events, max_active_tasks, initial_queue_len) +} + + +struct CleanupState { + pending_cleaned: bool, + active_task_cleaned: bool, + task_path_cleaned: bool, + iterations: usize, +} + +fn test_error_handling_for_nonexistent_file(app: &mut App) { + let non_existent_entity = app + .world_mut() + .spawn(bevy_asset_preview::PreviewAsset(PathBuf::from("non_existent.png"))) + .id(); + app.update(); + + let world = app.world(); + let pending_component = world + .entity(non_existent_entity) + .get::() + .expect("Non-existent file should have PendingPreviewLoad"); + let non_existent_task_id = pending_component.task_id; + + let loader = world.resource::(); + assert!( + loader.get_task_path(non_existent_task_id).is_some(), + "Task should be in queue after submission" + ); + + let cleanup_state = wait_for_failure_cleanup(app, non_existent_entity, non_existent_task_id); + verify_failure_cleanup_complete(app, non_existent_entity, non_existent_task_id, &cleanup_state); +} + +fn wait_for_failure_cleanup( + app: &mut App, + entity: Entity, + task_id: u64, +) -> CleanupState { + let mut iterations = 0; + const MAX_ITERATIONS: usize = 2000; + + let mut pending_cleaned = false; + let mut active_task_cleaned = false; + let mut task_path_cleaned = false; + + let mut active_task_query = app.world_mut().query::<&bevy_asset_preview::ActiveLoadTask>(); + + while iterations < MAX_ITERATIONS { + app.update(); + iterations += 1; + let world = app.world(); + + pending_cleaned = !world.entity(entity).contains::(); + + let has_active_task_now = active_task_query + .iter(world) + .any(|active_task| active_task.task_id == task_id); + active_task_cleaned = !has_active_task_now; + + let loader = world.resource::(); + task_path_cleaned = loader.get_task_path(task_id).is_none(); + + if pending_cleaned && active_task_cleaned && task_path_cleaned { + break; + } + } + + CleanupState { + pending_cleaned, + active_task_cleaned, + task_path_cleaned, + iterations, + } +} + +fn verify_failure_cleanup_complete( + app: &mut App, + entity: Entity, + task_id: u64, + state: &CleanupState, +) { + let mut active_task_query = app.world_mut().query::<&bevy_asset_preview::ActiveLoadTask>(); + let world = app.world(); + + assert!( + !world.entity(entity).contains::(), + "PendingPreviewLoad MUST be cleaned up (iterations: {})", + state.iterations + ); + + let has_active_task = active_task_query + .iter(world) + .any(|active_task| active_task.task_id == task_id); + assert!( + !has_active_task, + "ActiveLoadTask MUST be cleaned up (iterations: {})", + state.iterations + ); + + let loader = world.resource::(); + assert!( + loader.get_task_path(task_id).is_none(), + "Task path MUST be removed from loader (iterations: {})", + state.iterations + ); + + assert!( + !world.entity(entity).contains::(), + "PreviewAsset must be cleaned up after failure" + ); + + assert!( + world.entity(entity).contains::(), + "ImageNode (placeholder) should remain after failure cleanup" + ); +} + +fn test_boundary_conditions(app: &mut App) { + let empty_path_entity = app + .world_mut() + .spawn(bevy_asset_preview::PreviewAsset(PathBuf::from(""))) + .id(); + app.update(); + + let world = app.world(); + assert!( + world.entity(empty_path_entity).contains::(), + "Empty path should have ImageNode (placeholder)" + ); + assert!( + !world.entity(empty_path_entity).contains::(), + "Empty path should not have PendingPreviewLoad" + ); + + let special_char_entity = app + .world_mut() + .spawn(bevy_asset_preview::PreviewAsset(PathBuf::from("test_file_123.png"))) + .id(); + app.update(); + + let world = app.world(); + assert!( + world.entity(special_char_entity).contains::(), + "Special char path should have ImageNode" + ); +} + +#[test] +fn test_complete_workflow() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let assets_dir = temp_dir.path().join("assets"); + let cache_dir = temp_dir.path().join("cache"); + fs::create_dir_all(&assets_dir).expect("Failed to create assets directory"); + fs::create_dir_all(&cache_dir).expect("Failed to create cache directory"); + + unsafe { + std::env::set_var( + "BEVY_ASSET_ROOT", + temp_dir + .path() + .to_str() + .expect("Failed to convert path to string"), + ); + } + + let mut app = App::new(); + + // Register cache directory as asset source + let cache_dir_path = temp_dir.path().join("cache").join("asset_preview"); + app.register_asset_source( + "thumbnail_cache", + bevy::asset::io::AssetSourceBuilder::platform_default( + cache_dir_path + .to_str() + .expect("Cache dir path should be valid"), + None, + ), + ); + + // Initialize complete plugin system + app.add_plugins(MinimalPlugins) + .add_plugins(AssetPlugin { + file_path: assets_dir.display().to_string(), + ..Default::default() + }) + .add_plugins(bevy_asset_preview::AssetPreviewPlugin) + .init_asset::() + .register_asset_loader(ImageLoader::new(CompressedImageFormats::NONE)) + .add_event::() + .add_event::() + .add_event::() + .add_event::() + .init_resource::() + .add_systems(Update, monitor_save_completion); + + // Initialize Time resource by running one update + app.update(); + + // ========== Phase 1: Create files and save previews to cache ========== + let file_definitions = vec![ + ("icon.png", true, 64, 64, [255, 0, 0, 255]), // Small image, no compression + ("texture.png", true, 512, 512, [0, 255, 0, 255]), // Large image, needs compression + ("sprite.png", true, 800, 200, [0, 0, 255, 255]), // Wide image, needs compression + ("readme.md", false, 0, 0, [0, 0, 0, 0]), // Non-image file + ("script.rs", false, 0, 0, [0, 0, 0, 0]), // Non-image file + ]; + + let mut image_files = Vec::new(); + { + let mut images = app.world_mut().resource_mut::>(); + for (filename, is_image, width, height, color) in &file_definitions { + if *is_image { + let handle = create_test_image(&mut images, *width, *height, *color); + let image = images.get(&handle).unwrap(); + let path = assets_dir.join(filename); + save_test_image(image, &path).expect("Failed to save test image"); + image_files.push((filename.to_string(), handle)); + } else { + let path = assets_dir.join(filename); + fs::write(&path, format!("Content of {}", filename)) + .expect("Failed to write test file"); + } + } + } + + let save_tasks = app + .world_mut() + .resource_scope(|world, mut tracker: Mut| { + let images = world.get_resource::>().unwrap(); + let mut tasks = Vec::new(); + + for (filename, handle) in &image_files { + let writer = FileAssetWriter::new("", true); + let target_path = + AssetPath::from_path_buf(PathBuf::from("cache/asset_preview").join(filename)) + .into_owned(); + let task = save_image(handle.clone(), target_path.clone(), images, writer); + let task_id = tracker.create_task_id(); + let path_asset: AssetPath<'static> = + AssetPath::from_path_buf(PathBuf::from(filename)).into_owned(); + tracker.register_pending(task_id, path_asset.clone()); + tasks.push((task_id, path_asset, target_path, task)); + } + + tasks + }); + + let mut commands = app.world_mut().commands(); + for (task_id, path, target_path, task) in save_tasks { + commands.spawn(ActiveSaveTask { + task_id, + path, + target_path, + task, + }); + } + + wait_for_save_completion(&mut app, image_files.len(), 1000); + + std::thread::sleep(std::time::Duration::from_millis(100)); + for (filename, _) in &image_files { + let mut cached_path = cache_dir_path.join(filename); + cached_path.set_extension("webp"); + assert!( + cached_path.exists(), + "Cached preview file should exist: {:?}", + cached_path + ); + } + + // Phase 2: Preview request processing and initial state validation + let mut file_entities = Vec::new(); + for (filename, is_image, _, _, _) in &file_definitions { + let entity = app + .world_mut() + .spawn(bevy_asset_preview::PreviewAsset(PathBuf::from(filename))) + .id(); + file_entities.push((entity, filename.to_string(), *is_image)); + } + + app.update(); + + let world = app.world(); + for (entity, filename, is_image) in &file_entities { + assert!( + world.entity(*entity).contains::(), + "All files should have ImageNode: {}", + filename + ); + + if *is_image { + assert!( + world + .entity(*entity) + .contains::(), + "Image file {} should have PendingPreviewLoad", + filename + ); + } else { + assert!( + !world + .entity(*entity) + .contains::(), + "Non-image file {} should have PreviewAsset removed", + filename + ); + assert!( + !world + .entity(*entity) + .contains::(), + "Non-image file {} should not have PendingPreviewLoad", + filename + ); + } + } + + // Phase 3: Wait for load completion and validate + let image_entities: Vec<_> = file_entities + .iter() + .filter(|(_, _, is_image)| *is_image) + .map(|(entity, filename, _)| (*entity, filename.clone())) + .collect(); + + let (all_completed_events, max_active_tasks_observed, initial_queue_len) = + wait_for_load_completion(&mut app, image_entities.len(), 3000); + + assert!( + max_active_tasks_observed > 0, + "Should have observed active tasks during loading" + ); + assert!( + max_active_tasks_observed <= 4, + "Max active tasks should not exceed max_concurrent, got {}", + max_active_tasks_observed + ); + + if image_entities.len() > 4 { + assert!( + initial_queue_len > 0, + "Should have tasks in queue when loading more than max_concurrent tasks, got {}", + initial_queue_len + ); + } + + assert!( + all_completed_events.len() >= image_files.len(), + "Should have at least {} load completed events, got {}", + image_files.len(), + all_completed_events.len() + ); + + let world = app.world(); + let cache = world.resource::(); + let images = world.resource::>(); + + assert!(!cache.is_empty(), "Cache should not be empty"); + assert_eq!( + cache.len(), + image_files.len(), + "Cache should contain exactly {} entries", + image_files.len() + ); + + for (entity, filename, is_image) in &file_entities { + if *is_image { + assert!( + !world + .entity(*entity) + .contains::(), + "Image file {} should have PreviewAsset removed after loading", + filename + ); + assert!( + !world + .entity(*entity) + .contains::(), + "Image file {} should have PendingPreviewLoad removed after loading", + filename + ); + + let asset_path: AssetPath<'static> = AssetPath::from(filename.as_str()).into_owned(); + let cache_entry_by_path = cache.get_by_path(&asset_path); + assert!( + cache_entry_by_path.is_some(), + "Image file {} should be cached", + filename + ); + + let entry = cache_entry_by_path.unwrap(); + let cache_entry_by_id = cache.get_by_id(entry.asset_id); + assert!( + cache_entry_by_id.is_some(), + "Cache entry should be accessible by ID for {}", + filename + ); + assert_eq!( + entry.image_handle.id(), + cache_entry_by_id.unwrap().image_handle.id(), + "Cache entries by path and ID should match for {}", + filename + ); + + assert!( + !entry.timestamp.is_zero(), + "Cache entry for {} should have valid timestamp, got: {:?}", + filename, + entry.timestamp + ); + assert!( + entry.image_handle.is_strong(), + "Cache entry for {} should have strong handle", + filename + ); + + if filename == "texture.png" || filename == "sprite.png" { + if let Some(preview_image) = images.get(&entry.image_handle) { + assert!( + preview_image.width() <= 256 && preview_image.height() <= 256, + "Large image {} should be compressed, got {}x{}", + filename, + preview_image.width(), + preview_image.height() + ); + } + } + + if filename == "sprite.png" { + if let Some(preview_image) = images.get(&entry.image_handle) { + // Original: 800x200 = 4:1, compressed should be 256:64 + let expected_height = (200.0 * 256.0 / 800.0) as u32; + assert_eq!(preview_image.width(), 256, "Wide image width should be 256"); + assert_eq!( + preview_image.height(), + expected_height, + "Wide image should maintain aspect ratio, expected height: {}, got: {}", + expected_height, + preview_image.height() + ); + } + } + } + } + + let loader = app.world().resource::(); + assert_eq!( + loader.queue_len(), + 0, + "Loader queue should be empty after all tasks complete" + ); + assert_eq!( + loader.active_tasks(), + 0, + "Should have 0 active tasks after cleanup" + ); + + let mut query = app.world_mut().query::<&ActiveSaveTask>(); + let world = app.world(); + let save_task_count = query.iter(world).count(); + assert_eq!( + save_task_count, 0, + "All save task entities should be cleaned up, found {} remaining", + save_task_count + ); + + // Phase 4: Cache hit test + let mut second_batch_entities = Vec::new(); + for (filename, is_image, _, _, _) in &file_definitions { + if *is_image { + let entity = app + .world_mut() + .spawn(bevy_asset_preview::PreviewAsset(PathBuf::from(filename))) + .id(); + second_batch_entities.push((entity, filename.to_string())); + } + } + + let concurrent_entities: Vec<_> = (0..3) + .map(|_| { + app.world_mut() + .spawn(bevy_asset_preview::PreviewAsset(PathBuf::from("icon.png"))) + .id() + }) + .collect(); + + app.update(); + + let world = app.world(); + for (entity, filename) in &second_batch_entities { + assert!( + !world + .entity(*entity) + .contains::(), + "PreviewAsset should be removed immediately on cache hit for {}", + filename + ); + assert!( + !world + .entity(*entity) + .contains::(), + "PendingPreviewLoad should not be added on cache hit for {}", + filename + ); + assert!( + world.entity(*entity).contains::(), + "ImageNode should be added immediately on cache hit for {}", + filename + ); + } + + for entity in &concurrent_entities { + assert!( + !world + .entity(*entity) + .contains::(), + "Concurrent requests should use cache immediately" + ); + assert!( + !world + .entity(*entity) + .contains::(), + "Concurrent requests should not have PendingPreviewLoad" + ); + assert!( + world.entity(*entity).contains::(), + "Concurrent requests should have ImageNode" + ); + } + + // Phase 5: Error handling and boundary conditions + test_error_handling_for_nonexistent_file(&mut app); + test_boundary_conditions(&mut app); + + // Phase 6: Final integrity validation + let mut active_task_query = app + .world_mut() + .query::<&bevy_asset_preview::ActiveLoadTask>(); + let world = app.world(); + let cache = world.resource::(); + let loader = world.resource::(); + + assert!(!cache.is_empty(), "Cache should not be empty"); + assert_eq!( + cache.len(), + image_files.len(), + "Cache should contain all {} image previews", + image_files.len() + ); + + let non_existent_active_tasks = active_task_query + .iter(world) + .filter(|active_task| active_task.path.to_string().contains("non_existent")) + .count(); + + assert_eq!( + non_existent_active_tasks, 0, + "All non-existent file tasks must be cleaned up (found {} remaining)", + non_existent_active_tasks + ); + + let actual_active_tasks = loader.active_tasks(); + let actual_queue_len = loader.queue_len(); + + assert_eq!( + actual_active_tasks, 0, + "All active tasks must be cleaned up after failure handling (found {} remaining)", + actual_active_tasks + ); + + assert!( + actual_queue_len <= 1, + "Queue should have at most 1 task, found {}", + actual_queue_len + ); + + let mut image_entities_with_preview = 0; + for (entity, filename, is_image) in &file_entities { + if *is_image { + assert!( + world.entity(*entity).contains::(), + "Image file {} should have ImageNode", + filename + ); + image_entities_with_preview += 1; + } + } + assert_eq!( + image_entities_with_preview, + image_files.len(), + "All image entities should have ImageNode previews" + ); + + println!( + "Test completed: created {} files, {} image previews, cached {} entries, {} load completed events", + file_definitions.len(), + image_files.len(), + cache.len(), + all_completed_events.len() + ); +} From abc5a24bbde92bf4cb15bc0b2ed605575b0f854f Mon Sep 17 00:00:00 2001 From: Sieluna Date: Thu, 8 Jan 2026 18:30:45 +0200 Subject: [PATCH 6/8] Implement multi-resolution feature --- crates/bevy_asset_preview/src/asset/loader.rs | 4 +- crates/bevy_asset_preview/src/asset/saver.rs | 30 +- crates/bevy_asset_preview/src/lib.rs | 5 +- .../bevy_asset_preview/src/preview/cache.rs | 485 ++++++++++++++++-- crates/bevy_asset_preview/src/preview/mod.rs | 43 +- .../bevy_asset_preview/src/preview/systems.rs | 120 +++-- crates/bevy_asset_preview/src/ui/mod.rs | 35 +- .../bevy_asset_preview/tests/workflow_test.rs | 155 ++++-- 8 files changed, 683 insertions(+), 194 deletions(-) diff --git a/crates/bevy_asset_preview/src/asset/loader.rs b/crates/bevy_asset_preview/src/asset/loader.rs index 64cde2fb..79ed43f4 100644 --- a/crates/bevy_asset_preview/src/asset/loader.rs +++ b/crates/bevy_asset_preview/src/asset/loader.rs @@ -276,7 +276,7 @@ pub fn poll_load_tasks( ) { for (entity, active_task) in task_query.iter_mut() { if asset_server.is_loaded_with_dependencies(&active_task.handle) { - bevy::log::info!("Asset loaded successfully: {:?}", active_task.path); + info!("Asset loaded successfully: {:?}", active_task.path); loader.finish_task(); let handle_id = active_task.handle.id(); @@ -285,7 +285,7 @@ pub fn poll_load_tasks( } else { let load_state = asset_server.load_state(&active_task.handle); if let LoadState::Failed(_) = load_state { - bevy::log::warn!("Asset load failed: {:?}", active_task.path); + warn!("Asset load failed: {:?}", active_task.path); loader.finish_task(); let handle_id = active_task.handle.id(); diff --git a/crates/bevy_asset_preview/src/asset/saver.rs b/crates/bevy_asset_preview/src/asset/saver.rs index 0f2dbff3..55d7c49e 100644 --- a/crates/bevy_asset_preview/src/asset/saver.rs +++ b/crates/bevy_asset_preview/src/asset/saver.rs @@ -5,11 +5,8 @@ use bevy::{ ecs::event::{BufferedEvent, Event}, image::{Image, ImageFormat}, platform::collections::HashMap, - prelude::{ - Assets, Commands, Component, Entity, EventReader, EventWriter, Handle, Query, ResMut, - Resource, - }, - tasks::{IoTaskPool, Task}, + prelude::*, + tasks::{IoTaskPool, Task, block_on, futures_lite}, }; /// Active save task tracking component. @@ -98,7 +95,7 @@ pub fn save_image<'a>( if let Some(parent) = target_path_for_writer.parent() { if let Err(e) = writer.create_directory(parent).await { let error = format!("Failed to create directory {:?}: {:?}", parent, e); - bevy::log::error!("{}", error); + error!("{}", error); return Err(error); } } @@ -117,20 +114,20 @@ pub fn save_image<'a>( .await { Ok(_) => { - bevy::log::info!("Image saved successfully to {:?}", target_path_clone); + info!("Image saved successfully to {:?}", target_path_clone); Ok(()) } Err(e) => { let error = format!("Failed to save image to {:?}: {:?}", target_path_clone, e); - bevy::log::error!("{}", error); + error!("{}", error); Err(error) } } } Err(e) => { let error = format!("Failed to encode image to WebP: {:?}", e); - bevy::log::error!("{}", error); + error!("{}", error); Err(error) } } @@ -146,9 +143,7 @@ pub fn monitor_save_completion( ) { for (entity, mut active_task) in save_task_query.iter_mut() { // Poll the async task - if let Some(result) = bevy::tasks::block_on(bevy::tasks::futures_lite::future::poll_once( - &mut active_task.task, - )) { + if let Some(result) = block_on(futures_lite::future::poll_once(&mut active_task.task)) { // Task completed, send event save_completed_events.write(SaveCompleted { task_id: active_task.task_id, @@ -167,18 +162,15 @@ pub fn handle_save_completed(mut save_completed_events: EventReader { - bevy::log::debug!( + debug!( "Save task {} completed successfully for {:?}", - event.task_id, - event.path + event.task_id, event.path ); } Err(e) => { - bevy::log::warn!( + warn!( "Save task {} failed for {:?}: {}", - event.task_id, - event.path, - e + event.task_id, event.path, e ); } } diff --git a/crates/bevy_asset_preview/src/lib.rs b/crates/bevy_asset_preview/src/lib.rs index e82a8937..fa0d993f 100644 --- a/crates/bevy_asset_preview/src/lib.rs +++ b/crates/bevy_asset_preview/src/lib.rs @@ -28,8 +28,9 @@ pub struct AssetPreviewPlugin; impl Plugin for AssetPreviewPlugin { fn build(&self, app: &mut App) { // Initialize resources - app.init_resource::(); - app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); // Register events app.add_event::(); diff --git a/crates/bevy_asset_preview/src/preview/cache.rs b/crates/bevy_asset_preview/src/preview/cache.rs index b850a941..1b552829 100644 --- a/crates/bevy_asset_preview/src/preview/cache.rs +++ b/crates/bevy_asset_preview/src/preview/cache.rs @@ -1,5 +1,7 @@ use core::time::Duration; +use std::path::Path; + use bevy::{ asset::{AssetId, AssetPath}, image::Image, @@ -7,24 +9,26 @@ use bevy::{ prelude::{Handle, Resource}, }; -/// Cache entry for a preview image. +/// Cache entry for a preview image at a specific resolution. #[derive(Clone, Debug)] pub struct PreviewCacheEntry { /// The preview image handle. pub image_handle: Handle, /// The asset ID that this preview is for. pub asset_id: AssetId, - /// Timestamp when the preview was generated (for cache invalidation). + /// Resolution in pixels (max dimension). + pub resolution: u32, + /// Timestamp for cache invalidation. pub timestamp: Duration, } /// Cache for preview images to avoid re-rendering unchanged assets. #[derive(Resource, Default)] pub struct PreviewCache { - /// Maps asset path to cache entry. + /// Maps asset path with resolution suffix to cache entry. path_cache: HashMap, PreviewCacheEntry>, - /// Maps asset ID to cache entry. - id_cache: HashMap, PreviewCacheEntry>, + /// Maps asset ID to all resolutions for efficient lookup and cleanup. + id_to_paths: HashMap, Vec>>, } impl PreviewCache { @@ -32,70 +36,360 @@ impl PreviewCache { pub fn new() -> Self { Self { path_cache: HashMap::new(), - id_cache: HashMap::new(), + id_to_paths: HashMap::new(), + } + } + + /// Generates a cache path with resolution suffix. + /// Format: "path/to/image_64x64.png" + fn cache_path_for_resolution<'a>(path: &AssetPath<'a>, resolution: u32) -> AssetPath<'static> { + let path_buf = path.path(); + let parent = path_buf.parent(); + let file_name = path_buf.file_name().and_then(|n| n.to_str()); + + if let Some(file_name) = file_name { + if let Some(dot_pos) = file_name.rfind('.') { + let name_without_ext = &file_name[..dot_pos]; + let extension = &file_name[dot_pos..]; + let new_file_name = format!( + "{}_{}x{}{}", + name_without_ext, resolution, resolution, extension + ); + + if let Some(parent) = parent { + AssetPath::from(parent.join(new_file_name)) + } else { + AssetPath::from(new_file_name) + } + } else { + let new_file_name = format!("{}_{}x{}", file_name, resolution, resolution); + if let Some(parent) = parent { + AssetPath::from(parent.join(new_file_name)) + } else { + AssetPath::from(new_file_name) + } + } + } else { + let path_str = path.path().to_string_lossy(); + AssetPath::from(format!("{}_{}x{}", path_str, resolution, resolution)) + } + } + + /// Extracts the original path and resolution from a cache path. + fn parse_cache_path(path: &AssetPath<'static>) -> Option<(AssetPath<'static>, u32)> { + let path_buf = path.path(); + let parent = path_buf.parent(); + let file_name = path_buf.file_name().and_then(|n| n.to_str())?; + + let (name_without_res, resolution) = Self::extract_resolution_from_filename(file_name)?; + + let original_path = if let Some(parent) = parent { + parent.join(&name_without_res) + } else { + Path::new(&name_without_res).to_path_buf() + }; + + Some((AssetPath::from(original_path), resolution)) + } + + /// Extracts resolution from a filename. + fn extract_resolution_from_filename(file_name: &str) -> Option<(String, u32)> { + if let Some(dot_pos) = file_name.rfind('.') { + let name_without_ext = &file_name[..dot_pos]; + let extension = &file_name[dot_pos..]; + + if let Some(underscore_pos) = name_without_ext.rfind('_') { + if let Some(resolution) = + Self::parse_resolution_suffix(&name_without_ext[underscore_pos + 1..]) + { + let original_name = + format!("{}{}", &name_without_ext[..underscore_pos], extension); + return Some((original_name, resolution)); + } + } + } else { + if let Some(underscore_pos) = file_name.rfind('_') { + if let Some(resolution) = + Self::parse_resolution_suffix(&file_name[underscore_pos + 1..]) + { + let original_name = file_name[..underscore_pos].to_string(); + return Some((original_name, resolution)); + } + } + } + + None + } + + /// Parses a resolution suffix like "64x64" into a u32. + fn parse_resolution_suffix(suffix: &str) -> Option { + if let Some(x_pos) = suffix.find('x') { + let width_str = &suffix[..x_pos]; + let height_str = &suffix[x_pos + 1..]; + + if let (Ok(width), Ok(height)) = (width_str.parse::(), height_str.parse::()) { + if width == height && height_str.chars().all(|c| c.is_ascii_digit()) { + return Some(width); + } + } } + None } - /// Gets a cached preview by asset path. - pub fn get_by_path<'a>(&self, path: &AssetPath<'a>) -> Option<&PreviewCacheEntry> { - // Convert to owned path for lookup + /// Gets a cached preview by asset path and resolution. + /// Returns highest resolution if resolution is None. + pub fn get_by_path<'a>( + &self, + path: &AssetPath<'a>, + resolution: Option, + ) -> Option<&PreviewCacheEntry> { let owned_path: AssetPath<'static> = path.clone().into_owned(); - self.path_cache.get(&owned_path) + + if let Some(res) = resolution { + let cache_path = Self::cache_path_for_resolution(&owned_path, res); + self.path_cache.get(&cache_path) + } else { + self.find_highest_resolution_for_path(&owned_path) + } } - /// Gets a cached preview by asset ID. - pub fn get_by_id(&self, asset_id: AssetId) -> Option<&PreviewCacheEntry> { - self.id_cache.get(&asset_id) + /// Finds the highest resolution entry for a given path. + fn find_highest_resolution_for_path( + &self, + path: &AssetPath<'static>, + ) -> Option<&PreviewCacheEntry> { + let mut best_entry: Option<&PreviewCacheEntry> = None; + let mut best_resolution = 0u32; + + for (cache_path, entry) in &self.path_cache { + if let Some((original_path, res)) = Self::parse_cache_path(cache_path) { + if original_path.path() == path.path() && res > best_resolution { + best_resolution = res; + best_entry = Some(entry); + } + } + } + + best_entry + } + + /// Gets all cached previews for an asset path, sorted by resolution. + pub fn get_all_by_path<'a>(&self, path: &AssetPath<'a>) -> Vec<&PreviewCacheEntry> { + let owned_path: AssetPath<'static> = path.clone().into_owned(); + let mut entries = Vec::new(); + + for (cache_path, entry) in &self.path_cache { + if let Some((original_path, _)) = Self::parse_cache_path(cache_path) { + if original_path.path() == owned_path.path() { + entries.push(entry); + } + } + } + + entries.sort_by_key(|e| e.resolution); + entries + } + + /// Gets a cached preview by asset ID and resolution. + /// Returns highest resolution if resolution is None. + pub fn get_by_id( + &self, + asset_id: AssetId, + resolution: Option, + ) -> Option<&PreviewCacheEntry> { + let paths = self.id_to_paths.get(&asset_id)?; + + if let Some(res) = resolution { + for path in paths { + if let Some(entry) = self.path_cache.get(path) { + if entry.resolution == res { + return Some(entry); + } + } + } + None + } else { + let mut best_entry: Option<&PreviewCacheEntry> = None; + let mut best_resolution = 0u32; + + for path in paths { + if let Some(entry) = self.path_cache.get(path) { + if entry.resolution > best_resolution { + best_resolution = entry.resolution; + best_entry = Some(entry); + } + } + } + + best_entry + } } - /// Inserts a preview into the cache. + /// Inserts a preview into the cache for a specific resolution. pub fn insert<'a>( &mut self, path: impl Into>, asset_id: AssetId, + resolution: u32, image_handle: Handle, timestamp: Duration, ) { let path: AssetPath<'static> = path.into().into_owned(); + let cache_path = Self::cache_path_for_resolution(&path, resolution); + let entry = PreviewCacheEntry { image_handle, asset_id, + resolution, timestamp, }; - self.path_cache.insert(path.clone(), entry.clone()); - self.id_cache.insert(asset_id, entry); + + self.path_cache.insert(cache_path.clone(), entry); + + let paths = self.id_to_paths.entry(asset_id).or_insert_with(Vec::new); + if !paths.contains(&cache_path) { + paths.push(cache_path); + } } - /// Removes a preview from the cache by path. - pub fn remove_by_path<'a>(&mut self, path: &AssetPath<'a>) -> Option { - // Convert to owned path for lookup + /// Removes a preview from the cache by path and resolution. + /// Removes all resolutions if resolution is None. + pub fn remove_by_path<'a>( + &mut self, + path: &AssetPath<'a>, + resolution: Option, + ) -> Option { let owned_path: AssetPath<'static> = path.clone().into_owned(); - if let Some(entry) = self.path_cache.remove(&owned_path) { - self.id_cache.remove(&entry.asset_id); - Some(entry) + + if let Some(res) = resolution { + let cache_path = Self::cache_path_for_resolution(&owned_path, res); + if let Some(entry) = self.path_cache.remove(&cache_path) { + self.remove_path_from_id_mapping(&entry.asset_id, &cache_path); + Some(entry) + } else { + None + } } else { - None + self.remove_all_resolutions_for_path(&owned_path) } } - /// Removes a preview from the cache by asset ID. - pub fn remove_by_id(&mut self, asset_id: AssetId) -> Option { - if let Some(entry) = self.id_cache.remove(&asset_id) { - // Find and remove from path cache - self.path_cache.retain(|_, e| e.asset_id != asset_id); - Some(entry) - } else { + /// Removes all cache entries for a given path. + fn remove_all_resolutions_for_path( + &mut self, + path: &AssetPath<'static>, + ) -> Option { + let mut removed_entry: Option = None; + let mut asset_ids_to_check = Vec::new(); + + let cache_paths: Vec> = self + .path_cache + .keys() + .filter_map(|cache_path| { + if let Some((original_path, _)) = Self::parse_cache_path(cache_path) { + if original_path.path() == path.path() { + return Some(cache_path.clone()); + } + } + None + }) + .collect(); + + for cache_path in cache_paths { + if let Some(entry) = self.path_cache.remove(&cache_path) { + asset_ids_to_check.push(entry.asset_id); + if removed_entry.is_none() { + removed_entry = Some(entry); + } + } + } + + for asset_id in asset_ids_to_check { + self.cleanup_id_mapping(&asset_id, path); + } + + removed_entry + } + + /// Removes a path from an asset ID's path list. + fn remove_path_from_id_mapping( + &mut self, + asset_id: &AssetId, + cache_path: &AssetPath<'static>, + ) { + if let Some(paths) = self.id_to_paths.get_mut(asset_id) { + paths.retain(|p| p != cache_path); + if paths.is_empty() { + self.id_to_paths.remove(asset_id); + } + } + } + + /// Cleans up ID mapping for a removed asset. + fn cleanup_id_mapping( + &mut self, + asset_id: &AssetId, + original_path: &AssetPath<'static>, + ) { + if let Some(paths) = self.id_to_paths.get_mut(asset_id) { + paths.retain(|cache_path| { + if let Some((parsed_path, _)) = Self::parse_cache_path(cache_path) { + parsed_path.path() != original_path.path() + } else { + true + } + }); + if paths.is_empty() { + self.id_to_paths.remove(asset_id); + } + } + } + + /// Removes a preview from the cache by asset ID and resolution. + /// Removes all resolutions if resolution is None. + pub fn remove_by_id( + &mut self, + asset_id: AssetId, + resolution: Option, + ) -> Option { + let paths = self.id_to_paths.get(&asset_id)?.clone(); + + if let Some(res) = resolution { + for path in &paths { + if let Some(entry) = self.path_cache.get(path) { + if entry.resolution == res { + let cache_path = path.clone(); + if let Some(removed) = self.path_cache.remove(&cache_path) { + self.remove_path_from_id_mapping(&asset_id, &cache_path); + return Some(removed); + } + } + } + } None + } else { + let mut removed_entry: Option = None; + + for path in &paths { + if let Some(entry) = self.path_cache.remove(path) { + if removed_entry.is_none() { + removed_entry = Some(entry); + } + } + } + + self.id_to_paths.remove(&asset_id); + removed_entry } } /// Clears all cached previews. pub fn clear(&mut self) { self.path_cache.clear(); - self.id_cache.clear(); + self.id_to_paths.clear(); } - /// Returns the number of cached previews. + /// Returns the number of cached preview entries. pub fn len(&self) -> usize { self.path_cache.len() } @@ -148,6 +442,36 @@ mod tests { images.add(image) } + #[test] + fn test_cache_path_parsing() { + let path: AssetPath<'static> = "test.png".into(); + let cache_path = PreviewCache::cache_path_for_resolution(&path, 64); + assert_eq!(cache_path.path().to_string_lossy(), "test_64x64.png"); + + let parsed = PreviewCache::parse_cache_path(&cache_path); + assert!(parsed.is_some()); + let (original, res) = parsed.unwrap(); + assert_eq!(original.path().to_string_lossy(), "test.png"); + assert_eq!(res, 64); + + // Test with path + let path2: AssetPath<'static> = "path/to/image.jpg".into(); + let cache_path2 = PreviewCache::cache_path_for_resolution(&path2, 256); + assert_eq!( + cache_path2.path().file_name().unwrap().to_string_lossy(), + "image_256x256.jpg" + ); + + let parsed2 = PreviewCache::parse_cache_path(&cache_path2); + assert!(parsed2.is_some()); + let (original2, res2) = parsed2.unwrap(); + assert_eq!( + original2.path().file_name().unwrap().to_string_lossy(), + "image.jpg" + ); + assert_eq!(res2, 256); + } + #[test] fn test_preview_cache_operations() { let temp_dir = TempDir::new().expect("Failed to create temp directory"); @@ -180,22 +504,38 @@ mod tests { let mut cache = app.world_mut().resource_mut::(); // Test insertion and query by path - cache.insert(&path1, asset_id1, handle1.clone(), Duration::from_secs(100)); - cache.insert(&path2, asset_id2, handle2.clone(), Duration::from_secs(200)); + cache.insert( + &path1, + asset_id1, + 256, + handle1.clone(), + Duration::from_secs(100), + ); + cache.insert( + &path2, + asset_id2, + 256, + handle2.clone(), + Duration::from_secs(200), + ); assert_eq!(cache.len(), 2, "Cache should contain 2 entries"); assert!(!cache.is_empty(), "Cache should not be empty"); - let entry1 = cache.get_by_path(&path1).unwrap(); + let entry1 = cache.get_by_path(&path1, None).unwrap(); assert_eq!(entry1.asset_id, asset_id1, "Entry should match asset ID"); assert_eq!( entry1.timestamp, Duration::from_secs(100), "Entry should match timestamp" ); + assert_eq!( + entry1.resolution, 256, + "Entry should have correct resolution" + ); // Test query by ID - let entry1_by_id = cache.get_by_id(asset_id1).unwrap(); + let entry1_by_id = cache.get_by_id(asset_id1, None).unwrap(); assert_eq!( entry1_by_id.image_handle.id(), handle1.id(), @@ -203,42 +543,91 @@ mod tests { ); // Test consistency between path and ID queries - let entry2_by_path = cache.get_by_path(&path2).unwrap(); - let entry2_by_id = cache.get_by_id(asset_id2).unwrap(); + let entry2_by_path = cache.get_by_path(&path2, None).unwrap(); + let entry2_by_id = cache.get_by_id(asset_id2, None).unwrap(); assert_eq!( entry2_by_path.image_handle.id(), entry2_by_id.image_handle.id(), "Path and ID queries should return same entry" ); - // Test removal by path - let removed = cache.remove_by_path(&path1).unwrap(); - assert_eq!(removed.asset_id, asset_id1, "Removed entry should match"); + // Test multi-resolution + let handle1_64 = handle1.clone(); + cache.insert(&path1, asset_id1, 64, handle1_64, Duration::from_secs(150)); + assert_eq!(cache.len(), 3, "Cache should have 3 entries now"); + + let entry1_64 = cache.get_by_path(&path1, Some(64)).unwrap(); + assert_eq!(entry1_64.resolution, 64, "64px entry should exist"); + let entry1_256 = cache.get_by_path(&path1, Some(256)).unwrap(); + assert_eq!(entry1_256.resolution, 256, "256px entry should exist"); + // None should return highest resolution + let entry1_default = cache.get_by_path(&path1, None).unwrap(); + assert_eq!( + entry1_default.resolution, 256, + "None should return highest resolution" + ); + + // Test get_all_by_path + let all_entries = cache.get_all_by_path(&path1); + assert_eq!(all_entries.len(), 2, "Should have 2 resolutions for path1"); + assert_eq!(all_entries[0].resolution, 64); + assert_eq!(all_entries[1].resolution, 256); + + // Test removal by path with specific resolution + cache.remove_by_path(&path1, Some(64)); + assert_eq!(cache.len(), 2, "Cache should have 2 entries after removal"); + assert!( + cache.get_by_path(&path1, Some(64)).is_none(), + "64px entry should be removed" + ); + assert!( + cache.get_by_path(&path1, Some(256)).is_some(), + "256px entry should still exist" + ); + + // Test removal by path (all resolutions) + let removed = cache.remove_by_path(&path1, None); + assert!(removed.is_some(), "Should have removed entry"); assert_eq!(cache.len(), 1, "Cache should have 1 entry after removal"); assert!( - cache.get_by_path(&path1).is_none(), + cache.get_by_path(&path1, None).is_none(), "Path should be removed" ); - assert!(cache.get_by_id(asset_id1).is_none(), "ID should be removed"); + assert!( + cache.get_by_id(asset_id1, None).is_none(), + "ID should be removed" + ); // Test removal by ID - let removed2 = cache.remove_by_id(asset_id2).unwrap(); - assert_eq!(removed2.asset_id, asset_id2, "Removed entry should match"); + let removed2 = cache.remove_by_id(asset_id2, None); + assert!(removed2.is_some(), "Should have removed entry"); assert_eq!(cache.len(), 0, "Cache should be empty"); assert!(cache.is_empty(), "Cache should be empty"); // Test duplicate insertion (should overwrite) let handle1_clone = handle1.clone(); let handle1_clone2 = handle1.clone(); - cache.insert(&path1, asset_id1, handle1_clone, Duration::from_secs(100)); - cache.insert(&path1, asset_id1, handle1_clone2, Duration::from_secs(300)); // Overwrite + cache.insert( + &path1, + asset_id1, + 256, + handle1_clone, + Duration::from_secs(100), + ); + cache.insert( + &path1, + asset_id1, + 256, + handle1_clone2, + Duration::from_secs(300), + ); // Overwrite assert_eq!( cache.len(), 1, "Cache should have 1 entry after duplicate insert" ); assert_eq!( - cache.get_by_path(&path1).unwrap().timestamp, + cache.get_by_path(&path1, Some(256)).unwrap().timestamp, Duration::from_secs(300), "Entry should be updated" ); diff --git a/crates/bevy_asset_preview/src/preview/mod.rs b/crates/bevy_asset_preview/src/preview/mod.rs index 44d93cd1..8d3b4770 100644 --- a/crates/bevy_asset_preview/src/preview/mod.rs +++ b/crates/bevy_asset_preview/src/preview/mod.rs @@ -1,37 +1,48 @@ mod cache; mod systems; -use bevy::{asset::RenderAssetUsages, image::Image}; +use bevy::{asset::RenderAssetUsages, image::Image, prelude::*}; pub use cache::{PreviewCache, PreviewCacheEntry}; pub use systems::{ PendingPreviewRequest, PreviewFailed, PreviewReady, PreviewTaskManager, - handle_image_preview_events, request_image_preview, + generate_previews_for_resolutions, handle_image_preview_events, request_image_preview, }; -/// Maximum preview size for 2D images (256x256). -const MAX_PREVIEW_SIZE: u32 = 256; +/// Configuration for preview generation. +#[derive(Resource, Debug, Clone)] +pub struct PreviewConfig { + /// Resolutions to generate previews for (in pixels). Default: [64, 256] + pub resolutions: Vec, +} -/// Resizes an image to preview size if it's larger than the maximum. -/// Returns a new resized image, or None if the image is already small enough. -pub fn resize_image_for_preview(image: &Image) -> Option { +impl Default for PreviewConfig { + fn default() -> Self { + Self { + resolutions: vec![64, 256], + } + } +} +/// Resizes an image to a specific preview size. +/// Returns None if the image is already small enough. +pub fn resize_image_for_preview(image: &Image, target_size: u32) -> Option { let width = image.width(); let height = image.height(); // If image is already small enough, return None (use original) - if width <= MAX_PREVIEW_SIZE && height <= MAX_PREVIEW_SIZE { + if width <= target_size && height <= target_size { return None; } // Calculate new size maintaining aspect ratio let (new_width, new_height) = if width > height { ( - MAX_PREVIEW_SIZE, - (height as f32 * MAX_PREVIEW_SIZE as f32 / width as f32) as u32, + target_size, + (height as f32 * target_size as f32 / width as f32) as u32, ) } else { ( - (width as f32 * MAX_PREVIEW_SIZE as f32 / height as f32) as u32, - MAX_PREVIEW_SIZE, + (width as f32 * target_size as f32 / height as f32) as u32, + target_size, ) }; @@ -113,7 +124,7 @@ mod tests { // Test small image (should not be compressed) let small_handle = create_test_image(&mut images, 64, 64, [128, 128, 128, 255]); let small_image = images.get(&small_handle).unwrap(); - let compressed_small = resize_image_for_preview(small_image); + let compressed_small = resize_image_for_preview(small_image, 256); assert!( compressed_small.is_none(), "Small image should not be compressed" @@ -122,7 +133,7 @@ mod tests { // Test large image (should be compressed) let large_handle = create_test_image(&mut images, 512, 512, [128, 128, 128, 255]); let large_image = images.get(&large_handle).unwrap(); - let compressed_large = resize_image_for_preview(large_image); + let compressed_large = resize_image_for_preview(large_image, 256); assert!( compressed_large.is_some(), "Large image should be compressed" @@ -138,7 +149,7 @@ mod tests { // Test wide image (maintain aspect ratio) let wide_handle = create_test_image(&mut images, 800, 200, [128, 128, 128, 255]); let wide_image = images.get(&wide_handle).unwrap(); - let compressed_wide = resize_image_for_preview(wide_image); + let compressed_wide = resize_image_for_preview(wide_image, 256); assert!(compressed_wide.is_some(), "Wide image should be compressed"); let compressed = compressed_wide.unwrap(); assert_eq!(compressed.width(), 256, "Wide image width should be 256"); @@ -157,7 +168,7 @@ mod tests { // Test tall image (maintain aspect ratio) let tall_handle = create_test_image(&mut images, 200, 800, [128, 128, 128, 255]); let tall_image = images.get(&tall_handle).unwrap(); - let compressed_tall = resize_image_for_preview(tall_image); + let compressed_tall = resize_image_for_preview(tall_image, 256); assert!(compressed_tall.is_some(), "Tall image should be compressed"); let compressed = compressed_tall.unwrap(); assert_eq!(compressed.height(), 256, "Tall image height should be 256"); diff --git a/crates/bevy_asset_preview/src/preview/systems.rs b/crates/bevy_asset_preview/src/preview/systems.rs index 03e5ee58..b59e8f6f 100644 --- a/crates/bevy_asset_preview/src/preview/systems.rs +++ b/crates/bevy_asset_preview/src/preview/systems.rs @@ -1,3 +1,5 @@ +use core::time::Duration; + use bevy::{ asset::{AssetEvent, AssetPath, AssetServer}, ecs::event::{BufferedEvent, EventReader, EventWriter}, @@ -6,7 +8,32 @@ use bevy::{ prelude::*, }; -use crate::preview::{PreviewCache, resize_image_for_preview}; +use crate::preview::{PreviewCache, PreviewConfig, resize_image_for_preview}; + +/// Generates preview images for the specified resolutions and caches them. +pub fn generate_previews_for_resolutions( + images: &mut Assets, + original_image: &Image, + original_handle: Handle, + path: &AssetPath<'static>, + asset_id: AssetId, + resolutions: &[u32], + cache: &mut PreviewCache, + timestamp: Duration, +) { + for &resolution in resolutions { + if cache.get_by_path(path, Some(resolution)).is_some() { + continue; + } + + let preview_handle = match resize_image_for_preview(original_image, resolution) { + Some(compressed) => images.add(compressed), + None => original_handle.clone(), + }; + + cache.insert(path, asset_id, resolution, preview_handle, timestamp); + } +} /// Event emitted when a preview is ready. #[derive(Event, BufferedEvent, Debug, Clone)] @@ -81,23 +108,24 @@ impl PreviewTaskManager { } /// Requests a preview for an image asset path. -/// This is a helper that can be used in systems or other contexts. /// Returns the task ID for tracking. +/// Uses highest configured resolution if resolution is None. pub fn request_image_preview<'a>( mut commands: Commands, mut task_manager: ResMut, - cache: Res, + mut cache: ResMut, + config: Res, asset_server: Res, images: Res>, mut images_mut: ResMut>, mut preview_ready_events: EventWriter, + time: Res>, path: impl Into>, + resolution: Option, ) -> u64 { let path: AssetPath<'static> = path.into().into_owned(); - // Check cache first - if let Some(cache_entry) = cache.get_by_path(&path) { - // Cache hit - send ready event immediately + if let Some(cache_entry) = cache.get_by_path(&path, resolution) { let task_id = task_manager.create_task_id(); preview_ready_events.write(PreviewReady { task_id, @@ -107,30 +135,37 @@ pub fn request_image_preview<'a>( return task_id; } - // Cache miss - create request let task_id = task_manager.create_task_id(); let handle: Handle = asset_server.load(&path); - // Check if image is already loaded if let Some(image) = images.get(&handle) { - // Image is already loaded, compress if needed and cache - let preview_image = if let Some(compressed) = resize_image_for_preview(image) { - images_mut.add(compressed) - } else { - handle.clone() - }; - - // Cache will be handled in handle_image_preview_events when the event is processed + let image_clone = image.clone(); + let asset_id = handle.id(); + + generate_previews_for_resolutions( + &mut images_mut, + &image_clone, + handle.clone(), + &path, + asset_id, + &config.resolutions, + &mut cache, + time.elapsed(), + ); + + let preview_handle = cache + .get_by_path(&path, resolution) + .map(|entry| entry.image_handle.clone()) + .unwrap_or_else(|| handle.clone()); preview_ready_events.write(PreviewReady { task_id, path: path.clone(), - image_handle: preview_image, + image_handle: preview_handle, }); return task_id; } - // Image not loaded yet - create pending request let entity = commands .spawn(PendingPreviewRequest { task_id, @@ -141,10 +176,11 @@ pub fn request_image_preview<'a>( task_id } -/// System that handles image asset events for previews and caches ready previews. +/// System that handles image asset events for previews. pub fn handle_image_preview_events( mut commands: Commands, mut cache: ResMut, + config: Res, asset_server: Res, mut preview_ready_events: EventWriter, mut preview_failed_events: EventWriter, @@ -155,52 +191,41 @@ pub fn handle_image_preview_events( mut ready_events: EventReader, time: Res>, ) { - // Cache previews from ready events for event in ready_events.read() { - // Cache the preview if not already cached - if cache.get_by_path(&event.path).is_none() { - let asset_id = event.image_handle.id(); - cache.insert( - &event.path, - asset_id, - event.image_handle.clone(), - time.elapsed(), - ); - } + let _ = cache.get_by_path(&event.path, None); } for event in asset_events.read() { match event { AssetEvent::LoadedWithDependencies { id } => { - // Find requests waiting for this image for (entity, request) in requests.iter() { let handle: Handle = asset_server.load(&request.path); if handle.id() == *id { if let Some(image) = images.get(&handle) { - // Compress if needed - let preview_image = - if let Some(compressed) = resize_image_for_preview(image) { - images.add(compressed) - } else { - handle.clone() - }; - - // Cache the preview - let preview_id = preview_image.id(); - cache.insert( + let image_clone = image.clone(); + let asset_id = handle.id(); + + generate_previews_for_resolutions( + &mut images, + &image_clone, + handle.clone(), &request.path, - preview_id, - preview_image.clone(), + asset_id, + &config.resolutions, + &mut cache, time.elapsed(), ); - // Send ready event + let preview_image = cache + .get_by_path(&request.path, None) + .map(|entry| entry.image_handle.clone()) + .unwrap_or_else(|| handle.clone()); + preview_ready_events.write(PreviewReady { task_id: request.task_id, path: request.path.clone(), image_handle: preview_image, }); - // Cleanup task_manager.remove_task(request.task_id); commands.entity(entity).despawn(); } @@ -208,7 +233,8 @@ pub fn handle_image_preview_events( } } AssetEvent::Removed { id } => { - // Find requests for removed image + cache.remove_by_id(*id, None); + for (entity, request) in requests.iter() { let handle: Handle = asset_server.load(&request.path); if handle.id() == *id { diff --git a/crates/bevy_asset_preview/src/ui/mod.rs b/crates/bevy_asset_preview/src/ui/mod.rs index d74ec215..1e531cc4 100644 --- a/crates/bevy_asset_preview/src/ui/mod.rs +++ b/crates/bevy_asset_preview/src/ui/mod.rs @@ -8,7 +8,7 @@ use bevy::{ use crate::{ asset::{AssetLoader, LoadPriority}, - preview::{PreviewCache, resize_image_for_preview}, + preview::{PreviewCache, PreviewConfig}, }; #[derive(Component, Deref)] @@ -83,7 +83,7 @@ pub fn preview_handler( let asset_path: AssetPath<'static> = path.clone().into(); // Check cache first - if let Some(cache_entry) = cache.get_by_path(&asset_path) { + if let Some(cache_entry) = cache.get_by_path(&asset_path, None) { // Cache hit - use cached preview immediately commands .entity(entity) @@ -112,6 +112,7 @@ pub fn preview_handler( pub fn handle_preview_load_completed( mut commands: Commands, mut cache: ResMut, + config: Res, mut images: ResMut>, mut load_completed_events: EventReader, pending_query: Query<(Entity, &PendingPreviewLoad)>, @@ -124,22 +125,28 @@ pub fn handle_preview_load_completed( if pending.task_id == event.task_id { // Check if image is loaded if let Some(image) = images.get(&event.handle) { - // Compress if needed - let preview_image = if let Some(compressed) = resize_image_for_preview(image) { - images.add(compressed) - } else { - event.handle.clone() - }; - - // Cache the preview - let preview_id = preview_image.id(); - cache.insert( + // Clone image data before mutable operations + let image_clone = image.clone(); + let asset_id = event.handle.id(); + + // Generate previews for all configured resolutions + crate::preview::generate_previews_for_resolutions( + &mut images, + &image_clone, + event.handle.clone(), &pending.asset_path, - preview_id, - preview_image.clone(), + asset_id, + &config.resolutions, + &mut cache, time.elapsed(), ); + // Get the highest resolution preview (or original if none generated) + let preview_image = cache + .get_by_path(&pending.asset_path, None) + .map(|entry| entry.image_handle.clone()) + .unwrap_or_else(|| event.handle.clone()); + // Update ImageNode if let Ok(mut image_node) = image_node_query.get_mut(entity) { image_node.image = preview_image; diff --git a/crates/bevy_asset_preview/tests/workflow_test.rs b/crates/bevy_asset_preview/tests/workflow_test.rs index 8984381a..f45ff9c8 100644 --- a/crates/bevy_asset_preview/tests/workflow_test.rs +++ b/crates/bevy_asset_preview/tests/workflow_test.rs @@ -12,7 +12,8 @@ use bevy::{ }; use bevy_asset_preview::{ ActiveSaveTask, AssetHotReloaded, AssetLoadCompleted, AssetLoadFailed, AssetLoader, - PreviewCache, SaveCompleted, SaveTaskTracker, monitor_save_completion, save_image, + PreviewCache, PreviewConfig, SaveCompleted, SaveTaskTracker, monitor_save_completion, + save_image, }; use tempfile::TempDir; @@ -86,7 +87,7 @@ fn wait_for_save_completion( iterations += 1; let world = app.world(); - let save_events = world.resource::>(); + let save_events = world.resource::>(); let mut cursor = save_events.get_cursor(); for event in cursor.read(save_events) { if processed_task_ids.insert(event.task_id) { @@ -159,7 +160,6 @@ fn wait_for_load_completion( (completed_events, max_active_tasks, initial_queue_len) } - struct CleanupState { pending_cleaned: bool, active_task_cleaned: bool, @@ -170,7 +170,9 @@ struct CleanupState { fn test_error_handling_for_nonexistent_file(app: &mut App) { let non_existent_entity = app .world_mut() - .spawn(bevy_asset_preview::PreviewAsset(PathBuf::from("non_existent.png"))) + .spawn(bevy_asset_preview::PreviewAsset(PathBuf::from( + "non_existent.png", + ))) .id(); app.update(); @@ -188,14 +190,15 @@ fn test_error_handling_for_nonexistent_file(app: &mut App) { ); let cleanup_state = wait_for_failure_cleanup(app, non_existent_entity, non_existent_task_id); - verify_failure_cleanup_complete(app, non_existent_entity, non_existent_task_id, &cleanup_state); + verify_failure_cleanup_complete( + app, + non_existent_entity, + non_existent_task_id, + &cleanup_state, + ); } -fn wait_for_failure_cleanup( - app: &mut App, - entity: Entity, - task_id: u64, -) -> CleanupState { +fn wait_for_failure_cleanup(app: &mut App, entity: Entity, task_id: u64) -> CleanupState { let mut iterations = 0; const MAX_ITERATIONS: usize = 2000; @@ -203,14 +206,18 @@ fn wait_for_failure_cleanup( let mut active_task_cleaned = false; let mut task_path_cleaned = false; - let mut active_task_query = app.world_mut().query::<&bevy_asset_preview::ActiveLoadTask>(); + let mut active_task_query = app + .world_mut() + .query::<&bevy_asset_preview::ActiveLoadTask>(); while iterations < MAX_ITERATIONS { app.update(); iterations += 1; let world = app.world(); - pending_cleaned = !world.entity(entity).contains::(); + pending_cleaned = !world + .entity(entity) + .contains::(); let has_active_task_now = active_task_query .iter(world) @@ -239,11 +246,15 @@ fn verify_failure_cleanup_complete( task_id: u64, state: &CleanupState, ) { - let mut active_task_query = app.world_mut().query::<&bevy_asset_preview::ActiveLoadTask>(); + let mut active_task_query = app + .world_mut() + .query::<&bevy_asset_preview::ActiveLoadTask>(); let world = app.world(); assert!( - !world.entity(entity).contains::(), + !world + .entity(entity) + .contains::(), "PendingPreviewLoad MUST be cleaned up (iterations: {})", state.iterations ); @@ -265,7 +276,9 @@ fn verify_failure_cleanup_complete( ); assert!( - !world.entity(entity).contains::(), + !world + .entity(entity) + .contains::(), "PreviewAsset must be cleaned up after failure" ); @@ -288,13 +301,17 @@ fn test_boundary_conditions(app: &mut App) { "Empty path should have ImageNode (placeholder)" ); assert!( - !world.entity(empty_path_entity).contains::(), + !world + .entity(empty_path_entity) + .contains::(), "Empty path should not have PendingPreviewLoad" ); let special_char_entity = app .world_mut() - .spawn(bevy_asset_preview::PreviewAsset(PathBuf::from("test_file_123.png"))) + .spawn(bevy_asset_preview::PreviewAsset(PathBuf::from( + "test_file_123.png", + ))) .id(); app.update(); @@ -511,14 +528,20 @@ fn test_complete_workflow() { let world = app.world(); let cache = world.resource::(); + let config = world.resource::(); let images = world.resource::>(); assert!(!cache.is_empty(), "Cache should not be empty"); + + // Each image should have previews for all configured resolutions + let expected_cache_entries = image_files.len() * config.resolutions.len(); assert_eq!( cache.len(), + expected_cache_entries, + "Cache should contain exactly {} entries ({} images × {} resolutions)", + expected_cache_entries, image_files.len(), - "Cache should contain exactly {} entries", - image_files.len() + config.resolutions.len() ); for (entity, filename, is_image) in &file_entities { @@ -539,43 +562,77 @@ fn test_complete_workflow() { ); let asset_path: AssetPath<'static> = AssetPath::from(filename.as_str()).into_owned(); - let cache_entry_by_path = cache.get_by_path(&asset_path); - assert!( - cache_entry_by_path.is_some(), - "Image file {} should be cached", - filename - ); - let entry = cache_entry_by_path.unwrap(); - let cache_entry_by_id = cache.get_by_id(entry.asset_id); + // Check that all configured resolutions are cached + for &resolution in &config.resolutions { + let cache_entry_by_path = cache.get_by_path(&asset_path, Some(resolution)); + assert!( + cache_entry_by_path.is_some(), + "Image file {} should have {}px resolution cached", + filename, + resolution + ); + + let entry = cache_entry_by_path.unwrap(); + assert_eq!( + entry.resolution, resolution, + "Cached entry should have correct resolution {} for {}", + resolution, filename + ); + + let cache_entry_by_id = cache.get_by_id(entry.asset_id, Some(resolution)); + assert!( + cache_entry_by_id.is_some(), + "Cache entry should be accessible by ID for {} at {}px", + filename, + resolution + ); + assert_eq!( + entry.image_handle.id(), + cache_entry_by_id.unwrap().image_handle.id(), + "Cache entries by path and ID should match for {} at {}px", + filename, + resolution + ); + } + + // Check that highest resolution query works + let highest_entry = cache.get_by_path(&asset_path, None); assert!( - cache_entry_by_id.is_some(), - "Cache entry should be accessible by ID for {}", + highest_entry.is_some(), + "Image file {} should have highest resolution cached", filename ); + let highest_entry = highest_entry.unwrap(); + let highest_resolution = highest_entry.resolution; + let expected_highest = *config.resolutions.iter().max().unwrap(); assert_eq!( - entry.image_handle.id(), - cache_entry_by_id.unwrap().image_handle.id(), - "Cache entries by path and ID should match for {}", + highest_resolution, + expected_highest, + "Highest resolution should be {} for {}", + expected_highest, filename ); + // Validate highest resolution entry properties assert!( - !entry.timestamp.is_zero(), + !highest_entry.timestamp.is_zero(), "Cache entry for {} should have valid timestamp, got: {:?}", filename, - entry.timestamp + highest_entry.timestamp ); assert!( - entry.image_handle.is_strong(), + highest_entry.image_handle.is_strong(), "Cache entry for {} should have strong handle", filename ); + // Check compression for large images (using highest resolution) if filename == "texture.png" || filename == "sprite.png" { - if let Some(preview_image) = images.get(&entry.image_handle) { + if let Some(preview_image) = images.get(&highest_entry.image_handle) { + let max_dimension = expected_highest; assert!( - preview_image.width() <= 256 && preview_image.height() <= 256, + preview_image.width() <= max_dimension && preview_image.height() <= max_dimension, "Large image {} should be compressed, got {}x{}", filename, preview_image.width(), @@ -584,11 +641,12 @@ fn test_complete_workflow() { } } + // Check aspect ratio for wide image (using highest resolution) if filename == "sprite.png" { - if let Some(preview_image) = images.get(&entry.image_handle) { - // Original: 800x200 = 4:1, compressed should be 256:64 - let expected_height = (200.0 * 256.0 / 800.0) as u32; - assert_eq!(preview_image.width(), 256, "Wide image width should be 256"); + if let Some(preview_image) = images.get(&highest_entry.image_handle) { + // Original: 800x200 = 4:1, compressed should maintain aspect ratio + let expected_height = (200.0 * expected_highest as f32 / 800.0) as u32; + assert_eq!(preview_image.width(), expected_highest, "Wide image width should be {}", expected_highest); assert_eq!( preview_image.height(), expected_height, @@ -696,21 +754,26 @@ fn test_complete_workflow() { .query::<&bevy_asset_preview::ActiveLoadTask>(); let world = app.world(); let cache = world.resource::(); + let config = world.resource::(); let loader = world.resource::(); assert!(!cache.is_empty(), "Cache should not be empty"); + // Each image should have previews for all configured resolutions + let expected_cache_entries = image_files.len() * config.resolutions.len(); assert_eq!( cache.len(), + expected_cache_entries, + "Cache should contain all {} image previews ({} images × {} resolutions)", + expected_cache_entries, image_files.len(), - "Cache should contain all {} image previews", - image_files.len() + config.resolutions.len() ); let non_existent_active_tasks = active_task_query .iter(world) .filter(|active_task| active_task.path.to_string().contains("non_existent")) .count(); - + assert_eq!( non_existent_active_tasks, 0, "All non-existent file tasks must be cleaned up (found {} remaining)", @@ -719,13 +782,13 @@ fn test_complete_workflow() { let actual_active_tasks = loader.active_tasks(); let actual_queue_len = loader.queue_len(); - + assert_eq!( actual_active_tasks, 0, "All active tasks must be cleaned up after failure handling (found {} remaining)", actual_active_tasks ); - + assert!( actual_queue_len <= 1, "Queue should have at most 1 task, found {}", From 63c6812c51963101a52f7cdcd10acabf2d7190e2 Mon Sep 17 00:00:00 2001 From: Sieluna Date: Fri, 9 Jan 2026 00:24:24 +0200 Subject: [PATCH 7/8] Start implement model preview --- crates/bevy_asset_preview/Cargo.toml | 2 + crates/bevy_asset_preview/src/lib.rs | 32 ++ .../bevy_asset_preview/src/preview/cache.rs | 23 +- .../bevy_asset_preview/src/preview/image.rs | 343 ++++++++++++++++++ crates/bevy_asset_preview/src/preview/mod.rs | 230 ++++-------- .../bevy_asset_preview/src/preview/model.rs | 267 ++++++++++++++ .../src/preview/renderer.rs | 189 ++++++++++ .../bevy_asset_preview/src/preview/systems.rs | 254 ------------- crates/bevy_asset_preview/src/preview/task.rs | 82 +++++ crates/bevy_asset_preview/src/ui/mod.rs | 174 +++++++-- .../bevy_asset_preview/tests/workflow_test.rs | 214 ++++++++++- 11 files changed, 1343 insertions(+), 467 deletions(-) create mode 100644 crates/bevy_asset_preview/src/preview/image.rs create mode 100644 crates/bevy_asset_preview/src/preview/model.rs create mode 100644 crates/bevy_asset_preview/src/preview/renderer.rs delete mode 100644 crates/bevy_asset_preview/src/preview/systems.rs create mode 100644 crates/bevy_asset_preview/src/preview/task.rs diff --git a/crates/bevy_asset_preview/Cargo.toml b/crates/bevy_asset_preview/Cargo.toml index 99901e55..ed643a2e 100644 --- a/crates/bevy_asset_preview/Cargo.toml +++ b/crates/bevy_asset_preview/Cargo.toml @@ -9,3 +9,5 @@ image = "0.25" [dev-dependencies] tempfile = "3" +gltf = "1" +bytemuck = "1" diff --git a/crates/bevy_asset_preview/src/lib.rs b/crates/bevy_asset_preview/src/lib.rs index fa0d993f..e891176f 100644 --- a/crates/bevy_asset_preview/src/lib.rs +++ b/crates/bevy_asset_preview/src/lib.rs @@ -31,11 +31,14 @@ impl Plugin for AssetPreviewPlugin { app.init_resource::(); app.init_resource::(); app.init_resource::(); + app.init_resource::(); // Register events app.add_event::(); app.add_event::(); app.add_event::(); + app.add_event::(); + app.add_event::(); // Register systems // Process preview requests and submit to loader @@ -57,5 +60,34 @@ impl Plugin for AssetPreviewPlugin { ) .after(asset::handle_asset_events), ); + + // Handle image preview events + app.add_systems(Update, preview::handle_image_preview_events); + + // Process 3D preview requests + app.add_systems( + Update, + ( + preview::process_3d_preview_requests, + preview::wait_for_asset_load, + ) + .chain(), + ); + + // Capture screenshots for image previews + app.add_systems(Update, preview::capture_preview_screenshot); + + // Handle screenshot events (currently placeholder - requires observers for EntityEvent) + // app.add_systems(Update, preview::handle_preview_screenshots); + + // Update entity preview camera (temporarily disabled) + // app.add_systems( + // Update, + // ( + // preview::update_preview_camera, + // preview::update_preview_camera_bounds, + // ) + // .chain(), + // ); } } diff --git a/crates/bevy_asset_preview/src/preview/cache.rs b/crates/bevy_asset_preview/src/preview/cache.rs index 1b552829..eca4f6d0 100644 --- a/crates/bevy_asset_preview/src/preview/cache.rs +++ b/crates/bevy_asset_preview/src/preview/cache.rs @@ -3,10 +3,10 @@ use core::time::Duration; use std::path::Path; use bevy::{ - asset::{AssetId, AssetPath}, + asset::{AssetPath, UntypedAssetId}, image::Image, platform::collections::HashMap, - prelude::{Handle, Resource}, + prelude::*, }; /// Cache entry for a preview image at a specific resolution. @@ -15,7 +15,7 @@ pub struct PreviewCacheEntry { /// The preview image handle. pub image_handle: Handle, /// The asset ID that this preview is for. - pub asset_id: AssetId, + pub asset_id: UntypedAssetId, /// Resolution in pixels (max dimension). pub resolution: u32, /// Timestamp for cache invalidation. @@ -28,7 +28,7 @@ pub struct PreviewCache { /// Maps asset path with resolution suffix to cache entry. path_cache: HashMap, PreviewCacheEntry>, /// Maps asset ID to all resolutions for efficient lookup and cleanup. - id_to_paths: HashMap, Vec>>, + id_to_paths: HashMap>>, } impl PreviewCache { @@ -194,7 +194,7 @@ impl PreviewCache { /// Returns highest resolution if resolution is None. pub fn get_by_id( &self, - asset_id: AssetId, + asset_id: UntypedAssetId, resolution: Option, ) -> Option<&PreviewCacheEntry> { let paths = self.id_to_paths.get(&asset_id)?; @@ -229,12 +229,13 @@ impl PreviewCache { pub fn insert<'a>( &mut self, path: impl Into>, - asset_id: AssetId, + asset_id: impl Into, resolution: u32, image_handle: Handle, timestamp: Duration, ) { let path: AssetPath<'static> = path.into().into_owned(); + let asset_id = asset_id.into(); let cache_path = Self::cache_path_for_resolution(&path, resolution); let entry = PreviewCacheEntry { @@ -314,7 +315,7 @@ impl PreviewCache { /// Removes a path from an asset ID's path list. fn remove_path_from_id_mapping( &mut self, - asset_id: &AssetId, + asset_id: &UntypedAssetId, cache_path: &AssetPath<'static>, ) { if let Some(paths) = self.id_to_paths.get_mut(asset_id) { @@ -328,7 +329,7 @@ impl PreviewCache { /// Cleans up ID mapping for a removed asset. fn cleanup_id_mapping( &mut self, - asset_id: &AssetId, + asset_id: &UntypedAssetId, original_path: &AssetPath<'static>, ) { if let Some(paths) = self.id_to_paths.get_mut(asset_id) { @@ -349,7 +350,7 @@ impl PreviewCache { /// Removes all resolutions if resolution is None. pub fn remove_by_id( &mut self, - asset_id: AssetId, + asset_id: UntypedAssetId, resolution: Option, ) -> Option { let paths = self.id_to_paths.get(&asset_id)?.clone(); @@ -493,8 +494,8 @@ mod tests { .resource_scope(|_world, mut images: Mut>| { let h1 = create_test_image(&mut images, 64, 64, [255, 0, 0, 255]); let h2 = create_test_image(&mut images, 64, 64, [0, 255, 0, 255]); - let id1 = h1.id(); - let id2 = h2.id(); + let id1 = h1.id().untyped(); + let id2 = h2.id().untyped(); (h1, h2, id1, id2) }); diff --git a/crates/bevy_asset_preview/src/preview/image.rs b/crates/bevy_asset_preview/src/preview/image.rs new file mode 100644 index 00000000..163c22f4 --- /dev/null +++ b/crates/bevy_asset_preview/src/preview/image.rs @@ -0,0 +1,343 @@ +use core::time::Duration; + +use bevy::{ + asset::{AssetEvent, AssetPath, AssetServer, RenderAssetUsages, UntypedAssetId}, + ecs::event::{EventReader, EventWriter}, + image::Image, + prelude::*, +}; + +use crate::preview::{ + PreviewConfig, PreviewMode, PreviewRequestType, PreviewScene3D, + cache::PreviewCache, + task::{PendingPreviewRequest, PreviewFailed, PreviewReady, PreviewTaskManager}, +}; + +/// Generates preview images for the specified resolutions and caches them. +pub fn generate_previews_for_resolutions( + images: &mut Assets, + original_image: &Image, + original_handle: Handle, + path: &AssetPath<'static>, + asset_id: UntypedAssetId, + resolutions: &[u32], + cache: &mut PreviewCache, + timestamp: Duration, +) { + for &resolution in resolutions { + if cache.get_by_path(path, Some(resolution)).is_some() { + continue; + } + + let preview_handle = match resize_image_for_preview(original_image, resolution) { + Some(compressed) => images.add(compressed), + None => original_handle.clone(), + }; + + cache.insert(path, asset_id, resolution, preview_handle, timestamp); + } +} + +/// Requests a preview for an image asset path. +/// Returns the task ID for tracking. +/// Uses highest configured resolution if resolution is None. +pub fn request_image_preview<'a>( + mut commands: Commands, + mut task_manager: ResMut, + mut cache: ResMut, + config: Res, + asset_server: Res, + mut images: ResMut>, + mut preview_ready_events: EventWriter, + time: Res>, + path: impl Into>, + resolution: Option, +) -> u64 { + let path: AssetPath<'static> = path.into().into_owned(); + + if let Some(cache_entry) = cache.get_by_path(&path, resolution) { + let task_id = task_manager.create_task_id(); + preview_ready_events.write(PreviewReady { + task_id, + path: path.clone(), + image_handle: cache_entry.image_handle.clone(), + }); + return task_id; + } + + let task_id = task_manager.create_task_id(); + let handle: Handle = asset_server.load(&path); + + if let Some(image) = images.get(&handle) { + let image_clone = image.clone(); + let asset_id = handle.id().untyped(); + + generate_previews_for_resolutions( + &mut images, + &image_clone, + handle.clone(), + &path, + asset_id, + &config.resolutions, + &mut cache, + time.elapsed(), + ); + + let preview_handle = cache + .get_by_path(&path, resolution) + .map(|entry| entry.image_handle.clone()) + .unwrap_or_else(|| handle.clone()); + + preview_ready_events.write(PreviewReady { + task_id, + path: path.clone(), + image_handle: preview_handle, + }); + return task_id; + } + + let entity = commands + .spawn(PendingPreviewRequest { + task_id, + path: path.clone(), + request_type: PreviewRequestType::Image2D, + mode: PreviewMode::Image, + }) + .id(); + task_manager.register_task(task_id, entity); + task_id +} + +/// System that handles image asset events for previews. +pub fn handle_image_preview_events( + mut commands: Commands, + mut cache: ResMut, + config: Res, + asset_server: Res, + mut preview_ready_events: EventWriter, + mut preview_failed_events: EventWriter, + mut asset_events: EventReader>, + mut images: ResMut>, + requests: Query<(Entity, &PendingPreviewRequest), Without>, + mut task_manager: ResMut, + time: Res>, +) { + for event in asset_events.read() { + match event { + AssetEvent::LoadedWithDependencies { id } => { + for (entity, request) in requests.iter() { + let handle: Handle = asset_server.load(&request.path); + if handle.id() == *id { + if let Some(image) = images.get(&handle) { + let image_clone = image.clone(); + let asset_id = handle.id().untyped(); + + generate_previews_for_resolutions( + &mut images, + &image_clone, + handle.clone(), + &request.path, + asset_id, + &config.resolutions, + &mut cache, + time.elapsed(), + ); + + let preview_image = cache + .get_by_path(&request.path, None) + .map(|entry| entry.image_handle.clone()) + .unwrap_or_else(|| handle.clone()); + + preview_ready_events.write(PreviewReady { + task_id: request.task_id, + path: request.path.clone(), + image_handle: preview_image, + }); + + task_manager.remove_task(request.task_id); + commands.entity(entity).despawn(); + } + } + } + } + AssetEvent::Removed { id } => { + cache.remove_by_id(id.untyped(), None); + + for (entity, request) in requests.iter() { + let handle: Handle = asset_server.load(&request.path); + if handle.id() == *id { + preview_failed_events.write(PreviewFailed { + task_id: request.task_id, + path: request.path.clone(), + error: "Image asset was removed".to_string(), + }); + task_manager.remove_task(request.task_id); + commands.entity(entity).despawn(); + } + } + } + _ => {} + } + } +} + +/// Resizes an image to a specific preview size. +/// Returns None if the image is already small enough. +pub fn resize_image_for_preview(image: &Image, target_size: u32) -> Option { + let width = image.width(); + let height = image.height(); + + // If image is already small enough, return None (use original) + if width <= target_size && height <= target_size { + return None; + } + + // Calculate new size maintaining aspect ratio + let (new_width, new_height) = if width > height { + ( + target_size, + (height as f32 * target_size as f32 / width as f32) as u32, + ) + } else { + ( + (width as f32 * target_size as f32 / height as f32) as u32, + target_size, + ) + }; + + // Convert to dynamic image for resizing + let dynamic_image = match image.clone().try_into_dynamic() { + Ok(img) => img, + Err(_) => return None, + }; + + // Resize using high-quality filter + let resized = + dynamic_image.resize_exact(new_width, new_height, image::imageops::FilterType::Lanczos3); + + // Convert back to Image + Some(Image::from_dynamic( + resized, + true, // is_srgb + RenderAssetUsages::RENDER_WORLD, + )) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use bevy::{ + asset::AssetPlugin, + prelude::*, + render::{ + render_asset::RenderAssetUsages, + render_resource::{Extent3d, TextureDimension, TextureFormat}, + }, + }; + use tempfile::TempDir; + + use super::*; + + fn create_test_image( + images: &mut Assets, + width: u32, + height: u32, + color: [u8; 4], + ) -> Handle { + let pixel_data: Vec = (0..(width * height)) + .flat_map(|_| color.iter().copied()) + .collect(); + + let image = Image::new_fill( + Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + &pixel_data, + TextureFormat::Rgba8UnormSrgb, + RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD, + ); + + images.add(image) + } + + #[test] + fn test_image_compression() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let assets_dir = temp_dir.path().join("assets"); + fs::create_dir_all(&assets_dir).expect("Failed to create assets directory"); + + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(AssetPlugin { + file_path: assets_dir.display().to_string(), + ..Default::default() + }) + .init_asset::(); + + let mut images = app.world_mut().resource_mut::>(); + + // Test small image (should not be compressed) + let small_handle = create_test_image(&mut images, 64, 64, [128, 128, 128, 255]); + let small_image = images.get(&small_handle).unwrap(); + let compressed_small = resize_image_for_preview(small_image, 256); + assert!( + compressed_small.is_none(), + "Small image should not be compressed" + ); + + // Test large image (should be compressed) + let large_handle = create_test_image(&mut images, 512, 512, [128, 128, 128, 255]); + let large_image = images.get(&large_handle).unwrap(); + let compressed_large = resize_image_for_preview(large_image, 256); + assert!( + compressed_large.is_some(), + "Large image should be compressed" + ); + let compressed = compressed_large.unwrap(); + assert!( + compressed.width() <= 256 && compressed.height() <= 256, + "Compressed image should be <= 256x256, got {}x{}", + compressed.width(), + compressed.height() + ); + + // Test wide image (maintain aspect ratio) + let wide_handle = create_test_image(&mut images, 800, 200, [128, 128, 128, 255]); + let wide_image = images.get(&wide_handle).unwrap(); + let compressed_wide = resize_image_for_preview(wide_image, 256); + assert!(compressed_wide.is_some(), "Wide image should be compressed"); + let compressed = compressed_wide.unwrap(); + assert_eq!(compressed.width(), 256, "Wide image width should be 256"); + assert!( + compressed.height() < 256, + "Wide image height should be < 256" + ); + // Verify aspect ratio: 800/200 = 4:1, after compression should be 256:64 + let expected_height = (200.0 * 256.0 / 800.0) as u32; + assert_eq!( + compressed.height(), + expected_height, + "Wide image should maintain aspect ratio" + ); + + // Test tall image (maintain aspect ratio) + let tall_handle = create_test_image(&mut images, 200, 800, [128, 128, 128, 255]); + let tall_image = images.get(&tall_handle).unwrap(); + let compressed_tall = resize_image_for_preview(tall_image, 256); + assert!(compressed_tall.is_some(), "Tall image should be compressed"); + let compressed = compressed_tall.unwrap(); + assert_eq!(compressed.height(), 256, "Tall image height should be 256"); + assert!(compressed.width() < 256, "Tall image width should be < 256"); + // Verify aspect ratio: 200/800 = 1:4, after compression should be 64:256 + let expected_width = (200.0 * 256.0 / 800.0) as u32; + assert_eq!( + compressed.width(), + expected_width, + "Tall image should maintain aspect ratio" + ); + } +} diff --git a/crates/bevy_asset_preview/src/preview/mod.rs b/crates/bevy_asset_preview/src/preview/mod.rs index 8d3b4770..cdb36d8f 100644 --- a/crates/bevy_asset_preview/src/preview/mod.rs +++ b/crates/bevy_asset_preview/src/preview/mod.rs @@ -1,184 +1,86 @@ mod cache; -mod systems; +mod image; +mod model; +mod renderer; +mod task; +// mod entity_preview; // Temporarily disabled -use bevy::{asset::RenderAssetUsages, image::Image, prelude::*}; +use bevy::{mesh::Mesh, pbr::StandardMaterial, prelude::*, scene::Scene}; + +// Re-export public types and functions pub use cache::{PreviewCache, PreviewCacheEntry}; -pub use systems::{ - PendingPreviewRequest, PreviewFailed, PreviewReady, PreviewTaskManager, +pub use image::{ generate_previews_for_resolutions, handle_image_preview_events, request_image_preview, + resize_image_for_preview, +}; +pub use model::{ + PreviewScene3D, WaitingForScreenshot, capture_preview_screenshot, handle_preview_screenshots, + process_3d_preview_requests, wait_for_asset_load, }; +pub use renderer::*; +pub use task::{PendingPreviewRequest, PreviewFailed, PreviewReady, PreviewTaskManager}; +// pub use entity_preview::*; // Temporarily disabled + +/// Preview mode for 3D assets. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PreviewMode { + /// Static image preview (for folder views). + Image, + /// Interactive 3D preview window (like Unity Inspector). + Entity, +} + +/// Model format for 3D model files. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ModelFormat { + /// GLTF format (.gltf, .glb) + Gltf, + /// FBX format (.fbx) + FBX, + /// OBJ format (.obj) + Obj, + /// Other format (specified by string) + Other(String), +} + +/// Type of preview request. +#[derive(Debug, Clone)] +pub enum PreviewRequestType { + /// 2D image preview. + Image2D, + /// Generic model file preview (GLTF, OBJ, FBX, etc. loaded as Scene). + ModelFile { + /// Scene asset handle. + handle: Handle, + /// Model format. + format: ModelFormat, + }, + /// Material preview (on a sphere). + Material { + /// Material asset handle. + handle: Handle, + }, + /// Mesh preview. + Mesh { + /// Mesh asset handle. + handle: Handle, + }, +} /// Configuration for preview generation. #[derive(Resource, Debug, Clone)] pub struct PreviewConfig { /// Resolutions to generate previews for (in pixels). Default: [64, 256] pub resolutions: Vec, + /// Render layer for preview scene isolation. Default: 1 + pub render_layer: usize, } impl Default for PreviewConfig { fn default() -> Self { Self { resolutions: vec![64, 256], + render_layer: 1, } } } -/// Resizes an image to a specific preview size. -/// Returns None if the image is already small enough. -pub fn resize_image_for_preview(image: &Image, target_size: u32) -> Option { - let width = image.width(); - let height = image.height(); - - // If image is already small enough, return None (use original) - if width <= target_size && height <= target_size { - return None; - } - - // Calculate new size maintaining aspect ratio - let (new_width, new_height) = if width > height { - ( - target_size, - (height as f32 * target_size as f32 / width as f32) as u32, - ) - } else { - ( - (width as f32 * target_size as f32 / height as f32) as u32, - target_size, - ) - }; - - // Convert to dynamic image for resizing - let dynamic_image = match image.clone().try_into_dynamic() { - Ok(img) => img, - Err(_) => return None, - }; - - // Resize using high-quality filter - let resized = - dynamic_image.resize_exact(new_width, new_height, image::imageops::FilterType::Lanczos3); - - // Convert back to Image - Some(Image::from_dynamic( - resized, - true, // is_srgb - RenderAssetUsages::RENDER_WORLD, - )) -} - -#[cfg(test)] -mod tests { - use std::fs; - - use bevy::{ - asset::AssetPlugin, - prelude::*, - render::{ - render_asset::RenderAssetUsages, - render_resource::{Extent3d, TextureDimension, TextureFormat}, - }, - }; - use tempfile::TempDir; - - use super::*; - - fn create_test_image( - images: &mut Assets, - width: u32, - height: u32, - color: [u8; 4], - ) -> Handle { - let pixel_data: Vec = (0..(width * height)) - .flat_map(|_| color.iter().copied()) - .collect(); - - let image = Image::new_fill( - Extent3d { - width, - height, - depth_or_array_layers: 1, - }, - TextureDimension::D2, - &pixel_data, - TextureFormat::Rgba8UnormSrgb, - RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD, - ); - - images.add(image) - } - - #[test] - fn test_image_compression() { - let temp_dir = TempDir::new().expect("Failed to create temp directory"); - let assets_dir = temp_dir.path().join("assets"); - fs::create_dir_all(&assets_dir).expect("Failed to create assets directory"); - - let mut app = App::new(); - app.add_plugins(MinimalPlugins) - .add_plugins(AssetPlugin { - file_path: assets_dir.display().to_string(), - ..Default::default() - }) - .init_asset::(); - - let mut images = app.world_mut().resource_mut::>(); - - // Test small image (should not be compressed) - let small_handle = create_test_image(&mut images, 64, 64, [128, 128, 128, 255]); - let small_image = images.get(&small_handle).unwrap(); - let compressed_small = resize_image_for_preview(small_image, 256); - assert!( - compressed_small.is_none(), - "Small image should not be compressed" - ); - - // Test large image (should be compressed) - let large_handle = create_test_image(&mut images, 512, 512, [128, 128, 128, 255]); - let large_image = images.get(&large_handle).unwrap(); - let compressed_large = resize_image_for_preview(large_image, 256); - assert!( - compressed_large.is_some(), - "Large image should be compressed" - ); - let compressed = compressed_large.unwrap(); - assert!( - compressed.width() <= 256 && compressed.height() <= 256, - "Compressed image should be <= 256x256, got {}x{}", - compressed.width(), - compressed.height() - ); - - // Test wide image (maintain aspect ratio) - let wide_handle = create_test_image(&mut images, 800, 200, [128, 128, 128, 255]); - let wide_image = images.get(&wide_handle).unwrap(); - let compressed_wide = resize_image_for_preview(wide_image, 256); - assert!(compressed_wide.is_some(), "Wide image should be compressed"); - let compressed = compressed_wide.unwrap(); - assert_eq!(compressed.width(), 256, "Wide image width should be 256"); - assert!( - compressed.height() < 256, - "Wide image height should be < 256" - ); - // Verify aspect ratio: 800/200 = 4:1, after compression should be 256:64 - let expected_height = (200.0 * 256.0 / 800.0) as u32; - assert_eq!( - compressed.height(), - expected_height, - "Wide image should maintain aspect ratio" - ); - - // Test tall image (maintain aspect ratio) - let tall_handle = create_test_image(&mut images, 200, 800, [128, 128, 128, 255]); - let tall_image = images.get(&tall_handle).unwrap(); - let compressed_tall = resize_image_for_preview(tall_image, 256); - assert!(compressed_tall.is_some(), "Tall image should be compressed"); - let compressed = compressed_tall.unwrap(); - assert_eq!(compressed.height(), 256, "Tall image height should be 256"); - assert!(compressed.width() < 256, "Tall image width should be < 256"); - // Verify aspect ratio: 200/800 = 1:4, after compression should be 64:256 - let expected_width = (200.0 * 256.0 / 800.0) as u32; - assert_eq!( - compressed.width(), - expected_width, - "Tall image should maintain aspect ratio" - ); - } -} diff --git a/crates/bevy_asset_preview/src/preview/model.rs b/crates/bevy_asset_preview/src/preview/model.rs new file mode 100644 index 00000000..20ddf76c --- /dev/null +++ b/crates/bevy_asset_preview/src/preview/model.rs @@ -0,0 +1,267 @@ +use bevy::{ + asset::AssetPath, image::Image, mesh::Mesh, pbr::StandardMaterial, prelude::*, + render::view::screenshot::Screenshot, scene::Scene, +}; + +use crate::preview::{ + PreviewConfig, PreviewMode, PreviewRequestType, + cache::PreviewCache, + renderer::*, + task::{PendingPreviewRequest, PreviewReady, PreviewTaskManager}, +}; + +/// Component marking a 3D preview scene that's being set up. +#[derive(Component, Debug)] +pub struct PreviewScene3D { + /// Task ID. + pub task_id: u64, + /// Asset path. + pub path: AssetPath<'static>, + /// Camera entity. + pub camera_entity: Entity, + /// Render target image handle. + pub render_target: Handle, + /// Preview entity (GLTF scene, mesh, etc.). + pub preview_entity: Option, +} + +/// Component marking that we're waiting for a screenshot. +#[derive(Component, Debug)] +pub struct WaitingForScreenshot { + /// Task ID. + pub task_id: u64, + /// Asset path. + pub path: AssetPath<'static>, +} + +/// Processes 3D preview requests and sets up preview scenes. +pub fn process_3d_preview_requests( + mut commands: Commands, + mut images: ResMut>, + mut meshes: ResMut>, + mut materials: ResMut>, + mut task_manager: ResMut, + cache: ResMut, + config: Res, + _asset_server: Res, + scene_assets: Res>, + requests: Query< + (Entity, &PendingPreviewRequest), + ( + Added, + Without, + Without, + ), + >, + mut preview_ready_events: EventWriter, +) { + for (entity, request) in requests.iter() { + // Only process 3D requests + match &request.request_type { + PreviewRequestType::Image2D => continue, + _ => {} + } + + // Check cache first (for image mode) + let preview_resolution = config.resolutions.iter().max().copied().unwrap_or(256); + if request.mode == PreviewMode::Image { + if let Some(cache_entry) = cache.get_by_path(&request.path, Some(preview_resolution)) { + preview_ready_events.write(PreviewReady { + task_id: request.task_id, + path: request.path.clone(), + image_handle: cache_entry.image_handle.clone(), + }); + task_manager.remove_task(request.task_id); + commands.entity(entity).despawn(); + continue; + } + } + + // Create preview scene using the highest resolution from config + let (camera_entity, render_target, _) = + create_preview_scene(&mut commands, &mut images, &config, preview_resolution); + + // Spawn preview entity based on request type + let preview_entity = match &request.request_type { + PreviewRequestType::ModelFile { handle, .. } => { + // Direct Scene loading (GLTF, OBJ, FBX, etc.) + if scene_assets.get(handle).is_some() { + Some(spawn_model_scene_preview( + &mut commands, + handle.clone(), + config.render_layer, + )) + } else { + // Asset not loaded yet, wait for it + commands.entity(entity).insert(PreviewScene3D { + task_id: request.task_id, + path: request.path.clone(), + camera_entity, + render_target: render_target.clone(), + preview_entity: None, + }); + continue; + } + } + PreviewRequestType::Material { handle } => { + if materials.get(handle).is_some() { + Some(spawn_material_preview( + &mut commands, + &mut meshes, + handle.clone(), + config.render_layer, + )) + } else { + commands.entity(entity).insert(PreviewScene3D { + task_id: request.task_id, + path: request.path.clone(), + camera_entity, + render_target: render_target.clone(), + preview_entity: None, + }); + continue; + } + } + PreviewRequestType::Mesh { handle } => { + if meshes.get(handle).is_some() { + Some(spawn_mesh_preview( + &mut commands, + &mut materials, + handle.clone(), + config.render_layer, + )) + } else { + commands.entity(entity).insert(PreviewScene3D { + task_id: request.task_id, + path: request.path.clone(), + camera_entity, + render_target: render_target.clone(), + preview_entity: None, + }); + continue; + } + } + PreviewRequestType::Image2D => unreachable!(), + }; + + // Update preview scene component + commands.entity(entity).insert(PreviewScene3D { + task_id: request.task_id, + path: request.path.clone(), + camera_entity, + render_target: render_target.clone(), + preview_entity, + }); + + // For image mode, wait a frame then capture screenshot + if request.mode == PreviewMode::Image { + commands.entity(entity).insert(WaitingForScreenshot { + task_id: request.task_id, + path: request.path.clone(), + }); + } + } +} + +/// Waits for assets to load and updates preview scenes. +pub fn wait_for_asset_load( + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, + config: Res, + scene_assets: Res>, + mut preview_scenes: Query<(Entity, &mut PreviewScene3D, &PendingPreviewRequest)>, +) { + for (entity, mut scene, request) in preview_scenes.iter_mut() { + if scene.preview_entity.is_some() { + continue; // Already spawned + } + + let preview_entity = match &request.request_type { + PreviewRequestType::ModelFile { handle, .. } => { + // Direct Scene loading (GLTF, OBJ, FBX, etc.) + if scene_assets.get(handle).is_some() { + Some(spawn_model_scene_preview( + &mut commands, + handle.clone(), + config.render_layer, + )) + } else { + continue; + } + } + PreviewRequestType::Material { handle } => { + if materials.get(handle).is_some() { + Some(spawn_material_preview( + &mut commands, + &mut meshes, + handle.clone(), + config.render_layer, + )) + } else { + continue; + } + } + PreviewRequestType::Mesh { handle } => { + if meshes.get(handle).is_some() { + Some(spawn_mesh_preview( + &mut commands, + &mut materials, + handle.clone(), + config.render_layer, + )) + } else { + continue; + } + } + PreviewRequestType::Image2D => unreachable!(), + }; + + scene.preview_entity = preview_entity; + + // If image mode, mark for screenshot + if request.mode == PreviewMode::Image { + commands.entity(entity).insert(WaitingForScreenshot { + task_id: request.task_id, + path: request.path.clone(), + }); + } + } +} + +/// Captures screenshots for image preview mode. +pub fn capture_preview_screenshot( + mut commands: Commands, + preview_scenes: Query< + (Entity, &PreviewScene3D, &WaitingForScreenshot), + Added, + >, +) { + for (entity, scene, _waiting) in preview_scenes.iter() { + // Spawn screenshot component on camera + commands + .entity(scene.camera_entity) + .insert(Screenshot::image(scene.render_target.clone())); + + // Remove waiting component (screenshot will be handled by event) + commands.entity(entity).remove::(); + } +} + +/// Handles screenshot capture events and caches previews. +/// Note: ScreenshotCaptured is an EntityEvent, which requires observers. +/// This is a simplified placeholder - proper implementation would use observers. +pub fn handle_preview_screenshots( + _commands: Commands, + _images: ResMut>, + _cache: ResMut, + _preview_ready_events: EventWriter, + _task_manager: ResMut, + _preview_scenes: Query<(Entity, &PreviewScene3D)>, + _time: Res>, + // Note: ScreenshotCaptured is an EntityEvent, not a regular event + // We'll need to handle it via observers in the future +) { + // Placeholder: In a real implementation, we'd use observers to listen for ScreenshotCaptured + // For now, this system is registered but won't process events until observers are set up +} diff --git a/crates/bevy_asset_preview/src/preview/renderer.rs b/crates/bevy_asset_preview/src/preview/renderer.rs new file mode 100644 index 00000000..ed4a9764 --- /dev/null +++ b/crates/bevy_asset_preview/src/preview/renderer.rs @@ -0,0 +1,189 @@ +use bevy::{ + camera::{ + Camera3d, RenderTarget, + primitives::{Aabb, Sphere as CameraSphere}, + visibility::RenderLayers, + }, + image::Image, + light::{AmbientLight, DirectionalLight}, + math::{Vec3A, primitives::Sphere}, + mesh::{Mesh, Mesh3d}, + pbr::{MeshMaterial3d, StandardMaterial}, + prelude::*, + render::render_resource::TextureFormat, + scene::SceneRoot, +}; + +use crate::preview::PreviewConfig; + +/// Creates a render target texture for preview rendering. +pub fn setup_render_target(images: &mut Assets, resolution: u32) -> Handle { + let image = Image::new_target_texture(resolution, resolution, TextureFormat::bevy_default()); + images.add(image) +} + +/// Creates preview camera that renders to the specified target. +pub fn create_preview_camera( + commands: &mut Commands, + render_target: Handle, + render_layer: usize, +) -> Entity { + let render_layers = RenderLayers::layer(render_layer); + + commands + .spawn(( + Camera3d::default(), + Camera { + target: RenderTarget::Image(render_target.into()), + order: -1, // Render before main camera + ..default() + }, + render_layers, + )) + .id() +} + +/// Creates preview lights (ambient + directional). +pub fn create_preview_lights(commands: &mut Commands, render_layer: usize) { + let render_layers = RenderLayers::layer(render_layer); + + // Ambient light + commands.spawn(( + AmbientLight { + brightness: 0.3, + ..default() + }, + render_layers.clone(), + )); + + // Directional light + commands.spawn(( + DirectionalLight { + illuminance: 10000.0, + shadows_enabled: false, + ..default() + }, + Transform::from_rotation(Quat::from_euler(EulerRot::XYZ, -0.5, -0.5, 0.0)), + render_layers, + )); +} + +/// Calculates the bounding box of entities in a query. +pub fn calculate_bounds( + query: &Query<(&GlobalTransform, Option<&Aabb>), (With, Without)>, +) -> Option { + if query.iter().any(|(_, maybe_aabb)| maybe_aabb.is_none()) { + return None; + } + + let mut min = Vec3A::splat(f32::MAX); + let mut max = Vec3A::splat(f32::MIN); + let mut has_any = false; + + for (transform, maybe_aabb) in query.iter() { + let Some(aabb) = maybe_aabb else { + continue; + }; + + has_any = true; + + let sphere = CameraSphere { + center: Vec3A::from(transform.transform_point(Vec3::from(aabb.center))), + radius: transform.radius_vec3a(aabb.half_extents), + }; + let transformed_aabb = Aabb::from(sphere); + min = min.min(transformed_aabb.min()); + max = max.max(transformed_aabb.max()); + } + + if has_any { + Some(Aabb::from_min_max(Vec3::from(min), Vec3::from(max))) + } else { + None + } +} + +/// Positions camera to view the entire bounding box. +pub fn position_camera_for_bounds(camera_transform: &mut Transform, bounds: &Aabb) { + let center = Vec3::from(bounds.center); + let size = (Vec3::from(bounds.max()) - Vec3::from(bounds.min())).length(); + + camera_transform.translation = center + size * Vec3::new(0.5, 0.25, 0.5); + camera_transform.look_at(center, Vec3::Y); +} + +/// Spawns a model scene preview entity (for GLTF, OBJ, FBX, etc.). +pub fn spawn_model_scene_preview( + commands: &mut Commands, + scene_handle: Handle, + render_layer: usize, +) -> Entity { + let render_layers = RenderLayers::layer(render_layer); + + commands + .spawn((SceneRoot(scene_handle), render_layers)) + .id() +} + +/// Spawns a material preview entity (sphere with material). +pub fn spawn_material_preview( + commands: &mut Commands, + meshes: &mut Assets, + material_handle: Handle, + render_layer: usize, +) -> Entity { + let render_layers = RenderLayers::layer(render_layer); + + // Create a sphere mesh for material preview + let sphere_mesh = meshes.add(Sphere::default().mesh().uv(32, 18)); + + commands + .spawn(( + Mesh3d(sphere_mesh), + MeshMaterial3d(material_handle), + Transform::default(), + render_layers, + )) + .id() +} + +/// Spawns a mesh preview entity (mesh with default material). +pub fn spawn_mesh_preview( + commands: &mut Commands, + materials: &mut Assets, + mesh_handle: Handle, + render_layer: usize, +) -> Entity { + let render_layers = RenderLayers::layer(render_layer); + + // Create default material + let default_material = materials.add(StandardMaterial::default()); + + commands + .spawn(( + Mesh3d(mesh_handle), + MeshMaterial3d(default_material), + Transform::default(), + render_layers, + )) + .id() +} + +/// Creates a complete preview scene with camera, lights, and asset. +pub fn create_preview_scene( + commands: &mut Commands, + images: &mut Assets, + config: &PreviewConfig, + resolution: u32, +) -> (Entity, Handle, Entity) { + // Create render target + let render_target = setup_render_target(images, resolution); + + // Create camera + let camera_entity = create_preview_camera(commands, render_target.clone(), config.render_layer); + + // Create lights + create_preview_lights(commands, config.render_layer); + + (camera_entity, render_target, camera_entity) +} diff --git a/crates/bevy_asset_preview/src/preview/systems.rs b/crates/bevy_asset_preview/src/preview/systems.rs deleted file mode 100644 index b59e8f6f..00000000 --- a/crates/bevy_asset_preview/src/preview/systems.rs +++ /dev/null @@ -1,254 +0,0 @@ -use core::time::Duration; - -use bevy::{ - asset::{AssetEvent, AssetPath, AssetServer}, - ecs::event::{BufferedEvent, EventReader, EventWriter}, - image::Image, - platform::collections::HashMap, - prelude::*, -}; - -use crate::preview::{PreviewCache, PreviewConfig, resize_image_for_preview}; - -/// Generates preview images for the specified resolutions and caches them. -pub fn generate_previews_for_resolutions( - images: &mut Assets, - original_image: &Image, - original_handle: Handle, - path: &AssetPath<'static>, - asset_id: AssetId, - resolutions: &[u32], - cache: &mut PreviewCache, - timestamp: Duration, -) { - for &resolution in resolutions { - if cache.get_by_path(path, Some(resolution)).is_some() { - continue; - } - - let preview_handle = match resize_image_for_preview(original_image, resolution) { - Some(compressed) => images.add(compressed), - None => original_handle.clone(), - }; - - cache.insert(path, asset_id, resolution, preview_handle, timestamp); - } -} - -/// Event emitted when a preview is ready. -#[derive(Event, BufferedEvent, Debug, Clone)] -pub struct PreviewReady { - /// Task ID of the preview request. - pub task_id: u64, - /// Asset path. - pub path: AssetPath<'static>, - /// Preview image handle. - pub image_handle: Handle, -} - -/// Event emitted when a preview generation fails. -#[derive(Event, BufferedEvent, Debug, Clone)] -pub struct PreviewFailed { - /// Task ID of the preview request. - pub task_id: u64, - /// Asset path. - pub path: AssetPath<'static>, - /// Error message. - pub error: String, -} - -/// Component that tracks a pending preview request. -#[derive(Component, Debug)] -pub struct PendingPreviewRequest { - /// Task ID. - pub task_id: u64, - /// Asset path. - pub path: AssetPath<'static>, -} - -/// Resource that manages preview generation tasks. -#[derive(Resource, Default)] -pub struct PreviewTaskManager { - /// Next task ID. - next_task_id: u64, - /// Maps task ID to request entity. - task_to_entity: HashMap, -} - -impl PreviewTaskManager { - /// Creates a new task manager. - pub fn new() -> Self { - Self { - next_task_id: 0, - task_to_entity: HashMap::new(), - } - } - - /// Creates a new task ID. - pub fn create_task_id(&mut self) -> u64 { - let id = self.next_task_id; - self.next_task_id += 1; - id - } - - /// Registers a task entity. - pub fn register_task(&mut self, task_id: u64, entity: Entity) { - self.task_to_entity.insert(task_id, entity); - } - - /// Gets entity for a task ID. - pub fn get_entity(&self, task_id: u64) -> Option { - self.task_to_entity.get(&task_id).copied() - } - - /// Removes task registration. - pub fn remove_task(&mut self, task_id: u64) { - self.task_to_entity.remove(&task_id); - } -} - -/// Requests a preview for an image asset path. -/// Returns the task ID for tracking. -/// Uses highest configured resolution if resolution is None. -pub fn request_image_preview<'a>( - mut commands: Commands, - mut task_manager: ResMut, - mut cache: ResMut, - config: Res, - asset_server: Res, - images: Res>, - mut images_mut: ResMut>, - mut preview_ready_events: EventWriter, - time: Res>, - path: impl Into>, - resolution: Option, -) -> u64 { - let path: AssetPath<'static> = path.into().into_owned(); - - if let Some(cache_entry) = cache.get_by_path(&path, resolution) { - let task_id = task_manager.create_task_id(); - preview_ready_events.write(PreviewReady { - task_id, - path: path.clone(), - image_handle: cache_entry.image_handle.clone(), - }); - return task_id; - } - - let task_id = task_manager.create_task_id(); - let handle: Handle = asset_server.load(&path); - - if let Some(image) = images.get(&handle) { - let image_clone = image.clone(); - let asset_id = handle.id(); - - generate_previews_for_resolutions( - &mut images_mut, - &image_clone, - handle.clone(), - &path, - asset_id, - &config.resolutions, - &mut cache, - time.elapsed(), - ); - - let preview_handle = cache - .get_by_path(&path, resolution) - .map(|entry| entry.image_handle.clone()) - .unwrap_or_else(|| handle.clone()); - - preview_ready_events.write(PreviewReady { - task_id, - path: path.clone(), - image_handle: preview_handle, - }); - return task_id; - } - - let entity = commands - .spawn(PendingPreviewRequest { - task_id, - path: path.clone(), - }) - .id(); - task_manager.register_task(task_id, entity); - task_id -} - -/// System that handles image asset events for previews. -pub fn handle_image_preview_events( - mut commands: Commands, - mut cache: ResMut, - config: Res, - asset_server: Res, - mut preview_ready_events: EventWriter, - mut preview_failed_events: EventWriter, - mut asset_events: EventReader>, - mut images: ResMut>, - requests: Query<(Entity, &PendingPreviewRequest)>, - mut task_manager: ResMut, - mut ready_events: EventReader, - time: Res>, -) { - for event in ready_events.read() { - let _ = cache.get_by_path(&event.path, None); - } - for event in asset_events.read() { - match event { - AssetEvent::LoadedWithDependencies { id } => { - for (entity, request) in requests.iter() { - let handle: Handle = asset_server.load(&request.path); - if handle.id() == *id { - if let Some(image) = images.get(&handle) { - let image_clone = image.clone(); - let asset_id = handle.id(); - - generate_previews_for_resolutions( - &mut images, - &image_clone, - handle.clone(), - &request.path, - asset_id, - &config.resolutions, - &mut cache, - time.elapsed(), - ); - - let preview_image = cache - .get_by_path(&request.path, None) - .map(|entry| entry.image_handle.clone()) - .unwrap_or_else(|| handle.clone()); - - preview_ready_events.write(PreviewReady { - task_id: request.task_id, - path: request.path.clone(), - image_handle: preview_image, - }); - - task_manager.remove_task(request.task_id); - commands.entity(entity).despawn(); - } - } - } - } - AssetEvent::Removed { id } => { - cache.remove_by_id(*id, None); - - for (entity, request) in requests.iter() { - let handle: Handle = asset_server.load(&request.path); - if handle.id() == *id { - preview_failed_events.write(PreviewFailed { - task_id: request.task_id, - path: request.path.clone(), - error: "Image asset was removed".to_string(), - }); - task_manager.remove_task(request.task_id); - commands.entity(entity).despawn(); - } - } - } - _ => {} - } - } -} diff --git a/crates/bevy_asset_preview/src/preview/task.rs b/crates/bevy_asset_preview/src/preview/task.rs new file mode 100644 index 00000000..dbc210ec --- /dev/null +++ b/crates/bevy_asset_preview/src/preview/task.rs @@ -0,0 +1,82 @@ +use bevy::{ + asset::AssetPath, ecs::event::BufferedEvent, image::Image, platform::collections::HashMap, + prelude::*, +}; + +use crate::preview::{PreviewMode, PreviewRequestType}; + +/// Event emitted when a preview is ready. +#[derive(Event, BufferedEvent, Debug, Clone)] +pub struct PreviewReady { + /// Task ID of the preview request. + pub task_id: u64, + /// Asset path. + pub path: AssetPath<'static>, + /// Preview image handle. + pub image_handle: Handle, +} + +/// Event emitted when a preview generation fails. +#[derive(Event, BufferedEvent, Debug, Clone)] +pub struct PreviewFailed { + /// Task ID of the preview request. + pub task_id: u64, + /// Asset path. + pub path: AssetPath<'static>, + /// Error message. + pub error: String, +} + +/// Component that tracks a pending preview request. +#[derive(Component, Debug)] +pub struct PendingPreviewRequest { + /// Task ID. + pub task_id: u64, + /// Asset path. + pub path: AssetPath<'static>, + /// Preview request type. + pub request_type: PreviewRequestType, + /// Preview mode. + pub mode: PreviewMode, +} + +/// Resource that manages preview generation tasks. +#[derive(Resource, Default)] +pub struct PreviewTaskManager { + /// Next task ID. + next_task_id: u64, + /// Maps task ID to request entity. + task_to_entity: HashMap, +} + +impl PreviewTaskManager { + /// Creates a new task manager. + pub fn new() -> Self { + Self { + next_task_id: 0, + task_to_entity: HashMap::new(), + } + } + + /// Creates a new task ID. + pub fn create_task_id(&mut self) -> u64 { + let id = self.next_task_id; + self.next_task_id += 1; + id + } + + /// Registers a task entity. + pub fn register_task(&mut self, task_id: u64, entity: Entity) { + self.task_to_entity.insert(task_id, entity); + } + + /// Gets entity for a task ID. + pub fn get_entity(&self, task_id: u64) -> Option { + self.task_to_entity.get(&task_id).copied() + } + + /// Removes task registration. + pub fn remove_task(&mut self, task_id: u64) { + self.task_to_entity.remove(&task_id); + } +} diff --git a/crates/bevy_asset_preview/src/ui/mod.rs b/crates/bevy_asset_preview/src/ui/mod.rs index 1e531cc4..d10dcd81 100644 --- a/crates/bevy_asset_preview/src/ui/mod.rs +++ b/crates/bevy_asset_preview/src/ui/mod.rs @@ -1,14 +1,19 @@ use std::path::{Path, PathBuf}; use bevy::{ - asset::{AssetPath, AssetServer}, + asset::{AssetPath, AssetServer, LoadState}, + gltf::GltfAssetLabel, image::Image, prelude::*, + scene::Scene, }; use crate::{ asset::{AssetLoader, LoadPriority}, - preview::{PreviewCache, PreviewConfig}, + preview::{ + ModelFormat, PendingPreviewRequest, PreviewCache, PreviewConfig, PreviewMode, + PreviewRequestType, PreviewTaskManager, + }, }; #[derive(Component, Deref)] @@ -56,58 +61,165 @@ pub fn is_image_file(path: &Path) -> bool { .unwrap_or(false) } +/// Checks if a file path represents a GLTF model file based on its extension. +pub fn is_gltf_file(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| matches!(ext.to_lowercase().as_str(), "gltf" | "glb")) + .unwrap_or(false) +} + +/// Checks if a file path represents an OBJ model file based on its extension. +pub fn is_obj_file(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_lowercase().as_str() == "obj") + .unwrap_or(false) +} + +/// Checks if a file path represents a 3D model file based on its extension. +pub fn is_model_file(path: &Path) -> bool { + is_gltf_file(path) || is_obj_file(path) + // Add other model formats here as needed (fbx, 3ds, etc.) +} + /// System that handles PreviewAsset components and initiates preview loading. pub fn preview_handler( mut commands: Commands, mut requests_query: Query< (Entity, &PreviewAsset), - (Without, Without), + ( + Added, + Without, + Without, + ), >, asset_server: Res, mut loader: ResMut, + mut task_manager: ResMut, cache: Res, ) { for (entity, preview_asset) in &mut requests_query { let path = &preview_asset.0; + // Convert PathBuf to AssetPath + let asset_path: AssetPath<'static> = path.clone().into(); + // Check if it's an image file - if !is_image_file(path) { - // Not an image, use placeholder + if is_image_file(path) { + // Check cache first + if let Some(cache_entry) = cache.get_by_path(&asset_path, None) { + // Cache hit - use cached preview immediately + commands + .entity(entity) + .insert(ImageNode::new(cache_entry.image_handle.clone())); + commands.entity(entity).remove::(); + continue; + } + + // Cache miss - submit to AssetLoader with Preload priority + let task_id = loader.submit(&asset_path, LoadPriority::Preload); + + // Mark as pending + commands.entity(entity).insert(PendingPreviewLoad { + task_id, + asset_path: asset_path.clone(), + }); + + // Insert placeholder temporarily let placeholder = asset_server.load(FILE_PLACEHOLDER); commands.entity(entity).insert(ImageNode::new(placeholder)); - commands.entity(entity).remove::(); continue; } - // Convert PathBuf to AssetPath - let asset_path: AssetPath<'static> = path.clone().into(); + // Check if it's a 3D model file + if is_model_file(path) { + // Check cache first + if let Some(cache_entry) = cache.get_by_path(&asset_path, Some(256)) { + // Cache hit - use cached preview immediately + commands + .entity(entity) + .insert(ImageNode::new(cache_entry.image_handle.clone())); + commands.entity(entity).remove::(); + continue; + } - // Check cache first - if let Some(cache_entry) = cache.get_by_path(&asset_path, None) { - // Cache hit - use cached preview immediately - commands - .entity(entity) - .insert(ImageNode::new(cache_entry.image_handle.clone())); - commands.entity(entity).remove::(); + // Request 3D preview + let _task_id = request_3d_preview( + &mut commands, + &mut task_manager, + &asset_server, + &asset_path, + PreviewMode::Image, // Default to image mode for folder views + ); + + // Insert placeholder temporarily + let placeholder = asset_server.load(FILE_PLACEHOLDER); + commands.entity(entity).insert(ImageNode::new(placeholder)); continue; } - // Cache miss - submit to AssetLoader with Preload priority - // (can be upgraded to CurrentAccess based on viewport visibility later) - let task_id = loader.submit(&asset_path, LoadPriority::Preload); - - // Mark as pending - commands.entity(entity).insert(PendingPreviewLoad { - task_id, - asset_path: asset_path.clone(), - }); - - // Insert placeholder temporarily + // Not a supported file type, use placeholder let placeholder = asset_server.load(FILE_PLACEHOLDER); commands.entity(entity).insert(ImageNode::new(placeholder)); + commands.entity(entity).remove::(); } } +/// Requests a 3D preview for the given asset path. +pub fn request_3d_preview( + commands: &mut Commands, + task_manager: &mut PreviewTaskManager, + asset_server: &AssetServer, + path: &AssetPath<'static>, + mode: PreviewMode, +) -> u64 { + let task_id = task_manager.create_task_id(); + + // Determine request type based on file extension + let request_type = if is_gltf_file(path.path()) { + // Load GLTF Scene directly using GltfAssetLabel + let scene_path = GltfAssetLabel::Scene(0).from_asset(path.clone()); + let scene_handle: Handle = asset_server.load(scene_path); + PreviewRequestType::ModelFile { + handle: scene_handle, + format: ModelFormat::Gltf, + } + } else if is_obj_file(path.path()) { + let scene_handle: Handle = asset_server.load(path); + PreviewRequestType::ModelFile { + handle: scene_handle, + format: ModelFormat::Obj, + } + } else { + // Try as generic scene + let scene_handle: Handle = asset_server.load(path); + PreviewRequestType::ModelFile { + handle: scene_handle, + format: ModelFormat::Other( + path.path() + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("unknown") + .to_string(), + ), + } + }; + + // Spawn pending request entity + let entity = commands + .spawn(PendingPreviewRequest { + task_id, + path: path.clone(), + request_type, + mode, + }) + .id(); + + task_manager.register_task(task_id, entity); + task_id +} + /// System that handles completed asset loads and updates previews. pub fn handle_preview_load_completed( mut commands: Commands, @@ -115,7 +227,7 @@ pub fn handle_preview_load_completed( config: Res, mut images: ResMut>, mut load_completed_events: EventReader, - pending_query: Query<(Entity, &PendingPreviewLoad)>, + pending_query: Query<(Entity, &PendingPreviewLoad), With>, mut image_node_query: Query<&mut ImageNode>, time: Res>, ) { @@ -127,7 +239,7 @@ pub fn handle_preview_load_completed( if let Some(image) = images.get(&event.handle) { // Clone image data before mutable operations let image_clone = image.clone(); - let asset_id = event.handle.id(); + let asset_id = event.handle.id().untyped(); // Generate previews for all configured resolutions crate::preview::generate_previews_for_resolutions( @@ -166,7 +278,7 @@ pub fn handle_preview_load_completed( pub fn handle_preview_load_failed( mut commands: Commands, mut load_failed_events: EventReader, - pending_query: Query<(Entity, &PendingPreviewLoad)>, + pending_query: Query<(Entity, &PendingPreviewLoad), With>, ) { for event in load_failed_events.read() { // Find entities waiting for this task @@ -188,11 +300,9 @@ pub fn check_failed_loads( mut commands: Commands, mut loader: ResMut, asset_server: Res, - pending_query: Query<(Entity, &PendingPreviewLoad)>, + pending_query: Query<(Entity, &PendingPreviewLoad), With>, task_query: Query<(Entity, &crate::asset::ActiveLoadTask)>, ) { - use bevy::asset::LoadState; - for (entity, pending) in pending_query.iter() { // Try to find the active task for this pending load to get the correct handle let mut active_task_entity_opt = None; diff --git a/crates/bevy_asset_preview/tests/workflow_test.rs b/crates/bevy_asset_preview/tests/workflow_test.rs index f45ff9c8..63aa0d89 100644 --- a/crates/bevy_asset_preview/tests/workflow_test.rs +++ b/crates/bevy_asset_preview/tests/workflow_test.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::fs; use std::path::PathBuf; @@ -16,6 +17,14 @@ use bevy_asset_preview::{ save_image, }; use tempfile::TempDir; +use gltf::json::{ + Accessor, Buffer, Mesh, Node, Root, Scene, Value, + accessor::{ComponentType, GenericComponentType, Type}, + buffer::{Target, View}, + mesh::{Mode, Primitive, Semantic}, + serialize, + validation::{Checked::Valid, USize64}, +}; // ========== Helper functions ========== @@ -72,6 +81,195 @@ fn save_test_image( Ok(()) } +/// Save a simple cube model as glb file +fn save_model(path: &std::path::Path) -> Result<(), Box> { + // Simple cube vertices (position only) + let positions: Vec<[f32; 3]> = vec![ + // Front face + [-0.5, -0.5, 0.5], + [0.5, -0.5, 0.5], + [0.5, 0.5, 0.5], + [-0.5, 0.5, 0.5], + // Back face + [-0.5, -0.5, -0.5], + [-0.5, 0.5, -0.5], + [0.5, 0.5, -0.5], + [0.5, -0.5, -0.5], + // Top face + [-0.5, 0.5, -0.5], + [-0.5, 0.5, 0.5], + [0.5, 0.5, 0.5], + [0.5, 0.5, -0.5], + // Bottom face + [-0.5, -0.5, -0.5], + [0.5, -0.5, -0.5], + [0.5, -0.5, 0.5], + [-0.5, -0.5, 0.5], + // Right face + [0.5, -0.5, -0.5], + [0.5, 0.5, -0.5], + [0.5, 0.5, 0.5], + [0.5, -0.5, 0.5], + // Left face + [-0.5, -0.5, -0.5], + [-0.5, -0.5, 0.5], + [-0.5, 0.5, 0.5], + [-0.5, 0.5, -0.5], + ]; + + // Indices for triangles + let indices: Vec = vec![ + 0, 1, 2, 0, 2, 3, // Front + 4, 5, 6, 4, 6, 7, // Back + 8, 9, 10, 8, 10, 11, // Top + 12, 13, 14, 12, 14, 15, // Bottom + 16, 17, 18, 16, 18, 19, // Right + 20, 21, 22, 20, 22, 23, // Left + ]; + + let mut root = Root::default(); + + // Create buffer with positions and indices + let positions_bytes: &[u8] = bytemuck::cast_slice(&positions); + let indices_bytes: &[u8] = bytemuck::cast_slice(&indices); + let mut buffer_data = Vec::from(positions_bytes); + buffer_data.extend_from_slice(indices_bytes); + + // Pad buffer to multiple of 4 + while buffer_data.len() % 4 != 0 { + buffer_data.push(0); + } + + let buffer = root.push(Buffer { + byte_length: USize64::from(buffer_data.len()), + extensions: Default::default(), + extras: Default::default(), + name: None, + uri: None, // glb doesn't use uri + }); + + // Buffer view for positions + let positions_view = root.push(View { + buffer, + byte_length: USize64::from(positions_bytes.len()), + byte_offset: Some(USize64(0)), + byte_stride: None, + extensions: Default::default(), + extras: Default::default(), + name: None, + target: Some(Valid(Target::ArrayBuffer)), + }); + + // Buffer view for indices + let indices_view = root.push(View { + buffer, + byte_length: USize64::from(indices_bytes.len()), + byte_offset: Some(USize64::from(positions_bytes.len())), + byte_stride: None, + extensions: Default::default(), + extras: Default::default(), + name: None, + target: Some(Valid(Target::ElementArrayBuffer)), + }); + + // Accessor for positions + let positions_accessor = root.push(Accessor { + buffer_view: Some(positions_view), + byte_offset: None, + count: USize64::from(positions.len()), + component_type: Valid(GenericComponentType(ComponentType::F32)), + extensions: Default::default(), + extras: Default::default(), + type_: Valid(Type::Vec3), + min: Some(Value::from(vec![-0.5, -0.5, -0.5])), + max: Some(Value::from(vec![0.5, 0.5, 0.5])), + name: None, + normalized: false, + sparse: None, + }); + + // Accessor for indices + let indices_accessor = root.push(Accessor { + buffer_view: Some(indices_view), + byte_offset: None, + count: USize64::from(indices.len()), + component_type: Valid(GenericComponentType(ComponentType::U16)), + extensions: Default::default(), + extras: Default::default(), + type_: Valid(Type::Scalar), + min: None, + max: None, + name: None, + normalized: false, + sparse: None, + }); + + // Create mesh primitive + let primitive = Primitive { + attributes: { + let mut map = std::collections::BTreeMap::new(); + map.insert(Valid(Semantic::Positions), positions_accessor); + map + }, + extensions: Default::default(), + extras: Default::default(), + indices: Some(indices_accessor), + material: None, + mode: Valid(Mode::Triangles), + targets: None, + }; + + let mesh = root.push(Mesh { + extensions: Default::default(), + extras: Default::default(), + name: None, + primitives: vec![primitive], + weights: None, + }); + + let node = root.push(Node { + mesh: Some(mesh), + ..Default::default() + }); + + root.scenes = vec![Scene { + extensions: Default::default(), + extras: Default::default(), + name: None, + nodes: vec![node], + }]; + + // Save as glb (binary glTF) + let json_string = serialize::to_string(&root)?; + let mut json_offset = json_string.len(); + while json_offset % 4 != 0 { + json_offset += 1; + } + + let glb = gltf::binary::Glb { + header: gltf::binary::Header { + magic: *b"glTF", + version: 2, + length: (json_offset + buffer_data.len()) + .try_into() + .map_err(|_| "File size exceeds binary glTF limit")?, + }, + bin: Some(Cow::Owned(buffer_data)), + json: Cow::Owned({ + let mut json_bytes = json_string.into_bytes(); + while json_bytes.len() % 4 != 0 { + json_bytes.push(0x20); // pad with space + } + json_bytes + }), + }; + + let writer = std::fs::File::create(path)?; + glb.to_writer(writer)?; + + Ok(()) +} + fn wait_for_save_completion( app: &mut App, expected_count: usize, @@ -607,11 +805,9 @@ fn test_complete_workflow() { let highest_resolution = highest_entry.resolution; let expected_highest = *config.resolutions.iter().max().unwrap(); assert_eq!( - highest_resolution, - expected_highest, + highest_resolution, expected_highest, "Highest resolution should be {} for {}", - expected_highest, - filename + expected_highest, filename ); // Validate highest resolution entry properties @@ -632,7 +828,8 @@ fn test_complete_workflow() { if let Some(preview_image) = images.get(&highest_entry.image_handle) { let max_dimension = expected_highest; assert!( - preview_image.width() <= max_dimension && preview_image.height() <= max_dimension, + preview_image.width() <= max_dimension + && preview_image.height() <= max_dimension, "Large image {} should be compressed, got {}x{}", filename, preview_image.width(), @@ -646,7 +843,12 @@ fn test_complete_workflow() { if let Some(preview_image) = images.get(&highest_entry.image_handle) { // Original: 800x200 = 4:1, compressed should maintain aspect ratio let expected_height = (200.0 * expected_highest as f32 / 800.0) as u32; - assert_eq!(preview_image.width(), expected_highest, "Wide image width should be {}", expected_highest); + assert_eq!( + preview_image.width(), + expected_highest, + "Wide image width should be {}", + expected_highest + ); assert_eq!( preview_image.height(), expected_height, From a53a603aa555349a3f4ece80c571789928f7e655 Mon Sep 17 00:00:00 2001 From: Sieluna Date: Fri, 9 Jan 2026 17:18:52 +0200 Subject: [PATCH 8/8] Improve integration tests and refactor errors --- crates/bevy_asset_preview/Cargo.toml | 1 + crates/bevy_asset_preview/src/asset/error.rs | 41 + crates/bevy_asset_preview/src/asset/loader.rs | 59 +- crates/bevy_asset_preview/src/asset/mod.rs | 16 +- crates/bevy_asset_preview/src/asset/saver.rs | 147 ++- crates/bevy_asset_preview/src/asset/task.rs | 8 +- .../bevy_asset_preview/src/preview/cache.rs | 1 - .../bevy_asset_preview/src/preview/image.rs | 4 +- crates/bevy_asset_preview/src/preview/mod.rs | 14 +- .../bevy_asset_preview/src/preview/model.rs | 5 +- .../src/preview/renderer.rs | 6 - crates/bevy_asset_preview/src/preview/task.rs | 5 +- crates/bevy_asset_preview/tests/asset_test.rs | 422 +++++++ crates/bevy_asset_preview/tests/common/mod.rs | 342 ++++++ .../bevy_asset_preview/tests/preview_test.rs | 690 +++++++++++ .../bevy_asset_preview/tests/workflow_test.rs | 1024 ----------------- 16 files changed, 1669 insertions(+), 1116 deletions(-) create mode 100644 crates/bevy_asset_preview/src/asset/error.rs create mode 100644 crates/bevy_asset_preview/tests/asset_test.rs create mode 100644 crates/bevy_asset_preview/tests/common/mod.rs create mode 100644 crates/bevy_asset_preview/tests/preview_test.rs delete mode 100644 crates/bevy_asset_preview/tests/workflow_test.rs diff --git a/crates/bevy_asset_preview/Cargo.toml b/crates/bevy_asset_preview/Cargo.toml index ed643a2e..ec4ad122 100644 --- a/crates/bevy_asset_preview/Cargo.toml +++ b/crates/bevy_asset_preview/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] bevy = { workspace = true, features = ["webp"] } image = "0.25" +thiserror.workspace = true [dev-dependencies] tempfile = "3" diff --git a/crates/bevy_asset_preview/src/asset/error.rs b/crates/bevy_asset_preview/src/asset/error.rs new file mode 100644 index 00000000..abe22be1 --- /dev/null +++ b/crates/bevy_asset_preview/src/asset/error.rs @@ -0,0 +1,41 @@ +use std::path::PathBuf; + +use bevy::{ + asset::AssetPath, + prelude::{Handle, Image}, +}; +use thiserror::Error; + +/// Errors that can occur during asset operations. +#[derive(Error, Debug, Clone)] +pub enum AssetError { + #[error("Image not found: {handle:?}")] + ImageNotFound { handle: Handle }, + + #[error("Failed to convert image to dynamic format: {0}")] + ImageConversionFailed(String), + + #[error("Failed to encode image to {format}: {reason}")] + ImageEncodeFailed { format: String, reason: String }, + + #[error("Failed to create directory {path:?}: {reason}")] + DirectoryCreationFailed { path: PathBuf, reason: String }, + + #[error("Failed to write file to {path:?}: {reason}")] + FileWriteFailed { path: PathBuf, reason: String }, + + #[error("Asset load failed for {path:?}: {reason}")] + AssetLoadFailed { + path: AssetPath<'static>, + reason: String, + }, + + #[error("Asset was removed (possibly failed to load): {path:?}")] + AssetRemoved { path: AssetPath<'static> }, + + #[error("IO error: {0}")] + Io(String), + + #[error("Asset writer error: {0}")] + AssetWriter(String), +} diff --git a/crates/bevy_asset_preview/src/asset/loader.rs b/crates/bevy_asset_preview/src/asset/loader.rs index 79ed43f4..7fb81097 100644 --- a/crates/bevy_asset_preview/src/asset/loader.rs +++ b/crates/bevy_asset_preview/src/asset/loader.rs @@ -1,13 +1,12 @@ use std::collections::BinaryHeap; use bevy::{ - asset::{AssetEvent, AssetId, AssetPath, AssetServer, Handle, LoadState}, - ecs::event::{BufferedEvent, Event}, + asset::{AssetPath, LoadState}, platform::collections::HashMap, prelude::*, }; -use crate::asset::{LoadPriority, LoadTask}; +use crate::asset::{AssetError, LoadPriority, LoadTask}; /// Active asynchronous loading task. #[derive(Component)] @@ -32,7 +31,7 @@ pub struct AssetLoadCompleted { pub struct AssetLoadFailed { pub task_id: u64, pub path: AssetPath<'static>, - pub error: String, + pub error: AssetError, } /// Event emitted when an asset is hot-reloaded. @@ -212,11 +211,34 @@ pub fn handle_asset_events( mut commands: Commands, mut loader: ResMut, mut asset_events: EventReader>, + mut load_failed_events_bevy: EventReader>, + asset_server: Res, mut load_completed_events: EventWriter, mut load_failed_events: EventWriter, mut hot_reload_events: EventWriter, task_query: Query<&ActiveLoadTask>, ) { + // Handle Bevy's AssetLoadFailedEvent + for event in load_failed_events_bevy.read() { + if let Some(entity) = loader.get_entity_by_handle(event.id) { + if let Ok(active_task) = task_query.get(entity) { + load_failed_events.write(AssetLoadFailed { + task_id: active_task.task_id, + path: active_task.path.clone(), + error: AssetError::AssetLoadFailed { + path: active_task.path.clone(), + reason: format!("{:?}", event.error), + }, + }); + + loader.finish_task(); + loader.cleanup_task(active_task.task_id, event.id); + commands.entity(entity).despawn(); + } + } + } + + // Handle AssetEvent for event in asset_events.read() { match event { AssetEvent::LoadedWithDependencies { id } => { @@ -241,7 +263,9 @@ pub fn handle_asset_events( load_failed_events.write(AssetLoadFailed { task_id: active_task.task_id, path: active_task.path.clone(), - error: "Asset was removed (possibly failed to load)".to_string(), + error: AssetError::AssetRemoved { + path: active_task.path.clone(), + }, }); loader.finish_task(); @@ -264,6 +288,31 @@ pub fn handle_asset_events( _ => {} } } + + // Fallback: Check load state for failed tasks + let mut failed_entities = Vec::new(); + for active_task in task_query.iter() { + let load_state = asset_server.load_state(&active_task.handle); + if let bevy::asset::LoadState::Failed(_) = load_state { + if let Some(entity) = loader.get_entity_by_handle(active_task.handle.id()) { + load_failed_events.write(AssetLoadFailed { + task_id: active_task.task_id, + path: active_task.path.clone(), + error: AssetError::AssetLoadFailed { + path: active_task.path.clone(), + reason: "Asset load failed".to_string(), + }, + }); + + loader.finish_task(); + loader.cleanup_task(active_task.task_id, active_task.handle.id()); + failed_entities.push(entity); + } + } + } + for entity in failed_entities { + commands.entity(entity).despawn(); + } } /// Polls active tasks and handles completed ones (fallback approach). diff --git a/crates/bevy_asset_preview/src/asset/mod.rs b/crates/bevy_asset_preview/src/asset/mod.rs index 831add14..6f74cc6e 100644 --- a/crates/bevy_asset_preview/src/asset/mod.rs +++ b/crates/bevy_asset_preview/src/asset/mod.rs @@ -1,15 +1,11 @@ +mod error; mod loader; mod priority; mod saver; mod task; -pub use loader::{ - ActiveLoadTask, AssetHotReloaded, AssetLoadCompleted, AssetLoadFailed, AssetLoader, - handle_asset_events, process_load_queue, -}; -pub use priority::LoadPriority; -pub use saver::{ - ActiveSaveTask, SaveCompleted, SaveTaskTracker, handle_save_completed, monitor_save_completion, - save_image, -}; -pub use task::LoadTask; +pub use error::*; +pub use loader::*; +pub use priority::*; +pub use saver::*; +pub use task::*; diff --git a/crates/bevy_asset_preview/src/asset/saver.rs b/crates/bevy_asset_preview/src/asset/saver.rs index 55d7c49e..ae155307 100644 --- a/crates/bevy_asset_preview/src/asset/saver.rs +++ b/crates/bevy_asset_preview/src/asset/saver.rs @@ -2,12 +2,13 @@ use std::io::Cursor; use bevy::{ asset::{AssetPath, io::ErasedAssetWriter}, - ecs::event::{BufferedEvent, Event}, - image::{Image, ImageFormat}, platform::collections::HashMap, prelude::*, tasks::{IoTaskPool, Task, block_on, futures_lite}, }; +use image::ImageFormat; + +use crate::asset::AssetError; /// Active save task tracking component. #[derive(Component)] @@ -15,7 +16,7 @@ pub struct ActiveSaveTask { pub task_id: u64, pub path: AssetPath<'static>, pub target_path: AssetPath<'static>, - pub task: Task>, + pub task: Task>, } /// Event emitted when a save task completes. @@ -23,7 +24,7 @@ pub struct ActiveSaveTask { pub struct SaveCompleted { pub task_id: u64, pub path: AssetPath<'static>, - pub result: Result<(), String>, + pub result: Result<(), AssetError>, } /// Resource for save task tracking. @@ -50,6 +51,16 @@ impl SaveTaskTracker { pub fn mark_completed(&mut self, task_id: u64) { self.pending_saves.remove(&task_id); } + + /// Returns the number of pending saves. + pub fn pending_count(&self) -> usize { + self.pending_saves.len() + } + + /// Checks if a task ID is in pending saves. + pub fn is_pending(&self, task_id: u64) -> bool { + self.pending_saves.contains_key(&task_id) + } } /// Saves an image asset to the specified path asynchronously using AssetWriter abstraction. @@ -58,20 +69,20 @@ pub fn save_image<'a>( target_path: impl Into>, images: &Assets, writer: impl ErasedAssetWriter, -) -> Task> { +) -> Task> { let target_path: AssetPath<'static> = target_path.into().into_owned(); let Some(image_data) = images.get(&image) else { - let error = format!("Image not found: {:?}", image); - return IoTaskPool::get().spawn(async move { Err(error) }); + return IoTaskPool::get() + .spawn(async move { Err(AssetError::ImageNotFound { handle: image }) }); }; // Convert to dynamic image let dynamic_image = match image_data.clone().try_into_dynamic() { Ok(img) => img, Err(e) => { - let error = format!("Failed to convert image: {:?}", e); - return IoTaskPool::get().spawn(async move { Err(error) }); + return IoTaskPool::get() + .spawn(async move { Err(AssetError::ImageConversionFailed(e.to_string())) }); } }; @@ -93,44 +104,36 @@ pub fn save_image<'a>( task_pool.spawn(async move { // Create directory first if let Some(parent) = target_path_for_writer.parent() { - if let Err(e) = writer.create_directory(parent).await { - let error = format!("Failed to create directory {:?}: {:?}", parent, e); - error!("{}", error); - return Err(error); - } + writer.create_directory(parent).await.map_err(|e| { + AssetError::DirectoryCreationFailed { + path: parent.to_path_buf(), + reason: e.to_string(), + } + })?; } - // Encode PNG directly to memory + // Encode WebP directly to memory let mut cursor = Cursor::new(Vec::new()); - match rgba_image.write_to( - &mut cursor, - ImageFormat::WebP.as_image_crate_format().unwrap(), // unwrap is safe because we enable bevy webp feature - ) { - Ok(_) => { - let webp_bytes = cursor.into_inner(); - // Write via AssetWriter (atomic operation) - match writer - .write_bytes(&target_path_for_writer, &webp_bytes) - .await - { - Ok(_) => { - info!("Image saved successfully to {:?}", target_path_clone); - Ok(()) - } - Err(e) => { - let error = - format!("Failed to save image to {:?}: {:?}", target_path_clone, e); - error!("{}", error); - Err(error) - } - } - } - Err(e) => { - let error = format!("Failed to encode image to WebP: {:?}", e); - error!("{}", error); - Err(error) - } - } + rgba_image + .write_to(&mut cursor, ImageFormat::WebP) + .map_err(|e| AssetError::ImageEncodeFailed { + format: "WebP".to_string(), + reason: e.to_string(), + })?; + + let webp_bytes = cursor.into_inner(); + + // Write via AssetWriter (atomic operation) + writer + .write_bytes(&target_path_for_writer, &webp_bytes) + .await + .map_err(|e| AssetError::FileWriteFailed { + path: target_path_clone.path().to_path_buf(), + reason: e.to_string(), + })?; + + info!("Image saved successfully to {:?}", target_path_clone); + Ok(()) }) } @@ -167,12 +170,64 @@ pub fn handle_save_completed(mut save_completed_events: EventReader { + Err(err) => { warn!( "Save task {} failed for {:?}: {}", - event.task_id, event.path, e + event.task_id, event.path, err ); } } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_task_id_generation() { + let mut tracker = SaveTaskTracker::default(); + assert_eq!(tracker.create_task_id(), 0); + assert_eq!(tracker.create_task_id(), 1); + assert_eq!(tracker.create_task_id(), 2); + } + + #[test] + fn test_pending_save_registration() { + let mut tracker = SaveTaskTracker::default(); + let id1 = tracker.create_task_id(); + let id2 = tracker.create_task_id(); + + let path1 = AssetPath::from("test1.png"); + tracker.register_pending(id1, path1.clone()); + assert_eq!(tracker.pending_count(), 1); + assert!(tracker.is_pending(id1)); + + let path2 = AssetPath::from("test2.png"); + tracker.register_pending(id2, path2.clone()); + assert_eq!(tracker.pending_count(), 2); + assert!(tracker.is_pending(id2)); + } + + #[test] + fn test_mark_completed() { + let mut tracker = SaveTaskTracker::default(); + let id1 = tracker.create_task_id(); + let id2 = tracker.create_task_id(); + + let path1 = AssetPath::from("test1.png"); + let path2 = AssetPath::from("test2.png"); + tracker.register_pending(id1, path1); + tracker.register_pending(id2, path2); + + assert_eq!(tracker.pending_count(), 2); + tracker.mark_completed(id1); + assert_eq!(tracker.pending_count(), 1); + assert!(!tracker.is_pending(id1)); + assert!(tracker.is_pending(id2)); + + tracker.mark_completed(id2); + assert_eq!(tracker.pending_count(), 0); + assert!(!tracker.is_pending(id2)); + } +} diff --git a/crates/bevy_asset_preview/src/asset/task.rs b/crates/bevy_asset_preview/src/asset/task.rs index fa17db07..bb6c011d 100644 --- a/crates/bevy_asset_preview/src/asset/task.rs +++ b/crates/bevy_asset_preview/src/asset/task.rs @@ -1,3 +1,5 @@ +use core::cmp::Ordering; + use bevy::asset::AssetPath; use crate::asset::LoadPriority; @@ -33,16 +35,16 @@ impl PartialEq for LoadTask { impl Eq for LoadTask {} impl PartialOrd for LoadTask { - fn partial_cmp(&self, other: &Self) -> Option { + fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for LoadTask { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { + fn cmp(&self, other: &Self) -> Ordering { // Sort by priority first (higher priority first, BinaryHeap is max-heap) match self.priority.cmp(&other.priority) { - std::cmp::Ordering::Equal => { + Ordering::Equal => { // Same priority: sort by task ID (earlier tasks first) // BinaryHeap is max-heap, so reverse ID comparison other.task_id.cmp(&self.task_id) diff --git a/crates/bevy_asset_preview/src/preview/cache.rs b/crates/bevy_asset_preview/src/preview/cache.rs index eca4f6d0..776bd570 100644 --- a/crates/bevy_asset_preview/src/preview/cache.rs +++ b/crates/bevy_asset_preview/src/preview/cache.rs @@ -4,7 +4,6 @@ use std::path::Path; use bevy::{ asset::{AssetPath, UntypedAssetId}, - image::Image, platform::collections::HashMap, prelude::*, }; diff --git a/crates/bevy_asset_preview/src/preview/image.rs b/crates/bevy_asset_preview/src/preview/image.rs index 163c22f4..ce664136 100644 --- a/crates/bevy_asset_preview/src/preview/image.rs +++ b/crates/bevy_asset_preview/src/preview/image.rs @@ -1,9 +1,7 @@ use core::time::Duration; use bevy::{ - asset::{AssetEvent, AssetPath, AssetServer, RenderAssetUsages, UntypedAssetId}, - ecs::event::{EventReader, EventWriter}, - image::Image, + asset::{AssetPath, RenderAssetUsages, UntypedAssetId}, prelude::*, }; diff --git a/crates/bevy_asset_preview/src/preview/mod.rs b/crates/bevy_asset_preview/src/preview/mod.rs index cdb36d8f..f729be1e 100644 --- a/crates/bevy_asset_preview/src/preview/mod.rs +++ b/crates/bevy_asset_preview/src/preview/mod.rs @@ -8,17 +8,11 @@ mod task; use bevy::{mesh::Mesh, pbr::StandardMaterial, prelude::*, scene::Scene}; // Re-export public types and functions -pub use cache::{PreviewCache, PreviewCacheEntry}; -pub use image::{ - generate_previews_for_resolutions, handle_image_preview_events, request_image_preview, - resize_image_for_preview, -}; -pub use model::{ - PreviewScene3D, WaitingForScreenshot, capture_preview_screenshot, handle_preview_screenshots, - process_3d_preview_requests, wait_for_asset_load, -}; +pub use cache::*; +pub use image::*; +pub use model::*; pub use renderer::*; -pub use task::{PendingPreviewRequest, PreviewFailed, PreviewReady, PreviewTaskManager}; +pub use task::*; // pub use entity_preview::*; // Temporarily disabled /// Preview mode for 3D assets. diff --git a/crates/bevy_asset_preview/src/preview/model.rs b/crates/bevy_asset_preview/src/preview/model.rs index 20ddf76c..845b8054 100644 --- a/crates/bevy_asset_preview/src/preview/model.rs +++ b/crates/bevy_asset_preview/src/preview/model.rs @@ -1,7 +1,4 @@ -use bevy::{ - asset::AssetPath, image::Image, mesh::Mesh, pbr::StandardMaterial, prelude::*, - render::view::screenshot::Screenshot, scene::Scene, -}; +use bevy::{asset::AssetPath, prelude::*, render::view::screenshot::Screenshot}; use crate::preview::{ PreviewConfig, PreviewMode, PreviewRequestType, diff --git a/crates/bevy_asset_preview/src/preview/renderer.rs b/crates/bevy_asset_preview/src/preview/renderer.rs index ed4a9764..805b34b4 100644 --- a/crates/bevy_asset_preview/src/preview/renderer.rs +++ b/crates/bevy_asset_preview/src/preview/renderer.rs @@ -4,14 +4,8 @@ use bevy::{ primitives::{Aabb, Sphere as CameraSphere}, visibility::RenderLayers, }, - image::Image, - light::{AmbientLight, DirectionalLight}, - math::{Vec3A, primitives::Sphere}, - mesh::{Mesh, Mesh3d}, - pbr::{MeshMaterial3d, StandardMaterial}, prelude::*, render::render_resource::TextureFormat, - scene::SceneRoot, }; use crate::preview::PreviewConfig; diff --git a/crates/bevy_asset_preview/src/preview/task.rs b/crates/bevy_asset_preview/src/preview/task.rs index dbc210ec..cae03c5d 100644 --- a/crates/bevy_asset_preview/src/preview/task.rs +++ b/crates/bevy_asset_preview/src/preview/task.rs @@ -1,7 +1,4 @@ -use bevy::{ - asset::AssetPath, ecs::event::BufferedEvent, image::Image, platform::collections::HashMap, - prelude::*, -}; +use bevy::{asset::AssetPath, platform::collections::HashMap, prelude::*}; use crate::preview::{PreviewMode, PreviewRequestType}; diff --git a/crates/bevy_asset_preview/tests/asset_test.rs b/crates/bevy_asset_preview/tests/asset_test.rs new file mode 100644 index 00000000..81a216aa --- /dev/null +++ b/crates/bevy_asset_preview/tests/asset_test.rs @@ -0,0 +1,422 @@ +mod common; + +use std::fs; +use std::path::PathBuf; + +use bevy::{ + asset::{AssetPath, AssetPlugin, io::file::FileAssetWriter}, + image::{CompressedImageFormats, ImageLoader}, + prelude::*, +}; +use bevy_asset_preview::{ + ActiveLoadTask, ActiveSaveTask, AssetError, AssetHotReloaded, AssetLoadCompleted, + AssetLoadFailed, AssetLoader, LoadPriority, SaveCompleted, SaveTaskTracker, + handle_asset_events, monitor_save_completion, process_load_queue, save_image, +}; +use tempfile::TempDir; + +use common::{create_test_image, save_test_image, wait_for_load_completion}; + +#[test] +fn test_asset_loading_workflow() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let assets_dir = temp_dir.path().join("assets"); + fs::create_dir_all(&assets_dir).expect("Failed to create assets directory"); + + unsafe { + std::env::set_var( + "BEVY_ASSET_ROOT", + temp_dir + .path() + .to_str() + .expect("Failed to convert path to string"), + ); + } + + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(AssetPlugin { + file_path: assets_dir.display().to_string(), + ..Default::default() + }) + .init_asset::() + .register_asset_loader(ImageLoader::new(CompressedImageFormats::NONE)) + .init_resource::() + .add_event::() + .add_event::() + .add_event::() + .add_systems(Update, (process_load_queue, handle_asset_events)); + + app.finish(); + app.update(); + + // Create test image files + let mut images = app.world_mut().resource_mut::>(); + let mut image_files = Vec::new(); + for i in 0..3 { + let filename = format!("test_image_{}.png", i); + let handle = create_test_image(&mut images, 64, 64, [255, 0, 0, 255]); + let image = images.get(&handle).unwrap(); + let image_path = assets_dir.join(&filename); + save_test_image(image, &image_path).expect("Failed to save test image"); + image_files.push((filename, handle)); + } + drop(images); + + // Submit multiple loading tasks + let mut loader = app.world_mut().resource_mut::(); + let mut task_ids = Vec::new(); + for (filename, _) in &image_files { + let task_id = loader.submit(filename, LoadPriority::Preload); + task_ids.push(task_id); + } + assert_eq!(loader.queue_len(), 3); + drop(loader); + + // Process queue - should start up to 4 concurrent tasks + app.update(); + + let world = app.world(); + let loader = world.resource::(); + assert_eq!(loader.active_tasks(), 3, "Should have 3 active tasks"); + assert_eq!( + loader.queue_len(), + 0, + "Queue should be empty after processing" + ); + + // Verify ActiveLoadTask entities have been created + let mut query = app.world_mut().query::<&ActiveLoadTask>(); + let world = app.world(); + let task_count = query.iter(world).count(); + assert_eq!(task_count, 3, "Should have 3 ActiveLoadTask entities"); + + // Wait for all loads to complete + let completed_events = wait_for_load_completion(&mut app, 3, 3000); + assert_eq!(completed_events.len(), 3); + + // Verify all tasks have been cleaned up + let world = app.world(); + let loader = world.resource::(); + assert_eq!( + loader.active_tasks(), + 0, + "All active tasks should be cleaned up" + ); + assert_eq!(loader.queue_len(), 0, "Queue should be empty"); + + // Verify all ActiveLoadTask entities have been cleaned up + let mut query = app.world_mut().query::<&ActiveLoadTask>(); + let world = app.world(); + let task_count = query.iter(world).count(); + assert_eq!( + task_count, 0, + "All ActiveLoadTask entities should be despawned" + ); + + // Verify all task paths have been cleaned up + let loader = app.world().resource::(); + for task_id in task_ids { + assert_eq!( + loader.get_task_path(task_id), + None, + "Task path {} should be cleaned up", + task_id + ); + } + + // Test error handling: load non-existent file + let mut loader = app.world_mut().resource_mut::(); + let error_task_id = loader.submit("non_existent.png", LoadPriority::CurrentAccess); + drop(loader); + + app.update(); + + let world = app.world(); + let loader = world.resource::(); + assert_eq!( + loader.active_tasks(), + 1, + "Should have 1 active task for error case" + ); + + // Wait for failure event + let mut failed_count = 0; + let mut iterations = 0; + const MAX_ITERATIONS: usize = 2000; + + while failed_count == 0 && iterations < MAX_ITERATIONS { + app.update(); + iterations += 1; + + let world = app.world(); + let failed_events = world.resource::>(); + let mut cursor = failed_events.get_cursor(); + for event in cursor.read(failed_events) { + if event.task_id == error_task_id { + failed_count += 1; + match &event.error { + AssetError::AssetRemoved { path } => { + assert_eq!(path.to_string(), "non_existent.png"); + } + AssetError::AssetLoadFailed { path, .. } => { + assert_eq!(path.to_string(), "non_existent.png"); + } + _ => panic!("Unexpected error type: {:?}", event.error), + } + } + } + } + + assert!(failed_count > 0, "Should receive AssetLoadFailed event"); + + // Verify cleanup in error case + let world = app.world(); + let loader = world.resource::(); + assert_eq!( + loader.active_tasks(), + 0, + "Active tasks should be cleaned up after error" + ); + assert_eq!( + loader.get_task_path(error_task_id), + None, + "Task path should be cleaned up after error" + ); + + // Verify ActiveLoadTask entity has been cleaned up + let mut query = app.world_mut().query::<&ActiveLoadTask>(); + let world = app.world(); + let task_count = query.iter(world).count(); + assert_eq!( + task_count, 0, + "ActiveLoadTask entity should be despawned after error" + ); +} + +#[test] +fn test_asset_saving_workflow() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let assets_dir = temp_dir.path().join("assets"); + let cache_dir = temp_dir.path().join("cache"); + fs::create_dir_all(&assets_dir).expect("Failed to create assets directory"); + fs::create_dir_all(&cache_dir).expect("Failed to create cache directory"); + + unsafe { + std::env::set_var( + "BEVY_ASSET_ROOT", + temp_dir + .path() + .to_str() + .expect("Failed to convert path to string"), + ); + } + + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(AssetPlugin { + file_path: assets_dir.display().to_string(), + ..Default::default() + }) + .init_asset::() + .register_asset_loader(ImageLoader::new(CompressedImageFormats::NONE)) + .init_resource::() + .add_event::() + .add_systems(Update, monitor_save_completion); + + app.finish(); + app.update(); + + // Create test images + let mut images = app.world_mut().resource_mut::>(); + let mut image_files = Vec::new(); + for i in 0..2 { + let filename = format!("save_test_{}.png", i); + let handle = create_test_image(&mut images, 64, 64, [255, 0, 0, 255]); + image_files.push((filename, handle)); + } + drop(images); + + // Create save tasks + let save_tasks = app + .world_mut() + .resource_scope(|world, mut tracker: Mut| { + let images = world.get_resource::>().unwrap(); + let mut tasks = Vec::new(); + + for (filename, handle) in &image_files { + let writer = FileAssetWriter::new("", true); + let target_path = + AssetPath::from_path_buf(PathBuf::from("cache/asset_preview").join(filename)) + .into_owned(); + let task = save_image(handle.clone(), target_path.clone(), images, writer); + let task_id = tracker.create_task_id(); + let path_asset: AssetPath<'static> = + AssetPath::from_path_buf(PathBuf::from(filename)).into_owned(); + tracker.register_pending(task_id, path_asset.clone()); + tasks.push((task_id, path_asset, target_path, task)); + } + + tasks + }); + + // Verify tracker state + let tracker = app.world().resource::(); + assert_eq!(tracker.pending_count(), 2, "Should have 2 pending saves"); + + // Spawn ActiveSaveTask entities + let mut commands = app.world_mut().commands(); + for (task_id, path, target_path, task) in save_tasks { + commands.spawn(ActiveSaveTask { + task_id, + path, + target_path, + task, + }); + } + drop(commands); + + // Apply commands + app.update(); + + // Verify ActiveSaveTask entities have been created + let mut query = app.world_mut().query::<&ActiveSaveTask>(); + let world = app.world(); + let task_count = query.iter(world).count(); + assert_eq!(task_count, 2, "Should have 2 ActiveSaveTask entities"); + + // Wait for save completion + let mut save_completed_count = 0; + let mut processed_task_ids = std::collections::HashSet::new(); + let mut completed_events = Vec::new(); + let mut iterations = 0; + const MAX_SAVE_ITERATIONS: usize = 1000; + + while save_completed_count < 2 && iterations < MAX_SAVE_ITERATIONS { + app.update(); + iterations += 1; + + let world = app.world(); + let save_events = world.resource::>(); + let mut cursor = save_events.get_cursor(); + for event in cursor.read(save_events) { + if processed_task_ids.insert(event.task_id) { + match &event.result { + Ok(_) => { + save_completed_count += 1; + completed_events.push(event.clone()); + } + Err(e) => panic!( + "Save task {} failed after {} iterations: {}", + event.task_id, iterations, e + ), + } + } + } + } + + assert_eq!( + save_completed_count, 2, + "Expected 2 save completions, got {} after {} iterations", + save_completed_count, iterations + ); + assert_eq!(completed_events.len(), 2); + for event in &completed_events { + assert!(event.result.is_ok(), "Save should succeed"); + } + + // Verify ActiveSaveTask entities have been cleaned up + let mut query = app.world_mut().query::<&ActiveSaveTask>(); + let world = app.world(); + let task_count = query.iter(world).count(); + assert_eq!(task_count, 0, "ActiveSaveTask entities should be despawned"); + + // Verify tracker cleanup + let tracker = world.resource::(); + assert_eq!( + tracker.pending_count(), + 0, + "Pending saves should be cleaned up" + ); + + // Test error handling: save non-existent handle + let non_existent_handle = Handle::::default(); + let (error_task_id, error_path, error_target_path, error_task) = app + .world_mut() + .resource_scope(|world, mut tracker: Mut| { + let images = world.get_resource::>().unwrap(); + let writer = FileAssetWriter::new("", true); + let target_path = AssetPath::from_path_buf( + PathBuf::from("cache/asset_preview").join("non_existent.png"), + ) + .into_owned(); + let task = save_image( + non_existent_handle.clone(), + target_path.clone(), + images, + writer, + ); + let task_id = tracker.create_task_id(); + let path: AssetPath<'static> = AssetPath::from("non_existent.png").into_owned(); + tracker.register_pending(task_id, path.clone()); + (task_id, path, target_path, task) + }); + + app.world_mut().commands().spawn(ActiveSaveTask { + task_id: error_task_id, + path: error_path, + target_path: error_target_path, + task: error_task, + }); + + // Wait for save failure event + let mut save_completed_count = 0; + let mut iterations = 0; + const MAX_ITERATIONS: usize = 1000; + + while save_completed_count == 0 && iterations < MAX_ITERATIONS { + app.update(); + iterations += 1; + + let world = app.world(); + let save_events = world.resource::>(); + let mut cursor = save_events.get_cursor(); + for event in cursor.read(save_events) { + if event.task_id == error_task_id { + save_completed_count += 1; + assert!( + event.result.is_err(), + "Save should fail for non-existent handle" + ); + match &event.result { + Err(AssetError::ImageNotFound { handle: _ }) => { + // Expected error + } + Err(e) => panic!("Unexpected error type: {:?}", e), + Ok(_) => panic!("Save should have failed"), + } + } + } + } + + assert!( + save_completed_count > 0, + "Should receive SaveCompleted event with error" + ); + + // Verify cleanup in error case + let mut query = app.world_mut().query::<&ActiveSaveTask>(); + let world = app.world(); + let task_count = query.iter(world).count(); + assert_eq!( + task_count, 0, + "ActiveSaveTask entity should be despawned after error" + ); + + let tracker = world.resource::(); + assert_eq!( + tracker.pending_count(), + 0, + "Pending saves should be cleaned up after error" + ); +} diff --git a/crates/bevy_asset_preview/tests/common/mod.rs b/crates/bevy_asset_preview/tests/common/mod.rs new file mode 100644 index 00000000..51294167 --- /dev/null +++ b/crates/bevy_asset_preview/tests/common/mod.rs @@ -0,0 +1,342 @@ +use bevy::{ + asset::AssetPath, + prelude::*, + render::{ + render_asset::RenderAssetUsages, + render_resource::{Extent3d, TextureDimension, TextureFormat}, + }, +}; +use bevy_asset_preview::{AssetLoadCompleted, PreviewCache}; +use gltf::json::{ + Accessor, Buffer, Mesh, Node, Root, Scene, Value, + accessor::{ComponentType, GenericComponentType, Type}, + buffer::{Target, View}, + mesh::{Mode, Primitive, Semantic}, + validation::{Checked::Valid, USize64}, +}; + +pub fn create_test_image( + images: &mut Assets, + width: u32, + height: u32, + color: [u8; 4], +) -> Handle { + let pixel_data: Vec = (0..(width * height)) + .flat_map(|_| color.iter().copied()) + .collect(); + + let image = Image::new_fill( + Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + &pixel_data, + TextureFormat::Rgba8UnormSrgb, + RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD, + ); + + images.add(image) +} + +pub fn save_test_image( + image: &Image, + path: &std::path::Path, +) -> Result<(), Box> { + let dynamic_image = image + .clone() + .try_into_dynamic() + .map_err(|e| format!("Failed to convert image: {:?}", e))?; + + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("png") + .to_lowercase(); + + match ext.as_str() { + "jpg" | "jpeg" => { + let rgb_image = dynamic_image.into_rgb8(); + rgb_image.save(path)?; + } + _ => { + let rgba_image = dynamic_image.into_rgba8(); + rgba_image.save(path)?; + } + } + Ok(()) +} + +pub fn create_test_model() -> (Root, Vec) { + // Simple cube vertices (position only) + let positions: Vec<[f32; 3]> = vec![ + // Front face + [-0.5, -0.5, 0.5], + [0.5, -0.5, 0.5], + [0.5, 0.5, 0.5], + [-0.5, 0.5, 0.5], + // Back face + [-0.5, -0.5, -0.5], + [-0.5, 0.5, -0.5], + [0.5, 0.5, -0.5], + [0.5, -0.5, -0.5], + // Top face + [-0.5, 0.5, -0.5], + [-0.5, 0.5, 0.5], + [0.5, 0.5, 0.5], + [0.5, 0.5, -0.5], + // Bottom face + [-0.5, -0.5, -0.5], + [0.5, -0.5, -0.5], + [0.5, -0.5, 0.5], + [-0.5, -0.5, 0.5], + // Right face + [0.5, -0.5, -0.5], + [0.5, 0.5, -0.5], + [0.5, 0.5, 0.5], + [0.5, -0.5, 0.5], + // Left face + [-0.5, -0.5, -0.5], + [-0.5, -0.5, 0.5], + [-0.5, 0.5, 0.5], + [-0.5, 0.5, -0.5], + ]; + + // Indices for triangles + let indices: Vec = vec![ + 0, 1, 2, 0, 2, 3, // Front + 4, 5, 6, 4, 6, 7, // Back + 8, 9, 10, 8, 10, 11, // Top + 12, 13, 14, 12, 14, 15, // Bottom + 16, 17, 18, 16, 18, 19, // Right + 20, 21, 22, 20, 22, 23, // Left + ]; + + let mut root = Root::default(); + + // Create buffer with positions and indices + let positions_bytes: &[u8] = bytemuck::cast_slice(&positions); + let indices_bytes: &[u8] = bytemuck::cast_slice(&indices); + let mut buffer_data = Vec::from(positions_bytes); + buffer_data.extend_from_slice(indices_bytes); + + // Pad buffer to multiple of 4 + while buffer_data.len() % 4 != 0 { + buffer_data.push(0); + } + + let buffer = root.push(Buffer { + byte_length: USize64::from(buffer_data.len()), + extensions: Default::default(), + extras: Default::default(), + name: None, + uri: None, // glb doesn't use uri + }); + + // Buffer view for positions + let positions_view = root.push(View { + buffer, + byte_length: USize64::from(positions_bytes.len()), + byte_offset: Some(USize64(0)), + byte_stride: None, + extensions: Default::default(), + extras: Default::default(), + name: None, + target: Some(Valid(Target::ArrayBuffer)), + }); + + // Buffer view for indices + let indices_view = root.push(View { + buffer, + byte_length: USize64::from(indices_bytes.len()), + byte_offset: Some(USize64::from(positions_bytes.len())), + byte_stride: None, + extensions: Default::default(), + extras: Default::default(), + name: None, + target: Some(Valid(Target::ElementArrayBuffer)), + }); + + // Accessor for positions + let positions_accessor = root.push(Accessor { + buffer_view: Some(positions_view), + byte_offset: None, + count: USize64::from(positions.len()), + component_type: Valid(GenericComponentType(ComponentType::F32)), + extensions: Default::default(), + extras: Default::default(), + type_: Valid(Type::Vec3), + min: Some(Value::from(vec![-0.5, -0.5, -0.5])), + max: Some(Value::from(vec![0.5, 0.5, 0.5])), + name: None, + normalized: false, + sparse: None, + }); + + // Accessor for indices + let indices_accessor = root.push(Accessor { + buffer_view: Some(indices_view), + byte_offset: None, + count: USize64::from(indices.len()), + component_type: Valid(GenericComponentType(ComponentType::U16)), + extensions: Default::default(), + extras: Default::default(), + type_: Valid(Type::Scalar), + min: None, + max: None, + name: None, + normalized: false, + sparse: None, + }); + + // Create mesh primitive + let primitive = Primitive { + attributes: { + let mut map = std::collections::BTreeMap::new(); + map.insert(Valid(Semantic::Positions), positions_accessor); + map + }, + extensions: Default::default(), + extras: Default::default(), + indices: Some(indices_accessor), + material: None, + mode: Valid(Mode::Triangles), + targets: None, + }; + + let mesh = root.push(Mesh { + extensions: Default::default(), + extras: Default::default(), + name: None, + primitives: vec![primitive], + weights: None, + }); + + let node = root.push(Node { + mesh: Some(mesh), + ..Default::default() + }); + + root.scenes = vec![Scene { + extensions: Default::default(), + extras: Default::default(), + name: None, + nodes: vec![node], + }]; + + (root, buffer_data) +} + +pub fn save_test_model( + root: &Root, + buffer_data: &[u8], + path: &std::path::Path, +) -> Result<(), Box> { + use gltf::json::serialize; + use std::borrow::Cow; + + // Serialize to JSON + let json_string = serialize::to_string(root)?; + let mut json_offset = json_string.len(); + while json_offset % 4 != 0 { + json_offset += 1; + } + + // Create GLB structure + let glb = gltf::binary::Glb { + header: gltf::binary::Header { + magic: *b"glTF", + version: 2, + length: (json_offset + buffer_data.len()) + .try_into() + .map_err(|_| "File size exceeds binary glTF limit")?, + }, + bin: Some(Cow::Borrowed(buffer_data)), + json: Cow::Owned({ + let mut json_bytes = json_string.into_bytes(); + while json_bytes.len() % 4 != 0 { + json_bytes.push(0x20); // pad with space + } + json_bytes + }), + }; + + // Write to file + let writer = std::fs::File::create(path)?; + glb.to_writer(writer)?; + + Ok(()) +} + +pub fn wait_for(app: &mut App, condition: F, max_iterations: usize) -> bool +where + F: Fn(&App) -> bool, +{ + // Check condition before first update + if condition(app) { + return true; + } + + for _ in 0..max_iterations { + app.update(); + if condition(app) { + return true; + } + } + + false +} + +pub fn wait_for_preview_cached( + app: &mut App, + asset_path: &AssetPath<'static>, + resolution: Option, + max_iterations: usize, +) -> bool { + wait_for( + app, + |app| { + let world = app.world(); + let cache = world.resource::(); + cache.get_by_path(asset_path, resolution).is_some() + }, + max_iterations, + ) +} + +pub fn wait_for_load_completion( + app: &mut App, + expected_count: usize, + max_iterations: usize, +) -> Vec { + let mut loaded_count = 0; + let mut processed_task_ids = std::collections::HashSet::new(); + let mut completed_events = Vec::new(); + let mut iterations = 0; + + while loaded_count < expected_count && iterations < max_iterations { + app.update(); + iterations += 1; + + let world = app.world(); + let load_events = world.resource::>(); + let mut cursor = load_events.get_cursor(); + for event in cursor.read(load_events) { + if processed_task_ids.insert(event.task_id) { + loaded_count += 1; + completed_events.push(event.clone()); + } + } + } + + assert!( + loaded_count >= expected_count, + "Expected at least {} load completions, got {} after {} iterations", + expected_count, + loaded_count, + iterations + ); + + completed_events +} diff --git a/crates/bevy_asset_preview/tests/preview_test.rs b/crates/bevy_asset_preview/tests/preview_test.rs new file mode 100644 index 00000000..6e4fab63 --- /dev/null +++ b/crates/bevy_asset_preview/tests/preview_test.rs @@ -0,0 +1,690 @@ +mod common; + +use std::fs; +use std::path::PathBuf; +use std::time::Duration; + +use bevy::{ + app::ScheduleRunnerPlugin, + asset::{AssetPath, AssetPlugin}, + image::{CompressedImageFormats, ImageLoader}, + prelude::*, + render::view::screenshot::Screenshot, + window::ExitCondition, + winit::WinitPlugin, +}; +use bevy_asset_preview::{ + PendingPreviewLoad, PendingPreviewRequest, PreviewAsset, PreviewCache, PreviewConfig, + PreviewReady, PreviewScene3D, WaitingForScreenshot, +}; +use tempfile::TempDir; + +use common::{ + create_test_image, create_test_model, save_test_image, save_test_model, + wait_for_load_completion, wait_for_preview_cached, +}; + +#[test] +fn test_image_preview_generation() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let assets_dir = temp_dir.path().join("assets"); + fs::create_dir_all(&assets_dir).expect("Failed to create assets directory"); + + unsafe { + std::env::set_var( + "BEVY_ASSET_ROOT", + temp_dir + .path() + .to_str() + .expect("Failed to convert path to string"), + ); + } + + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(AssetPlugin { + file_path: assets_dir.display().to_string(), + ..Default::default() + }) + .add_plugins(bevy_asset_preview::AssetPreviewPlugin) + .init_asset::() + .init_asset::() + .init_asset::() + .init_asset::() + .register_asset_loader(ImageLoader::new(CompressedImageFormats::NONE)) + .add_event::(); + + app.finish(); + app.update(); + + // Create test image file + let mut images = app.world_mut().resource_mut::>(); + let filename = "test_preview.png"; + let handle = create_test_image(&mut images, 512, 512, [255, 0, 0, 255]); + let image = images.get(&handle).unwrap(); + let image_path = assets_dir.join(filename); + save_test_image(image, &image_path).expect("Failed to save test image"); + drop(images); + + // Request preview + let entity = app + .world_mut() + .spawn(PreviewAsset(PathBuf::from(filename))) + .id(); + + app.update(); + + // Verify initial state + let world = app.world(); + assert!( + world.entity(entity).contains::(), + "Should have ImageNode placeholder" + ); + assert!( + world.entity(entity).contains::(), + "Should have PendingPreviewLoad component" + ); + + // Wait for load completion + let completed_events = wait_for_load_completion(&mut app, 1, 3000); + assert_eq!(completed_events.len(), 1); + + // Wait for preview generation to complete + let asset_path: AssetPath<'static> = AssetPath::from(filename).into_owned(); + let config = app.world().resource::(); + let preview_resolution = config.resolutions.iter().max().copied().unwrap_or(256); + assert!( + wait_for_preview_cached(&mut app, &asset_path, Some(preview_resolution), 1000), + "Preview should be cached within max iterations" + ); + + // Verify preview was generated and cached + let world = app.world(); + let cache = world.resource::(); + let config = world.resource::(); + + // Check that all configured resolutions are cached + for &resolution in &config.resolutions { + let cache_entry = cache.get_by_path(&asset_path, Some(resolution)); + assert!( + cache_entry.is_some(), + "Should have cached preview at {}px resolution", + resolution + ); + } + + // Verify ImageNode was updated with preview + let image_node = world.entity(entity).get::(); + assert!( + image_node.is_some(), + "Entity should have ImageNode after preview generation" + ); + let image_node = image_node.unwrap(); + let images = world.resource::>(); + assert!( + images.get(&image_node.image).is_some(), + "ImageNode should reference a valid image asset" + ); + + // Verify cleanup + assert!( + !world.entity(entity).contains::(), + "PreviewAsset should be removed after preview generation" + ); + assert!( + !world.entity(entity).contains::(), + "PendingPreviewLoad should be removed after preview generation" + ); + + // Verify entity still exists and is valid + assert!( + world.entity(entity).contains::(), + "Entity should still exist with ImageNode after cleanup" + ); +} + +#[test] +fn test_image_preview_cache_hit() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let assets_dir = temp_dir.path().join("assets"); + fs::create_dir_all(&assets_dir).expect("Failed to create assets directory"); + + unsafe { + std::env::set_var( + "BEVY_ASSET_ROOT", + temp_dir + .path() + .to_str() + .expect("Failed to convert path to string"), + ); + } + + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(AssetPlugin { + file_path: assets_dir.display().to_string(), + ..Default::default() + }) + .add_plugins(bevy_asset_preview::AssetPreviewPlugin) + .init_asset::() + .init_asset::() + .init_asset::() + .init_asset::() + .register_asset_loader(ImageLoader::new(CompressedImageFormats::NONE)) + .add_event::(); + + app.finish(); + app.update(); + + // Create test image file + let mut images = app.world_mut().resource_mut::>(); + let filename = "cached_preview.png"; + let handle = create_test_image(&mut images, 256, 256, [0, 255, 0, 255]); + let image = images.get(&handle).unwrap(); + let image_path = assets_dir.join(filename); + save_test_image(image, &image_path).expect("Failed to save test image"); + drop(images); + + // First request - should generate preview + let entity1 = app + .world_mut() + .spawn(PreviewAsset(PathBuf::from(filename))) + .id(); + + app.update(); + + // Wait for first preview to complete + wait_for_load_completion(&mut app, 1, 3000); + + // Wait for preview generation to complete + let asset_path: AssetPath<'static> = AssetPath::from(filename).into_owned(); + let config = app.world().resource::(); + let preview_resolution = config.resolutions.iter().max().copied().unwrap_or(256); + assert!( + wait_for_preview_cached(&mut app, &asset_path, Some(preview_resolution), 1000), + "Preview should be cached within max iterations" + ); + + // Second request - should hit cache immediately + let entity2 = app + .world_mut() + .spawn(PreviewAsset(PathBuf::from(filename))) + .id(); + + app.update(); + + // Verify cache hit - should not have PendingPreviewLoad + let world = app.world(); + assert!( + world.entity(entity2).contains::(), + "Second request should have ImageNode immediately" + ); + assert!( + !world.entity(entity2).contains::(), + "Second request should not have PendingPreviewLoad (cache hit)" + ); + assert!( + !world.entity(entity2).contains::(), + "PreviewAsset should be removed immediately on cache hit" + ); + + // Verify both entities have ImageNode with valid images + let image_node1 = world.entity(entity1).get::(); + let image_node2 = world.entity(entity2).get::(); + assert!( + image_node1.is_some() && image_node2.is_some(), + "Both entities should have ImageNode" + ); + + let images = world.resource::>(); + let image_node1 = image_node1.unwrap(); + let image_node2 = image_node2.unwrap(); + + // Verify both ImageNodes reference valid images + assert!( + images.get(&image_node1.image).is_some(), + "Entity1 ImageNode should reference a valid image" + ); + assert!( + images.get(&image_node2.image).is_some(), + "Entity2 ImageNode should reference a valid image" + ); + + // Verify both entities reference the same cached image + assert_eq!( + image_node1.image.id(), + image_node2.image.id(), + "Both entities should reference the same cached preview image" + ); + + // Verify first entity was also cleaned up + assert!( + !world.entity(entity1).contains::(), + "First entity should have PreviewAsset removed" + ); + assert!( + !world.entity(entity1).contains::(), + "First entity should have PendingPreviewLoad removed" + ); +} + +#[test] +fn test_model_preview_generation() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let assets_dir = temp_dir.path().join("assets"); + fs::create_dir_all(&assets_dir).expect("Failed to create assets directory"); + + unsafe { + std::env::set_var( + "BEVY_ASSET_ROOT", + temp_dir + .path() + .to_str() + .expect("Failed to convert path to string"), + ); + } + + let mut app = App::new(); + app.add_plugins( + DefaultPlugins + .set(AssetPlugin { + file_path: assets_dir.display().to_string(), + ..Default::default() + }) + .set(WindowPlugin { + primary_window: None, + // Don't automatically exit due to having no windows + exit_condition: ExitCondition::DontExit, + ..Default::default() + }) + // WinitPlugin will panic in environments without a display server + .disable::(), + ) + .add_plugins(bevy_asset_preview::AssetPreviewPlugin) + .register_asset_loader(ImageLoader::new(CompressedImageFormats::NONE)) + .add_event::() + // ScheduleRunnerPlugin provides an alternative to the default bevy_winit app runner + // which manages the loop without creating a window + .add_plugins(ScheduleRunnerPlugin::run_loop(Duration::from_secs_f64( + 1.0 / 60.0, + ))); + + app.finish(); + // Run a few updates to allow render pipeline to initialize + for _ in 0..3 { + app.update(); + } + + // Create a simple glb model file + let filename = "test_model.glb"; + let model_path = assets_dir.join(filename); + let (root, buffer_data) = create_test_model(); + save_test_model(&root, &buffer_data, &model_path).expect("Failed to save glTF model"); + + // Request preview for model + let entity = app + .world_mut() + .spawn(PreviewAsset(PathBuf::from(filename))) + .id(); + + app.update(); + + // Verify initial state - should have ImageNode placeholder + let world = app.world(); + assert!( + world.entity(entity).contains::(), + "Should have ImageNode placeholder for model" + ); + + // PreviewAsset may or may not be removed immediately for model files + // depending on cache state, so we don't assert on this + + // Find the entity with PendingPreviewRequest (created by request_3d_preview) + let mut request_entity = None; + let mut request_task_id = None; + let mut request_handle = None; + + { + let mut preview_request_query = app.world_mut().query::<(Entity, &PendingPreviewRequest)>(); + let world = app.world(); + for (req_entity, request) in preview_request_query.iter(world) { + if request.path.to_string() == filename { + request_entity = Some(req_entity); + request_task_id = Some(request.task_id); + + // Extract handle for later use + match &request.request_type { + bevy_asset_preview::PreviewRequestType::ModelFile { handle, format } => { + assert_eq!( + format, + &bevy_asset_preview::ModelFormat::Gltf, + "Model file should be recognized as Gltf format" + ); + request_handle = Some(handle.clone()); + } + _ => panic!( + "Expected ModelFile request type, got {:?}", + request.request_type + ), + } + break; + } + } + } + + assert!( + request_entity.is_some(), + "Should have created PendingPreviewRequest for model file" + ); + let request_entity = request_entity.unwrap(); + let request_task_id = request_task_id.unwrap(); + let request_handle = request_handle.unwrap(); + + // Wait for PreviewScene3D to be created + let mut iterations = 0; + let mut preview_scene_created = false; + while !preview_scene_created && iterations < 1000 { + app.update(); + iterations += 1; + let world = app.world(); + if world.entity(request_entity).contains::() { + preview_scene_created = true; + } + } + assert!( + preview_scene_created, + "PreviewScene3D should be created within max iterations" + ); + + // Verify PreviewScene3D properties + let world = app.world(); + let scene_3d = world + .entity(request_entity) + .get::() + .unwrap(); + assert_eq!( + scene_3d.path.to_string(), + filename, + "PreviewScene3D should have correct path" + ); + assert_eq!( + scene_3d.task_id, request_task_id, + "PreviewScene3D should have correct task_id" + ); + + // Wait for scene to load + let mut iterations = 0; + let mut scene_loaded = false; + while !scene_loaded && iterations < 3000 { + app.update(); + iterations += 1; + let world = app.world(); + let scene_assets = world.resource::>(); + if scene_assets.get(&request_handle).is_some() { + scene_loaded = true; + } + } + + // If scene is loaded, verify preview_entity is set + if scene_loaded { + let world = app.world(); + let scene_3d = world + .entity(request_entity) + .get::() + .unwrap(); + assert!( + scene_3d.preview_entity.is_some(), + "PreviewScene3D should have preview_entity set after scene loads" + ); + } + + // Wait for WaitingForScreenshot component to be added (indicates scene is ready for screenshot) + let mut iterations = 0; + let mut waiting_for_screenshot = false; + while !waiting_for_screenshot && iterations < 1000 { + app.update(); + iterations += 1; + let world = app.world(); + if world + .entity(request_entity) + .contains::() + { + waiting_for_screenshot = true; + } + } + assert!( + waiting_for_screenshot, + "WaitingForScreenshot should be added when scene is ready" + ); + + // Wait for Screenshot component to be added to camera (capture_preview_screenshot system) + let world = app.world(); + let scene_3d = world + .entity(request_entity) + .get::() + .unwrap(); + let camera_entity = scene_3d.camera_entity; + + iterations = 0; + let mut screenshot_added = false; + while !screenshot_added && iterations < 100 { + app.update(); + iterations += 1; + let world = app.world(); + if world.entity(camera_entity).contains::() { + screenshot_added = true; + } + } + assert!( + screenshot_added, + "Screenshot component should be added to camera entity" + ); + + // Wait for rendering to complete and preview to be cached + // Render pipeline needs several frames to process the screenshot + let asset_path: AssetPath<'static> = AssetPath::from(filename).into_owned(); + let config = app.world().resource::(); + let preview_resolution = config.resolutions.iter().max().copied().unwrap_or(256); + + // Give render pipeline time to process screenshot and cache preview + // ScreenshotCaptured is an EntityEvent, so we wait for the cache to be populated + let preview_cached = + wait_for_preview_cached(&mut app, &asset_path, Some(preview_resolution), 2000); + + // Verify final state + let world = app.world(); + let cache = world.resource::(); + let config = world.resource::(); + let preview_resolution = config.resolutions.iter().max().copied().unwrap_or(256); + + // With render pipeline enabled, preview should be cached + assert!( + preview_cached, + "Preview should be cached after rendering with render pipeline enabled" + ); + + // Verify preview was actually cached + let cache_entry = cache.get_by_path(&asset_path, Some(preview_resolution)); + assert!( + cache_entry.is_some(), + "Preview should be in cache after rendering" + ); + + // Verify the cached image is valid + let cache_entry = cache_entry.unwrap(); + let images = world.resource::>(); + let cached_image = images.get(&cache_entry.image_handle); + assert!( + cached_image.is_some(), + "Cached preview image should be a valid asset" + ); + + // Verify original entity's ImageNode references the cached preview + let image_node = world.entity(entity).get::(); + assert!(image_node.is_some(), "Entity should have ImageNode"); + let image_node = image_node.unwrap(); + + // ImageNode should reference the cached preview image (not placeholder) + let image_node_image = images.get(&image_node.image); + assert!( + image_node_image.is_some(), + "ImageNode should reference a valid image" + ); + + // Verify ImageNode references the cached preview (same handle or same image data) + // The image should not be the placeholder + let placeholder_path = + bevy::asset::AssetPath::from("embedded://bevy_asset_browser/assets/file_icon.png"); + let placeholder_handle: Handle = world + .resource::() + .load(placeholder_path); + + // ImageNode should reference the preview, not the placeholder + assert_ne!( + image_node.image.id(), + placeholder_handle.id(), + "ImageNode should reference preview image, not placeholder" + ); + + // Verify request entity was cleaned up after preview generation + assert!( + !world + .entity(request_entity) + .contains::(), + "PendingPreviewRequest should be removed after preview generation" + ); + assert!( + !world + .entity(request_entity) + .contains::(), + "WaitingForScreenshot should be removed after screenshot is captured" + ); + + // Verify PreviewScene3D still exists with all required components + assert!( + world.entity(request_entity).contains::(), + "PreviewScene3D should still exist" + ); + let final_scene_3d = world + .entity(request_entity) + .get::() + .unwrap(); + assert!( + final_scene_3d.preview_entity.is_some(), + "PreviewScene3D should have preview_entity after scene loads" + ); + // Verify camera entity still exists (by checking it has components) + assert!( + world + .entity(final_scene_3d.camera_entity) + .contains::(), + "Camera entity should still exist with Camera3d component" + ); + + // Verify the preview was successfully generated and cached + // This completes the full rendering pipeline test +} + +#[test] +fn test_preview_error_handling() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let assets_dir = temp_dir.path().join("assets"); + fs::create_dir_all(&assets_dir).expect("Failed to create assets directory"); + + unsafe { + std::env::set_var( + "BEVY_ASSET_ROOT", + temp_dir + .path() + .to_str() + .expect("Failed to convert path to string"), + ); + } + + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(AssetPlugin { + file_path: assets_dir.display().to_string(), + ..Default::default() + }) + .add_plugins(bevy_asset_preview::AssetPreviewPlugin) + .init_asset::() + .init_asset::() + .init_asset::() + .init_asset::() + .register_asset_loader(ImageLoader::new(CompressedImageFormats::NONE)); + + app.finish(); + app.update(); + + // Request preview for non-existent file + let entity = app + .world_mut() + .spawn(PreviewAsset(PathBuf::from("non_existent.png"))) + .id(); + + app.update(); + + // Verify initial state + let world = app.world(); + assert!( + world.entity(entity).contains::(), + "Should have ImageNode placeholder for error case" + ); + assert!( + world.entity(entity).contains::(), + "Should have PendingPreviewLoad for non-existent file" + ); + + // Wait for failure to be handled + let mut iterations = 0; + let mut component_removed = false; + while !component_removed && iterations < 2000 { + app.update(); + iterations += 1; + let world = app.world(); + if !world.entity(entity).contains::() { + component_removed = true; + } + } + assert!( + component_removed, + "PendingPreviewLoad should be removed after error handling" + ); + + // Verify cleanup after error + let world = app.world(); + assert!( + !world.entity(entity).contains::(), + "PendingPreviewLoad should be cleaned up after error" + ); + assert!( + !world.entity(entity).contains::(), + "PreviewAsset should be removed after error" + ); + assert!( + world.entity(entity).contains::(), + "ImageNode should remain after error (placeholder)" + ); + + // Verify ImageNode still references placeholder + let image_node = world.entity(entity).get::(); + assert!( + image_node.is_some(), + "Entity should have ImageNode after error" + ); + + // Verify entity still exists (by checking it has components) + assert!( + world.entity(entity).contains::(), + "Entity should still exist with ImageNode after error handling" + ); + + // Verify no cache entry was created for non-existent file + let cache = world.resource::(); + let asset_path: AssetPath<'static> = AssetPath::from("non_existent.png").into_owned(); + assert!( + cache.get_by_path(&asset_path, None).is_none(), + "No cache entry should be created for non-existent file" + ); +} diff --git a/crates/bevy_asset_preview/tests/workflow_test.rs b/crates/bevy_asset_preview/tests/workflow_test.rs deleted file mode 100644 index 63aa0d89..00000000 --- a/crates/bevy_asset_preview/tests/workflow_test.rs +++ /dev/null @@ -1,1024 +0,0 @@ -use std::borrow::Cow; -use std::fs; -use std::path::PathBuf; - -use bevy::{ - asset::{AssetPath, AssetPlugin, io::file::FileAssetWriter}, - image::{CompressedImageFormats, ImageLoader}, - prelude::*, - render::{ - render_asset::RenderAssetUsages, - render_resource::{Extent3d, TextureDimension, TextureFormat}, - }, -}; -use bevy_asset_preview::{ - ActiveSaveTask, AssetHotReloaded, AssetLoadCompleted, AssetLoadFailed, AssetLoader, - PreviewCache, PreviewConfig, SaveCompleted, SaveTaskTracker, monitor_save_completion, - save_image, -}; -use tempfile::TempDir; -use gltf::json::{ - Accessor, Buffer, Mesh, Node, Root, Scene, Value, - accessor::{ComponentType, GenericComponentType, Type}, - buffer::{Target, View}, - mesh::{Mode, Primitive, Semantic}, - serialize, - validation::{Checked::Valid, USize64}, -}; - -// ========== Helper functions ========== - -fn create_test_image( - images: &mut Assets, - width: u32, - height: u32, - color: [u8; 4], -) -> Handle { - let pixel_data: Vec = (0..(width * height)) - .flat_map(|_| color.iter().copied()) - .collect(); - - let image = Image::new_fill( - Extent3d { - width, - height, - depth_or_array_layers: 1, - }, - TextureDimension::D2, - &pixel_data, - TextureFormat::Rgba8UnormSrgb, - RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD, - ); - - images.add(image) -} - -fn save_test_image( - image: &Image, - path: &std::path::Path, -) -> Result<(), Box> { - let dynamic_image = image - .clone() - .try_into_dynamic() - .map_err(|e| format!("Failed to convert image: {:?}", e))?; - - let ext = path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("png") - .to_lowercase(); - - match ext.as_str() { - "jpg" | "jpeg" => { - let rgb_image = dynamic_image.into_rgb8(); - rgb_image.save(path)?; - } - _ => { - let rgba_image = dynamic_image.into_rgba8(); - rgba_image.save(path)?; - } - } - Ok(()) -} - -/// Save a simple cube model as glb file -fn save_model(path: &std::path::Path) -> Result<(), Box> { - // Simple cube vertices (position only) - let positions: Vec<[f32; 3]> = vec![ - // Front face - [-0.5, -0.5, 0.5], - [0.5, -0.5, 0.5], - [0.5, 0.5, 0.5], - [-0.5, 0.5, 0.5], - // Back face - [-0.5, -0.5, -0.5], - [-0.5, 0.5, -0.5], - [0.5, 0.5, -0.5], - [0.5, -0.5, -0.5], - // Top face - [-0.5, 0.5, -0.5], - [-0.5, 0.5, 0.5], - [0.5, 0.5, 0.5], - [0.5, 0.5, -0.5], - // Bottom face - [-0.5, -0.5, -0.5], - [0.5, -0.5, -0.5], - [0.5, -0.5, 0.5], - [-0.5, -0.5, 0.5], - // Right face - [0.5, -0.5, -0.5], - [0.5, 0.5, -0.5], - [0.5, 0.5, 0.5], - [0.5, -0.5, 0.5], - // Left face - [-0.5, -0.5, -0.5], - [-0.5, -0.5, 0.5], - [-0.5, 0.5, 0.5], - [-0.5, 0.5, -0.5], - ]; - - // Indices for triangles - let indices: Vec = vec![ - 0, 1, 2, 0, 2, 3, // Front - 4, 5, 6, 4, 6, 7, // Back - 8, 9, 10, 8, 10, 11, // Top - 12, 13, 14, 12, 14, 15, // Bottom - 16, 17, 18, 16, 18, 19, // Right - 20, 21, 22, 20, 22, 23, // Left - ]; - - let mut root = Root::default(); - - // Create buffer with positions and indices - let positions_bytes: &[u8] = bytemuck::cast_slice(&positions); - let indices_bytes: &[u8] = bytemuck::cast_slice(&indices); - let mut buffer_data = Vec::from(positions_bytes); - buffer_data.extend_from_slice(indices_bytes); - - // Pad buffer to multiple of 4 - while buffer_data.len() % 4 != 0 { - buffer_data.push(0); - } - - let buffer = root.push(Buffer { - byte_length: USize64::from(buffer_data.len()), - extensions: Default::default(), - extras: Default::default(), - name: None, - uri: None, // glb doesn't use uri - }); - - // Buffer view for positions - let positions_view = root.push(View { - buffer, - byte_length: USize64::from(positions_bytes.len()), - byte_offset: Some(USize64(0)), - byte_stride: None, - extensions: Default::default(), - extras: Default::default(), - name: None, - target: Some(Valid(Target::ArrayBuffer)), - }); - - // Buffer view for indices - let indices_view = root.push(View { - buffer, - byte_length: USize64::from(indices_bytes.len()), - byte_offset: Some(USize64::from(positions_bytes.len())), - byte_stride: None, - extensions: Default::default(), - extras: Default::default(), - name: None, - target: Some(Valid(Target::ElementArrayBuffer)), - }); - - // Accessor for positions - let positions_accessor = root.push(Accessor { - buffer_view: Some(positions_view), - byte_offset: None, - count: USize64::from(positions.len()), - component_type: Valid(GenericComponentType(ComponentType::F32)), - extensions: Default::default(), - extras: Default::default(), - type_: Valid(Type::Vec3), - min: Some(Value::from(vec![-0.5, -0.5, -0.5])), - max: Some(Value::from(vec![0.5, 0.5, 0.5])), - name: None, - normalized: false, - sparse: None, - }); - - // Accessor for indices - let indices_accessor = root.push(Accessor { - buffer_view: Some(indices_view), - byte_offset: None, - count: USize64::from(indices.len()), - component_type: Valid(GenericComponentType(ComponentType::U16)), - extensions: Default::default(), - extras: Default::default(), - type_: Valid(Type::Scalar), - min: None, - max: None, - name: None, - normalized: false, - sparse: None, - }); - - // Create mesh primitive - let primitive = Primitive { - attributes: { - let mut map = std::collections::BTreeMap::new(); - map.insert(Valid(Semantic::Positions), positions_accessor); - map - }, - extensions: Default::default(), - extras: Default::default(), - indices: Some(indices_accessor), - material: None, - mode: Valid(Mode::Triangles), - targets: None, - }; - - let mesh = root.push(Mesh { - extensions: Default::default(), - extras: Default::default(), - name: None, - primitives: vec![primitive], - weights: None, - }); - - let node = root.push(Node { - mesh: Some(mesh), - ..Default::default() - }); - - root.scenes = vec![Scene { - extensions: Default::default(), - extras: Default::default(), - name: None, - nodes: vec![node], - }]; - - // Save as glb (binary glTF) - let json_string = serialize::to_string(&root)?; - let mut json_offset = json_string.len(); - while json_offset % 4 != 0 { - json_offset += 1; - } - - let glb = gltf::binary::Glb { - header: gltf::binary::Header { - magic: *b"glTF", - version: 2, - length: (json_offset + buffer_data.len()) - .try_into() - .map_err(|_| "File size exceeds binary glTF limit")?, - }, - bin: Some(Cow::Owned(buffer_data)), - json: Cow::Owned({ - let mut json_bytes = json_string.into_bytes(); - while json_bytes.len() % 4 != 0 { - json_bytes.push(0x20); // pad with space - } - json_bytes - }), - }; - - let writer = std::fs::File::create(path)?; - glb.to_writer(writer)?; - - Ok(()) -} - -fn wait_for_save_completion( - app: &mut App, - expected_count: usize, - max_iterations: usize, -) -> Vec { - let mut save_completed_count = 0; - let mut processed_task_ids = std::collections::HashSet::new(); - let mut completed_events = Vec::new(); - let mut iterations = 0; - - while save_completed_count < expected_count && iterations < max_iterations { - app.update(); - iterations += 1; - - let world = app.world(); - let save_events = world.resource::>(); - let mut cursor = save_events.get_cursor(); - for event in cursor.read(save_events) { - if processed_task_ids.insert(event.task_id) { - match &event.result { - Ok(_) => { - save_completed_count += 1; - completed_events.push(event.clone()); - } - Err(e) => panic!("Save task {} failed: {}", event.task_id, e), - } - } - } - } - - assert_eq!( - save_completed_count, expected_count, - "All save tasks should complete" - ); - - completed_events -} - -fn wait_for_load_completion( - app: &mut App, - expected_count: usize, - max_iterations: usize, -) -> (Vec, usize, usize) { - let mut loaded_count = 0; - let mut processed_task_ids = std::collections::HashSet::new(); - let mut completed_events = Vec::new(); - let mut max_active_tasks = 0; - let mut initial_queue_len = 0; - let mut queue_len_checked = false; - let mut iterations = 0; - - while loaded_count < expected_count && iterations < max_iterations { - app.update(); - iterations += 1; - - let world = app.world(); - let loader = world.resource::(); - let active_tasks = loader.active_tasks(); - let queue_len = loader.queue_len(); - - if !queue_len_checked && queue_len > 0 { - initial_queue_len = queue_len; - queue_len_checked = true; - } - - if active_tasks > max_active_tasks { - max_active_tasks = active_tasks; - } - - assert!( - active_tasks <= 4, - "Active tasks should not exceed max_concurrent (4), got {}", - active_tasks - ); - - let load_events = world.resource::>(); - let mut cursor = load_events.get_cursor(); - for event in cursor.read(load_events) { - if processed_task_ids.insert(event.task_id) { - loaded_count += 1; - completed_events.push(event.clone()); - } - } - } - - (completed_events, max_active_tasks, initial_queue_len) -} - -struct CleanupState { - pending_cleaned: bool, - active_task_cleaned: bool, - task_path_cleaned: bool, - iterations: usize, -} - -fn test_error_handling_for_nonexistent_file(app: &mut App) { - let non_existent_entity = app - .world_mut() - .spawn(bevy_asset_preview::PreviewAsset(PathBuf::from( - "non_existent.png", - ))) - .id(); - app.update(); - - let world = app.world(); - let pending_component = world - .entity(non_existent_entity) - .get::() - .expect("Non-existent file should have PendingPreviewLoad"); - let non_existent_task_id = pending_component.task_id; - - let loader = world.resource::(); - assert!( - loader.get_task_path(non_existent_task_id).is_some(), - "Task should be in queue after submission" - ); - - let cleanup_state = wait_for_failure_cleanup(app, non_existent_entity, non_existent_task_id); - verify_failure_cleanup_complete( - app, - non_existent_entity, - non_existent_task_id, - &cleanup_state, - ); -} - -fn wait_for_failure_cleanup(app: &mut App, entity: Entity, task_id: u64) -> CleanupState { - let mut iterations = 0; - const MAX_ITERATIONS: usize = 2000; - - let mut pending_cleaned = false; - let mut active_task_cleaned = false; - let mut task_path_cleaned = false; - - let mut active_task_query = app - .world_mut() - .query::<&bevy_asset_preview::ActiveLoadTask>(); - - while iterations < MAX_ITERATIONS { - app.update(); - iterations += 1; - let world = app.world(); - - pending_cleaned = !world - .entity(entity) - .contains::(); - - let has_active_task_now = active_task_query - .iter(world) - .any(|active_task| active_task.task_id == task_id); - active_task_cleaned = !has_active_task_now; - - let loader = world.resource::(); - task_path_cleaned = loader.get_task_path(task_id).is_none(); - - if pending_cleaned && active_task_cleaned && task_path_cleaned { - break; - } - } - - CleanupState { - pending_cleaned, - active_task_cleaned, - task_path_cleaned, - iterations, - } -} - -fn verify_failure_cleanup_complete( - app: &mut App, - entity: Entity, - task_id: u64, - state: &CleanupState, -) { - let mut active_task_query = app - .world_mut() - .query::<&bevy_asset_preview::ActiveLoadTask>(); - let world = app.world(); - - assert!( - !world - .entity(entity) - .contains::(), - "PendingPreviewLoad MUST be cleaned up (iterations: {})", - state.iterations - ); - - let has_active_task = active_task_query - .iter(world) - .any(|active_task| active_task.task_id == task_id); - assert!( - !has_active_task, - "ActiveLoadTask MUST be cleaned up (iterations: {})", - state.iterations - ); - - let loader = world.resource::(); - assert!( - loader.get_task_path(task_id).is_none(), - "Task path MUST be removed from loader (iterations: {})", - state.iterations - ); - - assert!( - !world - .entity(entity) - .contains::(), - "PreviewAsset must be cleaned up after failure" - ); - - assert!( - world.entity(entity).contains::(), - "ImageNode (placeholder) should remain after failure cleanup" - ); -} - -fn test_boundary_conditions(app: &mut App) { - let empty_path_entity = app - .world_mut() - .spawn(bevy_asset_preview::PreviewAsset(PathBuf::from(""))) - .id(); - app.update(); - - let world = app.world(); - assert!( - world.entity(empty_path_entity).contains::(), - "Empty path should have ImageNode (placeholder)" - ); - assert!( - !world - .entity(empty_path_entity) - .contains::(), - "Empty path should not have PendingPreviewLoad" - ); - - let special_char_entity = app - .world_mut() - .spawn(bevy_asset_preview::PreviewAsset(PathBuf::from( - "test_file_123.png", - ))) - .id(); - app.update(); - - let world = app.world(); - assert!( - world.entity(special_char_entity).contains::(), - "Special char path should have ImageNode" - ); -} - -#[test] -fn test_complete_workflow() { - let temp_dir = TempDir::new().expect("Failed to create temp directory"); - let assets_dir = temp_dir.path().join("assets"); - let cache_dir = temp_dir.path().join("cache"); - fs::create_dir_all(&assets_dir).expect("Failed to create assets directory"); - fs::create_dir_all(&cache_dir).expect("Failed to create cache directory"); - - unsafe { - std::env::set_var( - "BEVY_ASSET_ROOT", - temp_dir - .path() - .to_str() - .expect("Failed to convert path to string"), - ); - } - - let mut app = App::new(); - - // Register cache directory as asset source - let cache_dir_path = temp_dir.path().join("cache").join("asset_preview"); - app.register_asset_source( - "thumbnail_cache", - bevy::asset::io::AssetSourceBuilder::platform_default( - cache_dir_path - .to_str() - .expect("Cache dir path should be valid"), - None, - ), - ); - - // Initialize complete plugin system - app.add_plugins(MinimalPlugins) - .add_plugins(AssetPlugin { - file_path: assets_dir.display().to_string(), - ..Default::default() - }) - .add_plugins(bevy_asset_preview::AssetPreviewPlugin) - .init_asset::() - .register_asset_loader(ImageLoader::new(CompressedImageFormats::NONE)) - .add_event::() - .add_event::() - .add_event::() - .add_event::() - .init_resource::() - .add_systems(Update, monitor_save_completion); - - // Initialize Time resource by running one update - app.update(); - - // ========== Phase 1: Create files and save previews to cache ========== - let file_definitions = vec![ - ("icon.png", true, 64, 64, [255, 0, 0, 255]), // Small image, no compression - ("texture.png", true, 512, 512, [0, 255, 0, 255]), // Large image, needs compression - ("sprite.png", true, 800, 200, [0, 0, 255, 255]), // Wide image, needs compression - ("readme.md", false, 0, 0, [0, 0, 0, 0]), // Non-image file - ("script.rs", false, 0, 0, [0, 0, 0, 0]), // Non-image file - ]; - - let mut image_files = Vec::new(); - { - let mut images = app.world_mut().resource_mut::>(); - for (filename, is_image, width, height, color) in &file_definitions { - if *is_image { - let handle = create_test_image(&mut images, *width, *height, *color); - let image = images.get(&handle).unwrap(); - let path = assets_dir.join(filename); - save_test_image(image, &path).expect("Failed to save test image"); - image_files.push((filename.to_string(), handle)); - } else { - let path = assets_dir.join(filename); - fs::write(&path, format!("Content of {}", filename)) - .expect("Failed to write test file"); - } - } - } - - let save_tasks = app - .world_mut() - .resource_scope(|world, mut tracker: Mut| { - let images = world.get_resource::>().unwrap(); - let mut tasks = Vec::new(); - - for (filename, handle) in &image_files { - let writer = FileAssetWriter::new("", true); - let target_path = - AssetPath::from_path_buf(PathBuf::from("cache/asset_preview").join(filename)) - .into_owned(); - let task = save_image(handle.clone(), target_path.clone(), images, writer); - let task_id = tracker.create_task_id(); - let path_asset: AssetPath<'static> = - AssetPath::from_path_buf(PathBuf::from(filename)).into_owned(); - tracker.register_pending(task_id, path_asset.clone()); - tasks.push((task_id, path_asset, target_path, task)); - } - - tasks - }); - - let mut commands = app.world_mut().commands(); - for (task_id, path, target_path, task) in save_tasks { - commands.spawn(ActiveSaveTask { - task_id, - path, - target_path, - task, - }); - } - - wait_for_save_completion(&mut app, image_files.len(), 1000); - - std::thread::sleep(std::time::Duration::from_millis(100)); - for (filename, _) in &image_files { - let mut cached_path = cache_dir_path.join(filename); - cached_path.set_extension("webp"); - assert!( - cached_path.exists(), - "Cached preview file should exist: {:?}", - cached_path - ); - } - - // Phase 2: Preview request processing and initial state validation - let mut file_entities = Vec::new(); - for (filename, is_image, _, _, _) in &file_definitions { - let entity = app - .world_mut() - .spawn(bevy_asset_preview::PreviewAsset(PathBuf::from(filename))) - .id(); - file_entities.push((entity, filename.to_string(), *is_image)); - } - - app.update(); - - let world = app.world(); - for (entity, filename, is_image) in &file_entities { - assert!( - world.entity(*entity).contains::(), - "All files should have ImageNode: {}", - filename - ); - - if *is_image { - assert!( - world - .entity(*entity) - .contains::(), - "Image file {} should have PendingPreviewLoad", - filename - ); - } else { - assert!( - !world - .entity(*entity) - .contains::(), - "Non-image file {} should have PreviewAsset removed", - filename - ); - assert!( - !world - .entity(*entity) - .contains::(), - "Non-image file {} should not have PendingPreviewLoad", - filename - ); - } - } - - // Phase 3: Wait for load completion and validate - let image_entities: Vec<_> = file_entities - .iter() - .filter(|(_, _, is_image)| *is_image) - .map(|(entity, filename, _)| (*entity, filename.clone())) - .collect(); - - let (all_completed_events, max_active_tasks_observed, initial_queue_len) = - wait_for_load_completion(&mut app, image_entities.len(), 3000); - - assert!( - max_active_tasks_observed > 0, - "Should have observed active tasks during loading" - ); - assert!( - max_active_tasks_observed <= 4, - "Max active tasks should not exceed max_concurrent, got {}", - max_active_tasks_observed - ); - - if image_entities.len() > 4 { - assert!( - initial_queue_len > 0, - "Should have tasks in queue when loading more than max_concurrent tasks, got {}", - initial_queue_len - ); - } - - assert!( - all_completed_events.len() >= image_files.len(), - "Should have at least {} load completed events, got {}", - image_files.len(), - all_completed_events.len() - ); - - let world = app.world(); - let cache = world.resource::(); - let config = world.resource::(); - let images = world.resource::>(); - - assert!(!cache.is_empty(), "Cache should not be empty"); - - // Each image should have previews for all configured resolutions - let expected_cache_entries = image_files.len() * config.resolutions.len(); - assert_eq!( - cache.len(), - expected_cache_entries, - "Cache should contain exactly {} entries ({} images × {} resolutions)", - expected_cache_entries, - image_files.len(), - config.resolutions.len() - ); - - for (entity, filename, is_image) in &file_entities { - if *is_image { - assert!( - !world - .entity(*entity) - .contains::(), - "Image file {} should have PreviewAsset removed after loading", - filename - ); - assert!( - !world - .entity(*entity) - .contains::(), - "Image file {} should have PendingPreviewLoad removed after loading", - filename - ); - - let asset_path: AssetPath<'static> = AssetPath::from(filename.as_str()).into_owned(); - - // Check that all configured resolutions are cached - for &resolution in &config.resolutions { - let cache_entry_by_path = cache.get_by_path(&asset_path, Some(resolution)); - assert!( - cache_entry_by_path.is_some(), - "Image file {} should have {}px resolution cached", - filename, - resolution - ); - - let entry = cache_entry_by_path.unwrap(); - assert_eq!( - entry.resolution, resolution, - "Cached entry should have correct resolution {} for {}", - resolution, filename - ); - - let cache_entry_by_id = cache.get_by_id(entry.asset_id, Some(resolution)); - assert!( - cache_entry_by_id.is_some(), - "Cache entry should be accessible by ID for {} at {}px", - filename, - resolution - ); - assert_eq!( - entry.image_handle.id(), - cache_entry_by_id.unwrap().image_handle.id(), - "Cache entries by path and ID should match for {} at {}px", - filename, - resolution - ); - } - - // Check that highest resolution query works - let highest_entry = cache.get_by_path(&asset_path, None); - assert!( - highest_entry.is_some(), - "Image file {} should have highest resolution cached", - filename - ); - let highest_entry = highest_entry.unwrap(); - let highest_resolution = highest_entry.resolution; - let expected_highest = *config.resolutions.iter().max().unwrap(); - assert_eq!( - highest_resolution, expected_highest, - "Highest resolution should be {} for {}", - expected_highest, filename - ); - - // Validate highest resolution entry properties - assert!( - !highest_entry.timestamp.is_zero(), - "Cache entry for {} should have valid timestamp, got: {:?}", - filename, - highest_entry.timestamp - ); - assert!( - highest_entry.image_handle.is_strong(), - "Cache entry for {} should have strong handle", - filename - ); - - // Check compression for large images (using highest resolution) - if filename == "texture.png" || filename == "sprite.png" { - if let Some(preview_image) = images.get(&highest_entry.image_handle) { - let max_dimension = expected_highest; - assert!( - preview_image.width() <= max_dimension - && preview_image.height() <= max_dimension, - "Large image {} should be compressed, got {}x{}", - filename, - preview_image.width(), - preview_image.height() - ); - } - } - - // Check aspect ratio for wide image (using highest resolution) - if filename == "sprite.png" { - if let Some(preview_image) = images.get(&highest_entry.image_handle) { - // Original: 800x200 = 4:1, compressed should maintain aspect ratio - let expected_height = (200.0 * expected_highest as f32 / 800.0) as u32; - assert_eq!( - preview_image.width(), - expected_highest, - "Wide image width should be {}", - expected_highest - ); - assert_eq!( - preview_image.height(), - expected_height, - "Wide image should maintain aspect ratio, expected height: {}, got: {}", - expected_height, - preview_image.height() - ); - } - } - } - } - - let loader = app.world().resource::(); - assert_eq!( - loader.queue_len(), - 0, - "Loader queue should be empty after all tasks complete" - ); - assert_eq!( - loader.active_tasks(), - 0, - "Should have 0 active tasks after cleanup" - ); - - let mut query = app.world_mut().query::<&ActiveSaveTask>(); - let world = app.world(); - let save_task_count = query.iter(world).count(); - assert_eq!( - save_task_count, 0, - "All save task entities should be cleaned up, found {} remaining", - save_task_count - ); - - // Phase 4: Cache hit test - let mut second_batch_entities = Vec::new(); - for (filename, is_image, _, _, _) in &file_definitions { - if *is_image { - let entity = app - .world_mut() - .spawn(bevy_asset_preview::PreviewAsset(PathBuf::from(filename))) - .id(); - second_batch_entities.push((entity, filename.to_string())); - } - } - - let concurrent_entities: Vec<_> = (0..3) - .map(|_| { - app.world_mut() - .spawn(bevy_asset_preview::PreviewAsset(PathBuf::from("icon.png"))) - .id() - }) - .collect(); - - app.update(); - - let world = app.world(); - for (entity, filename) in &second_batch_entities { - assert!( - !world - .entity(*entity) - .contains::(), - "PreviewAsset should be removed immediately on cache hit for {}", - filename - ); - assert!( - !world - .entity(*entity) - .contains::(), - "PendingPreviewLoad should not be added on cache hit for {}", - filename - ); - assert!( - world.entity(*entity).contains::(), - "ImageNode should be added immediately on cache hit for {}", - filename - ); - } - - for entity in &concurrent_entities { - assert!( - !world - .entity(*entity) - .contains::(), - "Concurrent requests should use cache immediately" - ); - assert!( - !world - .entity(*entity) - .contains::(), - "Concurrent requests should not have PendingPreviewLoad" - ); - assert!( - world.entity(*entity).contains::(), - "Concurrent requests should have ImageNode" - ); - } - - // Phase 5: Error handling and boundary conditions - test_error_handling_for_nonexistent_file(&mut app); - test_boundary_conditions(&mut app); - - // Phase 6: Final integrity validation - let mut active_task_query = app - .world_mut() - .query::<&bevy_asset_preview::ActiveLoadTask>(); - let world = app.world(); - let cache = world.resource::(); - let config = world.resource::(); - let loader = world.resource::(); - - assert!(!cache.is_empty(), "Cache should not be empty"); - // Each image should have previews for all configured resolutions - let expected_cache_entries = image_files.len() * config.resolutions.len(); - assert_eq!( - cache.len(), - expected_cache_entries, - "Cache should contain all {} image previews ({} images × {} resolutions)", - expected_cache_entries, - image_files.len(), - config.resolutions.len() - ); - - let non_existent_active_tasks = active_task_query - .iter(world) - .filter(|active_task| active_task.path.to_string().contains("non_existent")) - .count(); - - assert_eq!( - non_existent_active_tasks, 0, - "All non-existent file tasks must be cleaned up (found {} remaining)", - non_existent_active_tasks - ); - - let actual_active_tasks = loader.active_tasks(); - let actual_queue_len = loader.queue_len(); - - assert_eq!( - actual_active_tasks, 0, - "All active tasks must be cleaned up after failure handling (found {} remaining)", - actual_active_tasks - ); - - assert!( - actual_queue_len <= 1, - "Queue should have at most 1 task, found {}", - actual_queue_len - ); - - let mut image_entities_with_preview = 0; - for (entity, filename, is_image) in &file_entities { - if *is_image { - assert!( - world.entity(*entity).contains::(), - "Image file {} should have ImageNode", - filename - ); - image_entities_with_preview += 1; - } - } - assert_eq!( - image_entities_with_preview, - image_files.len(), - "All image entities should have ImageNode previews" - ); - - println!( - "Test completed: created {} files, {} image previews, cached {} entries, {} load completed events", - file_definitions.len(), - image_files.len(), - cache.len(), - all_completed_events.len() - ); -}