diff --git a/packages/editor/src/components/ui/panels/elevator-panel.tsx b/packages/editor/src/components/ui/panels/elevator-panel.tsx
new file mode 100644
index 000000000..02bbc4aa1
--- /dev/null
+++ b/packages/editor/src/components/ui/panels/elevator-panel.tsx
@@ -0,0 +1,934 @@
+'use client'
+
+import {
+ type AnyNode,
+ type AnyNodeId,
+ type ElevatorNode,
+ ElevatorNode as ElevatorNodeSchema,
+ type LevelNode,
+ requestElevatorLevel,
+ 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'
+import { MetricControl } from '../controls/metric-control'
+import { PanelSection } from '../controls/panel-section'
+import { SliderControl } from '../controls/slider-control'
+import { PanelWrapper } from './panel-wrapper'
+
+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 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,
+ 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,
+ ])
+
+ 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
+ sfxEmitter.emit('sfx:item-pick')
+ clearLivePreview()
+ setMovingNode(node)
+ setSelection({ selectedIds: [] })
+ }, [clearLivePreview, node, setMovingNode, setSelection])
+
+ const handleDuplicate = useCallback(() => {
+ if (!(node && node.parentId)) return
+ sfxEmitter.emit('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
+ sfxEmitter.emit('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()
+ 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 (
+
+
+
+ } 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}
+ />
+
+
{
+ sfxEmitter.emit('sfx:item-rotate')
+ commitTransform(displayPosition, displayRotation - Math.PI / 4)
+ }}
+ />
+ {
+ sfxEmitter.emit('sfx:item-rotate')
+ commitTransform(displayPosition, displayRotation + Math.PI / 4)
+ }}
+ />
+
+
+
+
+ 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}
+ />
+
+
+
+
+
+
+ From
+
+
+
+
+
+
+ To
+
+
+
+
+
+
+
+ Default Floor
+
+
+
+
+
+
+
+ {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}
+ />
+
+
+ )
+}
diff --git a/packages/editor/src/components/ui/panels/node-display.ts b/packages/editor/src/components/ui/panels/node-display.ts
index 3b5456801..16ee20d9b 100644
--- a/packages/editor/src/components/ui/panels/node-display.ts
+++ b/packages/editor/src/components/ui/panels/node-display.ts
@@ -13,6 +13,7 @@ const TYPE_DEFAULTS: Record = {
slab: { icon: '/icons/floor.png', label: 'Slab' },
ceiling: { icon: '/icons/ceiling.png', label: 'Ceiling' },
column: { icon: '/icons/column.png', label: 'Column' },
+ elevator: { icon: '/icons/elevator.svg', label: 'Elevator' },
fence: { icon: '/icons/fence.png', label: 'Fence' },
roof: { icon: '/icons/roof.png', label: 'Roof' },
'roof-segment': { icon: '/icons/roof.png', label: 'Roof segment' },
diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx
index 76b63371a..7836b2e31 100644
--- a/packages/editor/src/components/ui/panels/panel-manager.tsx
+++ b/packages/editor/src/components/ui/panels/panel-manager.tsx
@@ -7,6 +7,7 @@ import {
type CeilingNode,
type ColumnNode,
type DoorNode,
+ type ElevatorNode,
type FenceNode,
type ItemNode,
type RoofNode,
@@ -26,6 +27,7 @@ import useEditor from '../../../store/use-editor'
import { CeilingPanel } from './ceiling-panel'
import { ColumnPanel } from './column-panel'
import { DoorPanel } from './door-panel'
+import { ElevatorPanel } from './elevator-panel'
import { FencePanel } from './fence-panel'
import { ItemPanel } from './item-panel'
import { MobilePanelSheet } from './mobile-panel-sheet'
@@ -46,6 +48,7 @@ type MovableNode =
| ItemNode
| WindowNode
| DoorNode
+ | ElevatorNode
| CeilingNode
| ColumnNode
| SlabNode
@@ -61,6 +64,7 @@ const MOVABLE_TYPES = new Set([
'item',
'window',
'door',
+ 'elevator',
'ceiling',
'column',
'slab',
@@ -104,6 +108,8 @@ function panelForType(type: string | null) {
return
case 'door':
return
+ case 'elevator':
+ return
case 'window':
return
default:
@@ -239,6 +245,5 @@ export function PanelManager() {
return
}
- // Show appropriate panel based on selected node type
return panelForType(selectedNodeType)
}
diff --git a/packages/editor/src/components/ui/panels/slab-panel.tsx b/packages/editor/src/components/ui/panels/slab-panel.tsx
index b44b1a64a..0034c7ca8 100644
--- a/packages/editor/src/components/ui/panels/slab-panel.tsx
+++ b/packages/editor/src/components/ui/panels/slab-panel.tsx
@@ -91,7 +91,7 @@ export function SlabPanel() {
(index: number) => {
if (!selectedId) return
const currentHoles = node?.holes || []
- if (node?.holeMetadata?.[index]?.source === 'stair') return
+ if ((node?.holeMetadata?.[index]?.source ?? 'manual') !== 'manual') return
const newHoles = currentHoles.filter((_, i) => i !== index)
const currentMetadata = currentHoles.map(
(_, metadataIndex) => node?.holeMetadata?.[metadataIndex] ?? { source: 'manual' as const },
@@ -173,7 +173,8 @@ export function SlabPanel() {
const isEditing =
editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
const source = node.holeMetadata?.[index]?.source ?? 'manual'
- const isAutoHole = source === 'stair'
+ const isAutoHole = source !== 'manual'
+ const autoLabel = source === 'elevator' ? 'Auto elevator cutout' : 'Auto stair cutout'
return (
{holeArea.toFixed(2)} m² · {hole.length} pts ·{' '}
- {isAutoHole ? 'Auto stair cutout' : 'Manual'}
+ {isAutoHole ? autoLabel : 'Manual'}
diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx
index fdbae4e90..5407b9299 100644
--- a/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx
+++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx
@@ -39,8 +39,10 @@ export const BuildingTreeNode = memo(function BuildingTreeNode({
const handleAddLevel = (e: React.MouseEvent) => {
e.stopPropagation()
+ const nodes = useScene.getState().nodes
+ const levelCount = children.filter((childId) => nodes[childId]?.type === 'level').length
const newLevel = LevelNode.parse({
- level: children.length,
+ level: levelCount,
children: [],
parentId: nodeId,
})
diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx
new file mode 100644
index 000000000..0f3451717
--- /dev/null
+++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx
@@ -0,0 +1,75 @@
+import { type AnyNodeId, type ElevatorNode, useScene } from '@pascal-app/core'
+import { useViewer } from '@pascal-app/viewer'
+import Image from 'next/image'
+import { memo, useCallback, useState } from 'react'
+import useEditor from './../../../../../store/use-editor'
+import { InlineRenameInput } from './inline-rename-input'
+import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
+import { TreeNodeActions } from './tree-node-actions'
+
+interface ElevatorTreeNodeProps {
+ nodeId: AnyNodeId
+ depth: number
+ isLast?: boolean
+}
+
+export const ElevatorTreeNode = memo(function ElevatorTreeNode({
+ nodeId,
+ depth,
+ isLast,
+}: ElevatorTreeNodeProps) {
+ const [isEditing, setIsEditing] = useState(false)
+ const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
+ const node = useScene((s) => s.nodes[nodeId] as ElevatorNode | undefined)
+ const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
+ const isHovered = useViewer((state) => state.hoveredId === nodeId)
+ const setSelection = useViewer((state) => state.setSelection)
+ const setHoveredId = useViewer((state) => state.setHoveredId)
+
+ const handleClick = useCallback(
+ (event: React.MouseEvent) => {
+ event.stopPropagation()
+ const handled = handleTreeSelection(
+ event,
+ nodeId,
+ useViewer.getState().selection.selectedIds,
+ setSelection,
+ )
+ if (!handled && useEditor.getState().phase === 'furnish') {
+ useEditor.getState().setPhase('structure')
+ }
+ },
+ [nodeId, setSelection],
+ )
+
+ return (
+
}
+ depth={depth}
+ expanded={false}
+ hasChildren={false}
+ icon={
+
+ }
+ isHovered={isHovered}
+ isLast={isLast}
+ isSelected={isSelected}
+ isVisible={isVisible}
+ label={
+
setIsEditing(true)}
+ onStopEditing={() => setIsEditing(false)}
+ />
+ }
+ nodeId={nodeId}
+ onClick={handleClick}
+ onDoubleClick={() => focusTreeNode(nodeId)}
+ onMouseEnter={() => setHoveredId(nodeId)}
+ onMouseLeave={() => setHoveredId(null)}
+ onToggle={() => {}}
+ />
+ )
+})
diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx
index 95e6ba397..2a3107492 100644
--- a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx
+++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx
@@ -58,6 +58,7 @@ import { BuildingTreeNode } from './building-tree-node'
import { CeilingTreeNode } from './ceiling-tree-node'
import { ColumnTreeNode } from './column-tree-node'
import { DoorTreeNode } from './door-tree-node'
+import { ElevatorTreeNode } from './elevator-tree-node'
import { FenceTreeNode } from './fence-tree-node'
import { ItemTreeNode } from './item-tree-node'
import { LevelTreeNode } from './level-tree-node'
@@ -89,6 +90,8 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr
return
case 'column':
return
+ case 'elevator':
+ return
case 'level':
return
case 'slab':
diff --git a/packages/editor/src/hooks/use-contextual-tools.ts b/packages/editor/src/hooks/use-contextual-tools.ts
index ebb288a61..e51121b19 100644
--- a/packages/editor/src/hooks/use-contextual-tools.ts
+++ b/packages/editor/src/hooks/use-contextual-tools.ts
@@ -28,6 +28,7 @@ export function useContextualTools() {
'slab',
'ceiling',
'roof',
+ 'elevator',
'door',
'window',
]
diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts
index 734dfc622..e7f9146f8 100644
--- a/packages/editor/src/hooks/use-keyboard.ts
+++ b/packages/editor/src/hooks/use-keyboard.ts
@@ -134,13 +134,19 @@ export const useKeyboard = ({
const { buildingId, levelId } = useViewer.getState().selection
if (buildingId) {
const building = useScene.getState().nodes[buildingId]
- if (building && building.type === 'building' && building.children.length > 0) {
- const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1
- const nextIdx = currentIdx < building.children.length - 1 ? currentIdx + 1 : currentIdx
+ const levels =
+ building?.type === 'building'
+ ? building.children.filter(
+ (childId) => useScene.getState().nodes[childId as AnyNodeId]?.type === 'level',
+ )
+ : []
+ if (levels.length > 0) {
+ const currentIdx = levelId ? levels.indexOf(levelId as any) : -1
+ const nextIdx = currentIdx < levels.length - 1 ? currentIdx + 1 : currentIdx
if (nextIdx !== -1 && nextIdx !== currentIdx) {
- useViewer.getState().setSelection({ levelId: building.children[nextIdx] as any })
+ useViewer.getState().setSelection({ levelId: levels[nextIdx] as any })
} else if (currentIdx === -1) {
- useViewer.getState().setSelection({ levelId: building.children[0] as any })
+ useViewer.getState().setSelection({ levelId: levels[0] as any })
}
}
}
@@ -149,15 +155,19 @@ export const useKeyboard = ({
const { buildingId, levelId } = useViewer.getState().selection
if (buildingId) {
const building = useScene.getState().nodes[buildingId]
- if (building && building.type === 'building' && building.children.length > 0) {
- const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1
+ const levels =
+ building?.type === 'building'
+ ? building.children.filter(
+ (childId) => useScene.getState().nodes[childId as AnyNodeId]?.type === 'level',
+ )
+ : []
+ if (levels.length > 0) {
+ const currentIdx = levelId ? levels.indexOf(levelId as any) : -1
const prevIdx = currentIdx > 0 ? currentIdx - 1 : currentIdx
if (prevIdx !== -1 && prevIdx !== currentIdx) {
- useViewer.getState().setSelection({ levelId: building.children[prevIdx] as any })
+ useViewer.getState().setSelection({ levelId: levels[prevIdx] as any })
} else if (currentIdx === -1) {
- useViewer
- .getState()
- .setSelection({ levelId: building.children[building.children.length - 1] as any })
+ useViewer.getState().setSelection({ levelId: levels[levels.length - 1] as any })
}
}
}
diff --git a/packages/editor/src/lib/elevator-support.ts b/packages/editor/src/lib/elevator-support.ts
new file mode 100644
index 000000000..711f2594b
--- /dev/null
+++ b/packages/editor/src/lib/elevator-support.ts
@@ -0,0 +1,96 @@
+import {
+ type AnyNode,
+ type AnyNodeId,
+ type BuildingNode,
+ 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 resolveCurrentBuildingId({
+ buildingId,
+ levelId,
+ nodes,
+}: {
+ buildingId: BuildingNode['id'] | null
+ levelId: LevelNode['id'] | null
+ nodes: Record
+}): BuildingNode['id'] | null {
+ if (buildingId) return buildingId
+ if (!levelId) return null
+
+ const level = nodes[levelId as AnyNodeId]
+ if (
+ level?.type === 'level' &&
+ level.parentId &&
+ nodes[level.parentId as AnyNodeId]?.type === 'building'
+ ) {
+ return level.parentId as BuildingNode['id']
+ }
+
+ return null
+}
+
+export function resolveElevatorSupportLevelId({
+ buildingId,
+ preferredLevelId,
+}: {
+ buildingId: string | null | undefined
+ preferredLevelId?: string | null
+}): LevelNode['id'] | null {
+ const nodes = useScene.getState().nodes
+ const preferred = preferredLevelId ? nodes[preferredLevelId as AnyNodeId] : undefined
+ const levels = getBuildingLevels(buildingId, nodes)
+ const preferredInBuilding = preferredLevelId
+ ? levels.find((level) => level.id === preferredLevelId)
+ : undefined
+
+ if (preferredInBuilding) return preferredInBuilding.id
+ if (levels.length === 0) return preferred?.type === 'level' ? preferred.id : null
+
+ return 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/editor/src/lib/floorplan/selection-tool.ts b/packages/editor/src/lib/floorplan/selection-tool.ts
index 15e133f03..56e7aa48b 100644
--- a/packages/editor/src/lib/floorplan/selection-tool.ts
+++ b/packages/editor/src/lib/floorplan/selection-tool.ts
@@ -2,6 +2,7 @@ import type {
CeilingNode,
ColumnNode,
DoorNode,
+ ElevatorNode,
ItemNode,
Point2D,
RoofNode,
@@ -59,6 +60,11 @@ type ColumnEntry = {
polygon: Point2D[]
}
+type ElevatorEntry = {
+ elevator: ElevatorNode
+ polygon: Point2D[]
+}
+
type RoofEntry = {
roof: RoofNode
segments: Array<{
@@ -78,6 +84,7 @@ type FloorplanSelectionToolContext = {
slabs: SlabEntry[]
ceilings: CeilingEntry[]
columns: ColumnEntry[]
+ elevators: ElevatorEntry[]
roofs: RoofEntry[]
openingHitTolerance: number
wallHitTolerance: number
@@ -130,6 +137,13 @@ export function getFloorplanHitNodeId(context: FloorplanSelectionToolContext) {
return stairHit.stair.id
}
+ const elevatorHit = context.elevators.find(({ polygon }) =>
+ isPointInsidePolygon(context.point, polygon),
+ )
+ if (elevatorHit) {
+ return elevatorHit.elevator.id
+ }
+
const columnHit = context.columns.find(({ polygon }) =>
isPointInsidePolygon(context.point, polygon),
)
@@ -181,6 +195,7 @@ type FloorplanSelectionBoundsContext = {
slabs: SlabEntry[]
ceilings: CeilingEntry[]
columns: ColumnEntry[]
+ elevators: ElevatorEntry[]
stairs: StairEntry[]
roofs: RoofEntry[]
}
@@ -195,6 +210,7 @@ export function getFloorplanSelectionIdsInBounds({
slabs,
ceilings,
columns,
+ elevators,
stairs,
roofs,
}: FloorplanSelectionBoundsContext) {
@@ -223,6 +239,9 @@ export function getFloorplanSelectionIdsInBounds({
const columnIds = columns
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ column }) => column.id)
+ const elevatorIds = elevators
+ .filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
+ .map(({ elevator }) => elevator.id)
const stairIds = stairs
.filter((stair) =>
getStairHitPolygons(stair).some((polygon) =>
@@ -244,6 +263,7 @@ export function getFloorplanSelectionIdsInBounds({
...slabIds,
...ceilingIds,
...columnIds,
+ ...elevatorIds,
...stairIds,
...roofIds,
]),
diff --git a/packages/editor/src/lib/history.ts b/packages/editor/src/lib/history.ts
index bdd37fbbc..281a476e3 100644
--- a/packages/editor/src/lib/history.ts
+++ b/packages/editor/src/lib/history.ts
@@ -1,6 +1,7 @@
-import { useLiveTransforms, useScene } from '@pascal-app/core'
+import { useLiveNodeOverrides, useLiveTransforms, useScene } from '@pascal-app/core'
function refreshSceneAfterHistoryJump() {
+ useLiveNodeOverrides.getState().clearAll()
useLiveTransforms.getState().clearAll()
const state = useScene.getState()
diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx
index 21a735f10..03d963c08 100644
--- a/packages/editor/src/store/use-editor.tsx
+++ b/packages/editor/src/store/use-editor.tsx
@@ -7,6 +7,7 @@ import {
type CeilingNode,
type ColumnNode,
type DoorNode,
+ type ElevatorNode,
type FenceNode,
type ItemNode,
type LevelNode,
@@ -27,7 +28,6 @@ import {
import { useViewer } from '@pascal-app/viewer'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
-import { getDefaultCatalogItem } from '../components/ui/item-catalog/catalog-items'
import {
type ActivePaintMaterial,
type PaintableMaterialTarget,
@@ -58,6 +58,7 @@ export type StructureTool =
| 'ceiling'
| 'roof'
| 'column'
+ | 'elevator'
| 'stair'
| 'item'
| 'zone'
@@ -138,6 +139,7 @@ type EditorState = {
| ItemNode
| WindowNode
| DoorNode
+ | ElevatorNode
| CeilingNode
| ColumnNode
| SlabNode
@@ -155,6 +157,7 @@ type EditorState = {
| ItemNode
| WindowNode
| DoorNode
+ | ElevatorNode
| CeilingNode
| ColumnNode
| SlabNode
@@ -453,9 +456,14 @@ export function selectDefaultBuildingAndLevel() {
})
if (level0Id) {
viewer.setSelection({ levelId: level0Id as LevelNode['id'] })
- } else if (buildingNode.children[0]) {
+ } else {
// Fallback to first level if level 0 doesn't exist
- viewer.setSelection({ levelId: buildingNode.children[0] as LevelNode['id'] })
+ const firstLevelId = buildingNode.children.find(
+ (childId) => scene.nodes[childId]?.type === 'level',
+ )
+ if (firstLevelId) {
+ viewer.setSelection({ levelId: firstLevelId as LevelNode['id'] })
+ }
}
}
}
@@ -559,12 +567,15 @@ const useEditor = create()(
| ItemNode
| WindowNode
| DoorNode
+ | ElevatorNode
| CeilingNode
+ | ColumnNode
| SlabNode
| WallNode
| FenceNode
| RoofNode
| RoofSegmentNode
+ | SpawnNode
| StairNode
| StairSegmentNode
| BuildingNode
diff --git a/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx b/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx
new file mode 100644
index 000000000..facfe5c82
--- /dev/null
+++ b/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx
@@ -0,0 +1,1310 @@
+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 { useFrame } from '@react-three/fiber'
+import { useCallback, 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'
+
+const SHAFT_WALL_COLOR = '#d7dce4'
+const SHAFT_SIDE_COLOR = '#4b5563'
+const SHAFT_TRIM_COLOR = '#eef2f7'
+const CAB_COLOR = '#d7dde5'
+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']
+
+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 DOOR_GROOVE_MATERIAL = new MeshStandardMaterial({
+ color: '#5f6978',
+ metalness: 0.28,
+ roughness: 0.42,
+})
+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,
+ }),
+ disabled: new MeshStandardMaterial({
+ color: '#475569',
+ metalness: 0.12,
+ roughness: 0.52,
+ }),
+}
+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,
+ }),
+ disabled: new MeshStandardMaterial({
+ color: '#334155',
+ metalness: 0.28,
+ roughness: 0.5,
+ }),
+}
+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,
+ }),
+ disabled: new MeshStandardMaterial({
+ color: '#94a3b8',
+ metalness: 0.08,
+ roughness: 0.5,
+ }),
+}
+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'
+ | '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: MeshStandardMaterial
+ position?: Vector3Tuple
+ receiveShadow?: boolean
+ rotation?: Vector3Tuple
+ scale: Vector3Tuple
+}) {
+ return (
+
+ )
+}
+
+function MeshButtonLabel({
+ faceSign = 1,
+ label,
+ material,
+ position,
+ scale,
+}: {
+ faceSign?: -1 | 1
+ label: string
+ material: MeshStandardMaterial
+ 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: MeshStandardMaterial
+ 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: MeshStandardMaterial
+ 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 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)),
+ )
+
+ 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}
+ />
+
+
+
+ )
+ })}
+
+ )
+}
diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx
index adf2abc1c..f2045fb49 100644
--- a/packages/viewer/src/components/renderers/node-renderer.tsx
+++ b/packages/viewer/src/components/renderers/node-renderer.tsx
@@ -5,6 +5,7 @@ import { BuildingRenderer } from './building/building-renderer'
import { CeilingRenderer } from './ceiling/ceiling-renderer'
import { ColumnRenderer } from './column/column-renderer'
import { DoorRenderer } from './door/door-renderer'
+import { ElevatorRenderer } from './elevator/elevator-renderer'
import { FenceRenderer } from './fence/fence-renderer'
import { GuideRenderer } from './guide/guide-renderer'
import { ItemRenderer } from './item/item-renderer'
@@ -32,6 +33,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
{node.type === 'building' && }
{node.type === 'ceiling' && }
{node.type === 'column' && }
+ {node.type === 'elevator' && }
{node.type === 'level' && }
{node.type === 'item' && }
{node.type === 'slab' && }
diff --git a/packages/viewer/src/components/renderers/site/site-renderer.tsx b/packages/viewer/src/components/renderers/site/site-renderer.tsx
index 9f77b8789..fc66eea62 100644
--- a/packages/viewer/src/components/renderers/site/site-renderer.tsx
+++ b/packages/viewer/src/components/renderers/site/site-renderer.tsx
@@ -2,6 +2,7 @@ import { useRegistry, useScene, type SiteNode, type SlabNode } from '@pascal-app
import { useMemo, useRef } from 'react'
import { BufferGeometry, Float32BufferAttribute, Path, Shape, type Group } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
+import { unionPolygons } from '../../../lib/polygon-union'
import useViewer from '../../../store/use-viewer'
import { NodeRenderer } from '../node-renderer'
@@ -87,16 +88,15 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i]![0], -pts[i]![1])
shape.closePath()
- for (const polygon of slabPolygons) {
- if (polygon.length < 3) continue
-
- const hole = new Path()
- hole.moveTo(polygon[0]![0], -polygon[0]![1])
- for (let i = 1; i < polygon.length; i++) {
- hole.lineTo(polygon[i]![0], -polygon[i]![1])
+ if (slabPolygons.length > 0) {
+ for (const ring of unionPolygons(slabPolygons.map((p) => p.map((pt) => [pt[0], -pt[1]])))) {
+ if (ring.length < 3) continue
+ const hole = new Path()
+ hole.moveTo(ring[0]![0], ring[0]![1])
+ for (let i = 1; i < ring.length; i++) hole.lineTo(ring[i]![0], ring[i]![1])
+ hole.closePath()
+ shape.holes.push(hole)
}
- hole.closePath()
- shape.holes.push(hole)
}
return shape
diff --git a/packages/viewer/src/components/viewer/ground-occluder.tsx b/packages/viewer/src/components/viewer/ground-occluder.tsx
index c90cafa67..a4c91dfbd 100644
--- a/packages/viewer/src/components/viewer/ground-occluder.tsx
+++ b/packages/viewer/src/components/viewer/ground-occluder.tsx
@@ -1,6 +1,7 @@
import { type LevelNode, useScene } from '@pascal-app/core'
import { useMemo } from 'react'
import * as THREE from 'three'
+import { unionPolygons } from '../../lib/polygon-union'
import useViewer from '../../store/use-viewer'
export const GroundOccluder = () => {
@@ -62,16 +63,18 @@ export const GroundOccluder = () => {
polygons.push(node.polygon as [number, number][])
})
- for (const polygon of polygons) {
- if (polygon.length < 3) continue
+ if (polygons.length > 0) {
+ for (const ring of unionPolygons(polygons.map((pts) => pts.map((p) => [p[0], -p[1]])))) {
+ if (ring.length < 3) continue
+ const hole = new THREE.Path()
- const hole = new THREE.Path()
- hole.moveTo(polygon[0]![0], -polygon[0]![1])
- for (let i = 1; i < polygon.length; i++) {
- hole.lineTo(polygon[i]![0], -polygon[i]![1])
+ hole.moveTo(ring[0]![0], ring[0]![1])
+ for (let i = 1; i < ring.length; i++) {
+ hole.lineTo(ring[i]![0], ring[i]![1])
+ }
+ hole.closePath()
+ s.holes.push(hole)
}
- hole.closePath()
- s.holes.push(hole)
}
return s
diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx
index c7b64c96c..e1aee9b1b 100644
--- a/packages/viewer/src/components/viewer/index.tsx
+++ b/packages/viewer/src/components/viewer/index.tsx
@@ -1,6 +1,7 @@
'use client'
-import { Canvas, extend, useFrame, useThree, type ThreeToJSXElements } from '@react-three/fiber'
+import { ElevatorOpeningSystem, ElevatorRuntimeSystem } from '@pascal-app/core'
+import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three/webgpu'
import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf'
@@ -8,6 +9,7 @@ import useViewer from '../../store/use-viewer'
import { CeilingSystem } from '../../systems/ceiling/ceiling-system'
import { DoorAnimationSystem } from '../../systems/door/door-animation-system'
import { DoorSystem } from '../../systems/door/door-system'
+import { ElevatorInteractionSystem } from '../../systems/elevator/elevator-interaction-system'
import { FenceSystem } from '../../systems/fence/fence-system'
import { GuideSystem } from '../../systems/guide/guide-system'
import { ItemLightSystem } from '../../systems/item-light/item-light-system'
@@ -106,7 +108,7 @@ function GPUDeviceWatcher() {
useEffect(() => {
const backend = (gl as any).backend
- const device: GPUDevice | undefined = backend?.device
+ const device = backend?.device as WebGPUDeviceLike | undefined
if (!device) {
console.warn('[viewer] No WebGPU device on backend — running on a fallback renderer.', {
@@ -121,22 +123,22 @@ function GPUDeviceWatcher() {
features: Array.from(device.features ?? []),
})
- device.lost.then((info) => {
+ device.lost.then((info: WebGPUDeviceLossInfo) => {
console.error(
- `[viewer] WebGPU device lost: reason="${info.reason}", message="${info.message}". ` +
+ `[viewer] WebGPU device lost: reason="${info.reason ?? 'unknown'}", message="${info.message ?? ''}". ` +
'The page must be reloaded to recover the GPU context.',
)
})
// Uncaptured errors are normally silent (only console-warned by Chrome at
// best). Pipe them to console.error so silent mobile crashes show up.
- const onUncapturedError = (event: GPUUncapturedErrorEvent) => {
- console.error('[viewer] WebGPU uncaptured error:', event.error.message, event.error)
+ const onUncapturedError = (event: any) => {
+ console.error('[viewer] WebGPU uncaptured error:', event?.error?.message, event?.error)
}
- device.addEventListener('uncapturederror', onUncapturedError as EventListener)
+ device.addEventListener?.('uncapturederror', onUncapturedError)
return () => {
- device.removeEventListener('uncapturederror', onUncapturedError as EventListener)
+ device.removeEventListener?.('uncapturederror', onUncapturedError)
}
}, [gl])
@@ -175,24 +177,12 @@ const Viewer: React.FC = ({
const canvas = props.canvas
const cached = canvas ? WEBGPU_RENDERER_CACHE.get(canvas) : undefined
if (cached) return cached
- // Surface the env we're about to ask WebGPU for — catches "no
- // navigator.gpu" / "adapter request failed" silently failing in
- // mobile WebViews where WebGPU is gated behind flags.
- const hasGpu = typeof navigator !== 'undefined' && 'gpu' in navigator
- console.log('[viewer] Creating WebGPURenderer', {
- hasNavigatorGPU: hasGpu,
- ua: typeof navigator !== 'undefined' ? navigator.userAgent : 'n/a',
- })
const promise = (async () => {
try {
const renderer = new THREE.WebGPURenderer(props as any)
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 0.9
await renderer.init()
- console.log('[viewer] WebGPURenderer ready', {
- backend: (renderer as any).backend?.constructor?.name,
- isWebGPU: (renderer as any).isWebGPURenderer === true,
- })
return renderer
} catch (err) {
// Drop the failed promise from the cache so a future Canvas
@@ -240,6 +230,9 @@ const Viewer: React.FC = ({
{/* Core systems */}
+
+
+
diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx
index 36d84755a..b64ffabfa 100644
--- a/packages/viewer/src/components/viewer/post-processing.tsx
+++ b/packages/viewer/src/components/viewer/post-processing.tsx
@@ -154,6 +154,7 @@ const PostProcessingPasses = ({
// Subscribe to projectId so the pipeline rebuilds on project switch
const projectId = useViewer((s) => s.projectId)
+ const lastProjectIdRef = useRef(projectId)
// Bump this to force a pipeline rebuild (used by retry logic)
const [pipelineVersion, setPipelineVersion] = useState(0)
@@ -169,6 +170,8 @@ const PostProcessingPasses = ({
// Reset retry state when project changes
useEffect(() => {
+ if (lastProjectIdRef.current === projectId) return
+ lastProjectIdRef.current = projectId
retryCountRef.current = 0
if (rebuildTimeoutRef.current !== null) {
clearTimeout(rebuildTimeoutRef.current)
@@ -231,6 +234,21 @@ const PostProcessingPasses = ({
hasPipelineErrorRef.current = false
+ // WebGPU availability check: SSGI, denoise, and RenderPipeline are all
+ // WebGPU-only APIs. When the browser falls back to WebGL2 (no
+ // `navigator.gpu`, or the device couldn't be created), building the
+ // pipeline either throws silently or produces a broken output where
+ // the scene renders for a few frames and then goes black as the retry
+ // loop fights the direct-render fallback path. Short-circuit here so
+ // `useFrame` uses the direct `renderer.render(scene, camera)` path
+ // exclusively and never attempts the TSL pipeline.
+ const hasWebGPU = typeof navigator !== 'undefined' && 'gpu' in navigator
+ if (!hasWebGPU) {
+ hasPipelineErrorRef.current = true
+ renderPipelineRef.current = null
+ return
+ }
+
// Clear outliner arrays synchronously to prevent stale Object3D refs
// from the previous project leaking into the new pipeline's outline passes.
const outliner = useViewer.getState().outliner
@@ -361,7 +379,6 @@ const PostProcessingPasses = ({
renderPipeline.outputNode = finalOutput
renderPipelineRef.current = renderPipeline
retryCountRef.current = 0
- console.log('[viewer/post-processing] Pipeline built OK', { version: pipelineVersion })
} catch (error) {
hasPipelineErrorRef.current = true
console.error(
@@ -388,6 +405,7 @@ const PostProcessingPasses = ({
}, [
camera,
hoverHiddenColor,
+ hoverHighlightMode,
hoverPulseMix,
hoverStrength,
hoverVisibleColor,
@@ -463,9 +481,6 @@ const PostProcessingPasses = ({
if (retryCountRef.current < MAX_PIPELINE_RETRIES) {
// Auto-retry: schedule a pipeline rebuild if we haven't exceeded the retry limit
retryCountRef.current++
- console.warn(
- `[viewer/post-processing] Scheduling pipeline rebuild (attempt ${retryCountRef.current}/${MAX_PIPELINE_RETRIES})`,
- )
if (rebuildTimeoutRef.current !== null) {
clearTimeout(rebuildTimeoutRef.current)
}
diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts
index 18487347e..e1e105039 100644
--- a/packages/viewer/src/hooks/use-node-events.ts
+++ b/packages/viewer/src/hooks/use-node-events.ts
@@ -7,6 +7,8 @@ import {
type ColumnNode,
type DoorEvent,
type DoorNode,
+ type ElevatorEvent,
+ type ElevatorNode,
type EventSuffix,
emitter,
type FenceEvent,
@@ -57,6 +59,7 @@ type NodeConfig = {
'stair-segment': { node: StairSegmentNode; event: StairSegmentEvent }
window: { node: WindowNode; event: WindowEvent }
door: { node: DoorNode; event: DoorEvent }
+ elevator: { node: ElevatorNode; event: ElevatorEvent }
}
type NodeType = keyof NodeConfig
diff --git a/packages/viewer/src/lib/polygon-union.test.ts b/packages/viewer/src/lib/polygon-union.test.ts
new file mode 100644
index 000000000..6bbaaa0e5
--- /dev/null
+++ b/packages/viewer/src/lib/polygon-union.test.ts
@@ -0,0 +1,77 @@
+// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
+// depend on @types/bun so the import type is unresolved at compile time.
+import { describe, expect, test } from 'bun:test'
+import { type Point2D, unionPolygons } from './polygon-union'
+
+function polygonArea(points: Point2D[]) {
+ let area = 0
+ for (let i = 0; i < points.length; i++) {
+ const current = points[i]!
+ const next = points[(i + 1) % points.length]!
+ area += current[0] * next[1] - next[0] * current[1]
+ }
+ return Math.abs(area / 2)
+}
+
+describe('unionPolygons', () => {
+ test('collapses a contained polygon into the containing polygon', () => {
+ const small: Point2D[] = [
+ [0, 0],
+ [1, 0],
+ [1, 1],
+ [0, 1],
+ ]
+ const large: Point2D[] = [
+ [-1, -1],
+ [2, -1],
+ [2, 2],
+ [-1, 2],
+ ]
+
+ const result = unionPolygons([small, large])
+
+ expect(result).toHaveLength(1)
+ expect(polygonArea(result[0]!)).toBeCloseTo(9)
+ })
+
+ test('combines overlapping rectangles into one boundary', () => {
+ const left: Point2D[] = [
+ [0, 0],
+ [2, 0],
+ [2, 2],
+ [0, 2],
+ ]
+ const right: Point2D[] = [
+ [1, 1],
+ [3, 1],
+ [3, 3],
+ [1, 3],
+ ]
+
+ const result = unionPolygons([left, right])
+
+ expect(result).toHaveLength(1)
+ expect(result[0]).toHaveLength(8)
+ expect(polygonArea(result[0]!)).toBeCloseTo(7)
+ })
+
+ test('keeps disjoint polygons as separate boundaries', () => {
+ const left: Point2D[] = [
+ [0, 0],
+ [1, 0],
+ [1, 1],
+ [0, 1],
+ ]
+ const right: Point2D[] = [
+ [2, 0],
+ [3, 0],
+ [3, 1],
+ [2, 1],
+ ]
+
+ const result = unionPolygons([left, right])
+
+ expect(result).toHaveLength(2)
+ expect(result.map(polygonArea)).toEqual([1, 1])
+ })
+})
diff --git a/packages/viewer/src/lib/polygon-union.ts b/packages/viewer/src/lib/polygon-union.ts
new file mode 100644
index 000000000..f078ccdd2
--- /dev/null
+++ b/packages/viewer/src/lib/polygon-union.ts
@@ -0,0 +1,294 @@
+export type Point2D = [number, number]
+
+const EPSILON = 1e-7
+const KEY_SCALE = 1e6
+
+type Edge = {
+ start: Point2D
+ end: Point2D
+ polygonIndex: number
+ splits: number[]
+}
+
+type Segment = {
+ start: Point2D
+ end: Point2D
+ used: boolean
+}
+
+function pointsEqual(a: Point2D, b: Point2D, tolerance = EPSILON) {
+ return Math.hypot(a[0] - b[0], a[1] - b[1]) <= tolerance
+}
+
+function pointKey(point: Point2D) {
+ return `${Math.round(point[0] * KEY_SCALE)}:${Math.round(point[1] * KEY_SCALE)}`
+}
+
+function interpolate(a: Point2D, b: Point2D, t: number): Point2D {
+ return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
+}
+
+function cross(ax: number, ay: number, bx: number, by: number) {
+ return ax * by - ay * bx
+}
+
+function polygonArea(points: Point2D[]) {
+ let area = 0
+ for (let i = 0; i < points.length; i++) {
+ const current = points[i]!
+ const next = points[(i + 1) % points.length]!
+ area += current[0] * next[1] - next[0] * current[1]
+ }
+ return area / 2
+}
+
+function pointOnSegment(point: Point2D, start: Point2D, end: Point2D) {
+ const dx = end[0] - start[0]
+ const dz = end[1] - start[1]
+ const crossValue = cross(point[0] - start[0], point[1] - start[1], dx, dz)
+ if (Math.abs(crossValue) > EPSILON) return false
+
+ const dot =
+ (point[0] - start[0]) * (point[0] - end[0]) + (point[1] - start[1]) * (point[1] - end[1])
+ return dot <= EPSILON
+}
+
+function pointInPolygon(point: Point2D, polygon: Point2D[]) {
+ let inside = false
+
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
+ const pi = polygon[i]!
+ const pj = polygon[j]!
+
+ if (pointOnSegment(point, pj, pi)) return false
+
+ const intersects =
+ pi[1] > point[1] !== pj[1] > point[1] &&
+ point[0] < ((pj[0] - pi[0]) * (point[1] - pi[1])) / (pj[1] - pi[1]) + pi[0]
+
+ if (intersects) inside = !inside
+ }
+
+ return inside
+}
+
+function normalizeRing(ring: Point2D[]) {
+ const normalized: Point2D[] = []
+
+ for (const [x, z] of ring) {
+ if (!Number.isFinite(x) || !Number.isFinite(z)) continue
+
+ const point: Point2D = [x, z]
+ const previous = normalized[normalized.length - 1]
+ if (!previous || !pointsEqual(previous, point)) {
+ normalized.push(point)
+ }
+ }
+
+ const first = normalized[0]
+ const last = normalized[normalized.length - 1]
+ if (first && last && pointsEqual(first, last)) {
+ normalized.pop()
+ }
+
+ if (normalized.length < 3 || Math.abs(polygonArea(normalized)) <= EPSILON) return []
+ return polygonArea(normalized) < 0 ? [...normalized].reverse() : normalized
+}
+
+function addSplit(edge: Edge, t: number) {
+ if (t < -EPSILON || t > 1 + EPSILON) return
+ const clamped = Math.max(0, Math.min(1, t))
+ if (edge.splits.some((split) => Math.abs(split - clamped) <= EPSILON)) return
+ edge.splits.push(clamped)
+}
+
+function parameterOnEdge(point: Point2D, edge: Edge) {
+ const dx = edge.end[0] - edge.start[0]
+ const dz = edge.end[1] - edge.start[1]
+ const lengthSquared = dx * dx + dz * dz
+ if (lengthSquared <= EPSILON) return 0
+ return ((point[0] - edge.start[0]) * dx + (point[1] - edge.start[1]) * dz) / lengthSquared
+}
+
+function addIntersectionSplits(left: Edge, right: Edge) {
+ const rx = left.end[0] - left.start[0]
+ const rz = left.end[1] - left.start[1]
+ const sx = right.end[0] - right.start[0]
+ const sz = right.end[1] - right.start[1]
+ const qpx = right.start[0] - left.start[0]
+ const qpz = right.start[1] - left.start[1]
+ const denominator = cross(rx, rz, sx, sz)
+ const numerator = cross(qpx, qpz, rx, rz)
+
+ if (Math.abs(denominator) <= EPSILON) {
+ if (Math.abs(numerator) > EPSILON) return
+
+ for (const point of [left.start, left.end, right.start, right.end]) {
+ if (
+ pointOnSegment(point, left.start, left.end) &&
+ pointOnSegment(point, right.start, right.end)
+ ) {
+ addSplit(left, parameterOnEdge(point, left))
+ addSplit(right, parameterOnEdge(point, right))
+ }
+ }
+ return
+ }
+
+ const t = cross(qpx, qpz, sx, sz) / denominator
+ const u = cross(qpx, qpz, rx, rz) / denominator
+ if (t < -EPSILON || t > 1 + EPSILON || u < -EPSILON || u > 1 + EPSILON) return
+
+ addSplit(left, t)
+ addSplit(right, u)
+}
+
+function buildEdges(polygons: Point2D[][]) {
+ const edges: Edge[] = []
+
+ polygons.forEach((polygon, polygonIndex) => {
+ for (let i = 0; i < polygon.length; i++) {
+ edges.push({
+ start: polygon[i]!,
+ end: polygon[(i + 1) % polygon.length]!,
+ polygonIndex,
+ splits: [0, 1],
+ })
+ }
+ })
+
+ for (let i = 0; i < edges.length; i++) {
+ for (let j = i + 1; j < edges.length; j++) {
+ const left = edges[i]!
+ const right = edges[j]!
+ if (left.polygonIndex === right.polygonIndex) continue
+ addIntersectionSplits(left, right)
+ }
+ }
+
+ return edges
+}
+
+function buildBoundarySegments(edges: Edge[], polygons: Point2D[][]) {
+ const segments: Segment[] = []
+
+ for (const edge of edges) {
+ const splits = [...edge.splits].sort((a, b) => a - b)
+
+ for (let i = 0; i < splits.length - 1; i++) {
+ const startT = splits[i]!
+ const endT = splits[i + 1]!
+ if (endT - startT <= EPSILON) continue
+
+ const start = interpolate(edge.start, edge.end, startT)
+ const end = interpolate(edge.start, edge.end, endT)
+ const mid = interpolate(edge.start, edge.end, (startT + endT) / 2)
+ const insideAnother = polygons.some(
+ (polygon, index) => index !== edge.polygonIndex && pointInPolygon(mid, polygon),
+ )
+
+ if (!insideAnother) {
+ segments.push({ start, end, used: false })
+ }
+ }
+ }
+
+ return removeDuplicateInteriorSegments(segments)
+}
+
+function segmentKey(segment: Segment) {
+ const start = pointKey(segment.start)
+ const end = pointKey(segment.end)
+ return start < end ? `${start}|${end}` : `${end}|${start}`
+}
+
+function removeDuplicateInteriorSegments(segments: Segment[]) {
+ const groups = new Map()
+ for (const segment of segments) {
+ const key = segmentKey(segment)
+ const group = groups.get(key)
+ if (group) {
+ group.push(segment)
+ } else {
+ groups.set(key, [segment])
+ }
+ }
+
+ const result: Segment[] = []
+ for (const group of groups.values()) {
+ if (group.length === 1) {
+ result.push(group[0]!)
+ continue
+ }
+
+ const firstStart = pointKey(group[0]!.start)
+ const firstEnd = pointKey(group[0]!.end)
+ const hasOppositeDirection = group.some(
+ (segment) => pointKey(segment.start) === firstEnd && pointKey(segment.end) === firstStart,
+ )
+
+ if (!hasOppositeDirection) {
+ result.push(group[0]!)
+ }
+ }
+
+ return result
+}
+
+function assembleRings(segments: Segment[]) {
+ const byStart = new Map()
+ for (const segment of segments) {
+ const key = pointKey(segment.start)
+ const group = byStart.get(key)
+ if (group) {
+ group.push(segment)
+ } else {
+ byStart.set(key, [segment])
+ }
+ }
+
+ const rings: Point2D[][] = []
+
+ for (const firstSegment of segments) {
+ if (firstSegment.used) continue
+
+ firstSegment.used = true
+ const ring: Point2D[] = [firstSegment.start, firstSegment.end]
+ const startKey = pointKey(firstSegment.start)
+ let currentKey = pointKey(firstSegment.end)
+
+ while (currentKey !== startKey) {
+ const next = byStart.get(currentKey)?.find((segment) => !segment.used)
+ if (!next) break
+
+ next.used = true
+ ring.push(next.end)
+ currentKey = pointKey(next.end)
+ }
+
+ if (currentKey !== startKey) continue
+
+ const last = ring[ring.length - 1]
+ if (last && pointsEqual(ring[0]!, last)) {
+ ring.pop()
+ }
+
+ const normalized = normalizeRing(ring)
+ if (normalized.length >= 3) {
+ rings.push(normalized)
+ }
+ }
+
+ return rings
+}
+
+export function unionPolygons(polygons: Point2D[][]): Point2D[][] {
+ const validPolygons = polygons.map(normalizeRing).filter((polygon) => polygon.length >= 3)
+ if (validPolygons.length <= 1) return validPolygons
+
+ const edges = buildEdges(validPolygons)
+ const segments = buildBoundarySegments(edges, validPolygons)
+ const rings = assembleRings(segments)
+
+ return rings.length > 0 ? rings : validPolygons
+}
diff --git a/packages/viewer/src/systems/ceiling/ceiling-system.tsx b/packages/viewer/src/systems/ceiling/ceiling-system.tsx
index c15d15258..d6fc0a0a1 100644
--- a/packages/viewer/src/systems/ceiling/ceiling-system.tsx
+++ b/packages/viewer/src/systems/ceiling/ceiling-system.tsx
@@ -1,6 +1,7 @@
import { type AnyNodeId, type CeilingNode, sceneRegistry, useScene } from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
+import { mergeSurfaceHolePolygons } from '../surface-hole-geometry'
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
const uv = geometry.getAttribute('uv')
@@ -82,7 +83,7 @@ export function generateCeilingGeometry(ceilingNode: CeilingNode): THREE.BufferG
shape.closePath()
// Add holes to the shape
- const holes = ceilingNode.holes || []
+ const holes = mergeSurfaceHolePolygons(ceilingNode.holes || [])
for (const holePolygon of holes) {
if (holePolygon.length < 3) continue
diff --git a/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx b/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx
new file mode 100644
index 000000000..ded3d4999
--- /dev/null
+++ b/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx
@@ -0,0 +1,93 @@
+import {
+ type AnyNodeId,
+ openElevatorDoor,
+ resolveElevatorDispatchTarget,
+ requestElevatorLevel,
+ useInteractive,
+ useScene,
+} from '@pascal-app/core'
+import { useThree } from '@react-three/fiber'
+import { useEffect, useRef } from 'react'
+import { type Object3D, Raycaster, Vector2 } from 'three'
+
+type ElevatorButtonAction = 'open-door' | 'request-level'
+
+type ElevatorButtonUserData = {
+ action: ElevatorButtonAction
+ disabled: boolean
+ elevatorId: AnyNodeId
+ kind: 'cab' | 'landing'
+ levelId?: AnyNodeId
+}
+
+function getElevatorButtonData(object: Object3D): ElevatorButtonUserData | null {
+ let current: Object3D | null = object
+
+ while (current) {
+ const data = (current.userData as { elevatorButton?: ElevatorButtonUserData }).elevatorButton
+ if (data) return data
+ current = current.parent
+ }
+
+ return null
+}
+
+export function ElevatorInteractionSystem() {
+ const camera = useThree((state) => state.camera)
+ const gl = useThree((state) => state.gl)
+ const scene = useThree((state) => state.scene)
+ const raycasterRef = useRef(new Raycaster())
+ const pointerRef = useRef(new Vector2())
+
+ useEffect(() => {
+ const canvas = gl.domElement
+
+ const handlePointerDown = (event: PointerEvent) => {
+ if (event.button !== 0) return
+
+ const rect = canvas.getBoundingClientRect()
+ const pointer = pointerRef.current
+ pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
+ pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
+
+ const raycaster = raycasterRef.current
+ raycaster.setFromCamera(pointer, camera)
+
+ const button = raycaster
+ .intersectObjects(scene.children, true)
+ .map((intersection) => getElevatorButtonData(intersection.object))
+ .find((data): data is ElevatorButtonUserData => data !== null)
+
+ if (!button || button.disabled) return
+
+ event.preventDefault()
+ event.stopPropagation()
+
+ if (button.action === 'open-door') {
+ openElevatorDoor(button.elevatorId)
+ return
+ }
+
+ if (!button.levelId) return
+
+ const targetElevatorId =
+ button.kind === 'landing'
+ ? resolveElevatorDispatchTarget({
+ elevators: useInteractive.getState().elevators,
+ levelId: button.levelId,
+ nodes: useScene.getState().nodes,
+ requestedElevatorId: button.elevatorId,
+ })
+ : button.elevatorId
+
+ requestElevatorLevel(targetElevatorId, button.levelId)
+ }
+
+ canvas.addEventListener('pointerdown', handlePointerDown, true)
+ return () => {
+ canvas.removeEventListener('pointerdown', handlePointerDown, true)
+ }
+ }, [camera, gl, scene])
+
+ return null
+}
diff --git a/packages/viewer/src/systems/slab/slab-system.tsx b/packages/viewer/src/systems/slab/slab-system.tsx
index 3d866445e..403f0438e 100644
--- a/packages/viewer/src/systems/slab/slab-system.tsx
+++ b/packages/viewer/src/systems/slab/slab-system.tsx
@@ -8,6 +8,7 @@ import {
import { useFrame } from '@react-three/fiber'
import { useEffect } from 'react'
import * as THREE from 'three'
+import { mergeSurfaceHolePolygons } from '../surface-hole-geometry'
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
const uv = geometry.getAttribute('uv')
@@ -86,6 +87,7 @@ export function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
const polygon = getRenderableSlabPolygon(slabNode)
const elevation = slabNode.elevation ?? 0.05
+ const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? [])
if (polygon.length < 3) return new THREE.BufferGeometry()
@@ -94,7 +96,7 @@ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry
for (let i = 1; i < polygon.length; i++) shape.lineTo(polygon[i]![0], -polygon[i]![1])
shape.closePath()
- for (const holePolygon of slabNode.holes ?? []) {
+ for (const holePolygon of holePolygons) {
if (holePolygon.length < 3) continue
const holePath = new THREE.Path()
holePath.moveTo(holePolygon[0]![0], -holePolygon[0]![1])
@@ -122,6 +124,7 @@ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry
function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
const polygon = getRenderableSlabPolygon(slabNode)
const depth = Math.abs(slabNode.elevation ?? 0.05)
+ const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? [])
if (polygon.length < 3) return new THREE.BufferGeometry()
@@ -134,7 +137,7 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
for (const [x, z] of polygon) {
bounds.expandByPoint(new THREE.Vector2(x, z))
}
- for (const hole of slabNode.holes ?? []) {
+ for (const hole of holePolygons) {
for (const [x, z] of hole) {
bounds.expandByPoint(new THREE.Vector2(x, z))
}
@@ -157,8 +160,8 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
for (const [x, z] of polygon) pushFloorVertex(x!, 0, z!)
const pts2d = polygon.map(([x, z]) => new THREE.Vector2(x!, z!))
- const holesPts2d = (slabNode.holes ?? []).map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
- for (const hole of slabNode.holes ?? []) {
+ const holesPts2d = holePolygons.map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
+ for (const hole of holePolygons) {
for (const [x, z] of hole) pushFloorVertex(x!, 0, z!)
}
diff --git a/packages/viewer/src/systems/surface-hole-geometry.ts b/packages/viewer/src/systems/surface-hole-geometry.ts
new file mode 100644
index 000000000..bd514ea00
--- /dev/null
+++ b/packages/viewer/src/systems/surface-hole-geometry.ts
@@ -0,0 +1,5 @@
+import { type Point2D, unionPolygons } from '../lib/polygon-union'
+
+export function mergeSurfaceHolePolygons(holes: Point2D[][]): Point2D[][] {
+ return unionPolygons(holes)
+}