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
67 changes: 62 additions & 5 deletions src/utils/load-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<THREE.Object3D | null> {
if (url.endsWith(".stl")) {
const modelCache = new Map<string, Promise<THREE.Object3D | null>>()

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<THREE.Object3D | null> {
const extension = getModelFileExtension(url)

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

export async function load3DModel(url: string): Promise<THREE.Object3D | null> {
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
}
55 changes: 55 additions & 0 deletions tests/load-model.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
Loading