convert.rs (17082B)
1 //! Convert protoverse Space AST to renderer space state. 2 3 use crate::room_state::{ 4 ObjectLocation, RoomObject, RoomObjectType, SpaceData, SpaceInfo, TilemapData, 5 }; 6 use glam::{Quat, Vec3}; 7 use protoverse::{Attribute, Cell, CellId, CellType, Location, ObjectType, Space}; 8 9 /// Convert a parsed protoverse Space into a SpaceData (info + objects). 10 pub fn convert_space(space: &Space) -> SpaceData { 11 let mut info = extract_space_info(space, space.root); 12 let mut objects = Vec::new(); 13 let mut tilemap = None; 14 collect_objects(space, space.root, &mut objects, &mut tilemap); 15 info.tilemap = tilemap; 16 SpaceData { info, objects } 17 } 18 19 fn extract_space_info(space: &Space, id: CellId) -> SpaceInfo { 20 let name = space.name(id).unwrap_or("Untitled Space").to_string(); 21 SpaceInfo { 22 name, 23 tilemap: None, 24 } 25 } 26 27 fn location_from_protoverse(loc: &Location) -> ObjectLocation { 28 match loc { 29 Location::Center => ObjectLocation::Center, 30 Location::Floor => ObjectLocation::Floor, 31 Location::Ceiling => ObjectLocation::Ceiling, 32 Location::TopOf(id) => ObjectLocation::TopOf(id.clone()), 33 Location::Near(id) => ObjectLocation::Near(id.clone()), 34 Location::Custom(s) => ObjectLocation::Custom(s.clone()), 35 } 36 } 37 38 fn location_to_protoverse(loc: &ObjectLocation) -> Location { 39 match loc { 40 ObjectLocation::Center => Location::Center, 41 ObjectLocation::Floor => Location::Floor, 42 ObjectLocation::Ceiling => Location::Ceiling, 43 ObjectLocation::TopOf(id) => Location::TopOf(id.clone()), 44 ObjectLocation::Near(id) => Location::Near(id.clone()), 45 ObjectLocation::Custom(s) => Location::Custom(s.clone()), 46 } 47 } 48 49 fn object_type_from_cell(obj_type: &ObjectType) -> RoomObjectType { 50 match obj_type { 51 ObjectType::Table => RoomObjectType::Table, 52 ObjectType::Chair => RoomObjectType::Chair, 53 ObjectType::Door => RoomObjectType::Door, 54 ObjectType::Light => RoomObjectType::Light, 55 ObjectType::Custom(s) if s == "prop" => RoomObjectType::Prop, 56 ObjectType::Custom(s) => RoomObjectType::Custom(s.clone()), 57 } 58 } 59 60 fn collect_objects( 61 space: &Space, 62 id: CellId, 63 objects: &mut Vec<RoomObject>, 64 tilemap: &mut Option<TilemapData>, 65 ) { 66 let cell = space.cell(id); 67 68 if cell.cell_type == CellType::Tilemap { 69 let width = space.width(id).unwrap_or(10.0) as u32; 70 let height = space.height(id).unwrap_or(10.0) as u32; 71 let tileset = space 72 .tileset(id) 73 .cloned() 74 .unwrap_or_else(|| vec!["grass".to_string()]); 75 let data_str = space.data(id).unwrap_or("0"); 76 let tiles = TilemapData::decode_data(data_str); 77 *tilemap = Some(TilemapData { 78 width, 79 height, 80 tileset, 81 tiles, 82 scene_object_id: None, 83 model_handle: None, 84 }); 85 } else if let CellType::Object(ref obj_type) = cell.cell_type { 86 let obj_id = space.id_str(id).unwrap_or("").to_string(); 87 88 // Generate a fallback id if none specified 89 let obj_id = if obj_id.is_empty() { 90 format!("obj-{}", id.0) 91 } else { 92 obj_id 93 }; 94 95 let name = space 96 .name(id) 97 .map(|s| s.to_string()) 98 .unwrap_or_else(|| cell.cell_type.to_string()); 99 100 let position = space 101 .position(id) 102 .map(|(x, y, z)| Vec3::new(x as f32, y as f32, z as f32)) 103 .unwrap_or(Vec3::ZERO); 104 105 let model_url = space.model_url(id).map(|s| s.to_string()); 106 let location = space.location(id).map(location_from_protoverse); 107 let rotation = space 108 .rotation(id) 109 .map(|(x, y, z)| { 110 Quat::from_euler( 111 glam::EulerRot::YXZ, 112 (y as f32).to_radians(), 113 (x as f32).to_radians(), 114 (z as f32).to_radians(), 115 ) 116 }) 117 .unwrap_or(Quat::IDENTITY); 118 119 let mut obj = RoomObject::new(obj_id, name, position) 120 .with_object_type(object_type_from_cell(obj_type)); 121 if let Some(url) = model_url { 122 obj = obj.with_model_url(url); 123 } 124 if let Some(loc) = location { 125 obj = obj.with_location(loc); 126 } 127 obj.rotation = rotation; 128 objects.push(obj); 129 } 130 131 // Recurse into children 132 for &child_id in space.children(id) { 133 collect_objects(space, child_id, objects, tilemap); 134 } 135 } 136 137 /// Build a protoverse Space from SpaceInfo and objects. 138 /// 139 /// Produces: (space (name ...) (group [tilemap] <objects...>)) 140 pub fn build_space(info: &SpaceInfo, objects: &[RoomObject]) -> Space { 141 let tilemap = info.tilemap.as_ref(); 142 let mut cells = Vec::new(); 143 let mut attributes = Vec::new(); 144 let mut child_ids = Vec::new(); 145 146 // Space attributes (just name) 147 let space_attr_start = attributes.len() as u32; 148 attributes.push(Attribute::Name(info.name.clone())); 149 let space_attr_count = (attributes.len() as u32 - space_attr_start) as u16; 150 151 // Space cell (index 0), child = group at index 1 152 let space_child_start = child_ids.len() as u32; 153 child_ids.push(CellId(1)); 154 cells.push(Cell { 155 cell_type: CellType::Space, 156 first_attr: space_attr_start, 157 attr_count: space_attr_count, 158 first_child: space_child_start, 159 child_count: 1, 160 parent: None, 161 }); 162 163 // Group cell (index 1), children start at index 2 164 let tilemap_offset: u32 = if tilemap.is_some() { 1 } else { 0 }; 165 let group_child_start = child_ids.len() as u32; 166 if tilemap.is_some() { 167 child_ids.push(CellId(2)); 168 } 169 for i in 0..objects.len() { 170 child_ids.push(CellId(2 + tilemap_offset + i as u32)); 171 } 172 let total_children = tilemap_offset as u16 + objects.len() as u16; 173 cells.push(Cell { 174 cell_type: CellType::Group, 175 first_attr: attributes.len() as u32, 176 attr_count: 0, 177 first_child: group_child_start, 178 child_count: total_children, 179 parent: Some(CellId(0)), 180 }); 181 182 // Tilemap cell (index 2, if present) 183 if let Some(tm) = tilemap { 184 build_tilemap_cell(tm, &mut cells, &mut attributes, &child_ids); 185 } 186 187 // Object cells 188 for obj in objects { 189 build_object_cell(obj, &mut cells, &mut attributes, &child_ids); 190 } 191 192 Space { 193 cells, 194 attributes, 195 child_ids, 196 root: CellId(0), 197 } 198 } 199 200 fn build_tilemap_cell( 201 tm: &TilemapData, 202 cells: &mut Vec<Cell>, 203 attributes: &mut Vec<Attribute>, 204 child_ids: &[CellId], 205 ) { 206 let attr_start = attributes.len() as u32; 207 attributes.push(Attribute::Width(tm.width as f64)); 208 attributes.push(Attribute::Height(tm.height as f64)); 209 attributes.push(Attribute::Tileset(tm.tileset.clone())); 210 attributes.push(Attribute::Data(tm.encode_data())); 211 212 cells.push(Cell { 213 cell_type: CellType::Tilemap, 214 first_attr: attr_start, 215 attr_count: (attributes.len() as u32 - attr_start) as u16, 216 first_child: child_ids.len() as u32, 217 child_count: 0, 218 parent: Some(CellId(1)), 219 }); 220 } 221 222 fn object_type_to_cell(obj_type: &RoomObjectType) -> CellType { 223 CellType::Object(match obj_type { 224 RoomObjectType::Table => ObjectType::Table, 225 RoomObjectType::Chair => ObjectType::Chair, 226 RoomObjectType::Door => ObjectType::Door, 227 RoomObjectType::Light => ObjectType::Light, 228 RoomObjectType::Prop => ObjectType::Custom("prop".to_string()), 229 RoomObjectType::Custom(s) => ObjectType::Custom(s.clone()), 230 }) 231 } 232 233 /// Build a single object Cell with its attributes and append to the Space vectors. 234 fn build_object_cell( 235 obj: &RoomObject, 236 cells: &mut Vec<Cell>, 237 attributes: &mut Vec<Attribute>, 238 child_ids: &[CellId], 239 ) { 240 let obj_attr_start = attributes.len() as u32; 241 242 attributes.push(Attribute::Id(obj.id.clone())); 243 attributes.push(Attribute::Name(obj.name.clone())); 244 if let Some(url) = &obj.model_url { 245 attributes.push(Attribute::ModelUrl(url.clone())); 246 } 247 if let Some(loc) = &obj.location { 248 attributes.push(Attribute::Location(location_to_protoverse(loc))); 249 } 250 251 // When the object has a resolved location base, save the offset 252 // from the base so that position remains relative to the location. 253 let pos = match obj.location_base { 254 Some(base) => obj.position - base, 255 None => obj.position, 256 }; 257 attributes.push(Attribute::Position( 258 pos.x as f64, 259 pos.y as f64, 260 pos.z as f64, 261 )); 262 263 // Only emit rotation when non-identity to keep output clean 264 if obj.rotation.angle_between(Quat::IDENTITY) > 1e-4 { 265 let (y, x, z) = obj.rotation.to_euler(glam::EulerRot::YXZ); 266 attributes.push(Attribute::Rotation( 267 x.to_degrees() as f64, 268 y.to_degrees() as f64, 269 z.to_degrees() as f64, 270 )); 271 } 272 273 cells.push(Cell { 274 cell_type: object_type_to_cell(&obj.object_type), 275 first_attr: obj_attr_start, 276 attr_count: (attributes.len() as u32 - obj_attr_start) as u16, 277 first_child: child_ids.len() as u32, 278 child_count: 0, 279 parent: Some(CellId(1)), 280 }); 281 } 282 283 #[cfg(test)] 284 mod tests { 285 use super::*; 286 use protoverse::parse; 287 288 #[test] 289 fn test_convert_simple_room() { 290 // Still accepts (room ...) for backward compatibility 291 let space = parse( 292 r#"(room (name "Test Room") (shape rectangle) (width 10) (height 5) (depth 8) 293 (group 294 (table (id desk) (name "My Desk") (position 1 0 2)) 295 (chair (id chair1) (name "Office Chair"))))"#, 296 ) 297 .unwrap(); 298 299 let data = convert_space(&space); 300 301 assert_eq!(data.info.name, "Test Room"); 302 303 assert_eq!(data.objects.len(), 2); 304 305 assert_eq!(data.objects[0].id, "desk"); 306 assert_eq!(data.objects[0].name, "My Desk"); 307 assert_eq!(data.objects[0].position, Vec3::new(1.0, 0.0, 2.0)); 308 assert!(matches!(data.objects[0].object_type, RoomObjectType::Table)); 309 310 assert_eq!(data.objects[1].id, "chair1"); 311 assert_eq!(data.objects[1].name, "Office Chair"); 312 assert_eq!(data.objects[1].position, Vec3::ZERO); 313 assert!(matches!(data.objects[1].object_type, RoomObjectType::Chair)); 314 } 315 316 #[test] 317 fn test_convert_with_model_url() { 318 let space = parse( 319 r#"(space (name "Gallery") 320 (group 321 (table (id t1) (name "Display Table") 322 (model-url "/models/table.glb") 323 (position 0 0 0))))"#, 324 ) 325 .unwrap(); 326 327 let data = convert_space(&space); 328 assert_eq!(data.objects.len(), 1); 329 assert_eq!( 330 data.objects[0].model_url.as_deref(), 331 Some("/models/table.glb") 332 ); 333 } 334 335 #[test] 336 fn test_convert_custom_object() { 337 let space = parse( 338 r#"(space (name "Test") 339 (group 340 (prop (id p1) (name "Water Bottle"))))"#, 341 ) 342 .unwrap(); 343 344 let data = convert_space(&space); 345 assert_eq!(data.objects.len(), 1); 346 assert_eq!(data.objects[0].id, "p1"); 347 assert_eq!(data.objects[0].name, "Water Bottle"); 348 } 349 350 #[test] 351 fn test_build_space_roundtrip() { 352 let info = SpaceInfo { 353 name: "My Space".to_string(), 354 tilemap: None, 355 }; 356 let objects = vec![ 357 RoomObject::new( 358 "desk".to_string(), 359 "Office Desk".to_string(), 360 Vec3::new(2.0, 0.0, 3.0), 361 ) 362 .with_object_type(RoomObjectType::Table) 363 .with_model_url("/models/desk.glb".to_string()), 364 RoomObject::new("lamp".to_string(), "Floor Lamp".to_string(), Vec3::ZERO) 365 .with_object_type(RoomObjectType::Light), 366 ]; 367 368 let space = build_space(&info, &objects); 369 370 // Serialize and re-parse 371 let serialized = protoverse::serialize(&space); 372 let reparsed = parse(&serialized).unwrap(); 373 374 // Convert back 375 let data = convert_space(&reparsed); 376 377 assert_eq!(data.info.name, "My Space"); 378 379 assert_eq!(data.objects.len(), 2); 380 assert_eq!(data.objects[0].id, "desk"); 381 assert_eq!(data.objects[0].name, "Office Desk"); 382 assert_eq!( 383 data.objects[0].model_url.as_deref(), 384 Some("/models/desk.glb") 385 ); 386 assert_eq!(data.objects[0].position, Vec3::new(2.0, 0.0, 3.0)); 387 assert!(matches!(data.objects[0].object_type, RoomObjectType::Table)); 388 389 assert_eq!(data.objects[1].id, "lamp"); 390 assert_eq!(data.objects[1].name, "Floor Lamp"); 391 assert!(matches!(data.objects[1].object_type, RoomObjectType::Light)); 392 } 393 394 #[test] 395 fn test_convert_defaults() { 396 let space = parse("(space)").unwrap(); 397 let data = convert_space(&space); 398 399 assert_eq!(data.info.name, "Untitled Space"); 400 assert!(data.objects.is_empty()); 401 } 402 403 #[test] 404 fn test_convert_location_top_of() { 405 let space = parse( 406 r#"(space (group 407 (table (id obj1) (name "Table") (position 0 0 0)) 408 (prop (id obj2) (name "Bottle") (location top-of obj1))))"#, 409 ) 410 .unwrap(); 411 412 let data = convert_space(&space); 413 assert_eq!(data.objects.len(), 2); 414 assert_eq!(data.objects[0].location, None); 415 assert_eq!( 416 data.objects[1].location, 417 Some(ObjectLocation::TopOf("obj1".to_string())) 418 ); 419 } 420 421 #[test] 422 fn test_build_space_always_emits_position() { 423 let info = SpaceInfo { 424 name: "Test".to_string(), 425 tilemap: None, 426 }; 427 let objects = vec![RoomObject::new( 428 "a".to_string(), 429 "Thing".to_string(), 430 Vec3::ZERO, 431 )]; 432 433 let space = build_space(&info, &objects); 434 let serialized = protoverse::serialize(&space); 435 436 // Position should appear even for Vec3::ZERO 437 assert!(serialized.contains("(position 0 0 0)")); 438 } 439 440 #[test] 441 fn test_build_space_location_roundtrip() { 442 let info = SpaceInfo { 443 name: "Test".to_string(), 444 tilemap: None, 445 }; 446 let objects = vec![ 447 RoomObject::new("obj1".to_string(), "Table".to_string(), Vec3::ZERO) 448 .with_object_type(RoomObjectType::Table), 449 RoomObject::new( 450 "obj2".to_string(), 451 "Bottle".to_string(), 452 Vec3::new(0.0, 1.5, 0.0), 453 ) 454 .with_location(ObjectLocation::TopOf("obj1".to_string())), 455 ]; 456 457 let space = build_space(&info, &objects); 458 let serialized = protoverse::serialize(&space); 459 let reparsed = parse(&serialized).unwrap(); 460 let data = convert_space(&reparsed); 461 462 assert_eq!( 463 data.objects[1].location, 464 Some(ObjectLocation::TopOf("obj1".to_string())) 465 ); 466 assert_eq!(data.objects[1].position, Vec3::new(0.0, 1.5, 0.0)); 467 } 468 469 #[test] 470 fn test_convert_tilemap() { 471 let space = parse( 472 r#"(space (name "Test") (group 473 (tilemap (width 5) (height 5) (tileset "grass" "stone") (data "0")) 474 (table (id t1) (name "Table") (position 0 0 0))))"#, 475 ) 476 .unwrap(); 477 478 let data = convert_space(&space); 479 assert_eq!(data.info.name, "Test"); 480 assert_eq!(data.objects.len(), 1); 481 assert_eq!(data.objects[0].id, "t1"); 482 483 let tm = data.info.tilemap.unwrap(); 484 assert_eq!(tm.width, 5); 485 assert_eq!(tm.height, 5); 486 assert_eq!(tm.tileset, vec!["grass", "stone"]); 487 assert_eq!(tm.tiles, vec![0]); // fill-all 488 } 489 490 #[test] 491 fn test_build_space_tilemap_roundtrip() { 492 let info = SpaceInfo { 493 name: "Test".to_string(), 494 tilemap: Some(TilemapData { 495 width: 8, 496 height: 8, 497 tileset: vec!["grass".to_string(), "water".to_string()], 498 tiles: vec![0], // fill-all 499 scene_object_id: None, 500 model_handle: None, 501 }), 502 }; 503 let objects = vec![RoomObject::new( 504 "a".to_string(), 505 "Thing".to_string(), 506 Vec3::ZERO, 507 )]; 508 509 let space = build_space(&info, &objects); 510 let serialized = protoverse::serialize(&space); 511 let reparsed = parse(&serialized).unwrap(); 512 let data = convert_space(&reparsed); 513 514 assert_eq!(data.objects.len(), 1); 515 assert_eq!(data.objects[0].id, "a"); 516 517 let tm = data.info.tilemap.unwrap(); 518 assert_eq!(tm.width, 8); 519 assert_eq!(tm.height, 8); 520 assert_eq!(tm.tileset, vec!["grass", "water"]); 521 assert_eq!(tm.tiles, vec![0]); 522 } 523 }