File size: 16,646 Bytes
99e34b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
// Standalone JS port of microduck_app's src/duck/kinematics.ts.
// Loads kinematics.json (built from the MJCF by build_kinematics.py) and
// builds an Object3D tree with one Group per body. Joints become per-body
// local rotations driven by setJointAngles / setJoint.

import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { STLLoader } from "three/addons/loaders/STLLoader.js";
import { mergeVertices, toCreasedNormals } from "three/addons/utils/BufferGeometryUtils.js";

// Cache-busting version appended to the GLB / kinematics / leftover STL
// fetches. Bump whenever microduck.glb is regenerated.
export const MESH_VERSION = "11";

// Default model directory: the complete mjlab export (converted from
// robot_walk.xml by tools/mjcf_to_kinematics.py). robot/v1.5/ and
// robot/alpha/ stay on disk for reference but the code no longer
// targets them.
export const MODEL_DIR = "./robot/mjlab";

// On a private HF Space, asset requests carry the ?__sign JWT (auth
// cookies may be blocked in the hub iframe). Identity everywhere else.
import { signed } from "./signed.js";

// Meshes fully occluded inside the shells at every demo camera angle,
// verified empirically by per-mesh pixel-diff (front 3/4, back, low
// front close-up): hiding each changes exactly 0 pixels. Skipped at
// load time so their bytes are never fetched. Load everything with
// `?all=1` for debugging.
const HIDDEN_MESHES = new Set([]);

export async function loadKinematics(url) {
  // Same cache-buster as the GLB: force-cache would otherwise keep
  // serving a stale kinematics.json after the mesh list changes.
  const r = await fetch(signed(`${url}?v=${MESH_VERSION}`), { cache: "force-cache" });
  if (!r.ok) throw new Error(`kinematics fetch ${r.status}`);
  const k = await r.json();
  // Full-resolution meshes by default (the reduction will be redone
  // interactively in Blender later). `?lite=1` opts into a decimated
  // meshes-lite/ sibling if present.
  if (new URLSearchParams(location.search).get("lite") === "1") {
    k.mesh_dir = k.mesh_dir.replace(/\/meshes$/, "/meshes-lite");
  }
  return k;
}

// One GLB, named meshes matching kinematics geom.mesh (e.g. "left_shell.stl").
// Positions are already welded (same 1e-4 m hash as mergeVertices). STL
// facet normals are omitted on purpose: creased normals are rebuilt here.
const GLB_URL = `${MODEL_DIR}/microduck.glb`;
const CREASE = Math.PI / 5; // 36 deg
let glbGeomsPromise = null;

export function loadGlbGeometries() {
  if (!glbGeomsPromise) {
    glbGeomsPromise = new GLTFLoader()
      .loadAsync(signed(`${GLB_URL}?v=${MESH_VERSION}`))
      .then((gltf) => {
        const map = new Map();
        gltf.scene.traverse((o) => {
          if (!o.isMesh || !o.geometry) return;
          const name = o.userData.meshFile || o.name || o.geometry.name;
          if (!name || map.has(name)) return;
          const welded = o.geometry;
          welded.deleteAttribute("normal");
          // toCreasedNormals hashes on a 0.01-unit grid. Meshes are in
          // metres, so scale a clone to mm (10 um grid) then back.
          const scaled = welded.clone();
          scaled.scale(1000, 1000, 1000);
          const display = toCreasedNormals(scaled, CREASE);
          display.scale(1e-3, 1e-3, 1e-3);
          const entry = { display, welded };
          map.set(name, entry);
          if (o.name && o.name !== name) map.set(o.name, entry);
        });
        return map;
      });
  }
  return glbGeomsPromise;
}

