From 4e510d0b2d0b96a311e5726fd4dd8de079ab11fe Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 15:14:00 +0000 Subject: [PATCH] fix: clone cached obj models --- src/hooks/use-global-obj-loader.ts | 45 ++++++++++++++++++++++----- tests/global-obj-loader-cache.test.ts | 27 ++++++++++++++++ 2 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 tests/global-obj-loader-cache.test.ts diff --git a/src/hooks/use-global-obj-loader.ts b/src/hooks/use-global-obj-loader.ts index 69422095..4a33e100 100644 --- a/src/hooks/use-global-obj-loader.ts +++ b/src/hooks/use-global-obj-loader.ts @@ -1,4 +1,5 @@ -import { useState, useEffect } from "react" +import { useEffect, useState } from "react" +import * as THREE from "three" import type { Object3D } from "three" import { MTLLoader, OBJLoader } from "three-stdlib" import { loadVrml } from "src/utils/vrml" @@ -9,6 +10,38 @@ interface CacheItem { result: Object3D | null } +export function cloneLoadedObject(source: Object3D): Object3D { + const clone = source.clone(true) + const sourceObjects: Object3D[] = [] + const clonedObjects: Object3D[] = [] + + source.traverse((object) => sourceObjects.push(object)) + clone.traverse((object) => clonedObjects.push(object)) + + clonedObjects.forEach((object, index) => { + const sourceObject = sourceObjects[index] + if ( + !(sourceObject instanceof THREE.Mesh) || + !(object instanceof THREE.Mesh) + ) { + return + } + + if (sourceObject.geometry) { + object.geometry = sourceObject.geometry + } + if (Array.isArray(sourceObject.material)) { + object.material = sourceObject.material.map((material) => + material.clone(), + ) + } else if (sourceObject.material) { + object.material = sourceObject.material.clone() + } + }) + + return clone +} + declare global { interface Window { TSCIRCUIT_OBJ_LOADER_CACHE: Map @@ -82,23 +115,19 @@ export function useGlobalObjLoader( if (cache.has(cleanUrl)) { const cacheItem = cache.get(cleanUrl)! if (cacheItem.result) { - // If we have a result, clone it - return Promise.resolve(cacheItem.result.clone()) + return Promise.resolve(cloneLoadedObject(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 cloneLoadedObject(result) }) } - // If it's not in the cache, create a new promise and cache it const promise = loadAndParseObj().then((result) => { if (result instanceof Error) { - // If the result is an Error, return it return result } cache.set(cleanUrl, { ...cache.get(cleanUrl)!, result }) - return result + return cloneLoadedObject(result) }) cache.set(cleanUrl, { promise, result: null }) return promise diff --git a/tests/global-obj-loader-cache.test.ts b/tests/global-obj-loader-cache.test.ts new file mode 100644 index 00000000..f5d10ced --- /dev/null +++ b/tests/global-obj-loader-cache.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "bun:test" +import * as THREE from "three" +import { cloneLoadedObject } from "../src/hooks/use-global-obj-loader" + +test("cloneLoadedObject deep-clones cached meshes while reusing geometry", () => { + const geometry = new THREE.BoxGeometry(1, 2, 3) + const material = new THREE.MeshStandardMaterial({ color: "red" }) + const mesh = new THREE.Mesh(geometry, material) + mesh.name = "cached-mesh" + + const source = new THREE.Group() + source.add(mesh) + + const firstClone = cloneLoadedObject(source) as THREE.Group + const secondClone = cloneLoadedObject(source) as THREE.Group + const firstMesh = firstClone.children[0] as THREE.Mesh + const secondMesh = secondClone.children[0] as THREE.Mesh + + expect(firstClone).not.toBe(source) + expect(firstMesh).not.toBe(mesh) + expect(secondMesh).not.toBe(firstMesh) + expect(firstMesh.geometry).toBe(geometry) + expect(secondMesh.geometry).toBe(geometry) + expect(firstMesh.material).not.toBe(material) + expect(secondMesh.material).not.toBe(material) + expect(secondMesh.material).not.toBe(firstMesh.material) +})