Skip to content

The robot description: one source of truth

Stage 4 · Model and simulation · ~30 min · Before this: 01 Degrees of freedom, 04 Actuators, 07 Mass budget · numbers from the repository as of 2026-11-02

Every simulator, every training run and every check needs the same robot: the same joints, limits, masses and motors. Marvin Bot writes that robot down once, in a handful of YAML files, and generates everything else from them, so the models cannot quietly drift apart.

Think of it like a recipe kept in one master cookbook. If three cooks each copied the recipe by hand and one of them changed “200 g sugar” to “250 g”, you would get three slightly different cakes and never know why. Before anything can be simulated or trained, the software needs a written-down robot. This page is about keeping that one written-down robot correct, and why that matters before a single motor turns.

A robot description is a file that tells software what the robot is made of: rigid bodies called links, connected by joints, each link with a mass, a centre of mass and an inertia tensor, plus shapes for drawing (visual) and for touching things (collision). (An inertia tensor is just a small table of numbers saying how hard the link is to spin about each direction; more below.) Different tools want different file formats. ROS (Robot Operating System, a widely used open-source robot software toolkit) and most robotics tools read URDF; MuJoCo reads MJCF; Isaac Sim and Isaac Lab load USD.

If you write these by hand you get three robots that are almost, but not quite, the same. A knee limit changed in one file and not the others is enough to make a policy (the trained neural-network controller of page 16) trained in one simulator fail in another. Marvin Bot avoids this by treating all of them as build products: files that a program writes from the YAML, never edited by hand.

How to read this picture: the five boxes on the left are the hand-edited source files; each arrow is a program (named in code) that turns one file into the next. Only the YAML files are ever edited by a person.

robot.yaml joints, ranges, motors, busesactuators.yaml torque, speed, massmass_budget.yamlskeleton.yaml frames, axes, boxescad_model.yaml from the code-CAD → tools/build_urdf.py →URDF → MuJoCo loads it, adds motors →MJCF (in memory) → sim/isaaclab/convert_urdf.py →USD

Two explorers below make the important details concrete: the shoulder’s coupled joint limit, which no simulator file format can express, and a one-joint version of the generator, where you can break things and see which check would catch you.

Marvin Bot’s shoulder roll may go below 0° (bringing the hands to the midline, for clapping or holding a box) only while the arm is swung forward; with the arm hanging, negative roll would push the upper arm into the torso. description/robot.yaml writes the lowest allowed roll as a piecewise-linear function of shoulder pitch (a graph made of straight-line pieces joined at corners). Drag the dot, or use the sliders.

In plain words: shoulder pitch swings the arm forwards and up (0° = hanging, 90° = pointing straight ahead); shoulder roll lifts the arm out sideways (positive) or brings it in across the chest (negative). Start with the Clap preset.

Presets

Pose (degrees, robot.yaml convention)

+ = arm forward and up (flexion). Range −60 to 180.
+ = arm out to the side (abduction). Range −20 to 120, below 0 only with the arm forward.

View

The clamp raises the roll to the coupled bound, which is what the robot's controller has to do with any commanded pose.
Lowest roll allowed here
–
This pose
–
Gap between the hands*
–
Hand forward / height
–

How to read this picture: the top chart is a map of every possible shoulder pose (pitch across, roll up). The dot is your pose. Green is safe; red is the trap: a simulator with a single −20° limit would allow it, but the real arm would hit the chest. The stick figures below show the same pose from the front and the side.

allowed on the robotallowed by a plain −20° limit, illegal on the robotoutside the joint rangesthe coupled bound

*Both arms in the same (mirrored) pose, elbows straight, arm 0.569 m from shoulder axis to fingertips (upper arm 0.200 + forearm 0.188 + hand 0.181), shoulder pitch axes 0.158 m from the midline (skeleton.yaml). Negative gap: the hands would overlap. Front view drawn as seen facing the robot.

A humanoid is a tree of rigid links. The root is the pelvis; each joint connects a parent link to a child link at a fixed offset (the joint’s origin) and lets the child rotate about one axis. Knowing every joint angle and walking down the tree, multiplying the transforms, gives every link’s position: this is forward kinematics.[9] Marvin Bot has 27 powered joints: 6 per leg, 6 per arm, waist yaw, neck yaw and neck pitch.

To simulate motion, not just draw it, each link needs three things: its mass m (kg), the position of its centre of mass, and its 3 × 3 inertia tensor I (kg·m²), which says how hard it is to spin the link about each axis. For a solid box of mass m and sides a, b, c, the inertia about the axis along a is m(b² + c²)/12. When several parts share a link, their inertias are added after shifting each to the common centre of mass with the parallel-axis theorem (I + m d², d the shift). tools/build_urdf.py does exactly this, in its combine() function, for the CAD parts, the estimated boxes and the motor housings on each link.

