Blacksite/crates/editor/src/assets/catalog.rs
Rbanh 9f95ba3082
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Add brush authoring schema
2026-06-06 05:42:19 -04:00

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