feat(ecs): add JSON/binary scene serialization and component registry

- Mini JSON writer/parser in voltex_ecs (no renderer dependency)
- ComponentRegistry with register/find/register_defaults
- serialize_scene_json/deserialize_scene_json with hex-encoded components
- serialize_scene_binary/deserialize_scene_binary (VSCN binary format)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 20:38:56 +09:00
parent f4b1174e13
commit 164eead5ec
5 changed files with 1166 additions and 1 deletions

View File

@@ -0,0 +1,263 @@
/// Binary scene format (.vscn binary).
///
/// Format:
/// Header: "VSCN" (4 bytes) + version u32 LE + entity_count u32 LE
/// Per entity:
/// parent_index i32 LE (-1 = no parent)
/// component_count u32 LE
/// Per component:
/// name_len u16 LE + name bytes
/// data_len u32 LE + data bytes
use std::collections::HashMap;
use crate::entity::Entity;
use crate::world::World;
use crate::transform::Transform;
use crate::hierarchy::{add_child, Parent};
use crate::component_registry::ComponentRegistry;
const MAGIC: &[u8; 4] = b"VSCN";
const VERSION: u32 = 1;
/// Serialize all entities with a Transform to the binary scene format.
pub fn serialize_scene_binary(world: &World, registry: &ComponentRegistry) -> Vec<u8> {
let entities_with_transform: Vec<(Entity, Transform)> = world
.query::<Transform>()
.map(|(e, t)| (e, *t))
.collect();
let entity_to_index: HashMap<Entity, usize> = entities_with_transform
.iter()
.enumerate()
.map(|(i, (e, _))| (*e, i))
.collect();
let entity_count = entities_with_transform.len() as u32;
let mut buf = Vec::new();
// Header
buf.extend_from_slice(MAGIC);
buf.extend_from_slice(&VERSION.to_le_bytes());
buf.extend_from_slice(&entity_count.to_le_bytes());
// Entities
for (entity, _) in &entities_with_transform {
// Parent index
let parent_idx: i32 = if let Some(parent_comp) = world.get::<Parent>(*entity) {
entity_to_index.get(&parent_comp.0)
.map(|&i| i as i32)
.unwrap_or(-1)
} else {
-1
};
buf.extend_from_slice(&parent_idx.to_le_bytes());
// Collect serializable components
let mut comp_data: Vec<(&str, Vec<u8>)> = Vec::new();
for entry in registry.entries() {
if let Some(data) = (entry.serialize)(world, *entity) {
comp_data.push((&entry.name, data));
}
}
let comp_count = comp_data.len() as u32;
buf.extend_from_slice(&comp_count.to_le_bytes());
for (name, data) in &comp_data {
let name_bytes = name.as_bytes();
buf.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
buf.extend_from_slice(name_bytes);
buf.extend_from_slice(&(data.len() as u32).to_le_bytes());
buf.extend_from_slice(data);
}
}
buf
}
/// Deserialize entities from binary scene data.
pub fn deserialize_scene_binary(
world: &mut World,
data: &[u8],
registry: &ComponentRegistry,
) -> Result<Vec<Entity>, String> {
if data.len() < 12 {
return Err("Binary scene data too short".into());
}
// Verify magic
if &data[0..4] != MAGIC {
return Err("Invalid magic bytes — expected VSCN".into());
}
let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
if version != 1 {
return Err(format!("Unsupported binary scene version: {}", version));
}
let entity_count = u32::from_le_bytes([data[8], data[9], data[10], data[11]]) as usize;
let mut pos = 12;
let mut created: Vec<Entity> = Vec::with_capacity(entity_count);
let mut parent_indices: Vec<i32> = Vec::with_capacity(entity_count);
for _ in 0..entity_count {
// Parent index
if pos + 4 > data.len() {
return Err("Unexpected end of data reading parent index".into());
}
let parent_idx = i32::from_le_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
pos += 4;
// Component count
if pos + 4 > data.len() {
return Err("Unexpected end of data reading component count".into());
}
let comp_count = u32::from_le_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
pos += 4;
let entity = world.spawn();
for _ in 0..comp_count {
// Name length
if pos + 2 > data.len() {
return Err("Unexpected end of data reading name length".into());
}
let name_len = u16::from_le_bytes([data[pos], data[pos + 1]]) as usize;
pos += 2;
// Name
if pos + name_len > data.len() {
return Err("Unexpected end of data reading component name".into());
}
let name = std::str::from_utf8(&data[pos..pos + name_len])
.map_err(|_| "Invalid UTF-8 in component name".to_string())?;
pos += name_len;
// Data length
if pos + 4 > data.len() {
return Err("Unexpected end of data reading data length".into());
}
let data_len = u32::from_le_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
pos += 4;
// Data
if pos + data_len > data.len() {
return Err("Unexpected end of data reading component data".into());
}
let comp_data = &data[pos..pos + data_len];
pos += data_len;
// Deserialize via registry
if let Some(entry) = registry.find(name) {
(entry.deserialize)(world, entity, comp_data)?;
}
}
created.push(entity);
parent_indices.push(parent_idx);
}
// Apply parent relationships
for (child_idx, &parent_idx) in parent_indices.iter().enumerate() {
if parent_idx >= 0 {
let pi = parent_idx as usize;
if pi < created.len() {
let child_entity = created[child_idx];
let parent_entity = created[pi];
add_child(world, parent_entity, child_entity);
}
}
}
Ok(created)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scene::Tag;
use voltex_math::Vec3;
#[test]
fn test_binary_roundtrip() {
let mut registry = ComponentRegistry::new();
registry.register_defaults();
let mut world = World::new();
let e = world.spawn();
world.add(e, Transform::from_position(Vec3::new(5.0, 0.0, -1.0)));
world.add(e, Tag("enemy".into()));
let data = serialize_scene_binary(&world, &registry);
assert_eq!(&data[0..4], b"VSCN");
let mut world2 = World::new();
let entities = deserialize_scene_binary(&mut world2, &data, &registry).unwrap();
assert_eq!(entities.len(), 1);
let t = world2.get::<Transform>(entities[0]).unwrap();
assert!((t.position.x - 5.0).abs() < 1e-6);
assert!((t.position.z - (-1.0)).abs() < 1e-6);
let tag = world2.get::<Tag>(entities[0]).unwrap();
assert_eq!(tag.0, "enemy");
}
#[test]
fn test_binary_with_hierarchy() {
let mut registry = ComponentRegistry::new();
registry.register_defaults();
let mut world = World::new();
let a = world.spawn();
let b = world.spawn();
world.add(a, Transform::new());
world.add(b, Transform::new());
add_child(&mut world, a, b);
let data = serialize_scene_binary(&world, &registry);
let mut world2 = World::new();
let entities = deserialize_scene_binary(&mut world2, &data, &registry).unwrap();
assert_eq!(entities.len(), 2);
assert!(world2.get::<Parent>(entities[1]).is_some());
let p = world2.get::<Parent>(entities[1]).unwrap();
assert_eq!(p.0, entities[0]);
}
#[test]
fn test_binary_invalid_magic() {
let data = vec![0u8; 20];
let mut world = World::new();
let registry = ComponentRegistry::new();
assert!(deserialize_scene_binary(&mut world, &data, &registry).is_err());
}
#[test]
fn test_binary_too_short() {
let data = vec![0u8; 5];
let mut world = World::new();
let registry = ComponentRegistry::new();
assert!(deserialize_scene_binary(&mut world, &data, &registry).is_err());
}
#[test]
fn test_binary_multiple_entities() {
let mut registry = ComponentRegistry::new();
registry.register_defaults();
let mut world = World::new();
for i in 0..3 {
let e = world.spawn();
world.add(e, Transform::from_position(Vec3::new(i as f32, 0.0, 0.0)));
world.add(e, Tag(format!("e{}", i)));
}
let data = serialize_scene_binary(&world, &registry);
let mut world2 = World::new();
let entities = deserialize_scene_binary(&mut world2, &data, &registry).unwrap();
assert_eq!(entities.len(), 3);
for (i, &e) in entities.iter().enumerate() {
let t = world2.get::<Transform>(e).unwrap();
assert!((t.position.x - i as f32).abs() < 1e-6);
let tag = world2.get::<Tag>(e).unwrap();
assert_eq!(tag.0, format!("e{}", i));
}
}
}

