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..ec4ad122 100644 --- a/crates/bevy_asset_preview/Cargo.toml +++ b/crates/bevy_asset_preview/Cargo.toml @@ -4,4 +4,11 @@ version = "0.1.0" edition = "2024" [dependencies] -bevy.workspace = true +bevy = { workspace = true, features = ["webp"] } +image = "0.25" +thiserror.workspace = true + +[dev-dependencies] +tempfile = "3" +gltf = "1" +bytemuck = "1" 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 new file mode 100644 index 00000000..7fb81097 --- /dev/null +++ b/crates/bevy_asset_preview/src/asset/loader.rs @@ -0,0 +1,460 @@ +use std::collections::BinaryHeap; + +use bevy::{ + asset::{AssetPath, LoadState}, + platform::collections::HashMap, + prelude::*, +}; + +use crate::asset::{AssetError, 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: AssetError, +} + +/// 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_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 } => { + 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: AssetError::AssetRemoved { + path: active_task.path.clone(), + }, + }); + + 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(), + }); + } + } + } + _ => {} + } + } + + // 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). +#[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) { + 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 { + 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); + + // 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; + 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)); + + 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); + // 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/asset/mod.rs b/crates/bevy_asset_preview/src/asset/mod.rs new file mode 100644 index 00000000..6f74cc6e --- /dev/null +++ b/crates/bevy_asset_preview/src/asset/mod.rs @@ -0,0 +1,11 @@ +mod error; +mod loader; +mod priority; +mod saver; +mod task; + +pub use error::*; +pub use loader::*; +pub use priority::*; +pub use saver::*; +pub use task::*; 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..ae155307 --- /dev/null +++ b/crates/bevy_asset_preview/src/asset/saver.rs @@ -0,0 +1,233 @@ +use std::io::Cursor; + +use bevy::{ + asset::{AssetPath, io::ErasedAssetWriter}, + 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)] +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<(), AssetError>, +} + +/// 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); + } + + /// 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. +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 { + 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) => { + return IoTaskPool::get() + .spawn(async move { Err(AssetError::ImageConversionFailed(e.to_string())) }); + } + }; + + // Convert to RGBA8 format + let rgba_image = dynamic_image.into_rgba8(); + + let task_pool = IoTaskPool::get(); + let target_path_clone = target_path.clone(); + // 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 + if let Some(parent) = target_path_for_writer.parent() { + writer.create_directory(parent).await.map_err(|e| { + AssetError::DirectoryCreationFailed { + path: parent.to_path_buf(), + reason: e.to_string(), + } + })?; + } + + // Encode WebP directly to memory + let mut cursor = Cursor::new(Vec::new()); + 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(()) + }) +} + +/// 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) = 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, + 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(_) => { + debug!( + "Save task {} completed successfully for {:?}", + event.task_id, event.path + ); + } + Err(err) => { + warn!( + "Save task {} failed for {:?}: {}", + 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 new file mode 100644 index 00000000..bb6c011d --- /dev/null +++ b/crates/bevy_asset_preview/src/asset/task.rs @@ -0,0 +1,74 @@ +use core::cmp::Ordering; + +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) -> Ordering { + // Sort by priority first (higher priority first, BinaryHeap is max-heap) + match self.priority.cmp(&other.priority) { + 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..e891176f 100644 --- a/crates/bevy_asset_preview/src/lib.rs +++ b/crates/bevy_asset_preview/src/lib.rs @@ -1,3 +1,11 @@ +mod asset; +mod preview; +mod ui; + +pub use asset::*; +pub use preview::*; +pub use ui::*; + use bevy::prelude::*; /// This crate is a work in progress and is not yet ready for use. @@ -6,8 +14,80 @@ 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. + +#[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::(); + 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 + 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, + ui::handle_preview_load_failed, + ui::check_failed_loads, + ) + .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 new file mode 100644 index 00000000..776bd570 --- /dev/null +++ b/crates/bevy_asset_preview/src/preview/cache.rs @@ -0,0 +1,640 @@ +use core::time::Duration; + +use std::path::Path; + +use bevy::{ + asset::{AssetPath, UntypedAssetId}, + platform::collections::HashMap, + prelude::*, +}; + +/// 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: UntypedAssetId, + /// 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 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>>, +} + +impl PreviewCache { + /// Creates a new empty cache. + pub fn new() -> Self { + Self { + path_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 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(); + + 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) + } + } + + /// 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: UntypedAssetId, + 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 for a specific resolution. + pub fn insert<'a>( + &mut self, + path: impl Into>, + 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 { + image_handle, + asset_id, + resolution, + timestamp, + }; + + 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 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(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 { + self.remove_all_resolutions_for_path(&owned_path) + } + } + + /// 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: &UntypedAssetId, + 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: &UntypedAssetId, + 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: UntypedAssetId, + 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_to_paths.clear(); + } + + /// Returns the number of cached preview entries. + 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() + } +} + +#[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_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"); + 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().untyped(); + let id2 = h2.id().untyped(); + (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, + 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, 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, None).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, 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 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, None).is_none(), + "Path 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, 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, + 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, Some(256)).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/image.rs b/crates/bevy_asset_preview/src/preview/image.rs new file mode 100644 index 00000000..ce664136 --- /dev/null +++ b/crates/bevy_asset_preview/src/preview/image.rs @@ -0,0 +1,341 @@ +use core::time::Duration; + +use bevy::{ + asset::{AssetPath, RenderAssetUsages, UntypedAssetId}, + 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 new file mode 100644 index 00000000..f729be1e --- /dev/null +++ b/crates/bevy_asset_preview/src/preview/mod.rs @@ -0,0 +1,80 @@ +mod cache; +mod image; +mod model; +mod renderer; +mod task; +// mod entity_preview; // Temporarily disabled + +use bevy::{mesh::Mesh, pbr::StandardMaterial, prelude::*, scene::Scene}; + +// Re-export public types and functions +pub use cache::*; +pub use image::*; +pub use model::*; +pub use renderer::*; +pub use task::*; +// 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, + } + } +} 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..845b8054 --- /dev/null +++ b/crates/bevy_asset_preview/src/preview/model.rs @@ -0,0 +1,264 @@ +use bevy::{asset::AssetPath, prelude::*, render::view::screenshot::Screenshot}; + +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..805b34b4 --- /dev/null +++ b/crates/bevy_asset_preview/src/preview/renderer.rs @@ -0,0 +1,183 @@ +use bevy::{ + camera::{ + Camera3d, RenderTarget, + primitives::{Aabb, Sphere as CameraSphere}, + visibility::RenderLayers, + }, + prelude::*, + render::render_resource::TextureFormat, +}; + +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/task.rs b/crates/bevy_asset_preview/src/preview/task.rs new file mode 100644 index 00000000..cae03c5d --- /dev/null +++ b/crates/bevy_asset_preview/src/preview/task.rs @@ -0,0 +1,79 @@ +use bevy::{asset::AssetPath, 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 new file mode 100644 index 00000000..d10dcd81 --- /dev/null +++ b/crates/bevy_asset_preview/src/ui/mod.rs @@ -0,0 +1,357 @@ +use std::path::{Path, PathBuf}; + +use bevy::{ + asset::{AssetPath, AssetServer, LoadState}, + gltf::GltfAssetLabel, + image::Image, + prelude::*, + scene::Scene, +}; + +use crate::{ + asset::{AssetLoader, LoadPriority}, + preview::{ + ModelFormat, PendingPreviewRequest, PreviewCache, PreviewConfig, PreviewMode, + PreviewRequestType, PreviewTaskManager, + }, +}; + +#[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) +} + +/// 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), + ( + 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) { + // 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)); + continue; + } + + // 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; + } + + // 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; + } + + // 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, + mut cache: ResMut, + config: Res, + mut images: ResMut>, + mut load_completed_events: EventReader, + pending_query: Query<(Entity, &PendingPreviewLoad), With>, + mut image_node_query: Query<&mut ImageNode>, + time: Res>, +) { + 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) { + // Clone image data before mutable operations + let image_clone = image.clone(); + let asset_id = event.handle.id().untyped(); + + // Generate previews for all configured resolutions + crate::preview::generate_previews_for_resolutions( + &mut images, + &image_clone, + event.handle.clone(), + &pending.asset_path, + 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; + } + + // Cleanup + commands.entity(entity).remove::(); + commands.entity(entity).remove::(); + } + break; + } + } + } +} + +/// 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), With>, +) { + 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), With>, + task_query: Query<(Entity, &crate::asset::ActiveLoadTask)>, +) { + 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 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" + ); +}