Compare commits

...

2 Commits

Author SHA1 Message Date
2fa64106c8 Polish inspector add component workflow
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
2026-06-06 03:08:28 -04:00
19c4d017dc Start M0 editor workflow polish 2026-06-06 02:15:14 -04:00
17 changed files with 983 additions and 214 deletions

16
.github/pull_request_template.md vendored Normal file
View File

@ -0,0 +1,16 @@
## Summary
-
## Validation
-
## Documentation
- [ ] I updated the root README for user controls, run commands, or troubleshooting changes.
- [ ] I updated `docs/editor/` for editor workflow or feature-design changes.
- [ ] I added or updated an ADR for architecture, policy, scene format, rendering path, PIE semantics, settings schema, or netcode decisions.
- [ ] I indexed new documentation in `docs/README.md`.
- [ ] If this is Jackdaw-inspired work, I identified it as design-inspired, API-inspired, or code-derived.
- [ ] If this includes copied or closely translated source, I included source URL, upstream commit/release, license compatibility notes, and required file-level notices.

View File

@ -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 |
@ -117,15 +117,17 @@ VS Code tasks: **target cleanup (dry run)** and **target cleanup (safe apply)**.
| Select Project Sun | Inspect project default lighting; create a scene sun override |
| Asset Browser project/file views | Browse `assets/`, search/filter/sort, switch grid/list, expand model subassets with generated thumbnails, inspect staged import/material settings, drag assets/submeshes into the viewport |
| Asset Browser context/details actions | Apply textures/materials, regenerate thumbnails, reimport models, place assets/submeshes, or move file assets to `assets/.trash/` |
| `Ctrl+P` | Command palette (`scene.reset_lighting`, `selection.group`, `selection.focus`, play commands) |
| `Ctrl+P` | Command palette; type to filter, use arrow keys to select, Enter to run (`scene.reset_lighting`, `selection.group`, `selection.focus`, `selection.reset_transform`, play commands) |
| `F7` | While paused in Play: advance one sim tick |
| Shift/Ctrl + click (Hierarchy) | Additive selection |
| 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,

View File

