//! Schema-aware scene document seam over the current DynamicScene RON format. //! //! This module intentionally stays Bevy-free. The editor can keep loading and //! saving DynamicScene RON while tools start depending on stable actor/component //! document concepts instead of runtime ECS entity IDs. use std::collections::BTreeMap; use ron::ser::PrettyConfig; use serde::{Deserialize, Serialize}; use shared::{ActorId, PrefabInstance, PrefabRef, SceneComposition}; use crate::{ migrate_scene_text, read_schema_version, stamp_schema_version, strip_schema_version, validate_scene_authoring_only, }; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SceneDocument { pub schema_version: u32, pub entities: Vec, pub composition: Option, pub resource_types: Vec, pub prefab_instances: Vec, pub prefab_refs: Vec, normalized_ron: String, resources: BTreeMap, source_keys: BTreeMap, } impl SceneDocument { pub fn from_ron_text(text: &str) -> Result { let normalized = migrate_scene_text(text)?; validate_scene_authoring_only(&normalized)?; validate_hierarchy_references(&normalized)?; let body = strip_schema_version(&normalized)?; let parsed: DynamicSceneBody = ron::from_str(&body) .map_err(|err| format!("could not parse scene document body: {err}"))?; let (resources, mut raw_entities) = extract_raw_scene_values(&normalized)?; let composition = extract_scene_composition(&parsed.resources)?; let resource_types = parsed.resources.keys().cloned().collect(); let prefab_instances = extract_prefab_instances(&parsed.entities)?; let prefab_refs = extract_prefab_refs(&parsed.entities)?; let mut entities = Vec::with_capacity(parsed.entities.len()); let mut source_keys = BTreeMap::new(); for (index, (source_key, _entity)) in parsed.entities.into_iter().enumerate() { let components = raw_entities .remove(&source_key) .ok_or_else(|| format!("scene entity `{source_key}` has no lossless syntax node"))? .into_iter() .map(|(type_name, ron)| SceneComponentBlob::from_ron(type_name, ron)) .collect::, _>>()?; let actor_id = extract_actor_id(&components); let name = extract_name(&components); let document_id = actor_id .as_ref() .map(|id| SceneEntityId::ActorId(id.clone())) .unwrap_or_else(|| SceneEntityId::Anonymous(format!("anonymous:{index}"))); entities.push(SceneEntity { document_id: document_id.clone(), actor_id, name, parent_actor_id: None, components, }); source_keys.insert(document_id, source_key); } let key_to_actor: BTreeMap = entities .iter() .filter_map(|entity| { let source_key = source_keys.get(&entity.document_id).copied()?; Some((source_key, entity.actor_id.clone()?)) }) .collect(); for entity in &mut entities { entity.parent_actor_id = entity .components .iter() .find(|component| component.type_name == CHILD_OF_COMPONENT) .and_then(|component| parse_child_of_source_key(&component.ron)) .and_then(|source_key| key_to_actor.get(&source_key).cloned()); } Ok(Self { schema_version: read_schema_version(&normalized), entities, composition, resource_types, prefab_instances, prefab_refs, normalized_ron: normalized, resources, source_keys, }) } pub fn to_ron_text(&self) -> Result { let mut resources = self.resources.clone(); match &self.composition { Some(composition) => { let composition_ron = ron::to_string(composition) .map_err(|error| format!("could not encode scene composition: {error}"))?; resources.insert(SCENE_COMPOSITION_RESOURCE.to_string(), composition_ron); } None => { resources.remove(SCENE_COMPOSITION_RESOURCE); } } let mut used_keys: std::collections::HashSet = self.source_keys.values().copied().collect(); let mut next_key = used_keys .iter() .copied() .max() .unwrap_or(0) .saturating_add(1); let mut entities = BTreeMap::new(); for entity in &self.entities { let source_key = self .source_keys .get(&entity.document_id) .copied() .unwrap_or_else(|| { while used_keys.contains(&next_key) { next_key = next_key.saturating_add(1); } let assigned = next_key; used_keys.insert(assigned); next_key = next_key.saturating_add(1); assigned }); let components = entity .components .iter() .map(|component| { ron::from_str::(&component.ron).map_err(|error| { format!("invalid component RON `{}`: {error}", component.type_name) })?; Ok((component.type_name.clone(), component.ron.clone())) }) .collect::, String>>()?; entities.insert(source_key, components); } encode_lossless_scene_body(&resources, &entities) } pub fn normalized_ron(&self) -> &str { &self.normalized_ron } pub fn find_entity(&self, id: &SceneEntityId) -> Option<&SceneEntity> { self.entities .iter() .find(|entity| &entity.document_id == id) } pub fn apply_patch(&mut self, patch: ScenePatch) -> Result<(), String> { match patch { ScenePatch::SetComponent { target, component } => { let entity = self .find_entity_mut(&target) .ok_or_else(|| format!("scene entity `{target}` not found"))?; if let Some(existing) = entity .components .iter_mut() .find(|existing| existing.type_name == component.type_name) { *existing = component; } else { entity.components.push(component); } } ScenePatch::RemoveComponent { target, component_type, } => { let entity = self .find_entity_mut(&target) .ok_or_else(|| format!("scene entity `{target}` not found"))?; entity .components .retain(|component| component.type_name != component_type); } ScenePatch::AddEntity { entity } => { if self.find_entity(&entity.document_id).is_some() { return Err(format!( "scene entity `{}` already exists", entity.document_id )); } let next_key = self .source_keys .values() .copied() .max() .unwrap_or(0) .saturating_add(1); self.source_keys .insert(entity.document_id.clone(), next_key); self.entities.push(entity); } ScenePatch::DeleteEntity { target } => { let Some(target_actor_id) = self .find_entity(&target) .and_then(|entity| entity.actor_id.clone()) else { return Err(format!("scene entity `{target}` not found")); }; let mut removed_actor_ids = std::collections::HashSet::from([target_actor_id]); let mut changed = true; while changed { changed = false; for entity in &self.entities { if entity .parent_actor_id .as_ref() .is_some_and(|parent| removed_actor_ids.contains(parent)) { if let Some(actor_id) = entity.actor_id.clone() { changed |= removed_actor_ids.insert(actor_id); } } } } let removed_ids: Vec<_> = self .entities .iter() .filter(|entity| { entity .actor_id .as_ref() .is_some_and(|actor_id| removed_actor_ids.contains(actor_id)) }) .map(|entity| entity.document_id.clone()) .collect(); self.entities .retain(|entity| !removed_ids.contains(&entity.document_id)); for removed in removed_ids { self.source_keys.remove(&removed); } } ScenePatch::Reparent { target, parent_actor_id, } => { let parent_source_key = parent_actor_id .as_ref() .map(|parent| { self.source_keys .get(&SceneEntityId::ActorId(parent.clone())) .copied() .ok_or_else(|| format!("parent actor `{parent}` not found")) }) .transpose()?; let entity = self .find_entity_mut(&target) .ok_or_else(|| format!("scene entity `{target}` not found"))?; match parent_source_key { Some(parent_source_key) => { let component = SceneComponentBlob::from_ron( CHILD_OF_COMPONENT, format!("({parent_source_key})"), )?; if let Some(existing) = entity .components .iter_mut() .find(|component| component.type_name == CHILD_OF_COMPONENT) { *existing = component; } else { entity.components.push(component); } } None => entity .components .retain(|component| component.type_name != CHILD_OF_COMPONENT), } entity.parent_actor_id = parent_actor_id; } ScenePatch::Rename { target, name } => { let entity = self .find_entity_mut(&target) .ok_or_else(|| format!("scene entity `{target}` not found"))?; let component = SceneComponentBlob::from_serializable(NAME_COMPONENT, &name)?; if let Some(existing) = entity .components .iter_mut() .find(|existing| existing.type_name == NAME_COMPONENT) { *existing = component; } else { entity.components.push(component); } entity.name = Some(name); } ScenePatch::SetTransform { target, transform } => { let entity = self .find_entity_mut(&target) .ok_or_else(|| format!("scene entity `{target}` not found"))?; let component = SceneComponentBlob::from_serializable(TRANSFORM_COMPONENT, &transform)?; if let Some(existing) = entity .components .iter_mut() .find(|existing| existing.type_name == TRANSFORM_COMPONENT) { *existing = component; } else { entity.components.push(component); } } } Ok(()) } fn find_entity_mut(&mut self, id: &SceneEntityId) -> Option<&mut SceneEntity> { self.entities .iter_mut() .find(|entity| &entity.document_id == id) } } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum SceneEntityId { ActorId(String), Anonymous(String), } impl std::fmt::Display for SceneEntityId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::ActorId(id) => write!(f, "actor:{id}"), Self::Anonymous(id) => write!(f, "{id}"), } } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SceneEntity { pub document_id: SceneEntityId, pub actor_id: Option, pub name: Option, pub parent_actor_id: Option, pub components: Vec, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SceneComponentBlob { pub type_name: String, pub ron: String, } impl SceneComponentBlob { pub fn from_serializable( type_name: impl Into, value: &impl Serialize, ) -> Result { let pretty = PrettyConfig::new(); let ron = ron::ser::to_string_pretty(value, pretty) .map_err(|err| format!("could not serialize component blob: {err}"))?; Ok(Self { type_name: type_name.into(), ron, }) } pub fn from_ron(type_name: impl Into, ron: impl Into) -> Result { let type_name = type_name.into(); let ron = ron.into(); ron::from_str::(&ron) .map_err(|error| format!("invalid component RON `{type_name}`: {error}"))?; Ok(Self { type_name, ron }) } pub fn to_value(&self) -> Result { ron::from_str(&self.ron) .map_err(|error| format!("invalid component RON `{}`: {error}", self.type_name)) } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum ScenePatch { SetComponent { target: SceneEntityId, component: SceneComponentBlob, }, RemoveComponent { target: SceneEntityId, component_type: String, }, AddEntity { entity: SceneEntity, }, DeleteEntity { target: SceneEntityId, }, Reparent { target: SceneEntityId, parent_actor_id: Option, }, Rename { target: SceneEntityId, name: String, }, SetTransform { target: SceneEntityId, transform: SceneTransform, }, } #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct SceneTransform { pub translation: [f32; 3], pub rotation: [f32; 4], pub scale: [f32; 3], } impl Default for SceneTransform { fn default() -> Self { Self { translation: [0.0, 0.0, 0.0], rotation: [0.0, 0.0, 0.0, 1.0], scale: [1.0, 1.0, 1.0], } } } #[derive(Debug, Clone, Serialize, Deserialize)] struct DynamicSceneBody { #[serde(default)] resources: BTreeMap, #[serde(default)] entities: BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize)] struct DynamicSceneEntity { #[serde(default)] components: BTreeMap, } const ACTOR_ID_COMPONENT: &str = "shared::components::ActorId"; const CHILD_OF_COMPONENT: &str = "bevy_ecs::hierarchy::ChildOf"; const NAME_COMPONENT: &str = "bevy_ecs::name::Name"; const SCENE_COMPOSITION_RESOURCE: &str = "shared::components::SceneComposition"; const PREFAB_INSTANCE_COMPONENT: &str = "shared::components::PrefabInstance"; const PREFAB_REF_COMPONENT: &str = "shared::components::PrefabRef"; pub const TRANSFORM_COMPONENT: &str = "bevy_transform::components::transform::Transform"; type RawSceneEntities = BTreeMap>; fn extract_raw_scene_values( text: &str, ) -> Result<(BTreeMap, RawSceneEntities), String> { let file = ron_edit::File::try_from(text) .map_err(|error| format!("could not parse lossless scene syntax: {error}"))?; let root = as_struct(&file.value.content, "scene root")?; let resources = as_map(struct_field(root, "resources")?, "scene resources")?; let entities = as_map(struct_field(root, "entities")?, "scene entities")?; let resources = resources .0 .values .iter() .map(|entry| { Ok(( parse_string_key(&entry.content.key)?, entry.content.value.content.to_string(), )) }) .collect::, String>>()?; let entities = entities .0 .values .iter() .map(|entry| { let source_key = entry .content .key .to_string() .parse::() .map_err(|error| format!("invalid scene entity key: {error}"))?; let entity = as_struct(&entry.content.value.content, "scene entity")?; let components = as_map(struct_field(entity, "components")?, "entity components")?; let components = components .0 .values .iter() .map(|component| { Ok(( parse_string_key(&component.content.key)?, component.content.value.content.to_string(), )) }) .collect::, String>>()?; Ok((source_key, components)) }) .collect::, String>>()?; Ok((resources, entities)) } fn as_struct<'a, 's>( value: &'a ron_edit::Value<'s>, label: &str, ) -> Result<&'a ron_edit::Struct<'s>, String> { match value { ron_edit::Value::Struct(value) => Ok(value), _ => Err(format!("{label} must be a RON struct")), } } fn as_map<'a, 's>( value: &'a ron_edit::Value<'s>, label: &str, ) -> Result<&'a ron_edit::Map<'s>, String> { match value { ron_edit::Value::Map(value) => Ok(value), _ => Err(format!("{label} must be a RON map")), } } fn struct_field<'a, 's>( value: &'a ron_edit::Struct<'s>, name: &str, ) -> Result<&'a ron_edit::Value<'s>, String> { value .fields .values .iter() .find(|field| field.content.key == name) .map(|field| &field.content.value.content) .ok_or_else(|| format!("RON struct is missing `{name}`")) } fn parse_string_key(value: &ron_edit::Value<'_>) -> Result { ron::from_str(&value.to_string()).map_err(|error| format!("invalid string map key: {error}")) } fn parse_child_of_source_key(text: &str) -> Option { let value = ron::from_str::(text).ok()?; parse_child_of_source_key_value(&value) } fn parse_child_of_source_key_value(value: &ron::Value) -> Option { let ron::Value::Seq(values) = value else { return None; }; let ron::Value::Number(ron::value::Number::Integer(value)) = values.first()? else { return None; }; u64::try_from(*value).ok() } pub(crate) fn validate_hierarchy_references(text: &str) -> Result<(), String> { let body = strip_schema_version(text)?; let parsed: DynamicSceneBody = ron::from_str(&body) .map_err(|error| format!("could not parse scene hierarchy: {error}"))?; let mut parents = BTreeMap::new(); for (entity_key, entity) in &parsed.entities { let Some(value) = entity.components.get(CHILD_OF_COMPONENT) else { continue; }; let parent_key = parse_child_of_source_key_value(value).ok_or_else(|| { format!("scene entity `{entity_key}` has a malformed `{CHILD_OF_COMPONENT}` value") })?; if !parsed.entities.contains_key(&parent_key) { return Err(format!( "scene entity `{entity_key}` references missing hierarchy parent `{parent_key}`" )); } parents.insert(*entity_key, parent_key); } for start in parents.keys().copied() { let mut visited = std::collections::BTreeSet::new(); let mut current = start; while let Some(parent) = parents.get(¤t).copied() { if !visited.insert(current) { return Err(format!( "scene hierarchy contains a cycle involving entity `{current}`" )); } current = parent; } } Ok(()) } fn encode_lossless_scene_body( resources: &BTreeMap, entities: &BTreeMap>, ) -> Result { let mut body = String::from("(resources: {\n"); for (type_name, value) in resources { ron::from_str::(value) .map_err(|error| format!("invalid resource RON `{type_name}`: {error}"))?; body.push_str(" "); body.push_str( &ron::to_string(type_name) .map_err(|error| format!("could not encode resource type: {error}"))?, ); body.push_str(": "); body.push_str(value); body.push_str(",\n"); } body.push_str(" },\n entities: {\n"); for (source_key, components) in entities { body.push_str(&format!(" {source_key}: (components: {{\n")); for (type_name, value) in components { body.push_str(" "); body.push_str( &ron::to_string(type_name) .map_err(|error| format!("could not encode component type: {error}"))?, ); body.push_str(": "); body.push_str(value); body.push_str(",\n"); } body.push_str(" }),\n"); } body.push_str(" },\n)"); stamp_schema_version(&body) } fn extract_scene_composition( resources: &BTreeMap, ) -> Result, String> { let mut matches = resources .iter() .filter(|(type_name, _)| type_name.as_str() == SCENE_COMPOSITION_RESOURCE); let Some((_type_name, value)) = matches.next() else { return Ok(None); }; if matches.next().is_some() { return Err("scene contains multiple composition resources".to_string()); } value .clone() .into_rust::() .map(Some) .map_err(|error| format!("could not parse scene composition: {error}")) } fn extract_prefab_instances( entities: &BTreeMap, ) -> Result, String> { entities .values() .filter_map(|entity| entity.components.get(PREFAB_INSTANCE_COMPONENT)) .map(|value| { value .clone() .into_rust::() .map_err(|error| format!("could not parse prefab instance: {error}")) }) .collect() } fn extract_prefab_refs( entities: &BTreeMap, ) -> Result, String> { entities .values() .filter_map(|entity| entity.components.get(PREFAB_REF_COMPONENT)) .map(|value| { value .clone() .into_rust::() .map_err(|error| format!("could not parse prefab reference: {error}")) }) .collect() } fn extract_name(components: &[SceneComponentBlob]) -> Option { components .iter() .find(|component| component.type_name == NAME_COMPONENT) .map(|component| component.ron.trim().trim_matches('"').to_string()) .filter(|name| !name.is_empty()) } fn extract_actor_id(components: &[SceneComponentBlob]) -> Option { let component = components .iter() .find(|component| component.type_name == ACTOR_ID_COMPONENT)?; ron::from_str::(&component.ron) .ok() .map(|id| id.0) .or_else(|| ron::from_str::(&component.ron).ok()) .or_else(|| { ron::from_str::>(&component.ron) .ok() .and_then(|values| values.into_iter().next()) }) .filter(|value| !value.trim().is_empty()) } #[cfg(test)] mod tests { use super::*; #[test] fn lossless_parser_accepts_anonymous_structs_and_tuples() { let value = "(translation: (1.0, 2.0, 3.0), scale: (1.0, 1.0, 1.0))"; assert!(ron_edit::File::try_from(value).is_ok()); } #[test] fn parses_scene_document_without_exposing_runtime_entity_ids() { let text = r#"(schema_version: 2, resources: {}, entities: { 4294967133: ( components: { "bevy_ecs::name::Name": "Crate", "shared::components::ActorId": ("crate-01"), "shared::components::LevelObject": (), }, ), }, )"#; let document = SceneDocument::from_ron_text(text).unwrap(); assert_eq!(document.schema_version, crate::CURRENT_SCENE_SCHEMA_VERSION); assert_eq!(document.entities.len(), 1); assert_eq!(document.entities[0].actor_id.as_deref(), Some("crate-01")); assert_eq!(document.entities[0].name.as_deref(), Some("Crate")); assert_eq!( document.entities[0].document_id, SceneEntityId::ActorId("crate-01".into()) ); assert!(!format!("{:?}", document.entities[0].document_id).contains("4294967133")); } #[test] fn round_trips_normalized_ron_text() { let body = "(resources: {}, entities: {})"; let document = SceneDocument::from_ron_text(body).unwrap(); let text = document.to_ron_text().unwrap(); assert!(text.starts_with("(schema_version: 2,")); assert!(SceneDocument::from_ron_text(&text) .unwrap() .entities .is_empty()); } #[test] fn patch_rename_and_transform_are_representable() { let text = r#"(schema_version: 2, resources: {}, entities: { 1: ( components: { "shared::components::ActorId": ("actor-a"), "shared::components::LevelObject": (), }, ), }, )"#; let mut document = SceneDocument::from_ron_text(text).unwrap(); let target = SceneEntityId::ActorId("actor-a".into()); document .apply_patch(ScenePatch::Rename { target: target.clone(), name: "Renamed".into(), }) .unwrap(); document .apply_patch(ScenePatch::SetTransform { target: target.clone(), transform: SceneTransform { translation: [1.0, 2.0, 3.0], ..Default::default() }, }) .unwrap(); let entity = document.find_entity(&target).unwrap(); assert_eq!(entity.name.as_deref(), Some("Renamed")); assert!(entity .components .iter() .any(|component| component.type_name == TRANSFORM_COMPONENT)); let reparsed = SceneDocument::from_ron_text(&document.to_ron_text().unwrap()).unwrap(); let entity = reparsed.find_entity(&target).unwrap(); assert_eq!(entity.name.as_deref(), Some("Renamed")); let transform = entity .components .iter() .find(|component| component.type_name == TRANSFORM_COMPONENT) .unwrap(); assert!(transform.ron.contains("1.0")); } }