Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 27 additions & 8 deletions src/three-components/STLModel.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -23,8 +23,8 @@ export function STLModel({
const [geom, setGeom] = useState<THREE.BufferGeometry | null>(null)

useEffect(() => {
const loader = new STLLoader()
if (stlData) {
const loader = new STLLoader()
try {
const geometry = loader.parse(stlData)
setGeom(geometry)
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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()
}
Expand Down
67 changes: 67 additions & 0 deletions src/utils/load-cached-stl-geometry.ts
Original file line number Diff line number Diff line change
@@ -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<string, Promise<THREE.BufferGeometry>>()

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<THREE.BufferGeometry> {
const cacheKey = normalizeStlUrlCacheKey(stlUrl)
let geometryPromise = stlGeometryCache.get(cacheKey)

if (!geometryPromise) {
geometryPromise = new Promise<THREE.BufferGeometry>((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()
}
80 changes: 80 additions & 0 deletions tests/load-cached-stl-geometry.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
Loading