@ -8,7 +8,7 @@ use crate::state::{EditorMode, PlayPossession};
use crate::ui::helpers::{
reset_scene_lighting_to_project_defaults, toggle_play_mode, toggle_play_paused,
};
use crate::ui::selection_ops::focus_editor_camera_on_selection;
use crate::ui::selection_ops::{focus_editor_camera_on_selection, reset_selected_transforms};
use crate::ui::theme::{apply_editor_theme, PANEL_BG, TEXT, TEXT_DIM};
use crate::ui::UiState;
@ -127,6 +127,8 @@ pub struct CommandPalette {
pub open: bool,
pub filter: String,
pub pending_run: Option<String>,
pub focus_filter: bool,
pub selected_index: usize,
}
pub struct ExtensibilityPlugin;
@ -160,6 +162,7 @@ fn register_builtin_commands(mut registry: ResMut<EditorCommandRegistry>) {
registry.register(Box::new(ResetLightingCommand));
registry.register(Box::new(GroupSelectionCommand));
registry.register(Box::new(FocusSelectionCommand));
registry.register(Box::new(ResetSelectionTransformCommand));
registry.register(Box::new(CreatePostProcessVolumeCommand));
registry.register(Box::new(FocusActiveVolumesCommand));
registry.register(Box::new(SelectVolumesAtCameraCommand));
@ -272,6 +275,18 @@ impl EditorCommand for FocusSelectionCommand {
}
}
struct ResetSelectionTransformCommand;
impl EditorCommand for ResetSelectionTransformCommand {
fn name(&self) -> &str {
"selection.reset_transform"
}
fn execute(&self, world: &mut World) {
reset_selected_transforms(world);
}
}
struct CreatePostProcessVolumeCommand;
impl EditorCommand for CreatePostProcessVolumeCommand {
@ -322,6 +337,8 @@ fn command_palette_ui(
if ctx.input(|input| input.modifiers.command && input.key_pressed(egui::Key::P)) {
palette.open = true;
palette.focus_filter = true;
palette.selected_index = 0;
}
if !palette.open {
@ -339,25 +356,71 @@ fn command_palette_ui(
.color(TEXT_DIM)
.small(),
);
ui.add(
let filter_response = ui.add(
egui::TextEdit::singleline(&mut palette.filter)
.hint_text("Type to filter...")
.desired_width(f32::INFINITY),
);
if palette.focus_filter {
filter_response.request_focus();
palette.focus_filter = false;
}
if filter_response.changed() {
palette.selected_index = 0;
}
let filter = palette.filter.to_lowercase();
let filtered_names: Vec<String> = registry
.names()
.into_iter()
.filter(|name| filter.is_empty() || name.to_lowercase().contains(&filter))
.collect();
if !filtered_names.is_empty() {
palette.selected_index = palette.selected_index.min(filtered_names.len() - 1);
} else {
palette.selected_index = 0;
}
if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown))
&& !filtered_names.is_empty()
{
palette.selected_index = (palette.selected_index + 1) % filtered_names.len();
}
if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp))
&& !filtered_names.is_empty()
{
palette.selected_index = if palette.selected_index == 0 {
filtered_names.len() - 1
} else {
palette.selected_index - 1
};
}
if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) {
if let Some(name) = filtered_names.get(palette.selected_index) {
palette.pending_run = Some(name.clone());
palette.open = false;
ui.close();
}
}
if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
palette.open = false;
ui.close();
}
ui.separator();
egui::ScrollArea::vertical()
.max_height(280.0)
.show(ui, |ui| {
for name in registry.names() {
if !palette.filter.is_empty()
&& !name.to_lowercase().contains(&palette.filter.to_lowercase())
{
continue;
}
for (index, name) in filtered_names.iter().enumerate() {
let selected = index == palette.selected_index;
let response =
ui.selectable_label(false, egui::RichText::new(&name).color(TEXT));
ui.selectable_label(selected, egui::RichText::new(name).color(TEXT));
if selected {
response.scroll_to_me(Some(egui::Align::Center));
}
if response.clicked() {
palette.pending_run = Some(name);
palette.selected_index = index;
palette.pending_run = Some(name.clone());
palette.open = false;
ui.close();
}

View File

@ -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};
@ -637,9 +637,42 @@ pub fn set_transform_with_history(
if transform_nearly_eq(&old, &new) {
return;
}
if let Some(mut transform) = world.get_mut::<Transform>(entity) {
*transform = new;
}
push_history(world, EditorCommand::SetTransform { entity, old, new });
}
pub fn set_transform_group_with_history(
world: &mut World,
changes: impl IntoIterator<Item = (Entity, Transform, Transform)>,
) {
let mut entities = Vec::new();
let mut olds = Vec::new();
let mut news = Vec::new();
for (entity, old, new) in changes {
if transform_nearly_eq(&old, &new) {
continue;
}
if let Some(mut transform) = world.get_mut::<Transform>(entity) {
*transform = new;
entities.push(entity);
olds.push(old);
news.push(new);
}
}
if !entities.is_empty() {
push_history(
world,
EditorCommand::SetTransformGroup {
entities,
olds,
news,
},
);
}
}
pub fn set_actor_kind_with_history(
world: &mut World,
entity: Entity,
@ -1156,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::<ButtonInput<KeyCode>>();
let ctrl = keys.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]);
@ -1219,7 +1256,7 @@ fn capture_gizmo_transform_edits(world: &mut World) {
let start_group =
active.map(|(entity, transform)| selected_transforms(world, &selection, entity, transform));
let finished_edit = {
let (finished_edit, live_edit) = {
let mut tracker = world.resource_mut::<TransformEditTracker>();
if tracker.active.is_none() {
if let (Some((entity, transform)), Some(group)) = (active, start_group) {
@ -1228,15 +1265,26 @@ fn capture_gizmo_transform_edits(world: &mut World) {
}
}
match tracker.active.take() {
Some((primary, old, group)) if active.is_none() => Some((primary, old, group)),
Some((primary, old, group)) if active.is_none() => (Some((primary, old, group)), None),
Some(state) if active.is_some() => {
let live = Some(state.clone());
tracker.active = Some(state);
None
(None, live)
}
_ => None,
_ => (None, None),
}
};
if let Some((primary, old_primary, group)) = live_edit {
let Some(new_primary) = world.get::<Transform>(primary).copied() else {
return;
};
if group.len() > 1 {
let changes = transform_group_changes(old_primary, new_primary, group);
apply_transform_group(world, &changes);
}
}
if let Some((primary, old_primary, group)) = finished_edit {
let Some(new_primary) = world.get::<Transform>(primary).copied() else {
return;
@ -1245,34 +1293,36 @@ fn capture_gizmo_transform_edits(world: &mut World) {
set_transform_with_history(world, primary, old_primary, new_primary);
return;
}
let translation_delta = new_primary.translation - old_primary.translation;
let rotation_delta = new_primary.rotation * old_primary.rotation.inverse();
let scale_ratio = new_primary.scale / old_primary.scale;
let mut entities = Vec::new();
let mut olds = Vec::new();
let mut news = Vec::new();
for (entity, old) in group {
let changes = transform_group_changes(old_primary, new_primary, group);
set_transform_group_with_history(world, changes);
}
}
fn transform_group_changes(
old_primary: Transform,
new_primary: Transform,
group: Vec<(Entity, Transform)>,
) -> Vec<(Entity, Transform, Transform)> {
let translation_delta = new_primary.translation - old_primary.translation;
let rotation_delta = new_primary.rotation * old_primary.rotation.inverse();
let scale_ratio = new_primary.scale / old_primary.scale;
group
.into_iter()
.map(|(entity, old)| {
let new = Transform {
translation: old.translation + translation_delta,
rotation: rotation_delta * old.rotation,
scale: old.scale * scale_ratio,
};
if let Some(mut transform) = world.get_mut::<Transform>(entity) {
*transform = new;
}
entities.push(entity);
olds.push(old);
news.push(new);
}
if !entities.is_empty() {
push_history(
world,
EditorCommand::SetTransformGroup {
entities,
olds,
news,
},
);
(entity, old, new)
})
.collect()
}
fn apply_transform_group(world: &mut World, changes: &[(Entity, Transform, Transform)]) {
for (entity, _, new) in changes {
if let Some(mut transform) = world.get_mut::<Transform>(*entity) {
*transform = *new;
}
}
}

View File

@ -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<ActorInspectorSectionRegistry>| {
registry.draw_sections(world, ui, entity);
});
});
},
);
inspector::add_component_footer(world, ui, entity);
world.resource_scope(|world, registry: Mut<ActorInspectorSectionRegistry>| {
registry.draw_sections(world, ui, entity);
});
inspector::add_component_footer(world, ui, entity);
});
}
fn actor_icon(kind: ActorKind) -> egui_phosphor_icons::Icon {

View File

@ -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.",
},
],
}

