Files
healer-man/scripts/runewaker-pipeline/environment-assembly.test.mjs
T
2026-10-06 22:25:34 -04:00

212 lines
8.2 KiB
JavaScript

import assert from "node:assert/strict";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { Document, NodeIO } from "@gltf-transform/core";
import { Matrix4, Vector3 } from "three";
import {
assembleInstancedGlb,
buildWdbPlacementPlan,
normalizeWdbResourcePath,
placementMatrixInPrimaryThree,
resourceCacheKey,
transformSourceHeadingToPrimaryThree,
transformSourcePointToPrimaryThree,
} from "./lib/environment-assembly.mjs";
const identityPlacement = (overrides = {}) => ({
guid: "PRIMARY",
resource: "model\\dungeon\\fixture\\room.ros",
scale: [1, 1, 1],
rotation: [0, 0, 0, 1],
translation: [10, 20, 30],
localBounds: { center: [0, 0, 0], radius: 1 },
worldBounds: { center: [10, 20, 30], radius: 1 },
...overrides,
});
const fixtureRecipe = (source = {}) => ({
slug: "fixture",
coordinateSystem: {
source: "left-handed Y-up",
nativeBridge: "three(x,y,z)=(runewaker.x,runewaker.y,-runewaker.z)",
metersPerSourceUnit: 0.1,
},
source: {
model: "model/dungeon/fixture/room.ros",
primaryPlacementTranslation: [10, 20, 30],
...source,
},
});
test("normalizes resource paths and makes case-insensitive stable cache keys", () => {
assert.equal(
normalizeWdbResourcePath("/Model/Dungeon/Fixture/Room.ROS"),
"model\\dungeon\\fixture\\room.ros",
);
assert.equal(
resourceCacheKey("Model/Dungeon/Fixture/Room.ROS"),
resourceCacheKey("model\\dungeon\\fixture\\room.ros"),
);
});
test("converts full placement translation, quaternion, and nonuniform scale", () => {
const primary = identityPlacement();
const placed = identityPlacement({
guid: "PLACED",
resource: "model\\dungeon\\fixture\\bridge.ros",
translation: [20, 22, 40],
rotation: [0, Math.SQRT1_2, 0, Math.SQRT1_2],
scale: [2, 3, 4],
});
const matrix = placementMatrixInPrimaryThree(placed, primary, 0.1);
const sourcePoint = [3, 4, 5];
const viaMatrix = new Vector3(sourcePoint[0] * 0.1, sourcePoint[1] * 0.1, -sourcePoint[2] * 0.1)
.applyMatrix4(new Matrix4().fromArray(matrix.toArray()))
.toArray();
const direct = transformSourcePointToPrimaryThree(sourcePoint, placed, primary, 0.1);
assert.deepEqual(viaMatrix.map((value) => Number(value.toFixed(9))), direct.map((value) => Number(value.toFixed(9))));
assert.notDeepEqual(matrix.toArray(), new Matrix4().identity().toArray());
});
test("transforms source headings through inverse primary rotation and handedness bridge", () => {
const primary = identityPlacement({
rotation: [0, Math.sin(Math.PI / 6), 0, Math.cos(Math.PI / 6)],
});
assert.deepEqual(
transformSourceHeadingToPrimaryThree(Math.PI / 6, primary).map((value) => Number(value.toFixed(9))),
[1, 0, 0],
"a 30-degree source heading in a 60-degree primary frame points along local +X",
);
assert.deepEqual(
transformSourceHeadingToPrimaryThree(Math.PI / 3, identityPlacement()).map((value) => Number(value.toFixed(9))),
[0.866025404, 0, 0.5],
"an identity primary frame preserves the existing +Z-forward Three.js convention",
);
assert.deepEqual(
transformSourceHeadingToPrimaryThree(Math.PI / 4, identityPlacement({ scale: [2, 1, -4] }))
.map((value) => Number(value.toFixed(9))),
[0.894427191, 0, -0.447213595],
"inverse nonuniform and negative primary scales affect heading before planar normalization",
);
});
test("builds deterministic static, dynamic, review, and unsupported placement groups", () => {
const primary = identityPlacement();
const door = identityPlacement({
guid: "DOOR",
resource: "model\\dungeon\\fixture\\boss_door.ros",
translation: [15, 20, 30],
});
const hintedBridge = identityPlacement({
guid: "BRIDGE",
resource: "model\\dungeon\\fixture\\drawbridge.ros",
translation: [20, 20, 30],
});
const unsupported = identityPlacement({
guid: "FX",
resource: "effect\\fixture\\mist.eft",
});
const recipe = fixtureRecipe({
wdbAssembly: {
mode: "all-ros",
dynamicPlacements: [{
id: "boss-door",
guid: "DOOR",
kind: "door",
initialState: "closed",
collision: "stateful",
evidence: ["fixture.lua:10"],
}],
},
});
const first = buildWdbPlacementPlan({
sourceWdb: "fixture.wdb",
descriptors: [unsupported, hintedBridge, door, primary],
}, recipe);
const second = buildWdbPlacementPlan({
sourceWdb: "fixture.wdb",
descriptors: [unsupported, hintedBridge, door, primary],
}, recipe);
assert.deepEqual(first, second);
assert.equal(first.staticPlacements.length, 2);
assert.equal(first.dynamicPlacements.length, 1);
assert.equal(first.dynamicPlacements[0].dynamic.kind, "door");
assert.equal(first.reviewRequiredPlacements.length, 1);
assert.equal(first.reviewRequiredPlacements[0].guid, "BRIDGE");
assert.equal(first.unsupportedPlacements.length, 1);
});
test("assembles repeated cached GLBs as transformed resource instances", async () => {
const temporary = await mkdtemp(path.join(os.tmpdir(), "healer-man-wdb-assembly-"));
try {
const sourceFile = path.join(temporary, "source.glb");
const outputFile = path.join(temporary, "assembled.glb");
const source = new Document();
const buffer = source.createBuffer("fixture-buffer");
const position = source.createAccessor("fixture-position", buffer)
.setType("VEC3")
.setArray(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]));
const primitive = source.createPrimitive().setAttribute("POSITION", position);
const mesh = source.createMesh("fixture-mesh").addPrimitive(primitive);
source.createScene("fixture-scene").addChild(source.createNode("fixture-node").setMesh(mesh));
const io = new NodeIO();
await io.write(sourceFile, source);
const makePlacement = (id, x) => ({
id,
guid: id.toUpperCase(),
resource: "model/dungeon/fixture/room.ros",
resourceCacheKey: resourceCacheKey("model/dungeon/fixture/room.ros"),
transform: {
primaryLocalThreeMatrix: new Matrix4().makeTranslation(x, 0, 0).toArray(),
},
});
const result = await assembleInstancedGlb([
{ assetFile: sourceFile, placement: makePlacement("first", 0) },
{ assetFile: sourceFile, placement: makePlacement("second", 5) },
], outputFile);
const assembled = await io.read(outputFile);
const scene = assembled.getRoot().getDefaultScene() ?? assembled.getRoot().listScenes()[0];
assert.deepEqual(scene.listChildren().map((node) => node.getName()), ["first", "second"]);
assert.deepEqual(result.bounds, { min: [0, 0, 0], max: [6, 1, 0] });
assert.equal(assembled.getRoot().listMeshes().length, 1, "repeated placements share cached mesh data");
} finally {
await rm(temporary, { recursive: true, force: true });
}
});
test("rejects dynamic rules that would remove the primary coordinate anchor", () => {
const primary = identityPlacement();
assert.throws(() => buildWdbPlacementPlan({ descriptors: [primary] }, fixtureRecipe({
wdbAssembly: {
dynamicPlacements: [{
id: "invalid-primary-door",
guid: "PRIMARY",
kind: "door",
evidence: ["fixture.lua:1"],
}],
},
})), /primary coordinate-anchor placement cannot be dynamic/);
});
test("current modular WDB reports account for every descriptor without guessed dynamics", async () => {
for (const slug of ["raksha-temple", "the-origin", "treasure-trove"]) {
const [recipe, report] = await Promise.all([
readFile(new URL("./recipes/" + slug + ".json", import.meta.url), "utf8").then(JSON.parse),
readFile(new URL("../../src/assets/game/dungeons/" + slug + "/wdb-report.json", import.meta.url), "utf8").then(JSON.parse),
]);
const plan = buildWdbPlacementPlan(report, recipe);
assert.equal(
plan.staticPlacements.length + plan.dynamicPlacements.length + plan.unsupportedPlacements.length,
report.descriptorCount,
slug,
);
assert.equal(plan.dynamicPlacements.length, 0, slug + " must not infer stateful behavior from names");
assert.ok(plan.resources.length > 1, slug + " should expose arbitrary resource assembly");
assert.equal(plan.primaryPlacement.resource.toLowerCase(), recipe.source.model.toLowerCase());
}
});