Loading an Articulation#

This tutorial loads a URDF as a generic objects.Articulation, inspects its links and joints, and verifies the drive type applied to the constructed physics entities. Generic articulations are passive by default: unlike objects.Robot, their joints use drive_type="none" unless a drive is configured explicitly.

The Code#

The complete example is available in scripts/tutorials/sim/create_articulation.py.

Code for create_articulation.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"""Open and close a passive drawer with joint forces.
 18
 19The default demonstrates a URDF drawer. Pass ``--asset usd`` to load
 20``DrawerUSD/drawer_001.usdc`` and move only its top drawer.
 21"""
 22
 23from __future__ import annotations
 24
 25import argparse
 26from typing import Literal
 27from embodichain.cli.sim import add_sim_args_to_parser
 28
 29
 30def build_parser() -> argparse.ArgumentParser:
 31    """Build CLI options without initializing simulation resources."""
 32    parser = argparse.ArgumentParser(
 33        description="Load an articulation with its default passive joint drive"
 34    )
 35    add_sim_args_to_parser(parser)
 36    parser.add_argument(
 37        "--asset",
 38        choices=("urdf", "usd"),
 39        default="urdf",
 40        help="Drawer asset to demonstrate; usd moves the top drawer of DrawerUSD.",
 41    )
 42    parser.add_argument(
 43        "--max-steps",
 44        type=int,
 45        default=None,
 46        help="Optional number of simulation steps before exiting.",
 47    )
 48    return parser
 49
 50
 51if __name__ == "__main__":
 52    # Parse before importing optional simulation/planning dependencies.
 53    _cli_args = build_parser().parse_args()
 54
 55
 56import torch
 57
 58from dexsim.types import DriveType
 59
 60from embodichain.data import get_data_path
 61from embodichain.lab.sim import SimulationManager, SimulationManagerCfg
 62from embodichain.lab.sim.cfg import (
 63    ArticulationCfg,
 64    ArticulationRootPropertiesCfg,
 65    DefaultRigidBodyPropertiesCfg,
 66    JointDrivePropertiesCfg,
 67    RenderCfg,
 68    RigidBodyPhysicsCfg,
 69    physics_cfg_for_backend,
 70)
 71from embodichain.lab.sim.objects import Articulation
 72from embodichain.lab.visualization import visualization_cfg_from_args
 73
 74DRAWER_ASSET = "SlidingBoxDrawer/SlidingBoxDrawer.urdf"
 75USD_DRAWER_ASSET = "DrawerUSD/drawer_001.usdc"
 76DRAWER_USER_QPOS_LIMITS = {"slide_rails": [0.0, 0.18]}
 77DRAWER_JOINT_FORCE_LIMIT = 1.0
 78DRAWER_POSITION_GAIN = 20.0
 79DRAWER_VELOCITY_GAIN = 4.0
 80JOINT_POSITION_TOLERANCE = 1.0e-3
 81JOINT_VELOCITY_TOLERANCE = 1.0e-2
 82
 83
 84def create_articulation(
 85    sim: SimulationManager, asset: Literal["urdf", "usd"] = "urdf"
 86) -> Articulation:
 87    """Load a drawer articulation with the passive default drive.
 88
 89    Args:
 90        sim: Simulation manager that owns the scene.
 91        asset: URDF drawer or the USD cabinet with three drawers.
 92
 93    Returns:
 94        The loaded drawer articulation.
 95
 96    Raises:
 97        ValueError: If the asset selector is unknown.
 98        RuntimeError: If the constructed backend joints are not passive.
 99    """
