212 lines
8.2 KiB
JavaScript
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());
|
|
}
|
|
});
|