notedeck

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

commit 5317b9b79ae04e557424a58620ed52794c841796
parent 14ac7d491d2e40292f9400d70159f4c006eaab6f
Author: William Casarin <jb55@jb55.com>
Date:   Tue, 24 Feb 2026 10:18:04 -0800

nostrverse: add grid snap, parented object dragging with breakaway

Enable dragging of parented objects (TopOf/Near) on the parent's
surface with automatic clamping to parent AABB bounds. When dragged
beyond 1m overshoot, the object breaks away and becomes a free
root object. Grid snapping (G key toggle, configurable size) applies
in the appropriate coordinate space for both free and parented drags.

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

Diffstat:
Mcrates/notedeck_nostrverse/src/room_state.rs | 25++++++++++++++++++++++++-
Mcrates/notedeck_nostrverse/src/room_view.rs | 209+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/renderbud/src/lib.rs | 10++++++++++
Mcrates/renderbud/src/model.rs | 16++++++++++++++++
Mcrates/renderbud/src/world.rs | 8++++++++
5 files changed, 251 insertions(+), 17 deletions(-)

diff --git a/crates/notedeck_nostrverse/src/room_state.rs b/crates/notedeck_nostrverse/src/room_state.rs @@ -2,7 +2,7 @@ use enostr::Pubkey; use glam::{Quat, Vec3}; -use renderbud::{Model, ObjectId}; +use renderbud::{Aabb, Model, ObjectId}; /// Actions that can be triggered from the nostrverse view #[derive(Clone, Debug)] @@ -204,6 +204,21 @@ impl RoomUser { } } +/// How a drag interaction is constrained +#[derive(Clone, Debug)] +pub enum DragMode { + /// Free object: drag on world-space Y plane + Free, + /// Parented object: slide on parent surface, may break away + Parented { + parent_id: String, + parent_scene_id: ObjectId, + parent_aabb: Aabb, + /// Local Y where child sits (e.g. parent top + child half height) + local_y: f32, + }, +} + /// State for an active object drag in the 3D viewport pub struct DragState { /// ID of the object being dragged @@ -212,6 +227,8 @@ pub struct DragState { pub grab_offset: Vec3, /// Y height of the drag constraint plane pub plane_y: f32, + /// Drag constraint mode + pub mode: DragMode, } /// State for a nostrverse view @@ -234,6 +251,10 @@ pub struct NostrverseState { pub dirty: bool, /// Active drag state for viewport object manipulation pub drag_state: Option<DragState>, + /// Grid snap size in meters + pub grid_snap: f32, + /// Whether grid snapping is enabled + pub grid_snap_enabled: bool, /// Cached serialized scene text (avoids re-serializing every frame) pub cached_scene_text: String, } @@ -250,6 +271,8 @@ impl NostrverseState { smooth_avatar_yaw: 0.0, dirty: false, drag_state: None, + grid_snap: 0.5, + grid_snap_enabled: false, cached_scene_text: String::new(), } } diff --git a/crates/notedeck_nostrverse/src/room_view.rs b/crates/notedeck_nostrverse/src/room_view.rs @@ -5,7 +5,7 @@ use glam::Vec3; use super::convert; use super::room_state::{ - DragState, NostrverseAction, NostrverseState, ObjectLocation, RoomObject, RoomShape, + DragMode, DragState, NostrverseAction, NostrverseState, ObjectLocation, RoomObject, RoomShape, }; /// Response from rendering the nostrverse view @@ -14,6 +14,84 @@ pub struct NostrverseResponse { pub action: Option<NostrverseAction>, } +fn snap_to_grid(pos: Vec3, grid: f32) -> Vec3 { + Vec3::new( + (pos.x / grid).round() * grid, + pos.y, + (pos.z / grid).round() * grid, + ) +} + +/// Result of computing a drag update — fully owned, no borrows into state. +enum DragUpdate { + Move { + id: String, + position: Vec3, + }, + Breakaway { + id: String, + world_pos: Vec3, + new_grab_offset: Vec3, + new_plane_y: f32, + }, +} + +/// Pure computation: given current drag state and pointer, decide what to do. +fn compute_drag_update( + drag: &DragState, + vp_x: f32, + vp_y: f32, + grid_snap: Option<f32>, + r: &renderbud::Renderer, +) -> Option<DragUpdate> { + match &drag.mode { + DragMode::Free => { + let hit = r.unproject_to_plane(vp_x, vp_y, drag.plane_y)?; + let mut new_pos = hit + drag.grab_offset; + if let Some(grid) = grid_snap { + new_pos = snap_to_grid(new_pos, grid); + } + Some(DragUpdate::Move { + id: drag.object_id.clone(), + position: new_pos, + }) + } + DragMode::Parented { + parent_scene_id, + parent_aabb, + local_y, + .. + } => { + let hit = r.unproject_to_plane(vp_x, vp_y, drag.plane_y)?; + let parent_world = r.world_matrix(*parent_scene_id)?; + let local_hit = parent_world.inverse().transform_point3(hit); + let mut local_pos = Vec3::new( + local_hit.x + drag.grab_offset.x, + *local_y, + local_hit.z + drag.grab_offset.z, + ); + if let Some(grid) = grid_snap { + local_pos = snap_to_grid(local_pos, grid); + } + + if parent_aabb.xz_overshoot(local_pos) > 1.0 { + let world_pos = parent_world.transform_point3(local_pos); + Some(DragUpdate::Breakaway { + id: drag.object_id.clone(), + world_pos, + new_grab_offset: world_pos - hit, + new_plane_y: world_pos.y, + }) + } else { + Some(DragUpdate::Move { + id: drag.object_id.clone(), + position: parent_aabb.clamp_xz(local_pos), + }) + } + } + } +} + /// Render the nostrverse room view with 3D scene pub fn show_room_view( ui: &mut Ui, @@ -44,17 +122,65 @@ pub fn show_room_view( .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); + let drag_info = match &obj.location { + Some(ObjectLocation::TopOf(parent_id)) + | Some(ObjectLocation::Near(parent_id)) => { + let parent_obj = state.objects.iter().find(|o| o.id == *parent_id); + if let Some(parent) = parent_obj + && let Some(parent_scene_id) = parent.scene_object_id + && let Some(parent_model) = parent.model_handle + && let Some(parent_aabb) = r.model_bounds(parent_model) + && let Some(parent_world) = r.world_matrix(parent_scene_id) + { + let child_half_h = obj + .model_handle + .and_then(|m| r.model_bounds(m)) + .map(|b| (b.max.y - b.min.y) * 0.5) + .unwrap_or(0.0); + let local_y = + if matches!(&obj.location, Some(ObjectLocation::TopOf(_))) { + parent_aabb.max.y + child_half_h + } else { + 0.0 + }; + let obj_world = parent_world.transform_point3(obj.position); + let plane_y = obj_world.y; + let hit = r + .unproject_to_plane(vp.x, vp.y, plane_y) + .unwrap_or(obj_world); + let local_hit = parent_world.inverse().transform_point3(hit); + let grab_offset = obj.position - local_hit; + Some(( + DragMode::Parented { + parent_id: parent_id.clone(), + parent_scene_id, + parent_aabb, + local_y, + }, + grab_offset, + plane_y, + )) + } else { + None + } + } + None | Some(ObjectLocation::Floor) => { + let plane_y = obj.position.y; + let hit = r + .unproject_to_plane(vp.x, vp.y, plane_y) + .unwrap_or(obj.position); + let grab_offset = obj.position - hit; + Some((DragMode::Free, grab_offset, plane_y)) + } + _ => None, // Center/Ceiling/Custom: not draggable + }; + + if let Some((mode, grab_offset, plane_y)) = drag_info { state.drag_state = Some(DragState { object_id: obj.id.clone(), - grab_offset: obj.position - hit, + grab_offset, plane_y, + mode, }); action = Some(NostrverseAction::SelectObject(Some(obj.id.clone()))); } @@ -63,15 +189,47 @@ pub fn show_room_view( // Dragging: move object or control camera if response.dragged() { - if let Some(ref drag) = state.drag_state { + let has_drag = state.drag_state.is_some(); + if has_drag { 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, - }); + let grid = state.grid_snap_enabled.then_some(state.grid_snap); + // Borrow of state.drag_state is scoped to this call + let update = compute_drag_update( + state.drag_state.as_ref().unwrap(), + vp.x, + vp.y, + grid, + &r, + ); + // Borrow released — free to mutate state + match update { + Some(DragUpdate::Move { id, position }) => { + action = Some(NostrverseAction::MoveObject { id, position }); + } + Some(DragUpdate::Breakaway { + id, + world_pos, + new_grab_offset, + new_plane_y, + }) => { + if let Some(obj) = state.objects.iter_mut().find(|o| o.id == id) { + if let Some(sid) = obj.scene_object_id { + r.set_parent(sid, None); + } + obj.position = world_pos; + obj.location = None; + obj.location_base = None; + state.dirty = true; + } + state.drag_state = Some(DragState { + object_id: id, + grab_offset: new_grab_offset, + plane_y: new_plane_y, + mode: DragMode::Free, + }); + } + None => {} } } ui.ctx().request_repaint(); @@ -119,6 +277,11 @@ pub fn show_room_view( } } + // G key: toggle grid snap + if ui.input(|i| i.key_pressed(egui::Key::G)) { + state.grid_snap_enabled = !state.grid_snap_enabled; + } + // WASD + QE movement: always available let dt = ui.input(|i| i.stable_dt); let mut forward = 0.0_f32; @@ -405,6 +568,20 @@ pub fn render_editing_panel(ui: &mut Ui, state: &mut NostrverseState) -> Option< } } + // --- Grid Snap --- + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.checkbox(&mut state.grid_snap_enabled, "Grid Snap (G)"); + if state.grid_snap_enabled { + ui.add( + egui::DragValue::new(&mut state.grid_snap) + .speed(0.05) + .range(0.05..=10.0) + .suffix("m"), + ); + } + }); + // --- Save button --- ui.add_space(12.0); ui.separator(); diff --git a/crates/renderbud/src/lib.rs b/crates/renderbud/src/lib.rs @@ -794,6 +794,16 @@ impl Renderer { self.models.get(&model).map(|md| md.bounds) } + /// Get the cached world matrix for a scene object. + pub fn world_matrix(&self, id: ObjectId) -> Option<glam::Mat4> { + self.world.world_matrix(id) + } + + /// Get the parent of a scene object, if it has one. + pub fn node_parent(&self, id: ObjectId) -> Option<ObjectId> { + self.world.node_parent(id) + } + /// 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) { diff --git a/crates/renderbud/src/model.rs b/crates/renderbud/src/model.rs @@ -614,4 +614,20 @@ impl Aabb { pub fn radius(&self) -> f32 { self.half_extents().length() } + + /// Clamp a point's XZ to the AABB's XZ extent. Y unchanged. + pub fn clamp_xz(&self, p: Vec3) -> Vec3 { + Vec3::new( + p.x.clamp(self.min.x, self.max.x), + p.y, + p.z.clamp(self.min.z, self.max.z), + ) + } + + /// Distance the point's XZ overshoots the AABB boundary. 0 if inside. + pub fn xz_overshoot(&self, p: Vec3) -> f32 { + let dx = (p.x - self.max.x).max(self.min.x - p.x).max(0.0); + let dz = (p.z - self.max.z).max(self.min.z - p.z).max(0.0); + (dx * dx + dz * dz).sqrt() + } } 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 parent of a node, if it has one. + pub fn node_parent(&self, id: NodeId) -> Option<NodeId> { + if !self.is_valid(id) { + return None; + } + self.nodes[id.index as usize].parent + } + /// 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) {