100    if asset not in ("urdf", "usd"):
101        raise ValueError(f"Unknown drawer asset: {asset!r}.")
102    is_usd = asset == "usd"
103    # Resolve the selected asset and explicitly request the passive drive used by
104    # this tutorial while retaining all unconfigured asset properties.
105    articulation_cfg = ArticulationCfg(
106        uid="drawer",
107        fpath=get_data_path(USD_DRAWER_ASSET if is_usd else DRAWER_ASSET),
108        asset_physics_mode="overlay",
109        # The USD cabinet extends 0.31 m below its root; lift it clear of the ground.
110        init_pos=(0.0, 0.0, 0.8 if is_usd else 0.05),
111        root_props=ArticulationRootPropertiesCfg(fixed_base=True),
112        joint_drive_props=JointDrivePropertiesCfg(drive_type="none"),
113        # Keep 90% of the URDF travel; retain the USD's named per-drawer limits.
114        qpos_limits=None if is_usd else DRAWER_USER_QPOS_LIMITS,
115        # Newton currently has no body-level damping setting. Remove the
116        # Default backend's damping so both passive models use zero damping.
117        attrs=RigidBodyPhysicsCfg(
118            rigid_props=DefaultRigidBodyPropertiesCfg(
119                linear_damping=0.0,
120                angular_damping=0.0,
121            )
122        ),
123    )
124
125    # Load one articulation instance into every simulation environment.
126    articulation: Articulation = sim.add_articulation(cfg=articulation_cfg)
127    sim.prepare()
128
129    # Query the constructed DexSim entities, not only the config object.
130    backend_drive_types = articulation.get_joint_drive_type()
131    expected_drive_types = [
132        [DriveType.NONE] * articulation.dof for _ in range(sim.num_envs)
133    ]
134    if backend_drive_types != expected_drive_types:
135        raise RuntimeError(
136            "Expected every articulation joint drive to be DriveType.NONE, "
137            f"but received {backend_drive_types!r}."
138        )
139
140    print(f"[INFO]: Loaded articulation with {articulation.dof} joint(s)", flush=True)
141    print(f"[INFO]: Joint names: {articulation.joint_names}", flush=True)
142    print(
143        f"[INFO]: Config drive type: {articulation.cfg.joint_drive_props.drive_type}",
144        flush=True,
145    )
146    print(f"[INFO]: Backend drive types: {backend_drive_types}", flush=True)
147    print(
148        f"[INFO]: Effective qpos limits: {articulation.get_qpos_limits()}", flush=True
149    )
150    return articulation
151
152
153def apply_drawer_force(
154    articulation: Articulation,
155    target_qpos: torch.Tensor,
156    joint_ids: list[int] | None = None,
157) -> None:
158    """Apply effort-limited PD control toward a drawer position.
159
160    Args:
161        articulation: Drawer articulation receiving the force.
162        target_qpos: Target positions for every environment and selected joint.
163        joint_ids: Joint indices to control; None selects all joints.
164    """
165    qpos = articulation.get_qpos()
166    qvel = articulation.get_qvel()
167    if joint_ids is not None:
168        qpos, qvel = qpos[:, joint_ids], qvel[:, joint_ids]
169    position_error = target_qpos - qpos
170    joint_forces = DRAWER_POSITION_GAIN * position_error - DRAWER_VELOCITY_GAIN * qvel
171    joint_forces = torch.clamp(
172        joint_forces,
173        min=-DRAWER_JOINT_FORCE_LIMIT,
174        max=DRAWER_JOINT_FORCE_LIMIT,
175    )
176    articulation.set_qf(joint_forces, joint_ids=joint_ids)
177
178
179def run_simulation(
180    sim: SimulationManager,
181    articulation: Articulation,
182    max_steps: int | None = None,
183    *,
184    joint_name: str = "slide_rails",
185    open_at_lower_limit: bool = False,
186) -> None:
187    """Open and close the drawer with effort-limited position tracking.
188
189    Args:
190        sim: Simulation manager to advance.
191        articulation: Drawer articulation whose joints are updated.
192        max_steps: Optional number of steps to run before returning.
193        joint_name: Drawer joint to move; all other joints remain passive.
194        open_at_lower_limit: Whether opening moves toward the lower joint limit.
195    """
196    joint_ids = [articulation.joint_names.index(joint_name)]
197    qpos_limits = articulation.get_qpos_limits(joint_ids=joint_ids)
198    closed_qpos = qpos_limits[..., 1 if open_at_lower_limit else 0]
199    open_qpos = qpos_limits[..., 0 if open_at_lower_limit else 1]
200    opening = True
201    target_qpos = open_qpos
202    step_count = 0
203    print(
204        f"[INFO]: Tracking {joint_name!r} toward the open position with effort limited to "
205        f"+/-{DRAWER_JOINT_FORCE_LIMIT:.1f} N",
206        flush=True,
207    )
208    try:
209        while max_steps is None or step_count < max_steps:
210            qpos = articulation.get_qpos()[:, joint_ids]
211            qvel = articulation.get_qvel()[:, joint_ids]
212            settled = torch.all(
213                (torch.abs(qpos - target_qpos) <= JOINT_POSITION_TOLERANCE)
214                & (torch.abs(qvel) <= JOINT_VELOCITY_TOLERANCE)
215            ).item()
216            if settled:
217                reached_position = "open" if opening else "closed"
218                print(
219                    f"[INFO]: Drawer settled at {reached_position} position: "
220                    f"qpos={qpos}, qvel={qvel}",
221                    flush=True,
222                )
223                opening = not opening
224                target_qpos = open_qpos if opening else closed_qpos
225                target_position = "open" if opening else "closed"
226                print(
227                    f"[INFO]: Tracking the {target_position} position",
228                    flush=True,
229                )
230
231            apply_drawer_force(
232                articulation, target_qpos=target_qpos, joint_ids=joint_ids
233            )
234            sim.update(step=1)
235            step_count += 1
236    except KeyboardInterrupt:
237        print("\n[INFO]: Stopping simulation...")
238    finally:
239        articulation.set_qf(
240            torch.zeros_like(articulation.get_qpos()[:, joint_ids]),
241            joint_ids=joint_ids,
242        )
243
244
245def main(args: argparse.Namespace | None = None) -> None:
246    """Load and simulate a passive drawer articulation."""
247    parser = build_parser()
248    if args is None:
249        args = parser.parse_args()
250    if args.max_steps is not None and args.max_steps < 1:
251        parser.error("--max-steps must be at least 1")
252
253    open_native_window = not args.headless and not args.viser
254
255    # Construct the World without a window so Spawn can finish first. The
256    # requested native window is opened explicitly after create_articulation().
257    sim_cfg = SimulationManagerCfg(
258        headless=True,
259        sim_device=args.device,
260        num_envs=args.num_envs,
261        arena_space=2.0,
262        physics_dt=1.0 / 100.0,
263        physics_cfg=physics_cfg_for_backend(args.physics),
264        render_cfg=RenderCfg(renderer=args.renderer),
265        visualization=visualization_cfg_from_args(args),
266    )
267    sim = SimulationManager(sim_cfg)
268
269    try:
270        articulation = create_articulation(sim, asset=args.asset)
271        print(f"[INFO]: Initial joint positions: {articulation.get_qpos()}", flush=True)
272
273        if open_native_window:
274            sim.open_window()
275
276        print("[INFO]: Running simulation. Press Ctrl+C to stop.", flush=True)
277        run_simulation(
278            sim,
279            articulation,
280            max_steps=args.max_steps,
281            joint_name="top_slide" if args.asset == "usd" else "slide_rails",
282            open_at_lower_limit=args.asset == "usd",
283        )
284    finally:
285        sim.destroy(exit_process=False)
286
287
288if __name__ == "__main__":
289    try:
290        main(_cli_args)
291    finally:
292        SimulationManager.flush_cleanup_queue()

