#!/usr/bin/env node import fs from "node:fs/promises"; import path from "node:path"; import { loadImplicitCadModelFromPath } from "../src/lib/implicitCad/export.js"; import { meshToFormat } from "../src/lib/implicitCad/exporters.js"; import { analyzeImplicitMeshQuality } from "../src/lib/implicitCad/meshQuality.js"; import { meshImplicitCadModel } from "../src/lib/implicitCad/mesh.js"; const FORMATS = Object.freeze(["glb", "stl", "3mf"]); const DEFAULT_ROOT = process.cwd(); function usage() { return `Usage: node scripts/verify-implicit-cad-exports.mjs node scripts/verify-implicit-cad-exports.mjs --root examples --resolution 96 --formats glb,stl,3mf Options: --root Directory containing .implicit.js files. Default: current directory. --file, --input Specific .implicit.js file to verify. May be repeated. --resolution Longest-axis sampling resolution. Default: 96. --max-cells Safety cap for grid cells. Default: exporter default. --formats Comma-separated export formats. Default: glb,stl,3mf. --orientation-samples Maximum sampled SDF orientation checks per mesh. Default: 1000. --write-dir Optional directory for generated export files. --json Print machine-readable result JSON. --help, -h Show this help. `; } function finiteNumber(value, fallback = 0) { const numeric = Number(value); return Number.isFinite(numeric) ? numeric : fallback; } function parseArgs(argv) { const options = { root: DEFAULT_ROOT, files: [], resolution: 96, maxCells: undefined, formats: [...FORMATS], orientationSamples: 1000, writeDir: "", json: false, help: false, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; const readValue = () => { index += 1; if (index >= argv.length) { throw new Error(`${arg} requires a value`); } return argv[index]; }; switch (arg) { case "--root": options.root = path.resolve(readValue()); break; case "--file": case "--input": options.files.push(path.resolve(readValue())); break; case "--resolution": options.resolution = Number(readValue()); break; case "--max-cells": options.maxCells = Number(readValue()); break; case "--formats": options.formats = readValue() .split(",") .map((format) => format.trim().toLowerCase()) .filter(Boolean); break; case "--orientation-samples": options.orientationSamples = Number(readValue()); break; case "--write-dir": options.writeDir = path.resolve(readValue()); break; case "--json": options.json = true; break; case "--help": case "-h": options.help = true; break; default: throw new Error(`Unknown argument: ${arg}`); } } if (!options.formats.length || options.formats.some((format) => !FORMATS.includes(format))) { throw new Error(`--formats must contain one or more of: ${FORMATS.join(", ")}`); } return options; } async function listImplicitFiles(root) { const entries = await fs.readdir(root, { withFileTypes: true }); return entries .filter((entry) => entry.isFile() && /\.implicit\.(?:js|mjs)$/i.test(entry.name)) .map((entry) => path.join(root, entry.name)) .sort((left, right) => left.localeCompare(right)); } function parseGlbJson(buffer) { if (buffer.length < 28 || buffer.toString("utf-8", 0, 4) !== "glTF") { throw new Error("GLB magic header is missing"); } const version = buffer.readUInt32LE(4); const totalLength = buffer.readUInt32LE(8); const jsonLength = buffer.readUInt32LE(12); const jsonType = buffer.readUInt32LE(16); if (version !== 2) { throw new Error(`Unsupported GLB version: ${version}`); } if (totalLength !== buffer.length) { throw new Error(`GLB length mismatch: header=${totalLength} actual=${buffer.length}`); } if (jsonType !== 0x4e4f534a) { throw new Error("GLB first chunk is not JSON"); } return JSON.parse(buffer.toString("utf-8", 20, 20 + jsonLength).trim()); } function validateFormatBuffer(format, buffer, mesh) { const triangleCount = mesh.triangleCount; if (!Buffer.isBuffer(buffer) || buffer.length <= 100) { return [`${format} buffer is unexpectedly small`]; } if (format === "stl") { const expectedLength = 84 + triangleCount * 50; const declaredTriangles = buffer.readUInt32LE(80); return [ declaredTriangles === triangleCount ? "" : `STL triangle count mismatch: ${declaredTriangles} != ${triangleCount}`, buffer.length === expectedLength ? "" : `STL byte length mismatch: ${buffer.length} != ${expectedLength}`, ].filter(Boolean); } if (format === "glb") { try { const json = parseGlbJson(buffer); const primitive = json.meshes?.[0]?.primitives?.[0]; const nodeExtras = json.nodes?.[0]?.extras || {}; return [ primitive?.attributes?.POSITION === 0 ? "" : "GLB POSITION accessor missing", primitive?.attributes?.NORMAL === 1 ? "" : "GLB NORMAL accessor missing", nodeExtras.cadOccurrenceId ? "" : "GLB CAD-native occurrence metadata missing", ].filter(Boolean); } catch (error) { return [error instanceof Error ? error.message : String(error)]; } } if (format === "3mf") { return buffer.readUInt32LE(0) === 0x04034b50 ? [] : ["3MF ZIP header missing"]; } return [`Unsupported format: ${format}`]; } function qualityFailures(quality) { const failures = []; if (quality.triangleCount < 100) { failures.push(`too few triangles: ${quality.triangleCount}`); } if (quality.triangles.nonFinitePositions > 0 || quality.triangles.nonFiniteNormals > 0) { failures.push("mesh contains non-finite position or normal data"); } if (quality.triangles.degenerateRatio > 0.002) { failures.push(`degenerate triangle ratio ${(quality.triangles.degenerateRatio * 100).toFixed(3)}%`); } if (quality.edges.boundaryRatio > 0.002) { failures.push(`boundary edge ratio ${(quality.edges.boundaryRatio * 100).toFixed(3)}%`); } if (quality.orientation.samples > 0 && quality.orientation.invertedRatio > 0.02) { failures.push(`inverted orientation sample ratio ${(quality.orientation.invertedRatio * 100).toFixed(3)}%`); } if (quality.triangles.poorNormalAlignment > Math.max(8, quality.triangleCount * 0.005)) { failures.push(`poor normal alignment count ${quality.triangles.poorNormalAlignment}`); } return failures; } async function verifyFile(inputPath, options) { const started = Date.now(); const model = await loadImplicitCadModelFromPath(inputPath); const mesh = meshImplicitCadModel(model, { resolution: options.resolution, maxCells: options.maxCells, smoothNormals: true, }); const quality = analyzeImplicitMeshQuality(mesh, { model, maxOrientationSamples: options.orientationSamples, }); const formatResults = []; for (const format of options.formats) { const exported = meshToFormat(mesh, format, { name: model.name, color: model.material?.color, }); const failures = validateFormatBuffer(format, exported.body, mesh); if (options.writeDir) { await fs.mkdir(options.writeDir, { recursive: true }); const stem = path.basename(inputPath).replace(/\.implicit\.(?:js|mjs)$/i, ""); await fs.writeFile(path.join(options.writeDir, `${stem}.${format}`), exported.body); } formatResults.push({ format, bytes: exported.body.length, failures, }); } const failures = [ ...qualityFailures(quality), ...formatResults.flatMap((result) => result.failures.map((failure) => `${result.format}: ${failure}`)), ]; return { input: path.relative(process.cwd(), inputPath), name: model.name, ok: failures.length === 0, failures, elapsedMs: Date.now() - started, mesh: { triangles: mesh.triangleCount, vertices: mesh.vertexCount, grid: mesh.grid, }, quality, formats: formatResults, }; } async function main() { const options = parseArgs(process.argv.slice(2)); if (options.help) { process.stdout.write(usage()); return; } const files = options.files.length ? options.files : await listImplicitFiles(options.root); if (!files.length) { throw new Error(`No implicit CAD files found under ${options.root}`); } const results = []; for (const file of files) { if (!options.json) { process.stdout.write(`VERIFY ${path.relative(process.cwd(), file)}\n`); } const result = await verifyFile(file, options); results.push(result); if (!options.json) { const status = result.ok ? "PASS" : "FAIL"; const formats = result.formats .map((formatResult) => `${formatResult.format}:${Math.round(formatResult.bytes / 1024)}KB`) .join(" "); process.stdout.write( `${status} ${result.input} triangles=${result.mesh.triangles.toLocaleString()} ` + `boundary=${result.quality.edges.boundary} nonManifold=${result.quality.edges.nonManifold} ` + `inverted=${result.quality.orientation.inverted}/${result.quality.orientation.samples} ${formats}\n` ); for (const failure of result.failures) { process.stdout.write(` - ${failure}\n`); } } } const summary = { ok: results.every((result) => result.ok), resolution: Math.floor(finiteNumber(options.resolution, 96)), formats: options.formats, fileCount: results.length, elapsedMs: results.reduce((total, result) => total + result.elapsedMs, 0), results, }; if (options.json) { process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); } if (!summary.ok) { process.exitCode = 1; } } main().catch((error) => { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exit(1); });