hyperbook 0.91.0 → 0.92.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,983 @@
1
+ /// <reference path="../hyperbook.types.js" />
2
+
3
+ /**
4
+ * OpenSCAD IDE directive.
5
+ * @type {any}
6
+ * @memberof hyperbook
7
+ */
8
+ hyperbook.openscad = (function () {
9
+ const _scriptBase = window.HYPERBOOK_ASSETS + "directive-openscad/";
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
+ let threePromise = null;
24
+
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 = [];
30
+
31
+ const i18nGet = (key, fallback = key) => hyperbook.i18n?.get(key) || fallback;
32
+
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 (_) {}
61
+ }
62
+ return instance;
63
+ };
64
+
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
+ };
73
+
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
+ };
137
+
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
+ };
149
+
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 (_) {}
162
+ }
163
+ try { instance.FS.writeFile(`/${libName}/${filePath}`, data); } catch (_) {}
164
+ }
165
+ }
166
+ };
167
+
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());
178
+ }
179
+ } catch (e) {
180
+ console.warn("[openscad] Failed to load fonts:", e);
181
+ }
182
+ };
183
+
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
+ }
195
+
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
+ }
224
+ } catch (e) {
225
+ console.warn("[openscad] WASM param extraction failed:", e);
226
+ }
227
+ return [];
228
+ };
229
+
230
+ const getThree = async () => {
231
+ if (!threePromise) {
232
+ threePromise = Promise.all([
233
+ import(/* @vite-ignore */ _scriptBase + "three.module.js"),
234
+ import(/* @vite-ignore */ _scriptBase + "STLLoader.js"),
235
+ import(/* @vite-ignore */ _scriptBase + "OrbitControls.js"),
236
+ ]).then(([THREE, STLLoaderModule, OrbitControlsModule]) => ({
237
+ THREE,
238
+ STLLoader: STLLoaderModule.STLLoader,
239
+ OrbitControls: OrbitControlsModule.OrbitControls,
240
+ }));
241
+ }
242
+ return threePromise;
243
+ };
244
+
245
+ const formatValue = (value) => {
246
+ if (typeof value === "string") return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
247
+ if (Array.isArray(value)) return `[${value.map(formatValue).join(",")}]`;
248
+ if (typeof value === "boolean" || typeof value === "number") return `${value}`;
249
+ throw new Error("Only numbers, booleans, strings and arrays are supported in parameters");
250
+ };
251
+
252
+ function setupCanvasParamsSplitter(leftSide, previewContainer, paramsPanel, splitter, onSplitChanged) {
253
+ if (!leftSide || !previewContainer || !paramsPanel || !splitter) return;
254
+
255
+ const minSize = 80;
256
+
257
+ const applySplitSize = (rawSize) => {
258
+ const total = leftSide.clientHeight;
259
+ const splitterSize = splitter.offsetHeight;
260
+ const maxSize = Math.max(minSize, total - splitterSize - minSize);
261
+ const clamped = Math.max(minSize, Math.min(rawSize, maxSize));
262
+ previewContainer.style.flex = `0 0 ${clamped}px`;
263
+ return clamped;
264
+ };
265
+
266
+ const applyStoredSplitSize = () => {
267
+ const rawStored = Number(leftSide.dataset.splitCanvasParams);
268
+ if (!Number.isFinite(rawStored) || rawStored <= 0) {
269
+ previewContainer.style.flex = "";
270
+ return;
271
+ }
272
+ applySplitSize(rawStored);
273
+ };
274
+
275
+ applyStoredSplitSize();
276
+
277
+ splitter.addEventListener("pointerdown", (event) => {
278
+ event.preventDefault();
279
+ splitter.setPointerCapture(event.pointerId);
280
+
281
+ const startPointer = event.clientY;
282
+ const startSize = previewContainer.getBoundingClientRect().height;
283
+
284
+ const onPointerMove = (moveEvent) => {
285
+ const delta = moveEvent.clientY - startPointer;
286
+ const size = applySplitSize(startSize + delta);
287
+ leftSide.dataset.splitCanvasParams = String(Math.round(size));
288
+ };
289
+
290
+ const onPointerUp = () => {
291
+ splitter.removeEventListener("pointermove", onPointerMove);
292
+ splitter.removeEventListener("pointerup", onPointerUp);
293
+ splitter.removeEventListener("pointercancel", onPointerUp);
294
+ const splitCanvasParams = Number(leftSide.dataset.splitCanvasParams);
295
+ if (Number.isFinite(splitCanvasParams) && splitCanvasParams > 0) {
296
+ onSplitChanged?.({ splitCanvasParams: Math.round(splitCanvasParams) });
297
+ }
298
+ };
299
+
300
+ splitter.addEventListener("pointermove", onPointerMove);
301
+ splitter.addEventListener("pointerup", onPointerUp);
302
+ splitter.addEventListener("pointercancel", onPointerUp);
303
+ });
304
+
305
+ window.addEventListener("resize", applyStoredSplitSize);
306
+ return applyStoredSplitSize;
307
+ }
308
+
309
+ function setupSplitter(elem, leftSide, editorContainer, splitter, onSplitChanged) {
310
+ if (!leftSide || !editorContainer || !splitter) return;
311
+
312
+ const previewContainer = leftSide;
313
+
314
+ const minPanelSize = 120;
315
+
316
+ const getIsHorizontal = () =>
317
+ getComputedStyle(elem).flexDirection.startsWith("row");
318
+
319
+ const applySplitSize = (rawSize, isHorizontal) => {
320
+ const total = isHorizontal ? elem.clientWidth : elem.clientHeight;
321
+ const splitterSize = isHorizontal ? splitter.offsetWidth : splitter.offsetHeight;
322
+ const maxSize = Math.max(minPanelSize, total - splitterSize - minPanelSize);
323
+ const clamped = Math.max(minPanelSize, Math.min(rawSize, maxSize));
324
+ previewContainer.style.flex = `0 0 ${clamped}px`;
325
+ return clamped;
326
+ };
327
+
328
+ const applyStoredSplitSize = () => {
329
+ const isHorizontal = getIsHorizontal();
330
+ elem.classList.toggle("split-horizontal", isHorizontal);
331
+ elem.classList.toggle("split-vertical", !isHorizontal);
332
+ const key = isHorizontal ? "splitHorizontal" : "splitVertical";
333
+ const rawStored = Number(elem.dataset[key]);
334
+ if (!Number.isFinite(rawStored) || rawStored <= 0) {
335
+ previewContainer.style.flex = "";
336
+ return;
337
+ }
338
+ applySplitSize(rawStored, isHorizontal);
339
+ };
340
+
341
+ applyStoredSplitSize();
342
+
343
+ splitter.addEventListener("pointerdown", (event) => {
344
+ event.preventDefault();
345
+ splitter.setPointerCapture(event.pointerId);
346
+
347
+ const isHorizontal = getIsHorizontal();
348
+ const key = isHorizontal ? "splitHorizontal" : "splitVertical";
349
+ const startPointer = isHorizontal ? event.clientX : event.clientY;
350
+ const startSize = isHorizontal
351
+ ? previewContainer.getBoundingClientRect().width
352
+ : previewContainer.getBoundingClientRect().height;
353
+
354
+ elem.classList.add("resizing");
355
+
356
+ const onPointerMove = (moveEvent) => {
357
+ const pointer = isHorizontal ? moveEvent.clientX : moveEvent.clientY;
358
+ const delta = pointer - startPointer;
359
+ const size = applySplitSize(startSize + delta, isHorizontal);
360
+ elem.dataset[key] = String(Math.round(size));
361
+ };
362
+
363
+ const onPointerUp = () => {
364
+ elem.classList.remove("resizing");
365
+ splitter.removeEventListener("pointermove", onPointerMove);
366
+ splitter.removeEventListener("pointerup", onPointerUp);
367
+ splitter.removeEventListener("pointercancel", onPointerUp);
368
+ const splitHorizontal = Number(elem.dataset.splitHorizontal);
369
+ const splitVertical = Number(elem.dataset.splitVertical);
370
+ onSplitChanged?.({
371
+ ...(Number.isFinite(splitHorizontal) && splitHorizontal > 0
372
+ ? { splitHorizontal: Math.round(splitHorizontal) }
373
+ : {}),
374
+ ...(Number.isFinite(splitVertical) && splitVertical > 0
375
+ ? { splitVertical: Math.round(splitVertical) }
376
+ : {}),
377
+ });
378
+ };
379
+
380
+ splitter.addEventListener("pointermove", onPointerMove);
381
+ splitter.addEventListener("pointerup", onPointerUp);
382
+ splitter.addEventListener("pointercancel", onPointerUp);
383
+ });
384
+
385
+ window.addEventListener("resize", applyStoredSplitSize);
386
+ return applyStoredSplitSize;
387
+ }
388
+
389
+ const updateFullscreenButtonState = (elem, button) => {
390
+ if (!elem || !button) return;
391
+ const isFullscreen = document.fullscreenElement === elem;
392
+ const label = i18nGet("ide-fullscreen-enter", "Fullscreen");
393
+ button.textContent = "⛶";
394
+ button.title = label;
395
+ button.setAttribute("aria-label", label);
396
+ button.classList.toggle("active", isFullscreen);
397
+ };
398
+
399
+ const toggleFullscreen = async (elem) => {
400
+ if (!elem) return;
401
+ if (document.fullscreenElement === elem) {
402
+ await document.exitFullscreen();
403
+ return;
404
+ }
405
+ await elem.requestFullscreen();
406
+ };
407
+
408
+ const syncFullscreenButtons = () => {
409
+ const elems = document.querySelectorAll(".directive-openscad");
410
+ elems.forEach((elem) => {
411
+ const fullscreen = elem.querySelector("button.fullscreen");
412
+ updateFullscreenButtonState(elem, fullscreen);
413
+ });
414
+ };
415
+
416
+ const toUint8Array = (data) => {
417
+ if (data instanceof Uint8Array) return data;
418
+ if (typeof data === "string") return new TextEncoder().encode(data);
419
+ return new Uint8Array(data || []);
420
+ };
421
+
422
+ function initElement(elem) {
423
+ if (elem.getAttribute("data-openscad-initialized") === "true") return;
424
+ elem.setAttribute("data-openscad-initialized", "true");
425
+
426
+ const id = elem.getAttribute("data-id");
427
+ const libraryNames = (elem.getAttribute("data-library") || "")
428
+ .split(",").map(s => s.trim()).filter(Boolean);
429
+
430
+ const previewContainer = elem.querySelector(".preview-container");
431
+ const leftSide = elem.querySelector(".left-side");
432
+ const canvasWrapper = elem.querySelector(".canvas-wrapper");
433
+ const canvasOverlay = elem.querySelector(".canvas-overlay");
434
+ const editorContainer = elem.querySelector(".editor-container");
435
+ const splitter = elem.querySelector(".splitter");
436
+ const canvasParamsSplitter = elem.querySelector(".canvas-params-splitter");
437
+ const canvas = elem.querySelector(".preview-canvas");
438
+ const editor = elem.querySelector("code-input.editor");
439
+ const params = elem.querySelector("textarea.parameters");
440
+
441
+ // The parameters panel is its own card below the canvas.
442
+ const paramsPanel = elem.querySelector(".parameters-panel");
443
+ const paramsForm = paramsPanel?.querySelector(".parameters-body") ?? paramsPanel;
444
+
445
+ const renderBtn = elem.querySelector("button.render");
446
+ const copyBtn = elem.querySelector("button.copy");
447
+ const downloadStlBtn = elem.querySelector("button.download-stl");
448
+ const resetBtn = elem.querySelector("button.reset");
449
+ const fullscreenBtn = elem.querySelector("button.fullscreen");
450
+
451
+ // --- Canvas overlay ---
452
+ let overlayDismissTimer = null;
453
+
454
+ const hideOverlay = () => {
455
+ clearTimeout(overlayDismissTimer);
456
+ if (canvasOverlay) {
457
+ canvasOverlay.className = "canvas-overlay hidden";
458
+ canvasOverlay.innerHTML = "";
459
+ }
460
+ };
461
+
462
+ const showOverlay = (type, message) => {
463
+ clearTimeout(overlayDismissTimer);
464
+ if (!canvasOverlay) return;
465
+ canvasOverlay.innerHTML = "";
466
+ canvasOverlay.className = `canvas-overlay ${type}`;
467
+
468
+ if (type === "loading") {
469
+ const spinner = document.createElement("div");
470
+ spinner.className = "canvas-spinner";
471
+ const label = document.createElement("span");
472
+ label.className = "overlay-message";
473
+ label.textContent = message;
474
+ canvasOverlay.appendChild(spinner);
475
+ canvasOverlay.appendChild(label);
476
+ } else {
477
+ const msg = document.createElement("div");
478
+ msg.className = "overlay-message";
479
+ msg.textContent = message;
480
+ const btn = document.createElement("button");
481
+ btn.className = "overlay-dismiss";
482
+ btn.textContent = "✕";
483
+ btn.addEventListener("click", hideOverlay);
484
+ canvasOverlay.appendChild(msg);
485
+ canvasOverlay.appendChild(btn);
486
+ }
487
+ };
488
+
489
+ // Resize the Three.js renderer to match the current canvas-wrapper size.
490
+ const resizeCanvas = () => {
491
+ if (!viewerState.renderer || !viewerState.camera || !canvasWrapper) return;
492
+ const w = Math.max(1, Math.floor(canvasWrapper.clientWidth));
493
+ const h = Math.max(1, Math.floor(canvasWrapper.clientHeight));
494
+ viewerState.renderer.setSize(w, h, false);
495
+ viewerState.camera.aspect = w / h;
496
+ viewerState.camera.updateProjectionMatrix();
497
+ };
498
+
499
+ const applyMainSplitSize = setupSplitter(elem, leftSide, editorContainer, splitter, () => {
500
+ resizeCanvas();
501
+ save();
502
+ });
503
+ const applyCanvasParamsSplitSize = setupCanvasParamsSplitter(leftSide, previewContainer, paramsPanel, canvasParamsSplitter, () => {
504
+ resizeCanvas();
505
+ save();
506
+ });
507
+
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
+ const save = async () => {
520
+ if (!id) return;
521
+ const splitHorizontal = Number(elem.dataset.splitHorizontal);
522
+ const splitVertical = Number(elem.dataset.splitVertical);
523
+ const splitCanvasParams = Number(leftSide?.dataset.splitCanvasParams);
524
+ await hyperbook.store.db.openscad.put({
525
+ id,
526
+ code: editor?.value || "",
527
+ params: params?.value || "{}",
528
+ ...(Number.isFinite(splitHorizontal) && splitHorizontal > 0
529
+ ? { splitHorizontal: Math.round(splitHorizontal) }
530
+ : {}),
531
+ ...(Number.isFinite(splitVertical) && splitVertical > 0
532
+ ? { splitVertical: Math.round(splitVertical) }
533
+ : {}),
534
+ ...(Number.isFinite(splitCanvasParams) && splitCanvasParams > 0
535
+ ? { splitCanvasParams: Math.round(splitCanvasParams) }
536
+ : {}),
537
+ });
538
+ };
539
+
540
+ const load = async () => {
541
+ if (!id) return null;
542
+ const result = await hyperbook.store.db.openscad.get(id);
543
+ if (!result) return null;
544
+ if (editor && typeof result.code === "string") {
545
+ editor.value = result.code;
546
+ }
547
+ if (params && typeof result.params === "string") {
548
+ params.value = result.params;
549
+ }
550
+ if (Number.isFinite(result.splitHorizontal) && result.splitHorizontal > 0) {
551
+ elem.dataset.splitHorizontal = String(Math.round(result.splitHorizontal));
552
+ }
553
+ if (Number.isFinite(result.splitVertical) && result.splitVertical > 0) {
554
+ elem.dataset.splitVertical = String(Math.round(result.splitVertical));
555
+ }
556
+ if (leftSide && Number.isFinite(result.splitCanvasParams) && result.splitCanvasParams > 0) {
557
+ leftSide.dataset.splitCanvasParams = String(Math.round(result.splitCanvasParams));
558
+ }
559
+ return result;
560
+ };
561
+
562
+ // Rebuild the parameters form from the code's top-level variable assignments.
563
+ // Stored overrides from the textarea are preserved so user edits survive
564
+ // code changes that don't touch those variable names.
565
+ const buildParamForm = async (code) => {
566
+ // Show a loading indicator while WASM extracts params.
567
+ paramsForm.innerHTML = "";
568
+ paramsPanel?.classList.remove("hidden");
569
+ canvasParamsSplitter?.classList.remove("hidden");
570
+ const loading = document.createElement("p");
571
+ loading.className = "params-empty";
572
+ loading.textContent = i18nGet("openscad-params-loading", "Loading parameters...");
573
+ paramsForm.appendChild(loading);
574
+
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");
581
+ if (params) params.value = "{}";
582
+ return;
583
+ }
584
+
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;
601
+ }
602
+ });
603
+ if (params) params.value = JSON.stringify(values);
604
+ save();
605
+ };
606
+
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
+ }
686
+
687
+ row.appendChild(label);
688
+ row.appendChild(input);
689
+ paramsForm.appendChild(row);
690
+ });
691
+
692
+ syncTextarea();
693
+ };
694
+
695
+ const getParamDefinitions = () => {
696
+ const parsed = JSON.parse(params?.value || "{}");
697
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
698
+ throw new Error(i18nGet("openscad-params-object", "Parameters must be a JSON object"));
699
+ }
700
+ return Object.entries(parsed).map(([k, v]) => `-D${k}=${formatValue(v)}`);
701
+ };
702
+
703
+ const renderWithFormat = async (format, libraryNames = []) => {
704
+ renderBtn?.setAttribute("disabled", "true");
705
+ showOverlay("loading", i18nGet("openscad-rendering", "Rendering..."));
706
+
707
+ 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);
714
+ }
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"));
740
+ }
741
+
742
+ const content = toUint8Array(instance.FS.readFile(outPath, { encoding: "binary" }));
743
+ return content;
744
+ } finally {
745
+ renderBtn?.removeAttribute("disabled");
746
+ }
747
+ };
748
+
749
+ const downloadBinary = (content, ext) => {
750
+ const blob = new Blob([content], { type: "application/octet-stream" });
751
+ const a = document.createElement("a");
752
+ a.href = URL.createObjectURL(blob);
753
+ a.download = `openscad-${id || "model"}.${ext}`;
754
+ a.click();
755
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
756
+ };
757
+
758
+ const renderPreview = async () => {
759
+ openscadStderr = [];
760
+ try {
761
+ // Ensure font bytes are fetched before creating the WASM instance
762
+ await loadFonts();
763
+ await save();
764
+ const stl = await renderWithFormat("stl", libraryNames);
765
+ await renderStl(stl);
766
+ hideOverlay();
767
+ } 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");
771
+ if (stderrErrors) {
772
+ showOverlay("error", stderrErrors);
773
+ } else if (typeof error === "number") {
774
+ showOverlay("error", i18nGet("openscad-render-failed", "OpenSCAD render failed"));
775
+ } else {
776
+ showOverlay("error", error?.message || `${error}`);
777
+ }
778
+ }
779
+ };
780
+
781
+ const renderStl = async (stlData) => {
782
+ const { THREE, STLLoader, OrbitControls } = await getThree();
783
+ if (!canvas) return;
784
+
785
+ if (!viewerState.renderer) {
786
+ viewerState.renderer = new THREE.WebGLRenderer({
787
+ canvas,
788
+ antialias: true,
789
+ alpha: true,
790
+ });
791
+ viewerState.renderer.setPixelRatio(window.devicePixelRatio || 1);
792
+
793
+ viewerState.scene = new THREE.Scene();
794
+ viewerState.scene.background = new THREE.Color(0xf8fafc);
795
+
796
+ const ambient = new THREE.AmbientLight(0xffffff, 0.75);
797
+ const key = new THREE.DirectionalLight(0xffffff, 1);
798
+ key.position.set(1, 1, 2);
799
+ const fill = new THREE.DirectionalLight(0xffffff, 0.5);
800
+ fill.position.set(-1, -1, 1);
801
+
802
+ viewerState.scene.add(ambient);
803
+ viewerState.scene.add(key);
804
+ viewerState.scene.add(fill);
805
+
806
+ viewerState.camera = new THREE.PerspectiveCamera(45, 1, 0.1, 5000);
807
+ 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();
819
+
820
+ viewerState.resizeObserver = new ResizeObserver(() => resizeCanvas());
821
+ viewerState.resizeObserver.observe(canvasWrapper || previewContainer);
822
+ }
823
+
824
+ const bounds = canvasWrapper?.getBoundingClientRect() ?? previewContainer?.getBoundingClientRect();
825
+ const width = Math.max(1, Math.floor(bounds?.width || canvas.clientWidth || 320));
826
+ const height = Math.max(1, Math.floor(bounds?.height || canvas.clientHeight || 320));
827
+ viewerState.renderer.setSize(width, height, false);
828
+ viewerState.camera.aspect = width / height;
829
+ viewerState.camera.updateProjectionMatrix();
830
+
831
+ if (viewerState.mesh) {
832
+ viewerState.scene.remove(viewerState.mesh);
833
+ viewerState.mesh.geometry?.dispose();
834
+ }
835
+
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);
853
+
854
+ const box = geometry.boundingBox;
855
+ const size = new THREE.Vector3();
856
+ const center = new THREE.Vector3();
857
+ box.getSize(size);
858
+ box.getCenter(center);
859
+
860
+ mesh.position.x = -center.x;
861
+ mesh.position.y = -center.y;
862
+ mesh.position.z = -center.z;
863
+
864
+ const maxDim = Math.max(size.x, size.y, size.z) || 1;
865
+ const distance = maxDim * 1.8;
866
+ viewerState.camera.position.set(distance, distance, distance);
867
+ viewerState.camera.near = Math.max(0.01, distance / 1000);
868
+ viewerState.camera.far = Math.max(1000, distance * 10);
869
+ viewerState.camera.lookAt(0, 0, 0);
870
+ viewerState.camera.updateProjectionMatrix();
871
+
872
+ viewerState.controls.target.set(0, 0, 0);
873
+ viewerState.controls.update();
874
+ };
875
+
876
+ copyBtn?.addEventListener("click", async () => {
877
+ await navigator.clipboard.writeText(editor?.value || "");
878
+ hideOverlay();
879
+ });
880
+
881
+ resetBtn?.addEventListener("click", async () => {
882
+ if (!window.confirm(i18nGet("openscad-reset-prompt", "Are you sure you want to reset the code?"))) {
883
+ return;
884
+ }
885
+ await hyperbook.store.db.openscad.delete(id);
886
+ window.location.reload();
887
+ });
888
+
889
+ renderBtn?.addEventListener("click", renderPreview);
890
+
891
+ downloadStlBtn?.addEventListener("click", async () => {
892
+ openscadStderr = [];
893
+ try {
894
+ await loadFonts();
895
+ await save();
896
+ const stl = await renderWithFormat("stl", libraryNames);
897
+ downloadBinary(stl, "stl");
898
+ await renderStl(stl);
899
+ hideOverlay();
900
+ } catch (error) {
901
+ const stderrErrors = openscadStderr.filter((l) => /error/i.test(l)).join("\n");
902
+ showOverlay("error", stderrErrors || error?.message || `${error}`);
903
+ }
904
+ });
905
+
906
+ fullscreenBtn?.addEventListener("click", async () => {
907
+ try {
908
+ await toggleFullscreen(elem);
909
+ } catch (error) {
910
+ console.error(error?.message || error);
911
+ }
912
+ });
913
+
914
+ updateFullscreenButtonState(elem, fullscreenBtn);
915
+
916
+ let editorStateRestored = false;
917
+ const restoreEditorState = async () => {
918
+ if (editorStateRestored) return;
919
+ editorStateRestored = true;
920
+
921
+ const stored = await load();
922
+ // Re-apply split sizes after stored dataset values are applied by load().
923
+ applyMainSplitSize?.();
924
+ applyCanvasParamsSplitSize?.();
925
+ let paramRebuildTimer = null;
926
+ editor.addEventListener("input", () => {
927
+ save();
928
+ clearTimeout(paramRebuildTimer);
929
+ paramRebuildTimer = setTimeout(() => buildParamForm(editor.value), 500);
930
+ });
931
+
932
+ // Use stored code if available; otherwise fall back to the editor's
933
+ // 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;
936
+ if (!params?.value.trim()) {
937
+ params.value = "{}";
938
+ }
939
+ await buildParamForm(initialCode);
940
+ // Only persist when there was no stored entry; if one already existed we
941
+ // must not overwrite it — reading editor.value right now may return stale
942
+ // data because code-input's async re-render may not have completed yet.
943
+ if (!stored) {
944
+ await save();
945
+ }
946
+ renderPreview();
947
+ };
948
+
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
+ }
955
+ }
956
+
957
+ function init(root) {
958
+ const elems = root.querySelectorAll(".directive-openscad");
959
+ elems.forEach(initElement);
960
+ }
961
+
962
+ document.addEventListener("DOMContentLoaded", () => {
963
+ init(document);
964
+ });
965
+ document.addEventListener("fullscreenchange", syncFullscreenButtons);
966
+
967
+ const observer = new MutationObserver((mutations) => {
968
+ mutations.forEach((mutation) => {
969
+ mutation.addedNodes.forEach((node) => {
970
+ if (
971
+ node.nodeType === 1 &&
972
+ node.classList.contains("directive-openscad")
973
+ ) {
974
+ initElement(node);
975
+ }
976
+ });
977
+ });
978
+ });
979
+
980
+ observer.observe(document.body, { childList: true, subtree: true });
981
+
982
+ return { init };
983
+ })();