View File

@ -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<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 {
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::<EditorComponentRegistry>()
.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::<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_ok() {
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().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::<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::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<String>,
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 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::<Vec<_>>()
.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::<Primitive>(entity).is_none(),
"shared::components::Primitive" => world.get::<Primitive>(entity).is_some(),
"shared::components::StaticMeshRenderer" => {
world.get::<StaticMeshRenderer>(entity).is_none()
world.get::<StaticMeshRenderer>(entity).is_some()
}
"shared::components::MaterialDesc" => world.get::<MaterialDesc>(entity).is_some(),
"shared::components::LightDesc" => world.get::<LightDesc>(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()
}
"shared::components::MaterialDesc" => world.get::<MaterialDesc>(entity).is_none(),
"shared::components::LightDesc" => world.get::<LightDesc>(entity).is_none(),
"shared::components::RigidBodyDesc" => world.get::<RigidBodyDesc>(entity).is_none(),
"shared::components::ColliderDesc" => world.get::<ColliderDesc>(entity).is_none(),
"shared::components::PlayerSpawn" => world.get::<PlayerSpawn>(entity).is_none(),
"shared::components::WeaponSpawn" => world.get::<WeaponSpawn>(entity).is_none(),
"shared::components::TriggerVolume" => world.get::<TriggerVolume>(entity).is_none(),
"shared::components::TeamSpawn" => world.get::<TeamSpawn>(entity).is_none(),
"shared::components::ObjectiveMarker" => world.get::<ObjectiveMarker>(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());
})
}
_ => {}
}
}

