Phase 5 Stage E: full kind migration into packages/nodes

Wholesale move of every remaining kind into its own subdirectory under
`packages/nodes/src/`, finishing the registry-driven migration. Each
kind now ships its definition, schema (re-exported from core), and any
of `geometry` / `renderer` / `system` / `floorplan` / `tool` /
`move-tool` / `panel` / `floorplan-move` / `floorplan-affordances` /
`parametrics` / `preview` it needs — no per-kind code remains under
`packages/editor/src/components/tools/` or
`packages/viewer/src/components/renderers/`.

Deleted (replaced by registry-driven equivalents):
- `tools/{ceiling,column,door,fence,item,slab,spawn,wall,window}/...`
  (boundary editors, hole editors, placement tools, move tools,
  endpoint movers, curve tools, helpers, math libs)
- `ui/helpers/{ceiling,slab,wall}-helper.tsx`
- `ui/panels/{column,door,elevator,item,roof,roof-segment,spawn,
  stair,stair-segment,wall,window}-panel.tsx`
- `viewer/src/components/renderers/{building,ceiling,column,door,
  elevator,fence,guide,item,level,roof,roof-segment,scan,site,slab,
  spawn,stair,stair-segment,wall,window,zone}-renderer.tsx`
- `viewer/src/components/viewer/legacy-system.tsx`

Added under `packages/nodes/src/`:
- `building/`, `column/`, `elevator/`, `guide/`, `level/`, `roof/`,
  `roof-segment/`, `scan/`, `shared/`, `site/`, `stair/`,
  `stair-segment/` packages with definition + schema + renderer / system
  / floorplan / panel as appropriate.
- New `floorplan-move.ts` for every kind that supports 2D moves
  (ceiling, door, item, shelf, slab, window) — single registry-driven
  dispatch path via `def.floorplanMoveTarget`.
- New `floorplan-affordances.ts` for kinds with polygon / endpoint
  drags (ceiling, fence, slab, wall) — using the shared
  `polygon-vertex-affordance` factories.
- New per-kind `panel.tsx` for kinds with custom inspector content
  (door, item, shelf, spawn, wall, window).
- New per-kind `tool.tsx` for placement (door, item, shelf, window).
- New per-kind `move-tool.tsx` for kinds with custom 3D move flows
  (door, item, slab, window).

Coordinator + manager updates in `packages/editor/`:
- `tool-manager.tsx` resolves tools from the registry only — no
  hardcoded type→component map.
- `panel-manager.tsx` resolves inspector panels the same way.
- `placement-{coordinator,strategies,types}.ts` extended with
  shelf-surface placement.
- `selection-manager.tsx` adds the registry-selectable fallback.
- `floorplan-panel.tsx`, `floorplan-background-placement.ts`,
  `floorplan-render-context.tsx` updated for the registry layer's new
  contract (props, affordance dispatch, render context).

Viewer updates:
- `viewer/index.tsx` drops legacy renderer mounts.
- `node-renderer.tsx` resolves by registry only.
- `scene-bvh.tsx`, `use-node-events.ts`, `level-system.tsx`,
  `wall-cutout.tsx`, `zone-system.tsx`, `materials.ts` adjusted for
  the registry-only world.

Sidebar tree nodes for ceiling / fence / slab / shelf / tree-node
updated to read from the registered nodes instead of the deleted
legacy renderer trees.

Wiki: new `plugin-authoring.md` page, README index updated.

