CoolFace
Apppublic

lerobot/glove_visualizer

sourceHugging Faceapache-2.0updated 8d agoView on Hugging Face
5likes
hand.js393 linesDownload Raw Back to src
1// Four fingers: universal MCP, PIP and a DIP that copies PIP 1:1.2// Thumb: CMC opposition/pitch, MCP and IP.3// Thumb: bare base segment, then pads on the MCP and IP segments.4import * as T from "../vendor/three.module.js";5import { clamp, vector, quaternion, rotation, components, angleBetween, serializePose, linearSolve, boundedLeastSquares, rotationError } from "./kinematics.js";6 7export const FINGERS = ["thumb", "index", "middle", "ring", "pinky"];8export const HAND_JOINTS = ["mcp_abduction", "mcp_pitch", "pip_pitch"];9export const THUMB_JOINTS = ["cmc_opposition", "cmc_pitch", "mcp_pitch", "ip_pitch"];10export const HAND_MODEL = "mcp-universal-pip-dip-coupled-thumb-middle-distal-pads";11export const PAD_LAYOUT_VERSION = 3;12export const DIP_COUPLINGS = Object.fromEntries(FINGERS.slice(1).map(name => [`${name}_dip_pitch`, { source: `${name}_pip_pitch`, multiplier: 1, offset_rad: 0 }]));13export const handJointNames = name => name === "thumb" ? THUMB_JOINTS : HAND_JOINTS;14export const isThumb = f => typeof f.proximal_cuff_joint === "string";15export const passiveJoints = f => isThumb(f) ? [f.intermediate_joint, f.bridge_joint, f.distal_intermediate_joint] : [f.intermediate_joint];16const LIMITS = [[-0.6, 0.6], [-0.35, 1.75], [0, 2.1]];17const LENGTHS = { thumb: [0.024, 0.024], index: [0.045, 0.043], middle: [0.05, 0.048], ring: [0.046, 0.045], pinky: [0.035, 0.036] };18 19export function fingerFK(f, angles = [0, 0, 0]) {20  const base = vector(f.mcp_m);21  // The thumb mount faces the opposite side of its digit. Positive anatomical22  // flexion must therefore rotate about the opposite local pitch direction.23  const pitch = isThumb(f) ? 1 : -1;24  let mcpQ = quaternion(f.base_quaternion_xyzw);25  if (isThumb(f)) {26    // Opposition carries the whole thumb around one fixed oblique axis.27    // p' = pivot + R(p - pivot): the apparent translation is a circular arc,28    // with no sliding, change of bone length, or independent axial travel.29    const pivot = vector(f.cmc_pivot_m), turn = rotation(f.cmc_axis, angles[0]);30    base.sub(pivot).applyQuaternion(turn).add(pivot);31    mcpQ.premultiply(turn);32  } else mcpQ.multiply(rotation([0, 0, 1], angles[0]));33  mcpQ.multiply(rotation([1, 0, 0], pitch * angles[1]));34  const pip = base.clone().add(new T.Vector3(0, f.proximal_m, 0).applyQuaternion(mcpQ));35  const middleQ = mcpQ.clone().multiply(rotation([1, 0, 0], pitch * angles[2]));36  const ip = pip.clone().add(vector([0, f.middle_m, 0]).applyQuaternion(middleQ));37  const tipAngle = isThumb(f) ? (angles[3] ?? 0) : angles[2];38  const distalQ = middleQ.clone().multiply(rotation([1, 0, 0], pitch * tipAngle));39  const contactQ = isThumb(f) ? distalQ : middleQ;40  const contact = (isThumb(f) ? ip : pip).clone().add(vector([f.lateral_m ?? 0, f.contact_m, f.pivot_height_m]).applyQuaternion(contactQ));41  const proximalContact = isThumb(f) ? pip.clone().add(vector([0, f.proximal_contact_m, f.proximal_pivot_height_m]).applyQuaternion(middleQ)) : null;42  const tip = ip.clone().add(new T.Vector3(0, f.distal_m, 0).applyQuaternion(distalQ));43  return { base, pip, ip, dip: ip, dipAngle: isThumb(f) ? null : tipAngle, contact, proximalContact, tip, mcpQ, middleQ, distalQ, contactQ };44}45 46export function fingerPoses(f, fk) {47  return isThumb(f)48    ? { cmc: serializePose(fk.base, fk.mcpQ), cmc_pivot: serializePose(vector(f.cmc_pivot_m), new T.Quaternion().setFromUnitVectors(vector([0, 0, 1]), vector(f.cmc_axis))),49        mcp: serializePose(fk.pip, fk.middleQ), ip: serializePose(fk.ip, fk.distalQ) }50    : { mcp: serializePose(fk.base, fk.mcpQ), pip: serializePose(fk.pip, fk.middleQ), dip: serializePose(fk.dip, fk.distalQ) };51}52 53export function handMounts(f, fk) {54  return [ ...(isThumb(f) ? [{ name: "middle", segment: "middle", joint: f.proximal_cuff_joint, p: fk.proximalContact, q: fk.middleQ, mount: f.proximal_cuff_quaternion_xyzw, offset: f.proximal_cuff_position_m }] : []),55    { name: isThumb(f) ? "distal" : "middle", segment: isThumb(f) ? "distal" : "middle", joint: f.cuff_joint, p: fk.contact, q: fk.contactQ, mount: f.cuff_quaternion_xyzw, offset: f.cuff_position_m } ];56}57 58// The attachment is the flat CAD base, not the hinge axis. Both its translation59// and orientation rotate with the cuff; moving just a mesh would break the rig.60export function cuffFrame(kin, mount, pose) {61  const frame = kin.jointFrame(mount.joint, pose, true);62  frame.p.add(vector(mount.offset ?? [0, 0, 0]).applyQuaternion(frame.q));63  frame.q.multiply(quaternion(mount.mount));64  return frame;65}66 67function contactResiduals(f, fk, kin, pose, orientation = true) {68  return handMounts(f, fk).flatMap(m => {69    const actual = cuffFrame(kin, m, pose);70    return [...actual.p.sub(m.p).toArray(), ...(orientation ? rotationError(actual.q, m.q, 0.025) : [])];71  });72}73 74export function makeHandConfig(kin) {75  const world = kin.forward({}), fingers = {};76  for (const name of FINGERS) {77    const g = kin.data.groups.find(g => g.label === name);78    if (!g) continue;79    const last = g.joints.at(-1), first = g.joints[0];80    const contact = kin.jointOrigin(last, world).p;81    const direction = contact.clone().sub(kin.jointOrigin(first, world).p);82    const endJoint = kin.joints.get(last).joint;83    const endOrigin = kin.jointOrigin(last, world);84    const x = vector(endJoint.axis).normalize().applyQuaternion(endOrigin.q);85    if (x.x < 0) x.negate();86    const y = name === "thumb" ? direction.clone().addScaledVector(x, -direction.dot(x)).normalize()87      : new T.Vector3(0, 0, 1).cross(x).normalize();88    const z = x.clone().cross(y).normalize();89    const q = new T.Quaternion().setFromRotationMatrix(new T.Matrix4().makeBasis(x, y, z));90    const [proximal_m, distal_m] = LENGTHS[name];91    const f = { proximal_m, middle_m: distal_m * 0.6, distal_m: distal_m * 0.4, contact_m: 0.012, pivot_height_m: 0.009,92      mcp_m: [0, 0, 0], base_quaternion_xyzw: q.toArray(), limits_rad: LIMITS.map(r => [...r]),93      cuff_joint: last, intermediate_joint: g.joints[2], cuff_quaternion_xyzw: [0, 0, 0, 1] };94    if (name === "thumb") Object.assign(f, { proximal_m: 0.024, middle_m: 0.035, distal_m: 0.024, contact_m: 0.0165,95      proximal_contact_m: 0.0175, proximal_pivot_height_m: -0.009, pivot_height_m: -0.009, lateral_m: 0.00185,96      proximal_cuff_joint: g.joints[3], bridge_joint: g.joints[4], distal_intermediate_joint: g.joints[5],97      proximal_cuff_quaternion_xyzw: [0, 0, 0, 1], limits_rad: [LIMITS[0].slice(), LIMITS[1].slice(), LIMITS[2].slice(), [0, 1.7]] });98    if (name === "thumb") {99      const origin = kin.jointOrigin(first, world);100      f.cmc_pivot_m = origin.p.toArray();101      f.cmc_axis = vector(kin.joints.get(first).joint.axis).applyQuaternion(origin.q).normalize().toArray();102      f.limits_rad[0] = [...kin.joints.get(first).joint.range];103    }104    f.mcp_m = contact.clone().sub(vector([0, proximal_m + f.contact_m, f.pivot_height_m]).applyQuaternion(q)).toArray();105    // A starting alignment, explicitly uncalibrated. A flat-hand fit replaces it.106    const linkQ = components(world.get(kin.joints.get(last).name)).q;107    f.cuff_quaternion_xyzw = linkQ.clone().invert().multiply(q).toArray();108    const cad = kin.data.cuff_contacts?.[last];109    f.cuff_position_m = cad?.position_m ?? [0, 0, 0];110    if (cad) f.cuff_quaternion_xyzw = [...cad.quaternion_xyzw];111    if (isThumb(f)) {112      const proximalOrigin = kin.jointOrigin(f.proximal_cuff_joint, world);113      f.mcp_m = proximalOrigin.p.clone().sub(vector([0, f.proximal_m + f.proximal_contact_m, f.proximal_pivot_height_m]).applyQuaternion(q)).toArray();114      f.proximal_cuff_quaternion_xyzw = components(world.get(kin.joints.get(f.proximal_cuff_joint).name)).q.invert().multiply(q).toArray();115      const proximalCAD = kin.data.cuff_contacts?.[f.proximal_cuff_joint];116      f.proximal_cuff_position_m = proximalCAD?.position_m ?? [0, 0, 0];117      if (proximalCAD) f.proximal_cuff_quaternion_xyzw = [...proximalCAD.quaternion_xyzw];118    }119    const anchor = cuffFrame(kin, handMounts(f, fingerFK(f)).at(isThumb(f) ? 0 : -1), {});120    f.mcp_m = anchor.p.sub(vector([isThumb(f) ? 0 : (f.lateral_m ?? 0), isThumb(f) ? f.proximal_m + f.proximal_contact_m : proximal_m + f.contact_m,121      isThumb(f) ? f.proximal_pivot_height_m : f.pivot_height_m]).applyQuaternion(q)).toArray();122    fingers[name] = f;123  }124  return { model: HAND_MODEL, pad_layout_version: PAD_LAYOUT_VERSION, contact_frame: "cad-base", calibrated: false, fingers };125}126 127export function validateHand(hand, kin) {128  if (hand?.model !== HAND_MODEL || hand.contact_frame !== "cad-base" || typeof hand.calibrated !== "boolean") throw new Error("Unsupported hand model");129  if (hand.pad_layout_version !== PAD_LAYOUT_VERSION) throw new Error("Unsupported pad layout");130  for (const name of FINGERS) {131    const f = hand.fingers?.[name];132    if (!f) throw new Error(`Missing hand finger ${name}`);133    if (isThumb(f) !== (name === "thumb")) throw new Error("Only the thumb has two attachments");134    if (name === "thumb") {135      for (const key of ["cmc_pivot_m", "cmc_axis"]) if (!Array.isArray(f[key]) || f[key].length !== 3 || !f[key].every(Number.isFinite)) throw new Error(`Invalid thumb ${key}`);136      if (Math.hypot(...f.cmc_axis) < 1e-6) throw new Error("Thumb CMC axis must have nonzero length");137      f.cmc_axis = vector(f.cmc_axis).normalize().toArray();138    }139    for (const key of ["proximal_m", "middle_m", "distal_m", "contact_m", "pivot_height_m", ...(isThumb(f) ? ["proximal_contact_m", "proximal_pivot_height_m"] : [])]) {140      const min = key.endsWith("pivot_height_m") ? -0.05 : 0;141      if (!Number.isFinite(f[key]) || f[key] < min || f[key] > 0.2) throw new Error(`Invalid ${name} ${key}`);142    }143    if (f.proximal_m < 0.005 || f.distal_m < 0.005 || f.middle_m < 0.005 || f.contact_m < 0.005 || f.contact_m > (isThumb(f) ? f.distal_m : f.middle_m)) throw new Error(`Invalid ${name} segment/contact lengths`);144    if (f.lateral_m !== undefined && (!Number.isFinite(f.lateral_m) || Math.abs(f.lateral_m) > 0.05)) throw new Error(`Invalid ${name} lateral mount offset`);145    for (const key of ["cuff_position_m", ...(isThumb(f) ? ["proximal_cuff_position_m"] : [])]) {146      if (!Array.isArray(f[key]) || f[key].length !== 3 || !f[key].every(v => Number.isFinite(v) && Math.abs(v) <= 0.1)) throw new Error(`Invalid ${name} ${key}`);147    }148    if (!Array.isArray(f.mcp_m) || f.mcp_m.length !== 3 || !f.mcp_m.every(Number.isFinite)) throw new Error(`Invalid ${name} MCP origin`);149    if (isThumb(f) && (f.proximal_contact_m < 0.005 || f.proximal_contact_m > f.middle_m)) throw new Error("Invalid thumb MCP attachment");150    for (const key of ["base_quaternion_xyzw", "cuff_quaternion_xyzw", ...(isThumb(f) ? ["proximal_cuff_quaternion_xyzw"] : [])]) {151      if (!Array.isArray(f[key]) || f[key].length !== 4 || !f[key].every(Number.isFinite) || Math.hypot(...f[key]) < 1e-6) throw new Error(`Invalid ${name} ${key}`);152      f[key] = quaternion(f[key]).toArray();153    }154    if (!Array.isArray(f.limits_rad) || f.limits_rad.length !== handJointNames(name).length || !f.limits_rad.every(r => Array.isArray(r) && r.length === 2 && r.every(Number.isFinite) && r[0] < r[1] && r[0] >= -Math.PI && r[1] <= Math.PI)) throw new Error(`Invalid ${name} hand limits`);155    if (isThumb(f) && f.ip_assumed_rad !== undefined) throw new Error("Thumb IP is solved from its pad; an assumed IP angle is not supported");156    const group = kin.data.groups.find(g => g.label === name);157    if (f.cuff_joint !== group?.joints.at(-1) || f.intermediate_joint !== group.joints[2]) throw new Error(`Invalid ${name} cuff/intermediate joints`);158    if (name === "thumb" && (f.proximal_cuff_joint !== group.joints[3] || f.bridge_joint !== group.joints[4] || f.distal_intermediate_joint !== group.joints[5])) throw new Error("Invalid thumb linkage joints");159  }160  return hand;161}162 163// Calibrate with the real fingers straight and the viewer's intermediate angles164// adjusted so the pads are flat. Measured glove joints retain their current pose.165export function fitFlatHand(hand, kin, pose) {166  const fitted = structuredClone(hand);167  for (const f of Object.values(fitted.fingers)) {168    const anchor = cuffFrame(kin, handMounts(f, fingerFK(f))[0], pose);169    // Register the straight finger to the actual pad plane. Keep the measured170    // CAD mounting rotation fixed so fitting cannot rotate the foot off the pad.171    f.base_quaternion_xyzw = anchor.q.toArray();172    f.mcp_m = anchor.p.sub(vector([isThumb(f) ? 0 : (f.lateral_m ?? 0), (isThumb(f) ? f.proximal_m + f.proximal_contact_m : f.proximal_m + f.contact_m), isThumb(f) ? f.proximal_pivot_height_m : f.pivot_height_m]).applyQuaternion(anchor.q)).toArray();173    if (isThumb(f)) {174      const first = kin.data.groups.find(g => g.label === "thumb").joints[0];175      f.limits_rad[0] = kin.joints.get(first).joint.range.map(v => v - (pose[first] ?? 0));176    }177  }178  fitted.calibrated = true;179  return fitted;180}181 182function optimizeFinger(f, target, initial, atHinge) {183  const point = angles => {184    const fk = fingerFK(f, angles);185    if (atHinge) fk.contact.sub(vector(f.cuff_position_m ?? [0, 0, 0]).applyQuaternion(quaternion(f.cuff_quaternion_xyzw).invert()).applyQuaternion(fk.contactQ));186    return fk.contact;187  };188  let q = initial.map((v, i) => clamp(v, ...f.limits_rad[i]));189  const eps = 1e-5, damping = 1e-7;190  let iterations = 0;191  for (; iterations < 55; iterations++) {192    const p = point(q), error = target.clone().sub(p);193    if (error.length() < 1e-6) break;194    const columns = q.map((_, j) => {195      const sample = [...q]; sample[j] += eps;196      return point(sample).sub(p).divideScalar(eps);197    });198    const a = columns.map((col, i) => columns.map((other, j) => col.dot(other) + (i === j ? damping : 0)));199    const dq = linearSolve(a, columns.map(c => c.dot(error))).map(v => clamp(v, -0.3, 0.3));200    let improved = false;201    for (const step of [1, 0.5, 0.25, 0.1]) {202      const candidate = q.map((v, i) => clamp(v + dq[i] * step, ...f.limits_rad[i]));203      if (point(candidate).distanceToSquared(target) < error.lengthSq() - 1e-15) {204        q = candidate; improved = true; break;205      }206    }207    if (!improved) break;208  }209  return { angles: q, error_m: point(q).distanceTo(target), iterations };210}211 212export function solveFinger(f, target, seed = [0, 0, 0], atHinge = false) {213  let best = optimizeFinger(f, target, seed, atHinge);214  // Escape the straight-finger singularity and retain a previous solution when215  // it fits. The offset pad can admit multiple bounded branches; the seed keeps216  // continuity instead of claiming that position alone picks a unique angle.217  if (best.error_m > 0.0001) for (const trial of [[0, 0.4, 0.8], [0, 0, 1.5], [0, 1.3, 0.3]]) {218    const result = optimizeFinger(f, target, trial, atHinge);219    if (result.error_m < best.error_m) best = result;220  }221  return best;222}223 224// Each encoder fixes a relative glove angle. Only the unsensed intermediate225// hinges may move to close the linkage against the hand's flat pad frames.226export function solveHand(hand, kin, measuredPose, seed = {}) {227  const glovePose = { ...measuredPose }, fingers = {}, joints = {};228  let converged = true;229  for (const [name, f] of Object.entries(hand.fingers)) {230    const thumb = isThumb(f), count = handJointNames(name).length, free = passiveJoints(f);231    const initial = [...handJointNames(name).map((_, i) => seed[name]?.[i] ?? 0), ...free.map(n => measuredPose[n] ?? 0)];232    const limits = [...f.limits_rad, ...free.map(n => kin.joints.get(n).joint.range ?? [-Math.PI, Math.PI])];233    const residual = values => {234      const pose = { ...measuredPose, ...Object.fromEntries(free.map((n, i) => [n, values[count + i]])) };235      return contactResiduals(f, fingerFK(f, values.slice(0, count)), kin, pose);236    };237    let result = boundedLeastSquares(initial, limits, residual, 55);238    // A straight chain has ambiguous branches. Prefer the previous fit, then239    // try bent starts without ever releasing an observed DIP/pad angle.240    if (result.error > 1e-9) {241      const starts = thumb ? [[0, 0.3, 0.5, 0.8], [0, 0.2, 0.2, 0.3]] : [[0, 0.4, 0.8], [0, 1.3, 0.3]];242      for (const angles of starts) {243        const alternate = boundedLeastSquares([...angles, ...initial.slice(count)], limits, residual, 55);244        if (alternate.error < result.error) result = alternate;245        if (result.error <= 1e-9) break;246      }247    }248    free.forEach((n, i) => { glovePose[n] = result.values[count + i]; });249    const angles = result.values.slice(0, count), fk = fingerFK(f, angles), attachments = {};250    for (const m of handMounts(f, fk)) {251      const actual = cuffFrame(kin, m, glovePose);252      attachments[m.name] = { segment: m.segment, pose: serializePose(m.p, m.q), target: serializePose(actual.p, actual.q),253        cuff_joint: m.joint, cuff_angle_rad: measuredPose[m.joint] ?? 0,254        position_error_m: actual.p.distanceTo(m.p), orientation_error_rad: angleBetween(actual.q, m.q) };255    }256    const positionError = Math.max(...Object.values(attachments).map(a => a.position_error_m));257    const orientationError = Math.max(...Object.values(attachments).map(a => a.orientation_error_rad));258    const ok = positionError <= 0.002 && orientationError <= 0.08; converged &&= ok;259    handJointNames(name).forEach((n, i) => { joints[`${name}_${n}`] = angles[i]; });260    if (!thumb) joints[`${name}_dip_pitch`] = fk.dipAngle;261    const attachment = attachments[thumb ? "distal" : "middle"];262    fingers[name] = { angles_rad: angles, converged: ok, position_error_m: positionError, orientation_error_rad: orientationError,263      iterations: result.iterations, attachments, cuff_joint: f.cuff_joint, cuff_angle_rad: measuredPose[f.cuff_joint] ?? 0,264      passive_joints_rad: Object.fromEntries(free.map(n => [n, glovePose[n]])),265      attachment_target: attachment.target, attachment: attachment.pose,266      fingertip: { ...serializePose(fk.tip, fk.distalQ), estimated: true, assumed_joints: thumb ? [] : [`${name}_dip_pitch`], source: thumb ? "attachment_ik" : "pip_coupling" },267      ...fingerPoses(f, fk),268      ...(thumb ? { bridge_joint: f.bridge_joint, bridge_angle_rad: glovePose[f.bridge_joint], ip_angle_source: "attachment_ik", unmeasured_joints: [] }269        : { dip_angle_rad: fk.dipAngle, dip_angle_source: "pip_coupling", unmeasured_joints: [`${name}_dip_pitch`] }) };270  }271  return { converged, glovePose, fingers, joints_rad: joints, joint_couplings: structuredClone(DIP_COUPLINGS) };272}273 274// Reverse direction for the hand demo: articulate the actual glove mesh to the275// hand's mount frames. The thumb has two simultaneous position/orientation goals.276export function solveGloveOverlay(hand, kin, angles, initialPose = {}) {277  const pose = { ...initialPose }, fits = {};278  for (const name of FINGERS) {279    const f = hand.fingers[name], fk = fingerFK(f, angles[name]);280    const names = kin.data.groups.find(g => g.label === name).joints;281    const limits = names.map(n => kin.joints.get(n).joint.range ?? [-Math.PI, Math.PI]);282    const residual = values => contactResiduals(f, fk, kin, { ...pose, ...Object.fromEntries(names.map((n, i) => [n, values[i]])) });283    let result = boundedLeastSquares(names.map(n => pose[n] ?? 0), limits, residual, 30);284    if (result.error > 2e-5 && initialPose[names[0]] === undefined) {285      const alternate = boundedLeastSquares(limits.map(([a, b]) => (a + b) * 0.5), limits, residual, 40);286      if (alternate.error < result.error) result = alternate;287    }288    names.forEach((n, i) => { pose[n] = result.values[i]; });289    fits[name] = handMounts(f, fk).map(m => {290      const actual = cuffFrame(kin, m, pose);291      return { name: m.name, position_error_m: actual.p.distanceTo(m.p), orientation_error_rad: angleBetween(actual.q, m.q) };292    });293  }294  return { pose, fits };295}296 297// A saddle with a flat CAD mounting face at local z = 0 and a curved inner298// surface around the finger. Extrusion runs along the segment's local Y axis.299// Mirroring the mesh's Z scale makes the thumb saddles wrap from the other side.300function makeCuffGeometry(height, lateral) {301  height = Math.max(height, .002);302  const radius = Math.min(.0073, height - .001);303  const halfWidth = Math.max(.009, Math.abs(lateral) + radius + .0015);304  const corner = .0015, wrap = 80 * Math.PI / 180;305  const endZ = -height + radius * Math.cos(wrap);306  const shape = new T.Shape();307  shape.moveTo(-halfWidth + corner, 0);308  shape.lineTo(halfWidth - corner, 0);309  shape.quadraticCurveTo(halfWidth, 0, halfWidth, -corner);310  shape.lineTo(halfWidth, endZ);311  for (let i = 0; i <= 32; i++) {312    const angle = wrap * (1 - i / 16);313    shape.lineTo(-lateral + radius * Math.sin(angle), -height + radius * Math.cos(angle));314  }315  shape.lineTo(-halfWidth, endZ);316  shape.lineTo(-halfWidth, -corner);317  shape.quadraticCurveTo(-halfWidth, 0, -halfWidth + corner, 0);318  shape.closePath();319  const geometry = new T.ExtrudeGeometry(shape, { depth: .015, steps: 1, bevelEnabled: false, curveSegments: 8 });320  geometry.translate(0, 0, -.0075).rotateX(Math.PI / 2);321  return geometry;322}323 324// Three segments per digit. Long-finger DIP follows PIP; thumb has two cuffs.325export function buildHandVisual(parent) {326  const root = new T.Group(); root.name = "anatomical_hand"; parent.add(root);327  const material = new T.MeshStandardMaterial({ color: 0xd5ac86, roughness: 0.85, transparent: true, opacity: 0.75 });328  const jointMaterial = new T.MeshStandardMaterial({ color: 0xffbd77 });329  const cuffMaterial = new T.MeshStandardMaterial({ color: 0x53d6c5, transparent: true, opacity: 0.7 });330  const boneGeom = new T.CylinderGeometry(1, 1, 1, 16);331  const sphereGeom = new T.SphereGeometry(1, 16, 10);332  const parts = {};333  for (const name of FINGERS) {334    const count = 3;335    const bones = Array.from({ length: count }, () => new T.Mesh(boneGeom, material));336    const joints = Array.from({ length: count }, () => new T.Mesh(name === "thumb" ? boneGeom : sphereGeom, jointMaterial));337    const cuffs = Array.from({ length: name === "thumb" ? 2 : 1 }, () => new T.Mesh(new T.BufferGeometry(), cuffMaterial)), axes = new T.AxesHelper(0.013);338    // A mirrored shape still displays proper right-handed local pose axes.339    axes.scale.x = parent.scale.x < 0 ? -1 : 1;340    root.add(...bones, ...joints, ...cuffs, axes); parts[name] = { bones, joints, cuffs, axes };341  }342  const palmGeometry = new T.BufferGeometry();343  palmGeometry.setAttribute("position", new T.Float32BufferAttribute(new Float32Array(36), 3));344  const palm = new T.Mesh(palmGeometry, new T.MeshStandardMaterial({ color: 0xc39a74, transparent: true, opacity: 0.45, side: T.DoubleSide }));345  root.add(palm);346  const update = (hand, solution) => {347    for (const [name, f] of Object.entries(hand.fingers)) {348      const result = solution.fingers[name], fk = fingerFK(f, result.angles_rad), p = parts[name];349      const segments = [[fk.base, fk.pip], [fk.pip, fk.ip], [fk.ip, fk.tip]];350      segments.forEach(([a, b], i) => {351        const delta = b.clone().sub(a);352        p.bones[i].position.copy(a).add(b).multiplyScalar(0.5);353        p.bones[i].quaternion.setFromUnitVectors(new T.Vector3(0, 1, 0), delta.clone().normalize());354        p.bones[i].scale.set(0.007, delta.length(), 0.007);355      });356      [fk.base, fk.pip, fk.ip].forEach((v, i) => {357        p.joints[i].position.copy(v);358        if (isThumb(f)) {359          const q = [fk.mcpQ, fk.middleQ, fk.distalQ][i];360          p.joints[i].quaternion.setFromUnitVectors(vector([0, 1, 0]), vector([1, 0, 0]).applyQuaternion(q));361          p.joints[i].scale.set(0.0075, 0.016, 0.0075);362        } else p.joints[i].scale.setScalar(0.0075);363      });364      handMounts(f, fk).forEach((m, i) => {365        const cuff = p.cuffs[i], firstThumb = isThumb(f) && i === 0;366        const height = firstThumb ? f.proximal_pivot_height_m : f.pivot_height_m;367        const lateral = firstThumb ? 0 : (f.lateral_m ?? 0);368        const shapeKey = `${height}:${lateral}`;369        if (cuff.userData.shapeKey !== shapeKey) {370          cuff.geometry.dispose(); cuff.geometry = makeCuffGeometry(Math.abs(height), lateral);371          cuff.userData.shapeKey = shapeKey;372        }373        cuff.position.copy(m.p); cuff.quaternion.copy(m.q); cuff.scale.set(1, 1, height < 0 ? -1 : 1);374      });375      p.axes.position.copy(fk.tip); p.axes.quaternion.copy(fk.distalQ);376    }377    const bases = ["index", "middle", "ring", "pinky"].map(n => vector(hand.fingers[n].mcp_m));378    const wrist = bases[0].clone().add(bases[3]).multiplyScalar(0.5)379      .add(vector([0, -0.065, 0]).applyQuaternion(quaternion(hand.fingers.middle.base_quaternion_xyzw)));380    const vertices = [];381    for (let i = 0; i < bases.length - 1; i++) vertices.push(...wrist.toArray(), ...bases[i].toArray(), ...bases[i + 1].toArray());382    const thumbBase = fingerFK(hand.fingers.thumb, solution.fingers.thumb.angles_rad).base;383    vertices.push(...wrist.toArray(), ...bases[0].toArray(), ...thumbBase.toArray());384    palmGeometry.attributes.position.array.set(vertices); palmGeometry.attributes.position.needsUpdate = true;385    palmGeometry.computeVertexNormals(); palmGeometry.computeBoundingSphere();386  };387  return { root, parts, update, dispose() {388    parent.remove(root); boneGeom.dispose(); sphereGeom.dispose(); palmGeometry.dispose();389    material.dispose(); jointMaterial.dispose(); cuffMaterial.dispose(); palm.material.dispose();390    for (const p of Object.values(parts)) { p.axes.dispose(); for (const cuff of p.cuffs) cuff.geometry.dispose(); }391  } };392}393