hyperbook 0.94.0 → 0.95.1

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.
@@ -179,11 +179,14 @@
179
179
 
180
180
  .directive-openscad .parameters-body {
181
181
  flex: 1;
182
+ min-height: 0;
182
183
  overflow-y: auto;
183
184
  padding: 12px;
184
- display: flex;
185
- flex-direction: column;
186
- gap: 10px;
185
+ box-sizing: border-box;
186
+ }
187
+
188
+ .directive-openscad .parameters-body > * + * {
189
+ margin-top: 10px;
187
190
  }
188
191
 
189
192
  .directive-openscad .editor-container {
@@ -286,7 +289,13 @@
286
289
  width: 100%;
287
290
  border: 1px solid var(--color-spacer);
288
291
  flex: 1;
289
- margin: 0; /* override code-input.min.css default margin: 8px */
292
+ margin: 0;
293
+ min-height: 0;
294
+ overflow: hidden;
295
+ }
296
+
297
+ .directive-openscad .editor .cm-editor {
298
+ height: 100%;
290
299
  }
291
300
 
292
301
  /* The parameters textarea is always hidden — the form replaces it visually */
@@ -343,6 +352,52 @@
343
352
  text-align: center;
344
353
  }
345
354
 
355
+ .directive-openscad .param-group {
356
+ border: 1px solid var(--color-spacer);
357
+ border-radius: 6px;
358
+ overflow: hidden;
359
+ }
360
+
361
+ .directive-openscad .param-group-summary {
362
+ padding: 6px 12px;
363
+ font-weight: 600;
364
+ cursor: pointer;
365
+ background: var(--color-background, var(--color--background, #fff));
366
+ user-select: none;
367
+ list-style: none;
368
+ display: flex;
369
+ align-items: center;
370
+ gap: 6px;
371
+ }
372
+
373
+ .directive-openscad .param-group-summary::-webkit-details-marker {
374
+ display: none;
375
+ }
376
+
377
+ .directive-openscad .param-group-summary::before {
378
+ content: "▶";
379
+ font-size: 0.65em;
380
+ transition: transform 0.15s ease;
381
+ flex-shrink: 0;
382
+ }
383
+
384
+ .directive-openscad .param-group[open] .param-group-summary::before {
385
+ transform: rotate(90deg);
386
+ }
387
+
388
+ .directive-openscad .param-group-summary:hover {
389
+ background: var(--color-spacer);
390
+ }
391
+
392
+ .directive-openscad .param-group-body {
393
+ padding: 8px 12px;
394
+ border-top: 1px solid var(--color-spacer);
395
+ }
396
+
397
+ .directive-openscad .param-group-body > * + * {
398
+ margin-top: 10px;
399
+ }
400
+
346
401
  .directive-openscad:fullscreen {
347
402
  width: 100vw;
348
403
  height: 100dvh !important;
@@ -204,6 +204,261 @@ const serializeInvocationResults = (result) => {
204
204
  };
205
205
  };
206
206
 
207
+ const getExtractedParameters = (result) => {
208
+ if (result?.error || result?.exitCode !== 0) {
209
+ return [];
210
+ }
211
+ const output = result?.outputs?.[0]?.[1];
212
+ if (!output) return [];
213
+ try {
214
+ const bytes = output instanceof Uint8Array ? output : new Uint8Array(output);
215
+ const json = new TextDecoder().decode(bytes);
216
+ const paramSet = JSON.parse(json);
217
+ if (!Array.isArray(paramSet?.parameters)) return [];
218
+ return paramSet.parameters.filter((p) => !p.name?.startsWith("$"));
219
+ } catch (_) {
220
+ return [];
221
+ }
222
+ };
223
+
224
+ const escapeHtml = (value) =>
225
+ String(value)
226
+ .replace(/&/g, "&")
227
+ .replace(/</g, "&lt;")
228
+ .replace(/>/g, "&gt;")
229
+ .replace(/"/g, "&quot;")
230
+ .replace(/'/g, "&#39;");
231
+
232
+ const toFiniteNumber = (value, fallback = 0) => {
233
+ const num = Number(value);
234
+ return Number.isFinite(num) ? num : fallback;
235
+ };
236
+
237
+ const buildParamFormUi = (codeParams, currentOverrides = {}, id = "model") => {
238
+ if (!Array.isArray(codeParams) || codeParams.length === 0) {
239
+ return { hasParams: false, html: "", values: {} };
240
+ }
241
+
242
+ const values = {};
243
+
244
+ const appendNumberAttrs = (min, max, step) => {
245
+ let attrs = "";
246
+ if (step != null) attrs += ` step="${escapeHtml(String(step))}"`;
247
+ else attrs += ' step="any"';
248
+ if (min != null) attrs += ` min="${escapeHtml(String(min))}"`;
249
+ if (max != null) attrs += ` max="${escapeHtml(String(max))}"`;
250
+ return attrs;
251
+ };
252
+
253
+ const buildParamRow = (param, index) => {
254
+ const name = param?.name;
255
+ if (!name) return null;
256
+ const caption = param?.caption || name;
257
+ const type = param?.type;
258
+ const initial = param?.initial;
259
+ const min = param?.min;
260
+ const max = param?.max;
261
+ const step = param?.step;
262
+ const options = Array.isArray(param?.options) ? param.options : [];
263
+ const current = Object.prototype.hasOwnProperty.call(currentOverrides, name)
264
+ ? currentOverrides[name]
265
+ : initial;
266
+
267
+ const inputId = `openscad-param-${id}-${index}`;
268
+ let controlHtml = "";
269
+
270
+ if (type === "boolean") {
271
+ const value = Boolean(current);
272
+ values[name] = value;
273
+ controlHtml = `<input id="${escapeHtml(inputId)}" type="checkbox" data-param-name="${escapeHtml(name)}" data-param-kind="boolean"${value ? " checked" : ""}>`;
274
+ } else if (options.length > 0) {
275
+ let selectedIndex = options.findIndex((opt) => String(opt?.value) === String(current));
276
+ if (selectedIndex < 0) selectedIndex = 0;
277
+ const selectedValue = options[selectedIndex]?.value ?? null;
278
+ values[name] = selectedValue;
279
+ const optionHtml = options.map((opt, optIndex) => {
280
+ const optLabel = opt?.name || String(opt?.value);
281
+ const optValueJson = JSON.stringify(opt?.value ?? null);
282
+ const selected = optIndex === selectedIndex ? " selected" : "";
283
+ return `<option value="${escapeHtml(String(optIndex))}" data-param-option-value="${escapeHtml(optValueJson)}"${selected}>${escapeHtml(optLabel)}</option>`;
284
+ }).join("");
285
+ controlHtml = `<select id="${escapeHtml(inputId)}" data-param-name="${escapeHtml(name)}" data-param-kind="option">${optionHtml}</select>`;
286
+ } else if (type === "number" && Array.isArray(initial)) {
287
+ const source = Array.isArray(current) ? current : initial;
288
+ const vector = source.map((entry) => toFiniteNumber(entry));
289
+ values[name] = vector;
290
+ const vectorInputs = vector.map((entry, vectorIndex) =>
291
+ `<input id="${escapeHtml(`${inputId}-${vectorIndex}`)}" type="number" value="${escapeHtml(String(entry))}" data-param-name="${escapeHtml(name)}" data-param-kind="vector" data-vector-index="${vectorIndex}"${appendNumberAttrs(min, max, step)}>`
292
+ ).join("");
293
+ controlHtml = `<span class="param-vector">${vectorInputs}</span>`;
294
+ } else if (type === "number") {
295
+ const value = toFiniteNumber(current, toFiniteNumber(initial));
296
+ values[name] = value;
297
+ controlHtml = `<input id="${escapeHtml(inputId)}" type="number" value="${escapeHtml(String(value))}" data-param-name="${escapeHtml(name)}" data-param-kind="number"${appendNumberAttrs(min, max, step)}>`;
298
+ } else {
299
+ const value = current == null ? "" : String(current);
300
+ values[name] = value;
301
+ controlHtml = `<input id="${escapeHtml(inputId)}" type="text" value="${escapeHtml(value)}" data-param-name="${escapeHtml(name)}" data-param-kind="string">`;
302
+ }
303
+
304
+ return `<div class="param-row"><label for="${escapeHtml(inputId)}">${escapeHtml(caption)}</label>${controlHtml}</div>`;
305
+ };
306
+
307
+ // Separate global/ungrouped params from named groups.
308
+ // Parameters with group=null/undefined/"Global" are always shown outside any accordion.
309
+ const globalRows = [];
310
+ const groupMap = new Map(); // preserves insertion order
311
+
312
+ codeParams.forEach((param, index) => {
313
+ const row = buildParamRow(param, index);
314
+ if (!row) return;
315
+ const group = param?.group;
316
+ if (!group || group === "Global") {
317
+ globalRows.push(row);
318
+ } else {
319
+ if (!groupMap.has(group)) groupMap.set(group, []);
320
+ groupMap.get(group).push(row);
321
+ }
322
+ });
323
+
324
+ const parts = [...globalRows];
325
+
326
+ for (const [groupName, groupRows] of groupMap) {
327
+ parts.push(
328
+ `<details class="param-group" open><summary class="param-group-summary">${escapeHtml(groupName)}</summary><div class="param-group-body">${groupRows.join("")}</div></details>`
329
+ );
330
+ }
331
+
332
+ const totalRows = globalRows.length + [...groupMap.values()].reduce((sum, rows) => sum + rows.length, 0);
333
+
334
+ return {
335
+ hasParams: totalRows > 0,
336
+ html: parts.join(""),
337
+ values,
338
+ };
339
+ };
340
+
341
+ const DEFAULT_FACE_COLOR_WORKER = [0xf9 / 255, 0xd7 / 255, 0x2c / 255, 1];
342
+
343
+ // Compute flat (per-triangle) normals for a non-indexed positions array.
344
+ // Every 9 consecutive floats represent one triangle (3 vertices × xyz).
345
+ const computeFlatNormals = (positions) => {
346
+ const normals = new Float32Array(positions.length);
347
+ for (let i = 0; i < positions.length; i += 9) {
348
+ const ax = positions[i], ay = positions[i + 1], az = positions[i + 2];
349
+ const bx = positions[i + 3], by = positions[i + 4], bz = positions[i + 5];
350
+ const cx = positions[i + 6], cy = positions[i + 7], cz = positions[i + 8];
351
+ const ex = bx - ax, ey = by - ay, ez = bz - az;
352
+ const fx = cx - ax, fy = cy - ay, fz = cz - az;
353
+ const nx = ey * fz - ez * fy;
354
+ const ny = ez * fx - ex * fz;
355
+ const nz = ex * fy - ey * fx;
356
+ const len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
357
+ const nnx = nx / len, nny = ny / len, nnz = nz / len;
358
+ normals[i] = nnx; normals[i + 1] = nny; normals[i + 2] = nnz;
359
+ normals[i + 3] = nnx; normals[i + 4] = nny; normals[i + 5] = nnz;
360
+ normals[i + 6] = nnx; normals[i + 7] = nny; normals[i + 8] = nnz;
361
+ }
362
+ return normals;
363
+ };
364
+
365
+ // Parse an OFF file into flat Float32Array colour buckets so the main thread can
366
+ // construct Three.js geometries without any text-parsing work.
367
+ // Returns { colorBuckets: [{color, positions, normals}], transfer } or null on failure.
368
+ const parseOffToColorBuckets = (offData) => {
369
+ try {
370
+ const text = new TextDecoder().decode(
371
+ offData instanceof Uint8Array ? offData : new Uint8Array(offData),
372
+ );
373
+ const lines = text
374
+ .split("\n")
375
+ .map((l) => l.trim())
376
+ .filter((l) => l.length > 0 && !l.startsWith("#"));
377
+ if (lines.length === 0) return null;
378
+
379
+ let countsLine = "";
380
+ let currentLine = 0;
381
+ if (/^OFF(\s|$)/.test(lines[0])) {
382
+ countsLine = lines[0].substring(3).trim();
383
+ currentLine = 1;
384
+ }
385
+ // Handle standard two-line OFF header: "OFF\nV F E"
386
+ if (!countsLine && currentLine < lines.length) {
387
+ countsLine = lines[currentLine];
388
+ currentLine++;
389
+ }
390
+ if (!countsLine) return null;
391
+
392
+ const countParts = countsLine.split(/\s+/).map(Number);
393
+ const vertexCount = Number.isFinite(countParts[0]) ? Math.floor(countParts[0]) : NaN;
394
+ const faceCount = Number.isFinite(countParts[1]) ? Math.floor(countParts[1]) : NaN;
395
+ if (!Number.isFinite(vertexCount) || !Number.isFinite(faceCount) || vertexCount <= 0 || faceCount <= 0) return null;
396
+ if (currentLine + vertexCount + faceCount > lines.length) return null;
397
+
398
+ const vertices = new Float32Array(vertexCount * 3);
399
+ for (let i = 0; i < vertexCount; i++) {
400
+ const parts = lines[currentLine + i].split(/\s+/).map(Number);
401
+ vertices[i * 3] = parts[0] || 0;
402
+ vertices[i * 3 + 1] = parts[1] || 0;
403
+ vertices[i * 3 + 2] = parts[2] || 0;
404
+ }
405
+ currentLine += vertexCount;
406
+
407
+ const colorMap = new Map();
408
+ const buckets = [];
409
+
410
+ for (let i = 0; i < faceCount; i++) {
411
+ const parts = lines[currentLine + i].split(/\s+/).map(Number);
412
+ const numVerts = Number.isFinite(parts[0]) ? Math.floor(parts[0]) : 0;
413
+ const faceVertices = parts.slice(1, numVerts + 1).map(Math.floor);
414
+ if (faceVertices.length < 3) continue;
415
+
416
+ let color = DEFAULT_FACE_COLOR_WORKER;
417
+ if (parts.length >= numVerts + 4) {
418
+ const raw = parts.slice(numVerts + 1, numVerts + 5).filter(Number.isFinite);
419
+ if (raw.length >= 3) {
420
+ const r = raw[0], g = raw[1], b = raw[2];
421
+ const a = raw.length >= 4 ? raw[3] : (Math.max(r, g, b) > 1 ? 255 : 1);
422
+ const div = Math.max(r, g, b, a) > 1 ? 255 : 1;
423
+ color = [r / div, g / div, b / div, a / div];
424
+ }
425
+ }
426
+
427
+ const colorKey = color.join(",");
428
+ let bucket = colorMap.get(colorKey);
429
+ if (!bucket) {
430
+ bucket = { color, positionsList: [] };
431
+ colorMap.set(colorKey, bucket);
432
+ buckets.push(bucket);
433
+ }
434
+
435
+ // Fan triangulation of potentially non-triangular faces
436
+ for (let j = 1; j < faceVertices.length - 1; j++) {
437
+ const i1 = faceVertices[0], i2 = faceVertices[j], i3 = faceVertices[j + 1];
438
+ bucket.positionsList.push(
439
+ vertices[i1 * 3], vertices[i1 * 3 + 1], vertices[i1 * 3 + 2],
440
+ vertices[i2 * 3], vertices[i2 * 3 + 1], vertices[i2 * 3 + 2],
441
+ vertices[i3 * 3], vertices[i3 * 3 + 1], vertices[i3 * 3 + 2],
442
+ );
443
+ }
444
+ }
445
+
446
+ if (buckets.length === 0) return null;
447
+
448
+ const transfer = [];
449
+ const colorBuckets = buckets.map((bucket) => {
450
+ const positions = new Float32Array(bucket.positionsList);
451
+ const normals = computeFlatNormals(positions);
452
+ transfer.push(positions.buffer, normals.buffer);
453
+ return { color: bucket.color, positions, normals };
454
+ });
455
+
456
+ return { colorBuckets, transfer };
457
+ } catch (_) {
458
+ return null;
459
+ }
460
+ };
461
+
207
462
  const runOpenScadInvocation = async ({
208
463
  code,
209
464
  sourcePath,
@@ -284,6 +539,40 @@ self.addEventListener("message", async (event) => {
284
539
  self.postMessage({ requestId, ok: true, result: serialized.result }, serialized.transfer);
285
540
  return;
286
541
  }
542
+ if (type === "buildParamForm") {
543
+ const sourcePath = "/tmp/params_model.scad";
544
+ const outPath = "/tmp/params_out.json";
545
+ result = await runOpenScadInvocation({
546
+ code: `$preview=true;\n${payload?.code || ""}`,
547
+ sourcePath,
548
+ outputPaths: [outPath],
549
+ libraryNames: payload?.libraryNames || [],
550
+ args: [
551
+ sourcePath,
552
+ "-o",
553
+ outPath,
554
+ "--export-format=param",
555
+ ],
556
+ });
557
+
558
+ const codeParams = getExtractedParameters(result);
559
+ const ui = buildParamFormUi(
560
+ codeParams,
561
+ payload?.currentOverrides || {},
562
+ payload?.id || "model",
563
+ );
564
+
565
+ self.postMessage({
566
+ requestId,
567
+ ok: true,
568
+ result: {
569
+ hasParams: ui.hasParams,
570
+ html: ui.html,
571
+ values: ui.values,
572
+ },
573
+ });
574
+ return;
575
+ }
287
576
  if (type === "render") {
288
577
  const format = payload?.format || "stl";
289
578
  const sourcePath = "/tmp/model.scad";
@@ -304,6 +593,27 @@ self.addEventListener("message", async (event) => {
304
593
  ...OPENSCAD_FEATURE_ARGS,
305
594
  ],
306
595
  });
596
+ // For preview OFF renders, parse geometry here in the worker so the main thread
597
+ // never has to do heavy text parsing or Float32Array building.
598
+ if (format === "off" && payload?.isPreview && result.exitCode === 0 && !result.error) {
599
+ const offOutput = result.outputs?.[0]?.[1];
600
+ if (offOutput) {
601
+ const parsed = parseOffToColorBuckets(offOutput);
602
+ if (parsed) {
603
+ self.postMessage({
604
+ requestId,
605
+ ok: true,
606
+ result: {
607
+ exitCode: result.exitCode,
608
+ mergedOutputs: result.mergedOutputs,
609
+ elapsedMillis: result.elapsedMillis,
610
+ parsedGeometry: parsed.colorBuckets,
611
+ },
612
+ }, parsed.transfer);
613
+ return;
614
+ }
615
+ }
616
+ }
307
617
  const serialized = serializeInvocationResults(result);
308
618
  self.postMessage({ requestId, ok: true, result: serialized.result }, serialized.transfer);
309
619
  return;
@@ -7,14 +7,6 @@
7
7
  * @see hyperbook.store
8
8
  */
9
9
  hyperbook.p5 = (function () {
10
- window.codeInput?.registerTemplate(
11
- "p5-highlighted",
12
- codeInput.templates.prism(window.Prism, [
13
- new codeInput.plugins.AutoCloseBrackets(),
14
- new codeInput.plugins.Indent(true, 2),
15
- ]),
16
- );
17
-
18
10
  const wrapSketch = (sketchCode) => {
19
11
  if (sketchCode !== "" && !sketchCode?.includes("setup")) {
20
12
  return `
@@ -128,7 +120,7 @@ hyperbook.p5 = (function () {
128
120
  const container = elem.querySelector(".container");
129
121
  const editorContainer = elem.querySelector(".editor-container");
130
122
  const splitter = elem.querySelector(".splitter");
131
- const editor = elem.getElementsByClassName("editor")[0];
123
+ const editorDiv = elem.getElementsByClassName("editor")[0];
132
124
  /** @type {HTMLButtonElement} */
133
125
  const update = elem.getElementsByClassName("update")[0];
134
126
  const frame = elem.getElementsByTagName("iframe")[0];
@@ -139,6 +131,20 @@ hyperbook.p5 = (function () {
139
131
  const downloadEl = elem.getElementsByClassName("download")[0];
140
132
  const fullscreenEl = elem.getElementsByClassName("fullscreen")[0];
141
133
 
134
+ // Initialize CodeMirror editor (only in editor mode)
135
+ let cm = null;
136
+ if (editorDiv) {
137
+ const initialSource = editorDiv.textContent;
138
+ editorDiv.textContent = "";
139
+ cm = HyperbookCM.create(editorDiv, {
140
+ lang: editorDiv.dataset.lang,
141
+ value: initialSource,
142
+ onChange: (code) => {
143
+ if (id) hyperbook.store.db.p5.put({ id, sketch: code });
144
+ },
145
+ });
146
+ }
147
+
142
148
  setupSplitter(elem, container, editorContainer, splitter);
143
149
 
144
150
  fullscreenEl?.addEventListener("click", async () => {
@@ -159,7 +165,7 @@ hyperbook.p5 = (function () {
159
165
 
160
166
  copyEl?.addEventListener("click", async () => {
161
167
  try {
162
- await navigator.clipboard.writeText(editor.value);
168
+ await navigator.clipboard.writeText(cm?.getValue() ?? "");
163
169
  } catch (error) {
164
170
  console.error(error.message);
165
171
  }
@@ -172,35 +178,30 @@ hyperbook.p5 = (function () {
172
178
 
173
179
  downloadEl?.addEventListener("click", () => {
174
180
  const a = document.createElement("a");
175
- const blob = new Blob([editor.value], { type: "text/plain" });
181
+ const blob = new Blob([cm?.getValue() ?? ""], { type: "text/plain" });
176
182
  a.href = URL.createObjectURL(blob);
177
183
  a.download = `sketch-${id}.js`;
178
184
  a.click();
179
185
  });
180
186
 
181
- editor?.addEventListener("code-input_load", async () => {
182
- if (id) {
183
- const result = await hyperbook.store.db.p5.get(id);
187
+ if (id && cm) {
188
+ hyperbook.store.db.p5.get(id).then((result) => {
184
189
  if (result) {
185
- editor.value = result.sketch;
190
+ cm.setValue(result.sketch);
186
191
  const code = result.sketch;
187
192
  frame.srcdoc = template
188
193
  .replace("###SLOT###", wrapSketch(code))
189
194
  .replaceAll("###ORIGIN###", window.location.origin)
190
195
  .replace(/\u00A0/g, " ");
191
196
  }
192
-
193
- editor.addEventListener("input", () => {
194
- hyperbook.store.db.p5.put({ id, sketch: editor.value });
195
- });
196
- }
197
-
198
- update?.addEventListener("click", () => {
199
- const code = editor.value;
200
- frame.srcdoc = template
201
- .replace("###SLOT###", wrapSketch(code))
202
- .replaceAll("###ORIGIN###", window.location.origin);
203
197
  });
198
+ }
199
+
200
+ update?.addEventListener("click", () => {
201
+ const code = cm?.getValue() ?? "";
202
+ frame.srcdoc = template
203
+ .replace("###SLOT###", wrapSketch(code))
204
+ .replaceAll("###ORIGIN###", window.location.origin);
204
205
  });
205
206
  }
206
207
 
@@ -11,6 +11,18 @@ code-input {
11
11
  margin: 0;
12
12
  }
13
13
 
14
+ .directive-p5 .editor {
15
+ width: 100%;
16
+ border: 1px solid var(--color-spacer);
17
+ flex: 1;
18
+ min-height: 0;
19
+ overflow: hidden;
20
+ }
21
+
22
+ .directive-p5 .editor .cm-editor {
23
+ height: 100%;
24
+ }
25
+
14
26
  .directive-p5 .container {
15
27
  width: 100%;
16
28
  min-height: 120px;
@@ -70,12 +82,6 @@ code-input {
70
82
  opacity: 0.75;
71
83
  }
72
84
 
73
- .directive-p5 .editor {
74
- width: 100%;
75
- border: 1px solid var(--color-spacer);
76
- flex: 1;
77
- }
78
-
79
85
  .directive-p5 .buttons {
80
86
  display: flex;
81
87
  border: 1px solid var(--color-spacer);