diff --git a/src/three-components/STLModel.tsx b/src/three-components/STLModel.tsx index e5c41e9d..98aa66b7 100644 --- a/src/three-components/STLModel.tsx +++ b/src/three-components/STLModel.tsx @@ -1,13 +1,13 @@ -import { useState, useEffect, useMemo } from "react" +import { useEffect, useMemo, useState } from "react" +import { useThree } from "src/react-three/ThreeContext" import * as THREE from "three" import { STLLoader } from "three-stdlib" -import { useThree } from "src/react-three/ThreeContext" import type { LayerType } from "../hooks/use-stls-from-geom" +import { loadCachedStlGeometry } from "../utils/load-cached-stl-geometry" export function STLModel({ stlUrl, stlData, - mtlUrl, color, opacity = 1, layerType, @@ -23,8 +23,8 @@ export function STLModel({ const [geom, setGeom] = useState(null) useEffect(() => { - const loader = new STLLoader() if (stlData) { + const loader = new STLLoader() try { const geometry = loader.parse(stlData) setGeom(geometry) @@ -35,10 +35,27 @@ export function STLModel({ return } if (stlUrl) { - loader.load(stlUrl, (geometry) => { - setGeom(geometry) - }) + let isMounted = true + loadCachedStlGeometry(stlUrl) + .then((geometry) => { + if (!isMounted) { + geometry.dispose() + return + } + setGeom(geometry) + }) + .catch((error) => { + if (!isMounted) return + console.error(`Failed to load STL model from ${stlUrl}`, error) + setGeom(null) + }) + + return () => { + isMounted = false + } } + + setGeom(null) }, [stlUrl, stlData]) const mesh = useMemo(() => { @@ -66,7 +83,9 @@ export function STLModel({ rootObject.remove(mesh) mesh.geometry.dispose() if (Array.isArray(mesh.material)) { - mesh.material.forEach((m) => m.dispose()) + for (const material of mesh.material) { + material.dispose() + } } else { mesh.material.dispose() } diff --git a/src/utils/load-cached-stl-geometry.ts b/src/utils/load-cached-stl-geometry.ts new file mode 100644 index 00000000..d601c162 --- /dev/null +++ b/src/utils/load-cached-stl-geometry.ts @@ -0,0 +1,67 @@ +import type * as THREE from "three" +import { STLLoader } from "three-stdlib" + +type StlLoaderLike = { + load: ( + url: string, + onLoad: (geometry: THREE.BufferGeometry) => void, + onProgress?: (event: ProgressEvent) => void, + onError?: (error: unknown) => void, + ) => void +} + +const stlGeometryCache = new Map>() + +export function normalizeStlUrlCacheKey(stlUrl: string): string { + try { + const parsedUrl = new URL(stlUrl, "https://cache-key.invalid") + const isRelativeUrl = parsedUrl.origin === "https://cache-key.invalid" + parsedUrl.searchParams.delete("cachebust_origin") + + if (isRelativeUrl) { + return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}` + } + + return parsedUrl.toString() + } catch { + const hashParts = stlUrl.split("#", 2) + const urlWithoutHash = hashParts[0] ?? "" + const hash = hashParts[1] ?? "" + const [pathname, query = ""] = urlWithoutHash.split("?", 2) + const filteredQuery = query + .split("&") + .filter((part) => part.split("=", 1)[0] !== "cachebust_origin") + .join("&") + + return `${pathname}${filteredQuery ? `?${filteredQuery}` : ""}${ + hash ? `#${hash}` : "" + }` + } +} + +export function clearStlGeometryCache() { + stlGeometryCache.clear() +} + +export async function loadCachedStlGeometry( + stlUrl: string, + createLoader: () => StlLoaderLike = () => + new STLLoader() as unknown as StlLoaderLike, +): Promise { + const cacheKey = normalizeStlUrlCacheKey(stlUrl) + let geometryPromise = stlGeometryCache.get(cacheKey) + + if (!geometryPromise) { + geometryPromise = new Promise((resolve, reject) => { + createLoader().load(stlUrl, resolve, undefined, reject) + }).catch((error) => { + stlGeometryCache.delete(cacheKey) + throw error + }) + + stlGeometryCache.set(cacheKey, geometryPromise) + } + + const geometry = await geometryPromise + return geometry.clone() +} diff --git a/tests/load-cached-stl-geometry.test.ts b/tests/load-cached-stl-geometry.test.ts new file mode 100644 index 00000000..a61ab5b0 --- /dev/null +++ b/tests/load-cached-stl-geometry.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, expect, test } from "bun:test" +import * as THREE from "three" +import { + clearStlGeometryCache, + loadCachedStlGeometry, + normalizeStlUrlCacheKey, +} from "../src/utils/load-cached-stl-geometry" + +beforeEach(() => { + clearStlGeometryCache() +}) + +test("normalizes cachebust_origin out of STL cache keys", () => { + expect( + normalizeStlUrlCacheKey( + "https://models.example.com/part.stl?uuid=abc&cachebust_origin=preview#mesh", + ), + ).toBe("https://models.example.com/part.stl?uuid=abc#mesh") + expect( + normalizeStlUrlCacheKey("/models/part.stl?cachebust_origin=one&uuid=abc"), + ).toBe("/models/part.stl?uuid=abc") +}) + +test("deduplicates concurrent STL loads for equivalent cache-busted URLs", async () => { + const loadedUrls: string[] = [] + const templateGeometry = new THREE.BufferGeometry() + templateGeometry.setAttribute( + "position", + new THREE.BufferAttribute(new Float32Array([0, 0, 0, 1, 0, 0]), 3), + ) + + const createLoader = () => ({ + load: (url: string, onLoad: (geometry: THREE.BufferGeometry) => void) => { + loadedUrls.push(url) + queueMicrotask(() => onLoad(templateGeometry)) + }, + }) + + const [first, second] = await Promise.all([ + loadCachedStlGeometry( + "/models/part.stl?cachebust_origin=first&uuid=abc", + createLoader, + ), + loadCachedStlGeometry( + "/models/part.stl?uuid=abc&cachebust_origin=second", + createLoader, + ), + ]) + + expect(loadedUrls).toEqual([ + "/models/part.stl?cachebust_origin=first&uuid=abc", + ]) + expect(first).not.toBe(templateGeometry) + expect(second).not.toBe(templateGeometry) + expect(first).not.toBe(second) +}) + +test("does not cache failed STL loads", async () => { + let attempts = 0 + const createLoader = () => ({ + load: ( + _url: string, + _onLoad: (geometry: THREE.BufferGeometry) => void, + _onProgress: unknown, + onError?: (error: Error) => void, + ) => { + attempts += 1 + queueMicrotask(() => onError?.(new Error("failed"))) + }, + }) + + await expect( + loadCachedStlGeometry("/models/broken.stl", createLoader), + ).rejects.toThrow("failed") + await expect( + loadCachedStlGeometry("/models/broken.stl", createLoader), + ).rejects.toThrow("failed") + + expect(attempts).toBe(2) +})