File size: 17,284 Bytes
24fe873
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
"""Generate the training-ready TwoLeg model from the reference assembly.

The reference assembly (``robot_item/chest_both_arms_legs.xml`` plus the
articulation that ``chest_move`` rigs onto it) is authoritative for geometry:
where every part sits and where every hinge pivots. It is not usable as a
free body, because 32 of its 121 bodies attach directly to the world and each
limb therefore hangs from the world instead of from the torso.

This script rebuilds the same parts into a connected kinematic tree:

* one root body, ``torso``, carrying a ``freejoint``
* the limb chains re-parented under ``torso``
* every body's ``pos`` and ``quat`` recomputed from its world pose at the zero
  pose, so the assembled geometry is unchanged
* the duplicate waist hinges dropped. The reference models each waist servo as
  two hinges on one shaft (``waist_L`` on the horn branch, ``L_waist_test`` on
  the leg branch), which would give the leg two serial hinges in one place. The
  leg-side hinge is kept and the horn branch is welded into the leg, giving the
  mechanism's true 17 actuated joints.
* geoms, and therefore mass and inertia, carried over untouched. The reference
  has no explicit ``<inertial>`` blocks, so MuJoCo infers mass from the geom
  densities (aluminium 2700, servo bodies 1600, visual geoms 0), which totals
  4.4818 kg.

It writes both consumers in one pass, so nothing has to patch the XML
afterwards:

* ``twoleg_mjcf/robot_twoleg.xml`` for viewing, meshes relative to that dir
* ``training/assets/twoleg.xml`` for mjlab, meshes relative to that dir

    uv run python training/scripts/generate_twoleg_model.py
    uv run python training/scripts/generate_twoleg_model.py --check
"""

import argparse
import copy
import os
import sys
import xml.etree.ElementTree as ET

import numpy as np

ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
ROBOT_ITEM = os.path.join(ROOT, "robot_item")
VIEW_OUT = os.path.join(ROOT, "twoleg_mjcf", "robot_twoleg.xml")
TRAIN_OUT = os.path.join(ROOT, "training", "assets", "twoleg.xml")

OUTPUTS = (
    (VIEW_OUT, "../robot_item"),
    (TRAIN_OUT, "../../robot_item"),
)

# Joint regularisation. The reference leaves damping, frictionloss and armature
# unset, which is fine for a puppet whose limbs are pinned to the world: the
# only bodies a hinge moves are its own subtree, and nothing has to hold the
# body up. In a free body it blows up. Several link bodies are massless, so a
# hinge can rotate near-zero inertia, the mass matrix goes singular, and the
# solver returns NaN within a few milliseconds. Armature adds reflected rotor
# inertia, which is physically the right quantity for a geared servo and also
# bounds the matrix away from singular.
JOINT_DAMPING = "0.05"
JOINT_FRICTIONLOSS = "0.02"
JOINT_ARMATURE = "0.01"

# The chest body is the top of the assembly, but it carries a 90-degree
# rotation of its own. Making it the free body's root would leave mjlab reading
# projected_gravity as [9.81, 0, 0] while the robot stands, which silently
# breaks the upright reward and the fell_over termination. So the root is an
# empty world-aligned frame, and the chest hangs from it with its own pose.
ROOT_BODY = "u_beam"
BASE_BODY = "torso"

# The reference's limb chains already hang together below these bodies; they
# only need re-attaching to the torso. Everything else keeps its parent.
LIMB_ROOTS = (
    "head_link",
    "abduct_L_link",
    "abduct_R_link",
    "L_waist_link",
    "R_waist_link",
)

# Torso shell and hardware, currently world children.
TORSO_PARTS = (
    "chest_center", "s1_screws", "chest_side_L", "chest_side_R",
    "horn_center", "horn_base_L", "horn_base_R", "s2_screws",
    "low_fin_0", "low_fin_1", "low_fin_2", "low_fin_3",
    "low_plate_0", "low_plate_1", "s3_nuts",
    "chest_servo_L", "chest_servo_R", "waist_servo_L", "waist_servo_R",
    "waist_pivot_shaft_L", "waist_pivot_bearing_L",
    "waist_pivot_shaft_R", "waist_pivot_bearing_R", "sh_screws",
)

# The horn branch of each waist hinge, welded into the leg it is bolted to.
WELDED_WAIST = {"waist_L_link": "L_leg_top", "waist_R_link": "R_leg_top"}
DUPLICATE_WAIST_JOINTS = ("waist_L", "waist_R")

