zero2robot · Phase 0 · Foundationsch0.2-scene · scene.py

Chapter 0.2

Bodies, Joints, and MJCF

By the end you can

  1. Author a MuJoCo scene in MJCF as a kinematic tree of bodies, each carrying geoms and joints
  2. Understand why the T-block's two geoms live in ONE body — the weld — so they move as a single rigid object
  3. Give a body exactly the degrees of freedom a task needs: a planar (slide-slide-hinge) joint set for the block, slide joints for the pusher
  4. Drive a body with position/velocity actuators, and mark a goal with a collision-free visual site
  5. Read back the compiled scene — kinematic tree, joint types, nq/nv — and prove it behaves with a short deterministic push

See it work

live · P2
Bodies, joints & MJCF — the weld, feltThe T-block is ONE body carrying two welded geoms, so it has exactly three planar degrees of freedom and moves as a single rigid piece. Author it as two bodies instead and it becomes six degrees of freedom that a push pulls apart.target1 body · 3 DOF · rigid↻ toggle the weld → two bodies · then push
toggle the weld, then push · one welded body stays rigid — two bodies come apart · poster reads with JS off
pusherthe agent that pushesT-blockthe thing being pushedtargetwhere it needs to end up

There's a switch on this scene labeled "weld." Leave it on and watch: the red pusher slides north into the blue T-block and shoves it clear across the table, and the T travels like what it is — one solid object, bar and stem locked together. Now flip the switch off and run it again. This time the "T" never even gets assembled: the two rectangles settle with a 0.0912 m gap where the weld should hold them at 0.06 m, sitting as two loose pieces before the pusher has so much as touched them.

Nothing about the physics changed between those two runs. What changed is one decision in the scene description — whether the T's two rectangles are one body or two — and that single decision is most of what this chapter is about. Last chapter you stepped a world someone handed you. This chapter you write the world, and MJCF, MuJoCo's XML for describing worlds, has firm opinions about what's attached to what.

Open in Colabsoon

Free-tier notebook — the button goes live when the course repository is published.

The problem

You can step a simulation now. You did it last chapter: load a model, call mj_step, read qpos. But the model came pre-built — a floor, a cube, a pusher, geometry chosen for you. That's fine exactly once. The moment you want the actual task this book trains on — a T-shaped block, a specific goal pose, a pusher that moves in a plane instead of on a rail — nobody hands you the XML. You write it.

And MJCF is not a drawing. It's a description of a kinematic tree: bodies nested inside bodies, each connected to its parent by joints that spell out how it's allowed to move. Get the tree right and physics does the rest for free — contact, friction, momentum, all of it. Get it wrong in a way that still parses, and you get a scene that runs, produces numbers, and lies to you: a T-block that isn't rigid, a block that tips into the third dimension when it should stay flat, a pusher that can only move along one axis. None of those throw an error. They just quietly do the wrong physics.

So this chapter builds the PushT scene from an empty file — about 150 lines of MJCF and the Python to load it — and then does the thing that separates authoring from guessing: it reads back the tree MuJoCo actually compiled, and pushes the block to prove it moves the way you meant.

Build

The scene grows one part at a time, and the file is organized to match: a region per part, in the order you'd build them if you were setting a table. Ground first, then the things that sit on it.

Setup

One thing to notice up front: there's a single random number generator, and it seeds exactly one thing — where the block starts.

