Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
"dependencies": {
"@jscad/regl-renderer": "^2.6.12",
"@jscad/stl-serializer": "^2.1.20",
"@resvg/resvg-wasm": "^2.6.2",
"circuit-to-canvas": "^0.0.26",
"circuit-to-svg": "^0.0.108",
"react-hot-toast": "^2.6.0",
"three": "^0.165.0",
"three-stdlib": "^2.36.0",
Expand Down Expand Up @@ -72,4 +74,4 @@
"vite": "^7.1.5",
"vite-tsconfig-paths": "^4.3.2"
}
}
}
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./exporter/gltf.ts"
export * from "./useManifoldBoardBuilder.ts"
export * from "./usePcbSvgTexture"
74 changes: 74 additions & 0 deletions src/hooks/usePcbSvgTexture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { useState, useEffect } from "react"
import type { AnyCircuitElement } from "circuit-json"
import * as THREE from "three"
import {
createPcbTextureFromCircuitJson,
initResvgWasm,
} from "../utils/svg-texture-utils"

interface UsePcbSvgTextureOptions {
circuitJson: AnyCircuitElement[] | null
enabled?: boolean
}

interface UsePcbSvgTextureResult {
topTexture: THREE.CanvasTexture | null
bottomTexture: THREE.CanvasTexture | null
isLoading: boolean
error: Error | null
}

export function usePcbSvgTexture(
options: UsePcbSvgTextureOptions,
): UsePcbSvgTextureResult {
const { circuitJson, enabled = true } = options
const [topTexture, setTopTexture] = useState<THREE.CanvasTexture | null>(null)
const [bottomTexture, setBottomTexture] =
useState<THREE.CanvasTexture | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)

useEffect(() => {
if (!circuitJson || !enabled) return

let cancelled = false

async function generate() {
setIsLoading(true)
try {
await initResvgWasm()
if (cancelled) return

const top = await createPcbTextureFromCircuitJson(circuitJson!, {
layer: "top",
})
const bottom = await createPcbTextureFromCircuitJson(circuitJson!, {
layer: "bottom",
})

if (!cancelled) {
setTopTexture(top)
setBottomTexture(bottom)
}
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e : new Error(String(e)))
} finally {
if (!cancelled) setIsLoading(false)
}
}

generate()
return () => {
cancelled = true
}
}, [circuitJson, enabled])

useEffect(() => {
return () => {
topTexture?.dispose()
bottomTexture?.dispose()
}
}, [topTexture, bottomTexture])

return { topTexture, bottomTexture, isLoading, error }
}
52 changes: 52 additions & 0 deletions src/utils/svg-texture-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { circuitJsonToPcbSvg } from "circuit-to-svg"
import { Resvg, initWasm } from "@resvg/resvg-wasm"
import type { AnyCircuitElement } from "circuit-json"
import * as THREE from "three"

let wasmInitialized = false

export async function initResvgWasm(): Promise<void> {
if (wasmInitialized) return
await initWasm(
fetch("https://unpkg.com/@resvg/resvg-wasm@2.6.2/index_bg.wasm"),
)
wasmInitialized = true
}

export async function svgToPngDataUrl(svgString: string): Promise<string> {
if (!wasmInitialized) await initResvgWasm()
const resvg = new Resvg(svgString, { font: { loadSystemFonts: false } })
const pngBuffer = resvg.render().asPng()
const base64 = btoa(
Array.from(pngBuffer)
.map((b) => String.fromCharCode(b))
.join(""),
)
return "data:image/png;base64," + base64
}

export async function createPcbTextureFromCircuitJson(
circuitJson: AnyCircuitElement[],
options: { layer?: "top" | "bottom" } = {},
): Promise<THREE.CanvasTexture | null> {
try {
const svgString = circuitJsonToPcbSvg(circuitJson, {
layer: options.layer || "top",
})
const pngDataUrl = await svgToPngDataUrl(svgString)
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => {
const canvas = document.createElement("canvas")
canvas.width = img.width
canvas.height = img.height
canvas.getContext("2d")?.drawImage(img, 0, 0)
resolve(new THREE.CanvasTexture(canvas))
}
img.onerror = reject
img.src = pngDataUrl
})
} catch (e) {
return null
}
}
212 changes: 212 additions & 0 deletions stories/PcbTexture.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import React from "react"
import type { Meta, StoryObj } from "@storybook/react"
import { CadViewer } from "../src/CadViewer"

/**
* PCB Texture Support Stories
*
* Demonstrates the new PCB texture rendering feature using circuit-to-svg and resvg-wasm.
* This feature converts circuit JSON to SVG and then renders it as PNG textures
* on the 3D PCB board.
*/
const meta: Meta<typeof CadViewer> = {
title: "Features/PCB Texture Support",
component: CadViewer,
parameters: {
layout: "fullscreen",
},
}

export default meta
type Story = StoryObj<typeof CadViewer>

