cadcode 0.1.0 → 0.3.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.
package/client/viewer.js CHANGED
@@ -1,61 +1,26 @@
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;
1
+ import { bindDisplayButtons, createScene, saveBlob } from './scene.js';
6
2
 
7
3
  const canvas = document.getElementById('view');
8
- const partSelect = document.getElementById('part');
4
+ const stage = document.getElementById('stage');
9
5
  const statusEl = document.getElementById('status');
10
6
  const fileEl = document.getElementById('file');
7
+ const emptyEl = document.getElementById('empty');
11
8
  const errorEl = document.getElementById('error');
12
9
  const errorText = document.getElementById('error-text');
13
10
  const logsEl = document.getElementById('logs');
11
+ const readout = {
12
+ size: document.getElementById('readout-size'),
13
+ mesh: document.getElementById('readout-mesh'),
14
+ time: document.getElementById('readout-time'),
15
+ };
14
16
 
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);
17
+ // The dev server bundles the render worker, three.js included, at /render-worker.js.
18
+ // The canvas is transparent: the stage's backdrop in viewer.css paints behind it,
19
+ // in the playground's colours.
20
+ const view = createScene(canvas, {
21
+ workerUrl: '/render-worker.js',
22
+ colors: { background: null, gridMajor: 0xc9ced5, gridMinor: 0xdfe2e6, edges: 0x1f2833 },
23
+ onCamera: cameraMoved,
59
24
  });
60
25
 
61
26
  // --- Camera persistence ------------------------------------------------------
