Scene-node drag events with rigid-body physics

The box carries linear and angular velocity (both R^3), both damped. Drag callbacks don’t set pose directly — they apply forces and torques that the physics loop integrates.

Three gestures:

  • Drag (no modifier) — teleport. Bypasses the physics and rigidly snaps the box so the grab point tracks the cursor exactly. Linear and angular velocity are zeroed each tick, so the box doesn’t keep moving after release.

  • Cmd/Ctrl + drag — spring pulls the grabbed body-point toward the cursor’s moving world-space target. Off-center grabs also produce torque (tau = lever x force), so yanking a corner makes the box tumble as it translates.

  • Cmd/Ctrl + Shift + drag — spring pulls the grabbed body-point toward the start point and keeps it there (a stiff pin), while an external torque along the drag vector spins the box around the arrow. The net effect is rotation around the arrow axis: the click point stays fixed in world space, the arrow defines the rotation axis, and the drag length controls the spin rate.

When you release, the forces stop and the box coasts — damped linear and angular velocity bleed off over ~1s.

SE(3) integration uses viser.transforms: viser.transforms.SO3.exp() takes a rotation vector (angular_velocity * dt) and returns the incremental rotation, which is left-multiplied onto the current orientation each tick.

Source: examples/03_interaction/06_scene_node_drag.py

Scene-node drag events with rigid-body physics

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# ----- Visual state ----------------------------------------------------------
 12IDLE_COLOR = (180, 180, 255)
 13TELEPORT_COLOR = (220, 120, 220)
 14TRANSLATE_COLOR = (255, 120, 60)
 15ROTATE_COLOR = (120, 220, 120)
 16
 17# ----- Physics parameters ----------------------------------------------------
 18MASS = 1.0
 19# Moment of inertia for a uniform unit cube: m * a^2 / 6. Scalar because
 20# the box is cubic — symmetry gives equal principal moments.
 21INERTIA = 1.0 / 6.0
 22
 23# Velocity damping (applied multiplicatively each tick). Equivalent to a
 24# drag force F = -damping * m * v.
 25LINEAR_DAMPING = 4.0  # 1/s
 26ANGULAR_DAMPING = 4.0  # 1/s
 27
 28# Spring stiffness. The same spring is used for both modes — in translate
 29# it pulls the grab point toward the cursor; in rotate it pins the grab
 30# point to the click location so the body can orbit around it.
 31SPRING_K = 60.0
 32
 33# Torque scale for the rotate gesture (world drag vector → torque along
 34# the same vector). With a ~1 world-unit drag, this puts the angular
 35# acceleration around ~6 rad/s² given the unit-cube inertia, so a
 36# sustained drag can reach ~1 rev/s before damping cuts in.
 37TORQUE_K = 1.5
 38
 39# Physics step. Smaller is more stable; 60Hz is plenty for a demo.
 40DT = 1.0 / 60.0
 41
 42
 43def main() -> None:
 44    server = viser.ViserServer()
 45    server.scene.set_up_direction("+z")
 46    server.initial_camera.position = (0.0, -6.0, 3.5)
 47    server.initial_camera.look_at = (0.0, 0.0, 0.5)
 48
 49    with server.gui.add_folder("Instructions"):
 50        server.gui.add_markdown(
 51            "**Drag** → teleport (rigid follow, no physics)  \n"
 52            "**Cmd/Ctrl + drag** → spring pull (off-center grabs torque too)  \n"
 53            "**Cmd/Ctrl + Shift + drag** → rotate around the drag arrow  \n"
 54            "Release: physics modes coast and damp; teleport stays put."
 55        )
 56
 57    server.scene.add_grid("/grid", width=8.0, height=8.0, plane="xy")
 58
 59    handle = server.scene.add_box(
 60        "/box",
 61        dimensions=(1.0, 1.0, 1.0),
 62        color=IDLE_COLOR,
 63        position=(0.0, 0.0, 0.5),
 64    )
 65
 66    # ----- Physics state -----------------------------------------------------
 67    # Pose + velocities live here and are written to the handle each tick.
 68    # The lock protects against concurrent read/write between the physics
 69    # thread and the drag callbacks (which run in viser's thread pool).
 70    lock = threading.Lock()
 71    position = np.array([0.0, 0.0, 0.5], dtype=float)
 72    wxyz = np.array([1.0, 0.0, 0.0, 0.0], dtype=float)
 73    linear_v = np.zeros(3)
 74    angular_v = np.zeros(3)
 75
 76    # Active-drag parameters. The spring mechanism drives both
 77    # Cmd-modified gestures — the difference is where `spring_target`
 78    # lives (moving with the cursor vs. locked to the click point) and
 79    # whether `ext_torque` is nonzero. The teleport path bypasses the
 80    # spring entirely and directly pins the pose.
 81    grab_body: np.ndarray | None = None  # body-frame grab point
 82    spring_target: np.ndarray | None = None  # world-space target for grab
 83    ext_torque = np.zeros(3)  # external torque (world frame)
 84
 85    # Teleport mode: non-None means "skip physics, snap position by the
 86    # drag vector each tick." ``teleport_drag_offset`` is the vector from
 87    # the initial cursor to the initial box center, fixed at drag_start.
 88    teleport_cursor: np.ndarray | None = None
 89    teleport_drag_offset: np.ndarray | None = None
 90
 91    shutdown = threading.Event()
 92
 93    def physics_loop() -> None:
 94        nonlocal position, wxyz, linear_v, angular_v
 95        last_t = time.monotonic()
 96        while not shutdown.is_set():
 97            now = time.monotonic()
 98            dt = min(now - last_t, 0.05)  # cap to avoid blow-ups after pauses
 99            last_t = now