Tests in `packages/nodes/src/index.test.ts` validate every registered
kind has the required shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-19 15:14:12 -04:00
co-authored by Claude Opus 4.7
parent 11015ea1ed
commit d747d2f0ea
204 changed files with 6888 additions and 7877 deletions
@@ -1,25 +0,0 @@
import { type BuildingNode, useRegistry } from '@pascal-app/core'
import { useRef } from 'react'
import type { Group } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { NodeRenderer } from '../node-renderer'
export const BuildingRenderer = ({ node }: { node: BuildingNode }) => {
const ref = useRef<Group>(null!)
useRegistry(node.id, node.type, ref)
const handlers = useNodeEvents(node, 'building')
return (
<group
position={node.position}
ref={ref}
rotation={[node.rotation[0], node.rotation[1], node.rotation[2]]}
{...handlers}
>
{node.children.map((childId) => (
<NodeRenderer key={childId} nodeId={childId} />
))}
</group>
)
}
@@ -1,103 +0,0 @@
import {
type CeilingNode,
getMaterialPresetByRef,
resolveMaterial,
useRegistry,
} from '@pascal-app/core'
import { useEffect, useMemo, useRef } from 'react'
import { BufferGeometry, Float32BufferAttribute } from 'three'
import { float, mix, positionWorld, smoothstep } from 'three/tsl'
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { NodeRenderer } from '../node-renderer'
function createEmptyGeometry() {
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute([], 3))
return geometry
}
const gridScale = 5
const gridX = positionWorld.x.mul(gridScale).fract()
const gridY = positionWorld.z.mul(gridScale).fract()
const lineWidth = 0.05
const lineX = smoothstep(lineWidth, 0, gridX).add(smoothstep(1.0 - lineWidth, 1.0, gridX))
const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.0, gridY))
const gridPattern = lineX.max(lineY)
const gridOpacity = mix(float(0.2), float(0.6), gridPattern)
function createCeilingMaterials(color = '#999999') {
const topMaterial = new MeshBasicNodeMaterial({
color,
transparent: true,
depthWrite: false,
side: FrontSide,
})
topMaterial.opacityNode = gridOpacity
const bottomMaterial = new MeshBasicNodeMaterial({
color,
transparent: true,
side: BackSide,
})
return { topMaterial, bottomMaterial }
}
const ceilingMaterialCache = new Map<string, ReturnType<typeof createCeilingMaterials>>()
function getCeilingMaterials(color = '#999999') {
const cacheKey = color
const cached = ceilingMaterialCache.get(cacheKey)
if (cached) return cached
const materials = createCeilingMaterials(color)
ceilingMaterialCache.set(cacheKey, materials)
return materials
}
export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
const ref = useRef<Mesh>(null!)
const placeholderGeometry = useMemo(createEmptyGeometry, [])
const gridPlaceholderGeometry = useMemo(createEmptyGeometry, [])
useRegistry(node.id, 'ceiling', ref)
const handlers = useNodeEvents(node, 'ceiling')
useEffect(
() => () => {
placeholderGeometry.dispose()
gridPlaceholderGeometry.dispose()
},
[gridPlaceholderGeometry, placeholderGeometry],
)
const materials = useMemo(() => {
const preset = getMaterialPresetByRef(node.materialPreset)
const props = preset?.mapProperties ?? resolveMaterial(node.material)
const color = props.color || '#999999'
return getCeilingMaterials(color)
}, [
node.materialPreset,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
])
return (
<mesh geometry={placeholderGeometry} material={materials.bottomMaterial} ref={ref}>
<mesh
geometry={gridPlaceholderGeometry}
material={materials.topMaterial}
name="ceiling-grid"
{...handlers}
scale={0}
visible={false}
/>
{node.children.map((childId) => (
<NodeRenderer key={childId} nodeId={childId} />
))}
</mesh>
)
}
File diff suppressed because it is too large Load Diff
@@ -1,32 +0,0 @@
import { type DoorNode, useRegistry, useScene } from '@pascal-app/core'
import { useLayoutEffect, useRef } from 'react'
import { type Mesh, MeshBasicMaterial } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
const doorHitboxMaterial = new MeshBasicMaterial({ visible: false })
export const DoorRenderer = ({ node }: { node: DoorNode }) => {
const ref = useRef<Mesh>(null!)
useRegistry(node.id, 'door', ref)
useLayoutEffect(() => {
useScene.getState().markDirty(node.id)
}, [node.id])
const handlers = useNodeEvents(node, 'door')
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient
return (
<mesh
castShadow
material={doorHitboxMaterial}
position={node.position}
receiveShadow
ref={ref}
rotation={node.rotation}
visible={node.visible}
{...(isTransient ? {} : handlers)}
>
<boxGeometry args={[0, 0, 0]} />
</mesh>
)
}
File diff suppressed because it is too large Load Diff
@@ -1,29 +0,0 @@
import { type FenceNode, useRegistry, useScene } from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
export const FenceRenderer = ({ node }: { node: FenceNode }) => {
const ref = useRef<Mesh>(null!)
const handlers = useNodeEvents(node, 'fence')
const material = useMemo(() => DEFAULT_STAIR_MATERIAL, [])
useRegistry(node.id, 'fence', ref)
useLayoutEffect(() => {
useScene.getState().markDirty(node.id)
}, [node.id])
return (
<mesh
castShadow
material={material}
receiveShadow
ref={ref}
visible={node.visible}
{...handlers}
>
<boxGeometry args={[0, 0, 0]} />
</mesh>
)
}
@@ -1,69 +0,0 @@
import { type GuideNode, useRegistry } from '@pascal-app/core'
import { useLoader } from '@react-three/fiber'
import { Suspense, useMemo, useRef } from 'react'
import { DoubleSide, type Group, type Texture, TextureLoader } from 'three'
import { float, texture } from 'three/tsl'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { useAssetUrl } from '../../../hooks/use-asset-url'
import useViewer from '../../../store/use-viewer'
export const GuideRenderer = ({ node }: { node: GuideNode }) => {
const showGuides = useViewer((s) => s.showGuides)
const ref = useRef<Group>(null!)
useRegistry(node.id, 'guide', ref)
const resolvedUrl = useAssetUrl(node.url)
return (
<group
position={node.position}
ref={ref}
rotation={[0, node.rotation[1], 0]}
visible={showGuides && node.visible !== false}
>
{resolvedUrl && (
<Suspense>
<GuidePlane opacity={node.opacity} scale={node.scale} url={resolvedUrl} />
</Suspense>
)}
</group>
)
}
const GuidePlane = ({ url, scale, opacity }: { url: string; scale: number; opacity: number }) => {
const tex = useLoader(TextureLoader, url) as Texture
const { width, height, material } = useMemo(() => {
const img = tex.image as HTMLImageElement | ImageBitmap
const w = img.width || 1
const h = img.height || 1
const aspect = w / h
// Default: 10 meters wide, height from aspect ratio
const planeWidth = 10 * scale
const planeHeight = (10 / aspect) * scale
const normalizedOpacity = opacity / 100
const mat = new MeshBasicNodeMaterial({
transparent: true,
colorNode: texture(tex),
opacityNode: float(normalizedOpacity),
side: DoubleSide,
depthWrite: false,
})
return { width: planeWidth, height: planeHeight, material: mat }
}, [tex, scale, opacity])
return (
<mesh
frustumCulled={false}
material={material}
raycast={() => {}}
rotation={[-Math.PI / 2, 0, 0]}
>
<planeGeometry args={[width, height]} boundingBox={null} boundingSphere={null} />
</mesh>
)
}
@@ -1,284 +0,0 @@
import {
type AnimationEffect,
type AnyNodeId,
type Interactive,
type ItemNode,
type LightEffect,
useInteractive,
useRegistry,
useScene,
} 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 { MathUtils } from 'three'
import { positionLocal, smoothstep, time } from 'three/tsl'
import { MeshStandardNodeMaterial } from 'three/webgpu'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { resolveCdnUrl } from '../../../lib/asset-url'
import { baseMaterial, glassMaterial } from '../../../lib/materials'
import { useItemLightPool } from '../../../store/use-item-light-pool'
import { ErrorBoundary } from '../../error-boundary'
import { NodeRenderer } from '../node-renderer'
const getMaterialForOriginal = (original: Material): Material => {
if (original.name.toLowerCase() === 'glass') {
return glassMaterial
}
return baseMaterial
}
const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
const handlers = useNodeEvents(node, 'item')
const [w, h, d] = node.asset.dimensions
return (
<mesh position-y={h / 2} {...handlers}>
<boxGeometry args={[w, h, d]} />
<meshStandardMaterial color="#ef4444" opacity={0.6} transparent wireframe />
</mesh>
)
}
export const ItemRenderer = ({ node }: { node: ItemNode }) => {
const ref = useRef<Group>(null!)
useRegistry(node.id, node.type, ref)
return (
<group position={node.position} ref={ref} rotation={node.rotation} visible={node.visible}>
<ErrorBoundary fallback={<BrokenItemFallback node={node} />}>
<Suspense fallback={<PreviewModel node={node} />}>
<ModelRenderer node={node} />
</Suspense>
</ErrorBoundary>
{node.children?.map((childId) => (
<NodeRenderer key={childId} nodeId={childId} />
))}
</group>
)
}
const previewMaterial = new MeshStandardNodeMaterial({
color: '#cccccc',
roughness: 1,
metalness: 0,
depthTest: false,
})
const previewOpacity = smoothstep(0.42, 0.55, positionLocal.y.add(time.mul(-0.2)).mul(10).fract())
previewMaterial.opacityNode = previewOpacity
previewMaterial.transparent = true
const PreviewModel = ({ node }: { node: ItemNode }) => {
return (
<mesh material={previewMaterial} position-y={node.asset.dimensions[1] / 2}>
<boxGeometry
args={[node.asset.dimensions[0], node.asset.dimensions[1], node.asset.dimensions[2]]}
/>
</mesh>
)
}
const multiplyScales = (
a: [number, number, number],
b: [number, number, number],
): [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 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(() => {
if (!node.parentId) return
useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
}, [node.parentId])
useEffect(() => {
const interactive = interactiveRef.current
if (!interactive) return
useInteractive.getState().initItem(node.id, interactive)
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')
// Fix geometry groups that reference materialIndex beyond the material
// array length — this causes three-mesh-bvh to crash with
// "Cannot read properties of undefined (reading 'side')"
const matCount = mesh.material.length
if (mesh.geometry.groups.length > 0) {
for (const group of mesh.geometry.groups) {
if (group.materialIndex !== undefined && group.materialIndex >= matCount) {
group.materialIndex = 0
}
}
}
} 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
const lightEffects =
interactive?.effects.filter((e): e is LightEffect => e.kind === 'light') ?? []
// useGLTF caches scenes, and Clone shares child geometry/material references.
// Undo can unmount one item while another clone of the same asset still needs them.
return (
<>
<Clone
dispose={null}
object={scene}
position={node.asset.offset}
ref={ref}
rotation={node.asset.rotation}
scale={multiplyScales(node.asset.scale || [1, 1, 1], node.scale || [1, 1, 1])}
{...handlers}
/>
{animations.length > 0 && (
<ItemAnimation
actions={actions}
animations={animations}
animEffect={animEffect}
interactive={interactive ?? null}
nodeId={node.id}
/>
)}
{lightEffects.map((effect, i) => (
<ItemLightRegistrar
effect={effect}
index={i}
interactive={interactive!}
key={i}
nodeId={node.id}
/>
))}
</>
)
}
const ItemAnimation = ({
nodeId,
animEffect,
interactive,
actions,
animations,
}: {
nodeId: AnyNodeId
animEffect: AnimationEffect | null
interactive: Interactive | null
actions: Record<string, AnimationAction | null>
animations: { name: string }[]
}) => {
const activeClipRef = useRef<string | null>(null)
const fadingOutRef = useRef<AnimationAction | null>(null)
// Reactive: derive target clip name — only re-renders when the clip name itself changes
const targetClip = useInteractive((s) => {
const values = s.items[nodeId]?.controlValues
if (!animEffect) return animations[0]?.name ?? null
const toggleIndex = interactive!.controls.findIndex((c) => c.kind === 'toggle')
const isOn = toggleIndex >= 0 ? Boolean(values?.[toggleIndex]) : false
return isOn
? (animEffect.clips.on ?? null)
: (animEffect.clips.off ?? animEffect.clips.loop ?? null)
})
// When target clip changes: kick off the transition
useEffect(() => {
// Cancel any ongoing fade-out immediately
if (fadingOutRef.current) {
fadingOutRef.current.timeScale = 0
fadingOutRef.current = null
}
// Move current clip to fade-out
if (activeClipRef.current && activeClipRef.current !== targetClip) {
const old = actions[activeClipRef.current]
if (old?.isRunning()) fadingOutRef.current = old
}
// Start new clip at timeScale 0.01 (as 0 would cause isRunning to be false and thus not play at all), then fade in to 1
activeClipRef.current = targetClip
if (targetClip) {
const next = actions[targetClip]
if (next) {
next.timeScale = 0.01
next.play()
}
}
}, [targetClip, actions])
// useFrame: only lerping — no logic
useFrame((_, delta) => {
if (fadingOutRef.current) {
const action = fadingOutRef.current
action.timeScale = MathUtils.lerp(action.timeScale, 0, Math.min(delta * 5, 1))
if (action.timeScale < 0.01) {
action.timeScale = 0
fadingOutRef.current = null
}
}
if (activeClipRef.current) {
const action = actions[activeClipRef.current]
if (action?.isRunning() && action.timeScale < 1) {
action.timeScale = MathUtils.lerp(action.timeScale, 1, Math.min(delta * 5, 1))
if (1 - action.timeScale < 0.01) action.timeScale = 1
}
}
})
return null
}
const ItemLightRegistrar = ({
nodeId,
effect,
interactive,
index,
}: {
nodeId: AnyNodeId
effect: LightEffect
interactive: Interactive
index: number
}) => {
useEffect(() => {
const key = `${nodeId}:${index}`
useItemLightPool.getState().register(key, nodeId, effect, interactive)
return () => useItemLightPool.getState().unregister(key)
}, [nodeId, index, effect, interactive])
return null
}
@@ -1,20 +0,0 @@
import { type LevelNode, useRegistry } from '@pascal-app/core'
import { useRef } from 'react'
import type { Group } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { NodeRenderer } from '../node-renderer'
export const LevelRenderer = ({ node }: { node: LevelNode }) => {
const ref = useRef<Group>(null!)
useRegistry(node.id, node.type, ref)
const handlers = useNodeEvents(node, 'level')
return (
<group ref={ref} {...handlers}>
{node.children.map((childId) => (
<NodeRenderer key={childId} nodeId={childId} />
))}
</group>
)
}
@@ -2,27 +2,7 @@
import { type AnyNode, nodeRegistry, type RendererSource, useScene } from '@pascal-app/core'
import { type ComponentType, lazy, Suspense } from 'react'
import { BuildingRenderer } from './building/building-renderer'
import { CeilingRenderer } from './ceiling/ceiling-renderer'
import { ColumnRenderer } from './column/column-renderer'
import { DoorRenderer } from './door/door-renderer'
import { ElevatorRenderer } from './elevator/elevator-renderer'
import { FenceRenderer } from './fence/fence-renderer'
import { GuideRenderer } from './guide/guide-renderer'
import { ItemRenderer } from './item/item-renderer'
import { LevelRenderer } from './level/level-renderer'
import { ParametricNodeRenderer } from './parametric-node-renderer'
import { RoofRenderer } from './roof/roof-renderer'
import { RoofSegmentRenderer } from './roof-segment/roof-segment-renderer'
import { ScanRenderer } from './scan/scan-renderer'
import { SiteRenderer } from './site/site-renderer'
import { SlabRenderer } from './slab/slab-renderer'
import { SpawnRenderer } from './spawn/spawn-renderer'
import { StairRenderer } from './stair/stair-renderer'
import { StairSegmentRenderer } from './stair-segment/stair-segment-renderer'
import { WallRenderer } from './wall/wall-renderer'
import { WindowRenderer } from './window/window-renderer'
import { ZoneRenderer } from './zone/zone-renderer'
// Cache lazy components by their RendererSource so React.lazy isn't re-invoked
// on every render — that would create a new Suspense boundary each time.
@@ -41,18 +21,17 @@ function getRegistryRenderer(
return Comp
}
function RegistryRenderer({ node }: { node: AnyNode }) {
export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
const node = useScene((state) => state.nodes[nodeId])
if (!node) return null
const def = nodeRegistry.get(node.type)
if (!def) return null
// Three-checkbox dispatch (see wiki/architecture/node-definitions.md):
// 1. Custom renderer overrides everything — JSX-side composition for
// kinds that need GLB, drei, <Html>, instancing, shader materials.
// 2. Else, if the kind ships `def.geometry`, use the generic empty-group
// <ParametricNodeRenderer>. `<GeometrySystem>` fills it from the pure
// builder. No per-kind renderer.tsx needed.
// 3. Else, the kind has neither — registered but unrenderable. Fall
// through to null; <NodeRenderer> falls back to the legacy switch
// (used by wall during milestone A before its runtime wired up).
// Two-checkbox dispatch (see wiki/architecture/node-definitions.md):
// 1. Custom renderer — JSX-side composition for kinds that need GLB,
// drei, <Html>, instancing, shader materials.
// 2. Else, if the kind ships `def.geometry`, the generic empty-group
// <ParametricNodeRenderer> is filled by <GeometrySystem> from the
// pure builder.
if (def.renderer) {
const Renderer = getRegistryRenderer(def.renderer as RendererSource<AnyNode>)
if (!Renderer) return null
@@ -67,42 +46,3 @@ function RegistryRenderer({ node }: { node: AnyNode }) {
}
return null
}
export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
const node = useScene((state) => state.nodes[nodeId])
if (!node) return null
// Registry-first: if a NodeDefinition is registered for this kind (via
// @pascal-app/nodes or a future plugin), it owns the render. Falls through
// to the legacy chain below for kinds not yet migrated. Legacy chain is
// removed in Phase 6 once every kind is registry-backed.
if (nodeRegistry.has(node.type)) {
return <RegistryRenderer node={node} />
}
return (
<>
{node.type === 'site' && <SiteRenderer node={node} />}
{node.type === 'building' && <BuildingRenderer node={node} />}
{node.type === 'ceiling' && <CeilingRenderer node={node} />}
{node.type === 'column' && <ColumnRenderer node={node} />}
{node.type === 'elevator' && <ElevatorRenderer node={node} />}
{node.type === 'level' && <LevelRenderer node={node} />}
{node.type === 'item' && <ItemRenderer node={node} />}
{node.type === 'slab' && <SlabRenderer node={node} />}
{node.type === 'spawn' && <SpawnRenderer node={node} />}
{node.type === 'wall' && <WallRenderer node={node} />}
{node.type === 'fence' && <FenceRenderer node={node} />}
{node.type === 'door' && <DoorRenderer node={node} />}
{node.type === 'window' && <WindowRenderer node={node} />}
{node.type === 'zone' && <ZoneRenderer node={node} />}
{node.type === 'roof' && <RoofRenderer node={node} />}
{node.type === 'roof-segment' && <RoofSegmentRenderer node={node} />}
{node.type === 'stair' && <StairRenderer node={node} />}
{node.type === 'stair-segment' && <StairSegmentRenderer node={node} />}
{node.type === 'scan' && <ScanRenderer node={node} />}
{node.type === 'guide' && <GuideRenderer node={node} />}
</>
)
}
@@ -1,63 +0,0 @@
import {
type AnyNodeId,
type RoofNode,
type RoofSegmentNode,
useRegistry,
useScene,
} from '@pascal-app/core'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import useViewer from '../../../store/use-viewer'
import { getRoofMaterialArray } from '../../../systems/roof/roof-materials'
import { roofDebugMaterials, roofMaterials } from '../roof/roof-materials'
export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
const ref = useRef<THREE.Mesh>(null!)
const nodes = useScene((state) => state.nodes)
useRegistry(node.id, 'roof-segment', ref)
const handlers = useNodeEvents(node, 'roof-segment')
const debugColors = useViewer((s) => s.debugColors)
const parentNode = node.parentId
? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined)
: undefined
const placeholderGeometry = useMemo(() => {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
geometry.addGroup(0, 0, 0)
geometry.addGroup(0, 0, 1)
geometry.addGroup(0, 0, 2)
geometry.addGroup(0, 0, 3)
return geometry
}, [])
const customMaterial = useMemo(() => {
if (node.material !== undefined || typeof node.materialPreset === 'string') {
return null
}
return parentNode ? getRoofMaterialArray(parentNode) : null
}, [node, parentNode])
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
useEffect(() => {
return () => {
placeholderGeometry.dispose()
}
}, [placeholderGeometry])
return (
<mesh
geometry={placeholderGeometry}
material={material}
position={node.position}
ref={ref}
rotation-y={node.rotation}
visible={node.visible}
{...handlers}
/>
)
}
@@ -1,18 +0,0 @@
import * as THREE from 'three'
// Production materials — match the rest of the scene (white walls, light-gray slabs).
// Indices: 0 = Wall/Trim, 1 = Deck, 2 = Interior, 3 = Shingle
export const roofMaterials: THREE.Material[] = [
new THREE.MeshStandardMaterial({ color: 'white', roughness: 1, side: THREE.DoubleSide }), // 0: Wall/Trim
new THREE.MeshStandardMaterial({ color: '#e5e5e5', roughness: 1, side: THREE.FrontSide }), // 1: Deck
new THREE.MeshStandardMaterial({ color: 'white', roughness: 1, side: THREE.DoubleSide }), // 2: Interior
new THREE.MeshStandardMaterial({ color: '#e5e5e5', roughness: 0.9, side: THREE.FrontSide }), // 3: Shingle
]
// Debug materials — vivid, distinct colours to identify each surface group.
export const roofDebugMaterials: THREE.Material[] = [
new THREE.MeshStandardMaterial({ color: '#eaeaea', roughness: 0.8, side: THREE.DoubleSide }), // 0: Wall
new THREE.MeshStandardMaterial({ color: '#000000', roughness: 0.9, side: THREE.FrontSide }), // 1: Deck
new THREE.MeshStandardMaterial({ color: '#dddddd', roughness: 0.9, side: THREE.DoubleSide }), // 2: Interior
new THREE.MeshStandardMaterial({ color: '#4ade80', roughness: 0.9, side: THREE.FrontSide }), // 3: Shingle
]
@@ -1,59 +0,0 @@
import { type RoofNode, useRegistry } from '@pascal-app/core'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import useViewer from '../../../store/use-viewer'
import { getRoofMaterialArray } from '../../../systems/roof/roof-materials'
import { NodeRenderer } from '../node-renderer'
import { roofDebugMaterials, roofMaterials } from './roof-materials'
export const RoofRenderer = ({ node }: { node: RoofNode }) => {
const ref = useRef<THREE.Group>(null!)
useRegistry(node.id, 'roof', ref)
const handlers = useNodeEvents(node, 'roof')
const debugColors = useViewer((s) => s.debugColors)
const placeholderGeometry = useMemo(() => {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
geometry.addGroup(0, 0, 0)
geometry.addGroup(0, 0, 1)
geometry.addGroup(0, 0, 2)
geometry.addGroup(0, 0, 3)
return geometry
}, [])
const customMaterial = useMemo(() => getRoofMaterialArray(node), [node])
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
useEffect(() => {
return () => {
placeholderGeometry.dispose()
}
}, [placeholderGeometry])
return (
<group
position={node.position}
ref={ref}
rotation-y={node.rotation}
visible={node.visible}
{...handlers}
>
<mesh
castShadow
geometry={placeholderGeometry}
material={material}
name="merged-roof"
receiveShadow
/>
<group name="segments-wrapper" visible={false}>
{(node.children ?? []).map((childId) => (
<NodeRenderer key={childId} nodeId={childId} />
))}
</group>
</group>
)
}
@@ -1,77 +0,0 @@
import { type ScanNode, useRegistry } from '@pascal-app/core'
import { Suspense, useMemo, useRef } from 'react'
import type { Group, Material, Mesh } from 'three'
import { useAssetUrl } from '../../../hooks/use-asset-url'
import { useGLTFKTX2 } from '../../../hooks/use-gltf-ktx2'
import useViewer from '../../../store/use-viewer'
export const ScanRenderer = ({ node }: { node: ScanNode }) => {
const showScans = useViewer((s) => s.showScans)
const ref = useRef<Group>(null!)
useRegistry(node.id, 'scan', ref)
const resolvedUrl = useAssetUrl(node.url)
return (
<group
position={node.position}
ref={ref}
rotation={node.rotation}
scale={[node.scale, node.scale, node.scale]}
visible={showScans}
>
{resolvedUrl && (
<Suspense>
<ScanModel opacity={node.opacity} url={resolvedUrl} />
</Suspense>
)}
</group>
)
}
const ScanModel = ({ url, opacity }: { url: string; opacity: number }) => {
const gltf = useGLTFKTX2(url) as any
const scene = gltf.scene
useMemo(() => {
const normalizedOpacity = opacity / 100
const isTransparent = normalizedOpacity < 1
const updateMaterial = (material: Material) => {
if (isTransparent) {
material.transparent = true
material.opacity = normalizedOpacity
material.depthWrite = false
} else {
material.transparent = false
material.opacity = 1
material.depthWrite = true
}
material.needsUpdate = true
}
scene.traverse((child: any) => {
if ((child as Mesh).isMesh) {
const mesh = child as Mesh
// Disable raycasting
mesh.raycast = () => {}
// Exclude from bounding box calculations
mesh.geometry.boundingBox = null
mesh.geometry.boundingSphere = null
mesh.frustumCulled = false
if (Array.isArray(mesh.material)) {
mesh.material.forEach((material) => {
updateMaterial(material)
})
} else {
updateMaterial(mesh.material)
}
}
})
}, [scene, opacity])
return <primitive object={scene} />
}
@@ -1,156 +0,0 @@
import { type SiteNode, type SlabNode, useRegistry, useScene } from '@pascal-app/core'
import { useMemo, useRef } from 'react'
import { BufferGeometry, Float32BufferAttribute, type Group, Path, Shape } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { unionPolygons } from '../../../lib/polygon-union'
import useViewer from '../../../store/use-viewer'
import { NodeRenderer } from '../node-renderer'
const Y_OFFSET = 0.01
/**
* Creates simple line geometry for site boundary
* Single horizontal line at ground level
*/
const createBoundaryLineGeometry = (points: Array<[number, number]>): BufferGeometry => {
const geometry = new BufferGeometry()
if (points.length < 2) return geometry
const positions: number[] = []
// Create a simple line loop at ground level
for (const [x, z] of points) {
positions.push(x ?? 0, Y_OFFSET, z ?? 0)
}
// Close the loop
positions.push(points[0]?.[0] ?? 0, Y_OFFSET, points[0]?.[1] ?? 0)
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
return geometry
}
type S = ReturnType<typeof useScene.getState>
export const SiteRenderer = ({ node }: { node: SiteNode }) => {
const ref = useRef<Group>(null!)
useRegistry(node.id, 'site', ref)
const theme = useViewer((state) => state.theme)
const bgColor = theme === 'dark' ? '#1f2433' : '#fafafa'
// Cache slab polygon references to keep the selector stable across unrelated store updates
const slabPolygonsCache = useRef<[number, number][][]>([])
const slabPolygons = useScene((state: S) => {
const nodeList = Object.values(state.nodes)
const levelIndexById = new Map<string, number>()
let lowestLevelIndex = Number.POSITIVE_INFINITY
nodeList.forEach((n) => {
if (n.type !== 'level') return
levelIndexById.set(n.id, n.level)
lowestLevelIndex = Math.min(lowestLevelIndex, n.level)
})
const next = nodeList
.filter(
(n): n is SlabNode =>
n.type === 'slab' &&
n.visible &&
n.polygon.length >= 3 &&
// Only recessed slabs should punch through the site ground.
// Positive slabs are real floor geometry and should not create a
// ghost footprint in the background ground fill.
(n.elevation ?? 0.05) < 0,
)
.filter((n) => {
if (!Number.isFinite(lowestLevelIndex)) return true
const parentLevel = n.parentId ? levelIndexById.get(n.parentId as string) : undefined
return parentLevel === lowestLevelIndex
})
.map((n) => n.polygon as [number, number][])
const prev = slabPolygonsCache.current
if (next.length === prev.length && next.every((p, i) => p === prev[i])) return prev
slabPolygonsCache.current = next
return next
})
// Ground shape: site polygon with slab footprints punched as holes
const groundShape = useMemo(() => {
if (!node?.polygon?.points || node.polygon.points.length < 3) return null
const pts = node.polygon.points
const shape = new Shape()
shape.moveTo(pts[0]![0], -pts[0]![1])
for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i]![0], -pts[i]![1])
shape.closePath()
if (slabPolygons.length > 0) {
for (const ring of unionPolygons(slabPolygons.map((p) => p.map((pt) => [pt[0], -pt[1]])))) {
if (ring.length < 3) continue
const hole = new Path()
hole.moveTo(ring[0]![0], ring[0]![1])
for (let i = 1; i < ring.length; i++) hole.lineTo(ring[i]![0], ring[i]![1])
hole.closePath()
shape.holes.push(hole)
}
}
return shape
}, [node?.polygon?.points, slabPolygons])
// Create boundary line geometry
const lineGeometry = useMemo(() => {
if (!node?.polygon?.points || node.polygon.points.length < 2) return null
return createBoundaryLineGeometry(node.polygon.points)
}, [node?.polygon?.points])
const handlers = useNodeEvents(node, 'site')
if (!(node && lineGeometry)) {
return null
}
return (
<group ref={ref} {...handlers}>
{/* Render children (buildings and items) */}
{node.children.map((child) => (
<NodeRenderer
key={typeof child === 'string' ? child : child.id}
nodeId={typeof child === 'string' ? child : child.id}
/>
))}
{/* Ground fill: site polygon with slab holes, occludes below-grade geometry */}
{groundShape && (
<mesh position={[0, -0.05, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<shapeGeometry args={[groundShape]} />
{/* PERF TEST: basic material — no PBR / shadows / lighting calc.
Ground color = canvas background, so lighting is invisible work. */}
<meshBasicMaterial
color={bgColor}
polygonOffset={true}
polygonOffsetFactor={1}
polygonOffsetUnits={1}
/>
{/* <meshStandardMaterial
color={bgColor}
depthWrite={true}
polygonOffset={true}
polygonOffsetFactor={1}
polygonOffsetUnits={1}
/> */}
</mesh>
)}
{/* Simple boundary line */}
{/* @ts-ignore */}
<line frustumCulled={false} geometry={lineGeometry} renderOrder={9}>
<lineBasicMaterial color="#f59e0b" linewidth={2} opacity={0.6} transparent />
</line>
</group>
)
}
@@ -1,95 +0,0 @@
import { getMaterialPresetByRef, type SlabNode, useRegistry } from '@pascal-app/core'
import { useEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three'
import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import {
applyMaterialPresetToMaterials,
createMaterial,
DEFAULT_SLAB_MATERIAL,
} from '../../../lib/materials'
const slabMaterialCache = new Map<string, THREE.MeshStandardMaterial>()
function createEmptyGeometry() {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
return geometry
}
function getSlabMaterial(
cacheKey: string,
params: { material?: SlabNode['material']; materialPreset?: string },
) {
const cached = slabMaterialCache.get(cacheKey)
if (cached) return cached
const preset = getMaterialPresetByRef(params.materialPreset)
const slabMaterial = preset
? new THREE.MeshStandardMaterial()
: params.material
? createMaterial(params.material).clone()
: DEFAULT_SLAB_MATERIAL.clone()
if (preset) {
// Apply the preset to the slab-owned material so async texture loads update
// the instance we actually render after refresh as well.
applyMaterialPresetToMaterials(slabMaterial, preset)
}
// Slabs participate in the WebGPU MRT scene pass. Keeping them opaque avoids
// pipeline variants that can fail when geometry is regenerated while a
// transparent/custom material is attached.
slabMaterial.transparent = false
slabMaterial.opacity = 1
slabMaterial.alphaMap = null
slabMaterial.side = THREE.DoubleSide
slabMaterial.depthWrite = true
slabMaterial.needsUpdate = true
slabMaterialCache.set(cacheKey, slabMaterial)
return slabMaterial
}
export const SlabRenderer = ({ node }: { node: SlabNode }) => {
const ref = useRef<Mesh>(null!)
const placeholderGeometry = useMemo(createEmptyGeometry, [])
useRegistry(node.id, 'slab', ref)
const handlers = useNodeEvents(node, 'slab')
useEffect(() => () => placeholderGeometry.dispose(), [placeholderGeometry])
const material = useMemo(() => {
const resolvedMaterial = node.material
const resolvedMaterialPreset = node.materialPreset
const cacheKey = JSON.stringify({
material: resolvedMaterial ?? null,
materialPreset: resolvedMaterialPreset ?? null,
})
return getSlabMaterial(cacheKey, {
material: resolvedMaterial,
materialPreset: resolvedMaterialPreset,
})
}, [
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
node.materialPreset,
])
return (
<mesh
castShadow
geometry={placeholderGeometry}
receiveShadow
ref={ref}
{...handlers}
material={material}
visible={node.visible}
/>
)
}
@@ -1,68 +0,0 @@
import { type SpawnNode, useLiveTransforms, useRegistry } from '@pascal-app/core'
import { useMemo, useRef } from 'react'
import type { Group } from 'three'
import { Color, Shape } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import useViewer from '../../../store/use-viewer'
const SPAWN_COLOR = new Color('#22c55e')
export const SpawnRenderer = ({ node }: { node: SpawnNode }) => {
const ref = useRef<Group>(null!)
const handlers = useNodeEvents(node, 'spawn')
const liveTransform = useLiveTransforms((state) => state.get(node.id))
const walkthroughMode = useViewer((state) => state.walkthroughMode)
useRegistry(node.id, 'spawn', ref)
const materialProps = useMemo(
() => ({
color: SPAWN_COLOR,
emissive: SPAWN_COLOR,
emissiveIntensity: 0.08,
metalness: 0.03,
roughness: 0.42,
}),
[],
)
const arrowShape = useMemo(() => {
const shape = new Shape()
// Positive local Y becomes negative world Z after the -90deg X rotation below,
// so this tip points "forward" for the player/spawn direction.
shape.moveTo(0, 0.24)
shape.lineTo(-0.18, -0.14)
shape.lineTo(0.18, -0.14)
shape.closePath()
return shape
}, [])
return (
<group
position={liveTransform?.position ?? node.position}
ref={ref}
rotation={[0, liveTransform?.rotation ?? node.rotation, 0]}
visible={!walkthroughMode}
>
<mesh position={[0, 0.09, 0]} rotation={[-Math.PI / 2, 0, 0]} {...handlers}>
<ringGeometry args={[0.34, 0.48, 48]} />
<meshStandardMaterial {...materialProps} />
</mesh>
<mesh position={[0, 0.1, -0.52]} rotation={[-Math.PI / 2, 0, 0]} {...handlers}>
<shapeGeometry args={[arrowShape]} />
<meshStandardMaterial {...materialProps} />
</mesh>
<mesh position={[0, 0.41, 0]} {...handlers}>
<boxGeometry args={[0.3, 0.54, 0.16]} />
<meshStandardMaterial {...materialProps} />
</mesh>
<mesh position={[0, 0.83, 0]} {...handlers}>
<boxGeometry args={[0.18, 0.18, 0.18]} />
<meshStandardMaterial {...materialProps} />
</mesh>
</group>
)
}
@@ -1,74 +0,0 @@
import {
type AnyNodeId,
type StairNode,
type StairSegmentNode,
useRegistry,
useScene,
} from '@pascal-app/core'
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { getStraightStairSegmentBodyMaterials } from '../../../systems/stair/stair-materials'
export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
const ref = useRef<THREE.Mesh>(null!)
const nodes = useScene((state) => state.nodes)
useRegistry(node.id, 'stair-segment', ref)
useLayoutEffect(() => {
useScene.getState().markDirty(node.id)
}, [node.id])
const handlers = useNodeEvents(node, 'stair-segment')
const parentNode = node.parentId
? (nodes[node.parentId as AnyNodeId] as StairNode | undefined)
: undefined
const material = useMemo(() => {
return getStraightStairSegmentBodyMaterials(node, parentNode)
}, [
node.materialPreset,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
parentNode?.materialPreset,
parentNode?.material,
parentNode?.material?.preset,
parentNode?.material?.properties,
parentNode?.material?.texture,
parentNode?.railingMaterialPreset,
parentNode?.railingMaterial,
parentNode?.sideMaterialPreset,
parentNode?.sideMaterial,
parentNode?.treadMaterialPreset,
parentNode?.treadMaterial,
])
const placeholderGeometry = useMemo(() => {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
geometry.addGroup(0, 0, 0)
geometry.addGroup(0, 0, 1)
return geometry
}, [])
useEffect(() => {
return () => {
placeholderGeometry.dispose()
}
}, [placeholderGeometry])
return (
<mesh
geometry={placeholderGeometry}
material={material}
position={node.position}
ref={ref}
rotation-y={node.rotation}
visible={node.visible}
{...handlers}
/>
)
}
File diff suppressed because it is too large Load Diff
@@ -1,63 +0,0 @@
import { useRegistry, useScene, type WallNode } from '@pascal-app/core'
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import { BufferGeometry, Float32BufferAttribute, type Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { getVisibleWallMaterials } from '../../../systems/wall/wall-materials'
import { NodeRenderer } from '../node-renderer'
function createEmptyWallGeometry() {
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute([], 3))
geometry.addGroup(0, 0, 0)
geometry.addGroup(0, 0, 1)
geometry.addGroup(0, 0, 2)
return geometry
}
export const WallRenderer = ({ node }: { node: WallNode }) => {
const ref = useRef<Mesh>(null!)
const placeholderGeometry = useMemo(createEmptyWallGeometry, [])
const collisionPlaceholderGeometry = useMemo(() => {
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute([], 3))
return geometry
}, [])
useRegistry(node.id, 'wall', ref)
useLayoutEffect(() => {
useScene.getState().markDirty(node.id)
}, [node.id])
useEffect(() => {
return () => {
placeholderGeometry.dispose()
collisionPlaceholderGeometry.dispose()
}
}, [collisionPlaceholderGeometry, placeholderGeometry])
const handlers = useNodeEvents(node, 'wall')
const material = getVisibleWallMaterials(node)
return (
<mesh
castShadow
geometry={placeholderGeometry}
material={material}
receiveShadow
ref={ref}
visible={node.visible}
>
<mesh
geometry={collisionPlaceholderGeometry}
name="collision-mesh"
visible={false}
{...handlers}
/>
{node.children.map((childId) => (
<NodeRenderer key={`${node.id}:${childId}`} nodeId={childId} />
))}
</mesh>
)
}
@@ -1,35 +0,0 @@
import { useRegistry, useScene, type WindowNode } from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, DEFAULT_WINDOW_MATERIAL } from '../../../lib/materials'
export const WindowRenderer = ({ node }: { node: WindowNode }) => {
const ref = useRef<Mesh>(null!)
useRegistry(node.id, 'window', ref)
useLayoutEffect(() => {
useScene.getState().markDirty(node.id)
}, [node.id])
const handlers = useNodeEvents(node, 'window')
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient
const material = useMemo(() => {
const mat = node.material
if (!mat) return DEFAULT_WINDOW_MATERIAL
return createMaterial(mat)
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
return (
<mesh
material={material}
position={node.position}
ref={ref}
rotation={node.rotation}
visible={node.visible}
{...(isTransient ? {} : handlers)}
>
<boxGeometry args={[0, 0, 0]} />
</mesh>
)
}
@@ -1,255 +0,0 @@
import { useRegistry, type ZoneNode } from '@pascal-app/core'
import { Html } from '@react-three/drei'
import { useMemo, useRef } from 'react'
import { BufferGeometry, Color, DoubleSide, Float32BufferAttribute, type Group, Shape } from 'three'
import { color, float, uniform, uv } from 'three/tsl'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { ZONE_LAYER } from '../../../lib/layers'
const Y_OFFSET = 0.01
const WALL_HEIGHT = 2.3
/**
* Creates a gradient material for zone walls using TSL
* Gradient goes from zone color at bottom to transparent at top
*/
const createWallGradientMaterial = (zoneColor: string) => {
const baseColor = color(new Color(zoneColor))
// Use UV y coordinate for vertical gradient (0 at bottom, 1 at top)
const gradientT = uv().y
const opacity = uniform(0)
// Fade opacity from 0.6 at bottom to 0 at top
const finalOpacity = float(0.6).mul(float(1).sub(gradientT)).mul(opacity)
return new MeshBasicNodeMaterial({
transparent: true,
colorNode: baseColor,
opacityNode: finalOpacity,
side: DoubleSide,
depthWrite: true,
depthTest: false,
userData: {
uOpacity: opacity,
},
})
}
/**
* Creates a floor material for zones using TSL
*/
const createFloorMaterial = (zoneColor: string) => {
const baseColor = color(new Color(zoneColor))
const opacity = uniform(0)
return new MeshBasicNodeMaterial({
transparent: true,
colorNode: baseColor,
opacityNode: float(0.25).mul(opacity),
side: DoubleSide,
depthWrite: false,
depthTest: false,
userData: { uOpacity: opacity },
})
}
/**
* Creates wall geometry for zone borders
* Each wall segment is a vertical quad from one polygon point to the next
*/
const createWallGeometry = (polygon: Array<[number, number]>): BufferGeometry => {
const geometry = new BufferGeometry()
if (polygon.length < 2) return geometry
const positions: number[] = []
const uvs: number[] = []
const indices: number[] = []
// Create a wall segment for each edge of the polygon
for (let i = 0; i < polygon.length; i++) {
const current = polygon[i]!
const next = polygon[(i + 1) % polygon.length]!
const baseIndex = i * 4
// Four vertices per wall segment (two triangles forming a quad)
// Bottom-left
positions.push(current[0]!, Y_OFFSET, current[1]!)
uvs.push(0, 0)
// Bottom-right
positions.push(next[0]!, Y_OFFSET, next[1]!)
uvs.push(1, 0)
// Top-right
positions.push(next[0]!, Y_OFFSET + WALL_HEIGHT, next[1]!)
uvs.push(1, 1)
// Top-left
positions.push(current[0]!, Y_OFFSET + WALL_HEIGHT, current[1]!)
uvs.push(0, 1)
// Two triangles for the quad
indices.push(baseIndex, baseIndex + 1, baseIndex + 2, baseIndex, baseIndex + 2, baseIndex + 3)
}
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2))
geometry.setIndex(indices)
geometry.computeVertexNormals()
return geometry
}
export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
const ref = useRef<Group>(null!)
useRegistry(node.id, 'zone', ref)
// Create floor shape from polygon
const floorShape = useMemo(() => {
if (!node?.polygon || node.polygon.length < 3) return null
const shape = new Shape()
const firstPt = node.polygon[0]!
// Shape is in X-Y plane, we rotate it to X-Z plane
// Negate Y (which becomes Z) to get correct orientation
shape.moveTo(firstPt[0]!, -firstPt[1]!)
for (let i = 1; i < node.polygon.length; i++) {
const pt = node.polygon[i]!
shape.lineTo(pt[0]!, -pt[1]!)
}
shape.closePath()
return shape
}, [node?.polygon])
// Create wall geometry from polygon
const wallGeometry = useMemo(() => {
if (!node?.polygon || node.polygon.length < 2) return null
return createWallGeometry(node.polygon)
}, [node?.polygon])
// Calculate polygon centroid for label positioning using the geometric centroid formula
// This correctly handles polygons regardless of vertex distribution along edges
const centroid = useMemo(() => {
if (!node?.polygon || node.polygon.length < 3) return [0, 0] as [number, number]
const polygon = node.polygon
let signedArea = 0
let cx = 0
let cz = 0
for (let i = 0; i < polygon.length; i++) {
const [x0, z0] = polygon[i]!
const [x1, z1] = polygon[(i + 1) % polygon.length]!
// Cross product for signed area
const cross = x0 * z1 - x1 * z0
signedArea += cross
cx += (x0 + x1) * cross
cz += (z0 + z1) * cross
}
signedArea /= 2
const factor = 1 / (6 * signedArea)
return [cx * factor, cz * factor] as [number, number]
}, [node?.polygon])
// Create materials
const floorMaterial = useMemo(() => {
if (!node?.color) return null
return createFloorMaterial(node.color)
}, [node?.color])
const wallMaterial = useMemo(() => {
if (!node?.color) return null
return createWallGradientMaterial(node.color)
}, [node?.color])
const handlers = useNodeEvents(node, 'zone')
if (!(node && floorShape && wallGeometry && floorMaterial && wallMaterial)) {
return null
}
return (
<group ref={ref} {...handlers} userData={{ labelPosition: [centroid[0], 1, centroid[1]] }}>
<Html
name="label"
position={[centroid[0], 1, centroid[1]]}
style={{ pointerEvents: 'none' }}
zIndexRange={[10, 0]}
>
<div
id={`${node.id}-label`}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
transform: 'translate3d(-50%, -50%, 0)',
opacity: 0,
transition: 'opacity 0.3s ease-in-out',
}}
>
<div
style={{
width: 'max-content',
color: 'white',
textShadow: `-1px -1px 0 ${node.color}, 1px -1px 0 ${node.color}, -1px 1px 0 ${node.color}, 1px 1px 0 ${node.color}`,
textAlign: 'center',
}}
>
<span>{node.name}</span>
</div>
<div
className="label-pin"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginTop: '2px',
opacity: 0,
transition: 'opacity 0.5s ease-in-out',
}}
>
<div
style={{
width: '2px',
height: '40px',
backgroundColor: node.color,
}}
/>
<div
style={{
width: '10px',
height: '10px',
borderRadius: '50%',
backgroundColor: node.color,
border: '1px solid white',
}}
/>
</div>
</div>
</Html>
{/* Floor fill */}
<mesh
layers={ZONE_LAYER}
material={floorMaterial}
name="floor"
position={[0, Y_OFFSET, 0]}
rotation={[-Math.PI / 2, 0, 0]}
>
<shapeGeometry args={[floorShape]} />
</mesh>
{/* Wall borders with gradient */}
<mesh geometry={wallGeometry} layers={ZONE_LAYER} material={wallMaterial} name="walls" />
</group>
)
}
@@ -1,34 +1,14 @@
'use client'
import { ElevatorOpeningSystem, ElevatorRuntimeSystem } from '@pascal-app/core'
import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three/webgpu'
import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf'
import useViewer from '../../store/use-viewer'
import { CeilingSystem } from '../../systems/ceiling/ceiling-system'
import { DoorAnimationSystem } from '../../systems/door/door-animation-system'
import { DoorSystem } from '../../systems/door/door-system'
import { ElevatorInteractionSystem } from '../../systems/elevator/elevator-interaction-system'
import { FenceSystem } from '../../systems/fence/fence-system'
import { GeometrySystem } from '../../systems/geometry/geometry-system'
import { GuideSystem } from '../../systems/guide/guide-system'
import { ItemSystem } from '../../systems/item/item-system'
import { ItemLightSystem } from '../../systems/item-light/item-light-system'
import { LevelSystem } from '../../systems/level/level-system'
import { RoofSystem } from '../../systems/roof/roof-system'
import { ScanSystem } from '../../systems/scan/scan-system'
import { SlabSystem } from '../../systems/slab/slab-system'
import { StairSystem } from '../../systems/stair/stair-system'
import { WallCutout } from '../../systems/wall/wall-cutout'
import { WallSystem } from '../../systems/wall/wall-system'
import { WindowAnimationSystem } from '../../systems/window/window-animation-system'
import { WindowSystem } from '../../systems/window/window-system'
import { ZoneSystem } from '../../systems/zone/zone-system'
import { ErrorBoundary } from '../error-boundary'
import { SceneRenderer } from '../renderers/scene-renderer'
import FrameLimiter from './frame-limiter'
import { LegacySystem } from './legacy-system'
import { Lights } from './lights'
import { PerfMonitor } from './perf-monitor'
import PostProcessing, { DEFAULT_HOVER_STYLES, type HoverStyles } from './post-processing'
@@ -225,84 +205,16 @@ const Viewer: React.FC<ViewerProps> = ({
<SceneRenderer />
)}
{/* Default Systems */}
<LegacySystem kind="level">
<LevelSystem />
</LegacySystem>
<LegacySystem kind="guide">
<GuideSystem />
</LegacySystem>
<LegacySystem kind="scan">
<ScanSystem />
</LegacySystem>
<LegacySystem kind="wall">
<WallCutout />
</LegacySystem>
{/* Core systems */}
<LegacySystem kind="ceiling">
<CeilingSystem />
</LegacySystem>
<LegacySystem kind="door">
<DoorAnimationSystem />
</LegacySystem>
<LegacySystem kind="elevator">
<ElevatorRuntimeSystem />
</LegacySystem>
<LegacySystem kind="elevator">
<ElevatorInteractionSystem />
</LegacySystem>
<LegacySystem kind="elevator">
<ElevatorOpeningSystem />
</LegacySystem>
<LegacySystem kind="window">
<WindowAnimationSystem />
</LegacySystem>
<LegacySystem kind="door">
<DoorSystem />
</LegacySystem>
<LegacySystem kind="fence">
<FenceSystem />
</LegacySystem>
<LegacySystem kind="item">
<ItemSystem />
</LegacySystem>
<LegacySystem kind="roof">
<RoofSystem />
</LegacySystem>
<LegacySystem kind="slab">
<SlabSystem />
</LegacySystem>
<LegacySystem kind="stair">
<StairSystem />
</LegacySystem>
<LegacySystem kind="wall">
<WallSystem />
</LegacySystem>
<LegacySystem kind="window">
<WindowSystem />
</LegacySystem>
<LegacySystem kind="zone">
<ZoneSystem />
</LegacySystem>
{/* Generic geometry rebuild loop for any registered kind that
ships `def.geometry`. Reads dirtyNodes, calls the kind's pure
builder, swaps the registered group's children. Runs alongside
per-kind systems — they coexist, this system only acts on
kinds whose definition exposes a `geometry` function. See
builder, swaps the registered group's children. See
wiki/architecture/node-definitions.md. */}
<GeometrySystem />
{/* Mounts systems contributed by registry-backed kinds. Today the
registry is empty so this renders nothing. Once kinds register
(Phase 2+), each kind's registered system runs here and its
legacy counterpart above short-circuits via the LegacySystem
wrapper (which checks nodeRegistry.has). */}
{/* Mounts systems contributed by registry-backed kinds. Each
kind's `def.system` is loaded via lazy() and rendered here,
ordered by `system.priority`. */}
<RegisteredSystems />
<PostProcessing hoverStyles={hoverStyles} />
{/* <DebugRenderer /> */}
<LegacySystem kind="item">
<ItemLightSystem />
</LegacySystem>
{selectionManager === 'default' && <SelectionManager />}
{(perf || PERF_OVERLAY_ENABLED) && <PerfMonitor />}
{children}
@@ -1,20 +0,0 @@
'use client'
import { nodeRegistry } from '@pascal-app/core'
import type { ReactNode } from 'react'
/**
* Wraps a legacy per-kind system component so it short-circuits the moment a
* NodeDefinition for the same kind appears in the registry. Lets us migrate
* one kind at a time without editing each legacy system file individually.
*
* Multiple legacy systems can belong to the same kind (e.g. door has both
* `<DoorSystem>` and `<DoorAnimationSystem>`) — wrap them together so they
* yield as a unit when the kind registers.
*
* Removed in Phase 6 alongside the legacy systems themselves.
*/
export function LegacySystem({ kind, children }: { kind: string; children: ReactNode }) {
if (nodeRegistry.has(kind)) return null
return <>{children}</>
}
@@ -85,8 +85,15 @@ export const SceneBvh = forwardRef<Group, SceneBvhProps>(
if (geometry.boundsTree || !hasBvhCompatibleGeometry(geometry)) return
try {
geometry.computeBoundsTree = computeBoundsTree
geometry.disposeBoundsTree = disposeBoundsTree
// The three-mesh-bvh + @types/three combo doesn't agree on
// BVH option / class identity (ComputeBVHOptions vs
// MeshBVHOptions, GeometryBVH vs MeshBVH) — cast through
// `unknown` to bypass the structural mismatch. Runtime is
// fine; we're just calling the library's own helpers.
;(geometry as { computeBoundsTree?: unknown }).computeBoundsTree =
computeBoundsTree as unknown as typeof geometry.computeBoundsTree
;(geometry as { disposeBoundsTree?: unknown }).disposeBoundsTree =
disposeBoundsTree as unknown as typeof geometry.disposeBoundsTree
geometry.computeBoundsTree(options)
computedGeometries.add(geometry)
} catch (error) {
+17 -66
View File
@@ -1,77 +1,25 @@
import {
type BuildingEvent,
type BuildingNode,
type CeilingEvent,
type CeilingNode,
type ColumnEvent,
type ColumnNode,
type DoorEvent,
type DoorNode,
type ElevatorEvent,
type ElevatorNode,
type AnyNode,
type AnyNodeType,
type EventSuffix,
emitter,
type FenceEvent,
type FenceNode,
type ItemEvent,
type ItemNode,
type LevelEvent,
type LevelNode,
type RoofEvent,
type RoofNode,
type RoofSegmentEvent,
type RoofSegmentNode,
type ShelfEvent,
type ShelfNode,
type SiteEvent,
type SiteNode,
type SlabEvent,
type SlabNode,
type SpawnEvent,
type SpawnNode,
type StairEvent,
type StairNode,
type StairSegmentEvent,
type StairSegmentNode,
type WallEvent,
type WallNode,
type WindowEvent,
type WindowNode,
type ZoneEvent,
type ZoneNode,
type NodeEvent,
} from '@pascal-app/core'
import type { ThreeEvent } from '@react-three/fiber'
import useViewer from '../store/use-viewer'
type NodeConfig = {
site: { node: SiteNode; event: SiteEvent }
item: { node: ItemNode; event: ItemEvent }
wall: { node: WallNode; event: WallEvent }
fence: { node: FenceNode; event: FenceEvent }
building: { node: BuildingNode; event: BuildingEvent }
level: { node: LevelNode; event: LevelEvent }
zone: { node: ZoneNode; event: ZoneEvent }
shelf: { node: ShelfNode; event: ShelfEvent }
slab: { node: SlabNode; event: SlabEvent }
spawn: { node: SpawnNode; event: SpawnEvent }
ceiling: { node: CeilingNode; event: CeilingEvent }
column: { node: ColumnNode; event: ColumnEvent }
roof: { node: RoofNode; event: RoofEvent }
'roof-segment': { node: RoofSegmentNode; event: RoofSegmentEvent }
stair: { node: StairNode; event: StairEvent }
'stair-segment': { node: StairSegmentNode; event: StairSegmentEvent }
window: { node: WindowNode; event: WindowEvent }
door: { node: DoorNode; event: DoorEvent }
elevator: { node: ElevatorNode; event: ElevatorEvent }
}
// Derive `{ node, event }` per kind directly from the `AnyNode`
// discriminated union — no hand-maintained kind→type map. Adding a new
// kind to `AnyNode` automatically makes it valid here; removing one
// removes its overload. `Extract<AnyNode, { type: K }>` picks the node
// shape, `NodeEvent<T>` adapts the bus payload to that shape.
type NodeByKind<K extends AnyNodeType> = Extract<AnyNode, { type: K }>
type NodeType = keyof NodeConfig
export function useNodeEvents<T extends NodeType>(node: NodeConfig[T]['node'], type: T) {
export function useNodeEvents<K extends AnyNodeType>(node: NodeByKind<K>, type: K) {
const emit = (suffix: EventSuffix, e: ThreeEvent<PointerEvent>) => {
const eventKey = `${type}:${suffix}` as `${T}:${EventSuffix}`
const eventKey = `${type}:${suffix}` as `${K}:${EventSuffix}`
const localPoint = e.object.worldToLocal(e.point.clone())
const payload = {
const payload: NodeEvent<NodeByKind<K>> = {
node,
position: [e.point.x, e.point.y, e.point.z],
localPosition: [localPoint.x, localPoint.y, localPoint.z],
@@ -80,9 +28,12 @@ export function useNodeEvents<T extends NodeType>(node: NodeConfig[T]['node'], t
object: e.object,
stopPropagation: () => e.stopPropagation(),
nativeEvent: e,
} as NodeConfig[T]['event']
}
emitter.emit(eventKey, payload)
// `emitter.emit` is typed over a fixed union of `${kind}:${suffix}`
// keys; the `as never` cast lets us emit a kind-specific payload
// through that generic surface without enumerating every kind.
emitter.emit(eventKey, payload as never)
}
return {
+32 -12
View File
@@ -3,18 +3,14 @@
// Public so registry-driven kinds can compose children without reaching
// into viewer's internal paths.
// Ceiling internals re-exported for the registry-driven ceiling definition.
// The ceiling renderer uses TSL shaders for a grid pattern — too much code
// to duplicate at Stage A. Wrap-exported here, ported into the nodes
// folder in a later stage.
export { CeilingRenderer } from './components/renderers/ceiling/ceiling-renderer'
// Door + window internals for registry-driven door / window definitions.
// Each kind has a geometry system + animation system to mount via
// RegisteredSystems when the kind is registry-driven.
export { DoorRenderer } from './components/renderers/door/door-renderer'
export { ItemRenderer } from './components/renderers/item/item-renderer'
export { ErrorBoundary } from './components/error-boundary'
// Stage A wrap-exports for the rest of the kinds — `@pascal-app/nodes`
// registers each via `def.renderer` (and `def.system` when present)
// Generic dispatch component used by recursive renderers (e.g. level →
// children, building → children). The per-kind renderers live in
// `@pascal-app/nodes/<kind>/renderer.tsx` and are loaded by the registry
// — no per-kind re-exports needed.
export { NodeRenderer } from './components/renderers/node-renderer'
export { WindowRenderer } from './components/renderers/window/window-renderer'
export { default as Viewer } from './components/viewer'
export type { HoverStyle, HoverStyles } from './components/viewer/post-processing'
export {
@@ -22,11 +18,14 @@ export {
SSGI_PARAMS,
} from './components/viewer/post-processing'
export { WalkthroughControls } from './components/viewer/walkthrough-controls'
export { useAssetUrl } from './hooks/use-asset-url'
export { useGLTFKTX2 } from './hooks/use-gltf-ktx2'
export { useNodeEvents } from './hooks/use-node-events'
export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url'
export { SCENE_LAYER, ZONE_LAYER } from './lib/layers'
export {
applyMaterialPresetToMaterials,
baseMaterial,
clearMaterialCache,
createDefaultMaterial,
createMaterial,
@@ -34,6 +33,7 @@ export {
DEFAULT_CEILING_MATERIAL,
DEFAULT_DOOR_MATERIAL,
DEFAULT_ROOF_MATERIAL,
DEFAULT_SHELF_MATERIAL,
DEFAULT_SLAB_MATERIAL,
DEFAULT_STAIR_MATERIAL,
DEFAULT_WALL_MATERIAL,
@@ -42,27 +42,46 @@ export {
glassMaterial,
} from './lib/materials'
export { mergedOutline } from './lib/merged-outline-node'
export { unionPolygons } from './lib/polygon-union'
export { useItemLightPool } from './store/use-item-light-pool'
export { default as useViewer } from './store/use-viewer'
export { CeilingSystem } from './systems/ceiling/ceiling-system'
export {
createColumnBoxGeometry,
createColumnCylinderGeometry,
createColumnSphereGeometry,
createColumnTorusGeometry,
} from './systems/column/column-geometry'
export { DoorAnimationSystem } from './systems/door/door-animation-system'
export { DoorSystem } from './systems/door/door-system'
export { ElevatorInteractionSystem } from './systems/elevator/elevator-interaction-system'
// Fence system follows the wall re-export pattern — composed into the
// registry-driven fence definition's `def.system`. Removed in Phase 6
// alongside the legacy fence mount point.
export { FenceSystem, generateFenceGeometry } from './systems/fence/fence-system'
export { GuideSystem } from './systems/guide/guide-system'
export { InteractiveSystem } from './systems/interactive/interactive-system'
// Item systems for the registry-driven item definition. ItemSystem
// applies attachTo-driven transforms each frame; ItemLightSystem
// manages item-mounted light sources.
export { ItemSystem } from './systems/item/item-system'
export { ItemLightSystem } from './systems/item-light/item-light-system'
export { LevelSystem } from './systems/level/level-system'
export { snapLevelsToTruePositions } from './systems/level/level-utils'
export { getRoofMaterialArray } from './systems/roof/roof-materials'
export { RoofSystem } from './systems/roof/roof-system'
export { ScanSystem } from './systems/scan/scan-system'
// Slab system follows the wall + fence re-export pattern — composed into
// the registry-driven slab definition's `def.system`. Removed in Phase 6
// alongside the legacy slab mount point.
export { generateSlabGeometry, SlabSystem } from './systems/slab/slab-system'
export { getStairBodyMaterials, getStairRailingMaterial } from './systems/stair/stair-materials'
export {
getStairBodyMaterials,
getStairRailingMaterial,
getStraightStairSegmentBodyMaterials,
type StairBodyMaterials,
} from './systems/stair/stair-materials'
export { StairSystem } from './systems/stair/stair-system'
export { WallCutout } from './systems/wall/wall-cutout'
export { getVisibleWallMaterials } from './systems/wall/wall-materials'
// Wall internals re-exported so `@pascal-app/nodes`' registry-driven wall
@@ -72,3 +91,4 @@ export { getVisibleWallMaterials } from './systems/wall/wall-materials'
export { WallSystem } from './systems/wall/wall-system'
export { WindowAnimationSystem } from './systems/window/window-animation-system'
export { WindowSystem } from './systems/window/window-system'
export { ZoneSystem } from './systems/zone/zone-system'
@@ -20,7 +20,7 @@ export const LevelSystem = () => {
obj: NonNullable<ReturnType<typeof sceneRegistry.nodes.get>>
}
const entries: LevelEntry[] = []
sceneRegistry.byType.level.forEach((levelId) => {
sceneRegistry.byType.level!.forEach((levelId) => {
const obj = sceneRegistry.nodes.get(levelId)
const level = nodes[levelId as LevelNode['id']]
if (obj && level) {
@@ -71,7 +71,7 @@ export function snapLevelsToTruePositions(): () => void {
}
const entries: LevelEntry[] = []
sceneRegistry.byType.level.forEach((levelId) => {
sceneRegistry.byType.level!.forEach((levelId) => {
const obj = sceneRegistry.nodes.get(levelId)
const level = nodes[levelId as LevelNode['id']]
if (obj && level) {
@@ -74,7 +74,7 @@ export const WallCutout = () => {
if (
((distanceMoved > 0.5 || directionChanged > 0.3) && timeSinceUpdate > 0.1) ||
lastWallMode.current !== wallMode ||
sceneRegistry.byType.wall.size !== lastNumberOfWalls.current ||
sceneRegistry.byType.wall!.size !== lastNumberOfWalls.current ||
lastHighlightKey.current !== highlightKey
) {
lastCameraPosition.current.copy(currentCameraPosition)
@@ -82,7 +82,7 @@ export const WallCutout = () => {
lastUpdateTime.current = currentTime
camera.getWorldDirection(u)
const walls = sceneRegistry.byType.wall
const walls = sceneRegistry.byType.wall!
walls.forEach((wallId) => {
const wallMesh = sceneRegistry.nodes.get(wallId)
if (!wallMesh) return
@@ -109,7 +109,7 @@ export const WallCutout = () => {
}
})
lastWallMode.current = wallMode
lastNumberOfWalls.current = sceneRegistry.byType.wall.size
lastNumberOfWalls.current = sceneRegistry.byType.wall!.size
lastHighlightKey.current = highlightKey
}
})
@@ -118,7 +118,7 @@ export const WallCutout = () => {
const snapshot = new Map<Mesh, Material | Material[]>()
const restoreForCapture = () => {
sceneRegistry.byType.wall.forEach((wallId) => {
sceneRegistry.byType.wall!.forEach((wallId) => {
const wallMesh = sceneRegistry.nodes.get(wallId) as Mesh | undefined
if (!wallMesh) return
const wallNode = useScene.getState().nodes[wallId as AnyNodeId] as WallNode | undefined
@@ -71,7 +71,7 @@ export const ZoneSystem = () => {
// Lerp speed: complete transition in ~400ms
const lerpSpeed = 10 * delta
sceneRegistry.byType.zone.forEach((zoneId) => {
sceneRegistry.byType.zone!.forEach((zoneId) => {
const zone = sceneRegistry.nodes.get(zoneId)
if (!zone) return