
Implicit Cad
- 3.4k installs
- 11k repo stars
- Updated July 11, 2026
- earthtojake/text-to-cad
implicit-cad authors browser-native .implicit.js CAD models with GLSL signed-distance fields and CAD Viewer verification.
About
The implicit-cad skill authors browser-native implicit CAD models as .implicit.js or .implicit.mjs modules exporting implicit.js/0.1.0 schema with GLSL sdf and optional color functions. It is experimental and prefers conventional STEP-first CAD unless the user explicitly wants implicit models. Modules may declare params becoming GLSL uniforms, animations with update callbacks, optional bounds and render settings, and procedural color via vec3 color. Authoring uses scripts/lib/implicit-cad.mjs helpers for primitives, smooth booleans, TPMS fields, and lattices without copying bundled files into outputs. Visual verification runs scripts/snapshot.mjs for single PNG or multi-camera job packets and optional animation GIFs. Export uses node scripts/export.mjs for GLB mesh handoff. After vault writes, handoff to $cad-viewer is mandatory when installed, returning live viewer links. Bounds auto-estimate from SDF unless animated or thin geometry needs explicit framing. Handoff to $cad-viewer is mandatory when that skill is installed.
- ES module exports implicit.js/0.1.0 with GLSL sdf and optional color.
- Params become GLSL uniforms; no separate uniforms object.
- snapshot.mjs job packets verify iso, front, top, and right views.
- export.mjs produces GLB for slicers and downstream viewers.
- Mandatory $cad-viewer handoff with live links when skill is installed.
Implicit Cad by the numbers
- 3,396 all-time installs (skills.sh)
- +641 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #128 of 1,340 Generative Media skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
implicit-cad capabilities & compatibility
- Capabilities
- implicit.js/0.1.0 module authoring with glsl sdf · helper library for primitives, booleans, tpms, a · snapshot.mjs visual verification packets and ani · glb export via scripts/export.mjs · cad viewer handoff with live file links
- Use cases
- ui design · image generation
What implicit-cad says it does
ALWAYS prefer conventional STEP-first CAD workflows unless the user explicitly asks
Number, boolean, color, and button params automatically become GLSL uniforms
npx skills add https://github.com/earthtojake/text-to-cad --skill implicit-cadAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3.4k |
|---|---|
| repo stars | ★ 11k |
| Last updated | July 11, 2026 |
| Repository | earthtojake/text-to-cad ↗ |
How do I create and verify an implicit CAD model that runs in CAD Viewer?
Create and verify browser-native implicit CAD .implicit.js models with GLSL SDFs, params, animations, and CAD Viewer snapshots.
Who is it for?
Users explicitly requesting implicit.js models, TPMS lattices, or procedural SDF geometry.
Skip if: Skip when conventional STEP-first CAD workflows suffice unless user asks for implicit models.
When should I use this skill?
User works with .implicit.js, GLSL SDF, implicit CAD, or CAD Viewer raymarch models.
What you get
Valid implicit module, verification snapshots, optional GLB export, and CAD Viewer links.
- .implicit.js CAD files
- Browser-rendered 3D snapshots
Files
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.
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.
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:
distanceFunctionemitsfloat sdf(vec3 p),colorFunctionemitsvec3 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:
node scripts/snapshot.mjs --input models/implicit-cad/<model>.implicit.js --output /tmp/implicit-review/<model>.pngFor 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:
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" }
]
}
JSONAdd 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:
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 }
}
JSONReview 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:
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 --helpUse 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:
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 --helpSupported 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.
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."
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.
#!/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;
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,
};
}
{
"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"
}
}
}
{
"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"
}
}
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
npm install implicitjsPeer 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.
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:
import {
loadImplicitModuleFromSource,
normalizeImplicitModel,
renderImplicitToDataUrl,
snapshotImplicitCadModel,
exportImplicitModel,
exportImplicitAnimatedGlb
} from "implicitjs";Useful subpath exports:
implicitjs/model: schema normalization and parameterized runtime models.implicitjs/loader: loading.implicit.jsmodules 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
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:
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 orbitThe 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.
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" }
]
}
JSONEach output path receives a UTC timestamp before its extension.
Exports
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:
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
npm test
npm run verify:exports -- --input examples/model.implicit.jsKeep 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.
#!/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;
});
#!/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);
<!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>
#!/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;
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/);
});
#!/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);
});
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";
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");
});
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
);
}
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";
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 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 || 18), 60));
const durationSeconds = Math.max(0.1, Math.min(Number(orbit.durationSeconds || 4), 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}`,
}))
};
}
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;
}
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);
}
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;
}
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";
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");
});
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 })
});
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 implicitGraphicsSettingsEqual(a, b) {
const normalizedA = normalizeImplicitGraphicsSettings(a);
const normalizedB = normalizeImplicitGraphicsSettings(b);
return Object.keys(DEFAULT_IMPLICIT_GRAPHICS_SETTINGS)
.every((key) => normalizedA[key] === normalizedB[key]);
}
Related skills
How it compares
Pick implicit-cad for shader-based browser solids; use mesh CAD or CNC skills when you need STEP, STL, or machine shop outputs.
FAQ
When should I use implicit CAD versus STEP?
Only when the user explicitly wants implicit models; otherwise prefer conventional STEP-first CAD.
How are parameters declared?
In params with implicitjs control schema; they auto-become GLSL uniforms with matching names.
What verification is required?
Run snapshot.mjs after geometry or bounds changes; hand off paths to $cad-viewer when installed.