From b47e8446bd8dd6b250b309d7b6ead8b187b8dc1c Mon Sep 17 00:00:00 2001 From: BravadoBoss Date: Thu, 26 Mar 2026 21:38:17 -0400 Subject: [PATCH 1/6] Add sizes and loading props to catalog item images --- packages/editor/src/components/ui/item-catalog/item-catalog.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/editor/src/components/ui/item-catalog/item-catalog.tsx b/packages/editor/src/components/ui/item-catalog/item-catalog.tsx index d75a984e..216f967d 100644 --- a/packages/editor/src/components/ui/item-catalog/item-catalog.tsx +++ b/packages/editor/src/components/ui/item-catalog/item-catalog.tsx @@ -190,6 +190,8 @@ export function ItemCatalog({ category }: { category: CatalogCategory }) { alt={item.name} className="rounded-lg object-cover" fill + loading="eager" + sizes="56px" src={resolveCdnUrl(item.thumbnail) || ''} /> {attachmentIcon && ( From c7c4765b2c69408b4a01db4843a46c164bf70de6 Mon Sep 17 00:00:00 2001 From: BravadoBoss Date: Fri, 27 Mar 2026 00:47:25 -0400 Subject: [PATCH 2/6] add null check to registry cleanup and cache GLTF scene --- packages/core/src/hooks/scene-registry/scene-registry.ts | 3 ++- .../viewer/src/components/renderers/item/item-renderer.tsx | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index 02cb06b6..de0423a1 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -51,7 +51,8 @@ export function useRegistry( // 4. Cleanup when component unmounts return () => { sceneRegistry.nodes.delete(id) - sceneRegistry.byType[type].delete(id) + const bucket = sceneRegistry.byType[type] + if (bucket) bucket.delete(id) } }, [id, type, ref]) } diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx index 6b3ce98f..4fb95958 100644 --- a/packages/viewer/src/components/renderers/item/item-renderer.tsx +++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx @@ -23,6 +23,9 @@ import { useItemLightPool } from '../../../store/use-item-light-pool' import { ErrorBoundary } from '../../error-boundary' import { NodeRenderer } from '../node-renderer' +// Cache for processed GLTF scenes — prevents redundant traverse() calls +const processedScenes = new Set() + // Shared materials to avoid creating new instances for every mesh const defaultMaterial = new MeshStandardNodeMaterial({ color: 0xff_ff_ff, @@ -131,6 +134,7 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { }, [node.id]) useMemo(() => { + if (processedScenes.has(scene)) return scene.traverse((child) => { if ((child as Mesh).isMesh) { const mesh = child as Mesh @@ -153,6 +157,7 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { mesh.receiveShadow = !hasGlass } }) + processedScenes.add(scene) }, [scene]) const interactive = interactiveRef.current From 4ce19ed4b364abc13705eafee6bd441a01f2f5d0 Mon Sep 17 00:00:00 2001 From: korvix Date: Fri, 27 Mar 2026 01:12:46 -0400 Subject: [PATCH 3/6] add GLTF URL caching hook and remove redundant scene processing cache --- .../renderers/item/item-renderer.tsx | 38 ++----------------- packages/viewer/src/hooks/use-cached-gltf.ts | 22 +++++++++++ 2 files changed, 26 insertions(+), 34 deletions(-) create mode 100644 packages/viewer/src/hooks/use-cached-gltf.ts diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx index 4fb95958..57df9118 100644 --- a/packages/viewer/src/components/renderers/item/item-renderer.tsx +++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx @@ -10,22 +10,19 @@ import { } from '@pascal-app/core' import { useAnimations } from '@react-three/drei' import { Clone } from '@react-three/drei/core/Clone' -import { useGLTF } from '@react-three/drei/core/Gltf' import { useFrame } from '@react-three/fiber' -import { Suspense, useEffect, useMemo, useRef } from 'react' -import type { AnimationAction, Group, Material, Mesh } from 'three' +import { Suspense, useEffect, useRef } from 'react' +import type { AnimationAction, Group, Material } from 'three' import { MathUtils } from 'three' import { positionLocal, smoothstep, time } from 'three/tsl' import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu' +import { useCachedGLTF } from '../../../hooks/use-cached-gltf' import { useNodeEvents } from '../../../hooks/use-node-events' import { resolveCdnUrl } from '../../../lib/asset-url' import { useItemLightPool } from '../../../store/use-item-light-pool' import { ErrorBoundary } from '../../error-boundary' import { NodeRenderer } from '../node-renderer' -// Cache for processed GLTF scenes — prevents redundant traverse() calls -const processedScenes = new Set() - // Shared materials to avoid creating new instances for every mesh const defaultMaterial = new MeshStandardNodeMaterial({ color: 0xff_ff_ff, @@ -109,7 +106,7 @@ const multiplyScales = ( ): [number, number, number] => [a[0] * b[0], a[1] * b[1], a[2] * b[2]] const ModelRenderer = ({ node }: { node: ItemNode }) => { - const { scene, nodes, animations } = useGLTF(resolveCdnUrl(node.asset.src) || '') + const { scene, nodes, animations } = useCachedGLTF(resolveCdnUrl(node.asset.src) || '') const ref = useRef(null!) const { actions } = useAnimations(animations, ref) // Freeze the interactive definition at mount — asset schemas don't change at runtime @@ -133,33 +130,6 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { return () => useInteractive.getState().removeItem(node.id) }, [node.id]) - useMemo(() => { - if (processedScenes.has(scene)) return - scene.traverse((child) => { - if ((child as Mesh).isMesh) { - const mesh = child as Mesh - if (mesh.name === 'cutout') { - child.visible = false - return - } - - let hasGlass = false - - // Handle both single material and material array cases - if (Array.isArray(mesh.material)) { - mesh.material = mesh.material.map((mat) => getMaterialForOriginal(mat)) - hasGlass = mesh.material.some((mat) => mat.name === 'glass') - } else { - mesh.material = getMaterialForOriginal(mesh.material) - hasGlass = mesh.material.name === 'glass' - } - mesh.castShadow = !hasGlass - mesh.receiveShadow = !hasGlass - } - }) - processedScenes.add(scene) - }, [scene]) - const interactive = interactiveRef.current const animEffect = interactive?.effects.find((e): e is AnimationEffect => e.kind === 'animation') ?? null diff --git a/packages/viewer/src/hooks/use-cached-gltf.ts b/packages/viewer/src/hooks/use-cached-gltf.ts new file mode 100644 index 00000000..ed769903 --- /dev/null +++ b/packages/viewer/src/hooks/use-cached-gltf.ts @@ -0,0 +1,22 @@ +import { useGLTF } from '@react-three/drei/core/Gltf' +import { useMemo } from 'react' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const gltfCache = new Map() + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function useCachedGLTF(url: string): any { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const gltf: any = useGLTF(url) + + return useMemo(() => { + if (!gltfCache.has(url)) { + gltfCache.set(url, gltf) + } + return gltfCache.get(url)! + }, [url, gltf]) +} + +export function clearGLTFCache() { + gltfCache.clear() +} From fef54508231e3da89a83ebcdd2191259b1a7b097 Mon Sep 17 00:00:00 2001 From: korvix Date: Fri, 27 Mar 2026 01:16:26 -0400 Subject: [PATCH 4/6] updates AGENTS.md --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 3d1132a4..f6048fa3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ Node storage is a flat dictionary (`nodes: Record`). Systems are pu - **components/viewer/** — Root `` canvas, camera, lights, post-processing, selection manager - **components/renderers/** — One renderer per node type (`WallRenderer`, `SlabRenderer`, …), dispatched by `NodeRenderer` → `SceneRenderer` +- **hooks/** — `useCachedGLTF` (URL-level GLTF caching to avoid redundant loads) - **systems/** — Viewer-specific systems: `LevelSystem` (stacked/exploded/solo), `WallCutout`, `ZoneSystem`, `InteractiveSystem` - **store/** — `useViewer`: selection path, camera mode, level mode, wall mode, theme, display toggles From 30bd7743c7edc080795785f8a2feb8607a05133a Mon Sep 17 00:00:00 2001 From: korvix Date: Fri, 27 Mar 2026 01:39:02 -0400 Subject: [PATCH 5/6] adds explicit types --- packages/viewer/src/hooks/use-cached-gltf.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/viewer/src/hooks/use-cached-gltf.ts b/packages/viewer/src/hooks/use-cached-gltf.ts index ed769903..b1ddec65 100644 --- a/packages/viewer/src/hooks/use-cached-gltf.ts +++ b/packages/viewer/src/hooks/use-cached-gltf.ts @@ -1,13 +1,17 @@ import { useGLTF } from '@react-three/drei/core/Gltf' +import type { ObjectMap } from '@react-three/fiber' import { useMemo } from 'react' -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const gltfCache = new Map() +interface GLTFWithObjectMap extends ObjectMap { + scene: import('three').Group + nodes: Record + animations: import('three').AnimationClip[] +} + +const gltfCache = new Map() -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function useCachedGLTF(url: string): any { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const gltf: any = useGLTF(url) +export function useCachedGLTF(url: string): GLTFWithObjectMap { + const gltf = useGLTF(url) as GLTFWithObjectMap return useMemo(() => { if (!gltfCache.has(url)) { From 8f9ca320308b87fbec15a2693eb7082a24410eb1 Mon Sep 17 00:00:00 2001 From: korvix Date: Fri, 27 Mar 2026 01:51:03 -0400 Subject: [PATCH 6/6] restores material processing --- .../renderers/item/item-renderer.tsx | 33 +----------- packages/viewer/src/hooks/use-cached-gltf.ts | 51 +++++++++++++++++++ 2 files changed, 53 insertions(+), 31 deletions(-) diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx index 57df9118..20f0a46a 100644 --- a/packages/viewer/src/components/renderers/item/item-renderer.tsx +++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx @@ -12,10 +12,10 @@ import { useAnimations } from '@react-three/drei' import { Clone } from '@react-three/drei/core/Clone' import { useFrame } from '@react-three/fiber' import { Suspense, useEffect, useRef } from 'react' -import type { AnimationAction, Group, Material } from 'three' +import type { AnimationAction, Group } from 'three' import { MathUtils } from 'three' import { positionLocal, smoothstep, time } from 'three/tsl' -import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu' +import { MeshStandardNodeMaterial } from 'three/webgpu' import { useCachedGLTF } from '../../../hooks/use-cached-gltf' import { useNodeEvents } from '../../../hooks/use-node-events' import { resolveCdnUrl } from '../../../lib/asset-url' @@ -23,31 +23,6 @@ import { useItemLightPool } from '../../../store/use-item-light-pool' import { ErrorBoundary } from '../../error-boundary' import { NodeRenderer } from '../node-renderer' -// Shared materials to avoid creating new instances for every mesh -const defaultMaterial = new MeshStandardNodeMaterial({ - color: 0xff_ff_ff, - roughness: 1, - metalness: 0, -}) - -const glassMaterial = new MeshStandardNodeMaterial({ - name: 'glass', - color: 'lightgray', - roughness: 0.8, - metalness: 0, - transparent: true, - opacity: 0.35, - side: DoubleSide, - depthWrite: false, -}) - -const getMaterialForOriginal = (original: Material): MeshStandardNodeMaterial => { - if (original.name.toLowerCase() === 'glass') { - return glassMaterial - } - return defaultMaterial -} - const BrokenItemFallback = ({ node }: { node: ItemNode }) => { const handlers = useNodeEvents(node, 'item') const [w, h, d] = node.asset.dimensions @@ -112,10 +87,6 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { // Freeze the interactive definition at mount — asset schemas don't change at runtime const interactiveRef = useRef(node.asset.interactive) - if (nodes.cutout) { - nodes.cutout.visible = false - } - const handlers = useNodeEvents(node, 'item') useEffect(() => { diff --git a/packages/viewer/src/hooks/use-cached-gltf.ts b/packages/viewer/src/hooks/use-cached-gltf.ts index b1ddec65..fd8e45de 100644 --- a/packages/viewer/src/hooks/use-cached-gltf.ts +++ b/packages/viewer/src/hooks/use-cached-gltf.ts @@ -1,6 +1,32 @@ import { useGLTF } from '@react-three/drei/core/Gltf' import type { ObjectMap } from '@react-three/fiber' import { useMemo } from 'react' +import type { Material, Mesh } from 'three' +import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu' + +const defaultMaterial = new MeshStandardNodeMaterial({ + color: 0xff_ff_ff, + roughness: 1, + metalness: 0, +}) + +const glassMaterial = new MeshStandardNodeMaterial({ + name: 'glass', + color: 'lightgray', + roughness: 0.8, + metalness: 0, + transparent: true, + opacity: 0.35, + side: DoubleSide, + depthWrite: false, +}) + +function getMaterialForOriginal(original: Material): MeshStandardNodeMaterial { + if (original.name.toLowerCase() === 'glass') { + return glassMaterial + } + return defaultMaterial +} interface GLTFWithObjectMap extends ObjectMap { scene: import('three').Group @@ -10,11 +36,36 @@ interface GLTFWithObjectMap extends ObjectMap { const gltfCache = new Map() +function processSceneMaterials(scene: import('three').Group) { + scene.traverse((child) => { + if ((child as Mesh).isMesh) { + const mesh = child as Mesh + if (mesh.name === 'cutout') { + child.visible = false + return + } + + let hasGlass = false + + if (Array.isArray(mesh.material)) { + mesh.material = mesh.material.map((mat) => getMaterialForOriginal(mat)) + hasGlass = mesh.material.some((mat) => mat.name === 'glass') + } else { + mesh.material = getMaterialForOriginal(mesh.material) + hasGlass = mesh.material.name === 'glass' + } + mesh.castShadow = !hasGlass + mesh.receiveShadow = !hasGlass + } + }) +} + export function useCachedGLTF(url: string): GLTFWithObjectMap { const gltf = useGLTF(url) as GLTFWithObjectMap return useMemo(() => { if (!gltfCache.has(url)) { + processSceneMaterials(gltf.scene) gltfCache.set(url, gltf) } return gltfCache.get(url)!