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:
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 => {}
}
}