Blacksite/crates/editor/src/assets/catalog.rs
Rbanh 0553a85220
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Build production-ready editor authoring workflows
2026-07-11 12:41:04 -04:00

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(&registry) {
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());
}
}