Release Healer Man 0.1.21
This commit is contained in:
1 parent
699fc858c9
commit
f6f2375145
210 files changed
+976039
-6247
No files matched your search
@@ -0,0 +1,383 @@
|
||||
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",
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user