Skip to content

Robots

A Robot is a parsed kinematic model — tree, joints, limits, geometry. It is immutable and holds no pose: joint values live in the Scene that owns it, which is also why the same model can be added to a scene more than once.

Three ways in

robot = bt.Robot.from_urdf("arm.urdf")
robot = bt.Robot.from_xacro("arm.urdf.xacro")
robot = bt.Robot.from_usd("franka.usd")

URDF — mesh paths resolve relative to the file, and package:// URIs are resolved heuristically, so most real-world URDFs load without a workspace. Meshes load from STL and OBJ; an OBJ that names an mtllib keeps its material colors, in the studio and in exported USD alike. A material that states Pm (metallic) or Pr (roughness) — the MTL PBR extension Blender writes — is drawn physically based in the studio, so painted metal reflects the room instead of reading as matte plastic; exported USD keeps its color. There is also from_urdf_string(xml) for generated descriptions.

Xacro — expanded without ROS: properties, macros, includes, and conditionals all work. Most real robot descriptions are xacro, and needing a ROS installation just to expand them is the usual dead end for ROS-free tools; botrail ships the expander.

USD — a UsdPhysics articulation, e.g. an Isaac Sim asset. Link and joint names are the prim paths (/panda/panda_hand), revolute limits are converted from degrees, distances from the stage's metersPerUnit, and Y-up stages are re-modeled as Z-up. articulation_root defaults to the first prim carrying PhysicsArticulationRootAPI; search_paths resolves external (omniverse://) references against local directories. Anything skipped during import is printed, not swallowed.

The model catalog

Instead of hunting down URDFs, load released packages straight from the botrail catalog (needs the optional extra: pip install botrail[catalog]):

robot = bt.Robot.from_catalog("2f-85")                       # newest public revision
robot = bt.Robot.from_catalog("robotiq/2f/2f-85/r1",
                              revision="<dataset commit sha>")  # pinned

Ids resolve exactly or by any unambiguous shorthand (2f-85, robotiq/2f-85). An id ends in a revision (.../r1), and when a shorthand matches several revisions of one product the newest public revision wins — a revision is the same machine re-cut from a better source, so short names follow it forward instead of breaking. Different products stay ambiguous and raise, listing what matched. Name a revision outright to pin it, and pin the revision a cell was taught on: a newer cut can move the geometry, the joint zero or the TCP.

Every load resolves to a concrete dataset commit and records the resolved id in the robot's source, so a saved project — and the script the studio exports — replays the same bytes later, on the revision it resolved to; that is the determinism story extended to model acquisition. Downloads land in the standard Hugging Face cache. A package whose meshes cannot be redistributed is recipe_only: it never wins a short name, and naming it in full raises with a pointer to building it locally with botrail-catalog-builder. A kit always loads the exact component revisions it was assembled from. The resolved id and dataset commit stay visible in the robot's source and on its BOM line.

A package directory you built yourself loads, manifest included, with:

coupling = bt.Robot.from_package("dist/robotiq/coupling/agc-cpl-062-002/r1")
# format="usd" selects the USD; the default prefers URDF.

from_package makes no network request and keeps the manifest's product ID, frames, order and mounting declarations (it needs PyYAML, included in the catalog extra). Its revision is local-sha256:<digest> of the files inside the package, and a saved .botrail project bundles the geometry, so the project and its generated Python reload without the directory. Loading just the URDF or USD carries none of the package identity.

The Robotiq demos run on the public r2 reference hand and its ES-062 kit — Menagerie's BSD-2-Clause meshes on botrail's joint tree and teaching frames, downloaded on first use:

uv run python examples/basics/sfc_chart_demo.py --studio

Not every package is a robot. A workpiece — a body-in-white, a casting, a fixture — is a pile of meshes a cell loads as obstacles, and catalog_package hands back its directory so the cell can reach them without a hand-written cache path that quietly stops matching when the dataset moves:

package = Path(bt.catalog_package("botrail/body/biw-sedan"))
for piece in sorted((package / "collision").glob("*.stl")):
    scene.add_mesh(f"body/{piece.stem}", str(piece), (0, 0, 0))

The frames a package manifest declares come along: frames.tcp_default becomes the model's tcp_link (the grasp center, not a fingertip), and flange_frame / mount_frame surface as robot.flange_link / robot.mount_link — which is what lets catalog parts mount without naming a single frame.

What a model knows

