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
83 changes: 44 additions & 39 deletions src/three-components/GltfModel.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { useState, useEffect } from "react"
import * as THREE from "three"
import { GLTFLoader } from "three-stdlib"
import { useThree } from "src/react-three/ThreeContext"
import { useEffect, useState } from "react"
import ContainerWithTooltip from "src/ContainerWithTooltip"
import { getDefaultEnvironmentMap } from "src/react-three/getDefaultEnvironmentMap"
import { useThree } from "src/react-three/ThreeContext"
import type { CadModelFitMode, CadModelSize } from "src/utils/cad-model-fit"
import { load3DModel } from "src/utils/load-model"
import * as THREE from "three"
import { useCadModelTransformGraph } from "./useCadModelTransformGraph"

const DEFAULT_ENV_MAP_INTENSITY = 1.25
Expand Down Expand Up @@ -39,7 +39,7 @@ export function GltfModel({
isTranslucent?: boolean
}) {
const { renderer } = useThree()
const [model, setModel] = useState<THREE.Group | null>(null)
const [model, setModel] = useState<THREE.Object3D | null>(null)
const [loadError, setLoadError] = useState<Error | null>(null)
const { boardTransformGroup } = useCadModelTransformGraph({
model,
Expand All @@ -55,50 +55,51 @@ export function GltfModel({

useEffect(() => {
if (!gltfUrl) return
const loader = new GLTFLoader()
let isMounted = true
loader.load(
gltfUrl,
(gltf) => {

load3DModel(gltfUrl, "glb")
.then((loadedModel) => {
if (!isMounted) return
const scene = gltf.scene

scene.traverse((child) => {
if (child instanceof THREE.Mesh && child.material) {
const setMaterialTransparency = (mat: THREE.Material) => {
mat.transparent = isTranslucent
mat.opacity = isTranslucent ? 0.5 : 1
mat.depthWrite = !isTranslucent
mat.needsUpdate = true
}

if (Array.isArray(child.material)) {
child.material.forEach(setMaterialTransparency)
} else {
setMaterialTransparency(child.material)
}

child.renderOrder = isTranslucent ? 2 : 1
}
})

setModel(scene)
},
undefined,
(error) => {
setModel(loadedModel)
setLoadError(null)
})
.catch((error) => {
if (!isMounted) return
console.error(`An error happened loading ${gltfUrl}`, error)
const err =
error instanceof Error
? error
: new Error(`Failed to load glTF model from ${gltfUrl}`)
setLoadError(err)
},
)
})

return () => {
isMounted = false
}
}, [gltfUrl, isTranslucent])
}, [gltfUrl])

useEffect(() => {
if (!model) return

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

const setMaterialTransparency = (mat: THREE.Material) => {
mat.transparent = isTranslucent
mat.opacity = isTranslucent ? 0.5 : 1
mat.depthWrite = !isTranslucent
mat.needsUpdate = true
}

if (Array.isArray(child.material)) {
child.material.forEach(setMaterialTransparency)
} else {
setMaterialTransparency(child.material)
}

child.renderOrder = isTranslucent ? 2 : 1
})
}, [model, isTranslucent])

useEffect(() => {
if (!model || !renderer) return
Expand Down Expand Up @@ -145,11 +146,15 @@ export function GltfModel({
})

return () => {
previousMaterialState.forEach(({ material, envMap, envMapIntensity }) => {
for (const {
material,
envMap,
envMapIntensity,
} of previousMaterialState) {
material.envMap = envMap
material.envMapIntensity = envMapIntensity
material.needsUpdate = true
})
}
}
}, [model, renderer])

Expand Down
87 changes: 82 additions & 5 deletions src/utils/load-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,85 @@ 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 MODEL_CACHEBUST_PARAM = "cachebust_origin"
const modelLoadCache = new Map<string, Promise<THREE.Object3D | null>>()
type ModelExtension = "stl" | "obj" | "wrl" | "gltf" | "glb"

function isAbsoluteUrl(url: string) {
return /^(?:[a-z][a-z\d+\-.]*:)?\/\//i.test(url)
}

export function getModelExtension(url: string): string | null {
try {
const parsed = new URL(url, "https://tscircuit.local")
const match = parsed.pathname.toLowerCase().match(/\.([a-z0-9]+)$/)
return match?.[1] ?? null
} catch {
const pathname = url.split(/[?#]/, 1)[0]?.toLowerCase() ?? ""
const match = pathname.match(/\.([a-z0-9]+)$/)
return match?.[1] ?? null
}
}

export function normalizeModelCacheKey(url: string): string {
try {
const absolute = isAbsoluteUrl(url)
const rootRelative = url.startsWith("/")
const parsed = new URL(url, "https://tscircuit.local")
parsed.searchParams.delete(MODEL_CACHEBUST_PARAM)

if (absolute) return parsed.toString()

const relativeUrl = `${parsed.pathname}${parsed.search}${parsed.hash}`
return rootRelative ? relativeUrl : relativeUrl.replace(/^\//, "")
} catch {
return url
.replace(new RegExp(`([?&])${MODEL_CACHEBUST_PARAM}=[^&#]*&?`), "$1")
.replace(/[?&]$/, "")
}
}

export function cloneModelForUse(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
}

export async function load3DModel(
url: string,
fallbackExtension?: ModelExtension,
): Promise<THREE.Object3D | null> {
const extension = getModelExtension(url) ?? fallbackExtension
const cacheKey = `${normalizeModelCacheKey(url)}#${extension ?? "unknown"}`
let modelPromise = modelLoadCache.get(cacheKey)

if (!modelPromise) {
modelPromise = load3DModelUncached(url, extension).catch((error) => {
modelLoadCache.delete(cacheKey)
throw error
})
modelLoadCache.set(cacheKey, modelPromise)
}

const model = await modelPromise
return model ? cloneModelForUse(model) : null
}

async function load3DModelUncached(
url: string,
extension: string | null | undefined,
): 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 +93,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 Down
57 changes: 57 additions & 0 deletions tests/load-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { expect, test } from "bun:test"
import * as THREE from "three"
import {
cloneModelForUse,
getModelExtension,
normalizeModelCacheKey,
} from "../src/utils/load-model"

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

test("normalizes cachebust_origin without dropping meaningful query params", () => {
expect(
normalizeModelCacheKey(
"https://cdn.example.com/model.glb?cachebust_origin=one&variant=blue#top",
),
).toBe("https://cdn.example.com/model.glb?variant=blue#top")
expect(
normalizeModelCacheKey(
"/models/chip.obj?variant=blue&cachebust_origin=two",
),
).toBe("/models/chip.obj?variant=blue")
expect(normalizeModelCacheKey("models/chip.wrl?cachebust_origin=three")).toBe(
"models/chip.wrl",
)
})

test("keeps protocol-relative model hosts in cache keys", () => {
expect(normalizeModelCacheKey("//cdn-a.example.com/models/chip.glb")).toBe(
"https://cdn-a.example.com/models/chip.glb",
)
expect(normalizeModelCacheKey("//cdn-b.example.com/models/chip.glb")).toBe(
"https://cdn-b.example.com/models/chip.glb",
)
})

test("clones model materials so cached templates are not mutated per instance", () => {
const material = new THREE.MeshStandardMaterial({ color: "red" })
const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), material)
const group = new THREE.Group()
group.add(mesh)

const cloned = cloneModelForUse(group)
const clonedMesh = cloned.children[0] as THREE.Mesh

expect(cloned).not.toBe(group)
expect(clonedMesh.geometry).toBe(mesh.geometry)
expect(clonedMesh.material).not.toBe(material)

;(clonedMesh.material as THREE.MeshStandardMaterial).opacity = 0.25
expect(material.opacity).toBe(1)
})
Loading