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", ], }; }