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/body-scene.ts
import mouthBytes from '../assets/mouth/mouth.bin?inline';
import { decodeMouth } from './mouth-data.js';
import { AmbientLight, Bone, Color, DirectionalLight, Group, HemisphereLight, Mesh, MeshStandardMaterial, PerspectiveCamera, Plane, Raycaster, Scene, Vector2, Vector3, WebGLRenderer, RingGeometry, CircleGeometry, MeshBasicMaterial, DoubleSide } from 'three';
import { type Concern, type Mark, type Mode, type Point3, type Pose } from './case.js';
import { SkinModel, decodeSkin } from './skin-model.js';
import type { AnatomyChoice } from './anatomy-contract.js';
import { captureBodyImages } from './body-capture.js';
import type { VisualEvidence } from './vision-contract.js';
import { decodeSkeleton } from './skeleton-data.js';
import { markLayer, type AnatomyLayer } from './layers.js';
import skeletonBytes from '../assets/skeleton/skeleton.bin?inline';
import muscleBytes from '../assets/anatomy/muscles.bin?inline';
import organBytes from '../assets/anatomy/organs.bin?inline';
import { decodeAtlas } from './atlas-data.js';
import { MUSCLE_MODEL, ORGAN_MODEL } from './layers.js';
import skinBytes from '../assets/skin/body-skin.bin?inline';
import { SceneGestures } from './scene-gestures.js';
import { BodyInspection } from './body-inspection.js';
import { type InspectionView } from './inspection-record.js';
import type { FingerPose, FingerId } from './finger-pose.js';
import { PoseHandles } from './pose-handles.js';

export interface SceneCallbacks {
  mark: (location: string, mark: Mark, newStroke: boolean) => void;
  pose: (joints: Record<string, Point3>) => void;
  hover: (location: string) => void;
  handle?: (id: string) => void;
  view?: (view: InspectionView, persist?: boolean) => void;
}

export class BodyScene {
  private renderer: WebGLRenderer;
  private scene = new Scene();
  private camera = new PerspectiveCamera(31, 1, 0.01, 30);
  private human = new Group();
  private root = new Group();
  private torso = new Group();
  private model!: SkinModel;
  private inspection!: BodyInspection;
  private joints = new Map<string, Bone>();
  private dragOffset = new Vector3();
  private bodyHeight = 172;
  private ray = new Raycaster();
  private pointer = new Vector2();
  private mode: Mode = 'rotate';
  private yaw = -0.14;
  private pitch = 0.02;
  private zoom = 3.8;
  private dragMode:Mode='rotate';
  private dragJoint = '';
  private handles!: PoseHandles;
  private last = new Vector2();
  private lastPaint = new Vector2(-100, -100);
  private stroke = 0;
  private freshStroke = true;
  private paintSize = 0.022;
  private resizeObserver: ResizeObserver;
  private animationId = 0;
  private wheelSaveTimer = 0;
  private poseName: Pose = 'standing';
  private skin = new MeshStandardMaterial({ color: '#aeb1b8', roughness: 0.8, metalness: 0 });
  private stageCenter = new Vector3(0, 0.96, 0);
  private paused = true;
  private elapsed = 0;
  private previousTime = 0;
  private markers: { el: HTMLButtonElement; concern: Concern }[] = [];
  private plane = new Plane(new Vector3(0, 0, 1), 0);
  private gestures=new SceneGestures({
    start:e=>this.startTool(e),move:e=>this.moveTool(e),end:()=>{this.dragJoint='';},
    hover:e=>{const hit=this.hit(e);this.callbacks.hover(hit?this.model.mark(hit,this.paintSize,this.stroke)?.label || '':'');},
    rotate:(dx,dy)=>this.rotate(dx,dy),zoom:delta=>this.changeZoom(delta,false),
  });
  private reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;