scene.py#setupsha256:a6635c7dc3…
import argparseimport jsonimport sysfrom pathlib import Path import mujocoimport numpy as npimport rerun as rr # Chapter artifacts run as loose scripts from the repo root; put the root on# sys.path so `curriculum.common` resolves (same pattern as ch0.1). device.py# keeps its torch import lazy, so this torch-free chapter stays torch-free.sys.path.insert(0, str(Path(__file__).resolve().parents[3]))from curriculum.common.device import banner  # noqa: E402 parser = argparse.ArgumentParser(description=__doc__)parser.add_argument("--seed", type=int, default=0, help="seeds the block's start pose, like a PushT reset")parser.add_argument("--steps", type=int, default=120)  # nudge length; any laptop: instantparser.add_argument("--smoke", action="store_true", help="fixed-length run for CI; two runs must match byte-for-byte")parser.add_argument("--out", type=Path, default=Path("outputs/ch0.2-scene"))parser.add_argument("--rerun", dest="rerun", action="store_true", default=True)  # recording is the default; opt OUT, not inparser.add_argument("--no-rerun", dest="rerun", action="store_false", help="skip the .rrd recording (CI smoke)")parser.add_argument("--break", dest="break_it", choices=["none", "split-tee"], default="none",                    help="split-tee: author the T as TWO bodies instead of one welded body — watch it settle already split")args = parser.parse_args() banner("ch0.2-scene")  # startup contract: every artifact prints tier + measured wall-clock (to stdout, not metrics.json)settle_steps = 40  # let the seeded start pose come to rest before we pushnudge_steps = 120 if args.smoke else args.steps  # smoke length is FIXED so CI can diff runs exactlyargs.out.mkdir(parents=True, exist_ok=True)rng = np.random.default_rng(args.seed)  # PCG64 — the only source of randomness in this file

The flags follow the convention you saw last chapter: free-tier defaults first, a --smoke mode that runs a fixed length so CI can compare two runs byte-for-byte, and --out for artifacts. Two are new. --break split-tee is the deliberate-failure switch from the hook, and we'll pull it in Break It. And --seed here plays the role a reset plays in a real task: it draws the block's starting position and angle, the way PushT drops the block somewhere new at the start of every episode. Physics itself needs no seed — same scene, same inputs, same trajectory on CPU — so routing that one random choice through one generator is all it takes to keep the whole file reproducible.

Ground

The table is the one geom in this scene that isn't quite what it looks like.

scene.py#groundsha256:23a621a130…
# The table is a plane at z=0 with collision turned OFF (contype/conaffinity 0):# the block never touches it. Planar friction is emulated with joint damping and# frictionloss instead, which keeps the physics quasi-static and CPU-deterministic# (chapter 3.x explains why real 3D contact is the hard case). Four walls box the# block into the +-0.40 m workspace so a hard push can't launch it off the edge.GROUND_MJCF = """    <geom name="table" type="plane" size="0.45 0.45 0.1" rgba="0.90 0.90 0.92 1"          contype="0" conaffinity="0"/>    <geom name="wall_n" type="box" pos="0  0.41 0.03" size="0.43 0.02 0.03" rgba="0.6 0.6 0.6 1"/>    <geom name="wall_s" type="box" pos="0 -0.41 0.03" size="0.43 0.02 0.03" rgba="0.6 0.6 0.6 1"/>    <geom name="wall_e" type="box" pos=" 0.41 0 0.03" size="0.02 0.43 0.03" rgba="0.6 0.6 0.6 1"/>    <geom name="wall_w" type="box" pos="-0.41 0 0.03" size="0.02 0.43 0.03" rgba="0.6 0.6 0.6 1"/>"""

It's a plane at z=0, but its collision is switched off — contype and conaffinity both zero. The block never actually touches it. That sounds backwards until you remember what we're simulating: a flat pushing task, quasi-static, where a real robot would feel sliding friction against a tabletop. Rather than model true 3D contact between block and table — which is genuinely hard, and which chapter 3.x spends three chapters earning — we fake the tabletop friction with joint frictionloss and damping on the block itself, and let the table be a backdrop. The four walls, by contrast, are real collision geometry: they box the block into a 0.40 m workspace so a hard shove can't send it off the edge into the void.

Tee

This is the region the whole chapter turns on, so read it slowly.

