diff --git a/src/three-components/GltfModel.tsx b/src/three-components/GltfModel.tsx index be58dc03..ea852bc4 100644 --- a/src/three-components/GltfModel.tsx +++ b/src/three-components/GltfModel.tsx @@ -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 @@ -39,7 +39,7 @@ export function GltfModel({ isTranslucent?: boolean }) { const { renderer } = useThree() - const [model, setModel] = useState(null) + const [model, setModel] = useState(null) const [loadError, setLoadError] = useState(null) const { boardTransformGroup } = useCadModelTransformGraph({ model, @@ -55,37 +55,15 @@ 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 = @@ -93,12 +71,35 @@ export function GltfModel({ ? 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 @@ -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]) diff --git a/src/utils/load-model.ts b/src/utils/load-model.ts index e80b9316..6ee05c46 100644 --- a/src/utils/load-model.ts +++ b/src/utils/load-model.ts @@ -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 { - if (url.endsWith(".stl")) { +const MODEL_CACHEBUST_PARAM = "cachebust_origin" +const modelLoadCache = new Map>() +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 { + 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 { + if (extension === "stl") { const loader = new STLLoader() const geometry = await loader.loadAsync(url) const material = new THREE.MeshStandardMaterial({ @@ -16,16 +93,16 @@ export async function load3DModel(url: string): Promise { 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 diff --git a/tests/load-model.test.ts b/tests/load-model.test.ts new file mode 100644 index 00000000..9ed9cf1f --- /dev/null +++ b/tests/load-model.test.ts @@ -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) +})