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.
- 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 +350 -219
- package/dist/assets/directive-openscad/style.css +59 -4
- package/dist/assets/directive-openscad/worker.js +310 -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 +37 -40
- 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 +37 -71
- package/dist/locales/de.json +1 -0
- package/dist/locales/en.json +1 -0
- package/package.json +4 -4
- 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
|
@@ -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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
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 || !
|
|
47
|
-
const pending =
|
|
48
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
67
|
+
s.promise = null;
|
|
75
68
|
reject(error);
|
|
76
69
|
}
|
|
77
70
|
});
|
|
78
71
|
}
|
|
79
|
-
return
|
|
72
|
+
return s.promise;
|
|
80
73
|
};
|
|
81
74
|
|
|
82
|
-
const
|
|
83
|
-
const worker = await
|
|
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
|
-
|
|
80
|
+
s.pending.set(requestId, { resolve, reject });
|
|
87
81
|
try {
|
|
88
82
|
worker.postMessage({ requestId, type, payload }, transfer);
|
|
89
83
|
} catch (error) {
|
|
90
|
-
|
|
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
|
-
//
|
|
102
|
-
const
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
719
|
-
|
|
786
|
+
scheduleResizeCanvas();
|
|
787
|
+
scheduleSave();
|
|
720
788
|
});
|
|
721
789
|
const applyCanvasParamsSplitSize = setupCanvasParamsSplitter(leftSide, previewContainer, paramsPanel, canvasParamsSplitter, () => {
|
|
722
|
-
|
|
723
|
-
|
|
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:
|
|
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 (
|
|
763
|
-
|
|
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
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
if (
|
|
797
|
-
|
|
798
|
-
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
|
|
822
|
-
save();
|
|
823
|
-
};
|
|
986
|
+
}
|
|
824
987
|
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
|
968
|
-
|
|
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 =
|
|
1149
|
+
viewerState.controls.enableDamping = false;
|
|
1150
|
+
viewerState.controls.addEventListener("change", requestRender);
|
|
1024
1151
|
|
|
1025
|
-
|
|
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 =
|
|
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 ||
|
|
1142
|
-
|
|
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
|
-
|
|
1147
|
-
|
|
1148
|
-
//
|
|
1149
|
-
|
|
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
|
-
|
|
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) {
|