'use client' import { type AnyNode, type AnyNodeId, type ElevatorNode, ElevatorNode as ElevatorNodeSchema, type LevelNode, requestElevatorLevel, useInteractive, useLiveNodeOverrides, useLiveTransforms, useScene, } from '@pascal-app/core' import { ActionButton, ActionGroup, MetricControl, PanelSection, PanelWrapper, resolveElevatorNodeSupportY, resolveElevatorSupportY, SliderControl, triggerSFX, useEditor, } from '@pascal-app/editor' 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' function findLevelId(levels: LevelNode[], levelId: string | null | undefined) { if (!levelId) return null return levels.some((level) => level.id === levelId) ? levelId : null } function getLegacyServedLevels(node: ElevatorNode | undefined, levels: LevelNode[]) { if (!node || node.fromLevelId || node.toLevelId || !node.servedLevelIds?.length) return [] const servedIds = new Set(node.servedLevelIds) return levels.filter((level) => servedIds.has(level.id)) } function getResolvedFromLevelId(node: ElevatorNode | undefined, levels: LevelNode[]) { if (!node) return levels[0]?.id ?? '' const legacyServedLevels = getLegacyServedLevels(node, levels) return ( findLevelId(levels, node.fromLevelId) ?? legacyServedLevels[0]?.id ?? findLevelId(levels, node.defaultLevelId) ?? levels[0]?.id ?? '' ) } function getResolvedToLevelId( node: ElevatorNode | undefined, levels: LevelNode[], fromLevelId: string, ) { if (!node) return levels[0]?.id ?? '' const explicitTo = findLevelId(levels, node.toLevelId) if (explicitTo) return explicitTo const legacyServedLevels = getLegacyServedLevels(node, levels) const legacyTo = legacyServedLevels[legacyServedLevels.length - 1]?.id if (legacyTo) return legacyTo const fromIndex = levels.findIndex((level) => level.id === fromLevelId) const fallbackIndex = fromIndex >= 0 ? Math.min(fromIndex + 1, levels.length - 1) : 0 return levels[fallbackIndex]?.id ?? fromLevelId } function getServiceLevels(levels: LevelNode[], fromLevelId: string, toLevelId: string) { const fromIndex = levels.findIndex((level) => level.id === fromLevelId) const toIndex = levels.findIndex((level) => level.id === toLevelId) if (fromIndex < 0 && toIndex < 0) return [] const resolvedFromIndex = fromIndex >= 0 ? fromIndex : toIndex const resolvedToIndex = toIndex >= 0 ? toIndex : Math.min(Math.max(resolvedFromIndex, 0) + 1, levels.length - 1) const minIndex = Math.min(resolvedFromIndex, resolvedToIndex) const maxIndex = Math.max(resolvedFromIndex, resolvedToIndex) return levels.slice(minIndex, maxIndex + 1) } function stripDuplicateFlags(metadata: ElevatorNode['metadata']) { if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) { return metadata } const nextMeta = { ...(metadata as Record) } delete nextMeta.isNew delete nextMeta.isTransient return nextMeta as ElevatorNode['metadata'] } type ElevatorMetricKey = | 'width' | 'depth' | 'shaftWidth' | 'shaftDepth' | 'shaftWallThickness' | 'cabHeight' | 'doorWidth' | 'doorHeight' type ElevatorAccessField = 'disabledLevelIds' | 'serviceOnlyLevelIds' const DOOR_STYLE_OPTIONS: Array<{ label: string value: ElevatorNode['doorStyle'] }> = [ { label: 'Center opening', value: 'center-opening' }, { label: 'Single left', value: 'single-left' }, { label: 'Single right', value: 'single-right' }, ] const DOOR_PANEL_STYLE_OPTIONS: Array<{ label: string value: ElevatorNode['doorPanelStyle'] }> = [ { label: 'Glass frame', value: 'glass-frame' }, { label: 'Solid panel', value: 'solid-panel' }, { label: 'Segmented panel', value: 'segmented-panel' }, ] const SHAFT_STYLE_OPTIONS: Array<{ label: string value: ElevatorNode['shaftStyle'] }> = [ { label: 'Solid', value: 'solid' }, { label: 'Glass', value: 'glass' }, ] function roundMeters(value: number) { return Math.round(value * 100) / 100 } function getResolvedShaftWidth(node: ElevatorNode) { return Math.max(node.shaftWidth ?? node.width, node.width, 0.8) } function getResolvedShaftDepth(node: ElevatorNode) { return Math.max(node.shaftDepth ?? node.depth, node.depth, 0.8) } function getResolvedShaftWallThickness(node: ElevatorNode) { return Math.max(node.shaftWallThickness ?? 0.09, 0.04) } function radiansToDegrees(radians: number) { return Math.round((radians * 180) / Math.PI) } function degreesToRadians(degrees: number) { return (degrees * Math.PI) / 180 } export default function ElevatorPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedCount = useViewer((s) => s.selection.selectedIds.length) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) const createNode = useScene((s) => s.createNode) const setMovingNode = useEditor((s) => s.setMovingNode) const runtime = useInteractive( useShallow((s) => { const state = selectedId ? s.elevators[selectedId as AnyNodeId] : null if (!state) return null return { currentLevelId: state.currentLevelId, requestedStops: state.requestedStops, queue: state.queue, targetLevelId: state.targetLevelId, } }), ) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as ElevatorNode | undefined) : undefined, ) 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) return useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId) useLiveTransforms.getState().clear(selectedId as AnyNodeId) } }, [selectedId]) const levels = useScene( useShallow((s) => { if (!(node?.parentId && s.nodes[node.parentId as AnyNodeId]?.type === 'building')) return [] const building = s.nodes[node.parentId as AnyNodeId] if (building?.type !== 'building') return [] return building.children .map((childId) => s.nodes[childId as AnyNodeId]) .filter((entry): entry is LevelNode => entry?.type === 'level') .sort((left, right) => left.level - right.level) }), ) const handleUpdate = useCallback( (updates: Partial) => { if (!selectedId) return updateNode(selectedId as AnyNode['id'], updates) }, [selectedId, updateNode], ) const clearLivePreview = useCallback(() => { 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, node, ]) const previewMetric = useCallback( (key: K, value: ElevatorNode[K]) => { if (!selectedId) return useLiveNodeOverrides.getState().set(selectedId as AnyNodeId, { [key]: value }) }, [selectedId], ) const commitMetric = useCallback( (key: K, value: ElevatorNode[K]) => { if (!selectedId) return const hasChange = !(node && Math.abs(Number(node[key]) - Number(value)) <= 1e-6) if (hasChange) { updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial) } useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId) }, [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: [] }) }, [clearLivePreview, setSelection]) const handleMove = useCallback(() => { if (!node) return triggerSFX('sfx:item-pick') clearLivePreview() setMovingNode(node) setSelection({ selectedIds: [] }) }, [clearLivePreview, node, setMovingNode, setSelection]) const handleDuplicate = useCallback(() => { if (!node?.parentId) return triggerSFX('sfx:item-pick') const duplicate = ElevatorNodeSchema.parse({ ...structuredClone(node), id: undefined, name: node.name ? `${node.name} Copy` : 'Elevator Copy', position: [node.position[0] + 1, node.position[1], node.position[2] + 1], metadata: { ...(stripDuplicateFlags(node.metadata) as Record), isNew: true }, }) createNode(duplicate, node.parentId as AnyNodeId) clearLivePreview() setMovingNode(duplicate) setSelection({ selectedIds: [] }) }, [clearLivePreview, node, createNode, setMovingNode, setSelection]) const handleDelete = useCallback(() => { if (!(selectedId && node)) return triggerSFX('sfx:structure-delete') clearLivePreview() useScene.getState().deleteNode(selectedId as AnyNodeId) setSelection({ selectedIds: [] }) }, [clearLivePreview, selectedId, node, setSelection]) const requestLevel = useCallback( (levelId: LevelNode['id']) => { if (!node) return if ((node.disabledLevelIds ?? []).includes(levelId)) return requestElevatorLevel(node.id as AnyNodeId, levelId as AnyNodeId) }, [node], ) const toggleLevelAccess = useCallback( (field: ElevatorAccessField, levelId: LevelNode['id']) => { if (!node) return const disabledIds = new Set(node.disabledLevelIds ?? []) const serviceOnlyIds = new Set(node.serviceOnlyLevelIds ?? []) const targetSet = field === 'disabledLevelIds' ? disabledIds : serviceOnlyIds if (targetSet.has(levelId)) { targetSet.delete(levelId) } else { targetSet.add(levelId) } if (field === 'disabledLevelIds' && disabledIds.has(levelId)) { serviceOnlyIds.delete(levelId) } if (field === 'serviceOnlyLevelIds' && serviceOnlyIds.has(levelId)) { disabledIds.delete(levelId) } const nextServiceLevels = getServiceLevels( levels, getResolvedFromLevelId(node, levels), getResolvedToLevelId(node, levels, getResolvedFromLevelId(node, levels)), ) const nextDefaultLevelId = node.defaultLevelId && !disabledIds.has(node.defaultLevelId) ? node.defaultLevelId : (nextServiceLevels.find((level) => !disabledIds.has(level.id))?.id ?? nextServiceLevels[0]?.id ?? null) handleUpdate({ defaultLevelId: nextDefaultLevelId, disabledLevelIds: Array.from(disabledIds), serviceOnlyLevelIds: Array.from(serviceOnlyIds), }) }, [handleUpdate, levels, node], ) const handleServiceBoundaryChange = useCallback( (field: 'fromLevelId' | 'toLevelId', levelId: string) => { if (!node) return const nextFromLevelId = field === 'fromLevelId' ? levelId : getResolvedFromLevelId(node, levels) const nextToLevelId = field === 'toLevelId' ? levelId : getResolvedToLevelId(node, levels, nextFromLevelId) const nextServedLevels = getServiceLevels(levels, nextFromLevelId, nextToLevelId) const currentDefaultIsServed = nextServedLevels.some( (level) => level.id === node.defaultLevelId, ) handleUpdate({ [field]: levelId || null, 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) }, [node, levels, handleUpdate], ) 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 displayShaftWidth = getResolvedShaftWidth(displayNode) const displayShaftDepth = getResolvedShaftDepth(displayNode) const displayShaftWallThickness = getResolvedShaftWallThickness(displayNode) const fromLevelId = getResolvedFromLevelId(node, levels) const toLevelId = getResolvedToLevelId(node, levels, fromLevelId) const servedLevels = getServiceLevels(levels, fromLevelId, toLevelId) const servedLevelIdSet = new Set(servedLevels.map((level) => level.id)) const disabledLevelIds = new Set( (node.disabledLevelIds ?? []).filter((levelId) => servedLevelIdSet.has(levelId)), ) const serviceOnlyLevelIds = new Set( (node.serviceOnlyLevelIds ?? []).filter((levelId) => servedLevelIdSet.has(levelId)), ) const enabledServedLevels = servedLevels.filter((level) => !disabledLevelIds.has(level.id)) const defaultLevelOptions = enabledServedLevels.length > 0 ? enabledServedLevels : servedLevels.length > 0 ? servedLevels : levels const selectedDefaultLevelId = defaultLevelOptions.some( (level) => level.id === node.defaultLevelId, ) ? (node.defaultLevelId ?? '') : fromLevelId const activeLevelId = runtime?.currentLevelId ?? (servedLevels.some((level) => level.id === node.defaultLevelId) ? node.defaultLevelId : fromLevelId || levels[0]?.id) ?? null const destinationOrderByLevelId = new Map() for (const [index, levelId] of (runtime?.requestedStops ?? []).entries()) { destinationOrderByLevelId.set(levelId, index + 1) } return ( } label="Move" onClick={handleMove} /> } label="Duplicate" onClick={handleDuplicate} /> } label="Delete" onClick={handleDelete} /> { 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])} /> { 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])} /> { 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])} /> previewTransform(displayPosition, degreesToRadians(degrees))} onCommit={(degrees) => commitTransform(displayPosition, degreesToRadians(degrees))} precision={0} restoreOnCommit={false} step={1} unit="°" value={displayRotationDegrees} />
{ triggerSFX('sfx:item-rotate') commitTransform(displayPosition, displayRotation - Math.PI / 4) }} /> { triggerSFX('sfx:item-rotate') commitTransform(displayPosition, displayRotation + Math.PI / 4) }} />
From
To
Default Floor
previewMetric('width', value)} onCommit={(value) => commitMetric('width', value)} precision={2} restoreOnCommit={false} step={0.05} unit="m" value={displayNode.width} /> previewMetric('depth', value)} onCommit={(value) => commitMetric('depth', value)} precision={2} restoreOnCommit={false} step={0.05} unit="m" value={displayNode.depth} /> previewMetric('cabHeight', value)} onCommit={(value) => commitMetric('cabHeight', value)} precision={2} restoreOnCommit={false} step={0.05} unit="m" value={displayNode.cabHeight} />
Shaft Style
previewMetric('shaftWidth', Math.max(value, displayNode.width))} onCommit={(value) => commitMetric('shaftWidth', Math.max(value, displayNode.width))} precision={2} restoreOnCommit={false} step={0.05} unit="m" value={displayShaftWidth} /> previewMetric('shaftDepth', Math.max(value, displayNode.depth))} onCommit={(value) => commitMetric('shaftDepth', Math.max(value, displayNode.depth))} precision={2} restoreOnCommit={false} step={0.05} unit="m" value={displayShaftDepth} /> previewMetric('shaftWallThickness', value)} onCommit={(value) => commitMetric('shaftWallThickness', value)} precision={2} restoreOnCommit={false} step={0.01} unit="m" value={displayShaftWallThickness} />
Opening Style
Door Type
previewMetric('doorWidth', value)} onCommit={(value) => commitMetric('doorWidth', value)} precision={2} restoreOnCommit={false} step={0.05} unit="m" value={displayNode.doorWidth} /> previewMetric('doorHeight', value)} onCommit={(value) => commitMetric('doorHeight', value)} precision={2} restoreOnCommit={false} step={0.05} unit="m" value={displayNode.doorHeight} />
{servedLevels.map((level) => { const isDisabled = disabledLevelIds.has(level.id) const isServiceOnly = serviceOnlyLevelIds.has(level.id) return (
{level.name || `Level ${level.level}`}
) })}
{servedLevels.map((level) => { const isActive = activeLevelId === level.id const stopOrder = destinationOrderByLevelId.get(level.id) const isDisabled = disabledLevelIds.has(level.id) const isServiceOnly = serviceOnlyLevelIds.has(level.id) return ( ) })}
handleUpdate({ speed: value })} precision={1} step={0.1} unit="m/s" value={node.speed} /> handleUpdate({ doorDurationMs: value })} step={50} unit="ms" value={node.doorDurationMs} /> handleUpdate({ dwellMs: value })} step={100} unit="ms" value={node.dwellMs} />
) }