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. */
|
/** Queue a request for an elevator to travel to a level. */
|
||||||
requestElevator: (elevatorId: AnyNodeId, levelId: AnyNodeId) => void
|
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. */
|
/** Merge runtime elevator state. */
|
||||||
setElevatorState: (elevatorId: AnyNodeId, value: Partial<ElevatorInteractiveState>) => void
|
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) => {
|
setElevatorState: (elevatorId, value) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const current = state.elevators[elevatorId]
|
const current = state.elevators[elevatorId]
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import { KeyboardControls } from '@react-three/drei'
|
|||||||
import { useFrame, useThree } from '@react-three/fiber'
|
import { useFrame, useThree } from '@react-three/fiber'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
BoxGeometry,
|
|
||||||
Box3,
|
Box3,
|
||||||
|
BoxGeometry,
|
||||||
Euler,
|
Euler,
|
||||||
type Group,
|
type Group,
|
||||||
Matrix4,
|
Matrix4,
|
||||||
@@ -142,16 +142,18 @@ type FirstPersonInteractableTarget =
|
|||||||
type: 'door' | 'window'
|
type: 'door' | 'window'
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
action: 'open-door' | 'request-level'
|
||||||
buttonKind: 'cab' | 'landing'
|
buttonKind: 'cab' | 'landing'
|
||||||
id: AnyNodeId
|
id: AnyNodeId
|
||||||
levelId: AnyNodeId
|
levelId?: AnyNodeId
|
||||||
type: 'elevator'
|
type: 'elevator'
|
||||||
}
|
}
|
||||||
|
|
||||||
type ElevatorButtonTarget = {
|
type ElevatorButtonTarget = {
|
||||||
|
action: 'open-door' | 'request-level'
|
||||||
buttonKind: 'cab' | 'landing'
|
buttonKind: 'cab' | 'landing'
|
||||||
elevatorId: AnyNodeId
|
elevatorId: AnyNodeId
|
||||||
levelId: AnyNodeId
|
levelId?: AnyNodeId
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | null {
|
function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | null {
|
||||||
@@ -161,6 +163,7 @@ function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | n
|
|||||||
const candidate = (
|
const candidate = (
|
||||||
current.userData as {
|
current.userData as {
|
||||||
elevatorButton?: {
|
elevatorButton?: {
|
||||||
|
action?: unknown
|
||||||
elevatorId?: unknown
|
elevatorId?: unknown
|
||||||
kind?: unknown
|
kind?: unknown
|
||||||
levelId?: unknown
|
levelId?: unknown
|
||||||
@@ -168,12 +171,24 @@ function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | n
|
|||||||
}
|
}
|
||||||
).elevatorButton
|
).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 (
|
if (
|
||||||
typeof candidate?.elevatorId === 'string' &&
|
typeof candidate?.elevatorId === 'string' &&
|
||||||
typeof candidate.levelId === 'string' &&
|
typeof candidate.levelId === 'string' &&
|
||||||
(candidate.kind === 'cab' || candidate.kind === 'landing')
|
(candidate.kind === 'cab' || candidate.kind === 'landing')
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
|
action: 'request-level',
|
||||||
buttonKind: candidate.kind,
|
buttonKind: candidate.kind,
|
||||||
elevatorId: candidate.elevatorId as AnyNodeId,
|
elevatorId: candidate.elevatorId as AnyNodeId,
|
||||||
levelId: candidate.levelId as AnyNodeId,
|
levelId: candidate.levelId as AnyNodeId,
|
||||||
@@ -712,10 +727,13 @@ export const FirstPersonControls = () => {
|
|||||||
|
|
||||||
const target = resolveElevatorButtonTarget(intersection.object)
|
const target = resolveElevatorButtonTarget(intersection.object)
|
||||||
if (!target || target.elevatorId !== elevatorId) continue
|
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
|
if (target.buttonKind === 'cab' && !canUseCabButtons) continue
|
||||||
|
|
||||||
closestTarget = {
|
closestTarget = {
|
||||||
|
action: target.action,
|
||||||
buttonKind: target.buttonKind,
|
buttonKind: target.buttonKind,
|
||||||
id: target.elevatorId,
|
id: target.elevatorId,
|
||||||
levelId: target.levelId,
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
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 { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import { CursorSphere } from '../shared/cursor-sphere'
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
import {
|
import {
|
||||||
@@ -76,16 +77,23 @@ function createElevatorPreviewGeometry(): THREE.BufferGeometry {
|
|||||||
|
|
||||||
function commitElevatorPlacement(
|
function commitElevatorPlacement(
|
||||||
buildingId: BuildingNode['id'],
|
buildingId: BuildingNode['id'],
|
||||||
position: [number, number, number],
|
x: number,
|
||||||
|
z: number,
|
||||||
rotation: number,
|
rotation: number,
|
||||||
): 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)
|
||||||
|
const supportY = resolveElevatorSupportY({
|
||||||
|
buildingId,
|
||||||
|
preferredLevelId: serviceRange.fromLevelId ?? serviceRange.defaultLevelId,
|
||||||
|
x,
|
||||||
|
z,
|
||||||
|
})
|
||||||
const elevator = ElevatorNode.parse({
|
const elevator = ElevatorNode.parse({
|
||||||
name: `Elevator ${elevatorCount + 1}`,
|
name: `Elevator ${elevatorCount + 1}`,
|
||||||
parentId: buildingId,
|
parentId: buildingId,
|
||||||
position,
|
position: [x, supportY, z],
|
||||||
rotation,
|
rotation,
|
||||||
width: DEFAULT_ELEVATOR_WIDTH,
|
width: DEFAULT_ELEVATOR_WIDTH,
|
||||||
depth: DEFAULT_ELEVATOR_DEPTH,
|
depth: DEFAULT_ELEVATOR_DEPTH,
|
||||||
@@ -131,10 +139,15 @@ export const ElevatorTool: React.FC = () => {
|
|||||||
const onGridMove = (event: GridEvent) => {
|
const onGridMove = (event: GridEvent) => {
|
||||||
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
|
||||||
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)
|
cursorRef.current?.position.set(gridX, supportY + GRID_OFFSET, gridZ)
|
||||||
previewRef.current?.position.set(gridX, y + DEFAULT_ELEVATOR_CAB_HEIGHT / 2, gridZ)
|
previewRef.current?.position.set(gridX, supportY + DEFAULT_ELEVATOR_CAB_HEIGHT / 2, gridZ)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
previousGridPosRef.current &&
|
previousGridPosRef.current &&
|
||||||
@@ -152,7 +165,7 @@ export const ElevatorTool: React.FC = () => {
|
|||||||
|
|
||||||
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, 0, gridZ], rotationRef.current)
|
commitElevatorPlacement(latestBuildingId, gridX, gridZ, rotationRef.current)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
|
type BuildingNode,
|
||||||
type ElevatorNode,
|
type ElevatorNode,
|
||||||
ElevatorNode as ElevatorNodeSchema,
|
ElevatorNode as ElevatorNodeSchema,
|
||||||
emitter,
|
emitter,
|
||||||
type GridEvent,
|
type GridEvent,
|
||||||
|
type LevelNode,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useLiveTransforms,
|
useLiveTransforms,
|
||||||
useScene,
|
useScene,
|
||||||
@@ -11,6 +13,7 @@ import {
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
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 { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { CursorSphere } from '../shared/cursor-sphere'
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
@@ -62,6 +65,10 @@ export function MoveElevatorTool({ node: movingNode }: { node: ElevatorNode }) {
|
|||||||
let wasCommitted = false
|
let wasCommitted = false
|
||||||
let wasCancelled = false
|
let wasCancelled = false
|
||||||
let pendingRotation = movingNode.rotation
|
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 = (
|
const applyPreview = (
|
||||||
position: ElevatorNode['position'],
|
position: ElevatorNode['position'],
|
||||||
@@ -98,7 +105,12 @@ export function MoveElevatorTool({ node: movingNode }: { node: ElevatorNode }) {
|
|||||||
const onGridMove = (event: GridEvent) => {
|
const onGridMove = (event: GridEvent) => {
|
||||||
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
|
||||||
const y = event.localPosition[1]
|
const supportY = resolveElevatorSupportY({
|
||||||
|
buildingId: supportBuildingId,
|
||||||
|
preferredLevelId: supportLevelId,
|
||||||
|
x: gridX,
|
||||||
|
z: gridZ,
|
||||||
|
})
|
||||||
|
|
||||||
if (
|
if (
|
||||||
previousGridPosRef.current &&
|
previousGridPosRef.current &&
|
||||||
@@ -108,21 +120,28 @@ export function MoveElevatorTool({ node: movingNode }: { node: ElevatorNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
previousGridPosRef.current = [gridX, gridZ]
|
previousGridPosRef.current = [gridX, gridZ]
|
||||||
setCursorPosition([gridX, y, gridZ])
|
setCursorPosition([gridX, supportY, gridZ])
|
||||||
previewPositionRef.current = [gridX, movingNode.position[1], gridZ]
|
previewPositionRef.current = [gridX, supportY, gridZ]
|
||||||
applyPreview(previewPositionRef.current, pendingRotation)
|
applyPreview(previewPositionRef.current, pendingRotation)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onGridClick = (event: GridEvent) => {
|
const onGridClick = (event: GridEvent) => {
|
||||||
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
|
||||||
|
const supportY = resolveElevatorSupportY({
|
||||||
|
buildingId: supportBuildingId,
|
||||||
|
preferredLevelId: supportLevelId,
|
||||||
|
x: gridX,
|
||||||
|
z: gridZ,
|
||||||
|
})
|
||||||
|
const nextPosition: ElevatorNode['position'] = [gridX, supportY, gridZ]
|
||||||
|
|
||||||
wasCommitted = true
|
wasCommitted = true
|
||||||
clearPreview()
|
clearPreview()
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
if (movingNodeId && useScene.getState().nodes[movingNodeId as AnyNodeId]) {
|
if (movingNodeId && useScene.getState().nodes[movingNodeId as AnyNodeId]) {
|
||||||
useScene.getState().updateNode(movingNodeId as AnyNodeId, {
|
useScene.getState().updateNode(movingNodeId as AnyNodeId, {
|
||||||
position: [gridX, movingNode.position[1], gridZ],
|
position: nextPosition,
|
||||||
rotation: pendingRotation,
|
rotation: pendingRotation,
|
||||||
metadata: committedMeta,
|
metadata: committedMeta,
|
||||||
})
|
})
|
||||||
@@ -131,7 +150,7 @@ export function MoveElevatorTool({ node: movingNode }: { node: ElevatorNode }) {
|
|||||||
const elevator = ElevatorNodeSchema.parse({
|
const elevator = ElevatorNodeSchema.parse({
|
||||||
...movingNode,
|
...movingNode,
|
||||||
id: undefined,
|
id: undefined,
|
||||||
position: [gridX, movingNode.position[1], gridZ],
|
position: nextPosition,
|
||||||
rotation: pendingRotation,
|
rotation: pendingRotation,
|
||||||
metadata: committedMeta,
|
metadata: committedMeta,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import {
|
|||||||
type LevelNode,
|
type LevelNode,
|
||||||
useInteractive,
|
useInteractive,
|
||||||
useLiveNodeOverrides,
|
useLiveNodeOverrides,
|
||||||
|
useLiveTransforms,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Copy, Move, Send, Trash2 } from 'lucide-react'
|
import { Copy, Move, Send, Trash2 } from 'lucide-react'
|
||||||
import { useCallback, useEffect } from 'react'
|
import { useCallback, useEffect } from 'react'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
import { resolveElevatorNodeSupportY, resolveElevatorSupportY } from '../../../lib/elevator-support'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
@@ -90,6 +92,18 @@ function stripDuplicateFlags(metadata: ElevatorNode['metadata']) {
|
|||||||
|
|
||||||
type ElevatorMetricKey = 'width' | 'depth' | 'cabHeight' | 'doorWidth' | 'doorHeight'
|
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() {
|
export function ElevatorPanel() {
|
||||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||||
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
|
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
|
||||||
@@ -115,10 +129,15 @@ export function ElevatorPanel() {
|
|||||||
const liveOverrides = useLiveNodeOverrides((s) =>
|
const liveOverrides = useLiveNodeOverrides((s) =>
|
||||||
selectedId ? s.get(selectedId as AnyNodeId) : undefined,
|
selectedId ? s.get(selectedId as AnyNodeId) : undefined,
|
||||||
)
|
)
|
||||||
|
const liveTransform = useLiveTransforms((s) =>
|
||||||
|
selectedId ? s.get(selectedId as AnyNodeId) : undefined,
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
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])
|
}, [selectedId])
|
||||||
|
|
||||||
@@ -143,9 +162,32 @@ export function ElevatorPanel() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const clearLivePreview = useCallback(() => {
|
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])
|
}, [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(
|
const previewMetric = useCallback(
|
||||||
<K extends ElevatorMetricKey>(key: K, value: ElevatorNode[K]) => {
|
<K extends ElevatorMetricKey>(key: K, value: ElevatorNode[K]) => {
|
||||||
if (!selectedId) return
|
if (!selectedId) return
|
||||||
@@ -167,6 +209,43 @@ export function ElevatorPanel() {
|
|||||||
[node, selectedId, updateNode],
|
[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(() => {
|
const handleClose = useCallback(() => {
|
||||||
clearLivePreview()
|
clearLivePreview()
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
@@ -231,6 +310,20 @@ export function ElevatorPanel() {
|
|||||||
defaultLevelId: currentDefaultIsServed
|
defaultLevelId: currentDefaultIsServed
|
||||||
? node.defaultLevelId
|
? node.defaultLevelId
|
||||||
: nextFromLevelId || nextServedLevels[0]?.id || null,
|
: 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,
|
servedLevelIds: undefined,
|
||||||
} as Partial<ElevatorNode>)
|
} as Partial<ElevatorNode>)
|
||||||
},
|
},
|
||||||
@@ -240,6 +333,9 @@ export function ElevatorPanel() {
|
|||||||
if (!(node && node.type === 'elevator' && selectedId && selectedCount === 1)) return null
|
if (!(node && node.type === 'elevator' && selectedId && selectedCount === 1)) return null
|
||||||
|
|
||||||
const displayNode = liveOverrides ? ({ ...node, ...liveOverrides } as ElevatorNode) : node
|
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 fromLevelId = getResolvedFromLevelId(node, levels)
|
||||||
const toLevelId = getResolvedToLevelId(node, levels, fromLevelId)
|
const toLevelId = getResolvedToLevelId(node, levels, fromLevelId)
|
||||||
const servedLevels = getServiceLevels(levels, fromLevelId, toLevelId)
|
const servedLevels = getServiceLevels(levels, fromLevelId, toLevelId)
|
||||||
@@ -255,9 +351,15 @@ export function ElevatorPanel() {
|
|||||||
? node.defaultLevelId
|
? node.defaultLevelId
|
||||||
: fromLevelId || levels[0]?.id) ??
|
: fromLevelId || levels[0]?.id) ??
|
||||||
null
|
null
|
||||||
const queuedLevelIds = new Set<string>()
|
const destinationOrderByLevelId = new Map<string, number>()
|
||||||
for (const levelId of runtime?.queue ?? []) queuedLevelIds.add(levelId)
|
const orderedDestinationIds: string[] = []
|
||||||
if (runtime?.targetLevelId) queuedLevelIds.add(runtime.targetLevelId)
|
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 (
|
return (
|
||||||
<PanelWrapper
|
<PanelWrapper
|
||||||
@@ -283,6 +385,102 @@ export function ElevatorPanel() {
|
|||||||
</ActionGroup>
|
</ActionGroup>
|
||||||
</PanelSection>
|
</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">
|
<PanelSection title="Cab">
|
||||||
<MetricControl
|
<MetricControl
|
||||||
label="Width"
|
label="Width"
|
||||||
@@ -410,25 +608,32 @@ export function ElevatorPanel() {
|
|||||||
<div className="grid grid-cols-2 gap-1.5">
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
{servedLevels.map((level) => {
|
{servedLevels.map((level) => {
|
||||||
const isActive = activeLevelId === level.id
|
const isActive = activeLevelId === level.id
|
||||||
const isQueued = queuedLevelIds.has(level.id)
|
const stopOrder = destinationOrderByLevelId.get(level.id)
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`flex min-h-11 items-center justify-between gap-2 rounded-lg border px-2.5 text-left transition-colors ${
|
className={`flex min-h-11 items-center justify-between gap-2 rounded-lg border px-2.5 text-left transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? 'border-sky-400/45 bg-sky-400/15 text-sky-100'
|
? 'border-emerald-400/45 bg-emerald-400/15 text-emerald-100'
|
||||||
: isQueued
|
|
||||||
? 'border-amber-300/45 bg-amber-300/15 text-amber-100'
|
|
||||||
: 'border-border/50 bg-[#2C2C2E] text-foreground hover:bg-[#3e3e3e]'
|
: 'border-border/50 bg-[#2C2C2E] text-foreground hover:bg-[#3e3e3e]'
|
||||||
}`}
|
}`}
|
||||||
key={level.id}
|
key={level.id}
|
||||||
onClick={() => requestLevel(level.id)}
|
onClick={() => requestLevel(level.id)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span className="min-w-0 truncate text-xs">
|
<span className="flex min-w-0 flex-col">
|
||||||
{level.name || `Level ${level.level}`}
|
<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>
|
||||||
|
<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>
|
</span>
|
||||||
</button>
|
</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