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