Blacksite/crates/scene/src/document.rs

802 lines
28 KiB
Rust

//! 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<SceneEntity>,
pub composition: Option<SceneComposition>,
pub resource_types: Vec<String>,
pub prefab_instances: Vec<PrefabInstance>,
pub prefab_refs: Vec<PrefabRef>,
normalized_ron: String,
resources: BTreeMap<String, String>,
source_keys: BTreeMap<SceneEntityId, u64>,
}
impl SceneDocument {
pub fn from_ron_text(text: &str) -> Result<Self, String> {
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::<Result<Vec<_>, _>>()?;
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<u64, String> = 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<String, String> {
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<u64> =
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::<ron::Value>(&component.ron).map_err(|error| {
format!("invalid component RON `{}`: {error}", component.type_name)
})?;
Ok((component.type_name.clone(), component.ron.clone()))
})
.collect::<Result<BTreeMap<_, _>, 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<String>,
pub name: Option<String>,
pub parent_actor_id: Option<String>,
pub components: Vec<SceneComponentBlob>,
}
#[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<String>,
value: &impl Serialize,
) -> Result<Self, String> {
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<String>, ron: impl Into<String>) -> Result<Self, String> {
let type_name = type_name.into();
let ron = ron.into();
ron::from_str::<ron::Value>(&ron)
.map_err(|error| format!("invalid component RON `{type_name}`: {error}"))?;
Ok(Self { type_name, ron })
}
pub fn to_value(&self) -> Result<ron::Value, String> {
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<String>,
},
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<String, ron::Value>,
#[serde(default)]
entities: BTreeMap<u64, DynamicSceneEntity>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct DynamicSceneEntity {
#[serde(default)]
components: BTreeMap<String, ron::Value>,
}
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<u64, BTreeMap<String, String>>;
fn extract_raw_scene_values(
text: &str,
) -> Result<(BTreeMap<String, String>, 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::<Result<BTreeMap<_, _>, String>>()?;
let entities = entities
.0
.values
.iter()
.map(|entry| {
let source_key = entry
.content
.key
.to_string()
.parse::<u64>()
.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::<Result<BTreeMap<_, _>, String>>()?;
Ok((source_key, components))
})
.collect::<Result<BTreeMap<_, _>, 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<String, String> {
ron::from_str(&value.to_string()).map_err(|error| format!("invalid string map key: {error}"))
}
fn parse_child_of_source_key(text: &str) -> Option<u64> {
let value = ron::from_str::<ron::Value>(text).ok()?;
parse_child_of_source_key_value(&value)
}
fn parse_child_of_source_key_value(value: &ron::Value) -> Option<u64> {
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(&current).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<String, String>,
entities: &BTreeMap<u64, BTreeMap<String, String>>,
) -> Result<String, String> {
let mut body = String::from("(resources: {\n");
for (type_name, value) in resources {
ron::from_str::<ron::Value>(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<String, ron::Value>,
) -> Result<Option<SceneComposition>, 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::<SceneComposition>()
.map(Some)
.map_err(|error| format!("could not parse scene composition: {error}"))
}
fn extract_prefab_instances(
entities: &BTreeMap<u64, DynamicSceneEntity>,
) -> Result<Vec<PrefabInstance>, String> {
entities
.values()
.filter_map(|entity| entity.components.get(PREFAB_INSTANCE_COMPONENT))
.map(|value| {
value
.clone()
.into_rust::<PrefabInstance>()
.map_err(|error| format!("could not parse prefab instance: {error}"))
})
.collect()
}
fn extract_prefab_refs(
entities: &BTreeMap<u64, DynamicSceneEntity>,
) -> Result<Vec<PrefabRef>, String> {
entities
.values()
.filter_map(|entity| entity.components.get(PREFAB_REF_COMPONENT))
.map(|value| {
value
.clone()
.into_rust::<PrefabRef>()
.map_err(|error| format!("could not parse prefab reference: {error}"))
})
.collect()
}
fn extract_name(components: &[SceneComponentBlob]) -> Option<String> {
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<String> {
let component = components
.iter()
.find(|component| component.type_name == ACTOR_ID_COMPONENT)?;
ron::from_str::<ActorId>(&component.ron)
.ok()
.map(|id| id.0)
.or_else(|| ron::from_str::<String>(&component.ron).ok())
.or_else(|| {
ron::from_str::<Vec<String>>(&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: 6,"));
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"));
}
}