diff --git a/src/hooks/use-global-obj-loader.ts b/src/hooks/use-global-obj-loader.ts index 69422095..239667a8 100644 --- a/src/hooks/use-global-obj-loader.ts +++ b/src/hooks/use-global-obj-loader.ts @@ -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 { @@ -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 diff --git a/src/three-components/GltfModel.tsx b/src/three-components/GltfModel.tsx index be58dc03..59121fe4 100644 --- a/src/three-components/GltfModel.tsx +++ b/src/three-components/GltfModel.tsx @@ -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 @@ -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 } diff --git a/src/utils/load-cached-gltf-model.ts b/src/utils/load-cached-gltf-model.ts new file mode 100644 index 00000000..20ba1dfa --- /dev/null +++ b/src/utils/load-cached-gltf-model.ts @@ -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 + result: THREE.Group | null +} + +declare global { + interface Window { + TSCIRCUIT_GLTF_LOADER_CACHE: Map + } +} + +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 { + if (!window.TSCIRCUIT_GLTF_LOADER_CACHE) { + window.TSCIRCUIT_GLTF_LOADER_CACHE = new Map() + } + + return window.TSCIRCUIT_GLTF_LOADER_CACHE +} + +function loadGltfScene(url: string): Promise { + 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 { + 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) +} diff --git a/src/utils/model-url-cache-key.ts b/src/utils/model-url-cache-key.ts new file mode 100644 index 00000000..d64e3905 --- /dev/null +++ b/src/utils/model-url-cache-key.ts @@ -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 "" + }, + ) + } +} diff --git a/tests/load-cached-gltf-model.test.ts b/tests/load-cached-gltf-model.test.ts new file mode 100644 index 00000000..faf9385b --- /dev/null +++ b/tests/load-cached-gltf-model.test.ts @@ -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) +}) diff --git a/tests/model-url-cache-key.test.ts b/tests/model-url-cache-key.test.ts new file mode 100644 index 00000000..5207e9b1 --- /dev/null +++ b/tests/model-url-cache-key.test.ts @@ -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") +})