873 lines
28 KiB
Rust
873 lines
28 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, PrefabInstance, PrefabRef, Primitive,
|
|
PrimitiveShape, RigidBodyDesc,
|
|
};
|
|
use walkdir::WalkDir;
|
|
|
|
use crate::asset_db::{
|
|
find_asset_by_path, find_asset_mut_by_path, AssetRegistry, MaterialImportPolicy,
|
|
ModelPlacementMode,
|
|
};
|
|
use crate::assets::static_mesh::{
|
|
load_static_mesh_manifest, refresh_static_mesh_artifact, renderer_from_manifest,
|
|
};
|
|
use crate::history::{spawn_with_history, EditorEntitySnapshot};
|
|
|
|
pub const BUILTINS_FOLDER: &str = "__builtins__";
|
|
pub const ASSETS_ROOT: &str = "assets";
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum AssetSubAssetKind {
|
|
Mesh,
|
|
Material,
|
|
Texture,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
pub enum AssetSelection {
|
|
Builtin(String),
|
|
File(String),
|
|
SubAsset {
|
|
parent_path: String,
|
|
sub_asset_id: String,
|
|
label: String,
|
|
kind: AssetSubAssetKind,
|
|
source_path: Option<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,
|
|
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>()
|
|
.add_systems(Update, refresh_assets_on_settings_change);
|
|
}
|
|
}
|
|
|
|
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 (kinds, extensions) = match extension.as_str() {
|
|
"gltf" | "glb" | "fbx" => (vec![EditorAssetKind::Model], vec!["gltf", "glb", "fbx"]),
|
|
"png" | "jpg" | "jpeg" | "webp" | "ktx2" => (
|
|
vec![EditorAssetKind::Texture],
|
|
vec!["png", "jpg", "jpeg", "webp", "ktx2"],
|
|
),
|
|
"ron" | "mat" | "material" => {
|
|
if path.to_string_lossy().contains("/materials/") {
|
|
(
|
|
vec![EditorAssetKind::Material],
|
|
vec!["ron", "mat", "material"],
|
|
)
|
|
} else if path.to_string_lossy().contains("/post_fx/") {
|
|
(vec![EditorAssetKind::PostProcessEffect], vec!["ron"])
|
|
} else if path.to_string_lossy().contains("/rendering_profiles/") {
|
|
(vec![EditorAssetKind::RenderingProfile], vec!["ron"])
|
|
} else if path.to_string_lossy().contains("/shaders/") {
|
|
(vec![EditorAssetKind::ShaderSchema], vec!["ron"])
|
|
} else {
|
|
(
|
|
vec![EditorAssetKind::Level, EditorAssetKind::Prefab],
|
|
vec!["ron"],
|
|
)
|
|
}
|
|
}
|
|
_ => return None,
|
|
};
|
|
|
|
if !has_extension(path, &extensions) {
|
|
return None;
|
|
}
|
|
|
|
let path_string = normalize_asset_path(path);
|
|
let folder_path = parent_folder_path(&path_string).unwrap_or_else(|| ASSETS_ROOT.to_string());
|
|
let label = path
|
|
.file_stem()
|
|
.and_then(|stem| stem.to_str())
|
|
.unwrap_or(&path_string)
|
|
.to_string();
|
|
let kind = if kinds.len() == 2 && path_string.ends_with(".scn.ron") {
|
|
kinds[1].clone()
|
|
} else {
|
|
kinds[0].clone()
|
|
};
|
|
|
|
Some(EditorAsset {
|
|
label,
|
|
path: Some(path_string),
|
|
folder_path,
|
|
kind,
|
|
})
|
|
}
|
|
|
|
fn normalize_path(path: &str) -> String {
|
|
path.replace('\\', "/").trim_end_matches('/').to_string()
|
|
}
|
|
|
|
fn normalize_asset_path(path: &Path) -> String {
|
|
let raw = path.to_string_lossy().replace('\\', "/");
|
|
raw.strip_prefix("assets/")
|
|
.or_else(|| raw.strip_prefix("./assets/"))
|
|
.map(|rest| format!("assets/{rest}"))
|
|
.unwrap_or(raw)
|
|
}
|
|
|
|
/// Path relative to Bevy's `assets/` folder for [`AssetServer::load`].
|
|
pub fn asset_server_path(catalog_path: &str) -> String {
|
|
shared::asset_server_path(catalog_path)
|
|
}
|
|
|
|
fn parent_folder_path(path: &str) -> Option<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))
|
|
})
|
|
}
|
|
|
|
pub fn snapshot_for_asset(asset: &EditorAsset, translation: Vec3) -> Option<EditorEntitySnapshot> {
|
|
let mut snapshot = EditorEntitySnapshot {
|
|
actor_id: None,
|
|
actor_kind: ActorKind::Empty,
|
|
actor_name: None,
|
|
name: Some(asset.label.clone()),
|
|
transform: Transform::from_translation(translation),
|
|
primitive: None,
|
|
brush: None,
|
|
static_mesh_renderer: None,
|
|
material: None,
|
|
material_override: None,
|
|
rigid_body: None,
|
|
collider: None,
|
|
physics: None,
|
|
light: None,
|
|
player_spawn: false,
|
|
model: None,
|
|
prefab: None,
|
|
prefab_instance: None,
|
|
weapon_spawn: None,
|
|
trigger_volume: None,
|
|
post_process_volume: None,
|
|
team_spawn: None,
|
|
objective: None,
|
|
hierarchy_sibling_index: 0,
|
|
editor_visibility: EditorVisibility::default(),
|
|
children: Vec::new(),
|
|
};
|
|
|
|
match &asset.kind {
|
|
EditorAssetKind::Primitive(shape) => {
|
|
snapshot.actor_kind = ActorKind::StaticMesh;
|
|
let size = match shape {
|
|
PrimitiveShape::Box => Vec3::ONE,
|
|
PrimitiveShape::Sphere => Vec3::splat(1.5),
|
|
PrimitiveShape::Ramp => Vec3::new(4.0, 0.4, 5.0),
|
|
};
|
|
snapshot.primitive = Some(match shape {
|
|
PrimitiveShape::Box => Primitive::cuboid(size),
|
|
PrimitiveShape::Sphere => Primitive::sphere(size.x * 0.5),
|
|
PrimitiveShape::Ramp => Primitive::ramp(size),
|
|
});
|
|
snapshot.material = Some(default_material_for(shape));
|
|
snapshot.rigid_body = Some(RigidBodyDesc::default());
|
|
snapshot.collider = Some(match shape {
|
|
PrimitiveShape::Sphere => ColliderDesc::static_sphere(size.x * 0.5),
|
|
_ => ColliderDesc::static_cuboid(size),
|
|
});
|
|
if matches!(shape, PrimitiveShape::Ramp) {
|
|
snapshot.transform.rotation = Quat::from_rotation_x(-std::f32::consts::PI / 9.0);
|
|
snapshot.transform.translation.y += 1.0;
|
|
} else {
|
|
snapshot.transform.translation.y += size.y * 0.5;
|
|
}
|
|
}
|
|
EditorAssetKind::Light(kind) => {
|
|
snapshot.actor_kind = ActorKind::Light;
|
|
snapshot.light = Some(LightDesc::for_kind(*kind));
|
|
snapshot.transform.translation.y += 3.0;
|
|
}
|
|
EditorAssetKind::PostProcessVolume => {
|
|
snapshot.actor_kind = ActorKind::PostProcessVolume;
|
|
snapshot.post_process_volume = Some(PostProcessVolumeDesc {
|
|
half_extents: Vec3::new(4.0, 2.0, 4.0),
|
|
..Default::default()
|
|
});
|
|
snapshot.transform.translation.y += 2.0;
|
|
}
|
|
EditorAssetKind::Model => {
|
|
snapshot.actor_kind = ActorKind::ImportedModel;
|
|
let path = asset.path.as_ref()?;
|
|
snapshot.model = Some(ModelRef::new(path.clone()));
|
|
snapshot.transform.translation.y += 0.1;
|
|
}
|
|
EditorAssetKind::Prefab | EditorAssetKind::Level => {
|
|
snapshot.actor_kind = ActorKind::PrefabAnchor;
|
|
let path = asset.path.as_ref()?;
|
|
snapshot.prefab = Some(PrefabRef::new(path.clone()));
|
|
}
|
|
EditorAssetKind::Texture
|
|
| EditorAssetKind::Material
|
|
| EditorAssetKind::PostProcessEffect
|
|
| EditorAssetKind::RenderingProfile
|
|
| EditorAssetKind::ShaderSchema => return None,
|
|
}
|
|
|
|
Some(snapshot)
|
|
}
|
|
|
|
pub fn spawn_asset_at(world: &mut World, asset: &EditorAsset, translation: Vec3) -> Option<Entity> {
|
|
let mut snapshot = snapshot_for_asset(asset, translation)?;
|
|
if let Some(light) = snapshot.light.as_mut() {
|
|
if matches!(light.kind, AuthoringLightKind::Directional) {
|
|
light.intensity = world
|
|
.resource::<ProjectSettings>()
|
|
.rendering
|
|
.sun_illuminance;
|
|
}
|
|
}
|
|
if let Some(path) = asset.path.as_ref() {
|
|
let record = world
|
|
.get_resource::<AssetRegistry>()
|
|
.and_then(|registry| find_asset_by_path(registry, path));
|
|
if let Some(record) = record {
|
|
match asset.kind {
|
|
EditorAssetKind::Model => {
|
|
snapshot.transform.scale *= record.import_settings.scale;
|
|
if matches!(
|
|
record.import_settings.placement_mode,
|
|
ModelPlacementMode::StaticAsset
|
|
) {
|
|
if let Some(renderer) = static_mesh_renderer_for_asset(world, path) {
|
|
let collider = record
|
|
.import_settings
|
|
.generate_collider
|
|
.then(|| static_mesh_collider_for_renderer(&renderer));
|
|
apply_static_mesh_placement_mode(
|
|
&mut snapshot,
|
|
renderer,
|
|
collider,
|
|
record.import_settings.hierarchy_mode,
|
|
);
|
|
} else {
|
|
warn!(
|
|
"Static mesh placement fell back to SceneRoot for {} because no renderer manifest was available",
|
|
path
|
|
);
|
|
}
|
|
}
|
|
}
|
|
EditorAssetKind::Prefab => {
|
|
snapshot.prefab_instance =
|
|
Some(PrefabInstance::new(record.id.as_string(), path.clone()));
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
Some(spawn_with_history(world, snapshot))
|
|
}
|
|
|
|
pub fn spawn_subasset_at(
|
|
world: &mut World,
|
|
selection: &AssetSelection,
|
|
translation: Vec3,
|
|
) -> Option<Entity> {
|
|
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,
|
|
player_spawn: false,
|
|
model: None,
|
|
prefab: None,
|
|
prefab_instance: None,
|
|
weapon_spawn: None,
|
|
trigger_volume: None,
|
|
post_process_volume: None,
|
|
team_spawn: None,
|
|
objective: None,
|
|
hierarchy_sibling_index: 0,
|
|
editor_visibility: EditorVisibility::default(),
|
|
children: Vec::new(),
|
|
};
|
|
snapshot.transform.scale *= record.import_settings.scale;
|
|
if record.import_settings.generate_collider {
|
|
snapshot.rigid_body = Some(RigidBodyDesc::default());
|
|
snapshot.collider = Some(ColliderDesc::static_mesh(vec![mesh_ref]));
|
|
}
|
|
|
|
Some(spawn_with_history(world, snapshot))
|
|
}
|
|
|
|
fn apply_static_mesh_placement_mode(
|
|
snapshot: &mut EditorEntitySnapshot,
|
|
renderer: shared::StaticMeshRenderer,
|
|
collider: Option<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,
|
|
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 refresh_static_mesh_artifact(record) {
|
|
Ok(manifest) => {
|
|
renderer = Some(renderer_from_manifest(&manifest, &record.import_settings));
|
|
registry.index_dirty = true;
|
|
}
|
|
Err(error) => {
|
|
warn!("Static mesh manifest refresh failed for {path}: {error}");
|
|
manifest_path = record.import_settings.static_mesh_manifest_path.clone();
|
|
}
|
|
}
|
|
}
|
|
if renderer.is_some() {
|
|
if let Err(error) = crate::asset_db::save_registry(®istry) {
|
|
warn!("Asset registry save failed after static mesh refresh: {error}");
|
|
} else {
|
|
registry.index_dirty = false;
|
|
}
|
|
return renderer;
|
|
}
|
|
}
|
|
|
|
let manifest_path = manifest_path?;
|
|
let registry = world.get_resource::<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 {
|
|
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),
|
|
}
|
|
}
|