From 92a98461938ddb4314825e61e3eeb6781db8dcff Mon Sep 17 00:00:00 2001 From: sudhir Date: Sun, 10 May 2026 12:38:44 +0530 Subject: [PATCH] Refactor elevator systems across core, viewer, and editor --- packages/core/src/index.ts | 1 + .../elevator/elevator-opening-system.tsx | 4 +- .../tools/elevator/elevator-tool.tsx | 55 ++++++----- .../tools/elevator/move-elevator-tool.tsx | 18 +++- .../src/components/tools/item/move-tool.tsx | 7 +- .../src/components/tools/tool-manager.tsx | 27 +++++- .../renderers/elevator/elevator-renderer.tsx | 90 +++++++----------- .../viewer/src/components/viewer/index.tsx | 4 +- .../elevator/elevator-animation-system.tsx | 7 ++ .../elevator/elevator-interaction-system.tsx | 91 +++++++++++++++++++ 10 files changed, 213 insertions(+), 91 deletions(-) rename packages/{viewer => core}/src/systems/elevator/elevator-opening-system.tsx (90%) create mode 100644 packages/viewer/src/systems/elevator/elevator-interaction-system.tsx diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b5996efd..fcc7dd29 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -87,6 +87,7 @@ export { default as useLiveTransforms, type LiveTransform } from './store/use-li export { clearSceneHistory, default as useScene } from './store/use-scene' export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch' export { syncAutoElevatorOpenings } from './systems/elevator/elevator-opening-sync' +export { ElevatorOpeningSystem } from './systems/elevator/elevator-opening-system' export { resolveElevatorBuildingLevels, resolveElevatorServiceLevelIds, diff --git a/packages/viewer/src/systems/elevator/elevator-opening-system.tsx b/packages/core/src/systems/elevator/elevator-opening-system.tsx similarity index 90% rename from packages/viewer/src/systems/elevator/elevator-opening-system.tsx rename to packages/core/src/systems/elevator/elevator-opening-system.tsx index 559f9a3b..5f8c6344 100644 --- a/packages/viewer/src/systems/elevator/elevator-opening-system.tsx +++ b/packages/core/src/systems/elevator/elevator-opening-system.tsx @@ -1,5 +1,7 @@ -import { type AnyNode, syncAutoElevatorOpenings, useScene } from '@pascal-app/core' import { useEffect, useRef } from 'react' +import type { AnyNode } from '../../schema' +import useScene from '../../store/use-scene' +import { syncAutoElevatorOpenings } from './elevator-opening-sync' function isOpeningRelevantNode(node: AnyNode | undefined) { return ( diff --git a/packages/editor/src/components/tools/elevator/elevator-tool.tsx b/packages/editor/src/components/tools/elevator/elevator-tool.tsx index d8e87636..a5702078 100644 --- a/packages/editor/src/components/tools/elevator/elevator-tool.tsx +++ b/packages/editor/src/components/tools/elevator/elevator-tool.tsx @@ -7,7 +7,6 @@ import { type LevelNode, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { resolveElevatorSupportY } from '../../../lib/elevator-support' @@ -26,8 +25,16 @@ import { const GRID_OFFSET = 0.02 -function resolveCurrentBuildingId(): BuildingNode['id'] | null { - const { buildingId, levelId } = useViewer.getState().selection +type ElevatorToolProps = { + buildingId: BuildingNode['id'] | null + levelId: LevelNode['id'] | null + onPlaced?: (elevatorId: AnyNodeId, buildingId: BuildingNode['id']) => void +} + +function resolveCurrentBuildingId( + buildingId: BuildingNode['id'] | null, + levelId: LevelNode['id'] | null, +): BuildingNode['id'] | null { if (buildingId) return buildingId as BuildingNode['id'] if (!levelId) return null @@ -39,12 +46,14 @@ function resolveCurrentBuildingId(): BuildingNode['id'] | null { return null } -function resolveDefaultServiceRange(buildingId: BuildingNode['id']): { +function resolveDefaultServiceRange( + buildingId: BuildingNode['id'], + selectedLevelId: LevelNode['id'] | null, +): { defaultLevelId: LevelNode['id'] | null fromLevelId: LevelNode['id'] | null toLevelId: LevelNode['id'] | null } { - const { levelId } = useViewer.getState().selection const nodes = useScene.getState().nodes const building = nodes[buildingId as AnyNodeId] if (building?.type !== 'building') { @@ -55,7 +64,7 @@ function resolveDefaultServiceRange(buildingId: BuildingNode['id']): { .map((childId) => nodes[childId as AnyNodeId]) .filter((node): node is LevelNode => node?.type === 'level') .sort((left, right) => left.level - right.level) - const selectedLevelIndex = levels.findIndex((level) => level.id === levelId) + const selectedLevelIndex = levels.findIndex((level) => level.id === selectedLevelId) const fromIndex = selectedLevelIndex >= 0 ? selectedLevelIndex : 0 const fromLevel = levels[fromIndex] const toLevel = levels[Math.min(fromIndex + 1, levels.length - 1)] ?? fromLevel @@ -77,13 +86,15 @@ function createElevatorPreviewGeometry(): THREE.BufferGeometry { function commitElevatorPlacement( buildingId: BuildingNode['id'], + selectedLevelId: LevelNode['id'] | null, x: number, z: number, rotation: number, + onPlaced: ElevatorToolProps['onPlaced'], ): void { const { createNode, nodes } = useScene.getState() const elevatorCount = Object.values(nodes).filter((node) => node.type === 'elevator').length - const serviceRange = resolveDefaultServiceRange(buildingId) + const serviceRange = resolveDefaultServiceRange(buildingId, selectedLevelId) const supportY = resolveElevatorSupportY({ buildingId, preferredLevelId: serviceRange.fromLevelId ?? serviceRange.defaultLevelId, @@ -107,30 +118,19 @@ function commitElevatorPlacement( }) createNode(elevator, buildingId) - useViewer.getState().setSelection({ buildingId, selectedIds: [elevator.id] }) + onPlaced?.(elevator.id as AnyNodeId, buildingId) sfxEmitter.emit('sfx:structure-build') } -export const ElevatorTool: React.FC = () => { +export const ElevatorTool: React.FC = ({ buildingId, levelId, onPlaced }) => { const cursorRef = useRef(null) const previewRef = useRef(null) const rotationRef = useRef(0) const previousGridPosRef = useRef<[number, number] | null>(null) - const buildingId = useViewer((state) => state.selection.buildingId) - const levelId = useViewer((state) => state.selection.levelId) const previewGeometry = useMemo(() => createElevatorPreviewGeometry(), []) useEffect(() => { - const currentBuildingId = - (buildingId as BuildingNode['id'] | null) ?? - (levelId - ? (() => { - const level = useScene.getState().nodes[levelId as AnyNodeId] - return level?.type === 'level' && level.parentId - ? (level.parentId as BuildingNode['id']) - : null - })() - : null) + const currentBuildingId = resolveCurrentBuildingId(buildingId, levelId) if (!currentBuildingId) return rotationRef.current = 0 @@ -160,12 +160,19 @@ export const ElevatorTool: React.FC = () => { } const onGridClick = (event: GridEvent) => { - const latestBuildingId = resolveCurrentBuildingId() + const latestBuildingId = resolveCurrentBuildingId(buildingId, levelId) if (!latestBuildingId) return const gridX = Math.round(event.localPosition[0] * 2) / 2 const gridZ = Math.round(event.localPosition[2] * 2) / 2 - commitElevatorPlacement(latestBuildingId, gridX, gridZ, rotationRef.current) + commitElevatorPlacement( + latestBuildingId, + levelId, + gridX, + gridZ, + rotationRef.current, + onPlaced, + ) } const onKeyDown = (event: KeyboardEvent) => { @@ -195,7 +202,7 @@ export const ElevatorTool: React.FC = () => { emitter.off('grid:click', onGridClick) window.removeEventListener('keydown', onKeyDown) } - }, [buildingId, levelId]) + }, [buildingId, levelId, onPlaced]) return ( diff --git a/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx b/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx index 046bf43a..2322c6dc 100644 --- a/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx +++ b/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx @@ -10,7 +10,6 @@ import { useLiveTransforms, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { resolveElevatorSupportY } from '../../../lib/elevator-support' @@ -29,7 +28,14 @@ function stripMoveMetadata(metadata: ElevatorNode['metadata']) { return nextMeta as ElevatorNode['metadata'] } -export function MoveElevatorTool({ node: movingNode }: { node: ElevatorNode }) { +export function MoveElevatorTool({ + node: movingNode, + onCommitted, +}: { + node: ElevatorNode + onCommitted?: (nodeId: AnyNodeId) => void +}) { + const onCommittedRef = useRef(onCommitted) const previousGridPosRef = useRef<[number, number] | null>(null) const previewPositionRef = useRef([ movingNode.position[0], @@ -46,6 +52,10 @@ export function MoveElevatorTool({ node: movingNode }: { node: ElevatorNode }) { useEditor.getState().setMovingNode(null) }, []) + useEffect(() => { + onCommittedRef.current = onCommitted + }, [onCommitted]) + useEffect(() => { useScene.temporal.getState().pause() const movingNodeId = (movingNode as { id?: ElevatorNode['id'] }).id @@ -145,7 +155,7 @@ export function MoveElevatorTool({ node: movingNode }: { node: ElevatorNode }) { rotation: pendingRotation, metadata: committedMeta, }) - useViewer.getState().setSelection({ selectedIds: [movingNodeId] }) + onCommittedRef.current?.(movingNodeId as AnyNodeId) } else if (movingNode.parentId) { const elevator = ElevatorNodeSchema.parse({ ...movingNode, @@ -155,7 +165,7 @@ export function MoveElevatorTool({ node: movingNode }: { node: ElevatorNode }) { metadata: committedMeta, }) useScene.getState().createNode(elevator, movingNode.parentId as AnyNodeId) - useViewer.getState().setSelection({ selectedIds: [elevator.id] }) + onCommittedRef.current?.(elevator.id as AnyNodeId) } sfxEmitter.emit('sfx:item-place') diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 1db6a11f..19948110 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -1,4 +1,5 @@ import type { + AnyNodeId, BuildingNode, CeilingNode, ColumnNode, @@ -93,15 +94,17 @@ function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { } export const MoveTool: React.FC<{ + onNodeMoved?: (nodeId: AnyNodeId) => void onSpawnMoved?: (nodeId: SpawnNode['id']) => void -}> = ({ onSpawnMoved }) => { +}> = ({ onNodeMoved, onSpawnMoved }) => { const movingNode = useEditor((state) => state.movingNode) if (!movingNode) return null if (movingNode.type === 'building') return if (movingNode.type === 'door') return - if (movingNode.type === 'elevator') return + if (movingNode.type === 'elevator') + return if (movingNode.type === 'window') return if (movingNode.type === 'fence') return if (movingNode.type === 'ceiling') return diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 600e2242..9f726fee 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -42,7 +42,6 @@ const tools: Record>> = { slab: SlabTool, ceiling: CeilingTool, roof: RoofTool, - elevator: ElevatorTool, stair: StairTool, door: DoorTool, item: ItemTool, @@ -132,12 +131,20 @@ export const ToolManager: React.FC = () => { const handlePlacedNodeSelected = (nodeId: AnyNodeId) => { setSelection({ selectedIds: [nodeId] }) } + const handlePlacedElevatorSelected = ( + nodeId: AnyNodeId, + elevatorBuildingId: BuildingNode['id'], + ) => { + setSelection({ buildingId: elevatorBuildingId, selectedIds: [nodeId] }) + } return ( <> {showSiteBoundaryEditor && } {/* World-space tools: site boundary and building movement operate in world coordinates */} - {movingNode?.type === 'building' && } + {movingNode?.type === 'building' && ( + + )} {/* Building-local group: all other tools are relative to the selected building. Cursor visuals set positions in building-local space; this group applies the @@ -162,7 +169,10 @@ export const ToolManager: React.FC = () => { {curvingWall && } {curvingFence && } {movingNode && movingNode.type !== 'building' && ( - + )} {!movingNode && showBuildTool && tool === 'spawn' && ( @@ -170,7 +180,16 @@ export const ToolManager: React.FC = () => { {!movingNode && showBuildTool && tool === 'column' && ( )} - {!movingNode && BuildToolComponent && tool !== 'column' && } + {!movingNode && showBuildTool && tool === 'elevator' && ( + + )} + {!movingNode && BuildToolComponent && tool !== 'column' && tool !== 'elevator' && ( + + )} ) diff --git a/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx b/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx index 088fe259..083455dd 100644 --- a/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx +++ b/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx @@ -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) => { - 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 ( - + {!disabled && (active || queued) && ( ['nodes'], +): Record { + const result: Record = {} + const building = elevator.parentId ? nodes[elevator.parentId as AnyNodeId] : null + if (building?.type !== 'building') return result as Record + + 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 +} + function DoorLeaf({ animated, doorOpen, @@ -1000,7 +1005,6 @@ function LandingDoor({ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { const ref = useRef(null!) const cabRef = useRef(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] diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 0ae36bf1..d0b7fdad 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -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 = ({ + diff --git a/packages/viewer/src/systems/elevator/elevator-animation-system.tsx b/packages/viewer/src/systems/elevator/elevator-animation-system.tsx index 49be4c84..f512579e 100644 --- a/packages/viewer/src/systems/elevator/elevator-animation-system.tsx +++ b/packages/viewer/src/systems/elevator/elevator-animation-system.tsx @@ -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] diff --git a/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx b/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx new file mode 100644 index 00000000..447259fb --- /dev/null +++ b/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx @@ -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 +}