CoolFace
Apppublic

lerobot/glove_visualizer

sourceHugging Faceapache-2.0updated 8d agoView on Hugging Face
5likes
robot.js204 linesDownload Raw Back to src
1// Generic MJCF rig builder, adapted from the microduck sandbox's2// src/game/duck.js. Loads kinematics.json (produced by3// tools/mjcf_to_kinematics.py) and builds an Object3D tree with one Group per4// body; joints become per-body local rotations driven by name.5//6// Differences from the duck version: the jaw hinge and its SITTING_POSE are7// gone (G1 has neither), meshes are keyed by the original glTF node name8// because three strips '.' from node names, and a missing GLB is fatal rather9// than optional since every G1 mesh is packed into one.10 11import * as THREE from "three";12import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";13import { STLLoader } from "three/addons/loaders/STLLoader.js";14import { mergeVertices, toCreasedNormals } from "three/addons/utils/BufferGeometryUtils.js";15 16const CREASE = Math.PI / 5; // 36 deg17 18export async function loadKinematics(url) {19  const r = await fetch(url);20  if (!r.ok) throw new Error(`kinematics fetch ${r.status} for ${url}`);21  return r.json();22}23 24// three's GLTFLoader runs node names through PropertyBinding.sanitizeNodeName,25// which strips '.' and ':' - so a node exported as "pelvis.STL" arrives as26// "pelvisSTL". The untouched original survives on userData.name, so register27// every alias we might be asked for.28function registerAliases(map, mesh, entry) {29  for (const key of [mesh.userData?.name, mesh.name, mesh.geometry?.name]) {30    if (key && !map.has(key)) map.set(key, entry);31  }32}33 34function prepare(raw) {35  // STL repeats every vertex per facet and the packed GLB is already welded;36  // normals are rebuilt here either way so creases stay sharp.37  raw.deleteAttribute("normal");38  const welded = raw.index ? raw : mergeVertices(raw, 1e-4);39  // toCreasedNormals hashes on a 0.01-unit grid. Meshes are in metres, so40  // scale a clone to mm (10 um grid) and back.41  const scaled = welded.clone();42  scaled.scale(1000, 1000, 1000);43  const display = toCreasedNormals(scaled, CREASE);44  display.scale(1e-3, 1e-3, 1e-3);45  return { display, welded };46}47 48export async function loadGlbGeometries(url) {49  const gltf = await new GLTFLoader().loadAsync(url);50  const map = new Map();51  gltf.scene.traverse((o) => {52    if (!o.isMesh || !o.geometry) return;53    registerAliases(map, o, prepare(o.geometry));54  });55  return map;56}57 58export async function buildRig(k, opts = {}) {59  // placer carries the world pose in three.js space (no axis conversion)...60  const placer = new THREE.Group();61  placer.name = "robot_placer";62  // ...and root applies the MJCF +Z up -> three.js +Y up convention fix, so63  // everything below it stays in MJCF coordinates.64  const root = new THREE.Group();65  root.name = "robot_root";66  root.rotation.x = -Math.PI / 2;67  placer.add(root);68 69  const bodies = new Map();70  const joints = new Map();71  const geomByName = opts.glbUrl ? await loadGlbGeometries(opts.glbUrl) : new Map();72 73  const stlCache = new Map();74  const loadMesh = (name) => {75    const entry = geomByName.get(name);76    if (entry) return Promise.resolve(entry);77    if (!stlCache.has(name)) {78      stlCache.set(79        name,80        new STLLoader().loadAsync(`${k.mesh_dir}/${name}`).then(prepare),81      );82    }83    return stlCache.get(name);84  };85 86  for (const b of k.bodies) {87    const g = new THREE.Group();88    g.name = b.name;89    g.position.set(b.pos[0], b.pos[1], b.pos[2]);90    g.quaternion.set(b.quat[1], b.quat[2], b.quat[3], b.quat[0]);91    bodies.set(b.name, g);92  }93  for (const b of k.bodies) {94    const g = bodies.get(b.name);95    if (b.parent && bodies.has(b.parent)) bodies.get(b.parent).add(g);96    else root.add(g);97  }98  for (const b of k.bodies) {99    if (!b.joint || (b.joint.type && b.joint.type !== "hinge")) continue;100    const g = bodies.get(b.name);101    joints.set(b.joint.name, {102      body: g,103      axis: new THREE.Vector3(...b.joint.axis).normalize(),104      baseQuat: g.quaternion.clone(),105      range: b.joint.range ?? null,106    });107  }108 109  // Identical PBR props share one GPU material instance.110  const matCache = new Map();111  const matFor = (rgba) => {112    const key = rgba.join(",");113    const cached = matCache.get(key);114    if (cached) return cached;115    const m = new THREE.MeshStandardMaterial({116      color: new THREE.Color(rgba[0], rgba[1], rgba[2]),117      roughness: opts.roughness ?? 0.55,118      metalness: opts.metalness ?? 0.25,119      transparent: (rgba[3] ?? 1) < 1,120      opacity: rgba[3] ?? 1,121    });122    matCache.set(key, m);123    return m;124  };125 126  const pending = [];127  const missing = new Set();128  for (const b of k.bodies) {129    const g = bodies.get(b.name);130    if (!g) continue;131    for (const geom of b.geoms) {132      if (geom.type && geom.type !== "mesh") continue;133      if (!geom.mesh) continue;134      pending.push(135        loadMesh(geom.mesh)136          .then(({ display }) => {137            const m = new THREE.Mesh(display, matFor(geom.color ?? [0.8, 0.8, 0.8, 1]));138            m.userData.meshName = geom.mesh;139            m.castShadow = true;140            m.receiveShadow = true;141            if (geom.pos) m.position.set(...geom.pos);142            if (geom.quat) m.quaternion.set(geom.quat[1], geom.quat[2], geom.quat[3], geom.quat[0]);143            g.add(m);144          })145          .catch(() => missing.add(geom.mesh)),146      );147    }148  }149  await Promise.all(pending);150  if (missing.size) console.warn(`[rig] ${missing.size} mesh(es) failed:`, [...missing]);151 152  return { placer, root, bodies, joints, missing, pose: Object.fromEntries([...joints.keys()].map(name => [name, 0])) };153}154 155const _q = new THREE.Quaternion();156 157// Set one named joint, clamped to its MJCF range when known.158export function setJoint(rig, name, angle) {159  const j = rig.joints.get(name);160  if (!j || !Number.isFinite(angle)) return false;161  let a = angle;162  if (j.range) a = Math.min(j.range[1], Math.max(j.range[0], a));163  j.body.quaternion.copy(j.baseQuat).multiply(_q.setFromAxisAngle(j.axis, a));164  rig.pose[name] = a;165  return true;166}167 168export function applyPose(rig, pose) {169  for (const [name, ang] of Object.entries(pose)) setJoint(rig, name, ang);170}171 172// MJCF is Z-up, three.js is Y-up. The rig's root already applies Rx(-90) to173// everything below it, so a base pose given in MJCF world coords must be174// mapped into three.js space before it lands on the placer: positions become175// (x, z, -y) and the orientation is conjugated by the same rotation.176const AXIS_FIX = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), -Math.PI / 2);177const AXIS_FIX_INV = AXIS_FIX.clone().invert();178const _qb = new THREE.Quaternion();179 180export function setBasePose(rig, pos, quat) {181  rig.placer.position.set(pos[0], pos[2], -pos[1]);182  if (!quat) return;183  _qb.set(quat[1], quat[2], quat[3], quat[0]);184  rig.placer.quaternion.copy(AXIS_FIX).multiply(_qb).multiply(AXIS_FIX_INV);185}186 187/** three.js world point -> MJCF world point, the inverse of the mapping above. */188export function toMujoco(v) {189  return [v.x, -v.z, v.y];190}191 192/** MJCF world point -> three.js world point. */193export function toThree(p, out) {194  return out.set(p[0], p[2], -p[1]);195}196 197/** Walk up from a hit mesh to the rig body that owns it. */198export function bodyOf(rig, object) {199  for (let o = object; o; o = o.parent) {200    if (rig.bodies.get(o.name) === o) return o.name;201  }202  return null;203}204