From fdf08c091f0b56986212b71294218172139adacd Mon Sep 17 00:00:00 2001 From: sudhir Date: Sun, 10 May 2026 10:50:22 +0530 Subject: [PATCH] Add elevator floorplan overlay --- .../src/components/editor/floorplan-panel.tsx | 347 ++++++++++++++++++ .../editor/use-floorplan-hit-testing.ts | 12 + .../src/lib/floorplan/selection-tool.ts | 20 + 3 files changed, 379 insertions(+) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 25e9cc2e..3ba8d9e0 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -9,6 +9,7 @@ import { type ColumnNode, calculateLevelMiters, DoorNode, + type ElevatorNode, emitter, type FenceNode, type GridEvent, @@ -28,6 +29,7 @@ import { type Point2D, type RoofNode, type RoofSegmentNode, + resolveElevatorServiceLevelIds, type SiteNode, SlabNode, type SpawnNode, @@ -37,6 +39,7 @@ import { StairSegmentNode as StairSegmentNodeSchema, sampleWallCenterline, sceneRegistry, + useInteractive, useLiveTransforms, useScene, type WallNode, @@ -631,6 +634,18 @@ type FloorplanColumnEntry = { polygon: Point2D[] } +type FloorplanElevatorEntry = { + center: Point2D + elevator: ElevatorNode + frontEdge: FloorplanLineSegment + frontNormal: Point2D + isCarOnLevel: boolean + isQueuedLevel: boolean + isTargetLevel: boolean + points: string + polygon: Point2D[] +} + type ReferenceFloorData = { ceilingPolygons: CeilingPolygonEntry[] columnEntries: ReferenceFloorColumnEntry[] @@ -6006,6 +6021,167 @@ const FloorplanFenceLayer = memo(function FloorplanFenceLayer({ ) }) +const FloorplanElevatorLayer = memo(function FloorplanElevatorLayer({ + canSelectElevators, + elevatorEntries, + highlightedIdSet, + hoveredElevatorId, + isDeleteMode, + onElevatorHoverChange, + onElevatorHoverEnter, + onElevatorSelect, + palette, + selectedIdSet, + wallSelectionHatchId, +}: { + canSelectElevators: boolean + elevatorEntries: FloorplanElevatorEntry[] + highlightedIdSet: ReadonlySet + hoveredElevatorId: ElevatorNode['id'] | null + isDeleteMode: boolean + onElevatorHoverChange: (elevatorId: ElevatorNode['id'] | null) => void + onElevatorHoverEnter: (elevatorId: ElevatorNode['id']) => void + onElevatorSelect: (elevator: ElevatorNode, event: ReactMouseEvent) => void + palette: FloorplanPalette + selectedIdSet: ReadonlySet + wallSelectionHatchId: string +}) { + if (elevatorEntries.length === 0) { + return null + } + + return ( + + {elevatorEntries.map((entry) => { + const { elevator } = entry + const isSelected = selectedIdSet.has(elevator.id) + const isHighlighted = highlightedIdSet.has(elevator.id) + const isHovered = hoveredElevatorId === elevator.id + const isDeleteHovered = isDeleteMode && isHovered + const isActive = isSelected || isHighlighted + const showChrome = isActive || isHovered + const fill = isDeleteHovered + ? palette.deleteFill + : isActive + ? `url(#${wallSelectionHatchId})` + : '#dbeafe' + const fillOpacity = isDeleteHovered ? 0.38 : isActive ? 0.84 : isHovered ? 0.78 : 0.64 + const stroke = isDeleteHovered + ? palette.deleteStroke + : isActive + ? palette.selectedStroke + : isHovered + ? palette.wallHoverStroke + : '#2563eb' + const doorStroke = isDeleteHovered + ? palette.deleteStroke + : isActive + ? palette.selectedStroke + : '#0284c7' + const centerX = toSvgX(entry.center.x) + const centerY = toSvgY(entry.center.y) + const frontCenter = { + x: (entry.frontEdge.start.x + entry.frontEdge.end.x) / 2, + y: (entry.frontEdge.start.y + entry.frontEdge.end.y) / 2, + } + const doorIndicatorEnd = { + x: frontCenter.x + entry.frontNormal.x * 0.34, + y: frontCenter.y + entry.frontNormal.y * 0.34, + } + const showCarMarker = entry.isCarOnLevel || entry.isTargetLevel || entry.isQueuedLevel + const carFill = entry.isCarOnLevel ? '#22c55e' : '#ffffff' + const carStroke = entry.isCarOnLevel ? '#15803d' : '#0ea5e9' + + return ( + onElevatorHoverEnter(elevator.id) : undefined + } + onPointerLeave={canSelectElevators ? () => onElevatorHoverChange(null) : undefined} + > + {showChrome ? ( + + ) : null} + + + + {showCarMarker ? ( + + ) : null} + { + event.stopPropagation() + onElevatorSelect(elevator, event) + } + : undefined + } + points={entry.points} + pointerEvents={canSelectElevators ? 'all' : 'none'} + style={canSelectElevators ? { cursor: EDITOR_CURSOR } : undefined} + > + {elevator.name || 'Elevator'} + + + ) + })} + + ) +}) + // Renders an item's 2D floor-plan image (top-down view, object-fit:contain) // inside its footprint rectangle. Placed at the same scene position/rotation // as the polygon so it lines up exactly. @@ -7574,6 +7750,19 @@ export function FloorplanPanel() { walls, zones, } = useFloorplanSceneData({ buildingId, levelId }) + const elevators = useScene( + useShallow((state) => { + const building = currentBuildingId ? state.nodes[currentBuildingId] : null + if (!building || building.type !== 'building') { + return [] as ElevatorNode[] + } + + return building.children.flatMap((childId) => { + const node = state.nodes[childId] + return node?.type === 'elevator' && node.visible !== false ? [node] : [] + }) + }), + ) const buildingRotationDeg = (buildingRotationY * 180) / Math.PI const floorplanSceneRotationDeg = FLOORPLAN_VIEW_ROTATION_DEG - buildingRotationDeg @@ -7631,6 +7820,7 @@ export function FloorplanPanel() { const [hoveredItemId, setHoveredItemId] = useState(null) const [hoveredSpawnId, setHoveredSpawnId] = useState(null) const [hoveredStairId, setHoveredStairId] = useState(null) + const [hoveredElevatorId, setHoveredElevatorId] = useState(null) const [hoveredZoneId, setHoveredZoneId] = useState(null) const [hoveredEndpointId, setHoveredEndpointId] = useState(null) const [hoveredWallCurveHandleId, setHoveredWallCurveHandleId] = useState(null) @@ -7655,6 +7845,29 @@ export function FloorplanPanel() { const [rotationModifierPressed, setRotationModifierPressed] = useState(false) const [movingFloorplanNodeRevision, setMovingFloorplanNodeRevision] = useState(0) const movingFloorplanNodeRefreshFrameRef = useRef(null) + const elevatorIds = useMemo(() => elevators.map((elevator) => elevator.id), [elevators]) + const elevatorRuntimeKey = useInteractive( + useCallback( + (state) => + elevatorIds + .map((elevatorId) => { + const runtime = state.elevators[elevatorId] + if (!runtime) { + return `${elevatorId}:` + } + + return [ + elevatorId, + runtime.currentLevelId ?? '', + runtime.targetLevelId ?? '', + runtime.phase, + runtime.queue.join(','), + ].join(':') + }) + .join('|'), + [elevatorIds], + ), + ) const [stairBuildPreviewPoint, setStairBuildPreviewPoint] = useState(null) const [stairBuildPreviewRotation, setStairBuildPreviewRotation] = useState(0) const [isPanning, setIsPanning] = useState(false) @@ -8281,6 +8494,68 @@ export function FloorplanPanel() { ] }) }, [cursorPoint, floorplanItems, levelDescendantNodeById, movingFloorplanNodeRevision]) + const floorplanElevatorEntries = useMemo(() => { + if (!levelNode) { + return [] + } + + const nodes = useScene.getState().nodes + const interactiveElevators = useInteractive.getState().elevators + + return elevators.flatMap((elevator) => { + const serviceLevelIds = resolveElevatorServiceLevelIds(elevator, nodes) + if (!serviceLevelIds.includes(levelNode.id)) { + return [] + } + + const live = useLiveTransforms.getState().get(elevator.id) + const position = live?.position ?? elevator.position + const rotation = live?.rotation ?? elevator.rotation + const center = { x: position[0], y: position[2] } + const halfWidth = Math.max(0.1, elevator.width / 2) + const halfDepth = Math.max(0.1, elevator.depth / 2) + const footprintCorners: Array = [ + [-halfWidth, -halfDepth], + [halfWidth, -halfDepth], + [halfWidth, halfDepth], + [-halfWidth, halfDepth], + ] + const polygon = footprintCorners.map(([localX, localY]) => { + const [offsetX, offsetY] = rotatePlanVector(localX, localY, rotation) + return { + x: center.x + offsetX, + y: center.y + offsetY, + } + }) + const frontStart = polygon[0] + const frontEnd = polygon[1] + if (!(frontStart && frontEnd)) { + return [] + } + const [frontNormalX, frontNormalY] = rotatePlanVector(0, -1, rotation) + const runtime = interactiveElevators[elevator.id] + + return [ + { + center, + elevator, + frontEdge: { + start: frontStart, + end: frontEnd, + }, + frontNormal: { + x: frontNormalX, + y: frontNormalY, + }, + isCarOnLevel: runtime?.currentLevelId === levelNode.id, + isQueuedLevel: runtime?.queue.includes(levelNode.id) ?? false, + isTargetLevel: runtime?.targetLevelId === levelNode.id, + points: formatPolygonPoints(polygon), + polygon, + }, + ] + }) + }, [elevatorRuntimeKey, elevators, levelNode, movingFloorplanNodeRevision]) const referenceFloorLevel = useMemo(() => { if (!(showReferenceFloor && levelNode)) { return null @@ -9114,6 +9389,7 @@ export function FloorplanPanel() { !movingFenceEndpoint && isFloorplanStructureContextActive) || isDeleteMode + const canSelectFloorplanElevators = canSelectFloorplanStairs const canSelectFloorplanSpawns = canSelectFloorplanStairs const canSelectFloorplanItems = (mode === 'select' && @@ -9788,6 +10064,7 @@ export function FloorplanPanel() { ...(visibleSitePolygon ? visibleSitePolygon.polygon : []), ...displayCeilingPolygons.flatMap((entry) => entry.polygon), ...displaySlabPolygons.flatMap((entry) => entry.polygon), + ...floorplanElevatorEntries.flatMap((entry) => entry.polygon), ...floorplanFenceEntries.flatMap((entry) => entry.centerline), ...floorplanItemEntries.flatMap((entry) => entry.polygon), ...floorplanRoofEntries.flatMap((entry) => @@ -9835,6 +10112,7 @@ export function FloorplanPanel() { }, [ displayCeilingPolygons, displaySlabPolygons, + floorplanElevatorEntries, floorplanFenceEntries, floorplanItemEntries, floorplanRoofEntries, @@ -13047,6 +13325,7 @@ export function FloorplanPanel() { columnPolygons: floorplanColumnEntries, displaySlabPolygons, displayWallPolygons, + floorplanElevatorEntries, floorplanItemEntries, floorplanOpeningHitTolerance, floorplanRoofEntries, @@ -13425,6 +13704,14 @@ export function FloorplanPanel() { [syncDeleteHoveredId], ) + const handleElevatorHoverChange = useCallback( + (elevatorId: ElevatorNode['id'] | null) => { + setHoveredElevatorId(elevatorId) + syncDeleteHoveredId(elevatorId) + }, + [syncDeleteHoveredId], + ) + const handleZoneHoverChange = useCallback( (zoneId: ZoneNodeType['id'] | null) => { setHoveredZoneId(zoneId) @@ -13440,11 +13727,13 @@ export function FloorplanPanel() { handleSlabHoverChange(null) handleCeilingHoverChange(null) handleStairHoverChange(null) + handleElevatorHoverChange(null) handleSpawnHoverChange(null) handleZoneHoverChange(null) handleItemHoverChange(itemId) }, [ + handleElevatorHoverChange, handleFenceHoverChange, handleItemHoverChange, handleOpeningHoverChange, @@ -13464,11 +13753,13 @@ export function FloorplanPanel() { handleSlabHoverChange(null) handleCeilingHoverChange(null) handleStairHoverChange(null) + handleElevatorHoverChange(null) handleSpawnHoverChange(null) handleZoneHoverChange(null) handleFenceHoverChange(fenceId) }, [ + handleElevatorHoverChange, handleFenceHoverChange, handleItemHoverChange, handleOpeningHoverChange, @@ -13489,10 +13780,12 @@ export function FloorplanPanel() { handleCeilingHoverChange(null) handleWallHoverChange(null) handleSpawnHoverChange(null) + handleElevatorHoverChange(null) handleZoneHoverChange(null) handleStairHoverChange(stairId) }, [ + handleElevatorHoverChange, handleFenceHoverChange, handleItemHoverChange, handleOpeningHoverChange, @@ -13513,11 +13806,39 @@ export function FloorplanPanel() { handleCeilingHoverChange(null) handleWallHoverChange(null) handleStairHoverChange(null) + handleElevatorHoverChange(null) handleZoneHoverChange(null) handleSpawnHoverChange(spawnId) }, [ handleCeilingHoverChange, + handleElevatorHoverChange, + handleFenceHoverChange, + handleItemHoverChange, + handleOpeningHoverChange, + handleSlabHoverChange, + handleSpawnHoverChange, + handleStairHoverChange, + handleWallHoverChange, + handleZoneHoverChange, + ], + ) + const handleFloorplanElevatorHoverEnter = useCallback( + (elevatorId: ElevatorNode['id']) => { + handleItemHoverChange(null) + handleFenceHoverChange(null) + handleOpeningHoverChange(null) + handleSlabHoverChange(null) + handleCeilingHoverChange(null) + handleWallHoverChange(null) + handleStairHoverChange(null) + handleSpawnHoverChange(null) + handleZoneHoverChange(null) + handleElevatorHoverChange(elevatorId) + }, + [ + handleCeilingHoverChange, + handleElevatorHoverChange, handleFenceHoverChange, handleItemHoverChange, handleOpeningHoverChange, @@ -13615,6 +13936,7 @@ export function FloorplanPanel() { | OpeningNode['id'] | SlabNode['id'] | CeilingNode['id'] + | ElevatorNode['id'] | SpawnNode['id'] | StairNode['id'] | ZoneNodeType['id'], @@ -13629,6 +13951,7 @@ export function FloorplanPanel() { node.type === 'ceiling' || node.type === 'door' || node.type === 'window' || + node.type === 'elevator' || node.type === 'item' || node.type === 'spawn' || node.type === 'stair' || @@ -13852,6 +14175,12 @@ export function FloorplanPanel() { }, [emitFloorplanNodeClick], ) + const handleElevatorSelect = useCallback( + (elevator: ElevatorNode, event: ReactMouseEvent) => { + emitFloorplanNodeClick(elevator.id, 'click', event) + }, + [emitFloorplanNodeClick], + ) const handleZoneLabelClick = useCallback( (zoneId: ZoneNodeType['id'], _event: ReactMouseEvent) => { const currentZoneId = useViewer.getState().selection.zoneId @@ -15648,6 +15977,7 @@ export function FloorplanPanel() { handleCeilingHoverChange(null) handleSpawnHoverChange(null) handleStairHoverChange(null) + handleElevatorHoverChange(null) handleZoneHoverChange(null) setHoveredEndpointId(null) setHoveredSiteHandleId(null) @@ -15661,6 +15991,7 @@ export function FloorplanPanel() { }, [ emitFloorplanWallLeave, handleCeilingHoverChange, + handleElevatorHoverChange, handleItemHoverChange, handleOpeningHoverChange, handleSlabHoverChange, @@ -15772,6 +16103,7 @@ export function FloorplanPanel() { handleSlabHoverChange(null) handleSpawnHoverChange(null) handleStairHoverChange(null) + handleElevatorHoverChange(null) handleZoneHoverChange(null) setHoveredEndpointId(null) floorplanMarqueeSnapPointRef.current = snappedPoint @@ -15788,6 +16120,7 @@ export function FloorplanPanel() { }, [ getPlanPointFromClientPoint, + handleElevatorHoverChange, handleItemHoverChange, handleOpeningHoverChange, handleSlabHoverChange, @@ -16528,6 +16861,20 @@ export function FloorplanPanel() { zonePolygons={visibleZonePolygons} /> + + + 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, ]),