Core concept

Real scenes are assembled from parts: a robot, a gripper, a table, objects, cameras. Writing each scene as one long file means copying the robot into every scene, and every copy drifts. MJCF has two composition mechanisms, and they work at different times.

<include file="part.xml"/> works in the parser. It is literally text inclusion: the children of the included file’s <mujoco> element are pasted where the include stands. No renaming happens, so including the same robot twice produces duplicate names and a compile error.

<attach> works in the compiler. You declare another model as an asset, <asset><model name="gripper" file="gripper.xml"/></asset>, and then place a body (or a frame, or the whole world body) of it anywhere in your tree with <attach model="gripper" body="hand" prefix="gripper/"/>. Everything the attached subtree refers to (its defaults, assets, actuators, sensors, tendons, equalities) is copied in, and every name gets the prefix. The documentation describes attach as a subset of the procedural attachment available through mjSpec, which Lesson 3.3 uses.

This course uses both mechanisms in earnest. gantry_gripper.xml attaches gripper.xml in MJCF. The pick-and-place, push, drawer and peg scenes attach a whole arm with an empty prefix, which keeps its joint names (j1, …, j7) and brings its keyframes. arm7_gripper.xml and bimanual.xml are generated in Python with MjSpec.attach by mjcourse/model_builder.py, and a test fails if the committed files go stale.

Visual intuition

The lab loads the bimanual cell: two copies of arm7_gripper.xml, attached with prefixes left/ and right/, facing each other across a table. Open the Actuators tab of the inspector in the parameter panel: sixteen actuators, every name prefixed.

```lab playground {“dock”: true, “model”: “bimanual”, “height”: 260, “editorHeight”: 200, “title”: “A composed scene”}


## Composition rules worth knowing

> [!established] Prefixes are required, and they rename everything
> The `prefix` attribute of `attach` is required. It is prepended to the names of all elements copied from the child model: bodies, joints, geoms, sites, actuators, sensors, tendons, equalities and default classes. This is what makes attaching the same model twice possible. An empty prefix is allowed, and it is useful when a scene attaches one robot and wants to keep its names, as this course's single-arm scenes do.

> [!implementation] Keyframes come along, padded
> When a child model has keyframes, they are copied into the parent and padded with the defaults of everything else (for example, free objects in the scene keep their `qpos0`). The documentation notes a limitation: with several attachments, the keyframes of earlier attachments can be lost unless the model is compiled in between, which is one reason this course's bimanual scene deletes the per-arm keyframes and defines its own.

> [!version] Attachment conflicts, 3.10 onward
> Since 3.10, `compiler/conflict` controls what happens when a child model's global settings (options, sizes) disagree with the parent's: `"warning"` (the default; the parent wins and a warning is printed), `"merge"` or `"error"`. The documentation announces that the default will change to `"merge"` in a future release. This course met the warning once: a generated file carried `<size nkey="2"/>`, every scene that attached it printed a conflict, and the generator now strips that element.

## Reading compiler errors

MuJoCo's compiler errors are short and specific, and they always end with the element and, for XML, the line. Here is a catalogue of common ones, produced by compiling broken snippets:

```io
INPUT: the composed `gantry_gripper.xml`, eight deliberately broken snippets, and `pendulum.xml`
PROCESS: list the attached names; compile each snippet; change a compiled parameter and save the model
OUTPUT: names, verbatim error messages, and the saved XML element

```python file=examples/l2_4_compose_and_errors.py “"”Lesson 2.4: composing models, reading compiler errors, seeing the compiled model.

INPUT gantry_gripper.xml (composed with ) and a list of broken MJCF snippets PROCESS (1) list the names the attachment produced and where they came from; (2) compile each broken snippet and print MuJoCo's own error message; (3) change a parameter of the compiled model and save it back to MJCF OUTPUT the names, the error catalogue, and the saved element

Run: python examples/l2_4_compose_and_errors.py “””

import tempfile from pathlib import Path

import mujoco

from mjcourse import model_path

BROKEN = { “unknown element”: “”, “unknown attribute”: ‘’, “too few numbers”: ‘<body pos="0 0"></body>’, “duplicate name”: ‘’, “undefined joint”: ‘’, “undefined class”: ‘’, “wrong keyframe length”: ‘<body></body>' '’, “missing mesh file”: ‘’, }

def attachment() -> None: model = mujoco.MjModel.from_xml_path(str(model_path(“gantry_gripper”))) joints = [model.joint(j).name for j in range(model.njnt)] acts = [model.actuator(a).name for a in range(model.nu)] print(“ joints: “, joints) print(“ actuators:”, acts) print(“ tendons: “, [model.tendon(t).name for t in range(model.ntendon)], “ equalities:”, [model.eq(e).name for e in range(model.neq)])

def errors() -> None: for label, xml in BROKEN.items(): try: mujoco.MjModel.from_xml_string(xml) print(f” {label:<22s} compiled (unexpected)”) except ValueError as err: first = str(err).strip().splitlines() print(f” {label:<22s} {first[0]}”) for line in first[1:3]: print(f” {‘’:<22s} {line}”)

def save_runtime_change() -> None: “"”mj_saveLastXML copies real-valued mjModel parameters back into the XML it writes.””” model = mujoco.MjModel.from_xml_path(str(model_path(“pendulum”))) model.dof_damping[0] = 0.25 # a change made to the compiled model at run time with tempfile.TemporaryDirectory() as tmp: out = Path(tmp) / “saved.xml” mujoco.mj_saveLastXML(str(out), model) saved = out.read_text() joint_line = next(ln.strip() for ln in saved.splitlines() if “<joint” in ln) print(f” saved joint element: {joint_line}”) print(f” comments kept: {‘<!–’ in saved}; lines in source {len(model_path(‘pendulum’).read_text().splitlines())},” f” in saved file {len(saved.splitlines())}”)

if name == “main”: print(“names created by <attach prefix="gripper/">:”) attachment() print(“compiler errors, verbatim:”) errors() print(“saving a model changed at run time:”) save_runtime_change()

Output with MuJoCo 3.14.0:

```text
names created by <attach prefix="gripper/">:
  joints:    ['gx', 'gy', 'gz', 'gripper/finger_left', 'gripper/finger_right', 'cube']
  actuators: ['gx', 'gy', 'gz', 'gripper/grip']
  tendons:   ['gripper/grip']  equalities: ['gripper/mirror']
