1352 lines
45 KiB
Rust
1352 lines
45 KiB
Rust
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<String>,
|
|
},
|
|
}
|
|
|
|
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<String>,
|
|
pub folder_path: String,
|
|
pub kind: EditorAssetKind,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct AssetFolder {
|
|
pub path: String,
|
|
pub name: String,
|
|
pub parent: Option<String>,
|
|
}
|
|
|
|
#[derive(Resource, Debug)]
|
|
pub struct EditorAssets {
|
|
pub folders: Vec<AssetFolder>,
|
|
pub assets: Vec<EditorAsset>,
|
|
pub current_folder: String,
|
|
pub selected: Option<AssetSelection>,
|
|
pub dragging: Option<AssetSelection>,
|
|
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::<EditorAssets>()
|
|
.init_resource::<super::prefab_overrides::PrefabHealthCache>()
|
|
.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<settings::ProjectSettings>,
|
|
mut assets: ResMut<EditorAssets>,
|
|
) {
|
|
if settings.is_changed() {
|
|
assets.refresh();
|
|
}
|
|
}
|
|
|
|
fn builtin_assets() -> Vec<EditorAsset> {
|
|
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<AssetFolder>, 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<EditorAsset>) {
|
|
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<EditorAsset> {
|
|
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<String> {
|
|
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<shared::EditorAssetRef, String> {
|
|
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::<AssetRegistry>()
|
|
.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<shared::AudioSourceDesc, String> {
|
|
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<EditorEntitySnapshot> {
|
|
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<Entity> {
|
|
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::<crate::scene_io::SceneIo>() {
|
|
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::<ProjectSettings>()
|
|
.rendering
|
|
.sun_illuminance;
|
|
}
|
|
}
|
|
if let Some(path) = asset.path.as_ref() {
|
|
let mut record = world
|
|
.get_resource::<AssetRegistry>()
|
|
.and_then(|registry| find_asset_by_path(registry, path));
|
|
if matches!(asset.kind, EditorAssetKind::Prefab) && record.is_none() {
|
|
record = world
|
|
.get_resource_mut::<AssetRegistry>()
|
|
.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::<crate::scene_io::SceneIo>() {
|
|
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::<crate::project_io::ProjectWorkspace>()
|
|
.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<Entity> {
|
|
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::<AssetRegistry>()
|
|
.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<AnimationClipAuthoringData, String> {
|
|
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::<AssetRegistry>()
|
|
.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<shared::AnimationSkeletonSignature, String> {
|
|
let record = world
|
|
.get_resource::<AssetRegistry>()
|
|
.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<shared::EditorAssetRef, String> {
|
|
let record = world
|
|
.get_resource::<AssetRegistry>()
|
|
.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<Entity> {
|
|
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::<crate::scene_io::SceneIo>() {
|
|
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<ColliderDesc>,
|
|
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<String> {
|
|
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<shared::StaticMeshRenderer> {
|
|
let mut manifest_path = None;
|
|
if let Some(mut registry) = world.get_resource_mut::<AssetRegistry>() {
|
|
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::<AssetRegistry>()?;
|
|
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<usize, String> {
|
|
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::<crate::history::EditorHistory>();
|
|
world.init_resource::<crate::selection::SelectedEntity>();
|
|
|
|
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::<ActorKind>(entity),
|
|
Some(&ActorKind::AudioSource)
|
|
);
|
|
let placed = world.get::<shared::AudioSourceDesc>(entity).unwrap();
|
|
assert_eq!(placed.clip.as_ref(), Some(&reference));
|
|
assert_eq!(
|
|
world.get::<Transform>(entity).unwrap().translation,
|
|
Vec3::new(1.0, 2.0, 3.0)
|
|
);
|
|
assert!(world.resource::<crate::history::EditorHistory>().can_undo());
|
|
}
|
|
}
|