Drag events on batched scene nodes¶
Batched meshes, batched GLBs, and batched axes render many instances of
the same geometry from a single scene node. Drag callbacks fire as usual,
and the event includes instance_index — which instance the user
clicked on — so you can route the gesture to the right cube.
Each of the N x N cubes carries its own linear and angular velocity. Drag-gestures feed forces and torques into the active instance’s velocity, and a vectorized physics loop integrates + damps every instance every tick. Releasing the mouse lets the instance coast and spin on momentum, independent of its neighbors.
Gestures:
Drag (no modifier) — teleport the clicked instance rigidly. Velocity is zeroed on pick-up.
Cmd/Ctrl + drag — spring-pull the clicked grab point toward the cursor. Off-center grabs naturally produce torque too, so dragging an edge yanks AND spins the cube.
Cmd/Ctrl + Shift + drag — pin the clicked point and apply torque along the drag arrow, so the instance rotates around the arrow axis.
Source: examples/03_interaction/07_batched_scene_node_drag.py
Code¶
1from __future__ import annotations
2
3import threading
4import time
5
6import numpy as np
7
8import viser
9import viser.transforms as tf
10
11# ----- Grid config -----
12GRID_N = 5 # N x N cubes
13GRID_SPACING = 1.5
14
15# ----- Colors -----
16IDLE_COLOR = (90, 200, 255)
17TELEPORT_COLOR = (220, 120, 220)
18TRANSLATE_COLOR = (255, 120, 60)
19ROTATE_COLOR = (120, 220, 120)
20
21# ----- Physics -----
22MASS = 1.0
23# Moment of inertia for a uniform unit cube.
24INERTIA = 1.0 / 6.0
25LINEAR_DAMPING = 4.0 # 1/s
26ANGULAR_DAMPING = 4.0 # 1/s
27# Spring stiffness: higher for rotate-mode (needs a rigid pin) than for
28# translate-mode (wants some elastic give).
29SPRING_K_TRANSLATE = 40.0
30SPRING_K_ROTATE = 80.0
31TORQUE_K = 1.5
32DT = 1.0 / 60.0
33
34
35def cube_mesh() -> tuple[np.ndarray, np.ndarray]:
36 v = np.array(
37 [
38 [-0.5, -0.5, -0.5],
39 [0.5, -0.5, -0.5],
40 [0.5, 0.5, -0.5],
41 [-0.5, 0.5, -0.5],
42 [-0.5, -0.5, 0.5],
43 [0.5, -0.5, 0.5],
44 [0.5, 0.5, 0.5],
45 [-0.5, 0.5, 0.5],
46 ],
47 dtype=np.float32,
48 )
49 f = np.array(
50 [
51 [0, 2, 1],
52 [0, 3, 2],
53 [4, 5, 6],
54 [4, 6, 7],
55 [0, 1, 5],
56 [0, 5, 4],
57 [2, 3, 7],
58 [2, 7, 6],
59 [1, 2, 6],
60 [1, 6, 5],
61 [0, 4, 7],
62 [0, 7, 3],
63 ],
64 dtype=np.int32,
65 )
66 return v, f
67
68
69def main() -> None:
70 server = viser.ViserServer()
71 server.scene.set_up_direction("+z")
72 server.initial_camera.position = (0.0, -10.0, 8.0)
73 server.initial_camera.look_at = (0.0, 0.0, 0.0)
74
75 with server.gui.add_folder("Instructions"):
76 server.gui.add_markdown(
77 "**Drag** → teleport the clicked cube \n"
78 "**Cmd/Ctrl + drag** → spring-pull (linear velocity) \n"
79 "**Cmd/Ctrl + Shift + drag** → rotate around the drag arrow"
80 " (angular velocity) \n"
81 "Release to let the cube coast / spin on its own."
82 )
83
84 with server.gui.add_folder("Active drag"):
85 active_idx_gui = server.gui.add_text("instance_index", initial_value="-")
86 active_mode_gui = server.gui.add_text("mode", initial_value="idle")
87
88 server.scene.add_grid(
89 "/grid",
90 width=GRID_N * GRID_SPACING + 2,
91 height=GRID_N * GRID_SPACING + 2,
92 plane="xy",
93 )
94
95 # Initial layout: N x N grid centered at origin, z = 0.5 so cubes
96 # sit on the floor.
97 n_instances = GRID_N * GRID_N
98 positions = np.zeros((n_instances, 3), dtype=np.float32)
99 for i in range(GRID_N):
100 for j in range(GRID_N):
101 idx = i * GRID_N + j
102 positions[idx] = [
103 (i - (GRID_N - 1) / 2) * GRID_SPACING,
104 (j - (GRID_N - 1) / 2) * GRID_SPACING,
105 0.5,
106 ]
107 wxyzs = np.tile(np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float32), (n_instances, 1))
108 colors = np.tile(np.array(IDLE_COLOR, dtype=np.uint8), (n_instances, 1))
109
110 vertices, faces = cube_mesh()
111 handle = server.scene.add_batched_meshes_simple(
112 "/cubes",
113 vertices=vertices,
114 faces=faces,
115 batched_wxyzs=wxyzs,
116 batched_positions=positions,
117 batched_colors=colors,
118 flat_shading=True,
119 )
120
121 # ----- Per-instance state -----------------------------------------------
122 # One linear + angular velocity per instance. Only the currently
123 # active instance accumulates forces, but every instance integrates
124 # + damps every tick (so post-release cubes coast independently).
125 lock = threading.Lock()
126 position_arr = positions.astype(np.float64)
127 wxyz_arr = wxyzs.astype(np.float64)
128 linear_v = np.zeros((n_instances, 3))
129 angular_v = np.zeros((n_instances, 3))
130
131 # Active drag. ``mode`` is one of "teleport" | "translate" | "rotate";
132 # other fields are parameters specific to the active mode.
133 active_idx: int | None = None
134 active_mode: str | None = None
135 grab_body: np.ndarray | None = None # body-frame grab, for translate/rotate
136 spring_target: np.ndarray | None = None # world target for the spring
137 teleport_offset: np.ndarray | None = None # cursor → instance position
138 ext_torque = np.zeros(3)
139
140 shutdown = threading.Event()
141
142 def physics_loop() -> None:
143 nonlocal position_arr, wxyz_arr, linear_v, angular_v
144 last_t = time.monotonic()
145 while not shutdown.is_set():
146 now = time.monotonic()
147 dt = min(now - last_t, 0.05)
148 last_t = now
149
150 with lock:
151 # --- Active-instance driving forces -----------------------
152 if active_idx is not None:
153 i = active_idx
154 if active_mode == "teleport":
155 # Kinematic: snap position, zero both velocities
156 # on this instance so it doesn't coast after
157 # release from teleport mode.
158 if spring_target is not None and teleport_offset is not None:
159 position_arr[i] = spring_target + teleport_offset
160 linear_v[i] = 0.0
161 angular_v[i] = 0.0
162 elif active_mode == "translate":
163 # Spring acts on the *grab point* (body-frame
164 # ``grab_body`` mapped to world via the body's
165 # current pose). Off-center grabs naturally
166 # produce a torque (lever × force) on top of
167 # the linear pull, so dragging an edge yanks
168 # AND spins the body. Force is zero at drag
169 # start (grab_world == spring_target == click)
170 # so a static cmd-click doesn't perturb the body.
171 assert grab_body is not None and spring_target is not None
172 R_i = tf.SO3(wxyz=wxyz_arr[i])
173 R_mat = R_i.as_matrix()
174 grab_world = position_arr[i] + R_mat @ grab_body
175 force = SPRING_K_TRANSLATE * (spring_target - grab_world)
176 linear_v[i] += force / MASS * dt
177 lever = grab_world - position_arr[i]
178 angular_v[i] += np.cross(lever, force) / INERTIA * dt
179 elif active_mode == "rotate":
180 # Spring pins the grabbed body point to its
181 # drag-start location; torque along drag_vec
182 # spins the body around that pin.
183 assert grab_body is not None and spring_target is not None
184 R_i = tf.SO3(wxyz=wxyz_arr[i])
185 R_mat = R_i.as_matrix()
186 grab_world = position_arr[i] + R_mat @ grab_body
187 force = SPRING_K_ROTATE * (spring_target - grab_world)
188 linear_v[i] += force / MASS * dt
189 lever = grab_world - position_arr[i]
190 angular_v[i] += np.cross(lever, force) / INERTIA * dt
191 angular_v[i] += ext_torque / INERTIA * dt
192
193 # --- Damping + integration, vectorized over all instances ---
194 linear_v *= np.exp(-LINEAR_DAMPING * dt)
195 angular_v *= np.exp(-ANGULAR_DAMPING * dt)
196
197 position_arr = position_arr + linear_v * dt
198 R_old = tf.SO3(wxyz=wxyz_arr)
199 R_delta = tf.SO3.exp(angular_v * dt)
200 wxyz_arr = np.asarray((R_delta @ R_old).wxyz)
201
202 pos_snapshot = position_arr.astype(np.float32)
203 wxyz_snapshot = wxyz_arr.astype(np.float32)
204
205 handle.batched_positions = pos_snapshot
206 handle.batched_wxyzs = wxyz_snapshot
207 time.sleep(DT)
208
209 threading.Thread(target=physics_loop, daemon=True).start()
210
211 def set_instance_color(idx: int, color: tuple[int, int, int]) -> None:
212 new_colors = np.array(handle.batched_colors)
213 new_colors[idx] = color
214 handle.batched_colors = new_colors
215
216 def compute_grab_body(idx: int, grab_world: np.ndarray) -> np.ndarray:
217 R_mat = tf.SO3(wxyz=wxyz_arr[idx]).as_matrix()
218 return R_mat.T @ (grab_world - position_arr[idx])
219
220 # ==========================================================================
221 # Drag (no modifier): teleport.
222 # ==========================================================================
223
224 @handle.on_drag_start("left", modifier="")
225 async def _(event: viser.SceneNodeDragEvent[viser.BatchedMeshHandle]) -> None:
226 nonlocal active_idx, active_mode, spring_target, teleport_offset
227 i = event.instance_index
228 if i is None:
229 return
230 set_instance_color(i, TELEPORT_COLOR)
231 active_idx_gui.value = str(i)
232 active_mode_gui.value = "teleport"
233 with lock:
234 active_idx = i
235 active_mode = "teleport"
236 cursor = np.array(event.start_position)
237 teleport_offset = position_arr[i] - cursor
238 spring_target = cursor
239
240 @handle.on_drag_update("left", modifier="")
241 async def _(event: viser.SceneNodeDragEvent[viser.BatchedMeshHandle]) -> None:
242 nonlocal spring_target
243 with lock:
244 spring_target = np.array(event.end_position)
245
246 @handle.on_drag_end("left", modifier="")
247 async def _(event: viser.SceneNodeDragEvent[viser.BatchedMeshHandle]) -> None:
248 nonlocal active_idx, active_mode, spring_target, teleport_offset
249 i = event.instance_index
250 if i is not None:
251 set_instance_color(i, IDLE_COLOR)
252 active_idx_gui.value = "-"
253 active_mode_gui.value = "idle"
254 with lock:
255 active_idx = None
256 active_mode = None
257 spring_target = None
258 teleport_offset = None
259
260 # ==========================================================================
261 # Cmd/Ctrl + drag: spring-pull that instance (linear velocity).
262 # ==========================================================================
263
264 @handle.on_drag_start("left", modifier="cmd/ctrl")
265 async def _(event: viser.SceneNodeDragEvent[viser.BatchedMeshHandle]) -> None:
266 nonlocal active_idx, active_mode, grab_body, spring_target
267 i = event.instance_index
268 if i is None:
269 return
270 set_instance_color(i, TRANSLATE_COLOR)
271 active_idx_gui.value = str(i)
272 active_mode_gui.value = "translate"
273 with lock:
274 active_idx = i
275 active_mode = "translate"
276 grab_world = np.array(event.start_position)
277 grab_body = compute_grab_body(i, grab_world)
278 spring_target = grab_world
279
280 @handle.on_drag_update("left", modifier="cmd/ctrl")
281 async def _(event: viser.SceneNodeDragEvent[viser.BatchedMeshHandle]) -> None:
282 nonlocal spring_target
283 with lock:
284 spring_target = np.array(event.end_position)
285
286 @handle.on_drag_end("left", modifier="cmd/ctrl")
287 async def _(event: viser.SceneNodeDragEvent[viser.BatchedMeshHandle]) -> None:
288 nonlocal active_idx, active_mode, grab_body, spring_target
289 i = event.instance_index
290 if i is not None:
291 set_instance_color(i, IDLE_COLOR)
292 active_idx_gui.value = "-"
293 active_mode_gui.value = "idle"
294 with lock:
295 active_idx = None
296 active_mode = None
297 grab_body = None
298 spring_target = None
299
300 # ==========================================================================
301 # Cmd/Ctrl + Shift + drag: rotate that instance around the drag arrow
302 # (angular velocity).
303 # ==========================================================================
304
305 @handle.on_drag_start("left", modifier="cmd/ctrl+shift")
306 async def _(event: viser.SceneNodeDragEvent[viser.BatchedMeshHandle]) -> None:
307 nonlocal active_idx, active_mode, grab_body, spring_target, ext_torque
308 i = event.instance_index
309 if i is None:
310 return
311 set_instance_color(i, ROTATE_COLOR)
312 active_idx_gui.value = str(i)
313 active_mode_gui.value = "rotate"
314 with lock:
315 active_idx = i
316 active_mode = "rotate"
317 grab_world = np.array(event.start_position)
318 grab_body = compute_grab_body(i, grab_world)
319 # Pin the grab point to where it was clicked — the instance
320 # rotates around this point.
321 spring_target = grab_world
322 ext_torque = np.zeros(3)
323
324 @handle.on_drag_update("left", modifier="cmd/ctrl+shift")
325 async def _(event: viser.SceneNodeDragEvent[viser.BatchedMeshHandle]) -> None:
326 nonlocal ext_torque
327 # Drag vector = rotation axis (direction) × spin magnitude (length).
328 # Use the *frozen* spring_target (click point at drag-start) rather
329 # than ``event.start_position``, which is live and tracks the
330 # instance's current pose — keeps the gesture independent of
331 # spring stiffness.
332 with lock:
333 assert spring_target is not None
334 drag_vec = np.array(event.end_position) - spring_target
335 ext_torque = TORQUE_K * drag_vec
336
337 @handle.on_drag_end("left", modifier="cmd/ctrl+shift")
338 async def _(event: viser.SceneNodeDragEvent[viser.BatchedMeshHandle]) -> None:
339 nonlocal active_idx, active_mode, grab_body, spring_target, ext_torque
340 i = event.instance_index
341 if i is not None:
342 set_instance_color(i, IDLE_COLOR)
343 active_idx_gui.value = "-"
344 active_mode_gui.value = "idle"
345 with lock:
346 active_idx = None
347 active_mode = None
348 grab_body = None
349 spring_target = None
350 ext_torque = np.zeros(3)
351
352 try:
353 while True:
354 time.sleep(10.0)
355 finally:
356 shutdown.set()
357
358
359if __name__ == "__main__":
360 main()