first commit

This commit is contained in:
2026-07-22 13:48:46 +08:00
commit c87751c3dc
2820 changed files with 726976 additions and 0 deletions
+138
View File
@@ -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;
});
+51
View File
@@ -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
View File
@@ -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);
});