# Servo count check: the real robot has 15 servos -- 4 per leg, 3 per arm, 1
# head. The arm-spread hinges abduct_L/R belong to no real actuator, so the
# abduct links are welded to the torso and their actuators dropped.
WELDED_STATIC = ("abduct_L_link", "abduct_R_link")
DROP_ACTUATORS = ("abduct_L", "abduct_R")

# 6-servo stance: hips, knees and ankles stay actuated (ankles give the
# push-off a gait needs). The other hinges are welded (joint elements removed)
# and every other actuator is dropped.
KEEP_JOINTS = ("L_hip_test", "R_hip_test", "L_knee_test", "R_knee_test",
               "L_ankle_test", "R_ankle_test")
WELD = True

# Collision geometry. The reference enables 26 mesh geoms across the two legs,
# masked so the legs hit each other and nothing else. Mesh-mesh contact on
# long, thin, near-parallel brackets is the usual source of solver blow-up in
# legged RL, and the reference never needed ground contact at all because its
# limbs were pinned to the world. So the meshes stay as visual geometry and the
# feet get one box each, which is what the previous model did and what legged
# RL normally does. The kinematics, mass and pivots are untouched.
FOOT_BOX_MIN_HALF_Z = 0.005
FOOT_COLLISION_RGBA = "0.9 0.2 0.9 1"


def reference_xml(dst):
    sys.path.insert(0, ROBOT_ITEM)
    import chest_both_arms_legs_move as ref  # noqa: E402

    return ref.build(dst, True)


def world_poses(path):
    """World position and rotation of every body at the zero pose."""
    import mujoco

    model = mujoco.MjModel.from_xml_path(path)
    data = mujoco.MjData(model)
    mujoco.mj_forward(model, data)
    out = {}
    for i in range(model.nbody):
        name = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_BODY, i)
        if name:
            out[name] = (data.xpos[i].copy(), data.xmat[i].reshape(3, 3).copy())
    return out


def quat_from_mat(mat):
    import mujoco

    q = np.zeros(4)
    mujoco.mju_mat2Quat(q, mat.flatten())
    return q


def vec_str(v):
    return " ".join(f"{x:.9g}" for x in v)


def reparent(poses, child, parent):
    """Child's pos/quat expressed in the parent's frame, from world poses."""
    cpos, cmat = poses[child]
    ppos, pmat = poses[parent]
    rel_pos = pmat.T @ (cpos - ppos)
    rel_mat = pmat.T @ cmat
    return vec_str(rel_pos), vec_str(quat_from_mat(rel_mat))


def build_tree(root):
    bodies = {}
    parent = {}
    for b in root.iter("body"):
        name = b.get("name")
        bodies[name] = b
    for b in root.iter("body"):
        for c in b.findall("body"):
            parent[c.get("name")] = b.get("name")

    final = {}
    for name in bodies:
        if name in WELDED_WAIST:
            final[name] = WELDED_WAIST[name]
        elif name in LIMB_ROOTS or name in TORSO_PARTS:
            final[name] = ROOT_BODY
        elif name in parent:
            final[name] = parent[name]
    return bodies, final


# Foot sites for the clearance and slip rewards. The reference has no sites, so
# they are placed at the sole of each foot: the lowest point of the foot's own
# collision mesh, expressed in the foot body frame.
FOOT_BODIES = {"L_foot": "left_foot", "R_foot": "right_foot"}


def sole_sites(path):
    """Body-frame AABB of each foot body's own geoms.

    Taken from the mesh vertices rather than the geom's bounding sphere, so the
    box lands on the real footprint whatever rotation the body and the geom
    carry. This model's left-right axis is X, not Y, so guessing the axes put
    the two foot boxes on top of each other and blew the solver up.
    """
    import mujoco

    model = mujoco.MjModel.from_xml_path(path)
    out = {}
    for body_name, site_name in FOOT_BODIES.items():
        bid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, body_name)
        lo = np.full(3, np.inf)
        hi = np.full(3, -np.inf)
        for g in range(model.ngeom):
            if model.geom_bodyid[g] != bid:
                continue
            mid = model.geom_dataid[g]
            if mid < 0:
                continue
            adr, num = model.mesh_vertadr[mid], model.mesh_vertnum[mid]
            verts = model.mesh_vert[adr : adr + num]
            rot = np.zeros(9)
            mujoco.mju_quat2Mat(rot, model.geom_quat[g])
            local = verts @ rot.reshape(3, 3).T + model.geom_pos[g]
            lo = np.minimum(lo, local.min(0))
            hi = np.maximum(hi, local.max(0))
        if not np.isfinite(lo).all():
            raise SystemExit(f"no mesh geoms on {body_name}")
        out[body_name] = (site_name, lo, hi)
    return out


