notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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 }