use std::fs; use std::path::{Path, PathBuf}; use bevy::prelude::*; use settings::ProjectSettings; use shared::{ ActorKind, AuthoringLightKind, ColliderDesc, ColorDesc, EditorVisibility, LightDesc, MaterialDesc, ModelRef, PostProcessVolumeDesc, PrefabInstance, PrefabRef, Primitive, PrimitiveShape, RigidBodyDesc, }; use walkdir::WalkDir; use crate::asset_db::{ find_asset_by_path, find_asset_mut_by_path, AssetRegistry, MaterialImportPolicy, ModelPlacementMode, }; use crate::assets::static_mesh::{ load_static_mesh_manifest, refresh_static_mesh_artifact, renderer_from_manifest, }; use crate::history::{spawn_with_history, EditorEntitySnapshot}; pub const BUILTINS_FOLDER: &str = "__builtins__"; pub const ASSETS_ROOT: &str = "assets"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum AssetSubAssetKind { Mesh, Material, Texture, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum AssetSelection { Builtin(String), File(String), SubAsset { parent_path: String, sub_asset_id: String, label: String, kind: AssetSubAssetKind, source_path: Option, }, } impl AssetSelection { pub fn from_asset(asset: &EditorAsset) -> Self { match &asset.path { Some(path) => AssetSelection::File(path.clone()), None => AssetSelection::Builtin(asset.label.clone()), } } pub fn parent_path(&self) -> Option<&str> { match self { AssetSelection::Builtin(_) => None, AssetSelection::File(path) => Some(path), AssetSelection::SubAsset { parent_path, .. } => Some(parent_path), } } pub fn display_label(&self) -> &str { match self { AssetSelection::Builtin(label) | AssetSelection::File(label) => label, AssetSelection::SubAsset { label, .. } => label, } } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum EditorAssetKind { Primitive(PrimitiveShape), Light(AuthoringLightKind), Model, Texture, Material, Level, Prefab, PostProcessVolume, PostProcessEffect, RenderingProfile, ShaderSchema, } #[derive(Debug, Clone)] pub struct EditorAsset { pub label: String, pub path: Option, pub folder_path: String, pub kind: EditorAssetKind, } #[derive(Debug, Clone)] pub struct AssetFolder { pub path: String, pub name: String, pub parent: Option, } #[derive(Resource, Debug)] pub struct EditorAssets { pub folders: Vec, pub assets: Vec, pub current_folder: String, pub selected: Option, pub dragging: Option, pub status: String, } impl Default for EditorAssets { fn default() -> Self { let mut assets = Self { folders: Vec::new(), assets: Vec::new(), current_folder: ASSETS_ROOT.to_string(), selected: None, dragging: None, status: "Assets not scanned yet".to_string(), }; assets.refresh(); assets } } impl EditorAssets { pub fn refresh(&mut self) { self.folders.clear(); self.assets.clear(); self.folders.push(AssetFolder { path: BUILTINS_FOLDER.to_string(), name: "Built-ins".to_string(), parent: None, }); register_folder_tree(&mut self.folders, ASSETS_ROOT); self.assets.extend(builtin_assets()); scan_assets_directory(&mut self.assets); if !self.folder_exists(&self.current_folder) { self.current_folder = ASSETS_ROOT.to_string(); } self.selected = self .selected .take() .filter(|selection| self.asset_for_selection(selection).is_some()); let folder_count = self.folders.len(); self.status = format!( "Scanned {} assets in {} folders", self.assets.len(), folder_count ); } fn folder_exists(&self, path: &str) -> bool { self.folders.iter().any(|folder| folder.path == path) } pub fn asset_for_selection(&self, selection: &AssetSelection) -> Option<&EditorAsset> { match selection { AssetSelection::Builtin(label) => self .assets .iter() .find(|asset| asset.path.is_none() && asset.label == *label), AssetSelection::File(path) => self .assets .iter() .find(|asset| asset.path.as_deref() == Some(path.as_str())), AssetSelection::SubAsset { parent_path, .. } => self .assets .iter() .find(|asset| asset.path.as_deref() == Some(parent_path.as_str())), } } pub fn selected_asset(&self) -> Option<&EditorAsset> { self.selected .as_ref() .and_then(|selection| self.asset_for_selection(selection)) } pub fn dragging_asset(&self) -> Option<&EditorAsset> { self.dragging .as_ref() .and_then(|selection| self.asset_for_selection(selection)) } pub fn dragging_selection(&self) -> Option<&AssetSelection> { self.dragging.as_ref() } pub fn select(&mut self, selection: AssetSelection) { if let Some(asset) = self.asset_for_selection(&selection).cloned() { let selected_label = match &selection { AssetSelection::SubAsset { label, kind, .. } => { format!("{kind:?}: {label}") } _ => asset.label.clone(), }; self.selected = Some(selection); self.status = format!("Selected {selected_label}"); } } pub fn start_drag(&mut self, selection: AssetSelection) { if self.asset_for_selection(&selection).is_some() { self.dragging = Some(selection); } } pub fn clear_drag(&mut self) { self.dragging = None; } } pub struct EditorAssetsPlugin; impl Plugin for EditorAssetsPlugin { fn build(&self, app: &mut App) { app.init_resource::() .add_systems(Update, refresh_assets_on_settings_change); } } fn refresh_assets_on_settings_change( settings: Res, mut assets: ResMut, ) { if settings.is_changed() { assets.refresh(); } } fn builtin_assets() -> Vec { vec![ EditorAsset { label: "Cube".to_string(), path: None, folder_path: BUILTINS_FOLDER.to_string(), kind: EditorAssetKind::Primitive(PrimitiveShape::Box), }, EditorAsset { label: "Sphere".to_string(), path: None, folder_path: BUILTINS_FOLDER.to_string(), kind: EditorAssetKind::Primitive(PrimitiveShape::Sphere), }, EditorAsset { label: "Ramp".to_string(), path: None, folder_path: BUILTINS_FOLDER.to_string(), kind: EditorAssetKind::Primitive(PrimitiveShape::Ramp), }, EditorAsset { label: "Point Light".to_string(), path: None, folder_path: BUILTINS_FOLDER.to_string(), kind: EditorAssetKind::Light(AuthoringLightKind::Point), }, EditorAsset { label: "Spot Light".to_string(), path: None, folder_path: BUILTINS_FOLDER.to_string(), kind: EditorAssetKind::Light(AuthoringLightKind::Spot), }, EditorAsset { label: "Directional Light".to_string(), path: None, folder_path: BUILTINS_FOLDER.to_string(), kind: EditorAssetKind::Light(AuthoringLightKind::Directional), }, EditorAsset { label: "Post Process Volume".to_string(), path: None, folder_path: BUILTINS_FOLDER.to_string(), kind: EditorAssetKind::PostProcessVolume, }, ] } fn register_folder_tree(folders: &mut Vec, root: &str) { let normalized = normalize_path(root); if folders.iter().any(|folder| folder.path == normalized) { return; } let parent = parent_folder_path(&normalized); if let Some(ref parent_path) = parent { if !folders.iter().any(|folder| folder.path == *parent_path) { register_folder_tree(folders, parent_path); } } folders.push(AssetFolder { name: folder_name(&normalized), path: normalized.clone(), parent, }); if Path::new(&normalized).is_dir() { let Ok(read_dir) = fs::read_dir(&normalized) else { return; }; for entry in read_dir.flatten() { if entry.path().is_dir() { register_folder_tree(folders, &entry.path().to_string_lossy()); } } } } fn scan_assets_directory(assets: &mut Vec) { let root_path = Path::new(ASSETS_ROOT); if !root_path.exists() { return; } for entry in WalkDir::new(root_path) .follow_links(false) .into_iter() .filter_map(Result::ok) { if !entry.file_type().is_file() { continue; } let path = entry.path(); if should_skip_asset_path(path) { continue; } if let Some(asset) = asset_from_file_path(path) { assets.push(asset); } } } fn should_skip_asset_path(path: &Path) -> bool { if path .file_name() .and_then(|name| name.to_str()) .is_some_and(|name| name.starts_with('.')) { return true; } let normalized = normalize_asset_path(path); normalized == "assets/project.ron" || normalized.starts_with("assets/.index/") || normalized.starts_with("assets/.trash/") || normalized.starts_with(crate::assets::static_mesh::STATIC_MESH_ARTIFACT_DIR) } fn asset_from_file_path(path: &Path) -> Option { let extension = path.extension()?.to_str()?.to_ascii_lowercase(); let (kinds, extensions) = match extension.as_str() { "gltf" | "glb" | "fbx" => (vec![EditorAssetKind::Model], vec!["gltf", "glb", "fbx"]), "png" | "jpg" | "jpeg" | "webp" | "ktx2" => ( vec![EditorAssetKind::Texture], vec!["png", "jpg", "jpeg", "webp", "ktx2"], ), "ron" | "mat" | "material" => { if path.to_string_lossy().contains("/materials/") { ( vec![EditorAssetKind::Material], vec!["ron", "mat", "material"], ) } else if path.to_string_lossy().contains("/post_fx/") { (vec![EditorAssetKind::PostProcessEffect], vec!["ron"]) } else if path.to_string_lossy().contains("/rendering_profiles/") { (vec![EditorAssetKind::RenderingProfile], vec!["ron"]) } else if path.to_string_lossy().contains("/shaders/") { (vec![EditorAssetKind::ShaderSchema], vec!["ron"]) } else { ( vec![EditorAssetKind::Level, EditorAssetKind::Prefab], vec!["ron"], ) } } _ => return None, }; if !has_extension(path, &extensions) { return None; } let path_string = normalize_asset_path(path); let folder_path = parent_folder_path(&path_string).unwrap_or_else(|| ASSETS_ROOT.to_string()); let label = path .file_stem() .and_then(|stem| stem.to_str()) .unwrap_or(&path_string) .to_string(); let kind = if kinds.len() == 2 && path_string.ends_with(".scn.ron") { kinds[1].clone() } else { kinds[0].clone() }; Some(EditorAsset { label, path: Some(path_string), folder_path, kind, }) } fn normalize_path(path: &str) -> String { path.replace('\\', "/").trim_end_matches('/').to_string() } fn normalize_asset_path(path: &Path) -> String { let raw = path.to_string_lossy().replace('\\', "/"); raw.strip_prefix("assets/") .or_else(|| raw.strip_prefix("./assets/")) .map(|rest| format!("assets/{rest}")) .unwrap_or(raw) } /// Path relative to Bevy's `assets/` folder for [`AssetServer::load`]. pub fn asset_server_path(catalog_path: &str) -> String { shared::asset_server_path(catalog_path) } fn parent_folder_path(path: &str) -> Option { let normalized = normalize_path(path); normalized .rfind('/') .map(|index| normalized[..index].to_string()) } fn folder_name(path: &str) -> String { Path::new(path) .file_name() .and_then(|name| name.to_str()) .unwrap_or(path) .to_string() } fn has_extension(path: &Path, extensions: &[&str]) -> bool { path.extension() .and_then(|ext| ext.to_str()) .is_some_and(|ext| { extensions .iter() .any(|candidate| ext.eq_ignore_ascii_case(candidate)) }) } pub fn snapshot_for_asset(asset: &EditorAsset, translation: Vec3) -> Option { let mut snapshot = EditorEntitySnapshot { actor_id: None, actor_kind: ActorKind::Empty, actor_name: None, name: Some(asset.label.clone()), transform: Transform::from_translation(translation), primitive: None, brush: None, static_mesh_renderer: None, material: None, material_override: None, rigid_body: None, collider: None, physics: None, light: None, player_spawn: false, model: None, prefab: None, prefab_instance: None, weapon_spawn: None, trigger_volume: None, post_process_volume: None, team_spawn: None, objective: None, hierarchy_sibling_index: 0, editor_visibility: EditorVisibility::default(), children: Vec::new(), }; match &asset.kind { EditorAssetKind::Primitive(shape) => { snapshot.actor_kind = ActorKind::StaticMesh; let size = match shape { PrimitiveShape::Box => Vec3::ONE, PrimitiveShape::Sphere => Vec3::splat(1.5), PrimitiveShape::Ramp => Vec3::new(4.0, 0.4, 5.0), }; snapshot.primitive = Some(match shape { PrimitiveShape::Box => Primitive::cuboid(size), PrimitiveShape::Sphere => Primitive::sphere(size.x * 0.5), PrimitiveShape::Ramp => Primitive::ramp(size), }); snapshot.material = Some(default_material_for(shape)); snapshot.rigid_body = Some(RigidBodyDesc::default()); snapshot.collider = Some(match shape { PrimitiveShape::Sphere => ColliderDesc::static_sphere(size.x * 0.5), _ => ColliderDesc::static_cuboid(size), }); if matches!(shape, PrimitiveShape::Ramp) { snapshot.transform.rotation = Quat::from_rotation_x(-std::f32::consts::PI / 9.0); snapshot.transform.translation.y += 1.0; } else { snapshot.transform.translation.y += size.y * 0.5; } } EditorAssetKind::Light(kind) => { snapshot.actor_kind = ActorKind::Light; snapshot.light = Some(LightDesc::for_kind(*kind)); snapshot.transform.translation.y += 3.0; } EditorAssetKind::PostProcessVolume => { snapshot.actor_kind = ActorKind::PostProcessVolume; snapshot.post_process_volume = Some(PostProcessVolumeDesc { half_extents: Vec3::new(4.0, 2.0, 4.0), ..Default::default() }); snapshot.transform.translation.y += 2.0; } EditorAssetKind::Model => { snapshot.actor_kind = ActorKind::ImportedModel; let path = asset.path.as_ref()?; snapshot.model = Some(ModelRef::new(path.clone())); snapshot.transform.translation.y += 0.1; } EditorAssetKind::Prefab | EditorAssetKind::Level => { snapshot.actor_kind = ActorKind::PrefabAnchor; let path = asset.path.as_ref()?; snapshot.prefab = Some(PrefabRef::new(path.clone())); } EditorAssetKind::Texture | EditorAssetKind::Material | EditorAssetKind::PostProcessEffect | EditorAssetKind::RenderingProfile | EditorAssetKind::ShaderSchema => return None, } Some(snapshot) } pub fn spawn_asset_at(world: &mut World, asset: &EditorAsset, translation: Vec3) -> Option { let mut snapshot = snapshot_for_asset(asset, translation)?; if let Some(light) = snapshot.light.as_mut() { if matches!(light.kind, AuthoringLightKind::Directional) { light.intensity = world .resource::() .rendering .sun_illuminance; } } if let Some(path) = asset.path.as_ref() { let record = world .get_resource::() .and_then(|registry| find_asset_by_path(registry, path)); if let Some(record) = record { match asset.kind { EditorAssetKind::Model => { snapshot.transform.scale *= record.import_settings.scale; if matches!( record.import_settings.placement_mode, ModelPlacementMode::StaticAsset ) { if let Some(renderer) = static_mesh_renderer_for_asset(world, path) { let collider = record .import_settings .generate_collider .then(|| static_mesh_collider_for_renderer(&renderer)); apply_static_mesh_placement_mode( &mut snapshot, renderer, collider, record.import_settings.hierarchy_mode, ); } else { warn!( "Static mesh placement fell back to SceneRoot for {} because no renderer manifest was available", path ); } } } EditorAssetKind::Prefab => { snapshot.prefab_instance = Some(PrefabInstance::new(record.id.as_string(), path.clone())); } _ => {} } } } Some(spawn_with_history(world, snapshot)) } pub fn spawn_subasset_at( world: &mut World, selection: &AssetSelection, translation: Vec3, ) -> Option { let AssetSelection::SubAsset { parent_path, sub_asset_id, label, kind: AssetSubAssetKind::Mesh, .. } = selection else { return None; }; let record = world .get_resource::() .and_then(|registry| find_asset_by_path(registry, parent_path))?; let manifest_path = record .import_settings .static_mesh_manifest_path .as_deref()?; let manifest = load_static_mesh_manifest(manifest_path).ok()?; let part = manifest .parts .iter() .find(|part| part_effective_id_for_selection(part) == *sub_asset_id)?; let mesh_ref = shared::EditorAssetRef::new( manifest.asset_id.clone(), sub_asset_id.clone(), label.clone(), ); let material = matches!( record.import_settings.material_policy, MaterialImportPolicy::SourceMaterials ) .then(|| { part_effective_material_id_for_selection(part).map(|id| { shared::EditorAssetRef::new( manifest.asset_id.clone(), id, part.material_slot_name.clone(), ) }) }) .flatten(); let slot = shared::StaticMeshRendererEntry { id: shared::ComponentInstanceId::new("slot:0"), name: label.clone(), mesh: mesh_ref.clone(), material, local_transform: part.local_transform, visible: true, cast_shadows: true, receive_shadows: true, }; let mut snapshot = EditorEntitySnapshot { actor_id: None, actor_kind: ActorKind::StaticMesh, actor_name: None, name: Some(label.clone()), transform: Transform::from_translation(translation + Vec3::Y * 0.1), primitive: None, brush: None, static_mesh_renderer: Some(shared::StaticMeshRenderer::single(slot)), material: None, material_override: None, rigid_body: None, collider: None, physics: None, light: None, player_spawn: false, model: None, prefab: None, prefab_instance: None, weapon_spawn: None, trigger_volume: None, post_process_volume: None, team_spawn: None, objective: None, hierarchy_sibling_index: 0, editor_visibility: EditorVisibility::default(), children: Vec::new(), }; snapshot.transform.scale *= record.import_settings.scale; if record.import_settings.generate_collider { snapshot.rigid_body = Some(RigidBodyDesc::default()); snapshot.collider = Some(ColliderDesc::static_mesh(vec![mesh_ref])); } Some(spawn_with_history(world, snapshot)) } fn apply_static_mesh_placement_mode( snapshot: &mut EditorEntitySnapshot, renderer: shared::StaticMeshRenderer, collider: Option, hierarchy_mode: crate::asset_db::ModelHierarchyMode, ) { snapshot.model = None; match hierarchy_mode { crate::asset_db::ModelHierarchyMode::SingleActor => { snapshot.actor_kind = ActorKind::StaticMesh; snapshot.static_mesh_renderer = Some(renderer); if let Some(collider) = collider { snapshot.rigid_body = Some(RigidBodyDesc::default()); snapshot.collider = Some(collider); } } crate::asset_db::ModelHierarchyMode::SourceHierarchy => { snapshot.actor_kind = ActorKind::Empty; snapshot.static_mesh_renderer = None; snapshot.children = renderer .slots .into_iter() .enumerate() .map(|(index, mut entry)| { let transform = entry.local_transform; let collider = collider .as_ref() .map(|_| ColliderDesc::static_mesh(vec![entry.mesh.clone()])); entry.local_transform = Transform::default(); EditorEntitySnapshot { actor_id: None, actor_kind: ActorKind::StaticMesh, actor_name: None, name: Some(if entry.name.trim().is_empty() { format!("Mesh Part {index}") } else { entry.name.clone() }), transform, primitive: None, brush: None, static_mesh_renderer: Some(shared::StaticMeshRenderer::single(entry)), material: None, material_override: None, rigid_body: collider.as_ref().map(|_| RigidBodyDesc::default()), collider, physics: None, light: None, player_spawn: false, model: None, prefab: None, prefab_instance: None, weapon_spawn: None, trigger_volume: None, post_process_volume: None, team_spawn: None, objective: None, hierarchy_sibling_index: index as i32, editor_visibility: EditorVisibility::default(), children: Vec::new(), } }) .collect(); } } } fn static_mesh_collider_for_renderer(renderer: &shared::StaticMeshRenderer) -> ColliderDesc { ColliderDesc::static_mesh( renderer .slots .iter() .map(|slot| slot.mesh.clone()) .collect(), ) } fn part_effective_id_for_selection(part: &crate::assets::static_mesh::StaticMeshPart) -> String { if part.id.trim().is_empty() { crate::assets::static_mesh::part_id_from_label(&part.mesh_label) } else { part.id.clone() } } fn part_effective_material_id_for_selection( part: &crate::assets::static_mesh::StaticMeshPart, ) -> Option { part.material_id.clone().or_else(|| { part.material_label .as_ref() .map(|label| crate::assets::static_mesh::material_id_from_label(label)) }) } fn static_mesh_renderer_for_asset( world: &mut World, path: &str, ) -> Option { let mut manifest_path = None; if let Some(mut registry) = world.get_resource_mut::() { let mut renderer = None; if let Some(record) = find_asset_mut_by_path(&mut registry, path) { match refresh_static_mesh_artifact(record) { Ok(manifest) => { renderer = Some(renderer_from_manifest(&manifest, &record.import_settings)); registry.index_dirty = true; } Err(error) => { warn!("Static mesh manifest refresh failed for {path}: {error}"); manifest_path = record.import_settings.static_mesh_manifest_path.clone(); } } } if renderer.is_some() { if let Err(error) = crate::asset_db::save_registry(®istry) { warn!("Asset registry save failed after static mesh refresh: {error}"); } else { registry.index_dirty = false; } return renderer; } } let manifest_path = manifest_path?; let registry = world.get_resource::()?; let record = find_asset_by_path(registry, path)?; let manifest = load_static_mesh_manifest(&manifest_path).ok()?; Some(renderer_from_manifest(&manifest, &record.import_settings)) } pub fn import_external_assets(paths: &[PathBuf]) -> Result { let mut copied = 0; for source in paths { let Some(file_name) = source.file_name() else { continue; }; let Some(extension) = source.extension().and_then(|ext| ext.to_str()) else { continue; }; let dest_dir = import_dir_for_extension(extension); if extension.eq_ignore_ascii_case("fbx") { super::import::copy_fbx_with_sidecar(source, Path::new(dest_dir))?; copied += 1; continue; } fs::create_dir_all(dest_dir) .map_err(|err| format!("could not create {dest_dir}: {err}"))?; let dest = Path::new(dest_dir).join(file_name); fs::copy(source, &dest).map_err(|err| { format!( "could not copy {} to {}: {err}", source.display(), dest.display() ) })?; copied += 1; } Ok(copied) } fn import_dir_for_extension(extension: &str) -> &'static str { match extension.to_ascii_lowercase().as_str() { "gltf" | "glb" | "fbx" => "assets/models", "png" | "jpg" | "jpeg" | "webp" | "ktx2" => "assets/textures", "ron" => "assets/levels", "mat" | "material" => "assets/materials", _ => "assets", } } fn default_material_for(shape: &PrimitiveShape) -> MaterialDesc { match shape { PrimitiveShape::Box => MaterialDesc::default(), PrimitiveShape::Sphere => MaterialDesc::new(ColorDesc::srgb(0.9, 0.9, 0.92), 1.0, 0.25), PrimitiveShape::Ramp => MaterialDesc::new(ColorDesc::srgb(0.30, 0.45, 0.70), 0.05, 0.55), } } pub fn asset_cache_key(asset: &EditorAsset) -> String { match &asset.path { Some(path) => path.clone(), None => format!("builtin:{}", asset.label), } }