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

Drag events on batched scene nodes

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()