A link can carry two kinds of shape. Visual geometry is only drawn; it can be a detailed mesh. Collision geometry is what the physics engine tests for contact, every step, for every robot. It should be simple (boxes, capsules, convex hulls) because contact detection is a large share of simulation cost and detailed meshes make contacts noisy. Marvin Bot’s collision model is 21 boxes (13 in skeleton.yaml, the left-side ones mirrored): feet, shins, thighs, pelvis, torso, head, arms and hands; its visuals are the CAD meshes.

URDF (Unified Robot Description Format) is the XML format of ROS.[1][2] A <link> holds <inertial> (origin, mass, the six independent inertia values ixx ixy ixz iyy iyz izz), any number of <visual> and <collision> elements. A <joint> names its type (revolute for a hinge with limits), its parent and child links, an <origin>, an <axis> and a <limit> with lower and upper in radians, effort (maximum torque, N·m) and velocity (maximum speed, rad/s); for a revolute joint the limit element is required.[1] URDF has no place for motor gains, rotor inertia, contact softness or a limit that depends on another joint.

MJCF is MuJoCo’s XML format.[3] Bodies nest inside each other (the tree is the XML nesting), a hinge <joint> lives inside the child body, and actuators are separate elements: <motor> applies a commanded torque, <position> is a built-in PD servo (a spring-and-damper pull towards a target angle, see page 15) with gains kp and kv. MJCF adds what URDF lacks:

  • armature: extra inertia added to a joint, used for the spinning motor rotor that the gearbox makes feel heavier (page 15);
  • frictionloss: dry joint friction, and actuatorfrcrange: the most torque a motor may apply;
  • contype/conaffinity: per-shape on/off switches that decide which shapes may collide;
  • solref, solimp: how soft or hard contacts feel.

MuJoCo also loads URDF directly; angles in MJCF are degrees by default unless <compiler angle="radian"/> is given, while URDF is always radians.[3][4] MuJoCo has no joint velocity limit: speed has to be limited in the motor model.

USD (Universal Scene Description, from Pixar, now OpenUSD) is a general 3-D scene format; NVIDIA’s physics schemas add rigid bodies, joints and articulations to it.[6] Isaac Lab ships a URDF converter that runs Isaac Sim’s URDF importer and writes USD.[7] Since Isaac Lab 3.0 most workflows run without Isaac Sim, but the importer still needs it.[8]

“Knee 15°” means nothing until you say which way is positive. robot.yaml uses anatomical names on the left side: pitch + = flexion (thigh or arm forward, knee bent, toes up), roll + = abduction (limb away from the midline), yaw + = towards the midline (toes or hand turning in). The physical axis is then chosen in skeleton.yaml so that the right-hand rule gives that direction. (Right-hand rule: point your right thumb along the axis; your curled fingers show which way a positive angle turns.) The knee’s axis is +y (rotating the shin about +y swings the foot backwards), the hip pitch’s is −y (the thigh swings forward), a left roll axis is +x and a left yaw axis is −z.

The right side is generated from the left by negating every y coordinate. For an axis that is a little subtler: a mirror flips the handedness of rotations, so pitch axes (along y) stay as they are, while roll (x) and yaw (z) axes are negated (mirror_axis() in build_urdf.py). The result: “left knee 30°” and “right knee 30°” both bend the knee, and “hip roll +10°” moves either leg outward.

Why care? Training doubles its data by mirroring: every left-leg step it sees can also be used as a right-leg step. Because the axes are mirrored, training can mirror a pose very simply: swap the left and right joint values unchanged, and negate only the centre-line yaw joints, waist yaw and neck yaw, which have no partner (FLIP_SIGN in sim/isaaclab/marvin_bot/tasks/symmetry.py). A limb axis mirrored wrongly breaks that rule silently: nothing crashes, but training is quietly fed mirrored poses that are wrong.

sim/mujoco/check_symmetry.py checks this with 20 random poses: it mirrors each pose by the rule, computes every link position with MuJoCo, and requires each link to land on its partner’s mirror image within 1 µm.

The coupled limit, and why simulators ignore it

Section titled “The coupled limit, and why simulators ignore it”

Every format above stores a joint’s range as two numbers, independent of the other joints. Shoulder roll’s real limit depends on shoulder pitch, so the formats cannot hold it. What each piece of software does:

  • Simulators (MuJoCo, Isaac Lab on Newton or PhysX) use −20° as a plain lower limit and rely on the collision boxes: an arm pushed into the chest meets the torso box and stops.
  • The CAD check (cad.covers --check) tests every point of the bound and the midpoints between them for part clearance.
  • The controller on the robot must clamp commands to the bound before they reach the motors, because collision boxes are only approximate and the real shell is not a box.

The lowest roll at pitch p is linear between the points (20°, 0°), (45°, −10°), (75°, −10°), (90°, −20°), (135°, −20°), (150°, 0°) and 0° outside them. For example at p = 60°: −10°; at p = 30°: 0 + (30 − 20)/(45 − 20) × (−10) = −4°.

