Files
healer-man/scripts/runewaker-pipeline/inspect-aeternal-support-ground.mjs
T
2026-10-06 22:25:34 -04:00

342 lines
17 KiB
JavaScript

import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const work = path.join(projectRoot, "runewaker-export-work", "aeternal-circle");
const inspection = path.join(work, "inspection");
const recipe = JSON.parse(await readFile(path.join(projectRoot, "scripts/runewaker-pipeline/recipes/aeternal-circle.json"), "utf8"));
const metadata = JSON.parse(await readFile(path.join(projectRoot, "src/assets/game/dungeons/aeternal-circle/import-metadata.json"), "utf8"));
const sourceReport = JSON.parse(await readFile(path.join(work, "prepared/export-report.json"), "utf8"));
const wdbReport = JSON.parse(await readFile(path.join(work, "raw/wdb-report.json"), "utf8"));
const document = await new NodeIO().registerExtensions(ALL_EXTENSIONS).read(path.join(work, "converted/aeternal-circle-visual.glb"));
const root = document.getRoot();
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
const pngDimensions = (bytes) => bytes.toString("hex", 0, 8) === "89504e470d0a1a0a"
? [bytes.readUInt32BE(16), bytes.readUInt32BE(20)]
: null;
const normalizeResource = (value) => String(value ?? "").replaceAll("/", "\\").toLowerCase();
const xyz = (matrix, point) => [
matrix[0] * point[0] + matrix[4] * point[1] + matrix[8] * point[2] + matrix[12],
matrix[1] * point[0] + matrix[5] * point[1] + matrix[9] * point[2] + matrix[13],
matrix[2] * point[0] + matrix[6] * point[1] + matrix[10] * point[2] + matrix[14],
];
const subtract = (a, b) => a.map((value, index) => value - b[index]);
const cross = (a, b) => [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
const normalize = (value) => {
const length = Math.hypot(...value);
return length ? value.map((component) => component / length) : [0, 0, 0];
};
const primary = wdbReport.descriptors.find((descriptor) => (
normalizeResource(descriptor.resource) === normalizeResource(recipe.source.model)
&& descriptor.translation.every((value, index) => Math.abs(value - recipe.source.primaryPlacementTranslation[index]) <= 0.0001)
));
if (!primary) throw new Error("Aeternal primary WDB placement could not be matched to the recipe.");
const sourceAnchor = recipe.source.sourceAnchorSupport.points[0];
const supportNode = root.listNodes().find((node) => node.getName() === "runewaker_source_anchor_support");
if (!supportNode?.getMesh()) throw new Error("Aeternal source-anchor support mesh is missing from the canonical converted visual.");
const supportPrimitive = supportNode.getMesh().listPrimitives()[0];
const supportPosition = supportPrimitive.getAttribute("POSITION");
const supportIndices = supportPrimitive.getIndices()?.getArray();
if (!supportPosition || !supportIndices) throw new Error("Aeternal support mesh is missing positions or indices.");
const supportMatrix = Array.from(supportNode.getWorldMatrix());
const supportAdjacency = Array.from({ length: supportPosition.getCount() }, () => new Set());
for (let index = 0; index < supportIndices.length; index += 3) {
const [a, b, c] = supportIndices.slice(index, index + 3);
supportAdjacency[a].add(b); supportAdjacency[a].add(c);
supportAdjacency[b].add(a); supportAdjacency[b].add(c);
supportAdjacency[c].add(a); supportAdjacency[c].add(b);
}
const supportComponents = [];
const visited = new Uint8Array(supportPosition.getCount());
for (let start = 0; start < visited.length; start += 1) {
if (visited[start]) continue;
const vertices = [start];
visited[start] = 1;
for (let cursor = 0; cursor < vertices.length; cursor += 1) {
for (const next of supportAdjacency[vertices[cursor]]) {
if (visited[next]) continue;
visited[next] = 1;
vertices.push(next);
}
}
const points = vertices.map((index) => xyz(supportMatrix, supportPosition.getElement(index, [0, 0, 0])));
const min = [0, 1, 2].map((axis) => Math.min(...points.map((point) => point[axis])));
const max = [0, 1, 2].map((axis) => Math.max(...points.map((point) => point[axis])));
supportComponents.push({
vertices,
min,
max,
center: [(min[0] + max[0]) / 2, max[1], (min[2] + max[2]) / 2],
});
}
const expectedAnchorApprox = [
(sourceAnchor[0] - primary.translation[0]) * recipe.coordinateSystem.metersPerSourceUnit,
(sourceAnchor[1] - primary.translation[1]) * recipe.coordinateSystem.metersPerSourceUnit,
(sourceAnchor[2] - primary.translation[2]) * recipe.coordinateSystem.metersPerSourceUnit,
];
const pad = supportComponents
.map((component) => ({
...component,
distanceXZ: Math.hypot(component.center[0] - expectedAnchorApprox[0], component.center[2] - expectedAnchorApprox[2]),
}))
.sort((a, b) => a.distanceXZ - b.distanceXZ)[0];
if (!pad || pad.distanceXZ > 0.02) throw new Error(`Could not match the first source spawn anchor to one support pad (nearest ${pad?.distanceXZ ?? "none"}m).`);
const candidateTriangles = [];
for (const node of root.listNodes()) {
if (!node.getMesh() || node === supportNode) continue;
const matrix = Array.from(node.getWorldMatrix());
for (const primitive of node.getMesh().listPrimitives()) {
const positions = primitive.getAttribute("POSITION");
const indices = primitive.getIndices()?.getArray();
if (!positions || !indices) continue;
const uv = primitive.getAttribute("TEXCOORD_0");
const material = primitive.getMaterial()?.getName() ?? "unassigned";
for (let index = 0; index < indices.length; index += 3) {
const triangleIndices = [indices[index], indices[index + 1], indices[index + 2]];
const [a, b, c] = triangleIndices.map((vertex) => xyz(matrix, positions.getElement(vertex, [0, 0, 0])));
const normal = normalize(cross(subtract(b, a), subtract(c, a)));
if (normal[1] < 0.75) continue;
if (Math.max(a[0], b[0], c[0]) < pad.min[0] || Math.min(a[0], b[0], c[0]) > pad.max[0]
|| Math.max(a[2], b[2], c[2]) < pad.min[2] || Math.min(a[2], b[2], c[2]) > pad.max[2]) continue;
if (Math.min(a[1], b[1], c[1]) > pad.max[1] + 0.001 || Math.max(a[1], b[1], c[1]) < pad.max[1] - 12) continue;
candidateTriangles.push({ node: node.getName(), material, indices: triangleIndices, positions, uv, a, b, c });
}
}
}
function barycentricXZ(x, z, a, b, c) {
const edgeAB = [b[0] - a[0], b[2] - a[2]];
const edgeAC = [c[0] - a[0], c[2] - a[2]];
const point = [x - a[0], z - a[2]];
const d00 = edgeAB[0] ** 2 + edgeAB[1] ** 2;
const d01 = edgeAB[0] * edgeAC[0] + edgeAB[1] * edgeAC[1];
const d11 = edgeAC[0] ** 2 + edgeAC[1] ** 2;
const d20 = point[0] * edgeAB[0] + point[1] * edgeAB[1];
const d21 = point[0] * edgeAC[0] + point[1] * edgeAC[1];
const denominator = d00 * d11 - d01 * d01;
if (Math.abs(denominator) < 1e-12) return null;
const v = (d11 * d20 - d01 * d21) / denominator;
const w = (d00 * d21 - d01 * d20) / denominator;
return [1 - v - w, v, w];
}
const nativeMaterialMap = new Map(sourceReport.materials.map((material) => [material.name, material]));
const sampleHits = [];
for (const dx of [-5.5, -3, 0, 3, 5.5]) {
for (const dz of [-5.5, -3, 0, 3, 5.5]) {
const x = pad.center[0] + dx;
const z = pad.center[2] + dz;
let highest = null;
for (const triangle of candidateTriangles) {
const barycentric = barycentricXZ(x, z, triangle.a, triangle.b, triangle.c);
if (!barycentric || barycentric.some((weight) => weight < -1e-5)) continue;
const y = barycentric[0] * triangle.a[1] + barycentric[1] * triangle.b[1] + barycentric[2] * triangle.c[1];
if (y > pad.max[1] + 0.001 || y < pad.max[1] - 12) continue;
if (highest && y <= highest.y) continue;
let uv = null;
if (triangle.uv) {
const values = triangle.indices.map((index) => triangle.uv.getElement(index, [0, 0]));
uv = [0, 1].map((axis) => values[0][axis] * barycentric[0] + values[1][axis] * barycentric[1] + values[2][axis] * barycentric[2]);
}
highest = {
y,
dropFromSupportTopMeters: pad.max[1] - y,
node: triangle.node,
nativeMaterial: triangle.material,
nativeTexture: nativeMaterialMap.get(triangle.material)?.sourceTexture ?? null,
uv0: uv,
};
}
sampleHits.push({ offsetFromPadCenterMeters: [dx, dz], ...(highest ?? { nativeSurface: null }) });
}
}
const hitSummary = new Map();
for (const hit of sampleHits) {
const name = hit.nativeMaterial ?? "no-native-upward-surface";
const record = hitSummary.get(name) ?? { count: 0, nodeNames: new Set(), texture: hit.nativeTexture ?? null, uv0: [] };
record.count += 1;
if (hit.node) record.nodeNames.add(hit.node);
if (hit.uv0) record.uv0.push(hit.uv0);
hitSummary.set(name, record);
}
const summarizeUv = (values) => values.length ? {
sampleCount: values.length,
min: [0, 1].map((axis) => Math.min(...values.map((uv) => uv[axis]))),
max: [0, 1].map((axis) => Math.max(...values.map((uv) => uv[axis]))),
} : null;
const atlasCandidates = ["station", "building_statue", "convey_ground", "production_center_ground_01", "ground_ceiling"];
const textures = [];
for (const leaf of atlasCandidates) {
const source = sourceReport.materials.find((material) => material.sourceTexture?.toLowerCase().endsWith(`\\${leaf}`));
if (!source) continue;
const stagedName = path.basename(source.stagedTexture);
const stem = path.parse(stagedName).name;
const dds = await readFile(path.join(work, "raw/textures", `${stem}.dds`));
const png = await readFile(path.join(work, "prepared/textures", `${stem}.png`));
const gltfMaterial = root.listMaterials().find((material) => material.getName() === source.name);
const image = Buffer.from(gltfMaterial?.getBaseColorTexture()?.getImage() ?? []);
textures.push({
sourceTexture: source.sourceTexture,
nativeMaterial: source.name,
stagedDds: stagedName,
ddsSha256: sha256(dds),
preparedPngSha256: sha256(png),
gltfImageSha256: sha256(image),
imageSize: pngDimensions(png),
atlasAssessment: {
station: "multi-region atlas: floor-like slate patch plus gold and trim regions",
building_statue: "multi-region statue/architecture atlas, not a full-tile floor",
convey_ground: "512-square repeated 2x2 stone-tile pattern, but the matching ROS surface is remote from this entry pad",
production_center_ground_01: "multi-region architecture/ground atlas",
ground_ceiling: "multi-region tile/ornament/trim atlas",
}[leaf],
});
}
const candidateMaterialNames = new Set(textures.map((texture) => texture.nativeMaterial));
const candidateMeshLocations = [];
for (const node of root.listNodes()) {
if (!node.getMesh()) continue;
const matrix = Array.from(node.getWorldMatrix());
for (const primitive of node.getMesh().listPrimitives()) {
const material = primitive.getMaterial()?.getName();
if (!candidateMaterialNames.has(material)) continue;
const positions = primitive.getAttribute("POSITION");
const indices = primitive.getIndices()?.getArray();
if (!positions || !indices) continue;
const points = Array.from({ length: positions.getCount() }, (_, index) => xyz(matrix, positions.getElement(index, [0, 0, 0])));
const min = [0, 1, 2].map((axis) => Math.min(...points.map((point) => point[axis])));
const max = [0, 1, 2].map((axis) => Math.max(...points.map((point) => point[axis])));
let upwardTriangleCount = 0;
for (let index = 0; index < indices.length; index += 3) {
const [a, b, c] = [indices[index], indices[index + 1], indices[index + 2]].map((vertex) => points[vertex]);
if (normalize(cross(subtract(b, a), subtract(c, a)))[1] > 0.75) upwardTriangleCount += 1;
}
const center = min.map((value, axis) => (value + max[axis]) / 2);
candidateMeshLocations.push({
nativeMaterial: material,
sourceTexture: nativeMaterialMap.get(material)?.sourceTexture ?? null,
node: node.getName(),
primitiveTriangles: indices.length / 3,
upwardTriangleCount,
bounds: { min, max },
center,
entryPadCenterDistanceXZMeters: Math.hypot(center[0] - pad.center[0], center[2] - pad.center[2]),
});
}
}
const point = sourceAnchor;
const secondary = wdbReport.descriptors
.filter((descriptor) => descriptor.guid !== primary.guid)
.map((descriptor) => {
const center = descriptor.worldBounds?.center ?? descriptor.translation;
const dx = center[0] - point[0];
const dz = center[2] - point[2];
const distanceXZ = Math.hypot(dx, dz);
const radius = Number(descriptor.worldBounds?.radius ?? 0);
return {
guid: descriptor.guid,
resource: descriptor.resource,
translation: descriptor.translation,
worldBoundsCenter: center,
worldBoundsRadius: radius,
distanceXZSourceUnits: distanceXZ,
lowerBoundHorizontalGapSourceUnits: Math.max(0, distanceXZ - radius),
};
})
.sort((a, b) => a.distanceXZSourceUnits - b.distanceXZSourceUnits)
.slice(0, 10);
const output = {
schemaVersion: 1,
kind: "aeternal-circle-generated-support-floor-source-triage",
generatedFrom: {
recipe: "scripts/runewaker-pipeline/recipes/aeternal-circle.json",
recipeWdbPolicy: recipe.review.wdbPlacements,
canonicalVisual: "runewaker-export-work/aeternal-circle/converted/aeternal-circle-visual.glb",
sourceModel: metadata.source.model,
sourceModelSha256: metadata.source.modelSha256,
sourceWdb: metadata.source.wdb,
sourceWdbSha256: metadata.source.wdbSha256,
sourceWdbDescriptorCount: wdbReport.descriptorCount,
recipeSupportAnchorCount: recipe.source.sourceAnchorSupport.points.length,
convertedSupportPrimitive: {
node: supportNode.getName(),
material: supportPrimitive.getMaterial()?.getName(),
vertices: supportPosition.getCount(),
triangles: supportIndices.length / 3,
disconnectedPadCount: supportComponents.length,
},
},
entryPad: {
sourceAnchor: point,
matchedWdbPrimary: {
guid: primary.guid,
translation: primary.translation,
rotation: primary.rotation,
scale: primary.scale,
},
actualConvertedSupportPad: {
worldCenterXZ: [pad.center[0], pad.center[2]],
topY: pad.max[1],
bounds: { min: pad.min, max: pad.max },
sideLengthMeters: [pad.max[0] - pad.min[0], pad.max[2] - pad.min[2]],
thicknessMeters: pad.max[1] - pad.min[1],
topSupportMeshTriangles: supportIndices.length / 3,
},
nativeUnderlyingSurfaceProbe: {
gridOffsetsMeters: [-5.5, -3, 0, 3, 5.5],
upwardNormalYThreshold: 0.75,
maximumBelowSupportTopMeters: 12,
sampleCount: sampleHits.length,
highestNativeSurfaceByMaterial: [...hitSummary.entries()].map(([nativeMaterial, record]) => ({
nativeMaterial,
sourceTexture: record.texture,
sampleCount: record.count,
nodes: [...record.nodeNames],
sampleUv0Bounds: summarizeUv(record.uv0),
dropRangeMeters: sampleHits.filter((hit) => hit.nativeMaterial === nativeMaterial)
.reduce((range, hit) => [Math.min(range[0], hit.dropFromSupportTopMeters), Math.max(range[1], hit.dropFromSupportTopMeters)], [Infinity, -Infinity]),
})),
samples: sampleHits,
interpretation: "Underlying upward-facing triangles vary between station and statue atlas meshes and do not form one continuous, same-height native floor across the generated support pad.",
},
},
candidateTextures: textures,
candidateTextureMeshLocations: candidateMeshLocations,
visibleReference: "artifacts/playtest/rom-environment-quality/aeternal-circle-view-0.png",
nearbyWdbPlacements: {
entriesAreMeasuredFrom: "first Aeternal support/source spawn anchor in native source units",
nearestSecondaryByBoundsCenter: secondary,
resourceNamesContainingGroundFloorTerrain: wdbReport.descriptors
.filter((descriptor) => /ground|floor|terrain/i.test(descriptor.resource))
.map((descriptor) => descriptor.resource),
interpretation: "No secondary WDB descriptor is explicitly named as floor/ground/terrain; nearest secondary geometry centers are tens of meters from the entry. This does not exclude ground surfaces embedded in large tower ROS resources or prove their placements irrelevant.",
},
recommendation: {
safeTextureFallbackProven: false,
nativeFloorRecoveryProven: false,
reason: "The adjacent native surfaces use multi-region station/statue atlases with different heights and UV islands. convey_ground is a visually repeatable tile but its matching primary-ROS mesh is over 200 m from the entry pad, so the evidence does not spatially justify borrowing it.",
nextEvidenceNeeded: "Review/export full secondary WDB placements and inspect walkable source triangles for a continuous native floor surface; only after that can a nearby repeatable source tile or native floor recovery be justified.",
},
};
await mkdir(inspection, { recursive: true });
const reportFile = path.join(inspection, "aeternal-derived-support-ground-triage.json");
await writeFile(reportFile, `${JSON.stringify(output, null, 2)}\n`, "utf8");
console.log(JSON.stringify({ report: path.relative(projectRoot, reportFile), sourceModelSha256: metadata.source.modelSha256, sourceWdbSha256: metadata.source.wdbSha256, supportPads: supportComponents.length, sampleHits: Object.fromEntries([...hitSummary].map(([name, record]) => [name, record.count])) }, null, 2));