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 visualFile = path.join(work, "converted/aeternal-circle-visual.glb"); const recipeFile = path.join(projectRoot, "scripts/runewaker-pipeline/recipes/aeternal-circle.json"); const wdbFile = path.join(work, "raw/wdb-report.json"); const reportFile = path.join(inspection, "aeternal-entry-native-ground-probe.json"); const existingTriageFile = path.join(inspection, "aeternal-derived-support-ground-triage.json"); const recipe = JSON.parse(await readFile(recipeFile, "utf8")); const wdbReport = JSON.parse(await readFile(wdbFile, "utf8")); const existingTriage = JSON.parse(await readFile(existingTriageFile, "utf8")); const visualBytes = await readFile(visualFile); const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); 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 meters = recipe.coordinateSystem.metersPerSourceUnit; const runtimeAnchor = [ (sourceAnchor[0] - primary.translation[0]) * meters, (sourceAnchor[1] - primary.translation[1]) * meters, -(sourceAnchor[2] - primary.translation[2]) * meters, ]; const reflectedWriterPosition = [runtimeAnchor[0], runtimeAnchor[1], -runtimeAnchor[2]]; const matrixPoint = (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 sub = (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 = (v) => { const length = Math.hypot(...v); return length ? v.map((value) => value / length) : [0, 0, 0]; }; const barycentricXZ = (x, z, a, b, c) => { const ab = [b[0] - a[0], b[2] - a[2]]; const ac = [c[0] - a[0], c[2] - a[2]]; const p = [x - a[0], z - a[2]]; const d00 = ab[0] * ab[0] + ab[1] * ab[1]; const d01 = ab[0] * ac[0] + ab[1] * ac[1]; const d11 = ac[0] * ac[0] + ac[1] * ac[1]; const d20 = p[0] * ab[0] + p[1] * ab[1]; const d21 = p[0] * ac[0] + p[1] * ac[1]; const determinant = d00 * d11 - d01 * d01; if (Math.abs(determinant) < 1e-12) return null; const v = (d11 * d20 - d01 * d21) / determinant; const w = (d00 * d21 - d01 * d20) / determinant; const weights = [1 - v - w, v, w]; return weights.some((weight) => weight < -1e-6) ? null : weights; }; const document = await new NodeIO().registerExtensions(ALL_EXTENSIONS).read(visualFile); const sourceReport = JSON.parse(await readFile(path.join(work, "prepared/export-report.json"), "utf8")); const sourceMaterials = new Map(sourceReport.materials.map((material) => [material.name, material])); const triangles = []; for (const node of document.getRoot().listNodes()) { if (!node.getMesh() || node.getName() === "runewaker_source_anchor_support") continue; const matrix = Array.from(node.getWorldMatrix()); for (const primitive of node.getMesh().listPrimitives()) { const positions = primitive.getAttribute("POSITION"); const indexArray = primitive.getIndices()?.getArray(); if (!positions || !indexArray) continue; const uvs = primitive.getAttribute("TEXCOORD_0"); const materialName = primitive.getMaterial()?.getName() ?? "unassigned"; const material = sourceMaterials.get(materialName); for (let offset = 0; offset < indexArray.length; offset += 3) { const indices = [indexArray[offset], indexArray[offset + 1], indexArray[offset + 2]]; const points = indices.map((index) => matrixPoint(matrix, positions.getElement(index, [0, 0, 0]))); const normal = normalize(cross(sub(points[1], points[0]), sub(points[2], points[0]))); if (normal[1] < 0.75) continue; if (Math.max(...points.map((point) => Math.abs(point[0] - runtimeAnchor[0]))) > 8 || Math.max(...points.map((point) => Math.abs(point[2] - runtimeAnchor[2]))) > 8) continue; triangles.push({ node: node.getName(), materialName, sourceTexture: material?.sourceTexture ?? null, points, normal, indices, uvs, uvValues: uvs ? indices.map((index) => uvs.getElement(index, [0, 0])) : null, }); } } } const offsets = [-5.5, -3, 0, 3, 5.5]; const queryGrid = (center, label) => { const hits = []; for (const dx of offsets) for (const dz of offsets) { const x = center[0] + dx; const z = center[2] + dz; let highest = null; for (const triangle of triangles) { const weights = barycentricXZ(x, z, ...triangle.points); if (!weights) continue; const y = weights.reduce((sum, weight, index) => sum + weight * triangle.points[index][1], 0); if (y > center[1] + 0.25 || y < center[1] - 12) continue; if (highest && y <= highest.point[1]) continue; const uv0 = triangle.uvValues?.[0].map((_, axis) => weights.reduce((sum, weight, index) => sum + weight * triangle.uvValues[index][axis], 0)) ?? null; highest = { node: triangle.node, material: triangle.materialName, sourceTexture: triangle.sourceTexture, point: [x, y, z].map((value) => Number(value.toFixed(6))), dropFromAnchorMeters: Number((center[1] - y).toFixed(6)), normal: triangle.normal.map((value) => Number(value.toFixed(6))), uv0: uv0?.map((value) => Number(value.toFixed(6))) ?? null, }; } hits.push({ offsetMeters: [dx, dz], hit: highest }); } const bySurface = new Map(); for (const { hit } of hits) { const key = hit ? `${hit.material}|${hit.sourceTexture}` : "no-upward-surface"; bySurface.set(key, (bySurface.get(key) ?? 0) + 1); } return { label, center, sampleCount: hits.length, hitSurfaceCounts: Object.fromEntries(bySurface), hits }; }; const originalPadCenter = reflectedWriterPosition; const correctedPadCenter = runtimeAnchor; const halfExtent = Number(recipe.source.sourceAnchorSupport.halfExtentSourceUnits ?? 60) * meters; const primaryDescriptor = primary; const secondaryAnchors = recipe.source.sourceAnchorSupport.points.map((point, index) => { const local = [ (Number(point[0]) - primary.translation[0]) * meters, (Number(point[1]) - primary.translation[1]) * meters, -(Number(point[2]) - primary.translation[2]) * meters, ]; const nearest = wdbReport.descriptors .filter((descriptor) => descriptor !== primaryDescriptor) .map((descriptor) => { const center = descriptor.worldBounds?.center ?? descriptor.worldBoundsCenter ?? descriptor.translation; const radius = Number(descriptor.worldBounds?.radius ?? descriptor.worldBoundsRadius ?? descriptor.localBounds?.radius ?? 0); const distanceXZ = Math.hypot(Number(point[0]) - center[0], Number(point[2]) - center[2]); return { guid: descriptor.guid, resource: descriptor.resource, distanceXZSourceUnits: Number(distanceXZ.toFixed(3)), worldBoundsRadiusSourceUnits: radius, sphereLowerBoundGapSourceUnits: Number((distanceXZ - radius).toFixed(3)), }; }) .sort((a, b) => a.distanceXZSourceUnits - b.distanceXZSourceUnits || a.sphereLowerBoundGapSourceUnits - b.sphereLowerBoundGapSourceUnits) .slice(0, 3); return { index, sourcePosition: point, runtimeLocal: local.map((value) => Number(value.toFixed(4))), nearestSecondaryDescriptors: nearest }; }); const nearSecondaryResourceCounts = new Map(); for (const anchor of secondaryAnchors) { for (const descriptor of anchor.nearestSecondaryDescriptors.slice(0, 1)) { nearSecondaryResourceCounts.set(descriptor.resource, (nearSecondaryResourceCounts.get(descriptor.resource) ?? 0) + 1); } } const report = { schemaVersion: 1, kind: "aeternal-circle-entry-native-ground-probe", inputs: { recipe: path.relative(projectRoot, recipeFile).replaceAll("\\", "/"), sourceRos: recipe.source.model, sourceRosSha256: existingTriage.generatedFrom.sourceModelSha256, sourceWdbSha256: existingTriage.generatedFrom.sourceWdbSha256, canonicalVisual: path.relative(projectRoot, visualFile).replaceAll("\\", "/"), canonicalVisualSha256: sha256(visualBytes), exporterSource: "scripts/runewaker-pipeline/native/runewaker_model_exporter.cpp (ROS vertex Z reflected before OBJ emission)", supportWriter: "scripts/runewaker-pipeline/build-environment.mjs::appendSourceAnchorSupport (writes primary-local source coordinates without the OBJ Z reflection)", }, placement: { sourceAnchor, primaryTranslation: primary.translation, runtimeLocalAnchor: runtimeAnchor, observedCurrentVisualPadCenter: originalPadCenter, proposedCorrectedPadCenter: correctedPadCenter, supportHalfExtentMeters: halfExtent, proposedCorrectedVisualTopY: runtimeAnchor[1], proposedCorrectedWorldBounds: { min: [correctedPadCenter[0] - halfExtent, correctedPadCenter[1] - 0.3, correctedPadCenter[2] - halfExtent], max: [correctedPadCenter[0] + halfExtent, correctedPadCenter[1], correctedPadCenter[2] + halfExtent], }, }, nativeSurfaceProbe: { queryYRangeMeters: [runtimeAnchor[1] - 12, runtimeAnchor[1] + 0.25], upwardNormalYThreshold: 0.75, nativeTrianglesConsidered: triangles.length, atRuntimeSpawn: queryGrid(runtimeAnchor, "runtime spawn footprint"), atErroneousReflectedPad: queryGrid(originalPadCenter, "current reflected visual pad footprint"), }, secondaryWdbProximity: { sourceAnchorCount: secondaryAnchors.length, method: "For each NPCData/source support anchor, rank the nearest non-primary WDB descriptors by horizontal distance to descriptor world-bounds center. A bounds-sphere gap is recorded only as a coarse candidate hint; it does not prove a floor surface or vertical intersection.", nearestSecondaryResourceCounts: [...nearSecondaryResourceCounts].sort((a, b) => b[1] - a[1]).slice(0, 20).map(([resource, anchorCount]) => ({ resource, anchorCount })), anchorsWithSecondaryBoundsCenterWithin20m: secondaryAnchors.filter((anchor) => anchor.nearestSecondaryDescriptors[0]?.distanceXZSourceUnits <= 200).length, anchors: secondaryAnchors, groundFloorTerrainResourceCount: wdbReport.descriptors.filter((descriptor) => /(?:ground|floor|terrain)/i.test(descriptor.resource)).length, }, recommendation: { nativeEntryLandingFound: true, surface: "room_a_outside_statue_39_mesh_0 / runewaker_material_39 / texture\\dungeon\\wizards_haunt\\building_statue", interpretation: "The native up-facing surface under the runtime anchor samples the stone-cobble inset in the building_statue atlas, consistent with a textured landing/plinth top rather than a blank derived pad. The local native-only Recast test is in aeternal-native-entry-roi-probe.json.", removeOnlyEntryDerivedPadCandidate: true, limitation: "This is a local entry-footprint finding. It does not certify a complete path beyond the cropped ROI or justify removing support at the other 151 anchors.", safeTextureFallbackProven: false, }, }; await mkdir(inspection, { recursive: true }); await writeFile(reportFile, `${JSON.stringify(report, null, 2)}\n`, "utf8"); console.log(JSON.stringify({ report: path.relative(projectRoot, reportFile).replaceAll("\\", "/"), currentPadCenter: originalPadCenter, correctedPadCenter, nativeTrianglesConsidered: triangles.length, spawnSurfaceCounts: report.nativeSurfaceProbe.atRuntimeSpawn.hitSurfaceCounts, reflectedSurfaceCounts: report.nativeSurfaceProbe.atErroneousReflectedPad.hitSurfaceCounts, }, null, 2));