scene.py#teesha256:8699813e14…
# The T-block is the whole point of this region. It is ONE body carrying TWO box# geoms — a horizontal bar and a vertical stem, offset so their union is a T.# Because both geoms live in the same body, MuJoCo treats them as a single rigid# object: they share one set of joints and can never move relative to each other.# That shared rigidity IS the weld. The block connects to the world through a# PLANAR joint set — slide-x, slide-y, hinge-yaw — so it has exactly the three# degrees of freedom a flat pushing task needs (glide on the table, spin in# place) and no way to tip up into 3D. qpos for this body is [x, y, yaw].TEE_WELDED_MJCF = """    <body name="tee" pos="0 0 0.0152">      <joint name="tee_x"   type="slide" axis="1 0 0" damping="4"    frictionloss="1.2"/>      <joint name="tee_y"   type="slide" axis="0 1 0" damping="4"    frictionloss="1.2"/>      <joint name="tee_yaw" type="hinge" axis="0 0 1" damping="0.02" frictionloss="0.006"/>      <geom name="tee_bar"  type="box" size="0.06 0.015 0.015" pos="0  0.00 0" rgba="0.45 0.5 0.95 1" mass="0.06"/>      <geom name="tee_stem" type="box" size="0.015 0.045 0.015" pos="0 -0.06 0" rgba="0.45 0.5 0.95 1" mass="0.045"/>    </body>"""# Break It: the SAME two geoms, but each in its own body with its own planar# joints. Nothing welds them now — the stem is a separate rigid object. The reset# below seeds ONLY the bar body's joints (looked up by name); the stem body is# never positioned, so the T can't even be placed as one piece. It settles# already split — gap ~0.09 m, not the welded 0.06 m [measured 2026-07-04] — and# that settled gap is the tell. This is the single most common MJCF authoring# mistake, and the sim below measures exactly how far the halves sit apart.TEE_SPLIT_MJCF = """    <body name="tee" pos="0 0 0.0152">      <joint name="tee_x"   type="slide" axis="1 0 0" damping="4"    frictionloss="1.2"/>      <joint name="tee_y"   type="slide" axis="0 1 0" damping="4"    frictionloss="1.2"/>      <joint name="tee_yaw" type="hinge" axis="0 0 1" damping="0.02" frictionloss="0.006"/>      <geom name="tee_bar"  type="box" size="0.06 0.015 0.015" pos="0 0 0" rgba="0.45 0.5 0.95 1" mass="0.06"/>    </body>    <body name="tee_stem_body" pos="0 -0.06 0.0152">      <joint name="stem_x"   type="slide" axis="1 0 0" damping="4"    frictionloss="1.2"/>      <joint name="stem_y"   type="slide" axis="0 1 0" damping="4"    frictionloss="1.2"/>      <joint name="stem_yaw" type="hinge" axis="0 0 1" damping="0.02" frictionloss="0.006"/>      <geom name="tee_stem" type="box" size="0.015 0.045 0.015" pos="0 0 0" rgba="0.95 0.5 0.45 1" mass="0.045"/>    </body>"""TEE_MJCF = TEE_SPLIT_MJCF if args.break_it == "split-tee" else TEE_WELDED_MJCF

The T-block is one body carrying two geoms — a wide bar and a tall stem, offset so their union looks like a T. That "one body, two geoms" is the entire idea. When two geoms share a body, MuJoCo treats them as a single rigid object: they share one set of joints, and they can never, under any force, move relative to each other. There is no explicit <weld> tag here — the rigidity is a consequence of the tree structure. Putting both geoms in one body is the weld. You'll feel the alternative in Break It, and it's not pretty.

The joints are the other half of the region. The block connects to the world through three of them — a slide along x, a slide along y, and a hinge about the vertical axis — and that specific set is called a planar joint: it grants exactly the three degrees of freedom a flat task needs. The block can glide anywhere on the table and spin in place, and it has no way to lift, tip, or roll, because you gave it no joint that permits those. Its state is three numbers, [x, y, yaw]. Degrees of freedom aren't something physics decides; they're something you grant, one joint at a time, and granting the wrong ones is a bug you'll write more than once (exercise 1 makes you feel it).

Pusher

The pusher is the robot, such as it is, and it's deliberately less capable than the block.

