cadcode 0.2.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,66 @@ 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
+
249
212
  // --- Download ----------------------------------------------------------------
250
213
  // Fetched rather than navigated to, so a failing program shows its error here
251
214
  // instead of replacing the viewer.
@@ -255,13 +218,9 @@ const downloadButton = document.getElementById('download');
255
218
  downloadButton.addEventListener('click', async () => {
256
219
  downloadButton.disabled = true;
257
220
  try {
258
- const response = await fetch(`/stl?part=${encodeURIComponent(partSelect.value)}`);
221
+ const response = await fetch(`/stl?part=${encodeURIComponent(currentPart)}`);
259
222
  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
223
+ saveBlob(await response.blob(), response.headers.get('Content-Disposition').match(/filename="(.+)"/)[1]);
265
224
  } catch (error) {
266
225
  showError(`STL download failed: ${error.message}`);
267
226
  } finally {
@@ -269,6 +228,19 @@ downloadButton.addEventListener('click', async () => {
269
228
  }
270
229
  });
271
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
+
272
244
  // --- Connection --------------------------------------------------------------
273
245
 
274
246
  let socket = null;
@@ -278,11 +250,6 @@ function requestPart(name) {
278
250
  if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'part', name }));
279
251
  }
280
252
 
281
- partSelect.addEventListener('change', () => {
282
- storage.set(`cadcode:part:${fileKey}`, partSelect.value);
283
- requestPart(partSelect.value);
284
- });
285
-
286
253
  function connect() {
287
254
  socket = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/`);
288
255
  socket.binaryType = 'arraybuffer';
@@ -307,6 +274,7 @@ function connect() {
307
274
  fileEl.querySelector('.dir').textContent = header.file.slice(0, slash);
308
275
  fileEl.querySelector('.name').textContent = header.file.slice(slash);
309
276
  fileEl.title = header.file;
277
+ fileEl.hidden = false;
310
278
  document.title = `${header.file} · cadcode`;
311
279
  }
312
280
  showParts(header.partNames, header.part);
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "cadcode",
3
- "version": "0.2.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) {
@@ -119,6 +118,12 @@ export async function startDevServer({ file, port, host, part, overrides, open }
119
118
  worker.once('error', (error) => fail(error.message));
120
119
  }
121
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
+
122
127
  const options = bundleOptions(file);
123
128
  const ctx = await esbuild.context({
124
129
  ...options,
@@ -145,7 +150,7 @@ export async function startDevServer({ file, port, host, part, overrides, open }
145
150
  const url = new URL(req.url ?? '/', 'http://localhost');
146
151
  if (url.pathname === '/') return serveFile(res, CLIENT_DIR, 'index.html');
147
152
  if (url.pathname === '/stl') return downloadSTL(res, url.searchParams.get('part') ?? state.part);
148
- if (url.pathname.startsWith('/vendor/three/')) return serveFile(res, THREE_DIR, url.pathname.slice('/vendor/three/'.length));
153
+ if (url.pathname === '/render-worker.js') return void res.writeHead(200, { 'Content-Type': 'text/javascript', 'Cache-Control': 'no-cache' }).end(renderWorker);
149
154
  serveFile(res, CLIENT_DIR, url.pathname.slice(1));
150
155
  });
151
156
 
package/src/dev/worker.js CHANGED
@@ -23,7 +23,8 @@ for (const level of ['log', 'info', 'warn', 'error']) {
23
23
  const { bundle, part, overrides, output } = workerData;
24
24
  const started = performance.now();
25
25
  try {
26
- const result = await evaluateProgram(bundle, { part, overrides, fallbackToFirst: output !== 'stl' });
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 });
27
28
  if (output === 'stl') {
28
29
  const stl = toSTL(result.solids);
29
30
  parentPort.postMessage({ ok: true, partNames: result.partNames, part: result.part, stl }, [stl.buffer]);
@@ -34,6 +35,8 @@ try {
34
35
  ok: true,
35
36
  partNames: result.partNames,
36
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)),
37
40
  groups,
38
41
  logs,
39
42
  ms: Math.round(performance.now() - started),
package/src/export/stl.js CHANGED
@@ -3,14 +3,16 @@ import { mergedSolid } from '../core/mesh.js';
3
3
  /**
4
4
  * Binary STL of the part's solids merged into one.
5
5
  * @param {import('../lib/shapes.js').Solid[]} solids
6
- * @returns {Buffer}
6
+ * Plain typed arrays rather than a Buffer, so it also runs in the browser.
7
+ * @returns {Uint8Array}
7
8
  */
8
9
  export function toSTL(solids) {
9
10
  const { numProp, vertProperties, triVerts } = mergedSolid(solids).manifold.getMesh();
10
11
  const triCount = triVerts.length / 3;
11
- const buf = Buffer.alloc(84 + triCount * 50);
12
- buf.write('cadcode', 0, 'ascii');
13
- buf.writeUInt32LE(triCount, 80);
12
+ const bytes = new Uint8Array(84 + triCount * 50);
13
+ bytes.set(new TextEncoder().encode('cadcode'));
14
+ const view = new DataView(bytes.buffer);
15
+ view.setUint32(80, triCount, true);
14
16
 
15
17
  const p = (v, k) => vertProperties[v * numProp + k];
16
18
  let o = 84;
@@ -20,9 +22,9 @@ export function toSTL(solids) {
20
22
  const w = [p(c, 0) - p(a, 0), p(c, 1) - p(a, 1), p(c, 2) - p(a, 2)];
21
23
  const n = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]];
22
24
  const len = Math.hypot(...n) || 1;
23
- for (const value of [n[0] / len, n[1] / len, n[2] / len]) (buf.writeFloatLE(value, o), (o += 4));
24
- for (const v of [a, b, c]) for (let k = 0; k < 3; k++) (buf.writeFloatLE(p(v, k), o), (o += 4));
25
+ for (const value of [n[0] / len, n[1] / len, n[2] / len]) (view.setFloat32(o, value, true), (o += 4));
26
+ for (const v of [a, b, c]) for (let k = 0; k < 3; k++) (view.setFloat32(o, p(v, k), true), (o += 4));
25
27
  o += 2; // attribute byte count
26
28
  }
27
- return buf;
29
+ return bytes;
28
30
  }