// MuJoCo's WASM compiler still wants binary STL in its VFS. Rebuild from
// the already-loaded GLB so the browser never re-downloads the mesh files.
export function geometryToBinaryStl(geometry) {
  const pos = geometry.attributes.position;
  const idx = geometry.index;
  const triCount = (idx ? idx.count : pos.count) / 3;
  const buf = new ArrayBuffer(84 + triCount * 50);
  const view = new DataView(buf);
  view.setUint32(80, triCount, true);
  let off = 84;
  const vx = (i) => {
    const j = idx ? idx.getX(i) : i;
    return [pos.getX(j), pos.getY(j), pos.getZ(j)];
  };
  for (let t = 0; t < triCount; t++) {
    const a = vx(t * 3);
    const b = vx(t * 3 + 1);
    const c = vx(t * 3 + 2);
    const nx = (b[1] - a[1]) * (c[2] - a[2]) - (b[2] - a[2]) * (c[1] - a[1]);
    const ny = (b[2] - a[2]) * (c[0] - a[0]) - (b[0] - a[0]) * (c[2] - a[2]);
    const nz = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
    const len = Math.hypot(nx, ny, nz) || 1;
    view.setFloat32(off, nx / len, true);
    view.setFloat32(off + 4, ny / len, true);
    view.setFloat32(off + 8, nz / len, true);
    view.setFloat32(off + 12, a[0], true);
    view.setFloat32(off + 16, a[1], true);
    view.setFloat32(off + 20, a[2], true);
    view.setFloat32(off + 24, b[0], true);
    view.setFloat32(off + 28, b[1], true);
    view.setFloat32(off + 32, b[2], true);
    view.setFloat32(off + 36, c[0], true);
    view.setFloat32(off + 40, c[1], true);
    view.setFloat32(off + 44, c[2], true);
    view.setUint16(off + 48, 0, true);
    off += 50;
  }
  return buf;
}

// Materials are PBR (MeshStandardMaterial), same family as the Reachy
// mobile app's glb viewer. Optional silhouette outlines via three's
// OutlineEffect - see main.js.

export async function buildRig(k, opts = {}) {
  // placer holds world-space position + yaw (no axis-conversion).
  const placer = new THREE.Group();
  placer.name = "duck_placer";
  // root applies the MJCF +Z up -> three.js +Y up convention fix.
  const root = new THREE.Group();
  root.name = "duck_root";
  root.rotation.x = -Math.PI / 2;
  placer.add(root);

  const bodies = new Map();
  const joints = new Map();
  const geomByName = await loadGlbGeometries();
  // Roller-only meshes (tire, rim, ...) are not in the shared landing GLB.
  const extraStlCache = new Map();
  const loadMesh = (name) => {
    const entry = geomByName.get(name);
    if (entry) return Promise.resolve(entry);
    if (!extraStlCache.has(name)) {
      extraStlCache.set(
        name,
        new STLLoader().loadAsync(signed(`${k.mesh_dir}/${name}?v=${MESH_VERSION}`)).then((raw) => {
          raw.deleteAttribute("normal");
          const welded = mergeVertices(raw, 1e-4);
          welded.scale(1000, 1000, 1000);
          const display = toCreasedNormals(welded, CREASE);
          display.scale(1e-3, 1e-3, 1e-3);
          welded.scale(1e-3, 1e-3, 1e-3);
          return { display, welded };
        }),
      );
    }
    return extraStlCache.get(name);
  };

  for (const b of k.bodies) {
    const g = new THREE.Group();
    g.name = b.name;
    g.position.set(b.pos[0], b.pos[1], b.pos[2]);
    g.quaternion.set(b.quat[1], b.quat[2], b.quat[3], b.quat[0]);
    bodies.set(b.name, g);
  }
  for (const b of k.bodies) {
    const g = bodies.get(b.name);
    if (b.parent && bodies.has(b.parent)) bodies.get(b.parent).add(g);
    else root.add(g);
  }
  for (const b of k.bodies) {
    if (!b.joint || (b.joint.type && b.joint.type !== "hinge")) continue;
    const g = bodies.get(b.name);
    joints.set(b.joint.name, {
      body: g,
      axis: new THREE.Vector3(...b.joint.axis).normalize(),
      baseQuat: g.quaternion.clone(),
      range: b.joint.range ?? null,
    });
  }

  // Cache materials by their resolved PBR props so identical parts share
  // one GPU material instance.
  const matCache = new Map();
  // Optional (meshName, bodyName, rgba) -> material spec hook. A spec is
  // { color: [r,g,b], roughness, metalness, opacity? }; plain rgba arrays
  // are also accepted for backwards compat.
  const materialForMesh = opts.materialForMesh ?? null;
  const matFor = (spec) => {
    const color = spec.color;
    const roughness = spec.roughness ?? 0.5;
    const metalness = spec.metalness ?? 0.0;
    const opacity = spec.opacity ?? 1;
    const key = `${color.join(",")}|${roughness}|${metalness}|${opacity}`;
    const cached = matCache.get(key);
    if (cached) return cached;
    const m = new THREE.MeshStandardMaterial({
      color: new THREE.Color(...color),
      roughness,
      metalness,
      transparent: opacity < 1,
      opacity,
    });
    matCache.set(key, m);
    return m;
  };
  const toSpec = (v, fallbackRgba) => {
    if (!v) return { color: fallbackRgba.slice(0, 3), opacity: fallbackRgba[3] ?? 1 };
    if (Array.isArray(v)) return { color: v.slice(0, 3), opacity: v[3] ?? 1 };
    return v;
  };

  // Optional interior ink lines: hard edges above the threshold angle
  // drawn as line segments, comic style. Cached per mesh file.
  const inkOpts = opts.inkEdges ?? null;
  const inkMat = inkOpts
    ? new THREE.LineBasicMaterial({
        color: inkOpts.color ?? 0x0a0a0e,
        transparent: true,
        opacity: inkOpts.opacity ?? 0.55,
      })
    : null;
  const edgeCache = new Map();
  const edgesFor = (name, welded) => {
    if (!edgeCache.has(name)) {
      edgeCache.set(name, new THREE.EdgesGeometry(welded, inkOpts.threshold ?? 40));
    }
    return edgeCache.get(name);
  };

  const pending = [];
  const loadAll = new URLSearchParams(location.search).get("all") === "1";
  // The MJCF lists a few geoms twice with identical transforms (visual +
  // collision copies of power_support, soles, legs); drawing both would
  // only z-fight, so exact duplicates are skipped.
  const seenGeoms = new Set();
  for (const b of k.bodies) {
    const g = bodies.get(b.name);
    if (!g) continue;
    for (const geom of b.geoms) {
      if (geom.type && geom.type !== "mesh") continue;
      if (!geom.mesh) continue;
      if (!loadAll && HIDDEN_MESHES.has(geom.mesh)) continue;
      const dupKey = `${b.name}|${geom.mesh}|${geom.pos}|${geom.quat}`;
      if (seenGeoms.has(dupKey)) continue;
      seenGeoms.add(dupKey);
      pending.push(
        loadMesh(geom.mesh).then(({ display, welded }) => {
          const rgba = geom.color
            ? [geom.color[0], geom.color[1], geom.color[2], geom.color[3] ?? 1]
            : [0.85, 0.85, 0.85, 1];
          const spec = toSpec(materialForMesh?.(geom.mesh, b.name, rgba), rgba);
          const m = new THREE.Mesh(display, matFor(spec));
          // Mesh filename tag so callers can re-skin materials in place
          // (survives cloneRig: Object3D.copy deep-copies userData).
          m.userData.meshName = geom.mesh;
          if (geom.pos) m.position.set(...geom.pos);
          if (geom.quat) m.quaternion.set(geom.quat[1], geom.quat[2], geom.quat[3], geom.quat[0]);
          g.add(m);
          if (inkMat) {
            const lines = new THREE.LineSegments(edgesFor(geom.mesh, welded), inkMat);
            lines.position.copy(m.position);
            lines.quaternion.copy(m.quaternion);
            g.add(lines);
          }
        }),
      );
    }
  }
  await Promise.all(pending);

  const rig = { placer, root, bodies, joints };
  setupJawPivot(rig);
  return rig;
}

