notedeck

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

commit cc40f0a30aab2d295e393334b71c5ce165999bf7
parent 03eac33213245aa33bfb3f8e43f6414d21fba48e
Author: William Casarin <jb55@jb55.com>
Date:   Sun, 22 Feb 2026 12:17:46 -0800

nostrverse: load rooms through nostr events via nostrdb

Room data now flows through nostr events instead of being constructed
directly from parsed .space data. The demo room is ingested as a
signed kind 37555 event into the local nostrdb, then loaded back via
subscription queries — the same path real rooms will use.

- Add nostr_events module: build/parse room events, local ingestion
- Add subscriptions module: local nostrdb subscription for room events
- Add convert module: protoverse Space AST to Room/RoomObject conversion
- Extend protoverse parser: position, model-url attrs, custom object types
- Add Space accessor helpers: id_str, position, model_url, width, etc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
MCargo.lock | 1+
Mcrates/notedeck_nostrverse/Cargo.toml | 1+
Acrates/notedeck_nostrverse/src/convert.rs | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_nostrverse/src/lib.rs | 217+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Acrates/notedeck_nostrverse/src/nostr_events.rs | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_nostrverse/src/subscriptions.rs | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/protoverse/src/ast.rs | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/protoverse/src/parser.rs | 17+++++++++++++----
Mcrates/protoverse/src/serializer.rs | 12++++++++++++
9 files changed, 602 insertions(+), 65 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4299,6 +4299,7 @@ dependencies = [ "glam", "nostrdb", "notedeck", + "protoverse", "renderbud", "tracing", ] diff --git a/crates/notedeck_nostrverse/Cargo.toml b/crates/notedeck_nostrverse/Cargo.toml @@ -10,5 +10,6 @@ egui-wgpu = { workspace = true } enostr = { workspace = true } glam = { workspace = true } nostrdb = { workspace = true } +protoverse = { path = "../protoverse" } renderbud = { workspace = true } tracing = { workspace = true } diff --git a/crates/notedeck_nostrverse/src/convert.rs b/crates/notedeck_nostrverse/src/convert.rs @@ -0,0 +1,151 @@ +//! Convert protoverse Space AST to renderer room state. + +use crate::room_state::{Room, RoomObject, RoomShape}; +use glam::Vec3; +use protoverse::{CellId, CellType, Shape, Space}; + +/// Convert a parsed protoverse Space into a Room and its objects. +pub fn convert_space(space: &Space) -> (Room, Vec<RoomObject>) { + let room = extract_room(space, space.root); + let mut objects = Vec::new(); + collect_objects(space, space.root, &mut objects); + (room, objects) +} + +fn extract_room(space: &Space, id: CellId) -> Room { + let name = space.name(id).unwrap_or("Untitled Room").to_string(); + + let shape = match space.shape(id) { + Some(Shape::Rectangle) | Some(Shape::Square) => RoomShape::Rectangle, + Some(Shape::Circle) => RoomShape::Circle, + None => RoomShape::Rectangle, + }; + + let width = space.width(id).unwrap_or(20.0) as f32; + let height = space.height(id).unwrap_or(15.0) as f32; + let depth = space.depth(id).unwrap_or(10.0) as f32; + + Room { + name, + shape, + width, + height, + depth, + } +} + +fn collect_objects(space: &Space, id: CellId, objects: &mut Vec<RoomObject>) { + let cell = space.cell(id); + + if let CellType::Object(_) = &cell.cell_type { + let obj_id = space.id_str(id).unwrap_or_else(|| "").to_string(); + + // Generate a fallback id if none specified + let obj_id = if obj_id.is_empty() { + format!("obj-{}", id.0) + } else { + obj_id + }; + + let name = space + .name(id) + .map(|s| s.to_string()) + .unwrap_or_else(|| cell.cell_type.to_string()); + + let position = space + .position(id) + .map(|(x, y, z)| Vec3::new(x as f32, y as f32, z as f32)) + .unwrap_or(Vec3::ZERO); + + let model_url = space.model_url(id).map(|s| s.to_string()); + + let mut obj = RoomObject::new(obj_id, name, position); + if let Some(url) = model_url { + obj = obj.with_model_url(url); + } + objects.push(obj); + } + + // Recurse into children + for &child_id in space.children(id) { + collect_objects(space, child_id, objects); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use protoverse::parse; + + #[test] + fn test_convert_simple_room() { + let space = parse( + r#"(room (name "Test Room") (shape rectangle) (width 10) (height 5) (depth 8) + (group + (table (id desk) (name "My Desk") (position 1 0 2)) + (chair (id chair1) (name "Office Chair"))))"#, + ) + .unwrap(); + + let (room, objects) = convert_space(&space); + + assert_eq!(room.name, "Test Room"); + assert_eq!(room.shape, RoomShape::Rectangle); + assert_eq!(room.width, 10.0); + assert_eq!(room.height, 5.0); + assert_eq!(room.depth, 8.0); + + assert_eq!(objects.len(), 2); + + assert_eq!(objects[0].id, "desk"); + assert_eq!(objects[0].name, "My Desk"); + assert_eq!(objects[0].position, Vec3::new(1.0, 0.0, 2.0)); + + assert_eq!(objects[1].id, "chair1"); + assert_eq!(objects[1].name, "Office Chair"); + assert_eq!(objects[1].position, Vec3::ZERO); + } + + #[test] + fn test_convert_with_model_url() { + let space = parse( + r#"(room (name "Gallery") + (group + (table (id t1) (name "Display Table") + (model-url "/models/table.glb") + (position 0 0 0))))"#, + ) + .unwrap(); + + let (_, objects) = convert_space(&space); + assert_eq!(objects.len(), 1); + assert_eq!(objects[0].model_url.as_deref(), Some("/models/table.glb")); + } + + #[test] + fn test_convert_custom_object() { + let space = parse( + r#"(room (name "Test") + (group + (prop (id p1) (name "Water Bottle"))))"#, + ) + .unwrap(); + + let (_, objects) = convert_space(&space); + assert_eq!(objects.len(), 1); + assert_eq!(objects[0].id, "p1"); + assert_eq!(objects[0].name, "Water Bottle"); + } + + #[test] + fn test_convert_defaults() { + let space = parse("(room)").unwrap(); + let (room, objects) = convert_space(&space); + + assert_eq!(room.name, "Untitled Room"); + assert_eq!(room.width, 20.0); + assert_eq!(room.height, 15.0); + assert_eq!(room.depth, 10.0); + assert!(objects.is_empty()); + } +} diff --git a/crates/notedeck_nostrverse/src/lib.rs b/crates/notedeck_nostrverse/src/lib.rs @@ -6,8 +6,11 @@ //! Rooms are rendered as 3D scenes using renderbud's PBR pipeline, //! embedded in egui via wgpu paint callbacks. +mod convert; +mod nostr_events; mod room_state; mod room_view; +mod subscriptions; pub use room_state::{ NostrverseAction, NostrverseState, Room, RoomObject, RoomRef, RoomShape, RoomUser, @@ -36,6 +39,15 @@ const AVATAR_SCALE: f32 = 7.0; /// How fast the avatar yaw lerps toward the target (higher = faster) const AVATAR_YAW_LERP_SPEED: f32 = 10.0; +/// Demo room in protoverse .space format +const DEMO_SPACE: &str = r#"(room (name "Demo Room") (shape rectangle) (width 20) (height 15) (depth 10) + (group + (table (id obj1) (name "Ironwood Table") + (model-url "/home/jb55/var/models/ironwood/ironwood.glb") + (position 0 0 0)) + (prop (id obj2) (name "Water Bottle") + (model-url "/home/jb55/var/models/WaterBottle.glb"))))"#; + /// Event kinds for nostrverse pub mod kinds { /// Room event kind (addressable) @@ -56,10 +68,12 @@ pub struct NostrverseApp { device: Option<wgpu::Device>, /// GPU queue for model loading (Arc-wrapped internally by wgpu) queue: Option<wgpu::Queue>, - /// Whether the app has been initialized with demo data + /// Whether the app has been initialized initialized: bool, /// Cached avatar model AABB for ground placement avatar_bounds: Option<renderbud::Aabb>, + /// Local nostrdb subscription for room events + room_sub: Option<subscriptions::RoomSubscription>, } impl NostrverseApp { @@ -77,6 +91,7 @@ impl NostrverseApp { queue, initialized: false, avatar_bounds: None, + room_sub: None, } } @@ -101,81 +116,61 @@ impl NostrverseApp { } } - /// Initialize with demo data (for testing) - fn init_demo_data(&mut self) { + /// Initialize: ingest demo room into local nostrdb and subscribe. + fn initialize(&mut self, ctx: &mut AppContext<'_>) { if self.initialized { return; } - // Set up demo room - self.state.room = Some(Room { - name: "Demo Room".to_string(), - shape: RoomShape::Rectangle, - width: 20.0, - height: 15.0, - depth: 10.0, - }); - - // Load test models from disk - let bottle = self.load_model("/home/jb55/var/models/WaterBottle.glb"); - let ironwood = self.load_model("/home/jb55/var/models/ironwood/ironwood.glb"); - - // Query AABBs for placement - let renderer = self.renderer.as_ref(); - let model_bounds = |m: Option<renderbud::Model>| -> Option<renderbud::Aabb> { - let r = renderer?.renderer.lock().unwrap(); - r.model_bounds(m?) + // Parse the demo room and ingest it as a local nostr event + let space = match protoverse::parse(DEMO_SPACE) { + Ok(s) => s, + Err(e) => { + tracing::error!("Failed to parse demo space: {}", e); + return; + } }; - let table_bounds = model_bounds(ironwood); - let bottle_bounds = model_bounds(bottle); + // Ingest as a local-only room event if we have a keypair + if let Some(kp) = ctx.accounts.selected_filled() { + let builder = nostr_events::build_room_event(&space, &self.state.room_ref.id); + nostr_events::ingest_room_event(builder, ctx.ndb, kp); + } - // Table top Y (in model space, 1 unit = 1 meter) - let table_top_y = table_bounds.map(|b| b.max.y).unwrap_or(0.86); - // Bottle half-height (real-world scale, ~0.26m tall) - let bottle_half_h = bottle_bounds - .map(|b| (b.max.y - b.min.y) * 0.5) - .unwrap_or(0.0); + // Subscribe to room events in local nostrdb + self.room_sub = Some(subscriptions::RoomSubscription::new(ctx.ndb)); - // Ironwood (table) at origin - let mut obj1 = RoomObject::new( - "obj1".to_string(), - "Ironwood Table".to_string(), - Vec3::new(0.0, 0.0, 0.0), - ) - .with_scale(Vec3::splat(1.0)); - obj1.model_handle = ironwood; - - // Water bottle on top of the table: table_top + half bottle height - let mut obj2 = RoomObject::new( - "obj2".to_string(), - "Water Bottle".to_string(), - Vec3::new(0.0, table_top_y + bottle_half_h, 0.0), - ) - .with_scale(Vec3::splat(1.0)); - obj2.model_handle = bottle; - - self.state.objects = vec![obj1, obj2]; + // Query for any existing room events (including the one we just ingested) + let txn = nostrdb::Transaction::new(ctx.ndb).expect("txn"); + self.load_room_from_ndb(ctx.ndb, &txn); // Add self user + let self_pubkey = *ctx.accounts.selected_account_pubkey(); self.state.users = vec![ - RoomUser::new( - demo_pubkey(), - "jb55".to_string(), - Vec3::new(-2.0, 0.0, -2.0), - ) - .with_self(true), + RoomUser::new(self_pubkey, "jb55".to_string(), Vec3::new(-2.0, 0.0, -2.0)) + .with_self(true), ]; - // Assign the bottle model as avatar placeholder for all users - if let Some(model) = bottle { + // Assign avatar model (use first model with id "obj2" as placeholder) + let avatar_model = self + .state + .objects + .iter() + .find(|o| o.id == "obj2") + .and_then(|o| o.model_handle); + let avatar_bounds = avatar_model.and_then(|m| { + let renderer = self.renderer.as_ref()?; + let r = renderer.renderer.lock().unwrap(); + r.model_bounds(m) + }); + if let Some(model) = avatar_model { for user in &mut self.state.users { user.model_handle = Some(model); } } - self.avatar_bounds = bottle_bounds; + self.avatar_bounds = avatar_bounds; - // Switch to third-person camera mode centered on the self-user + // Switch to third-person camera mode if let Some(renderer) = &self.renderer { let self_pos = self .state @@ -191,6 +186,103 @@ impl NostrverseApp { self.initialized = true; } + /// Load room state from a nostrdb query result. + fn load_room_from_ndb(&mut self, ndb: &nostrdb::Ndb, txn: &nostrdb::Transaction) { + let notes = subscriptions::RoomSubscription::query_existing(ndb, txn); + + for note in &notes { + // Find the room matching our room_ref + let Some(room_id) = nostr_events::get_room_id(note) else { + continue; + }; + if room_id != self.state.room_ref.id { + continue; + } + + let Some(space) = nostr_events::parse_room_event(note) else { + tracing::warn!("Failed to parse room event content"); + continue; + }; + + let (room, mut objects) = convert::convert_space(&space); + self.state.room = Some(room); + + // Load models and compute placement + self.load_object_models(&mut objects); + self.state.objects = objects; + + tracing::info!("Loaded room '{}' from nostrdb", room_id); + return; + } + } + + /// Load 3D models for objects and handle AABB-based placement. + fn load_object_models(&self, objects: &mut Vec<RoomObject>) { + let renderer = self.renderer.as_ref(); + let model_bounds_fn = |m: Option<renderbud::Model>| -> Option<renderbud::Aabb> { + let r = renderer?.renderer.lock().unwrap(); + r.model_bounds(m?) + }; + + let mut table_top_y: f32 = 0.86; + let mut bottle_bounds = None; + + for obj in objects.iter_mut() { + if let Some(url) = &obj.model_url { + let model = self.load_model(url); + let bounds = model_bounds_fn(model); + + if obj.id == "obj1" { + if let Some(b) = bounds { + table_top_y = b.max.y; + } + } + + if obj.id == "obj2" { + bottle_bounds = bounds; + } + + obj.model_handle = model; + } + } + + // Position the bottle on top of the table (runtime AABB placement) + if let Some(obj2) = objects.iter_mut().find(|o| o.id == "obj2") { + let bottle_half_h = bottle_bounds + .map(|b| (b.max.y - b.min.y) * 0.5) + .unwrap_or(0.0); + obj2.position = Vec3::new(0.0, table_top_y + bottle_half_h, 0.0); + } + } + + /// Poll the room subscription for updates. + fn poll_room_updates(&mut self, ndb: &nostrdb::Ndb) { + let Some(sub) = &self.room_sub else { + return; + }; + let txn = nostrdb::Transaction::new(ndb).expect("txn"); + let notes = sub.poll(ndb, &txn); + + for note in &notes { + let Some(room_id) = nostr_events::get_room_id(note) else { + continue; + }; + if room_id != self.state.room_ref.id { + continue; + } + + let Some(space) = nostr_events::parse_room_event(note) else { + continue; + }; + + let (room, mut objects) = convert::convert_space(&space); + self.state.room = Some(room); + self.load_object_models(&mut objects); + self.state.objects = objects; + tracing::info!("Room '{}' updated from nostrdb", room_id); + } + } + /// Sync room objects and user avatars to the renderbud scene fn sync_scene(&mut self) { let Some(renderer) = &self.renderer else { @@ -277,9 +369,12 @@ impl NostrverseApp { } impl notedeck::App for NostrverseApp { - fn update(&mut self, _ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> AppResponse { - // Initialize demo data on first frame - self.init_demo_data(); + fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> AppResponse { + // Initialize on first frame + self.initialize(ctx); + + // Poll for room event updates + self.poll_room_updates(ctx.ndb); // Sync state to 3D scene self.sync_scene(); diff --git a/crates/notedeck_nostrverse/src/nostr_events.rs b/crates/notedeck_nostrverse/src/nostr_events.rs @@ -0,0 +1,162 @@ +//! Nostr event creation and parsing for nostrverse rooms. +//! +//! Room events (kind 37555) are NIP-33 parameterized replaceable events +//! where the content is a protoverse `.space` s-expression. + +use enostr::FilledKeypair; +use nostrdb::{Ndb, Note, NoteBuilder}; +use protoverse::Space; + +use crate::kinds; + +/// Build a room event (kind 37555) from a protoverse Space. +/// +/// Tags: ["d", room_id], ["name", room_name], ["summary", text_description] +/// Content: serialized .space s-expression +pub fn build_room_event<'a>(space: &Space, room_id: &str) -> NoteBuilder<'a> { + let content = protoverse::serialize(space); + let summary = protoverse::describe(space); + let name = space.name(space.root).unwrap_or("Untitled Room"); + + NoteBuilder::new() + .kind(kinds::ROOM as u32) + .content(&content) + .start_tag() + .tag_str("d") + .tag_str(room_id) + .start_tag() + .tag_str("name") + .tag_str(name) + .start_tag() + .tag_str("summary") + .tag_str(&summary) +} + +/// Parse a room event's content into a protoverse Space. +pub fn parse_room_event(note: &Note<'_>) -> Option<Space> { + let content = note.content(); + if content.is_empty() { + return None; + } + protoverse::parse(content).ok() +} + +/// Extract the "d" tag (room identifier) from a note. +pub fn get_room_id<'a>(note: &'a Note<'a>) -> Option<&'a str> { + get_tag_value(note, "d") +} + +/// Extract a tag value by name from a note. +fn get_tag_value<'a>(note: &'a Note<'a>, tag_name: &str) -> Option<&'a str> { + for tag in note.tags() { + if tag.count() < 2 { + continue; + } + let Some(name) = tag.get_str(0) else { + continue; + }; + if name != tag_name { + continue; + } + return tag.get_str(1); + } + None +} + +/// Sign and ingest a room event into the local nostrdb only (no relay publishing). +pub fn ingest_room_event(builder: NoteBuilder<'_>, ndb: &Ndb, kp: FilledKeypair) { + let note = builder + .sign(&kp.secret_key.secret_bytes()) + .build() + .expect("build note"); + + let Ok(event) = &enostr::ClientMessage::event(&note) else { + tracing::error!("ingest_room_event: failed to build client message"); + return; + }; + + let Ok(json) = event.to_json() else { + tracing::error!("ingest_room_event: failed to serialize json"); + return; + }; + + let _ = ndb.process_event_with(&json, nostrdb::IngestMetadata::new().client(true)); + tracing::info!("ingested room event locally"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_room_event() { + let space = protoverse::parse( + r#"(room (name "Test Room") (shape rectangle) (width 10) (depth 8) + (group (table (id desk) (name "My Desk"))))"#, + ) + .unwrap(); + + let mut builder = build_room_event(&space, "my-room"); + let note = builder.build().expect("build note"); + + // Content should be the serialized space + let content = note.content(); + assert!(content.contains("room")); + assert!(content.contains("Test Room")); + + // Should have d, name, summary tags + let mut has_d = false; + let mut has_name = false; + let mut has_summary = false; + + for tag in note.tags() { + if tag.count() < 2 { + continue; + } + match tag.get_str(0) { + Some("d") => { + assert_eq!(tag.get_str(1), Some("my-room")); + has_d = true; + } + Some("name") => { + assert_eq!(tag.get_str(1), Some("Test Room")); + has_name = true; + } + Some("summary") => { + has_summary = true; + } + _ => {} + } + } + + assert!(has_d, "missing d tag"); + assert!(has_name, "missing name tag"); + assert!(has_summary, "missing summary tag"); + } + + #[test] + fn test_parse_room_event_roundtrip() { + let original = r#"(room (name "Test Room") (shape rectangle) (width 10) (depth 8) + (group (table (id desk) (name "My Desk"))))"#; + + let space = protoverse::parse(original).unwrap(); + let mut builder = build_room_event(&space, "test-room"); + let note = builder.build().expect("build note"); + + // Parse the event content back into a Space + let parsed = parse_room_event(&note).expect("parse room event"); + assert_eq!(parsed.name(parsed.root), Some("Test Room")); + + // Should have same structure + assert_eq!(space.cells.len(), parsed.cells.len()); + } + + #[test] + fn test_get_room_id() { + let space = protoverse::parse("(room (name \"X\"))").unwrap(); + let mut builder = build_room_event(&space, "my-id"); + let note = builder.build().expect("build note"); + + assert_eq!(get_room_id(&note), Some("my-id")); + } +} diff --git a/crates/notedeck_nostrverse/src/subscriptions.rs b/crates/notedeck_nostrverse/src/subscriptions.rs @@ -0,0 +1,53 @@ +//! Local nostrdb subscription management for nostrverse rooms. +//! +//! Subscribes to room events (kind 37555) in the local nostrdb and +//! polls for updates each frame. No remote relay subscriptions — rooms +//! are local-only for now. + +use nostrdb::{Filter, Ndb, Note, Subscription, Transaction}; + +use crate::kinds; + +/// Manages a local nostrdb subscription for room events. +pub struct RoomSubscription { + /// Local nostrdb subscription handle + sub: Subscription, +} + +impl RoomSubscription { + /// Subscribe to all room events (kind 37555) in the local nostrdb. + pub fn new(ndb: &Ndb) -> Self { + let filter = Filter::new().kinds([kinds::ROOM as u64]).build(); + let sub = ndb.subscribe(&[filter]).expect("room subscription"); + Self { sub } + } + + /// Subscribe to room events from a specific author. + pub fn for_author(ndb: &Ndb, author: &[u8; 32]) -> Self { + let filter = Filter::new() + .kinds([kinds::ROOM as u64]) + .authors([author]) + .build(); + let sub = ndb.subscribe(&[filter]).expect("room subscription"); + Self { sub } + } + + /// Poll for new room events. Returns parsed notes. + pub fn poll<'a>(&self, ndb: &'a Ndb, txn: &'a Transaction) -> Vec<Note<'a>> { + let note_keys = ndb.poll_for_notes(self.sub, 50); + note_keys + .into_iter() + .filter_map(|nk| ndb.get_note_by_key(txn, nk).ok()) + .collect() + } + + /// Query for existing room events (e.g. on startup). + pub fn query_existing<'a>(ndb: &'a Ndb, txn: &'a Transaction) -> Vec<Note<'a>> { + let filter = Filter::new().kinds([kinds::ROOM as u64]).limit(50).build(); + ndb.query(txn, &[filter], 50) + .unwrap_or_default() + .into_iter() + .map(|qr| qr.note) + .collect() + } +} diff --git a/crates/protoverse/src/ast.rs b/crates/protoverse/src/ast.rs @@ -48,6 +48,7 @@ pub enum ObjectType { Chair, Door, Light, + Custom(String), } #[derive(Clone, Debug, PartialEq)] @@ -63,6 +64,8 @@ pub enum Attribute { Height(f64), Location(String), State(CellState), + Position(f64, f64, f64), + ModelUrl(String), } #[derive(Clone, Copy, Debug, PartialEq)] @@ -88,6 +91,7 @@ impl fmt::Display for ObjectType { ObjectType::Chair => write!(f, "chair"), ObjectType::Door => write!(f, "door"), ObjectType::Light => write!(f, "light"), + ObjectType::Custom(s) => write!(f, "{}", s), } } } @@ -157,4 +161,53 @@ impl Space { { self.attrs(id).iter().find(|a| pred(a)) } + + pub fn id_str(&self, id: CellId) -> Option<&str> { + self.attrs(id).iter().find_map(|a| match a { + Attribute::Id(s) => Some(s.as_str()), + _ => None, + }) + } + + pub fn position(&self, id: CellId) -> Option<(f64, f64, f64)> { + self.attrs(id).iter().find_map(|a| match a { + Attribute::Position(x, y, z) => Some((*x, *y, *z)), + _ => None, + }) + } + + pub fn model_url(&self, id: CellId) -> Option<&str> { + self.attrs(id).iter().find_map(|a| match a { + Attribute::ModelUrl(s) => Some(s.as_str()), + _ => None, + }) + } + + pub fn width(&self, id: CellId) -> Option<f64> { + self.attrs(id).iter().find_map(|a| match a { + Attribute::Width(n) => Some(*n), + _ => None, + }) + } + + pub fn height(&self, id: CellId) -> Option<f64> { + self.attrs(id).iter().find_map(|a| match a { + Attribute::Height(n) => Some(*n), + _ => None, + }) + } + + pub fn depth(&self, id: CellId) -> Option<f64> { + self.attrs(id).iter().find_map(|a| match a { + Attribute::Depth(n) => Some(*n), + _ => None, + }) + } + + pub fn shape(&self, id: CellId) -> Option<&Shape> { + self.attrs(id).iter().find_map(|a| match a { + Attribute::Shape(s) => Some(s), + _ => None, + }) + } } diff --git a/crates/protoverse/src/parser.rs b/crates/protoverse/src/parser.rs @@ -193,6 +193,18 @@ impl<'a> Parser<'a> { "width" => self.eat_number().map(Attribute::Width), "height" => self.eat_number().map(Attribute::Height), "depth" => self.eat_number().map(Attribute::Depth), + "position" => { + let x = self.eat_number(); + let y = self.eat_number(); + let z = self.eat_number(); + match (x, y, z) { + (Some(x), Some(y), Some(z)) => Some(Attribute::Position(x, y, z)), + _ => None, + } + } + "model-url" => self + .eat_string() + .map(|s| Attribute::ModelUrl(s.to_string())), _ => None, }; @@ -339,10 +351,7 @@ impl<'a> Parser<'a> { "chair" => ObjectType::Chair, "door" => ObjectType::Door, "light" => ObjectType::Light, - _ => { - self.restore(cp); - return None; - } + _ => ObjectType::Custom(sym.to_string()), }; match self.parse_cell_attrs(CellType::Object(obj_type)) { diff --git a/crates/protoverse/src/serializer.rs b/crates/protoverse/src/serializer.rs @@ -89,6 +89,18 @@ fn write_attr(attr: &Attribute, out: &mut String) { Attribute::Depth(n) => { let _ = write!(out, "(depth {})", format_number(*n)); } + Attribute::Position(x, y, z) => { + let _ = write!( + out, + "(position {} {} {})", + format_number(*x), + format_number(*y), + format_number(*z) + ); + } + Attribute::ModelUrl(s) => { + let _ = write!(out, "(model-url \"{}\")", s); + } } }