scene.py#pushersha256:2803fa8c9a…
# The pusher is a cylinder on two slide joints — it glides in x and y but, unlike# the block, cannot rotate: you gave it no hinge. The actuators are VELOCITY# servos (kv is the gain): ctrl is a target speed in m/s, not a raw force, so a# policy in chapter 1.1 can command "move at 1 m/s toward the block" and MuJoCo# finds the force. forcerange caps how hard the servo may push. The block's DOFs# are unactuated — it only ever moves because the pusher shoves it.PUSHER_MJCF = """    <body name="pusher" pos="0 0 0.02">      <joint name="pusher_x" type="slide" axis="1 0 0" damping="0.5"/>      <joint name="pusher_y" type="slide" axis="0 1 0" damping="0.5"/>      <geom name="pusher_tip" type="cylinder" size="0.015 0.02" rgba="0.9 0.4 0.35 1" mass="0.2"/>    </body>"""ACTUATOR_MJCF = """    <velocity name="pusher_vx" joint="pusher_x" kv="20" ctrlrange="-1 1" forcerange="-30 30"/>    <velocity name="pusher_vy" joint="pusher_y" kv="20" ctrlrange="-1 1" forcerange="-30 30"/>"""

It's a cylinder on two slide joints — x and y — and notice what's missing: no hinge. The pusher cannot rotate, because you didn't give it a joint that lets it. The block can spin; the pusher can't. That asymmetry is the whole shape of a robot-in-a-world: the block goes wherever physics sends it, but the pusher can only do what its actuators allow.

And the actuators are worth a careful look, because they're velocity servos, not raw motors. ctrl is a target speed in meters per second, and kv is the gain — how hard the servo works to hit that speed. Commanding vy = 1.0 means "try to move north at 1 m/s," and MuJoCo solves for whatever force achieves it, up to the forcerange cap. That's the interface a policy will write to in chapter 1.1: not forces, but desired velocities. Exercise 3 sweeps kv and shows you what a mushy gain does to a push.

Target

The goal is the simplest body in the scene: it does nothing.

scene.py#targetsha256:ee929ad9df…
# The goal pose the task scores against, drawn as a translucent green T. It is a# body with NO joint (welded to the world, so it never moves) and geoms with# collision off — purely something to look at and, later, to measure distance to.# A site marks its exact origin; sites are massless, collision-free reference# frames you attach for exactly this kind of bookkeeping.TARGET_MJCF = """    <body name="target" pos="0 0 0.0005">      <site name="target_site" pos="0 0 0" size="0.005" rgba="0 0 0 0"/>      <geom name="target_bar"  type="box" size="0.06 0.015 0.0005" pos="0  0.00 0"            rgba="0.35 0.8 0.4 0.5" contype="0" conaffinity="0"/>      <geom name="target_stem" type="box" size="0.015 0.045 0.0005" pos="0 -0.06 0"            rgba="0.35 0.8 0.4 0.5" contype="0" conaffinity="0"/>    </body>"""

It's a translucent green T with no joint at all, which means it's welded to the world and never moves, and its geoms have collision switched off, so the block passes right through it. It's there to be looked at and, later, measured against — the task's reward is the distance between the block's pose and this one. The <site> inside it is a massless, collision-free marker: a named reference frame you attach for bookkeeping, and something you'll lean on constantly once chapter 0.3 makes you fluent in frames.

Build

Now the parts become a world.

