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, PrefabRef, Primitive, PrimitiveShape, RigidBodyDesc, AUDIO_CLIP_SUB_ASSET_ID, }; use walkdir::WalkDir; use crate::asset_db::{ ensure_asset_record, find_asset_by_id, find_asset_by_path, find_asset_mut_by_path, AssetRegistry, MaterialImportPolicy, ModelPlacementMode, }; use crate::assets::animation::load_animation_manifest; use crate::assets::static_mesh::{load_static_mesh_manifest, renderer_from_manifest}; use crate::history::{spawn_with_history, EditorEntitySnapshot}; pub const BUILTINS_FOLDER: &str = "__builtins__"; pub const ASSETS_ROOT: &str = "assets"; pub const AUDIO_ASSET_EXTENSIONS: &[&str] = &["ogg", "oga", "spx", "wav", "mp3", "flac"]; pub const IMPORTABLE_ASSET_EXTENSIONS: &[&str] = &[ "gltf", "glb", "fbx", "png", "jpg", "jpeg", "webp", "ktx2", "ogg", "oga", "spx", "wav", "mp3", "flac", ]; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum AssetSubAssetKind { Mesh, Material, Texture, Skeleton, AnimationClip, } #[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, AudioClip, 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::() .init_resource::() .add_observer(super::prefab_overrides::invalidate_prefab_health_on_ready) .add_systems( Update, ( refresh_assets_on_settings_change, super::prefab_overrides::guard_prefab_reimports, ), ); } } 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 normalized_path = normalize_asset_path(path); let (kinds, extensions) = if is_audio_asset_extension(&extension) { ( vec![EditorAssetKind::AudioClip], AUDIO_ASSET_EXTENSIONS.to_vec(), ) } else { 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 normalized_path.contains("/materials/") { ( vec![EditorAssetKind::Material], vec!["ron", "mat", "material"], ) } else if normalized_path.contains("/post_fx/") { (vec![EditorAssetKind::PostProcessEffect], vec!["ron"]) } else if normalized_path.contains("/rendering_profiles/") { (vec![EditorAssetKind::RenderingProfile], vec!["ron"]) } else if normalized_path.contains("/shaders/") { (vec![EditorAssetKind::ShaderSchema], vec!["ron"]) } else if normalized_path.ends_with(".scn.ron") && (normalized_path.starts_with("assets/prefabs/") || normalized_path.contains("/assets/prefabs/")) { (vec![EditorAssetKind::Prefab], vec!["ron"]) } else { (vec![EditorAssetKind::Level], vec!["ron"]) } } _ => return None, } }; if !has_extension(path, &extensions) { return None; } let path_string = normalized_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 = 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)) }) } fn is_audio_asset_extension(extension: &str) -> bool { AUDIO_ASSET_EXTENSIONS .iter() .any(|candidate| extension.eq_ignore_ascii_case(candidate)) } pub fn audio_clip_ref_for_asset( world: &mut World, asset: &EditorAsset, ) -> Result { if !matches!(asset.kind, EditorAssetKind::AudioClip) { return Err(format!("{} is not an audio clip", asset.label)); } let path = asset .path .as_deref() .ok_or_else(|| format!("audio clip {} has no source path", asset.label))?; let record = world .get_resource_mut::() .ok_or_else(|| "asset registry is unavailable".to_string()) .and_then(|mut registry| { ensure_asset_record( &mut registry, path.to_string(), asset.label.clone(), "AudioClip", ) })?; Ok(shared::EditorAssetRef::new( record.id.as_string(), AUDIO_CLIP_SUB_ASSET_ID, asset.label.clone(), ) .with_source_path(path)) } pub fn audio_source_desc_for_asset( world: &mut World, asset: &EditorAsset, ) -> Result { Ok(shared::AudioSourceDesc { clip: Some(audio_clip_ref_for_asset(world, asset)?), ..Default::default() }) } fn empty_snapshot_for_asset(asset: &EditorAsset, translation: Vec3) -> EditorEntitySnapshot { 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, animation_controller: None, audio_source: None, audio_listener: 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(), } } pub fn snapshot_for_asset(asset: &EditorAsset, translation: Vec3) -> Option { let mut snapshot = empty_snapshot_for_asset(asset, translation); 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 => { snapshot.actor_kind = ActorKind::PrefabAnchor; let path = asset.path.as_ref()?; snapshot.prefab = Some(PrefabRef::new(path.clone())); } EditorAssetKind::Level => return None, EditorAssetKind::Texture | EditorAssetKind::Material | EditorAssetKind::AudioClip | 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 = if matches!(asset.kind, EditorAssetKind::AudioClip) { let descriptor = match audio_source_desc_for_asset(world, asset) { Ok(descriptor) => descriptor, Err(error) => { warn!("Audio clip placement failed for {}: {error}", asset.label); if let Some(mut scene_io) = world.get_resource_mut::() { scene_io.status = format!("Audio placement failed: {error}"); } return None; } }; let mut snapshot = empty_snapshot_for_asset(asset, translation); snapshot.actor_kind = ActorKind::AudioSource; snapshot.audio_source = Some(descriptor); snapshot } else { 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 mut record = world .get_resource::() .and_then(|registry| find_asset_by_path(registry, path)); if matches!(asset.kind, EditorAssetKind::Prefab) && record.is_none() { record = world .get_resource_mut::() .and_then(|mut registry| { ensure_asset_record(&mut registry, path.clone(), asset.label.clone(), "Prefab") .map_err(|error| { error!("Could not register prefab asset {path}: {error}"); }) .ok() }); } if matches!(asset.kind, EditorAssetKind::Prefab) && record.is_none() { if let Some(mut scene_io) = world.get_resource_mut::() { scene_io.status = format!("Prefab placement failed: could not register {path}"); } return None; } if let Some(record) = record { match asset.kind { EditorAssetKind::Model => { if let Some(model) = snapshot.model.as_mut() { model.asset_id = record.id.as_string(); } 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(super::prefab_overrides::new_tracked_prefab_instance( world, record.id.as_string(), path.clone(), )); } _ => {} } } } Some(spawn_with_history(world, snapshot)) } pub fn validate_prefab_asset(world: &World, asset: &EditorAsset) -> Result<(), String> { if !matches!(asset.kind, EditorAssetKind::Prefab) { return Ok(()); } let source_path = asset .path .as_deref() .ok_or_else(|| "Prefab asset has no source path".to_string())?; let project_root = world .get_resource::() .map(|workspace| PathBuf::from(&workspace.root)) .ok_or_else(|| "Project workspace is unavailable".to_string())?; scene::validate_prefab_graph(&project_root.join(source_path), &project_root) .map_err(|error| format!("Prefab dependency validation failed: {error}")) } pub fn spawn_subasset_at( world: &mut World, selection: &AssetSelection, translation: Vec3, ) -> Option { if matches!( selection, AssetSelection::SubAsset { kind: AssetSubAssetKind::AnimationClip, .. } ) { return spawn_animation_clip_at(world, selection, translation); } 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, animation_controller: None, audio_source: None, audio_listener: 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)) } #[derive(Debug, Clone)] pub struct AnimationClipAuthoringData { pub model_asset_id: String, pub model_path: String, pub model_label: String, pub model_scale: f32, pub skeleton: shared::EditorAssetRef, pub skeleton_signature: shared::AnimationSkeletonSignature, pub state: shared::AnimationStateDesc, } pub fn animation_clip_authoring_data( world: &World, selection: &AssetSelection, ) -> Result { let AssetSelection::SubAsset { parent_path, sub_asset_id, kind: AssetSubAssetKind::AnimationClip, .. } = selection else { return Err("the selected sub-asset is not an animation clip".into()); }; let record = world .get_resource::() .and_then(|registry| find_asset_by_path(registry, parent_path)) .ok_or_else(|| format!("model `{parent_path}` is missing from the asset registry"))?; let manifest_path = record .import_settings .animation_manifest_path .as_deref() .ok_or_else(|| format!("model `{parent_path}` has no generated animation manifest"))?; let manifest = load_animation_manifest(manifest_path)?; if !manifest.runtime_supported { return Err(format!( "model `{parent_path}` is not supported by the animation runtime; animation v1 requires glTF/GLB" )); } let clip = manifest .clips .iter() .find(|clip| clip.id == *sub_asset_id) .ok_or_else(|| { format!( "animation clip `{sub_asset_id}` is missing from `{manifest_path}`; reimport the model" ) })?; let target_signature = clip.target_skeleton_signature.as_ref().ok_or_else(|| { format!( "animation clip `{}` has no uniquely resolved target skeleton", clip.label ) })?; let skeleton = manifest .skeletons .iter() .find(|skeleton| &skeleton.signature == target_signature) .ok_or_else(|| { format!( "animation clip `{}` targets skeleton `{}` but no exact match exists in `{parent_path}`", clip.label, target_signature.0 ) })?; let asset_id = record.id.as_string(); let skeleton_ref = shared::EditorAssetRef::new( asset_id.clone(), skeleton.id.clone(), skeleton.label.clone(), ) .with_source_path(parent_path); let clip_ref = shared::EditorAssetRef::new(asset_id, clip.id.clone(), clip.label.clone()) .with_source_path(parent_path); let state_id = animation_state_id(&clip.label, clip.source_index); Ok(AnimationClipAuthoringData { model_asset_id: record.id.as_string(), model_path: parent_path.clone(), model_label: record.label, model_scale: record.import_settings.scale, skeleton: skeleton_ref, skeleton_signature: skeleton.signature.clone(), state: shared::AnimationStateDesc { id: state_id, label: clip.label.clone(), clip: clip_ref, looping: true, speed: 1.0, range: shared::AnimationPlaybackRange::default(), }, }) } pub fn animation_skeleton_signature_for_ref( world: &World, reference: &shared::EditorAssetRef, ) -> Result { let record = world .get_resource::() .and_then(|registry| find_asset_by_id(registry, &reference.asset_id)) .ok_or_else(|| { format!( "skeleton asset `{}` is missing from the asset registry", reference.asset_id ) })?; let manifest_path = record .import_settings .animation_manifest_path .as_deref() .ok_or_else(|| format!("model `{}` has no animation manifest", record.path))?; let manifest = load_animation_manifest(manifest_path)?; manifest .skeletons .iter() .find(|skeleton| skeleton.id == reference.sub_asset_id) .map(|skeleton| skeleton.signature.clone()) .ok_or_else(|| { format!( "skeleton `{}` is missing from `{manifest_path}`; reimport the model", reference.sub_asset_id ) }) } pub fn compatible_animation_skeleton_for_model( world: &World, model: &ModelRef, signature: &shared::AnimationSkeletonSignature, ) -> Result { let record = world .get_resource::() .and_then(|registry| { if model.asset_id.trim().is_empty() { find_asset_by_path(registry, &model.path) } else { find_asset_by_id(registry, &model.asset_id) } }) .ok_or_else(|| { format!( "model `{}` ({}) is missing from the asset registry", model.path, model.asset_id ) })?; let model_path = record.path.as_str(); let manifest_path = record .import_settings .animation_manifest_path .as_deref() .ok_or_else(|| format!("model `{model_path}` has no animation manifest"))?; let manifest = load_animation_manifest(manifest_path)?; let skeleton = manifest .skeletons .iter() .find(|skeleton| &skeleton.signature == signature) .ok_or_else(|| { format!( "model `{model_path}` has no skeleton matching animation rig `{}`", signature.0 ) })?; Ok(shared::EditorAssetRef::new( record.id.as_string(), skeleton.id.clone(), skeleton.label.clone(), ) .with_source_path(model_path)) } fn animation_state_id(label: &str, source_index: usize) -> String { let mut id = String::new(); for character in label.chars() { if character.is_ascii_alphanumeric() { id.push(character.to_ascii_lowercase()); } else if !id.ends_with('_') { id.push('_'); } } let id = id.trim_matches('_'); if id.is_empty() { format!("clip_{source_index}") } else { id.to_string() } } fn spawn_animation_clip_at( world: &mut World, selection: &AssetSelection, translation: Vec3, ) -> Option { let authored = match animation_clip_authoring_data(world, selection) { Ok(authored) => authored, Err(error) => { warn!("Animation clip placement failed: {error}"); if let Some(mut scene_io) = world.get_resource_mut::() { scene_io.status = format!("Animation placement failed: {error}"); } return None; } }; let default_state = authored.state.id.clone(); let snapshot = EditorEntitySnapshot { actor_id: None, actor_kind: ActorKind::ImportedModel, actor_name: None, name: Some(authored.model_label), transform: Transform { translation: translation + Vec3::Y * 0.1, scale: Vec3::splat(authored.model_scale), ..default() }, primitive: None, brush: None, static_mesh_renderer: None, material: None, material_override: None, rigid_body: None, collider: None, physics: None, light: None, animation_controller: Some(shared::AnimationControllerDesc { skeleton: Some(authored.skeleton), states: vec![authored.state], default_state, ..default() }), audio_source: None, audio_listener: None, player_spawn: false, model: Some(ModelRef::new(authored.model_path).with_asset_id(authored.model_asset_id)), 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(), }; 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, animation_controller: None, audio_source: None, audio_listener: 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 super::refresh_model_artifacts(record) { Ok(manifest) => { renderer = Some(renderer_from_manifest(&manifest, &record.import_settings)); registry.index_dirty = true; } Err(error) => { warn!("Model artifact 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 { if is_audio_asset_extension(extension) { return "assets/audio"; } 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), } } #[cfg(test)] mod tests { use super::*; use crate::asset_db::{AssetId, AssetRecord}; use uuid::Uuid; #[test] fn scene_assets_are_classified_by_authoring_folder() { let level = asset_from_file_path(Path::new("assets/levels/arena.scn.ron")).unwrap(); let prefab = asset_from_file_path(Path::new("assets/prefabs/crate.scn.ron")).unwrap(); assert_eq!(level.kind, EditorAssetKind::Level); assert_eq!(prefab.kind, EditorAssetKind::Prefab); } #[test] fn bevy_audio_extensions_are_classified_and_imported_to_audio() { for extension in AUDIO_ASSET_EXTENSIONS { let path = format!("assets/audio/impact.{extension}"); let asset = asset_from_file_path(Path::new(&path)).unwrap(); assert_eq!(asset.kind, EditorAssetKind::AudioClip); assert_eq!(asset.folder_path, "assets/audio"); assert_eq!(import_dir_for_extension(extension), "assets/audio"); assert!(IMPORTABLE_ASSET_EXTENSIONS.contains(extension)); } assert!(asset_from_file_path(Path::new("assets/audio/impact.m4a")).is_none()); assert_eq!(import_dir_for_extension("WAV"), "assets/audio"); } #[test] fn audio_clip_reference_keeps_stable_registry_identity_and_runtime_path() { let asset = EditorAsset { label: "Impact".into(), path: Some("assets/audio/impact.ogg".into()), folder_path: "assets/audio".into(), kind: EditorAssetKind::AudioClip, }; let stable_id = AssetId(Uuid::nil()); let mut world = World::new(); world.insert_resource(AssetRegistry { records: vec![AssetRecord { id: stable_id.clone(), path: asset.path.clone().unwrap(), label: asset.label.clone(), kind_tag: "AudioClip".into(), import_settings: Default::default(), dependencies: Vec::new(), }], index_dirty: false, }); world.init_resource::(); world.init_resource::(); let descriptor = audio_source_desc_for_asset(&mut world, &asset).unwrap(); let reference = descriptor.clip.unwrap(); assert_eq!(reference.asset_id, stable_id.as_string()); assert_eq!(reference.sub_asset_id, AUDIO_CLIP_SUB_ASSET_ID); assert_eq!(reference.label, "Impact"); assert_eq!(reference.source_path.as_deref(), asset.path.as_deref()); assert_eq!( audio_clip_ref_for_asset(&mut world, &asset) .unwrap() .asset_id, reference.asset_id ); let entity = spawn_asset_at(&mut world, &asset, Vec3::new(1.0, 2.0, 3.0)).unwrap(); assert_eq!( world.get::(entity), Some(&ActorKind::AudioSource) ); let placed = world.get::(entity).unwrap(); assert_eq!(placed.clip.as_ref(), Some(&reference)); assert_eq!( world.get::(entity).unwrap().translation, Vec3::new(1.0, 2.0, 3.0) ); assert!(world.resource::().can_undo()); } }