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/LICENSE +21 -0
- package/README.md +22 -10
- package/client/favicon.svg +17 -0
- package/client/index.html +48 -35
- package/client/render-worker.js +333 -0
- package/client/scene.js +166 -0
- package/client/viewer.css +421 -112
- package/client/viewer.js +127 -159
- package/package.json +9 -3
- package/src/core/evaluate.js +59 -0
- package/src/core/program.js +4 -44
- package/src/dev/server.js +8 -3
- package/src/dev/worker.js +4 -1
- package/src/export/stl.js +9 -7
package/client/viewer.js
CHANGED
|
@@ -1,61 +1,26 @@
|
|
|
1
|
-
import
|
|
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
|
|
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
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
221
|
+
const response = await fetch(`/stl?part=${encodeURIComponent(currentPart)}`);
|
|
259
222
|
if (!response.ok) throw new Error(await response.text());
|
|
260
|
-
|
|
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.
|
|
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
|
+
}
|
package/src/core/program.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
79
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
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]) (
|
|
24
|
-
for (const v of [a, b, c]) for (let k = 0; k < 3; k++) (
|
|
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
|
|
29
|
+
return bytes;
|
|
28
30
|
}
|