notedeck

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

commit 2836d0d271e5d899fef9750fd896116ffdd498a5
parent 7ac7dc20993ddf89e6c3e0408eaf55a8ed959c14
Author: William Casarin <jb55@jb55.com>
Date:   Mon, 23 Feb 2026 15:09:14 -0800

Merge nostrverse, diff context, and custom session titles

nostrverse:
- Use scene graph parenting for relative object placement, so
  semantically-located objects follow their parent automatically
- Add velocity-based dead reckoning for presence interpolation,
  replacing snap-to-position with smooth extrapolation
- Fix editing panel layout by switching to vertical layout

dave:
- Custom session titles with cross-device sync via kind-31988
  session state events (right-click/long-press to rename)

diff:
- Add expandable context for local session diffs (show more
  lines above/below with on-disk file reads)
- Fix clippy collapsible_if warnings

Diffstat:
Mcrates/notedeck_columns/src/ui/add_column.rs | 1+
Mcrates/notedeck_dave/src/file_update.rs | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/lib.rs | 43+++++++++++++++++++++++++++++++++++--------
Mcrates/notedeck_dave/src/session.rs | 10++++++++++
Mcrates/notedeck_dave/src/session_events.rs | 6++++++
Mcrates/notedeck_dave/src/session_loader.rs | 3+++
Mcrates/notedeck_dave/src/ui/dave.rs | 7++++---
Mcrates/notedeck_dave/src/ui/diff.rs | 97++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/notedeck_dave/src/ui/mod.rs | 2+-
Mcrates/notedeck_dave/src/ui/scene.rs | 2+-
Mcrates/notedeck_dave/src/ui/session_list.rs | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/notedeck_nostrverse/src/convert.rs | 7++++++-
Mcrates/notedeck_nostrverse/src/lib.rs | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mcrates/notedeck_nostrverse/src/nostr_events.rs | 41++++++++++++++++++++++++++++++++++++-----
Mcrates/notedeck_nostrverse/src/presence.rs | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mcrates/notedeck_nostrverse/src/room_state.rs | 13+++++++++++++
Mcrates/notedeck_nostrverse/src/room_view.rs | 417++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mcrates/renderbud/src/lib.rs | 17+++++++++++++++++
18 files changed, 879 insertions(+), 331 deletions(-)

