laureen
Withdraw Code Training from published main pending fixes
99e34b4
Raw
History Blame Contribute Delete
16.6 kB
// 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,
};