Blacksite/crates/editor/src/ui/inspector/mesh_renderers.rs
Rbanh 93e23ba194
Some checks failed
CI / Format, lint, test, build (push) Has been cancelled
feat(editor): unify renderer materials and content browser shell
2026-07-18 14:05:26 -04:00

446 lines
18 KiB
Rust

use super::*;
pub(super) fn skinned_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let mesh_candidates = static_mesh_asset_ref_candidates(world, AssetRefCandidateKind::Mesh);
let material_candidates = brush_face_material_ref_candidates(world);
let material_drop_candidate = world
.get_resource::<EditorAssets>()
.and_then(|assets| assets.dragging_selection().cloned())
.and_then(|selection| {
asset_ref_candidate_from_selection(world, &selection, AssetRefCandidateKind::Material)
});
let invalid_material_drop =
material_drop_invalid_reason(world, material_drop_candidate.is_some());
let Some(mut renderer) = world.get::<SkinnedMeshRenderer>(entity).cloned() else {
return;
};
let original = renderer.clone();
let mut changed = false;
let mut accepted_drop = false;
let mut browse_mesh = false;
let mut locate_mesh = false;
let mut options = ComponentCardOptions::removable(
COMPONENT_SKINNED_MESH_RENDERER,
"Skinned Mesh Renderer",
icons::PERSON_SIMPLE_RUN,
);
options.active_toggle = false;
options.removable = world.get::<AnimationControllerDesc>(entity).is_none();
options.resettable = false;
options.copyable = false;
options.summary = "Skeleton-bound renderer";
options.body_margin = 0;
let context = component_card_context(world, entity, options);
let response = component_card(ui, &context, |ui| {
let renderer_open_id = ui.make_persistent_id(("skinned_renderer_open", entity));
let mut renderer_open = ui
.ctx()
.data_mut(|data| data.get_persisted::<bool>(renderer_open_id))
.unwrap_or(true);
let source_candidate = mesh_candidates
.iter()
.find(|candidate| candidate.reference.asset_id == renderer.asset_id);
let label = Path::new(&renderer.path)
.file_stem()
.and_then(|stem| stem.to_str())
.filter(|label| !label.trim().is_empty())
.unwrap_or("Skinned mesh");
let thumbnail = source_candidate.and_then(|candidate| candidate.texture_id);
renderer_panel(ui, |ui| {
renderer_panel_header(
ui,
RendererPanelHeaderViewModel {
index: 0,
label,
path: if renderer.path.trim().is_empty() {
"No model source assigned"
} else {
renderer.path.as_str()
},
slot_count: renderer.materials.slots.len(),
texture_id: thumbnail,
},
&mut renderer_open,
|ui| {
if editor_ui::design_system::controls::icon_button(
ui,
icons::CROSSHAIR,
"Locate model",
source_candidate.is_some(),
)
.clicked()
{
locate_mesh = true;
}
if editor_ui::design_system::controls::icon_button(
ui,
icons::FOLDER_OPEN,
"Browse models",
true,
)
.clicked()
{
browse_mesh = true;
}
},
);
if !renderer_open {
return;
}
egui::Frame::new()
.inner_margin(egui::Margin::symmetric(12, 10))
.show(ui, |ui| {
renderer_properties_header(ui);
property_row(ui, "Source", |ui| {
ui.add(
egui::Label::new(if renderer.path.trim().is_empty() {
"Unassigned"
} else {
renderer.path.as_str()
})
.truncate(),
);
});
property_row(ui, "Scene", |ui| {
ui.label(renderer.scene_index.to_string());
});
property_row(ui, "Asset ID", |ui| {
ui.add(
egui::Label::new(if renderer.asset_id.trim().is_empty() {
"Unresolved"
} else {
renderer.asset_id.as_str()
})
.truncate(),
);
});
ui.small(
egui::RichText::new(
"Imported skeleton hierarchy and skin bindings remain model-owned.",
)
.color(TEXT_DIM),
);
});
renderer_material_slots_header(ui, renderer.materials.slots.len());
if renderer.materials.slots.is_empty() {
egui::Frame::new()
.inner_margin(egui::Margin::symmetric(12, 10))
.show(ui, |ui| {
ui.small(
egui::RichText::new("No imported slots; reimport the source model")
.color(TEXT_DIM),
);
});
}
for (index, slot) in renderer.materials.slots.iter_mut().enumerate() {
let widget = renderer_material_slot_row(ui, index, |ui| {
material_slot_widget_ui(
world,
ui,
entity,
slot,
MaterialSlotWidgetContext {
candidates: &material_candidates,
drop_candidate: material_drop_candidate.as_ref(),
invalid_drop_reason: invalid_material_drop,
expanded_header_when_parameters_closed: true,
},
)
});
changed |= widget.changed;
accepted_drop |= widget.accepted_drop;
}
});
ui.ctx()
.data_mut(|data| data.insert_persisted(renderer_open_id, renderer_open));
for orphan in &renderer.materials.orphaned_assignments {
ui.label(
egui::RichText::new(format!(
"Orphaned: {} ({})",
orphan.last_known_name, orphan.slot_id.0
))
.small()
.color(egui::Color32::YELLOW),
);
}
if world.get::<AnimationControllerDesc>(entity).is_some() {
ui.label(
egui::RichText::new(
"Remove the Animation Controller before removing its renderer.",
)
.small()
.color(TEXT_MUTED),
);
}
});
apply_component_card_response(world, entity, response);
if browse_mesh {
crate::ui::request_editor_tab(world, crate::ui::EditorTab::AssetBrowser);
}
if locate_mesh {
if let Some(candidate) = mesh_candidates
.iter()
.find(|candidate| candidate.reference.asset_id == renderer.asset_id)
{
reveal_asset_in_browser(world, &candidate.folder_path, &candidate.selection);
}
}
if accepted_drop {
clear_asset_drag(world);
}
if changed && renderer != original {
let result = reflected_component_transaction(
world,
entity,
"Assign Skinned Material Slot",
shared::AUTHORING_COMPONENT_SKINNED_MESH_RENDERER,
COMPONENT_SKINNED_MESH_RENDERER,
move |world, entity| {
world.entity_mut(entity).insert(renderer);
Ok(())
},
);
if let Err(error) = result {
world.resource_mut::<crate::scene_io::SceneIo>().status = error;
}
}
}
pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let mesh_candidates = static_mesh_asset_ref_candidates(world, AssetRefCandidateKind::Mesh);
let material_candidates = brush_face_material_ref_candidates(world);
let material_drop_candidate = world
.get_resource::<EditorAssets>()
.and_then(|assets| assets.dragging_selection().cloned())
.and_then(|selection| {
asset_ref_candidate_from_selection(world, &selection, AssetRefCandidateKind::Material)
});
let invalid_material_drop =
material_drop_invalid_reason(world, material_drop_candidate.is_some());
let Some(mut renderer) = world.get::<StaticMeshRenderer>(entity).cloned() else {
return;
};
let original = renderer.clone();
let mut changed = false;
let mut accepted_drop = false;
let mut remove_entry = None;
let mut browse_mesh = false;
let mut locate_mesh = None;
for index in 0..renderer.slots.len() {
ensure_slot_id(&mut renderer.slots[index], index);
let part = &renderer.slots[index];
if renderer.materials.slot(&part.material_slot_id).is_none() {
renderer.materials.slots.push(shared::MaterialSlot {
id: part.material_slot_id.clone(),
name: part.name.clone(),
material: None,
});
}
}
let mut options = ComponentCardOptions::removable(
COMPONENT_STATIC_MESH_RENDERER,
"Static Mesh Renderer",
icons::CUBE,
);
options.summary = "Renderer array";
options.body_margin = 0;
let card = component_card_context(world, entity, options);
let card_response = component_card(ui, &card, |ui| {
let add_slot = renderer_array_header(ui, renderer.slots.len());
ui.add_space(26.0);
if renderer.slots.is_empty() {
ui.label(egui::RichText::new("No renderer slots").color(TEXT_DIM));
}
let (entries, material_set) = (&mut renderer.slots, &mut renderer.materials);
for (index, entry) in entries.iter_mut().enumerate() {
ensure_slot_id(entry, index);
let thumbnail = thumbnail_for_mesh(&entry.mesh, &mesh_candidates);
let renderer_open_id = ui.make_persistent_id(("static_renderer_open", &entry.id.0));
let mut renderer_open = ui
.ctx()
.data_mut(|data| data.get_persisted::<bool>(renderer_open_id))
.unwrap_or(index == 0);
renderer_panel(ui, |ui| {
renderer_panel_header(
ui,
RendererPanelHeaderViewModel {
index,
label: &entry.name,
path: entry
.mesh
.source_path
.as_deref()
.unwrap_or(&entry.mesh.sub_asset_id),
slot_count: 1,
texture_id: thumbnail,
},
&mut renderer_open,
|ui| {
editor_ui::design_system::controls::icon_button(
ui,
icons::DOTS_THREE_VERTICAL,
"Renderer actions",
true,
);
if editor_ui::design_system::controls::icon_button(
ui,
icons::TRASH,
"Remove slot",
true,
)
.clicked()
{
remove_entry = Some(index);
}
if editor_ui::design_system::controls::icon_button(
ui,
icons::CROSSHAIR,
"Locate mesh",
true,
)
.clicked()
{
locate_mesh = Some(entry.mesh.clone());
}
if editor_ui::design_system::controls::icon_button(
ui,
icons::FOLDER_OPEN,
"Browse mesh",
true,
)
.clicked()
{
browse_mesh = true;
}
},
);
if !renderer_open {
return;
}
egui::Frame::new()
.inner_margin(egui::Margin::symmetric(12, 10))
.show(ui, |ui| {
renderer_properties_header(ui);
ui.add_space(2.0);
property_row(ui, "Name", |ui| {
changed |= ui
.add_sized(
[text_field_width(ui), 20.0],
egui::TextEdit::singleline(&mut entry.name),
)
.changed();
});
ui.add_space(4.0);
let mesh_response = asset_selector_row(
ui,
"Mesh",
icons::CUBE,
Some(&entry.mesh),
None,
false,
&mesh_candidates,
None,
None,
AssetSelectorActions::Full,
);
if let Some(selected) = mesh_response.selected {
entry.mesh = selected;
changed = true;
}
if mesh_response.locate {
locate_asset_ref(world, Some(&entry.mesh), &mesh_candidates);
}
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
changed |= ui.checkbox(&mut entry.visible, "Visible").changed();
changed |= ui
.checkbox(&mut entry.cast_shadows, "Cast shadows")
.changed();
changed |= ui
.checkbox(&mut entry.receive_shadows, "Receive shadows")
.changed();
});
ui.add_space(4.0);
});
renderer_material_slots_header(ui, 1);
if let Some(slot) = material_set.slot_mut(&entry.material_slot_id) {
let widget = renderer_material_slot_row(ui, index, |ui| {
material_slot_widget_ui(
world,
ui,
entity,
slot,
MaterialSlotWidgetContext {
candidates: &material_candidates,
drop_candidate: material_drop_candidate.as_ref(),
invalid_drop_reason: invalid_material_drop,
expanded_header_when_parameters_closed: true,
},
)
});
changed |= widget.changed;
accepted_drop |= widget.accepted_drop;
}
});
ui.ctx()
.data_mut(|data| data.insert_persisted(renderer_open_id, renderer_open));
}
if add_slot {
let index = renderer.slots.len();
let entry = StaticMeshRendererEntry {
id: ComponentInstanceId::new(format!("slot:{index}")),
material_slot_id: ComponentInstanceId::new(format!("slot:manual:{index}")),
..Default::default()
};
renderer.materials.slots.push(shared::MaterialSlot {
id: entry.material_slot_id.clone(),
name: format!("Material {index}"),
material: None,
});
renderer.slots.push(entry);
changed = true;
}
});
apply_component_card_response(world, entity, card_response);
if browse_mesh {
crate::ui::request_editor_tab(world, crate::ui::EditorTab::AssetBrowser);
}
if let Some(mesh) = locate_mesh.as_ref() {
locate_asset_ref(world, Some(mesh), &mesh_candidates);
}
if accepted_drop {
clear_asset_drag(world);
}
if let Some(index) = remove_entry {
let removed = renderer.slots.remove(index);
if let Some(slot_index) = renderer
.materials
.slots
.iter()
.position(|slot| slot.id == removed.material_slot_id)
{
let slot = renderer.materials.slots.remove(slot_index);
if let Some(material) = slot.material {
renderer
.materials
.orphaned_assignments
.push(shared::OrphanedMaterialAssignment {
slot_id: slot.id,
last_known_name: slot.name,
material,
});
}
}
changed = true;
}
if changed && renderer != original {
set_static_mesh_renderer_with_history(world, entity, renderer);
}
}