diff --git a/crates/notedeck_columns/src/ui/add_column.rs b/crates/notedeck_columns/src/ui/add_column.rs @@ -694,6 +694,7 @@ fn nip05_profile_ui( resp } +#[allow(clippy::too_many_arguments)] fn contacts_list_column_ui( ui: &mut Ui, contacts: &ContactState, diff --git a/crates/notedeck_dave/src/file_update.rs b/crates/notedeck_dave/src/file_update.rs @@ -1,5 +1,6 @@ use serde_json::Value; use similar::{ChangeTag, TextDiff}; +use std::path::Path; /// Represents a proposed file modification from an AI tool call #[derive(Debug, Clone)] @@ -45,6 +46,20 @@ impl From<ChangeTag> for DiffTag { } } +/// Result of expanding diff context by reading the actual file from disk. +pub struct ExpandedDiffContext { + /// Extra Equal lines loaded above the diff + pub above: Vec<DiffLine>, + /// Extra Equal lines loaded below the diff + pub below: Vec<DiffLine>, + /// 1-based line number in the file where the first displayed line starts + pub start_line: usize, + /// Whether there are more lines above that could be loaded + pub has_more_above: bool, + /// Whether there are more lines below that could be loaded + pub has_more_below: bool, +} + impl FileUpdate { /// Create a new FileUpdate, computing the diff eagerly pub fn new(file_path: String, update_type: FileUpdateType) -> Self { @@ -121,6 +136,65 @@ impl FileUpdate { } } + /// Read the file from disk and expand context around the edit. + /// + /// Returns `None` if this is not an Edit, the file can't be read, + /// or `old_string` can't be found in the file. + pub fn expanded_context( + &self, + extra_above: usize, + extra_below: usize, + ) -> Option<ExpandedDiffContext> { + let FileUpdateType::Edit { old_string, .. } = &self.update_type else { + return None; + }; + + let file_content = std::fs::read_to_string(Path::new(&self.file_path)).ok()?; + + // Find where old_string appears in the file + let byte_offset = file_content.find(old_string.as_str())?; + + // Count newlines before the match to get 0-based start line index + let start_idx = file_content[..byte_offset] + .chars() + .filter(|&c| c == '\n') + .count(); + + let file_lines: Vec<&str> = file_content.lines().collect(); + let total_lines = file_lines.len(); + + let old_line_count = old_string.lines().count(); + let end_idx = start_idx + old_line_count; // exclusive, 0-based + + // Extra lines above + let above_start = start_idx.saturating_sub(extra_above); + let above: Vec<DiffLine> = file_lines[above_start..start_idx] + .iter() + .map(|line| DiffLine { + tag: DiffTag::Equal, + content: format!("{}\n", line), + }) + .collect(); + + // Extra lines below + let below_end = (end_idx + extra_below).min(total_lines); + let below: Vec<DiffLine> = file_lines[end_idx..below_end] + .iter() + .map(|line| DiffLine { + tag: DiffTag::Equal, + content: format!("{}\n", line), + }) + .collect(); + + Some(ExpandedDiffContext { + start_line: above_start + 1, // 1-based + has_more_above: above_start > 0, + has_more_below: below_end < total_lines, + above, + below, + }) + } + /// Compute the diff lines for an update type (internal helper) fn compute_diff_for(update_type: &FileUpdateType) -> Vec<DiffLine> { match update_type { diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -1059,6 +1059,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr SessionListAction::Delete(id) => { self.delete_session(id); } + SessionListAction::Rename(id, new_title) => { + self.rename_session(id, new_title); + } } } @@ -1093,6 +1096,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr SessionListAction::Delete(id) => { self.delete_session(id); } + SessionListAction::Rename(id, new_title) => { + self.rename_session(id, new_title); + } } } @@ -1319,7 +1325,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr }; for session in self.session_manager.iter_mut() { - if !session.state_dirty || session.is_remote() { + if !session.state_dirty { continue; } @@ -1339,6 +1345,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr session_events::build_session_state_event( &claude_sid, &session.details.title, + session.details.custom_title.as_deref(), &cwd, status, &self.hostname, @@ -1371,6 +1378,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr session_events::build_session_state_event( &info.claude_session_id, &info.title, + None, &info.cwd, "deleted", &self.hostname, @@ -1501,6 +1509,8 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr self.hostname.clone() }; + session.details.custom_title = state.custom_title.clone(); + // Use home_dir from the event for remote abbreviation if !state.home_dir.is_empty() { session.details.home_dir = state.home_dir.clone(); @@ -1631,14 +1641,23 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr if existing_ids.contains(claude_sid) { let ts = note.created_at(); let new_status = AgentStatus::from_status_str(status_str); + let new_custom_title = + session_events::get_tag_value(&note, "custom_title").map(|s| s.to_string()); for session in self.session_manager.iter_mut() { - if session.is_remote() { - if let Some(agentic) = &mut session.agentic { - if agentic.event_session_id() == Some(claude_sid) - && ts > agentic.remote_status_ts - { + let is_remote = session.is_remote(); + if let Some(agentic) = &mut session.agentic { + if agentic.event_session_id() == Some(claude_sid) + && ts > agentic.remote_status_ts + { + agentic.remote_status_ts = ts; + // custom_title syncs for both local and remote + if new_custom_title.is_some() { + session.details.custom_title = new_custom_title.clone(); + } + // Status only updates for remote sessions (local + // sessions derive status from the actual process) + if is_remote { agentic.remote_status = new_status; - agentic.remote_status_ts = ts; } } } @@ -1676,6 +1695,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr if let Some(session) = self.session_manager.get_mut(dave_sid) { session.details.hostname = state.hostname.clone(); + session.details.custom_title = state.custom_title.clone(); if !state.home_dir.is_empty() { session.details.home_dir = state.home_dir.clone(); } @@ -1970,7 +1990,14 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr (remote_user_messages, events_to_publish) } - /// Delete a session and clean up backend resources + fn rename_session(&mut self, id: SessionId, new_title: String) { + let Some(session) = self.session_manager.get_mut(id) else { + return; + }; + session.details.custom_title = Some(new_title); + session.state_dirty = true; + } + fn delete_session(&mut self, id: SessionId) { // Capture session info before deletion so we can publish a "deleted" state event if let Some(session) = self.session_manager.get(id) { diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -30,6 +30,8 @@ pub enum SessionSource { /// Session metadata for display in chat headers pub struct SessionDetails { pub title: String, + /// User-set title that takes precedence over the auto-generated one. + pub custom_title: Option<String>, pub hostname: String, pub cwd: Option<PathBuf>, /// Home directory of the machine where this session originated. @@ -37,6 +39,13 @@ pub struct SessionDetails { pub home_dir: String, } +impl SessionDetails { + /// Returns custom_title if set, otherwise the auto-generated title. + pub fn display_title(&self) -> &str { + self.custom_title.as_deref().unwrap_or(&self.title) + } +} + /// Tracks the "Compact & Approve" lifecycle. /// /// Button click → `WaitingForCompaction` (intent recorded). @@ -357,6 +366,7 @@ impl ChatSession { source: SessionSource::Local, details: SessionDetails { title: "New Chat".to_string(), + custom_title: None, hostname: String::new(), cwd: details_cwd, home_dir: dirs::home_dir() diff --git a/crates/notedeck_dave/src/session_events.rs b/crates/notedeck_dave/src/session_events.rs @@ -649,9 +649,11 @@ pub fn build_permission_response_event( /// Published on every status change so remote clients and startup restore /// can discover active sessions. nostrdb auto-replaces older versions /// with same (kind, pubkey, d-tag). +#[allow(clippy::too_many_arguments)] pub fn build_session_state_event( claude_session_id: &str, title: &str, + custom_title: Option<&str>, cwd: &str, status: &str, hostname: &str, @@ -665,6 +667,9 @@ pub fn build_session_state_event( // Session metadata as tags builder = builder.start_tag().tag_str("title").tag_str(title); + if let Some(ct) = custom_title { + builder = builder.start_tag().tag_str("custom_title").tag_str(ct); + } builder = builder.start_tag().tag_str("cwd").tag_str(cwd); builder = builder.start_tag().tag_str("status").tag_str(status); builder = builder.start_tag().tag_str("hostname").tag_str(hostname); @@ -1116,6 +1121,7 @@ mod tests { let event = build_session_state_event( "sess-state-test", "Fix the login bug", + Some("My Custom Title"), "/tmp/project", "working", "my-laptop", diff --git a/crates/notedeck_dave/src/session_loader.rs b/crates/notedeck_dave/src/session_loader.rs @@ -240,6 +240,7 @@ pub fn load_session_messages(ndb: &Ndb, txn: &Transaction, session_id: &str) -> pub struct SessionState { pub claude_session_id: String, pub title: String, + pub custom_title: Option<String>, pub cwd: String, pub status: String, pub hostname: String, @@ -285,6 +286,7 @@ pub fn load_session_states(ndb: &Ndb, txn: &Transaction) -> Vec<SessionState> { title: get_tag_value(&note, "title") .unwrap_or("Untitled") .to_string(), + custom_title: get_tag_value(&note, "custom_title").map(|s| s.to_string()), cwd: get_tag_value(&note, "cwd").unwrap_or("").to_string(), status: get_tag_value(&note, "status").unwrap_or("idle").to_string(), hostname: get_tag_value(&note, "hostname").unwrap_or("").to_string(), @@ -329,6 +331,7 @@ pub fn latest_valid_session( title: get_tag_value(note, "title") .unwrap_or("Untitled") .to_string(), + custom_title: get_tag_value(note, "custom_title").map(|s| s.to_string()), cwd: get_tag_value(note, "cwd").unwrap_or("").to_string(), status: get_tag_value(note, "status").unwrap_or("idle").to_string(), hostname: get_tag_value(note, "hostname").unwrap_or("").to_string(), diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -592,8 +592,9 @@ impl<'a> DaveUi<'a> { // Header with file path diff::file_path_header(&file_update, ui); - // Diff view - diff::file_update_ui(&file_update, ui); + // Diff view (expand context only for local sessions) + let is_local = !self.flags.contains(DaveUiFlags::IsRemote); + diff::file_update_ui(&file_update, is_local, ui); // Approve/deny buttons at the bottom left ui.horizontal(|ui| { @@ -1374,7 +1375,7 @@ fn session_header_ui(ui: &mut egui::Ui, details: &SessionDetails) { ui.vertical(|ui| { ui.spacing_mut().item_spacing.y = 1.0; ui.add( - egui::Label::new(egui::RichText::new(&details.title).size(13.0)) + egui::Label::new(egui::RichText::new(details.display_title()).size(13.0)) .wrap_mode(egui::TextWrapMode::Truncate), ); if let Some(cwd) = &details.cwd { diff --git a/crates/notedeck_dave/src/ui/diff.rs b/crates/notedeck_dave/src/ui/diff.rs @@ -5,25 +5,106 @@ use egui::{Color32, RichText, Ui}; const DELETE_COLOR: Color32 = Color32::from_rgb(200, 60, 60); const INSERT_COLOR: Color32 = Color32::from_rgb(60, 180, 60); const LINE_NUMBER_COLOR: Color32 = Color32::from_rgb(128, 128, 128); +const EXPAND_LINES_PER_CLICK: usize = 3; + +/// Render a file update diff view. +/// +/// When `is_local` is true and the update is an Edit, expand-context +/// buttons are shown at the top and bottom of the diff. +pub fn file_update_ui(update: &FileUpdate, is_local: bool, ui: &mut Ui) { + let can_expand = is_local && matches!(update.update_type, FileUpdateType::Edit { .. }); + + // egui temp state for how many extra lines above/below + let expand_id = ui.id().with("diff_expand").with(&update.file_path); + let (extra_above, extra_below): (usize, usize) = if can_expand { + ui.data(|d| d.get_temp(expand_id).unwrap_or((0, 0))) + } else { + (0, 0) + }; + + // Try to compute expanded context from the file on disk + let expanded = if can_expand { + update.expanded_context(extra_above, extra_below) + } else { + None + }; -/// Render a file update diff view -pub fn file_update_ui(update: &FileUpdate, ui: &mut Ui) { egui::Frame::new() .fill(ui.visuals().extreme_bg_color) .inner_margin(8.0) .corner_radius(4.0) .show(ui, |ui| { egui::ScrollArea::horizontal().show(ui, |ui| { - render_diff_lines(update.diff_lines(), &update.update_type, ui); + if let Some(ctx) = &expanded { + // "Expand above" button + if ctx.has_more_above && expand_button(ui, true) { + ui.data_mut(|d| { + d.insert_temp( + expand_id, + (extra_above + EXPAND_LINES_PER_CLICK, extra_below), + ); + }); + } + + // Build combined lines: above + core diff + below + let combined: Vec<&DiffLine> = ctx + .above + .iter() + .chain(update.diff_lines().iter()) + .chain(ctx.below.iter()) + .collect(); + + render_diff_lines(&combined, &update.update_type, ctx.start_line, ui); + + // "Expand below" button + if ctx.has_more_below && expand_button(ui, false) { + ui.data_mut(|d| { + d.insert_temp( + expand_id, + (extra_above, extra_below + EXPAND_LINES_PER_CLICK), + ); + }); + } + } else { + // No expansion available: render as before (line numbers from 1) + let refs: Vec<&DiffLine> = update.diff_lines().iter().collect(); + render_diff_lines(&refs, &update.update_type, 1, ui); + } }); }); } -/// Render the diff lines with proper coloring -fn render_diff_lines(lines: &[DiffLine], update_type: &FileUpdateType, ui: &mut Ui) { - // Track line numbers for old and new - let mut old_line = 1usize; - let mut new_line = 1usize; +/// Render a clickable expand-context button. Returns true if clicked. +fn expand_button(ui: &mut Ui, is_above: bool) -> bool { + let text = if is_above { + " \u{25B2} Show more context above" + } else { + " \u{25BC} Show more context below" + }; + ui.add( + egui::Label::new( + RichText::new(text) + .monospace() + .size(11.0) + .color(LINE_NUMBER_COLOR), + ) + .sense(egui::Sense::click()), + ) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() +} + +/// Render the diff lines with proper coloring. +/// +/// `start_line` is the 1-based file line number of the first displayed line. +fn render_diff_lines( + lines: &[&DiffLine], + update_type: &FileUpdateType, + start_line: usize, + ui: &mut Ui, +) { + let mut old_line = start_line; + let mut new_line = start_line; for diff_line in lines { ui.horizontal(|ui| { diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs @@ -263,7 +263,7 @@ pub fn scene_ui( .show(ui, |ui| { if let Some(selected_id) = scene.primary_selection() { if let Some(session) = session_manager.get_mut(selected_id) { - ui.heading(&session.details.title); + ui.heading(session.details.display_title()); ui.separator(); let response = build_dave_ui( diff --git a/crates/notedeck_dave/src/ui/scene.rs b/crates/notedeck_dave/src/ui/scene.rs @@ -165,7 +165,7 @@ impl AgentScene { let keybind_number = keybind_idx + 1; // 1-indexed for display let position = agentic.scene_position; let status = session.status(); - let title = &session.details.title; + let title = session.details.display_title(); let is_selected = selected_ids.contains(&id); let queue_priority = focus_queue.get_session_priority(id); diff --git a/crates/notedeck_dave/src/ui/session_list.rs b/crates/notedeck_dave/src/ui/session_list.rs @@ -15,6 +15,7 @@ pub enum SessionListAction { NewSession, SwitchTo(SessionId), Delete(SessionId), + Rename(SessionId, String), } /// UI component for displaying the session list sidebar @@ -153,9 +154,22 @@ impl<'a> SessionListUi<'a> { let empty_path = PathBuf::new(); let cwd = session.cwd().unwrap_or(&empty_path); + let rename_id = egui::Id::new("session_rename_state"); + let mut renaming: Option<(SessionId, String)> = + ui.data(|d| d.get_temp::<(SessionId, String)>(rename_id)); + let is_renaming = renaming + .as_ref() + .map(|(id, _)| *id == session.id) + .unwrap_or(false); + + let display_title = if is_renaming { + "" + } else { + session.details.display_title() + }; let response = self.session_item_ui( ui, - &session.details.title, + display_title, cwd, &session.details.home_dir, is_active, @@ -166,10 +180,57 @@ impl<'a> SessionListUi<'a> { ); let mut action = None; - if response.clicked() { + + if is_renaming { + let outcome = renaming + .as_mut() + .and_then(|(_, buf)| inline_rename_ui(ui, &response, buf)); + match outcome { + Some(RenameOutcome::Confirmed(title)) => { + action = Some(SessionListAction::Rename(session.id, title)); + ui.data_mut(|d| d.remove_by_type::<(SessionId, String)>()); + } + Some(RenameOutcome::Cancelled) => { + ui.data_mut(|d| d.remove_by_type::<(SessionId, String)>()); + } + None => { + if let Some(r) = renaming { + ui.data_mut(|d| d.insert_temp(rename_id, r)); + } + } + } + } else if response.clicked() { action = Some(SessionListAction::SwitchTo(session.id)); } + + // Long-press to rename (mobile) + if !is_renaming { + let press_id = egui::Id::new("session_long_press"); + if response.is_pointer_button_down_on() { + let now = ui.input(|i| i.time); + let start: Option<PressStart> = ui.data(|d| d.get_temp(press_id)); + if start.is_none() { + ui.data_mut(|d| d.insert_temp(press_id, PressStart(now))); + } else if let Some(s) = start { + if now - s.0 > 0.5 { + let rename_state = + (session.id, session.details.display_title().to_string()); + ui.data_mut(|d| d.insert_temp(rename_id, rename_state)); + ui.data_mut(|d| d.remove_by_type::<PressStart>()); + } + } + } else { + ui.data_mut(|d| d.remove_by_type::<PressStart>()); + } + } + response.context_menu(|ui| { + if ui.button("Rename").clicked() { + let rename_state = (session.id, session.details.display_title().to_string()); + ui.ctx() + .data_mut(|d| d.insert_temp(rename_id, rename_state)); + ui.close_menu(); + } if ui.button("Delete").clicked() { action = Some(SessionListAction::Delete(session.id)); ui.close_menu(); @@ -294,6 +355,50 @@ impl<'a> SessionListUi<'a> { } } +#[derive(Clone, Copy)] +struct PressStart(f64); + +enum RenameOutcome { + Confirmed(String), + Cancelled, +} + +fn inline_rename_ui( + ui: &mut egui::Ui, + response: &egui::Response, + buf: &mut String, +) -> Option<RenameOutcome> { + let edit_rect = response.rect.shrink2(egui::vec2(8.0, 4.0)); + let edit = egui::Area::new(egui::Id::new("rename_textedit")) + .fixed_pos(edit_rect.min) + .order(egui::Order::Foreground) + .show(ui.ctx(), |ui| { + ui.set_width(edit_rect.width()); + ui.add( + egui::TextEdit::singleline(buf) + .font(egui::FontId::proportional(14.0)) + .frame(false), + ) + }) + .inner; + + if !edit.has_focus() && !edit.lost_focus() { + edit.request_focus(); + } + + if edit.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + Some(RenameOutcome::Confirmed(buf.clone())) + } else if edit.lost_focus() { + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + Some(RenameOutcome::Cancelled) + } else { + Some(RenameOutcome::Confirmed(buf.clone())) + } + } else { + None + } +} + /// Draw cwd text (monospace, weak+small) with clipping. fn cwd_ui(ui: &mut egui::Ui, cwd_path: &Path, home_dir: &str, pos: egui::Pos2, max_width: f32) { let display_text = if home_dir.is_empty() { 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 @@ -40,6 +40,12 @@ fn demo_pubkey() -> Pubkey { 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; +/// How fast remote avatar position lerps toward extrapolated target +const AVATAR_POS_LERP_SPEED: f32 = 8.0; +/// Maximum extrapolation time (seconds) before clamping dead reckoning +const MAX_EXTRAPOLATION_TIME: f64 = 3.0; +/// Maximum extrapolation distance from last known position +const MAX_EXTRAPOLATION_DISTANCE: 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) @@ -85,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, } @@ -110,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(), } } @@ -141,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![ @@ -207,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; @@ -239,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; @@ -251,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); } @@ -278,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; } } @@ -351,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; }; @@ -431,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, @@ -442,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); } } @@ -456,6 +511,7 @@ impl NostrverseApp { && let Some(self_user) = self.state.users.iter_mut().find(|u| u.is_self) { self_user.position = pos; + self_user.display_position = pos; } // Sync all user avatars to the scene @@ -464,6 +520,8 @@ impl NostrverseApp { .map(|b| (b.max.y - b.min.y) * 0.5) .unwrap_or(0.0); let avatar_y_offset = avatar_half_h * AVATAR_SCALE; + let now = self.start_time.elapsed().as_secs_f64(); + let dt = 1.0 / 60.0_f32; // Smoothly lerp avatar yaw toward target if let Some(target_yaw) = avatar_yaw { @@ -471,12 +529,30 @@ impl NostrverseApp { let mut diff = target_yaw - current; diff = (diff + std::f32::consts::PI).rem_euclid(std::f32::consts::TAU) - std::f32::consts::PI; - let dt = 1.0 / 60.0; let t = (AVATAR_YAW_LERP_SPEED * dt).min(1.0); self.state.smooth_avatar_yaw = current + diff * t; } for user in &mut self.state.users { + // Dead reckoning for remote users + if !user.is_self { + let time_since_update = (now - user.update_time).min(MAX_EXTRAPOLATION_TIME) as f32; + let extrapolated = user.position + user.velocity * time_since_update; + + // Clamp extrapolation distance to prevent runaway drift + let offset = extrapolated - user.position; + let target = if offset.length() > MAX_EXTRAPOLATION_DISTANCE { + user.position + offset.normalize() * MAX_EXTRAPOLATION_DISTANCE + } else { + extrapolated + }; + + // Smooth lerp display_position toward the extrapolated target + let t = (AVATAR_POS_LERP_SPEED * dt).min(1.0); + user.display_position = user.display_position.lerp(target, t); + } + + let render_pos = user.display_position; let yaw = if user.is_self { self.state.smooth_avatar_yaw } else { @@ -484,7 +560,7 @@ impl NostrverseApp { }; let transform = Transform { - translation: user.position + Vec3::new(0.0, avatar_y_offset, 0.0), + translation: render_pos + Vec3::new(0.0, avatar_y_offset, 0.0), rotation: glam::Quat::from_rotation_y(yaw), scale: Vec3::splat(AVATAR_SCALE), }; @@ -552,11 +628,15 @@ impl notedeck::App for NostrverseApp { // Editing panel (always visible in edit mode) if self.state.edit_mode { - ui.allocate_ui(egui::vec2(panel_width, available.y), |ui| { - if let Some(action) = render_editing_panel(ui, &mut self.state) { - self.handle_action(action, ctx); - } - }); + ui.allocate_ui_with_layout( + egui::vec2(panel_width, available.y), + egui::Layout::top_down(egui::Align::LEFT), + |ui| { + if let Some(action) = render_editing_panel(ui, &mut self.state) { + self.handle_action(action, ctx); + } + }, + ); } }); }); diff --git a/crates/notedeck_nostrverse/src/nostr_events.rs b/crates/notedeck_nostrverse/src/nostr_events.rs @@ -71,8 +71,13 @@ fn get_tag_value<'a>(note: &'a Note<'a>, tag_name: &str) -> Option<&'a str> { /// /// The expiration tag (NIP-40) tells relays/nostrdb to discard the event /// after 90 seconds, matching the client-side stale timeout. -pub fn build_presence_event<'a>(room_naddr: &str, position: glam::Vec3) -> NoteBuilder<'a> { +pub fn build_presence_event<'a>( + room_naddr: &str, + position: glam::Vec3, + velocity: glam::Vec3, +) -> NoteBuilder<'a> { let pos_str = format!("{} {} {}", position.x, position.y, position.z); + let vel_str = format!("{} {} {}", velocity.x, velocity.y, velocity.z); let expiration = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -91,6 +96,9 @@ pub fn build_presence_event<'a>(room_naddr: &str, position: glam::Vec3) -> NoteB .tag_str("position") .tag_str(&pos_str) .start_tag() + .tag_str("velocity") + .tag_str(&vel_str) + .start_tag() .tag_str("expiration") .tag_str(&exp_str) } @@ -105,29 +113,46 @@ pub fn parse_presence_position(note: &Note<'_>) -> Option<glam::Vec3> { Some(glam::Vec3::new(x, y, z)) } +/// Parse a presence event's velocity tag into a Vec3. +/// Returns Vec3::ZERO if no velocity tag (backward compatible with old events). +pub fn parse_presence_velocity(note: &Note<'_>) -> glam::Vec3 { + let Some(vel_str) = get_tag_value(note, "velocity") else { + return glam::Vec3::ZERO; + }; + let mut parts = vel_str.split_whitespace(); + let x: f32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0.0); + let y: f32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0.0); + let z: f32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0.0); + glam::Vec3::new(x, y, z) +} + /// Extract the "a" tag (room naddr) from a presence note. pub fn get_presence_room<'a>(note: &'a Note<'a>) -> Option<&'a str> { get_tag_value(note, "a") } /// 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)] @@ -209,7 +234,8 @@ mod tests { #[test] fn test_build_presence_event() { let pos = glam::Vec3::new(1.5, 0.0, -3.2); - let mut builder = build_presence_event("37555:abc123:my-room", pos); + let vel = glam::Vec3::new(2.0, 0.0, -1.0); + let mut builder = build_presence_event("37555:abc123:my-room", pos, vel); let note = builder.build().expect("build note"); assert_eq!(note.content(), ""); @@ -220,6 +246,11 @@ mod tests { assert!((parsed_pos.y - 0.0).abs() < 0.01); assert!((parsed_pos.z - (-3.2)).abs() < 0.01); + let parsed_vel = parse_presence_velocity(&note); + assert!((parsed_vel.x - 2.0).abs() < 0.01); + assert!((parsed_vel.y - 0.0).abs() < 0.01); + assert!((parsed_vel.z - (-1.0)).abs() < 0.01); + // Should have an expiration tag (NIP-40) let exp = get_tag_value(&note, "expiration").expect("missing expiration tag"); let exp_ts: u64 = exp.parse().expect("expiration should be a number"); diff --git a/crates/notedeck_nostrverse/src/presence.rs b/crates/notedeck_nostrverse/src/presence.rs @@ -25,15 +25,28 @@ const STALE_TIMEOUT: f64 = 90.0; /// How often to check for stale users (seconds). const EXPIRY_CHECK_INTERVAL: f64 = 10.0; +/// Minimum speed to consider "moving" (units/s). Below this, velocity is zeroed. +const MIN_SPEED: f32 = 0.1; + +/// Direction change threshold (dot product). cos(30°) ≈ 0.866. +/// If the normalized velocity direction changes by more than ~30°, publish. +const DIRECTION_CHANGE_THRESHOLD: f32 = 0.866; + /// Publishes local user presence as kind 10555 events. /// -/// Only publishes when position changes meaningfully, plus periodic -/// keep-alive to maintain room presence. Does not spam on idle. +/// Only publishes when position or velocity changes meaningfully, plus periodic +/// keep-alive to maintain room presence. Includes velocity for dead reckoning. pub struct PresencePublisher { /// Last position we published last_position: Vec3, + /// Last velocity we published + last_velocity: Vec3, /// Monotonic time of last publish last_publish_time: f64, + /// Previous position sample (for computing velocity) + prev_position: Vec3, + /// Time of previous position sample + prev_position_time: f64, /// Whether we've published at least once published_once: bool, } @@ -42,14 +55,30 @@ impl PresencePublisher { pub fn new() -> Self { Self { last_position: Vec3::ZERO, + last_velocity: Vec3::ZERO, last_publish_time: 0.0, + prev_position: Vec3::ZERO, + prev_position_time: 0.0, published_once: false, } } + /// Compute instantaneous velocity from position samples. + fn compute_velocity(&self, position: Vec3, now: f64) -> Vec3 { + let dt = now - self.prev_position_time; + if dt < 0.01 { + return self.last_velocity; + } + let vel = (position - self.prev_position) / dt as f32; + if vel.length() < MIN_SPEED { + Vec3::ZERO + } else { + vel + } + } + /// Check whether a publish should happen (without side effects). - /// Used for both the real publish path and tests. - fn should_publish(&self, position: Vec3, now: f64) -> bool { + fn should_publish(&self, position: Vec3, velocity: Vec3, now: f64) -> bool { // Always publish the first time if !self.published_once { return true; @@ -63,18 +92,34 @@ impl PresencePublisher { } // Publish if position changed meaningfully - let moved = self.last_position.distance(position) > POSITION_THRESHOLD; - if moved { + if self.last_position.distance(position) > POSITION_THRESHOLD { + return true; + } + + // Publish on start/stop transitions + let was_moving = self.last_velocity.length() > MIN_SPEED; + let is_moving = velocity.length() > MIN_SPEED; + if was_moving != is_moving { return true; } + // Publish on significant direction change while moving + if was_moving && is_moving { + let old_dir = self.last_velocity.normalize(); + let new_dir = velocity.normalize(); + if old_dir.dot(new_dir) < DIRECTION_CHANGE_THRESHOLD { + return true; + } + } + // Keep-alive: publish periodically even when idle elapsed >= KEEPALIVE_INTERVAL } /// Record that a publish happened (update internal state). - fn record_publish(&mut self, position: Vec3, now: f64) { + fn record_publish(&mut self, position: Vec3, velocity: Vec3, now: f64) { self.last_position = position; + self.last_velocity = velocity; self.last_publish_time = now; self.published_once = true; } @@ -88,14 +133,20 @@ impl PresencePublisher { position: Vec3, now: f64, ) -> bool { - if !self.should_publish(position, now) { + let velocity = self.compute_velocity(position, now); + + // Always update position sample for velocity computation + self.prev_position = position; + self.prev_position_time = now; + + if !self.should_publish(position, velocity, now) { return false; } - let builder = nostr_events::build_presence_event(room_naddr, position); + let builder = nostr_events::build_presence_event(room_naddr, position, velocity); nostr_events::ingest_event(builder, ndb, kp); - self.record_publish(position, now); + self.record_publish(position, velocity, now); true } } @@ -135,12 +186,20 @@ pub fn poll_presence( continue; } + let velocity = nostr_events::parse_presence_velocity(note); + // Update or insert user if let Some(user) = users.iter_mut().find(|u| u.pubkey == pubkey) { + // Update authoritative state; preserve display_position for smooth lerp user.position = position; + user.velocity = velocity; + user.update_time = now; user.last_seen = now; } else { let mut user = RoomUser::new(pubkey, "anon".to_string(), position); + user.velocity = velocity; + user.display_position = position; // snap on first appearance + user.update_time = now; user.last_seen = now; users.push(user); } @@ -223,40 +282,88 @@ mod tests { fn test_publisher_first_publish() { let pub_ = PresencePublisher::new(); // First publish should always happen - assert!(pub_.should_publish(Vec3::ZERO, 0.0)); + assert!(pub_.should_publish(Vec3::ZERO, Vec3::ZERO, 0.0)); } #[test] fn test_publisher_no_spam_when_idle() { let mut pub_ = PresencePublisher::new(); - pub_.record_publish(Vec3::ZERO, 0.0); + pub_.record_publish(Vec3::ZERO, Vec3::ZERO, 0.0); // Idle at same position — should NOT publish at 1s, 5s, 10s, 30s - assert!(!pub_.should_publish(Vec3::ZERO, 1.0)); - assert!(!pub_.should_publish(Vec3::ZERO, 5.0)); - assert!(!pub_.should_publish(Vec3::ZERO, 10.0)); - assert!(!pub_.should_publish(Vec3::ZERO, 30.0)); + assert!(!pub_.should_publish(Vec3::ZERO, Vec3::ZERO, 1.0)); + assert!(!pub_.should_publish(Vec3::ZERO, Vec3::ZERO, 5.0)); + assert!(!pub_.should_publish(Vec3::ZERO, Vec3::ZERO, 10.0)); + assert!(!pub_.should_publish(Vec3::ZERO, Vec3::ZERO, 30.0)); // Keep-alive triggers at 60s - assert!(pub_.should_publish(Vec3::ZERO, 60.1)); + assert!(pub_.should_publish(Vec3::ZERO, Vec3::ZERO, 60.1)); } #[test] fn test_publisher_on_movement() { let mut pub_ = PresencePublisher::new(); - pub_.record_publish(Vec3::ZERO, 0.0); + pub_.record_publish(Vec3::ZERO, Vec3::ZERO, 0.0); // Small movement below threshold — no publish - assert!(!pub_.should_publish(Vec3::new(0.1, 0.0, 0.0), 2.0)); + assert!(!pub_.should_publish(Vec3::new(0.1, 0.0, 0.0), Vec3::ZERO, 2.0)); // Significant movement — publish - assert!(pub_.should_publish(Vec3::new(5.0, 0.0, 0.0), 2.0)); + assert!(pub_.should_publish(Vec3::new(5.0, 0.0, 0.0), Vec3::ZERO, 2.0)); // But rate limited: can't publish again within 1s - pub_.record_publish(Vec3::new(5.0, 0.0, 0.0), 2.0); - assert!(!pub_.should_publish(Vec3::new(10.0, 0.0, 0.0), 2.5)); + pub_.record_publish(Vec3::new(5.0, 0.0, 0.0), Vec3::ZERO, 2.0); + assert!(!pub_.should_publish(Vec3::new(10.0, 0.0, 0.0), Vec3::ZERO, 2.5)); // After 1s gap, can publish again - assert!(pub_.should_publish(Vec3::new(10.0, 0.0, 0.0), 3.1)); + assert!(pub_.should_publish(Vec3::new(10.0, 0.0, 0.0), Vec3::ZERO, 3.1)); + } + + #[test] + fn test_publisher_velocity_start_stop() { + let mut pub_ = PresencePublisher::new(); + let pos = Vec3::new(1.0, 0.0, 0.0); + pub_.record_publish(pos, Vec3::ZERO, 0.0); + + // Start moving — should trigger (velocity went from zero to non-zero) + let vel = Vec3::new(3.0, 0.0, 0.0); + assert!(pub_.should_publish(pos, vel, 2.0)); + pub_.record_publish(pos, vel, 2.0); + + // Stop moving — should trigger (velocity went from non-zero to zero) + assert!(pub_.should_publish(pos, Vec3::ZERO, 3.5)); + } + + #[test] + fn test_publisher_velocity_direction_change() { + let mut pub_ = PresencePublisher::new(); + let pos = Vec3::new(1.0, 0.0, 0.0); + let vel_east = Vec3::new(3.0, 0.0, 0.0); + pub_.record_publish(pos, vel_east, 0.0); + + // Small direction change (still mostly east) — no publish + let vel_slight = Vec3::new(3.0, 0.0, 0.5); + assert!(!pub_.should_publish(pos, vel_slight, 2.0)); + + // Large direction change (east → north, 90 degrees) — should publish + let vel_north = Vec3::new(0.0, 0.0, 3.0); + assert!(pub_.should_publish(pos, vel_north, 2.0)); + } + + #[test] + fn test_compute_velocity() { + let mut pub_ = PresencePublisher::new(); + pub_.prev_position = Vec3::ZERO; + pub_.prev_position_time = 0.0; + + // 5 units in 1 second = 5 units/s + let vel = pub_.compute_velocity(Vec3::new(5.0, 0.0, 0.0), 1.0); + assert!((vel.x - 5.0).abs() < 0.01); + + // Very small movement → zeroed (below MIN_SPEED) + pub_.prev_position = Vec3::ZERO; + pub_.prev_position_time = 0.0; + let vel = pub_.compute_velocity(Vec3::new(0.01, 0.0, 0.0), 1.0); + assert_eq!(vel, Vec3::ZERO); } } 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, @@ -161,7 +164,14 @@ impl RoomObject { pub struct RoomUser { pub pubkey: Pubkey, pub display_name: String, + /// Authoritative position from last presence event pub position: Vec3, + /// Velocity from last presence event (units/second) + pub velocity: Vec3, + /// Smoothed display position (interpolated for remote users, direct for self) + pub display_position: Vec3, + /// Monotonic time when last presence update was received (extrapolation base) + pub update_time: f64, /// Whether this is the current user pub is_self: bool, /// Monotonic timestamp (seconds) of last presence update @@ -178,6 +188,9 @@ impl RoomUser { pubkey, display_name, position, + velocity: Vec3::ZERO, + display_position: position, + update_time: 0.0, is_self: false, last_seen: 0.0, scene_object_id: None, diff --git a/crates/notedeck_nostrverse/src/room_view.rs b/crates/notedeck_nostrverse/src/room_view.rs @@ -1,6 +1,6 @@ //! Room 3D rendering and editing UI for nostrverse via renderbud -use egui::{Color32, Pos2, Rect, Response, Sense, Stroke, Ui}; +use egui::{Color32, Pos2, Rect, Response, Sense, Ui}; use glam::Vec3; use super::room_state::{NostrverseAction, NostrverseState, RoomObject, RoomShape}; @@ -121,232 +121,219 @@ fn draw_info_overlay(painter: &egui::Painter, state: &NostrverseState, rect: Rec pub fn render_editing_panel(ui: &mut Ui, state: &mut NostrverseState) -> Option<NostrverseAction> { let mut action = None; - let panel = egui::Frame::default() - .fill(Color32::from_rgba_unmultiplied(30, 35, 45, 240)) - .inner_margin(12.0) - .outer_margin(8.0) - .corner_radius(8.0) - .stroke(Stroke::new(1.0, Color32::from_rgb(80, 90, 110))); - - panel.show(ui, |ui| { - ui.set_min_width(220.0); - - egui::ScrollArea::vertical().show(ui, |ui| { - // --- Room Properties --- - if let Some(room) = &mut state.room { - ui.strong("Room"); - ui.separator(); - - let name_changed = ui - .horizontal(|ui| { - ui.label("Name:"); - ui.text_edit_singleline(&mut room.name).changed() + // --- Room Properties --- + if let Some(room) = &mut state.room { + ui.strong("Room"); + ui.separator(); + + let name_changed = ui + .horizontal(|ui| { + ui.label("Name:"); + ui.text_edit_singleline(&mut room.name).changed() + }) + .inner; + + let mut width = room.width; + let mut height = room.height; + let mut depth = room.depth; + + let dims_changed = ui + .horizontal(|ui| { + ui.label("W:"); + let w = ui + .add( + egui::DragValue::new(&mut width) + .speed(0.5) + .range(1.0..=200.0), + ) + .changed(); + ui.label("H:"); + let h = ui + .add( + egui::DragValue::new(&mut height) + .speed(0.5) + .range(1.0..=200.0), + ) + .changed(); + ui.label("D:"); + let d = ui + .add( + egui::DragValue::new(&mut depth) + .speed(0.5) + .range(1.0..=200.0), + ) + .changed(); + w || h || d + }) + .inner; + + room.width = width; + room.height = height; + room.depth = depth; + + let shape_changed = ui + .horizontal(|ui| { + ui.label("Shape:"); + let mut changed = false; + egui::ComboBox::from_id_salt("room_shape") + .selected_text(match room.shape { + RoomShape::Rectangle => "Rectangle", + RoomShape::Circle => "Circle", + RoomShape::Custom => "Custom", }) - .inner; - - let mut width = room.width; - let mut height = room.height; - let mut depth = room.depth; - - let dims_changed = ui - .horizontal(|ui| { - ui.label("W:"); - let w = ui - .add( - egui::DragValue::new(&mut width) - .speed(0.5) - .range(1.0..=200.0), - ) + .show_ui(ui, |ui| { + changed |= ui + .selectable_value(&mut room.shape, RoomShape::Rectangle, "Rectangle") .changed(); - ui.label("H:"); - let h = ui - .add( - egui::DragValue::new(&mut height) - .speed(0.5) - .range(1.0..=200.0), - ) + changed |= ui + .selectable_value(&mut room.shape, RoomShape::Circle, "Circle") .changed(); - ui.label("D:"); - let d = ui - .add( - egui::DragValue::new(&mut depth) - .speed(0.5) - .range(1.0..=200.0), - ) - .changed(); - w || h || d - }) - .inner; - - room.width = width; - room.height = height; - room.depth = depth; - - let shape_changed = ui - .horizontal(|ui| { - ui.label("Shape:"); - let mut changed = false; - egui::ComboBox::from_id_salt("room_shape") - .selected_text(match room.shape { - RoomShape::Rectangle => "Rectangle", - RoomShape::Circle => "Circle", - RoomShape::Custom => "Custom", - }) - .show_ui(ui, |ui| { - changed |= ui - .selectable_value( - &mut room.shape, - RoomShape::Rectangle, - "Rectangle", - ) - .changed(); - changed |= ui - .selectable_value(&mut room.shape, RoomShape::Circle, "Circle") - .changed(); - }); - changed - }) - .inner; + }); + changed + }) + .inner; - if name_changed || dims_changed || shape_changed { - state.dirty = true; - } + if name_changed || dims_changed || shape_changed { + state.dirty = true; + } - ui.add_space(8.0); - } + ui.add_space(8.0); + } - // --- Object List --- - ui.strong("Objects"); - ui.separator(); - - let num_objects = state.objects.len(); - for i in 0..num_objects { - let is_selected = state - .selected_object - .as_ref() - .map(|s| s == &state.objects[i].id) - .unwrap_or(false); - - let label = format!("{} ({})", state.objects[i].name, state.objects[i].id); - if ui.selectable_label(is_selected, label).clicked() { - let id = state.objects[i].id.clone(); - state.selected_object = if is_selected { None } else { Some(id) }; - } - } + // --- Object List --- + ui.strong("Objects"); + ui.separator(); + + let num_objects = state.objects.len(); + for i in 0..num_objects { + let is_selected = state + .selected_object + .as_ref() + .map(|s| s == &state.objects[i].id) + .unwrap_or(false); + + let label = format!("{} ({})", state.objects[i].name, state.objects[i].id); + if ui.selectable_label(is_selected, label).clicked() { + let id = state.objects[i].id.clone(); + state.selected_object = if is_selected { None } else { Some(id) }; + } + } - // Add object button - ui.add_space(4.0); - if ui.button("+ Add Object").clicked() { - let new_id = format!("obj-{}", state.objects.len() + 1); - let obj = RoomObject::new(new_id.clone(), "New Object".to_string(), Vec3::ZERO); - action = Some(NostrverseAction::AddObject(obj)); - } + // Add object button + ui.add_space(4.0); + if ui.button("+ Add Object").clicked() { + let new_id = format!("obj-{}", state.objects.len() + 1); + let obj = RoomObject::new(new_id.clone(), "New Object".to_string(), Vec3::ZERO); + action = Some(NostrverseAction::AddObject(obj)); + } - ui.add_space(12.0); + ui.add_space(12.0); - // --- Object Inspector --- - if let Some(selected_id) = state.selected_object.clone() - && let Some(obj) = state.objects.iter_mut().find(|o| o.id == selected_id) - { - ui.strong("Inspector"); - ui.separator(); + // --- Object Inspector --- + if let Some(selected_id) = state.selected_object.as_ref() + && let Some(obj) = state.objects.iter_mut().find(|o| &o.id == selected_id) + { + ui.strong("Inspector"); + ui.separator(); + + ui.small(format!("ID: {}", obj.id)); + ui.add_space(4.0); + + // Editable name + let name_changed = ui + .horizontal(|ui| { + ui.label("Name:"); + ui.text_edit_singleline(&mut obj.name).changed() + }) + .inner; + + // 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_label); + let x = ui + .add(egui::DragValue::new(&mut ox).speed(0.1).prefix("x:")) + .changed(); + let y = ui + .add(egui::DragValue::new(&mut oy).speed(0.1).prefix("y:")) + .changed(); + let z = ui + .add(egui::DragValue::new(&mut oz).speed(0.1).prefix("z:")) + .changed(); + x || y || z + }) + .inner; + obj.position = base + Vec3::new(ox, oy, oz); + + // Editable scale (uniform) + let mut sx = obj.scale.x; + let mut sy = obj.scale.y; + let mut sz = obj.scale.z; + let scale_changed = ui + .horizontal(|ui| { + ui.label("Scale:"); + let x = ui + .add( + egui::DragValue::new(&mut sx) + .speed(0.05) + .prefix("x:") + .range(0.01..=100.0), + ) + .changed(); + let y = ui + .add( + egui::DragValue::new(&mut sy) + .speed(0.05) + .prefix("y:") + .range(0.01..=100.0), + ) + .changed(); + let z = ui + .add( + egui::DragValue::new(&mut sz) + .speed(0.05) + .prefix("z:") + .range(0.01..=100.0), + ) + .changed(); + x || y || z + }) + .inner; + obj.scale = Vec3::new(sx, sy, sz); + + // Model URL (read-only for now) + if let Some(url) = &obj.model_url { + ui.add_space(4.0); + ui.small(format!("Model: {}", url)); + } - ui.small(format!("ID: {}", obj.id)); - ui.add_space(4.0); + if name_changed || pos_changed || scale_changed { + state.dirty = true; + } - // Editable name - let name_changed = ui - .horizontal(|ui| { - ui.label("Name:"); - ui.text_edit_singleline(&mut obj.name).changed() - }) - .inner; - - // Editable position - let mut px = obj.position.x; - let mut py = obj.position.y; - let mut pz = obj.position.z; - let pos_changed = ui - .horizontal(|ui| { - ui.label("Pos:"); - let x = ui - .add(egui::DragValue::new(&mut px).speed(0.1).prefix("x:")) - .changed(); - let y = ui - .add(egui::DragValue::new(&mut py).speed(0.1).prefix("y:")) - .changed(); - let z = ui - .add(egui::DragValue::new(&mut pz).speed(0.1).prefix("z:")) - .changed(); - x || y || z - }) - .inner; - obj.position = Vec3::new(px, py, pz); - - // Editable scale (uniform) - let mut sx = obj.scale.x; - let mut sy = obj.scale.y; - let mut sz = obj.scale.z; - let scale_changed = ui - .horizontal(|ui| { - ui.label("Scale:"); - let x = ui - .add( - egui::DragValue::new(&mut sx) - .speed(0.05) - .prefix("x:") - .range(0.01..=100.0), - ) - .changed(); - let y = ui - .add( - egui::DragValue::new(&mut sy) - .speed(0.05) - .prefix("y:") - .range(0.01..=100.0), - ) - .changed(); - let z = ui - .add( - egui::DragValue::new(&mut sz) - .speed(0.05) - .prefix("z:") - .range(0.01..=100.0), - ) - .changed(); - x || y || z - }) - .inner; - obj.scale = Vec3::new(sx, sy, sz); - - // Model URL (read-only for now) - if let Some(url) = &obj.model_url { - ui.add_space(4.0); - ui.small(format!("Model: {}", url)); - } - - if name_changed || pos_changed || scale_changed { - state.dirty = true; - } - - ui.add_space(8.0); - if ui.button("Delete Object").clicked() { - action = Some(NostrverseAction::RemoveObject(selected_id)); - } - } + ui.add_space(8.0); + if ui.button("Delete Object").clicked() { + action = Some(NostrverseAction::RemoveObject(selected_id.to_owned())); + } + } - // --- Save button --- - ui.add_space(12.0); - ui.separator(); - let save_label = if state.dirty { "Save *" } else { "Save" }; - if ui - .add_enabled(state.dirty, egui::Button::new(save_label)) - .clicked() - { - action = Some(NostrverseAction::SaveRoom); - } - }); - }); + // --- Save button --- + ui.add_space(12.0); + ui.separator(); + let save_label = if state.dirty { "Save *" } else { "Save" }; + if ui + .add_enabled(state.dirty, egui::Button::new(save_label)) + .clicked() + { + action = Some(NostrverseAction::SaveRoom); + } action } 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)