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

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.