The Code Explained#

Configuring the simulation#

Create a SimulationManager in manual-update mode. The common launcher arguments let the same script run on CPU or CUDA, in multiple environments, or with native or browser visualization.

def create_articulation(
    sim: SimulationManager, asset: Literal["urdf", "usd"] = "urdf"
) -> Articulation:
    """Load a drawer articulation with the passive default drive.

    Args:
        sim: Simulation manager that owns the scene.
        asset: URDF drawer or the USD cabinet with three drawers.

    Returns:
        The loaded drawer articulation.

    Raises:
        ValueError: If the asset selector is unknown.
        RuntimeError: If the constructed backend joints are not passive.
    """
    if asset not in ("urdf", "usd"):
        raise ValueError(f"Unknown drawer asset: {asset!r}.")
    is_usd = asset == "usd"
    # Resolve the selected asset and explicitly request the passive drive used by
    # this tutorial while retaining all unconfigured asset properties.
    articulation_cfg = ArticulationCfg(
        uid="drawer",
        fpath=get_data_path(USD_DRAWER_ASSET if is_usd else DRAWER_ASSET),
        asset_physics_mode="overlay",
        # The USD cabinet extends 0.31 m below its root; lift it clear of the ground.
        init_pos=(0.0, 0.0, 0.8 if is_usd else 0.05),
        root_props=ArticulationRootPropertiesCfg(fixed_base=True),
        joint_drive_props=JointDrivePropertiesCfg(drive_type="none"),
        # Keep 90% of the URDF travel; retain the USD's named per-drawer limits.
        qpos_limits=None if is_usd else DRAWER_USER_QPOS_LIMITS,
        # Newton currently has no body-level damping setting. Remove the
        # Default backend's damping so both passive models use zero damping.
        attrs=RigidBodyPhysicsCfg(
            rigid_props=DefaultRigidBodyPropertiesCfg(
                linear_damping=0.0,
                angular_damping=0.0,
            )
        ),
    )

    # Load one articulation instance into every simulation environment.
    articulation: Articulation = sim.add_articulation(cfg=articulation_cfg)
    sim.prepare()

    # Query the constructed DexSim entities, not only the config object.
    backend_drive_types = articulation.get_joint_drive_type()
    expected_drive_types = [
        [DriveType.NONE] * articulation.dof for _ in range(sim.num_envs)
    ]
    if backend_drive_types != expected_drive_types:
        raise RuntimeError(
            "Expected every articulation joint drive to be DriveType.NONE, "
            f"but received {backend_drive_types!r}."
        )

    print(f"[INFO]: Loaded articulation with {articulation.dof} joint(s)", flush=True)
    print(f"[INFO]: Joint names: {articulation.joint_names}", flush=True)
    print(
        f"[INFO]: Config drive type: {articulation.cfg.joint_drive_props.drive_type}",
        flush=True,
    )
    print(f"[INFO]: Backend drive types: {backend_drive_types}", flush=True)
    print(
        f"[INFO]: Effective qpos limits: {articulation.get_qpos_limits()}", flush=True
    )
    return articulation