def generate(dst, mesh_dir):
    ref_path = reference_xml("/tmp/_twoleg_ref_build.xml")
    tree = ET.parse(ref_path)
    root = tree.getroot()
    poses = world_poses(ref_path)

    bodies, final_parent = build_tree(root)
    if ROOT_BODY not in bodies:
        raise SystemExit(f"root body {ROOT_BODY!r} not found")
    missing = [n for n in bodies if n != ROOT_BODY and n not in final_parent]
    if missing:
        raise SystemExit(f"bodies with no assigned parent: {missing}")

    # Reposition every body that changes parent, before copying anything.
    old_parent = {}
    for b in root.iter("body"):
        for c in b.findall("body"):
            old_parent[c.get("name")] = b.get("name")

    for name, parent_name in final_parent.items():
        if old_parent.get(name) != parent_name:
            pos, quat = reparent(poses, name, parent_name)
            el = bodies[name]
            el.set("pos", pos)
            el.set("quat", quat)

    # Rebuild from copies so no element is ever in two places.
    flat = {}
    for name, el in bodies.items():
        new = ET.Element("body", dict(el.attrib))
        for child in el:
            if child.tag != "body":
                new.append(copy.deepcopy(child))
        flat[name] = new

    kids = {name: [] for name in bodies}
    for name, parent_name in final_parent.items():
        kids[parent_name].append(name)

    def attach(parent_el, parent_name):
        for child_name in kids[parent_name]:
            parent_el.append(flat[child_name])
            attach(flat[child_name], child_name)

    worldbody = root.find("worldbody")
    for child in list(worldbody):
        worldbody.remove(child)
    base = ET.Element("body", {"name": BASE_BODY, "pos": "0 0 0", "quat": "1 0 0 0"})
    base.append(ET.Element("freejoint", {"name": "root_joint"}))
    base.append(ET.Element("site", {"name": "imu", "pos": "0 0 0.04", "size": "0.001"}))
    base.append(flat[ROOT_BODY])
    # Front indicator: red arrow welded to the free root, pointing at the
    # model's forward (-y, face side) so a side-on vs front-on render is unambiguous.
    base.append(ET.Element("geom", {
        "name": "front_shaft", "type": "cylinder", "size": "0.004 0.07",
        "pos": "0 -0.10 0.25", "quat": "0.7071068 0.7071068 0 0",
        "rgba": "1 0.1 0.1 1", "contype": "0", "conaffinity": "0",
    }))
    base.append(ET.Element("geom", {
        "name": "front_tip", "type": "cylinder", "size": "0.012 0.015",
        "pos": "0 -0.19 0.25", "quat": "0.7071068 0.7071068 0 0",
        "rgba": "1 0.1 0.1 1", "contype": "0", "conaffinity": "0",
    }))
    worldbody.append(base)
    attach(flat[ROOT_BODY], ROOT_BODY)

    # Drop the duplicate waist hinges and their actuators. Each waist servo is
    # modelled twice in the reference, once on the horn branch (waist_L) and
    # once on the leg branch (L_waist_test), 0.6 mm apart on one shaft. Keeping
    # both would give the leg two serial hinges in the same place. The leg-side
    # hinge stays; the horn branch is welded into the leg.
    #
    # Then weld everything except the four hip/ankle hinges: the 4-servo
    # configuration used in this debugging/teaching run. Every mubljoint
    # transformation you kept is still valid (joint transforms stay in the
    # body's pos/quat wrt parent); a nested joint removed just makes the
    # subtree rigid with the parent's frame.
    for body_name in list(WELDED_WAIST) + list(WELDED_STATIC):
        el = flat.get(body_name)
        if el is None:
            continue
        for j in list(el):
            if j.tag == "joint":
                el.remove(j)
    for act in root.findall("actuator"):
        for a in list(act):
            drop_names = DUPLICATE_WAIST_JOINTS + DROP_ACTUATORS
            if a.get("name") in drop_names or (WELD and a.get("name") not in KEEP_JOINTS):
                act.remove(a)

    # Compiler and model tag. Drop the reference's solver options: mjlab owns
    # the timestep and integrator and warns when the model disagrees.
    for old in root.findall("option"):
        root.remove(old)
    compiler = root.find("compiler")
    compiler.set("meshdir", mesh_dir)
    compiler.set("angle", "radian")
    compiler.set("autolimits", "true")
    root.set("model", "twoleg")

    if WELD:
        for body in root.iter("body"):
            for j in list(body):
                if j.tag == "joint" and j.get("name") not in KEEP_JOINTS:
                    body.remove(j)
    # Regularise every hinge. See JOINT_ARMATURE above.
    for joint in root.iter("joint"):
        if joint.get("type", "hinge") == "freejoint":
            continue
        joint.set("damping", JOINT_DAMPING)
        joint.set("frictionloss", JOINT_FRICTIONLOSS)
        joint.set("armature", JOINT_ARMATURE)

    # Collision: clear every geom, then give each foot one box at the sole.
    for geom in root.iter("geom"):
        geom.set("contype", "0")
        geom.set("conaffinity", "0")

    # Foot site and collision box from each foot's real body-frame AABB.
    for body_name, (site_name, lo, hi) in sole_sites(ref_path).items():
        el = flat[body_name]
        for old in list(el):
            if old.tag == "site":
                el.remove(old)
        center = (lo + hi) / 2.0
        half = (hi - lo) / 2.0
        # The plate should read long along the walk direction (+y). The raw
        # mesh AABB is long along x due to how the part was exported, which
        # would make both pink slabs overlap about 5 cm at spawn.
        half[0], half[1] = half[1], half[0]
        # Thicken the slab so a 2 mm plate cannot tunnel through the floor.
        half[2] = max(half[2], FOOT_BOX_MIN_HALF_Z)
        center[2] = lo[2] + half[2]
        el.append(ET.Element("site", {"name": site_name, "pos": vec_str([center[0], center[1], lo[2]])}))
        el.append(
            ET.Element(
                "geom",
                {
                    "name": f"{site_name}_collision",
                    "type": "box",
                    "size": vec_str(half),
                    "pos": vec_str(center),
                    "contype": "1",
                    "conaffinity": "1",
                    "density": "0",
                    "rgba": FOOT_COLLISION_RGBA,
                },
            )
        )

    # mjlab sensors.
    for old in root.findall("sensor"):
        root.remove(old)
    sensor = ET.SubElement(root, "sensor")
    ET.SubElement(sensor, "gyro", {"name": "imu_ang_vel", "site": "imu"})
    ET.SubElement(sensor, "velocimeter", {"name": "imu_lin_vel", "site": "imu"})
    ET.SubElement(sensor, "subtreeangmom", {"name": "root_angmom", "body": BASE_BODY})

    ET.indent(tree, space="  ")
    tree.write(dst, encoding="utf-8", xml_declaration=True)
    return dst


