SOURCE / PINNED RELEASE
Made of little things.
Body Journal
- Release
- e8049f8da4e2…
- Author-recorded commit
- 61c04bf0e8aa…
- License
- LICENSE
- Author’s source reference
- nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-6c4554844c7
Archive hash verified: 4369d778da046a8b…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import { Vector3, type Bone } from 'three';
import { digits, handSides, parseFingerPose, type FingerPose, type FingerId } from './finger-pose.js';
/** Rest bones have translation-only frames. Bend about axes derived from the
* actual palm, not global Euler X. Ancestor limb rotations then carry the hand. */
export class FingerRig {
private pose: FingerPose = {};
private axes = new Map<Bone, Vector3>();
private tips = new Map<FingerId, Vector3>();
constructor(private bones: Bone[]) {}
snapshot(): FingerPose { return parseFingerPose(this.pose); }
set(pose: FingerPose = {}): void { this.pose = parseFingerPose(pose); this.apply(); }
shape(heads: Float32Array): void {
const point = (name: string) => new Vector3().fromArray(heads, this.bones.findIndex(b => b.name === name) * 3);
for (const side of handSides) {
const suffix = side === 'left' ? 'l' : 'r';
const down = point(`middle_01_${suffix}`).sub(point(`hand_${suffix}`));
const across = point(`index_01_${suffix}`).sub(point(`pinky_01_${suffix}`));
const palm = down.cross(across).normalize().multiplyScalar(side === 'left' ? 1 : -1);
for (const digit of digits) for (let i = 1; i <= 3; i++) {
const name = `${digit}_0${i}_${suffix}`, bone = this.bones.find(b => b.name === name)!;
const direction = i < 3 ? point(`${digit}_0${i + 1}_${suffix}`).sub(point(name)) : point(name).sub(point(`${digit}_02_${suffix}`));
if(i===3)this.tips.set(`${side}-${digit}`,direction.clone().multiplyScalar(.72));
this.axes.set(bone, direction.cross(palm).normalize());
}
}
this.apply();
}
private chain(id: FingerId): Bone[] {
const [side,digit]=id.split('-');
return [1,2,3].map(i=>this.bones.find(b=>b.name===`${digit}_0${i}_${side==='left'?'l':'r'}`)!);
}
endpoint(id: FingerId): Vector3 { return this.chain(id)[2].localToWorld(this.tips.get(id)!.clone()); }
/** Bounded hinge CCD: move only the selected digit, in its posed palm frame. */
solve(id: FingerId, target: Vector3): void {
if(![target.x,target.y,target.z].every(Number.isFinite)||!this.tips.has(id))return;
const chain=this.chain(id),limits=id.endsWith('thumb')?[.65,1.05,1.2]:[1.15,1.55,1.1];
let best=[...(this.pose[id] || [0,0,0])],error=Infinity;
// A bent seed escapes the straight-chain singularity for an inward target.
for(const seed of [best,[.35,.55,.35]]) {
const bends=[...seed];
const apply=()=>{chain.forEach((bone,i)=>bone.quaternion.setFromAxisAngle(this.axes.get(bone)!,bends[i]*limits[i]));chain[0].updateWorldMatrix(true,true);};
apply();
for(let pass=0;pass<24;pass++)for(let i=2;i>=0;i--){
const bone=chain[i],axis=this.axes.get(bone)!.clone().transformDirection(bone.parent!.matrixWorld),pivot=bone.getWorldPosition(new Vector3());
const from=this.endpoint(id).sub(pivot).projectOnPlane(axis),to=target.clone().sub(pivot).projectOnPlane(axis);
if(from.lengthSq()<1e-14||to.lengthSq()<1e-14)continue;
const angle=Math.atan2(axis.dot(from.clone().cross(to)),from.dot(to));
bends[i]=Math.max(0,Math.min(1,bends[i]+angle/limits[i]));apply();
}
const distance=this.endpoint(id).distanceToSquared(target);
if(distance<error){error=distance;best=[...bends];}
}
this.pose[id]=best as [number,number,number];this.apply();chain[0].updateWorldMatrix(true,true);
}
private apply(): void {
for (const side of handSides) for (const digit of digits) {
const bends = this.pose[`${side}-${digit}` as FingerId] || [0, 0, 0];
// Conservative display limits; these do not describe patient mobility.
const limits = digit === 'thumb' ? [.65, 1.05, 1.2] : [1.15, 1.55, 1.1];
for (let i = 0; i < 3; i++) {
const bone = this.bones.find(b => b.name === `${digit}_0${i + 1}_${side === 'left' ? 'l' : 'r'}`)!;
const axis = this.axes.get(bone); if (axis) bone.quaternion.setFromAxisAngle(axis, bends[i] * limits[i]);
}
}
}
}