Playground B: one joint from YAML to URDF and MJCF

Section titled “Playground B: one joint from YAML to URDF and MJCF”

Pick a joint, then edit its entries in the mini description on the left. The panel on the right regenerates the URDF joint (as tools/build_urdf.py writes it, both sides) and the MuJoCo version, and lists the checks the repo’s tools would run on it. The highlighted fields are editable.

What the less obvious fields mean: model is which motor drives the joint (page 04); bus is which communication cable it hangs on (page 10); axis is the direction the joint turns about; kp, kd are the stiffness and damping of the joint’s controller, armature the motor’s spinning inertia and friction its dry friction (all on page 15). Start with A broken one: the Checks list goes red, and each line tells you which repo tool would catch the mistake. Green = passes, amber = suspicious, red = the build would fail.

# description/robot.yaml · joints
- {name: "{side}_", axis: , range: [, ]}
# description/robot.yaml · motors
- {name: "{side}_…", model: , bus: , drives: [this joint]}
# description/skeleton.yaml · joints (left side; right is mirrored)
left_…: {parent: , xyz: , axis: }
# sim/isaaclab/marvin_bot/robot_cfg.py · GAINS (kp, kd, armature, friction)
"…": (, , , )
# what-if: hand-edit the URDF effort (blank = generated)
effort override: N·m
# what-if: how the right side's axis is made
right side:

Checks

    
    
    effort (URDF)
    –
    velocity (URDF)
    –
    lower / upper (rad)
    –
    checks failing
    –
    Approach For Against
    Hand-written URDF (common in open-source robots) No tooling; every ROS tool reads it Masses, limits and motors copied by hand; nothing stops the URDF disagreeing with the CAD or the BOM
    URDF exported from a CAD package Inertia straight from the CAD Needs the CAD tool; motor data and limits are added by hand afterwards; regenerating loses hand edits
    Hand-written MJCF as master MJCF holds more (armature, contact, actuators) Isaac Lab and ROS want URDF or USD; converting MJCF the other way loses information
    YAML source + generated URDF (Marvin Bot) One place to change; generator checks lengths and masses; CI fails if anything is stale; BOM and CAN load are checked against the same file A custom generator to maintain; simulator-specific details (gains, armature, contact) live in code next to each simulator
    Xacro macros (ROS) Standard, mirrors left/right with parameters Still a URDF dialect; no link to the motor data, BOM or checks

    The coupled limit is a smaller trade-off of the same kind. Writing it as a plain −30° limit was tried and rejected: at −30° the shoulder yaw motor meets the chest shell at every forward angle (docs/decisions.md). Enforcing the bound in the simulator itself (for example with a custom constraint) is possible in MuJoCo but would have to be repeated for each backend; the repo instead relies on the collision boxes in simulation and leaves the exact clamp to the controller.

    • Where a new constraint lives: in the description (and so in every model) or in one simulator’s code. Rule of thumb: geometry, mass and limits in description/; gains, contact and solver settings beside each simulator. The trade: putting it in the description means every model gets it automatically, but the generator has to learn a new field; putting it in one simulator is quicker but the others will not know about it.
    • Whether a joint needs a coupled limit, and who enforces it on hardware (the controller’s command clamp) and in training (collisions, or a penalty). The trade: a plain limit of 0° (no roll inwards at all) is simple and safe everywhere but takes away the clapping and box-holding poses; the coupled limit keeps them but someone must write and test the clamp.
    • Whether a new joint’s sign follows the anatomical convention, and whether it belongs in FLIP_SIGN. Breaking the convention costs nothing today but makes every future mirror, retarget and log harder to read.
    Your decision note

    Built from your settings above. Paste it into an issue, a design discussion or an AI assistant session.

    1. ROS wiki, urdf/XML/joint — used for: joint types, origin, axis, limit attributes (lower, upper, effort, velocity) and units.
    2. ROS wiki, urdf/XML/link — used for: inertial, visual and collision elements.
    3. MuJoCo documentation, XML Reference — used for: joint armature, frictionloss, actuatorfrcrange, geom contype/conaffinity, position and motor actuators, compiler angle.
    4. MuJoCo documentation, Modeling — used for: MuJoCo loading URDF and the URDF extensions.
    5. RobStride, Product_Information — used for: peak torque and no-load speed behind the effort and velocity limits (via description/actuators.yaml).
    6. OpenUSD, Introduction to USD — used for: what USD is.
    7. Isaac Lab documentation, Importing a New Asset — used for: the URDF converter.
    8. Isaac Lab v3.0.0 release notes (2026-10-20) — used for: Isaac Sim optional in Isaac Lab 3.0, physics backends.
    9. K. M. Lynch and F. C. Park, Modern Robotics, Cambridge University Press 2017 — used for: forward kinematics, rigid-body inertia and the parallel-axis theorem.