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. 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 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 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.
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. Packages whose meshes
cannot be redistributed are recipe_only: from_catalog raises and points at
building them locally with botrail-catalog-builder.
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("ur5e")
coupling = bt.Robot.from_catalog("gripper-coupling")
gripper = bt.Robot.from_catalog("2f-85")
robot = arm.attach_tool(coupling).attach_tool(gripper) # frames from the manifests
robot.dof # 6 + 1
robot.tcp_link # the gripper's declared TCP — IK now 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:
robot = arm.attach_tool(
gripper,
flange="flange", # arm-side link (ISO 9409-1 face)
mount="robotiq_arg2f_base_link", # tool-side link — its root
offset_position=(0, 0, 0.0139), # e.g. the coupling's thickness
)
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.