notedeck

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

lib.rs (36729B)


      1 //! Nostrverse: Virtual spaces as Nostr events
      2 //!
      3 //! This app implements spatial views for nostrverse - a protocol where
      4 //! spaces and objects are Nostr events (kinds 37555, 37556, 10555).
      5 //!
      6 //! Spaces are rendered as 3D scenes using renderbud's PBR pipeline,
      7 //! embedded in egui via wgpu paint callbacks.
      8 
      9 mod convert;
     10 mod model_cache;
     11 mod nostr_events;
     12 mod presence;
     13 mod room_state;
     14 mod room_view;
     15 mod subscriptions;
     16 mod tilemap;
     17 
     18 pub use room_state::{
     19     NostrverseAction, NostrverseState, RoomObject, RoomObjectType, RoomUser, SpaceData, SpaceInfo,
     20     SpaceRef,
     21 };
     22 pub use room_view::{NostrverseResponse, render_editing_panel, show_room_view};
     23 
     24 use enostr::{NormRelayUrl, Pubkey, RelayId};
     25 use glam::Vec3;
     26 use nostrdb::Filter;
     27 use notedeck::{
     28     AppContext, AppResponse, RelaySelection, ScopedSubIdentity, SubConfig, SubKey, SubOwnerKey,
     29 };
     30 use renderbud::Transform;
     31 
     32 use egui_wgpu::wgpu;
     33 
     34 /// Demo pubkey (jb55) used for testing
     35 const DEMO_PUBKEY_HEX: &str = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245";
     36 const FALLBACK_PUBKEY_HEX: &str =
     37     "0000000000000000000000000000000000000000000000000000000000000001";
     38 
     39 fn demo_pubkey() -> Pubkey {
     40     Pubkey::from_hex(DEMO_PUBKEY_HEX)
     41         .unwrap_or_else(|_| Pubkey::from_hex(FALLBACK_PUBKEY_HEX).unwrap())
     42 }
     43 
     44 /// Scoped-sub identity for nostrverse's dedicated relay room/presence feed.
     45 fn nostrverse_remote_sub_identity() -> ScopedSubIdentity {
     46     ScopedSubIdentity::account(
     47         SubOwnerKey::new("nostrverse-owner"),
     48         SubKey::new("nostrverse-room-presence"),
     49     )
     50 }
     51 
     52 /// Publish a locally ingested note to the dedicated nostrverse relay.
     53 fn publish_ingested_note(
     54     publisher: &mut notedeck::ExplicitPublishApi<'_, '_>,
     55     relay_url: &NormRelayUrl,
     56     note: &nostrdb::Note<'_>,
     57 ) {
     58     publisher.publish_note(note, vec![RelayId::Websocket(relay_url.clone())]);
     59 }
     60 
     61 fn configured_relay_url() -> NormRelayUrl {
     62     let raw = std::env::var("NOSTRVERSE_RELAY")
     63         .unwrap_or_else(|_| NostrverseApp::DEFAULT_RELAY.to_string());
     64     match NormRelayUrl::new(&raw) {
     65         Ok(url) => url,
     66         Err(err) => {
     67             tracing::warn!(
     68                 "Invalid NOSTRVERSE_RELAY '{}': {err:?}; falling back to {}",
     69                 raw,
     70                 NostrverseApp::DEFAULT_RELAY
     71             );
     72             NormRelayUrl::new(NostrverseApp::DEFAULT_RELAY).expect("default nostrverse relay URL")
     73         }
     74     }
     75 }
     76 
     77 fn room_filter() -> Filter {
     78     Filter::new().kinds([kinds::ROOM as u64]).build()
     79 }
     80 
     81 fn presence_filter() -> Filter {
     82     Filter::new().kinds([kinds::PRESENCE as u64]).build()
     83 }
     84 
     85 /// Avatar scale: water bottle model is ~0.26m, scaled to human height (~1.8m)
     86 const AVATAR_SCALE: f32 = 7.0;
     87 /// How fast the avatar yaw lerps toward the target (higher = faster)
     88 const AVATAR_YAW_LERP_SPEED: f32 = 10.0;
     89 /// How fast remote avatar position lerps toward extrapolated target
     90 const AVATAR_POS_LERP_SPEED: f32 = 8.0;
     91 /// Maximum extrapolation time (seconds) before clamping dead reckoning
     92 const MAX_EXTRAPOLATION_TIME: f64 = 3.0;
     93 /// Maximum extrapolation distance from last known position
     94 const MAX_EXTRAPOLATION_DISTANCE: f32 = 10.0;
     95 
     96 /// Demo space in protoverse .space format
     97 const DEMO_SPACE: &str = r#"(space (name "Demo Space")
     98   (group
     99     (tilemap (width 10) (height 10)
    100       (tileset "grass" "stone" "water" "sand" "dirt" "snow" "wood")
    101       (data "0 0 0 0 0 0 0 0 0 0 0 1 1 1 0 0 4 4 4 0 0 1 1 1 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 3 3 3 0 0 0 0 5 5 5 3 3 3 0 0 0 0 5 5 5 0 0 0 0 2 2 0 0 0 0 0 0 0 0 2 2 0 0 0 0 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6"))
    102     (table (id obj1) (name "Ironwood Table")
    103            (model-url "/home/jb55/var/models/ironwood/ironwood.glb")
    104            (position 0 0 0))
    105     (prop (id obj2) (name "Water Bottle")
    106           (model-url "/home/jb55/var/models/WaterBottle.glb")
    107           (location top-of obj1))))"#;
    108 
    109 /// Event kinds for nostrverse
    110 pub mod kinds {
    111     /// Room event kind (addressable)
    112     pub const ROOM: u16 = 37555;
    113     /// Object event kind (addressable)
    114     pub const OBJECT: u16 = 37556;
    115     /// Presence event kind (user-replaceable)
    116     pub const PRESENCE: u16 = 10555;
    117 }
    118 
    119 /// Nostrverse app - a 3D spatial canvas for virtual spaces
    120 pub struct NostrverseApp {
    121     /// Current space state
    122     state: NostrverseState,
    123     /// 3D renderer (None if wgpu unavailable)
    124     renderer: Option<renderbud::egui::EguiRenderer>,
    125     /// GPU device for model loading (Arc-wrapped internally by wgpu)
    126     device: Option<wgpu::Device>,
    127     /// GPU queue for model loading (Arc-wrapped internally by wgpu)
    128     queue: Option<wgpu::Queue>,
    129     /// Whether the app has been initialized
    130     initialized: bool,
    131     /// Cached avatar model AABB for ground placement
    132     avatar_bounds: Option<renderbud::Aabb>,
    133     /// Local nostrdb subscription for space events
    134     room_sub: Option<subscriptions::RoomSubscription>,
    135     /// Presence publisher (throttled heartbeats)
    136     presence_pub: presence::PresencePublisher,
    137     /// Presence expiry (throttled stale-user cleanup)
    138     presence_expiry: presence::PresenceExpiry,
    139     /// Local nostrdb subscription for presence events
    140     presence_sub: Option<subscriptions::PresenceSubscription>,
    141     /// Cached space naddr string (avoids format! per frame)
    142     space_naddr: String,
    143     /// Event ID of the last save we made (to skip our own echo in polls)
    144     last_save_id: Option<[u8; 32]>,
    145     /// Monotonic time tracker (seconds since app start)
    146     start_time: std::time::Instant,
    147     /// Model download/cache manager (initialized lazily in initialize())
    148     model_cache: Option<model_cache::ModelCache>,
    149     /// Dedicated relay URL for multiplayer sync (from NOSTRVERSE_RELAY env)
    150     relay_url: NormRelayUrl,
    151 }
    152 
    153 impl NostrverseApp {
    154     const DEFAULT_RELAY: &str = "ws://relay.jb55.com";
    155 
    156     /// Create a new nostrverse app with a space reference
    157     pub fn new(space_ref: SpaceRef, render_state: Option<&egui_wgpu::RenderState>) -> Self {
    158         let renderer = render_state.map(|rs| renderbud::egui::EguiRenderer::new(rs, (800, 600)));
    159 
    160         let device = render_state.map(|rs| rs.device.clone());
    161         let queue = render_state.map(|rs| rs.queue.clone());
    162 
    163         let relay_url = configured_relay_url();
    164 
    165         let space_naddr = space_ref.to_naddr();
    166         Self {
    167             state: NostrverseState::new(space_ref),
    168             renderer,
    169             device,
    170             queue,
    171             initialized: false,
    172             avatar_bounds: None,
    173             room_sub: None,
    174             presence_pub: presence::PresencePublisher::new(),
    175             presence_expiry: presence::PresenceExpiry::new(),
    176             presence_sub: None,
    177             space_naddr,
    178             last_save_id: None,
    179             start_time: std::time::Instant::now(),
    180             model_cache: None,
    181             relay_url,
    182         }
    183     }
    184 
    185     /// Create with a demo space
    186     pub fn demo(render_state: Option<&egui_wgpu::RenderState>) -> Self {
    187         let space_ref = SpaceRef::new("demo-room".to_string(), demo_pubkey());
    188         Self::new(space_ref, render_state)
    189     }
    190 
    191     /// Load a glTF model and return its handle
    192     fn load_model(&self, path: &str) -> Option<renderbud::Model> {
    193         let renderer = self.renderer.as_ref()?;
    194         let device = self.device.as_ref()?;
    195         let queue = self.queue.as_ref()?;
    196         let mut r = renderer.renderer.lock().unwrap();
    197         match r.load_gltf_model(device, queue, path) {
    198             Ok(model) => Some(model),
    199             Err(e) => {
    200                 tracing::warn!("Failed to load model {}: {}", path, e);
    201                 None
    202             }
    203         }
    204     }
    205 
    206     /// Initialize: ingest demo space into local nostrdb and subscribe.
    207     fn initialize(&mut self, ctx: &mut AppContext<'_>) {
    208         if self.initialized {
    209             return;
    210         }
    211 
    212         // Initialize model cache
    213         let cache_dir = ctx.path.path(notedeck::DataPathType::Cache).join("models");
    214         self.model_cache = Some(model_cache::ModelCache::new(cache_dir));
    215 
    216         // Subscribe to space and presence events in local nostrdb
    217         self.room_sub = Some(subscriptions::RoomSubscription::new(ctx.ndb));
    218         self.presence_sub = Some(subscriptions::PresenceSubscription::new(ctx.ndb));
    219 
    220         // Declare remote room/presence feed on the dedicated relay.
    221         let relays = std::iter::once(self.relay_url.clone()).collect();
    222         let config = SubConfig {
    223             relays: RelaySelection::Explicit(relays),
    224             filters: vec![room_filter(), presence_filter()],
    225             use_transparent: false,
    226         };
    227         let _ = ctx
    228             .remote
    229             .scoped_subs(ctx.accounts)
    230             .set_sub(nostrverse_remote_sub_identity(), config);
    231         tracing::info!(
    232             "Declared nostrverse scoped relay subscription on {}",
    233             self.relay_url
    234         );
    235 
    236         // Try to load an existing space from nostrdb first
    237         let txn = nostrdb::Transaction::new(ctx.ndb).expect("txn");
    238         self.load_space_from_ndb(ctx.ndb, &txn);
    239 
    240         // Only ingest the demo space if no saved space was found
    241         if self.state.space.is_none() {
    242             let space = match protoverse::parse(DEMO_SPACE) {
    243                 Ok(s) => s,
    244                 Err(e) => {
    245                     tracing::error!("Failed to parse demo space: {}", e);
    246                     return;
    247                 }
    248             };
    249 
    250             if let Some(kp) = ctx.accounts.selected_filled() {
    251                 let builder = nostr_events::build_space_event(&space, &self.state.space_ref.id);
    252                 if let Some(note) = nostr_events::ingest_event(builder, ctx.ndb, kp) {
    253                     let mut publisher = ctx.remote.publisher_explicit();
    254                     publish_ingested_note(&mut publisher, &self.relay_url, &note);
    255                 }
    256             }
    257             // room_sub (set up above) will pick up the ingested event
    258             // on the next poll_space_updates() frame.
    259         }
    260 
    261         // Add self user
    262         let self_pubkey = *ctx.accounts.selected_account_pubkey();
    263         self.state.users = vec![
    264             RoomUser::new(self_pubkey, "jb55".to_string(), Vec3::new(-2.0, 0.0, -2.0))
    265                 .with_self(true),
    266         ];
    267 
    268         // Assign avatar model (use first model with id "obj2" as placeholder)
    269         let avatar_model = self
    270             .state
    271             .objects
    272             .iter()
    273             .find(|o| o.id == "obj2")
    274             .and_then(|o| o.model_handle);
    275         let avatar_bounds = avatar_model.and_then(|m| {
    276             let renderer = self.renderer.as_ref()?;
    277             let r = renderer.renderer.lock().unwrap();
    278             r.model_bounds(m)
    279         });
    280         if let Some(model) = avatar_model {
    281             for user in &mut self.state.users {
    282                 user.model_handle = Some(model);
    283             }
    284         }
    285         self.avatar_bounds = avatar_bounds;
    286 
    287         // Switch to third-person camera mode
    288         if let Some(renderer) = &self.renderer {
    289             let self_pos = self
    290                 .state
    291                 .self_user()
    292                 .map(|u| u.position)
    293                 .unwrap_or(Vec3::ZERO);
    294             let mut r = renderer.renderer.lock().unwrap();
    295             r.set_third_person_mode(self_pos);
    296         }
    297 
    298         self.initialized = true;
    299     }
    300 
    301     /// Apply a parsed Space to the state: convert, load models, update state.
    302     /// Preserves renderer scene handles for objects that still exist by ID,
    303     /// and removes orphaned scene objects from the renderer.
    304     fn apply_space(&mut self, space: &protoverse::Space) {
    305         let mut data = convert::convert_space(space);
    306 
    307         // Transfer scene/model handles from existing objects with matching IDs
    308         for new_obj in &mut data.objects {
    309             if let Some(old_obj) = self.state.objects.iter().find(|o| o.id == new_obj.id) {
    310                 new_obj.scene_object_id = old_obj.scene_object_id;
    311                 new_obj.model_handle = old_obj.model_handle;
    312             }
    313         }
    314 
    315         // Transfer tilemap handles before overwriting state
    316         let old_tilemap_handles = self
    317             .state
    318             .tilemap()
    319             .map(|tm| (tm.scene_object_id, tm.model_handle));
    320         if let (Some(new_tm), Some((scene_id, model_handle))) =
    321             (&mut data.info.tilemap, old_tilemap_handles)
    322         {
    323             new_tm.scene_object_id = scene_id;
    324             new_tm.model_handle = model_handle;
    325         }
    326 
    327         // Remove orphaned scene objects (old objects not in the new set)
    328         if let Some(renderer) = &self.renderer {
    329             let mut r = renderer.renderer.lock().unwrap();
    330             for old_obj in &self.state.objects {
    331                 if let Some(scene_id) = old_obj.scene_object_id
    332                     && !data.objects.iter().any(|o| o.id == old_obj.id)
    333                 {
    334                     r.remove_object(scene_id);
    335                 }
    336             }
    337             // Remove old tilemap scene object if being replaced
    338             if let Some((Some(scene_id), _)) = old_tilemap_handles {
    339                 r.remove_object(scene_id);
    340             }
    341         }
    342 
    343         self.load_object_models(&mut data.objects);
    344         self.state.space = Some(data.info);
    345         self.state.objects = data.objects;
    346         self.state.dirty = false;
    347     }
    348 
    349     /// Load space state from a nostrdb query result.
    350     fn load_space_from_ndb(&mut self, ndb: &nostrdb::Ndb, txn: &nostrdb::Transaction) {
    351         let notes = subscriptions::RoomSubscription::query_existing(ndb, txn);
    352 
    353         for note in &notes {
    354             let Some(space_id) = nostr_events::get_space_id(note) else {
    355                 continue;
    356             };
    357             if space_id != self.state.space_ref.id {
    358                 continue;
    359             }
    360 
    361             let Some(space) = nostr_events::parse_space_event(note) else {
    362                 tracing::warn!("Failed to parse space event content");
    363                 continue;
    364             };
    365 
    366             self.apply_space(&space);
    367             tracing::info!("Loaded space '{}' from nostrdb", space_id);
    368             return;
    369         }
    370     }
    371 
    372     /// Save current space state: build Space, serialize, ingest as new nostr event.
    373     fn save_space(&mut self, ctx: &mut AppContext<'_>) {
    374         let Some(info) = &self.state.space else {
    375             tracing::warn!("save_space: no space to save");
    376             return;
    377         };
    378         let Some(kp) = ctx.accounts.selected_filled() else {
    379             tracing::warn!("save_space: no keypair available");
    380             return;
    381         };
    382 
    383         let space = convert::build_space(info, &self.state.objects);
    384         let builder = nostr_events::build_space_event(&space, &self.state.space_ref.id);
    385         if let Some(note) = nostr_events::ingest_event(builder, ctx.ndb, kp) {
    386             self.last_save_id = Some(*note.id());
    387             publish_ingested_note(&mut ctx.remote.publisher_explicit(), &self.relay_url, &note);
    388         }
    389         tracing::info!("Saved space '{}'", self.state.space_ref.id);
    390     }
    391 
    392     /// Load 3D models for objects, then resolve any semantic locations
    393     /// (e.g. "top-of obj1") to concrete positions using AABB bounds.
    394     ///
    395     /// For remote URLs (http/https), the model cache handles async download
    396     /// and disk caching. Models that aren't yet downloaded will be loaded
    397     /// on a future frame via `poll_model_downloads`.
    398     fn load_object_models(&mut self, objects: &mut [RoomObject]) {
    399         let renderer = self.renderer.as_ref();
    400         let model_bounds_fn = |m: Option<renderbud::Model>| -> Option<renderbud::Aabb> {
    401             let r = renderer?.renderer.lock().unwrap();
    402             r.model_bounds(m?)
    403         };
    404 
    405         // Phase 1: Load all models and cache their AABB bounds.
    406         // Remote URLs may return None (download in progress); those objects
    407         // will get their model_handle assigned later via poll_model_downloads.
    408         let mut bounds_by_id: std::collections::HashMap<String, renderbud::Aabb> =
    409             std::collections::HashMap::new();
    410 
    411         for obj in objects.iter_mut() {
    412             // Skip if already loaded
    413             if obj.model_handle.is_some() {
    414                 if let Some(bounds) = model_bounds_fn(obj.model_handle) {
    415                     bounds_by_id.insert(obj.id.clone(), bounds);
    416                 }
    417                 continue;
    418             }
    419 
    420             if let Some(url) = obj.model_url.clone() {
    421                 let local_path = if let Some(cache) = &mut self.model_cache {
    422                     cache.request(&url)
    423                 } else {
    424                     Some(std::path::PathBuf::from(&url))
    425                 };
    426 
    427                 if let Some(path) = local_path {
    428                     let model = self.load_model(path.to_str().unwrap_or(&url));
    429                     if let Some(bounds) = model_bounds_fn(model) {
    430                         bounds_by_id.insert(obj.id.clone(), bounds);
    431                     }
    432                     obj.model_handle = model;
    433                     if let Some(cache) = &mut self.model_cache {
    434                         cache.mark_loaded(&url);
    435                     }
    436                 }
    437             }
    438         }
    439 
    440         resolve_locations(objects, &bounds_by_id);
    441     }
    442 
    443     /// Poll for completed model downloads, load into GPU, and re-resolve
    444     /// semantic locations so dependent objects are positioned correctly.
    445     fn poll_model_downloads(&mut self) {
    446         let Some(cache) = &mut self.model_cache else {
    447             return;
    448         };
    449 
    450         let ready = cache.poll();
    451         if ready.is_empty() {
    452             return;
    453         }
    454 
    455         let mut any_loaded = false;
    456         for (url, path) in ready {
    457             let path_str = path.to_string_lossy();
    458             let model = self.load_model(&path_str);
    459 
    460             if model.is_none() {
    461                 tracing::warn!("Failed to load cached model at {}", path_str);
    462                 continue;
    463             }
    464 
    465             for obj in &mut self.state.objects {
    466                 if obj.model_url.as_deref() == Some(&url) && obj.model_handle.is_none() {
    467                     obj.model_handle = model;
    468                     obj.scene_object_id = None;
    469                     any_loaded = true;
    470                 }
    471             }
    472 
    473             if let Some(cache) = &mut self.model_cache {
    474                 cache.mark_loaded(&url);
    475             }
    476         }
    477 
    478         if any_loaded {
    479             resolve_object_locations(self.renderer.as_ref(), &mut self.state.objects);
    480         }
    481     }
    482 
    483     /// Poll the space subscription for updates.
    484     /// Skips applying updates while the space has unsaved local edits.
    485     fn poll_space_updates(&mut self, ndb: &nostrdb::Ndb) {
    486         if self.state.dirty {
    487             return;
    488         }
    489         let Some(sub) = &self.room_sub else {
    490             return;
    491         };
    492         let txn = nostrdb::Transaction::new(ndb).expect("txn");
    493         let notes = sub.poll(ndb, &txn);
    494 
    495         for note in &notes {
    496             // Skip our own save — the in-memory state is already correct
    497             if let Some(last_id) = &self.last_save_id
    498                 && note.id() == last_id
    499             {
    500                 self.last_save_id = None;
    501                 continue;
    502             }
    503 
    504             let Some(space_id) = nostr_events::get_space_id(note) else {
    505                 continue;
    506             };
    507             if space_id != self.state.space_ref.id {
    508                 continue;
    509             }
    510 
    511             let Some(space) = nostr_events::parse_space_event(note) else {
    512                 continue;
    513             };
    514 
    515             self.apply_space(&space);
    516             tracing::info!("Space '{}' updated from nostrdb", space_id);
    517         }
    518     }
    519 
    520     /// Run one tick of presence: publish local position, poll remote, expire stale.
    521     fn tick_presence(&mut self, ctx: &mut AppContext<'_>) {
    522         let now = self.start_time.elapsed().as_secs_f64();
    523 
    524         // Publish our position (throttled — only on change or keep-alive)
    525         if let Some(kp) = ctx.accounts.selected_filled() {
    526             let self_pos = self
    527                 .state
    528                 .self_user()
    529                 .map(|u| u.position)
    530                 .unwrap_or(Vec3::ZERO);
    531 
    532             if let Some(note) =
    533                 self.presence_pub
    534                     .maybe_publish(ctx.ndb, kp, &self.space_naddr, self_pos, now)
    535             {
    536                 publish_ingested_note(&mut ctx.remote.publisher_explicit(), &self.relay_url, &note);
    537             }
    538         }
    539 
    540         // Poll for remote presence events
    541         let self_pubkey = *ctx.accounts.selected_account_pubkey();
    542         if let Some(sub) = &self.presence_sub {
    543             let changed = presence::poll_presence(
    544                 sub,
    545                 ctx.ndb,
    546                 &self.space_naddr,
    547                 &self_pubkey,
    548                 &mut self.state.users,
    549                 now,
    550             );
    551 
    552             // Assign avatar model to new users
    553             if changed {
    554                 let avatar_model = self.state.self_user().and_then(|u| u.model_handle);
    555                 if let Some(model) = avatar_model {
    556                     for user in &mut self.state.users {
    557                         if user.model_handle.is_none() {
    558                             user.model_handle = Some(model);
    559                         }
    560                     }
    561                 }
    562             }
    563         }
    564 
    565         // Expire stale remote users (throttled to every ~10s)
    566         let expired = self
    567             .presence_expiry
    568             .maybe_expire(&mut self.state.users, now);
    569         if !expired.is_empty() {
    570             tracing::info!("Expired {} stale users", expired.len());
    571             // Clean up scene objects so avatars don't linger in the 3D scene
    572             if let Some(renderer) = &self.renderer {
    573                 let mut r = renderer.renderer.lock().unwrap();
    574                 for user in &expired {
    575                     if let Some(scene_id) = user.scene_object_id {
    576                         r.remove_object(scene_id);
    577                     }
    578                 }
    579             }
    580         }
    581     }
    582 
    583     /// Sync space objects and user avatars to the renderbud scene
    584     fn sync_scene(&mut self) {
    585         let Some(renderer) = &self.renderer else {
    586             return;
    587         };
    588         let mut r = renderer.renderer.lock().unwrap();
    589 
    590         sync_objects_to_scene(&mut self.state.objects, &mut r);
    591 
    592         // Build + place tilemap if needed
    593         if let Some(tm) = self.state.tilemap_mut() {
    594             if tm.model_handle.is_none()
    595                 && let (Some(device), Some(queue)) = (&self.device, &self.queue)
    596             {
    597                 tm.model_handle = Some(tilemap::build_tilemap_model(tm, &mut r, device, queue));
    598             }
    599             if tm.scene_object_id.is_none()
    600                 && let Some(model) = tm.model_handle
    601             {
    602                 let transform = renderbud::Transform {
    603                     translation: glam::Vec3::ZERO,
    604                     rotation: glam::Quat::IDENTITY,
    605                     scale: glam::Vec3::ONE,
    606                 };
    607                 tm.scene_object_id = Some(r.place_object(model, transform));
    608             }
    609         }
    610 
    611         // Update self-user's position from the camera controller
    612         if let Some(pos) = r.avatar_position()
    613             && let Some(self_user) = self.state.self_user_mut()
    614         {
    615             self_user.position = pos;
    616             self_user.display_position = pos;
    617         }
    618 
    619         // Smoothly lerp avatar yaw toward controller target
    620         let dt = 1.0 / 60.0_f32;
    621         if let Some(target_yaw) = r.avatar_yaw() {
    622             self.state.smooth_avatar_yaw = lerp_yaw(
    623                 self.state.smooth_avatar_yaw,
    624                 target_yaw,
    625                 AVATAR_YAW_LERP_SPEED * dt,
    626             );
    627         }
    628 
    629         let now = self.start_time.elapsed().as_secs_f64();
    630         let avatar_y_offset = self
    631             .avatar_bounds
    632             .map(|b| (b.max.y - b.min.y) * 0.5)
    633             .unwrap_or(0.0)
    634             * AVATAR_SCALE;
    635 
    636         update_remote_user_positions(&mut self.state.users, dt, now);
    637         sync_users_to_scene(
    638             &mut self.state.users,
    639             self.state.smooth_avatar_yaw,
    640             avatar_y_offset,
    641             &mut r,
    642         );
    643     }
    644 
    645     /// Get the current state
    646     pub fn state(&self) -> &NostrverseState {
    647         &self.state
    648     }
    649 
    650     /// Get mutable state
    651     pub fn state_mut(&mut self) -> &mut NostrverseState {
    652         &mut self.state
    653     }
    654 }
    655 
    656 impl notedeck::App for NostrverseApp {
    657     fn update(&mut self, ctx: &mut AppContext<'_>, _egui_ctx: &egui::Context) {
    658         self.initialize(ctx);
    659         self.poll_space_updates(ctx.ndb);
    660         self.poll_model_downloads();
    661         self.tick_presence(ctx);
    662         self.sync_scene();
    663     }
    664 
    665     fn render(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> AppResponse {
    666         let available = ui.available_size();
    667         let panel_width = 240.0;
    668 
    669         // Main layout: 3D view + editing panel
    670         ui.allocate_ui(available, |ui| {
    671             ui.horizontal(|ui| {
    672                 let room_width = if self.state.edit_mode {
    673                     available.x - panel_width
    674                 } else {
    675                     available.x
    676                 };
    677 
    678                 ui.allocate_ui(egui::vec2(room_width, available.y), |ui| {
    679                     if let Some(renderer) = &self.renderer {
    680                         let response = show_room_view(ui, &mut self.state, renderer);
    681 
    682                         if let Some(action) = response.action {
    683                             self.handle_action(action, ctx);
    684                         }
    685                     } else {
    686                         ui.centered_and_justified(|ui| {
    687                             ui.label("3D rendering unavailable (no wgpu)");
    688                         });
    689                     }
    690                 });
    691 
    692                 // Editing panel (always visible in edit mode)
    693                 if self.state.edit_mode {
    694                     ui.allocate_ui_with_layout(
    695                         egui::vec2(panel_width, available.y),
    696                         egui::Layout::top_down(egui::Align::LEFT),
    697                         |ui| {
    698                             egui::Frame::default().inner_margin(8.0).show(ui, |ui| {
    699                                 if let Some(action) = render_editing_panel(ui, &mut self.state) {
    700                                     self.handle_action(action, ctx);
    701                                 }
    702                             });
    703                         },
    704                     );
    705                 }
    706             });
    707         });
    708 
    709         AppResponse::none()
    710     }
    711 }
    712 
    713 impl NostrverseApp {
    714     fn handle_action(&mut self, action: NostrverseAction, ctx: &mut AppContext<'_>) {
    715         match action {
    716             NostrverseAction::MoveObject { id, position } => {
    717                 if let Some(obj) = self.state.get_object_mut(&id) {
    718                     obj.position = position;
    719                     self.state.dirty = true;
    720                 }
    721             }
    722             NostrverseAction::SelectObject(selected) => {
    723                 // Update renderer outline highlight
    724                 if let Some(renderer) = &self.renderer {
    725                     let scene_id = selected.as_ref().and_then(|sel_id| {
    726                         self.state
    727                             .objects
    728                             .iter()
    729                             .find(|o| &o.id == sel_id)
    730                             .and_then(|o| o.scene_object_id)
    731                     });
    732                     renderer.renderer.lock().unwrap().set_selected(scene_id);
    733                 }
    734                 self.state.selected_object = selected;
    735             }
    736             NostrverseAction::SaveSpace => {
    737                 self.save_space(ctx);
    738                 self.state.dirty = false;
    739             }
    740             NostrverseAction::AddObject(mut obj) => {
    741                 // Try to load model immediately (handles local + cached remote)
    742                 if let Some(url) = obj.model_url.clone() {
    743                     let local_path = self.model_cache.as_mut().and_then(|c| c.request(&url));
    744                     if let Some(path) = local_path {
    745                         obj.model_handle = self.load_model(path.to_str().unwrap_or(&url));
    746                         if obj.model_handle.is_some()
    747                             && let Some(cache) = &mut self.model_cache
    748                         {
    749                             cache.mark_loaded(&url);
    750                         }
    751                     }
    752                 }
    753                 self.state.objects.push(obj);
    754                 self.state.dirty = true;
    755             }
    756             NostrverseAction::RemoveObject(id) => {
    757                 remove_object(&id, &mut self.state, self.renderer.as_ref());
    758             }
    759             NostrverseAction::RotateObject { id, rotation } => {
    760                 if let Some(obj) = self.state.get_object_mut(&id) {
    761                     obj.rotation = rotation;
    762                     self.state.dirty = true;
    763                 }
    764             }
    765             NostrverseAction::ResetSpace => {
    766                 if let Ok(space) = protoverse::parse(DEMO_SPACE) {
    767                     self.apply_space(&space);
    768                     self.save_space(ctx);
    769                     self.state.dirty = false;
    770                 }
    771             }
    772             NostrverseAction::DuplicateObject(id) => {
    773                 let Some(src) = self.state.objects.iter().find(|o| o.id == id).cloned() else {
    774                     return;
    775                 };
    776                 let new_id = format!("{}-copy-{}", src.id, self.state.objects.len());
    777                 let mut dup = src;
    778                 dup.id = new_id.clone();
    779                 dup.name = format!("{} (copy)", dup.name);
    780                 dup.position.x += 0.5;
    781                 // Clear scene node — sync_scene will create a new one.
    782                 // Keep model_handle: it's a shared ref to loaded GPU data.
    783                 dup.scene_object_id = None;
    784                 self.state.objects.push(dup);
    785                 self.state.dirty = true;
    786                 self.state.selected_object = Some(new_id);
    787             }
    788         }
    789     }
    790 }
    791 
    792 /// Remove an object from both the state and the renderer scene graph.
    793 fn remove_object(
    794     id: &str,
    795     state: &mut NostrverseState,
    796     renderer: Option<&renderbud::egui::EguiRenderer>,
    797 ) {
    798     if let Some(renderer) = renderer {
    799         let mut r = renderer.renderer.lock().unwrap();
    800         if let Some(scene_id) = state
    801             .objects
    802             .iter()
    803             .find(|o| o.id == id)
    804             .and_then(|o| o.scene_object_id)
    805         {
    806             r.remove_object(scene_id);
    807         }
    808         if state.selected_object.as_deref() == Some(id) {
    809             r.set_selected(None);
    810         }
    811     }
    812     state.objects.retain(|o| o.id != id);
    813     if state.selected_object.as_deref() == Some(id) {
    814         state.selected_object = None;
    815     }
    816     state.dirty = true;
    817 }
    818 
    819 /// Sync room objects to the renderbud scene graph.
    820 /// Updates transforms for existing objects and places new ones.
    821 fn sync_objects_to_scene(objects: &mut [RoomObject], r: &mut renderbud::Renderer) {
    822     let mut id_to_scene: std::collections::HashMap<String, renderbud::ObjectId> = objects
    823         .iter()
    824         .filter_map(|obj| Some((obj.id.clone(), obj.scene_object_id?)))
    825         .collect();
    826 
    827     for obj in objects.iter_mut() {
    828         let transform = Transform {
    829             translation: obj.position,
    830             rotation: obj.rotation,
    831             scale: obj.scale,
    832         };
    833 
    834         if let Some(scene_id) = obj.scene_object_id {
    835             r.update_object_transform(scene_id, transform);
    836         } else if let Some(model) = obj.model_handle {
    837             let parent_scene_id = obj.location.as_ref().and_then(|loc| match loc {
    838                 room_state::ObjectLocation::TopOf(target_id)
    839                 | room_state::ObjectLocation::Near(target_id) => {
    840                     id_to_scene.get(target_id).copied()
    841                 }
    842                 _ => None,
    843             });
    844 
    845             let scene_id = if let Some(parent_id) = parent_scene_id {
    846                 r.place_object_with_parent(model, transform, parent_id)
    847             } else {
    848                 r.place_object(model, transform)
    849             };
    850 
    851             obj.scene_object_id = Some(scene_id);
    852             id_to_scene.insert(obj.id.clone(), scene_id);
    853         }
    854     }
    855 }
    856 
    857 /// Smoothly interpolate between two yaw angles, wrapping around TAU.
    858 fn lerp_yaw(current: f32, target: f32, speed: f32) -> f32 {
    859     let mut diff = target - current;
    860     diff = (diff + std::f32::consts::PI).rem_euclid(std::f32::consts::TAU) - std::f32::consts::PI;
    861     current + diff * speed.min(1.0)
    862 }
    863 
    864 /// Apply dead reckoning to remote users, smoothing their display positions.
    865 fn update_remote_user_positions(users: &mut [RoomUser], dt: f32, now: f64) {
    866     for user in users.iter_mut() {
    867         if user.is_self {
    868             continue;
    869         }
    870         let time_since_update = (now - user.update_time).min(MAX_EXTRAPOLATION_TIME) as f32;
    871         let extrapolated = user.position + user.velocity * time_since_update;
    872 
    873         let offset = extrapolated - user.position;
    874         let target = if offset.length() > MAX_EXTRAPOLATION_DISTANCE {
    875             user.position + offset.normalize() * MAX_EXTRAPOLATION_DISTANCE
    876         } else {
    877             extrapolated
    878         };
    879 
    880         let t = (AVATAR_POS_LERP_SPEED * dt).min(1.0);
    881         user.display_position = user.display_position.lerp(target, t);
    882     }
    883 }
    884 
    885 /// Sync user avatars to the renderbud scene with proper transforms.
    886 fn sync_users_to_scene(
    887     users: &mut [RoomUser],
    888     smooth_yaw: f32,
    889     avatar_y_offset: f32,
    890     r: &mut renderbud::Renderer,
    891 ) {
    892     for user in users.iter_mut() {
    893         let yaw = if user.is_self { smooth_yaw } else { 0.0 };
    894 
    895         let transform = Transform {
    896             translation: user.display_position + Vec3::new(0.0, avatar_y_offset, 0.0),
    897             rotation: glam::Quat::from_rotation_y(yaw),
    898             scale: Vec3::splat(AVATAR_SCALE),
    899         };
    900 
    901         if let Some(scene_id) = user.scene_object_id {
    902             r.update_object_transform(scene_id, transform);
    903         } else if let Some(model) = user.model_handle {
    904             user.scene_object_id = Some(r.place_object(model, transform));
    905         }
    906     }
    907 }
    908 
    909 /// Resolve semantic locations (top-of, near, floor) to concrete positions
    910 /// using the provided AABB bounds map.
    911 fn resolve_locations(
    912     objects: &mut [RoomObject],
    913     bounds_by_id: &std::collections::HashMap<String, renderbud::Aabb>,
    914 ) {
    915     let mut resolved: Vec<(usize, Vec3, Vec3)> = Vec::new();
    916 
    917     for (i, obj) in objects.iter().enumerate() {
    918         let Some(loc) = &obj.location else {
    919             continue;
    920         };
    921 
    922         let local_base = match loc {
    923             room_state::ObjectLocation::TopOf(target_id) => {
    924                 let target_top = bounds_by_id.get(target_id).map(|b| b.max.y).unwrap_or(0.0);
    925                 let self_half_h = bounds_by_id
    926                     .get(&obj.id)
    927                     .map(|b| (b.max.y - b.min.y) * 0.5)
    928                     .unwrap_or(0.0);
    929                 Some(Vec3::new(0.0, target_top + self_half_h, 0.0))
    930             }
    931             room_state::ObjectLocation::Near(target_id) => {
    932                 let offset = bounds_by_id
    933                     .get(target_id)
    934                     .map(|b| b.max.x - b.min.x)
    935                     .unwrap_or(1.0);
    936                 Some(Vec3::new(offset, 0.0, 0.0))
    937             }
    938             room_state::ObjectLocation::Floor => {
    939                 let self_half_h = bounds_by_id
    940                     .get(&obj.id)
    941                     .map(|b| (b.max.y - b.min.y) * 0.5)
    942                     .unwrap_or(0.0);
    943                 Some(Vec3::new(0.0, self_half_h, 0.0))
    944             }
    945             _ => None,
    946         };
    947 
    948         if let Some(base) = local_base {
    949             resolved.push((i, base, base + obj.position));
    950         }
    951     }
    952 
    953     for (i, base, pos) in resolved {
    954         objects[i].location_base = Some(base);
    955         objects[i].position = pos;
    956     }
    957 }
    958 
    959 /// Collect AABB bounds for all objects that have a loaded model.
    960 fn collect_bounds(
    961     renderer: Option<&renderbud::egui::EguiRenderer>,
    962     objects: &[RoomObject],
    963 ) -> std::collections::HashMap<String, renderbud::Aabb> {
    964     let mut bounds = std::collections::HashMap::new();
    965     let Some(renderer) = renderer else {
    966         return bounds;
    967     };
    968     let r = renderer.renderer.lock().unwrap();
    969     for obj in objects {
    970         if let Some(model) = obj.model_handle
    971             && let Some(b) = r.model_bounds(model)
    972         {
    973             bounds.insert(obj.id.clone(), b);
    974         }
    975     }
    976     bounds
    977 }
    978 
    979 /// Re-resolve semantic locations (top-of, near, floor) using current model bounds.
    980 fn resolve_object_locations(
    981     renderer: Option<&renderbud::egui::EguiRenderer>,
    982     objects: &mut [RoomObject],
    983 ) {
    984     let bounds_by_id = collect_bounds(renderer, objects);
    985     resolve_locations(objects, &bounds_by_id);
    986 }