use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use crate::asset::MeshAssetRef;
use crate::audio::AudioDef;
use crate::behavior::BehaviorDef;
use crate::identity::{CreationId, EntityId, EntityName};
use crate::instance::InstanceOf;
use crate::light::LightDef;
use crate::material::MaterialDef;
use crate::modulation::ModulationDef;
use crate::shape::Shape;
use crate::spatial::ChunkCoord;
use crate::trigger::TriggerDef;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct WorldTransform {
#[serde(default)]
pub position: [f32; 3],
#[serde(default)]
pub rotation_degrees: [f32; 3],
#[serde(default = "default_scale")]
pub scale: [f32; 3],
#[serde(default = "default_true")]
pub visible: bool,
}
impl Default for WorldTransform {
fn default() -> Self {
Self {
position: [0.0, 0.0, 0.0],
rotation_degrees: [0.0, 0.0, 0.0],
scale: default_scale(),
visible: true,
}
}
}
fn default_scale() -> [f32; 3] {
[1.0, 1.0, 1.0]
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct WorldEntity {
pub id: EntityId,
pub name: EntityName,
#[cfg_attr(feature = "schema", schemars(default))]
pub transform: WorldTransform,
#[cfg_attr(feature = "schema", schemars(extend("x-entity-ref" = "bindable")))]
pub parent: Option<EntityId>,
pub chunk: Option<ChunkCoord>,
pub creation_id: Option<CreationId>,
pub shape: Option<Shape>,
pub material: Option<MaterialDef>,
pub light: Option<LightDef>,
#[cfg_attr(feature = "schema", schemars(default))]
pub behaviors: Vec<BehaviorDef>,
pub audio: Option<AudioDef>,
pub mesh_asset: Option<MeshAssetRef>,
#[cfg_attr(feature = "schema", schemars(default))]
pub modulations: Vec<ModulationDef>,
pub instance_of: Option<InstanceOf>,
#[cfg_attr(feature = "schema", schemars(default))]
pub triggers: Vec<TriggerDef>,
#[cfg_attr(feature = "schema", schemars(default))]
pub extra: BTreeMap<String, serde_json::Value>,
}
pub(crate) struct SomeRef<'a, T>(pub(crate) &'a Option<T>);
struct SomeRefBorrowed<'a, T>(Option<&'a T>);
impl<T: Serialize> Serialize for SomeRefBorrowed<'_, T> {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self.0 {
Some(value) => serializer.serialize_some(value),
None => serializer.serialize_none(),
}
}
}
impl<T: Serialize> Serialize for SomeRef<'_, T> {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self.0 {
Some(value) => serializer.serialize_some(value),
None => serializer.serialize_none(),
}
}
}
pub(crate) fn interned(key: &str) -> &'static str {
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
static TABLE: OnceLock<Mutex<HashMap<String, &'static str>>> = OnceLock::new();
let table = TABLE.get_or_init(|| Mutex::new(HashMap::new()));
let mut guard = table.lock().unwrap();
if let Some(existing) = guard.get(key) {
return existing;
}
let leaked: &'static str = Box::leak(key.to_string().into_boxed_str());
guard.insert(key.to_string(), leaked);
leaked
}
impl Serialize for WorldEntity {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
if self.extra.is_empty() {
let mut s = serializer.serialize_struct("WorldEntity", 3)?;
s.serialize_field("id", &self.id)?;
s.serialize_field("name", &self.name)?;
s.serialize_field("transform", &self.transform)?;
fn slot<'a, M, T>(
s: &mut M,
key: &'static str,
value: &'a Option<T>,
) -> Result<(), M::Error>
where
M: SerializeStruct,
T: Serialize + 'a,
{
s.serialize_field(key, &SomeRef(value))
}
slot(&mut s, "parent", &self.parent)?;
slot(&mut s, "chunk", &self.chunk)?;
slot(&mut s, "creation_id", &self.creation_id)?;
slot(&mut s, "shape", &self.shape)?;
slot(&mut s, "material", &self.material)?;
slot(&mut s, "light", &self.light)?;
if !self.behaviors.is_empty() {
s.serialize_field("behaviors", &self.behaviors)?;
}
slot(&mut s, "audio", &self.audio)?;
slot(&mut s, "mesh_asset", &self.mesh_asset)?;
if !self.modulations.is_empty() {
s.serialize_field("modulations", &self.modulations)?;
}
slot(&mut s, "instance_of", &self.instance_of)?;
if !self.triggers.is_empty() {
s.serialize_field("triggers", &self.triggers)?;
}
s.end()
} else {
self.serialize_map_form(serializer)
}
}
}
impl WorldEntity {
fn serialize_map_form<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut map = serializer.serialize_map(Some(3 + self.extra.len()))?;
map.serialize_entry("id", &self.id)?;
map.serialize_entry("name", &self.name)?;
map.serialize_entry("transform", &self.transform)?;
fn opt<M, T: Serialize>(
map: &mut M,
key: &'static str,
value: &Option<T>,
) -> Result<(), M::Error>
where
M: serde::ser::SerializeMap,
{
if value.is_some() {
map.serialize_entry(key, &SomeRef(value))?;
}
Ok(())
}
opt(&mut map, "parent", &self.parent)?;
opt(&mut map, "chunk", &self.chunk)?;
opt(&mut map, "creation_id", &self.creation_id)?;
opt(&mut map, "shape", &self.shape)?;
opt(&mut map, "material", &self.material)?;
opt(&mut map, "light", &self.light)?;
opt(&mut map, "audio", &self.audio)?;
opt(&mut map, "mesh_asset", &self.mesh_asset)?;
opt(&mut map, "instance_of", &self.instance_of)?;
if !self.behaviors.is_empty() {
map.serialize_entry("behaviors", &self.behaviors)?;
}
if !self.modulations.is_empty() {
map.serialize_entry("modulations", &self.modulations)?;
}
if !self.triggers.is_empty() {
map.serialize_entry("triggers", &self.triggers)?;
}
for (key, value) in &self.extra {
map.serialize_entry(interned(key), value)?;
}
map.end()
}
}
impl<'de> Deserialize<'de> for WorldEntity {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = WorldEntity;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a world entity")
}
fn visit_map<A: serde::de::MapAccess<'de>>(
self,
mut map: A,
) -> Result<WorldEntity, A::Error> {
let mut entity = WorldEntity::new(0, "");
entity.extra = BTreeMap::new();
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"id" => entity.id = map.next_value()?,
"name" => entity.name = map.next_value()?,
"transform" => entity.transform = map.next_value()?,
"parent" => entity.parent = map.next_value::<NullOr<_>>()?.into_option(),
"chunk" => entity.chunk = map.next_value::<NullOr<_>>()?.into_option(),
"creation_id" => {
entity.creation_id = map.next_value::<NullOr<_>>()?.into_option()
}
"shape" => entity.shape = map.next_value::<NullOr<_>>()?.into_option(),
"material" => {
entity.material = map.next_value::<NullOr<_>>()?.into_option()
}
"light" => entity.light = map.next_value::<NullOr<_>>()?.into_option(),
"behaviors" => entity.behaviors = map.next_value()?,
"audio" => entity.audio = map.next_value::<NullOr<_>>()?.into_option(),
"mesh_asset" => {
entity.mesh_asset = map.next_value::<NullOr<_>>()?.into_option()
}
"modulations" => entity.modulations = map.next_value()?,
"instance_of" => {
entity.instance_of = map.next_value::<NullOr<_>>()?.into_option()
}
"triggers" => entity.triggers = map.next_value()?,
_ => {
let value: serde_json::Value = map.next_value()?;
entity.extra.insert(key, value);
}
}
}
if entity.name.as_str().is_empty() {
return Err(serde::de::Error::custom("an entity needs a name"));
}
Ok(entity)
}
}
deserializer.deserialize_any(Visitor)
}
}
impl WorldEntity {
pub fn new(id: u64, name: impl Into<String>) -> Self {
Self {
id: EntityId(id),
name: EntityName::new(name),
transform: WorldTransform::default(),
parent: None,
chunk: None,
creation_id: None,
shape: None,
material: None,
light: None,
behaviors: Vec::new(),
audio: None,
mesh_asset: None,
modulations: Vec::new(),
instance_of: None,
triggers: Vec::new(),
extra: BTreeMap::new(),
}
}
pub fn with_shape(mut self, shape: Shape) -> Self {
self.shape = Some(shape);
self
}
pub fn with_material(mut self, material: MaterialDef) -> Self {
self.material = Some(material);
self
}
pub fn with_light(mut self, light: LightDef) -> Self {
self.light = Some(light);
self
}
pub fn with_modulation(mut self, modulation: ModulationDef) -> Self {
self.modulations.push(modulation);
self
}
pub fn with_behavior(mut self, behavior: BehaviorDef) -> Self {
self.behaviors.push(behavior);
self
}
pub fn with_audio(mut self, audio: AudioDef) -> Self {
self.audio = Some(audio);
self
}
pub fn at(mut self, position: [f32; 3]) -> Self {
self.transform.position = position;
self
}
}
#[derive(Debug, Clone, Default, PartialEq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct EntityPatch {
pub name: Option<EntityName>,
pub transform: Option<WorldTransform>,
#[cfg_attr(feature = "schema", schemars(extend("x-entity-ref" = "bindable")))]
pub parent: Option<Option<EntityId>>,
pub shape: Option<Option<Shape>>,
pub material: Option<Option<MaterialDef>>,
pub light: Option<Option<LightDef>>,
pub behaviors: Option<Vec<BehaviorDef>>,
pub audio: Option<Option<AudioDef>>,
pub mesh_asset: Option<Option<MeshAssetRef>>,
pub modulations: Option<Vec<ModulationDef>>,
pub instance_of: Option<Option<InstanceOf>>,
pub triggers: Option<Vec<TriggerDef>>,
#[cfg_attr(feature = "schema", schemars(default))]
pub extra: BTreeMap<String, Option<serde_json::Value>>,
}
impl Serialize for EntityPatch {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
fn slot<M, T>(
map: &mut M,
key: &'static str,
value: &Option<Option<T>>,
) -> Result<(), M::Error>
where
M: SerializeMap,
T: Serialize,
{
if let Some(inner) = value {
match inner {
Some(v) => map.serialize_entry(key, &SomeRefBorrowed(Some(v)))?,
None => map.serialize_entry(key, &SomeRefBorrowed(None::<&T>))?,
}
}
Ok(())
}
let set = [
self.name.is_some(),
self.transform.is_some(),
self.parent.is_some(),
self.shape.is_some(),
self.material.is_some(),
self.light.is_some(),
self.behaviors.is_some(),
self.audio.is_some(),
self.mesh_asset.is_some(),
self.modulations.is_some(),
self.instance_of.is_some(),
self.triggers.is_some(),
]
.into_iter()
.filter(|s| *s)
.count();
let mut map = serializer.serialize_map(Some(set + self.extra.len()))?;
if let Some(name) = &self.name {
map.serialize_entry("name", name)?;
}
if let Some(transform) = &self.transform {
map.serialize_entry("transform", transform)?;
}
slot(&mut map, "parent", &self.parent)?;
slot(&mut map, "shape", &self.shape)?;
slot(&mut map, "material", &self.material)?;
slot(&mut map, "light", &self.light)?;
slot(&mut map, "audio", &self.audio)?;
slot(&mut map, "mesh_asset", &self.mesh_asset)?;
slot(&mut map, "instance_of", &self.instance_of)?;
if let Some(behaviors) = &self.behaviors {
map.serialize_entry("behaviors", behaviors)?;
}
if let Some(modulations) = &self.modulations {
map.serialize_entry("modulations", modulations)?;
}
if let Some(triggers) = &self.triggers {
map.serialize_entry("triggers", triggers)?;
}
for (key, value) in &self.extra {
match value {
Some(value) => map.serialize_entry(key, value)?,
None => map.serialize_entry(key, &serde_json::Value::Null)?,
}
}
map.end()
}
}
enum NullOr<T> {
Null,
Value(T),
}
impl<'de, T: Deserialize<'de>> Deserialize<'de> for NullOr<T> {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct Visitor<T>(std::marker::PhantomData<T>);
impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for Visitor<T> {
type Value = NullOr<T>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a value or a clear")
}
fn visit_none<E: serde::de::Error>(self) -> Result<Self::Value, E> {
Ok(NullOr::Null)
}
fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
Ok(NullOr::Null)
}
fn visit_some<D: serde::Deserializer<'de>>(
self,
deserializer: D,
) -> Result<Self::Value, D::Error> {
T::deserialize(deserializer).map(NullOr::Value)
}
fn visit_bool<E: serde::de::Error>(self, v: bool) -> Result<Self::Value, E> {
T::deserialize(serde_json::Value::from(v))
.map(NullOr::Value)
.map_err(serde::de::Error::custom)
}
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Self::Value, E> {
T::deserialize(serde_json::Value::from(v))
.map(NullOr::Value)
.map_err(serde::de::Error::custom)
}
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Self::Value, E> {
T::deserialize(serde_json::Value::from(v))
.map(NullOr::Value)
.map_err(serde::de::Error::custom)
}
fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<Self::Value, E> {
T::deserialize(serde_json::Value::from(v))
.map(NullOr::Value)
.map_err(serde::de::Error::custom)
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
T::deserialize(serde_json::Value::from(v))
.map(NullOr::Value)
.map_err(serde::de::Error::custom)
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(
self,
seq: A,
) -> Result<Self::Value, A::Error> {
use serde::de::value::SeqAccessDeserializer;
T::deserialize(SeqAccessDeserializer::new(seq)).map(NullOr::Value)
}
fn visit_map<A: serde::de::MapAccess<'de>>(
self,
map: A,
) -> Result<Self::Value, A::Error> {
use serde::de::value::MapAccessDeserializer;
T::deserialize(MapAccessDeserializer::new(map)).map(NullOr::Value)
}
fn visit_enum<A: serde::de::EnumAccess<'de>>(
self,
data: A,
) -> Result<Self::Value, A::Error> {
use serde::de::value::EnumAccessDeserializer;
T::deserialize(EnumAccessDeserializer::new(data)).map(NullOr::Value)
}
}
deserializer.deserialize_any(Visitor(std::marker::PhantomData))
}
}
impl<'de> Deserialize<'de> for EntityPatch {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = EntityPatch;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("an entity patch")
}
fn visit_map<A: serde::de::MapAccess<'de>>(
self,
mut map: A,
) -> Result<EntityPatch, A::Error> {
let mut patch = EntityPatch::default();
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"name" => patch.name = Some(map.next_value()?),
"transform" => patch.transform = Some(map.next_value()?),
"parent" => patch.parent = map.next_value::<NullOr<_>>()?.into(),
"shape" => patch.shape = map.next_value::<NullOr<_>>()?.into(),
"material" => patch.material = map.next_value::<NullOr<_>>()?.into(),
"light" => patch.light = map.next_value::<NullOr<_>>()?.into(),
"audio" => patch.audio = map.next_value::<NullOr<_>>()?.into(),
"mesh_asset" => {
patch.mesh_asset = map.next_value::<NullOr<_>>()?.into();
}
"instance_of" => {
patch.instance_of = map.next_value::<NullOr<_>>()?.into();
}
"behaviors" => patch.behaviors = Some(map.next_value()?),
"modulations" => patch.modulations = Some(map.next_value()?),
"triggers" => patch.triggers = Some(map.next_value()?),
_ if key.starts_with("ext-") => {
let value: Option<serde_json::Value> = map.next_value()?;
patch.extra.insert(key, value);
}
_ => {
let _ = map.next_value::<serde::de::IgnoredAny>()?;
}
}
}
Ok(patch)
}
}
deserializer.deserialize_any(Visitor)
}
}
impl<T> From<NullOr<T>> for Option<Option<T>> {
fn from(value: NullOr<T>) -> Self {
match value {
NullOr::Null => Some(None),
NullOr::Value(value) => Some(Some(value)),
}
}
}
impl EntityPatch {
pub fn between(base: &WorldEntity, target: &WorldEntity) -> EntityPatch {
fn changed<T: Serialize + Clone>(a: &T, b: &T) -> Option<T> {
(!values_close(a, b)).then(|| b.clone())
}
EntityPatch {
name: None,
parent: None,
transform: changed(&base.transform, &target.transform),
shape: changed(&base.shape, &target.shape),
material: changed(&base.material, &target.material),
light: changed(&base.light, &target.light),
behaviors: changed(&base.behaviors, &target.behaviors),
audio: changed(&base.audio, &target.audio),
mesh_asset: changed(&base.mesh_asset, &target.mesh_asset),
modulations: changed(&base.modulations, &target.modulations),
instance_of: changed(&base.instance_of, &target.instance_of),
triggers: changed(&base.triggers, &target.triggers),
extra: diff_extra(&base.extra, &target.extra),
}
}
pub fn is_empty(&self) -> bool {
*self == EntityPatch::default()
}
pub fn apply(&self, entity: &mut WorldEntity) {
if let Some(ref name) = self.name {
entity.name = name.clone();
}
if let Some(ref transform) = self.transform {
entity.transform = transform.clone();
}
if let Some(ref parent) = self.parent {
entity.parent = *parent;
}
if let Some(ref shape) = self.shape {
entity.shape = shape.clone();
}
if let Some(ref material) = self.material {
entity.material = material.clone();
}
if let Some(ref light) = self.light {
entity.light = light.clone();
}
if let Some(ref behaviors) = self.behaviors {
entity.behaviors = behaviors.clone();
}
if let Some(ref audio) = self.audio {
entity.audio = audio.clone();
}
if let Some(ref mesh_asset) = self.mesh_asset {
entity.mesh_asset = mesh_asset.clone();
}
if let Some(ref modulations) = self.modulations {
entity.modulations = modulations.clone();
}
if let Some(ref instance_of) = self.instance_of {
entity.instance_of = instance_of.clone();
}
if let Some(ref triggers) = self.triggers {
entity.triggers = triggers.clone();
}
for (key, slot) in &self.extra {
match slot {
Some(value) => {
entity.extra.insert(key.clone(), value.clone());
}
None => {
entity.extra.remove(key);
}
}
}
}
}
fn diff_extra(
base: &BTreeMap<String, serde_json::Value>,
target: &BTreeMap<String, serde_json::Value>,
) -> BTreeMap<String, Option<serde_json::Value>> {
let mut diff = BTreeMap::new();
for key in base.keys().chain(target.keys()) {
if !key.starts_with("ext-") {
continue;
}
match target.get(key) {
Some(value) => {
if base.get(key) != Some(value) {
diff.insert(key.clone(), Some(value.clone()));
}
}
None => {
diff.insert(key.clone(), None);
}
}
}
diff
}
pub fn values_close<T: Serialize>(a: &T, b: &T) -> bool {
match (serde_json::to_value(a), serde_json::to_value(b)) {
(Ok(a), Ok(b)) => json_close(&a, &b),
_ => false,
}
}
fn json_close(a: &serde_json::Value, b: &serde_json::Value) -> bool {
use serde_json::Value;
match (a, b) {
(Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) {
(Some(x), Some(y)) => (x - y).abs() <= 1e-4 * x.abs().max(y.abs()).max(1.0),
_ => x == y,
},
(Value::Array(x), Value::Array(y)) => {
x.len() == y.len() && x.iter().zip(y).all(|(x, y)| json_close(x, y))
}
(Value::Object(x), Value::Object(y)) => {
x.len() == y.len()
&& x.iter()
.all(|(k, v)| y.get(k).is_some_and(|w| json_close(v, w)))
}
_ => a == b,
}
}
impl<T> NullOr<T> {
fn into_option(self) -> Option<T> {
match self {
NullOr::Value(v) => Some(v),
NullOr::Null => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audio::{AudioDef, AudioKind, AudioSource, Rolloff};
#[test]
fn patch_between_ignores_float_noise() {
let base = WorldEntity::new(1, "trunk")
.with_shape(Shape::Cylinder {
radius: 0.3,
height: 3.0,
})
.at([0.0, 1.5, 0.0]);
let mut target = base.clone();
target.name = EntityName::new("oak_1/trunk");
target.parent = Some(EntityId(9));
target.transform.position[1] = 1.500_001;
target.transform.rotation_degrees[1] = -0.000_02;
assert!(EntityPatch::between(&base, &target).is_empty());
target.shape = Some(Shape::Cylinder {
radius: 0.3,
height: 4.5,
});
let patch = EntityPatch::between(&base, &target);
assert_eq!(patch.shape, Some(target.shape.clone()));
assert!(patch.transform.is_none() && patch.name.is_none());
let mut rebuilt = base.clone();
patch.apply(&mut rebuilt);
assert_eq!(rebuilt.shape, target.shape);
}
#[test]
fn entity_builder() {
let e = WorldEntity::new(1, "campfire")
.at([5.0, 0.0, 3.0])
.with_shape(Shape::Cone {
radius: 0.5,
height: 1.0,
})
.with_light(LightDef {
light_type: crate::light::LightType::Point,
color: [1.0, 0.8, 0.3, 1.0],
intensity: 500.0,
direction: None,
shadows: true,
range: None,
outer_angle: None,
inner_angle: None,
})
.with_audio(AudioDef {
kind: AudioKind::Sfx,
source: AudioSource::Fire {
intensity: 0.8,
crackle: 0.5,
},
volume: 0.7,
radius: Some(15.0),
rolloff: Rolloff::InverseSquare,
});
assert_eq!(e.name.as_str(), "campfire");
assert!(e.shape.is_some());
assert!(e.light.is_some());
assert!(e.audio.is_some());
assert_eq!(e.transform.position, [5.0, 0.0, 3.0]);
}
#[test]
fn entity_roundtrip() {
let e = WorldEntity::new(42, "test_cube").with_shape(Shape::Cuboid {
x: 2.0,
y: 3.0,
z: 4.0,
});
let json = serde_json::to_string_pretty(&e).unwrap();
let back: WorldEntity = serde_json::from_str(&json).unwrap();
assert_eq!(e.id, back.id);
assert_eq!(e.name, back.name);
assert_eq!(e.shape, back.shape);
}
#[test]
fn entity_patch_apply() {
let mut e = WorldEntity::new(1, "box");
let patch = EntityPatch {
name: Some(EntityName::new("renamed_box")),
shape: Some(Some(Shape::Sphere { radius: 2.0 })),
..Default::default()
};
patch.apply(&mut e);
assert_eq!(e.name.as_str(), "renamed_box");
assert!(matches!(e.shape, Some(Shape::Sphere { radius }) if radius == 2.0));
}
#[test]
fn entity_patch_clear_field() {
let mut e = WorldEntity::new(1, "lit_box").with_light(LightDef::default());
assert!(e.light.is_some());
let patch = EntityPatch {
light: Some(None), ..Default::default()
};
patch.apply(&mut e);
assert!(e.light.is_none());
}
}