Blacksite/crates/editor/src/assets/materials.rs
Rbanh 0798aa5d57 Build renderer and material component foundations
Add dedicated skinned rendering, pose restoration, shared Material and Material Instance slots, registry-driven components, Surface/Solari integration, transactional schema upgrades, navigation authoring, documentation, and evaluation evidence.
2026-07-12 00:24:06 -04:00

185 lines
6.8 KiB
Rust

//! Material asset loading and inspector/browser integration.
use bevy::prelude::*;
use bevy_egui::egui;
use shared::{
ComponentInstanceId, LevelObject, MaterialAsset, MaterialDesc, MaterialRef,
RendererMaterialSlot,
};
use crate::assets::{EditorAsset, EditorAssetKind, EditorAssets};
use crate::history::{reflected_component_transaction, set_material_group_with_history};
use crate::ui::inspector::property_row;
pub fn load_material_asset(catalog_path: &str) -> Result<MaterialAsset, String> {
MaterialAsset::load_from_path(catalog_path)
}
pub fn material_desc_from_asset(catalog_path: &str) -> Result<MaterialDesc, String> {
let asset = load_material_asset(catalog_path)?;
let mut desc = asset.material;
desc.material_asset_path = Some(catalog_path.to_string());
Ok(desc)
}
pub fn apply_material_asset_to_selection(
world: &mut World,
asset: &EditorAsset,
selected: &bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities,
) -> Result<(), String> {
let path = asset
.path
.clone()
.ok_or_else(|| format!("Material has no source path: {}", asset.label))?;
let record = world
.get_resource::<crate::asset_db::AssetRegistry>()
.and_then(|registry| crate::asset_db::find_asset_by_path(registry, &path))
.ok_or_else(|| format!("Material is missing from the asset registry: {path}"))?;
let sub_asset_id = if shared::MaterialInstanceAsset::load_from_path(&path).is_ok() {
"material:instance"
} else {
"material:source"
};
let material_ref = MaterialRef::new(
shared::EditorAssetRef::new(record.id.as_string(), sub_asset_id, asset.label.clone())
.with_source_path(path.clone()),
);
let desc = material_desc_from_asset(&path).ok();
let entities = crate::ui::helpers::selected_level_entities(world, selected);
let mut renderer_changes = 0usize;
let mut legacy_changes = Vec::new();
for entity in entities {
if world.get::<LevelObject>(entity).is_none() {
continue;
}
if let Some(mut renderer) = world.get::<shared::StaticMeshRenderer>(entity).cloned() {
ensure_static_material_slots(&mut renderer);
for slot in &mut renderer.materials.slots {
slot.material = Some(material_ref.clone());
}
reflected_component_transaction(
world,
entity,
"Assign Material Slots",
shared::AUTHORING_COMPONENT_STATIC_MESH_RENDERER,
shared::COMPONENT_STATIC_MESH_RENDERER,
move |world, entity| {
world.entity_mut(entity).insert(renderer);
Ok(())
},
)?;
renderer_changes += 1;
continue;
}
if let Some(mut renderer) = world.get::<shared::SkinnedMeshRenderer>(entity).cloned() {
if renderer.materials.slots.is_empty() {
return Err(format!(
"Skinned renderer `{}` has no imported material slots; reimport its model",
renderer.path
));
}
for slot in &mut renderer.materials.slots {
slot.material = Some(material_ref.clone());
}
reflected_component_transaction(
world,
entity,
"Assign Material Slots",
shared::AUTHORING_COMPONENT_SKINNED_MESH_RENDERER,
shared::COMPONENT_SKINNED_MESH_RENDERER,
move |world, entity| {
world.entity_mut(entity).insert(renderer);
Ok(())
},
)?;
renderer_changes += 1;
continue;
}
if let Some(desc) = desc.as_ref() {
legacy_changes.push((entity, desc.clone()));
}
}
if !legacy_changes.is_empty() {
set_material_group_with_history(world, legacy_changes);
}
if renderer_changes == 0 && desc.is_none() {
return Err("No selected renderable actor accepts this material".into());
}
world.resource_mut::<crate::scene_io::SceneIo>().status =
format!("Applied shared material {}", asset.label);
Ok(())
}
fn ensure_static_material_slots(renderer: &mut shared::StaticMeshRenderer) {
for part in &mut renderer.slots {
if part.material_slot_id.0.trim().is_empty() {
part.material_slot_id = if part.id.0.trim().is_empty() {
ComponentInstanceId::new("slot:legacy")
} else {
ComponentInstanceId::new(format!("slot:{}", part.id.0))
};
}
if renderer.materials.slot(&part.material_slot_id).is_none() {
renderer.materials.slots.push(RendererMaterialSlot {
id: part.material_slot_id.clone(),
name: part.name.clone(),
source_material: part.material.clone().map(MaterialRef::new),
material: None,
});
}
}
}
pub fn material_asset_picker_ui(
world: &mut World,
ui: &mut egui::Ui,
_entity: Entity,
material: &mut MaterialDesc,
_original: &MaterialDesc,
) -> bool {
let assets: Vec<(String, String)> = world
.resource::<EditorAssets>()
.assets
.iter()
.filter(|a| matches!(a.kind, EditorAssetKind::Material))
.filter_map(|a| Some((a.label.clone(), a.path.clone()?)))
.collect();
let current = material.material_asset_path.clone().unwrap_or_default();
let mut selected_label = assets
.iter()
.find(|(_, path)| path == &current)
.map(|(label, _)| label.clone())
.unwrap_or_else(|| "(none)".to_string());
let mut changed = false;
property_row(ui, "Material asset", |ui| {
egui::ComboBox::from_id_salt("material_asset_picker")
.selected_text(&selected_label)
.show_ui(ui, |ui| {
if ui.selectable_label(current.is_empty(), "(none)").clicked() {
material.material_asset_path = None;
selected_label = "(none)".into();
changed = true;
}
for (label, path) in &assets {
if ui
.selectable_label(&selected_label == label, label)
.clicked()
{
if let Ok(desc) = material_desc_from_asset(path) {
*material = desc;
selected_label = label.clone();
changed = true;
} else {
world.resource_mut::<crate::scene_io::SceneIo>().status =
format!("Could not load material {path}");
}
}
}
});
});
changed
}