852 lines
40 KiB
JavaScript
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));
|