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) {