hyperbook 0.93.1 → 0.95.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,632 @@
1
+ const scriptBase = new URL("./", self.location.href);
2
+
3
+ const FONTS_CONF = `<?xml version="1.0" encoding="UTF-8"?>
4
+ <!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
5
+ <fontconfig>
6
+ <dir>/fonts</dir>
7
+ </fontconfig>`;
8
+
9
+ const KNOWN_LIBRARIES = {
10
+ BOSL2: "https://ochafik.com/openscad2/libraries/BOSL2.zip",
11
+ BOSL: "https://ochafik.com/openscad2/libraries/BOSL.zip",
12
+ MCAD: "https://ochafik.com/openscad2/libraries/MCAD.zip",
13
+ NopSCADlib: "https://ochafik.com/openscad2/libraries/NopSCADlib.zip",
14
+ fonts: "https://ochafik.com/openscad2/libraries/fonts.zip",
15
+ };
16
+ const OPENSCAD_BACKEND_ARG = "--backend=manifold";
17
+ const OPENSCAD_FEATURE_ARGS = ["--enable=lazy-union"];
18
+
19
+ let openscadModulePromise = null;
20
+ let robotoFontData = null;
21
+
22
+ // Per-name cache of extracted file maps: Map<name, { [path]: Uint8Array }>
23
+ const libraryCache = new Map();
24
+
25
+ const invokeOpenScad = (instance, args) => {
26
+ try {
27
+ return instance.callMain(args);
28
+ } catch (e) {
29
+ if (typeof e === "number" && typeof instance.formatException === "function") {
30
+ throw new Error(`OpenSCAD invocation failed: ${instance.formatException(e)}`);
31
+ }
32
+ throw new Error(`OpenSCAD invocation failed: ${e}`);
33
+ }
34
+ };
35
+
36
+ const getOpenScad = async (mergedOutputs) => {
37
+ if (!openscadModulePromise) {
38
+ openscadModulePromise = import(/* @vite-ignore */ new URL("openscad.js", scriptBase).href);
39
+ }
40
+ const OpenSCAD = (await openscadModulePromise).default;
41
+ const instance = await OpenSCAD({
42
+ noInitialRun: true,
43
+ locateFile: (file) => new URL(file, scriptBase).href,
44
+ print: (text) => mergedOutputs.push({ stdout: text }),
45
+ printErr: (text) => mergedOutputs.push({ stderr: text }),
46
+ });
47
+ const fs = instance.FS;
48
+ try {
49
+ fs.mkdir("/tmp");
50
+ } catch (_) {}
51
+ try {
52
+ fs.mkdir("/fonts");
53
+ } catch (_) {}
54
+ // Fonts are resolved from $(cwd)/fonts — keep cwd at /
55
+ try {
56
+ instance.FS.chdir("/");
57
+ } catch (_) {}
58
+ try {
59
+ fs.writeFile("/fonts/fonts.conf", FONTS_CONF);
60
+ } catch (_) {}
61
+ // Write cached font data if already fetched.
62
+ if (robotoFontData) {
63
+ try {
64
+ fs.writeFile("/fonts/Roboto-Regular.ttf", robotoFontData);
65
+ } catch (_) {}
66
+ }
67
+ return instance;
68
+ };
69
+
70
+ // Minimal ZIP extractor using the browser-native DecompressionStream API.
71
+ // Supports Stored (method 0) and Deflate (method 8) entries.
72
+ const extractZip = async (buffer) => {
73
+ const view = new DataView(buffer);
74
+ const bytes = new Uint8Array(buffer);
75
+ const files = {};
76
+ const dec = new TextDecoder();
77
+
78
+ // Locate End of Central Directory record.
79
+ let eocdPos = -1;
80
+ for (let i = buffer.byteLength - 22; i >= Math.max(0, buffer.byteLength - 65558); i--) {
81
+ if (view.getUint32(i, true) === 0x06054b50) {
82
+ eocdPos = i;
83
+ break;
84
+ }
85
+ }
86
+ if (eocdPos < 0) throw new Error("Not a valid ZIP file");
87
+
88
+ const entryCount = view.getUint16(eocdPos + 10, true);
89
+ let cdOffset = view.getUint32(eocdPos + 16, true);
90
+
91
+ for (let i = 0; i < entryCount; i++) {
92
+ if (view.getUint32(cdOffset, true) !== 0x02014b50) break;
93
+ const compression = view.getUint16(cdOffset + 10, true);
94
+ const compressedSize = view.getUint32(cdOffset + 20, true);
95
+ const fnLen = view.getUint16(cdOffset + 28, true);
96
+ const extraLen = view.getUint16(cdOffset + 30, true);
97
+ const commentLen = view.getUint16(cdOffset + 32, true);
98
+ const localOffset = view.getUint32(cdOffset + 42, true);
99
+ const name = dec.decode(bytes.subarray(cdOffset + 46, cdOffset + 46 + fnLen));
100
+ cdOffset += 46 + fnLen + extraLen + commentLen;
101
+
102
+ if (name.endsWith("/")) continue;
103
+
104
+ const localFnLen = view.getUint16(localOffset + 26, true);
105
+ const localExtraLen = view.getUint16(localOffset + 28, true);
106
+ const dataStart = localOffset + 30 + localFnLen + localExtraLen;
107
+ const compressed = bytes.subarray(dataStart, dataStart + compressedSize);
108
+
109
+ if (compression === 0) {
110
+ files[name] = new Uint8Array(compressed);
111
+ } else if (compression === 8) {
112
+ const ds = new DecompressionStream("deflate-raw");
113
+ const writer = ds.writable.getWriter();
114
+ const reader = ds.readable.getReader();
115
+ writer.write(compressed);
116
+ writer.close();
117
+ const chunks = [];
118
+ let totalLen = 0;
119
+ while (true) {
120
+ const { done, value } = await reader.read();
121
+ if (done) break;
122
+ chunks.push(value);
123
+ totalLen += value.byteLength;
124
+ }
125
+ const out = new Uint8Array(totalLen);
126
+ let pos = 0;
127
+ for (const c of chunks) {
128
+ out.set(c, pos);
129
+ pos += c.byteLength;
130
+ }
131
+ files[name] = out;
132
+ }
133
+ }
134
+ return files;
135
+ };
136
+
137
+ const loadLibrary = async (name) => {
138
+ if (libraryCache.has(name)) return libraryCache.get(name);
139
+ const url = KNOWN_LIBRARIES[name];
140
+ if (!url) throw new Error(`Unknown OpenSCAD library: ${name}`);
141
+ const resp = await fetch(url);
142
+ if (!resp.ok) throw new Error(`Failed to fetch library ${name}: ${resp.status}`);
143
+ const files = await extractZip(await resp.arrayBuffer());
144
+ libraryCache.set(name, files);
145
+ return files;
146
+ };
147
+
148
+ // Mount a list of libraries into a WASM FS instance.
149
+ // Each library is written to /<name>/<file> so `use <BOSL2/std.scad>` resolves correctly.
150
+ const mountLibraries = async (instance, libraryNames) => {
151
+ for (const libName of libraryNames) {
152
+ const files = await loadLibrary(libName);
153
+ try {
154
+ instance.FS.mkdir(`/${libName}`);
155
+ } catch (_) {}
156
+ for (const [filePath, data] of Object.entries(files)) {
157
+ const parts = filePath.split("/");
158
+ let dir = `/${libName}`;
159
+ for (let j = 0; j < parts.length - 1; j++) {
160
+ dir += "/" + parts[j];
161
+ try {
162
+ instance.FS.mkdir(dir);
163
+ } catch (_) {}
164
+ }
165
+ try {
166
+ instance.FS.writeFile(`/${libName}/${filePath}`, data);
167
+ } catch (_) {}
168
+ }
169
+ }
170
+ };
171
+
172
+ // Fetch the Roboto TTF once and cache it in memory so it can be written
173
+ // to each new WASM instance's FS to enable OpenSCAD text() rendering.
174
+ const loadFonts = async () => {
175
+ if (robotoFontData) return;
176
+ try {
177
+ const resp = await fetch("https://fonts.gstatic.com/s/roboto/v32/KFOmCnqEu92Fr1Me5Q.ttf");
178
+ if (resp.ok) {
179
+ robotoFontData = new Uint8Array(await resp.arrayBuffer());
180
+ }
181
+ } catch (e) {
182
+ console.warn("[openscad] Failed to load fonts:", e);
183
+ }
184
+ };
185
+
186
+ const toArrayBuffer = (content) => {
187
+ const typed = content instanceof Uint8Array ? content : new Uint8Array(content || []);
188
+ return typed.buffer.slice(typed.byteOffset, typed.byteOffset + typed.byteLength);
189
+ };
190
+
191
+ const serializeInvocationResults = (result) => {
192
+ const transfer = [];
193
+ const outputs = (result.outputs || []).map(([path, content]) => {
194
+ const buffer = toArrayBuffer(content);
195
+ transfer.push(buffer);
196
+ return [path, buffer];
197
+ });
198
+ return {
199
+ result: {
200
+ ...result,
201
+ outputs,
202
+ },
203
+ transfer,
204
+ };
205
+ };
206
+
207
+ const getExtractedParameters = (result) => {
208
+ if (result?.error || result?.exitCode !== 0) {
209
+ return [];
210
+ }
211
+ const output = result?.outputs?.[0]?.[1];
212
+ if (!output) return [];
213
+ try {
214
+ const bytes = output instanceof Uint8Array ? output : new Uint8Array(output);
215
+ const json = new TextDecoder().decode(bytes);
216
+ const paramSet = JSON.parse(json);
217
+ if (!Array.isArray(paramSet?.parameters)) return [];
218
+ return paramSet.parameters.filter((p) => !p.name?.startsWith("$"));
219
+ } catch (_) {
220
+ return [];
221
+ }
222
+ };
223
+
224
+ const escapeHtml = (value) =>
225
+ String(value)
226
+ .replace(/&/g, "&amp;")
227
+ .replace(/</g, "&lt;")
228
+ .replace(/>/g, "&gt;")
229
+ .replace(/"/g, "&quot;")
230
+ .replace(/'/g, "&#39;");
231
+
232
+ const toFiniteNumber = (value, fallback = 0) => {
233
+ const num = Number(value);
234
+ return Number.isFinite(num) ? num : fallback;
235
+ };
236
+
237
+ const buildParamFormUi = (codeParams, currentOverrides = {}, id = "model") => {
238
+ if (!Array.isArray(codeParams) || codeParams.length === 0) {
239
+ return { hasParams: false, html: "", values: {} };
240
+ }
241
+
242
+ const values = {};
243
+
244
+ const appendNumberAttrs = (min, max, step) => {
245
+ let attrs = "";
246
+ if (step != null) attrs += ` step="${escapeHtml(String(step))}"`;
247
+ else attrs += ' step="any"';
248
+ if (min != null) attrs += ` min="${escapeHtml(String(min))}"`;
249
+ if (max != null) attrs += ` max="${escapeHtml(String(max))}"`;
250
+ return attrs;
251
+ };
252
+
253
+ const buildParamRow = (param, index) => {
254
+ const name = param?.name;
255
+ if (!name) return null;
256
+ const caption = param?.caption || name;
257
+ const type = param?.type;
258
+ const initial = param?.initial;
259
+ const min = param?.min;
260
+ const max = param?.max;
261
+ const step = param?.step;
262
+ const options = Array.isArray(param?.options) ? param.options : [];
263
+ const current = Object.prototype.hasOwnProperty.call(currentOverrides, name)
264
+ ? currentOverrides[name]
265
+ : initial;
266
+
267
+ const inputId = `openscad-param-${id}-${index}`;
268
+ let controlHtml = "";
269
+
270
+ if (type === "boolean") {
271
+ const value = Boolean(current);
272
+ values[name] = value;
273
+ controlHtml = `<input id="${escapeHtml(inputId)}" type="checkbox" data-param-name="${escapeHtml(name)}" data-param-kind="boolean"${value ? " checked" : ""}>`;
274
+ } else if (options.length > 0) {
275
+ let selectedIndex = options.findIndex((opt) => String(opt?.value) === String(current));
276
+ if (selectedIndex < 0) selectedIndex = 0;
277
+ const selectedValue = options[selectedIndex]?.value ?? null;
278
+ values[name] = selectedValue;
279
+ const optionHtml = options.map((opt, optIndex) => {
280
+ const optLabel = opt?.name || String(opt?.value);
281
+ const optValueJson = JSON.stringify(opt?.value ?? null);
282
+ const selected = optIndex === selectedIndex ? " selected" : "";
283
+ return `<option value="${escapeHtml(String(optIndex))}" data-param-option-value="${escapeHtml(optValueJson)}"${selected}>${escapeHtml(optLabel)}</option>`;
284
+ }).join("");
285
+ controlHtml = `<select id="${escapeHtml(inputId)}" data-param-name="${escapeHtml(name)}" data-param-kind="option">${optionHtml}</select>`;
286
+ } else if (type === "number" && Array.isArray(initial)) {
287
+ const source = Array.isArray(current) ? current : initial;
288
+ const vector = source.map((entry) => toFiniteNumber(entry));
289
+ values[name] = vector;
290
+ const vectorInputs = vector.map((entry, vectorIndex) =>
291
+ `<input id="${escapeHtml(`${inputId}-${vectorIndex}`)}" type="number" value="${escapeHtml(String(entry))}" data-param-name="${escapeHtml(name)}" data-param-kind="vector" data-vector-index="${vectorIndex}"${appendNumberAttrs(min, max, step)}>`
292
+ ).join("");
293
+ controlHtml = `<span class="param-vector">${vectorInputs}</span>`;
294
+ } else if (type === "number") {
295
+ const value = toFiniteNumber(current, toFiniteNumber(initial));
296
+ values[name] = value;
297
+ controlHtml = `<input id="${escapeHtml(inputId)}" type="number" value="${escapeHtml(String(value))}" data-param-name="${escapeHtml(name)}" data-param-kind="number"${appendNumberAttrs(min, max, step)}>`;
298
+ } else {
299
+ const value = current == null ? "" : String(current);
300
+ values[name] = value;
301
+ controlHtml = `<input id="${escapeHtml(inputId)}" type="text" value="${escapeHtml(value)}" data-param-name="${escapeHtml(name)}" data-param-kind="string">`;
302
+ }
303
+
304
+ return `<div class="param-row"><label for="${escapeHtml(inputId)}">${escapeHtml(caption)}</label>${controlHtml}</div>`;
305
+ };
306
+
307
+ // Separate global/ungrouped params from named groups.
308
+ // Parameters with group=null/undefined/"Global" are always shown outside any accordion.
309
+ const globalRows = [];
310
+ const groupMap = new Map(); // preserves insertion order
311
+
312
+ codeParams.forEach((param, index) => {
313
+ const row = buildParamRow(param, index);
314
+ if (!row) return;
315
+ const group = param?.group;
316
+ if (!group || group === "Global") {
317
+ globalRows.push(row);
318
+ } else {
319
+ if (!groupMap.has(group)) groupMap.set(group, []);
320
+ groupMap.get(group).push(row);
321
+ }
322
+ });
323
+
324
+ const parts = [...globalRows];
325
+
326
+ for (const [groupName, groupRows] of groupMap) {
327
+ parts.push(
328
+ `<details class="param-group" open><summary class="param-group-summary">${escapeHtml(groupName)}</summary><div class="param-group-body">${groupRows.join("")}</div></details>`
329
+ );
330
+ }
331
+
332
+ const totalRows = globalRows.length + [...groupMap.values()].reduce((sum, rows) => sum + rows.length, 0);
333
+
334
+ return {
335
+ hasParams: totalRows > 0,
336
+ html: parts.join(""),
337
+ values,
338
+ };
339
+ };
340
+
341
+ const DEFAULT_FACE_COLOR_WORKER = [0xf9 / 255, 0xd7 / 255, 0x2c / 255, 1];
342
+
343
+ // Compute flat (per-triangle) normals for a non-indexed positions array.
344
+ // Every 9 consecutive floats represent one triangle (3 vertices × xyz).
345
+ const computeFlatNormals = (positions) => {
346
+ const normals = new Float32Array(positions.length);
347
+ for (let i = 0; i < positions.length; i += 9) {
348
+ const ax = positions[i], ay = positions[i + 1], az = positions[i + 2];
349
+ const bx = positions[i + 3], by = positions[i + 4], bz = positions[i + 5];
350
+ const cx = positions[i + 6], cy = positions[i + 7], cz = positions[i + 8];
351
+ const ex = bx - ax, ey = by - ay, ez = bz - az;
352
+ const fx = cx - ax, fy = cy - ay, fz = cz - az;
353
+ const nx = ey * fz - ez * fy;
354
+ const ny = ez * fx - ex * fz;
355
+ const nz = ex * fy - ey * fx;
356
+ const len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
357
+ const nnx = nx / len, nny = ny / len, nnz = nz / len;
358
+ normals[i] = nnx; normals[i + 1] = nny; normals[i + 2] = nnz;
359
+ normals[i + 3] = nnx; normals[i + 4] = nny; normals[i + 5] = nnz;
360
+ normals[i + 6] = nnx; normals[i + 7] = nny; normals[i + 8] = nnz;
361
+ }
362
+ return normals;
363
+ };
364
+
365
+ // Parse an OFF file into flat Float32Array colour buckets so the main thread can
366
+ // construct Three.js geometries without any text-parsing work.
367
+ // Returns { colorBuckets: [{color, positions, normals}], transfer } or null on failure.
368
+ const parseOffToColorBuckets = (offData) => {
369
+ try {
370
+ const text = new TextDecoder().decode(
371
+ offData instanceof Uint8Array ? offData : new Uint8Array(offData),
372
+ );
373
+ const lines = text
374
+ .split("\n")
375
+ .map((l) => l.trim())
376
+ .filter((l) => l.length > 0 && !l.startsWith("#"));
377
+ if (lines.length === 0) return null;
378
+
379
+ let countsLine = "";
380
+ let currentLine = 0;
381
+ if (/^OFF(\s|$)/.test(lines[0])) {
382
+ countsLine = lines[0].substring(3).trim();
383
+ currentLine = 1;
384
+ }
385
+ // Handle standard two-line OFF header: "OFF\nV F E"
386
+ if (!countsLine && currentLine < lines.length) {
387
+ countsLine = lines[currentLine];
388
+ currentLine++;
389
+ }
390
+ if (!countsLine) return null;
391
+
392
+ const countParts = countsLine.split(/\s+/).map(Number);
393
+ const vertexCount = Number.isFinite(countParts[0]) ? Math.floor(countParts[0]) : NaN;
394
+ const faceCount = Number.isFinite(countParts[1]) ? Math.floor(countParts[1]) : NaN;
395
+ if (!Number.isFinite(vertexCount) || !Number.isFinite(faceCount) || vertexCount <= 0 || faceCount <= 0) return null;
396
+ if (currentLine + vertexCount + faceCount > lines.length) return null;
397
+
398
+ const vertices = new Float32Array(vertexCount * 3);
399
+ for (let i = 0; i < vertexCount; i++) {
400
+ const parts = lines[currentLine + i].split(/\s+/).map(Number);
401
+ vertices[i * 3] = parts[0] || 0;
402
+ vertices[i * 3 + 1] = parts[1] || 0;
403
+ vertices[i * 3 + 2] = parts[2] || 0;
404
+ }
405
+ currentLine += vertexCount;
406
+
407
+ const colorMap = new Map();
408
+ const buckets = [];
409
+
410
+ for (let i = 0; i < faceCount; i++) {
411
+ const parts = lines[currentLine + i].split(/\s+/).map(Number);
412
+ const numVerts = Number.isFinite(parts[0]) ? Math.floor(parts[0]) : 0;
413
+ const faceVertices = parts.slice(1, numVerts + 1).map(Math.floor);
414
+ if (faceVertices.length < 3) continue;
415
+
416
+ let color = DEFAULT_FACE_COLOR_WORKER;
417
+ if (parts.length >= numVerts + 4) {
418
+ const raw = parts.slice(numVerts + 1, numVerts + 5).filter(Number.isFinite);
419
+ if (raw.length >= 3) {
420
+ const r = raw[0], g = raw[1], b = raw[2];
421
+ const a = raw.length >= 4 ? raw[3] : (Math.max(r, g, b) > 1 ? 255 : 1);
422
+ const div = Math.max(r, g, b, a) > 1 ? 255 : 1;
423
+ color = [r / div, g / div, b / div, a / div];
424
+ }
425
+ }
426
+
427
+ const colorKey = color.join(",");
428
+ let bucket = colorMap.get(colorKey);
429
+ if (!bucket) {
430
+ bucket = { color, positionsList: [] };
431
+ colorMap.set(colorKey, bucket);
432
+ buckets.push(bucket);
433
+ }
434
+
435
+ // Fan triangulation of potentially non-triangular faces
436
+ for (let j = 1; j < faceVertices.length - 1; j++) {
437
+ const i1 = faceVertices[0], i2 = faceVertices[j], i3 = faceVertices[j + 1];
438
+ bucket.positionsList.push(
439
+ vertices[i1 * 3], vertices[i1 * 3 + 1], vertices[i1 * 3 + 2],
440
+ vertices[i2 * 3], vertices[i2 * 3 + 1], vertices[i2 * 3 + 2],
441
+ vertices[i3 * 3], vertices[i3 * 3 + 1], vertices[i3 * 3 + 2],
442
+ );
443
+ }
444
+ }
445
+
446
+ if (buckets.length === 0) return null;
447
+
448
+ const transfer = [];
449
+ const colorBuckets = buckets.map((bucket) => {
450
+ const positions = new Float32Array(bucket.positionsList);
451
+ const normals = computeFlatNormals(positions);
452
+ transfer.push(positions.buffer, normals.buffer);
453
+ return { color: bucket.color, positions, normals };
454
+ });
455
+
456
+ return { colorBuckets, transfer };
457
+ } catch (_) {
458
+ return null;
459
+ }
460
+ };
461
+
462
+ const runOpenScadInvocation = async ({
463
+ code,
464
+ sourcePath,
465
+ outputPaths = [],
466
+ args = [],
467
+ libraryNames = [],
468
+ }) => {
469
+ const mergedOutputs = [];
470
+ const start = performance.now();
471
+
472
+ try {
473
+ await loadFonts();
474
+ const instance = await getOpenScad(mergedOutputs);
475
+
476
+ if (libraryNames.length > 0) {
477
+ await mountLibraries(instance, libraryNames);
478
+ }
479
+
480
+ try {
481
+ instance.FS.unlink(sourcePath);
482
+ } catch (_) {}
483
+ for (const outputPath of outputPaths) {
484
+ try {
485
+ instance.FS.unlink(outputPath);
486
+ } catch (_) {}
487
+ }
488
+
489
+ instance.FS.writeFile(sourcePath, code || "");
490
+ const exitCode = invokeOpenScad(instance, args);
491
+
492
+ const outputs = [];
493
+ for (const outputPath of outputPaths) {
494
+ const content = instance.FS.readFile(outputPath, { encoding: "binary" });
495
+ outputs.push([outputPath, content]);
496
+ }
497
+
498
+ return {
499
+ exitCode,
500
+ outputs,
501
+ mergedOutputs,
502
+ elapsedMillis: performance.now() - start,
503
+ };
504
+ } catch (e) {
505
+ const error = `${e}`;
506
+ mergedOutputs.push({ error });
507
+ return {
508
+ exitCode: undefined,
509
+ error,
510
+ outputs: [],
511
+ mergedOutputs,
512
+ elapsedMillis: performance.now() - start,
513
+ };
514
+ }
515
+ };
516
+
517
+ self.addEventListener("message", async (event) => {
518
+ const { requestId, type, payload } = event.data || {};
519
+ if (!requestId || typeof type !== "string") return;
520
+
521
+ try {
522
+ let result;
523
+ if (type === "extractParams") {
524
+ const sourcePath = "/tmp/params_model.scad";
525
+ const outPath = "/tmp/params_out.json";
526
+ result = await runOpenScadInvocation({
527
+ code: `$preview=true;\n${payload?.code || ""}`,
528
+ sourcePath,
529
+ outputPaths: [outPath],
530
+ libraryNames: payload?.libraryNames || [],
531
+ args: [
532
+ sourcePath,
533
+ "-o",
534
+ outPath,
535
+ "--export-format=param",
536
+ ],
537
+ });
538
+ const serialized = serializeInvocationResults(result);
539
+ self.postMessage({ requestId, ok: true, result: serialized.result }, serialized.transfer);
540
+ return;
541
+ }
542
+ if (type === "buildParamForm") {
543
+ const sourcePath = "/tmp/params_model.scad";
544
+ const outPath = "/tmp/params_out.json";
545
+ result = await runOpenScadInvocation({
546
+ code: `$preview=true;\n${payload?.code || ""}`,
547
+ sourcePath,
548
+ outputPaths: [outPath],
549
+ libraryNames: payload?.libraryNames || [],
550
+ args: [
551
+ sourcePath,
552
+ "-o",
553
+ outPath,
554
+ "--export-format=param",
555
+ ],
556
+ });
557
+
558
+ const codeParams = getExtractedParameters(result);
559
+ const ui = buildParamFormUi(
560
+ codeParams,
561
+ payload?.currentOverrides || {},
562
+ payload?.id || "model",
563
+ );
564
+
565
+ self.postMessage({
566
+ requestId,
567
+ ok: true,
568
+ result: {
569
+ hasParams: ui.hasParams,
570
+ html: ui.html,
571
+ values: ui.values,
572
+ },
573
+ });
574
+ return;
575
+ }
576
+ if (type === "render") {
577
+ const format = payload?.format || "stl";
578
+ const sourcePath = "/tmp/model.scad";
579
+ const outPath = `/tmp/output.${format}`;
580
+ const exportFormat = format === "stl" ? "binstl" : format;
581
+ result = await runOpenScadInvocation({
582
+ code: payload?.isPreview ? `$preview=true;\n${payload?.code || ""}` : (payload?.code || ""),
583
+ sourcePath,
584
+ outputPaths: [outPath],
585
+ libraryNames: payload?.libraryNames || [],
586
+ args: [
587
+ sourcePath,
588
+ "-o",
589
+ outPath,
590
+ OPENSCAD_BACKEND_ARG,
591
+ `--export-format=${exportFormat}`,
592
+ ...(payload?.paramDefinitions || []),
593
+ ...OPENSCAD_FEATURE_ARGS,
594
+ ],
595
+ });
596
+ // For preview OFF renders, parse geometry here in the worker so the main thread
597
+ // never has to do heavy text parsing or Float32Array building.
598
+ if (format === "off" && payload?.isPreview && result.exitCode === 0 && !result.error) {
599
+ const offOutput = result.outputs?.[0]?.[1];
600
+ if (offOutput) {
601
+ const parsed = parseOffToColorBuckets(offOutput);
602
+ if (parsed) {
603
+ self.postMessage({
604
+ requestId,
605
+ ok: true,
606
+ result: {
607
+ exitCode: result.exitCode,
608
+ mergedOutputs: result.mergedOutputs,
609
+ elapsedMillis: result.elapsedMillis,
610
+ parsedGeometry: parsed.colorBuckets,
611
+ },
612
+ }, parsed.transfer);
613
+ return;
614
+ }
615
+ }
616
+ }
617
+ const serialized = serializeInvocationResults(result);
618
+ self.postMessage({ requestId, ok: true, result: serialized.result }, serialized.transfer);
619
+ return;
620
+ }
621
+ throw new Error(`Unknown OpenSCAD worker request: ${type}`);
622
+ } catch (error) {
623
+ self.postMessage({
624
+ requestId,
625
+ ok: false,
626
+ error: {
627
+ message: error?.message || String(error),
628
+ stderr: Array.isArray(error?.stderr) ? error.stderr : [],
629
+ },
630
+ });
631
+ }
632
+ });