diff --git a/README.md b/README.md index 6787f5b..3f4aa9f 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ VS Code tasks: **target cleanup (dry run)** and **target cleanup (safe apply)**. | `Ctrl+G` | Toggle viewport grid | | `F` | Focus editor camera on selection | | `Ctrl+Shift+1` / `Ctrl+Shift+2` | Save / recall viewport camera bookmark | -| RMB + mouse | Editor camera look | +| RMB + mouse | Editor camera look; cursor hides while held | | RMB + `W` `A` `S` `D` | Editor camera fly | | RMB + `Q` / `E` | Editor camera down / up | | Mouse wheel | Dolly editor camera | @@ -123,9 +123,11 @@ VS Code tasks: **target cleanup (dry run)** and **target cleanup (safe apply)**. | Hierarchy context | Reparent to other selection / Unparent | | File menu | New, Open, Save, Save As, Import Assets, Export Selection, Save Selection As Prefab, Recent Scenes | | Inspector component card | Collapse with caret, toggle active with status dot, or use triple-dot menu for reset/copy/paste/move/remove actions | -| Inspector footer → Add Component | Pinned search/add control for registered authoring, rendering, physics, and gameplay components with undo | +| Inspector footer → Add Component | Expands an inline search shelf for registered authoring, rendering, physics, gameplay, and volume components with descriptions, availability hints, and undo | | Edit → Project Settings… | Edit `assets/project.ron` (rendering, physics, input) | +Viewport shortcut keys require the pointer to be in the viewport and are suspended while typing in egui text fields or actively using camera navigation. `Delete`, `Backspace`, duplicate, and undo/redo also defer to text-field focus. + ### Play Mode - Press **F5** (or Play menu) to run the **real game** in-process: same `GamePlugin`, player, diff --git a/crates/editor/src/history/mod.rs b/crates/editor/src/history/mod.rs index c95d768..535ae73 100644 --- a/crates/editor/src/history/mod.rs +++ b/crates/editor/src/history/mod.rs @@ -13,7 +13,7 @@ use crate::ui::hierarchy_ops::{ apply_sibling_change, apply_sibling_change_new, next_sibling_index, reorder_entities_under_parent, would_create_cycle, }; -use crate::ui::UiState; +use crate::ui::{egui_captures_keyboard_from_world, UiState}; mod commands; pub use commands::{EditorCommand, EditorEntitySnapshot, SiblingChange}; @@ -1189,6 +1189,10 @@ fn remove_authored_components(world: &mut World, entity: Entity, snapshot: &Edit } fn handle_history_hotkeys(world: &mut World) { + if egui_captures_keyboard_from_world(world) { + return; + } + let (undo, redo, delete, duplicate) = { let keys = world.resource::>(); let ctrl = keys.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]); diff --git a/crates/editor/src/ui/actor_inspector/mod.rs b/crates/editor/src/ui/actor_inspector/mod.rs index f661c15..5f4427c 100644 --- a/crates/editor/src/ui/actor_inspector/mod.rs +++ b/crates/editor/src/ui/actor_inspector/mod.rs @@ -79,40 +79,33 @@ pub fn draw_actor_inspector(world: &mut World, ui: &mut egui::Ui, entity: Entity }); ui.add_space(6.0); - let footer_reserve = 52.0; - let body_height = (ui.available_height() - footer_reserve).max(1.0); - ui.allocate_ui_with_layout( - egui::vec2(ui.available_width(), body_height), - egui::Layout::top_down(egui::Align::Min), - |ui| { - egui::ScrollArea::vertical() - .auto_shrink([false, false]) - .show(ui, |ui| { - transform_inspector_ui(world, ui, entity); + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + transform_inspector_ui(world, ui, entity); - match kind { - ActorKind::StaticMesh - | ActorKind::ImportedModel - | ActorKind::Light - | ActorKind::Empty - | ActorKind::PrefabAnchor - | ActorKind::PlayerSpawn - | ActorKind::WeaponSpawn - | ActorKind::TriggerVolume - | ActorKind::PostProcessVolume - | ActorKind::TeamSpawn - | ActorKind::Objective => { - inspector::authoring_inspector_ui(world, ui, entity); - } - } + match kind { + ActorKind::StaticMesh + | ActorKind::ImportedModel + | ActorKind::Light + | ActorKind::Empty + | ActorKind::PrefabAnchor + | ActorKind::PlayerSpawn + | ActorKind::WeaponSpawn + | ActorKind::TriggerVolume + | ActorKind::PostProcessVolume + | ActorKind::TeamSpawn + | ActorKind::Objective => { + inspector::authoring_inspector_ui(world, ui, entity); + } + } - world.resource_scope(|world, registry: Mut| { - registry.draw_sections(world, ui, entity); - }); - }); - }, - ); - inspector::add_component_footer(world, ui, entity); + world.resource_scope(|world, registry: Mut| { + registry.draw_sections(world, ui, entity); + }); + + inspector::add_component_footer(world, ui, entity); + }); } fn actor_icon(kind: ActorKind) -> egui_phosphor_icons::Icon { diff --git a/crates/editor/src/ui/component_registry.rs b/crates/editor/src/ui/component_registry.rs index c36ef85..a452e58 100644 --- a/crates/editor/src/ui/component_registry.rs +++ b/crates/editor/src/ui/component_registry.rs @@ -5,9 +5,11 @@ use egui_phosphor_icons::icons; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EditorComponentCategory { + Authoring, Rendering, Physics, Gameplay, + Volumes, Editor, } @@ -19,8 +21,13 @@ pub struct EditorComponentDescriptor { pub addable: bool, pub removable: bool, pub reorderable: bool, + pub hidden: bool, pub icon: &'static str, + pub description: &'static str, pub search_terms: &'static [&'static str], + pub recommended: &'static [&'static str], + pub conflicts_with: &'static [&'static str], + pub hydration_effect: &'static str, } #[derive(Resource, Debug, Clone)] @@ -39,8 +46,14 @@ impl Default for EditorComponentRegistry { addable: true, removable: true, reorderable: true, + hidden: false, icon: icons::CUBE.as_str(), - search_terms: &["mesh", "renderer", "rendering", "model"], + description: "Renders one or more normalized imported mesh slots.", + search_terms: &["mesh", "renderer", "rendering", "model", "static"], + recommended: &[], + conflicts_with: &["shared::components::Primitive"], + hydration_effect: + "Hydrates into generated mesh children and material bindings.", }, EditorComponentDescriptor { type_name: "shared::components::MaterialDesc", @@ -49,8 +62,13 @@ impl Default for EditorComponentRegistry { addable: true, removable: true, reorderable: true, + hidden: false, icon: icons::PALETTE.as_str(), + description: "Defines an authored material directly on this actor.", search_terms: &["material", "shader", "texture"], + recommended: &[], + conflicts_with: &[], + hydration_effect: "Hydrates into Bevy material assets for renderable actors.", }, EditorComponentDescriptor { type_name: "shared::components::RigidBodyDesc", @@ -59,8 +77,13 @@ impl Default for EditorComponentRegistry { addable: true, removable: true, reorderable: true, + hidden: false, icon: icons::SPHERE.as_str(), + description: "Controls authored rigid-body behavior for physics hydration.", search_terms: &["body", "physics", "rigidbody"], + recommended: &["shared::components::ColliderDesc"], + conflicts_with: &[], + hydration_effect: "Hydrates into runtime Avian rigid-body state.", }, EditorComponentDescriptor { type_name: "shared::components::ColliderDesc", @@ -69,8 +92,13 @@ impl Default for EditorComponentRegistry { addable: true, removable: true, reorderable: true, + hidden: false, icon: icons::SELECTION.as_str(), + description: "Defines authored collision shape data.", search_terms: &["collision", "trigger", "physics"], + recommended: &["shared::components::RigidBodyDesc"], + conflicts_with: &[], + hydration_effect: "Hydrates into runtime Avian collider state.", }, EditorComponentDescriptor { type_name: "shared::components::LightDesc", @@ -79,18 +107,29 @@ impl Default for EditorComponentRegistry { addable: true, removable: true, reorderable: true, + hidden: false, icon: icons::LIGHTBULB.as_str(), - search_terms: &["light", "lamp", "illumination"], + description: "Adds authored point, spot, directional, or area lighting.", + search_terms: &["light", "lamp", "illumination", "sun"], + recommended: &[], + conflicts_with: &[], + hydration_effect: "Hydrates into runtime Bevy light components.", }, EditorComponentDescriptor { type_name: "shared::components::Primitive", display_name: "Primitive", - category: EditorComponentCategory::Rendering, + category: EditorComponentCategory::Authoring, addable: true, removable: true, reorderable: true, + hidden: false, icon: icons::CUBE.as_str(), + description: "Creates an authored built-in primitive shape.", search_terms: &["box", "sphere", "ramp", "primitive"], + recommended: &[], + conflicts_with: &["shared::components::StaticMeshRenderer"], + hydration_effect: + "Hydrates into generated primitive render and collision data.", }, EditorComponentDescriptor { type_name: "shared::components::PlayerSpawn", @@ -99,8 +138,14 @@ impl Default for EditorComponentRegistry { addable: true, removable: true, reorderable: true, + hidden: false, icon: icons::PERSON_SIMPLE_RUN.as_str(), + description: "Marks where Play mode should spawn the player.", search_terms: &["player", "spawn", "start"], + recommended: &[], + conflicts_with: &[], + hydration_effect: + "Used by PIE/session startup; no saved runtime player is created.", }, EditorComponentDescriptor { type_name: "shared::components::WeaponSpawn", @@ -109,18 +154,28 @@ impl Default for EditorComponentRegistry { addable: true, removable: true, reorderable: true, + hidden: false, icon: icons::CROSSHAIR.as_str(), + description: "Marks a gameplay weapon spawn point.", search_terms: &["weapon", "spawn"], + recommended: &[], + conflicts_with: &[], + hydration_effect: "Hydrates into gameplay spawn metadata.", }, EditorComponentDescriptor { type_name: "shared::components::TriggerVolume", display_name: "Trigger Volume", - category: EditorComponentCategory::Gameplay, + category: EditorComponentCategory::Volumes, addable: true, removable: true, reorderable: true, + hidden: false, icon: icons::SELECTION.as_str(), + description: "Adds an authored gameplay trigger volume.", search_terms: &["trigger", "volume", "event"], + recommended: &[], + conflicts_with: &[], + hydration_effect: "Hydrates into gameplay trigger queries and visualizers.", }, EditorComponentDescriptor { type_name: "shared::components::TeamSpawn", @@ -129,8 +184,13 @@ impl Default for EditorComponentRegistry { addable: true, removable: true, reorderable: true, + hidden: false, icon: icons::FLAG.as_str(), + description: "Marks a gameplay team spawn point.", search_terms: &["team", "spawn"], + recommended: &[], + conflicts_with: &[], + hydration_effect: "Hydrates into gameplay spawn metadata.", }, EditorComponentDescriptor { type_name: "shared::components::ObjectiveMarker", @@ -139,8 +199,29 @@ impl Default for EditorComponentRegistry { addable: true, removable: true, reorderable: true, + hidden: false, icon: icons::FLAG.as_str(), + description: "Marks an authored gameplay objective location.", search_terms: &["objective", "marker", "gameplay"], + recommended: &[], + conflicts_with: &[], + hydration_effect: "Hydrates into gameplay objective metadata.", + }, + EditorComponentDescriptor { + type_name: "shared::components::PostProcessVolumeDesc", + display_name: "Post-Process Volume", + category: EditorComponentCategory::Volumes, + addable: true, + removable: true, + reorderable: true, + hidden: false, + icon: icons::APERTURE.as_str(), + description: "Adds a local rendering profile override volume.", + search_terms: &["post", "process", "volume", "rendering", "fx"], + recommended: &[], + conflicts_with: &[], + hydration_effect: + "Affects active camera rendering when the camera is inside the volume.", }, EditorComponentDescriptor { type_name: "shared::components::InspectorOrder", @@ -149,8 +230,14 @@ impl Default for EditorComponentRegistry { addable: false, removable: false, reorderable: false, + hidden: true, icon: icons::LIST_BULLETS.as_str(), + description: "Stores editor-only component ordering and active-state metadata.", search_terms: &["editor", "order"], + recommended: &[], + conflicts_with: &[], + hydration_effect: + "Controls editor presentation and authoring component active state.", }, ], } diff --git a/crates/editor/src/ui/inspector.rs b/crates/editor/src/ui/inspector.rs index 6fddb86..5a1cc40 100644 --- a/crates/editor/src/ui/inspector.rs +++ b/crates/editor/src/ui/inspector.rs @@ -34,7 +34,9 @@ use crate::ui::theme::{ }; use crate::ui::widgets::{icon_button_small, phosphor_icon, phosphor_icon_text}; -use super::component_registry::{EditorComponentCategory, EditorComponentRegistry}; +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}; @@ -101,6 +103,22 @@ pub(crate) struct InspectorClipboard { component: Option, } +#[derive(Resource, Default, Debug, Clone)] +pub(crate) struct InspectorPanelState { + add_component_search: String, + add_component_open: bool, + add_component_focus_search: bool, + add_component_scroll_selected: bool, + add_component_selected_index: usize, + add_component_target: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AddComponentShelfDirection { + Up, + Down, +} + #[derive(Debug, Clone)] enum CopiedComponent { Primitive(Primitive), @@ -664,90 +682,420 @@ fn draw_authoring_component_by_type( } pub(crate) fn add_component_footer(world: &mut World, ui: &mut egui::Ui, entity: Entity) { - let descriptors = world - .resource::() - .descriptors - .clone(); ui.add_space(4.0); - egui::Frame::new() + 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| { - ui.horizontal_wrapped(|ui| { - let mut search = String::new(); - let search_width = fit_width(ui, MIN_INLINE_CONTROL_WIDTH, TEXT_FIELD_MAX_WIDTH); - ui.add( - egui::TextEdit::singleline(&mut search) - .hint_text("Search components...") - .desired_width(search_width), - ); - ui.menu_button("Add Component", |ui| { - for category in [ - EditorComponentCategory::Rendering, - EditorComponentCategory::Physics, - EditorComponentCategory::Gameplay, - ] { - let mut wrote_category = false; - for descriptor in descriptors.iter().filter(|descriptor| { - descriptor.addable && descriptor.category == category - }) { - if !component_addable(world, entity, descriptor.type_name) { - continue; - } - if !wrote_category { - ui.label(panel_heading(match category { - EditorComponentCategory::Rendering => "Rendering", - EditorComponentCategory::Physics => "Physics", - EditorComponentCategory::Gameplay => "Gameplay", - EditorComponentCategory::Editor => "Editor", - })); - wrote_category = true; - } - let mut clicked = false; - ui.horizontal(|ui| { - ui.label(phosphor_icon_text(descriptor.icon, 14.0).color(TEXT_DIM)); - clicked = ui - .button(descriptor.display_name) - .on_hover_text(format!( - "{} | removable={} reorderable={} | {}", - descriptor.type_name, - descriptor.removable, - descriptor.reorderable, - descriptor.search_terms.join(", ") - )) - .clicked(); - }); - if clicked { - insert_registered_component(world, entity, descriptor.type_name); - ui.close(); - } - } - if wrote_category { - ui.separator(); - } + let open_for_entity = add_component_picker_open_for(world, entity); + let label = if open_for_entity { + "Close Add Component" + } else { + "+ Add Component" + }; + if ui.button(label).clicked() { + if let Some(mut state) = world.get_resource_mut::() { + if open_for_entity { + state.add_component_open = false; + state.add_component_target = None; + } else { + state.add_component_open = true; + state.add_component_focus_search = true; + state.add_component_scroll_selected = true; + state.add_component_selected_index = 0; + state.add_component_target = Some(entity); + state.add_component_search.clear(); } - }); - }); + } + } + }); + + if add_component_picker_open_for(world, entity) { + add_component_picker_shelf(world, ui, entity, button_response.response.rect); + } +} + +fn add_component_picker_open_for(world: &World, entity: Entity) -> bool { + world + .get_resource::() + .is_some_and(|state| state.add_component_open && state.add_component_target == Some(entity)) +} + +fn shelf_list_max_height(visible_space: f32) -> f32 { + (visible_space - 108.0).clamp(56.0, 320.0) +} + +fn add_component_picker_shelf( + world: &mut World, + ui: &mut egui::Ui, + target: Entity, + anchor: egui::Rect, +) { + if !world.get_entity(target).is_ok() { + if let Some(mut state) = world.get_resource_mut::() { + state.add_component_open = false; + state.add_component_target = None; + } + return; + } + + let descriptors = world + .resource::() + .descriptors + .clone(); + + let visible = ui.clip_rect().intersect(ui.ctx().available_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 component_addable(world: &World, entity: Entity, type_name: &str) -> bool { +fn add_component_picker_shelf_contents( + world: &mut World, + ui: &mut egui::Ui, + target: Entity, + descriptors: &[EditorComponentDescriptor], + list_max_height: f32, +) { + egui::Frame::new() + .fill(PANEL_BG_DARK) + .stroke(egui::Stroke::new(1.0, BORDER)) + .corner_radius(egui::CornerRadius::same(4)) + .inner_margin(egui::Margin::symmetric(8, 8)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(panel_heading("Add Component")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if icon_button_small(ui, icons::X, "Close Add Component").clicked() { + let mut state = world.resource_mut::(); + state.add_component_open = false; + state.add_component_target = None; + } + }); + }); + ui.add_space(4.0); + + let mut search_input = world + .resource::() + .add_component_search + .clone(); + let search_response = ui.add( + egui::TextEdit::singleline(&mut search_input) + .hint_text("Search components...") + .desired_width(f32::INFINITY), + ); + if search_response.changed() { + let mut state = world.resource_mut::(); + state.add_component_search = search_input.clone(); + state.add_component_scroll_selected = true; + state.add_component_selected_index = 0; + } + if world + .resource::() + .add_component_focus_search + { + search_response.request_focus(); + world + .resource_mut::() + .add_component_focus_search = false; + } + + let search = search_input.to_lowercase(); + let filtered = filtered_component_descriptors(&descriptors, &search); + { + let mut state = world.resource_mut::(); + if !filtered.is_empty() { + state.add_component_selected_index = + state.add_component_selected_index.min(filtered.len() - 1); + } else { + state.add_component_selected_index = 0; + } + } + + if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown)) + && !filtered.is_empty() + { + let mut state = world.resource_mut::(); + state.add_component_selected_index = + (state.add_component_selected_index + 1) % filtered.len(); + state.add_component_scroll_selected = true; + } + if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp)) + && !filtered.is_empty() + { + let mut state = world.resource_mut::(); + state.add_component_selected_index = if state.add_component_selected_index == 0 { + filtered.len() - 1 + } else { + state.add_component_selected_index - 1 + }; + state.add_component_scroll_selected = true; + } + if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + let mut state = world.resource_mut::(); + state.add_component_open = false; + state.add_component_target = None; + } + if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) { + let selected_index = world + .resource::() + .add_component_selected_index; + if let Some(descriptor) = filtered.get(selected_index) { + let add_state = component_add_state(world, target, descriptor, &descriptors); + if add_state.addable { + insert_registered_component(world, target, descriptor.type_name); + let mut state = world.resource_mut::(); + state.add_component_open = false; + state.add_component_target = None; + } + } + } + + ui.separator(); + if filtered.is_empty() { + ui.label(egui::RichText::new("No matching components").color(TEXT_DIM)); + return; + } + + egui::ScrollArea::vertical() + .max_height(list_max_height) + .show(ui, |ui| { + let (selected_index, scroll_selected) = { + let state = world.resource::(); + ( + state.add_component_selected_index, + state.add_component_scroll_selected, + ) + }; + let mut last_category = None; + for (index, descriptor) in filtered.iter().enumerate() { + if last_category != Some(descriptor.category) { + if last_category.is_some() { + ui.separator(); + } + ui.label(panel_heading(component_category_label(descriptor.category))); + last_category = Some(descriptor.category); + } + + let add_state = + component_add_state(world, target, descriptor, &descriptors); + let selected = index == selected_index; + let row = ui + .horizontal(|ui| { + ui.label(phosphor_icon_text(descriptor.icon, 14.0).color(TEXT_DIM)); + ui.add_enabled( + add_state.addable, + egui::Button::selectable(selected, descriptor.display_name), + ) + }) + .inner + .on_hover_text(component_hover_text(descriptor, &add_state)); + if selected && scroll_selected { + row.scroll_to_me(Some(egui::Align::Center)); + } + if row.clicked() { + world + .resource_mut::() + .add_component_selected_index = index; + if add_state.addable { + insert_registered_component(world, target, descriptor.type_name); + let mut state = world.resource_mut::(); + state.add_component_open = false; + state.add_component_target = None; + } + } + } + }); + world + .resource_mut::() + .add_component_scroll_selected = false; + }); +} + +fn filtered_component_descriptors<'a>( + descriptors: &'a [EditorComponentDescriptor], + search: &str, +) -> Vec<&'a EditorComponentDescriptor> { + [ + EditorComponentCategory::Authoring, + EditorComponentCategory::Rendering, + EditorComponentCategory::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::Physics => "Physics", + EditorComponentCategory::Gameplay => "Gameplay", + EditorComponentCategory::Volumes => "Volumes", + EditorComponentCategory::Editor => "Editor", + } +} + +struct ComponentAddState { + addable: bool, + reason: Option, + recommended: Vec<&'static str>, + conflicts: Vec<&'static str>, +} + +fn descriptor_matches_search(descriptor: &EditorComponentDescriptor, search: &str) -> bool { + search.trim().is_empty() + || descriptor.display_name.to_lowercase().contains(search) + || descriptor.type_name.to_lowercase().contains(search) + || descriptor.description.to_lowercase().contains(search) + || descriptor + .search_terms + .iter() + .any(|term| term.to_lowercase().contains(search)) +} + +fn component_add_state( + world: &World, + entity: Entity, + descriptor: &EditorComponentDescriptor, + descriptors: &[EditorComponentDescriptor], +) -> ComponentAddState { + let duplicate = component_present(world, entity, descriptor.type_name); + let conflicts = descriptor + .conflicts_with + .iter() + .copied() + .filter(|type_name| component_present(world, entity, type_name)) + .collect::>(); + let recommended = descriptor + .recommended + .iter() + .copied() + .filter(|type_name| !component_present(world, entity, type_name)) + .collect::>(); + let reason = if duplicate { + Some("Already present on this actor.".to_string()) + } else if !conflicts.is_empty() { + Some(format!( + "Conflicts with {}.", + conflicts + .iter() + .map(|type_name| component_display_name(descriptors, type_name)) + .collect::>() + .join(", ") + )) + } else { + None + }; + ComponentAddState { + addable: reason.is_none(), + reason, + 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.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 { match type_name { - "shared::components::Primitive" => world.get::(entity).is_none(), + "shared::components::Primitive" => world.get::(entity).is_some(), "shared::components::StaticMeshRenderer" => { - world.get::(entity).is_none() + world.get::(entity).is_some() + } + "shared::components::MaterialDesc" => world.get::(entity).is_some(), + "shared::components::LightDesc" => world.get::(entity).is_some(), + "shared::components::RigidBodyDesc" => world.get::(entity).is_some(), + "shared::components::ColliderDesc" => world.get::(entity).is_some(), + "shared::components::PlayerSpawn" => world.get::(entity).is_some(), + "shared::components::WeaponSpawn" => world.get::(entity).is_some(), + "shared::components::TriggerVolume" => world.get::(entity).is_some(), + "shared::components::TeamSpawn" => world.get::(entity).is_some(), + "shared::components::ObjectiveMarker" => world.get::(entity).is_some(), + "shared::components::PostProcessVolumeDesc" => { + world.get::(entity).is_some() } - "shared::components::MaterialDesc" => world.get::(entity).is_none(), - "shared::components::LightDesc" => world.get::(entity).is_none(), - "shared::components::RigidBodyDesc" => world.get::(entity).is_none(), - "shared::components::ColliderDesc" => world.get::(entity).is_none(), - "shared::components::PlayerSpawn" => world.get::(entity).is_none(), - "shared::components::WeaponSpawn" => world.get::(entity).is_none(), - "shared::components::TriggerVolume" => world.get::(entity).is_none(), - "shared::components::TeamSpawn" => world.get::(entity).is_none(), - "shared::components::ObjectiveMarker" => world.get::(entity).is_none(), _ => false, } } @@ -791,6 +1139,11 @@ fn insert_registered_component(world: &mut World, entity: Entity, type_name: &st objective_id: "objective".into(), }); }), + "shared::components::PostProcessVolumeDesc" => { + insert_component(world, entity, |world, e| { + world.entity_mut(e).insert(PostProcessVolumeDesc::default()); + }) + } _ => {} } } diff --git a/crates/editor/src/ui/mod.rs b/crates/editor/src/ui/mod.rs index 0ba0d9f..b4f7bf0 100644 --- a/crates/editor/src/ui/mod.rs +++ b/crates/editor/src/ui/mod.rs @@ -65,6 +65,31 @@ pub struct UiState { last_mode_tab: Option, } +pub fn egui_captures_keyboard(ctx: &egui::Context) -> bool { + ctx.wants_keyboard_input() +} + +pub fn egui_captures_keyboard_from_world(world: &mut World) -> bool { + let Ok(egui_context) = world + .query_filtered::<&mut EguiContext, With>() + .single(world) + else { + return false; + }; + let mut egui_context = egui_context.clone(); + egui_captures_keyboard(egui_context.get_mut()) +} + +pub fn viewport_keyboard_shortcuts_active( + ui_state: &UiState, + ctx: &egui::Context, + buttons: &ButtonInput, +) -> bool { + ui_state.pointer_in_viewport + && !egui_captures_keyboard(ctx) + && !buttons.any_pressed([MouseButton::Right, MouseButton::Middle]) +} + impl UiState { pub fn default_layout() -> Self { Self::from_prefs(&UserPreferences::default()) @@ -113,6 +138,7 @@ impl UiState { ); editor_toolbar_panel(world, ctx); + status_bar_ui(world, ctx, &self.selected_entities, mode); self.pointer_in_viewport = false; self.viewport_pointer_pos = None; @@ -154,8 +180,6 @@ impl UiState { ); tick_layout_save(world, dt); - status_bar_ui(world, ctx, &self.selected_entities, mode); - let mut diagnostics_open = world.resource::().open; diagnostics_window(world, ctx, &mut diagnostics_open); world.resource_mut::().open = diagnostics_open; @@ -226,6 +250,7 @@ impl Plugin for EditorUiPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .add_systems( diff --git a/crates/editor/src/ui/viewport_chrome.rs b/crates/editor/src/ui/viewport_chrome.rs index 1b0a50c..3899143 100644 --- a/crates/editor/src/ui/viewport_chrome.rs +++ b/crates/editor/src/ui/viewport_chrome.rs @@ -58,8 +58,9 @@ pub fn viewport_tab_ui( .filter(|pos| rect.contains(*pos)); *pointer_in_viewport = viewport_pointer_pos.is_some(); - let primary_clicked = - ui.input(|input| input.pointer.button_clicked(egui::PointerButton::Primary)); + let primary_clicked = ui.input(|input| { + input.pointer.button_clicked(egui::PointerButton::Primary) && !input.modifiers.alt + }); if primary_clicked && !clean_game_view { if let Some(pos) = response .interact_pointer_pos() diff --git a/crates/editor/src/viewport/camera.rs b/crates/editor/src/viewport/camera.rs index 885350c..3e7ae5f 100644 --- a/crates/editor/src/viewport/camera.rs +++ b/crates/editor/src/viewport/camera.rs @@ -1,12 +1,13 @@ use bevy::camera::visibility::RenderLayers; use bevy::input::mouse::{AccumulatedMouseMotion, MouseWheel}; use bevy::prelude::*; +use bevy::window::{CursorOptions, PrimaryWindow}; use bevy_egui::{EguiContexts, EguiGlobalSettings, PrimaryEguiContext}; use transform_gizmo_bevy::prelude::GizmoCamera; use crate::infra::EditorOnly; use crate::state::scene_tools_active; -use crate::ui::UiState; +use crate::ui::{egui_captures_keyboard, UiState}; use settings::ProjectSettings; #[derive(Component)] @@ -28,14 +29,29 @@ impl Default for EditorCamera { pub struct EditorCameraPlugin; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CameraNavigationMode { + Look, + Pan, +} + +#[derive(Resource, Default)] +struct EditorCameraInputState { + mode: Option, + cursor_hidden_for_look: bool, + cursor_visible_before_look: bool, +} + impl Plugin for EditorCameraPlugin { fn build(&self, app: &mut App) { - app.add_systems(Startup, spawn_editor_camera).add_systems( - Update, - (sync_editor_camera_from_settings, update_editor_camera) - .chain() - .run_if(scene_tools_active), - ); + app.init_resource::() + .add_systems(Startup, spawn_editor_camera) + .add_systems( + Update, + (sync_editor_camera_from_settings, update_editor_camera) + .chain() + .run_if(scene_tools_active), + ); } } @@ -104,25 +120,61 @@ fn update_editor_camera( mut scroll: MessageReader, mut contexts: EguiContexts, ui_state: Res, + mut input_state: ResMut, mut cameras: Query<(&mut Transform, &EditorCamera)>, + mut cursor: Single<&mut CursorOptions, With>, ) -> Result { let ctx = contexts.ctx_mut()?; let in_scene = ui_state.pointer_in_viewport; - if ctx.wants_keyboard_input() { + let navigation_pressed = + buttons.pressed(MouseButton::Right) || buttons.pressed(MouseButton::Middle); + if !navigation_pressed { + input_state.mode = None; + restore_editor_camera_cursor(&mut input_state, &mut cursor); + } + if egui_captures_keyboard(ctx) { + input_state.mode = None; + restore_editor_camera_cursor(&mut input_state, &mut cursor); return Ok(()); } - if !in_scene && ctx.wants_pointer_input() { + if in_scene && buttons.just_pressed(MouseButton::Right) { + input_state.mode = Some(CameraNavigationMode::Look); + input_state.cursor_visible_before_look = cursor.visible; + input_state.cursor_hidden_for_look = true; + cursor.visible = false; + } else if in_scene && buttons.just_pressed(MouseButton::Middle) { + input_state.mode = Some(CameraNavigationMode::Pan); + restore_editor_camera_cursor(&mut input_state, &mut cursor); + } + if input_state.mode.is_none() && navigation_pressed { return Ok(()); } - if in_scene && ctx.wants_pointer_input() && !buttons.pressed(MouseButton::Right) { + if !in_scene && input_state.mode.is_none() && ctx.wants_pointer_input() { + return Ok(()); + } + if in_scene + && input_state.mode.is_none() + && ctx.wants_pointer_input() + && !buttons.pressed(MouseButton::Right) + { return Ok(()); } let dt = time.delta_secs(); - let scroll_delta: f32 = scroll.read().map(|event| event.y).sum(); + let raw_scroll_delta: f32 = scroll.read().map(|event| event.y).sum(); + let scroll_delta = if in_scene && !ctx.wants_pointer_input() { + raw_scroll_delta + } else { + 0.0 + }; for (mut transform, camera) in &mut cameras { - if buttons.pressed(MouseButton::Right) { + let looking = matches!(input_state.mode, Some(CameraNavigationMode::Look)) + && buttons.pressed(MouseButton::Right); + let panning = matches!(input_state.mode, Some(CameraNavigationMode::Pan)) + && buttons.pressed(MouseButton::Middle); + + if looking { let delta = mouse_motion.delta; if delta != Vec2::ZERO { let (yaw, pitch, _) = transform.rotation.to_euler(EulerRot::YXZ); @@ -140,23 +192,25 @@ fn update_editor_camera( let up = Vec3::Y; let mut wish = Vec3::ZERO; - if keys.pressed(KeyCode::KeyW) { - wish += forward; - } - if keys.pressed(KeyCode::KeyS) { - wish -= forward; - } - if keys.pressed(KeyCode::KeyD) { - wish += right; - } - if keys.pressed(KeyCode::KeyA) { - wish -= right; - } - if keys.pressed(KeyCode::KeyE) { - wish += up; - } - if keys.pressed(KeyCode::KeyQ) { - wish -= up; + if looking { + if keys.pressed(KeyCode::KeyW) { + wish += forward; + } + if keys.pressed(KeyCode::KeyS) { + wish -= forward; + } + if keys.pressed(KeyCode::KeyD) { + wish += right; + } + if keys.pressed(KeyCode::KeyA) { + wish -= right; + } + if keys.pressed(KeyCode::KeyE) { + wish += up; + } + if keys.pressed(KeyCode::KeyQ) { + wish -= up; + } } let sprint = if keys.pressed(KeyCode::ShiftLeft) { @@ -167,7 +221,7 @@ fn update_editor_camera( transform.translation += wish.normalize_or_zero() * camera.fly_speed * sprint * dt; transform.translation += forward * scroll_delta * camera.fly_speed * 0.08; - if buttons.pressed(MouseButton::Middle) { + if panning { let delta = mouse_motion.delta; let distance_scale = transform.translation.length().max(1.0).sqrt(); transform.translation += @@ -177,3 +231,13 @@ fn update_editor_camera( Ok(()) } + +fn restore_editor_camera_cursor( + input_state: &mut EditorCameraInputState, + cursor: &mut CursorOptions, +) { + if input_state.cursor_hidden_for_look { + cursor.visible = input_state.cursor_visible_before_look; + input_state.cursor_hidden_for_look = false; + } +} diff --git a/crates/editor/src/viewport/gizmos.rs b/crates/editor/src/viewport/gizmos.rs index 3bb4de5..1999353 100644 --- a/crates/editor/src/viewport/gizmos.rs +++ b/crates/editor/src/viewport/gizmos.rs @@ -1,8 +1,9 @@ use bevy::math::Rect; use bevy::prelude::*; +use bevy_egui::EguiContexts; use transform_gizmo_bevy::prelude::{GizmoMode, GizmoOptions, GizmoOrientation}; -use crate::ui::UiState; +use crate::ui::{viewport_keyboard_shortcuts_active, UiState}; use crate::viewport::ViewportDisplayMode; #[derive(Resource, Default, Debug, Clone, Copy, PartialEq, Eq)] @@ -40,12 +41,19 @@ impl Plugin for EditorGizmoPlugin { fn handle_gizmo_hotkeys( keys: Res>, + buttons: Res>, + mut contexts: EguiContexts, + ui_state: Res, display: Res, mut mode: ResMut, mut space: ResMut, -) { +) -> Result { if display.clean_game_view { - return; + return Ok(()); + } + let ctx = contexts.ctx_mut()?; + if !viewport_keyboard_shortcuts_active(&ui_state, ctx, &buttons) { + return Ok(()); } if keys.just_pressed(KeyCode::KeyW) { *mode = EditorGizmoMode::Translate; @@ -62,6 +70,7 @@ fn handle_gizmo_hotkeys( EditorGizmoSpace::Local => EditorGizmoSpace::World, }; } + Ok(()) } /// Maps the viewport egui panel to gizmo cursor space (required for render-to-texture). diff --git a/crates/editor/src/viewport/panel.rs b/crates/editor/src/viewport/panel.rs index d68a7b3..4d96a7a 100644 --- a/crates/editor/src/viewport/panel.rs +++ b/crates/editor/src/viewport/panel.rs @@ -3,10 +3,11 @@ use crate::camera::EditorCamera; use crate::selection::SelectedEntity; use crate::state::scene_tools_active; +use crate::ui::{viewport_keyboard_shortcuts_active, UiState}; use crate::viewport::viewport_mode::EditorViewportModePlugin; use bevy::prelude::*; use bevy::window::Window; -use bevy_egui::egui; +use bevy_egui::{egui, EguiContexts}; /// Viewport display and snapping options (editor-only). #[derive(Resource, Debug, Clone)] @@ -155,22 +156,29 @@ pub fn snap_translation(position: Vec3, settings: &ViewportSettings) -> Vec3 { fn focus_selection_hotkey( keys: Res>, + buttons: Res>, + mut contexts: EguiContexts, + ui_state: Res, display: Res, selected: Res, mut editor_cameras: Query<&mut Transform, With>, targets: Query<&GlobalTransform>, -) { +) -> Result { if display.clean_game_view { - return; + return Ok(()); + } + let ctx = contexts.ctx_mut()?; + if !viewport_keyboard_shortcuts_active(&ui_state, ctx, &buttons) { + return Ok(()); } if !keys.just_pressed(KeyCode::KeyF) { - return; + return Ok(()); } let Some(entity) = selected.0 else { - return; + return Ok(()); }; let Ok(target) = targets.get(entity) else { - return; + return Ok(()); }; let center = target.translation(); for mut transform in &mut editor_cameras { @@ -179,13 +187,21 @@ fn focus_selection_hotkey( transform.translation = center - transform.forward().as_vec3() * distance; transform.look_at(center, Vec3::Y); } + Ok(()) } fn toggle_viewport_hotkeys( keys: Res>, + buttons: Res>, + mut contexts: EguiContexts, + ui_state: Res, mut settings: ResMut, mut display: ResMut, -) { +) -> Result { + let ctx = contexts.ctx_mut()?; + if !viewport_keyboard_shortcuts_active(&ui_state, ctx, &buttons) { + return Ok(()); + } if keys.just_pressed(KeyCode::KeyG) { let ctrl = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight); if ctrl { @@ -194,18 +210,26 @@ fn toggle_viewport_hotkeys( display.clean_game_view = !display.clean_game_view; } } + Ok(()) } fn camera_bookmark_hotkeys( keys: Res>, + buttons: Res>, + mut contexts: EguiContexts, + ui_state: Res, scene_io: Res, mut bookmarks: ResMut, mut editor_cameras: Query<&mut Transform, With>, -) { +) -> Result { + let ctx = contexts.ctx_mut()?; + if !viewport_keyboard_shortcuts_active(&ui_state, ctx, &buttons) { + return Ok(()); + } let shift = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight); let ctrl = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight); if !shift || !ctrl { - return; + return Ok(()); } let scene_key = scene_io @@ -216,19 +240,20 @@ fn camera_bookmark_hotkeys( if keys.just_pressed(KeyCode::Digit1) { let Ok(transform) = editor_cameras.single() else { - return; + return Ok(()); }; save_camera_bookmark(&mut bookmarks, &scene_key, *transform); } if keys.just_pressed(KeyCode::Digit2) { let Some(saved) = recall_camera_bookmark(&bookmarks, &scene_key) else { - return; + return Ok(()); }; for mut transform in &mut editor_cameras { *transform = saved; } } + Ok(()) } fn draw_viewport_grid( diff --git a/crates/editor/src/viewport/selection.rs b/crates/editor/src/viewport/selection.rs index fbd84ab..f90cd96 100644 --- a/crates/editor/src/viewport/selection.rs +++ b/crates/editor/src/viewport/selection.rs @@ -92,22 +92,26 @@ fn handle_pick_events( proxies: Query<&EditorVisualizerProxy>, icon_proxies: Query<&ActorIconProxy>, keys: Res>, + buttons: Res>, display: Res, gizmo_targets: Query<&GizmoTarget>, hierarchy: Option>, -) { - if keys.just_pressed(KeyCode::Escape) { +) -> Result { + if ui_state.pointer_in_viewport + && !buttons.any_pressed([MouseButton::Right, MouseButton::Middle]) + && keys.just_pressed(KeyCode::Escape) + { ui_state.selected_entities.clear(); selected.0 = None; pick_stack.entities.clear(); pick_stack.index = 0; pick_stack.anchor = None; - return; + return Ok(()); } if display.clean_game_view { viewport_click.0 = None; - return; + return Ok(()); } if gizmo_targets @@ -115,7 +119,7 @@ fn handle_pick_events( .any(|g| GizmoTarget::is_focused(g) || GizmoTarget::is_active(g)) { viewport_click.0 = None; - return; + return Ok(()); } let additive = keys.any_pressed([KeyCode::ControlLeft, KeyCode::ShiftLeft]); @@ -134,11 +138,11 @@ fn handle_pick_events( pointer_pos, additive, ); - return; + return Ok(()); } if !ui_state.pointer_in_viewport { - return; + return Ok(()); } for event in click_events.read() { @@ -187,6 +191,7 @@ fn handle_pick_events( additive, ); } + Ok(()) } fn cycle_overlapping_viewport_pick( @@ -194,25 +199,29 @@ fn cycle_overlapping_viewport_pick( mut selected: ResMut, mut pick_stack: ResMut, keys: Res>, + buttons: Res>, display: Res, -) { +) -> Result { if display.clean_game_view { - return; + return Ok(()); + } + if !ui_state.pointer_in_viewport + || buttons.any_pressed([MouseButton::Right, MouseButton::Middle]) + { + return Ok(()); } if !keys.just_pressed(KeyCode::Tab) || pick_stack.entities.len() < 2 { - return; + return Ok(()); } if keys.any_pressed([KeyCode::ControlLeft, KeyCode::ShiftLeft]) { - return; - } - if !ui_state.pointer_in_viewport { - return; + return Ok(()); } pick_stack.index = (pick_stack.index + 1) % pick_stack.entities.len(); let entity = pick_stack.entities[pick_stack.index]; ui_state.selected_entities.select_replace(entity); selected.0 = Some(entity); + Ok(()) } fn apply_viewport_pick( diff --git a/docs/editor/README.md b/docs/editor/README.md index 59b9d05..aa0632c 100644 --- a/docs/editor/README.md +++ b/docs/editor/README.md @@ -48,8 +48,9 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a - **Player placement** is authored through `PlayerSpawn`; selecting the runtime player in Edit mode redirects to a saved `Player Start`. - **World lighting** has two layers: project default sun/ambient settings, and optional scene-authored directional `LightDesc` overrides. **Scene → Lighting** menu and **Rendering** panel (Window) restore project sun or bake a scene directional from project settings. **Apply** in Project Settings updates ambient and sun illuminance live. - **Rendering** — `GiMode` + post-process volumes; see [rendering.md](rendering.md). **Window → Rendering** shows requested/effective active-camera stack, fallback reason, Solari eligibility, viewport GI badge, and volume HUD. -- **Inspector** shows a responsive header, **Transform**, registered authoring component cards, and a pinned bottom Add Component footer (`actor_inspector`); only the component body scrolls. Rows wrap or stack inside narrow docks instead of forcing the panel wider, and legacy authoring sections use the same component-card shell. Component card carets collapse/expand for the current editor session, status dots toggle persisted active state, and triple-dot menus provide reset, copy/paste values, move, remove, and documentation slots. Inactive authoring components remain in the scene but are skipped by hydration/runtime queries, stripping their generated meshes, lights, physics, or post-process effects. `StaticMeshRenderer` uses concept-style slot cards with model thumbnails, imported mesh/material selectors, visibility, shadow flags, and per-actor material overrides. Empty actor material refs display the inherited source material from the generated static mesh artifact as **Source default**; selector **Browse** assigns explicit normalized imported sub-assets, **Locate** focuses the owning asset in the Asset Browser, and **Clear** returns optional material slots to the inherited source default. `ColliderDesc` and `RigidBodyDesc` are separate component cards. Game crates add sections via `ActorInspectorSection` + `game::editor_ext::actor_inspector_section_ids()`. +- **Inspector** shows a responsive header, **Transform**, registered authoring component cards, and an Add Component footer (`actor_inspector`) that expands an inline search shelf above the button; only the component body scrolls. Rows wrap or stack inside narrow docks instead of forcing the panel wider, and legacy authoring sections use the same component-card shell. Component card carets collapse/expand for the current editor session, status dots toggle persisted active state, and triple-dot menus provide reset, copy/paste values, move, remove, and documentation slots. Inactive authoring components remain in the scene but are skipped by hydration/runtime queries, stripping their generated meshes, lights, physics, or post-process effects. The Add Component shelf is registry-driven with persistent search, category groups, descriptions, hydration hints, duplicate/conflict disabled states, and recommendation hints. `StaticMeshRenderer` uses concept-style slot cards with model thumbnails, imported mesh/material selectors, visibility, shadow flags, and per-actor material overrides. Empty actor material refs display the inherited source material from the generated static mesh artifact as **Source default**; selector **Browse** assigns explicit normalized imported sub-assets, **Locate** focuses the owning asset in the Asset Browser, and **Clear** returns optional material slots to the inherited source default. `ColliderDesc` and `RigidBodyDesc` are separate component cards. Game crates add sections via `ActorInspectorSection` + `game::editor_ext::actor_inspector_section_ids()`. - **Command palette** (`Ctrl+P`): focuses the filter on open, selects the first filtered command, supports arrow-key selection and Enter to run. Commands include `scene.reset_lighting`, `rendering.create_volume`, `rendering.select_volumes_at_camera`, `rendering.focus_active_volumes`, `selection.group`, `selection.focus`, `selection.reset_transform`, and play commands. +- **Editor input routing** gives egui text fields first claim on keyboard input. Viewport shortcut keys require viewport pointer focus and are suspended while RMB/MMB camera navigation is active. - **ActorKind** is required on saved level objects (schema v2). Save strips hydrated ECS before writing `.scn.ron`. - **Scene visualizers** expose actor root icons, colliders, lights, player spawns, prefab/model anchors, Project Sun, and runtime player/camera markers without changing saved scene data. Viewport options include actor icon size and transform gizmo size sliders. - **Clean game-view overlay** (`G`) hides editor chrome, grid, selection outlines, transform gizmos, actor root icons, visualizers, and selectable proxies without changing camera ownership; `Ctrl+G` toggles grid.