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.
@@ -8,234 +8,121 @@
8
8
  hyperbook.openscad = (function () {
9
9
  const _scriptBase = window.HYPERBOOK_ASSETS + "directive-openscad/";
10
10
 
11
- window.codeInput?.registerTemplate(
12
- "openscad-highlighted",
13
- codeInput.templates.prism(window.Prism, [
14
- new codeInput.plugins.AutoCloseBrackets(),
15
- new codeInput.plugins.Indent(true, 2),
16
- ]),
17
- );
18
-
19
- // Cache the ESM module import (loaded once). Each render creates a fresh
20
- // WASM instance to avoid C++ singleton state issues
21
- // (e.g. the Manifold backend throwing a C++ exception on second callMain).
22
- let openscadModulePromise = null;
23
11
  let threePromise = null;
12
+ let workerRequestId = 0;
24
13
 
25
- // Font bytes are fetched once and re-written to each fresh WASM instance.
26
- let robotoFontData = null;
27
-
28
- // Per-render stderr capture cleared before each render.
29
- let openscadStderr = [];
14
+ // Two separate worker instances: one for rendering, one for parameter extraction.
15
+ // This allows both to run concurrently in truly separate threads.
16
+ const workerSlots = {
17
+ render: { promise: null, pending: new Map() },
18
+ param: { promise: null, pending: new Map() },
19
+ };
30
20
 
31
21
  const i18nGet = (key, fallback = key) => hyperbook.i18n?.get(key) || fallback;
32
22
 
33
- const FONTS_CONF = `<?xml version="1.0" encoding="UTF-8"?>
34
- <!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
35
- <fontconfig>
36
- <dir>/fonts</dir>
37
- </fontconfig>`;
38
-
39
- // Create a fresh OpenSCAD WASM instance for each render.
40
- // The ESM module (and its compiled WASM binary) is imported only once;
41
- // the browser's WebAssembly module cache makes subsequent instantiations fast.
42
- const getOpenScad = async () => {
43
- if (!openscadModulePromise) {
44
- openscadModulePromise = import(/* @vite-ignore */ _scriptBase + "openscad.js");
45
- }
46
- const OpenSCAD = (await openscadModulePromise).default;
47
- const instance = await OpenSCAD({
48
- noInitialRun: true,
49
- locateFile: (file) => _scriptBase + file,
50
- printErr: (text) => openscadStderr.push(text),
51
- });
52
- const fs = instance.FS;
53
- try { fs.mkdir("/tmp"); } catch (_) {}
54
- try { fs.mkdir("/fonts"); } catch (_) {}
55
- // Fonts are resolved from $(cwd)/fonts — keep cwd at /
56
- try { instance.FS.chdir("/"); } catch (_) {}
57
- try { fs.writeFile("/fonts/fonts.conf", FONTS_CONF); } catch (_) {}
58
- // Write cached font data if already fetched
59
- if (robotoFontData) {
60
- try { fs.writeFile("/fonts/Roboto-Regular.ttf", robotoFontData); } catch (_) {}
23
+ const getWorker = async (slot) => {
24
+ if (!window.Worker) {
25
+ throw new Error(i18nGet("openscad-render-failed", "OpenSCAD render failed"));
61
26
  }
62
- return instance;
63
- };
27
+ const s = workerSlots[slot];
28
+ if (!s.promise) {
29
+ s.promise = new Promise((resolve, reject) => {
30
+ try {
31
+ const worker = new Worker(new URL(_scriptBase + "worker.js", window.location.href), {
32
+ type: "module",
33
+ });
64
34
 
65
- // Known library URLs hosted at the openscad-playground deployment.
66
- const KNOWN_LIBRARIES = {
67
- BOSL2: "https://ochafik.com/openscad2/libraries/BOSL2.zip",
68
- BOSL: "https://ochafik.com/openscad2/libraries/BOSL.zip",
69
- MCAD: "https://ochafik.com/openscad2/libraries/MCAD.zip",
70
- NopSCADlib: "https://ochafik.com/openscad2/libraries/NopSCADlib.zip",
71
- fonts: "https://ochafik.com/openscad2/libraries/fonts.zip",
72
- };
35
+ worker.addEventListener("message", (event) => {
36
+ const { requestId, ok, result, error } = event.data || {};
37
+ if (!requestId || !s.pending.has(requestId)) return;
38
+ const pending = s.pending.get(requestId);
39
+ s.pending.delete(requestId);
40
+ if (ok) {
41
+ pending.resolve(result);
42
+ return;
43
+ }
44
+ const workerError = new Error(error?.message || "OpenSCAD worker request failed");
45
+ if (Array.isArray(error?.stderr)) {
46
+ workerError.stderr = error.stderr;
47
+ }
48
+ pending.reject(workerError);
49
+ });
73
50
 
74
- // Per-name cache of extracted file maps: Map<name, { [path]: Uint8Array }>
75
- const libraryCache = new Map();
76
-
77
- // Minimal ZIP extractor using the browser-native DecompressionStream API.
78
- // Supports Stored (method 0) and Deflate (method 8) entries.
79
- const extractZip = async (buffer) => {
80
- const view = new DataView(buffer);
81
- const bytes = new Uint8Array(buffer);
82
- const files = {};
83
- const dec = new TextDecoder();
84
-
85
- // Locate End of Central Directory record.
86
- let eocdPos = -1;
87
- for (let i = buffer.byteLength - 22; i >= Math.max(0, buffer.byteLength - 65558); i--) {
88
- if (view.getUint32(i, true) === 0x06054b50) { eocdPos = i; break; }
89
- }
90
- if (eocdPos < 0) throw new Error("Not a valid ZIP file");
91
-
92
- const entryCount = view.getUint16(eocdPos + 10, true);
93
- let cdOffset = view.getUint32(eocdPos + 16, true);
94
-
95
- for (let i = 0; i < entryCount; i++) {
96
- if (view.getUint32(cdOffset, true) !== 0x02014b50) break;
97
- const compression = view.getUint16(cdOffset + 10, true);
98
- const compressedSize = view.getUint32(cdOffset + 20, true);
99
- const fnLen = view.getUint16(cdOffset + 28, true);
100
- const extraLen = view.getUint16(cdOffset + 30, true);
101
- const commentLen = view.getUint16(cdOffset + 32, true);
102
- const localOffset = view.getUint32(cdOffset + 42, true);
103
- const name = dec.decode(bytes.subarray(cdOffset + 46, cdOffset + 46 + fnLen));
104
- cdOffset += 46 + fnLen + extraLen + commentLen;
105
-
106
- if (name.endsWith("/")) continue;
107
-
108
- const localFnLen = view.getUint16(localOffset + 26, true);
109
- const localExtraLen = view.getUint16(localOffset + 28, true);
110
- const dataStart = localOffset + 30 + localFnLen + localExtraLen;
111
- const compressed = bytes.subarray(dataStart, dataStart + compressedSize);
112
-
113
- if (compression === 0) {
114
- files[name] = new Uint8Array(compressed);
115
- } else if (compression === 8) {
116
- const ds = new DecompressionStream("deflate-raw");
117
- const writer = ds.writable.getWriter();
118
- const reader = ds.readable.getReader();
119
- writer.write(compressed);
120
- writer.close();
121
- const chunks = [];
122
- let totalLen = 0;
123
- while (true) {
124
- const { done, value } = await reader.read();
125
- if (done) break;
126
- chunks.push(value);
127
- totalLen += value.byteLength;
128
- }
129
- const out = new Uint8Array(totalLen);
130
- let pos = 0;
131
- for (const c of chunks) { out.set(c, pos); pos += c.byteLength; }
132
- files[name] = out;
133
- }
134
- }
135
- return files;
136
- };
51
+ worker.addEventListener("error", (event) => {
52
+ const workerError = new Error(event?.message || "OpenSCAD worker crashed");
53
+ for (const { reject } of s.pending.values()) reject(workerError);
54
+ s.pending.clear();
55
+ s.promise = null;
56
+ });
137
57
 
138
- // Fetch and extract a library zip, caching the result.
139
- const loadLibrary = async (name) => {
140
- if (libraryCache.has(name)) return libraryCache.get(name);
141
- const url = KNOWN_LIBRARIES[name];
142
- if (!url) throw new Error(`Unknown OpenSCAD library: ${name}`);
143
- const resp = await fetch(url);
144
- if (!resp.ok) throw new Error(`Failed to fetch library ${name}: ${resp.status}`);
145
- const files = await extractZip(await resp.arrayBuffer());
146
- libraryCache.set(name, files);
147
- return files;
148
- };
58
+ worker.addEventListener("messageerror", () => {
59
+ const workerError = new Error("OpenSCAD worker message error");
60
+ for (const { reject } of s.pending.values()) reject(workerError);
61
+ s.pending.clear();
62
+ s.promise = null;
63
+ });
149
64
 
150
- // Mount a list of libraries into a WASM FS instance.
151
- // Each library is written to /<name>/<file> so `use <BOSL2/std.scad>` resolves correctly.
152
- const mountLibraries = async (instance, libraryNames) => {
153
- for (const libName of libraryNames) {
154
- const files = await loadLibrary(libName);
155
- try { instance.FS.mkdir(`/${libName}`); } 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 { instance.FS.mkdir(dir); } catch (_) {}
65
+ resolve(worker);
66
+ } catch (error) {
67
+ s.promise = null;
68
+ reject(error);
162
69
  }
163
- try { instance.FS.writeFile(`/${libName}/${filePath}`, data); } catch (_) {}
164
- }
70
+ });
165
71
  }
72
+ return s.promise;
166
73
  };
167
74
 
168
- // Fetch the Roboto TTF once and cache it in memory so it can be written
169
- // to each new WASM instance's FS to enable OpenSCAD text() rendering.
170
- const loadFonts = async () => {
171
- if (robotoFontData) return;
172
- try {
173
- const resp = await fetch(
174
- "https://fonts.gstatic.com/s/roboto/v32/KFOmCnqEu92Fr1Me5Q.ttf",
175
- );
176
- if (resp.ok) {
177
- robotoFontData = new Uint8Array(await resp.arrayBuffer());
75
+ const callWorker = async (slot, type, payload, transfer = []) => {
76
+ const worker = await getWorker(slot);
77
+ const s = workerSlots[slot];
78
+ const requestId = ++workerRequestId;
79
+ return new Promise((resolve, reject) => {
80
+ s.pending.set(requestId, { resolve, reject });
81
+ try {
82
+ worker.postMessage({ requestId, type, payload }, transfer);
83
+ } catch (error) {
84
+ s.pending.delete(requestId);
85
+ reject(error);
178
86
  }
179
- } catch (e) {
180
- console.warn("[openscad] Failed to load fonts:", e);
181
- }
87
+ });
182
88
  };
183
89
 
184
- // Extract parameters from SCAD code. Tries OpenSCAD WASM with
185
- // --export-format=param first (uses the built-in Customizer engine with full
186
- // comment syntax support). Falls back to regex parsing if WASM returns nothing.
187
- const extractParams = async (code, libraryNames = []) => {
188
- try {
189
- const openscad = await getOpenScad();
190
- const instance = openscad;
191
-
192
- if (libraryNames.length > 0) {
193
- await mountLibraries(instance, libraryNames);
194
- }
90
+ const getInvocationStderr = (invocationResult) =>
91
+ (invocationResult?.mergedOutputs || [])
92
+ .filter((entry) => typeof entry?.stderr === "string")
93
+ .map((entry) => entry.stderr);
195
94
 
196
- const sourcePath = "/tmp/params_model.scad";
197
- const outPath = "/tmp/params_out.json";
198
-
199
- try { instance.FS.unlink(sourcePath); } catch (_) {}
200
- try { instance.FS.unlink(outPath); } catch (_) {}
201
-
202
- // Prepend $preview=true as the playground does, to avoid full geometry evaluation.
203
- instance.FS.writeFile(sourcePath, "$preview=true;\n" + code);
204
-
205
- const exitCode = instance.callMain([
206
- sourcePath,
207
- "-o", outPath,
208
- "--export-format=param",
209
- ]);
210
-
211
- if (exitCode === 0) {
212
- try {
213
- const json = instance.FS.readFile(outPath, { encoding: "utf8" });
214
- const paramSet = JSON.parse(json);
215
- if (Array.isArray(paramSet.parameters) && paramSet.parameters.length > 0) {
216
- // Filter out OpenSCAD special variables (e.g. $preview, $fn, $fa, $fs)
217
- // that are internal and should not be exposed in the parameter UI.
218
- return paramSet.parameters.filter(p => !p.name?.startsWith('$'));
219
- }
220
- } catch (e) {
221
- console.warn("[openscad] Failed to parse param output:", e);
222
- }
223
- }
95
+ // Build parameter UI metadata/markup in the worker to minimize main-thread work.
96
+ const buildParamUiInWorker = async (code, libraryNames = [], currentOverrides = {}, id = "") => {
97
+ try {
98
+ const result = await callWorker("param", "buildParamForm", {
99
+ code,
100
+ libraryNames,
101
+ currentOverrides,
102
+ id,
103
+ });
104
+ return {
105
+ hasParams: Boolean(result?.hasParams),
106
+ html: typeof result?.html === "string" ? result.html : "",
107
+ values: result?.values && typeof result.values === "object" ? result.values : {},
108
+ };
224
109
  } catch (e) {
225
- console.warn("[openscad] WASM param extraction failed:", e);
110
+ console.warn("[openscad] Worker param UI build failed:", e);
111
+ return {
112
+ hasParams: false,
113
+ html: "",
114
+ values: {},
115
+ };
226
116
  }
227
- return [];
228
117
  };
229
118
 
230
119
  const getThree = async () => {
231
120
  if (!threePromise) {
232
121
  threePromise = Promise.all([
233
122
  import(/* @vite-ignore */ _scriptBase + "three.module.js"),
234
- import(/* @vite-ignore */ _scriptBase + "STLLoader.js"),
235
123
  import(/* @vite-ignore */ _scriptBase + "OrbitControls.js"),
236
- ]).then(([THREE, STLLoaderModule, OrbitControlsModule]) => ({
124
+ ]).then(([THREE, OrbitControlsModule]) => ({
237
125
  THREE,
238
- STLLoader: STLLoaderModule.STLLoader,
239
126
  OrbitControls: OrbitControlsModule.OrbitControls,
240
127
  }));
241
128
  }
@@ -285,6 +172,7 @@ hyperbook.openscad = (function () {
285
172
  const delta = moveEvent.clientY - startPointer;
286
173
  const size = applySplitSize(startSize + delta);
287
174
  leftSide.dataset.splitCanvasParams = String(Math.round(size));
175
+ onSplitChanged?.({ splitCanvasParams: Math.round(size) });
288
176
  };
289
177
 
290
178
  const onPointerUp = () => {
@@ -358,6 +246,9 @@ hyperbook.openscad = (function () {
358
246
  const delta = pointer - startPointer;
359
247
  const size = applySplitSize(startSize + delta, isHorizontal);
360
248
  elem.dataset[key] = String(Math.round(size));
249
+ onSplitChanged?.({
250
+ [key]: Math.round(size),
251
+ });
361
252
  };
362
253
 
363
254
  const onPointerUp = () => {
@@ -419,6 +310,347 @@ hyperbook.openscad = (function () {
419
310
  return new Uint8Array(data || []);
420
311
  };
421
312
 
313
+ const textEncoder = new TextEncoder();
314
+ const DEFAULT_FACE_COLOR = [0xf9 / 255, 0xd7 / 255, 0x2c / 255, 1];
315
+ const PAINT_COLOR_MAP = ["", "8", "0C", "1C", "2C", "3C", "4C", "5C", "6C", "7C", "8C", "9C", "AC", "BC", "CC", "DC"];
316
+ const CRC32_TABLE = (() => {
317
+ const table = new Uint32Array(256);
318
+ for (let n = 0; n < 256; n++) {
319
+ let c = n;
320
+ for (let k = 0; k < 8; k++) {
321
+ c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
322
+ }
323
+ table[n] = c >>> 0;
324
+ }
325
+ return table;
326
+ })();
327
+
328
+ const crc32 = (bytes) => {
329
+ let crc = 0xffffffff;
330
+ for (let i = 0; i < bytes.length; i++) {
331
+ crc = CRC32_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
332
+ }
333
+ return (crc ^ 0xffffffff) >>> 0;
334
+ };
335
+
336
+ const concatUint8Arrays = (chunks) => {
337
+ const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
338
+ const out = new Uint8Array(total);
339
+ let offset = 0;
340
+ for (const chunk of chunks) {
341
+ out.set(chunk, offset);
342
+ offset += chunk.length;
343
+ }
344
+ return out;
345
+ };
346
+
347
+ const createZip = (files) => {
348
+ const localChunks = [];
349
+ const centralChunks = [];
350
+ let offset = 0;
351
+
352
+ for (const [name, dataLike] of Object.entries(files)) {
353
+ const nameBytes = textEncoder.encode(name);
354
+ const data = toUint8Array(dataLike);
355
+ const crc = crc32(data);
356
+ const localHeader = new Uint8Array(30 + nameBytes.length);
357
+ const localView = new DataView(localHeader.buffer);
358
+ localView.setUint32(0, 0x04034b50, true);
359
+ localView.setUint16(4, 20, true);
360
+ localView.setUint16(6, 0, true);
361
+ localView.setUint16(8, 0, true);
362
+ localView.setUint16(10, 0, true);
363
+ localView.setUint16(12, 0, true);
364
+ localView.setUint32(14, crc, true);
365
+ localView.setUint32(18, data.length, true);
366
+ localView.setUint32(22, data.length, true);
367
+ localView.setUint16(26, nameBytes.length, true);
368
+ localView.setUint16(28, 0, true);
369
+ localHeader.set(nameBytes, 30);
370
+ localChunks.push(localHeader, data);
371
+
372
+ const centralHeader = new Uint8Array(46 + nameBytes.length);
373
+ const centralView = new DataView(centralHeader.buffer);
374
+ centralView.setUint32(0, 0x02014b50, true);
375
+ centralView.setUint16(4, 20, true);
376
+ centralView.setUint16(6, 20, true);
377
+ centralView.setUint16(8, 0, true);
378
+ centralView.setUint16(10, 0, true);
379
+ centralView.setUint16(12, 0, true);
380
+ centralView.setUint16(14, 0, true);
381
+ centralView.setUint32(16, crc, true);
382
+ centralView.setUint32(20, data.length, true);
383
+ centralView.setUint32(24, data.length, true);
384
+ centralView.setUint16(28, nameBytes.length, true);
385
+ centralView.setUint16(30, 0, true);
386
+ centralView.setUint16(32, 0, true);
387
+ centralView.setUint16(34, 0, true);
388
+ centralView.setUint16(36, 0, true);
389
+ centralView.setUint32(38, 0, true);
390
+ centralView.setUint32(42, offset, true);
391
+ centralHeader.set(nameBytes, 46);
392
+ centralChunks.push(centralHeader);
393
+
394
+ offset += localHeader.length + data.length;
395
+ }
396
+
397
+ const centralDirectory = concatUint8Arrays(centralChunks);
398
+ const eocd = new Uint8Array(22);
399
+ const eocdView = new DataView(eocd.buffer);
400
+ eocdView.setUint32(0, 0x06054b50, true);
401
+ eocdView.setUint16(4, 0, true);
402
+ eocdView.setUint16(6, 0, true);
403
+ eocdView.setUint16(8, centralChunks.length, true);
404
+ eocdView.setUint16(10, centralChunks.length, true);
405
+ eocdView.setUint32(12, centralDirectory.length, true);
406
+ eocdView.setUint32(16, offset, true);
407
+ eocdView.setUint16(20, 0, true);
408
+
409
+ return concatUint8Arrays([...localChunks, centralDirectory, eocd]);
410
+ };
411
+
412
+ const toHexByte = (value) => Math.round(Math.max(0, Math.min(1, value)) * 255).toString(16).padStart(2, "0").toUpperCase();
413
+
414
+ const colorToDisplayColor = ([r, g, b, a = 1]) => {
415
+ const base = `#${toHexByte(r)}${toHexByte(g)}${toHexByte(b)}`;
416
+ return a < 1 ? `${base}${toHexByte(a)}` : base;
417
+ };
418
+
419
+ const createUuid = () =>
420
+ (globalThis.crypto?.randomUUID?.() ||
421
+ `${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}-${Math.random().toString(16).slice(2, 10)}`);
422
+
423
+ const parseOffToIndexedPolyhedron = (offData) => {
424
+ const arrayBuffer = offData.buffer.slice(
425
+ offData.byteOffset,
426
+ offData.byteOffset + offData.byteLength,
427
+ );
428
+ const text = new TextDecoder().decode(arrayBuffer);
429
+ const lines = text
430
+ .split("\n")
431
+ .map((line) => line.trim())
432
+ .filter((line) => line.length > 0 && !line.startsWith("#"));
433
+
434
+ if (lines.length === 0) {
435
+ throw new Error(i18nGet("openscad-render-failed", "OpenSCAD render failed"));
436
+ }
437
+
438
+ let countsLine = "";
439
+ let currentLine = 0;
440
+ if (/^OFF(\s|$)/.test(lines[0])) {
441
+ countsLine = lines[0].substring(3).trim();
442
+ currentLine = 1;
443
+ } else if (lines[0] === "OFF" && lines.length > 1) {
444
+ countsLine = lines[1];
445
+ currentLine = 2;
446
+ } else {
447
+ throw new Error(i18nGet("openscad-render-failed", "OpenSCAD render failed"));
448
+ }
449
+
450
+ const [vertexCountRaw, faceCountRaw] = countsLine.split(/\s+/).map(Number);
451
+ const vertexCount = Number.isFinite(vertexCountRaw) ? Math.floor(vertexCountRaw) : NaN;
452
+ const faceCount = Number.isFinite(faceCountRaw) ? Math.floor(faceCountRaw) : NaN;
453
+ if (!Number.isFinite(vertexCount) || !Number.isFinite(faceCount) || vertexCount <= 0 || faceCount <= 0) {
454
+ throw new Error(i18nGet("openscad-render-failed", "OpenSCAD render failed"));
455
+ }
456
+ if (currentLine + vertexCount + faceCount > lines.length) {
457
+ throw new Error(i18nGet("openscad-render-failed", "OpenSCAD render failed"));
458
+ }
459
+
460
+ const vertices = [];
461
+ for (let i = 0; i < vertexCount; i++) {
462
+ const parts = lines[currentLine + i].split(/\s+/).map(Number);
463
+ if (parts.length < 3 || parts.some(Number.isNaN)) {
464
+ throw new Error(i18nGet("openscad-render-failed", "OpenSCAD render failed"));
465
+ }
466
+ vertices.push({ x: parts[0], y: parts[1], z: parts[2] });
467
+ }
468
+ currentLine += vertexCount;
469
+
470
+ const colors = [];
471
+ const colorMap = new Map();
472
+ const faces = [];
473
+
474
+ for (let i = 0; i < faceCount; i++) {
475
+ const parts = lines[currentLine + i].split(/\s+/).map(Number);
476
+ const numVerts = Number.isFinite(parts[0]) ? Math.floor(parts[0]) : 0;
477
+ const faceVertices = parts.slice(1, numVerts + 1).map((index) => Math.floor(index));
478
+ if (faceVertices.length < 3) {
479
+ throw new Error(i18nGet("openscad-render-failed", "OpenSCAD render failed"));
480
+ }
481
+
482
+ let color = DEFAULT_FACE_COLOR;
483
+ if (parts.length >= numVerts + 4) {
484
+ const raw = parts.slice(numVerts + 1, numVerts + 5).filter(Number.isFinite);
485
+ if (raw.length >= 3) {
486
+ const r = raw[0];
487
+ const g = raw[1];
488
+ const b = raw[2];
489
+ const a = raw.length >= 4 ? raw[3] : (Math.max(r, g, b) > 1 ? 255 : 1);
490
+ const divisor = Math.max(r, g, b, a) > 1 ? 255 : 1;
491
+ color = [r / divisor, g / divisor, b / divisor, a / divisor];
492
+ }
493
+ }
494
+
495
+ const colorKey = color.join(",");
496
+ let colorIndex = colorMap.get(colorKey);
497
+ if (colorIndex == null) {
498
+ colorIndex = colors.length;
499
+ colors.push(color);
500
+ colorMap.set(colorKey, colorIndex);
501
+ }
502
+
503
+ if (faceVertices.length === 3) {
504
+ faces.push({ vertices: faceVertices, colorIndex });
505
+ } else {
506
+ for (let j = 1; j < faceVertices.length - 1; j++) {
507
+ faces.push({
508
+ vertices: [faceVertices[0], faceVertices[j], faceVertices[j + 1]],
509
+ colorIndex,
510
+ });
511
+ }
512
+ }
513
+ }
514
+
515
+ return { vertices, faces, colors };
516
+ };
517
+
518
+ const buildThreeModelFromIndexedPolyhedron = (polyhedron, THREE) => {
519
+ const model = new THREE.Group();
520
+ const facesByColor = new Map();
521
+
522
+ for (const face of polyhedron.faces) {
523
+ const [i1, i2, i3] = face.vertices;
524
+ const v1 = polyhedron.vertices[i1];
525
+ const v2 = polyhedron.vertices[i2];
526
+ const v3 = polyhedron.vertices[i3];
527
+ if (!v1 || !v2 || !v3) continue;
528
+ const color = polyhedron.colors[face.colorIndex] || DEFAULT_FACE_COLOR;
529
+ const colorKey = color.join(",");
530
+ let bucket = facesByColor.get(colorKey);
531
+ if (!bucket) {
532
+ bucket = { color, positions: [] };
533
+ facesByColor.set(colorKey, bucket);
534
+ }
535
+ bucket.positions.push(v1.x, v1.y, v1.z, v2.x, v2.y, v2.z, v3.x, v3.y, v3.z);
536
+ }
537
+
538
+ if (facesByColor.size === 0) {
539
+ throw new Error(i18nGet("openscad-render-failed", "OpenSCAD render failed"));
540
+ }
541
+
542
+ for (const bucket of facesByColor.values()) {
543
+ const geometry = new THREE.BufferGeometry();
544
+ geometry.setAttribute("position", new THREE.Float32BufferAttribute(bucket.positions, 3));
545
+ geometry.computeBoundingBox();
546
+ geometry.computeVertexNormals();
547
+ const [r, g, b, a = 1] = bucket.color;
548
+ const material = new THREE.MeshStandardMaterial({
549
+ color: new THREE.Color(r, g, b),
550
+ transparent: a < 1,
551
+ opacity: a,
552
+ metalness: 0.1,
553
+ roughness: 0.6,
554
+ });
555
+ model.add(new THREE.Mesh(geometry, material));
556
+ }
557
+
558
+ return model;
559
+ };
560
+
561
+ // Build a Three.js Group from pre-parsed colour buckets returned by the render worker.
562
+ // The Float32Array buffers are already computed off the main thread — no text parsing needed.
563
+ const buildThreeModelFromColorBuckets = (colorBuckets, THREE) => {
564
+ const model = new THREE.Group();
565
+ for (const bucket of colorBuckets) {
566
+ const posArray = bucket.positions instanceof Float32Array
567
+ ? bucket.positions : new Float32Array(bucket.positions || []);
568
+ const normArray = bucket.normals instanceof Float32Array
569
+ ? bucket.normals : new Float32Array(bucket.normals || []);
570
+ if (posArray.length === 0) continue;
571
+ const geometry = new THREE.BufferGeometry();
572
+ geometry.setAttribute("position", new THREE.Float32BufferAttribute(posArray, 3));
573
+ geometry.setAttribute("normal", new THREE.Float32BufferAttribute(normArray, 3));
574
+ const [r, g, b, a = 1] = bucket.color || DEFAULT_FACE_COLOR;
575
+ const material = new THREE.MeshStandardMaterial({
576
+ color: new THREE.Color(r, g, b),
577
+ transparent: a < 1,
578
+ opacity: a,
579
+ metalness: 0.1,
580
+ roughness: 0.6,
581
+ });
582
+ model.add(new THREE.Mesh(geometry, material));
583
+ }
584
+ if (model.children.length === 0) {
585
+ throw new Error(i18nGet("openscad-render-failed", "OpenSCAD render failed"));
586
+ }
587
+ return model;
588
+ };
589
+
590
+ const exportIndexedPolyhedronTo3mf = (polyhedron) => {
591
+ const objectUuid = createUuid();
592
+ const buildUuid = createUuid();
593
+ const extruderIndexByColorIndex = polyhedron.colors.map((_, idx) => idx % PAINT_COLOR_MAP.length);
594
+
595
+ const modelXml = [
596
+ '<?xml version="1.0" encoding="utf-8"?>',
597
+ '<model unit="millimeter" xml:lang="en-US" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06">',
598
+ '<meta name="BambuStudio:3mfVersion" value="1"/>',
599
+ '<meta name="slic3rpe:Version3mf" value="1"/>',
600
+ '<meta name="slic3rpe:MmPaintingVersion" value="1"/>',
601
+ "<resources>",
602
+ '<basematerials id="2">',
603
+ ...polyhedron.colors.map((color, i) => `<base name="color_${i}" displaycolor="${colorToDisplayColor(color)}"/>`),
604
+ "</basematerials>",
605
+ `<object id="1" name="OpenSCAD Model" type="model" p:UUID="${objectUuid}" pid="2" pindex="0">`,
606
+ "<mesh>",
607
+ "<vertices>",
608
+ ...polyhedron.vertices.map((vertex) => `<vertex x="${vertex.x}" y="${vertex.y}" z="${vertex.z}" />`),
609
+ "</vertices>",
610
+ "<triangles>",
611
+ ...polyhedron.faces.map((face) => {
612
+ const [v1, v2, v3] = face.vertices;
613
+ const attrs = [`v1="${v1}"`, `v2="${v2}"`, `v3="${v3}"`];
614
+ if (face.colorIndex > 0) {
615
+ attrs.push(`pid="2"`, `p1="${face.colorIndex}"`);
616
+ }
617
+ const paintColor = PAINT_COLOR_MAP[extruderIndexByColorIndex[face.colorIndex]];
618
+ if (paintColor) {
619
+ attrs.push(`paint_color="${paintColor}"`);
620
+ }
621
+ return `<triangle ${attrs.join(" ")} />`;
622
+ }),
623
+ "</triangles>",
624
+ "</mesh>",
625
+ "</object>",
626
+ "</resources>",
627
+ `<build p:UUID="${buildUuid}">`,
628
+ `<item objectid="1" p:UUID="${objectUuid}"/>`,
629
+ "</build>",
630
+ "</model>",
631
+ ].join("\n");
632
+
633
+ const contentTypesXml = [
634
+ '<?xml version="1.0" encoding="utf-8"?>',
635
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">',
636
+ '<Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>',
637
+ "</Types>",
638
+ ].join("\n");
639
+
640
+ const relationshipsXml = [
641
+ '<?xml version="1.0" encoding="utf-8"?>',
642
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">',
643
+ '<Relationship Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel" Target="/3D/3dmodel.model" Id="rel0"/>',
644
+ "</Relationships>",
645
+ ].join("\n");
646
+
647
+ return createZip({
648
+ "3D/3dmodel.model": textEncoder.encode(modelXml),
649
+ "[Content_Types].xml": textEncoder.encode(contentTypesXml),
650
+ "_rels/.rels": textEncoder.encode(relationshipsXml),
651
+ });
652
+ };
653
+
422
654
  function initElement(elem) {
423
655
  if (elem.getAttribute("data-openscad-initialized") === "true") return;
424
656
  elem.setAttribute("data-openscad-initialized", "true");
@@ -435,18 +667,39 @@ hyperbook.openscad = (function () {
435
667
  const splitter = elem.querySelector(".splitter");
436
668
  const canvasParamsSplitter = elem.querySelector(".canvas-params-splitter");
437
669
  const canvas = elem.querySelector(".preview-canvas");
438
- const editor = elem.querySelector("code-input.editor");
670
+ const editorDiv = elem.querySelector(".editor");
439
671
  const params = elem.querySelector("textarea.parameters");
440
672
 
673
+ // `cm` will be initialized after scheduleSave/scheduleParamBuild are defined.
674
+ let cm = null;
675
+
441
676
  // The parameters panel is its own card below the canvas.
442
677
  const paramsPanel = elem.querySelector(".parameters-panel");
443
678
  const paramsForm = paramsPanel?.querySelector(".parameters-body") ?? paramsPanel;
444
679
 
445
680
  const renderBtn = elem.querySelector("button.render");
446
681
  const copyBtn = elem.querySelector("button.copy");
447
- const downloadStlBtn = elem.querySelector("button.download-stl");
682
+ const downloadBtn = elem.querySelector("button.download-stl");
448
683
  const resetBtn = elem.querySelector("button.reset");
449
684
  const fullscreenBtn = elem.querySelector("button.fullscreen");
685
+ const bottomButtons = elem.querySelector(".buttons.bottom");
686
+ let downloadFormatSelect = bottomButtons?.querySelector("select.download-format");
687
+ if (!downloadFormatSelect && bottomButtons && downloadBtn) {
688
+ downloadFormatSelect = document.createElement("select");
689
+ downloadFormatSelect.className = "download-format";
690
+ downloadFormatSelect.setAttribute("aria-label", i18nGet("openscad-download-format", "Download format"));
691
+ const stlOption = document.createElement("option");
692
+ stlOption.value = "stl";
693
+ stlOption.textContent = "STL";
694
+ const threeMfOption = document.createElement("option");
695
+ threeMfOption.value = "3mf";
696
+ threeMfOption.textContent = "3MF";
697
+ downloadFormatSelect.append(stlOption, threeMfOption);
698
+ bottomButtons.insertBefore(downloadFormatSelect, downloadBtn);
699
+ }
700
+ if (downloadBtn) {
701
+ downloadBtn.textContent = i18nGet("openscad-download", "Download");
702
+ }
450
703
 
451
704
  // --- Canvas overlay ---
452
705
  let overlayDismissTimer = null;
@@ -486,6 +739,30 @@ hyperbook.openscad = (function () {
486
739
  }
487
740
  };
488
741
 
742
+ const viewerState = {
743
+ renderer: null,
744
+ camera: null,
745
+ scene: null,
746
+ controls: null,
747
+ model: null,
748
+ renderRaf: 0,
749
+ resizeRaf: 0,
750
+ disposed: false,
751
+ resizeObserver: null,
752
+ };
753
+
754
+ const requestRender = () => {
755
+ if (viewerState.renderRaf || viewerState.disposed) return;
756
+ viewerState.renderRaf = requestAnimationFrame(() => {
757
+ viewerState.renderRaf = 0;
758
+ if (viewerState.disposed) return;
759
+ if (viewerState.controls) viewerState.controls.update();
760
+ if (viewerState.renderer && viewerState.scene && viewerState.camera) {
761
+ viewerState.renderer.render(viewerState.scene, viewerState.camera);
762
+ }
763
+ });
764
+ };
765
+
489
766
  // Resize the Three.js renderer to match the current canvas-wrapper size.
490
767
  const resizeCanvas = () => {
491
768
  if (!viewerState.renderer || !viewerState.camera || !canvasWrapper) return;
@@ -494,28 +771,26 @@ hyperbook.openscad = (function () {
494
771
  viewerState.renderer.setSize(w, h, false);
495
772
  viewerState.camera.aspect = w / h;
496
773
  viewerState.camera.updateProjectionMatrix();
774
+ requestRender();
775
+ };
776
+
777
+ const scheduleResizeCanvas = () => {
778
+ if (viewerState.resizeRaf || viewerState.disposed) return;
779
+ viewerState.resizeRaf = requestAnimationFrame(() => {
780
+ viewerState.resizeRaf = 0;
781
+ resizeCanvas();
782
+ });
497
783
  };
498
784
 
499
785
  const applyMainSplitSize = setupSplitter(elem, leftSide, editorContainer, splitter, () => {
500
- resizeCanvas();
501
- save();
786
+ scheduleResizeCanvas();
787
+ scheduleSave();
502
788
  });
503
789
  const applyCanvasParamsSplitSize = setupCanvasParamsSplitter(leftSide, previewContainer, paramsPanel, canvasParamsSplitter, () => {
504
- resizeCanvas();
505
- save();
790
+ scheduleResizeCanvas();
791
+ scheduleSave();
506
792
  });
507
793
 
508
- const viewerState = {
509
- renderer: null,
510
- camera: null,
511
- scene: null,
512
- controls: null,
513
- mesh: null,
514
- raf: 0,
515
- disposed: false,
516
- resizeObserver: null,
517
- };
518
-
519
794
  const save = async () => {
520
795
  if (!id) return;
521
796
  const splitHorizontal = Number(elem.dataset.splitHorizontal);
@@ -523,7 +798,7 @@ hyperbook.openscad = (function () {
523
798
  const splitCanvasParams = Number(leftSide?.dataset.splitCanvasParams);
524
799
  await hyperbook.store.db.openscad.put({
525
800
  id,
526
- code: editor?.value || "",
801
+ code: cm?.getValue() || "",
527
802
  params: params?.value || "{}",
528
803
  ...(Number.isFinite(splitHorizontal) && splitHorizontal > 0
529
804
  ? { splitHorizontal: Math.round(splitHorizontal) }
@@ -541,8 +816,8 @@ hyperbook.openscad = (function () {
541
816
  if (!id) return null;
542
817
  const result = await hyperbook.store.db.openscad.get(id);
543
818
  if (!result) return null;
544
- if (editor && typeof result.code === "string") {
545
- editor.value = result.code;
819
+ if (cm && typeof result.code === "string") {
820
+ cm.setValue(result.code);
546
821
  }
547
822
  if (params && typeof result.params === "string") {
548
823
  params.value = result.params;
@@ -559,195 +834,241 @@ hyperbook.openscad = (function () {
559
834
  return result;
560
835
  };
561
836
 
837
+ const SAVE_DEBOUNCE_MS = 250;
838
+ const PARAM_REBUILD_DEBOUNCE_MS = 900;
839
+ const PARAM_RENDER_DEBOUNCE_MS = 600;
840
+ let saveTimer = 0;
841
+ let paramRebuildTimer = 0;
842
+ let paramRenderTimer = 0;
843
+ let pendingParamCode = null;
844
+ let lastBuiltParamCode = null;
845
+ let paramBuildInFlight = false;
846
+ let latestParamBuildToken = 0;
847
+ // Suppresses scheduleParamBuild when code is updated programmatically from a param change.
848
+ let suppressParamBuild = false;
849
+
850
+ const scheduleSave = () => {
851
+ clearTimeout(saveTimer);
852
+ saveTimer = window.setTimeout(() => {
853
+ const runSave = () => { void save(); };
854
+ if (typeof window.requestIdleCallback === "function") {
855
+ window.requestIdleCallback(runSave, { timeout: 500 });
856
+ } else {
857
+ runSave();
858
+ }
859
+ }, SAVE_DEBOUNCE_MS);
860
+ };
861
+
862
+ let paramValues = {};
863
+
864
+ // Surgically replaces the value of a top-level variable assignment in SCAD source,
865
+ // preserving any trailing inline comment (e.g. // [min:max] annotations).
866
+ const updateVariableInCode = (code, name, value) => {
867
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
868
+ const pattern = new RegExp(
869
+ `^([ \\t]*${escaped}[ \\t]*=[ \\t]*)([^;\\n]*)(;[^\\n]*)$`,
870
+ "m",
871
+ );
872
+ try {
873
+ return code.replace(pattern, `$1${formatValue(value)}$3`);
874
+ } catch (_) {
875
+ return code;
876
+ }
877
+ };
878
+
879
+ const syncParamsTextareaFromState = () => {
880
+ if (params) {
881
+ params.value = JSON.stringify(paramValues);
882
+ }
883
+ };
884
+
885
+ const handleParamFieldEvent = (event) => {
886
+ const target = event?.target;
887
+ const name = target?.dataset?.paramName;
888
+ const kind = target?.dataset?.paramKind;
889
+ if (!name || !kind) return;
890
+
891
+ if (kind === "boolean") {
892
+ paramValues[name] = Boolean(target.checked);
893
+ } else if (kind === "number") {
894
+ paramValues[name] = Number(target.value);
895
+ } else if (kind === "vector") {
896
+ const vectorContainer = target.closest(".param-vector");
897
+ if (!vectorContainer) return;
898
+ const vectorInputs = Array.from(
899
+ vectorContainer.querySelectorAll('input[data-param-kind="vector"]'),
900
+ ).filter((input) => input.dataset.paramName === name);
901
+ paramValues[name] = vectorInputs.map((input) => Number(input.value));
902
+ } else if (kind === "option") {
903
+ const selected = target.options?.[target.selectedIndex];
904
+ const raw = selected?.dataset?.paramOptionValue;
905
+ if (typeof raw === "string") {
906
+ try {
907
+ paramValues[name] = JSON.parse(raw);
908
+ } catch (_) {
909
+ paramValues[name] = target.value;
910
+ }
911
+ } else {
912
+ paramValues[name] = target.value;
913
+ }
914
+ } else {
915
+ paramValues[name] = target.value;
916
+ }
917
+
918
+ syncParamsTextareaFromState();
919
+
920
+ // Reflect the new value back into the editor source code.
921
+ const currentCode = cm?.getValue() || "";
922
+ const updatedCode = updateVariableInCode(currentCode, name, paramValues[name]);
923
+ if (updatedCode !== currentCode) {
924
+ suppressParamBuild = true;
925
+ cm?.setValue(updatedCode);
926
+ suppressParamBuild = false;
927
+ }
928
+
929
+ scheduleParamRender();
930
+ scheduleSave();
931
+ };
932
+
933
+ paramsForm?.addEventListener("input", handleParamFieldEvent);
934
+ paramsForm?.addEventListener("change", handleParamFieldEvent);
935
+
562
936
  // Rebuild the parameters form from the code's top-level variable assignments.
563
937
  // Stored overrides from the textarea are preserved so user edits survive
564
938
  // code changes that don't touch those variable names.
565
- const buildParamForm = async (code) => {
566
- // Show a loading indicator while WASM extracts params.
939
+ const buildParamForm = async (code, buildToken) => {
940
+ // Show a loading indicator while WASM extracts params and builds UI model.
567
941
  paramsForm.innerHTML = "";
568
- paramsPanel?.classList.remove("hidden");
569
- canvasParamsSplitter?.classList.remove("hidden");
570
942
  const loading = document.createElement("p");
571
943
  loading.className = "params-empty";
572
944
  loading.textContent = i18nGet("openscad-params-loading", "Loading parameters...");
573
945
  paramsForm.appendChild(loading);
574
946
 
575
- const codeParams = await extractParams(code, libraryNames);
576
- paramsForm.innerHTML = "";
577
-
578
- if (codeParams.length === 0) {
579
- paramsPanel?.classList.add("hidden");
580
- canvasParamsSplitter?.classList.add("hidden");
947
+ // Code is the source of truth — param changes are always synced back to the
948
+ // code, so we never need stored overrides to win over the code's own values.
949
+ const result = await buildParamUiInWorker(code, libraryNames, {}, id || "model");
950
+ if (buildToken !== latestParamBuildToken) {
951
+ return false;
952
+ }
953
+ if (!result.hasParams) {
954
+ paramsForm.innerHTML = "";
955
+ const empty = document.createElement("p");
956
+ empty.className = "params-empty";
957
+ empty.textContent = i18nGet("openscad-params-none", "No parameters");
958
+ paramsForm.appendChild(empty);
959
+ paramValues = {};
581
960
  if (params) params.value = "{}";
582
- return;
961
+ return true;
583
962
  }
584
963
 
585
- let currentOverrides = {};
586
- try {
587
- currentOverrides = JSON.parse(params?.value || "{}");
588
- } catch (_) {}
589
-
590
- const syncTextarea = () => {
591
- const values = {};
592
- paramsForm.querySelectorAll("[data-param-name]").forEach((input) => {
593
- const name = input.dataset.paramName;
594
- const type = input.dataset.paramType;
595
- if (type === "boolean") {
596
- values[name] = input.checked;
597
- } else if (type === "number") {
598
- values[name] = Number(input.value);
599
- } else {
600
- values[name] = input.value;
964
+ // Preserve accordion open/closed state by group name before replacing HTML.
965
+ const accordionStates = new Map();
966
+ paramsForm.querySelectorAll("details.param-group").forEach((details) => {
967
+ const summary = details.querySelector("summary.param-group-summary");
968
+ if (summary) {
969
+ accordionStates.set(summary.textContent.trim(), details.open);
970
+ }
971
+ });
972
+
973
+ paramsForm.innerHTML = result.html;
974
+
975
+ // Restore accordion states after the rebuild.
976
+ if (accordionStates.size > 0) {
977
+ paramsForm.querySelectorAll("details.param-group").forEach((details) => {
978
+ const summary = details.querySelector("summary.param-group-summary");
979
+ if (summary) {
980
+ const name = summary.textContent.trim();
981
+ if (accordionStates.has(name)) {
982
+ details.open = accordionStates.get(name);
983
+ }
601
984
  }
602
985
  });
603
- if (params) params.value = JSON.stringify(values);
604
- save();
605
- };
986
+ }
606
987
 
607
- codeParams.forEach(({ name, caption, type, initial, min, max, step, options }) => {
608
- const current =
609
- currentOverrides[name] !== undefined ? currentOverrides[name] : initial;
610
-
611
- const row = document.createElement("div");
612
- row.className = "param-row";
613
-
614
- const label = document.createElement("label");
615
- label.textContent = caption || name;
616
- label.setAttribute("for", `openscad-param-${id}-${name}`);
617
-
618
- let input;
619
- if (type === "boolean") {
620
- input = document.createElement("input");
621
- input.type = "checkbox";
622
- input.checked = Boolean(current);
623
- } else if (options && options.length > 0) {
624
- // Dropdown for parameters with a fixed set of options.
625
- input = document.createElement("select");
626
- options.forEach(({ name: optName, value: optValue }) => {
627
- const opt = document.createElement("option");
628
- opt.value = String(optValue);
629
- opt.textContent = optName || String(optValue);
630
- if (String(optValue) === String(current)) opt.selected = true;
631
- input.appendChild(opt);
632
- });
633
- input.addEventListener("change", syncTextarea);
634
- } else if (type === "number" && Array.isArray(initial)) {
635
- // Vector: render one number input per component.
636
- input = document.createElement("span");
637
- input.className = "param-vector";
638
- const arr = Array.isArray(current) ? current : initial;
639
- arr.forEach((val, idx) => {
640
- const ni = document.createElement("input");
641
- ni.type = "number";
642
- ni.value = String(val);
643
- ni.step = step != null ? String(step) : "any";
644
- if (min != null) ni.min = String(min);
645
- if (max != null) ni.max = String(max);
646
- ni.dataset.paramName = name;
647
- ni.dataset.paramType = "vector";
648
- ni.dataset.vectorIndex = String(idx);
649
- ni.addEventListener("input", () => {
650
- const all = Array.from(input.querySelectorAll("input")).map(
651
- (i) => Number(i.value)
652
- );
653
- const sibling = paramsForm.querySelector(
654
- `[data-param-name="${name}"][data-param-type="number"]`
655
- );
656
- if (sibling) sibling.value = JSON.stringify(all);
657
- syncTextarea();
658
- });
659
- input.appendChild(ni);
660
- });
661
- // Hidden input holds the JSON array for syncTextarea to read.
662
- const hidden = document.createElement("input");
663
- hidden.type = "hidden";
664
- hidden.dataset.paramName = name;
665
- hidden.dataset.paramType = "number";
666
- hidden.value = JSON.stringify(arr);
667
- input.appendChild(hidden);
668
- } else if (type === "number") {
669
- input = document.createElement("input");
670
- input.type = "number";
671
- input.value = String(current);
672
- input.step = step != null ? String(step) : "any";
673
- if (min != null) input.min = String(min);
674
- if (max != null) input.max = String(max);
675
- } else {
676
- input = document.createElement("input");
677
- input.type = "text";
678
- input.value = String(current);
679
- }
680
- input.id = `openscad-param-${id}-${name}`;
681
- if (input.tagName !== "SPAN") {
682
- input.dataset.paramName = name;
683
- input.dataset.paramType = type;
684
- input.addEventListener("input", syncTextarea);
685
- }
988
+ paramValues = result.values || {};
989
+ syncParamsTextareaFromState();
990
+ scheduleSave();
991
+ return true;
992
+ };
686
993
 
687
- row.appendChild(label);
688
- row.appendChild(input);
689
- paramsForm.appendChild(row);
690
- });
994
+ const runPendingParamBuild = async () => {
995
+ if (paramBuildInFlight) return;
996
+ while (pendingParamCode !== null && pendingParamCode !== lastBuiltParamCode) {
997
+ const nextCode = pendingParamCode;
998
+ const nextToken = latestParamBuildToken;
999
+ pendingParamCode = null;
1000
+ paramBuildInFlight = true;
1001
+ try {
1002
+ const wasApplied = await buildParamForm(nextCode, nextToken);
1003
+ if (wasApplied && nextToken === latestParamBuildToken) {
1004
+ lastBuiltParamCode = nextCode;
1005
+ }
1006
+ } finally {
1007
+ paramBuildInFlight = false;
1008
+ }
1009
+ }
1010
+ };
691
1011
 
692
- syncTextarea();
1012
+ const scheduleParamBuild = (code) => {
1013
+ latestParamBuildToken += 1;
1014
+ pendingParamCode = code;
1015
+ clearTimeout(paramRebuildTimer);
1016
+ paramRebuildTimer = window.setTimeout(() => {
1017
+ void runPendingParamBuild();
1018
+ }, PARAM_REBUILD_DEBOUNCE_MS);
693
1019
  };
694
1020
 
695
1021
  const getParamDefinitions = () => {
696
- const parsed = JSON.parse(params?.value || "{}");
1022
+ const parsed = paramValues;
697
1023
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
698
1024
  throw new Error(i18nGet("openscad-params-object", "Parameters must be a JSON object"));
699
1025
  }
700
1026
  return Object.entries(parsed).map(([k, v]) => `-D${k}=${formatValue(v)}`);
701
1027
  };
702
1028
 
703
- const renderWithFormat = async (format, libraryNames = []) => {
1029
+ const renderWithFormat = async (format, libraryNames = [], isPreview = false) => {
704
1030
  renderBtn?.setAttribute("disabled", "true");
705
1031
  showOverlay("loading", i18nGet("openscad-rendering", "Rendering..."));
706
1032
 
707
1033
  try {
708
- const paramDefinitions = getParamDefinitions();
709
- const openscad = await getOpenScad();
710
- const instance = openscad;
711
-
712
- if (libraryNames.length > 0) {
713
- await mountLibraries(instance, libraryNames);
1034
+ // Param values are always synced back into the editor code, so the code
1035
+ // itself is the single source of truth. Passing -D overrides is both
1036
+ // redundant and causes stale-value renders when the render fires before
1037
+ // the next param-build cycle completes.
1038
+ const result = await callWorker("render", "render", {
1039
+ code: cm?.getValue() || "",
1040
+ format,
1041
+ libraryNames,
1042
+ paramDefinitions: [],
1043
+ isPreview,
1044
+ });
1045
+ const stderr = getInvocationStderr(result);
1046
+ if (result?.error || result?.exitCode !== 0) {
1047
+ const error = new Error(result?.error || i18nGet("openscad-render-failed", "OpenSCAD render failed"));
1048
+ error.stderr = stderr;
1049
+ throw error;
714
1050
  }
715
-
716
- const sourcePath = "/tmp/model.scad";
717
- const outPath = `/tmp/output.${format}`;
718
- const exportFormat = format === "stl" ? "binstl" : format;
719
-
720
- try {
721
- instance.FS.unlink(sourcePath);
722
- } catch (_) {}
723
- try {
724
- instance.FS.unlink(outPath);
725
- } catch (_) {}
726
-
727
- instance.FS.writeFile(sourcePath, editor?.value || "");
728
-
729
- const args = [
730
- sourcePath,
731
- "-o",
732
- outPath,
733
- `--export-format=${exportFormat}`,
734
- ...paramDefinitions,
735
- ];
736
-
737
- const exitCode = instance.callMain(args);
738
- if (exitCode !== 0) {
739
- throw new Error(i18nGet("openscad-render-failed", "OpenSCAD render failed"));
1051
+ // Worker returns pre-parsed geometry for preview OFF renders (avoids main-thread text parsing).
1052
+ if (result?.parsedGeometry) {
1053
+ return { parsedGeometry: result.parsedGeometry, stderr };
740
1054
  }
741
-
742
- const content = toUint8Array(instance.FS.readFile(outPath, { encoding: "binary" }));
743
- return content;
1055
+ const output = result?.outputs?.[0]?.[1];
1056
+ if (!output) {
1057
+ const error = new Error(i18nGet("openscad-render-failed", "OpenSCAD render failed"));
1058
+ error.stderr = stderr;
1059
+ throw error;
1060
+ }
1061
+ return {
1062
+ content: toUint8Array(output),
1063
+ stderr,
1064
+ };
744
1065
  } finally {
745
1066
  renderBtn?.removeAttribute("disabled");
746
1067
  }
747
1068
  };
748
1069
 
749
- const downloadBinary = (content, ext) => {
750
- const blob = new Blob([content], { type: "application/octet-stream" });
1070
+ const downloadBinary = (content, ext, mimeType = "application/octet-stream") => {
1071
+ const blob = new Blob([content], { type: mimeType });
751
1072
  const a = document.createElement("a");
752
1073
  a.href = URL.createObjectURL(blob);
753
1074
  a.download = `openscad-${id || "model"}.${ext}`;
@@ -756,18 +1077,17 @@ hyperbook.openscad = (function () {
756
1077
  };
757
1078
 
758
1079
  const renderPreview = async () => {
759
- openscadStderr = [];
760
1080
  try {
761
- // Ensure font bytes are fetched before creating the WASM instance
762
- await loadFonts();
763
1081
  await save();
764
- const stl = await renderWithFormat("stl", libraryNames);
765
- await renderStl(stl);
1082
+ const renderResult = await renderWithFormat("off", libraryNames, true);
1083
+ if (renderResult.parsedGeometry) {
1084
+ await renderOff({ colorBuckets: renderResult.parsedGeometry });
1085
+ } else {
1086
+ await renderOff(renderResult.content);
1087
+ }
766
1088
  hideOverlay();
767
1089
  } catch (error) {
768
- // Prefer actual OpenSCAD error lines over raw JS/C++ exception values.
769
- // emscripten throws C++ exceptions as raw numbers (WASM memory pointers).
770
- const stderrErrors = openscadStderr.filter((l) => /error/i.test(l)).join("\n");
1090
+ const stderrErrors = (error?.stderr || []).filter((l) => /error/i.test(l)).join("\n");
771
1091
  if (stderrErrors) {
772
1092
  showOverlay("error", stderrErrors);
773
1093
  } else if (typeof error === "number") {
@@ -778,8 +1098,29 @@ hyperbook.openscad = (function () {
778
1098
  }
779
1099
  };
780
1100
 
781
- const renderStl = async (stlData) => {
782
- const { THREE, STLLoader, OrbitControls } = await getThree();
1101
+ const scheduleParamRender = () => {
1102
+ clearTimeout(paramRenderTimer);
1103
+ paramRenderTimer = window.setTimeout(() => {
1104
+ void renderPreview();
1105
+ }, PARAM_RENDER_DEBOUNCE_MS);
1106
+ };
1107
+
1108
+ const disposeModel = () => {
1109
+ if (!viewerState.model || !viewerState.scene) return;
1110
+ viewerState.scene.remove(viewerState.model);
1111
+ viewerState.model.traverse((child) => {
1112
+ child.geometry?.dispose?.();
1113
+ if (Array.isArray(child.material)) {
1114
+ child.material.forEach((material) => material?.dispose?.());
1115
+ } else {
1116
+ child.material?.dispose?.();
1117
+ }
1118
+ });
1119
+ viewerState.model = null;
1120
+ };
1121
+
1122
+ const renderOff = async (offData) => {
1123
+ const { THREE, OrbitControls } = await getThree();
783
1124
  if (!canvas) return;
784
1125
 
785
1126
  if (!viewerState.renderer) {
@@ -805,19 +1146,10 @@ hyperbook.openscad = (function () {
805
1146
 
806
1147
  viewerState.camera = new THREE.PerspectiveCamera(45, 1, 0.1, 5000);
807
1148
  viewerState.controls = new OrbitControls(viewerState.camera, canvas);
808
- viewerState.controls.enableDamping = true;
809
-
810
- const tick = () => {
811
- if (viewerState.disposed) return;
812
- if (viewerState.controls) viewerState.controls.update();
813
- if (viewerState.renderer && viewerState.scene && viewerState.camera) {
814
- viewerState.renderer.render(viewerState.scene, viewerState.camera);
815
- }
816
- viewerState.raf = requestAnimationFrame(tick);
817
- };
818
- tick();
1149
+ viewerState.controls.enableDamping = false;
1150
+ viewerState.controls.addEventListener("change", requestRender);
819
1151
 
820
- viewerState.resizeObserver = new ResizeObserver(() => resizeCanvas());
1152
+ viewerState.resizeObserver = new ResizeObserver(() => scheduleResizeCanvas());
821
1153
  viewerState.resizeObserver.observe(canvasWrapper || previewContainer);
822
1154
  }
823
1155
 
@@ -828,38 +1160,27 @@ hyperbook.openscad = (function () {
828
1160
  viewerState.camera.aspect = width / height;
829
1161
  viewerState.camera.updateProjectionMatrix();
830
1162
 
831
- if (viewerState.mesh) {
832
- viewerState.scene.remove(viewerState.mesh);
833
- viewerState.mesh.geometry?.dispose();
834
- }
1163
+ disposeModel();
835
1164
 
836
- const loader = new STLLoader();
837
- const arrayBuffer = stlData.buffer.slice(
838
- stlData.byteOffset,
839
- stlData.byteOffset + stlData.byteLength,
840
- );
841
- const geometry = loader.parse(arrayBuffer);
842
- geometry.computeBoundingBox();
843
- geometry.computeVertexNormals();
844
-
845
- const material = new THREE.MeshStandardMaterial({
846
- color: 0x3b82f6,
847
- metalness: 0.1,
848
- roughness: 0.6,
849
- });
850
- const mesh = new THREE.Mesh(geometry, material);
851
- viewerState.mesh = mesh;
852
- viewerState.scene.add(mesh);
1165
+ const polyhedron = offData?.colorBuckets ? null : parseOffToIndexedPolyhedron(offData);
1166
+ const model = offData?.colorBuckets
1167
+ ? buildThreeModelFromColorBuckets(offData.colorBuckets, THREE)
1168
+ : buildThreeModelFromIndexedPolyhedron(polyhedron, THREE);
1169
+ viewerState.model = model;
1170
+ viewerState.scene.add(model);
853
1171
 
854
- const box = geometry.boundingBox;
1172
+ const box = new THREE.Box3().setFromObject(model);
1173
+ if (box.isEmpty()) {
1174
+ throw new Error(i18nGet("openscad-render-failed", "OpenSCAD render failed"));
1175
+ }
855
1176
  const size = new THREE.Vector3();
856
1177
  const center = new THREE.Vector3();
857
1178
  box.getSize(size);
858
1179
  box.getCenter(center);
859
1180
 
860
- mesh.position.x = -center.x;
861
- mesh.position.y = -center.y;
862
- mesh.position.z = -center.z;
1181
+ model.position.x = -center.x;
1182
+ model.position.y = -center.y;
1183
+ model.position.z = -center.z;
863
1184
 
864
1185
  const maxDim = Math.max(size.x, size.y, size.z) || 1;
865
1186
  const distance = maxDim * 1.8;
@@ -871,6 +1192,7 @@ hyperbook.openscad = (function () {
871
1192
 
872
1193
  viewerState.controls.target.set(0, 0, 0);
873
1194
  viewerState.controls.update();
1195
+ requestRender();
874
1196
  };
875
1197
 
876
1198
  copyBtn?.addEventListener("click", async () => {
@@ -888,17 +1210,22 @@ hyperbook.openscad = (function () {
888
1210
 
889
1211
  renderBtn?.addEventListener("click", renderPreview);
890
1212
 
891
- downloadStlBtn?.addEventListener("click", async () => {
892
- openscadStderr = [];
1213
+ downloadBtn?.addEventListener("click", async () => {
893
1214
  try {
894
- await loadFonts();
895
1215
  await save();
896
- const stl = await renderWithFormat("stl", libraryNames);
897
- downloadBinary(stl, "stl");
898
- await renderStl(stl);
1216
+ const selectedFormat = downloadFormatSelect?.value === "3mf" ? "3mf" : "stl";
1217
+ if (selectedFormat === "3mf") {
1218
+ const { content: off } = await renderWithFormat("off", libraryNames);
1219
+ const polyhedron = parseOffToIndexedPolyhedron(off);
1220
+ const threeMf = exportIndexedPolyhedronTo3mf(polyhedron);
1221
+ downloadBinary(threeMf, "3mf", "model/3mf");
1222
+ } else {
1223
+ const { content: stl } = await renderWithFormat("stl", libraryNames);
1224
+ downloadBinary(stl, "stl");
1225
+ }
899
1226
  hideOverlay();
900
1227
  } catch (error) {
901
- const stderrErrors = openscadStderr.filter((l) => /error/i.test(l)).join("\n");
1228
+ const stderrErrors = (error?.stderr || []).filter((l) => /error/i.test(l)).join("\n");
902
1229
  showOverlay("error", stderrErrors || error?.message || `${error}`);
903
1230
  }
904
1231
  });
@@ -914,6 +1241,24 @@ hyperbook.openscad = (function () {
914
1241
  updateFullscreenButtonState(elem, fullscreenBtn);
915
1242
 
916
1243
  let editorStateRestored = false;
1244
+
1245
+ // Initialize CodeMirror now that scheduleSave/scheduleParamBuild are defined.
1246
+ if (editorDiv) {
1247
+ const initialSource = editorDiv.textContent;
1248
+ editorDiv.textContent = "";
1249
+ cm = HyperbookCM.create(editorDiv, {
1250
+ lang: editorDiv.dataset.lang || "clike",
1251
+ value: initialSource,
1252
+ onChange: (code) => {
1253
+ scheduleSave();
1254
+ if (!suppressParamBuild) {
1255
+ scheduleParamBuild(code);
1256
+ scheduleParamRender();
1257
+ }
1258
+ },
1259
+ });
1260
+ }
1261
+
917
1262
  const restoreEditorState = async () => {
918
1263
  if (editorStateRestored) return;
919
1264
  editorStateRestored = true;
@@ -922,36 +1267,29 @@ hyperbook.openscad = (function () {
922
1267
  // Re-apply split sizes after stored dataset values are applied by load().
923
1268
  applyMainSplitSize?.();
924
1269
  applyCanvasParamsSplitSize?.();
925
- let paramRebuildTimer = null;
926
- editor.addEventListener("input", () => {
927
- save();
928
- clearTimeout(paramRebuildTimer);
929
- paramRebuildTimer = setTimeout(() => buildParamForm(editor.value), 500);
930
- });
931
1270
 
932
1271
  // Use stored code if available; otherwise fall back to the editor's
933
1272
  // current value (the markdown default) or the built-in placeholder.
934
- const initialCode = stored?.code || editor.value.trim() || "// OpenSCAD\ncube([20,20,20], center=true);";
935
- editor.value = initialCode;
1273
+ const initialCode = stored?.code || cm?.getValue().trim() || "// OpenSCAD\ncube([20,20,20], center=true);";
1274
+ cm?.setValue(initialCode);
936
1275
  if (!params?.value.trim()) {
937
1276
  params.value = "{}";
938
1277
  }
939
- await buildParamForm(initialCode);
940
- // Only persist when there was no stored entry; if one already existed we
941
- // must not overwrite itreading editor.value right now may return stale
942
- // data because code-input's async re-render may not have completed yet.
1278
+ latestParamBuildToken += 1;
1279
+ const initParamToken = latestParamBuildToken;
1280
+ // Fire param extraction and rendering concurrently each uses its own dedicated worker.
1281
+ buildParamForm(initialCode, initParamToken).then(() => {
1282
+ if (initParamToken === latestParamBuildToken) {
1283
+ lastBuiltParamCode = initialCode;
1284
+ }
1285
+ }).catch(() => {});
943
1286
  if (!stored) {
944
1287
  await save();
945
1288
  }
946
1289
  renderPreview();
947
1290
  };
948
1291
 
949
- editor?.addEventListener("code-input_load", restoreEditorState);
950
- // SPA timing: if code-input already rendered its inner textarea before we
951
- // attached the listener, fire the handler immediately (mirrors pyide).
952
- if (editor?.querySelector("textarea")) {
953
- void restoreEditorState();
954
- }
1292
+ void restoreEditorState();
955
1293
  }
956
1294
 
957
1295
  function init(root) {