import { mkdir, readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { Document, NodeIO } from "@gltf-transform/core"; import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; import { BufferAttribute, BufferGeometry, Vector3 } from "three"; import { MeshBVH } from "three-mesh-bvh"; import { bakeNavigationFromCollisionAssets } from "../dungeon-pipeline/recast-bake.mjs"; 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 wdbReport = JSON.parse(await readFile(path.join(work, "raw/wdb-report.json"), "utf8")); const sourceFile = path.join(work, "converted/aeternal-circle-visual.glb"); const cropFile = path.join(inspection, "aeternal-native-entry-roi.glb"); const navFile = path.join(inspection, "aeternal-native-entry-roi-navigation.glb"); const navReportFile = path.join(inspection, "aeternal-native-entry-roi-recast-report.json"); const outputFile = path.join(inspection, "aeternal-native-entry-roi-probe.json"); const primary = wdbReport.descriptors.find((descriptor) => ( descriptor.resource.replaceAll("/", "\\").toLowerCase() === recipe.source.model.replaceAll("/", "\\").toLowerCase() && descriptor.translation.every((value, index) => Math.abs(value - recipe.source.primaryPlacementTranslation[index]) <= 0.0001) )); if (!primary) throw new Error("Cannot resolve the recipe's primary WDB placement."); const sourceAnchor = recipe.source.sourceAnchorSupport.points[0].map(Number); const scale = recipe.coordinateSystem.metersPerSourceUnit; const entry = [ (sourceAnchor[0] - primary.translation[0]) * scale, (sourceAnchor[1] - primary.translation[1]) * scale, -(sourceAnchor[2] - primary.translation[2]) * scale, ]; const roi = { halfExtent: 28, minY: entry[1] - 5, maxY: entry[1] + 2 }; const transformPoint = (matrix, p) => [ matrix[0] * p[0] + matrix[4] * p[1] + matrix[8] * p[2] + matrix[12], matrix[1] * p[0] + matrix[5] * p[1] + matrix[9] * p[2] + matrix[13], matrix[2] * p[0] + matrix[6] * p[1] + matrix[10] * p[2] + matrix[14], ]; const inputDocument = await new NodeIO().registerExtensions(ALL_EXTENSIONS).read(sourceFile); const allPoints = []; let retainedTriangles = 0; let skippedSupportTriangles = 0; for (const node of inputDocument.getRoot().listNodes()) { if (!node.getMesh()) continue; if (node.getName() === "runewaker_source_anchor_support") { skippedSupportTriangles += node.getMesh().listPrimitives().reduce((sum, primitive) => ( sum + (primitive.getIndices()?.getCount() ?? 0) / 3 ), 0); continue; } const matrix = Array.from(node.getWorldMatrix()); for (const primitive of node.getMesh().listPrimitives()) { const position = primitive.getAttribute("POSITION"); const array = primitive.getIndices()?.getArray(); if (!position || !array) continue; for (let offset = 0; offset < array.length; offset += 3) { const triangle = [array[offset], array[offset + 1], array[offset + 2]] .map((index) => transformPoint(matrix, position.getElement(index, [0, 0, 0]))); const min = [0, 1, 2].map((axis) => Math.min(...triangle.map((point) => point[axis]))); const max = [0, 1, 2].map((axis) => Math.max(...triangle.map((point) => point[axis]))); if (max[0] < entry[0] - roi.halfExtent || min[0] > entry[0] + roi.halfExtent || max[2] < entry[2] - roi.halfExtent || min[2] > entry[2] + roi.halfExtent || max[1] < roi.minY || min[1] > roi.maxY) continue; allPoints.push(...triangle.flat()); retainedTriangles += 1; } } } if (!retainedTriangles) throw new Error("No source-native triangles intersect the Aeternal entry ROI."); const cropDocument = new Document(); const buffer = cropDocument.createBuffer("aeternal-native-entry-roi-buffer"); const positions = cropDocument.createAccessor("native-source-positions", buffer) .setType("VEC3").setArray(new Float32Array(allPoints)); const primitive = cropDocument.createPrimitive().setAttribute("POSITION", positions); const mesh = cropDocument.createMesh("aeternal-native-entry-roi").addPrimitive(primitive); cropDocument.createScene("aeternal-native-entry-roi-scene") .addChild(cropDocument.createNode("native-primary-ros-roi").setMesh(mesh)); await mkdir(inspection, { recursive: true }); await new NodeIO().registerExtensions(ALL_EXTENSIONS).write(cropFile, cropDocument); const navReport = await bakeNavigationFromCollisionAssets({ slug: "aeternal-native-entry-roi-inspection", collisionAssets: [{ id: "native-entry-roi", role: "collision", path: path.relative(projectRoot, cropFile).replaceAll("\\", "/") }], settings: { ...recipe.navigation, partition: "monotone", retainTileIntermediates: false }, output: navFile, reportFile: navReportFile, source: { kind: "inspection-only-native-primary-ROS-entry-ROI", canonicalVisual: path.relative(projectRoot, sourceFile).replaceAll("\\", "/"), excludedGeneratedSupportNode: "runewaker_source_anchor_support", excludedSupportTriangles: skippedSupportTriangles, bounds: roi, }, }); const navDocument = await new NodeIO().registerExtensions(ALL_EXTENSIONS).read(navFile); const navNode = navDocument.getRoot().listNodes().find((node) => node.getName() === "navigation"); if (!navNode?.getMesh()) throw new Error("Local native-only Recast output has no navigation node."); const navTrianglePositions = []; for (const navPrimitive of navNode.getMesh().listPrimitives()) { const position = navPrimitive.getAttribute("POSITION"); const indices = navPrimitive.getIndices()?.getArray(); if (!position || !indices) continue; const matrix = Array.from(navNode.getWorldMatrix()); for (let offset = 0; offset < indices.length; offset += 3) { navTrianglePositions.push([indices[offset], indices[offset + 1], indices[offset + 2]].map((index) => { const p = position.getElement(index, [0, 0, 0]); return transformPoint(matrix, p); })); } } const navGeometry = new BufferGeometry(); navGeometry.setAttribute("position", new BufferAttribute(new Float32Array(navTrianglePositions.flat(2)), 3)); const bvh = new MeshBVH(navGeometry); const pointKey = (point) => point.map((value) => Math.round(value * 10000)).join(","); const edgeKey = (a, b) => [pointKey(a), pointKey(b)].sort().join("|"); const edgeUsers = new Map(); for (const [triangleIndex, triangle] of navTrianglePositions.entries()) { for (const [a, b] of [[0, 1], [1, 2], [2, 0]]) { const key = edgeKey(triangle[a], triangle[b]); const users = edgeUsers.get(key) ?? []; users.push(triangleIndex); edgeUsers.set(key, users); } } const adjacency = Array.from({ length: navTrianglePositions.length }, () => new Set()); for (const users of edgeUsers.values()) { for (const from of users) for (const to of users) if (from !== to) adjacency[from].add(to); } const componentForTriangle = new Int32Array(navTrianglePositions.length).fill(-1); const components = []; for (let start = 0; start < navTrianglePositions.length; start += 1) { if (componentForTriangle[start] !== -1) continue; const id = components.length; const queue = [start]; componentForTriangle[start] = id; for (let cursor = 0; cursor < queue.length; cursor += 1) { for (const next of adjacency[queue[cursor]]) { if (componentForTriangle[next] !== -1) continue; componentForTriangle[next] = id; queue.push(next); } } const points = queue.flatMap((triangleIndex) => navTrianglePositions[triangleIndex]); components.push({ id, triangleCount: queue.length, bounds: { min: [0, 1, 2].map((axis) => Math.min(...points.map((point) => point[axis]))), max: [0, 1, 2].map((axis) => Math.max(...points.map((point) => point[axis]))), }, }); } const projections = []; for (let dx = -24; dx <= 24; dx += 4) for (let dz = -24; dz <= 24; dz += 4) { const target = new Vector3(entry[0] + dx, entry[1], entry[2] + dz); const closest = bvh.closestPointToPoint(target); if (!closest) continue; const triangle = navTrianglePositions[closest.faceIndex]; const a = triangle[1].map((value, index) => value - triangle[0][index]); const b = triangle[2].map((value, index) => value - triangle[0][index]); const normal = [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 length = Math.hypot(...normal) || 1; projections.push({ offsetMeters: [dx, dz], point: closest.point.toArray().map((value) => Number(value.toFixed(5))), distanceMeters: Number(closest.distance.toFixed(5)), upwardNormalY: Number((normal[1] / length).toFixed(5)), triangleIndex: closest.faceIndex, componentId: componentForTriangle[closest.faceIndex], }); } const entryProjection = projections.find((projection) => projection.offsetMeters[0] === 0 && projection.offsetMeters[1] === 0) ?? null; const report = { schemaVersion: 1, kind: "aeternal-circle-native-entry-roi-navigation-probe", input: { canonicalVisual: path.relative(projectRoot, sourceFile).replaceAll("\\", "/"), diagnosticCrop: path.relative(projectRoot, cropFile).replaceAll("\\", "/"), supportNodeExcluded: "runewaker_source_anchor_support", supportTrianglesExcluded: skippedSupportTriangles, entryRuntimeLocal: entry, roi: { halfExtentMeters: roi.halfExtent, minY: roi.minY, maxY: roi.maxY }, retainedNativeTriangles: retainedTriangles, }, nativeOnlyRecast: { geometry: navReport.geometry, build: navReport.build, settings: navReport.settings, triangleCount: navTrianglePositions.length, componentCount: components.length, largestComponents: components.slice().sort((a, b) => b.triangleCount - a.triangleCount).slice(0, 10), entryConnectedComponent: entryProjection ? components[entryProjection.componentId] : null, projections, entryProjection, }, interpretation: { localNativeWalkSurfaceFound: Boolean(entryProjection && entryProjection.distanceMeters <= 0.5 && entryProjection.upwardNormalY >= 0.75), entryProjectionBelongsToConnectedComponent: Boolean(entryProjection && components[entryProjection.componentId]?.triangleCount > 1), entryProjectionMaxLocalDistanceMeters: 0.5, minimumUpwardNormalY: 0.75, recommendation: "The source primary ROS supplies a locally connected walk surface at the entry anchor without the generated support mesh. Remove only this entry's generated pad after checking the shipped coordinate correction; keep the remaining pads pending per-anchor native-route evidence.", limitation: "The diagnostic crops only the primary ROS to a 56m entry ROI. It does not certify complete dungeon traversal or assess secondary WDB placements.", }, caveat: "This inspection-only 56m ROI tests native primary-ROS Recast support. It excludes all generated source-anchor support and all secondary WDB placements; it cannot establish a full-dungeon route by itself.", }; await (await import("node:fs/promises")).writeFile(outputFile, `${JSON.stringify(report, null, 2)}\n`, "utf8"); navGeometry.dispose(); console.log(JSON.stringify({ report: path.relative(projectRoot, outputFile).replaceAll("\\", "/"), entry, retainedNativeTriangles: retainedTriangles, navTriangles: navTrianglePositions.length, gridCells: navReport.build.gridCells, entryProjection: report.nativeOnlyRecast.entryProjection, }, null, 2));