9eead719b0
Full project snapshot migrated to new Gitea remote without history: engine, editor, physics, script, examples, tests, docs, and assets. Relicensed from GPLv3 to MIT and updated repo URLs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
416 lines
13 KiB
Rust
416 lines
13 KiB
Rust
//! Concrete editor commands that mutate the [`EditorState`](crate::state::EditorState).
|
|
//!
|
|
//! Routed through the [`CommandStack`](crate::command::CommandStack) so every
|
|
//! one is undoable through the Edit menu, `Ctrl+Z`/`Ctrl+Y`, and the same path
|
|
//! that future tools (transform gizmos, sculpt, paint) will use.
|
|
//!
|
|
//! Piece 6 ships the **first** commands so the undo plumbing is exercised
|
|
//! end-to-end:
|
|
//!
|
|
//! - [`SetTransformCmd`] — change an entity's local [`Transform`]. Consecutive
|
|
//! edits to the same entity coalesce via [`Command::merge`] so a slider drag
|
|
//! or a (future) gizmo drag becomes one undo entry.
|
|
//! - [`RenameCmd`] — rename an entity.
|
|
//!
|
|
//! Spawn/despawn aren't wired yet: round-tripping a despawn would need stable
|
|
//! entity ids across re-spawn (the scene reuses ids), which is a Stage-7
|
|
//! design step. The hierarchy panel still offers Add/Delete; they bypass the
|
|
//! stack today and are clearly labeled as "not undoable" in the shell.
|
|
|
|
use std::any::Any;
|
|
|
|
use oxide_engine::prelude::*;
|
|
|
|
use crate::command::Command;
|
|
use crate::state::EditorState;
|
|
|
|
/// Replaces the open UI document's panel wholesale (widget tree + sizes).
|
|
///
|
|
/// The UI canvas snapshots the panel before an edit and again after, so any
|
|
/// structural change (add / remove / move a widget) or property change goes
|
|
/// through one undoable command without per-operation bookkeeping. A panel is a
|
|
/// small data tree, so cloning it for the snapshots is cheap.
|
|
pub struct SetUiPanelCmd {
|
|
/// The panel before the edit.
|
|
pub before: UiPanel,
|
|
/// The panel after the edit.
|
|
pub after: UiPanel,
|
|
/// Human-readable description for the Edit menu.
|
|
pub label: String,
|
|
}
|
|
|
|
impl Command<EditorState> for SetUiPanelCmd {
|
|
fn apply(&mut self, state: &mut EditorState) {
|
|
if let Some(doc) = &mut state.ui_doc {
|
|
doc.panel = self.after.clone();
|
|
doc.dirty = true;
|
|
}
|
|
}
|
|
|
|
fn undo(&mut self, state: &mut EditorState) {
|
|
if let Some(doc) = &mut state.ui_doc {
|
|
doc.panel = self.before.clone();
|
|
doc.dirty = true;
|
|
}
|
|
}
|
|
|
|
fn label(&self) -> String {
|
|
self.label.clone()
|
|
}
|
|
|
|
fn as_any_mut(&mut self) -> &mut dyn Any {
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Replaces an entity's local [`Transform`]. Coalesces consecutive edits to
|
|
/// the same entity so an interactive drag is one undo entry.
|
|
pub struct SetTransformCmd {
|
|
pub entity: Entity,
|
|
/// The transform before the first apply — preserved through merges so
|
|
/// undo reverses the whole drag at once.
|
|
pub before: Transform,
|
|
/// The transform after the most recent apply.
|
|
pub after: Transform,
|
|
}
|
|
|
|
impl SetTransformCmd {
|
|
/// Builds the command, snapshotting the entity's current transform as the
|
|
/// pre-edit state. Returns `None` if the entity has no transform (e.g. it
|
|
/// was just despawned).
|
|
pub fn new(state: &EditorState, entity: Entity, after: Transform) -> Option<Self> {
|
|
let before = state.scene.local_transform(entity)?;
|
|
Some(Self {
|
|
entity,
|
|
before,
|
|
after,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Command<EditorState> for SetTransformCmd {
|
|
fn apply(&mut self, state: &mut EditorState) {
|
|
state.scene.set_local_transform(self.entity, self.after);
|
|
}
|
|
|
|
fn undo(&mut self, state: &mut EditorState) {
|
|
state.scene.set_local_transform(self.entity, self.before);
|
|
}
|
|
|
|
fn label(&self) -> String {
|
|
"Edit Transform".to_owned()
|
|
}
|
|
|
|
fn as_any_mut(&mut self) -> &mut dyn Any {
|
|
self
|
|
}
|
|
|
|
fn merge(&mut self, next: &mut dyn Command<EditorState>) -> bool {
|
|
let Some(next) = next.as_any_mut().downcast_mut::<SetTransformCmd>() else {
|
|
return false;
|
|
};
|
|
if next.entity != self.entity {
|
|
return false;
|
|
}
|
|
// Absorb `next` by extending our `after` while preserving `before`,
|
|
// so a long drag remains a single undo step.
|
|
self.after = next.after;
|
|
true
|
|
}
|
|
}
|
|
|
|
/// Sets a single **reflected field** of a component on an entity, addressed by
|
|
/// type name + field name and carried as RON.
|
|
///
|
|
/// This is the generic counterpart to [`SetTransformCmd`]: the
|
|
/// reflection-driven inspector emits one of these for *any* registered
|
|
/// component's field, so a new component type becomes undoably editable with no
|
|
/// new command type. Consecutive edits to the same `(entity, type, field)`
|
|
/// coalesce via [`Command::merge`], so dragging a value slider is one undo
|
|
/// entry.
|
|
pub struct SetFieldCmd {
|
|
pub entity: Entity,
|
|
/// The registered type name (e.g. `"Transform"`).
|
|
pub type_name: &'static str,
|
|
/// The reflected field name (e.g. `"translation"`).
|
|
pub field: &'static str,
|
|
/// The field's RON before the first apply — preserved through merges.
|
|
pub before: String,
|
|
/// The field's RON after the most recent apply.
|
|
pub after: String,
|
|
}
|
|
|
|
impl SetFieldCmd {
|
|
/// Builds the command, snapshotting the field's current RON as the
|
|
/// pre-edit state. Returns `None` if the field can't be read (unknown
|
|
/// type/field, or the entity lacks the component).
|
|
pub fn new(
|
|
state: &EditorState,
|
|
entity: Entity,
|
|
type_name: &'static str,
|
|
field: &'static str,
|
|
after: String,
|
|
) -> Option<Self> {
|
|
let before = state
|
|
.registry
|
|
.get_field(state.scene.world(), entity, type_name, field)
|
|
.ok()?;
|
|
Some(Self {
|
|
entity,
|
|
type_name,
|
|
field,
|
|
before,
|
|
after,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Command<EditorState> for SetFieldCmd {
|
|
fn apply(&mut self, state: &mut EditorState) {
|
|
// Disjoint borrows of EditorState: ®istry (receiver) + &mut scene
|
|
// (the world). A write only fails if the entity/component vanished
|
|
// between snapshot and apply, in which case there's nothing to do.
|
|
let _ = state.registry.set_field(
|
|
state.scene.world_mut(),
|
|
self.entity,
|
|
self.type_name,
|
|
self.field,
|
|
&self.after,
|
|
);
|
|
}
|
|
|
|
fn undo(&mut self, state: &mut EditorState) {
|
|
let _ = state.registry.set_field(
|
|
state.scene.world_mut(),
|
|
self.entity,
|
|
self.type_name,
|
|
self.field,
|
|
&self.before,
|
|
);
|
|
}
|
|
|
|
fn label(&self) -> String {
|
|
format!("Edit {}.{}", self.type_name, self.field)
|
|
}
|
|
|
|
fn as_any_mut(&mut self) -> &mut dyn Any {
|
|
self
|
|
}
|
|
|
|
fn merge(&mut self, next: &mut dyn Command<EditorState>) -> bool {
|
|
let Some(next) = next.as_any_mut().downcast_mut::<SetFieldCmd>() else {
|
|
return false;
|
|
};
|
|
// Only coalesce edits to the *same* field of the same component on the
|
|
// same entity; preserve `before` so undo reverses the whole drag.
|
|
if next.entity != self.entity
|
|
|| next.type_name != self.type_name
|
|
|| next.field != self.field
|
|
{
|
|
return false;
|
|
}
|
|
self.after = std::mem::take(&mut next.after);
|
|
true
|
|
}
|
|
}
|
|
|
|
/// Renames an entity.
|
|
pub struct RenameCmd {
|
|
pub entity: Entity,
|
|
pub before: String,
|
|
pub after: String,
|
|
}
|
|
|
|
impl RenameCmd {
|
|
/// Snapshots the entity's current name as the pre-edit state.
|
|
pub fn new(state: &EditorState, entity: Entity, after: String) -> Self {
|
|
let before = state.scene.name(entity).unwrap_or_default();
|
|
Self {
|
|
entity,
|
|
before,
|
|
after,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Command<EditorState> for RenameCmd {
|
|
fn apply(&mut self, state: &mut EditorState) {
|
|
state.scene.set_name(self.entity, self.after.clone());
|
|
}
|
|
|
|
fn undo(&mut self, state: &mut EditorState) {
|
|
state.scene.set_name(self.entity, self.before.clone());
|
|
}
|
|
|
|
fn label(&self) -> String {
|
|
format!("Rename to '{}'", self.after)
|
|
}
|
|
|
|
fn as_any_mut(&mut self) -> &mut dyn Any {
|
|
self
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::command::CommandStack;
|
|
|
|
fn state_with_entity() -> (EditorState, Entity) {
|
|
let mut state = EditorState::new();
|
|
let e = state
|
|
.scene
|
|
.spawn("alpha", Transform::from_translation(Vec3::ZERO));
|
|
(state, e)
|
|
}
|
|
|
|
#[test]
|
|
fn transform_undo_redo_round_trips() {
|
|
let (mut state, e) = state_with_entity();
|
|
let target = Transform::from_translation(Vec3::new(1.0, 2.0, 3.0));
|
|
let cmd = SetTransformCmd::new(&state, e, target).expect("transform present");
|
|
|
|
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
|
|
stack.push(cmd, &mut state);
|
|
assert_eq!(
|
|
state.scene.local_transform(e).unwrap().translation,
|
|
target.translation
|
|
);
|
|
|
|
assert!(stack.undo(&mut state).is_some());
|
|
assert_eq!(
|
|
state.scene.local_transform(e).unwrap().translation,
|
|
Vec3::ZERO
|
|
);
|
|
|
|
assert!(stack.redo(&mut state).is_some());
|
|
assert_eq!(
|
|
state.scene.local_transform(e).unwrap().translation,
|
|
target.translation
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn consecutive_transform_edits_coalesce_into_one_undo() {
|
|
// Mirrors the "interactive drag" case: dozens of per-frame edits, one
|
|
// undo step that returns to the pre-drag state.
|
|
let (mut state, e) = state_with_entity();
|
|
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
|
|
|
|
for step in 1..=5 {
|
|
let target = Transform::from_translation(Vec3::splat(step as f32));
|
|
let cmd = SetTransformCmd::new(&state, e, target).unwrap();
|
|
stack.push(cmd, &mut state);
|
|
}
|
|
assert_eq!(
|
|
state.scene.local_transform(e).unwrap().translation,
|
|
Vec3::splat(5.0)
|
|
);
|
|
|
|
// A single undo wipes the whole drag — that's the merge contract.
|
|
assert!(stack.undo(&mut state).is_some());
|
|
assert_eq!(
|
|
state.scene.local_transform(e).unwrap().translation,
|
|
Vec3::ZERO
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn set_field_undo_redo_round_trips() {
|
|
let (mut state, e) = state_with_entity();
|
|
let cmd = SetFieldCmd::new(
|
|
&state,
|
|
e,
|
|
"Transform",
|
|
"translation",
|
|
"(1.0,2.0,3.0)".into(),
|
|
)
|
|
.expect("transform field readable");
|
|
|
|
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
|
|
stack.push(cmd, &mut state);
|
|
assert_eq!(
|
|
state.scene.local_transform(e).unwrap().translation,
|
|
Vec3::new(1.0, 2.0, 3.0)
|
|
);
|
|
|
|
assert!(stack.undo(&mut state).is_some());
|
|
assert_eq!(
|
|
state.scene.local_transform(e).unwrap().translation,
|
|
Vec3::ZERO
|
|
);
|
|
|
|
assert!(stack.redo(&mut state).is_some());
|
|
assert_eq!(
|
|
state.scene.local_transform(e).unwrap().translation,
|
|
Vec3::new(1.0, 2.0, 3.0)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn consecutive_field_edits_to_same_field_coalesce() {
|
|
// A value-slider drag: many per-frame edits, one undo back to start.
|
|
let (mut state, e) = state_with_entity();
|
|
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
|
|
for step in 1..=5 {
|
|
let ron = format!("({0}.0,{0}.0,{0}.0)", step);
|
|
let cmd = SetFieldCmd::new(&state, e, "Transform", "translation", ron).unwrap();
|
|
stack.push(cmd, &mut state);
|
|
}
|
|
assert_eq!(
|
|
state.scene.local_transform(e).unwrap().translation,
|
|
Vec3::splat(5.0)
|
|
);
|
|
assert!(stack.undo(&mut state).is_some());
|
|
assert_eq!(
|
|
state.scene.local_transform(e).unwrap().translation,
|
|
Vec3::ZERO
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn set_field_edits_to_different_fields_do_not_coalesce() {
|
|
// Editing translation then scale must be two undo steps, not one.
|
|
let (mut state, e) = state_with_entity();
|
|
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
|
|
stack.push(
|
|
SetFieldCmd::new(
|
|
&state,
|
|
e,
|
|
"Transform",
|
|
"translation",
|
|
"(1.0,0.0,0.0)".into(),
|
|
)
|
|
.unwrap(),
|
|
&mut state,
|
|
);
|
|
stack.push(
|
|
SetFieldCmd::new(&state, e, "Transform", "scale", "(2.0,2.0,2.0)".into()).unwrap(),
|
|
&mut state,
|
|
);
|
|
// Undo reverses scale only.
|
|
assert!(stack.undo(&mut state).is_some());
|
|
let t = state.scene.local_transform(e).unwrap();
|
|
assert_eq!(t.scale, Vec3::ONE);
|
|
assert_eq!(t.translation, Vec3::new(1.0, 0.0, 0.0));
|
|
// A second undo reverses translation.
|
|
assert!(stack.undo(&mut state).is_some());
|
|
assert_eq!(
|
|
state.scene.local_transform(e).unwrap().translation,
|
|
Vec3::ZERO
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rename_undo_restores_previous_name() {
|
|
let (mut state, e) = state_with_entity();
|
|
let cmd = RenameCmd::new(&state, e, "beta".into());
|
|
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
|
|
stack.push(cmd, &mut state);
|
|
assert_eq!(state.scene.name(e).as_deref(), Some("beta"));
|
|
|
|
assert!(stack.undo(&mut state).is_some());
|
|
assert_eq!(state.scene.name(e).as_deref(), Some("alpha"));
|
|
}
|
|
}
|