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.
- package/dist/assets/codemirror/codemirror.bundle.js +26 -0
- package/dist/assets/directive-abc-music/client.js +42 -42
- package/dist/assets/directive-abc-music/style.css +6 -0
- package/dist/assets/directive-openscad/client.js +782 -444
- package/dist/assets/directive-openscad/style.css +71 -4
- package/dist/assets/directive-openscad/worker.js +632 -0
- package/dist/assets/directive-p5/client.js +27 -26
- package/dist/assets/directive-p5/style.css +12 -6
- package/dist/assets/directive-pyide/client.js +20 -32
- package/dist/assets/directive-pyide/style.css +12 -6
- package/dist/assets/directive-typst/client.js +20 -55
- package/dist/assets/directive-typst/style.css +12 -6
- package/dist/assets/directive-webide/client.js +45 -79
- package/dist/assets/directive-webide/style.css +12 -6
- package/dist/index.js +41 -71
- package/dist/locales/de.json +3 -0
- package/dist/locales/en.json +3 -0
- package/package.json +2 -2
- package/dist/assets/code-input/auto-close-brackets.min.js +0 -1
- package/dist/assets/code-input/code-input.min.css +0 -1
- package/dist/assets/code-input/code-input.min.js +0 -12
- package/dist/assets/code-input/indent.min.js +0 -1
- package/dist/assets/directive-openscad/STLLoader.js +0 -411
|
@@ -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
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
164
|
-
}
|
|
70
|
+
});
|
|
165
71
|
}
|
|
72
|
+
return s.promise;
|
|
166
73
|
};
|
|
167
74
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
}
|
|
180
|
-
console.warn("[openscad] Failed to load fonts:", e);
|
|
181
|
-
}
|
|
87
|
+
});
|
|
182
88
|
};
|
|
183
89
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
"
|
|
208
|
-
"
|
|
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]
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
501
|
-
|
|
786
|
+
scheduleResizeCanvas();
|
|
787
|
+
scheduleSave();
|
|
502
788
|
});
|
|
503
789
|
const applyCanvasParamsSplitSize = setupCanvasParamsSplitter(leftSide, previewContainer, paramsPanel, canvasParamsSplitter, () => {
|
|
504
|
-
|
|
505
|
-
|
|
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:
|
|
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 (
|
|
545
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
if (
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
|
|
604
|
-
save();
|
|
605
|
-
};
|
|
986
|
+
}
|
|
606
987
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
717
|
-
|
|
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
|
-
|
|
743
|
-
|
|
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:
|
|
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
|
|
765
|
-
|
|
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
|
-
|
|
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
|
|
782
|
-
|
|
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 =
|
|
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(() =>
|
|
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
|
-
|
|
832
|
-
viewerState.scene.remove(viewerState.mesh);
|
|
833
|
-
viewerState.mesh.geometry?.dispose();
|
|
834
|
-
}
|
|
1163
|
+
disposeModel();
|
|
835
1164
|
|
|
836
|
-
const
|
|
837
|
-
const
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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 =
|
|
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
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
-
|
|
892
|
-
openscadStderr = [];
|
|
1213
|
+
downloadBtn?.addEventListener("click", async () => {
|
|
893
1214
|
try {
|
|
894
|
-
await loadFonts();
|
|
895
1215
|
await save();
|
|
896
|
-
const
|
|
897
|
-
|
|
898
|
-
|
|
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 =
|
|
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 ||
|
|
935
|
-
|
|
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
|
-
|
|
940
|
-
|
|
941
|
-
//
|
|
942
|
-
|
|
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
|
-
|
|
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) {
|