216 lines
7.8 KiB
JavaScript
216 lines
7.8 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Stage 1 — automated real-model validation.
|
|
*
|
|
* Reads the manifest (src/data/modelAssets.ts, transpiled with the project's
|
|
* typescript dep), then for every SKU independently:
|
|
* - checks the runtime file exists (size + SHA-256 vs manifest)
|
|
* - parses the STL, applies manifest rotation + uniform mm scale
|
|
* - measures the world-axis bounding box
|
|
* - compares against worldExpectedMm with tolerance max(2mm, 1%) per axis
|
|
* - checks triangle/file budgets
|
|
* - verifies honest marking for SKUs without an official model
|
|
*
|
|
* Output: docs/stage1_real_models/real-models-validation.json
|
|
* Exit code: non-zero on any critical failure. Never hand-edited results.
|
|
*/
|
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
import { createHash } from 'node:crypto';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, resolve } from 'node:path';
|
|
import ts from 'typescript';
|
|
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const OUT_PATH = resolve(ROOT, 'docs/stage1_real_models/real-models-validation.json');
|
|
|
|
// ---------- manifest loading (TS → ESM via typescript transpile) ----------
|
|
async function loadManifest() {
|
|
const src = readFileSync(resolve(ROOT, 'src/data/modelAssets.ts'), 'utf8');
|
|
const js = ts.transpileModule(src, {
|
|
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
|
}).outputText;
|
|
const tmp = resolve(ROOT, 'node_modules/.cache/modelAssets.manifest.mjs');
|
|
mkdirSync(dirname(tmp), { recursive: true });
|
|
writeFileSync(tmp, js);
|
|
const mod = await import(`file://${tmp}?t=${Date.now()}`);
|
|
return { MODEL_ASSETS: mod.MODEL_ASSETS, ARCHIVE_ONLY_MODELS: mod.ARCHIVE_ONLY_MODELS };
|
|
}
|
|
|
|
// ---------- STL parsing + transform measurement ----------
|
|
function readStlVertices(path) {
|
|
const buf = readFileSync(path);
|
|
const isAscii = buf.subarray(0, 5).toString('ascii').toLowerCase() === 'solid'
|
|
&& !(buf.length >= 84 && 84 + buf.readUInt32LE(80) * 50 === buf.length);
|
|
const verts = [];
|
|
if (isAscii) {
|
|
const re = /vertex\s+([-\d.eE+]+)\s+([-\d.eE+]+)\s+([-\d.eE+]+)/g;
|
|
const text = buf.toString('ascii');
|
|
let m;
|
|
while ((m = re.exec(text)) !== null) verts.push([+m[1], +m[2], +m[3]]);
|
|
return { verts, triCount: verts.length / 3, bytes: buf.length };
|
|
}
|
|
const count = buf.readUInt32LE(80);
|
|
for (let i = 0; i < count; i++) {
|
|
const off = 84 + i * 50 + 12;
|
|
for (let v = 0; v < 3; v++) {
|
|
verts.push([buf.readFloatLE(off + v * 12), buf.readFloatLE(off + v * 12 + 4), buf.readFloatLE(off + v * 12 + 8)]);
|
|
}
|
|
}
|
|
return { verts, triCount: count, bytes: buf.length };
|
|
}
|
|
|
|
/** Apply manifest rotation (X→Y→Z order, same as three.js geometry.rotate*) */
|
|
function rotatePoint([x, y, z], [rx, ry, rz]) {
|
|
if (rx) {
|
|
const c = Math.cos(rx), s = Math.sin(rx);
|
|
[y, z] = [y * c - z * s, y * s + z * c];
|
|
}
|
|
if (ry) {
|
|
const c = Math.cos(ry), s = Math.sin(ry);
|
|
[x, z] = [x * c + z * s, -x * s + z * c];
|
|
}
|
|
if (rz) {
|
|
const c = Math.cos(rz), s = Math.sin(rz);
|
|
[x, y] = [x * c - y * s, x * s + y * c];
|
|
}
|
|
return [x, y, z];
|
|
}
|
|
|
|
function sha256(path) {
|
|
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
}
|
|
|
|
const TOL_ABS_MM = 2;
|
|
const TOL_REL = 0.01;
|
|
const FILE_HARD_LIMIT = 1.5 * 1024 * 1024;
|
|
const TRI_ACCEPTABLE = 100_000;
|
|
const r2 = (n) => Math.round(n * 100) / 100;
|
|
|
|
const { MODEL_ASSETS, ARCHIVE_ONLY_MODELS } = await loadManifest();
|
|
const results = [];
|
|
let failures = 0;
|
|
|
|
for (const asset of MODEL_ASSETS) {
|
|
const base = {
|
|
sku: asset.itemId,
|
|
model: asset.displayName,
|
|
source: asset.sourceFile ? `${asset.sourceArchive} → ${asset.sourceFile}` : null,
|
|
sourceSha256: asset.sourceSha256,
|
|
runtimePath: asset.runtimePath,
|
|
format: asset.runtimeFormat,
|
|
expectedDimensionsMm: asset.dimensions,
|
|
axisMapping: asset.axisMapping,
|
|
rotation: asset.rotation,
|
|
pivot: asset.pivotMode,
|
|
defaultRealAsset: asset.defaultRealAsset,
|
|
};
|
|
|
|
if (!asset.defaultRealAsset) {
|
|
const honest = (asset.notes ?? '').includes('NO_EXACT_OFFICIAL_MODEL');
|
|
if (!honest) failures++;
|
|
results.push({
|
|
...base,
|
|
status: honest ? 'PASS' : 'FAIL',
|
|
mode: 'procedural-fallback',
|
|
honestMarking: honest,
|
|
withinTolerance: null,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const diskPath = resolve(ROOT, 'public', asset.runtimePath.replace(/^\//, ''));
|
|
const record = { ...base, mode: 'real-model' };
|
|
|
|
if (!existsSync(diskPath)) {
|
|
failures++;
|
|
results.push({ ...record, status: 'FAIL', error: 'RUNTIME_FILE_MISSING' });
|
|
continue;
|
|
}
|
|
|
|
const actualSha = sha256(diskPath);
|
|
record.checksumMatch = actualSha === asset.runtimeSha256;
|
|
const { verts, triCount, bytes } = readStlVertices(diskPath);
|
|
record.fileSizeBytes = bytes;
|
|
record.triangleCount = triCount;
|
|
record.fileSizeMatchesManifest = bytes === asset.fileSizeBytes;
|
|
record.triangleCountMatchesManifest = triCount === asset.triangleCount;
|
|
|
|
// world-space measurement after manifest rotation (mm)
|
|
const min = [Infinity, Infinity, Infinity];
|
|
const max = [-Infinity, -Infinity, -Infinity];
|
|
for (const p of verts) {
|
|
const w = rotatePoint(p, asset.rotation);
|
|
for (let a = 0; a < 3; a++) {
|
|
if (w[a] < min[a]) min[a] = w[a];
|
|
if (w[a] > max[a]) max[a] = w[a];
|
|
}
|
|
}
|
|
const measuredMm = { x: max[0] - min[0], y: max[1] - min[1], z: max[2] - min[2] };
|
|
const expected = asset.worldExpectedMm;
|
|
const deviationMm = {};
|
|
const deviationPercent = {};
|
|
let withinTolerance = true;
|
|
for (const axis of ['x', 'y', 'z']) {
|
|
const dev = Math.abs(measuredMm[axis] - expected[axis]);
|
|
const tol = Math.max(TOL_ABS_MM, expected[axis] * TOL_REL);
|
|
deviationMm[axis] = r2(dev);
|
|
deviationPercent[axis] = r2((dev / expected[axis]) * 100);
|
|
if (dev > tol) withinTolerance = false;
|
|
}
|
|
record.expectedWorldMm = expected;
|
|
record.measuredDimensionsMm = { x: r2(measuredMm.x), y: r2(measuredMm.y), z: r2(measuredMm.z) };
|
|
record.deviationMm = deviationMm;
|
|
record.deviationPercent = deviationPercent;
|
|
record.uniformScale = 1.0; // mm→m uniform only; no fitting scale
|
|
record.withinTolerance = withinTolerance;
|
|
record.budget = {
|
|
fileUnderHardLimit: bytes <= FILE_HARD_LIMIT,
|
|
trianglesUnderAcceptable: triCount <= TRI_ACCEPTABLE,
|
|
};
|
|
|
|
const pass = record.checksumMatch
|
|
&& record.fileSizeMatchesManifest
|
|
&& record.triangleCountMatchesManifest
|
|
&& withinTolerance
|
|
&& record.budget.fileUnderHardLimit
|
|
&& record.budget.trianglesUnderAcceptable;
|
|
if (!pass) failures++;
|
|
record.status = pass ? 'PASS' : 'FAIL';
|
|
results.push(record);
|
|
}
|
|
|
|
const summary = {
|
|
generatedAt: new Date().toISOString(),
|
|
toleranceRule: 'max(2mm, 1%) per axis, uniform scale only',
|
|
budgets: { fileHardLimitBytes: FILE_HARD_LIMIT, trianglesAcceptable: TRI_ACCEPTABLE },
|
|
totals: {
|
|
skus: MODEL_ASSETS.length,
|
|
realAssets: results.filter((r) => r.mode === 'real-model').length,
|
|
proceduralFallbacks: results.filter((r) => r.mode === 'procedural-fallback').length,
|
|
pass: results.filter((r) => r.status === 'PASS').length,
|
|
fail: results.filter((r) => r.status === 'FAIL').length,
|
|
},
|
|
archiveOnlyModels: ARCHIVE_ONLY_MODELS.map((m) => ({
|
|
model: m.displayName,
|
|
source: `${m.sourceArchive} → ${m.sourceFile}`,
|
|
sourceSha256: m.sourceSha256,
|
|
note: m.notes,
|
|
})),
|
|
};
|
|
|
|
mkdirSync(dirname(OUT_PATH), { recursive: true });
|
|
writeFileSync(OUT_PATH, JSON.stringify({ summary, results }, null, 2) + '\n');
|
|
console.log(JSON.stringify(summary.totals));
|
|
for (const r of results) {
|
|
const tag = r.status === 'PASS' ? 'PASS' : 'FAIL';
|
|
const dims = r.measuredDimensionsMm
|
|
? ` measured=${r.measuredDimensionsMm.x}/${r.measuredDimensionsMm.y}/${r.measuredDimensionsMm.z}mm`
|
|
: '';
|
|
console.log(`${tag} ${r.sku}${dims}${r.error ? ' ' + r.error : ''}`);
|
|
}
|
|
if (failures > 0) {
|
|
console.error(`\n${failures} critical failure(s)`);
|
|
process.exit(1);
|
|
}
|
|
console.log('\nAll validations passed.');
|