first commit

This commit is contained in:
2026-07-22 13:48:46 +08:00
commit c87751c3dc
2820 changed files with 726976 additions and 0 deletions
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 earthtojake and Gradient Control Laboratories
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,169 @@
---
name: implicit-cad
description: Create, edit, render, and snapshot browser-native implicit CAD `.implicit.js` and `.implicit.mjs` files using GLSL signed-distance fields, shader primitives, smooth booleans, TPMS fields, and direct CAD Viewer raymarch rendering. Experimental.
---
# Implicit CAD
Use this skill for implicit CAD models that should run directly in CAD Viewer as browser JS modules. The primary artifact is a `.implicit.js` or `.implicit.mjs`.
This skill is experimental. ALWAYS prefer conventional STEP-first CAD workflows unless the user explicitly asks for an implicit model.
## File Format
An implicit CAD file is an ES module exporting an `implicit.js/0.1.0` object. The schema source of truth lives in the bundled package at `scripts/packages/implicitjs/src/lib/implicitCad/schema.js`; `scripts/lib/implicit-cad.mjs` re-exports it as `SCHEMA` for helper-authored modules.
```js
export default {
schema: "implicit.js/0.1.0",
name: "rounded capsule block",
glsl: `
float sdf(vec3 p) {
float sphere = implicit_sphere(p, vec3(0.0), 22.0);
float block = implicit_box_centered(p, vec3(34.0, 18.0, 18.0), vec3(0.0));
return implicit_union_round(sphere, block, 3.0);
}
vec3 color(vec3 p, vec3 normal) {
return mix(vec3(0.20, 0.55, 0.95), vec3(0.95, 0.45, 0.20), smoothstep(-15.0, 20.0, p.z));
}
`,
};
```
Models may also declare params and animations. Parameter definitions use the implicitjs control schema: `number`, `boolean`, `enum`/`select`, `color`, `string`, and `button`. Number, boolean, color, and button params automatically become GLSL uniforms with the same name; do not add a separate `uniforms` object. `bounds` is optional and is estimated from the SDF when omitted; add explicit bounds only when the auto estimate is too broad, too slow, or misses an unusual field. `bounds` and `render` may be JavaScript functions that receive `{ ...params, params, animation, animationState, elapsedSec, progress, t }`.
Built-in GLSL helpers use the `implicit_*` namespace, for example `implicit_sphere`, `implicit_box_centered`, and `implicit_union_round`.
```js
export default {
schema: "implicit.js/0.1.0",
name: "breathing orb",
params: {
radius: {
type: "number",
label: "Radius",
min: 12,
max: 34,
default: 22,
unit: "mm",
},
},
animations: {
breathe: {
label: "Breathe",
duration: 3,
update({ progress, set }) {
set("radius", 18 + Math.sin(progress * Math.PI) * 10);
},
},
},
render: { steps: 224, epsilon: 0.004 },
glsl: `
float sdf(vec3 p) {
return length(p) - radius;
}
vec3 color(vec3 p, vec3 normal) {
return mix(vec3(0.10, 0.58, 0.95), vec3(1.0, 0.34, 0.12), smoothstep(-18.0, 18.0, p.z));
}
`,
};
```
Do not copy bundled helper files out of this skill. If helper functions are useful, use `scripts/lib/implicit-cad.mjs` during authoring or emit standalone GLSL into the final `.implicit.js`/`.implicit.mjs` module.
## Authoring Workflow
1. Write a natural-language modeling brief with dimensions, coordinate assumptions, procedural color intent, and visual checks.
2. Create or edit the user-specified `.implicit.js`/`.implicit.mjs` module.
3. Use `scripts/lib/implicit-cad.mjs` helpers for primitives and field composition when useful:
- primitives: `sphere`, `circle`, `boxCentered`, `plane`, `lineSegment`, `torus`, `axis`, `cylinder`, `cylinderCapped`, `capsule`, `cone`, `coneCapped`, `coneCapsule`
- booleans/blends: `unionSharp`, `intersectSharp`, `unionRound`, `intersectRound`, `unionChamfer`, `intersectChamfer`, `unionExp`, `intersectExp`, `unionLpNorm`, `intersectLpNorm`, `unionRvachev`, `intersectRvachev`, `difference`
- modifiers/lattices: `shell`, `rotateAxis`, `repeatCentered`, `remapCylindrical`, `cubicGrid`, `squareHoneycomb`, `squareHoneycombReinforced`, `squareDiagonalHoneycomb`, `octetHoneycomb`, `hexagonalHoneycomb`, `triangularHoneycomb`
- TPMS fields: `tpmsGyroid`, `tpmsSchwarz`, `tpmsDiamond`, `tpmsLidinoid`, `tpmsNeovius`, `tpmsSplitP`, `tpmsIwp`
- shader wrappers: `distanceFunction` emits `float sdf(vec3 p)`, `colorFunction` emits `vec3 color(vec3 p, vec3 normal)`
4. Add optional `params` and `animations` for dimensions, toggles, palettes, mode switches, and animated exploration. Use param names directly in GLSL; the runtime declares matching uniforms.
5. Add optional procedural color with `vec3 color(vec3 p, vec3 normal)` when the model benefits from local material variation. Keep color values in 0..1 RGB.
6. Rely on automatic SDF bounds first. Add explicit bounds when an animated, periodic, translated, or very thin model needs tighter or more reliable framing/export sampling.
7. Run the lightweight visual verification flow below after visible geometry, color, params, animation, bounds, render, or export-affecting changes.
8. Run `node scripts/export.mjs --input <model.implicit.js> --format glb` when a mesh artifact is needed for downstream viewers, slicers, or file handoff.
## Visual Verification
Use this skill's snapshot tool as a fast visual check, not as a substitute for deterministic import/export validation. Keep the packet small and purposeful.
For simple static edits, one image is enough:
```bash
node scripts/snapshot.mjs --input models/implicit-cad/<model>.implicit.js --output /tmp/implicit-review/<model>.png
```
For topology, periodicity, thin features, Boolean blends, object identity, color, or suspected framing issues, render a small packet in one CLI call so the browser, module, and runtime model are reused:
```bash
node scripts/snapshot.mjs --job - <<'JSON'
{
"input": "models/implicit-cad/<model>.implicit.js",
"mode": "view",
"render": { "sizeProfile": "simple", "frameMargin": 1.55 },
"graphics": { "modelColors": true, "detail": 1.2, "shadows": true, "ambientOcclusion": true },
"outputs": [
{ "path": "/tmp/implicit-review/<model>-iso.png", "camera": "iso" },
{ "path": "/tmp/implicit-review/<model>-front.png", "camera": "front" },
{ "path": "/tmp/implicit-review/<model>-top.png", "camera": "top" },
{ "path": "/tmp/implicit-review/<model>-right.png", "camera": "right" }
]
}
JSON
```
Add `implicitParameters` at the job level for one parameter state, or on individual outputs when the point of the review is comparing parameter variants. Use `render.frameMargin` around `1.5` when a model is close to the edge; if a snapshot still appears clipped, first check whether the source `bounds` is cutting the raymarch itself.
For animations, create a short GIF only when motion is part of the request:
```bash
node scripts/snapshot.mjs --job - <<'JSON'
{
"input": "models/implicit-cad/<model>.implicit.js",
"mode": "animate",
"outputs": [{ "path": "/tmp/implicit-review/<model>-animation.gif" }],
"implicitAnimation": { "activeId": "<animation-id>", "durationSeconds": 3, "fps": 12 }
}
JSON
```
Review the resulting PNG/GIF for centered framing, no top/bottom/side clipping, expected silhouette and topology, visible parameter differences, GLSL-defined colors, no unexpected holes/gaps, and smooth enough edges for the requested graphics settings. If the snapshot reveals a mismatch, fix the implicit source or bounds and rerun only the relevant packet.
## Handoff
After completing implicit CAD work that creates or modifies `.implicit.js`, `.implicit.mjs`, `.glb`, `.stl`, or `.3mf` artifacts, you must ALWAYS hand the explicit file path(s) to `$cad-viewer` when that skill is installed. `$cad-viewer` must start CAD Viewer if it is not already running and return link(s) to the relevant created or updated file(s); include those live viewer link(s) in the final response. If `$cad-viewer` is unavailable or startup fails, report that instead of silently omitting the handoff.
When verification snapshots are generated, also include the saved PNG/GIF snapshot(s) in the final response. If no snapshot applies, or if snapshot generation fails, say why and report the deterministic validation that still ran.
## Snapshot Tool
From this skill directory:
```bash
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
node scripts/snapshot.mjs --help
```
Use `node scripts/snapshot.mjs --help` for the complete current command interface. The tool appends a UTC timestamp before the output extension. JSON jobs may be a single job, one job with multiple `outputs`, a raw array of jobs, or `{ "jobs": [...] }`; prefer a multi-output job for review packets because it avoids rebuilding the same artifact for each camera.
## Export Tool
From this skill directory:
```bash
node scripts/export.mjs --input <model.implicit.js> --format glb
node scripts/export.mjs --input <model.implicit.js> --output <mesh.stl> --resolution <resolution>
node scripts/export.mjs --input <model.implicit.js> --format 3mf --params '<parameter-json>' --json
node scripts/export.mjs --help
```
Supported export formats are `glb`, `stl`, and `3mf`. The exporter samples the implicit SDF inside the declared bounds and extracts a triangle mesh. If `--output` is omitted, the mesh is written next to the source file using the same stem, such as `<model>.glb` for `<model>.implicit.js`. Use `node scripts/export.mjs --help` for the complete current command interface.
@@ -0,0 +1,4 @@
interface:
display_name: "Implicit CAD"
short_description: "Write shader-native implicit CAD files."
default_prompt: "Use $implicit-cad to create browser-rendered implicit CAD `.implicit.js` files with GLSL SDF primitives and snapshots."
+22
View File
@@ -0,0 +1,22 @@
#!/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 scriptsDir = path.dirname(fileURLToPath(import.meta.url));
const packageCli = path.join(scriptsDir, "packages", "implicitjs", "scripts", "export.mjs");
if (!fs.existsSync(packageCli)) {
process.stderr.write(
`Missing implicitjs export CLI at ${packageCli}. Restore the development symlink layout or rebuild the production skill bundle.\n`
);
process.exit(1);
}
const completed = spawnSync(process.execPath, [packageCli, ...process.argv.slice(2)], {
cwd: process.cwd(),
stdio: "inherit",
});
process.exitCode = completed.status ?? 1;
@@ -0,0 +1,394 @@
import { IMPLICIT_CAD_SCHEMA } from "../packages/implicitjs/src/lib/implicitCad/schema.js";
export const SCHEMA = IMPLICIT_CAD_SCHEMA;
export const PI = Math.PI;
export const TWO_PI = Math.PI * 2;
export const SQRT2 = Math.sqrt(2);
export const SQRT3 = Math.sqrt(3);
function numberLiteral(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
throw new Error(`Expected finite number, got ${value}`);
}
if (Object.is(numeric, -0)) {
return "0.0";
}
return Number.isInteger(numeric) ? `${numeric}.0` : String(numeric);
}
function componentList(value, dimension) {
if (typeof value === "number") {
return Array.from({ length: dimension }, () => numberLiteral(value));
}
if (!Array.isArray(value) && !ArrayBuffer.isView(value)) {
throw new Error(`Expected vec${dimension} array or scalar`);
}
return Array.from({ length: dimension }, (_, index) => numberLiteral(value[index] ?? 0));
}
export function vec2(value, y = null) {
const components = y === null ? componentList(value, 2) : [numberLiteral(value), numberLiteral(y)];
return `vec2(${components.join(", ")})`;
}
export function vec3(value, y = null, z = null) {
const components = y === null && z === null
? componentList(value, 3)
: [numberLiteral(value), numberLiteral(y), numberLiteral(z)];
return `vec3(${components.join(", ")})`;
}
export function expr(value) {
if (value && typeof value === "object" && typeof value.expr === "string") {
return value.expr;
}
if (typeof value === "number") {
return numberLiteral(value);
}
return String(value);
}
export function glsl(strings, ...values) {
return strings.reduce((result, part, index) => (
`${result}${part}${index < values.length ? expr(values[index]) : ""}`
), "");
}
function reduceCall(values, callName, radius = null) {
const parts = values.map(expr);
if (parts.length === 0) {
throw new Error(`${callName} requires at least one field`);
}
if (parts.length === 1) {
return parts[0];
}
return parts.slice(1).reduce((acc, value) => (
radius === null
? `${callName}(${acc}, ${value})`
: `${callName}(${acc}, ${value}, ${numberLiteral(radius)})`
), parts[0]);
}
function reduceCallWithArgs(values, callName, args = []) {
const parts = values.map(expr);
if (parts.length === 0) {
throw new Error(`${callName} requires at least one field`);
}
if (parts.length === 1) {
return parts[0];
}
const suffix = args.length > 0 ? `, ${args.map(expr).join(", ")}` : "";
return parts.slice(1).reduce((acc, value) => `${callName}(${acc}, ${value}${suffix})`, parts[0]);
}
export function linearMap(value, inMin, inMax, outMin, outMax) {
return `implicit_linear_map(${expr(value)}, ${numberLiteral(inMin)}, ${numberLiteral(inMax)}, ${numberLiteral(outMin)}, ${numberLiteral(outMax)})`;
}
export function ramp(value, inMin, inMax, outMin, outMax) {
return `implicit_ramp(${expr(value)}, ${numberLiteral(inMin)}, ${numberLiteral(inMax)}, ${numberLiteral(outMin)}, ${numberLiteral(outMax)})`;
}
export function twoBodyField(a, b) {
return `implicit_two_body_field(${expr(a)}, ${expr(b)})`;
}
export function twoBodyPolar(a, b, angle) {
return `implicit_two_body_polar(${expr(a)}, ${expr(b)}, ${numberLiteral(angle)})`;
}
export function triangleWaveEven(value, period) {
return `implicit_triangle_wave_even(${expr(value)}, ${expr(period)})`;
}
export function triangleWaveEvenPositive(value, period) {
return `implicit_triangle_wave_even_positive(${expr(value)}, ${expr(period)})`;
}
export function triangleWaveOdd(value, period) {
return `implicit_triangle_wave_odd(${expr(value)}, ${expr(period)})`;
}
export function triangleWaveOddPositive(value, period) {
return `implicit_triangle_wave_odd_positive(${expr(value)}, ${expr(period)})`;
}
export function unionSharp(values) {
return reduceCall(values, "implicit_union_sharp");
}
export function intersectSharp(values) {
return reduceCall(values, "implicit_intersect_sharp");
}
export function unionRound(values, radius = 0) {
return reduceCall(values, "implicit_union_round", radius);
}
export function intersectRound(values, radius = 0) {
return reduceCall(values, "implicit_intersect_round", radius);
}
export function unionChamfer(values, radius = 0) {
return reduceCall(values, "implicit_union_chamfer", radius);
}
export function intersectChamfer(values, radius = 0) {
return reduceCall(values, "implicit_intersect_chamfer", radius);
}
export function unionExp(values, radius = 0) {
return reduceCall(values, "implicit_union_exp", radius);
}
export function intersectExp(values, radius = 0) {
return reduceCall(values, "implicit_intersect_exp", radius);
}
export function unionLpNorm(values, radius = 0, normPower = 2) {
return reduceCallWithArgs(values, "implicit_union_lp_norm", [numberLiteral(radius), numberLiteral(normPower)]);
}
export function intersectLpNorm(values, radius = 0, normPower = 2) {
return reduceCallWithArgs(values, "implicit_intersect_lp_norm", [numberLiteral(radius), numberLiteral(normPower)]);
}
export function unionRvachev(values, radius = 0) {
return reduceCall(values, "implicit_union_rvachev", radius);
}
export function intersectRvachev(values, radius = 0) {
return reduceCall(values, "implicit_intersect_rvachev", radius);
}
export function booleanSharp() {
return { union: unionSharp, intersect: intersectSharp };
}
export function booleanRound(radius = 0) {
return {
radius,
union: (values) => unionRound(values, radius),
intersect: (values) => intersectRound(values, radius),
};
}
export function booleanExp(radius = 0) {
return {
radius,
union: (values) => unionExp(values, radius),
intersect: (values) => intersectExp(values, radius),
};
}
export function booleanLp(normPower, radius = 0) {
return {
radius,
normPower,
union: (values) => unionLpNorm(values, radius, normPower),
intersect: (values) => intersectLpNorm(values, radius, normPower),
};
}
export function booleanRvachev(radius = 0) {
return {
radius,
union: (values) => unionRvachev(values, radius),
intersect: (values) => intersectRvachev(values, radius),
};
}
export function booleanChamfer(radius = 0) {
return {
radius,
union: (values) => unionChamfer(values, radius),
intersect: (values) => intersectChamfer(values, radius),
};
}
export function difference(target, tools, {
union = unionSharp,
intersect = intersectSharp
} = {}) {
const toolList = Array.isArray(tools) ? tools : [tools];
const toolUnion = union(toolList);
return intersect([target, `-(${toolUnion})`]);
}
export function sphere(p, center, radius) {
return `implicit_sphere(${expr(p)}, ${vec3(center)}, ${numberLiteral(radius)})`;
}
export function circle(p, center, radius) {
return sphere(p, center, radius);
}
export function boxCentered(p, size, { center = [0, 0, 0] } = {}) {
return `implicit_box_centered(${expr(p)}, ${vec3(size)}, ${vec3(center)})`;
}
export function plane(p, origin, normal) {
return `implicit_plane(${expr(p)}, ${vec3(origin)}, ${vec3(normal)})`;
}
export function lineSegment(p, endpointA, endpointB) {
return `implicit_line_segment(${expr(p)}, ${vec3(endpointA)}, ${vec3(endpointB)})`;
}
export function torus(p, majorRadius, minorRadius) {
return `implicit_torus(${expr(p)}, ${numberLiteral(majorRadius)}, ${numberLiteral(minorRadius)})`;
}
export function axis(p, origin, direction) {
return `implicit_axis(${expr(p)}, ${vec3(origin)}, ${vec3(direction)})`;
}
export function cylinder(p, origin, direction, radius) {
return `implicit_cylinder(${expr(p)}, ${vec3(origin)}, ${vec3(direction)}, ${numberLiteral(radius)})`;
}
export function cylinderCapped(p, endpointA, endpointB, radius) {
return `implicit_cylinder_capped(${expr(p)}, ${vec3(endpointA)}, ${vec3(endpointB)}, ${numberLiteral(radius)})`;
}
export function capsule(p, endpointA, endpointB, radius) {
return `implicit_capsule(${expr(p)}, ${vec3(endpointA)}, ${vec3(endpointB)}, ${numberLiteral(radius)})`;
}
export function coneCapsule(p, endpointA, endpointB, radiusA, radiusB) {
return `implicit_cone_capsule(${expr(p)}, ${vec3(endpointA)}, ${vec3(endpointB)}, ${numberLiteral(radiusA)}, ${numberLiteral(radiusB)})`;
}
export function cone(p, apex, direction, halfAngle) {
return `implicit_cone(${expr(p)}, ${vec3(apex)}, ${vec3(direction)}, ${numberLiteral(halfAngle)})`;
}
export function coneCapped(p, endpointA, endpointB, radiusA, radiusB) {
return `implicit_cone_capped(${expr(p)}, ${vec3(endpointA)}, ${vec3(endpointB)}, ${numberLiteral(radiusA)}, ${numberLiteral(radiusB)})`;
}
export function shell(distance, thickness, bias = 0) {
return `implicit_shell(${expr(distance)}, ${numberLiteral(thickness)}, ${numberLiteral(bias)})`;
}
export function rotateAxis(p, origin, direction, angle) {
return `implicit_rotate_axis(${expr(p)}, ${vec3(origin)}, ${vec3(direction)}, ${numberLiteral(angle)})`;
}
export function repeatCentered(p, period) {
return `implicit_repeat_centered(${expr(p)}, ${vec3(period)})`;
}
export function remapCylindrical(p, circumference) {
return `implicit_remap_cylindrical(${expr(p)}, ${numberLiteral(circumference)})`;
}
export function cubicGrid(p, size) {
return `implicit_cubic_grid(${expr(p)}, ${vec3(size)})`;
}
export function squareHoneycomb(p, size) {
return `implicit_square_honeycomb(${expr(p)}, ${vec2(size)})`;
}
export function squareHoneycombReinforced(p, size, { rotation = 0.25, rotation2 = null } = {}) {
return `implicit_square_honeycomb_reinforced(${expr(p)}, ${vec2(size)}, ${numberLiteral(rotation)}, ${numberLiteral(rotation2 ?? 0)}, ${numberLiteral(rotation2 === null ? 0 : 1)})`;
}
export function squareDiagonalHoneycomb(p, size) {
return `implicit_square_diagonal_honeycomb(${expr(p)}, ${vec2(size)})`;
}
export function octetHoneycomb(p, size) {
return `implicit_octet_honeycomb(${expr(p)}, ${vec2(size)})`;
}
export function hexagonalHoneycomb(p, size, { setback = 1 / 3 } = {}) {
const resolvedSize = typeof size === "number" ? [size, size * SQRT3] : size;
return `implicit_hexagonal_honeycomb(${expr(p)}, ${vec2(resolvedSize)}, ${numberLiteral(setback)})`;
}
export function triangularHoneycomb(p, size) {
const resolvedSize = typeof size === "number" ? [size, size * SQRT3] : size;
return `implicit_triangular_honeycomb(${expr(p)}, ${vec2(resolvedSize)})`;
}
export function tpmsGyroid(p, period, drop = [1, 1, 1]) {
return `implicit_tpms_gyroid(${expr(p)}, ${vec3(period)}, ${vec3(drop)})`;
}
export function tpmsSchwarz(p, period, { drop = [1, 1, 1], gyroidBlend = 0 } = {}) {
return `implicit_tpms_schwarz(${expr(p)}, ${vec3(period)}, ${vec3(drop)}, ${numberLiteral(gyroidBlend)})`;
}
export function tpmsDiamond(p, period, { drop = [1, 1, 1], gyroidBlend = 0 } = {}) {
return `implicit_tpms_diamond(${expr(p)}, ${vec3(period)}, ${vec3(drop)}, ${numberLiteral(gyroidBlend)})`;
}
export function tpmsLidinoid(p, period, { drop = [1, 1, 1], gyroidBlend = 0 } = {}) {
return `implicit_tpms_lidinoid(${expr(p)}, ${vec3(period)}, ${vec3(drop)}, ${numberLiteral(gyroidBlend)})`;
}
export function tpmsNeovius(p, period, { drop = [1, 1, 1], schwarzBlend = 0 } = {}) {
return `implicit_tpms_neovius(${expr(p)}, ${vec3(period)}, ${vec3(drop)}, ${numberLiteral(schwarzBlend)})`;
}
export function tpmsSplitP(p, period, { lidinoidBlend = 1, gyroidOctave = 1, schwarzOctave = 1 } = {}) {
return `implicit_tpms_split_p(${expr(p)}, ${vec3(period)}, ${numberLiteral(lidinoidBlend)}, ${numberLiteral(gyroidOctave)}, ${numberLiteral(schwarzOctave)})`;
}
export function tpmsIwp(p, period, drop = [1, 1, 1]) {
return `implicit_tpms_iwp(${expr(p)}, ${vec3(period)}, ${vec3(drop)})`;
}
export function distanceFunction(distanceExpression, { point = "p" } = {}) {
return `float sdf(vec3 ${point}) {\n return ${expr(distanceExpression)};\n}`;
}
export function colorFunction(colorExpression, { point = "p", normal = "normal" } = {}) {
return `vec3 color(vec3 ${point}, vec3 ${normal}) {\n return clamp(${expr(colorExpression)}, vec3(0.0), vec3(1.0));\n}`;
}
function distanceSource(value) {
return typeof value === "function" || /\bsdf\s*\(/.test(expr(value))
? value
: distanceFunction(value);
}
function colorSource(value) {
return typeof value === "function" || /\bcolor\s*\(/.test(expr(value))
? value
: colorFunction(value);
}
export function createImplicitModel({
name = "Implicit CAD",
description = "",
units = "mm",
bounds,
params = {},
values = {},
animations = {},
render = {},
glsl = "",
distance,
color = "",
} = {}) {
const glslSource = glsl || [
distance !== undefined ? distanceSource(distance) : "",
color ? colorSource(color) : "",
].filter(Boolean).join("\n\n");
return {
schema: SCHEMA,
name,
description,
units,
...(bounds !== undefined ? { bounds } : {}),
...(params && Object.keys(params).length ? { params } : {}),
...(values && Object.keys(values).length ? { values } : {}),
...(animations && Object.keys(animations).length ? { animations } : {}),
...(render && Object.keys(render).length ? { render } : {}),
glsl: glslSource,
};
}
@@ -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"
}
}
@@ -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>
@@ -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;
}
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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/
);
});
@@ -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;
@@ -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);
}
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env node
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const scriptsDir = path.dirname(fileURLToPath(import.meta.url));
const packageCli = path.join(scriptsDir, "packages", "implicitjs", "scripts", "snapshot.mjs");
const { runRenderCli } = await import(pathToFileURL(packageCli).href).catch((error) => {
process.stderr.write(
`Missing implicitjs snapshot CLI at ${packageCli}. Restore the development symlink layout or rebuild the production skill bundle.\n${error instanceof Error ? error.message : String(error)}\n`
);
process.exit(1);
});
process.exitCode = await runRenderCli(process.argv.slice(2), {
cwd: process.cwd(),
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
});