diff --git a/src/react-three/Canvas.tsx b/src/react-three/Canvas.tsx index 1d53f92a..d805871b 100644 --- a/src/react-three/Canvas.tsx +++ b/src/react-three/Canvas.tsx @@ -11,6 +11,7 @@ import * as THREE from "three" import { ThreeContext, ThreeContextState } from "./ThreeContext" import { HoverProvider } from "./HoverContext" import { removeExistingCanvases } from "./remove-existing-canvases" +import { configureRenderer } from "./configure-renderer" declare global { interface Window { @@ -65,6 +66,7 @@ export const Canvas = forwardRef( removeExistingCanvases(mountRef.current) const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }) + configureRenderer(renderer) renderer.setSize( mountRef.current.clientWidth, mountRef.current.clientHeight, diff --git a/src/react-three/configure-renderer.ts b/src/react-three/configure-renderer.ts new file mode 100644 index 00000000..7a5c9e55 --- /dev/null +++ b/src/react-three/configure-renderer.ts @@ -0,0 +1,12 @@ +import * as THREE from "three" + +/** + * Applies renderer configuration that ensures GLTF/GLB assets are rendered + * using the expected color space and tone mapping. Without this configuration + * GLB assets can appear noticeably darker than intended. + */ +export const configureRenderer = (renderer: THREE.WebGLRenderer) => { + renderer.outputColorSpace = THREE.SRGBColorSpace + renderer.toneMapping = THREE.ACESFilmicToneMapping + renderer.toneMappingExposure = 1 +} diff --git a/src/react-three/getDefaultEnvironmentMap.ts b/src/react-three/getDefaultEnvironmentMap.ts new file mode 100644 index 00000000..4a8a23b3 --- /dev/null +++ b/src/react-three/getDefaultEnvironmentMap.ts @@ -0,0 +1,36 @@ +import * as THREE from "three" +import { RoomEnvironment } from "three-stdlib" + +type RendererLike = THREE.WebGLRenderer | (Record & object) + +const environmentCache = new WeakMap() + +const createFallbackEnvironmentMap = () => { + const texture = new THREE.Texture() + texture.name = "fallback-environment-map" + texture.mapping = THREE.EquirectangularReflectionMapping + return texture +} + +export const getDefaultEnvironmentMap = ( + renderer: RendererLike | null | undefined, +): THREE.Texture | null => { + if (!renderer) return null + + const cacheKey = renderer as object + const cached = environmentCache.get(cacheKey) + if (cached) return cached + + let texture: THREE.Texture + + if (renderer instanceof THREE.WebGLRenderer) { + const pmremGenerator = new THREE.PMREMGenerator(renderer) + texture = pmremGenerator.fromScene(RoomEnvironment(), 0.04).texture + pmremGenerator.dispose() + } else { + texture = createFallbackEnvironmentMap() + } + + environmentCache.set(cacheKey, texture) + return texture +} diff --git a/src/three-components/GltfModel.tsx b/src/three-components/GltfModel.tsx index 7736b30b..6d245560 100644 --- a/src/three-components/GltfModel.tsx +++ b/src/three-components/GltfModel.tsx @@ -3,6 +3,9 @@ import * as THREE from "three" import { GLTFLoader } from "three-stdlib" import { useThree } from "src/react-three/ThreeContext" import ContainerWithTooltip from "src/ContainerWithTooltip" +import { getDefaultEnvironmentMap } from "src/react-three/getDefaultEnvironmentMap" + +const DEFAULT_ENV_MAP_INTENSITY = 1.25 export function GltfModel({ gltfUrl, @@ -21,7 +24,7 @@ export function GltfModel({ isHovered: boolean scale?: number }) { - const { rootObject } = useThree() + const { renderer, rootObject } = useThree() const [model, setModel] = useState(null) useEffect(() => { @@ -68,6 +71,61 @@ export function GltfModel({ } }, [rootObject, model]) + useEffect(() => { + if (!model || !renderer) return + + const environmentMap = getDefaultEnvironmentMap(renderer) + if (!environmentMap) return + + const previousMaterialState: Array<{ + material: THREE.MeshStandardMaterial + envMap: THREE.Texture | null + envMapIntensity: number + }> = [] + + const applyEnvironmentToMaterial = (material: THREE.Material) => { + if (!(material instanceof THREE.MeshStandardMaterial)) return + + previousMaterialState.push({ + material, + envMap: material.envMap ?? null, + envMapIntensity: material.envMapIntensity ?? 1, + }) + + if (!material.envMap) { + material.envMap = environmentMap + } + + if ( + typeof material.envMapIntensity !== "number" || + material.envMapIntensity < DEFAULT_ENV_MAP_INTENSITY + ) { + material.envMapIntensity = DEFAULT_ENV_MAP_INTENSITY + } + + material.needsUpdate = true + } + + model.traverse((child) => { + if (!(child instanceof THREE.Mesh)) return + + const material = child.material + if (Array.isArray(material)) { + material.forEach(applyEnvironmentToMaterial) + } else if (material) { + applyEnvironmentToMaterial(material) + } + }) + + return () => { + previousMaterialState.forEach(({ material, envMap, envMapIntensity }) => { + material.envMap = envMap + material.envMapIntensity = envMapIntensity + material.needsUpdate = true + }) + } + }, [model, renderer]) + useEffect(() => { if (!model) return model.traverse((child) => { diff --git a/stories/Bugs/RemoteGlbBrightness.stories.tsx b/stories/Bugs/RemoteGlbBrightness.stories.tsx new file mode 100644 index 00000000..72cf16aa --- /dev/null +++ b/stories/Bugs/RemoteGlbBrightness.stories.tsx @@ -0,0 +1,29 @@ +import { CadViewer } from "src/CadViewer" + +export const RemoteSoic6Glb = () => ( +
+ + + + + + } + /> + + +
+) + +RemoteSoic6Glb.storyName = "Remote SOIC-6 GLB" + +export default { + title: "Bugs/Remote GLB Brightness", +} diff --git a/tests/cad-model-glb-brightness.test.tsx b/tests/cad-model-glb-brightness.test.tsx new file mode 100644 index 00000000..d9e4387a --- /dev/null +++ b/tests/cad-model-glb-brightness.test.tsx @@ -0,0 +1,269 @@ +import { expect, mock, test } from "bun:test" +import { JSDOM } from "jsdom" +import { createRoot } from "react-dom/client" +import { act } from "react" +import * as THREE from "three" +import type { AnyCircuitElement, CadComponent } from "circuit-json" + +const loadedMaterials: THREE.MeshStandardMaterial[] = [] +const envTexture = new THREE.Texture() +envTexture.name = "test-environment-map" +const getDefaultEnvironmentMapMock = mock(() => envTexture) + +;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true + +mock.module("three-stdlib", () => { + class MockGLTFLoader { + load( + _url: string, + onLoad: (gltf: { scene: THREE.Group }) => void, + _onProgress?: (event: ProgressEvent) => void, + onError?: (error: unknown) => void, + ) { + try { + const material = new THREE.MeshStandardMaterial({ + envMapIntensity: 0.1, + }) + const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), material) + const scene = new THREE.Group() + scene.add(mesh) + loadedMaterials.push(material) + queueMicrotask(() => onLoad({ scene })) + } catch (error) { + onError?.(error) + } + } + } + + class MockVRMLLoader { + load( + _url: string, + onLoad: (object: THREE.Group) => void, + _onProgress?: (event: ProgressEvent) => void, + onError?: (error: unknown) => void, + ) { + try { + onLoad(new THREE.Group()) + } catch (error) { + onError?.(error) + } + } + } + + class MockSTLLoader { + load( + _url: string, + onLoad: (geometry: THREE.BufferGeometry) => void, + _onProgress?: (event: ProgressEvent) => void, + onError?: (error: unknown) => void, + ) { + try { + onLoad(new THREE.BufferGeometry()) + } catch (error) { + onError?.(error) + } + } + } + + class MockOBJLoader { + load( + _url: string, + onLoad: (object: THREE.Group) => void, + _onProgress?: (event: ProgressEvent) => void, + onError?: (error: unknown) => void, + ) { + try { + onLoad(new THREE.Group()) + } catch (error) { + onError?.(error) + } + } + } + + class MockMTLLoader { + load( + _url: string, + onLoad: (materials: unknown) => void, + _onProgress?: (event: ProgressEvent) => void, + onError?: (error: unknown) => void, + ) { + try { + onLoad({}) + } catch (error) { + onError?.(error) + } + } + } + + return { + GLTFLoader: MockGLTFLoader, + VRMLLoader: MockVRMLLoader, + STLLoader: MockSTLLoader, + OBJLoader: MockOBJLoader, + MTLLoader: MockMTLLoader, + } +}) + +mock.module("../src/react-three/getDefaultEnvironmentMap.ts", () => ({ + getDefaultEnvironmentMap: getDefaultEnvironmentMapMock, +})) + +test("GLB cadModel applies the default environment map", async () => { + loadedMaterials.length = 0 + getDefaultEnvironmentMapMock.mockReset() + getDefaultEnvironmentMapMock.mockImplementation(() => envTexture) + + const dom = new JSDOM( + '
', + { + url: "https://localhost", + pretendToBeVisual: true, + }, + ) + + const originalGlobals = { + window: globalThis.window as any, + document: globalThis.document as any, + navigator: globalThis.navigator as any, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + devicePixelRatio: globalThis.devicePixelRatio, + HTMLElement: globalThis.HTMLElement as any, + HTMLCanvasElement: globalThis.HTMLCanvasElement as any, + localStorage: (globalThis as any).localStorage as any, + } + + const { window } = dom + Object.assign(globalThis, { + window, + document: window.document, + navigator: window.navigator, + requestAnimationFrame: + window.requestAnimationFrame?.bind(window) ?? + ((cb: FrameRequestCallback) => + setTimeout(() => cb(performance.now()), 16)), + cancelAnimationFrame: + window.cancelAnimationFrame?.bind(window) ?? + ((handle: number) => clearTimeout(handle)), + devicePixelRatio: 1, + HTMLElement: window.HTMLElement, + HTMLCanvasElement: window.HTMLCanvasElement, + localStorage: window.localStorage, + }) + + const board: AnyCircuitElement = { + type: "pcb_board", + pcb_board_id: "pcb_board_remote_glb", + center: { x: 0, y: 0 }, + thickness: 1.6, + num_layers: 2, + material: "fr4", + width: 10, + height: 10, + } + + const pcbComponent: AnyCircuitElement = { + type: "pcb_component", + pcb_component_id: "pcb_component_remote_glb", + source_component_id: "source_component_remote_glb", + center: { x: 0, y: 0 }, + width: 1, + height: 1, + layer: "top", + rotation: 0, + } + + const sourceComponent: AnyCircuitElement = { + type: "source_component", + source_component_id: "source_component_remote_glb", + name: "Remote GLB Chip", + ftype: "simple_chip", + } + + const cadComponent: CadComponent = { + type: "cad_component", + cad_component_id: "cad_component_remote_glb", + source_component_id: "source_component_remote_glb", + pcb_component_id: "pcb_component_remote_glb", + position: { x: 0, y: 0, z: 0.6 }, + rotation: { x: 90, y: 0, z: 0 }, + model_glb_url: "https://modelcdn.tscircuit.com/jscad_models/soic6.glb", + } + + const circuitJson: AnyCircuitElement[] = [ + board, + pcbComponent, + sourceComponent, + cadComponent, + ] + + expect(cadComponent.model_glb_url).toBe( + "https://modelcdn.tscircuit.com/jscad_models/soic6.glb", + ) + + const scene = new THREE.Scene() + const camera = new THREE.PerspectiveCamera() + const rootObject = new THREE.Object3D() + const renderer = { + domElement: window.document.createElement("canvas"), + setSize: () => {}, + setPixelRatio: () => {}, + render: () => {}, + dispose: () => {}, + } as unknown as THREE.WebGLRenderer + + const frameListeners = new Set<(time: number, delta: number) => void>() + const contextValue = { + scene, + camera, + renderer, + rootObject, + addFrameListener: (listener: (time: number, delta: number) => void) => { + frameListeners.add(listener) + }, + removeFrameListener: (listener: (time: number, delta: number) => void) => { + frameListeners.delete(listener) + }, + } + + const [{ AnyCadComponent }, { HoverProvider }, { ThreeContext }] = + await Promise.all([ + import("../src/AnyCadComponent.tsx"), + import("../src/react-three/HoverContext.tsx"), + import("../src/react-three/ThreeContext.ts"), + ]) + + const container = window.document.getElementById("root") as HTMLElement + const root = createRoot(container) + + await act(async () => { + root.render( + + + + + , + ) + await Promise.resolve() + }) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(loadedMaterials).toHaveLength(1) + const material = loadedMaterials[0]! + expect(material.envMap).toBe(envTexture) + expect(material.envMapIntensity).toBeCloseTo(1.25) + expect(getDefaultEnvironmentMapMock).toHaveBeenCalledTimes(1) + + await act(async () => { + root.unmount() + }) + + Object.assign(globalThis, originalGlobals) + dom.window.close() +})