robot.dof            # actuated joints
robot.joint_names    # in q-vector order — every `positions` list uses this order
robot.joint_limits   # (lower, upper) per joint, None for continuous
robot.link_names
robot.tcp_link       # declared TCP if any (catalog, attach_tool), else deepest leaf

Mimic joints

Joints that follow another joint — URDF <mimic>, USD NewtonMimicAPI (Isaac Sim 6 / Newton and the official URDF importer) or the legacy PhysxMimicJointAPI, or the botrail:mimic customData that URDF-to-USD converters author — never appear in joint_names or in a position vector. A two-finger gripper with a mimicked second finger costs one DOF, not two:

robot.mimic_joints          # {joint: (source joint, multiplier, offset)}
robot.joint_values(q)       # every joint's value, mimics resolved, fixed = 0

joint_values is the bridge to consumers that want all joints (a firmware interface, an animation rig): it expands a DOF vector into a per-joint map with the mimic relations applied.

When a mimic is authored wrong

The Isaac Franka authors its finger mimic about a different axis than the joint actually moves on. PhysxMimicJointAPI names the dof it constrains, so botrail refuses to guess: it prints mimic joint authored onrotXbut the joint moves abouttransX; ignored and keeps both fingers as independent DOF. If your vector is one longer than you expected, read the import notices. (three-usd-robot reads it the same way, so the studio picture matches the model.)

Mounting a tool

attach_tool welds an end-effector onto a flange and returns the composite — one kinematic tree whose DOF vector is the arm's joints followed by the tool's, mimic joints included. Neither input changes; robots are immutable.

arm = bt.Robot.from_catalog("universal_robots/ur/ur5e/r2")
kit = bt.Robot.from_catalog("robotiq/2f/2f-85-ur-es-062-kit/r2")

robot = arm.attach_tool(kit)   # includes the ES-062 coupling once, under `kit_`
robot.dof        # 6 + 1
robot.tcp_link   # 'kit_gripper/tcp' — the gripper's declared TCP, so IK targets the grasp center

With catalog parts nothing needs naming: flange defaults to the robot's declared flange_link, mount to the tool's declared mount_link (else its root), and a coupling's outward face becomes the composite's flange, so the next attach_tool in the stack keeps chaining. Models without declared frames spell them out:

tool = bt.Robot.from_urdf("custom_tool.urdf")
robot = arm.attach_tool(tool, flange="tool0", mount="mount")

The composite's TCP comes from tcp= if you pass it, else from a TCP the tool declares (catalog manifests do), else the deepest-leaf heuristic — which on a merged model would pick an arbitrary fingertip, exactly the case the explicit TCP exists for. The weld is a fixed joint, so the flange/mount pair is treated like any adjacent pair in collision checking. If the two models share a link or joint name, pass prefix="g_" to namespace the tool's names. Saved projects and exported scripts carry the attachment and rebuild it on load.

mount must be the tool's root link; welding a tool by a mid-chain link would need re-rooting its tree, which botrail refuses rather than guesses.

IK without a scene

The model solves IK on its own — useful for reachability studies before any cell exists:

ik = robot.ik((0.4, 0.1, 0.5))            # position only
ik = robot.ik((0.4, 0.1, 0.5), quaternion=(0, 1, 0, 0), link="/panda/panda_hand")

ik.converged     # always check — the solver returns its best effort
ik.q             # best configuration found, always within limits
ik.pos_error     # m
ik.rot_error     # rad

link defaults to the TCP link and seed to the neutral configuration. When a seed does not converge — a robot whose limits exclude zero starts clamped against them, the FR3 famously so — the solver retries from deterministically generated seeds (limits midpoint, then fixed-seed samples within the limits), so the same call returns the same answer every time; restarts=0 disables this. The studio's drag-to-pose solver never restarts: a per-frame solve must stay on its solution branch. Inside a scene, set_tcp_target is the same solve seeded from the current pose — and applied.

Several robots in one cell

scene = bt.Scene(robot, name="near")
scene.add_robot(robot, name="far",
                base_position=(1.2, 0.0, 0.4),
                base_quaternion=(0.0, 0.0, 1.0, 0.0))   # facing back

scene.robots               # ['near', 'far'] — instance names, insertion order
scene.robot_of("far")      # the model behind an instance
scene.joint_positions_of("far")

