Files
healer-man/scripts/runewaker-pipeline/probe-aeternal-entry-native-ground.mjs
2026-10-06 22:25:34 -04:00

222 lines
12 KiB
JavaScript

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));