forked from phenom/healer-man
384 lines
14 KiB
JavaScript
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",
|
|
],
|
|
};
|
|
}
|