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