scene.py#buildsha256:3829f878e9…
# Assemble the regions into one MJCF document. worldbody is the root of the# kinematic tree; everything above drops into it, and the actuators sit in their# own <actuator> block. implicitfast is a stable, cheap integrator for this# quasi-static task; timestep 0.01 s means 100 physics steps per simulated second.SCENE_XML = f"""<mujoco model="pusht_ch02">  <option timestep="0.01" integrator="implicitfast"/>  <worldbody>    <camera name="top" pos="0 0 1.0" quat="1 0 0 0" fovy="50"/>{GROUND_MJCF}{TARGET_MJCF}{TEE_MJCF}{PUSHER_MJCF}  </worldbody>  <actuator>{ACTUATOR_MJCF}  </actuator></mujoco>""" # from_xml_string compiles the text into an mjModel — the frozen world. If the# XML is malformed MuJoCo raises HERE, before a single step runs, which is the# best time to hear about a scene bug.model = mujoco.MjModel.from_xml_string(SCENE_XML)data = mujoco.MjData(model) # Validate: read back the tree MuJoCo actually compiled, not the tree you think# you wrote. Every body's parent, every joint's type, and the state-vector sizes# nq (positions) / nv (velocities) are the ground truth for the whole scene.JOINT_TYPE_NAME = {int(mujoco.mjtJoint.mjJNT_FREE): "free", int(mujoco.mjtJoint.mjJNT_BALL): "ball",                   int(mujoco.mjtJoint.mjJNT_SLIDE): "slide", int(mujoco.mjtJoint.mjJNT_HINGE): "hinge"}joint_types = [JOINT_TYPE_NAME[int(model.jnt(i).type[0])] for i in range(model.njnt)]print(f"kinematic tree: {model.nbody - 1} bodies under worldbody, {model.njnt} joints, nq={model.nq} nv={model.nv}")for i in range(1, model.nbody):  # body 0 is the world; skip it    body = model.body(i)    print(f"  body '{body.name}' <- parent '{model.body(int(body.parentid[0])).name}'")print(f"joint types (in qpos order): {joint_types}") # Set the block's start pose from the seed — the same idea as a PushT reset. Look# the joints up by NAME so this still works when --break split-tee changes the# qpos layout out from under us (the welded T has 3 dofs; the split T has 6).tee_x_adr = int(model.joint("tee_x").qposadr[0])tee_y_adr = int(model.joint("tee_y").qposadr[0])tee_yaw_adr = int(model.joint("tee_yaw").qposadr[0])data.qpos[tee_x_adr] = float(rng.uniform(-0.03, 0.03))data.qpos[tee_y_adr] = float(rng.uniform(0.02, 0.06))data.qpos[tee_yaw_adr] = float(rng.uniform(-0.2, 0.2))data.qpos[int(model.joint("pusher_y").qposadr[0])] = -0.25  # start the pusher south of the blockmujoco.mj_forward(model, data)  # derive body/geom world poses from qpos without stepping time # The weld invariant: the world-space distance between the bar and stem geoms.# In a correctly welded T it is a constant 0.06 m forever, no matter how the# block moves. If it EVER leaves 0.06, the two geoms are NOT rigidly attached —# so the robust rigidity verdict is the max deviation from 0.06 over the whole# run, not the fragile final gap (a split T's halves can drift and then happen to# drift back near 0.06 under the push; the SETTLED and PEAK gaps do not lie).bar_gid, stem_gid = model.geom("tee_bar").id, model.geom("tee_stem").iddef tee_gap() -> float:    return float(np.linalg.norm(data.geom_xpos[bar_gid] - data.geom_xpos[stem_gid])) if args.rerun:    rr.init("zero2robot/ch0.2-scene", spawn=False)    rr.save(str(args.out / "scene.rrd"))    # Shapes never change; log them once as static, then stream only transforms.    rr.log("world/objects/target", rr.Boxes3D(half_sizes=[[0.06, 0.015, 0.001], [0.015, 0.045, 0.001]],           centers=[[0, 0, 0], [0, -0.06, 0]], colors=[[90, 204, 102]]), static=True)    rr.log("world/objects/tee", rr.Boxes3D(half_sizes=[[0.06, 0.015, 0.015], [0.015, 0.045, 0.015]],           centers=[[0, 0, 0], [0, -0.06, 0]], colors=[[115, 128, 242]]), static=True)    rr.log("world/robot/pusher", rr.Boxes3D(half_sizes=[[0.015, 0.015, 0.02]], colors=[[230, 102, 89]]), static=True) def log_step() -> None:    if not args.rerun:        return    rr.set_time("sim_time", duration=data.time)    rr.log("world/objects/tee", rr.Transform3D(translation=data.xpos[model.body("tee").id],           quaternion=data.xquat[model.body("tee").id][[1, 2, 3, 0]]))  # MuJoCo wxyz -> rerun xyzw    rr.log("world/robot/pusher", rr.Transform3D(translation=data.xpos[model.body("pusher").id])) # Settle: no control, let friction bring the seeded start pose to rest. Track the# largest deviation of the bar-stem gap from the welded 0.06 m across every step# of the whole run — this max is 0.0 for a truly welded T and large the instant# the halves are two bodies (the split T is already off 0.06 before the push).gap_max_dev = abs(tee_gap() - 0.06)for _ in range(settle_steps):    data.ctrl[:] = 0.0    mujoco.mj_step(model, data)    gap_max_dev = max(gap_max_dev, abs(tee_gap() - 0.06))    log_step()tee_settled = [data.qpos[tee_x_adr], data.qpos[tee_y_adr], data.qpos[tee_yaw_adr]]gap_settled = tee_gap() # Nudge: drive the pusher north at full commanded speed. It contacts the block# and drives it up the table — the whole T should travel as one piece.for _ in range(nudge_steps):    data.ctrl[0], data.ctrl[1] = 0.0, 1.0  # [vx, vy] m/s targets for the two velocity servos    mujoco.mj_step(model, data)    gap_max_dev = max(gap_max_dev, abs(tee_gap() - 0.06))    log_step()tee_final = [data.qpos[tee_x_adr], data.qpos[tee_y_adr], data.qpos[tee_yaw_adr]] metrics = {    "break_it": args.break_it,    "joint_types": joint_types,    "n_bodies": int(model.nbody - 1),  # excluding the world body    "n_geoms": int(model.ngeom),    "n_joints": int(model.njnt),    "nq": int(model.nq),    "nu": int(model.nu),    "nv": int(model.nv),    "pusher_final": [round(float(data.qpos[int(model.joint("pusher_x").qposadr[0])]), 6),                     round(float(data.qpos[int(model.joint("pusher_y").qposadr[0])]), 6)],    "seed": args.seed,    "tee_final_pose": [round(float(v), 6) for v in tee_final],    "tee_gap_max_dev": round(gap_max_dev, 6),  # robust verdict: 0.0 iff welded, >=0.03 for a split T (whole-run peak)    "tee_gap_settled": round(gap_settled, 6),  # already != 0.06 for a split T: the reset can't even place it as one piece    "tee_settled_pose": [round(float(v), 6) for v in tee_settled],    "tee_stayed_rigid": bool(gap_max_dev < 1e-4),  # keyed off the robust whole-run peak, not the fragile final gap}(args.out / "metrics.json").write_text(json.dumps(metrics, indent=2, sort_keys=True) + "\n") print(f"nudge: pusher drove block from y={tee_settled[1]:.3f} to y={tee_final[1]:.3f}")print(f"weld check: bar-stem gap settled at {gap_settled:.4f} m, peak deviation {gap_max_dev:.4f} m from the 0.06 weld "      f"({'RIGID' if metrics['tee_stayed_rigid'] else 'SPLIT — never one rigid body'})")print(f"metrics: {args.out / 'metrics.json'}")if args.rerun:    print(f"recording: {args.out / 'scene.rrd'} — open it with: rerun {args.out / 'scene.rrd'}")

