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.
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 fileThe 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.
# 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.
# 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_MJCFThe 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.
# 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.
# 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.
# 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
| cpu-laptop | expected wall-clock on cpu-laptop: ~0.02 min (measured) | measured |
| mps | wall-clock on mps: not yet measured | pending |
| t4 | expected wall-clock on t4: ~0.14 min (measured) | measured |
| 4090 | wall-clock on 4090: not yet measured | pending |
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.