From fd9ef3f964714fecbc00f28fd54872652565bbe3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 07:13:56 +0000 Subject: [PATCH 1/3] feat(markdown): add openscad directive with render and export UI Agent-Logs-Url: https://github.com/openpatch/hyperbook/sessions/39c145aa-ea14-44e2-bb81-b6b782af5e9a --- packages/markdown/README.md | 2 +- .../assets/directive-openscad/client.js | 486 ++++++++++++++++++ .../assets/directive-openscad/style.css | 173 +++++++ packages/markdown/assets/store.js | 3 +- packages/markdown/locales/de.json | 15 + packages/markdown/locales/en.json | 15 + packages/markdown/src/process.ts | 2 + .../markdown/src/remarkDirectiveOpenscad.ts | 279 ++++++++++ .../remarkDirectiveOpenscad.test.ts.snap | 19 + .../tests/remarkDirectiveOpenscad.test.ts | 49 ++ 10 files changed, 1041 insertions(+), 2 deletions(-) create mode 100644 packages/markdown/assets/directive-openscad/client.js create mode 100644 packages/markdown/assets/directive-openscad/style.css create mode 100644 packages/markdown/src/remarkDirectiveOpenscad.ts create mode 100644 packages/markdown/tests/__snapshots__/remarkDirectiveOpenscad.test.ts.snap create mode 100644 packages/markdown/tests/remarkDirectiveOpenscad.test.ts diff --git a/packages/markdown/README.md b/packages/markdown/README.md index 147aa303..d2e1fabb 100644 --- a/packages/markdown/README.md +++ b/packages/markdown/README.md @@ -15,7 +15,7 @@ Markdown processing engine for Hyperbook. This package provides extensive markdo - Image processing with attributes **Supported Directives:** -`:alert`, `:video`, `:youtube`, `:audio`, `:archive`, `:download`, `:embed`, `:excalidraw`, `:mermaid`, `:plantuml`, `:collapsible`, `:tabs`, `:tiles`, `:slideshow`, `:term`, `:pagelist`, `:bookmarks`, `:qr`, `:protect`, `:textinput`, `:pyide`, `:sqlide`, `:webide`, `:onlineide`, `:scratchblock`, `:h5p`, `:geogebra`, `:jsxgraph`, `:abcmusic`, `:learningmap`, `:struktog`, `:typst`, and more. +`:alert`, `:video`, `:youtube`, `:audio`, `:archive`, `:download`, `:embed`, `:excalidraw`, `:mermaid`, `:plantuml`, `:collapsible`, `:tabs`, `:tiles`, `:slideshow`, `:term`, `:pagelist`, `:bookmarks`, `:qr`, `:protect`, `:textinput`, `:pyide`, `:sqlide`, `:webide`, `:onlineide`, `:scratchblock`, `:h5p`, `:geogebra`, `:jsxgraph`, `:abcmusic`, `:learningmap`, `:struktog`, `:typst`, `:openscad`, and more. ## Installation diff --git a/packages/markdown/assets/directive-openscad/client.js b/packages/markdown/assets/directive-openscad/client.js new file mode 100644 index 00000000..aa9e3c58 --- /dev/null +++ b/packages/markdown/assets/directive-openscad/client.js @@ -0,0 +1,486 @@ +/// + +/** + * OpenSCAD IDE directive. + * @type {any} + * @memberof hyperbook + */ +hyperbook.openscad = (function () { + window.codeInput?.registerTemplate( + "openscad-highlighted", + codeInput.templates.prism(window.Prism, [ + new codeInput.plugins.AutoCloseBrackets(), + new codeInput.plugins.Indent(true, 2), + ]), + ); + + let openscadPromise = null; + let threePromise = null; + + const i18nGet = (key, fallback = key) => hyperbook.i18n?.get(key) || fallback; + + const getOpenScad = async () => { + if (!openscadPromise) { + openscadPromise = import("https://cdn.jsdelivr.net/npm/openscad-wasm@0.0.4/+esm") + .then((m) => m.createOpenSCAD()) + .then((instance) => { + const fs = instance.getInstance().FS; + try { + fs.mkdir("/tmp"); + } catch (_) {} + return instance; + }); + } + return openscadPromise; + }; + + const getThree = async () => { + if (!threePromise) { + threePromise = Promise.all([ + import("https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js"), + import("https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/loaders/STLLoader.js"), + import("https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/controls/OrbitControls.js"), + ]).then(([THREE, STLLoaderModule, OrbitControlsModule]) => ({ + THREE, + STLLoader: STLLoaderModule.STLLoader, + OrbitControls: OrbitControlsModule.OrbitControls, + })); + } + return threePromise; + }; + + const formatValue = (value) => { + if (typeof value === "string") return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; + if (Array.isArray(value)) return `[${value.map(formatValue).join(",")}]`; + if (typeof value === "boolean" || typeof value === "number") return `${value}`; + throw new Error("Only numbers, booleans, strings and arrays are supported in parameters"); + }; + + function setupSplitter(elem, previewContainer, editorContainer, splitter) { + if (!previewContainer || !editorContainer || !splitter) return; + + const minPanelSize = 120; + + const getIsHorizontal = () => + getComputedStyle(elem).flexDirection.startsWith("row"); + + const applySplitSize = (rawSize, isHorizontal) => { + const total = isHorizontal ? elem.clientWidth : elem.clientHeight; + const splitterSize = isHorizontal ? splitter.offsetWidth : splitter.offsetHeight; + const maxSize = Math.max(minPanelSize, total - splitterSize - minPanelSize); + const clamped = Math.max(minPanelSize, Math.min(rawSize, maxSize)); + previewContainer.style.flex = `0 0 ${clamped}px`; + return clamped; + }; + + const applyStoredSplitSize = () => { + const isHorizontal = getIsHorizontal(); + elem.classList.toggle("split-horizontal", isHorizontal); + elem.classList.toggle("split-vertical", !isHorizontal); + const key = isHorizontal ? "splitHorizontal" : "splitVertical"; + const rawStored = Number(elem.dataset[key]); + if (!Number.isFinite(rawStored) || rawStored <= 0) { + previewContainer.style.flex = ""; + return; + } + applySplitSize(rawStored, isHorizontal); + }; + + applyStoredSplitSize(); + + splitter.addEventListener("pointerdown", (event) => { + event.preventDefault(); + splitter.setPointerCapture(event.pointerId); + + const isHorizontal = getIsHorizontal(); + const key = isHorizontal ? "splitHorizontal" : "splitVertical"; + const startPointer = isHorizontal ? event.clientX : event.clientY; + const startSize = isHorizontal + ? previewContainer.getBoundingClientRect().width + : previewContainer.getBoundingClientRect().height; + + elem.classList.add("resizing"); + + const onPointerMove = (moveEvent) => { + const pointer = isHorizontal ? moveEvent.clientX : moveEvent.clientY; + const delta = pointer - startPointer; + const size = applySplitSize(startSize + delta, isHorizontal); + elem.dataset[key] = String(Math.round(size)); + }; + + const onPointerUp = () => { + elem.classList.remove("resizing"); + splitter.removeEventListener("pointermove", onPointerMove); + splitter.removeEventListener("pointerup", onPointerUp); + splitter.removeEventListener("pointercancel", onPointerUp); + }; + + splitter.addEventListener("pointermove", onPointerMove); + splitter.addEventListener("pointerup", onPointerUp); + splitter.addEventListener("pointercancel", onPointerUp); + }); + + window.addEventListener("resize", applyStoredSplitSize); + } + + const updateFullscreenButtonState = (elem, button) => { + if (!elem || !button) return; + const isFullscreen = document.fullscreenElement === elem; + const label = i18nGet("ide-fullscreen-enter", "Fullscreen"); + button.textContent = "⛶"; + button.title = label; + button.setAttribute("aria-label", label); + button.classList.toggle("active", isFullscreen); + }; + + const toggleFullscreen = async (elem) => { + if (!elem) return; + if (document.fullscreenElement === elem) { + await document.exitFullscreen(); + return; + } + await elem.requestFullscreen(); + }; + + const syncFullscreenButtons = () => { + const elems = document.querySelectorAll(".directive-openscad"); + elems.forEach((elem) => { + const fullscreen = elem.querySelector("button.fullscreen"); + updateFullscreenButtonState(elem, fullscreen); + }); + }; + + const toUint8Array = (data) => { + if (data instanceof Uint8Array) return data; + if (typeof data === "string") return new TextEncoder().encode(data); + return new Uint8Array(data || []); + }; + + function initElement(elem) { + if (elem.getAttribute("data-openscad-initialized") === "true") return; + elem.setAttribute("data-openscad-initialized", "true"); + + const id = elem.getAttribute("data-id"); + + const previewContainer = elem.querySelector(".preview-container"); + const editorContainer = elem.querySelector(".editor-container"); + const splitter = elem.querySelector(".splitter"); + const canvas = elem.querySelector(".preview-canvas"); + const output = elem.querySelector(".output"); + const editor = elem.querySelector("code-input.editor"); + const params = elem.querySelector("textarea.parameters"); + + const tabCode = elem.querySelector("button.tab-code"); + const tabParameters = elem.querySelector("button.tab-parameters"); + + const renderBtn = elem.querySelector("button.render"); + const copyBtn = elem.querySelector("button.copy"); + const downloadStlBtn = elem.querySelector("button.download-stl"); + const download3mfBtn = elem.querySelector("button.download-3mf"); + const resetBtn = elem.querySelector("button.reset"); + const fullscreenBtn = elem.querySelector("button.fullscreen"); + + setupSplitter(elem, previewContainer, editorContainer, splitter); + + const viewerState = { + renderer: null, + camera: null, + scene: null, + controls: null, + mesh: null, + raf: 0, + disposed: false, + }; + + const setOutput = (text) => { + if (output) output.textContent = text || ""; + }; + + const save = async () => { + if (!id) return; + await hyperbook.store.db.openscad.put({ + id, + code: editor?.value || "", + params: params?.value || "{}", + }); + }; + + const load = async () => { + if (!id) return; + const result = await hyperbook.store.db.openscad.get(id); + if (!result) return; + if (editor && typeof result.code === "string") { + editor.value = result.code; + } + if (params && typeof result.params === "string") { + params.value = result.params; + } + }; + + const getParamDefinitions = () => { + const parsed = JSON.parse(params?.value || "{}"); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(i18nGet("openscad-params-object", "Parameters must be a JSON object")); + } + return Object.entries(parsed).map(([k, v]) => `-D${k}=${formatValue(v)}`); + }; + + const renderWithFormat = async (format) => { + renderBtn?.setAttribute("disabled", "true"); + setOutput(i18nGet("openscad-rendering", "Rendering ...")); + + try { + const paramDefinitions = getParamDefinitions(); + const openscad = await getOpenScad(); + const instance = openscad.getInstance(); + + const sourcePath = "/tmp/model.scad"; + const outPath = `/tmp/output.${format}`; + const exportFormat = format === "stl" ? "binstl" : format; + + try { + instance.FS.unlink(sourcePath); + } catch (_) {} + try { + instance.FS.unlink(outPath); + } catch (_) {} + + instance.FS.writeFile(sourcePath, editor?.value || ""); + + const args = [ + sourcePath, + "-o", + outPath, + "--backend=manifold", + `--export-format=${exportFormat}`, + ...paramDefinitions, + ]; + + const exitCode = instance.callMain(args); + if (exitCode !== 0) { + throw new Error(i18nGet("openscad-render-failed", "OpenSCAD render failed")); + } + + const content = toUint8Array(instance.FS.readFile(outPath, { encoding: "binary" })); + return content; + } finally { + renderBtn?.removeAttribute("disabled"); + } + }; + + const downloadBinary = (content, ext) => { + const blob = new Blob([content], { type: "application/octet-stream" }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = `openscad-${id || "model"}.${ext}`; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); + }; + + const renderPreview = async () => { + try { + await save(); + const stl = await renderWithFormat("stl"); + await renderStl(stl); + setOutput(i18nGet("openscad-render-success", "Render complete")); + } catch (error) { + setOutput(error?.message || `${error}`); + } + }; + + const renderStl = async (stlData) => { + const { THREE, STLLoader, OrbitControls } = await getThree(); + if (!canvas) return; + + if (!viewerState.renderer) { + viewerState.renderer = new THREE.WebGLRenderer({ + canvas, + antialias: true, + alpha: true, + }); + viewerState.renderer.setPixelRatio(window.devicePixelRatio || 1); + + viewerState.scene = new THREE.Scene(); + viewerState.scene.background = new THREE.Color(0xf8fafc); + + const ambient = new THREE.AmbientLight(0xffffff, 0.75); + const key = new THREE.DirectionalLight(0xffffff, 1); + key.position.set(1, 1, 2); + const fill = new THREE.DirectionalLight(0xffffff, 0.5); + fill.position.set(-1, -1, 1); + + viewerState.scene.add(ambient); + viewerState.scene.add(key); + viewerState.scene.add(fill); + + viewerState.camera = new THREE.PerspectiveCamera(45, 1, 0.1, 5000); + viewerState.controls = new OrbitControls(viewerState.camera, canvas); + viewerState.controls.enableDamping = true; + + const tick = () => { + if (viewerState.disposed) return; + if (viewerState.controls) viewerState.controls.update(); + if (viewerState.renderer && viewerState.scene && viewerState.camera) { + viewerState.renderer.render(viewerState.scene, viewerState.camera); + } + viewerState.raf = requestAnimationFrame(tick); + }; + tick(); + } + + const bounds = previewContainer?.getBoundingClientRect(); + const width = Math.max(1, Math.floor(bounds?.width || canvas.clientWidth || 320)); + const height = Math.max(1, Math.floor(bounds?.height || canvas.clientHeight || 320)); + viewerState.renderer.setSize(width, height, false); + viewerState.camera.aspect = width / height; + viewerState.camera.updateProjectionMatrix(); + + if (viewerState.mesh) { + viewerState.scene.remove(viewerState.mesh); + viewerState.mesh.geometry?.dispose(); + } + + const loader = new STLLoader(); + const arrayBuffer = stlData.buffer.slice( + stlData.byteOffset, + stlData.byteOffset + stlData.byteLength, + ); + const geometry = loader.parse(arrayBuffer); + geometry.computeBoundingBox(); + geometry.computeVertexNormals(); + + const material = new THREE.MeshStandardMaterial({ + color: 0x3b82f6, + metalness: 0.1, + roughness: 0.6, + }); + const mesh = new THREE.Mesh(geometry, material); + viewerState.mesh = mesh; + viewerState.scene.add(mesh); + + const box = geometry.boundingBox; + const size = new THREE.Vector3(); + const center = new THREE.Vector3(); + box.getSize(size); + box.getCenter(center); + + mesh.position.x = -center.x; + mesh.position.y = -center.y; + mesh.position.z = -center.z; + + const maxDim = Math.max(size.x, size.y, size.z) || 1; + const distance = maxDim * 1.8; + viewerState.camera.position.set(distance, distance, distance); + viewerState.camera.near = Math.max(0.01, distance / 1000); + viewerState.camera.far = Math.max(1000, distance * 10); + viewerState.camera.lookAt(0, 0, 0); + viewerState.camera.updateProjectionMatrix(); + + viewerState.controls.target.set(0, 0, 0); + viewerState.controls.update(); + }; + + tabCode?.addEventListener("click", () => { + tabCode.classList.add("active"); + tabParameters?.classList.remove("active"); + editor?.classList.add("active"); + params?.classList.remove("active"); + }); + + tabParameters?.addEventListener("click", () => { + tabParameters.classList.add("active"); + tabCode?.classList.remove("active"); + params?.classList.add("active"); + editor?.classList.remove("active"); + }); + + copyBtn?.addEventListener("click", async () => { + await navigator.clipboard.writeText(editor?.value || ""); + setOutput(i18nGet("openscad-copy-done", "Code copied")); + }); + + resetBtn?.addEventListener("click", async () => { + if (!window.confirm(i18nGet("openscad-reset-prompt", "Are you sure you want to reset the code?"))) { + return; + } + await hyperbook.store.db.openscad.delete(id); + window.location.reload(); + }); + + renderBtn?.addEventListener("click", renderPreview); + + downloadStlBtn?.addEventListener("click", async () => { + try { + await save(); + const stl = await renderWithFormat("stl"); + downloadBinary(stl, "stl"); + await renderStl(stl); + setOutput(i18nGet("openscad-download-ready", "Download ready")); + } catch (error) { + setOutput(error?.message || `${error}`); + } + }); + + download3mfBtn?.addEventListener("click", async () => { + try { + await save(); + const threeMf = await renderWithFormat("3mf"); + downloadBinary(threeMf, "3mf"); + setOutput(i18nGet("openscad-download-ready", "Download ready")); + } catch (error) { + setOutput(error?.message || `${error}`); + } + }); + + fullscreenBtn?.addEventListener("click", async () => { + try { + await toggleFullscreen(elem); + } catch (error) { + console.error(error?.message || error); + } + }); + + updateFullscreenButtonState(elem, fullscreenBtn); + + editor?.addEventListener("code-input_load", async () => { + await load(); + editor.addEventListener("input", save); + params?.addEventListener("input", save); + if (!editor.value.trim()) { + editor.value = "// OpenSCAD\ncube([20,20,20], center=true);"; + } + if (!params?.value.trim()) { + params.value = "{}"; + } + await save(); + renderPreview(); + }); + } + + function init(root) { + const elems = root.querySelectorAll(".directive-openscad"); + elems.forEach(initElement); + } + + document.addEventListener("DOMContentLoaded", () => { + init(document); + }); + document.addEventListener("fullscreenchange", syncFullscreenButtons); + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if ( + node.nodeType === 1 && + node.classList.contains("directive-openscad") + ) { + initElement(node); + } + }); + }); + }); + + observer.observe(document.body, { childList: true, subtree: true }); + + return { init }; +})(); diff --git a/packages/markdown/assets/directive-openscad/style.css b/packages/markdown/assets/directive-openscad/style.css new file mode 100644 index 00000000..3d4b00d7 --- /dev/null +++ b/packages/markdown/assets/directive-openscad/style.css @@ -0,0 +1,173 @@ +.directive-openscad { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + overflow: hidden; + gap: 8px; + height: var(--openscad-height, calc(100dvh - 80px)); +} + +.directive-openscad .preview-container { + width: 100%; + min-height: 120px; + min-width: 120px; + border: 1px solid var(--color-spacer); + border-radius: 8px; + overflow: hidden; + background-color: var(--color--background); + flex: 1 1 0; + display: flex; + flex-direction: column; +} + +.directive-openscad .preview-header { + border-bottom: 1px solid var(--color-spacer); + padding: 8px 16px; + font-weight: 600; +} + +.directive-openscad .preview-canvas { + width: 100%; + height: 100%; + min-height: 260px; + display: block; + background: linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%); +} + +.directive-openscad .output { + margin: 0; + border-top: 1px solid var(--color-spacer); + padding: 8px 12px; + min-height: 56px; + max-height: 120px; + overflow: auto; + white-space: pre-wrap; + font-family: hyperbook-monospace, monospace; +} + +.directive-openscad .editor-container { + width: 100%; + display: flex; + flex-direction: column; + min-height: 120px; + min-width: 120px; + flex: 1 1 0; +} + +.directive-openscad .splitter { + background: var(--color-spacer); + border-radius: 999px; + flex-shrink: 0; + touch-action: none; + opacity: 0.45; + transition: opacity 0.15s ease-in-out; +} + +.directive-openscad .splitter:hover { + opacity: 0.65; +} + +.directive-openscad.split-vertical .splitter { + width: 100%; + height: 4px; + cursor: row-resize; +} + +.directive-openscad.split-horizontal .splitter { + width: 4px; + height: 100%; + cursor: col-resize; +} + +.directive-openscad.resizing { + user-select: none; +} + +.directive-openscad .buttons { + display: flex; + border: 1px solid var(--color-spacer); + border-radius: 8px; + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + overflow: hidden; +} + +.directive-openscad .buttons.bottom { + border: 1px solid var(--color-spacer); + border-radius: 8px; + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.directive-openscad button { + flex: 1; + padding: 8px 16px; + border: none; + border-right: 1px solid var(--color-spacer); + background-color: var(--color--background); + color: var(--color-text); + cursor: pointer; +} + +.directive-openscad .buttons button:last-child { + border-right: none; +} + +.directive-openscad button.fullscreen { + flex: 0 0 auto; + min-width: 42px; + width: 42px; + padding: 8px 0; +} + +.directive-openscad button:hover, +.directive-openscad button.active { + background-color: var(--color-spacer); +} + +.directive-openscad .editor, +.directive-openscad .parameters { + width: 100%; + border: 1px solid var(--color-spacer); + flex: 1; +} + +.directive-openscad .parameters { + display: none; + box-sizing: border-box; + resize: none; + padding: 12px; + font-family: hyperbook-monospace, monospace; +} + +.directive-openscad .editor:not(.active), +.directive-openscad .parameters:not(.active) { + display: none; +} + +.directive-openscad:fullscreen { + width: 100vw; + height: 100dvh !important; + padding: 12px; + box-sizing: border-box; + background-color: var(--color-background, var(--color--background, #fff)); +} + +.directive-openscad:fullscreen::backdrop { + background-color: var(--color-background, var(--color--background, #fff)); +} + +@media screen and (min-width: 1024px) { + .directive-openscad { + flex-direction: row; + } + + .directive-openscad .preview-container, + .directive-openscad .editor-container { + flex: 1; + height: 100% !important; + } +} diff --git a/packages/markdown/assets/store.js b/packages/markdown/assets/store.js index b4807e34..5a761898 100644 --- a/packages/markdown/assets/store.js +++ b/packages/markdown/assets/store.js @@ -12,7 +12,7 @@ var hyperbook = window.hyperbook = window.hyperbook || {}; hyperbook.store = (function () { /** @type {import("dexie").Dexie} */ var db = new Dexie("Hyperbook"); - db.version(4).stores({ + db.version(5).stores({ consent: `id`, currentState: ` id, @@ -46,6 +46,7 @@ hyperbook.store = (function () { struktolab: `id,tree`, multievent: `id,state`, typst: `id,code`, + openscad: `id,code,params`, }); /** @returns {Promise} */ diff --git a/packages/markdown/locales/de.json b/packages/markdown/locales/de.json index b5d80dcb..14346c7d 100644 --- a/packages/markdown/locales/de.json +++ b/packages/markdown/locales/de.json @@ -88,6 +88,21 @@ "typst-file-replace": "Existierende Datei ersetzen?", "typst-binary-files": "Binärdateien", "typst-no-binary-files": "Keine Binärdateien", + "openscad-preview": "Vorschau", + "openscad-code": "Code", + "openscad-parameters": "Parameter", + "openscad-render": "Rendern", + "openscad-copy": "Kopieren", + "openscad-copy-done": "Code kopiert", + "openscad-download-stl": "STL herunterladen", + "openscad-download-3mf": "3MF herunterladen", + "openscad-download-ready": "Download bereit", + "openscad-reset": "Zurücksetzen", + "openscad-reset-prompt": "Sind Sie sicher, dass Sie den Code zurücksetzen möchten?", + "openscad-rendering": "Rendern ...", + "openscad-render-success": "Rendern abgeschlossen", + "openscad-render-failed": "OpenSCAD-Rendern fehlgeschlagen", + "openscad-params-object": "Parameter müssen ein JSON-Objekt sein", "user-login-title": "Anmelden", "user-username": "Benutzername", "user-password": "Passwort", diff --git a/packages/markdown/locales/en.json b/packages/markdown/locales/en.json index 1088e91d..39f688ee 100644 --- a/packages/markdown/locales/en.json +++ b/packages/markdown/locales/en.json @@ -88,6 +88,21 @@ "typst-file-replace": "Replace existing file?", "typst-binary-files": "Binary Files", "typst-no-binary-files": "No binary files", + "openscad-preview": "Preview", + "openscad-code": "Code", + "openscad-parameters": "Parameters", + "openscad-render": "Render", + "openscad-copy": "Copy", + "openscad-copy-done": "Code copied", + "openscad-download-stl": "Download STL", + "openscad-download-3mf": "Download 3MF", + "openscad-download-ready": "Download ready", + "openscad-reset": "Reset", + "openscad-reset-prompt": "Are you sure you want to reset the code?", + "openscad-rendering": "Rendering ...", + "openscad-render-success": "Render complete", + "openscad-render-failed": "OpenSCAD render failed", + "openscad-params-object": "Parameters must be a JSON object", "user-login-title": "Login", "user-username": "Username", "user-password": "Password", diff --git a/packages/markdown/src/process.ts b/packages/markdown/src/process.ts index 9e51f9eb..68086a01 100644 --- a/packages/markdown/src/process.ts +++ b/packages/markdown/src/process.ts @@ -65,6 +65,7 @@ import remarkImageAttrs from "./remarkImageAttrs"; import remarkDirectiveLearningmap from "./remarkDirectiveLearningmap"; import remarkDirectiveTextinput from "./remarkDirectiveTextinput"; import remarkDirectiveTypst from "./remarkDirectiveTypst"; +import remarkDirectiveOpenscad from "./remarkDirectiveOpenscad"; import remarkDirectiveStruktolab from "./remarkDirectiveStruktolab"; import remarkDirectiveBlockflowPlayer from "./remarkDirectiveBlockflowPlayer"; import remarkDirectiveBlockflowEditor from "./remarkDirectiveBlockflowEditor"; @@ -115,6 +116,7 @@ export const remark = (ctx: HyperbookContext) => { remarkDirectiveLearningmap(ctx), remarkDirectiveTextinput(ctx), remarkDirectiveTypst(ctx), + remarkDirectiveOpenscad(ctx), remarkCode(ctx), remarkMath, remarkGithubEmoji, diff --git a/packages/markdown/src/remarkDirectiveOpenscad.ts b/packages/markdown/src/remarkDirectiveOpenscad.ts new file mode 100644 index 00000000..19fdaba7 --- /dev/null +++ b/packages/markdown/src/remarkDirectiveOpenscad.ts @@ -0,0 +1,279 @@ +// Register directive nodes in mdast: +/// +// +import { HyperbookContext } from "@hyperbook/types"; +import { Code, Root } from "mdast"; +import { visit } from "unist-util-visit"; +import { VFile } from "vfile"; +import { + expectContainerDirective, + isDirective, + registerDirective, + requestCSS, + requestJS, +} from "./remarkHelper"; +import hash from "./objectHash"; +import { i18n } from "./i18n"; +import { readFile } from "./helper"; + +function htmlEntities(str: string) { + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +export default (ctx: HyperbookContext) => () => { + const name = "openscad"; + + return (tree: Root, file: VFile) => { + visit(tree, function (node) { + if (isDirective(node) && node.name === name) { + const { src = "", id = hash(node), height } = node.attributes || {}; + const data = node.data || (node.data = {}); + + expectContainerDirective(node, file, name); + registerDirective(file, name, ["client.js"], ["style.css"], []); + requestJS(file, ["code-input", "code-input.min.js"]); + requestCSS(file, ["code-input", "code-input.min.css"]); + requestJS(file, ["code-input", "auto-close-brackets.min.js"]); + requestJS(file, ["code-input", "indent.min.js"]); + + let source = ""; + if (src) { + source = readFile(src, ctx) || ""; + } else { + source = + ( + node.children.find( + (c) => + c.type === "code" && + ((c as Code).lang === "scad" || (c as Code).lang === "openscad"), + ) as Code + )?.value || ""; + } + + data.hName = "div"; + data.hProperties = { + class: "directive-openscad", + "data-id": id, + ...(height ? { style: `--openscad-height: ${height}` } : {}), + }; + + data.hChildren = [ + { + type: "element", + tagName: "div", + properties: { + class: "preview-container", + }, + children: [ + { + type: "element", + tagName: "div", + properties: { + class: "preview-header", + }, + children: [ + { + type: "text", + value: i18n.get("openscad-preview"), + }, + ], + }, + { + type: "element", + tagName: "canvas", + properties: { + class: "preview-canvas", + }, + children: [], + }, + { + type: "element", + tagName: "pre", + properties: { + class: "output", + }, + children: [], + }, + ], + }, + { + type: "element", + tagName: "div", + properties: { + class: "splitter", + role: "separator", + "aria-label": "Resize panels", + }, + children: [], + }, + { + type: "element", + tagName: "div", + properties: { + class: "editor-container", + }, + children: [ + { + type: "element", + tagName: "div", + properties: { + class: "buttons", + }, + children: [ + { + type: "element", + tagName: "button", + properties: { + class: "tab-code active", + }, + children: [ + { + type: "text", + value: i18n.get("openscad-code"), + }, + ], + }, + { + type: "element", + tagName: "button", + properties: { + class: "tab-parameters", + }, + children: [ + { + type: "text", + value: i18n.get("openscad-parameters"), + }, + ], + }, + ], + }, + { + type: "element", + tagName: "code-input", + properties: { + class: "editor active line-numbers", + language: "clike", + template: "openscad-highlighted", + }, + children: [ + { + type: "raw", + value: htmlEntities(source), + }, + ], + }, + { + type: "element", + tagName: "textarea", + properties: { + class: "parameters", + placeholder: '{"size": 20, "height": 10}', + }, + children: [ + { + type: "text", + value: "{}", + }, + ], + }, + { + type: "element", + tagName: "div", + properties: { + class: "buttons bottom", + }, + children: [ + { + type: "element", + tagName: "button", + properties: { + class: "render", + }, + children: [ + { + type: "text", + value: i18n.get("openscad-render"), + }, + ], + }, + { + type: "element", + tagName: "button", + properties: { + class: "copy", + }, + children: [ + { + type: "text", + value: i18n.get("openscad-copy"), + }, + ], + }, + { + type: "element", + tagName: "button", + properties: { + class: "download-stl", + }, + children: [ + { + type: "text", + value: i18n.get("openscad-download-stl"), + }, + ], + }, + { + type: "element", + tagName: "button", + properties: { + class: "download-3mf", + }, + children: [ + { + type: "text", + value: i18n.get("openscad-download-3mf"), + }, + ], + }, + { + type: "element", + tagName: "button", + properties: { + class: "reset", + }, + children: [ + { + type: "text", + value: i18n.get("openscad-reset"), + }, + ], + }, + { + type: "element", + tagName: "button", + properties: { + class: "fullscreen", + title: i18n.get("ide-fullscreen-enter"), + "aria-label": i18n.get("ide-fullscreen-enter"), + }, + children: [ + { + type: "text", + value: "⛶", + }, + ], + }, + ], + }, + ], + }, + ]; + } + }); + }; +}; diff --git a/packages/markdown/tests/__snapshots__/remarkDirectiveOpenscad.test.ts.snap b/packages/markdown/tests/__snapshots__/remarkDirectiveOpenscad.test.ts.snap new file mode 100644 index 00000000..63fd4dd4 --- /dev/null +++ b/packages/markdown/tests/__snapshots__/remarkDirectiveOpenscad.test.ts.snap @@ -0,0 +1,19 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`remarkDirectiveOpenscad > should transform basic openscad 1`] = ` +" +
+
+
openscad-preview
+ +

