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.

Franka approaches the handle, pulls the passive drawer open, and pushes it halfway back using three generated arm trajectories.

Download the MP4 video.

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:

Control boundary#

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:

\[R_{grasp} = R_{handle} R_z(\pi / 2)\]

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:

  1. robot.compute_ik converts 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.

  2. MotionGenerator passes the joint waypoints to TOPPRA, applying velocity and acceleration constraints and sampling a smooth trajectory.

  3. 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:

  1. Approach: move through the pre-grasp and handle poses with the gripper open.

  2. Pull: close the fingers, let contact settle, then move 16 cm along TCP -Z.

  3. 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.