Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions src/hooks/use-global-gltf-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { useEffect, useState } from "react"
import { type Group, type Material, Mesh } from "three"
import { GLTFLoader, type GLTF } from "three-stdlib"

type CachedGltf = {
promise: Promise<GLTF>
result: GLTF | null
}

function getGltfLoaderCache(): Map<string, CachedGltf> {
const globalScope = globalThis as {
TSCIRCUIT_GLTF_LOADER_CACHE?: Map<string, CachedGltf>
}

if (!globalScope.TSCIRCUIT_GLTF_LOADER_CACHE) {
globalScope.TSCIRCUIT_GLTF_LOADER_CACHE = new Map()
}

return globalScope.TSCIRCUIT_GLTF_LOADER_CACHE
}

function cloneGltfScene(gltf: GLTF): Group {
const scene = gltf.scene.clone(true)

scene.traverse((child) => {
if (!(child instanceof Mesh)) return

if (Array.isArray(child.material)) {
child.material = child.material.map((material) => material.clone())
} else if (child.material) {
child.material = (child.material as Material).clone()
}
})

return scene
}

export function clearGlobalGltfLoaderCache(): void {
getGltfLoaderCache().clear()
}

export async function loadCachedGltfScene(gltfUrl: string): Promise<Group> {
const gltf = await loadGltf(gltfUrl)
return cloneGltfScene(gltf)
}

function loadGltf(gltfUrl: string): Promise<GLTF> {
const cache = getGltfLoaderCache()
const cached = cache.get(gltfUrl)

if (cached?.result) {
return Promise.resolve(cached.result)
}

if (cached) {
return cached.promise
}

const loader = new GLTFLoader()
const promise = loader
.loadAsync(gltfUrl)
.then((gltf) => {
const cacheItem = cache.get(gltfUrl)
if (cacheItem) {
cacheItem.result = gltf
}
return gltf
})
.catch((error) => {
cache.delete(gltfUrl)
throw error
})

cache.set(gltfUrl, { promise, result: null })
return promise
}

export function useGlobalGltfLoader(
gltfUrl: string | null,
): Group | null | Error {
const [model, setModel] = useState<Group | null | Error>(null)

useEffect(() => {
let isActive = true
setModel(null)

if (!gltfUrl) return

loadCachedGltfScene(gltfUrl)
.then((scene) => {
if (!isActive) return
setModel(scene)
})
.catch((error) => {
if (!isActive) return
console.error(`An error happened loading ${gltfUrl}`, error)
setModel(
error instanceof Error
? error
: new Error(`Failed to load glTF model from ${gltfUrl}`),
)
})

return () => {
isActive = false
}
}, [gltfUrl])

return model
}
83 changes: 29 additions & 54 deletions src/three-components/GltfModel.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { useState, useEffect } from "react"
import { useEffect } from "react"
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"
import type { CadModelFitMode, CadModelSize } from "src/utils/cad-model-fit"
import { useGlobalGltfLoader } from "src/hooks/use-global-gltf-loader"
import { useCadModelTransformGraph } from "./useCadModelTransformGraph"

const DEFAULT_ENV_MAP_INTENSITY = 1.25
Expand Down Expand Up @@ -39,10 +39,9 @@ export function GltfModel({
isTranslucent?: boolean
}) {
const { renderer } = useThree()
const [model, setModel] = useState<THREE.Group | null>(null)
const [loadError, setLoadError] = useState<Error | null>(null)
const model = useGlobalGltfLoader(gltfUrl)
const { boardTransformGroup } = useCadModelTransformGraph({
model,
model: model instanceof Error ? null : model,
position,
rotation,
modelOffset,
Expand All @@ -54,54 +53,30 @@ export function GltfModel({
})

useEffect(() => {
if (!gltfUrl) return
const loader = new GLTFLoader()
let isMounted = true
loader.load(
gltfUrl,
(gltf) => {
if (!isMounted) return
const scene = gltf.scene

scene.traverse((child) => {
if (child instanceof THREE.Mesh && child.material) {
const setMaterialTransparency = (mat: THREE.Material) => {
mat.transparent = isTranslucent
mat.opacity = isTranslucent ? 0.5 : 1
mat.depthWrite = !isTranslucent
mat.needsUpdate = true
}

if (Array.isArray(child.material)) {
child.material.forEach(setMaterialTransparency)
} else {
setMaterialTransparency(child.material)
}

child.renderOrder = isTranslucent ? 2 : 1
}
})

setModel(scene)
},
undefined,
(error) => {
if (!isMounted) return
console.error(`An error happened loading ${gltfUrl}`, error)
const err =
error instanceof Error
? error
: new Error(`Failed to load glTF model from ${gltfUrl}`)
setLoadError(err)
},
)
return () => {
isMounted = false
}
}, [gltfUrl, isTranslucent])
if (!model || model instanceof Error) return

model.traverse((child) => {
if (child instanceof THREE.Mesh && child.material) {
const setMaterialTransparency = (mat: THREE.Material) => {
mat.transparent = isTranslucent
mat.opacity = isTranslucent ? 0.5 : 1
mat.depthWrite = !isTranslucent
mat.needsUpdate = true
}

if (Array.isArray(child.material)) {
child.material.forEach(setMaterialTransparency)
} else {
setMaterialTransparency(child.material)
}

child.renderOrder = isTranslucent ? 2 : 1
}
})
}, [model, isTranslucent])

useEffect(() => {
if (!model || !renderer) return
if (!model || model instanceof Error || !renderer) return

const environmentMap = getDefaultEnvironmentMap(renderer)
if (!environmentMap) return
Expand Down Expand Up @@ -154,7 +129,7 @@ export function GltfModel({
}, [model, renderer])

useEffect(() => {
if (!model) return
if (!model || model instanceof Error) return
model.traverse((child) => {
if (
child instanceof THREE.Mesh &&
Expand All @@ -170,8 +145,8 @@ export function GltfModel({
})
}, [isHovered, model])

if (loadError) {
throw loadError
if (model instanceof Error) {
throw model
}

if (!model) return null
Expand Down
32 changes: 32 additions & 0 deletions tests/global-gltf-loader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { afterEach, expect, mock, test } from "bun:test"
import { Group } from "three"
import {
clearGlobalGltfLoaderCache,
loadCachedGltfScene,
} from "../src/hooks/use-global-gltf-loader"

const loadAsyncMock = mock(async () => ({ scene: new Group() }))

mock.module("three-stdlib", () => ({
GLTFLoader: class {
loadAsync = loadAsyncMock
},
}))

afterEach(() => {
clearGlobalGltfLoaderCache()
loadAsyncMock.mockClear()
})

test("loads the same glTF URL once and returns cloned scenes", async () => {
const [firstScene, secondScene] = await Promise.all([
loadCachedGltfScene("/models/chip.glb"),
loadCachedGltfScene("/models/chip.glb"),
])

expect(loadAsyncMock).toHaveBeenCalledTimes(1)
expect(loadAsyncMock).toHaveBeenCalledWith("/models/chip.glb")
expect(firstScene).toBeInstanceOf(Group)
expect(secondScene).toBeInstanceOf(Group)
expect(firstScene).not.toBe(secondScene)
})
Loading