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 { 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};