+  
+ +
+
+ cube([20,20,20], center=true); +
+
+
+" +`; diff --git a/packages/markdown/tests/remarkDirectiveOpenscad.test.ts b/packages/markdown/tests/remarkDirectiveOpenscad.test.ts new file mode 100644 index 00000000..2a94783c --- /dev/null +++ b/packages/markdown/tests/remarkDirectiveOpenscad.test.ts @@ -0,0 +1,49 @@ +import { HyperbookContext } from "@hyperbook/types"; +import { describe, expect, it } from "vitest"; +import rehypeStringify from "rehype-stringify"; +import remarkToRehype from "remark-rehype"; +import rehypeFormat from "rehype-format"; +import { unified, PluggableList } from "unified"; +import remarkDirective from "remark-directive"; +import remarkDirectiveRehype from "remark-directive-rehype"; +import remarkDirectiveOpenscad from "../src/remarkDirectiveOpenscad"; +import { ctx } from "./mock"; +import remarkParse from "../src/remarkParse"; + +export const toHtml = (md: string, ctx: HyperbookContext) => { + const remarkPlugins: PluggableList = [ + remarkDirective, + remarkDirectiveRehype, + remarkDirectiveOpenscad(ctx), + ]; + + return unified() + .use(remarkParse) + .use(remarkPlugins) + .use(remarkToRehype) + .use(rehypeFormat) + .use(rehypeStringify, { + allowDangerousCharacters: true, + allowDangerousHtml: true, + }) + .processSync(md); +}; + +describe("remarkDirectiveOpenscad", () => { + it("should transform basic openscad", async () => { + expect( + toHtml( + `:::openscad + +\`\`\`scad +cube([20,20,20], center=true); +\`\`\` + +::: + +`, + ctx, + ).value, + ).toMatchSnapshot(); + }); +}); From e6ef1b9c09e46abf7a07b04681f7626412b3f6f7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 08:30:55 +0000 Subject: [PATCH 2/3] docs: add openscad element docs for website en/de Agent-Logs-Url: https://github.com/openpatch/hyperbook/sessions/0a35bb49-7ef3-47ce-b6e2-4b318ff52522 --- website/de/book/elements/openscad.md | 124 ++++++++++++++++++ .../elements/openscad-docs-screenshot.png | Bin 0 -> 41291 bytes website/en/book/elements/openscad.md | 123 +++++++++++++++++ 3 files changed, 247 insertions(+) create mode 100644 website/de/book/elements/openscad.md create mode 100644 website/en/book/elements/openscad-docs-screenshot.png create mode 100644 website/en/book/elements/openscad.md diff --git a/website/de/book/elements/openscad.md b/website/de/book/elements/openscad.md new file mode 100644 index 00000000..8bd1e09a --- /dev/null +++ b/website/de/book/elements/openscad.md @@ -0,0 +1,124 @@ +--- +name: OpenSCAD +permaid: openscad +lang: de +--- + +# OpenSCAD + +Die `openscad`-Direktive bietet einen interaktiven OpenSCAD-Editor mit: + +- einer **Code-Ansicht**, +- einer **Parameter-Ansicht** (JSON-Objekt, das auf `-D`-Variablen gemappt wird), +- und einer **3D-Vorschau**. + +Sie können das Modell rendern, den Code kopieren und als **STL** oder **3MF** herunterladen. + +## Verwendung + +Packen Sie OpenSCAD-Code in einen `:::openscad`-Block und verwenden Sie einen `scad`- (oder `openscad`-) Codeblock. + +````md +:::openscad + +```scad +cube([20,20,20], center=true); +``` + +::: +```` + +:::openscad + +```scad +cube([20,20,20], center=true); +``` + +::: + +## Attribute + +| Attribut | Beschreibung | Standard | +|---|---|---| +| `id` | Eindeutige ID für Persistenz | automatisch generiert | +| `src` | Lädt Quellcode aus einem externen Dateipfad | eingebetteter Codeblock | +| `height` | Höhe des Editor-/Vorschau-Containers | `calc(100dvh - 80px)` | + +## Code aus Datei laden + +````md +:::openscad{src="openscad/example.scad"} +::: +```` + +## Parameter (JSON) + +Öffnen Sie den Tab **Parameters** und geben Sie ein JSON-Objekt an. Jedes Schlüssel/Wert-Paar wird als `-Dname=value` an OpenSCAD übergeben. + +Beispiel: + +```json +{ + "size": 24, + "height": 16, + "segments": 64, + "rounded": true, + "label": "A" +} +``` + +## Beispiel mit Variablen + +````md +:::openscad + +```scad +$fn = segments; + +module body(size, height, rounded) { + if (rounded) { + minkowski() { + cube([size, size, height], center=true); + sphere(r=1); + } + } else { + cube([size, size, height], center=true); + } +} + +difference() { + body(size, height, rounded); + translate([0, 0, height / 2 + 0.1]) + linear_extrude(height=1) + text(label, size=8, halign="center", valign="center"); +} +``` + +::: +```` + +:::openscad + +```scad +$fn = segments; + +module body(size, height, rounded) { + if (rounded) { + minkowski() { + cube([size, size, height], center=true); + sphere(r=1); + } + } else { + cube([size, size, height], center=true); + } +} + +difference() { + body(size, height, rounded); + translate([0, 0, height / 2 + 0.1]) + linear_extrude(height=1) + text(label, size=8, halign="center", valign="center"); +} +``` + +::: diff --git a/website/en/book/elements/openscad-docs-screenshot.png b/website/en/book/elements/openscad-docs-screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..e7efb091f4fb11eb264124b4b4007eb74af8080d GIT binary patch literal 41291 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 Y#-(4`%~o6pCuiY)#RGd%6b}FTUrVrVr2qf` literal 0 HcmV?d00001 diff --git a/website/en/book/elements/openscad.md b/website/en/book/elements/openscad.md new file mode 100644 index 00000000..e7b9ef46 --- /dev/null +++ b/website/en/book/elements/openscad.md @@ -0,0 +1,123 @@ +--- +name: OpenSCAD +permaid: openscad +--- + +# OpenSCAD + +The `openscad` directive provides an interactive OpenSCAD editor with: + +- a **code view**, +- a **parameter view** (JSON object mapped to `-D` variables), +- and a **3D preview**. + +You can render the model, copy the code, and download exports as **STL** or **3MF**. + +## Usage + +Wrap OpenSCAD code in a `:::openscad` block and use a `scad` (or `openscad`) code fence. + +````md +:::openscad + +```scad +cube([20,20,20], center=true); +``` + +::: +```` + +:::openscad + +```scad +cube([20,20,20], center=true); +``` + +::: + +## Attributes + +| Attribute | Description | Default | +|---|---|---| +| `id` | Unique id for persistence | auto-generated | +| `src` | Load source from an external file path | inline code block | +| `height` | Height of the editor/preview container | `calc(100dvh - 80px)` | + +## Load code from file + +````md +:::openscad{src="openscad/example.scad"} +::: +```` + +## Parameters (JSON) + +Open the **Parameters** tab and provide a JSON object. Each key/value pair is passed to OpenSCAD as `-Dname=value`. + +Example: + +```json +{ + "size": 24, + "height": 16, + "segments": 64, + "rounded": true, + "label": "A" +} +``` + +## Example with variables + +````md +:::openscad + +```scad +$fn = segments; + +module body(size, height, rounded) { + if (rounded) { + minkowski() { + cube([size, size, height], center=true); + sphere(r=1); + } + } else { + cube([size, size, height], center=true); + } +} + +difference() { + body(size, height, rounded); + translate([0, 0, height / 2 + 0.1]) + linear_extrude(height=1) + text(label, size=8, halign="center", valign="center"); +} +``` + +::: +```` + +:::openscad + +```scad +$fn = segments; + +module body(size, height, rounded) { + if (rounded) { + minkowski() { + cube([size, size, height], center=true); + sphere(r=1); + } + } else { + cube([size, size, height], center=true); + } +} + +difference() { + body(size, height, rounded); + translate([0, 0, height / 2 + 0.1]) + linear_extrude(height=1) + text(label, size=8, halign="center", valign="center"); +} +``` + +::: From c71b72df585440ddf05da1bf8cbdad90f58cc044 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 08:45:15 +0000 Subject: [PATCH 3/3] fix(openscad): use ESM-remapped three imports for browser Agent-Logs-Url: https://github.com/openpatch/hyperbook/sessions/54797f57-f0b3-4844-9b47-55758f96cca5 --- packages/markdown/assets/directive-openscad/client.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/markdown/assets/directive-openscad/client.js b/packages/markdown/assets/directive-openscad/client.js index aa9e3c58..ffb6a104 100644 --- a/packages/markdown/assets/directive-openscad/client.js +++ b/packages/markdown/assets/directive-openscad/client.js @@ -37,9 +37,9 @@ hyperbook.openscad = (function () { const getThree = async () => { if (!threePromise) { threePromise = Promise.all([ - import("https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js"), - import("https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/loaders/STLLoader.js"), - import("https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/controls/OrbitControls.js"), + import("https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js/+esm"), + import("https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/loaders/STLLoader.js/+esm"), + import("https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/controls/OrbitControls.js/+esm"), ]).then(([THREE, STLLoaderModule, OrbitControlsModule]) => ({ THREE, STLLoader: STLLoaderModule.STLLoader,