Opening a Drawer with Contact-Rich Motion#
This tutorial combines a driven Franka Panda with a passive drawer. The robot
approaches a handle, pulls the drawer open through gripper contact, and then
pushes it back to half of the measured opening. Arm motion is generated with
MotionGenerator; the drawer joint is never commanded directly.
Before starting, it helps to be familiar with Simulating a Robot, Loading an Articulation, and Motion Generator.
Learning objectives#
After completing this tutorial, you should understand how to:
derive robot targets from a moving articulation link rather than fixed world coordinates;
convert Cartesian task poses into joint waypoints and time-parameterized trajectories;
separate planning, robot control, and physics interaction;
use measured articulation state to plan the next contact phase;
verify task success from object state instead of commanded robot motion.
The complete example is scripts/tutorials/sim/open_drawer.py.
Complete open_drawer.py
1# ----------------------------------------------------------------------------
2# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
3#
4# Licensed under the Apache License, Version 2.0 (the "License");
5# you may not use this file except in compliance with the License.
6# You may obtain a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15# ----------------------------------------------------------------------------
16
17"""Use a Franka Panda and MotionGenerator to open a passive drawer."""
18
19from __future__ import annotations
20
21import argparse
22from collections.abc import Sequence
23
24import torch
25
26from embodichain.data import get_data_path
27from embodichain.lab.gym.utils.gym_utils import add_env_launcher_args_to_parser
28from embodichain.lab.sim import SimulationManager, SimulationManagerCfg
29from embodichain.lab.sim.cfg import (
30 ArticulationCfg,
31 JointDrivePropertiesCfg,
32 RenderCfg,
33 RigidBodyAttributesCfg,
34)
35from embodichain.lab.sim.objects import Articulation, Robot
36from embodichain.lab.sim.motion.motion_generator import (
37 MotionGenCfg,
38 MotionGenerator,
39 MotionGenOptions,
40)
41from embodichain.lab.sim.motion.planners import (
42 PlanState,
43 ToppraPlannerCfg,
44 ToppraPlanOptions,
45 TrajectorySampleMethod,
46)
47from embodichain.lab.sim.robots import FrankaPandaCfg
48from embodichain.lab.visualization import visualization_cfg_from_args
49
50__all__ = [
51 "create_scene",
52 "generate_arm_trajectory",
53 "get_handle_grasp_pose",
54 "main",
55 "move_gripper",
56 "open_drawer",
57 "play_arm_trajectory",
58 "solve_ik_waypoints",
59]
60
61ARM_NAME = "arm"
62HAND_NAME = "hand"
63HANDLE_LINK_NAME = "handle_xpos"
64DRAWER_ASSET = "SlidingBoxDrawer/SlidingBoxDrawer.urdf"
65
66APPROACH_DISTANCE = 0.10
67PULL_DISTANCE = 0.16
68DRAWER_SUCCESS_THRESHOLD = 0.10
69HALF_OPEN_FRACTION = 0.5
70HALF_OPEN_TOLERANCE = 0.02
71RECORD_WIDTH = 1280
72RECORD_HEIGHT = 720
73RECORD_LOOK_AT = (
74 (-0.72, -1.05, 1.0),
75 (0.45, 0.0, 0.52),
76 (0.0, 0.0, 1.0),
77)
78
79
80def create_scene(sim: SimulationManager) -> tuple[Robot, Articulation]:
81 """Add a Franka Panda and a passive sliding drawer to the scene.
82
83 Args:
84 sim: Simulation manager that owns the scene.
85
86 Returns:
87 The Franka robot and drawer articulation.
88
89 Raises:
90 RuntimeError: If the robot could not be added.
91 """
92 # Add the existing Franka configuration. Higher contact friction helps the
93 # fingertips retain the narrow drawer handle during the pull phase.
94 robot_cfg = FrankaPandaCfg.from_dict(
95 {
96 "uid": "tutorial_franka",
97 "robot_type": "panda",
98 "attrs": {
99 "static_friction": 1.0,
100 "dynamic_friction": 1.0,
101 },
102 }
103 )
104 robot = sim.add_robot(cfg=robot_cfg)
105 if robot is None:
106 raise RuntimeError("Failed to add the Franka Panda robot.")
107
108 # Keep the drawer base fixed while leaving its prismatic joint passive. The
109 # 180-degree yaw makes the drawer's opening direction point toward Franka.
110 drawer = sim.add_articulation(
111 cfg=ArticulationCfg(
112 uid="drawer",
113 fpath=get_data_path(DRAWER_ASSET),
114 init_pos=(0.72, 0.0, 0.42),
115 init_rot=(0.0, 0.0, 180.0),
116 fix_base=True,
117 drive_pros=JointDrivePropertiesCfg(drive_type="none"),
118 attrs=RigidBodyAttributesCfg(
119 static_friction=1.0,
120 dynamic_friction=1.0,
121 ),
122 )
123 )
124 return robot, drawer
125
126
127def solve_ik_waypoints(
128 robot: Robot,
129 target_poses: Sequence[torch.Tensor],
130 start_qpos: torch.Tensor,
131) -> list[torch.Tensor]:
132 """Solve sparse Cartesian waypoints with the previous solution as the seed.
133
134 Args:
135 robot: Robot whose arm solver is used.
136 target_poses: Batched target TCP poses, each shaped ``(B, 4, 4)``.
137 start_qpos: Batched initial arm positions shaped ``(B, arm_dof)``.
138
139 Returns:
140 Batched arm-joint waypoints in the same order as ``target_poses``.
141
142 Raises:
143 RuntimeError: If IK fails for any environment.
144 """
145 qpos_seed = start_qpos
146 qpos_waypoints: list[torch.Tensor] = []
147 for waypoint_index, target_pose in enumerate(target_poses):
148 success, qpos = robot.compute_ik(
149 pose=target_pose,
150 joint_seed=qpos_seed,
151 name=ARM_NAME,
152 )
153 failed_env_ids = (
154 torch.nonzero(~success.bool(), as_tuple=False).flatten().cpu().tolist()
155 )
156 if failed_env_ids:
157 raise RuntimeError(
158 f"IK failed at waypoint {waypoint_index} for environments "
159 f"{failed_env_ids}."
160 )
161 qpos_waypoints.append(qpos)
162 qpos_seed = qpos
163 return qpos_waypoints
164
165
166def generate_arm_trajectory(
167 motion_generator: MotionGenerator,
168 qpos_waypoints: Sequence[torch.Tensor],
169 start_qpos: torch.Tensor,
170 sample_count: int,
171) -> torch.Tensor:
172 """Time-parameterize arm waypoints with MotionGenerator and TOPPRA.
173
174 Args:
175 motion_generator: Motion generator bound to the Franka robot.
176 qpos_waypoints: Batched arm-joint targets.
177 start_qpos: Batched starting arm positions.
178 sample_count: Number of trajectory samples returned by TOPPRA.
179
180 Returns:
181 Joint positions shaped ``(B, sample_count, arm_dof)``.
182
183 Raises:
184 ValueError: If no target waypoint is supplied.
185 RuntimeError: If trajectory generation fails.
186 """
187 if not qpos_waypoints:
188 raise ValueError("qpos_waypoints must contain at least one target.")
189
190 result = motion_generator.generate(
191 target_states=[PlanState.from_qpos(qpos) for qpos in qpos_waypoints],
192 options=MotionGenOptions(
193 control_part=ARM_NAME,
194 start_qpos=start_qpos,
195 is_interpolate=True,
196 is_linear=False,
197 interpolate_nums=8,
198 plan_opts=ToppraPlanOptions(
199 constraints={
200 "velocity": 0.35,
201 "acceleration": 0.75,
202 },
203 sample_method=TrajectorySampleMethod.QUANTITY,
204 sample_interval=sample_count,
205 ),
206 ),
207 )
208 if result.positions is None or not result.is_all_success():
209 raise RuntimeError("MotionGenerator failed to produce an arm trajectory.")
210 return result.positions
211
212
213def play_arm_trajectory(
214 sim: SimulationManager,
215 robot: Robot,
216 trajectory: torch.Tensor,
217 *,
218 physics_steps_per_waypoint: int = 4,
219) -> None:
220 """Send a planned arm trajectory to the robot's position drives.
221
222 Args:
223 sim: Simulation manager to advance.
224 robot: Franka robot to control.
225 trajectory: Batched joint positions shaped ``(B, N, arm_dof)``.
226 physics_steps_per_waypoint: Physics updates between consecutive targets.
227 """
228 for qpos in trajectory.unbind(dim=1):
229 robot.set_qpos(qpos=qpos, name=ARM_NAME)
230 sim.update(step=physics_steps_per_waypoint)
231
232
233def move_gripper(
234 sim: SimulationManager,
235 robot: Robot,
236 target_qpos: torch.Tensor,
237 *,
238 num_steps: int = 40,
239) -> None:
240 """Interpolate the gripper from its current position to a target.
241
242 Args:
243 sim: Simulation manager to advance.
244 robot: Franka robot to control.
245 target_qpos: Batched gripper target shaped ``(B, hand_dof)``.
246 num_steps: Number of interpolation samples.
247 """
248 start_qpos = robot.get_qpos(name=HAND_NAME)
249 interpolation = torch.linspace(
250 0.0,
251 1.0,
252 steps=num_steps,
253 dtype=start_qpos.dtype,
254 device=start_qpos.device,
255 )
256 for alpha in interpolation:
257 robot.set_qpos(
258 qpos=torch.lerp(start_qpos, target_qpos, alpha),
259 name=HAND_NAME,
260 )
261 sim.update(step=4)
262
263
264def get_handle_grasp_pose(drawer: Articulation) -> torch.Tensor:
265 """Return the handle frame with the gripper rolled 90 degrees.
266
267 The rotation is applied around the TCP's local Z axis, preserving the
268 approach and pull direction while rotating the finger-closing direction.
269
270 Args:
271 drawer: Drawer articulation that owns the handle link.
272
273 Returns:
274 Batched grasp poses shaped ``(B, 4, 4)``.
275 """
276 grasp_pose = drawer.get_link_pose(HANDLE_LINK_NAME, to_matrix=True)
277 quarter_turn_about_tcp_z = grasp_pose.new_tensor(
278 [
279 [0.0, -1.0, 0.0],
280 [1.0, 0.0, 0.0],
281 [0.0, 0.0, 1.0],
282 ]
283 )
284 grasp_pose[:, :3, :3] = grasp_pose[:, :3, :3] @ quarter_turn_about_tcp_z
285 return grasp_pose
286
287
288def open_drawer(
289 sim: SimulationManager,
290 robot: Robot,
291 drawer: Articulation,
292 motion_generator: MotionGenerator,
293 *,
294 wait_for_input: bool = True,
295) -> torch.Tensor:
296 """Pull the drawer open, then push it halfway closed.
297
298 Args:
299 sim: Simulation manager to advance.
300 robot: Franka robot used for manipulation.
301 drawer: Passive drawer articulation.
302 motion_generator: Motion generator bound to ``robot``.
303 wait_for_input: Whether to wait for Enter before executing trajectories.
304
305 Returns:
306 Final drawer joint positions shaped ``(B, drawer_dof)``.
307
308 Raises:
309 RuntimeError: If the drawer does not open or return halfway as expected.
310 """
311 hand_limits = robot.get_qpos_limits(name=HAND_NAME)
312 hand_open_qpos = hand_limits[..., 1]
313 hand_closed_qpos = hand_limits[..., 0]
314 move_gripper(sim, robot, hand_open_qpos, num_steps=20)
315
316 # Finish tool initialization before establishing the task's initial state.
317 # This also clears any startup contact impulse from opening the fingers.
318 drawer.reset()
319 sim.update(step=5)
320
321 # Roll the asset's handle frame 90 degrees around TCP Z. Its approach axis
322 # stays unchanged while the fingers rotate to close vertically on the handle.
323 grasp_pose = get_handle_grasp_pose(drawer)
324 approach_pose = grasp_pose.clone()
325 approach_pose[:, :3, 3] -= grasp_pose[:, :3, 2] * APPROACH_DISTANCE
326
327 start_qpos = robot.get_qpos(name=ARM_NAME)
328 approach_waypoints = solve_ik_waypoints(
329 robot,
330 target_poses=[approach_pose, grasp_pose],
331 start_qpos=start_qpos,
332 )
333 approach_trajectory = generate_arm_trajectory(
334 motion_generator,
335 qpos_waypoints=approach_waypoints,
336 start_qpos=start_qpos,
337 sample_count=60,
338 )
339 if wait_for_input:
340 input("[READY]: Trajectory planned. Press Enter to start execution...")
341 play_arm_trajectory(sim, robot, approach_trajectory)
342
343 # Close around the handle, then allow contacts to settle before pulling.
344 move_gripper(sim, robot, hand_closed_qpos)
345 sim.update(step=10)
346
347 # Re-read the live handle frame after grasping. Pulling along its -Z axis
348 # follows the drawer's prismatic joint toward Franka.
349 grasped_handle_pose = get_handle_grasp_pose(drawer)
350 pull_pose = grasped_handle_pose.clone()
351 pull_pose[:, :3, 3] -= grasped_handle_pose[:, :3, 2] * PULL_DISTANCE
352
353 pull_start_qpos = robot.get_qpos(name=ARM_NAME)
354 pull_waypoints = solve_ik_waypoints(
355 robot,
356 target_poses=[pull_pose],
357 start_qpos=pull_start_qpos,
358 )
359 pull_trajectory = generate_arm_trajectory(
360 motion_generator,
361 qpos_waypoints=pull_waypoints,
362 start_qpos=pull_start_qpos,
363 sample_count=80,
364 )
365 play_arm_trajectory(
366 sim,
367 robot,
368 pull_trajectory,
369 physics_steps_per_waypoint=5,
370 )
371 sim.update(step=50)
372
373 pulled_opening = drawer.get_qpos()[:, 0].clone()
374 print(
375 "[INFO]: Drawer opening after pull (m): "
376 f"{pulled_opening.detach().cpu().tolist()}",
377 flush=True,
378 )
379 if not torch.all(pulled_opening >= DRAWER_SUCCESS_THRESHOLD).item():
380 raise RuntimeError(
381 "The drawer did not open far enough through gripper contact. "
382 f"Expected at least {DRAWER_SUCCESS_THRESHOLD:.2f} m."
383 )
384
385 # Push the drawer back by half of its measured opening. Moving along the
386 # handle frame's +Z axis reverses the pull while the gripper stays closed.
387 half_open_target = pulled_opening * HALF_OPEN_FRACTION
388 push_distance = pulled_opening - half_open_target
389 pushed_handle_pose = get_handle_grasp_pose(drawer)
390 push_pose = pushed_handle_pose.clone()
391 push_pose[:, :3, 3] += pushed_handle_pose[:, :3, 2] * push_distance.unsqueeze(-1)
392
393 push_start_qpos = robot.get_qpos(name=ARM_NAME)
394 push_waypoints = solve_ik_waypoints(
395 robot,
396 target_poses=[push_pose],
397 start_qpos=push_start_qpos,
398 )
399 push_trajectory = generate_arm_trajectory(
400 motion_generator,
401 qpos_waypoints=push_waypoints,
402 start_qpos=push_start_qpos,
403 sample_count=50,
404 )
405 play_arm_trajectory(
406 sim,
407 robot,
408 push_trajectory,
409 physics_steps_per_waypoint=5,
410 )
411 sim.update(step=50)
412
413 drawer_qpos = drawer.get_qpos()
414 final_opening = drawer_qpos[:, 0]
415 print(
416 "[INFO]: Drawer opening after half push (m): "
417 f"{final_opening.detach().cpu().tolist()}",
418 flush=True,
419 )
420 if not torch.all(
421 torch.abs(final_opening - half_open_target) <= HALF_OPEN_TOLERANCE
422 ).item():
423 raise RuntimeError(
424 "The drawer did not return to half of its pulled opening. "
425 f"Expected an error no greater than {HALF_OPEN_TOLERANCE:.2f} m."
426 )
427 return drawer_qpos
428
429
430def main() -> None:
431 """Run the Franka drawer-manipulation tutorial."""
432 parser = argparse.ArgumentParser(
433 description="Use a Franka Panda and MotionGenerator to open a drawer."
434 )
435 add_env_launcher_args_to_parser(parser)
436 parser.add_argument(
437 "--hold-steps",
438 type=int,
439 default=100,
440 help="Physics steps to hold the final open-drawer pose before exiting.",
441 )
442 parser.add_argument(
443 "--auto-start",
444 action="store_true",
445 help="Execute trajectories without waiting for Enter.",
446 )
447 parser.add_argument(
448 "--record-save-path",
449 type=str,
450 default=None,
451 help="Optional MP4 path for recording from a fixed headless camera.",
452 )
453 parser.add_argument(
454 "--record-fps",
455 type=int,
456 default=30,
457 help="Frames per second for headless recording.",
458 )
459 args = parser.parse_args()
460 if args.num_envs < 1:
461 parser.error("--num_envs must be at least 1")
462 if args.hold_steps < 0:
463 parser.error("--hold-steps must be non-negative")
464 if args.record_fps < 1:
465 parser.error("--record-fps must be at least 1")
466 if args.record_save_path is not None and not args.headless:
467 parser.error("--record-save-path requires --headless")
468
469 sim = SimulationManager(
470 SimulationManagerCfg(
471 width=RECORD_WIDTH,
472 height=RECORD_HEIGHT,
473 headless=args.headless,
474 sim_device=args.device,
475 num_envs=args.num_envs,
476 arena_space=args.arena_space,
477 physics_dt=1.0 / 100.0,
478 render_cfg=RenderCfg(renderer=args.renderer),
479 visualization=visualization_cfg_from_args(args),
480 )
481 )
482
483 try:
484 robot, drawer = create_scene(sim)
485
486 if sim.is_use_gpu_physics:
487 sim.init_gpu_physics()
488 if not args.headless and not args.viser:
489 sim.open_window()
490
491 sim.update(step=5)
492 motion_generator = MotionGenerator(
493 cfg=MotionGenCfg(
494 planner_cfg=ToppraPlannerCfg(
495 robot_uid=robot.uid,
496 # Keep this small tutorial deterministic across platforms.
497 max_workers=1,
498 ),
499 )
500 )
501
502 if args.record_save_path is not None:
503 if not sim.start_window_record(
504 save_path=args.record_save_path,
505 fps=args.record_fps,
506 max_memory=2048,
507 video_prefix="open_drawer_headless",
508 look_at=RECORD_LOOK_AT,
509 use_sim_time=True,
510 ):
511 raise RuntimeError("Failed to start headless recording.")
512
513 print(
514 f"[INFO]: Opening drawers in {sim.num_envs} environment(s).",
515 flush=True,
516 )
517 open_drawer(
518 sim,
519 robot,
520 drawer,
521 motion_generator,
522 wait_for_input=not args.auto_start,
523 )
524 if args.hold_steps:
525 sim.update(step=args.hold_steps)
526 finally:
527 if sim.is_window_recording():
528 sim.stop_window_record()
529 sim.wait_window_record_saves()
530 sim.destroy()
531
532
533if __name__ == "__main__":
534 main()
Understand what is controlled#
The robot and drawer are both articulations, but they play different roles:
Entity |
Command |
Effect |
|---|---|---|
Franka arm |
Arm joint-position targets |
Tracks the generated trajectory through its joint drives. |
Franka gripper |
Finger joint-position targets |
Creates and maintains contact with the handle. |
Drawer |
No joint target |
Its passive prismatic joint moves only in response to contact forces. |
The drawer uses drive_type="none" and a fixed base. Fixing the base does
not lock the slide joint; it only prevents the cabinet body from moving. Contact
friction is increased so the fingertips can retain the narrow handle during
the pull.
The full data flow is:
handle link pose
↓
Cartesian TCP waypoints
↓ inverse kinematics
arm joint waypoints
↓ MotionGenerator + TOPPRA
time-sampled joint trajectory
↓ position drives + physics updates
gripper contact moves the passive drawer
This distinction is important: MotionGenerator generates robot motion. It
does not generate a drawer trajectory or directly change drawer state.
Build targets in the handle frame#
The drawer URDF provides a link named handle_xpos. Treating this link as the
task frame keeps all targets attached to the drawer when it moves.
The asset’s handle frame already points TCP +Z along the approach axis. The example post-multiplies its orientation by a 90-degree local-Z rotation:
Post-multiplication matters here: it rolls the gripper in the handle frame. The TCP Z axis and approach direction remain unchanged, while the finger-closing direction rotates to grip the handle vertically. A world-frame rotation would also change the approach direction.
def get_handle_grasp_pose(drawer: Articulation) -> torch.Tensor:
"""Return the handle frame with the gripper rolled 90 degrees.
The rotation is applied around the TCP's local Z axis, preserving the
approach and pull direction while rotating the finger-closing direction.
Args:
drawer: Drawer articulation that owns the handle link.
Returns:
Batched grasp poses shaped ``(B, 4, 4)``.
"""
grasp_pose = drawer.get_link_pose(HANDLE_LINK_NAME, to_matrix=True)
quarter_turn_about_tcp_z = grasp_pose.new_tensor(
[
[0.0, -1.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 0.0, 1.0],
]
)
grasp_pose[:, :3, :3] = grasp_pose[:, :3, :3] @ quarter_turn_about_tcp_z
return grasp_pose
For this asset, the three translations are:
pre-grasp = handle position - TCP_Z × 0.10 m
pull = live handle position - TCP_Z × 0.16 m
push = live handle position + TCP_Z × half the measured opening
The signs come from the bundled handle frame convention. For another asset, inspect its task-frame axes before reusing them. The handle pose is read again before the pull and push because previous contact phases may have moved the drawer.
Turn task poses into trajectories#
Trajectory generation is a three-stage process:
robot.compute_ikconverts each sparse TCP pose into an arm joint waypoint. The previous IK result seeds the next solve, which encourages a continuous solution instead of switching kinematic branches.MotionGeneratorpasses the joint waypoints to TOPPRA, applying velocity and acceleration constraints and sampling a smooth trajectory.The script sends each sample to the arm position drives and advances physics. Generating a trajectory alone does not move the robot.
The tensors stay batched throughout this process. For B environments, the
generated arm positions have shape (B, samples, arm_dof). Consequently,
each environment can use a different measured drawer opening while sharing the
same planning code.
Attention
TOPPRA time-parameterizes the supplied path but does not collision-check it. Keep the pre-grasp outside the object, inspect the motion in the viewer, and add collision-aware planning when obstacles make a straight approach unsafe.
Plan contact phases separately#
The example uses three arm trajectories instead of planning the entire task at once:
Approach: move through the pre-grasp and handle poses with the gripper open.
Pull: close the fingers, let contact settle, then move 16 cm along TCP -Z.
Push: measure the achieved drawer opening and move half that distance back along TCP +Z while keeping the gripper closed.
Splitting the task is necessary because closing the gripper changes the contact state, and the push target is not known until the pull has physically executed. This is phase-level feedback: each trajectory is played open-loop, but the next phase is planned from newly measured simulator state.
The push target uses the achieved opening rather than the requested 16 cm:
# Push the drawer back by half of its measured opening. Moving along the
# handle frame's +Z axis reverses the pull while the gripper stays closed.
half_open_target = pulled_opening * HALF_OPEN_FRACTION
push_distance = pulled_opening - half_open_target
pushed_handle_pose = get_handle_grasp_pose(drawer)
push_pose = pushed_handle_pose.clone()
push_pose[:, :3, 3] += pushed_handle_pose[:, :3, 2] * push_distance.unsqueeze(-1)
pulled_opening is cloned before further simulation updates so the 50%
target remains fixed while the push executes.
Synchronize execution and verify the object#
The approach trajectory is generated before execution starts. By default the script then pauses at the terminal:
[READY]: Trajectory planned. Press Enter to start execution...
Pressing Enter starts the complete approach, grasp, pull, and push sequence.
This breakpoint is useful for checking the initial scene and robot state before
any arm target is applied. Use --auto-start only for unattended runs.
Success is evaluated from drawer.get_qpos() rather than the final TCP pose.
The script checks two facts:
the pull opened every drawer by at least 10 cm;
the push finished within 2 cm of half the opening actually achieved by that environment.
This catches contact failures that a robot-only trajectory check would miss.
Run the tutorial#
From the repository root, run with the native viewer:
python scripts/tutorials/sim/open_drawer.py
For a non-interactive CPU run:
python scripts/tutorials/sim/open_drawer.py \
--headless \
--device cpu \
--hold-steps 0 \
--auto-start
CUDA physics and multiple environments use the common launcher arguments:
python scripts/tutorials/sim/open_drawer.py \
--headless \
--device cuda \
--num_envs 4 \
--auto-start
The embedded video was recorded directly from a fixed camera in headless mode. You can reproduce it with:
python scripts/tutorials/sim/open_drawer.py \
--headless \
--device cuda \
--hold-steps 100 \
--auto-start \
--record-fps 30 \
--record-save-path outputs/videos/open_drawer.mp4
Typical output is similar to:
[INFO]: Drawer opening after pull (m): [0.1606]
[INFO]: Drawer opening after half push (m): [0.0811]
Exact values vary slightly because the drawer is moved through simulated
contact. --hold-steps controls how long the final pose remains visible.
Diagnose common failures#
Symptom |
What to inspect |
|---|---|
IK fails before execution |
Check handle-frame orientation, reachability, and the seeded arm configuration. Shorten the approach distance when necessary. |
The arm moves but the drawer does not |
Confirm the drawer drive is passive, the fingertips close around the handle, and the contact materials provide enough friction. |
Pull succeeds but the push misses halfway |
Re-read the handle pose after pulling and calculate the push from measured drawer position, not the requested pull distance. |
The arm intersects the cabinet |
Add safe Cartesian waypoints or use a collision-aware planner; TOPPRA alone does not change the geometric path. |
Adapt the pattern#
For another prismatic mechanism, provide a task frame whose approach axis and joint axis have known directions, then adjust the signed translation distances.
A revolute door needs a different geometric path: sample handle poses along an
arc around the hinge, keep the gripper orientation consistent with the door,
solve the poses sequentially with seeded IK, and pass those joint waypoints to
MotionGenerator. The remaining structure—contact transition, state
measurement, replanning, and object-state validation—stays the same.