forked from phenom/healer-man
222 lines
12 KiB
JavaScript
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));
|