import { describe, expect, it } from "vitest"; import { AnimationClip as ThreeClip, Bone, SkinnedMesh } from "three"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { buildDefaultClips } from "../anim/procedural.js"; import { analyzeReference } from "../image/analyze.js"; import { denoise, segmentForeground } from "../image/segment.js"; import { DEFAULT_FIGURE, renderFigure } from "../image/testFixtures.js"; import { buildHumanoidMesh } from "../mesh/humanoid.js"; import { triangleCount, vertexCount } from "../mesh/MeshBuilder.js"; import { buildSkeleton } from "../rig/skeleton.js"; import { computeInverseBindMatrices, computeSkinWeights, skinStatistics } from "../rig/skin.js"; import { exportGlb } from "./glb.js"; /** * The exporter is hand-rolled, so "it produced bytes" proves nothing. These * tests feed the output back through three's own `GLTFLoader` — the exact * parser the runtime uses — and assert on the reconstructed scene graph. */ function buildCharacter() { const raster = renderFigure(DEFAULT_FIGURE); const mask = denoise(segmentForeground(raster)); const analysis = analyzeReference(raster, mask); const skeleton = buildSkeleton(analysis.measurements, { height: 1.7 }); const mesh = buildHumanoidMesh(skeleton, analysis.measurements, analysis.palette); computeSkinWeights(mesh, skeleton); const inverseBindMatrices = computeInverseBindMatrices(skeleton); const clips = buildDefaultClips(); const glb = exportGlb(mesh, skeleton, inverseBindMatrices, { name: "TestCharacter", clips }); return { glb, mesh, skeleton, clips, analysis }; } /** Parses GLB bytes with the real loader. */ function parseGlb(glb: Uint8Array) { const loader = new GLTFLoader(); const buffer = glb.buffer.slice(glb.byteOffset, glb.byteOffset + glb.byteLength) as ArrayBuffer; return new Promise((resolve, reject) => { loader.parse(buffer, "", resolve, reject); }); } describe("GLB container", () => { it("writes a valid header", () => { const { glb } = buildCharacter(); const view = new DataView(glb.buffer, glb.byteOffset, glb.byteLength); expect(view.getUint32(0, true)).toBe(0x46546c67); // "glTF" expect(view.getUint32(4, true)).toBe(2); expect(view.getUint32(8, true)).toBe(glb.byteLength); }); it("keeps every chunk 4-byte aligned", () => { const { glb } = buildCharacter(); const view = new DataView(glb.buffer, glb.byteOffset, glb.byteLength); let offset = 12; while (offset < glb.byteLength) { const chunkLength = view.getUint32(offset, true); expect(chunkLength % 4).toBe(0); offset += 8 + chunkLength; } // Chunks must tile the file exactly. expect(offset).toBe(glb.byteLength); }); }); describe("GLTFLoader round trip", () => { it("parses without error", async () => { const { glb } = buildCharacter(); const gltf = await parseGlb(glb); expect(gltf.scene).toBeDefined(); }); it("reconstructs a skinned mesh with the expected geometry", async () => { const { glb, mesh } = buildCharacter(); const gltf = await parseGlb(glb); let skinned: SkinnedMesh | null = null; gltf.scene.traverse((object) => { if ((object as SkinnedMesh).isSkinnedMesh) skinned = object as SkinnedMesh; }); expect(skinned).not.toBeNull(); const geometry = skinned!.geometry; expect(geometry.getAttribute("position").count).toBe(vertexCount(mesh)); expect(geometry.index!.count / 3).toBe(triangleCount(mesh)); // Vertex colours carry the palette; without them the character is white. expect(geometry.getAttribute("color")).toBeDefined(); expect(geometry.getAttribute("normal")).toBeDefined(); expect(geometry.getAttribute("skinIndex")).toBeDefined(); expect(geometry.getAttribute("skinWeight")).toBeDefined(); }); it("binds all 22 canonical bones", async () => { const { glb, skeleton } = buildCharacter(); const gltf = await parseGlb(glb); let skinned: SkinnedMesh | null = null; gltf.scene.traverse((object) => { if ((object as SkinnedMesh).isSkinnedMesh) skinned = object as SkinnedMesh; }); expect(skinned!.skeleton.bones).toHaveLength(skeleton.joints.length); const names = skinned!.skeleton.bones.map((bone: Bone) => bone.name); expect(names).toContain("Hips"); expect(names).toContain("Head"); expect(names).toContain("LeftHand"); expect(names).toContain("RightToeBase"); }); it("preserves the bone hierarchy", async () => { const { glb } = buildCharacter(); const gltf = await parseGlb(glb); const bones = new Map(); gltf.scene.traverse((object) => { if ((object as Bone).isBone) bones.set(object.name, object as Bone); }); // A flat list of bones would still load but would not animate correctly. expect(bones.get("LeftForeArm")?.parent?.name).toBe("LeftArm"); expect(bones.get("LeftLeg")?.parent?.name).toBe("LeftUpLeg"); expect(bones.get("Spine")?.parent?.name).toBe("Hips"); }); it("carries every animation clip with named bone tracks", async () => { const { glb, clips } = buildCharacter(); const gltf = await parseGlb(glb); expect(gltf.animations).toHaveLength(clips.length); const walk = gltf.animations.find((clip: ThreeClip) => clip.name === "Walk"); expect(walk).toBeDefined(); expect(walk!.duration).toBeGreaterThan(0); expect(walk!.tracks.length).toBeGreaterThan(0); // Tracks must target bone names, which is what makes clips portable // between characters produced by the harness. const targets = walk!.tracks.map((track) => track.name.split(".")[0]); expect(targets).toContain("LeftUpLeg"); expect(targets).toContain("RightUpLeg"); }); it("keeps the character upright and roughly the requested height", async () => { const { glb } = buildCharacter(); const gltf = await parseGlb(glb); let skinned: SkinnedMesh | null = null; gltf.scene.traverse((object) => { if ((object as SkinnedMesh).isSkinnedMesh) skinned = object as SkinnedMesh; }); skinned!.geometry.computeBoundingBox(); const bbox = skinned!.geometry.boundingBox!; // Feet near the origin, crown near the target height. expect(bbox.min.y).toBeGreaterThan(-0.05); expect(bbox.max.y).toBeGreaterThan(1.5); expect(bbox.max.y).toBeLessThan(1.85); // Taller than wide — a figure, not a puddle. expect(bbox.max.y - bbox.min.y).toBeGreaterThan(bbox.max.x - bbox.min.x); }); }); describe("skin weights", () => { it("normalises every vertex to exactly one", () => { const { mesh } = buildCharacter(); const stats = skinStatistics(mesh); expect(stats.unweighted).toBe(0); expect(stats.maxInfluences).toBeLessThanOrEqual(4); }); it("assigns hand vertices to the hand bone, not the torso", () => { const { mesh, skeleton } = buildCharacter(); const handIndex = skeleton.index.get("LeftHand")!; const foreArmIndex = skeleton.index.get("LeftForeArm")!; const handWorld = skeleton.joints[handIndex]!.world; // Find the vertex nearest the left hand joint. let nearest = -1; let nearestDistance = Infinity; for (let v = 0; v < vertexCount(mesh); v++) { const distance = Math.hypot( mesh.positions[v * 3]! - handWorld[0], mesh.positions[v * 3 + 1]! - handWorld[1], mesh.positions[v * 3 + 2]! - handWorld[2], ); if (distance < nearestDistance) { nearestDistance = distance; nearest = v; } } const influences = [0, 1, 2, 3].map((i) => ({ joint: mesh.joints[nearest * 4 + i]!, weight: mesh.weights[nearest * 4 + i]!, })); const dominant = influences.reduce((best, entry) => (entry.weight > best.weight ? entry : best)); // Distance-to-joint skinning would let the spine claim this vertex; the // segment-based version must not. expect([handIndex, foreArmIndex]).toContain(dominant.joint); }); it("keeps left-side vertices off right-side bones", () => { const { mesh, skeleton } = buildCharacter(); const rightBones = new Set( skeleton.joints .map((joint, index) => ({ joint, index })) .filter(({ joint }) => joint.name.startsWith("Right")) .map(({ index }) => index), ); let violations = 0; for (let v = 0; v < vertexCount(mesh); v++) { const x = mesh.positions[v * 3]!; if (x < 0.15) continue; // clearly on the left side for (let i = 0; i < 4; i++) { if (rightBones.has(mesh.joints[v * 4 + i]!) && mesh.weights[v * 4 + i]! > 0.2) { violations++; } } } expect(violations).toBe(0); }); });