diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index 7b924b6b..26e5152d 100644
--- a/.storybook/preview.tsx
+++ b/.storybook/preview.tsx
@@ -1,6 +1,4 @@
-import React from "react"
-import type { Preview } from "@storybook/react"
-import type { StoryFn } from "@storybook/react"
+import type { Preview, StoryFn } from "@storybook/react"
const withContainer = (Story: StoryFn) => (
@@ -10,6 +8,7 @@ const withContainer = (Story: StoryFn) => (
const preview: Preview = {
parameters: {
+ layout: "fullscreen",
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
diff --git a/src/CadViewer.tsx b/src/CadViewer.tsx
index 61317d6b..3497f163 100644
--- a/src/CadViewer.tsx
+++ b/src/CadViewer.tsx
@@ -1,5 +1,4 @@
-import type React from "react"
-import { useState, useCallback, useRef, useEffect } from "react"
+import { useCallback, useEffect, useRef, useState } from "react"
import * as THREE from "three"
// Constants for camera initialization - defined once, reused across renders
@@ -13,28 +12,29 @@ const readStoredCameraType = (): "perspective" | "orthographic" | undefined => {
? stored
: undefined
}
+
import { CadViewerJscad } from "./CadViewerJscad"
import CadViewerManifold from "./CadViewerManifold"
-import { useContextMenu } from "./hooks/useContextMenu"
-import { useCameraPreset } from "./hooks/useCameraPreset"
-import { useGlobalDownloadGltf } from "./hooks/useGlobalDownloadGltf"
+import { ContextMenu } from "./components/ContextMenu"
+import { KeyboardShortcutsDialog } from "./components/KeyboardShortcutsDialog"
+import { AppearanceProvider } from "./contexts/appearance-context"
import {
- useRegisteredHotkey,
- registerHotkeyViewer,
-} from "./hooks/useRegisteredHotkey"
+ CameraControllerProvider,
+ useCameraController,
+} from "./contexts/CameraControllerContext"
import {
LayerVisibilityProvider,
useLayerVisibility,
} from "./contexts/LayerVisibilityContext"
-import { RenderingModeProvider } from "./contexts/RenderingModeContext"
-import {
- CameraControllerProvider,
- useCameraController,
-} from "./contexts/CameraControllerContext"
import { ToastProvider, useToast } from "./contexts/ToastContext"
-import { ContextMenu } from "./components/ContextMenu"
-import { KeyboardShortcutsDialog } from "./components/KeyboardShortcutsDialog"
import type { CameraController, CameraPreset } from "./hooks/cameraAnimation"
+import { useCameraPreset } from "./hooks/useCameraPreset"
+import { useContextMenu } from "./hooks/useContextMenu"
+import { useGlobalDownloadGltf } from "./hooks/useGlobalDownloadGltf"
+import {
+ registerHotkeyViewer,
+ useRegisteredHotkey,
+} from "./hooks/useRegisteredHotkey"
const CadViewerInner = (props: any) => {
const [engine, setEngine] = useState<"jscad" | "manifold">(() => {
@@ -46,14 +46,14 @@ const CadViewerInner = (props: any) => {
useState(false)
const [autoRotate, setAutoRotate] = useState(() => {
const stored = window.localStorage.getItem("cadViewerAutoRotate")
- return stored === "false" ? false : true
+ return stored !== "false"
})
const [autoRotateUserToggled, setAutoRotateUserToggled] = useState(() => {
const stored = window.localStorage.getItem("cadViewerAutoRotateUserToggled")
return stored === "true"
})
const [cameraPreset, setCameraPreset] = useState
("Custom")
- const { cameraType, setCameraType } = useCameraController()
+ const { cameraType } = useCameraController()
const { visibility, setLayerVisibility } = useLayerVisibility()
const { showToast } = useToast()
@@ -313,11 +313,11 @@ export const CadViewer = (props: any) => {
initialCameraType={readStoredCameraType()}
>
-
+
-
+
)
diff --git a/src/CadViewerContainer.tsx b/src/CadViewerContainer.tsx
index 91e627e1..85340bce 100644
--- a/src/CadViewerContainer.tsx
+++ b/src/CadViewerContainer.tsx
@@ -1,19 +1,19 @@
import type * as React from "react"
import { forwardRef, useEffect, useMemo, useState } from "react"
import * as THREE from "three"
+import { zIndexMap } from "../lib/utils/z-index-map"
import packageJson from "../package.json"
+import { useAppearance } from "./contexts/appearance-context"
+import { useCameraController } from "./contexts/CameraControllerContext"
+import type { CameraController } from "./hooks/cameraAnimation"
+import { CameraAnimatorWithContext } from "./hooks/cameraAnimation"
+import { useCameraSession } from "./hooks/useCameraSession"
import { Canvas } from "./react-three/Canvas"
+import { Lights } from "./react-three/Lights"
import { OrbitControls } from "./react-three/OrbitControls"
-import { Grid } from "./react-three/Grid"
import { useFrame, useThree } from "./react-three/ThreeContext"
-import { Lights } from "./react-three/Lights"
-import { CameraAnimatorWithContext } from "./hooks/cameraAnimation"
-import { useCameraController } from "./contexts/CameraControllerContext"
-import { useRenderingMode } from "./contexts/RenderingModeContext"
-import { useCameraSession } from "./hooks/useCameraSession"
-import type { CameraController } from "./hooks/cameraAnimation"
import { OrientationCubeCanvas } from "./three-components/OrientationCubeCanvas"
-import { zIndexMap } from "../lib/utils/z-index-map"
+
export type {
CameraController,
CameraPreset,
@@ -65,7 +65,7 @@ export const CadViewerContainer = forwardRef<
const { mainCameraRef, handleControlsChange, controller } =
useCameraController()
- const { shadowsEnabled } = useRenderingMode()
+ const { darkBackgroundEnabled, lightingEnabled } = useAppearance()
const {
handleCameraCreated,
handleControlsChange: handleSessionControlsChange,
@@ -77,15 +77,6 @@ export const CadViewerContainer = forwardRef<
}
}, [controller, onCameraControllerReady])
- const gridSectionSize = useMemo(() => {
- if (!boardDimensions) return 10
- const width = boardDimensions.width ?? 0
- const height = boardDimensions.height ?? 0
- const largest = Math.max(width, height)
- const desired = largest * 1.5
- return desired > 10 ? desired : 10
- }, [boardDimensions])
-
const orbitTarget = useMemo(() => {
if (!boardCenter) return undefined
return [boardCenter.x, boardCenter.y, 0] as [number, number, number]
@@ -125,14 +116,8 @@ export const CadViewerContainer = forwardRef<
-
{children}
diff --git a/src/CadViewerManifold.tsx b/src/CadViewerManifold.tsx
index 86cbeadf..a4b8604b 100644
--- a/src/CadViewerManifold.tsx
+++ b/src/CadViewerManifold.tsx
@@ -7,7 +7,6 @@ import * as THREE from "three"
import { AnyCadComponent } from "./AnyCadComponent"
import { CadViewerContainer } from "./CadViewerContainer"
import { useLayerVisibility } from "./contexts/LayerVisibilityContext"
-import { useRenderingMode } from "./contexts/RenderingModeContext"
import type { CameraController } from "./hooks/cameraAnimation"
import { useConvertChildrenToCircuitJson } from "./hooks/use-convert-children-to-soup"
import { useManifoldBoardBuilder } from "./hooks/useManifoldBoardBuilder"
@@ -77,7 +76,7 @@ export const BoardMeshes = ({
useEffect(() => {
if (!rootObject) return
- geometryMeshes.forEach((mesh) => {
+ for (const mesh of geometryMeshes) {
let shouldShow = true
if (mesh.name === "board-geom") {
shouldShow = visibility.boardBody
@@ -91,32 +90,32 @@ export const BoardMeshes = ({
if (shouldShow) {
rootObject.add(mesh)
}
- })
+ }
return () => {
- geometryMeshes.forEach((mesh) => {
+ for (const mesh of geometryMeshes) {
if (mesh.parent === rootObject) {
rootObject.remove(mesh)
}
disposeMesh(mesh)
- })
+ }
}
}, [rootObject, geometryMeshes, visibility])
useEffect(() => {
if (!rootObject) return
- textureMeshes.forEach((mesh) => {
+ for (const mesh of textureMeshes) {
rootObject.add(mesh)
- })
+ }
return () => {
- textureMeshes.forEach((mesh) => {
+ for (const mesh of textureMeshes) {
if (mesh.parent === rootObject) {
rootObject.remove(mesh)
}
disposeMesh(mesh)
- })
+ }
}
}, [rootObject, textureMeshes])
@@ -157,7 +156,6 @@ const CadViewerManifold: React.FC = ({
string | null
>(null)
const { visibility } = useLayerVisibility()
- const { shadowsEnabled } = useRenderingMode()
useEffect(() => {
if (
@@ -248,11 +246,8 @@ try {
const geometryMeshes = useMemo(() => createGeometryMeshes(geoms), [geoms])
const textureMeshes = useMemo(
- () =>
- createTextureMeshes(textures, boardData, pcbThickness, isFauxBoard, {
- shadowsEnabled,
- }),
- [textures, boardData, pcbThickness, isFauxBoard, shadowsEnabled],
+ () => createTextureMeshes(textures, boardData, pcbThickness, isFauxBoard),
+ [textures, boardData, pcbThickness, isFauxBoard],
)
const cadComponents = useMemo(
diff --git a/src/board-surface-textures.ts b/src/board-surface-textures.ts
new file mode 100644
index 00000000..3d0bfad2
--- /dev/null
+++ b/src/board-surface-textures.ts
@@ -0,0 +1,24 @@
+/**
+ * Values tuned for a manufactured, ENIG-finished FR-4 board under soft studio
+ * lighting. The solder mask has a fine satin texture, while the finished
+ * copper stays deliberately less reflective than polished metal.
+ */
+export const REALISTIC_BOARD_SURFACE_MATERIAL = {
+ bumpScale: 0.12,
+ normalScale: 0.08,
+ // This value is carried by the roughness map directly. Keep the clear coat
+ // restrained so the mask looks satin, not mirror-polished.
+ roughness: 0.7,
+ roughnessBias: 0.015,
+ roughnessVariance: 0.025,
+ clearcoat: 0.08,
+ clearcoatRoughness: 0.55,
+ detailStrength: 0.035,
+}
+
+export const PAD_COPPER_TEXTURE_MATERIAL = {
+ roughness: 0.42,
+ metalness: 0.5,
+ roughnessVariance: 0.035,
+ detailStrength: 0.028,
+}
diff --git a/src/components/AppearanceMenu.tsx b/src/components/AppearanceMenu.tsx
index 7aa06d01..c7dc0e49 100644
--- a/src/components/AppearanceMenu.tsx
+++ b/src/components/AppearanceMenu.tsx
@@ -1,10 +1,10 @@
+import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
+import type React from "react"
import { useState } from "react"
+import { zIndexMap } from "../../lib/utils/z-index-map"
+import { useAppearance } from "../contexts/appearance-context"
import { useLayerVisibility } from "../contexts/LayerVisibilityContext"
-import { useRenderingMode } from "../contexts/RenderingModeContext"
-import type React from "react"
-import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon } from "./Icons"
-import { zIndexMap } from "../../lib/utils/z-index-map"
const itemStyles: React.CSSProperties = {
padding: "6px 8px",
@@ -62,7 +62,12 @@ const iconContainerStyles: React.CSSProperties = {
export const AppearanceMenu = () => {
const { visibility, setLayerVisibility } = useLayerVisibility()
- const { lightingEnabled, setLightingEnabled } = useRenderingMode()
+ const {
+ darkBackgroundEnabled,
+ setDarkBackgroundEnabled,
+ lightingEnabled,
+ setLightingEnabled,
+ } = useAppearance()
const [appearanceSubOpen, setAppearanceSubOpen] = useState(false)
const [hoveredItem, setHoveredItem] = useState(null)
@@ -105,6 +110,29 @@ export const AppearanceMenu = () => {
collisionPadding={10}
avoidCollisions={true}
>
+ e.preventDefault()}
+ onPointerDown={(e) => {
+ e.preventDefault()
+ setDarkBackgroundEnabled(!darkBackgroundEnabled)
+ }}
+ onMouseEnter={() => setHoveredItem("darkBackground")}
+ onMouseLeave={() => setHoveredItem(null)}
+ onTouchStart={() => setHoveredItem("darkBackground")}
+ >
+
+ {darkBackgroundEnabled && }
+
+
+ Dark Background
+
+
+
void
- lightingEnabled: boolean
- setLightingEnabled: (enabled: boolean) => void
- shadowsEnabled: boolean
-}
-
-const STORAGE_KEY = "cadViewerRenderingMode"
-const LIGHTING_STORAGE_KEY = "cadViewerLightingEnabled"
-
-const readStoredRenderingMode = (): RenderingMode => {
- if (typeof window === "undefined") return "engineering"
- const stored = window.localStorage.getItem(STORAGE_KEY)
- return stored === "realistic" || stored === "engineering"
- ? stored
- : "engineering"
-}
-
-const readStoredLightingEnabled = (): boolean => {
- if (typeof window === "undefined") return true
- return window.localStorage.getItem(LIGHTING_STORAGE_KEY) !== "false"
-}
-
-const RenderingModeContext = createContext<
- RenderingModeContextType | undefined
->(undefined)
-
-export const RenderingModeProvider: React.FC<{
- children: React.ReactNode
-}> = ({ children }) => {
- const [renderingMode, setRenderingModeState] = useState(
- readStoredRenderingMode,
- )
- const [lightingEnabled, setLightingEnabled] = useState(
- readStoredLightingEnabled,
- )
-
- const setRenderingMode = useCallback((mode: RenderingMode) => {
- setRenderingModeState(mode)
- }, [])
-
- useEffect(() => {
- window.localStorage.setItem(STORAGE_KEY, renderingMode)
- }, [renderingMode])
-
- useEffect(() => {
- window.localStorage.setItem(
- LIGHTING_STORAGE_KEY,
- lightingEnabled ? "true" : "false",
- )
- }, [lightingEnabled])
-
- const value = useMemo(
- () => ({
- renderingMode,
- setRenderingMode,
- lightingEnabled,
- setLightingEnabled,
- shadowsEnabled: lightingEnabled && renderingMode === "realistic",
- }),
- [lightingEnabled, renderingMode, setRenderingMode],
- )
-
- return (
-
- {children}
-
- )
-}
-
-export const useRenderingMode = () => {
- const context = useContext(RenderingModeContext)
- if (!context) {
- throw new Error(
- "useRenderingMode must be used within a RenderingModeProvider",
- )
- }
- return context
-}
diff --git a/src/contexts/appearance-context.tsx b/src/contexts/appearance-context.tsx
new file mode 100644
index 00000000..264cee5a
--- /dev/null
+++ b/src/contexts/appearance-context.tsx
@@ -0,0 +1,62 @@
+import type React from "react"
+import { createContext, useContext, useEffect, useMemo, useState } from "react"
+
+interface AppearanceContextType {
+ darkBackgroundEnabled: boolean
+ setDarkBackgroundEnabled: (enabled: boolean) => void
+ lightingEnabled: boolean
+ setLightingEnabled: (enabled: boolean) => void
+}
+
+const LIGHTING_STORAGE_KEY = "cadViewerLightingEnabled"
+
+const readStoredLightingEnabled = (): boolean => {
+ if (typeof window === "undefined") return true
+ return window.localStorage.getItem(LIGHTING_STORAGE_KEY) !== "false"
+}
+
+const AppearanceContext = createContext(
+ undefined,
+)
+
+export const AppearanceProvider: React.FC<{ children: React.ReactNode }> = ({
+ children,
+}) => {
+ // Transparent is the default. The dark studio backdrop is an opt-in review
+ // aid and intentionally does not persist between new viewer sessions.
+ const [darkBackgroundEnabled, setDarkBackgroundEnabled] = useState(false)
+ const [lightingEnabled, setLightingEnabled] = useState(
+ readStoredLightingEnabled,
+ )
+
+ useEffect(() => {
+ window.localStorage.setItem(
+ LIGHTING_STORAGE_KEY,
+ lightingEnabled ? "true" : "false",
+ )
+ }, [lightingEnabled])
+
+ const value = useMemo(
+ () => ({
+ darkBackgroundEnabled,
+ setDarkBackgroundEnabled,
+ lightingEnabled,
+ setLightingEnabled,
+ }),
+ [darkBackgroundEnabled, lightingEnabled],
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+export const useAppearance = () => {
+ const context = useContext(AppearanceContext)
+ if (!context) {
+ throw new Error("useAppearance must be used within an AppearanceProvider")
+ }
+ return context
+}
diff --git a/src/geoms/constants.ts b/src/geoms/constants.ts
index 7ce3abd9..cdc47fc0 100644
--- a/src/geoms/constants.ts
+++ b/src/geoms/constants.ts
@@ -4,10 +4,12 @@ import type { PcbBoard } from "circuit-json"
export const M = 0.01
export const colors = {
- copper: [0.9, 0.6, 0.2],
+ // Warm ENIG gold, rather than saturated orange, matches manufactured pads.
+ copper: [0.78, 0.63, 0.39],
fr4Tan: [0.6, 0.43, 0.28],
- fr4SolderMaskGreen: [0.015, 0.059, 0.027],
- fr4TracesWithMaskGreen: [0.063, 0.141, 0.023],
+ // Deep, slightly blue-green solder mask keeps detail visible on dark UI.
+ fr4SolderMaskGreen: [0.06, 0.31, 0.19],
+ fr4TracesWithMaskGreen: [0.09, 0.38, 0.23],
fr4TracesWithoutMaskTan: [0.6, 0.43, 0.28],
fr1Tan: [0.8, 0.4, 0.2],
fr1TracesWithMaskCopper: [0.9, 0.6, 0.2],
diff --git a/src/react-three/Lights.tsx b/src/react-three/Lights.tsx
index fd68110d..24001728 100644
--- a/src/react-three/Lights.tsx
+++ b/src/react-three/Lights.tsx
@@ -1,16 +1,19 @@
-import React, { useEffect, useMemo } from "react"
+import type React from "react"
+import { useEffect, useMemo } from "react"
import * as THREE from "three"
import { useThree } from "./ThreeContext"
type LightsProps = {
boardDimensions?: { width?: number; height?: number }
boardCenter?: { x: number; y: number }
+ darkBackgroundEnabled?: boolean
shadowsEnabled?: boolean
}
export const Lights: React.FC = ({
boardDimensions,
boardCenter,
+ darkBackgroundEnabled = false,
shadowsEnabled = false,
}) => {
const { scene } = useThree()
@@ -28,13 +31,13 @@ export const Lights: React.FC = ({
)
const shadowHalfSize = largestBoardDimension * 0.8
const lightDistance = largestBoardDimension
- const keyLightDistance = largestBoardDimension * 8.3
+ const keyLightDistance = largestBoardDimension * 1.7
- const ambientLight = new THREE.AmbientLight(0xffffff, 0.18)
+ const ambientLight = new THREE.AmbientLight(0xf6fbf8, 0.22)
ambientLight.name = "cad-viewer-soft-ambient"
rig.add(ambientLight)
- const hemisphereLight = new THREE.HemisphereLight(0xdde8ff, 0x1f211d, 0.45)
+ const hemisphereLight = new THREE.HemisphereLight(0xe4f1ed, 0x18221d, 0.2)
hemisphereLight.name = "cad-viewer-hemisphere"
rig.add(hemisphereLight)
@@ -80,29 +83,44 @@ export const Lights: React.FC = ({
addDirectionalLight(
"cad-viewer-key-light",
- 0xffffff,
- 2.4,
+ 0xfff8ee,
+ 1.35,
[
- keyLightDistance * 0.22,
- -keyLightDistance * 0.28,
- keyLightDistance * 1.15,
+ keyLightDistance * 0.68,
+ -keyLightDistance * 0.8,
+ keyLightDistance * 1.08,
],
shadowsEnabled,
)
- addDirectionalLight("cad-viewer-fill-light", 0xdde8ff, 0.7, [
- -lightDistance * 0.65,
- lightDistance * 0.45,
- lightDistance * 0.35,
- ])
- addDirectionalLight("cad-viewer-rim-light", 0xffffff, 1.1, [
- -lightDistance * 0.25,
+ addDirectionalLight("cad-viewer-fill-light", 0xdce8f2, 0.25, [
+ -lightDistance * 0.85,
+ lightDistance * 0.55,
lightDistance * 0.75,
- lightDistance * 0.6,
+ ])
+ addDirectionalLight("cad-viewer-rim-light", 0xb9ead3, 0.5, [
+ -lightDistance * 0.35,
+ lightDistance * 0.85,
+ lightDistance * 0.95,
])
return rig
}, [boardCenter, boardDimensions, shadowsEnabled])
+ useEffect(() => {
+ const previousBackground = scene.background
+ const previousEnvironment = scene.environment
+ scene.background = darkBackgroundEnabled ? new THREE.Color(0x101310) : null
+ // The generic RoomEnvironment is very bright and makes a green solder
+ // mask look white. The controlled key/fill/rim rig provides the tighter,
+ // product-render highlights used for the board itself.
+ scene.environment = null
+
+ return () => {
+ scene.background = previousBackground
+ scene.environment = previousEnvironment
+ }
+ }, [darkBackgroundEnabled, scene])
+
useEffect(() => {
if (!scene) return
scene.add(lightRig)
diff --git a/src/textures/create-combined-board-textures.ts b/src/textures/create-combined-board-textures.ts
index 8b4bc215..812d545d 100644
--- a/src/textures/create-combined-board-textures.ts
+++ b/src/textures/create-combined-board-textures.ts
@@ -6,8 +6,8 @@ import { calculateOutlineBounds } from "../utils/outline-bounds"
import { createPadTextureForLayer } from "../utils/pad-texture"
import { createPanelOutlineTextureForLayer } from "../utils/panel-outline-texture"
import { createTraceTextureForLayer } from "../utils/trace-texture"
-import { createCopperTextTextureForLayer } from "./create-copper-text-texture-for-layer"
import { createCopperPourTextureForLayer } from "./create-copper-pour-texture-for-layer"
+import { createCopperTextTextureForLayer } from "./create-copper-text-texture-for-layer"
import { createFabricationNoteTextureForLayer } from "./create-fabrication-note-texture-for-layer"
import { createKeepoutTextureForLayer } from "./create-keepout-texture-for-layer"
import { createPcbNoteTextureForLayer } from "./create-pcb-note-texture-for-layer"
@@ -18,6 +18,9 @@ import { createThroughHoleTextureForLayer } from "./create-through-hole-texture-
export interface CombinedBoardTextures {
topBoard?: THREE.CanvasTexture | null
bottomBoard?: THREE.CanvasTexture | null
+ /** Geometry-only alpha masks used for relief, never as color maps. */
+ topMaskedCopper?: THREE.CanvasTexture | null
+ bottomMaskedCopper?: THREE.CanvasTexture | null
}
const toRgb = (colorArr: number[]) => {
@@ -120,11 +123,11 @@ const createCombinedTexture = ({
const ctx = canvas.getContext("2d")
if (!ctx) return null
- textures.forEach((texture) => {
- if (!texture?.image) return
+ for (const texture of textures) {
+ if (!texture?.image) continue
const image = texture.image as HTMLCanvasElement
ctx.drawImage(image, 0, 0, canvasWidth, canvasHeight)
- })
+ }
applySoldermaskSurfaceFilter(ctx, canvasWidth, canvasHeight, {
includeReflection: layer === "top",
@@ -140,6 +143,44 @@ const createCombinedTexture = ({
return combinedTexture
}
+const createMaskedCopperMask = ({
+ textures,
+ boardData,
+ traceTextureResolution,
+}: {
+ textures: Array
+ boardData: PcbBoard
+ traceTextureResolution: number
+}): THREE.CanvasTexture | null => {
+ if (!textures.some((texture) => texture?.image)) return null
+
+ const bounds = calculateOutlineBounds(boardData)
+ const width = Math.floor(bounds.width * traceTextureResolution)
+ const height = Math.floor(bounds.height * traceTextureResolution)
+ if (width <= 0 || height <= 0) return null
+
+ const canvas = document.createElement("canvas")
+ canvas.width = width
+ // Match the board color texture dimensions; this avoids UV resampling.
+ canvas.height = height + 1
+ const ctx = canvas.getContext("2d")
+ if (!ctx) return null
+
+ for (const texture of textures) {
+ if (!texture?.image) continue
+ ctx.drawImage(texture.image as HTMLCanvasElement, 0, 0, width, height)
+ }
+
+ const maskTexture = new THREE.CanvasTexture(canvas)
+ maskTexture.colorSpace = THREE.NoColorSpace
+ maskTexture.generateMipmaps = false
+ maskTexture.minFilter = THREE.LinearFilter
+ maskTexture.magFilter = THREE.LinearFilter
+ maskTexture.premultiplyAlpha = false
+ maskTexture.needsUpdate = true
+ return maskTexture
+}
+
export function createCombinedBoardTextures({
circuitJson,
boardData,
@@ -198,6 +239,18 @@ export function createCombinedBoardTextures({
})
: null
+ const maskedCopperPourTexture =
+ showMask && showCopper
+ ? createCopperPourTextureForLayer({
+ layer,
+ circuitJson,
+ boardData,
+ traceTextureResolution,
+ copperColor,
+ onlyCoveredBySoldermask: true,
+ })
+ : null
+
const copperTextTexture = showCopper
? createCopperTextTextureForLayer({
layer,
@@ -274,7 +327,7 @@ export function createCombinedBoardTextures({
})
: null
- return createCombinedTexture({
+ const boardTexture = createCombinedTexture({
textures: [
copperPourTexture,
traceTexture,
@@ -292,12 +345,27 @@ export function createCombinedBoardTextures({
traceTextureResolution,
layer,
})
+ const maskedCopperTexture =
+ showMask && showCopper
+ ? createMaskedCopperMask({
+ textures: [traceTexture, maskedCopperPourTexture],
+ boardData,
+ traceTextureResolution,
+ })
+ : null
+
+ return { boardTexture, maskedCopperTexture }
}
const numLayers = boardData.num_layers ?? 2
+ const top = buildForLayer("top")
+ const bottom = numLayers < 2 ? null : buildForLayer("bottom")
+
return {
- topBoard: buildForLayer("top"),
- bottomBoard: numLayers < 2 ? null : buildForLayer("bottom"),
+ topBoard: top.boardTexture,
+ bottomBoard: bottom?.boardTexture ?? null,
+ topMaskedCopper: top.maskedCopperTexture,
+ bottomMaskedCopper: bottom?.maskedCopperTexture ?? null,
}
}
diff --git a/src/textures/create-copper-pour-texture-for-layer.ts b/src/textures/create-copper-pour-texture-for-layer.ts
index 82c156c6..13195bb4 100644
--- a/src/textures/create-copper-pour-texture-for-layer.ts
+++ b/src/textures/create-copper-pour-texture-for-layer.ts
@@ -35,12 +35,15 @@ export function createCopperPourTextureForLayer({
boardData,
traceTextureResolution = TRACE_TEXTURE_RESOLUTION,
copperColor = toRgb(defaultColors.copper),
+ onlyCoveredBySoldermask = false,
}: {
layer: "top" | "bottom"
circuitJson: AnyCircuitElement[]
boardData: PcbBoard
traceTextureResolution?: number
copperColor?: string
+ /** Used only for the realistic relief mask; excludes exposed pours. */
+ onlyCoveredBySoldermask?: boolean
}): THREE.CanvasTexture | null {
const copperPours = circuitJson.filter(
(e) => e.type === "pcb_copper_pour",
@@ -48,7 +51,11 @@ export function createCopperPourTextureForLayer({
const pcbRenderLayer: PcbRenderLayer =
layer === "top" ? "top_copper" : "bottom_copper"
- const poursOnLayer = copperPours.filter((p) => p.layer === layer)
+ const poursOnLayer = copperPours.filter(
+ (pour) =>
+ pour.layer === layer &&
+ (!onlyCoveredBySoldermask || pour.covered_with_solder_mask !== false),
+ )
if (poursOnLayer.length === 0) return null
const holes = circuitJson.filter((e) => e.type === "pcb_hole") as PcbHole[]
const platedHolesOnLayer = circuitJson.filter((e): e is PcbPlatedHole => {
@@ -153,7 +160,9 @@ export function createCopperPourTextureForLayer({
)
setColorAndDraw(coveredPours, coveredColor)
- setColorAndDraw(uncoveredPours, uncoveredColor)
+ if (!onlyCoveredBySoldermask) {
+ setColorAndDraw(uncoveredPours, uncoveredColor)
+ }
const texture = new THREE.CanvasTexture(canvas)
texture.generateMipmaps = true
diff --git a/src/textures/create-three-texture-meshes.ts b/src/textures/create-three-texture-meshes.ts
index dfd4ece6..d363fd98 100644
--- a/src/textures/create-three-texture-meshes.ts
+++ b/src/textures/create-three-texture-meshes.ts
@@ -1,13 +1,15 @@
import type { PcbBoard } from "circuit-json"
import * as THREE from "three"
+import { REALISTIC_BOARD_SURFACE_MATERIAL } from "../board-surface-textures"
import { FAUX_BOARD_OPACITY } from "../geoms/constants"
import { configureObjectShadows } from "../utils/configure-object-shadows"
-import { createBoardShadowReceiverPlane } from "../utils/create-board-shadow-receiver-plane"
+import { createBoardReliefTextures } from "../utils/create-board-relief-textures"
import { calculateOutlineBounds } from "../utils/outline-bounds"
import type { CombinedBoardTextures } from "./index"
interface TexturePlaneConfig {
texture: THREE.CanvasTexture | null | undefined
+ maskedCopperMask?: THREE.CanvasTexture | null
yOffset: number
isBottomLayer: boolean
usePolygonOffset?: boolean
@@ -21,6 +23,7 @@ function createTexturePlane(
): THREE.Mesh | null {
const {
texture,
+ maskedCopperMask,
yOffset,
isBottomLayer,
usePolygonOffset = false,
@@ -36,7 +39,8 @@ function createTexturePlane(
boardOutlineBounds.width,
boardOutlineBounds.height,
)
- const material = new THREE.MeshBasicMaterial({
+ texture.colorSpace = THREE.SRGBColorSpace
+ const sharedMaterialOptions = {
map: texture,
transparent: true,
alphaTest: 0.08,
@@ -46,6 +50,26 @@ function createTexturePlane(
polygonOffsetFactor: usePolygonOffset ? -4 : 0, // Increased for better z-fighting prevention
polygonOffsetUnits: usePolygonOffset ? -4 : 0,
opacity: isFaux ? FAUX_BOARD_OPACITY : 1.0,
+ } satisfies THREE.MeshBasicMaterialParameters
+ const reliefTextures = createBoardReliefTextures(texture, maskedCopperMask)
+ const material = new THREE.MeshPhysicalMaterial({
+ ...sharedMaterialOptions,
+ bumpMap: reliefTextures?.bumpMap ?? null,
+ bumpScale: REALISTIC_BOARD_SURFACE_MATERIAL.bumpScale,
+ normalMap: reliefTextures?.normalMap ?? null,
+ normalScale: new THREE.Vector2(
+ REALISTIC_BOARD_SURFACE_MATERIAL.normalScale,
+ REALISTIC_BOARD_SURFACE_MATERIAL.normalScale,
+ ),
+ roughnessMap: reliefTextures?.roughnessMap ?? null,
+ // Three.js multiplies roughnessMap by this value. Use 1 so the map
+ // represents the actual local finish rather than becoming glossy.
+ roughness: 1,
+ metalnessMap: reliefTextures?.metalnessMap ?? null,
+ metalness: 1,
+ clearcoat: REALISTIC_BOARD_SURFACE_MATERIAL.clearcoat,
+ clearcoatRoughness: REALISTIC_BOARD_SURFACE_MATERIAL.clearcoatRoughness,
+ envMapIntensity: 0.18,
})
const mesh = new THREE.Mesh(planeGeom, material)
mesh.position.set(
@@ -67,16 +91,15 @@ export function createTextureMeshes(
boardData: PcbBoard | null,
pcbThickness: number | null,
isFaux: boolean = false,
- options: { shadowsEnabled?: boolean } = {},
): THREE.Mesh[] {
const meshes: THREE.Mesh[] = []
if (!textures || !boardData || pcbThickness === null) return meshes
const SURFACE_OFFSET = 0.005
- const SHADOW_RECEIVER_OFFSET = SURFACE_OFFSET + 0.002
const topBoardMesh = createTexturePlane(
{
texture: textures.topBoard,
+ maskedCopperMask: textures.topMaskedCopper,
yOffset: pcbThickness / 2 + SURFACE_OFFSET,
isBottomLayer: false,
usePolygonOffset: true,
@@ -86,19 +109,11 @@ export function createTextureMeshes(
boardData,
)
if (topBoardMesh) meshes.push(topBoardMesh)
- if (options.shadowsEnabled) {
- meshes.push(
- createBoardShadowReceiverPlane({
- boardData,
- offset: pcbThickness / 2 + SHADOW_RECEIVER_OFFSET,
- isBottomLayer: false,
- }),
- )
- }
const bottomBoardMesh = createTexturePlane(
{
texture: textures.bottomBoard,
+ maskedCopperMask: textures.bottomMaskedCopper,
yOffset: -pcbThickness / 2 - SURFACE_OFFSET,
isBottomLayer: true,
usePolygonOffset: true,
@@ -108,15 +123,6 @@ export function createTextureMeshes(
boardData,
)
if (bottomBoardMesh) meshes.push(bottomBoardMesh)
- if (options.shadowsEnabled) {
- meshes.push(
- createBoardShadowReceiverPlane({
- boardData,
- offset: -pcbThickness / 2 - SHADOW_RECEIVER_OFFSET,
- isBottomLayer: true,
- }),
- )
- }
return meshes
}
diff --git a/src/three-components/JscadBoardTextures.tsx b/src/three-components/JscadBoardTextures.tsx
index c5903bfb..62307d02 100644
--- a/src/three-components/JscadBoardTextures.tsx
+++ b/src/three-components/JscadBoardTextures.tsx
@@ -3,15 +3,15 @@ import type { AnyCircuitElement, PcbBoard, PcbPanel } from "circuit-json"
import { useEffect, useMemo } from "react"
import { createCombinedBoardTextures } from "src/textures"
import * as THREE from "three"
+import { REALISTIC_BOARD_SURFACE_MATERIAL } from "../board-surface-textures"
import { useLayerVisibility } from "../contexts/LayerVisibilityContext"
-import { useRenderingMode } from "../contexts/RenderingModeContext"
import {
FAUX_BOARD_OPACITY,
TRACE_TEXTURE_RESOLUTION,
} from "../geoms/constants"
import { useThree } from "../react-three/ThreeContext"
import { configureObjectShadows } from "../utils/configure-object-shadows"
-import { createBoardShadowReceiverPlane } from "../utils/create-board-shadow-receiver-plane"
+import { createBoardReliefTextures } from "../utils/create-board-relief-textures"
import { getLayerTextureResolution } from "../utils/layer-texture-resolution"
import { calculateOutlineBounds } from "../utils/outline-bounds"
@@ -28,7 +28,6 @@ export function JscadBoardTextures({
}: JscadBoardTexturesProps) {
const { rootObject } = useThree()
const { visibility } = useLayerVisibility()
- const { shadowsEnabled } = useRenderingMode()
const boardData = useMemo(() => {
// Check for panel first
@@ -68,7 +67,7 @@ export function JscadBoardTextures({
}, [boardData])
const textures = useMemo(() => {
- if (!boardData || !boardData.width || !boardData.height) return null
+ if (!boardData?.width || !boardData.height) return null
return createCombinedBoardTextures({
circuitJson,
boardData,
@@ -109,15 +108,25 @@ export function JscadBoardTextures({
material.dispose()
}
- const createTexturePlane = (
- texture: THREE.CanvasTexture | null | undefined,
- zOffset: number,
- isBottomLayer: boolean,
- name: string,
+ const createTexturePlane = ({
+ texture,
+ maskedCopperMask,
+ zOffset,
+ isBottomLayer,
+ name,
usePolygonOffset = false,
depthWrite = true,
renderOrder = 1,
- ) => {
+ }: {
+ texture: THREE.CanvasTexture | null | undefined
+ maskedCopperMask: THREE.CanvasTexture | null | undefined
+ zOffset: number
+ isBottomLayer: boolean
+ name: string
+ usePolygonOffset?: boolean
+ depthWrite?: boolean
+ renderOrder?: number
+ }) => {
if (!texture) return null
// Use board outline bounds for plane geometry to match texture dimensions
@@ -126,7 +135,8 @@ export function JscadBoardTextures({
boardOutlineBounds.width,
boardOutlineBounds.height,
)
- const material = new THREE.MeshBasicMaterial({
+ texture.colorSpace = THREE.SRGBColorSpace
+ const sharedMaterialOptions = {
map: texture,
transparent: true,
alphaTest: 0.08,
@@ -136,6 +146,27 @@ export function JscadBoardTextures({
polygonOffsetFactor: usePolygonOffset ? -4 : 0,
polygonOffsetUnits: usePolygonOffset ? -4 : 0,
opacity: isFaux ? FAUX_BOARD_OPACITY : 1.0,
+ } satisfies THREE.MeshBasicMaterialParameters
+ const reliefTextures = createBoardReliefTextures(
+ texture,
+ maskedCopperMask,
+ )
+ const material = new THREE.MeshPhysicalMaterial({
+ ...sharedMaterialOptions,
+ bumpMap: reliefTextures?.bumpMap ?? null,
+ bumpScale: REALISTIC_BOARD_SURFACE_MATERIAL.bumpScale,
+ normalMap: reliefTextures?.normalMap ?? null,
+ normalScale: new THREE.Vector2(
+ REALISTIC_BOARD_SURFACE_MATERIAL.normalScale,
+ REALISTIC_BOARD_SURFACE_MATERIAL.normalScale,
+ ),
+ roughnessMap: reliefTextures?.roughnessMap ?? null,
+ roughness: 1,
+ metalnessMap: reliefTextures?.metalnessMap ?? null,
+ metalness: 1,
+ clearcoat: REALISTIC_BOARD_SURFACE_MATERIAL.clearcoat,
+ clearcoatRoughness: REALISTIC_BOARD_SURFACE_MATERIAL.clearcoatRoughness,
+ envMapIntensity: 0.18,
})
const mesh = new THREE.Mesh(planeGeom, material)
mesh.position.set(
@@ -155,71 +186,52 @@ export function JscadBoardTextures({
// Small offset to place textures just above board surface (same as Manifold)
const SURFACE_OFFSET = 0.005
- const SHADOW_RECEIVER_OFFSET = SURFACE_OFFSET + 0.002
-
- const topBoardMesh = createTexturePlane(
- textures.topBoard,
- pcbThickness / 2 + SURFACE_OFFSET,
- false,
- "jscad-top-board-texture",
- true,
- )
+
+ const topBoardMesh = createTexturePlane({
+ texture: textures.topBoard,
+ maskedCopperMask: textures.topMaskedCopper,
+ zOffset: pcbThickness / 2 + SURFACE_OFFSET,
+ isBottomLayer: false,
+ name: "jscad-top-board-texture",
+ usePolygonOffset: true,
+ })
if (topBoardMesh) {
meshes.push(topBoardMesh)
rootObject.add(topBoardMesh)
}
- if (shadowsEnabled) {
- const topShadowReceiver = createBoardShadowReceiverPlane({
- boardData,
- offset: pcbThickness / 2 + SHADOW_RECEIVER_OFFSET,
- isBottomLayer: false,
- name: "jscad-top-board-shadow-receiver",
- frustumCulled: false,
- })
- meshes.push(topShadowReceiver)
- rootObject.add(topShadowReceiver)
- }
- const bottomBoardMesh = createTexturePlane(
- textures.bottomBoard,
- -pcbThickness / 2 - SURFACE_OFFSET,
- true,
- "jscad-bottom-board-texture",
- true,
- )
+ const bottomBoardMesh = createTexturePlane({
+ texture: textures.bottomBoard,
+ maskedCopperMask: textures.bottomMaskedCopper,
+ zOffset: -pcbThickness / 2 - SURFACE_OFFSET,
+ isBottomLayer: true,
+ name: "jscad-bottom-board-texture",
+ usePolygonOffset: true,
+ })
if (bottomBoardMesh) {
meshes.push(bottomBoardMesh)
rootObject.add(bottomBoardMesh)
}
- if (shadowsEnabled) {
- const bottomShadowReceiver = createBoardShadowReceiverPlane({
- boardData,
- offset: -pcbThickness / 2 - SHADOW_RECEIVER_OFFSET,
- isBottomLayer: true,
- name: "jscad-bottom-board-shadow-receiver",
- frustumCulled: false,
- })
- meshes.push(bottomShadowReceiver)
- rootObject.add(bottomShadowReceiver)
- }
return () => {
- meshes.forEach((mesh) => {
+ for (const mesh of meshes) {
if (mesh.parent === rootObject) {
rootObject.remove(mesh)
}
mesh.geometry.dispose()
if (Array.isArray(mesh.material)) {
- mesh.material.forEach((material) => disposeTextureMaterial(material))
+ for (const material of mesh.material) {
+ disposeTextureMaterial(material)
+ }
} else if (mesh.material instanceof THREE.Material) {
disposeTextureMaterial(mesh.material)
}
- })
+ }
textures.topBoard?.dispose()
textures.bottomBoard?.dispose()
}
- }, [rootObject, boardData, textures, pcbThickness, shadowsEnabled])
+ }, [rootObject, boardData, textures, pcbThickness])
return null
}
diff --git a/src/utils/create-board-material.ts b/src/utils/create-board-material.ts
index e33851cf..e3aaa041 100644
--- a/src/utils/create-board-material.ts
+++ b/src/utils/create-board-material.ts
@@ -1,5 +1,5 @@
-import * as THREE from "three"
import type { PcbBoard } from "circuit-json"
+import * as THREE from "three"
import { FAUX_BOARD_OPACITY } from "../geoms/constants"
type BoardMaterialType = PcbBoard["material"]
@@ -21,14 +21,17 @@ export const createBoardMaterial = ({
}: CreateBoardMaterialOptions): THREE.MeshStandardMaterial => {
if (material === "fr4") {
return new THREE.MeshPhysicalMaterial({
- color,
+ // A dark edge lets the green solder mask read as a finished PCB rather
+ // than a tan substrate with a decal placed on top.
+ color: 0x103a26,
side,
metalness: 0.0,
- roughness: 0.8,
- specularIntensity: 0.2,
+ roughness: 0.48,
+ specularIntensity: 0.3,
ior: 1.45,
sheen: 0.0,
- clearcoat: 0.0,
+ clearcoat: 0.16,
+ clearcoatRoughness: 0.3,
transparent: isFaux,
opacity: isFaux ? FAUX_BOARD_OPACITY : 1.0,
flatShading: true,
diff --git a/src/utils/create-board-relief-textures.ts b/src/utils/create-board-relief-textures.ts
new file mode 100644
index 00000000..df858081
--- /dev/null
+++ b/src/utils/create-board-relief-textures.ts
@@ -0,0 +1,311 @@
+import * as THREE from "three"
+import {
+ PAD_COPPER_TEXTURE_MATERIAL,
+ REALISTIC_BOARD_SURFACE_MATERIAL,
+} from "../board-surface-textures"
+
+const PLAIN_SOLDERMASK_HEIGHT = 0.22
+const MASKED_COPPER_HEIGHT = 0.7
+const EXPOSED_COPPER_HEIGHT = 0.86
+
+type BoardSurfaceProfile = {
+ height: number
+ microSurfaceWeight: number
+ roughness: number
+ metalness: number
+ isExposedCopper: boolean
+ isMaskedCopper: boolean
+}
+
+const clamp01 = (value: number) => Math.max(0, Math.min(1, value))
+const invertSurfaceHeight = (height: number) => 1 - height
+
+const hashNoise = (x: number, y: number, salt: number) => {
+ let hash = Math.imul(x, 374761393) ^ Math.imul(y, 668265263) ^ salt
+ hash = Math.imul(hash ^ (hash >>> 13), 1274126177)
+ return ((hash ^ (hash >>> 16)) >>> 0) / 4294967295
+}
+
+const smoothstep = (value: number) => value * value * (3 - 2 * value)
+const lerp = (a: number, b: number, t: number) => a + (b - a) * t
+
+const valueNoise = (x: number, y: number, scale: number, salt: number) => {
+ const sx = x / scale
+ const sy = y / scale
+ const x0 = Math.floor(sx)
+ const y0 = Math.floor(sy)
+ const tx = smoothstep(sx - x0)
+ const ty = smoothstep(sy - y0)
+ const top = lerp(hashNoise(x0, y0, salt), hashNoise(x0 + 1, y0, salt), tx)
+ const bottom = lerp(
+ hashNoise(x0, y0 + 1, salt),
+ hashNoise(x0 + 1, y0 + 1, salt),
+ tx,
+ )
+ return lerp(top, bottom, ty)
+}
+
+const createCopperDetail = (x: number, y: number, salt: number) => {
+ // A restrained etched/brushed grain breaks up large pads without sparkles.
+ const etch = (valueNoise(x, y, 7, salt + 31) - 0.5) * 0.55
+ const brush = Math.sin(x * 0.12 + valueNoise(x, y, 44, salt + 37) * 2) * 0.22
+ return (etch + brush) * PAD_COPPER_TEXTURE_MATERIAL.detailStrength
+}
+
+const createMaskedTraceDetail = (x: number, y: number, salt: number) => {
+ // Covered routing has a visible but non-metallic etched finish.
+ const etch = (hashNoise(x, y, salt + 53) - 0.5) * 0.42
+ const routingGrain =
+ Math.sin(x * 0.26 + y * 0.06) * 0.2 + Math.sin(y * 0.31 - x * 0.04) * 0.12
+ return (etch + routingGrain) * 0.045
+}
+
+const getBoardSurfaceProfile = (
+ r: number,
+ g: number,
+ b: number,
+ hasMaskedCopper: boolean,
+): BoardSurfaceProfile => {
+ const isExposedCopper =
+ r > 120 && g > 70 && b < 150 && r > g * 1.05 && g > b * 1.15
+ if (isExposedCopper) {
+ return {
+ height: EXPOSED_COPPER_HEIGHT,
+ microSurfaceWeight: 0.9,
+ roughness: PAD_COPPER_TEXTURE_MATERIAL.roughness,
+ metalness: PAD_COPPER_TEXTURE_MATERIAL.metalness,
+ isExposedCopper: true,
+ isMaskedCopper: false,
+ }
+ }
+
+ const isBrightLegend = r > 170 && g > 170 && b > 160
+ if (isBrightLegend) {
+ return {
+ height: PLAIN_SOLDERMASK_HEIGHT,
+ microSurfaceWeight: 0.4,
+ roughness: 0.76,
+ metalness: 0,
+ isExposedCopper: false,
+ isMaskedCopper: false,
+ }
+ }
+
+ const isGreenSoldermask =
+ g > r * 1.25 && g > b * 1.05 && r < 110 && g < 180 && b < 130
+ if (!isGreenSoldermask) {
+ return {
+ height: PLAIN_SOLDERMASK_HEIGHT,
+ microSurfaceWeight: 0.7,
+ roughness: 0.58,
+ metalness: 0.015,
+ isExposedCopper: false,
+ isMaskedCopper: false,
+ }
+ }
+
+ // The color map may be graded for presentation, so it cannot identify
+ // buried copper reliably. Only the dedicated geometry mask may do that.
+ return hasMaskedCopper
+ ? {
+ height: MASKED_COPPER_HEIGHT,
+ microSurfaceWeight: 0.9,
+ roughness: 0.54,
+ metalness: 0.025,
+ isExposedCopper: false,
+ isMaskedCopper: true,
+ }
+ : {
+ height: PLAIN_SOLDERMASK_HEIGHT,
+ microSurfaceWeight: 1,
+ roughness: 0.7,
+ metalness: 0.015,
+ isExposedCopper: false,
+ isMaskedCopper: false,
+ }
+}
+
+const createDataTexture = (canvas: HTMLCanvasElement) => {
+ const texture = new THREE.CanvasTexture(canvas)
+ texture.colorSpace = THREE.NoColorSpace
+ texture.generateMipmaps = false
+ texture.minFilter = THREE.LinearFilter
+ texture.magFilter = THREE.LinearFilter
+ texture.wrapS = THREE.ClampToEdgeWrapping
+ texture.wrapT = THREE.ClampToEdgeWrapping
+ texture.needsUpdate = true
+ return texture
+}
+
+export const createBoardReliefTextures = (
+ texture: THREE.CanvasTexture,
+ maskedCopperMask?: THREE.CanvasTexture | null,
+): {
+ bumpMap: THREE.CanvasTexture
+ normalMap: THREE.CanvasTexture
+ roughnessMap: THREE.CanvasTexture
+ metalnessMap: THREE.CanvasTexture
+} | null => {
+ const sourceCanvas = texture.image as HTMLCanvasElement | undefined
+ if (!sourceCanvas?.width || !sourceCanvas.height) return null
+
+ const sourceCtx = sourceCanvas.getContext("2d")
+ if (!sourceCtx) return null
+
+ const maskCanvas = maskedCopperMask?.image as HTMLCanvasElement | undefined
+ const maskCtx = maskCanvas?.getContext("2d")
+ const maskWidth = maskCanvas?.width ?? 0
+ const maskHeight = maskCanvas?.height ?? 0
+ const maskImageData =
+ maskCtx && maskWidth && maskHeight
+ ? maskCtx.getImageData(0, 0, maskWidth, maskHeight)
+ : null
+
+ const imageData = sourceCtx.getImageData(
+ 0,
+ 0,
+ sourceCanvas.width,
+ sourceCanvas.height,
+ )
+ const data = imageData.data
+ const heights = new Float32Array(sourceCanvas.width * sourceCanvas.height)
+ const roughnessCanvas = document.createElement("canvas")
+ const metalnessCanvas = document.createElement("canvas")
+ roughnessCanvas.width = metalnessCanvas.width = sourceCanvas.width
+ roughnessCanvas.height = metalnessCanvas.height = sourceCanvas.height
+ const roughnessCtx = roughnessCanvas.getContext("2d")
+ const metalnessCtx = metalnessCanvas.getContext("2d")
+ if (!roughnessCtx || !metalnessCtx) return null
+ const roughnessImageData = roughnessCtx.createImageData(
+ sourceCanvas.width,
+ sourceCanvas.height,
+ )
+ const metalnessImageData = metalnessCtx.createImageData(
+ sourceCanvas.width,
+ sourceCanvas.height,
+ )
+ const roughnessData = roughnessImageData.data
+ const metalnessData = metalnessImageData.data
+
+ for (let i = 0; i < data.length; i += 4) {
+ const pixelIndex = i / 4
+ const x = pixelIndex % sourceCanvas.width
+ const y = Math.floor(pixelIndex / sourceCanvas.width)
+ const alpha = data[i + 3] ?? 0
+ if (alpha < 16) {
+ heights[pixelIndex] = invertSurfaceHeight(PLAIN_SOLDERMASK_HEIGHT)
+ continue
+ }
+
+ const maskX = maskImageData
+ ? Math.min(
+ maskWidth - 1,
+ Math.floor((x / sourceCanvas.width) * maskWidth),
+ )
+ : 0
+ const maskY = maskImageData
+ ? Math.min(
+ maskHeight - 1,
+ Math.floor((y / sourceCanvas.height) * maskHeight),
+ )
+ : 0
+ const maskAlpha = maskImageData
+ ? (maskImageData.data[(maskY * maskWidth + maskX) * 4 + 3] ?? 0)
+ : 0
+ const profile = getBoardSurfaceProfile(
+ data[i] ?? 0,
+ data[i + 1] ?? 0,
+ data[i + 2] ?? 0,
+ maskAlpha >= 16,
+ )
+ const salt = sourceCanvas.width + sourceCanvas.height
+ const fineGrain = profile.isExposedCopper
+ ? createCopperDetail(x, y, salt)
+ : profile.isMaskedCopper
+ ? createMaskedTraceDetail(x, y, salt)
+ : 0
+ const height = clamp01(
+ invertSurfaceHeight(profile.height) +
+ fineGrain * profile.microSurfaceWeight,
+ )
+ heights[pixelIndex] = height
+
+ const heightChannel = height * 255
+ data[i] = data[i + 1] = data[i + 2] = heightChannel
+ data[i + 3] = 255
+
+ const roughness = profile.isExposedCopper
+ ? clamp01(
+ profile.roughness +
+ fineGrain * 0.7 +
+ (valueNoise(x, y, 36, salt + 17) - 0.5) *
+ PAD_COPPER_TEXTURE_MATERIAL.roughnessVariance,
+ )
+ : profile.isMaskedCopper
+ ? clamp01(
+ profile.roughness +
+ fineGrain +
+ REALISTIC_BOARD_SURFACE_MATERIAL.roughnessBias,
+ )
+ : profile.roughness
+ const roughnessChannel = roughness * 255
+ roughnessData[i] =
+ roughnessData[i + 1] =
+ roughnessData[i + 2] =
+ roughnessChannel
+ roughnessData[i + 3] = 255
+
+ const metalnessChannel = profile.metalness * 255
+ metalnessData[i] =
+ metalnessData[i + 1] =
+ metalnessData[i + 2] =
+ metalnessChannel
+ metalnessData[i + 3] = 255
+ }
+ roughnessCtx.putImageData(roughnessImageData, 0, 0)
+ metalnessCtx.putImageData(metalnessImageData, 0, 0)
+
+ const bumpCanvas = document.createElement("canvas")
+ bumpCanvas.width = sourceCanvas.width
+ bumpCanvas.height = sourceCanvas.height
+ const bumpCtx = bumpCanvas.getContext("2d")
+ if (!bumpCtx) return null
+ bumpCtx.putImageData(imageData, 0, 0)
+
+ const normalCanvas = document.createElement("canvas")
+ normalCanvas.width = sourceCanvas.width
+ normalCanvas.height = sourceCanvas.height
+ const normalCtx = normalCanvas.getContext("2d")
+ if (!normalCtx) return null
+ const normalImageData = normalCtx.createImageData(
+ sourceCanvas.width,
+ sourceCanvas.height,
+ )
+ const normalData = normalImageData.data
+ const getHeight = (x: number, y: number) =>
+ heights[
+ Math.max(0, Math.min(sourceCanvas.height - 1, y)) * sourceCanvas.width +
+ Math.max(0, Math.min(sourceCanvas.width - 1, x))
+ ] ?? 0
+
+ for (let y = 0; y < sourceCanvas.height; y++) {
+ for (let x = 0; x < sourceCanvas.width; x++) {
+ const dx = (getHeight(x + 1, y) - getHeight(x - 1, y)) * 4
+ const dy = (getHeight(x, y + 1) - getHeight(x, y - 1)) * 4
+ const normal = new THREE.Vector3(-dx, -dy, 1).normalize()
+ const i = (y * sourceCanvas.width + x) * 4
+ normalData[i] = (normal.x * 0.5 + 0.5) * 255
+ normalData[i + 1] = (normal.y * 0.5 + 0.5) * 255
+ normalData[i + 2] = (normal.z * 0.5 + 0.5) * 255
+ normalData[i + 3] = 255
+ }
+ }
+ normalCtx.putImageData(normalImageData, 0, 0)
+
+ return {
+ bumpMap: createDataTexture(bumpCanvas),
+ normalMap: createDataTexture(normalCanvas),
+ roughnessMap: createDataTexture(roughnessCanvas),
+ metalnessMap: createDataTexture(metalnessCanvas),
+ }
+}
diff --git a/src/utils/manifold/create-three-geometry-meshes.ts b/src/utils/manifold/create-three-geometry-meshes.ts
index c8cebc69..93750f3f 100644
--- a/src/utils/manifold/create-three-geometry-meshes.ts
+++ b/src/utils/manifold/create-three-geometry-meshes.ts
@@ -9,7 +9,7 @@ export function createGeometryMeshes(
const meshes: THREE.Mesh[] = []
if (!geoms) return meshes
- if (geoms.board && geoms.board.geometry) {
+ if (geoms.board?.geometry) {
const mesh = new THREE.Mesh(
geoms.board.geometry,
createBoardMaterial({
@@ -32,7 +32,7 @@ export function createGeometryMeshes(
}>,
) => {
if (geomArray) {
- geomArray.forEach((comp) => {
+ for (const comp of geomArray) {
const mesh = new THREE.Mesh(
comp.geometry,
new THREE.MeshStandardMaterial({
@@ -44,7 +44,7 @@ export function createGeometryMeshes(
mesh.name = comp.key // Use provided key for identification
configureObjectShadows(mesh)
meshes.push(mesh)
- })
+ }
}
}
diff --git a/tests/board-relief-textures.test.ts b/tests/board-relief-textures.test.ts
new file mode 100644
index 00000000..4fe397cb
--- /dev/null
+++ b/tests/board-relief-textures.test.ts
@@ -0,0 +1,155 @@
+import { expect, test } from "bun:test"
+import * as THREE from "three"
+import { createBoardReliefTextures } from "../src/utils/create-board-relief-textures"
+
+type PixelImage = {
+ data: Uint8ClampedArray
+ width: number
+ height: number
+}
+
+class TestCanvas {
+ private pixels = new Uint8ClampedArray()
+ private canvasWidth = 0
+ private canvasHeight = 0
+
+ get width() {
+ return this.canvasWidth
+ }
+
+ set width(value: number) {
+ this.canvasWidth = value
+ this.reset()
+ }
+
+ get height() {
+ return this.canvasHeight
+ }
+
+ set height(value: number) {
+ this.canvasHeight = value
+ this.reset()
+ }
+
+ setPixel(x: number, y: number, rgba: [number, number, number, number]) {
+ this.pixels.set(rgba, (y * this.width + x) * 4)
+ }
+
+ channel(x: number, y: number, channel: number) {
+ return this.pixels[(y * this.width + x) * 4 + channel] ?? 0
+ }
+
+ snapshot() {
+ return this.pixels.slice()
+ }
+
+ getContext() {
+ return {
+ getImageData: (x: number, y: number, width: number, height: number) => {
+ const data = new Uint8ClampedArray(width * height * 4)
+ for (let row = 0; row < height; row += 1) {
+ data.set(
+ this.pixels.subarray(
+ ((y + row) * this.width + x) * 4,
+ ((y + row) * this.width + x + width) * 4,
+ ),
+ row * width * 4,
+ )
+ }
+ return { data, width, height } satisfies PixelImage
+ },
+ createImageData: (width: number, height: number) =>
+ ({
+ data: new Uint8ClampedArray(width * height * 4),
+ width,
+ height,
+ }) satisfies PixelImage,
+ putImageData: (image: PixelImage, x: number, y: number) => {
+ for (let row = 0; row < image.height; row += 1) {
+ this.pixels.set(
+ image.data.subarray(
+ row * image.width * 4,
+ (row + 1) * image.width * 4,
+ ),
+ ((y + row) * this.width + x) * 4,
+ )
+ }
+ },
+ }
+ }
+
+ private reset() {
+ this.pixels = new Uint8ClampedArray(this.width * this.height * 4)
+ }
+}
+
+const createCanvas = (width: number, height: number) => {
+ const canvas = new TestCanvas()
+ canvas.width = width
+ canvas.height = height
+ return canvas
+}
+
+const asTexture = (canvas: TestCanvas) =>
+ new THREE.CanvasTexture(canvas as unknown as HTMLCanvasElement)
+
+const withCanvasDocument = (run: () => void) => {
+ const previousDocument = globalThis.document
+ Object.assign(globalThis, {
+ document: {
+ createElement: (tag: string) => {
+ if (tag !== "canvas") throw new Error(`Unexpected element: ${tag}`)
+ return new TestCanvas()
+ },
+ },
+ })
+ try {
+ run()
+ } finally {
+ Object.assign(globalThis, { document: previousDocument })
+ }
+}
+
+const fillSoldermask = (canvas: TestCanvas) => {
+ for (let y = 0; y < canvas.height; y += 1) {
+ for (let x = 0; x < canvas.width; x += 1) {
+ // This bright green used to trigger the g > 88 masked-copper heuristic.
+ canvas.setPixel(x, y, [20, 110, 55, 255])
+ }
+ }
+}
+
+test("empty PCB has no masked-copper relief and retains its color texture", () => {
+ withCanvasDocument(() => {
+ const board = createCanvas(4, 4)
+ fillSoldermask(board)
+ const boardColorBefore = board.snapshot()
+
+ const relief = createBoardReliefTextures(asTexture(board))
+ expect(relief).not.toBeNull()
+ expect(board.snapshot()).toEqual(boardColorBefore)
+
+ const bump = relief!.bumpMap.image as unknown as TestCanvas
+ const height = bump.channel(0, 0, 0)
+ expect(height).toBeGreaterThan(170)
+ expect(bump.channel(3, 3, 0)).toBe(height)
+ })
+})
+
+test("trace mask applies masked-copper relief only on trace geometry", () => {
+ withCanvasDocument(() => {
+ const board = createCanvas(4, 4)
+ fillSoldermask(board)
+ const traceMask = createCanvas(4, 4)
+ traceMask.setPixel(2, 2, [255, 255, 255, 255])
+
+ const relief = createBoardReliefTextures(
+ asTexture(board),
+ asTexture(traceMask),
+ )
+ expect(relief).not.toBeNull()
+
+ const bump = relief!.bumpMap.image as unknown as TestCanvas
+ expect(bump.channel(2, 2, 0)).toBeLessThan(bump.channel(1, 1, 0) - 80)
+ })
+})