  constructor(private container: HTMLElement, private callbacks: SceneCallbacks) {
    this.renderer = new WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'low-power' });
    this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
    this.renderer.setClearColor(new Color('#ffffff'), 0);
    this.renderer.domElement.setAttribute('aria-label', 'Interactive body. Choose mark to add a concern, rotate to explore, or pose to adjust a limb.');
    this.renderer.domElement.setAttribute('role', 'img');
    this.container.prepend(this.renderer.domElement);
    this.scene.add(new AmbientLight('#ffffff', 1.3));
    this.scene.add(new HemisphereLight('#ffffff', '#51545c', 1.4));
    const light = new DirectionalLight('#ffffff', 2.6); light.position.set(-3, 5, 4); this.scene.add(light);
    const rim = new DirectionalLight('#ffffff', 1.3); rim.position.set(3, 2, -2); this.scene.add(rim);
    this.scene.add(this.root); this.root.add(this.human); this.human.add(this.torso);
    this.buildBody();
    this.handles = new PoseHandles(container,this.onDown,this.onWheel,this.handleKey);
    this.refreshHandles();
    const floor = new Mesh(new CircleGeometry(0.42, 64), new MeshBasicMaterial({ color: '#90949b', transparent: true, opacity: 0.14, depthWrite: false }));
    floor.rotation.x = -Math.PI / 2; floor.position.y = -0.015; this.scene.add(floor);
    const ring = new Mesh(new RingGeometry(0.42, 0.424, 100), new MeshBasicMaterial({ color: '#aeb1b7', side: DoubleSide }));
    ring.rotation.x = -Math.PI / 2; ring.position.y = -0.016; this.scene.add(ring);
    this.setPose('standing');
    const canvas = this.renderer.domElement;
    canvas.addEventListener('pointerdown', this.onDown);
    canvas.addEventListener('pointermove', this.onMove);
    canvas.addEventListener('pointerup', this.onUp);
    canvas.addEventListener('pointercancel', this.onUp);
    canvas.addEventListener('lostpointercapture', this.onUp);
    canvas.addEventListener('wheel', this.onWheel, { passive: false });
    canvas.addEventListener('contextmenu', e => e.preventDefault());
    window.addEventListener('blur', this.onBlur);
    this.resizeObserver = new ResizeObserver(() => this.resize());
    this.resizeObserver.observe(container); this.resize();
    this.tick(0);
  }

  private buildBody(): void {
    const raw = atob(skinBytes.split(',')[1]);
    const bytes = Uint8Array.from(raw, c => c.charCodeAt(0));
    const unpack=(value:string)=>Uint8Array.from(atob(value.split(',')[1]),c=>c.charCodeAt(0)).buffer;
    this.model = new SkinModel(decodeSkin(bytes.buffer),this.skin,decodeSkeleton(unpack(skeletonBytes)),[decodeAtlas(unpack(muscleBytes),MUSCLE_MODEL),decodeAtlas(unpack(organBytes),ORGAN_MODEL)],decodeMouth(unpack(mouthBytes)));
    this.joints = this.model.joints;
    this.inspection = new BodyInspection(this.model);
    this.model.group.position.y = -0.906;
    this.torso.add(this.model.group);
    this.container.dataset.model = 'mpfb-skin-1';
  }
  setBody(height: number, weight: number, sex = '', anatomy: AnatomyChoice = 'auto'): void {
    const view=this.getInspection();this.inspection.clear();
    this.bodyHeight = height;
    this.human.scale.setScalar(height / 172);
    this.model.shape(height,weight,sex,anatomy);
    this.restoreInspection(view);
  }
  private updateCenter(): void {
    const bounds=this.inspection.bounds();
    if(bounds) bounds.getCenter(this.stageCenter);
    else this.stageCenter.set(0,(this.poseName === 'lying' ? .46 : this.poseName === 'sitting' ? .78 : .88) * this.bodyHeight / 172,0);
  }
  private refreshHandles(): void {this.handles?.visibility(this.inspection.tree(),this.inspection.hidden);}
  private handlePoint = (id:string):Vector3 => /-(arm|hip)$/.test(id)?this.model.endpoint(id):this.model.fingers.endpoint(id as FingerId);
  private solveHandle(id:string,target:Vector3):void {
    if(/-(arm|hip)$/.test(id))this.model.solveLimb(id,target);else this.model.fingers.solve(id as FingerId,target);
    this.model.update();this.callbacks.pose(this.getPoseSnapshot());
  }
  private handleKey = (id:string,e:KeyboardEvent):void => {
    if(!['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key))return;
    e.preventDefault();this.paused=true;this.handles.select(id);this.callbacks.handle?.(id);
    const step=this.zoom*Math.tan(this.camera.fov*Math.PI/360)*2/this.container.clientHeight*(e.shiftKey?2:8);
    const delta=new Vector3(e.key==='ArrowLeft'?-step:e.key==='ArrowRight'?step:0,e.key==='ArrowUp'?step:e.key==='ArrowDown'?-step:0,0).applyQuaternion(this.camera.quaternion);
    this.solveHandle(id,this.handlePoint(id).add(delta));this.commitView();
  };
  setMode(mode: Mode): void { this.mode = mode; this.container.dataset.mode = mode; this.paused = true; }
  canRenderMark(mark:Mark):boolean { return !!this.model.anchor(mark); }
  setLayer(layer:AnatomyLayer):void { this.gestures.reset();this.inspection.clear();this.model.setLayer(layer);this.container.dataset.layer=layer;this.restoreInspection(); }
  setBrush(size: number): void { this.paintSize = size; }
  getBrush():number { return this.paintSize; }
  setView(view: 'front' | 'back' | 'left' | 'right'): void {
    this.updateCenter();
    this.yaw = { front: 0, back: Math.PI, left: Math.PI / 2, right: -Math.PI / 2 }[view]; this.pitch = 0.02; this.updateCamera();
    this.callbacks.view?.(this.getInspection());
  }
  focusPelvis():void { if(this.model.layer!=='skin')this.setLayer('skin');this.focus('region:pelvis'); }
  focusOptions() { return this.inspection.options(); }
  getInspection(): InspectionView {
    return {version:2,layer:this.model.layer,target:this.inspection.target,hidden:this.inspection.hidden,yaw:Math.atan2(Math.sin(this.yaw),Math.cos(this.yaw)),pitch:this.pitch,distance:this.zoom};
  }
  restoreInspection(view?: InspectionView): boolean {
    const available=(!view || view.layer===this.model.layer) && this.inspection.set(view?.target || null);
    if(!available)this.inspection.set(null);
    this.inspection.setHidden(view?.layer===this.model.layer ? view.hidden : []);
    this.yaw=available && view ? view.yaw : 0;this.pitch=available && view ? view.pitch : this.poseName==='lying'?.9:.02;
    this.zoom=available && view ? view.distance : 3.8;
    this.updateCenter();this.updateCamera();this.refreshHandles();return available;
  }
  focus(target:string|null):boolean {
    this.paused=true;
    if(!this.inspection.set(target))return false;
    this.updateCenter();const bounds=this.inspection.bounds();
    const halfAngle=Math.atan(Math.tan(this.camera.fov*Math.PI/360)*Math.min(1,this.camera.aspect));
    this.zoom=bounds?Math.min(5.5,Math.max(.08,bounds.getSize(new Vector3()).length()/2/Math.sin(halfAngle)*1.2)):3.8;
    if(bounds) { const size=bounds.getSize(new Vector3());this.paintSize=Math.max(.002,Math.min(this.paintSize,Math.min(size.x,size.y,size.z)/4)); }
    this.yaw=0;this.pitch=this.poseName==='lying'?.9:.02;this.updateCamera();this.callbacks.view?.(this.getInspection());return true;
  }
  visibilityTree() { return this.inspection.tree(); }
  setVisible(id:string,visible:boolean):void {
    this.inspection.setVisible(id,visible);this.refreshHandles();this.updateCamera();this.callbacks.view?.(this.getInspection());
  }
  changeZoom(delta: number,persist=true): void { this.zoom = Math.min(5.5, Math.max(.08, this.zoom + delta*(this.inspection.target?this.zoom/3.8:1))); this.updateCamera();this.callbacks.view?.(this.getInspection(),persist); }
  setPose(pose: Pose, snapshot?: Record<string, Point3>, fingers: FingerPose = {}, jawOpening = 0): void {
    this.poseName = pose; this.paused = true; this.elapsed = 0;
    for (const joint of this.joints.values()) joint.rotation.set(0,0,0);
    this.torso.rotation.set(0,0,0); this.torso.position.set(0, 0.906, 0);
    if (pose === 'sitting') {
      for (const side of ['left','right']) { this.joints.get(`${side}-hip`)!.rotation.x = -1.48; this.joints.get(`${side}-knee`)!.rotation.x = 1.48; this.joints.get(`${side}-elbow`)!.rotation.x = -0.75; }
      this.torso.position.y = 0.61;
    }
    if (pose === 'lying') { this.torso.rotation.x = -Math.PI / 2; this.torso.position.set(0,0.46,0.08); this.pitch = 0.9; }
    else this.pitch = 0.02;
    if (pose === 'walking' || pose === 'jogging') this.applyGait(0.9);
    if (snapshot) for (const [id, xyz] of Object.entries(snapshot)) this.joints.get(id)?.rotation.set(...xyz);
    this.model.fingers.set(fingers);this.model.jawOpening=jawOpening;
    this.model.update(); this.updateCenter(); this.updateCamera();
  }
  getJawOpening(): number { return this.model.jawOpening; }
  setJawOpening(value: number): void { this.paused=true;this.model.jawOpening=value;this.model.update();this.updateCenter();this.updateCamera(); }
  getFingerPose(): FingerPose { return this.model.fingers.snapshot(); }
  setFingerPose(pose: FingerPose): void {
    this.paused=true;this.model.fingers.set(pose);this.model.update();this.updateCenter();this.updateCamera();
  }
  getPoseSnapshot(): Record<string, Point3> { return Object.fromEntries([...this.joints].map(([id,g]) => [id, [g.rotation.x, g.rotation.y, g.rotation.z] as Point3])); }
  toggleAnimation(): boolean { if (this.poseName === 'walking' || this.poseName === 'jogging') this.paused = !this.paused; return !this.paused; }
  private applyGait(t: number): void {
    const run = this.poseName === 'jogging';
    for (const [side, phase] of [['left', 0], ['right', Math.PI]] as const) {
      const wave = Math.sin(t + phase);
      this.joints.get(`${side}-hip`)!.rotation.x = wave * (run ? 0.8 : 0.42);
      this.joints.get(`${side}-knee`)!.rotation.x = Math.max(0.05, -wave * (run ? 1.3 : 0.55));
      this.joints.get(`${side}-arm`)!.rotation.x = -wave * (run ? 0.6 : 0.3);
      this.joints.get(`${side}-elbow`)!.rotation.x = run ? -1.1 : -0.2;
    }
  }
  showMarks(concerns: Concern[], select: (id: number) => void): void {
    this.model.clearDots();
    for (const marker of this.markers) marker.el.remove(); this.markers = [];
    for (const c of concerns) {
      for (const m of c.marks) this.model.addDot(m,c.color);
      if (c.marks.some(m => m.surface)) {
        const el = document.createElement('button'); el.className = 'body-pin'; el.textContent = String(concerns.indexOf(c) + 1); el.style.setProperty('--pin', c.color); el.setAttribute('aria-label', `Select ${c.location}`); el.onclick = () => select(c.id); this.container.append(el); this.markers.push({ el, concern: c });
      }
    }
  }
  capture(concerns:Concern[]):VisualEvidence {
    const layer=this.model.layer,pose=this.poseName,joints=this.getPoseSnapshot(),fingers=this.getFingerPose(),jawOpening=this.getJawOpening(),paused=this.paused,elapsed=this.elapsed;
    const view=this.getInspection();
    const marks=this.markers.map(m=>m.concern);
    try { return captureBodyImages(this.model,this.scene,this.renderer,concerns,c=>{
      this.inspection.clear();this.model.setLayer(c.layer);this.setPose(c.pose,c.joints,c.fingers,c.jawOpening);
      const available=this.restoreInspection(c.inspection);
      if(!c.inspection || !available)return;
      const camera=this.camera.clone();camera.aspect=384/420;camera.updateProjectionMatrix();camera.updateMatrixWorld(true);return camera;
    },this.inspection); }
    finally {
      this.inspection.clear();this.model.setLayer(layer);this.setPose(pose,joints,fingers,jawOpening);this.restoreInspection(view);this.paused=paused;this.elapsed=elapsed;
      this.model.clearDots();for(const c of marks)for(const m of c.marks)this.model.addDot(m,c.color);
      this.model.update();this.model.updateDots();this.renderer.render(this.scene,this.camera);
    }
  }
  private updateCamera(): void {
    this.camera.position.copy(this.stageCenter).add(new Vector3(Math.sin(this.yaw) * Math.cos(this.pitch) * this.zoom, Math.sin(this.pitch) * this.zoom, Math.cos(this.yaw) * Math.cos(this.pitch) * this.zoom));
    this.camera.lookAt(this.stageCenter);
    this.camera.updateMatrixWorld(true);
    this.container.dataset.focus=this.inspection.target || 'whole';
    this.container.dataset.hidden=String(this.inspection.hidden.length);
  }
  private resize(): void {
    const w = this.container.clientWidth, h = this.container.clientHeight;
    if (!w || !h) return;
    this.renderer.setSize(w,h); this.camera.aspect = w/h; this.camera.updateProjectionMatrix(); this.updateCamera();
  }
  private hit(e: Pick<PointerEvent,'clientX'|'clientY'>) {
    const rect = this.renderer.domElement.getBoundingClientRect();
    this.pointer.set((e.clientX-rect.left)/rect.width*2-1, -(e.clientY-rect.top)/rect.height*2+1);
    this.scene.updateMatrixWorld(true); this.ray.setFromCamera(this.pointer, this.camera);
    this.model.update();
    return this.ray.intersectObjects(this.inspection.surfaces, false).find(hit=>this.inspection.accepts(hit));
  }
  private paint(e: Pick<PointerEvent,'clientX'|'clientY'>): void {
    if (this.lastPaint.distanceTo(new Vector2(e.clientX,e.clientY)) < 5) return;
    const hit = this.hit(e); if (!hit?.face) return;
    const annotation = this.model.mark(hit,this.paintSize,this.stroke); if (!annotation) return;
    this.callbacks.mark(annotation.label, annotation.mark, this.freshStroke);
    this.freshStroke = false; this.lastPaint.set(e.clientX,e.clientY);
  }
  private onDown = (e: PointerEvent): void => {
    if(this.gestures.down(e)) {e.preventDefault();this.paused=true;this.renderer.domElement.setPointerCapture(e.pointerId);}
  };
  private startTool(e:Pick<PointerEvent,'clientX'|'clientY'>):void {
    this.dragMode=this.mode;this.paused=true;this.last.set(e.clientX,e.clientY);this.lastPaint.set(-100,-100);this.stroke++;this.freshStroke=true;
    if (this.dragMode === 'paint') this.paint(e);
    if (this.dragMode === 'pose') {
      const handle=this.handles.pick(e);
      if(handle){
        this.dragJoint=handle;this.handles.select(handle);this.callbacks.handle?.(handle);this.hit(e);
        const endpoint=this.handlePoint(handle);this.plane.setFromNormalAndCoplanarPoint(this.camera.getWorldDirection(new Vector3()),endpoint);
        const hit=this.ray.ray.intersectPlane(this.plane,new Vector3());this.dragOffset.copy(endpoint).sub(hit || endpoint);return;
      }
      const hit = this.hit(e); const id = hit ? this.model.mark(hit,this.paintSize,this.stroke)?.mark.part || '' : '';
      const side = id.split('-')[0];
      this.dragJoint = /hand|forearm|elbow|upperarm|shoulder/.test(id) ? `${side}-arm` : /foot|calf|knee|thigh/.test(id) ? `${side}-hip` : '';
      if (hit && this.dragJoint) { this.dragOffset.copy(this.model.endpoint(this.dragJoint)).sub(hit.point); this.plane.setFromNormalAndCoplanarPoint(this.camera.getWorldDirection(new Vector3()), hit.point); }
    }
  }
  private rotate(dx:number,dy:number):void {
    this.paused=true;this.yaw-=dx*.009;this.pitch=Math.max(-1.45,Math.min(1.45,this.pitch+dy*.006));this.updateCamera();this.callbacks.view?.(this.getInspection(),false);
  }
  private moveTool(e:Pick<PointerEvent,'clientX'|'clientY'>):void {
    if (this.dragMode === 'paint') this.paint(e);
    if (this.dragMode === 'rotate') this.rotate(e.clientX-this.last.x,e.clientY-this.last.y);
    if (this.dragMode === 'pose' && this.dragJoint) {
      this.hit(e);
      const target = this.ray.ray.intersectPlane(this.plane, new Vector3());
      if (target) {
        this.solveHandle(this.dragJoint,target.add(this.dragOffset));
      }
    }
    this.last.set(e.clientX,e.clientY);
  }
  private onMove = (e:PointerEvent):void => {this.gestures.move(e);};
  private commitView = ():void => {clearTimeout(this.wheelSaveTimer);this.callbacks.view?.(this.getInspection());};
  private onUp = (e:PointerEvent):void => {this.gestures.up(e,e.type!=='pointerup');this.commitView();};
  private onBlur = ():void => {this.gestures.reset();this.paused=true;this.commitView();};
  private onWheel = (e:WheelEvent):void => {
    e.preventDefault();this.gestures.wheel(e);clearTimeout(this.wheelSaveTimer);
    this.wheelSaveTimer=window.setTimeout(this.commitView,180);
  };
  private tick = (time: number): void => {
    if (!this.container.offsetParent || document.hidden) { this.previousTime = time; this.animationId = requestAnimationFrame(this.tick); return; }
    const delta = Math.min(0.05, (time-this.previousTime)/1000); this.previousTime = time;
    if (!this.paused && !this.reducedMotion && (this.poseName === 'walking' || this.poseName === 'jogging')) { this.elapsed += delta * (this.poseName === 'jogging' ? 5.5 : 3); this.applyGait(this.elapsed); }
    this.model.update(); this.model.updateDots();
    if(!this.paused && this.inspection.target){this.updateCenter();this.updateCamera();}
    this.renderer.render(this.scene, this.camera);
    this.handles.update(this.mode==='pose'&&!['mouth','organs'].includes(this.model.layer),this.camera,this.handlePoint,this.inspection.target);
    const rect = this.container.getBoundingClientRect();
    for (const {el,concern} of this.markers) {
      const m = concern.marks.find(m => m.surface); if (!m) continue;
      if(markLayer(m)!==this.model.layer || !this.inspection.includesMark(m)) {el.hidden=true;continue;}
      const anchor = this.model.anchor(m); if (!anchor) { el.hidden = true; continue; }
      const world = this.model.mesh.localToWorld(anchor.point);
      const normal = anchor.normal.transformDirection(this.model.mesh.matrixWorld);
      const facing = normal.dot(this.camera.position.clone().sub(world)) > 0;
      const projected = world.project(this.camera);
      el.hidden = !facing || projected.z > 1;
      el.style.left = `${(projected.x*0.5+0.5)*rect.width + 17}px`; el.style.top = `${(-projected.y*0.5+0.5)*rect.height - 10}px`;
    }
    this.animationId = requestAnimationFrame(this.tick);
  };
  dispose(): void {
    clearTimeout(this.wheelSaveTimer);
    this.handles.dispose();this.inspection.dispose();
    cancelAnimationFrame(this.animationId); this.resizeObserver.disconnect(); window.removeEventListener('blur',this.onBlur);
    this.scene.traverse(object => { if (object instanceof Mesh) { object.geometry.dispose(); const materials = Array.isArray(object.material) ? object.material : [object.material]; materials.forEach(m => m.dispose()); } });
    this.model.dispose(); this.renderer.dispose();
  }
}