// ── Jaw hinge ───────────────────────────────────────────────────────────
// The mjlab model has no passive jaw joints: jaw.stl / jaw_soft.stl are
// rigid geoms of the head body (named "jaw_soft" in the MJCF, it carries
// the head_roll joint). The quack re-creates the hinge in JS: both jaw
// meshes are reparented into a "jaw_pivot" group whose origin sits on the
// physical hinge, and setJawOpen rotates that pivot about the robot's
// left-right axis so the beak tip swings down.
const JAW_MESH_NAMES = new Set(["jaw.stl", "jaw_soft.stl"]);
export const JAW_MAX_OPEN = 0.32; // rad at openness 1

// The physical hinge: jaw.stl ends in a circular boss (8 mm ring with the
// axle hole at its center) on each side, hole axis along mesh-local X (the
// robot's left-right). Circle fitted offline on the STL's hole-wall
// vertices (RANSAC, 68 inliers, < 0.15 mm spread); x = 0 sits mid-way
// between the two bosses, on the hinge line.
const JAW_HINGE_LOCAL = new THREE.Vector3(0, 0.00004, 0.0075);

function setupJawPivot(rig) {
  const meshes = [];
  rig.root.traverse((o) => {
    if (o.isMesh && JAW_MESH_NAMES.has(o.userData.meshName)) meshes.push(o);
  });
  if (!meshes.length) return;
  const body = meshes[0].parent;
  // The placer is still untransformed right after buildRig, so world
  // coords == placer coords here: robot forward is +X, up is +Y.
  rig.placer.updateWorldMatrix(true, true);
  const jawMesh = meshes.find((m) => m.userData.meshName === "jaw.stl") ?? meshes[0];
  const hingeW = jawMesh.localToWorld(JAW_HINGE_LOCAL.clone());
  // +angle about -Z rotates the +X beak tip toward -Y (down).
  const axisW = new THREE.Vector3(0, 0, -1);
  const bodyQuatInv = body.getWorldQuaternion(new THREE.Quaternion()).invert();
  const hingeL = body.worldToLocal(hingeW.clone());
  const axisL = axisW.applyQuaternion(bodyQuatInv).normalize();
  const pivot = new THREE.Group();
  pivot.name = "jaw_pivot";
  pivot.position.copy(hingeL);
  // Plain array so Object3D.copy's JSON userData clone preserves it.
  pivot.userData.jawAxis = axisL.toArray();
  body.add(pivot);
  for (const m of meshes) {
    m.position.sub(hingeL);
    pivot.add(m);
  }
}

