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