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 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/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 && ( diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx index 6b3ce98f..20f0a46a 100644 --- a/packages/viewer/src/components/renderers/item/item-renderer.tsx +++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx @@ -10,44 +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 } 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' 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 @@ -106,16 +81,12 @@ 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 const interactiveRef = useRef(node.asset.interactive) - if (nodes.cutout) { - nodes.cutout.visible = false - } - const handlers = useNodeEvents(node, 'item') useEffect(() => { @@ -130,31 +101,6 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { return () => useInteractive.getState().removeItem(node.id) }, [node.id]) - useMemo(() => { - 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 - } - }) - }, [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..fd8e45de --- /dev/null +++ b/packages/viewer/src/hooks/use-cached-gltf.ts @@ -0,0 +1,77 @@ +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 + nodes: Record + animations: import('three').AnimationClip[] +} + +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)! + }, [url, gltf]) +} + +export function clearGLTFCache() { + gltfCache.clear() +}