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 supportName = "runewaker_source_anchor_support"; 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 include(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 emptyBounds() { return { minimum: [Infinity, Infinity, Infinity], maximum: [-Infinity, -Infinity, -Infinity] }; } 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 conversion = JSON.parse(await readFile(path.join(workRoot, slug, "converted/conversion-report.json"), "utf8")); const sourceReportPath = path.join(stageDungeon, "native-primary-collision-report.json"); const sourceReport = JSON.parse(await readFile(sourceReportPath, "utf8")); const meters = Number(recipe.coordinateSystem.metersPerSourceUnit); const sourceBounds = sourceReport.nativeCollisionBounds; const expectedNativeBounds = { minimum: [ Number(sourceBounds.min[0]) * meters, Number(sourceBounds.min[1]) * meters, -Number(sourceBounds.max[2]) * meters, ], maximum: [ Number(sourceBounds.max[0]) * meters, Number(sourceBounds.max[1]) * meters, -Number(sourceBounds.min[2]) * meters, ], }; const convertedNativeBounds = emptyBounds(); let convertedNativeTriangles = 0; for (const record of conversion.collision ?? []) { const stagedRecord = stageReport.staged.collision.find((item) => item.source === record.file); const file = path.join(stageDungeon, stagedRecord?.staged ?? record.file); const document = await io.read(file); for (const node of document.getRoot().listNodes()) { if (!node.getMesh() || node.getExtras()?.sourceChunk === supportName) continue; const matrix = new Matrix4().fromArray(Array.from(node.getWorldMatrix())); for (const primitive of node.getMesh().listPrimitives()) { const position = primitive.getAttribute("POSITION"); const indices = primitive.getIndices(); convertedNativeTriangles += (indices?.getCount() ?? position?.getCount() ?? 0) / 3; if (!position) continue; const values = position.getArray(); for (let offset = 0; offset < values.length; offset += 3) { include(convertedNativeBounds, new Vector3().fromArray(values, offset).applyMatrix4(matrix).toArray()); } } } } const placement = stageReport.source.primaryPlacement; const sourcePoints = recipe.source.sourceAnchorSupport.points.map((point) => point.map(Number)); const omitted = new Set(stageReport.source.omittedNativeGroundedPointIndices); const nativeXZ = { minX: convertedNativeBounds.minimum[0], maxX: convertedNativeBounds.maximum[0], minZ: convertedNativeBounds.minimum[2], maxZ: convertedNativeBounds.maximum[2], }; const padRows = []; const supportBounds = emptyBounds(); const coreSupportBounds = emptyBounds(); for (const [sourcePointIndex, sourcePoint] of sourcePoints.entries()) { if (omitted.has(sourcePointIndex)) continue; const geometry = sourceAnchorSupportGeometry([sourcePoint], placement, { halfExtent: Number(stageReport.source.halfExtentSourceUnits), thickness: Number(stageReport.source.thicknessSourceUnits), metersPerSourceUnit: meters, }); const top = []; for (let vertex = 0; vertex < 4; vertex += 1) { top.push(geometry.positions.slice(vertex * 3, vertex * 3 + 3)); } const box = emptyBounds(); for (const point of geometry.positions.reduce((all, value, index) => { if (index % 3 === 0) all.push(geometry.positions.slice(index, index + 3)); return all; }, [])) { include(box, point); include(supportBounds, point); } const overlapsNativeXZ = box.maximum[0] >= nativeXZ.minX && box.minimum[0] <= nativeXZ.maxX && box.maximum[2] >= nativeXZ.minZ && box.minimum[2] <= nativeXZ.maxZ; const yGapM = box.minimum[1] > convertedNativeBounds.maximum[1] ? box.minimum[1] - convertedNativeBounds.maximum[1] : box.maximum[1] < convertedNativeBounds.minimum[1] ? convertedNativeBounds.minimum[1] - box.maximum[1] : 0; if (overlapsNativeXZ) { for (const point of geometry.positions.reduce((all, value, index) => { if (index % 3 === 0) all.push(geometry.positions.slice(index, index + 3)); return all; }, [])) include(coreSupportBounds, point); } padRows.push({ sourcePointIndex, sourcePoint, center: top.reduce((center, point) => center.map((value, axis) => value + point[axis] / top.length), [0, 0, 0]), bounds: box, overlapsNativeXZ, verticalGapToNativeBoundsM: yGapM }); } const coreBounds = emptyBounds(); for (const point of [ [convertedNativeBounds.minimum[0], convertedNativeBounds.minimum[1], convertedNativeBounds.minimum[2]], [convertedNativeBounds.maximum[0], convertedNativeBounds.maximum[1], convertedNativeBounds.maximum[2]], [coreSupportBounds.minimum[0], coreSupportBounds.minimum[1], coreSupportBounds.minimum[2]], [coreSupportBounds.maximum[0], coreSupportBounds.maximum[1], coreSupportBounds.maximum[2]], ]) { if (point.every(Number.isFinite)) include(coreBounds, point); } const gridWidth = Math.ceil((coreBounds.maximum[0] - coreBounds.minimum[0]) / Number(recipe.navigation.cellSize)); const gridDepth = Math.ceil((coreBounds.maximum[2] - coreBounds.minimum[2]) / Number(recipe.navigation.cellSize)); const overlapRows = padRows.filter((row) => row.overlapsNativeXZ); const remoteRows = padRows.filter((row) => !row.overlapsNativeXZ); const report = { schemaVersion: 1, slug, source: { primaryModel: recipe.source.model, primaryModelSha256: stageReport.source.sourceModelSha256, nativeCollisionReport: relative(sourceReportPath), nativeCollisionReportSha256: sha256(await readFile(sourceReportPath)), nativeCollisionTriangles: sourceReport.nativeCollisionTriangleCount, visualFallbackTriangles: sourceReport.renderMeshFallbackTriangleCount, nativeTriangleFlags: sourceReport.nativeTriangleFlagCounts, pathCollisionExclusionMask: sourceReport.pathCollisionPolicy.exclusionMask, convertedCollisionChunks: conversion.collision.map((record) => record.file), }, boundsProof: { convertedCollisionExcludingSupport: convertedNativeBounds, expectedNativeCollisionBoundsAfterUnitsAndZBridge: expectedNativeBounds, comparisonMaximumErrorM: convertedNativeBounds.minimum.map((value, axis) => Math.max( Math.abs(value - expectedNativeBounds.minimum[axis]), Math.abs(convertedNativeBounds.maximum[axis] - expectedNativeBounds.maximum[axis]), )).reduce((max, value) => Math.max(max, value), 0), convertedNativeCollisionTriangles: convertedNativeTriangles, sourceQueryNativeTriangles: sourceReport.nativeCollisionTriangleCount, exactBoundsMatchToleranceM: 0.01, nativeCoreBounds: convertedNativeBounds, supportUnionBounds: supportBounds, nativeCorePlusXZOverlappingSupportBounds: coreBounds, }, selection: { sourceAnchorPointCount: sourcePoints.length, omittedNativeGroundedPointIndices: [...omitted], retainedSupportCount: padRows.length, coreXzOverlapCount: overlapRows.length, isolatedRemoteCount: remoteRows.length, coreSupportSourcePointIndices: overlapRows.map((row) => row.sourcePointIndex), remoteSupportSourcePointIndices: remoteRows.map((row) => row.sourcePointIndex), remoteSupportClearanceM: Number(recipe.navigation.agentRadius ?? 0.36), remoteSupportTopInsetHalfExtentM: Number(stageReport.source.halfExtentSourceUnits) * meters - Number(recipe.navigation.agentRadius ?? 0.36), }, predictedCoreRecastGrid: { cellSize: Number(recipe.navigation.cellSize), widthCells: gridWidth, depthCells: gridDepth, cellCount: gridWidth * gridDepth, recipeTileSize: recipe.navigation.tileSize ?? null, }, padRows, conclusion: "This report only classifies source-grounded collision and support selection. It does not merge/bake any nav surfaces. Before staging, verify the bounds comparison and keep remote supports as separate explicit components with agent-radius inset; no route connectivity is inferred.", }; const output = path.join(stageDungeon, "native-core-support-overlap.json"); await writeFile(output, `${JSON.stringify(report, null, 2)}\n`, "utf8"); console.log(JSON.stringify({ report: relative(output), nativeCollisionTriangles: sourceReport.nativeCollisionTriangleCount, convertedNativeTriangles, boundsErrorM: report.boundsProof.comparisonMaximumErrorM, coreXzOverlapCount: overlapRows.length, isolatedRemoteCount: remoteRows.length, coreBounds, grid: report.predictedCoreRecastGrid, }, null, 2));