cadcode 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,331 @@
1
+ import * as THREE from 'three';
2
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
3
+
4
+ const DEFAULT_COLOR = '#f2c14e';
5
+ const EDGE_ANGLE = 25;
6
+
7
+ const canvas = document.getElementById('view');
8
+ const partSelect = document.getElementById('part');
9
+ const statusEl = document.getElementById('status');
10
+ const fileEl = document.getElementById('file');
11
+ const errorEl = document.getElementById('error');
12
+ const errorText = document.getElementById('error-text');
13
+ const logsEl = document.getElementById('logs');
14
+
15
+ // --- Scene -------------------------------------------------------------------
16
+
17
+ THREE.Object3D.DEFAULT_UP.set(0, 0, 1); // CAD convention: Z is up
18
+
19
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
20
+ renderer.setPixelRatio(window.devicePixelRatio);
21
+ renderer.setClearColor(0xeef0f3);
22
+
23
+ const scene = new THREE.Scene();
24
+ const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 10000);
25
+ camera.up.set(0, 0, 1);
26
+ camera.position.set(120, -160, 110);
27
+ scene.add(camera);
28
+
29
+ scene.add(new THREE.HemisphereLight(0xffffff, 0x8a8f99, 1.6));
30
+ const keyLight = new THREE.DirectionalLight(0xffffff, 1.5);
31
+ keyLight.position.set(-0.4, 0.8, 1); // relative to the camera, so shading follows the view
32
+ camera.add(keyLight);
33
+
34
+ const controls = new OrbitControls(camera, canvas);
35
+ controls.enableDamping = true;
36
+ controls.dampingFactor = 0.15;
37
+ controls.screenSpacePanning = true; // shift+drag and right-drag pan in the view plane
38
+
39
+ const grid = new THREE.Group();
40
+ scene.add(grid);
41
+ const axes = new THREE.AxesHelper(10);
42
+ scene.add(axes);
43
+
44
+ let model = new THREE.Group();
45
+ scene.add(model);
46
+
47
+ function resize() {
48
+ const { clientWidth: w, clientHeight: h } = canvas;
49
+ renderer.setSize(w, h, false);
50
+ camera.aspect = w / h;
51
+ camera.updateProjectionMatrix();
52
+ }
53
+ window.addEventListener('resize', resize);
54
+ resize();
55
+
56
+ renderer.setAnimationLoop(() => {
57
+ controls.update();
58
+ renderer.render(scene, camera);
59
+ });
60
+
61
+ // --- Camera persistence ------------------------------------------------------
62
+ // The camera survives model reloads untouched; it is also stored per file so a
63
+ // browser refresh or a restarted server comes back to the same view.
64
+
65
+ let fileKey = null;
66
+
67
+ const storage = {
68
+ get(key) {
69
+ try {
70
+ return JSON.parse(localStorage.getItem(key));
71
+ } catch {
72
+ return null;
73
+ }
74
+ },
75
+ set(key, value) {
76
+ try {
77
+ localStorage.setItem(key, JSON.stringify(value));
78
+ } catch {
79
+ // Storage unavailable (private window): the view just isn't remembered.
80
+ }
81
+ },
82
+ };
83
+
84
+ function saveCamera() {
85
+ if (!fileKey) return;
86
+ storage.set(`cadcode:camera:${fileKey}`, {
87
+ position: camera.position.toArray(),
88
+ target: controls.target.toArray(),
89
+ zoom: camera.zoom,
90
+ });
91
+ }
92
+
93
+ function restoreCamera() {
94
+ const saved = storage.get(`cadcode:camera:${fileKey}`);
95
+ if (!saved) return false;
96
+ camera.position.fromArray(saved.position);
97
+ controls.target.fromArray(saved.target);
98
+ camera.zoom = saved.zoom ?? 1;
99
+ camera.updateProjectionMatrix();
100
+ controls.update();
101
+ return true;
102
+ }
103
+
104
+ let saveTimer = 0;
105
+ controls.addEventListener('change', () => {
106
+ clearTimeout(saveTimer);
107
+ saveTimer = setTimeout(saveCamera, 200);
108
+ });
109
+ window.addEventListener('beforeunload', saveCamera);
110
+
111
+ function modelSphere() {
112
+ return new THREE.Box3().setFromObject(model).getBoundingSphere(new THREE.Sphere());
113
+ }
114
+
115
+ function fitView() {
116
+ const sphere = modelSphere();
117
+ if (sphere.isEmpty()) return;
118
+ const distance = (sphere.radius / Math.sin(THREE.MathUtils.degToRad(camera.fov / 2))) * 1.15;
119
+ const direction = new THREE.Vector3(1, -1.3, 0.9).normalize();
120
+ controls.target.copy(sphere.center);
121
+ camera.position.copy(sphere.center).addScaledVector(direction, distance);
122
+ camera.zoom = 1;
123
+ camera.updateProjectionMatrix();
124
+ controls.update();
125
+ saveCamera();
126
+ }
127
+
128
+ document.getElementById('fit').addEventListener('click', fitView);
129
+ window.addEventListener('keydown', (event) => {
130
+ if (event.target instanceof HTMLSelectElement) return;
131
+ if (event.key === 'f' || event.key === 'F') fitView();
132
+ });
133
+
134
+ // --- Model -------------------------------------------------------------------
135
+
136
+ function disposeGroup(group) {
137
+ group.traverse((object) => {
138
+ object.geometry?.dispose();
139
+ object.material?.dispose();
140
+ });
141
+ }
142
+
143
+ function buildGrid(sphere) {
144
+ disposeGroup(grid);
145
+ grid.clear();
146
+ const step = 10 ** Math.floor(Math.log10(Math.max(sphere.radius, 1)));
147
+ const size = Math.ceil((sphere.radius * 3) / step) * step;
148
+ const helper = new THREE.GridHelper(size, size / step, 0xb9bec7, 0xd6d9df);
149
+ helper.rotation.x = Math.PI / 2;
150
+ helper.position.set(Math.round(sphere.center.x / step) * step, Math.round(sphere.center.y / step) * step, 0);
151
+ grid.add(helper);
152
+ axes.scale.setScalar(step / 10);
153
+ }
154
+
155
+ /** Decodes a binary model frame: u32 header length, JSON header, then f32 positions and u32 indices per group. */
156
+ function decodeModel(buffer) {
157
+ const headerLength = new DataView(buffer).getUint32(0, true);
158
+ const header = JSON.parse(new TextDecoder().decode(new Uint8Array(buffer, 4, headerLength)));
159
+ let offset = 4 + headerLength;
160
+ const groups = header.groups.map((g) => {
161
+ const positions = new Float32Array(buffer, offset, g.vertices * 3);
162
+ offset += positions.byteLength;
163
+ const indices = new Uint32Array(buffer, offset, g.indices);
164
+ offset += indices.byteLength;
165
+ return { ...g, positions, indices };
166
+ });
167
+ return { header, groups };
168
+ }
169
+
170
+ function showModel({ header, groups }) {
171
+ const next = new THREE.Group();
172
+ for (const g of groups) {
173
+ const geometry = new THREE.BufferGeometry();
174
+ geometry.setAttribute('position', new THREE.BufferAttribute(g.positions, 3));
175
+ geometry.setIndex(new THREE.BufferAttribute(g.indices, 1));
176
+ geometry.computeVertexNormals();
177
+
178
+ const [r, gr, b, a] = g.color ?? [...new THREE.Color(DEFAULT_COLOR).toArray(), 1];
179
+ const transparent = a < 0.999;
180
+ const material = new THREE.MeshStandardMaterial({
181
+ color: new THREE.Color(r, gr, b),
182
+ roughness: 0.65,
183
+ metalness: 0.05,
184
+ flatShading: true,
185
+ transparent,
186
+ opacity: a,
187
+ depthWrite: !transparent,
188
+ side: transparent ? THREE.DoubleSide : THREE.FrontSide,
189
+ polygonOffset: true,
190
+ polygonOffsetFactor: 1,
191
+ polygonOffsetUnits: 1,
192
+ });
193
+ const mesh = new THREE.Mesh(geometry, material);
194
+ mesh.renderOrder = transparent ? 1 : 0;
195
+ next.add(mesh);
196
+
197
+ const edges = new THREE.LineSegments(
198
+ new THREE.EdgesGeometry(geometry, EDGE_ANGLE),
199
+ new THREE.LineBasicMaterial({ color: 0x2a2f36, transparent: true, opacity: transparent ? 0.25 : 0.55 }),
200
+ );
201
+ next.add(edges);
202
+ }
203
+
204
+ scene.remove(model);
205
+ disposeGroup(model);
206
+ model = next;
207
+ scene.add(model);
208
+
209
+ const sphere = modelSphere();
210
+ if (!sphere.isEmpty()) {
211
+ camera.near = Math.max(sphere.radius / 1000, 0.01);
212
+ camera.far = sphere.radius * 200 + camera.position.distanceTo(sphere.center);
213
+ camera.updateProjectionMatrix();
214
+ buildGrid(sphere);
215
+ }
216
+
217
+ const triangles = groups.reduce((n, g) => n + g.indices.length / 3, 0);
218
+ setStatus('ready', `${header.part} · ${triangles.toLocaleString()} tris · ${header.ms} ms`);
219
+ }
220
+
221
+ // --- UI state ----------------------------------------------------------------
222
+
223
+ function setStatus(state, text = state) {
224
+ statusEl.dataset.state = state;
225
+ statusEl.textContent = text;
226
+ }
227
+
228
+ function showError(message) {
229
+ errorText.textContent = message;
230
+ errorEl.hidden = !message;
231
+ }
232
+
233
+ function showLogs(logs = []) {
234
+ logsEl.replaceChildren(
235
+ ...logs.map(({ level, text }) => {
236
+ const line = document.createElement('div');
237
+ line.className = `log-${level}`;
238
+ line.textContent = text;
239
+ return line;
240
+ }),
241
+ );
242
+ logsEl.hidden = logs.length === 0;
243
+ }
244
+
245
+ function showParts(names, current) {
246
+ partSelect.replaceChildren(...names.map((name) => new Option(name, name, false, name === current)));
247
+ }
248
+
249
+ // --- Download ----------------------------------------------------------------
250
+ // Fetched rather than navigated to, so a failing program shows its error here
251
+ // instead of replacing the viewer.
252
+
253
+ const downloadButton = document.getElementById('download');
254
+
255
+ downloadButton.addEventListener('click', async () => {
256
+ downloadButton.disabled = true;
257
+ try {
258
+ const response = await fetch(`/stl?part=${encodeURIComponent(partSelect.value)}`);
259
+ if (!response.ok) throw new Error(await response.text());
260
+ const link = document.createElement('a');
261
+ link.href = URL.createObjectURL(await response.blob());
262
+ link.download = response.headers.get('Content-Disposition').match(/filename="(.+)"/)[1];
263
+ link.click();
264
+ setTimeout(() => URL.revokeObjectURL(link.href)); // after the browser has started the download
265
+ } catch (error) {
266
+ showError(`STL download failed: ${error.message}`);
267
+ } finally {
268
+ downloadButton.disabled = false;
269
+ }
270
+ });
271
+
272
+ // --- Connection --------------------------------------------------------------
273
+
274
+ let socket = null;
275
+ let firstModel = true;
276
+
277
+ function requestPart(name) {
278
+ if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'part', name }));
279
+ }
280
+
281
+ partSelect.addEventListener('change', () => {
282
+ storage.set(`cadcode:part:${fileKey}`, partSelect.value);
283
+ requestPart(partSelect.value);
284
+ });
285
+
286
+ function connect() {
287
+ socket = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/`);
288
+ socket.binaryType = 'arraybuffer';
289
+
290
+ socket.addEventListener('message', (event) => {
291
+ if (typeof event.data === 'string') {
292
+ const message = JSON.parse(event.data);
293
+ if (message.type === 'status') setStatus(message.status);
294
+ if (message.type === 'error') {
295
+ setStatus('error');
296
+ showError(message.stack && message.stack.includes(message.message) ? message.stack : message.message);
297
+ showLogs(message.logs);
298
+ }
299
+ return;
300
+ }
301
+
302
+ const frame = decodeModel(event.data);
303
+ const { header } = frame;
304
+ if (fileKey !== header.file) {
305
+ fileKey = header.file;
306
+ const slash = header.file.lastIndexOf('/') + 1;
307
+ fileEl.querySelector('.dir').textContent = header.file.slice(0, slash);
308
+ fileEl.querySelector('.name').textContent = header.file.slice(slash);
309
+ fileEl.title = header.file;
310
+ document.title = `${header.file} · cadcode`;
311
+ }
312
+ showParts(header.partNames, header.part);
313
+ showError('');
314
+ showLogs(header.logs);
315
+ showModel(frame);
316
+
317
+ if (firstModel) {
318
+ firstModel = false;
319
+ if (!restoreCamera()) fitView();
320
+ const preferred = storage.get(`cadcode:part:${fileKey}`);
321
+ if (preferred && preferred !== header.part && header.partNames.includes(preferred)) requestPart(preferred);
322
+ }
323
+ });
324
+
325
+ socket.addEventListener('close', () => {
326
+ setStatus('disconnected');
327
+ setTimeout(connect, 1000);
328
+ });
329
+ }
330
+
331
+ connect();
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "cadcode",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "scripts": {
5
+ "test": "node --test \"test/*.test.js\""
6
+ },
7
+ "keywords": [
8
+ "cad",
9
+ "csg",
10
+ "3d-printing",
11
+ "stl",
12
+ "manifold"
13
+ ],
14
+ "license": "MIT",
15
+ "description": "Code-first CAD: write models in JavaScript, preview live in the browser, export STL and PNG.",
16
+ "dependencies": {
17
+ "esbuild": "^0.28.2",
18
+ "manifold-3d": "^3.5.4",
19
+ "three": "^0.187.0",
20
+ "ws": "^8.22.1"
21
+ },
22
+ "type": "module",
23
+ "bin": {
24
+ "cadcode": "src/cli.js"
25
+ },
26
+ "exports": {
27
+ ".": "./src/lib/index.js"
28
+ },
29
+ "files": [
30
+ "src",
31
+ "client"
32
+ ],
33
+ "engines": {
34
+ "node": ">=20"
35
+ }
36
+ }
package/src/cli.js ADDED
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { parseArgs } from 'node:util';
5
+ import { bundleProgram, evaluateProgram, parseOverrides } from './core/program.js';
6
+
7
+ process.setSourceMapsEnabled(true);
8
+
9
+ const USAGE = `cadcode — code-first CAD
10
+
11
+ Usage:
12
+ cadcode dev <file> Live preview in the browser; reloads on save
13
+ cadcode stl <file> Export a part to binary STL
14
+ cadcode png <file> Render a part to a PNG preview
15
+ cadcode parts <file> List the parts a program defines
16
+
17
+ Options:
18
+ -p, --part <name> Part to use (default: the first one)
19
+ -a, --all stl/png: export every part
20
+ -s, --set <k=v> Override a parameter (repeatable); values are JSON or strings
21
+ -o, --out <path> Output file, or directory with --all
22
+ --view <name> png: iso, iso-back, front, back, left, right, top, bottom, sheet (default: iso)
23
+ --size <WxH> png: image size (default: 800x600)
24
+ --port <n> dev: port (default: 5173)
25
+ --host <addr> dev: address to bind (default: 127.0.0.1)
26
+ --open dev: open the browser
27
+ `;
28
+
29
+ const { values, positionals } = parseArgs({
30
+ allowPositionals: true,
31
+ options: {
32
+ part: { type: 'string', short: 'p' },
33
+ all: { type: 'boolean', short: 'a' },
34
+ set: { type: 'string', short: 's', multiple: true },
35
+ out: { type: 'string', short: 'o' },
36
+ view: { type: 'string', default: 'iso' },
37
+ size: { type: 'string', default: '800x600' },
38
+ port: { type: 'string', default: '5173' },
39
+ host: { type: 'string', default: '127.0.0.1' },
40
+ open: { type: 'boolean' },
41
+ help: { type: 'boolean', short: 'h' },
42
+ },
43
+ });
44
+
45
+ const [command, file] = positionals;
46
+
47
+ async function main() {
48
+ if (values.help || !command) return void process.stdout.write(USAGE);
49
+ if (!file) throw new Error(`Missing <file>. Try: cadcode ${command} model.js`);
50
+ if (!fs.existsSync(file)) throw new Error(`No such file: ${file}`);
51
+ const overrides = parseOverrides(values.set);
52
+
53
+ if (command === 'dev') {
54
+ const { startDevServer } = await import('./dev/server.js');
55
+ return startDevServer({ file, port: Number(values.port), host: values.host, part: values.part, overrides, open: values.open });
56
+ }
57
+
58
+ const bundle = await bundleProgram(file);
59
+ const first = await evaluateProgram(bundle, { part: values.part, overrides });
60
+
61
+ if (command === 'parts') return void console.log(first.partNames.join('\n'));
62
+ if (command !== 'stl' && command !== 'png') throw new Error(`Unknown command "${command}"\n\n${USAGE}`);
63
+
64
+ const ext = command;
65
+ const base = path.basename(file).replace(/\.[^.]+$/, '');
66
+ const parts = values.all ? first.partNames : [first.part];
67
+ for (const part of parts) {
68
+ const result = part === first.part ? first : await evaluateProgram(bundle, { part, overrides });
69
+ const name = parts.length > 1 || first.partNames.length > 1 ? `${base}-${part}.${ext}` : `${base}.${ext}`;
70
+ const out = values.all ? path.join(values.out ?? '.', name) : (values.out ?? name);
71
+ fs.mkdirSync(path.dirname(out), { recursive: true });
72
+
73
+ const started = performance.now();
74
+ if (ext === 'stl') {
75
+ const { toSTL } = await import('./export/stl.js');
76
+ fs.writeFileSync(out, toSTL(result.solids));
77
+ } else {
78
+ const { toPNG } = await import('./export/png.js');
79
+ const [width, height] = values.size.split('x').map(Number);
80
+ if (!width || !height) throw new Error(`--size must look like 800x600, got "${values.size}"`);
81
+ fs.writeFileSync(out, toPNG(result.solids, { width, height, view: values.view }));
82
+ }
83
+ console.log(`${part} → ${out} (${Math.round(performance.now() - started)} ms)`);
84
+ }
85
+ }
86
+
87
+ const OWN_SOURCES = path.dirname(new URL(import.meta.url).pathname);
88
+
89
+ main().catch((error) => {
90
+ console.error(`cadcode: ${error.message}`);
91
+ // Point at the user's code: the first stack frame outside cadcode itself.
92
+ const where = error.stack?.split('\n').find((line) => line.trim().startsWith('at ') && !line.includes(OWN_SOURCES));
93
+ if (where) console.error(` ${where.trim()}`);
94
+ if (process.env.CADCODE_DEBUG) console.error(error.stack);
95
+ process.exit(1);
96
+ });
@@ -0,0 +1,69 @@
1
+ import { colorOfId, union } from '../lib/shapes.js';
2
+
3
+ /**
4
+ * @typedef {object} MeshGroup
5
+ * @property {number} body index of the solid this group came from
6
+ * @property {[number, number, number, number] | null} color RGBA in 0..1, null when uncolored
7
+ * @property {Float32Array} positions xyz per vertex
8
+ * @property {Uint32Array} indices three per triangle
9
+ */
10
+
11
+ /**
12
+ * Splits each solid's mesh into groups of one color each, for rendering.
13
+ * @param {import('../lib/shapes.js').Solid[]} solids
14
+ * @returns {MeshGroup[]}
15
+ */
16
+ export function meshGroups(solids) {
17
+ const groups = [];
18
+ solids.forEach((solid, body) => {
19
+ const mesh = solid.manifold.getMesh();
20
+ const { numProp, vertProperties, triVerts, runIndex, runOriginalID } = mesh;
21
+
22
+ /** @type {Map<string, { color: any, tris: number[] }>} */
23
+ const byColor = new Map();
24
+ for (let run = 0; run < runOriginalID.length; run++) {
25
+ const color = colorOfId(runOriginalID[run]) ?? null;
26
+ const key = color ? color.join(',') : '';
27
+ if (!byColor.has(key)) byColor.set(key, { color, tris: [] });
28
+ const tris = byColor.get(key).tris;
29
+ for (let i = runIndex[run]; i < runIndex[run + 1]; i++) tris.push(triVerts[i]);
30
+ }
31
+
32
+ for (const { color, tris } of byColor.values()) {
33
+ const remap = new Map();
34
+ const positions = [];
35
+ const indices = new Uint32Array(tris.length);
36
+ tris.forEach((v, i) => {
37
+ let n = remap.get(v);
38
+ if (n === undefined) {
39
+ n = remap.size;
40
+ remap.set(v, n);
41
+ positions.push(vertProperties[v * numProp], vertProperties[v * numProp + 1], vertProperties[v * numProp + 2]);
42
+ }
43
+ indices[i] = n;
44
+ });
45
+ groups.push({ body, color, positions: new Float32Array(positions), indices });
46
+ }
47
+ });
48
+ return groups;
49
+ }
50
+
51
+ /** Bounding box over all groups. */
52
+ export function groupBounds(groups) {
53
+ const min = [Infinity, Infinity, Infinity];
54
+ const max = [-Infinity, -Infinity, -Infinity];
55
+ for (const { positions } of groups) {
56
+ for (let i = 0; i < positions.length; i += 3) {
57
+ for (let k = 0; k < 3; k++) {
58
+ min[k] = Math.min(min[k], positions[i + k]);
59
+ max[k] = Math.max(max[k], positions[i + k]);
60
+ }
61
+ }
62
+ }
63
+ return { min, max };
64
+ }
65
+
66
+ /** The solids of a part merged into one, as written to STL. */
67
+ export function mergedSolid(solids) {
68
+ return union(solids);
69
+ }
@@ -0,0 +1,118 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
5
+ import * as esbuild from 'esbuild';
6
+ import { Solid, Shape2 } from '../lib/index.js';
7
+
8
+ const LIB_URL = pathToFileURL(fileURLToPath(new URL('../lib/index.js', import.meta.url))).href;
9
+
10
+ // User programs import 'cadcode'. It must resolve to this very copy of the
11
+ // library: colors are tracked in module state, and the program's solids are
12
+ // read back by our exporters.
13
+ const cadcodePlugin = {
14
+ name: 'cadcode',
15
+ setup(build) {
16
+ build.onResolve({ filter: /^cadcode$/ }, () => ({ path: LIB_URL, external: true }));
17
+ },
18
+ };
19
+
20
+ /**
21
+ * esbuild options that bundle a user program (and its local imports) into one ESM file.
22
+ * Bundling lets programs import their own helper files and npm packages, and
23
+ * gives the dev server the list of files to watch.
24
+ */
25
+ export function bundleOptions(entry) {
26
+ // Real paths: on macOS the temp dir is behind a symlink, and source maps would
27
+ // otherwise resolve the program's path against the wrong base.
28
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cadcode-')));
29
+ return {
30
+ entryPoints: [fs.realpathSync(entry)],
31
+ outfile: path.join(dir, 'program.mjs'),
32
+ bundle: true,
33
+ format: 'esm',
34
+ platform: 'node',
35
+ sourcemap: 'inline',
36
+ metafile: true,
37
+ logLevel: 'silent',
38
+ plugins: [cadcodePlugin],
39
+ };
40
+ }
41
+
42
+ /** Bundles a program once and returns the path of the bundle. */
43
+ export async function bundleProgram(entry) {
44
+ const options = bundleOptions(entry);
45
+ await esbuild.build(options);
46
+ return options.outfile;
47
+ }
48
+
49
+ function flattenModel(model, partName) {
50
+ const solids = [];
51
+ const walk = (item) => {
52
+ if (Array.isArray(item)) item.forEach(walk);
53
+ else if (item instanceof Solid) solids.push(item);
54
+ else if (item instanceof Shape2) throw new Error(`Part "${partName}" contains a 2D shape; extrude it to make a solid`);
55
+ else if (item) throw new Error(`Part "${partName}" contains ${typeof item} ${String(item)}, not a solid`);
56
+ };
57
+ walk(model);
58
+ return solids;
59
+ }
60
+
61
+ const isModel = (value) => value instanceof Solid || value instanceof Shape2 || Array.isArray(value);
62
+
63
+ /**
64
+ * Loads a bundled program and evaluates one part.
65
+ *
66
+ * A program `export default`s either a model (a solid or an array of solids), or an
67
+ * object of named parts whose values are models or functions returning models, or a
68
+ * function of `params` returning either. `export const params = {...}` declares the
69
+ * parameters that `overrides` may change.
70
+ *
71
+ * @param {string} bundlePath
72
+ * `fallbackToFirst` shows the first part instead of failing when `part` no longer
73
+ * exists — the dev server uses it so renaming a part doesn't blank the preview.
74
+ *
75
+ * @param {string} bundlePath
76
+ * @param {{ part?: string, overrides?: Record<string, unknown>, fallbackToFirst?: boolean }} [options]
77
+ */
78
+ export async function evaluateProgram(bundlePath, { part, overrides = {}, fallbackToFirst = false } = {}) {
79
+ const mod = await import(`${pathToFileURL(bundlePath).href}?t=${Date.now()}`);
80
+ if (mod.default === undefined) throw new Error('The program has no `export default`');
81
+
82
+ const declared = mod.params ?? {};
83
+ for (const key of Object.keys(overrides)) {
84
+ if (!(key in declared)) throw new Error(`Unknown parameter "${key}". Declared: ${Object.keys(declared).join(', ') || 'none'}`);
85
+ }
86
+ const params = { ...declared, ...overrides };
87
+
88
+ const value = typeof mod.default === 'function' ? await mod.default(params) : mod.default;
89
+ const parts = isModel(value) ? { default: value } : value;
90
+ if (!parts || typeof parts !== 'object') throw new Error('The default export must be a model, an object of parts, or a function returning one');
91
+
92
+ const partNames = Object.keys(parts);
93
+ if (partNames.length === 0) throw new Error('The program defines no parts');
94
+ const name = part && partNames.includes(part) ? part : partNames[0];
95
+ if (part && part !== name && !fallbackToFirst) throw new Error(`No part "${part}". Parts: ${partNames.join(', ')}`);
96
+
97
+ const entry = parts[name];
98
+ const model = typeof entry === 'function' ? await entry(params) : entry;
99
+ return { partNames, part: name, params, solids: flattenModel(model, name) };
100
+ }
101
+
102
+ /** Parses `key=value` overrides; values are JSON when they parse, strings otherwise. */
103
+ export function parseOverrides(pairs = []) {
104
+ const out = {};
105
+ for (const pair of pairs) {
106
+ const eq = pair.indexOf('=');
107
+ if (eq < 1) throw new Error(`Expected key=value, got "${pair}"`);
108
+ const raw = pair.slice(eq + 1);
109
+ let value;
110
+ try {
111
+ value = JSON.parse(raw);
112
+ } catch {
113
+ value = raw;
114
+ }
115
+ out[pair.slice(0, eq)] = value;
116
+ }
117
+ return out;
118
+ }