def apply_drawer_force(
    articulation: Articulation,
    target_qpos: torch.Tensor,
    joint_ids: list[int] | None = None,
) -> None:
    """Apply effort-limited PD control toward a drawer position.

    Args:
        articulation: Drawer articulation receiving the force.
        target_qpos: Target positions for every environment and selected joint.
        joint_ids: Joint indices to control; None selects all joints.
    """
    qpos = articulation.get_qpos()
    qvel = articulation.get_qvel()
    if joint_ids is not None:
        qpos, qvel = qpos[:, joint_ids], qvel[:, joint_ids]
    position_error = target_qpos - qpos
    joint_forces = DRAWER_POSITION_GAIN * position_error - DRAWER_VELOCITY_GAIN * qvel
    joint_forces = torch.clamp(
        joint_forces,
        min=-DRAWER_JOINT_FORCE_LIMIT,
        max=DRAWER_JOINT_FORCE_LIMIT,
    )
    articulation.set_qf(joint_forces, joint_ids=joint_ids)


def run_simulation(
    sim: SimulationManager,
    articulation: Articulation,
    max_steps: int | None = None,
    *,
    joint_name: str = "slide_rails",
    open_at_lower_limit: bool = False,
) -> None:
    """Open and close the drawer with effort-limited position tracking.

    Args:
        sim: Simulation manager to advance.
        articulation: Drawer articulation whose joints are updated.
        max_steps: Optional number of steps to run before returning.
        joint_name: Drawer joint to move; all other joints remain passive.
        open_at_lower_limit: Whether opening moves toward the lower joint limit.
    """
    joint_ids = [articulation.joint_names.index(joint_name)]
    qpos_limits = articulation.get_qpos_limits(joint_ids=joint_ids)
    closed_qpos = qpos_limits[..., 1 if open_at_lower_limit else 0]
    open_qpos = qpos_limits[..., 0 if open_at_lower_limit else 1]
    opening = True
    target_qpos = open_qpos
    step_count = 0
    print(
        f"[INFO]: Tracking {joint_name!r} toward the open position with effort limited to "
        f"+/-{DRAWER_JOINT_FORCE_LIMIT:.1f} N",
        flush=True,
    )
    try:
        while max_steps is None or step_count < max_steps:
            qpos = articulation.get_qpos()[:, joint_ids]
            qvel = articulation.get_qvel()[:, joint_ids]
            settled = torch.all(
                (torch.abs(qpos - target_qpos) <= JOINT_POSITION_TOLERANCE)
                & (torch.abs(qvel) <= JOINT_VELOCITY_TOLERANCE)
            ).item()
            if settled:
                reached_position = "open" if opening else "closed"
                print(
                    f"[INFO]: Drawer settled at {reached_position} position: "
                    f"qpos={qpos}, qvel={qvel}",
                    flush=True,
                )
                opening = not opening
                target_qpos = open_qpos if opening else closed_qpos
                target_position = "open" if opening else "closed"
                print(
                    f"[INFO]: Tracking the {target_position} position",
                    flush=True,
                )

            apply_drawer_force(
                articulation, target_qpos=target_qpos, joint_ids=joint_ids
            )
            sim.update(step=1)
            step_count += 1
    except KeyboardInterrupt:
        print("\n[INFO]: Stopping simulation...")
    finally:
        articulation.set_qf(
            torch.zeros_like(articulation.get_qpos()[:, joint_ids]),
            joint_ids=joint_ids,
        )