Instances are what everything else addresses: methods take robot= (defaulting to the first robot), motions belong to an instance, and USD exports place each one under /World/<instance name>. rename_robot renames safely — sequence actions, robot_done conditions, and sensor watch lists follow the new name.

Planning with several robots is per-robot with the others frozen as obstacles; the sequence rollout then re-checks robot-against-robot every tick. The Two arms, one belt tutorial shows the full pattern, including the interlocks.

Arms of one robot

A dual-arm robot — an OpenArm, a YuMi, two UR5e on one torso and one controller — is one Robot whose planning groups are its arms. A group is a named set of joints with a tip link; a single-arm robot has one group covering everything, a dual-arm robot one per arm, and every API that moves a robot takes group= to say which:

pair = bt.Robot.dual_arm(ur5e, ur5e,
                         left_position=(0.0, 0.3, 0.0), left_quaternion=(0, 0, 0, 1),
                         right_position=(0.0, -0.3, 0.0), right_quaternion=(0, 0, 0, 1))
pair.groups                        # ['left', 'right']
pair.group("left").tip             # 'left_tool0'
pair.group("left").joints          # the six left joints, in q order

scene = bt.Scene(pair)
scene.set_tcp_target(p, quat, group="left")      # the right arm does not move
scene.plan_to_pose(p, quat, group="left")        # the right arm is a frozen obstacle
scene.add_segment("left_pick", goal=q, group="left")
scene.attach("part", group="left")               # at the left arm's tip

Where the arms come from:

  • The catalog. A manipulator.dual_arm package names its arms (frames.arms[] — base, flange, TCP and joints per arm), and bt.Robot.from_catalog("openarm") loads with exactly those groups. A whole-body machine — a humanoid upper body on wheels, on a pedestal or on legs — names the rest of its body the same way (frames.groups[]): see More than arms below.
  • The product's description. A URDF with two arms on a body derives its groups: the branches under the shared body become groups named by their joints' common prefix (left / right for left_shoulder… and right_shoulder…). A branch of one joint is a finger, not an arm, and stays with the arm it hangs off.
  • Two single arms, one robot. Robot.dual_arm(left, right, ...) welds two models to a bare body and names the groups left and right; robot.mount(part, ..., role="arm", group="left") is the general form. Tools go on an arm: pair.attach_tool(gripper, group="left").
  • Declared. robot.define_group("left", tip="left_hand", joints=[...]) overrides the derivation; the joints default to the tip's chain up to the nearest branch.

group= may be left off on a robot with one group (everything works as it always has) and on a derived dual-arm robot when a link makes the arm clear (plan_to_pose(..., link="left_hand")); once groups are declared, an ambiguous call is an error rather than a guess. joint_positions interleaves the arms (it is the tree's breadth-first order), so address joints by name or through group("left").joints.

More than arms

A semi-humanoid has a torso under its arms and a head over them, and they are groups too — not arms (they have no flange and no TCP), but sets of joints one ramp or one plan moves. A catalog package declares them after its arms:

robot = bt.Robot.from_catalog("unitree/g1/g1-d")
robot.groups        # ['left', 'right', 'torso', 'left_hand', 'right_hand',
                    #  'left_with_torso', 'right_with_torso']
robot.group("torso").joints     # the lift column's two stages, waist pitch, waist yaw
  • An arm plans without the torso: plan_to_pose(..., group="right") leaves the lift where it stands, so the other arm's base does not move under it. Raise or lower the torso with a ramp (bt.seq.ramp on the torso's joints), then plan the arm.
  • A composite (right_with_torso) is the torso and one arm planned as one: the way to reach a shelf the arm alone cannot — the floor, or overhead. While it runs it drives the torso, so nothing else may.
  • The wheels are in no group, and an unnamed plan on such a robot is refused rather than allowed to sample them. They turn with the vehicle the machine is mounted on as its own running gear — scene.mount_robot(..., wheels=bt.Wheels.from_catalog(...)), see The robot is the vehicle.
  • Reach circles, per-arm requirements and attach defaults are the arms'; a torso or a head gets none.

A semi-humanoid picks a shelf is the pattern end to end: the torso ramped to a posture the teaching chose, then the arm planned, from the low board of a bay to its top one.

Two arms from two robots is the other way to build a dual-arm cell — scene.add_robot(...) twice, as above — and the right one when the arms have two controllers. The rollout treats the two builds alike: each arm plans on its own with the other frozen, and the two are re-checked against each other every tick. Two arms, one robot walks through a kitting cell built the single-robot way.