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
56 changes: 28 additions & 28 deletions src/hooks/use-global-obj-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,14 @@ export function useGlobalObjLoader(

useEffect(() => {
if (!url) return
if (typeof window === "undefined") return

const cleanUrl = url.replace(/&cachebust_origin=$/, "")
const cleanUrl = url.split("&cachebust_origin=")[0] || url

const cache = window.TSCIRCUIT_OBJ_LOADER_CACHE
let hasUrlChanged = false
let cancelled = false

async function loadAndParseObj() {
async function loadAndParseObj(): Promise<Object3D | Error> {
try {
if (cleanUrl.endsWith(".wrl")) {
return await loadVrml(cleanUrl)
Expand All @@ -45,29 +46,30 @@ export function useGlobalObjLoader(
`Failed to fetch "${cleanUrl}": ${response.status} ${response.statusText}`,
)
}

const text = await response.text()
const objLoader = new OBJLoader()

const mtlContentArr = text.match(/newmtl[\s\S]*?endmtl/g)

const objLoader = new OBJLoader()

if (mtlContentArr?.length) {
const mtlContent = mtlContentArr.join("\n").replace(/d 0\./g, "d 1.")

const objContent = text
.replace(/newmtl[\s\S]*?endmtl/g, "")
.replace(/^mtllib.*/gm, "")

const mtlLoader = new MTLLoader()
mtlLoader.setMaterialOptions({
invertTrProperty: true,
})
mtlLoader.setMaterialOptions({ invertTrProperty: true })

const materials = mtlLoader.parse(
mtlContent.replace(
/Kd\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/g,
"Kd $2 $2 $2",
),
"embedded.mtl",
)

objLoader.setMaterials(materials)
return objLoader.parse(objContent)
}
Expand All @@ -78,43 +80,41 @@ export function useGlobalObjLoader(
}
}

function loadUrl() {
function loadUrl(): Promise<Object3D | Error> {
if (cache.has(cleanUrl)) {
const cacheItem = cache.get(cleanUrl)!
if (cacheItem.result) {
// If we have a result, clone it
return Promise.resolve(cacheItem.result.clone())
const cached = cache.get(cleanUrl)!
if (cached.result) {
return Promise.resolve(cached.result.clone())
}
// If we're still loading, return the existing promise
return cacheItem.promise.then((result) => {

return cached.promise.then((result) => {
if (result instanceof Error) return result
return result.clone()
})
}
// If it's not in the cache, create a new promise and cache it

const promise = loadAndParseObj().then((result) => {
if (result instanceof Error) {
// If the result is an Error, return it
return result
if (!(result instanceof Error)) {
cache.set(cleanUrl, { promise, result })
} else {
// Remove failed entry so future calls can retry
cache.delete(cleanUrl)
}
Comment on lines +97 to 102
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failed loads are cached permanently: When loadAndParseObj() returns an Error, the cache entry remains as { promise, result: null } (set on line 103). All subsequent requests for this URL will return the cached error indefinitely. Transient failures (network issues, temporary server errors) can never recover without a page refresh.

// Fix: Don't cache errors, or add cache expiry
const promise = loadAndParseObj().then((result) => {
  if (!(result instanceof Error)) {
    cache.set(cleanUrl, { promise, result })
  } else {
    // Remove failed entry to allow retry
    cache.delete(cleanUrl)
  }
  return result
})
Suggested change
if (!(result instanceof Error)) {
cache.set(cleanUrl, { promise, result })
}
if (!(result instanceof Error)) {
cache.set(cleanUrl, { promise, result })
} else {
// Remove failed entry to allow retry
cache.delete(cleanUrl)
}

Spotted by Graphite Agent

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

This comment came from an experimental review—please leave feedback if it was helpful/unhelpful. Learn more about experimental comments here.

cache.set(cleanUrl, { ...cache.get(cleanUrl)!, result })
return result
})

cache.set(cleanUrl, { promise, result: null })
return promise
}

loadUrl()
.then((result) => {
if (hasUrlChanged) return
loadUrl().then((result) => {
if (!cancelled) {
setObj(result)
})
.catch((error) => {
console.error(error)
})
}
})

return () => {
hasUrlChanged = true
cancelled = true
}
}, [url])

Expand Down
87 changes: 67 additions & 20 deletions src/utils/load-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,80 @@ import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js"
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js"
import { loadVrml } from "./vrml"

// ✅ Finished model cache
const modelCache = new Map<string, THREE.Object3D>()

// ✅ In-flight promise cache (prevents duplicate simultaneous loads)
const loadingPromises = new Map<string, Promise<THREE.Object3D | null>>()

export async function load3DModel(url: string): Promise<THREE.Object3D | null> {
if (url.endsWith(".stl")) {
const loader = new STLLoader()
const geometry = await loader.loadAsync(url)
const material = new THREE.MeshStandardMaterial({
color: 0x888888,
metalness: 0.5,
roughness: 0.5,
})
return new THREE.Mesh(geometry, material)
// ✅ Cache HIT (already loaded)
if (modelCache.has(url)) {
return modelCache.get(url)!.clone()
}
Comment on lines +15 to +17
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Race condition: Multiple simultaneous requests for the same uncached URL will all trigger separate network requests and parsing operations. Unlike use-global-obj-loader.ts which caches the promise itself, this only checks for completed results.

What breaks: If 10 components mount simultaneously requesting the same model, all 10 will make separate network requests instead of sharing one.

Fix: Cache the loading promise, not just the result:

const modelCache = new Map<string, THREE.Object3D>()
const loadingPromises = new Map<string, Promise<THREE.Object3D | null>>()

export async function load3DModel(url: string): Promise<THREE.Object3D | null> {
  if (modelCache.has(url)) {
    return modelCache.get(url)!.clone()
  }
  
  if (loadingPromises.has(url)) {
    const result = await loadingPromises.get(url)!
    return result ? result.clone() : null
  }
  
  const promise = (async () => {
    // ... existing loading logic ...
  })()
  
  loadingPromises.set(url, promise)
  const result = await promise
  loadingPromises.delete(url)
  
  return result ? result.clone() : null
}

Spotted by Graphite Agent

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

This comment came from an experimental review—please leave feedback if it was helpful/unhelpful. Learn more about experimental comments here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved


if (url.endsWith(".obj")) {
const loader = new OBJLoader()
return await loader.loadAsync(url)
// ✅ Already loading → await same promise
if (loadingPromises.has(url)) {
const result = await loadingPromises.get(url)!
return result ? result.clone() : null
}

if (url.endsWith(".wrl")) {
return await loadVrml(url)
}
// ✅ Start new load and store promise immediately
const promise = (async (): Promise<THREE.Object3D | null> => {
try {
// STL
if (url.endsWith(".stl")) {
const loader = new STLLoader()
const geometry = await loader.loadAsync(url)

const material = new THREE.MeshStandardMaterial({
color: 0x888888,
metalness: 0.5,
roughness: 0.5,
})

return new THREE.Mesh(geometry, material)
}

// OBJ
if (url.endsWith(".obj")) {
const loader = new OBJLoader()
return await loader.loadAsync(url)
}

// WRL
if (url.endsWith(".wrl")) {
return await loadVrml(url)
}

// GLTF / GLB
if (url.endsWith(".gltf") || url.endsWith(".glb")) {
const loader = new GLTFLoader()
const gltf = await loader.loadAsync(url)
return gltf.scene
}

console.error("Unsupported file format:", url)
return null
} catch (err) {
console.error("Failed to load model:", url, err)
return null
}
})()

loadingPromises.set(url, promise)

// Await load result
const result = await promise

// Remove promise after completion
loadingPromises.delete(url)

if (url.endsWith(".gltf") || url.endsWith(".glb")) {
const loader = new GLTFLoader()
const gltf = await loader.loadAsync(url)
return gltf.scene
// Store successful result in final cache
if (result) {
modelCache.set(url, result)
return result.clone()
}

console.error("Unsupported file format or failed to load 3D model.")
return null
}