Files
healer-man/scripts/runewaker-pipeline/lib/environment-assembly.mjs
T
2026-09-08 10:40:26 -04:00

384 lines
14 KiB
JavaScript

import { createHash } from "node:crypto";
import path from "node:path";
import { Document, NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { getBounds, mergeDocuments, unpartition } from "@gltf-transform/functions";
import { Matrix4, Quaternion, Vector3 } from "three";
const SUPPORTED_RESOURCE_EXTENSIONS = new Set([".ros"]);
const DYNAMIC_HINT_PATTERN = /(?:^|[\\/_-])(door|gate|bridge|drawbridge|lift|elevator|switch|lever|chest|treasure|portal)(?:[\\/_.-]|$)/i;
const DYNAMIC_KINDS = new Set([
"door",
"gate",
"bridge",
"lift",
"switch",
"chest",
"portal",
"other",
]);
export function normalizeWdbResourcePath(value) {
return String(value ?? "")
.replaceAll("/", "\\")
.replace(/^\\+/, "")
.toLowerCase();
}
export function resourceCacheKey(value) {
return createHash("sha256")
.update(normalizeWdbResourcePath(value))
.digest("hex")
.slice(0, 24);
}
function finiteTuple(value, length, name) {
if (!Array.isArray(value) || value.length !== length
|| value.some((entry) => !Number.isFinite(Number(entry)))) {
throw new Error(name + " must contain exactly " + length + " finite numbers.");
}
return value.map(Number);
}
export function descriptorSourceMatrix(descriptor) {
const translation = finiteTuple(descriptor.translation, 3, "descriptor.translation");
const rotation = finiteTuple(descriptor.rotation, 4, "descriptor.rotation");
const scale = finiteTuple(descriptor.scale, 3, "descriptor.scale");
const quaternion = new Quaternion(...rotation);
if (quaternion.lengthSq() < 1e-12) throw new Error("descriptor.rotation cannot be zero.");
quaternion.normalize();
return new Matrix4().compose(
new Vector3(...translation),
quaternion,
new Vector3(...scale),
);
}
export function placementMatrixInPrimaryThree(descriptor, primary, metersPerSourceUnit) {
const meters = Number(metersPerSourceUnit);
if (!Number.isFinite(meters) || meters <= 0) {
throw new Error("metersPerSourceUnit must be a positive finite number.");
}
const sourceToThree = new Matrix4().makeScale(meters, meters, -meters);
const threeToSource = sourceToThree.clone().invert();
return sourceToThree
.clone()
.multiply(descriptorSourceMatrix(primary).invert())
.multiply(descriptorSourceMatrix(descriptor))
.multiply(threeToSource);
}
export function transformSourcePointToPrimaryThree(
sourcePoint,
descriptor,
primary,
metersPerSourceUnit,
) {
const point = new Vector3(...finiteTuple(sourcePoint, 3, "sourcePoint"));
point.applyMatrix4(descriptorSourceMatrix(descriptor));
point.applyMatrix4(descriptorSourceMatrix(primary).invert());
const meters = Number(metersPerSourceUnit);
return [point.x * meters, point.y * meters, -point.z * meters];
}
function cloneNodeTree(document, node) {
const clone = document.createNode(node.getName())
.setMatrix(node.getMatrix())
.setExtras({ ...node.getExtras() })
.setMesh(node.getMesh())
.setCamera(node.getCamera())
.setSkin(node.getSkin())
.setWeights([...node.getWeights()]);
for (const child of node.listChildren()) clone.addChild(cloneNodeTree(document, child));
return clone;
}
export async function assembleInstancedGlb(records, outputFile) {
if (!records.length) throw new Error("Cannot assemble an empty WDB GLB.");
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const target = new Document();
const scene = target.createScene("WDB assembly");
const recordsByAsset = new Map();
for (const record of records) {
const rows = recordsByAsset.get(record.assetFile) ?? [];
rows.push(record);
recordsByAsset.set(record.assetFile, rows);
}
for (const assetFile of [...recordsByAsset.keys()].sort()) {
const source = await io.read(assetFile);
const sourceScene = source.getRoot().getDefaultScene() ?? source.getRoot().listScenes()[0];
if (!sourceScene) throw new Error("Cached WDB resource has no scene: " + assetFile);
const mapping = mergeDocuments(target, source);
const mergedScene = mapping.get(sourceScene);
if (!mergedScene) throw new Error("Could not merge cached WDB resource: " + assetFile);
const roots = mergedScene.listChildren();
if (!roots.length) throw new Error("Cached WDB resource has no scene roots: " + assetFile);
for (const [index, record] of recordsByAsset.get(assetFile).entries()) {
const placement = target.createNode(record.placement.id)
.setMatrix(record.placement.transform.primaryLocalThreeMatrix)
.setExtras({
wdbPlacementId: record.placement.id,
wdbGuid: record.placement.guid,
sourceResource: record.placement.resource,
resourceCacheKey: record.placement.resourceCacheKey,
});
const nodes = index === 0
? roots
: roots.map((rootNode) => cloneNodeTree(target, rootNode));
for (const rootNode of nodes) placement.addChild(rootNode);
scene.addChild(placement);
}
mergedScene.dispose();
}
await target.transform(unpartition());
const bounds = getBounds(scene);
await io.write(outputFile, target);
return {
bounds: {
min: bounds.min.map((value) => Number(value.toFixed(6))),
max: bounds.max.map((value) => Number(value.toFixed(6))),
},
};
}
function matrixArray(matrix) {
return matrix.toArray().map((value) => Number(value.toFixed(10)));
}
function selectPrimaryDescriptor(report, recipe) {
const target = normalizeWdbResourcePath(recipe.source.model);
const matches = (report.descriptors ?? []).filter((descriptor) => (
normalizeWdbResourcePath(descriptor.resource) === target
));
if (!matches.length) {
throw new Error("Expected at least one WDB placement for " + recipe.source.model + ".");
}
if (matches.length === 1) return matches[0];
const expected = recipe.source.primaryPlacementTranslation;
if (!Array.isArray(expected) || expected.length !== 3) {
throw new Error("Multiple primary-resource placements require source.primaryPlacementTranslation.");
}
const match = matches.find((descriptor) => descriptor.translation.every((value, index) => (
Math.abs(Number(value) - Number(expected[index])) <= 0.0001
)));
if (!match) throw new Error("Configured primary WDB placement was not found.");
return match;
}
function reviewedDynamicRules(recipe) {
const rules = recipe.source.wdbAssembly?.dynamicPlacements ?? [];
const ids = new Set();
return rules.map((rule, index) => {
if (!rule || typeof rule !== "object") {
throw new Error("source.wdbAssembly.dynamicPlacements[" + index + "] must be an object.");
}
if (!String(rule.id ?? "").trim()) throw new Error("Dynamic placement rule requires a stable id.");
if (ids.has(rule.id)) throw new Error("Duplicate dynamic placement id " + rule.id + ".");
ids.add(rule.id);
if (!DYNAMIC_KINDS.has(rule.kind)) {
throw new Error("Dynamic placement " + rule.id + " has unsupported kind " + rule.kind + ".");
}
if (!rule.guid && !rule.resource) {
throw new Error("Dynamic placement " + rule.id + " requires guid or resource evidence.");
}
if (!Array.isArray(rule.evidence) || !rule.evidence.length) {
throw new Error("Dynamic placement " + rule.id + " requires reviewed evidence.");
}
return {
...rule,
normalizedResource: rule.resource ? normalizeWdbResourcePath(rule.resource) : null,
};
});
}
function matchingDynamicRule(descriptor, rules) {
const resource = normalizeWdbResourcePath(descriptor.resource);
return rules.find((rule) => (
(rule.guid && String(rule.guid).toLowerCase() === String(descriptor.guid).toLowerCase())
|| (rule.normalizedResource && rule.normalizedResource === resource)
));
}
function placementRecord(descriptor, index, primary, recipe, classification, dynamicRule = null) {
const resource = normalizeWdbResourcePath(descriptor.resource);
const id = classification === "dynamic"
? dynamicRule.id + ":" + String(descriptor.guid ?? index)
: "wdb:" + String(descriptor.guid ?? index);
return {
id,
guid: descriptor.guid ?? null,
resource: String(descriptor.resource).replaceAll("\\", "/"),
normalizedResource: resource,
resourceCacheKey: resourceCacheKey(resource),
classification,
transform: {
source: {
translation: descriptor.translation.map(Number),
rotation: descriptor.rotation.map(Number),
scale: descriptor.scale.map(Number),
},
primaryLocalThreeMatrix: matrixArray(placementMatrixInPrimaryThree(
descriptor,
primary,
recipe.coordinateSystem.metersPerSourceUnit,
)),
},
sourceBounds: {
local: descriptor.localBounds ?? null,
world: descriptor.worldBounds ?? null,
},
sourceFlags: {
zoneFlags: Number(descriptor.zoneFlags ?? 0),
detailClass: Number(descriptor.detailClass ?? 0),
zoneType: Number(descriptor.zoneType ?? 0),
},
...(dynamicRule ? {
dynamic: {
definitionId: dynamicRule.id,
kind: dynamicRule.kind,
initialState: dynamicRule.initialState ?? "default",
collision: dynamicRule.collision ?? "stateful",
evidence: dynamicRule.evidence,
},
} : {}),
};
}
export function buildWdbPlacementPlan(report, recipe) {
if (!report || !Array.isArray(report.descriptors)) {
throw new Error("WDB report must contain descriptors.");
}
const primary = selectPrimaryDescriptor(report, recipe);
const dynamicRules = reviewedDynamicRules(recipe);
const matchedDynamicRuleIds = new Set();
const staticPlacements = [];
const dynamicPlacements = [];
const reviewRequiredPlacements = [];
const unsupportedPlacements = [];
const resourceMap = new Map();
for (const [index, descriptor] of report.descriptors.entries()) {
const resource = normalizeWdbResourcePath(descriptor.resource);
const extension = path.extname(resource);
if (!SUPPORTED_RESOURCE_EXTENSIONS.has(extension)) {
unsupportedPlacements.push({
id: "wdb:" + String(descriptor.guid ?? index),
guid: descriptor.guid ?? null,
resource: descriptor.resource,
reason: extension
? "unsupported WDB placement resource extension " + extension
: "WDB placement resource has no extension",
});
continue;
}
let matrix;
try {
matrix = placementMatrixInPrimaryThree(
descriptor,
primary,
recipe.coordinateSystem.metersPerSourceUnit,
);
} catch (error) {
unsupportedPlacements.push({
id: "wdb:" + String(descriptor.guid ?? index),
guid: descriptor.guid ?? null,
resource: descriptor.resource,
reason: "invalid WDB transform: " + error.message,
});
continue;
}
if (matrix.elements.some((value) => !Number.isFinite(value))) {
unsupportedPlacements.push({
id: "wdb:" + String(descriptor.guid ?? index),
guid: descriptor.guid ?? null,
resource: descriptor.resource,
reason: "WDB transform produced a non-finite matrix",
});
continue;
}
const dynamicRule = matchingDynamicRule(descriptor, dynamicRules);
if (descriptor === primary && dynamicRule) {
throw new Error("The primary coordinate-anchor placement cannot be dynamic ("
+ dynamicRule.id + ").");
}
const classification = dynamicRule ? "dynamic" : "static";
const placement = placementRecord(
descriptor,
index,
primary,
recipe,
classification,
dynamicRule,
);
if (dynamicRule) {
matchedDynamicRuleIds.add(dynamicRule.id);
dynamicPlacements.push(placement);
} else {
staticPlacements.push(placement);
if (DYNAMIC_HINT_PATTERN.test(resource)) {
reviewRequiredPlacements.push({
placementId: placement.id,
guid: placement.guid,
resource: placement.resource,
reason: "Resource name resembles a movable or interactive object, but no reviewed dynamic-placement rule proves its behavior.",
});
}
}
if (!resourceMap.has(resource)) {
resourceMap.set(resource, {
resource: placement.resource,
normalizedResource: resource,
cacheKey: placement.resourceCacheKey,
placementCount: 0,
staticPlacementCount: 0,
dynamicPlacementCount: 0,
});
}
const resourceRecord = resourceMap.get(resource);
resourceRecord.placementCount += 1;
resourceRecord[classification + "PlacementCount"] += 1;
}
const unmatchedDynamicRules = dynamicRules
.filter((rule) => !matchedDynamicRuleIds.has(rule.id))
.map((rule) => ({
id: rule.id,
guid: rule.guid ?? null,
resource: rule.resource ?? null,
reason: "Reviewed dynamic-placement selector matched no WDB descriptor.",
}));
if (unmatchedDynamicRules.length) {
throw new Error(unmatchedDynamicRules.map((rule) => rule.reason + " " + rule.id).join("\n"));
}
return {
schemaVersion: 1,
dungeonId: recipe.slug,
sourceWdb: report.sourceWdb,
sourceDescriptorCount: report.descriptors.length,
primaryPlacement: {
guid: primary.guid ?? null,
resource: String(primary.resource).replaceAll("\\", "/"),
translation: primary.translation.map(Number),
rotation: primary.rotation.map(Number),
scale: primary.scale.map(Number),
},
coordinateTransform: {
source: recipe.coordinateSystem.source,
nativeBridge: recipe.coordinateSystem.nativeBridge,
metersPerSourceUnit: Number(recipe.coordinateSystem.metersPerSourceUnit),
formula: "threeMatrix=S*inverse(primaryTRS)*placementTRS*inverse(S), S=scale(meters,meters,-meters)",
},
resources: [...resourceMap.values()].sort((left, right) => (
left.normalizedResource.localeCompare(right.normalizedResource)
)),
staticPlacements: staticPlacements.sort((left, right) => left.id.localeCompare(right.id)),
dynamicPlacements: dynamicPlacements.sort((left, right) => left.id.localeCompare(right.id)),
reviewRequiredPlacements: reviewRequiredPlacements
.sort((left, right) => left.placementId.localeCompare(right.placementId)),
unsupportedPlacements: unsupportedPlacements.sort((left, right) => left.id.localeCompare(right.id)),
unsupportedResourceTypes: [
"WDB placement resources other than ROS",
"particle, light, sound, camera, and script-only components not represented as ROS mesh descriptors",
"animated or stateful behavior without a reviewed source.wdbAssembly.dynamicPlacements rule",
],
};
}