diff --git a/.changeset/hot-frogs-train.md b/.changeset/hot-frogs-train.md new file mode 100644 index 000000000..219adc6d2 --- /dev/null +++ b/.changeset/hot-frogs-train.md @@ -0,0 +1,6 @@ +--- +"@hyperbook/markdown": minor +"hyperbook": minor +--- + +Add openscad element diff --git a/packages/markdown/README.md b/packages/markdown/README.md index 147aa303b..d2e1fabb4 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 000000000..7cd127430 --- /dev/null +++ b/packages/markdown/assets/directive-openscad/client.js @@ -0,0 +1,983 @@ +/// + +/** + * OpenSCAD IDE directive. + * @type {any} + * @memberof hyperbook + */ +hyperbook.openscad = (function () { + const _scriptBase = window.HYPERBOOK_ASSETS + "directive-openscad/"; + + window.codeInput?.registerTemplate( + "openscad-highlighted", + codeInput.templates.prism(window.Prism, [ + new codeInput.plugins.AutoCloseBrackets(), + new codeInput.plugins.Indent(true, 2), + ]), + ); + + // Cache the ESM module import (loaded once). Each render creates a fresh + // WASM instance to avoid C++ singleton state issues + // (e.g. the Manifold backend throwing a C++ exception on second callMain). + let openscadModulePromise = null; + let threePromise = null; + + // Font bytes are fetched once and re-written to each fresh WASM instance. + let robotoFontData = null; + + // Per-render stderr capture — cleared before each render. + let openscadStderr = []; + + const i18nGet = (key, fallback = key) => hyperbook.i18n?.get(key) || fallback; + + const FONTS_CONF = ` + + + /fonts +`; + + // Create a fresh OpenSCAD WASM instance for each render. + // The ESM module (and its compiled WASM binary) is imported only once; + // the browser's WebAssembly module cache makes subsequent instantiations fast. + const getOpenScad = async () => { + if (!openscadModulePromise) { + openscadModulePromise = import(/* @vite-ignore */ _scriptBase + "openscad.js"); + } + const OpenSCAD = (await openscadModulePromise).default; + const instance = await OpenSCAD({ + noInitialRun: true, + locateFile: (file) => _scriptBase + file, + printErr: (text) => openscadStderr.push(text), + }); + const fs = instance.FS; + try { fs.mkdir("/tmp"); } catch (_) {} + try { fs.mkdir("/fonts"); } catch (_) {} + // Fonts are resolved from $(cwd)/fonts — keep cwd at / + try { instance.FS.chdir("/"); } catch (_) {} + try { fs.writeFile("/fonts/fonts.conf", FONTS_CONF); } catch (_) {} + // Write cached font data if already fetched + if (robotoFontData) { + try { fs.writeFile("/fonts/Roboto-Regular.ttf", robotoFontData); } catch (_) {} + } + return instance; + }; + + // Known library URLs hosted at the openscad-playground deployment. + const KNOWN_LIBRARIES = { + BOSL2: "https://ochafik.com/openscad2/libraries/BOSL2.zip", + BOSL: "https://ochafik.com/openscad2/libraries/BOSL.zip", + MCAD: "https://ochafik.com/openscad2/libraries/MCAD.zip", + NopSCADlib: "https://ochafik.com/openscad2/libraries/NopSCADlib.zip", + fonts: "https://ochafik.com/openscad2/libraries/fonts.zip", + }; + + // Per-name cache of extracted file maps: Map + const libraryCache = new Map(); + + // Minimal ZIP extractor using the browser-native DecompressionStream API. + // Supports Stored (method 0) and Deflate (method 8) entries. + const extractZip = async (buffer) => { + const view = new DataView(buffer); + const bytes = new Uint8Array(buffer); + const files = {}; + const dec = new TextDecoder(); + + // Locate End of Central Directory record. + let eocdPos = -1; + for (let i = buffer.byteLength - 22; i >= Math.max(0, buffer.byteLength - 65558); i--) { + if (view.getUint32(i, true) === 0x06054b50) { eocdPos = i; break; } + } + if (eocdPos < 0) throw new Error("Not a valid ZIP file"); + + const entryCount = view.getUint16(eocdPos + 10, true); + let cdOffset = view.getUint32(eocdPos + 16, true); + + for (let i = 0; i < entryCount; i++) { + if (view.getUint32(cdOffset, true) !== 0x02014b50) break; + const compression = view.getUint16(cdOffset + 10, true); + const compressedSize = view.getUint32(cdOffset + 20, true); + const fnLen = view.getUint16(cdOffset + 28, true); + const extraLen = view.getUint16(cdOffset + 30, true); + const commentLen = view.getUint16(cdOffset + 32, true); + const localOffset = view.getUint32(cdOffset + 42, true); + const name = dec.decode(bytes.subarray(cdOffset + 46, cdOffset + 46 + fnLen)); + cdOffset += 46 + fnLen + extraLen + commentLen; + + if (name.endsWith("/")) continue; + + const localFnLen = view.getUint16(localOffset + 26, true); + const localExtraLen = view.getUint16(localOffset + 28, true); + const dataStart = localOffset + 30 + localFnLen + localExtraLen; + const compressed = bytes.subarray(dataStart, dataStart + compressedSize); + + if (compression === 0) { + files[name] = new Uint8Array(compressed); + } else if (compression === 8) { + const ds = new DecompressionStream("deflate-raw"); + const writer = ds.writable.getWriter(); + const reader = ds.readable.getReader(); + writer.write(compressed); + writer.close(); + const chunks = []; + let totalLen = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + totalLen += value.byteLength; + } + const out = new Uint8Array(totalLen); + let pos = 0; + for (const c of chunks) { out.set(c, pos); pos += c.byteLength; } + files[name] = out; + } + } + return files; + }; + + // Fetch and extract a library zip, caching the result. + const loadLibrary = async (name) => { + if (libraryCache.has(name)) return libraryCache.get(name); + const url = KNOWN_LIBRARIES[name]; + if (!url) throw new Error(`Unknown OpenSCAD library: ${name}`); + const resp = await fetch(url); + if (!resp.ok) throw new Error(`Failed to fetch library ${name}: ${resp.status}`); + const files = await extractZip(await resp.arrayBuffer()); + libraryCache.set(name, files); + return files; + }; + + // Mount a list of libraries into a WASM FS instance. + // Each library is written to // so `use ` resolves correctly. + const mountLibraries = async (instance, libraryNames) => { + for (const libName of libraryNames) { + const files = await loadLibrary(libName); + try { instance.FS.mkdir(`/${libName}`); } catch (_) {} + for (const [filePath, data] of Object.entries(files)) { + const parts = filePath.split("/"); + let dir = `/${libName}`; + for (let j = 0; j < parts.length - 1; j++) { + dir += "/" + parts[j]; + try { instance.FS.mkdir(dir); } catch (_) {} + } + try { instance.FS.writeFile(`/${libName}/${filePath}`, data); } catch (_) {} + } + } + }; + + // Fetch the Roboto TTF once and cache it in memory so it can be written + // to each new WASM instance's FS to enable OpenSCAD text() rendering. + const loadFonts = async () => { + if (robotoFontData) return; + try { + const resp = await fetch( + "https://fonts.gstatic.com/s/roboto/v32/KFOmCnqEu92Fr1Me5Q.ttf", + ); + if (resp.ok) { + robotoFontData = new Uint8Array(await resp.arrayBuffer()); + } + } catch (e) { + console.warn("[openscad] Failed to load fonts:", e); + } + }; + + // Extract parameters from SCAD code. Tries OpenSCAD WASM with + // --export-format=param first (uses the built-in Customizer engine with full + // comment syntax support). Falls back to regex parsing if WASM returns nothing. + const extractParams = async (code, libraryNames = []) => { + try { + const openscad = await getOpenScad(); + const instance = openscad; + + if (libraryNames.length > 0) { + await mountLibraries(instance, libraryNames); + } + + const sourcePath = "/tmp/params_model.scad"; + const outPath = "/tmp/params_out.json"; + + try { instance.FS.unlink(sourcePath); } catch (_) {} + try { instance.FS.unlink(outPath); } catch (_) {} + + // Prepend $preview=true as the playground does, to avoid full geometry evaluation. + instance.FS.writeFile(sourcePath, "$preview=true;\n" + code); + + const exitCode = instance.callMain([ + sourcePath, + "-o", outPath, + "--export-format=param", + ]); + + if (exitCode === 0) { + try { + const json = instance.FS.readFile(outPath, { encoding: "utf8" }); + const paramSet = JSON.parse(json); + if (Array.isArray(paramSet.parameters) && paramSet.parameters.length > 0) { + // Filter out OpenSCAD special variables (e.g. $preview, $fn, $fa, $fs) + // that are internal and should not be exposed in the parameter UI. + return paramSet.parameters.filter(p => !p.name?.startsWith('$')); + } + } catch (e) { + console.warn("[openscad] Failed to parse param output:", e); + } + } + } catch (e) { + console.warn("[openscad] WASM param extraction failed:", e); + } + return []; + }; + + const getThree = async () => { + if (!threePromise) { + threePromise = Promise.all([ + import(/* @vite-ignore */ _scriptBase + "three.module.js"), + import(/* @vite-ignore */ _scriptBase + "STLLoader.js"), + import(/* @vite-ignore */ _scriptBase + "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 setupCanvasParamsSplitter(leftSide, previewContainer, paramsPanel, splitter, onSplitChanged) { + if (!leftSide || !previewContainer || !paramsPanel || !splitter) return; + + const minSize = 80; + + const applySplitSize = (rawSize) => { + const total = leftSide.clientHeight; + const splitterSize = splitter.offsetHeight; + const maxSize = Math.max(minSize, total - splitterSize - minSize); + const clamped = Math.max(minSize, Math.min(rawSize, maxSize)); + previewContainer.style.flex = `0 0 ${clamped}px`; + return clamped; + }; + + const applyStoredSplitSize = () => { + const rawStored = Number(leftSide.dataset.splitCanvasParams); + if (!Number.isFinite(rawStored) || rawStored <= 0) { + previewContainer.style.flex = ""; + return; + } + applySplitSize(rawStored); + }; + + applyStoredSplitSize(); + + splitter.addEventListener("pointerdown", (event) => { + event.preventDefault(); + splitter.setPointerCapture(event.pointerId); + + const startPointer = event.clientY; + const startSize = previewContainer.getBoundingClientRect().height; + + const onPointerMove = (moveEvent) => { + const delta = moveEvent.clientY - startPointer; + const size = applySplitSize(startSize + delta); + leftSide.dataset.splitCanvasParams = String(Math.round(size)); + }; + + const onPointerUp = () => { + splitter.removeEventListener("pointermove", onPointerMove); + splitter.removeEventListener("pointerup", onPointerUp); + splitter.removeEventListener("pointercancel", onPointerUp); + const splitCanvasParams = Number(leftSide.dataset.splitCanvasParams); + if (Number.isFinite(splitCanvasParams) && splitCanvasParams > 0) { + onSplitChanged?.({ splitCanvasParams: Math.round(splitCanvasParams) }); + } + }; + + splitter.addEventListener("pointermove", onPointerMove); + splitter.addEventListener("pointerup", onPointerUp); + splitter.addEventListener("pointercancel", onPointerUp); + }); + + window.addEventListener("resize", applyStoredSplitSize); + return applyStoredSplitSize; + } + + function setupSplitter(elem, leftSide, editorContainer, splitter, onSplitChanged) { + if (!leftSide || !editorContainer || !splitter) return; + + const previewContainer = leftSide; + + 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); + const splitHorizontal = Number(elem.dataset.splitHorizontal); + const splitVertical = Number(elem.dataset.splitVertical); + onSplitChanged?.({ + ...(Number.isFinite(splitHorizontal) && splitHorizontal > 0 + ? { splitHorizontal: Math.round(splitHorizontal) } + : {}), + ...(Number.isFinite(splitVertical) && splitVertical > 0 + ? { splitVertical: Math.round(splitVertical) } + : {}), + }); + }; + + splitter.addEventListener("pointermove", onPointerMove); + splitter.addEventListener("pointerup", onPointerUp); + splitter.addEventListener("pointercancel", onPointerUp); + }); + + window.addEventListener("resize", applyStoredSplitSize); + return 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 libraryNames = (elem.getAttribute("data-library") || "") + .split(",").map(s => s.trim()).filter(Boolean); + + const previewContainer = elem.querySelector(".preview-container"); + const leftSide = elem.querySelector(".left-side"); + const canvasWrapper = elem.querySelector(".canvas-wrapper"); + const canvasOverlay = elem.querySelector(".canvas-overlay"); + const editorContainer = elem.querySelector(".editor-container"); + const splitter = elem.querySelector(".splitter"); + const canvasParamsSplitter = elem.querySelector(".canvas-params-splitter"); + const canvas = elem.querySelector(".preview-canvas"); + const editor = elem.querySelector("code-input.editor"); + const params = elem.querySelector("textarea.parameters"); + + // The parameters panel is its own card below the canvas. + const paramsPanel = elem.querySelector(".parameters-panel"); + const paramsForm = paramsPanel?.querySelector(".parameters-body") ?? paramsPanel; + + const renderBtn = elem.querySelector("button.render"); + const copyBtn = elem.querySelector("button.copy"); + const downloadStlBtn = elem.querySelector("button.download-stl"); + const resetBtn = elem.querySelector("button.reset"); + const fullscreenBtn = elem.querySelector("button.fullscreen"); + + // --- Canvas overlay --- + let overlayDismissTimer = null; + + const hideOverlay = () => { + clearTimeout(overlayDismissTimer); + if (canvasOverlay) { + canvasOverlay.className = "canvas-overlay hidden"; + canvasOverlay.innerHTML = ""; + } + }; + + const showOverlay = (type, message) => { + clearTimeout(overlayDismissTimer); + if (!canvasOverlay) return; + canvasOverlay.innerHTML = ""; + canvasOverlay.className = `canvas-overlay ${type}`; + + if (type === "loading") { + const spinner = document.createElement("div"); + spinner.className = "canvas-spinner"; + const label = document.createElement("span"); + label.className = "overlay-message"; + label.textContent = message; + canvasOverlay.appendChild(spinner); + canvasOverlay.appendChild(label); + } else { + const msg = document.createElement("div"); + msg.className = "overlay-message"; + msg.textContent = message; + const btn = document.createElement("button"); + btn.className = "overlay-dismiss"; + btn.textContent = "✕"; + btn.addEventListener("click", hideOverlay); + canvasOverlay.appendChild(msg); + canvasOverlay.appendChild(btn); + } + }; + + // Resize the Three.js renderer to match the current canvas-wrapper size. + const resizeCanvas = () => { + if (!viewerState.renderer || !viewerState.camera || !canvasWrapper) return; + const w = Math.max(1, Math.floor(canvasWrapper.clientWidth)); + const h = Math.max(1, Math.floor(canvasWrapper.clientHeight)); + viewerState.renderer.setSize(w, h, false); + viewerState.camera.aspect = w / h; + viewerState.camera.updateProjectionMatrix(); + }; + + const applyMainSplitSize = setupSplitter(elem, leftSide, editorContainer, splitter, () => { + resizeCanvas(); + save(); + }); + const applyCanvasParamsSplitSize = setupCanvasParamsSplitter(leftSide, previewContainer, paramsPanel, canvasParamsSplitter, () => { + resizeCanvas(); + save(); + }); + + const viewerState = { + renderer: null, + camera: null, + scene: null, + controls: null, + mesh: null, + raf: 0, + disposed: false, + resizeObserver: null, + }; + + const save = async () => { + if (!id) return; + const splitHorizontal = Number(elem.dataset.splitHorizontal); + const splitVertical = Number(elem.dataset.splitVertical); + const splitCanvasParams = Number(leftSide?.dataset.splitCanvasParams); + await hyperbook.store.db.openscad.put({ + id, + code: editor?.value || "", + params: params?.value || "{}", + ...(Number.isFinite(splitHorizontal) && splitHorizontal > 0 + ? { splitHorizontal: Math.round(splitHorizontal) } + : {}), + ...(Number.isFinite(splitVertical) && splitVertical > 0 + ? { splitVertical: Math.round(splitVertical) } + : {}), + ...(Number.isFinite(splitCanvasParams) && splitCanvasParams > 0 + ? { splitCanvasParams: Math.round(splitCanvasParams) } + : {}), + }); + }; + + const load = async () => { + if (!id) return null; + const result = await hyperbook.store.db.openscad.get(id); + if (!result) return null; + if (editor && typeof result.code === "string") { + editor.value = result.code; + } + if (params && typeof result.params === "string") { + params.value = result.params; + } + if (Number.isFinite(result.splitHorizontal) && result.splitHorizontal > 0) { + elem.dataset.splitHorizontal = String(Math.round(result.splitHorizontal)); + } + if (Number.isFinite(result.splitVertical) && result.splitVertical > 0) { + elem.dataset.splitVertical = String(Math.round(result.splitVertical)); + } + if (leftSide && Number.isFinite(result.splitCanvasParams) && result.splitCanvasParams > 0) { + leftSide.dataset.splitCanvasParams = String(Math.round(result.splitCanvasParams)); + } + return result; + }; + + // Rebuild the parameters form from the code's top-level variable assignments. + // Stored overrides from the textarea are preserved so user edits survive + // code changes that don't touch those variable names. + const buildParamForm = async (code) => { + // Show a loading indicator while WASM extracts params. + paramsForm.innerHTML = ""; + paramsPanel?.classList.remove("hidden"); + canvasParamsSplitter?.classList.remove("hidden"); + const loading = document.createElement("p"); + loading.className = "params-empty"; + loading.textContent = i18nGet("openscad-params-loading", "Loading parameters..."); + paramsForm.appendChild(loading); + + const codeParams = await extractParams(code, libraryNames); + paramsForm.innerHTML = ""; + + if (codeParams.length === 0) { + paramsPanel?.classList.add("hidden"); + canvasParamsSplitter?.classList.add("hidden"); + if (params) params.value = "{}"; + return; + } + + let currentOverrides = {}; + try { + currentOverrides = JSON.parse(params?.value || "{}"); + } catch (_) {} + + const syncTextarea = () => { + const values = {}; + paramsForm.querySelectorAll("[data-param-name]").forEach((input) => { + const name = input.dataset.paramName; + const type = input.dataset.paramType; + if (type === "boolean") { + values[name] = input.checked; + } else if (type === "number") { + values[name] = Number(input.value); + } else { + values[name] = input.value; + } + }); + if (params) params.value = JSON.stringify(values); + save(); + }; + + codeParams.forEach(({ name, caption, type, initial, min, max, step, options }) => { + const current = + currentOverrides[name] !== undefined ? currentOverrides[name] : initial; + + const row = document.createElement("div"); + row.className = "param-row"; + + const label = document.createElement("label"); + label.textContent = caption || name; + label.setAttribute("for", `openscad-param-${id}-${name}`); + + let input; + if (type === "boolean") { + input = document.createElement("input"); + input.type = "checkbox"; + input.checked = Boolean(current); + } else if (options && options.length > 0) { + // Dropdown for parameters with a fixed set of options. + input = document.createElement("select"); + options.forEach(({ name: optName, value: optValue }) => { + const opt = document.createElement("option"); + opt.value = String(optValue); + opt.textContent = optName || String(optValue); + if (String(optValue) === String(current)) opt.selected = true; + input.appendChild(opt); + }); + input.addEventListener("change", syncTextarea); + } else if (type === "number" && Array.isArray(initial)) { + // Vector: render one number input per component. + input = document.createElement("span"); + input.className = "param-vector"; + const arr = Array.isArray(current) ? current : initial; + arr.forEach((val, idx) => { + const ni = document.createElement("input"); + ni.type = "number"; + ni.value = String(val); + ni.step = step != null ? String(step) : "any"; + if (min != null) ni.min = String(min); + if (max != null) ni.max = String(max); + ni.dataset.paramName = name; + ni.dataset.paramType = "vector"; + ni.dataset.vectorIndex = String(idx); + ni.addEventListener("input", () => { + const all = Array.from(input.querySelectorAll("input")).map( + (i) => Number(i.value) + ); + const sibling = paramsForm.querySelector( + `[data-param-name="${name}"][data-param-type="number"]` + ); + if (sibling) sibling.value = JSON.stringify(all); + syncTextarea(); + }); + input.appendChild(ni); + }); + // Hidden input holds the JSON array for syncTextarea to read. + const hidden = document.createElement("input"); + hidden.type = "hidden"; + hidden.dataset.paramName = name; + hidden.dataset.paramType = "number"; + hidden.value = JSON.stringify(arr); + input.appendChild(hidden); + } else if (type === "number") { + input = document.createElement("input"); + input.type = "number"; + input.value = String(current); + input.step = step != null ? String(step) : "any"; + if (min != null) input.min = String(min); + if (max != null) input.max = String(max); + } else { + input = document.createElement("input"); + input.type = "text"; + input.value = String(current); + } + input.id = `openscad-param-${id}-${name}`; + if (input.tagName !== "SPAN") { + input.dataset.paramName = name; + input.dataset.paramType = type; + input.addEventListener("input", syncTextarea); + } + + row.appendChild(label); + row.appendChild(input); + paramsForm.appendChild(row); + }); + + syncTextarea(); + }; + + 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, libraryNames = []) => { + renderBtn?.setAttribute("disabled", "true"); + showOverlay("loading", i18nGet("openscad-rendering", "Rendering...")); + + try { + const paramDefinitions = getParamDefinitions(); + const openscad = await getOpenScad(); + const instance = openscad; + + if (libraryNames.length > 0) { + await mountLibraries(instance, libraryNames); + } + + 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, + `--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 () => { + openscadStderr = []; + try { + // Ensure font bytes are fetched before creating the WASM instance + await loadFonts(); + await save(); + const stl = await renderWithFormat("stl", libraryNames); + await renderStl(stl); + hideOverlay(); + } catch (error) { + // Prefer actual OpenSCAD error lines over raw JS/C++ exception values. + // emscripten throws C++ exceptions as raw numbers (WASM memory pointers). + const stderrErrors = openscadStderr.filter((l) => /error/i.test(l)).join("\n"); + if (stderrErrors) { + showOverlay("error", stderrErrors); + } else if (typeof error === "number") { + showOverlay("error", i18nGet("openscad-render-failed", "OpenSCAD render failed")); + } else { + showOverlay("error", 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(); + + viewerState.resizeObserver = new ResizeObserver(() => resizeCanvas()); + viewerState.resizeObserver.observe(canvasWrapper || previewContainer); + } + + const bounds = canvasWrapper?.getBoundingClientRect() ?? 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(); + }; + + copyBtn?.addEventListener("click", async () => { + await navigator.clipboard.writeText(editor?.value || ""); + hideOverlay(); + }); + + 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 () => { + openscadStderr = []; + try { + await loadFonts(); + await save(); + const stl = await renderWithFormat("stl", libraryNames); + downloadBinary(stl, "stl"); + await renderStl(stl); + hideOverlay(); + } catch (error) { + const stderrErrors = openscadStderr.filter((l) => /error/i.test(l)).join("\n"); + showOverlay("error", stderrErrors || error?.message || `${error}`); + } + }); + + fullscreenBtn?.addEventListener("click", async () => { + try { + await toggleFullscreen(elem); + } catch (error) { + console.error(error?.message || error); + } + }); + + updateFullscreenButtonState(elem, fullscreenBtn); + + let editorStateRestored = false; + const restoreEditorState = async () => { + if (editorStateRestored) return; + editorStateRestored = true; + + const stored = await load(); + // Re-apply split sizes after stored dataset values are applied by load(). + applyMainSplitSize?.(); + applyCanvasParamsSplitSize?.(); + let paramRebuildTimer = null; + editor.addEventListener("input", () => { + save(); + clearTimeout(paramRebuildTimer); + paramRebuildTimer = setTimeout(() => buildParamForm(editor.value), 500); + }); + + // Use stored code if available; otherwise fall back to the editor's + // current value (the markdown default) or the built-in placeholder. + const initialCode = stored?.code || editor.value.trim() || "// OpenSCAD\ncube([20,20,20], center=true);"; + editor.value = initialCode; + if (!params?.value.trim()) { + params.value = "{}"; + } + await buildParamForm(initialCode); + // Only persist when there was no stored entry; if one already existed we + // must not overwrite it — reading editor.value right now may return stale + // data because code-input's async re-render may not have completed yet. + if (!stored) { + await save(); + } + renderPreview(); + }; + + editor?.addEventListener("code-input_load", restoreEditorState); + // SPA timing: if code-input already rendered its inner textarea before we + // attached the listener, fire the handler immediately (mirrors pyide). + if (editor?.querySelector("textarea")) { + void restoreEditorState(); + } + } + + 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 000000000..8dbe86bd8 --- /dev/null +++ b/packages/markdown/assets/directive-openscad/style.css @@ -0,0 +1,371 @@ +.directive-openscad { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + overflow: hidden; + gap: 8px; + height: var(--openscad-height, calc(100dvh - 80px)); +} + +/* Left side: wraps preview-container + params card */ +.directive-openscad .left-side { + width: 100%; + min-height: 120px; + min-width: 120px; + flex: 1 1 0; + display: flex; + flex-direction: column; + gap: 0; + overflow: hidden; +} + +.directive-openscad .preview-container { + width: 100%; + min-height: 120px; + border: 1px solid var(--color-spacer); + border-radius: 8px; + overflow: hidden; + background-color: var(--color-background, var(--color--background, #fff)); + 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%; + display: block; + background: linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%); +} + +/* Canvas wrapper: relative container for overlay positioning */ +.directive-openscad .canvas-wrapper { + position: relative; + flex: 1; + overflow: hidden; + min-height: 80px; + display: flex; +} + +/* Canvas overlay: covers the canvas area */ +.directive-openscad .canvas-overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 16px; + box-sizing: border-box; + pointer-events: none; +} + +.directive-openscad .canvas-overlay.hidden { + display: none; +} + +/* Loading state */ +.directive-openscad .canvas-overlay.loading { + background: rgba(0, 0, 0, 0.45); + color: #fff; +} + +/* Error state */ +.directive-openscad .canvas-overlay.error { + background: rgba(239, 68, 68, 0.12); + color: #991b1b; + align-items: flex-start; + justify-content: flex-start; + overflow-y: auto; + pointer-events: auto; +} + +.directive-openscad .canvas-overlay .overlay-message { + font-weight: 600; + font-size: 0.95em; + text-align: center; +} + +.directive-openscad .canvas-overlay.error .overlay-message { + white-space: pre-wrap; + word-break: break-word; + font-weight: 400; + text-align: left; + font-family: hyperbook-monospace, monospace; + font-size: 0.85em; +} + +.directive-openscad .overlay-dismiss { + padding: 4px 14px; + border: 1px solid currentColor; + border-radius: 4px; + background: transparent; + color: inherit; + cursor: pointer; + font-size: 0.85em; + opacity: 0.8; + flex: 0 0 auto; +} + +.directive-openscad .overlay-dismiss:hover { + opacity: 1; + background: rgba(0, 0, 0, 0.06); +} + +/* Spinner */ +.directive-openscad .canvas-spinner { + width: 32px; + height: 32px; + border: 3px solid rgba(255, 255, 255, 0.3); + border-top-color: #fff; + border-radius: 50%; + animation: openscad-spin 0.75s linear infinite; +} + +@keyframes openscad-spin { + to { transform: rotate(360deg); } +} + +/* Splitter between canvas and parameters card */ +.directive-openscad .canvas-params-splitter { + width: 100%; + height: 4px; + margin: 6px 0; + cursor: row-resize; + 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 .canvas-params-splitter:hover { + opacity: 0.65; +} + +/* Parameters card */ +.directive-openscad .parameters-panel { + width: 100%; + min-height: 80px; + border: 1px solid var(--color-spacer); + border-radius: 8px; + overflow: hidden; + background-color: var(--color-background, var(--color--background, #fff)); + flex: 1 1 0; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +.directive-openscad .parameters-panel.hidden { + display: none; +} + +.directive-openscad .parameters-header { + border-bottom: 1px solid var(--color-spacer); + padding: 8px 16px; + font-weight: 600; + flex-shrink: 0; +} + +.directive-openscad .parameters-body { + flex: 1; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.directive-openscad .editor-container { + width: 100%; + display: flex; + flex-direction: column; + min-height: 120px; + min-width: 120px; + flex: 1 1 0; +} + +/* Main left/right splitter */ +.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, var(--color--background, #fff)); + 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; + margin: 0; /* override code-input.min.css default margin: 8px */ +} + +/* The parameters textarea is always hidden — the form replaces it visually */ +.directive-openscad .parameters { + display: none !important; +} + +.directive-openscad .param-row { + display: flex; + align-items: center; + gap: 10px; +} + +.directive-openscad .param-row label { + flex: 1; + font-weight: 500; + min-width: 0; + word-break: break-word; +} + +.directive-openscad .param-row input[type="text"], +.directive-openscad .param-row input[type="number"], +.directive-openscad .param-row select { + flex: 1; + padding: 4px 8px; + border: 1px solid var(--color-spacer); + border-radius: 4px; + background: var(--color-background, var(--color--background, #fff)); + color: var(--color-text); + font-family: inherit; +} + +.directive-openscad .param-vector { + flex: 1; + display: flex; + gap: 4px; +} + +.directive-openscad .param-vector input[type="number"] { + flex: 1; + min-width: 0; +} + +.directive-openscad .param-row input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; +} + +.directive-openscad .params-empty { + color: var(--color-text-muted, #888); + font-style: italic; + margin: auto; + text-align: center; +} + +.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 .left-side, + .directive-openscad .editor-container { + flex: 1; + height: 100% !important; + } +} + +@media (prefers-color-scheme: dark) { + .directive-openscad .preview-canvas { + background: linear-gradient(180deg, #2a2a2a 0%, #1e1e1e 100%); + } + + .directive-openscad .canvas-overlay.error { + background: rgba(239, 68, 68, 0.18); + color: #fca5a5; + } + + .directive-openscad .overlay-dismiss:hover { + background: rgba(255, 255, 255, 0.1); + } +} diff --git a/packages/markdown/assets/store.js b/packages/markdown/assets/store.js index b4807e34d..5a761898a 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 b5d80dcb7..71fbe7082 100644 --- a/packages/markdown/locales/de.json +++ b/packages/markdown/locales/de.json @@ -88,6 +88,20 @@ "typst-file-replace": "Existierende Datei ersetzen?", "typst-binary-files": "Binärdateien", "typst-no-binary-files": "Keine Binärdateien", + "openscad-preview": "Vorschau", + "openscad-render": "Rendern", + "openscad-copy": "Kopieren", + "openscad-copy-done": "Code kopiert", + "openscad-download-stl": "STL 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", + "openscad-params-loading": "Parameter werden geladen...", + "openscad-parameters": "Parameter", "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 1088e91d2..5a7a5fd7e 100644 --- a/packages/markdown/locales/en.json +++ b/packages/markdown/locales/en.json @@ -88,6 +88,20 @@ "typst-file-replace": "Replace existing file?", "typst-binary-files": "Binary Files", "typst-no-binary-files": "No binary files", + "openscad-preview": "Preview", + "openscad-render": "Render", + "openscad-copy": "Copy", + "openscad-copy-done": "Code copied", + "openscad-download-stl": "Download STL", + "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", + "openscad-params-loading": "Loading parameters...", + "openscad-parameters": "Parameters", "user-login-title": "Login", "user-username": "Username", "user-password": "Password", diff --git a/packages/markdown/openscad-config.json b/packages/markdown/openscad-config.json new file mode 100644 index 000000000..4393e0b1a --- /dev/null +++ b/packages/markdown/openscad-config.json @@ -0,0 +1,7 @@ +{ + "wasmBuild": { + "url": "https://files.openscad.org/playground/OpenSCAD-2025.03.25.wasm24456-WebAssembly-web.zip", + "target": "directive-openscad", + "files": ["openscad.js", "openscad.wasm"] + } +} diff --git a/packages/markdown/package.json b/packages/markdown/package.json index d6e259ab0..95898ccb3 100644 --- a/packages/markdown/package.json +++ b/packages/markdown/package.json @@ -67,6 +67,7 @@ "remark-rehype": "^11.1.2", "shiki": "^3.21.0", "sort-keys": "^6.0.0", + "three": "0.170.0", "unified": "^11.0.5", "unist-util-find-after": "^5.0.0", "unist-util-visit": "^5.1.0", diff --git a/packages/markdown/postbuild.mjs b/packages/markdown/postbuild.mjs index f28c9e81b..2934a09b2 100644 --- a/packages/markdown/postbuild.mjs +++ b/packages/markdown/postbuild.mjs @@ -92,6 +92,9 @@ async function downloadAndExtractZip(url, destination) { } async function postbuild() { + // Read openscad-config.json for WASM asset configuration + const openscadConfig = JSON.parse(await readFile("openscad-config.json", "utf-8")); + // Download and extract zips const zipFiles = [ { @@ -102,6 +105,10 @@ async function postbuild() { url: "https://github.com/openpatch/online-ide/releases/download/v2.2.1-hyperbook.5/dist-embedded.zip", dst: path.join("./dist", "assets", "directive-onlineide", "include"), }, + { + url: openscadConfig.wasmBuild.url, + dst: path.join("./dist", "assets", openscadConfig.wasmBuild.target), + }, ]; for (let zip of zipFiles) { @@ -358,6 +365,20 @@ async function postbuild() { "struktolab-renderer.umd.js", ), }, + { + src: path.join("./node_modules", "three", "build", "three.module.js"), + dst: path.join("./dist", "assets", "directive-openscad", "three.module.js"), + }, + { + src: path.join("./node_modules", "three", "examples", "jsm", "loaders", "STLLoader.js"), + dst: path.join("./dist", "assets", "directive-openscad", "STLLoader.js"), + rewriteThreeImport: true, + }, + { + src: path.join("./node_modules", "three", "examples", "jsm", "controls", "OrbitControls.js"), + dst: path.join("./dist", "assets", "directive-openscad", "OrbitControls.js"), + rewriteThreeImport: true, + }, ]; for (let asset of assets) { @@ -368,6 +389,13 @@ async function postbuild() { mangle: true, }); await writeFile(asset.dst, result.code); + } else if (asset.rewriteThreeImport) { + // Rewrite bare `from 'three'` specifier to a relative path so the file + // works as a standalone ES module without an import map. + let code = await readFile(asset.src, "utf8"); + code = code.replaceAll("from 'three'", "from './three.module.js'"); + await mkdir(path.dirname(asset.dst), { recursive: true }); + await writeFile(asset.dst, code); } else { await cp(asset.src, asset.dst, { recursive: true }); } diff --git a/packages/markdown/src/process.ts b/packages/markdown/src/process.ts index 9e51f9ebe..68086a01a 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 000000000..add4c1224 --- /dev/null +++ b/packages/markdown/src/remarkDirectiveOpenscad.ts @@ -0,0 +1,222 @@ +// 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, library } = 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}` } : {}), + ...(library ? { "data-library": library } : {}), + }; + + data.hChildren = [ + { + type: "element", + tagName: "div", + properties: { class: "left-side" }, + children: [ + { + 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: "div", + properties: { class: "canvas-wrapper" }, + children: [ + { + type: "element", + tagName: "canvas", + properties: { class: "preview-canvas" }, + children: [], + }, + { + type: "element", + tagName: "div", + properties: { class: "canvas-overlay hidden" }, + children: [], + }, + ], + }, + ], + }, + { + type: "element", + tagName: "div", + properties: { + class: "canvas-params-splitter hidden", + role: "separator", + "aria-label": "Resize canvas and parameters", + }, + children: [], + }, + { + type: "element", + tagName: "div", + properties: { class: "parameters-panel hidden" }, + children: [ + { + type: "element", + tagName: "div", + properties: { class: "parameters-header" }, + children: [{ type: "text", value: i18n.get("openscad-parameters") }], + }, + { + type: "element", + tagName: "div", + properties: { class: "parameters-body" }, + 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: "render" }, + children: [{ type: "text", value: i18n.get("openscad-render") }], + }, + ], + }, + { + type: "element", + tagName: "code-input", + properties: { + class: "editor 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: "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: "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 000000000..5884f71ce --- /dev/null +++ b/packages/markdown/tests/__snapshots__/remarkDirectiveOpenscad.test.ts.snap @@ -0,0 +1,28 @@ +// 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 000000000..2a94783c0 --- /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(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16e4e0231..85eab820d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -326,6 +326,9 @@ importers: sort-keys: specifier: ^6.0.0 version: 6.0.0 + three: + specifier: 0.170.0 + version: 0.170.0 unified: specifier: ^11.0.5 version: 11.0.5 @@ -8168,6 +8171,9 @@ packages: resolution: {integrity: sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==} engines: {node: '>=4'} + three@0.170.0: + resolution: {integrity: sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==} + throttle-debounce@5.0.2: resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} engines: {node: '>=12.22'} @@ -17717,6 +17723,8 @@ snapshots: dependencies: editions: 6.22.0 + three@0.170.0: {} + throttle-debounce@5.0.2: {} through2@4.0.2: diff --git a/website/de/book/elements/openscad.md b/website/de/book/elements/openscad.md new file mode 100644 index 000000000..f0d167bbb --- /dev/null +++ b/website/de/book/elements/openscad.md @@ -0,0 +1,225 @@ +--- +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)` | +| `library` | Kommaseparierte Liste von Bibliotheken, die in die OpenSCAD-Umgebung geladen werden sollen | keine | + +Mögliche Bibliotheken sind: BOSL2, BOSL, MCAD, NopSCADlib, fonts + +## Code aus Datei laden + +````md +:::openscad{src="openscad/example.scad"} +::: +```` + +## Beispiel mit Variablen + +````md +:::openscad + +```scad +segments = 5; +size = 5; +height = 10; +rounded = 1; +label = "K"; + +$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 +segments = 5; +size = 5; +height = 10; +rounded = 1; +label = "K"; + +$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"); +} +``` + +::: + +## Beispiel mit Bibliothek + +````hyperbook +:::openscad{library="BOSL2"} +```scad +include +include + +$fn = 100; +rod_diameter = 12; + +module bridge(width = 230, height = 80, rod_diameter = 12) { + pilar_width = height / 2; + pilar_height = height + 5; + middle_width = width - pilar_width * 2; + middle_thickness = max(rod_diameter, 19); + rod_diameter_plus_threshold = rod_diameter + 0.5; + module pilar() { + difference() { + cube([pilar_width, middle_thickness + 10, pilar_height], center=true); + rotate([90, 0, 0]) + translate([0, 0, -middle_thickness/2]) + cylinder(h=middle_thickness, d=rod_diameter_plus_threshold, center=true); + rotate([90, 0, 0]) + translate([0, 0, middle_thickness/2]) + cylinder(h=middle_thickness, d=rod_diameter_plus_threshold / 4, center=true); + translate([0, 0, pilar_height/2]) + cylinder(h=rod_diameter_plus_threshold*2, d=rod_diameter_plus_threshold, center=true); + translate([0, 0, -pilar_height/2]) + cylinder(h=rod_diameter_plus_threshold*2, d=rod_diameter_plus_threshold, center=true); + } + } + + module middle() { + s = [[0, 0], [middle_width, 0], [middle_width, -height / 2 - 20], [middle_width - 20, -height / 2 - 10], [20, -height / 2 - 10], [0, -height / 2 - 20]]; + rotate([90, 0, 0]) + translate([0,2.5,0]) + hex_panel(s, 2, 10, h=12, frame=5); + } + + middle(); + translate([-pilar_width / 2, 0, -height/2]) + pilar(); + translate([middle_width + pilar_width / 2, 0, -height/2]) + pilar(); +} + +bridge(rod_diameter=rod_diameter); +``` + +::: +```` + +:::openscad{library="BOSL2"} +```scad +include +include + +$fn = 100; +rod_diameter = 12; + +module bridge(width = 230, height = 80, rod_diameter = 12) { + pilar_width = height / 2; + pilar_height = height + 5; + middle_width = width - pilar_width * 2; + middle_thickness = max(rod_diameter, 19); + rod_diameter_plus_threshold = rod_diameter + 0.5; + module pilar() { + difference() { + cube([pilar_width, middle_thickness + 10, pilar_height], center=true); + rotate([90, 0, 0]) + translate([0, 0, -middle_thickness/2]) + cylinder(h=middle_thickness, d=rod_diameter_plus_threshold, center=true); + rotate([90, 0, 0]) + translate([0, 0, middle_thickness/2]) + cylinder(h=middle_thickness, d=rod_diameter_plus_threshold / 4, center=true); + translate([0, 0, pilar_height/2]) + cylinder(h=rod_diameter_plus_threshold*2, d=rod_diameter_plus_threshold, center=true); + translate([0, 0, -pilar_height/2]) + cylinder(h=rod_diameter_plus_threshold*2, d=rod_diameter_plus_threshold, center=true); + } + } + + module middle() { + s = [[0, 0], [middle_width, 0], [middle_width, -height / 2 - 20], [middle_width - 20, -height / 2 - 10], [20, -height / 2 - 10], [0, -height / 2 - 20]]; + rotate([90, 0, 0]) + translate([0,2.5,0]) + hex_panel(s, 2, 10, h=12, frame=5); + } + + middle(); + translate([-pilar_width / 2, 0, -height/2]) + pilar(); + translate([middle_width + pilar_width / 2, 0, -height/2]) + pilar(); +} + +bridge(rod_diameter=rod_diameter); +``` + +::: diff --git a/website/en/book/changelog.md b/website/en/book/changelog.md index 3d4072728..788a209e1 100644 --- a/website/en/book/changelog.md +++ b/website/en/book/changelog.md @@ -38,6 +38,18 @@ If you need a new feature, open an [issue](https://github.com/openpatch/hyperboo :::: --> +## v0.92.0 + +::::tabs + +:::tab{title="Improved :+1:" id="improved"} + +- Add OpenSCAD element for rendering OpenSCAD code with interactive 3D viewer. [Learn more](/elements/openscad) + +::: + +:::: + ## v0.91.1 ::::tabs 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 000000000..e7efb091f Binary files /dev/null and b/website/en/book/elements/openscad-docs-screenshot.png differ diff --git a/website/en/book/elements/openscad.md b/website/en/book/elements/openscad.md new file mode 100644 index 000000000..9fbbee9e4 --- /dev/null +++ b/website/en/book/elements/openscad.md @@ -0,0 +1,223 @@ +--- +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)` | +| `library` | Comma-separated list of libraries to load into the OpenSCAD environment | none | + +Possible libraries include: BOSL2, BOSL, MCAD, NopSCADlib, fonts + +## Load code from file + +````md +:::openscad{src="openscad/example.scad"} +::: +```` +## Example with variables + +````md +:::openscad + +```scad +segments = 5; +size = 5; +height = 10; +rounded = 1; +label = "K"; + +$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 +segments = 5; +size = 5; +height = 10; +rounded = 1; +label = "K"; + +$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"); +} +``` + +::: + +## Example with Library + +````hyperbook +:::openscad{library="BOSL2"} +```scad +include +include + +$fn = 100; +rod_diameter = 12; + +module bridge(width = 230, height = 80, rod_diameter = 12) { + pilar_width = height / 2; + pilar_height = height + 5; + middle_width = width - pilar_width * 2; + middle_thickness = max(rod_diameter, 19); + rod_diameter_plus_threshold = rod_diameter + 0.5; + module pilar() { + difference() { + cube([pilar_width, middle_thickness + 10, pilar_height], center=true); + rotate([90, 0, 0]) + translate([0, 0, -middle_thickness/2]) + cylinder(h=middle_thickness, d=rod_diameter_plus_threshold, center=true); + rotate([90, 0, 0]) + translate([0, 0, middle_thickness/2]) + cylinder(h=middle_thickness, d=rod_diameter_plus_threshold / 4, center=true); + translate([0, 0, pilar_height/2]) + cylinder(h=rod_diameter_plus_threshold*2, d=rod_diameter_plus_threshold, center=true); + translate([0, 0, -pilar_height/2]) + cylinder(h=rod_diameter_plus_threshold*2, d=rod_diameter_plus_threshold, center=true); + } + } + + module middle() { + s = [[0, 0], [middle_width, 0], [middle_width, -height / 2 - 20], [middle_width - 20, -height / 2 - 10], [20, -height / 2 - 10], [0, -height / 2 - 20]]; + rotate([90, 0, 0]) + translate([0,2.5,0]) + hex_panel(s, 2, 10, h=12, frame=5); + } + + middle(); + translate([-pilar_width / 2, 0, -height/2]) + pilar(); + translate([middle_width + pilar_width / 2, 0, -height/2]) + pilar(); +} + +bridge(rod_diameter=rod_diameter); +``` + +::: +```` + +:::openscad{library="BOSL2"} +```scad +include +include + +$fn = 100; +rod_diameter = 12; + +module bridge(width = 230, height = 80, rod_diameter = 12) { + pilar_width = height / 2; + pilar_height = height + 5; + middle_width = width - pilar_width * 2; + middle_thickness = max(rod_diameter, 19); + rod_diameter_plus_threshold = rod_diameter + 0.5; + module pilar() { + difference() { + cube([pilar_width, middle_thickness + 10, pilar_height], center=true); + rotate([90, 0, 0]) + translate([0, 0, -middle_thickness/2]) + cylinder(h=middle_thickness, d=rod_diameter_plus_threshold, center=true); + rotate([90, 0, 0]) + translate([0, 0, middle_thickness/2]) + cylinder(h=middle_thickness, d=rod_diameter_plus_threshold / 4, center=true); + translate([0, 0, pilar_height/2]) + cylinder(h=rod_diameter_plus_threshold*2, d=rod_diameter_plus_threshold, center=true); + translate([0, 0, -pilar_height/2]) + cylinder(h=rod_diameter_plus_threshold*2, d=rod_diameter_plus_threshold, center=true); + } + } + + module middle() { + s = [[0, 0], [middle_width, 0], [middle_width, -height / 2 - 20], [middle_width - 20, -height / 2 - 10], [20, -height / 2 - 10], [0, -height / 2 - 20]]; + rotate([90, 0, 0]) + translate([0,2.5,0]) + hex_panel(s, 2, 10, h=12, frame=5); + } + + middle(); + translate([-pilar_width / 2, 0, -height/2]) + pilar(); + translate([middle_width + pilar_width / 2, 0, -height/2]) + pilar(); +} + +bridge(rod_diameter=rod_diameter); +``` + +:::