def report(path):
    import mujoco

    model = mujoco.MjModel.from_xml_path(path)
    data = mujoco.MjData(model)
    mujoco.mj_forward(model, data)
    pinned = [i for i in range(1, model.nbody) if model.body_parentid[i] == 0]
    free = [j for j in range(model.njnt) if model.jnt_type[j] == mujoco.mjtJoint.mjJNT_FREE]
    print(f"  bodies      : {model.nbody - 1}")
    print(f"  joints      : {model.njnt}  (freejoints {len(free)})")
    print(f"  actuators   : {model.nu}")
    print(f"  geoms       : {model.ngeom}")
    print(f"  mass        : {model.body_mass.sum():.4f} kg")
    print(f"  world-pinned: {len(pinned)}")
    lowest = min(
        data.geom_xpos[g][2]
        - (
            model.geom_size[g][2]
            if model.geom_type[g] == mujoco.mjtGeom.mjGEOM_BOX
            else model.geom_rbound[g]
        )
        for g in range(model.ngeom)
    )
    print(f"  lowest point: {lowest:.4f} m  (spawn the torso near {-lowest:.4f} m)")
    names = [mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_JOINT, j) for j in range(model.njnt)]
    print(f"  joints      : {sorted(n for n in names if n)}")
    return model


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--check", action="store_true", help="verify without writing")
    args = ap.parse_args()
    for dst, mesh_dir in OUTPUTS:
        target = dst + ".tmp" if args.check else dst
        generate(target, mesh_dir)
        print(f"wrote {os.path.relpath(target, ROOT)}  (meshdir={mesh_dir})")
        if not args.check:
            print("compiled:")
            report(target)
    if args.check:
        for dst, _ in OUTPUTS:
            if os.path.exists(dst + ".tmp"):
                os.remove(dst + ".tmp")


if __name__ == "__main__":
    main()