notedeck

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

commit 95f43aeeabaa83d6fa84201ba3dd58aa28c924c6
parent 342ee9404fe0c8d57e86ee92cf43cfa5f232c342
Author: William Casarin <jb55@jb55.com>
Date:   Mon, 23 Feb 2026 15:08:37 -0800

nostrverse: use scene graph parenting for relative object placement

Objects with semantic locations (TopOf, Near) are now placed as
children of their target in renderbud's scene graph, so they
automatically follow when the parent moves. Location offsets are
computed as local transforms relative to the parent origin.

Also fixes: save persistence (skip demo re-ingest when room exists),
self-echo prevention (track last save event ID), duplicate object
removal on save (preserve scene handles, remove orphans), and editor
offset display for location-based objects.

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

Diffstat:
Mcrates/notedeck_nostrverse/src/convert.rs | 7++++++-
Mcrates/notedeck_nostrverse/src/lib.rs | 163+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mcrates/notedeck_nostrverse/src/nostr_events.rs | 10+++++++---
Mcrates/notedeck_nostrverse/src/room_state.rs | 3+++
Mcrates/notedeck_nostrverse/src/room_view.rs | 22+++++++++++++---------
Mcrates/renderbud/src/lib.rs | 17+++++++++++++++++
6 files changed, 152 insertions(+), 70 deletions(-)

