diff --git a/packages/core/src/store/use-interactive.ts b/packages/core/src/store/use-interactive.ts index 76455a49..84354557 100644 --- a/packages/core/src/store/use-interactive.ts +++ b/packages/core/src/store/use-interactive.ts @@ -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) => void @@ -274,6 +277,24 @@ export const useInteractive = create((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] diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index ae4a0a1e..3b0bb1e5 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -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 } diff --git a/packages/editor/src/components/tools/elevator/elevator-tool.tsx b/packages/editor/src/components/tools/elevator/elevator-tool.tsx index 9d10a82b..d8e87636 100644 --- a/packages/editor/src/components/tools/elevator/elevator-tool.tsx +++ b/packages/editor/src/components/tools/elevator/elevator-tool.tsx @@ -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) => { diff --git a/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx b/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx index 43f8a31e..046bf43a 100644 --- a/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx +++ b/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx @@ -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, }) diff --git a/packages/editor/src/components/ui/panels/elevator-panel.tsx b/packages/editor/src/components/ui/panels/elevator-panel.tsx index f5e4fa9d..da7f5dd4 100644 --- a/packages/editor/src/components/ui/panels/elevator-panel.tsx +++ b/packages/editor/src/components/ui/panels/elevator-panel.tsx @@ -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( (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) }, @@ -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() - for (const levelId of runtime?.queue ?? []) queuedLevelIds.add(levelId) - if (runtime?.targetLevelId) queuedLevelIds.add(runtime.targetLevelId) + const destinationOrderByLevelId = new Map() + 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 ( + + { + 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} + /> +
+ { + sfxEmitter.emit('sfx:item-rotate') + commitTransform(displayPosition, displayRotation - Math.PI / 4) + }} + /> + { + sfxEmitter.emit('sfx:item-rotate') + commitTransform(displayPosition, displayRotation + Math.PI / 4) + }} + /> +
+
+ {servedLevels.map((level) => { const isActive = activeLevelId === level.id - const isQueued = queuedLevelIds.has(level.id) + const stopOrder = destinationOrderByLevelId.get(level.id) return ( ) diff --git a/packages/editor/src/lib/elevator-support.ts b/packages/editor/src/lib/elevator-support.ts new file mode 100644 index 00000000..fc874562 --- /dev/null +++ b/packages/editor/src/lib/elevator-support.ts @@ -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, +): 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], + }) +} diff --git a/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx b/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx index 0b5dc355..136159f9 100644 --- a/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx +++ b/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx @@ -3,12 +3,21 @@ import { type ElevatorNode, useInteractive, useLiveNodeOverrides, + useLiveTransforms, useRegistry, useScene, } from '@pascal-app/core' -import { useFrame, type ThreeEvent } from '@react-three/fiber' -import { useEffect, useMemo, useRef } from 'react' -import type { Group } from 'three' +import { type ThreeEvent, useFrame } from '@react-three/fiber' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' +import { + BoxGeometry, + CylinderGeometry, + type Group, + type InstancedMesh, + MeshStandardMaterial, + Object3D, + TorusGeometry, +} from 'three' import { useShallow } from 'zustand/react/shallow' import { useNodeEvents } from '../../../hooks/use-node-events' import { resolveElevatorLevels } from '../../../systems/elevator/elevator-utils' @@ -21,6 +30,177 @@ 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 + +const SHAFT_WALL_MATERIAL = new MeshStandardMaterial({ + color: SHAFT_WALL_COLOR, + metalness: 0.08, + roughness: 0.56, +}) +const SHAFT_SIDE_MATERIAL = new MeshStandardMaterial({ + color: SHAFT_SIDE_COLOR, + metalness: 0.12, + roughness: 0.58, +}) +const SHAFT_TRIM_MATERIAL = new MeshStandardMaterial({ + color: SHAFT_TRIM_COLOR, + metalness: 0.2, + roughness: 0.38, +}) +const CAB_MATERIAL = new MeshStandardMaterial({ + color: CAB_COLOR, + metalness: 0.2, + roughness: 0.48, +}) +const DOOR_MATERIAL = new MeshStandardMaterial({ + color: DOOR_COLOR, + metalness: 0.34, + roughness: 0.34, +}) +const GLASS_MATERIAL = new MeshStandardMaterial({ + color: GLASS_COLOR, + depthWrite: false, + metalness: 0, + opacity: 0.2, + roughness: 0.08, + transparent: true, +}) +const PANEL_MATERIAL = new MeshStandardMaterial({ + color: PANEL_COLOR, + metalness: 0.32, + roughness: 0.36, +}) +const LANDING_PANEL_MATERIAL = new MeshStandardMaterial({ + color: PANEL_COLOR, + metalness: 0.25, + roughness: 0.4, +}) +const INDICATOR_SCREEN_MATERIALS = { + active: new MeshStandardMaterial({ + color: '#041f2f', + emissive: '#0ea5e9', + emissiveIntensity: 0.16, + metalness: 0.12, + roughness: 0.38, + }), + idle: new MeshStandardMaterial({ + color: '#111827', + metalness: 0.12, + roughness: 0.38, + }), +} +const INDICATOR_GLYPH_MATERIALS = { + active: new MeshStandardMaterial({ + color: '#38bdf8', + emissive: '#38bdf8', + emissiveIntensity: 0.36, + metalness: 0.08, + roughness: 0.32, + }), + idle: new MeshStandardMaterial({ + color: '#94a3b8', + emissive: '#94a3b8', + emissiveIntensity: 0.18, + metalness: 0.08, + roughness: 0.32, + }), +} +const BUTTON_FACE_MATERIALS = { + active: new MeshStandardMaterial({ + color: '#38bdf8', + emissive: '#38bdf8', + emissiveIntensity: 0.28, + metalness: 0.22, + roughness: 0.3, + }), + queued: new MeshStandardMaterial({ + color: '#fbbf24', + emissive: '#fbbf24', + emissiveIntensity: 0.18, + metalness: 0.22, + roughness: 0.3, + }), + idle: new MeshStandardMaterial({ + color: '#d6dde7', + metalness: 0.22, + roughness: 0.3, + }), +} +const BUTTON_RING_MATERIALS = { + active: new MeshStandardMaterial({ + color: '#0ea5e9', + emissive: '#0ea5e9', + emissiveIntensity: 0.16, + metalness: 0.48, + roughness: 0.28, + }), + queued: new MeshStandardMaterial({ + color: '#f59e0b', + emissive: '#f59e0b', + emissiveIntensity: 0.1, + metalness: 0.48, + roughness: 0.28, + }), + idle: new MeshStandardMaterial({ + color: '#64748b', + metalness: 0.48, + roughness: 0.28, + }), +} +const BUTTON_GLOW_MATERIALS = { + active: new MeshStandardMaterial({ + color: '#38bdf8', + depthWrite: false, + emissive: '#38bdf8', + emissiveIntensity: 0.28, + opacity: 0.58, + transparent: true, + }), + queued: new MeshStandardMaterial({ + color: '#fbbf24', + depthWrite: false, + emissive: '#fbbf24', + emissiveIntensity: 0.18, + opacity: 0.58, + transparent: true, + }), +} +const BUTTON_LABEL_MATERIALS = { + lit: new MeshStandardMaterial({ + color: '#111827', + metalness: 0.12, + roughness: 0.34, + }), + idle: new MeshStandardMaterial({ + color: '#334155', + metalness: 0.12, + roughness: 0.34, + }), +} +const QUEUE_STRIP_MATERIALS = { + queued: new MeshStandardMaterial({ + color: '#fbbf24', + emissive: '#fbbf24', + emissiveIntensity: 0.16, + metalness: 0.18, + roughness: 0.42, + }), + idle: new MeshStandardMaterial({ + color: '#64748b', + metalness: 0.18, + roughness: 0.42, + }), +} + +type ElevatorButtonAction = 'open-door' | 'request-level' + type SegmentName = | 'bottom' | 'lowerLeft' @@ -57,97 +237,138 @@ const SEGMENT_PROPS: Record< 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: MeshStandardMaterial + position?: Vector3Tuple + receiveShadow?: boolean + rotation?: Vector3Tuple + scale: Vector3Tuple +}) { + return ( + + ) +} + function MeshButtonLabel({ - color, label, + material, position, scale, }: { - color: string label: string + material: MeshStandardMaterial position: [number, number, number] scale: number }) { - const characters = label.split('').filter((character) => DIGIT_SEGMENTS[character]) - const spacing = 0.72 * scale - const startX = -((characters.length - 1) * spacing) / 2 + 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 - if (characters.length === 0) return null + return characters.flatMap((character, charIndex) => + (DIGIT_SEGMENTS[character] ?? []).map((segment) => { + const props = SEGMENT_PROPS[segment] + return { + position: [ + 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, + } + }), + ) + }, [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 ( - - {characters.map((character, charIndex) => ( - - {(DIGIT_SEGMENTS[character] ?? []).map((segment) => { - const props = SEGMENT_PROPS[segment] - return ( - - - - - ) - })} - - ))} - + ) } function ElevatorDirectionGlyph({ - color, direction, + material, position, scale, }: { - color: string direction: 'down' | 'up' | null + material: MeshStandardMaterial position: [number, number, number] scale: number }) { if (!direction) { return ( - - - - + ) } const ySign = direction === 'up' ? 1 : -1 return ( - - - - - - - - + rotation={[0, 0, (-ySign * Math.PI) / 4]} + scale={[0.16 * scale, 0.035 * scale, 0.018]} + /> + ) } @@ -159,6 +380,7 @@ function ElevatorFloorIndicator({ label, position, scale = 1, + showReadout = true, }: { active: boolean direction: 'down' | 'up' | null @@ -166,132 +388,196 @@ function ElevatorFloorIndicator({ label: string position: [number, number, number] scale?: number + showReadout?: boolean }) { - const glowColor = active ? '#38bdf8' : '#94a3b8' - const screenColor = active ? '#041f2f' : '#111827' + 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: MeshStandardMaterial + positionZ: number + scale: number +}) { + return ( + + + + + + + ) } function ElevatorMeshButton({ + action = 'request-level', active, buttonKind, elevatorId, faceSign = -1, + glyph, label, levelId, - onRequest, position, queued, radius = 0.055, }: { + action?: ElevatorButtonAction active: boolean buttonKind: 'cab' | 'landing' elevatorId: AnyNodeId faceSign?: -1 | 1 + glyph?: 'door-open' label?: string - levelId: AnyNodeId - onRequest: () => void + levelId?: AnyNodeId position: [number, number, number] queued: boolean radius?: number }) { - const buttonColor = active ? '#38bdf8' : queued ? '#fbbf24' : '#d6dde7' - const labelColor = active || queued ? '#111827' : '#334155' - const ringColor = active ? '#0ea5e9' : queued ? '#f59e0b' : '#64748b' + const state = active ? 'active' : queued ? 'queued' : 'idle' const depth = active ? 0.028 : 0.04 const faceZ = faceSign * (depth / 2 + 0.004) + const labelMaterial = active || queued ? BUTTON_LABEL_MATERIALS.lit : BUTTON_LABEL_MATERIALS.idle const userData = useMemo( () => ({ elevatorButton: { + action, elevatorId, kind: buttonKind, levelId, }, }), - [buttonKind, elevatorId, levelId], + [action, buttonKind, elevatorId, levelId], ) const press = (event: ThreeEvent) => { if (event.button !== 0) return - onRequest() + if (action === 'open-door') { + useInteractive.getState().openElevatorDoor(elevatorId) + return + } + if (levelId) useInteractive.getState().requestElevator(elevatorId, levelId) } return ( {(active || queued) && ( - - - - + )} - - - - - - - - + + {label && ( )} + {glyph === 'door-open' && ( + + )} ) } @@ -345,33 +631,39 @@ function DoorLeaf({ return ( - - - - - - - - - - - - - - - - - - - - + + + + + ) } @@ -392,50 +684,65 @@ function LandingDoorFrame({ z: number }) { const wallDepth = 0.09 - const levelHeight = Math.max(levelTopY - levelY, doorHeight + 0.24) + 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(levelHeight - doorHeight, 0.14) + const headerHeight = Math.max(levelTopY - (levelY + doorHeight), 0) const trim = 0.055 return ( <> - - - - - - - - - - - - - - - - - + + {headerHeight > 0.01 && ( + + )} + + - - - - + - - - - - - - + scale={[trim, doorHeight, wallDepth * 1.12]} + /> + ) } @@ -489,6 +796,7 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { const nodes = useScene((state) => state.nodes) const handlers = useNodeEvents(node, 'elevator') 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], @@ -496,7 +804,7 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { useRegistry(node.id, 'elevator', ref) - const { entries, defaultEntry, shaftBaseY, shaftTopY, totalHeight } = useMemo( + const { entries, defaultEntry, shaftBaseY, totalHeight } = useMemo( () => resolveElevatorLevels(renderNode, nodes), [renderNode, nodes], ) @@ -554,8 +862,11 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { const doorWidth = Math.min(Math.max(renderNode.doorWidth, 0.45), shaftWidth - 0.18) const doorHeight = Math.min(Math.max(renderNode.doorHeight, 1.2), cabHeight - 0.1) const shaftHeight = Math.max(totalHeight, cabHeight + 0.3) - const resolvedShaftTopY = Math.max(shaftTopY, shaftBaseY + shaftHeight) const shaftWallThickness = 0.09 + 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 = @@ -583,13 +894,25 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { runtimeStatus?.phase === 'opening' || runtimeSnapshot?.phase === 'opening', ) - const queuedLevelIds = new Set() - for (const levelId of runtimeStatus?.queue ?? runtimeSnapshot?.queue ?? []) - queuedLevelIds.add(levelId) - if (runtimeStatus?.targetLevelId ?? runtimeSnapshot?.targetLevelId) { - queuedLevelIds.add((runtimeStatus?.targetLevelId ?? runtimeSnapshot?.targetLevelId)!) - } + 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 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) @@ -599,99 +922,111 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { const cabButtonRows = Math.max(1, Math.ceil(entries.length / cabButtonColumns)) const cabButtonSpacingX = 0.14 const cabButtonSpacingY = 0.15 - const cabPanelWidth = cabButtonColumns * cabButtonSpacingX + 0.13 + 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 = entries.map((entry, index) => { - const nextEntry = entries[index + 1] - return { - entry, - levelTopY: Math.max(nextEntry?.baseY ?? resolvedShaftTopY, entry.baseY + doorHeight + 0.24), - } - }) - const requestLevel = (levelId: AnyNodeId) => { - useInteractive.getState().requestElevator(elevatorId, levelId) - } + 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 ( - - - - - + - - - - + - - - - + - - - + scale={[ + shaftWidth + shaftWallThickness * 2, + shaftWallThickness, + shaftDepth + shaftWallThickness * 2, + ]} + /> - - - - + - - - - + - - - - + - - - - + - - - - + { /> - - - - + {entries.map((entry, index) => { const column = index % cabButtonColumns const row = Math.floor(index / cabButtonColumns) - const x = (column - (cabButtonColumns - 1) / 2) * cabButtonSpacingX - const y = ((cabButtonRows - 1) / 2 - row) * cabButtonSpacingY + const x = + cabFloorButtonOffsetX + (column - (cabButtonColumns - 1) / 2) * cabButtonSpacingX + const y = (row - (cabButtonRows - 1) / 2) * cabButtonSpacingY return ( { key={entry.id} label={entry.label} levelId={entry.id as AnyNodeId} - onRequest={() => requestLevel(entry.id as AnyNodeId)} position={[x, y, 0.045]} queued={queuedLevelIds.has(entry.id)} /> ) })} + - {entrySpans.map(({ entry, levelTopY }) => ( - - - - - - - - - - 0.5} - buttonKind="landing" - elevatorId={elevatorId} - levelId={entry.id as AnyNodeId} - onRequest={() => requestLevel(entry.id as AnyNodeId)} - position={[0, 0.06, -0.045]} - queued={queuedLevelIds.has(entry.id)} - radius={0.045} + {entrySpans.map(({ entry, levelTopY }) => { + const isCurrentLevel = activeLevelId === entry.id + const isQueuedLevel = queuedLevelIds.has(entry.id) + const isPendingLevel = pendingLevelId === entry.id + const showLandingReadout = isCurrentLevel || isPendingLevel || isQueuedLevel + + return ( + + - - - + + + - + 0.5} + buttonKind="landing" + elevatorId={elevatorId} + levelId={entry.id as AnyNodeId} + position={[0, 0.06, -0.045]} + queued={isQueuedLevel} + radius={0.045} + /> + + - - ))} + ) + })} ) }