Add cab door-open control and align elevator buttons

This commit is contained in:
sudhir
2026-05-10 02:28:00 +05:30
parent 2480be91b1
commit 263228cbf0
7 changed files with 1051 additions and 351 deletions
@@ -98,6 +98,9 @@ type InteractiveStore = {
/** Queue a request for an elevator to travel to a level. */
requestElevator: (elevatorId: AnyNodeId, levelId: AnyNodeId) => void
/** Open the elevator doors at the current level when the car is not moving. */
openElevatorDoor: (elevatorId: AnyNodeId) => void
/** Merge runtime elevator state. */
setElevatorState: (elevatorId: AnyNodeId, value: Partial<ElevatorInteractiveState>) => void
@@ -274,6 +277,24 @@ export const useInteractive = create<InteractiveStore>((set, get) => ({
})
},
openElevatorDoor: (elevatorId) => {
set((state) => {
const elevator = state.elevators[elevatorId]
if (!elevator?.currentLevelId || elevator.phase === 'moving') return state
return {
elevators: {
...state.elevators,
[elevatorId]: {
...elevator,
phase: 'opening',
phaseStartedAt: null,
},
},
}
})
},
setElevatorState: (elevatorId, value) => {
set((state) => {
const current = state.elevators[elevatorId]
@@ -17,8 +17,8 @@ import { KeyboardControls } from '@react-three/drei'
import { useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
BoxGeometry,
Box3,
BoxGeometry,
Euler,
type Group,
Matrix4,
@@ -142,16 +142,18 @@ type FirstPersonInteractableTarget =
type: 'door' | 'window'
}
| {
action: 'open-door' | 'request-level'
buttonKind: 'cab' | 'landing'
id: AnyNodeId
levelId: AnyNodeId
levelId?: AnyNodeId
type: 'elevator'
}
type ElevatorButtonTarget = {
action: 'open-door' | 'request-level'
buttonKind: 'cab' | 'landing'
elevatorId: AnyNodeId
levelId: AnyNodeId
levelId?: AnyNodeId
}
function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | null {
@@ -161,6 +163,7 @@ function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | n
const candidate = (
current.userData as {
elevatorButton?: {
action?: unknown
elevatorId?: unknown
kind?: unknown
levelId?: unknown
@@ -168,12 +171,24 @@ function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | n
}
).elevatorButton
if (typeof candidate?.elevatorId === 'string' && candidate.kind === 'cab') {
const action = candidate.action === 'open-door' ? 'open-door' : 'request-level'
if (action === 'open-door') {
return {
action,
buttonKind: candidate.kind,
elevatorId: candidate.elevatorId as AnyNodeId,
}
}
}
if (
typeof candidate?.elevatorId === 'string' &&
typeof candidate.levelId === 'string' &&
(candidate.kind === 'cab' || candidate.kind === 'landing')
) {
return {
action: 'request-level',
buttonKind: candidate.kind,
elevatorId: candidate.elevatorId as AnyNodeId,
levelId: candidate.levelId as AnyNodeId,
@@ -712,10 +727,13 @@ export const FirstPersonControls = () => {
const target = resolveElevatorButtonTarget(intersection.object)
if (!target || target.elevatorId !== elevatorId) continue
if (nodes[target.levelId]?.type !== 'level') continue
if (target.action === 'request-level') {
if (!target.levelId || nodes[target.levelId]?.type !== 'level') continue
}
if (target.buttonKind === 'cab' && !canUseCabButtons) continue
closestTarget = {
action: target.action,
buttonKind: target.buttonKind,
id: target.elevatorId,
levelId: target.levelId,
@@ -756,7 +774,11 @@ export const FirstPersonControls = () => {
}
}
}
useInteractive.getState().requestElevator(target.id, target.levelId)
if (target.action === 'open-door') {
useInteractive.getState().openElevatorDoor(target.id)
return
}
if (target.levelId) useInteractive.getState().requestElevator(target.id, target.levelId)
return
}
@@ -10,6 +10,7 @@ import {
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { resolveElevatorSupportY } from '../../../lib/elevator-support'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
import {
@@ -76,16 +77,23 @@ function createElevatorPreviewGeometry(): THREE.BufferGeometry {
function commitElevatorPlacement(
buildingId: BuildingNode['id'],
position: [number, number, number],
x: number,
z: number,
rotation: number,
): void {
const { createNode, nodes } = useScene.getState()
const elevatorCount = Object.values(nodes).filter((node) => node.type === 'elevator').length
const serviceRange = resolveDefaultServiceRange(buildingId)
const supportY = resolveElevatorSupportY({
buildingId,
preferredLevelId: serviceRange.fromLevelId ?? serviceRange.defaultLevelId,
x,
z,
})
const elevator = ElevatorNode.parse({
name: `Elevator ${elevatorCount + 1}`,
parentId: buildingId,
position,
position: [x, supportY, z],
rotation,
width: DEFAULT_ELEVATOR_WIDTH,
depth: DEFAULT_ELEVATOR_DEPTH,
@@ -131,10 +139,15 @@ export const ElevatorTool: React.FC = () => {
const onGridMove = (event: GridEvent) => {
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
const y = event.localPosition[1]
const supportY = resolveElevatorSupportY({
buildingId: currentBuildingId,
preferredLevelId: levelId as LevelNode['id'] | null,
x: gridX,
z: gridZ,
})
cursorRef.current?.position.set(gridX, y + GRID_OFFSET, gridZ)
previewRef.current?.position.set(gridX, y + DEFAULT_ELEVATOR_CAB_HEIGHT / 2, gridZ)
cursorRef.current?.position.set(gridX, supportY + GRID_OFFSET, gridZ)
previewRef.current?.position.set(gridX, supportY + DEFAULT_ELEVATOR_CAB_HEIGHT / 2, gridZ)
if (
previousGridPosRef.current &&
@@ -152,7 +165,7 @@ export const ElevatorTool: React.FC = () => {
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
commitElevatorPlacement(latestBuildingId, [gridX, 0, gridZ], rotationRef.current)
commitElevatorPlacement(latestBuildingId, gridX, gridZ, rotationRef.current)
}
const onKeyDown = (event: KeyboardEvent) => {
@@ -1,9 +1,11 @@
import {
type AnyNodeId,
type BuildingNode,
type ElevatorNode,
ElevatorNode as ElevatorNodeSchema,
emitter,
type GridEvent,
type LevelNode,
sceneRegistry,
useLiveTransforms,
useScene,
@@ -11,6 +13,7 @@ import {
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'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
@@ -62,6 +65,10 @@ export function MoveElevatorTool({ node: movingNode }: { node: ElevatorNode }) {
let wasCommitted = false
let wasCancelled = false
let pendingRotation = movingNode.rotation
const supportBuildingId = movingNode.parentId as BuildingNode['id'] | null | undefined
const supportLevelId = (movingNode.fromLevelId ?? movingNode.defaultLevelId) as
| LevelNode['id']
| null
const applyPreview = (
position: ElevatorNode['position'],
@@ -98,7 +105,12 @@ export function MoveElevatorTool({ node: movingNode }: { node: ElevatorNode }) {
const onGridMove = (event: GridEvent) => {
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
const y = event.localPosition[1]
const supportY = resolveElevatorSupportY({
buildingId: supportBuildingId,
preferredLevelId: supportLevelId,
x: gridX,
z: gridZ,
})
if (
previousGridPosRef.current &&
@@ -108,21 +120,28 @@ export function MoveElevatorTool({ node: movingNode }: { node: ElevatorNode }) {
}
previousGridPosRef.current = [gridX, gridZ]
setCursorPosition([gridX, y, gridZ])
previewPositionRef.current = [gridX, movingNode.position[1], gridZ]
setCursorPosition([gridX, supportY, gridZ])
previewPositionRef.current = [gridX, supportY, gridZ]
applyPreview(previewPositionRef.current, pendingRotation)
}
const onGridClick = (event: GridEvent) => {
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
const supportY = resolveElevatorSupportY({
buildingId: supportBuildingId,
preferredLevelId: supportLevelId,
x: gridX,
z: gridZ,
})
const nextPosition: ElevatorNode['position'] = [gridX, supportY, gridZ]
wasCommitted = true
clearPreview()
useScene.temporal.getState().resume()
if (movingNodeId && useScene.getState().nodes[movingNodeId as AnyNodeId]) {
useScene.getState().updateNode(movingNodeId as AnyNodeId, {
position: [gridX, movingNode.position[1], gridZ],
position: nextPosition,
rotation: pendingRotation,
metadata: committedMeta,
})
@@ -131,7 +150,7 @@ export function MoveElevatorTool({ node: movingNode }: { node: ElevatorNode }) {
const elevator = ElevatorNodeSchema.parse({
...movingNode,
id: undefined,
position: [gridX, movingNode.position[1], gridZ],
position: nextPosition,
rotation: pendingRotation,
metadata: committedMeta,
})
@@ -8,12 +8,14 @@ import {
type LevelNode,
useInteractive,
useLiveNodeOverrides,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Send, Trash2 } from 'lucide-react'
import { useCallback, useEffect } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { resolveElevatorNodeSupportY, resolveElevatorSupportY } from '../../../lib/elevator-support'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
@@ -90,6 +92,18 @@ function stripDuplicateFlags(metadata: ElevatorNode['metadata']) {
type ElevatorMetricKey = 'width' | 'depth' | 'cabHeight' | 'doorWidth' | 'doorHeight'
function roundMeters(value: number) {
return Math.round(value * 100) / 100
}
function radiansToDegrees(radians: number) {
return Math.round((radians * 180) / Math.PI)
}
function degreesToRadians(degrees: number) {
return (degrees * Math.PI) / 180
}
export function ElevatorPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
@@ -115,10 +129,15 @@ export function ElevatorPanel() {
const liveOverrides = useLiveNodeOverrides((s) =>
selectedId ? s.get(selectedId as AnyNodeId) : undefined,
)
const liveTransform = useLiveTransforms((s) =>
selectedId ? s.get(selectedId as AnyNodeId) : undefined,
)
useEffect(() => {
return () => {
if (selectedId) useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
if (!selectedId) return
useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
useLiveTransforms.getState().clear(selectedId as AnyNodeId)
}
}, [selectedId])
@@ -143,9 +162,32 @@ export function ElevatorPanel() {
)
const clearLivePreview = useCallback(() => {
if (selectedId) useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
if (!selectedId) return
useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
useLiveTransforms.getState().clear(selectedId as AnyNodeId)
}, [selectedId])
useEffect(() => {
if (!(selectedId && node?.type === 'elevator')) return
const supportY = resolveElevatorNodeSupportY(node)
if (node.position[1] >= supportY - 1e-4) return
updateNode(selectedId as AnyNode['id'], {
position: [node.position[0], supportY, node.position[2]],
})
}, [
node?.defaultLevelId,
node?.fromLevelId,
node?.id,
node?.parentId,
node?.position[0],
node?.position[1],
node?.position[2],
node?.type,
selectedId,
updateNode,
])
const previewMetric = useCallback(
<K extends ElevatorMetricKey>(key: K, value: ElevatorNode[K]) => {
if (!selectedId) return
@@ -167,6 +209,43 @@ export function ElevatorPanel() {
[node, selectedId, updateNode],
)
const previewTransform = useCallback(
(position: ElevatorNode['position'], rotation: ElevatorNode['rotation']) => {
if (!selectedId) return
useLiveTransforms.getState().set(selectedId as AnyNodeId, { position, rotation })
},
[selectedId],
)
const commitTransform = useCallback(
(position: ElevatorNode['position'], rotation: ElevatorNode['rotation']) => {
if (!(selectedId && node)) return
useLiveTransforms.getState().clear(selectedId as AnyNodeId)
const positionChanged = node.position.some(
(value, index) => Math.abs(value - position[index]!) > 1e-6,
)
const rotationChanged = Math.abs(node.rotation - rotation) > 1e-6
if (positionChanged || rotationChanged) {
updateNode(selectedId as AnyNode['id'], { position, rotation })
}
},
[node, selectedId, updateNode],
)
const getSupportedPosition = useCallback(
(x: number, z: number): ElevatorNode['position'] => {
if (!node) return [x, 0, z]
const supportY = resolveElevatorSupportY({
buildingId: node.parentId,
preferredLevelId: node.fromLevelId ?? node.defaultLevelId,
x,
z,
})
return [x, supportY, z]
},
[node],
)
const handleClose = useCallback(() => {
clearLivePreview()
setSelection({ selectedIds: [] })
@@ -231,6 +310,20 @@ export function ElevatorPanel() {
defaultLevelId: currentDefaultIsServed
? node.defaultLevelId
: nextFromLevelId || nextServedLevels[0]?.id || null,
...(field === 'fromLevelId'
? {
position: [
node.position[0],
resolveElevatorSupportY({
buildingId: node.parentId,
preferredLevelId: nextFromLevelId,
x: node.position[0],
z: node.position[2],
}),
node.position[2],
] as ElevatorNode['position'],
}
: {}),
servedLevelIds: undefined,
} as Partial<ElevatorNode>)
},
@@ -240,6 +333,9 @@ export function ElevatorPanel() {
if (!(node && node.type === 'elevator' && selectedId && selectedCount === 1)) return null
const displayNode = liveOverrides ? ({ ...node, ...liveOverrides } as ElevatorNode) : node
const displayPosition = liveTransform?.position ?? displayNode.position
const displayRotation = liveTransform?.rotation ?? displayNode.rotation
const displayRotationDegrees = radiansToDegrees(displayRotation)
const fromLevelId = getResolvedFromLevelId(node, levels)
const toLevelId = getResolvedToLevelId(node, levels, fromLevelId)
const servedLevels = getServiceLevels(levels, fromLevelId, toLevelId)
@@ -255,9 +351,15 @@ export function ElevatorPanel() {
? node.defaultLevelId
: fromLevelId || levels[0]?.id) ??
null
const queuedLevelIds = new Set<string>()
for (const levelId of runtime?.queue ?? []) queuedLevelIds.add(levelId)
if (runtime?.targetLevelId) queuedLevelIds.add(runtime.targetLevelId)
const destinationOrderByLevelId = new Map<string, number>()
const orderedDestinationIds: string[] = []
if (runtime?.targetLevelId) orderedDestinationIds.push(runtime.targetLevelId)
for (const levelId of runtime?.queue ?? []) {
if (!orderedDestinationIds.includes(levelId)) orderedDestinationIds.push(levelId)
}
orderedDestinationIds.forEach((levelId, index) => {
destinationOrderByLevelId.set(levelId, index + 1)
})
return (
<PanelWrapper
@@ -283,6 +385,102 @@ export function ElevatorPanel() {
</ActionGroup>
</PanelSection>
<PanelSection title="Position">
<SliderControl
label="X"
max={50}
min={-50}
onChange={(value) => {
const position = getSupportedPosition(value, displayPosition[2])
previewTransform(position, displayRotation)
}}
onCommit={(value) => {
const position = getSupportedPosition(value, displayPosition[2])
commitTransform(position, displayRotation)
}}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={roundMeters(displayPosition[0])}
/>
<SliderControl
label="Y"
max={50}
min={-50}
onChange={(value) => {
const position: ElevatorNode['position'] = [
displayPosition[0],
value,
displayPosition[2],
]
previewTransform(position, displayRotation)
}}
onCommit={(value) => {
const position: ElevatorNode['position'] = [
displayPosition[0],
value,
displayPosition[2],
]
commitTransform(position, displayRotation)
}}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={roundMeters(displayPosition[1])}
/>
<SliderControl
label="Z"
max={50}
min={-50}
onChange={(value) => {
const position = getSupportedPosition(displayPosition[0], value)
previewTransform(position, displayRotation)
}}
onCommit={(value) => {
const position = getSupportedPosition(displayPosition[0], value)
commitTransform(position, displayRotation)
}}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={roundMeters(displayPosition[2])}
/>
</PanelSection>
<PanelSection title="Rotation">
<SliderControl
label="Yaw"
max={180}
min={-180}
onChange={(degrees) => previewTransform(displayPosition, degreesToRadians(degrees))}
onCommit={(degrees) => commitTransform(displayPosition, degreesToRadians(degrees))}
precision={0}
restoreOnCommit={false}
step={1}
unit="°"
value={displayRotationDegrees}
/>
<div className="flex gap-1.5 px-1 pt-2 pb-1">
<ActionButton
label="-45°"
onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
commitTransform(displayPosition, displayRotation - Math.PI / 4)
}}
/>
<ActionButton
label="+45°"
onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
commitTransform(displayPosition, displayRotation + Math.PI / 4)
}}
/>
</div>
</PanelSection>
<PanelSection title="Cab">
<MetricControl
label="Width"
@@ -410,25 +608,32 @@ export function ElevatorPanel() {
<div className="grid grid-cols-2 gap-1.5">
{servedLevels.map((level) => {
const isActive = activeLevelId === level.id
const isQueued = queuedLevelIds.has(level.id)
const stopOrder = destinationOrderByLevelId.get(level.id)
return (
<button
className={`flex min-h-11 items-center justify-between gap-2 rounded-lg border px-2.5 text-left transition-colors ${
isActive
? 'border-sky-400/45 bg-sky-400/15 text-sky-100'
: isQueued
? 'border-amber-300/45 bg-amber-300/15 text-amber-100'
: 'border-border/50 bg-[#2C2C2E] text-foreground hover:bg-[#3e3e3e]'
? 'border-emerald-400/45 bg-emerald-400/15 text-emerald-100'
: 'border-border/50 bg-[#2C2C2E] text-foreground hover:bg-[#3e3e3e]'
}`}
key={level.id}
onClick={() => requestLevel(level.id)}
type="button"
>
<span className="min-w-0 truncate text-xs">
{level.name || `Level ${level.level}`}
<span className="flex min-w-0 flex-col">
<span className="truncate text-xs">{level.name || `Level ${level.level}`}</span>
{stopOrder && (
<span className="mt-0.5 text-[10px] font-medium uppercase tracking-[0.12em] text-current/65">
Stop {stopOrder}
</span>
)}
</span>
<span className="flex h-6 min-w-6 items-center justify-center rounded-full border border-white/15 bg-black/20">
<Send className="h-3 w-3" />
<span
className={`flex h-6 min-w-6 items-center justify-center rounded-full border border-white/15 bg-black/20 ${
stopOrder ? 'px-1.5 font-mono text-[11px] font-semibold' : ''
}`}
>
{stopOrder ?? <Send className="h-3 w-3" />}
</span>
</button>
)
@@ -0,0 +1,68 @@
import {
type AnyNode,
type AnyNodeId,
type ElevatorNode,
type LevelNode,
spatialGridManager,
useScene,
} from '@pascal-app/core'
function getBuildingLevels(
buildingId: string | null | undefined,
nodes: Record<string, AnyNode>,
): LevelNode[] {
if (!buildingId) return []
const building = nodes[buildingId as AnyNodeId]
if (building?.type !== 'building') return []
return building.children
.map((childId) => nodes[childId as AnyNodeId])
.filter((entry): entry is LevelNode => entry?.type === 'level')
.sort((left, right) => left.level - right.level)
}
export function resolveElevatorSupportLevelId({
buildingId,
preferredLevelId,
}: {
buildingId: string | null | undefined
preferredLevelId?: string | null
}): LevelNode['id'] | null {
const nodes = useScene.getState().nodes
const levels = getBuildingLevels(buildingId, nodes)
if (levels.length === 0) return null
const preferred = preferredLevelId
? levels.find((level) => level.id === preferredLevelId)
: undefined
return preferred?.id ?? levels[0]?.id ?? null
}
export function resolveElevatorSupportY({
buildingId,
preferredLevelId,
x,
z,
}: {
buildingId: string | null | undefined
preferredLevelId?: string | null
x: number
z: number
}): number {
const levelId = resolveElevatorSupportLevelId({ buildingId, preferredLevelId })
if (!levelId) return 0
return Math.max(0, spatialGridManager.getSlabElevationAt(levelId, x, z))
}
export function resolveElevatorNodeSupportY(
node: ElevatorNode,
position: [number, number, number] = node.position,
): number {
return resolveElevatorSupportY({
buildingId: node.parentId,
preferredLevelId: node.fromLevelId ?? node.defaultLevelId,
x: position[0],
z: position[2],
})
}
File diff suppressed because it is too large Load Diff