notedeck

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

commit a1b89cd2ea0c123880491037e322f15bc4d78c12
parent 385571a5b32d00e7edf60fe59983effedb8e481f
Author: William Casarin <jb55@jb55.com>
Date:   Mon, 23 Feb 2026 17:49:03 -0800

nostrverse: add click-to-select and drag-to-move objects in 3D viewport

In edit mode, clicking objects selects them and dragging repositions
them on the ground plane. Adds ray-AABB picking and plane unprojection
to renderbud. Scene serialization is cached to avoid re-serializing
during drag.

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

Diffstat:
Mcrates/notedeck_nostrverse/src/room_state.rs | 16++++++++++++++++
Mcrates/notedeck_nostrverse/src/room_view.rs | 112++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/renderbud/src/lib.rs | 78+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/renderbud/src/world.rs | 8++++++++
4 files changed, 198 insertions(+), 16 deletions(-)

diff --git a/crates/notedeck_nostrverse/src/room_state.rs b/crates/notedeck_nostrverse/src/room_state.rs @@ -204,6 +204,16 @@ impl RoomUser { } } +/// State for an active object drag in the 3D viewport +pub struct DragState { + /// ID of the object being dragged + pub object_id: String, + /// Offset from object position to the initial grab point + pub grab_offset: Vec3, + /// Y height of the drag constraint plane + pub plane_y: f32, +} + /// State for a nostrverse view pub struct NostrverseState { /// Reference to the room being viewed @@ -222,6 +232,10 @@ pub struct NostrverseState { pub smooth_avatar_yaw: f32, /// Room has unsaved edits pub dirty: bool, + /// Active drag state for viewport object manipulation + pub drag_state: Option<DragState>, + /// Cached serialized scene text (avoids re-serializing every frame) + pub cached_scene_text: String, } impl NostrverseState { @@ -235,6 +249,8 @@ impl NostrverseState { edit_mode: true, smooth_avatar_yaw: 0.0, dirty: false, + drag_state: None, + cached_scene_text: String::new(), } } diff --git a/crates/notedeck_nostrverse/src/room_view.rs b/crates/notedeck_nostrverse/src/room_view.rs @@ -4,7 +4,9 @@ use egui::{Color32, Pos2, Rect, Response, Sense, Ui}; use glam::Vec3; use super::convert; -use super::room_state::{NostrverseAction, NostrverseState, RoomObject, RoomShape}; +use super::room_state::{ + DragState, NostrverseAction, NostrverseState, ObjectLocation, RoomObject, RoomShape, +}; /// Response from rendering the nostrverse view pub struct NostrverseResponse { @@ -21,18 +23,95 @@ pub fn show_room_view( let available_size = ui.available_size(); let (rect, response) = ui.allocate_exact_size(available_size, Sense::click_and_drag()); + let mut action: Option<NostrverseAction> = None; + // Update renderer target size and handle input { let mut r = renderer.renderer.lock().unwrap(); r.set_target_size((rect.width() as u32, rect.height() as u32)); - // Handle mouse drag for camera look - if response.dragged() { - let delta = response.drag_delta(); - r.on_mouse_drag(delta.x, delta.y); + if state.edit_mode { + // --- Edit mode: click-to-select, drag-to-move objects --- + + // Drag start: pick to decide object-drag vs camera + if response.drag_started() + && let Some(pos) = response.interact_pointer_pos() + { + let vp = pos - rect.min.to_vec2(); + if let Some(scene_id) = r.pick(vp.x, vp.y) + && let Some(obj) = state + .objects + .iter() + .find(|o| o.scene_object_id == Some(scene_id)) + { + let can_drag = obj.location.is_none() + || matches!(obj.location, Some(ObjectLocation::Floor)); + if can_drag { + let plane_y = obj.position.y; + let hit = r + .unproject_to_plane(vp.x, vp.y, plane_y) + .unwrap_or(obj.position); + state.drag_state = Some(DragState { + object_id: obj.id.clone(), + grab_offset: obj.position - hit, + plane_y, + }); + action = Some(NostrverseAction::SelectObject(Some(obj.id.clone()))); + } + } + } + + // Dragging: move object or control camera + if response.dragged() { + if let Some(ref drag) = state.drag_state { + if let Some(pos) = response.interact_pointer_pos() { + let vp = pos - rect.min.to_vec2(); + if let Some(hit) = r.unproject_to_plane(vp.x, vp.y, drag.plane_y) { + let new_pos = hit + drag.grab_offset; + action = Some(NostrverseAction::MoveObject { + id: drag.object_id.clone(), + position: new_pos, + }); + } + } + ui.ctx().request_repaint(); + } else { + let delta = response.drag_delta(); + r.on_mouse_drag(delta.x, delta.y); + } + } + + // Drag end: clear state + if response.drag_stopped() { + state.drag_state = None; + } + + // Click (no drag): select/deselect + if response.clicked() + && let Some(pos) = response.interact_pointer_pos() + { + let vp = pos - rect.min.to_vec2(); + if let Some(scene_id) = r.pick(vp.x, vp.y) { + if let Some(obj) = state + .objects + .iter() + .find(|o| o.scene_object_id == Some(scene_id)) + { + action = Some(NostrverseAction::SelectObject(Some(obj.id.clone()))); + } + } else { + action = Some(NostrverseAction::SelectObject(None)); + } + } + } else { + // --- View mode: camera only --- + if response.dragged() { + let delta = response.drag_delta(); + r.on_mouse_drag(delta.x, delta.y); + } } - // Handle scroll for speed adjustment + // Scroll: always routes to camera (zoom/speed) if response.hover_pos().is_some() { let scroll = ui.input(|i| i.raw_scroll_delta.y); if scroll.abs() > 0.0 { @@ -40,7 +119,7 @@ pub fn show_room_view( } } - // WASD + QE movement + // WASD + QE movement: always available let dt = ui.input(|i| i.stable_dt); let mut forward = 0.0_f32; let mut right = 0.0_f32; @@ -83,10 +162,7 @@ pub fn show_room_view( let painter = ui.painter_at(rect); draw_info_overlay(&painter, state, rect); - NostrverseResponse { - response, - action: None, - } + NostrverseResponse { response, action } } fn draw_info_overlay(painter: &egui::Painter, state: &NostrverseState, rect: Rect) { @@ -337,13 +413,19 @@ pub fn render_editing_panel(ui: &mut Ui, state: &mut NostrverseState) -> Option< } // --- Scene body (syntax-highlighted, read-only) --- + // Only re-serialize when not actively dragging an object + if state.drag_state.is_none() + && let Some(room) = &state.room + { + let space = convert::build_space(room, &state.objects); + state.cached_scene_text = protoverse::serialize(&space); + } + ui.add_space(12.0); ui.strong("Scene"); ui.separator(); - if let Some(room) = &state.room { - let space = convert::build_space(room, &state.objects); - let text = protoverse::serialize(&space); - let layout_job = highlight_sexp(&text, ui); + if !state.cached_scene_text.is_empty() { + let layout_job = highlight_sexp(&state.cached_scene_text, ui); let code_bg = if ui.visuals().dark_mode { Color32::from_rgb(0x1E, 0x1C, 0x19) } else { diff --git a/crates/renderbud/src/lib.rs b/crates/renderbud/src/lib.rs @@ -1,4 +1,4 @@ -use glam::{Mat4, Vec2, Vec3}; +use glam::{Mat4, Vec2, Vec3, Vec4}; use crate::material::{MaterialUniform, make_material_gpudata}; use crate::model::ModelData; @@ -308,6 +308,26 @@ fn make_dynamic_object_buffer( ) } +/// Ray-AABB intersection using the slab method. +/// Transforms the ray into the object's local space via the inverse world matrix. +/// Returns the distance along the ray if there's a hit. +fn ray_aabb(origin: Vec3, dir: Vec3, aabb: &Aabb, world: &Mat4) -> Option<f32> { + let inv = world.inverse(); + let lo = (inv * origin.extend(1.0)).truncate(); + let ld = (inv * dir.extend(0.0)).truncate(); + let t1 = (aabb.min - lo) / ld; + let t2 = (aabb.max - lo) / ld; + let tmin = t1.min(t2); + let tmax = t1.max(t2); + let enter = tmin.x.max(tmin.y).max(tmin.z); + let exit = tmax.x.min(tmax.y).min(tmax.z); + if exit >= enter.max(0.0) { + Some(enter.max(0.0)) + } else { + None + } +} + impl Renderer { pub fn new( device: &wgpu::Device, @@ -718,6 +738,62 @@ impl Renderer { self.models.get(&model).map(|md| md.bounds) } + /// Convert screen coordinates (relative to viewport) to a world-space ray. + /// Returns (origin, direction). + fn screen_to_ray(&self, screen_x: f32, screen_y: f32) -> (Vec3, Vec3) { + let (w, h) = self.target_size; + let ndc_x = (screen_x / w as f32) * 2.0 - 1.0; + let ndc_y = 1.0 - (screen_y / h as f32) * 2.0; + let vp = self.world.camera.view_proj(w as f32, h as f32); + let inv_vp = vp.inverse(); + let near4 = inv_vp * Vec4::new(ndc_x, ndc_y, 0.0, 1.0); + let far4 = inv_vp * Vec4::new(ndc_x, ndc_y, 1.0, 1.0); + let near = near4.truncate() / near4.w; + let far = far4.truncate() / far4.w; + (near, (far - near).normalize()) + } + + /// Pick the closest scene object at the given screen coordinates. + /// Coordinates are relative to the viewport (0,0 = top-left). + pub fn pick(&self, screen_x: f32, screen_y: f32) -> Option<ObjectId> { + let (origin, dir) = self.screen_to_ray(screen_x, screen_y); + let mut closest: Option<(ObjectId, f32)> = None; + for &id in self.world.renderables() { + let model = match self.world.node_model(id) { + Some(m) => m, + None => continue, + }; + let aabb = match self.model_bounds(model) { + Some(a) => a, + None => continue, + }; + let world = match self.world.world_matrix(id) { + Some(w) => w, + None => continue, + }; + if let Some(t) = ray_aabb(origin, dir, &aabb, &world) + && closest.is_none_or(|(_, d)| t < d) + { + closest = Some((id, t)); + } + } + closest.map(|(id, _)| id) + } + + /// Unproject screen coordinates to a point on a horizontal plane at the given Y height. + /// Useful for constraining object drag to the ground plane. + pub fn unproject_to_plane(&self, screen_x: f32, screen_y: f32, plane_y: f32) -> Option<Vec3> { + let (origin, dir) = self.screen_to_ray(screen_x, screen_y); + if dir.y.abs() < 1e-6 { + return None; + } + let t = (plane_y - origin.y) / dir.y; + if t < 0.0 { + return None; + } + Some(origin + dir * t) + } + /// Handle mouse drag for camera look/orbit. pub fn on_mouse_drag(&mut self, delta_x: f32, delta_y: f32) { match &mut self.camera_mode { diff --git a/crates/renderbud/src/world.rs b/crates/renderbud/src/world.rs @@ -349,6 +349,14 @@ impl World { Some(&self.nodes[id.index as usize]) } + /// Get the Model handle for a node, if it has one. + pub fn node_model(&self, id: NodeId) -> Option<Model> { + if !self.is_valid(id) { + return None; + } + self.nodes[id.index as usize].model + } + /// Iterate renderable node ids (nodes with a Model). pub fn renderables(&self) -> &[NodeId] { &self.renderables