The regions concatenate into one MJCF string, from_xml_string compiles it, and — this is the payoff — the next few lines read the tree back out of the compiled model, not out of the XML you think you wrote. That distinction matters: the XML is your intent; the compiled mjModel is what MuJoCo actually built, and when they differ, the model is right and you are wrong. So we print it. The kinematic tree comes out as three bodies under the world — target, tee, pusher — each hanging directly off the root. Five joints, in qpos order: slide, slide, hinge, slide, slide. nq = nv = 5: five position numbers, five velocities, and — unlike last chapter's free-jointed box — no quaternion in sight, so no mismatch between the two counts. Two actuators. That printout is the first thing you check when a scene misbehaves, and it's how you'll catch Break It in the act.

Then a short, deterministic demonstration: set the block's start pose from the seed, let it settle for a few steps, and drive the pusher north into it. The block travels from y≈0.031 to y≈0.376 — the pusher is actuated, the block is not, and yet the block moves, because contact does what contact does. And the whole time, we track one number: the distance between the bar geom and the stem geom, in world space. In a properly welded T that number is 0.06 m and it never changes, no matter how hard the push. That constant is the weld, made into a measurement.

Run it

python scene.py
wall-clock · rendered from wallclock.csvone source · every tier
cpu-laptopexpected wall-clock on cpu-laptop: ~0.02 min (measured)measured
mpswall-clock on mps: not yet measuredpending
t4expected wall-clock on t4: ~0.14 min (measured)measured
4090wall-clock on 4090: not yet measuredpending