View File

@ -65,6 +65,31 @@ pub struct UiState {
last_mode_tab: Option<EditorMode>,
}
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<PrimaryEguiContext>>()
.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<MouseButton>,
) -> 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::<DiagnosticsPanel>().open;
diagnostics_window(world, ctx, &mut diagnostics_open);
world.resource_mut::<DiagnosticsPanel>().open = diagnostics_open;
@ -226,6 +250,7 @@ impl Plugin for EditorUiPlugin {
.init_resource::<crate::rendering_diagnostics::RenderingDiagnosticsPanel>()
.init_resource::<asset_browser::AssetBrowserUiState>()
.init_resource::<inspector::InspectorClipboard>()
.init_resource::<inspector::InspectorPanelState>()
.init_resource::<ViewportUiState>()
.init_resource::<LayoutSaveTimer>()
.add_systems(

View File

@ -5,8 +5,11 @@ use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities;
use shared::LevelObject;
use crate::camera::EditorCamera;
use crate::history::{delete_entities_with_history, duplicate_entities_with_history};
use crate::history::{
delete_entities_with_history, duplicate_entities_with_history, set_transform_group_with_history,
};
use crate::selection::SelectedEntity;
use crate::ui::UiState;
pub fn focus_editor_camera_on_selection(world: &mut World) {
let Some(entity) = world.resource::<SelectedEntity>().0 else {
@ -58,3 +61,19 @@ pub fn duplicate_selection(world: &mut World, selected: &SelectedEntities) {
let entities = selected_level_entities(world, selected);
duplicate_entities_with_history(world, &entities);
}
pub fn reset_selected_transforms(world: &mut World) {
let changes: Vec<_> = world
.resource::<UiState>()
.selected_entities
.iter()
.filter(|entity| is_level_object(world, *entity))
.filter_map(|entity| {
world
.get::<Transform>(entity)
.copied()
.map(|old| (entity, old, Transform::default()))
})
.collect();
set_transform_group_with_history(world, changes);
}

View File

@ -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()

View File

@ -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<CameraNavigationMode>,
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::<EditorCameraInputState>()
.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<MouseWheel>,
mut contexts: EguiContexts,
ui_state: Res<UiState>,
mut input_state: ResMut<EditorCameraInputState>,
mut cameras: Query<(&mut Transform, &EditorCamera)>,
mut cursor: Single<&mut CursorOptions, With<PrimaryWindow>>,
) -> 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;
}
}

View File

@ -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<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
mut contexts: EguiContexts,
ui_state: Res<UiState>,
display: Res<ViewportDisplayMode>,
mut mode: ResMut<EditorGizmoMode>,
mut space: ResMut<EditorGizmoSpace>,
) {
) -> 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).

View File

