import { createHash } from "node:crypto"; import { 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"; import { Matrix4, Vector3 } from "three"; import { sourceAnchorSupportGeometry } from "./lib/source-anchor-support.mjs"; const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const workRoot = path.join(projectRoot, "runewaker-export-work"); const slug = process.argv[2] ?? "aeternal-circle"; const stageDungeon = path.join(workRoot, "source-anchor-support-correction-staged-v2", slug); const io = new NodeIO().registerExtensions(ALL_EXTENSIONS); const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); const relative = (file) => path.relative(projectRoot, file).replaceAll("\\", "/"); function includePoint(bounds, point) { for (let axis = 0; axis < 3; axis += 1) { bounds.minimum[axis] = Math.min(bounds.minimum[axis], point[axis]); bounds.maximum[axis] = Math.max(bounds.maximum[axis], point[axis]); } } function newBounds() { return { minimum: [Infinity, Infinity, Infinity], maximum: [-Infinity, -Infinity, -Infinity] }; } function summarizeCollision(document) { const global = newBounds(); const nativeBounds = newBounds(); let totalTriangles = 0; let totalVertices = 0; let degenerateTriangles = 0; let invalidIndices = 0; let nonFiniteCoordinates = 0; const meshes = []; const padCenters = []; const edgeAb = new Vector3(); const edgeAc = new Vector3(); const normal = new Vector3(); for (const node of document.getRoot().listNodes()) { if (!node.getMesh()) continue; const support = node.getExtras()?.sourceChunk === "runewaker_source_anchor_support"; const nodeMatrix = new Matrix4().fromArray(Array.from(node.getWorldMatrix())); let nodeTriangles = 0; let nodeVertices = 0; let nodeDegenerates = 0; const nodeBounds = newBounds(); for (const primitive of node.getMesh().listPrimitives()) { const position = primitive.getAttribute("POSITION"); const indices = primitive.getIndices(); if (!position) continue; const positions = position.getArray(); const elements = indices?.getArray() ?? Array.from({ length: position.getCount() }, (_, index) => index); nodeVertices += position.getCount(); totalVertices += position.getCount(); nodeTriangles += elements.length / 3; totalTriangles += elements.length / 3; for (let vertex = 0; vertex < position.getCount(); vertex += 1) { const point = new Vector3().fromArray(positions, vertex * 3).applyMatrix4(nodeMatrix).toArray(); if (!point.every(Number.isFinite)) { nonFiniteCoordinates += 1; continue; } includePoint(global, point); includePoint(nodeBounds, point); if (!support) includePoint(nativeBounds, point); } for (let element = 0; element < elements.length; element += 3) { const triangle = [elements[element], elements[element + 1], elements[element + 2]]; if (triangle.some((index) => !Number.isInteger(index) || index < 0 || index >= position.getCount())) { invalidIndices += 1; continue; } const a = new Vector3().fromArray(positions, triangle[0] * 3).applyMatrix4(nodeMatrix); const b = new Vector3().fromArray(positions, triangle[1] * 3).applyMatrix4(nodeMatrix); const c = new Vector3().fromArray(positions, triangle[2] * 3).applyMatrix4(nodeMatrix); if (normal.crossVectors(edgeAb.subVectors(b, a), edgeAc.subVectors(c, a)).lengthSq() <= 1e-16) { degenerateTriangles += 1; nodeDegenerates += 1; } } } if (support) { const primitive = node.getMesh().listPrimitives()[0]; const position = primitive.getAttribute("POSITION"); const values = position.getArray(); for (let offset = 0, pointIndex = 0; offset < position.getCount(); offset += 8, pointIndex += 1) { const top = []; for (let vertex = 0; vertex < 4; vertex += 1) { top.push(new Vector3().fromArray(values, (offset + vertex) * 3).applyMatrix4(nodeMatrix).toArray()); } const center = [0, 1, 2].map((axis) => top.reduce((sum, point) => sum + point[axis], 0) / top.length); padCenters.push({ pointIndex, center }); } } meshes.push({ node: node.getName(), sourceChunk: node.getExtras()?.sourceChunk ?? null, generatedSupport: support, vertices: nodeVertices, triangles: nodeTriangles, degenerateTriangles: nodeDegenerates, bounds: nodeBounds, spans: nodeBounds.minimum.map((value, axis) => nodeBounds.maximum[axis] - value), }); } return { totals: { vertices: totalVertices, triangles: totalTriangles, degenerateTriangles, invalidIndices, nonFiniteCoordinates }, bounds: global, nativeOnlyBounds: nativeBounds, supportPadCount: padCenters.length, supportCenters: padCenters, largestMeshes: meshes.slice().sort((left, right) => right.triangles - left.triangles).slice(0, 12), widestMeshes: meshes.slice().sort((left, right) => Math.max(...right.spans) - Math.max(...left.spans)).slice(0, 12), meshes, }; } const recipe = JSON.parse(await readFile(path.join(projectRoot, "scripts/runewaker-pipeline/recipes", `${slug}.json`), "utf8")); const stageReport = JSON.parse(await readFile(path.join(stageDungeon, "staging-report.json"), "utf8")); const originalFile = path.join(workRoot, slug, "converted", stageReport.staged.collision[0].source); const correctedFile = path.join(stageDungeon, stageReport.staged.collision[0].staged); const [original, corrected] = await Promise.all([io.read(originalFile), io.read(correctedFile)]); const originalSummary = summarizeCollision(original); const correctedSummary = summarizeCollision(corrected); const oldReport = JSON.parse(await readFile(path.join(workRoot, slug, "converted/recast-report.json"), "utf8")); const sourceBounds = oldReport.input.bounds; const sourceGrid = oldReport.build; const cellSize = Number(recipe.navigation.cellSize); const correctedWidth = Math.ceil((correctedSummary.bounds.maximum[0] - correctedSummary.bounds.minimum[0]) / cellSize); const correctedHeight = Math.ceil((correctedSummary.bounds.maximum[2] - correctedSummary.bounds.minimum[2]) / cellSize); const pointGeometry = sourceAnchorSupportGeometry(recipe.source.sourceAnchorSupport.points, stageReport.source.primaryPlacement, { halfExtent: Number(stageReport.source.halfExtentSourceUnits), thickness: Number(stageReport.source.thicknessSourceUnits), metersPerSourceUnit: Number(recipe.coordinateSystem.metersPerSourceUnit), omitPointIndices: stageReport.source.omittedNativeGroundedPointIndices, }); const expectedPads = newBounds(); for (let offset = 0; offset < pointGeometry.positions.length; offset += 3) { includePoint(expectedPads, pointGeometry.positions.slice(offset, offset + 3)); } const report = { schemaVersion: 1, slug, source: { collisionBeforeCorrection: relative(originalFile), collisionBeforeCorrectionSha256: stageReport.source.originalCollisionFiles[0].sha256, correctedCollision: relative(correctedFile), correctedCollisionSha256: sha256(await readFile(correctedFile)), oldRecastInputBounds: sourceBounds, oldRecastGrid: sourceGrid, collisionTrianglesInOldRecastReport: oldReport.input.triangleCount, recipeCellSize: cellSize, expectedRetainedSupportBounds: expectedPads, expectedRetainedSupportTriangles: pointGeometry.indices.length / 3, expectedSupportNavigationTriangles: pointGeometry.navigationPositions.length / 9, }, original: originalSummary, corrected: correctedSummary, diff: { triangleDelta: correctedSummary.totals.triangles - originalSummary.totals.triangles, nativeBoundsEqual: JSON.stringify(originalSummary.nativeOnlyBounds) === JSON.stringify(correctedSummary.nativeOnlyBounds), correctedBounds: correctedSummary.bounds, nativeOnlyBounds: correctedSummary.nativeOnlyBounds, correctedGridWidthCells: correctedWidth, correctedGridDepthCells: correctedHeight, correctedGridCellCount: correctedWidth * correctedHeight, tilesAtRecipe64: Math.ceil(correctedWidth / 64) * Math.ceil(correctedHeight / 64), tilesAtDerived512: Math.ceil(correctedWidth / 512) * Math.ceil(correctedHeight / 512), tilesAtDerived1024: Math.ceil(correctedWidth / 1024) * Math.ceil(correctedHeight / 1024), allCollisionCoordinatesFinite: correctedSummary.totals.nonFiniteCoordinates === 0, allIndicesValid: correctedSummary.totals.invalidIndices === 0, }, conclusion: { explanation: "Compare global/native-only bounds and mesh scales with old Recast report; unchanged native bounds mean support correction did not create a new world-space envelope. The all-dungeon rebake cost is therefore in rebuilding the broad provisional primary-ROS collision mesh, which copies decorative renderables, rather than the 1,812 support triangles.", sourceAnchorPoint0Omitted: stageReport.source.omittedNativeGroundedPointIndices.includes(0), }, }; const output = path.join(stageDungeon, "nav-rebake-diagnostic.json"); await writeFile(output, `${JSON.stringify(report, null, 2)}\n`, "utf8"); console.log(JSON.stringify({ report: relative(output), original: originalSummary.totals, corrected: correctedSummary.totals, nativeBoundsEqual: report.diff.nativeBoundsEqual, fullBounds: correctedSummary.bounds, nativeOnlyBounds: correctedSummary.nativeOnlyBounds, gridCells: report.diff.correctedGridCellCount, tilesAtRecipe64: report.diff.tilesAtRecipe64, tilesAtDerived512: report.diff.tilesAtDerived512, largestMeshes: correctedSummary.largestMeshes, widestMeshes: correctedSummary.widestMeshes, }, null, 2));