Back to Body Journal
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.

src/finger-rig.ts
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]);
      }
    }
  }
}