// Simple circuit with a resistor and LED
const simpleCircuit = [
{
type: "pcb_board",
pcb_board_id: "board_1",
center: { x: 0, y: 0 },
width: 40,
height: 30,
thickness: 1.6,
num_layers: 2,
outline: [
{ x: -20, y: -15 },
{ x: 20, y: -15 },
{ x: 20, y: 15 },
{ x: -20, y: 15 },
],
},
{
type: "pcb_component",
pcb_component_id: "pcb_comp_1",
source_component_id: "source_comp_1",
center: { x: -8, y: 0 },
layer: "top",
rotation: 0,
width: 3.2,
height: 1.6,
},
{
type: "pcb_smtpad",
pcb_smtpad_id: "pad_1",
pcb_component_id: "pcb_comp_1",
shape: "rect",
x: -9.1,
y: 0,
width: 1,
height: 1.2,
layer: "top",
port_hints: ["1"],
},
{
type: "pcb_smtpad",
pcb_smtpad_id: "pad_2",
pcb_component_id: "pcb_comp_1",
shape: "rect",
x: -6.9,
y: 0,
width: 1,
height: 1.2,
layer: "top",
port_hints: ["2"],
},
{
type: "pcb_trace",
pcb_trace_id: "trace_1",
route: [
{ route_type: "wire", x: -6.9, y: 0, width: 0.2, layer: "top" },
{ route_type: "wire", x: 0, y: 0, width: 0.2, layer: "top" },
{ route_type: "wire", x: 5, y: 3, width: 0.2, layer: "top" },
],
},
{
type: "pcb_silkscreen_text",
pcb_silkscreen_text_id: "silk_1",
font: "tscircuit2024",
font_size: 1.2,
pcb_component_id: "pcb_comp_1",
text: "R1",
anchor_position: { x: -8, y: 2.5 },
anchor_alignment: "center",
layer: "top",
},
]

export const BasicPcbTexture: Story = {
args: {
soup: simpleCircuit as any,
},
parameters: {
docs: {
description: {
story:
"Basic PCB with texture rendering using circuit-to-svg and resvg-wasm. Shows a simple resistor with traces and silkscreen text.",
},
},
},
}

// Complex circuit with multiple components
const complexCircuit = [
{
type: "pcb_board",
pcb_board_id: "board_2",
center: { x: 0, y: 0 },
width: 60,
height: 40,
thickness: 1.6,
num_layers: 4,
outline: [
{ x: -30, y: -20 },
{ x: 30, y: -20 },
{ x: 30, y: 20 },
{ x: -30, y: 20 },
],
},
// IC Component
{
type: "pcb_component",
pcb_component_id: "pcb_ic_1",
source_component_id: "source_ic_1",
center: { x: 0, y: 0 },
layer: "top",
rotation: 0,
width: 10,
height: 10,
},
// Multiple traces
{
type: "pcb_trace",
pcb_trace_id: "trace_ic_1",
route: [
{ route_type: "wire", x: -5, y: 2, width: 0.25, layer: "top" },
{ route_type: "wire", x: -15, y: 2, width: 0.25, layer: "top" },
{ route_type: "wire", x: -20, y: 8, width: 0.25, layer: "top" },
],
},
{
type: "pcb_trace",
pcb_trace_id: "trace_ic_2",
route: [
{ route_type: "wire", x: 5, y: -2, width: 0.25, layer: "top" },
{ route_type: "wire", x: 15, y: -2, width: 0.25, layer: "top" },
{ route_type: "wire", x: 20, y: -10, width: 0.25, layer: "top" },
],
},
// Silkscreen labels
{
type: "pcb_silkscreen_text",
pcb_silkscreen_text_id: "silk_ic",
font: "tscircuit2024",
font_size: 1.5,
pcb_component_id: "pcb_ic_1",
text: "U1",
anchor_position: { x: 0, y: 7 },
anchor_alignment: "center",
layer: "top",
},
{
type: "pcb_silkscreen_text",
pcb_silkscreen_text_id: "silk_title",
font: "tscircuit2024",
font_size: 2,
text: "PCB TEXTURE DEMO",
anchor_position: { x: 0, y: -15 },
anchor_alignment: "center",
layer: "top",
},
// Vias
{
type: "pcb_via",
pcb_via_id: "via_1",
x: -10,
y: 5,
outer_diameter: 0.8,
hole_diameter: 0.4,
layers: ["top", "bottom"],
},
{
type: "pcb_via",
pcb_via_id: "via_2",
x: 10,
y: -5,
outer_diameter: 0.8,
hole_diameter: 0.4,
layers: ["top", "bottom"],
},
]

export const ComplexPcbTexture: Story = {
args: {
soup: complexCircuit as any,
},
parameters: {
docs: {
description: {
story:
"Complex PCB with multiple components, traces, vias, and silkscreen text demonstrating the full texture rendering capabilities.",
},
},
},
}
Loading