Complete editor operator invariants
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
This commit is contained in:
parent
9a67a25a15
commit
1ab3886028
@ -0,0 +1,33 @@
|
|||||||
|
# Operator Invariants Completion
|
||||||
|
|
||||||
|
Working plan for Gitea
|
||||||
|
[`#33`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/33).
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Implementation complete. Source and headless gates pass on the publication branch; the final exact
|
||||||
|
commit is recorded in the issue closure comment. Packaged-runtime testing remains deferred by the
|
||||||
|
project owner.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
1. Make registered commands distinguish immediate completion from modal Preview ownership.
|
||||||
|
2. Require stable operator IDs and terminal phases for commit, cancel, block, failure, no-op, and
|
||||||
|
automatic interruption paths.
|
||||||
|
3. Make Group Selection and lighting changes atomic history transactions with repeatable undo/redo.
|
||||||
|
4. Exercise production dispatch/finalizers for assets, material drops, brushes, terrain, physics
|
||||||
|
placement, and transform gizmos through reusable semantic projections.
|
||||||
|
5. Publish the invariant contract, acceptance evidence, and production-gate status without claiming
|
||||||
|
release-candidate acceptance.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `cargo test -p editor --lib --no-fail-fast`
|
||||||
|
- `cargo clippy -p editor --lib --tests -- -D warnings`
|
||||||
|
- `cargo fmt --all -- --check`
|
||||||
|
- `cargo test --workspace --all-targets --no-fail-fast`
|
||||||
|
- `cargo clippy --workspace --all-targets -- -D warnings`
|
||||||
|
- `cargo validate-levels --project .`
|
||||||
|
|
||||||
|
Live editor checks cover command-palette modal ownership and the grouped selection/lighting history
|
||||||
|
surface. Packaged tests are deliberately excluded until re-enabled by the project owner.
|
||||||
@ -15,12 +15,12 @@ owner requests it again.
|
|||||||
|
|
||||||
1. Keep one evidence matrix under `docs/editor/evaluations/production-readiness/`; historical H1-H6
|
1. Keep one evidence matrix under `docs/editor/evaluations/production-readiness/`; historical H1-H6
|
||||||
notes remain context only.
|
notes remain context only.
|
||||||
2. Close implementation blockers before nominating a candidate: the deformed Solari geometry
|
2. Close implementation blockers before nominating a candidate. Renderer foundation `#51`, terrain
|
||||||
boundary in `#51`, terrain `#22`-`#24`, physics placement/diagnostics `#25`-`#26`, and their
|
`#22`-`#24`, physics placement/diagnostics `#25`-`#26`, operator invariants `#33`, collaborative
|
||||||
regression fixtures. Collaborative safety `#49`, Material Library/targeted drops `#16`/`#18`,
|
safety `#49`, Material Library/targeted drops `#16`/`#18`, and native-dialog responsiveness `#52`
|
||||||
and native-dialog responsiveness `#52` are accepted and integrated.
|
are accepted and integrated.
|
||||||
3. Complete the representative regression project, mutation-invariant coverage, performance budgets,
|
3. Complete the representative regression project, performance budgets, and first-hour workflow
|
||||||
and first-hour workflow tracked by `#32`-`#36`.
|
tracked by `#32`, `#34`, `#35`, and `#36`.
|
||||||
4. Nominate one exact commit, validate it from a clean checkout, and record source/headless results.
|
4. Nominate one exact commit, validate it from a clean checkout, and record source/headless results.
|
||||||
5. When packaged testing is re-enabled, run the candidate's package/build and packaged-runtime
|
5. When packaged testing is re-enabled, run the candidate's package/build and packaged-runtime
|
||||||
matrix without substituting older artifacts.
|
matrix without substituting older artifacts.
|
||||||
|
|||||||
@ -419,7 +419,7 @@ crates/
|
|||||||
- [x] CI workflow for format, check, clippy, tests, and binary builds
|
- [x] CI workflow for format, check, clippy, tests, and binary builds
|
||||||
- [x] ADRs for roadmap architecture and Bevy migration policy
|
- [x] ADRs for roadmap architecture and Bevy migration policy
|
||||||
- [x] Determinism harness: same inputs over same ticks produce the same state summary/hash
|
- [x] Determinism harness: same inputs over same ticks produce the same state summary/hash
|
||||||
- [x] Reusable editor operator harness for commit/cancel/block, helper cleanup, dirty state, grouped undo, and undo/redo round trips
|
- [x] Production operator invariants across palette dispatch, assets/material drops, brush/terrain/physics modal tools, grouping/lighting, and the transform finalizer: stable terminal status, exact cancel/failure rollback, helper cleanup, grouped history, and repeated undo/redo projections ([testing contract](docs/editor/operator-regression-testing.md), [evaluation](docs/editor/evaluations/operator-invariants/), [Gitea #33](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/33))
|
||||||
- [x] Verify: `cargo fmt --check` / `cargo check --workspace` / `cargo clippy --workspace` / strict foundation clippy / `cargo test -p sim`
|
- [x] Verify: `cargo fmt --check` / `cargo check --workspace` / `cargo clippy --workspace` / strict foundation clippy / `cargo test -p sim`
|
||||||
- [x] Stable asset registry with UUIDs + import settings in asset browser details
|
- [x] Stable asset registry with UUIDs + import settings in asset browser details
|
||||||
- [x] Asset Browser expandable model subasset shelves, independent mesh/material/texture thumbnails, staged import/material details with shader-schema parameters, context actions, and trash-first file removal
|
- [x] Asset Browser expandable model subasset shelves, independent mesh/material/texture thumbnails, staged import/material details with shader-schema parameters, context actions, and trash-first file removal
|
||||||
|
|||||||
@ -469,6 +469,86 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_actor_material_assignment_is_one_undo_group() {
|
||||||
|
let mut world = World::new();
|
||||||
|
world.init_resource::<ActiveOperator>();
|
||||||
|
world.init_resource::<EditorHistory>();
|
||||||
|
world.init_resource::<SceneIo>();
|
||||||
|
let material_path = std::env::temp_dir().join(format!(
|
||||||
|
"blacksite-material-operator-{}.material.ron",
|
||||||
|
uuid::Uuid::new_v4()
|
||||||
|
));
|
||||||
|
let fixture = shared::MaterialAsset {
|
||||||
|
schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION,
|
||||||
|
label: "Concrete".into(),
|
||||||
|
shader: None,
|
||||||
|
shader_ref: None,
|
||||||
|
render_state: Default::default(),
|
||||||
|
material: MaterialDesc {
|
||||||
|
roughness: 0.85,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
std::fs::write(
|
||||||
|
&material_path,
|
||||||
|
ron::ser::to_string_pretty(&fixture, ron::ser::PrettyConfig::default()).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let material_path = material_path.to_string_lossy().into_owned();
|
||||||
|
world.insert_resource(AssetRegistry {
|
||||||
|
records: vec![AssetRecord {
|
||||||
|
id: AssetId::new(),
|
||||||
|
path: material_path.clone(),
|
||||||
|
label: "Concrete".into(),
|
||||||
|
kind_tag: "Material".into(),
|
||||||
|
import_settings: Default::default(),
|
||||||
|
dependencies: Vec::new(),
|
||||||
|
}],
|
||||||
|
index_dirty: false,
|
||||||
|
});
|
||||||
|
let first = world.spawn((LevelObject, MaterialDesc::default())).id();
|
||||||
|
let second = world.spawn((LevelObject, MaterialDesc::default())).id();
|
||||||
|
let mut selected = SelectedEntities::default();
|
||||||
|
selected.select_replace(first);
|
||||||
|
selected.select_maybe_add(second, true);
|
||||||
|
let asset = EditorAsset {
|
||||||
|
label: "Concrete".to_string(),
|
||||||
|
path: Some(material_path.clone()),
|
||||||
|
folder_path: "assets/materials".to_string(),
|
||||||
|
kind: super::super::EditorAssetKind::Material,
|
||||||
|
};
|
||||||
|
super::super::materials::material_desc_from_asset(&material_path)
|
||||||
|
.expect("committed material fixture should resolve");
|
||||||
|
let harness = OperatorInvariantHarness::capture(&mut world);
|
||||||
|
let read_materials = move |world: &mut World| {
|
||||||
|
[first, second]
|
||||||
|
.into_iter()
|
||||||
|
.map(|entity| {
|
||||||
|
world
|
||||||
|
.get::<MaterialDesc>(entity)
|
||||||
|
.and_then(|material| material.material_asset_path.clone())
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
|
let applied = apply_material_operator(&mut world, asset, &selected);
|
||||||
|
assert!(
|
||||||
|
applied,
|
||||||
|
"material operator failed: {:?}",
|
||||||
|
world.resource::<ActiveOperator>().status
|
||||||
|
);
|
||||||
|
|
||||||
|
harness.assert_committed(&mut world, 1, 0);
|
||||||
|
assert_undo_redo_round_trip(
|
||||||
|
&mut world,
|
||||||
|
vec![None, None],
|
||||||
|
vec![Some(material_path.clone()), Some(material_path.clone())],
|
||||||
|
read_materials,
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(material_path);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_subasset_commit_cancels_without_side_effects() {
|
fn missing_subasset_commit_cancels_without_side_effects() {
|
||||||
let mut world = World::new();
|
let mut world = World::new();
|
||||||
@ -533,4 +613,59 @@ mod tests {
|
|||||||
|
|
||||||
harness.assert_blocked(&mut world);
|
harness.assert_blocked(&mut world);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn material_assignment_without_authored_selection_is_blocked() {
|
||||||
|
let mut world = World::new();
|
||||||
|
world.init_resource::<ActiveOperator>();
|
||||||
|
world.init_resource::<EditorHistory>();
|
||||||
|
world.init_resource::<SceneIo>();
|
||||||
|
let harness = OperatorInvariantHarness::capture(&mut world);
|
||||||
|
let asset = EditorAsset {
|
||||||
|
label: "Concrete".to_string(),
|
||||||
|
path: Some("assets/materials/concrete.ron".to_string()),
|
||||||
|
folder_path: "assets/materials".to_string(),
|
||||||
|
kind: super::super::EditorAssetKind::Material,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!apply_material_operator(
|
||||||
|
&mut world,
|
||||||
|
asset,
|
||||||
|
&SelectedEntities::default()
|
||||||
|
));
|
||||||
|
|
||||||
|
harness.assert_blocked(&mut world);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_and_animation_assignment_reject_incompatible_actors() {
|
||||||
|
let mut world = World::new();
|
||||||
|
world.init_resource::<ActiveOperator>();
|
||||||
|
world.init_resource::<EditorHistory>();
|
||||||
|
world.init_resource::<SceneIo>();
|
||||||
|
let entity = world.spawn(LevelObject).id();
|
||||||
|
let audio = EditorAsset {
|
||||||
|
label: "Impact".into(),
|
||||||
|
path: Some("assets/audio/impact.ogg".into()),
|
||||||
|
folder_path: "assets/audio".into(),
|
||||||
|
kind: super::super::EditorAssetKind::AudioClip,
|
||||||
|
};
|
||||||
|
let audio_harness = OperatorInvariantHarness::capture(&mut world);
|
||||||
|
|
||||||
|
assert!(!assign_audio_clip_operator(&mut world, audio, entity));
|
||||||
|
audio_harness.assert_blocked(&mut world);
|
||||||
|
|
||||||
|
let animation_harness = OperatorInvariantHarness::capture(&mut world);
|
||||||
|
let selection = AssetSelection::SubAsset {
|
||||||
|
parent_path: "assets/models/missing.glb".into(),
|
||||||
|
sub_asset_id: "animation:missing".into(),
|
||||||
|
label: "Missing".into(),
|
||||||
|
kind: super::super::AssetSubAssetKind::AnimationClip,
|
||||||
|
source_path: None,
|
||||||
|
};
|
||||||
|
assert!(!assign_animation_clip_operator(
|
||||||
|
&mut world, selection, entity
|
||||||
|
));
|
||||||
|
animation_harness.assert_blocked(&mut world);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ use bevy::prelude::*;
|
|||||||
use bevy_egui::{egui, EguiContexts, EguiPrimaryContextPass};
|
use bevy_egui::{egui, EguiContexts, EguiPrimaryContextPass};
|
||||||
|
|
||||||
use crate::history::group_selection_with_history;
|
use crate::history::group_selection_with_history;
|
||||||
use crate::operators::{run_immediate_operator, OperatorAvailability};
|
use crate::operators::{run_operator_action, OperatorAction, OperatorAvailability};
|
||||||
use crate::state::{EditorMode, PlayPossession};
|
use crate::state::{EditorMode, PlayPossession};
|
||||||
use crate::ui::helpers::{
|
use crate::ui::helpers::{
|
||||||
reset_scene_lighting_to_project_defaults, toggle_play_mode, toggle_play_paused,
|
reset_scene_lighting_to_project_defaults, toggle_play_mode, toggle_play_paused,
|
||||||
@ -27,7 +27,7 @@ pub trait EditorCommand: Send + Sync {
|
|||||||
fn disabled_reason(&self, _world: &World) -> Option<String> {
|
fn disabled_reason(&self, _world: &World) -> Option<String> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
fn execute(&self, world: &mut World);
|
fn execute(&self, world: &mut World) -> Result<OperatorAction, String>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Resource, Default)]
|
#[derive(Resource, Default)]
|
||||||
@ -70,13 +70,11 @@ impl EditorCommandRegistry {
|
|||||||
.map(|cmd| (cmd.label().to_string(), cmd.disabled_reason(world)))
|
.map(|cmd| (cmd.label().to_string(), cmd.disabled_reason(world)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(&self, world: &mut World, name: &str) -> bool {
|
pub fn run(&self, world: &mut World, name: &str) -> Option<Result<OperatorAction, String>> {
|
||||||
if let Some(command) = self.commands.iter().find(|cmd| cmd.name() == name) {
|
self.commands
|
||||||
command.execute(world);
|
.iter()
|
||||||
true
|
.find(|command| command.name() == name)
|
||||||
} else {
|
.map(|command| command.execute(world))
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,6 +189,10 @@ pub fn register_editor_plugin(app: &mut App, plugin: Box<dyn EditorPlugin>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn register_builtin_commands(mut registry: ResMut<EditorCommandRegistry>) {
|
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(TogglePlayCommand));
|
||||||
registry.register(Box::new(TogglePlayPausedCommand));
|
registry.register(Box::new(TogglePlayPausedCommand));
|
||||||
registry.register(Box::new(TogglePossessionCommand));
|
registry.register(Box::new(TogglePossessionCommand));
|
||||||
@ -235,7 +237,7 @@ fn dispatch_editor_command(world: &mut World, name: &str) {
|
|||||||
let name_for_commit = name.to_string();
|
let name_for_commit = name.to_string();
|
||||||
let name_for_status = name.to_string();
|
let name_for_status = name.to_string();
|
||||||
let label_for_commit = label.clone();
|
let label_for_commit = label.clone();
|
||||||
run_immediate_operator(
|
run_operator_action(
|
||||||
world,
|
world,
|
||||||
&name_for_status,
|
&name_for_status,
|
||||||
&label,
|
&label,
|
||||||
@ -244,14 +246,10 @@ fn dispatch_editor_command(world: &mut World, name: &str) {
|
|||||||
None => OperatorAvailability::Ready,
|
None => OperatorAvailability::Ready,
|
||||||
},
|
},
|
||||||
move |world| {
|
move |world| {
|
||||||
let ran = world.resource_scope(|world, registry: Mut<EditorCommandRegistry>| {
|
let result = world.resource_scope(|world, registry: Mut<EditorCommandRegistry>| {
|
||||||
registry.run(world, &name_for_commit)
|
registry.run(world, &name_for_commit)
|
||||||
});
|
});
|
||||||
if ran {
|
result.unwrap_or_else(|| Err(format!("Unknown editor command: {name_for_commit}")))
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(format!("Unknown editor command: {name_for_commit}"))
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -269,8 +267,9 @@ impl EditorCommand for TogglePlayCommand {
|
|||||||
"Toggle Play / Edit"
|
"Toggle Play / Edit"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute(&self, world: &mut World) {
|
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
|
||||||
toggle_play_mode(world);
|
toggle_play_mode(world);
|
||||||
|
Ok(OperatorAction::Commit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -290,8 +289,9 @@ impl EditorCommand for TogglePlayPausedCommand {
|
|||||||
.then(|| "Enter Play mode before pausing the simulation".to_string())
|
.then(|| "Enter Play mode before pausing the simulation".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute(&self, world: &mut World) {
|
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
|
||||||
toggle_play_paused(world);
|
toggle_play_paused(world);
|
||||||
|
Ok(OperatorAction::Commit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -311,12 +311,13 @@ impl EditorCommand for TogglePossessionCommand {
|
|||||||
.then(|| "Enter Play mode before toggling possession".to_string())
|
.then(|| "Enter Play mode before toggling possession".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute(&self, world: &mut World) {
|
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
|
||||||
let next = match *world.resource::<PlayPossession>() {
|
let next = match *world.resource::<PlayPossession>() {
|
||||||
PlayPossession::Possessed => PlayPossession::Ejected,
|
PlayPossession::Possessed => PlayPossession::Ejected,
|
||||||
PlayPossession::Ejected => PlayPossession::Possessed,
|
PlayPossession::Ejected => PlayPossession::Possessed,
|
||||||
};
|
};
|
||||||
*world.resource_mut::<PlayPossession>() = next;
|
*world.resource_mut::<PlayPossession>() = next;
|
||||||
|
Ok(OperatorAction::Commit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -331,8 +332,9 @@ impl EditorCommand for ResetLightingCommand {
|
|||||||
"Reset Scene Lighting"
|
"Reset Scene Lighting"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute(&self, world: &mut World) {
|
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
|
||||||
reset_scene_lighting_to_project_defaults(world);
|
reset_scene_lighting_to_project_defaults(world);
|
||||||
|
Ok(OperatorAction::Commit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -347,13 +349,23 @@ impl EditorCommand for GroupSelectionCommand {
|
|||||||
"Group Selection"
|
"Group Selection"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute(&self, world: &mut World) {
|
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
|
let selected: Vec<Entity> = world
|
||||||
.resource::<UiState>()
|
.resource::<UiState>()
|
||||||
.selected_entities
|
.selected_entities
|
||||||
.iter()
|
.iter()
|
||||||
.collect();
|
.collect();
|
||||||
group_selection_with_history(world, &selected);
|
group_selection_with_history(world, &selected);
|
||||||
|
Ok(OperatorAction::Commit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -368,8 +380,9 @@ impl EditorCommand for FocusSelectionCommand {
|
|||||||
"Focus Selection"
|
"Focus Selection"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute(&self, world: &mut World) {
|
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
|
||||||
focus_editor_camera_on_selection(world);
|
focus_editor_camera_on_selection(world);
|
||||||
|
Ok(OperatorAction::Commit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -384,8 +397,9 @@ impl EditorCommand for ResetSelectionTransformCommand {
|
|||||||
"Reset Selection Transform"
|
"Reset Selection Transform"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute(&self, world: &mut World) {
|
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
|
||||||
reset_selected_transforms(world);
|
reset_selected_transforms(world);
|
||||||
|
Ok(OperatorAction::Commit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -405,8 +419,9 @@ impl EditorCommand for DrawBrushCommand {
|
|||||||
.then(|| "Enter Edit mode before drawing brushes".to_string())
|
.then(|| "Enter Edit mode before drawing brushes".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute(&self, world: &mut World) {
|
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
|
||||||
start_draw_brush_tool(world);
|
start_draw_brush_tool(world);
|
||||||
|
Ok(OperatorAction::ContinuePreview)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -425,8 +440,9 @@ impl EditorCommand for IntersectBrushesCommand {
|
|||||||
(selected_brush_count(world) < 2).then(|| "Select at least two brushes".to_string())
|
(selected_brush_count(world) < 2).then(|| "Select at least two brushes".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute(&self, world: &mut World) {
|
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
|
||||||
intersect_selected_brushes(world);
|
intersect_selected_brushes(world)?;
|
||||||
|
Ok(OperatorAction::ContinuePreview)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -445,8 +461,9 @@ impl EditorCommand for MergeBrushesCommand {
|
|||||||
(selected_brush_count(world) < 2).then(|| "Select at least two brushes".to_string())
|
(selected_brush_count(world) < 2).then(|| "Select at least two brushes".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute(&self, world: &mut World) {
|
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
|
||||||
merge_selected_brushes(world);
|
merge_selected_brushes(world)?;
|
||||||
|
Ok(OperatorAction::ContinuePreview)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -465,8 +482,9 @@ impl EditorCommand for SubtractBrushesCommand {
|
|||||||
(selected_brush_count(world) < 2).then(|| "Select at least two brushes".to_string())
|
(selected_brush_count(world) < 2).then(|| "Select at least two brushes".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute(&self, world: &mut World) {
|
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
|
||||||
subtract_selected_brushes(world);
|
subtract_selected_brushes(world)?;
|
||||||
|
Ok(OperatorAction::ContinuePreview)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -481,8 +499,9 @@ impl EditorCommand for CreatePostProcessVolumeCommand {
|
|||||||
"Create Post-process Volume"
|
"Create Post-process Volume"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute(&self, world: &mut World) {
|
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
|
||||||
crate::rendering_diagnostics::spawn_post_process_volume_at_camera(world);
|
crate::rendering_diagnostics::spawn_post_process_volume_at_camera(world);
|
||||||
|
Ok(OperatorAction::Commit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -497,9 +516,10 @@ impl EditorCommand for FocusActiveVolumesCommand {
|
|||||||
"Focus Active Post-process Volumes"
|
"Focus Active Post-process Volumes"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute(&self, world: &mut World) {
|
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
|
||||||
crate::rendering_diagnostics::select_volumes_at_camera(world);
|
crate::rendering_diagnostics::select_volumes_at_camera(world);
|
||||||
focus_editor_camera_on_selection(world);
|
focus_editor_camera_on_selection(world);
|
||||||
|
Ok(OperatorAction::Commit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -514,8 +534,9 @@ impl EditorCommand for SelectVolumesAtCameraCommand {
|
|||||||
"Select Volumes at Camera"
|
"Select Volumes at Camera"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute(&self, world: &mut World) {
|
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
|
||||||
crate::rendering_diagnostics::select_volumes_at_camera(world);
|
crate::rendering_diagnostics::select_volumes_at_camera(world);
|
||||||
|
Ok(OperatorAction::Commit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -671,6 +692,53 @@ fn command_palette_row(entry: &EditorCommandEntry) -> egui::WidgetText {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn command_filter_matches_human_label_and_stable_id() {
|
fn command_filter_matches_human_label_and_stable_id() {
|
||||||
@ -697,4 +765,208 @@ mod tests {
|
|||||||
assert_eq!(entries[0].name, "play.toggle");
|
assert_eq!(entries[0].name, "play.toggle");
|
||||||
assert_eq!(entries[1].name, "scene.reset_lighting");
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -106,6 +106,11 @@ pub struct PrefabEditState {
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum EditorCommand {
|
pub enum EditorCommand {
|
||||||
|
GroupSelection {
|
||||||
|
snapshot: EditorEntitySnapshot,
|
||||||
|
group: Option<Entity>,
|
||||||
|
changes: Vec<SiblingChange>,
|
||||||
|
},
|
||||||
Spawn {
|
Spawn {
|
||||||
snapshot: EditorEntitySnapshot,
|
snapshot: EditorEntitySnapshot,
|
||||||
entity: Option<Entity>,
|
entity: Option<Entity>,
|
||||||
@ -152,6 +157,12 @@ pub enum EditorCommand {
|
|||||||
old: Option<LightDesc>,
|
old: Option<LightDesc>,
|
||||||
new: LightDesc,
|
new: LightDesc,
|
||||||
},
|
},
|
||||||
|
SetLightGroup {
|
||||||
|
entities: Vec<Entity>,
|
||||||
|
olds: Vec<Option<LightDesc>>,
|
||||||
|
news: Vec<Option<LightDesc>>,
|
||||||
|
label: &'static str,
|
||||||
|
},
|
||||||
SetAnimationController {
|
SetAnimationController {
|
||||||
entity: Entity,
|
entity: Entity,
|
||||||
old: Option<AnimationControllerDesc>,
|
old: Option<AnimationControllerDesc>,
|
||||||
@ -293,6 +304,7 @@ pub enum EditorCommand {
|
|||||||
impl EditorCommand {
|
impl EditorCommand {
|
||||||
pub fn label(&self) -> &'static str {
|
pub fn label(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
|
EditorCommand::GroupSelection { .. } => "Group Selection",
|
||||||
EditorCommand::Spawn { .. } => "Spawn",
|
EditorCommand::Spawn { .. } => "Spawn",
|
||||||
EditorCommand::SpawnMany { .. } => "Spawn Brushes",
|
EditorCommand::SpawnMany { .. } => "Spawn Brushes",
|
||||||
EditorCommand::Despawn { .. } => "Delete",
|
EditorCommand::Despawn { .. } => "Delete",
|
||||||
@ -303,6 +315,7 @@ impl EditorCommand {
|
|||||||
EditorCommand::SetMaterialGroup { .. } => "Apply Material to Selection",
|
EditorCommand::SetMaterialGroup { .. } => "Apply Material to Selection",
|
||||||
EditorCommand::SetMaterialOverride { .. } => "Set Material Override",
|
EditorCommand::SetMaterialOverride { .. } => "Set Material Override",
|
||||||
EditorCommand::SetLight { .. } => "Set Light",
|
EditorCommand::SetLight { .. } => "Set Light",
|
||||||
|
EditorCommand::SetLightGroup { label, .. } => label,
|
||||||
EditorCommand::SetAnimationController { .. } => "Set Animation Controller",
|
EditorCommand::SetAnimationController { .. } => "Set Animation Controller",
|
||||||
EditorCommand::SetAudioSource { .. } => "Set Audio Source",
|
EditorCommand::SetAudioSource { .. } => "Set Audio Source",
|
||||||
EditorCommand::SetAudioListener { .. } => "Set Audio Listener",
|
EditorCommand::SetAudioListener { .. } => "Set Audio Listener",
|
||||||
|
|||||||
@ -17,6 +17,7 @@ use shared::{
|
|||||||
TriggerVolume, WeaponSpawn,
|
TriggerVolume, WeaponSpawn,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus};
|
||||||
use crate::scene_io::{SceneIo, SceneIoRequest};
|
use crate::scene_io::{SceneIo, SceneIoRequest};
|
||||||
use crate::selection::SelectedEntity;
|
use crate::selection::SelectedEntity;
|
||||||
use crate::state::scene_tools_active;
|
use crate::state::scene_tools_active;
|
||||||
@ -560,6 +561,40 @@ pub fn set_light_with_history(world: &mut World, entity: Entity, new: LightDesc)
|
|||||||
push_history(world, EditorCommand::SetLight { entity, old, new });
|
push_history(world, EditorCommand::SetLight { entity, old, new });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_light_group_with_history(
|
||||||
|
world: &mut World,
|
||||||
|
changes: impl IntoIterator<Item = (Entity, Option<LightDesc>)>,
|
||||||
|
label: &'static str,
|
||||||
|
) {
|
||||||
|
let mut entities = Vec::new();
|
||||||
|
let mut olds = Vec::new();
|
||||||
|
let mut news = Vec::new();
|
||||||
|
for (entity, new) in changes {
|
||||||
|
if !is_mutable_level_object(world, entity) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let old = world.get::<LightDesc>(entity).cloned();
|
||||||
|
if old.is_none() && new.is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
apply_light(world, entity, &new);
|
||||||
|
entities.push(entity);
|
||||||
|
olds.push(old);
|
||||||
|
news.push(new);
|
||||||
|
}
|
||||||
|
if !entities.is_empty() {
|
||||||
|
push_history(
|
||||||
|
world,
|
||||||
|
EditorCommand::SetLightGroup {
|
||||||
|
entities,
|
||||||
|
olds,
|
||||||
|
news,
|
||||||
|
label,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_audio_source_with_history(world: &mut World, entity: Entity, new: AudioSourceDesc) {
|
pub fn set_audio_source_with_history(world: &mut World, entity: Entity, new: AudioSourceDesc) {
|
||||||
if !is_mutable_level_object(world, entity) {
|
if !is_mutable_level_object(world, entity) {
|
||||||
return;
|
return;
|
||||||
@ -752,9 +787,13 @@ pub fn apply_brush_csg_with_history(
|
|||||||
new_transform: Transform,
|
new_transform: Transform,
|
||||||
new_brush: BrushDesc,
|
new_brush: BrushDesc,
|
||||||
delete_entities: &[Entity],
|
delete_entities: &[Entity],
|
||||||
) {
|
) -> bool {
|
||||||
if !is_level_object(world, primary) {
|
if !is_mutable_level_object(world, primary)
|
||||||
return;
|
|| delete_entities
|
||||||
|
.iter()
|
||||||
|
.any(|entity| !is_mutable_level_object(world, *entity))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
let old_transform = world.get::<Transform>(primary).copied().unwrap_or_default();
|
let old_transform = world.get::<Transform>(primary).copied().unwrap_or_default();
|
||||||
let old_brush = world.get::<BrushDesc>(primary).cloned();
|
let old_brush = world.get::<BrushDesc>(primary).cloned();
|
||||||
@ -769,7 +808,7 @@ pub fn apply_brush_csg_with_history(
|
|||||||
&& old_brush.as_ref() == Some(&new_brush)
|
&& old_brush.as_ref() == Some(&new_brush)
|
||||||
&& deleted.is_empty()
|
&& deleted.is_empty()
|
||||||
{
|
{
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
if let Ok(mut entity_mut) = world.get_entity_mut(primary) {
|
if let Ok(mut entity_mut) = world.get_entity_mut(primary) {
|
||||||
entity_mut.insert(new_transform);
|
entity_mut.insert(new_transform);
|
||||||
@ -790,6 +829,7 @@ pub fn apply_brush_csg_with_history(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
select_one(world, primary);
|
select_one(world, primary);
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_static_mesh_renderer_with_history(
|
pub fn set_static_mesh_renderer_with_history(
|
||||||
@ -943,14 +983,16 @@ pub fn group_selection_with_history(world: &mut World, entities: &[Entity]) {
|
|||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
};
|
};
|
||||||
let group = spawn_snapshot(world, &snapshot);
|
let group = spawn_snapshot(world, &snapshot);
|
||||||
|
let changes = reorder_entities_under_parent(world, &entities, Some(group), i32::MAX);
|
||||||
push_history(
|
push_history(
|
||||||
world,
|
world,
|
||||||
EditorCommand::Spawn {
|
EditorCommand::GroupSelection {
|
||||||
snapshot,
|
snapshot,
|
||||||
entity: Some(group),
|
group: Some(group),
|
||||||
|
changes,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
reorder_siblings_with_history(world, &entities, Some(group), i32::MAX);
|
select_one(world, group);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_empty_child_with_history(world: &mut World, parent: Entity) {
|
pub fn create_empty_child_with_history(world: &mut World, parent: Entity) {
|
||||||
@ -1637,6 +1679,13 @@ fn prepare_prefab_source_history(
|
|||||||
|
|
||||||
fn undo_command(world: &mut World, command: &mut EditorCommand) {
|
fn undo_command(world: &mut World, command: &mut EditorCommand) {
|
||||||
match command {
|
match command {
|
||||||
|
EditorCommand::GroupSelection { group, changes, .. } => {
|
||||||
|
for change in changes.iter().rev() {
|
||||||
|
apply_sibling_change(world, change);
|
||||||
|
}
|
||||||
|
despawn_entity(world, *group);
|
||||||
|
clear_selection(world);
|
||||||
|
}
|
||||||
EditorCommand::Spawn { entity, .. } => {
|
EditorCommand::Spawn { entity, .. } => {
|
||||||
despawn_entity(world, entity.take());
|
despawn_entity(world, entity.take());
|
||||||
clear_selection(world);
|
clear_selection(world);
|
||||||
@ -1682,6 +1731,11 @@ fn undo_command(world: &mut World, command: &mut EditorCommand) {
|
|||||||
EditorCommand::SetLight { entity, old, .. } => {
|
EditorCommand::SetLight { entity, old, .. } => {
|
||||||
apply_light(world, *entity, old);
|
apply_light(world, *entity, old);
|
||||||
}
|
}
|
||||||
|
EditorCommand::SetLightGroup { entities, olds, .. } => {
|
||||||
|
for (entity, old) in entities.iter().zip(olds.iter()) {
|
||||||
|
apply_light(world, *entity, old);
|
||||||
|
}
|
||||||
|
}
|
||||||
EditorCommand::SetAnimationController { entity, old, .. } => {
|
EditorCommand::SetAnimationController { entity, old, .. } => {
|
||||||
apply_animation_controller(world, *entity, old);
|
apply_animation_controller(world, *entity, old);
|
||||||
}
|
}
|
||||||
@ -1818,6 +1872,25 @@ fn undo_command(world: &mut World, command: &mut EditorCommand) {
|
|||||||
|
|
||||||
fn redo_command(world: &mut World, command: &mut EditorCommand) {
|
fn redo_command(world: &mut World, command: &mut EditorCommand) {
|
||||||
match command {
|
match command {
|
||||||
|
EditorCommand::GroupSelection {
|
||||||
|
snapshot,
|
||||||
|
group,
|
||||||
|
changes,
|
||||||
|
} => {
|
||||||
|
let previous_group = *group;
|
||||||
|
let spawned = spawn_snapshot(world, snapshot);
|
||||||
|
*group = Some(spawned);
|
||||||
|
for change in changes.iter_mut() {
|
||||||
|
if Some(change.entity) == previous_group {
|
||||||
|
change.entity = spawned;
|
||||||
|
}
|
||||||
|
if change.new_parent == previous_group {
|
||||||
|
change.new_parent = Some(spawned);
|
||||||
|
}
|
||||||
|
apply_sibling_change_new(world, change);
|
||||||
|
}
|
||||||
|
select_one(world, spawned);
|
||||||
|
}
|
||||||
EditorCommand::Spawn { snapshot, entity } => {
|
EditorCommand::Spawn { snapshot, entity } => {
|
||||||
let spawned = spawn_snapshot(world, snapshot);
|
let spawned = spawn_snapshot(world, snapshot);
|
||||||
*entity = Some(spawned);
|
*entity = Some(spawned);
|
||||||
@ -1881,6 +1954,11 @@ fn redo_command(world: &mut World, command: &mut EditorCommand) {
|
|||||||
entity_mut.insert(new.clone());
|
entity_mut.insert(new.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
EditorCommand::SetLightGroup { entities, news, .. } => {
|
||||||
|
for (entity, new) in entities.iter().zip(news.iter()) {
|
||||||
|
apply_light(world, *entity, new);
|
||||||
|
}
|
||||||
|
}
|
||||||
EditorCommand::SetAnimationController { entity, new, .. } => {
|
EditorCommand::SetAnimationController { entity, new, .. } => {
|
||||||
if let Ok(mut entity_mut) = world.get_entity_mut(*entity) {
|
if let Ok(mut entity_mut) = world.get_entity_mut(*entity) {
|
||||||
entity_mut.insert(new.clone());
|
entity_mut.insert(new.clone());
|
||||||
@ -2614,25 +2692,41 @@ fn capture_gizmo_transform_edits(world: &mut World) {
|
|||||||
let start_group =
|
let start_group =
|
||||||
active.map(|(entity, transform)| selected_transforms(world, &selection, entity, transform));
|
active.map(|(entity, transform)| selected_transforms(world, &selection, entity, transform));
|
||||||
|
|
||||||
|
let mut started_edit = false;
|
||||||
let (finished_edit, live_edit) = {
|
let (finished_edit, live_edit) = {
|
||||||
let mut tracker = world.resource_mut::<TransformEditTracker>();
|
let mut tracker = world.resource_mut::<TransformEditTracker>();
|
||||||
if tracker.active.is_none() {
|
if tracker.active.is_none() {
|
||||||
if let (Some((entity, transform)), Some(group)) = (active, start_group) {
|
if let (Some((entity, transform)), Some(group)) = (active, start_group) {
|
||||||
tracker.active = Some((entity, transform, group));
|
tracker.active = Some((entity, transform, group));
|
||||||
return;
|
started_edit = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
match tracker.active.take() {
|
if started_edit {
|
||||||
Some((primary, old, group)) if active.is_none() => (Some((primary, old, group)), None),
|
(None, None)
|
||||||
Some(state) if active.is_some() => {
|
} else {
|
||||||
let live = Some(state.clone());
|
match tracker.active.take() {
|
||||||
tracker.active = Some(state);
|
Some((primary, old, group)) if active.is_none() => {
|
||||||
(None, live)
|
(Some((primary, old, group)), None)
|
||||||
|
}
|
||||||
|
Some(state) if active.is_some() => {
|
||||||
|
let live = Some(state.clone());
|
||||||
|
tracker.active = Some(state);
|
||||||
|
(None, live)
|
||||||
|
}
|
||||||
|
_ => (None, None),
|
||||||
}
|
}
|
||||||
_ => (None, None),
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if started_edit {
|
||||||
|
set_transform_operator_status(
|
||||||
|
world,
|
||||||
|
OperatorPhase::Preview,
|
||||||
|
"Transform gizmo drag in progress",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some((primary, old_primary, group)) = live_edit {
|
if let Some((primary, old_primary, group)) = live_edit {
|
||||||
let Some(new_primary) = world.get::<Transform>(primary).copied() else {
|
let Some(new_primary) = world.get::<Transform>(primary).copied() else {
|
||||||
return;
|
return;
|
||||||
@ -2645,14 +2739,50 @@ fn capture_gizmo_transform_edits(world: &mut World) {
|
|||||||
|
|
||||||
if let Some((primary, old_primary, group)) = finished_edit {
|
if let Some((primary, old_primary, group)) = finished_edit {
|
||||||
let Some(new_primary) = world.get::<Transform>(primary).copied() else {
|
let Some(new_primary) = world.get::<Transform>(primary).copied() else {
|
||||||
|
for (entity, old) in group {
|
||||||
|
if let Some(mut transform) = world.get_mut::<Transform>(entity) {
|
||||||
|
*transform = old;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set_transform_operator_status(
|
||||||
|
world,
|
||||||
|
OperatorPhase::Canceled,
|
||||||
|
"Transform target disappeared; surviving previews restored",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let undo_depth = world.resource::<EditorHistory>().undo_depth();
|
||||||
if group.len() <= 1 {
|
if group.len() <= 1 {
|
||||||
set_transform_with_history(world, primary, old_primary, new_primary);
|
set_transform_with_history(world, primary, old_primary, new_primary);
|
||||||
return;
|
} else {
|
||||||
|
let changes = transform_group_changes(old_primary, new_primary, group);
|
||||||
|
set_transform_group_with_history(world, changes);
|
||||||
}
|
}
|
||||||
let changes = transform_group_changes(old_primary, new_primary, group);
|
if world.resource::<EditorHistory>().undo_depth() > undo_depth {
|
||||||
set_transform_group_with_history(world, changes);
|
set_transform_operator_status(
|
||||||
|
world,
|
||||||
|
OperatorPhase::Committed,
|
||||||
|
"Transform gizmo edit committed",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
set_transform_operator_status(
|
||||||
|
world,
|
||||||
|
OperatorPhase::Canceled,
|
||||||
|
"Transform gizmo edit made no changes",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_transform_operator_status(world: &mut World, phase: OperatorPhase, hint: impl Into<String>) {
|
||||||
|
if let Some(mut active) = world.get_resource_mut::<ActiveOperator>() {
|
||||||
|
active.status = Some(OperatorStatus {
|
||||||
|
id: "transform.gizmo".to_string(),
|
||||||
|
label: "Transform Gizmo".to_string(),
|
||||||
|
phase,
|
||||||
|
hint: hint.into(),
|
||||||
|
warnings: Vec::new(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2796,7 +2926,7 @@ pub(crate) fn material_eq(a: &MaterialDesc, b: &MaterialDesc) -> bool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::operators::test_harness::{assert_undo_redo_round_trip, OperatorInvariantHarness};
|
use crate::operators::test_harness::{assert_undo_redo_round_trip, OperatorInvariantHarness};
|
||||||
use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus};
|
use crate::operators::{ActiveOperator, OperatorPhase};
|
||||||
|
|
||||||
#[derive(Component, Reflect, Default, Debug, Clone, PartialEq)]
|
#[derive(Component, Reflect, Default, Debug, Clone, PartialEq)]
|
||||||
#[reflect(Component, Default)]
|
#[reflect(Component, Default)]
|
||||||
@ -3074,6 +3204,7 @@ mod tests {
|
|||||||
world.init_resource::<ActiveOperator>();
|
world.init_resource::<ActiveOperator>();
|
||||||
world.init_resource::<EditorHistory>();
|
world.init_resource::<EditorHistory>();
|
||||||
world.init_resource::<SceneIo>();
|
world.init_resource::<SceneIo>();
|
||||||
|
world.init_resource::<TransformEditTracker>();
|
||||||
let first = world.spawn((LevelObject, Transform::default())).id();
|
let first = world.spawn((LevelObject, Transform::default())).id();
|
||||||
let second = world
|
let second = world
|
||||||
.spawn((
|
.spawn((
|
||||||
@ -3085,30 +3216,19 @@ mod tests {
|
|||||||
let committed = vec![Vec3::Z, Vec3::new(2.0, 0.0, 1.0)];
|
let committed = vec![Vec3::Z, Vec3::new(2.0, 0.0, 1.0)];
|
||||||
let harness = OperatorInvariantHarness::capture(&mut world);
|
let harness = OperatorInvariantHarness::capture(&mut world);
|
||||||
|
|
||||||
set_transform_group_with_history(
|
world.resource_mut::<TransformEditTracker>().active = Some((
|
||||||
&mut world,
|
first,
|
||||||
[
|
Transform::default(),
|
||||||
(
|
vec![
|
||||||
first,
|
(first, Transform::default()),
|
||||||
Transform::default(),
|
(second, Transform::from_translation(initial[1])),
|
||||||
Transform::from_translation(committed[0]),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
second,
|
|
||||||
Transform::from_translation(initial[1]),
|
|
||||||
Transform::from_translation(committed[1]),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
));
|
||||||
world.resource_mut::<ActiveOperator>().status = Some(OperatorStatus {
|
*world.get_mut::<Transform>(first).unwrap() = Transform::from_translation(committed[0]);
|
||||||
id: "transform.gizmo".to_string(),
|
capture_gizmo_transform_edits(&mut world);
|
||||||
label: "Transform Gizmo".to_string(),
|
|
||||||
phase: OperatorPhase::Committed,
|
|
||||||
hint: "Committed multi-selection drag".to_string(),
|
|
||||||
warnings: Vec::new(),
|
|
||||||
});
|
|
||||||
|
|
||||||
harness.assert_committed(&mut world, 1, 0);
|
harness.assert_committed(&mut world, 1, 0);
|
||||||
|
harness.assert_status(&world, "transform.gizmo", OperatorPhase::Committed);
|
||||||
assert_undo_redo_round_trip(&mut world, initial, committed, |world| {
|
assert_undo_redo_round_trip(&mut world, initial, committed, |world| {
|
||||||
[first, second]
|
[first, second]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@ -3117,6 +3237,63 @@ mod tests {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_op_transform_release_terminates_preview_without_history() {
|
||||||
|
let mut world = World::new();
|
||||||
|
world.init_resource::<ActiveOperator>();
|
||||||
|
world.init_resource::<EditorHistory>();
|
||||||
|
world.init_resource::<SceneIo>();
|
||||||
|
world.init_resource::<TransformEditTracker>();
|
||||||
|
let transform = Transform::from_xyz(1.0, 2.0, 3.0);
|
||||||
|
let entity = world.spawn((LevelObject, transform)).id();
|
||||||
|
world.resource_mut::<TransformEditTracker>().active =
|
||||||
|
Some((entity, transform, vec![(entity, transform)]));
|
||||||
|
set_transform_operator_status(
|
||||||
|
&mut world,
|
||||||
|
OperatorPhase::Preview,
|
||||||
|
"Transform gizmo drag in progress",
|
||||||
|
);
|
||||||
|
let harness = OperatorInvariantHarness::capture(&mut world);
|
||||||
|
|
||||||
|
capture_gizmo_transform_edits(&mut world);
|
||||||
|
|
||||||
|
harness.assert_canceled(&mut world);
|
||||||
|
harness.assert_status(&world, "transform.gizmo", OperatorPhase::Canceled);
|
||||||
|
assert_eq!(world.get::<Transform>(entity), Some(&transform));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_primary_transform_rolls_back_surviving_preview_targets() {
|
||||||
|
let mut world = World::new();
|
||||||
|
world.init_resource::<ActiveOperator>();
|
||||||
|
world.init_resource::<EditorHistory>();
|
||||||
|
world.init_resource::<SceneIo>();
|
||||||
|
world.init_resource::<TransformEditTracker>();
|
||||||
|
let primary_old = Transform::from_xyz(1.0, 0.0, 0.0);
|
||||||
|
let survivor_old = Transform::from_xyz(2.0, 0.0, 0.0);
|
||||||
|
let primary = world.spawn((LevelObject, primary_old)).id();
|
||||||
|
let survivor = world.spawn((LevelObject, survivor_old)).id();
|
||||||
|
world.resource_mut::<TransformEditTracker>().active = Some((
|
||||||
|
primary,
|
||||||
|
primary_old,
|
||||||
|
vec![(primary, primary_old), (survivor, survivor_old)],
|
||||||
|
));
|
||||||
|
*world.get_mut::<Transform>(survivor).unwrap() = Transform::from_xyz(8.0, 0.0, 0.0);
|
||||||
|
world.despawn(primary);
|
||||||
|
set_transform_operator_status(
|
||||||
|
&mut world,
|
||||||
|
OperatorPhase::Preview,
|
||||||
|
"Transform gizmo drag in progress",
|
||||||
|
);
|
||||||
|
let harness = OperatorInvariantHarness::capture(&mut world);
|
||||||
|
|
||||||
|
capture_gizmo_transform_edits(&mut world);
|
||||||
|
|
||||||
|
harness.assert_canceled(&mut world);
|
||||||
|
harness.assert_status(&world, "transform.gizmo", OperatorPhase::Canceled);
|
||||||
|
assert_eq!(world.get::<Transform>(survivor), Some(&survivor_old));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unpack_prefab_is_one_undoable_history_command() {
|
fn unpack_prefab_is_one_undoable_history_command() {
|
||||||
let mut world = World::new();
|
let mut world = World::new();
|
||||||
|
|||||||
@ -59,6 +59,12 @@ pub enum OperatorAvailability {
|
|||||||
Disabled(String),
|
Disabled(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum OperatorAction {
|
||||||
|
Commit,
|
||||||
|
ContinuePreview,
|
||||||
|
}
|
||||||
|
|
||||||
pub trait EditorOperator {
|
pub trait EditorOperator {
|
||||||
fn id(&self) -> &str;
|
fn id(&self) -> &str;
|
||||||
fn label(&self) -> &str;
|
fn label(&self) -> &str;
|
||||||
@ -221,6 +227,51 @@ pub fn run_immediate_operator(
|
|||||||
run_operator(world, &mut operator)
|
run_operator(world, &mut operator)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs an editor command that may either complete immediately or hand ownership to a modal tool.
|
||||||
|
/// Modal starters retain the preview status installed by their production entry point.
|
||||||
|
pub fn run_operator_action(
|
||||||
|
world: &mut World,
|
||||||
|
id: &str,
|
||||||
|
label: &str,
|
||||||
|
availability: OperatorAvailability,
|
||||||
|
action: impl FnOnce(&mut World) -> Result<OperatorAction, String>,
|
||||||
|
) -> bool {
|
||||||
|
if let OperatorAvailability::Disabled(reason) = availability {
|
||||||
|
set_operator_status(
|
||||||
|
world,
|
||||||
|
OperatorStatus::new(id, label, OperatorPhase::Blocked, reason),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
set_operator_status(
|
||||||
|
world,
|
||||||
|
OperatorStatus::new(id, label, OperatorPhase::Preview, "Starting"),
|
||||||
|
);
|
||||||
|
match action(world) {
|
||||||
|
Ok(OperatorAction::Commit) => {
|
||||||
|
set_operator_status(
|
||||||
|
world,
|
||||||
|
OperatorStatus::new(
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
OperatorPhase::Committed,
|
||||||
|
format!("Completed {label}"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Ok(OperatorAction::ContinuePreview) => true,
|
||||||
|
Err(error) => {
|
||||||
|
set_operator_status(
|
||||||
|
world,
|
||||||
|
OperatorStatus::new(id, label, OperatorPhase::Canceled, error),
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn set_operator_status(world: &mut World, status: OperatorStatus) {
|
fn set_operator_status(world: &mut World, status: OperatorStatus) {
|
||||||
if let Some(mut active) = world.get_resource_mut::<ActiveOperator>() {
|
if let Some(mut active) = world.get_resource_mut::<ActiveOperator>() {
|
||||||
active.status = Some(status);
|
active.status = Some(status);
|
||||||
@ -355,6 +406,52 @@ mod tests {
|
|||||||
harness.assert_no_helpers::<PreviewHelper>(&mut world);
|
harness.assert_no_helpers::<PreviewHelper>(&mut world);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preview_failure_runs_cancel_and_cleans_helpers() {
|
||||||
|
struct PreviewFailure(CounterOperator);
|
||||||
|
|
||||||
|
impl EditorOperator for PreviewFailure {
|
||||||
|
fn id(&self) -> &str {
|
||||||
|
self.0.id()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(&self) -> &str {
|
||||||
|
self.0.label()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn begin(&mut self, world: &mut World) -> Result<(), String> {
|
||||||
|
self.0.begin(world)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preview(&mut self, world: &mut World) -> Result<(), String> {
|
||||||
|
self.0.preview(world)?;
|
||||||
|
Err("preview failed".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit(&mut self, _world: &mut World) -> Result<(), String> {
|
||||||
|
panic!("commit must not run after preview failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel(&mut self, world: &mut World) {
|
||||||
|
self.0.cancel(world);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut world = test_world();
|
||||||
|
let harness = OperatorInvariantHarness::capture(&mut world);
|
||||||
|
let mut operator = PreviewFailure(CounterOperator {
|
||||||
|
before: 0,
|
||||||
|
helper: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(!run_operator(&mut world, &mut operator));
|
||||||
|
|
||||||
|
harness.assert_canceled(&mut world);
|
||||||
|
harness.assert_status(&world, "test.counter", OperatorPhase::Canceled);
|
||||||
|
harness.assert_no_helpers::<PreviewHelper>(&mut world);
|
||||||
|
assert_eq!(world.resource::<Counter>().0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blocked_operator_never_begins_or_mutates_world() {
|
fn blocked_operator_never_begins_or_mutates_world() {
|
||||||
let mut world = test_world();
|
let mut world = test_world();
|
||||||
|
|||||||
@ -47,6 +47,12 @@ impl OperatorInvariantHarness {
|
|||||||
world.resource::<SceneIo>().dirty,
|
world.resource::<SceneIo>().dirty,
|
||||||
"a committed authored mutation must mark the scene dirty"
|
"a committed authored mutation must mark the scene dirty"
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
assert_eq!(
|
||||||
|
world.resource::<SceneIo>().dirty,
|
||||||
|
self.dirty,
|
||||||
|
"a committed non-authoring operator must preserve the prior dirty state"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
authored_count(world) as isize,
|
authored_count(world) as isize,
|
||||||
@ -93,6 +99,39 @@ impl OperatorInvariantHarness {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn assert_status(
|
||||||
|
&self,
|
||||||
|
world: &World,
|
||||||
|
expected_id: &str,
|
||||||
|
expected_phase: OperatorPhase,
|
||||||
|
) {
|
||||||
|
let status = world
|
||||||
|
.resource::<ActiveOperator>()
|
||||||
|
.status
|
||||||
|
.as_ref()
|
||||||
|
.expect("operator must retain an inspectable lifecycle status");
|
||||||
|
assert_eq!(status.id, expected_id, "operator reported the wrong id");
|
||||||
|
assert_eq!(
|
||||||
|
status.phase, expected_phase,
|
||||||
|
"operator reported the wrong phase"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn assert_projection_unchanged<T>(
|
||||||
|
&self,
|
||||||
|
world: &mut World,
|
||||||
|
expected: T,
|
||||||
|
mut read: impl FnMut(&mut World) -> T,
|
||||||
|
) where
|
||||||
|
T: Debug + PartialEq,
|
||||||
|
{
|
||||||
|
assert_eq!(
|
||||||
|
read(world),
|
||||||
|
expected,
|
||||||
|
"operator changed authored state without a committed history transaction"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn assert_phase(&self, world: &World, expected: OperatorPhase) {
|
fn assert_phase(&self, world: &World, expected: OperatorPhase) {
|
||||||
let phase = world
|
let phase = world
|
||||||
.resource::<ActiveOperator>()
|
.resource::<ActiveOperator>()
|
||||||
|
|||||||
@ -11,7 +11,10 @@ use sim::Player;
|
|||||||
use crate::assets::{snapshot_for_asset, EditorAsset, EditorAssetKind};
|
use crate::assets::{snapshot_for_asset, EditorAsset, EditorAssetKind};
|
||||||
use crate::camera::EditorCamera;
|
use crate::camera::EditorCamera;
|
||||||
use crate::gizmos::{EditorGizmoMode, EditorGizmoSpace};
|
use crate::gizmos::{EditorGizmoMode, EditorGizmoSpace};
|
||||||
use crate::history::{set_material_group_with_history, spawn_with_history, EditorEntitySnapshot};
|
use crate::history::{
|
||||||
|
set_light_group_with_history, set_material_group_with_history, spawn_with_history,
|
||||||
|
EditorEntitySnapshot,
|
||||||
|
};
|
||||||
use crate::play::default_player_spawn;
|
use crate::play::default_player_spawn;
|
||||||
use crate::scene_io::SceneIo;
|
use crate::scene_io::SceneIo;
|
||||||
use crate::viewport::{recall_camera_bookmark, save_camera_bookmark, CameraBookmarks};
|
use crate::viewport::{recall_camera_bookmark, save_camera_bookmark, CameraBookmarks};
|
||||||
@ -191,37 +194,23 @@ pub fn ensure_player_spawn_for_edit(world: &mut World) -> Entity {
|
|||||||
|
|
||||||
/// Removes all authored [`LightDesc`] so project sun/ambient drive outdoor lighting.
|
/// Removes all authored [`LightDesc`] so project sun/ambient drive outdoor lighting.
|
||||||
pub fn reset_scene_lighting_to_project_defaults(world: &mut World) {
|
pub fn reset_scene_lighting_to_project_defaults(world: &mut World) {
|
||||||
let mut query = world.query_filtered::<Entity, With<LevelObject>>();
|
let mut query = world.query_filtered::<(Entity, &LightDesc), With<LevelObject>>();
|
||||||
let with_lights: Vec<Entity> = query
|
let changes = query
|
||||||
.iter(world)
|
.iter(world)
|
||||||
.filter(|entity| world.get::<LightDesc>(*entity).is_some())
|
.map(|(entity, _)| (entity, None))
|
||||||
.collect();
|
.collect::<Vec<_>>();
|
||||||
for entity in with_lights {
|
set_light_group_with_history(world, changes, "Reset Scene Lighting");
|
||||||
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
|
|
||||||
entity_mut.remove::<LightDesc>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
world
|
|
||||||
.resource_mut::<crate::scene_io::SceneIo>()
|
|
||||||
.mark_dirty();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes authored directional lights so [`ProjectSun`] drives outdoor lighting.
|
/// Removes authored directional lights so [`ProjectSun`] drives outdoor lighting.
|
||||||
pub fn use_project_sun(world: &mut World) {
|
pub fn use_project_sun(world: &mut World) {
|
||||||
let mut query = world.query_filtered::<(Entity, &LightDesc), With<LevelObject>>();
|
let mut query = world.query_filtered::<(Entity, &LightDesc), With<LevelObject>>();
|
||||||
let directionals: Vec<Entity> = query
|
let changes = query
|
||||||
.iter(world)
|
.iter(world)
|
||||||
.filter(|(_, light)| matches!(light.kind, AuthoringLightKind::Directional))
|
.filter(|(_, light)| matches!(light.kind, AuthoringLightKind::Directional))
|
||||||
.map(|(e, _)| e)
|
.map(|(entity, _)| (entity, None))
|
||||||
.collect();
|
.collect::<Vec<_>>();
|
||||||
for entity in directionals {
|
set_light_group_with_history(world, changes, "Use Project Sun");
|
||||||
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
|
|
||||||
entity_mut.remove::<LightDesc>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
world
|
|
||||||
.resource_mut::<crate::scene_io::SceneIo>()
|
|
||||||
.mark_dirty();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_scene_sun_override_from_project_settings(world: &mut World) -> Entity {
|
pub fn create_scene_sun_override_from_project_settings(world: &mut World) -> Entity {
|
||||||
|
|||||||
@ -4,7 +4,7 @@ use bevy::math::Affine3A;
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use shared::{
|
use shared::{
|
||||||
brush_math::{validate_brush, BrushDiagnosticSeverity},
|
brush_math::{validate_brush, BrushDiagnosticSeverity},
|
||||||
BrushDesc, LevelObject,
|
BrushDesc,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::history::apply_brush_csg_with_history;
|
use crate::history::apply_brush_csg_with_history;
|
||||||
@ -64,24 +64,27 @@ pub fn selected_brush_count(world: &World) -> usize {
|
|||||||
.resource::<UiState>()
|
.resource::<UiState>()
|
||||||
.selected_entities
|
.selected_entities
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|entity| world.get::<BrushDesc>(*entity).is_some())
|
.filter(|entity| {
|
||||||
|
world.get::<BrushDesc>(*entity).is_some()
|
||||||
|
&& crate::ui::selection_ops::is_mutable_level_object(world, *entity)
|
||||||
|
})
|
||||||
.count()
|
.count()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn intersect_selected_brushes(world: &mut World) {
|
pub fn intersect_selected_brushes(world: &mut World) -> Result<(), String> {
|
||||||
run_bounds_csg(world, BrushCsgOp::Intersect);
|
run_bounds_csg(world, BrushCsgOp::Intersect)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn merge_selected_brushes(world: &mut World) {
|
pub fn merge_selected_brushes(world: &mut World) -> Result<(), String> {
|
||||||
run_bounds_csg(world, BrushCsgOp::Merge);
|
run_bounds_csg(world, BrushCsgOp::Merge)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn subtract_selected_brushes(world: &mut World) {
|
pub fn subtract_selected_brushes(world: &mut World) -> Result<(), String> {
|
||||||
run_bounds_csg(world, BrushCsgOp::Subtract);
|
run_bounds_csg(world, BrushCsgOp::Subtract)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Resource, Default, Debug, Clone)]
|
#[derive(Resource, Default, Debug, Clone)]
|
||||||
struct BrushCsgPreview {
|
pub(crate) struct BrushCsgPreview {
|
||||||
pending: Option<PendingBrushCsg>,
|
pending: Option<PendingBrushCsg>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,18 +102,18 @@ enum BrushCsgOp {
|
|||||||
Subtract,
|
Subtract,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_bounds_csg(world: &mut World, op: BrushCsgOp) {
|
fn run_bounds_csg(world: &mut World, op: BrushCsgOp) -> Result<(), String> {
|
||||||
let selected: Vec<Entity> = world
|
let selected: Vec<Entity> = world
|
||||||
.resource::<UiState>()
|
.resource::<UiState>()
|
||||||
.selected_entities
|
.selected_entities
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|entity| {
|
.filter(|entity| {
|
||||||
world.get::<LevelObject>(*entity).is_some() && world.get::<BrushDesc>(*entity).is_some()
|
world.get::<BrushDesc>(*entity).is_some()
|
||||||
|
&& crate::ui::selection_ops::is_mutable_level_object(world, *entity)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
if selected.len() < 2 {
|
if selected.len() < 2 {
|
||||||
set_status(world, "Select at least two brushes for CSG");
|
return csg_failure(world, "Select at least two editable brushes for CSG");
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
for entity in &selected {
|
for entity in &selected {
|
||||||
let Some(brush) = world.get::<BrushDesc>(*entity) else {
|
let Some(brush) = world.get::<BrushDesc>(*entity) else {
|
||||||
@ -118,20 +121,18 @@ fn run_bounds_csg(world: &mut World, op: BrushCsgOp) {
|
|||||||
};
|
};
|
||||||
let validation = validate_brush(brush);
|
let validation = validate_brush(brush);
|
||||||
if !validation.is_valid() {
|
if !validation.is_valid() {
|
||||||
set_status(
|
return csg_failure(
|
||||||
world,
|
world,
|
||||||
format!(
|
format!(
|
||||||
"CSG failed: selected brush is invalid: {}",
|
"CSG failed: selected brush is invalid: {}",
|
||||||
first_brush_error(&validation)
|
first_brush_error(&validation)
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(primary_bounds) = brush_world_bounds(world, selected[0]) else {
|
let Some(primary_bounds) = brush_world_bounds(world, selected[0]) else {
|
||||||
set_status(world, "Primary brush has no valid bounds");
|
return csg_failure(world, "Primary brush has no valid bounds");
|
||||||
return;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = match op {
|
let result = match op {
|
||||||
@ -147,8 +148,7 @@ fn run_bounds_csg(world: &mut World, op: BrushCsgOp) {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(intersection) = result.intersection(bounds) else {
|
let Some(intersection) = result.intersection(bounds) else {
|
||||||
set_status(world, "Brush intersect failed: no overlap");
|
return csg_failure(world, "Brush intersect failed: no overlap");
|
||||||
return;
|
|
||||||
};
|
};
|
||||||
result = intersection;
|
result = intersection;
|
||||||
}
|
}
|
||||||
@ -156,16 +156,13 @@ fn run_bounds_csg(world: &mut World, op: BrushCsgOp) {
|
|||||||
}
|
}
|
||||||
BrushCsgOp::Subtract => {
|
BrushCsgOp::Subtract => {
|
||||||
let Some(cutter_bounds) = brush_world_bounds(world, selected[1]) else {
|
let Some(cutter_bounds) = brush_world_bounds(world, selected[1]) else {
|
||||||
set_status(world, "Subtract failed: cutter brush has no valid bounds");
|
return csg_failure(world, "Subtract failed: cutter brush has no valid bounds");
|
||||||
return;
|
|
||||||
};
|
};
|
||||||
let Some(overlap) = primary_bounds.intersection(cutter_bounds) else {
|
let Some(overlap) = primary_bounds.intersection(cutter_bounds) else {
|
||||||
set_status(world, "Subtract failed: brushes do not overlap");
|
return csg_failure(world, "Subtract failed: brushes do not overlap");
|
||||||
return;
|
|
||||||
};
|
};
|
||||||
let Some(result) = largest_remaining_slab(primary_bounds, overlap) else {
|
let Some(result) = largest_remaining_slab(primary_bounds, overlap) else {
|
||||||
set_status(world, "Subtract failed: result would be empty");
|
return csg_failure(world, "Subtract failed: result would be empty");
|
||||||
return;
|
|
||||||
};
|
};
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
@ -175,14 +172,13 @@ fn run_bounds_csg(world: &mut World, op: BrushCsgOp) {
|
|||||||
let new_brush = BrushDesc::cuboid(size);
|
let new_brush = BrushDesc::cuboid(size);
|
||||||
let validation = validate_brush(&new_brush);
|
let validation = validate_brush(&new_brush);
|
||||||
if !validation.is_valid() {
|
if !validation.is_valid() {
|
||||||
set_status(
|
return csg_failure(
|
||||||
world,
|
world,
|
||||||
format!(
|
format!(
|
||||||
"CSG failed: result is invalid: {}",
|
"CSG failed: result is invalid: {}",
|
||||||
first_brush_error(&validation)
|
first_brush_error(&validation)
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
world.resource_mut::<BrushCsgPreview>().pending = Some(PendingBrushCsg {
|
world.resource_mut::<BrushCsgPreview>().pending = Some(PendingBrushCsg {
|
||||||
@ -197,6 +193,13 @@ fn run_bounds_csg(world: &mut World, op: BrushCsgOp) {
|
|||||||
};
|
};
|
||||||
set_status(world, hint);
|
set_status(world, hint);
|
||||||
set_csg_operator_status(world, OperatorPhase::Preview, hint);
|
set_csg_operator_status(world, OperatorPhase::Preview, hint);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn csg_failure(world: &mut World, message: impl Into<String>) -> Result<(), String> {
|
||||||
|
let message = message.into();
|
||||||
|
set_status(world, message.clone());
|
||||||
|
Err(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn brush_csg_preview_input(world: &mut World) {
|
fn brush_csg_preview_input(world: &mut World) {
|
||||||
@ -253,6 +256,22 @@ fn commit_csg_preview(world: &mut World, pending: PendingBrushCsg) {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if pending
|
||||||
|
.selected
|
||||||
|
.iter()
|
||||||
|
.any(|entity| !crate::ui::selection_ops::is_mutable_level_object(world, *entity))
|
||||||
|
{
|
||||||
|
set_status(
|
||||||
|
world,
|
||||||
|
"Brush CSG commit blocked: selected content is no longer editable",
|
||||||
|
);
|
||||||
|
set_csg_operator_status(
|
||||||
|
world,
|
||||||
|
OperatorPhase::Blocked,
|
||||||
|
"Commit blocked: selected content is no longer editable",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let new_brush = BrushDesc::cuboid(pending.result.size());
|
let new_brush = BrushDesc::cuboid(pending.result.size());
|
||||||
let to_delete: Vec<_> = if matches!(pending.op, BrushCsgOp::Merge | BrushCsgOp::Intersect) {
|
let to_delete: Vec<_> = if matches!(pending.op, BrushCsgOp::Merge | BrushCsgOp::Intersect) {
|
||||||
pending
|
pending
|
||||||
@ -265,7 +284,11 @@ fn commit_csg_preview(world: &mut World, pending: PendingBrushCsg) {
|
|||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
apply_bounds_result(world, primary, pending.result, new_brush, &to_delete);
|
if !apply_bounds_result(world, primary, pending.result, new_brush, &to_delete) {
|
||||||
|
set_status(world, "Brush CSG commit made no changes");
|
||||||
|
set_csg_operator_status(world, OperatorPhase::Canceled, "No changes");
|
||||||
|
return;
|
||||||
|
}
|
||||||
let hint = match pending.op {
|
let hint = match pending.op {
|
||||||
BrushCsgOp::Intersect => "Brush intersect committed",
|
BrushCsgOp::Intersect => "Brush intersect committed",
|
||||||
BrushCsgOp::Merge => "Brush convex merge committed",
|
BrushCsgOp::Merge => "Brush convex merge committed",
|
||||||
@ -332,14 +355,14 @@ fn apply_bounds_result(
|
|||||||
bounds: BrushBounds,
|
bounds: BrushBounds,
|
||||||
new_brush: BrushDesc,
|
new_brush: BrushDesc,
|
||||||
delete_entities: &[Entity],
|
delete_entities: &[Entity],
|
||||||
) {
|
) -> bool {
|
||||||
let center = bounds.center();
|
let center = bounds.center();
|
||||||
let old_transform = world.get::<Transform>(entity).copied().unwrap_or_default();
|
let old_transform = world.get::<Transform>(entity).copied().unwrap_or_default();
|
||||||
let mut new_transform = old_transform;
|
let mut new_transform = old_transform;
|
||||||
new_transform.translation = center;
|
new_transform.translation = center;
|
||||||
new_transform.rotation = Quat::IDENTITY;
|
new_transform.rotation = Quat::IDENTITY;
|
||||||
new_transform.scale = Vec3::ONE;
|
new_transform.scale = Vec3::ONE;
|
||||||
apply_brush_csg_with_history(world, entity, new_transform, new_brush, delete_entities);
|
apply_brush_csg_with_history(world, entity, new_transform, new_brush, delete_entities)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn brush_world_bounds(world: &World, entity: Entity) -> Option<BrushBounds> {
|
fn brush_world_bounds(world: &World, entity: Entity) -> Option<BrushBounds> {
|
||||||
@ -426,6 +449,7 @@ mod tests {
|
|||||||
use crate::history::{EditorCommand, EditorHistory};
|
use crate::history::{EditorCommand, EditorHistory};
|
||||||
use crate::operators::test_harness::{assert_undo_redo_round_trip, OperatorInvariantHarness};
|
use crate::operators::test_harness::{assert_undo_redo_round_trip, OperatorInvariantHarness};
|
||||||
use crate::selection::SelectedEntity;
|
use crate::selection::SelectedEntity;
|
||||||
|
use shared::LevelObject;
|
||||||
|
|
||||||
fn bounds(min: Vec3, max: Vec3) -> BrushBounds {
|
fn bounds(min: Vec3, max: Vec3) -> BrushBounds {
|
||||||
BrushBounds { min, max }
|
BrushBounds { min, max }
|
||||||
|
|||||||
@ -1308,4 +1308,55 @@ mod tests {
|
|||||||
assert!(state.old_brush.is_none());
|
assert!(state.old_brush.is_none());
|
||||||
assert!(!state.changed);
|
assert!(!state.changed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn brush_element_release_uses_production_finalizer_and_round_trips() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.init_resource::<ActiveOperator>()
|
||||||
|
.init_resource::<EditorHistory>()
|
||||||
|
.init_resource::<SceneIo>()
|
||||||
|
.init_resource::<BrushEditMode>()
|
||||||
|
.init_resource::<BrushElementSelection>()
|
||||||
|
.init_resource::<BrushElementGizmoState>()
|
||||||
|
.add_systems(Update, apply_brush_element_gizmo);
|
||||||
|
let original = BrushDesc::cuboid(Vec3::ONE);
|
||||||
|
let preview = BrushDesc::cuboid(Vec3::new(1.5, 1.0, 1.0));
|
||||||
|
let brush_entity = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((LevelObject, preview.clone(), GlobalTransform::default()))
|
||||||
|
.id();
|
||||||
|
let helper = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((
|
||||||
|
BrushElementGizmo,
|
||||||
|
Transform::default(),
|
||||||
|
GizmoTarget::default(),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
*app.world_mut().resource_mut::<BrushEditMode>() = BrushEditMode::Vertex;
|
||||||
|
*app.world_mut().resource_mut::<BrushElementSelection>() = BrushElementSelection {
|
||||||
|
brush: Some(brush_entity),
|
||||||
|
elements: vec![BrushElementKey::Vertex {
|
||||||
|
face: original.faces[0].id.clone(),
|
||||||
|
index: 0,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
*app.world_mut().resource_mut::<BrushElementGizmoState>() = BrushElementGizmoState {
|
||||||
|
entity: Some(helper),
|
||||||
|
brush: Some(brush_entity),
|
||||||
|
old_brush: Some(original.clone()),
|
||||||
|
old_gizmo: Some(Transform::default()),
|
||||||
|
last_gizmo: Some(Transform::from_translation(Vec3::X * 0.25)),
|
||||||
|
changed: true,
|
||||||
|
};
|
||||||
|
let harness = OperatorInvariantHarness::capture(app.world_mut());
|
||||||
|
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
harness.assert_committed(app.world_mut(), 1, 0);
|
||||||
|
harness.assert_status(app.world(), "brush.edit_mode", OperatorPhase::Committed);
|
||||||
|
assert_undo_redo_round_trip(app.world_mut(), original, preview, |world| {
|
||||||
|
world.get::<BrushDesc>(brush_entity).unwrap().clone()
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -779,31 +779,17 @@ fn update_material_drop_session(world: &mut World) {
|
|||||||
.is_some_and(|buttons| buttons.just_pressed(MouseButton::Right));
|
.is_some_and(|buttons| buttons.just_pressed(MouseButton::Right));
|
||||||
|
|
||||||
if cancel_requested && (selection.is_some() || state.preview.is_some()) {
|
if cancel_requested && (selection.is_some() || state.preview.is_some()) {
|
||||||
restore_material_drop_preview(world, state.preview.take());
|
cancel_material_drop_session(world, &mut state, true);
|
||||||
clear_drag_and_viewport_click(world);
|
clear_drag_and_viewport_click(world);
|
||||||
state.feedback = None;
|
|
||||||
state.captures_viewport_input = false;
|
|
||||||
state.cancel_consumed = true;
|
|
||||||
set_drop_operator_status(
|
|
||||||
world,
|
|
||||||
OperatorPhase::Canceled,
|
|
||||||
"Surface assignment canceled",
|
|
||||||
);
|
|
||||||
world.insert_resource(state);
|
world.insert_resource(state);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(selection) = selection else {
|
let Some(selection) = selection else {
|
||||||
let had_preview = state.preview.is_some();
|
if state.preview.is_some() {
|
||||||
restore_material_drop_preview(world, state.preview.take());
|
cancel_material_drop_session(world, &mut state, false);
|
||||||
state.feedback = None;
|
} else {
|
||||||
state.captures_viewport_input = false;
|
reset_material_drop_session(&mut state, false);
|
||||||
if had_preview {
|
|
||||||
set_drop_operator_status(
|
|
||||||
world,
|
|
||||||
OperatorPhase::Canceled,
|
|
||||||
"Surface assignment canceled",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
world.insert_resource(state);
|
world.insert_resource(state);
|
||||||
return;
|
return;
|
||||||
@ -877,19 +863,44 @@ fn update_material_drop_session(world: &mut World) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if primary_released {
|
if primary_released {
|
||||||
let preview = state.preview.take();
|
commit_material_drop_session(world, &mut state);
|
||||||
if let Some(preview) = preview.as_ref() {
|
|
||||||
preview.snapshot.restore(world);
|
|
||||||
}
|
|
||||||
if let Some(preview) = preview {
|
|
||||||
commit_material_drop_preview(world, preview);
|
|
||||||
}
|
|
||||||
clear_drag_and_viewport_click(world);
|
clear_drag_and_viewport_click(world);
|
||||||
state.captures_viewport_input = false;
|
|
||||||
}
|
}
|
||||||
world.insert_resource(state);
|
world.insert_resource(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cancel_material_drop_session(
|
||||||
|
world: &mut World,
|
||||||
|
state: &mut MaterialDropState,
|
||||||
|
cancel_consumed: bool,
|
||||||
|
) {
|
||||||
|
restore_material_drop_preview(world, state.preview.take());
|
||||||
|
reset_material_drop_session(state, cancel_consumed);
|
||||||
|
set_drop_operator_status(
|
||||||
|
world,
|
||||||
|
OperatorPhase::Canceled,
|
||||||
|
"Surface assignment canceled",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit_material_drop_session(world: &mut World, state: &mut MaterialDropState) -> bool {
|
||||||
|
let Some(preview) = state.preview.take() else {
|
||||||
|
reset_material_drop_session(state, false);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
preview.snapshot.restore(world);
|
||||||
|
let committed = commit_material_drop_preview(world, preview);
|
||||||
|
reset_material_drop_session(state, false);
|
||||||
|
committed
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_material_drop_session(state: &mut MaterialDropState, cancel_consumed: bool) {
|
||||||
|
state.resolved = None;
|
||||||
|
state.feedback = None;
|
||||||
|
state.captures_viewport_input = false;
|
||||||
|
state.cancel_consumed = cancel_consumed;
|
||||||
|
}
|
||||||
|
|
||||||
fn restore_material_drop_preview(world: &mut World, preview: Option<MaterialDropPreview>) {
|
fn restore_material_drop_preview(world: &mut World, preview: Option<MaterialDropPreview>) {
|
||||||
if let Some(preview) = preview {
|
if let Some(preview) = preview {
|
||||||
preview.snapshot.restore(world);
|
preview.snapshot.restore(world);
|
||||||
@ -1029,7 +1040,8 @@ fn draw_material_drop_target(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::history::{apply_command_undo, EditorHistory};
|
use crate::history::EditorHistory;
|
||||||
|
use crate::operators::test_harness::{assert_undo_redo_round_trip, OperatorInvariantHarness};
|
||||||
use shared::{ActorKind, RendererMaterialSet, RendererMaterialSlot, SharedTypesPlugin};
|
use shared::{ActorKind, RendererMaterialSet, RendererMaterialSlot, SharedTypesPlugin};
|
||||||
|
|
||||||
fn test_app() -> App {
|
fn test_app() -> App {
|
||||||
@ -1098,6 +1110,7 @@ mod tests {
|
|||||||
hit_point: Vec3::ZERO,
|
hit_point: Vec3::ZERO,
|
||||||
hit_normal: Vec3::Y,
|
hit_normal: Vec3::Y,
|
||||||
};
|
};
|
||||||
|
let harness = OperatorInvariantHarness::capture(world);
|
||||||
let preview = begin_material_drop_preview(
|
let preview = begin_material_drop_preview(
|
||||||
world,
|
world,
|
||||||
AssetSelection::File("assets/materials/steel.ron".into()),
|
AssetSelection::File("assets/materials/steel.ron".into()),
|
||||||
@ -1123,18 +1136,25 @@ mod tests {
|
|||||||
assert!(!world.resource::<SceneIo>().dirty);
|
assert!(!world.resource::<SceneIo>().dirty);
|
||||||
assert_eq!(world.resource::<EditorHistory>().undo_depth(), 0);
|
assert_eq!(world.resource::<EditorHistory>().undo_depth(), 0);
|
||||||
|
|
||||||
preview.snapshot.restore(world);
|
let mut state = MaterialDropState {
|
||||||
assert!(world
|
resolved: Some(target),
|
||||||
.get::<StaticMeshRenderer>(entity)
|
preview: Some(preview),
|
||||||
.unwrap()
|
feedback: Some(MaterialDropFeedback {
|
||||||
.materials
|
valid: true,
|
||||||
.slot(&slot_b)
|
action: "Assign material".into(),
|
||||||
.unwrap()
|
target: "Crate / B".into(),
|
||||||
.material
|
}),
|
||||||
.is_none());
|
captures_viewport_input: true,
|
||||||
assert!(commit_material_drop_preview(world, preview));
|
cancel_consumed: false,
|
||||||
assert_eq!(world.resource::<EditorHistory>().undo_depth(), 1);
|
};
|
||||||
assert!(world.resource::<SceneIo>().dirty);
|
assert!(commit_material_drop_session(world, &mut state));
|
||||||
|
harness.assert_committed(world, 1, 0);
|
||||||
|
harness.assert_status(world, "assets.assign_surface", OperatorPhase::Committed);
|
||||||
|
assert!(state.preview.is_none());
|
||||||
|
assert!(state.resolved.is_none());
|
||||||
|
assert!(state.feedback.is_none());
|
||||||
|
assert!(!state.captures_viewport_input);
|
||||||
|
assert!(!state.cancel_consumed);
|
||||||
assert!(world
|
assert!(world
|
||||||
.get::<StaticMeshRenderer>(entity)
|
.get::<StaticMeshRenderer>(entity)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@ -1143,16 +1163,17 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.material
|
.material
|
||||||
.is_none());
|
.is_none());
|
||||||
|
assert_undo_redo_round_trip(world, None, Some("steel".to_string()), |world| {
|
||||||
apply_command_undo(world);
|
world
|
||||||
assert!(world
|
.get::<StaticMeshRenderer>(entity)
|
||||||
.get::<StaticMeshRenderer>(entity)
|
.unwrap()
|
||||||
.unwrap()
|
.materials
|
||||||
.materials
|
.slot(&slot_b)
|
||||||
.slot(&slot_b)
|
.unwrap()
|
||||||
.unwrap()
|
.material
|
||||||
.material
|
.as_ref()
|
||||||
.is_none());
|
.map(|material| material.0.asset_id.clone())
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1172,6 +1193,7 @@ mod tests {
|
|||||||
hit_point: Vec3::ZERO,
|
hit_point: Vec3::ZERO,
|
||||||
hit_normal: Vec3::Y,
|
hit_normal: Vec3::Y,
|
||||||
};
|
};
|
||||||
|
let harness = OperatorInvariantHarness::capture(world);
|
||||||
let preview = begin_material_drop_preview(
|
let preview = begin_material_drop_preview(
|
||||||
world,
|
world,
|
||||||
AssetSelection::File("assets/materials/steel.ron".into()),
|
AssetSelection::File("assets/materials/steel.ron".into()),
|
||||||
@ -1196,9 +1218,26 @@ mod tests {
|
|||||||
.material
|
.material
|
||||||
.is_none());
|
.is_none());
|
||||||
|
|
||||||
preview.snapshot.restore(world);
|
let mut state = MaterialDropState {
|
||||||
|
resolved: Some(target),
|
||||||
|
preview: Some(preview),
|
||||||
|
feedback: Some(MaterialDropFeedback {
|
||||||
|
valid: true,
|
||||||
|
action: "Assign material".into(),
|
||||||
|
target: "Blockout / face".into(),
|
||||||
|
}),
|
||||||
|
captures_viewport_input: true,
|
||||||
|
cancel_consumed: false,
|
||||||
|
};
|
||||||
|
cancel_material_drop_session(world, &mut state, true);
|
||||||
|
harness.assert_canceled(world);
|
||||||
|
harness.assert_status(world, "assets.assign_surface", OperatorPhase::Canceled);
|
||||||
assert_eq!(world.get::<BrushDesc>(entity), Some(&brush));
|
assert_eq!(world.get::<BrushDesc>(entity), Some(&brush));
|
||||||
assert!(!world.resource::<SceneIo>().dirty);
|
assert!(state.preview.is_none());
|
||||||
|
assert!(state.resolved.is_none());
|
||||||
|
assert!(state.feedback.is_none());
|
||||||
|
assert!(!state.captures_viewport_input);
|
||||||
|
assert!(state.cancel_consumed);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -6,7 +6,7 @@ use std::time::Duration;
|
|||||||
use avian3d::prelude::*;
|
use avian3d::prelude::*;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
use crate::history::set_transform_group_with_history;
|
use crate::history::{set_transform_group_with_history, EditorHistory};
|
||||||
use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus};
|
use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus};
|
||||||
use crate::state::EditorMode;
|
use crate::state::EditorMode;
|
||||||
use crate::ui::selection_ops::{entity_name, is_mutable_level_object};
|
use crate::ui::selection_ops::{entity_name, is_mutable_level_object};
|
||||||
@ -256,6 +256,7 @@ fn finish_physics_placement(world: &mut World, commit: bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let undo_depth = world.resource::<EditorHistory>().undo_depth();
|
||||||
if commit {
|
if commit {
|
||||||
let changes = session.selected.iter().filter_map(|snapshot| {
|
let changes = session.selected.iter().filter_map(|snapshot| {
|
||||||
final_transforms
|
final_transforms
|
||||||
@ -265,6 +266,7 @@ fn finish_physics_placement(world: &mut World, commit: bool) {
|
|||||||
});
|
});
|
||||||
set_transform_group_with_history(world, changes);
|
set_transform_group_with_history(world, changes);
|
||||||
}
|
}
|
||||||
|
let committed = commit && world.resource::<EditorHistory>().undo_depth() > undo_depth;
|
||||||
|
|
||||||
state.phase = PhysicsPlacementPhase::Inactive;
|
state.phase = PhysicsPlacementPhase::Inactive;
|
||||||
state.request = PhysicsPlacementRequest::None;
|
state.request = PhysicsPlacementRequest::None;
|
||||||
@ -272,7 +274,14 @@ fn finish_physics_placement(world: &mut World, commit: bool) {
|
|||||||
state.simulated_steps = 0;
|
state.simulated_steps = 0;
|
||||||
state.last_error = None;
|
state.last_error = None;
|
||||||
world.insert_resource(state);
|
world.insert_resource(state);
|
||||||
clear_operator_status(world);
|
let (phase, hint) = if committed {
|
||||||
|
(OperatorPhase::Committed, "Physics placement committed")
|
||||||
|
} else if commit {
|
||||||
|
(OperatorPhase::Canceled, "Physics placement made no changes")
|
||||||
|
} else {
|
||||||
|
(OperatorPhase::Canceled, "Physics placement canceled")
|
||||||
|
};
|
||||||
|
set_operator_status(world, phase, hint.to_string(), Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tick_physics_placement(world: &mut World) {
|
fn tick_physics_placement(world: &mut World) {
|
||||||
@ -437,22 +446,12 @@ fn set_operator_status(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clear_operator_status(world: &mut World) {
|
|
||||||
if let Some(mut operator) = world.get_resource_mut::<ActiveOperator>() {
|
|
||||||
if operator
|
|
||||||
.status
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|status| status.id == "physics.placement")
|
|
||||||
{
|
|
||||||
operator.status = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::history::{apply_command_undo, EditorHistory};
|
use crate::history::EditorHistory;
|
||||||
|
use crate::operators::test_harness::{assert_undo_redo_round_trip, OperatorInvariantHarness};
|
||||||
|
use crate::scene_io::SceneIo;
|
||||||
use bevy::asset::{AssetApp, AssetPlugin};
|
use bevy::asset::{AssetApp, AssetPlugin};
|
||||||
use bevy::state::app::StatesPlugin;
|
use bevy::state::app::StatesPlugin;
|
||||||
use shared::{ColliderDesc, LevelObject, RigidBodyDesc};
|
use shared::{ColliderDesc, LevelObject, RigidBodyDesc};
|
||||||
@ -469,7 +468,9 @@ mod tests {
|
|||||||
.init_asset::<Mesh>()
|
.init_asset::<Mesh>()
|
||||||
.init_state::<EditorMode>()
|
.init_state::<EditorMode>()
|
||||||
.init_resource::<PhysicsPlacementState>()
|
.init_resource::<PhysicsPlacementState>()
|
||||||
.init_resource::<EditorHistory>();
|
.init_resource::<EditorHistory>()
|
||||||
|
.init_resource::<ActiveOperator>()
|
||||||
|
.init_resource::<SceneIo>();
|
||||||
app.finish();
|
app.finish();
|
||||||
app.cleanup();
|
app.cleanup();
|
||||||
app.update();
|
app.update();
|
||||||
@ -498,12 +499,16 @@ mod tests {
|
|||||||
.world_mut()
|
.world_mut()
|
||||||
.spawn((Name::new("No Physics"), LevelObject, Transform::default()))
|
.spawn((Name::new("No Physics"), LevelObject, Transform::default()))
|
||||||
.id();
|
.id();
|
||||||
|
let harness = OperatorInvariantHarness::capture(app.world_mut());
|
||||||
|
|
||||||
let error = begin_physics_placement(app.world_mut(), [entity]).unwrap_err();
|
let error = begin_physics_placement(app.world_mut(), [entity]).unwrap_err();
|
||||||
|
|
||||||
assert!(error.contains("add and enable a Rigid Body"));
|
assert!(error.contains("add and enable a Rigid Body"));
|
||||||
assert!(error.contains("add and enable a non-trigger Collider"));
|
assert!(error.contains("add and enable a non-trigger Collider"));
|
||||||
assert!(!app.world().resource::<PhysicsPlacementState>().active());
|
assert!(!app.world().resource::<PhysicsPlacementState>().active());
|
||||||
|
harness.assert_blocked(app.world_mut());
|
||||||
|
harness.assert_status(app.world(), "physics.placement", OperatorPhase::Blocked);
|
||||||
|
harness.assert_no_helpers::<PhysicsPlacementPreview>(app.world_mut());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -531,6 +536,7 @@ mod tests {
|
|||||||
))
|
))
|
||||||
.id();
|
.id();
|
||||||
app.update();
|
app.update();
|
||||||
|
let harness = OperatorInvariantHarness::capture(app.world_mut());
|
||||||
|
|
||||||
begin_physics_placement(app.world_mut(), [selected]).unwrap();
|
begin_physics_placement(app.world_mut(), [selected]).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@ -581,6 +587,9 @@ mod tests {
|
|||||||
Some(&LinearVelocity(Vec3::X * 3.0))
|
Some(&LinearVelocity(Vec3::X * 3.0))
|
||||||
);
|
);
|
||||||
assert_eq!(app.world().resource::<EditorHistory>().undo_depth(), 0);
|
assert_eq!(app.world().resource::<EditorHistory>().undo_depth(), 0);
|
||||||
|
harness.assert_canceled(app.world_mut());
|
||||||
|
harness.assert_status(app.world(), "physics.placement", OperatorPhase::Canceled);
|
||||||
|
harness.assert_no_helpers::<PhysicsPlacementPreview>(app.world_mut());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -594,6 +603,7 @@ mod tests {
|
|||||||
let start = Transform::from_xyz(0.0, 4.0, 0.0);
|
let start = Transform::from_xyz(0.0, 4.0, 0.0);
|
||||||
let prop = spawn_placeable(app.world_mut(), "Drop Prop", start);
|
let prop = spawn_placeable(app.world_mut(), "Drop Prop", start);
|
||||||
app.update();
|
app.update();
|
||||||
|
let harness = OperatorInvariantHarness::capture(app.world_mut());
|
||||||
|
|
||||||
begin_physics_placement(app.world_mut(), [prop]).unwrap();
|
begin_physics_placement(app.world_mut(), [prop]).unwrap();
|
||||||
for _ in 0..400 {
|
for _ in 0..400 {
|
||||||
@ -613,15 +623,75 @@ mod tests {
|
|||||||
|
|
||||||
commit_physics_placement(app.world_mut());
|
commit_physics_placement(app.world_mut());
|
||||||
|
|
||||||
assert_eq!(app.world().resource::<EditorHistory>().undo_depth(), 1);
|
harness.assert_committed(app.world_mut(), 1, 0);
|
||||||
|
harness.assert_status(app.world(), "physics.placement", OperatorPhase::Committed);
|
||||||
|
harness.assert_no_helpers::<PhysicsPlacementPreview>(app.world_mut());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
app.world().resource::<EditorHistory>().status,
|
app.world().resource::<EditorHistory>().status,
|
||||||
"Undo: Move Selection"
|
"Undo: Move Selection"
|
||||||
);
|
);
|
||||||
assert_eq!(app.world().get::<RigidBody>(prop), Some(&RigidBody::Static));
|
assert_eq!(app.world().get::<RigidBody>(prop), Some(&RigidBody::Static));
|
||||||
assert_eq!(*app.world().get::<Transform>(prop).unwrap(), settled);
|
assert_eq!(*app.world().get::<Transform>(prop).unwrap(), settled);
|
||||||
|
assert_undo_redo_round_trip(app.world_mut(), start, settled, |world| {
|
||||||
|
*world.get::<Transform>(prop).unwrap()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
apply_command_undo(app.world_mut());
|
#[test]
|
||||||
|
fn no_op_commit_terminates_without_dirtying_or_history() {
|
||||||
|
let mut app = app_with_physics();
|
||||||
|
let start = Transform::from_xyz(0.0, 4.0, 0.0);
|
||||||
|
let prop = spawn_placeable(app.world_mut(), "Static Prop", start);
|
||||||
|
app.update();
|
||||||
|
let harness = OperatorInvariantHarness::capture(app.world_mut());
|
||||||
|
|
||||||
|
begin_physics_placement(app.world_mut(), [prop]).unwrap();
|
||||||
|
commit_physics_placement(app.world_mut());
|
||||||
|
|
||||||
|
harness.assert_canceled(app.world_mut());
|
||||||
|
harness.assert_status(app.world(), "physics.placement", OperatorPhase::Canceled);
|
||||||
|
harness.assert_no_helpers::<PhysicsPlacementPreview>(app.world_mut());
|
||||||
assert_eq!(*app.world().get::<Transform>(prop).unwrap(), start);
|
assert_eq!(*app.world().get::<Transform>(prop).unwrap(), start);
|
||||||
|
assert_eq!(app.world().get::<RigidBody>(prop), Some(&RigidBody::Static));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_select_commit_is_one_transaction_and_cleans_every_preview_helper() {
|
||||||
|
let mut app = app_with_physics();
|
||||||
|
let starts = [
|
||||||
|
Transform::from_xyz(-1.0, 4.0, 0.0),
|
||||||
|
Transform::from_xyz(1.0, 6.0, 0.0),
|
||||||
|
];
|
||||||
|
let first = spawn_placeable(app.world_mut(), "First", starts[0]);
|
||||||
|
let second = spawn_placeable(app.world_mut(), "Second", starts[1]);
|
||||||
|
app.update();
|
||||||
|
let harness = OperatorInvariantHarness::capture(app.world_mut());
|
||||||
|
|
||||||
|
begin_physics_placement(app.world_mut(), [first, second, first]).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
app.world()
|
||||||
|
.resource::<PhysicsPlacementState>()
|
||||||
|
.selected_count,
|
||||||
|
2,
|
||||||
|
"duplicate selections must collapse to one placement snapshot"
|
||||||
|
);
|
||||||
|
let committed = [
|
||||||
|
Transform::from_xyz(-1.0, 0.5, 0.0),
|
||||||
|
Transform::from_xyz(1.0, 0.5, 0.0),
|
||||||
|
];
|
||||||
|
app.world_mut().entity_mut(first).insert(committed[0]);
|
||||||
|
app.world_mut().entity_mut(second).insert(committed[1]);
|
||||||
|
|
||||||
|
commit_physics_placement(app.world_mut());
|
||||||
|
|
||||||
|
harness.assert_committed(app.world_mut(), 1, 0);
|
||||||
|
harness.assert_status(app.world(), "physics.placement", OperatorPhase::Committed);
|
||||||
|
harness.assert_no_helpers::<PhysicsPlacementPreview>(app.world_mut());
|
||||||
|
assert_undo_redo_round_trip(app.world_mut(), starts, committed, |world| {
|
||||||
|
[
|
||||||
|
*world.get::<Transform>(first).unwrap(),
|
||||||
|
*world.get::<Transform>(second).unwrap(),
|
||||||
|
]
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ use shared::{TerrainDesc, AUTHORING_COMPONENT_TERRAIN, COMPONENT_TERRAIN_DESC};
|
|||||||
|
|
||||||
use super::terrain_sculpt::{active_scene_ray, smooth_falloff, terrain_ray_hit, TerrainHit};
|
use super::terrain_sculpt::{active_scene_ray, smooth_falloff, terrain_ray_hit, TerrainHit};
|
||||||
use crate::camera::EditorCamera;
|
use crate::camera::EditorCamera;
|
||||||
use crate::history::reflected_component_transaction;
|
use crate::history::{reflected_component_transaction, EditorHistory};
|
||||||
use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus};
|
use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus};
|
||||||
use crate::scene_io::SceneIo;
|
use crate::scene_io::SceneIo;
|
||||||
use crate::selection::{SelectedEntity, ViewportClick};
|
use crate::selection::{SelectedEntity, ViewportClick};
|
||||||
@ -125,16 +125,30 @@ fn terrain_paint_input(
|
|||||||
viewport_click.0 = None;
|
viewport_click.0 = None;
|
||||||
state.clamp_settings();
|
state.clamp_settings();
|
||||||
let Some(entity) = state.target.or(selected.0) else {
|
let Some(entity) = state.target.or(selected.0) else {
|
||||||
cancel_stroke(&mut state, &mut terrains);
|
stop_tool_with_rollback(
|
||||||
state.stop();
|
&mut state,
|
||||||
|
&mut terrains,
|
||||||
|
&mut scene_io,
|
||||||
|
&mut active_operator,
|
||||||
|
OperatorPhase::Blocked,
|
||||||
|
"Terrain paint stopped: target is unavailable",
|
||||||
|
"Target unavailable",
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
if selected.0 != Some(entity) {
|
if selected.0 != Some(entity) {
|
||||||
selected.0 = Some(entity);
|
selected.0 = Some(entity);
|
||||||
}
|
}
|
||||||
if display.clean_game_view {
|
if display.clean_game_view {
|
||||||
cancel_stroke(&mut state, &mut terrains);
|
stop_tool_with_rollback(
|
||||||
state.stop();
|
&mut state,
|
||||||
|
&mut terrains,
|
||||||
|
&mut scene_io,
|
||||||
|
&mut active_operator,
|
||||||
|
OperatorPhase::Canceled,
|
||||||
|
"Terrain paint stopped for clean game view",
|
||||||
|
"Clean game view enabled; weights restored",
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let ctx = contexts.ctx_mut()?;
|
let ctx = contexts.ctx_mut()?;
|
||||||
@ -142,12 +156,11 @@ fn terrain_paint_input(
|
|||||||
keys.just_pressed(KeyCode::Escape) || buttons.just_pressed(MouseButton::Right);
|
keys.just_pressed(KeyCode::Escape) || buttons.just_pressed(MouseButton::Right);
|
||||||
if cancel_requested {
|
if cancel_requested {
|
||||||
if state.is_stroking() {
|
if state.is_stroking() {
|
||||||
cancel_stroke(&mut state, &mut terrains);
|
cancel_active_stroke(
|
||||||
scene_io.status = "Terrain paint stroke canceled".into();
|
&mut state,
|
||||||
set_status(
|
&mut terrains,
|
||||||
|
&mut scene_io,
|
||||||
&mut active_operator,
|
&mut active_operator,
|
||||||
OperatorPhase::Canceled,
|
|
||||||
"Weights restored",
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
state.stop();
|
state.stop();
|
||||||
@ -157,14 +170,29 @@ fn terrain_paint_input(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let Ok((global, mut terrain)) = terrains.get_mut(entity) else {
|
let Ok((global, mut terrain)) = terrains.get_mut(entity) else {
|
||||||
state.stroke = None;
|
stop_tool_with_rollback(
|
||||||
state.stop();
|
&mut state,
|
||||||
|
&mut terrains,
|
||||||
|
&mut scene_io,
|
||||||
|
&mut active_operator,
|
||||||
|
OperatorPhase::Blocked,
|
||||||
|
"Terrain paint stopped: target no longer exists",
|
||||||
|
"Target no longer exists",
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let layer_count = terrain.material_layers.len();
|
let layer_count = terrain.material_layers.len();
|
||||||
if layer_count == 0 {
|
if layer_count == 0 {
|
||||||
|
if let Some(stroke) = state.stroke.take() {
|
||||||
|
*terrain = stroke.original;
|
||||||
|
}
|
||||||
state.stop();
|
state.stop();
|
||||||
scene_io.status = "Add a terrain material layer before painting".into();
|
scene_io.status = "Add a terrain material layer before painting".into();
|
||||||
|
set_status(
|
||||||
|
&mut active_operator,
|
||||||
|
OperatorPhase::Blocked,
|
||||||
|
"Add a material layer before painting",
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
state.active_layer = state.active_layer.min(layer_count - 1);
|
state.active_layer = state.active_layer.min(layer_count - 1);
|
||||||
@ -227,17 +255,8 @@ fn terrain_paint_input(
|
|||||||
let final_terrain = terrain.clone();
|
let final_terrain = terrain.clone();
|
||||||
let mode = state.mode;
|
let mode = state.mode;
|
||||||
commands.queue(move |world: &mut World| {
|
commands.queue(move |world: &mut World| {
|
||||||
if let Err(error) = commit_paint_stroke(world, stroke, final_terrain) {
|
finish_paint_stroke(world, stroke, final_terrain, mode);
|
||||||
world.resource_mut::<SceneIo>().status =
|
|
||||||
format!("Terrain paint commit failed: {error}");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
scene_io.status = format!("{} terrain material weights committed", mode.label());
|
|
||||||
set_status(
|
|
||||||
&mut active_operator,
|
|
||||||
OperatorPhase::Committed,
|
|
||||||
format!("{} weights; Ctrl+Z to undo", mode.label()),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -248,10 +267,11 @@ fn commit_paint_stroke(
|
|||||||
stroke: TerrainPaintStroke,
|
stroke: TerrainPaintStroke,
|
||||||
final_terrain: TerrainDesc,
|
final_terrain: TerrainDesc,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
let original = stroke.original;
|
||||||
if let Ok(mut actor) = world.get_entity_mut(stroke.entity) {
|
if let Ok(mut actor) = world.get_entity_mut(stroke.entity) {
|
||||||
actor.insert(stroke.original);
|
actor.insert(original.clone());
|
||||||
}
|
}
|
||||||
reflected_component_transaction(
|
let result = reflected_component_transaction(
|
||||||
world,
|
world,
|
||||||
stroke.entity,
|
stroke.entity,
|
||||||
"Paint Terrain Material",
|
"Paint Terrain Material",
|
||||||
@ -261,7 +281,77 @@ fn commit_paint_stroke(
|
|||||||
world.entity_mut(entity).insert(final_terrain);
|
world.entity_mut(entity).insert(final_terrain);
|
||||||
Ok(())
|
Ok(())
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
if result.is_err() {
|
||||||
|
if let Ok(mut actor) = world.get_entity_mut(stroke.entity) {
|
||||||
|
actor.insert(original);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_paint_stroke(
|
||||||
|
world: &mut World,
|
||||||
|
stroke: TerrainPaintStroke,
|
||||||
|
final_terrain: TerrainDesc,
|
||||||
|
mode: TerrainPaintMode,
|
||||||
|
) {
|
||||||
|
let undo_depth = world.resource::<EditorHistory>().undo_depth();
|
||||||
|
match commit_paint_stroke(world, stroke, final_terrain) {
|
||||||
|
Ok(()) if world.resource::<EditorHistory>().undo_depth() > undo_depth => {
|
||||||
|
world.resource_mut::<SceneIo>().status =
|
||||||
|
format!("{} terrain material weights committed", mode.label());
|
||||||
|
set_status(
|
||||||
|
&mut world.resource_mut::<ActiveOperator>(),
|
||||||
|
OperatorPhase::Committed,
|
||||||
|
format!("{} weights; Ctrl+Z to undo", mode.label()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(()) => {
|
||||||
|
world.resource_mut::<SceneIo>().status =
|
||||||
|
format!("{} terrain stroke made no changes", mode.label());
|
||||||
|
set_status(
|
||||||
|
&mut world.resource_mut::<ActiveOperator>(),
|
||||||
|
OperatorPhase::Canceled,
|
||||||
|
format!("{} stroke made no changes", mode.label()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
world.resource_mut::<SceneIo>().status =
|
||||||
|
format!("Terrain paint commit failed: {error}");
|
||||||
|
set_status(
|
||||||
|
&mut world.resource_mut::<ActiveOperator>(),
|
||||||
|
OperatorPhase::Blocked,
|
||||||
|
format!("Commit failed: {error}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel_active_stroke(
|
||||||
|
state: &mut TerrainPaintState,
|
||||||
|
terrains: &mut Query<(&GlobalTransform, &mut TerrainDesc)>,
|
||||||
|
scene_io: &mut SceneIo,
|
||||||
|
active_operator: &mut ActiveOperator,
|
||||||
|
) {
|
||||||
|
cancel_stroke(state, terrains);
|
||||||
|
scene_io.status = "Terrain paint stroke canceled".into();
|
||||||
|
set_status(active_operator, OperatorPhase::Canceled, "Weights restored");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_tool_with_rollback(
|
||||||
|
state: &mut TerrainPaintState,
|
||||||
|
terrains: &mut Query<(&GlobalTransform, &mut TerrainDesc)>,
|
||||||
|
scene_io: &mut SceneIo,
|
||||||
|
active_operator: &mut ActiveOperator,
|
||||||
|
phase: OperatorPhase,
|
||||||
|
message: &str,
|
||||||
|
hint: &str,
|
||||||
|
) {
|
||||||
|
cancel_stroke(state, terrains);
|
||||||
|
state.stop();
|
||||||
|
scene_io.status = message.to_string();
|
||||||
|
set_status(active_operator, phase, hint);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cancel_stroke(
|
fn cancel_stroke(
|
||||||
@ -415,7 +505,8 @@ fn draw_terrain_paint_preview(state: Res<TerrainPaintState>, mut gizmos: Gizmos)
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::history::{apply_command_redo, apply_command_undo, EditorHistory};
|
use crate::history::EditorHistory;
|
||||||
|
use crate::operators::test_harness::{assert_undo_redo_round_trip, OperatorInvariantHarness};
|
||||||
use shared::{ActorKind, LevelObject};
|
use shared::{ActorKind, LevelObject};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -452,8 +543,10 @@ mod tests {
|
|||||||
.register_type::<shared::TerrainMaterialLayer>()
|
.register_type::<shared::TerrainMaterialLayer>()
|
||||||
.register_type::<shared::EditorAssetRef>();
|
.register_type::<shared::EditorAssetRef>();
|
||||||
let world = app.world_mut();
|
let world = app.world_mut();
|
||||||
|
world.init_resource::<ActiveOperator>();
|
||||||
world.init_resource::<EditorHistory>();
|
world.init_resource::<EditorHistory>();
|
||||||
world.init_resource::<SceneIo>();
|
world.init_resource::<SceneIo>();
|
||||||
|
world.init_resource::<TerrainPaintState>();
|
||||||
let mut original = TerrainDesc::flat(9);
|
let mut original = TerrainDesc::flat(9);
|
||||||
original.material_layers = vec![Default::default(), Default::default()];
|
original.material_layers = vec![Default::default(), Default::default()];
|
||||||
let entity = world
|
let entity = world
|
||||||
@ -471,23 +564,121 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
world.entity_mut(entity).insert(final_terrain.clone());
|
world.entity_mut(entity).insert(final_terrain.clone());
|
||||||
commit_paint_stroke(
|
let stroke = TerrainPaintStroke {
|
||||||
|
entity,
|
||||||
|
original: original.clone(),
|
||||||
|
last_local: Vec3::ZERO,
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let mut state = world.resource_mut::<TerrainPaintState>();
|
||||||
|
state.start(entity, 2);
|
||||||
|
state.stroke = Some(stroke);
|
||||||
|
}
|
||||||
|
let stroke = world
|
||||||
|
.resource_mut::<TerrainPaintState>()
|
||||||
|
.stroke
|
||||||
|
.take()
|
||||||
|
.unwrap();
|
||||||
|
let harness = OperatorInvariantHarness::capture(world);
|
||||||
|
|
||||||
|
finish_paint_stroke(
|
||||||
|
world,
|
||||||
|
stroke,
|
||||||
|
final_terrain.clone(),
|
||||||
|
TerrainPaintMode::Paint,
|
||||||
|
);
|
||||||
|
|
||||||
|
harness.assert_committed(world, 1, 0);
|
||||||
|
harness.assert_status(world, "terrain.paint", OperatorPhase::Committed);
|
||||||
|
assert_eq!(world.get::<TerrainDesc>(entity), Some(&final_terrain));
|
||||||
|
let state = world.resource::<TerrainPaintState>();
|
||||||
|
assert!(state.active);
|
||||||
|
assert_eq!(state.target, Some(entity));
|
||||||
|
assert!(!state.is_stroking());
|
||||||
|
assert_undo_redo_round_trip(world, original, final_terrain, |world| {
|
||||||
|
world.get::<TerrainDesc>(entity).unwrap().clone()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_op_paint_release_terminates_without_dirtying_or_history() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.register_type::<ActorKind>()
|
||||||
|
.register_type::<TerrainDesc>()
|
||||||
|
.register_type::<shared::TerrainMaterialLayer>()
|
||||||
|
.register_type::<shared::EditorAssetRef>();
|
||||||
|
let world = app.world_mut();
|
||||||
|
world.init_resource::<ActiveOperator>();
|
||||||
|
world.init_resource::<EditorHistory>();
|
||||||
|
world.init_resource::<SceneIo>();
|
||||||
|
let mut original = TerrainDesc::flat(5);
|
||||||
|
original.material_layers = vec![Default::default(), Default::default()];
|
||||||
|
original.ensure_material_weights();
|
||||||
|
let entity = world
|
||||||
|
.spawn((LevelObject, ActorKind::Terrain, original.clone()))
|
||||||
|
.id();
|
||||||
|
let harness = OperatorInvariantHarness::capture(world);
|
||||||
|
|
||||||
|
finish_paint_stroke(
|
||||||
world,
|
world,
|
||||||
TerrainPaintStroke {
|
TerrainPaintStroke {
|
||||||
entity,
|
entity,
|
||||||
original: original.clone(),
|
original: original.clone(),
|
||||||
last_local: Vec3::ZERO,
|
last_local: Vec3::ZERO,
|
||||||
},
|
},
|
||||||
final_terrain.clone(),
|
original.clone(),
|
||||||
)
|
TerrainPaintMode::Erase,
|
||||||
.unwrap();
|
);
|
||||||
|
|
||||||
assert_eq!(world.resource::<EditorHistory>().undo_depth(), 1);
|
harness.assert_canceled(world);
|
||||||
assert_eq!(world.get::<TerrainDesc>(entity), Some(&final_terrain));
|
harness.assert_status(world, "terrain.paint", OperatorPhase::Canceled);
|
||||||
apply_command_undo(world);
|
|
||||||
assert_eq!(world.get::<TerrainDesc>(entity), Some(&original));
|
assert_eq!(world.get::<TerrainDesc>(entity), Some(&original));
|
||||||
apply_command_redo(world);
|
}
|
||||||
assert_eq!(world.get::<TerrainDesc>(entity), Some(&final_terrain));
|
|
||||||
|
#[test]
|
||||||
|
fn commit_failure_restores_preview_and_reports_blocked_without_history() {
|
||||||
|
let mut app = App::new();
|
||||||
|
let world = app.world_mut();
|
||||||
|
world.init_resource::<ActiveOperator>();
|
||||||
|
world.init_resource::<EditorHistory>();
|
||||||
|
world.init_resource::<SceneIo>();
|
||||||
|
let mut original = TerrainDesc::flat(5);
|
||||||
|
original.material_layers = vec![Default::default(), Default::default()];
|
||||||
|
let mut preview = original.clone();
|
||||||
|
apply_weight_dab(
|
||||||
|
&mut preview,
|
||||||
|
Vec3::ZERO,
|
||||||
|
3.0,
|
||||||
|
0.8,
|
||||||
|
1,
|
||||||
|
TerrainPaintMode::Paint,
|
||||||
|
);
|
||||||
|
let entity = world
|
||||||
|
.spawn((LevelObject, ActorKind::Terrain, preview.clone()))
|
||||||
|
.id();
|
||||||
|
let mut hierarchy = crate::ui::hierarchy_state::HierarchyPanelState::default();
|
||||||
|
hierarchy.locked.insert(entity);
|
||||||
|
world.insert_resource(hierarchy);
|
||||||
|
let harness = OperatorInvariantHarness::capture(world);
|
||||||
|
|
||||||
|
finish_paint_stroke(
|
||||||
|
world,
|
||||||
|
TerrainPaintStroke {
|
||||||
|
entity,
|
||||||
|
original: original.clone(),
|
||||||
|
last_local: Vec3::ZERO,
|
||||||
|
},
|
||||||
|
preview,
|
||||||
|
TerrainPaintMode::Paint,
|
||||||
|
);
|
||||||
|
|
||||||
|
harness.assert_blocked(world);
|
||||||
|
harness.assert_status(world, "terrain.paint", OperatorPhase::Blocked);
|
||||||
|
assert_eq!(world.get::<TerrainDesc>(entity), Some(&original));
|
||||||
|
assert!(world
|
||||||
|
.resource::<SceneIo>()
|
||||||
|
.status
|
||||||
|
.starts_with("Terrain paint commit failed:"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -495,11 +686,21 @@ mod tests {
|
|||||||
fn cancel_once(
|
fn cancel_once(
|
||||||
mut state: ResMut<TerrainPaintState>,
|
mut state: ResMut<TerrainPaintState>,
|
||||||
mut terrains: Query<(&GlobalTransform, &mut TerrainDesc)>,
|
mut terrains: Query<(&GlobalTransform, &mut TerrainDesc)>,
|
||||||
|
mut scene_io: ResMut<SceneIo>,
|
||||||
|
mut active_operator: ResMut<ActiveOperator>,
|
||||||
) {
|
) {
|
||||||
cancel_stroke(&mut state, &mut terrains);
|
cancel_active_stroke(
|
||||||
|
&mut state,
|
||||||
|
&mut terrains,
|
||||||
|
&mut scene_io,
|
||||||
|
&mut active_operator,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
|
app.init_resource::<ActiveOperator>()
|
||||||
|
.init_resource::<EditorHistory>()
|
||||||
|
.init_resource::<SceneIo>();
|
||||||
let mut original = TerrainDesc::flat(5);
|
let mut original = TerrainDesc::flat(5);
|
||||||
original.material_layers = vec![Default::default(), Default::default()];
|
original.material_layers = vec![Default::default(), Default::default()];
|
||||||
let mut preview = original.clone();
|
let mut preview = original.clone();
|
||||||
@ -513,7 +714,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let entity = app
|
let entity = app
|
||||||
.world_mut()
|
.world_mut()
|
||||||
.spawn((GlobalTransform::default(), preview))
|
.spawn((LevelObject, GlobalTransform::default(), preview))
|
||||||
.id();
|
.id();
|
||||||
let mut state = TerrainPaintState::default();
|
let mut state = TerrainPaintState::default();
|
||||||
state.start(entity, 2);
|
state.start(entity, 2);
|
||||||
@ -523,9 +724,72 @@ mod tests {
|
|||||||
last_local: Vec3::ZERO,
|
last_local: Vec3::ZERO,
|
||||||
});
|
});
|
||||||
app.insert_resource(state).add_systems(Update, cancel_once);
|
app.insert_resource(state).add_systems(Update, cancel_once);
|
||||||
|
let harness = OperatorInvariantHarness::capture(app.world_mut());
|
||||||
|
|
||||||
app.update();
|
app.update();
|
||||||
|
harness.assert_canceled(app.world_mut());
|
||||||
|
harness.assert_status(app.world(), "terrain.paint", OperatorPhase::Canceled);
|
||||||
assert_eq!(app.world().get::<TerrainDesc>(entity), Some(&original));
|
assert_eq!(app.world().get::<TerrainDesc>(entity), Some(&original));
|
||||||
assert!(!app.world().resource::<TerrainPaintState>().is_stroking());
|
let state = app.world().resource::<TerrainPaintState>();
|
||||||
|
assert!(state.active);
|
||||||
|
assert_eq!(state.target, Some(entity));
|
||||||
|
assert!(!state.is_stroking());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clean_view_interruption_restores_weights_and_terminates_tool() {
|
||||||
|
fn interrupt_once(
|
||||||
|
mut state: ResMut<TerrainPaintState>,
|
||||||
|
mut terrains: Query<(&GlobalTransform, &mut TerrainDesc)>,
|
||||||
|
mut scene_io: ResMut<SceneIo>,
|
||||||
|
mut active_operator: ResMut<ActiveOperator>,
|
||||||
|
) {
|
||||||
|
stop_tool_with_rollback(
|
||||||
|
&mut state,
|
||||||
|
&mut terrains,
|
||||||
|
&mut scene_io,
|
||||||
|
&mut active_operator,
|
||||||
|
OperatorPhase::Canceled,
|
||||||
|
"Terrain paint stopped for clean game view",
|
||||||
|
"Clean game view enabled; weights restored",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.init_resource::<ActiveOperator>()
|
||||||
|
.init_resource::<EditorHistory>()
|
||||||
|
.init_resource::<SceneIo>();
|
||||||
|
let mut original = TerrainDesc::flat(5);
|
||||||
|
original.material_layers = vec![Default::default(), Default::default()];
|
||||||
|
let mut preview = original.clone();
|
||||||
|
apply_weight_dab(
|
||||||
|
&mut preview,
|
||||||
|
Vec3::ZERO,
|
||||||
|
3.0,
|
||||||
|
0.8,
|
||||||
|
1,
|
||||||
|
TerrainPaintMode::Paint,
|
||||||
|
);
|
||||||
|
let entity = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((LevelObject, GlobalTransform::default(), preview))
|
||||||
|
.id();
|
||||||
|
let mut state = TerrainPaintState::default();
|
||||||
|
state.start(entity, 2);
|
||||||
|
state.stroke = Some(TerrainPaintStroke {
|
||||||
|
entity,
|
||||||
|
original: original.clone(),
|
||||||
|
last_local: Vec3::ZERO,
|
||||||
|
});
|
||||||
|
app.insert_resource(state)
|
||||||
|
.add_systems(Update, interrupt_once);
|
||||||
|
let harness = OperatorInvariantHarness::capture(app.world_mut());
|
||||||
|
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
harness.assert_canceled(app.world_mut());
|
||||||
|
harness.assert_status(app.world(), "terrain.paint", OperatorPhase::Canceled);
|
||||||
|
assert_eq!(app.world().get::<TerrainDesc>(entity), Some(&original));
|
||||||
|
assert!(!app.world().resource::<TerrainPaintState>().active);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ use bevy_egui::EguiContexts;
|
|||||||
use shared::{TerrainDesc, AUTHORING_COMPONENT_TERRAIN, COMPONENT_TERRAIN_DESC};
|
use shared::{TerrainDesc, AUTHORING_COMPONENT_TERRAIN, COMPONENT_TERRAIN_DESC};
|
||||||
|
|
||||||
use crate::camera::EditorCamera;
|
use crate::camera::EditorCamera;
|
||||||
use crate::history::reflected_component_transaction;
|
use crate::history::{reflected_component_transaction, EditorHistory};
|
||||||
use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus};
|
use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus};
|
||||||
use crate::scene_io::SceneIo;
|
use crate::scene_io::SceneIo;
|
||||||
use crate::selection::{SelectedEntity, ViewportClick};
|
use crate::selection::{SelectedEntity, ViewportClick};
|
||||||
@ -143,8 +143,15 @@ fn terrain_sculpt_input(
|
|||||||
state.clamp_settings();
|
state.clamp_settings();
|
||||||
|
|
||||||
let Some(entity) = state.target.or(selected.0) else {
|
let Some(entity) = state.target.or(selected.0) else {
|
||||||
cancel_stroke(&mut state, &mut terrains);
|
stop_tool_with_rollback(
|
||||||
state.stop();
|
&mut state,
|
||||||
|
&mut terrains,
|
||||||
|
&mut scene_io,
|
||||||
|
&mut active_operator,
|
||||||
|
OperatorPhase::Blocked,
|
||||||
|
"Terrain sculpt stopped: target is unavailable",
|
||||||
|
"Target unavailable",
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
if selected.0 != Some(entity) {
|
if selected.0 != Some(entity) {
|
||||||
@ -152,8 +159,15 @@ fn terrain_sculpt_input(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if display.clean_game_view {
|
if display.clean_game_view {
|
||||||
cancel_stroke(&mut state, &mut terrains);
|
stop_tool_with_rollback(
|
||||||
state.stop();
|
&mut state,
|
||||||
|
&mut terrains,
|
||||||
|
&mut scene_io,
|
||||||
|
&mut active_operator,
|
||||||
|
OperatorPhase::Canceled,
|
||||||
|
"Terrain sculpt stopped for clean game view",
|
||||||
|
"Clean game view enabled; terrain restored",
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,12 +177,11 @@ fn terrain_sculpt_input(
|
|||||||
keys.just_pressed(KeyCode::Escape) || buttons.just_pressed(MouseButton::Right);
|
keys.just_pressed(KeyCode::Escape) || buttons.just_pressed(MouseButton::Right);
|
||||||
if cancel_requested {
|
if cancel_requested {
|
||||||
if state.is_stroking() {
|
if state.is_stroking() {
|
||||||
cancel_stroke(&mut state, &mut terrains);
|
cancel_active_stroke(
|
||||||
scene_io.status = "Terrain sculpt stroke canceled".to_string();
|
&mut state,
|
||||||
set_status(
|
&mut terrains,
|
||||||
|
&mut scene_io,
|
||||||
&mut active_operator,
|
&mut active_operator,
|
||||||
OperatorPhase::Canceled,
|
|
||||||
"Stroke canceled; terrain restored",
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
state.stop();
|
state.stop();
|
||||||
@ -179,8 +192,15 @@ fn terrain_sculpt_input(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let Ok((global, mut terrain)) = terrains.get_mut(entity) else {
|
let Ok((global, mut terrain)) = terrains.get_mut(entity) else {
|
||||||
state.stroke = None;
|
stop_tool_with_rollback(
|
||||||
state.stop();
|
&mut state,
|
||||||
|
&mut terrains,
|
||||||
|
&mut scene_io,
|
||||||
|
&mut active_operator,
|
||||||
|
OperatorPhase::Blocked,
|
||||||
|
"Terrain sculpt stopped: target no longer exists",
|
||||||
|
"Target no longer exists",
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -253,18 +273,8 @@ fn terrain_sculpt_input(
|
|||||||
let label = sculpt_history_label(state.mode);
|
let label = sculpt_history_label(state.mode);
|
||||||
let mode_label = state.mode.label();
|
let mode_label = state.mode.label();
|
||||||
commands.queue(move |world: &mut World| {
|
commands.queue(move |world: &mut World| {
|
||||||
let result = commit_terrain_stroke(world, stroke, final_terrain, label);
|
finish_terrain_stroke(world, stroke, final_terrain, label, mode_label);
|
||||||
if let Err(error) = result {
|
|
||||||
world.resource_mut::<SceneIo>().status =
|
|
||||||
format!("Terrain sculpt commit failed: {error}");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
scene_io.status = format!("{mode_label} terrain stroke committed");
|
|
||||||
set_status(
|
|
||||||
&mut active_operator,
|
|
||||||
OperatorPhase::Committed,
|
|
||||||
format!("{mode_label} stroke; Ctrl+Z to undo"),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -276,10 +286,11 @@ fn commit_terrain_stroke(
|
|||||||
final_terrain: TerrainDesc,
|
final_terrain: TerrainDesc,
|
||||||
label: &'static str,
|
label: &'static str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
let original = stroke.original;
|
||||||
if let Ok(mut actor) = world.get_entity_mut(stroke.entity) {
|
if let Ok(mut actor) = world.get_entity_mut(stroke.entity) {
|
||||||
actor.insert(stroke.original);
|
actor.insert(original.clone());
|
||||||
}
|
}
|
||||||
reflected_component_transaction(
|
let result = reflected_component_transaction(
|
||||||
world,
|
world,
|
||||||
stroke.entity,
|
stroke.entity,
|
||||||
label,
|
label,
|
||||||
@ -289,7 +300,82 @@ fn commit_terrain_stroke(
|
|||||||
world.entity_mut(entity).insert(final_terrain);
|
world.entity_mut(entity).insert(final_terrain);
|
||||||
Ok(())
|
Ok(())
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
if result.is_err() {
|
||||||
|
if let Ok(mut actor) = world.get_entity_mut(stroke.entity) {
|
||||||
|
actor.insert(original);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_terrain_stroke(
|
||||||
|
world: &mut World,
|
||||||
|
stroke: TerrainStroke,
|
||||||
|
final_terrain: TerrainDesc,
|
||||||
|
label: &'static str,
|
||||||
|
mode_label: &'static str,
|
||||||
|
) {
|
||||||
|
let undo_depth = world.resource::<EditorHistory>().undo_depth();
|
||||||
|
match commit_terrain_stroke(world, stroke, final_terrain, label) {
|
||||||
|
Ok(()) if world.resource::<EditorHistory>().undo_depth() > undo_depth => {
|
||||||
|
world.resource_mut::<SceneIo>().status =
|
||||||
|
format!("{mode_label} terrain stroke committed");
|
||||||
|
set_status(
|
||||||
|
&mut world.resource_mut::<ActiveOperator>(),
|
||||||
|
OperatorPhase::Committed,
|
||||||
|
format!("{mode_label} stroke; Ctrl+Z to undo"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(()) => {
|
||||||
|
world.resource_mut::<SceneIo>().status =
|
||||||
|
format!("{mode_label} terrain stroke made no changes");
|
||||||
|
set_status(
|
||||||
|
&mut world.resource_mut::<ActiveOperator>(),
|
||||||
|
OperatorPhase::Canceled,
|
||||||
|
format!("{mode_label} stroke made no changes"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
world.resource_mut::<SceneIo>().status =
|
||||||
|
format!("Terrain sculpt commit failed: {error}");
|
||||||
|
set_status(
|
||||||
|
&mut world.resource_mut::<ActiveOperator>(),
|
||||||
|
OperatorPhase::Blocked,
|
||||||
|
format!("Commit failed: {error}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel_active_stroke(
|
||||||
|
state: &mut TerrainSculptState,
|
||||||
|
terrains: &mut Query<(&GlobalTransform, &mut TerrainDesc)>,
|
||||||
|
scene_io: &mut SceneIo,
|
||||||
|
active_operator: &mut ActiveOperator,
|
||||||
|
) {
|
||||||
|
cancel_stroke(state, terrains);
|
||||||
|
scene_io.status = "Terrain sculpt stroke canceled".to_string();
|
||||||
|
set_status(
|
||||||
|
active_operator,
|
||||||
|
OperatorPhase::Canceled,
|
||||||
|
"Stroke canceled; terrain restored",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_tool_with_rollback(
|
||||||
|
state: &mut TerrainSculptState,
|
||||||
|
terrains: &mut Query<(&GlobalTransform, &mut TerrainDesc)>,
|
||||||
|
scene_io: &mut SceneIo,
|
||||||
|
active_operator: &mut ActiveOperator,
|
||||||
|
phase: OperatorPhase,
|
||||||
|
message: &str,
|
||||||
|
hint: &str,
|
||||||
|
) {
|
||||||
|
cancel_stroke(state, terrains);
|
||||||
|
state.stop();
|
||||||
|
scene_io.status = message.to_string();
|
||||||
|
set_status(active_operator, phase, hint);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cancel_stroke(
|
fn cancel_stroke(
|
||||||
@ -561,7 +647,8 @@ fn draw_terrain_sculpt_preview(state: Res<TerrainSculptState>, mut gizmos: Gizmo
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::history::{apply_command_redo, apply_command_undo, EditorHistory};
|
use crate::history::EditorHistory;
|
||||||
|
use crate::operators::test_harness::{assert_undo_redo_round_trip, OperatorInvariantHarness};
|
||||||
use shared::{ActorKind, LevelObject};
|
use shared::{ActorKind, LevelObject};
|
||||||
|
|
||||||
fn sample(terrain: &TerrainDesc, x: u32, z: u32) -> f32 {
|
fn sample(terrain: &TerrainDesc, x: u32, z: u32) -> f32 {
|
||||||
@ -657,8 +744,10 @@ mod tests {
|
|||||||
app.register_type::<ActorKind>()
|
app.register_type::<ActorKind>()
|
||||||
.register_type::<TerrainDesc>();
|
.register_type::<TerrainDesc>();
|
||||||
let world = app.world_mut();
|
let world = app.world_mut();
|
||||||
|
world.init_resource::<ActiveOperator>();
|
||||||
world.init_resource::<EditorHistory>();
|
world.init_resource::<EditorHistory>();
|
||||||
world.init_resource::<SceneIo>();
|
world.init_resource::<SceneIo>();
|
||||||
|
world.init_resource::<TerrainSculptState>();
|
||||||
let original = TerrainDesc::flat(9);
|
let original = TerrainDesc::flat(9);
|
||||||
let entity = world
|
let entity = world
|
||||||
.spawn((LevelObject, ActorKind::Terrain, original.clone()))
|
.spawn((LevelObject, ActorKind::Terrain, original.clone()))
|
||||||
@ -676,26 +765,126 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
world.entity_mut(entity).insert(final_terrain.clone());
|
world.entity_mut(entity).insert(final_terrain.clone());
|
||||||
commit_terrain_stroke(
|
let stroke = TerrainStroke {
|
||||||
|
entity,
|
||||||
|
original: original.clone(),
|
||||||
|
last_local: Vec3::ZERO,
|
||||||
|
flatten_height: 0.0,
|
||||||
|
seed: 7,
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let mut state = world.resource_mut::<TerrainSculptState>();
|
||||||
|
state.start(entity);
|
||||||
|
state.stroke = Some(stroke);
|
||||||
|
}
|
||||||
|
let stroke = world
|
||||||
|
.resource_mut::<TerrainSculptState>()
|
||||||
|
.stroke
|
||||||
|
.take()
|
||||||
|
.unwrap();
|
||||||
|
let harness = OperatorInvariantHarness::capture(world);
|
||||||
|
|
||||||
|
finish_terrain_stroke(
|
||||||
|
world,
|
||||||
|
stroke,
|
||||||
|
final_terrain.clone(),
|
||||||
|
"Raise Terrain",
|
||||||
|
"Raise",
|
||||||
|
);
|
||||||
|
|
||||||
|
harness.assert_committed(world, 1, 0);
|
||||||
|
harness.assert_status(world, "terrain.sculpt", OperatorPhase::Committed);
|
||||||
|
assert_eq!(world.get::<TerrainDesc>(entity), Some(&final_terrain));
|
||||||
|
let state = world.resource::<TerrainSculptState>();
|
||||||
|
assert!(state.active);
|
||||||
|
assert_eq!(state.target, Some(entity));
|
||||||
|
assert!(!state.is_stroking());
|
||||||
|
assert_undo_redo_round_trip(world, original, final_terrain, |world| {
|
||||||
|
world.get::<TerrainDesc>(entity).unwrap().clone()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_op_stroke_terminates_without_dirtying_or_history() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.register_type::<ActorKind>()
|
||||||
|
.register_type::<TerrainDesc>();
|
||||||
|
let world = app.world_mut();
|
||||||
|
world.init_resource::<ActiveOperator>();
|
||||||
|
world.init_resource::<EditorHistory>();
|
||||||
|
world.init_resource::<SceneIo>();
|
||||||
|
let original = TerrainDesc::flat(5);
|
||||||
|
let entity = world
|
||||||
|
.spawn((LevelObject, ActorKind::Terrain, original.clone()))
|
||||||
|
.id();
|
||||||
|
let harness = OperatorInvariantHarness::capture(world);
|
||||||
|
|
||||||
|
finish_terrain_stroke(
|
||||||
world,
|
world,
|
||||||
TerrainStroke {
|
TerrainStroke {
|
||||||
entity,
|
entity,
|
||||||
original: original.clone(),
|
original: original.clone(),
|
||||||
last_local: Vec3::ZERO,
|
last_local: Vec3::ZERO,
|
||||||
flatten_height: 0.0,
|
flatten_height: 0.0,
|
||||||
seed: 7,
|
seed: 1,
|
||||||
},
|
},
|
||||||
final_terrain.clone(),
|
original.clone(),
|
||||||
"Raise Terrain",
|
"Smooth Terrain",
|
||||||
)
|
"Smooth",
|
||||||
.unwrap();
|
);
|
||||||
|
|
||||||
assert_eq!(world.resource::<EditorHistory>().undo_depth(), 1);
|
harness.assert_canceled(world);
|
||||||
assert_eq!(world.get::<TerrainDesc>(entity), Some(&final_terrain));
|
harness.assert_status(world, "terrain.sculpt", OperatorPhase::Canceled);
|
||||||
apply_command_undo(world);
|
|
||||||
assert_eq!(world.get::<TerrainDesc>(entity), Some(&original));
|
assert_eq!(world.get::<TerrainDesc>(entity), Some(&original));
|
||||||
apply_command_redo(world);
|
}
|
||||||
assert_eq!(world.get::<TerrainDesc>(entity), Some(&final_terrain));
|
|
||||||
|
#[test]
|
||||||
|
fn commit_failure_restores_preview_and_reports_blocked_without_history() {
|
||||||
|
let mut app = App::new();
|
||||||
|
let world = app.world_mut();
|
||||||
|
world.init_resource::<ActiveOperator>();
|
||||||
|
world.init_resource::<EditorHistory>();
|
||||||
|
world.init_resource::<SceneIo>();
|
||||||
|
let original = TerrainDesc::flat(5);
|
||||||
|
let mut preview = original.clone();
|
||||||
|
apply_dab(
|
||||||
|
&mut preview,
|
||||||
|
Vec3::ZERO,
|
||||||
|
3.0,
|
||||||
|
0.8,
|
||||||
|
TerrainSculptMode::Raise,
|
||||||
|
0.0,
|
||||||
|
11,
|
||||||
|
);
|
||||||
|
let entity = world
|
||||||
|
.spawn((LevelObject, ActorKind::Terrain, preview.clone()))
|
||||||
|
.id();
|
||||||
|
let mut hierarchy = crate::ui::hierarchy_state::HierarchyPanelState::default();
|
||||||
|
hierarchy.locked.insert(entity);
|
||||||
|
world.insert_resource(hierarchy);
|
||||||
|
let harness = OperatorInvariantHarness::capture(world);
|
||||||
|
|
||||||
|
finish_terrain_stroke(
|
||||||
|
world,
|
||||||
|
TerrainStroke {
|
||||||
|
entity,
|
||||||
|
original: original.clone(),
|
||||||
|
last_local: Vec3::ZERO,
|
||||||
|
flatten_height: 0.0,
|
||||||
|
seed: 11,
|
||||||
|
},
|
||||||
|
preview,
|
||||||
|
"Raise Terrain",
|
||||||
|
"Raise",
|
||||||
|
);
|
||||||
|
|
||||||
|
harness.assert_blocked(world);
|
||||||
|
harness.assert_status(world, "terrain.sculpt", OperatorPhase::Blocked);
|
||||||
|
assert_eq!(world.get::<TerrainDesc>(entity), Some(&original));
|
||||||
|
assert!(world
|
||||||
|
.resource::<SceneIo>()
|
||||||
|
.status
|
||||||
|
.starts_with("Terrain sculpt commit failed:"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -703,11 +892,21 @@ mod tests {
|
|||||||
fn cancel_once(
|
fn cancel_once(
|
||||||
mut state: ResMut<TerrainSculptState>,
|
mut state: ResMut<TerrainSculptState>,
|
||||||
mut terrains: Query<(&GlobalTransform, &mut TerrainDesc)>,
|
mut terrains: Query<(&GlobalTransform, &mut TerrainDesc)>,
|
||||||
|
mut scene_io: ResMut<SceneIo>,
|
||||||
|
mut active_operator: ResMut<ActiveOperator>,
|
||||||
) {
|
) {
|
||||||
cancel_stroke(&mut state, &mut terrains);
|
cancel_active_stroke(
|
||||||
|
&mut state,
|
||||||
|
&mut terrains,
|
||||||
|
&mut scene_io,
|
||||||
|
&mut active_operator,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
|
app.init_resource::<ActiveOperator>()
|
||||||
|
.init_resource::<EditorHistory>()
|
||||||
|
.init_resource::<SceneIo>();
|
||||||
let original = TerrainDesc::flat(5);
|
let original = TerrainDesc::flat(5);
|
||||||
let mut preview = original.clone();
|
let mut preview = original.clone();
|
||||||
apply_dab(
|
apply_dab(
|
||||||
@ -721,7 +920,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let entity = app
|
let entity = app
|
||||||
.world_mut()
|
.world_mut()
|
||||||
.spawn((GlobalTransform::default(), preview))
|
.spawn((LevelObject, GlobalTransform::default(), preview))
|
||||||
.id();
|
.id();
|
||||||
let mut state = TerrainSculptState::default();
|
let mut state = TerrainSculptState::default();
|
||||||
state.start(entity);
|
state.start(entity);
|
||||||
@ -733,9 +932,74 @@ mod tests {
|
|||||||
seed: 11,
|
seed: 11,
|
||||||
});
|
});
|
||||||
app.insert_resource(state).add_systems(Update, cancel_once);
|
app.insert_resource(state).add_systems(Update, cancel_once);
|
||||||
|
let harness = OperatorInvariantHarness::capture(app.world_mut());
|
||||||
|
|
||||||
app.update();
|
app.update();
|
||||||
|
harness.assert_canceled(app.world_mut());
|
||||||
|
harness.assert_status(app.world(), "terrain.sculpt", OperatorPhase::Canceled);
|
||||||
assert_eq!(app.world().get::<TerrainDesc>(entity), Some(&original));
|
assert_eq!(app.world().get::<TerrainDesc>(entity), Some(&original));
|
||||||
assert!(!app.world().resource::<TerrainSculptState>().is_stroking());
|
let state = app.world().resource::<TerrainSculptState>();
|
||||||
|
assert!(state.active);
|
||||||
|
assert_eq!(state.target, Some(entity));
|
||||||
|
assert!(!state.is_stroking());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clean_view_interruption_restores_stroke_and_terminates_tool() {
|
||||||
|
fn interrupt_once(
|
||||||
|
mut state: ResMut<TerrainSculptState>,
|
||||||
|
mut terrains: Query<(&GlobalTransform, &mut TerrainDesc)>,
|
||||||
|
mut scene_io: ResMut<SceneIo>,
|
||||||
|
mut active_operator: ResMut<ActiveOperator>,
|
||||||
|
) {
|
||||||
|
stop_tool_with_rollback(
|
||||||
|
&mut state,
|
||||||
|
&mut terrains,
|
||||||
|
&mut scene_io,
|
||||||
|
&mut active_operator,
|
||||||
|
OperatorPhase::Canceled,
|
||||||
|
"Terrain sculpt stopped for clean game view",
|
||||||
|
"Clean game view enabled; terrain restored",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.init_resource::<ActiveOperator>()
|
||||||
|
.init_resource::<EditorHistory>()
|
||||||
|
.init_resource::<SceneIo>();
|
||||||
|
let original = TerrainDesc::flat(5);
|
||||||
|
let mut preview = original.clone();
|
||||||
|
apply_dab(
|
||||||
|
&mut preview,
|
||||||
|
Vec3::ZERO,
|
||||||
|
3.0,
|
||||||
|
0.8,
|
||||||
|
TerrainSculptMode::Raise,
|
||||||
|
0.0,
|
||||||
|
11,
|
||||||
|
);
|
||||||
|
let entity = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((LevelObject, GlobalTransform::default(), preview))
|
||||||
|
.id();
|
||||||
|
let mut state = TerrainSculptState::default();
|
||||||
|
state.start(entity);
|
||||||
|
state.stroke = Some(TerrainStroke {
|
||||||
|
entity,
|
||||||
|
original: original.clone(),
|
||||||
|
last_local: Vec3::ZERO,
|
||||||
|
flatten_height: 0.0,
|
||||||
|
seed: 11,
|
||||||
|
});
|
||||||
|
app.insert_resource(state)
|
||||||
|
.add_systems(Update, interrupt_once);
|
||||||
|
let harness = OperatorInvariantHarness::capture(app.world_mut());
|
||||||
|
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
harness.assert_canceled(app.world_mut());
|
||||||
|
harness.assert_status(app.world(), "terrain.sculpt", OperatorPhase::Canceled);
|
||||||
|
assert_eq!(app.world().get::<TerrainDesc>(entity), Some(&original));
|
||||||
|
assert!(!app.world().resource::<TerrainSculptState>().active);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -91,6 +91,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
|
|||||||
| [editor/evaluations/physics-placement/](editor/evaluations/physics-placement/) | Live screenshot and acceptance results for physics settling, cancel, commit, undo, and redo |
|
| [editor/evaluations/physics-placement/](editor/evaluations/physics-placement/) | Live screenshot and acceptance results for physics settling, cancel, commit, undo, and redo |
|
||||||
| [editor/evaluations/collider-diagnostics/](editor/evaluations/collider-diagnostics/) | Live screenshot and acceptance results for collider overlays, diagnostics, shape history, and placement preflight |
|
| [editor/evaluations/collider-diagnostics/](editor/evaluations/collider-diagnostics/) | Live screenshot and acceptance results for collider overlays, diagnostics, shape history, and placement preflight |
|
||||||
| [editor/evaluations/navigation-authoring/](editor/evaluations/navigation-authoring/) | Live screenshots and acceptance results for navigation authoring and runtime parity |
|
| [editor/evaluations/navigation-authoring/](editor/evaluations/navigation-authoring/) | Live screenshots and acceptance results for navigation authoring and runtime parity |
|
||||||
|
| [editor/evaluations/operator-invariants/](editor/evaluations/operator-invariants/) | Source acceptance results for production operator lifecycle, rollback, cleanup, and undo/redo invariants |
|
||||||
| [editor/evaluations/production-readiness/](editor/evaluations/production-readiness/) | Current versioned production gate, evidence matrix, candidate commands, soak protocol, and independent sign-off requirements |
|
| [editor/evaluations/production-readiness/](editor/evaluations/production-readiness/) | Current versioned production gate, evidence matrix, candidate commands, soak protocol, and independent sign-off requirements |
|
||||||
|
|
||||||
## Working plans (not canonical long-term)
|
## Working plans (not canonical long-term)
|
||||||
@ -114,6 +115,7 @@ Detailed milestone and feature plans live in [`.cursor/plans/`](../.cursor/plans
|
|||||||
| `production_readiness_acceptance_*.plan.md` | Release-candidate evidence matrix, blocker sequence, clean-checkout checks, soak, budgets, and independent sign-off |
|
| `production_readiness_acceptance_*.plan.md` | Release-candidate evidence matrix, blocker sequence, clean-checkout checks, soak, budgets, and independent sign-off |
|
||||||
| `material_library_and_targeted_drop_*.plan.md` | Dedicated Material Library, exact viewport slot/primitive/brush targeting, hover preview, cancel, and grouped history |
|
| `material_library_and_targeted_drop_*.plan.md` | Dedicated Material Library, exact viewport slot/primitive/brush targeting, hover preview, cancel, and grouped history |
|
||||||
| `terrain_material_layers_*.plan.md` | Terrain shared-material layers, normalized weights, blended hydration, and modal painting |
|
| `terrain_material_layers_*.plan.md` | Terrain shared-material layers, normalized weights, blended hydration, and modal painting |
|
||||||
|
| `operator_invariants_completion_*.plan.md` | Production operator dispatch, interruption, rollback, cleanup, and undo/redo acceptance |
|
||||||
|
|
||||||
## Crate responsibilities (quick reference)
|
## Crate responsibilities (quick reference)
|
||||||
|
|
||||||
|
|||||||
@ -39,6 +39,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
|
|||||||
| [evaluations/physics-placement/](evaluations/physics-placement/) | Live screenshot and verification record for multi-prop settling, cancel, commit, undo, and redo |
|
| [evaluations/physics-placement/](evaluations/physics-placement/) | Live screenshot and verification record for multi-prop settling, cancel, commit, undo, and redo |
|
||||||
| [evaluations/collider-diagnostics/](evaluations/collider-diagnostics/) | Live screenshot and verification record for semantic collider overlays, health linking, shape switching, and placement reuse |
|
| [evaluations/collider-diagnostics/](evaluations/collider-diagnostics/) | Live screenshot and verification record for semantic collider overlays, health linking, shape switching, and placement reuse |
|
||||||
| [evaluations/navigation-authoring/](evaluations/navigation-authoring/) | Live screenshots and verification record for navigation authoring and runtime parity |
|
| [evaluations/navigation-authoring/](evaluations/navigation-authoring/) | Live screenshots and verification record for navigation authoring and runtime parity |
|
||||||
|
| [evaluations/operator-invariants/](evaluations/operator-invariants/) | Source acceptance record for production operator lifecycle, rollback, cleanup, and undo/redo invariants |
|
||||||
| [evaluations/production-readiness/](evaluations/production-readiness/) | Current #50 release-candidate matrix and required clean-checkout, soak, performance, limitation, and independent-signoff evidence |
|
| [evaluations/production-readiness/](evaluations/production-readiness/) | Current #50 release-candidate matrix and required clean-checkout, soak, performance, limitation, and independent-signoff evidence |
|
||||||
|
|
||||||
## Subsystems (code → doc)
|
## Subsystems (code → doc)
|
||||||
|
|||||||
@ -245,13 +245,16 @@ the Edit-to-Play boundary restore the complete runtime snapshot. See
|
|||||||
|
|
||||||
Structural edits (spawn, delete, transform, rename) go through `EditorHistory` command objects in `history.rs`.
|
Structural edits (spawn, delete, transform, rename) go through `EditorHistory` command objects in `history.rs`.
|
||||||
User-facing actions route through the operator lifecycle in `operators.rs`: begin, preview,
|
User-facing actions route through the operator lifecycle in `operators.rs`: begin, preview,
|
||||||
commit, cancel, disabled reason, and status text. The first bridge wraps command-palette and queued
|
commit, cancel, disabled reason, stable ID, and status text. Registered commands return typed
|
||||||
editor commands as immediate operators, while existing `EditorHistory` commands remain the undo
|
`OperatorAction::Commit` or `OperatorAction::ContinuePreview`, so immediate commands finish while
|
||||||
payload. Asset placement, mesh-subasset placement, texture apply, and material apply actions now
|
Draw Brush and CSG retain modal Preview ownership; validation failures terminate instead of leaving
|
||||||
use immediate operators from `assets::operators`; texture/material changes across a selection use
|
stale status. Existing `EditorHistory` commands remain the undo payload. Group Selection is one
|
||||||
one `SetMaterialGroup` command. Draw Brush, brush CSG, clip, and element gizmo paths publish the
|
atomic command that remaps its transient group identity on every redo without reparenting unrelated
|
||||||
same preview/commit/cancel phases. `operators/test_harness.rs` verifies authored deltas, dirty
|
siblings. Lighting reset and Project Sun changes use one grouped light command. Asset placement,
|
||||||
state, helper cleanup, undo grouping, and undo/redo restoration; see
|
assignment, exact material drops, brush tools, terrain strokes, physics placement, and the real
|
||||||
|
transform tracker all publish terminal lifecycle state. `operators/test_harness.rs` verifies semantic
|
||||||
|
projections, dirty-state preservation, stable IDs, helper cleanup, undo grouping, repeated undo/redo,
|
||||||
|
and automatic interruption rollback; see
|
||||||
[operator-regression-testing.md](operator-regression-testing.md).
|
[operator-regression-testing.md](operator-regression-testing.md).
|
||||||
|
|
||||||
PIE stop restores player simulation state only; authored `LevelObject` edits made during PIE remain in the scene.
|
PIE stop restores player simulation state only; authored `LevelObject` edits made during PIE remain in the scene.
|
||||||
|
|||||||
@ -10,6 +10,7 @@ Living checklist for the production editor program ([ADR 0012](../adr/0012-zero-
|
|||||||
| Monolithic `hydrate_authoring_entities` | Done | `crates/shared/src/hydration/*` modules |
|
| Monolithic `hydrate_authoring_entities` | Done | `crates/shared/src/hydration/*` modules |
|
||||||
| Optional / inferred `ActorKind` as long-term save path | Done | Schema v2 + migration; save validates |
|
| Optional / inferred `ActorKind` as long-term save path | Done | Schema v2 + migration; save validates |
|
||||||
| Hydrated components in committed `.scn.ron` | CI | `scene::validate_scene_authoring_only` + `repo_editor_scene_has_no_hydrated_components` |
|
| Hydrated components in committed `.scn.ron` | CI | `scene::validate_scene_authoring_only` + `repo_editor_scene_has_no_hydrated_components` |
|
||||||
|
| Production mutation without commit/cancel/failure/undo invariants | Done | Shared operator harness plus typed history projections cover palette, asset, brush, terrain, physics, grouping, lighting, material-drop, and transform paths; see [evaluation](evaluations/operator-invariants/) |
|
||||||
| Dual FBX thumbnail ad-hoc path (parallel to unified pipeline) | Partial | Phase 5 `assets/thumbnails/` refactor |
|
| Dual FBX thumbnail ad-hoc path (parallel to unified pipeline) | Partial | Phase 5 `assets/thumbnails/` refactor |
|
||||||
| `failed_keys` thumbnail cache without retry API | Partial | `asset_thumbnails.rs`; Phase 5 `ThumbnailState` |
|
| `failed_keys` thumbnail cache without retry API | Partial | `asset_thumbnails.rs`; Phase 5 `ThumbnailState` |
|
||||||
|
|
||||||
@ -21,7 +22,7 @@ Living checklist for the production editor program ([ADR 0012](../adr/0012-zero-
|
|||||||
| P1 | `ActorKind` required; `validate_actor` on save |
|
| P1 | `ActorKind` required; `validate_actor` on save |
|
||||||
| P2 | No runtime components in default inspector |
|
| P2 | No runtime components in default inspector |
|
||||||
| P3 | `HierarchySiblingIndex`; no undefined sibling order |
|
| P3 | `HierarchySiblingIndex`; no undefined sibling order |
|
||||||
| P6 | `ActorInspectorSection` registry; command palette commands; BRP authoring policy |
|
| P6 | `ActorInspectorSection` registry; typed immediate/modal command dispatch; BRP authoring policy; production mutation invariants |
|
||||||
| P7 | ADRs 0009–0012 indexed; CI `shared` + `scene` + `editor` checks |
|
| P7 | ADRs 0009–0012 indexed; CI `shared` + `scene` + `editor` checks |
|
||||||
|
|
||||||
## Manual verification (release)
|
## Manual verification (release)
|
||||||
@ -29,3 +30,4 @@ Living checklist for the production editor program ([ADR 0012](../adr/0012-zero-
|
|||||||
- [ ] R1–R6 rendering/inspector scenarios ([roadmap.md](roadmap.md))
|
- [ ] R1–R6 rendering/inspector scenarios ([roadmap.md](roadmap.md))
|
||||||
- [ ] Save/load roundtrip: hierarchy, lights, materials unchanged
|
- [ ] Save/load roundtrip: hierarchy, lights, materials unchanged
|
||||||
- [ ] PIE 60s stop: project lighting profile unchanged unless scene overrides edited
|
- [ ] PIE 60s stop: project lighting profile unchanged unless scene overrides edited
|
||||||
|
- [ ] Rerun the [operator invariant matrix](operator-regression-testing.md) from the nominated release-candidate commit
|
||||||
|
|||||||
45
docs/editor/evaluations/operator-invariants/README.md
Normal file
45
docs/editor/evaluations/operator-invariants/README.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# Operator Invariants Evaluation
|
||||||
|
|
||||||
|
Date: 2026-07-12
|
||||||
|
|
||||||
|
Gitea issue:
|
||||||
|
[`#33`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/33)
|
||||||
|
|
||||||
|
This acceptance record covers the source-level production operator contract. It does not nominate a
|
||||||
|
release candidate or replace the clean-checkout rerun required by production-readiness gate G3.
|
||||||
|
|
||||||
|
## Accepted Coverage
|
||||||
|
|
||||||
|
- Registered commands distinguish immediate completion from modal Preview ownership; validation
|
||||||
|
failures terminate with their stable command ID.
|
||||||
|
- Group Selection is one history transaction and preserves unrelated hierarchy rows through repeated
|
||||||
|
undo/redo even though the transient group entity is respawned.
|
||||||
|
- Reset Lighting and Project Sun changes are grouped, undoable light transactions.
|
||||||
|
- Asset/sub-asset placement; material/texture/audio/animation assignment; and exact viewport material
|
||||||
|
drops cover commit, block/failure, cancel where applicable, and semantic undo/redo projections.
|
||||||
|
- Draw Brush, CSG, clip, and element-gizmo paths exercise production modal finalizers and helper
|
||||||
|
cleanup.
|
||||||
|
- Terrain sculpt/paint cover multi-dab commit, failure rollback, cancel, clean-view interruption, and
|
||||||
|
resource cleanup.
|
||||||
|
- Physics placement covers prerequisite block, exact cancel, real settle commit, multi-selection
|
||||||
|
grouping, helper cleanup, undo, and redo.
|
||||||
|
- The transform tracker covers continuous multi-target grouping and no-op release termination.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
The publication worktree passed:
|
||||||
|
|
||||||
|
- `cargo test -p editor --lib --no-fail-fast`: 245 passed, 0 failed.
|
||||||
|
- `cargo clippy -p editor --lib --tests -- -D warnings`.
|
||||||
|
- `cargo fmt --all -- --check`.
|
||||||
|
- `cargo test --workspace --all-targets --no-fail-fast`.
|
||||||
|
- `cargo clippy --workspace --all-targets -- -D warnings`.
|
||||||
|
- `cargo validate-levels --project .`: 62 dependencies, 5 expected non-blocking findings, 0 blocking
|
||||||
|
errors.
|
||||||
|
|
||||||
|
The Gitea closure comment records these results with the exact publication commit.
|
||||||
|
|
||||||
|
No screenshot is required for this ticket: the accepted surface is lifecycle, rollback, cleanup, and
|
||||||
|
history behavior exercised through headless production entry points. Live command/status checks add
|
||||||
|
confidence but are not substituted for semantic assertions. Packaged-runtime testing remains
|
||||||
|
deferred by project-owner request.
|
||||||
@ -32,12 +32,12 @@ another commit, a dirty worktree, or an older package do not transfer to the can
|
|||||||
|----|-------------|-------|--------------------------|
|
|----|-------------|-------|--------------------------|
|
||||||
| G1 | Project create/open/resume, scene authoring, autosave/recovery, hierarchy, prefab, and asset integrity pass | Partial | Project/recovery/session/multi-scene/prefab implementations are documented in [project launcher](../../project-launcher.md), [session recovery](../../session-recovery.md), [multi-scene composition](../../multi-scene-composition.md), and [prefab authoring](../../prefab-authoring.md). Collaborative file safety `#49` and non-blocking native dialogs `#52` passed live acceptance. Candidate-specific end-to-end reruns remain. |
|
| G1 | Project create/open/resume, scene authoring, autosave/recovery, hierarchy, prefab, and asset integrity pass | Partial | Project/recovery/session/multi-scene/prefab implementations are documented in [project launcher](../../project-launcher.md), [session recovery](../../session-recovery.md), [multi-scene composition](../../multi-scene-composition.md), and [prefab authoring](../../prefab-authoring.md). Collaborative file safety `#49` and non-blocking native dialogs `#52` passed live acceptance. Candidate-specific end-to-end reruns remain. |
|
||||||
| G2 | Brush, material, terrain, physics placement, animation, audio, navigation, PIE, and build/package samples pass | Fail | Animation, audio, navigation, renderer/material foundation `#51`, Material Library, targeted material drops, terrain `#22`-`#24`, physics placement `#25`, and build foundations are implemented with source/live fixtures. Brush acceptance `#37` remains incomplete. Packaged testing is owner-deferred. |
|
| G2 | Brush, material, terrain, physics placement, animation, audio, navigation, PIE, and build/package samples pass | Fail | Animation, audio, navigation, renderer/material foundation `#51`, Material Library, targeted material drops, terrain `#22`-`#24`, physics placement `#25`, and build foundations are implemented with source/live fixtures. Brush acceptance `#37` remains incomplete. Packaged testing is owner-deferred. |
|
||||||
| G3 | Undo/redo/cancel invariants and helper cleanup cover every production mutation path | Partial | The reusable harness and current tool coverage are documented in [operator regression testing](../../operator-regression-testing.md). Terrain stroke and physics settle/cancel/undo fixtures now exist; Gitea `#33` remains open for full mutation-path coverage. |
|
| G3 | Undo/redo/cancel invariants and helper cleanup cover every production mutation path | Partial | Source implementation and focused evidence are complete in [operator regression testing](../../operator-regression-testing.md) and the [operator-invariants evaluation](../operator-invariants/); Gitea `#33` is ready to close. A clean, exact-candidate rerun is still required for `Pass`. |
|
||||||
| G4 | Representative project completes an eight-hour soak without unbounded memory/target growth or unrecoverable failure | Missing | No candidate soak log, resource timeline, failure ledger, or target-growth measurement exists. |
|
| G4 | Representative project completes an eight-hour soak without unbounded memory/target growth or unrecoverable failure | Missing | No candidate soak log, resource timeline, failure ledger, or target-growth measurement exists. |
|
||||||
| G5 | Cold start, scene open/save, asset refresh, common manipulation, and package-build budgets are documented and measured | Missing | Gitea `#34` is open; no ratified budgets or candidate measurement record exists. |
|
| G5 | Cold start, scene open/save, asset refresh, common manipulation, and package-build budgets are documented and measured | Missing | Gitea `#34` is open; no ratified budgets or candidate measurement record exists. |
|
||||||
| G6 | Headless content validation and CI are green from a clean checkout | Missing | [CI configuration](../../../../.github/workflows/ci.yml) exists and local source/headless checks have passed during feature work, but no clean-checkout candidate run is linked. The current Gitea server does not expose an Actions run endpoint for this repository. |
|
| G6 | Headless content validation and CI are green from a clean checkout | Missing | [CI configuration](../../../../.github/workflows/ci.yml) exists and local source/headless checks have passed during feature work, but no clean-checkout candidate run is linked. The current Gitea server does not expose an Actions run endpoint for this repository. |
|
||||||
| G7 | First-hour UX and recovery QA are signed off by someone other than the implementer | External | No independent sign-off exists. Gitea `#36` remains open; historical H1-H6 implementation-pass notes do not count. |
|
| G7 | First-hour UX and recovery QA are signed off by someone other than the implementer | External | No independent sign-off exists. Gitea `#36` remains open; historical H1-H6 implementation-pass notes do not count. |
|
||||||
| G8 | Known limitations have severity/workaround and no P0 blocker remains | Fail | Limitations are distributed across feature docs rather than one candidate ledger. Open P0 work includes `#32` and `#33`; `#50` cannot close while a gate-relevant P0 remains. Renderer foundation `#51`, roadmap audit `#35`, Material Library `#16`, targeted drops `#18`, collaborative safety `#49`, and dialog responsiveness `#52` are closed. Property-block application `#53` is P1; dynamic deformed Solari geometry `#54` is a documented P2 limitation with Forward/raster fallback. |
|
| G8 | Known limitations have severity/workaround and no P0 blocker remains | Fail | Limitations are distributed across feature docs rather than one candidate ledger. The remaining open P0 implementation blocker is `#32`; `#50` cannot close while it remains. Operator invariants `#33`, renderer foundation `#51`, roadmap audit `#35`, Material Library `#16`, targeted drops `#18`, collaborative safety `#49`, and dialog responsiveness `#52` are complete. Property-block application `#53` is P1; dynamic deformed Solari geometry `#54` is a documented P2 limitation with Forward/raster fallback. |
|
||||||
|
|
||||||
## Deliverables
|
## Deliverables
|
||||||
|
|
||||||
|
|||||||
@ -11,11 +11,13 @@ Every production operator test must cover the paths it exposes:
|
|||||||
|
|
||||||
| Path | Required proof |
|
| Path | Required proof |
|
||||||
|------|----------------|
|
|------|----------------|
|
||||||
| Commit | Expected authored entity delta, exact undo-group delta, dirty state, committed phase |
|
| Commit | Expected authored projection, exact undo-group delta, dirty state, stable operator ID, committed phase |
|
||||||
| Cancel or commit failure | No authored entity delta, no new history, prior dirty state, canceled phase |
|
| Zero-history commit | Authored projection and prior dirty state remain unchanged |
|
||||||
|
| Cancel or commit failure | Exact authored projection restored, no new history, prior dirty state, stable operator ID, terminal canceled/blocked phase |
|
||||||
| Blocked start | Commit closure never runs; authored state, history, and dirty state are unchanged |
|
| Blocked start | Commit closure never runs; authored state, history, and dirty state are unchanged |
|
||||||
|
| Preview failure | Cancel finalizer runs, authored state is restored, and helpers are removed |
|
||||||
| Preview helpers | No helper component remains after commit or cancel |
|
| Preview helpers | No helper component remains after commit or cancel |
|
||||||
| Undoable commit | `assert_undo_redo_round_trip` restores both initial and committed projections |
|
| Undoable commit | `assert_undo_redo_round_trip` restores both initial and committed semantic projections |
|
||||||
| Continuous edit | The entire interaction creates one history command, not one per frame or target |
|
| Continuous edit | The entire interaction creates one history command, not one per frame or target |
|
||||||
|
|
||||||
The harness intentionally fails with the invariant name in the assertion message. Tests may add
|
The harness intentionally fails with the invariant name in the assertion message. Tests may add
|
||||||
@ -26,12 +28,12 @@ domain assertions, but should not replace these shared lifecycle checks.
|
|||||||
1. Build a minimal `World` with `ActiveOperator`, `EditorHistory`, and `SceneIo`. Add tool-specific
|
1. Build a minimal `World` with `ActiveOperator`, `EditorHistory`, and `SceneIo`. Add tool-specific
|
||||||
selection/resources and `SelectedEntity` when history helpers update selection.
|
selection/resources and `SelectedEntity` when history helpers update selection.
|
||||||
2. Capture `OperatorInvariantHarness` immediately before starting the operation.
|
2. Capture `OperatorInvariantHarness` immediately before starting the operation.
|
||||||
3. Run the real operator entry point or the same production commit/cancel function used by its
|
3. Run the real dispatch path or production finalizer used by the input system. Do not duplicate its
|
||||||
input system.
|
state transition in the test.
|
||||||
4. Call `assert_committed`, `assert_canceled`, or `assert_blocked` with the expected authored and
|
4. Call `assert_committed`, `assert_canceled`, or `assert_blocked` with the expected authored and
|
||||||
undo deltas.
|
undo deltas, then `assert_status` with the stable operator ID and terminal phase.
|
||||||
5. For helpers, call `assert_no_helpers::<ToolHelper>`. For undoable work, project the meaningful
|
5. For helpers, call `assert_no_helpers::<ToolHelper>`. For undoable work, project the meaningful
|
||||||
state through `assert_undo_redo_round_trip`.
|
state through `assert_undo_redo_round_trip`; use `assert_projection_unchanged` for no-op paths.
|
||||||
|
|
||||||
Do not satisfy a lifecycle test by constructing an `EditorCommand` and checking only its label.
|
Do not satisfy a lifecycle test by constructing an `EditorCommand` and checking only its label.
|
||||||
The test must mutate a world and prove restoration.
|
The test must mutate a world and prove restoration.
|
||||||
@ -40,15 +42,17 @@ The test must mutate a world and prove restoration.
|
|||||||
|
|
||||||
| Workflow | Coverage |
|
| Workflow | Coverage |
|
||||||
|----------|----------|
|
|----------|----------|
|
||||||
| Generic `EditorOperator` | Commit cleanup, commit failure rollback, blocked no-op |
|
| Generic `EditorOperator` | Commit cleanup, preview/commit failure rollback, blocked no-op, terminal ID/phase |
|
||||||
| Asset placement | Commit, dirty state, single undo, authored spawn round trip |
|
| Palette and registered commands | Typed immediate commit versus modal preview ownership; failed CSG start terminates |
|
||||||
| Sub-asset placement | Missing dependency commit failure leaves no side effects |
|
| Selection and scene commands | Atomic Group Selection across repeated undo/redo; grouped Reset Lighting and Project Sun history |
|
||||||
| Texture/material assignment | Multi-actor grouped undo; missing material failure; empty-selection block |
|
| Asset workflows | Asset/sub-asset placement; material/texture group assignment; audio/animation assignment and incompatible targets |
|
||||||
| Draw Brush | Full modal-state cancel; decomposed multi-brush grouped commit and round trip |
|
| Viewport material drop | Exact renderer slot/primitive/brush-face preview, cancel, commit, cleanup, undo, and redo |
|
||||||
| Brush CSG | Pending-preview cancel; merge commit/deletion grouped round trip |
|
| Draw Brush and CSG | Modal cancel; decomposed commit; CSG validation/read-only block, cancel, grouped commit, and deleted-brush restoration |
|
||||||
| Brush clip and element gizmo | Clip grouped geometry round trip; Escape rollback and helper cleanup |
|
| Brush clip and element gizmo | Production finalizers, geometry round trip, Escape rollback, and helper cleanup |
|
||||||
| Transform gizmo finalization | Multi-target continuous edit grouped into one undo command |
|
| Terrain sculpt and paint | Multi-dab grouped commit, no-op release, commit failure rollback, cancel, clean-view interruption, and resource cleanup |
|
||||||
|
| Physics placement | Prerequisite block, no-op commit, exact cancel, real settle commit, multi-selection grouping, helper cleanup, undo, and redo |
|
||||||
|
| Transform gizmo finalization | Actual tracker finalizer, continuous multi-target grouping, no-op release, and missing-primary survivor rollback |
|
||||||
|
|
||||||
Terrain strokes and physics-settle sessions are roadmap tools, not current production operators.
|
The shared harness covers formal operators and multi-frame modal paths. Atomic inspector and history
|
||||||
Their implementation tickets must add harness fixtures before those tools can pass their own exit
|
mutations that do not own `ActiveOperator` use focused typed history tests with equivalent semantic
|
||||||
gates; Gitea issue BS-JD-502 remains the umbrella until that coverage exists.
|
projection and undo/redo assertions.
|
||||||
|
|||||||
@ -134,6 +134,7 @@ Phased work for the in-process editor (`crates/editor`). Status reflects the cur
|
|||||||
| `ActorInspectorSection` registry | Done | `ext/extensibility.rs` + `game_inspector.rs` |
|
| `ActorInspectorSection` registry | Done | `ext/extensibility.rs` + `game_inspector.rs` |
|
||||||
| Command registry | Done | Play, lighting reset, group, focus |
|
| Command registry | Done | Play, lighting reset, group, focus |
|
||||||
| Command palette (Ctrl+P) | Done | Executes registered commands |
|
| Command palette (Ctrl+P) | Done | Executes registered commands |
|
||||||
|
| Typed operator dispatch and mutation invariants | Done | Immediate commands commit, modal commands retain Preview ownership, and production asset/brush/terrain/physics/history paths pass the shared lifecycle and undo contract ([testing guide](operator-regression-testing.md), [Gitea #33](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/33)) |
|
||||||
| `EditorPlugin` trait | Done | `register_editor_plugin`; dogfood panel from `game::editor_ext` |
|
| `EditorPlugin` trait | Done | `register_editor_plugin`; dogfood panel from `game::editor_ext` |
|
||||||
| Undo: `SetActorKind`, add/remove component | Done | `SetActorKind` + `AddComponent` / `RemoveComponent` |
|
| Undo: `SetActorKind`, add/remove component | Done | `SetActorKind` + `AddComponent` / `RemoveComponent` |
|
||||||
| BRP authoring-only policy | Done | [brp.md](brp.md) |
|
| BRP authoring-only policy | Done | [brp.md](brp.md) |
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user