From 647983b94e662a2885643d2f83b6e5e2c61b6212 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 12 May 2026 01:37:45 -0600 Subject: [PATCH] fix: cache gltf model loads --- src/hooks/use-global-obj-loader.ts | 12 +++- src/three-components/GltfModel.tsx | 104 ++++++++++++++++++++--------- src/utils/model-cache.ts | 41 ++++++++++++ tests/model-cache.test.ts | 34 ++++++++++ 4 files changed, 158 insertions(+), 33 deletions(-) create mode 100644 src/utils/model-cache.ts create mode 100644 tests/model-cache.test.ts diff --git a/src/hooks/use-global-obj-loader.ts b/src/hooks/use-global-obj-loader.ts index 6942209..770bd4a 100644 --- a/src/hooks/use-global-obj-loader.ts +++ b/src/hooks/use-global-obj-loader.ts @@ -1,6 +1,10 @@ import { useState, useEffect } from "react" import type { Object3D } from "three" import { MTLLoader, OBJLoader } from "three-stdlib" +import { + getModelCacheKey, + cloneObject3DWithUniqueMaterials, +} from "src/utils/model-cache" import { loadVrml } from "src/utils/vrml" // Define the type for our cache @@ -28,7 +32,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 @@ -83,12 +87,14 @@ export function useGlobalObjLoader( const cacheItem = cache.get(cleanUrl)! if (cacheItem.result) { // If we have a result, clone it - return Promise.resolve(cacheItem.result.clone()) + return Promise.resolve( + cloneObject3DWithUniqueMaterials(cacheItem.result), + ) } // If we're still loading, return the existing promise return cacheItem.promise.then((result) => { if (result instanceof Error) return result - return result.clone() + return cloneObject3DWithUniqueMaterials(result) }) } // If it's not in the cache, create a new promise and cache it diff --git a/src/three-components/GltfModel.tsx b/src/three-components/GltfModel.tsx index be58dc0..4de1783 100644 --- a/src/three-components/GltfModel.tsx +++ b/src/three-components/GltfModel.tsx @@ -5,10 +5,75 @@ 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 { + cloneObject3DWithUniqueMaterials, + getModelCacheKey, +} from "src/utils/model-cache" import { useCadModelTransformGraph } from "./useCadModelTransformGraph" const DEFAULT_ENV_MAP_INTENSITY = 1.25 +interface GltfCacheItem { + promise: Promise + result: THREE.Group | null +} + +declare global { + interface Window { + TSCIRCUIT_GLTF_LOADER_CACHE: Map + } +} + +function getGltfCache() { + if (!window.TSCIRCUIT_GLTF_LOADER_CACHE) { + window.TSCIRCUIT_GLTF_LOADER_CACHE = new Map() + } + + return window.TSCIRCUIT_GLTF_LOADER_CACHE +} + +async function loadCachedGltfModel(gltfUrl: string): Promise { + const cacheKey = getModelCacheKey(gltfUrl) + const cache = getGltfCache() + const cached = cache.get(cacheKey) + + if (cached) { + const scene = cached.result ?? (await cached.promise) + return cloneObject3DWithUniqueMaterials(scene) + } + + const loader = new GLTFLoader() + const promise = loader.loadAsync(gltfUrl).then((gltf) => { + cache.set(cacheKey, { ...cache.get(cacheKey)!, result: gltf.scene }) + return gltf.scene + }) + cache.set(cacheKey, { promise, result: null }) + + const scene = await promise + return cloneObject3DWithUniqueMaterials(scene) +} + +function applyModelTransparency(model: THREE.Object3D, isTranslucent: boolean) { + 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 + } + }) +} + export function GltfModel({ gltfUrl, position, @@ -55,37 +120,16 @@ 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((scene) => { + if (!isMounted) return + applyModelTransparency(scene, isTranslucent) setModel(scene) - }, - undefined, - (error) => { + setLoadError(null) + }) + .catch((error) => { if (!isMounted) return console.error(`An error happened loading ${gltfUrl}`, error) const err = @@ -93,8 +137,8 @@ export function GltfModel({ ? error : new Error(`Failed to load glTF model from ${gltfUrl}`) setLoadError(err) - }, - ) + }) + return () => { isMounted = false } diff --git a/src/utils/model-cache.ts b/src/utils/model-cache.ts new file mode 100644 index 0000000..f0c98b2 --- /dev/null +++ b/src/utils/model-cache.ts @@ -0,0 +1,41 @@ +import * as THREE from "three" + +export function getModelCacheKey(url: string): string { + if (!url.includes("cachebust_origin")) return url + + try { + const baseUrl = + typeof window !== "undefined" && window.location?.href + ? window.location.href + : "https://tscircuit.local/" + const parsed = new URL(url, baseUrl) + parsed.searchParams.delete("cachebust_origin") + + if (!/^https?:\/\//.test(url) && !url.startsWith("//")) { + return `${parsed.pathname}${parsed.search}${parsed.hash}` + } + + return parsed.toString() + } catch { + return url + .replace(/([?&])cachebust_origin=[^&]*&?/g, (_match, prefix) => prefix) + .replace(/[?&]$/, "") + .replace("?&", "?") + } +} + +export function cloneObject3DWithUniqueMaterials( + object: T, +): T { + const clone = object.clone(true) as T + + clone.traverse((child) => { + if (!(child instanceof THREE.Mesh) || !child.material) return + + child.material = Array.isArray(child.material) + ? child.material.map((material) => material.clone()) + : child.material.clone() + }) + + return clone +} diff --git a/tests/model-cache.test.ts b/tests/model-cache.test.ts new file mode 100644 index 0000000..87c4ee9 --- /dev/null +++ b/tests/model-cache.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test" +import * as THREE from "three" +import { + cloneObject3DWithUniqueMaterials, + getModelCacheKey, +} from "../src/utils/model-cache" + +test("model cache key removes only cachebust_origin query params", () => { + expect( + getModelCacheKey( + "https://modelcdn.tscircuit.com/model.glb?uuid=abc&cachebust_origin=https%3A%2F%2Ftscircuit.com&version=1", + ), + ).toBe("https://modelcdn.tscircuit.com/model.glb?uuid=abc&version=1") +}) + +test("model cache key preserves URLs without cachebust_origin", () => { + expect(getModelCacheKey("/models/chip.glb?variant=1")).toBe( + "/models/chip.glb?variant=1", + ) +}) + +test("cloneObject3DWithUniqueMaterials clones nested mesh materials", () => { + const material = new THREE.MeshStandardMaterial({ color: "red" }) + const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), material) + const group = new THREE.Group() + group.add(mesh) + + const clone = cloneObject3DWithUniqueMaterials(group) + const clonedMesh = clone.children[0] as THREE.Mesh + + expect(clone).not.toBe(group) + expect(clonedMesh).not.toBe(mesh) + expect(clonedMesh.material).not.toBe(material) +}) -- 2.43.0