//! 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, ActorId, 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, TerrainDesc, 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_TERRAIN_DESC, 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( 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, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum AssetRefCandidateKind { Mesh, Material, Texture, } #[derive(Default)] struct AssetSelectorResponse { selected: Option, clear: bool, locate: bool, accepted_drop: bool, } #[derive(Debug, Clone)] struct TextureAssetCandidate { label: String, path: String, folder_path: String, selection: AssetSelection, texture_id: Option, } #[derive(Resource, Default, Debug, Clone)] pub(crate) struct InspectorClipboard { component: Option, } #[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, } #[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, pub(crate) active: Option, 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::(entity); let registry = world.resource::(); let state_key = registry.stable_id_for_type(options.type_name); let active = authoring_component_active( world.get::(entity), order, state_key, ); let collapsed = world .get_resource::() .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::() .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::() { 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::(entity) .cloned() .unwrap_or_else(|| AuthoringComponentStates { states: world .get::(entity) .map(|order| order.component_states.clone()) .unwrap_or_default(), }); let component_id = world .resource::() .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::(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::() .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::(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::() { 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::(entity).is_some() { present.push(COMPONENT_ANIMATION_CONTROLLER_DESC); } if world.get::(entity).is_some() { present.push(COMPONENT_STATIC_MESH_RENDERER); } if world.get::(entity).is_some() { present.push(COMPONENT_SKINNED_MESH_RENDERER); } if world.get::(entity).is_some() { present.push(COMPONENT_PRIMITIVE); } if world.get::(entity).is_some() { present.push(COMPONENT_BRUSH_DESC); } if world.get::(entity).is_some() || world.get::(entity).is_some() { present.push(COMPONENT_MATERIAL_DESC); } if world.get::(entity).is_some() { present.push(COMPONENT_LIGHT_DESC); } if world.get::(entity).is_some() { present.push(COMPONENT_AUDIO_SOURCE_DESC); } if world.get::(entity).is_some() { present.push(COMPONENT_AUDIO_LISTENER_DESC); } if world.get::(entity).is_some() { present.push(COMPONENT_RIGID_BODY_DESC); } if world.get::(entity).is_some() { present.push(COMPONENT_COLLIDER_DESC); } if world.get::(entity).is_some() { present.push(COMPONENT_PHYSICS_BODY); } if world.get::(entity).is_some() { present.push(COMPONENT_PLAYER_SPAWN); } if world.get::(entity).is_some() { present.push(COMPONENT_WEAPON_SPAWN); } if world.get::(entity).is_some() { present.push(COMPONENT_TRIGGER_VOLUME); } if world.get::(entity).is_some() { present.push(COMPONENT_TEAM_SPAWN); } if world.get::(entity).is_some() { present.push(COMPONENT_OBJECTIVE_MARKER); } if world.get::(entity).is_some() { present.push(COMPONENT_PREFAB_INSTANCE); } if world.get::(entity).is_some() { present.push(COMPONENT_POST_PROCESS_VOLUME); } if world.get::(entity).is_some() { present.push(COMPONENT_PROJECT_SUN); } if world.get::(entity).is_some() { present.push(COMPONENT_NAVIGATION_BOUNDS); } if world.get::(entity).is_some() { present.push(COMPONENT_NAVIGATION_OBSTACLE); } if world.get::(entity).is_some() { present.push(COMPONENT_NAVIGATION_AREA); } if world.get::(entity).is_some() { present.push(COMPONENT_NAVIGATION_LINK); } present } fn copy_component(world: &mut World, entity: Entity, type_name: &str) { let descriptor = world .resource::() .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::().component = Some(CopiedComponent::Reflected(value)); return; } } let component = match type_name { COMPONENT_ANIMATION_CONTROLLER_DESC => world .get::(entity) .cloned() .map(CopiedComponent::AnimationControllerDesc), COMPONENT_PRIMITIVE => world .get::(entity) .cloned() .map(CopiedComponent::Primitive), COMPONENT_BRUSH_DESC => world .get::(entity) .cloned() .map(CopiedComponent::BrushDesc), COMPONENT_STATIC_MESH_RENDERER => world .get::(entity) .cloned() .map(CopiedComponent::StaticMeshRenderer), COMPONENT_MATERIAL_DESC => world .get::(entity) .cloned() .map(CopiedComponent::MaterialDesc), COMPONENT_LIGHT_DESC => world .get::(entity) .cloned() .map(CopiedComponent::LightDesc), COMPONENT_AUDIO_SOURCE_DESC => world .get::(entity) .cloned() .map(CopiedComponent::AudioSourceDesc), COMPONENT_AUDIO_LISTENER_DESC => world .get::(entity) .copied() .map(CopiedComponent::AudioListenerDesc), COMPONENT_RIGID_BODY_DESC => world .get::(entity) .copied() .map(CopiedComponent::RigidBodyDesc), COMPONENT_COLLIDER_DESC => world .get::(entity) .cloned() .map(CopiedComponent::ColliderDesc), COMPONENT_PHYSICS_BODY => world .get::(entity) .cloned() .map(CopiedComponent::PhysicsBody), COMPONENT_WEAPON_SPAWN => world .get::(entity) .cloned() .map(CopiedComponent::WeaponSpawn), COMPONENT_TRIGGER_VOLUME => world .get::(entity) .cloned() .map(CopiedComponent::TriggerVolume), COMPONENT_TEAM_SPAWN => world .get::(entity) .cloned() .map(CopiedComponent::TeamSpawn), COMPONENT_OBJECTIVE_MARKER => world .get::(entity) .cloned() .map(CopiedComponent::ObjectiveMarker), COMPONENT_POST_PROCESS_VOLUME => world .get::(entity) .cloned() .map(CopiedComponent::PostProcessVolume), COMPONENT_PREFAB_INSTANCE => world .get::(entity) .cloned() .map(CopiedComponent::PrefabInstance), COMPONENT_NAVIGATION_BOUNDS => world .get::(entity) .cloned() .map(CopiedComponent::NavigationBounds), COMPONENT_NAVIGATION_OBSTACLE => world .get::(entity) .cloned() .map(CopiedComponent::NavigationObstacle), COMPONENT_NAVIGATION_AREA => world .get::(entity) .cloned() .map(CopiedComponent::NavigationArea), COMPONENT_NAVIGATION_LINK => world .get::(entity) .cloned() .map(CopiedComponent::NavigationLink), _ => None, }; if let Some(component) = component { world.resource_mut::().component = Some(component); } } fn paste_component(world: &mut World, entity: Entity, type_name: &str) { let component = world .get_resource::() .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(mut value)) if type_name == COMPONENT_NAVIGATION_BOUNDS => { value.artifact_path = world .get::(entity) .map(|bounds| bounds.artifact_path.clone()) .unwrap_or_else(|| navigation_bounds_for_entity(world, entity).artifact_path); 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::() .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 if type_path == COMPONENT_NAVIGATION_BOUNDS { let bounds = reset_navigation_bounds_for_entity(world, entity); let _ = crate::history::reflected_component_transaction( world, entity, "Reset Component", component_id, type_path, move |world, entity| { world.entity_mut(entity).insert(bounds); 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(reset_navigation_bounds_for_entity(world, entity)), ..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(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::() { scene_io.mark_dirty(); } } fn remove_registered_component(world: &mut World, entity: Entity, type_name: &str) { let descriptor = world .resource::() .by_type_name(type_name) .cloned(); if let Some(descriptor) = descriptor { if !descriptor.removable { return; } let dependents = world .resource::() .present_dependents(world, entity, descriptor.id) .iter() .map(|dependent| dependent.display_name) .collect::>(); if !dependents.is_empty() { world .resource_mut::() .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::(); true } COMPONENT_PRIMITIVE if before.primitive.is_some() => { entity_mut.remove::(); true } COMPONENT_BRUSH_DESC if before.brush.is_some() => { entity_mut.remove::(); true } COMPONENT_STATIC_MESH_RENDERER if before.static_mesh_renderer.is_some() => { entity_mut.remove::(); true } COMPONENT_SKINNED_MESH_RENDERER if before.skinned_mesh_renderer.is_some() => { entity_mut.remove::(); true } COMPONENT_MATERIAL_DESC if before.material.is_some() => { entity_mut.remove::(); true } COMPONENT_LIGHT_DESC if before.light.is_some() => { entity_mut.remove::(); true } COMPONENT_AUDIO_SOURCE_DESC if before.audio_source.is_some() => { entity_mut.remove::(); true } COMPONENT_AUDIO_LISTENER_DESC if before.audio_listener.is_some() => { entity_mut.remove::(); true } COMPONENT_RIGID_BODY_DESC if before.rigid_body.is_some() => { entity_mut.remove::(); true } COMPONENT_COLLIDER_DESC if before.collider.is_some() => { entity_mut.remove::(); true } COMPONENT_PHYSICS_BODY if before.physics.is_some() => { entity_mut.remove::(); true } COMPONENT_PLAYER_SPAWN if before.player_spawn => { entity_mut.remove::(); true } COMPONENT_WEAPON_SPAWN if before.weapon_spawn.is_some() => { entity_mut.remove::(); true } COMPONENT_TRIGGER_VOLUME if before.trigger_volume.is_some() => { entity_mut.remove::(); true } COMPONENT_TEAM_SPAWN if before.team_spawn.is_some() => { entity_mut.remove::(); true } COMPONENT_OBJECTIVE_MARKER if before.objective.is_some() => { entity_mut.remove::(); true } COMPONENT_PREFAB_INSTANCE if before.prefab_instance.is_some() => { entity_mut.remove::(); true } COMPONENT_POST_PROCESS_VOLUME if before.post_process_volume.is_some() => { entity_mut.remove::(); true } COMPONENT_NAVIGATION_BOUNDS if before.navigation_bounds.is_some() => { entity_mut.remove::(); true } COMPONENT_NAVIGATION_OBSTACLE if before.navigation_obstacle.is_some() => { entity_mut.remove::(); true } COMPONENT_NAVIGATION_AREA if before.navigation_area.is_some() => { entity_mut.remove::(); true } COMPONENT_NAVIGATION_LINK if before.navigation_link.is_some() => { entity_mut.remove::(); 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::()) { return; } let present = present_component_type_names(world, entity); let ordered = if let Some(order) = world.get::(entity) { world .resource::() .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::() .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_TERRAIN_DESC => terrain_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::() { 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::() .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::() { state.add_component_open = false; state.add_component_target = None; } return; } let descriptors = world .resource::() .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::(); state.add_component_open = false; state.add_component_target = None; } }); }); ui.add_space(4.0); let mut search_input = world .resource::() .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::(); state.add_component_search = search_input.clone(); state.add_component_scroll_selected = true; state.add_component_selected_index = 0; } if world .resource::() .add_component_focus_search { search_response.request_focus(); world .resource_mut::() .add_component_focus_search = false; } let search = search_input.to_lowercase(); let filtered = filtered_component_descriptors(descriptors, &search); { let mut state = world.resource_mut::(); 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::(); 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::(); 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::(); 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::() .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::(); 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::(); ( 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::() .add_component_selected_index = index; if add_state.addable { insert_registered_component(world, target, descriptor.type_name); let mut state = world.resource_mut::(); state.add_component_open = false; state.add_component_target = None; } } } }); world .resource_mut::() .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, 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::>(); let recommended = descriptor .recommended .iter() .copied() .filter(|type_name| !component_present(world, entity, type_name)) .collect::>(); let registry = world.resource::(); 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::>(); 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::>() .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::() { 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::(entity).is_some() } "shared::components::Primitive" => world.get::(entity).is_some(), "shared::components::BrushDesc" => world.get::(entity).is_some(), "shared::components::StaticMeshRenderer" => { world.get::(entity).is_some() } COMPONENT_SKINNED_MESH_RENDERER => world.get::(entity).is_some(), "shared::components::MaterialDesc" => world.get::(entity).is_some(), "shared::components::LightDesc" => world.get::(entity).is_some(), "shared::components::AudioSourceDesc" => world.get::(entity).is_some(), "shared::components::AudioListenerDesc" => world.get::(entity).is_some(), "shared::components::RigidBodyDesc" => world.get::(entity).is_some(), "shared::components::ColliderDesc" => world.get::(entity).is_some(), "shared::components::PlayerSpawn" => world.get::(entity).is_some(), "shared::components::WeaponSpawn" => world.get::(entity).is_some(), "shared::components::TriggerVolume" => world.get::(entity).is_some(), "shared::components::TeamSpawn" => world.get::(entity).is_some(), "shared::components::ObjectiveMarker" => world.get::(entity).is_some(), "shared::components::PostProcessVolumeDesc" => { world.get::(entity).is_some() } COMPONENT_NAVIGATION_BOUNDS => world.get::(entity).is_some(), COMPONENT_NAVIGATION_OBSTACLE => world.get::(entity).is_some(), COMPONENT_NAVIGATION_AREA => world.get::(entity).is_some(), COMPONENT_NAVIGATION_LINK => world.get::(entity).is_some(), _ => false, } } fn insert_registered_component(world: &mut World, entity: Entity, type_name: &str) { let descriptor = world .resource::() .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 if descriptor.type_name == COMPONENT_NAVIGATION_BOUNDS { let bounds = navigation_bounds_for_entity(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(bounds); 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| { let bounds = navigation_bounds_for_entity(world, e); world.entity_mut(e).insert((ActorKind::Navigation, bounds)); }), 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 navigation_bounds_for_entity(world: &World, entity: Entity) -> NavigationBounds { world .get::(entity) .map(|actor_id| NavigationBounds::for_actor(&actor_id.0)) .unwrap_or_else(|| NavigationBounds::for_actor(&uuid::Uuid::new_v4().to_string())) } fn reset_navigation_bounds_for_entity(world: &World, entity: Entity) -> NavigationBounds { let mut bounds = navigation_bounds_for_entity(world, entity); if let Some(current) = world.get::(entity) { bounds.artifact_path.clone_from(¤t.artifact_path); } bounds } 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 { let thumbnail_by_path = model_thumbnail_texture_by_path(world); let Some(registry) = world.get_resource::() else { return Vec::new(); }; let catalog = world.get_resource::(); let mut seen = HashSet::new(); let mut candidates = Vec::new(); for record in ®istry.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 { let Some(catalog) = world.get_resource::() else { return Vec::new(); }; let snapshot = world .get_resource::() .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 { let Some(catalog) = world.get_resource::() else { return Vec::new(); }; let Some(registry) = world.get_resource::() else { return Vec::new(); }; let snapshot = world .get_resource::() .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 { let mut candidates = static_mesh_asset_ref_candidates(world, AssetRefCandidateKind::Material); let Some(catalog) = world.get_resource::() else { return candidates; }; let Some(registry) = world.get_resource::() 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 { let registry = world.get_resource::()?; match (kind, selection) { (AssetRefCandidateKind::Material, AssetSelection::File(path)) => { let asset = world .get_resource::()? .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::()? .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::() .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::().is_none() { return; } let asset_server = world.resource::().clone(); world.resource_scope(|_world, mut cache: Mut| { for (key, path) in requests { cache.request_texture(key, path, &asset_server); } }); } fn texture_path_from_selection( world: &World, selection: &AssetSelection, ) -> Option { match selection { AssetSelection::File(path) => { let asset = world .get_resource::()? .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::().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 { 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 { let model_assets: Vec = world .get_resource::() .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::().is_none() { return HashMap::new(); } let asset_server = world.resource::().clone(); world.resource_scope(|world, mut cache: Mut| { if let Some(mut studio) = world.get_resource_mut::() { 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::().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::() { scene_io.status = format!("Could not locate imported asset {}", asset.label); } return; }; if let Some(mut assets) = world.get_resource_mut::() { assets.current_folder = candidate.folder_path.clone(); assets.select(candidate.selection.clone()); } if let Some(mut ui_state) = world.get_resource_mut::() { 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::(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::(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::(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::().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::(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) { 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 { 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(¶meter.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( 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::(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::(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::(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 terrain_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { let Some(mut terrain) = world.get::(entity).cloned() else { return; }; let original = terrain.clone(); let mut changed = false; let mut requested_resolution = terrain.resolution; let card = component_card_context( world, entity, ComponentCardOptions::removable(COMPONENT_TERRAIN_DESC, "Terrain", icons::MOUNTAINS), ); let card_response = component_card(ui, &card, |ui| { property_row(ui, "Grid", |ui| { ui.horizontal(|ui| { ui.add( egui::DragValue::new(&mut requested_resolution) .range(2..=1025) .suffix(" samples"), ); if ui .add_enabled( requested_resolution != terrain.resolution, egui::Button::new("Resize Flat"), ) .on_hover_text("Replaces the current height grid with a flat grid") .clicked() { let replacement = TerrainDesc::flat(requested_resolution); terrain.resolution = replacement.resolution; terrain.heights = replacement.heights; terrain.chunk_quads = terrain.chunk_quads.min(terrain.resolution - 1).max(1); changed = true; } }); }); property_row(ui, "Sample Spacing", |ui| { changed |= ui .add( egui::DragValue::new(&mut terrain.sample_spacing) .range(0.01..=1000.0) .speed(0.1) .suffix(" m"), ) .changed(); }); property_row(ui, "Height Scale", |ui| { changed |= ui .add( egui::DragValue::new(&mut terrain.height_scale) .range(0.01..=10000.0) .speed(0.1) .suffix(" m"), ) .changed(); }); property_row(ui, "Chunk Size", |ui| { changed |= ui .add( egui::DragValue::new(&mut terrain.chunk_quads) .range(1..=terrain.resolution.saturating_sub(1)) .suffix(" quads"), ) .changed(); }); property_row(ui, "Collision", |ui| { changed |= ui .checkbox(&mut terrain.generate_colliders, "Generate") .changed(); }); property_row(ui, "Shadows", |ui| { ui.horizontal_wrapped(|ui| { changed |= ui.checkbox(&mut terrain.cast_shadows, "Cast").changed(); changed |= ui .checkbox(&mut terrain.receive_shadows, "Receive") .changed(); }); }); property_row(ui, "Base Material", |ui| { ui.label( terrain .base_material .as_ref() .map(|material| material.label.as_str()) .filter(|label| !label.is_empty()) .unwrap_or("Terrain fallback"), ); }); match terrain.validate() { Ok(()) => { ui.label( egui::RichText::new(format!( "{} heights • {}×{} chunks", terrain.heights.len(), (terrain.resolution - 1).div_ceil(terrain.chunk_quads), (terrain.resolution - 1).div_ceil(terrain.chunk_quads) )) .color(super::theme::SUCCESS), ); } Err(error) => { ui.label(egui::RichText::new(error).color(super::theme::ERROR)); } } }); apply_component_card_response(world, entity, card_response); if changed && terrain != original { let _ = reflected_component_transaction( world, entity, "Edit Terrain", shared::AUTHORING_COMPONENT_TERRAIN, COMPONENT_TERRAIN_DESC, move |world, entity| { world.entity_mut(entity).insert(terrain); Ok(()) }, ); } } 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::() 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::() .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::() { assets.clear_drag(); } } fn primitive_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { let Some(mut primitive) = world.get::(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::(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::() .is_some_and(|profile| profile.gi_path == settings::GiPath::SolariDeferred) && world .get_resource::() .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::(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::(entity).cloned() else { return; }; let original = collider.clone(); let mut changed = false; let renderer_meshes: Vec = world .get::(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::(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::(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::(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::(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::(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::(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::(entity).is_none() { return; } let rendering = world .resource::() .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::().0 = Some(sun); if let Some(mut ui_state) = world.get_resource_mut::() { 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>() .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, candidates: &[TextureAssetCandidate], ) -> bool { let current_path = value.clone(); let dragging_selection = world .get_resource::() .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 = 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::() { 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::() { scene_io.status = format!("Could not locate texture {path}"); } return; }; if let Some(mut assets) = world.get_resource_mut::() { assets.current_folder = candidate.folder_path.clone(); assets.select(candidate.selection.clone()); } if let Some(mut ui_state) = world.get_resource_mut::() { 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) -> 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 }