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 Camera } from 'three';
import { digits, digitNames } from './finger-pose.js';
import { anatomyLeaves, flattenAnatomy, type AnatomyNode } from './anatomy-tree.js';
const definitions = ['left','right'].flatMap(side=>[
{id:`${side}-arm`,label:`${side} wrist`,region:`region:${side}-hand`,finger:false},
{id:`${side}-hip`,label:`${side} ankle`,region:`region:${side}-foot`,finger:false},
...digits.map(d=>({id:`${side}-${d}`,label:`${side} ${digitNames[d].toLowerCase()} tip`,region:`region:${side}-${d}`,finger:true})),
]);
/** Projected UI spheres stay out of rendered evidence and offer keyboard targets. */
export class PoseHandles {
private available = new Set<string>();
private handTargets = new Map<string,string>();
private items: {id:string;label:string;region:string;finger:boolean;el:HTMLButtonElement}[];
constructor(private container:HTMLElement,down:(e:PointerEvent)=>void,wheel:(e:WheelEvent)=>void,key:(id:string,e:KeyboardEvent)=>void) {
this.items=definitions.map(def=>{
const el=document.createElement('button');el.className='pose-handle';el.hidden=true;el.dataset.handle=def.id;el.setAttribute('aria-label',`Move ${def.label}`);el.setAttribute('aria-pressed','false');
el.title=`${def.label}: drag or use arrow keys`;el.innerHTML=`<span class="handle-ball"></span><span class="handle-label">${def.label}</span>`;
el.onpointerdown=e=>down(e);el.onwheel=e=>wheel(e);el.oncontextmenu=e=>e.preventDefault();el.onkeydown=e=>key(def.id,e);
container.append(el);return {...def,el};
});
}
visibility(tree:AnatomyNode,hidden:readonly string[]):void {
const nodes=new Map(flattenAnatomy(tree).map(n=>[n.id,n])),off=new Set(hidden);
this.available=new Set(this.items.filter(item=>{const node=nodes.get(item.region);return node&&anatomyLeaves(node).some(id=>!off.has(id));}).map(item=>item.id));
this.handTargets.clear();
for(const side of ['left','right']){const hand=nodes.get(`region:${side}-hand`);if(hand)for(const node of flattenAnatomy(hand))this.handTargets.set(node.id,side);}
}
update(active:boolean,camera:Camera,point:(id:string)=>Vector3,focus:string|null):void {
const rect=this.container.getBoundingClientRect();
const handSide=this.handTargets.get(focus || '');
for(const item of this.items){
item.el.hidden=true;if(!active||!this.available.has(item.id))continue;
if(handSide && (!item.id.startsWith(handSide)||item.id.endsWith('-hip')))continue;
const p=point(item.id).project(camera);
if(p.z< -1||p.z>1||Math.abs(p.x)>.98||Math.abs(p.y)>.98)continue;
if(item.finger){
const side=item.id.split('-')[0],wrist=point(`${side}-arm`).project(camera),middle=point(`${side}-middle`).project(camera);
if(Math.hypot((wrist.x-middle.x)*rect.width/2,(wrist.y-middle.y)*rect.height/2)<75)continue;
}
item.el.hidden=false;item.el.style.left=`${(p.x*.5+.5)*rect.width}px`;item.el.style.top=`${(-p.y*.5+.5)*rect.height}px`;
}
}
pick(e:Pick<PointerEvent,'clientX'|'clientY'>):string|undefined {
const radius=matchMedia('(pointer:coarse)').matches?24:17;
return this.items.filter(item=>!item.el.hidden).map(item=>{const r=item.el.getBoundingClientRect();return {id:item.id,d:Math.hypot(e.clientX-r.x-r.width/2,e.clientY-r.y-r.height/2)};}).filter(item=>item.d<=radius).sort((a,b)=>a.d-b.d)[0]?.id;
}
select(id:string):void {for(const item of this.items)item.el.setAttribute('aria-pressed',String(item.id===id));this.items.find(item=>item.id===id)?.el.focus({preventScroll:true});}
dispose():void {for(const item of this.items)item.el.remove();}
}