@@ -63,6 +28,7 @@ renderer.setAnimationLoop(() => {
63
28
  // browser refresh or a restarted server comes back to the same view.
64
29
 
65
30
  let fileKey = null;
31
+ let camera = null; // the latest from the view: { position, target, zoom }
66
32
 
67
33
  const storage = {
68
34
  get(key) {
@@ -82,76 +48,39 @@ const storage = {
82
48
  };
83
49
 
84
50
  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
- });
51
+ if (!fileKey || !camera) return;
52
+ storage.set(`cadcode:camera:${fileKey}`, camera);
91
53
  }
92
54
 
93
55
  function restoreCamera() {
94
56
  const saved = storage.get(`cadcode:camera:${fileKey}`);
95
57
  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();
58
+ view.setCamera(saved);
101
59
  return true;
102
60
  }
103
61
 
104
62
  let saveTimer = 0;
105
- controls.addEventListener('change', () => {
63
+ function cameraMoved(moved) {
64
+ camera = moved;
106
65
  clearTimeout(saveTimer);
107
66
  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
67
  }
68
+ window.addEventListener('beforeunload', saveCamera);
114
69
 
70
+ // The fitted camera arrives as a move and is saved from there.
115
71
  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();
72
+ view.fitView();
126
73
  }
127
74
 
128
75
  document.getElementById('fit').addEventListener('click', fitView);
76
+ bindDisplayButtons(view, [...document.querySelectorAll('[data-display]')]);
129
77
  window.addEventListener('keydown', (event) => {
130
- if (event.target instanceof HTMLSelectElement) return;
78
+ if (event.target.closest?.('#part-menu')) return;
131
79
  if (event.key === 'f' || event.key === 'F') fitView();
132
80
  });
133
81
 
134
82
  // --- Model -------------------------------------------------------------------
135
83
 
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
84
  /** Decodes a binary model frame: u32 header length, JSON header, then f32 positions and u32 indices per group. */
156
85
  function decodeModel(buffer) {
157
86
  const headerLength = new DataView(buffer).getUint32(0, true);
@@ -167,62 +96,40 @@ function decodeModel(buffer) {
167
96
  return { header, groups };
168
97
  }
169
98
 
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
- }
99
+ let shown = null; // header of the model on screen
216
100
 
217
- const triangles = groups.reduce((n, g) => n + g.indices.length / 3, 0);
218
- setStatus('ready', `${header.part} · ${triangles.toLocaleString()} tris · ${header.ms} ms`);
101
+ async function showModel({ header, groups }) {
102
+ shown = header;
103
+ const mesh = await view.showModel(groups);
104
+ if (shown !== header) return; // a newer frame arrived meanwhile
105
+ delete stage.dataset.empty;
106
+ showReadout(mesh, header.ms);
107
+ setStatus('ready');
219
108
  }
220
109
 
221
110
  // --- UI state ----------------------------------------------------------------
222
111
 
223
- function setStatus(state, text = state) {
112
+ const STATUS_TEXT = {
113
+ connecting: 'Connecting',
114
+ building: 'Building',
115
+ evaluating: 'Running',
116
+ ready: 'Ready',
117
+ error: 'Error',
118
+ disconnected: 'Server stopped, reconnecting',
119
+ };
120
+
121
+ /** Shows the state by the status dot; while the program rebuilds the stage shows it too (see "Busy" in viewer.css). */
122
+ function setStatus(state) {
224
123
  statusEl.dataset.state = state;
225
- statusEl.textContent = text;
124
+ statusEl.textContent = STATUS_TEXT[state] ?? state;
125
+ toggleFlag('busy', state === 'building' || state === 'evaluating' || state === 'connecting');
126
+ toggleFlag('offline', state === 'disconnected');
127
+ if (stage.dataset.empty !== undefined) emptyEl.textContent = state === 'disconnected' ? 'Waiting for the dev server…' : 'Building the model…';
128
+ }
129
+
130
+ function toggleFlag(name, on) {
131
+ if (on) stage.dataset[name] = '';
132
+ else delete stage.dataset[name];
226
133
  }
227
134
 
228
135
  function showError(message) {
@@ -242,10 +149,98 @@ function showLogs(logs = []) {
242
149
  logsEl.hidden = logs.length === 0;
243
150
  }
244
151
 
152
+ const formatMm = (n) => (Math.round(n * 10) / 10).toLocaleString();
153
+
154
+ /** Fills the readout: the model's overall size, large, then its mesh and how long the program took. */
155
+ function showReadout({ triangles, size }, ms) {
156
+ readout.size.replaceChildren(size ? size.map(formatMm).join(' × ') : 'Empty');
157
+ if (size) readout.size.append(Object.assign(document.createElement('small'), { textContent: 'mm' }));
158
+ readout.mesh.textContent = `${triangles.toLocaleString()} triangles`;
159
+ readout.time.textContent = `${ms.toLocaleString()} ms`;
160
+ }
161
+
162
+ // --- Parts -------------------------------------------------------------------
163
+ // A pill naming the part on screen; it opens a menu of the program's parts. A
164
+ // program with one part has no pill.
165
+
166
+ const partOpen = document.getElementById('part-open');
167
+ const partMenu = document.getElementById('part-menu');
168
+ let currentPart = '';
169
+
245
170
  function showParts(names, current) {
246
- partSelect.replaceChildren(...names.map((name) => new Option(name, name, false, name === current)));
171
+ currentPart = current;
172
+ document.getElementById('part-title').textContent = current;
173
+ partMenu.replaceChildren(
174
+ ...names.map((name) => {
175
+ const item = Object.assign(document.createElement('button'), { type: 'button', textContent: name });
176
+ item.setAttribute('role', 'menuitemradio');
177
+ item.setAttribute('aria-checked', String(name === current));
178
+ item.addEventListener('click', () => choosePart(name));
179
+ return item;
180
+ }),
181
+ );
182
+ partOpen.hidden = names.length < 2;
183
+ }
184
+
185
+ function choosePart(name) {
186
+ partMenu.hidePopover();
187
+ if (name === currentPart) return;
188
+ currentPart = name;
189
+ storage.set(`cadcode:part:${fileKey}`, name);
190
+ requestPart(name);
247
191
  }
248
192
 
193
+ partMenu.addEventListener('toggle', (event) => {
194
+ partOpen.setAttribute('aria-expanded', String(event.newState === 'open'));
195
+ if (event.newState !== 'open') return;
196
+ const box = partOpen.getBoundingClientRect();
197
+ partMenu.style.left = `${box.left}px`;
198
+ partMenu.style.top = `${box.bottom + 8}px`;
199
+ const items = [...partMenu.children];
200
+ (items.find((item) => item.getAttribute('aria-checked') === 'true') ?? items[0])?.focus();
201
+ });
202
+
203
+ // Arrows move through the menu; Escape closes it (the popover's own).
204
+ partMenu.addEventListener('keydown', (event) => {
205
+ const step = { ArrowDown: 1, ArrowUp: -1 }[event.key];
206
+ if (!step) return;
207
+ event.preventDefault();
208
+ const items = [...partMenu.children];
209
+ items[(items.indexOf(document.activeElement) + step + items.length) % items.length]?.focus();
210
+ });
211
+
212
+ // --- Download ----------------------------------------------------------------
213
+ // Fetched rather than navigated to, so a failing program shows its error here
214
+ // instead of replacing the viewer.
215
+
216
+ const downloadButton = document.getElementById('download');
217
+
218
+ downloadButton.addEventListener('click', async () => {
219
+ downloadButton.disabled = true;
220
+ try {
221
+ const response = await fetch(`/stl?part=${encodeURIComponent(currentPart)}`);
222
+ if (!response.ok) throw new Error(await response.text());
223
+ saveBlob(await response.blob(), response.headers.get('Content-Disposition').match(/filename="(.+)"/)[1]);
224
+ } catch (error) {
225
+ showError(`STL download failed: ${error.message}`);
226
+ } finally {
227
+ downloadButton.disabled = false;
228
+ }
229
+ });
230
+
231
+ // The picture is the view on screen, so it needs no server round trip.
232
+ // Named like the STL: the program's name, plus the part when there are several.
233
+ document.getElementById('png').addEventListener('click', async () => {
234
+ if (!shown) return;
235
+ const base = shown.file.slice(shown.file.lastIndexOf('/') + 1).replace(/\.[^.]+$/, '');
236
+ const name = shown.partNames.length > 1 ? `${base}-${shown.part}.png` : `${base}.png`;
237
+ try {
238
+ saveBlob(await view.snapshotPNG(), name);
239
+ } catch (error) {
240
+ showError(`PNG export failed: ${error.message}`);
241
+ }
242
+ });
243
+
249
244
  // --- Connection --------------------------------------------------------------
250
245
 
251
246
  let socket = null;
@@ -255,11 +250,6 @@ function requestPart(name) {
255
250
  if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'part', name }));
256
251
  }
257
252
 
258
- partSelect.addEventListener('change', () => {
259
- storage.set(`cadcode:part:${fileKey}`, partSelect.value);
260
- requestPart(partSelect.value);
261
- });
262
-
263
253
  function connect() {
264
254
  socket = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/`);
265
255
  socket.binaryType = 'arraybuffer';
@@ -284,6 +274,7 @@ function connect() {
284
274
  fileEl.querySelector('.dir').textContent = header.file.slice(0, slash);
285
275
  fileEl.querySelector('.name').textContent = header.file.slice(slash);
286
276
  fileEl.title = header.file;
277
+ fileEl.hidden = false;
287
278
  document.title = `${header.file} · cadcode`;
288
279
  }
289
280
  showParts(header.partNames, header.part);
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "cadcode",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "scripts": {
5
- "test": "node --test \"test/*.test.js\""
5
+ "test": "node --test \"test/*.test.js\"",
6
+ "site": "node site/build.js"
6
7
  },
7
8
  "keywords": [
8
9
  "cad",
@@ -32,5 +33,10 @@
32
33
  ],
33
34
  "engines": {
34
35
  "node": ">=20"
35
- }
36
+ },
37
+ "devDependencies": {
38
+ "monaco-editor": "0.57.0",
39
+ "typescript": "6.0.3"
40
+ },
41
+ "homepage": "https://jwo1f.github.io/cadcode/"
36
42
  }
@@ -0,0 +1,59 @@
1
+ // Turns a loaded program module into the solids of one part. Free of Node APIs:
2
+ // the CLI and dev server reach it through program.js, the site's playground directly.
3
+ import { Solid, Shape2 } from '../lib/index.js';
4
+
5
+ function flattenModel(model, partName) {
6
+ const solids = [];
7
+ const walk = (item) => {
8
+ if (Array.isArray(item)) item.forEach(walk);
9
+ else if (item instanceof Solid) solids.push(item);
10
+ else if (item instanceof Shape2) throw new Error(`Part "${partName}" contains a 2D shape; extrude it to make a solid`);
11
+ else if (item) throw new Error(`Part "${partName}" contains ${typeof item} ${String(item)}, not a solid`);
12
+ };
13
+ walk(model);
14
+ return solids;
15
+ }
16
+
17
+ const isModel = (value) => value instanceof Solid || value instanceof Shape2 || Array.isArray(value);
18
+
19
+ /**
20
+ * Evaluates one part of a program module.
21
+ *
22
+ * A program `export default`s either a model (a solid or an array of solids), or an
23
+ * object of named parts whose values are models or functions returning models, or a
24
+ * function of `params` returning either. `export const params = {...}` declares the
25
+ * parameters that `overrides` may change.
26
+ *
27
+ * `fallbackToFirst` shows the first part instead of failing when `part` no longer
28
+ * exists — the dev server uses it so renaming a part doesn't blank the preview.
29
+ * `ignoreUnknown` skips overrides of parameters the program no longer declares,
30
+ * instead of failing — the dev server keeps the browser's values across edits.
31
+ *
32
+ * @param {{ default?: unknown, params?: Record<string, unknown> }} mod
33
+ * @param {{ part?: string, overrides?: Record<string, unknown>, fallbackToFirst?: boolean, ignoreUnknown?: boolean }} [options]
34
+ * @returns the part's solids, the part names, the params it ran with and the declared ones (`declared`)
35
+ */
36
+ export async function evaluateModule(mod, { part, overrides = {}, fallbackToFirst = false, ignoreUnknown = false } = {}) {
37
+ if (mod.default === undefined) throw new Error('The program has no `export default`');
38
+
39
+ const declared = mod.params ?? {};
40
+ const known = {};
41
+ for (const [key, value] of Object.entries(overrides)) {
42
+ if (key in declared) known[key] = value;
43
+ else if (!ignoreUnknown) throw new Error(`Unknown parameter "${key}". Declared: ${Object.keys(declared).join(', ') || 'none'}`);
44
+ }
45
+ const params = { ...declared, ...known };
46
+
47
+ const value = typeof mod.default === 'function' ? await mod.default(params) : mod.default;
48
+ const parts = isModel(value) ? { default: value } : value;
49
+ if (!parts || typeof parts !== 'object') throw new Error('The default export must be a model, an object of parts, or a function returning one');
50
+
51
+ const partNames = Object.keys(parts);
52
+ if (partNames.length === 0) throw new Error('The program defines no parts');
53
+ const name = part && partNames.includes(part) ? part : partNames[0];
54
+ if (part && part !== name && !fallbackToFirst) throw new Error(`No part "${part}". Parts: ${partNames.join(', ')}`);
55
+
56
+ const entry = parts[name];
57
+ const model = typeof entry === 'function' ? await entry(params) : entry;
58
+ return { partNames, part: name, params, declared, solids: flattenModel(model, name) };
59
+ }
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import fs from 'node:fs';
4
4
  import { fileURLToPath, pathToFileURL } from 'node:url';
5
5
  import * as esbuild from 'esbuild';
6
- import { Solid, Shape2 } from '../lib/index.js';
6
+ import { evaluateModule } from './evaluate.js';
7
7
 
8
8
  const LIB_URL = pathToFileURL(fileURLToPath(new URL('../lib/index.js', import.meta.url))).href;
9
9
 
@@ -46,57 +46,17 @@ export async function bundleProgram(entry) {
46
46
  return options.outfile;
47
47
  }
48
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
49
  /**
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.
50
+ * Loads a bundled program and evaluates one part; see evaluateModule.
70
51
  *
71
- * @param {string} bundlePath
72
52
  * `fallbackToFirst` shows the first part instead of failing when `part` no longer
73
53
  * exists — the dev server uses it so renaming a part doesn't blank the preview.
74
54
  *
75
55
  * @param {string} bundlePath
76
56
  * @param {{ part?: string, overrides?: Record<string, unknown>, fallbackToFirst?: boolean }} [options]
77
57
  */
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) };
58
+ export async function evaluateProgram(bundlePath, options) {
59
+ return evaluateModule(await import(`${pathToFileURL(bundlePath).href}?t=${Date.now()}`), options);
100
60
  }
101
61
 
102
62
  /** Parses `key=value` overrides; values are JSON when they parse, strings otherwise. */
package/src/dev/server.js CHANGED
@@ -9,10 +9,9 @@ import { WebSocketServer } from 'ws';
9
9
  import { bundleOptions } from '../core/program.js';
10
10
 
11
11
  const CLIENT_DIR = fileURLToPath(new URL('../../client/', import.meta.url));
12
- const THREE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.resolve('three'))), '..');
13
12
  const WORKER_URL = new URL('./worker.js', import.meta.url);
14
13
 
15
- const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json' };
14
+ const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml' };
16
15
 
17
16
  /** Serves a file from `root`, refusing paths that escape it. */
18
17
  function serveFile(res, root, relative) {
@@ -63,6 +62,7 @@ function openBrowser(url) {
63
62
  export async function startDevServer({ file, port, host, part, overrides, open }) {
64
63
  const relative = path.relative(process.cwd(), path.resolve(file));
65
64
  const displayName = relative.startsWith('..') ? path.resolve(file) : relative;
65
+ const baseName = path.basename(file).replace(/\.[^.]+$/, '');
66
66
  // lastError is replayed to browsers that connect while the program is broken.
67
67
  const state = { part, lastFrame: null, lastError: null, worker: null, bundle: null };
68
68
  const sockets = new Set();
@@ -102,6 +102,28 @@ export async function startDevServer({ file, port, host, part, overrides, open }
102
102
  });
103
103
  }
104
104
 
105
+ // A download evaluates in its own worker, so it never cancels or waits for the preview.
106
+ function downloadSTL(res, part) {
107
+ if (!state.bundle) return void res.writeHead(503, { 'Content-Type': 'text/plain' }).end('The program has not built yet');
108
+ const worker = new Worker(WORKER_URL, { workerData: { bundle: state.bundle, part, overrides, output: 'stl' } });
109
+ const fail = (message) => res.writeHead(500, { 'Content-Type': 'text/plain' }).end(message);
110
+ worker.once('message', (result) => {
111
+ worker.terminate();
112
+ if (!result.ok) return fail(result.message);
113
+ const name = result.partNames.length > 1 ? `${baseName}-${result.part}.stl` : `${baseName}.stl`;
114
+ res.writeHead(200, { 'Content-Type': 'model/stl', 'Content-Disposition': `attachment; filename="${name}"` });
115
+ res.end(Buffer.from(result.stl.buffer, result.stl.byteOffset, result.stl.byteLength));
116
+ console.log(`↓ ${name}`);
117
+ });
118
+ worker.once('error', (error) => fail(error.message));
119
+ }
120
+
121
+ // The viewer's scene runs in a worker, where an import map cannot resolve 'three';
122
+ // it is bundled once, three.js included, and served from memory.
123
+ const renderWorker = (
124
+ await esbuild.build({ entryPoints: [path.join(CLIENT_DIR, 'render-worker.js')], bundle: true, format: 'esm', write: false, logLevel: 'warning' })
125
+ ).outputFiles[0].contents;
126
+
105
127
  const options = bundleOptions(file);
106
128
  const ctx = await esbuild.context({
107
129
  ...options,
@@ -127,7 +149,8 @@ export async function startDevServer({ file, port, host, part, overrides, open }
127
149
  const server = http.createServer((req, res) => {
128
150
  const url = new URL(req.url ?? '/', 'http://localhost');
129
151
  if (url.pathname === '/') return serveFile(res, CLIENT_DIR, 'index.html');
130
- if (url.pathname.startsWith('/vendor/three/')) return serveFile(res, THREE_DIR, url.pathname.slice('/vendor/three/'.length));
152
+ if (url.pathname === '/stl') return downloadSTL(res, url.searchParams.get('part') ?? state.part);
153
+ if (url.pathname === '/render-worker.js') return void res.writeHead(200, { 'Content-Type': 'text/javascript', 'Cache-Control': 'no-cache' }).end(renderWorker);
131
154
  serveFile(res, CLIENT_DIR, url.pathname.slice(1));
132
155
  });
133
156
 
package/src/dev/worker.js CHANGED
@@ -1,10 +1,12 @@
1
- // Evaluates a program in a worker thread. Each evaluation gets a fresh worker:
1
+ // Evaluates a program in a worker thread, for the viewer's meshes or, with
2
+ // output 'stl', for a download. Each evaluation gets a fresh worker:
2
3
  // that frees all WASM memory between runs, resets library state such as
3
4
  // setResolution(), and lets the server kill a run that a newer save made stale.
4
5
  import { parentPort, workerData } from 'node:worker_threads';
5
6
  import { format } from 'node:util';
6
7
  import { evaluateProgram } from '../core/program.js';
7
8
  import { meshGroups } from '../core/mesh.js';
9
+ import { toSTL } from '../export/stl.js';
8
10
 
9
11
  // Bundles carry inline source maps; this makes stack traces point at the user's files.
10
12
  process.setSourceMapsEnabled(true);
@@ -18,22 +20,30 @@ for (const level of ['log', 'info', 'warn', 'error']) {
18
20
  };
19
21
  }
20
22
 
21
- const { bundle, part, overrides } = workerData;
23
+ const { bundle, part, overrides, output } = workerData;
22
24
  const started = performance.now();
23
25
  try {
24
- const result = await evaluateProgram(bundle, { part, overrides, fallbackToFirst: true });
25
- const groups = meshGroups(result.solids);
26
- parentPort.postMessage(
27
- {
28
- ok: true,
29
- partNames: result.partNames,
30
- part: result.part,
31
- groups,
32
- logs,
33
- ms: Math.round(performance.now() - started),
34
- },
35
- groups.flatMap((g) => [g.positions.buffer, g.indices.buffer]),
36
- );
26
+ // Overrides of parameters the program has dropped are skipped; the server prunes them.
27
+ const result = await evaluateProgram(bundle, { part, overrides, fallbackToFirst: output !== 'stl', ignoreUnknown: true });
28
+ if (output === 'stl') {
29
+ const stl = toSTL(result.solids);
30
+ parentPort.postMessage({ ok: true, partNames: result.partNames, part: result.part, stl }, [stl.buffer]);
31
+ } else {
32
+ const groups = meshGroups(result.solids);
33
+ parentPort.postMessage(
34
+ {
35
+ ok: true,
36
+ partNames: result.partNames,
37
+ part: result.part,
38
+ // Plain data only: the browser edits these as numbers, strings, booleans and JSON.
39
+ declared: JSON.parse(JSON.stringify(result.declared)),
40
+ groups,
41
+ logs,
42
+ ms: Math.round(performance.now() - started),
43
+ },
44
+ groups.flatMap((g) => [g.positions.buffer, g.indices.buffer]),
45
+ );
46
+ }
37
47
  } catch (error) {
38
48
  parentPort.postMessage({ ok: false, message: error?.message ?? String(error), stack: error?.stack, logs });
39
49
  }