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 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();
}
}
