496 lines
15 KiB
Rust
496 lines
15 KiB
Rust
//! Thumbnail cache for asset browser grid cells.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
use bevy::prelude::*;
|
|
use bevy_egui::{egui, EguiPrimaryContextPass, EguiTextureHandle, EguiUserTextures};
|
|
use egui_phosphor_icons::icons;
|
|
|
|
use super::sources::gltf::{gltf_base_color_texture_path, validate_gltf_dependencies};
|
|
use super::studio::{model_file_exists, ThumbnailStudio};
|
|
use super::ThumbnailJobSource;
|
|
use crate::assets::{
|
|
asset_cache_key, asset_server_path, EditorAsset, EditorAssetKind, EditorAssets,
|
|
};
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum ThumbnailState {
|
|
Pending,
|
|
Ready,
|
|
Failed { reason: String, retryable: bool },
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct ThumbnailFailure {
|
|
reason: String,
|
|
retryable: bool,
|
|
}
|
|
|
|
#[derive(Resource, Default)]
|
|
pub struct AssetThumbnailCache {
|
|
pub texture_ids: HashMap<String, egui::TextureId>,
|
|
pending: HashMap<String, Handle<Image>>,
|
|
pub(crate) studio_pending: HashSet<String>,
|
|
failed: HashMap<String, ThumbnailFailure>,
|
|
prefetched_folder: Option<String>,
|
|
}
|
|
|
|
pub struct AssetThumbnailsPlugin;
|
|
|
|
impl Plugin for AssetThumbnailsPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.init_resource::<AssetThumbnailCache>()
|
|
.add_systems(EguiPrimaryContextPass, register_loaded_thumbnails);
|
|
}
|
|
}
|
|
|
|
impl AssetThumbnailCache {
|
|
pub fn state(&self, key: &str) -> Option<ThumbnailState> {
|
|
if self.texture_ids.contains_key(key) {
|
|
return Some(ThumbnailState::Ready);
|
|
}
|
|
if self.failed.contains_key(key) {
|
|
let failure = self.failed.get(key)?;
|
|
return Some(ThumbnailState::Failed {
|
|
reason: failure.reason.clone(),
|
|
retryable: failure.retryable,
|
|
});
|
|
}
|
|
if self.pending.contains_key(key) || self.studio_pending.contains(key) {
|
|
return Some(ThumbnailState::Pending);
|
|
}
|
|
None
|
|
}
|
|
|
|
pub fn retry(&mut self, key: &str) {
|
|
self.failed.remove(key);
|
|
self.pending.remove(key);
|
|
self.studio_pending.remove(key);
|
|
self.texture_ids.remove(key);
|
|
}
|
|
|
|
pub fn request_texture(&mut self, key: String, path: String, asset_server: &AssetServer) {
|
|
if matches!(
|
|
self.state(&key),
|
|
Some(ThumbnailState::Ready | ThumbnailState::Pending)
|
|
) {
|
|
return;
|
|
}
|
|
if self.failed.contains_key(&key) {
|
|
return;
|
|
}
|
|
let handle: Handle<Image> = asset_server.load(asset_server_path(&path));
|
|
self.pending.insert(key, handle);
|
|
}
|
|
|
|
pub fn request_model(
|
|
&mut self,
|
|
key: String,
|
|
model_path: String,
|
|
asset_server: &AssetServer,
|
|
studio: &mut ThumbnailStudio,
|
|
) {
|
|
if matches!(
|
|
self.state(&key),
|
|
Some(ThumbnailState::Ready | ThumbnailState::Pending)
|
|
) {
|
|
return;
|
|
}
|
|
if self.failed.contains_key(&key) {
|
|
return;
|
|
}
|
|
|
|
if !self.model_source_ready(&key, &model_path) {
|
|
return;
|
|
}
|
|
|
|
if let Some(texture_path) = gltf_base_color_texture_path(&model_path) {
|
|
self.request_texture(key, texture_path, asset_server);
|
|
return;
|
|
}
|
|
|
|
if studio.enqueue(key.clone(), model_path) {
|
|
self.studio_pending.insert(key);
|
|
}
|
|
}
|
|
|
|
pub fn request_mesh_subasset(
|
|
&mut self,
|
|
key: String,
|
|
model_path: String,
|
|
mesh_label: String,
|
|
material_label: Option<String>,
|
|
studio: &mut ThumbnailStudio,
|
|
) {
|
|
if matches!(
|
|
self.state(&key),
|
|
Some(ThumbnailState::Ready | ThumbnailState::Pending)
|
|
) || self.failed.contains_key(&key)
|
|
{
|
|
return;
|
|
}
|
|
if !self.model_source_ready(&key, &model_path) {
|
|
return;
|
|
}
|
|
if studio.enqueue_source(
|
|
key.clone(),
|
|
ThumbnailJobSource::MeshSubAsset {
|
|
model_path,
|
|
mesh_label,
|
|
material_label,
|
|
},
|
|
) {
|
|
self.studio_pending.insert(key);
|
|
}
|
|
}
|
|
|
|
pub fn request_source_material(
|
|
&mut self,
|
|
key: String,
|
|
model_path: String,
|
|
material_label: String,
|
|
studio: &mut ThumbnailStudio,
|
|
) {
|
|
if matches!(
|
|
self.state(&key),
|
|
Some(ThumbnailState::Ready | ThumbnailState::Pending)
|
|
) || self.failed.contains_key(&key)
|
|
{
|
|
return;
|
|
}
|
|
if !self.model_source_ready(&key, &model_path) {
|
|
return;
|
|
}
|
|
if studio.enqueue_source(
|
|
key.clone(),
|
|
ThumbnailJobSource::SourceMaterial {
|
|
model_path,
|
|
material_label,
|
|
},
|
|
) {
|
|
self.studio_pending.insert(key);
|
|
}
|
|
}
|
|
|
|
pub fn request_material_asset(
|
|
&mut self,
|
|
key: String,
|
|
path: String,
|
|
studio: &mut ThumbnailStudio,
|
|
) {
|
|
if matches!(
|
|
self.state(&key),
|
|
Some(ThumbnailState::Ready | ThumbnailState::Pending)
|
|
) || self.failed.contains_key(&key)
|
|
{
|
|
return;
|
|
}
|
|
let material = match shared::MaterialAsset::load_from_path(&path) {
|
|
Ok(asset) => asset,
|
|
Err(error) => {
|
|
self.mark_studio_failed(&key, &error, true);
|
|
return;
|
|
}
|
|
};
|
|
if studio.enqueue_source(
|
|
key.clone(),
|
|
ThumbnailJobSource::MaterialAsset {
|
|
label: material.label.clone(),
|
|
material: Box::new(material.material),
|
|
},
|
|
) {
|
|
self.studio_pending.insert(key);
|
|
}
|
|
}
|
|
|
|
fn model_source_ready(&mut self, key: &str, model_path: &str) -> bool {
|
|
if !model_file_exists(model_path) {
|
|
self.mark_studio_failed(key, "file not found", false);
|
|
return false;
|
|
}
|
|
if let Err(reason) = validate_gltf_dependencies(model_path) {
|
|
self.mark_studio_failed(key, &reason, false);
|
|
return false;
|
|
}
|
|
true
|
|
}
|
|
|
|
pub(crate) fn complete_studio_thumbnail(
|
|
&mut self,
|
|
key: &str,
|
|
image: Handle<Image>,
|
|
textures: &mut EguiUserTextures,
|
|
) {
|
|
self.studio_pending.remove(key);
|
|
self.failed.remove(key);
|
|
let texture_id = textures
|
|
.image_id(&image)
|
|
.unwrap_or_else(|| textures.add_image(EguiTextureHandle::Strong(image)));
|
|
self.texture_ids.insert(key.to_string(), texture_id);
|
|
}
|
|
|
|
pub(crate) fn mark_studio_failed(&mut self, key: &str, reason: &str, retryable: bool) {
|
|
self.studio_pending.remove(key);
|
|
self.pending.remove(key);
|
|
self.failed.insert(
|
|
key.to_string(),
|
|
ThumbnailFailure {
|
|
reason: reason.to_string(),
|
|
retryable,
|
|
},
|
|
);
|
|
}
|
|
|
|
pub fn invalidate_all(&mut self) {
|
|
self.texture_ids.clear();
|
|
self.pending.clear();
|
|
self.studio_pending.clear();
|
|
self.failed.clear();
|
|
self.prefetched_folder = None;
|
|
}
|
|
|
|
pub fn snapshot(&self) -> ThumbnailCacheSnapshot {
|
|
ThumbnailCacheSnapshot {
|
|
texture_ids: self.texture_ids.clone(),
|
|
pending_keys: self
|
|
.pending
|
|
.keys()
|
|
.chain(self.studio_pending.iter())
|
|
.cloned()
|
|
.collect(),
|
|
failed_keys: self.failed.keys().cloned().collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn register_loaded_thumbnails(
|
|
asset_server: Res<AssetServer>,
|
|
mut cache: ResMut<AssetThumbnailCache>,
|
|
mut textures: ResMut<EguiUserTextures>,
|
|
) {
|
|
let ready: Vec<(String, Handle<Image>)> = cache
|
|
.pending
|
|
.iter()
|
|
.filter(|(_, handle)| asset_server.is_loaded_with_dependencies(*handle))
|
|
.map(|(key, handle)| (key.clone(), handle.clone()))
|
|
.collect();
|
|
|
|
for (key, handle) in ready {
|
|
cache.pending.remove(&key);
|
|
cache.failed.remove(&key);
|
|
let texture_id = textures
|
|
.image_id(&handle)
|
|
.unwrap_or_else(|| textures.add_image(EguiTextureHandle::Strong(handle)));
|
|
cache.texture_ids.insert(key, texture_id);
|
|
}
|
|
}
|
|
|
|
pub fn kind_icon(kind: &EditorAssetKind) -> egui_phosphor_icons::Icon {
|
|
match kind {
|
|
EditorAssetKind::Primitive(_) => icons::CUBE,
|
|
EditorAssetKind::Light(_) => icons::LIGHTBULB,
|
|
EditorAssetKind::Model => icons::CUBE_TRANSPARENT,
|
|
EditorAssetKind::Texture => icons::IMAGE,
|
|
EditorAssetKind::Material => icons::PAINT_BRUSH,
|
|
EditorAssetKind::Level => icons::MAP_TRIFOLD,
|
|
EditorAssetKind::Prefab => icons::PACKAGE,
|
|
EditorAssetKind::PostProcessVolume => icons::CAMERA,
|
|
EditorAssetKind::PostProcessEffect => icons::SPARKLE,
|
|
EditorAssetKind::RenderingProfile => icons::SLIDERS,
|
|
EditorAssetKind::ShaderSchema => icons::CODE,
|
|
}
|
|
}
|
|
|
|
pub fn draw_asset_cell_with(
|
|
ui: &mut egui::Ui,
|
|
asset: &EditorAsset,
|
|
texture_id: Option<egui::TextureId>,
|
|
pending: bool,
|
|
failed: Option<&str>,
|
|
selected: bool,
|
|
thumbnail_size: f32,
|
|
) -> egui::Response {
|
|
let thumb_size = thumbnail_size.clamp(48.0, 112.0);
|
|
let cell_size = egui::vec2(thumb_size + 20.0, thumb_size + 30.0);
|
|
let (rect, response) = ui.allocate_exact_size(cell_size, egui::Sense::click_and_drag());
|
|
|
|
let fill = if selected {
|
|
crate::ui::theme::SELECTION_BG_MUTED
|
|
} else if response.hovered() {
|
|
crate::ui::theme::ELEVATED_BG
|
|
} else {
|
|
crate::ui::theme::WIDGET_BG
|
|
};
|
|
ui.painter().rect(
|
|
rect,
|
|
4.0,
|
|
fill,
|
|
egui::Stroke::new(
|
|
1.0,
|
|
if selected {
|
|
crate::ui::theme::ACCENT_HOVER
|
|
} else {
|
|
crate::ui::theme::BORDER
|
|
},
|
|
),
|
|
egui::StrokeKind::Inside,
|
|
);
|
|
|
|
let thumb_rect = egui::Rect::from_min_size(
|
|
rect.min + egui::vec2(10.0, 8.0),
|
|
egui::vec2(thumb_size, thumb_size),
|
|
);
|
|
|
|
if let Some(texture_id) = texture_id {
|
|
ui.painter().image(
|
|
texture_id,
|
|
thumb_rect,
|
|
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
|
egui::Color32::WHITE,
|
|
);
|
|
} else if pending {
|
|
ui.painter().text(
|
|
thumb_rect.center(),
|
|
egui::Align2::CENTER_CENTER,
|
|
icons::CIRCLE_NOTCH.as_str(),
|
|
egui::FontId::new(24.0, egui::FontFamily::Name("phosphor-regular".into())),
|
|
crate::ui::theme::TEXT_DIM,
|
|
);
|
|
} else if failed.is_some() {
|
|
ui.painter().text(
|
|
thumb_rect.center(),
|
|
egui::Align2::CENTER_CENTER,
|
|
icons::WARNING_CIRCLE.as_str(),
|
|
egui::FontId::new(24.0, egui::FontFamily::Name("phosphor-regular".into())),
|
|
crate::ui::theme::TEXT_DIM,
|
|
);
|
|
} else {
|
|
let icon = kind_icon(&asset.kind);
|
|
ui.painter().text(
|
|
thumb_rect.center(),
|
|
egui::Align2::CENTER_CENTER,
|
|
icon.as_str(),
|
|
egui::FontId::new(28.0, egui::FontFamily::Name("phosphor-regular".into())),
|
|
crate::ui::theme::TEXT,
|
|
);
|
|
}
|
|
|
|
ui.painter().text(
|
|
egui::pos2(rect.center().x, rect.max.y - 6.0),
|
|
egui::Align2::CENTER_BOTTOM,
|
|
asset.label.as_str(),
|
|
egui::FontId::new(11.0, egui::FontFamily::Proportional),
|
|
if selected {
|
|
crate::ui::theme::TEXT_SELECTED
|
|
} else {
|
|
crate::ui::theme::TEXT
|
|
},
|
|
);
|
|
|
|
response
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct ThumbnailCacheSnapshot {
|
|
pub texture_ids: HashMap<String, egui::TextureId>,
|
|
pub pending_keys: Vec<String>,
|
|
pub failed_keys: Vec<String>,
|
|
}
|
|
|
|
impl ThumbnailCacheSnapshot {
|
|
pub fn texture_for_key(&self, key: &str) -> Option<egui::TextureId> {
|
|
self.texture_ids.get(key).copied()
|
|
}
|
|
|
|
pub fn is_pending_key(&self, key: &str) -> bool {
|
|
self.pending_keys.iter().any(|pending| pending == key)
|
|
}
|
|
|
|
pub fn is_failed_key(&self, key: &str) -> bool {
|
|
self.failed_keys.iter().any(|failed| failed == key)
|
|
}
|
|
|
|
pub fn texture_for(&self, asset: &EditorAsset) -> Option<egui::TextureId> {
|
|
self.texture_ids.get(&asset_cache_key(asset)).copied()
|
|
}
|
|
|
|
pub fn is_pending(&self, asset: &EditorAsset) -> bool {
|
|
self.pending_keys
|
|
.iter()
|
|
.any(|key| key == &asset_cache_key(asset))
|
|
}
|
|
|
|
pub fn is_failed(&self, asset: &EditorAsset) -> bool {
|
|
self.failed_keys
|
|
.iter()
|
|
.any(|key| key == &asset_cache_key(asset))
|
|
}
|
|
}
|
|
|
|
pub fn prefetch_folder_thumbnails(world: &mut World, folder: &str) {
|
|
{
|
|
let cache = world.resource::<AssetThumbnailCache>();
|
|
if cache.prefetched_folder.as_deref() == Some(folder) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
let requests: Vec<(String, String, EditorAssetKind)> = {
|
|
let assets = world.resource::<EditorAssets>();
|
|
assets
|
|
.assets
|
|
.iter()
|
|
.filter(|asset| asset.folder_path == folder)
|
|
.filter(|asset| {
|
|
matches!(
|
|
asset.kind,
|
|
EditorAssetKind::Texture | EditorAssetKind::Model | EditorAssetKind::Material
|
|
)
|
|
})
|
|
.filter_map(|asset| {
|
|
Some((
|
|
asset_cache_key(asset),
|
|
asset.path.clone()?,
|
|
asset.kind.clone(),
|
|
))
|
|
})
|
|
.collect()
|
|
};
|
|
let asset_server = world.resource::<AssetServer>().clone();
|
|
world.resource_scope(|world, mut cache: Mut<AssetThumbnailCache>| {
|
|
world.resource_scope(|_world, mut studio: Mut<ThumbnailStudio>| {
|
|
for (key, path, kind) in requests {
|
|
match kind {
|
|
EditorAssetKind::Texture => cache.request_texture(key, path, &asset_server),
|
|
EditorAssetKind::Model => {
|
|
cache.request_model(key, path, &asset_server, &mut studio)
|
|
}
|
|
EditorAssetKind::Material => {
|
|
cache.request_material_asset(key, path, &mut studio)
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
world
|
|
.resource_mut::<AssetThumbnailCache>()
|
|
.prefetched_folder = Some(folder.to_string());
|
|
}
|
|
|
|
pub fn invalidate_on_catalog_refresh(world: &mut World) {
|
|
world.resource_mut::<AssetThumbnailCache>().invalidate_all();
|
|
if !world.contains_resource::<ThumbnailStudio>() {
|
|
return;
|
|
}
|
|
let cleanup = {
|
|
let mut studio = world.resource_mut::<ThumbnailStudio>();
|
|
studio.clear_queue();
|
|
studio.clear_jobs()
|
|
};
|
|
if let Some(root) = cleanup.active_root {
|
|
world.commands().entity(root).despawn_children();
|
|
world.commands().entity(root).despawn();
|
|
}
|
|
}
|