Blacksite/crates/editor/src/ui/inspector.rs
Rbanh ed21bcc52d
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Add chunked terrain authoring foundation
2026-07-12 18:43:42 -04:00

4645 lines
173 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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