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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Node storage is a flat dictionary (`nodes: Record<id, AnyNode>`). Systems are pu

- **components/viewer/** — Root `<Viewer>` 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

Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/hooks/scene-registry/scene-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 && (
Expand Down
64 changes: 5 additions & 59 deletions packages/viewer/src/components/renderers/item/item-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Group>(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(() => {
Expand All @@ -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
Expand Down
77 changes: 77 additions & 0 deletions packages/viewer/src/hooks/use-cached-gltf.ts
Original file line number Diff line number Diff line change
@@ -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<string, import('three').Object3D>
animations: import('three').AnimationClip[]
}

const gltfCache = new Map<string, GLTFWithObjectMap>()

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()
}