From 2c7467208ce52d3c19af1707f00c4603f5abad7e Mon Sep 17 00:00:00 2001 From: starburtMr Date: Wed, 13 May 2026 11:26:03 +0800 Subject: [PATCH] Improve 3D model load caching --- src/utils/load-model.ts | 67 +++++++++++++++++++++++++++++++++++++--- tests/load-model.test.ts | 55 +++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 tests/load-model.test.ts diff --git a/src/utils/load-model.ts b/src/utils/load-model.ts index e80b9316..11a23d64 100644 --- a/src/utils/load-model.ts +++ b/src/utils/load-model.ts @@ -4,8 +4,49 @@ import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js" import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js" import { loadVrml } from "./vrml" -export async function load3DModel(url: string): Promise { - if (url.endsWith(".stl")) { +const modelCache = new Map>() + +export function getModelCacheKey(url: string): string { + try { + const isAbsoluteUrl = /^[a-z][a-z\d+\-.]*:\/\//i.test(url) + const parsedUrl = new URL(url, "https://cache-key.invalid") + parsedUrl.searchParams.delete("cachebust_origin") + const normalized = `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}` + return isAbsoluteUrl ? `${parsedUrl.origin}${normalized}` : normalized + } catch { + return url + .replace(/([?&])cachebust_origin=[^&#]*&?/g, "$1") + .replace(/[?&]$/, "") + } +} + +export function getModelFileExtension(url: string): string { + try { + const parsedUrl = new URL(url, "https://model-url.invalid") + return parsedUrl.pathname.split(".").pop()?.toLowerCase() ?? "" + } catch { + return url.split(/[?#]/)[0]?.split(".").pop()?.toLowerCase() ?? "" + } +} + +export function cloneModelInstance(model: THREE.Object3D): THREE.Object3D { + const clone = model.clone(true) + + 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 +} + +async function loadModelTemplate(url: string): Promise { + const extension = getModelFileExtension(url) + + if (extension === "stl") { const loader = new STLLoader() const geometry = await loader.loadAsync(url) const material = new THREE.MeshStandardMaterial({ @@ -16,16 +57,16 @@ export async function load3DModel(url: string): Promise { return new THREE.Mesh(geometry, material) } - if (url.endsWith(".obj")) { + if (extension === "obj") { const loader = new OBJLoader() return await loader.loadAsync(url) } - if (url.endsWith(".wrl")) { + if (extension === "wrl") { return await loadVrml(url) } - if (url.endsWith(".gltf") || url.endsWith(".glb")) { + if (extension === "gltf" || extension === "glb") { const loader = new GLTFLoader() const gltf = await loader.loadAsync(url) return gltf.scene @@ -34,3 +75,19 @@ export async function load3DModel(url: string): Promise { console.error("Unsupported file format or failed to load 3D model.") return null } + +export async function load3DModel(url: string): Promise { + const cacheKey = getModelCacheKey(url) + let templatePromise = modelCache.get(cacheKey) + + if (!templatePromise) { + templatePromise = loadModelTemplate(url).catch((error) => { + modelCache.delete(cacheKey) + throw error + }) + modelCache.set(cacheKey, templatePromise) + } + + const template = await templatePromise + return template ? cloneModelInstance(template) : null +} diff --git a/tests/load-model.test.ts b/tests/load-model.test.ts new file mode 100644 index 00000000..c8ec0658 --- /dev/null +++ b/tests/load-model.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test" +import * as THREE from "three" +import { + cloneModelInstance, + getModelCacheKey, + getModelFileExtension, +} from "../src/utils/load-model" + +test("detects model extensions from the URL path before query and hash fragments", () => { + expect( + getModelFileExtension( + "https://cdn.example.com/models/chip.glb?cachebust_origin=http%3A%2F%2Flocalhost#viewer", + ), + ).toBe("glb") + expect(getModelFileExtension("/models/part.OBJ?version=1")).toBe("obj") + expect(getModelFileExtension("/models/part.stl#mesh")).toBe("stl") + expect(getModelFileExtension("/models/part.wrl?cachebust_origin=")).toBe( + "wrl", + ) +}) + +test("normalizes cache keys by removing only cachebust_origin", () => { + expect( + getModelCacheKey( + "https://modelcdn.tscircuit.com/easyeda_models/download?uuid=abc&cachebust_origin=https%3A%2F%2Ftscircuit.com&pn=C1", + ), + ).toBe( + "https://modelcdn.tscircuit.com/easyeda_models/download?uuid=abc&pn=C1", + ) + + expect(getModelCacheKey("/models/chip.glb?cachebust_origin=x&v=1")).toBe( + "/models/chip.glb?v=1", + ) + expect(getModelCacheKey("models/chip.glb?cachebust_origin=x&v=1")).toBe( + "/models/chip.glb?v=1", + ) +}) + +test("clones cached model instances with independent materials", () => { + const material = new THREE.MeshStandardMaterial({ color: 0xff0000 }) + const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), material) + const model = new THREE.Group() + model.add(mesh) + + const clone = cloneModelInstance(model) as THREE.Group + const clonedMesh = clone.children[0] as THREE.Mesh + + expect(clone).not.toBe(model) + expect(clonedMesh).not.toBe(mesh) + expect(clonedMesh.geometry).toBe(mesh.geometry) + expect(clonedMesh.material).not.toBe(mesh.material) + + ;(clonedMesh.material as THREE.MeshStandardMaterial).opacity = 0.25 + expect((mesh.material as THREE.MeshStandardMaterial).opacity).toBe(1) +})