Blacksite/crates/editor/src/ext/extensibility.rs
Rbanh 1ab3886028
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Complete editor operator invariants
2026-07-12 23:53:46 -04:00

973 lines
31 KiB
Rust

//! Editor extensibility: command registry, inspector sections, and extension hooks.
use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts, EguiPrimaryContextPass};
use crate::history::group_selection_with_history;
use crate::operators::{run_operator_action, OperatorAction, OperatorAvailability};
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, reset_selected_transforms};
use crate::ui::theme::{apply_editor_theme, PANEL_BG, TEXT, TEXT_DIM};
use crate::ui::UiState;
use crate::viewport::brush_tool::start_draw_brush_tool;
use crate::viewport::{
intersect_selected_brushes, merge_selected_brushes, selected_brush_count,
subtract_selected_brushes,
};
/// Named editor command invokable from the palette and BRP.
pub trait EditorCommand: Send + Sync {
fn name(&self) -> &str;
fn label(&self) -> &str {
self.name()
}
fn disabled_reason(&self, _world: &World) -> Option<String> {
None
}
fn execute(&self, world: &mut World) -> Result<OperatorAction, String>;
}
#[derive(Resource, Default)]
pub struct EditorCommandRegistry {
commands: Vec<Box<dyn EditorCommand>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EditorCommandEntry {
pub name: String,
pub label: String,
}
impl EditorCommandRegistry {
pub fn register(&mut self, command: Box<dyn EditorCommand>) {
self.commands.push(command);
}
pub fn names(&self) -> Vec<String> {
self.commands
.iter()
.map(|cmd| cmd.name().to_string())
.collect()
}
pub fn entries(&self) -> Vec<EditorCommandEntry> {
self.commands
.iter()
.map(|command| EditorCommandEntry {
name: command.name().to_string(),
label: command.label().to_string(),
})
.collect()
}
pub fn command_state(&self, world: &World, name: &str) -> Option<(String, Option<String>)> {
self.commands
.iter()
.find(|cmd| cmd.name() == name)
.map(|cmd| (cmd.label().to_string(), cmd.disabled_reason(world)))
}
pub fn run(&self, world: &mut World, name: &str) -> Option<Result<OperatorAction, String>> {
self.commands
.iter()
.find(|command| command.name() == name)
.map(|command| command.execute(world))
}
}
/// Custom inspector body for level objects (game crates register via [`register_actor_inspector_section`]).
pub trait ActorInspectorSection: Send + Sync {
fn id(&self) -> &str;
fn title(&self) -> &str;
fn order(&self) -> i32;
fn applies_to(&self, world: &World, entity: Entity) -> bool;
fn ui(&self, world: &mut World, ui: &mut egui::Ui, entity: Entity);
}
#[derive(Resource, Default)]
pub struct ActorInspectorSectionRegistry {
sections: Vec<Box<dyn ActorInspectorSection>>,
}
impl ActorInspectorSectionRegistry {
pub fn register(&mut self, section: Box<dyn ActorInspectorSection>) {
self.sections.push(section);
}
pub fn sections_for(&self, world: &World, entity: Entity) -> Vec<&dyn ActorInspectorSection> {
let mut matched: Vec<_> = self
.sections
.iter()
.filter(|section| section.applies_to(world, entity))
.map(|section| section.as_ref())
.collect();
matched.sort_by_key(|section| section.order());
matched
}
pub fn draw_sections(&self, world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let mut matched: Vec<_> = self
.sections
.iter()
.filter(|section| section.applies_to(world, entity))
.collect();
matched.sort_by_key(|section| section.order());
for section in matched {
section.ui(world, ui, entity);
}
}
}
pub fn register_actor_inspector_section(app: &mut App, section: Box<dyn ActorInspectorSection>) {
if !app
.world()
.contains_resource::<ActorInspectorSectionRegistry>()
{
app.init_resource::<ActorInspectorSectionRegistry>();
}
app.world_mut()
.resource_mut::<ActorInspectorSectionRegistry>()
.register(section);
}
/// Optional extension point for registering editor panels and menu hooks.
pub trait EditorExtension: Send + Sync {
fn name(&self) -> &str;
fn register(&self, _app: &mut App) {}
}
/// Formal editor plugin surface for game-specific panels (H5).
pub trait EditorPlugin: Send + Sync {
fn name(&self) -> &str;
fn register(&self, app: &mut App);
}
#[derive(Resource, Default)]
pub struct EditorExtensions {
pub extensions: Vec<Box<dyn EditorExtension>>,
}
#[derive(Resource, Default)]
pub struct RegisteredEditorPlugins {
pub plugins: Vec<Box<dyn EditorPlugin>>,
}
#[derive(Resource, Default)]
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;
impl Plugin for ExtensibilityPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<EditorCommandRegistry>()
.init_resource::<ActorInspectorSectionRegistry>()
.init_resource::<EditorExtensions>()
.init_resource::<RegisteredEditorPlugins>()
.init_resource::<CommandPalette>()
.init_resource::<crate::command_queue::PendingEditorCommands>()
.add_systems(Startup, register_builtin_commands)
.add_systems(Update, run_palette_commands)
.add_systems(EguiPrimaryContextPass, command_palette_ui);
}
}
pub fn register_editor_plugin(app: &mut App, plugin: Box<dyn EditorPlugin>) {
plugin.register(app);
app.world_mut()
.resource_mut::<RegisteredEditorPlugins>()
.plugins
.push(plugin);
}
fn register_builtin_commands(mut registry: ResMut<EditorCommandRegistry>) {
populate_builtin_commands(&mut registry);
}
fn populate_builtin_commands(registry: &mut EditorCommandRegistry) {
registry.register(Box::new(TogglePlayCommand));
registry.register(Box::new(TogglePlayPausedCommand));
registry.register(Box::new(TogglePossessionCommand));
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));
registry.register(Box::new(DrawBrushCommand));
registry.register(Box::new(IntersectBrushesCommand));
registry.register(Box::new(MergeBrushesCommand));
registry.register(Box::new(SubtractBrushesCommand));
}
fn run_palette_commands(world: &mut World) {
let pending = world.resource_mut::<CommandPalette>().pending_run.take();
if let Some(name) = pending {
dispatch_editor_command(world, &name);
}
let names = std::mem::take(
&mut world
.resource_mut::<crate::command_queue::PendingEditorCommands>()
.queue,
);
for name in names {
dispatch_editor_command(world, &name);
}
}
fn dispatch_editor_command(world: &mut World, name: &str) {
let Some((label, disabled_reason)) =
world.resource_scope(|world, registry: Mut<EditorCommandRegistry>| {
registry.command_state(world, name)
})
else {
warn!("unknown editor command: {name}");
return;
};
let name_for_commit = name.to_string();
let name_for_status = name.to_string();
let label_for_commit = label.clone();
run_operator_action(
world,
&name_for_status,
&label,
match disabled_reason {
Some(reason) => OperatorAvailability::Disabled(reason),
None => OperatorAvailability::Ready,
},
move |world| {
let result = world.resource_scope(|world, registry: Mut<EditorCommandRegistry>| {
registry.run(world, &name_for_commit)
});
result.unwrap_or_else(|| Err(format!("Unknown editor command: {name_for_commit}")))
},
);
trace!("dispatched editor operator command {label_for_commit}");
}
struct TogglePlayCommand;
impl EditorCommand for TogglePlayCommand {
fn name(&self) -> &str {
"play.toggle"
}
fn label(&self) -> &str {
"Toggle Play / Edit"
}
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
toggle_play_mode(world);
Ok(OperatorAction::Commit)
}
}
struct TogglePlayPausedCommand;
impl EditorCommand for TogglePlayPausedCommand {
fn name(&self) -> &str {
"play.toggle_pause"
}
fn label(&self) -> &str {
"Pause / Resume Simulation"
}
fn disabled_reason(&self, world: &World) -> Option<String> {
(*world.resource::<State<EditorMode>>().get() != EditorMode::Playing)
.then(|| "Enter Play mode before pausing the simulation".to_string())
}
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
toggle_play_paused(world);
Ok(OperatorAction::Commit)
}
}
struct TogglePossessionCommand;
impl EditorCommand for TogglePossessionCommand {
fn name(&self) -> &str {
"play.toggle_possession"
}
fn label(&self) -> &str {
"Possess / Eject Player"
}
fn disabled_reason(&self, world: &World) -> Option<String> {
(*world.resource::<State<EditorMode>>().get() != EditorMode::Playing)
.then(|| "Enter Play mode before toggling possession".to_string())
}
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
let next = match *world.resource::<PlayPossession>() {
PlayPossession::Possessed => PlayPossession::Ejected,
PlayPossession::Ejected => PlayPossession::Possessed,
};
*world.resource_mut::<PlayPossession>() = next;
Ok(OperatorAction::Commit)
}
}
struct ResetLightingCommand;
impl EditorCommand for ResetLightingCommand {
fn name(&self) -> &str {
"scene.reset_lighting"
}
fn label(&self) -> &str {
"Reset Scene Lighting"
}
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
reset_scene_lighting_to_project_defaults(world);
Ok(OperatorAction::Commit)
}
}
struct GroupSelectionCommand;
impl EditorCommand for GroupSelectionCommand {
fn name(&self) -> &str {
"selection.group"
}
fn label(&self) -> &str {
"Group Selection"
}
fn disabled_reason(&self, world: &World) -> Option<String> {
world
.resource::<UiState>()
.selected_entities
.as_slice()
.is_empty()
.then(|| "Select at least one actor to group".to_string())
}
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
let selected: Vec<Entity> = world
.resource::<UiState>()
.selected_entities
.iter()
.collect();
group_selection_with_history(world, &selected);
Ok(OperatorAction::Commit)
}
}
struct FocusSelectionCommand;
impl EditorCommand for FocusSelectionCommand {
fn name(&self) -> &str {
"selection.focus"
}
fn label(&self) -> &str {
"Focus Selection"
}
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
focus_editor_camera_on_selection(world);
Ok(OperatorAction::Commit)
}
}
struct ResetSelectionTransformCommand;
impl EditorCommand for ResetSelectionTransformCommand {
fn name(&self) -> &str {
"selection.reset_transform"
}
fn label(&self) -> &str {
"Reset Selection Transform"
}
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
reset_selected_transforms(world);
Ok(OperatorAction::Commit)
}
}
struct DrawBrushCommand;
impl EditorCommand for DrawBrushCommand {
fn name(&self) -> &str {
"brush.draw"
}
fn label(&self) -> &str {
"Draw Brush"
}
fn disabled_reason(&self, world: &World) -> Option<String> {
(*world.resource::<State<EditorMode>>().get() != EditorMode::Editing)
.then(|| "Enter Edit mode before drawing brushes".to_string())
}
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
start_draw_brush_tool(world);
Ok(OperatorAction::ContinuePreview)
}
}
struct IntersectBrushesCommand;
impl EditorCommand for IntersectBrushesCommand {
fn name(&self) -> &str {
"brush.intersect"
}
fn label(&self) -> &str {
"Brush Intersect"
}
fn disabled_reason(&self, world: &World) -> Option<String> {
(selected_brush_count(world) < 2).then(|| "Select at least two brushes".to_string())
}
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
intersect_selected_brushes(world)?;
Ok(OperatorAction::ContinuePreview)
}
}
struct MergeBrushesCommand;
impl EditorCommand for MergeBrushesCommand {
fn name(&self) -> &str {
"brush.merge_convex"
}
fn label(&self) -> &str {
"Brush Convex Merge"
}
fn disabled_reason(&self, world: &World) -> Option<String> {
(selected_brush_count(world) < 2).then(|| "Select at least two brushes".to_string())
}
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
merge_selected_brushes(world)?;
Ok(OperatorAction::ContinuePreview)
}
}
struct SubtractBrushesCommand;
impl EditorCommand for SubtractBrushesCommand {
fn name(&self) -> &str {
"brush.subtract"
}
fn label(&self) -> &str {
"Brush Subtract"
}
fn disabled_reason(&self, world: &World) -> Option<String> {
(selected_brush_count(world) < 2).then(|| "Select at least two brushes".to_string())
}
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
subtract_selected_brushes(world)?;
Ok(OperatorAction::ContinuePreview)
}
}
struct CreatePostProcessVolumeCommand;
impl EditorCommand for CreatePostProcessVolumeCommand {
fn name(&self) -> &str {
"rendering.create_volume"
}
fn label(&self) -> &str {
"Create Post-process Volume"
}
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
crate::rendering_diagnostics::spawn_post_process_volume_at_camera(world);
Ok(OperatorAction::Commit)
}
}
struct FocusActiveVolumesCommand;
impl EditorCommand for FocusActiveVolumesCommand {
fn name(&self) -> &str {
"rendering.focus_active_volumes"
}
fn label(&self) -> &str {
"Focus Active Post-process Volumes"
}
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
crate::rendering_diagnostics::select_volumes_at_camera(world);
focus_editor_camera_on_selection(world);
Ok(OperatorAction::Commit)
}
}
struct SelectVolumesAtCameraCommand;
impl EditorCommand for SelectVolumesAtCameraCommand {
fn name(&self) -> &str {
"rendering.select_volumes_at_camera"
}
fn label(&self) -> &str {
"Select Volumes at Camera"
}
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
crate::rendering_diagnostics::select_volumes_at_camera(world);
Ok(OperatorAction::Commit)
}
}
fn command_palette_ui(
mut contexts: EguiContexts,
mut palette: ResMut<CommandPalette>,
registry: Res<EditorCommandRegistry>,
) -> Result {
let Ok(ctx) = contexts.ctx_mut() else {
return Ok(());
};
apply_editor_theme(ctx);
if ctx.input(|input| input.modifiers.command && input.key_pressed(egui::Key::P)) {
palette.open = true;
palette.filter.clear();
palette.focus_filter = true;
palette.selected_index = 0;
}
if !palette.open {
return Ok(());
}
let mut open = palette.open;
let palette_width = (ctx.content_rect().width() - 32.0).clamp(320.0, 520.0);
egui::Window::new("Command Palette")
.open(&mut open)
.anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 64.0))
.collapsible(false)
.resizable(false)
.default_width(palette_width)
.min_width(palette_width)
.max_width(palette_width)
.frame(egui::Frame::window(&ctx.global_style()).fill(PANEL_BG))
.show(ctx, |ui| {
let filter_response = ui.add(
egui::TextEdit::singleline(&mut palette.filter)
.hint_text("Search commands...")
.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 filtered_entries = filtered_command_entries(&registry, &palette.filter);
if !filtered_entries.is_empty() {
palette.selected_index = palette.selected_index.min(filtered_entries.len() - 1);
} else {
palette.selected_index = 0;
}
if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown))
&& !filtered_entries.is_empty()
{
palette.selected_index = (palette.selected_index + 1) % filtered_entries.len();
}
if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp))
&& !filtered_entries.is_empty()
{
palette.selected_index = if palette.selected_index == 0 {
filtered_entries.len() - 1
} else {
palette.selected_index - 1
};
}
if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) {
if let Some(entry) = filtered_entries.get(palette.selected_index) {
palette.pending_run = Some(entry.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(320.0)
.show(ui, |ui| {
if filtered_entries.is_empty() {
ui.weak("No matching commands");
}
for (index, entry) in filtered_entries.iter().enumerate() {
let selected = index == palette.selected_index;
let response = ui.add_sized(
[ui.available_width(), 40.0],
egui::Button::selectable(selected, command_palette_row(entry)),
);
if selected {
response.scroll_to_me(Some(egui::Align::Center));
}
if response.clicked() {
palette.selected_index = index;
palette.pending_run = Some(entry.name.clone());
palette.open = false;
ui.close();
}
}
});
});
palette.open = open;
Ok(())
}
fn filtered_command_entries(
registry: &EditorCommandRegistry,
filter: &str,
) -> Vec<EditorCommandEntry> {
let filter = filter.trim().to_lowercase();
registry
.entries()
.into_iter()
.filter(|entry| {
filter.is_empty()
|| entry.name.to_lowercase().contains(&filter)
|| entry.label.to_lowercase().contains(&filter)
})
.collect()
}
fn command_palette_row(entry: &EditorCommandEntry) -> egui::WidgetText {
let mut job = egui::text::LayoutJob::default();
job.append(
&entry.label,
0.0,
egui::TextFormat {
font_id: egui::FontId::new(13.0, egui::FontFamily::Proportional),
color: TEXT,
..Default::default()
},
);
job.append(
&format!("\n{}", entry.name),
0.0,
egui::TextFormat {
font_id: egui::FontId::new(11.0, egui::FontFamily::Monospace),
color: TEXT_DIM,
..Default::default()
},
);
job.into()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::history::{apply_command_redo, apply_command_undo, EditorHistory};
use crate::operators::test_harness::OperatorInvariantHarness;
use crate::operators::{ActiveOperator, OperatorPhase};
use crate::scene_io::SceneIo;
use crate::selection::{SelectedEntity, ViewportClick};
use crate::viewport::brush_tool::BrushToolState;
use shared::{
ActorKind, AuthoringLightKind, BrushDesc, EditorVisibility, HierarchySiblingIndex,
LevelObject, LightDesc,
};
fn command_world() -> World {
let mut world = World::new();
world.init_resource::<ActiveOperator>();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
world.init_resource::<SelectedEntity>();
world.init_resource::<ViewportClick>();
world.init_resource::<BrushToolState>();
world.init_resource::<crate::viewport::brush_csg::BrushCsgPreview>();
world.insert_resource(State::new(EditorMode::Editing));
world.insert_resource(UiState::default_layout());
let mut registry = EditorCommandRegistry::default();
populate_builtin_commands(&mut registry);
world.insert_resource(registry);
world
}
fn hierarchy_projection(world: &mut World) -> Vec<(String, Option<String>, i32)> {
let mut query = world.query_filtered::<(Entity, &Name), With<LevelObject>>();
let mut result = query
.iter(world)
.map(|(entity, name)| {
let parent = world
.get::<ChildOf>(entity)
.and_then(|child| world.get::<Name>(child.parent()))
.map(|name| name.as_str().to_string());
let sibling_index = world
.get::<HierarchySiblingIndex>(entity)
.map(|index| index.0)
.unwrap_or_default();
(name.as_str().to_string(), parent, sibling_index)
})
.collect::<Vec<_>>();
result.sort();
result
}
#[test]
fn command_filter_matches_human_label_and_stable_id() {
let mut registry = EditorCommandRegistry::default();
registry.register(Box::new(TogglePlayCommand));
registry.register(Box::new(ResetLightingCommand));
let by_label = filtered_command_entries(&registry, "scene lighting");
assert_eq!(by_label.len(), 1);
assert_eq!(by_label[0].name, "scene.reset_lighting");
let by_id = filtered_command_entries(&registry, "play.toggle");
assert_eq!(by_id.len(), 1);
assert_eq!(by_id[0].label, "Toggle Play / Edit");
}
#[test]
fn empty_command_filter_preserves_registration_order() {
let mut registry = EditorCommandRegistry::default();
registry.register(Box::new(TogglePlayCommand));
registry.register(Box::new(ResetLightingCommand));
let entries = filtered_command_entries(&registry, " ");
assert_eq!(entries[0].name, "play.toggle");
assert_eq!(entries[1].name, "scene.reset_lighting");
}
#[test]
fn grouping_dispatch_is_one_history_transaction_and_round_trips() {
let mut world = command_world();
let first = world
.spawn((
Name::new("First"),
LevelObject,
ActorKind::Empty,
Transform::from_xyz(1.0, 0.0, 0.0),
GlobalTransform::default(),
HierarchySiblingIndex(0),
EditorVisibility::default(),
))
.id();
let second = world
.spawn((
Name::new("Second"),
LevelObject,
ActorKind::Empty,
Transform::from_xyz(2.0, 0.0, 0.0),
GlobalTransform::default(),
HierarchySiblingIndex(1),
EditorVisibility::default(),
))
.id();
let unselected = world
.spawn((
Name::new("Unselected"),
LevelObject,
ActorKind::Empty,
Transform::from_xyz(3.0, 0.0, 0.0),
GlobalTransform::default(),
HierarchySiblingIndex(2),
EditorVisibility::default(),
))
.id();
world
.resource_mut::<UiState>()
.selected_entities
.select_replace(first);
world
.resource_mut::<UiState>()
.selected_entities
.select_maybe_add(second, true);
let initial = hierarchy_projection(&mut world);
let harness = OperatorInvariantHarness::capture(&mut world);
dispatch_editor_command(&mut world, "selection.group");
harness.assert_committed(&mut world, 1, 1);
harness.assert_status(&world, "selection.group", OperatorPhase::Committed);
let committed = hierarchy_projection(&mut world);
assert!(committed
.iter()
.any(|(name, parent, _)| name == "First" && parent.as_deref() == Some("Group")));
assert!(committed
.iter()
.any(|(name, parent, _)| name == "Second" && parent.as_deref() == Some("Group")));
apply_command_undo(&mut world);
assert_eq!(hierarchy_projection(&mut world), initial);
apply_command_redo(&mut world);
assert_eq!(hierarchy_projection(&mut world), committed);
assert!(world.get::<ChildOf>(unselected).is_none());
apply_command_undo(&mut world);
assert_eq!(hierarchy_projection(&mut world), initial);
apply_command_redo(&mut world);
assert_eq!(hierarchy_projection(&mut world), committed);
assert!(world.get::<ChildOf>(unselected).is_none());
}
#[test]
fn reset_lighting_dispatch_is_one_undoable_group() {
let mut world = command_world();
let point = world
.spawn((
LevelObject,
ActorKind::Light,
LightDesc {
kind: AuthoringLightKind::Point,
..Default::default()
},
))
.id();
let directional = world
.spawn((
LevelObject,
ActorKind::Light,
LightDesc {
kind: AuthoringLightKind::Directional,
..Default::default()
},
))
.id();
let harness = OperatorInvariantHarness::capture(&mut world);
dispatch_editor_command(&mut world, "scene.reset_lighting");
harness.assert_committed(&mut world, 1, 0);
harness.assert_status(&world, "scene.reset_lighting", OperatorPhase::Committed);
assert!(world.get::<LightDesc>(point).is_none());
assert!(world.get::<LightDesc>(directional).is_none());
apply_command_undo(&mut world);
assert!(world.get::<LightDesc>(point).is_some());
assert!(world.get::<LightDesc>(directional).is_some());
apply_command_redo(&mut world);
assert!(world.get::<LightDesc>(point).is_none());
assert!(world.get::<LightDesc>(directional).is_none());
}
#[test]
fn modal_command_dispatch_retains_preview_ownership() {
let mut world = command_world();
let harness = OperatorInvariantHarness::capture(&mut world);
dispatch_editor_command(&mut world, "brush.draw");
harness.assert_status(&world, "brush.draw", OperatorPhase::Preview);
harness.assert_projection_unchanged(&mut world, 0, |world| {
world.resource::<EditorHistory>().undo_depth()
});
assert!(!world.resource::<SceneIo>().dirty);
assert!(world.resource::<BrushToolState>().active);
}
#[test]
fn failed_csg_dispatch_terminates_without_preview_or_history() {
let mut world = command_world();
let first = world
.spawn((
LevelObject,
BrushDesc::cuboid(Vec3::splat(1.0)),
Transform::IDENTITY,
))
.id();
let mut invalid = BrushDesc::cuboid(Vec3::splat(1.0));
invalid.faces.clear();
let second = world
.spawn((LevelObject, invalid, Transform::IDENTITY))
.id();
world
.resource_mut::<UiState>()
.selected_entities
.select_replace(first);
world
.resource_mut::<UiState>()
.selected_entities
.select_maybe_add(second, true);
let harness = OperatorInvariantHarness::capture(&mut world);
dispatch_editor_command(&mut world, "brush.intersect");
harness.assert_canceled(&mut world);
harness.assert_status(&world, "brush.intersect", OperatorPhase::Canceled);
assert!(!world.resource::<SceneIo>().dirty);
}
#[test]
fn csg_dispatch_blocks_read_only_selection_without_history() {
let mut world = command_world();
let first = world
.spawn((
LevelObject,
BrushDesc::cuboid(Vec3::splat(1.0)),
Transform::IDENTITY,
))
.id();
let second = world
.spawn((
LevelObject,
BrushDesc::cuboid(Vec3::splat(1.0)),
Transform::from_xyz(0.5, 0.0, 0.0),
))
.id();
let mut hierarchy = crate::ui::hierarchy_state::HierarchyPanelState::default();
hierarchy.locked.insert(second);
world.insert_resource(hierarchy);
world
.resource_mut::<UiState>()
.selected_entities
.select_replace(first);
world
.resource_mut::<UiState>()
.selected_entities
.select_maybe_add(second, true);
let harness = OperatorInvariantHarness::capture(&mut world);
dispatch_editor_command(&mut world, "brush.merge_convex");
harness.assert_blocked(&mut world);
harness.assert_status(&world, "brush.merge_convex", OperatorPhase::Blocked);
assert!(!world.resource::<SceneIo>().dirty);
}
#[test]
fn disabled_command_dispatch_is_a_blocked_no_op() {
let mut world = command_world();
let harness = OperatorInvariantHarness::capture(&mut world);
dispatch_editor_command(&mut world, "selection.group");
harness.assert_blocked(&mut world);
harness.assert_status(&world, "selection.group", OperatorPhase::Blocked);
}
}