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/hand-controls.ts
import type { BodyScene } from './body-scene.js';
import { bendNames, digits, digitNames, type Digit, type HandSide, type FingerPose, type FingerId } from './finger-pose.js';
import { icon } from './icons.js';

/** Inline editor keeps the model interactive while adjusting a digit. */
export class HandControls {
  private panel: HTMLElement;
  private button: HTMLButtonElement;
  private side: HandSide = 'left';
  private digit: Digit = 'index';
  private frame = 0;
  constructor(private workspace: HTMLElement, private scene: BodyScene | undefined, private changed: (pose: FingerPose, persist: boolean) => void, private scope: () => string) {
    this.button=document.createElement('button');this.button.id='fingerControls';this.button.className='pose-button';this.button.innerHTML=`${icon('hand')}<span>Fingers</span>`;
    this.button.setAttribute('aria-expanded','false');this.button.setAttribute('aria-controls','handControls');this.button.disabled=!scene;
    workspace.querySelector('#poseOptions')!.append(this.button);
    this.panel=document.createElement('section');this.panel.id='handControls';this.panel.className='hand-controls';this.panel.hidden=true;this.panel.setAttribute('aria-label','Finger posing');
    this.panel.innerHTML=`<div class="hand-heading"><label><span>Hand</span><select id="handSide"><option value="left">Left hand</option><option value="right">Right hand</option></select></label><label><span>Finger</span><select id="handDigit">${digits.map(d=>`<option value="${d}">${digitNames[d]}</option>`).join('')}</select></label><button id="closeHandControls" aria-label="Close finger controls" title="Close finger controls">×</button></div><div class="finger-bends">${[0,1,2].map(i=>`<label for="fingerBend${i}"><span class="bend-name"></span><input id="fingerBend${i}" type="range" min="0" max="100" step="1" data-bend="${i}" aria-describedby="handHelp" /><output for="fingerBend${i}">0%</output></label>`).join('')}</div><div class="hand-actions"><button id="resetFinger">Reset finger</button><button id="openHand">Open hand</button></div><p id="handHelp">Drag a tip in Pose mode, or use the sliders. Bends are illustrative.</p><p id="handScope"></p>`;
    workspace.append(this.panel);
    this.button.onclick=()=>this.open();
    this.panel.querySelector<HTMLButtonElement>('#closeHandControls')!.onclick=()=>this.close();
    this.panel.querySelector<HTMLSelectElement>('#handSide')!.onchange=e=>{this.side=(e.target as HTMLSelectElement).value as HandSide;this.refresh();this.focusHand();};
    this.panel.querySelector<HTMLSelectElement>('#handDigit')!.onchange=e=>{this.digit=(e.target as HTMLSelectElement).value as Digit;this.refresh();};
    this.panel.querySelector<HTMLButtonElement>('#resetFinger')!.onclick=()=>{const pose=this.scene!.getFingerPose();delete pose[this.id];this.apply(pose,true);};
    this.panel.querySelector<HTMLButtonElement>('#openHand')!.onclick=()=>{const pose=this.scene!.getFingerPose();for(const digit of digits)delete pose[`${this.side}-${digit}`];this.apply(pose,true);};
    this.panel.addEventListener('input',e=>{
      const input=e.target as HTMLInputElement;if(input.dataset.bend===undefined)return;
      const pose=this.scene!.getFingerPose(),values=pose[this.id] || [0,0,0];values[Number(input.dataset.bend)]=Number(input.value)/100;pose[this.id]=values;this.apply(pose,false);
    });
    this.panel.addEventListener('change',e=>{if((e.target as HTMLElement).dataset.bend!==undefined)this.changed(this.scene!.getFingerPose(),true);});
    // Also commit a cancelled touch and a slider release with no change event.
    for(const event of ['pointerup','pointercancel','lostpointercapture'])this.panel.addEventListener(event,()=>this.changed(this.scene!.getFingerPose(),true));
  }
  private get id(): FingerId { return `${this.side}-${this.digit}`; }
  private apply(pose: FingerPose, persist: boolean): void { this.scene!.setFingerPose(pose);this.changed(this.scene!.getFingerPose(),persist);this.refresh(); }
  open(): void {
    if(!this.scene)return;
    this.followView();
    this.panel.hidden=false;this.workspace.classList.add('hand-posing');this.button.setAttribute('aria-expanded','true');this.refresh();
    this.focusHand();this.panel.querySelector<HTMLSelectElement>('#handSide')!.focus({preventScroll:true});
  }
  selectHandle(id: string): void {
    const match=id.match(/^(left|right)-(thumb|index|middle|ring|pinky)$/);
    if(match){this.side=match[1] as HandSide;this.digit=match[2] as Digit;this.refresh();}
  }
  followView(): void {
    if(!this.scene)return;
    const target=this.scene.getInspection().target?.match(/^region:(left|right)-(hand|thumb|index|middle|ring|pinky)$/);
    if(target){this.side=target[1] as HandSide;if(target[2]!=='hand')this.digit=target[2] as Digit;}
    this.refresh();
  }
  close(commit=true): void { cancelAnimationFrame(this.frame);if(commit)this.changed(this.scene!.getFingerPose(),true);this.panel.hidden=true;this.workspace.classList.remove('hand-posing');this.button.setAttribute('aria-expanded','false');if(commit)this.button.focus({preventScroll:true}); }
  layerChanged(refocus=false): void {
    this.button.disabled=!this.scene || ['organs','mouth'].includes(this.scene.getInspection().layer);
    if(this.panel.hidden)return;
    if(this.button.disabled)this.close(false);else if(refocus)this.focusHand();
  }
  private focusHand(): void {
    cancelAnimationFrame(this.frame);
    this.frame=requestAnimationFrame(()=>{
      // Let the layout reserve the editor's space before framing the hand.
      if(this.scene?.focus(`region:${this.side}-hand`))this.scene.setView(this.side==='left'?'left':'right');
    });
  }
  refresh(): void {
    if(this.panel.hidden)return;
    this.panel.querySelector<HTMLSelectElement>('#handSide')!.value=this.side;this.panel.querySelector<HTMLSelectElement>('#handDigit')!.value=this.digit;
    const values=this.scene?.getFingerPose()[this.id] || [0,0,0];
    this.panel.querySelectorAll<HTMLElement>('.finger-bends label').forEach((label,i)=>{
      label.querySelector('.bend-name')!.textContent=bendNames(this.digit)[i];
      const input=label.querySelector('input')!;input.value=String(Math.round(values[i]*100));input.setAttribute('aria-valuetext',`${input.value}% illustration bend`);label.querySelector('output')!.textContent=`${input.value}%`;
    });
    this.panel.querySelector('#handScope')!.textContent=this.scope();
  }
  dispose(): void { cancelAnimationFrame(this.frame); }
}