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 { BufferGeometry, Float32BufferAttribute, Group, Matrix3, Mesh, MeshStandardMaterial, Triangle, Uint16BufferAttribute, Vector3 } from 'three';
import type { Bone, BufferAttribute, InterleavedBufferAttribute, Intersection } from 'three';
import type { Mark, Point3 } from './case.js';
import { MOUTH_MODEL } from './layers.js';
import type { MouthData, MouthPart } from './mouth-data.js';
import { parseJawOpening } from './jaw-pose.js';
/** Separate head-local atlas; existing skin and skeleton anchor indices stay intact. */
export class MouthModel {
readonly group = new Group();
private lower = new Group();
private opening = 0;
private incisorEdge = Infinity;
private incisorFront = -Infinity;
private items: { mesh: Mesh; info: MouthPart }[] = [];
private materials = {
tooth: new MeshStandardMaterial({ color: '#e2e3e6', roughness: .48 }),
tongue: new MeshStandardMaterial({ color: '#a95662', roughness: .85 }),
jaw: new MeshStandardMaterial({ color: '#9ca2ad', roughness: .85 }),
};
constructor(private data: MouthData, head: Bone) {
head.add(this.group); this.group.add(this.lower); this.lower.position.fromArray(data.hinge);
for (const info of data.parts) {
const geometry = new BufferGeometry();
geometry.setAttribute('position', new Float32BufferAttribute(new Float32Array(data.buffer, data.start + info.positions, info.vertices * 3), 3));
geometry.setIndex(new Uint16BufferAttribute(new Uint16Array(data.buffer, data.start + info.indices, info.triangles * 3), 1));
geometry.computeVertexNormals();
if (info.fdi === 11 || info.fdi === 21) {
geometry.computeBoundingBox();
this.incisorEdge = Math.min(this.incisorEdge, geometry.boundingBox!.min.y);
this.incisorFront = Math.max(this.incisorFront, geometry.boundingBox!.max.z);
}
const mesh = new Mesh(geometry, this.materials[info.kind]); mesh.name = info.id; mesh.userData.anatomicalPart = info.part;
if (info.jaw === 'lower') { mesh.position.copy(this.lower.position).negate(); this.lower.add(mesh); }
else this.group.add(mesh);
this.items.push({ mesh, info });
}
this.group.visible = false;
}
/** Skin's inner lip and preauricular landmarks fit the atlas in profile.
* Transform the whole assembly (including hinge), never the triangle addresses. */
fit(positions: BufferAttribute | InterleavedBufferAttribute, head: Vector3): void {
const lip = new Vector3().fromBufferAttribute(positions, 537);
const ear = new Vector3().fromBufferAttribute(positions, 12017);
const hinge = new Vector3(...this.data.hinge).add(new Vector3(...this.data.head));
const front = this.incisorFront + this.data.head[2];
const fittedHinge = hinge.clone();
fittedHinge.y += lip.y - .002 - (this.incisorEdge + this.data.head[1]);
fittedHinge.z = ear.z + .01;
this.group.scale.set(ear.x / .0796545073390007, 1, (lip.z - .004 - fittedHinge.z) / (front - hinge.z));
this.group.position.copy(fittedHinge).sub(head).sub(new Vector3(...this.data.hinge).multiply(this.group.scale));
}
set visible(value: boolean) { this.group.visible = value; }
get surfaces(): Mesh[] { return this.items.map(item => item.mesh); }
get jawOpening(): number { return this.opening; }
set jawOpening(value: number) { this.opening = parseJawOpening(value); this.lower.rotation.x = value * .48; }
anchor(mark: Mark, owner: Group): { point: Vector3; normal: Vector3 } | undefined {
const s = mark.surface, item = this.items.find(item => item.info.id === s?.zone);
if (!s || !item || s.triangle < 0 || s.triangle >= item.info.triangles) return;
const triangle = this.triangle(item.mesh, s.triangle);
const point = triangle.reduce((p,v,i) => p.addScaledVector(v,s.barycentric[i]),new Vector3());
const transform = owner.matrixWorld.clone().invert().multiply(item.mesh.matrixWorld);
return { point: point.applyMatrix4(transform), normal: Triangle.getNormal(...triangle,new Vector3()).applyNormalMatrix(new Matrix3().getNormalMatrix(transform)) };
}
mark(hit: Intersection, size: number, stroke: number, owner: Group): { mark: Mark; label: string } | undefined {
const item = this.items.find(item => item.mesh === hit.object); if (!item || hit.faceIndex == null) return;
const local = item.mesh.worldToLocal(hit.point.clone());
const bary = Triangle.getBarycoord(local,...this.triangle(item.mesh,hit.faceIndex),new Vector3()); if (!bary) return;
const mark: Mark = { part: 'head', point: local.clone().add(new Vector3(...this.data.head)).toArray() as Point3, normal: [0,0,1], size, stroke,
surface: { model: MOUTH_MODEL, triangle: hit.faceIndex, barycentric: bary.toArray() as Point3, uv: [0,0], zone: item.info.id, label: item.info.label } };
mark.normal = this.anchor(mark,owner)!.normal.toArray() as Point3;
return { mark, label: item.info.label };
}
private triangle(mesh: Mesh, index: number): [Vector3,Vector3,Vector3] {
return [0,1,2].map(i => new Vector3().fromBufferAttribute(mesh.geometry.getAttribute('position'),mesh.geometry.index!.getX(index*3+i))) as [Vector3,Vector3,Vector3];
}
dispose(): void { for (const {mesh} of this.items) mesh.geometry.dispose(); for (const material of Object.values(this.materials)) material.dispose(); this.group.removeFromParent(); }
}
