hyperbook 0.93.0 → 0.94.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.
@@ -16,226 +16,114 @@ hyperbook.openscad = (function () {
16
16
  ]),
17
17
  );
18
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
19
  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 = [];
20
+ let openscadWorkerPromise = null;
21
+ let workerRequestId = 0;
22
+ const pendingWorkerRequests = new Map();
30
23
 
31
24
  const i18nGet = (key, fallback = key) => hyperbook.i18n?.get(key) || fallback;
32
25
 
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 (_) {}
26
+ const rejectPendingWorkerRequests = (error) => {
27
+ for (const { reject } of pendingWorkerRequests.values()) {
28
+ reject(error);
61
29
  }
62
- return instance;
30
+ pendingWorkerRequests.clear();
63
31
  };
64
32
 
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
- }
33
+ const getOpenScadWorker = async () => {
34
+ if (!window.Worker) {
35
+ throw new Error(i18nGet("openscad-render-failed", "OpenSCAD render failed"));
134
36
  }
135
- return files;
136
- };
37
+ if (!openscadWorkerPromise) {
38
+ openscadWorkerPromise = new Promise((resolve, reject) => {
39
+ try {
40
+ const worker = new Worker(new URL(_scriptBase + "worker.js", window.location.href), {
41
+ type: "module",
42
+ });
137
43
 
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
- };
44
+ worker.addEventListener("message", (event) => {
45
+ const { requestId, ok, result, error } = event.data || {};
46
+ if (!requestId || !pendingWorkerRequests.has(requestId)) return;
47
+ const pending = pendingWorkerRequests.get(requestId);
48
+ pendingWorkerRequests.delete(requestId);
49
+ if (ok) {
50
+ pending.resolve(result);
51
+ return;
52
+ }
53
+ const workerError = new Error(error?.message || "OpenSCAD worker request failed");
54
+ if (Array.isArray(error?.stderr)) {
55
+ workerError.stderr = error.stderr;
56
+ }
57
+ pending.reject(workerError);
58
+ });
59
+
60
+ worker.addEventListener("error", (event) => {
61
+ const workerError = new Error(event?.message || "OpenSCAD worker crashed");
62
+ rejectPendingWorkerRequests(workerError);
63
+ openscadWorkerPromise = null;
64
+ });
149
65
 
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 (_) {}
66
+ worker.addEventListener("messageerror", () => {
67
+ const workerError = new Error("OpenSCAD worker message error");
68
+ rejectPendingWorkerRequests(workerError);
69
+ openscadWorkerPromise = null;
70
+ });
71
+
72
+ resolve(worker);
73
+ } catch (error) {
74
+ openscadWorkerPromise = null;
75
+ reject(error);
162
76
  }
163
- try { instance.FS.writeFile(`/${libName}/${filePath}`, data); } catch (_) {}
164
- }
77
+ });
165
78
  }
79
+ return openscadWorkerPromise;
166
80
  };
167
81
 
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());
82
+ const callOpenScadWorker = async (type, payload, transfer = []) => {
83
+ const worker = await getOpenScadWorker();
84
+ const requestId = ++workerRequestId;
85
+ return new Promise((resolve, reject) => {
86
+ pendingWorkerRequests.set(requestId, { resolve, reject });
87
+ try {
88
+ worker.postMessage({ requestId, type, payload }, transfer);
89
+ } catch (error) {
90
+ pendingWorkerRequests.delete(requestId);
91
+ reject(error);
178
92
  }
179
- } catch (e) {
180
- console.warn("[openscad] Failed to load fonts:", e);
181
- }
93
+ });
182
94
  };
183
95
 
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.
96
+ const getInvocationStderr = (invocationResult) =>
97
+ (invocationResult?.mergedOutputs || [])
98
+ .filter((entry) => typeof entry?.stderr === "string")
99
+ .map((entry) => entry.stderr);
100
+
101
+ // Extract parameters from SCAD code in the worker to keep the main thread responsive.
187
102
  const extractParams = async (code, libraryNames = []) => {
188
103
  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
- }
104
+ const result = await callOpenScadWorker("extractParams", { code, libraryNames });
105
+ if (result?.error || result?.exitCode !== 0) {
106
+ return [];
223
107
  }
