Refactor elevator systems across core, viewer, and editor

This commit is contained in:
sudhir
2026-05-10 12:38:44 +05:30
parent 909f96ef09
commit 92a9846193
10 changed files with 213 additions and 91 deletions
@@ -1,15 +1,15 @@
import {
type AnyNode,
type AnyNodeId,
type ElevatorNode,
resolveElevatorDispatchTarget,
useInteractive,
useLiveNodeOverrides,
useLiveTransforms,
useRegistry,
useScene,
} from '@pascal-app/core'
import { type ThreeEvent, useFrame } from '@react-three/fiber'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import { useFrame } from '@react-three/fiber'
import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
import {
BoxGeometry,
CylinderGeometry,
@@ -561,29 +561,8 @@ function ElevatorMeshButton({
[action, buttonKind, disabled, elevatorId, levelId],
)
const press = (event: ThreeEvent<PointerEvent>) => {
if (event.button !== 0) return
if (disabled) return
if (action === 'open-door') {
useInteractive.getState().openElevatorDoor(elevatorId)
return
}
if (levelId) {
const targetElevatorId =
buttonKind === 'landing'
? resolveElevatorDispatchTarget({
elevators: useInteractive.getState().elevators,
levelId,
nodes: useScene.getState().nodes,
requestedElevatorId: elevatorId,
})
: elevatorId
useInteractive.getState().requestElevator(targetElevatorId, levelId)
}
}
return (
<group onPointerDown={press} position={position} userData={userData}>
<group position={position} userData={userData}>
{!disabled && (active || queued) && (
<mesh
dispose={null}
@@ -700,6 +679,32 @@ function getElevatorShaftDepth(node: ElevatorNode, cabDepth = getElevatorCabDept
return Math.max(node.shaftDepth ?? cabDepth, cabDepth, 0.8)
}
function getElevatorLevelContextNodes(
elevator: ElevatorNode,
nodes: ReturnType<typeof useScene.getState>['nodes'],
): Record<AnyNodeId, AnyNode> {
const result: Record<string, AnyNode> = {}
const building = elevator.parentId ? nodes[elevator.parentId as AnyNodeId] : null
if (building?.type !== 'building') return result as Record<AnyNodeId, AnyNode>
result[building.id] = building
for (const childId of building.children) {
const level = nodes[childId as AnyNodeId]
if (level?.type !== 'level') continue
result[level.id] = level
for (const levelChildId of level.children) {
const child = nodes[levelChildId as AnyNodeId]
if (child?.type === 'ceiling' || child?.type === 'wall') {
result[child.id] = child
}
}
}
return result as Record<AnyNodeId, AnyNode>
}
function DoorLeaf({
animated,
doorOpen,
@@ -1000,7 +1005,6 @@ function LandingDoor({
export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
const ref = useRef<Group>(null!)
const cabRef = useRef<Group>(null)
const nodes = useScene((state) => state.nodes)
const handlers = useNodeEvents(node, 'elevator')
const liveOverrides = useLiveNodeOverrides((state) => state.get(node.id))
const liveTransform = useLiveTransforms((state) => state.get(node.id))
@@ -1008,12 +1012,15 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
() => (liveOverrides ? ({ ...node, ...liveOverrides } as ElevatorNode) : node),
[liveOverrides, node],
)
const levelContextNodes = useScene(
useShallow((state) => getElevatorLevelContextNodes(renderNode, state.nodes)),
)
useRegistry(node.id, 'elevator', ref)
const { entries, defaultEntry, shaftBaseY, totalHeight } = useMemo(
() => resolveElevatorLevels(renderNode, nodes),
[renderNode, nodes],
() => resolveElevatorLevels(renderNode, levelContextNodes),
[renderNode, levelContextNodes],
)
const elevatorId = node.id as AnyNodeId
const runtimeStatus = useInteractive(
@@ -1029,33 +1036,6 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
}),
)
useEffect(() => {
if (!defaultEntry) return
const elevatorId = node.id as AnyNodeId
const interactive = useInteractive.getState()
const current = interactive.elevators[elevatorId]
if (!current) {
interactive.initElevator(elevatorId, defaultEntry.id as AnyNodeId, defaultEntry.baseY)
} else if (!entries.some((entry) => entry.id === current.currentLevelId)) {
interactive.setElevatorState(elevatorId, {
carY: defaultEntry.baseY,
currentLevelId: defaultEntry.id as AnyNodeId,
doorOpen: 0,
phase: 'idle',
phaseStartedAt: null,
queue: [],
targetLevelId: null,
})
}
}, [defaultEntry, entries, node.id])
useEffect(() => {
return () => {
useInteractive.getState().removeElevator(elevatorId)
}
}, [elevatorId])
useFrame(() => {
if (!cabRef.current) return
const runtime = useInteractive.getState().elevators[elevatorId]
@@ -1,5 +1,6 @@
'use client'
import { ElevatorOpeningSystem } from '@pascal-app/core'
import { Bvh } from '@react-three/drei'
import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef } from 'react'
@@ -9,7 +10,7 @@ import { CeilingSystem } from '../../systems/ceiling/ceiling-system'
import { DoorAnimationSystem } from '../../systems/door/door-animation-system'
import { DoorSystem } from '../../systems/door/door-system'
import { ElevatorAnimationSystem } from '../../systems/elevator/elevator-animation-system'
import { ElevatorOpeningSystem } from '../../systems/elevator/elevator-opening-system'
import { ElevatorInteractionSystem } from '../../systems/elevator/elevator-interaction-system'
import { FenceSystem } from '../../systems/fence/fence-system'
import { GuideSystem } from '../../systems/guide/guide-system'
import { ItemSystem } from '../../systems/item/item-system'
@@ -208,6 +209,7 @@ const Viewer: React.FC<ViewerProps> = ({
<CeilingSystem />
<DoorAnimationSystem />
<ElevatorAnimationSystem />
<ElevatorInteractionSystem />
<ElevatorOpeningSystem />
<WindowAnimationSystem />
<DoorSystem />
@@ -22,6 +22,13 @@ export function ElevatorAnimationSystem() {
const nodes = useScene.getState().nodes
const now = clock.getElapsedTime() * 1000
for (const elevatorId of Object.keys(interactive.elevators)) {
const typedElevatorId = elevatorId as AnyNodeId
if (nodes[typedElevatorId]?.type !== 'elevator') {
interactive.removeElevator(typedElevatorId)
}
}
for (const elevatorId of sceneRegistry.byType.elevator) {
const typedElevatorId = elevatorId as AnyNodeId
const node = nodes[typedElevatorId]
@@ -0,0 +1,91 @@
import {
type AnyNodeId,
resolveElevatorDispatchTarget,
useInteractive,
useScene,
} from '@pascal-app/core'
import { useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import { type Object3D, Raycaster, Vector2 } from 'three'
type ElevatorButtonAction = 'open-door' | 'request-level'
type ElevatorButtonUserData = {
action: ElevatorButtonAction
disabled: boolean
elevatorId: AnyNodeId
kind: 'cab' | 'landing'
levelId?: AnyNodeId
}
function getElevatorButtonData(object: Object3D): ElevatorButtonUserData | null {
let current: Object3D | null = object
while (current) {
const data = (current.userData as { elevatorButton?: ElevatorButtonUserData }).elevatorButton
if (data) return data
current = current.parent
}
return null
}
export function ElevatorInteractionSystem() {
const camera = useThree((state) => state.camera)
const gl = useThree((state) => state.gl)
const scene = useThree((state) => state.scene)
const raycasterRef = useRef(new Raycaster())
const pointerRef = useRef(new Vector2())
useEffect(() => {
const canvas = gl.domElement
const handlePointerDown = (event: PointerEvent) => {
if (event.button !== 0) return
const rect = canvas.getBoundingClientRect()
const pointer = pointerRef.current
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
const raycaster = raycasterRef.current
raycaster.setFromCamera(pointer, camera)
const button = raycaster
.intersectObjects(scene.children, true)
.map((intersection) => getElevatorButtonData(intersection.object))
.find((data): data is ElevatorButtonUserData => data !== null)
if (!button || button.disabled) return
event.preventDefault()
event.stopPropagation()
if (button.action === 'open-door') {
useInteractive.getState().openElevatorDoor(button.elevatorId)
return
}
if (!button.levelId) return
const targetElevatorId =
button.kind === 'landing'
? resolveElevatorDispatchTarget({
elevators: useInteractive.getState().elevators,
levelId: button.levelId,
nodes: useScene.getState().nodes,
requestedElevatorId: button.elevatorId,
})
: button.elevatorId
useInteractive.getState().requestElevator(targetElevatorId, button.levelId)
}
canvas.addEventListener('pointerdown', handlePointerDown, true)
return () => {
canvas.removeEventListener('pointerdown', handlePointerDown, true)
}
}, [camera, gl, scene])
return null
}
@@ -1,54 +0,0 @@
import { type AnyNode, syncAutoElevatorOpenings, useScene } from '@pascal-app/core'
import { useEffect, useRef } from 'react'
function isOpeningRelevantNode(node: AnyNode | undefined) {
return (
node?.type === 'building' ||
node?.type === 'ceiling' ||
node?.type === 'elevator' ||
node?.type === 'level' ||
node?.type === 'slab'
)
}
function hasOpeningRelevantNodeChange(
nextNodes: Record<string, AnyNode>,
prevNodes: Record<string, AnyNode>,
) {
if (nextNodes === prevNodes) return false
const ids = new Set([...Object.keys(nextNodes), ...Object.keys(prevNodes)])
for (const id of ids) {
const nextNode = nextNodes[id]
const prevNode = prevNodes[id]
if (nextNode === prevNode) continue
if (isOpeningRelevantNode(nextNode) || isOpeningRelevantNode(prevNode)) return true
}
return false
}
export const ElevatorOpeningSystem = () => {
const syncingAutoOpeningsRef = useRef(false)
useEffect(() => {
const applyUpdates = (updates: ReturnType<typeof syncAutoElevatorOpenings>) => {
if (updates.length === 0) return
syncingAutoOpeningsRef.current = true
useScene.getState().updateNodes(updates)
queueMicrotask(() => {
syncingAutoOpeningsRef.current = false
})
}
applyUpdates(syncAutoElevatorOpenings(useScene.getState().nodes))
return useScene.subscribe((state, prevState) => {
if (syncingAutoOpeningsRef.current) return
if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return
applyUpdates(syncAutoElevatorOpenings(state.nodes))
})
}, [])
return null
}