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