View File

@@ -0,0 +1,198 @@
/// Registration-based component serialization for scene formats.
/// Each registered component type has a name, a serialize function,
/// and a deserialize function.
use crate::entity::Entity;
use crate::world::World;
pub type SerializeFn = fn(&World, Entity) -> Option<Vec<u8>>;
pub type DeserializeFn = fn(&mut World, Entity, &[u8]) -> Result<(), String>;
pub struct ComponentEntry {
pub name: String,
pub serialize: SerializeFn,
pub deserialize: DeserializeFn,
}
pub struct ComponentRegistry {
entries: Vec<ComponentEntry>,
}
impl ComponentRegistry {
pub fn new() -> Self {
Self { entries: Vec::new() }
}
pub fn register(&mut self, name: &str, ser: SerializeFn, deser: DeserializeFn) {
self.entries.push(ComponentEntry {
name: name.to_string(),
serialize: ser,
deserialize: deser,
});
}
pub fn find(&self, name: &str) -> Option<&ComponentEntry> {
self.entries.iter().find(|e| e.name == name)
}
pub fn entries(&self) -> &[ComponentEntry] {
&self.entries
}
/// Register the default built-in component types: transform and tag.
pub fn register_defaults(&mut self) {
self.register("transform", serialize_transform, deserialize_transform);
self.register("tag", serialize_tag, deserialize_tag);
}
}
impl Default for ComponentRegistry {
fn default() -> Self {
Self::new()
}
}
// ── Transform: 9 f32s in little-endian (pos.xyz, rot.xyz, scale.xyz) ─
fn serialize_transform(world: &World, entity: Entity) -> Option<Vec<u8>> {
let t = world.get::<crate::Transform>(entity)?;
let mut data = Vec::with_capacity(36);
for &v in &[
t.position.x, t.position.y, t.position.z,
t.rotation.x, t.rotation.y, t.rotation.z,
t.scale.x, t.scale.y, t.scale.z,
] {
data.extend_from_slice(&v.to_le_bytes());
}
Some(data)
}
fn deserialize_transform(world: &mut World, entity: Entity, data: &[u8]) -> Result<(), String> {
if data.len() < 36 {
return Err("Transform data too short".into());
}
let f = |off: usize| f32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]]);
let t = crate::Transform {
position: voltex_math::Vec3::new(f(0), f(4), f(8)),
rotation: voltex_math::Vec3::new(f(12), f(16), f(20)),
scale: voltex_math::Vec3::new(f(24), f(28), f(32)),
};
world.add(entity, t);
Ok(())
}
// ── Tag: UTF-8 string bytes ─────────────────────────────────────────
fn serialize_tag(world: &World, entity: Entity) -> Option<Vec<u8>> {
let tag = world.get::<crate::scene::Tag>(entity)?;
Some(tag.0.as_bytes().to_vec())
}
fn deserialize_tag(world: &mut World, entity: Entity, data: &[u8]) -> Result<(), String> {
let s = std::str::from_utf8(data).map_err(|_| "Invalid UTF-8 in tag".to_string())?;
world.add(entity, crate::scene::Tag(s.to_string()));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{World, Transform};
use voltex_math::Vec3;
#[test]
fn test_register_and_find() {
let mut registry = ComponentRegistry::new();
registry.register_defaults();
assert!(registry.find("transform").is_some());
assert!(registry.find("tag").is_some());
assert!(registry.find("nonexistent").is_none());
}
#[test]
fn test_entries_count() {
let mut registry = ComponentRegistry::new();
registry.register_defaults();
assert_eq!(registry.entries().len(), 2);
}
#[test]
fn test_serialize_transform() {
let mut registry = ComponentRegistry::new();
registry.register_defaults();
let mut world = World::new();
let e = world.spawn();
world.add(e, Transform::from_position(Vec3::new(1.0, 2.0, 3.0)));
let entry = registry.find("transform").unwrap();
let data = (entry.serialize)(&world, e);
assert!(data.is_some());
assert_eq!(data.unwrap().len(), 36); // 9 f32s * 4 bytes
}
#[test]
fn test_serialize_missing_component() {
let mut registry = ComponentRegistry::new();
registry.register_defaults();
let mut world = World::new();
let e = world.spawn();
// no Transform added
let entry = registry.find("transform").unwrap();
assert!((entry.serialize)(&world, e).is_none());
}
#[test]
fn test_roundtrip_transform() {
let mut registry = ComponentRegistry::new();
registry.register_defaults();
let mut world = World::new();
let e = world.spawn();
world.add(e, Transform {
position: Vec3::new(1.0, 2.0, 3.0),
rotation: Vec3::new(0.1, 0.2, 0.3),
scale: Vec3::new(4.0, 5.0, 6.0),
});
let entry = registry.find("transform").unwrap();
let data = (entry.serialize)(&world, e).unwrap();
let mut world2 = World::new();
let e2 = world2.spawn();
(entry.deserialize)(&mut world2, e2, &data).unwrap();
let t = world2.get::<Transform>(e2).unwrap();
assert!((t.position.x - 1.0).abs() < 1e-6);
assert!((t.position.y - 2.0).abs() < 1e-6);
assert!((t.position.z - 3.0).abs() < 1e-6);
assert!((t.rotation.x - 0.1).abs() < 1e-6);
assert!((t.scale.x - 4.0).abs() < 1e-6);
}
#[test]
fn test_roundtrip_tag() {
let mut registry = ComponentRegistry::new();
registry.register_defaults();
let mut world = World::new();
let e = world.spawn();
world.add(e, crate::scene::Tag("hello world".to_string()));
let entry = registry.find("tag").unwrap();
let data = (entry.serialize)(&world, e).unwrap();
let mut world2 = World::new();
let e2 = world2.spawn();
(entry.deserialize)(&mut world2, e2, &data).unwrap();
let tag = world2.get::<crate::scene::Tag>(e2).unwrap();
assert_eq!(tag.0, "hello world");
}
#[test]
fn test_deserialize_transform_too_short() {
let mut world = World::new();
let e = world.spawn();
let result = deserialize_transform(&mut world, e, &[0u8; 10]);
assert!(result.is_err());
}
}

