From 99b2fb66d03f3d1f8f0fcf7ac675cffceae32e0b Mon Sep 17 00:00:00 2001 From: sucloudflare Date: Mon, 25 May 2026 16:53:55 -0300 Subject: [PATCH] feat(3d): add PcbBoxViewer with PCB texture on box Uses circuit-to-svg to render the board layout as SVG, rasterizes it with @resvg/resvg-wasm to produce a PNG, then applies it as a Three.js texture on the top face of a BoxGeometry. - New: src/components/PcbBoxViewer.tsx - New: src/examples/2026/pcb-box-viewer.fixture.tsx - Export added to src/index.tsx - Deps: three, @types/three, @resvg/resvg-wasm Closes #419 --- package.json | 3 + src/components/PcbBoxViewer.tsx | 211 +++++++++++++++++++ src/examples/2026/pcb-box-viewer.fixture.tsx | 67 ++++++ src/index.tsx | 1 + 4 files changed, 282 insertions(+) create mode 100644 src/components/PcbBoxViewer.tsx create mode 100644 src/examples/2026/pcb-box-viewer.fixture.tsx diff --git a/package.json b/package.json index 82ad84b9..0348554f 100644 --- a/package.json +++ b/package.json @@ -50,8 +50,10 @@ }, "dependencies": { "@emotion/css": "^11.11.2", + "@resvg/resvg-wasm": "^2.6.2", "@tscircuit/alphabet": "^0.0.23", "@tscircuit/math-utils": "^0.0.29", + "@types/three": "^0.184.1", "@vitejs/plugin-react": "^5.0.2", "circuit-json": "^0.0.421", "circuit-to-canvas": "^0.0.98", @@ -59,6 +61,7 @@ "color": "^4.2.3", "react-supergrid": "^1.0.10", "react-toastify": "^10.0.5", + "three": "^0.184.0", "transformation-matrix": "^2.13.0", "zustand": "^4.5.2" } diff --git a/src/components/PcbBoxViewer.tsx b/src/components/PcbBoxViewer.tsx new file mode 100644 index 00000000..28444f1e --- /dev/null +++ b/src/components/PcbBoxViewer.tsx @@ -0,0 +1,211 @@ +import type { AnyCircuitElement } from "circuit-json" +import { convertCircuitJsonToPcbSvg } from "circuit-to-svg" +import React, { useEffect, useRef, useState } from "react" +import * as THREE from "three" + +interface Props { + circuitJson: AnyCircuitElement[] + width?: number + height?: number + /** PCB thickness in mm, default 1.6 */ + boardThickness?: number +} + +export const PcbBoxViewer = ({ + circuitJson, + width = 600, + height = 400, + boardThickness = 1.6, +}: Props) => { + const mountRef = useRef(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (!mountRef.current) return + + let animFrameId: number + let renderer: THREE.WebGLRenderer | null = null + + const init = async () => { + try { + setLoading(true) + + const svgString = convertCircuitJsonToPcbSvg(circuitJson, { + width: 1024, + height: 1024, + }) + + let textureUrl: string + try { + const { Resvg, initWasm } = await import("@resvg/resvg-wasm") + try { + await initWasm( + fetch( + new URL( + "@resvg/resvg-wasm/index_bg.wasm", + import.meta.url, + ).toString(), + ), + ) + } catch { + // already initialized + } + const resvg = new Resvg(svgString, { + fitTo: { mode: "width", value: 1024 }, + }) + const pngData = resvg.render() + const pngBuffer = pngData.asPng() + const blob = new Blob([pngBuffer as unknown as ArrayBuffer], { + type: "image/png", + }) + textureUrl = URL.createObjectURL(blob) + } catch { + const encoded = encodeURIComponent(svgString) + textureUrl = `data:image/svg+xml,${encoded}` + } + + const textureLoader = new THREE.TextureLoader() + const pcbTexture = await new Promise( + (resolve, reject) => { + textureLoader.load(textureUrl, resolve, undefined, reject) + }, + ) + pcbTexture.colorSpace = THREE.SRGBColorSpace + + const boardEl = circuitJson.find((e) => e.type === "pcb_board") as + | { width?: number; height?: number; thickness?: number } + | undefined + + const boardW = boardEl?.width ?? 30 + const boardH = boardEl?.height ?? 20 + const boardD = boardEl?.thickness ?? boardThickness + const scale = 4 / Math.max(boardW, boardH) + const w = boardW * scale + const h = boardD * scale * 3 + const d = boardH * scale + + const scene = new THREE.Scene() + scene.background = new THREE.Color(0x1a1a2e) + + const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100) + camera.position.set(3, 2.5, 3) + camera.lookAt(0, 0, 0) + + renderer = new THREE.WebGLRenderer({ antialias: true }) + renderer.setSize(width, height) + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) + mountRef.current!.appendChild(renderer.domElement) + + const bodyMat = new THREE.MeshStandardMaterial({ + color: 0x1a472a, + roughness: 0.6, + metalness: 0.1, + }) + + const topMat = new THREE.MeshStandardMaterial({ + map: pcbTexture, + roughness: 0.4, + metalness: 0.15, + }) + + const materials = [ + bodyMat, + bodyMat, + topMat, + bodyMat, + bodyMat, + bodyMat, + ] + + const geometry = new THREE.BoxGeometry(w, h, d) + const mesh = new THREE.Mesh(geometry, materials) + scene.add(mesh) + + scene.add(new THREE.AmbientLight(0xffffff, 0.6)) + const dirLight = new THREE.DirectionalLight(0xffffff, 1.2) + dirLight.position.set(5, 8, 5) + scene.add(dirLight) + + const fillLight = new THREE.DirectionalLight(0x4466ff, 0.3) + fillLight.position.set(-3, -4, -3) + scene.add(fillLight) + + setLoading(false) + + const animate = () => { + animFrameId = requestAnimationFrame(animate) + mesh.rotation.y += 0.005 + mesh.rotation.x = 0.25 + renderer!.render(scene, camera) + } + animate() + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + setLoading(false) + } + } + + init() + + return () => { + cancelAnimationFrame(animFrameId) + renderer?.dispose() + if (mountRef.current) { + const canvas = mountRef.current.querySelector("canvas") + canvas?.remove() + } + } + }, [circuitJson, width, height, boardThickness]) + + return ( +
+
+ {loading && ( +
+ Generating PCB texture… +
+ )} + {error && ( +
+ {error} +
+ )} +
+ ) +} + +export default PcbBoxViewer diff --git a/src/examples/2026/pcb-box-viewer.fixture.tsx b/src/examples/2026/pcb-box-viewer.fixture.tsx new file mode 100644 index 00000000..2e056bdc --- /dev/null +++ b/src/examples/2026/pcb-box-viewer.fixture.tsx @@ -0,0 +1,67 @@ +import type React from "react" +import { PcbBoxViewer } from "../../components/PcbBoxViewer" + +const sampleCircuit = [ + { + type: "pcb_board", + pcb_board_id: "board_0", + center: { x: 0, y: 0 }, + width: 30, + height: 20, + thickness: 1.6, + num_layers: 2, + }, + { + type: "pcb_smtpad", + pcb_smtpad_id: "pad_0", + pcb_component_id: "comp_0", + pcb_port_id: "port_0", + shape: "rect", + x: -5, + y: 2, + width: 1.8, + height: 1.2, + layer: "top", + port_hints: ["1"], + }, + { + type: "pcb_smtpad", + pcb_smtpad_id: "pad_1", + pcb_component_id: "comp_0", + pcb_port_id: "port_1", + shape: "rect", + x: -5, + y: -2, + width: 1.8, + height: 1.2, + layer: "top", + port_hints: ["2"], + }, + { + type: "pcb_trace", + pcb_trace_id: "trace_0", + route: [ + { route_type: "wire", x: -5, y: 2, width: 0.25, layer: "top" }, + { route_type: "wire", x: 5, y: 2, width: 0.25, layer: "top" }, + ], + }, + { + type: "pcb_trace", + pcb_trace_id: "trace_1", + route: [ + { route_type: "wire", x: -5, y: -2, width: 0.25, layer: "top" }, + { route_type: "wire", x: 5, y: -2, width: 0.25, layer: "top" }, + ], + }, +] as any + +export const PcbBoxViewerDemo: React.FC = () => ( +
+

+ PCB Box Viewer — texture from circuit-json +

+ +
+) + +export default PcbBoxViewerDemo diff --git a/src/index.tsx b/src/index.tsx index 112649b2..233c3255 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,2 +1,3 @@ export * from "./PCBViewer" export { CanvasElementsRenderer } from "./components/CanvasElementsRenderer" +export { PcbBoxViewer } from "./components/PcbBoxViewer"