MjSpec is the editable form of a model: the tree of bodies, geoms, joints, actuators and the rest, with defaults and names, before compilation turns it into the flat arrays of mjModel. Lesson 1.1 placed it on the road from XML to simulation; here you drive on it.
Use MjSpec when a model is easier to describe with a loop than with a file (towers, grids, randomized clutter, robot variants), when you want to compose models from parts in code, or when you need to change the structure of a model (add or remove bodies, change joint types, change geom shapes). Changing parameters of an existing model (friction, mass, gains) is faster on mjModel directly (Lesson 1.1); changing structure is only possible on the spec.
[!established] Three entry points
mujoco.MjSpec()creates an empty spec;mujoco.MjSpec.from_file(path)andmujoco.MjSpec.from_string(xml)parse MJCF into one.spec.compile()returns anMjModel;spec.to_xml()returns MJCF text;spec.recompile(model, data)returns a newMjModelandMjDatawith the state carried over. The Python documentation notes this last difference from the C API, wheremj_recompilemodifies its arguments in place: Python returns new objects “to avoid dangling references”.
The lab runs a tower built by the script below and written out with spec.to_xml(). Watch the top box’s height: the tower is stable, but it settles by a few millimetres as six soft contacts compress.
```lab simlab
{“dock”: true, “title”: “A tower built in code”, “xml”: “<mujoco model="tower_6"><compiler angle="radian"/><option timestep="0.002"/>
## Implementation
```io
INPUT: nothing for the tower; `cube_table.xml` for the edits
PROCESS: build a tower in code; edit a loaded model; add a body to a running simulation with recompile; read values with bind
OUTPUT: printed checks and the generated MJCF
```python file=examples/l3_3_mjspec.py “"”Lesson 3.3: building and editing models in code with MjSpec.
INPUT nothing for the procedural tower; cube_table.xml for the edits PROCESS (1) build a tower of boxes from code, compile, simulate, check it stands; (2) load an existing model as a spec, change a geom and add a body; (3) add a body to a running simulation with spec.recompile, keeping state; (4) read simulation values for spec elements with data.bind OUTPUT printed checks and the first lines of the generated MJCF
Run: python examples/l3_3_mjspec.py “””
import mujoco import numpy as np
from mjcourse import model_path
BOX = mujoco.mjtGeom.mjGEOM_BOX
def build_tower(n: int, half: float = 0.03) -> mujoco.MjSpec: spec = mujoco.MjSpec() spec.modelname = f”tower_{n}” spec.compiler.degree = False # angles in radians, as in every course model spec.option.timestep = 0.002 world = spec.worldbody world.add_light(pos=[0, -1, 2], dir=[0, 0.5, -1]) world.add_geom(name=”floor”, type=mujoco.mjtGeom.mjGEOM_PLANE, size=[1, 1, 0.05]) for k in range(n): body = world.add_body(name=f”box{k}”, pos=[0, 0, half + 2 * half * k]) body.add_freejoint() body.add_geom(name=f”box{k}”, type=BOX, size=[half] * 3, mass=0.1, rgba=[0.2 + 0.8 * k / max(n - 1, 1), 0.4, 0.8, 1]) return spec
def tower() -> None: spec = build_tower(6) model = spec.compile() data = mujoco.MjData(model) mujoco.mj_forward(model, data) # xpos is only valid after forward kinematics top0 = data.body(“box5”).xpos.copy() for _ in range(1000): mujoco.mj_step(model, data) drift = np.linalg.norm(data.body(“box5”).xpos - top0) print(f” 6-box tower: nbody={model.nbody}, top box moved {1e3 * drift:.3f} mm in 2 s”) print(“ generated MJCF, first lines:”) for line in spec.to_xml().splitlines()[:6]: print(“ “ + line)
def edit_existing() -> None: spec = mujoco.MjSpec.from_file(str(model_path(“cube_table”))) spec.geom(“rest_cube”).friction = [0.2, 0.005, 0.0001] ball = spec.worldbody.add_body(name=”ball”, pos=[0, 0.2, 0.3]) ball.add_freejoint() ball.add_geom(name=”ball”, type=mujoco.mjtGeom.mjGEOM_SPHERE, size=[0.03], mass=0.05) model = spec.compile() print(f” edited cube_table: nbody {model.nbody}, rest_cube friction {model.geom(‘rest_cube’).friction}”)
def recompile_keeps_state() -> None: spec = mujoco.MjSpec.from_file(str(model_path(“cube_table”))) model = spec.compile() data = mujoco.MjData(model) for _ in range(400): mujoco.mj_step(model, data) t, drop_z = data.time, data.body(“drop_cube”).xpos[2] extra = spec.worldbody.add_body(name=”extra”, pos=[0, -0.3, 0.5]) extra.add_freejoint() extra.add_geom(type=mujoco.mjtGeom.mjGEOM_SPHERE, size=[0.04], mass=0.1) model, data = spec.recompile(model, data) # returns new objects in Python mujoco.mj_forward(model, data) print(f” before: t = {t:.3f} s, drop_cube z = {drop_z:.4f} m; after recompile: t = {data.time:.3f} s, “ f”drop_cube z = {data.body(‘drop_cube’).xpos[2]:.4f} m, nbody {model.nbody}”)
bound = data.bind(spec.geom("drop_cube")) # simulation values for a spec element
print(f" data.bind(spec.geom('drop_cube')).xpos = {np.round(bound.xpos, 4)}")
if name == “main”: print(“procedural model:”) tower() print(“editing a loaded model:”) edit_existing() print(“recompiling a running simulation:”) recompile_keeps_state()
Output:
```text
procedural model:
6-box tower: nbody=7, top box moved 2.735 mm in 2 s
generated MJCF, first lines:
<mujoco model="tower_6">
<compiler angle="radian"/>
<worldbody>
<geom name="floor" size="1 1 0.05" type="plane"/>
<light pos="0 -1 2" dir="0 0.447214 -0.894427"/>
editing a loaded model:
edited cube_table: nbody 5, rest_cube friction [2.e-01 5.e-03 1.e-04]
recompiling a running simulation:
before: t = 0.800 s, drop_cube z = 0.0399 m; after recompile: t = 0.800 s, drop_cube z = 0.0399 m, nbody 5
data.bind(spec.geom('drop_cube')).xpos = [-0.1609 0.0142 0.0399]
The recompiled simulation continues from where it was: time, the dropped cube’s position, everything that existed before. The new body starts at its default pose. The bind call is the bridge between the two worlds: it gives you, for an element of the spec, the corresponding rows of mjModel (model.bind(...)) or mjData (data.bind(...)) without looking up an index.
[!implementation] Units in a spec
spec.compiler.degree = Falseis the code equivalent of<compiler angle="radian"/>. A spec created from scratch follows the same MJCF default (degrees) unless you change it, and it applies to every angle you set later, includingeulervalues and joint ranges.
Lesson 2.4 used <attach> in MJCF. The spec version is more flexible: you can attach a child model at any frame or site of a parent, including sites inside models that were themselves attached, which MJCF cannot reach. This course’s mjcourse/model_builder.py builds the arm-with-gripper model that way:
```python file=src/mjcourse/model_builder.py “"”Build composed models from hand-written parts with MjSpec.
INPUT models/arm7.xml, models/gripper.xml (hand-written sources) PROCESS MjSpec.attach composes them, MjSpec.to_xml serializes the result OUTPUT models/arm7_gripper.xml and models/bimanual.xml (generated, committed)
The generated files are checked in so the browser runtime, which loads plain
MJCF, sees exactly what Python sees. tests/test_models.py fails if a generated
file is stale, so edit the sources and rerun:
python -m mjcourse.model_builder """
from future import annotations
import argparse import re from pathlib import Path
import mujoco
from mjcourse.paths import MODELS_DIR
HEADER = ( “\n” )
def _with_header(xml: str, sources: str, notes: str) -> str: # to_xml() records the keyframe count in <size nkey=…/>. That element makes # MJCF attachment report a size conflict in every scene that attaches the # generated file, and the compiler recounts keyframes anyway, so drop it. xml = re.sub(r”\n\s*<size nkey="\d+"/>\n”, “\n”, xml) return HEADER.format(sources=sources, notes=notes) + xml
def build_arm7_gripper() -> mujoco.MjSpec:
“"”Mount the parallel gripper on arm7’s flange site.
`attach(..., site=...)` places the child's world body in a new frame at the
site, so the gripper inherits the flange pose. Names gain the "gripper/"
prefix, which keeps them unique if a scene later attaches two arms.
"""
arm = mujoco.MjSpec.from_file(str(MODELS_DIR / "arm7.xml"))
gripper = mujoco.MjSpec.from_file(str(MODELS_DIR / "gripper.xml"))
arm.modelname = "arm7_gripper"
arm.attach(gripper, site="flange", prefix="gripper/")
# The arm's own `ee` site sits where the gripper's tcp is; keep both so code
# written for the bare arm runs unchanged on the gripper model.
# The arm's keyframes list 7 joint values; extend them with an open gripper.
for key in arm.keys:
key.qpos = list(key.qpos)[:7] + [0.04, 0.04]
key.ctrl = [0.0] * 7 + [0.04]
arm.compile() # validates the composition before we serialize it
return arm
def build_arm7_peg() -> mujoco.MjSpec: “"”arm7 holding a cylindrical peg rigidly at the flange, for insertion tasks.
The peg is welded to the flange (no joint): this removes grasp slip from the
problem, so the insertion lessons study contact and compliance control only.
Peg: radius 12 mm, length 100 mm, tip site `tool/peg_tip` at the free end.
The tool is attached with prefix "tool/": an empty prefix would leave the
tool's default class without a name, which MJCF cannot serialize.
"""
arm = mujoco.MjSpec.from_file(str(MODELS_DIR / "arm7.xml"))
arm.modelname = "arm7_peg"
tool = mujoco.MjSpec()
tool.compiler.degree = False
peg = tool.worldbody.add_body(name="peg")
peg.add_geom(name="peg_collar", type=mujoco.mjtGeom.mjGEOM_CYLINDER, size=[0.025, 0.01],
pos=[0, 0, 0.01], mass=0.1, rgba=[0.25, 0.27, 0.3, 1])
peg.add_geom(name="peg", type=mujoco.mjtGeom.mjGEOM_CYLINDER, size=[0.012, 0.05],
pos=[0, 0, 0.07], mass=0.1, rgba=[0.9, 0.55, 0.15, 1],
friction=[0.3, 0.005, 0.0001], condim=3)
peg.add_site(name="peg_tip", pos=[0, 0, 0.12], size=[0.006, 0, 0])
arm.attach(tool, site="flange", prefix="tool/")
arm.compile()
return arm
def build_bimanual() -> mujoco.MjSpec: “"”Two arm7_gripper copies facing each other across a shared table.
The left arm sits at y = +0.62 and the right arm at y = -0.62, both yawed to
face the table. The box is long along y so each gripper can take one end. Each is attached into a frame with a prefix, so every name
is unique: left/j1 ... left/gripper/grip, right/j1 ...
"""
scene = mujoco.MjSpec()
scene.modelname = "bimanual"
scene.compiler.degree = False
scene.option.timestep = 0.002
scene.option.integrator = mujoco.mjtIntegrator.mjINT_IMPLICITFAST
scene.option.cone = mujoco.mjtCone.mjCONE_ELLIPTIC
scene.option.impratio = 10
world = scene.worldbody
world.add_light(pos=[0, 0, 2.5], dir=[0, 0, -1])
world.add_geom(name="floor", type=mujoco.mjtGeom.mjGEOM_PLANE, size=[1.5, 1.5, 0.05],
rgba=[0.86, 0.88, 0.86, 1])
world.add_geom(name="table", type=mujoco.mjtGeom.mjGEOM_BOX, size=[0.2, 0.25, 0.06],
pos=[0.45, 0, 0.06], rgba=[0.55, 0.45, 0.35, 1])
box = world.add_body(name="box", pos=[0.45, 0, 0.15])
box.add_freejoint(name="box")
box.add_geom(name="box", type=mujoco.mjtGeom.mjGEOM_BOX, size=[0.025, 0.16, 0.03],
mass=0.4, rgba=[0.15, 0.45, 0.8, 1])
for side, y, yaw in (("left", 0.62, -1.5707963), ("right", -0.62, 1.5707963)):
arm = build_arm7_gripper()
for key in list(arm.keys): # per-arm keyframes would be padded and misleading
arm.delete(key)
quat = [float(v) for v in _yaw_quat(yaw)]
frame = world.add_frame(name=f"{side}_mount", pos=[0.45, y, 0.0], quat=quat)
frame.attach_body(arm.body("link0"), f"{side}/", "")
scene.add_key(name="home", qpos=_bimanual_home())
scene.compile()
return scene
def _yaw_quat(yaw: float): import math
return (math.cos(yaw / 2), 0.0, 0.0, math.sin(yaw / 2))
def _bimanual_home() -> list[float]: arm_home = [0, 0.2, 0, 1.9, 0, 1.0416, 0, 0.04, 0.04] box_free = [0.45, 0, 0.15, 1, 0, 0, 0] # qpos follows body order in the tree: the box was added first. return box_free + arm_home + arm_home
def write_all(out_dir: Path = MODELS_DIR) -> dict[str, str]: “"”Build every composed model and return {filename: xml}; write them to out_dir.””” outputs = { “arm7_gripper.xml”: _with_header( build_arm7_gripper().to_xml(), “arm7.xml + gripper.xml”, “ ctrl layout (8): a1..a7 joint torques (N m), then gripper/grip half-opening (m).\n”), “arm7_peg.xml”: _with_header( build_arm7_peg().to_xml(), “arm7.xml + a peg tool built in model_builder.py”, “ ctrl layout (7): a1..a7 joint torques (N m). The peg is rigidly fixed to the flange.\n”), “bimanual.xml”: _with_header( build_bimanual().to_xml(), “arm7.xml + gripper.xml (twice)”, “ ctrl layout (16): left/a1..a7, left/gripper/grip, right/a1..a7, right/gripper/grip.\n”), } for name, xml in outputs.items(): (out_dir / name).write_text(xml) return outputs
def main() -> None: parser = argparse.ArgumentParser(description=doc.splitlines()[0]) parser.add_argument(“–out”, type=Path, default=MODELS_DIR) args = parser.parse_args() for name in write_all(args.out): print(f”wrote {args.out / name}”)
if name == “main”: main()
Four details in that file are lessons learned while building it, each of which cost a failed compile or a wrong pose:
1. A keyframe written for the 7-joint arm has the wrong length once a 2-joint gripper is attached; the builder extends it.
2. `to_xml()` emits `<size nkey="..."/>`, which then caused an attachment conflict warning in every scene; the builder strips it.
3. An empty prefix for an attached tool spec leaves its default class without a name, which MJCF cannot serialize; the peg is attached with prefix `tool/`.
4. In the bimanual scene, `qpos` follows the order in which bodies were added, so the box (added first) comes before both arms in the keyframe.
## Debugging
**`spec.compile()` raises, but `spec.to_xml()` works.** `to_xml` serializes what you built, valid or not; `compile` validates it. Compile early and often while building a spec in a loop.
**`data.bind(...)` raises after an edit.** The documentation states that `bind` needs the model and data to be compiled from the current spec; if you added or removed elements since, recompile first.
**Changes to the spec do not show up in the running simulation.** They will not until you recompile; a spec and a compiled model are separate objects.
## Exercise
Write `build_shelf(rows, cols)` that returns a spec of a shelf (static boxes) with a free object on each compartment floor, with names `obj_r{r}_c{c}`. Compile for (2, 3) and (4, 5), and check with a test that every object rests on its shelf after 1 s.
## Challenge
Generate 100 tower variants with randomized box sizes, masses and friction from a seeded generator, simulate each for 3 s, and record which ones collapse. Write each variant's XML only for the collapsed ones. Then show that regenerating from the same seed reproduces exactly the same set of collapses.
## Research connection
Procedural generation is how benchmarks get breadth: randomized object sets, layouts and distractors for generalization tests (Level 21.5). The scientific requirement is that a generated environment is a pure function of (generator version, seed). Log both with every result, and keep the generated XML of anything you report.
```quiz
{"id": "3.3-check", "title": "Knowledge check", "questions": [
{"kind": "mcq", "q": "You want to change the friction of every cube between training episodes, as fast as possible. Which do you edit?",
"options": ["The <code>MjSpec</code>, then recompile", "<code>model.geom_friction</code> directly", "The XML file", "<code>data.contact</code>"],
"answer": 1,
"explain": "<p>Friction is a real-valued parameter that is safe to change on <code>mjModel</code> directly (Lesson 1.1). Recompiling is reserved for structural changes.</p>"},
{"kind": "mcq", "q": "What does <code>spec.recompile(model, data)</code> return in Python?",
"options": ["Nothing; it modifies model and data in place", "A new MjModel and a new MjData with the state carried over", "A new MjSpec", "Only a new MjModel"],
"answer": 1,
"explain": "<p>Python returns new objects to avoid dangling references; the C function modifies in place.</p>"},
{"kind": "open", "q": "Why does this course commit the generated <code>arm7_gripper.xml</code> instead of generating it at load time in the browser?",
"reference": "<p>The browser runtime loads plain MJCF through the WebAssembly bindings, whose <code>mjspec</code> functions the bindings' own README calls untested in real web applications; committing the generated file means the browser and Python see byte-identical models, and a test fails if the committed file goes stale. The cost is a generated file in the repository and a build step to remember.</p>"}
]}
Lesson 3.4 looks at the JavaScript bindings this course runs on, with the gotchas found while building it.