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, ¬e); 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 ¬es { 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, ¬e); 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 ¬es { 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, ¬e); 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 }