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^2 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("left")
167    async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None:
168        nonlocal teleport_cursor, teleport_drag_offset
169        if event.phase == "start":
170            handle.color = TELEPORT_COLOR
171            with lock:
172                cursor = np.array(event.start_position)
173                teleport_cursor = cursor
174                # Fixed offset from cursor to box center. Re-adding
175                # each tick keeps the grab point under the cursor as
176                # it moves.
177                teleport_drag_offset = position - cursor
178        elif event.phase == "update":
179            with lock:
180                teleport_cursor = np.array(event.end_position)
181        else:  # "end"
182            handle.color = IDLE_COLOR
183            with lock:
184                teleport_cursor = None
185                teleport_drag_offset = None
186
187    # ==========================================================================
188    # Cmd/Ctrl + drag: spring pull on the grab point toward the cursor.
189    # ==========================================================================
190
191    @handle.on_drag("left", modifier="cmd/ctrl")
192    async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None:
193        nonlocal grab_body, spring_target
194        if event.phase == "start":
195            handle.color = TRANSLATE_COLOR
196            with lock:
197                grab_world = np.array(event.start_position)
198                grab_body = compute_grab_body(grab_world)
199                spring_target = grab_world
200        elif event.phase == "update":
201            with lock:
202                spring_target = np.array(event.end_position)
203        else:  # "end"
204            handle.color = IDLE_COLOR
205            with lock:
206                grab_body = None
207                spring_target = None
208
209    # ==========================================================================
210    # Cmd/Ctrl + Shift + drag: rotate *around* the drag arrow.
211    #
212    # The grab point is pinned in world space (spring_target locked
213    # to start.position), and an external torque along the drag vector
214    # spins the body around that pin. Geometrically, the rotation axis
215    # is the line through ``start.position`` parallel to the drag
216    # arrow. Drag length scales spin speed.
217    # ==========================================================================
218
219    @handle.on_drag("left", modifier="cmd/ctrl+shift")
220    async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None:
221        nonlocal grab_body, spring_target, ext_torque
222        if event.phase == "start":
223            handle.color = ROTATE_COLOR
224            with lock:
225                grab_world = np.array(event.start_position)
226                grab_body = compute_grab_body(grab_world)
227                # Pin the grab point to where it was clicked. This
228                # stays put for the duration of the drag -- the body
229                # rotates around it.
230                spring_target = grab_world
231                ext_torque = np.zeros(3)
232        elif event.phase == "update":
233            # Drag vector (world space) used directly as torque: its
234            # direction is the rotation axis, its length the
235            # magnitude. The visible drag arrow coincides with the
236            # instantaneous axis. Use the frozen ``spring_target``
237            # (click point at drag-start) rather than
238            # ``event.start_position``, which is live and tracks the
239            # body's current pose -- making the gesture independent
240            # of spring stiffness.
241            with lock:
242                assert spring_target is not None
243                drag_vec = np.array(event.end_position) - spring_target
244                ext_torque = TORQUE_K * drag_vec
245        else:  # "end"
246            handle.color = IDLE_COLOR
247            with lock:
248                grab_body = None
249                spring_target = None
250                ext_torque = np.zeros(3)
251
252    try:
253        while True:
254            time.sleep(10.0)
255    finally:
256        shutdown.set()
257
258
259if __name__ == "__main__":
260    main()