4949 lines
185 KiB
Rust
4949 lines
185 KiB
Rust
//! Inspector extensions for authoring components.
|
||
|
||
use std::collections::{HashMap, HashSet};
|
||
use std::path::Path;
|
||
|
||
use bevy::prelude::*;
|
||
use bevy_egui::egui;
|
||
use egui_phosphor_icons::{icons, Icon};
|
||
use shared::{
|
||
authoring_component_active,
|
||
brush_math::{validate_brush, BrushDiagnosticSeverity},
|
||
infer_actor_kind, 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 crate::viewport::collider_diagnostics::{
|
||
diagnose_collider, ColliderDiagnosticSeverity, ColliderOverlayStatus,
|
||
};
|
||
|
||
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(¤t.artifact_path);
|
||
}
|
||
bounds
|
||
}
|
||
|
||
fn insert_component(world: &mut World, entity: Entity, insert: impl FnOnce(&mut World, Entity)) {
|
||
let before = crate::history::snapshot_entity(world, entity);
|
||
let old_kind = before.as_ref().map(|s| s.actor_kind);
|
||
insert(world, entity);
|
||
let after = crate::history::snapshot_entity(world, entity);
|
||
if let (Some(before), Some(after)) = (before, after) {
|
||
if let Some(old) = old_kind {
|
||
if old != after.actor_kind {
|
||
crate::history::set_actor_kind_with_history(world, entity, old, after.actor_kind);
|
||
}
|
||
}
|
||
crate::history::push_command(
|
||
world,
|
||
crate::history::EditorCommand::AddComponent {
|
||
entity,
|
||
snapshot: diff_added(&before, &after),
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
fn diff_added(before: &EditorEntitySnapshot, after: &EditorEntitySnapshot) -> EditorEntitySnapshot {
|
||
EditorEntitySnapshot {
|
||
actor_id: None,
|
||
actor_kind: after.actor_kind,
|
||
actor_name: None,
|
||
name: None,
|
||
transform: after.transform,
|
||
primitive: after
|
||
.primitive
|
||
.clone()
|
||
.filter(|_| before.primitive.is_none()),
|
||
brush: after.brush.clone().filter(|_| before.brush.is_none()),
|
||
static_mesh_renderer: after
|
||
.static_mesh_renderer
|
||
.clone()
|
||
.filter(|_| before.static_mesh_renderer.is_none()),
|
||
skinned_mesh_renderer: after
|
||
.skinned_mesh_renderer
|
||
.clone()
|
||
.filter(|_| before.skinned_mesh_renderer.is_none()),
|
||
material: after.material.clone().filter(|_| before.material.is_none()),
|
||
material_override: after
|
||
.material_override
|
||
.clone()
|
||
.filter(|_| before.material_override.is_none()),
|
||
rigid_body: after.rigid_body.filter(|_| before.rigid_body.is_none()),
|
||
collider: after.collider.clone().filter(|_| before.collider.is_none()),
|
||
physics: after.physics.clone().filter(|_| before.physics.is_none()),
|
||
light: after.light.clone().filter(|_| before.light.is_none()),
|
||
animation_controller: after
|
||
.animation_controller
|
||
.clone()
|
||
.filter(|_| before.animation_controller.is_none()),
|
||
audio_source: after
|
||
.audio_source
|
||
.clone()
|
||
.filter(|_| before.audio_source.is_none()),
|
||
audio_listener: after
|
||
.audio_listener
|
||
.filter(|_| before.audio_listener.is_none()),
|
||
player_spawn: after.player_spawn && !before.player_spawn,
|
||
model: after.model.clone().filter(|_| before.model.is_none()),
|
||
prefab: after.prefab.clone().filter(|_| before.prefab.is_none()),
|
||
prefab_instance: after
|
||
.prefab_instance
|
||
.clone()
|
||
.filter(|_| before.prefab_instance.is_none()),
|
||
weapon_spawn: after
|
||
.weapon_spawn
|
||
.clone()
|
||
.filter(|_| before.weapon_spawn.is_none()),
|
||
trigger_volume: after
|
||
.trigger_volume
|
||
.clone()
|
||
.filter(|_| before.trigger_volume.is_none()),
|
||
post_process_volume: after
|
||
.post_process_volume
|
||
.clone()
|
||
.filter(|_| before.post_process_volume.is_none()),
|
||
team_spawn: after
|
||
.team_spawn
|
||
.clone()
|
||
.filter(|_| before.team_spawn.is_none()),
|
||
objective: after
|
||
.objective
|
||
.clone()
|
||
.filter(|_| before.objective.is_none()),
|
||
navigation_bounds: after
|
||
.navigation_bounds
|
||
.clone()
|
||
.filter(|_| before.navigation_bounds.is_none()),
|
||
navigation_obstacle: after
|
||
.navigation_obstacle
|
||
.clone()
|
||
.filter(|_| before.navigation_obstacle.is_none()),
|
||
navigation_area: after
|
||
.navigation_area
|
||
.clone()
|
||
.filter(|_| before.navigation_area.is_none()),
|
||
navigation_link: after
|
||
.navigation_link
|
||
.clone()
|
||
.filter(|_| before.navigation_link.is_none()),
|
||
hierarchy_sibling_index: after.hierarchy_sibling_index,
|
||
editor_visibility: after.editor_visibility,
|
||
inspector_order: None,
|
||
component_states: None,
|
||
children: Vec::new(),
|
||
}
|
||
}
|
||
|
||
fn static_mesh_asset_ref_candidates(
|
||
world: &mut World,
|
||
kind: AssetRefCandidateKind,
|
||
) -> Vec<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 ®istry.records {
|
||
let Some(manifest_path) = record.import_settings.static_mesh_manifest_path.as_deref()
|
||
else {
|
||
continue;
|
||
};
|
||
let Ok(manifest) = load_static_mesh_manifest(manifest_path) else {
|
||
continue;
|
||
};
|
||
let selection = AssetSelection::File(manifest.source.path.clone());
|
||
let texture_id = thumbnail_by_path.get(&manifest.source.path).copied();
|
||
let folder_path = catalog
|
||
.and_then(|assets| {
|
||
assets
|
||
.assets
|
||
.iter()
|
||
.find(|asset| asset.path.as_deref() == Some(manifest.source.path.as_str()))
|
||
.map(|asset| asset.folder_path.clone())
|
||
})
|
||
.unwrap_or_else(|| fallback_asset_folder(&manifest.source.path));
|
||
|
||
for part in &manifest.parts {
|
||
let source_material_ref = source_material_ref_for_part(&manifest.asset_id, part);
|
||
let candidate = match kind {
|
||
AssetRefCandidateKind::Mesh => {
|
||
let sub_asset_id = if part.id.trim().is_empty() {
|
||
part_id_from_label(&part.mesh_label)
|
||
} else {
|
||
part.id.clone()
|
||
};
|
||
AssetRefCandidate {
|
||
reference: EditorAssetRef::new(
|
||
manifest.asset_id.clone(),
|
||
sub_asset_id,
|
||
part.name.clone(),
|
||
),
|
||
label: part.name.clone(),
|
||
detail: format!("{} | {}", manifest.label, manifest.source.path),
|
||
selection: selection.clone(),
|
||
folder_path: folder_path.clone(),
|
||
texture_id,
|
||
}
|
||
}
|
||
AssetRefCandidateKind::Material => {
|
||
let Some(material_ref) = source_material_ref else {
|
||
continue;
|
||
};
|
||
AssetRefCandidate {
|
||
reference: material_ref,
|
||
label: part.material_slot_name.clone(),
|
||
detail: format!("{} | {}", manifest.label, manifest.source.path),
|
||
selection: selection.clone(),
|
||
folder_path: folder_path.clone(),
|
||
texture_id: None,
|
||
}
|
||
}
|
||
AssetRefCandidateKind::Texture => continue,
|
||
};
|
||
|
||
let key = (
|
||
candidate.reference.asset_id.clone(),
|
||
candidate.reference.sub_asset_id.clone(),
|
||
);
|
||
if seen.insert(key) {
|
||
candidates.push(candidate);
|
||
}
|
||
}
|
||
}
|
||
|
||
candidates.sort_by(|a, b| a.detail.cmp(&b.detail).then(a.label.cmp(&b.label)));
|
||
candidates
|
||
}
|
||
|
||
fn texture_asset_candidates(world: &World) -> Vec<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(¶meter.name);
|
||
match &mut parameter.value {
|
||
MaterialParameterValue::Bool(value) => ui.checkbox(value, "").changed(),
|
||
MaterialParameterValue::Float(value) => ui
|
||
.add_sized(
|
||
[fit_width(ui, 64.0, 120.0), 20.0],
|
||
egui::DragValue::new(value)
|
||
.speed(0.01)
|
||
.min_decimals(2)
|
||
.max_decimals(4),
|
||
)
|
||
.changed(),
|
||
MaterialParameterValue::Vec2(value) => {
|
||
let mut changed = false;
|
||
changed |= ui
|
||
.add_sized(
|
||
[fit_width(ui, 52.0, 88.0), 20.0],
|
||
egui::DragValue::new(&mut value.x).speed(0.01),
|
||
)
|
||
.changed();
|
||
changed |= ui
|
||
.add_sized(
|
||
[fit_width(ui, 52.0, 88.0), 20.0],
|
||
egui::DragValue::new(&mut value.y).speed(0.01),
|
||
)
|
||
.changed();
|
||
changed
|
||
}
|
||
MaterialParameterValue::Vec3(value) => {
|
||
let mut changed = false;
|
||
changed |= ui
|
||
.add_sized(
|
||
[fit_width(ui, 52.0, 88.0), 20.0],
|
||
egui::DragValue::new(&mut value.x).speed(0.01),
|
||
)
|
||
.changed();
|
||
changed |= ui
|
||
.add_sized(
|
||
[fit_width(ui, 52.0, 88.0), 20.0],
|
||
egui::DragValue::new(&mut value.y).speed(0.01),
|
||
)
|
||
.changed();
|
||
changed |= ui
|
||
.add_sized(
|
||
[fit_width(ui, 52.0, 88.0), 20.0],
|
||
egui::DragValue::new(&mut value.z).speed(0.01),
|
||
)
|
||
.changed();
|
||
changed
|
||
}
|
||
MaterialParameterValue::Color(value) => {
|
||
let mut rgba = [value.r, value.g, value.b, value.a];
|
||
let changed = ui.color_edit_button_rgba_unmultiplied(&mut rgba).changed();
|
||
if changed {
|
||
*value = ColorDesc {
|
||
r: rgba[0],
|
||
g: rgba[1],
|
||
b: rgba[2],
|
||
a: rgba[3],
|
||
};
|
||
}
|
||
changed
|
||
}
|
||
MaterialParameterValue::Enum(value) => ui
|
||
.add_sized(
|
||
[
|
||
fit_width(ui, MIN_INLINE_CONTROL_WIDTH, TEXT_FIELD_MAX_WIDTH),
|
||
20.0,
|
||
],
|
||
egui::TextEdit::singleline(value),
|
||
)
|
||
.changed(),
|
||
}
|
||
})
|
||
.inner
|
||
}
|
||
|
||
pub(crate) fn component_card(
|
||
ui: &mut egui::Ui,
|
||
context: &ComponentCardContext,
|
||
add_contents: impl FnOnce(&mut egui::Ui),
|
||
) -> ComponentCardResponse {
|
||
let mut response = ComponentCardResponse {
|
||
type_name: context.options.type_name,
|
||
..Default::default()
|
||
};
|
||
egui::Frame::new()
|
||
.fill(WIDGET_BG)
|
||
.stroke(egui::Stroke::new(1.0, BORDER))
|
||
.corner_radius(egui::CornerRadius::same(4))
|
||
.inner_margin(egui::Margin::symmetric(8, 8))
|
||
.show(ui, |ui| {
|
||
ui.horizontal(|ui| {
|
||
let caret = if context.collapsed {
|
||
icons::CARET_RIGHT
|
||
} else {
|
||
icons::CARET_DOWN
|
||
};
|
||
if ui
|
||
.add(
|
||
egui::Button::new(phosphor_icon(caret, 13.0).color(TEXT_DIM))
|
||
.frame(false)
|
||
.min_size(egui::vec2(20.0, 20.0)),
|
||
)
|
||
.on_hover_text(if context.collapsed {
|
||
"Expand component"
|
||
} else {
|
||
"Collapse component"
|
||
})
|
||
.clicked()
|
||
{
|
||
response.collapsed = Some(!context.collapsed);
|
||
}
|
||
ui.label(phosphor_icon(context.options.icon, 16.0).color(TEXT_DIM));
|
||
ui.label(panel_heading(context.options.title));
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||
let menu = ui.menu_button(
|
||
phosphor_icon(icons::DOTS_THREE_VERTICAL, 16.0).color(TEXT_DIM),
|
||
|ui| {
|
||
ui.set_min_width(160.0);
|
||
if ui
|
||
.add_enabled(context.options.resettable, egui::Button::new("Reset"))
|
||
.clicked()
|
||
{
|
||
response.reset = true;
|
||
ui.close();
|
||
}
|
||
if ui
|
||
.add_enabled(
|
||
context.options.copyable,
|
||
egui::Button::new("Copy Values"),
|
||
)
|
||
.clicked()
|
||
{
|
||
response.copy = true;
|
||
ui.close();
|
||
}
|
||
if ui
|
||
.add_enabled(context.pasteable, egui::Button::new("Paste Values"))
|
||
.clicked()
|
||
{
|
||
response.paste = true;
|
||
ui.close();
|
||
}
|
||
ui.separator();
|
||
if ui
|
||
.add_enabled(context.can_move_up, egui::Button::new("Move Up"))
|
||
.clicked()
|
||
{
|
||
response.move_up = true;
|
||
ui.close();
|
||
}
|
||
if ui
|
||
.add_enabled(context.can_move_down, egui::Button::new("Move Down"))
|
||
.clicked()
|
||
{
|
||
response.move_down = true;
|
||
ui.close();
|
||
}
|
||
ui.separator();
|
||
ui.add_enabled(false, egui::Button::new("Open Documentation"));
|
||
if ui
|
||
.add_enabled(
|
||
context.options.removable,
|
||
egui::Button::new(
|
||
egui::RichText::new("Remove")
|
||
.color(egui::Color32::from_rgb(255, 137, 129)),
|
||
),
|
||
)
|
||
.clicked()
|
||
{
|
||
response.remove = true;
|
||
ui.close();
|
||
}
|
||
},
|
||
);
|
||
menu.response.on_hover_text("Component actions");
|
||
if context.options.active_toggle {
|
||
if status_dot_button(
|
||
ui,
|
||
if context.active {
|
||
egui::Color32::from_rgb(74, 181, 104)
|
||
} else {
|
||
TEXT_MUTED
|
||
},
|
||
if context.active {
|
||
"Component active. Click to disable."
|
||
} else {
|
||
"Component disabled. Click to enable."
|
||
},
|
||
)
|
||
.clicked()
|
||
{
|
||
response.active = Some(!context.active);
|
||
}
|
||
} else {
|
||
status_dot(
|
||
ui,
|
||
egui::Color32::from_rgb(74, 181, 104),
|
||
"Component active",
|
||
);
|
||
}
|
||
});
|
||
});
|
||
if !context.collapsed {
|
||
ui.add_space(4.0);
|
||
egui::Frame::new()
|
||
.fill(ELEVATED_BG.linear_multiply(0.35))
|
||
.corner_radius(egui::CornerRadius::same(4))
|
||
.inner_margin(egui::Margin::symmetric(8, 8))
|
||
.show(ui, |ui| {
|
||
ui.set_clip_rect(ui.max_rect());
|
||
ui.set_max_width(ui.available_width());
|
||
add_contents(ui);
|
||
});
|
||
}
|
||
});
|
||
ui.add_space(6.0);
|
||
response
|
||
}
|
||
|
||
pub(crate) fn property_row<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 material_candidates = brush_face_material_ref_candidates(world);
|
||
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 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 mut remove_layer = None;
|
||
let mut swap_layers = None;
|
||
let mut locate_layer = None;
|
||
let mut accepted_drop = false;
|
||
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.material_weights.clear();
|
||
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();
|
||
});
|
||
});
|
||
ui.add_space(6.0);
|
||
ui.separator();
|
||
ui.horizontal(|ui| {
|
||
ui.label(egui::RichText::new("Material Layers").strong());
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||
if ui
|
||
.add_enabled(
|
||
terrain.material_layers.len() < shared::TERRAIN_MATERIAL_LAYER_LIMIT,
|
||
egui::Button::new(phosphor_icon(icons::PLUS, 16.0)),
|
||
)
|
||
.on_hover_text("Add a terrain blend channel")
|
||
.clicked()
|
||
{
|
||
let material = if terrain.material_layers.is_empty() {
|
||
terrain.base_material.take()
|
||
} else {
|
||
None
|
||
};
|
||
terrain.material_layers.push(shared::TerrainMaterialLayer {
|
||
material,
|
||
..Default::default()
|
||
});
|
||
changed = true;
|
||
}
|
||
});
|
||
});
|
||
if terrain.material_layers.is_empty() {
|
||
ui.label(
|
||
egui::RichText::new(
|
||
terrain
|
||
.base_material
|
||
.as_ref()
|
||
.map(|material| format!("Legacy base: {}", material.label))
|
||
.unwrap_or_else(|| "No layers assigned; visible terrain fallback".into()),
|
||
)
|
||
.color(TEXT_DIM)
|
||
.small(),
|
||
);
|
||
}
|
||
for index in 0..terrain.material_layers.len() {
|
||
ui.add_space(4.0);
|
||
ui.horizontal(|ui| {
|
||
ui.label(
|
||
egui::RichText::new(format!("Layer {}", index + 1))
|
||
.strong()
|
||
.color(if index == 0 {
|
||
SELECTION_BG_MUTED
|
||
} else {
|
||
TEXT_DIM
|
||
}),
|
||
);
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||
if icon_button_small(ui, icons::TRASH, "Remove layer").clicked() {
|
||
remove_layer = Some(index);
|
||
}
|
||
if ui
|
||
.add_enabled(
|
||
index + 1 < terrain.material_layers.len(),
|
||
egui::Button::new(phosphor_icon(icons::ARROW_DOWN, 15.0)).frame(false),
|
||
)
|
||
.on_hover_text("Move layer down")
|
||
.clicked()
|
||
{
|
||
swap_layers = Some((index, index + 1));
|
||
}
|
||
if ui
|
||
.add_enabled(
|
||
index > 0,
|
||
egui::Button::new(phosphor_icon(icons::ARROW_UP, 15.0)).frame(false),
|
||
)
|
||
.on_hover_text("Move layer up")
|
||
.clicked()
|
||
{
|
||
swap_layers = Some((index, index - 1));
|
||
}
|
||
});
|
||
});
|
||
let response = asset_selector_row(
|
||
ui,
|
||
"Material",
|
||
icons::PALETTE,
|
||
terrain.material_layers[index].material.as_ref(),
|
||
None,
|
||
true,
|
||
&material_candidates,
|
||
material_drop_candidate.as_ref(),
|
||
);
|
||
if let Some(selected) = response.selected {
|
||
terrain.material_layers[index].material = Some(selected);
|
||
changed = true;
|
||
}
|
||
if response.clear {
|
||
terrain.material_layers[index].material = None;
|
||
changed = true;
|
||
}
|
||
if response.locate {
|
||
locate_layer = Some(index);
|
||
}
|
||
accepted_drop |= response.accepted_drop;
|
||
property_row(ui, "UV Tiling", |ui| {
|
||
changed |= ui
|
||
.add(
|
||
egui::DragValue::new(&mut terrain.material_layers[index].uv_scale)
|
||
.range(0.01..=1024.0)
|
||
.speed(0.1)
|
||
.suffix("×"),
|
||
)
|
||
.changed();
|
||
});
|
||
if !terrain.material_weights.is_empty() {
|
||
let covered = terrain
|
||
.material_weights
|
||
.iter()
|
||
.filter(|weights| weights[index] > 0)
|
||
.count();
|
||
ui.label(
|
||
egui::RichText::new(format!(
|
||
"{} / {} samples carry this layer",
|
||
covered,
|
||
terrain.material_weights.len()
|
||
))
|
||
.color(TEXT_DIM)
|
||
.small(),
|
||
);
|
||
}
|
||
}
|
||
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 let Some(index) = remove_layer {
|
||
remove_terrain_material_layer(&mut terrain, index);
|
||
changed = true;
|
||
}
|
||
if let Some((a, b)) = swap_layers {
|
||
terrain.material_layers.swap(a, b);
|
||
for weights in &mut terrain.material_weights {
|
||
weights.swap(a, b);
|
||
}
|
||
changed = true;
|
||
}
|
||
if let Some(index) = locate_layer {
|
||
locate_asset_ref(
|
||
world,
|
||
terrain
|
||
.material_layers
|
||
.get(index)
|
||
.and_then(|layer| layer.material.as_ref()),
|
||
&material_candidates,
|
||
);
|
||
}
|
||
if accepted_drop {
|
||
clear_asset_drag(world);
|
||
}
|
||
|
||
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 remove_terrain_material_layer(terrain: &mut TerrainDesc, index: usize) {
|
||
if index >= terrain.material_layers.len() {
|
||
return;
|
||
}
|
||
terrain.material_layers.remove(index);
|
||
if terrain.material_layers.is_empty() {
|
||
terrain.material_weights.clear();
|
||
return;
|
||
}
|
||
for weights in &mut terrain.material_weights {
|
||
for channel in index..3 {
|
||
weights[channel] = weights[channel + 1];
|
||
}
|
||
weights[3] = 0;
|
||
normalize_terrain_weights(weights, terrain.material_layers.len());
|
||
}
|
||
}
|
||
|
||
fn normalize_terrain_weights(weights: &mut [u8; 4], layer_count: usize) {
|
||
for weight in weights.iter_mut().skip(layer_count) {
|
||
*weight = 0;
|
||
}
|
||
let sum: u16 = weights
|
||
.iter()
|
||
.take(layer_count)
|
||
.copied()
|
||
.map(u16::from)
|
||
.sum();
|
||
if sum == 0 {
|
||
*weights = [255, 0, 0, 0];
|
||
return;
|
||
}
|
||
let source = *weights;
|
||
let mut assigned = 0_u16;
|
||
for index in 0..layer_count {
|
||
weights[index] = ((u16::from(source[index]) * 255) / sum) as u8;
|
||
assigned += u16::from(weights[index]);
|
||
}
|
||
let largest = (0..layer_count)
|
||
.max_by_key(|index| source[*index])
|
||
.unwrap_or(0);
|
||
weights[largest] = weights[largest].saturating_add((255 - assigned) as u8);
|
||
}
|
||
|
||
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 diagnostic_entry = diagnose_collider(world, entity);
|
||
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| {
|
||
if let Some(entry) = diagnostic_entry.as_ref() {
|
||
let (label, color) = match entry.overlay_status {
|
||
ColliderOverlayStatus::Valid => ("Ready", egui::Color32::from_rgb(112, 210, 144)),
|
||
ColliderOverlayStatus::Trigger => {
|
||
("Trigger", egui::Color32::from_rgb(86, 195, 235))
|
||
}
|
||
ColliderOverlayStatus::Disabled => {
|
||
("Disabled", egui::Color32::from_rgb(143, 151, 163))
|
||
}
|
||
ColliderOverlayStatus::Warning => {
|
||
("Warning", egui::Color32::from_rgb(242, 173, 72))
|
||
}
|
||
ColliderOverlayStatus::Error => ("Invalid", egui::Color32::from_rgb(244, 91, 99)),
|
||
};
|
||
ui.horizontal_wrapped(|ui| {
|
||
ui.colored_label(color, egui::RichText::new(label).strong());
|
||
ui.label(egui::RichText::new(entry.shape_label).color(TEXT_DIM));
|
||
if entry.runtime_ready {
|
||
ui.label(egui::RichText::new("Hydrated").color(TEXT_MUTED).small());
|
||
}
|
||
});
|
||
for diagnostic in &entry.diagnostics {
|
||
let color = match diagnostic.severity {
|
||
ColliderDiagnosticSeverity::Info => TEXT_MUTED,
|
||
ColliderDiagnosticSeverity::Warning => egui::Color32::from_rgb(242, 173, 72),
|
||
ColliderDiagnosticSeverity::Error => egui::Color32::from_rgb(244, 91, 99),
|
||
};
|
||
ui.label(
|
||
egui::RichText::new(&diagnostic.message)
|
||
.color(color)
|
||
.small(),
|
||
);
|
||
ui.label(
|
||
egui::RichText::new(&diagnostic.repair)
|
||
.color(TEXT_MUTED)
|
||
.small(),
|
||
);
|
||
}
|
||
if entry.highest_severity() == Some(ColliderDiagnosticSeverity::Error)
|
||
&& ui.small_button("Reset shape").clicked()
|
||
{
|
||
collider.shape = ColliderShapeDesc::default();
|
||
collider.enabled = true;
|
||
changed = true;
|
||
}
|
||
ui.add_space(2.0);
|
||
}
|
||
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", entity))
|
||
.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 =
|
||
convert_collider_shape(&collider.shape, shape_kind, renderer_meshes.clone());
|
||
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 convert_collider_shape(
|
||
previous: &ColliderShapeDesc,
|
||
shape_kind: &str,
|
||
renderer_meshes: Vec<EditorAssetRef>,
|
||
) -> ColliderShapeDesc {
|
||
let dimensions = match previous {
|
||
ColliderShapeDesc::Cuboid {
|
||
x_length,
|
||
y_length,
|
||
z_length,
|
||
} => Vec3::new(*x_length, *y_length, *z_length),
|
||
ColliderShapeDesc::Sphere { radius } => Vec3::splat(*radius * 2.0),
|
||
ColliderShapeDesc::Capsule { radius, height } => {
|
||
Vec3::new(*radius * 2.0, *height, *radius * 2.0)
|
||
}
|
||
ColliderShapeDesc::StaticMesh { .. } => Vec3::ONE,
|
||
}
|
||
.max(Vec3::splat(0.001));
|
||
|
||
match shape_kind {
|
||
"Sphere" => ColliderShapeDesc::Sphere {
|
||
radius: dimensions.max_element() * 0.5,
|
||
},
|
||
"Capsule" => ColliderShapeDesc::Capsule {
|
||
radius: dimensions.x.max(dimensions.z) * 0.5,
|
||
height: dimensions.y,
|
||
},
|
||
"Static Mesh" => ColliderShapeDesc::static_mesh(renderer_meshes),
|
||
_ => ColliderShapeDesc::Cuboid {
|
||
x_length: dimensions.x,
|
||
y_length: dimensions.y,
|
||
z_length: dimensions.z,
|
||
},
|
||
}
|
||
}
|
||
|
||
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.001..=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
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod collider_inspector_tests {
|
||
use super::*;
|
||
use crate::history::{apply_command_undo, EditorHistory};
|
||
|
||
#[test]
|
||
fn shape_switch_preserves_dimensions_and_is_one_undoable_edit() {
|
||
let mut world = World::new();
|
||
world.init_resource::<EditorHistory>();
|
||
let original = ColliderDesc::static_cuboid(Vec3::new(2.0, 4.0, 6.0));
|
||
let entity = world.spawn((LevelObject, original.clone())).id();
|
||
|
||
let sphere = ColliderDesc {
|
||
shape: convert_collider_shape(&original.shape, "Sphere", Vec::new()),
|
||
..original.clone()
|
||
};
|
||
assert_eq!(sphere.shape, ColliderShapeDesc::Sphere { radius: 3.0 });
|
||
|
||
set_collider_with_history(&mut world, entity, sphere.clone());
|
||
assert_eq!(world.resource::<EditorHistory>().undo_depth(), 1);
|
||
assert_eq!(world.get::<ColliderDesc>(entity), Some(&sphere));
|
||
|
||
apply_command_undo(&mut world);
|
||
assert_eq!(world.get::<ColliderDesc>(entity), Some(&original));
|
||
}
|
||
}
|