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.
@@ -8,34 +8,25 @@
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
11
  let threePromise = null;
20
- let openscadWorkerPromise = null;
21
12
  let workerRequestId = 0;
22
- const pendingWorkerRequests = new Map();
23
-
24
- const i18nGet = (key, fallback = key) => hyperbook.i18n?.get(key) || fallback;
25
13
 
26
- const rejectPendingWorkerRequests = (error) => {
27
- for (const { reject } of pendingWorkerRequests.values()) {
28
- reject(error);
29
- }
30
- pendingWorkerRequests.clear();
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() },
31
19
  };
32
20
 
33
- const getOpenScadWorker = async () => {
21
+ const i18nGet = (key, fallback = key) => hyperbook.i18n?.get(key) || fallback;
22
+
23
+ const getWorker = async (slot) => {
34
24
  if (!window.Worker) {
35
25
  throw new Error(i18nGet("openscad-render-failed", "OpenSCAD render failed"));
36
26
  }
37
- if (!openscadWorkerPromise) {
38
- openscadWorkerPromise = new Promise((resolve, reject) => {
27
+ const s = workerSlots[slot];
28
+ if (!s.promise) {
29
+ s.promise = new Promise((resolve, reject) => {
39
30
  try {
40
31
  const worker = new Worker(new URL(_scriptBase + "worker.js", window.location.href), {
41
32
  type: "module",
@@ -43,9 +34,9 @@ hyperbook.openscad = (function () {
43
34
 
44
35
  worker.addEventListener("message", (event) => {
45
36
  const { requestId, ok, result, error } = event.data || {};
46
- if (!requestId || !pendingWorkerRequests.has(requestId)) return;
47
- const pending = pendingWorkerRequests.get(requestId);
48
- pendingWorkerRequests.delete(requestId);
37
+ if (!requestId || !s.pending.has(requestId)) return;
38
+ const pending = s.pending.get(requestId);
39
+ s.pending.delete(requestId);
49
40
  if (ok) {
50
41
  pending.resolve(result);
51
42
  return;
@@ -59,35 +50,38 @@ hyperbook.openscad = (function () {
59
50
 
60
51
  worker.addEventListener("error", (event) => {
61
52
  const workerError = new Error(event?.message || "OpenSCAD worker crashed");
62
- rejectPendingWorkerRequests(workerError);
63
- openscadWorkerPromise = null;
53
+ for (const { reject } of s.pending.values()) reject(workerError);
54
+ s.pending.clear();
55
+ s.promise = null;
64
56
  });
65
57
 
66
58
  worker.addEventListener("messageerror", () => {
67
59
  const workerError = new Error("OpenSCAD worker message error");
68
- rejectPendingWorkerRequests(workerError);
69
- openscadWorkerPromise = null;
60
+ for (const { reject } of s.pending.values()) reject(workerError);
61
+ s.pending.clear();
62
+ s.promise = null;
70
63
  });
71
64
 
72
65
  resolve(worker);
73
66
  } catch (error) {
74
- openscadWorkerPromise = null;
67
+ s.promise = null;
75
68
  reject(error);
76
69
  }
77
70
  });
78
71
  }
79
- return openscadWorkerPromise;
72
+ return s.promise;
80
73
  };
81
74
 
82
- const callOpenScadWorker = async (type, payload, transfer = []) => {
83
- const worker = await getOpenScadWorker();
75
+ const callWorker = async (slot, type, payload, transfer = []) => {
76
+ const worker = await getWorker(slot);
77
+ const s = workerSlots[slot];
84
78
  const requestId = ++workerRequestId;
85
79
  return new Promise((resolve, reject) => {
86
- pendingWorkerRequests.set(requestId, { resolve, reject });
80
+ s.pending.set(requestId, { resolve, reject });
87
81
  try {
88
82
  worker.postMessage({ requestId, type, payload }, transfer);
89
83
  } catch (error) {
90
- pendingWorkerRequests.delete(requestId);
84
+ s.pending.delete(requestId);
91
85
  reject(error);
92
86
  }
93
87
  });
@@ -98,22 +92,27 @@ hyperbook.openscad = (function () {
98
92
  .filter((entry) => typeof entry?.stderr === "string")
99
93
  .map((entry) => entry.stderr);
100
94
 
101
- // Extract parameters from SCAD code in the worker to keep the main thread responsive.
102
- const extractParams = async (code, libraryNames = []) => {
95
+ // Build parameter UI metadata/markup in the worker to minimize main-thread work.
96
+ const buildParamUiInWorker = async (code, libraryNames = [], currentOverrides = {}, id = "") => {
103
97
  try {
104
- const result = await callOpenScadWorker("extractParams", { code, libraryNames });
105
- if (result?.error || result?.exitCode !== 0) {
106
- return [];
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("$"));
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
+ };
114
109
  } catch (e) {
115
- console.warn("[openscad] Worker param extraction failed:", e);
116
- return [];
110
+ console.warn("[openscad] Worker param UI build failed:", e);
111
+ return {
112
+ hasParams: false,
113
+ html: "",
114
+ values: {},
115
+ };
117
116
  }
118
117
  };
119
118
 
@@ -173,6 +172,7 @@ hyperbook.openscad = (function () {
173
172
  const delta = moveEvent.clientY - startPointer;
174
173
  const size = applySplitSize(startSize + delta);
175
174
  leftSide.dataset.splitCanvasParams = String(Math.round(size));
175
+ onSplitChanged?.({ splitCanvasParams: Math.round(size) });
176
176
  };
177
177
 
178
178
  const onPointerUp = () => {
@@ -246,6 +246,9 @@ hyperbook.openscad = (function () {
246
246
  const delta = pointer - startPointer;
247
247
  const size = applySplitSize(startSize + delta, isHorizontal);
248
248
  elem.dataset[key] = String(Math.round(size));
249
+ onSplitChanged?.({
250
+ [key]: Math.round(size),
251
+ });
249
252
  };
250
253
 
251
254
  const onPointerUp = () => {
@@ -555,6 +558,35 @@ hyperbook.openscad = (function () {
555
558
  return model;
556
559
  };
557
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
+
558
590
  const exportIndexedPolyhedronTo3mf = (polyhedron) => {
559
591
  const objectUuid = createUuid();
560
592
  const buildUuid = createUuid();
@@ -635,9 +667,12 @@ hyperbook.openscad = (function () {
635
667
  const splitter = elem.querySelector(".splitter");
636
668
  const canvasParamsSplitter = elem.querySelector(".canvas-params-splitter");
637
669
  const canvas = elem.querySelector(".preview-canvas");
638
- const editor = elem.querySelector("code-input.editor");
670
+ const editorDiv = elem.querySelector(".editor");
639
671
  const params = elem.querySelector("textarea.parameters");
640
672
 
673
+ // `cm` will be initialized after scheduleSave/scheduleParamBuild are defined.
674
+ let cm = null;
675
+
641
676
  // The parameters panel is its own card below the canvas.
642
677
  const paramsPanel = elem.querySelector(".parameters-panel");
643
678
  const paramsForm = paramsPanel?.querySelector(".parameters-body") ?? paramsPanel;
@@ -704,6 +739,30 @@ hyperbook.openscad = (function () {
704
739
  }
705
740
  };
706
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
+
707
766
  // Resize the Three.js renderer to match the current canvas-wrapper size.
708
767
  const resizeCanvas = () => {
709
768
  if (!viewerState.renderer || !viewerState.camera || !canvasWrapper) return;
@@ -712,28 +771,26 @@ hyperbook.openscad = (function () {
712
771
  viewerState.renderer.setSize(w, h, false);
713
772
  viewerState.camera.aspect = w / h;
714
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
+ });
715
783
  };
716
784
 
717
785
  const applyMainSplitSize = setupSplitter(elem, leftSide, editorContainer, splitter, () => {
718
- resizeCanvas();
719
- save();
786
+ scheduleResizeCanvas();
787
+ scheduleSave();
720
788
  });
721
789
  const applyCanvasParamsSplitSize = setupCanvasParamsSplitter(leftSide, previewContainer, paramsPanel, canvasParamsSplitter, () => {
722
- resizeCanvas();
723
- save();
790
+ scheduleResizeCanvas();
791
+ scheduleSave();
724
792
  });
725
793
 
726
- const viewerState = {
727
- renderer: null,
728
- camera: null,
729
- scene: null,
730
- controls: null,
731
- model: null,
732
- raf: 0,
733
- disposed: false,
734
- resizeObserver: null,
735
- };
736
-
737
794
  const save = async () => {
738
795
  if (!id) return;
739
796
  const splitHorizontal = Number(elem.dataset.splitHorizontal);
@@ -741,7 +798,7 @@ hyperbook.openscad = (function () {
741
798
  const splitCanvasParams = Number(leftSide?.dataset.splitCanvasParams);
742
799
  await hyperbook.store.db.openscad.put({
743
800
  id,
744
- code: editor?.value || "",
801
+ code: cm?.getValue() || "",
745
802
  params: params?.value || "{}",
746
803
  ...(Number.isFinite(splitHorizontal) && splitHorizontal > 0
747
804
  ? { splitHorizontal: Math.round(splitHorizontal) }
@@ -759,8 +816,8 @@ hyperbook.openscad = (function () {
759
816
  if (!id) return null;
760
817
  const result = await hyperbook.store.db.openscad.get(id);
761
818
  if (!result) return null;
762
- if (editor && typeof result.code === "string") {
763
- editor.value = result.code;
819
+ if (cm && typeof result.code === "string") {
820
+ cm.setValue(result.code);
764
821
  }
765
822
  if (params && typeof result.params === "string") {
766
823
  params.value = result.params;
@@ -777,141 +834,192 @@ hyperbook.openscad = (function () {
777
834
  return result;
778
835
  };
779
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
+
780
936
  // Rebuild the parameters form from the code's top-level variable assignments.
781
937
  // Stored overrides from the textarea are preserved so user edits survive
782
938
  // code changes that don't touch those variable names.
783
- const buildParamForm = async (code) => {
784
- // 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.
785
941
  paramsForm.innerHTML = "";
786
- paramsPanel?.classList.remove("hidden");
787
- canvasParamsSplitter?.classList.remove("hidden");
788
942
  const loading = document.createElement("p");
789
943
  loading.className = "params-empty";
790
944
  loading.textContent = i18nGet("openscad-params-loading", "Loading parameters...");
791
945
  paramsForm.appendChild(loading);
792
946
 
793
- const codeParams = await extractParams(code, libraryNames);
794
- paramsForm.innerHTML = "";
795
-
796
- if (codeParams.length === 0) {
797
- paramsPanel?.classList.add("hidden");
798
- canvasParamsSplitter?.classList.add("hidden");
947
+ // Code is the source of truth — param changes are always synced back to the
948
+ // code, so we never need stored overrides to win over the code's own values.
949
+ const result = await buildParamUiInWorker(code, libraryNames, {}, id || "model");
950
+ if (buildToken !== latestParamBuildToken) {
951
+ return false;
952
+ }
953
+ if (!result.hasParams) {
954
+ paramsForm.innerHTML = "";
955
+ const empty = document.createElement("p");
956
+ empty.className = "params-empty";
957
+ empty.textContent = i18nGet("openscad-params-none", "No parameters");
958
+ paramsForm.appendChild(empty);
959
+ paramValues = {};
799
960
  if (params) params.value = "{}";
800
- return;
961
+ return true;
801
962
  }
802
963
 
803
- let currentOverrides = {};
804
- try {
805
- currentOverrides = JSON.parse(params?.value || "{}");
806
- } catch (_) {}
807
-
808
- const syncTextarea = () => {
809
- const values = {};
810
- paramsForm.querySelectorAll("[data-param-name]").forEach((input) => {
811
- const name = input.dataset.paramName;
812
- const type = input.dataset.paramType;
813
- if (type === "boolean") {
814
- values[name] = input.checked;
815
- } else if (type === "number") {
816
- values[name] = Number(input.value);
817
- } else {
818
- values[name] = input.value;
964
+ // Preserve accordion open/closed state by group name before replacing HTML.
965
+ const accordionStates = new Map();
966
+ paramsForm.querySelectorAll("details.param-group").forEach((details) => {
967
+ const summary = details.querySelector("summary.param-group-summary");
968
+ if (summary) {
969
+ accordionStates.set(summary.textContent.trim(), details.open);
970
+ }
971
+ });
972
+
973
+ paramsForm.innerHTML = result.html;
974
+
975
+ // Restore accordion states after the rebuild.
976
+ if (accordionStates.size > 0) {
977
+ paramsForm.querySelectorAll("details.param-group").forEach((details) => {
978
+ const summary = details.querySelector("summary.param-group-summary");
979
+ if (summary) {
980
+ const name = summary.textContent.trim();
981
+ if (accordionStates.has(name)) {
982
+ details.open = accordionStates.get(name);
983
+ }
819
984
  }
820
985
  });
821
- if (params) params.value = JSON.stringify(values);
822
- save();
823
- };
986
+ }
824
987
 
825
- codeParams.forEach(({ name, caption, type, initial, min, max, step, options }) => {
826
- const current =
827
- currentOverrides[name] !== undefined ? currentOverrides[name] : initial;
828
-
829
- const row = document.createElement("div");
830
- row.className = "param-row";
831
-
832
- const label = document.createElement("label");
833
- label.textContent = caption || name;
834
- label.setAttribute("for", `openscad-param-${id}-${name}`);
835
-
836
- let input;
837
- if (type === "boolean") {
838
- input = document.createElement("input");
839
- input.type = "checkbox";
840
- input.checked = Boolean(current);
841
- } else if (options && options.length > 0) {
842
- // Dropdown for parameters with a fixed set of options.
843
- input = document.createElement("select");
844
- options.forEach(({ name: optName, value: optValue }) => {
845
- const opt = document.createElement("option");
846
- opt.value = String(optValue);
847
- opt.textContent = optName || String(optValue);
848
- if (String(optValue) === String(current)) opt.selected = true;
849
- input.appendChild(opt);
850
- });
851
- input.addEventListener("change", syncTextarea);
852
- } else if (type === "number" && Array.isArray(initial)) {
853
- // Vector: render one number input per component.
854
- input = document.createElement("span");
855
- input.className = "param-vector";
856
- const arr = Array.isArray(current) ? current : initial;
857
- arr.forEach((val, idx) => {
858
- const ni = document.createElement("input");
859
- ni.type = "number";
860
- ni.value = String(val);
861
- ni.step = step != null ? String(step) : "any";
862
- if (min != null) ni.min = String(min);
863
- if (max != null) ni.max = String(max);
864
- ni.dataset.paramName = name;
865
- ni.dataset.paramType = "vector";
866
- ni.dataset.vectorIndex = String(idx);
867
- ni.addEventListener("input", () => {
868
- const all = Array.from(input.querySelectorAll("input")).map(
869
- (i) => Number(i.value)
870
- );
871
- const sibling = paramsForm.querySelector(
872
- `[data-param-name="${name}"][data-param-type="number"]`
873
- );
874
- if (sibling) sibling.value = JSON.stringify(all);
875
- syncTextarea();
876
- });
877
- input.appendChild(ni);
878
- });
879
- // Hidden input holds the JSON array for syncTextarea to read.
880
- const hidden = document.createElement("input");
881
- hidden.type = "hidden";
882
- hidden.dataset.paramName = name;
883
- hidden.dataset.paramType = "number";
884
- hidden.value = JSON.stringify(arr);
885
- input.appendChild(hidden);
886
- } else if (type === "number") {
887
- input = document.createElement("input");
888
- input.type = "number";
889
- input.value = String(current);
890
- input.step = step != null ? String(step) : "any";
891
- if (min != null) input.min = String(min);
892
- if (max != null) input.max = String(max);
893
- } else {
894
- input = document.createElement("input");
895
- input.type = "text";
896
- input.value = String(current);
897
- }
898
- input.id = `openscad-param-${id}-${name}`;
899
- if (input.tagName !== "SPAN") {
900
- input.dataset.paramName = name;
901
- input.dataset.paramType = type;
902
- input.addEventListener("input", syncTextarea);
903
- }
988
+ paramValues = result.values || {};
989
+ syncParamsTextareaFromState();
990
+ scheduleSave();
991
+ return true;
992
+ };
904
993
 
905
- row.appendChild(label);
906
- row.appendChild(input);
907
- paramsForm.appendChild(row);
908
- });
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
+ };
909
1011
 
910
- syncTextarea();
1012
+ const scheduleParamBuild = (code) => {
1013
+ latestParamBuildToken += 1;
1014
+ pendingParamCode = code;
1015
+ clearTimeout(paramRebuildTimer);
1016
+ paramRebuildTimer = window.setTimeout(() => {
1017
+ void runPendingParamBuild();
1018
+ }, PARAM_REBUILD_DEBOUNCE_MS);
911
1019
  };
912
1020
 
913
1021
  const getParamDefinitions = () => {
914
- const parsed = JSON.parse(params?.value || "{}");
1022
+ const parsed = paramValues;
915
1023
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
916
1024
  throw new Error(i18nGet("openscad-params-object", "Parameters must be a JSON object"));
917
1025
  }
@@ -923,12 +1031,15 @@ hyperbook.openscad = (function () {
923
1031
  showOverlay("loading", i18nGet("openscad-rendering", "Rendering..."));
924
1032
 
925
1033
  try {
926
- const paramDefinitions = getParamDefinitions();
927
- const result = await callOpenScadWorker("render", {
928
- code: editor?.value || "",
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() || "",
929
1040
  format,
930
1041
  libraryNames,
931
- paramDefinitions,
1042
+ paramDefinitions: [],
932
1043
  isPreview,
933
1044
  });
934
1045
  const stderr = getInvocationStderr(result);
@@ -937,6 +1048,10 @@ hyperbook.openscad = (function () {
937
1048
  error.stderr = stderr;
938
1049
  throw error;
939
1050
  }
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 };
1054
+ }
940
1055
  const output = result?.outputs?.[0]?.[1];
941
1056
  if (!output) {
942
1057
  const error = new Error(i18nGet("openscad-render-failed", "OpenSCAD render failed"));
@@ -964,8 +1079,12 @@ hyperbook.openscad = (function () {
964
1079
  const renderPreview = async () => {
965
1080
  try {
966
1081
  await save();
967
- const { content: off } = await renderWithFormat("off", libraryNames, true);
968
- await renderOff(off);
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
+ }
969
1088
  hideOverlay();
970
1089
  } catch (error) {
971
1090
  const stderrErrors = (error?.stderr || []).filter((l) => /error/i.test(l)).join("\n");
@@ -979,6 +1098,13 @@ hyperbook.openscad = (function () {
979
1098
  }
980
1099
  };
981
1100
 
1101
+ const scheduleParamRender = () => {
1102
+ clearTimeout(paramRenderTimer);
1103
+ paramRenderTimer = window.setTimeout(() => {
1104
+ void renderPreview();
1105
+ }, PARAM_RENDER_DEBOUNCE_MS);
1106
+ };
1107
+
982
1108
  const disposeModel = () => {
983
1109
  if (!viewerState.model || !viewerState.scene) return;
984
1110
  viewerState.scene.remove(viewerState.model);
@@ -1020,19 +1146,10 @@ hyperbook.openscad = (function () {
1020
1146
 
1021
1147
  viewerState.camera = new THREE.PerspectiveCamera(45, 1, 0.1, 5000);
1022
1148
  viewerState.controls = new OrbitControls(viewerState.camera, canvas);
1023
- viewerState.controls.enableDamping = true;
1149
+ viewerState.controls.enableDamping = false;
1150
+ viewerState.controls.addEventListener("change", requestRender);
1024
1151
 
1025
- const tick = () => {
1026
- if (viewerState.disposed) return;
1027
- if (viewerState.controls) viewerState.controls.update();
1028
- if (viewerState.renderer && viewerState.scene && viewerState.camera) {
1029
- viewerState.renderer.render(viewerState.scene, viewerState.camera);
1030
- }
1031
- viewerState.raf = requestAnimationFrame(tick);
1032
- };
1033
- tick();
1034
-
1035
- viewerState.resizeObserver = new ResizeObserver(() => resizeCanvas());
1152
+ viewerState.resizeObserver = new ResizeObserver(() => scheduleResizeCanvas());
1036
1153
  viewerState.resizeObserver.observe(canvasWrapper || previewContainer);
1037
1154
  }
1038
1155
 
@@ -1045,8 +1162,10 @@ hyperbook.openscad = (function () {
1045
1162
 
1046
1163
  disposeModel();
1047
1164
 
1048
- const polyhedron = parseOffToIndexedPolyhedron(offData);
1049
- const model = buildThreeModelFromIndexedPolyhedron(polyhedron, THREE);
1165
+ const polyhedron = offData?.colorBuckets ? null : parseOffToIndexedPolyhedron(offData);
1166
+ const model = offData?.colorBuckets
1167
+ ? buildThreeModelFromColorBuckets(offData.colorBuckets, THREE)
1168
+ : buildThreeModelFromIndexedPolyhedron(polyhedron, THREE);
1050
1169
  viewerState.model = model;
1051
1170
  viewerState.scene.add(model);
1052
1171
 
@@ -1073,6 +1192,7 @@ hyperbook.openscad = (function () {
1073
1192
 
1074
1193
  viewerState.controls.target.set(0, 0, 0);
1075
1194
  viewerState.controls.update();
1195
+ requestRender();
1076
1196
  };
1077
1197
 
1078
1198
  copyBtn?.addEventListener("click", async () => {
@@ -1121,6 +1241,24 @@ hyperbook.openscad = (function () {
1121
1241
  updateFullscreenButtonState(elem, fullscreenBtn);
1122
1242
 
1123
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
+
1124
1262
  const restoreEditorState = async () => {
1125
1263
  if (editorStateRestored) return;
1126
1264
  editorStateRestored = true;
@@ -1129,36 +1267,29 @@ hyperbook.openscad = (function () {
1129
1267
  // Re-apply split sizes after stored dataset values are applied by load().
1130
1268
  applyMainSplitSize?.();
1131
1269
  applyCanvasParamsSplitSize?.();
1132
- let paramRebuildTimer = null;
1133
- editor.addEventListener("input", () => {
1134
- save();
1135
- clearTimeout(paramRebuildTimer);
1136
- paramRebuildTimer = setTimeout(() => buildParamForm(editor.value), 500);
1137
- });
1138
1270
 
1139
1271
  // Use stored code if available; otherwise fall back to the editor's
1140
1272
  // current value (the markdown default) or the built-in placeholder.
1141
- const initialCode = stored?.code || editor.value.trim() || "// OpenSCAD\ncube([20,20,20], center=true);";
1142
- editor.value = initialCode;
1273
+ const initialCode = stored?.code || cm?.getValue().trim() || "// OpenSCAD\ncube([20,20,20], center=true);";
1274
+ cm?.setValue(initialCode);
1143
1275
  if (!params?.value.trim()) {
1144
1276
  params.value = "{}";
1145
1277
  }
1146
- await buildParamForm(initialCode);
1147
- // Only persist when there was no stored entry; if one already existed we
1148
- // must not overwrite itreading editor.value right now may return stale
1149
- // data because code-input's async re-render may not have completed yet.
1278
+ latestParamBuildToken += 1;
1279
+ const initParamToken = latestParamBuildToken;
1280
+ // Fire param extraction and rendering concurrently each uses its own dedicated worker.
1281
+ buildParamForm(initialCode, initParamToken).then(() => {
1282
+ if (initParamToken === latestParamBuildToken) {
1283
+ lastBuiltParamCode = initialCode;
1284
+ }
1285
+ }).catch(() => {});
1150
1286
  if (!stored) {
1151
1287
  await save();
1152
1288
  }
1153
1289
  renderPreview();
1154
1290
  };
1155
1291
 
1156
- editor?.addEventListener("code-input_load", restoreEditorState);
1157
- // SPA timing: if code-input already rendered its inner textarea before we
1158
- // attached the listener, fire the handler immediately (mirrors pyide).
1159
- if (editor?.querySelector("textarea")) {
1160
- void restoreEditorState();
1161
- }
1292
+ void restoreEditorState();
1162
1293
  }
1163
1294
 
1164
1295
  function init(root) {