From 6db324657e5b4feafaa25edaab29f163a9b7596d Mon Sep 17 00:00:00 2001 From: seveibar Date: Sat, 4 Jul 2026 15:35:13 -0700 Subject: [PATCH 1/8] wip --- src/react-three/Lights.tsx | 2 +- .../create-combined-board-textures.ts | 44 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/react-three/Lights.tsx b/src/react-three/Lights.tsx index 8f71266a..df80e11b 100644 --- a/src/react-three/Lights.tsx +++ b/src/react-three/Lights.tsx @@ -76,7 +76,7 @@ export const Lights: React.FC = ({ addDirectionalLight( "cad-viewer-key-light", - 0xfff0df, + 0xffffff, 2.4, [lightDistance * 0.22, -lightDistance * 0.28, lightDistance * 1.15], shadowsEnabled, diff --git a/src/textures/create-combined-board-textures.ts b/src/textures/create-combined-board-textures.ts index d19737cc..6a871df6 100644 --- a/src/textures/create-combined-board-textures.ts +++ b/src/textures/create-combined-board-textures.ts @@ -27,6 +27,48 @@ const toRgb = (colorArr: number[]) => { )})` } +const clampChannel = (value: number) => Math.max(0, Math.min(255, value)) + +const applySoldermaskSurfaceFilter = ( + ctx: CanvasRenderingContext2D, + width: number, + height: number, +) => { + const imageData = ctx.getImageData(0, 0, width, height) + const data = imageData.data + const maxX = Math.max(width - 1, 1) + const maxY = Math.max(height - 1, 1) + + for (let i = 0; i < data.length; i += 4) { + const alpha = data[i + 3] ?? 0 + if (alpha < 16) continue + + const r = data[i] ?? 0 + const g = data[i + 1] ?? 0 + const b = data[i + 2] ?? 0 + const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b + const isDarkGreen = + g > r * 1.35 && g > b * 1.15 && luminance < 90 && r < 80 && b < 80 + + if (!isDarkGreen) continue + + const pixelIndex = i / 4 + const x = pixelIndex % width + const y = Math.floor(pixelIndex / width) + const u = x / maxX + const v = y / maxY + const diagonalLight = u * 0.58 + (1 - v) * 0.42 + const broadVariation = Math.sin((u * 1.15 + v * 0.35) * Math.PI) * 0.04 + const lightFactor = 0.78 + diagonalLight * 0.18 + broadVariation + + data[i] = clampChannel((r * 1.12 + 6) * lightFactor) + data[i + 1] = clampChannel((g * 1.16 + 18) * lightFactor) + data[i + 2] = clampChannel((b * 1.12 + 8) * lightFactor) + } + + ctx.putImageData(imageData, 0, 0) +} + const createCombinedTexture = ({ textures, boardData, @@ -60,6 +102,8 @@ const createCombinedTexture = ({ ctx.drawImage(image, 0, 0, canvasWidth, canvasHeight) }) + applySoldermaskSurfaceFilter(ctx, canvasWidth, canvasHeight) + const combinedTexture = new THREE.CanvasTexture(canvas) combinedTexture.generateMipmaps = false combinedTexture.minFilter = THREE.LinearFilter From 386d795d68d79c4a9bc0e077446b8bf0d4f0f487 Mon Sep 17 00:00:00 2001 From: seveibar Date: Sat, 4 Jul 2026 15:39:05 -0700 Subject: [PATCH 2/8] wip --- src/textures/create-combined-board-textures.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textures/create-combined-board-textures.ts b/src/textures/create-combined-board-textures.ts index 6a871df6..80426261 100644 --- a/src/textures/create-combined-board-textures.ts +++ b/src/textures/create-combined-board-textures.ts @@ -61,9 +61,9 @@ const applySoldermaskSurfaceFilter = ( const broadVariation = Math.sin((u * 1.15 + v * 0.35) * Math.PI) * 0.04 const lightFactor = 0.78 + diagonalLight * 0.18 + broadVariation - data[i] = clampChannel((r * 1.12 + 6) * lightFactor) - data[i + 1] = clampChannel((g * 1.16 + 18) * lightFactor) - data[i + 2] = clampChannel((b * 1.12 + 8) * lightFactor) + data[i] = clampChannel((r * 0.9 + 3) * lightFactor) + data[i + 1] = clampChannel((g * 1.08 + 15) * lightFactor) + data[i + 2] = clampChannel((b * 1.28 + 13) * lightFactor) } ctx.putImageData(imageData, 0, 0) From 122bd4da38c5e826ec76f353bcbfaf744b6c4a9e Mon Sep 17 00:00:00 2001 From: seveibar Date: Sat, 4 Jul 2026 15:40:32 -0700 Subject: [PATCH 3/8] subtle gloss --- src/textures/create-combined-board-textures.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/textures/create-combined-board-textures.ts b/src/textures/create-combined-board-textures.ts index 80426261..4233335d 100644 --- a/src/textures/create-combined-board-textures.ts +++ b/src/textures/create-combined-board-textures.ts @@ -59,7 +59,10 @@ const applySoldermaskSurfaceFilter = ( const v = y / maxY const diagonalLight = u * 0.58 + (1 - v) * 0.42 const broadVariation = Math.sin((u * 1.15 + v * 0.35) * Math.PI) * 0.04 - const lightFactor = 0.78 + diagonalLight * 0.18 + broadVariation + const sheenPosition = u * 0.72 + (1 - v) * 0.28 + const softSheen = + Math.max(0, 1 - Math.abs(sheenPosition - 0.68) / 0.18) * 0.07 + const lightFactor = 0.78 + diagonalLight * 0.18 + broadVariation + softSheen data[i] = clampChannel((r * 0.9 + 3) * lightFactor) data[i + 1] = clampChannel((g * 1.08 + 15) * lightFactor) From dc69d6a354a835933d16785bc6ed369b891a1f82 Mon Sep 17 00:00:00 2001 From: seveibar Date: Sat, 4 Jul 2026 15:58:18 -0700 Subject: [PATCH 4/8] reflection off board --- src/react-three/Lights.tsx | 12 ++++- .../create-combined-board-textures.ts | 44 ++++++++++++++----- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/src/react-three/Lights.tsx b/src/react-three/Lights.tsx index df80e11b..fd68110d 100644 --- a/src/react-three/Lights.tsx +++ b/src/react-three/Lights.tsx @@ -28,6 +28,7 @@ export const Lights: React.FC = ({ ) const shadowHalfSize = largestBoardDimension * 0.8 const lightDistance = largestBoardDimension + const keyLightDistance = largestBoardDimension * 8.3 const ambientLight = new THREE.AmbientLight(0xffffff, 0.18) ambientLight.name = "cad-viewer-soft-ambient" @@ -66,7 +67,10 @@ export const Lights: React.FC = ({ shadowCamera.top = shadowHalfSize shadowCamera.bottom = -shadowHalfSize shadowCamera.near = 0.5 - shadowCamera.far = largestBoardDimension * 4 + shadowCamera.far = Math.max( + largestBoardDimension * 4, + Math.hypot(...position) + largestBoardDimension * 2, + ) shadowCamera.updateProjectionMatrix() } @@ -78,7 +82,11 @@ export const Lights: React.FC = ({ "cad-viewer-key-light", 0xffffff, 2.4, - [lightDistance * 0.22, -lightDistance * 0.28, lightDistance * 1.15], + [ + keyLightDistance * 0.22, + -keyLightDistance * 0.28, + keyLightDistance * 1.15, + ], shadowsEnabled, ) addDirectionalLight("cad-viewer-fill-light", 0xdde8ff, 0.7, [ diff --git a/src/textures/create-combined-board-textures.ts b/src/textures/create-combined-board-textures.ts index 4233335d..8b4bc215 100644 --- a/src/textures/create-combined-board-textures.ts +++ b/src/textures/create-combined-board-textures.ts @@ -33,6 +33,7 @@ const applySoldermaskSurfaceFilter = ( ctx: CanvasRenderingContext2D, width: number, height: number, + options: { includeReflection?: boolean } = {}, ) => { const imageData = ctx.getImageData(0, 0, width, height) const data = imageData.data @@ -57,16 +58,34 @@ const applySoldermaskSurfaceFilter = ( const y = Math.floor(pixelIndex / width) const u = x / maxX const v = y / maxY - const diagonalLight = u * 0.58 + (1 - v) * 0.42 + const diagonalLight = u * 0.62 + (1 - v) * 0.38 const broadVariation = Math.sin((u * 1.15 + v * 0.35) * Math.PI) * 0.04 - const sheenPosition = u * 0.72 + (1 - v) * 0.28 - const softSheen = - Math.max(0, 1 - Math.abs(sheenPosition - 0.68) / 0.18) * 0.07 - const lightFactor = 0.78 + diagonalLight * 0.18 + broadVariation + softSheen - - data[i] = clampChannel((r * 0.9 + 3) * lightFactor) - data[i + 1] = clampChannel((g * 1.08 + 15) * lightFactor) - data[i + 2] = clampChannel((b * 1.28 + 13) * lightFactor) + const lightFactor = 0.74 + diagonalLight * 0.18 + broadVariation + + const whiteReflection = options.includeReflection + ? (() => { + const reflectionX = 0.36 + const reflectionY = 0.22 + const dx = u - reflectionX + const dy = v - reflectionY + const radialFalloff = Math.max(0, 1 - Math.hypot(dx, dy) / 0.38) + return radialFalloff * radialFalloff * 0.07 + })() + : 0 + + const filteredR = (r * 0.9 + 3) * lightFactor + const filteredG = (g * 1.08 + 15) * lightFactor + const filteredB = (b * 1.28 + 13) * lightFactor + + data[i] = clampChannel( + filteredR * (1 - whiteReflection) + 255 * whiteReflection, + ) + data[i + 1] = clampChannel( + filteredG * (1 - whiteReflection) + 255 * whiteReflection, + ) + data[i + 2] = clampChannel( + filteredB * (1 - whiteReflection) + 255 * whiteReflection, + ) } ctx.putImageData(imageData, 0, 0) @@ -76,10 +95,12 @@ const createCombinedTexture = ({ textures, boardData, traceTextureResolution, + layer, }: { textures: Array boardData: PcbBoard traceTextureResolution: number + layer: "top" | "bottom" }): THREE.CanvasTexture | null => { const hasImage = textures.some((texture) => texture?.image) if (!hasImage) return null @@ -105,7 +126,9 @@ const createCombinedTexture = ({ ctx.drawImage(image, 0, 0, canvasWidth, canvasHeight) }) - applySoldermaskSurfaceFilter(ctx, canvasWidth, canvasHeight) + applySoldermaskSurfaceFilter(ctx, canvasWidth, canvasHeight, { + includeReflection: layer === "top", + }) const combinedTexture = new THREE.CanvasTexture(canvas) combinedTexture.generateMipmaps = false @@ -267,6 +290,7 @@ export function createCombinedBoardTextures({ ], boardData, traceTextureResolution, + layer, }) } From 5fdb76e997e3f1ffe72bfcab681991b2fa52d67c Mon Sep 17 00:00:00 2001 From: seveibar Date: Sat, 4 Jul 2026 16:01:11 -0700 Subject: [PATCH 5/8] tweak to lighting mode --- src/CadViewerContainer.tsx | 14 +++++----- src/components/AppearanceMenu.tsx | 37 +++++---------------------- src/contexts/RenderingModeContext.tsx | 24 +++++++++++++++-- 3 files changed, 37 insertions(+), 38 deletions(-) diff --git a/src/CadViewerContainer.tsx b/src/CadViewerContainer.tsx index 91e627e1..593c40fd 100644 --- a/src/CadViewerContainer.tsx +++ b/src/CadViewerContainer.tsx @@ -65,7 +65,7 @@ export const CadViewerContainer = forwardRef< const { mainCameraRef, handleControlsChange, controller } = useCameraController() - const { shadowsEnabled } = useRenderingMode() + const { lightingEnabled, shadowsEnabled } = useRenderingMode() const { handleCameraCreated, handleControlsChange: handleSessionControlsChange, @@ -122,11 +122,13 @@ export const CadViewerContainer = forwardRef< }} /> )} - + {lightingEnabled && ( + + )} { const { visibility, setLayerVisibility } = useLayerVisibility() - const { renderingMode, setRenderingMode } = useRenderingMode() + const { lightingEnabled, setLightingEnabled } = useRenderingMode() const [appearanceSubOpen, setAppearanceSubOpen] = useState(false) const [hoveredItem, setHoveredItem] = useState(null) @@ -109,45 +109,22 @@ export const AppearanceMenu = () => { style={{ ...itemStyles, backgroundColor: - hoveredItem === "engineeringMode" ? "#404040" : "transparent", + hoveredItem === "lighting" ? "#404040" : "transparent", }} onSelect={(e) => e.preventDefault()} onPointerDown={(e) => { e.preventDefault() - setRenderingMode("engineering") + setLightingEnabled(!lightingEnabled) }} - onMouseEnter={() => setHoveredItem("engineeringMode")} + onMouseEnter={() => setHoveredItem("lighting")} onMouseLeave={() => setHoveredItem(null)} - onTouchStart={() => setHoveredItem("engineeringMode")} + onTouchStart={() => setHoveredItem("lighting")} > - {renderingMode === "engineering" && } + {lightingEnabled && } - Engineering Mode - - - - e.preventDefault()} - onPointerDown={(e) => { - e.preventDefault() - setRenderingMode("realistic") - }} - onMouseEnter={() => setHoveredItem("realisticMode")} - onMouseLeave={() => setHoveredItem(null)} - onTouchStart={() => setHoveredItem("realisticMode")} - > - - {renderingMode === "realistic" && } - - - Lighting Mode + Lighting diff --git a/src/contexts/RenderingModeContext.tsx b/src/contexts/RenderingModeContext.tsx index 249043d9..08298534 100644 --- a/src/contexts/RenderingModeContext.tsx +++ b/src/contexts/RenderingModeContext.tsx @@ -12,10 +12,13 @@ export type RenderingMode = "engineering" | "realistic" interface RenderingModeContextType { renderingMode: RenderingMode setRenderingMode: (mode: RenderingMode) => 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" @@ -25,6 +28,11 @@ const readStoredRenderingMode = (): RenderingMode => { : "engineering" } +const readStoredLightingEnabled = (): boolean => { + if (typeof window === "undefined") return true + return window.localStorage.getItem(LIGHTING_STORAGE_KEY) !== "false" +} + const RenderingModeContext = createContext< RenderingModeContextType | undefined >(undefined) @@ -35,6 +43,9 @@ export const RenderingModeProvider: React.FC<{ const [renderingMode, setRenderingModeState] = useState( readStoredRenderingMode, ) + const [lightingEnabled, setLightingEnabled] = useState( + readStoredLightingEnabled, + ) const setRenderingMode = useCallback((mode: RenderingMode) => { setRenderingModeState(mode) @@ -44,13 +55,22 @@ export const RenderingModeProvider: React.FC<{ window.localStorage.setItem(STORAGE_KEY, renderingMode) }, [renderingMode]) + useEffect(() => { + window.localStorage.setItem( + LIGHTING_STORAGE_KEY, + lightingEnabled ? "true" : "false", + ) + }, [lightingEnabled]) + const value = useMemo( () => ({ renderingMode, setRenderingMode, - shadowsEnabled: renderingMode === "realistic", + lightingEnabled, + setLightingEnabled, + shadowsEnabled: lightingEnabled && renderingMode === "realistic", }), - [renderingMode, setRenderingMode], + [lightingEnabled, renderingMode, setRenderingMode], ) return ( From 039dfb1a34bec5dd00e9265a20c5095fb818b3f8 Mon Sep 17 00:00:00 2001 From: seveibar Date: Sat, 4 Jul 2026 16:04:59 -0700 Subject: [PATCH 6/8] wip --- src/CadViewerContainer.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/CadViewerContainer.tsx b/src/CadViewerContainer.tsx index 593c40fd..91e627e1 100644 --- a/src/CadViewerContainer.tsx +++ b/src/CadViewerContainer.tsx @@ -65,7 +65,7 @@ export const CadViewerContainer = forwardRef< const { mainCameraRef, handleControlsChange, controller } = useCameraController() - const { lightingEnabled, shadowsEnabled } = useRenderingMode() + const { shadowsEnabled } = useRenderingMode() const { handleCameraCreated, handleControlsChange: handleSessionControlsChange, @@ -122,13 +122,11 @@ export const CadViewerContainer = forwardRef< }} /> )} - {lightingEnabled && ( - - )} + Date: Sat, 4 Jul 2026 16:49:32 -0700 Subject: [PATCH 7/8] wip --- src/CadViewerJscad.tsx | 7 + src/CadViewerManifold.tsx | 13 +- src/hooks/useManifoldBoardBuilder.ts | 15 +- src/react-three/Lights.tsx | 5 + .../create-combined-board-textures.ts | 2 +- src/textures/create-three-texture-meshes.ts | 12 +- src/three-components/JscadBoardTextures.tsx | 31 +-- src/utils/create-board-texture-material.ts | 177 ++++++++++++++++++ stories/keypad.stories.tsx | 13 +- 9 files changed, 250 insertions(+), 25 deletions(-) create mode 100644 src/utils/create-board-texture-material.ts diff --git a/src/CadViewerJscad.tsx b/src/CadViewerJscad.tsx index 41056d21..3be4dfca 100644 --- a/src/CadViewerJscad.tsx +++ b/src/CadViewerJscad.tsx @@ -15,6 +15,7 @@ import { VisibleSTLModel } from "./three-components/VisibleSTLModel" import { ThreeErrorBoundary } from "./three-components/ThreeErrorBoundary" import { JscadBoardTextures } from "./three-components/JscadBoardTextures" import { addFauxBoardIfNeeded } from "./utils/preprocess-circuit-json" +import type { TextureResolutionOptions } from "./utils/layer-texture-resolution" interface Props { /** @@ -28,6 +29,8 @@ interface Props { onUserInteraction?: () => void onCameraControllerReady?: (controller: CameraController | null) => void resolveStaticAsset?: (modelUrl: string) => string + textureResolution?: number + textureResolutionOptions?: TextureResolutionOptions } export const CadViewerJscad = forwardRef< @@ -43,6 +46,8 @@ export const CadViewerJscad = forwardRef< onUserInteraction, onCameraControllerReady, resolveStaticAsset, + textureResolution, + textureResolutionOptions, }, ref, ) => { @@ -148,6 +153,8 @@ export const CadViewerJscad = forwardRef< circuitJson={internalCircuitJson} pcbThickness={pcbThickness} isFaux={isFauxBoard} + textureResolution={textureResolution} + textureResolutionOptions={textureResolutionOptions} /> {cad_components.map((cad_component) => ( void onCameraControllerReady?: (controller: CameraController | null) => void resolveStaticAsset?: (modelUrl: string) => string + textureResolution?: number + textureResolutionOptions?: TextureResolutionOptions } & ( | { circuitJson: AnyCircuitElement[]; children?: React.ReactNode } | { circuitJson?: never; children: React.ReactNode } @@ -144,6 +147,8 @@ const CadViewerManifold: React.FC = ({ children, onCameraControllerReady, resolveStaticAsset, + textureResolution, + textureResolutionOptions, }) => { const childrenCircuitJson = useConvertChildrenToCircuitJson(children) const circuitJson = useMemo(() => { @@ -243,7 +248,13 @@ try { isLoading: builderIsLoading, boardData, isFauxBoard, - } = useManifoldBoardBuilder(manifoldJSModule, circuitJson, visibility) + } = useManifoldBoardBuilder( + manifoldJSModule, + circuitJson, + visibility, + textureResolution, + textureResolutionOptions, + ) const geometryMeshes = useMemo(() => createGeometryMeshes(geoms), [geoms]) const textureMeshes = useMemo( diff --git a/src/hooks/useManifoldBoardBuilder.ts b/src/hooks/useManifoldBoardBuilder.ts index 0b375fa5..5701cc8c 100644 --- a/src/hooks/useManifoldBoardBuilder.ts +++ b/src/hooks/useManifoldBoardBuilder.ts @@ -13,7 +13,10 @@ import { colors as defaultColors, TRACE_TEXTURE_RESOLUTION, } from "../geoms/constants" -import { getLayerTextureResolution } from "../utils/layer-texture-resolution" +import { + getLayerTextureResolution, + type TextureResolutionOptions, +} from "../utils/layer-texture-resolution" import { createManifoldBoard } from "../utils/manifold/create-manifold-board" import { processCutoutsForManifold } from "../utils/manifold/process-cutouts" import { processNonPlatedHolesForManifold } from "../utils/manifold/process-non-plated-holes" @@ -56,6 +59,8 @@ export const useManifoldBoardBuilder = ( manifoldJSModule: ManifoldToplevel | null, circuitJson: AnyCircuitElement[], visibility: LayerVisibilityState, + textureResolution = TRACE_TEXTURE_RESOLUTION, + textureResolutionOptions?: TextureResolutionOptions, ): UseManifoldBoardBuilderResult => { const [geoms, setGeoms] = useState(null) const [pcbThickness, setPcbThickness] = useState(null) @@ -103,8 +108,12 @@ export const useManifoldBoardBuilder = ( const traceTextureResolution = useMemo(() => { if (!boardData) return TRACE_TEXTURE_RESOLUTION - return getLayerTextureResolution(boardData, TRACE_TEXTURE_RESOLUTION) - }, [boardData]) + return getLayerTextureResolution( + boardData, + textureResolution, + textureResolutionOptions, + ) + }, [boardData, textureResolution, textureResolutionOptions]) useEffect(() => { if (!manifoldJSModule || !boardData) { diff --git a/src/react-three/Lights.tsx b/src/react-three/Lights.tsx index fd68110d..e6f93922 100644 --- a/src/react-three/Lights.tsx +++ b/src/react-three/Lights.tsx @@ -94,6 +94,11 @@ export const Lights: React.FC = ({ lightDistance * 0.45, lightDistance * 0.35, ]) + addDirectionalLight("cad-viewer-lower-fill-light", 0xffffff, 0.24, [ + lightDistance * 0.15, + -lightDistance * 0.1, + -lightDistance * 0.55, + ]) addDirectionalLight("cad-viewer-rim-light", 0xffffff, 1.1, [ -lightDistance * 0.25, lightDistance * 0.75, diff --git a/src/textures/create-combined-board-textures.ts b/src/textures/create-combined-board-textures.ts index 8b4bc215..3fd477df 100644 --- a/src/textures/create-combined-board-textures.ts +++ b/src/textures/create-combined-board-textures.ts @@ -60,7 +60,7 @@ const applySoldermaskSurfaceFilter = ( const v = y / maxY const diagonalLight = u * 0.62 + (1 - v) * 0.38 const broadVariation = Math.sin((u * 1.15 + v * 0.35) * Math.PI) * 0.04 - const lightFactor = 0.74 + diagonalLight * 0.18 + broadVariation + const lightFactor = 0.82 + diagonalLight * 0.17 + broadVariation const whiteReflection = options.includeReflection ? (() => { diff --git a/src/textures/create-three-texture-meshes.ts b/src/textures/create-three-texture-meshes.ts index dfd4ece6..152a54a6 100644 --- a/src/textures/create-three-texture-meshes.ts +++ b/src/textures/create-three-texture-meshes.ts @@ -1,8 +1,8 @@ import type { PcbBoard } from "circuit-json" import * as THREE from "three" -import { FAUX_BOARD_OPACITY } from "../geoms/constants" import { configureObjectShadows } from "../utils/configure-object-shadows" import { createBoardShadowReceiverPlane } from "../utils/create-board-shadow-receiver-plane" +import { createBoardTextureMaterial } from "../utils/create-board-texture-material" import { calculateOutlineBounds } from "../utils/outline-bounds" import type { CombinedBoardTextures } from "./index" @@ -36,16 +36,14 @@ function createTexturePlane( boardOutlineBounds.width, boardOutlineBounds.height, ) - const material = new THREE.MeshBasicMaterial({ - map: texture, - transparent: true, - alphaTest: 0.08, + const material = createBoardTextureMaterial({ + texture, side: THREE.FrontSide, depthWrite: true, polygonOffset: usePolygonOffset, - polygonOffsetFactor: usePolygonOffset ? -4 : 0, // Increased for better z-fighting prevention + polygonOffsetFactor: usePolygonOffset ? -4 : 0, polygonOffsetUnits: usePolygonOffset ? -4 : 0, - opacity: isFaux ? FAUX_BOARD_OPACITY : 1.0, + isFaux, }) const mesh = new THREE.Mesh(planeGeom, material) mesh.position.set( diff --git a/src/three-components/JscadBoardTextures.tsx b/src/three-components/JscadBoardTextures.tsx index c5903bfb..6a601311 100644 --- a/src/three-components/JscadBoardTextures.tsx +++ b/src/three-components/JscadBoardTextures.tsx @@ -5,26 +5,31 @@ import { createCombinedBoardTextures } from "src/textures" import * as THREE from "three" import { useLayerVisibility } from "../contexts/LayerVisibilityContext" import { useRenderingMode } from "../contexts/RenderingModeContext" -import { - FAUX_BOARD_OPACITY, - TRACE_TEXTURE_RESOLUTION, -} from "../geoms/constants" +import { 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 { getLayerTextureResolution } from "../utils/layer-texture-resolution" +import { createBoardTextureMaterial } from "../utils/create-board-texture-material" +import { + getLayerTextureResolution, + type TextureResolutionOptions, +} from "../utils/layer-texture-resolution" import { calculateOutlineBounds } from "../utils/outline-bounds" interface JscadBoardTexturesProps { circuitJson: AnyCircuitElement[] pcbThickness: number isFaux?: boolean + textureResolution?: number + textureResolutionOptions?: TextureResolutionOptions } export function JscadBoardTextures({ circuitJson, pcbThickness, isFaux = false, + textureResolution, + textureResolutionOptions, }: JscadBoardTexturesProps) { const { rootObject } = useThree() const { visibility } = useLayerVisibility() @@ -64,8 +69,12 @@ export function JscadBoardTextures({ const traceTextureResolution = useMemo(() => { if (!boardData) return TRACE_TEXTURE_RESOLUTION - return getLayerTextureResolution(boardData, TRACE_TEXTURE_RESOLUTION) - }, [boardData]) + return getLayerTextureResolution( + boardData, + textureResolution ?? TRACE_TEXTURE_RESOLUTION, + textureResolutionOptions, + ) + }, [boardData, textureResolution, textureResolutionOptions]) const textures = useMemo(() => { if (!boardData || !boardData.width || !boardData.height) return null @@ -126,16 +135,14 @@ export function JscadBoardTextures({ boardOutlineBounds.width, boardOutlineBounds.height, ) - const material = new THREE.MeshBasicMaterial({ - map: texture, - transparent: true, - alphaTest: 0.08, + const material = createBoardTextureMaterial({ + texture, side: THREE.FrontSide, depthWrite, polygonOffset: usePolygonOffset, polygonOffsetFactor: usePolygonOffset ? -4 : 0, polygonOffsetUnits: usePolygonOffset ? -4 : 0, - opacity: isFaux ? FAUX_BOARD_OPACITY : 1.0, + isFaux, }) const mesh = new THREE.Mesh(planeGeom, material) mesh.position.set( diff --git a/src/utils/create-board-texture-material.ts b/src/utils/create-board-texture-material.ts new file mode 100644 index 00000000..b086d82c --- /dev/null +++ b/src/utils/create-board-texture-material.ts @@ -0,0 +1,177 @@ +import * as THREE from "three" +import { FAUX_BOARD_OPACITY } from "../geoms/constants" + +interface CreateBoardTextureMaterialOptions { + texture: THREE.CanvasTexture + side?: THREE.Side + depthWrite?: boolean + polygonOffset?: boolean + polygonOffsetFactor?: number + polygonOffsetUnits?: number + isFaux?: boolean +} + +const PLAIN_SOLDERMASK_HEIGHT = 0.22 +const MASKED_COPPER_HEIGHT = 0.58 +const EXPOSED_COPPER_HEIGHT = 0.86 +const invertSurfaceHeight = (height: number) => 1 - height + +const getBoardSurfaceHeight = (r: number, g: number, b: number): number => { + const isExposedCopper = + r > 120 && g > 70 && b < 120 && r > g * 1.08 && g > b * 1.25 + + if (isExposedCopper) return EXPOSED_COPPER_HEIGHT + + const isGreenSoldermask = + g > r * 1.3 && g > b * 1.05 && r < 85 && g < 140 && b < 90 + + if (!isGreenSoldermask) return PLAIN_SOLDERMASK_HEIGHT + + const isCopperUnderSoldermask = r > b * 0.62 || g > 34 + return isCopperUnderSoldermask + ? MASKED_COPPER_HEIGHT + : PLAIN_SOLDERMASK_HEIGHT +} + +const createHeightTexture = ( + canvas: HTMLCanvasElement, +): THREE.CanvasTexture => { + 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 +} + +const createBoardReliefTextures = ( + texture: THREE.CanvasTexture, +): { bumpMap: THREE.CanvasTexture; normalMap: 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 imageData = sourceCtx.getImageData( + 0, + 0, + sourceCanvas.width, + sourceCanvas.height, + ) + const data = imageData.data + const heights = new Float32Array(sourceCanvas.width * sourceCanvas.height) + + for (let i = 0; i < data.length; i += 4) { + const pixelIndex = i / 4 + const alpha = data[i + 3] ?? 0 + if (alpha < 16) { + const height = invertSurfaceHeight(PLAIN_SOLDERMASK_HEIGHT) + const heightChannel = height * 255 + data[i] = heightChannel + data[i + 1] = heightChannel + data[i + 2] = heightChannel + data[i + 3] = 0 + heights[pixelIndex] = height + continue + } + + const r = data[i] ?? 0 + const g = data[i + 1] ?? 0 + const b = data[i + 2] ?? 0 + const height = invertSurfaceHeight(getBoardSurfaceHeight(r, g, b)) + heights[pixelIndex] = height + + const heightChannel = height * 255 + data[i] = heightChannel + data[i + 1] = heightChannel + data[i + 2] = heightChannel + data[i + 3] = 255 + } + + 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 strength = 8 + + const getHeight = (x: number, y: number) => { + const clampedX = Math.max(0, Math.min(sourceCanvas.width - 1, x)) + const clampedY = Math.max(0, Math.min(sourceCanvas.height - 1, y)) + return heights[clampedY * sourceCanvas.width + clampedX] ?? 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)) * strength + const dy = (getHeight(x, y + 1) - getHeight(x, y - 1)) * strength + 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: createHeightTexture(bumpCanvas), + normalMap: createHeightTexture(normalCanvas), + } +} + +export const createBoardTextureMaterial = ({ + texture, + side = THREE.FrontSide, + depthWrite = true, + polygonOffset = false, + polygonOffsetFactor = 0, + polygonOffsetUnits = 0, + isFaux = false, +}: CreateBoardTextureMaterialOptions): THREE.MeshStandardMaterial => { + texture.colorSpace = THREE.SRGBColorSpace + texture.needsUpdate = true + + const reliefTextures = createBoardReliefTextures(texture) + + return new THREE.MeshStandardMaterial({ + map: texture, + emissiveMap: texture, + emissive: new THREE.Color(0xffffff), + emissiveIntensity: 0.2, + bumpMap: reliefTextures?.bumpMap ?? null, + bumpScale: 1.2, + normalMap: reliefTextures?.normalMap ?? null, + normalScale: new THREE.Vector2(1.8, 1.8), + transparent: true, + alphaTest: 0.08, + side, + depthWrite, + polygonOffset, + polygonOffsetFactor, + polygonOffsetUnits, + opacity: isFaux ? FAUX_BOARD_OPACITY : 1.0, + metalness: 0, + roughness: 0.5, + }) +} diff --git a/stories/keypad.stories.tsx b/stories/keypad.stories.tsx index 1ab2a7fc..d6a47e55 100644 --- a/stories/keypad.stories.tsx +++ b/stories/keypad.stories.tsx @@ -1,7 +1,18 @@ import { CadViewer } from "src/CadViewer" import keypad from "./assets/keypad.json" -export const Default = () => +const keypadTextureResolutionOptions = { + maxTexturePixels: 16_000_000, + maxTextureDimension: 8192, +} as const + +export const Default = () => ( + +) export default { title: "Keypad", From 0718b387b77c252f48e3f77998937d879e5e8735 Mon Sep 17 00:00:00 2001 From: seveibar Date: Sat, 4 Jul 2026 17:53:24 -0700 Subject: [PATCH 8/8] conditional bump map --- src/CadViewerManifold.tsx | 12 ++++++++++-- src/textures/create-combined-board-textures.ts | 9 +++++++-- src/textures/create-three-texture-meshes.ts | 7 ++++++- src/three-components/JscadBoardTextures.tsx | 12 ++++++++++-- src/utils/create-board-texture-material.ts | 6 +++++- 5 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/CadViewerManifold.tsx b/src/CadViewerManifold.tsx index 9d3d2ee0..54fcc70e 100644 --- a/src/CadViewerManifold.tsx +++ b/src/CadViewerManifold.tsx @@ -161,7 +161,7 @@ const CadViewerManifold: React.FC = ({ string | null >(null) const { visibility } = useLayerVisibility() - const { shadowsEnabled } = useRenderingMode() + const { lightingEnabled, shadowsEnabled } = useRenderingMode() useEffect(() => { if ( @@ -260,9 +260,17 @@ try { const textureMeshes = useMemo( () => createTextureMeshes(textures, boardData, pcbThickness, isFauxBoard, { + lightingEnabled, shadowsEnabled, }), - [textures, boardData, pcbThickness, isFauxBoard, shadowsEnabled], + [ + textures, + boardData, + pcbThickness, + isFauxBoard, + lightingEnabled, + shadowsEnabled, + ], ) const cadComponents = useMemo( diff --git a/src/textures/create-combined-board-textures.ts b/src/textures/create-combined-board-textures.ts index 3fd477df..a7694d8e 100644 --- a/src/textures/create-combined-board-textures.ts +++ b/src/textures/create-combined-board-textures.ts @@ -33,7 +33,7 @@ const applySoldermaskSurfaceFilter = ( ctx: CanvasRenderingContext2D, width: number, height: number, - options: { includeReflection?: boolean } = {}, + options: { includeReflection?: boolean; brightnessBoost?: number } = {}, ) => { const imageData = ctx.getImageData(0, 0, width, height) const data = imageData.data @@ -60,7 +60,11 @@ const applySoldermaskSurfaceFilter = ( const v = y / maxY const diagonalLight = u * 0.62 + (1 - v) * 0.38 const broadVariation = Math.sin((u * 1.15 + v * 0.35) * Math.PI) * 0.04 - const lightFactor = 0.82 + diagonalLight * 0.17 + broadVariation + const lightFactor = + 0.82 + + diagonalLight * 0.17 + + broadVariation + + (options.brightnessBoost ?? 0) const whiteReflection = options.includeReflection ? (() => { @@ -128,6 +132,7 @@ const createCombinedTexture = ({ applySoldermaskSurfaceFilter(ctx, canvasWidth, canvasHeight, { includeReflection: layer === "top", + brightnessBoost: layer === "bottom" ? 0.08 : 0, }) const combinedTexture = new THREE.CanvasTexture(canvas) diff --git a/src/textures/create-three-texture-meshes.ts b/src/textures/create-three-texture-meshes.ts index 152a54a6..eb316182 100644 --- a/src/textures/create-three-texture-meshes.ts +++ b/src/textures/create-three-texture-meshes.ts @@ -13,6 +13,7 @@ interface TexturePlaneConfig { usePolygonOffset?: boolean renderOrder?: number isFaux?: boolean + reliefEnabled?: boolean } function createTexturePlane( @@ -26,6 +27,7 @@ function createTexturePlane( usePolygonOffset = false, renderOrder = 0, isFaux = false, + reliefEnabled = true, } = config if (!texture) return null @@ -44,6 +46,7 @@ function createTexturePlane( polygonOffsetFactor: usePolygonOffset ? -4 : 0, polygonOffsetUnits: usePolygonOffset ? -4 : 0, isFaux, + reliefEnabled, }) const mesh = new THREE.Mesh(planeGeom, material) mesh.position.set( @@ -65,7 +68,7 @@ export function createTextureMeshes( boardData: PcbBoard | null, pcbThickness: number | null, isFaux: boolean = false, - options: { shadowsEnabled?: boolean } = {}, + options: { shadowsEnabled?: boolean; lightingEnabled?: boolean } = {}, ): THREE.Mesh[] { const meshes: THREE.Mesh[] = [] if (!textures || !boardData || pcbThickness === null) return meshes @@ -80,6 +83,7 @@ export function createTextureMeshes( usePolygonOffset: true, renderOrder: 1, isFaux, + reliefEnabled: options.lightingEnabled ?? true, }, boardData, ) @@ -102,6 +106,7 @@ export function createTextureMeshes( usePolygonOffset: true, renderOrder: 1, isFaux, + reliefEnabled: options.lightingEnabled ?? true, }, boardData, ) diff --git a/src/three-components/JscadBoardTextures.tsx b/src/three-components/JscadBoardTextures.tsx index 6a601311..07a0fb4a 100644 --- a/src/three-components/JscadBoardTextures.tsx +++ b/src/three-components/JscadBoardTextures.tsx @@ -33,7 +33,7 @@ export function JscadBoardTextures({ }: JscadBoardTexturesProps) { const { rootObject } = useThree() const { visibility } = useLayerVisibility() - const { shadowsEnabled } = useRenderingMode() + const { lightingEnabled, shadowsEnabled } = useRenderingMode() const boardData = useMemo(() => { // Check for panel first @@ -143,6 +143,7 @@ export function JscadBoardTextures({ polygonOffsetFactor: usePolygonOffset ? -4 : 0, polygonOffsetUnits: usePolygonOffset ? -4 : 0, isFaux, + reliefEnabled: lightingEnabled, }) const mesh = new THREE.Mesh(planeGeom, material) mesh.position.set( @@ -226,7 +227,14 @@ export function JscadBoardTextures({ textures.topBoard?.dispose() textures.bottomBoard?.dispose() } - }, [rootObject, boardData, textures, pcbThickness, shadowsEnabled]) + }, [ + rootObject, + boardData, + textures, + pcbThickness, + lightingEnabled, + shadowsEnabled, + ]) return null } diff --git a/src/utils/create-board-texture-material.ts b/src/utils/create-board-texture-material.ts index b086d82c..70274e79 100644 --- a/src/utils/create-board-texture-material.ts +++ b/src/utils/create-board-texture-material.ts @@ -9,6 +9,7 @@ interface CreateBoardTextureMaterialOptions { polygonOffsetFactor?: number polygonOffsetUnits?: number isFaux?: boolean + reliefEnabled?: boolean } const PLAIN_SOLDERMASK_HEIGHT = 0.22 @@ -148,11 +149,14 @@ export const createBoardTextureMaterial = ({ polygonOffsetFactor = 0, polygonOffsetUnits = 0, isFaux = false, + reliefEnabled = true, }: CreateBoardTextureMaterialOptions): THREE.MeshStandardMaterial => { texture.colorSpace = THREE.SRGBColorSpace texture.needsUpdate = true - const reliefTextures = createBoardReliefTextures(texture) + const reliefTextures = reliefEnabled + ? createBoardReliefTextures(texture) + : null return new THREE.MeshStandardMaterial({ map: texture,