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