Files
healer-man/scripts/runewaker-pipeline/build-native-core-support-candidate.mjs
2026-10-06 22:25:34 -04:00

852 lines
40 KiB
JavaScript

import { createHash } from "node:crypto";
import { spawn } from "node:child_process";
import { access, mkdir, readFile, writeFile } 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 { Matrix4, Vector3 } from "three";
import { bakeNavigationFromCollisionAssets } from "../dungeon-pipeline/recast-bake.mjs";
import { sourceAnchorSupportGeometry } from "./lib/source-anchor-support.mjs";
import { sourceTerrainToThreeMatrix } from "./sand-vortex/terrain-geometry.mjs";
import { buildWdbPlacementPlan } from "./lib/environment-assembly.mjs";
import {
buildNativeCollisionAuditArguments,
validateNativeCoreCandidateProvenance,
} from "./lib/native-core-candidate-provenance.mjs";
import { sourceRootFor, validateEnvironmentRecipe } from "./lib/recipe.mjs";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const workRoot = path.join(projectRoot, "runewaker-export-work");
const argv = process.argv.slice(2);
const slug = argv.find((argument) => !argument.startsWith("--")) ?? "";
const stageOnly = argv.includes("--stage-only");
const respectOptions = argv.includes("--respect-options");
const outputRootArgument = argv.find((argument) => argument.startsWith("--output-root="))?.slice("--output-root=".length);
const outputRoot = outputRootArgument
? path.resolve(projectRoot, outputRootArgument)
: path.join(workRoot, slug, "native-core-candidate-v1");
const nativeReportArgument = argv.find((argument) => argument.startsWith("--native-report="))?.slice("--native-report=".length);
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("\\", "/");
if (!["aeternal-circle", "demon-stronghold", "mystic-altar"].includes(slug)) {
throw new Error(`Unsupported native-core candidate target ${slug}.`);
}
if (!respectOptions) {
throw new Error("Native-core candidates require --respect-options so collision-disabled ROS subtrees cannot enter the bake.");
}
const outputRelativeToSlugWork = path.relative(path.join(workRoot, slug), outputRoot);
if (!outputRelativeToSlugWork || outputRelativeToSlugWork === ".."
|| outputRelativeToSlugWork.startsWith(`..${path.sep}`) || path.isAbsolute(outputRelativeToSlugWork)) {
throw new Error(`--output-root must be a new directory under runewaker-export-work/${slug}.`);
}
function emptyBounds() {
return { minimum: [Infinity, Infinity, Infinity], maximum: [-Infinity, -Infinity, -Infinity] };
}
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 measureTriangleList(positions) {
if (positions.length % 9 !== 0) throw new Error("Triangle-list POSITION data is not divisible into triangles.");
const bounds = emptyBounds();
for (let offset = 0; offset < positions.length; offset += 3) {
const point = [positions[offset], positions[offset + 1], positions[offset + 2]];
if (!point.every(Number.isFinite)) throw new Error("Triangle-list POSITION contains a non-finite coordinate.");
include(bounds, point);
}
return bounds;
}
function directBridgeBounds(nativePositions, meters) {
const bounds = emptyBounds();
for (let offset = 0; offset < nativePositions.length; offset += 3) {
include(bounds, [nativePositions[offset] * meters, nativePositions[offset + 1] * meters, -nativePositions[offset + 2] * meters]);
}
return bounds;
}
function upwardFloorHitsAtXZ(positions, point) {
const hits = [];
for (let offset = 0; offset < positions.length; offset += 9) {
const ax = positions[offset];
const ay = positions[offset + 1];
const az = positions[offset + 2];
const bx = positions[offset + 3];
const by = positions[offset + 4];
const bz = positions[offset + 5];
const cx = positions[offset + 6];
const cy = positions[offset + 7];
const cz = positions[offset + 8];
const denominator = (bz - cz) * (ax - cx) + (cx - bx) * (az - cz);
if (Math.abs(denominator) < 1e-12) continue;
const weightA = ((bz - cz) * (point[0] - cx) + (cx - bx) * (point[2] - cz)) / denominator;
const weightB = ((cz - az) * (point[0] - cx) + (ax - cx) * (point[2] - cz)) / denominator;
const weightC = 1 - weightA - weightB;
if (Math.min(weightA, weightB, weightC) < -1e-6) continue;
const normalY = (bz - az) * (cx - ax) - (bx - ax) * (cz - az);
if (normalY <= 1e-9) continue;
hits.push({
y: weightA * ay + weightB * by + weightC * cy,
normalY: normalY / Math.hypot(
(by - ay) * (cz - az) - (bz - az) * (cy - ay),
normalY,
(bx - ax) * (cy - ay) - (by - ay) * (cx - ax),
),
triangle: offset / 9,
});
}
return hits.sort((left, right) => right.y - left.y || left.triangle - right.triangle);
}
function boundsDifference(left, right) {
let maximum = 0;
for (let axis = 0; axis < 3; axis += 1) {
maximum = Math.max(maximum,
Math.abs(left.minimum[axis] - right.minimum[axis]),
Math.abs(left.maximum[axis] - right.maximum[axis]));
}
return maximum;
}
function triangleListFromIndexedGeometry(geometry) {
const positions = [];
for (const index of geometry.indices) {
const offset = index * 3;
positions.push(geometry.positions[offset], geometry.positions[offset + 1], geometry.positions[offset + 2]);
}
return positions;
}
function runNativeCollisionExtractor(executable, args) {
return new Promise((resolve, reject) => {
const child = spawn(executable, args, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk; });
child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; });
child.once("error", reject);
child.once("close", (code) => {
if (code !== 0) reject(new Error(`Native collision extraction failed (${code}): ${stderr || stdout}`));
else resolve({ stdout, stderr });
});
});
}
function assertStrictNativeQueryReport(report, slug, modelResource) {
if (String(report?.sourceModel ?? "").replaceAll("\\", "/").toLowerCase() !== modelResource.toLowerCase()
|| report.collisionMode !== "respect-options"
|| report.queryMasks?.ignoreEntityCollisionOption !== false
|| report.renderMeshFallbackTriangleCount !== 0
|| !Number.isInteger(report.nativeCollisionTriangleCount)
|| report.nativeCollisionTriangleCount <= 0
|| report.nativeCollisionTriangleCount !== report.enabledNativeCollisionTriangleCount) {
throw new Error(`${slug}: native extractor output is not a complete strict respect-options collision query.`);
}
}
function assertNativeQueryOutputPaths(report, reportPath, objPath, flagsPath) {
if (path.resolve(String(report?.geometryOutput ?? "")) !== path.resolve(objPath)
|| path.resolve(String(report?.triangleFlagOutput ?? "")) !== path.resolve(flagsPath)) {
throw new Error(`Native collision report outputs do not match its cache paths: ${reportPath}`);
}
}
async function obtainNativeCollisionReport({ recipe, recipeBytes, sourceModelSha256, sourceWdbSha256 }) {
const sourceRoot = sourceRootFor(recipe);
const resourceRoot = path.resolve(sourceRoot, recipe.source.resourceRoot);
const executable = path.join(projectRoot, "scripts/runewaker-pipeline/native/bin/native-collision-audit.exe");
const extractorSource = path.join(projectRoot, "scripts/runewaker-pipeline/native/native-collision-audit.cpp");
const [executableBytes, extractorSourceBytes] = await Promise.all([readFile(executable), readFile(extractorSource)]);
const provenanceInputs = {
schemaVersion: 1,
slug: recipe.slug,
sourceModel: recipe.source.model.replaceAll("\\", "/"),
sourceModelSha256,
sourceWdb: recipe.source.wdb.replaceAll("\\", "/"),
sourceWdbSha256,
recipeSha256: sha256(recipeBytes),
extractorSha256: sha256(executableBytes),
extractorSourceSha256: sha256(extractorSourceBytes),
extractorPathArgumentFormat: "forward-slashes-v2",
collisionMode: "respect-options",
};
const cacheKey = sha256(Buffer.from(JSON.stringify(provenanceInputs)));
const cacheRoot = path.join(workRoot, "resource-cache/native-core-collision", recipe.slug, cacheKey);
const reportPath = nativeReportArgument
? path.resolve(projectRoot, nativeReportArgument)
: path.join(cacheRoot, "native-collision-report.json");
const manifestPath = `${reportPath}.provenance.json`;
const objPath = `${reportPath}.obj`;
const flagsPath = `${reportPath}.flags.bin`;
const validateCached = async () => {
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
if (JSON.stringify(manifest.inputs) !== JSON.stringify(provenanceInputs)) {
throw new Error(`${recipe.slug}: native collision cache manifest is not bound to current ROS/WDB/recipe/tool hashes.`);
}
const reportBytes = await readFile(reportPath);
const objBytes = await readFile(objPath);
const flagsBytes = await readFile(flagsPath);
if (sha256(reportBytes) !== manifest.outputs?.reportSha256
|| sha256(objBytes) !== manifest.outputs?.geometrySha256
|| sha256(flagsBytes) !== manifest.outputs?.flagsSha256) {
throw new Error(`${recipe.slug}: native collision cache output hash validation failed.`);
}
const report = JSON.parse(reportBytes.toString("utf8"));
assertStrictNativeQueryReport(report, recipe.slug, provenanceInputs.sourceModel);
assertNativeQueryOutputPaths(report, reportPath, objPath, flagsPath);
return {
report,
reportPath,
objPath,
flagsPath,
reportSha256: manifest.outputs.reportSha256,
geometrySha256: manifest.outputs.geometrySha256,
flagsSha256: manifest.outputs.flagsSha256,
manifestPath,
manifestSha256: sha256(await readFile(manifestPath)),
cacheKey,
inputs: provenanceInputs,
reused: true,
};
};
try {
return await validateCached();
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
if (nativeReportArgument) {
throw new Error(`${recipe.slug}: --native-report reuse requires a SHA-validated .provenance.json sidecar.`);
}
let existingEntries = [];
try {
existingEntries = await import("node:fs/promises").then(({ readdir }) => readdir(cacheRoot));
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
if (existingEntries.length) {
throw new Error(`${recipe.slug}: refusing to overwrite an incomplete native collision cache at ${cacheRoot}.`);
}
await mkdir(cacheRoot, { recursive: true });
await runNativeCollisionExtractor(executable, buildNativeCollisionAuditArguments({
resourceRoot,
sourceModel: recipe.source.model,
reportPath,
}));
const reportBytes = await readFile(reportPath);
const objBytes = await readFile(objPath);
const flagsBytes = await readFile(flagsPath);
const report = JSON.parse(reportBytes.toString("utf8"));
assertStrictNativeQueryReport(report, recipe.slug, provenanceInputs.sourceModel);
assertNativeQueryOutputPaths(report, reportPath, objPath, flagsPath);
const outputs = {
reportSha256: sha256(reportBytes),
geometrySha256: sha256(objBytes),
flagsSha256: sha256(flagsBytes),
reportByteLength: reportBytes.byteLength,
geometryByteLength: objBytes.byteLength,
flagsByteLength: flagsBytes.byteLength,
};
const manifest = { schemaVersion: 1, inputs: provenanceInputs, outputs };
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
return {
report,
reportPath,
objPath,
flagsPath,
...outputs,
manifestPath,
manifestSha256: sha256(await readFile(manifestPath)),
cacheKey,
inputs: provenanceInputs,
reused: false,
};
}
function createTriangleMesh(document, scene, name, positions, extras) {
if (positions.length % 9 !== 0) throw new Error(`${name}: positions are not triangle-list data.`);
const buffer = document.getRoot().listBuffers()[0] ?? document.createBuffer("candidate-geometry-buffer");
const accessor = document.createAccessor(`${name}-position`)
.setType("VEC3")
.setArray(new Float32Array(positions))
.setBuffer(buffer);
const primitive = document.createPrimitive().setAttribute("POSITION", accessor);
const mesh = document.createMesh(name).addPrimitive(primitive);
const node = document.createNode(name).setMesh(mesh).setExtras(extras);
scene.addChild(node);
return node;
}
async function writeScene(file, components) {
const document = new Document();
const scene = document.createScene("native-core-support-candidate");
document.getRoot().setDefaultScene(scene);
document.createBuffer("candidate-geometry-buffer");
for (const component of components) {
createTriangleMesh(document, scene, component.name, component.positions, component.extras);
}
await io.write(file, document);
}
function parseNativeCollisionObj(text) {
const positions = [];
for (const line of text.split(/\r?\n/)) {
if (!line.startsWith("v ")) continue;
const values = line.trim().split(/\s+/).slice(1, 4).map(Number);
if (values.length !== 3 || !values.every(Number.isFinite)) throw new Error("Native collision OBJ contains an invalid vertex.");
positions.push(...values);
}
if (positions.length % 9 !== 0) throw new Error("Native collision OBJ vertex count is not triangle-aligned.");
return positions;
}
function bridgeAndFilterNativeCollision(sourcePositions, flagsBuffer, exclusionMask, meters) {
const sourceTriangleCount = sourcePositions.length / 9;
if (flagsBuffer.byteLength !== sourceTriangleCount * 4) {
throw new Error(`Native collision flags contain ${flagsBuffer.byteLength / 4} entries for ${sourceTriangleCount} source triangles.`);
}
const flags = new DataView(flagsBuffer.buffer, flagsBuffer.byteOffset, flagsBuffer.byteLength);
const acceptedPositions = [];
let excludedTriangleCount = 0;
let nonzeroSourceFlagCount = 0;
for (let triangle = 0; triangle < sourceTriangleCount; triangle += 1) {
const flag = flags.getUint32(triangle * 4, true);
if (flag !== 0) nonzeroSourceFlagCount += 1;
if ((flag & exclusionMask) !== 0) {
excludedTriangleCount += 1;
continue;
}
const base = triangle * 9;
// The reflected-Z bridge reverses handedness; swap B/C exactly once.
for (const sourceVertex of [0, 2, 1]) {
const offset = base + sourceVertex * 3;
acceptedPositions.push(
sourcePositions[offset] * meters,
sourcePositions[offset + 1] * meters,
-sourcePositions[offset + 2] * meters,
);
}
}
return { positions: acceptedPositions, sourceTriangleCount, excludedTriangleCount, nonzeroSourceFlagCount };
}
async function appendRemoteSupportNavigation(navFile, remoteNavPositions, remotePointIndices, insetM) {
if (!remotePointIndices.length) return { triangleCount: 0, vertexCount: 0, mergedIntoPrimaryMesh: false };
const document = await io.read(navFile);
if (remoteNavPositions.length % 9 !== 0) throw new Error("Remote support navigation positions are not triangle aligned.");
const node = document.getRoot().listNodes().find((candidate) => candidate.getName() === "navigation" && candidate.getMesh());
const primitive = node?.getMesh()?.listPrimitives()[0];
const position = primitive?.getAttribute("POSITION");
if (!node || !primitive || !position) throw new Error("Recast output has no primary navigation mesh to receive support islands.");
if (document.getRoot().getExtras()?.runewakerNavigationTopology) {
throw new Error("Cannot merge disconnected support islands into source-pinned Detour detail geometry.");
}
const previousPositions = position.getArray();
const baseVertexCount = position.getCount();
const oldIndex = primitive.getIndices()?.getArray();
const oldIndexCount = oldIndex?.length ?? baseVertexCount;
if (oldIndexCount % 3 !== 0) throw new Error("Recast navigation index data is not triangle aligned.");
const mergedPositions = new Float32Array(previousPositions.length + remoteNavPositions.length);
mergedPositions.set(previousPositions);
mergedPositions.set(remoteNavPositions, previousPositions.length);
const mergedIndices = new Uint32Array(oldIndexCount + remoteNavPositions.length / 3);
if (oldIndex) mergedIndices.set(oldIndex);
else for (let index = 0; index < baseVertexCount; index += 1) mergedIndices[index] = index;
for (let index = 0; index < remoteNavPositions.length / 3; index += 1) {
mergedIndices[oldIndexCount + index] = baseVertexCount + index;
}
const buffer = document.getRoot().listBuffers()[0] ?? document.createBuffer("navigation-support-buffer");
const mergedPosition = document.createAccessor("navigation-position-with-isolated-supports")
.setType("VEC3")
.setArray(mergedPositions)
.setBuffer(buffer);
const mergedIndex = document.createAccessor("navigation-indices-with-isolated-supports")
.setType("SCALAR")
.setArray(mergedIndices)
.setBuffer(buffer);
primitive.setAttribute("POSITION", mergedPosition).setIndices(mergedIndex);
node.setExtras({
...node.getExtras(),
navigationComponentPolicy: "all-components",
isolatedDerivedSupportNavigation: {
source: supportName,
sourcePointIndices: remotePointIndices,
triangleCount: remoteNavPositions.length / 9,
clearanceInsetM: insetM,
inferredConnectivity: false,
},
});
await io.write(navFile, document);
const roundTrip = await io.read(navFile);
const retained = roundTrip.getRoot().listNodes().find((candidate) => candidate.getName() === node.getName());
const retainedPrimitive = retained?.getMesh()?.listPrimitives()[0];
const retainedPosition = retainedPrimitive?.getAttribute("POSITION");
const retainedIndex = retainedPrimitive?.getIndices();
if (!retainedPosition || !retainedIndex
|| retainedIndex.getCount() / 3 !== (oldIndexCount / 3) + remoteNavPositions.length / 9
|| retainedPosition.getCount() !== baseVertexCount + remoteNavPositions.length / 3) {
throw new Error("Merged remote support nav triangles failed GLB round-trip validation.");
}
return {
meshName: node.getName(),
triangleCount: remoteNavPositions.length / 9,
vertexCount: remoteNavPositions.length / 3,
remotePadCount: remotePointIndices.length,
sourcePointIndices: remotePointIndices,
clearanceInsetM: insetM,
mergedIntoPrimaryMesh: true,
mergedFinalTriangleCount: retainedIndex.getCount() / 3,
mergedFinalVertexCount: retainedPosition.getCount(),
navigationComponentPolicy: "all-components",
inferredConnectivity: false,
};
}
const recipeFile = path.join(projectRoot, "scripts/runewaker-pipeline/recipes", `${slug}.json`);
const recipeBytes = await readFile(recipeFile);
const recipe = validateEnvironmentRecipe(JSON.parse(recipeBytes.toString("utf8")), relative(recipeFile));
const recipeSha256 = sha256(recipeBytes);
const importMetadataPath = path.join(projectRoot, "src/assets/game/dungeons", slug, "import-metadata.json");
const importMetadata = JSON.parse(await readFile(importMetadataPath, "utf8"));
const sourceRoot = sourceRootFor(recipe);
const resourceRoot = path.join(sourceRoot, recipe.source.resourceRoot);
const sourceModelPath = path.join(resourceRoot, recipe.source.model);
const sourceWdbPath = path.join(sourceRoot, recipe.source.wdb);
const sourceModelSha256 = sha256(await readFile(sourceModelPath));
const sourceWdbSha256 = sha256(await readFile(sourceWdbPath));
const queryCache = await obtainNativeCollisionReport({ recipe, recipeBytes, sourceModelSha256, sourceWdbSha256 });
const sourceReportPath = queryCache.reportPath;
const sourceReport = queryCache.report;
const nativeObjPath = queryCache.objPath;
const nativeFlagsPath = queryCache.flagsPath;
const nativeObjText = await readFile(nativeObjPath, "utf8");
const nativeSourcePositions = parseNativeCollisionObj(nativeObjText);
const meters = Number(recipe.coordinateSystem.metersPerSourceUnit);
const nativeData = bridgeAndFilterNativeCollision(
nativeSourcePositions,
await readFile(nativeFlagsPath),
Number(sourceReport.pathCollisionPolicy.exclusionMask),
meters,
);
if (nativeData.sourceTriangleCount !== sourceReport.nativeCollisionTriangleCount) {
throw new Error(`${slug}: native query OBJ triangle count does not match its options-aware report.`);
}
const nativeBounds = measureTriangleList(nativeData.positions);
const reportedBounds = directBridgeBounds(nativeSourcePositions, meters);
const nativeBoundsProofErrorM = boundsDifference(nativeBounds, reportedBounds);
if (nativeBoundsProofErrorM > 1e-4) throw new Error(`${slug}: bridge bounds do not agree with native source report (${nativeBoundsProofErrorM}m).`);
const wdbReportPath = path.join(workRoot, slug, "raw/wdb-report.json");
const exportReportPath = path.join(workRoot, slug, "prepared/export-report.json");
const conversionReportPath = path.join(workRoot, slug, "converted/conversion-report.json");
const wdbReportBytes = await readFile(wdbReportPath);
const exportReportBytes = await readFile(exportReportPath);
const conversionReportBytes = await readFile(conversionReportPath);
const wdbReport = JSON.parse(wdbReportBytes.toString("utf8"));
const exportReport = JSON.parse(exportReportBytes.toString("utf8"));
const collisionConversion = JSON.parse(conversionReportBytes.toString("utf8"));
const placement = buildWdbPlacementPlan(wdbReport, recipe).primaryPlacement;
const provenance = validateNativeCoreCandidateProvenance({
slug,
recipe,
recipeSha256,
importMetadata,
actualSourceModelSha256: sourceModelSha256,
actualSourceWdbSha256: sourceWdbSha256,
wdbReport,
wdbReportSha256: sha256(wdbReportBytes),
exportReport,
exportReportSha256: sha256(exportReportBytes),
conversionReportSha256: sha256(conversionReportBytes),
nativeCollisionReport: sourceReport,
nativeCollisionReportSha256: queryCache.reportSha256,
nativeCollisionObjSha256: queryCache.geometrySha256,
nativeCollisionFlagsSha256: queryCache.flagsSha256,
primaryPlacement: placement,
});
if (sourceReport.nativeCollisionTriangleCount !== sourceReport.enabledNativeCollisionTriangleCount) {
throw new Error(`${slug}: respecting-options geometry count does not match enabled serialized native collision triangles.`);
}
const points = recipe.source.sourceAnchorSupport.points.map((point) => point.map(Number));
const supportPolicy = recipe.source.sourceAnchorSupport;
const omittedPointIndices = new Set(supportPolicy.omitNativeGroundedPointIndices ?? []);
const retained = points.map((point, sourcePointIndex) => ({ point, sourcePointIndex })).filter(({ sourcePointIndex }) => !omittedPointIndices.has(sourcePointIndex));
const halfExtent = Number(supportPolicy.halfExtentSourceUnits ?? 60);
const thickness = Number(supportPolicy.thicknessSourceUnits ?? 3);
const expectedSupportGeometry = sourceAnchorSupportGeometry(retained.map(({ point }) => point), placement, {
halfExtent,
thickness,
metersPerSourceUnit: meters,
});
const expectedSupportWorldPositionHash = sha256(Buffer.from(new Float32Array(expectedSupportGeometry.positions).buffer));
let proofOmissionProbeRows = [];
if (omittedPointIndices.size) {
const omissionEvidence = supportPolicy.nativeGroundOmissionEvidence;
if (!omissionEvidence || omissionEvidence.primaryRosSha256 !== provenance.sourceModelSha256
|| !omittedPointIndices.has(omissionEvidence.sourcePointIndex)) {
throw new Error(`${slug}: omitted native-grounded anchors lack matching recipe evidence tied to the current ROS hash.`);
}
const omissionProbeRows = [];
for (const pointIndex of omittedPointIndices) {
const point = points[pointIndex];
const bridgedPoint = new Vector3(...point).applyMatrix4(sourceTerrainToThreeMatrix(placement, meters)).toArray();
const floorHits = upwardFloorHitsAtXZ(nativeData.positions, bridgedPoint);
const nearestHit = floorHits.find((hit) => Math.abs(hit.y - bridgedPoint[1]) <= 0.5);
if (!nearestHit) {
throw new Error(`${slug}: omitted source support ${pointIndex} no longer has a matching native upward floor hit within 0.5m.`);
}
omissionProbeRows.push({ sourcePointIndex: pointIndex, sourcePoint: point, bridgedPoint, nearestUpwardNativeHit: nearestHit });
}
proofOmissionProbeRows = omissionProbeRows;
}
const agentRadius = Number(recipe.navigation.agentRadius ?? 0.36);
const insetSourceUnits = agentRadius / meters;
const insetHalfExtent = halfExtent - insetSourceUnits;
if (insetHalfExtent <= 0) throw new Error(`${slug}: remote navigation inset consumes the full support pad.`);
const coreSupportTrianglePositions = [];
const allSupportTrianglePositions = [];
const remoteSupportNavPositions = [];
const corePointIndices = [];
const remotePointIndices = [];
const supportRows = [];
for (const { point, sourcePointIndex } of retained) {
const box = sourceAnchorSupportGeometry([point], placement, { halfExtent, thickness, metersPerSourceUnit: meters });
const boxBounds = measureTriangleList(triangleListFromIndexedGeometry(box));
const overlapsNativeCoreXZ = boxBounds.maximum[0] >= nativeBounds.minimum[0]
&& boxBounds.minimum[0] <= nativeBounds.maximum[0]
&& boxBounds.maximum[2] >= nativeBounds.minimum[2]
&& boxBounds.minimum[2] <= nativeBounds.maximum[2];
const boxTrianglePositions = triangleListFromIndexedGeometry(box);
allSupportTrianglePositions.push(...boxTrianglePositions);
if (overlapsNativeCoreXZ) {
corePointIndices.push(sourcePointIndex);
coreSupportTrianglePositions.push(...boxTrianglePositions);
} else {
remotePointIndices.push(sourcePointIndex);
const inset = sourceAnchorSupportGeometry([point], placement, {
halfExtent: insetHalfExtent,
thickness,
metersPerSourceUnit: meters,
});
remoteSupportNavPositions.push(...inset.navigationPositions);
}
supportRows.push({ sourcePointIndex, bounds: boxBounds, overlapsNativeCoreXZ });
}
const coreBounds = emptyBounds();
for (let offset = 0; offset < nativeData.positions.length; offset += 3) {
include(coreBounds, nativeData.positions.slice(offset, offset + 3));
}
if (coreSupportTrianglePositions.length) {
const extraBounds = measureTriangleList(coreSupportTrianglePositions);
include(coreBounds, extraBounds.minimum);
include(coreBounds, extraBounds.maximum);
}
const cellSize = Number(recipe.navigation.cellSize);
const predictedGrid = {
cellSize,
widthCells: Math.ceil((coreBounds.maximum[0] - coreBounds.minimum[0]) / cellSize),
depthCells: Math.ceil((coreBounds.maximum[2] - coreBounds.minimum[2]) / cellSize),
};
predictedGrid.cellCount = predictedGrid.widthCells * predictedGrid.depthCells;
await mkdir(outputRoot, { recursive: true });
const nativeCoreInput = path.join(outputRoot, `${slug}-native-core-input.glb`);
const fullCollisionCandidate = path.join(outputRoot, `${slug}-native-collision-and-all-supports-candidate.glb`);
const navFile = path.join(outputRoot, `${slug}-native-core-navigation.collision-candidate.glb`);
const recastReportFile = path.join(outputRoot, "recast-report.json");
const proofFile = path.join(outputRoot, "candidate-proof.json");
let stagedProof = null;
if (stageOnly) {
for (const file of [nativeCoreInput, fullCollisionCandidate, proofFile]) {
try {
await access(file);
throw new Error(`Refusing to overwrite staged candidate artifact ${file}.`);
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
}
} else {
try {
stagedProof = JSON.parse(await readFile(proofFile, "utf8"));
if (stagedProof.status !== "staged-inputs-no-nav-bake") throw new Error(`${slug}: existing candidate proof is already baked or invalid.`);
for (const file of [nativeCoreInput, fullCollisionCandidate]) await access(file);
const stagedSource = stagedProof.source ?? {};
for (const [field, expected] of Object.entries({
recipeSha256,
sourceRosSha256: provenance.sourceModelSha256,
sourceWdbSha256: provenance.sourceWdbSha256,
wdbPlacementReportSha256: provenance.wdbReportSha256,
nativeExportReportSha256: provenance.exportReportSha256,
conversionReportSha256: provenance.conversionReportSha256,
nativeCollisionReportSha256: queryCache.reportSha256,
nativeCollisionObjSha256: queryCache.geometrySha256,
nativeCollisionFlagsSha256: queryCache.flagsSha256,
nativeCollisionCacheManifestSha256: queryCache.manifestSha256,
})) {
if (stagedSource[field] !== expected) throw new Error(`${slug}: staged source hash changed for ${field}.`);
}
if (stagedProof.nativeCoreInput.sha256 !== sha256(await readFile(nativeCoreInput))
|| stagedProof.fullCollisionCandidate.sha256 !== sha256(await readFile(fullCollisionCandidate))) {
throw new Error(`${slug}: staged candidate GLB hash disagrees with its proof.`);
}
} catch (error) {
if (error?.code !== "ENOENT") throw error;
stagedProof = null;
for (const file of [nativeCoreInput, fullCollisionCandidate, proofFile]) {
try {
await access(file);
throw new Error(`Refusing to overwrite incomplete candidate artifact ${file}.`);
} catch (checkError) {
if (checkError?.code !== "ENOENT") throw checkError;
}
}
}
for (const file of [navFile, recastReportFile]) {
try {
await access(file);
throw new Error(`Refusing to overwrite candidate output ${file}.`);
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
}
}
const commonSourceExtras = {
candidateOnly: true,
exactNativeQuerySource: relative(sourceReportPath),
exactNativeQuerySourceSha256: queryCache.reportSha256,
exactNativeCollisionObjSha256: queryCache.geometrySha256,
exactNativeCollisionFlagsSha256: queryCache.flagsSha256,
sourceModelSha256: provenance.sourceModelSha256,
recipeSha256,
nativeQueryMode: "respect-options",
nativeQueryCacheKey: queryCache.cacheKey,
sourceCollisionTriangleCount: sourceReport.nativeCollisionTriangleCount,
sourcePathExclusionMask: Number(sourceReport.pathCollisionPolicy.exclusionMask),
excludedByPathFlagsTriangleCount: nativeData.excludedTriangleCount,
reflectedZBridgeAndWindingSwapAppliedOnce: true,
};
if (!stagedProof) {
await writeScene(nativeCoreInput, [
{ name: "native-query-collision-core", positions: nativeData.positions, extras: { ...commonSourceExtras, role: "native-core-collision" } },
{ name: "core-overlapping-derived-support-boxes", positions: coreSupportTrianglePositions, extras: {
role: "derived-collision-included-in-native-core-recast",
source: supportName,
sourcePointIndices: corePointIndices,
reason: "XZ footprint overlaps native collision bounds; full boxes are included for Recast surface/obstacle/clearance handling.",
} },
]);
await writeScene(fullCollisionCandidate, [
{ name: "native-query-collision-core", positions: nativeData.positions, extras: { ...commonSourceExtras, role: "native-core-collision" } },
{ name: "all-recipe-derived-support-boxes", positions: allSupportTrianglePositions, extras: {
role: "derived-collision-candidate",
source: supportName,
sourcePointIndices: retained.map(({ sourcePointIndex }) => sourcePointIndex),
sourceWorldPositionHash: expectedSupportWorldPositionHash,
generatedDirectlyFromRecipeSourceCoordinates: true,
canonicalGeneratedSupportWasNotReadOrTransformed: true,
} },
]);
}
const proof = {
schemaVersion: 1,
slug,
status: "staged-inputs-no-nav-bake",
source: {
recipe: relative(recipeFile),
recipeSha256,
importMetadata: relative(importMetadataPath),
importMetadataSha256: sha256(await readFile(importMetadataPath)),
sourceRosResource: recipe.source.model,
sourceRosSha256: provenance.sourceModelSha256,
sourceWdbResource: recipe.source.wdb,
sourceWdbSha256: provenance.sourceWdbSha256,
wdbPlacementReport: relative(wdbReportPath),
wdbPlacementReportSha256: provenance.wdbReportSha256,
nativeExportReport: relative(exportReportPath),
nativeExportReportSha256: provenance.exportReportSha256,
conversionReport: relative(conversionReportPath),
conversionReportSha256: provenance.conversionReportSha256,
sourceModel: sourceReport.sourceModel,
primaryPlacement: placement,
nativeCollisionCacheKey: queryCache.cacheKey,
nativeCollisionCacheInputs: queryCache.inputs,
nativeCollisionCacheManifest: relative(queryCache.manifestPath),
nativeCollisionCacheManifestSha256: queryCache.manifestSha256,
nativeCollisionCacheReused: queryCache.reused,
nativeCollisionReport: relative(sourceReportPath),
nativeCollisionReportSha256: queryCache.reportSha256,
nativeCollisionObj: relative(nativeObjPath),
nativeCollisionObjSha256: queryCache.geometrySha256,
nativeCollisionFlags: relative(nativeFlagsPath),
nativeCollisionFlagsSha256: queryCache.flagsSha256,
sourceSupportGeometry: "sourceAnchorSupportGeometry(recipe points, primary WDB placement, source-unit dimensions)",
sourceSupportWasGeneratedDirectly: true,
transformedCanonicalSupportWasNotConsumed: true,
nativeGroundedOmissionProbeRows: proofOmissionProbeRows,
collisionConversionReport: relative(conversionReportPath),
collisionChunksRetainedAsComparisonOnly: collisionConversion.collision.map((row) => row.file),
},
nativeCollision: {
queryTriangles: sourceReport.nativeCollisionTriangleCount,
completeSerializedTriangles: sourceReport.completeNativeCollisionTriangleCount ?? sourceReport.nativeCollisionTriangleCount,
collisionOptionEnabledTriangles: sourceReport.enabledNativeCollisionTriangleCount ?? sourceReport.nativeCollisionTriangleCount,
trianglesRemovedByCollisionOptionGates: sourceReport.disabledSubtreeNativeTriangles ?? 0,
queryRespectsEntityCollisionOptions: sourceReport.queryMasks?.ignoreEntityCollisionOption === false,
sourceNonzeroTriangleFlags: nativeData.nonzeroSourceFlagCount,
exclusionMask: Number(sourceReport.pathCollisionPolicy.exclusionMask),
excludedTriangleCount: nativeData.excludedTriangleCount,
acceptedTriangleCount: nativeData.positions.length / 9,
sourceDirectBridgeBounds: nativeBounds,
boundsErrorAgainstReportM: nativeBoundsProofErrorM,
convertedCollisionComparisonOnly: true,
},
supportSelection: {
originalCount: points.length,
omittedNativeGroundedPointIndices: [...omittedPointIndices],
retainedCount: retained.length,
sourceWorldPositionHash: expectedSupportWorldPositionHash,
generatedFromRawRecipeCoordinatesWithoutCanonicalSupportInput: true,
coreOverlappingCount: corePointIndices.length,
coreSourcePointIndices: corePointIndices,
remoteSeparateCount: remotePointIndices.length,
remoteSourcePointIndices: remotePointIndices,
remoteNavClearanceInsetM: agentRadius,
remoteNavTopInsetHalfExtentM: halfExtent * meters - agentRadius,
inferredRemoteConnectivity: false,
rows: supportRows,
},
nativeCoreInput: {
file: relative(nativeCoreInput),
sha256: sha256(await readFile(nativeCoreInput)),
triangleCount: (nativeData.positions.length + coreSupportTrianglePositions.length) / 9,
nativeCorePlusXZOverlappingSupportBounds: coreBounds,
predictedCoreGrid: predictedGrid,
settings: recipe.navigation,
},
fullCollisionCandidate: {
file: relative(fullCollisionCandidate),
sha256: sha256(await readFile(fullCollisionCandidate)),
triangleCount: (nativeData.positions.length + allSupportTrianglePositions.length) / 9,
sourceGeometryOnly: true,
existingShippingUntouched: true,
},
sourceGroundEntryAnchor: null,
navCandidate: null,
};
const entrySourcePoint = points[0];
proof.sourceGroundEntryAnchor = new Vector3(...entrySourcePoint)
.applyMatrix4(sourceTerrainToThreeMatrix(placement, meters))
.toArray();
if (!stageOnly) await writeFile(proofFile, `${JSON.stringify(proof, null, 2)}\n`, "utf8");
if (!stageOnly) {
console.log(`[native-core-nav-candidate] ${slug}: baking native core + ${corePointIndices.length} footprint-overlapping supports, ${predictedGrid.cellCount} predicted cells`);
const baked = await bakeNavigationFromCollisionAssets({
slug,
collisionAssets: [{ id: "native-core-and-overlapping-supports", role: "collision", path: relative(nativeCoreInput) }],
settings: { ...recipe.navigation },
output: navFile,
reportFile: recastReportFile,
includeDetourTopology: remotePointIndices.length === 0,
source: {
kind: "native-core-plus-overlapping-recipe-support-candidate",
candidateOnly: true,
collisionQueryMode: "respect-options",
nativeQueryReportSha256: commonSourceExtras.exactNativeQuerySourceSha256,
nativeQueryObjSha256: commonSourceExtras.exactNativeCollisionObjSha256,
nativeQueryFlagsSha256: commonSourceExtras.exactNativeCollisionFlagsSha256,
recipeSha256,
sourceModelSha256: provenance.sourceModelSha256,
sourceWdbSha256: provenance.sourceWdbSha256,
directRecipeSupportWorldPositionHash: expectedSupportWorldPositionHash,
sourceTransformAppliedOnce: true,
allRemoteSupportsRemainExcludedFromRecastInput: true,
},
detourRouteProbes: [{
id: "original-entry-anchor-projection",
start: proof.sourceGroundEntryAnchor,
end: proof.sourceGroundEntryAnchor,
maxStartSnapDistance: 0.5,
maxEndSnapDistance: 0.5,
}],
});
const remoteNode = await appendRemoteSupportNavigation(navFile, remoteSupportNavPositions, remotePointIndices, agentRadius);
const recastReport = JSON.parse(await readFile(recastReportFile, "utf8"));
const probe = recastReport.detourRouteProbes?.find((row) => row.id === "original-entry-anchor-projection");
if (remoteNode.triangleCount > 0) {
const recastGeometry = recastReport.geometry;
const baseTriangleCount = recastGeometry.triangles;
const baseVertexCount = recastGeometry.vertices;
const minimum = recastGeometry.bounds[0].slice();
const maximum = recastGeometry.bounds[1].slice();
for (let offset = 0; offset < remoteSupportNavPositions.length; offset += 3) {
for (let axis = 0; axis < 3; axis += 1) {
minimum[axis] = Math.min(minimum[axis], remoteSupportNavPositions[offset + axis]);
maximum[axis] = Math.max(maximum[axis], remoteSupportNavPositions[offset + axis]);
}
}
recastReport.navigationComponentPolicy = "all-components";
recastReport.geometry = {
...recastGeometry,
bounds: [minimum, maximum],
sourceRecastTriangles: baseTriangleCount,
sourceRecastVertices: baseVertexCount,
triangles: baseTriangleCount + remoteNode.triangleCount,
vertices: baseVertexCount + remoteNode.vertexCount,
};
recastReport.isolatedSupportNavigation = {
...remoteNode,
appendedWithoutLinks: true,
componentPolicy: "all-components",
};
}
proof.navCandidate = {
file: relative(navFile),
sha256: sha256(await readFile(navFile)),
recastReport: relative(recastReportFile),
recastReportSha256: sha256(await readFile(recastReportFile)),
buildMode: baked.build.mode,
partition: baked.build.partition,
tileCount: baked.build.tileCount,
populatedTileCount: baked.build.populatedTileCount,
inputTriangleCount: baked.input.triangleCount,
bakedNavigationTriangleCount: baked.geometry.triangles,
finalNavigationTriangleCount: remoteNode.mergedFinalTriangleCount ?? baked.geometry.triangles,
navigationComponentPolicy: remoteNode.triangleCount > 0 ? "all-components" : null,
entryProjection: probe?.start ?? null,
entryWithinHalfMeter: probe?.start?.found === true && probe.start.distance <= 0.5,
separateRemoteSupportNavigation: remoteNode,
};
recastReport.artifactOnlyNativeCoreCandidate = proof.navCandidate;
recastReport.outputSha256 = proof.navCandidate.sha256;
await writeFile(recastReportFile, `${JSON.stringify(recastReport, null, 2)}\n`, "utf8");
proof.navCandidate.recastReportSha256 = sha256(await readFile(recastReportFile));
proof.status = "candidate-nav-baked";
}
await writeFile(proofFile, `${JSON.stringify(proof, null, 2)}\n`, "utf8");
console.log(JSON.stringify({
status: proof.status,
proof: relative(proofFile),
nativeTriangles: nativeData.positions.length / 9,
coreSupportCount: corePointIndices.length,
remoteSupportCount: remotePointIndices.length,
coreInputTriangles: proof.nativeCoreInput.triangleCount,
coreGrid: predictedGrid,
navCandidate: proof.navCandidate,
}, null, 2));