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:
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) {