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:
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_armpackage names its arms (frames.arms[]— base, flange, TCP and joints per arm), andbt.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/rightforleft_shoulder…andright_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 groupsleftandright;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.rampon 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
attachdefaults 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.