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"""Load a passive articulation and open or close it with joint forces."""
 18
 19from __future__ import annotations
 20
 21import argparse
 22
 23import torch
 24
 25from dexsim.types import DriveType
 26
 27from embodichain.data import get_data_path
 28from embodichain.lab.gym.utils.gym_utils import add_env_launcher_args_to_parser
 29from embodichain.lab.sim import SimulationManager, SimulationManagerCfg
 30from embodichain.lab.sim.cfg import ArticulationCfg, RenderCfg
 31from embodichain.lab.sim.objects import Articulation
 32from embodichain.lab.visualization import visualization_cfg_from_args
 33
 34DRAWER_ASSET = "SlidingBoxDrawer/SlidingBoxDrawer.urdf"
 35DRAWER_USER_QPOS_LIMITS = {"slide_rails": [0.0, 0.18]}
 36DRAWER_JOINT_FORCE = 1.0
 37JOINT_LIMIT_TOLERANCE = 1.0e-3
 38
 39
 40def create_articulation(sim: SimulationManager) -> Articulation:
 41    """Load a drawer articulation with the passive default drive.
 42
 43    Args:
 44        sim: Simulation manager that owns the scene.
 45
 46    Returns:
 47        The loaded drawer articulation.
 48
 49    Raises:
 50        RuntimeError: If the constructed backend joints are not passive.
 51    """
 52    # Resolve the drawer URDF and configure its initial pose. ``drive_pros`` is
 53    # intentionally omitted: ArticulationCfg defaults to drive_type="none".
 54    articulation_cfg = ArticulationCfg(
 55        uid="drawer",
 56        fpath=get_data_path(DRAWER_ASSET),
 57        init_pos=(0.0, 0.0, 0.05),
 58        fix_base=True,
 59        # The asset limit is [0.0, 0.2]; keep 90% of its travel range.
 60        qpos_limits=DRAWER_USER_QPOS_LIMITS,
 61    )
 62
 63    # Load one articulation instance into every simulation environment.
 64    articulation: Articulation = sim.add_articulation(cfg=articulation_cfg)
 65
 66    # Query the constructed DexSim entities, not only the config object.
 67    backend_drive_types = articulation.get_joint_drive_type()
 68    expected_drive_types = [
 69        [DriveType.NONE] * articulation.dof for _ in range(sim.num_envs)
 70    ]
 71    if backend_drive_types != expected_drive_types:
 72        raise RuntimeError(
 73            "Expected every articulation joint drive to be DriveType.NONE, "
 74            f"but received {backend_drive_types!r}."
 75        )
 76
 77    print(f"[INFO]: Loaded articulation with {articulation.dof} joint(s)", flush=True)
 78    print(f"[INFO]: Joint names: {articulation.joint_names}", flush=True)
 79    print(
 80        f"[INFO]: Config drive type: {articulation.cfg.drive_pros.drive_type}",
 81        flush=True,
 82    )
 83    print(f"[INFO]: Backend drive types: {backend_drive_types}", flush=True)
 84    print(
 85        f"[INFO]: Effective qpos limits: {articulation.get_qpos_limits()}", flush=True
 86    )
 87    return articulation
 88
 89
 90def apply_drawer_force(articulation: Articulation, opening: bool) -> None:
 91    """Apply a joint force that opens or closes the drawer.
 92
 93    Args:
 94        articulation: Drawer articulation receiving the force.
 95        opening: If True, apply positive force; otherwise apply negative force.
 96    """
 97    force = DRAWER_JOINT_FORCE if opening else -DRAWER_JOINT_FORCE
 98    joint_forces = torch.full_like(articulation.get_qpos(), force)
 99    articulation.set_qf(joint_forces)
