Blacksite/crates/editor/src/ui/asset_browser/panel.rs
Rbanh 3e053a81ab
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Expand asset browser subasset workflow
2026-06-06 01:38:27 -04:00

3184 lines
109 KiB
Rust

//! 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<String>,
}
#[derive(Clone)]
struct AssetRow {
selection: AssetSelection,
asset: EditorAsset,
file_size: Option<u64>,
modified: Option<SystemTime>,
}
#[derive(Clone)]
struct EmbeddedAsset {
selection: AssetSelection,
label: String,
kind: AssetSubAssetKind,
detail: String,
texture_path: Option<String>,
thumbnail_key: String,
mesh_label: Option<String>,
material_label: Option<String>,
}
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<R>(
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::<EditorAssets>();
let cache = world.resource::<AssetThumbnailCache>();
let state = world.resource::<AssetBrowserUiState>();
let folders: Vec<FolderSnapshot> = assets
.folders
.iter()
.map(|folder| FolderSnapshot {
path: folder.path.clone(),
name: folder.name.clone(),
parent: folder.parent.clone(),
})
.collect();
let asset_rows: Vec<AssetRow> = 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, &current_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,
&current_folder,
);
}
});
},
);
ui.separator();
}
exact_region(
ui,
egui::vec2(content_width, body_height),
egui::Layout::top_down(egui::Align::Min),
|ui| {
content_header(world, ui, &current_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,
&current_folder,
&state_snapshot.search,
);
let visible_assets =
visible_assets(&assets, &current_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<EmbeddedAsset> {
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::<crate::asset_db::AssetRegistry>()
.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<EmbeddedAsset> {
let AssetSelection::SubAsset { parent_path, .. } = selection else {
return None;
};
let asset = world
.resource::<EditorAssets>()
.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<String> {
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::<EditorAssets>().refresh();
invalidate_on_catalog_refresh(world);
}
if icon_button_small(ui, icons::UPLOAD, "Import assets").clicked() {
world.resource_mut::<SceneIo>().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::<AssetBrowserUiState>();
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::<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.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::<EditorAssets>().current_folder = path.clone();
}
}
}
fn selection_footer(world: &mut World, ui: &mut egui::Ui, selected_entities: &SelectedEntities) {
let selection = world.resource::<EditorAssets>().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::<EditorAssets>().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::<AssetBrowserUiState>()
.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::<AssetBrowserUiState>();
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::<EditorAssets>().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<FolderSnapshot> {
if !search.trim().is_empty() {
return Vec::new();
}
let mut children: Vec<FolderSnapshot> = 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<AssetRow> {
let search = state.search.trim().to_ascii_lowercase();
let mut rows: Vec<AssetRow> = 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<AssetSelection>,
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::<EditorAssets>().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::<AssetBrowserUiState>()
.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<AssetSelection>,
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::<AssetBrowserUiState>()
.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::<crate::assets::AssetThumbnailCache>()
.retry(&key);
}
if response.clicked() {
world
.resource_mut::<EditorAssets>()
.select(selection.clone());
}
if response.drag_started() {
world
.resource_mut::<EditorAssets>()
.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<AssetSelection>,
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::<AssetServer>().clone();
let requests: Vec<EmbeddedAsset> = embedded.to_vec();
world.resource_scope(|world, mut cache: Mut<AssetThumbnailCache>| {
world.resource_scope(|_world, mut studio: Mut<crate::assets::ThumbnailStudio>| {
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<AssetSelection>,
embedded: &EmbeddedAsset,
cache_snapshot: &ThumbnailCacheSnapshot,
_parent_texture: Option<egui::TextureId>,
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::<EditorAssets>()
.select(embedded.selection.clone());
}
if response.drag_started() {
world
.resource_mut::<EditorAssets>()
.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<egui::TextureId> {
let assets = world.resource::<EditorAssets>();
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::<AssetBrowserUiState>();
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<AssetSelection>,
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::<EditorAssets>().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::<AssetBrowserUiState>()
.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<AssetSelection>,
row: &AssetRow,
) {
let has_children = has_embedded_assets(world, &row.asset);
let expanded = row.asset.path.as_deref().is_some_and(|path| {
world
.resource::<AssetBrowserUiState>()
.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::<EditorAssets>()
.select(row.selection.clone());
}
if response.drag_started() {
world
.resource_mut::<EditorAssets>()
.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<AssetSelection>,
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::<EditorAssets>().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::<AssetBrowserUiState>()
.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::<EditorAssets>()
.select(row.selection.clone());
}
if response.drag_started() {
world
.resource_mut::<EditorAssets>()
.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::<EditorAssets>().selected.clone();
if let Some(selection @ AssetSelection::SubAsset { .. }) = selection {
subasset_details_panel(world, ui, selected_entities, &selection);
} else if let Some(asset) = world.resource::<EditorAssets>().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::<crate::asset_db::AssetRegistry>()
.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::<EditorAssets>()
.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::<AssetBrowserUiState>();
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::<AssetBrowserUiState>();
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::<AssetBrowserUiState>().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::<crate::asset_db::AssetRegistry>() {
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(&registry) {
status = format!("Asset registry save failed: {error}");
warn!("{status}");
} else {
registry.index_dirty = false;
}
}
invalidate_on_catalog_refresh(world);
world.resource_mut::<SceneIo>().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::<AssetBrowserUiState>();
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::<AssetBrowserUiState>().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::<AssetBrowserUiState>()
.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::<AssetBrowserUiState>().material_draft = Some(draft);
}
fn save_material_draft(world: &mut World) {
let Some(draft) = world
.resource::<AssetBrowserUiState>()
.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::<AssetBrowserUiState>()
.material_draft
.as_mut()
{
current.error = None;
}
world.resource_mut::<EditorAssets>().refresh();
invalidate_on_catalog_refresh(world);
world.resource_mut::<SceneIo>().status = format!("Saved material {}", draft.path);
}
Err(error) => {
if let Some(current) = world
.resource_mut::<AssetBrowserUiState>()
.material_draft
.as_mut()
{
current.error = Some(error.clone());
}
world.resource_mut::<SceneIo>().status = error;
}
}
}
fn shader_schema_assets(world: &World) -> Vec<(String, String)> {
let mut schemas: Vec<(String, String)> = world
.resource::<EditorAssets>()
.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::<crate::asset_db::AssetRegistry>();
let mut textures: Vec<(String, EditorAssetRef)> = world
.resource::<EditorAssets>()
.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(&parameter.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<MaterialParameter>,
) {
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<MaterialTextureBinding>,
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(&parameter.value);
material_parameter_value_ui(ui, &parameter.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<MaterialParameter>,
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(&parameters[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<MaterialTextureBinding>,
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<String>) {
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<AssetSelection>,
) -> Option<EditorAsset> {
let Some(AssetSelection::SubAsset {
label,
kind: AssetSubAssetKind::Texture,
source_path: Some(path),
..
}) = selection
else {
return None;
};
if let Some(asset) = world
.resource::<EditorAssets>()
.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::<EditorAssets>().current_folder.clone();
if let Some(parent) = current.rfind('/').map(|index| current[..index].to_string()) {
world.resource_mut::<EditorAssets>().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<u64> {
fs::metadata(path).ok().map(|metadata| metadata.len())
}
fn modified_time(path: &str) -> Option<SystemTime> {
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::<EditorAssets>()
.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::<AssetBrowserUiState>()
.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::<EditorAssets>()
.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::<EditorAssets>()
.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::<AssetServer>().clone();
world.resource_scope(|world, mut cache: Mut<AssetThumbnailCache>| {
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<ThumbnailStudio>| {
cache.request_model(key.clone(), path.clone(), &asset_server, &mut studio);
});
}
EditorAssetKind::Material => {
world.resource_scope(|_world, mut studio: Mut<ThumbnailStudio>| {
cache.request_material_asset(key.clone(), path.clone(), &mut studio);
});
}
_ => {}
}
});
world.resource_mut::<SceneIo>().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::<AssetServer>().clone();
world.resource_scope(|world, mut cache: Mut<AssetThumbnailCache>| {
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<ThumbnailStudio>| {
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<ThumbnailStudio>| {
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::<SceneIo>().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::<crate::asset_db::AssetRegistry>() {
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(&registry) {
status = format!("Asset registry save failed: {error}");
warn!("{status}");
} else {
registry.index_dirty = false;
}
}
invalidate_on_catalog_refresh(world);
world.resource_mut::<SceneIo>().status = status;
}
fn request_delete_for_asset(world: &mut World, asset: &EditorAsset) {
if let Some(request) = build_delete_request(world, asset) {
world.resource_mut::<AssetBrowserUiState>().pending_delete = Some(request);
}
}
fn build_delete_request(world: &World, asset: &EditorAsset) -> Option<AssetDeleteRequest> {
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::<crate::asset_db::AssetRegistry>()
.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::<AssetBrowserUiState>()
.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::<AssetBrowserUiState>().pending_delete = None;
}
Some(true) => {
execute_delete_request(world, &request);
world.resource_mut::<AssetBrowserUiState>().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::<SceneIo>().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::<crate::asset_db::AssetRegistry>() {
registry.records.retain(|record| record.path != parent_path);
registry.index_dirty = true;
if let Err(error) = save_registry(&registry) {
warn!("Asset registry save failed after delete: {error}");
} else {
registry.index_dirty = false;
}
}
}
{
let mut assets = world.resource_mut::<EditorAssets>();
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::<SceneIo>().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<String>) {
let mut seen = HashSet::new();
values.retain(|value| seen.insert(value.clone()));
}