first commit
This commit is contained in:
+138
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env node
|
||||
import { exportImplicitCadFile, IMPLICIT_CAD_EXPORT_FORMATS } from "../src/lib/implicitCad/export.js";
|
||||
|
||||
function usage() {
|
||||
return `Usage:
|
||||
node scripts/export.mjs --input <model.implicit.js> --format glb
|
||||
node scripts/export.mjs --input <model.implicit.js> --output <mesh.stl> --resolution <resolution>
|
||||
|
||||
Options:
|
||||
--input, -i Input .implicit.js/.implicit.mjs file.
|
||||
--output, -o Output file. Defaults to same folder/stem with selected format.
|
||||
--format, -f Export format: ${IMPLICIT_CAD_EXPORT_FORMATS.join(", ")}.
|
||||
--resolution Longest-axis sampling resolution. Default: 96.
|
||||
--max-cells Safety cap for grid cells. Default: 2500000.
|
||||
--params JSON object of implicit parameter values.
|
||||
--animation JSON object of implicit animation state.
|
||||
--animated Export a GLB morph-target animation from the selected animation.
|
||||
--frames Animation frame count for --animated. Default: 18.
|
||||
--duration Animation duration in seconds for --animated. Default: animation duration.
|
||||
--json Print machine-readable result JSON.
|
||||
--help, -h Show this help.
|
||||
`;
|
||||
}
|
||||
|
||||
function parseJsonOption(value, label) {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error(`${label} must be a JSON object`);
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid ${label}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
input: "",
|
||||
output: "",
|
||||
format: "",
|
||||
resolution: 96,
|
||||
maxCells: undefined,
|
||||
params: null,
|
||||
animationState: null,
|
||||
animated: false,
|
||||
frames: undefined,
|
||||
duration: undefined,
|
||||
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 "--input":
|
||||
case "-i":
|
||||
options.input = readValue();
|
||||
break;
|
||||
case "--output":
|
||||
case "-o":
|
||||
options.output = readValue();
|
||||
break;
|
||||
case "--format":
|
||||
case "-f":
|
||||
options.format = readValue();
|
||||
break;
|
||||
case "--resolution":
|
||||
options.resolution = Number(readValue());
|
||||
break;
|
||||
case "--max-cells":
|
||||
options.maxCells = Number(readValue());
|
||||
break;
|
||||
case "--params":
|
||||
options.params = parseJsonOption(readValue(), "--params");
|
||||
break;
|
||||
case "--animation":
|
||||
options.animationState = parseJsonOption(readValue(), "--animation");
|
||||
break;
|
||||
case "--animated":
|
||||
options.animated = true;
|
||||
break;
|
||||
case "--frames":
|
||||
options.frames = Number(readValue());
|
||||
break;
|
||||
case "--duration":
|
||||
options.duration = Number(readValue());
|
||||
break;
|
||||
case "--json":
|
||||
options.json = true;
|
||||
break;
|
||||
case "--help":
|
||||
case "-h":
|
||||
options.help = true;
|
||||
break;
|
||||
default:
|
||||
if (!options.input && !arg.startsWith("-")) {
|
||||
options.input = arg;
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
process.stdout.write(usage());
|
||||
return;
|
||||
}
|
||||
if (!options.input) {
|
||||
throw new Error("Missing --input");
|
||||
}
|
||||
const result = await exportImplicitCadFile(options);
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
} else {
|
||||
process.stdout.write(`Exported ${result.format.toUpperCase()} ${result.output}\n`);
|
||||
process.stdout.write(`Triangles: ${result.triangleCount.toLocaleString()} Bytes: ${result.bytes.toLocaleString()}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const repoRoot = path.resolve(packageRoot, "..", "..");
|
||||
const testRoots = [
|
||||
path.join(packageRoot, "src"),
|
||||
path.join(packageRoot, "scripts"),
|
||||
];
|
||||
|
||||
function collectTests(dir, tests = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
collectTests(entryPath, tests);
|
||||
} else if (/\.test\.[cm]?js$/u.test(entry.name)) {
|
||||
tests.push(entryPath);
|
||||
}
|
||||
}
|
||||
return tests;
|
||||
}
|
||||
|
||||
const requestedTests = process.argv.slice(2).map((testPath) => path.resolve(packageRoot, testPath));
|
||||
const tests = (requestedTests.length ? requestedTests : testRoots.flatMap((root) => collectTests(root))).sort();
|
||||
if (!tests.length) {
|
||||
console.error("No implicitjs tests found.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = spawnSync(process.execPath, [
|
||||
"--test",
|
||||
"--experimental-default-type=module",
|
||||
...tests,
|
||||
], {
|
||||
cwd: packageRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
...(fs.existsSync(path.join(repoRoot, ".venv", "bin", "python"))
|
||||
? { CAD_PYTHON: path.join(repoRoot, ".venv", "bin", "python") }
|
||||
: {}),
|
||||
...(fs.existsSync(path.join(repoRoot, "packages", "cadpy", "src"))
|
||||
? { CAD_PYTHONPATH: path.join(repoRoot, "packages", "cadpy", "src") }
|
||||
: {}),
|
||||
},
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Implicit CAD snapshot render</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"gifenc": "/node_modules/gifenc/dist/gifenc.esm.js",
|
||||
"three": "/node_modules/three/build/three.module.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="/src/common/implicitHeadlessRenderEntry.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+978
@@ -0,0 +1,978 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
export const SNAPSHOT_ORIGIN = "http://implicit-cad-snapshot.local";
|
||||
const SNAPSHOT_RENDER_URL = `${SNAPSHOT_ORIGIN}/render.html`;
|
||||
const SNAPSHOT_ROUTE_GLOB = `${SNAPSHOT_ORIGIN}/**`;
|
||||
export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
export const RUNTIME_DIR = path.join(PACKAGE_ROOT, "scripts", "snapshot-runtime");
|
||||
export const RENDER_HTML_PATH = path.join(RUNTIME_DIR, "render.html");
|
||||
const DEFAULT_TIMEOUT_SECONDS = 180;
|
||||
const RENDER_BROWSER_STARTUP_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_RENDER_THEME_ID = "workbench";
|
||||
const WORKBENCH_RENDER_THEME_IDS = new Set([DEFAULT_RENDER_THEME_ID]);
|
||||
const SIMPLE_RENDER_WIDTH = 1200;
|
||||
const SIMPLE_RENDER_HEIGHT = 900;
|
||||
const SIMPLE_SQUARE_RENDER_WIDTH = 1024;
|
||||
const SIMPLE_SQUARE_RENDER_HEIGHT = 1024;
|
||||
const DIAGNOSTIC_RENDER_WIDTH = 1600;
|
||||
const DIAGNOSTIC_RENDER_HEIGHT = 1200;
|
||||
const DEFAULT_RENDER_WIDTH = DIAGNOSTIC_RENDER_WIDTH;
|
||||
const DEFAULT_RENDER_HEIGHT = DIAGNOSTIC_RENDER_HEIGHT;
|
||||
const COMPLEX_ASSEMBLY_RENDER_WIDTH = 1800;
|
||||
const COMPLEX_ASSEMBLY_RENDER_HEIGHT = 1200;
|
||||
const COMPLEX_ASSEMBLY_LARGE_RENDER_WIDTH = 1920;
|
||||
const COMPLEX_ASSEMBLY_LARGE_RENDER_HEIGHT = 1440;
|
||||
const PRESENTATION_RENDER_WIDTH = 2400;
|
||||
const PRESENTATION_RENDER_HEIGHT = 1600;
|
||||
const PRESENTATION_LARGE_RENDER_WIDTH = 2800;
|
||||
const PRESENTATION_LARGE_RENDER_HEIGHT = 1800;
|
||||
const ORBIT_RENDER_WIDTH = 960;
|
||||
const ORBIT_RENDER_HEIGHT = 640;
|
||||
const CONTACT_SHEET_RENDER_WIDTH = 2400;
|
||||
const CONTACT_SHEET_RENDER_HEIGHT = 1600;
|
||||
|
||||
const APPEARANCE_OPTION_KEYS = new Set([
|
||||
"materials",
|
||||
"edges",
|
||||
"background",
|
||||
"floor",
|
||||
"environment",
|
||||
"lighting",
|
||||
]);
|
||||
const GRAPHICS_OPTION_KEYS = new Set([
|
||||
"resolutionScale",
|
||||
"interactionResolutionScale",
|
||||
"detail",
|
||||
"normalSmoothing",
|
||||
"modelColors",
|
||||
"shadows",
|
||||
"ambientOcclusion",
|
||||
"rimLight",
|
||||
]);
|
||||
|
||||
export class SnapshotError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "SnapshotError";
|
||||
}
|
||||
}
|
||||
|
||||
class RouteFileError extends SnapshotError {
|
||||
constructor(message, { status = 404 } = {}) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export function helpText() {
|
||||
return `Usage:
|
||||
node scripts/snapshot.mjs --input <model.implicit.js> --output <snapshot.png>
|
||||
node scripts/snapshot.mjs --input <model.implicit.js> --output <orbit.gif> --mode orbit
|
||||
node scripts/snapshot.mjs --job <render-job.json>
|
||||
node scripts/snapshot.mjs --job - --json
|
||||
|
||||
Snapshot renders browser-native implicit CAD .implicit.js/.implicit.mjs modules. Supported shortcut flags are --input, --output/-o, --mode, --appearance, --camera, --size-profile, --width, --height, --params, --graphics, --job, and --json. The default appearance is the built-in light/dark-aware theme. --appearance accepts a saved theme name, an inline JSON appearance settings object, or a JSON appearance settings file path. --camera accepts a preset, azimuth:elevation pair, or JSON object with preset, position, target, up, direction, and zoom fields. JSON jobs use input, mode=view/orbit/animate, outputs, camera, width/height, appearance, graphics, implicitParameters, implicitAnimation, orbit, and optional render.transparent/render.zoom/render.frameMargin. A job can include multiple outputs, and --job can load a raw job, an array of jobs, or { "jobs": [...] }; prefer one multi-output job for review packets so the browser, source module, and runtime model are reused. Output file names are saved with a shared UTC seconds timestamp before the extension.
|
||||
`;
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function positiveInteger(value, label) {
|
||||
const parsed = Number.parseInt(String(value || ""), 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new SnapshotError(`${label} must be a positive integer`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readRequiredValue(argv, index, flag) {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith("--")) {
|
||||
throw new SnapshotError(`${flag} requires a value`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionDefaults() {
|
||||
return {
|
||||
job: "",
|
||||
input: "",
|
||||
output: "",
|
||||
mode: "",
|
||||
modeSpecified: false,
|
||||
appearance: DEFAULT_RENDER_THEME_ID,
|
||||
appearanceSpecified: false,
|
||||
camera: "iso",
|
||||
cameraSpecified: false,
|
||||
sizeProfile: "",
|
||||
graphics: null,
|
||||
graphicsSpecified: false,
|
||||
width: null,
|
||||
height: null,
|
||||
params: null,
|
||||
paramsSpecified: false,
|
||||
json: false,
|
||||
help: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSnapshotArgs(argv) {
|
||||
const options = optionDefaults();
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
options.help = true;
|
||||
} else if (arg === "--json") {
|
||||
options.json = true;
|
||||
} else if (arg === "--job") {
|
||||
options.job = readRequiredValue(argv, index, arg);
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--job=")) {
|
||||
options.job = arg.slice("--job=".length);
|
||||
} else if (arg === "--input") {
|
||||
options.input = readRequiredValue(argv, index, arg);
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--input=")) {
|
||||
options.input = arg.slice("--input=".length);
|
||||
} else if (arg === "--output" || arg === "-o") {
|
||||
options.output = readRequiredValue(argv, index, arg);
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--output=")) {
|
||||
options.output = arg.slice("--output=".length);
|
||||
} else if (arg === "--mode") {
|
||||
options.mode = readRequiredValue(argv, index, arg);
|
||||
options.modeSpecified = true;
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--mode=")) {
|
||||
options.mode = arg.slice("--mode=".length);
|
||||
options.modeSpecified = true;
|
||||
} else if (arg === "--appearance") {
|
||||
options.appearance = readRequiredValue(argv, index, arg);
|
||||
options.appearanceSpecified = true;
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--appearance=")) {
|
||||
options.appearance = arg.slice("--appearance=".length);
|
||||
options.appearanceSpecified = true;
|
||||
} else if (arg === "--camera") {
|
||||
options.camera = readRequiredValue(argv, index, arg);
|
||||
options.cameraSpecified = true;
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--camera=")) {
|
||||
options.camera = arg.slice("--camera=".length);
|
||||
options.cameraSpecified = true;
|
||||
} else if (arg === "--size-profile") {
|
||||
options.sizeProfile = readRequiredValue(argv, index, arg);
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--size-profile=")) {
|
||||
options.sizeProfile = arg.slice("--size-profile=".length);
|
||||
} else if (arg === "--width") {
|
||||
options.width = positiveInteger(readRequiredValue(argv, index, arg), arg);
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--width=")) {
|
||||
options.width = positiveInteger(arg.slice("--width=".length), "--width");
|
||||
} else if (arg === "--height") {
|
||||
options.height = positiveInteger(readRequiredValue(argv, index, arg), arg);
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--height=")) {
|
||||
options.height = positiveInteger(arg.slice("--height=".length), "--height");
|
||||
} else if (arg === "--params") {
|
||||
options.params = readRequiredValue(argv, index, arg);
|
||||
options.paramsSpecified = true;
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--params=")) {
|
||||
options.params = arg.slice("--params=".length);
|
||||
options.paramsSpecified = true;
|
||||
} else if (arg === "--graphics") {
|
||||
options.graphics = readRequiredValue(argv, index, arg);
|
||||
options.graphicsSpecified = true;
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--graphics=")) {
|
||||
options.graphics = arg.slice("--graphics=".length);
|
||||
options.graphicsSpecified = true;
|
||||
} else {
|
||||
throw new SnapshotError(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function loadJsonText(text, sourceLabel) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
throw new SnapshotError(`Failed to parse JSON from ${sourceLabel}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseCameraOption(rawCamera) {
|
||||
const camera = String(rawCamera || "").trim();
|
||||
if (!camera) {
|
||||
throw new SnapshotError("--camera requires a preset, azimuth:elevation pair, or JSON camera object");
|
||||
}
|
||||
if (!camera.startsWith("{")) {
|
||||
return camera;
|
||||
}
|
||||
const parsed = loadJsonText(camera, "--camera");
|
||||
if (!isPlainObject(parsed)) {
|
||||
throw new SnapshotError("--camera must be a preset, azimuth:elevation pair, or JSON object");
|
||||
}
|
||||
return { ...parsed };
|
||||
}
|
||||
|
||||
function parseParamsOption(rawParams) {
|
||||
const parsed = loadJsonText(String(rawParams || ""), "--params");
|
||||
if (!isPlainObject(parsed)) {
|
||||
throw new SnapshotError("--params must be an implicit parameter JSON object");
|
||||
}
|
||||
return { ...parsed };
|
||||
}
|
||||
|
||||
function validateDirectSettingsPayload(parsed, {
|
||||
optionName,
|
||||
sourceLabel,
|
||||
allowedKeys,
|
||||
settingLabel,
|
||||
}) {
|
||||
if (!isPlainObject(parsed)) {
|
||||
throw new SnapshotError(`${optionName} JSON must be a ${settingLabel} object: ${sourceLabel}`);
|
||||
}
|
||||
const unknownKeys = Object.keys(parsed).filter((key) => !allowedKeys.has(key));
|
||||
if (unknownKeys.length) {
|
||||
throw new SnapshotError(
|
||||
`${optionName} JSON must be the ${settingLabel} object directly; unsupported keys: ${unknownKeys.join(", ")}`
|
||||
);
|
||||
}
|
||||
if (!Object.keys(parsed).length) {
|
||||
throw new SnapshotError(`${optionName} JSON must include at least one ${settingLabel} field: ${sourceLabel}`);
|
||||
}
|
||||
return { ...parsed };
|
||||
}
|
||||
|
||||
function resolveMaybeRelative(rawPath, cwd) {
|
||||
return path.isAbsolute(rawPath) ? path.resolve(rawPath) : path.resolve(cwd, rawPath);
|
||||
}
|
||||
|
||||
function loadAppearanceOption(rawAppearance, { cwd }) {
|
||||
const appearance = String(rawAppearance || DEFAULT_RENDER_THEME_ID).trim() || DEFAULT_RENDER_THEME_ID;
|
||||
if (appearance.startsWith("{")) {
|
||||
return validateDirectSettingsPayload(loadJsonText(appearance, "--appearance"), {
|
||||
optionName: "--appearance",
|
||||
sourceLabel: "--appearance",
|
||||
allowedKeys: APPEARANCE_OPTION_KEYS,
|
||||
settingLabel: "appearance settings",
|
||||
});
|
||||
}
|
||||
|
||||
const appearancePath = resolveMaybeRelative(appearance, cwd);
|
||||
const looksLikeFile = appearance.toLowerCase().endsWith(".json") || appearance.includes("/") || appearance.includes("\\");
|
||||
if (!looksLikeFile && !fs.existsSync(appearancePath)) {
|
||||
return appearance;
|
||||
}
|
||||
if (!fs.existsSync(appearancePath)) {
|
||||
throw new SnapshotError(`Appearance JSON file does not exist: ${appearance}`);
|
||||
}
|
||||
return validateDirectSettingsPayload(loadJsonText(fs.readFileSync(appearancePath, "utf8"), appearancePath), {
|
||||
optionName: "--appearance",
|
||||
sourceLabel: appearancePath,
|
||||
allowedKeys: APPEARANCE_OPTION_KEYS,
|
||||
settingLabel: "appearance settings",
|
||||
});
|
||||
}
|
||||
|
||||
function loadGraphicsOption(rawGraphics, { cwd }) {
|
||||
const graphics = String(rawGraphics || "").trim();
|
||||
if (!graphics) {
|
||||
throw new SnapshotError("--graphics requires a JSON object or JSON file path");
|
||||
}
|
||||
if (graphics.startsWith("{")) {
|
||||
return validateDirectSettingsPayload(loadJsonText(graphics, "--graphics"), {
|
||||
optionName: "--graphics",
|
||||
sourceLabel: "--graphics",
|
||||
allowedKeys: GRAPHICS_OPTION_KEYS,
|
||||
settingLabel: "implicit graphics settings",
|
||||
});
|
||||
}
|
||||
|
||||
const graphicsPath = resolveMaybeRelative(graphics, cwd);
|
||||
if (!fs.existsSync(graphicsPath)) {
|
||||
throw new SnapshotError(`Graphics JSON file does not exist: ${graphics}`);
|
||||
}
|
||||
return validateDirectSettingsPayload(loadJsonText(fs.readFileSync(graphicsPath, "utf8"), graphicsPath), {
|
||||
optionName: "--graphics",
|
||||
sourceLabel: graphicsPath,
|
||||
allowedKeys: GRAPHICS_OPTION_KEYS,
|
||||
settingLabel: "implicit graphics settings",
|
||||
});
|
||||
}
|
||||
|
||||
function cloneJson(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function applyOptionOverridesToJob(job, options, { cwd }) {
|
||||
if (!isPlainObject(job)) {
|
||||
return job;
|
||||
}
|
||||
if (!options.modeSpecified &&
|
||||
!options.appearanceSpecified &&
|
||||
!options.cameraSpecified &&
|
||||
!options.sizeProfile &&
|
||||
!options.width &&
|
||||
!options.height &&
|
||||
!options.paramsSpecified &&
|
||||
!options.graphicsSpecified) {
|
||||
return job;
|
||||
}
|
||||
const nextJob = cloneJson(job);
|
||||
if (options.modeSpecified) {
|
||||
nextJob.mode = options.mode;
|
||||
}
|
||||
if (options.appearanceSpecified) {
|
||||
nextJob.appearance = loadAppearanceOption(options.appearance, { cwd });
|
||||
}
|
||||
if (options.cameraSpecified) {
|
||||
nextJob.camera = parseCameraOption(options.camera);
|
||||
}
|
||||
if (options.width) {
|
||||
nextJob.width = options.width;
|
||||
}
|
||||
if (options.height) {
|
||||
nextJob.height = options.height;
|
||||
}
|
||||
if (options.sizeProfile) {
|
||||
nextJob.render = {
|
||||
...(isPlainObject(nextJob.render) ? nextJob.render : {}),
|
||||
sizeProfile: options.sizeProfile,
|
||||
};
|
||||
}
|
||||
if (options.paramsSpecified) {
|
||||
nextJob.implicitParameters = parseParamsOption(options.params);
|
||||
}
|
||||
if (options.graphicsSpecified) {
|
||||
nextJob.graphics = loadGraphicsOption(options.graphics, { cwd });
|
||||
}
|
||||
return nextJob;
|
||||
}
|
||||
|
||||
async function readStream(stream) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let text = "";
|
||||
stream.setEncoding("utf8");
|
||||
stream.on("data", (chunk) => {
|
||||
text += chunk;
|
||||
});
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve(text));
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadJobFromOptions(options, {
|
||||
stdin = process.stdin,
|
||||
cwd = process.cwd(),
|
||||
} = {}) {
|
||||
const resolvedCwd = path.resolve(cwd);
|
||||
if (options.job) {
|
||||
const text = options.job === "-"
|
||||
? await readStream(stdin)
|
||||
: fs.readFileSync(resolveMaybeRelative(options.job, resolvedCwd), "utf8");
|
||||
const sourceLabel = options.job === "-" ? "stdin" : resolveMaybeRelative(options.job, resolvedCwd);
|
||||
const payload = loadJsonText(text, sourceLabel);
|
||||
if (Array.isArray(payload)) {
|
||||
return payload.map((job) => applyOptionOverridesToJob(job, options, { cwd: resolvedCwd }));
|
||||
}
|
||||
if (isPlainObject(payload) && Array.isArray(payload.jobs)) {
|
||||
return {
|
||||
...cloneJson(payload),
|
||||
jobs: payload.jobs.map((job) => applyOptionOverridesToJob(job, options, { cwd: resolvedCwd })),
|
||||
};
|
||||
}
|
||||
return applyOptionOverridesToJob(payload, options, { cwd: resolvedCwd });
|
||||
}
|
||||
|
||||
if (!stdin.isTTY && !options.input) {
|
||||
const text = await readStream(stdin);
|
||||
if (text.trim()) {
|
||||
return loadJsonText(text, "stdin");
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.input) {
|
||||
throw new SnapshotError("render requires --job, stdin JSON, or --input");
|
||||
}
|
||||
if (!options.output) {
|
||||
throw new SnapshotError("render shortcut requires --output");
|
||||
}
|
||||
|
||||
const output = {
|
||||
path: options.output,
|
||||
camera: parseCameraOption(options.camera),
|
||||
};
|
||||
if (options.width) {
|
||||
output.width = options.width;
|
||||
}
|
||||
if (options.height) {
|
||||
output.height = options.height;
|
||||
}
|
||||
let mode = String(options.mode || "").trim().toLowerCase();
|
||||
if (!mode) {
|
||||
mode = options.output.toLowerCase().endsWith(".gif") ? "orbit" : "view";
|
||||
}
|
||||
const job = {
|
||||
input: options.input,
|
||||
mode,
|
||||
outputs: [output],
|
||||
appearance: loadAppearanceOption(options.appearance, { cwd: resolvedCwd }),
|
||||
};
|
||||
if (options.sizeProfile) {
|
||||
job.render = { sizeProfile: options.sizeProfile };
|
||||
}
|
||||
if (options.paramsSpecified) {
|
||||
job.implicitParameters = parseParamsOption(options.params);
|
||||
}
|
||||
if (options.graphicsSpecified) {
|
||||
job.graphics = loadGraphicsOption(options.graphics, { cwd: resolvedCwd });
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
function pathIsInsideOrEqual(child, parent) {
|
||||
const resolvedChild = path.resolve(child);
|
||||
const resolvedParent = path.resolve(parent);
|
||||
const relative = path.relative(resolvedParent, resolvedChild);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function isImplicitPath(filePath) {
|
||||
const name = path.basename(filePath).toLowerCase();
|
||||
return name.endsWith(".implicit.js") || name.endsWith(".implicit.mjs");
|
||||
}
|
||||
|
||||
function resolveInputPath(rawInput, { cwd }) {
|
||||
const inputText = String(rawInput || "").trim();
|
||||
if (!inputText) {
|
||||
throw new SnapshotError("render job is missing input");
|
||||
}
|
||||
const selected = resolveMaybeRelative(inputText, cwd);
|
||||
if (!fs.existsSync(selected)) {
|
||||
throw new SnapshotError(`Render input does not exist: ${inputText}`);
|
||||
}
|
||||
if (!isImplicitPath(selected)) {
|
||||
throw new SnapshotError("Snapshot supports only .implicit.js and .implicit.mjs inputs");
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function encodePathParam(value) {
|
||||
return value.replaceAll(path.sep, "/").split("/").map(encodeURIComponent).join("/");
|
||||
}
|
||||
|
||||
function assetUrlForPath(filePath, rootPath) {
|
||||
if (!pathIsInsideOrEqual(filePath, rootPath)) {
|
||||
throw new SnapshotError(`Render asset must be inside the snapshot render root: ${filePath}`);
|
||||
}
|
||||
const stat = fs.statSync(filePath);
|
||||
const versionSource = `${path.resolve(filePath)}:${stat.mtimeMs}:${stat.size}`;
|
||||
const version = Buffer.from(versionSource).toString("base64url");
|
||||
return `/__render_asset/${encodePathParam(path.relative(path.resolve(rootPath), path.resolve(filePath)))}?v=${version}`;
|
||||
}
|
||||
|
||||
export function snapshotTimestamp() {
|
||||
return new Date().toISOString().replace(/[-:]/gu, "").replace(/\.\d{3}Z$/u, "Z");
|
||||
}
|
||||
|
||||
export function timestampOutputPath(outputPath, timestamp) {
|
||||
if (!outputPath) {
|
||||
return "";
|
||||
}
|
||||
const parsed = path.parse(outputPath);
|
||||
return path.join(parsed.dir, `${parsed.name}_${timestamp}${parsed.ext}`);
|
||||
}
|
||||
|
||||
function normalizeSnapshotJobPacket(rawPayload) {
|
||||
if (Array.isArray(rawPayload)) {
|
||||
return { single: false, jobs: rawPayload };
|
||||
}
|
||||
if (isPlainObject(rawPayload) && Array.isArray(rawPayload.jobs)) {
|
||||
return { single: false, jobs: rawPayload.jobs };
|
||||
}
|
||||
return { single: true, jobs: [rawPayload] };
|
||||
}
|
||||
|
||||
function appearanceThemeIdForJob(job) {
|
||||
const appearance = job.appearance;
|
||||
return typeof appearance === "string"
|
||||
? (appearance.trim().toLowerCase() || DEFAULT_RENDER_THEME_ID)
|
||||
: DEFAULT_RENDER_THEME_ID;
|
||||
}
|
||||
|
||||
function normalizeSizeProfile(value) {
|
||||
return String(value || "").trim().toLowerCase().replaceAll("_", "-");
|
||||
}
|
||||
|
||||
function explicitSizeProfile(job, output) {
|
||||
const render = isPlainObject(job.render) ? job.render : {};
|
||||
return normalizeSizeProfile(output.sizeProfile || render.sizeProfile || job.sizeProfile || "");
|
||||
}
|
||||
|
||||
function defaultRenderSize(job, output) {
|
||||
const mode = String(job.mode || "view").trim().toLowerCase();
|
||||
const profile = explicitSizeProfile(job, output);
|
||||
if (profile === "simple-square" || profile === "square") {
|
||||
return [SIMPLE_SQUARE_RENDER_WIDTH, SIMPLE_SQUARE_RENDER_HEIGHT];
|
||||
}
|
||||
if (["simple", "simple-part", "unlabeled"].includes(profile)) {
|
||||
return [SIMPLE_RENDER_WIDTH, SIMPLE_RENDER_HEIGHT];
|
||||
}
|
||||
if (["presentation-large", "hero", "large-presentation"].includes(profile)) {
|
||||
return [PRESENTATION_LARGE_RENDER_WIDTH, PRESENTATION_LARGE_RENDER_HEIGHT];
|
||||
}
|
||||
if (profile === "presentation") {
|
||||
return [PRESENTATION_RENDER_WIDTH, PRESENTATION_RENDER_HEIGHT];
|
||||
}
|
||||
if (["complex-assembly-large", "assembly-large"].includes(profile)) {
|
||||
return [COMPLEX_ASSEMBLY_LARGE_RENDER_WIDTH, COMPLEX_ASSEMBLY_LARGE_RENDER_HEIGHT];
|
||||
}
|
||||
if (["complex-assembly", "assembly"].includes(profile)) {
|
||||
return [COMPLEX_ASSEMBLY_RENDER_WIDTH, COMPLEX_ASSEMBLY_RENDER_HEIGHT];
|
||||
}
|
||||
if (profile === "contact-sheet" || profile === "contactsheet") {
|
||||
return [CONTACT_SHEET_RENDER_WIDTH, CONTACT_SHEET_RENDER_HEIGHT];
|
||||
}
|
||||
if (profile === "orbit" || mode === "orbit" || mode === "animate" || isPlainObject(job.implicitAnimation)) {
|
||||
return [ORBIT_RENDER_WIDTH, ORBIT_RENDER_HEIGHT];
|
||||
}
|
||||
if (["dimensioned", "section", "labeled", "diagnostic"].includes(profile) ||
|
||||
WORKBENCH_RENDER_THEME_IDS.has(appearanceThemeIdForJob(job))) {
|
||||
return [DIAGNOSTIC_RENDER_WIDTH, DIAGNOSTIC_RENDER_HEIGHT];
|
||||
}
|
||||
return [SIMPLE_RENDER_WIDTH, SIMPLE_RENDER_HEIGHT];
|
||||
}
|
||||
|
||||
function resolveOutputSize(job, output) {
|
||||
const [defaultWidth, defaultHeight] = defaultRenderSize(job, output);
|
||||
return [
|
||||
positiveInteger(output.width || job.width || defaultWidth, "output width"),
|
||||
positiveInteger(output.height || job.height || defaultHeight, "output height"),
|
||||
];
|
||||
}
|
||||
|
||||
export function resolveRenderJob(rawJob, {
|
||||
cwd = process.cwd(),
|
||||
timestamp = snapshotTimestamp(),
|
||||
} = {}) {
|
||||
if (!isPlainObject(rawJob)) {
|
||||
throw new SnapshotError("render job must be an object");
|
||||
}
|
||||
const job = cloneJson(rawJob);
|
||||
if ("theme" in job) {
|
||||
throw new SnapshotError("render jobs use appearance; theme is reserved for saved appearance settings");
|
||||
}
|
||||
if ("params" in job) {
|
||||
throw new SnapshotError("render jobs use implicitParameters; params is reserved for shortcut --params parsing");
|
||||
}
|
||||
const mode = String(job.mode || "view").trim().toLowerCase();
|
||||
if (!["view", "orbit", "animate"].includes(mode)) {
|
||||
throw new SnapshotError("Implicit CAD snapshot supports only view, orbit, and animate modes");
|
||||
}
|
||||
const renderSettings = isPlainObject(job.render) ? { ...job.render } : {};
|
||||
const graphicsSettings = isPlainObject(job.graphics) ? { ...job.graphics } : {};
|
||||
const appearanceSettings = job.appearance || DEFAULT_RENDER_THEME_ID;
|
||||
const resolvedCwd = path.resolve(cwd);
|
||||
const inputPath = resolveInputPath(job.input, { cwd: resolvedCwd });
|
||||
const rootPath = path.dirname(inputPath);
|
||||
const outputs = Array.isArray(job.outputs) ? job.outputs : [];
|
||||
if (!outputs.length) {
|
||||
throw new SnapshotError("render job must include outputs");
|
||||
}
|
||||
const normalizedOutputs = outputs.map((output) => {
|
||||
const outputObject = isPlainObject(output) ? { ...output } : {};
|
||||
const [width, height] = resolveOutputSize({
|
||||
...job,
|
||||
mode,
|
||||
appearance: appearanceSettings,
|
||||
render: renderSettings,
|
||||
}, outputObject);
|
||||
const outputPath = String(outputObject.path || "");
|
||||
const timestampedOutputPath = timestampOutputPath(outputPath, timestamp);
|
||||
return {
|
||||
...outputObject,
|
||||
path: timestampedOutputPath ? path.resolve(resolvedCwd, timestampedOutputPath) : "",
|
||||
width,
|
||||
height,
|
||||
camera: outputObject.camera || job.camera || "iso",
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...job,
|
||||
mode,
|
||||
appearance: appearanceSettings,
|
||||
graphics: graphicsSettings,
|
||||
render: renderSettings,
|
||||
outputs: normalizedOutputs,
|
||||
resolved: {
|
||||
rootPath,
|
||||
inputPath,
|
||||
inputUrl: assetUrlForPath(inputPath, rootPath),
|
||||
kind: "implicit",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveRenderJobPacket(rawPayload, {
|
||||
cwd = process.cwd(),
|
||||
timestamp = snapshotTimestamp(),
|
||||
} = {}) {
|
||||
const { single, jobs } = normalizeSnapshotJobPacket(rawPayload);
|
||||
return {
|
||||
single,
|
||||
jobs: jobs.map((job) => resolveRenderJob(job, { cwd, timestamp })),
|
||||
};
|
||||
}
|
||||
|
||||
function contentTypeForPath(filePath) {
|
||||
const suffix = path.extname(filePath).toLowerCase();
|
||||
if (suffix === ".mjs" || suffix === ".js") {
|
||||
return "text/javascript; charset=utf-8";
|
||||
}
|
||||
if (suffix === ".html") {
|
||||
return "text/html; charset=utf-8";
|
||||
}
|
||||
if (suffix === ".json") {
|
||||
return "application/json; charset=utf-8";
|
||||
}
|
||||
if (suffix === ".png") {
|
||||
return "image/png";
|
||||
}
|
||||
if (suffix === ".gif") {
|
||||
return "image/gif";
|
||||
}
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
function routeFile(pathname, prefix, root) {
|
||||
const relativePath = decodeURIComponent(pathname.slice(prefix.length));
|
||||
const filePath = path.resolve(root, relativePath.replace(/^\/+/u, ""));
|
||||
if (!pathIsInsideOrEqual(filePath, root)) {
|
||||
throw new RouteFileError(`forbidden route path: ${pathname}`, { status: 403 });
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function resolveSnapshotRouteFile(rawUrl, { activeRootPath = null } = {}) {
|
||||
const parsed = new URL(rawUrl);
|
||||
if (parsed.origin !== SNAPSHOT_ORIGIN) {
|
||||
throw new RouteFileError(`unsupported snapshot origin: ${parsed.origin}`, { status: 403 });
|
||||
}
|
||||
if (parsed.pathname === "/render.html") {
|
||||
return RENDER_HTML_PATH;
|
||||
}
|
||||
if (parsed.pathname.startsWith("/src/")) {
|
||||
return routeFile(parsed.pathname, "/src/", path.join(PACKAGE_ROOT, "src"));
|
||||
}
|
||||
if (parsed.pathname.startsWith("/node_modules/")) {
|
||||
return routeFile(parsed.pathname, "/node_modules/", path.join(PACKAGE_ROOT, "node_modules"));
|
||||
}
|
||||
if (parsed.pathname.startsWith("/__render_asset/")) {
|
||||
if (!activeRootPath) {
|
||||
throw new RouteFileError("snapshot render asset requested without an active render root");
|
||||
}
|
||||
return routeFile(parsed.pathname, "/__render_asset/", activeRootPath);
|
||||
}
|
||||
throw new RouteFileError(`snapshot route not found: ${parsed.pathname}`);
|
||||
}
|
||||
|
||||
function maxOutputSize(job) {
|
||||
const outputs = Array.isArray(job.outputs) && job.outputs.length ? job.outputs : [];
|
||||
const widths = outputs.filter(isPlainObject).map((output) => Number(output.width || DEFAULT_RENDER_WIDTH));
|
||||
const heights = outputs.filter(isPlainObject).map((output) => Number(output.height || DEFAULT_RENDER_HEIGHT));
|
||||
return [
|
||||
Math.max(...(widths.length ? widths : [DEFAULT_RENDER_WIDTH])),
|
||||
Math.max(...(heights.length ? heights : [DEFAULT_RENDER_HEIGHT])),
|
||||
];
|
||||
}
|
||||
|
||||
async function withSnapshotTimeout(promise, timeoutSeconds, label = "snapshot") {
|
||||
const timeout = Math.max(1, Number(timeoutSeconds || DEFAULT_TIMEOUT_SECONDS));
|
||||
let timer = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new SnapshotError(`${label} timed out after ${timeoutSeconds}s`)), timeout * 1000);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function chromiumLaunchOptions() {
|
||||
return {
|
||||
headless: true,
|
||||
timeout: RENDER_BROWSER_STARTUP_TIMEOUT_MS,
|
||||
args: [],
|
||||
};
|
||||
}
|
||||
|
||||
const PAGE_ISSUE_MAX_LENGTH = 2000;
|
||||
|
||||
function pageIssueText(prefix, text) {
|
||||
const message = String(text || "").trim();
|
||||
const truncated = message.length > PAGE_ISSUE_MAX_LENGTH
|
||||
? `${message.slice(0, PAGE_ISSUE_MAX_LENGTH)}… [truncated]`
|
||||
: message;
|
||||
return `${prefix}: ${truncated}`;
|
||||
}
|
||||
|
||||
class BatchSnapshotRenderer {
|
||||
constructor() {
|
||||
this.browser = null;
|
||||
this.context = null;
|
||||
this.page = null;
|
||||
this.activeRootPath = null;
|
||||
this.started = false;
|
||||
this.pageIssues = [];
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.started) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { chromium } = await import("playwright").catch((error) => {
|
||||
throw new SnapshotError(
|
||||
`Implicit CAD snapshot requires the JavaScript playwright package. Install implicitjs dependencies and run playwright install chromium if needed. ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
});
|
||||
this.browser = await chromium.launch(chromiumLaunchOptions());
|
||||
this.context = await this.browser.newContext({
|
||||
viewport: { width: DEFAULT_RENDER_WIDTH, height: DEFAULT_RENDER_HEIGHT },
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
this.page = await this.context.newPage();
|
||||
// THREE.js reports shader compile failures through console.error inside
|
||||
// the page; without these hooks a broken shader renders a blank image
|
||||
// with no diagnostic at all.
|
||||
this.page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
this.pageIssues.push(pageIssueText("page console error", message.text()));
|
||||
}
|
||||
});
|
||||
this.page.on("pageerror", (error) => {
|
||||
this.pageIssues.push(pageIssueText("page error", error instanceof Error ? error.message : error));
|
||||
});
|
||||
await this.page.route(SNAPSHOT_ROUTE_GLOB, (route) => this.handleRoute(route));
|
||||
await this.page.goto(SNAPSHOT_RENDER_URL, {
|
||||
waitUntil: "load",
|
||||
timeout: DEFAULT_TIMEOUT_SECONDS * 1000,
|
||||
});
|
||||
await this.page.waitForFunction(
|
||||
"typeof window.__implicitCadSnapshotRender === 'function'",
|
||||
null,
|
||||
{ timeout: DEFAULT_TIMEOUT_SECONDS * 1000 }
|
||||
);
|
||||
this.started = true;
|
||||
} catch (error) {
|
||||
await this.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async handleRoute(route) {
|
||||
const request = route.request();
|
||||
if (request.method() !== "GET") {
|
||||
await route.fulfill({ status: 405, contentType: "text/plain; charset=utf-8", body: "method not allowed" });
|
||||
return;
|
||||
}
|
||||
let filePath = "";
|
||||
try {
|
||||
filePath = resolveSnapshotRouteFile(request.url(), { activeRootPath: this.activeRootPath });
|
||||
} catch (error) {
|
||||
await route.fulfill({
|
||||
status: error instanceof RouteFileError ? error.status : 500,
|
||||
contentType: "text/plain; charset=utf-8",
|
||||
body: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
||||
await route.fulfill({ status: 404, contentType: "text/plain; charset=utf-8", body: "not found" });
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: contentTypeForPath(filePath),
|
||||
headers: { "cache-control": "no-store" },
|
||||
body: fs.readFileSync(filePath),
|
||||
});
|
||||
}
|
||||
|
||||
async render(job) {
|
||||
await this.start();
|
||||
const resolved = isPlainObject(job.resolved) ? job.resolved : {};
|
||||
this.activeRootPath = path.resolve(String(resolved.rootPath || ""));
|
||||
const [width, height] = maxOutputSize(job);
|
||||
await this.page.setViewportSize({ width, height });
|
||||
const issueStart = this.pageIssues.length;
|
||||
const result = await withSnapshotTimeout(
|
||||
this.page.evaluate((renderJob) => window.__implicitCadSnapshotRender(renderJob), { ...job }),
|
||||
job.timeoutSeconds || DEFAULT_TIMEOUT_SECONDS
|
||||
);
|
||||
const issues = [...new Set(this.pageIssues.slice(issueStart))];
|
||||
if (!isPlainObject(result) || !result.ok) {
|
||||
const failure = String(isPlainObject(result) ? result.error || "" : "") || "unknown browser snapshot failure";
|
||||
throw new SnapshotError([failure, ...issues].join("\n"));
|
||||
}
|
||||
if (issues.length) {
|
||||
result.warnings = [...(Array.isArray(result.warnings) ? result.warnings : []), ...issues];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.context) {
|
||||
try {
|
||||
await this.context.close();
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
this.context = null;
|
||||
}
|
||||
if (this.browser) {
|
||||
try {
|
||||
await this.browser.close();
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
this.browser = null;
|
||||
}
|
||||
this.page = null;
|
||||
this.started = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function renderResolvedJobPacket(packet) {
|
||||
const renderer = new BatchSnapshotRenderer();
|
||||
const started = performance.now();
|
||||
const results = [];
|
||||
try {
|
||||
for (const job of packet.jobs) {
|
||||
const result = await renderer.render(job);
|
||||
results.push(packet.single ? result : { input: job.input, ...result });
|
||||
}
|
||||
} finally {
|
||||
await renderer.close();
|
||||
}
|
||||
if (packet.single) {
|
||||
return results[0];
|
||||
}
|
||||
return {
|
||||
ok: results.every((result) => result.ok !== false),
|
||||
jobs: results,
|
||||
timings: {
|
||||
jobCount: results.length,
|
||||
totalMs: performance.now() - started,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function writeOutputPayload(output) {
|
||||
const outputPath = String(output.path || "");
|
||||
if (!outputPath) {
|
||||
return;
|
||||
}
|
||||
const dataUrl = String(output.dataUrl || "");
|
||||
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/su);
|
||||
if (!match) {
|
||||
throw new SnapshotError(`Snapshot output did not include a base64 data URL: ${outputPath}`);
|
||||
}
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, Buffer.from(match[2], "base64"));
|
||||
}
|
||||
|
||||
export function writeRenderOutputs(result) {
|
||||
if (Array.isArray(result.jobs)) {
|
||||
for (const jobResult of result.jobs) {
|
||||
if (isPlainObject(jobResult)) {
|
||||
writeRenderOutputs(jobResult);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
const outputs = Array.isArray(result.outputs) ? result.outputs : [];
|
||||
for (const output of outputs) {
|
||||
if (isPlainObject(output)) {
|
||||
writeOutputPayload(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function printRenderResult(result, { jsonOutput = false, stdout = process.stdout } = {}) {
|
||||
if (jsonOutput) {
|
||||
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(result.jobs)) {
|
||||
for (const jobResult of result.jobs) {
|
||||
if (isPlainObject(jobResult)) {
|
||||
printRenderResult(jobResult, { jsonOutput: false, stdout });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
const outputs = Array.isArray(result.outputs) ? result.outputs : null;
|
||||
if (!outputs) {
|
||||
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
for (const output of outputs) {
|
||||
if (isPlainObject(output) && output.path) {
|
||||
stdout.write(`saved snapshot: ${output.path}\n`);
|
||||
}
|
||||
}
|
||||
const warnings = Array.isArray(result.warnings) ? result.warnings : [];
|
||||
for (const warning of warnings) {
|
||||
stdout.write(`snapshot warning: ${warning}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runRenderCli(argv, {
|
||||
cwd = process.cwd(),
|
||||
stdin = process.stdin,
|
||||
stdout = process.stdout,
|
||||
stderr = process.stderr,
|
||||
} = {}) {
|
||||
try {
|
||||
const options = parseSnapshotArgs(argv);
|
||||
if (options.help) {
|
||||
stdout.write(helpText());
|
||||
return 0;
|
||||
}
|
||||
const rawPayload = await loadJobFromOptions(options, { stdin, cwd });
|
||||
const packet = resolveRenderJobPacket(rawPayload, { cwd });
|
||||
const result = await renderResolvedJobPacket(packet);
|
||||
writeRenderOutputs(result);
|
||||
printRenderResult(result, { jsonOutput: options.json, stdout });
|
||||
return 0;
|
||||
} catch (error) {
|
||||
stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
runRenderCli(process.argv.slice(2)).then((status) => {
|
||||
process.exitCode = status;
|
||||
});
|
||||
}
|
||||
|
||||
export const snapshotCliModuleUrl = pathToFileURL(fileURLToPath(import.meta.url)).href;
|
||||
@@ -0,0 +1,265 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
PACKAGE_ROOT,
|
||||
RENDER_HTML_PATH,
|
||||
SNAPSHOT_ORIGIN,
|
||||
SnapshotError,
|
||||
chromiumLaunchOptions,
|
||||
loadJobFromOptions,
|
||||
parseSnapshotArgs,
|
||||
printRenderResult,
|
||||
resolveRenderJobPacket,
|
||||
resolveSnapshotRouteFile,
|
||||
timestampOutputPath,
|
||||
} from "./snapshot.mjs";
|
||||
|
||||
const ttyStdin = { isTTY: true };
|
||||
|
||||
function withTempImplicitModel(callback) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "implicitjs-snapshot-"));
|
||||
try {
|
||||
const models = path.join(root, "models", "implicit-cad");
|
||||
fs.mkdirSync(models, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(models, "orb.implicit.js"),
|
||||
"export default {glsl: 'float sdf(vec3 p) { return length(p) - 1.0; }'};\n",
|
||||
"utf8"
|
||||
);
|
||||
return callback({ root, models });
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("shortcut job shape is built by the JavaScript snapshot CLI", async () => {
|
||||
const options = parseSnapshotArgs([
|
||||
"--input",
|
||||
"models/implicit-cad/orb.implicit.js",
|
||||
"--output",
|
||||
"tmp/orb.png",
|
||||
"--camera",
|
||||
"front",
|
||||
"--width",
|
||||
"640",
|
||||
"--height",
|
||||
"480",
|
||||
"--params",
|
||||
"{\"radius\":24}",
|
||||
"--appearance",
|
||||
"workbench",
|
||||
"--graphics",
|
||||
"{\"detail\":2,\"resolutionScale\":3}",
|
||||
"--size-profile",
|
||||
"simple",
|
||||
]);
|
||||
|
||||
const job = await loadJobFromOptions(options, { stdin: ttyStdin, cwd: process.cwd() });
|
||||
|
||||
assert.equal(job.input, "models/implicit-cad/orb.implicit.js");
|
||||
assert.equal(job.mode, "view");
|
||||
assert.equal(job.outputs[0].path, "tmp/orb.png");
|
||||
assert.equal(job.outputs[0].camera, "front");
|
||||
assert.equal(job.outputs[0].width, 640);
|
||||
assert.equal(job.outputs[0].height, 480);
|
||||
assert.equal(job.appearance, "workbench");
|
||||
assert.deepEqual(job.graphics, { detail: 2, resolutionScale: 3 });
|
||||
assert.deepEqual(job.render, { sizeProfile: "simple" });
|
||||
assert.deepEqual(job.implicitParameters, { radius: 24 });
|
||||
});
|
||||
|
||||
test("shortcut accepts JSON camera objects", async () => {
|
||||
const options = parseSnapshotArgs([
|
||||
"--input",
|
||||
"models/implicit-cad/orb.implicit.js",
|
||||
"--output",
|
||||
"tmp/orb.png",
|
||||
"--camera",
|
||||
"{\"position\":[4,-5,3],\"target\":[0,0,0],\"up\":[0,0,1],\"zoom\":1.4}",
|
||||
]);
|
||||
|
||||
const job = await loadJobFromOptions(options, { stdin: ttyStdin, cwd: process.cwd() });
|
||||
|
||||
assert.deepEqual(job.outputs[0].camera, {
|
||||
position: [4, -5, 3],
|
||||
target: [0, 0, 0],
|
||||
up: [0, 0, 1],
|
||||
zoom: 1.4,
|
||||
});
|
||||
});
|
||||
|
||||
test("GIF shortcut defaults to orbit mode", async () => {
|
||||
const options = parseSnapshotArgs([
|
||||
"--input",
|
||||
"models/implicit-cad/orb.implicit.js",
|
||||
"--output",
|
||||
"tmp/orb.gif",
|
||||
]);
|
||||
|
||||
const job = await loadJobFromOptions(options, { stdin: ttyStdin, cwd: process.cwd() });
|
||||
|
||||
assert.equal(job.mode, "orbit");
|
||||
assert.equal(job.outputs[0].path, "tmp/orb.gif");
|
||||
});
|
||||
|
||||
test("render job derives asset root from input parent", () => withTempImplicitModel(({ root, models }) => {
|
||||
const packet = resolveRenderJobPacket({
|
||||
input: "models/implicit-cad/orb.implicit.js",
|
||||
outputs: [{ path: "tmp/orb.png", camera: "iso" }],
|
||||
}, {
|
||||
cwd: root,
|
||||
timestamp: "20260527T163012Z",
|
||||
});
|
||||
|
||||
const job = packet.jobs[0];
|
||||
assert.equal(packet.single, true);
|
||||
assert.equal(job.resolved.kind, "implicit");
|
||||
assert.equal(job.resolved.rootPath, models);
|
||||
assert.match(job.resolved.inputUrl, /^\/__render_asset\/orb\.implicit\.js\?v=/u);
|
||||
assert.equal(job.appearance, "workbench");
|
||||
assert.deepEqual(job.render, {});
|
||||
assert.deepEqual(job.graphics, {});
|
||||
assert.equal(job.outputs[0].width, 1600);
|
||||
assert.equal(job.outputs[0].height, 1200);
|
||||
assert.equal(path.basename(job.outputs[0].path), "orb_20260527T163012Z.png");
|
||||
}));
|
||||
|
||||
test("render job packet supports multi-output review snapshots", () => withTempImplicitModel(({ root }) => {
|
||||
const packet = resolveRenderJobPacket({
|
||||
input: "models/implicit-cad/orb.implicit.js",
|
||||
render: { frameMargin: 1.55 },
|
||||
outputs: [
|
||||
{ path: "tmp/orb-iso.png", camera: "iso" },
|
||||
{ path: "tmp/orb-front.png", camera: "front", width: 900, height: 700 },
|
||||
{ path: "tmp/orb-top.png", camera: "top" },
|
||||
],
|
||||
}, {
|
||||
cwd: root,
|
||||
timestamp: "20260527T163012Z",
|
||||
});
|
||||
|
||||
const job = packet.jobs[0];
|
||||
assert.equal(packet.single, true);
|
||||
assert.equal(job.outputs.length, 3);
|
||||
assert.equal(job.render.frameMargin, 1.55);
|
||||
assert.equal(job.outputs[0].camera, "iso");
|
||||
assert.equal(job.outputs[1].camera, "front");
|
||||
assert.equal(job.outputs[1].width, 900);
|
||||
assert.equal(job.outputs[1].height, 700);
|
||||
assert.equal(job.outputs[2].camera, "top");
|
||||
assert.deepEqual(
|
||||
job.outputs.map((output) => path.basename(output.path)),
|
||||
[
|
||||
"orb-iso_20260527T163012Z.png",
|
||||
"orb-front_20260527T163012Z.png",
|
||||
"orb-top_20260527T163012Z.png",
|
||||
]
|
||||
);
|
||||
}));
|
||||
|
||||
test("render job packet accepts arrays with one shared timestamp", () => withTempImplicitModel(({ root }) => {
|
||||
const packet = resolveRenderJobPacket([
|
||||
{ input: "models/implicit-cad/orb.implicit.js", outputs: [{ path: "tmp/a.png" }] },
|
||||
{ input: "models/implicit-cad/orb.implicit.js", outputs: [{ path: "tmp/b.png" }] },
|
||||
], {
|
||||
cwd: root,
|
||||
timestamp: "20260527T163012Z",
|
||||
});
|
||||
|
||||
assert.equal(packet.single, false);
|
||||
assert.equal(packet.jobs.length, 2);
|
||||
assert.equal(path.basename(packet.jobs[0].outputs[0].path), "a_20260527T163012Z.png");
|
||||
assert.equal(path.basename(packet.jobs[1].outputs[0].path), "b_20260527T163012Z.png");
|
||||
assert.equal(packet.jobs[0].resolved.inputUrl, packet.jobs[1].resolved.inputUrl);
|
||||
}));
|
||||
|
||||
test("render job accepts animation mode", () => withTempImplicitModel(({ root }) => {
|
||||
const packet = resolveRenderJobPacket({
|
||||
input: "models/implicit-cad/orb.implicit.js",
|
||||
mode: "animate",
|
||||
outputs: [{ path: "tmp/orb.gif" }],
|
||||
}, { cwd: root });
|
||||
|
||||
assert.equal(packet.jobs[0].mode, "animate");
|
||||
}));
|
||||
|
||||
test("non implicit input files are rejected", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "implicitjs-snapshot-"));
|
||||
try {
|
||||
const models = path.join(root, "models");
|
||||
fs.mkdirSync(models, { recursive: true });
|
||||
fs.writeFileSync(path.join(models, "part.js"), "export default {};\n", "utf8");
|
||||
|
||||
assert.throws(
|
||||
() => resolveRenderJobPacket({ input: "models/part.js", outputs: [{ path: "tmp/part.png" }] }, { cwd: root }),
|
||||
/only \.implicit\.js and \.implicit\.mjs/u
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("legacy top-level theme and params fields are rejected", () => withTempImplicitModel(({ root }) => {
|
||||
assert.throws(
|
||||
() => resolveRenderJobPacket({
|
||||
input: "models/implicit-cad/orb.implicit.js",
|
||||
theme: "dark",
|
||||
outputs: [{ path: "tmp/orb.png" }],
|
||||
}, { cwd: root }),
|
||||
/use appearance/u
|
||||
);
|
||||
assert.throws(
|
||||
() => resolveRenderJobPacket({
|
||||
input: "models/implicit-cad/orb.implicit.js",
|
||||
params: {},
|
||||
outputs: [{ path: "tmp/orb.png" }],
|
||||
}, { cwd: root }),
|
||||
/use implicitParameters/u
|
||||
);
|
||||
}));
|
||||
|
||||
test("timestamp output path preserves extension", () => {
|
||||
assert.equal(
|
||||
timestampOutputPath("snapshots/review.png", "20260527T163012Z"),
|
||||
"snapshots/review_20260527T163012Z.png"
|
||||
);
|
||||
});
|
||||
|
||||
test("snapshot routes are package-owned and self-contained", () => {
|
||||
assert.equal(resolveSnapshotRouteFile(`${SNAPSHOT_ORIGIN}/render.html`), RENDER_HTML_PATH);
|
||||
assert.equal(
|
||||
resolveSnapshotRouteFile(`${SNAPSHOT_ORIGIN}/src/common/implicitHeadlessRenderEntry.js`),
|
||||
path.join(PACKAGE_ROOT, "src", "common", "implicitHeadlessRenderEntry.js")
|
||||
);
|
||||
assert.equal(
|
||||
resolveSnapshotRouteFile(`${SNAPSHOT_ORIGIN}/src/common/implicitHeadlessRenderEntry.js?v=test`),
|
||||
path.join(PACKAGE_ROOT, "src", "common", "implicitHeadlessRenderEntry.js")
|
||||
);
|
||||
assert.throws(
|
||||
() => resolveSnapshotRouteFile(`${SNAPSHOT_ORIGIN}/__render_asset/orb.implicit.js`),
|
||||
SnapshotError
|
||||
);
|
||||
});
|
||||
|
||||
test("snapshot renderer does not force Chromium single-process mode", () => {
|
||||
assert.notEqual(chromiumLaunchOptions().args?.includes("--single-process"), true);
|
||||
});
|
||||
|
||||
test("render result printing surfaces captured page warnings", () => {
|
||||
const lines = [];
|
||||
const stdout = { write: (text) => lines.push(text) };
|
||||
|
||||
printRenderResult({
|
||||
ok: true,
|
||||
mode: "view",
|
||||
outputs: [{ path: "tmp/orb.png" }],
|
||||
warnings: ["page console error: THREE.WebGLProgram: Shader Error 0 - VALIDATE_STATUS false"],
|
||||
}, { jsonOutput: false, stdout });
|
||||
|
||||
assert.equal(lines[0], "saved snapshot: tmp/orb.png\n");
|
||||
assert.match(lines[1], /^snapshot warning: page console error: THREE\.WebGLProgram/);
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
#!/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);
|
||||
});
|
||||
Reference in New Issue
Block a user