Files
healer-man/scripts/manastorm-assets/scaffold-converted-import.mjs
T
2026-08-14 15:56:39 -04:00

544 lines
21 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from "node:crypto";
import {
access,
readFile,
readdir,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { NodeIO } from "@gltf-transform/core";
import {
assertRecastCollisionCoverage,
IDENTITY_CHUNK_TRANSFORM,
normalizeChunkTransform,
} from "./compound-stage.mjs";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
async function exists(file) {
try {
await access(file);
return true;
} catch {
return false;
}
}
async function readJson(file) {
return JSON.parse(await readFile(file, "utf8"));
}
async function sha256(file) {
const hash = createHash("sha256");
hash.update(await readFile(file));
return hash.digest("hex");
}
async function inspectTriangleCount(file) {
const document = await new NodeIO().read(file);
return document.getRoot().listMeshes().reduce((total, mesh) => (
total + mesh.listPrimitives().reduce((meshTotal, primitive) => {
const indices = primitive.getIndices()?.getCount();
const positions = primitive.getAttribute("POSITION")?.getCount() ?? 0;
return meshTotal + Math.floor((indices ?? positions) / 3);
}, 0)
), 0);
}
function relativeProjectPath(file) {
return path.relative(projectRoot, file).replace(/\\/g, "/");
}
function rounded(point) {
return point.map((value) => Number(value.toFixed(6)));
}
async function navigationAnalysis(file, {
anchorComponentMinimumHeight = null,
anchorComponentMaximumHorizontalSpan = null,
semanticPositions = null,
} = {}) {
const document = await new NodeIO().read(file);
const primitive = document.getRoot().listMeshes()[0]?.listPrimitives()[0];
const positions = primitive?.getAttribute("POSITION")?.getArray();
const sourceIndices = primitive?.getIndices()?.getArray();
if (!positions) throw new Error(`${relativeProjectPath(file)} has no navigation positions.`);
const indices = sourceIndices
? Array.from(sourceIndices)
: Array.from({ length: positions.length / 3 }, (_, index) => index);
const welded = new Map();
const vertexIds = [];
const uniquePoints = [];
for (let index = 0; index < positions.length; index += 3) {
const point = [positions[index], positions[index + 1], positions[index + 2]];
const key = point.map((value) => Math.round(value * 1000)).join(":");
if (!welded.has(key)) {
welded.set(key, welded.size);
uniquePoints.push(point);
}
vertexIds.push(welded.get(key));
}
const triangleCount = Math.floor(indices.length / 3);
const parent = Array.from({ length: triangleCount }, (_, index) => index);
const owner = new Map();
const root = (start) => {
let value = start;
while (parent[value] !== value) {
parent[value] = parent[parent[value]];
value = parent[value];
}
return value;
};
const union = (left, right) => {
const leftRoot = root(left);
const rightRoot = root(right);
if (leftRoot !== rightRoot) parent[rightRoot] = leftRoot;
};
for (let triangle = 0; triangle < triangleCount; triangle += 1) {
for (let corner = 0; corner < 3; corner += 1) {
const vertex = vertexIds[indices[triangle * 3 + corner]];
if (owner.has(vertex)) union(triangle, owner.get(vertex));
else owner.set(vertex, triangle);
}
}
const components = new Map();
for (let triangle = 0; triangle < triangleCount; triangle += 1) {
const component = root(triangle);
if (!components.has(component)) components.set(component, []);
components.get(component).push(triangle);
}
const sortedComponents = [...components.values()].sort((left, right) => right.length - left.length);
const largest = sortedComponents[0];
if (!largest?.length) throw new Error(`${relativeProjectPath(file)} has no navigation triangles.`);
const componentVertices = (component) => {
const result = new Set();
for (const triangle of component) {
for (let corner = 0; corner < 3; corner += 1) {
result.add(vertexIds[indices[triangle * 3 + corner]]);
}
}
return result;
};
const axisRange = (points, axis) => {
let minimum = Infinity;
let maximum = -Infinity;
for (const point of points) {
minimum = Math.min(minimum, point[axis]);
maximum = Math.max(maximum, point[axis]);
}
return [minimum, maximum];
};
const largestVertices = componentVertices(largest);
let anchorComponent = largest;
let anchorVertices = largestVertices;
let anchorSelection = "largest";
const selectionConstraints = [];
if (Number.isFinite(anchorComponentMinimumHeight)) {
selectionConstraints.push(`minimum-height:${anchorComponentMinimumHeight}`);
}
if (Number.isFinite(anchorComponentMaximumHorizontalSpan)) {
selectionConstraints.push(`maximum-horizontal-span:${anchorComponentMaximumHorizontalSpan}`);
}
if (selectionConstraints.length) {
for (const component of sortedComponents) {
const vertices = componentVertices(component);
const points = [...vertices].map((index) => uniquePoints[index]);
const minimumHeight = axisRange(points, 1)[0];
const horizontalX = axisRange(points, 0);
const horizontalZ = axisRange(points, 2);
const horizontalSpan = Math.max(
Math.abs(horizontalX[1] - horizontalX[0]),
Math.abs(horizontalZ[1] - horizontalZ[0]),
);
const satisfiesMinimumHeight = !Number.isFinite(anchorComponentMinimumHeight)
|| minimumHeight >= anchorComponentMinimumHeight;
const satisfiesMaximumSpan = !Number.isFinite(anchorComponentMaximumHorizontalSpan)
|| horizontalSpan <= anchorComponentMaximumHorizontalSpan;
if (satisfiesMinimumHeight && satisfiesMaximumSpan) {
anchorComponent = component;
anchorVertices = vertices;
anchorSelection = selectionConstraints.join(",");
break;
}
}
}
const selectedVertices = new Set();
for (const triangle of anchorComponent) {
for (let corner = 0; corner < 3; corner += 1) {
selectedVertices.add(vertexIds[indices[triangle * 3 + corner]]);
}
}
const points = uniquePoints.filter((_, index) => selectedVertices.has(index));
const average = (values) => values.reduce((sum, value) => sum + value, 0) / values.length;
const ranges = [0, 1, 2].map((axis) => axisRange(points, axis));
const dominantAxis = [0, 2].sort((left, right) => (
(ranges[right][1] - ranges[right][0]) - (ranges[left][1] - ranges[left][0])
))[0];
const low = ranges[dominantAxis][0];
const high = ranges[dominantAxis][1];
const span = high - low;
const startBand = points.filter((point) => point[dominantAxis] < low + span * 0.08);
const endBand = points.filter((point) => point[dominantAxis] > high - span * 0.08);
const nearest = (target) => points.reduce((best, point) => (
Math.hypot(...point.map((value, axis) => value - target[axis]))
< Math.hypot(...best.map((value, axis) => value - target[axis]))
? point
: best
));
const centroid = [0, 1, 2].map((axis) => average(points.map((point) => point[axis])));
const startTarget = [0, 1, 2].map((axis) => average(startBand.map((point) => point[axis])));
const endTarget = [0, 1, 2].map((axis) => average(endBand.map((point) => point[axis])));
startTarget[dominantAxis] = low + span * 0.04;
endTarget[dominantAxis] = high - span * 0.04;
const semanticAnchors = semanticPositions
? Object.fromEntries(Object.entries(semanticPositions).map(([kind, point]) => [
kind,
rounded(nearest(point)),
]))
: undefined;
return {
triangles: triangleCount,
components: components.size,
largestComponentTriangles: largest.length,
largestComponentVertices: largestVertices.size,
anchorComponentTriangles: anchorComponent.length,
anchorComponentVertices: anchorVertices.size,
anchorSelection,
dominantAxis: dominantAxis === 0 ? "X" : "Z",
anchors: {
start: rounded(nearest(startTarget)),
middle: rounded(nearest(centroid)),
end: rounded(nearest(endTarget)),
},
...(semanticAnchors ? { semanticAnchors } : {}),
};
}
async function placementAnalysis(sourceDirectory) {
const files = (await readdir(sourceDirectory))
.filter((name) => name.endsWith("_ModelPlacementInformation.csv"))
.sort();
let rows = 0;
let unresolvedRows = 0;
const uniqueModels = new Set();
const unresolvedModels = new Set();
for (const name of files) {
const lines = (await readFile(path.join(sourceDirectory, name), "utf8"))
.split(/\r?\n/).slice(1).filter(Boolean);
for (const line of lines) {
const rawModel = line.split(";")[0];
const model = rawModel.startsWith("\"") && rawModel.endsWith("\"")
? rawModel.slice(1, -1).replace(/""/g, "\"")
: rawModel;
if (!model) continue;
rows += 1;
uniqueModels.add(model.toLowerCase());
const resolved = path.join(sourceDirectory, ...model.split(/[\\/]/));
if (!await exists(resolved)) {
unresolvedRows += 1;
unresolvedModels.add(model.toLowerCase());
}
}
}
return {
files: files.length,
rows,
uniqueModels: uniqueModels.size,
unresolvedRows,
unresolvedModels: unresolvedModels.size,
};
}
function anchor(id, kind, sourcePosition, source, note) {
return {
id,
kind,
sourcePosition,
provenance: "authored",
source,
note,
};
}
function reportEntries(value) {
return Array.isArray(value) ? value : value ? [value] : [];
}
function epochAdtBasis(position) {
return [position[2], position[1], -position[0]];
}
function semanticRuntimePositions(runtimeDraft) {
if (!runtimeDraft?.entrance) return null;
const player = epochAdtBasis(runtimeDraft.entrance);
const mappedBosses = (runtimeDraft.bossCandidates ?? []).map((spawn) => (
epochAdtBasis(spawn.position)
));
const distanceSquared = (left, right) => (
(left[0] - right[0]) ** 2 + (left[2] - right[2]) ** 2
);
const boss = mappedBosses.sort((left, right) => (
distanceSquared(right, player) - distanceSquared(left, player)
))[0];
if (!boss) return null;
const middleTarget = player.map((value, axis) => (value + boss[axis]) / 2);
const mappedTrash = (runtimeDraft.spawns ?? [])
.filter((spawn) => !(runtimeDraft.bossCandidates ?? []).some(
(bossSpawn) => bossSpawn.id === spawn.id,
))
.map((spawn) => epochAdtBasis(spawn.position));
const trash = mappedTrash.sort((left, right) => (
distanceSquared(left, middleTarget) - distanceSquared(right, middleTarget)
))[0] ?? middleTarget;
return { player, trash, boss };
}
async function conversionAssetDescriptor({
conversion,
conversionFile,
entry,
index,
count,
role,
}) {
const file = path.join(path.dirname(conversionFile), entry.file);
if (!await exists(file)) throw new Error(`${conversion.dungeonId}: missing ${role} chunk ${entry.file}.`);
const actualTriangles = await inspectTriangleCount(file);
if (role !== "visual" && entry.triangles !== undefined && entry.triangles !== actualTriangles) {
throw new Error(
`${conversion.dungeonId}: ${role} chunk ${entry.file} reports ${entry.triangles} triangles but contains ${actualTriangles}.`,
);
}
const id = entry.id ?? (count === 1 ? role : `${role}-${String(index).padStart(3, "0")}`);
return {
id,
path: relativeProjectPath(file),
checksum: await sha256(file),
triangleCount: actualTriangles,
...(role === "visual" && entry.triangles !== undefined
? { instanceTriangleCount: entry.triangles }
: {}),
transform: normalizeChunkTransform(
entry.transform ?? IDENTITY_CHUNK_TRANSFORM,
`${conversion.dungeonId}: ${role} chunk ${id}`,
),
provenance: {
kind: "blender-conversion-report",
report: relativeProjectPath(conversionFile),
sourceFiles: entry.sources ?? conversion.sources ?? [],
...(entry.provenance ?? {}),
},
};
}
export async function scaffold(manastormSlug, dungeonSlug) {
const manastormRecipeFile = path.join(
projectRoot,
"dungeon-pipeline/manastorm/recipes",
`${manastormSlug}.json`,
);
const manastormRecipe = await readJson(manastormRecipeFile);
const dungeonRecipe = await readJson(path.join(
projectRoot,
"dungeon-pipeline/recipes",
`${dungeonSlug}.json`,
));
const work = path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons", dungeonSlug);
const sourceDirectory = path.join(work, "source-export");
const conversionFile = path.join(work, "staging/environment/conversion-report.json");
const recastFile = path.join(work, "recast-report.json");
const conversion = await readJson(conversionFile);
const recast = await readJson(recastFile);
if (!["green", "review-required"].includes(conversion.status)) {
throw new Error(`${dungeonSlug}: conversion is not reviewable.`);
}
const visualEntries = reportEntries(conversion.visual);
const collisionEntries = reportEntries(conversion.collision);
if (!visualEntries.length) throw new Error(`${dungeonSlug}: conversion has no visual chunks.`);
if (!collisionEntries.length) throw new Error(`${dungeonSlug}: conversion has no collision chunks.`);
const visualAssets = await Promise.all(visualEntries.map((entry, index) => conversionAssetDescriptor({
conversion,
conversionFile,
entry,
index,
count: visualEntries.length,
role: "visual",
})));
const collisionAssets = await Promise.all(collisionEntries.map((entry, index) => conversionAssetDescriptor({
conversion,
conversionFile,
entry,
index,
count: collisionEntries.length,
role: "collision",
})));
assertRecastCollisionCoverage(collisionAssets, recast, dungeonSlug);
const navigation = path.resolve(projectRoot, recast.output);
if (!await exists(navigation)) throw new Error(`${dungeonSlug}: navigation GLB is missing.`);
const navigationTriangles = await inspectTriangleCount(navigation);
if (recast.geometry?.triangles !== undefined && recast.geometry.triangles !== navigationTriangles) {
throw new Error(`${dungeonSlug}: navigation report triangle count does not match its GLB.`);
}
const navigationAsset = {
id: "navigation",
path: relativeProjectPath(navigation),
checksum: await sha256(navigation),
triangleCount: navigationTriangles,
transform: normalizeChunkTransform(
recast.transform ?? IDENTITY_CHUNK_TRANSFORM,
`${dungeonSlug}: navigation chunk`,
),
provenance: {
kind: "recast-navigation-report",
report: relativeProjectPath(recastFile),
collisionChunkIds: collisionAssets.map((asset) => asset.id),
collisionInputPaths: [...recast.inputChunks],
},
};
const automationFile = path.join(sourceDirectory, "automation-result.json");
const automation = await readJson(automationFile);
if (!automation.ok || automation.missingDependencies?.length) {
throw new Error(`${dungeonSlug}: source export has unresolved dependencies.`);
}
const sourceObjects = await Promise.all((conversion.sources ?? []).map(async (source) => {
const file = path.join(sourceDirectory, source);
if (!await exists(file)) throw new Error(`${dungeonSlug}: conversion source ${source} is missing.`);
return file;
}));
if (!sourceObjects.length) throw new Error(`${dungeonSlug}: conversion has no source objects.`);
const rootWmoRecords = (automation.manifest ?? []).filter((entry) => entry.type === "WMO");
const rootWmos = (await Promise.all(rootWmoRecords.map(async (entry) => {
const file = path.join(sourceDirectory, entry.file);
return await exists(file) ? file : null;
}))).filter(Boolean);
if (conversion.environmentMode === "global-wmo" && !rootWmos.length) {
throw new Error(`${dungeonSlug}: source WMO evidence is missing.`);
}
const runtimeDraftFile = path.join(work, "runtime-draft.json");
const runtimeDraft = await exists(runtimeDraftFile) ? await readJson(runtimeDraftFile) : null;
const semanticPositions = dungeonRecipe.clientBuild === "3.3.5a-Epoch"
&& conversion.environmentMode === "adt-hybrid"
? semanticRuntimePositions(runtimeDraft)
: null;
const nav = await navigationAnalysis(navigation, {
anchorComponentMinimumHeight: dungeonRecipe.navigation?.anchorComponentMinimumHeight,
anchorComponentMaximumHorizontalSpan:
dungeonRecipe.navigation?.anchorComponentMaximumHorizontalSpan,
semanticPositions,
});
const placements = await placementAnalysis(sourceDirectory);
const warnings = [
nav.anchorSelection === "largest"
? `The Recast result has ${nav.components} connected components; provisional anchors stay on the largest ${nav.largestComponentTriangles.toLocaleString("en-US")}-triangle component.`
: `The Recast result has ${nav.components} connected components; provisional anchors use the largest component satisfying ${nav.anchorSelection}, with ${nav.anchorComponentTriangles.toLocaleString("en-US")} triangles (the largest overall component has ${nav.largestComponentTriangles.toLocaleString("en-US")}).`,
"Entrance, boss, portal, and resurrection semantics require encounter-data and playtest review.",
...(semanticPositions
? ["Player, trash, and boss anchors were projected from AzerothCore server coordinates onto the selected Recast component."]
: []),
...(conversion.warnings ?? []),
...(dungeonRecipe.warnings ?? []),
];
const cappedCollision = collisionAssets.filter((asset) => asset.triangleCount >= 99_999);
if (cappedCollision.length) {
warnings.push(
`${cappedCollision.length} collision ${cappedCollision.length === 1 ? "chunk reached" : "chunks reached"} the 100,000-triangle conversion cap.`,
);
}
if (placements.unresolvedRows) {
warnings.push(
`${placements.unresolvedRows.toLocaleString("en-US")} of ${placements.rows.toLocaleString("en-US")} decorative placement rows reference ${placements.unresolvedModels.toLocaleString("en-US")} unavailable source-root OBJ paths; architectural WMO/collision completeness is unaffected, but decoration requires visual review.`,
);
}
const start = nav.semanticAnchors?.player ?? nav.anchors.start;
const middle = nav.semanticAnchors?.trash ?? nav.anchors.middle;
const end = nav.semanticAnchors?.boss ?? nav.anchors.end;
const anchorSource = semanticPositions
? "AzerothCore server coordinate projected to the selected Recast component."
: "Geometry-derived provisional Recast component anchor.";
const config = {
schemaVersion: 1,
slug: manastormSlug,
mapId: manastormRecipe.mapId,
reviewStatus: "provisional",
warnings,
coordinateSystem: {
units: "meters",
upAxis: "+Y",
fromWow: "three(x,y,z)=(-wow.x,wow.z,wow.y)",
scale: 1,
},
sourcePackage: {
conversionReport: relativeProjectPath(conversionFile),
navigationReport: relativeProjectPath(recastFile),
omissions: automation.sourceOmissions ?? [],
fallbacks: automation.sourceFallbacks ?? [],
evidence: [
{ id: "wow-export-result", path: relativeProjectPath(automationFile) },
...sourceObjects.map((file, index) => ({
id: `wow-export-obj-${String(index).padStart(3, "0")}`,
path: relativeProjectPath(file),
})),
...rootWmos.map((file, index) => ({
id: `client-root-wmo-${String(index).padStart(3, "0")}`,
path: relativeProjectPath(file),
})),
{ id: "blender-conversion-report", path: relativeProjectPath(conversionFile) },
{ id: "recast-navigation-report", path: relativeProjectPath(recastFile) },
],
},
assets: {
visual: visualAssets,
collision: collisionAssets,
navigation: navigationAsset,
},
navigationAnalysis: nav,
placementAnalysis: placements,
anchors: [
anchor("player-provisional-main-component", "player", start, anchorSource, "Provisional projected entrance."),
anchor("trash-provisional-main-component", "trash", middle, anchorSource, "Provisional projected trash anchor."),
anchor("boss-provisional-main-component", "boss", end, anchorSource, "Provisional projected boss anchor."),
anchor("portal-provisional-main-component", "portal", start, "Reuses the provisional player anchor for the stage exit.", "Provisional until portal placement is playtested."),
anchor("resurrection-provisional-main-component", "resurrection", start, "Reuses the provisional player anchor as a safe navigation fallback.", "Provisional until resurrection placement is playtested."),
],
};
const output = path.join(
projectRoot,
"dungeon-pipeline/manastorm/imports",
`${manastormSlug}.json`,
);
await writeFile(output, `${JSON.stringify(config, null, 2)}\n`, "utf8");
return {
status: "green",
mapId: config.mapId,
slug: manastormSlug,
output: relativeProjectPath(output),
navigation: nav,
placements,
warnings,
};
}
async function main() {
const [, , manastormSlug, dungeonSlug] = process.argv;
try {
if (!manastormSlug || !dungeonSlug) {
throw new Error("Usage: node scripts/manastorm-assets/scaffold-converted-import.mjs MANASTORM_SLUG DUNGEON_SLUG");
}
console.log(JSON.stringify(await scaffold(manastormSlug, dungeonSlug), null, 2));
} catch (error) {
console.error(JSON.stringify({
status: "error",
manastormSlug,
dungeonSlug,
error: error instanceof Error ? error.message : String(error),
}, null, 2));
process.exitCode = 1;
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await main();
}