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
115 changes: 110 additions & 5 deletions src/utils/load-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,76 @@ 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<THREE.Object3D | null> {
if (url.endsWith(".stl")) {
export type CachedModel = {
promise: Promise<THREE.Object3D | null>
result: THREE.Object3D | null
}

export type LoadModelImplementation = (
url: string,
extension: string | null,
) => Promise<THREE.Object3D | null>

export type Load3DModelOptions = {
cache?: Map<string, CachedModel>
loadModel?: LoadModelImplementation
}

const sharedModelCache = new Map<string, CachedModel>()

export function getModelFileExtension(url: string): string | null {
const pathWithoutHash = url.split("#", 1)[0] ?? url
const pathWithoutQuery = pathWithoutHash.split("?", 1)[0] ?? pathWithoutHash
const match = pathWithoutQuery.toLowerCase().match(/\.([a-z0-9]+)$/)
return match?.[1] ?? null
}

export function normalizeModelUrlForCache(url: string): string {
const [beforeHash, ...hashParts] = url.split("#")
const hash = hashParts.length > 0 ? `#${hashParts.join("#")}` : ""
const [path, ...queryParts] = (beforeHash ?? url).split("?")
const query = queryParts.join("?")

if (!query) {
return url
}

const keptQueryParts = query
.split("&")
.filter((part) => {
const [rawKey = ""] = part.split("=", 1)
try {
return decodeURIComponent(rawKey) !== "cachebust_origin"
} catch {
return rawKey !== "cachebust_origin"
}
})
.filter(Boolean)

return `${path}${keptQueryParts.length > 0 ? `?${keptQueryParts.join("&")}` : ""}${hash}`
}

export function cloneLoadedModel(model: THREE.Object3D): THREE.Object3D {
const clone = model.clone(true)

clone.traverse((child) => {
if (!(child instanceof THREE.Mesh) || !child.material) return

if (Array.isArray(child.material)) {
child.material = child.material.map((material) => material.clone())
} else {
child.material = child.material.clone()
}
})

return clone
}

async function load3DModelUncached(
url: string,
extension: string | null,
): Promise<THREE.Object3D | null> {
if (extension === "stl") {
const loader = new STLLoader()
const geometry = await loader.loadAsync(url)
const material = new THREE.MeshStandardMaterial({
Expand All @@ -16,16 +84,16 @@ export async function load3DModel(url: string): Promise<THREE.Object3D | null> {
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
Expand All @@ -34,3 +102,40 @@ export async function load3DModel(url: string): Promise<THREE.Object3D | null> {
console.error("Unsupported file format or failed to load 3D model.")
return null
}

async function cloneCachedModel(cacheItem: CachedModel) {
const model = cacheItem.result ?? (await cacheItem.promise)
return model ? cloneLoadedModel(model) : null
}

export async function load3DModel(
url: string,
options: Load3DModelOptions = {},
): Promise<THREE.Object3D | null> {
const cache = options.cache ?? sharedModelCache
const loadModel = options.loadModel ?? load3DModelUncached
const cacheKey = normalizeModelUrlForCache(url)
const extension = getModelFileExtension(url)
const cachedModel = cache.get(cacheKey)

if (cachedModel) {
return cloneCachedModel(cachedModel)
}

const cacheItem: CachedModel = {
promise: loadModel(url, extension),
result: null,
}
cache.set(cacheKey, cacheItem)

try {
const model = await cacheItem.promise
cacheItem.result = model
return model ? cloneLoadedModel(model) : null
} catch (error) {
if (cache.get(cacheKey) === cacheItem) {
cache.delete(cacheKey)
}
throw error
}
}
88 changes: 88 additions & 0 deletions tests/load-model-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { expect, test } from "bun:test"
import * as THREE from "three"
import {
cloneLoadedModel,
getModelFileExtension,
load3DModel,
normalizeModelUrlForCache,
} from "../src/utils/load-model"

test("normalizes cachebust_origin out of model cache keys", () => {
expect(
normalizeModelUrlForCache(
"https://cdn.example.com/model.obj?cachebust_origin=&pn=C1#mesh",
),
).toBe("https://cdn.example.com/model.obj?pn=C1#mesh")

expect(
normalizeModelUrlForCache(
"https://cdn.example.com/model.obj?pn=C1&cachebust_origin=&uuid=abc",
),
).toBe("https://cdn.example.com/model.obj?pn=C1&uuid=abc")

expect(
normalizeModelUrlForCache(
"https://cdn.example.com/model.obj?cachebust_origin=",
),
).toBe("https://cdn.example.com/model.obj")
})

test("detects model extensions before query strings and hashes", () => {
expect(getModelFileExtension("https://cdn.example.com/model.glb?v=1")).toBe(
"glb",
)
expect(getModelFileExtension("/models/part.OBJ#mesh")).toBe("obj")
expect(getModelFileExtension("/models/part.stl?cachebust_origin=#mesh")).toBe(
"stl",
)
expect(getModelFileExtension("/models/part")).toBeNull()
})

test("clones loaded model materials for independent instances", () => {
const material = new THREE.MeshStandardMaterial({ color: 0xff0000 })
const model = new THREE.Group()
model.add(new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), material))

const firstClone = cloneLoadedModel(model) as THREE.Group
const secondClone = cloneLoadedModel(model) as THREE.Group
const firstMesh = firstClone.children[0] as THREE.Mesh
const secondMesh = secondClone.children[0] as THREE.Mesh

expect(firstClone).not.toBe(secondClone)
expect(firstMesh.material).not.toBe(secondMesh.material)
expect(firstMesh.material).not.toBe(material)
})

test("load3DModel deduplicates concurrent cache-busted loads", async () => {
const cache = new Map()
let loadCount = 0
const material = new THREE.MeshStandardMaterial({ color: 0x00ff00 })
const loadedModel = new THREE.Group()
loadedModel.add(new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), material))

const [firstModel, secondModel] = await Promise.all([
load3DModel("https://cdn.example.com/model.obj?cachebust_origin=1", {
cache,
loadModel: async (_url, extension) => {
loadCount += 1
expect(extension).toBe("obj")
return loadedModel
},
}),
load3DModel("https://cdn.example.com/model.obj?cachebust_origin=2", {
cache,
loadModel: async (_url, extension) => {
loadCount += 1
expect(extension).toBe("obj")
return loadedModel
},
}),
])

expect(loadCount).toBe(1)
expect(firstModel).not.toBe(secondModel)

const firstMesh = firstModel?.children[0] as THREE.Mesh
const secondMesh = secondModel?.children[0] as THREE.Mesh
expect(firstMesh.material).not.toBe(secondMesh.material)
})
Loading