Add cab door-open control and align elevator buttons
This commit is contained in:
@@ -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-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 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>
|
||||
<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
Reference in New Issue
Block a user