Blacksite/crates/editor/src/ui/inspector.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

4478 lines
167 KiB
Rust

//! Inspector extensions for authoring components.
use std::collections::{HashMap, HashSet};
use std::path::Path;
use bevy::prelude::*;
use bevy_egui::egui;
use egui_phosphor_icons::{icons, Icon};
use shared::{
authoring_component_active,
brush_math::{validate_brush, BrushDiagnosticSeverity},
infer_actor_kind, ActorKind, AnimationControllerDesc, AudioListenerDesc, AudioSourceDesc,
AuthoringComponentStates, AuthoringLightKind, AuthoringRigidBody, BrushDesc, BrushKind,
ColliderDesc, ColliderShapeDesc, ColorDesc, ComponentInstanceId, EditorAssetRef,
InspectorOrder, LevelObject, LightDesc, MaterialDesc, MaterialParameter,
MaterialParameterValue, MaterialRef, MaterialShaderKind, NavigationArea, NavigationBounds,
NavigationLink, NavigationObstacle, ObjectiveMarker, PhysicsBody, PlayerSpawn,
PostProcessVolumeDesc, PrefabInstance, Primitive, PrimitiveShape, ProjectSun, RigidBodyDesc,
SkinnedMeshRenderer, StaticMeshRenderer, StaticMeshRendererEntry, TeamSpawn, TriggerVolume,
WeaponSpawn, AUTHORING_DIRECTIONAL_LUX_MAX, AUTHORING_POINT_SPOT_LUMENS_MAX,
COMPONENT_ANIMATION_CONTROLLER_DESC, COMPONENT_AUDIO_LISTENER_DESC,
COMPONENT_AUDIO_SOURCE_DESC, COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC,
COMPONENT_LIGHT_DESC, COMPONENT_MATERIAL_DESC, COMPONENT_NAVIGATION_AREA,
COMPONENT_NAVIGATION_BOUNDS, COMPONENT_NAVIGATION_LINK, COMPONENT_NAVIGATION_OBSTACLE,
COMPONENT_OBJECTIVE_MARKER, COMPONENT_PHYSICS_BODY, COMPONENT_PLAYER_SPAWN,
COMPONENT_POST_PROCESS_VOLUME, COMPONENT_PREFAB_INSTANCE, COMPONENT_PRIMITIVE,
COMPONENT_PROJECT_SUN, COMPONENT_RIGID_BODY_DESC, COMPONENT_SKINNED_MESH_RENDERER,
COMPONENT_STATIC_MESH_RENDERER, COMPONENT_TEAM_SPAWN, COMPONENT_TRIGGER_VOLUME,
COMPONENT_WEAPON_SPAWN,
};
use crate::history::{
material_eq, reflected_component_transaction, set_animation_controller_with_history,
set_audio_listener_with_history, set_audio_source_with_history, set_brush_with_history,
set_collider_with_history, set_inspector_order_with_history, set_light_with_history,
set_material_with_history, set_physics_with_history, set_post_process_volume_with_history,
set_primitive_with_history, set_rigid_body_with_history, set_static_mesh_renderer_with_history,
EditorEntitySnapshot,
};
use crate::selection::SelectedEntity;
use crate::ui::theme::{
panel_heading, BORDER, ELEVATED_BG, PANEL_BG_DARK, SELECTION_BG_MUTED, TEXT_DIM, TEXT_MUTED,
WIDGET_BG,
};
use crate::ui::widgets::{icon_button_small, phosphor_icon, phosphor_icon_text};
use crate::viewport::brush_edit::{BrushElementKey, BrushElementSelection};
use super::component_registry::{
EditorComponentCategory, EditorComponentDescriptor, EditorComponentRegistry,
};
use super::dock_tabs::open_and_focus_tab;
use super::helpers::create_scene_sun_override_from_project_settings;
use super::{EditorTab, UiState};
use crate::assets::asset_db::{find_asset_by_path, AssetRegistry};
use crate::assets::static_mesh::{
load_static_mesh_manifest, material_id_from_label, part_id_from_label,
};
use crate::assets::thumbnails::ThumbnailStudio;
use crate::assets::{
asset_cache_key, AssetSelection, AssetSubAssetKind, AssetThumbnailCache, EditorAsset,
EditorAssetKind, EditorAssets,
};
const COMPACT_INSPECTOR_WIDTH: f32 = 360.0;
const ASSET_SELECTOR_LABEL_WIDTH: f32 = 72.0;
const ASSET_SELECTOR_HEIGHT: f32 = 48.0;
const MIN_INLINE_CONTROL_WIDTH: f32 = 120.0;
const TEXT_FIELD_MAX_WIDTH: f32 = 320.0;
const PROPERTY_LABEL_WIDTH: f32 = 136.0;
pub(crate) const COMPONENT_TRANSFORM: &str = "bevy_transform::components::Transform";
fn fit_width(ui: &egui::Ui, min: f32, max: f32) -> f32 {
let available = ui.available_width().max(1.0);
available.min(max).max(min.min(available))
}
fn exact_region<R>(
ui: &mut egui::Ui,
size: egui::Vec2,
layout: egui::Layout,
add_contents: impl FnOnce(&mut egui::Ui) -> R,
) -> R {
let (rect, _response) = ui.allocate_exact_size(size, egui::Sense::hover());
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(layout));
add_contents(&mut child)
}
#[derive(Debug, Clone)]
struct AssetRefCandidate {
reference: EditorAssetRef,
label: String,
detail: String,
selection: AssetSelection,
folder_path: String,
texture_id: Option<egui::TextureId>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AssetRefCandidateKind {
Mesh,
Material,
Texture,
}
#[derive(Default)]
struct AssetSelectorResponse {
selected: Option<EditorAssetRef>,
clear: bool,
locate: bool,
accepted_drop: bool,
}
#[derive(Debug, Clone)]
struct TextureAssetCandidate {
label: String,
path: String,
folder_path: String,
selection: AssetSelection,
texture_id: Option<egui::TextureId>,
}
#[derive(Resource, Default, Debug, Clone)]
pub(crate) struct InspectorClipboard {
component: Option<CopiedComponent>,
}
#[derive(Resource, Default, Debug, Clone)]
pub(crate) struct InspectorPanelState {
add_component_search: String,
add_component_open: bool,
add_component_focus_search: bool,
add_component_scroll_selected: bool,
add_component_selected_index: usize,
add_component_target: Option<Entity>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AddComponentShelfDirection {
Up,
Down,
}
#[derive(Debug, Clone)]
enum CopiedComponent {
Reflected(crate::history::ReflectedComponentValue),
AnimationControllerDesc(AnimationControllerDesc),
Primitive(Primitive),
BrushDesc(BrushDesc),
StaticMeshRenderer(StaticMeshRenderer),
MaterialDesc(MaterialDesc),
LightDesc(LightDesc),
AudioSourceDesc(AudioSourceDesc),
AudioListenerDesc(AudioListenerDesc),
RigidBodyDesc(RigidBodyDesc),
ColliderDesc(ColliderDesc),
PhysicsBody(PhysicsBody),
WeaponSpawn(WeaponSpawn),
TriggerVolume(TriggerVolume),
TeamSpawn(TeamSpawn),
ObjectiveMarker(ObjectiveMarker),
PostProcessVolume(PostProcessVolumeDesc),
PrefabInstance(PrefabInstance),
NavigationBounds(NavigationBounds),
NavigationObstacle(NavigationObstacle),
NavigationArea(NavigationArea),
NavigationLink(NavigationLink),
}
impl CopiedComponent {
fn type_name(&self) -> &str {
match self {
Self::Reflected(value) => &value.type_path,
Self::AnimationControllerDesc(_) => COMPONENT_ANIMATION_CONTROLLER_DESC,
Self::Primitive(_) => COMPONENT_PRIMITIVE,
Self::BrushDesc(_) => COMPONENT_BRUSH_DESC,
Self::StaticMeshRenderer(_) => COMPONENT_STATIC_MESH_RENDERER,
Self::MaterialDesc(_) => COMPONENT_MATERIAL_DESC,
Self::LightDesc(_) => COMPONENT_LIGHT_DESC,
Self::AudioSourceDesc(_) => COMPONENT_AUDIO_SOURCE_DESC,
Self::AudioListenerDesc(_) => COMPONENT_AUDIO_LISTENER_DESC,
Self::RigidBodyDesc(_) => COMPONENT_RIGID_BODY_DESC,
Self::ColliderDesc(_) => COMPONENT_COLLIDER_DESC,
Self::PhysicsBody(_) => COMPONENT_PHYSICS_BODY,
Self::WeaponSpawn(_) => COMPONENT_WEAPON_SPAWN,
Self::TriggerVolume(_) => COMPONENT_TRIGGER_VOLUME,
Self::TeamSpawn(_) => COMPONENT_TEAM_SPAWN,
Self::ObjectiveMarker(_) => COMPONENT_OBJECTIVE_MARKER,
Self::PostProcessVolume(_) => COMPONENT_POST_PROCESS_VOLUME,
Self::PrefabInstance(_) => COMPONENT_PREFAB_INSTANCE,
Self::NavigationBounds(_) => COMPONENT_NAVIGATION_BOUNDS,
Self::NavigationObstacle(_) => COMPONENT_NAVIGATION_OBSTACLE,
Self::NavigationArea(_) => COMPONENT_NAVIGATION_AREA,
Self::NavigationLink(_) => COMPONENT_NAVIGATION_LINK,
}
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct ComponentCardOptions {
pub type_name: &'static str,
pub title: &'static str,
pub icon: Icon,
pub active_toggle: bool,
pub removable: bool,
pub reorderable: bool,
pub resettable: bool,
pub copyable: bool,
}
impl ComponentCardOptions {
pub(crate) fn removable(type_name: &'static str, title: &'static str, icon: Icon) -> Self {
Self {
type_name,
title,
icon,
active_toggle: true,
removable: true,
reorderable: true,
resettable: true,
copyable: true,
}
}
pub(crate) fn fixed(type_name: &'static str, title: &'static str, icon: Icon) -> Self {
Self {
type_name,
title,
icon,
active_toggle: false,
removable: false,
reorderable: false,
resettable: true,
copyable: false,
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct ComponentCardContext {
options: ComponentCardOptions,
active: bool,
collapsed: bool,
pasteable: bool,
can_move_up: bool,
can_move_down: bool,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct ComponentCardResponse {
pub(crate) type_name: &'static str,
pub(crate) collapsed: Option<bool>,
pub(crate) active: Option<bool>,
pub(crate) reset: bool,
pub(crate) copy: bool,
pub(crate) paste: bool,
pub(crate) move_up: bool,
pub(crate) move_down: bool,
pub(crate) remove: bool,
}
pub(crate) fn component_card_context(
world: &World,
entity: Entity,
options: ComponentCardOptions,
) -> ComponentCardContext {
let present = present_component_type_names(world, entity);
let order = world.get::<InspectorOrder>(entity);
let registry = world.resource::<EditorComponentRegistry>();
let state_key = registry.stable_id_for_type(options.type_name);
let active = authoring_component_active(
world.get::<AuthoringComponentStates>(entity),
order,
state_key,
);
let collapsed = world
.get_resource::<UiState>()
.map(|state| {
state
.inspector_collapsed_components
.contains(&component_card_key(world, entity, options.type_name))
})
.unwrap_or(false);
let pasteable = options.copyable
&& world
.get_resource::<InspectorClipboard>()
.and_then(|clipboard| clipboard.component.as_ref())
.is_some_and(|component| component.type_name() == options.type_name);
let ordered = order
.map(|order| registry.ordered_components(order, &present))
.unwrap_or_else(|| present.clone());
let order_index = ordered
.iter()
.position(|type_name| *type_name == options.type_name);
ComponentCardContext {
options,
active,
collapsed,
pasteable,
can_move_up: options.reorderable && order_index.is_some_and(|index| index > 0),
can_move_down: options.reorderable
&& order_index.is_some_and(|index| index + 1 < ordered.len()),
}
}
pub(crate) fn apply_component_card_response(
world: &mut World,
entity: Entity,
response: ComponentCardResponse,
) {
let type_name = response_type_name(&response).unwrap_or_default();
if type_name.is_empty() {
return;
}
apply_component_card_response_for_type(world, entity, type_name, response);
}
fn apply_component_card_response_for_type(
world: &mut World,
entity: Entity,
type_name: &'static str,
response: ComponentCardResponse,
) {
if let Some(collapsed) = response.collapsed {
let key = component_card_key(world, entity, type_name);
if let Some(mut ui_state) = world.get_resource_mut::<UiState>() {
if collapsed {
ui_state.inspector_collapsed_components.insert(key);
} else {
ui_state.inspector_collapsed_components.remove(&key);
}
}
}
if let Some(active) = response.active {
let mut states = world
.get::<AuthoringComponentStates>(entity)
.cloned()
.unwrap_or_else(|| AuthoringComponentStates {
states: world
.get::<InspectorOrder>(entity)
.map(|order| order.component_states.clone())
.unwrap_or_default(),
});
let component_id = world
.resource::<EditorComponentRegistry>()
.stable_id_for_type(type_name);
states.set_component_active(component_id, active);
let _ = crate::history::reflected_component_transaction(
world,
entity,
"Set Component Active",
"editor.component_states",
"shared::components::AuthoringComponentStates",
move |world, entity| {
world.entity_mut(entity).insert(states);
Ok(())
},
);
}
if response.move_up || response.move_down {
let mut order = world
.get::<InspectorOrder>(entity)
.cloned()
.unwrap_or_default();
let present = present_component_type_names(world, entity);
let offset = if response.move_up { -1 } else { 1 };
let moved = world
.resource::<EditorComponentRegistry>()
.move_component(&mut order, type_name, offset, &present);
if moved {
set_inspector_order_with_history(world, entity, order);
}
}
if response.copy {
copy_component(world, entity, type_name);
}
if response.paste {
paste_component(world, entity, type_name);
}
if response.reset {
reset_component(world, entity, type_name);
}
if response.remove {
remove_registered_component(world, entity, type_name);
}
}
fn response_type_name(_response: &ComponentCardResponse) -> Option<&'static str> {
Some(_response.type_name)
}
fn component_card_key(world: &World, entity: Entity, type_name: &str) -> String {
let actor_key = world
.get::<shared::ActorId>(entity)
.map(|id| id.0.as_str())
.filter(|id| !id.trim().is_empty())
.map(str::to_string)
.unwrap_or_else(|| format!("{entity:?}"));
format!("{actor_key}::{type_name}")
}
fn present_component_type_names(world: &World, entity: Entity) -> Vec<&'static str> {
if let Some(registry) = world.get_resource::<EditorComponentRegistry>() {
return registry
.descriptors
.iter()
.filter(|descriptor| {
!descriptor.hidden
&& registry.component_present(world, entity, descriptor.type_name)
})
.map(|descriptor| descriptor.type_name)
.collect();
}
let mut present = Vec::new();
if world.get::<AnimationControllerDesc>(entity).is_some() {
present.push(COMPONENT_ANIMATION_CONTROLLER_DESC);
}
if world.get::<StaticMeshRenderer>(entity).is_some() {
present.push(COMPONENT_STATIC_MESH_RENDERER);
}
if world.get::<SkinnedMeshRenderer>(entity).is_some() {
present.push(COMPONENT_SKINNED_MESH_RENDERER);
}
if world.get::<Primitive>(entity).is_some() {
present.push(COMPONENT_PRIMITIVE);
}
if world.get::<BrushDesc>(entity).is_some() {
present.push(COMPONENT_BRUSH_DESC);
}
if world.get::<MaterialDesc>(entity).is_some() || world.get::<Primitive>(entity).is_some() {
present.push(COMPONENT_MATERIAL_DESC);
}
if world.get::<LightDesc>(entity).is_some() {
present.push(COMPONENT_LIGHT_DESC);
}
if world.get::<AudioSourceDesc>(entity).is_some() {
present.push(COMPONENT_AUDIO_SOURCE_DESC);
}
if world.get::<AudioListenerDesc>(entity).is_some() {
present.push(COMPONENT_AUDIO_LISTENER_DESC);
}
if world.get::<RigidBodyDesc>(entity).is_some() {
present.push(COMPONENT_RIGID_BODY_DESC);
}
if world.get::<ColliderDesc>(entity).is_some() {
present.push(COMPONENT_COLLIDER_DESC);
}
if world.get::<PhysicsBody>(entity).is_some() {
present.push(COMPONENT_PHYSICS_BODY);
}
if world.get::<PlayerSpawn>(entity).is_some() {
present.push(COMPONENT_PLAYER_SPAWN);
}
if world.get::<WeaponSpawn>(entity).is_some() {
present.push(COMPONENT_WEAPON_SPAWN);
}
if world.get::<TriggerVolume>(entity).is_some() {
present.push(COMPONENT_TRIGGER_VOLUME);
}
if world.get::<TeamSpawn>(entity).is_some() {
present.push(COMPONENT_TEAM_SPAWN);
}
if world.get::<ObjectiveMarker>(entity).is_some() {
present.push(COMPONENT_OBJECTIVE_MARKER);
}
if world.get::<PrefabInstance>(entity).is_some() {
present.push(COMPONENT_PREFAB_INSTANCE);
}
if world.get::<PostProcessVolumeDesc>(entity).is_some() {
present.push(COMPONENT_POST_PROCESS_VOLUME);
}
if world.get::<ProjectSun>(entity).is_some() {
present.push(COMPONENT_PROJECT_SUN);
}
if world.get::<NavigationBounds>(entity).is_some() {
present.push(COMPONENT_NAVIGATION_BOUNDS);
}
if world.get::<NavigationObstacle>(entity).is_some() {
present.push(COMPONENT_NAVIGATION_OBSTACLE);
}
if world.get::<NavigationArea>(entity).is_some() {
present.push(COMPONENT_NAVIGATION_AREA);
}
if world.get::<NavigationLink>(entity).is_some() {
present.push(COMPONENT_NAVIGATION_LINK);
}
present
}
fn copy_component(world: &mut World, entity: Entity, type_name: &str) {
let descriptor = world
.resource::<EditorComponentRegistry>()
.by_type_name(type_name)
.cloned();
if let Some(descriptor) = descriptor {
if let Ok(Some(value)) = crate::history::capture_reflected_component(
world,
entity,
descriptor.id,
descriptor.type_name,
) {
world.resource_mut::<InspectorClipboard>().component =
Some(CopiedComponent::Reflected(value));
return;
}
}
let component = match type_name {
COMPONENT_ANIMATION_CONTROLLER_DESC => world
.get::<AnimationControllerDesc>(entity)
.cloned()
.map(CopiedComponent::AnimationControllerDesc),
COMPONENT_PRIMITIVE => world
.get::<Primitive>(entity)
.cloned()
.map(CopiedComponent::Primitive),
COMPONENT_BRUSH_DESC => world
.get::<BrushDesc>(entity)
.cloned()
.map(CopiedComponent::BrushDesc),
COMPONENT_STATIC_MESH_RENDERER => world
.get::<StaticMeshRenderer>(entity)
.cloned()
.map(CopiedComponent::StaticMeshRenderer),
COMPONENT_MATERIAL_DESC => world
.get::<MaterialDesc>(entity)
.cloned()
.map(CopiedComponent::MaterialDesc),
COMPONENT_LIGHT_DESC => world
.get::<LightDesc>(entity)
.cloned()
.map(CopiedComponent::LightDesc),
COMPONENT_AUDIO_SOURCE_DESC => world
.get::<AudioSourceDesc>(entity)
.cloned()
.map(CopiedComponent::AudioSourceDesc),
COMPONENT_AUDIO_LISTENER_DESC => world
.get::<AudioListenerDesc>(entity)
.copied()
.map(CopiedComponent::AudioListenerDesc),
COMPONENT_RIGID_BODY_DESC => world
.get::<RigidBodyDesc>(entity)
.copied()
.map(CopiedComponent::RigidBodyDesc),
COMPONENT_COLLIDER_DESC => world
.get::<ColliderDesc>(entity)
.cloned()
.map(CopiedComponent::ColliderDesc),
COMPONENT_PHYSICS_BODY => world
.get::<PhysicsBody>(entity)
.cloned()
.map(CopiedComponent::PhysicsBody),
COMPONENT_WEAPON_SPAWN => world
.get::<WeaponSpawn>(entity)
.cloned()
.map(CopiedComponent::WeaponSpawn),
COMPONENT_TRIGGER_VOLUME => world
.get::<TriggerVolume>(entity)
.cloned()
.map(CopiedComponent::TriggerVolume),
COMPONENT_TEAM_SPAWN => world
.get::<TeamSpawn>(entity)
.cloned()
.map(CopiedComponent::TeamSpawn),
COMPONENT_OBJECTIVE_MARKER => world
.get::<ObjectiveMarker>(entity)
.cloned()
.map(CopiedComponent::ObjectiveMarker),
COMPONENT_POST_PROCESS_VOLUME => world
.get::<PostProcessVolumeDesc>(entity)
.cloned()
.map(CopiedComponent::PostProcessVolume),
COMPONENT_PREFAB_INSTANCE => world
.get::<PrefabInstance>(entity)
.cloned()
.map(CopiedComponent::PrefabInstance),
COMPONENT_NAVIGATION_BOUNDS => world
.get::<NavigationBounds>(entity)
.cloned()
.map(CopiedComponent::NavigationBounds),
COMPONENT_NAVIGATION_OBSTACLE => world
.get::<NavigationObstacle>(entity)
.cloned()
.map(CopiedComponent::NavigationObstacle),
COMPONENT_NAVIGATION_AREA => world
.get::<NavigationArea>(entity)
.cloned()
.map(CopiedComponent::NavigationArea),
COMPONENT_NAVIGATION_LINK => world
.get::<NavigationLink>(entity)
.cloned()
.map(CopiedComponent::NavigationLink),
_ => None,
};
if let Some(component) = component {
world.resource_mut::<InspectorClipboard>().component = Some(component);
}
}
fn paste_component(world: &mut World, entity: Entity, type_name: &str) {
let component = world
.get_resource::<InspectorClipboard>()
.and_then(|clipboard| clipboard.component.clone());
if let Some(CopiedComponent::Reflected(value)) = component.as_ref() {
if value.type_path == type_name {
let value = value.clone();
let component_id = value.component_id.clone();
let _ = crate::history::reflected_component_transaction(
world,
entity,
"Paste Component",
&component_id,
type_name,
move |world, entity| {
crate::history::apply_reflected_component(
world,
entity,
type_name,
Some(&value),
)
},
);
}
return;
}
match component {
Some(CopiedComponent::Reflected(_)) => {}
Some(CopiedComponent::AnimationControllerDesc(value))
if type_name == COMPONENT_ANIMATION_CONTROLLER_DESC =>
{
set_animation_controller_with_history(world, entity, value);
}
Some(CopiedComponent::Primitive(value)) if type_name == COMPONENT_PRIMITIVE => {
set_primitive_with_history(world, entity, value);
}
Some(CopiedComponent::BrushDesc(value)) if type_name == COMPONENT_BRUSH_DESC => {
set_brush_with_history(world, entity, value);
}
Some(CopiedComponent::StaticMeshRenderer(value))
if type_name == COMPONENT_STATIC_MESH_RENDERER =>
{
set_static_mesh_renderer_with_history(world, entity, value);
}
Some(CopiedComponent::MaterialDesc(value)) if type_name == COMPONENT_MATERIAL_DESC => {
set_material_with_history(world, entity, value);
}
Some(CopiedComponent::LightDesc(value)) if type_name == COMPONENT_LIGHT_DESC => {
set_light_with_history(world, entity, value);
}
Some(CopiedComponent::AudioSourceDesc(value))
if type_name == COMPONENT_AUDIO_SOURCE_DESC =>
{
set_audio_source_with_history(world, entity, value);
}
Some(CopiedComponent::AudioListenerDesc(value))
if type_name == COMPONENT_AUDIO_LISTENER_DESC =>
{
set_audio_listener_with_history(world, entity, value);
}
Some(CopiedComponent::RigidBodyDesc(value)) if type_name == COMPONENT_RIGID_BODY_DESC => {
set_rigid_body_with_history(world, entity, value);
}
Some(CopiedComponent::ColliderDesc(value)) if type_name == COMPONENT_COLLIDER_DESC => {
set_collider_with_history(world, entity, value);
}
Some(CopiedComponent::PhysicsBody(value)) if type_name == COMPONENT_PHYSICS_BODY => {
set_physics_with_history(world, entity, value);
}
Some(CopiedComponent::PostProcessVolume(value))
if type_name == COMPONENT_POST_PROCESS_VOLUME =>
{
set_post_process_volume_with_history(world, entity, value);
}
Some(CopiedComponent::WeaponSpawn(value)) if type_name == COMPONENT_WEAPON_SPAWN => {
insert_direct_component(world, entity, value);
}
Some(CopiedComponent::TriggerVolume(value)) if type_name == COMPONENT_TRIGGER_VOLUME => {
insert_direct_component(world, entity, value);
}
Some(CopiedComponent::TeamSpawn(value)) if type_name == COMPONENT_TEAM_SPAWN => {
insert_direct_component(world, entity, value);
}
Some(CopiedComponent::ObjectiveMarker(value))
if type_name == COMPONENT_OBJECTIVE_MARKER =>
{
insert_direct_component(world, entity, value);
}
Some(CopiedComponent::PrefabInstance(value)) if type_name == COMPONENT_PREFAB_INSTANCE => {
insert_direct_component(world, entity, value);
}
Some(CopiedComponent::NavigationBounds(value))
if type_name == COMPONENT_NAVIGATION_BOUNDS =>
{
crate::history::set_navigation_with_history(
world,
entity,
crate::history::NavigationComponentState {
bounds: Some(value),
..Default::default()
},
);
}
Some(CopiedComponent::NavigationObstacle(value))
if type_name == COMPONENT_NAVIGATION_OBSTACLE =>
{
crate::history::set_navigation_with_history(
world,
entity,
crate::history::NavigationComponentState {
obstacle: Some(value),
..Default::default()
},
);
}
Some(CopiedComponent::NavigationArea(value)) if type_name == COMPONENT_NAVIGATION_AREA => {
crate::history::set_navigation_with_history(
world,
entity,
crate::history::NavigationComponentState {
area: Some(value),
..Default::default()
},
);
}
Some(CopiedComponent::NavigationLink(value)) if type_name == COMPONENT_NAVIGATION_LINK => {
crate::history::set_navigation_with_history(
world,
entity,
crate::history::NavigationComponentState {
link: Some(value),
..Default::default()
},
);
}
_ => {}
}
}
fn reset_component(world: &mut World, entity: Entity, type_name: &str) {
let descriptor = world
.resource::<EditorComponentRegistry>()
.by_type_name(type_name)
.cloned();
if let Some(descriptor) = descriptor {
let component_id = descriptor.id;
let type_path = descriptor.type_name;
if type_path == COMPONENT_ANIMATION_CONTROLLER_DESC {
let controller =
crate::ui::animation_inspector::default_controller_for_actor(world, entity);
let _ = crate::history::reflected_component_transaction(
world,
entity,
"Reset Component",
component_id,
type_path,
move |world, entity| {
world.entity_mut(entity).insert(controller);
Ok(())
},
);
} else {
let _ = crate::history::reflected_component_transaction(
world,
entity,
"Reset Component",
component_id,
type_path,
move |world, entity| {
crate::history::apply_reflected_default(world, entity, type_path)
},
);
}
return;
}
match type_name {
COMPONENT_ANIMATION_CONTROLLER_DESC => {
let controller =
crate::ui::animation_inspector::default_controller_for_actor(world, entity);
set_animation_controller_with_history(world, entity, controller);
}
COMPONENT_PRIMITIVE => set_primitive_with_history(world, entity, Primitive::default()),
COMPONENT_BRUSH_DESC => set_brush_with_history(world, entity, BrushDesc::default()),
COMPONENT_STATIC_MESH_RENDERER => {
set_static_mesh_renderer_with_history(world, entity, StaticMeshRenderer::default());
}
COMPONENT_MATERIAL_DESC => {
set_material_with_history(world, entity, MaterialDesc::default())
}
COMPONENT_LIGHT_DESC => set_light_with_history(world, entity, LightDesc::default()),
COMPONENT_AUDIO_SOURCE_DESC => {
set_audio_source_with_history(world, entity, AudioSourceDesc::default())
}
COMPONENT_AUDIO_LISTENER_DESC => {
set_audio_listener_with_history(world, entity, AudioListenerDesc::default())
}
COMPONENT_RIGID_BODY_DESC => {
set_rigid_body_with_history(world, entity, RigidBodyDesc::default());
}
COMPONENT_COLLIDER_DESC => {
set_collider_with_history(world, entity, ColliderDesc::default())
}
COMPONENT_PHYSICS_BODY => set_physics_with_history(world, entity, PhysicsBody::default()),
COMPONENT_POST_PROCESS_VOLUME => {
set_post_process_volume_with_history(world, entity, PostProcessVolumeDesc::default());
}
COMPONENT_NAVIGATION_BOUNDS => crate::history::set_navigation_with_history(
world,
entity,
crate::history::NavigationComponentState {
bounds: Some(NavigationBounds::default()),
..Default::default()
},
),
COMPONENT_NAVIGATION_OBSTACLE => crate::history::set_navigation_with_history(
world,
entity,
crate::history::NavigationComponentState {
obstacle: Some(NavigationObstacle::default()),
..Default::default()
},
),
COMPONENT_NAVIGATION_AREA => crate::history::set_navigation_with_history(
world,
entity,
crate::history::NavigationComponentState {
area: Some(NavigationArea::default()),
..Default::default()
},
),
COMPONENT_NAVIGATION_LINK => crate::history::set_navigation_with_history(
world,
entity,
crate::history::NavigationComponentState {
link: Some(NavigationLink::default()),
..Default::default()
},
),
COMPONENT_WEAPON_SPAWN => insert_direct_component(
world,
entity,
WeaponSpawn {
weapon_id: "rifle".into(),
},
),
COMPONENT_TRIGGER_VOLUME => {
insert_direct_component(world, entity, TriggerVolume::default())
}
COMPONENT_TEAM_SPAWN => insert_direct_component(world, entity, TeamSpawn { team_id: 0 }),
COMPONENT_OBJECTIVE_MARKER => insert_direct_component(
world,
entity,
ObjectiveMarker {
objective_id: "objective".into(),
},
),
_ => {}
}
}
fn insert_direct_component<T: Component>(world: &mut World, entity: Entity, component: T) {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
entity_mut.insert(component);
}
if let Some(mut scene_io) = world.get_resource_mut::<crate::scene_io::SceneIo>() {
scene_io.mark_dirty();
}
}
fn remove_registered_component(world: &mut World, entity: Entity, type_name: &str) {
let descriptor = world
.resource::<EditorComponentRegistry>()
.by_type_name(type_name)
.cloned();
if let Some(descriptor) = descriptor {
if !descriptor.removable {
return;
}
let dependents = world
.resource::<EditorComponentRegistry>()
.present_dependents(world, entity, descriptor.id)
.iter()
.map(|dependent| dependent.display_name)
.collect::<Vec<_>>();
if !dependents.is_empty() {
world
.resource_mut::<crate::scene_io::SceneIo>()
.set_status(format!(
"Remove blocked: required by {}. Remove dependent components first.",
dependents.join(", ")
));
return;
}
let result = crate::history::reflected_component_transaction(
world,
entity,
"Remove Component",
descriptor.id,
descriptor.type_name,
|world, entity| {
crate::history::apply_reflected_component(world, entity, descriptor.type_name, None)
},
);
if result.is_ok() && type_name == COMPONENT_ANIMATION_CONTROLLER_DESC {
crate::ui::animation_inspector::stop_preview_if_actor(world, entity);
}
return;
}
let Some(before) = crate::history::snapshot_entity(world, entity) else {
return;
};
let removed_dedicated_audio_kind = matches!(
(type_name, before.actor_kind),
(COMPONENT_AUDIO_SOURCE_DESC, ActorKind::AudioSource)
| (COMPONENT_AUDIO_LISTENER_DESC, ActorKind::AudioListener)
);
let removed_dedicated_navigation_kind = before.actor_kind == ActorKind::Navigation
&& matches!(
type_name,
COMPONENT_NAVIGATION_BOUNDS
| COMPONENT_NAVIGATION_OBSTACLE
| COMPONENT_NAVIGATION_AREA
| COMPONENT_NAVIGATION_LINK
);
let removed_dedicated_skinned_kind =
type_name == COMPONENT_SKINNED_MESH_RENDERER && before.actor_kind == ActorKind::SkinnedMesh;
let removed = if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
match type_name {
COMPONENT_ANIMATION_CONTROLLER_DESC if before.animation_controller.is_some() => {
entity_mut.remove::<AnimationControllerDesc>();
true
}
COMPONENT_PRIMITIVE if before.primitive.is_some() => {
entity_mut.remove::<Primitive>();
true
}
COMPONENT_BRUSH_DESC if before.brush.is_some() => {
entity_mut.remove::<BrushDesc>();
true
}
COMPONENT_STATIC_MESH_RENDERER if before.static_mesh_renderer.is_some() => {
entity_mut.remove::<StaticMeshRenderer>();
true
}
COMPONENT_SKINNED_MESH_RENDERER if before.skinned_mesh_renderer.is_some() => {
entity_mut.remove::<SkinnedMeshRenderer>();
true
}
COMPONENT_MATERIAL_DESC if before.material.is_some() => {
entity_mut.remove::<MaterialDesc>();
true
}
COMPONENT_LIGHT_DESC if before.light.is_some() => {
entity_mut.remove::<LightDesc>();
true
}
COMPONENT_AUDIO_SOURCE_DESC if before.audio_source.is_some() => {
entity_mut.remove::<AudioSourceDesc>();
true
}
COMPONENT_AUDIO_LISTENER_DESC if before.audio_listener.is_some() => {
entity_mut.remove::<AudioListenerDesc>();
true
}
COMPONENT_RIGID_BODY_DESC if before.rigid_body.is_some() => {
entity_mut.remove::<RigidBodyDesc>();
true
}
COMPONENT_COLLIDER_DESC if before.collider.is_some() => {
entity_mut.remove::<ColliderDesc>();
true
}
COMPONENT_PHYSICS_BODY if before.physics.is_some() => {
entity_mut.remove::<PhysicsBody>();
true
}
COMPONENT_PLAYER_SPAWN if before.player_spawn => {
entity_mut.remove::<PlayerSpawn>();
true
}
COMPONENT_WEAPON_SPAWN if before.weapon_spawn.is_some() => {
entity_mut.remove::<WeaponSpawn>();
true
}
COMPONENT_TRIGGER_VOLUME if before.trigger_volume.is_some() => {
entity_mut.remove::<TriggerVolume>();
true
}
COMPONENT_TEAM_SPAWN if before.team_spawn.is_some() => {
entity_mut.remove::<TeamSpawn>();
true
}
COMPONENT_OBJECTIVE_MARKER if before.objective.is_some() => {
entity_mut.remove::<ObjectiveMarker>();
true
}
COMPONENT_PREFAB_INSTANCE if before.prefab_instance.is_some() => {
entity_mut.remove::<PrefabInstance>();
true
}
COMPONENT_POST_PROCESS_VOLUME if before.post_process_volume.is_some() => {
entity_mut.remove::<PostProcessVolumeDesc>();
true
}
COMPONENT_NAVIGATION_BOUNDS if before.navigation_bounds.is_some() => {
entity_mut.remove::<NavigationBounds>();
true
}
COMPONENT_NAVIGATION_OBSTACLE if before.navigation_obstacle.is_some() => {
entity_mut.remove::<NavigationObstacle>();
true
}
COMPONENT_NAVIGATION_AREA if before.navigation_area.is_some() => {
entity_mut.remove::<NavigationArea>();
true
}
COMPONENT_NAVIGATION_LINK if before.navigation_link.is_some() => {
entity_mut.remove::<NavigationLink>();
true
}
_ => false,
}
} else {
false
};
if !removed {
return;
}
if type_name == COMPONENT_ANIMATION_CONTROLLER_DESC {
crate::ui::animation_inspector::stop_preview_if_actor(world, entity);
}
if removed_dedicated_audio_kind
|| removed_dedicated_navigation_kind
|| removed_dedicated_skinned_kind
{
let fallback = world
.get_entity(entity)
.ok()
.and_then(infer_actor_kind)
.unwrap_or(ActorKind::Empty);
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
entity_mut.insert(fallback);
}
}
if let Some(after) = crate::history::snapshot_entity(world, entity) {
let snapshot = diff_added(&after, &before);
crate::history::push_command(
world,
crate::history::EditorCommand::RemoveComponent { entity, snapshot },
);
}
}
pub fn authoring_inspector_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
if !world
.get_entity(entity)
.is_ok_and(|entity_ref| entity_ref.contains::<LevelObject>())
{
return;
}
let present = present_component_type_names(world, entity);
let ordered = if let Some(order) = world.get::<InspectorOrder>(entity) {
world
.resource::<EditorComponentRegistry>()
.ordered_components(order, &present)
} else {
present
};
for type_name in ordered {
draw_authoring_component_by_type(world, ui, entity, type_name);
}
}
fn draw_authoring_component_by_type(
world: &mut World,
ui: &mut egui::Ui,
entity: Entity,
type_name: &'static str,
) {
if let Some(inspector) = world
.resource::<EditorComponentRegistry>()
.inspector(type_name)
{
inspector(world, ui, entity);
return;
}
match type_name {
COMPONENT_ANIMATION_CONTROLLER_DESC => {
crate::ui::animation_inspector::animation_controller_inspector_ui(world, ui, entity);
}
COMPONENT_STATIC_MESH_RENDERER => static_mesh_renderer_ui(world, ui, entity),
COMPONENT_SKINNED_MESH_RENDERER => skinned_mesh_renderer_ui(world, ui, entity),
COMPONENT_BRUSH_DESC => brush_editor_ui(world, ui, entity),
COMPONENT_PRIMITIVE => primitive_editor_ui(world, ui, entity),
COMPONENT_MATERIAL_DESC => material_editor_ui(world, ui, entity),
COMPONENT_LIGHT_DESC => light_editor_ui(world, ui, entity),
COMPONENT_AUDIO_SOURCE_DESC => {
crate::ui::audio_inspector::audio_source_inspector_ui(world, ui, entity);
}
COMPONENT_AUDIO_LISTENER_DESC => {
crate::ui::audio_inspector::audio_listener_inspector_ui(world, ui, entity);
}
COMPONENT_RIGID_BODY_DESC => rigid_body_editor_ui(world, ui, entity),
COMPONENT_COLLIDER_DESC => collider_editor_ui(world, ui, entity),
COMPONENT_PHYSICS_BODY => physics_editor_ui(world, ui, entity),
COMPONENT_PLAYER_SPAWN => player_spawn_ui(world, ui, entity),
COMPONENT_WEAPON_SPAWN => weapon_spawn_ui(world, ui, entity),
COMPONENT_TRIGGER_VOLUME => trigger_volume_ui(world, ui, entity),
COMPONENT_TEAM_SPAWN => team_spawn_ui(world, ui, entity),
COMPONENT_OBJECTIVE_MARKER => objective_ui(world, ui, entity),
COMPONENT_PREFAB_INSTANCE => prefab_instance_ui(world, ui, entity),
COMPONENT_POST_PROCESS_VOLUME => {
crate::ui::post_process_volume_ui::post_process_volume_inspector_ui(world, ui, entity);
}
COMPONENT_PROJECT_SUN => project_sun_ui(world, ui, entity),
COMPONENT_NAVIGATION_BOUNDS => {
super::navigation_inspector::navigation_bounds_inspector_ui(world, ui, entity)
}
COMPONENT_NAVIGATION_OBSTACLE => {
super::navigation_inspector::navigation_obstacle_inspector_ui(world, ui, entity)
}
COMPONENT_NAVIGATION_AREA => {
super::navigation_inspector::navigation_area_inspector_ui(world, ui, entity)
}
COMPONENT_NAVIGATION_LINK => {
super::navigation_inspector::navigation_link_inspector_ui(world, ui, entity)
}
_ => {}
}
}
pub(crate) fn add_component_footer(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
ui.add_space(4.0);
let button_response = egui::Frame::new()
.fill(WIDGET_BG)
.stroke(egui::Stroke::new(1.0, BORDER))
.corner_radius(egui::CornerRadius::same(4))
.inner_margin(egui::Margin::symmetric(8, 6))
.show(ui, |ui| {
let open_for_entity = add_component_picker_open_for(world, entity);
let label = if open_for_entity {
"Close Add Component"
} else {
"+ Add Component"
};
if ui.button(label).clicked() {
if let Some(mut state) = world.get_resource_mut::<InspectorPanelState>() {
if open_for_entity {
state.add_component_open = false;
state.add_component_target = None;
} else {
state.add_component_open = true;
state.add_component_focus_search = true;
state.add_component_scroll_selected = true;
state.add_component_selected_index = 0;
state.add_component_target = Some(entity);
state.add_component_search.clear();
}
}
}
});
if add_component_picker_open_for(world, entity) {
add_component_picker_shelf(world, ui, entity, button_response.response.rect);
}
}
fn add_component_picker_open_for(world: &World, entity: Entity) -> bool {
world
.get_resource::<InspectorPanelState>()
.is_some_and(|state| state.add_component_open && state.add_component_target == Some(entity))
}
fn shelf_list_max_height(visible_space: f32) -> f32 {
(visible_space - 108.0).clamp(56.0, 320.0)
}
fn add_component_picker_shelf(
world: &mut World,
ui: &mut egui::Ui,
target: Entity,
anchor: egui::Rect,
) {
if world.get_entity(target).is_err() {
if let Some(mut state) = world.get_resource_mut::<InspectorPanelState>() {
state.add_component_open = false;
state.add_component_target = None;
}
return;
}
let descriptors = world
.resource::<EditorComponentRegistry>()
.descriptors
.clone();
let visible = ui.clip_rect().intersect(ui.ctx().content_rect());
let space_above = (anchor.min.y - visible.top()).max(0.0);
let space_below = (visible.bottom() - anchor.max.y).max(0.0);
let direction = if space_below >= space_above {
AddComponentShelfDirection::Down
} else {
AddComponentShelfDirection::Up
};
let visible_space = match direction {
AddComponentShelfDirection::Up => space_above,
AddComponentShelfDirection::Down => space_below,
};
let list_max_height = shelf_list_max_height(visible_space);
let shelf_height = (list_max_height + 108.0).min((visible_space - 4.0).max(96.0));
let x = anchor
.min
.x
.clamp(visible.left(), visible.right() - anchor.width());
let y = match direction {
AddComponentShelfDirection::Up => anchor.min.y - shelf_height - 4.0,
AddComponentShelfDirection::Down => anchor.max.y + 4.0,
}
.clamp(
visible.top(),
(visible.bottom() - shelf_height).max(visible.top()),
);
let shelf_width = anchor.width().max(260.0).min(visible.width());
egui::Area::new(egui::Id::new(("add_component_shelf", target)))
.order(egui::Order::Foreground)
.fixed_pos(egui::pos2(x, y))
.show(ui.ctx(), |ui| {
ui.set_width(shelf_width);
add_component_picker_shelf_contents(world, ui, target, &descriptors, list_max_height);
});
}
fn add_component_picker_shelf_contents(
world: &mut World,
ui: &mut egui::Ui,
target: Entity,
descriptors: &[EditorComponentDescriptor],
list_max_height: f32,
) {
egui::Frame::new()
.fill(PANEL_BG_DARK)
.stroke(egui::Stroke::new(1.0, BORDER))
.corner_radius(egui::CornerRadius::same(4))
.inner_margin(egui::Margin::symmetric(8, 8))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(panel_heading("Add Component"));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if icon_button_small(ui, icons::X, "Close Add Component").clicked() {
let mut state = world.resource_mut::<InspectorPanelState>();
state.add_component_open = false;
state.add_component_target = None;
}
});
});
ui.add_space(4.0);
let mut search_input = world
.resource::<InspectorPanelState>()
.add_component_search
.clone();
let search_response = ui.add(
egui::TextEdit::singleline(&mut search_input)
.hint_text("Search components...")
.desired_width(f32::INFINITY),
);
if search_response.changed() {
let mut state = world.resource_mut::<InspectorPanelState>();
state.add_component_search = search_input.clone();
state.add_component_scroll_selected = true;
state.add_component_selected_index = 0;
}
if world
.resource::<InspectorPanelState>()
.add_component_focus_search
{
search_response.request_focus();
world
.resource_mut::<InspectorPanelState>()
.add_component_focus_search = false;
}
let search = search_input.to_lowercase();
let filtered = filtered_component_descriptors(descriptors, &search);
{
let mut state = world.resource_mut::<InspectorPanelState>();
if !filtered.is_empty() {
state.add_component_selected_index =
state.add_component_selected_index.min(filtered.len() - 1);
} else {
state.add_component_selected_index = 0;
}
}
if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown))
&& !filtered.is_empty()
{
let mut state = world.resource_mut::<InspectorPanelState>();
state.add_component_selected_index =
(state.add_component_selected_index + 1) % filtered.len();
state.add_component_scroll_selected = true;
}
if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp))
&& !filtered.is_empty()
{
let mut state = world.resource_mut::<InspectorPanelState>();
state.add_component_selected_index = if state.add_component_selected_index == 0 {
filtered.len() - 1
} else {
state.add_component_selected_index - 1
};
state.add_component_scroll_selected = true;
}
if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
let mut state = world.resource_mut::<InspectorPanelState>();
state.add_component_open = false;
state.add_component_target = None;
}
if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) {
let selected_index = world
.resource::<InspectorPanelState>()
.add_component_selected_index;
if let Some(descriptor) = filtered.get(selected_index) {
let add_state = component_add_state(world, target, descriptor, descriptors);
if add_state.addable {
insert_registered_component(world, target, descriptor.type_name);
let mut state = world.resource_mut::<InspectorPanelState>();
state.add_component_open = false;
state.add_component_target = None;
}
}
}
ui.separator();
if filtered.is_empty() {
ui.label(egui::RichText::new("No matching components").color(TEXT_DIM));
return;
}
egui::ScrollArea::vertical()
.max_height(list_max_height)
.show(ui, |ui| {
let (selected_index, scroll_selected) = {
let state = world.resource::<InspectorPanelState>();
(
state.add_component_selected_index,
state.add_component_scroll_selected,
)
};
let mut last_category = None;
for (index, descriptor) in filtered.iter().enumerate() {
if last_category != Some(descriptor.category) {
if last_category.is_some() {
ui.separator();
}
ui.label(panel_heading(component_category_label(descriptor.category)));
last_category = Some(descriptor.category);
}
let add_state = component_add_state(world, target, descriptor, descriptors);
let selected = index == selected_index;
let row = ui
.horizontal(|ui| {
ui.label(phosphor_icon_text(descriptor.icon, 14.0).color(TEXT_DIM));
ui.add_enabled(
add_state.addable,
egui::Button::selectable(selected, descriptor.display_name),
)
})
.inner
.on_hover_text(component_hover_text(descriptor, &add_state));
if selected && scroll_selected {
row.scroll_to_me(Some(egui::Align::Center));
}
if row.clicked() {
world
.resource_mut::<InspectorPanelState>()
.add_component_selected_index = index;
if add_state.addable {
insert_registered_component(world, target, descriptor.type_name);
let mut state = world.resource_mut::<InspectorPanelState>();
state.add_component_open = false;
state.add_component_target = None;
}
}
}
});
world
.resource_mut::<InspectorPanelState>()
.add_component_scroll_selected = false;
});
}
fn filtered_component_descriptors<'a>(
descriptors: &'a [EditorComponentDescriptor],
search: &str,
) -> Vec<&'a EditorComponentDescriptor> {
[
EditorComponentCategory::Authoring,
EditorComponentCategory::Rendering,
EditorComponentCategory::Animation,
EditorComponentCategory::Audio,
EditorComponentCategory::Navigation,
EditorComponentCategory::Physics,
EditorComponentCategory::Gameplay,
EditorComponentCategory::Volumes,
]
.into_iter()
.flat_map(|category| {
descriptors.iter().filter(move |descriptor| {
descriptor.addable
&& !descriptor.hidden
&& descriptor.category == category
&& descriptor_matches_search(descriptor, search)
})
})
.collect()
}
fn component_category_label(category: EditorComponentCategory) -> &'static str {
match category {
EditorComponentCategory::Authoring => "Authoring",
EditorComponentCategory::Rendering => "Rendering",
EditorComponentCategory::Animation => "Animation",
EditorComponentCategory::Audio => "Audio",
EditorComponentCategory::Navigation => "Navigation",
EditorComponentCategory::Physics => "Physics",
EditorComponentCategory::Gameplay => "Gameplay",
EditorComponentCategory::Volumes => "Volumes",
EditorComponentCategory::Editor => "Editor",
}
}
struct ComponentAddState {
addable: bool,
reason: Option<String>,
required: Vec<&'static str>,
recommended: Vec<&'static str>,
conflicts: Vec<&'static str>,
}
fn descriptor_matches_search(descriptor: &EditorComponentDescriptor, search: &str) -> bool {
search.trim().is_empty()
|| descriptor.display_name.to_lowercase().contains(search)
|| descriptor.type_name.to_lowercase().contains(search)
|| descriptor.description.to_lowercase().contains(search)
|| descriptor
.search_terms
.iter()
.any(|term| term.to_lowercase().contains(search))
}
fn component_add_state(
world: &World,
entity: Entity,
descriptor: &EditorComponentDescriptor,
descriptors: &[EditorComponentDescriptor],
) -> ComponentAddState {
let duplicate = component_present(world, entity, descriptor.type_name);
let conflicts = descriptor
.conflicts_with
.iter()
.copied()
.filter(|type_name| component_present(world, entity, type_name))
.collect::<Vec<_>>();
let recommended = descriptor
.recommended
.iter()
.copied()
.filter(|type_name| !component_present(world, entity, type_name))
.collect::<Vec<_>>();
let registry = world.resource::<EditorComponentRegistry>();
let required = registry
.required_component_ids(descriptor)
.iter()
.filter_map(|id| registry.by_id(id))
.filter(|required| !registry.component_present(world, entity, required.type_name))
.map(|required| required.display_name)
.collect::<Vec<_>>();
let reason = if duplicate {
Some("Already present on this actor.".to_string())
} else if !required.is_empty() {
Some(format!("Requires {}.", required.join(", ")))
} else if !conflicts.is_empty() {
Some(format!(
"Conflicts with {}.",
conflicts
.iter()
.map(|type_name| component_display_name(descriptors, type_name))
.collect::<Vec<_>>()
.join(", ")
))
} else {
None
};
ComponentAddState {
addable: reason.is_none(),
reason,
required,
recommended,
conflicts,
}
}
fn component_hover_text(
descriptor: &EditorComponentDescriptor,
state: &ComponentAddState,
) -> String {
let mut lines = vec![
descriptor.description.to_string(),
format!("Type: {}", descriptor.type_name),
format!("Hydration: {}", descriptor.hydration_effect),
format!(
"Inspector: removable={} reorderable={}",
descriptor.removable, descriptor.reorderable
),
];
if let Some(reason) = &state.reason {
lines.push(format!("Unavailable: {reason}"));
}
if !state.required.is_empty() {
lines.push(format!("Requires: {}", state.required.join(", ")));
}
if !state.recommended.is_empty() {
lines.push(format!(
"Recommended with: {}",
state.recommended.join(", ")
));
}
if !state.conflicts.is_empty() {
lines.push(format!("Conflicts: {}", state.conflicts.join(", ")));
}
lines.push(format!("Search: {}", descriptor.search_terms.join(", ")));
lines.join("\n")
}
fn component_display_name(
descriptors: &[EditorComponentDescriptor],
type_name: &str,
) -> &'static str {
descriptors
.iter()
.find(|descriptor| descriptor.type_name == type_name)
.map(|descriptor| descriptor.display_name)
.unwrap_or("component")
}
fn component_present(world: &World, entity: Entity, type_name: &str) -> bool {
if let Some(registry) = world.get_resource::<EditorComponentRegistry>() {
if registry.by_type_name(type_name).is_some() {
return registry.component_present(world, entity, type_name);
}
}
match type_name {
COMPONENT_ANIMATION_CONTROLLER_DESC => {
world.get::<AnimationControllerDesc>(entity).is_some()
}
"shared::components::Primitive" => world.get::<Primitive>(entity).is_some(),
"shared::components::BrushDesc" => world.get::<BrushDesc>(entity).is_some(),
"shared::components::StaticMeshRenderer" => {
world.get::<StaticMeshRenderer>(entity).is_some()
}
COMPONENT_SKINNED_MESH_RENDERER => world.get::<SkinnedMeshRenderer>(entity).is_some(),
"shared::components::MaterialDesc" => world.get::<MaterialDesc>(entity).is_some(),
"shared::components::LightDesc" => world.get::<LightDesc>(entity).is_some(),
"shared::components::AudioSourceDesc" => world.get::<AudioSourceDesc>(entity).is_some(),
"shared::components::AudioListenerDesc" => world.get::<AudioListenerDesc>(entity).is_some(),
"shared::components::RigidBodyDesc" => world.get::<RigidBodyDesc>(entity).is_some(),
"shared::components::ColliderDesc" => world.get::<ColliderDesc>(entity).is_some(),
"shared::components::PlayerSpawn" => world.get::<PlayerSpawn>(entity).is_some(),
"shared::components::WeaponSpawn" => world.get::<WeaponSpawn>(entity).is_some(),
"shared::components::TriggerVolume" => world.get::<TriggerVolume>(entity).is_some(),
"shared::components::TeamSpawn" => world.get::<TeamSpawn>(entity).is_some(),
"shared::components::ObjectiveMarker" => world.get::<ObjectiveMarker>(entity).is_some(),
"shared::components::PostProcessVolumeDesc" => {
world.get::<PostProcessVolumeDesc>(entity).is_some()
}
COMPONENT_NAVIGATION_BOUNDS => world.get::<NavigationBounds>(entity).is_some(),
COMPONENT_NAVIGATION_OBSTACLE => world.get::<NavigationObstacle>(entity).is_some(),
COMPONENT_NAVIGATION_AREA => world.get::<NavigationArea>(entity).is_some(),
COMPONENT_NAVIGATION_LINK => world.get::<NavigationLink>(entity).is_some(),
_ => false,
}
}
fn insert_registered_component(world: &mut World, entity: Entity, type_name: &str) {
let descriptor = world
.resource::<EditorComponentRegistry>()
.by_type_name(type_name)
.cloned();
if let Some(descriptor) = descriptor {
if !descriptor.addable || component_present(world, entity, descriptor.type_name) {
return;
}
if descriptor.type_name == COMPONENT_ANIMATION_CONTROLLER_DESC {
let controller =
crate::ui::animation_inspector::default_controller_for_actor(world, entity);
let _ = crate::history::reflected_component_transaction(
world,
entity,
"Add Component",
descriptor.id,
descriptor.type_name,
move |world, entity| {
world.entity_mut(entity).insert(controller);
Ok(())
},
);
} else {
let _ = crate::history::reflected_component_transaction(
world,
entity,
"Add Component",
descriptor.id,
descriptor.type_name,
move |world, entity| {
crate::history::apply_reflected_default(world, entity, descriptor.type_name)
},
);
}
return;
}
match type_name {
COMPONENT_ANIMATION_CONTROLLER_DESC => {
let controller =
crate::ui::animation_inspector::default_controller_for_actor(world, entity);
insert_component(world, entity, move |world, e| {
world.entity_mut(e).insert(controller);
});
}
"shared::components::Primitive" => insert_component(world, entity, |world, e| {
world
.entity_mut(e)
.insert((shared::ActorKind::StaticMesh, Primitive::cuboid(Vec3::ONE)));
}),
"shared::components::BrushDesc" => insert_component(world, entity, |world, e| {
world
.entity_mut(e)
.insert((shared::ActorKind::Brush, BrushDesc::default()));
}),
"shared::components::StaticMeshRenderer" => insert_component(world, entity, |world, e| {
world
.entity_mut(e)
.insert((shared::ActorKind::StaticMesh, StaticMeshRenderer::default()));
}),
"shared::components::MaterialDesc" => insert_component(world, entity, |world, e| {
world.entity_mut(e).insert(MaterialDesc::default());
}),
"shared::components::LightDesc" => insert_component(world, entity, |world, e| {
world.entity_mut(e).insert(LightDesc::default());
}),
"shared::components::AudioSourceDesc" => insert_component(world, entity, |world, e| {
world
.entity_mut(e)
.insert((shared::ActorKind::AudioSource, AudioSourceDesc::default()));
}),
"shared::components::AudioListenerDesc" => insert_component(world, entity, |world, e| {
world.entity_mut(e).insert((
shared::ActorKind::AudioListener,
AudioListenerDesc::default(),
));
}),
"shared::components::RigidBodyDesc" => insert_component(world, entity, |world, e| {
world.entity_mut(e).insert(RigidBodyDesc::default());
}),
"shared::components::ColliderDesc" => insert_component(world, entity, |world, e| {
world.entity_mut(e).insert(ColliderDesc::default());
}),
"shared::components::PlayerSpawn" => insert_component(world, entity, |world, e| {
world.entity_mut(e).insert(PlayerSpawn);
}),
"shared::components::WeaponSpawn" => insert_component(world, entity, |world, e| {
world.entity_mut(e).insert(WeaponSpawn {
weapon_id: "rifle".into(),
});
}),
"shared::components::TriggerVolume" => insert_component(world, entity, |world, e| {
world.entity_mut(e).insert(TriggerVolume::default());
}),
"shared::components::TeamSpawn" => insert_component(world, entity, |world, e| {
world.entity_mut(e).insert(TeamSpawn { team_id: 0 });
}),
"shared::components::ObjectiveMarker" => insert_component(world, entity, |world, e| {
world.entity_mut(e).insert(ObjectiveMarker {
objective_id: "objective".into(),
});
}),
COMPONENT_NAVIGATION_BOUNDS => insert_component(world, entity, |world, e| {
world
.entity_mut(e)
.insert((ActorKind::Navigation, NavigationBounds::default()));
}),
COMPONENT_NAVIGATION_OBSTACLE => insert_component(world, entity, |world, e| {
world
.entity_mut(e)
.insert((ActorKind::Navigation, NavigationObstacle::default()));
}),
COMPONENT_NAVIGATION_AREA => insert_component(world, entity, |world, e| {
world
.entity_mut(e)
.insert((ActorKind::Navigation, NavigationArea::default()));
}),
COMPONENT_NAVIGATION_LINK => insert_component(world, entity, |world, e| {
world
.entity_mut(e)
.insert((ActorKind::Navigation, NavigationLink::default()));
}),
"shared::components::PostProcessVolumeDesc" => {
insert_component(world, entity, |world, e| {
world.entity_mut(e).insert(PostProcessVolumeDesc::default());
})
}
_ => {}
}
}
fn insert_component(world: &mut World, entity: Entity, insert: impl FnOnce(&mut World, Entity)) {
let before = crate::history::snapshot_entity(world, entity);
let old_kind = before.as_ref().map(|s| s.actor_kind);
insert(world, entity);
let after = crate::history::snapshot_entity(world, entity);
if let (Some(before), Some(after)) = (before, after) {
if let Some(old) = old_kind {
if old != after.actor_kind {
crate::history::set_actor_kind_with_history(world, entity, old, after.actor_kind);
}
}
crate::history::push_command(
world,
crate::history::EditorCommand::AddComponent {
entity,
snapshot: diff_added(&before, &after),
},
);
}
}
fn diff_added(before: &EditorEntitySnapshot, after: &EditorEntitySnapshot) -> EditorEntitySnapshot {
EditorEntitySnapshot {
actor_id: None,
actor_kind: after.actor_kind,
actor_name: None,
name: None,
transform: after.transform,
primitive: after
.primitive
.clone()
.filter(|_| before.primitive.is_none()),
brush: after.brush.clone().filter(|_| before.brush.is_none()),
static_mesh_renderer: after
.static_mesh_renderer
.clone()
.filter(|_| before.static_mesh_renderer.is_none()),
skinned_mesh_renderer: after
.skinned_mesh_renderer
.clone()
.filter(|_| before.skinned_mesh_renderer.is_none()),
material: after.material.clone().filter(|_| before.material.is_none()),
material_override: after
.material_override
.clone()
.filter(|_| before.material_override.is_none()),
rigid_body: after.rigid_body.filter(|_| before.rigid_body.is_none()),
collider: after.collider.clone().filter(|_| before.collider.is_none()),
physics: after.physics.clone().filter(|_| before.physics.is_none()),
light: after.light.clone().filter(|_| before.light.is_none()),
animation_controller: after
.animation_controller
.clone()
.filter(|_| before.animation_controller.is_none()),
audio_source: after
.audio_source
.clone()
.filter(|_| before.audio_source.is_none()),
audio_listener: after
.audio_listener
.filter(|_| before.audio_listener.is_none()),
player_spawn: after.player_spawn && !before.player_spawn,
model: after.model.clone().filter(|_| before.model.is_none()),
prefab: after.prefab.clone().filter(|_| before.prefab.is_none()),
prefab_instance: after
.prefab_instance
.clone()
.filter(|_| before.prefab_instance.is_none()),
weapon_spawn: after
.weapon_spawn
.clone()
.filter(|_| before.weapon_spawn.is_none()),
trigger_volume: after
.trigger_volume
.clone()
.filter(|_| before.trigger_volume.is_none()),
post_process_volume: after
.post_process_volume
.clone()
.filter(|_| before.post_process_volume.is_none()),
team_spawn: after
.team_spawn
.clone()
.filter(|_| before.team_spawn.is_none()),
objective: after
.objective
.clone()
.filter(|_| before.objective.is_none()),
navigation_bounds: after
.navigation_bounds
.clone()
.filter(|_| before.navigation_bounds.is_none()),
navigation_obstacle: after
.navigation_obstacle
.clone()
.filter(|_| before.navigation_obstacle.is_none()),
navigation_area: after
.navigation_area
.clone()
.filter(|_| before.navigation_area.is_none()),
navigation_link: after
.navigation_link
.clone()
.filter(|_| before.navigation_link.is_none()),
hierarchy_sibling_index: after.hierarchy_sibling_index,
editor_visibility: after.editor_visibility,
inspector_order: None,
component_states: None,
children: Vec::new(),
}
}
fn static_mesh_asset_ref_candidates(
world: &mut World,
kind: AssetRefCandidateKind,
) -> Vec<AssetRefCandidate> {
let thumbnail_by_path = model_thumbnail_texture_by_path(world);
let Some(registry) = world.get_resource::<AssetRegistry>() else {
return Vec::new();
};
let catalog = world.get_resource::<EditorAssets>();
let mut seen = HashSet::new();
let mut candidates = Vec::new();
for record in &registry.records {
let Some(manifest_path) = record.import_settings.static_mesh_manifest_path.as_deref()
else {
continue;
};
let Ok(manifest) = load_static_mesh_manifest(manifest_path) else {
continue;
};
let selection = AssetSelection::File(manifest.source.path.clone());
let texture_id = thumbnail_by_path.get(&manifest.source.path).copied();
let folder_path = catalog
.and_then(|assets| {
assets
.assets
.iter()
.find(|asset| asset.path.as_deref() == Some(manifest.source.path.as_str()))
.map(|asset| asset.folder_path.clone())
})
.unwrap_or_else(|| fallback_asset_folder(&manifest.source.path));
for part in &manifest.parts {
let source_material_ref = source_material_ref_for_part(&manifest.asset_id, part);
let candidate = match kind {
AssetRefCandidateKind::Mesh => {
let sub_asset_id = if part.id.trim().is_empty() {
part_id_from_label(&part.mesh_label)
} else {
part.id.clone()
};
AssetRefCandidate {
reference: EditorAssetRef::new(
manifest.asset_id.clone(),
sub_asset_id,
part.name.clone(),
),
label: part.name.clone(),
detail: format!("{} | {}", manifest.label, manifest.source.path),
selection: selection.clone(),
folder_path: folder_path.clone(),
texture_id,
}
}
AssetRefCandidateKind::Material => {
let Some(material_ref) = source_material_ref else {
continue;
};
AssetRefCandidate {
reference: material_ref,
label: part.material_slot_name.clone(),
detail: format!("{} | {}", manifest.label, manifest.source.path),
selection: selection.clone(),
folder_path: folder_path.clone(),
texture_id: None,
}
}
AssetRefCandidateKind::Texture => continue,
};
let key = (
candidate.reference.asset_id.clone(),
candidate.reference.sub_asset_id.clone(),
);
if seen.insert(key) {
candidates.push(candidate);
}
}
}
candidates.sort_by(|a, b| a.detail.cmp(&b.detail).then(a.label.cmp(&b.label)));
candidates
}
fn texture_asset_candidates(world: &World) -> Vec<TextureAssetCandidate> {
let Some(catalog) = world.get_resource::<EditorAssets>() else {
return Vec::new();
};
let snapshot = world
.get_resource::<AssetThumbnailCache>()
.map(AssetThumbnailCache::snapshot);
let mut candidates: Vec<_> = catalog
.assets
.iter()
.filter(|asset| matches!(asset.kind, EditorAssetKind::Texture))
.filter_map(|asset| {
let path = asset.path.clone()?;
let texture_id = snapshot
.as_ref()
.and_then(|snapshot| snapshot.texture_for(asset));
Some(TextureAssetCandidate {
label: asset.label.clone(),
path: path.clone(),
folder_path: asset.folder_path.clone(),
selection: AssetSelection::File(path),
texture_id,
})
})
.collect();
candidates.sort_by(|a, b| a.label.cmp(&b.label).then(a.path.cmp(&b.path)));
candidates
}
fn texture_asset_ref_candidates(world: &World) -> Vec<AssetRefCandidate> {
let Some(catalog) = world.get_resource::<EditorAssets>() else {
return Vec::new();
};
let Some(registry) = world.get_resource::<AssetRegistry>() else {
return Vec::new();
};
let snapshot = world
.get_resource::<AssetThumbnailCache>()
.map(AssetThumbnailCache::snapshot);
let mut candidates: Vec<_> = catalog
.assets
.iter()
.filter(|asset| matches!(asset.kind, EditorAssetKind::Texture))
.filter_map(|asset| {
let path = asset.path.as_deref()?;
let record = find_asset_by_path(registry, path)?;
Some(AssetRefCandidate {
reference: EditorAssetRef::new(
record.id.as_string(),
"texture:source",
asset.label.clone(),
)
.with_source_path(path),
label: asset.label.clone(),
detail: path.to_string(),
selection: AssetSelection::File(path.to_string()),
folder_path: asset.folder_path.clone(),
texture_id: snapshot
.as_ref()
.and_then(|snapshot| snapshot.texture_for(asset)),
})
})
.collect();
candidates.sort_by(|a, b| a.label.cmp(&b.label).then(a.detail.cmp(&b.detail)));
candidates
}
fn brush_face_material_ref_candidates(world: &mut World) -> Vec<AssetRefCandidate> {
let mut candidates = static_mesh_asset_ref_candidates(world, AssetRefCandidateKind::Material);
let Some(catalog) = world.get_resource::<EditorAssets>() else {
return candidates;
};
let Some(registry) = world.get_resource::<AssetRegistry>() else {
return candidates;
};
let mut seen: HashSet<_> = candidates
.iter()
.map(|candidate| {
(
candidate.reference.asset_id.clone(),
candidate.reference.sub_asset_id.clone(),
)
})
.collect();
for asset in catalog
.assets
.iter()
.filter(|asset| matches!(asset.kind, EditorAssetKind::Material))
{
let Some(path) = asset.path.as_deref() else {
continue;
};
let Some(record) = find_asset_by_path(registry, path) else {
continue;
};
let sub_asset_id = if shared::MaterialInstanceAsset::load_from_path(path).is_ok() {
"material:instance"
} else {
"material:source"
};
let reference = EditorAssetRef::new(record.id.as_string(), sub_asset_id, &asset.label)
.with_source_path(path);
let key = (reference.asset_id.clone(), reference.sub_asset_id.clone());
if !seen.insert(key) {
continue;
}
candidates.push(AssetRefCandidate {
reference,
label: asset.label.clone(),
detail: path.to_string(),
selection: AssetSelection::File(path.to_string()),
folder_path: asset.folder_path.clone(),
texture_id: None,
});
}
candidates.sort_by(|a, b| a.label.cmp(&b.label).then(a.detail.cmp(&b.detail)));
candidates
}
fn asset_ref_candidate_from_selection(
world: &World,
selection: &AssetSelection,
kind: AssetRefCandidateKind,
) -> Option<AssetRefCandidate> {
let registry = world.get_resource::<AssetRegistry>()?;
match (kind, selection) {
(AssetRefCandidateKind::Material, AssetSelection::File(path)) => {
let asset = world
.get_resource::<EditorAssets>()?
.assets
.iter()
.find(|asset| {
matches!(asset.kind, EditorAssetKind::Material)
&& asset.path.as_deref() == Some(path.as_str())
})?;
let record = find_asset_by_path(registry, path)?;
let sub_asset_id = if shared::MaterialInstanceAsset::load_from_path(path).is_ok() {
"material:instance"
} else {
"material:source"
};
Some(AssetRefCandidate {
reference: EditorAssetRef::new(
record.id.as_string(),
sub_asset_id,
asset.label.clone(),
)
.with_source_path(path),
label: asset.label.clone(),
detail: path.clone(),
selection: selection.clone(),
folder_path: asset.folder_path.clone(),
texture_id: None,
})
}
(AssetRefCandidateKind::Texture, AssetSelection::File(path)) => {
let asset = world
.get_resource::<EditorAssets>()?
.assets
.iter()
.find(|asset| {
matches!(asset.kind, EditorAssetKind::Texture)
&& asset.path.as_deref() == Some(path.as_str())
})?;
let record = find_asset_by_path(registry, path)?;
Some(AssetRefCandidate {
reference: EditorAssetRef::new(
record.id.as_string(),
"texture:source",
asset.label.clone(),
)
.with_source_path(path),
label: asset.label.clone(),
detail: path.clone(),
selection: selection.clone(),
folder_path: asset.folder_path.clone(),
texture_id: None,
})
}
(
AssetRefCandidateKind::Material,
AssetSelection::SubAsset {
parent_path,
sub_asset_id,
label,
kind: AssetSubAssetKind::Material,
..
},
) => {
let record = find_asset_by_path(registry, parent_path)?;
Some(AssetRefCandidate {
reference: EditorAssetRef::new(
record.id.as_string(),
sub_asset_id.clone(),
label.clone(),
),
label: label.clone(),
detail: parent_path.clone(),
selection: selection.clone(),
folder_path: fallback_asset_folder(parent_path),
texture_id: None,
})
}
(
AssetRefCandidateKind::Texture,
AssetSelection::SubAsset {
parent_path,
sub_asset_id,
label,
kind: AssetSubAssetKind::Texture,
source_path,
},
) => {
let record = find_asset_by_path(registry, parent_path)?;
Some(AssetRefCandidate {
reference: EditorAssetRef::new(
record.id.as_string(),
sub_asset_id.clone(),
label.clone(),
)
.with_source_path(source_path.clone().unwrap_or_else(|| parent_path.clone())),
label: label.clone(),
detail: source_path.clone().unwrap_or_else(|| parent_path.clone()),
selection: selection.clone(),
folder_path: fallback_asset_folder(parent_path),
texture_id: None,
})
}
_ => None,
}
}
fn request_texture_asset_thumbnails(world: &mut World) {
let requests: Vec<_> = world
.get_resource::<EditorAssets>()
.map(|assets| {
assets
.assets
.iter()
.filter(|asset| matches!(asset.kind, EditorAssetKind::Texture))
.filter_map(|asset| Some((asset_cache_key(asset), asset.path.clone()?)))
.collect()
})
.unwrap_or_default();
if requests.is_empty() || world.get_resource::<AssetThumbnailCache>().is_none() {
return;
}
let asset_server = world.resource::<AssetServer>().clone();
world.resource_scope(|_world, mut cache: Mut<AssetThumbnailCache>| {
for (key, path) in requests {
cache.request_texture(key, path, &asset_server);
}
});
}
fn texture_path_from_selection(
world: &World,
selection: &AssetSelection,
) -> Option<TextureAssetCandidate> {
match selection {
AssetSelection::File(path) => {
let asset = world
.get_resource::<EditorAssets>()?
.assets
.iter()
.find(|asset| {
matches!(asset.kind, EditorAssetKind::Texture)
&& asset.path.as_deref() == Some(path.as_str())
})?;
Some(TextureAssetCandidate {
label: asset.label.clone(),
path: path.clone(),
folder_path: asset.folder_path.clone(),
selection: selection.clone(),
texture_id: None,
})
}
AssetSelection::SubAsset {
label,
kind: AssetSubAssetKind::Texture,
source_path: Some(path),
..
} => {
if let Some(asset) = world.get_resource::<EditorAssets>().and_then(|assets| {
assets
.assets
.iter()
.find(|asset| asset.path.as_deref() == Some(path.as_str()))
}) {
return Some(TextureAssetCandidate {
label: asset.label.clone(),
path: path.clone(),
folder_path: asset.folder_path.clone(),
selection: selection.clone(),
texture_id: None,
});
}
Some(TextureAssetCandidate {
label: label.clone(),
path: path.clone(),
folder_path: fallback_asset_folder(path),
selection: selection.clone(),
texture_id: None,
})
}
_ => None,
}
}
fn source_material_ref_for_part(
asset_id: &str,
part: &crate::assets::static_mesh::StaticMeshPart,
) -> Option<EditorAssetRef> {
let material_label = part.material_label.as_deref()?;
let sub_asset_id = part
.material_id
.clone()
.filter(|id| !id.trim().is_empty())
.unwrap_or_else(|| material_id_from_label(material_label));
Some(EditorAssetRef::new(
asset_id.to_string(),
sub_asset_id,
part.material_slot_name.clone(),
))
}
fn model_thumbnail_texture_by_path(world: &mut World) -> HashMap<String, egui::TextureId> {
let model_assets: Vec<EditorAsset> = world
.get_resource::<EditorAssets>()
.map(|assets| {
assets
.assets
.iter()
.filter(|asset| matches!(asset.kind, EditorAssetKind::Model))
.cloned()
.collect()
})
.unwrap_or_default();
if model_assets.is_empty() {
return HashMap::new();
}
if world.get_resource::<AssetThumbnailCache>().is_none() {
return HashMap::new();
}
let asset_server = world.resource::<AssetServer>().clone();
world.resource_scope(|world, mut cache: Mut<AssetThumbnailCache>| {
if let Some(mut studio) = world.get_resource_mut::<ThumbnailStudio>() {
for asset in &model_assets {
let Some(path) = asset.path.as_ref() else {
continue;
};
cache.request_model(
asset_cache_key(asset),
path.clone(),
&asset_server,
&mut studio,
);
}
}
});
let snapshot = world.resource::<AssetThumbnailCache>().snapshot();
model_assets
.iter()
.filter_map(|asset| {
Some((
asset.path.clone()?,
snapshot.texture_ids.get(&asset_cache_key(asset)).copied()?,
))
})
.collect()
}
fn fallback_asset_folder(path: &str) -> String {
Path::new(path)
.parent()
.and_then(|parent| parent.to_str())
.filter(|folder| !folder.trim().is_empty())
.unwrap_or(crate::assets::ASSETS_ROOT)
.replace('\\', "/")
}
fn locate_asset_ref(
world: &mut World,
asset: Option<&EditorAssetRef>,
candidates: &[AssetRefCandidate],
) {
let Some(asset) = asset.filter(|asset| asset.is_resolved()) else {
return;
};
let Some(candidate) = candidates.iter().find(|candidate| {
candidate.reference.asset_id == asset.asset_id
&& candidate.reference.sub_asset_id == asset.sub_asset_id
}) else {
if let Some(mut scene_io) = world.get_resource_mut::<crate::scene_io::SceneIo>() {
scene_io.status = format!("Could not locate imported asset {}", asset.label);
}
return;
};
if let Some(mut assets) = world.get_resource_mut::<EditorAssets>() {
assets.current_folder = candidate.folder_path.clone();
assets.select(candidate.selection.clone());
}
if let Some(mut ui_state) = world.get_resource_mut::<UiState>() {
let panel_nodes = ui_state.panel_nodes;
open_and_focus_tab(
&mut ui_state.dock_state,
EditorTab::AssetBrowser,
&panel_nodes,
);
}
}
fn skinned_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let material_candidates = brush_face_material_ref_candidates(world);
let Some(mut renderer) = world.get::<SkinnedMeshRenderer>(entity).cloned() else {
return;
};
let original = renderer.clone();
let mut changed = 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;
let context = component_card_context(world, entity, options);
let response = component_card(ui, &context, |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() {
"Legacy/path-only"
} else {
renderer.asset_id.as_str()
})
.truncate(),
);
});
ui.label(
egui::RichText::new(
"Preserves the imported skeleton hierarchy and Bevy skinned-mesh bindings.",
)
.small()
.color(TEXT_DIM),
);
ui.add_space(8.0);
ui.label(egui::RichText::new("Material slots").strong());
if renderer.materials.slots.is_empty() {
ui.label(
egui::RichText::new("No imported slots; reimport the source model")
.small()
.color(TEXT_DIM),
);
}
for slot in &mut renderer.materials.slots {
slot_card(ui, |ui| {
ui.label(egui::RichText::new(&slot.name).strong());
let response = asset_selector_row(
ui,
"Material",
icons::PALETTE,
slot.material.as_ref().map(|reference| &reference.0),
slot.source_material.as_ref().map(|reference| &reference.0),
true,
&material_candidates,
None,
);
if let Some(selected) = response.selected {
slot.material = Some(MaterialRef::new(selected));
changed = true;
}
if response.clear {
slot.material = None;
changed = true;
}
if response.locate {
let reference = slot
.material
.as_ref()
.or(slot.source_material.as_ref())
.map(|reference| &reference.0);
locate_asset_ref(world, reference, &material_candidates);
}
ui.label(
egui::RichText::new(format!("ID: {}", slot.id.0))
.small()
.color(TEXT_DIM),
);
});
}
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 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;
}
}
}
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 Some(mut renderer) = world.get::<StaticMeshRenderer>(entity).cloned() else {
return;
};
let original = renderer.clone();
let mut changed = false;
let mut remove_entry = 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::RendererMaterialSlot {
id: part.material_slot_id.clone(),
name: part.name.clone(),
source_material: part.material.clone().map(MaterialRef::new),
material: None,
});
}
}
let card = component_card_context(
world,
entity,
ComponentCardOptions::removable(
COMPONENT_STATIC_MESH_RENDERER,
"Static Mesh Renderer",
icons::CUBE,
),
);
let card_response = component_card(ui, &card, |ui| {
if renderer.slots.is_empty() {
ui.label(egui::RichText::new("No renderer slots").color(TEXT_DIM));
}
for (index, entry) in renderer.slots.iter_mut().enumerate() {
ensure_slot_id(entry, index);
let material_slot_name = renderer
.materials
.slot(&entry.material_slot_id)
.map(|slot| slot.name.clone())
.unwrap_or_else(|| "Missing slot".into());
let thumbnail = thumbnail_for_mesh(&entry.mesh, &mesh_candidates);
slot_card(ui, |ui| {
slot_header(ui, index, &entry.name, |ui| {
status_dot(ui, egui::Color32::from_rgb(74, 181, 104), "Slot active");
if icon_button_small(ui, icons::TRASH, "Remove slot").clicked() {
remove_entry = Some(index);
}
ui.label(phosphor_icon(icons::DOTS_THREE_VERTICAL, 16.0).color(TEXT_DIM));
});
ui.add_space(6.0);
let mut draw_fields = |ui: &mut egui::Ui, entry: &mut StaticMeshRendererEntry| {
property_row(ui, "Name", |ui| {
changed |= ui
.add_sized(
[text_field_width(ui), 20.0],
egui::TextEdit::singleline(&mut entry.name),
)
.changed();
});
let mesh_response = asset_selector_row(
ui,
"Mesh",
icons::CUBE,
Some(&entry.mesh),
None,
false,
&mesh_candidates,
None,
);
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);
}
property_row(ui, "Material slot", |ui| {
ui.label(&material_slot_name);
});
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();
});
};
if ui.available_width() < 430.0 {
slot_thumbnail(ui, thumbnail);
ui.add_space(6.0);
draw_fields(ui, entry);
} else {
ui.horizontal(|ui| {
slot_thumbnail(ui, thumbnail);
ui.add_space(10.0);
ui.vertical(|ui| {
ui.set_max_width(ui.available_width());
draw_fields(ui, entry);
});
});
}
});
}
ui.add_space(8.0);
ui.label(egui::RichText::new("Material slots").strong());
for slot in &mut renderer.materials.slots {
slot_card(ui, |ui| {
ui.label(egui::RichText::new(&slot.name).strong());
let response = asset_selector_row(
ui,
"Material",
icons::PALETTE,
slot.material.as_ref().map(|reference| &reference.0),
slot.source_material.as_ref().map(|reference| &reference.0),
true,
&material_candidates,
None,
);
if let Some(selected) = response.selected {
slot.material = Some(MaterialRef::new(selected));
changed = true;
}
if response.clear {
slot.material = None;
changed = true;
}
if response.locate {
let reference = slot
.material
.as_ref()
.or(slot.source_material.as_ref())
.map(|reference| &reference.0);
locate_asset_ref(world, reference, &material_candidates);
}
ui.label(
egui::RichText::new(format!("ID: {}", slot.id.0))
.small()
.color(TEXT_DIM),
);
});
}
let add_slot = ui
.add_sized(
[ui.available_width().max(1.0), 28.0],
egui::Button::new("+ Add Slot").fill(ELEVATED_BG.linear_multiply(0.45)),
)
.clicked();
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::RendererMaterialSlot {
id: entry.material_slot_id.clone(),
name: format!("Material {index}"),
source_material: None,
material: None,
});
renderer.slots.push(entry);
changed = true;
}
});
apply_component_card_response(world, entity, card_response);
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);
}
}
fn ensure_slot_id(entry: &mut StaticMeshRendererEntry, index: usize) {
if entry.id.is_empty() {
entry.id = ComponentInstanceId::new(format!("slot:{index}"));
}
if entry.material_slot_id.is_empty() {
entry.material_slot_id = ComponentInstanceId::new(format!("slot:{}", entry.id.0));
}
}
fn slot_card(ui: &mut egui::Ui, add_contents: impl FnOnce(&mut egui::Ui)) {
egui::Frame::new()
.fill(WIDGET_BG.linear_multiply(0.82))
.stroke(egui::Stroke::new(1.0, BORDER))
.corner_radius(egui::CornerRadius::same(4))
.inner_margin(egui::Margin::symmetric(8, 8))
.show(ui, |ui| {
ui.set_max_width(ui.available_width());
add_contents(ui);
});
ui.add_space(4.0);
}
fn slot_header(
ui: &mut egui::Ui,
index: usize,
slot_name: &str,
add_actions: impl FnOnce(&mut egui::Ui),
) {
ui.horizontal(|ui| {
ui.label(phosphor_icon(icons::CARET_DOWN, 13.0).color(TEXT_DIM));
ui.label(format!("Slot {index}"));
let badge = if slot_name.trim().is_empty() {
"Mesh"
} else {
slot_name.trim()
};
ui.add(
egui::Button::new(
egui::RichText::new(badge).color(egui::Color32::from_rgb(153, 202, 255)),
)
.fill(SELECTION_BG_MUTED)
.stroke(egui::Stroke::new(1.0, SELECTION_BG_MUTED))
.corner_radius(4.0),
);
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
add_actions,
);
});
}
fn status_dot(ui: &mut egui::Ui, color: egui::Color32, tooltip: &str) {
let (rect, response) = ui.allocate_exact_size(egui::vec2(18.0, 20.0), egui::Sense::hover());
ui.painter().circle_filled(rect.center(), 4.0, color);
response.on_hover_text(tooltip);
}
fn status_dot_button(ui: &mut egui::Ui, color: egui::Color32, tooltip: &str) -> egui::Response {
let (rect, response) = ui.allocate_exact_size(egui::vec2(18.0, 20.0), egui::Sense::click());
let color = if response.hovered() {
color.linear_multiply(1.2)
} else {
color
};
ui.painter().circle_filled(rect.center(), 4.0, color);
response.on_hover_text(tooltip)
}
fn slot_thumbnail(ui: &mut egui::Ui, texture_id: Option<egui::TextureId>) {
let size = 86.0_f32.min(ui.available_width().max(1.0));
let (rect, _response) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::hover());
ui.painter().rect(
rect,
4.0,
PANEL_BG_DARK,
egui::Stroke::new(1.0, BORDER),
egui::StrokeKind::Inside,
);
if let Some(texture_id) = texture_id {
let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
ui.painter()
.image(texture_id, rect.shrink(6.0), uv, egui::Color32::WHITE);
} else {
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
icons::CUBE.as_str(),
egui::FontId::new(28.0, egui::FontFamily::Name("phosphor-regular".into())),
TEXT_MUTED,
);
}
}
fn thumbnail_for_mesh(
mesh: &EditorAssetRef,
candidates: &[AssetRefCandidate],
) -> Option<egui::TextureId> {
candidates
.iter()
.find(|candidate| {
candidate.reference.asset_id == mesh.asset_id
&& candidate.reference.sub_asset_id == mesh.sub_asset_id
})
.and_then(|candidate| candidate.texture_id)
}
#[expect(
clippy::too_many_arguments,
reason = "asset selector rows keep immediate-mode UI inputs explicit"
)]
fn asset_selector_row(
ui: &mut egui::Ui,
label: &str,
icon: Icon,
asset: Option<&EditorAssetRef>,
inherited_asset: Option<&EditorAssetRef>,
clearable: bool,
candidates: &[AssetRefCandidate],
drop_candidate: Option<&AssetRefCandidate>,
) -> AssetSelectorResponse {
let mut response = AssetSelectorResponse::default();
if ui.available_width() < COMPACT_INSPECTOR_WIDTH {
ui.vertical(|ui| {
ui.label(label);
let control_width = ui.available_width().max(1.0);
asset_selector_control(
ui,
icon,
asset,
inherited_asset,
clearable,
control_width,
candidates,
drop_candidate,
&mut response,
);
});
} else {
ui.horizontal(|ui| {
let row_width = ui.available_width().max(1.0);
let label_width = ASSET_SELECTOR_LABEL_WIDTH.min(row_width);
let control_width = (row_width - label_width - ui.spacing().item_spacing.x).max(1.0);
ui.add_sized([label_width, 20.0], egui::Label::new(label));
asset_selector_control(
ui,
icon,
asset,
inherited_asset,
clearable,
control_width,
candidates,
drop_candidate,
&mut response,
);
});
}
response
}
#[expect(
clippy::too_many_arguments,
reason = "asset selector controls keep immediate-mode UI inputs explicit"
)]
fn asset_selector_control(
ui: &mut egui::Ui,
icon: Icon,
asset: Option<&EditorAssetRef>,
inherited_asset: Option<&EditorAssetRef>,
clearable: bool,
control_width: f32,
candidates: &[AssetRefCandidate],
drop_candidate: Option<&AssetRefCandidate>,
response: &mut AssetSelectorResponse,
) {
let display_asset = asset.or(inherited_asset);
let inherited = asset.is_none() && inherited_asset.is_some();
let selector_width = control_width.min(ui.available_width()).max(1.0);
exact_region(
ui,
egui::vec2(selector_width, ASSET_SELECTOR_HEIGHT),
egui::Layout::top_down(egui::Align::Min),
|ui| {
ui.set_clip_rect(ui.max_rect());
let rect = ui.max_rect();
let valid_drag = drop_candidate.is_some();
let drop_hovered = valid_drag && ui.rect_contains_pointer(rect);
let stroke = if drop_hovered {
egui::Stroke::new(2.0, egui::Color32::from_rgb(125, 198, 255))
} else if valid_drag {
egui::Stroke::new(1.0, egui::Color32::from_rgb(58, 88, 122))
} else {
egui::Stroke::new(1.0, BORDER)
};
let fill = if drop_hovered {
egui::Color32::from_rgb(29, 57, 86)
} else {
WIDGET_BG.linear_multiply(0.75)
};
let inner_width = (selector_width - 18.0).max(1.0);
egui::Frame::new()
.fill(fill)
.stroke(stroke)
.corner_radius(egui::CornerRadius::same(4))
.inner_margin(egui::Margin::symmetric(8, 6))
.show(ui, |ui| {
ui.set_width(inner_width);
ui.horizontal(|ui| {
ui.add_sized(
[18.0, 20.0],
egui::Label::new(phosphor_icon(icon, 14.0).color(TEXT_DIM)),
);
let action_width = if clearable { 78.0 } else { 52.0 };
let text_width = (ui.available_width() - action_width)
.max(1.0)
.min(ui.available_width().max(1.0));
ui.vertical(|ui| {
ui.set_max_width(text_width);
let name = display_asset
.map(|asset| asset.label.as_str())
.filter(|label| !label.trim().is_empty())
.unwrap_or("(none)");
ui.add(egui::Label::new(name).truncate());
let id = display_asset
.map(|asset| {
if inherited {
format!("Source default | {}", asset.sub_asset_id)
} else {
asset.sub_asset_id.clone()
}
})
.filter(|id| !id.trim().is_empty())
.unwrap_or_else(|| "No imported asset selected".to_string());
ui.add(
egui::Label::new(egui::RichText::new(id).color(TEXT_DIM))
.truncate(),
);
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if clearable {
let clear = ui.add_enabled(
asset.is_some(),
egui::Button::new(phosphor_icon(icons::X, 16.0))
.frame(false)
.min_size(egui::vec2(22.0, 22.0)),
);
if clear.on_hover_text("Clear actor override").clicked() {
response.clear = true;
}
}
let locate = ui.add_enabled(
display_asset.is_some_and(EditorAssetRef::is_resolved),
egui::Button::new(phosphor_icon(icons::CROSSHAIR, 16.0))
.frame(false)
.min_size(egui::vec2(22.0, 22.0)),
);
if locate.on_hover_text("Locate in content browser").clicked() {
response.locate = true;
}
if candidates.is_empty() {
ui.add_enabled(
false,
egui::Button::new(phosphor_icon(icons::FOLDER_OPEN, 16.0))
.frame(false)
.min_size(egui::vec2(22.0, 22.0)),
)
.on_hover_text("No imported assets available");
} else {
let menu =
ui.menu_button(phosphor_icon(icons::FOLDER_OPEN, 16.0), |ui| {
ui.set_min_width(220.0);
for candidate in candidates {
let selected = display_asset
.is_some_and(|asset| asset == &candidate.reference);
let clicked = ui
.selectable_label(
selected,
candidate.label.as_str(),
)
.on_hover_text(candidate.detail.as_str())
.clicked();
if clicked {
response.selected =
Some(candidate.reference.clone());
ui.close();
}
}
});
menu.response.on_hover_text("Browse assets");
}
});
});
});
if drop_hovered && ui.input(|input| input.pointer.any_released()) {
if let Some(candidate) = drop_candidate {
response.selected = Some(candidate.reference.clone());
response.accepted_drop = true;
}
}
},
);
}
fn material_shader_ui(ui: &mut egui::Ui, material: &mut MaterialDesc) -> bool {
let mut changed = false;
property_row(ui, "Shader", |ui| {
egui::ComboBox::from_id_salt("material_shader_kind")
.selected_text(match material.shader.kind {
MaterialShaderKind::StandardLit => "Standard Lit",
MaterialShaderKind::Unlit => "Unlit",
MaterialShaderKind::Custom => "Custom",
})
.show_ui(ui, |ui| {
changed |= ui
.selectable_value(
&mut material.shader.kind,
MaterialShaderKind::StandardLit,
"Standard Lit",
)
.changed();
changed |= ui
.selectable_value(
&mut material.shader.kind,
MaterialShaderKind::Unlit,
"Unlit",
)
.changed();
changed |= ui
.selectable_value(
&mut material.shader.kind,
MaterialShaderKind::Custom,
"Custom",
)
.changed();
});
});
if matches!(material.shader.kind, MaterialShaderKind::Custom) {
changed |= option_string_ui(ui, "Shader schema", &mut material.shader.schema_path);
changed |= option_string_ui(ui, "WGSL shader", &mut material.shader.shader_path);
}
if !material.parameters.is_empty() {
ui.label(panel_heading("Shader Parameters"));
for parameter in &mut material.parameters {
changed |= material_parameter_ui(ui, parameter);
}
}
changed
}
fn material_parameter_ui(ui: &mut egui::Ui, parameter: &mut MaterialParameter) -> bool {
ui.horizontal_wrapped(|ui| {
ui.label(&parameter.name);
match &mut parameter.value {
MaterialParameterValue::Bool(value) => ui.checkbox(value, "").changed(),
MaterialParameterValue::Float(value) => ui
.add_sized(
[fit_width(ui, 64.0, 120.0), 20.0],
egui::DragValue::new(value)
.speed(0.01)
.min_decimals(2)
.max_decimals(4),
)
.changed(),
MaterialParameterValue::Vec2(value) => {
let mut changed = false;
changed |= ui
.add_sized(
[fit_width(ui, 52.0, 88.0), 20.0],
egui::DragValue::new(&mut value.x).speed(0.01),
)
.changed();
changed |= ui
.add_sized(
[fit_width(ui, 52.0, 88.0), 20.0],
egui::DragValue::new(&mut value.y).speed(0.01),
)
.changed();
changed
}
MaterialParameterValue::Vec3(value) => {
let mut changed = false;
changed |= ui
.add_sized(
[fit_width(ui, 52.0, 88.0), 20.0],
egui::DragValue::new(&mut value.x).speed(0.01),
)
.changed();
changed |= ui
.add_sized(
[fit_width(ui, 52.0, 88.0), 20.0],
egui::DragValue::new(&mut value.y).speed(0.01),
)
.changed();
changed |= ui
.add_sized(
[fit_width(ui, 52.0, 88.0), 20.0],
egui::DragValue::new(&mut value.z).speed(0.01),
)
.changed();
changed
}
MaterialParameterValue::Color(value) => {
let mut rgba = [value.r, value.g, value.b, value.a];
let changed = ui.color_edit_button_rgba_unmultiplied(&mut rgba).changed();
if changed {
*value = ColorDesc {
r: rgba[0],
g: rgba[1],
b: rgba[2],
a: rgba[3],
};
}
changed
}
MaterialParameterValue::Enum(value) => ui
.add_sized(
[
fit_width(ui, MIN_INLINE_CONTROL_WIDTH, TEXT_FIELD_MAX_WIDTH),
20.0,
],
egui::TextEdit::singleline(value),
)
.changed(),
}
})
.inner
}
pub(crate) fn component_card(
ui: &mut egui::Ui,
context: &ComponentCardContext,
add_contents: impl FnOnce(&mut egui::Ui),
) -> ComponentCardResponse {
let mut response = ComponentCardResponse {
type_name: context.options.type_name,
..Default::default()
};
egui::Frame::new()
.fill(WIDGET_BG)
.stroke(egui::Stroke::new(1.0, BORDER))
.corner_radius(egui::CornerRadius::same(4))
.inner_margin(egui::Margin::symmetric(8, 8))
.show(ui, |ui| {
ui.horizontal(|ui| {
let caret = if context.collapsed {
icons::CARET_RIGHT
} else {
icons::CARET_DOWN
};
if ui
.add(
egui::Button::new(phosphor_icon(caret, 13.0).color(TEXT_DIM))
.frame(false)
.min_size(egui::vec2(20.0, 20.0)),
)
.on_hover_text(if context.collapsed {
"Expand component"
} else {
"Collapse component"
})
.clicked()
{
response.collapsed = Some(!context.collapsed);
}
ui.label(phosphor_icon(context.options.icon, 16.0).color(TEXT_DIM));
ui.label(panel_heading(context.options.title));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let menu = ui.menu_button(
phosphor_icon(icons::DOTS_THREE_VERTICAL, 16.0).color(TEXT_DIM),
|ui| {
ui.set_min_width(160.0);
if ui
.add_enabled(context.options.resettable, egui::Button::new("Reset"))
.clicked()
{
response.reset = true;
ui.close();
}
if ui
.add_enabled(
context.options.copyable,
egui::Button::new("Copy Values"),
)
.clicked()
{
response.copy = true;
ui.close();
}
if ui
.add_enabled(context.pasteable, egui::Button::new("Paste Values"))
.clicked()
{
response.paste = true;
ui.close();
}
ui.separator();
if ui
.add_enabled(context.can_move_up, egui::Button::new("Move Up"))
.clicked()
{
response.move_up = true;
ui.close();
}
if ui
.add_enabled(context.can_move_down, egui::Button::new("Move Down"))
.clicked()
{
response.move_down = true;
ui.close();
}
ui.separator();
ui.add_enabled(false, egui::Button::new("Open Documentation"));
if ui
.add_enabled(
context.options.removable,
egui::Button::new(
egui::RichText::new("Remove")
.color(egui::Color32::from_rgb(255, 137, 129)),
),
)
.clicked()
{
response.remove = true;
ui.close();
}
},
);
menu.response.on_hover_text("Component actions");
if context.options.active_toggle {
if status_dot_button(
ui,
if context.active {
egui::Color32::from_rgb(74, 181, 104)
} else {
TEXT_MUTED
},
if context.active {
"Component active. Click to disable."
} else {
"Component disabled. Click to enable."
},
)
.clicked()
{
response.active = Some(!context.active);
}
} else {
status_dot(
ui,
egui::Color32::from_rgb(74, 181, 104),
"Component active",
);
}
});
});
if !context.collapsed {
ui.add_space(4.0);
egui::Frame::new()
.fill(ELEVATED_BG.linear_multiply(0.35))
.corner_radius(egui::CornerRadius::same(4))
.inner_margin(egui::Margin::symmetric(8, 8))
.show(ui, |ui| {
ui.set_clip_rect(ui.max_rect());
ui.set_max_width(ui.available_width());
add_contents(ui);
});
}
});
ui.add_space(6.0);
response
}
pub(crate) fn property_row<R>(
ui: &mut egui::Ui,
label: &str,
add_contents: impl FnOnce(&mut egui::Ui) -> R,
) -> R {
if ui.available_width() < COMPACT_INSPECTOR_WIDTH {
ui.vertical(|ui| {
ui.label(label);
add_contents(ui)
})
.inner
} else {
ui.horizontal(|ui| {
let label_width = PROPERTY_LABEL_WIDTH.min(ui.available_width().max(1.0));
ui.add_sized([label_width, 20.0], egui::Label::new(label));
add_contents(ui)
})
.inner
}
}
pub(crate) fn text_field_width(ui: &egui::Ui) -> f32 {
fit_width(ui, MIN_INLINE_CONTROL_WIDTH, TEXT_FIELD_MAX_WIDTH).min(ui.available_width().max(1.0))
}
pub fn material_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let mut material = world
.get::<MaterialDesc>(entity)
.cloned()
.unwrap_or_default();
request_texture_asset_thumbnails(world);
let texture_candidates = texture_asset_candidates(world);
let original = material.clone();
let mut changed = false;
let mut options = ComponentCardOptions::removable(
COMPONENT_MATERIAL_DESC,
"Authoring Material",
icons::PALETTE,
);
options.removable = world.get::<MaterialDesc>(entity).is_some();
options.copyable = options.removable;
let card = component_card_context(world, entity, options);
let card_response = component_card(ui, &card, |ui| {
changed |= material_shader_ui(ui, &mut material);
let mut color = [
material.base_color.r,
material.base_color.g,
material.base_color.b,
material.base_color.a,
];
property_row(ui, "Base color", |ui| {
if ui.color_edit_button_rgba_unmultiplied(&mut color).changed() {
material.base_color = ColorDesc {
r: color[0],
g: color[1],
b: color[2],
a: color[3],
};
changed = true;
}
});
property_row(ui, "Metallic", |ui| {
changed |= ui
.add(egui::Slider::new(&mut material.metallic, 0.0..=1.0))
.changed();
});
property_row(ui, "Roughness", |ui| {
changed |= ui
.add(egui::Slider::new(&mut material.roughness, 0.0..=1.0))
.changed();
});
let mut emissive_color = [
material.emissive_color.r,
material.emissive_color.g,
material.emissive_color.b,
material.emissive_color.a,
];
property_row(ui, "Emissive", |ui| {
if ui
.color_edit_button_rgba_unmultiplied(&mut emissive_color)
.changed()
{
material.emissive_color = ColorDesc {
r: emissive_color[0],
g: emissive_color[1],
b: emissive_color[2],
a: emissive_color[3],
};
changed = true;
}
});
property_row(ui, "Emissive nits", |ui| {
changed |= ui
.add(egui::Slider::new(
&mut material.emissive_intensity,
0.0..=20_000.0,
))
.changed();
});
changed |= texture_asset_picker_ui(
world,
ui,
"Base color texture",
&mut material.base_color_texture,
&texture_candidates,
);
changed |= texture_asset_picker_ui(
world,
ui,
"Emissive texture",
&mut material.emissive_texture,
&texture_candidates,
);
changed |= texture_asset_picker_ui(
world,
ui,
"Normal map",
&mut material.normal_map_texture,
&texture_candidates,
);
changed |= texture_asset_picker_ui(
world,
ui,
"Metallic/roughness texture",
&mut material.metallic_roughness_texture,
&texture_candidates,
);
if crate::assets::materials::material_asset_picker_ui(
world,
ui,
entity,
&mut material,
&original,
) {
changed = true;
}
});
apply_component_card_response(world, entity, card_response);
if changed && !material_eq(&original, &material) {
set_material_with_history(world, entity, material);
}
}
fn brush_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let material_candidates = brush_face_material_ref_candidates(world);
request_texture_asset_thumbnails(world);
let texture_candidates = texture_asset_ref_candidates(world);
let Some(mut brush) = world.get::<BrushDesc>(entity).cloned() else {
return;
};
let original = brush.clone();
let mut changed = false;
let mut reset_cube = false;
let card = component_card_context(
world,
entity,
ComponentCardOptions::removable(COMPONENT_BRUSH_DESC, "Brush", icons::CUBE),
);
let card_response = component_card(ui, &card, |ui| {
property_row(ui, "Kind", |ui| {
egui::ComboBox::from_id_salt("brush_kind")
.selected_text(match brush.kind {
BrushKind::Additive => "Additive",
BrushKind::SubtractiveMarker => "Subtractive Marker",
})
.show_ui(ui, |ui| {
changed |= ui
.selectable_value(&mut brush.kind, BrushKind::Additive, "Additive")
.changed();
changed |= ui
.selectable_value(
&mut brush.kind,
BrushKind::SubtractiveMarker,
"Subtractive Marker",
)
.changed();
});
});
property_row(ui, "Faces", |ui| {
ui.label(format!("{}", brush.faces.len()));
});
property_row(ui, "Shadows", |ui| {
ui.horizontal_wrapped(|ui| {
changed |= ui.checkbox(&mut brush.cast_shadows, "Cast").changed();
changed |= ui.checkbox(&mut brush.receive_shadows, "Receive").changed();
});
});
brush_validation_ui(ui, &brush);
changed |= selected_brush_face_controls(
world,
ui,
entity,
&mut brush,
&material_candidates,
&texture_candidates,
);
if ui.button("Reset Cube Brush").clicked() {
reset_cube = true;
}
});
apply_component_card_response(world, entity, card_response);
if reset_cube {
brush = BrushDesc::default();
changed = true;
}
if changed && brush != original {
set_brush_with_history(world, entity, brush);
}
}
fn brush_validation_ui(ui: &mut egui::Ui, brush: &BrushDesc) {
let report = validate_brush(brush);
if report.diagnostics.is_empty() {
return;
}
ui.add_space(6.0);
egui::Frame::new()
.fill(egui::Color32::from_rgb(31, 32, 36))
.stroke(egui::Stroke::new(1.0, BORDER))
.inner_margin(egui::Margin::symmetric(8, 6))
.show(ui, |ui| {
let has_errors = !report.is_valid();
let title_color = if has_errors {
egui::Color32::from_rgb(255, 137, 129)
} else {
egui::Color32::from_rgb(255, 190, 110)
};
ui.horizontal(|ui| {
let icon = if has_errors {
icons::WARNING
} else {
icons::INFO
};
ui.label(phosphor_icon(icon, 14.0).color(title_color));
ui.label(
egui::RichText::new(if has_errors {
"Brush geometry needs repair"
} else {
"Brush diagnostics"
})
.color(title_color),
);
});
for diagnostic in report.diagnostics.iter().take(4) {
let color = match diagnostic.severity {
BrushDiagnosticSeverity::Error => egui::Color32::from_rgb(255, 137, 129),
BrushDiagnosticSeverity::Warning => egui::Color32::from_rgb(255, 190, 110),
};
let face = diagnostic
.face
.as_ref()
.filter(|face| !face.is_empty())
.map(|face| format!("{}: ", face.0))
.unwrap_or_default();
ui.label(egui::RichText::new(format!("{face}{}", diagnostic.message)).color(color));
}
let hidden_count = report.diagnostics.len().saturating_sub(4);
if hidden_count > 0 {
ui.label(egui::RichText::new(format!("+{hidden_count} more")).color(TEXT_MUTED));
}
});
}
fn selected_brush_face_controls(
world: &mut World,
ui: &mut egui::Ui,
entity: Entity,
brush: &mut BrushDesc,
material_candidates: &[AssetRefCandidate],
texture_candidates: &[AssetRefCandidate],
) -> bool {
let Some(selection) = world.get_resource::<BrushElementSelection>() else {
return false;
};
if selection.brush != Some(entity) {
return false;
}
let selected_faces: Vec<_> = selection
.elements
.iter()
.filter_map(|element| match element {
BrushElementKey::Face { face } => Some(face.clone()),
_ => None,
})
.collect();
if selected_faces.is_empty() {
return false;
}
let dragging_selection = world
.get_resource::<EditorAssets>()
.and_then(|assets| assets.dragging_selection().cloned());
let material_drop_candidate = dragging_selection.as_ref().and_then(|selection| {
asset_ref_candidate_from_selection(world, selection, AssetRefCandidateKind::Material)
});
let texture_drop_candidate = dragging_selection.as_ref().and_then(|selection| {
asset_ref_candidate_from_selection(world, selection, AssetRefCandidateKind::Texture)
});
let mut changed = false;
ui.add_space(6.0);
ui.separator();
ui.label(
egui::RichText::new(format!("{} selected face(s)", selected_faces.len())).color(TEXT_DIM),
);
for face_id in selected_faces {
let Some(face) = brush.faces.iter_mut().find(|face| face.id == face_id) else {
continue;
};
let label = if face.id.0.trim().is_empty() {
"Face".to_string()
} else {
face.id.0.clone()
};
egui::CollapsingHeader::new(label)
.default_open(true)
.show(ui, |ui| {
property_row(ui, "UV offset", |ui| {
changed |= ui
.add(egui::DragValue::new(&mut face.uv_offset.x).speed(0.05))
.changed();
changed |= ui
.add(egui::DragValue::new(&mut face.uv_offset.y).speed(0.05))
.changed();
});
property_row(ui, "UV scale", |ui| {
changed |= ui
.add(egui::DragValue::new(&mut face.uv_scale.x).speed(0.05))
.changed();
changed |= ui
.add(egui::DragValue::new(&mut face.uv_scale.y).speed(0.05))
.changed();
});
property_row(ui, "UV rotation", |ui| {
changed |= ui
.add(egui::DragValue::new(&mut face.uv_rotation).speed(1.0))
.changed();
});
let material_response = asset_selector_row(
ui,
"Material",
icons::PALETTE,
face.material.as_ref(),
None,
true,
material_candidates,
material_drop_candidate.as_ref(),
);
if let Some(selected) = material_response.selected {
face.material = Some(selected);
changed = true;
}
if material_response.clear {
face.material = None;
changed = true;
}
if material_response.locate {
locate_asset_ref(world, face.material.as_ref(), material_candidates);
}
if material_response.accepted_drop {
clear_asset_drag(world);
}
let texture_response = asset_selector_row(
ui,
"Texture",
icons::IMAGE,
face.texture.as_ref(),
None,
true,
texture_candidates,
texture_drop_candidate.as_ref(),
);
if let Some(selected) = texture_response.selected {
face.texture = Some(selected);
changed = true;
}
if texture_response.clear {
face.texture = None;
changed = true;
}
if texture_response.locate {
locate_asset_ref(world, face.texture.as_ref(), texture_candidates);
}
if texture_response.accepted_drop {
clear_asset_drag(world);
}
});
}
changed
}
fn clear_asset_drag(world: &mut World) {
if let Some(mut assets) = world.get_resource_mut::<EditorAssets>() {
assets.clear_drag();
}
}
fn primitive_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let Some(mut primitive) = world.get::<Primitive>(entity).cloned() else {
return;
};
let _original = primitive.clone();
let mut changed = false;
let card = component_card_context(
world,
entity,
ComponentCardOptions::removable(COMPONENT_PRIMITIVE, "Primitive", icons::CUBE),
);
let card_response = component_card(ui, &card, |ui| {
property_row(ui, "Shape", |ui| {
ui.horizontal_wrapped(|ui| {
for (label, shape) in [
("Box", PrimitiveShape::Box),
("Sphere", PrimitiveShape::Sphere),
("Ramp", PrimitiveShape::Ramp),
] {
changed |= ui
.selectable_value(&mut primitive.shape, shape, label)
.changed();
}
});
});
property_row(ui, "Size X", |ui| {
changed |= ui
.add(egui::Slider::new(&mut primitive.size.x, 0.1..=20.0))
.changed();
});
property_row(ui, "Size Y", |ui| {
changed |= ui
.add(egui::Slider::new(&mut primitive.size.y, 0.1..=20.0))
.changed();
});
property_row(ui, "Size Z", |ui| {
changed |= ui
.add(egui::Slider::new(&mut primitive.size.z, 0.1..=20.0))
.changed();
});
});
apply_component_card_response(world, entity, card_response);
if changed {
set_primitive_with_history(world, entity, primitive);
}
}
fn light_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let Some(mut light) = world.get::<LightDesc>(entity).cloned() else {
return;
};
let _original = light.clone();
let mut changed = false;
let card = component_card_context(
world,
entity,
ComponentCardOptions::removable(COMPONENT_LIGHT_DESC, "Light", icons::LIGHTBULB),
);
let card_response = component_card(ui, &card, |ui| {
property_row(ui, "Kind", |ui| {
ui.horizontal_wrapped(|ui| {
for (label, kind) in [
("Point", AuthoringLightKind::Point),
("Spot", AuthoringLightKind::Spot),
("Directional", AuthoringLightKind::Directional),
] {
if ui.selectable_label(light.kind == kind, label).clicked() {
let color = light.color;
light = LightDesc {
color,
..LightDesc::for_kind(kind)
};
changed = true;
}
}
});
});
let solari_disabled = solari_disables_local_light(world, &light);
if solari_disabled {
ui.colored_label(
egui::Color32::from_rgb(255, 180, 100),
"Point and spot lights are disabled while Solari is active. Use a directional light or emissive material, or switch GI to Forward.",
);
}
ui.add_enabled_ui(!solari_disabled, |ui| {
let mut color = [light.color.r, light.color.g, light.color.b, light.color.a];
property_row(ui, "Color", |ui| {
if ui.color_edit_button_rgba_unmultiplied(&mut color).changed() {
light.color = ColorDesc {
r: color[0],
g: color[1],
b: color[2],
a: color[3],
};
changed = true;
}
});
let intensity_label = match light.kind {
AuthoringLightKind::Directional => "Intensity (lux)",
_ => "Intensity (lumens)",
};
let intensity_max = match light.kind {
AuthoringLightKind::Directional => AUTHORING_DIRECTIONAL_LUX_MAX,
_ => AUTHORING_POINT_SPOT_LUMENS_MAX,
};
property_row(ui, intensity_label, |ui| {
ui.horizontal_wrapped(|ui| {
if ui
.add(
egui::Slider::new(&mut light.intensity, 0.0..=intensity_max)
.show_value(false),
)
.changed()
{
changed = true;
}
if ui
.add_sized(
[fit_width(ui, 72.0, 120.0), 20.0],
egui::DragValue::new(&mut light.intensity)
.range(0.0..=intensity_max)
.speed(intensity_max * 0.001),
)
.changed()
{
changed = true;
}
});
});
if matches!(
light.kind,
AuthoringLightKind::Point | AuthoringLightKind::Spot
) {
property_row(ui, "Range (m)", |ui| {
changed |= ui
.add(egui::Slider::new(&mut light.range, 0.0..=100.0))
.changed();
});
}
if matches!(light.kind, AuthoringLightKind::Spot) {
property_row(ui, "Inner angle", |ui| {
changed |= ui
.add(egui::Slider::new(&mut light.inner_angle_deg, 1.0..=80.0))
.changed();
});
property_row(ui, "Outer angle", |ui| {
changed |= ui
.add(egui::Slider::new(&mut light.outer_angle_deg, 1.0..=90.0))
.changed();
});
}
property_row(ui, "Shadows", |ui| {
changed |= ui.checkbox(&mut light.shadows, "Cast shadows").changed();
});
if matches!(light.kind, AuthoringLightKind::Directional) {
ui.small("Controls project sun while this directional exists.");
}
});
});
apply_component_card_response(world, entity, card_response);
if changed {
set_light_with_history(world, entity, light);
}
}
fn solari_disables_local_light(world: &World, light: &LightDesc) -> bool {
matches!(
light.kind,
AuthoringLightKind::Point | AuthoringLightKind::Spot
) && world
.get_resource::<settings::ActiveCameraRenderProfile>()
.is_some_and(|profile| profile.gi_path == settings::GiPath::SolariDeferred)
&& world
.get_resource::<settings::RenderingCapabilities>()
.is_some_and(|caps| caps.rt_supported)
}
fn rigid_body_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let Some(mut body) = world.get::<RigidBodyDesc>(entity).copied() else {
return;
};
let original = body;
let mut changed = false;
let card = component_card_context(
world,
entity,
ComponentCardOptions::removable(COMPONENT_RIGID_BODY_DESC, "Rigid Body", icons::SPHERE),
);
let card_response = component_card(ui, &card, |ui| {
property_row(ui, "Body", |ui| {
ui.horizontal_wrapped(|ui| {
for (label, kind) in [
("Static", AuthoringRigidBody::Static),
("Kinematic", AuthoringRigidBody::Kinematic),
("Dynamic", AuthoringRigidBody::Dynamic),
] {
changed |= ui.selectable_value(&mut body.body, kind, label).changed();
}
});
});
});
apply_component_card_response(world, entity, card_response);
if changed && body != original {
set_rigid_body_with_history(world, entity, body);
}
}
fn collider_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let mesh_candidates = static_mesh_asset_ref_candidates(world, AssetRefCandidateKind::Mesh);
let Some(mut collider) = world.get::<ColliderDesc>(entity).cloned() else {
return;
};
let original = collider.clone();
let mut changed = false;
let renderer_meshes: Vec<EditorAssetRef> = world
.get::<StaticMeshRenderer>(entity)
.map(|renderer| {
renderer
.slots
.iter()
.map(|slot| slot.mesh.clone())
.collect()
})
.unwrap_or_default();
let card = component_card_context(
world,
entity,
ComponentCardOptions::removable(COMPONENT_COLLIDER_DESC, "Collider", icons::SELECTION),
);
let card_response = component_card(ui, &card, |ui| {
property_row(ui, "Mode", |ui| {
changed |= ui.checkbox(&mut collider.enabled, "Enabled").changed();
changed |= ui.checkbox(&mut collider.is_trigger, "Trigger").changed();
});
property_row(ui, "Shape", |ui| {
let mut shape_kind = collider_shape_kind(&collider.shape);
egui::ComboBox::from_id_salt("collider_shape_kind")
.selected_text(shape_kind)
.show_ui(ui, |ui| {
for label in ["Box", "Sphere", "Capsule", "Static Mesh"] {
if ui.selectable_label(shape_kind == label, label).clicked() {
shape_kind = label;
}
}
});
if shape_kind != collider_shape_kind(&collider.shape) {
collider.shape = match shape_kind {
"Sphere" => ColliderShapeDesc::Sphere { radius: 0.5 },
"Capsule" => ColliderShapeDesc::Capsule {
radius: 0.5,
height: 1.0,
},
"Static Mesh" => ColliderShapeDesc::static_mesh(renderer_meshes.clone()),
_ => ColliderShapeDesc::default(),
};
changed = true;
}
});
match &mut collider.shape {
ColliderShapeDesc::Cuboid {
x_length,
y_length,
z_length,
} => {
changed |= dimension_drag(ui, "X", x_length);
changed |= dimension_drag(ui, "Y", y_length);
changed |= dimension_drag(ui, "Z", z_length);
}
ColliderShapeDesc::Sphere { radius } => {
changed |= dimension_drag(ui, "Radius", radius);
}
ColliderShapeDesc::Capsule { radius, height } => {
changed |= dimension_drag(ui, "Radius", radius);
changed |= dimension_drag(ui, "Height", height);
}
ColliderShapeDesc::StaticMesh { meshes, .. } => {
if meshes.is_empty() {
ui.label(egui::RichText::new("No mesh collider sources").color(TEXT_DIM));
}
for mesh in meshes.iter_mut() {
let mesh_response = asset_selector_row(
ui,
"Mesh",
icons::CUBE,
Some(mesh),
None,
false,
&mesh_candidates,
None,
);
if let Some(selected) = mesh_response.selected {
*mesh = selected;
changed = true;
}
if mesh_response.locate {
locate_asset_ref(world, Some(mesh), &mesh_candidates);
}
}
if !renderer_meshes.is_empty() && ui.button("Use renderer meshes").clicked() {
*meshes = renderer_meshes.clone();
changed = true;
}
}
}
});
apply_component_card_response(world, entity, card_response);
if changed && collider != original {
set_collider_with_history(world, entity, collider);
}
}
fn collider_shape_kind(shape: &ColliderShapeDesc) -> &'static str {
match shape {
ColliderShapeDesc::Cuboid { .. } => "Box",
ColliderShapeDesc::Sphere { .. } => "Sphere",
ColliderShapeDesc::Capsule { .. } => "Capsule",
ColliderShapeDesc::StaticMesh { .. } => "Static Mesh",
}
}
fn dimension_drag(ui: &mut egui::Ui, label: &str, value: &mut f32) -> bool {
property_row(ui, label, |ui| {
ui.add_sized(
[fit_width(ui, 72.0, 120.0), 20.0],
egui::DragValue::new(value)
.range(0.0..=10_000.0)
.speed(0.05)
.min_decimals(2)
.max_decimals(3),
)
.changed()
})
}
fn physics_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let Some(mut body) = world.get::<PhysicsBody>(entity).cloned() else {
return;
};
let _original = body.clone();
let mut changed = false;
let card = component_card_context(
world,
entity,
ComponentCardOptions::removable(COMPONENT_PHYSICS_BODY, "Physics", icons::SPHERE),
);
let card_response = component_card(ui, &card, |ui| {
property_row(ui, "Body", |ui| {
ui.horizontal_wrapped(|ui| {
for (label, kind) in [
("Static", AuthoringRigidBody::Static),
("Kinematic", AuthoringRigidBody::Kinematic),
("Dynamic", AuthoringRigidBody::Dynamic),
] {
if ui.selectable_label(body.body == kind, label).clicked() {
body.body = kind;
changed = true;
}
}
});
});
});
apply_component_card_response(world, entity, card_response);
if changed {
set_physics_with_history(world, entity, body);
}
}
fn player_spawn_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
if world.get::<PlayerSpawn>(entity).is_none() {
return;
}
let card = component_card_context(
world,
entity,
ComponentCardOptions::removable(
COMPONENT_PLAYER_SPAWN,
"Player Spawn",
icons::PERSON_SIMPLE_RUN,
),
);
let card_response = component_card(ui, &card, |ui| {
ui.label(
egui::RichText::new("Uses this actor transform as a player start.").color(TEXT_DIM),
);
});
apply_component_card_response(world, entity, card_response);
}
fn weapon_spawn_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let Some(mut spawn) = world.get::<WeaponSpawn>(entity).cloned() else {
return;
};
let card = component_card_context(
world,
entity,
ComponentCardOptions::removable(COMPONENT_WEAPON_SPAWN, "Weapon Spawn", icons::CROSSHAIR),
);
let card_response = component_card(ui, &card, |ui| {
property_row(ui, "Weapon ID", |ui| {
ui.add_sized(
[text_field_width(ui), 20.0],
egui::TextEdit::singleline(&mut spawn.weapon_id),
);
});
});
apply_component_card_response(world, entity, card_response);
if let Ok(mut e) = world.get_entity_mut(entity) {
e.insert(spawn);
}
}
fn trigger_volume_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let Some(mut trigger) = world.get::<TriggerVolume>(entity).cloned() else {
return;
};
let card = component_card_context(
world,
entity,
ComponentCardOptions::removable(
COMPONENT_TRIGGER_VOLUME,
"Trigger Volume",
icons::SELECTION,
),
);
let card_response = component_card(ui, &card, |ui| {
property_row(ui, "Event", |ui| {
ui.add_sized(
[text_field_width(ui), 20.0],
egui::TextEdit::singleline(&mut trigger.event_name),
);
});
property_row(ui, "Half X", |ui| {
ui.add(egui::Slider::new(&mut trigger.half_extents.x, 0.1..=50.0));
});
property_row(ui, "Half Y", |ui| {
ui.add(egui::Slider::new(&mut trigger.half_extents.y, 0.1..=50.0));
});
property_row(ui, "Half Z", |ui| {
ui.add(egui::Slider::new(&mut trigger.half_extents.z, 0.1..=50.0));
});
});
apply_component_card_response(world, entity, card_response);
if let Ok(mut e) = world.get_entity_mut(entity) {
e.insert(trigger);
}
}
fn team_spawn_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let Some(mut spawn) = world.get::<TeamSpawn>(entity).cloned() else {
return;
};
let card = component_card_context(
world,
entity,
ComponentCardOptions::removable(COMPONENT_TEAM_SPAWN, "Team Spawn", icons::FLAG),
);
let card_response = component_card(ui, &card, |ui| {
property_row(ui, "Team", |ui| {
ui.add(egui::Slider::new(&mut spawn.team_id, 0..=8));
});
});
apply_component_card_response(world, entity, card_response);
if let Ok(mut e) = world.get_entity_mut(entity) {
e.insert(spawn);
}
}
fn objective_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let Some(mut marker) = world.get::<ObjectiveMarker>(entity).cloned() else {
return;
};
let card = component_card_context(
world,
entity,
ComponentCardOptions::removable(COMPONENT_OBJECTIVE_MARKER, "Objective", icons::TARGET),
);
let card_response = component_card(ui, &card, |ui| {
property_row(ui, "Objective ID", |ui| {
ui.add_sized(
[text_field_width(ui), 20.0],
egui::TextEdit::singleline(&mut marker.objective_id),
);
});
});
apply_component_card_response(world, entity, card_response);
if let Ok(mut e) = world.get_entity_mut(entity) {
e.insert(marker);
}
}
fn prefab_instance_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
crate::assets::prefab_overrides::prefab_instance_inspector_ui(world, ui, entity);
}
pub fn project_sun_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
if world.get::<ProjectSun>(entity).is_none() {
return;
}
let rendering = world
.resource::<settings::ProjectSettings>()
.rendering
.clone();
let scene_override_active = has_scene_sun_override(world);
let mut create_override = false;
let mut options = ComponentCardOptions::fixed(COMPONENT_PROJECT_SUN, "Project Sun", icons::SUN);
options.resettable = false;
let card = component_card_context(world, entity, options);
let card_response = component_card(ui, &card, |ui| {
ui.label("Runtime default world lighting from assets/project.ron.");
property_row(ui, "Illuminance", |ui| {
ui.small(format!("{:.0} lux", rendering.sun_illuminance));
});
property_row(ui, "Ambient", |ui| {
ui.small(format!(
"{:.2}, {:.2}, {:.2} @ {:.1}",
rendering.ambient_color[0],
rendering.ambient_color[1],
rendering.ambient_color[2],
rendering.ambient_brightness
));
});
property_row(ui, "Shadows", |ui| {
ui.small(format!(
"{} cascades, max {:.0}m",
rendering.shadow_cascades, rendering.shadow_max_distance
));
});
if scene_override_active {
ui.label(egui::RichText::new("Scene sun override is active.").weak());
} else if ui.button("Create Scene Sun Override").clicked() {
create_override = true;
}
});
apply_component_card_response(world, entity, card_response);
if create_override {
let sun = create_scene_sun_override_from_project_settings(world);
world.resource_mut::<SelectedEntity>().0 = Some(sun);
if let Some(mut ui_state) = world.get_resource_mut::<crate::ui::UiState>() {
ui_state.selected_entities.select_replace(sun);
}
}
}
fn has_scene_sun_override(world: &mut World) -> bool {
world
.query_filtered::<(
&shared::LightDesc,
Option<&AuthoringComponentStates>,
Option<&InspectorOrder>,
), With<LevelObject>>()
.iter(world)
.any(|(light, states, legacy_order)| {
authoring_component_active(states, legacy_order, COMPONENT_LIGHT_DESC)
&& matches!(light.kind, shared::AuthoringLightKind::Directional)
})
}
fn texture_asset_picker_ui(
world: &mut World,
ui: &mut egui::Ui,
label: &str,
value: &mut Option<String>,
candidates: &[TextureAssetCandidate],
) -> bool {
let current_path = value.clone();
let dragging_selection = world
.get_resource::<EditorAssets>()
.and_then(|assets| assets.dragging_selection().cloned());
let drop_candidate = dragging_selection
.as_ref()
.and_then(|selection| texture_path_from_selection(world, selection));
let mut selected_path: Option<String> = None;
let mut clear = false;
let mut locate = false;
let mut accepted_drop = false;
let current_candidate = current_path.as_ref().and_then(|path| {
candidates
.iter()
.find(|candidate| candidate.path == *path)
.cloned()
});
property_row(ui, label, |ui| {
let control_width = ui
.available_width()
.max(MIN_INLINE_CONTROL_WIDTH.min(ui.available_width().max(1.0)));
let row_height = 30.0;
let (rect, _response) =
ui.allocate_exact_size(egui::vec2(control_width, row_height), egui::Sense::hover());
let valid_drag = drop_candidate.is_some();
let row_hovered = ui.rect_contains_pointer(rect);
let drop_hovered = valid_drag && row_hovered;
let stroke = if drop_hovered {
egui::Stroke::new(2.0, egui::Color32::from_rgb(125, 198, 255))
} else if valid_drag {
egui::Stroke::new(1.0, egui::Color32::from_rgb(58, 88, 122))
} else if row_hovered {
egui::Stroke::new(1.0, egui::Color32::from_rgb(92, 102, 118))
} else {
egui::Stroke::new(1.0, BORDER)
};
let fill = if drop_hovered {
egui::Color32::from_rgb(29, 57, 86)
} else if valid_drag {
WIDGET_BG.linear_multiply(0.88)
} else if row_hovered {
WIDGET_BG.linear_multiply(1.05)
} else {
WIDGET_BG.linear_multiply(0.75)
};
ui.painter()
.rect(rect, 4.0, fill, stroke, egui::StrokeKind::Inside);
if drop_hovered {
let badge_rect = egui::Rect::from_min_size(
rect.right_top() + egui::vec2(-82.0, 4.0),
egui::vec2(74.0, 16.0),
);
ui.painter().rect(
badge_rect,
3.0,
egui::Color32::from_rgb(35, 95, 155),
egui::Stroke::NONE,
egui::StrokeKind::Inside,
);
ui.painter().text(
badge_rect.center(),
egui::Align2::CENTER_CENTER,
"Drop texture",
egui::FontId::new(10.0, egui::FontFamily::Proportional),
egui::Color32::from_rgb(225, 241, 255),
);
}
let mut child = ui.new_child(
egui::UiBuilder::new()
.max_rect(rect.shrink2(egui::vec2(6.0, 4.0)))
.layout(egui::Layout::left_to_right(egui::Align::Center)),
);
child.set_clip_rect(rect);
let preview_size = 22.0;
let (preview_rect, _preview_response) =
child.allocate_exact_size(egui::vec2(preview_size, preview_size), egui::Sense::hover());
child.painter().rect(
preview_rect,
3.0,
PANEL_BG_DARK,
egui::Stroke::new(1.0, BORDER),
egui::StrokeKind::Inside,
);
if let Some(texture_id) = current_candidate
.as_ref()
.and_then(|candidate| candidate.texture_id)
{
let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
child.painter().image(
texture_id,
preview_rect.shrink(1.0),
uv,
egui::Color32::WHITE,
);
} else {
child.painter().text(
preview_rect.center(),
egui::Align2::CENTER_CENTER,
icons::IMAGE.as_str(),
egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-regular".into())),
TEXT_DIM,
);
}
let action_width = 92.0;
let text_width = (child.available_width() - action_width).max(1.0);
child.allocate_ui_with_layout(
egui::vec2(text_width, 22.0),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
let display_label = current_candidate
.as_ref()
.map(|candidate| candidate.label.as_str())
.or(current_path.as_deref())
.unwrap_or("(none)");
ui.add_sized(
[text_width, 20.0],
egui::Label::new(egui::RichText::new(display_label).color(
if current_path.is_some() {
TEXT_DIM.linear_multiply(1.45)
} else {
TEXT_DIM
},
))
.truncate(),
);
},
);
child.allocate_ui_with_layout(
egui::vec2(action_width, 28.0),
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
let clear_response = ui.add_enabled(
current_path.is_some(),
egui::Button::new(phosphor_icon(icons::X, 16.0))
.frame(false)
.min_size(egui::vec2(22.0, 22.0)),
);
if clear_response.on_hover_text("Clear texture").clicked() {
clear = true;
}
let locate_response = ui.add_enabled(
current_path.is_some(),
egui::Button::new(phosphor_icon(icons::CROSSHAIR, 16.0))
.frame(false)
.min_size(egui::vec2(22.0, 22.0)),
);
if locate_response
.on_hover_text("Locate in content browser")
.clicked()
{
locate = true;
}
if candidates.is_empty() {
ui.add_enabled(
false,
egui::Button::new(phosphor_icon(icons::FOLDER_OPEN, 16.0))
.frame(false)
.min_size(egui::vec2(22.0, 22.0)),
)
.on_hover_text("No texture assets available");
} else {
let menu = ui.menu_button(phosphor_icon(icons::FOLDER_OPEN, 16.0), |ui| {
ui.set_min_width(240.0);
for candidate in candidates {
let selected = current_path.as_deref() == Some(candidate.path.as_str());
if ui
.selectable_label(selected, candidate.label.as_str())
.on_hover_text(candidate.path.as_str())
.clicked()
{
selected_path = Some(candidate.path.clone());
ui.close();
}
}
});
menu.response.on_hover_text("Browse textures");
}
},
);
if drop_hovered && ui.input(|input| input.pointer.any_released()) {
if let Some(candidate) = drop_candidate.as_ref() {
selected_path = Some(candidate.path.clone());
accepted_drop = true;
}
}
});
if locate {
locate_texture_asset(world, current_path.as_deref(), candidates);
}
if accepted_drop {
if let Some(mut assets) = world.get_resource_mut::<EditorAssets>() {
assets.clear_drag();
}
}
if clear {
*value = None;
return current_path.is_some();
}
if let Some(path) = selected_path {
let changed = current_path.as_deref() != Some(path.as_str());
*value = Some(path);
return changed;
}
false
}
fn locate_texture_asset(
world: &mut World,
path: Option<&str>,
candidates: &[TextureAssetCandidate],
) {
let Some(path) = path else {
return;
};
let Some(candidate) = candidates.iter().find(|candidate| candidate.path == path) else {
if let Some(mut scene_io) = world.get_resource_mut::<crate::scene_io::SceneIo>() {
scene_io.status = format!("Could not locate texture {path}");
}
return;
};
if let Some(mut assets) = world.get_resource_mut::<EditorAssets>() {
assets.current_folder = candidate.folder_path.clone();
assets.select(candidate.selection.clone());
}
if let Some(mut ui_state) = world.get_resource_mut::<UiState>() {
let panel_nodes = ui_state.panel_nodes;
open_and_focus_tab(
&mut ui_state.dock_state,
EditorTab::AssetBrowser,
&panel_nodes,
);
}
}
fn option_string_ui(ui: &mut egui::Ui, label: &str, value: &mut Option<String>) -> bool {
let mut text = value.clone().unwrap_or_default();
let before = text.clone();
property_row(ui, label, |ui| {
ui.horizontal_wrapped(|ui| {
let clear_width = 52.0;
let field_width = (ui.available_width() - clear_width - ui.spacing().item_spacing.x)
.max(MIN_INLINE_CONTROL_WIDTH.min(ui.available_width().max(1.0)))
.min(TEXT_FIELD_MAX_WIDTH)
.min(ui.available_width().max(1.0));
ui.add_sized([field_width, 20.0], egui::TextEdit::singleline(&mut text));
if ui.button("Clear").clicked() {
text.clear();
}
});
});
let changed = before != text;
*value = if text.trim().is_empty() {
None
} else {
Some(text)
};
changed
}