From 497151fabbbf80521ad30ad59a59dcc43865720b Mon Sep 17 00:00:00 2001 From: AnasSarkiz Date: Tue, 4 Aug 2026 17:15:06 +0200 Subject: [PATCH 1/6] Render PCBs with Flux-style studio materials --- src/CadViewerContainer.tsx | 19 +- src/CadViewerManifold.tsx | 17 +- src/board-surface-textures.ts | 24 ++ src/components/AppearanceMenu.tsx | 35 +- src/geoms/constants.ts | 8 +- src/react-three/Lights.tsx | 105 ++++-- .../create-combined-board-textures.ts | 82 ++++- .../create-copper-pour-texture-for-layer.ts | 13 +- src/textures/create-three-texture-meshes.ts | 52 ++- src/three-components/JscadBoardTextures.tsx | 50 ++- src/utils/create-board-material.ts | 14 +- src/utils/create-board-relief-textures.ts | 311 ++++++++++++++++++ .../manifold/create-three-geometry-meshes.ts | 3 + tests/board-relief-textures.test.ts | 155 +++++++++ 14 files changed, 823 insertions(+), 65 deletions(-) create mode 100644 src/board-surface-textures.ts create mode 100644 src/utils/create-board-relief-textures.ts create mode 100644 tests/board-relief-textures.test.ts diff --git a/src/CadViewerContainer.tsx b/src/CadViewerContainer.tsx index 91e627e1..cbfd361a 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 { renderingMode, shadowsEnabled } = useRenderingMode() const { handleCameraCreated, handleControlsChange: handleSessionControlsChange, @@ -126,14 +126,17 @@ export const CadViewerContainer = forwardRef< boardDimensions={boardDimensions} boardCenter={boardCenter} shadowsEnabled={shadowsEnabled} + renderingMode={renderingMode} /> - + {renderingMode !== "realistic" && ( + + )} {children}
= ({ string | null >(null) const { visibility } = useLayerVisibility() - const { shadowsEnabled } = useRenderingMode() + const { renderingMode, shadowsEnabled } = useRenderingMode() useEffect(() => { if ( @@ -246,13 +246,24 @@ try { isFauxBoard, } = useManifoldBoardBuilder(manifoldJSModule, circuitJson, visibility) - const geometryMeshes = useMemo(() => createGeometryMeshes(geoms), [geoms]) + const geometryMeshes = useMemo( + () => createGeometryMeshes(geoms, renderingMode), + [geoms, renderingMode], + ) const textureMeshes = useMemo( () => createTextureMeshes(textures, boardData, pcbThickness, isFauxBoard, { shadowsEnabled, + renderingMode, }), - [textures, boardData, pcbThickness, isFauxBoard, shadowsEnabled], + [ + textures, + boardData, + pcbThickness, + isFauxBoard, + renderingMode, + shadowsEnabled, + ], ) 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..def4ee80 100644 --- a/src/components/AppearanceMenu.tsx +++ b/src/components/AppearanceMenu.tsx @@ -62,7 +62,12 @@ const iconContainerStyles: React.CSSProperties = { export const AppearanceMenu = () => { const { visibility, setLayerVisibility } = useLayerVisibility() - const { lightingEnabled, setLightingEnabled } = useRenderingMode() + const { + renderingMode, + setRenderingMode, + lightingEnabled, + setLightingEnabled, + } = useRenderingMode() const [appearanceSubOpen, setAppearanceSubOpen] = useState(false) const [hoveredItem, setHoveredItem] = useState(null) @@ -105,6 +110,34 @@ export const AppearanceMenu = () => { collisionPadding={10} avoidCollisions={true} > + e.preventDefault()} + onPointerDown={(e) => { + e.preventDefault() + if (renderingMode === "realistic") { + setRenderingMode("engineering") + } else { + setRenderingMode("realistic") + setLightingEnabled(true) + } + }} + onMouseEnter={() => setHoveredItem("presentation")} + onMouseLeave={() => setHoveredItem(null)} + onTouchStart={() => setHoveredItem("presentation")} + > + + {renderingMode === "realistic" && } + + + Realistic Presentation Mode + + + = ({ boardDimensions, boardCenter, shadowsEnabled = false, + renderingMode = "engineering", }) => { const { scene } = useThree() @@ -28,13 +32,21 @@ export const Lights: React.FC = ({ ) const shadowHalfSize = largestBoardDimension * 0.8 const lightDistance = largestBoardDimension - const keyLightDistance = largestBoardDimension * 8.3 + const isRealistic = renderingMode === "realistic" + const keyLightDistance = largestBoardDimension * (isRealistic ? 1.7 : 8.3) - const ambientLight = new THREE.AmbientLight(0xffffff, 0.18) + const ambientLight = new THREE.AmbientLight( + isRealistic ? 0xf6fbf8 : 0xffffff, + isRealistic ? 0.22 : 0.18, + ) ambientLight.name = "cad-viewer-soft-ambient" rig.add(ambientLight) - const hemisphereLight = new THREE.HemisphereLight(0xdde8ff, 0x1f211d, 0.45) + const hemisphereLight = new THREE.HemisphereLight( + isRealistic ? 0xe4f1ed : 0xdde8ff, + isRealistic ? 0x18221d : 0x1f211d, + isRealistic ? 0.2 : 0.45, + ) hemisphereLight.name = "cad-viewer-hemisphere" rig.add(hemisphereLight) @@ -78,30 +90,71 @@ export const Lights: React.FC = ({ rig.add(light.target) } - addDirectionalLight( - "cad-viewer-key-light", - 0xffffff, - 2.4, - [ - keyLightDistance * 0.22, - -keyLightDistance * 0.28, - keyLightDistance * 1.15, - ], - 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, - lightDistance * 0.75, - lightDistance * 0.6, - ]) + if (isRealistic) { + addDirectionalLight( + "cad-viewer-key-light", + 0xfff8ee, + 1.35, + [ + keyLightDistance * 0.68, + -keyLightDistance * 0.8, + keyLightDistance * 1.08, + ], + shadowsEnabled, + ) + addDirectionalLight("cad-viewer-fill-light", 0xdce8f2, 0.25, [ + -lightDistance * 0.85, + lightDistance * 0.55, + lightDistance * 0.75, + ]) + addDirectionalLight("cad-viewer-rim-light", 0xb9ead3, 0.5, [ + -lightDistance * 0.35, + lightDistance * 0.85, + lightDistance * 0.95, + ]) + } else { + addDirectionalLight( + "cad-viewer-key-light", + 0xffffff, + 2.4, + [ + keyLightDistance * 0.22, + -keyLightDistance * 0.28, + keyLightDistance * 1.15, + ], + 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, + lightDistance * 0.75, + lightDistance * 0.6, + ]) + } return rig - }, [boardCenter, boardDimensions, shadowsEnabled]) + }, [boardCenter, boardDimensions, renderingMode, shadowsEnabled]) + + useEffect(() => { + if (renderingMode !== "realistic") return + + const previousBackground = scene.background + const previousEnvironment = scene.environment + scene.background = new THREE.Color(0x101310) + // 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 + } + }, [renderingMode, scene]) useEffect(() => { if (!scene) return 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..80f53b7f 100644 --- a/src/textures/create-three-texture-meshes.ts +++ b/src/textures/create-three-texture-meshes.ts @@ -1,18 +1,23 @@ import type { PcbBoard } from "circuit-json" import * as THREE from "three" +import { REALISTIC_BOARD_SURFACE_MATERIAL } from "../board-surface-textures" +import type { RenderingMode } from "../contexts/RenderingModeContext" import { FAUX_BOARD_OPACITY } from "../geoms/constants" import { configureObjectShadows } from "../utils/configure-object-shadows" +import { createBoardReliefTextures } from "../utils/create-board-relief-textures" import { createBoardShadowReceiverPlane } from "../utils/create-board-shadow-receiver-plane" 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 renderOrder?: number isFaux?: boolean + renderingMode?: RenderingMode } function createTexturePlane( @@ -21,11 +26,13 @@ function createTexturePlane( ): THREE.Mesh | null { const { texture, + maskedCopperMask, yOffset, isBottomLayer, usePolygonOffset = false, renderOrder = 0, isFaux = false, + renderingMode = "engineering", } = config if (!texture) return null @@ -36,7 +43,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,7 +54,34 @@ 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 = + renderingMode === "realistic" + ? createBoardReliefTextures(texture, maskedCopperMask) + : null + const material = + renderingMode === "realistic" + ? 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, + }) + : new THREE.MeshBasicMaterial(sharedMaterialOptions) const mesh = new THREE.Mesh(planeGeom, material) mesh.position.set( boardOutlineBounds.centerX, @@ -67,7 +102,10 @@ export function createTextureMeshes( boardData: PcbBoard | null, pcbThickness: number | null, isFaux: boolean = false, - options: { shadowsEnabled?: boolean } = {}, + options: { + shadowsEnabled?: boolean + renderingMode?: RenderingMode + } = {}, ): THREE.Mesh[] { const meshes: THREE.Mesh[] = [] if (!textures || !boardData || pcbThickness === null) return meshes @@ -77,16 +115,18 @@ export function createTextureMeshes( const topBoardMesh = createTexturePlane( { texture: textures.topBoard, + maskedCopperMask: textures.topMaskedCopper, yOffset: pcbThickness / 2 + SURFACE_OFFSET, isBottomLayer: false, usePolygonOffset: true, renderOrder: 1, isFaux, + renderingMode: options.renderingMode, }, boardData, ) if (topBoardMesh) meshes.push(topBoardMesh) - if (options.shadowsEnabled) { + if (options.shadowsEnabled && options.renderingMode !== "realistic") { meshes.push( createBoardShadowReceiverPlane({ boardData, @@ -99,16 +139,18 @@ export function createTextureMeshes( const bottomBoardMesh = createTexturePlane( { texture: textures.bottomBoard, + maskedCopperMask: textures.bottomMaskedCopper, yOffset: -pcbThickness / 2 - SURFACE_OFFSET, isBottomLayer: true, usePolygonOffset: true, renderOrder: 1, isFaux, + renderingMode: options.renderingMode, }, boardData, ) if (bottomBoardMesh) meshes.push(bottomBoardMesh) - if (options.shadowsEnabled) { + if (options.shadowsEnabled && options.renderingMode !== "realistic") { meshes.push( createBoardShadowReceiverPlane({ boardData, diff --git a/src/three-components/JscadBoardTextures.tsx b/src/three-components/JscadBoardTextures.tsx index c5903bfb..e99b13e5 100644 --- a/src/three-components/JscadBoardTextures.tsx +++ b/src/three-components/JscadBoardTextures.tsx @@ -5,12 +5,14 @@ import { createCombinedBoardTextures } from "src/textures" import * as THREE from "three" import { useLayerVisibility } from "../contexts/LayerVisibilityContext" import { useRenderingMode } from "../contexts/RenderingModeContext" +import { REALISTIC_BOARD_SURFACE_MATERIAL } from "../board-surface-textures" import { FAUX_BOARD_OPACITY, TRACE_TEXTURE_RESOLUTION, } from "../geoms/constants" import { useThree } from "../react-three/ThreeContext" import { configureObjectShadows } from "../utils/configure-object-shadows" +import { createBoardReliefTextures } from "../utils/create-board-relief-textures" import { createBoardShadowReceiverPlane } from "../utils/create-board-shadow-receiver-plane" import { getLayerTextureResolution } from "../utils/layer-texture-resolution" import { calculateOutlineBounds } from "../utils/outline-bounds" @@ -28,7 +30,7 @@ export function JscadBoardTextures({ }: JscadBoardTexturesProps) { const { rootObject } = useThree() const { visibility } = useLayerVisibility() - const { shadowsEnabled } = useRenderingMode() + const { renderingMode, shadowsEnabled } = useRenderingMode() const boardData = useMemo(() => { // Check for panel first @@ -111,6 +113,7 @@ export function JscadBoardTextures({ const createTexturePlane = ( texture: THREE.CanvasTexture | null | undefined, + maskedCopperMask: THREE.CanvasTexture | null | undefined, zOffset: number, isBottomLayer: boolean, name: string, @@ -126,7 +129,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,7 +140,32 @@ export function JscadBoardTextures({ polygonOffsetFactor: usePolygonOffset ? -4 : 0, polygonOffsetUnits: usePolygonOffset ? -4 : 0, opacity: isFaux ? FAUX_BOARD_OPACITY : 1.0, - }) + } satisfies THREE.MeshBasicMaterialParameters + const reliefTextures = + renderingMode === "realistic" + ? createBoardReliefTextures(texture, maskedCopperMask) + : null + const material = + renderingMode === "realistic" + ? 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, + }) + : new THREE.MeshBasicMaterial(sharedMaterialOptions) const mesh = new THREE.Mesh(planeGeom, material) mesh.position.set( boardOutlineBounds.centerX, @@ -159,6 +188,7 @@ export function JscadBoardTextures({ const topBoardMesh = createTexturePlane( textures.topBoard, + textures.topMaskedCopper, pcbThickness / 2 + SURFACE_OFFSET, false, "jscad-top-board-texture", @@ -168,7 +198,7 @@ export function JscadBoardTextures({ meshes.push(topBoardMesh) rootObject.add(topBoardMesh) } - if (shadowsEnabled) { + if (shadowsEnabled && renderingMode !== "realistic") { const topShadowReceiver = createBoardShadowReceiverPlane({ boardData, offset: pcbThickness / 2 + SHADOW_RECEIVER_OFFSET, @@ -182,6 +212,7 @@ export function JscadBoardTextures({ const bottomBoardMesh = createTexturePlane( textures.bottomBoard, + textures.bottomMaskedCopper, -pcbThickness / 2 - SURFACE_OFFSET, true, "jscad-bottom-board-texture", @@ -191,7 +222,7 @@ export function JscadBoardTextures({ meshes.push(bottomBoardMesh) rootObject.add(bottomBoardMesh) } - if (shadowsEnabled) { + if (shadowsEnabled && renderingMode !== "realistic") { const bottomShadowReceiver = createBoardShadowReceiverPlane({ boardData, offset: -pcbThickness / 2 - SHADOW_RECEIVER_OFFSET, @@ -219,7 +250,14 @@ export function JscadBoardTextures({ textures.topBoard?.dispose() textures.bottomBoard?.dispose() } - }, [rootObject, boardData, textures, pcbThickness, shadowsEnabled]) + }, [ + rootObject, + boardData, + textures, + pcbThickness, + renderingMode, + shadowsEnabled, + ]) return null } diff --git a/src/utils/create-board-material.ts b/src/utils/create-board-material.ts index e33851cf..9526dae1 100644 --- a/src/utils/create-board-material.ts +++ b/src/utils/create-board-material.ts @@ -1,5 +1,6 @@ import * as THREE from "three" import type { PcbBoard } from "circuit-json" +import type { RenderingMode } from "../contexts/RenderingModeContext" import { FAUX_BOARD_OPACITY } from "../geoms/constants" type BoardMaterialType = PcbBoard["material"] @@ -9,6 +10,7 @@ interface CreateBoardMaterialOptions { color: THREE.ColorRepresentation side?: THREE.Side isFaux?: boolean + renderingMode?: RenderingMode } const DEFAULT_SIDE = THREE.DoubleSide @@ -18,17 +20,21 @@ export const createBoardMaterial = ({ color, side = DEFAULT_SIDE, isFaux = false, + renderingMode = "engineering", }: 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: renderingMode === "realistic" ? 0x103a26 : color, side, metalness: 0.0, - roughness: 0.8, - specularIntensity: 0.2, + roughness: renderingMode === "realistic" ? 0.48 : 0.8, + specularIntensity: renderingMode === "realistic" ? 0.3 : 0.2, ior: 1.45, sheen: 0.0, - clearcoat: 0.0, + clearcoat: renderingMode === "realistic" ? 0.16 : 0.0, + clearcoatRoughness: renderingMode === "realistic" ? 0.3 : 0.0, 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..1b8e9c59 100644 --- a/src/utils/manifold/create-three-geometry-meshes.ts +++ b/src/utils/manifold/create-three-geometry-meshes.ts @@ -1,10 +1,12 @@ import * as THREE from "three" +import type { RenderingMode } from "../../contexts/RenderingModeContext" import type { ManifoldGeoms } from "../../hooks/useManifoldBoardBuilder" import { configureObjectShadows } from "../configure-object-shadows" import { createBoardMaterial } from "../create-board-material" export function createGeometryMeshes( geoms: ManifoldGeoms | null, + renderingMode: RenderingMode = "engineering", ): THREE.Mesh[] { const meshes: THREE.Mesh[] = [] if (!geoms) return meshes @@ -17,6 +19,7 @@ export function createGeometryMeshes( color: geoms.board.color, side: THREE.DoubleSide, isFaux: geoms.board.isFaux, + renderingMode, }), ) mesh.name = "board-geom" 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) + }) +}) From 3f0b6cbdf8db91e3d39f9f118055deebcc493f1b Mon Sep 17 00:00:00 2001 From: AnasSarkiz Date: Tue, 4 Aug 2026 17:49:28 +0200 Subject: [PATCH 2/6] Make Flux presentation rendering the default --- src/CadViewerContainer.tsx | 37 ++------ src/CadViewerManifold.tsx | 38 +++----- src/components/AppearanceMenu.tsx | 41 +------- src/contexts/RenderingModeContext.tsx | 40 +------- src/react-three/Lights.tsx | 89 ++++++------------ src/textures/create-three-texture-meshes.ts | 76 ++++----------- src/three-components/JscadBoardTextures.tsx | 93 ++++++------------- src/utils/create-board-material.ts | 15 ++- .../manifold/create-three-geometry-meshes.ts | 9 +- 9 files changed, 112 insertions(+), 326 deletions(-) diff --git a/src/CadViewerContainer.tsx b/src/CadViewerContainer.tsx index cbfd361a..25d7f70a 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 { Canvas } from "./react-three/Canvas" -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 { 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 { useFrame, useThree } from "./react-three/ThreeContext" 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 { renderingMode, shadowsEnabled } = useRenderingMode() + const { shadowsEnabled } = useRenderingMode() 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] @@ -126,17 +117,7 @@ export const CadViewerContainer = forwardRef< boardDimensions={boardDimensions} boardCenter={boardCenter} shadowsEnabled={shadowsEnabled} - renderingMode={renderingMode} /> - {renderingMode !== "realistic" && ( - - )} {children}
{ 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 { renderingMode, shadowsEnabled } = useRenderingMode() useEffect(() => { if ( @@ -246,24 +244,10 @@ try { isFauxBoard, } = useManifoldBoardBuilder(manifoldJSModule, circuitJson, visibility) - const geometryMeshes = useMemo( - () => createGeometryMeshes(geoms, renderingMode), - [geoms, renderingMode], - ) + const geometryMeshes = useMemo(() => createGeometryMeshes(geoms), [geoms]) const textureMeshes = useMemo( - () => - createTextureMeshes(textures, boardData, pcbThickness, isFauxBoard, { - shadowsEnabled, - renderingMode, - }), - [ - textures, - boardData, - pcbThickness, - isFauxBoard, - renderingMode, - shadowsEnabled, - ], + () => createTextureMeshes(textures, boardData, pcbThickness, isFauxBoard), + [textures, boardData, pcbThickness, isFauxBoard], ) const cadComponents = useMemo( diff --git a/src/components/AppearanceMenu.tsx b/src/components/AppearanceMenu.tsx index def4ee80..d0e00979 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 { 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,12 +62,7 @@ const iconContainerStyles: React.CSSProperties = { export const AppearanceMenu = () => { const { visibility, setLayerVisibility } = useLayerVisibility() - const { - renderingMode, - setRenderingMode, - lightingEnabled, - setLightingEnabled, - } = useRenderingMode() + const { lightingEnabled, setLightingEnabled } = useRenderingMode() const [appearanceSubOpen, setAppearanceSubOpen] = useState(false) const [hoveredItem, setHoveredItem] = useState(null) @@ -110,34 +105,6 @@ export const AppearanceMenu = () => { collisionPadding={10} avoidCollisions={true} > - e.preventDefault()} - onPointerDown={(e) => { - e.preventDefault() - if (renderingMode === "realistic") { - setRenderingMode("engineering") - } else { - setRenderingMode("realistic") - setLightingEnabled(true) - } - }} - onMouseEnter={() => setHoveredItem("presentation")} - onMouseLeave={() => setHoveredItem(null)} - onTouchStart={() => setHoveredItem("presentation")} - > - - {renderingMode === "realistic" && } - - - Realistic Presentation Mode - - - 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" @@ -40,21 +21,10 @@ const RenderingModeContext = createContext< 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, @@ -64,13 +34,11 @@ export const RenderingModeProvider: React.FC<{ const value = useMemo( () => ({ - renderingMode, - setRenderingMode, lightingEnabled, setLightingEnabled, - shadowsEnabled: lightingEnabled && renderingMode === "realistic", + shadowsEnabled: lightingEnabled, }), - [lightingEnabled, renderingMode, setRenderingMode], + [lightingEnabled], ) return ( diff --git a/src/react-three/Lights.tsx b/src/react-three/Lights.tsx index 4e868df8..47e91648 100644 --- a/src/react-three/Lights.tsx +++ b/src/react-three/Lights.tsx @@ -1,21 +1,18 @@ import type React from "react" import { useEffect, useMemo } from "react" import * as THREE from "three" -import type { RenderingMode } from "../contexts/RenderingModeContext" import { useThree } from "./ThreeContext" type LightsProps = { boardDimensions?: { width?: number; height?: number } boardCenter?: { x: number; y: number } shadowsEnabled?: boolean - renderingMode?: RenderingMode } export const Lights: React.FC = ({ boardDimensions, boardCenter, shadowsEnabled = false, - renderingMode = "engineering", }) => { const { scene } = useThree() @@ -32,21 +29,13 @@ export const Lights: React.FC = ({ ) const shadowHalfSize = largestBoardDimension * 0.8 const lightDistance = largestBoardDimension - const isRealistic = renderingMode === "realistic" - const keyLightDistance = largestBoardDimension * (isRealistic ? 1.7 : 8.3) + const keyLightDistance = largestBoardDimension * 1.7 - const ambientLight = new THREE.AmbientLight( - isRealistic ? 0xf6fbf8 : 0xffffff, - isRealistic ? 0.22 : 0.18, - ) + const ambientLight = new THREE.AmbientLight(0xf6fbf8, 0.22) ambientLight.name = "cad-viewer-soft-ambient" rig.add(ambientLight) - const hemisphereLight = new THREE.HemisphereLight( - isRealistic ? 0xe4f1ed : 0xdde8ff, - isRealistic ? 0x18221d : 0x1f211d, - isRealistic ? 0.2 : 0.45, - ) + const hemisphereLight = new THREE.HemisphereLight(0xe4f1ed, 0x18221d, 0.2) hemisphereLight.name = "cad-viewer-hemisphere" rig.add(hemisphereLight) @@ -90,58 +79,32 @@ export const Lights: React.FC = ({ rig.add(light.target) } - if (isRealistic) { - addDirectionalLight( - "cad-viewer-key-light", - 0xfff8ee, - 1.35, - [ - keyLightDistance * 0.68, - -keyLightDistance * 0.8, - keyLightDistance * 1.08, - ], - shadowsEnabled, - ) - addDirectionalLight("cad-viewer-fill-light", 0xdce8f2, 0.25, [ - -lightDistance * 0.85, - lightDistance * 0.55, - lightDistance * 0.75, - ]) - addDirectionalLight("cad-viewer-rim-light", 0xb9ead3, 0.5, [ - -lightDistance * 0.35, - lightDistance * 0.85, - lightDistance * 0.95, - ]) - } else { - addDirectionalLight( - "cad-viewer-key-light", - 0xffffff, - 2.4, - [ - keyLightDistance * 0.22, - -keyLightDistance * 0.28, - keyLightDistance * 1.15, - ], - 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, - lightDistance * 0.75, - lightDistance * 0.6, - ]) - } + addDirectionalLight( + "cad-viewer-key-light", + 0xfff8ee, + 1.35, + [ + keyLightDistance * 0.68, + -keyLightDistance * 0.8, + keyLightDistance * 1.08, + ], + shadowsEnabled, + ) + addDirectionalLight("cad-viewer-fill-light", 0xdce8f2, 0.25, [ + -lightDistance * 0.85, + lightDistance * 0.55, + lightDistance * 0.75, + ]) + addDirectionalLight("cad-viewer-rim-light", 0xb9ead3, 0.5, [ + -lightDistance * 0.35, + lightDistance * 0.85, + lightDistance * 0.95, + ]) return rig - }, [boardCenter, boardDimensions, renderingMode, shadowsEnabled]) + }, [boardCenter, boardDimensions, shadowsEnabled]) useEffect(() => { - if (renderingMode !== "realistic") return - const previousBackground = scene.background const previousEnvironment = scene.environment scene.background = new THREE.Color(0x101310) @@ -154,7 +117,7 @@ export const Lights: React.FC = ({ scene.background = previousBackground scene.environment = previousEnvironment } - }, [renderingMode, scene]) + }, [scene]) useEffect(() => { if (!scene) return diff --git a/src/textures/create-three-texture-meshes.ts b/src/textures/create-three-texture-meshes.ts index 80f53b7f..d363fd98 100644 --- a/src/textures/create-three-texture-meshes.ts +++ b/src/textures/create-three-texture-meshes.ts @@ -1,11 +1,9 @@ import type { PcbBoard } from "circuit-json" import * as THREE from "three" import { REALISTIC_BOARD_SURFACE_MATERIAL } from "../board-surface-textures" -import type { RenderingMode } from "../contexts/RenderingModeContext" import { FAUX_BOARD_OPACITY } from "../geoms/constants" import { configureObjectShadows } from "../utils/configure-object-shadows" import { createBoardReliefTextures } from "../utils/create-board-relief-textures" -import { createBoardShadowReceiverPlane } from "../utils/create-board-shadow-receiver-plane" import { calculateOutlineBounds } from "../utils/outline-bounds" import type { CombinedBoardTextures } from "./index" @@ -17,7 +15,6 @@ interface TexturePlaneConfig { usePolygonOffset?: boolean renderOrder?: number isFaux?: boolean - renderingMode?: RenderingMode } function createTexturePlane( @@ -32,7 +29,6 @@ function createTexturePlane( usePolygonOffset = false, renderOrder = 0, isFaux = false, - renderingMode = "engineering", } = config if (!texture) return null @@ -55,33 +51,26 @@ function createTexturePlane( polygonOffsetUnits: usePolygonOffset ? -4 : 0, opacity: isFaux ? FAUX_BOARD_OPACITY : 1.0, } satisfies THREE.MeshBasicMaterialParameters - const reliefTextures = - renderingMode === "realistic" - ? createBoardReliefTextures(texture, maskedCopperMask) - : null - const material = - renderingMode === "realistic" - ? 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, - }) - : new THREE.MeshBasicMaterial(sharedMaterialOptions) + 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( boardOutlineBounds.centerX, @@ -102,15 +91,10 @@ export function createTextureMeshes( boardData: PcbBoard | null, pcbThickness: number | null, isFaux: boolean = false, - options: { - shadowsEnabled?: boolean - renderingMode?: RenderingMode - } = {}, ): 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( { @@ -121,20 +105,10 @@ export function createTextureMeshes( usePolygonOffset: true, renderOrder: 1, isFaux, - renderingMode: options.renderingMode, }, boardData, ) if (topBoardMesh) meshes.push(topBoardMesh) - if (options.shadowsEnabled && options.renderingMode !== "realistic") { - meshes.push( - createBoardShadowReceiverPlane({ - boardData, - offset: pcbThickness / 2 + SHADOW_RECEIVER_OFFSET, - isBottomLayer: false, - }), - ) - } const bottomBoardMesh = createTexturePlane( { @@ -145,20 +119,10 @@ export function createTextureMeshes( usePolygonOffset: true, renderOrder: 1, isFaux, - renderingMode: options.renderingMode, }, boardData, ) if (bottomBoardMesh) meshes.push(bottomBoardMesh) - if (options.shadowsEnabled && options.renderingMode !== "realistic") { - 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 e99b13e5..cd655955 100644 --- a/src/three-components/JscadBoardTextures.tsx +++ b/src/three-components/JscadBoardTextures.tsx @@ -3,9 +3,8 @@ import type { AnyCircuitElement, PcbBoard, PcbPanel } from "circuit-json" import { useEffect, useMemo } from "react" import { createCombinedBoardTextures } from "src/textures" import * as THREE from "three" -import { useLayerVisibility } from "../contexts/LayerVisibilityContext" -import { useRenderingMode } from "../contexts/RenderingModeContext" import { REALISTIC_BOARD_SURFACE_MATERIAL } from "../board-surface-textures" +import { useLayerVisibility } from "../contexts/LayerVisibilityContext" import { FAUX_BOARD_OPACITY, TRACE_TEXTURE_RESOLUTION, @@ -13,7 +12,6 @@ import { import { useThree } from "../react-three/ThreeContext" import { configureObjectShadows } from "../utils/configure-object-shadows" import { createBoardReliefTextures } from "../utils/create-board-relief-textures" -import { createBoardShadowReceiverPlane } from "../utils/create-board-shadow-receiver-plane" import { getLayerTextureResolution } from "../utils/layer-texture-resolution" import { calculateOutlineBounds } from "../utils/outline-bounds" @@ -30,7 +28,6 @@ export function JscadBoardTextures({ }: JscadBoardTexturesProps) { const { rootObject } = useThree() const { visibility } = useLayerVisibility() - const { renderingMode, shadowsEnabled } = useRenderingMode() const boardData = useMemo(() => { // Check for panel first @@ -70,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, @@ -141,31 +138,27 @@ export function JscadBoardTextures({ polygonOffsetUnits: usePolygonOffset ? -4 : 0, opacity: isFaux ? FAUX_BOARD_OPACITY : 1.0, } satisfies THREE.MeshBasicMaterialParameters - const reliefTextures = - renderingMode === "realistic" - ? createBoardReliefTextures(texture, maskedCopperMask) - : null - const material = - renderingMode === "realistic" - ? 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, - }) - : new THREE.MeshBasicMaterial(sharedMaterialOptions) + 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( boardOutlineBounds.centerX, @@ -184,7 +177,6 @@ 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, @@ -198,17 +190,6 @@ export function JscadBoardTextures({ meshes.push(topBoardMesh) rootObject.add(topBoardMesh) } - if (shadowsEnabled && renderingMode !== "realistic") { - 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, @@ -222,42 +203,26 @@ export function JscadBoardTextures({ meshes.push(bottomBoardMesh) rootObject.add(bottomBoardMesh) } - if (shadowsEnabled && renderingMode !== "realistic") { - 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, - renderingMode, - shadowsEnabled, - ]) + }, [rootObject, boardData, textures, pcbThickness]) return null } diff --git a/src/utils/create-board-material.ts b/src/utils/create-board-material.ts index 9526dae1..e3aaa041 100644 --- a/src/utils/create-board-material.ts +++ b/src/utils/create-board-material.ts @@ -1,6 +1,5 @@ -import * as THREE from "three" import type { PcbBoard } from "circuit-json" -import type { RenderingMode } from "../contexts/RenderingModeContext" +import * as THREE from "three" import { FAUX_BOARD_OPACITY } from "../geoms/constants" type BoardMaterialType = PcbBoard["material"] @@ -10,7 +9,6 @@ interface CreateBoardMaterialOptions { color: THREE.ColorRepresentation side?: THREE.Side isFaux?: boolean - renderingMode?: RenderingMode } const DEFAULT_SIDE = THREE.DoubleSide @@ -20,21 +18,20 @@ export const createBoardMaterial = ({ color, side = DEFAULT_SIDE, isFaux = false, - renderingMode = "engineering", }: CreateBoardMaterialOptions): THREE.MeshStandardMaterial => { if (material === "fr4") { return new THREE.MeshPhysicalMaterial({ // 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: renderingMode === "realistic" ? 0x103a26 : color, + color: 0x103a26, side, metalness: 0.0, - roughness: renderingMode === "realistic" ? 0.48 : 0.8, - specularIntensity: renderingMode === "realistic" ? 0.3 : 0.2, + roughness: 0.48, + specularIntensity: 0.3, ior: 1.45, sheen: 0.0, - clearcoat: renderingMode === "realistic" ? 0.16 : 0.0, - clearcoatRoughness: renderingMode === "realistic" ? 0.3 : 0.0, + clearcoat: 0.16, + clearcoatRoughness: 0.3, transparent: isFaux, opacity: isFaux ? FAUX_BOARD_OPACITY : 1.0, flatShading: true, diff --git a/src/utils/manifold/create-three-geometry-meshes.ts b/src/utils/manifold/create-three-geometry-meshes.ts index 1b8e9c59..93750f3f 100644 --- a/src/utils/manifold/create-three-geometry-meshes.ts +++ b/src/utils/manifold/create-three-geometry-meshes.ts @@ -1,17 +1,15 @@ import * as THREE from "three" -import type { RenderingMode } from "../../contexts/RenderingModeContext" import type { ManifoldGeoms } from "../../hooks/useManifoldBoardBuilder" import { configureObjectShadows } from "../configure-object-shadows" import { createBoardMaterial } from "../create-board-material" export function createGeometryMeshes( geoms: ManifoldGeoms | null, - renderingMode: RenderingMode = "engineering", ): THREE.Mesh[] { 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({ @@ -19,7 +17,6 @@ export function createGeometryMeshes( color: geoms.board.color, side: THREE.DoubleSide, isFaux: geoms.board.isFaux, - renderingMode, }), ) mesh.name = "board-geom" @@ -35,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({ @@ -47,7 +44,7 @@ export function createGeometryMeshes( mesh.name = comp.key // Use provided key for identification configureObjectShadows(mesh) meshes.push(mesh) - }) + } } } From 44e10c073709e86ea648e49cb3ec15c682beaaf3 Mon Sep 17 00:00:00 2001 From: AnasSarkiz Date: Tue, 4 Aug 2026 22:42:36 +0200 Subject: [PATCH 3/6] Add studio background options --- src/CadViewerContainer.tsx | 3 +- src/components/AppearanceMenu.tsx | 97 ++++++++++++++++++++++++++- src/contexts/RenderingModeContext.tsx | 22 +++++- src/react-three/Lights.tsx | 10 ++- 4 files changed, 126 insertions(+), 6 deletions(-) diff --git a/src/CadViewerContainer.tsx b/src/CadViewerContainer.tsx index 25d7f70a..5c63c4ff 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 { backgroundMode, shadowsEnabled } = useRenderingMode() const { handleCameraCreated, handleControlsChange: handleSessionControlsChange, @@ -114,6 +114,7 @@ export const CadViewerContainer = forwardRef< /> )} { const { visibility, setLayerVisibility } = useLayerVisibility() - const { lightingEnabled, setLightingEnabled } = useRenderingMode() + const { + backgroundMode, + setBackgroundMode, + lightingEnabled, + setLightingEnabled, + } = useRenderingMode() const [appearanceSubOpen, setAppearanceSubOpen] = useState(false) + const [backgroundSubOpen, setBackgroundSubOpen] = useState(false) const [hoveredItem, setHoveredItem] = useState(null) + const selectBackgroundMode = (mode: string) => { + if (mode === "dark" || mode === "light" || mode === "transparent") { + setBackgroundMode(mode as BackgroundMode) + } + } + return ( <> @@ -105,6 +120,84 @@ export const AppearanceMenu = () => { collisionPadding={10} avoidCollisions={true} > + + setHoveredItem("background")} + onMouseLeave={() => setHoveredItem(null)} + onTouchStart={() => setHoveredItem("background")} + > + + Background + +
+ +
+
+ + + + + {[ + ["dark", "Dark Studio"], + ["light", "Light Studio"], + ["transparent", "Transparent"], + ].map(([mode, label]) => ( + + setHoveredItem(`background-${mode}`) + } + onMouseLeave={() => setHoveredItem(null)} + onTouchStart={() => + setHoveredItem(`background-${mode}`) + } + > + + {backgroundMode === mode && } + + + {label} + + + ))} + + + +
+ + + void lightingEnabled: boolean setLightingEnabled: (enabled: boolean) => void shadowsEnabled: boolean } +const BACKGROUND_STORAGE_KEY = "cadViewerBackgroundMode" const LIGHTING_STORAGE_KEY = "cadViewerLightingEnabled" +const readStoredBackgroundMode = (): BackgroundMode => { + if (typeof window === "undefined") return "dark" + const stored = window.localStorage.getItem(BACKGROUND_STORAGE_KEY) + return stored === "light" || stored === "transparent" ? stored : "dark" +} + const readStoredLightingEnabled = (): boolean => { if (typeof window === "undefined") return true return window.localStorage.getItem(LIGHTING_STORAGE_KEY) !== "false" @@ -21,10 +32,17 @@ const RenderingModeContext = createContext< export const RenderingModeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [backgroundMode, setBackgroundMode] = useState( + readStoredBackgroundMode, + ) const [lightingEnabled, setLightingEnabled] = useState( readStoredLightingEnabled, ) + useEffect(() => { + window.localStorage.setItem(BACKGROUND_STORAGE_KEY, backgroundMode) + }, [backgroundMode]) + useEffect(() => { window.localStorage.setItem( LIGHTING_STORAGE_KEY, @@ -34,11 +52,13 @@ export const RenderingModeProvider: React.FC<{ const value = useMemo( () => ({ + backgroundMode, + setBackgroundMode, lightingEnabled, setLightingEnabled, shadowsEnabled: lightingEnabled, }), - [lightingEnabled], + [backgroundMode, lightingEnabled], ) return ( diff --git a/src/react-three/Lights.tsx b/src/react-three/Lights.tsx index 47e91648..b8ac6e17 100644 --- a/src/react-three/Lights.tsx +++ b/src/react-three/Lights.tsx @@ -1,15 +1,18 @@ import type React from "react" import { useEffect, useMemo } from "react" import * as THREE from "three" +import type { BackgroundMode } from "../contexts/RenderingModeContext" import { useThree } from "./ThreeContext" type LightsProps = { + backgroundMode?: BackgroundMode boardDimensions?: { width?: number; height?: number } boardCenter?: { x: number; y: number } shadowsEnabled?: boolean } export const Lights: React.FC = ({ + backgroundMode = "dark", boardDimensions, boardCenter, shadowsEnabled = false, @@ -107,7 +110,10 @@ export const Lights: React.FC = ({ useEffect(() => { const previousBackground = scene.background const previousEnvironment = scene.environment - scene.background = new THREE.Color(0x101310) + scene.background = + backgroundMode === "transparent" + ? null + : new THREE.Color(backgroundMode === "dark" ? 0x101310 : 0xe8ece9) // 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. @@ -117,7 +123,7 @@ export const Lights: React.FC = ({ scene.background = previousBackground scene.environment = previousEnvironment } - }, [scene]) + }, [backgroundMode, scene]) useEffect(() => { if (!scene) return From 3a4fae83bd848f9567fa142994a554cdee35d80d Mon Sep 17 00:00:00 2001 From: AnasSarkiz Date: Tue, 4 Aug 2026 22:51:40 +0200 Subject: [PATCH 4/6] Simplify dark background control --- .storybook/preview.tsx | 5 +- src/CadViewerContainer.tsx | 4 +- src/components/AppearanceMenu.tsx | 115 ++++++-------------------- src/contexts/RenderingModeContext.tsx | 32 +++---- src/react-three/Lights.tsx | 12 +-- 5 files changed, 49 insertions(+), 119 deletions(-) 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/CadViewerContainer.tsx b/src/CadViewerContainer.tsx index 5c63c4ff..f4b8edd4 100644 --- a/src/CadViewerContainer.tsx +++ b/src/CadViewerContainer.tsx @@ -65,7 +65,7 @@ export const CadViewerContainer = forwardRef< const { mainCameraRef, handleControlsChange, controller } = useCameraController() - const { backgroundMode, shadowsEnabled } = useRenderingMode() + const { darkBackgroundEnabled, shadowsEnabled } = useRenderingMode() const { handleCameraCreated, handleControlsChange: handleSessionControlsChange, @@ -114,9 +114,9 @@ export const CadViewerContainer = forwardRef< /> )} {children} diff --git a/src/components/AppearanceMenu.tsx b/src/components/AppearanceMenu.tsx index 430e4587..edba1833 100644 --- a/src/components/AppearanceMenu.tsx +++ b/src/components/AppearanceMenu.tsx @@ -3,10 +3,7 @@ import type React from "react" import { useState } from "react" import { zIndexMap } from "../../lib/utils/z-index-map" import { useLayerVisibility } from "../contexts/LayerVisibilityContext" -import { - type BackgroundMode, - useRenderingMode, -} from "../contexts/RenderingModeContext" +import { useRenderingMode } from "../contexts/RenderingModeContext" import { CheckIcon, ChevronRightIcon } from "./Icons" const itemStyles: React.CSSProperties = { @@ -66,21 +63,14 @@ const iconContainerStyles: React.CSSProperties = { export const AppearanceMenu = () => { const { visibility, setLayerVisibility } = useLayerVisibility() const { - backgroundMode, - setBackgroundMode, + darkBackgroundEnabled, + setDarkBackgroundEnabled, lightingEnabled, setLightingEnabled, } = useRenderingMode() const [appearanceSubOpen, setAppearanceSubOpen] = useState(false) - const [backgroundSubOpen, setBackgroundSubOpen] = useState(false) const [hoveredItem, setHoveredItem] = useState(null) - const selectBackgroundMode = (mode: string) => { - if (mode === "dark" || mode === "light" || mode === "transparent") { - setBackgroundMode(mode as BackgroundMode) - } - } - return ( <> @@ -120,83 +110,28 @@ export const AppearanceMenu = () => { collisionPadding={10} avoidCollisions={true} > - - setHoveredItem("background")} - onMouseLeave={() => setHoveredItem(null)} - onTouchStart={() => setHoveredItem("background")} - > - - Background - -
- -
-
- - - - - {[ - ["dark", "Dark Studio"], - ["light", "Light Studio"], - ["transparent", "Transparent"], - ].map(([mode, label]) => ( - - setHoveredItem(`background-${mode}`) - } - onMouseLeave={() => setHoveredItem(null)} - onTouchStart={() => - setHoveredItem(`background-${mode}`) - } - > - - {backgroundMode === mode && } - - - {label} - - - ))} - - - -
- - + e.preventDefault()} + onPointerDown={(e) => { + e.preventDefault() + setDarkBackgroundEnabled(!darkBackgroundEnabled) + }} + onMouseEnter={() => setHoveredItem("darkBackground")} + onMouseLeave={() => setHoveredItem(null)} + onTouchStart={() => setHoveredItem("darkBackground")} + > + + {darkBackgroundEnabled && } + + + Dark Background + + void + darkBackgroundEnabled: boolean + setDarkBackgroundEnabled: (enabled: boolean) => void lightingEnabled: boolean setLightingEnabled: (enabled: boolean) => void shadowsEnabled: boolean } -const BACKGROUND_STORAGE_KEY = "cadViewerBackgroundMode" +const DARK_BACKGROUND_STORAGE_KEY = "cadViewerDarkBackgroundEnabled" const LIGHTING_STORAGE_KEY = "cadViewerLightingEnabled" -const readStoredBackgroundMode = (): BackgroundMode => { - if (typeof window === "undefined") return "dark" - const stored = window.localStorage.getItem(BACKGROUND_STORAGE_KEY) - return stored === "light" || stored === "transparent" ? stored : "dark" +const readStoredDarkBackgroundEnabled = (): boolean => { + if (typeof window === "undefined") return true + return window.localStorage.getItem(DARK_BACKGROUND_STORAGE_KEY) !== "false" } const readStoredLightingEnabled = (): boolean => { @@ -32,16 +29,19 @@ const RenderingModeContext = createContext< export const RenderingModeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [backgroundMode, setBackgroundMode] = useState( - readStoredBackgroundMode, + const [darkBackgroundEnabled, setDarkBackgroundEnabled] = useState( + readStoredDarkBackgroundEnabled, ) const [lightingEnabled, setLightingEnabled] = useState( readStoredLightingEnabled, ) useEffect(() => { - window.localStorage.setItem(BACKGROUND_STORAGE_KEY, backgroundMode) - }, [backgroundMode]) + window.localStorage.setItem( + DARK_BACKGROUND_STORAGE_KEY, + darkBackgroundEnabled ? "true" : "false", + ) + }, [darkBackgroundEnabled]) useEffect(() => { window.localStorage.setItem( @@ -52,13 +52,13 @@ export const RenderingModeProvider: React.FC<{ const value = useMemo( () => ({ - backgroundMode, - setBackgroundMode, + darkBackgroundEnabled, + setDarkBackgroundEnabled, lightingEnabled, setLightingEnabled, shadowsEnabled: lightingEnabled, }), - [backgroundMode, lightingEnabled], + [darkBackgroundEnabled, lightingEnabled], ) return ( diff --git a/src/react-three/Lights.tsx b/src/react-three/Lights.tsx index b8ac6e17..1271a124 100644 --- a/src/react-three/Lights.tsx +++ b/src/react-three/Lights.tsx @@ -1,20 +1,19 @@ import type React from "react" import { useEffect, useMemo } from "react" import * as THREE from "three" -import type { BackgroundMode } from "../contexts/RenderingModeContext" import { useThree } from "./ThreeContext" type LightsProps = { - backgroundMode?: BackgroundMode boardDimensions?: { width?: number; height?: number } boardCenter?: { x: number; y: number } + darkBackgroundEnabled?: boolean shadowsEnabled?: boolean } export const Lights: React.FC = ({ - backgroundMode = "dark", boardDimensions, boardCenter, + darkBackgroundEnabled = true, shadowsEnabled = false, }) => { const { scene } = useThree() @@ -110,10 +109,7 @@ export const Lights: React.FC = ({ useEffect(() => { const previousBackground = scene.background const previousEnvironment = scene.environment - scene.background = - backgroundMode === "transparent" - ? null - : new THREE.Color(backgroundMode === "dark" ? 0x101310 : 0xe8ece9) + 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. @@ -123,7 +119,7 @@ export const Lights: React.FC = ({ scene.background = previousBackground scene.environment = previousEnvironment } - }, [backgroundMode, scene]) + }, [darkBackgroundEnabled, scene]) useEffect(() => { if (!scene) return From 3d414ecfd9f92b10aa89d396e000c557a81b56ad Mon Sep 17 00:00:00 2001 From: AnasSarkiz Date: Tue, 4 Aug 2026 23:03:04 +0200 Subject: [PATCH 5/6] Default viewer backgrounds to transparent --- src/CadViewer.tsx | 38 +++++++-------- src/CadViewerContainer.tsx | 6 +-- src/components/AppearanceMenu.tsx | 4 +- ...ModeContext.tsx => appearance-context.tsx} | 47 ++++++------------- src/react-three/Lights.tsx | 2 +- 5 files changed, 40 insertions(+), 57 deletions(-) rename src/contexts/{RenderingModeContext.tsx => appearance-context.tsx} (51%) 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 f4b8edd4..85340bce 100644 --- a/src/CadViewerContainer.tsx +++ b/src/CadViewerContainer.tsx @@ -3,8 +3,8 @@ 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 { useRenderingMode } from "./contexts/RenderingModeContext" import type { CameraController } from "./hooks/cameraAnimation" import { CameraAnimatorWithContext } from "./hooks/cameraAnimation" import { useCameraSession } from "./hooks/useCameraSession" @@ -65,7 +65,7 @@ export const CadViewerContainer = forwardRef< const { mainCameraRef, handleControlsChange, controller } = useCameraController() - const { darkBackgroundEnabled, shadowsEnabled } = useRenderingMode() + const { darkBackgroundEnabled, lightingEnabled } = useAppearance() const { handleCameraCreated, handleControlsChange: handleSessionControlsChange, @@ -117,7 +117,7 @@ export const CadViewerContainer = forwardRef< boardDimensions={boardDimensions} boardCenter={boardCenter} darkBackgroundEnabled={darkBackgroundEnabled} - shadowsEnabled={shadowsEnabled} + shadowsEnabled={lightingEnabled} /> {children} diff --git a/src/components/AppearanceMenu.tsx b/src/components/AppearanceMenu.tsx index edba1833..c7dc0e49 100644 --- a/src/components/AppearanceMenu.tsx +++ b/src/components/AppearanceMenu.tsx @@ -2,8 +2,8 @@ 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 { CheckIcon, ChevronRightIcon } from "./Icons" const itemStyles: React.CSSProperties = { @@ -67,7 +67,7 @@ export const AppearanceMenu = () => { setDarkBackgroundEnabled, lightingEnabled, setLightingEnabled, - } = useRenderingMode() + } = useAppearance() const [appearanceSubOpen, setAppearanceSubOpen] = useState(false) const [hoveredItem, setHoveredItem] = useState(null) diff --git a/src/contexts/RenderingModeContext.tsx b/src/contexts/appearance-context.tsx similarity index 51% rename from src/contexts/RenderingModeContext.tsx rename to src/contexts/appearance-context.tsx index 9806e4f0..264cee5a 100644 --- a/src/contexts/RenderingModeContext.tsx +++ b/src/contexts/appearance-context.tsx @@ -1,48 +1,34 @@ import type React from "react" import { createContext, useContext, useEffect, useMemo, useState } from "react" -interface RenderingModeContextType { +interface AppearanceContextType { darkBackgroundEnabled: boolean setDarkBackgroundEnabled: (enabled: boolean) => void lightingEnabled: boolean setLightingEnabled: (enabled: boolean) => void - shadowsEnabled: boolean } -const DARK_BACKGROUND_STORAGE_KEY = "cadViewerDarkBackgroundEnabled" const LIGHTING_STORAGE_KEY = "cadViewerLightingEnabled" -const readStoredDarkBackgroundEnabled = (): boolean => { - if (typeof window === "undefined") return true - return window.localStorage.getItem(DARK_BACKGROUND_STORAGE_KEY) !== "false" -} - const readStoredLightingEnabled = (): boolean => { if (typeof window === "undefined") return true return window.localStorage.getItem(LIGHTING_STORAGE_KEY) !== "false" } -const RenderingModeContext = createContext< - RenderingModeContextType | undefined ->(undefined) +const AppearanceContext = createContext( + undefined, +) -export const RenderingModeProvider: React.FC<{ - children: React.ReactNode -}> = ({ children }) => { - const [darkBackgroundEnabled, setDarkBackgroundEnabled] = useState( - readStoredDarkBackgroundEnabled, - ) +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( - DARK_BACKGROUND_STORAGE_KEY, - darkBackgroundEnabled ? "true" : "false", - ) - }, [darkBackgroundEnabled]) - useEffect(() => { window.localStorage.setItem( LIGHTING_STORAGE_KEY, @@ -56,24 +42,21 @@ export const RenderingModeProvider: React.FC<{ setDarkBackgroundEnabled, lightingEnabled, setLightingEnabled, - shadowsEnabled: lightingEnabled, }), [darkBackgroundEnabled, lightingEnabled], ) return ( - + {children} - + ) } -export const useRenderingMode = () => { - const context = useContext(RenderingModeContext) +export const useAppearance = () => { + const context = useContext(AppearanceContext) if (!context) { - throw new Error( - "useRenderingMode must be used within a RenderingModeProvider", - ) + throw new Error("useAppearance must be used within an AppearanceProvider") } return context } diff --git a/src/react-three/Lights.tsx b/src/react-three/Lights.tsx index 1271a124..24001728 100644 --- a/src/react-three/Lights.tsx +++ b/src/react-three/Lights.tsx @@ -13,7 +13,7 @@ type LightsProps = { export const Lights: React.FC = ({ boardDimensions, boardCenter, - darkBackgroundEnabled = true, + darkBackgroundEnabled = false, shadowsEnabled = false, }) => { const { scene } = useThree() From 506622ea30ddb8e7903c02718ca9ded26af929ef Mon Sep 17 00:00:00 2001 From: AnasSarkiz Date: Wed, 5 Aug 2026 10:13:45 +0200 Subject: [PATCH 6/6] Use named board texture plane options --- src/three-components/JscadBoardTextures.tsx | 55 ++++++++++++--------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/src/three-components/JscadBoardTextures.tsx b/src/three-components/JscadBoardTextures.tsx index cd655955..62307d02 100644 --- a/src/three-components/JscadBoardTextures.tsx +++ b/src/three-components/JscadBoardTextures.tsx @@ -108,16 +108,25 @@ export function JscadBoardTextures({ material.dispose() } - const createTexturePlane = ( - texture: THREE.CanvasTexture | null | undefined, - maskedCopperMask: 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 @@ -178,27 +187,27 @@ export function JscadBoardTextures({ // Small offset to place textures just above board surface (same as Manifold) const SURFACE_OFFSET = 0.005 - const topBoardMesh = createTexturePlane( - textures.topBoard, - textures.topMaskedCopper, - 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) } - const bottomBoardMesh = createTexturePlane( - textures.bottomBoard, - textures.bottomMaskedCopper, - -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)