From 10c5f83d0d6c6b4724b9cb70507a9e18241c0703 Mon Sep 17 00:00:00 2001 From: seveibar Date: Sat, 11 Jul 2026 18:42:26 -0700 Subject: [PATCH] Use embedded Manifold browser runtime --- .github/workflows/browser-manifold.yml | 20 +++++ package.json | 9 ++- scripts/test-browser-manifold.mjs | 75 +++++++++++++++++ src/CadViewerManifold.tsx | 80 +++---------------- src/hooks/useManifoldBoardBuilder.ts | 2 +- src/utils/manifold-mesh-to-three-geometry.ts | 2 +- src/utils/manifold/create-manifold-board.ts | 2 +- src/utils/manifold/load-manifold-runtime.ts | 34 ++++++++ src/utils/manifold/process-cutouts.ts | 2 +- .../manifold/process-non-plated-holes.ts | 2 +- src/utils/manifold/process-plated-holes.ts | 2 +- src/utils/manifold/process-vias.ts | 2 +- 12 files changed, 151 insertions(+), 81 deletions(-) create mode 100644 .github/workflows/browser-manifold.yml create mode 100644 scripts/test-browser-manifold.mjs create mode 100644 src/utils/manifold/load-manifold-runtime.ts diff --git a/.github/workflows/browser-manifold.yml b/.github/workflows/browser-manifold.yml new file mode 100644 index 00000000..747ab4a6 --- /dev/null +++ b/.github/workflows/browser-manifold.yml @@ -0,0 +1,20 @@ +name: Test Browser Manifold Runtime + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: bun install + - run: bunx playwright install chromium --with-deps + - run: bun run test:browser-manifold diff --git a/package.json b/package.json index 3c8656e3..762adcd2 100644 --- a/package.json +++ b/package.json @@ -24,11 +24,13 @@ "vercel-build": "bun run build-storybook", "format": "biome format . --write", "format:check": "biome format .", - "test:node-bundle": "node ./scripts/load-bundle-in-node.js" + "test:node-bundle": "node ./scripts/load-bundle-in-node.js", + "test:browser-manifold": "node ./scripts/test-browser-manifold.mjs" }, "dependencies": { "@jscad/regl-renderer": "^2.6.12", "@jscad/stl-serializer": "^2.1.20", + "@tscircuit/manifold-2d": "https://jscdn.tscircuit.com/@tscircuit/manifold-2d/0.0.4.tgz", "circuit-json": "^0.0.446", "circuit-to-canvas": "^0.0.111", "react-hot-toast": "^2.6.0", @@ -47,6 +49,7 @@ "@biomejs/biome": "^2.1.4", "@chromatic-com/storybook": "^1.9.0", "@jscad/modeling": "^2.12.5", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@storybook/blocks": "9.0.0-alpha.17", "@storybook/react-vite": "^9.1.5", "@tscircuit/alphabet": "^0.0.25", @@ -56,15 +59,15 @@ "@types/react": "19", "@types/react-dom": "19", "@types/three": "^0.165.0", - "@radix-ui/react-dropdown-menu": "^2.1.16", "@vitejs/plugin-react": "^4.3.4", "bun-match-svg": "^0.0.9", "bun-types": "1.2.1", "debug": "^4.4.0", + "esbuild": "^0.28.1", "jscad-electronics": "^0.0.138", "jscad-planner": "^0.0.13", "jsdom": "^26.0.0", - "manifold-3d": "^3.2.1", + "playwright": "^1.61.1", "react-use-gesture": "^9.1.3", "semver": "^7.7.0", "strip-ansi": "^7.1.0", diff --git a/scripts/test-browser-manifold.mjs b/scripts/test-browser-manifold.mjs new file mode 100644 index 00000000..33a7cc4b --- /dev/null +++ b/scripts/test-browser-manifold.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict" +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { createServer } from "node:http" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { build } from "esbuild" +import { chromium } from "playwright" + +const testDir = await mkdtemp(join(tmpdir(), "3d-viewer-manifold-browser-")) +const entryPath = join(testDir, "entry.ts") +const bundlePath = join(testDir, "bundle.js") + +await writeFile( + entryPath, + ` + import { loadManifoldRuntime } from ${JSON.stringify( + new URL("../src/utils/manifold/load-manifold-runtime.ts", import.meta.url) + .pathname, + )} + + try { + const module = await loadManifoldRuntime() + const cube = module.Manifold.cube([2, 3, 4]) + const triangles = cube.numTri() + cube.delete() + document.body.dataset.result = triangles > 0 ? "ok" : "empty" + } catch (error) { + document.body.dataset.result = "error" + document.body.dataset.message = String(error?.stack ?? error) + } + `, +) + +await build({ + entryPoints: [entryPath], + outfile: bundlePath, + bundle: true, + external: ["node:module"], + format: "esm", + platform: "browser", +}) + +const browserBundle = await readFile(bundlePath, "utf8") +assert.equal(browserBundle.includes("manifold-3d"), false) + +const server = createServer((request, response) => { + if (request.url === "/bundle.js") { + response.setHeader("Content-Type", "text/javascript") + response.end(browserBundle) + return + } + response.setHeader("Content-Type", "text/html") + response.end('') +}) + +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) +const address = server.address() +assert(address && typeof address === "object") + +let browser +try { + browser = await chromium.launch({ headless: true }) + const page = await browser.newPage() + await page.goto(`http://127.0.0.1:${address.port}`) + await page.waitForFunction(() => document.body.dataset.result) + const result = await page.locator("body").getAttribute("data-result") + const message = await page.locator("body").getAttribute("data-message") + assert.equal(result, "ok", message ?? "Manifold browser runtime failed") +} finally { + await browser?.close() + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ) + await rm(testDir, { recursive: true, force: true }) +} diff --git a/src/CadViewerManifold.tsx b/src/CadViewerManifold.tsx index 86cbeadf..39c46793 100644 --- a/src/CadViewerManifold.tsx +++ b/src/CadViewerManifold.tsx @@ -1,6 +1,5 @@ import { su } from "@tscircuit/circuit-json-util" import type { AnyCircuitElement, CadComponent } from "circuit-json" -import type { ManifoldToplevel } from "manifold-3d" import type React from "react" import { useEffect, useMemo, useState } from "react" import * as THREE from "three" @@ -16,16 +15,9 @@ import { createTextureMeshes } from "./textures" import { Error3d } from "./three-components/Error3d" import { ThreeErrorBoundary } from "./three-components/ThreeErrorBoundary" import { createGeometryMeshes } from "./utils/manifold/create-three-geometry-meshes" +import { loadManifoldRuntime } from "./utils/manifold/load-manifold-runtime" import { addFauxBoardIfNeeded } from "./utils/preprocess-circuit-json" -declare global { - interface Window { - ManifoldModule: any - MANIFOLD?: any - MANIFOLD_MODULE?: any - } -} - export const BoardMeshes = ({ geometryMeshes, textureMeshes, @@ -135,8 +127,6 @@ type CadViewerManifoldProps = { | { circuitJson?: never; children: React.ReactNode } ) -const MANIFOLD_CDN_BASE_URL = "https://cdn.jsdelivr.net/npm/manifold-3d@3.2.1" - const CadViewerManifold: React.FC = ({ circuitJson: circuitJsonProp, autoRotateDisabled, @@ -160,22 +150,16 @@ const CadViewerManifold: React.FC = ({ const { shadowsEnabled } = useRenderingMode() useEffect(() => { - if ( - window.ManifoldModule && - typeof window.ManifoldModule === "object" && - window.ManifoldModule.setup - ) { - setManifoldJSModule(window.ManifoldModule) - return - } + let cancelled = false - const initManifold = async (ManifoldModule: any) => { + const initManifold = async () => { try { - const loadedModule: ManifoldToplevel = await ManifoldModule() - loadedModule.setup() - window.ManifoldModule = loadedModule + const loadedModule = await loadManifoldRuntime() + + if (cancelled) return setManifoldJSModule(loadedModule) } catch (error) { + if (cancelled) return console.error("Failed to initialize Manifold:", error) setManifoldLoadingError( `Failed to initialize Manifold: ${error instanceof Error ? error.message : "Unknown error"}`, @@ -183,56 +167,10 @@ const CadViewerManifold: React.FC = ({ } } - const existingManifold = - window.ManifoldModule ?? window.MANIFOLD ?? window.MANIFOLD_MODULE - if (existingManifold) { - window.ManifoldModule = existingManifold - initManifold(window.ManifoldModule) - return - } - - const eventName = "manifoldLoaded" - const handleLoad = () => { - const loadedManifold = - window.ManifoldModule ?? window.MANIFOLD ?? window.MANIFOLD_MODULE - if (loadedManifold) { - window.ManifoldModule = loadedManifold - initManifold(window.ManifoldModule) - } else { - const errText = "ManifoldModule not found on window after script load." - console.error(errText) - setManifoldLoadingError(errText) - } - } - - window.addEventListener(eventName, handleLoad, { once: true }) - - const script = document.createElement("script") - script.type = "module" - script.innerHTML = ` -try { - const { default: ManifoldModule } = await import('${MANIFOLD_CDN_BASE_URL}/manifold.js'); - window.ManifoldModule = ManifoldModule; -} catch (e) { - console.error('Error importing manifold in dynamic script:', e); -} finally { - window.dispatchEvent(new CustomEvent('${eventName}')); -} - `.trim() - - const scriptError = (err: any) => { - const errText = "Failed to load Manifold loader script." - console.error(errText, err) - setManifoldLoadingError(errText) - window.removeEventListener(eventName, handleLoad) - } - - script.addEventListener("error", scriptError) - document.body.appendChild(script) + void initManifold() return () => { - window.removeEventListener(eventName, handleLoad) - script.removeEventListener("error", scriptError) + cancelled = true } }, []) diff --git a/src/hooks/useManifoldBoardBuilder.ts b/src/hooks/useManifoldBoardBuilder.ts index 0b375fa5..0d3617b2 100644 --- a/src/hooks/useManifoldBoardBuilder.ts +++ b/src/hooks/useManifoldBoardBuilder.ts @@ -1,6 +1,6 @@ import { su } from "@tscircuit/circuit-json-util" import type { AnyCircuitElement, PcbBoard, PcbPanel } from "circuit-json" -import type { ManifoldToplevel } from "manifold-3d" +import type { ManifoldToplevel } from "@tscircuit/manifold-2d" import { useEffect, useMemo, useRef, useState } from "react" import type { LayerVisibilityState } from "src/contexts/LayerVisibilityContext" import { diff --git a/src/utils/manifold-mesh-to-three-geometry.ts b/src/utils/manifold-mesh-to-three-geometry.ts index a7f8d0e4..e1229a22 100644 --- a/src/utils/manifold-mesh-to-three-geometry.ts +++ b/src/utils/manifold-mesh-to-three-geometry.ts @@ -1,5 +1,5 @@ import * as THREE from "three" -import type { Mesh } from "manifold-3d" +import type { Mesh } from "@tscircuit/manifold-2d" export function manifoldMeshToThreeGeometry( manifoldMesh: Mesh, diff --git a/src/utils/manifold/create-manifold-board.ts b/src/utils/manifold/create-manifold-board.ts index fada21ec..7cf2302b 100644 --- a/src/utils/manifold/create-manifold-board.ts +++ b/src/utils/manifold/create-manifold-board.ts @@ -1,7 +1,7 @@ import type { ManifoldToplevel, CrossSection as ManifoldCrossSection, -} from "manifold-3d" +} from "@tscircuit/manifold-2d" import type { PcbBoard } from "circuit-json" const arePointsClockwise = (points: Array<[number, number]>): boolean => { diff --git a/src/utils/manifold/load-manifold-runtime.ts b/src/utils/manifold/load-manifold-runtime.ts new file mode 100644 index 00000000..8796e44b --- /dev/null +++ b/src/utils/manifold/load-manifold-runtime.ts @@ -0,0 +1,34 @@ +import { + getManifoldModule, + type ManifoldToplevel, +} from "@tscircuit/manifold-2d" + +declare global { + interface Window { + ManifoldModule: any + MANIFOLD?: any + MANIFOLD_MODULE?: any + } +} + +export const loadManifoldRuntime = async (): Promise => { + const existingManifold = + window.ManifoldModule ?? window.MANIFOLD ?? window.MANIFOLD_MODULE + let loadedModule: ManifoldToplevel + + if ( + existingManifold && + typeof existingManifold === "object" && + existingManifold.setup + ) { + loadedModule = existingManifold + } else if (existingManifold) { + loadedModule = await existingManifold() + loadedModule.setup() + } else { + loadedModule = await getManifoldModule() + } + + window.ManifoldModule = loadedModule + return loadedModule +} diff --git a/src/utils/manifold/process-cutouts.ts b/src/utils/manifold/process-cutouts.ts index e37f74b8..39b269ab 100644 --- a/src/utils/manifold/process-cutouts.ts +++ b/src/utils/manifold/process-cutouts.ts @@ -1,4 +1,4 @@ -import type { ManifoldToplevel, CrossSection } from "manifold-3d" +import type { CrossSection, ManifoldToplevel } from "@tscircuit/manifold-2d" import type { AnyCircuitElement, PcbCutout } from "circuit-json" import { su } from "@tscircuit/circuit-json-util" import { SMOOTH_CIRCLE_SEGMENTS } from "../../geoms/constants" diff --git a/src/utils/manifold/process-non-plated-holes.ts b/src/utils/manifold/process-non-plated-holes.ts index d165d988..e39595f9 100644 --- a/src/utils/manifold/process-non-plated-holes.ts +++ b/src/utils/manifold/process-non-plated-holes.ts @@ -1,6 +1,6 @@ import { su } from "@tscircuit/circuit-json-util" import type { AnyCircuitElement } from "circuit-json" -import type { ManifoldToplevel } from "manifold-3d" +import type { ManifoldToplevel } from "@tscircuit/manifold-2d" import { SMOOTH_CIRCLE_SEGMENTS } from "../../geoms/constants" import { createCircleHoleDrill } from "../hole-geoms" import { createRoundedRectPrism } from "../pad-geoms" diff --git a/src/utils/manifold/process-plated-holes.ts b/src/utils/manifold/process-plated-holes.ts index 82a2b89a..8c7ec4bc 100644 --- a/src/utils/manifold/process-plated-holes.ts +++ b/src/utils/manifold/process-plated-holes.ts @@ -1,6 +1,6 @@ import { su } from "@tscircuit/circuit-json-util" import type { AnyCircuitElement, PcbPlatedHole } from "circuit-json" -import type { Manifold, ManifoldToplevel } from "manifold-3d" +import type { Manifold, ManifoldToplevel } from "@tscircuit/manifold-2d" import * as THREE from "three" import { colors as defaultColors, diff --git a/src/utils/manifold/process-vias.ts b/src/utils/manifold/process-vias.ts index 0e60e59f..2d55b897 100644 --- a/src/utils/manifold/process-vias.ts +++ b/src/utils/manifold/process-vias.ts @@ -1,4 +1,4 @@ -import type { ManifoldToplevel } from "manifold-3d" +import type { ManifoldToplevel } from "@tscircuit/manifold-2d" import type { AnyCircuitElement, PcbVia } from "circuit-json" import { su } from "@tscircuit/circuit-json-util" import * as THREE from "three"