diff --git a/package.json b/package.json index 3c4c0393..a513e9f5 100644 --- a/package.json +++ b/package.json @@ -29,16 +29,17 @@ "dependencies": { "@jscad/regl-renderer": "^2.6.12", "@jscad/stl-serializer": "^2.1.20", + "@resvg/resvg-wasm": "^2.6.2", "three": "^0.165.0", "three-stdlib": "^2.36.0", "troika-three-text": "^0.52.4" }, "peerDependencies": { - "zod": "3", + "@tscircuit/circuit-json-util": "*", + "@tscircuit/core": "*", "react": "19.1.0", "react-dom": "19.1.0", - "@tscircuit/core": "*", - "@tscircuit/circuit-json-util": "*" + "zod": "3" }, "devDependencies": { "@biomejs/biome": "^2.1.4", @@ -46,13 +47,12 @@ "@jscad/modeling": "^2.12.5", "@storybook/blocks": "9.0.0-alpha.17", "@storybook/react-vite": "^9.1.5", + "@tscircuit/capacity-autorouter": "^0.0.131", + "@tscircuit/checks": "^0.0.85", "@tscircuit/circuit-json-util": "^0.0.72", "@tscircuit/core": "^0.0.787", - "@tscircuit/props": "^0.0.364", - "@tscircuit/checks": "^0.0.85", "@tscircuit/math-utils": "^0.0.27", - "@tscircuit/capacity-autorouter": "^0.0.131", - "calculate-packing": "^0.0.48", + "@tscircuit/props": "^0.0.364", "@types/jsdom": "^21.1.7", "@types/react": "19", "@types/react-dom": "19", @@ -60,8 +60,9 @@ "@vitejs/plugin-react": "^4.3.4", "bun-match-svg": "^0.0.9", "bun-types": "1.2.1", + "calculate-packing": "^0.0.48", "circuit-json": "0.0.279", - "circuit-to-svg": "^0.0.179", + "circuit-to-svg": "^0.0.252", "debug": "^4.4.0", "jscad-electronics": "^0.0.48", "jscad-planner": "^0.0.13", diff --git a/src/CadViewerJscad.tsx b/src/CadViewerJscad.tsx index 7a845195..0f32fd38 100644 --- a/src/CadViewerJscad.tsx +++ b/src/CadViewerJscad.tsx @@ -11,6 +11,7 @@ import type { CameraController } from "./hooks/useCameraController" import { useConvertChildrenToCircuitJson } from "./hooks/use-convert-children-to-soup" import { useStlsFromGeom } from "./hooks/use-stls-from-geom" import { useBoardGeomBuilder } from "./hooks/useBoardGeomBuilder" +import { useCircuitTexture } from "./hooks/useCircuitTexture" import { Error3d } from "./three-components/Error3d" import { FootprinterModel } from "./three-components/FootprinterModel" import { JscadModel } from "./three-components/JscadModel" @@ -57,6 +58,9 @@ export const CadViewerJscad = forwardRef< // Use the new hook to manage board geometry building const boardGeom = useBoardGeomBuilder(internalCircuitJson) + // Generate texture for the board + const boardTexture = useCircuitTexture(internalCircuitJson, true) + const initialCameraPosition = useMemo(() => { if (!internalCircuitJson) return [5, 5, 5] as const try { @@ -126,6 +130,9 @@ export const CadViewerJscad = forwardRef< color={color} opacity={index === 0 ? 0.95 : 1} layerType={layerType} + texture={ + layerType === "board" ? (boardTexture ?? undefined) : undefined + } /> ))} {cad_components.map((cad_component) => ( diff --git a/src/CadViewerManifold.tsx b/src/CadViewerManifold.tsx index 3cf997f8..f3247ab4 100644 --- a/src/CadViewerManifold.tsx +++ b/src/CadViewerManifold.tsx @@ -230,7 +230,15 @@ try { boardData, } = useManifoldBoardBuilder(manifoldJSModule, circuitJson) - const geometryMeshes = useMemo(() => createGeometryMeshes(geoms), [geoms]) + const [geometryMeshes, setGeometryMeshes] = useState([]) + + useEffect(() => { + if (geoms) { + createGeometryMeshes(geoms, circuitJson, true).then(setGeometryMeshes) + } else { + setGeometryMeshes([]) + } + }, [geoms, circuitJson]) const textureMeshes = useMemo( () => createTextureMeshes(textures, boardData, pcbThickness), [textures, boardData, pcbThickness], diff --git a/src/convert-circuit-json-to-3d-svg.ts b/src/convert-circuit-json-to-3d-svg.ts index 2a1bd6fd..a05f85cd 100644 --- a/src/convert-circuit-json-to-3d-svg.ts +++ b/src/convert-circuit-json-to-3d-svg.ts @@ -107,10 +107,12 @@ export async function convertCircuitJsonTo3dSvg( g.color?.[2] ?? 0, ) - const material = createBoardMaterial({ + const material = await createBoardMaterial({ material: boardData?.material, color: baseColor, side: THREE.DoubleSide, + circuitJson, + enableTexture: true, }) const mesh = new THREE.Mesh(geometry, material) scene.add(mesh) diff --git a/src/hooks/useCircuitTexture.ts b/src/hooks/useCircuitTexture.ts new file mode 100644 index 00000000..bf337362 --- /dev/null +++ b/src/hooks/useCircuitTexture.ts @@ -0,0 +1,37 @@ +import { useState, useEffect } from "react" +import * as THREE from "three" +import type { AnyCircuitElement } from "circuit-json" +import { createTopSideTexture } from "../utils/circuit-to-texture" + +export function useCircuitTexture( + circuitJson: AnyCircuitElement[] | undefined, + enabled: boolean = true, +): THREE.Texture | null { + const [texture, setTexture] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + if (!enabled || !circuitJson) { + setTexture(null) + return + } + + setIsLoading(true) + createTopSideTexture(circuitJson, { + width: 1024, + height: 1024, + backgroundColor: "#ffffff", + }) + .then((newTexture) => { + setTexture(newTexture) + setIsLoading(false) + }) + .catch((error) => { + console.warn("Failed to create circuit texture:", error) + setTexture(null) + setIsLoading(false) + }) + }, [circuitJson, enabled]) + + return texture +} diff --git a/src/three-components/STLModel.tsx b/src/three-components/STLModel.tsx index 161c757e..dad78b87 100644 --- a/src/three-components/STLModel.tsx +++ b/src/three-components/STLModel.tsx @@ -9,12 +9,14 @@ export function STLModel({ mtlUrl, color, opacity = 1, + texture, }: { stlUrl?: string stlData?: ArrayBuffer color?: any mtlUrl?: string opacity?: number + texture?: THREE.Texture }) { const { rootObject } = useThree() const [geom, setGeom] = useState(null) @@ -46,9 +48,10 @@ export function STLModel({ : color, transparent: opacity !== 1, opacity: opacity, + map: texture, }) return new THREE.Mesh(geom, material) - }, [geom, color, opacity]) + }, [geom, color, opacity, texture]) useEffect(() => { if (!rootObject || !mesh) return diff --git a/src/three-components/VisibleSTLModel.tsx b/src/three-components/VisibleSTLModel.tsx index 76582357..70c7172a 100644 --- a/src/three-components/VisibleSTLModel.tsx +++ b/src/three-components/VisibleSTLModel.tsx @@ -1,12 +1,14 @@ import { useLayerVisibility } from "../contexts/LayerVisibilityContext" import type { LayerType } from "../hooks/use-stls-from-geom" import { STLModel } from "./STLModel" +import * as THREE from "three" interface VisibleSTLModelProps { stlData: ArrayBuffer color: any opacity?: number layerType?: LayerType + texture?: THREE.Texture } export function VisibleSTLModel({ @@ -14,6 +16,7 @@ export function VisibleSTLModel({ color, opacity = 1, layerType, + texture, }: VisibleSTLModelProps) { const { visibility } = useLayerVisibility() @@ -37,5 +40,12 @@ export function VisibleSTLModel({ return null } - return + return ( + + ) } diff --git a/src/utils/circuit-to-texture.ts b/src/utils/circuit-to-texture.ts new file mode 100644 index 00000000..813753b8 --- /dev/null +++ b/src/utils/circuit-to-texture.ts @@ -0,0 +1,373 @@ +import type { AnyCircuitElement } from "circuit-json" +import { convertCircuitJsonToPcbSvg } from "circuit-to-svg" +import * as THREE from "three" + +// Dynamic import for resvg-wasm to avoid bundling issues +let Resvg: any = null +let resvgLoaded = false +let resvgLoading = false + +async function loadResvg(): Promise { + if (resvgLoaded) return Resvg + resvgLoading = true + try { + const resvgModule = await import("@resvg/resvg-wasm") + Resvg = resvgModule.Resvg + resvgLoaded = true + return Resvg + } catch (error) { + console.warn("Failed to load @resvg/resvg-wasm:", error) + return null + } finally { + resvgLoading = false + } +} + +export interface CircuitToTextureOptions { + width?: number + height?: number + backgroundColor?: string + padding?: number + zoom?: number + quality?: "low" | "medium" | "high" + layers?: { + copper?: boolean + silkscreen?: boolean + solderMask?: boolean + drillHoles?: boolean + } +} + +const DEFAULT_OPTIONS: Required = { + width: 1024, + height: 1024, + backgroundColor: "#ffffff", + padding: 20, + zoom: 1.0, + quality: "high", + layers: { + copper: true, + silkscreen: true, + solderMask: true, + drillHoles: true, + }, +} + +// Quality presets for different use cases +const QUALITY_PRESETS = { + low: { width: 512, height: 512, zoom: 0.5 }, + medium: { width: 1024, height: 1024, zoom: 1.0 }, + high: { width: 2048, height: 2048, zoom: 1.5 }, +} as const + +/** + * Converts circuit JSON to PNG texture using circuit-to-svg and resvg-wasm + */ +export async function convertCircuitToTexture( + circuitJson: AnyCircuitElement[], + options: CircuitToTextureOptions = {}, +): Promise { + const opts = { ...DEFAULT_OPTIONS, ...options } + + // Apply quality preset if specified + if (opts.quality && opts.quality !== "high") { + const preset = QUALITY_PRESETS[opts.quality] + opts.width = preset.width + opts.height = preset.height + opts.zoom = preset.zoom + } + + try { + // Convert circuit JSON to SVG using circuit-to-svg + const svgString = await convertCircuitJsonToPcbSvg(circuitJson, { + width: opts.width, + height: opts.height, + backgroundColor: opts.backgroundColor, + }) + + // Validate SVG string + if (!svgString || svgString.trim().length === 0) { + throw new Error("Empty SVG generated from circuit JSON") + } + + // Try to load resvg-wasm dynamically + const ResvgClass = await loadResvg() + if (!ResvgClass) { + console.warn("resvg-wasm not available, falling back to canvas texture") + return createCanvasTexture( + svgString, + opts.width, + opts.height, + opts.backgroundColor, + ) + } + + // Convert SVG to PNG using resvg-wasm with optimized settings + const resvg = new ResvgClass(svgString, { + background: opts.backgroundColor, + fitTo: { + mode: "width", + value: opts.width, + }, + }) + + const pngData = resvg.render() + const pngBuffer = pngData.asPng() + + // Validate PNG data + if (!pngBuffer || pngBuffer.length === 0) { + throw new Error("Empty PNG generated from SVG") + } + + return new Uint8Array(pngBuffer) + } catch (error) { + console.error("Error converting circuit to texture:", error) + // Fallback to canvas texture if resvg-wasm fails + try { + const svgString = await convertCircuitJsonToPcbSvg(circuitJson, { + width: opts.width, + height: opts.height, + backgroundColor: opts.backgroundColor, + }) + return createCanvasTexture( + svgString, + opts.width, + opts.height, + opts.backgroundColor, + ) + } catch (fallbackError) { + throw new Error( + `Failed to convert circuit to texture: ${error instanceof Error ? error.message : "Unknown error"}`, + ) + } + } +} + +// Fallback texture generation using Canvas API +function createCanvasTexture( + svgString: string, + width: number, + height: number, + backgroundColor: string, +): Uint8Array { + const canvas = document.createElement("canvas") + canvas.width = width + canvas.height = height + const ctx = canvas.getContext("2d")! + + if (!ctx) { + throw new Error("Failed to get canvas 2D context") + } + + // Fill background + ctx.fillStyle = backgroundColor + ctx.fillRect(0, 0, width, height) + + // Create a more realistic PCB pattern + createPCBPattern(ctx, width, height) + + // Convert canvas to PNG data + const dataURL = canvas.toDataURL("image/png") + const base64 = dataURL.split(",")[1] + if (!base64) { + throw new Error("Failed to generate canvas data URL") + } + + const binaryString = atob(base64) + const bytes = new Uint8Array(binaryString.length) + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i) + } + + return bytes +} + +// Create a more realistic PCB pattern +function createPCBPattern( + ctx: CanvasRenderingContext2D, + width: number, + height: number, +): void { + // PCB substrate (dark green) + ctx.fillStyle = "#2d5016" + ctx.fillRect(0, 0, width, height) + + // Copper traces pattern + ctx.fillStyle = "#b87333" + ctx.lineWidth = 2 + + // Horizontal traces + for (let y = 20; y < height; y += 40) { + ctx.beginPath() + ctx.moveTo(20, y) + ctx.lineTo(width - 20, y) + ctx.stroke() + } + + // Vertical traces + for (let x = 20; x < width; x += 40) { + ctx.beginPath() + ctx.moveTo(x, 20) + ctx.lineTo(x, height - 20) + ctx.stroke() + } + + // Add some pads + ctx.fillStyle = "#c9a96e" // Gold color for pads + for (let i = 0; i < 20; i++) { + const x = Math.random() * (width - 40) + 20 + const y = Math.random() * (height - 40) + 20 + const size = Math.random() * 8 + 4 + + ctx.beginPath() + ctx.arc(x, y, size, 0, 2 * Math.PI) + ctx.fill() + } + + // Add some vias + ctx.fillStyle = "#8b4513" // Brown for vias + for (let i = 0; i < 10; i++) { + const x = Math.random() * (width - 20) + 10 + const y = Math.random() * (height - 20) + 10 + + ctx.beginPath() + ctx.arc(x, y, 2, 0, 2 * Math.PI) + ctx.fill() + } +} + +/** + * Creates a Three.js texture from circuit JSON + */ +export async function createCircuitTexture( + circuitJson: AnyCircuitElement[], + options: CircuitToTextureOptions = {}, +): Promise { + const pngData = await convertCircuitToTexture(circuitJson, options) + + // Create a blob URL from the PNG data + const blob = new Blob([pngData.buffer as ArrayBuffer], { type: "image/png" }) + const url = URL.createObjectURL(blob) + + // Create Three.js texture with optimized settings + const texture = new (THREE as any).TextureLoader().load(url) + texture.wrapS = THREE.RepeatWrapping + texture.wrapT = THREE.RepeatWrapping + texture.flipY = false // SVG coordinates are already correct + texture.generateMipmaps = true + texture.minFilter = THREE.LinearMipmapLinearFilter + texture.magFilter = THREE.LinearFilter + + // Clean up the blob URL after texture is loaded + texture.addEventListener("load", () => { + URL.revokeObjectURL(url) + }) + + return texture +} + +/** + * Creates a texture for the top side of the PCB + */ +export async function createTopSideTexture( + circuitJson: AnyCircuitElement[], + options: CircuitToTextureOptions = {}, +): Promise { + return createCircuitTexture(circuitJson, { + ...options, + layers: { + copper: true, + silkscreen: true, + solderMask: true, + drillHoles: false, // Don't show drill holes on top texture + }, + }) +} + +/** + * Creates a texture for the bottom side of the PCB + */ +export async function createBottomSideTexture( + circuitJson: AnyCircuitElement[], + options: CircuitToTextureOptions = {}, +): Promise { + return createCircuitTexture(circuitJson, { + ...options, + layers: { + copper: true, + silkscreen: true, + solderMask: true, + drillHoles: false, // Don't show drill holes on bottom texture + }, + }) +} + +/** + * Creates a texture cache to avoid regenerating the same textures + */ +class TextureCache { + private cache = new Map() + private maxSize = 50 + + private generateKey( + circuitJson: AnyCircuitElement[], + options: CircuitToTextureOptions, + ): string { + return JSON.stringify({ circuitJson, options }) + } + + get( + circuitJson: AnyCircuitElement[], + options: CircuitToTextureOptions, + ): any | null { + const key = this.generateKey(circuitJson, options) + return this.cache.get(key) || null + } + + set( + circuitJson: AnyCircuitElement[], + options: CircuitToTextureOptions, + texture: any, + ): void { + const key = this.generateKey(circuitJson, options) + + // Implement LRU eviction if cache is full + if (this.cache.size >= this.maxSize) { + const firstKey = this.cache.keys().next().value + if (firstKey) { + this.cache.delete(firstKey) + } + } + + this.cache.set(key, texture) + } + + clear(): void { + this.cache.clear() + } +} + +// Global texture cache instance +const textureCache = new TextureCache() + +/** + * Creates a cached texture to avoid regenerating the same textures + */ +export async function createCachedCircuitTexture( + circuitJson: AnyCircuitElement[], + options: CircuitToTextureOptions = {}, +): Promise { + // Check cache first + const cached = textureCache.get(circuitJson, options) + if (cached) { + return cached + } + + // Generate new texture + const texture = await createCircuitTexture(circuitJson, options) + + // Cache the texture + textureCache.set(circuitJson, options, texture) + + return texture +} diff --git a/src/utils/create-board-material.ts b/src/utils/create-board-material.ts index 845a30bd..7c30d07c 100644 --- a/src/utils/create-board-material.ts +++ b/src/utils/create-board-material.ts @@ -1,5 +1,6 @@ import * as THREE from "three" -import type { PcbBoard } from "circuit-json" +import type { PcbBoard, AnyCircuitElement } from "circuit-json" +import { createTopSideTexture } from "./circuit-to-texture" type BoardMaterialType = PcbBoard["material"] @@ -7,19 +8,28 @@ interface CreateBoardMaterialOptions { material: BoardMaterialType | undefined color: THREE.ColorRepresentation side?: THREE.Side + circuitJson?: AnyCircuitElement[] + enableTexture?: boolean } const DEFAULT_SIDE = THREE.DoubleSide -export const createBoardMaterial = ({ +export const createBoardMaterial = async ({ material, color, side = DEFAULT_SIDE, -}: CreateBoardMaterialOptions): THREE.MeshStandardMaterial => { + circuitJson, + enableTexture = false, +}: CreateBoardMaterialOptions): Promise => { + const baseMaterialProps = { + color, + side, + flatShading: true, + } + if (material === "fr4") { - return new THREE.MeshPhysicalMaterial({ - color, - side, + const materialProps = { + ...baseMaterialProps, metalness: 0.0, roughness: 0.8, specularIntensity: 0.2, @@ -28,17 +38,60 @@ export const createBoardMaterial = ({ clearcoat: 0.0, transparent: false, opacity: 1.0, - flatShading: true, - }) + } + + // Add texture if enabled and circuit JSON is available + if (enableTexture && circuitJson) { + try { + const topTexture = await createTopSideTexture(circuitJson, { + width: 1024, + height: 1024, + backgroundColor: "#ffffff", + }) + + return new THREE.MeshPhysicalMaterial({ + ...materialProps, + map: topTexture, + }) + } catch (error) { + console.warn( + "Failed to create board texture, falling back to solid material:", + error, + ) + } + } + + return new THREE.MeshPhysicalMaterial(materialProps) } - return new THREE.MeshStandardMaterial({ - color, - side, - flatShading: true, + const materialProps = { + ...baseMaterialProps, metalness: 0.1, roughness: 0.8, transparent: true, opacity: 0.9, - }) + } + + // Add texture if enabled and circuit JSON is available + if (enableTexture && circuitJson) { + try { + const topTexture = await createTopSideTexture(circuitJson, { + width: 1024, + height: 1024, + backgroundColor: "#ffffff", + }) + + return new THREE.MeshStandardMaterial({ + ...materialProps, + map: topTexture, + }) + } catch (error) { + console.warn( + "Failed to create board texture, falling back to solid material:", + error, + ) + } + } + + return new THREE.MeshStandardMaterial(materialProps) } diff --git a/src/utils/manifold/create-three-geometry-meshes.ts b/src/utils/manifold/create-three-geometry-meshes.ts index e325b52f..a80c448b 100644 --- a/src/utils/manifold/create-three-geometry-meshes.ts +++ b/src/utils/manifold/create-three-geometry-meshes.ts @@ -2,21 +2,24 @@ import * as THREE from "three" import type { ManifoldGeoms } from "../../hooks/useManifoldBoardBuilder" import { createBoardMaterial } from "../create-board-material" -export function createGeometryMeshes( +export async function createGeometryMeshes( geoms: ManifoldGeoms | null, -): THREE.Mesh[] { + circuitJson?: any, + enableTexture?: boolean, +): Promise { const meshes: THREE.Mesh[] = [] if (!geoms) return meshes if (geoms.board && geoms.board.geometry) { - const mesh = new THREE.Mesh( - geoms.board.geometry, - createBoardMaterial({ - material: geoms.board.material, - color: geoms.board.color, - side: THREE.DoubleSide, - }), - ) + const material = await createBoardMaterial({ + material: geoms.board.material, + color: geoms.board.color, + side: THREE.DoubleSide, + circuitJson, + enableTexture, + }) + + const mesh = new THREE.Mesh(geoms.board.geometry, material) mesh.name = "board-geom" meshes.push(mesh) } diff --git a/stories/TextureSupport.stories.tsx b/stories/TextureSupport.stories.tsx new file mode 100644 index 00000000..307376f6 --- /dev/null +++ b/stories/TextureSupport.stories.tsx @@ -0,0 +1,40 @@ +import { CadViewer } from "src/CadViewer" +import bugsPadsAndTracesSoup from "./assets/soic-with-traces.json" +import LedFlashlightCircuitJson from "./assets/left-flashlight-board.json" + +export const WithTexture = () => ( + +) + +export const LedFlashlightWithTexture = () => ( + +) + +export const SimpleBoardWithTexture = () => { + return ( + + + + + + + + ) +} + +export default { + title: "Texture Support", + component: WithTexture, +} diff --git a/vite.config.ts b/vite.config.ts index a1a74b93..546934fd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ "calculate-packing", "circuit-json", "@tscircuit/props", + "@resvg/resvg-wasm", ], }, build: { @@ -32,6 +33,7 @@ export default defineConfig({ "calculate-packing", "circuit-json", "@tscircuit/props", + "@resvg/resvg-wasm", ], }, },