compiler errors, verbatim:
  unknown element        XML Error: Schema violation: unrecognized element
                         Element 'bod', line 1
  unknown attribute      XML Error: Schema violation: unrecognized attribute: 'colour'
                         Element 'geom', line 1
  too few numbers        XML Error: attribute 'pos' does not have enough data
                         Element 'body', line 1
  duplicate name         XML Error: Error: repeated name 'a' in geom
                         Element 'geom', line 1
  undefined joint        Error: unknown transmission target 'nope' for actuator id = 0
                         Element name '', id 0, line 1
  undefined class        XML Error: unknown default class name 'nope'
                         Element 'geom', line 1
  wrong keyframe length  Error: keyframe '': invalid qpos size, expected 1, got 2
                         Element name '', id 0
  missing mesh file      Error: Error opening file 'nope.stl'
saving a model changed at run time:
  saved joint element: <joint name="hinge" damping="0.25" axis="0 1 0"/>
  comments kept: False; lines in source 54, in saved file 38

The error messages fall into two families. XML Error: comes from the parser and names the XML element and line: a typo, a wrong attribute name, a missing number. Error: without the XML prefix comes from the compiler, after parsing, and names the model element and its index: a reference to something that does not exist, an inconsistent size, a physically impossible inertia. The playground shows the same messages when a model fails to compile.

[!established] Saving the compiled model mj_saveLastXML writes the last model compiled from XML back to MJCF, after copying the real-valued parameters of the given mjModel into it. The documentation describes this mechanism as “reasonable but not perfect”: it covers real-valued parameters, not every change a user could make, and the only way to save everything is MuJoCo’s binary format (mj_saveModel) or, better, making the change in the XML or the mjSpec itself. Comments are not preserved.

Debugging

repeated name after an include. You included the same part twice. Use attach with two prefixes.

Assets not found after attaching. Since 3.8, asset paths in an attached child model are resolved relative to the child model’s own file, not the parent’s. If a child refers to meshes/link.stl, that path is relative to the child’s directory.

A name you expected is missing. Print the names the compiler produced (as the script does) and look for the prefix. Named access with a wrong name raises a KeyError whose message lists every valid name of that type, which is the fastest way to discover a prefix: on the gantry scene, model.joint("finger_left") fails with Invalid name 'finger_left'. Valid names: ['cube', 'gripper/finger_left', ...].

Exercise

Write a scene file that attaches arm7.xml twice, with prefixes a/ and b/, placing the second copy 1 m away along $y$ inside a <frame>, plus a free cube between them. Compile it, and print nq, nv, nu and the first three joint names. Predict all six numbers first.

Challenge

Reproduce mjcourse/model_builder.py’s construction of arm7_gripper.xml in pure MJCF, without Python. Explain what MJCF cannot express here that MjSpec.attach(..., site="flange") can, and how the course works around it.

Research connection

Reproducible research needs reproducible models. If your scene is generated (randomized object sets, procedurally placed obstacles), save the generated MJCF with every experiment, or store the generator and its seed and test that regeneration is bit-identical. A test like this course’s test_generated_models_are_current is cheap and catches the most common reproducibility break: an edited source file whose generated artefacts were not rebuilt.

{"id": "2.4-check", "title": "Knowledge check", "questions": [
  {"kind": "mcq", "q": "You need two copies of the same gripper in one scene. Which mechanism works without editing the gripper file?",
   "options": ["<code>include</code> twice", "<code>attach</code> twice with different prefixes", "<code>include</code> once and copy the bodies", "Neither: MuJoCo cannot have two identical sub-models"],
   "answer": 1,
   "explain": "<p><code>include</code> pastes text, so names collide. <code>attach</code> renames everything with the prefix.</p>"},
  {"kind": "mcq", "q": "Which message comes from the compiler rather than the XML parser?",
   "options": ["<code>Schema violation: unrecognized attribute</code>", "<code>attribute 'pos' does not have enough data</code>", "<code>unknown transmission target 'nope' for actuator id = 0</code>", "<code>unrecognized element</code>"],
   "answer": 2,
   "explain": "<p>Resolving the actuator's target needs the whole model, so it happens at compile time. The others are syntax-level checks made while parsing.</p>"},
  {"kind": "open", "q": "A colleague randomizes friction in the compiled model during training and wants to save the final model to XML for evaluation. What do you tell them?",
   "reference": "<p><code>mj_saveLastXML</code> will write the changed real-valued parameters (friction is one), so it works for this case, but it writes the randomized values of the moment of saving, not the randomization distribution, and drops comments. For evaluation it is better to save the nominal model and the randomization ranges and seed separately, so that evaluation can use either the nominal model or a fresh, reproducible draw.</p>"}
]}

Next

Level 3 drives MuJoCo from Python in earnest, starting with named access, and with the views-versus-copies distinction that silently corrupts more logs than any other bug.