'use client' import { type AnyNode, type AnyNodeId, type ElevatorDoorSide, type ElevatorNode, getElevatorCabDepth, getElevatorCabWidth, getElevatorDoorLeafSides, getElevatorDoorLeafWidth, getElevatorDoorLeafX, getElevatorShaftDepth, getElevatorShaftWallThickness, getElevatorShaftWidth, getResolvedElevatorDoorPanelStyle as getResolvedDoorPanelStyle, getResolvedElevatorDoorStyle as getResolvedDoorStyle, getResolvedElevatorShaftStyle as getResolvedShaftStyle, resolveElevatorLevels, useInteractive, useLiveNodeOverrides, useLiveTransforms, useRegistry, useScene, } from '@pascal-app/core' import { type ColorPreset, createDefaultMaterial, createSurfaceRoleMaterial, type RenderShading, useNodeEvents, useViewer, } from '@pascal-app/viewer' import { useFrame } from '@react-three/fiber' import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' import { BoxGeometry, CylinderGeometry, type Group, type InstancedMesh, type Material, Object3D, TorusGeometry, } from 'three' import { useShallow } from 'zustand/react/shallow' const DEFAULT_STRUCTURE_WHITE = '#f2f0ed' const SHAFT_WALL_COLOR = DEFAULT_STRUCTURE_WHITE const SHAFT_SIDE_COLOR = DEFAULT_STRUCTURE_WHITE const SHAFT_TRIM_COLOR = DEFAULT_STRUCTURE_WHITE const CAB_COLOR = DEFAULT_STRUCTURE_WHITE const GLASS_COLOR = '#f8fafc' const DOOR_COLOR = '#8e98a6' const PANEL_COLOR = '#1f2937' type Vector3Tuple = [number, number, number] const UNIT_BOX_GEOMETRY = new BoxGeometry(1, 1, 1) const BUTTON_FACE_GEOMETRY = new CylinderGeometry(1, 0.92, 1, 24) const BUTTON_GLOW_GEOMETRY = new CylinderGeometry(1.42, 1.42, 1, 24) const BUTTON_RING_GEOMETRY = new TorusGeometry(1.12, 0.12, 8, 24) const LABEL_MATRIX_DUMMY = new Object3D() const SHAFT_TOP_FRAME_CLEARANCE = 0.006 type ElevatorDoorPanelStyleValue = ElevatorNode['doorPanelStyle'] type ElevatorDoorStyleValue = ElevatorNode['doorStyle'] type ElevatorMaterial = Material & { depthWrite?: boolean emissive?: { set: (color: string) => void } emissiveIntensity?: number metalness?: number opacity?: number roughness?: number transparent?: boolean } type ElevatorMaterialParams = { color: string depthWrite?: boolean emissive?: string emissiveIntensity?: number metalness?: number opacity?: number roughness?: number transparent?: boolean } function createElevatorMaterial(params: ElevatorMaterialParams, shading: RenderShading): Material { const material = createDefaultMaterial( params.color, params.roughness ?? 0.5, shading, ) as ElevatorMaterial if ('metalness' in material) material.metalness = params.metalness ?? 0 if ('roughness' in material && params.roughness !== undefined) { material.roughness = params.roughness } if (params.depthWrite !== undefined) material.depthWrite = params.depthWrite if (params.opacity !== undefined) material.opacity = params.opacity if (params.transparent !== undefined) material.transparent = params.transparent if (params.emissive && material.emissive) material.emissive.set(params.emissive) if ('emissiveIntensity' in material && params.emissiveIntensity !== undefined) { material.emissiveIntensity = params.emissiveIntensity } material.needsUpdate = true return material } function createElevatorMaterials( shading: RenderShading, textures = true, colorPreset: ColorPreset = 'clay', ) { if (!textures) { const material = createSurfaceRoleMaterial('joinery', colorPreset) return { SHAFT_WALL_MATERIAL: material, SHAFT_SIDE_MATERIAL: material, SHAFT_TRIM_MATERIAL: material, CAB_MATERIAL: material, DOOR_MATERIAL: material, DOOR_GROOVE_MATERIAL: material, GLASS_MATERIAL: material, PANEL_MATERIAL: material, LANDING_PANEL_MATERIAL: material, INDICATOR_SCREEN_MATERIALS: { active: material, idle: material, }, INDICATOR_GLYPH_MATERIALS: { active: material, idle: material, }, BUTTON_FACE_MATERIALS: { active: material, queued: material, idle: material, disabled: material, }, BUTTON_RING_MATERIALS: { active: material, queued: material, idle: material, disabled: material, }, BUTTON_GLOW_MATERIALS: { active: material, queued: material, }, BUTTON_LABEL_MATERIALS: { lit: material, idle: material, disabled: material, }, QUEUE_STRIP_MATERIALS: { queued: material, idle: material, }, } } return { SHAFT_WALL_MATERIAL: createElevatorMaterial( { color: SHAFT_WALL_COLOR, metalness: 0.08, roughness: 0.56 }, shading, ), SHAFT_SIDE_MATERIAL: createElevatorMaterial( { color: SHAFT_SIDE_COLOR, metalness: 0.12, roughness: 0.58 }, shading, ), SHAFT_TRIM_MATERIAL: createElevatorMaterial( { color: SHAFT_TRIM_COLOR, metalness: 0.2, roughness: 0.38 }, shading, ), CAB_MATERIAL: createElevatorMaterial( { color: CAB_COLOR, metalness: 0.2, roughness: 0.48 }, shading, ), DOOR_MATERIAL: createElevatorMaterial( { color: DOOR_COLOR, metalness: 0.34, roughness: 0.34 }, shading, ), DOOR_GROOVE_MATERIAL: createElevatorMaterial( { color: '#5f6978', metalness: 0.28, roughness: 0.42 }, shading, ), GLASS_MATERIAL: createElevatorMaterial( { color: GLASS_COLOR, depthWrite: false, metalness: 0, opacity: 0.2, roughness: 0.08, transparent: true, }, shading, ), PANEL_MATERIAL: createElevatorMaterial( { color: PANEL_COLOR, metalness: 0.32, roughness: 0.36 }, shading, ), LANDING_PANEL_MATERIAL: createElevatorMaterial( { color: PANEL_COLOR, metalness: 0.25, roughness: 0.4 }, shading, ), INDICATOR_SCREEN_MATERIALS: { active: createElevatorMaterial( { color: '#041f2f', emissive: '#0ea5e9', emissiveIntensity: 0.16, metalness: 0.12, roughness: 0.38, }, shading, ), idle: createElevatorMaterial({ color: '#111827', metalness: 0.12, roughness: 0.38 }, shading), }, INDICATOR_GLYPH_MATERIALS: { active: createElevatorMaterial( { color: '#38bdf8', emissive: '#38bdf8', emissiveIntensity: 0.36, metalness: 0.08, roughness: 0.32, }, shading, ), idle: createElevatorMaterial( { color: '#94a3b8', emissive: '#94a3b8', emissiveIntensity: 0.18, metalness: 0.08, roughness: 0.32, }, shading, ), }, BUTTON_FACE_MATERIALS: { active: createElevatorMaterial( { color: '#38bdf8', emissive: '#38bdf8', emissiveIntensity: 0.28, metalness: 0.22, roughness: 0.3, }, shading, ), queued: createElevatorMaterial( { color: '#fbbf24', emissive: '#fbbf24', emissiveIntensity: 0.18, metalness: 0.22, roughness: 0.3, }, shading, ), idle: createElevatorMaterial({ color: '#d6dde7', metalness: 0.22, roughness: 0.3 }, shading), disabled: createElevatorMaterial( { color: '#475569', metalness: 0.12, roughness: 0.52 }, shading, ), }, BUTTON_RING_MATERIALS: { active: createElevatorMaterial( { color: '#0ea5e9', emissive: '#0ea5e9', emissiveIntensity: 0.16, metalness: 0.48, roughness: 0.28, }, shading, ), queued: createElevatorMaterial( { color: '#f59e0b', emissive: '#f59e0b', emissiveIntensity: 0.1, metalness: 0.48, roughness: 0.28, }, shading, ), idle: createElevatorMaterial({ color: '#64748b', metalness: 0.48, roughness: 0.28 }, shading), disabled: createElevatorMaterial( { color: '#334155', metalness: 0.28, roughness: 0.5 }, shading, ), }, BUTTON_GLOW_MATERIALS: { active: createElevatorMaterial( { color: '#38bdf8', depthWrite: false, emissive: '#38bdf8', emissiveIntensity: 0.28, opacity: 0.58, transparent: true, }, shading, ), queued: createElevatorMaterial( { color: '#fbbf24', depthWrite: false, emissive: '#fbbf24', emissiveIntensity: 0.18, opacity: 0.58, transparent: true, }, shading, ), }, BUTTON_LABEL_MATERIALS: { lit: createElevatorMaterial({ color: '#111827', metalness: 0.12, roughness: 0.34 }, shading), idle: createElevatorMaterial({ color: '#334155', metalness: 0.12, roughness: 0.34 }, shading), disabled: createElevatorMaterial( { color: '#94a3b8', metalness: 0.08, roughness: 0.5 }, shading, ), }, QUEUE_STRIP_MATERIALS: { queued: createElevatorMaterial( { color: '#fbbf24', emissive: '#fbbf24', emissiveIntensity: 0.16, metalness: 0.18, roughness: 0.42, }, shading, ), idle: createElevatorMaterial({ color: '#64748b', metalness: 0.18, roughness: 0.42 }, shading), }, } } type ElevatorMaterialSet = ReturnType const elevatorMaterialsCache = new Map() function getElevatorMaterials( shading: RenderShading, textures = true, colorPreset: ColorPreset = 'clay', ): ElevatorMaterialSet { const cacheKey = `${shading}-${textures}-${colorPreset}` const cached = elevatorMaterialsCache.get(cacheKey) if (cached) return cached const materials = createElevatorMaterials(shading, textures, colorPreset) elevatorMaterialsCache.set(cacheKey, materials) return materials } let { SHAFT_WALL_MATERIAL, SHAFT_SIDE_MATERIAL, SHAFT_TRIM_MATERIAL, CAB_MATERIAL, DOOR_MATERIAL, DOOR_GROOVE_MATERIAL, GLASS_MATERIAL, PANEL_MATERIAL, LANDING_PANEL_MATERIAL, INDICATOR_SCREEN_MATERIALS, INDICATOR_GLYPH_MATERIALS, BUTTON_FACE_MATERIALS, BUTTON_RING_MATERIALS, BUTTON_GLOW_MATERIALS, BUTTON_LABEL_MATERIALS, QUEUE_STRIP_MATERIALS, } = getElevatorMaterials('rendered') function setElevatorMaterials( shading: RenderShading, textures = true, colorPreset: ColorPreset = 'clay', ) { ;({ SHAFT_WALL_MATERIAL, SHAFT_SIDE_MATERIAL, SHAFT_TRIM_MATERIAL, CAB_MATERIAL, DOOR_MATERIAL, DOOR_GROOVE_MATERIAL, GLASS_MATERIAL, PANEL_MATERIAL, LANDING_PANEL_MATERIAL, INDICATOR_SCREEN_MATERIALS, INDICATOR_GLYPH_MATERIALS, BUTTON_FACE_MATERIALS, BUTTON_RING_MATERIALS, BUTTON_GLOW_MATERIALS, BUTTON_LABEL_MATERIALS, QUEUE_STRIP_MATERIALS, } = getElevatorMaterials(shading, textures, colorPreset)) } type ElevatorButtonAction = 'open-door' | 'request-level' type SegmentName = | 'bottom' | 'lowerLeft' | 'lowerRight' | 'middle' | 'top' | 'upperLeft' | 'upperRight' const DIGIT_SEGMENTS: Record = { '0': ['top', 'upperLeft', 'upperRight', 'lowerLeft', 'lowerRight', 'bottom'], '1': ['upperRight', 'lowerRight'], '2': ['top', 'upperRight', 'middle', 'lowerLeft', 'bottom'], '3': ['top', 'upperRight', 'middle', 'lowerRight', 'bottom'], '4': ['upperLeft', 'upperRight', 'middle', 'lowerRight'], '5': ['top', 'upperLeft', 'middle', 'lowerRight', 'bottom'], '6': ['top', 'upperLeft', 'middle', 'lowerLeft', 'lowerRight', 'bottom'], '7': ['top', 'upperRight', 'lowerRight'], '8': ['top', 'upperLeft', 'upperRight', 'middle', 'lowerLeft', 'lowerRight', 'bottom'], '9': ['top', 'upperLeft', 'upperRight', 'middle', 'lowerRight', 'bottom'], '-': ['middle'], } const SEGMENT_PROPS: Record< SegmentName, { position: [number, number, number]; size: [number, number, number] } > = { bottom: { position: [0, -0.44, 0], size: [0.56, 0.11, 0.018] }, lowerLeft: { position: [-0.32, -0.22, 0], size: [0.11, 0.42, 0.018] }, lowerRight: { position: [0.32, -0.22, 0], size: [0.11, 0.42, 0.018] }, middle: { position: [0, 0, 0], size: [0.52, 0.1, 0.018] }, top: { position: [0, 0.44, 0], size: [0.56, 0.11, 0.018] }, upperLeft: { position: [-0.32, 0.22, 0], size: [0.11, 0.42, 0.018] }, upperRight: { position: [0.32, 0.22, 0], size: [0.11, 0.42, 0.018] }, } function BoxPrimitive({ castShadow = false, material, position, receiveShadow = false, rotation, scale, }: { castShadow?: boolean material: Material position?: Vector3Tuple receiveShadow?: boolean rotation?: Vector3Tuple scale: Vector3Tuple }) { return ( ) } function MeshButtonLabel({ faceSign = 1, label, material, position, scale, }: { faceSign?: -1 | 1 label: string material: Material position: [number, number, number] scale: number }) { const ref = useRef(null) const instances = useMemo(() => { const characters = label.split('').filter((character) => DIGIT_SEGMENTS[character]) const spacing = 0.72 * scale const startX = -((characters.length - 1) * spacing) / 2 return characters.flatMap((character, charIndex) => (DIGIT_SEGMENTS[character] ?? []).map((segment) => { const props = SEGMENT_PROPS[segment] return { position: [ faceSign * (startX + charIndex * spacing + props.position[0] * scale), props.position[1] * scale, props.position[2], ] as Vector3Tuple, scale: [props.size[0] * scale, props.size[1] * scale, props.size[2]] as Vector3Tuple, } }), ) }, [faceSign, label, scale]) const applyInstanceMatrices = useCallback( (mesh: InstancedMesh) => { for (let index = 0; index < instances.length; index += 1) { const instance = instances[index] if (!instance) continue LABEL_MATRIX_DUMMY.position.set(...instance.position) LABEL_MATRIX_DUMMY.rotation.set(0, 0, 0) LABEL_MATRIX_DUMMY.scale.set(...instance.scale) LABEL_MATRIX_DUMMY.updateMatrix() mesh.setMatrixAt(index, LABEL_MATRIX_DUMMY.matrix) } mesh.instanceMatrix.needsUpdate = true }, [instances], ) useLayoutEffect(() => { const mesh = ref.current if (!mesh) return applyInstanceMatrices(mesh) }, [applyInstanceMatrices]) if (instances.length === 0) return null return ( ) } function ElevatorDirectionGlyph({ direction, material, position, scale, }: { direction: 'down' | 'up' | null material: Material position: [number, number, number] scale: number }) { if (!direction) { return ( ) } const ySign = direction === 'up' ? -1 : 1 return ( ) } function ElevatorFloorIndicator({ active, direction, faceSign = -1, label, position, scale = 1, showReadout = true, }: { active: boolean direction: 'down' | 'up' | null faceSign?: -1 | 1 label: string position: [number, number, number] scale?: number showReadout?: boolean }) { const glyphMaterial = active ? INDICATOR_GLYPH_MATERIALS.active : INDICATOR_GLYPH_MATERIALS.idle const screenMaterial = active ? INDICATOR_SCREEN_MATERIALS.active : INDICATOR_SCREEN_MATERIALS.idle const displayLabel = label || '-' const screenZ = faceSign * 0.026 * scale const glyphZ = faceSign * 0.041 * scale return ( {showReadout ? ( <> ) : ( )} ) } function DoorOpenGlyph({ material, positionZ, scale, }: { material: Material positionZ: number scale: number }) { return ( ) } function ElevatorMeshButton({ action = 'request-level', active, buttonKind, disabled = false, elevatorId, faceSign = -1, glyph, label, levelId, position, queued, radius = 0.055, }: { action?: ElevatorButtonAction active: boolean buttonKind: 'cab' | 'landing' disabled?: boolean elevatorId: AnyNodeId faceSign?: -1 | 1 glyph?: 'door-open' label?: string levelId?: AnyNodeId position: [number, number, number] queued: boolean radius?: number }) { const state = disabled ? 'disabled' : active ? 'active' : queued ? 'queued' : 'idle' const depth = active ? 0.028 : 0.04 const faceZ = faceSign * (depth / 2 + 0.004) const labelMaterial = disabled ? BUTTON_LABEL_MATERIALS.disabled : active || queued ? BUTTON_LABEL_MATERIALS.lit : BUTTON_LABEL_MATERIALS.idle const userData = useMemo( () => ({ elevatorButton: { action, disabled, elevatorId, kind: buttonKind, levelId, }, }), [action, buttonKind, disabled, elevatorId, levelId], ) return ( {!disabled && (active || queued) && ( )} {label && ( )} {glyph === 'door-open' && ( )} ) } function getElevatorLevelContextNodes( elevator: ElevatorNode, nodes: ReturnType['nodes'], ): Record { const result: Record = {} const building = elevator.parentId ? nodes[elevator.parentId as AnyNodeId] : null if (building?.type !== 'building') return result as Record result[building.id] = building for (const childId of building.children) { const level = nodes[childId as AnyNodeId] if (level?.type !== 'level') continue result[level.id] = level for (const levelChildId of level.children) { const child = nodes[levelChildId as AnyNodeId] if (child?.type === 'ceiling' || child?.type === 'wall') { result[child.id] = child } } } return result as Record } function DoorLeaf({ animated, doorOpen, doorPanelStyle, doorStyle, height, side, width, y, z, }: { animated?: | { elevatorId: AnyNodeId kind: 'cab' } | { elevatorId: AnyNodeId kind: 'landing' levelId: AnyNodeId } doorOpen: number doorPanelStyle: ElevatorDoorPanelStyleValue doorStyle: ElevatorDoorStyleValue height: number side: ElevatorDoorSide width: number y: number z: number }) { const ref = useRef(null) const getLeafX = (openAmount: number) => getElevatorDoorLeafX(side, width, openAmount, doorStyle) const leafWidth = getElevatorDoorLeafWidth(width, doorStyle) const resolvedPanelStyle = getResolvedDoorPanelStyle(doorPanelStyle) const railHeight = Math.min(0.09, Math.max(0.055, height * 0.04)) const stileWidth = Math.min(0.07, Math.max(0.04, leafWidth * 0.18)) const glassWidth = Math.max(leafWidth - stileWidth * 2.2, 0.03) const glassHeight = Math.max(height - railHeight * 3, 0.2) const panelInsetWidth = Math.max(leafWidth - 0.12, 0.05) const panelInsetHeight = Math.max(height - 0.26, 0.2) const segmentCount = 4 const segmentSpacing = panelInsetHeight / segmentCount useFrame(() => { if (!(animated && ref.current)) return const runtime = useInteractive.getState().elevators[animated.elevatorId] const nextDoorOpen = animated.kind === 'cab' ? (runtime?.doorOpen ?? 0) : runtime?.currentLevelId === animated.levelId ? (runtime?.doorOpen ?? 0) : 0 ref.current.position.x = getLeafX(nextDoorOpen) }, 2.6) return ( {resolvedPanelStyle === 'glass-frame' ? ( <> ) : ( <> {resolvedPanelStyle === 'segmented-panel' ? Array.from({ length: segmentCount - 1 }).map((_, index) => ( )) : null} )} ) } function ElevatorDoorLeaves({ animated, doorOpen, doorPanelStyle, doorStyle, height, width, y, z, }: { animated?: | { elevatorId: AnyNodeId kind: 'cab' } | { elevatorId: AnyNodeId kind: 'landing' levelId: AnyNodeId } doorOpen: number doorPanelStyle: ElevatorDoorPanelStyleValue doorStyle: ElevatorDoorStyleValue height: number width: number y: number z: number }) { return ( <> {getElevatorDoorLeafSides(doorStyle).map((side) => ( ))} ) } function LandingDoorFrame({ doorHeight, doorWidth, levelTopY, levelY, shaftWidth, z, }: { doorHeight: number doorWidth: number levelTopY: number levelY: number shaftWidth: number z: number }) { const wallDepth = 0.09 const levelHeight = Math.max(levelTopY - levelY, 0.01) const jambWidth = Math.max((shaftWidth - doorWidth) / 2, 0.08) const jambCenterOffset = doorWidth / 2 + jambWidth / 2 const headerHeight = Math.max(levelTopY - (levelY + doorHeight), 0) const trim = 0.055 return ( <> {headerHeight > 0.01 && ( )} ) } function LandingDoor({ animated, doorPanelStyle, doorStyle, elevatorId, doorOpen, doorHeight, doorWidth, levelId, levelY, z, }: { animated: boolean doorPanelStyle: ElevatorDoorPanelStyleValue doorStyle: ElevatorDoorStyleValue elevatorId: AnyNodeId doorOpen: number doorHeight: number doorWidth: number levelId: AnyNodeId levelY: number z: number }) { return ( ) } export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { const ref = useRef(null!) const cabRef = useRef(null) const handlers = useNodeEvents(node, 'elevator') const shading = useViewer((state) => state.shading) const textures = useViewer((state) => state.textures) const colorPreset = useViewer((state) => state.colorPreset) const liveOverrides = useLiveNodeOverrides((state) => state.get(node.id)) const liveTransform = useLiveTransforms((state) => state.get(node.id)) const renderNode = useMemo( () => (liveOverrides ? ({ ...node, ...liveOverrides } as ElevatorNode) : node), [liveOverrides, node], ) const levelContextNodes = useScene( useShallow((state) => getElevatorLevelContextNodes(renderNode, state.nodes)), ) setElevatorMaterials(shading, textures, colorPreset) useRegistry(node.id, 'elevator', ref) const { entries, defaultEntry, shaftBaseY, totalHeight } = useMemo( () => resolveElevatorLevels(renderNode, levelContextNodes), [renderNode, levelContextNodes], ) const elevatorId = node.id as AnyNodeId const runtimeStatus = useInteractive( useShallow((state) => { const runtime = state.elevators[elevatorId] if (!runtime) return null return { currentLevelId: runtime.currentLevelId, phase: runtime.phase, queue: runtime.queue, targetLevelId: runtime.targetLevelId, } }), ) useFrame(() => { if (!cabRef.current) return const runtime = useInteractive.getState().elevators[elevatorId] if (!runtime) return cabRef.current.position.y = runtime.carY }, 2.6) const cabWidth = getElevatorCabWidth(renderNode) const cabDepth = getElevatorCabDepth(renderNode) const shaftWidth = getElevatorShaftWidth(renderNode, cabWidth) const shaftDepth = getElevatorShaftDepth(renderNode, cabDepth) const cabHeight = Math.max(renderNode.cabHeight, 1.4) const shaftWallThickness = getElevatorShaftWallThickness(renderNode) const doorWidth = Math.min( Math.max(renderNode.doorWidth, 0.45), cabWidth - 0.18, shaftWidth - 0.18, ) const doorHeight = Math.min(Math.max(renderNode.doorHeight, 1.2), cabHeight - 0.1) const doorPanelStyle = getResolvedDoorPanelStyle(renderNode.doorPanelStyle) const doorStyle = getResolvedDoorStyle(renderNode.doorStyle) const shaftStyle = getResolvedShaftStyle(renderNode.shaftStyle) const shaftShellMaterial = shaftStyle === 'glass' ? GLASS_MATERIAL : SHAFT_SIDE_MATERIAL const shaftTopMaterial = shaftStyle === 'glass' ? SHAFT_TRIM_MATERIAL : SHAFT_SIDE_MATERIAL const shaftHeight = Math.max(totalHeight, cabHeight + 0.3) const shaftBodyHeight = Math.max(shaftHeight - shaftWallThickness, 0.01) const shaftBodyCenterY = shaftBaseY + shaftBodyHeight / 2 const shaftTopCapBottomY = shaftBaseY + shaftHeight - shaftWallThickness const shaftFrameTopY = Math.max(shaftBaseY, shaftTopCapBottomY - SHAFT_TOP_FRAME_CLEARANCE) const runtimeSnapshot = useInteractive.getState().elevators[elevatorId] const cabBaseY = runtimeSnapshot?.carY ?? defaultEntry?.baseY ?? 0 const activeLevelId = runtimeStatus?.currentLevelId ?? runtimeSnapshot?.currentLevelId ?? defaultEntry?.id ?? null const pendingLevelId = runtimeStatus?.targetLevelId ?? runtimeSnapshot?.targetLevelId ?? runtimeStatus?.queue[0] ?? runtimeSnapshot?.queue[0] ?? null const currentEntry = entries.find((entry) => entry.id === activeLevelId) ?? defaultEntry ?? entries[0] ?? null const pendingEntry = pendingLevelId ? entries.find((entry) => entry.id === pendingLevelId) : null const indicatorEntry = pendingEntry ?? currentEntry const indicatorDirection = currentEntry && pendingEntry && Math.abs(pendingEntry.baseY - currentEntry.baseY) > 0.001 ? pendingEntry.baseY > currentEntry.baseY ? 'up' : 'down' : null const indicatorActive = Boolean( pendingEntry || runtimeStatus?.phase === 'moving' || runtimeSnapshot?.phase === 'moving' || runtimeStatus?.phase === 'opening' || runtimeSnapshot?.phase === 'opening', ) const queuedLevelIds = useMemo(() => { const next = new Set() for (const levelId of runtimeStatus?.queue ?? runtimeSnapshot?.queue ?? []) next.add(levelId) const targetLevelId = runtimeStatus?.targetLevelId ?? runtimeSnapshot?.targetLevelId if (targetLevelId) next.add(targetLevelId) return next }, [ runtimeSnapshot?.queue, runtimeSnapshot?.targetLevelId, runtimeStatus?.queue, runtimeStatus?.targetLevelId, ]) const disabledLevelIds = useMemo( () => new Set(renderNode.disabledLevelIds ?? []), [renderNode.disabledLevelIds], ) const serviceOnlyLevelIds = useMemo( () => new Set(renderNode.serviceOnlyLevelIds ?? []), [renderNode.serviceOnlyLevelIds], ) const doorOpen = runtimeSnapshot?.doorOpen ?? 0 const doorOpenButtonActive = doorOpen > 0.12 || runtimeStatus?.phase === 'opening' || runtimeSnapshot?.phase === 'opening' || runtimeStatus?.phase === 'open' || runtimeSnapshot?.phase === 'open' const frontWallZ = -shaftDepth / 2 - shaftWallThickness / 2 const frontZ = frontWallZ - shaftWallThickness / 2 - 0.018 const landingPanelX = Math.min(shaftWidth / 2 - 0.16, doorWidth / 2 + 0.18) const cabCenterZ = -shaftDepth / 2 + cabDepth / 2 const cabPanelX = cabWidth / 2 - 0.075 const cabPanelZ = cabCenterZ - cabDepth / 2 + 0.36 const cabButtonColumns = entries.length > 1 ? 2 : 1 const cabButtonRows = Math.max(1, Math.ceil(entries.length / cabButtonColumns)) const cabButtonSpacingX = 0.14 const cabButtonSpacingY = 0.15 const cabDoorButtonOffsetX = 0.17 const cabFloorButtonOffsetX = entries.length > 0 ? -cabDoorButtonOffsetX / 2 : 0 const cabDoorButtonX = cabFloorButtonOffsetX + ((cabButtonColumns - 1) / 2) * cabButtonSpacingX + cabDoorButtonOffsetX const cabDoorButtonY = -((cabButtonRows - 1) / 2) * cabButtonSpacingY const cabPanelWidth = cabButtonColumns * cabButtonSpacingX + 0.13 + cabDoorButtonOffsetX const cabPanelHeight = cabButtonRows * cabButtonSpacingY + 0.12 const panelRelativeY = Math.min(Math.max(doorHeight * 0.6, 0.95), cabHeight - 0.35) const cabPanelY = panelRelativeY const entrySpans = useMemo( () => entries.map((entry, index) => { const nextEntry = entries[index + 1] const minDoorFrameTopY = entry.baseY + doorHeight + 0.12 const targetTopY = Math.max(nextEntry?.baseY ?? shaftFrameTopY, minDoorFrameTopY) return { entry, levelTopY: nextEntry ? targetTopY : Math.min(targetTopY, shaftFrameTopY), } }), [doorHeight, entries, shaftFrameTopY], ) return ( {entries.map((entry, index) => { const column = index % cabButtonColumns const row = Math.floor(index / cabButtonColumns) const isDisabledLevel = disabledLevelIds.has(entry.id) const x = cabFloorButtonOffsetX + (column - (cabButtonColumns - 1) / 2) * cabButtonSpacingX const y = (row - (cabButtonRows - 1) / 2) * cabButtonSpacingY return ( ) })} {entrySpans.map(({ entry, levelTopY }) => { const isCurrentLevel = activeLevelId === entry.id const isDisabledLevel = disabledLevelIds.has(entry.id) const isServiceOnlyLevel = serviceOnlyLevelIds.has(entry.id) const isQueuedLevel = !isDisabledLevel && queuedLevelIds.has(entry.id) const isPendingLevel = pendingLevelId === entry.id const showLandingReadout = isCurrentLevel || isPendingLevel || isQueuedLevel return ( 0.5} buttonKind="landing" disabled={isDisabledLevel || isServiceOnlyLevel} elevatorId={elevatorId} levelId={entry.id as AnyNodeId} position={[0, 0.06, -0.045]} queued={isQueuedLevel} radius={0.045} /> ) })} ) } export default ElevatorRenderer