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 embodichain.cli.sim import (
23 add_sim_args_to_parser,
24 add_seed_arg_to_parser,
25 resolve_seed,
26)
27
28
29def build_parser() -> argparse.ArgumentParser:
30 """Build CLI options without initializing simulation resources."""
31 parser = argparse.ArgumentParser(
32 description="Use a Franka Panda and MotionGenerator to open a drawer."
33 )
34 add_sim_args_to_parser(parser)
35 add_seed_arg_to_parser(parser, scope="PyTorch IK seed sampling")
36 parser.add_argument(
37 "--hold-steps",
38 type=int,
39 default=100,
40 help="Physics steps to hold the final open-drawer pose before exiting.",
41 )
42 parser.add_argument(
43 "--auto-start",
44 action="store_true",
45 help="Execute trajectories without waiting for Enter.",
46 )
47 parser.add_argument(
48 "--record-save-path",
49 type=str,
50 default=None,
51 help="Optional MP4 path for recording from a fixed headless camera.",
52 )
53 parser.add_argument(
54 "--record-fps",
55 type=int,
56 default=30,
57 help="Frames per second for headless recording.",
58 )
59 return parser
60
61
62if __name__ == "__main__":
63 # Parse before importing optional simulation/planning dependencies.
64 _cli_args = build_parser().parse_args()
65
66
67from collections.abc import Sequence
68from typing import Literal
69
70import torch
71
72from embodichain.data import get_data_path
73from embodichain.lab.sim import SimulationManager, SimulationManagerCfg
74from embodichain.lab.sim.cfg import (
75 ArticulationCfg,
76 ArticulationRootPropertiesCfg,
77 JointDrivePropertiesCfg,
78 LinkPhysicsOverrideCfg,
79 NewtonCollisionPropertiesCfg,
80 NewtonPhysicsCfg,
81 NewtonRigidBodyMaterialCfg,
82 PhysicsBackendCfg,
83 RenderCfg,
84 RigidBodyPhysicsCfg,
85 physics_cfg_for_backend,
86)
87from embodichain.lab.sim.objects import Articulation, Robot
88from embodichain.lab.sim.motion.motion_generator import (
89 MotionGenCfg,
90 MotionGenerator,
91 MotionGenOptions,
92)
93from embodichain.lab.sim.motion.planners import (
94 PlanState,
95 ToppraPlannerCfg,
96 ToppraPlanOptions,
97 TrajectorySampleMethod,
98)
99from embodichain_robots import FrankaPandaCfg
100from embodichain.lab.visualization import visualization_cfg_from_args
101
102__all__ = [
103 "create_scene",
104 "generate_arm_trajectory",
105 "get_handle_grasp_pose",
106 "main",
107 "move_gripper",
108 "open_drawer",
109 "play_arm_trajectory",
110 "solve_ik_waypoints",
111]
112
113ARM_NAME = "arm"
114HAND_NAME = "eef"
115HANDLE_LINK_NAME = "handle_xpos"
116DRAWER_CONTACT_LINK_NAME = "inner_box"
117LEFT_FINGER_LINK_NAME = "fr3_leftfinger"
118RIGHT_FINGER_LINK_NAME = "fr3_rightfinger"
119DRAWER_ASSET = "SlidingBoxDrawer/SlidingBoxDrawer.urdf"
120
121APPROACH_DISTANCE = 0.10
122PULL_DISTANCE = 0.16
123NEWTON_GRASP_CONTACT_STIFFNESS = 4.0e4
124NEWTON_GRASP_CONTACT_DAMPING = 4.0e2
125# Match the shared atomic-action grasp profile. ``condim=4`` activates the
126# torsional term in MuJoCo-Warp; the rolling coefficient is retained for
127# solvers/condim=6 that support it. Keep both coefficients on the fingers and
128# handle only; changing the world material would alter unrelated contacts.
129NEWTON_GRASP_TORSIONAL_FRICTION = 0.1
130NEWTON_GRASP_ROLLING_FRICTION = 0.01
131DRAWER_SUCCESS_THRESHOLD = 0.10
132HALF_OPEN_FRACTION = 0.5
133HALF_OPEN_TOLERANCE = 0.02
134RECORD_WIDTH = 1280
135RECORD_HEIGHT = 720
136RECORD_LOOK_AT = (
137 (-0.72, -1.05, 1.0),
138 (0.45, 0.0, 0.52),
139 (0.0, 0.0, 1.0),
140)
141
142
143def _newton_grasp_contact_override(
144 link_names_expr: str,
145) -> LinkPhysicsOverrideCfg:
146 """Build the Newton contact material used at the drawer grasp."""
147 return LinkPhysicsOverrideCfg(
148 link_names_expr=[link_names_expr],
149 attrs=RigidBodyPhysicsCfg(
150 collision_props=NewtonCollisionPropertiesCfg(condim=4),
151 material_props=NewtonRigidBodyMaterialCfg(
152 ke=NEWTON_GRASP_CONTACT_STIFFNESS,
153 kd=NEWTON_GRASP_CONTACT_DAMPING,
154 torsional_friction=NEWTON_GRASP_TORSIONAL_FRICTION,
155 rolling_friction=NEWTON_GRASP_ROLLING_FRICTION,
156 ),
157 ),
158 )
159
160
161def create_scene(sim: SimulationManager) -> tuple[Robot, Articulation]:
162 """Add a Franka Panda and a passive sliding drawer to the scene.
163
164 Args:
165 sim: Simulation manager that owns the scene.
166
167 Returns:
168 The Franka robot and drawer articulation.
169
170 Raises:
171 RuntimeError: If the robot could not be added.
172 """
173 # Add the existing Franka configuration. Higher contact friction helps the
174 # fingertips retain the narrow drawer handle during the pull phase.
175 robot_cfg = FrankaPandaCfg.from_dict(
176 {
177 "uid": "tutorial_franka",
178 "robot_type": "panda",
179 "attrs": {
180 "material_props": {
181 "static_friction": 1.0,
182 "dynamic_friction": 1.0,
183 },
184 },
185 }
186 )
187 if sim.is_newton_backend:
188 robot_cfg.link_attrs = {
189 **(robot_cfg.link_attrs or {}),
190 "newton_gripper_contacts": _newton_grasp_contact_override(
191 f"(?:{LEFT_FINGER_LINK_NAME}|{RIGHT_FINGER_LINK_NAME})"
192 ),
193 }
194 robot = sim.add_robot(cfg=robot_cfg)
195 if robot is None:
196 raise RuntimeError("Failed to add the Franka Panda robot.")
197
198 # Keep the drawer base fixed while leaving its prismatic joint passive. The
199 # 180-degree yaw makes the drawer's opening direction point toward Franka.
200 drawer_cfg = ArticulationCfg(
201 uid="drawer",
202 fpath=get_data_path(DRAWER_ASSET),
203 asset_physics_mode="overlay",
204 init_pos=(0.72, 0.0, 0.42),
205 init_rot=(0.0, 0.0, 180.0),
206 root_props=ArticulationRootPropertiesCfg(fixed_base=True),
207 joint_drive_props=JointDrivePropertiesCfg(drive_type="none"),
208 attrs=RigidBodyPhysicsCfg.from_dict(
209 {"material_props": {"static_friction": 1.0, "dynamic_friction": 1.0}}
210 ),
211 )
212 if sim.is_newton_backend:
213 # The handle marker has no geometry; its collision belongs to inner_box.
214 drawer_cfg.link_attrs = {
215 "newton_handle_contacts": _newton_grasp_contact_override(
216 DRAWER_CONTACT_LINK_NAME
217 )
218 }
219 drawer = sim.add_articulation(cfg=drawer_cfg)
220 return robot, drawer
221
222
223def solve_ik_waypoints(
224 robot: Robot,
225 target_poses: Sequence[torch.Tensor],
226 start_qpos: torch.Tensor,
227) -> list[torch.Tensor]:
228 """Solve sparse Cartesian waypoints with the previous solution as the seed.
229
230 Args:
231 robot: Robot whose arm solver is used.
232 target_poses: Batched target TCP poses, each shaped ``(B, 4, 4)``.
233 start_qpos: Batched initial arm positions shaped ``(B, arm_dof)``.
234
235 Returns:
236 Batched arm-joint waypoints in the same order as ``target_poses``.
237
238 Raises:
239 RuntimeError: If IK fails for any environment.
240 """
241 qpos_seed = start_qpos
242 qpos_waypoints: list[torch.Tensor] = []
243 for waypoint_index, target_pose in enumerate(target_poses):
244 success, qpos = robot.compute_ik(
245 pose=target_pose,
246 joint_seed=qpos_seed,
247 name=ARM_NAME,
248 )
249 failed_env_ids = (
250 torch.nonzero(~success.bool(), as_tuple=False).flatten().cpu().tolist()
251 )
252 if failed_env_ids:
253 raise RuntimeError(
254 f"IK failed at waypoint {waypoint_index} for environments "
255 f"{failed_env_ids}."
256 )
257 qpos_waypoints.append(qpos)
258 qpos_seed = qpos
259 return qpos_waypoints
260
261
262def generate_arm_trajectory(
263 motion_generator: MotionGenerator,
264 qpos_waypoints: Sequence[torch.Tensor],
265 start_qpos: torch.Tensor,
266 sample_count: int,
267) -> torch.Tensor:
268 """Time-parameterize arm waypoints with MotionGenerator and TOPPRA.
269
270 Args:
271 motion_generator: Motion generator bound to the Franka robot.
272 qpos_waypoints: Batched arm-joint targets.
273 start_qpos: Batched starting arm positions.
274 sample_count: Number of trajectory samples returned by TOPPRA.
275
276 Returns:
277 Joint positions shaped ``(B, sample_count, arm_dof)``.
278
279 Raises:
280 ValueError: If no target waypoint is supplied.
281 RuntimeError: If trajectory generation fails.
282 """
283 if not qpos_waypoints:
284 raise ValueError("qpos_waypoints must contain at least one target.")
285
286 result = motion_generator.generate(
287 target_states=[PlanState.from_qpos(qpos) for qpos in qpos_waypoints],
288 options=MotionGenOptions(
289 control_part=ARM_NAME,
290 start_qpos=start_qpos,
291 is_interpolate=True,
292 is_linear=False,
293 interpolate_nums=8,
294 plan_opts=ToppraPlanOptions(
295 constraints={
296 "velocity": 0.35,
297 "acceleration": 0.75,
298 },
299 sample_method=TrajectorySampleMethod.QUANTITY,
300 sample_interval=sample_count,
301 ),
302 ),
303 )
304 if result.positions is None or not result.is_all_success():
305 raise RuntimeError("MotionGenerator failed to produce an arm trajectory.")
306 return result.positions
307
308
309def play_arm_trajectory(
310 sim: SimulationManager,
311 robot: Robot,
312 trajectory: torch.Tensor,
313 *,
314 physics_steps_per_waypoint: int = 4,
315) -> None:
316 """Send a planned arm trajectory to the robot's position drives.
317
318 Args:
319 sim: Simulation manager to advance.
320 robot: Franka robot to control.
321 trajectory: Batched joint positions shaped ``(B, N, arm_dof)``.
322 physics_steps_per_waypoint: Physics updates between consecutive targets.
323 """
324 for qpos in trajectory.unbind(dim=1):
325 robot.set_qpos(qpos=qpos, name=ARM_NAME)
326 sim.update(step=physics_steps_per_waypoint)
327
328
329def _move_arm_to_poses(
330 sim: SimulationManager,
331 robot: Robot,
332 motion_generator: MotionGenerator,
333 target_poses: Sequence[torch.Tensor],
334 *,
335 sample_count: int,
336 physics_steps_per_waypoint: int = 4,
337 wait_for_input: bool = False,
338) -> None:
339 """Plan and execute arm motion through Cartesian target poses."""
340 start_qpos = robot.get_qpos(name=ARM_NAME)
341 trajectory = generate_arm_trajectory(
342 motion_generator,
343 qpos_waypoints=solve_ik_waypoints(robot, target_poses, start_qpos),
344 start_qpos=start_qpos,
345 sample_count=sample_count,
346 )
347 if wait_for_input:
348 input("[READY]: Trajectory planned. Press Enter to start execution...")
349 play_arm_trajectory(
350 sim,
351 robot,
352 trajectory,
353 physics_steps_per_waypoint=physics_steps_per_waypoint,
354 )
355
356
357def move_gripper(
358 sim: SimulationManager,
359 robot: Robot,
360 target_qpos: torch.Tensor,
361 *,
362 num_steps: int = 40,
363) -> None:
364 """Interpolate the gripper from its current position to a target.
365
366 Args:
367 sim: Simulation manager to advance.
368 robot: Franka robot to control.
369 target_qpos: Batched gripper target shaped ``(B, hand_dof)``.
370 num_steps: Number of interpolation samples.
371 """
372 start_qpos = robot.get_qpos(name=HAND_NAME)
373 interpolation = torch.linspace(
374 0.0,
375 1.0,
376 steps=num_steps,
377 dtype=start_qpos.dtype,
378 device=start_qpos.device,
379 )
380 for alpha in interpolation:
381 robot.set_qpos(
382 qpos=torch.lerp(start_qpos, target_qpos, alpha),
383 name=HAND_NAME,
384 )
385 sim.update(step=4)
386
387
388def get_handle_grasp_pose(drawer: Articulation) -> torch.Tensor:
389 """Return the handle frame with the gripper rolled 90 degrees.
390
391 The rotation is applied around the TCP's local Z axis, preserving the
392 approach and pull direction while rotating the finger-closing direction.
393
394 Args:
395 drawer: Drawer articulation that owns the handle link.
396
397 Returns:
398 Batched grasp poses shaped ``(B, 4, 4)``.
399 """
400 grasp_pose = drawer.get_link_pose(HANDLE_LINK_NAME, to_matrix=True)
401 quarter_turn_about_tcp_z = grasp_pose.new_tensor(
402 [
403 [0.0, -1.0, 0.0],
404 [1.0, 0.0, 0.0],
405 [0.0, 0.0, 1.0],
406 ]
407 )
408 grasp_pose[:, :3, :3] = grasp_pose[:, :3, :3] @ quarter_turn_about_tcp_z
409 return grasp_pose
410
411
412def open_drawer(
413 sim: SimulationManager,
414 robot: Robot,
415 drawer: Articulation,
416 motion_generator: MotionGenerator,
417 *,
418 wait_for_input: bool = True,
419) -> torch.Tensor:
420 """Pull the drawer open, then push it halfway closed.
421
422 Args:
423 sim: Simulation manager to advance.
424 robot: Franka robot used for manipulation.
425 drawer: Passive drawer articulation.
426 motion_generator: Motion generator bound to ``robot``.
427 wait_for_input: Whether to wait for Enter before executing trajectories.
428
429 Returns:
430 Final drawer joint positions shaped ``(B, drawer_dof)``.
431
432 Raises:
433 RuntimeError: If the drawer does not open or return halfway as expected.
434 """
435 hand_limits = robot.get_qpos_limits(name=HAND_NAME)
436 hand_open_qpos = hand_limits[..., 1]
437 hand_closed_qpos = hand_limits[..., 0]
438 # Newton must clear every articulation in a world together. Establish the
439 # initial scene state before opening the fingers for the approach.
440 sim.reset_objects_state()
441 move_gripper(sim, robot, hand_open_qpos, num_steps=20)
442 sim.update(step=5)
443
444 # Roll the asset's handle frame 90 degrees around TCP Z. Its approach axis
445 # stays unchanged while the fingers rotate to close vertically on the handle.
446 grasp_pose = get_handle_grasp_pose(drawer)
447 approach_pose = grasp_pose.clone()
448 approach_pose[:, :3, 3] -= grasp_pose[:, :3, 2] * APPROACH_DISTANCE
449
450 _move_arm_to_poses(
451 sim,
452 robot,
453 motion_generator,
454 [approach_pose, grasp_pose],
455 sample_count=60,
456 wait_for_input=wait_for_input,
457 )
458
459 # Close around the handle, then allow contacts to settle before pulling.
460 move_gripper(sim, robot, hand_closed_qpos)
461 sim.update(step=10)
462
463 # Re-read the live handle frame after grasping. Pulling along its -Z axis
464 # follows the drawer's prismatic joint toward Franka.
465 grasped_handle_pose = get_handle_grasp_pose(drawer)
466 pull_pose = grasped_handle_pose.clone()
467 pull_pose[:, :3, 3] -= grasped_handle_pose[:, :3, 2] * PULL_DISTANCE
468
469 _move_arm_to_poses(
470 sim,
471 robot,
472 motion_generator,
473 [pull_pose],
474 sample_count=80,
475 physics_steps_per_waypoint=5,
476 )
477 sim.update(step=50)
478
479 pulled_opening = drawer.get_qpos()[:, 0].clone()
480 print(
481 "[INFO]: Drawer opening after pull (m): "
482 f"{pulled_opening.detach().cpu().tolist()}",
483 flush=True,
484 )
485 if not torch.all(pulled_opening >= DRAWER_SUCCESS_THRESHOLD).item():
486 raise RuntimeError(
487 "The drawer did not open far enough through gripper contact. "
488 f"Expected at least {DRAWER_SUCCESS_THRESHOLD:.2f} m."
489 )
490
491 # Push the drawer back by half of its measured opening. Moving along the
492 # handle frame's +Z axis reverses the pull while the gripper stays closed.
493 half_open_target = pulled_opening * HALF_OPEN_FRACTION
494 push_distance = pulled_opening - half_open_target
495 pushed_handle_pose = get_handle_grasp_pose(drawer)
496 push_pose = pushed_handle_pose.clone()
497 push_pose[:, :3, 3] += pushed_handle_pose[:, :3, 2] * push_distance.unsqueeze(-1)
498
499 _move_arm_to_poses(
500 sim,
501 robot,
502 motion_generator,
503 [push_pose],
504 sample_count=50,
505 physics_steps_per_waypoint=5,
506 )
507 sim.update(step=50)
508
509 drawer_qpos = drawer.get_qpos()
510 final_opening = drawer_qpos[:, 0]
511 half_open_error = torch.abs(final_opening - half_open_target)
512 print(
513 "[INFO]: Drawer opening after half push (m): final="
514 f"{final_opening.detach().cpu().tolist()}, target="
515 f"{half_open_target.detach().cpu().tolist()}, abs_error="
516 f"{half_open_error.detach().cpu().tolist()}",
517 flush=True,
518 )
519 if not torch.all(half_open_error <= HALF_OPEN_TOLERANCE).item():
520 raise RuntimeError(
521 "The drawer did not return to half of its pulled opening. "
522 f"Expected an error no greater than {HALF_OPEN_TOLERANCE:.2f} m."
523 )
524 return drawer_qpos
525
526
527def _tutorial_physics_cfg(
528 backend: Literal["default", "newton"],
529) -> PhysicsBackendCfg:
530 """Build the physics configuration used by this tutorial."""
531 physics_cfg = physics_cfg_for_backend(backend)
532 if isinstance(physics_cfg, NewtonPhysicsCfg):
533 # Keep the same MuJoCo-Warp solver/contact profile as the atomic-action
534 # tutorial and packaged Newton task. The finer drawer substep cadence
535 # is intentional; per-world capacities remain auto-sized by DexSim
536 # from the finalized scene.
537 physics_cfg.num_substeps = 20
538 physics_cfg.collision_cfg = None
539 physics_cfg.solver_cfg = {
540 "solver_type": "mujoco_warp",
541 "solver": "newton",
542 "integrator": "implicitfast",
543 "iterations": 20,
544 "ls_iterations": 100,
545 "cone": "elliptic",
546 "impratio": 1_000.0,
547 "use_mujoco_contacts": True,
548 "enable_multiccd": True,
549 }
550 return physics_cfg
551
552
553def main(args: argparse.Namespace | None = None) -> None:
554 """Run the Franka drawer-manipulation tutorial."""
555 parser = build_parser()
556 if args is None:
557 args = parser.parse_args()
558 if args.num_envs < 1:
559 parser.error("--num_envs must be at least 1")
560 if args.hold_steps < 0:
561 parser.error("--hold-steps must be non-negative")
562 if args.record_fps < 1:
563 parser.error("--record-fps must be at least 1")
564 if args.record_save_path is not None and not args.headless:
565 parser.error("--record-save-path requires --headless")
566
567 open_native_window = not args.headless and not args.viser
568
569 # PytorchSolver samples multiple IK seeds; make the tutorial trajectory
570 # reproducible across repeated runs of the same backend.
571 seed = resolve_seed(args.seed)
572 print(f"[INFO]: IK sampling seed: {seed}", flush=True)
573 torch.manual_seed(seed)
574
575 # Construct the World without a window so Spawn can finish first.
576 sim = SimulationManager(
577 SimulationManagerCfg(
578 width=RECORD_WIDTH,
579 height=RECORD_HEIGHT,
580 headless=True,
581 sim_device=args.device,
582 num_envs=args.num_envs,
583 arena_space=args.arena_space,
584 physics_dt=1.0 / 100.0,
585 physics_cfg=_tutorial_physics_cfg(args.physics),
586 render_cfg=RenderCfg(renderer=args.renderer),
587 visualization=visualization_cfg_from_args(args),
588 )
589 )
590
591 try:
592 robot, drawer = create_scene(sim)
593
594 sim.prepare()
595 if open_native_window:
596 sim.open_window()
597
598 sim.update(step=5)
599 motion_generator = MotionGenerator(
600 cfg=MotionGenCfg(
601 planner_cfg=ToppraPlannerCfg(
602 robot_uid=robot.uid,
603 ),
604 )
605 )
606
607 if args.record_save_path is not None:
608 if not sim.start_window_record(
609 save_path=args.record_save_path,
610 fps=args.record_fps,
611 max_memory=2048,
612 video_prefix="open_drawer_headless",
613 look_at=RECORD_LOOK_AT,
614 use_sim_time=True,
615 ):
616 raise RuntimeError("Failed to start headless recording.")
617
618 print(
619 f"[INFO]: Opening drawers in {sim.num_envs} environment(s).",
620 flush=True,
621 )
622 open_drawer(
623 sim,
624 robot,
625 drawer,
626 motion_generator,
627 wait_for_input=not args.auto_start,
628 )
629 if args.hold_steps:
630 sim.update(step=args.hold_steps)
631 finally:
632 if sim.is_window_recording():
633 sim.stop_window_record()
634 sim.wait_window_record_saves()
635 sim.destroy(exit_process=False)
636
637
638if __name__ == "__main__":
639 try:
640 main(_cli_args)
641 finally:
642 SimulationManager.flush_cleanup_queue()
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.