CoolFace
Apppublic

lerobot/glove_visualizer

sourceHugging Faceapache-2.0updated 8d agoView on Hugging Face
5likes
main.js245 linesDownload Raw Back to src
1// Static URDF viewer for the Project Homunculus hardware.2//3// Everything is client side: each model is a kinematics.json (the tree) plus a4// packed GLB (the meshes), both produced by build_space.sh. There is no5// backend, so the sliders drive forward kinematics in the browser.6//7// Lighting and palette follow the g1 viewer: INK background, room environment8// for PBR reflections, one key light plus a warm rim.9 10import * as THREE from "three";11import { OrbitControls } from "three/addons/controls/OrbitControls.js";12import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";13import { buildRig, loadKinematics, setJoint, applyPose } from "./robot.js";14import { buildPanel, bindKeys } from "./panel.js";15import { GloveController } from "./glove-panel.js";16import { gloveControlLayout } from "./glove.js";17 18const INK = 0x08080c;19const MODELS_URL = "./robot/models.json";20 21const el = (id) => document.getElementById(id);22 23function buildScene(canvasParent) {24  const renderer = new THREE.WebGLRenderer({ antialias: true });25  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));26  renderer.setSize(innerWidth, innerHeight);27  renderer.setClearColor(INK, 1);28  renderer.outputColorSpace = THREE.SRGBColorSpace;29  renderer.toneMapping = THREE.ACESFilmicToneMapping;30  renderer.toneMappingExposure = 1.05;31  renderer.shadowMap.enabled = true;32  renderer.shadowMap.type = THREE.PCFSoftShadowMap;33  canvasParent.appendChild(renderer.domElement);34 35  const scene = new THREE.Scene();36  scene.background = new THREE.Color(INK);37  const pmrem = new THREE.PMREMGenerator(renderer);38  scene.environment = pmrem.fromScene(new RoomEnvironment()).texture;39  scene.environmentIntensity = 0.45;40 41  const camera = new THREE.PerspectiveCamera(40, innerWidth / innerHeight, 0.001, 200);42 43  scene.add(new THREE.AmbientLight(0xffffff, 0.6));44  const key = new THREE.DirectionalLight(0xffffff, 1.6);45  key.castShadow = true;46  key.shadow.mapSize.set(2048, 2048);47  key.shadow.bias = -0.0005;48  scene.add(key);49  scene.add(fill(0xffffff, 0.4, [-3, 2.5, 2]));50  scene.add(fill(0xffb366, 0.7, [0, 3, -3]));51 52  const controls = new OrbitControls(camera, renderer.domElement);53  controls.enableDamping = true;54  controls.dampingFactor = 0.08;55  controls.maxPolarAngle = Math.PI; // inspect the hand from above and underneath56 57  return { renderer, scene, camera, controls, key };58}59 60function fill(color, intensity, [x, y, z]) {61  const l = new THREE.DirectionalLight(color, intensity);62  l.position.set(x, y, z);63  return l;64}65 66/** Shadow catcher plus a two-tier grid, both sized to whatever is on stage. */67function buildFloor(scene, radius) {68  const group = new THREE.Group();69  const extent = radius * 16;70 71  const shadow = new THREE.Mesh(72    new THREE.PlaneGeometry(extent, extent),73    // depthWrite off: the catcher sits in the same plane as the grid, and74    // writing depth lets it z-fight the lines away.75    new THREE.ShadowMaterial({ opacity: 0.5, depthWrite: false }),76  );77  shadow.rotation.x = -Math.PI / 2;78  shadow.receiveShadow = true;79  group.add(shadow);80 81  // Cell size is derived from the subject: a 40 m grid of 0.1 m cells reads as82  // a floor under a humanoid and as solid fill under a 20 cm glove.83  const cell = radius / 2;84  const fine = new THREE.GridHelper(extent, Math.round(extent / cell), 0x2b323d, 0x2b323d);85  const section = new THREE.GridHelper(extent, Math.round(extent / (cell * 5)), 0x55637a, 0x55637a);86  fine.position.y = radius * 1e-3;87  section.position.y = radius * 2e-3;88  group.add(fine, section);89 90  scene.add(group);91  return group;92}93 94/** Sit the rig on the floor, centred, and return how big it turned out. */95function ground(rig) {96  const box = new THREE.Box3().setFromObject(rig.placer);97  const centre = box.getCenter(new THREE.Vector3());98  rig.placer.position.set(-centre.x, -box.min.y, -centre.z);99 100  const size = box.getSize(new THREE.Vector3());101  const radius = Math.max(size.x, size.y, size.z) / 2 || 0.1;102  return { radius, height: size.y };103}104 105function frame({ camera, controls, key }, radius, height) {106  const target = new THREE.Vector3(0, height / 2, 0);107  // Off-axis and slightly above: a straight-on view of a hand reads flat.108  camera.position.set(radius * 1.6, height / 2 + radius * 1.3, radius * 2.2);109  camera.near = radius / 100;110  camera.far = radius * 200;111  camera.updateProjectionMatrix();112  controls.target.copy(target);113  controls.minDistance = radius * 0.4;114  controls.maxDistance = radius * 20;115  controls.update();116 117  key.position.set(radius * 3, radius * 5, radius * 3);118  const s = radius * 1.6;119  Object.assign(key.shadow.camera, { left: -s, right: s, top: s, bottom: -s, far: radius * 20 });120  key.shadow.camera.updateProjectionMatrix();121}122 123/** Release the GPU buffers of a rig we are about to drop. */124function dispose(scene, rig) {125  scene.remove(rig.placer);126  rig.placer.traverse((o) => {127    if (!o.isMesh) return;128    o.geometry?.dispose();129    // Materials are shared across meshes by colour, so disposing per mesh130    // would free the same one repeatedly; harmless, and simpler than a set.131    o.material?.dispose();132  });133}134 135export async function main(progress) {136  progress("loading models…");137  const r = await fetch(MODELS_URL);138  if (!r.ok) throw new Error(`models.json fetch ${r.status}`);139  const models = await r.json();140  if (!models.length) throw new Error("models.json is empty");141 142  const view = buildScene(document.body);143  const { renderer, scene, camera, controls } = view;144 145  let current = null; // { rig, floor, panel }146  let busy = false;147  const buttons = new Map();148 149  const show = async (model) => {150    if (busy) return;151    busy = true;152    for (const [id, b] of buttons) {153      b.classList.toggle("on", id === model.id);154      b.disabled = true;155    }156    buttons.get(model.id).classList.add("loading");157 158    try {159      const k = await loadKinematics(model.kinematics);160      const rig = await buildRig(k, { glbUrl: model.glb });161      rig.root.scale.x = -1; // Mirror both the glove and hand before centring.162      scene.add(rig.placer);163 164      const { radius, height } = ground(rig);165      frame(view, radius, height);166      // Fog hides the grid's outer edge; tie it to the subject so it never167      // swallows the model or leaves the horizon hard.168      scene.fog = new THREE.Fog(INK, radius * 3, radius * 14);169 170      if (current) {171        await current.glove?.dispose();172        dispose(scene, current.rig);173        scene.remove(current.floor);174      }175      const floor = buildFloor(scene, radius);176 177      let glove = null;178      const panel = buildPanel(el("controls"), model.id === "glove" ? gloveControlLayout(k) : k, (joint, value, label) => {179        setJoint(rig, joint, value);180        el("joint").textContent = `${label} ${value >= 0 ? "+" : ""}${value.toFixed(2)}`;181        glove?.manualChanged();182      });183      if (model.id === "glove") glove = new GloveController({ rig, kinematics: k, panel, container: el("glove-input"), applyJointPose: pose => applyPose(rig, pose) });184      else el("glove-input").hidden = true;185      current = { rig, floor, panel, glove };186      el("hint").textContent = glove ? "Drag to orbit above or below · scroll to zoom · calibration at left" : "drag a slider to pose a joint";187 188      const meshes = k.bodies.reduce((n, b) => n + b.geoms.length, 0);189      el("model").textContent = k.name ?? model.label;190      el("subline").textContent = model.note ?? "";191      el("stats").textContent = `${panel.count} dof · ${rig.bodies.size} links · ${meshes} meshes`;192      el("joint").innerHTML = " ";193    } finally {194      busy = false;195      for (const b of buttons.values()) b.disabled = false;196      buttons.get(model.id).classList.remove("loading");197    }198  };199 200  const tabs = el("tabs");201  for (const model of models) {202    const btn = document.createElement("button");203    btn.textContent = model.tab;204    btn.title = model.label;205    btn.className = "pbtn";206    btn.onclick = () => show(model).catch(error => { el("hint").textContent = `Model load failed: ${error.message}`; console.error(error); });207    buttons.set(model.id, btn);208    tabs.appendChild(btn);209  }210 211  const zero = document.createElement("button");212  zero.textContent = "ZERO";213  zero.className = "pbtn ghost";214  zero.title = "Return every joint to zero";215  zero.onclick = () => (current?.glove?.activePanel ?? current?.panel).zero();216  tabs.appendChild(zero);217 218  const fist = document.createElement("button");219  fist.textContent = "FIST";220  fist.className = "pbtn ghost";221  fist.title = "Curl the flexion joints";222  fist.onclick = () => (current?.glove?.activePanel ?? current?.panel).fist();223  tabs.appendChild(fist);224 225  bindKeys(() => current?.glove?.activePanel ?? current?.panel);226 227  progress("loading meshes…");228  await show(models[0]);229 230  addEventListener("resize", () => {231    camera.aspect = innerWidth / innerHeight;232    camera.updateProjectionMatrix();233    renderer.setSize(innerWidth, innerHeight);234  });235 236  renderer.setAnimationLoop(() => {237    controls.update();238    if (current) current.floor.visible = camera.position.y >= 0;239    renderer.render(scene, camera);240  });241 242  window.__viewer = { scene, camera, renderer, get rig() { return current?.rig; } };243  return view;244}245