342 lines
17 KiB
JavaScript
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));
|