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/skin-model.ts
import { MouthModel } from './mouth-model.js';
import type { MouthData } from './mouth-data.js';
import { BackSide, Bone, BufferGeometry, CircleGeometry, Float32BufferAttribute, Group, Matrix4, Mesh, MeshBasicMaterial, MeshStandardMaterial, Quaternion, Skeleton, SkinnedMesh, Sphere, Triangle, Uint16BufferAttribute, Vector2, Vector3 } from 'three';
import type { Intersection } from 'three';
import { decodeSkin, SKIN_MODEL_ID, type SkinData, shapeWeights } from './skin-data.js';
import { SkeletonModel } from './skeleton-model.js';
import type { SkeletonData } from './skeleton-data.js';
import { AtlasModel } from './atlas-model.js';
import type { AtlasData } from './atlas-data.js';
import { markLayer, MOUTH_MODEL, SKELETON_MODEL, MUSCLE_MODEL, ORGAN_MODEL, type AnatomyLayer } from './layers.js';
import { skinRegion } from './body-regions.js';
import type { Mark, Point3 } from './case.js';
import { ExternalAnatomy, ANATOMY_MODEL, type AnatomyChoice } from './external-anatomy.js';
import { FingerRig } from './finger-rig.js';

export class SkinModel {
  readonly group=new Group();
  readonly mesh: SkinnedMesh;
  readonly bones: Bone[];
  readonly joints=new Map<string,Bone>();
  readonly fingers: FingerRig;
  private skeleton: Skeleton;
  private heads: Float32Array;
  private a=new Vector3(); private b=new Vector3(); private c=new Vector3();
  private dotGeometry=new CircleGeometry(1,24);
  private dots: {mesh:Mesh; mark:Mark}[]=[];
  private anatomy: ExternalAnatomy;
  private pelvicVertices: number[];
  private internal?: SkeletonModel;
  private mouth?: MouthModel;
  get jawOpening(): number { return this.mouth?.jawOpening || 0; }
  set jawOpening(value: number) { if (this.mouth) this.mouth.jawOpening = value; }
  private softLayers = new Map<AnatomyLayer, AtlasModel>();
  private outline: SkinnedMesh;
  layer: AnatomyLayer = 'skin';
  markVisibility?: (mark: Mark) => boolean;
  showOutline(visible: boolean): void { this.outline.visible = visible && (this.layer === 'organs' || this.layer === 'muscles' || this.layer === 'mouth'); }
  constructor(readonly data:SkinData, material:MeshStandardMaterial, skeletonData?:SkeletonData, atlasData:AtlasData[] = [], mouthData?: MouthData) {
    const geometry=new BufferGeometry();
    geometry.setIndex(new Uint16BufferAttribute(data.indices,1));
    geometry.setAttribute('position',new Float32BufferAttribute(data.positions[4].slice(),3));
    geometry.setAttribute('skinIndex',new Uint16BufferAttribute(data.skinIndices,4));
    geometry.setAttribute('skinWeight',new Float32BufferAttribute(data.skinWeights,4));
    geometry.computeVertexNormals();
    this.mesh=new SkinnedMesh(geometry,material); this.mesh.name='skin'; this.mesh.frustumCulled=false;
    // Conservative local bound covers every permitted pose; ray tests still hit
    // the actual skinned triangles. Avoid a stale rest-pose bounding sphere.
    this.mesh.boundingSphere=new Sphere(new Vector3(0,.8,0),3);
    this.bones=data.meta.bones.map(b=>{const bone=new Bone();bone.name=b.name;return bone;});
    this.fingers=new FingerRig(this.bones);
    data.meta.bones.forEach((b,i)=>(b.parent<0?this.group:this.bones[b.parent]).add(this.bones[i]));
    this.group.add(this.mesh); this.group.updateMatrixWorld(true);
    this.skeleton=new Skeleton(this.bones); this.mesh.bind(this.skeleton);
    this.outline=new SkinnedMesh(geometry,new MeshBasicMaterial({color:'#68727e',transparent:true,opacity:.12,depthWrite:false,side:BackSide}));
    this.outline.frustumCulled=false;this.outline.visible=false;this.group.add(this.outline);this.outline.bind(this.skeleton,new Matrix4());
    this.anatomy=new ExternalAnatomy(material);
    this.bones[1].add(this.anatomy.group);
    // Stable vertices at the pubic surface and its turn under the pelvis.
    // Both follow the same nine shape samples as the surrounding skin.
    this.pelvicVertices=[[0,.876,.122],[0,.811,.058]].map(target=>{
      let distance=Infinity,vertex=0;
      for(let i=0;i<data.meta.vertices;i++) {
        const d=new Vector3().fromArray(data.positions[4],i*3).distanceToSquared(new Vector3(...target));
        if(d<distance){distance=d;vertex=i;}
      }
      return vertex;
    });
    this.heads=data.heads[4].slice();
    if(skeletonData) this.internal=new SkeletonModel(skeletonData,this.bones,data.heads[4]);
    for(const atlas of atlasData) this.softLayers.set(atlas.id===MUSCLE_MODEL?'muscles':'organs',new AtlasModel(atlas,this.skeleton,this.group,data.heads[4],data.meta.bones.map(b=>b.parent)));
    for (const [side,suffix] of [['left','l'],['right','r']]) for (const [alias,name] of [['arm','upperarm'],['elbow','lowerarm'],['hip','thigh'],['knee','calf']]) this.joints.set(`${side}-${alias}`,this.bones.find(b=>b.name===`${name}_${suffix}`)!);
    if (mouthData) this.mouth = new MouthModel(mouthData,this.bones[44]);
    this.shape(172,70,'');
  }
  get surfaces(): Mesh[] { return this.layer==='mouth' ? this.mouth?.surfaces || [] : this.layer==='skin' ? [this.mesh,...this.anatomy.surfaces] : this.layer==='skeleton' ? this.internal?.surfaces || [] : this.softLayers.get(this.layer)?.surfaces || []; }
  setLayer(layer:AnatomyLayer):void {
    this.layer=layer;this.mesh.visible=layer==='skin';this.anatomy.group.visible=layer==='skin';
    if(this.internal) this.internal.visible=layer==='skeleton';
    if(this.mouth) this.mouth.visible=layer==='mouth';
    for(const [name,model] of this.softLayers) model.visible=name===layer;
    this.outline.visible=layer==='organs'||layer==='muscles'||layer==='mouth';
    this.updateDots();
  }
  shape(height:number,weight:number,sex:string,anatomy:AnatomyChoice='auto'):void {
    const gender=sex==='Female'?0:sex==='Male'?1:.5;
    const bmi=weight/(height/100)**2;
    const blends=shapeWeights(gender,Math.max(0,Math.min(1,(bmi-16)/20)));
    const positions=this.mesh.geometry.getAttribute('position');
    for(let i=0;i<positions.array.length;i++) positions.array[i]=blends.reduce((sum,w,j)=>sum+w*this.data.positions[j][i],0);
    for(let i=0;i<this.heads.length;i++) this.heads[i]=blends.reduce((sum,w,j)=>sum+w*this.data.heads[j][i],0);
    this.data.meta.bones.forEach((bone,i)=>{
      const p=bone.parent, head=new Vector3().fromArray(this.heads,i*3);
      this.bones[i].position.copy(head); if(p>=0) this.bones[i].position.sub(new Vector3().fromArray(this.heads,p*3));
      this.skeleton.boneInverses[i]=new Matrix4().makeTranslation(-head.x,-head.y,-head.z);
    });
    positions.needsUpdate=true; this.mesh.geometry.computeVertexNormals();
    this.anatomy.select(anatomy,sex);
    this.anatomy.group.visible=this.layer==='skin';
    this.internal?.shape(this.heads);
    this.fingers.shape(this.heads);
    this.mouth?.fit(positions,new Vector3().fromArray(this.heads,44*3));
    for(const model of this.softLayers.values()) { model.select(anatomy,sex); model.shape(this.heads); }
    this.update();
  }
  update():void {
    this.group.updateWorldMatrix(true,false);this.group.updateMatrixWorld(true);this.skeleton.update();
    const [pubic,perineal]=this.pelvicVertices.map(i=>this.bones[1].worldToLocal(this.mesh.localToWorld(this.mesh.getVertexPosition(i,new Vector3()))));
    this.anatomy.fit(pubic,perineal);
    this.anatomy.group.updateMatrixWorld(true);
  }
  anchor(mark:Mark, point=new Vector3(), normal=new Vector3()):{point:Vector3;normal:Vector3}|undefined {
    if(mark.surface?.model===MOUTH_MODEL) return this.mouth?.anchor(mark,this.group);
    if(mark.surface?.model===SKELETON_MODEL) return this.internal?.anchor(mark,this.group);
    if(mark.surface?.model===MUSCLE_MODEL || mark.surface?.model===ORGAN_MODEL) return this.softLayers.get(markLayer(mark))?.anchor(mark);
    if(mark.surface?.model===ANATOMY_MODEL) return this.anatomy.anchor(mark,this.group);
    const s=mark.surface; if(!s || s.model!==SKIN_MODEL_ID || s.triangle<0 || s.triangle>=this.data.meta.triangles) return;
    const i=s.triangle*3;
    this.mesh.getVertexPosition(this.data.indices[i],this.a); this.mesh.getVertexPosition(this.data.indices[i+1],this.b); this.mesh.getVertexPosition(this.data.indices[i+2],this.c);
    point.copy(this.a).multiplyScalar(s.barycentric[0]).addScaledVector(this.b,s.barycentric[1]).addScaledVector(this.c,s.barycentric[2]);
    Triangle.getNormal(this.a,this.b,this.c,normal); return {point,normal};
  }
  mark(hit:Intersection,size:number,stroke:number):{mark:Mark;label:string}|undefined {
    if(this.layer==='mouth') return this.mouth?.mark(hit,size,stroke,this.group);
    if(this.layer==='skeleton') return this.internal?.mark(hit,size,stroke,this.group);
    if(this.layer!=='skin') return this.softLayers.get(this.layer)?.mark(hit,size,stroke);
    if(hit.object!==this.mesh) return this.anatomy.mark(hit,size,stroke,this.group);
    if(hit.faceIndex==null) return;
    const i=hit.faceIndex*3, indices=[this.data.indices[i],this.data.indices[i+1],this.data.indices[i+2]];
    const local=this.mesh.worldToLocal(hit.point.clone());
    this.mesh.getVertexPosition(indices[0],this.a); this.mesh.getVertexPosition(indices[1],this.b); this.mesh.getVertexPosition(indices[2],this.c);
    const bary=Triangle.getBarycoord(local,this.a,this.b,this.c,new Vector3()); if(!bary) return;
    // Canonical location stays fixed across body shape, posture and view changes.
    const p=new Vector3(), weights=bary.toArray(), uv=new Vector2(); const scores=new Float32Array(this.bones.length);
    indices.forEach((vertex,j)=>{
      p.addScaledVector(new Vector3().fromArray(this.data.positions[4],vertex*3),weights[j]);
      uv.x+=this.data.uvCorners[i*2+j*2]*weights[j]; uv.y+=this.data.uvCorners[i*2+j*2+1]*weights[j];
      for(let k=0;k<4;k++) scores[this.data.skinIndices[vertex*4+k]]+=this.data.skinWeights[vertex*4+k]*weights[j];
    });
    const bone=this.data.meta.bones[scores.indexOf(Math.max(...scores))].name;
    const region=skinRegion(p.toArray() as Point3,bone);
    const normal=Triangle.getNormal(this.a,this.b,this.c,new Vector3());
    return {label:region.label,mark:{part:region.part,point:p.toArray() as Point3,normal:normal.toArray() as Point3,size,stroke,surface:{model:SKIN_MODEL_ID,triangle:hit.faceIndex,barycentric:bary.toArray() as Point3,uv:uv.toArray(),zone:region.zone,label:region.label}}};
  }
  clearDots():void { for(const {mesh} of this.dots){mesh.removeFromParent();(mesh.material as MeshBasicMaterial).dispose();} this.dots=[]; }
  addDot(mark:Mark,color:string):void {
    if(!mark.surface) return;
    const mesh=new Mesh(this.dotGeometry,new MeshBasicMaterial({color,transparent:true,opacity:.7,depthWrite:false,polygonOffset:true,polygonOffsetFactor:-2}));
    mesh.renderOrder=1; mesh.scale.setScalar(mark.size); this.group.add(mesh); this.dots.push({mesh,mark});
  }
  updateDots():void {
    for(const {mesh,mark} of this.dots){const a=this.anchor(mark);mesh.visible=!!a && markLayer(mark)===this.layer && (!this.markVisibility || this.markVisibility(mark));if(a){mesh.position.copy(a.point).addScaledVector(a.normal,.0015);mesh.quaternion.setFromUnitVectors(new Vector3(0,0,1),a.normal);}}
  }
  solveLimb(id:string,worldTarget:Vector3):void {
    const upper=this.joints.get(id)!; const side=id.split('-')[0],arm=id.endsWith('arm');
    const lower=this.joints.get(`${side}-${arm?'elbow':'knee'}`)!;
    const end=this.bones.find(b=>b.name===`${arm?'hand':'foot'}_${side==='left'?'l':'r'}`)!;
    const target=upper.parent!.worldToLocal(worldTarget.clone()).sub(upper.position);
    const a=lower.position.length(),b=end.position.length();
    const distance=Math.max(Math.abs(a-b)+.04,Math.min(a+b-.002,target.length()));
    const bend=Math.acos(Math.max(-1,Math.min(1,(distance*distance-a*a-b*b)/(2*a*b))));
    lower.rotation.set((arm?-1:1)*Math.min(2.3,bend),0,0);
    const rest=lower.position.clone().add(end.position.clone().applyQuaternion(lower.quaternion));
    upper.quaternion.copy(new Quaternion().setFromUnitVectors(rest.normalize(),target.normalize()));
    upper.rotation.x=Math.max(-1.8,Math.min(arm?1.5:.7,upper.rotation.x));
    upper.rotation.y=Math.max(-.8,Math.min(.8,upper.rotation.y));
    upper.rotation.z=Math.max(arm?-1.7:-.6,Math.min(arm?1.7:.6,upper.rotation.z));
    this.update();
  }
  endpoint(id:string):Vector3 { const side=id.startsWith('left')?'l':'r'; return this.bones.find(b=>b.name===`${id.endsWith('arm')?'hand':'foot'}_${side}`)!.getWorldPosition(new Vector3()); }
  dispose():void { this.clearDots(); this.dotGeometry.dispose(); this.skeleton.dispose(); this.anatomy.dispose(); this.internal?.dispose();this.mouth?.dispose();for(const model of this.softLayers.values())model.dispose();(this.outline.material as MeshBasicMaterial).dispose();this.outline.removeFromParent(); }
}
export {decodeSkin};