Import Oxide engine (Stages 0–10) under MIT license
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>
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
//! RON serialization for [`Scene`].
|
||||
//!
|
||||
//! `hecs::Entity` handles are runtime values that are not stable across a
|
||||
//! save/load, so the scene is flattened to a list of records with array
|
||||
//! indices standing in for entity references. The list is built in a
|
||||
//! deterministic pre-order walk of the hierarchy, so a serialize → deserialize
|
||||
//! → serialize cycle is byte-for-byte stable.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use hecs::Entity;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{Node, Scene, SceneError};
|
||||
use crate::math::Transform;
|
||||
|
||||
/// One entity in the flattened scene. `children` holds indices into the
|
||||
/// surrounding [`SceneData::nodes`] list.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
struct NodeRecord {
|
||||
name: String,
|
||||
enabled: bool,
|
||||
transform: Transform,
|
||||
children: Vec<usize>,
|
||||
}
|
||||
|
||||
/// The serializable form of a [`Scene`]: a flat node list plus the indices of
|
||||
/// the root nodes. Parent links are implied by the `children` arrays.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
struct SceneData {
|
||||
nodes: Vec<NodeRecord>,
|
||||
roots: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// Serializes the scene to a pretty-printed RON string.
|
||||
pub fn to_ron(&self) -> Result<String, SceneError> {
|
||||
let data = self.to_data();
|
||||
ron::ser::to_string_pretty(&data, ron::ser::PrettyConfig::default())
|
||||
.map_err(|e| SceneError::Serialize(e.to_string()))
|
||||
}
|
||||
|
||||
/// Reconstructs a scene from a RON string produced by [`to_ron`](Self::to_ron).
|
||||
pub fn from_ron(ron: &str) -> Result<Scene, SceneError> {
|
||||
let data: SceneData =
|
||||
ron::from_str(ron).map_err(|e| SceneError::Deserialize(e.to_string()))?;
|
||||
Scene::from_data(&data)
|
||||
}
|
||||
|
||||
/// Flattens the hierarchy into index-based records via a deterministic
|
||||
/// pre-order walk (roots in order, then each subtree depth-first).
|
||||
fn to_data(&self) -> SceneData {
|
||||
let mut index: HashMap<Entity, usize> = HashMap::with_capacity(self.len());
|
||||
let mut order: Vec<Entity> = Vec::with_capacity(self.len());
|
||||
for &root in self.roots() {
|
||||
self.assign_indices(root, &mut index, &mut order);
|
||||
}
|
||||
|
||||
let nodes = order
|
||||
.iter()
|
||||
.map(|&entity| {
|
||||
let node = self
|
||||
.get::<Node>(entity)
|
||||
.expect("entity in hierarchy must have a Node");
|
||||
let transform = self
|
||||
.local_transform(entity)
|
||||
.expect("entity in hierarchy must have a Transform");
|
||||
NodeRecord {
|
||||
name: node.name.clone(),
|
||||
enabled: node.enabled,
|
||||
transform,
|
||||
children: self.children(entity).iter().map(|c| index[c]).collect(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let roots = self.roots().iter().map(|r| index[r]).collect();
|
||||
SceneData { nodes, roots }
|
||||
}
|
||||
|
||||
/// Pre-order index assignment helper for [`to_data`](Self::to_data).
|
||||
fn assign_indices(
|
||||
&self,
|
||||
entity: Entity,
|
||||
index: &mut HashMap<Entity, usize>,
|
||||
order: &mut Vec<Entity>,
|
||||
) {
|
||||
index.insert(entity, order.len());
|
||||
order.push(entity);
|
||||
for &child in self.children(entity) {
|
||||
self.assign_indices(child, index, order);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuilds a scene from flattened records, validating index references.
|
||||
fn from_data(data: &SceneData) -> Result<Scene, SceneError> {
|
||||
let mut scene = Scene::new();
|
||||
let n = data.nodes.len();
|
||||
|
||||
// Spawn every entity first (as a root), so all indices resolve before
|
||||
// wiring up parent/child links.
|
||||
let entities: Vec<Entity> = data
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|rec| {
|
||||
scene.spawn(
|
||||
Node {
|
||||
name: rec.name.clone(),
|
||||
enabled: rec.enabled,
|
||||
},
|
||||
rec.transform,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Re-link: each record's children become children of that record's
|
||||
// entity (and are removed from the root list).
|
||||
for (i, rec) in data.nodes.iter().enumerate() {
|
||||
for &child_idx in &rec.children {
|
||||
let child = *entities
|
||||
.get(child_idx)
|
||||
.ok_or(SceneError::Deserialize(format!(
|
||||
"child index {child_idx} out of range (have {n} nodes)"
|
||||
)))?;
|
||||
scene
|
||||
.set_parent(child, Some(entities[i]))
|
||||
.map_err(|e| SceneError::Deserialize(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the declared roots match the entities left parentless. The
|
||||
// re-link above already produced the correct root set; we just confirm
|
||||
// the file's `roots` list is consistent so corrupt input is rejected.
|
||||
for &root_idx in &data.roots {
|
||||
let entity = *entities
|
||||
.get(root_idx)
|
||||
.ok_or(SceneError::Deserialize(format!(
|
||||
"root index {root_idx} out of range (have {n} nodes)"
|
||||
)))?;
|
||||
if scene.parent(entity).is_some() {
|
||||
return Err(SceneError::Deserialize(format!(
|
||||
"node {root_idx} is listed as a root but is also a child"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(scene)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::math::{Quat, Vec3};
|
||||
use crate::scene::DespawnPolicy;
|
||||
|
||||
/// Builds a small, varied scene used by the round-trip tests.
|
||||
fn sample() -> Scene {
|
||||
let mut scene = Scene::new();
|
||||
let root = scene.spawn(
|
||||
Node {
|
||||
name: "root".into(),
|
||||
enabled: true,
|
||||
},
|
||||
Transform::from_translation(Vec3::new(1.0, 2.0, 3.0)),
|
||||
);
|
||||
let arm = scene.spawn_child(
|
||||
root,
|
||||
"arm",
|
||||
Transform::from_rotation(Quat::from_rotation_y(0.5)),
|
||||
);
|
||||
scene.spawn_child(arm, "hand", Transform::from_scale(Vec3::splat(2.0)));
|
||||
let mut disabled = Node::new("disabled");
|
||||
disabled.enabled = false;
|
||||
scene.spawn_child(root, disabled, Transform::IDENTITY);
|
||||
// A second independent root, to exercise multi-root serialization.
|
||||
scene.spawn("other-root", Transform::from_translation(Vec3::NEG_X));
|
||||
scene
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_preserves_structure() {
|
||||
let scene = sample();
|
||||
let ron = scene.to_ron().unwrap();
|
||||
let restored = Scene::from_ron(&ron).unwrap();
|
||||
|
||||
// Re-serializing the restored scene yields identical text: structure,
|
||||
// names, flags, transforms, and ordering all survived.
|
||||
assert_eq!(ron, restored.to_ron().unwrap());
|
||||
assert_eq!(scene.len(), restored.len());
|
||||
assert_eq!(scene.roots().len(), restored.roots().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_preserves_world_transforms() {
|
||||
let scene = sample();
|
||||
let restored = Scene::from_ron(&scene.to_ron().unwrap()).unwrap();
|
||||
|
||||
// Compare resolved world transforms by name, since entity ids differ
|
||||
// across the rebuild.
|
||||
let by_name = |s: &Scene| -> Vec<(String, Vec3)> {
|
||||
let worlds = s.world_transforms();
|
||||
let mut v: Vec<_> = worlds
|
||||
.iter()
|
||||
.map(|(&e, t)| (s.name(e).unwrap(), t.translation))
|
||||
.collect();
|
||||
v.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
v
|
||||
};
|
||||
let a = by_name(&scene);
|
||||
let b = by_name(&restored);
|
||||
assert_eq!(a.len(), b.len());
|
||||
for ((na, ta), (nb, tb)) in a.iter().zip(b.iter()) {
|
||||
assert_eq!(na, nb);
|
||||
assert!((*ta - *tb).length() <= 1e-5, "{na}: {ta} vs {tb}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_scene_round_trips() {
|
||||
let scene = Scene::new();
|
||||
let restored = Scene::from_ron(&scene.to_ron().unwrap()).unwrap();
|
||||
assert!(restored.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reordering_after_edits_still_round_trips() {
|
||||
// Mutating the scene (despawn) must not break index bookkeeping.
|
||||
let mut scene = sample();
|
||||
let root = scene.roots()[0];
|
||||
let kid = scene.children(root)[0];
|
||||
scene.despawn(kid, DespawnPolicy::DetachChildren);
|
||||
let ron = scene.to_ron().unwrap();
|
||||
assert_eq!(ron, Scene::from_ron(&ron).unwrap().to_ron().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_child_index_is_rejected() {
|
||||
let bad = r#"(nodes: [(name: "a", enabled: true, transform: (translation: (0,0,0), rotation: (0,0,0,1), scale: (1,1,1)), children: [5])], roots: [0])"#;
|
||||
assert!(Scene::from_ron(bad).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user