def main(args: argparse.Namespace | None = None) -> None:
    """Load and simulate a passive drawer articulation."""
    parser = build_parser()
    if args is None:
        args = parser.parse_args()
    if args.max_steps is not None and args.max_steps < 1:
        parser.error("--max-steps must be at least 1")

    open_native_window = not args.headless and not args.viser

    # Construct the World without a window so Spawn can finish first. The
    # requested native window is opened explicitly after create_articulation().
    sim_cfg = SimulationManagerCfg(
        headless=True,
        sim_device=args.device,
        num_envs=args.num_envs,
        arena_space=2.0,
        physics_dt=1.0 / 100.0,
        physics_cfg=physics_cfg_for_backend(args.physics),
        render_cfg=RenderCfg(renderer=args.renderer),
        visualization=visualization_cfg_from_args(args),
    )
    sim = SimulationManager(sim_cfg)

Loading the URDF#

Resolve the bundled drawer asset, then pass its path to cfg.ArticulationCfg. The example intentionally does not set joint_drive_props. Therefore the configuration uses the Articulation default, drive_type="none". SimulationManager.add_articulation loads one drawer into each configured environment and returns a batched objects.Articulation handle.

The URDF defines slide_rails over [0.0, 0.2] metres. The example also sets qpos_limits={"slide_rails": [0.0, 0.18]}, retaining 90% of the asset’s travel while keeping the fully closed position valid. This becomes the effective physics limit used by both the backend and the force-control loop.

Verifying the constructed drive type#

Checking articulation.cfg.joint_drive_props.drive_type confirms the requested configuration, but it does not prove what the physics backend received. The example therefore calls objects.Articulation.get_joint_drive_type(), which reads the drive type from every constructed DexSim entity. It raises an error unless every joint in every environment is DriveType.NONE.

    # Query the constructed DexSim entities, not only the config object.
    backend_drive_types = articulation.get_joint_drive_type()
    expected_drive_types = [
        [DriveType.NONE] * articulation.dof for _ in range(sim.num_envs)
    ]
    if backend_drive_types != expected_drive_types:
        raise RuntimeError(
            "Expected every articulation joint drive to be DriveType.NONE, "
            f"but received {backend_drive_types!r}."
        )

For the bundled drawer, the relevant output is:

[INFO]: Loaded articulation with 1 joint(s)
[INFO]: Config drive type: none
[INFO]: Backend drive types: [[<DriveType.NONE: ...>]]
[INFO]: Effective qpos limits: tensor([[[0.0000, 0.1800]]])

The numeric enum value represented by ... is backend-version dependent; the semantic value is always DriveType.NONE.

Opening and closing the drawer#

The articulation state APIs are batched. For example, objects.Articulation.get_qpos() returns a tensor with shape (num_envs, dof), while objects.Articulation.get_qpos_limits() returns (num_envs, dof, 2). The tutorial treats each lower limit as the closed position and each upper limit as the open position.

Because this Articulation has drive_type="none", it does not track position or velocity targets. Instead, the example uses objects.Articulation.set_qf() to apply a +1 N generalized joint force while opening and a -1 N force while closing. Applying external effort does not change the passive drive type.

def apply_drawer_force(
    articulation: Articulation,
    target_qpos: torch.Tensor,
    joint_ids: list[int] | None = None,
) -> None:
    """Apply effort-limited PD control toward a drawer position.

    Args:
        articulation: Drawer articulation receiving the force.
        target_qpos: Target positions for every environment and selected joint.
        joint_ids: Joint indices to control; None selects all joints.
    """
    qpos = articulation.get_qpos()
    qvel = articulation.get_qvel()
    if joint_ids is not None:
        qpos, qvel = qpos[:, joint_ids], qvel[:, joint_ids]
    position_error = target_qpos - qpos
    joint_forces = DRAWER_POSITION_GAIN * position_error - DRAWER_VELOCITY_GAIN * qvel
    joint_forces = torch.clamp(
        joint_forces,
        min=-DRAWER_JOINT_FORCE_LIMIT,
        max=DRAWER_JOINT_FORCE_LIMIT,
    )
    articulation.set_qf(joint_forces, joint_ids=joint_ids)