View File

@@ -0,0 +1,476 @@
/// Mini JSON writer and parser for scene serialization.
/// No external dependencies — self-contained within voltex_ecs.
#[derive(Debug, Clone, PartialEq)]
pub enum JsonVal {
Null,
Bool(bool),
Number(f64),
Str(String),
Array(Vec<JsonVal>),
Object(Vec<(String, JsonVal)>),
}
// ── Writer helpers ──────────────────────────────────────────────────
pub fn json_write_null() -> String {
"null".to_string()
}
pub fn json_write_f32(v: f32) -> String {
// Emit integer form when the value has no fractional part
if v.fract() == 0.0 && v.abs() < 1e15 {
format!("{}", v as i64)
} else {
format!("{}", v)
}
}
pub fn json_write_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
_ => out.push(c),
}
}
out.push('"');
out
}
/// Write a JSON array from pre-formatted element strings.
pub fn json_write_array(elements: &[&str]) -> String {
let mut out = String::from("[");
for (i, elem) in elements.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(elem);
}
out.push(']');
out
}
/// Write a JSON object from (key, pre-formatted-value) pairs.
pub fn json_write_object(pairs: &[(&str, &str)]) -> String {
let mut out = String::from("{");
for (i, (key, val)) in pairs.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&json_write_string(key));
out.push(':');
out.push_str(val);
}
out.push('}');
out
}
// ── Accessors on JsonVal ────────────────────────────────────────────
impl JsonVal {
pub fn get(&self, key: &str) -> Option<&JsonVal> {
match self {
JsonVal::Object(pairs) => pairs.iter().find(|(k, _)| k == key).map(|(_, v)| v),
_ => None,
}
}
pub fn as_f64(&self) -> Option<f64> {
match self {
JsonVal::Number(n) => Some(*n),
_ => None,
}
}
pub fn as_str(&self) -> Option<&str> {
match self {
JsonVal::Str(s) => Some(s.as_str()),
_ => None,
}
}
pub fn as_array(&self) -> Option<&Vec<JsonVal>> {
match self {
JsonVal::Array(a) => Some(a),
_ => None,
}
}
pub fn as_object(&self) -> Option<&Vec<(String, JsonVal)>> {
match self {
JsonVal::Object(o) => Some(o),
_ => None,
}
}
pub fn as_bool(&self) -> Option<bool> {
match self {
JsonVal::Bool(b) => Some(*b),
_ => None,
}
}
}
// ── Parser (recursive descent) ──────────────────────────────────────
pub fn json_parse(input: &str) -> Result<JsonVal, String> {
let bytes = input.as_bytes();
let (val, pos) = parse_value(bytes, skip_ws(bytes, 0))?;
let pos = skip_ws(bytes, pos);
if pos != bytes.len() {
return Err(format!("Unexpected trailing content at position {}", pos));
}
Ok(val)
}
fn skip_ws(b: &[u8], mut pos: usize) -> usize {
while pos < b.len() && matches!(b[pos], b' ' | b'\t' | b'\n' | b'\r') {
pos += 1;
}
pos
}
fn parse_value(b: &[u8], pos: usize) -> Result<(JsonVal, usize), String> {
if pos >= b.len() {
return Err("Unexpected end of input".into());
}
match b[pos] {
b'"' => parse_string(b, pos),
b'{' => parse_object(b, pos),
b'[' => parse_array(b, pos),
b't' | b'f' => parse_bool(b, pos),
b'n' => parse_null(b, pos),
_ => parse_number(b, pos),
}
}
fn parse_string(b: &[u8], pos: usize) -> Result<(JsonVal, usize), String> {
let (s, end) = read_string(b, pos)?;
Ok((JsonVal::Str(s), end))
}
fn read_string(b: &[u8], pos: usize) -> Result<(String, usize), String> {
if pos >= b.len() || b[pos] != b'"' {
return Err(format!("Expected '\"' at position {}", pos));
}
let mut i = pos + 1;
let mut s = String::new();
while i < b.len() {
match b[i] {
b'"' => return Ok((s, i + 1)),
b'\\' => {
i += 1;
if i >= b.len() {
return Err("Unexpected end in string escape".into());
}
match b[i] {
b'"' => s.push('"'),
b'\\' => s.push('\\'),
b'/' => s.push('/'),
b'n' => s.push('\n'),
b'r' => s.push('\r'),
b't' => s.push('\t'),
_ => {
s.push('\\');
s.push(b[i] as char);
}
}
i += 1;
}
ch => {
s.push(ch as char);
i += 1;
}
}
}
Err("Unterminated string".into())
}
fn parse_number(b: &[u8], pos: usize) -> Result<(JsonVal, usize), String> {
let mut i = pos;
// optional minus
if i < b.len() && b[i] == b'-' {
i += 1;
}
// digits
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
// fractional
if i < b.len() && b[i] == b'.' {
i += 1;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
}
// exponent
if i < b.len() && (b[i] == b'e' || b[i] == b'E') {
i += 1;
if i < b.len() && (b[i] == b'+' || b[i] == b'-') {
i += 1;
}
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
}
if i == pos {
return Err(format!("Expected number at position {}", pos));
}
let s = std::str::from_utf8(&b[pos..i]).unwrap();
let n: f64 = s.parse().map_err(|e| format!("Invalid number '{}': {}", s, e))?;
Ok((JsonVal::Number(n), i))
}
fn parse_bool(b: &[u8], pos: usize) -> Result<(JsonVal, usize), String> {
if b[pos..].starts_with(b"true") {
Ok((JsonVal::Bool(true), pos + 4))
} else if b[pos..].starts_with(b"false") {
Ok((JsonVal::Bool(false), pos + 5))
} else {
Err(format!("Expected bool at position {}", pos))
}
}
fn parse_null(b: &[u8], pos: usize) -> Result<(JsonVal, usize), String> {
if b[pos..].starts_with(b"null") {
Ok((JsonVal::Null, pos + 4))
} else {
Err(format!("Expected null at position {}", pos))
}
}
fn parse_array(b: &[u8], pos: usize) -> Result<(JsonVal, usize), String> {
let mut i = pos + 1; // skip '['
let mut arr = Vec::new();
i = skip_ws(b, i);
if i < b.len() && b[i] == b']' {
return Ok((JsonVal::Array(arr), i + 1));
}
loop {
i = skip_ws(b, i);
let (val, next) = parse_value(b, i)?;
arr.push(val);
i = skip_ws(b, next);
if i >= b.len() {
return Err("Unterminated array".into());
}
if b[i] == b']' {
return Ok((JsonVal::Array(arr), i + 1));
}
if b[i] != b',' {
return Err(format!("Expected ',' or ']' at position {}", i));
}
i += 1; // skip ','
}
}
fn parse_object(b: &[u8], pos: usize) -> Result<(JsonVal, usize), String> {
let mut i = pos + 1; // skip '{'
let mut pairs = Vec::new();
i = skip_ws(b, i);
if i < b.len() && b[i] == b'}' {
return Ok((JsonVal::Object(pairs), i + 1));
}
loop {
i = skip_ws(b, i);
let (key, next) = read_string(b, i)?;
i = skip_ws(b, next);
if i >= b.len() || b[i] != b':' {
return Err(format!("Expected ':' at position {}", i));
}
i = skip_ws(b, i + 1);
let (val, next) = parse_value(b, i)?;
pairs.push((key, val));
i = skip_ws(b, next);
if i >= b.len() {
return Err("Unterminated object".into());
}
if b[i] == b'}' {
return Ok((JsonVal::Object(pairs), i + 1));
}
if b[i] != b',' {
return Err(format!("Expected ',' or '}}' at position {}", i));
}
i += 1; // skip ','
}
}
// ── JsonVal -> String serialization ─────────────────────────────────
impl JsonVal {
/// Serialize this JsonVal to a compact JSON string.
pub fn to_json_string(&self) -> String {
match self {
JsonVal::Null => "null".to_string(),
JsonVal::Bool(b) => if *b { "true" } else { "false" }.to_string(),
JsonVal::Number(n) => {
if n.fract() == 0.0 && n.abs() < 1e15 {
format!("{}", *n as i64)
} else {
format!("{}", n)
}
}
JsonVal::Str(s) => json_write_string(s),
JsonVal::Array(arr) => {
let mut out = String::from("[");
for (i, v) in arr.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&v.to_json_string());
}
out.push(']');
out
}
JsonVal::Object(pairs) => {
let mut out = String::from("{");
for (i, (k, v)) in pairs.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&json_write_string(k));
out.push(':');
out.push_str(&v.to_json_string());
}
out.push('}');
out
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_write_null() {
assert_eq!(json_write_null(), "null");
}
#[test]
fn test_write_number() {
assert_eq!(json_write_f32(3.14), "3.14");
assert_eq!(json_write_f32(1.0), "1");
}
#[test]
fn test_write_string() {
assert_eq!(json_write_string("hello"), "\"hello\"");
assert_eq!(json_write_string("a\"b"), "\"a\\\"b\"");
}
#[test]
fn test_write_array() {
assert_eq!(json_write_array(&["1", "2", "3"]), "[1,2,3]");
}
#[test]
fn test_write_object() {
let pairs = vec![("name", "\"test\""), ("value", "42")];
let result = json_write_object(&pairs);
assert_eq!(result, r#"{"name":"test","value":42}"#);
}
#[test]
fn test_parse_number() {
match json_parse("42.5").unwrap() {
JsonVal::Number(n) => assert!((n - 42.5).abs() < 1e-10),
_ => panic!("expected number"),
}
}
#[test]
fn test_parse_negative_number() {
match json_parse("-3.14").unwrap() {
JsonVal::Number(n) => assert!((n - (-3.14)).abs() < 1e-10),
_ => panic!("expected number"),
}
}
#[test]
fn test_parse_string() {
assert_eq!(json_parse("\"hello\"").unwrap(), JsonVal::Str("hello".into()));
}
#[test]
fn test_parse_string_with_escapes() {
assert_eq!(
json_parse(r#""a\"b\\c""#).unwrap(),
JsonVal::Str("a\"b\\c".into())
);
}
#[test]
fn test_parse_array() {
match json_parse("[1,2,3]").unwrap() {
JsonVal::Array(a) => assert_eq!(a.len(), 3),
_ => panic!("expected array"),
}
}
#[test]
fn test_parse_empty_array() {
match json_parse("[]").unwrap() {
JsonVal::Array(a) => assert_eq!(a.len(), 0),
_ => panic!("expected array"),
}
}
#[test]
fn test_parse_object() {
let val = json_parse(r#"{"x":1,"y":2}"#).unwrap();
assert!(matches!(val, JsonVal::Object(_)));
assert_eq!(val.get("x").unwrap().as_f64().unwrap(), 1.0);
assert_eq!(val.get("y").unwrap().as_f64().unwrap(), 2.0);
}
#[test]
fn test_parse_null() {
assert_eq!(json_parse("null").unwrap(), JsonVal::Null);
}
#[test]
fn test_parse_bool() {
assert_eq!(json_parse("true").unwrap(), JsonVal::Bool(true));
assert_eq!(json_parse("false").unwrap(), JsonVal::Bool(false));
}
#[test]
fn test_parse_nested() {
let val = json_parse(r#"{"a":[1,2],"b":{"c":3}}"#).unwrap();
assert!(matches!(val, JsonVal::Object(_)));
let arr = val.get("a").unwrap().as_array().unwrap();
assert_eq!(arr.len(), 2);
let inner = val.get("b").unwrap();
assert_eq!(inner.get("c").unwrap().as_f64().unwrap(), 3.0);
}
#[test]
fn test_parse_whitespace() {
let val = json_parse(" { \"a\" : 1 , \"b\" : [ 2 , 3 ] } ").unwrap();
assert!(matches!(val, JsonVal::Object(_)));
}
#[test]
fn test_json_val_to_json_string_roundtrip() {
let val = JsonVal::Object(vec![
("name".into(), JsonVal::Str("test".into())),
("count".into(), JsonVal::Number(42.0)),
("items".into(), JsonVal::Array(vec![
JsonVal::Number(1.0),
JsonVal::Null,
JsonVal::Bool(true),
])),
]);
let s = val.to_json_string();
let parsed = json_parse(&s).unwrap();
assert_eq!(parsed, val);
}
}

View File

@@ -6,6 +6,9 @@ pub mod hierarchy;
pub mod world_transform;
pub mod scene;
pub mod scheduler;
pub mod json;
pub mod component_registry;
pub mod binary_scene;
pub use entity::{Entity, EntityAllocator};
pub use sparse_set::SparseSet;
@@ -13,5 +16,7 @@ pub use world::World;
pub use transform::Transform;
pub use hierarchy::{Parent, Children, add_child, remove_child, despawn_recursive, roots};
pub use world_transform::{WorldTransform, propagate_transforms};
pub use scene::{Tag, serialize_scene, deserialize_scene};
pub use scene::{Tag, serialize_scene, deserialize_scene, serialize_scene_json, deserialize_scene_json};
pub use scheduler::{Scheduler, System};
pub use component_registry::ComponentRegistry;
pub use binary_scene::{serialize_scene_binary, deserialize_scene_binary};

View File

@@ -4,6 +4,8 @@ use crate::entity::Entity;
use crate::world::World;
use crate::transform::Transform;
use crate::hierarchy::{add_child, Parent};
use crate::component_registry::ComponentRegistry;
use crate::json::{self, JsonVal};
/// String tag for entity identification.
#[derive(Debug, Clone)]
@@ -152,10 +154,160 @@ pub fn deserialize_scene(world: &mut World, source: &str) -> Vec<Entity> {
created
}
// ── Hex encoding helpers ────────────────────────────────────────────
fn bytes_to_hex(data: &[u8]) -> String {
let mut s = String::with_capacity(data.len() * 2);
for &b in data {
s.push_str(&format!("{:02x}", b));
}
s
}
fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
if hex.len() % 2 != 0 {
return Err("Hex string has odd length".into());
}
let mut bytes = Vec::with_capacity(hex.len() / 2);
let mut i = 0;
let chars: Vec<u8> = hex.bytes().collect();
while i < chars.len() {
let hi = hex_digit(chars[i])?;
let lo = hex_digit(chars[i + 1])?;
bytes.push((hi << 4) | lo);
i += 2;
}
Ok(bytes)
}
fn hex_digit(c: u8) -> Result<u8, String> {
match c {
b'0'..=b'9' => Ok(c - b'0'),
b'a'..=b'f' => Ok(c - b'a' + 10),
b'A'..=b'F' => Ok(c - b'A' + 10),
_ => Err(format!("Invalid hex digit: {}", c as char)),
}
}
// ── JSON scene serialization ────────────────────────────────────────
/// Serialize all entities with a Transform to JSON format using the component registry.
/// Format: {"version":1,"entities":[{"parent":null_or_idx,"components":{"name":"hex",...}}]}
pub fn serialize_scene_json(world: &World, registry: &ComponentRegistry) -> String {
let entities_with_transform: Vec<(Entity, Transform)> = world
.query::<Transform>()
.map(|(e, t)| (e, *t))
.collect();
let entity_to_index: HashMap<Entity, usize> = entities_with_transform
.iter()
.enumerate()
.map(|(i, (e, _))| (*e, i))
.collect();
// Build entity JSON values
let mut entity_vals = Vec::new();
for (entity, _) in &entities_with_transform {
// Parent index
let parent_val = if let Some(parent_comp) = world.get::<Parent>(*entity) {
if let Some(&idx) = entity_to_index.get(&parent_comp.0) {
JsonVal::Number(idx as f64)
} else {
JsonVal::Null
}
} else {
JsonVal::Null
};
// Components
let mut comp_pairs = Vec::new();
for entry in registry.entries() {
if let Some(data) = (entry.serialize)(world, *entity) {
comp_pairs.push((entry.name.clone(), JsonVal::Str(bytes_to_hex(&data))));
}
}
entity_vals.push(JsonVal::Object(vec![
("parent".into(), parent_val),
("components".into(), JsonVal::Object(comp_pairs)),
]));
}
let root = JsonVal::Object(vec![
("version".into(), JsonVal::Number(1.0)),
("entities".into(), JsonVal::Array(entity_vals)),
]);
root.to_json_string()
}
/// Deserialize entities from a JSON scene string.
pub fn deserialize_scene_json(
world: &mut World,
json_str: &str,
registry: &ComponentRegistry,
) -> Result<Vec<Entity>, String> {
let root = json::json_parse(json_str)?;
let version = root.get("version")
.and_then(|v| v.as_f64())
.ok_or("Missing or invalid 'version'")?;
if version as u32 != 1 {
return Err(format!("Unsupported version: {}", version));
}
let entities_arr = root.get("entities")
.and_then(|v| v.as_array())
.ok_or("Missing or invalid 'entities'")?;
// First pass: create entities and deserialize components
let mut created: Vec<Entity> = Vec::with_capacity(entities_arr.len());
let mut parent_indices: Vec<Option<usize>> = Vec::with_capacity(entities_arr.len());
for entity_val in entities_arr {
let entity = world.spawn();
// Parse parent index
let parent_idx = match entity_val.get("parent") {
Some(JsonVal::Number(n)) => Some(*n as usize),
_ => None,
};
parent_indices.push(parent_idx);
// Deserialize components
if let Some(comps) = entity_val.get("components").and_then(|v| v.as_object()) {
for (name, hex_val) in comps {
if let Some(hex_str) = hex_val.as_str() {
let data = hex_to_bytes(hex_str)?;
if let Some(entry) = registry.find(name) {
(entry.deserialize)(world, entity, &data)?;
}
}
}
}
created.push(entity);
}
// Second pass: apply parent relationships
for (child_idx, parent_idx_opt) in parent_indices.iter().enumerate() {
if let Some(parent_idx) = parent_idx_opt {
if *parent_idx < created.len() {
let child_entity = created[child_idx];
let parent_entity = created[*parent_idx];
add_child(world, parent_entity, child_entity);
}
}
}
Ok(created)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hierarchy::{add_child, roots, Parent};
use crate::component_registry::ComponentRegistry;
use voltex_math::Vec3;
#[test]
@@ -270,4 +422,75 @@ entity 2
let scene_roots = roots(&world);
assert_eq!(scene_roots.len(), 2, "should have exactly 2 root entities");
}
// ── JSON scene tests ────────────────────────────────────────────
#[test]
fn test_hex_roundtrip() {
let data = vec![0u8, 1, 15, 16, 255];
let hex = bytes_to_hex(&data);
assert_eq!(hex, "00010f10ff");
let back = hex_to_bytes(&hex).unwrap();
assert_eq!(back, data);
}
#[test]
fn test_json_roundtrip() {
let mut registry = ComponentRegistry::new();
registry.register_defaults();
let mut world = World::new();
let e = world.spawn();
world.add(e, Transform::from_position(Vec3::new(1.0, 2.0, 3.0)));
world.add(e, Tag("player".into()));
let json = serialize_scene_json(&world, &registry);
assert!(json.contains("\"version\":1"));
let mut world2 = World::new();
let entities = deserialize_scene_json(&mut world2, &json, &registry).unwrap();
assert_eq!(entities.len(), 1);
let t = world2.get::<Transform>(entities[0]).unwrap();
assert!((t.position.x - 1.0).abs() < 1e-4);
assert!((t.position.y - 2.0).abs() < 1e-4);
assert!((t.position.z - 3.0).abs() < 1e-4);
let tag = world2.get::<Tag>(entities[0]).unwrap();
assert_eq!(tag.0, "player");
}
#[test]
fn test_json_with_parent() {
let mut registry = ComponentRegistry::new();
registry.register_defaults();
let mut world = World::new();
let parent = world.spawn();
let child = world.spawn();
world.add(parent, Transform::new());
world.add(child, Transform::new());
add_child(&mut world, parent, child);
let json = serialize_scene_json(&world, &registry);
let mut world2 = World::new();
let entities = deserialize_scene_json(&mut world2, &json, &registry).unwrap();
assert_eq!(entities.len(), 2);
assert!(world2.get::<Parent>(entities[1]).is_some());
let parent_comp = world2.get::<Parent>(entities[1]).unwrap();
assert_eq!(parent_comp.0, entities[0]);
}
#[test]
fn test_json_multiple_entities() {
let mut registry = ComponentRegistry::new();
registry.register_defaults();
let mut world = World::new();
for i in 0..5 {
let e = world.spawn();
world.add(e, Transform::from_position(Vec3::new(i as f32, 0.0, 0.0)));
world.add(e, Tag(format!("entity_{}", i)));
}
let json = serialize_scene_json(&world, &registry);
let mut world2 = World::new();
let entities = deserialize_scene_json(&mut world2, &json, &registry).unwrap();
assert_eq!(entities.len(), 5);
}
}