// Open the beak: 0 = closed, 1 = fully open (JAW_MAX_OPEN rad). The pivot
// is resolved lazily by name so clones from cloneRig work transparently.
const _jawAxis = new THREE.Vector3();
export function setJawOpen(rig, open) {
  if (rig._jawPivot === undefined) {
    rig._jawPivot = rig.placer.getObjectByName("jaw_pivot") ?? null;
  }
  const pivot = rig._jawPivot;
  if (!pivot) return;
  _jawAxis.fromArray(pivot.userData.jawAxis);
  pivot.quaternion.setFromAxisAngle(_jawAxis, JAW_MAX_OPEN * open);
}

// Set one named joint, clamped to its MJCF range when known.
export function setJoint(rig, name, angle) {
  const j = rig.joints.get(name);
  if (!j) return;
  let a = angle;
  if (j.range) a = Math.min(j.range[1], Math.max(j.range[0], a));
  const rot = _q.setFromAxisAngle(j.axis, a);
  j.body.quaternion.copy(j.baseQuat).multiply(rot);
}
const _q = new THREE.Quaternion();

export function applyPose(rig, pose) {
  for (const [name, ang] of Object.entries(pose)) setJoint(rig, name, ang);
}

// Deep-clone a built rig without re-parsing the GLB: Object3D.clone
// shares geometry and materials, so N clones cost almost nothing on top of
// the first buildRig. The bodies/joints maps are rebuilt by looking up the
// cloned nodes by name (body names are unique in the MJCF).
export function cloneRig(rig) {
  const placer = rig.placer.clone(true);
  const root = placer.getObjectByName("duck_root");
  const bodies = new Map();
  for (const name of rig.bodies.keys()) {
    bodies.set(name, placer.getObjectByName(name));
  }
  const joints = new Map();
  for (const [name, j] of rig.joints) {
    joints.set(name, {
      body: placer.getObjectByName(j.body.name),
      axis: j.axis, // read-only, safe to share
      baseQuat: j.baseQuat.clone(),
      range: j.range,
    });
  }
  return { placer, root, bodies, joints };
}

// Ground using the whole rig's bounding box (sitting pose folds the legs
// under the trunk, so the feet are not the lowest point).
const _box = new THREE.Box3();
export function groundFullBody(rig, floorY = 0) {
  rig.placer.updateWorldMatrix(true, true);
  _box.setFromObject(rig.placer);
  if (!Number.isFinite(_box.min.y)) return 0;
  rig.placer.position.y += floorY - _box.min.y;
  return floorY - _box.min.y;
}

// "SIT" keyframe. The mjlab model shares alpha's conventions (same
// onshape-to-robot pipeline): neck_pitch range max is 1.0472, so
// neck_pitch sits just under it (headroom for the breathing oscillation,
// +-0.025) and head_pitch compensates to keep the head level-ish with a
// slight upward tilt toward the camera. NOTE: head_pitch sign is
// inverted vs v1.5 (positive = head down), so the compensation is
// positive here (verified visually).
export const SITTING_POSE = {
  left_hip_yaw: 0.0,
  left_hip_roll: 0.0,
  left_hip_pitch: -0.5236,
  left_knee: 1.0472,
  left_ankle: 0.0,
  neck_pitch: 1.02,
  head_pitch: 0.9,
  head_yaw: 0.0,
  head_roll: 0.0,
  right_hip_yaw: 0.0,
  right_hip_roll: 0.0,
  right_hip_pitch: 0.5236,
  right_knee: -1.0472,
  right_ankle: 0.0,
};