100
101            with lock:
102                if teleport_cursor is not None and teleport_drag_offset is not None:
103                    # Kinematic teleport: snap position, freeze all
104                    # velocities so the box doesn't drift after release.
105                    # Orientation stays constant (no angular impulse is
106                    # applied; angular velocity is zeroed).
107                    position = teleport_cursor + teleport_drag_offset
108                    linear_v = np.zeros(3)
109                    angular_v = np.zeros(3)
110                else:
111                    R = tf.SO3(wxyz=wxyz)
112                    R_mat = R.as_matrix()
113
114                    # Accumulate accelerations.
115                    linear_accel = np.zeros(3)
116                    angular_accel = np.zeros(3)
117
118                    # Spring force: pull the grabbed body point toward
119                    # the world-space target. Generates both a linear
120                    # force on the COM and a torque from the off-center
121                    # lever arm.
122                    if grab_body is not None and spring_target is not None:
123                        grab_world = position + R_mat @ grab_body
124                        force = SPRING_K * (spring_target - grab_world)
125                        linear_accel += force / MASS
126                        lever = grab_world - position
127                        angular_accel += np.cross(lever, force) / INERTIA
128
129                    # External torque (rotate gesture). Combined with the
130                    # stationary pin above, this drives rotation around
131                    # an axis through the pin point, along the torque
132                    # direction.
133                    angular_accel += ext_torque / INERTIA
134
135                    # Integrate velocity, then damp.
136                    linear_v = linear_v + linear_accel * dt
137                    angular_v = angular_v + angular_accel * dt
138                    linear_v *= np.exp(-LINEAR_DAMPING * dt)
139                    angular_v *= np.exp(-ANGULAR_DAMPING * dt)
140
141                    # Integrate pose. Angular velocity is in the world
142                    # frame (torques come from world-frame lever x
143                    # force), so the incremental rotation
144                    # left-multiplies the current one.
145                    position = position + linear_v * dt
146                    R_new = tf.SO3.exp(angular_v * dt) @ R
147                    wxyz = np.array(R_new.wxyz)
148
149                pose_snapshot = (tuple(position), tuple(wxyz))
150
151            handle.position = pose_snapshot[0]
152            handle.wxyz = pose_snapshot[1]
153            time.sleep(DT)
154
155    threading.Thread(target=physics_loop, daemon=True).start()
156
157    # Shared helper: compute body-frame offset of the click point.
158    def compute_grab_body(grab_world: np.ndarray) -> np.ndarray:
159        R_mat = tf.SO3(wxyz=wxyz).as_matrix()
160        return R_mat.T @ (grab_world - position)
161
162    # ==========================================================================
163    # Drag (no modifier): teleport — rigid follow, no physics.
164    # ==========================================================================
165
166    @handle.on_drag_start("left", modifier="")
167    async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None:
168        nonlocal teleport_cursor, teleport_drag_offset
169        handle.color = TELEPORT_COLOR
170        with lock:
171            cursor = np.array(event.start_position)
172            teleport_cursor = cursor
173            # Fixed offset from cursor to box center. Re-adding this each
174            # tick keeps the grab point under the cursor as it moves.
175            teleport_drag_offset = position - cursor
176
177    @handle.on_drag_update("left", modifier="")
178    async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None:
179        nonlocal teleport_cursor
180        with lock:
181            teleport_cursor = np.array(event.end_position)
182
183    @handle.on_drag_end("left", modifier="")
184    async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None:
185        nonlocal teleport_cursor, teleport_drag_offset
186        del event
187        handle.color = IDLE_COLOR
188        with lock:
189            teleport_cursor = None
190            teleport_drag_offset = None
191
192    # ==========================================================================
193    # Cmd/Ctrl + drag: spring pull on the grab point toward the cursor.
194    # ==========================================================================
195
196    @handle.on_drag_start("left", modifier="cmd/ctrl")
197    async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None:
198        nonlocal grab_body, spring_target
199        handle.color = TRANSLATE_COLOR
200        with lock:
201            grab_world = np.array(event.start_position)
202            grab_body = compute_grab_body(grab_world)
203            spring_target = grab_world
204
205    @handle.on_drag_update("left", modifier="cmd/ctrl")
206    async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None:
207        nonlocal spring_target
208        with lock:
209            spring_target = np.array(event.end_position)
210
211    @handle.on_drag_end("left", modifier="cmd/ctrl")
212    async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None:
213        nonlocal grab_body, spring_target
214        del event
215        handle.color = IDLE_COLOR
216        with lock:
217            grab_body = None
218            spring_target = None
219
220    # ==========================================================================
221    # Cmd/Ctrl + Shift + drag: rotate *around* the drag arrow.
222    #
223    # The grab point is pinned in world space (spring_target locked to
224    # start.position), and an external torque along the drag vector spins
225    # the body around that pin. Geometrically, the rotation axis is the
226    # line through ``start.position`` parallel to the drag arrow — i.e.
227    # the arrow itself. Drag length scales spin speed.
228    # ==========================================================================
229
230    @handle.on_drag_start("left", modifier="cmd/ctrl+shift")
231    async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None:
232        nonlocal grab_body, spring_target, ext_torque
233        handle.color = ROTATE_COLOR
234        with lock:
235            grab_world = np.array(event.start_position)
236            grab_body = compute_grab_body(grab_world)
237            # Pin the grab point to where it was clicked. This stays put
238            # for the duration of the drag — the body rotates around it.
239            spring_target = grab_world
240            ext_torque = np.zeros(3)
241
242    @handle.on_drag_update("left", modifier="cmd/ctrl+shift")
243    async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None:
244        nonlocal ext_torque
245        # Drag vector (world space) used directly as the torque: its
246        # direction is the rotation axis, its length the magnitude. The
247        # visible drag arrow coincides with the instantaneous axis.
248        # Use the *frozen* spring_target (the click point at drag-start)
249        # rather than ``event.start_position``, which is live and tracks
250        # the body's current pose — making the gesture independent of
251        # spring stiffness.
252        with lock:
253            assert spring_target is not None
254            drag_vec = np.array(event.end_position) - spring_target
255            ext_torque = TORQUE_K * drag_vec
256
257    @handle.on_drag_end("left", modifier="cmd/ctrl+shift")
258    async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None:
259        nonlocal grab_body, spring_target, ext_torque
260        del event
261        handle.color = IDLE_COLOR
262        with lock:
263            grab_body = None
264            spring_target = None
265            ext_torque = np.zeros(3)
266
267    try:
268        while True:
269            time.sleep(10.0)
270    finally:
271        shutdown.set()
272
273
274if __name__ == "__main__":
275    main()