@ -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<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
mut contexts: EguiContexts,
ui_state: Res<UiState>,
display: Res<ViewportDisplayMode>,
selected: Res<SelectedEntity>,
mut editor_cameras: Query<&mut Transform, With<EditorCamera>>,
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<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
mut contexts: EguiContexts,
ui_state: Res<UiState>,
mut settings: ResMut<ViewportSettings>,
mut display: ResMut<ViewportDisplayMode>,
) {
) -> 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<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
mut contexts: EguiContexts,
ui_state: Res<UiState>,
scene_io: Res<crate::scene_io::SceneIo>,
mut bookmarks: ResMut<CameraBookmarks>,
mut editor_cameras: Query<&mut Transform, With<EditorCamera>>,
) {
) -> 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(

View File

@ -92,22 +92,26 @@ fn handle_pick_events(
proxies: Query<&EditorVisualizerProxy>,
icon_proxies: Query<&ActorIconProxy>,
keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
display: Res<ViewportDisplayMode>,
gizmo_targets: Query<&GizmoTarget>,
hierarchy: Option<Res<HierarchyPanelState>>,
) {
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<SelectedEntity>,
mut pick_stack: ResMut<ViewportPickStack>,
keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
display: Res<ViewportDisplayMode>,
) {
) -> 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(

View File

@ -34,6 +34,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
| [0017](adr/0017-normalized-static-mesh-assets.md) | Normalized static mesh assets and renderer placement |
| [0018](adr/0018-componentized-actor-inspector-and-materials.md) | Componentized actor inspector, imported asset refs, collider split, material overrides |
| [0019](adr/0019-local-bevy-render-timeout-patch.md) | Local `bevy_render` patch for transient Linux swapchain timeouts |
| [0020](adr/0020-jackdaw-inspired-editor-roadmap.md) | Jackdaw-inspired roadmap source policy and attribution requirements |
## Editor framework
@ -58,6 +59,7 @@ Detailed milestone and feature plans live in [`.cursor/plans/`](../.cursor/plans
| `rendering_unification_*.plan.md` | Unified rendering stack, Hybrid Auto GI, emissive materials |
| `static_mesh_asset_refactor_*.plan.md` | Static mesh renderer, normalized model artifacts, inspector redesign |
| `component_system_refactor_*.plan.md` | Component registry, imported asset refs, collider split, material overrides |
| `jackdaw_feature_roadmap_*.plan.md` | Jackdaw-inspired production roadmap; tracked in Gitea as `BS-JD-*` issues |
## Crate responsibilities (quick reference)

View File

@ -0,0 +1,49 @@
Status: Accepted
Context
=======
Blacksite is evolving from a game-specific editor scaffold into a reusable
Bevy editor sandbox for future game work. The Jackdaw editor provides useful
public reference material for editor workflows such as brush geometry, material
browser organization, terrain tools, physics placement, project launchers, and
extension boundaries.
The roadmap should use those references as product and workflow inspiration
without accidentally turning Blacksite into a fork, importing code without
attribution, or overriding Blacksite's existing authoring/hydration model.
Decision
========
Treat Jackdaw as an inspiration source, not as an architecture or format
authority. Blacksite keeps its current foundations: Bevy DynamicScene RON,
schema stamping and migration, authoring-only validation, hydration into runtime
components, in-process PIE, and the unified viewport.
Each Jackdaw-inspired ticket must identify whether the work is:
- Design-inspired: adapted from public behavior, screenshots, docs, or workflow
descriptions.
- API-inspired: adapted from public API shape or concepts without copied code.
- Code-derived: containing copied or closely translated source code.
Code-derived work requires a source URL, upstream commit or release reference,
compatible license confirmation, and preserved file-level notices when required
by the source license. Prefer clean-room implementation for geometry, terrain,
operators, and extension APIs because Blacksite's scene and runtime ownership
model differs from Jackdaw's.
Consequences
============
The Jackdaw roadmap can be used for planning and prioritization while keeping
Blacksite's architecture coherent and auditable.
M0-M5 planning details live in `.cursor/plans/` until implemented. Durable
decisions move into `docs/adr/` or `docs/editor/`; user-visible workflows move
into the root README. The Gitea issue tracker is the production task board, but
docs remain the long-term source for accepted policy and architecture.
Reviewers should reject Jackdaw-inspired changes that do not declare their
inspiration level or that copy source without license and source attribution.

View File

@ -34,6 +34,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
## Design intentions (stable)
- **EditorOnly** helper entities never appear in hierarchy or saved scenes. Visualizer proxies are editor-only but selectable; selection resolves to their source entity.
- **Jackdaw-inspired roadmap work** follows ADR 0020: use Jackdaw as design/API inspiration only unless a ticket explicitly marks code-derived work with source, license, and upstream commit notes. Clean-room implementation is preferred for geometry, terrain, operator, and extension systems.
- **Dedicated egui `Camera2d`** at full window — never attach `PrimaryEguiContext` to a viewport-cropped 3D camera (NaN layout panic).
- **Unified viewport** uses one render-to-texture target for both the editor fly camera and the possessed player camera so HDR/atmosphere is not broken by sub-viewport cropping.
- **PIE:** F8 possess/eject while sim runs; **F6** pauses/resumes simulation in Play; project settings drive shared rendering for the active viewport camera.
@ -47,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()`.
- **Command palette** (`Ctrl+P`): `scene.reset_lighting`, `rendering.create_volume`, `rendering.select_volumes_at_camera`, `rendering.focus_active_volumes`, `selection.group`, `selection.focus`, and play commands.
- **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.