100
101
102def run_simulation(
103    sim: SimulationManager,
104    articulation: Articulation,
105    max_steps: int | None = None,
106) -> None:
107    """Open and close the drawer by reversing force at its joint limits.
108
109    Args:
110        sim: Simulation manager to advance.
111        articulation: Drawer articulation whose joints are updated.
112        max_steps: Optional number of steps to run before returning.
113    """
114    if sim.is_use_gpu_physics:
115        sim.init_gpu_physics()
116
117    qpos_limits = articulation.get_qpos_limits()
118    closed_qpos = qpos_limits[..., 0]
119    open_qpos = qpos_limits[..., 1]
120    opening = True
121    step_count = 0
122    print(
123        f"[INFO]: Applying +{DRAWER_JOINT_FORCE:.1f} N to open the drawer",
124        flush=True,
125    )
126    try:
127        while max_steps is None or step_count < max_steps:
128            qpos = articulation.get_qpos()
129            if opening and torch.all(qpos >= open_qpos - JOINT_LIMIT_TOLERANCE).item():
130                print(f"[INFO]: Drawer reached open limit: {qpos}", flush=True)
131                opening = False
132                print(
133                    f"[INFO]: Applying -{DRAWER_JOINT_FORCE:.1f} N to close the drawer",
134                    flush=True,
135                )
136            elif (
137                not opening
138                and torch.all(qpos <= closed_qpos + JOINT_LIMIT_TOLERANCE).item()
139            ):
140                print(f"[INFO]: Drawer reached closed limit: {qpos}", flush=True)
141                opening = True
142                print(
143                    f"[INFO]: Applying +{DRAWER_JOINT_FORCE:.1f} N to open the drawer",
144                    flush=True,
145                )
146
147            apply_drawer_force(articulation, opening=opening)
148            sim.update(step=1)
149            step_count += 1
150    except KeyboardInterrupt:
151        print("\n[INFO]: Stopping simulation...")
152    finally:
153        articulation.set_qf(torch.zeros_like(articulation.get_qpos()))
154
155
156def main() -> None:
157    """Load and simulate a passive drawer articulation."""
158    parser = argparse.ArgumentParser(
159        description="Load an articulation with its default passive joint drive"
160    )
161    add_env_launcher_args_to_parser(parser)
162    parser.add_argument(
163        "--max-steps",
164        type=int,
165        default=None,
166        help="Optional number of simulation steps before exiting.",
167    )
168    args = parser.parse_args()
169    if args.max_steps is not None and args.max_steps < 1:
170        parser.error("--max-steps must be at least 1")
171
172    # Configure the simulation. Window creation is deferred until the asset is loaded.
173    sim_cfg = SimulationManagerCfg(
174        headless=args.headless,
175        sim_device=args.device,
176        num_envs=args.num_envs,
177        arena_space=2.0,
178        physics_dt=1.0 / 100.0,
179        render_cfg=RenderCfg(renderer=args.renderer),
180        visualization=visualization_cfg_from_args(args),
181    )
182    sim = SimulationManager(sim_cfg)
183
184    try:
185        articulation = create_articulation(sim)
186        print(f"[INFO]: Initial joint positions: {articulation.get_qpos()}", flush=True)
187
188        if not args.headless and not args.viser:
189            sim.open_window()
190
191        print("[INFO]: Running simulation. Press Ctrl+C to stop.", flush=True)
192        run_simulation(sim, articulation, max_steps=args.max_steps)
193    finally:
194        sim.destroy()
195
196
197if __name__ == "__main__":
198    main()

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.

    # Configure the simulation. Window creation is deferred until the asset is loaded.
    sim_cfg = SimulationManagerCfg(
        headless=args.headless,
        sim_device=args.device,
        num_envs=args.num_envs,
        arena_space=2.0,
        physics_dt=1.0 / 100.0,
        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 drive_pros. 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.

    # Resolve the drawer URDF and configure its initial pose. ``drive_pros`` is
    # intentionally omitted: ArticulationCfg defaults to drive_type="none".
    articulation_cfg = ArticulationCfg(
        uid="drawer",
        fpath=get_data_path(DRAWER_ASSET),
        init_pos=(0.0, 0.0, 0.05),
        fix_base=True,
        # The asset limit is [0.0, 0.2]; keep 90% of its travel range.
        qpos_limits=DRAWER_USER_QPOS_LIMITS,
    )

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

Verifying the constructed drive type#

Checking articulation.cfg.drive_pros.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, opening: bool) -> None:
    """Apply a joint force that opens or closes the drawer.

    Args:
        articulation: Drawer articulation receiving the force.
        opening: If True, apply positive force; otherwise apply negative force.
    """
    force = DRAWER_JOINT_FORCE if opening else -DRAWER_JOINT_FORCE
    joint_forces = torch.full_like(articulation.get_qpos(), force)
    articulation.set_qf(joint_forces)


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,
) -> None:
    """Open and close the drawer by reversing force at its joint limits.

    Args:
        sim: Simulation manager to advance.
        articulation: Drawer articulation whose joints are updated.
        max_steps: Optional number of steps to run before returning.
    """
    if sim.is_use_gpu_physics:
        sim.init_gpu_physics()

    qpos_limits = articulation.get_qpos_limits()
    closed_qpos = qpos_limits[..., 0]
    open_qpos = qpos_limits[..., 1]
    opening = True
    step_count = 0
    print(
        f"[INFO]: Applying +{DRAWER_JOINT_FORCE:.1f} N to open the drawer",
        flush=True,
    )
    try:
        while max_steps is None or step_count < max_steps:
            qpos = articulation.get_qpos()
            if opening and torch.all(qpos >= open_qpos - JOINT_LIMIT_TOLERANCE).item():
                print(f"[INFO]: Drawer reached open limit: {qpos}", flush=True)
                opening = False
                print(
                    f"[INFO]: Applying -{DRAWER_JOINT_FORCE:.1f} N to close the drawer",
                    flush=True,
                )
            elif (
                not opening
                and torch.all(qpos <= closed_qpos + JOINT_LIMIT_TOLERANCE).item()
            ):
                print(f"[INFO]: Drawer reached closed limit: {qpos}", flush=True)
                opening = True
                print(
                    f"[INFO]: Applying +{DRAWER_JOINT_FORCE:.1f} N to open the drawer",
                    flush=True,
                )

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


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",
    drive_pros=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 USD assets, use_usd_properties=True preserves the drive types stored in the USD file instead of applying the Articulation configuration default.

Next Steps#