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
3 changes: 2 additions & 1 deletion src/hooks/use-global-obj-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"
import type { Object3D } from "three"
import { MTLLoader, OBJLoader } from "three-stdlib"
import { loadVrml } from "src/utils/vrml"
import { getModelCacheKey } from "src/utils/model-url-cache-key"

// Define the type for our cache
interface CacheItem {
Expand All @@ -28,7 +29,7 @@ export function useGlobalObjLoader(
useEffect(() => {
if (!url) return

const cleanUrl = url.replace(/&cachebust_origin=$/, "")
const cleanUrl = getModelCacheKey(url)

const cache = window.TSCIRCUIT_OBJ_LOADER_CACHE
let hasUrlChanged = false
Expand Down
70 changes: 31 additions & 39 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 * 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 { loadCachedGltfModel } from "src/utils/load-cached-gltf-model"
import { useCadModelTransformGraph } from "./useCadModelTransformGraph"

const DEFAULT_ENV_MAP_INTENSITY = 1.25
Expand Down Expand Up @@ -55,46 +55,38 @@ 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

loadCachedGltfModel(gltfUrl).then((loadedModel) => {
if (!isMounted) return

if (loadedModel instanceof Error) {
setLoadError(loadedModel)
return
}

loadedModel.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)
}
})

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

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

setModel(loadedModel)
})

return () => {
isMounted = false
}
Expand Down
85 changes: 85 additions & 0 deletions src/utils/load-cached-gltf-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { GLTFLoader } from "three-stdlib"
import * as THREE from "three"
import { getModelCacheKey } from "src/utils/model-url-cache-key"

interface GltfCacheItem {
promise: Promise<THREE.Group | Error>
result: THREE.Group | null
}

declare global {
interface Window {
TSCIRCUIT_GLTF_LOADER_CACHE: Map<string, GltfCacheItem>
}
}

function cloneMaterial(material: THREE.Material): THREE.Material {
return material.clone()
}

export function cloneModelForInstance(model: THREE.Group): THREE.Group {
const cloned = model.clone(true)

cloned.traverse((child) => {
if (!(child instanceof THREE.Mesh) || !child.material) return

if (Array.isArray(child.material)) {
child.material = child.material.map(cloneMaterial)
} else {
child.material = cloneMaterial(child.material)
}
})

return cloned
}

function getGltfCache(): Map<string, GltfCacheItem> {
if (!window.TSCIRCUIT_GLTF_LOADER_CACHE) {
window.TSCIRCUIT_GLTF_LOADER_CACHE = new Map<string, GltfCacheItem>()
}

return window.TSCIRCUIT_GLTF_LOADER_CACHE
}

function loadGltfScene(url: string): Promise<THREE.Group | Error> {
return new Promise((resolve) => {
const loader = new GLTFLoader()

loader.load(
url,
(gltf) => resolve(gltf.scene),
undefined,
(error) => {
console.error(`An error happened loading ${url}`, error)
resolve(
error instanceof Error
? error
: new Error(`Failed to load glTF model from ${url}`),
)
},
)
})
}

export async function loadCachedGltfModel(
url: string,
): Promise<THREE.Group | Error> {
const cacheKey = getModelCacheKey(url)
const cache = getGltfCache()
const cached = cache.get(cacheKey)

if (cached) {
const result = cached.result ?? (await cached.promise)
if (result instanceof Error) return result
return cloneModelForInstance(result)
}

const promise = loadGltfScene(cacheKey)
cache.set(cacheKey, { promise, result: null })

const result = await promise
if (result instanceof Error) return result

cache.set(cacheKey, { promise, result })
return cloneModelForInstance(result)
}
22 changes: 22 additions & 0 deletions src/utils/model-url-cache-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export function getModelCacheKey(url: string): string {
try {
const parsedUrl = new URL(url, "http://tscircuit.local")
parsedUrl.searchParams.delete("cachebust_origin")

if (url.startsWith("http://") || url.startsWith("https://")) {
return parsedUrl.toString()
}

return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`
} catch {
return url.replace(
/([?&])cachebust_origin=[^&#]*(&?)/,
(_match, prefix, suffix) => {
if (prefix === "?" && suffix) return "?"
if (prefix === "?" && !suffix) return ""
if (prefix === "&" && suffix) return "&"
return ""
},
)
}
}
24 changes: 24 additions & 0 deletions tests/load-cached-gltf-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { expect, test } from "bun:test"
import * as THREE from "three"
import { cloneModelForInstance } from "../src/utils/load-cached-gltf-model"

test("cloneModelForInstance clones mesh materials for independent hover/translucency state", () => {
const source = new THREE.Group()
const material = new THREE.MeshStandardMaterial({ color: 0xff0000 })
const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), material)
source.add(mesh)

const firstClone = cloneModelForInstance(source)
const secondClone = cloneModelForInstance(source)

const firstMesh = firstClone.children[0] as THREE.Mesh
const secondMesh = secondClone.children[0] as THREE.Mesh

expect(firstMesh).not.toBe(mesh)
expect(firstMesh.material).not.toBe(material)
expect(secondMesh.material).not.toBe(material)
expect(firstMesh.material).not.toBe(secondMesh.material)

;(firstMesh.material as THREE.MeshStandardMaterial).opacity = 0.5
expect((secondMesh.material as THREE.MeshStandardMaterial).opacity).toBe(1)
})
28 changes: 28 additions & 0 deletions tests/model-url-cache-key.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { expect, test } from "bun:test"
import { getModelCacheKey } from "../src/utils/model-url-cache-key"

test("removes cachebust_origin from absolute model URLs", () => {
expect(
getModelCacheKey(
"https://modelcdn.tscircuit.com/easyeda_models/download?uuid=abc&pn=C123&cachebust_origin=http%3A%2F%2Flocalhost%3A6006",
),
).toBe(
"https://modelcdn.tscircuit.com/easyeda_models/download?uuid=abc&pn=C123",
)
})

test("removes cachebust_origin while preserving other query parameters", () => {
expect(
getModelCacheKey(
"https://example.com/model.glb?cachebust_origin=http%3A%2F%2Flocalhost%3A6006&variant=preview",
),
).toBe("https://example.com/model.glb?variant=preview")
})

test("normalizes relative model URLs without forcing a fake origin", () => {
expect(
getModelCacheKey(
"/assets/model.glb?cachebust_origin=http%3A%2F%2Flocalhost%3A6006&v=1#part",
),
).toBe("/assets/model.glb?v=1#part")
})
Loading