use serde::{Deserialize, Serialize};
use crate::audio::AudioDef;
use crate::entity::{EntityPatch, WorldEntity};
use crate::identity::EntityId;
use crate::world::{CameraDef, EnvironmentDef};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct WorldEdit {
pub seq: u64,
pub op: EditOp,
pub inverse: EditOp,
pub timestamp_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum EditOp {
SpawnEntity { entity: WorldEntity },
DeleteEntity { id: EntityId },
ModifyEntity { id: EntityId, patch: EntityPatch },
SetEnvironment { env: EnvironmentDef },
SetCamera { camera: CameraDef },
SetAmbience { ambience: Vec<AmbienceLayerDef> },
SpawnAudioEmitter { name: String, audio: AudioDef },
RemoveAudioEmitter { name: String, audio: AudioDef },
Batch { ops: Vec<EditOp> },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct AmbienceLayerDef {
pub name: String,
pub source: crate::audio::AudioSource,
pub volume: f32,
}
impl EditOp {
pub fn compute_inverse_spawn(entity: &WorldEntity) -> EditOp {
EditOp::DeleteEntity { id: entity.id }
}
pub fn spawn(entity: WorldEntity) -> EditOp {
EditOp::SpawnEntity { entity }
}
pub fn delete(id: EntityId) -> EditOp {
EditOp::DeleteEntity { id }
}
pub fn modify(id: EntityId, patch: EntityPatch) -> EditOp {
EditOp::ModifyEntity { id, patch }
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct EditHistory {
pub edits: Vec<WorldEdit>,
pub cursor: usize,
}
impl EditHistory {
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, op: EditOp, inverse: EditOp, author: Option<String>) {
self.edits.truncate(self.cursor);
let seq = self.edits.len() as u64;
self.edits.push(WorldEdit {
seq,
op,
inverse,
timestamp_ms: 0, author,
});
self.cursor = self.edits.len();
}
pub fn undo(&mut self) -> Option<&EditOp> {
if self.cursor == 0 {
return None;
}
self.cursor -= 1;
Some(&self.edits[self.cursor].inverse)
}
pub fn redo(&mut self) -> Option<&EditOp> {
if self.cursor >= self.edits.len() {
return None;
}
let op = &self.edits[self.cursor].op;
self.cursor += 1;
Some(op)
}
pub fn undo_count(&self) -> usize {
self.cursor
}
pub fn redo_count(&self) -> usize {
self.edits.len() - self.cursor
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entity::WorldEntity;
#[test]
fn undo_redo_basic() {
let mut history = EditHistory::new();
let entity = WorldEntity::new(1, "cube");
let op = EditOp::spawn(entity.clone());
let inverse = EditOp::delete(entity.id);
history.push(op, inverse, None);
assert_eq!(history.undo_count(), 1);
assert_eq!(history.redo_count(), 0);
let undo_op = history.undo().unwrap();
assert!(matches!(undo_op, EditOp::DeleteEntity { .. }));
assert_eq!(history.undo_count(), 0);
assert_eq!(history.redo_count(), 1);
let redo_op = history.redo().unwrap();
assert!(matches!(redo_op, EditOp::SpawnEntity { .. }));
assert_eq!(history.undo_count(), 1);
assert_eq!(history.redo_count(), 0);
}
#[test]
fn new_edit_truncates_redo() {
let mut history = EditHistory::new();
for i in 0..2 {
let entity = WorldEntity::new(i, format!("e{i}"));
history.push(
EditOp::spawn(entity.clone()),
EditOp::delete(entity.id),
None,
);
}
history.undo();
assert_eq!(history.redo_count(), 1);
let entity = WorldEntity::new(99, "new");
history.push(
EditOp::spawn(entity.clone()),
EditOp::delete(entity.id),
None,
);
assert_eq!(history.redo_count(), 0);
assert_eq!(history.undo_count(), 2); }
#[test]
fn edit_op_set_environment_roundtrip() {
let op = EditOp::SetEnvironment {
env: crate::world::EnvironmentDef {
background_color: Some([0.1, 0.2, 0.3, 1.0]),
ambient_intensity: Some(0.5),
ambient_color: Some([1.0, 1.0, 0.9, 1.0]),
fog_density: Some(0.02),
fog_color: None,
extra: ::std::collections::BTreeMap::new(),
},
};
let json = serde_json::to_string(&op).unwrap();
let back: EditOp = serde_json::from_str(&json).unwrap();
assert!(matches!(back, EditOp::SetEnvironment { .. }));
}
#[test]
fn edit_op_set_camera_roundtrip() {
let op = EditOp::SetCamera {
camera: crate::world::CameraDef {
position: [10.0, 5.0, 10.0],
look_at: [0.0, 0.0, 0.0],
fov_degrees: 60.0,
},
};
let json = serde_json::to_string(&op).unwrap();
let back: EditOp = serde_json::from_str(&json).unwrap();
assert!(matches!(back, EditOp::SetCamera { .. }));
}
#[test]
fn edit_op_batch_roundtrip() {
let entity = WorldEntity::new(5, "test_batch");
let op = EditOp::Batch {
ops: vec![
EditOp::delete(EntityId(1)),
EditOp::spawn(entity),
EditOp::SetEnvironment {
env: crate::world::EnvironmentDef {
background_color: Some([0.0, 0.0, 0.0, 1.0]),
ambient_intensity: None,
ambient_color: None,
fog_density: None,
fog_color: None,
extra: ::std::collections::BTreeMap::new(),
},
},
],
};
let json = serde_json::to_string(&op).unwrap();
let back: EditOp = serde_json::from_str(&json).unwrap();
assert!(matches!(back, EditOp::Batch { ops } if ops.len() == 3));
}
#[test]
fn edit_op_modify_entity_roundtrip() {
let op = EditOp::ModifyEntity {
id: EntityId(42),
patch: EntityPatch {
name: Some(crate::identity::EntityName::new("renamed")),
shape: Some(Some(crate::shape::Shape::Sphere { radius: 3.0 })),
light: Some(None), ..Default::default()
},
};
let json = serde_json::to_string(&op).unwrap();
let back: EditOp = serde_json::from_str(&json).unwrap();
assert!(matches!(back, EditOp::ModifyEntity { id, .. } if id.0 == 42));
}
}