Refactor elevator systems across core, viewer, and editor
This commit is contained in:
@@ -87,6 +87,7 @@ export { default as useLiveTransforms, type LiveTransform } from './store/use-li
|
|||||||
export { clearSceneHistory, default as useScene } from './store/use-scene'
|
export { clearSceneHistory, default as useScene } from './store/use-scene'
|
||||||
export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch'
|
export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch'
|
||||||
export { syncAutoElevatorOpenings } from './systems/elevator/elevator-opening-sync'
|
export { syncAutoElevatorOpenings } from './systems/elevator/elevator-opening-sync'
|
||||||
|
export { ElevatorOpeningSystem } from './systems/elevator/elevator-opening-system'
|
||||||
export {
|
export {
|
||||||
resolveElevatorBuildingLevels,
|
resolveElevatorBuildingLevels,
|
||||||
resolveElevatorServiceLevelIds,
|
resolveElevatorServiceLevelIds,
|
||||||
|
|||||||
+3
-1
@@ -1,5 +1,7 @@
|
|||||||
import { type AnyNode, syncAutoElevatorOpenings, useScene } from '@pascal-app/core'
|
|
||||||
import { useEffect, useRef } from 'react'
|
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) {
|
function isOpeningRelevantNode(node: AnyNode | undefined) {
|
||||||
return (
|
return (
|
||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
type LevelNode,
|
type LevelNode,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
|
||||||
import { useEffect, useMemo, useRef } from 'react'
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { resolveElevatorSupportY } from '../../../lib/elevator-support'
|
import { resolveElevatorSupportY } from '../../../lib/elevator-support'
|
||||||
@@ -26,8 +25,16 @@ import {
|
|||||||
|
|
||||||
const GRID_OFFSET = 0.02
|
const GRID_OFFSET = 0.02
|
||||||
|
|
||||||
function resolveCurrentBuildingId(): BuildingNode['id'] | null {
|
type ElevatorToolProps = {
|
||||||
const { buildingId, levelId } = useViewer.getState().selection
|
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 (buildingId) return buildingId as BuildingNode['id']
|
||||||
if (!levelId) return null
|
if (!levelId) return null
|
||||||
|
|
||||||
@@ -39,12 +46,14 @@ function resolveCurrentBuildingId(): BuildingNode['id'] | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDefaultServiceRange(buildingId: BuildingNode['id']): {
|
function resolveDefaultServiceRange(
|
||||||
|
buildingId: BuildingNode['id'],
|
||||||
|
selectedLevelId: LevelNode['id'] | null,
|
||||||
|
): {
|
||||||
defaultLevelId: LevelNode['id'] | null
|
defaultLevelId: LevelNode['id'] | null
|
||||||
fromLevelId: LevelNode['id'] | null
|
fromLevelId: LevelNode['id'] | null
|
||||||
toLevelId: LevelNode['id'] | null
|
toLevelId: LevelNode['id'] | null
|
||||||
} {
|
} {
|
||||||
const { levelId } = useViewer.getState().selection
|
|
||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
const building = nodes[buildingId as AnyNodeId]
|
const building = nodes[buildingId as AnyNodeId]
|
||||||
if (building?.type !== 'building') {
|
if (building?.type !== 'building') {
|
||||||
@@ -55,7 +64,7 @@ function resolveDefaultServiceRange(buildingId: BuildingNode['id']): {
|
|||||||
.map((childId) => nodes[childId as AnyNodeId])
|
.map((childId) => nodes[childId as AnyNodeId])
|
||||||
.filter((node): node is LevelNode => node?.type === 'level')
|
.filter((node): node is LevelNode => node?.type === 'level')
|
||||||
.sort((left, right) => left.level - right.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 fromIndex = selectedLevelIndex >= 0 ? selectedLevelIndex : 0
|
||||||
const fromLevel = levels[fromIndex]
|
const fromLevel = levels[fromIndex]
|
||||||
const toLevel = levels[Math.min(fromIndex + 1, levels.length - 1)] ?? fromLevel
|
const toLevel = levels[Math.min(fromIndex + 1, levels.length - 1)] ?? fromLevel
|
||||||
@@ -77,13 +86,15 @@ function createElevatorPreviewGeometry(): THREE.BufferGeometry {
|
|||||||
|
|
||||||
function commitElevatorPlacement(
|
function commitElevatorPlacement(
|
||||||
buildingId: BuildingNode['id'],
|
buildingId: BuildingNode['id'],
|
||||||
|
selectedLevelId: LevelNode['id'] | null,
|
||||||
x: number,
|
x: number,
|
||||||
z: number,
|
z: number,
|
||||||
rotation: number,
|
rotation: number,
|
||||||
|
onPlaced: ElevatorToolProps['onPlaced'],
|
||||||
): void {
|
): void {
|
||||||
const { createNode, nodes } = useScene.getState()
|
const { createNode, nodes } = useScene.getState()
|
||||||
const elevatorCount = Object.values(nodes).filter((node) => node.type === 'elevator').length
|
const elevatorCount = Object.values(nodes).filter((node) => node.type === 'elevator').length
|
||||||
const serviceRange = resolveDefaultServiceRange(buildingId)
|
const serviceRange = resolveDefaultServiceRange(buildingId, selectedLevelId)
|
||||||
const supportY = resolveElevatorSupportY({
|
const supportY = resolveElevatorSupportY({
|
||||||
buildingId,
|
buildingId,
|
||||||
preferredLevelId: serviceRange.fromLevelId ?? serviceRange.defaultLevelId,
|
preferredLevelId: serviceRange.fromLevelId ?? serviceRange.defaultLevelId,
|
||||||
@@ -107,30 +118,19 @@ function commitElevatorPlacement(
|
|||||||
})
|
})
|
||||||
|
|
||||||
createNode(elevator, buildingId)
|
createNode(elevator, buildingId)
|
||||||
useViewer.getState().setSelection({ buildingId, selectedIds: [elevator.id] })
|
onPlaced?.(elevator.id as AnyNodeId, buildingId)
|
||||||
sfxEmitter.emit('sfx:structure-build')
|
sfxEmitter.emit('sfx:structure-build')
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ElevatorTool: React.FC = () => {
|
export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId, onPlaced }) => {
|
||||||
const cursorRef = useRef<THREE.Group>(null)
|
const cursorRef = useRef<THREE.Group>(null)
|
||||||
const previewRef = useRef<THREE.Group>(null)
|
const previewRef = useRef<THREE.Group>(null)
|
||||||
const rotationRef = useRef(0)
|
const rotationRef = useRef(0)
|
||||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
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(), [])
|
const previewGeometry = useMemo(() => createElevatorPreviewGeometry(), [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentBuildingId =
|
const currentBuildingId = resolveCurrentBuildingId(buildingId, levelId)
|
||||||
(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)
|
|
||||||
if (!currentBuildingId) return
|
if (!currentBuildingId) return
|
||||||
|
|
||||||
rotationRef.current = 0
|
rotationRef.current = 0
|
||||||
@@ -160,12 +160,19 @@ export const ElevatorTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onGridClick = (event: GridEvent) => {
|
const onGridClick = (event: GridEvent) => {
|
||||||
const latestBuildingId = resolveCurrentBuildingId()
|
const latestBuildingId = resolveCurrentBuildingId(buildingId, levelId)
|
||||||
if (!latestBuildingId) return
|
if (!latestBuildingId) return
|
||||||
|
|
||||||
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
||||||
const gridZ = Math.round(event.localPosition[2] * 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) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -195,7 +202,7 @@ export const ElevatorTool: React.FC = () => {
|
|||||||
emitter.off('grid:click', onGridClick)
|
emitter.off('grid:click', onGridClick)
|
||||||
window.removeEventListener('keydown', onKeyDown)
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
}
|
}
|
||||||
}, [buildingId, levelId])
|
}, [buildingId, levelId, onPlaced])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
useLiveTransforms,
|
useLiveTransforms,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||||
import { resolveElevatorSupportY } from '../../../lib/elevator-support'
|
import { resolveElevatorSupportY } from '../../../lib/elevator-support'
|
||||||
@@ -29,7 +28,14 @@ function stripMoveMetadata(metadata: ElevatorNode['metadata']) {
|
|||||||
return nextMeta as 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 previousGridPosRef = useRef<[number, number] | null>(null)
|
||||||
const previewPositionRef = useRef<ElevatorNode['position']>([
|
const previewPositionRef = useRef<ElevatorNode['position']>([
|
||||||
movingNode.position[0],
|
movingNode.position[0],
|
||||||
@@ -46,6 +52,10 @@ export function MoveElevatorTool({ node: movingNode }: { node: ElevatorNode }) {
|
|||||||
useEditor.getState().setMovingNode(null)
|
useEditor.getState().setMovingNode(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onCommittedRef.current = onCommitted
|
||||||
|
}, [onCommitted])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
const movingNodeId = (movingNode as { id?: ElevatorNode['id'] }).id
|
const movingNodeId = (movingNode as { id?: ElevatorNode['id'] }).id
|
||||||
@@ -145,7 +155,7 @@ export function MoveElevatorTool({ node: movingNode }: { node: ElevatorNode }) {
|
|||||||
rotation: pendingRotation,
|
rotation: pendingRotation,
|
||||||
metadata: committedMeta,
|
metadata: committedMeta,
|
||||||
})
|
})
|
||||||
useViewer.getState().setSelection({ selectedIds: [movingNodeId] })
|
onCommittedRef.current?.(movingNodeId as AnyNodeId)
|
||||||
} else if (movingNode.parentId) {
|
} else if (movingNode.parentId) {
|
||||||
const elevator = ElevatorNodeSchema.parse({
|
const elevator = ElevatorNodeSchema.parse({
|
||||||
...movingNode,
|
...movingNode,
|
||||||
@@ -155,7 +165,7 @@ export function MoveElevatorTool({ node: movingNode }: { node: ElevatorNode }) {
|
|||||||
metadata: committedMeta,
|
metadata: committedMeta,
|
||||||
})
|
})
|
||||||
useScene.getState().createNode(elevator, movingNode.parentId as AnyNodeId)
|
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')
|
sfxEmitter.emit('sfx:item-place')
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AnyNodeId,
|
||||||
BuildingNode,
|
BuildingNode,
|
||||||
CeilingNode,
|
CeilingNode,
|
||||||
ColumnNode,
|
ColumnNode,
|
||||||
@@ -93,15 +94,17 @@ function MoveItemContent({ movingNode }: { movingNode: ItemNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const MoveTool: React.FC<{
|
export const MoveTool: React.FC<{
|
||||||
|
onNodeMoved?: (nodeId: AnyNodeId) => void
|
||||||
onSpawnMoved?: (nodeId: SpawnNode['id']) => void
|
onSpawnMoved?: (nodeId: SpawnNode['id']) => void
|
||||||
}> = ({ onSpawnMoved }) => {
|
}> = ({ onNodeMoved, onSpawnMoved }) => {
|
||||||
const movingNode = useEditor((state) => state.movingNode)
|
const movingNode = useEditor((state) => state.movingNode)
|
||||||
|
|
||||||
if (!movingNode) return null
|
if (!movingNode) return null
|
||||||
if (movingNode.type === 'building')
|
if (movingNode.type === 'building')
|
||||||
return <MoveBuildingContent node={movingNode as BuildingNode} />
|
return <MoveBuildingContent node={movingNode as BuildingNode} />
|
||||||
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
|
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
|
||||||
if (movingNode.type === 'elevator') return <MoveElevatorTool node={movingNode as ElevatorNode} />
|
if (movingNode.type === 'elevator')
|
||||||
|
return <MoveElevatorTool node={movingNode as ElevatorNode} onCommitted={onNodeMoved} />
|
||||||
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
|
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
|
||||||
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
|
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
|
||||||
if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
|
if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
|
|||||||
slab: SlabTool,
|
slab: SlabTool,
|
||||||
ceiling: CeilingTool,
|
ceiling: CeilingTool,
|
||||||
roof: RoofTool,
|
roof: RoofTool,
|
||||||
elevator: ElevatorTool,
|
|
||||||
stair: StairTool,
|
stair: StairTool,
|
||||||
door: DoorTool,
|
door: DoorTool,
|
||||||
item: ItemTool,
|
item: ItemTool,
|
||||||
@@ -132,12 +131,20 @@ export const ToolManager: React.FC = () => {
|
|||||||
const handlePlacedNodeSelected = (nodeId: AnyNodeId) => {
|
const handlePlacedNodeSelected = (nodeId: AnyNodeId) => {
|
||||||
setSelection({ selectedIds: [nodeId] })
|
setSelection({ selectedIds: [nodeId] })
|
||||||
}
|
}
|
||||||
|
const handlePlacedElevatorSelected = (
|
||||||
|
nodeId: AnyNodeId,
|
||||||
|
elevatorBuildingId: BuildingNode['id'],
|
||||||
|
) => {
|
||||||
|
setSelection({ buildingId: elevatorBuildingId, selectedIds: [nodeId] })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
||||||
{/* World-space tools: site boundary and building movement operate in world coordinates */}
|
{/* World-space tools: site boundary and building movement operate in world coordinates */}
|
||||||
{movingNode?.type === 'building' && <MoveTool onSpawnMoved={handlePlacedNodeSelected} />}
|
{movingNode?.type === 'building' && (
|
||||||
|
<MoveTool onNodeMoved={handlePlacedNodeSelected} onSpawnMoved={handlePlacedNodeSelected} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Building-local group: all other tools are relative to the selected 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
|
Cursor visuals set positions in building-local space; this group applies the
|
||||||
@@ -162,7 +169,10 @@ export const ToolManager: React.FC = () => {
|
|||||||
{curvingWall && <CurveWallTool node={curvingWall} />}
|
{curvingWall && <CurveWallTool node={curvingWall} />}
|
||||||
{curvingFence && <CurveFenceTool node={curvingFence} />}
|
{curvingFence && <CurveFenceTool node={curvingFence} />}
|
||||||
{movingNode && movingNode.type !== 'building' && (
|
{movingNode && movingNode.type !== 'building' && (
|
||||||
<MoveTool onSpawnMoved={handlePlacedNodeSelected} />
|
<MoveTool
|
||||||
|
onNodeMoved={handlePlacedNodeSelected}
|
||||||
|
onSpawnMoved={handlePlacedNodeSelected}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{!movingNode && showBuildTool && tool === 'spawn' && (
|
{!movingNode && showBuildTool && tool === 'spawn' && (
|
||||||
<SpawnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} />
|
<SpawnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} />
|
||||||
@@ -170,7 +180,16 @@ export const ToolManager: React.FC = () => {
|
|||||||
{!movingNode && showBuildTool && tool === 'column' && (
|
{!movingNode && showBuildTool && tool === 'column' && (
|
||||||
<ColumnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} />
|
<ColumnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} />
|
||||||
)}
|
)}
|
||||||
{!movingNode && BuildToolComponent && tool !== 'column' && <BuildToolComponent />}
|
{!movingNode && showBuildTool && tool === 'elevator' && (
|
||||||
|
<ElevatorTool
|
||||||
|
buildingId={buildingId as BuildingNode['id'] | null}
|
||||||
|
levelId={selectedLevelId}
|
||||||
|
onPlaced={handlePlacedElevatorSelected}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!movingNode && BuildToolComponent && tool !== 'column' && tool !== 'elevator' && (
|
||||||
|
<BuildToolComponent />
|
||||||
|
)}
|
||||||
</group>
|
</group>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type ElevatorNode,
|
type ElevatorNode,
|
||||||
resolveElevatorDispatchTarget,
|
|
||||||
useInteractive,
|
useInteractive,
|
||||||
useLiveNodeOverrides,
|
useLiveNodeOverrides,
|
||||||
useLiveTransforms,
|
useLiveTransforms,
|
||||||
useRegistry,
|
useRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { type ThreeEvent, useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
BoxGeometry,
|
BoxGeometry,
|
||||||
CylinderGeometry,
|
CylinderGeometry,
|
||||||
@@ -561,29 +561,8 @@ function ElevatorMeshButton({
|
|||||||
[action, buttonKind, disabled, elevatorId, levelId],
|
[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 (
|
return (
|
||||||
<group onPointerDown={press} position={position} userData={userData}>
|
<group position={position} userData={userData}>
|
||||||
{!disabled && (active || queued) && (
|
{!disabled && (active || queued) && (
|
||||||
<mesh
|
<mesh
|
||||||
dispose={null}
|
dispose={null}
|
||||||
@@ -700,6 +679,32 @@ function getElevatorShaftDepth(node: ElevatorNode, cabDepth = getElevatorCabDept
|
|||||||
return Math.max(node.shaftDepth ?? cabDepth, cabDepth, 0.8)
|
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({
|
function DoorLeaf({
|
||||||
animated,
|
animated,
|
||||||
doorOpen,
|
doorOpen,
|
||||||
@@ -1000,7 +1005,6 @@ function LandingDoor({
|
|||||||
export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
|
export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
|
||||||
const ref = useRef<Group>(null!)
|
const ref = useRef<Group>(null!)
|
||||||
const cabRef = useRef<Group>(null)
|
const cabRef = useRef<Group>(null)
|
||||||
const nodes = useScene((state) => state.nodes)
|
|
||||||
const handlers = useNodeEvents(node, 'elevator')
|
const handlers = useNodeEvents(node, 'elevator')
|
||||||
const liveOverrides = useLiveNodeOverrides((state) => state.get(node.id))
|
const liveOverrides = useLiveNodeOverrides((state) => state.get(node.id))
|
||||||
const liveTransform = useLiveTransforms((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, ...liveOverrides } as ElevatorNode) : node),
|
||||||
[liveOverrides, node],
|
[liveOverrides, node],
|
||||||
)
|
)
|
||||||
|
const levelContextNodes = useScene(
|
||||||
|
useShallow((state) => getElevatorLevelContextNodes(renderNode, state.nodes)),
|
||||||
|
)
|
||||||
|
|
||||||
useRegistry(node.id, 'elevator', ref)
|
useRegistry(node.id, 'elevator', ref)
|
||||||
|
|
||||||
const { entries, defaultEntry, shaftBaseY, totalHeight } = useMemo(
|
const { entries, defaultEntry, shaftBaseY, totalHeight } = useMemo(
|
||||||
() => resolveElevatorLevels(renderNode, nodes),
|
() => resolveElevatorLevels(renderNode, levelContextNodes),
|
||||||
[renderNode, nodes],
|
[renderNode, levelContextNodes],
|
||||||
)
|
)
|
||||||
const elevatorId = node.id as AnyNodeId
|
const elevatorId = node.id as AnyNodeId
|
||||||
const runtimeStatus = useInteractive(
|
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(() => {
|
useFrame(() => {
|
||||||
if (!cabRef.current) return
|
if (!cabRef.current) return
|
||||||
const runtime = useInteractive.getState().elevators[elevatorId]
|
const runtime = useInteractive.getState().elevators[elevatorId]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { ElevatorOpeningSystem } from '@pascal-app/core'
|
||||||
import { Bvh } from '@react-three/drei'
|
import { Bvh } from '@react-three/drei'
|
||||||
import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
|
import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
|
||||||
import { useEffect, useMemo, useRef } from 'react'
|
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 { DoorAnimationSystem } from '../../systems/door/door-animation-system'
|
||||||
import { DoorSystem } from '../../systems/door/door-system'
|
import { DoorSystem } from '../../systems/door/door-system'
|
||||||
import { ElevatorAnimationSystem } from '../../systems/elevator/elevator-animation-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 { FenceSystem } from '../../systems/fence/fence-system'
|
||||||
import { GuideSystem } from '../../systems/guide/guide-system'
|
import { GuideSystem } from '../../systems/guide/guide-system'
|
||||||
import { ItemSystem } from '../../systems/item/item-system'
|
import { ItemSystem } from '../../systems/item/item-system'
|
||||||
@@ -208,6 +209,7 @@ const Viewer: React.FC<ViewerProps> = ({
|
|||||||
<CeilingSystem />
|
<CeilingSystem />
|
||||||
<DoorAnimationSystem />
|
<DoorAnimationSystem />
|
||||||
<ElevatorAnimationSystem />
|
<ElevatorAnimationSystem />
|
||||||
|
<ElevatorInteractionSystem />
|
||||||
<ElevatorOpeningSystem />
|
<ElevatorOpeningSystem />
|
||||||
<WindowAnimationSystem />
|
<WindowAnimationSystem />
|
||||||
<DoorSystem />
|
<DoorSystem />
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ export function ElevatorAnimationSystem() {
|
|||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
const now = clock.getElapsedTime() * 1000
|
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) {
|
for (const elevatorId of sceneRegistry.byType.elevator) {
|
||||||
const typedElevatorId = elevatorId as AnyNodeId
|
const typedElevatorId = elevatorId as AnyNodeId
|
||||||
const node = nodes[typedElevatorId]
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user