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/mouth-model.ts
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(); }
}