first commit
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
# implicitjs
|
||||
|
||||
`implicitjs` is a standalone JavaScript runtime for browser-native implicit CAD
|
||||
models. It defines the `.implicit.js` model schema, builds GLSL raymarch
|
||||
shaders, renders models with Three.js, evaluates SDFs on the CPU, produces
|
||||
headless PNG/GIF snapshots, samples meshes, checks mesh quality, and exports
|
||||
STL, 3MF, and GLB artifacts.
|
||||
|
||||
The package is UI-framework agnostic. Applications provide their own editor,
|
||||
catalog, sidebar, persistence, and product workflow; `implicitjs` provides the
|
||||
model, rendering, snapshot, and export logic.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install implicitjs
|
||||
```
|
||||
|
||||
Peer usage expects a modern ESM JavaScript environment. Rendering APIs require
|
||||
Three.js-compatible WebGL support. The headless snapshot CLI uses Playwright.
|
||||
|
||||
## Model Format
|
||||
|
||||
An implicit model is an ES module that exports an `implicit.js/0.1.0` object.
|
||||
The `glsl` string must provide `float sdf(vec3 p)`. It may also provide
|
||||
`vec3 color(vec3 p, vec3 normal)` for procedural color.
|
||||
|
||||
```js
|
||||
const GLSL = `
|
||||
float sdf(vec3 p) {
|
||||
return implicit_sphere(p, vec3(0.0), radius);
|
||||
}
|
||||
|
||||
vec3 color(vec3 p, vec3 normal) {
|
||||
return mix(vec3(0.0, 0.65, 1.0), vec3(1.0, 0.3, 0.8), normal.z * 0.5 + 0.5);
|
||||
}
|
||||
`;
|
||||
|
||||
export default {
|
||||
schema: "implicit.js/0.1.0",
|
||||
name: "parametric sphere",
|
||||
units: "mm",
|
||||
params: {
|
||||
radius: { type: "number", label: "Radius", min: 5, max: 50, default: 22, unit: "mm" }
|
||||
},
|
||||
bounds: ({ params }) => {
|
||||
const r = params.radius + 2;
|
||||
return [[-r, -r, -r], [r, r, r]];
|
||||
},
|
||||
render: { steps: 192 },
|
||||
glsl: GLSL
|
||||
};
|
||||
```
|
||||
|
||||
Number, boolean, color, and button params automatically become GLSL uniforms
|
||||
with matching names. Use param names directly in GLSL; a separate `uniforms`
|
||||
object is not needed.
|
||||
|
||||
`bounds` is optional and can be estimated from the SDF, but explicit bounds are
|
||||
recommended for unusual fields, thin features, periodic models, animated size
|
||||
changes, or export-heavy workflows.
|
||||
|
||||
Authored GLSL can use built-in helpers in the `implicit_*` namespace, such as
|
||||
`implicit_sphere`, `implicit_box_centered`, `implicit_cylinder_capped`,
|
||||
`implicit_line_segment2`, `implicit_union_round`, and
|
||||
`implicit_intersect_round`.
|
||||
|
||||
## Public APIs
|
||||
|
||||
Common imports:
|
||||
|
||||
```js
|
||||
import {
|
||||
loadImplicitModuleFromSource,
|
||||
normalizeImplicitModel,
|
||||
renderImplicitToDataUrl,
|
||||
snapshotImplicitCadModel,
|
||||
exportImplicitModel,
|
||||
exportImplicitAnimatedGlb
|
||||
} from "implicitjs";
|
||||
```
|
||||
|
||||
Useful subpath exports:
|
||||
|
||||
- `implicitjs/model`: schema normalization and parameterized runtime models.
|
||||
- `implicitjs/loader`: loading `.implicit.js` modules or source strings.
|
||||
- `implicitjs/render`: Three.js shader and camera helpers.
|
||||
- `implicitjs/snapshot`: browser PNG snapshot helpers.
|
||||
- `implicitjs/mesh`: SDF mesh sampling.
|
||||
- `implicitjs/meshQuality`: mesh quality checks.
|
||||
- `implicitjs/export`: STL/3MF/GLB export APIs.
|
||||
- `implicitjs/sdfEvaluator`: CPU SDF evaluator for compatible GLSL.
|
||||
|
||||
## Rendering
|
||||
|
||||
```js
|
||||
import * as THREE from "three";
|
||||
import { loadImplicitModuleFromSource, renderImplicitToDataUrl } from "implicitjs";
|
||||
|
||||
const model = await loadImplicitModuleFromSource(source);
|
||||
const pngDataUrl = await renderImplicitToDataUrl(THREE, model, {
|
||||
width: 1200,
|
||||
height: 900,
|
||||
camera: "iso",
|
||||
render: { frameMargin: 1.45 },
|
||||
graphics: { modelColors: true, detail: 1.2 }
|
||||
});
|
||||
```
|
||||
|
||||
Camera presets include `iso`, `front`, `back`, `left`, `right`, `top`, and
|
||||
`bottom`. Cameras can also be JSON objects with `position`, `target`, `up`,
|
||||
`direction`, `preset`, and `zoom`.
|
||||
|
||||
## Snapshots
|
||||
|
||||
From a package checkout:
|
||||
|
||||
```bash
|
||||
npm run snapshot -- --input examples/model.implicit.js --output /tmp/model.png
|
||||
npm run snapshot -- --input examples/model.implicit.js --output /tmp/model.gif --mode orbit
|
||||
```
|
||||
|
||||
Orbit GIF jobs default to 6 fps over 12 seconds for a calmer review spin
|
||||
without increasing default render frame count.
|
||||
Override with `orbit.fps` and `orbit.durationSeconds` in JSON jobs when needed.
|
||||
|
||||
The snapshot CLI also accepts JSON jobs. A single job can include multiple
|
||||
outputs, and `--job` can load one job, an array of jobs, or `{ "jobs": [...] }`.
|
||||
Prefer one multi-output job when rendering review packets because the browser,
|
||||
module, and runtime model can be reused.
|
||||
|
||||
```bash
|
||||
npm run snapshot -- --job - <<'JSON'
|
||||
{
|
||||
"input": "examples/model.implicit.js",
|
||||
"render": { "frameMargin": 1.55 },
|
||||
"outputs": [
|
||||
{ "path": "/tmp/model-iso.png", "camera": "iso" },
|
||||
{ "path": "/tmp/model-front.png", "camera": "front" },
|
||||
{ "path": "/tmp/model-top.png", "camera": "top" }
|
||||
]
|
||||
}
|
||||
JSON
|
||||
```
|
||||
|
||||
Each output path receives a UTC timestamp before its extension.
|
||||
|
||||
## Exports
|
||||
|
||||
```js
|
||||
import { exportImplicitModel, exportImplicitAnimatedGlb } from "implicitjs/browser";
|
||||
|
||||
const glb = await exportImplicitModel(model, {
|
||||
format: "glb",
|
||||
resolution: 96,
|
||||
params: { radius: 24 }
|
||||
});
|
||||
|
||||
const animated = await exportImplicitAnimatedGlb(model, {
|
||||
animationId: "breathe",
|
||||
params: { radius: 24 },
|
||||
frames: 24,
|
||||
resolution: 72
|
||||
});
|
||||
```
|
||||
|
||||
From a package checkout:
|
||||
|
||||
```bash
|
||||
npm run export -- --input examples/model.implicit.js --format glb --output /tmp/model.glb
|
||||
npm run export -- --input examples/model.implicit.js --format stl --resolution 96
|
||||
npm run export -- --input examples/model.implicit.js --format 3mf --params '{"radius":24}'
|
||||
```
|
||||
|
||||
Exports sample the model's SDF inside its bounds. Higher resolutions produce
|
||||
denser meshes and take longer.
|
||||
|
||||
## Package Layout
|
||||
|
||||
- `src/index.js`: public package entrypoint.
|
||||
- `src/browser.js`: browser-oriented entrypoint.
|
||||
- `src/common/`: shared camera, parameter, theme, render-option, and headless
|
||||
snapshot helpers.
|
||||
- `src/lib/implicitCad/`: schema, model normalization, loading, shader
|
||||
rendering, CPU evaluation, mesh sampling, mesh quality, and exporters.
|
||||
- `src/lib/viewer/`: internal render presentation defaults used by shaders.
|
||||
- `scripts/`: snapshot, export, export-verification, and test CLIs.
|
||||
|
||||
Tests live beside the modules they cover as `*.test.js`.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run verify:exports -- --input examples/model.implicit.js
|
||||
```
|
||||
|
||||
Keep the package reusable and UI-independent. Runtime behavior that applies to
|
||||
model normalization, rendering, snapshots, CPU sampling, mesh export, graphics
|
||||
settings, parameters, and animations belongs here. Product-specific UI state,
|
||||
catalogs, editors, file sheets, routing, and persistence should live in the
|
||||
consuming application.
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"name": "implicitjs",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "implicitjs",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"gifenc": "^1.0.3",
|
||||
"playwright": "^1.52.0",
|
||||
"three": "0.160.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gifenc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/gifenc/-/gifenc-1.0.3.tgz",
|
||||
"integrity": "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.160.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.160.0.tgz",
|
||||
"integrity": "sha512-DLU8lc0zNIPkM7rH5/e1Ks1Z8tWCGRq6g8mPowdDJpw1CFBJMU7UoJjC6PefXW7z//SSl0b2+GCw14LB+uDhng==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "implicitjs",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Standalone browser-native implicit CAD runtime, renderer, mesh sampler, and exporters.",
|
||||
"exports": {
|
||||
".": {
|
||||
"browser": "./src/browser.js",
|
||||
"default": "./src/index.js"
|
||||
},
|
||||
"./browser": "./src/browser.js",
|
||||
"./schema": "./src/lib/implicitCad/schema.js",
|
||||
"./animation": "./src/lib/implicitCad/animation.js",
|
||||
"./model": "./src/lib/implicitCad/model.js",
|
||||
"./implicit": "./src/browser.js",
|
||||
"./loader": "./src/lib/implicitCad/loader.js",
|
||||
"./render": "./src/lib/implicitCad/render.js",
|
||||
"./snapshot": "./src/lib/implicitCad/snapshot.js",
|
||||
"./graphicsSettings": "./src/lib/implicitCad/graphicsSettings.js",
|
||||
"./mesh": "./src/lib/implicitCad/mesh.js",
|
||||
"./meshQuality": "./src/lib/implicitCad/meshQuality.js",
|
||||
"./exportModel": "./src/lib/implicitCad/exportModel.js",
|
||||
"./export": "./src/lib/implicitCad/export.js",
|
||||
"./exporters": "./src/lib/implicitCad/exporters.js",
|
||||
"./sdfEvaluator": "./src/lib/implicitCad/sdfEvaluator.js",
|
||||
"./headlessRenderEntry": "./src/common/implicitHeadlessRenderEntry.js",
|
||||
"./common/*": "./src/common/*",
|
||||
"./lib/implicitCad/*": "./src/lib/implicitCad/*"
|
||||
},
|
||||
"scripts": {
|
||||
"export": "node scripts/export.mjs",
|
||||
"snapshot": "node scripts/snapshot.mjs",
|
||||
"test": "node scripts/run-tests.mjs",
|
||||
"verify:exports": "node scripts/verify-implicit-cad-exports.mjs"
|
||||
},
|
||||
"sideEffects": [
|
||||
"./src/common/implicitHeadlessRenderEntry.js"
|
||||
],
|
||||
"dependencies": {
|
||||
"gifenc": "^1.0.3",
|
||||
"playwright": "^1.52.0",
|
||||
"three": "0.160.0"
|
||||
}
|
||||
}
|
||||
+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);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
export * from "./common/parameters.js";
|
||||
export * from "./lib/implicitCad/schema.js";
|
||||
export * from "./lib/implicitCad/animation.js";
|
||||
export * from "./lib/implicitCad/model.js";
|
||||
export * from "./lib/implicitCad/loader.js";
|
||||
export * from "./lib/implicitCad/graphicsSettings.js";
|
||||
export * from "./lib/implicitCad/render.js";
|
||||
export * from "./lib/implicitCad/snapshot.js";
|
||||
export * from "./lib/implicitCad/sdfEvaluator.js";
|
||||
export * from "./lib/implicitCad/mesh.js";
|
||||
export * from "./lib/implicitCad/meshQuality.js";
|
||||
export * from "./lib/implicitCad/exporters.js";
|
||||
export * from "./lib/implicitCad/exportModel.js";
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
analyzeImplicitMeshQuality,
|
||||
exportImplicitAnimatedGlb,
|
||||
exportImplicitModel,
|
||||
createImplicitMaterial,
|
||||
IMPLICIT_CAD_SCHEMA,
|
||||
loadImplicitSource,
|
||||
normalizeImplicitDefinition,
|
||||
normalizeParameterValue,
|
||||
snapshotImplicitCadModel
|
||||
} from "./browser.js";
|
||||
|
||||
test("browser entry exposes editable-source, parameter, model, and render APIs", () => {
|
||||
assert.equal(typeof loadImplicitSource, "function");
|
||||
assert.equal(IMPLICIT_CAD_SCHEMA, "implicit.js/0.1.0");
|
||||
assert.equal(typeof normalizeImplicitDefinition, "function");
|
||||
assert.equal(typeof normalizeParameterValue, "function");
|
||||
assert.equal(typeof createImplicitMaterial, "function");
|
||||
assert.equal(typeof analyzeImplicitMeshQuality, "function");
|
||||
assert.equal(typeof snapshotImplicitCadModel, "function");
|
||||
assert.equal(typeof exportImplicitModel, "function");
|
||||
assert.equal(typeof exportImplicitAnimatedGlb, "function");
|
||||
});
|
||||
@@ -0,0 +1,337 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
export const WORLD_UP = Object.freeze([0, 0, 1]);
|
||||
export const TOP_VIEW_UP = Object.freeze([0, 1, 0]);
|
||||
|
||||
export const RENDER_CAMERA_PRESETS = Object.freeze({
|
||||
front: Object.freeze({ name: "front", direction: Object.freeze([0, -1, 0]), up: WORLD_UP }),
|
||||
back: Object.freeze({ name: "back", direction: Object.freeze([0, 1, 0]), up: WORLD_UP }),
|
||||
right: Object.freeze({ name: "right", direction: Object.freeze([1, 0, 0]), up: WORLD_UP }),
|
||||
left: Object.freeze({ name: "left", direction: Object.freeze([-1, 0, 0]), up: WORLD_UP }),
|
||||
top: Object.freeze({ name: "top", direction: Object.freeze([0, 0, 1]), up: TOP_VIEW_UP }),
|
||||
bottom: Object.freeze({ name: "bottom", direction: Object.freeze([0, 0, -1]), up: TOP_VIEW_UP }),
|
||||
iso: Object.freeze({ name: "iso", direction: Object.freeze([1, -1, 0.8]), up: WORLD_UP }),
|
||||
isometric: Object.freeze({ name: "iso", direction: Object.freeze([1, -1, 0.8]), up: WORLD_UP }),
|
||||
side: Object.freeze({ name: "side", direction: Object.freeze([1, 0, 0]), up: WORLD_UP })
|
||||
});
|
||||
|
||||
export const RENDER_VIEW_PRESETS = RENDER_CAMERA_PRESETS;
|
||||
|
||||
const DEFAULT_CAMERA_PRESET = "iso";
|
||||
const CAMERA_SPEC_KEYS = Object.freeze(["preset", "position", "target", "up", "zoom", "direction", "name"]);
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function toFiniteNumber(value, fallback = 0) {
|
||||
const numericValue = Number(value);
|
||||
return Number.isFinite(numericValue) ? numericValue : fallback;
|
||||
}
|
||||
|
||||
function normalizeVector(value, {
|
||||
fieldName = "camera vector",
|
||||
required = false,
|
||||
nonZero = false,
|
||||
strict = true
|
||||
} = {}) {
|
||||
if (value === undefined || value === null) {
|
||||
if (required && strict) {
|
||||
throw new Error(`${fieldName} must be a three-number array`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(value) || value.length < 3) {
|
||||
if (strict) {
|
||||
throw new Error(`${fieldName} must be a three-number array`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const vector = [
|
||||
Number(value[0]),
|
||||
Number(value[1]),
|
||||
Number(value[2])
|
||||
];
|
||||
if (!vector.every(Number.isFinite)) {
|
||||
if (strict) {
|
||||
throw new Error(`${fieldName} must contain only finite numbers`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (nonZero && vector[0] ** 2 + vector[1] ** 2 + vector[2] ** 2 <= 1e-12) {
|
||||
if (strict) {
|
||||
throw new Error(`${fieldName} must not be the zero vector`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return vector;
|
||||
}
|
||||
|
||||
export function cloneCameraVector(vector) {
|
||||
return normalizeVector(vector, { strict: false });
|
||||
}
|
||||
|
||||
export function normalizeCameraZoom(value, fallback = 1, {
|
||||
fieldName = "camera.zoom",
|
||||
strict = false
|
||||
} = {}) {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return fallback;
|
||||
}
|
||||
const numericValue = Number(value);
|
||||
if (Number.isFinite(numericValue) && numericValue > 0) {
|
||||
return numericValue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new Error(`${fieldName} must be a positive finite number`);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function clonePreset(preset) {
|
||||
return {
|
||||
name: preset.name,
|
||||
direction: [...preset.direction],
|
||||
up: [...preset.up]
|
||||
};
|
||||
}
|
||||
|
||||
function cameraPresetByName(name, {
|
||||
presets = RENDER_CAMERA_PRESETS,
|
||||
strict = true
|
||||
} = {}) {
|
||||
const raw = String(name || DEFAULT_CAMERA_PRESET).trim().toLowerCase();
|
||||
if (presets[raw]) {
|
||||
return clonePreset(presets[raw]);
|
||||
}
|
||||
const parts = raw.split(":").map((entry) => Number(entry));
|
||||
if (parts.length >= 2 && parts.every(Number.isFinite)) {
|
||||
const azimuth = (parts[0] * Math.PI) / 180;
|
||||
const elevation = (parts[1] * Math.PI) / 180;
|
||||
const cosElevation = Math.cos(elevation);
|
||||
return {
|
||||
name: `${parts[0]}:${parts[1]}`,
|
||||
direction: [
|
||||
Math.sin(azimuth) * cosElevation,
|
||||
-Math.cos(azimuth) * cosElevation,
|
||||
Math.sin(elevation)
|
||||
],
|
||||
up: [...WORLD_UP]
|
||||
};
|
||||
}
|
||||
if (strict) {
|
||||
throw new Error(`Unknown camera preset: ${name}`);
|
||||
}
|
||||
return clonePreset(presets[DEFAULT_CAMERA_PRESET]);
|
||||
}
|
||||
|
||||
function parseCameraJsonString(raw, { strict = true } = {}) {
|
||||
const text = String(raw || "").trim();
|
||||
if (!text.startsWith("{")) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (!isPlainObject(parsed)) {
|
||||
throw new Error("camera JSON must be an object");
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if (strict) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Invalid camera JSON: ${message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function validateCameraSpecKeys(spec) {
|
||||
const unknownKeys = Object.keys(spec).filter((key) => !CAMERA_SPEC_KEYS.includes(key));
|
||||
if (unknownKeys.length) {
|
||||
throw new Error(`Unsupported camera fields: ${unknownKeys.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCameraSpec(rawCamera = DEFAULT_CAMERA_PRESET, {
|
||||
presets = RENDER_CAMERA_PRESETS,
|
||||
strict = true
|
||||
} = {}) {
|
||||
const parsedJson = typeof rawCamera === "string"
|
||||
? parseCameraJsonString(rawCamera, { strict })
|
||||
: null;
|
||||
const source = parsedJson || rawCamera;
|
||||
if (!isPlainObject(source)) {
|
||||
const preset = cameraPresetByName(source || DEFAULT_CAMERA_PRESET, { presets, strict });
|
||||
return {
|
||||
sourceKind: "string",
|
||||
name: preset.name,
|
||||
preset: preset.name,
|
||||
direction: preset.direction,
|
||||
position: null,
|
||||
target: null,
|
||||
up: preset.up,
|
||||
zoom: 1,
|
||||
hasExplicitPosition: false,
|
||||
hasExplicitTarget: false,
|
||||
hasExplicitUp: false,
|
||||
hasExplicitZoom: false
|
||||
};
|
||||
}
|
||||
|
||||
validateCameraSpecKeys(source);
|
||||
const rawPreset = source.preset ?? DEFAULT_CAMERA_PRESET;
|
||||
const preset = cameraPresetByName(rawPreset, { presets, strict });
|
||||
const direction = normalizeVector(source.direction, {
|
||||
fieldName: "camera.direction",
|
||||
nonZero: true,
|
||||
strict
|
||||
}) || preset.direction;
|
||||
const position = normalizeVector(source.position, {
|
||||
fieldName: "camera.position",
|
||||
strict
|
||||
});
|
||||
const target = normalizeVector(source.target, {
|
||||
fieldName: "camera.target",
|
||||
strict
|
||||
});
|
||||
const up = normalizeVector(source.up, {
|
||||
fieldName: "camera.up",
|
||||
nonZero: true,
|
||||
strict
|
||||
}) || preset.up || [...WORLD_UP];
|
||||
const hasExplicitZoom = Object.prototype.hasOwnProperty.call(source, "zoom");
|
||||
return {
|
||||
sourceKind: "object",
|
||||
name: String(source.name || preset.name || "custom"),
|
||||
preset: preset.name,
|
||||
direction,
|
||||
position,
|
||||
target,
|
||||
up,
|
||||
zoom: normalizeCameraZoom(source.zoom, 1, { strict, fieldName: "camera.zoom" }),
|
||||
hasExplicitPosition: Object.prototype.hasOwnProperty.call(source, "position"),
|
||||
hasExplicitTarget: Object.prototype.hasOwnProperty.call(source, "target"),
|
||||
hasExplicitUp: Object.prototype.hasOwnProperty.call(source, "up"),
|
||||
hasExplicitZoom
|
||||
};
|
||||
}
|
||||
|
||||
function settingsForCamera(sceneScale, settingsByScale) {
|
||||
if (settingsByScale?.[sceneScale]) {
|
||||
return settingsByScale[sceneScale];
|
||||
}
|
||||
if (settingsByScale?.cad) {
|
||||
return settingsByScale.cad;
|
||||
}
|
||||
return {
|
||||
minBoundsSpan: 1,
|
||||
minModelRadius: 1,
|
||||
minCameraDistance: 10,
|
||||
minCameraFar: 1000
|
||||
};
|
||||
}
|
||||
|
||||
function centerAndRadiusFromBounds(bounds, sceneScale, settingsByScale) {
|
||||
const settings = settingsForCamera(sceneScale, settingsByScale);
|
||||
const min = Array.isArray(bounds?.min) ? bounds.min : [0, 0, 0];
|
||||
const max = Array.isArray(bounds?.max) ? bounds.max : [1, 1, 1];
|
||||
const center = new THREE.Vector3(
|
||||
(toFiniteNumber(min[0]) + toFiniteNumber(max[0], 1)) / 2,
|
||||
(toFiniteNumber(min[1]) + toFiniteNumber(max[1], 1)) / 2,
|
||||
(toFiniteNumber(min[2]) + toFiniteNumber(max[2], 1)) / 2
|
||||
);
|
||||
const size = new THREE.Vector3(
|
||||
Math.max(toFiniteNumber(max[0], 1) - toFiniteNumber(min[0]), settings.minBoundsSpan),
|
||||
Math.max(toFiniteNumber(max[1], 1) - toFiniteNumber(min[1]), settings.minBoundsSpan),
|
||||
Math.max(toFiniteNumber(max[2], 1) - toFiniteNumber(min[2]), settings.minBoundsSpan)
|
||||
);
|
||||
return {
|
||||
center,
|
||||
radius: Math.max(size.length() / 2, settings.minModelRadius),
|
||||
size
|
||||
};
|
||||
}
|
||||
|
||||
function vectorFromArray(vector) {
|
||||
return new THREE.Vector3(vector[0], vector[1], vector[2]);
|
||||
}
|
||||
|
||||
export function resolveCameraView(rawCamera = DEFAULT_CAMERA_PRESET, options = {}) {
|
||||
const spec = normalizeCameraSpec(rawCamera, options);
|
||||
const target = spec.target ? vectorFromArray(spec.target) : null;
|
||||
const position = spec.position ? vectorFromArray(spec.position) : null;
|
||||
const direction = position && target
|
||||
? position.clone().sub(target).normalize().toArray()
|
||||
: normalizeVector(spec.direction, {
|
||||
fieldName: "camera.direction",
|
||||
nonZero: true,
|
||||
strict: false
|
||||
}) || [...RENDER_CAMERA_PRESETS[DEFAULT_CAMERA_PRESET].direction];
|
||||
return {
|
||||
name: spec.name,
|
||||
direction,
|
||||
up: [...(spec.up || WORLD_UP)],
|
||||
zoom: spec.zoom
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCameraSnapshot(rawCamera = DEFAULT_CAMERA_PRESET, bounds = null, {
|
||||
sceneScale = "cad",
|
||||
settingsByScale = null,
|
||||
presets = RENDER_CAMERA_PRESETS,
|
||||
strict = true
|
||||
} = {}) {
|
||||
const spec = normalizeCameraSpec(rawCamera, { presets, strict });
|
||||
const settings = settingsForCamera(sceneScale, settingsByScale);
|
||||
const { center, radius, size } = centerAndRadiusFromBounds(bounds, sceneScale, settingsByScale);
|
||||
const target = spec.target ? vectorFromArray(spec.target) : center.clone();
|
||||
const direction = normalizeVector(spec.direction, {
|
||||
fieldName: "camera.direction",
|
||||
nonZero: true,
|
||||
strict
|
||||
}) || [...RENDER_CAMERA_PRESETS[DEFAULT_CAMERA_PRESET].direction];
|
||||
const position = spec.position
|
||||
? vectorFromArray(spec.position)
|
||||
: target.clone().add(vectorFromArray(direction).normalize().multiplyScalar(
|
||||
Math.max(radius * 3.2, settings.minCameraDistance || settings.minModelRadius * 10 || 10)
|
||||
));
|
||||
const resolvedDirection = position.clone().sub(target);
|
||||
if (resolvedDirection.lengthSq() <= 1e-12) {
|
||||
throw new Error("camera.position must differ from camera.target");
|
||||
}
|
||||
const up = vectorFromArray(spec.up || WORLD_UP);
|
||||
if (up.lengthSq() <= 1e-12) {
|
||||
throw new Error("camera.up must not be the zero vector");
|
||||
}
|
||||
return {
|
||||
name: spec.name,
|
||||
preset: spec.preset,
|
||||
position: position.toArray(),
|
||||
target: target.toArray(),
|
||||
up: up.normalize().toArray(),
|
||||
zoom: spec.zoom,
|
||||
direction: resolvedDirection.normalize().toArray(),
|
||||
view: {
|
||||
name: spec.name,
|
||||
direction: resolvedDirection.normalize().toArray(),
|
||||
up: up.normalize().toArray(),
|
||||
zoom: spec.zoom
|
||||
},
|
||||
sourceKind: spec.sourceKind,
|
||||
hasExplicitPosition: spec.hasExplicitPosition,
|
||||
hasExplicitTarget: spec.hasExplicitTarget,
|
||||
hasExplicitUp: spec.hasExplicitUp,
|
||||
hasExplicitZoom: spec.hasExplicitZoom,
|
||||
radius,
|
||||
size: size.toArray()
|
||||
};
|
||||
}
|
||||
|
||||
export function cameraSpecUsesPerspectiveProjection(rawCamera = DEFAULT_CAMERA_PRESET, options = {}) {
|
||||
const spec = normalizeCameraSpec(rawCamera, options);
|
||||
return spec.sourceKind === "object" && (
|
||||
spec.hasExplicitPosition ||
|
||||
spec.hasExplicitTarget ||
|
||||
spec.hasExplicitUp
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export const DEFAULT_ORBIT_FPS = 6;
|
||||
export const DEFAULT_ORBIT_DURATION_SECONDS = 12;
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function orbitFrameOutputs(job) {
|
||||
const orbit = isObject(job.orbit) ? job.orbit : {};
|
||||
const output = Array.isArray(job.outputs) && job.outputs.length && isObject(job.outputs[0])
|
||||
? job.outputs[0]
|
||||
: {};
|
||||
const width = Math.max(1, Math.floor(Number(output.width || job.width || orbit.width || 720)));
|
||||
const height = Math.max(1, Math.floor(Number(output.height || job.height || orbit.height || 480)));
|
||||
const fps = Math.max(1, Math.min(Number(orbit.fps || DEFAULT_ORBIT_FPS), 60));
|
||||
const durationSeconds = Math.max(0.1, Math.min(Number(orbit.durationSeconds || DEFAULT_ORBIT_DURATION_SECONDS), 60));
|
||||
const frameCount = Math.max(2, Math.min(Math.round(fps * durationSeconds), 720));
|
||||
const startAzimuth = Number.isFinite(Number(orbit.startAzimuth)) ? Number(orbit.startAzimuth) : -45;
|
||||
const elevation = Number.isFinite(Number(orbit.elevation)) ? Number(orbit.elevation) : 28;
|
||||
const turns = Number.isFinite(Number(orbit.turns)) ? Number(orbit.turns) : 1;
|
||||
return {
|
||||
path: String(output.path || job.output || ""),
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
durationSeconds,
|
||||
frameCount,
|
||||
appearance: output.appearance || job.appearance || "workbench",
|
||||
graphics: {
|
||||
...(isObject(job.graphics) ? job.graphics : {}),
|
||||
...(isObject(output.graphics) ? output.graphics : {}),
|
||||
},
|
||||
render: {
|
||||
...(isObject(job.render) ? job.render : {}),
|
||||
...(isObject(output.render) ? output.render : {}),
|
||||
},
|
||||
outputs: Array.from({ length: frameCount }, (_, index) => ({
|
||||
path: "",
|
||||
width,
|
||||
height,
|
||||
camera: `${startAzimuth + ((360 * turns * index) / frameCount)}:${elevation}`,
|
||||
}))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import gifencDefault, {
|
||||
GIFEncoder as exportedGifEncoder,
|
||||
applyPalette as exportedApplyPalette,
|
||||
quantize as exportedQuantize
|
||||
} from "gifenc";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
normalizeParameterValue,
|
||||
normalizeParameterValues
|
||||
} from "./parameters.js";
|
||||
import { loadImplicitCadModule } from "../lib/implicitCad/loader.js";
|
||||
import {
|
||||
snapshotImplicitCadModel,
|
||||
snapshotImplicitCadOutputOptions
|
||||
} from "../lib/implicitCad/snapshot.js";
|
||||
import {
|
||||
orbitFrameOutputs
|
||||
} from "./implicitHeadlessOrbitFrames.js";
|
||||
|
||||
const GIFEncoder = exportedGifEncoder || gifencDefault?.GIFEncoder || gifencDefault;
|
||||
const quantize = exportedQuantize || gifencDefault?.quantize;
|
||||
const applyPalette = exportedApplyPalette || gifencDefault?.applyPalette;
|
||||
const implicitModuleCache = new Map();
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function clampNumber(value, min, max) {
|
||||
const numeric = Number(value);
|
||||
return Math.min(Math.max(Number.isFinite(numeric) ? numeric : min, min), max);
|
||||
}
|
||||
|
||||
function implicitParameterValues(rawParameters) {
|
||||
if (!isObject(rawParameters)) {
|
||||
return {};
|
||||
}
|
||||
return isObject(rawParameters.values) ? rawParameters.values : rawParameters;
|
||||
}
|
||||
|
||||
function implicitAnimationState(rawParameters, job = {}, output = {}) {
|
||||
const raw = isObject(output.implicitAnimationState)
|
||||
? output.implicitAnimationState
|
||||
: isObject(job.implicitAnimationState)
|
||||
? job.implicitAnimationState
|
||||
: isObject(rawParameters?.animationState)
|
||||
? rawParameters.animationState
|
||||
: isObject(rawParameters?.animation)
|
||||
? rawParameters.animation
|
||||
: {};
|
||||
return raw;
|
||||
}
|
||||
|
||||
function canonicalJsonValue(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(canonicalJsonValue);
|
||||
}
|
||||
if (!isObject(value)) {
|
||||
return value;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => [key, canonicalJsonValue(value[key])])
|
||||
);
|
||||
}
|
||||
|
||||
function canonicalJson(value) {
|
||||
return JSON.stringify(canonicalJsonValue(value));
|
||||
}
|
||||
|
||||
function findImplicitAnimation(definition, animationId) {
|
||||
const animations = Array.isArray(definition?.animations) ? definition.animations : [];
|
||||
if (!animations.length) {
|
||||
return null;
|
||||
}
|
||||
const id = String(animationId || "").trim();
|
||||
return animations.find((animation) => animation.id === id) || animations[0] || null;
|
||||
}
|
||||
|
||||
function buildAnimatedImplicitParameterValues(definition, animation, currentValues, elapsedSec) {
|
||||
if (!definition || typeof animation?.update !== "function") {
|
||||
return currentValues;
|
||||
}
|
||||
const duration = Math.max(Number(animation.duration) || 1, 0.001);
|
||||
const clampedElapsedSec = clampNumber(elapsedSec, 0, duration);
|
||||
const progress = duration > 0 ? clampNumber(clampedElapsedSec / duration, 0, 1) : 0;
|
||||
const normalizedCurrent = normalizeParameterValues(definition, currentValues);
|
||||
const nextValues = { ...normalizedCurrent };
|
||||
const set = (parameterId, value) => {
|
||||
const id = String(parameterId || "").trim();
|
||||
const parameter = definition.parameterMap?.[id];
|
||||
if (!parameter) {
|
||||
return;
|
||||
}
|
||||
nextValues[id] = normalizeParameterValue(parameter, value);
|
||||
};
|
||||
animation.update({
|
||||
...normalizedCurrent,
|
||||
elapsed: clampedElapsedSec,
|
||||
elapsedSec: clampedElapsedSec,
|
||||
duration,
|
||||
progress,
|
||||
cycle: duration > 0 ? clampedElapsedSec / duration : 0,
|
||||
t: clampedElapsedSec,
|
||||
loop: animation.loop !== false,
|
||||
params: normalizedCurrent,
|
||||
set
|
||||
});
|
||||
return nextValues;
|
||||
}
|
||||
|
||||
function applyImplicitAnimation(definition, parameterValues, animationState = {}) {
|
||||
const animation = findImplicitAnimation(definition, animationState.activeId);
|
||||
if (!animation) {
|
||||
return parameterValues;
|
||||
}
|
||||
return buildAnimatedImplicitParameterValues(
|
||||
definition,
|
||||
animation,
|
||||
parameterValues,
|
||||
Number(animationState.elapsedSec ?? animationState.elapsed ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
function implicitRuntimeState(model, job = {}, output = {}) {
|
||||
const rawParameters = isObject(output.implicitParameters)
|
||||
? output.implicitParameters
|
||||
: isObject(job.implicitParameters)
|
||||
? job.implicitParameters
|
||||
: {};
|
||||
const animationState = implicitAnimationState(rawParameters, job, output);
|
||||
const parameterValues = applyImplicitAnimation(
|
||||
model?.definition,
|
||||
implicitParameterValues(rawParameters),
|
||||
animationState
|
||||
);
|
||||
return { animationState, parameterValues };
|
||||
}
|
||||
|
||||
function implicitRuntimeModel(model, job = {}, output = {}) {
|
||||
const { animationState, parameterValues } = implicitRuntimeState(model, job, output);
|
||||
return typeof model?.definition?.buildModel === "function"
|
||||
? model.definition.buildModel(
|
||||
parameterValues,
|
||||
animationState
|
||||
)
|
||||
: model;
|
||||
}
|
||||
|
||||
function cachedImplicitRuntimeModel(model, job = {}, output = {}, cache = null) {
|
||||
if (!cache || typeof model?.definition?.buildModel !== "function") {
|
||||
return implicitRuntimeModel(model, job, output);
|
||||
}
|
||||
const state = implicitRuntimeState(model, job, output);
|
||||
const key = canonicalJson(state);
|
||||
if (cache.has(key)) {
|
||||
return cache.get(key);
|
||||
}
|
||||
const runtimeModel = model.definition.buildModel(state.parameterValues, state.animationState);
|
||||
cache.set(key, runtimeModel);
|
||||
return runtimeModel;
|
||||
}
|
||||
|
||||
async function loadCachedImplicitCadModule(inputUrl) {
|
||||
const key = String(inputUrl || "");
|
||||
if (!implicitModuleCache.has(key)) {
|
||||
implicitModuleCache.set(
|
||||
key,
|
||||
loadImplicitCadModule(key).catch((error) => {
|
||||
implicitModuleCache.delete(key);
|
||||
throw error;
|
||||
})
|
||||
);
|
||||
}
|
||||
return implicitModuleCache.get(key);
|
||||
}
|
||||
|
||||
function implicitAnimationOptions(job, output = {}) {
|
||||
if (isObject(output.implicitAnimation)) {
|
||||
return output.implicitAnimation;
|
||||
}
|
||||
if (isObject(job.implicitAnimation)) {
|
||||
return job.implicitAnimation;
|
||||
}
|
||||
if (String(job.mode || "").trim().toLowerCase() === "animate") {
|
||||
return {};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function implicitAnimationFrameOutputs(model, job) {
|
||||
const output = Array.isArray(job.outputs) && job.outputs.length && isObject(job.outputs[0])
|
||||
? job.outputs[0]
|
||||
: {};
|
||||
const options = implicitAnimationOptions(job, output);
|
||||
const animation = findImplicitAnimation(model?.definition, options?.activeId || options?.id || job.implicitAnimationState?.activeId);
|
||||
if (!animation) {
|
||||
throw new Error("Implicit CAD animation snapshot requires an animation on the model");
|
||||
}
|
||||
const width = Math.max(1, Math.floor(Number(output.width || job.width || options?.width || 720)));
|
||||
const height = Math.max(1, Math.floor(Number(output.height || job.height || options?.height || 480)));
|
||||
const fps = Math.max(1, Math.min(Number(options?.fps || 18), 60));
|
||||
const durationSeconds = Math.max(0.1, Math.min(Number(options?.durationSeconds || options?.duration || animation.duration || 4), 60));
|
||||
const frameCount = Math.max(2, Math.min(Math.round(fps * durationSeconds), 720));
|
||||
const baseRawParameters = isObject(output.implicitParameters)
|
||||
? output.implicitParameters
|
||||
: isObject(job.implicitParameters)
|
||||
? job.implicitParameters
|
||||
: {};
|
||||
const baseAnimationState = implicitAnimationState(baseRawParameters, job, output);
|
||||
return {
|
||||
path: String(output.path || job.output || ""),
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
durationSeconds,
|
||||
frameCount,
|
||||
loop: options?.loop !== false && animation.loop !== false,
|
||||
appearance: output.appearance || job.appearance || "workbench",
|
||||
graphics: {
|
||||
...(isObject(job.graphics) ? job.graphics : {}),
|
||||
...(isObject(output.graphics) ? output.graphics : {}),
|
||||
},
|
||||
render: {
|
||||
...(isObject(job.render) ? job.render : {}),
|
||||
...(isObject(output.render) ? output.render : {}),
|
||||
},
|
||||
outputs: Array.from({ length: frameCount }, (_, index) => ({
|
||||
...output,
|
||||
path: "",
|
||||
width,
|
||||
height,
|
||||
implicitAnimationState: {
|
||||
...baseAnimationState,
|
||||
activeId: animation.id,
|
||||
playing: true,
|
||||
elapsedSec: (durationSeconds * index) / frameCount,
|
||||
},
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
async function dataUrlToImageData(dataUrl, width, height) {
|
||||
const image = new Image();
|
||||
image.decoding = "async";
|
||||
const loaded = new Promise((resolve, reject) => {
|
||||
image.onload = resolve;
|
||||
image.onerror = () => reject(new Error("Failed to load rendered implicit CAD GIF frame"));
|
||||
});
|
||||
image.src = dataUrl;
|
||||
await loaded;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
return context.getImageData(0, 0, width, height);
|
||||
}
|
||||
|
||||
function shouldEncodeTransparentGif(job = {}, output = {}) {
|
||||
return Boolean(job.render?.transparent || output.render?.transparent);
|
||||
}
|
||||
|
||||
function encodeGifFrameImageData(imageData, { transparent = false } = {}) {
|
||||
if (!transparent) {
|
||||
const palette = quantize(imageData.data, 256);
|
||||
return {
|
||||
indexed: applyPalette(imageData.data, palette),
|
||||
palette,
|
||||
transparent: false,
|
||||
transparentIndex: 0
|
||||
};
|
||||
}
|
||||
|
||||
const palette = quantize(imageData.data, 256, {
|
||||
format: "rgba4444",
|
||||
oneBitAlpha: true
|
||||
});
|
||||
const transparentIndex = palette.findIndex((color) => Number(color?.[3]) <= 127);
|
||||
return {
|
||||
indexed: applyPalette(imageData.data, palette, "rgba4444"),
|
||||
palette,
|
||||
transparent: transparentIndex >= 0,
|
||||
transparentIndex: Math.max(transparentIndex, 0)
|
||||
};
|
||||
}
|
||||
|
||||
function gifDataUrlFromEncoder(encoder) {
|
||||
encoder.finish();
|
||||
const bytes = encoder.bytesView();
|
||||
let binary = "";
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
binary += String.fromCharCode(bytes[index]);
|
||||
}
|
||||
return `data:image/gif;base64,${btoa(binary)}`;
|
||||
}
|
||||
|
||||
async function renderImplicitGifFrames(model, job, frameSpec, { mode = "orbit" } = {}) {
|
||||
const encoder = GIFEncoder();
|
||||
const transparent = shouldEncodeTransparentGif(job, frameSpec);
|
||||
const runtimeModelCache = new Map();
|
||||
for (const frameOutput of frameSpec.outputs) {
|
||||
const modelForOutput = cachedImplicitRuntimeModel(model, job, frameOutput, runtimeModelCache);
|
||||
const renderedOutput = await snapshotImplicitCadModel(THREE, modelForOutput, {
|
||||
width: frameSpec.width,
|
||||
height: frameSpec.height,
|
||||
camera: frameOutput.camera || job.camera || "iso",
|
||||
appearance: frameOutput.appearance || frameSpec.appearance,
|
||||
graphics: {
|
||||
...(isObject(frameSpec.graphics) ? frameSpec.graphics : {}),
|
||||
...(isObject(frameOutput.graphics) ? frameOutput.graphics : {}),
|
||||
},
|
||||
render: {
|
||||
...(isObject(frameSpec.render) ? frameSpec.render : {}),
|
||||
...(isObject(frameOutput.render) ? frameOutput.render : {}),
|
||||
},
|
||||
});
|
||||
const imageData = await dataUrlToImageData(renderedOutput.dataUrl, frameSpec.width, frameSpec.height);
|
||||
const frame = encodeGifFrameImageData(imageData, { transparent });
|
||||
encoder.writeFrame(frame.indexed, frameSpec.width, frameSpec.height, {
|
||||
palette: frame.palette,
|
||||
transparent: frame.transparent,
|
||||
transparentIndex: frame.transparentIndex,
|
||||
delay: 1000 / frameSpec.fps,
|
||||
repeat: frameSpec.loop === false ? -1 : 0,
|
||||
dispose: frame.transparent ? 2 : -1
|
||||
});
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
mode,
|
||||
outputs: [{
|
||||
path: frameSpec.path,
|
||||
width: frameSpec.width,
|
||||
height: frameSpec.height,
|
||||
frameCount: frameSpec.frameCount,
|
||||
fps: frameSpec.fps,
|
||||
durationSeconds: frameSpec.durationSeconds,
|
||||
loop: frameSpec.loop !== false,
|
||||
mimeType: "image/gif",
|
||||
dataUrl: gifDataUrlFromEncoder(encoder)
|
||||
}],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function runImplicitCadHeadlessRenderJob(job = {}) {
|
||||
const resolved = isObject(job.resolved) ? job.resolved : {};
|
||||
const inputUrl = String(resolved.inputUrl || job.inputUrl || job.url || "").trim();
|
||||
if (!inputUrl) {
|
||||
throw new Error("Implicit CAD snapshot job requires resolved.inputUrl");
|
||||
}
|
||||
const model = await loadCachedImplicitCadModule(inputUrl);
|
||||
const mode = String(job.mode || "view").trim().toLowerCase();
|
||||
if (mode === "orbit") {
|
||||
return renderImplicitGifFrames(model, job, orbitFrameOutputs(job), { mode: "orbit" });
|
||||
}
|
||||
if (mode === "animate" || implicitAnimationOptions(job, Array.isArray(job.outputs) ? job.outputs[0] : {})) {
|
||||
return renderImplicitGifFrames(model, job, implicitAnimationFrameOutputs(model, job), { mode: "animate" });
|
||||
}
|
||||
const outputs = Array.isArray(job.outputs) ? job.outputs : [];
|
||||
const renderedOutputs = [];
|
||||
const runtimeModelCache = new Map();
|
||||
for (const output of outputs) {
|
||||
const outputObject = isObject(output) ? output : {};
|
||||
const modelForOutput = cachedImplicitRuntimeModel(model, job, outputObject, runtimeModelCache);
|
||||
renderedOutputs.push(await snapshotImplicitCadModel(THREE, modelForOutput, {
|
||||
...snapshotImplicitCadOutputOptions(job, outputObject),
|
||||
}));
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
mode,
|
||||
outputs: renderedOutputs,
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.__implicitCadSnapshotRender = runImplicitCadHeadlessRenderJob;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
DEFAULT_ORBIT_DURATION_SECONDS,
|
||||
DEFAULT_ORBIT_FPS,
|
||||
orbitFrameOutputs
|
||||
} from "./implicitHeadlessOrbitFrames.js";
|
||||
|
||||
test("orbitFrameOutputs uses calm default orbit timing", () => {
|
||||
const orbit = orbitFrameOutputs({
|
||||
mode: "orbit",
|
||||
outputs: [{ path: "orbit.gif" }]
|
||||
});
|
||||
|
||||
assert.equal(orbit.fps, DEFAULT_ORBIT_FPS);
|
||||
assert.equal(orbit.durationSeconds, DEFAULT_ORBIT_DURATION_SECONDS);
|
||||
assert.equal(orbit.frameCount, 72);
|
||||
assert.equal(orbit.outputs.length, 72);
|
||||
assert.equal(orbit.outputs[0].camera, "-45:28");
|
||||
assert.equal(orbit.outputs.at(-1).camera, "310:28");
|
||||
});
|
||||
|
||||
test("orbitFrameOutputs honors explicit orbit timing", () => {
|
||||
const orbit = orbitFrameOutputs({
|
||||
mode: "orbit",
|
||||
orbit: {
|
||||
fps: 18,
|
||||
durationSeconds: 4,
|
||||
turns: 0.5,
|
||||
startAzimuth: 10,
|
||||
elevation: 20
|
||||
},
|
||||
outputs: [{ path: "orbit.gif" }]
|
||||
});
|
||||
|
||||
assert.equal(orbit.fps, 18);
|
||||
assert.equal(orbit.durationSeconds, 4);
|
||||
assert.equal(orbit.frameCount, 72);
|
||||
assert.equal(orbit.outputs[0].camera, "10:20");
|
||||
assert.equal(orbit.outputs.at(-1).camera, "187.5:20");
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
export const PARAMETER_SCHEMA_VERSION = 1;
|
||||
|
||||
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
||||
|
||||
function isObject(value) {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeString(value, fallback = "") {
|
||||
const text = String(value ?? fallback).trim();
|
||||
return text || fallback;
|
||||
}
|
||||
|
||||
function toFiniteNumber(value, fallback = 0) {
|
||||
const numericValue = Number(value);
|
||||
return Number.isFinite(numericValue) ? numericValue : fallback;
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
export function normalizeParameterType(value) {
|
||||
const type = normalizeString(value, "number").toLowerCase();
|
||||
if (["number", "boolean", "enum", "select", "color", "string", "button"].includes(type)) {
|
||||
return type === "select" ? "enum" : type;
|
||||
}
|
||||
return "number";
|
||||
}
|
||||
|
||||
export function normalizeParameterOptions(value) {
|
||||
return (Array.isArray(value) ? value : [])
|
||||
.map((option) => {
|
||||
if (isObject(option)) {
|
||||
const valueText = normalizeString(option.value);
|
||||
return valueText ? {
|
||||
value: valueText,
|
||||
label: normalizeString(option.label, valueText)
|
||||
} : null;
|
||||
}
|
||||
const valueText = normalizeString(option);
|
||||
return valueText ? { value: valueText, label: valueText } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function normalizeParameterValue(definition, value) {
|
||||
const type = normalizeParameterType(definition?.type);
|
||||
if (type === "boolean") {
|
||||
return value === true;
|
||||
}
|
||||
if (type === "color") {
|
||||
const color = normalizeString(value, normalizeString(definition?.defaultValue, "#ffffff"));
|
||||
return HEX_COLOR_RE.test(color) ? color : "#ffffff";
|
||||
}
|
||||
if (type === "enum") {
|
||||
const options = Array.isArray(definition?.options) ? definition.options : [];
|
||||
const valueText = normalizeString(value, options[0]?.value || "");
|
||||
return options.some((option) => option.value === valueText) ? valueText : (options[0]?.value || "");
|
||||
}
|
||||
if (type === "string") {
|
||||
return String(value ?? "");
|
||||
}
|
||||
if (type === "button") {
|
||||
return Math.max(0, Math.floor(toFiniteNumber(value, 0)));
|
||||
}
|
||||
const min = toFiniteNumber(definition?.min, 0);
|
||||
const max = Math.max(toFiniteNumber(definition?.max, min), min);
|
||||
return clamp(toFiniteNumber(value, toFiniteNumber(definition?.defaultValue, min)), min, max);
|
||||
}
|
||||
|
||||
export function normalizeParameterDefinition(id, rawDefinition) {
|
||||
const raw = isObject(rawDefinition) ? rawDefinition : {};
|
||||
const type = normalizeParameterType(raw.type);
|
||||
const min = toFiniteNumber(raw.min, 0);
|
||||
const max = Math.max(toFiniteNumber(raw.max, type === "number" ? 1 : min), min);
|
||||
const options = normalizeParameterOptions(raw.options || raw.values);
|
||||
const fallbackDefault = type === "boolean"
|
||||
? false
|
||||
: type === "color"
|
||||
? "#ffffff"
|
||||
: type === "enum"
|
||||
? options[0]?.value || ""
|
||||
: type === "string"
|
||||
? ""
|
||||
: 0;
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
label: normalizeString(raw.label, id),
|
||||
description: normalizeString(raw.description),
|
||||
unit: normalizeString(raw.unit),
|
||||
min,
|
||||
max,
|
||||
step: Math.max(toFiniteNumber(raw.step, type === "number" ? 0.01 : 1), 0),
|
||||
defaultValue: normalizeParameterValue({ type, min, max, options }, raw.default ?? raw.defaultValue ?? fallbackDefault),
|
||||
options
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeParameterDefinitions(value) {
|
||||
const entries = Array.isArray(value)
|
||||
? value.map((definition) => [definition?.id, definition])
|
||||
: Object.entries(isObject(value) ? value : {});
|
||||
return entries
|
||||
.map(([id, rawDefinition]) => normalizeParameterDefinition(normalizeString(id), rawDefinition))
|
||||
.filter((definition) => definition.id);
|
||||
}
|
||||
|
||||
export function parameterMapForDefinitions(parameters) {
|
||||
return Object.fromEntries(
|
||||
(Array.isArray(parameters) ? parameters : [])
|
||||
.filter((parameter) => parameter?.id)
|
||||
.map((parameter) => [parameter.id, parameter])
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeParameterValues(definition, values = {}) {
|
||||
const parameterMap = definition?.parameterMap || {};
|
||||
return Object.fromEntries(
|
||||
Object.values(parameterMap).map((parameter) => [
|
||||
parameter.id,
|
||||
normalizeParameterValue(parameter, isObject(values) && Object.hasOwn(values, parameter.id)
|
||||
? values[parameter.id]
|
||||
: parameter.defaultValue)
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeParameterAnimations(value) {
|
||||
const entries = Array.isArray(value)
|
||||
? value.map((animation) => [animation?.id, animation])
|
||||
: Object.entries(isObject(value) ? value : {});
|
||||
return entries
|
||||
.map(([id, rawAnimation]) => {
|
||||
const raw = isObject(rawAnimation) ? rawAnimation : {};
|
||||
const animationId = normalizeString(id);
|
||||
return animationId ? {
|
||||
id: animationId,
|
||||
label: normalizeString(raw.label, animationId),
|
||||
description: normalizeString(raw.description),
|
||||
duration: Math.max(toFiniteNumber(raw.duration ?? raw.durationSeconds, 1), 0.001),
|
||||
loop: raw.loop !== false,
|
||||
update: typeof raw.update === "function" ? raw.update : null,
|
||||
raw
|
||||
} : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
cloneThemeSettings,
|
||||
normalizeThemeSettings,
|
||||
resolveThemeSettingsForColorMode
|
||||
} from "./themeSettings.js";
|
||||
|
||||
export function resolveAppearanceJobConfig(job = {}, { defaultThemeId = "workbench" } = {}) {
|
||||
if (typeof job.appearance === "string") {
|
||||
return {
|
||||
themeId: job.appearance,
|
||||
settings: null
|
||||
};
|
||||
}
|
||||
if (job.appearance && typeof job.appearance === "object" && !Array.isArray(job.appearance)) {
|
||||
return {
|
||||
themeId: defaultThemeId,
|
||||
settings: job.appearance
|
||||
};
|
||||
}
|
||||
return {
|
||||
themeId: defaultThemeId,
|
||||
settings: null
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAppearanceSettings(job = {}, { defaultThemeId = "workbench" } = {}) {
|
||||
const appearance = resolveAppearanceJobConfig(job, { defaultThemeId });
|
||||
const themeSettings = cloneThemeSettings(appearance.themeId || defaultThemeId);
|
||||
const normalized = normalizeThemeSettings(appearance.settings || themeSettings);
|
||||
return typeof job.appearance === "string"
|
||||
? resolveThemeSettingsForColorMode(normalized, { prefersDark: false })
|
||||
: normalized;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
export * from "./common/parameters.js";
|
||||
export * from "./lib/implicitCad/schema.js";
|
||||
export * from "./lib/implicitCad/animation.js";
|
||||
export * from "./lib/implicitCad/model.js";
|
||||
export * from "./lib/implicitCad/loader.js";
|
||||
export * from "./lib/implicitCad/graphicsSettings.js";
|
||||
export * from "./lib/implicitCad/render.js";
|
||||
export * from "./lib/implicitCad/snapshot.js";
|
||||
export * from "./lib/implicitCad/sdfEvaluator.js";
|
||||
export * from "./lib/implicitCad/mesh.js";
|
||||
export * from "./lib/implicitCad/meshQuality.js";
|
||||
export * from "./lib/implicitCad/exporters.js";
|
||||
export {
|
||||
IMPLICIT_EXPORT_FORMATS,
|
||||
exportImplicitAnimatedGlb,
|
||||
exportImplicitModel,
|
||||
normalizeImplicitExportFormat
|
||||
} from "./lib/implicitCad/exportModel.js";
|
||||
export * from "./lib/implicitCad/export.js";
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
IMPLICIT_EXPORT_FORMATS,
|
||||
exportImplicitAnimatedGlb,
|
||||
exportImplicitModel,
|
||||
normalizeImplicitExportFormat,
|
||||
} from "./index.js";
|
||||
|
||||
test("root entry exposes concise export aliases", () => {
|
||||
assert.deepEqual(IMPLICIT_EXPORT_FORMATS, ["glb", "stl", "3mf"]);
|
||||
assert.equal(normalizeImplicitExportFormat("gltf"), "glb");
|
||||
assert.equal(typeof exportImplicitModel, "function");
|
||||
assert.equal(typeof exportImplicitAnimatedGlb, "function");
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
normalizeParameterValue,
|
||||
normalizeParameterValues
|
||||
} from "../../common/parameters.js";
|
||||
|
||||
function finiteNumber(value, fallback = 0) {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : fallback;
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
export function findImplicitAnimation(definition, animationId = "") {
|
||||
const animations = Array.isArray(definition?.animations) ? definition.animations : [];
|
||||
if (!animations.length) {
|
||||
return null;
|
||||
}
|
||||
const id = String(animationId || "").trim();
|
||||
return animations.find((animation) => animation.id === id) || animations[0] || null;
|
||||
}
|
||||
|
||||
export function normalizeImplicitAnimationElapsed(elapsedSec, animation) {
|
||||
const duration = Math.max(finiteNumber(animation?.duration, 1), 0.001);
|
||||
const elapsed = Math.max(finiteNumber(elapsedSec, 0), 0);
|
||||
return animation?.loop === false
|
||||
? Math.min(elapsed, duration)
|
||||
: elapsed % duration;
|
||||
}
|
||||
|
||||
export function animatedImplicitParameterValues(definition, animationOrId, baseValues = {}, elapsedSec = 0) {
|
||||
const animation = typeof animationOrId === "string"
|
||||
? findImplicitAnimation(definition, animationOrId)
|
||||
: animationOrId;
|
||||
const normalizedBase = normalizeParameterValues(definition, baseValues);
|
||||
if (!definition || typeof animation?.update !== "function") {
|
||||
return normalizedBase;
|
||||
}
|
||||
const duration = Math.max(finiteNumber(animation.duration, 1), 0.001);
|
||||
const elapsed = normalizeImplicitAnimationElapsed(elapsedSec, animation);
|
||||
const nextValues = { ...normalizedBase };
|
||||
const set = (parameterId, value) => {
|
||||
const id = String(parameterId || "").trim();
|
||||
const parameter = definition.parameterMap?.[id];
|
||||
if (parameter) {
|
||||
nextValues[id] = normalizeParameterValue(parameter, value);
|
||||
}
|
||||
};
|
||||
|
||||
animation.update({
|
||||
...normalizedBase,
|
||||
elapsed,
|
||||
elapsedSec: elapsed,
|
||||
duration,
|
||||
progress: clamp(elapsed / duration, 0, 1),
|
||||
cycle: elapsed / duration,
|
||||
t: elapsed,
|
||||
time: elapsed,
|
||||
loop: animation.loop !== false,
|
||||
params: normalizedBase,
|
||||
set
|
||||
});
|
||||
|
||||
return nextValues;
|
||||
}
|
||||
|
||||
export function buildImplicitAnimatedModel(definition, {
|
||||
animationId = "",
|
||||
elapsedSec = 0,
|
||||
params = null,
|
||||
parameterValues = null,
|
||||
playing = false,
|
||||
speed = 1
|
||||
} = {}) {
|
||||
if (!definition?.buildModel) {
|
||||
throw new Error("Implicit animation export requires a compiled implicit definition.");
|
||||
}
|
||||
const animation = findImplicitAnimation(definition, animationId);
|
||||
const baseValues = params || parameterValues || definition.defaultParameterValues || {};
|
||||
const animatedValues = animatedImplicitParameterValues(definition, animation, baseValues, elapsedSec);
|
||||
return definition.buildModel(animatedValues, {
|
||||
activeId: animation?.id || animationId,
|
||||
elapsedSec: normalizeImplicitAnimationElapsed(elapsedSec, animation),
|
||||
playing,
|
||||
speed
|
||||
});
|
||||
}
|
||||
|
||||
export const implicitAnimatedParameterValues = animatedImplicitParameterValues;
|
||||
export const buildImplicitAnimationModel = buildImplicitAnimatedModel;
|
||||
@@ -0,0 +1,123 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { normalizeImplicitCadModel } from "./model.js";
|
||||
import {
|
||||
exportImplicitCadAnimatedGlb,
|
||||
exportImplicitCadModel,
|
||||
IMPLICIT_CAD_EXPORT_FORMATS,
|
||||
normalizeImplicitExportFormat
|
||||
} from "./exportModel.js";
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeFormat(value) {
|
||||
return normalizeImplicitExportFormat(value);
|
||||
}
|
||||
|
||||
function formatFromOutputPath(outputPath) {
|
||||
return normalizeFormat(path.extname(String(outputPath || "")).slice(1));
|
||||
}
|
||||
|
||||
export function defaultImplicitCadExportPath(inputPath, format = "glb") {
|
||||
const normalizedFormat = normalizeFormat(format) || "glb";
|
||||
const raw = String(inputPath || "").trim();
|
||||
const basename = raw
|
||||
.replace(/\.implicit\.(?:mjs|js)$/i, "")
|
||||
.replace(/\.(?:mjs|js)$/i, "");
|
||||
return `${basename}.${normalizedFormat}`;
|
||||
}
|
||||
|
||||
export async function loadImplicitCadModelFromPath(inputPath, {
|
||||
params = null,
|
||||
parameterValues = null,
|
||||
animationState = null,
|
||||
} = {}) {
|
||||
const resolvedInput = path.resolve(String(inputPath || ""));
|
||||
const stats = await fs.stat(resolvedInput);
|
||||
if (!stats.isFile()) {
|
||||
throw new Error(`Implicit CAD input is not a file: ${resolvedInput}`);
|
||||
}
|
||||
const inputUrl = pathToFileURL(resolvedInput);
|
||||
inputUrl.searchParams.set("mtime", String(Number(stats.mtimeMs)));
|
||||
const moduleValue = await import(inputUrl.href);
|
||||
const defaultModel = normalizeImplicitCadModel(moduleValue, { sourceUrl: inputUrl.href });
|
||||
const nextParams = isObject(params) ? params : isObject(parameterValues) ? parameterValues : null;
|
||||
if (nextParams || animationState) {
|
||||
return defaultModel.definition.buildModel(
|
||||
nextParams || defaultModel.defaultParameterValues,
|
||||
isObject(animationState) ? animationState : defaultModel.animationState
|
||||
);
|
||||
}
|
||||
return defaultModel;
|
||||
}
|
||||
|
||||
export async function exportImplicitCadFile({
|
||||
input,
|
||||
output = "",
|
||||
format = "",
|
||||
params = null,
|
||||
parameterValues = null,
|
||||
animationState = null,
|
||||
animated = false,
|
||||
frames = undefined,
|
||||
duration = undefined,
|
||||
resolution = 96,
|
||||
maxCells = undefined,
|
||||
normalEpsilon = undefined,
|
||||
smoothNormals = undefined,
|
||||
} = {}) {
|
||||
const inputPath = path.resolve(String(input || ""));
|
||||
const outputFormat = animated ? "glb" : normalizeFormat(format) || formatFromOutputPath(output) || "glb";
|
||||
const outputPath = path.resolve(output || defaultImplicitCadExportPath(inputPath, outputFormat));
|
||||
const model = await loadImplicitCadModelFromPath(inputPath, {
|
||||
params,
|
||||
parameterValues,
|
||||
animationState,
|
||||
});
|
||||
const result = animated
|
||||
? exportImplicitCadAnimatedGlb(model, {
|
||||
animationId: animationState?.activeId,
|
||||
params: model.parameterValues,
|
||||
duration,
|
||||
frames,
|
||||
resolution,
|
||||
maxCells,
|
||||
normalEpsilon,
|
||||
smoothNormals,
|
||||
})
|
||||
: exportImplicitCadModel(model, {
|
||||
format: outputFormat,
|
||||
resolution,
|
||||
maxCells,
|
||||
normalEpsilon,
|
||||
smoothNormals,
|
||||
});
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(outputPath, result.body);
|
||||
return {
|
||||
ok: true,
|
||||
input: inputPath,
|
||||
output: outputPath,
|
||||
format: result.format,
|
||||
contentType: result.contentType,
|
||||
bytes: result.body.length,
|
||||
triangleCount: result.mesh.triangleCount,
|
||||
vertexCount: result.mesh.vertexCount,
|
||||
grid: result.mesh.grid,
|
||||
model: {
|
||||
name: result.model.name,
|
||||
bounds: result.model.bounds,
|
||||
units: result.model.units,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
exportImplicitCadAnimatedGlb,
|
||||
exportImplicitCadModel,
|
||||
IMPLICIT_CAD_EXPORT_FORMATS
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
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 {
|
||||
exportImplicitCadAnimatedGlb,
|
||||
exportImplicitCadFile,
|
||||
exportImplicitCadModel,
|
||||
} from "./export.js";
|
||||
|
||||
const sphereModel = {
|
||||
schema: "implicit.js/0.1.0",
|
||||
name: "export sphere",
|
||||
bounds: [[-8, -8, -8], [8, 8, 8]],
|
||||
glsl: `
|
||||
float sdf(vec3 p) { return implicit_sphere(p, vec3(0.0), 5.0); }
|
||||
vec3 color(vec3 p, vec3 normal) { return vec3(0.53, 0.80, 1.0); }
|
||||
`,
|
||||
};
|
||||
|
||||
function parseGlbJson(buffer) {
|
||||
assert.equal(buffer.toString("utf-8", 0, 4), "glTF");
|
||||
assert.equal(buffer.readUInt32LE(4), 2);
|
||||
assert.equal(buffer.readUInt32LE(16), 0x4e4f534a);
|
||||
const jsonLength = buffer.readUInt32LE(12);
|
||||
return JSON.parse(buffer.toString("utf-8", 20, 20 + jsonLength).trim());
|
||||
}
|
||||
|
||||
test("exportImplicitCadModel writes GLB, STL, and 3MF buffers", () => {
|
||||
for (const format of ["glb", "stl", "3mf"]) {
|
||||
const result = exportImplicitCadModel(sphereModel, { format, resolution: 12 });
|
||||
assert.equal(result.format, format);
|
||||
assert.ok(result.body.length > 100);
|
||||
assert.ok(result.mesh.triangleCount > 0);
|
||||
if (format === "glb") {
|
||||
assert.equal(result.body.toString("utf-8", 0, 4), "glTF");
|
||||
}
|
||||
if (format === "3mf") {
|
||||
assert.equal(result.body.readUInt32LE(0), 0x04034b50);
|
||||
assert.ok(result.body.includes(Buffer.from('displaycolor="#87CCFFFF"')));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("exportImplicitCadModel applies parameter values before meshing", () => {
|
||||
const result = exportImplicitCadModel({
|
||||
schema: "implicit.js/0.1.0",
|
||||
name: "param sphere",
|
||||
params: {
|
||||
radius: { type: "number", min: 2, max: 10, default: 3 }
|
||||
},
|
||||
bounds: ({ params }) => {
|
||||
const radius = params.radius + 1;
|
||||
return [[-radius, -radius, -radius], [radius, radius, radius]];
|
||||
},
|
||||
glsl: "float sdf(vec3 p) { return length(p) - radius; }"
|
||||
}, {
|
||||
format: "glb",
|
||||
params: { radius: 6 },
|
||||
resolution: 10,
|
||||
});
|
||||
assert.equal(result.model.parameterValues.radius, 6);
|
||||
assert.equal(result.model.bounds.max[0], 7);
|
||||
assert.ok(result.mesh.triangleCount > 0);
|
||||
});
|
||||
|
||||
test("exportImplicitCadFile applies parameter values and writes next to source by default", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "implicit-cad-export-"));
|
||||
const input = path.join(tempDir, "orb.implicit.js");
|
||||
fs.writeFileSync(input, `
|
||||
export default {
|
||||
schema: "implicit.js/0.1.0",
|
||||
name: "param orb",
|
||||
params: {
|
||||
radius: { type: "number", min: 2, max: 8, default: 3 }
|
||||
},
|
||||
bounds: ({ params }) => [[-params.radius - 2, -params.radius - 2, -params.radius - 2], [params.radius + 2, params.radius + 2, params.radius + 2]],
|
||||
glsl: \`float sdf(vec3 p) { return length(p) - radius; }\`
|
||||
};
|
||||
`, "utf-8");
|
||||
|
||||
const result = await exportImplicitCadFile({
|
||||
input,
|
||||
format: "stl",
|
||||
params: { radius: 6 },
|
||||
resolution: 10,
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.output, path.join(tempDir, "orb.stl"));
|
||||
assert.equal(fs.existsSync(result.output), true);
|
||||
assert.ok(result.triangleCount > 0);
|
||||
});
|
||||
|
||||
test("implicit GLB exports include CAD-native millimeter metadata", () => {
|
||||
const result = exportImplicitCadModel(sphereModel, { format: "glb", resolution: 12 });
|
||||
const json = parseGlbJson(result.body);
|
||||
assert.equal(json.nodes[0].extras.cadOccurrenceId, "implicit-cad:0");
|
||||
assert.equal(json.nodes[0].extras.cadSourceKind, "implicit-cad");
|
||||
assert.equal(json.nodes[0].extras.cadUnits, "mm");
|
||||
assert.equal(json.materials[0].extras.cadSourceColor, true);
|
||||
assert.deepEqual(json.meshes[0].primitives[0].attributes, { POSITION: 0, NORMAL: 1 });
|
||||
});
|
||||
|
||||
test("implicit animated GLB exports morph target animation", () => {
|
||||
const result = exportImplicitCadAnimatedGlb({
|
||||
schema: "implicit.js/0.1.0",
|
||||
name: "animated sphere",
|
||||
params: {
|
||||
radius: { type: "number", min: 2, max: 8, default: 4 }
|
||||
},
|
||||
animations: {
|
||||
pulse: {
|
||||
duration: 1,
|
||||
update({ progress, set }) {
|
||||
set("radius", 4 + Math.sin(progress * Math.PI) * 0.75);
|
||||
}
|
||||
}
|
||||
},
|
||||
bounds: ({ params }) => [[-params.radius - 2, -params.radius - 2, -params.radius - 2], [params.radius + 2, params.radius + 2, params.radius + 2]],
|
||||
glsl: "float sdf(vec3 p) { return length(p) - radius; }"
|
||||
}, {
|
||||
animationId: "pulse",
|
||||
frames: 4,
|
||||
resolution: 10,
|
||||
});
|
||||
assert.equal(result.format, "glb");
|
||||
assert.ok(result.body.length > 100);
|
||||
const json = parseGlbJson(result.body);
|
||||
assert.equal(json.animations.length, 1);
|
||||
assert.ok(json.meshes[0].primitives[0].targets.length > 0);
|
||||
assert.equal(json.nodes[0].extras.implicitjsAnimated, true);
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
import { buildImplicitAnimatedModel, findImplicitAnimation } from "./animation.js";
|
||||
import { meshImplicitCadModel } from "./mesh.js";
|
||||
import { normalizeImplicitCadModel } from "./model.js";
|
||||
import { createImplicitCadColorEvaluator, createImplicitCadSdfEvaluator } from "./sdfEvaluator.js";
|
||||
import { meshToAnimatedGlb, meshToFormat } from "./exporters.js";
|
||||
|
||||
export const IMPLICIT_CAD_EXPORT_FORMATS = Object.freeze(["glb", "stl", "3mf"]);
|
||||
export const IMPLICIT_EXPORT_FORMATS = IMPLICIT_CAD_EXPORT_FORMATS;
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function finiteNumber(value, fallback = 0) {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : fallback;
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
export function normalizeImplicitExportFormat(value) {
|
||||
const normalized = String(value || "").trim().toLowerCase().replace(/^\./, "");
|
||||
if (normalized === "gltf") {
|
||||
return "glb";
|
||||
}
|
||||
return IMPLICIT_CAD_EXPORT_FORMATS.includes(normalized) ? normalized : "";
|
||||
}
|
||||
|
||||
function rgb01ToHex(rgb) {
|
||||
const channel = (value) => {
|
||||
const numeric = Number(value);
|
||||
const clamped = Math.min(Math.max(Number.isFinite(numeric) ? numeric : 0, 0), 1);
|
||||
return Math.round(clamped * 255).toString(16).padStart(2, "0");
|
||||
};
|
||||
return `#${channel(rgb?.[0])}${channel(rgb?.[1])}${channel(rgb?.[2])}`;
|
||||
}
|
||||
|
||||
function sampleImplicitCadMeshColor(model, mesh, { sampleLimit = 768 } = {}) {
|
||||
if (!model?.colorSource) {
|
||||
return model?.material?.color;
|
||||
}
|
||||
try {
|
||||
const colorAt = createImplicitCadColorEvaluator(model);
|
||||
const positions = mesh.positions || new Float32Array();
|
||||
const normals = mesh.normals && mesh.normals.length === positions.length
|
||||
? mesh.normals
|
||||
: null;
|
||||
const vertexCount = Math.floor(positions.length / 3);
|
||||
const stride = Math.max(1, Math.floor(vertexCount / Math.max(sampleLimit, 1)));
|
||||
const sum = [0, 0, 0];
|
||||
let count = 0;
|
||||
for (let vertex = 0; vertex < vertexCount; vertex += stride) {
|
||||
const offset = vertex * 3;
|
||||
const color = colorAt(
|
||||
[positions[offset], positions[offset + 1], positions[offset + 2]],
|
||||
normals ? [normals[offset], normals[offset + 1], normals[offset + 2]] : [0, 0, 1]
|
||||
);
|
||||
if (!color.every((component) => Number.isFinite(component))) {
|
||||
continue;
|
||||
}
|
||||
sum[0] += color[0];
|
||||
sum[1] += color[1];
|
||||
sum[2] += color[2];
|
||||
count += 1;
|
||||
}
|
||||
return count ? rgb01ToHex(sum.map((component) => component / count)) : model?.material?.color;
|
||||
} catch {
|
||||
return model?.material?.color;
|
||||
}
|
||||
}
|
||||
|
||||
function estimateGradient(sdf, point, epsilon) {
|
||||
const [x, y, z] = point;
|
||||
const gradient = [
|
||||
finiteNumber(sdf(x + epsilon, y, z), 0) - finiteNumber(sdf(x - epsilon, y, z), 0),
|
||||
finiteNumber(sdf(x, y + epsilon, z), 0) - finiteNumber(sdf(x, y - epsilon, z), 0),
|
||||
finiteNumber(sdf(x, y, z + epsilon), 0) - finiteNumber(sdf(x, y, z - epsilon), 0),
|
||||
];
|
||||
const length = Math.hypot(gradient[0], gradient[1], gradient[2]);
|
||||
return length > 1e-9
|
||||
? [gradient[0] / length, gradient[1] / length, gradient[2] / length]
|
||||
: [0, 0, 1];
|
||||
}
|
||||
|
||||
function projectMeshPositionsToModel(basePositions, targetModel, {
|
||||
iterations = 8,
|
||||
maxStep = null,
|
||||
} = {}) {
|
||||
const sdf = createImplicitCadSdfEvaluator(targetModel);
|
||||
const targetPositions = new Float32Array(basePositions.length);
|
||||
const epsilon = Math.max(finiteNumber(targetModel.normalEpsilon, targetModel.radius * 0.001), 1e-4);
|
||||
const stepLimit = Math.max(
|
||||
finiteNumber(maxStep, finiteNumber(targetModel.maxStep, targetModel.radius * 0.08)),
|
||||
epsilon
|
||||
);
|
||||
for (let index = 0; index < basePositions.length; index += 3) {
|
||||
const point = [
|
||||
basePositions[index],
|
||||
basePositions[index + 1],
|
||||
basePositions[index + 2],
|
||||
];
|
||||
for (let pass = 0; pass < iterations; pass += 1) {
|
||||
const distance = finiteNumber(sdf(point[0], point[1], point[2]), 0);
|
||||
if (Math.abs(distance) <= epsilon * 0.35) {
|
||||
break;
|
||||
}
|
||||
const normal = estimateGradient(sdf, point, epsilon);
|
||||
const step = clamp(distance, -stepLimit, stepLimit);
|
||||
point[0] -= normal[0] * step;
|
||||
point[1] -= normal[1] * step;
|
||||
point[2] -= normal[2] * step;
|
||||
}
|
||||
targetPositions[index] = point[0];
|
||||
targetPositions[index + 1] = point[1];
|
||||
targetPositions[index + 2] = point[2];
|
||||
}
|
||||
return targetPositions;
|
||||
}
|
||||
|
||||
function definitionFromModel(modelValue) {
|
||||
if (modelValue?.definition?.buildModel) {
|
||||
return modelValue.definition;
|
||||
}
|
||||
if (modelValue?.buildModel) {
|
||||
return modelValue;
|
||||
}
|
||||
return normalizeImplicitCadModel(modelValue).definition;
|
||||
}
|
||||
|
||||
function runtimeModelForExport(modelValue, {
|
||||
params = null,
|
||||
parameterValues = null,
|
||||
animationState = null,
|
||||
} = {}) {
|
||||
const model = normalizeImplicitCadModel(modelValue);
|
||||
const nextParams = isObject(params)
|
||||
? params
|
||||
: isObject(parameterValues)
|
||||
? parameterValues
|
||||
: null;
|
||||
if (!nextParams && !animationState) {
|
||||
return model;
|
||||
}
|
||||
return typeof model.definition?.buildModel === "function"
|
||||
? model.definition.buildModel(
|
||||
nextParams || model.parameterValues || model.defaultParameterValues || {},
|
||||
isObject(animationState) ? animationState : model.animationState || {}
|
||||
)
|
||||
: model;
|
||||
}
|
||||
|
||||
export function exportImplicitCadModel(modelValue, {
|
||||
format = "glb",
|
||||
params = null,
|
||||
parameterValues = null,
|
||||
animationState = null,
|
||||
resolution = 96,
|
||||
maxCells = undefined,
|
||||
normalEpsilon = undefined,
|
||||
smoothNormals = undefined,
|
||||
} = {}) {
|
||||
const model = runtimeModelForExport(modelValue, {
|
||||
params,
|
||||
parameterValues,
|
||||
animationState,
|
||||
});
|
||||
const outputFormat = normalizeImplicitExportFormat(format);
|
||||
const mesh = meshImplicitCadModel(model, {
|
||||
resolution,
|
||||
maxCells,
|
||||
normalEpsilon,
|
||||
smoothNormals: smoothNormals ?? outputFormat === "glb",
|
||||
});
|
||||
if (!mesh.triangleCount) {
|
||||
throw new Error("Implicit CAD export produced an empty mesh. Check bounds, parameters, and resolution.");
|
||||
}
|
||||
const exported = meshToFormat(mesh, format, {
|
||||
name: model.name,
|
||||
color: sampleImplicitCadMeshColor(model, mesh),
|
||||
});
|
||||
return {
|
||||
...exported,
|
||||
mesh,
|
||||
model,
|
||||
format: outputFormat,
|
||||
};
|
||||
}
|
||||
|
||||
export function exportImplicitCadAnimatedGlb(modelOrDefinition, {
|
||||
animationId = "",
|
||||
params = null,
|
||||
parameterValues = null,
|
||||
duration = undefined,
|
||||
frames = 18,
|
||||
resolution = 64,
|
||||
maxCells = undefined,
|
||||
normalEpsilon = undefined,
|
||||
smoothNormals = true,
|
||||
projectionIterations = 8,
|
||||
} = {}) {
|
||||
const definition = definitionFromModel(modelOrDefinition);
|
||||
const animation = findImplicitAnimation(definition, animationId);
|
||||
if (!animation) {
|
||||
throw new Error("Animated GLB export requires an animation on the implicit model.");
|
||||
}
|
||||
const baseValues = isObject(params)
|
||||
? params
|
||||
: isObject(parameterValues)
|
||||
? parameterValues
|
||||
: modelOrDefinition?.parameterValues || definition.defaultParameterValues || {};
|
||||
const durationSeconds = Math.max(finiteNumber(duration, animation.duration), 0.001);
|
||||
const frameCount = Math.max(3, Math.min(Math.floor(finiteNumber(frames, 18)), 48));
|
||||
const baseModel = buildImplicitAnimatedModel(definition, {
|
||||
animationId: animation.id,
|
||||
params: baseValues,
|
||||
elapsedSec: 0,
|
||||
playing: false
|
||||
});
|
||||
const baseMesh = meshImplicitCadModel(baseModel, {
|
||||
resolution,
|
||||
maxCells,
|
||||
normalEpsilon,
|
||||
smoothNormals,
|
||||
});
|
||||
if (!baseMesh.triangleCount) {
|
||||
throw new Error("Animated GLB export produced an empty base mesh. Check bounds, parameters, and resolution.");
|
||||
}
|
||||
|
||||
const targetPositions = [];
|
||||
const times = [];
|
||||
for (let frame = 0; frame < frameCount; frame += 1) {
|
||||
const elapsedSec = (durationSeconds * frame) / (frameCount - 1);
|
||||
times.push(elapsedSec);
|
||||
if (frame === 0 || (animation.loop !== false && frame === frameCount - 1)) {
|
||||
continue;
|
||||
}
|
||||
const frameModel = buildImplicitAnimatedModel(definition, {
|
||||
animationId: animation.id,
|
||||
params: baseValues,
|
||||
elapsedSec,
|
||||
playing: false
|
||||
});
|
||||
targetPositions.push(projectMeshPositionsToModel(baseMesh.positions, frameModel, {
|
||||
iterations: projectionIterations,
|
||||
}));
|
||||
}
|
||||
|
||||
const body = meshToAnimatedGlb(baseMesh, {
|
||||
name: baseModel.name,
|
||||
color: sampleImplicitCadMeshColor(baseModel, baseMesh),
|
||||
duration: durationSeconds,
|
||||
times,
|
||||
targetPositions,
|
||||
});
|
||||
return {
|
||||
body,
|
||||
contentType: "model/gltf-binary",
|
||||
extension: ".glb",
|
||||
format: "glb",
|
||||
mesh: baseMesh,
|
||||
model: baseModel,
|
||||
animation,
|
||||
frameCount,
|
||||
duration: durationSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
export const exportImplicitModel = exportImplicitCadModel;
|
||||
export const exportImplicitAnimatedGlb = exportImplicitCadAnimatedGlb;
|
||||
@@ -0,0 +1,585 @@
|
||||
const NativeBuffer = globalThis.Buffer;
|
||||
const textEncoder = typeof TextEncoder !== "undefined" ? new TextEncoder() : null;
|
||||
|
||||
function finiteNumber(value, fallback = 0) {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : fallback;
|
||||
}
|
||||
|
||||
function clamp01(value) {
|
||||
return Math.min(Math.max(finiteNumber(value), 0), 1);
|
||||
}
|
||||
|
||||
function bytesFromString(value, encoding = "utf-8") {
|
||||
if (NativeBuffer?.from) {
|
||||
return NativeBuffer.from(String(value), encoding);
|
||||
}
|
||||
if (encoding !== "utf-8" && encoding !== "utf8") {
|
||||
const text = String(value);
|
||||
const bytes = new Uint8Array(text.length);
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
bytes[index] = text.charCodeAt(index) & 0xff;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
return textEncoder.encode(String(value));
|
||||
}
|
||||
|
||||
function allocBytes(length, fill = 0) {
|
||||
if (NativeBuffer?.alloc) {
|
||||
return NativeBuffer.alloc(length, fill);
|
||||
}
|
||||
const bytes = new Uint8Array(length);
|
||||
if (fill) {
|
||||
bytes.fill(fill);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function concatBytes(parts, totalLength = undefined) {
|
||||
if (NativeBuffer?.concat) {
|
||||
return NativeBuffer.concat(parts, totalLength);
|
||||
}
|
||||
const length = totalLength ?? parts.reduce((sum, part) => sum + part.length, 0);
|
||||
const bytes = new Uint8Array(length);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
bytes.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function typedArrayBytes(array) {
|
||||
return new Uint8Array(array.buffer, array.byteOffset, array.byteLength);
|
||||
}
|
||||
|
||||
function viewFor(bytes) {
|
||||
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
}
|
||||
|
||||
function writeUInt16LE(bytes, offset, value) {
|
||||
viewFor(bytes).setUint16(offset, value, true);
|
||||
}
|
||||
|
||||
function writeUInt32LE(bytes, offset, value) {
|
||||
viewFor(bytes).setUint32(offset, value, true);
|
||||
}
|
||||
|
||||
function writeFloatLE(bytes, offset, value) {
|
||||
viewFor(bytes).setFloat32(offset, value, true);
|
||||
}
|
||||
|
||||
function writeAscii(bytes, offset, text, maxLength) {
|
||||
const source = String(text).slice(0, maxLength);
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
bytes[offset + index] = source.charCodeAt(index) & 0x7f;
|
||||
}
|
||||
}
|
||||
|
||||
function align4Buffer(buffer, fill = 0x20) {
|
||||
const padding = (4 - (buffer.length % 4)) % 4;
|
||||
return padding ? concatBytes([buffer, allocBytes(padding, fill)]) : buffer;
|
||||
}
|
||||
|
||||
function hexToRgb01(hex, fallback = "#d4d4d8") {
|
||||
const raw = String(hex || fallback).trim();
|
||||
const value = /^#(?:[0-9a-fA-F]{3}){1,2}$/.test(raw) ? raw : fallback;
|
||||
const expanded = value.length === 4
|
||||
? `${value[1]}${value[1]}${value[2]}${value[2]}${value[3]}${value[3]}`
|
||||
: value.slice(1);
|
||||
return [
|
||||
parseInt(expanded.slice(0, 2), 16) / 255,
|
||||
parseInt(expanded.slice(2, 4), 16) / 255,
|
||||
parseInt(expanded.slice(4, 6), 16) / 255,
|
||||
];
|
||||
}
|
||||
|
||||
function hexTo3mfDisplayColor(hex, fallback = "#d4d4d8") {
|
||||
const toChannel = (component) => Math.round(clamp01(component) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0")
|
||||
.toUpperCase();
|
||||
const rgb = hexToRgb01(hex, fallback);
|
||||
return `#${toChannel(rgb[0])}${toChannel(rgb[1])}${toChannel(rgb[2])}FF`;
|
||||
}
|
||||
|
||||
function triangleNormal(positions, offset) {
|
||||
const ax = positions[offset];
|
||||
const ay = positions[offset + 1];
|
||||
const az = positions[offset + 2];
|
||||
const bx = positions[offset + 3];
|
||||
const by = positions[offset + 4];
|
||||
const bz = positions[offset + 5];
|
||||
const cx = positions[offset + 6];
|
||||
const cy = positions[offset + 7];
|
||||
const cz = positions[offset + 8];
|
||||
const ux = bx - ax;
|
||||
const uy = by - ay;
|
||||
const uz = bz - az;
|
||||
const vx = cx - ax;
|
||||
const vy = cy - ay;
|
||||
const vz = cz - az;
|
||||
const nx = uy * vz - uz * vy;
|
||||
const ny = uz * vx - ux * vz;
|
||||
const nz = ux * vy - uy * vx;
|
||||
const length = Math.hypot(nx, ny, nz);
|
||||
return length > 1e-12 ? [nx / length, ny / length, nz / length] : [0, 0, 1];
|
||||
}
|
||||
|
||||
function sanitizeName(value, fallback = "implicit-cad") {
|
||||
return String(value || fallback).trim().replace(/[\x00-\x1f<>:"/\\|?*]+/g, "-") || fallback;
|
||||
}
|
||||
|
||||
function boundsForPositions(positions) {
|
||||
const min = [Infinity, Infinity, Infinity];
|
||||
const max = [-Infinity, -Infinity, -Infinity];
|
||||
for (let index = 0; index < positions.length; index += 3) {
|
||||
min[0] = Math.min(min[0], positions[index]);
|
||||
min[1] = Math.min(min[1], positions[index + 1]);
|
||||
min[2] = Math.min(min[2], positions[index + 2]);
|
||||
max[0] = Math.max(max[0], positions[index]);
|
||||
max[1] = Math.max(max[1], positions[index + 1]);
|
||||
max[2] = Math.max(max[2], positions[index + 2]);
|
||||
}
|
||||
return {
|
||||
min: min.map((value) => Number.isFinite(value) ? value : 0),
|
||||
max: max.map((value) => Number.isFinite(value) ? value : 0),
|
||||
};
|
||||
}
|
||||
|
||||
function buildGlb(gltf, binaryParts) {
|
||||
const binaryChunk = align4Buffer(concatBytes(binaryParts), 0);
|
||||
gltf.buffers = [{ byteLength: binaryChunk.length }];
|
||||
const jsonChunk = align4Buffer(bytesFromString(JSON.stringify(gltf)), 0x20);
|
||||
const totalLength = 12 + 8 + jsonChunk.length + 8 + binaryChunk.length;
|
||||
const header = allocBytes(12);
|
||||
writeUInt32LE(header, 0, 0x46546c67);
|
||||
writeUInt32LE(header, 4, 2);
|
||||
writeUInt32LE(header, 8, totalLength);
|
||||
const jsonHeader = allocBytes(8);
|
||||
writeUInt32LE(jsonHeader, 0, jsonChunk.length);
|
||||
writeUInt32LE(jsonHeader, 4, 0x4e4f534a);
|
||||
const binHeader = allocBytes(8);
|
||||
writeUInt32LE(binHeader, 0, binaryChunk.length);
|
||||
writeUInt32LE(binHeader, 4, 0x004e4942);
|
||||
return concatBytes([header, jsonHeader, jsonChunk, binHeader, binaryChunk], totalLength);
|
||||
}
|
||||
|
||||
export function meshToBinaryStl(mesh, { name = "implicit-cad" } = {}) {
|
||||
const positions = mesh.positions || new Float32Array();
|
||||
const triangleCount = Math.floor(positions.length / 9);
|
||||
const buffer = allocBytes(84 + triangleCount * 50);
|
||||
writeAscii(buffer, 0, `implicit-cad ${sanitizeName(name)}`, 80);
|
||||
writeUInt32LE(buffer, 80, triangleCount);
|
||||
let offset = 84;
|
||||
for (let triangle = 0; triangle < triangleCount; triangle += 1) {
|
||||
const positionOffset = triangle * 9;
|
||||
const normal = triangleNormal(positions, positionOffset);
|
||||
for (const component of normal) {
|
||||
writeFloatLE(buffer, offset, component);
|
||||
offset += 4;
|
||||
}
|
||||
for (let vertex = 0; vertex < 9; vertex += 1) {
|
||||
writeFloatLE(buffer, offset, positions[positionOffset + vertex]);
|
||||
offset += 4;
|
||||
}
|
||||
writeUInt16LE(buffer, offset, 0);
|
||||
offset += 2;
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export function meshToGlb(mesh, { name = "Implicit CAD", color = "#d4d4d8" } = {}) {
|
||||
const positions = mesh.positions || new Float32Array();
|
||||
const normals = mesh.normals && mesh.normals.length === positions.length
|
||||
? mesh.normals
|
||||
: new Float32Array(positions.length);
|
||||
const vertexCount = Math.floor(positions.length / 3);
|
||||
const positionBuffer = typedArrayBytes(positions);
|
||||
const normalBuffer = typedArrayBytes(normals);
|
||||
const positionBounds = boundsForPositions(positions);
|
||||
const baseColor = hexToRgb01(color);
|
||||
const gltf = {
|
||||
asset: { version: "2.0", generator: "implicitjs exporter" },
|
||||
scene: 0,
|
||||
scenes: [{ nodes: [0] }],
|
||||
nodes: [{
|
||||
mesh: 0,
|
||||
name: sanitizeName(name, "Implicit CAD"),
|
||||
extras: {
|
||||
cadOccurrenceId: "implicit-cad:0",
|
||||
cadSourceKind: "implicit-cad",
|
||||
cadUnits: "mm",
|
||||
},
|
||||
}],
|
||||
meshes: [{
|
||||
primitives: [{
|
||||
attributes: { POSITION: 0, NORMAL: 1 },
|
||||
material: 0,
|
||||
mode: 4,
|
||||
}],
|
||||
}],
|
||||
materials: [{
|
||||
name: "Implicit material",
|
||||
doubleSided: true,
|
||||
extras: { cadSourceColor: true },
|
||||
pbrMetallicRoughness: {
|
||||
baseColorFactor: [...baseColor.map(clamp01), 1],
|
||||
roughnessFactor: 0.72,
|
||||
metallicFactor: 0.02,
|
||||
},
|
||||
}],
|
||||
bufferViews: [
|
||||
{ buffer: 0, byteOffset: 0, byteLength: positionBuffer.length, target: 34962 },
|
||||
{ buffer: 0, byteOffset: positionBuffer.length, byteLength: normalBuffer.length, target: 34962 },
|
||||
],
|
||||
accessors: [
|
||||
{
|
||||
bufferView: 0,
|
||||
byteOffset: 0,
|
||||
componentType: 5126,
|
||||
count: vertexCount,
|
||||
type: "VEC3",
|
||||
min: positionBounds.min,
|
||||
max: positionBounds.max,
|
||||
},
|
||||
{
|
||||
bufferView: 1,
|
||||
byteOffset: 0,
|
||||
componentType: 5126,
|
||||
count: vertexCount,
|
||||
type: "VEC3",
|
||||
},
|
||||
],
|
||||
};
|
||||
return buildGlb(gltf, [positionBuffer, normalBuffer]);
|
||||
}
|
||||
|
||||
export function meshToAnimatedGlb(mesh, {
|
||||
name = "Implicit CAD",
|
||||
color = "#d4d4d8",
|
||||
duration = 4,
|
||||
times = [],
|
||||
targetPositions = [],
|
||||
} = {}) {
|
||||
const positions = mesh.positions || new Float32Array();
|
||||
const normals = mesh.normals && mesh.normals.length === positions.length
|
||||
? mesh.normals
|
||||
: new Float32Array(positions.length);
|
||||
const vertexCount = Math.floor(positions.length / 3);
|
||||
const positionBuffer = typedArrayBytes(positions);
|
||||
const normalBuffer = typedArrayBytes(normals);
|
||||
const positionBounds = boundsForPositions(positions);
|
||||
const cleanTargets = targetPositions
|
||||
.filter((target) => target && target.length === positions.length)
|
||||
.map((target) => {
|
||||
const delta = new Float32Array(positions.length);
|
||||
for (let index = 0; index < positions.length; index += 1) {
|
||||
delta[index] = finiteNumber(target[index], positions[index]) - positions[index];
|
||||
}
|
||||
return delta;
|
||||
});
|
||||
if (!cleanTargets.length) {
|
||||
throw new Error("Animated GLB export needs at least one compatible animation frame.");
|
||||
}
|
||||
|
||||
const keyTimes = Array.isArray(times) && times.length >= 2
|
||||
? times.map((value) => Math.max(finiteNumber(value, 0), 0))
|
||||
: [0, Math.max(finiteNumber(duration, 4), 0.001)];
|
||||
const targetCount = cleanTargets.length;
|
||||
const weightValues = new Float32Array(keyTimes.length * targetCount);
|
||||
for (let key = 0; key < keyTimes.length; key += 1) {
|
||||
const targetIndex = key - 1;
|
||||
if (targetIndex >= 0 && targetIndex < targetCount) {
|
||||
weightValues[key * targetCount + targetIndex] = 1;
|
||||
}
|
||||
}
|
||||
const targetBuffers = cleanTargets.map((target) => typedArrayBytes(target));
|
||||
const timeValues = new Float32Array(keyTimes);
|
||||
const timeBuffer = typedArrayBytes(timeValues);
|
||||
const weightBuffer = typedArrayBytes(weightValues);
|
||||
const offsets = [];
|
||||
let byteOffset = 0;
|
||||
for (const part of [positionBuffer, normalBuffer, ...targetBuffers, timeBuffer, weightBuffer]) {
|
||||
offsets.push(byteOffset);
|
||||
byteOffset += part.length;
|
||||
}
|
||||
const baseColor = hexToRgb01(color);
|
||||
const targetAccessorStart = 2;
|
||||
const timeAccessor = targetAccessorStart + targetCount;
|
||||
const weightAccessor = timeAccessor + 1;
|
||||
const gltf = {
|
||||
asset: { version: "2.0", generator: "implicitjs animated exporter" },
|
||||
scene: 0,
|
||||
scenes: [{ nodes: [0] }],
|
||||
nodes: [{
|
||||
mesh: 0,
|
||||
name: sanitizeName(name, "Implicit CAD"),
|
||||
weights: Array.from({ length: targetCount }, () => 0),
|
||||
extras: {
|
||||
cadOccurrenceId: "implicit-cad:0",
|
||||
cadSourceKind: "implicit-cad",
|
||||
cadUnits: "mm",
|
||||
implicitjsAnimated: true,
|
||||
},
|
||||
}],
|
||||
meshes: [{
|
||||
weights: Array.from({ length: targetCount }, () => 0),
|
||||
primitives: [{
|
||||
attributes: { POSITION: 0, NORMAL: 1 },
|
||||
targets: cleanTargets.map((_, index) => ({ POSITION: targetAccessorStart + index })),
|
||||
material: 0,
|
||||
mode: 4,
|
||||
}],
|
||||
}],
|
||||
materials: [{
|
||||
name: "Implicit material",
|
||||
doubleSided: true,
|
||||
extras: { cadSourceColor: true },
|
||||
pbrMetallicRoughness: {
|
||||
baseColorFactor: [...baseColor.map(clamp01), 1],
|
||||
roughnessFactor: 0.72,
|
||||
metallicFactor: 0.02,
|
||||
},
|
||||
}],
|
||||
animations: [{
|
||||
name: "Implicit parameter animation",
|
||||
channels: [{ sampler: 0, target: { node: 0, path: "weights" } }],
|
||||
samplers: [{ input: timeAccessor, output: weightAccessor, interpolation: "LINEAR" }],
|
||||
}],
|
||||
bufferViews: [
|
||||
{ buffer: 0, byteOffset: offsets[0], byteLength: positionBuffer.length, target: 34962 },
|
||||
{ buffer: 0, byteOffset: offsets[1], byteLength: normalBuffer.length, target: 34962 },
|
||||
...targetBuffers.map((target, index) => ({
|
||||
buffer: 0,
|
||||
byteOffset: offsets[2 + index],
|
||||
byteLength: target.length,
|
||||
target: 34962,
|
||||
})),
|
||||
{ buffer: 0, byteOffset: offsets[2 + targetCount], byteLength: timeBuffer.length },
|
||||
{ buffer: 0, byteOffset: offsets[3 + targetCount], byteLength: weightBuffer.length },
|
||||
],
|
||||
accessors: [
|
||||
{
|
||||
bufferView: 0,
|
||||
byteOffset: 0,
|
||||
componentType: 5126,
|
||||
count: vertexCount,
|
||||
type: "VEC3",
|
||||
min: positionBounds.min,
|
||||
max: positionBounds.max,
|
||||
},
|
||||
{
|
||||
bufferView: 1,
|
||||
byteOffset: 0,
|
||||
componentType: 5126,
|
||||
count: vertexCount,
|
||||
type: "VEC3",
|
||||
},
|
||||
...cleanTargets.map((target, index) => {
|
||||
const bounds = boundsForPositions(target);
|
||||
return {
|
||||
bufferView: targetAccessorStart + index,
|
||||
byteOffset: 0,
|
||||
componentType: 5126,
|
||||
count: vertexCount,
|
||||
type: "VEC3",
|
||||
min: bounds.min,
|
||||
max: bounds.max,
|
||||
};
|
||||
}),
|
||||
{
|
||||
bufferView: timeAccessor,
|
||||
byteOffset: 0,
|
||||
componentType: 5126,
|
||||
count: timeValues.length,
|
||||
type: "SCALAR",
|
||||
min: [Math.min(...keyTimes)],
|
||||
max: [Math.max(...keyTimes)],
|
||||
},
|
||||
{
|
||||
bufferView: weightAccessor,
|
||||
byteOffset: 0,
|
||||
componentType: 5126,
|
||||
count: weightValues.length,
|
||||
type: "SCALAR",
|
||||
min: [0],
|
||||
max: [1],
|
||||
},
|
||||
],
|
||||
};
|
||||
return buildGlb(gltf, [positionBuffer, normalBuffer, ...targetBuffers, timeBuffer, weightBuffer]);
|
||||
}
|
||||
|
||||
function xmlEscape(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function crc32(buffer) {
|
||||
let table = crc32.table;
|
||||
if (!table) {
|
||||
table = new Uint32Array(256);
|
||||
for (let index = 0; index < 256; index += 1) {
|
||||
let c = index;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
table[index] = c >>> 0;
|
||||
}
|
||||
crc32.table = table;
|
||||
}
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of buffer) {
|
||||
crc = table[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function dosDateTime(date = new Date()) {
|
||||
const year = Math.max(date.getFullYear(), 1980);
|
||||
const dosTime = (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2);
|
||||
const dosDate = ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate();
|
||||
return { dosTime, dosDate };
|
||||
}
|
||||
|
||||
function zipStore(files) {
|
||||
const localParts = [];
|
||||
const centralParts = [];
|
||||
let offset = 0;
|
||||
const { dosTime, dosDate } = dosDateTime();
|
||||
for (const file of files) {
|
||||
const nameBuffer = bytesFromString(file.name);
|
||||
const body = file.body instanceof Uint8Array ? file.body : bytesFromString(String(file.body || ""));
|
||||
const crc = crc32(body);
|
||||
const local = allocBytes(30);
|
||||
writeUInt32LE(local, 0, 0x04034b50);
|
||||
writeUInt16LE(local, 4, 20);
|
||||
writeUInt16LE(local, 6, 0);
|
||||
writeUInt16LE(local, 8, 0);
|
||||
writeUInt16LE(local, 10, dosTime);
|
||||
writeUInt16LE(local, 12, dosDate);
|
||||
writeUInt32LE(local, 14, crc);
|
||||
writeUInt32LE(local, 18, body.length);
|
||||
writeUInt32LE(local, 22, body.length);
|
||||
writeUInt16LE(local, 26, nameBuffer.length);
|
||||
writeUInt16LE(local, 28, 0);
|
||||
localParts.push(local, nameBuffer, body);
|
||||
|
||||
const central = allocBytes(46);
|
||||
writeUInt32LE(central, 0, 0x02014b50);
|
||||
writeUInt16LE(central, 4, 20);
|
||||
writeUInt16LE(central, 6, 20);
|
||||
writeUInt16LE(central, 8, 0);
|
||||
writeUInt16LE(central, 10, 0);
|
||||
writeUInt16LE(central, 12, dosTime);
|
||||
writeUInt16LE(central, 14, dosDate);
|
||||
writeUInt32LE(central, 16, crc);
|
||||
writeUInt32LE(central, 20, body.length);
|
||||
writeUInt32LE(central, 24, body.length);
|
||||
writeUInt16LE(central, 28, nameBuffer.length);
|
||||
writeUInt16LE(central, 30, 0);
|
||||
writeUInt16LE(central, 32, 0);
|
||||
writeUInt16LE(central, 34, 0);
|
||||
writeUInt16LE(central, 36, 0);
|
||||
writeUInt32LE(central, 38, 0);
|
||||
writeUInt32LE(central, 42, offset);
|
||||
centralParts.push(central, nameBuffer);
|
||||
offset += local.length + nameBuffer.length + body.length;
|
||||
}
|
||||
const centralStart = offset;
|
||||
const centralDirectory = concatBytes(centralParts);
|
||||
const end = allocBytes(22);
|
||||
writeUInt32LE(end, 0, 0x06054b50);
|
||||
writeUInt16LE(end, 4, 0);
|
||||
writeUInt16LE(end, 6, 0);
|
||||
writeUInt16LE(end, 8, files.length);
|
||||
writeUInt16LE(end, 10, files.length);
|
||||
writeUInt32LE(end, 12, centralDirectory.length);
|
||||
writeUInt32LE(end, 16, centralStart);
|
||||
writeUInt16LE(end, 20, 0);
|
||||
return concatBytes([...localParts, centralDirectory, end]);
|
||||
}
|
||||
|
||||
export function meshTo3mf(mesh, { name = "Implicit CAD", color = "#d4d4d8" } = {}) {
|
||||
const positions = mesh.positions || new Float32Array();
|
||||
const vertexCount = Math.floor(positions.length / 3);
|
||||
const triangleCount = Math.floor(positions.length / 9);
|
||||
const displayColor = hexTo3mfDisplayColor(color);
|
||||
const vertices = [];
|
||||
for (let index = 0; index < vertexCount; index += 1) {
|
||||
const offset = index * 3;
|
||||
vertices.push(`<vertex x="${positions[offset]}" y="${positions[offset + 1]}" z="${positions[offset + 2]}"/>`);
|
||||
}
|
||||
const triangles = [];
|
||||
for (let index = 0; index < triangleCount; index += 1) {
|
||||
const base = index * 3;
|
||||
triangles.push(`<triangle v1="${base}" v2="${base + 1}" v3="${base + 2}" pid="2" p1="0"/>`);
|
||||
}
|
||||
const modelXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<model unit="millimeter" xml:lang="en-US" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02">
|
||||
<metadata name="Title">${xmlEscape(name)}</metadata>
|
||||
<resources>
|
||||
<basematerials id="2">
|
||||
<base name="Implicit material" displaycolor="${displayColor}"/>
|
||||
</basematerials>
|
||||
<object id="1" type="model" name="${xmlEscape(name)}">
|
||||
<mesh>
|
||||
<vertices>
|
||||
${vertices.join("\n ")}
|
||||
</vertices>
|
||||
<triangles>
|
||||
${triangles.join("\n ")}
|
||||
</triangles>
|
||||
</mesh>
|
||||
</object>
|
||||
</resources>
|
||||
<build>
|
||||
<item objectid="1"/>
|
||||
</build>
|
||||
</model>`;
|
||||
const contentTypes = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>
|
||||
</Types>`;
|
||||
const relationships = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>
|
||||
</Relationships>`;
|
||||
return zipStore([
|
||||
{ name: "[Content_Types].xml", body: contentTypes },
|
||||
{ name: "_rels/.rels", body: relationships },
|
||||
{ name: "3D/3dmodel.model", body: modelXml },
|
||||
]);
|
||||
}
|
||||
|
||||
export function meshToFormat(mesh, format, options = {}) {
|
||||
const normalized = String(format || "").trim().toLowerCase().replace(/^\./, "");
|
||||
if (normalized === "stl") {
|
||||
return {
|
||||
body: meshToBinaryStl(mesh, options),
|
||||
contentType: "model/stl",
|
||||
extension: ".stl",
|
||||
};
|
||||
}
|
||||
if (normalized === "glb" || normalized === "gltf") {
|
||||
return {
|
||||
body: meshToGlb(mesh, options),
|
||||
contentType: "model/gltf-binary",
|
||||
extension: ".glb",
|
||||
};
|
||||
}
|
||||
if (normalized === "3mf") {
|
||||
return {
|
||||
body: meshTo3mf(mesh, options),
|
||||
contentType: "model/3mf",
|
||||
extension: ".3mf",
|
||||
};
|
||||
}
|
||||
throw new Error(`Unsupported implicit CAD export format: ${format || "(missing)"}`);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
export const DEFAULT_IMPLICIT_GRAPHICS_SETTINGS = Object.freeze({
|
||||
resolutionScale: 2,
|
||||
interactionResolutionScale: 1.25,
|
||||
detail: 1.25,
|
||||
normalSmoothing: 1,
|
||||
modelColors: true,
|
||||
shadows: true,
|
||||
ambientOcclusion: true,
|
||||
rimLight: true
|
||||
});
|
||||
|
||||
export const IMPLICIT_GRAPHICS_LIMITS = Object.freeze({
|
||||
resolutionScale: Object.freeze({ min: 0.5, max: 5, step: 0.05 }),
|
||||
interactionResolutionScale: Object.freeze({ min: 0.25, max: 4, step: 0.05 }),
|
||||
detail: Object.freeze({ min: 0.25, max: 8, step: 0.05 }),
|
||||
normalSmoothing: Object.freeze({ min: 0.25, max: 5, step: 0.05 })
|
||||
});
|
||||
|
||||
export const IMPLICIT_INTERACTION_STEP_BUDGET = 96;
|
||||
export const IMPLICIT_INTERACTION_DETAIL = 0.75;
|
||||
|
||||
function clampNumber(value, fallback, { min = -Infinity, max = Infinity } = {}) {
|
||||
const numericValue = Number(value);
|
||||
const resolvedValue = Number.isFinite(numericValue) ? numericValue : fallback;
|
||||
return Math.min(Math.max(resolvedValue, min), max);
|
||||
}
|
||||
|
||||
function normalizeBoolean(value, fallback) {
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
|
||||
export function normalizeImplicitGraphicsSettings(value = {}) {
|
||||
const settings = value && typeof value === "object" ? value : {};
|
||||
return {
|
||||
resolutionScale: clampNumber(
|
||||
settings.resolutionScale,
|
||||
DEFAULT_IMPLICIT_GRAPHICS_SETTINGS.resolutionScale,
|
||||
IMPLICIT_GRAPHICS_LIMITS.resolutionScale
|
||||
),
|
||||
interactionResolutionScale: clampNumber(
|
||||
settings.interactionResolutionScale,
|
||||
DEFAULT_IMPLICIT_GRAPHICS_SETTINGS.interactionResolutionScale,
|
||||
IMPLICIT_GRAPHICS_LIMITS.interactionResolutionScale
|
||||
),
|
||||
detail: clampNumber(
|
||||
settings.detail,
|
||||
DEFAULT_IMPLICIT_GRAPHICS_SETTINGS.detail,
|
||||
IMPLICIT_GRAPHICS_LIMITS.detail
|
||||
),
|
||||
normalSmoothing: clampNumber(
|
||||
settings.normalSmoothing,
|
||||
DEFAULT_IMPLICIT_GRAPHICS_SETTINGS.normalSmoothing,
|
||||
IMPLICIT_GRAPHICS_LIMITS.normalSmoothing
|
||||
),
|
||||
modelColors: normalizeBoolean(settings.modelColors, DEFAULT_IMPLICIT_GRAPHICS_SETTINGS.modelColors),
|
||||
shadows: normalizeBoolean(settings.shadows, DEFAULT_IMPLICIT_GRAPHICS_SETTINGS.shadows),
|
||||
ambientOcclusion: normalizeBoolean(
|
||||
settings.ambientOcclusion,
|
||||
DEFAULT_IMPLICIT_GRAPHICS_SETTINGS.ambientOcclusion
|
||||
),
|
||||
rimLight: normalizeBoolean(settings.rimLight, DEFAULT_IMPLICIT_GRAPHICS_SETTINGS.rimLight)
|
||||
};
|
||||
}
|
||||
|
||||
export function implicitGraphicsRenderResolutionScale(value = {}, { interaction = false } = {}) {
|
||||
const settings = normalizeImplicitGraphicsSettings(value);
|
||||
return interaction ? settings.interactionResolutionScale : settings.resolutionScale;
|
||||
}
|
||||
|
||||
export function implicitGraphicsRenderSettings(value = {}, { interaction = false } = {}) {
|
||||
const settings = normalizeImplicitGraphicsSettings(value);
|
||||
if (!interaction) {
|
||||
return settings;
|
||||
}
|
||||
const source = value && typeof value === "object" ? value : {};
|
||||
const requestedStepBudget = Number.isFinite(Number(source.stepBudget))
|
||||
? Math.max(1, Math.floor(Number(source.stepBudget)))
|
||||
: IMPLICIT_INTERACTION_STEP_BUDGET;
|
||||
return {
|
||||
...settings,
|
||||
detail: Math.min(settings.detail, IMPLICIT_INTERACTION_DETAIL),
|
||||
stepBudget: Math.min(requestedStepBudget, IMPLICIT_INTERACTION_STEP_BUDGET),
|
||||
shadows: false,
|
||||
ambientOcclusion: false
|
||||
};
|
||||
}
|
||||
|
||||
export function implicitGraphicsSettingsEqual(a, b) {
|
||||
const normalizedA = normalizeImplicitGraphicsSettings(a);
|
||||
const normalizedB = normalizeImplicitGraphicsSettings(b);
|
||||
return Object.keys(DEFAULT_IMPLICIT_GRAPHICS_SETTINGS)
|
||||
.every((key) => normalizedA[key] === normalizedB[key]);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
implicitGraphicsRenderResolutionScale,
|
||||
implicitGraphicsRenderSettings,
|
||||
normalizeImplicitGraphicsSettings
|
||||
} from "./graphicsSettings.js";
|
||||
|
||||
test("implicit graphics render resolution uses idle or interaction scale", () => {
|
||||
const settings = normalizeImplicitGraphicsSettings({
|
||||
resolutionScale: 2.5,
|
||||
interactionResolutionScale: 0.75
|
||||
});
|
||||
|
||||
assert.equal(implicitGraphicsRenderResolutionScale(settings), 2.5);
|
||||
assert.equal(implicitGraphicsRenderResolutionScale(settings, { interaction: true }), 0.75);
|
||||
});
|
||||
|
||||
test("implicit graphics render settings reduce shader cost while interacting", () => {
|
||||
const settings = {
|
||||
resolutionScale: 2.5,
|
||||
interactionResolutionScale: 0.75,
|
||||
detail: 2,
|
||||
shadows: true,
|
||||
ambientOcclusion: true,
|
||||
rimLight: true
|
||||
};
|
||||
|
||||
assert.deepEqual(implicitGraphicsRenderSettings(settings), {
|
||||
...normalizeImplicitGraphicsSettings(settings)
|
||||
});
|
||||
assert.deepEqual(implicitGraphicsRenderSettings(settings, { interaction: true }), {
|
||||
...normalizeImplicitGraphicsSettings(settings),
|
||||
detail: 0.75,
|
||||
stepBudget: 96,
|
||||
shadows: false,
|
||||
ambientOcclusion: false
|
||||
});
|
||||
assert.equal(
|
||||
implicitGraphicsRenderSettings({ ...settings, detail: 0.5, stepBudget: 24 }, { interaction: true }).stepBudget,
|
||||
24
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { normalizeImplicitCadModel } from "./model.js";
|
||||
|
||||
const implicitCadModuleCache = new Map();
|
||||
|
||||
function cacheKey(url) {
|
||||
return String(url || "").trim();
|
||||
}
|
||||
|
||||
function importUrl(url) {
|
||||
const rawUrl = String(url || "").trim();
|
||||
if (!rawUrl) {
|
||||
throw new Error("Missing implicit CAD module URL");
|
||||
}
|
||||
return rawUrl;
|
||||
}
|
||||
|
||||
function sourceDataUrl(source) {
|
||||
const text = `${String(source || "")}\n`;
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return `data:text/javascript;base64,${Buffer.from(text, "utf8").toString("base64")}`;
|
||||
}
|
||||
const encoded = encodeURIComponent(text).replace(/'/g, "%27").replace(/"/g, "%22");
|
||||
return `data:text/javascript;charset=utf-8,${encoded}`;
|
||||
}
|
||||
|
||||
export async function loadImplicitCadModule(url, { signal } = {}) {
|
||||
const key = cacheKey(url);
|
||||
if (!key) {
|
||||
throw new Error("Missing implicit CAD module URL");
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException("The operation was aborted.", "AbortError");
|
||||
}
|
||||
if (!implicitCadModuleCache.has(key)) {
|
||||
let pending;
|
||||
pending = import(/* @vite-ignore */ importUrl(key)).then((moduleValue) => {
|
||||
const model = normalizeImplicitCadModel(moduleValue, { sourceUrl: key });
|
||||
if (implicitCadModuleCache.get(key) === pending) {
|
||||
implicitCadModuleCache.set(key, model);
|
||||
}
|
||||
return model;
|
||||
}).catch((error) => {
|
||||
implicitCadModuleCache.delete(key);
|
||||
throw error;
|
||||
});
|
||||
implicitCadModuleCache.set(
|
||||
key,
|
||||
pending
|
||||
);
|
||||
}
|
||||
const pending = implicitCadModuleCache.get(key);
|
||||
if (!pending || typeof pending.then !== "function") {
|
||||
return pending;
|
||||
}
|
||||
if (!signal) {
|
||||
return pending;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const abort = () => {
|
||||
signal.removeEventListener?.("abort", abort);
|
||||
reject(new DOMException("The operation was aborted.", "AbortError"));
|
||||
};
|
||||
if (signal.aborted) {
|
||||
abort();
|
||||
return;
|
||||
}
|
||||
signal.addEventListener?.("abort", abort, { once: true });
|
||||
pending.then(
|
||||
(value) => {
|
||||
signal.removeEventListener?.("abort", abort);
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
signal.removeEventListener?.("abort", abort);
|
||||
reject(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function peekImplicitCadModule(url) {
|
||||
const cached = implicitCadModuleCache.get(cacheKey(url));
|
||||
return cached && typeof cached.then !== "function" ? cached : null;
|
||||
}
|
||||
|
||||
export async function loadImplicitModuleFromSource(source, {
|
||||
signal,
|
||||
sourceUrl = "inline://implicit.js"
|
||||
} = {}) {
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException("The operation was aborted.", "AbortError");
|
||||
}
|
||||
const url = sourceDataUrl(source);
|
||||
const moduleValue = await import(/* @vite-ignore */ url);
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException("The operation was aborted.", "AbortError");
|
||||
}
|
||||
return normalizeImplicitCadModel(moduleValue, { sourceUrl });
|
||||
}
|
||||
|
||||
export const loadImplicitModule = loadImplicitCadModule;
|
||||
export const peekImplicitModule = peekImplicitCadModule;
|
||||
export const loadImplicitSource = loadImplicitModuleFromSource;
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
loadImplicitModuleFromSource,
|
||||
loadImplicitSource,
|
||||
normalizeImplicitModel
|
||||
} from "../../index.js";
|
||||
|
||||
test("loadImplicitModuleFromSource normalizes editable implicit source strings", async () => {
|
||||
const model = await loadImplicitModuleFromSource(`
|
||||
export default {
|
||||
schema: "implicit.js/0.1.0",
|
||||
name: "inline sphere",
|
||||
params: {
|
||||
radius: { type: "number", min: 1, max: 8, default: 4 }
|
||||
},
|
||||
bounds: ({ params }) => params.radius + 1,
|
||||
glsl: "float sdf(vec3 p) { return length(p) - radius; }"
|
||||
};
|
||||
`);
|
||||
|
||||
assert.equal(model.name, "inline sphere");
|
||||
assert.equal(model.parameterValues.radius, 4);
|
||||
assert.equal(model.bounds.max[0], 5);
|
||||
assert.equal(loadImplicitSource, loadImplicitModuleFromSource);
|
||||
});
|
||||
|
||||
test("implicit aliases point at the normalized model API", () => {
|
||||
const model = normalizeImplicitModel({
|
||||
glsl: "float sdf(vec3 p) { return length(p) - 1.0; }"
|
||||
});
|
||||
|
||||
assert.equal(model.kind, "implicit");
|
||||
assert.equal(model.name, "Implicit CAD");
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import { normalizeImplicitCadModel } from "./model.js";
|
||||
import { createImplicitCadSdfEvaluator } from "./sdfEvaluator.js";
|
||||
|
||||
const DEFAULT_RESOLUTION = 96;
|
||||
const DEFAULT_MAX_CELLS = 2500000;
|
||||
const TETRAHEDRA = Object.freeze([
|
||||
[0, 5, 1, 6],
|
||||
[0, 1, 2, 6],
|
||||
[0, 2, 3, 6],
|
||||
[0, 3, 7, 6],
|
||||
[0, 7, 4, 6],
|
||||
[0, 4, 5, 6],
|
||||
]);
|
||||
const CUBE_OFFSETS = Object.freeze([
|
||||
[0, 0, 0],
|
||||
[1, 0, 0],
|
||||
[1, 1, 0],
|
||||
[0, 1, 0],
|
||||
[0, 0, 1],
|
||||
[1, 0, 1],
|
||||
[1, 1, 1],
|
||||
[0, 1, 1],
|
||||
]);
|
||||
|
||||
function finiteNumber(value, fallback = 0) {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : fallback;
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function normalizeResolution(value = DEFAULT_RESOLUTION) {
|
||||
return Math.max(8, Math.min(192, Math.floor(finiteNumber(value, DEFAULT_RESOLUTION))));
|
||||
}
|
||||
|
||||
function normalizedGrid(model, { resolution = DEFAULT_RESOLUTION, maxCells = DEFAULT_MAX_CELLS } = {}) {
|
||||
const bounds = model.bounds;
|
||||
const size = model.size;
|
||||
const longest = Math.max(size[0], size[1], size[2], 1e-6);
|
||||
const targetResolution = normalizeResolution(resolution);
|
||||
const cellSize = longest / targetResolution;
|
||||
let nx = Math.max(2, Math.ceil(size[0] / cellSize));
|
||||
let ny = Math.max(2, Math.ceil(size[1] / cellSize));
|
||||
let nz = Math.max(2, Math.ceil(size[2] / cellSize));
|
||||
const maxCellCount = Math.max(4096, Math.floor(finiteNumber(maxCells, DEFAULT_MAX_CELLS)));
|
||||
const cellCount = nx * ny * nz;
|
||||
if (cellCount > maxCellCount) {
|
||||
const scale = Math.cbrt(maxCellCount / cellCount);
|
||||
nx = Math.max(2, Math.floor(nx * scale));
|
||||
ny = Math.max(2, Math.floor(ny * scale));
|
||||
nz = Math.max(2, Math.floor(nz * scale));
|
||||
}
|
||||
return {
|
||||
min: bounds.min,
|
||||
max: bounds.max,
|
||||
size,
|
||||
nx,
|
||||
ny,
|
||||
nz,
|
||||
step: [
|
||||
size[0] / nx,
|
||||
size[1] / ny,
|
||||
size[2] / nz,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function pointAt(grid, ix, iy, iz) {
|
||||
return [
|
||||
grid.min[0] + ix * grid.step[0],
|
||||
grid.min[1] + iy * grid.step[1],
|
||||
grid.min[2] + iz * grid.step[2],
|
||||
];
|
||||
}
|
||||
|
||||
function subtract(a, b) {
|
||||
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
}
|
||||
|
||||
function cross(a, b) {
|
||||
return [
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
];
|
||||
}
|
||||
|
||||
function dot(a, b) {
|
||||
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
}
|
||||
|
||||
function normalize(v) {
|
||||
const len = Math.hypot(v[0], v[1], v[2]);
|
||||
return len > 1e-12 ? [v[0] / len, v[1] / len, v[2] / len] : [0, 0, 1];
|
||||
}
|
||||
|
||||
function midpoint(a, b, c) {
|
||||
return [
|
||||
(a[0] + b[0] + c[0]) / 3,
|
||||
(a[1] + b[1] + c[1]) / 3,
|
||||
(a[2] + b[2] + c[2]) / 3,
|
||||
];
|
||||
}
|
||||
|
||||
function estimateGradient(sdf, point, epsilon) {
|
||||
const [x, y, z] = point;
|
||||
return normalize([
|
||||
sdf(x + epsilon, y, z) - sdf(x - epsilon, y, z),
|
||||
sdf(x, y + epsilon, z) - sdf(x, y - epsilon, z),
|
||||
sdf(x, y, z + epsilon) - sdf(x, y, z - epsilon),
|
||||
]);
|
||||
}
|
||||
|
||||
function offsetPoint(point, direction, distance) {
|
||||
return [
|
||||
point[0] + direction[0] * distance,
|
||||
point[1] + direction[1] * distance,
|
||||
point[2] + direction[2] * distance,
|
||||
];
|
||||
}
|
||||
|
||||
function normalFacesOutward(sdf, point, normal, epsilon) {
|
||||
const positive = finiteNumber(sdf(...offsetPoint(point, normal, epsilon)), 1e6);
|
||||
const negative = finiteNumber(sdf(...offsetPoint(point, normal, -epsilon)), 1e6);
|
||||
return positive >= negative;
|
||||
}
|
||||
|
||||
function smoothNormalForVertex(sdf, vertex, epsilon, normalCache) {
|
||||
const key = `${vertex[0].toPrecision(12)},${vertex[1].toPrecision(12)},${vertex[2].toPrecision(12)}`;
|
||||
let normal = normalCache.get(key);
|
||||
if (!normal) {
|
||||
normal = estimateGradient(sdf, vertex, epsilon);
|
||||
normalCache.set(key, normal);
|
||||
}
|
||||
return normal;
|
||||
}
|
||||
|
||||
function interpolateVertex(a, b, iso = 0) {
|
||||
const denominator = b.value - a.value;
|
||||
const t = Math.abs(denominator) < 1e-12
|
||||
? 0.5
|
||||
: clamp((iso - a.value) / denominator, 0, 1);
|
||||
return [
|
||||
a.position[0] + (b.position[0] - a.position[0]) * t,
|
||||
a.position[1] + (b.position[1] - a.position[1]) * t,
|
||||
a.position[2] + (b.position[2] - a.position[2]) * t,
|
||||
];
|
||||
}
|
||||
|
||||
function pushTriangle(mesh, sdf, normalEpsilon, a, b, c, {
|
||||
normalCache = null,
|
||||
orientByGradient = false,
|
||||
orientBySdf = true,
|
||||
orientationEpsilon = normalEpsilon,
|
||||
smoothNormals = false,
|
||||
} = {}) {
|
||||
let v0 = a;
|
||||
let v1 = b;
|
||||
let v2 = c;
|
||||
let normal = normalize(cross(subtract(v1, v0), subtract(v2, v0)));
|
||||
const triangleMidpoint = midpoint(v0, v1, v2);
|
||||
const gradient = orientByGradient ? estimateGradient(sdf, triangleMidpoint, normalEpsilon) : null;
|
||||
const facesOutward = gradient
|
||||
? dot(normal, gradient) >= 0
|
||||
: !orientBySdf || normalFacesOutward(sdf, triangleMidpoint, normal, orientationEpsilon);
|
||||
if (!facesOutward) {
|
||||
v1 = c;
|
||||
v2 = b;
|
||||
normal = [-normal[0], -normal[1], -normal[2]];
|
||||
}
|
||||
for (const vertex of [v0, v1, v2]) {
|
||||
const vertexNormal = smoothNormals && normalCache
|
||||
? smoothNormalForVertex(sdf, vertex, normalEpsilon, normalCache)
|
||||
: normal;
|
||||
const alignedNormal = dot(vertexNormal, normal) < 0
|
||||
? [-vertexNormal[0], -vertexNormal[1], -vertexNormal[2]]
|
||||
: vertexNormal;
|
||||
mesh.positions.push(vertex[0], vertex[1], vertex[2]);
|
||||
mesh.normals.push(alignedNormal[0], alignedNormal[1], alignedNormal[2]);
|
||||
}
|
||||
}
|
||||
|
||||
function polygonizeTetra(mesh, sdf, normalEpsilon, corners, iso = 0, options = {}) {
|
||||
const inside = [];
|
||||
const outside = [];
|
||||
for (const corner of corners) {
|
||||
if (corner.value <= iso) {
|
||||
inside.push(corner);
|
||||
} else {
|
||||
outside.push(corner);
|
||||
}
|
||||
}
|
||||
if (inside.length === 0 || inside.length === 4) {
|
||||
return;
|
||||
}
|
||||
const edge = (a, b) => interpolateVertex(a, b, iso);
|
||||
if (inside.length === 1) {
|
||||
pushTriangle(mesh, sdf, normalEpsilon, edge(inside[0], outside[0]), edge(inside[0], outside[1]), edge(inside[0], outside[2]), options);
|
||||
return;
|
||||
}
|
||||
if (inside.length === 3) {
|
||||
pushTriangle(mesh, sdf, normalEpsilon, edge(outside[0], inside[0]), edge(outside[0], inside[2]), edge(outside[0], inside[1]), options);
|
||||
return;
|
||||
}
|
||||
const a = edge(inside[0], outside[0]);
|
||||
const b = edge(inside[1], outside[0]);
|
||||
const c = edge(inside[1], outside[1]);
|
||||
const d = edge(inside[0], outside[1]);
|
||||
pushTriangle(mesh, sdf, normalEpsilon, a, b, c, options);
|
||||
pushTriangle(mesh, sdf, normalEpsilon, a, c, d, options);
|
||||
}
|
||||
|
||||
function sampledValueIndex(grid, ix, iy, iz) {
|
||||
return ix + (grid.nx + 1) * (iy + (grid.ny + 1) * iz);
|
||||
}
|
||||
|
||||
export function meshImplicitCadModel(modelValue, options = {}) {
|
||||
const model = normalizeImplicitCadModel(modelValue);
|
||||
const sdf = typeof options.sdf === "function"
|
||||
? options.sdf
|
||||
: createImplicitCadSdfEvaluator(model);
|
||||
const grid = normalizedGrid(model, options);
|
||||
const valueCount = (grid.nx + 1) * (grid.ny + 1) * (grid.nz + 1);
|
||||
const values = new Float32Array(valueCount);
|
||||
|
||||
for (let iz = 0; iz <= grid.nz; iz += 1) {
|
||||
for (let iy = 0; iy <= grid.ny; iy += 1) {
|
||||
for (let ix = 0; ix <= grid.nx; ix += 1) {
|
||||
const p = pointAt(grid, ix, iy, iz);
|
||||
values[sampledValueIndex(grid, ix, iy, iz)] = finiteNumber(sdf(p[0], p[1], p[2]), 1e6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mesh = {
|
||||
positions: [],
|
||||
normals: [],
|
||||
format: "triangle-list",
|
||||
sourceModel: model,
|
||||
grid: {
|
||||
resolution: [grid.nx, grid.ny, grid.nz],
|
||||
step: grid.step,
|
||||
},
|
||||
};
|
||||
const normalEpsilon = Math.max(finiteNumber(options.normalEpsilon, model.normalEpsilon), 1e-5);
|
||||
const orientationEpsilon = Math.max(Math.min(...grid.step) * 0.15, 1e-5);
|
||||
const normalCache = options.smoothNormals === true ? new Map() : null;
|
||||
|
||||
for (let iz = 0; iz < grid.nz; iz += 1) {
|
||||
for (let iy = 0; iy < grid.ny; iy += 1) {
|
||||
for (let ix = 0; ix < grid.nx; ix += 1) {
|
||||
const corners = CUBE_OFFSETS.map(([dx, dy, dz]) => {
|
||||
const cx = ix + dx;
|
||||
const cy = iy + dy;
|
||||
const cz = iz + dz;
|
||||
return {
|
||||
position: pointAt(grid, cx, cy, cz),
|
||||
value: values[sampledValueIndex(grid, cx, cy, cz)],
|
||||
};
|
||||
});
|
||||
for (const tetra of TETRAHEDRA) {
|
||||
polygonizeTetra(mesh, sdf, normalEpsilon, tetra.map((index) => corners[index]), 0, {
|
||||
normalCache,
|
||||
orientByGradient: options.orientByGradient === true,
|
||||
orientBySdf: options.orientBySdf !== false,
|
||||
orientationEpsilon,
|
||||
smoothNormals: options.smoothNormals === true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...mesh,
|
||||
positions: new Float32Array(mesh.positions),
|
||||
normals: new Float32Array(mesh.normals),
|
||||
vertexCount: mesh.positions.length / 3,
|
||||
triangleCount: mesh.positions.length / 9,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { meshImplicitCadModel } from "./mesh.js";
|
||||
|
||||
const sphereModel = {
|
||||
schema: "implicit.js/0.1.0",
|
||||
name: "orientation sphere",
|
||||
bounds: [[-6, -6, -6], [6, 6, 6]],
|
||||
glsl: "float sdf(vec3 p) { return length(p) - 4.0; }",
|
||||
};
|
||||
|
||||
function triangleFacesOutwardFromOrigin(positions, offset) {
|
||||
const ax = positions[offset];
|
||||
const ay = positions[offset + 1];
|
||||
const az = positions[offset + 2];
|
||||
const bx = positions[offset + 3];
|
||||
const by = positions[offset + 4];
|
||||
const bz = positions[offset + 5];
|
||||
const cx = positions[offset + 6];
|
||||
const cy = positions[offset + 7];
|
||||
const cz = positions[offset + 8];
|
||||
const ux = bx - ax;
|
||||
const uy = by - ay;
|
||||
const uz = bz - az;
|
||||
const vx = cx - ax;
|
||||
const vy = cy - ay;
|
||||
const vz = cz - az;
|
||||
const nx = uy * vz - uz * vy;
|
||||
const ny = uz * vx - ux * vz;
|
||||
const nz = ux * vy - uy * vx;
|
||||
const mx = (ax + bx + cx) / 3;
|
||||
const my = (ay + by + cy) / 3;
|
||||
const mz = (az + bz + cz) / 3;
|
||||
return nx * mx + ny * my + nz * mz > 0;
|
||||
}
|
||||
|
||||
test("implicit mesh extraction orients closed SDF triangles outward", () => {
|
||||
const mesh = meshImplicitCadModel(sphereModel, { resolution: 14 });
|
||||
assert.ok(mesh.triangleCount > 0);
|
||||
for (let offset = 0; offset < mesh.positions.length; offset += 9) {
|
||||
assert.equal(triangleFacesOutwardFromOrigin(mesh.positions, offset), true);
|
||||
}
|
||||
});
|
||||
|
||||
test("implicit mesh extraction evaluates shader uniforms", () => {
|
||||
const mesh = meshImplicitCadModel({
|
||||
schema: "implicit.js/0.1.0",
|
||||
name: "uniform sphere",
|
||||
params: {
|
||||
radius: { type: "number", min: 1, max: 6, default: 4 }
|
||||
},
|
||||
bounds: [[-6, -6, -6], [6, 6, 6]],
|
||||
glsl: "float sdf(vec3 p) { return length(p) - radius; }",
|
||||
}, { resolution: 12 });
|
||||
assert.ok(mesh.triangleCount > 0);
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import { normalizeImplicitCadModel } from "./model.js";
|
||||
import { createImplicitCadSdfEvaluator } from "./sdfEvaluator.js";
|
||||
|
||||
function finiteNumber(value, fallback = 0) {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : fallback;
|
||||
}
|
||||
|
||||
function isNumericArray(value) {
|
||||
return value instanceof Float32Array || value instanceof Float64Array || Array.isArray(value);
|
||||
}
|
||||
|
||||
function subtract(a, b) {
|
||||
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
}
|
||||
|
||||
function cross(a, b) {
|
||||
return [
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
];
|
||||
}
|
||||
|
||||
function dot(a, b) {
|
||||
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
}
|
||||
|
||||
function normalize(v) {
|
||||
const length = Math.hypot(v[0], v[1], v[2]);
|
||||
return length > 1e-12 ? [v[0] / length, v[1] / length, v[2] / length] : [0, 0, 0];
|
||||
}
|
||||
|
||||
function midpoint(a, b, c) {
|
||||
return [
|
||||
(a[0] + b[0] + c[0]) / 3,
|
||||
(a[1] + b[1] + c[1]) / 3,
|
||||
(a[2] + b[2] + c[2]) / 3,
|
||||
];
|
||||
}
|
||||
|
||||
function triangleAreaNormal(a, b, c) {
|
||||
const normal = cross(subtract(b, a), subtract(c, a));
|
||||
const doubleArea = Math.hypot(normal[0], normal[1], normal[2]);
|
||||
return {
|
||||
area: doubleArea * 0.5,
|
||||
normal: doubleArea > 1e-12 ? [normal[0] / doubleArea, normal[1] / doubleArea, normal[2] / doubleArea] : [0, 0, 0],
|
||||
};
|
||||
}
|
||||
|
||||
function readVec3(array, offset) {
|
||||
return [array[offset], array[offset + 1], array[offset + 2]];
|
||||
}
|
||||
|
||||
function boundsForPositions(positions) {
|
||||
const min = [Infinity, Infinity, Infinity];
|
||||
const max = [-Infinity, -Infinity, -Infinity];
|
||||
for (let index = 0; index < positions.length; index += 3) {
|
||||
min[0] = Math.min(min[0], finiteNumber(positions[index], 0));
|
||||
min[1] = Math.min(min[1], finiteNumber(positions[index + 1], 0));
|
||||
min[2] = Math.min(min[2], finiteNumber(positions[index + 2], 0));
|
||||
max[0] = Math.max(max[0], finiteNumber(positions[index], 0));
|
||||
max[1] = Math.max(max[1], finiteNumber(positions[index + 1], 0));
|
||||
max[2] = Math.max(max[2], finiteNumber(positions[index + 2], 0));
|
||||
}
|
||||
if (!min.every(Number.isFinite) || !max.every(Number.isFinite)) {
|
||||
return { min: [0, 0, 0], max: [0, 0, 0], size: [0, 0, 0], diagonal: 0 };
|
||||
}
|
||||
const size = [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
|
||||
return {
|
||||
min,
|
||||
max,
|
||||
size,
|
||||
diagonal: Math.hypot(size[0], size[1], size[2]),
|
||||
};
|
||||
}
|
||||
|
||||
function quantizedVertexKey(vertex, tolerance) {
|
||||
return vertex.map((component) => Math.round(component / tolerance)).join(",");
|
||||
}
|
||||
|
||||
function sortedEdgeKey(a, b) {
|
||||
return a < b ? `${a}|${b}` : `${b}|${a}`;
|
||||
}
|
||||
|
||||
function sdfForOptions(options) {
|
||||
if (typeof options.sdf === "function") {
|
||||
return options.sdf;
|
||||
}
|
||||
if (options.model) {
|
||||
return createImplicitCadSdfEvaluator(normalizeImplicitCadModel(options.model));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sampleNormalAlignment(normals, triangleOffset, faceNormal) {
|
||||
let worst = 1;
|
||||
for (let vertex = 0; vertex < 3; vertex += 1) {
|
||||
const normal = normalize(readVec3(normals, triangleOffset + vertex * 3));
|
||||
const length = Math.hypot(normal[0], normal[1], normal[2]);
|
||||
if (length <= 0) {
|
||||
worst = -1;
|
||||
} else {
|
||||
worst = Math.min(worst, dot(normal, faceNormal));
|
||||
}
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
|
||||
export function analyzeImplicitMeshQuality(mesh, options = {}) {
|
||||
const positions = mesh?.positions || new Float32Array();
|
||||
const normals = isNumericArray(mesh?.normals) && mesh.normals.length === positions.length ? mesh.normals : null;
|
||||
const triangleCount = Math.floor(positions.length / 9);
|
||||
const bounds = boundsForPositions(positions);
|
||||
const stepValues = Array.isArray(mesh?.grid?.step) ? mesh.grid.step.map((value) => finiteNumber(value, 0)).filter((value) => value > 0) : [];
|
||||
const minStep = stepValues.length ? Math.min(...stepValues) : Math.max(bounds.diagonal / 64, 1e-3);
|
||||
const edgeTolerance = Math.max(finiteNumber(options.edgeTolerance, 0), minStep * 1e-4, bounds.diagonal * 1e-8, 1e-7);
|
||||
const areaTolerance = Math.max(finiteNumber(options.areaTolerance, 0), minStep * minStep * 1e-10, 1e-12);
|
||||
const orientationEpsilon = Math.max(finiteNumber(options.orientationEpsilon, 0), minStep * 0.2, 1e-5);
|
||||
const orientationTolerance = Math.max(finiteNumber(options.orientationTolerance, 0), minStep * 1e-4, 1e-6);
|
||||
const maxOrientationSamples = Math.max(0, Math.floor(finiteNumber(options.maxOrientationSamples, 5000)));
|
||||
const orientationStride = maxOrientationSamples > 0 ? Math.max(1, Math.floor(triangleCount / maxOrientationSamples)) : Infinity;
|
||||
const sdf = sdfForOptions(options);
|
||||
const edgeCounts = new Map();
|
||||
let nonFinitePositions = 0;
|
||||
let nonFiniteNormals = 0;
|
||||
let degenerateTriangles = 0;
|
||||
let invertedOrientationSamples = 0;
|
||||
let ambiguousOrientationSamples = 0;
|
||||
let orientationSamples = 0;
|
||||
let poorNormalAlignment = 0;
|
||||
let worstNormalAlignment = 1;
|
||||
let minArea = Infinity;
|
||||
let maxArea = 0;
|
||||
|
||||
for (let offset = 0; offset < positions.length; offset += 9) {
|
||||
const triangleIndex = offset / 9;
|
||||
const a = readVec3(positions, offset);
|
||||
const b = readVec3(positions, offset + 3);
|
||||
const c = readVec3(positions, offset + 6);
|
||||
if (![...a, ...b, ...c].every(Number.isFinite)) {
|
||||
nonFinitePositions += 1;
|
||||
continue;
|
||||
}
|
||||
const { area, normal } = triangleAreaNormal(a, b, c);
|
||||
minArea = Math.min(minArea, area);
|
||||
maxArea = Math.max(maxArea, area);
|
||||
if (area <= areaTolerance) {
|
||||
degenerateTriangles += 1;
|
||||
continue;
|
||||
}
|
||||
if (normals) {
|
||||
const alignment = sampleNormalAlignment(normals, offset, normal);
|
||||
worstNormalAlignment = Math.min(worstNormalAlignment, alignment);
|
||||
if (!Number.isFinite(alignment)) {
|
||||
nonFiniteNormals += 1;
|
||||
} else if (alignment < -0.35) {
|
||||
poorNormalAlignment += 1;
|
||||
}
|
||||
}
|
||||
const vertexKeys = [a, b, c].map((vertex) => quantizedVertexKey(vertex, edgeTolerance));
|
||||
edgeCounts.set(sortedEdgeKey(vertexKeys[0], vertexKeys[1]), (edgeCounts.get(sortedEdgeKey(vertexKeys[0], vertexKeys[1])) || 0) + 1);
|
||||
edgeCounts.set(sortedEdgeKey(vertexKeys[1], vertexKeys[2]), (edgeCounts.get(sortedEdgeKey(vertexKeys[1], vertexKeys[2])) || 0) + 1);
|
||||
edgeCounts.set(sortedEdgeKey(vertexKeys[2], vertexKeys[0]), (edgeCounts.get(sortedEdgeKey(vertexKeys[2], vertexKeys[0])) || 0) + 1);
|
||||
|
||||
if (sdf && triangleIndex % orientationStride === 0) {
|
||||
const center = midpoint(a, b, c);
|
||||
const positive = finiteNumber(
|
||||
sdf(
|
||||
center[0] + normal[0] * orientationEpsilon,
|
||||
center[1] + normal[1] * orientationEpsilon,
|
||||
center[2] + normal[2] * orientationEpsilon
|
||||
),
|
||||
1e6
|
||||
);
|
||||
const negative = finiteNumber(
|
||||
sdf(
|
||||
center[0] - normal[0] * orientationEpsilon,
|
||||
center[1] - normal[1] * orientationEpsilon,
|
||||
center[2] - normal[2] * orientationEpsilon
|
||||
),
|
||||
1e6
|
||||
);
|
||||
orientationSamples += 1;
|
||||
if (Math.abs(positive - negative) <= orientationTolerance) {
|
||||
ambiguousOrientationSamples += 1;
|
||||
} else if (positive < negative) {
|
||||
invertedOrientationSamples += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let boundaryEdges = 0;
|
||||
let manifoldEdges = 0;
|
||||
let nonManifoldEdges = 0;
|
||||
for (const count of edgeCounts.values()) {
|
||||
if (count === 1) {
|
||||
boundaryEdges += 1;
|
||||
} else if (count === 2) {
|
||||
manifoldEdges += 1;
|
||||
} else {
|
||||
nonManifoldEdges += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const edgeCount = edgeCounts.size;
|
||||
return {
|
||||
triangleCount,
|
||||
vertexCount: Math.floor(positions.length / 3),
|
||||
bounds,
|
||||
grid: mesh?.grid || null,
|
||||
minStep,
|
||||
tolerances: {
|
||||
edge: edgeTolerance,
|
||||
area: areaTolerance,
|
||||
orientation: orientationEpsilon,
|
||||
},
|
||||
triangles: {
|
||||
minArea: Number.isFinite(minArea) ? minArea : 0,
|
||||
maxArea,
|
||||
degenerate: degenerateTriangles,
|
||||
degenerateRatio: triangleCount ? degenerateTriangles / triangleCount : 0,
|
||||
nonFinitePositions,
|
||||
nonFiniteNormals,
|
||||
poorNormalAlignment,
|
||||
worstNormalAlignment,
|
||||
},
|
||||
orientation: {
|
||||
samples: orientationSamples,
|
||||
inverted: invertedOrientationSamples,
|
||||
ambiguous: ambiguousOrientationSamples,
|
||||
invertedRatio: orientationSamples ? invertedOrientationSamples / orientationSamples : 0,
|
||||
},
|
||||
edges: {
|
||||
total: edgeCount,
|
||||
manifold: manifoldEdges,
|
||||
boundary: boundaryEdges,
|
||||
nonManifold: nonManifoldEdges,
|
||||
boundaryRatio: edgeCount ? boundaryEdges / edgeCount : 0,
|
||||
nonManifoldRatio: edgeCount ? nonManifoldEdges / edgeCount : 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { meshImplicitCadModel } from "./mesh.js";
|
||||
import { analyzeImplicitMeshQuality } from "./meshQuality.js";
|
||||
|
||||
const sphereModel = {
|
||||
schema: "implicit.js/0.1.0",
|
||||
name: "quality sphere",
|
||||
bounds: [[-6, -6, -6], [6, 6, 6]],
|
||||
glsl: "float sdf(vec3 p) { return length(p) - 4.0; }",
|
||||
};
|
||||
|
||||
test("implicit mesh quality reports closed outward sphere mesh", () => {
|
||||
const mesh = meshImplicitCadModel(sphereModel, { resolution: 20, smoothNormals: true });
|
||||
const quality = analyzeImplicitMeshQuality(mesh, { model: sphereModel });
|
||||
assert.ok(quality.triangleCount > 0);
|
||||
assert.equal(quality.triangles.nonFinitePositions, 0);
|
||||
assert.equal(quality.triangles.degenerate, 0);
|
||||
assert.equal(quality.edges.boundary, 0);
|
||||
assert.equal(quality.edges.nonManifold, 0);
|
||||
assert.equal(quality.orientation.inverted, 0);
|
||||
assert.ok(quality.triangles.worstNormalAlignment > 0.4);
|
||||
});
|
||||
|
||||
test("implicit mesh quality detects boundary edges", () => {
|
||||
const mesh = {
|
||||
positions: new Float32Array([
|
||||
0, 0, 0,
|
||||
1, 0, 0,
|
||||
0, 1, 0,
|
||||
]),
|
||||
normals: new Float32Array([
|
||||
0, 0, 1,
|
||||
0, 0, 1,
|
||||
0, 0, 1,
|
||||
]),
|
||||
grid: { step: [1, 1, 1] },
|
||||
};
|
||||
const quality = analyzeImplicitMeshQuality(mesh);
|
||||
assert.equal(quality.triangleCount, 1);
|
||||
assert.equal(quality.edges.boundary, 3);
|
||||
assert.equal(quality.edges.nonManifold, 0);
|
||||
});
|
||||
@@ -0,0 +1,614 @@
|
||||
import {
|
||||
normalizeParameterAnimations,
|
||||
normalizeParameterDefinitions,
|
||||
normalizeParameterValues,
|
||||
parameterMapForDefinitions
|
||||
} from "../../common/parameters.js";
|
||||
import { createImplicitCadSdfEvaluator } from "./sdfEvaluator.js";
|
||||
export {
|
||||
IMPLICIT_CAD_EXTENSIONS,
|
||||
IMPLICIT_CAD_KIND,
|
||||
IMPLICIT_CAD_SCHEMA,
|
||||
IMPLICIT_EXTENSIONS,
|
||||
IMPLICIT_KIND,
|
||||
IMPLICIT_SCHEMA
|
||||
} from "./schema.js";
|
||||
import {
|
||||
IMPLICIT_CAD_EXTENSIONS,
|
||||
IMPLICIT_CAD_KIND,
|
||||
IMPLICIT_CAD_SCHEMA
|
||||
} from "./schema.js";
|
||||
|
||||
const DEFAULT_BOUNDS = Object.freeze({
|
||||
min: [-50, -50, -50],
|
||||
max: [50, 50, 50]
|
||||
});
|
||||
const AUTO_BOUNDS_RADII = Object.freeze([4, 8, 16, 32, 64, 128, 256, 512]);
|
||||
const AUTO_BOUNDS_SCAN_SAMPLES = 5;
|
||||
const AUTO_BOUNDS_REFINE_SAMPLES = 9;
|
||||
const AUTO_BOUNDS_CACHE_LIMIT = 32;
|
||||
const DEFAULT_COLOR = "#f4f4f5";
|
||||
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
|
||||
const GLSL_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const UNIFORM_TYPES = Object.freeze(new Set(["float", "int", "bool", "vec2", "vec3", "vec4"]));
|
||||
const PARAM_UNIFORM_TYPES = Object.freeze({
|
||||
number: "float",
|
||||
boolean: "bool",
|
||||
color: "vec3",
|
||||
button: "int"
|
||||
});
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function finiteNumber(value, fallback = 0) {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : fallback;
|
||||
}
|
||||
|
||||
function finitePositiveNumber(value, fallback) {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
|
||||
}
|
||||
|
||||
function finiteVec3(value, fallback) {
|
||||
const source = Array.isArray(value) || ArrayBuffer.isView(value) ? value : [];
|
||||
return [0, 1, 2].map((index) => finiteNumber(source[index], fallback[index] || 0));
|
||||
}
|
||||
|
||||
function finiteVector(value, length, fallback = []) {
|
||||
const source = Array.isArray(value) || ArrayBuffer.isView(value) ? value : [];
|
||||
return Array.from({ length }, (_, index) => finiteNumber(source[index], fallback[index] || 0));
|
||||
}
|
||||
|
||||
function normalizeBounds(value, fallback = DEFAULT_BOUNDS) {
|
||||
const radius = finitePositiveNumber(value, null);
|
||||
const raw = radius
|
||||
? { min: [-radius, -radius, -radius], max: [radius, radius, radius] }
|
||||
: Array.isArray(value) && value.length >= 2
|
||||
? { min: value[0], max: value[1] }
|
||||
: isObject(value)
|
||||
? value
|
||||
: fallback;
|
||||
const min = finiteVec3(raw.min, DEFAULT_BOUNDS.min);
|
||||
const max = finiteVec3(raw.max, DEFAULT_BOUNDS.max);
|
||||
for (let axis = 0; axis < 3; axis += 1) {
|
||||
if (max[axis] <= min[axis]) {
|
||||
const center = (min[axis] + max[axis]) / 2;
|
||||
min[axis] = center - 0.5;
|
||||
max[axis] = center + 0.5;
|
||||
}
|
||||
}
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
function cloneBounds(bounds) {
|
||||
return {
|
||||
min: [...bounds.min],
|
||||
max: [...bounds.max]
|
||||
};
|
||||
}
|
||||
|
||||
function hasExplicitBounds(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return false;
|
||||
}
|
||||
if (String(value).trim().toLowerCase() === "auto") {
|
||||
return false;
|
||||
}
|
||||
return !(isObject(value) && value.auto === true);
|
||||
}
|
||||
|
||||
function boundsSize(bounds) {
|
||||
return [
|
||||
bounds.max[0] - bounds.min[0],
|
||||
bounds.max[1] - bounds.min[1],
|
||||
bounds.max[2] - bounds.min[2],
|
||||
];
|
||||
}
|
||||
|
||||
function expandBounds(min, max, margin) {
|
||||
const safeMargin = Math.max(finiteNumber(margin, 0), 0.25);
|
||||
return normalizeBounds({
|
||||
min: [min[0] - safeMargin, min[1] - safeMargin, min[2] - safeMargin],
|
||||
max: [max[0] + safeMargin, max[1] + safeMargin, max[2] + safeMargin]
|
||||
});
|
||||
}
|
||||
|
||||
function sampleAutoBounds(sdf, bounds, samples) {
|
||||
const size = boundsSize(bounds);
|
||||
const steps = size.map((axisSize) => axisSize / Math.max(samples - 1, 1));
|
||||
const threshold = Math.max(Math.min(...steps) * 0.35, 0.002);
|
||||
const hitMin = [Infinity, Infinity, Infinity];
|
||||
const hitMax = [-Infinity, -Infinity, -Infinity];
|
||||
let hitCount = 0;
|
||||
let boundaryTouch = false;
|
||||
|
||||
for (let iz = 0; iz < samples; iz += 1) {
|
||||
const z = bounds.min[2] + steps[2] * iz;
|
||||
for (let iy = 0; iy < samples; iy += 1) {
|
||||
const y = bounds.min[1] + steps[1] * iy;
|
||||
for (let ix = 0; ix < samples; ix += 1) {
|
||||
const x = bounds.min[0] + steps[0] * ix;
|
||||
const value = finiteNumber(sdf(x, y, z), 1e6);
|
||||
if (value > threshold) {
|
||||
continue;
|
||||
}
|
||||
hitCount += 1;
|
||||
hitMin[0] = Math.min(hitMin[0], x);
|
||||
hitMin[1] = Math.min(hitMin[1], y);
|
||||
hitMin[2] = Math.min(hitMin[2], z);
|
||||
hitMax[0] = Math.max(hitMax[0], x);
|
||||
hitMax[1] = Math.max(hitMax[1], y);
|
||||
hitMax[2] = Math.max(hitMax[2], z);
|
||||
if (value <= 0 && (
|
||||
ix === 0 || iy === 0 || iz === 0 ||
|
||||
ix === samples - 1 || iy === samples - 1 || iz === samples - 1
|
||||
)) {
|
||||
boundaryTouch = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hitCount,
|
||||
hitMin,
|
||||
hitMax,
|
||||
boundaryTouch,
|
||||
step: steps,
|
||||
threshold
|
||||
};
|
||||
}
|
||||
|
||||
function estimateAutoBoundsStartRadius(definition, params) {
|
||||
const largest = definition.parameters
|
||||
.filter((parameter) => parameter.type === "number")
|
||||
.filter((parameter) => !likelyPoseParameter(parameter))
|
||||
.reduce((maxValue, parameter) => {
|
||||
const value = Math.abs(finiteNumber(params[parameter.id], 0));
|
||||
return Math.max(maxValue, value);
|
||||
}, 1);
|
||||
const target = Math.max(largest * 3, 4);
|
||||
return AUTO_BOUNDS_RADII.find((radius) => radius >= target) || AUTO_BOUNDS_RADII[AUTO_BOUNDS_RADII.length - 1];
|
||||
}
|
||||
|
||||
function estimateImplicitCadAutoBounds(partialModel, definition, context) {
|
||||
let sdf = null;
|
||||
try {
|
||||
sdf = createImplicitCadSdfEvaluator(partialModel);
|
||||
} catch {
|
||||
return cloneBounds(DEFAULT_BOUNDS);
|
||||
}
|
||||
|
||||
let candidate = null;
|
||||
const startRadius = estimateAutoBoundsStartRadius(definition, context.params);
|
||||
for (const radius of AUTO_BOUNDS_RADII.filter((candidateRadius) => candidateRadius >= startRadius)) {
|
||||
const bounds = normalizeBounds(radius);
|
||||
const scan = sampleAutoBounds(sdf, bounds, AUTO_BOUNDS_SCAN_SAMPLES);
|
||||
if (!scan.hitCount) {
|
||||
continue;
|
||||
}
|
||||
const margin = Math.max(Math.max(...scan.step) * 1.5, radius * 0.06, 0.5);
|
||||
candidate = expandBounds(scan.hitMin, scan.hitMax, margin);
|
||||
if (!scan.boundaryTouch) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!candidate) {
|
||||
return cloneBounds(DEFAULT_BOUNDS);
|
||||
}
|
||||
|
||||
for (let pass = 0; pass < 1; pass += 1) {
|
||||
const scan = sampleAutoBounds(sdf, candidate, AUTO_BOUNDS_REFINE_SAMPLES);
|
||||
if (!scan.hitCount) {
|
||||
break;
|
||||
}
|
||||
const margin = Math.max(Math.max(...scan.step) * 1.1, Math.max(...boundsSize(candidate)) * 0.025, 0.25);
|
||||
candidate = expandBounds(scan.hitMin, scan.hitMax, margin);
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function likelyPoseParameter(parameter) {
|
||||
const unit = String(parameter?.unit || "").trim().toLowerCase();
|
||||
if (unit === "deg" || unit === "rad") {
|
||||
return true;
|
||||
}
|
||||
const text = `${parameter?.id || ""} ${parameter?.label || ""}`.toLowerCase();
|
||||
return [
|
||||
"angle",
|
||||
"phase",
|
||||
"rotation",
|
||||
"rotate",
|
||||
"orbit",
|
||||
"spin",
|
||||
"yaw",
|
||||
"pitch",
|
||||
"roll"
|
||||
].some((token) => text.includes(token));
|
||||
}
|
||||
|
||||
function autoBoundsCacheKey(definition, params, glslSource) {
|
||||
const values = definition.parameters
|
||||
.filter((parameter) => !likelyPoseParameter(parameter))
|
||||
.filter((parameter) => PARAM_UNIFORM_TYPES[parameter.type])
|
||||
.filter((parameter) => parameter.type !== "color")
|
||||
.map((parameter) => {
|
||||
const value = params[parameter.id];
|
||||
if (typeof value === "number") {
|
||||
return `${parameter.id}:${Number.isFinite(value) ? value.toPrecision(8) : "0"}`;
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return `${parameter.id}:${value ? "1" : "0"}`;
|
||||
}
|
||||
return `${parameter.id}:${String(value ?? "")}`;
|
||||
})
|
||||
.join(";");
|
||||
return `${glslSource.length}:${values}`;
|
||||
}
|
||||
|
||||
function readCachedAutoBounds(cache, key, animationPlaying) {
|
||||
if (!cache) {
|
||||
return null;
|
||||
}
|
||||
if (cache.map.has(key)) {
|
||||
return cloneBounds(cache.map.get(key));
|
||||
}
|
||||
if (animationPlaying && cache.last) {
|
||||
return cloneBounds(cache.last);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function rememberAutoBounds(cache, key, bounds) {
|
||||
if (!cache) {
|
||||
return;
|
||||
}
|
||||
const cloned = cloneBounds(bounds);
|
||||
cache.map.set(key, cloned);
|
||||
cache.last = cloned;
|
||||
while (cache.map.size > AUTO_BOUNDS_CACHE_LIMIT) {
|
||||
const oldestKey = cache.map.keys().next().value;
|
||||
cache.map.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveImplicitCadBounds(value, partialModel, definition, context, cache) {
|
||||
if (hasExplicitBounds(value)) {
|
||||
return {
|
||||
bounds: normalizeBounds(value),
|
||||
source: "explicit"
|
||||
};
|
||||
}
|
||||
const key = autoBoundsCacheKey(definition, context.params, partialModel.glslSource);
|
||||
const cached = readCachedAutoBounds(cache, key, context.animationState.playing);
|
||||
if (cached) {
|
||||
return {
|
||||
bounds: cached,
|
||||
source: "auto"
|
||||
};
|
||||
}
|
||||
const estimated = estimateImplicitCadAutoBounds(partialModel, definition, context);
|
||||
rememberAutoBounds(cache, key, estimated);
|
||||
return {
|
||||
bounds: estimated,
|
||||
source: "auto"
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHexColor(value, fallback = DEFAULT_COLOR) {
|
||||
const color = String(value || "").trim();
|
||||
return HEX_COLOR_PATTERN.test(color) ? color : fallback;
|
||||
}
|
||||
|
||||
function hexToRgb01(hex, fallback = DEFAULT_COLOR) {
|
||||
const value = normalizeHexColor(hex, fallback);
|
||||
const expanded = value.length === 4
|
||||
? `${value[1]}${value[1]}${value[2]}${value[2]}${value[3]}${value[3]}`
|
||||
: value.slice(1);
|
||||
return [
|
||||
parseInt(expanded.slice(0, 2), 16) / 255,
|
||||
parseInt(expanded.slice(2, 4), 16) / 255,
|
||||
parseInt(expanded.slice(4, 6), 16) / 255,
|
||||
];
|
||||
}
|
||||
|
||||
function evaluateValue(value, context) {
|
||||
return typeof value === "function" ? value(context) : value;
|
||||
}
|
||||
|
||||
function normalizeMaterial() {
|
||||
const material = {};
|
||||
return {
|
||||
color: normalizeHexColor(material.color, DEFAULT_COLOR),
|
||||
roughness: Math.min(Math.max(finiteNumber(material.roughness, 0.75), 0), 1),
|
||||
metalness: Math.min(Math.max(finiteNumber(material.metalness, 0.02), 0), 1),
|
||||
};
|
||||
}
|
||||
|
||||
function unwrapModuleExports(moduleValue) {
|
||||
if (typeof moduleValue === "function") {
|
||||
return moduleValue();
|
||||
}
|
||||
if (!isObject(moduleValue)) {
|
||||
return moduleValue;
|
||||
}
|
||||
if (moduleValue.default !== undefined) {
|
||||
return typeof moduleValue.default === "function" ? moduleValue.default() : moduleValue.default;
|
||||
}
|
||||
if (moduleValue.model !== undefined) {
|
||||
return typeof moduleValue.model === "function" ? moduleValue.model() : moduleValue.model;
|
||||
}
|
||||
return moduleValue;
|
||||
}
|
||||
|
||||
function normalizeUniformValue(rawValue, context = {}) {
|
||||
const raw = evaluateValue(rawValue, context);
|
||||
const value = isObject(raw) && Object.hasOwn(raw, "value")
|
||||
? evaluateValue(raw.value, context)
|
||||
: raw;
|
||||
const explicitType = isObject(raw) ? String(raw.type || raw.kind || "").trim().toLowerCase() : "";
|
||||
const type = UNIFORM_TYPES.has(explicitType)
|
||||
? explicitType
|
||||
: typeof value === "boolean"
|
||||
? "bool"
|
||||
: typeof value === "number"
|
||||
? "float"
|
||||
: typeof value === "string" && HEX_COLOR_PATTERN.test(value)
|
||||
? "vec3"
|
||||
: Array.isArray(value) || ArrayBuffer.isView(value)
|
||||
? `vec${Math.min(Math.max(value.length, 2), 4)}`
|
||||
: "";
|
||||
if (!UNIFORM_TYPES.has(type)) {
|
||||
return null;
|
||||
}
|
||||
if (type === "bool") {
|
||||
return { type, value: value === true };
|
||||
}
|
||||
if (type === "int") {
|
||||
return { type, value: Math.trunc(finiteNumber(value, 0)) };
|
||||
}
|
||||
if (type === "float") {
|
||||
return { type, value: finiteNumber(value, 0) };
|
||||
}
|
||||
if (typeof value === "string" && HEX_COLOR_PATTERN.test(value)) {
|
||||
return { type, value: hexToRgb01(value) };
|
||||
}
|
||||
const length = Number(type.slice(3));
|
||||
const fallback = Array.from({ length }, () => 0);
|
||||
return {
|
||||
type,
|
||||
value: finiteVector(value, length, fallback)
|
||||
};
|
||||
}
|
||||
|
||||
function uniformsFromParams(parameters = [], params = {}) {
|
||||
return Object.fromEntries(
|
||||
parameters
|
||||
.map((parameter) => {
|
||||
const uniformName = String(parameter?.id || "").trim();
|
||||
if (!GLSL_IDENTIFIER_PATTERN.test(uniformName) || uniformName.startsWith("gl_")) {
|
||||
throw new Error(`Implicit CAD param "${uniformName}" is not a valid GLSL uniform identifier.`);
|
||||
}
|
||||
const type = PARAM_UNIFORM_TYPES[parameter.type];
|
||||
if (!type) {
|
||||
return null;
|
||||
}
|
||||
const uniform = normalizeUniformValue({ type, value: params[parameter.id] });
|
||||
return uniform ? [uniformName, uniform] : null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
function glslSourceFromModel(model, context = {}) {
|
||||
return String(evaluateValue(model.glsl ?? model.glslSource, context) || "").trim();
|
||||
}
|
||||
|
||||
export function pathIsImplicitCadSource(value = "") {
|
||||
const pathname = String(value || "").split(/[?#]/, 1)[0].toLowerCase();
|
||||
return IMPLICIT_CAD_EXTENSIONS.some((extension) => pathname.endsWith(extension));
|
||||
}
|
||||
|
||||
function normalizeAnimationState(value = {}) {
|
||||
const activeId = String(value?.activeId || "").trim();
|
||||
return {
|
||||
activeId,
|
||||
playing: value?.playing === true,
|
||||
elapsedSec: Math.max(finiteNumber(value?.elapsedSec ?? value?.elapsed, 0), 0),
|
||||
speed: Math.min(Math.max(finiteNumber(value?.speed, 1), 0.1), 5)
|
||||
};
|
||||
}
|
||||
|
||||
function findAnimation(definition, animationId) {
|
||||
const animations = Array.isArray(definition?.animations) ? definition.animations : [];
|
||||
if (!animations.length) {
|
||||
return null;
|
||||
}
|
||||
const normalizedId = String(animationId || "").trim();
|
||||
return animations.find((animation) => animation.id === normalizedId) || animations[0] || null;
|
||||
}
|
||||
|
||||
function buildImplicitCadContext(definition, parameterValues = {}, animationState = {}) {
|
||||
const params = normalizeParameterValues(definition, parameterValues);
|
||||
const normalizedAnimationState = normalizeAnimationState(animationState);
|
||||
const animation = findAnimation(definition, normalizedAnimationState.activeId);
|
||||
const duration = Math.max(Number(animation?.duration) || 0, 0.001);
|
||||
const elapsedSec = Math.min(normalizedAnimationState.elapsedSec, duration);
|
||||
const progress = animation ? Math.min(Math.max(elapsedSec / duration, 0), 1) : 0;
|
||||
return {
|
||||
...params,
|
||||
params,
|
||||
parameterValues: params,
|
||||
animation,
|
||||
animationState: {
|
||||
...normalizedAnimationState,
|
||||
activeId: animation?.id || normalizedAnimationState.activeId,
|
||||
duration: animation?.duration || 0,
|
||||
loop: animation?.loop !== false
|
||||
},
|
||||
elapsed: elapsedSec,
|
||||
elapsedSec,
|
||||
duration: animation?.duration || 0,
|
||||
progress,
|
||||
cycle: duration > 0 ? elapsedSec / duration : 0,
|
||||
t: elapsedSec,
|
||||
time: elapsedSec
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeImplicitCadRuntimeModel(rawModel, definition, parameterValues = {}, animationState = {}, { sourceUrl = "", autoBoundsCache = null } = {}) {
|
||||
const context = buildImplicitCadContext(definition, parameterValues, animationState);
|
||||
const model = rawModel;
|
||||
if (!isObject(model)) {
|
||||
throw new Error("Implicit CAD module must export an object, or a function returning an object.");
|
||||
}
|
||||
const uniforms = uniformsFromParams(definition.parameters, context.params);
|
||||
const glslSource = glslSourceFromModel(model, context);
|
||||
if (!glslSource) {
|
||||
throw new Error("Implicit CAD module must export GLSL code in glsl.");
|
||||
}
|
||||
const resolvedBounds = resolveImplicitCadBounds(
|
||||
evaluateValue(model.bounds, context),
|
||||
{ glslSource, uniforms },
|
||||
definition,
|
||||
context,
|
||||
autoBoundsCache
|
||||
);
|
||||
const bounds = resolvedBounds.bounds;
|
||||
const size = [
|
||||
bounds.max[0] - bounds.min[0],
|
||||
bounds.max[1] - bounds.min[1],
|
||||
bounds.max[2] - bounds.min[2],
|
||||
];
|
||||
const center = [
|
||||
(bounds.min[0] + bounds.max[0]) / 2,
|
||||
(bounds.min[1] + bounds.max[1]) / 2,
|
||||
(bounds.min[2] + bounds.max[2]) / 2,
|
||||
];
|
||||
const radius = Math.max(Math.hypot(size[0], size[1], size[2]) / 2, 1e-3);
|
||||
const evaluatedRender = evaluateValue(model.render, context);
|
||||
const render = isObject(evaluatedRender) ? evaluatedRender : {};
|
||||
const epsilon = finitePositiveNumber(evaluateValue(render.epsilon, context), Math.max(radius * 0.0007, 0.002));
|
||||
const normalEpsilon = finitePositiveNumber(
|
||||
evaluateValue(render.normalEpsilon, context),
|
||||
Math.max(epsilon * 2.5, radius * 0.001)
|
||||
);
|
||||
const stepScale = Math.min(
|
||||
Math.max(finiteNumber(evaluateValue(render.stepScale, context), 0.45), 0.05),
|
||||
1
|
||||
);
|
||||
const maxStep = finitePositiveNumber(
|
||||
evaluateValue(render.maxStep, context),
|
||||
Math.max(radius * 0.035, epsilon * 10)
|
||||
);
|
||||
return {
|
||||
schema: String(model.schema || IMPLICIT_CAD_SCHEMA),
|
||||
kind: IMPLICIT_CAD_KIND,
|
||||
name: String(model.name || model.title || "Implicit CAD").trim() || "Implicit CAD",
|
||||
description: String(model.description || "").trim(),
|
||||
units: String(model.units || "mm").trim() || "mm",
|
||||
sourceUrl: String(sourceUrl || model.sourceUrl || "").trim(),
|
||||
parameters: definition.parameters,
|
||||
parameterMap: definition.parameterMap,
|
||||
defaultParameterValues: definition.defaultParameterValues,
|
||||
parameterValues: context.params,
|
||||
animations: definition.animations,
|
||||
animationState: context.animationState,
|
||||
glslSource,
|
||||
distanceSource: glslSource,
|
||||
colorSource: /\bvec3\s+color\s*\(/.test(glslSource) ? glslSource : "",
|
||||
uniforms,
|
||||
uniformSignature: Object.entries(uniforms)
|
||||
.map(([name, uniform]) => `${name}:${uniform.type}`)
|
||||
.sort()
|
||||
.join(";"),
|
||||
bounds,
|
||||
boundsSource: resolvedBounds.source,
|
||||
center,
|
||||
size,
|
||||
radius,
|
||||
material: normalizeMaterial(),
|
||||
background: {},
|
||||
maxSteps: Math.max(16, Math.min(Math.floor(finiteNumber(evaluateValue(render.steps, context), 192)), 768)),
|
||||
maxDistance: finitePositiveNumber(evaluateValue(render.maxDistance, context), radius * 8),
|
||||
stepScale,
|
||||
maxStep,
|
||||
epsilon,
|
||||
normalEpsilon,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeImplicitCadDefinition(moduleValue, { sourceUrl = "" } = {}) {
|
||||
const model = unwrapModuleExports(moduleValue);
|
||||
if (!isObject(model)) {
|
||||
throw new Error("Implicit CAD module must export an object, or a function returning an object.");
|
||||
}
|
||||
if (model.kind === IMPLICIT_CAD_KIND && model.glslSource && Array.isArray(model.parameters)) {
|
||||
return model.definition ? model : { ...model, definition: null };
|
||||
}
|
||||
const parameters = normalizeParameterDefinitions(model.params);
|
||||
const parameterMap = parameterMapForDefinitions(parameters);
|
||||
const autoBoundsCache = {
|
||||
map: new Map(),
|
||||
last: null
|
||||
};
|
||||
const definition = {
|
||||
schema: String(model.schema || IMPLICIT_CAD_SCHEMA),
|
||||
kind: IMPLICIT_CAD_KIND,
|
||||
name: String(model.name || model.title || "Implicit CAD").trim() || "Implicit CAD",
|
||||
description: String(model.description || "").trim(),
|
||||
units: String(model.units || "mm").trim() || "mm",
|
||||
sourceUrl: String(sourceUrl || model.sourceUrl || "").trim(),
|
||||
parameters,
|
||||
parameterMap,
|
||||
defaultParameterValues: {},
|
||||
animations: normalizeParameterAnimations(model.animations),
|
||||
buildModel(parameterValues = {}, animationState = {}) {
|
||||
return normalizeImplicitCadRuntimeModel(model, definition, parameterValues, animationState, { sourceUrl, autoBoundsCache });
|
||||
}
|
||||
};
|
||||
definition.defaultParameterValues = normalizeParameterValues(
|
||||
definition,
|
||||
model.values || {}
|
||||
);
|
||||
const defaultModel = definition.buildModel(definition.defaultParameterValues);
|
||||
return {
|
||||
...defaultModel,
|
||||
definition
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeImplicitCadModel(moduleValue, { sourceUrl = "" } = {}) {
|
||||
return normalizeImplicitCadDefinition(moduleValue, { sourceUrl });
|
||||
}
|
||||
|
||||
export function implicitCadBoundsCenterAndRadius(model) {
|
||||
let normalized = null;
|
||||
try {
|
||||
normalized = isObject(model) || typeof model === "function" ? normalizeImplicitCadModel(model) : null;
|
||||
} catch {
|
||||
normalized = null;
|
||||
}
|
||||
if (normalized) {
|
||||
return {
|
||||
center: normalized.center,
|
||||
radius: normalized.radius,
|
||||
bounds: normalized.bounds,
|
||||
};
|
||||
}
|
||||
return {
|
||||
center: [0, 0, 0],
|
||||
radius: 1,
|
||||
bounds: { ...DEFAULT_BOUNDS },
|
||||
};
|
||||
}
|
||||
|
||||
export const pathIsImplicitSource = pathIsImplicitCadSource;
|
||||
export const normalizeImplicitDefinition = normalizeImplicitCadDefinition;
|
||||
export const normalizeImplicitModel = normalizeImplicitCadModel;
|
||||
export const implicitBoundsCenterAndRadius = implicitCadBoundsCenterAndRadius;
|
||||
@@ -0,0 +1,152 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
IMPLICIT_CAD_SCHEMA,
|
||||
normalizeImplicitCadDefinition,
|
||||
normalizeImplicitCadModel,
|
||||
pathIsImplicitCadSource
|
||||
} from "./model.js";
|
||||
|
||||
test("pathIsImplicitCadSource recognizes explicit implicit CAD module suffixes", () => {
|
||||
assert.equal(pathIsImplicitCadSource("models/demo.implicit.js"), true);
|
||||
assert.equal(pathIsImplicitCadSource("models/demo.implicit.mjs?v=123"), true);
|
||||
assert.equal(pathIsImplicitCadSource("models/implicit-cad.mjs"), false);
|
||||
assert.equal(pathIsImplicitCadSource("models/demo.js"), false);
|
||||
});
|
||||
|
||||
test("normalizeImplicitCadModel accepts default exports and computes bounds metadata", () => {
|
||||
const model = normalizeImplicitCadModel({
|
||||
default: {
|
||||
schema: IMPLICIT_CAD_SCHEMA,
|
||||
name: "Orb",
|
||||
bounds: [[-2, -4, -6], [2, 4, 6]],
|
||||
glsl: `
|
||||
float sdf(vec3 p) { return length(p) - 1.0; }
|
||||
vec3 color(vec3 p, vec3 normal) { return vec3(0.9, 0.2, 0.1); }
|
||||
`
|
||||
}
|
||||
}, { sourceUrl: "/models/orb.implicit.js" });
|
||||
assert.equal(model.name, "Orb");
|
||||
assert.equal(model.sourceUrl, "/models/orb.implicit.js");
|
||||
assert.deepEqual(model.center, [0, 0, 0]);
|
||||
assert.deepEqual(model.size, [4, 8, 12]);
|
||||
assert.equal(model.stepScale, 0.45);
|
||||
assert.equal(model.maxDistance, model.radius * 8);
|
||||
assert.ok(model.maxStep > 0);
|
||||
assert.match(model.glslSource, /float sdf/);
|
||||
assert.match(model.colorSource, /vec3 color/);
|
||||
});
|
||||
|
||||
test("normalizeImplicitCadDefinition builds runtime models from params", () => {
|
||||
const model = normalizeImplicitCadDefinition({
|
||||
name: "Parametric orb",
|
||||
params: {
|
||||
radius: { type: "number", min: 0.5, max: 4, default: 1.25 },
|
||||
tint: { type: "color", default: "#336699" }
|
||||
},
|
||||
animations: {
|
||||
pulse: {
|
||||
duration: 2,
|
||||
update({ progress, set }) {
|
||||
set("radius", 1 + progress);
|
||||
}
|
||||
}
|
||||
},
|
||||
glsl: `
|
||||
float sdf(vec3 p) { return length(p) - radius; }
|
||||
vec3 color(vec3 p, vec3 normal) { return tint; }
|
||||
`
|
||||
}, { sourceUrl: "/models/parametric-orb.implicit.js" });
|
||||
|
||||
assert.equal(model.definition.parameters.length, 2);
|
||||
assert.equal(model.definition.defaultParameterValues.radius, 1.25);
|
||||
assert.match(model.glslSource, /radius/);
|
||||
|
||||
const runtimeModel = model.definition.buildModel({ radius: 2.5, tint: "#ff0000" });
|
||||
assert.equal(runtimeModel.glslSource, model.glslSource);
|
||||
assert.equal(runtimeModel.uniforms.radius.value, 2.5);
|
||||
assert.deepEqual(runtimeModel.uniforms.tint.value, [1, 0, 0]);
|
||||
});
|
||||
|
||||
test("normalizeImplicitCadDefinition auto-generates parameter uniforms", () => {
|
||||
const model = normalizeImplicitCadDefinition({
|
||||
name: "Uniform orb",
|
||||
params: {
|
||||
radius: { type: "number", min: 0.5, max: 4, default: 1.25 },
|
||||
tint: { type: "color", default: "#336699" }
|
||||
},
|
||||
glsl: `
|
||||
float sdf(vec3 p) { return length(p) - radius; }
|
||||
vec3 color(vec3 p, vec3 normal) { return tint; }
|
||||
`
|
||||
});
|
||||
|
||||
assert.equal(model.uniforms.radius.type, "float");
|
||||
assert.equal(model.uniforms.radius.value, 1.25);
|
||||
assert.equal(model.uniforms.tint.type, "vec3");
|
||||
assert.deepEqual(model.uniforms.tint.value, [0.2, 0.4, 0.6]);
|
||||
assert.equal(model.uniformSignature, "radius:float;tint:vec3");
|
||||
|
||||
const runtimeModel = model.definition.buildModel({ radius: 2.5, tint: "#ff0000" });
|
||||
assert.equal(runtimeModel.glslSource, model.glslSource);
|
||||
assert.equal(runtimeModel.uniforms.radius.value, 2.5);
|
||||
assert.deepEqual(runtimeModel.uniforms.tint.value, [1, 0, 0]);
|
||||
assert.equal(runtimeModel.uniformSignature, model.uniformSignature);
|
||||
});
|
||||
|
||||
test("normalizeImplicitCadDefinition evaluates dynamic bounds and render fields once per build", () => {
|
||||
let renderCount = 0;
|
||||
const model = normalizeImplicitCadDefinition({
|
||||
params: {
|
||||
half: { type: "number", min: 1, max: 5, default: 2 }
|
||||
},
|
||||
bounds: ({ half }) => [[-half, -half, -half], [half, half, half]],
|
||||
render: () => {
|
||||
renderCount += 1;
|
||||
return { steps: 64 };
|
||||
},
|
||||
glsl: "float sdf(vec3 p) { return length(p) - half; }"
|
||||
});
|
||||
|
||||
assert.equal(model.maxSteps, 64);
|
||||
assert.deepEqual(model.bounds.min, [-2, -2, -2]);
|
||||
assert.equal(renderCount, 1);
|
||||
});
|
||||
|
||||
test("normalizeImplicitCadModel accepts raymarch render controls", () => {
|
||||
const model = normalizeImplicitCadModel({
|
||||
glsl: "float sdf(vec3 p) { return length(p) - 1.0; }",
|
||||
render: {
|
||||
stepScale: 0.25,
|
||||
maxStep: 0.5
|
||||
}
|
||||
});
|
||||
assert.equal(model.stepScale, 0.25);
|
||||
assert.equal(model.maxStep, 0.5);
|
||||
});
|
||||
|
||||
test("normalizeImplicitCadModel estimates bounds when omitted", () => {
|
||||
const model = normalizeImplicitCadModel({
|
||||
params: {
|
||||
radius: { type: "number", min: 1, max: 8, default: 3 }
|
||||
},
|
||||
glsl: "float sdf(vec3 p) { return length(p) - radius; }"
|
||||
});
|
||||
|
||||
assert.equal(model.boundsSource, "auto");
|
||||
assert.ok(model.bounds.min[0] < -2.5);
|
||||
assert.ok(model.bounds.max[0] > 2.5);
|
||||
assert.ok(model.radius < 20);
|
||||
|
||||
const larger = model.definition.buildModel({ radius: 6 });
|
||||
assert.equal(larger.boundsSource, "auto");
|
||||
assert.ok(larger.bounds.max[0] > model.bounds.max[0]);
|
||||
});
|
||||
|
||||
test("normalizeImplicitCadModel rejects modules without distance code", () => {
|
||||
assert.throws(
|
||||
() => normalizeImplicitCadModel({ default: { bounds: [[0, 0, 0], [1, 1, 1]] } }),
|
||||
/GLSL code/
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,334 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import {
|
||||
createImplicitCadMaterial,
|
||||
estimateImplicitCadFrameBoundsAsync,
|
||||
implicitCadCameraState,
|
||||
implicitCadModelShaderKey,
|
||||
implicitCadFragmentShader,
|
||||
normalizeImplicitCadGlslFloatLiterals,
|
||||
refreshImplicitCadFloorBounds,
|
||||
resolveImplicitCadAppearanceSettings,
|
||||
updateImplicitCadAppearanceUniforms,
|
||||
updateImplicitCadGraphicsUniforms
|
||||
} from "./render.js";
|
||||
import { normalizeImplicitCadModel } from "./model.js";
|
||||
|
||||
test("normalizes integer literals in float shader expressions", () => {
|
||||
assert.equal(
|
||||
normalizeImplicitCadGlslFloatLiterals("length(p) - 22"),
|
||||
"length(p) - 22.0"
|
||||
);
|
||||
assert.equal(
|
||||
normalizeImplicitCadGlslFloatLiterals("smoothstep(-18, 18, p.z)"),
|
||||
"smoothstep(-18.0, 18.0, p.z)"
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps integer control-flow and array-index literals intact", () => {
|
||||
assert.equal(
|
||||
normalizeImplicitCadGlslFloatLiterals("for (int i = 0; i < 4; i++) { value += points[i]; value += weights[0]; }"),
|
||||
"for (int i = 0; i < 4; i++) { value += points[i]; value += weights[0]; }"
|
||||
);
|
||||
assert.equal(
|
||||
normalizeImplicitCadGlslFloatLiterals("for (int i = 0; i < LIMIT; i += 1) { value += points[i]; }"),
|
||||
"for (int i = 0; i < LIMIT; i += 1) { value += points[i]; }"
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps signed scientific-notation exponents intact", () => {
|
||||
assert.equal(
|
||||
normalizeImplicitCadGlslFloatLiterals("max(k1, 1.0e-6)"),
|
||||
"max(k1, 1.0e-6)"
|
||||
);
|
||||
assert.equal(
|
||||
normalizeImplicitCadGlslFloatLiterals("float tiny = 2e-4; float huge = 1.5E+8;"),
|
||||
"float tiny = 2e-4; float huge = 1.5E+8;"
|
||||
);
|
||||
// A minus before a literal is still ordinary subtraction.
|
||||
assert.equal(
|
||||
normalizeImplicitCadGlslFloatLiterals("value - 6"),
|
||||
"value - 6.0"
|
||||
);
|
||||
assert.equal(
|
||||
normalizeImplicitCadGlslFloatLiterals("value-6"),
|
||||
"value-6.0"
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps comparisons against declared int identifiers intact", () => {
|
||||
assert.equal(
|
||||
normalizeImplicitCadGlslFloatLiterals("for (int i = 0; i < 2; i++) { float s = (i == 0) ? 1.0 : -1.0; }"),
|
||||
"for (int i = 0; i < 2; i++) { float s = (i == 0) ? 1.0 : -1.0; }"
|
||||
);
|
||||
assert.equal(
|
||||
normalizeImplicitCadGlslFloatLiterals("int counter = 3; float v = (counter != 4) ? 0.5 : (counter <= 2) ? 1.5 : 2.5;"),
|
||||
"int counter = 3; float v = (counter != 4) ? 0.5 : (counter <= 2) ? 1.5 : 2.5;"
|
||||
);
|
||||
// Comparisons against float identifiers still promote.
|
||||
assert.equal(
|
||||
normalizeImplicitCadGlslFloatLiterals("float x = 1.5; float y = (x == 0) ? 2.0 : 3.0;"),
|
||||
"float x = 1.5; float y = (x == 0.0) ? 2.0 : 3.0;"
|
||||
);
|
||||
// Plain assignment from an equals sign still promotes.
|
||||
assert.equal(
|
||||
normalizeImplicitCadGlslFloatLiterals("float z = 0;"),
|
||||
"float z = 0.0;"
|
||||
);
|
||||
});
|
||||
|
||||
test("fragment shader accepts ordinary JS integer template output", () => {
|
||||
const shader = implicitCadFragmentShader({
|
||||
glslSource: `
|
||||
float sdf(vec3 p) { return length(p) - 22; }
|
||||
vec3 color(vec3 p, vec3 normal) { return vec3(1, 0, 0); }
|
||||
`,
|
||||
maxSteps: 64
|
||||
});
|
||||
|
||||
assert.match(shader, /length\(p\) - 22\.0/);
|
||||
assert.match(shader, /vec3\(1\.0, 0\.0, 0\.0\)/);
|
||||
});
|
||||
|
||||
test("fragment shader caps raymarch steps for thin procedural fields", () => {
|
||||
const shader = implicitCadFragmentShader({
|
||||
glslSource: "float sdf(vec3 p) { return length(p) - 1.0; }",
|
||||
maxSteps: 64
|
||||
});
|
||||
|
||||
assert.match(shader, /uniform float uStepScale;/);
|
||||
assert.match(shader, /uniform float uMaxStep;/);
|
||||
assert.match(shader, /t \+= min\(stepDistance, uMaxStep\);/);
|
||||
});
|
||||
|
||||
test("fragment shader exposes graphics lighting controls", () => {
|
||||
const shader = implicitCadFragmentShader({
|
||||
glslSource: "float sdf(vec3 p) { return length(p) - 1.0; }",
|
||||
maxSteps: 64
|
||||
});
|
||||
|
||||
assert.match(shader, /uniform float uShadowStrength;/);
|
||||
assert.match(shader, /uniform float uAmbientOcclusionStrength;/);
|
||||
assert.match(shader, /uniform float uRimStrength;/);
|
||||
assert.match(shader, /clamp\(uShadowStrength, 0\.0, 1\.0\)/);
|
||||
});
|
||||
|
||||
test("fragment shader declares parameter uniforms", () => {
|
||||
const shader = implicitCadFragmentShader({
|
||||
glslSource: `
|
||||
float sdf(vec3 p) { return length(p) - radius; }
|
||||
vec3 color(vec3 p, vec3 normal) { return tint; }
|
||||
`,
|
||||
maxSteps: 64,
|
||||
uniforms: {
|
||||
radius: { type: "float", value: 2.5 },
|
||||
tint: { type: "vec3", value: [1, 0, 0] }
|
||||
}
|
||||
});
|
||||
|
||||
assert.match(shader, /uniform float radius;/);
|
||||
assert.match(shader, /uniform vec3 tint;/);
|
||||
assert.match(shader, /length\(p\) - radius/);
|
||||
});
|
||||
|
||||
test("shader key is stable for uniform-only parameter changes", () => {
|
||||
const base = {
|
||||
params: {
|
||||
radius: { type: "number", min: 1, max: 10, default: 2 }
|
||||
},
|
||||
glsl: "float sdf(vec3 p) { return length(p) - radius; }"
|
||||
};
|
||||
const first = implicitCadModelShaderKey(base);
|
||||
const second = implicitCadModelShaderKey({
|
||||
...base,
|
||||
values: { radius: 8 }
|
||||
});
|
||||
const changedSource = implicitCadModelShaderKey({
|
||||
...base,
|
||||
glsl: "float sdf(vec3 p) { return length(p) - radius + 0.1; }"
|
||||
});
|
||||
|
||||
assert.equal(first, second);
|
||||
assert.notEqual(first, changedSource);
|
||||
});
|
||||
|
||||
test("fragment shader uses a zero-direction-safe ray bounds intersection", () => {
|
||||
const shader = implicitCadFragmentShader({
|
||||
glslSource: "float sdf(vec3 p) { return length(p) - 1.0; }",
|
||||
maxSteps: 64
|
||||
});
|
||||
|
||||
assert.match(shader, /vec2 implicit_ray_slab/);
|
||||
assert.match(shader, /abs\(direction\) < 1\.0e-8/);
|
||||
});
|
||||
|
||||
test("camera objects support explicit CAD snapshot-style controls", () => {
|
||||
const state = implicitCadCameraState(
|
||||
normalizeImplicitCadModel({
|
||||
glsl: "float sdf(vec3 p) { return length(p) - 1.0; }",
|
||||
bounds: { min: [-1, -1, -1], max: [1, 1, 1] }
|
||||
}),
|
||||
{
|
||||
position: [4, -5, 3],
|
||||
target: [0.5, 0, -0.25],
|
||||
up: [0, 0, 1],
|
||||
zoom: 1.8
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepEqual(state.position, [4, -5, 3]);
|
||||
assert.deepEqual(state.target, [0.5, 0, -0.25]);
|
||||
assert.deepEqual(state.up, [0, 0, 1]);
|
||||
assert.equal(state.zoom, 1.8);
|
||||
});
|
||||
|
||||
test("camera framing samples SDF bounds when declared bounds are roomy", () => {
|
||||
const state = implicitCadCameraState(
|
||||
normalizeImplicitCadModel({
|
||||
glsl: "float sdf(vec3 p) { return length(p) - 1.0; }",
|
||||
bounds: { min: [-10, -10, -10], max: [10, 10, 10] }
|
||||
}),
|
||||
"iso",
|
||||
{ width: 1200, height: 900, zoom: 1 }
|
||||
);
|
||||
const distance = Math.hypot(
|
||||
state.position[0] - state.target[0],
|
||||
state.position[1] - state.target[1],
|
||||
state.position[2] - state.target[2]
|
||||
);
|
||||
|
||||
assert.ok(state.frameBounds.min[0] > -5, `expected sampled min, got ${state.frameBounds.min[0]}`);
|
||||
assert.ok(state.frameBounds.max[0] < 5, `expected sampled max, got ${state.frameBounds.max[0]}`);
|
||||
assert.ok(distance < 30, `expected a camera fit to sampled geometry, got ${distance}`);
|
||||
});
|
||||
|
||||
test("camera framing honors a larger frame margin for safer snapshots", () => {
|
||||
const model = normalizeImplicitCadModel({
|
||||
glsl: "float sdf(vec3 p) { return length(p) - 1.0; }",
|
||||
bounds: { min: [-1, -1, -1], max: [1, 1, 1] }
|
||||
});
|
||||
const compact = implicitCadCameraState(model, "iso", {
|
||||
width: 1200,
|
||||
height: 900,
|
||||
zoom: 1,
|
||||
frameMargin: 1.05
|
||||
});
|
||||
const padded = implicitCadCameraState(model, "iso", {
|
||||
width: 1200,
|
||||
height: 900,
|
||||
zoom: 1,
|
||||
frameMargin: 1.7
|
||||
});
|
||||
const compactDistance = Math.hypot(
|
||||
compact.position[0] - compact.target[0],
|
||||
compact.position[1] - compact.target[1],
|
||||
compact.position[2] - compact.target[2]
|
||||
);
|
||||
const paddedDistance = Math.hypot(
|
||||
padded.position[0] - padded.target[0],
|
||||
padded.position[1] - padded.target[1],
|
||||
padded.position[2] - padded.target[2]
|
||||
);
|
||||
|
||||
assert.ok(paddedDistance > compactDistance, `expected padded camera to stand farther back than ${compactDistance}, got ${paddedDistance}`);
|
||||
});
|
||||
|
||||
test("camera framing can skip the CPU SDF estimate for interactive fits", () => {
|
||||
// Distinct radius keeps this model's estimate-cache key unique across tests,
|
||||
// so the no-estimate path sees a cold cache and uses declared bounds.
|
||||
const model = normalizeImplicitCadModel({
|
||||
glsl: "float sdf(vec3 p) { return length(p) - 2.5; }",
|
||||
bounds: { min: [-10, -10, -10], max: [10, 10, 10] }
|
||||
});
|
||||
const started = performance.now();
|
||||
const state = implicitCadCameraState(model, "iso", {
|
||||
width: 1200,
|
||||
height: 900,
|
||||
zoom: 1,
|
||||
estimateFrameBounds: false
|
||||
});
|
||||
|
||||
assert.ok(performance.now() - started < 50, "no-estimate fit should not run the evaluator grid");
|
||||
assert.deepEqual(state.frameBounds, { min: [-10, -10, -10], max: [10, 10, 10] });
|
||||
});
|
||||
|
||||
test("async frame-bounds estimate matches the sync estimate and refreshes floor uniforms", async () => {
|
||||
// Distinct radius keeps this model's estimate-cache key unique across tests.
|
||||
const source = {
|
||||
glsl: "float sdf(vec3 p) { return length(p) - 1.5; }",
|
||||
bounds: { min: [-10, -10, -10], max: [10, 10, 10] }
|
||||
};
|
||||
const model = normalizeImplicitCadModel(source);
|
||||
const material = createImplicitCadMaterial(THREE, model);
|
||||
// Floor placement starts on declared bounds because no estimate exists yet.
|
||||
assert.equal(material.uniforms.uFloorZ.value, -10);
|
||||
|
||||
const estimated = await estimateImplicitCadFrameBoundsAsync(model);
|
||||
assert.ok(estimated.min[2] > -5, `expected sampled min z, got ${estimated.min[2]}`);
|
||||
|
||||
const syncState = implicitCadCameraState(model, "iso", { width: 1200, height: 900, zoom: 1 });
|
||||
assert.deepEqual(syncState.frameBounds, estimated);
|
||||
|
||||
const floorBounds = await refreshImplicitCadFloorBounds(material, model);
|
||||
assert.equal(material.uniforms.uFloorZ.value, floorBounds.min[2]);
|
||||
assert.ok(material.uniforms.uFloorZ.value > -5, "floor should snap to sampled bounds");
|
||||
|
||||
material.dispose();
|
||||
});
|
||||
|
||||
test("camera framing falls back to declared bounds when CPU SDF sampling cannot evaluate GLSL", () => {
|
||||
const state = implicitCadCameraState(
|
||||
normalizeImplicitCadModel({
|
||||
glsl: `
|
||||
float sdf(vec3 p) {
|
||||
for (int i = 0; i < 2; i += 1) {
|
||||
break;
|
||||
}
|
||||
return length(p) - 1.0;
|
||||
}
|
||||
`,
|
||||
bounds: { min: [-10, -10, -10], max: [10, 10, 10] }
|
||||
}),
|
||||
"iso",
|
||||
{ width: 1200, height: 900, zoom: 1 }
|
||||
);
|
||||
|
||||
assert.deepEqual(state.frameBounds, { min: [-10, -10, -10], max: [10, 10, 10] });
|
||||
});
|
||||
|
||||
test("workbench appearance and graphics update the shared shader uniforms", () => {
|
||||
const model = normalizeImplicitCadModel({
|
||||
glsl: `
|
||||
float sdf(vec3 p) { return length(p) - 1.0; }
|
||||
vec3 color(vec3 p, vec3 normal) { return vec3(0.1, 0.8, 1.0); }
|
||||
`,
|
||||
material: { color: "#ff3366" }
|
||||
});
|
||||
const material = createImplicitCadMaterial(THREE, model);
|
||||
|
||||
const themeSettings = updateImplicitCadAppearanceUniforms(THREE, material, model, {
|
||||
appearance: "workbench",
|
||||
graphicsSettings: { modelColors: false }
|
||||
});
|
||||
const graphicsSettings = updateImplicitCadGraphicsUniforms(material, model, {
|
||||
detail: 4,
|
||||
normalSmoothing: 2,
|
||||
stepBudget: 24,
|
||||
shadows: false,
|
||||
ambientOcclusion: false,
|
||||
rimLight: false
|
||||
});
|
||||
|
||||
assert.equal(material.uniforms.uUseProceduralColor.value, 0);
|
||||
assert.equal(material.uniforms.uShadowStrength.value, 0);
|
||||
assert.equal(material.uniforms.uAmbientOcclusionStrength.value, 0);
|
||||
assert.equal(material.uniforms.uRimStrength.value, 0);
|
||||
assert.equal(material.uniforms.uStepBudget.value, 24);
|
||||
assert.equal(graphicsSettings.detail, 4);
|
||||
assert.ok(material.uniforms.uHitEpsilon.value < model.epsilon);
|
||||
assert.equal(resolveImplicitCadAppearanceSettings({ appearance: "workbench" }).background.type, themeSettings.background.type);
|
||||
|
||||
material.dispose();
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
export const IMPLICIT_CAD_SCHEMA = "implicit.js/0.1.0";
|
||||
export const IMPLICIT_CAD_KIND = "implicit";
|
||||
export const IMPLICIT_CAD_EXTENSIONS = Object.freeze([".implicit.js", ".implicit.mjs"]);
|
||||
|
||||
export const IMPLICIT_SCHEMA = IMPLICIT_CAD_SCHEMA;
|
||||
export const IMPLICIT_KIND = IMPLICIT_CAD_KIND;
|
||||
export const IMPLICIT_EXTENSIONS = IMPLICIT_CAD_EXTENSIONS;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
import { renderImplicitCadToDataUrl } from "./render.js";
|
||||
|
||||
const DEFAULT_SNAPSHOT_WIDTH = 1200;
|
||||
const DEFAULT_SNAPSHOT_HEIGHT = 900;
|
||||
const DEFAULT_APPEARANCE = "workbench";
|
||||
const PNG_MIME_TYPE = "image/png";
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function positiveInteger(value, fallback) {
|
||||
const numeric = Math.floor(Number(value));
|
||||
return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
|
||||
}
|
||||
|
||||
function mergedPlainObject(...values) {
|
||||
return values.reduce((merged, value) => (
|
||||
isPlainObject(value) ? { ...merged, ...value } : merged
|
||||
), {});
|
||||
}
|
||||
|
||||
export function snapshotImplicitCadOutputOptions(job = {}, output = {}) {
|
||||
const normalizedJob = isPlainObject(job) ? job : {};
|
||||
const normalizedOutput = isPlainObject(output) ? output : {};
|
||||
const width = positiveInteger(
|
||||
normalizedOutput.width ?? normalizedJob.width,
|
||||
DEFAULT_SNAPSHOT_WIDTH
|
||||
);
|
||||
const height = positiveInteger(
|
||||
normalizedOutput.height ?? normalizedJob.height,
|
||||
DEFAULT_SNAPSHOT_HEIGHT
|
||||
);
|
||||
|
||||
return {
|
||||
path: String(normalizedOutput.path || normalizedJob.output || ""),
|
||||
width,
|
||||
height,
|
||||
camera: normalizedOutput.camera || normalizedJob.camera || "iso",
|
||||
appearance: normalizedOutput.appearance || normalizedJob.appearance || DEFAULT_APPEARANCE,
|
||||
graphics: mergedPlainObject(normalizedJob.graphics, normalizedOutput.graphics),
|
||||
render: mergedPlainObject(normalizedJob.render, normalizedOutput.render),
|
||||
};
|
||||
}
|
||||
|
||||
export async function snapshotImplicitCadModel(THREE, model, options = {}) {
|
||||
const normalizedOptions = isPlainObject(options.job) || isPlainObject(options.output)
|
||||
? snapshotImplicitCadOutputOptions(options.job, options.output)
|
||||
: snapshotImplicitCadOutputOptions(options, options);
|
||||
|
||||
return {
|
||||
path: normalizedOptions.path,
|
||||
width: normalizedOptions.width,
|
||||
height: normalizedOptions.height,
|
||||
mimeType: PNG_MIME_TYPE,
|
||||
dataUrl: await renderImplicitCadToDataUrl(THREE, model, {
|
||||
width: normalizedOptions.width,
|
||||
height: normalizedOptions.height,
|
||||
camera: normalizedOptions.camera,
|
||||
appearance: normalizedOptions.appearance,
|
||||
graphics: normalizedOptions.graphics,
|
||||
render: normalizedOptions.render,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function snapshotImplicitCadModelToDataUrl(THREE, model, options = {}) {
|
||||
const result = await snapshotImplicitCadModel(THREE, model, options);
|
||||
return result.dataUrl;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { snapshotImplicitCadOutputOptions } from "./snapshot.js";
|
||||
|
||||
test("snapshot output options merge job and output settings", () => {
|
||||
const options = snapshotImplicitCadOutputOptions({
|
||||
output: "fallback.png",
|
||||
width: 800,
|
||||
height: 600,
|
||||
camera: "front",
|
||||
appearance: "workbench",
|
||||
graphics: { detail: 0.5, shadows: false },
|
||||
render: { transparent: false }
|
||||
}, {
|
||||
path: "preview.png",
|
||||
width: 1200,
|
||||
camera: "top",
|
||||
graphics: { shadows: true },
|
||||
render: { transparent: true }
|
||||
});
|
||||
|
||||
assert.deepEqual(options, {
|
||||
path: "preview.png",
|
||||
width: 1200,
|
||||
height: 600,
|
||||
camera: "top",
|
||||
appearance: "workbench",
|
||||
graphics: { detail: 0.5, shadows: true },
|
||||
render: { transparent: true }
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
export const BASE_VIEWER_THEME = {
|
||||
sceneBackground: "#09090b",
|
||||
surface: "#f4f4f5",
|
||||
surfaceRoughness: 0.92,
|
||||
surfaceMetalness: 0.03
|
||||
};
|
||||
|
||||
export const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
|
||||
|
||||
export function getViewerThemeValue(viewerTheme, key, fallback) {
|
||||
const value = viewerTheme?.[key];
|
||||
return value ?? BASE_VIEWER_THEME[key] ?? fallback;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
BASE_VIEWER_THEME,
|
||||
HEX_COLOR_PATTERN
|
||||
} from "./stageTheme.js";
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function firstThemeFillColor(materialSettings = {}, fallback = BASE_VIEWER_THEME.surface) {
|
||||
const fillColors = Array.isArray(materialSettings.fillColors) ? materialSettings.fillColors : [];
|
||||
return String(fillColors[0] || materialSettings.defaultColor || fallback || BASE_VIEWER_THEME.surface);
|
||||
}
|
||||
|
||||
export function readSourceColor(THREE, value) {
|
||||
const normalized = String(value || "").trim();
|
||||
if (!HEX_COLOR_PATTERN.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
const expanded = normalized.length === 4
|
||||
? `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}`
|
||||
: normalized;
|
||||
return new THREE.Color(expanded);
|
||||
}
|
||||
|
||||
export function shapeSourceColor(THREE, sourceColor, materialSettings = {}, { applyTint = true } = {}) {
|
||||
const shaped = (sourceColor || new THREE.Color("#ffffff")).clone();
|
||||
const tintStrength = clamp(Number(materialSettings.tintStrength) || 0, 0, 1);
|
||||
if (applyTint && tintStrength > 0) {
|
||||
const tintColor = readSourceColor(THREE, materialSettings.defaultColor || materialSettings.tintColor) || new THREE.Color("#ffffff");
|
||||
if (materialSettings.tintMode === "blend") {
|
||||
shaped.lerp(tintColor, tintStrength);
|
||||
} else {
|
||||
shaped.lerp(shaped.clone().multiply(tintColor), tintStrength);
|
||||
}
|
||||
}
|
||||
|
||||
const saturation = clamp(Number(materialSettings.saturation) || 1, 0, 2.5);
|
||||
if (Math.abs(saturation - 1) > 1e-4) {
|
||||
const hsl = {};
|
||||
shaped.getHSL(hsl);
|
||||
shaped.setHSL(hsl.h, clamp(hsl.s * saturation, 0, 1), hsl.l);
|
||||
}
|
||||
|
||||
const contrast = clamp(Number(materialSettings.contrast) || 1, 0, 2.5);
|
||||
const brightness = clamp(Number(materialSettings.brightness) || 1, 0, 2);
|
||||
shaped.r = clamp(((shaped.r - 0.5) * contrast + 0.5) * brightness, 0, 1);
|
||||
shaped.g = clamp(((shaped.g - 0.5) * contrast + 0.5) * brightness, 0, 1);
|
||||
shaped.b = clamp(((shaped.b - 0.5) * contrast + 0.5) * brightness, 0, 1);
|
||||
return shaped;
|
||||
}
|
||||
|
||||
export function resolveSourceBaseColor(THREE, {
|
||||
hasVertexColors = false,
|
||||
sourceColor = null,
|
||||
materialSettings = {},
|
||||
fallbackColor = BASE_VIEWER_THEME.surface,
|
||||
forceFill = false
|
||||
} = {}) {
|
||||
if (forceFill || !sourceColor) {
|
||||
const fillColor = readSourceColor(
|
||||
THREE,
|
||||
firstThemeFillColor(materialSettings, fallbackColor)
|
||||
) || new THREE.Color(fallbackColor || BASE_VIEWER_THEME.surface);
|
||||
return shapeSourceColor(THREE, fillColor, materialSettings, { applyTint: false });
|
||||
}
|
||||
if (hasVertexColors) {
|
||||
return new THREE.Color("#ffffff");
|
||||
}
|
||||
return shapeSourceColor(THREE, sourceColor, materialSettings);
|
||||
}
|
||||
Reference in New Issue
Block a user