160 lines
6.2 KiB
TypeScript
160 lines
6.2 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { describe, it } from "node:test";
|
|
import * as THREE from "three";
|
|
import {
|
|
DOG_METRICS,
|
|
buildDog,
|
|
cloneDog,
|
|
disposeDog,
|
|
dogPoseStateForSpeed,
|
|
poseDog,
|
|
poseDogAttention,
|
|
type DogRig,
|
|
} from "../assets/actors/index.ts";
|
|
|
|
function meshes(root: THREE.Object3D): THREE.Mesh[] {
|
|
const result: THREE.Mesh[] = [];
|
|
root.traverse((object) => {
|
|
if (object instanceof THREE.Mesh) result.push(object);
|
|
});
|
|
return result;
|
|
}
|
|
|
|
function transformSnapshot(rig: DogRig): readonly number[][] {
|
|
const result: number[][] = [];
|
|
rig.root.traverse((object) => {
|
|
result.push([
|
|
...object.position.toArray(),
|
|
object.rotation.x,
|
|
object.rotation.y,
|
|
object.rotation.z,
|
|
...object.scale.toArray(),
|
|
]);
|
|
});
|
|
return result;
|
|
}
|
|
|
|
describe("procedural dog v2", () => {
|
|
it("builds a floor-centred articulated silhouette within the peer/mobile budget", () => {
|
|
const rig = buildDog();
|
|
assert.equal(rig.root.userData.rigVersion, 2);
|
|
for (const leg of [rig.joints.frontLeft, rig.joints.frontRight, rig.joints.rearLeft, rig.joints.rearRight]) {
|
|
assert.equal(leg.lower.parent, leg.upper);
|
|
assert.equal(leg.paw.parent, leg.lower);
|
|
}
|
|
for (const name of [
|
|
"dog.shoulder-mass",
|
|
"dog.hip-mass",
|
|
"dog.neck",
|
|
"dog.chest",
|
|
"dog.muzzle",
|
|
"dog.collar",
|
|
"dog.tag",
|
|
"dog.tail.tip",
|
|
]) assert.ok(rig.root.getObjectByName(name), `missing ${name}`);
|
|
|
|
const box = new THREE.Box3().setFromObject(rig.root);
|
|
const size = box.getSize(new THREE.Vector3());
|
|
assert.ok(box.min.y > -0.02, `floor ${box.min.y}`);
|
|
assert.ok(Math.abs(size.y - DOG_METRICS.height) < 0.08, `height ${size.y}`);
|
|
assert.ok(Math.abs(size.z - DOG_METRICS.length) < 0.08, `length ${size.z}`);
|
|
const allMeshes = meshes(rig.root);
|
|
const triangles = allMeshes.reduce((sum, mesh) => {
|
|
const geometry = mesh.geometry;
|
|
return sum + (geometry.index?.count ?? geometry.getAttribute("position").count) / 3;
|
|
}, 0);
|
|
assert.ok(allMeshes.length <= 34, `${allMeshes.length} meshes`);
|
|
assert.ok(triangles <= 3_600, `${triangles} triangles`);
|
|
disposeDog(rig);
|
|
});
|
|
|
|
it("separates idle, attention, walk, trot, run, turn and start/stop poses", () => {
|
|
const rig = buildDog();
|
|
poseDog(rig, { state: "idle", phase: Math.PI / 2, amount: 1 });
|
|
const idleChest = rig.joints.chest.scale.y;
|
|
assert.equal(rig.root.userData.pose, "idle");
|
|
assert.equal(rig.joints.frontLeft.upper.rotation.x, 0);
|
|
|
|
poseDog(rig, { state: "attention", phase: Math.PI / 2, amount: 1, attentionYaw: 0.35 });
|
|
assert.equal(rig.root.userData.pose, "attention");
|
|
assert.equal(rig.joints.head.rotation.y, 0.35);
|
|
assert.notEqual(rig.joints.earLeft.rotation.z, -rig.joints.earRight.rotation.z);
|
|
|
|
poseDog(rig, { state: "walk", phase: Math.PI / 2, amount: 1 });
|
|
const walkStride = Math.abs(rig.joints.frontLeft.upper.rotation.x);
|
|
assert.ok(walkStride > 0.3);
|
|
assert.equal(rig.joints.frontLeft.upper.rotation.x, rig.joints.rearRight.upper.rotation.x);
|
|
assert.equal(rig.joints.frontRight.upper.rotation.x, rig.joints.rearLeft.upper.rotation.x);
|
|
assert.notEqual(rig.joints.chest.scale.y, idleChest);
|
|
|
|
poseDog(rig, { state: "trot", phase: Math.PI / 2, amount: 1 });
|
|
const trotStride = Math.abs(rig.joints.frontLeft.upper.rotation.x);
|
|
assert.ok(trotStride > walkStride);
|
|
|
|
poseDog(rig, { state: "run", phase: 0.8, amount: 1 });
|
|
assert.equal(rig.root.userData.pose, "run");
|
|
assert.notEqual(rig.joints.frontLeft.upper.rotation.x, rig.joints.frontRight.upper.rotation.x,
|
|
"a gathered run offsets the front pair rather than using a rigid scissor");
|
|
|
|
poseDog(rig, { state: "trot", phase: 0.7, amount: 1, turn: 0.8, acceleration: 0.7 });
|
|
assert.ok(rig.joints.body.rotation.z < -0.09, "turn banks through the chest");
|
|
assert.ok(rig.joints.body.rotation.x < -0.05, "starting lowers the chest into motion");
|
|
poseDog(rig, { state: "idle", phase: 0.7, amount: 1, acceleration: -0.8 });
|
|
assert.ok(rig.joints.body.rotation.x > 0.06, "stopping braces the chest");
|
|
disposeDog(rig);
|
|
});
|
|
|
|
it("selects gait thresholds and repeats an exact pose without accumulating transforms", () => {
|
|
assert.equal(dogPoseStateForSpeed(Number.NaN), "idle");
|
|
assert.equal(dogPoseStateForSpeed(0.07), "idle");
|
|
assert.equal(dogPoseStateForSpeed(0.5), "walk");
|
|
assert.equal(dogPoseStateForSpeed(1.5), "trot");
|
|
assert.equal(dogPoseStateForSpeed(3), "run");
|
|
|
|
const rig = buildDog();
|
|
const pose = {
|
|
state: "trot" as const,
|
|
phase: 2.17,
|
|
amount: 0.83,
|
|
turn: -0.42,
|
|
acceleration: -0.3,
|
|
attentionYaw: 0.11,
|
|
attentionPitch: -0.08,
|
|
};
|
|
poseDog(rig, pose);
|
|
const first = transformSnapshot(rig);
|
|
poseDog(rig, pose);
|
|
assert.deepEqual(transformSnapshot(rig), first);
|
|
poseDogAttention(rig, 0.2, 1.7);
|
|
const attention = transformSnapshot(rig);
|
|
poseDogAttention(rig, 0.2, 1.7);
|
|
assert.deepEqual(transformSnapshot(rig), attention, "legacy attention remains idempotent");
|
|
disposeDog(rig);
|
|
});
|
|
|
|
it("clones shared resources into independent joints and respects material ownership", () => {
|
|
const source = buildDog();
|
|
const clone = cloneDog(source);
|
|
const sourceMeshes = meshes(source.root);
|
|
const cloneMeshes = meshes(clone.root);
|
|
assert.equal(cloneMeshes.length, sourceMeshes.length);
|
|
for (let index = 0; index < sourceMeshes.length; index += 1) {
|
|
assert.equal(cloneMeshes[index]!.geometry, sourceMeshes[index]!.geometry);
|
|
assert.equal(cloneMeshes[index]!.material, sourceMeshes[index]!.material);
|
|
}
|
|
poseDog(clone, { state: "run", phase: 1.3, amount: 1 });
|
|
assert.notEqual(clone.joints.frontLeft.upper.rotation.x, source.joints.frontLeft.upper.rotation.x);
|
|
assert.equal(clone.ownsMaterials, false);
|
|
|
|
const shared = sourceMeshes[0]!.material as THREE.Material;
|
|
let disposals = 0;
|
|
shared.addEventListener("dispose", () => disposals++);
|
|
const external = { coat: shared, markings: shared, nose: shared, collar: shared };
|
|
const externalRig = buildDog({ materials: external });
|
|
disposeDog(externalRig);
|
|
assert.equal(disposals, 0);
|
|
disposeDog(source);
|
|
assert.equal(disposals, 1);
|
|
});
|
|
});
|