108
+ const output = result?.outputs?.[0]?.[1];
109
+ if (!output) return [];
110
+ const json = new TextDecoder().decode(toUint8Array(output));
111
+ const paramSet = JSON.parse(json);
112
+ if (!Array.isArray(paramSet?.parameters)) return [];
113
+ return paramSet.parameters.filter((p) => !p.name?.startsWith("$"));
224
114
  } catch (e) {
225
- console.warn("[openscad] WASM param extraction failed:", e);
115
+ console.warn("[openscad] Worker param extraction failed:", e);
116
+ return [];
226
117
  }
227
- return [];
228
118
  };
229
119
 
230
120
  const getThree = async () => {
231
121
  if (!threePromise) {
232
122
  threePromise = Promise.all([
233
123
  import(/* @vite-ignore */ _scriptBase + "three.module.js"),
234
- import(/* @vite-ignore */ _scriptBase + "STLLoader.js"),
235
124
  import(/* @vite-ignore */ _scriptBase + "OrbitControls.js"),
236
- ]).then(([THREE, STLLoaderModule, OrbitControlsModule]) => ({
125
+ ]).then(([THREE, OrbitControlsModule]) => ({
237
126
  THREE,
238
- STLLoader: STLLoaderModule.STLLoader,
239
127
  OrbitControls: OrbitControlsModule.OrbitControls,
240
128
  }));
241
129
  }
@@ -419,6 +307,318 @@ hyperbook.openscad = (function () {
419
307
  return new Uint8Array(data || []);
420
308
  };
421
309
 
310
+ const textEncoder = new TextEncoder();
311
+ const DEFAULT_FACE_COLOR = [0xf9 / 255, 0xd7 / 255, 0x2c / 255, 1];
312
+ const PAINT_COLOR_MAP = ["", "8", "0C", "1C", "2C", "3C", "4C", "5C", "6C", "7C", "8C", "9C", "AC", "BC", "CC", "DC"];
313
+ const CRC32_TABLE = (() => {
314
+ const table = new Uint32Array(256);
315
+ for (let n = 0; n < 256; n++) {
316
+ let c = n;
317
+ for (let k = 0; k < 8; k++) {
318
+ c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
319
+ }
320
+ table[n] = c >>> 0;
321
+ }
322
+ return table;
323
+ })();
324
+
325
+ const crc32 = (bytes) => {
326
+ let crc = 0xffffffff;
327
+ for (let i = 0; i < bytes.length; i++) {
328
+ crc = CRC32_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
329
+ }
330
+ return (crc ^ 0xffffffff) >>> 0;
331
+ };
332
+
333
+ const concatUint8Arrays = (chunks) => {
334
+ const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
335
+ const out = new Uint8Array(total);
336
+ let offset = 0;
337
+ for (const chunk of chunks) {
338
+ out.set(chunk, offset);
339
+ offset += chunk.length;
340
+ }
341
+ return out;
342
+ };
343
+
344
+ const createZip = (files) => {
345
+ const localChunks = [];
346
+ const centralChunks = [];
347
+ let offset = 0;
348
+
349
+ for (const [name, dataLike] of Object.entries(files)) {
350
+ const nameBytes = textEncoder.encode(name);
351
+ const data = toUint8Array(dataLike);
352
+ const crc = crc32(data);
353
+ const localHeader = new Uint8Array(30 + nameBytes.length);
354
+ const localView = new DataView(localHeader.buffer);
355
+ localView.setUint32(0, 0x04034b50, true);
356
+ localView.setUint16(4, 20, true);
357
+ localView.setUint16(6, 0, true);
358
+ localView.setUint16(8, 0, true);
359
+ localView.setUint16(10, 0, true);
360
+ localView.setUint16(12, 0, true);
361
+ localView.setUint32(14, crc, true);
362
+ localView.setUint32(18, data.length, true);
363
+ localView.setUint32(22, data.length, true);
364
+ localView.setUint16(26, nameBytes.length, true);
365
+ localView.setUint16(28, 0, true);
366
+ localHeader.set(nameBytes, 30);
367
+ localChunks.push(localHeader, data);
368
+
369
+ const centralHeader = new Uint8Array(46 + nameBytes.length);
370
+ const centralView = new DataView(centralHeader.buffer);
371
+ centralView.setUint32(0, 0x02014b50, true);
372
+ centralView.setUint16(4, 20, true);
373
+ centralView.setUint16(6, 20, true);
374
+ centralView.setUint16(8, 0, true);
375
+ centralView.setUint16(10, 0, true);
376
+ centralView.setUint16(12, 0, true);
377
+ centralView.setUint16(14, 0, true);
378
+ centralView.setUint32(16, crc, true);
379
+ centralView.setUint32(20, data.length, true);
380
+ centralView.setUint32(24, data.length, true);
381
+ centralView.setUint16(28, nameBytes.length, true);
382
+ centralView.setUint16(30, 0, true);
383
+ centralView.setUint16(32, 0, true);
384
+ centralView.setUint16(34, 0, true);
385
+ centralView.setUint16(36, 0, true);
386
+ centralView.setUint32(38, 0, true);
387
+ centralView.setUint32(42, offset, true);
388
+ centralHeader.set(nameBytes, 46);
389
+ centralChunks.push(centralHeader);
390
+
391
+ offset += localHeader.length + data.length;
392
+ }
393
+
394
+ const centralDirectory = concatUint8Arrays(centralChunks);
395
+ const eocd = new Uint8Array(22);
396
+ const eocdView = new DataView(eocd.buffer);
397
+ eocdView.setUint32(0, 0x06054b50, true);
398
+ eocdView.setUint16(4, 0, true);
399
+ eocdView.setUint16(6, 0, true);
400
+ eocdView.setUint16(8, centralChunks.length, true);
401
+ eocdView.setUint16(10, centralChunks.length, true);
402
+ eocdView.setUint32(12, centralDirectory.length, true);
403
+ eocdView.setUint32(16, offset, true);
404
+ eocdView.setUint16(20, 0, true);
405
+
406
+ return concatUint8Arrays([...localChunks, centralDirectory, eocd]);
407
+ };
408
+
409
+ const toHexByte = (value) => Math.round(Math.max(0, Math.min(1, value)) * 255).toString(16).padStart(2, "0").toUpperCase();
410
+
411
+ const colorToDisplayColor = ([r, g, b, a = 1]) => {
412
+ const base = `#${toHexByte(r)}${toHexByte(g)}${toHexByte(b)}`;
413
+ return a < 1 ? `${base}${toHexByte(a)}` : base;
414
+ };
415
+
416
+ const createUuid = () =>
417
+ (globalThis.crypto?.randomUUID?.() ||
418
+ `${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}-${Math.random().toString(16).slice(2, 10)}`);
419
+
420
+ const parseOffToIndexedPolyhedron = (offData) => {
421
+ const arrayBuffer = offData.buffer.slice(
422
+ offData.byteOffset,
423
+ offData.byteOffset + offData.byteLength,
424
+ );
425
+ const text = new TextDecoder().decode(arrayBuffer);
426
+ const lines = text
427
+ .split("\n")
428
+ .map((line) => line.trim())
429
+ .filter((line) => line.length > 0 && !line.startsWith("#"));
430
+
431
+ if (lines.length === 0) {
432
+ throw new Error(i18nGet("openscad-render-failed", "OpenSCAD render failed"));
433
+ }
434
+
435
+ let countsLine = "";
436
+ let currentLine = 0;
437
+ if (/^OFF(\s|$)/.test(lines[0])) {
438
+ countsLine = lines[0].substring(3).trim();
439
+ currentLine = 1;
440
+ } else if (lines[0] === "OFF" && lines.length > 1) {
441
+ countsLine = lines[1];
442
+ currentLine = 2;
443
+ } else {
444
+ throw new Error(i18nGet("openscad-render-failed", "OpenSCAD render failed"));
445
+ }
446
+
447
+ const [vertexCountRaw, faceCountRaw] = countsLine.split(/\s+/).map(Number);
448
+ const vertexCount = Number.isFinite(vertexCountRaw) ? Math.floor(vertexCountRaw) : NaN;
449
+ const faceCount = Number.isFinite(faceCountRaw) ? Math.floor(faceCountRaw) : NaN;
450
+ if (!Number.isFinite(vertexCount) || !Number.isFinite(faceCount) || vertexCount <= 0 || faceCount <= 0) {
451
+ throw new Error(i18nGet("openscad-render-failed", "OpenSCAD render failed"));
452
+ }
453
+ if (currentLine + vertexCount + faceCount > lines.length) {
454
+ throw new Error(i18nGet("openscad-render-failed", "OpenSCAD render failed"));
455
+ }
456
+
457
+ const vertices = [];
458
+ for (let i = 0; i < vertexCount; i++) {
459
+ const parts = lines[currentLine + i].split(/\s+/).map(Number);
460
+ if (parts.length < 3 || parts.some(Number.isNaN)) {
461
+ throw new Error(i18nGet("openscad-render-failed", "OpenSCAD render failed"));
462
+ }
463
+ vertices.push({ x: parts[0], y: parts[1], z: parts[2] });
464
+ }
465
+ currentLine += vertexCount;
466
+
467
+ const colors = [];
468
+ const colorMap = new Map();
469
+ const faces = [];
470
+
471
+ for (let i = 0; i < faceCount; i++) {
472
+ const parts = lines[currentLine + i].split(/\s+/).map(Number);
473
+ const numVerts = Number.isFinite(parts[0]) ? Math.floor(parts[0]) : 0;
474
+ const faceVertices = parts.slice(1, numVerts + 1).map((index) => Math.floor(index));
475
+ if (faceVertices.length < 3) {
476
+ throw new Error(i18nGet("openscad-render-failed", "OpenSCAD render failed"));
477
+ }
478
+
479
+ let color = DEFAULT_FACE_COLOR;
480
+ if (parts.length >= numVerts + 4) {
481
+ const raw = parts.slice(numVerts + 1, numVerts + 5).filter(Number.isFinite);
482
+ if (raw.length >= 3) {
483
+ const r = raw[0];
484
+ const g = raw[1];
485
+ const b = raw[2];
486
+ const a = raw.length >= 4 ? raw[3] : (Math.max(r, g, b) > 1 ? 255 : 1);
487
+ const divisor = Math.max(r, g, b, a) > 1 ? 255 : 1;
488
+ color = [r / divisor, g / divisor, b / divisor, a / divisor];
489
+ }
490
+ }
491
+
492
+ const colorKey = color.join(",");
493
+ let colorIndex = colorMap.get(colorKey);
494
+ if (colorIndex == null) {
495
+ colorIndex = colors.length;
496
+ colors.push(color);
497
+ colorMap.set(colorKey, colorIndex);
498
+ }
499
+
500
+ if (faceVertices.length === 3) {
501
+ faces.push({ vertices: faceVertices, colorIndex });
502
+ } else {
503
+ for (let j = 1; j < faceVertices.length - 1; j++) {
504
+ faces.push({
505
+ vertices: [faceVertices[0], faceVertices[j], faceVertices[j + 1]],
506
+ colorIndex,
507
+ });
508
+ }
509
+ }
510
+ }
511
+
512
+ return { vertices, faces, colors };
513
+ };
514
+
515
+ const buildThreeModelFromIndexedPolyhedron = (polyhedron, THREE) => {
516
+ const model = new THREE.Group();
517
+ const facesByColor = new Map();
518
+
519
+ for (const face of polyhedron.faces) {
520
+ const [i1, i2, i3] = face.vertices;
521
+ const v1 = polyhedron.vertices[i1];
522
+ const v2 = polyhedron.vertices[i2];
523
+ const v3 = polyhedron.vertices[i3];
524
+ if (!v1 || !v2 || !v3) continue;
525
+ const color = polyhedron.colors[face.colorIndex] || DEFAULT_FACE_COLOR;
526
+ const colorKey = color.join(",");
527
+ let bucket = facesByColor.get(colorKey);
528
+ if (!bucket) {
529
+ bucket = { color, positions: [] };
530
+ facesByColor.set(colorKey, bucket);
531
+ }
532
+ bucket.positions.push(v1.x, v1.y, v1.z, v2.x, v2.y, v2.z, v3.x, v3.y, v3.z);
533
+ }
534
+
535
+ if (facesByColor.size === 0) {
536
+ throw new Error(i18nGet("openscad-render-failed", "OpenSCAD render failed"));
537
+ }
538
+
539
+ for (const bucket of facesByColor.values()) {
540
+ const geometry = new THREE.BufferGeometry();
541
+ geometry.setAttribute("position", new THREE.Float32BufferAttribute(bucket.positions, 3));
542
+ geometry.computeBoundingBox();
543
+ geometry.computeVertexNormals();
544
+ const [r, g, b, a = 1] = bucket.color;
545
+ const material = new THREE.MeshStandardMaterial({
546
+ color: new THREE.Color(r, g, b),
547
+ transparent: a < 1,
548
+ opacity: a,
549
+ metalness: 0.1,
550
+ roughness: 0.6,
551
+ });
552
+ model.add(new THREE.Mesh(geometry, material));
553
+ }
554
+
555
+ return model;
556
+ };
557
+
558
+ const exportIndexedPolyhedronTo3mf = (polyhedron) => {
559
+ const objectUuid = createUuid();
560
+ const buildUuid = createUuid();
561
+ const extruderIndexByColorIndex = polyhedron.colors.map((_, idx) => idx % PAINT_COLOR_MAP.length);
562
+
563
+ const modelXml = [
564
+ '<?xml version="1.0" encoding="utf-8"?>',
565
+ '<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">',
566
+ '<meta name="BambuStudio:3mfVersion" value="1"/>',
567
+ '<meta name="slic3rpe:Version3mf" value="1"/>',
568
+ '<meta name="slic3rpe:MmPaintingVersion" value="1"/>',
569
+ "<resources>",
570
+ '<basematerials id="2">',
571
+ ...polyhedron.colors.map((color, i) => `<base name="color_${i}" displaycolor="${colorToDisplayColor(color)}"/>`),
572
+ "</basematerials>",
573
+ `<object id="1" name="OpenSCAD Model" type="model" p:UUID="${objectUuid}" pid="2" pindex="0">`,
574
+ "<mesh>",
575
+ "<vertices>",
576
+ ...polyhedron.vertices.map((vertex) => `<vertex x="${vertex.x}" y="${vertex.y}" z="${vertex.z}" />`),
577
+ "</vertices>",
578
+ "<triangles>",
579
+ ...polyhedron.faces.map((face) => {
580
+ const [v1, v2, v3] = face.vertices;
581
+ const attrs = [`v1="${v1}"`, `v2="${v2}"`, `v3="${v3}"`];
582
+ if (face.colorIndex > 0) {
583
+ attrs.push(`pid="2"`, `p1="${face.colorIndex}"`);
584
+ }
585
+ const paintColor = PAINT_COLOR_MAP[extruderIndexByColorIndex[face.colorIndex]];
586
+ if (paintColor) {
587
+ attrs.push(`paint_color="${paintColor}"`);
588
+ }
589
+ return `<triangle ${attrs.join(" ")} />`;
590
+ }),
591
+ "</triangles>",
592
+ "</mesh>",
593
+ "</object>",
594
+ "</resources>",
595
+ `<build p:UUID="${buildUuid}">`,
596
+ `<item objectid="1" p:UUID="${objectUuid}"/>`,
597
+ "</build>",
598
+ "</model>",
599
+ ].join("\n");
600
+
601
+ const contentTypesXml = [
602
+ '<?xml version="1.0" encoding="utf-8"?>',
603
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">',
604
+ '<Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>',
605
+ "</Types>",
606
+ ].join("\n");
607
+
608
+ const relationshipsXml = [
609
+ '<?xml version="1.0" encoding="utf-8"?>',
610
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">',
611
+ '<Relationship Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel" Target="/3D/3dmodel.model" Id="rel0"/>',
612
+ "</Relationships>",
613
+ ].join("\n");
614
+
615
+ return createZip({
616
+ "3D/3dmodel.model": textEncoder.encode(modelXml),
617
+ "[Content_Types].xml": textEncoder.encode(contentTypesXml),
618
+ "_rels/.rels": textEncoder.encode(relationshipsXml),
619
+ });
620
+ };
621
+
422
622
  function initElement(elem) {
423
623
  if (elem.getAttribute("data-openscad-initialized") === "true") return;
424
624
  elem.setAttribute("data-openscad-initialized", "true");
@@ -444,9 +644,27 @@ hyperbook.openscad = (function () {
444
644
 
445
645
  const renderBtn = elem.querySelector("button.render");
446
646
  const copyBtn = elem.querySelector("button.copy");
447
- const downloadStlBtn = elem.querySelector("button.download-stl");
647
+ const downloadBtn = elem.querySelector("button.download-stl");
448
648
  const resetBtn = elem.querySelector("button.reset");
449
649
  const fullscreenBtn = elem.querySelector("button.fullscreen");
650
+ const bottomButtons = elem.querySelector(".buttons.bottom");
651
+ let downloadFormatSelect = bottomButtons?.querySelector("select.download-format");
652
+ if (!downloadFormatSelect && bottomButtons && downloadBtn) {
653
+ downloadFormatSelect = document.createElement("select");
654
+ downloadFormatSelect.className = "download-format";
655
+ downloadFormatSelect.setAttribute("aria-label", i18nGet("openscad-download-format", "Download format"));
656
+ const stlOption = document.createElement("option");
657
+ stlOption.value = "stl";
658
+ stlOption.textContent = "STL";
659
+ const threeMfOption = document.createElement("option");
660
+ threeMfOption.value = "3mf";
661
+ threeMfOption.textContent = "3MF";
662
+ downloadFormatSelect.append(stlOption, threeMfOption);
663
+ bottomButtons.insertBefore(downloadFormatSelect, downloadBtn);
664
+ }
665
+ if (downloadBtn) {
666
+ downloadBtn.textContent = i18nGet("openscad-download", "Download");
667
+ }
450
668
 
451
669
  // --- Canvas overlay ---
452
670
  let overlayDismissTimer = null;
@@ -510,7 +728,7 @@ hyperbook.openscad = (function () {
510
728
  camera: null,
511
729
  scene: null,
512
730
  controls: null,
513
- mesh: null,
731
+ model: null,
514
732
  raf: 0,
515
733
  disposed: false,
516
734
  resizeObserver: null,
@@ -700,54 +918,42 @@ hyperbook.openscad = (function () {
700
918
  return Object.entries(parsed).map(([k, v]) => `-D${k}=${formatValue(v)}`);
701
919
  };
702
920
 
703
- const renderWithFormat = async (format, libraryNames = []) => {
921
+ const renderWithFormat = async (format, libraryNames = [], isPreview = false) => {
704
922
  renderBtn?.setAttribute("disabled", "true");
705
923
  showOverlay("loading", i18nGet("openscad-rendering", "Rendering..."));
706
924
 
707
925
  try {
708
926
  const paramDefinitions = getParamDefinitions();
709
- const openscad = await getOpenScad();
710
- const instance = openscad;
711
-
712
- if (libraryNames.length > 0) {
713
- await mountLibraries(instance, libraryNames);
927
+ const result = await callOpenScadWorker("render", {
928
+ code: editor?.value || "",
929
+ format,
930
+ libraryNames,
931
+ paramDefinitions,
932
+ isPreview,
933
+ });
934
+ const stderr = getInvocationStderr(result);
935
+ if (result?.error || result?.exitCode !== 0) {
936
+ const error = new Error(result?.error || i18nGet("openscad-render-failed", "OpenSCAD render failed"));
937
+ error.stderr = stderr;
938
+ throw error;
714
939
  }
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"));
940
+ const output = result?.outputs?.[0]?.[1];
941
+ if (!output) {
942
+ const error = new Error(i18nGet("openscad-render-failed", "OpenSCAD render failed"));
943
+ error.stderr = stderr;
944
+ throw error;
740
945
  }
741
-
742
- const content = toUint8Array(instance.FS.readFile(outPath, { encoding: "binary" }));
743
- return content;
946
+ return {
947
+ content: toUint8Array(output),
948
+ stderr,
949
+ };
744
950
  } finally {
745
951
  renderBtn?.removeAttribute("disabled");
746
952
  }
747
953
  };
748
954
 
749
- const downloadBinary = (content, ext) => {
750
- const blob = new Blob([content], { type: "application/octet-stream" });
955
+ const downloadBinary = (content, ext, mimeType = "application/octet-stream") => {
956
+ const blob = new Blob([content], { type: mimeType });
751
957
  const a = document.createElement("a");
752
958
  a.href = URL.createObjectURL(blob);
753
959
  a.download = `openscad-${id || "model"}.${ext}`;
@@ -756,18 +962,13 @@ hyperbook.openscad = (function () {
756
962
  };
757
963
 
758
964
  const renderPreview = async () => {
759
- openscadStderr = [];
760
965
  try {
761
- // Ensure font bytes are fetched before creating the WASM instance
762
- await loadFonts();
763
966
  await save();
764
- const stl = await renderWithFormat("stl", libraryNames);
765
- await renderStl(stl);
967
+ const { content: off } = await renderWithFormat("off", libraryNames, true);
968
+ await renderOff(off);
766
969
  hideOverlay();
767
970
  } 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");
971
+ const stderrErrors = (error?.stderr || []).filter((l) => /error/i.test(l)).join("\n");
771
972
  if (stderrErrors) {
772
973
  showOverlay("error", stderrErrors);
773
974
  } else if (typeof error === "number") {
@@ -778,8 +979,22 @@ hyperbook.openscad = (function () {
778
979
  }
779
980
  };
780
981
 
781
- const renderStl = async (stlData) => {
782
- const { THREE, STLLoader, OrbitControls } = await getThree();
982
+ const disposeModel = () => {
983
+ if (!viewerState.model || !viewerState.scene) return;
984
+ viewerState.scene.remove(viewerState.model);
985
+ viewerState.model.traverse((child) => {
986
+ child.geometry?.dispose?.();
987
+ if (Array.isArray(child.material)) {
988
+ child.material.forEach((material) => material?.dispose?.());
989
+ } else {
990
+ child.material?.dispose?.();
991
+ }
992
+ });
993
+ viewerState.model = null;
994
+ };
995
+
996
+ const renderOff = async (offData) => {
997
+ const { THREE, OrbitControls } = await getThree();
783
998
  if (!canvas) return;
784
999
 
785
1000
  if (!viewerState.renderer) {
@@ -828,38 +1043,25 @@ hyperbook.openscad = (function () {
828
1043
  viewerState.camera.aspect = width / height;
829
1044
  viewerState.camera.updateProjectionMatrix();
830
1045
 
831
- if (viewerState.mesh) {
832
- viewerState.scene.remove(viewerState.mesh);
833
- viewerState.mesh.geometry?.dispose();
834
- }
1046
+ disposeModel();
835
1047
 
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();
1048
+ const polyhedron = parseOffToIndexedPolyhedron(offData);
1049
+ const model = buildThreeModelFromIndexedPolyhedron(polyhedron, THREE);
1050
+ viewerState.model = model;
1051
+ viewerState.scene.add(model);
844
1052
 
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;
1053
+ const box = new THREE.Box3().setFromObject(model);
1054
+ if (box.isEmpty()) {
1055
+ throw new Error(i18nGet("openscad-render-failed", "OpenSCAD render failed"));
1056
+ }
855
1057
  const size = new THREE.Vector3();
856
1058
  const center = new THREE.Vector3();
857
1059
  box.getSize(size);
858
1060
  box.getCenter(center);
859
1061
 
860
- mesh.position.x = -center.x;
861
- mesh.position.y = -center.y;
862
- mesh.position.z = -center.z;
1062
+ model.position.x = -center.x;
1063
+ model.position.y = -center.y;
1064
+ model.position.z = -center.z;
863
1065
 
864
1066
  const maxDim = Math.max(size.x, size.y, size.z) || 1;
865
1067
  const distance = maxDim * 1.8;
@@ -888,17 +1090,22 @@ hyperbook.openscad = (function () {
888
1090
 
889
1091
  renderBtn?.addEventListener("click", renderPreview);
890
1092
 
891
- downloadStlBtn?.addEventListener("click", async () => {
892
- openscadStderr = [];
1093
+ downloadBtn?.addEventListener("click", async () => {
893
1094
  try {
894
- await loadFonts();
895
1095
  await save();
896
- const stl = await renderWithFormat("stl", libraryNames);
897
- downloadBinary(stl, "stl");
898
- await renderStl(stl);
1096
+ const selectedFormat = downloadFormatSelect?.value === "3mf" ? "3mf" : "stl";
1097
+ if (selectedFormat === "3mf") {
1098
+ const { content: off } = await renderWithFormat("off", libraryNames);
1099
+ const polyhedron = parseOffToIndexedPolyhedron(off);
1100
+ const threeMf = exportIndexedPolyhedronTo3mf(polyhedron);
1101
+ downloadBinary(threeMf, "3mf", "model/3mf");
1102
+ } else {
1103
+ const { content: stl } = await renderWithFormat("stl", libraryNames);
1104
+ downloadBinary(stl, "stl");
1105
+ }
899
1106
  hideOverlay();
900
1107
  } catch (error) {
901
- const stderrErrors = openscadStderr.filter((l) => /error/i.test(l)).join("\n");
1108
+ const stderrErrors = (error?.stderr || []).filter((l) => /error/i.test(l)).join("\n");
902
1109
  showOverlay("error", stderrErrors || error?.message || `${error}`);
903
1110
  }
904
1111
  });