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"