notedeck

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

commit 686c15d9b2f9f987a0794fa1f7bf3f36147861c3
parent 778ae178dd4ed75de9f758c04a7180ad82faaad3
Author: William Casarin <jb55@jb55.com>
Date:   Tue, 24 Feb 2026 10:55:26 -0800

nostrverse: add drag-to-snap for placing objects on surfaces

When dragging a free object over another object's AABB footprint,
automatically snap it to that surface as a TopOf child. Computes
grab offset by re-unprojecting the cursor onto the new drag plane
for a smooth transition. Fixes early-return bug in snap scan that
aborted on objects without scene IDs.

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

Diffstat:
Mcrates/notedeck_nostrverse/src/room_view.rs | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 140 insertions(+), 0 deletions(-)

diff --git a/crates/notedeck_nostrverse/src/room_view.rs b/crates/notedeck_nostrverse/src/room_view.rs @@ -34,6 +34,80 @@ enum DragUpdate { new_grab_offset: Vec3, new_plane_y: f32, }, + SnapToParent { + id: String, + parent_id: String, + parent_scene_id: renderbud::ObjectId, + parent_aabb: renderbud::Aabb, + local_pos: Vec3, + local_y: f32, + plane_y: f32, + new_grab_offset: Vec3, + }, +} + +/// During a free drag, check if the world position lands on another object's +/// top surface. Returns snap info if a suitable parent is found. +/// Takes viewport coords to re-unproject onto the new drag plane for a +/// smooth grab-offset transition. +fn find_snap_parent( + world_pos: Vec3, + drag_id: &str, + child_half_h: f32, + vp_x: f32, + vp_y: f32, + objects: &[RoomObject], + r: &renderbud::Renderer, +) -> Option<DragUpdate> { + for obj in objects { + if obj.id == drag_id { + continue; + } + let Some(scene_id) = obj.scene_object_id else { + continue; + }; + let Some(model) = obj.model_handle else { + continue; + }; + let Some(aabb) = r.model_bounds(model) else { + continue; + }; + let Some(parent_world) = r.world_matrix(scene_id) else { + continue; + }; + let inv_parent = parent_world.inverse(); + let local_hit = inv_parent.transform_point3(world_pos); + + // Check if XZ is within the parent's AABB + if aabb.xz_overshoot(local_hit) < 0.01 { + let local_y = aabb.max.y + child_half_h; + let local_pos = aabb.clamp_xz(Vec3::new(local_hit.x, local_y, local_hit.z)); + let snapped_world = parent_world.transform_point3(local_pos); + let plane_y = snapped_world.y; + + // Compute grab offset so the object doesn't jump: + // re-unproject cursor onto the new (higher) drag plane, + // then compute offset in parent-local space. + let grab_offset = if let Some(new_hit) = r.unproject_to_plane(vp_x, vp_y, plane_y) { + let new_local = inv_parent.transform_point3(new_hit); + Vec3::new(local_pos.x - new_local.x, 0.0, local_pos.z - new_local.z) + } else { + Vec3::ZERO + }; + + return Some(DragUpdate::SnapToParent { + id: drag_id.to_string(), + parent_id: obj.id.clone(), + parent_scene_id: scene_id, + parent_aabb: aabb, + local_pos, + local_y, + plane_y, + new_grab_offset: grab_offset, + }); + } + } + None } /// Pure computation: given current drag state and pointer, decide what to do. @@ -207,6 +281,41 @@ pub fn show_room_view( &r, ); // Borrow released — free to mutate state + // For free drags, check if we should snap to a parent + let update = if let Some(DragUpdate::Move { + ref id, + ref position, + }) = update + { + if matches!( + state.drag_state.as_ref().map(|d| &d.mode), + Some(DragMode::Free) + ) { + let child_half_h = state + .objects + .iter() + .find(|o| o.id == *id) + .and_then(|o| o.model_handle) + .and_then(|m| r.model_bounds(m)) + .map(|b| (b.max.y - b.min.y) * 0.5) + .unwrap_or(0.0); + find_snap_parent( + *position, + id, + child_half_h, + vp.x, + vp.y, + &state.objects, + &r, + ) + .or(update) + } else { + update + } + } else { + update + }; + match update { Some(DragUpdate::Move { id, position }) => { action = Some(NostrverseAction::MoveObject { id, position }); @@ -233,6 +342,37 @@ pub fn show_room_view( mode: DragMode::Free, }); } + Some(DragUpdate::SnapToParent { + id, + parent_id, + parent_scene_id, + parent_aabb, + local_pos, + local_y, + plane_y, + new_grab_offset, + }) => { + 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, Some(parent_scene_id)); + } + obj.position = local_pos; + obj.location = Some(ObjectLocation::TopOf(parent_id.clone())); + obj.location_base = Some(Vec3::new(0.0, local_y, 0.0)); + state.dirty = true; + } + state.drag_state = Some(DragState { + object_id: id, + grab_offset: new_grab_offset, + plane_y, + mode: DragMode::Parented { + parent_id, + parent_scene_id, + parent_aabb, + local_y, + }, + }); + } None => {} } }