lerobot/glove_visualizer
5
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 