diff --git a/crates/notedeck_nostrverse/src/convert.rs b/crates/notedeck_nostrverse/src/convert.rs @@ -169,7 +169,12 @@ pub fn build_space(room: &Room, objects: &[RoomObject]) -> Space { if let Some(loc) = &obj.location { attributes.push(Attribute::Location(location_to_protoverse(loc))); } - let pos = obj.position; + // When the object has a resolved location base, save the offset + // from the base so that position remains relative to the location. + let pos = match obj.location_base { + Some(base) => obj.position - base, + None => obj.position, + }; attributes.push(Attribute::Position( pos.x as f64, pos.y as f64, diff --git a/crates/notedeck_nostrverse/src/lib.rs b/crates/notedeck_nostrverse/src/lib.rs @@ -91,6 +91,8 @@ pub struct NostrverseApp { presence_sub: Option<subscriptions::PresenceSubscription>, /// Cached room naddr string (avoids format! per frame) room_naddr: String, + /// Event ID of the last save we made (to skip our own echo in polls) + last_save_id: Option<[u8; 32]>, /// Monotonic time tracker (seconds since app start) start_time: std::time::Instant, } @@ -116,6 +118,7 @@ impl NostrverseApp { presence_expiry: presence::PresenceExpiry::new(), presence_sub: None, room_naddr, + last_save_id: None, start_time: std::time::Instant::now(), } } @@ -147,29 +150,34 @@ impl NostrverseApp { return; } - // 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; - } - }; - - // 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_event(builder, ctx.ndb, kp); - } - // Subscribe to room and presence events in local nostrdb self.room_sub = Some(subscriptions::RoomSubscription::new(ctx.ndb)); self.presence_sub = Some(subscriptions::PresenceSubscription::new(ctx.ndb)); - // Query for any existing room events (including the one we just ingested) + // Try to load an existing room from nostrdb first let txn = nostrdb::Transaction::new(ctx.ndb).expect("txn"); self.load_room_from_ndb(ctx.ndb, &txn); + // Only ingest the demo room if no saved room was found + if self.state.room.is_none() { + let space = match protoverse::parse(DEMO_SPACE) { + Ok(s) => s, + Err(e) => { + tracing::error!("Failed to parse demo space: {}", e); + return; + } + }; + + if let Some(kp) = ctx.accounts.selected_filled() { + let builder = nostr_events::build_room_event(&space, &self.state.room_ref.id); + nostr_events::ingest_event(builder, ctx.ndb, kp); + } + + // Re-load now that we've ingested the demo + 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![ @@ -213,9 +221,32 @@ impl NostrverseApp { } /// Apply a parsed Space to the room state: convert, load models, update state. + /// Preserves renderer scene handles for objects that still exist by ID, + /// and removes orphaned scene objects from the renderer. fn apply_space(&mut self, space: &protoverse::Space) { let (room, mut objects) = convert::convert_space(space); self.state.room = Some(room); + + // Transfer scene/model handles from existing objects with matching IDs + for new_obj in &mut objects { + if let Some(old_obj) = self.state.objects.iter().find(|o| o.id == new_obj.id) { + new_obj.scene_object_id = old_obj.scene_object_id; + new_obj.model_handle = old_obj.model_handle; + } + } + + // Remove orphaned scene objects (old objects not in the new set) + if let Some(renderer) = &self.renderer { + let mut r = renderer.renderer.lock().unwrap(); + for old_obj in &self.state.objects { + if let Some(scene_id) = old_obj.scene_object_id + && !objects.iter().any(|o| o.id == old_obj.id) + { + r.remove_object(scene_id); + } + } + } + self.load_object_models(&mut objects); self.state.objects = objects; self.state.dirty = false; @@ -245,7 +276,7 @@ impl NostrverseApp { } /// Save current room state: build Space, serialize, ingest as new nostr event. - fn save_room(&self, ctx: &mut AppContext<'_>) { + fn save_room(&mut self, ctx: &mut AppContext<'_>) { let Some(room) = &self.state.room else { tracing::warn!("save_room: no room to save"); return; @@ -257,7 +288,7 @@ impl NostrverseApp { let space = convert::build_space(room, &self.state.objects); let builder = nostr_events::build_room_event(&space, &self.state.room_ref.id); - nostr_events::ingest_event(builder, ctx.ndb, kp); + self.last_save_id = nostr_events::ingest_event(builder, ctx.ndb, kp); tracing::info!("Saved room '{}'", self.state.room_ref.id); } @@ -284,62 +315,49 @@ impl NostrverseApp { } } - // Phase 2: Resolve semantic locations to positions - // Collect resolved positions first to avoid borrow issues - let mut resolved: Vec<(usize, Vec3)> = Vec::new(); + // Phase 2: Resolve semantic locations to local offsets from parent. + // For parented objects (TopOf, Near), the position becomes local to the parent node. + // The location_base stores the bounds-derived offset so the editor can show user offset. + let mut resolved: Vec<(usize, Vec3, Vec3)> = Vec::new(); for (i, obj) in objects.iter().enumerate() { let Some(loc) = &obj.location else { continue; }; - match loc { + let local_base = match loc { room_state::ObjectLocation::TopOf(target_id) => { - // Find the target object's position and top-of-AABB - let target = objects.iter().find(|o| o.id == *target_id); - if let Some(target) = target { - let target_top = - bounds_by_id.get(target_id).map(|b| b.max.y).unwrap_or(0.0); - let self_half_h = bounds_by_id - .get(&obj.id) - .map(|b| (b.max.y - b.min.y) * 0.5) - .unwrap_or(0.0); - let pos = Vec3::new( - target.position.x, - target_top + self_half_h, - target.position.z, - ); - resolved.push((i, pos)); - } + let target_top = bounds_by_id.get(target_id).map(|b| b.max.y).unwrap_or(0.0); + let self_half_h = bounds_by_id + .get(&obj.id) + .map(|b| (b.max.y - b.min.y) * 0.5) + .unwrap_or(0.0); + Some(Vec3::new(0.0, target_top + self_half_h, 0.0)) } room_state::ObjectLocation::Near(target_id) => { - // Place nearby: offset by target's width + margin - let target = objects.iter().find(|o| o.id == *target_id); - if let Some(target) = target { - let offset = bounds_by_id - .get(target_id) - .map(|b| b.max.x - b.min.x) - .unwrap_or(1.0); - let pos = Vec3::new( - target.position.x + offset, - target.position.y, - target.position.z, - ); - resolved.push((i, pos)); - } + let offset = bounds_by_id + .get(target_id) + .map(|b| b.max.x - b.min.x) + .unwrap_or(1.0); + Some(Vec3::new(offset, 0.0, 0.0)) } room_state::ObjectLocation::Floor => { let self_half_h = bounds_by_id .get(&obj.id) .map(|b| (b.max.y - b.min.y) * 0.5) .unwrap_or(0.0); - resolved.push((i, Vec3::new(obj.position.x, self_half_h, obj.position.z))); + Some(Vec3::new(0.0, self_half_h, 0.0)) } - _ => {} + _ => None, + }; + + if let Some(base) = local_base { + resolved.push((i, base, base + obj.position)); } } - for (i, pos) in resolved { + for (i, base, pos) in resolved { + objects[i].location_base = Some(base); objects[i].position = pos; } } @@ -357,6 +375,14 @@ impl NostrverseApp { let notes = sub.poll(ndb, &txn); for note in &notes { + // Skip our own save — the in-memory state is already correct + if let Some(last_id) = &self.last_save_id + && note.id() == last_id + { + self.last_save_id = None; + continue; + } + let Some(room_id) = nostr_events::get_room_id(note) else { continue; }; @@ -437,7 +463,15 @@ impl NostrverseApp { }; let mut r = renderer.renderer.lock().unwrap(); - // Sync room objects + // Build map of object string ID -> scene ObjectId for parenting lookups + let mut id_to_scene: std::collections::HashMap<String, renderbud::ObjectId> = self + .state + .objects + .iter() + .filter_map(|obj| Some((obj.id.clone(), obj.scene_object_id?))) + .collect(); + + // Sync room objects to the scene graph for obj in &mut self.state.objects { let transform = Transform { translation: obj.position, @@ -448,8 +482,23 @@ impl NostrverseApp { if let Some(scene_id) = obj.scene_object_id { r.update_object_transform(scene_id, transform); } else if let Some(model) = obj.model_handle { - let scene_id = r.place_object(model, transform); + // Find parent scene node for objects with location references + let parent_scene_id = obj.location.as_ref().and_then(|loc| match loc { + room_state::ObjectLocation::TopOf(target_id) + | room_state::ObjectLocation::Near(target_id) => { + id_to_scene.get(target_id).copied() + } + _ => None, + }); + + let scene_id = if let Some(parent_id) = parent_scene_id { + r.place_object_with_parent(model, transform, parent_id) + } else { + r.place_object(model, transform) + }; + obj.scene_object_id = Some(scene_id); + id_to_scene.insert(obj.id.clone(), scene_id); } } diff --git a/crates/notedeck_nostrverse/src/nostr_events.rs b/crates/notedeck_nostrverse/src/nostr_events.rs @@ -132,23 +132,27 @@ pub fn get_presence_room<'a>(note: &'a Note<'a>) -> Option<&'a str> { } /// Sign and ingest a nostr event into the local nostrdb only (no relay publishing). -pub fn ingest_event(builder: NoteBuilder<'_>, ndb: &Ndb, kp: FilledKeypair) { +/// Returns the 32-byte event ID on success. +pub fn ingest_event(builder: NoteBuilder<'_>, ndb: &Ndb, kp: FilledKeypair) -> Option<[u8; 32]> { let note = builder .sign(&kp.secret_key.secret_bytes()) .build() .expect("build note"); + let id = *note.id(); + let Ok(event) = &enostr::ClientMessage::event(&note) else { tracing::error!("ingest_event: failed to build client message"); - return; + return None; }; let Ok(json) = event.to_json() else { tracing::error!("ingest_event: failed to serialize json"); - return; + return None; }; let _ = ndb.process_event_with(&json, nostrdb::IngestMetadata::new().client(true)); + Some(id) } #[cfg(test)] diff --git a/crates/notedeck_nostrverse/src/room_state.rs b/crates/notedeck_nostrverse/src/room_state.rs @@ -109,6 +109,8 @@ pub struct RoomObject { pub location: Option<ObjectLocation>, /// 3D position in world space pub position: Vec3, + /// Base position from resolved location (used to compute offset for saving) + pub location_base: Option<Vec3>, /// 3D rotation pub rotation: Quat, /// 3D scale @@ -128,6 +130,7 @@ impl RoomObject { model_url: None, location: None, position, + location_base: None, rotation: Quat::IDENTITY, scale: Vec3::ONE, scene_object_id: None, diff --git a/crates/notedeck_nostrverse/src/room_view.rs b/crates/notedeck_nostrverse/src/room_view.rs @@ -247,26 +247,30 @@ pub fn render_editing_panel(ui: &mut Ui, state: &mut NostrverseState) -> Option< }) .inner; - // Editable position - let mut px = obj.position.x; - let mut py = obj.position.y; - let mut pz = obj.position.z; + // Edit offset (relative to location base) or absolute position + let base = obj.location_base.unwrap_or(Vec3::ZERO); + let offset = obj.position - base; + let mut ox = offset.x; + let mut oy = offset.y; + let mut oz = offset.z; + let has_location = obj.location.is_some(); + let pos_label = if has_location { "Offset:" } else { "Pos:" }; let pos_changed = ui .horizontal(|ui| { - ui.label("Pos:"); + ui.label(pos_label); let x = ui - .add(egui::DragValue::new(&mut px).speed(0.1).prefix("x:")) + .add(egui::DragValue::new(&mut ox).speed(0.1).prefix("x:")) .changed(); let y = ui - .add(egui::DragValue::new(&mut py).speed(0.1).prefix("y:")) + .add(egui::DragValue::new(&mut oy).speed(0.1).prefix("y:")) .changed(); let z = ui - .add(egui::DragValue::new(&mut pz).speed(0.1).prefix("z:")) + .add(egui::DragValue::new(&mut oz).speed(0.1).prefix("z:")) .changed(); x || y || z }) .inner; - obj.position = Vec3::new(px, py, pz); + obj.position = base + Vec3::new(ox, oy, oz); // Editable scale (uniform) let mut sx = obj.scale.x; diff --git a/crates/renderbud/src/lib.rs b/crates/renderbud/src/lib.rs @@ -637,6 +637,23 @@ impl Renderer { self.world.add_object(model, transform) } + /// Place a loaded model as a child of an existing scene node. + /// The transform is local (relative to the parent). + pub fn place_object_with_parent( + &mut self, + model: Model, + transform: Transform, + parent: ObjectId, + ) -> ObjectId { + self.world.create_renderable(model, transform, Some(parent)) + } + + /// Set or clear the parent of a scene object. + /// When parented, the object's transform becomes local to the parent. + pub fn set_parent(&mut self, id: ObjectId, parent: Option<ObjectId>) -> bool { + self.world.set_parent(id, parent) + } + /// Remove an object from the scene. pub fn remove_object(&mut self, id: ObjectId) -> bool { self.world.remove_object(id)