The simulation loop reads the current joint position on every step. When every drawer instance reaches its upper limit, it reverses the force to close; at the lower limit, it reverses again to open. The force is cleared before the loop returns.

def run_simulation(
    sim: SimulationManager,
    articulation: Articulation,
    max_steps: int | None = None,
    *,
    joint_name: str = "slide_rails",
    open_at_lower_limit: bool = False,
) -> None:
    """Open and close the drawer with effort-limited position tracking.

    Args:
        sim: Simulation manager to advance.
        articulation: Drawer articulation whose joints are updated.
        max_steps: Optional number of steps to run before returning.
        joint_name: Drawer joint to move; all other joints remain passive.
        open_at_lower_limit: Whether opening moves toward the lower joint limit.
    """
    joint_ids = [articulation.joint_names.index(joint_name)]
    qpos_limits = articulation.get_qpos_limits(joint_ids=joint_ids)
    closed_qpos = qpos_limits[..., 1 if open_at_lower_limit else 0]
    open_qpos = qpos_limits[..., 0 if open_at_lower_limit else 1]
    opening = True
    target_qpos = open_qpos
    step_count = 0
    print(
        f"[INFO]: Tracking {joint_name!r} toward the open position with effort limited to "
        f"+/-{DRAWER_JOINT_FORCE_LIMIT:.1f} N",
        flush=True,
    )
    try:
        while max_steps is None or step_count < max_steps:
            qpos = articulation.get_qpos()[:, joint_ids]
            qvel = articulation.get_qvel()[:, joint_ids]
            settled = torch.all(
                (torch.abs(qpos - target_qpos) <= JOINT_POSITION_TOLERANCE)
                & (torch.abs(qvel) <= JOINT_VELOCITY_TOLERANCE)
            ).item()
            if settled:
                reached_position = "open" if opening else "closed"
                print(
                    f"[INFO]: Drawer settled at {reached_position} position: "
                    f"qpos={qpos}, qvel={qvel}",
                    flush=True,
                )
                opening = not opening
                target_qpos = open_qpos if opening else closed_qpos
                target_position = "open" if opening else "closed"
                print(
                    f"[INFO]: Tracking the {target_position} position",
                    flush=True,
                )

            apply_drawer_force(
                articulation, target_qpos=target_qpos, joint_ids=joint_ids
            )
            sim.update(step=1)
            step_count += 1
    except KeyboardInterrupt:
        print("\n[INFO]: Stopping simulation...")
    finally:
        articulation.set_qf(
            torch.zeros_like(articulation.get_qpos()[:, joint_ids]),
            joint_ids=joint_ids,
        )


The finite run reports both limit transitions:

[INFO]: Applying +1.0 N to open the drawer
[INFO]: Drawer reached open limit: tensor([[0.1800]])
[INFO]: Applying -1.0 N to close the drawer
[INFO]: Drawer reached closed limit: tensor([[0.]])

Run the interactive example from the repository root:

python scripts/tutorials/sim/create_articulation.py

For a finite, headless CPU verification:

python scripts/tutorials/sim/create_articulation.py \
    --headless \
    --device cpu \
    --max-steps 210

To inspect the articulation in a browser:

python scripts/tutorials/sim/create_articulation.py --viser

Enabling a drive explicitly#

Passive joints react to contacts, gravity, friction, and externally applied forces, but they do not track position or velocity targets. If a generic articulation needs an actuator, opt in with cfg.JointDrivePropertiesCfg:

from embodichain.lab.sim.cfg import ArticulationCfg, JointDrivePropertiesCfg

articulation_cfg = ArticulationCfg(
    fpath="path/to/articulation.urdf",
    joint_drive_props=JointDrivePropertiesCfg(
        drive_type="force",
        stiffness=1.0e4,
        damping=1.0e3,
    ),
)

For a controllable robot, prefer cfg.RobotCfg and SimulationManager.add_robot(); robots default to drive_type="force".

Attention

For file-backed assets, asset_physics_mode="preserve" keeps source physics, while "overlay" applies explicitly configured values.

Next Steps#