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:
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 ¬es {
+ // 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 ¬es {
+ 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(¬e) 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(¬e).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(¬e), 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);
+ }
}
}