Files
psxbase/tools/src/export/glb.test.ts
T
ryanfitzpatrickio 8a96ede9f2 Initial public release of PSX Adventure Engine
Browser reference stack for PSX-era third-person adventure: fixed cameras,
inventory puzzles, Box3D physics, host-authoritative P2P co-op, and a modular
character harness. Ships the Ashgrove Precinct Level 1 investigation demo with
a full cast and nine linked rooms.
2026-07-31 06:32:43 -05:00

235 lines
8.6 KiB
TypeScript

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<import("three/examples/jsm/loaders/GLTFLoader.js").GLTF>((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<string, Bone>();
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);
});
});