It prints the kinematic tree, runs the push, and drops a recording at outputs/ch0.2-scene/scene.rrd. Open it:

rerun outputs/ch0.2-scene/scene.rrd

Scrub the sim_time timeline. You should see the block sitting at its seeded start, then the pusher gliding up from the south and driving it north as one clean rigid piece, bar and stem locked, spinning slightly as it goes. The three entity paths are the same ones every chapter uses — world/objects/tee, world/objects/target, world/robot/pusher — so the debugging habits you build here transfer straight through to the capstone.

If the tree printout doesn't say three bodies and five joints, stop and read your XML against the compiled model before trusting anything downstream — a scene that compiles wrong is a scene that lies quietly.

Break it

Here's the failure the whole chapter has been pointing at. Author the T as two bodies instead of one:

python scene.py --break split-tee --no-rerun

--break split-tee puts the bar and the stem in separate bodies, each with its own planar joint set. Everything still parses. Everything still runs. But look at the tree printout: it's a different scene now — four bodies where there were three, eight joints where there were five, nq = nv = 8. You didn't mean to add three degrees of freedom, but you did, because every body needs its own way to move and you gave the stem its own.

And the weld invariant gives it away — before the push even lands. In the welded scene the bar–stem gap holds at 0.06 m start to finish; its deviation from 0.06 is exactly zero at every step of the run. In the split scene the gap is already wrong at rest: the block settles at 0.0912 m, half again the 0.06 m it should be.

Here's the actual mechanism, and it's worth getting right, because the reason is more mundane than "it came apart." The reset seeds the bar body's joints — it nudges the bar a few centimeters north to its start pose, exactly as a PushT reset would. But the stem is a separate body now, with its own joints, and nobody seeded those. So the bar walks north to its seeded position while the stem stays home, and the "T" settles as two rectangles a gap apart. It can't even be placed as one piece: assembling it correctly would mean positioning two bodies, and the reset only knew about one. That settled gap is the signature to memorize — when a thing you meant to be one rigid object can't even be set down as one, the rigidity was never there. You drew a T, but you built two blocks.

What happens during the push is a red herring, and the code is careful not to be fooled by it. The pusher comes up from the south, meets the loose stem first, and just shoves it around near the bar; the two halves actually drift back toward 0.06 m for a moment before the run ends. So the rigidity verdict never looks at the final gap — it watches the peak deviation from 0.06 over the whole run. For the welded T that peak is 0.0 (the gap is 0.06 at every single step); for the split T it is 0.031 m or more. Two loose pieces can wander back together by accident, but a gap that was ever off 0.06 was never a weld.

The diagnosis walk in rerun: put the welded and split recordings side by side and scrub to the very first settled frame, before the pusher moves. Welded, the stem sits locked to the bar at 0.06 m and stays there through the whole push. Split, the stem is already sitting apart from the bar in that first frame — the weld gap is visibly wrong before anything gets pushed. Same geoms, same code — the only difference is which body each geom lives in, and that's the difference between a rigid object and a pile of parts. When a later chapter's gripper or multi-link arm behaves like it's made of loosely associated pieces, this is the first thing to check: are the parts you meant to weld actually sharing a body?

Exercises

Three ways to convince yourself you can author a tree and predict what it will do — predict before you run.

What's next

You built the scene, and you proved it's rigid, but you did it by watching a distance stay constant — a crude proxy for the thing you actually care about, which is pose: where the block is and how it's turned, in what frame, relative to what. The moment you tried to describe the block's orientation you reached for a single number, yaw, and got away with it only because this task is flat. Real robots aren't flat. The instant the block can tip, or the camera sits at an angle, or the gripper approaches from the side, you need rotations that don't collapse to one number, and you need to know which frame every quantity lives in. That's the next chapter, and it's where the quaternion you dodged twice now finally comes due.

Practice

