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:
co-authored by
Claude Opus 4.7
parent
11015ea1ed
commit
d747d2f0ea
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user