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) + }) +})