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:
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(¬e, "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(¬e, "title")
.unwrap_or("Untitled")
.to_string(),
+ custom_title: get_tag_value(¬e, "custom_title").map(|s| s.to_string()),
cwd: get_tag_value(¬e, "cwd").unwrap_or("").to_string(),
status: get_tag_value(¬e, "status").unwrap_or("idle").to_string(),
hostname: get_tag_value(¬e, "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 ¬es {
+ // 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(¬e) 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(¬e);
+ 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(¬e, "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)