//! Asset browser dock panel: project tree, file grid/list, and asset details. use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use bevy::prelude::*; use bevy_egui::egui; use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities; use egui_phosphor_icons::{icons, Icon}; use shared::{ ColorDesc, EditorAssetRef, MaterialAsset, MaterialDesc, MaterialParameter, MaterialParameterValue, MaterialShaderKind, MaterialTextureBinding, ShaderPropertyDesc, ShaderPropertyType, ShaderSchemaAsset, }; use crate::assets::{ spawn_asset_at, spawn_subasset_at, AssetSelection, AssetSubAssetKind, EditorAsset, EditorAssetKind, EditorAssets, ASSETS_ROOT, BUILTINS_FOLDER, }; use crate::scene_io::{SceneIo, SceneIoRequest}; use super::state::{ AssetBrowserUiState, AssetBrowserView, AssetDeleteRequest, AssetKindFilter, AssetSort, ImportSettingsDraft, MaterialAssetDraft, }; use crate::asset_db::{ find_asset_by_path, find_asset_mut_by_path, save_registry, update_import_settings, ImportSettings, MaterialImportPolicy, ModelHierarchyMode, ModelPlacementMode, }; use crate::assets::static_mesh::{ load_static_mesh_manifest, material_id_from_label, part_id_from_label, refresh_static_mesh_artifact, }; use crate::assets::{ asset_cache_key, draw_asset_cell_with, invalidate_on_catalog_refresh, kind_icon, prefetch_folder_thumbnails, AssetThumbnailCache, ThumbnailCacheSnapshot, ThumbnailStudio, }; use crate::ui::helpers::{ apply_material_asset_to_selection, apply_texture_to_selection, asset_label, }; use crate::ui::theme::{panel_heading, ACCENT, BORDER, ELEVATED_BG, TEXT, TEXT_DIM, WIDGET_BG}; use crate::ui::widgets::{icon_button_small, tool_button}; const FOOTER_HEIGHT: f32 = 64.0; const TOOLBAR_HEIGHT: f32 = 40.0; const DETAILS_WIDTH: f32 = 260.0; const DETAILS_MIN_PANEL_WIDTH: f32 = 760.0; const DETAILS_MIN_WIDTH: f32 = 200.0; const TREE_MIN_PANEL_WIDTH: f32 = 540.0; const TREE_MIN_WIDTH: f32 = 150.0; const TREE_MAX_WIDTH: f32 = 240.0; const MIN_CONTENT_WIDTH: f32 = 160.0; const COMPACT_LIST_WIDTH: f32 = 560.0; const PHOSPHOR: &str = "phosphor-regular"; #[derive(Clone)] struct FolderSnapshot { path: String, name: String, parent: Option, } #[derive(Clone)] struct AssetRow { selection: AssetSelection, asset: EditorAsset, file_size: Option, modified: Option, } #[derive(Clone)] struct EmbeddedAsset { selection: AssetSelection, label: String, kind: AssetSubAssetKind, detail: String, texture_path: Option, thumbnail_key: String, mesh_label: Option, material_label: Option, } fn fit_width(ui: &egui::Ui, min: f32, max: f32) -> f32 { let available = ui.available_width().max(1.0); available.min(max).max(min.min(available)) } fn icon_text(icon: Icon, size: f32) -> egui::RichText { egui::RichText::new(icon.as_str()).font(egui::FontId::new( size, egui::FontFamily::Name(PHOSPHOR.into()), )) } fn exact_region( ui: &mut egui::Ui, size: egui::Vec2, layout: egui::Layout, add_contents: impl FnOnce(&mut egui::Ui) -> R, ) -> R { let (rect, _response) = ui.allocate_exact_size(size, egui::Sense::hover()); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(layout)); add_contents(&mut child) } pub fn asset_browser_ui( world: &mut World, ui: &mut egui::Ui, selected_entities: &SelectedEntities, ) { let available_width = ui.available_width().max(1.0); let available_height = ui.available_height().max(1.0); let body_height = (available_height - TOOLBAR_HEIGHT - FOOTER_HEIGHT - 4.0).max(40.0); exact_region( ui, egui::vec2(available_width, available_height), egui::Layout::top_down(egui::Align::Min), |ui| { exact_region( ui, egui::vec2(available_width, TOOLBAR_HEIGHT), egui::Layout::left_to_right(egui::Align::Center), |ui| { asset_toolbar(world, ui); }, ); ui.separator(); let (current_folder, selected, folders, assets, cache_snapshot, state_snapshot) = { let assets = world.resource::(); let cache = world.resource::(); let state = world.resource::(); let folders: Vec = assets .folders .iter() .map(|folder| FolderSnapshot { path: folder.path.clone(), name: folder.name.clone(), parent: folder.parent.clone(), }) .collect(); let asset_rows: Vec = assets .assets .iter() .map(|asset| AssetRow { selection: AssetSelection::from_asset(asset), asset: asset.clone(), file_size: asset.path.as_deref().and_then(file_size), modified: asset.path.as_deref().and_then(modified_time), }) .collect(); ( assets.current_folder.clone(), assets.selected.clone(), folders, asset_rows, cache.snapshot(), state_snapshot(state), ) }; prefetch_folder_thumbnails(world, ¤t_folder); let show_tree = available_width >= TREE_MIN_PANEL_WIDTH; let show_details = state_snapshot.show_details && available_width >= DETAILS_MIN_PANEL_WIDTH; let tree_width = if show_tree { (available_width * 0.22).clamp(TREE_MIN_WIDTH, TREE_MAX_WIDTH) } else { 0.0 }; let details_width = if show_details { DETAILS_WIDTH.min((available_width * 0.28).max(DETAILS_MIN_WIDTH)) } else { 0.0 }; let separator_budget = (show_tree as u8 as f32 + show_details as u8 as f32) * 10.0; let content_width = (available_width - tree_width - details_width - separator_budget) .max(MIN_CONTENT_WIDTH.min(available_width)); exact_region( ui, egui::vec2(available_width, body_height), egui::Layout::left_to_right(egui::Align::Min), |ui| { if show_tree { exact_region( ui, egui::vec2(tree_width, body_height), egui::Layout::top_down(egui::Align::Min), |ui| { ui.label(panel_heading("Project")); egui::ScrollArea::vertical() .id_salt("asset_folder_tree") .max_height(ui.available_height()) .auto_shrink([false, false]) .show(ui, |ui| { for root in [BUILTINS_FOLDER, ASSETS_ROOT] { folder_tree_branch( world, ui, &folders, root, 0, ¤t_folder, ); } }); }, ); ui.separator(); } exact_region( ui, egui::vec2(content_width, body_height), egui::Layout::top_down(egui::Align::Min), |ui| { content_header(world, ui, ¤t_folder, &folders, &assets); egui::ScrollArea::vertical() .id_salt("asset_content_area") .max_height(ui.available_height()) .auto_shrink([false, false]) .show(ui, |ui| { let child_folders = child_folders_for_content( &folders, ¤t_folder, &state_snapshot.search, ); let visible_assets = visible_assets(&assets, ¤t_folder, &state_snapshot); prefetch_asset_row_thumbnails(world, &visible_assets); if child_folders.is_empty() && visible_assets.is_empty() { empty_content(ui, &state_snapshot.search); } else if state_snapshot.view == AssetBrowserView::Grid { let thumbnail_size = state_snapshot .thumbnail_size .min((content_width - 26.0).max(48.0)); asset_grid( world, ui, selected_entities, &selected, &child_folders, &visible_assets, &cache_snapshot, thumbnail_size, ); } else { asset_list( world, ui, selected_entities, &selected, &child_folders, &visible_assets, &cache_snapshot, ); } }); }, ); if show_details { ui.separator(); exact_region( ui, egui::vec2(details_width, body_height), egui::Layout::top_down(egui::Align::Min), |ui| { egui::ScrollArea::vertical() .id_salt("asset_details_area") .max_height(ui.available_height()) .auto_shrink([false, false]) .show(ui, |ui| { details_panel(world, ui, selected_entities); }); }, ); } }, ); ui.separator(); exact_region( ui, egui::vec2(available_width, FOOTER_HEIGHT), egui::Layout::top_down(egui::Align::Min), |ui| { egui::Frame::new() .fill(ELEVATED_BG.linear_multiply(0.5)) .inner_margin(egui::Margin::symmetric(8, 6)) .show(ui, |ui| { ui.set_max_height(FOOTER_HEIGHT - 12.0); selection_footer(world, ui, selected_entities); }); }, ); }, ); draw_delete_modal(world, ui.ctx()); } #[derive(Clone)] struct AssetBrowserStateSnapshot { search: String, view: AssetBrowserView, sort: AssetSort, kind_filter: AssetKindFilter, thumbnail_size: f32, recursive: bool, show_details: bool, } fn state_snapshot(state: &AssetBrowserUiState) -> AssetBrowserStateSnapshot { AssetBrowserStateSnapshot { search: state.search.clone(), view: state.view, sort: state.sort, kind_filter: state.kind_filter, thumbnail_size: state.thumbnail_size, recursive: state.recursive, show_details: state.show_details, } } fn embedded_assets_for_asset(world: &World, asset: &EditorAsset) -> Vec { if !matches!(asset.kind, EditorAssetKind::Model) { return Vec::new(); } let Some(parent_path) = asset.path.as_ref() else { return Vec::new(); }; let Some(record) = world .get_resource::() .and_then(|registry| find_asset_by_path(registry, parent_path)) else { return Vec::new(); }; let Some(manifest_path) = record.import_settings.static_mesh_manifest_path.as_deref() else { return Vec::new(); }; let Ok(manifest) = load_static_mesh_manifest(manifest_path) else { return Vec::new(); }; let mut embedded = Vec::new(); for part in &manifest.parts { let mesh_id = part_effective_id(part); let material_label = part.material_label.clone(); embedded.push(EmbeddedAsset { selection: AssetSelection::SubAsset { parent_path: parent_path.clone(), sub_asset_id: mesh_id, label: part.name.clone(), kind: AssetSubAssetKind::Mesh, source_path: None, }, label: part.name.clone(), kind: AssetSubAssetKind::Mesh, detail: part .source_mesh .clone() .unwrap_or_else(|| part.mesh_label.clone()), texture_path: None, thumbnail_key: subasset_thumbnail_key( parent_path, AssetSubAssetKind::Mesh, &part_effective_id(part), ), mesh_label: Some(part.mesh_label.clone()), material_label, }); } let mut material_ids = HashSet::new(); for part in &manifest.parts { let Some(material_id) = part_effective_material_id(part) else { continue; }; if !material_ids.insert(material_id.clone()) { continue; } let label = if part.material_slot_name.trim().is_empty() { part.material_label .clone() .unwrap_or_else(|| "Source Material".to_string()) } else { part.material_slot_name.clone() }; embedded.push(EmbeddedAsset { selection: AssetSelection::SubAsset { parent_path: parent_path.clone(), sub_asset_id: material_id, label: label.clone(), kind: AssetSubAssetKind::Material, source_path: None, }, label, kind: AssetSubAssetKind::Material, detail: "Embedded source material".to_string(), texture_path: None, thumbnail_key: subasset_thumbnail_key( parent_path, AssetSubAssetKind::Material, &part_effective_material_id(part).unwrap_or_else(|| { material_id_from_label( part.material_label .as_deref() .unwrap_or(&part.material_slot_name), ) }), ), mesh_label: None, material_label: part.material_label.clone(), }); } let mut texture_paths = HashSet::new(); for dependency in &manifest.source.dependencies { if !is_texture_path(dependency) || !texture_paths.insert(dependency.clone()) { continue; } let label = Path::new(dependency) .file_stem() .and_then(|stem| stem.to_str()) .unwrap_or(dependency) .to_string(); embedded.push(EmbeddedAsset { selection: AssetSelection::SubAsset { parent_path: parent_path.clone(), sub_asset_id: format!("texture:{}", stable_subasset_slug(dependency)), label: label.clone(), kind: AssetSubAssetKind::Texture, source_path: Some(dependency.clone()), }, label, kind: AssetSubAssetKind::Texture, detail: dependency.clone(), texture_path: Some(dependency.clone()), thumbnail_key: subasset_thumbnail_key( parent_path, AssetSubAssetKind::Texture, &stable_subasset_slug(dependency), ), mesh_label: None, material_label: None, }); } embedded } fn embedded_asset_for_selection( world: &World, selection: &AssetSelection, ) -> Option { let AssetSelection::SubAsset { parent_path, .. } = selection else { return None; }; let asset = world .resource::() .assets .iter() .find(|asset| asset.path.as_deref() == Some(parent_path.as_str()))? .clone(); embedded_assets_for_asset(world, &asset) .into_iter() .find(|embedded| &embedded.selection == selection) } fn has_embedded_assets(world: &World, asset: &EditorAsset) -> bool { !embedded_assets_for_asset(world, asset).is_empty() } fn part_effective_id(part: &crate::assets::static_mesh::StaticMeshPart) -> String { if part.id.trim().is_empty() { part_id_from_label(&part.mesh_label) } else { part.id.clone() } } fn part_effective_material_id(part: &crate::assets::static_mesh::StaticMeshPart) -> Option { part.material_id.clone().or_else(|| { part.material_label .as_ref() .map(|label| material_id_from_label(label)) }) } fn is_texture_path(path: &str) -> bool { Path::new(path) .extension() .and_then(|extension| extension.to_str()) .is_some_and(|extension| { matches!( extension.to_ascii_lowercase().as_str(), "png" | "jpg" | "jpeg" | "webp" | "ktx2" ) }) } fn stable_subasset_slug(label: &str) -> String { let mut slug = String::new(); for ch in label.chars() { if ch.is_ascii_alphanumeric() { slug.push(ch.to_ascii_lowercase()); } else if !slug.ends_with('_') { slug.push('_'); } } slug.trim_matches('_').to_string() } fn subasset_thumbnail_key( parent_path: &str, kind: AssetSubAssetKind, sub_asset_id: &str, ) -> String { format!( "{}#{}:{}", parent_path, subasset_kind_label(kind).to_ascii_lowercase(), sub_asset_id ) } fn asset_toolbar(world: &mut World, ui: &mut egui::Ui) { ui.horizontal(|ui| { if icon_button_small(ui, icons::ARROWS_CLOCKWISE, "Refresh").clicked() { world.resource_mut::().refresh(); invalidate_on_catalog_refresh(world); } if icon_button_small(ui, icons::UPLOAD, "Import assets").clicked() { world.resource_mut::().request = Some(SceneIoRequest::ImportAssets); } if icon_button_small(ui, icons::ARROW_UP, "Parent folder").clicked() { navigate_to_parent(world); } ui.separator(); let mut state = world.resource_mut::(); ui.add( egui::TextEdit::singleline(&mut state.search) .hint_text("Search assets...") .desired_width(fit_width(ui, 120.0, 190.0)), ); ui.checkbox(&mut state.recursive, "Subfolders"); egui::ComboBox::from_id_salt("asset_kind_filter") .selected_text(kind_filter_label(state.kind_filter)) .show_ui(ui, |ui| { ui.selectable_value(&mut state.kind_filter, AssetKindFilter::All, "All"); ui.selectable_value(&mut state.kind_filter, AssetKindFilter::Model, "Models"); ui.selectable_value(&mut state.kind_filter, AssetKindFilter::Texture, "Textures"); ui.selectable_value( &mut state.kind_filter, AssetKindFilter::Material, "Materials", ); ui.selectable_value(&mut state.kind_filter, AssetKindFilter::Level, "Levels"); ui.selectable_value(&mut state.kind_filter, AssetKindFilter::Prefab, "Prefabs"); ui.selectable_value( &mut state.kind_filter, AssetKindFilter::Builtin, "Built-ins", ); }); egui::ComboBox::from_id_salt("asset_sort") .selected_text(sort_label(state.sort)) .show_ui(ui, |ui| { ui.selectable_value(&mut state.sort, AssetSort::Name, "Name"); ui.selectable_value(&mut state.sort, AssetSort::Kind, "Type"); ui.selectable_value(&mut state.sort, AssetSort::Modified, "Modified"); ui.selectable_value(&mut state.sort, AssetSort::Size, "Size"); }); ui.separator(); if tool_button( ui, icons::GRID_FOUR, state.view == AssetBrowserView::Grid, "Grid view", ) .clicked() { state.view = AssetBrowserView::Grid; } if tool_button( ui, icons::LIST, state.view == AssetBrowserView::List, "List view", ) .clicked() { state.view = AssetBrowserView::List; } if ui.available_width() > 180.0 { ui.label(icon_text(icons::IMAGE_SQUARE, 13.0).color(TEXT_DIM)); ui.add_sized( [96.0, 20.0], egui::Slider::new(&mut state.thumbnail_size, 48.0..=112.0).show_value(false), ); } ui.checkbox(&mut state.show_details, "Details"); }); } fn content_header( world: &mut World, ui: &mut egui::Ui, current_folder: &str, folders: &[FolderSnapshot], assets: &[AssetRow], ) { ui.horizontal_wrapped(|ui| { ui.label(panel_heading("Assets")); ui.separator(); breadcrumb(world, ui, current_folder); }); let direct_assets = assets .iter() .filter(|row| row.asset.folder_path == current_folder) .count(); let direct_folders = folders .iter() .filter(|folder| folder.parent.as_deref() == Some(current_folder)) .count(); ui.small( egui::RichText::new(format!("{direct_folders} folders, {direct_assets} assets")) .color(TEXT_DIM), ); } fn prefetch_asset_row_thumbnails(world: &mut World, rows: &[AssetRow]) { let requests: Vec<(String, String, EditorAssetKind)> = rows .iter() .filter(|row| { matches!( row.asset.kind, EditorAssetKind::Texture | EditorAssetKind::Model | EditorAssetKind::Material ) }) .filter_map(|row| { Some(( asset_cache_key(&row.asset), row.asset.path.clone()?, row.asset.kind.clone(), )) }) .collect(); if requests.is_empty() { return; } let asset_server = world.resource::().clone(); world.resource_scope(|world, mut cache: Mut| { world.resource_scope(|_world, mut studio: Mut| { for (key, path, kind) in &requests { match kind { EditorAssetKind::Texture => { cache.request_texture(key.clone(), path.clone(), &asset_server); } EditorAssetKind::Model => { cache.request_model(key.clone(), path.clone(), &asset_server, &mut studio); } EditorAssetKind::Material => { cache.request_material_asset(key.clone(), path.clone(), &mut studio); } _ => {} } } }); }); } fn breadcrumb(world: &mut World, ui: &mut egui::Ui, current_folder: &str) { let mut parts = Vec::new(); let mut cursor = Some(current_folder.to_string()); while let Some(path) = cursor { let label = if path == BUILTINS_FOLDER { "Built-ins".to_string() } else { Path::new(&path) .file_name() .and_then(|name| name.to_str()) .unwrap_or(&path) .to_string() }; cursor = path.rfind('/').map(|index| path[..index].to_string()); parts.push((path, label)); } parts.reverse(); for (index, (path, label)) in parts.iter().enumerate() { if index > 0 { ui.label(egui::RichText::new("/").color(TEXT_DIM)); } if ui.link(label).clicked() { world.resource_mut::().current_folder = path.clone(); } } } fn selection_footer(world: &mut World, ui: &mut egui::Ui, selected_entities: &SelectedEntities) { let selection = world.resource::().selected.clone(); if let Some(AssetSelection::SubAsset { label, kind, .. }) = selection.clone() { ui.add( egui::Label::new(format!( "Selected: {}: {}", subasset_kind_label(kind), label )) .truncate(), ); ui.horizontal_wrapped(|ui| match kind { AssetSubAssetKind::Mesh => { if ui.button("Place At Origin").clicked() { if let Some(selection) = selection.as_ref() { spawn_subasset_at(world, selection, Vec3::ZERO); } } } AssetSubAssetKind::Texture => { if ui.button("Apply Texture To Selection").clicked() { if let Some(asset) = texture_asset_from_subasset_selection(world, &selection) { apply_texture_to_selection(world, &asset, selected_entities); } } } AssetSubAssetKind::Material => { ui.small(egui::RichText::new("Embedded source material").color(TEXT_DIM)); } }); } else if let Some(asset) = world.resource::().selected_asset().cloned() { ui.add(egui::Label::new(format!("Selected: {}", asset_label(&asset))).truncate()); ui.horizontal_wrapped(|ui| { if matches!(asset.kind, EditorAssetKind::Texture) && ui.button("Apply Texture To Selection").clicked() { apply_texture_to_selection(world, &asset, selected_entities); } if matches!( asset.kind, EditorAssetKind::Primitive(_) | EditorAssetKind::Light(_) | EditorAssetKind::Model | EditorAssetKind::Prefab | EditorAssetKind::Level ) && ui.button("Place At Origin").clicked() { spawn_asset_at(world, &asset, Vec3::ZERO); } }); } else { ui.label(egui::RichText::new("No asset selected").color(TEXT_DIM)); } } fn folder_tree_branch( world: &mut World, ui: &mut egui::Ui, folders: &[FolderSnapshot], node_path: &str, depth: usize, current_folder: &str, ) { let Some(folder) = folders.iter().find(|folder| folder.path == node_path) else { return; }; let path = &folder.path; let name = &folder.name; let children: Vec<&FolderSnapshot> = folders .iter() .filter(|child| child.parent.as_deref() == Some(path.as_str())) .collect(); let expanded = children.is_empty() || world .resource::() .expanded_folders .contains(path); let selected = current_folder == *path; ui.horizontal(|ui| { ui.add_space(depth as f32 * 12.0); if children.is_empty() { ui.add_sized( [18.0, 20.0], egui::Label::new(icon_text(icons::DOT_OUTLINE, 12.0).color(TEXT_DIM)), ); } else { let icon = if expanded { icons::CARET_DOWN } else { icons::CARET_RIGHT }; if ui .add_sized( [18.0, 18.0], egui::Button::new(icon_text(icon, 12.0)).frame(false), ) .clicked() { let mut state = world.resource_mut::(); if expanded { state.expanded_folders.remove(path); } else { state.expanded_folders.insert(path.clone()); } } } ui.label(icon_text(icons::FOLDER, 14.0).color(TEXT_DIM)); if ui .add_sized( [fit_width(ui, 72.0, f32::INFINITY), 20.0], egui::Button::selectable(selected, name.as_str()), ) .clicked() { world.resource_mut::().current_folder = path.clone(); } }); if expanded { for child in children { folder_tree_branch(world, ui, folders, &child.path, depth + 1, current_folder); } } } fn child_folders_for_content( folders: &[FolderSnapshot], current_folder: &str, search: &str, ) -> Vec { if !search.trim().is_empty() { return Vec::new(); } let mut children: Vec = folders .iter() .filter(|folder| folder.parent.as_deref() == Some(current_folder)) .cloned() .collect(); children.sort_by(|a, b| natural_cmp(&a.name, &b.name)); children } fn visible_assets( assets: &[AssetRow], current_folder: &str, state: &AssetBrowserStateSnapshot, ) -> Vec { let search = state.search.trim().to_ascii_lowercase(); let mut rows: Vec = assets .iter() .filter(|row| { if state.recursive { row.asset.folder_path == current_folder || row .asset .folder_path .strip_prefix(current_folder) .is_some_and(|rest| rest.starts_with('/')) } else { row.asset.folder_path == current_folder } }) .filter(|row| asset_matches_kind_filter(&row.asset, state.kind_filter)) .filter(|row| { search.is_empty() || row.asset.label.to_ascii_lowercase().contains(&search) || row .asset .path .as_deref() .unwrap_or_default() .to_ascii_lowercase() .contains(&search) }) .cloned() .collect(); rows.sort_by(|a, b| match state.sort { AssetSort::Name => natural_cmp(&a.asset.label, &b.asset.label), AssetSort::Kind => kind_label(&a.asset.kind).cmp(kind_label(&b.asset.kind)), AssetSort::Modified => b.modified.cmp(&a.modified), AssetSort::Size => b.file_size.cmp(&a.file_size), }); rows } fn asset_grid( world: &mut World, ui: &mut egui::Ui, selected_entities: &SelectedEntities, selected: &Option, folders: &[FolderSnapshot], assets: &[AssetRow], cache_snapshot: &ThumbnailCacheSnapshot, thumbnail_size: f32, ) { let thumb_size = thumbnail_size.clamp(48.0, 112.0); let cell_width = thumb_size + 20.0; let gap = 6.0; let columns = ((ui.available_width() + gap) / (cell_width + gap)) .floor() .max(1.0) as usize; for folder_row in folders.chunks(columns) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing = egui::vec2(gap, gap); for folder in folder_row { let response = draw_folder_cell(ui, folder, thumbnail_size); if response.double_clicked() || response.clicked() { world.resource_mut::().current_folder = folder.path.clone(); } } }); } for asset_row in assets.chunks(columns) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing = egui::vec2(gap, gap); for row in asset_row { draw_asset_grid_item( world, ui, selected_entities, selected, row, cache_snapshot, thumbnail_size, ); } }); for row in asset_row { if row.asset.path.as_deref().is_some_and(|path| { world .resource::() .expanded_assets .contains(path) }) { embedded_asset_shelf( world, ui, selected_entities, selected, &row.asset, cache_snapshot, thumbnail_size, ); } } } } fn draw_asset_grid_item( world: &mut World, ui: &mut egui::Ui, selected_entities: &SelectedEntities, selected: &Option, row: &AssetRow, cache_snapshot: &ThumbnailCacheSnapshot, thumbnail_size: f32, ) { let selection = &row.selection; let asset = &row.asset; let is_selected = selected.as_ref() == Some(selection); let failed = cache_snapshot.is_failed(asset); let has_children = has_embedded_assets(world, asset); let response = draw_asset_cell_with( ui, asset, cache_snapshot.texture_for(asset), cache_snapshot.is_pending(asset), failed.then_some("failed"), is_selected, thumbnail_size, ); if has_children { let expanded = asset.path.as_deref().is_some_and(|path| { world .resource::() .expanded_assets .contains(path) }); let icon = if expanded { icons::CARET_DOWN } else { icons::CARET_RIGHT }; let button_rect = egui::Rect::from_min_size( response.rect.min + egui::vec2(6.0, 6.0), egui::vec2(18.0, 18.0), ); let expand_response = ui.interact( button_rect, egui::Id::new(( "asset_expand", asset.path.as_deref().unwrap_or(&asset.label), )), egui::Sense::click(), ); ui.painter().rect( button_rect, 3.0, if expand_response.hovered() { ELEVATED_BG } else { WIDGET_BG.linear_multiply(1.15) }, egui::Stroke::new(1.0, BORDER), egui::StrokeKind::Inside, ); ui.painter().text( button_rect.center(), egui::Align2::CENTER_CENTER, icon.as_str(), egui::FontId::new(11.0, egui::FontFamily::Name(PHOSPHOR.into())), TEXT, ); if expand_response.clicked() { if let Some(path) = asset.path.as_ref() { toggle_asset_expanded(world, path); } } } if failed && response.double_clicked() { let key = crate::assets::asset_cache_key(asset); world .resource_mut::() .retry(&key); } if response.clicked() { world .resource_mut::() .select(selection.clone()); } if response.drag_started() { world .resource_mut::() .start_drag(selection.clone()); } response.context_menu(|ui| { asset_context_menu(world, ui, asset, selected_entities); }); } fn embedded_asset_shelf( world: &mut World, ui: &mut egui::Ui, selected_entities: &SelectedEntities, selected: &Option, parent_asset: &EditorAsset, cache_snapshot: &ThumbnailCacheSnapshot, thumbnail_size: f32, ) { let embedded = embedded_assets_for_asset(world, parent_asset); if embedded.is_empty() { return; } prefetch_embedded_thumbnails(world, parent_asset, &embedded); let parent_texture = cache_snapshot.texture_for(parent_asset); ui.add_space(4.0); egui::Frame::new() .fill(WIDGET_BG.linear_multiply(0.72)) .stroke(egui::Stroke::new(1.0, BORDER.linear_multiply(0.85))) .corner_radius(egui::CornerRadius::same(4)) .inner_margin(egui::Margin::symmetric(8, 5)) .show(ui, |ui| { ui.set_min_width(ui.available_width()); ui.horizontal(|ui| { ui.label(icon_text(icons::TREE_STRUCTURE, 13.0).color(TEXT_DIM)); ui.small( egui::RichText::new(format!("{} contents", parent_asset.label)).color(TEXT_DIM), ); }); egui::ScrollArea::horizontal() .id_salt(format!( "embedded_shelf_{}", parent_asset.path.as_deref().unwrap_or(&parent_asset.label) )) .max_height(thumbnail_size.clamp(48.0, 88.0) + 56.0) .auto_shrink([false, true]) .show(ui, |ui| { ui.horizontal(|ui| { ui.spacing_mut().item_spacing = egui::vec2(7.0, 5.0); for embedded_asset in &embedded { draw_embedded_asset_cell( world, ui, selected_entities, selected, embedded_asset, cache_snapshot, parent_texture, thumbnail_size, ); } }); }); }); ui.add_space(4.0); } fn prefetch_embedded_thumbnails( world: &mut World, parent_asset: &EditorAsset, embedded: &[EmbeddedAsset], ) { let Some(parent_path) = parent_asset.path.clone() else { return; }; let asset_server = world.resource::().clone(); let requests: Vec = embedded.to_vec(); world.resource_scope(|world, mut cache: Mut| { world.resource_scope(|_world, mut studio: Mut| { for embedded_asset in &requests { match embedded_asset.kind { AssetSubAssetKind::Mesh => { let Some(mesh_label) = embedded_asset.mesh_label.clone() else { continue; }; cache.request_mesh_subasset( embedded_asset.thumbnail_key.clone(), parent_path.clone(), mesh_label, embedded_asset.material_label.clone(), &mut studio, ); } AssetSubAssetKind::Material => { let Some(material_label) = embedded_asset.material_label.clone() else { continue; }; cache.request_source_material( embedded_asset.thumbnail_key.clone(), parent_path.clone(), material_label, &mut studio, ); } AssetSubAssetKind::Texture => { let Some(texture_path) = embedded_asset.texture_path.clone() else { continue; }; cache.request_texture( embedded_asset.thumbnail_key.clone(), texture_path, &asset_server, ); } } } }); }); } fn draw_embedded_asset_cell( world: &mut World, ui: &mut egui::Ui, selected_entities: &SelectedEntities, selected: &Option, embedded: &EmbeddedAsset, cache_snapshot: &ThumbnailCacheSnapshot, _parent_texture: Option, thumbnail_size: f32, ) { let selected = selected.as_ref() == Some(&embedded.selection); let texture_id = cache_snapshot .texture_for_key(&embedded.thumbnail_key) .or_else(|| { embedded .texture_path .as_deref() .and_then(|path| thumbnail_for_path(world, cache_snapshot, path)) }); let pending = cache_snapshot.is_pending_key(&embedded.thumbnail_key); let failed = cache_snapshot.is_failed_key(&embedded.thumbnail_key); let thumb_size = thumbnail_size.clamp(44.0, 64.0); let cell_size = egui::vec2(104.0, thumb_size + 50.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() { WIDGET_BG.linear_multiply(1.2) } else { WIDGET_BG }; ui.painter().rect( rect, 4.0, fill, egui::Stroke::new(1.0, if selected { ACCENT } else { BORDER }), egui::StrokeKind::Inside, ); let thumb_rect = egui::Rect::from_min_size( egui::pos2(rect.center().x - thumb_size * 0.5, rect.min.y + 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 { ui.painter().rect( thumb_rect, 4.0, ELEVATED_BG.linear_multiply(0.6), egui::Stroke::new(1.0, BORDER), egui::StrokeKind::Inside, ); let icon = if pending { icons::CIRCLE_NOTCH } else if failed { icons::WARNING_CIRCLE } else { subasset_icon(embedded.kind) }; ui.painter().text( thumb_rect.center(), egui::Align2::CENTER_CENTER, icon.as_str(), egui::FontId::new(24.0, egui::FontFamily::Name(PHOSPHOR.into())), TEXT, ); } let label = compact_subasset_label(embedded); let detail = compact_text(&embedded.detail, 18); let label_pos = egui::pos2(rect.center().x, thumb_rect.max.y + 7.0); ui.painter().text( label_pos, egui::Align2::CENTER_TOP, label.as_str(), egui::FontId::new(12.0, egui::FontFamily::Proportional), if selected { crate::ui::theme::TEXT_SELECTED } else { TEXT }, ); ui.painter().text( label_pos + egui::vec2(0.0, 17.0), egui::Align2::CENTER_TOP, format!("{} | {}", subasset_kind_label(embedded.kind), detail), egui::FontId::new(10.0, egui::FontFamily::Proportional), TEXT_DIM, ); if response.clicked() { world .resource_mut::() .select(embedded.selection.clone()); } if response.drag_started() { world .resource_mut::() .start_drag(embedded.selection.clone()); } response.context_menu(|ui| { subasset_context_menu(world, ui, embedded, selected_entities); }); } fn compact_subasset_label(embedded: &EmbeddedAsset) -> String { let base = match embedded.kind { AssetSubAssetKind::Mesh => embedded .label .split_once(" / ") .map(|(head, _)| head) .unwrap_or(&embedded.label), AssetSubAssetKind::Material | AssetSubAssetKind::Texture => embedded.label.as_str(), }; compact_text(base, 16) } fn compact_text(text: &str, max_chars: usize) -> String { let mut chars = text.chars(); let mut compact = String::new(); for _ in 0..max_chars { let Some(ch) = chars.next() else { return text.to_string(); }; compact.push(ch); } if chars.next().is_some() { compact.push_str("..."); } compact } fn thumbnail_for_path( world: &World, cache_snapshot: &ThumbnailCacheSnapshot, path: &str, ) -> Option { let assets = world.resource::(); assets .assets .iter() .find(|asset| asset.path.as_deref() == Some(path)) .and_then(|asset| cache_snapshot.texture_for(asset)) } fn toggle_asset_expanded(world: &mut World, path: &str) { let mut state = world.resource_mut::(); if !state.expanded_assets.insert(path.to_string()) { state.expanded_assets.remove(path); } } fn subasset_icon(kind: AssetSubAssetKind) -> Icon { match kind { AssetSubAssetKind::Mesh => icons::CUBE, AssetSubAssetKind::Material => icons::PALETTE, AssetSubAssetKind::Texture => icons::IMAGE, } } fn subasset_kind_label(kind: AssetSubAssetKind) -> &'static str { match kind { AssetSubAssetKind::Mesh => "Mesh", AssetSubAssetKind::Material => "Material", AssetSubAssetKind::Texture => "Texture", } } fn asset_list( world: &mut World, ui: &mut egui::Ui, selected_entities: &SelectedEntities, selected: &Option, folders: &[FolderSnapshot], assets: &[AssetRow], cache_snapshot: &ThumbnailCacheSnapshot, ) { if ui.available_width() < COMPACT_LIST_WIDTH { compact_asset_list( world, ui, selected_entities, selected, folders, assets, cache_snapshot, ); return; } ui.horizontal(|ui| { ui.add_sized([236.0, 18.0], egui::Label::new("Name")); ui.add_sized([80.0, 18.0], egui::Label::new("Type")); ui.add_sized([72.0, 18.0], egui::Label::new("Size")); ui.add_sized([88.0, 18.0], egui::Label::new("Modified")); ui.label("Path"); }); for folder in folders { let response = egui::Frame::new() .fill(WIDGET_BG.linear_multiply(0.9)) .inner_margin(egui::Margin::symmetric(6, 3)) .show(ui, |ui| { ui.horizontal(|ui| { ui.add_sized([20.0, 20.0], egui::Label::new("")); ui.label(icon_text(icons::FOLDER, 14.0).color(TEXT_DIM)); ui.add_sized( [196.0, 20.0], egui::Button::selectable(false, folder.name.as_str()), ) }) .inner }) .inner; if response.clicked() { world.resource_mut::().current_folder = folder.path.clone(); } } for row in assets { draw_asset_list_item(world, ui, selected_entities, selected, row); if row.asset.path.as_deref().is_some_and(|path| { world .resource::() .expanded_assets .contains(path) }) { embedded_asset_shelf( world, ui, selected_entities, selected, &row.asset, cache_snapshot, 56.0, ); } } } fn draw_asset_list_item( world: &mut World, ui: &mut egui::Ui, selected_entities: &SelectedEntities, selected: &Option, row: &AssetRow, ) { let has_children = has_embedded_assets(world, &row.asset); let expanded = row.asset.path.as_deref().is_some_and(|path| { world .resource::() .expanded_assets .contains(path) }); let is_selected = selected.as_ref() == Some(&row.selection); let response = egui::Frame::new() .fill(if is_selected { crate::ui::theme::SELECTION_BG_MUTED } else { WIDGET_BG.linear_multiply(0.9) }) .stroke(egui::Stroke::new( 1.0, if is_selected { ACCENT } else { BORDER }, )) .inner_margin(egui::Margin::symmetric(6, 3)) .show(ui, |ui| { ui.horizontal(|ui| { if has_children { let icon = if expanded { icons::CARET_DOWN } else { icons::CARET_RIGHT }; if ui .add_sized( [20.0, 20.0], egui::Button::new(icon_text(icon, 12.0)).frame(false), ) .clicked() { if let Some(path) = row.asset.path.as_ref() { toggle_asset_expanded(world, path); } } } else { ui.add_sized([20.0, 20.0], egui::Label::new("")); } ui.label(icon_text(kind_icon(&row.asset.kind), 14.0).color(TEXT_DIM)); let response = ui.add_sized( [196.0, 20.0], egui::Button::selectable(is_selected, row.asset.label.as_str()), ); ui.add_sized([80.0, 20.0], egui::Label::new(kind_label(&row.asset.kind))); ui.add_sized( [72.0, 20.0], egui::Label::new( row.file_size .map(format_bytes) .unwrap_or_else(|| "-".to_string()), ), ); ui.add_sized( [88.0, 20.0], egui::Label::new( row.modified .map(format_modified) .unwrap_or_else(|| "-".to_string()), ), ); ui.add( egui::Label::new(row.asset.path.as_deref().unwrap_or("Built-in")).truncate(), ); ui.menu_button(icon_text(icons::DOTS_THREE_VERTICAL, 13.0), |ui| { asset_context_menu(world, ui, &row.asset, selected_entities); }); response }) .inner }) .inner; if response.clicked() { world .resource_mut::() .select(row.selection.clone()); } if response.drag_started() { world .resource_mut::() .start_drag(row.selection.clone()); } response.context_menu(|ui| { asset_context_menu(world, ui, &row.asset, selected_entities); }); } fn compact_asset_list( world: &mut World, ui: &mut egui::Ui, selected_entities: &SelectedEntities, selected: &Option, folders: &[FolderSnapshot], assets: &[AssetRow], cache_snapshot: &ThumbnailCacheSnapshot, ) { for folder in folders { let mut clicked = false; ui.horizontal(|ui| { ui.label(icon_text(icons::FOLDER, 14.0).color(TEXT_DIM)); clicked = ui .add_sized( [fit_width(ui, 120.0, f32::INFINITY), 22.0], egui::Button::selectable(false, folder.name.as_str()), ) .clicked(); }); if clicked { world.resource_mut::().current_folder = folder.path.clone(); } ui.small(egui::RichText::new("Folder").color(TEXT_DIM)); ui.add_space(4.0); } for row in assets { let is_selected = selected.as_ref() == Some(&row.selection); let has_children = has_embedded_assets(world, &row.asset); let expanded = row.asset.path.as_deref().is_some_and(|path| { world .resource::() .expanded_assets .contains(path) }); let mut response = None; ui.horizontal(|ui| { if has_children { let icon = if expanded { icons::CARET_DOWN } else { icons::CARET_RIGHT }; if ui .add_sized( [20.0, 20.0], egui::Button::new(icon_text(icon, 12.0)).frame(false), ) .clicked() { if let Some(path) = row.asset.path.as_ref() { toggle_asset_expanded(world, path); } } } ui.label(icon_text(kind_icon(&row.asset.kind), 14.0).color(TEXT_DIM)); response = Some(ui.add_sized( [fit_width(ui, 120.0, f32::INFINITY), 22.0], egui::Button::selectable(is_selected, row.asset.label.as_str()), )); ui.menu_button(icon_text(icons::DOTS_THREE_VERTICAL, 13.0), |ui| { asset_context_menu(world, ui, &row.asset, selected_entities); }); }); let response = response.expect("compact asset list response"); let size = row .file_size .map(format_bytes) .unwrap_or_else(|| "-".to_string()); let modified = row .modified .map(format_modified) .unwrap_or_else(|| "-".to_string()); let path = row.asset.path.as_deref().unwrap_or("Built-in"); ui.add( egui::Label::new( egui::RichText::new(format!( "{} | {} | {} | {}", kind_label(&row.asset.kind), size, modified, path )) .color(TEXT_DIM), ) .truncate(), ); ui.add_space(4.0); if response.clicked() { world .resource_mut::() .select(row.selection.clone()); } if response.drag_started() { world .resource_mut::() .start_drag(row.selection.clone()); } response.context_menu(|ui| { asset_context_menu(world, ui, &row.asset, selected_entities); }); if expanded { embedded_asset_shelf( world, ui, selected_entities, selected, &row.asset, cache_snapshot, 56.0, ); } } } fn draw_folder_cell( ui: &mut egui::Ui, folder: &FolderSnapshot, 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()); let fill = if response.hovered() { ELEVATED_BG } else { WIDGET_BG }; ui.painter().rect( rect, 4.0, fill, egui::Stroke::new(1.0, BORDER), egui::StrokeKind::Inside, ); ui.painter().text( rect.center_top() + egui::vec2(0.0, 10.0 + thumb_size * 0.45), egui::Align2::CENTER_CENTER, icons::FOLDER_SIMPLE.as_str(), egui::FontId::new( (thumb_size * 0.48).max(24.0), egui::FontFamily::Name("phosphor-regular".into()), ), TEXT, ); ui.painter().text( egui::pos2(rect.center().x, rect.max.y - 6.0), egui::Align2::CENTER_BOTTOM, folder.name.as_str(), egui::FontId::new(11.0, egui::FontFamily::Proportional), TEXT, ); response } fn details_panel(world: &mut World, ui: &mut egui::Ui, selected_entities: &SelectedEntities) { ui.label(panel_heading("Details")); ui.separator(); let selection = world.resource::().selected.clone(); if let Some(selection @ AssetSelection::SubAsset { .. }) = selection { subasset_details_panel(world, ui, selected_entities, &selection); } else if let Some(asset) = world.resource::().selected_asset().cloned() { top_level_asset_details_panel(world, ui, selected_entities, &asset); } else { ui.label(egui::RichText::new("No asset selected").color(TEXT_DIM)); } } fn top_level_asset_details_panel( world: &mut World, ui: &mut egui::Ui, selected_entities: &SelectedEntities, asset: &EditorAsset, ) { asset_details_header(ui, asset); ui.add_space(8.0); detail_row(ui, "Path", asset.path.as_deref().unwrap_or("Built-in")); detail_row(ui, "Folder", asset.folder_path.as_str()); if let Some(path) = asset.path.as_deref() { if let Some(record) = world .get_resource::() .and_then(|registry| find_asset_by_path(registry, path)) { detail_row(ui, "Asset ID", &record.id.as_string()); if matches!(asset.kind, EditorAssetKind::Model) { ui.separator(); ui.label(panel_heading("Import Settings")); import_settings_editor(world, ui, asset, path, &record.import_settings); if let Some(manifest) = record.import_settings.static_mesh_manifest_path.as_deref() { detail_row(ui, "Static mesh artifact", manifest); } } if !record.dependencies.is_empty() { ui.separator(); ui.label(panel_heading("Dependencies")); for dep in &record.dependencies { ui.add(egui::Label::new(dep).truncate()); } } } detail_row( ui, "Size", file_size(path).map(format_bytes).as_deref().unwrap_or("-"), ); detail_row( ui, "Modified", modified_time(path) .map(format_modified) .as_deref() .unwrap_or("-"), ); if matches!(asset.kind, EditorAssetKind::Material) { ui.separator(); ui.label(panel_heading("Material")); material_asset_editor(world, ui, asset, path); } } ui.separator(); asset_action_buttons(world, ui, selected_entities, asset); } fn asset_details_header(ui: &mut egui::Ui, asset: &EditorAsset) { ui.horizontal_wrapped(|ui| { ui.label( egui::RichText::new(kind_icon(&asset.kind).as_str()) .font(egui::FontId::new( 30.0, egui::FontFamily::Name(PHOSPHOR.into()), )) .color(ACCENT), ); ui.vertical(|ui| { ui.strong(asset.label.as_str()); ui.small(egui::RichText::new(kind_label(&asset.kind)).color(TEXT_DIM)); }); }); } fn subasset_details_panel( world: &mut World, ui: &mut egui::Ui, selected_entities: &SelectedEntities, selection: &AssetSelection, ) { let Some(embedded) = embedded_asset_for_selection(world, selection) else { ui.colored_label(egui::Color32::YELLOW, "Embedded asset is not available."); return; }; let AssetSelection::SubAsset { parent_path, sub_asset_id, .. } = selection else { return; }; ui.horizontal_wrapped(|ui| { ui.label( egui::RichText::new(subasset_icon(embedded.kind).as_str()) .font(egui::FontId::new( 30.0, egui::FontFamily::Name(PHOSPHOR.into()), )) .color(ACCENT), ); ui.vertical(|ui| { ui.strong(embedded.label.as_str()); ui.small(egui::RichText::new(subasset_kind_label(embedded.kind)).color(TEXT_DIM)); }); }); ui.add_space(8.0); detail_row(ui, "Parent", parent_path); detail_row(ui, "ID", sub_asset_id); detail_row(ui, "Source", &embedded.detail); ui.separator(); match embedded.kind { AssetSubAssetKind::Mesh => { if ui.button("Place At Origin").clicked() { spawn_subasset_at(world, selection, Vec3::ZERO); } } AssetSubAssetKind::Texture => { if ui.button("Apply Texture To Selection").clicked() { if let Some(asset) = texture_asset_from_subasset_selection(world, &Some(selection.clone())) { apply_texture_to_selection(world, &asset, selected_entities); } } } AssetSubAssetKind::Material => { ui.small( egui::RichText::new( "Source materials are assigned through static mesh renderer slots.", ) .color(TEXT_DIM), ); } } if ui.button("Select Parent Asset").clicked() { world .resource_mut::() .select(AssetSelection::File(parent_path.clone())); } } fn asset_action_buttons( world: &mut World, ui: &mut egui::Ui, selected_entities: &SelectedEntities, asset: &EditorAsset, ) { ui.horizontal_wrapped(|ui| { if matches!(asset.kind, EditorAssetKind::Texture) && ui.button("Apply Texture").clicked() { apply_texture_to_selection(world, asset, selected_entities); } if matches!(asset.kind, EditorAssetKind::Material) && ui.button("Apply Material").clicked() { apply_material_asset_to_selection(world, asset, selected_entities); } if matches!( asset.kind, EditorAssetKind::Primitive(_) | EditorAssetKind::Light(_) | EditorAssetKind::Model | EditorAssetKind::Prefab | EditorAssetKind::Level ) && ui.button("Place At Origin").clicked() { spawn_asset_at(world, asset, Vec3::ZERO); } if matches!(asset.kind, EditorAssetKind::Model) && has_embedded_assets(world, asset) && ui.button("Toggle Contents").clicked() { if let Some(path) = asset.path.as_ref() { toggle_asset_expanded(world, path); } } if matches!(asset.kind, EditorAssetKind::Model) && ui.button("Reimport").clicked() { reimport_asset(world, asset); } if asset.path.is_some() && ui.button("Move To Trash").clicked() { request_delete_for_asset(world, asset); } }); } fn import_settings_editor( world: &mut World, ui: &mut egui::Ui, asset: &EditorAsset, path: &str, current: &ImportSettings, ) { { let mut state = world.resource_mut::(); let reset = state .import_draft .as_ref() .is_none_or(|draft| draft.path != path); if reset { state.import_draft = Some(ImportSettingsDraft { path: path.to_string(), settings: current.clone(), }); } } let mut apply = None; let mut revert = false; { let mut state = world.resource_mut::(); let Some(draft) = state.import_draft.as_mut() else { return; }; ui.add(egui::Slider::new(&mut draft.settings.scale, 0.01..=10.0).text("Scale")); ui.checkbox(&mut draft.settings.generate_collider, "Generate collider"); ui.checkbox(&mut draft.settings.lod0_only, "LOD0 only"); egui::ComboBox::from_id_salt("model_placement_mode") .selected_text(match draft.settings.placement_mode { ModelPlacementMode::StaticAsset => "Static Asset", ModelPlacementMode::SceneInstance => "Scene Instance", }) .show_ui(ui, |ui| { ui.selectable_value( &mut draft.settings.placement_mode, ModelPlacementMode::StaticAsset, "Static Asset", ); ui.selectable_value( &mut draft.settings.placement_mode, ModelPlacementMode::SceneInstance, "Scene Instance", ); }); egui::ComboBox::from_id_salt("model_hierarchy_mode") .selected_text(match draft.settings.hierarchy_mode { ModelHierarchyMode::SingleActor => "One Actor", ModelHierarchyMode::SourceHierarchy => "Source Hierarchy", }) .show_ui(ui, |ui| { ui.selectable_value( &mut draft.settings.hierarchy_mode, ModelHierarchyMode::SingleActor, "One Actor", ); ui.selectable_value( &mut draft.settings.hierarchy_mode, ModelHierarchyMode::SourceHierarchy, "Source Hierarchy", ); }); egui::ComboBox::from_id_salt("model_material_policy") .selected_text(match draft.settings.material_policy { MaterialImportPolicy::SourceMaterials => "Source Materials", MaterialImportPolicy::AuthoringOverride => "Authoring Override", }) .show_ui(ui, |ui| { ui.selectable_value( &mut draft.settings.material_policy, MaterialImportPolicy::SourceMaterials, "Source Materials", ); ui.selectable_value( &mut draft.settings.material_policy, MaterialImportPolicy::AuthoringOverride, "Authoring Override", ); }); ui.horizontal(|ui| { if ui.button("Apply").clicked() { apply = Some(draft.settings.clone()); } if ui.button("Revert").clicked() { revert = true; } }); } if revert { world.resource_mut::().import_draft = Some(ImportSettingsDraft { path: path.to_string(), settings: current.clone(), }); } if let Some(settings) = apply { apply_import_settings(world, asset, path, settings); } } fn apply_import_settings( world: &mut World, asset: &EditorAsset, path: &str, settings: ImportSettings, ) { let mut status = format!("Updated import settings for {}", asset.label); if let Some(mut registry) = world.get_resource_mut::() { update_import_settings(&mut registry, path, settings); if matches!(asset.kind, EditorAssetKind::Model) { if let Some(record) = find_asset_mut_by_path(&mut registry, path) { if let Err(error) = refresh_static_mesh_artifact(record) { status = format!("Static mesh refresh failed for {}: {error}", asset.label); warn!("{status}"); } } } if let Err(error) = save_registry(®istry) { status = format!("Asset registry save failed: {error}"); warn!("{status}"); } else { registry.index_dirty = false; } } invalidate_on_catalog_refresh(world); world.resource_mut::().status = status; } fn material_asset_editor(world: &mut World, ui: &mut egui::Ui, asset: &EditorAsset, path: &str) { ensure_material_draft(world, path, &asset.label); let shader_schemas = shader_schema_assets(world); let texture_assets = texture_asset_refs(world); let mut apply = false; let mut revert = false; { let mut state = world.resource_mut::(); let Some(draft) = state.material_draft.as_mut() else { return; }; if let Some(error) = draft.error.as_ref() { ui.colored_label(egui::Color32::YELLOW, error); } compact_text_edit(ui, "Label", &mut draft.asset.label); material_shader_interface(ui, &mut draft.asset, &shader_schemas, &texture_assets); ui.separator(); ui.label(panel_heading("Standard Surface")); color_desc_edit(ui, "Base color", &mut draft.asset.material.base_color); ui.add(egui::Slider::new(&mut draft.asset.material.metallic, 0.0..=1.0).text("Metallic")); ui.add(egui::Slider::new(&mut draft.asset.material.roughness, 0.0..=1.0).text("Roughness")); color_desc_edit(ui, "Emissive", &mut draft.asset.material.emissive_color); ui.add( egui::Slider::new(&mut draft.asset.material.emissive_intensity, 0.0..=50_000.0) .text("Emissive nits"), ); optional_path_edit( ui, "Base texture", &mut draft.asset.material.base_color_texture, ); optional_path_edit( ui, "Emissive texture", &mut draft.asset.material.emissive_texture, ); optional_path_edit( ui, "Normal map", &mut draft.asset.material.normal_map_texture, ); optional_path_edit( ui, "Metal/rough", &mut draft.asset.material.metallic_roughness_texture, ); ui.horizontal(|ui| { if ui.button("Apply").clicked() { apply = true; } if ui.button("Revert").clicked() { revert = true; } }); } if revert { world.resource_mut::().material_draft = None; ensure_material_draft(world, path, &asset.label); } if apply { save_material_draft(world); } } fn ensure_material_draft(world: &mut World, path: &str, fallback_label: &str) { let needs_load = world .resource::() .material_draft .as_ref() .is_none_or(|draft| draft.path != path); if !needs_load { return; } let draft = match MaterialAsset::load_from_path(path) { Ok(asset) => MaterialAssetDraft { path: path.to_string(), asset, error: None, }, Err(error) => MaterialAssetDraft { path: path.to_string(), asset: MaterialAsset { label: fallback_label.to_string(), shader: None, material: MaterialDesc::default(), }, error: Some(error), }, }; world.resource_mut::().material_draft = Some(draft); } fn save_material_draft(world: &mut World) { let Some(draft) = world .resource::() .material_draft .clone() else { return; }; let result = ron::ser::to_string_pretty(&draft.asset, ron::ser::PrettyConfig::default()) .map_err(|error| format!("could not serialize material {}: {error}", draft.path)) .and_then(|text| { fs::write(&draft.path, text) .map_err(|error| format!("could not write material {}: {error}", draft.path)) }); match result { Ok(()) => { if let Some(current) = world .resource_mut::() .material_draft .as_mut() { current.error = None; } world.resource_mut::().refresh(); invalidate_on_catalog_refresh(world); world.resource_mut::().status = format!("Saved material {}", draft.path); } Err(error) => { if let Some(current) = world .resource_mut::() .material_draft .as_mut() { current.error = Some(error.clone()); } world.resource_mut::().status = error; } } } fn shader_schema_assets(world: &World) -> Vec<(String, String)> { let mut schemas: Vec<(String, String)> = world .resource::() .assets .iter() .filter(|asset| matches!(asset.kind, EditorAssetKind::ShaderSchema)) .filter_map(|asset| Some((asset.label.clone(), asset.path.clone()?))) .collect(); schemas.sort_by(|a, b| natural_cmp(&a.0, &b.0)); schemas } fn texture_asset_refs(world: &World) -> Vec<(String, EditorAssetRef)> { let registry = world.get_resource::(); let mut textures: Vec<(String, EditorAssetRef)> = world .resource::() .assets .iter() .filter(|asset| matches!(asset.kind, EditorAssetKind::Texture)) .filter_map(|asset| { let path = asset.path.as_deref()?; let record = registry.and_then(|registry| find_asset_by_path(registry, path))?; Some(( asset.label.clone(), EditorAssetRef::new(record.id.as_string(), "texture:source", asset.label.clone()), )) }) .collect(); textures.sort_by(|a, b| natural_cmp(&a.0, &b.0)); textures } fn material_shader_interface( ui: &mut egui::Ui, asset: &mut MaterialAsset, shader_schemas: &[(String, String)], texture_assets: &[(String, EditorAssetRef)], ) { shader_kind_picker(ui, &mut asset.material.shader.kind); if matches!(asset.material.shader.kind, MaterialShaderKind::Custom) { let mut selected_schema = asset.material.shader.schema_path.clone(); ui.horizontal(|ui| { ui.add_sized([92.0, 20.0], egui::Label::new("Schema")); egui::ComboBox::from_id_salt("asset_material_shader_schema") .selected_text( selected_schema .as_deref() .and_then(|path| { shader_schemas .iter() .find(|(_, schema_path)| schema_path == path) .map(|(label, _)| label.as_str()) }) .unwrap_or("(none)"), ) .show_ui(ui, |ui| { if ui .selectable_label(selected_schema.is_none(), "(none)") .clicked() { selected_schema = None; } for (label, path) in shader_schemas { if ui .selectable_label(selected_schema.as_deref() == Some(path), label) .clicked() { selected_schema = Some(path.clone()); } } }); }); if selected_schema != asset.material.shader.schema_path { asset.material.shader.schema_path = selected_schema.clone(); if let Some(path) = selected_schema.as_deref() { if let Err(error) = apply_shader_schema(asset, path) { ui.colored_label(egui::Color32::YELLOW, error); } } } optional_path_edit(ui, "WGSL shader", &mut asset.material.shader.shader_path); if let Some(path) = asset.material.shader.schema_path.clone() { ui.horizontal(|ui| { if ui.button("Reload Schema").clicked() { if let Err(error) = apply_shader_schema(asset, &path) { ui.colored_label(egui::Color32::YELLOW, error); } } ui.small(egui::RichText::new(path).color(TEXT_DIM)); }); } } else { asset.material.shader.schema_path = None; asset.material.shader.shader_path = None; } let schema = asset .material .shader .schema_path .as_deref() .and_then(|path| ShaderSchemaAsset::load_from_path(path).ok()); if let Some(schema) = schema.as_ref() { ui.separator(); ui.label(panel_heading("Shader Parameters")); shader_schema_parameter_editor(ui, &schema.parameters, &mut asset.material.parameters); shader_schema_texture_editor( ui, &schema.parameters, &mut asset.material.textures, texture_assets, ); } else if !asset.material.parameters.is_empty() || !asset.material.textures.is_empty() { ui.separator(); ui.label(panel_heading("Stored Shader Values")); raw_material_parameter_editor(ui, &mut asset.material.parameters); raw_material_texture_editor(ui, &mut asset.material.textures, texture_assets); } } fn apply_shader_schema(asset: &mut MaterialAsset, path: &str) -> Result<(), String> { let schema = ShaderSchemaAsset::load_from_path(path)?; asset.shader = Some(schema.label.clone()); asset.material.shader.kind = schema.kind; asset.material.shader.schema_path = Some(path.to_string()); if asset.material.shader.shader_path.is_none() { asset.material.shader.shader_path = schema.wgsl_path.clone(); } for property in &schema.parameters { if matches!(property.property_type, ShaderPropertyType::Texture) { if !asset .material .textures .iter() .any(|texture| texture.name == property.name) { let default = schema .default_textures .iter() .find(|texture| texture.name == property.name) .cloned() .unwrap_or(MaterialTextureBinding { name: property.name.clone(), texture: None, }); asset.material.textures.push(default); } continue; } let default = schema .default_values .iter() .find(|parameter| parameter.name == property.name) .map(|parameter| parameter.value.clone()) .unwrap_or_else(|| default_value_for_shader_property(&property.property_type)); match asset .material .parameters .iter_mut() .find(|parameter| parameter.name == property.name) { Some(parameter) => { if !parameter_value_matches_property(¶meter.value, &property.property_type) { parameter.value = default; } } None => asset.material.parameters.push(MaterialParameter { name: property.name.clone(), value: default, }), } } Ok(()) } fn shader_schema_parameter_editor( ui: &mut egui::Ui, properties: &[ShaderPropertyDesc], parameters: &mut Vec, ) { for property in properties .iter() .filter(|property| !matches!(property.property_type, ShaderPropertyType::Texture)) { let value = ensure_material_parameter(parameters, property); material_parameter_value_ui(ui, &property.display_name, &property.property_type, value); } } fn shader_schema_texture_editor( ui: &mut egui::Ui, properties: &[ShaderPropertyDesc], textures: &mut Vec, texture_assets: &[(String, EditorAssetRef)], ) { let texture_props: Vec<&ShaderPropertyDesc> = properties .iter() .filter(|property| matches!(property.property_type, ShaderPropertyType::Texture)) .collect(); if texture_props.is_empty() { return; } ui.separator(); ui.label(panel_heading("Shader Textures")); for property in texture_props { let texture = ensure_material_texture_binding(textures, &property.name); texture_binding_ui(ui, &property.display_name, texture, texture_assets); } } fn raw_material_parameter_editor(ui: &mut egui::Ui, parameters: &mut [MaterialParameter]) { for parameter in parameters { let property_type = property_type_for_value(¶meter.value); material_parameter_value_ui(ui, ¶meter.name, &property_type, &mut parameter.value); } } fn raw_material_texture_editor( ui: &mut egui::Ui, textures: &mut [MaterialTextureBinding], texture_assets: &[(String, EditorAssetRef)], ) { if textures.is_empty() { return; } ui.separator(); ui.label(panel_heading("Stored Shader Textures")); for texture in textures { let label = texture.name.clone(); texture_binding_ui(ui, &label, texture, texture_assets); } } fn ensure_material_parameter<'a>( parameters: &'a mut Vec, property: &ShaderPropertyDesc, ) -> &'a mut MaterialParameterValue { let index = parameters .iter() .position(|parameter| parameter.name == property.name) .unwrap_or_else(|| { parameters.push(MaterialParameter { name: property.name.clone(), value: default_value_for_shader_property(&property.property_type), }); parameters.len() - 1 }); if !parameter_value_matches_property(¶meters[index].value, &property.property_type) { parameters[index].value = default_value_for_shader_property(&property.property_type); } &mut parameters[index].value } fn ensure_material_texture_binding<'a>( textures: &'a mut Vec, name: &str, ) -> &'a mut MaterialTextureBinding { let index = textures .iter() .position(|texture| texture.name == name) .unwrap_or_else(|| { textures.push(MaterialTextureBinding { name: name.to_string(), texture: None, }); textures.len() - 1 }); &mut textures[index] } fn material_parameter_value_ui( ui: &mut egui::Ui, label: &str, property_type: &ShaderPropertyType, value: &mut MaterialParameterValue, ) { if !parameter_value_matches_property(value, property_type) { *value = default_value_for_shader_property(property_type); } ui.horizontal(|ui| { ui.add_sized([92.0, 20.0], egui::Label::new(label)); match value { MaterialParameterValue::Bool(value) => { ui.checkbox(value, ""); } MaterialParameterValue::Float(value) => { let (min, max) = match property_type { ShaderPropertyType::Float { min, max } => (*min, *max), _ => (None, None), }; match (min, max) { (Some(min), Some(max)) => { ui.add(egui::Slider::new(value, min..=max)); } _ => { ui.add(egui::DragValue::new(value).speed(0.01)); } } } MaterialParameterValue::Vec2(value) => { ui.add_sized([64.0, 20.0], egui::DragValue::new(&mut value.x).speed(0.01)); ui.add_sized([64.0, 20.0], egui::DragValue::new(&mut value.y).speed(0.01)); } MaterialParameterValue::Vec3(value) => { ui.add_sized([52.0, 20.0], egui::DragValue::new(&mut value.x).speed(0.01)); ui.add_sized([52.0, 20.0], egui::DragValue::new(&mut value.y).speed(0.01)); ui.add_sized([52.0, 20.0], egui::DragValue::new(&mut value.z).speed(0.01)); } MaterialParameterValue::Color(value) => { let mut rgba = [value.r, value.g, value.b, value.a]; if ui.color_edit_button_rgba_unmultiplied(&mut rgba).changed() { *value = ColorDesc { r: rgba[0], g: rgba[1], b: rgba[2], a: rgba[3], }; } } MaterialParameterValue::Enum(value) => { let options = match property_type { ShaderPropertyType::Enum { options } => options.as_slice(), _ => &[], }; egui::ComboBox::from_id_salt(format!("material_enum_{label}")) .selected_text(value.as_str()) .show_ui(ui, |ui| { for option in options { ui.selectable_value(value, option.clone(), option); } }); } } }); } fn texture_binding_ui( ui: &mut egui::Ui, label: &str, binding: &mut MaterialTextureBinding, texture_assets: &[(String, EditorAssetRef)], ) { ui.horizontal(|ui| { ui.add_sized([92.0, 20.0], egui::Label::new(label)); let selected = binding .texture .as_ref() .and_then(|current| { texture_assets .iter() .find(|(_, candidate)| *candidate == *current) .map(|(label, _)| label.as_str()) }) .unwrap_or("(none)"); egui::ComboBox::from_id_salt(format!("texture_binding_{}", binding.name)) .selected_text(selected) .show_ui(ui, |ui| { if ui .selectable_label(binding.texture.is_none(), "(none)") .clicked() { binding.texture = None; } for (label, reference) in texture_assets { if ui .selectable_label(binding.texture.as_ref() == Some(reference), label) .clicked() { binding.texture = Some(reference.clone()); } } }); if ui.button("Clear").clicked() { binding.texture = None; } }); } fn default_value_for_shader_property(property_type: &ShaderPropertyType) -> MaterialParameterValue { match property_type { ShaderPropertyType::Bool => MaterialParameterValue::Bool(false), ShaderPropertyType::Float { .. } => MaterialParameterValue::Float(0.0), ShaderPropertyType::Vec2 => MaterialParameterValue::Vec2(Vec2::ZERO), ShaderPropertyType::Vec3 => MaterialParameterValue::Vec3(Vec3::ZERO), ShaderPropertyType::Color => MaterialParameterValue::Color(ColorDesc::default()), ShaderPropertyType::Enum { options } => { MaterialParameterValue::Enum(options.first().cloned().unwrap_or_default()) } ShaderPropertyType::Texture => MaterialParameterValue::Float(0.0), } } fn parameter_value_matches_property( value: &MaterialParameterValue, property_type: &ShaderPropertyType, ) -> bool { matches!( (value, property_type), (MaterialParameterValue::Bool(_), ShaderPropertyType::Bool) | ( MaterialParameterValue::Float(_), ShaderPropertyType::Float { .. } ) | (MaterialParameterValue::Vec2(_), ShaderPropertyType::Vec2) | (MaterialParameterValue::Vec3(_), ShaderPropertyType::Vec3) | (MaterialParameterValue::Color(_), ShaderPropertyType::Color) | ( MaterialParameterValue::Enum(_), ShaderPropertyType::Enum { .. } ) ) } fn property_type_for_value(value: &MaterialParameterValue) -> ShaderPropertyType { match value { MaterialParameterValue::Bool(_) => ShaderPropertyType::Bool, MaterialParameterValue::Float(_) => ShaderPropertyType::Float { min: None, max: None, }, MaterialParameterValue::Vec2(_) => ShaderPropertyType::Vec2, MaterialParameterValue::Vec3(_) => ShaderPropertyType::Vec3, MaterialParameterValue::Color(_) => ShaderPropertyType::Color, MaterialParameterValue::Enum(_) => ShaderPropertyType::Enum { options: Vec::new(), }, } } fn compact_text_edit(ui: &mut egui::Ui, label: &str, value: &mut String) { ui.horizontal(|ui| { ui.add_sized([92.0, 20.0], egui::Label::new(label)); ui.add_sized( [fit_width(ui, 100.0, f32::INFINITY), 22.0], egui::TextEdit::singleline(value), ); }); } fn shader_kind_picker(ui: &mut egui::Ui, kind: &mut MaterialShaderKind) { ui.horizontal(|ui| { ui.add_sized([92.0, 20.0], egui::Label::new("Shader")); egui::ComboBox::from_id_salt("asset_material_shader_kind") .selected_text(match kind { MaterialShaderKind::StandardLit => "Standard Lit", MaterialShaderKind::Unlit => "Unlit", MaterialShaderKind::Custom => "Custom", }) .show_ui(ui, |ui| { ui.selectable_value(kind, MaterialShaderKind::StandardLit, "Standard Lit"); ui.selectable_value(kind, MaterialShaderKind::Unlit, "Unlit"); ui.selectable_value(kind, MaterialShaderKind::Custom, "Custom"); }); }); } fn color_desc_edit(ui: &mut egui::Ui, label: &str, color: &mut ColorDesc) { ui.horizontal(|ui| { ui.add_sized([92.0, 20.0], egui::Label::new(label)); let mut rgba = [color.r, color.g, color.b, color.a]; if ui.color_edit_button_rgba_unmultiplied(&mut rgba).changed() { *color = ColorDesc { r: rgba[0], g: rgba[1], b: rgba[2], a: rgba[3], }; } }); } fn optional_path_edit(ui: &mut egui::Ui, label: &str, value: &mut Option) { ui.horizontal(|ui| { ui.add_sized([92.0, 20.0], egui::Label::new(label)); let mut text = value.clone().unwrap_or_default(); if ui .add_sized( [fit_width(ui, 80.0, f32::INFINITY), 22.0], egui::TextEdit::singleline(&mut text), ) .changed() { *value = if text.trim().is_empty() { None } else { Some(text) }; } if ui.button("Clear").clicked() { *value = None; } }); } fn texture_asset_from_subasset_selection( world: &World, selection: &Option, ) -> Option { let Some(AssetSelection::SubAsset { label, kind: AssetSubAssetKind::Texture, source_path: Some(path), .. }) = selection else { return None; }; if let Some(asset) = world .resource::() .assets .iter() .find(|asset| asset.path.as_deref() == Some(path.as_str())) { return Some(asset.clone()); } Some(EditorAsset { label: label.clone(), path: Some(path.clone()), folder_path: Path::new(path) .parent() .map(|parent| parent.to_string_lossy().replace('\\', "/")) .unwrap_or_else(|| ASSETS_ROOT.to_string()), kind: EditorAssetKind::Texture, }) } fn detail_row(ui: &mut egui::Ui, label: &str, value: &str) { ui.horizontal_wrapped(|ui| { ui.label(egui::RichText::new(label).color(TEXT_DIM)); ui.add(egui::Label::new(value).wrap().selectable(false)); }); } fn empty_content(ui: &mut egui::Ui, search: &str) { ui.add_space(24.0); ui.vertical_centered(|ui| { ui.label( egui::RichText::new(icons::FOLDER_OPEN.as_str()).font(egui::FontId::new( 28.0, egui::FontFamily::Name("phosphor-regular".into()), )), ); if search.trim().is_empty() { ui.label(egui::RichText::new("Folder is empty").color(TEXT_DIM)); } else { ui.label(egui::RichText::new("No matching assets").color(TEXT_DIM)); } }); } fn navigate_to_parent(world: &mut World) { let current = world.resource::().current_folder.clone(); if let Some(parent) = current.rfind('/').map(|index| current[..index].to_string()) { world.resource_mut::().current_folder = parent; } } fn asset_matches_kind_filter(asset: &EditorAsset, filter: AssetKindFilter) -> bool { match filter { AssetKindFilter::All => true, AssetKindFilter::Model => matches!(asset.kind, EditorAssetKind::Model), AssetKindFilter::Texture => matches!(asset.kind, EditorAssetKind::Texture), AssetKindFilter::Material => matches!(asset.kind, EditorAssetKind::Material), AssetKindFilter::Level => matches!(asset.kind, EditorAssetKind::Level), AssetKindFilter::Prefab => matches!(asset.kind, EditorAssetKind::Prefab), AssetKindFilter::Builtin => asset.path.is_none(), } } fn kind_filter_label(filter: AssetKindFilter) -> &'static str { match filter { AssetKindFilter::All => "All", AssetKindFilter::Model => "Models", AssetKindFilter::Texture => "Textures", AssetKindFilter::Material => "Materials", AssetKindFilter::Level => "Levels", AssetKindFilter::Prefab => "Prefabs", AssetKindFilter::Builtin => "Built-ins", } } fn sort_label(sort: AssetSort) -> &'static str { match sort { AssetSort::Name => "Name", AssetSort::Kind => "Type", AssetSort::Modified => "Modified", AssetSort::Size => "Size", } } fn kind_label(kind: &EditorAssetKind) -> &'static str { match kind { EditorAssetKind::Primitive(_) => "Primitive", EditorAssetKind::Light(_) => "Light", EditorAssetKind::Model => "Model", EditorAssetKind::Texture => "Texture", EditorAssetKind::Material => "Material", EditorAssetKind::Level => "Level", EditorAssetKind::Prefab => "Prefab", EditorAssetKind::PostProcessVolume => "Post Process Volume", EditorAssetKind::PostProcessEffect => "Post FX", EditorAssetKind::RenderingProfile => "Rendering Profile", EditorAssetKind::ShaderSchema => "Shader Schema", } } fn file_size(path: &str) -> Option { fs::metadata(path).ok().map(|metadata| metadata.len()) } fn modified_time(path: &str) -> Option { fs::metadata(path) .ok() .and_then(|metadata| metadata.modified().ok()) } fn format_bytes(bytes: u64) -> String { const KIB: f64 = 1024.0; const MIB: f64 = KIB * 1024.0; const GIB: f64 = MIB * 1024.0; let bytes = bytes as f64; if bytes >= GIB { format!("{:.1} GB", bytes / GIB) } else if bytes >= MIB { format!("{:.1} MB", bytes / MIB) } else if bytes >= KIB { format!("{:.1} KB", bytes / KIB) } else { format!("{bytes:.0} B") } } fn format_modified(time: SystemTime) -> String { let Ok(duration) = SystemTime::now().duration_since(time) else { return "Just now".to_string(); }; let days = duration.as_secs() / 86_400; if days == 0 { "Today".to_string() } else if days == 1 { "Yesterday".to_string() } else if days < 30 { format!("{days} days ago") } else if days < 365 { format!("{} months ago", days / 30) } else { format!("{} years ago", days / 365) } } fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering { a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase()) } fn asset_context_menu( world: &mut World, ui: &mut egui::Ui, asset: &EditorAsset, selected_entities: &SelectedEntities, ) { if ui.button("Select").clicked() { world .resource_mut::() .select(AssetSelection::from_asset(asset)); ui.close(); } if matches!( asset.kind, EditorAssetKind::Primitive(_) | EditorAssetKind::Light(_) | EditorAssetKind::Model | EditorAssetKind::Prefab | EditorAssetKind::Level ) && ui.button("Place at Origin").clicked() { spawn_asset_at(world, asset, Vec3::ZERO); ui.close(); } if matches!(asset.kind, EditorAssetKind::Model) && has_embedded_assets(world, asset) { let expanded = asset.path.as_deref().is_some_and(|path| { world .resource::() .expanded_assets .contains(path) }); if ui .button(if expanded { "Collapse Contents" } else { "Expand Contents" }) .clicked() { if let Some(path) = asset.path.as_ref() { toggle_asset_expanded(world, path); } ui.close(); } } if matches!(asset.kind, EditorAssetKind::Texture) && ui.button("Apply as Base Color Texture").clicked() { apply_texture_to_selection(world, asset, selected_entities); ui.close(); } if matches!(asset.kind, EditorAssetKind::Material) && ui.button("Apply Material To Selection").clicked() { apply_material_asset_to_selection(world, asset, selected_entities); ui.close(); } if matches!(asset.kind, EditorAssetKind::Model) && ui.button("Reimport").clicked() { reimport_asset(world, asset); ui.close(); } if matches!( asset.kind, EditorAssetKind::Texture | EditorAssetKind::Model | EditorAssetKind::Material ) && ui.button("Regenerate Thumbnail").clicked() { regenerate_asset_thumbnail(world, asset); ui.close(); } if asset.path.is_some() { ui.separator(); if ui.button("Move To Trash").clicked() { request_delete_for_asset(world, asset); ui.close(); } } } fn subasset_context_menu( world: &mut World, ui: &mut egui::Ui, embedded: &EmbeddedAsset, selected_entities: &SelectedEntities, ) { if ui.button("Select").clicked() { world .resource_mut::() .select(embedded.selection.clone()); ui.close(); } match embedded.kind { AssetSubAssetKind::Mesh => { if ui.button("Place at Origin").clicked() { spawn_subasset_at(world, &embedded.selection, Vec3::ZERO); ui.close(); } } AssetSubAssetKind::Texture => { if ui.button("Apply as Base Color Texture").clicked() { if let Some(asset) = texture_asset_from_subasset_selection(world, &Some(embedded.selection.clone())) { apply_texture_to_selection(world, &asset, selected_entities); } ui.close(); } } AssetSubAssetKind::Material => { ui.label(egui::RichText::new("Embedded source material").color(TEXT_DIM)); } } if ui.button("Regenerate Thumbnail").clicked() { regenerate_subasset_thumbnail(world, embedded); ui.close(); } if let AssetSelection::SubAsset { parent_path, .. } = &embedded.selection { if ui.button("Select Parent Asset").clicked() { world .resource_mut::() .select(AssetSelection::File(parent_path.clone())); ui.close(); } } } fn regenerate_asset_thumbnail(world: &mut World, asset: &EditorAsset) { let Some(path) = asset.path.clone() else { return; }; let key = asset_cache_key(asset); let kind = asset.kind.clone(); let asset_server = world.resource::().clone(); world.resource_scope(|world, mut cache: Mut| { cache.retry(&key); match kind { EditorAssetKind::Texture => { cache.request_texture(key.clone(), path.clone(), &asset_server); } EditorAssetKind::Model => { world.resource_scope(|_world, mut studio: Mut| { cache.request_model(key.clone(), path.clone(), &asset_server, &mut studio); }); } EditorAssetKind::Material => { world.resource_scope(|_world, mut studio: Mut| { cache.request_material_asset(key.clone(), path.clone(), &mut studio); }); } _ => {} } }); world.resource_mut::().status = format!("Regenerating thumbnail for {}", asset.label); } fn regenerate_subasset_thumbnail(world: &mut World, embedded: &EmbeddedAsset) { let AssetSelection::SubAsset { parent_path, .. } = &embedded.selection else { return; }; let key = embedded.thumbnail_key.clone(); let asset_server = world.resource::().clone(); world.resource_scope(|world, mut cache: Mut| { cache.retry(&key); match embedded.kind { AssetSubAssetKind::Mesh => { let Some(mesh_label) = embedded.mesh_label.clone() else { return; }; world.resource_scope(|_world, mut studio: Mut| { cache.request_mesh_subasset( key.clone(), parent_path.clone(), mesh_label, embedded.material_label.clone(), &mut studio, ); }); } AssetSubAssetKind::Material => { let Some(material_label) = embedded.material_label.clone() else { return; }; world.resource_scope(|_world, mut studio: Mut| { cache.request_source_material( key.clone(), parent_path.clone(), material_label, &mut studio, ); }); } AssetSubAssetKind::Texture => { if let Some(texture_path) = embedded.texture_path.clone() { cache.request_texture(key.clone(), texture_path, &asset_server); } } } }); world.resource_mut::().status = format!("Regenerating thumbnail for {}", embedded.label); } fn reimport_asset(world: &mut World, asset: &EditorAsset) { let Some(path) = asset.path.as_deref() else { return; }; let mut status = format!("Reimported {}", asset.label); if let Some(mut registry) = world.get_resource_mut::() { if let Some(record) = find_asset_mut_by_path(&mut registry, path) { if let Err(error) = refresh_static_mesh_artifact(record) { status = format!("Reimport failed for {}: {error}", asset.label); warn!("{status}"); } } if let Err(error) = save_registry(®istry) { status = format!("Asset registry save failed: {error}"); warn!("{status}"); } else { registry.index_dirty = false; } } invalidate_on_catalog_refresh(world); world.resource_mut::().status = status; } fn request_delete_for_asset(world: &mut World, asset: &EditorAsset) { if let Some(request) = build_delete_request(world, asset) { world.resource_mut::().pending_delete = Some(request); } } fn build_delete_request(world: &World, asset: &EditorAsset) -> Option { let path = asset.path.clone()?; let mut files = vec![path.clone()]; let mut warnings = Vec::new(); if matches!(asset.kind, EditorAssetKind::Model) { if let Some(record) = world .get_resource::() .and_then(|registry| find_asset_by_path(registry, &path)) { if let Some(manifest) = record.import_settings.static_mesh_manifest_path { files.push(manifest); } if !record.dependencies.is_empty() { warnings.push(format!( "{} imported dependencies will be kept because they may be shared.", record.dependencies.len() )); } } } warnings.push("Scene and prefab references are not rewritten automatically.".to_string()); dedup_strings(&mut files); Some(AssetDeleteRequest { selection: AssetSelection::from_asset(asset), label: asset.label.clone(), files, warnings, }) } fn draw_delete_modal(world: &mut World, ctx: &egui::Context) { let pending = world .resource::() .pending_delete .clone(); let Some(request) = pending else { return; }; let mut action = None; egui::Window::new("Move Asset To Trash") .collapsible(false) .resizable(false) .default_width(360.0) .show(ctx, |ui| { ui.label(format!("Move {} to assets/.trash?", request.label)); ui.separator(); ui.label(panel_heading("Files")); for file in &request.files { ui.add(egui::Label::new(file).wrap()); } if !request.warnings.is_empty() { ui.separator(); ui.label(panel_heading("Warnings")); for warning in &request.warnings { ui.small(egui::RichText::new(warning).color(TEXT_DIM)); } } ui.separator(); ui.horizontal(|ui| { if ui.button("Cancel").clicked() { action = Some(false); } if ui.button("Move To Trash").clicked() { action = Some(true); } }); }); match action { Some(false) => { world.resource_mut::().pending_delete = None; } Some(true) => { execute_delete_request(world, &request); world.resource_mut::().pending_delete = None; } None => {} } } fn execute_delete_request(world: &mut World, request: &AssetDeleteRequest) { let trash_root = PathBuf::from("assets/.trash").join(trash_timestamp()); let mut moved = 0usize; let mut errors = Vec::new(); for file in &request.files { let source = Path::new(file); if !source.exists() { continue; } match move_file_to_trash(source, &trash_root) { Ok(()) => moved += 1, Err(error) => errors.push(format!("{file}: {error}")), } } if !errors.is_empty() { world.resource_mut::().status = format!( "Could not move {} file(s) to trash: {}", errors.len(), errors.join("; ") ); return; } if let Some(parent_path) = request.selection.parent_path().map(str::to_string) { if let Some(mut registry) = world.get_resource_mut::() { registry.records.retain(|record| record.path != parent_path); registry.index_dirty = true; if let Err(error) = save_registry(®istry) { warn!("Asset registry save failed after delete: {error}"); } else { registry.index_dirty = false; } } } { let mut assets = world.resource_mut::(); if let Some(parent_path) = request.selection.parent_path() { if assets .selected .as_ref() .and_then(AssetSelection::parent_path) == Some(parent_path) { assets.selected = None; } } assets.refresh(); } invalidate_on_catalog_refresh(world); world.resource_mut::().status = format!( "Moved {} file(s) for {} to {}", moved, request.label, trash_root.to_string_lossy() ); } fn move_file_to_trash(source: &Path, trash_root: &Path) -> Result<(), String> { let target = trash_root.join(source); if let Some(parent) = target.parent() { fs::create_dir_all(parent) .map_err(|error| format!("could not create {}: {error}", parent.display()))?; } fs::rename(source, &target) .or_else(|_| { fs::copy(source, &target)?; fs::remove_file(source) }) .map_err(|error| format!("could not move to {}: {error}", target.display()))?; Ok(()) } fn trash_timestamp() -> String { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_secs().to_string()) .unwrap_or_else(|_| "now".to_string()) } fn dedup_strings(values: &mut Vec) { let mut seen = HashSet::new(); values.retain(|value| seen.insert(value.clone())); }