practice · candidate exercisesdrafted by the exercise generator, pending human promotion. Answers reveal only after you predict — honor system.

  1. predict-then-run

    Exercise 1

    SUGGESTED exercise candidate (humans promote) — predict-then-run, ch0.2.

    Objective tested: a joint's TYPE decides a body's degrees of freedom, and the degrees of freedom decide what the physics is even allowed to do. The chapter's T-block hangs from a planar joint set (slide-x, slide-y, hinge-yaw) — three DOF, all in the table plane, no way to leave it. Here you swap that for a single <freejoint/>: six DOF, the block floats free in 3D.

    THE DIFF UNDER STUDY (same push in both; the ONLY change is the block's joint):

    - <joint tee_x slide/> <joint tee_y slide/> <joint tee_yaw hinge/>   # 3 DOF, planar
    + <freejoint/>                                                        # 6 DOF, full 3D
    

    PREDICT before you run: the pusher drives into the block from the corner. With the freejoint instead of the planar joints, the block...

    • A) behaves the same — a T on a table is a T on a table
    • B) slides AND now rises off the table plane (its z stops being pinned)
    • C) immediately falls through the floor

    Estimated learner time: 10 minutes.

    Predict, then commit

    Pick the outcome you expect from the options above. The answer and the local run command reveal only after you commit — predicting after you know teaches nothing.

  2. bug-hunt

    Exercise 2

    SUGGESTED exercise candidate (humans promote) — bug-hunt, ch0.2.

    This is the chapter scene with EXACTLY ONE authoring bug injected into the MJCF. Its smoke metrics should match scene.py's (same seed, same physics) — they don't. Symptom: the pusher drives forward but the block barely moves north (tee ends at y~0.04 instead of ~0.38 [measured]), as if the pusher can only travel along one axis.

    Before you touch the XML, write one sentence: if the pusher drives forward but the block barely moves north, what must be true about the two pusher joints' axes — and which direction can the pusher no longer travel?

    Find the bug by READING the MJCF the way the chapter taught: a joint's axis is the direction it moves along, and two joints on a body should span the two directions you want. Fix it, then re-run checks.py until the metrics agree with the chapter's.

    Run: python ex2_bughunt_pusher_axis.py --smoke --seed 0 Estimated learner time: 20 minutes.

    Run it locally:

    pytest curriculum/phase0_foundations/ch0.2_scene/exercises/suggested/checks.py -k ex2
  3. hyperparameter-investigation

    Exercise 3

    SUGGESTED exercise candidate (humans promote) — hyperparameter-investigation, ch0.2.

    Objective tested: the actuator is part of the scene you author, and its gain is a knob with physical consequences. The chapter's pusher runs VELOCITY servos with kv="20": ctrl is a target speed, kv is how hard the servo works to hit that speed. Too low and the servo is mushy — it never reaches the commanded speed against the block's friction; too high and it slams to speed instantly.

    THE QUESTION (measurable): sweep the pusher's velocity gain kv over [5, 20, 80] and, with the SAME commanded push (vy = 1.0 m/s for a fixed number of steps), measure how far north the block travels. The walls are removed here so the full travel is visible rather than clipped at the workspace edge.

    PREDICT before running: the ORDERING of block travel across the three gains, and roughly how big the spread is (1.5x? 3x? 10x?). Write it in PREDICTION, then run this file and compare. Estimated learner time: 15 minutes.

    Run it locally:

    pytest curriculum/phase0_foundations/ch0.2_scene/exercises/suggested/checks.py -k ex3

Colophon · provenance

The code on this page is not pasted — each panel is included by region straight fromscene.py, and these fingerprints are its sha256, the same ones check_prose_code_drift re-checks on every PR. Edit a shown region without re-rendering and CI turns red.

#setup
a6635c7dc37525cb003f006d04f0c697864fa44dc0fb4cc4cd617346eae1ae27
#ground
23a621a1300335770b445fe3a1cd3cf059c32fc5000f46a700e16864d3c8912b
#tee
8699813e143b9b29eee4a049ec0acd11691acae407abf17e25fb6f102ebc5cc6
#pusher
2803fa8c9a66c5cbca2052617950fc6879359e37e4f501aecbb1fc3f8e5d16fc
#target
ee929ad9dfb2d5ad5d477fabe91c28c812d33917f16ec1de2fe4df0ed823941b
#build
3829f878e94aea8678ce70dc87c4d8243f7791f05ccc6a17c11e0966bb6631ef