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/jaw-controls.ts
import type { BodyScene } from './body-scene.js';

/** Inline, keyboard/touch-accessible editor. Focus is always an explicit action. */
export class JawControls {
  private panel: HTMLElement;
  private button: HTMLButtonElement;
  constructor(private workspace: HTMLElement, private scene: BodyScene | undefined, private changed: (opening: number, persist: boolean) => void, private scope: () => string) {
    this.button = document.createElement('button'); this.button.id='jawControlsButton'; this.button.className='pose-button'; this.button.hidden=true;
    this.button.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M4 7q8-4 16 0v9q-8 6-16 0Z M4 10q8 4 16 0 M4 14q8 4 16 0"/></svg><span>Jaw</span>';
    this.button.setAttribute('aria-expanded','false'); this.button.setAttribute('aria-controls','jawControls');
    workspace.querySelector('#poseOptions')!.append(this.button);
    this.panel=document.createElement('section'); this.panel.id='jawControls'; this.panel.className='hand-controls jaw-controls'; this.panel.hidden=true; this.panel.setAttribute('aria-label','Jaw opening');
    this.panel.innerHTML='<div class="jaw-heading"><strong>Mouth inspection</strong><button id="closeJawControls" aria-label="Close jaw controls">×</button></div><div class="finger-bends"><label for="jawOpening"><span>Jaw opening</span><input id="jawOpening" type="range" min="0" max="100" step="1" aria-describedby="jawHelp"/><output for="jawOpening">0%</output></label></div><div class="hand-actions"><button id="focusMouth">Focus mouth</button><button id="closeJaw">Closed</button><button id="openJaw">Open</button></div><p id="jawHelp">Illustration only, not measured motion. 28 adult teeth · FDI numbers. Roots are exposed; gums, palate and wisdom teeth aren’t modeled.</p><p id="jawScope"></p>';
    workspace.append(this.panel);
    this.button.onclick=()=>this.open();
    this.panel.querySelector<HTMLButtonElement>('#closeJawControls')!.onclick=()=>this.close();
    this.panel.querySelector<HTMLButtonElement>('#focusMouth')!.onclick=()=>this.scene?.focus('region:mouth');
    this.panel.querySelector<HTMLButtonElement>('#closeJaw')!.onclick=()=>this.apply(0,true);
    this.panel.querySelector<HTMLButtonElement>('#openJaw')!.onclick=()=>this.apply(1,true);
    const input=this.panel.querySelector<HTMLInputElement>('#jawOpening')!;
    input.oninput=()=>this.apply(Number(input.value)/100,false);
    input.onchange=()=>this.changed(this.scene!.getJawOpening(),true);
    for (const event of ['pointerup','pointercancel','lostpointercapture']) input.addEventListener(event,()=>this.changed(this.scene!.getJawOpening(),true));
  }
  private apply(value: number, persist: boolean): void { this.scene!.setJawOpening(value);this.changed(value,persist);this.refresh(); }
  open(): void { if (!this.scene) return;this.panel.hidden=false;this.workspace.classList.add('hand-posing');this.button.setAttribute('aria-expanded','true');this.refresh(); }
  close(focus=true): void { this.panel.hidden=true;this.workspace.classList.remove('hand-posing');this.button.setAttribute('aria-expanded','false');if(focus){this.changed(this.scene!.getJawOpening(),true);this.button.focus({preventScroll:true});} }
  layerChanged(): void {
    const available=!!this.scene && this.scene.getInspection().layer==='mouth';this.button.hidden=!available;
    if (available) this.open(); else if (!this.panel.hidden) this.close(false);
  }
  refresh(): void {
    if (this.panel.hidden) return;
    const input=this.panel.querySelector<HTMLInputElement>('#jawOpening')!;input.value=String(Math.round((this.scene?.getJawOpening() || 0)*100));
    input.setAttribute('aria-valuetext',`${input.value}% illustration opening`);this.panel.querySelector('output')!.textContent=`${input.value}%`;
    this.panel.querySelector('#jawScope')!.textContent=this.scope();
  }
}