Refactor elevator runtime into core

This commit is contained in:
sudhir
2026-05-13 01:56:06 +05:30
parent b782ce9cf2
commit 9838d2cd4f
14 changed files with 595 additions and 429 deletions
@@ -1,7 +1,20 @@
import {
type AnyNode,
type AnyNodeId,
type ElevatorDoorSide,
type ElevatorNode,
getElevatorCabDepth,
getElevatorCabWidth,
getElevatorDoorLeafSides,
getElevatorDoorLeafWidth,
getElevatorDoorLeafX,
getElevatorShaftDepth,
getElevatorShaftWallThickness,
getElevatorShaftWidth,
getResolvedElevatorDoorPanelStyle as getResolvedDoorPanelStyle,
getResolvedElevatorDoorStyle as getResolvedDoorStyle,
getResolvedElevatorShaftStyle as getResolvedShaftStyle,
resolveElevatorLevels,
useInteractive,
useLiveNodeOverrides,
useLiveTransforms,
@@ -21,7 +34,6 @@ import {
} from 'three'
import { useShallow } from 'zustand/react/shallow'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { resolveElevatorLevels } from '../../../systems/elevator/elevator-utils'
const SHAFT_WALL_COLOR = '#d7dce4'
const SHAFT_SIDE_COLOR = '#4b5563'
@@ -30,7 +42,6 @@ const CAB_COLOR = '#d7dde5'
const GLASS_COLOR = '#f8fafc'
const DOOR_COLOR = '#8e98a6'
const PANEL_COLOR = '#1f2937'
const DEFAULT_SHAFT_WALL_THICKNESS = 0.09
type Vector3Tuple = [number, number, number]
@@ -41,10 +52,8 @@ const BUTTON_RING_GEOMETRY = new TorusGeometry(1.12, 0.12, 8, 24)
const LABEL_MATRIX_DUMMY = new Object3D()
const SHAFT_TOP_FRAME_CLEARANCE = 0.006
type ElevatorDoorSide = 'left' | 'right'
type ElevatorDoorPanelStyleValue = ElevatorNode['doorPanelStyle']
type ElevatorDoorStyleValue = ElevatorNode['doorStyle']
type ElevatorShaftStyleValue = ElevatorNode['shaftStyle']
const SHAFT_WALL_MATERIAL = new MeshStandardMaterial({
color: SHAFT_WALL_COLOR,
@@ -611,78 +620,6 @@ function ElevatorMeshButton({
)
}
function getResolvedDoorStyle(
doorStyle: ElevatorDoorStyleValue | undefined,
): ElevatorDoorStyleValue {
return doorStyle ?? 'center-opening'
}
function getResolvedDoorPanelStyle(
doorPanelStyle: ElevatorDoorPanelStyleValue | undefined,
): ElevatorDoorPanelStyleValue {
return doorPanelStyle ?? 'glass-frame'
}
function getResolvedShaftStyle(
shaftStyle: ElevatorShaftStyleValue | undefined,
): ElevatorShaftStyleValue {
return shaftStyle ?? 'solid'
}
function getElevatorDoorLeafSides(
doorStyle: ElevatorDoorStyleValue | undefined,
): ElevatorDoorSide[] {
const resolvedDoorStyle = getResolvedDoorStyle(doorStyle)
if (resolvedDoorStyle === 'single-left') return ['left']
if (resolvedDoorStyle === 'single-right') return ['right']
return ['left', 'right']
}
function getElevatorDoorLeafX(
side: ElevatorDoorSide,
openingWidth: number,
doorOpen: number,
doorStyle: ElevatorDoorStyleValue | undefined,
) {
const resolvedDoorStyle = getResolvedDoorStyle(doorStyle)
if (resolvedDoorStyle === 'center-opening') {
const direction = side === 'left' ? -1 : 1
return direction * (openingWidth / 4 + doorOpen * openingWidth * 0.34)
}
const direction = resolvedDoorStyle === 'single-left' ? -1 : 1
return direction * doorOpen * openingWidth * 0.68
}
function getElevatorDoorLeafWidth(
openingWidth: number,
doorStyle: ElevatorDoorStyleValue | undefined,
) {
return getResolvedDoorStyle(doorStyle) === 'center-opening'
? Math.max(openingWidth / 2 - 0.018, 0.12)
: Math.max(openingWidth - 0.018, 0.18)
}
function getElevatorCabWidth(node: ElevatorNode) {
return Math.max(node.width, 0.8)
}
function getElevatorCabDepth(node: ElevatorNode) {
return Math.max(node.depth, 0.8)
}
function getElevatorShaftWallThickness(node: ElevatorNode) {
return Math.max(node.shaftWallThickness ?? DEFAULT_SHAFT_WALL_THICKNESS, 0.04)
}
function getElevatorShaftWidth(node: ElevatorNode, cabWidth = getElevatorCabWidth(node)) {
return Math.max(node.shaftWidth ?? cabWidth, cabWidth, 0.8)
}
function getElevatorShaftDepth(node: ElevatorNode, cabDepth = getElevatorCabDepth(node)) {
return Math.max(node.shaftDepth ?? cabDepth, cabDepth, 0.8)
}
function getElevatorLevelContextNodes(
elevator: ElevatorNode,
nodes: ReturnType<typeof useScene.getState>['nodes'],
@@ -1,6 +1,6 @@
'use client'
import { ElevatorOpeningSystem } from '@pascal-app/core'
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'
@@ -8,7 +8,6 @@ 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 { ElevatorAnimationSystem } from '../../systems/elevator/elevator-animation-system'
import { ElevatorInteractionSystem } from '../../systems/elevator/elevator-interaction-system'
import { FenceSystem } from '../../systems/fence/fence-system'
import { GuideSystem } from '../../systems/guide/guide-system'
@@ -225,7 +224,7 @@ const Viewer: React.FC<ViewerProps> = ({
{/* Core systems */}
<CeilingSystem />
<DoorAnimationSystem />
<ElevatorAnimationSystem />
<ElevatorRuntimeSystem />
<ElevatorInteractionSystem />
<ElevatorOpeningSystem />
<WindowAnimationSystem />
@@ -1,162 +0,0 @@
import {
type AnyNodeId,
type ElevatorNode,
sceneRegistry,
useInteractive,
useScene,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import { resolveElevatorLevels } from './elevator-utils'
const EPSILON = 0.001
function moveToward(current: number, target: number, maxDelta: number) {
const delta = target - current
if (Math.abs(delta) <= maxDelta) return target
return current + Math.sign(delta) * maxDelta
}
export function ElevatorAnimationSystem() {
useFrame(({ clock }, delta) => {
const interactive = useInteractive.getState()
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]
if (node?.type !== 'elevator') {
interactive.removeElevator(typedElevatorId)
continue
}
const elevator = node as ElevatorNode
const { entries, defaultEntry } = resolveElevatorLevels(elevator, nodes)
if (!defaultEntry) continue
const state = interactive.elevators[typedElevatorId]
if (!state) {
interactive.initElevator(typedElevatorId, defaultEntry.id as AnyNodeId, defaultEntry.baseY)
continue
}
const currentEntry =
entries.find((entry) => entry.id === state.currentLevelId) ?? defaultEntry
if (currentEntry.id !== state.currentLevelId) {
interactive.setElevatorState(typedElevatorId, {
currentLevelId: currentEntry.id as AnyNodeId,
carY: currentEntry.baseY,
targetLevelId: null,
phase: 'idle',
phaseStartedAt: null,
queue: [],
doorOpen: 0,
})
continue
}
const targetEntry = state.targetLevelId
? entries.find((entry) => entry.id === state.targetLevelId)
: state.queue[0]
? entries.find((entry) => entry.id === state.queue[0])
: null
const doorDurationMs = Math.max(elevator.doorDurationMs ?? 900, 1)
const doorStep = (delta * 1000) / doorDurationMs
switch (state.phase) {
case 'idle': {
const nextLevelId = state.queue[0] ?? null
if (!nextLevelId) {
if (state.doorOpen > EPSILON) {
interactive.setElevatorState(typedElevatorId, {
doorOpen: Math.max(0, state.doorOpen - doorStep),
})
}
break
}
interactive.setElevatorState(typedElevatorId, {
targetLevelId: nextLevelId,
phase:
state.doorOpen > EPSILON
? 'closing'
: nextLevelId === state.currentLevelId
? 'opening'
: 'moving',
phaseStartedAt: now,
})
break
}
case 'closing': {
const doorOpen = Math.max(0, state.doorOpen - doorStep)
interactive.setElevatorState(typedElevatorId, {
doorOpen,
phase: doorOpen <= EPSILON ? (state.targetLevelId ? 'moving' : 'idle') : 'closing',
phaseStartedAt: doorOpen <= EPSILON ? now : state.phaseStartedAt,
})
break
}
case 'moving': {
if (!targetEntry) {
interactive.setElevatorState(typedElevatorId, {
targetLevelId: null,
phase: 'idle',
queue: [],
})
break
}
const speed = Math.max(elevator.speed ?? 2.2, 0.1)
const nextY = moveToward(state.carY, targetEntry.baseY, speed * delta)
const arrived = Math.abs(nextY - targetEntry.baseY) <= EPSILON
interactive.setElevatorState(typedElevatorId, {
carY: nextY,
currentLevelId: arrived ? (targetEntry.id as AnyNodeId) : state.currentLevelId,
phase: arrived ? 'opening' : 'moving',
phaseStartedAt: arrived ? now : state.phaseStartedAt,
})
break
}
case 'opening': {
const doorOpen = Math.min(1, state.doorOpen + doorStep)
interactive.setElevatorState(typedElevatorId, {
doorOpen,
phase: doorOpen >= 1 - EPSILON ? 'open' : 'opening',
phaseStartedAt: doorOpen >= 1 - EPSILON ? now : state.phaseStartedAt,
targetLevelId: doorOpen >= 1 - EPSILON ? null : state.targetLevelId,
queue:
doorOpen >= 1 - EPSILON && state.queue[0] === state.currentLevelId
? state.queue.slice(1)
: state.queue,
})
break
}
case 'open': {
const elapsed = now - (state.phaseStartedAt ?? now)
if (elapsed < Math.max(elevator.dwellMs ?? 1400, 0)) break
interactive.setElevatorState(typedElevatorId, {
phase: 'closing',
phaseStartedAt: now,
targetLevelId: state.queue[0] ?? null,
})
break
}
}
}
}, 2)
return null
}
@@ -1,6 +1,8 @@
import {
type AnyNodeId,
openElevatorDoor,
resolveElevatorDispatchTarget,
requestElevatorLevel,
useInteractive,
useScene,
} from '@pascal-app/core'
@@ -62,7 +64,7 @@ export function ElevatorInteractionSystem() {
event.stopPropagation()
if (button.action === 'open-door') {
useInteractive.getState().openElevatorDoor(button.elevatorId)
openElevatorDoor(button.elevatorId)
return
}
@@ -78,7 +80,7 @@ export function ElevatorInteractionSystem() {
})
: button.elevatorId
useInteractive.getState().requestElevator(targetElevatorId, button.levelId)
requestElevatorLevel(targetElevatorId, button.levelId)
}
canvas.addEventListener('pointerdown', handlePointerDown, true)
@@ -1,68 +0,0 @@
import {
resolveElevatorBuildingLevels,
resolveElevatorServiceLevels,
type AnyNode,
type AnyNodeId,
type ElevatorNode,
type LevelNode,
} from '@pascal-app/core'
import { getLevelHeight } from '../level/level-utils'
export type ElevatorLevelEntry = {
id: LevelNode['id']
label: string
baseY: number
}
export function resolveElevatorLevels(
elevator: ElevatorNode,
nodes: Record<AnyNodeId, AnyNode>,
): {
entries: ElevatorLevelEntry[]
defaultEntry: ElevatorLevelEntry | null
shaftBaseY: number
shaftTopY: number
totalHeight: number
} {
const allLevels = resolveElevatorBuildingLevels(elevator, nodes)
const baseYByLevelId = new Map<string, number>()
let cumulativeY = 0
for (const level of allLevels) {
baseYByLevelId.set(level.id, cumulativeY)
cumulativeY += getLevelHeight(level.id, nodes)
}
const serviceLevels = resolveElevatorServiceLevels(elevator, nodes)
const entries = serviceLevels.map((level) => ({
id: level.id,
label: String(level.level),
baseY: baseYByLevelId.get(level.id) ?? 0,
}))
const defaultEntry =
entries.find((entry) => entry.id === elevator.defaultLevelId) ??
entries.find((entry) => entry.id === elevator.fromLevelId) ??
entries[0] ??
null
const firstServedLevel = serviceLevels[0] ?? null
const lastServedLevel = serviceLevels[serviceLevels.length - 1] ?? null
const shaftBaseY = firstServedLevel ? (baseYByLevelId.get(firstServedLevel.id) ?? 0) : 0
const lastServedIndex = lastServedLevel
? allLevels.findIndex((level) => level.id === lastServedLevel.id)
: -1
const nextLevel = lastServedIndex >= 0 ? allLevels[lastServedIndex + 1] : null
const shaftTopY = nextLevel
? (baseYByLevelId.get(nextLevel.id) ?? cumulativeY)
: lastServedLevel
? cumulativeY
: elevator.cabHeight + 0.3
return {
entries,
defaultEntry,
shaftBaseY,
shaftTopY,
totalHeight: Math.max(shaftTopY - shaftBaseY, elevator.cabHeight + 0.3),
}
}