Improve elevator floorplan resize and drag controls

This commit is contained in:
sudhir
2026-05-10 12:20:53 +05:30
parent fdf08c091f
commit 909f96ef09
10 changed files with 1531 additions and 232 deletions
+1
View File
@@ -85,6 +85,7 @@ export {
} from './store/use-live-node-overrides'
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
export { clearSceneHistory, default as useScene } from './store/use-scene'
export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch'
export { syncAutoElevatorOpenings } from './systems/elevator/elevator-opening-sync'
export {
resolveElevatorBuildingLevels,
+6 -1
View File
@@ -42,7 +42,12 @@ export {
ColumnSupportStyle,
} from './nodes/column'
export { DoorNode, DoorSegment } from './nodes/door'
export { ElevatorDoorStyle, ElevatorNode } from './nodes/elevator'
export {
ElevatorDoorPanelStyle,
ElevatorDoorStyle,
ElevatorNode,
ElevatorShaftStyle,
} from './nodes/elevator'
export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence'
export { GuideNode, GuideScaleReference } from './nodes/guide'
export type {
+19 -2
View File
@@ -4,8 +4,12 @@ import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material'
export const ElevatorDoorStyle = z.enum(['center-opening', 'single-left', 'single-right'])
export const ElevatorDoorPanelStyle = z.enum(['glass-frame', 'solid-panel', 'segmented-panel'])
export const ElevatorShaftStyle = z.enum(['solid', 'glass'])
export type ElevatorDoorPanelStyle = z.infer<typeof ElevatorDoorPanelStyle>
export type ElevatorDoorStyle = z.infer<typeof ElevatorDoorStyle>
export type ElevatorShaftStyle = z.infer<typeof ElevatorShaftStyle>
export const ElevatorNode = BaseNode.extend({
id: objectId('elevator'),
@@ -17,13 +21,20 @@ export const ElevatorNode = BaseNode.extend({
rotation: z.number().default(0),
width: z.number().default(1.6),
depth: z.number().default(1.6),
shaftWidth: z.number().optional(),
shaftDepth: z.number().optional(),
shaftWallThickness: z.number().default(0.09),
shaftStyle: ElevatorShaftStyle.default('solid'),
cabHeight: z.number().default(2.35),
doorWidth: z.number().default(0.95),
doorHeight: z.number().default(2.1),
doorStyle: ElevatorDoorStyle.default('center-opening'),
doorPanelStyle: ElevatorDoorPanelStyle.default('glass-frame'),
fromLevelId: z.string().nullable().default(null),
toLevelId: z.string().nullable().default(null),
servedLevelIds: z.array(z.string()).optional(),
disabledLevelIds: z.array(z.string()).default([]),
serviceOnlyLevelIds: z.array(z.string()).default([]),
defaultLevelId: z.string().nullable().default(null),
speed: z.number().default(2.2),
doorDurationMs: z.number().default(900),
@@ -34,11 +45,17 @@ export const ElevatorNode = BaseNode.extend({
- parentId: building that owns this elevator
- position: building-local shaft center on the X/Z plane
- rotation: rotation around the Y axis
- width/depth: shaft and cab footprint
- width/depth: cab footprint
- shaftWidth/shaftDepth: optional clear shaft footprint; falls back to cab footprint
- shaftWallThickness: visible shaft shell thickness
- shaftStyle: solid or glass shaft shell presentation
- cabHeight: visible elevator cab height
- doorWidth/doorHeight/doorStyle: landing and cab door presentation
- doorWidth/doorHeight/doorStyle: landing and cab door movement/opening presentation
- doorPanelStyle: visual leaf style for glass-frame, solid-panel, or segmented-panel doors
- fromLevelId / toLevelId: source and destination levels used for service range and auto cutouts
- servedLevelIds: legacy optional explicit level list; used only when from/to are missing
- disabledLevelIds: stops visible in the service range but unavailable for public/cab requests
- serviceOnlyLevelIds: stops unavailable from landing calls but available from cab/admin controls
- defaultLevelId: starting/resting level, falling back to the lowest served level
- speed/doorDurationMs/dwellMs: runtime animation defaults
`,
@@ -0,0 +1,90 @@
import type { AnyNode, AnyNodeId, ElevatorNode } from '../../schema'
import type { ElevatorInteractiveState } from '../../store/use-interactive'
import { resolveElevatorServiceLevels } from './elevator-service'
type ElevatorRuntimeMap = Record<AnyNodeId, ElevatorInteractiveState>
type ResolveElevatorDispatchTargetArgs = {
elevators: ElevatorRuntimeMap
levelId: AnyNodeId
nodes: Record<string, AnyNode>
requestedElevatorId: AnyNodeId
}
function getRuntimeLevelId(runtime: ElevatorInteractiveState | undefined, elevator: ElevatorNode) {
return runtime?.currentLevelId ?? elevator.defaultLevelId ?? elevator.fromLevelId ?? null
}
function scoreDispatchCandidate({
elevator,
elevators,
levelId,
nodes,
}: {
elevator: ElevatorNode
elevators: ElevatorRuntimeMap
levelId: AnyNodeId
nodes: Record<string, AnyNode>
}) {
if ((elevator.disabledLevelIds ?? []).includes(levelId)) return null
if ((elevator.serviceOnlyLevelIds ?? []).includes(levelId)) return null
const serviceLevels = resolveElevatorServiceLevels(elevator, nodes)
const targetIndex = serviceLevels.findIndex((level) => level.id === levelId)
if (targetIndex < 0) return null
const runtime = elevators[elevator.id as AnyNodeId]
const runtimeLevelId = getRuntimeLevelId(runtime, elevator)
const currentIndex = runtimeLevelId
? serviceLevels.findIndex((level) => level.id === runtimeLevelId)
: -1
const resolvedCurrentIndex = currentIndex >= 0 ? currentIndex : 0
const distance = Math.abs(targetIndex - resolvedCurrentIndex)
const queuePenalty = (runtime?.queue.length ?? 0) * 2 + (runtime?.targetLevelId ? 1 : 0)
const motionPenalty = runtime?.phase === 'moving' || runtime?.phase === 'closing' ? 0.5 : 0
const openPenalty = runtime?.phase === 'open' || runtime?.phase === 'opening' ? 0.2 : 0
return distance + queuePenalty + motionPenalty + openPenalty
}
export function resolveElevatorDispatchTarget({
elevators,
levelId,
nodes,
requestedElevatorId,
}: ResolveElevatorDispatchTargetArgs): AnyNodeId {
const requestedElevator = nodes[requestedElevatorId]
if (!(requestedElevator?.type === 'elevator' && requestedElevator.parentId)) {
return requestedElevatorId
}
const building = nodes[requestedElevator.parentId as AnyNodeId]
if (building?.type !== 'building') {
return requestedElevatorId
}
let bestElevatorId: AnyNodeId | null = null
let bestScore = Number.POSITIVE_INFINITY
for (const childId of building.children) {
const candidate = nodes[childId as AnyNodeId]
if (!(candidate?.type === 'elevator' && candidate.visible !== false)) continue
const score = scoreDispatchCandidate({
elevator: candidate,
elevators,
levelId,
nodes,
})
if (score === null) continue
const tieBreaker = candidate.id === requestedElevatorId ? -0.01 : 0
const finalScore = score + tieBreaker
if (finalScore < bestScore) {
bestScore = finalScore
bestElevatorId = candidate.id as AnyNodeId
}
}
return bestElevatorId ?? requestedElevatorId
}
@@ -11,6 +11,7 @@ type SurfaceHoleMetadata = {
}
const ELEVATOR_OPENING_PADDING = 0.08
const DEFAULT_ELEVATOR_SHAFT_WALL_THICKNESS = 0.09
function pointsEqual(a: Point2D, b: Point2D, tolerance = 1e-5) {
const dx = a[0] - b[0]
@@ -141,8 +142,14 @@ function shouldApplyElevatorToCeiling(
}
function getElevatorOpeningPolygon(elevator: ElevatorNode): Point2D[] {
const halfWidth = Math.max(elevator.width, 0.8) / 2 + ELEVATOR_OPENING_PADDING
const halfDepth = Math.max(elevator.depth, 0.8) / 2 + ELEVATOR_OPENING_PADDING
const wallThickness = Math.max(
elevator.shaftWallThickness ?? DEFAULT_ELEVATOR_SHAFT_WALL_THICKNESS,
0.04,
)
const shaftWidth = Math.max(elevator.shaftWidth ?? elevator.width, elevator.width, 0.8)
const shaftDepth = Math.max(elevator.shaftDepth ?? elevator.depth, elevator.depth, 0.8)
const halfWidth = shaftWidth / 2 + wallThickness + ELEVATOR_OPENING_PADDING
const halfDepth = shaftDepth / 2 + wallThickness + ELEVATOR_OPENING_PADDING
const corners: Point2D[] = [
[-halfWidth, -halfDepth],
[halfWidth, -halfDepth],
@@ -20,6 +20,7 @@ export type FloorplanActionMenuEntry = {
}
type FloorplanActionMenuLayerProps = {
elevator: FloorplanActionMenuEntry
item: FloorplanActionMenuEntry
wall: FloorplanActionMenuEntry
fence: FloorplanActionMenuEntry
@@ -33,6 +34,7 @@ type FloorplanActionMenuLayerProps = {
}
export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
elevator,
item,
wall,
fence,
@@ -55,6 +57,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
}
const entries: FloorplanActionMenuEntry[] = [
elevator,
item,
wall,
fence,
@@ -7,6 +7,7 @@ import {
type ElevatorNode,
emitter,
resolveElevatorBuildingLevels,
resolveElevatorDispatchTarget,
resolveElevatorServiceLevels,
sceneRegistry,
useInteractive,
@@ -129,9 +130,12 @@ type ElevatorColliderUserData = {
levelId?: AnyNodeId
localPosition: [number, number, number]
matrixInitialized?: boolean
side?: 'left' | 'right'
side?: ElevatorDoorSide
}
type ElevatorDoorSide = 'left' | 'right'
type ElevatorDoorStyleValue = ElevatorNode['doorStyle']
type ElevatorColliderMesh = Mesh & {
userData: Mesh['userData'] & ElevatorColliderUserData
}
@@ -164,6 +168,7 @@ function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | n
current.userData as {
elevatorButton?: {
action?: unknown
disabled?: unknown
elevatorId?: unknown
kind?: unknown
levelId?: unknown
@@ -171,6 +176,10 @@ function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | n
}
).elevatorButton
if (candidate?.disabled === true) {
return null
}
if (typeof candidate?.elevatorId === 'string' && candidate.kind === 'cab') {
const action = candidate.action === 'open-door' ? 'open-door' : 'request-level'
if (action === 'open-door') {
@@ -208,9 +217,65 @@ function getInteractableTargetKey(target: FirstPersonInteractableTarget | null)
: `${target.type}:${target.id}`
}
function getElevatorDoorLeafX(side: 'left' | 'right', width: number, doorOpen: number) {
const direction = side === 'left' ? -1 : 1
return direction * (width / 4 + doorOpen * width * 0.34)
function getResolvedElevatorDoorStyle(
doorStyle: ElevatorDoorStyleValue | undefined,
): ElevatorDoorStyleValue {
return doorStyle ?? 'center-opening'
}
function getElevatorDoorLeafSides(
doorStyle: ElevatorDoorStyleValue | undefined,
): ElevatorDoorSide[] {
const resolvedDoorStyle = getResolvedElevatorDoorStyle(doorStyle)
if (resolvedDoorStyle === 'single-left') return ['left']
if (resolvedDoorStyle === 'single-right') return ['right']
return ['left', 'right']
}
function getElevatorDoorLeafWidth(width: number, doorStyle: ElevatorDoorStyleValue | undefined) {
return getResolvedElevatorDoorStyle(doorStyle) === 'center-opening'
? Math.max(width / 2 - 0.018, 0.12)
: Math.max(width - 0.018, 0.18)
}
function getElevatorDoorLeafX(
side: ElevatorDoorSide,
width: number,
doorOpen: number,
doorStyle: ElevatorDoorStyleValue | undefined,
) {
const resolvedDoorStyle = getResolvedElevatorDoorStyle(doorStyle)
if (resolvedDoorStyle === 'center-opening') {
const direction = side === 'left' ? -1 : 1
return direction * (width / 4 + doorOpen * width * 0.34)
}
const direction = resolvedDoorStyle === 'single-left' ? -1 : 1
return direction * doorOpen * width * 0.68
}
function getElevatorCabWidth(elevator: ElevatorNode) {
return Math.max(elevator.width, 0.8)
}
function getElevatorCabDepth(elevator: ElevatorNode) {
return Math.max(elevator.depth, 0.8)
}
function getElevatorShaftWallThickness(elevator: ElevatorNode) {
return Math.max(elevator.shaftWallThickness ?? ELEVATOR_COLLIDER_WALL_THICKNESS, 0.04)
}
function getElevatorShaftWidth(elevator: ElevatorNode, cabWidth = getElevatorCabWidth(elevator)) {
return Math.max(elevator.shaftWidth ?? cabWidth, cabWidth, 0.8)
}
function getElevatorShaftDepth(elevator: ElevatorNode, cabDepth = getElevatorCabDepth(elevator)) {
return Math.max(elevator.shaftDepth ?? cabDepth, cabDepth, 0.8)
}
function getElevatorCabCenterZ(elevator: ElevatorNode) {
return -getElevatorShaftDepth(elevator) / 2 + getElevatorCabDepth(elevator) / 2
}
function isDynamicElevatorCollider(kind: ElevatorColliderKind) {
@@ -222,13 +287,14 @@ function isInsideElevatorCab(
runtime: NonNullable<ReturnType<typeof useInteractive.getState>['elevators'][AnyNodeId]>,
localEyePosition: Vector3,
) {
const halfWidth = Math.max(elevator.width, 0.8) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
const halfDepth = Math.max(elevator.depth, 0.8) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
const halfWidth = getElevatorCabWidth(elevator) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
const halfDepth = getElevatorCabDepth(elevator) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
const cabCenterZ = getElevatorCabCenterZ(elevator)
const cabHeight = Math.max(elevator.cabHeight, 1.4)
return (
Math.abs(localEyePosition.x) <= Math.max(halfWidth, 0.24) &&
Math.abs(localEyePosition.z) <= Math.max(halfDepth, 0.24) &&
Math.abs(localEyePosition.z - cabCenterZ) <= Math.max(halfDepth, 0.24) &&
localEyePosition.y >= runtime.carY + 0.35 &&
localEyePosition.y <= runtime.carY + cabHeight + 0.7
)
@@ -347,18 +413,23 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] {
node,
nodes,
)
const shaftWidth = Math.max(node.width, 0.8)
const shaftDepth = Math.max(node.depth, 0.8)
const cabWidth = getElevatorCabWidth(node)
const cabDepth = getElevatorCabDepth(node)
const shaftWidth = getElevatorShaftWidth(node, cabWidth)
const shaftDepth = getElevatorShaftDepth(node, cabDepth)
const cabHeight = Math.max(node.cabHeight, 1.4)
const doorWidth = Math.min(Math.max(node.doorWidth, 0.45), shaftWidth - 0.18)
const doorWidth = Math.min(Math.max(node.doorWidth, 0.45), cabWidth - 0.18, shaftWidth - 0.18)
const doorHeight = Math.min(Math.max(node.doorHeight, 1.2), cabHeight - 0.1)
const doorStyle = getResolvedElevatorDoorStyle(node.doorStyle)
const shaftHeight = Math.max(totalHeight, cabHeight + 0.3)
const wallThickness = ELEVATOR_COLLIDER_WALL_THICKNESS
const cabFloorWidth = Math.max(shaftWidth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48)
const cabFloorDepth = Math.max(shaftDepth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48)
const wallThickness = getElevatorShaftWallThickness(node)
const cabFloorWidth = Math.max(cabWidth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48)
const cabFloorDepth = Math.max(cabDepth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48)
const frontWallZ = -shaftDepth / 2 - wallThickness / 2
const frontZ = frontWallZ - wallThickness / 2 - 0.018
const leafWidth = Math.max(doorWidth / 2 - 0.018, 0.12)
const cabCenterZ = -shaftDepth / 2 + cabDepth / 2
const leafWidth = getElevatorDoorLeafWidth(doorWidth, doorStyle)
const doorLeafSides = getElevatorDoorLeafSides(doorStyle)
const resolvedShaftTopY = Math.max(shaftTopY, shaftBaseY + shaftHeight)
meshes.push(
@@ -390,38 +461,31 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] {
typedElevatorId,
'cab-floor',
[cabFloorWidth, ELEVATOR_COLLIDER_FLOOR_THICKNESS, cabFloorDepth],
[0, ELEVATOR_COLLIDER_FLOOR_THICKNESS / 2, 0],
[0, ELEVATOR_COLLIDER_FLOOR_THICKNESS / 2, cabCenterZ],
),
createElevatorColliderMesh(
typedElevatorId,
'cab-ceiling',
[shaftWidth, wallThickness, shaftDepth],
[0, cabHeight - wallThickness / 2, 0],
[cabWidth, wallThickness, cabDepth],
[0, cabHeight - wallThickness / 2, cabCenterZ],
),
createElevatorColliderMesh(
typedElevatorId,
'cab-back',
[shaftWidth, cabHeight, wallThickness],
[0, cabHeight / 2, shaftDepth / 2 - wallThickness / 2],
[cabWidth, cabHeight, wallThickness],
[0, cabHeight / 2, cabCenterZ + cabDepth / 2 - wallThickness / 2],
),
createElevatorColliderMesh(
typedElevatorId,
'cab-left',
[wallThickness, cabHeight, shaftDepth],
[-shaftWidth / 2 + wallThickness / 2, cabHeight / 2, 0],
[wallThickness, cabHeight, cabDepth],
[-cabWidth / 2 + wallThickness / 2, cabHeight / 2, cabCenterZ],
),
createElevatorColliderMesh(
typedElevatorId,
'cab-right',
[wallThickness, cabHeight, shaftDepth],
[shaftWidth / 2 - wallThickness / 2, cabHeight / 2, 0],
),
createElevatorColliderMesh(
typedElevatorId,
'cab-door-left',
[leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
[0, doorHeight / 2, frontZ],
{ doorWidth, side: 'left' },
[wallThickness, cabHeight, cabDepth],
[cabWidth / 2 - wallThickness / 2, cabHeight / 2, cabCenterZ],
),
createElevatorColliderMesh(
typedElevatorId,
@@ -430,12 +494,14 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] {
[0, doorHeight / 2, frontZ],
{ doorWidth },
),
createElevatorColliderMesh(
typedElevatorId,
'cab-door-right',
[leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
[0, doorHeight / 2, frontZ],
{ doorWidth, side: 'right' },
...doorLeafSides.map((side) =>
createElevatorColliderMesh(
typedElevatorId,
side === 'left' ? 'cab-door-left' : 'cab-door-right',
[leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
[0, doorHeight / 2, frontZ],
{ doorWidth, side },
),
),
)
@@ -473,13 +539,6 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] {
[shaftWidth, headerHeight, wallDepth],
[0, entry.baseY + doorHeight + headerHeight / 2, frontWallZ],
),
createElevatorColliderMesh(
typedElevatorId,
'landing-door-left',
[leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
[0, entry.baseY + doorHeight / 2, frontZ - 0.02],
{ doorWidth, levelId: entry.id, side: 'left' },
),
createElevatorColliderMesh(
typedElevatorId,
'landing-door-gate',
@@ -487,12 +546,14 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] {
[0, entry.baseY + doorHeight / 2, frontZ - 0.02],
{ doorWidth, levelId: entry.id },
),
createElevatorColliderMesh(
typedElevatorId,
'landing-door-right',
[leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
[0, entry.baseY + doorHeight / 2, frontZ - 0.02],
{ doorWidth, levelId: entry.id, side: 'right' },
...doorLeafSides.map((side) =>
createElevatorColliderMesh(
typedElevatorId,
side === 'left' ? 'landing-door-left' : 'landing-door-right',
[leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
[0, entry.baseY + doorHeight / 2, frontZ - 0.02],
{ doorWidth, levelId: entry.id, side },
),
),
)
}
@@ -778,7 +839,18 @@ export const FirstPersonControls = () => {
useInteractive.getState().openElevatorDoor(target.id)
return
}
if (target.levelId) useInteractive.getState().requestElevator(target.id, target.levelId)
if (target.levelId) {
const targetElevatorId =
target.buttonKind === 'landing'
? resolveElevatorDispatchTarget({
elevators: useInteractive.getState().elevators,
levelId: target.levelId,
nodes: useScene.getState().nodes,
requestedElevatorId: target.id,
})
: target.id
useInteractive.getState().requestElevator(targetElevatorId, target.levelId)
}
return
}
@@ -1004,7 +1076,12 @@ export const FirstPersonControls = () => {
mesh.visible = false
continue
}
localX = getElevatorDoorLeafX(side ?? 'left', doorWidth ?? node.doorWidth, runtime.doorOpen)
localX = getElevatorDoorLeafX(
side ?? 'left',
doorWidth ?? node.doorWidth,
runtime.doorOpen,
node.doorStyle,
)
mesh.visible = true
} else if (isCabDoorGate) {
if (!runtime) {
@@ -1014,7 +1091,12 @@ export const FirstPersonControls = () => {
mesh.visible = runtime.doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD
} else if (isDoorCollider) {
const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0
localX = getElevatorDoorLeafX(side ?? 'left', doorWidth ?? node.doorWidth, doorOpen)
localX = getElevatorDoorLeafX(
side ?? 'left',
doorWidth ?? node.doorWidth,
doorOpen,
node.doorStyle,
)
mesh.visible = true
} else if (isLandingDoorGate) {
const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0
@@ -1045,6 +1127,7 @@ export const FirstPersonControls = () => {
const activeRide = ridingElevatorRef.current
let nextRide: {
cabHeight: number
cabCenterZ: number
carY: number
doorOpen: number
elevatorId: AnyNodeId
@@ -1076,12 +1159,13 @@ export const FirstPersonControls = () => {
elevatorLocalEyePosition.copy(camera.position)
object.worldToLocal(elevatorLocalEyePosition)
const halfWidth = Math.max(node.width, 0.8) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
const halfDepth = Math.max(node.depth, 0.8) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
const halfWidth = getElevatorCabWidth(node) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
const halfDepth = getElevatorCabDepth(node) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
const cabCenterZ = getElevatorCabCenterZ(node)
const cabHeight = Math.max(node.cabHeight, 1.4)
const insideFootprint =
Math.abs(elevatorLocalEyePosition.x) <= Math.max(halfWidth, 0.24) &&
Math.abs(elevatorLocalEyePosition.z) <= Math.max(halfDepth, 0.24)
Math.abs(elevatorLocalEyePosition.z - cabCenterZ) <= Math.max(halfDepth, 0.24)
const insideCabHeight =
elevatorLocalEyePosition.y >= runtime.carY + 0.35 &&
elevatorLocalEyePosition.y <= runtime.carY + cabHeight + 0.7
@@ -1094,6 +1178,7 @@ export const FirstPersonControls = () => {
if ((insideFootprint && insideCabHeight) || continuingRide) {
nextRide = {
cabHeight,
cabCenterZ,
carY: runtime.carY,
doorOpen: runtime.doorOpen,
elevatorId: typedElevatorId,
@@ -1151,8 +1236,8 @@ export const FirstPersonControls = () => {
Math.min(nextRide.halfWidth, elevatorLocalControllerPosition.x),
)
const clampedZ = Math.max(
-nextRide.halfDepth,
Math.min(nextRide.halfDepth, elevatorLocalControllerPosition.z),
nextRide.cabCenterZ - nextRide.halfDepth,
Math.min(nextRide.cabCenterZ + nextRide.halfDepth, elevatorLocalControllerPosition.z),
)
if (
@@ -40,6 +40,7 @@ import {
sampleWallCenterline,
sceneRegistry,
useInteractive,
useLiveNodeOverrides,
useLiveTransforms,
useScene,
type WallNode,
@@ -346,6 +347,21 @@ type PendingFenceDragState = {
startClientY: number
}
type ElevatorResizeHandle =
| 'width-negative'
| 'width-positive'
| 'depth-negative'
| 'depth-positive'
type ElevatorResizeDragState = {
center: Point2D
elevatorId: ElevatorNode['id']
handle: ElevatorResizeHandle
pointerId: number
rotation: number
shaftWallThickness: number
}
const GUIDE_CORNERS = ['nw', 'ne', 'se', 'sw'] as const
type GuideCorner = (typeof GUIDE_CORNERS)[number]
@@ -634,16 +650,38 @@ type FloorplanColumnEntry = {
polygon: Point2D[]
}
type FloorplanElevatorServedLevel = {
id: LevelNode['id']
isCurrent: boolean
isDisabled: boolean
isQueued: boolean
isServiceOnly: boolean
isTarget: boolean
label: string
}
type FloorplanElevatorEntry = {
cabCenterLocalY: number
cabDepth: number
cabWidth: number
center: Point2D
doorStyle: ElevatorNode['doorStyle']
doorWidth: number
elevator: ElevatorNode
frontEdge: FloorplanLineSegment
frontNormal: Point2D
isCarOnLevel: boolean
isQueuedLevel: boolean
isTargetLevel: boolean
outerHalfDepth: number
outerHalfWidth: number
points: string
polygon: Point2D[]
rotation: number
servedLevels: FloorplanElevatorServedLevel[]
shaftDepth: number
shaftWallThickness: number
shaftWidth: number
}
type ReferenceFloorData = {
@@ -822,6 +860,18 @@ function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
function roundPlanMeters(value: number) {
return Math.round(value * 100) / 100
}
function getElevatorResizeAxis(handle: ElevatorResizeHandle) {
return handle.startsWith('width') ? 'width' : 'depth'
}
function getElevatorResizeSign(handle: ElevatorResizeHandle) {
return handle.endsWith('positive') ? 1 : -1
}
function getSelectionModifierKeys(event?: { metaKey?: boolean; ctrlKey?: boolean }) {
return {
meta: Boolean(event?.metaKey),
@@ -6029,6 +6079,10 @@ const FloorplanElevatorLayer = memo(function FloorplanElevatorLayer({
isDeleteMode,
onElevatorHoverChange,
onElevatorHoverEnter,
onElevatorPointerDown,
onElevatorResizePointerDown,
onElevatorResizePointerMove,
onElevatorResizePointerUp,
onElevatorSelect,
palette,
selectedIdSet,
@@ -6041,6 +6095,17 @@ const FloorplanElevatorLayer = memo(function FloorplanElevatorLayer({
isDeleteMode: boolean
onElevatorHoverChange: (elevatorId: ElevatorNode['id'] | null) => void
onElevatorHoverEnter: (elevatorId: ElevatorNode['id']) => void
onElevatorPointerDown: (
elevatorId: ElevatorNode['id'],
event: ReactPointerEvent<SVGElement>,
) => void
onElevatorResizePointerDown: (
entry: FloorplanElevatorEntry,
handle: ElevatorResizeHandle,
event: ReactPointerEvent<SVGCircleElement>,
) => void
onElevatorResizePointerMove: (event: ReactPointerEvent<SVGCircleElement>) => void
onElevatorResizePointerUp: (event: ReactPointerEvent<SVGCircleElement>) => void
onElevatorSelect: (elevator: ElevatorNode, event: ReactMouseEvent<SVGElement>) => void
palette: FloorplanPalette
selectedIdSet: ReadonlySet<string>
@@ -6060,37 +6125,100 @@ const FloorplanElevatorLayer = memo(function FloorplanElevatorLayer({
const isDeleteHovered = isDeleteMode && isHovered
const isActive = isSelected || isHighlighted
const showChrome = isActive || isHovered
const fill = isDeleteHovered
const isGlassShaft = elevator.shaftStyle === 'glass'
const shaftShellFill = isDeleteHovered
? palette.deleteFill
: isActive
? `url(#${wallSelectionHatchId})`
: '#dbeafe'
const fillOpacity = isDeleteHovered ? 0.38 : isActive ? 0.84 : isHovered ? 0.78 : 0.64
: isGlassShaft
? '#dff6ff'
: '#e5e7eb'
const shaftClearFill = isGlassShaft ? '#ecfeff' : '#f8fafc'
const shaftShellOpacity = isDeleteHovered ? 0.38 : isActive ? 0.9 : isHovered ? 0.86 : 0.76
const stroke = isDeleteHovered
? palette.deleteStroke
: isActive
? palette.selectedStroke
: isHovered
? palette.wallHoverStroke
: '#2563eb'
: isGlassShaft
? '#0891b2'
: '#475569'
const doorStroke = isDeleteHovered
? palette.deleteStroke
: isActive
? palette.selectedStroke
: '#0284c7'
: '#0369a1'
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 rotationDeg = (-entry.rotation * 180) / Math.PI
const shaftWidth = entry.outerHalfWidth * 2
const shaftDepth = entry.outerHalfDepth * 2
const shaftClearX = -entry.shaftWidth / 2
const shaftClearY = -entry.shaftDepth / 2
const cabX = -entry.cabWidth / 2
const cabY = entry.cabCenterLocalY - entry.cabDepth / 2
const frontLocalY = -entry.outerHalfDepth
const doorHalfWidth = entry.doorWidth / 2
const doorTrackY = frontLocalY - 0.075
const callStationX = Math.min(entry.outerHalfWidth - 0.12, doorHalfWidth + 0.2)
const callStationY = frontLocalY - 0.16
const cabFill = entry.isCarOnLevel
? '#dcfce7'
: entry.isTargetLevel || entry.isQueuedLevel
? '#e0f2fe'
: '#f8fafc'
const cabStroke = entry.isCarOnLevel
? '#16a34a'
: entry.isTargetLevel || entry.isQueuedLevel
? '#0ea5e9'
: '#64748b'
const showCarMarker = entry.isCarOnLevel || entry.isTargetLevel || entry.isQueuedLevel
const carFill = entry.isCarOnLevel ? '#22c55e' : '#ffffff'
const carStroke = entry.isCarOnLevel ? '#15803d' : '#0ea5e9'
const resizeHandles = [
{
cursor: 'ew-resize',
handle: 'width-negative' as const,
localX: -entry.outerHalfWidth,
localY: 0,
},
{
cursor: 'ew-resize',
handle: 'width-positive' as const,
localX: entry.outerHalfWidth,
localY: 0,
},
{
cursor: 'ns-resize',
handle: 'depth-negative' as const,
localX: 0,
localY: -entry.outerHalfDepth,
},
{
cursor: 'ns-resize',
handle: 'depth-positive' as const,
localX: 0,
localY: entry.outerHalfDepth,
},
].map((handle) => {
const [offsetX, offsetY] = rotatePlanVector(handle.localX, handle.localY, entry.rotation)
return {
...handle,
x: entry.center.x + offsetX,
y: entry.center.y + offsetY,
}
})
const rangeStep = 0.18
const rangeHeight = Math.max(0, (entry.servedLevels.length - 1) * rangeStep)
const [rangeOffsetX, rangeOffsetY] = rotatePlanVector(
entry.outerHalfWidth + 0.38,
0,
entry.rotation,
)
const rangeX = entry.center.x + rangeOffsetX
const rangeTopY = entry.center.y + rangeOffsetY - rangeHeight / 2
const rangeBottomY = entry.center.y + rangeOffsetY + rangeHeight / 2
return (
<g
@@ -6112,53 +6240,154 @@ const FloorplanElevatorLayer = memo(function FloorplanElevatorLayer({
vectorEffect="non-scaling-stroke"
/>
) : null}
<polygon
fill={fill}
fillOpacity={fillOpacity}
points={entry.points}
<g
pointerEvents="none"
stroke={stroke}
strokeLinejoin="round"
strokeWidth={isActive ? '2.4' : isHovered ? '2.1' : '1.45'}
vectorEffect="non-scaling-stroke"
/>
<line
pointerEvents="none"
stroke={doorStroke}
strokeLinecap="round"
strokeWidth={isActive ? '3.2' : '2.4'}
vectorEffect="non-scaling-stroke"
x1={toSvgX(entry.frontEdge.start.x)}
x2={toSvgX(entry.frontEdge.end.x)}
y1={toSvgY(entry.frontEdge.start.y)}
y2={toSvgY(entry.frontEdge.end.y)}
/>
<line
pointerEvents="none"
stroke={doorStroke}
strokeLinecap="round"
strokeOpacity={0.84}
strokeWidth="1.7"
vectorEffect="non-scaling-stroke"
x1={toSvgX(frontCenter.x)}
x2={toSvgX(doorIndicatorEnd.x)}
y1={toSvgY(frontCenter.y)}
y2={toSvgY(doorIndicatorEnd.y)}
/>
{showCarMarker ? (
transform={`translate(${centerX} ${centerY}) rotate(${rotationDeg})`}
>
<rect
fill={shaftShellFill}
fillOpacity={shaftShellOpacity}
height={shaftDepth}
stroke={stroke}
strokeLinejoin="round"
strokeWidth={isActive ? '2.4' : isHovered ? '2.1' : '1.45'}
vectorEffect="non-scaling-stroke"
width={shaftWidth}
x={-entry.outerHalfWidth}
y={-entry.outerHalfDepth}
/>
<rect
fill={shaftClearFill}
fillOpacity={isGlassShaft ? 0.46 : 0.94}
height={entry.shaftDepth}
stroke={isGlassShaft ? '#67e8f9' : '#cbd5e1'}
strokeDasharray={isGlassShaft ? '2 2' : undefined}
strokeWidth="1"
vectorEffect="non-scaling-stroke"
width={entry.shaftWidth}
x={shaftClearX}
y={shaftClearY}
/>
<rect
fill={cabFill}
fillOpacity={0.96}
height={entry.cabDepth}
stroke={cabStroke}
strokeDasharray={entry.isTargetLevel && !entry.isCarOnLevel ? '3 2' : undefined}
strokeWidth={entry.isCarOnLevel ? '1.7' : '1.25'}
vectorEffect="non-scaling-stroke"
width={entry.cabWidth}
x={cabX}
y={cabY}
/>
<line
stroke="#94a3b8"
strokeLinecap="round"
strokeOpacity={0.7}
strokeWidth="1"
vectorEffect="non-scaling-stroke"
x1={cabX + 0.12}
x2={cabX + entry.cabWidth - 0.12}
y1={cabY + entry.cabDepth - 0.12}
y2={cabY + entry.cabDepth - 0.12}
/>
<line
stroke={stroke}
strokeLinecap="round"
strokeWidth={isActive ? '3.2' : '2.7'}
vectorEffect="non-scaling-stroke"
x1={-entry.outerHalfWidth}
x2={-doorHalfWidth}
y1={frontLocalY}
y2={frontLocalY}
/>
<line
stroke={stroke}
strokeLinecap="round"
strokeWidth={isActive ? '3.2' : '2.7'}
vectorEffect="non-scaling-stroke"
x1={doorHalfWidth}
x2={entry.outerHalfWidth}
y1={frontLocalY}
y2={frontLocalY}
/>
<line
stroke={doorStroke}
strokeLinecap="round"
strokeOpacity={0.92}
strokeWidth="1.75"
vectorEffect="non-scaling-stroke"
x1={-doorHalfWidth}
x2={doorHalfWidth}
y1={doorTrackY}
y2={doorTrackY}
/>
{entry.doorStyle === 'center-opening' ? (
<>
<path
d={`M ${-doorHalfWidth + 0.06} ${doorTrackY - 0.055} L ${-0.05} ${doorTrackY - 0.055}`}
fill="none"
stroke={doorStroke}
strokeLinecap="round"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
<path
d={`M ${doorHalfWidth - 0.06} ${doorTrackY - 0.055} L ${0.05} ${doorTrackY - 0.055}`}
fill="none"
stroke={doorStroke}
strokeLinecap="round"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
</>
) : (
<path
d={
entry.doorStyle === 'single-left'
? `M ${doorHalfWidth - 0.06} ${doorTrackY - 0.055} L ${-doorHalfWidth + 0.06} ${doorTrackY - 0.055}`
: `M ${-doorHalfWidth + 0.06} ${doorTrackY - 0.055} L ${doorHalfWidth - 0.06} ${doorTrackY - 0.055}`
}
fill="none"
stroke={doorStroke}
strokeLinecap="round"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
)}
<rect
fill="#111827"
height={0.16}
stroke={isDeleteHovered ? palette.deleteStroke : '#334155'}
strokeWidth="0.7"
vectorEffect="non-scaling-stroke"
width={0.1}
x={callStationX - 0.05}
y={callStationY - 0.08}
/>
<circle
cx={centerX}
cy={centerY}
fill={carFill}
fillOpacity={entry.isCarOnLevel ? 0.92 : 0.82}
pointerEvents="none"
r={entry.isCarOnLevel ? 0.14 : 0.11}
stroke={carStroke}
strokeDasharray={entry.isCarOnLevel ? undefined : '3 2'}
strokeWidth="1.8"
cx={callStationX}
cy={callStationY - 0.025}
fill={entry.isQueuedLevel || entry.isTargetLevel ? '#22c55e' : '#e2e8f0'}
r={0.025}
stroke="#f8fafc"
strokeWidth="0.5"
vectorEffect="non-scaling-stroke"
/>
) : null}
{showCarMarker ? (
<circle
cx={0}
cy={entry.cabCenterLocalY}
fill={carFill}
fillOpacity={entry.isCarOnLevel ? 0.96 : 0.84}
r={entry.isCarOnLevel ? 0.13 : 0.1}
stroke={carStroke}
strokeDasharray={entry.isCarOnLevel ? undefined : '3 2'}
strokeWidth="1.7"
vectorEffect="non-scaling-stroke"
/>
) : null}
</g>
<polygon
fill="transparent"
onClick={
@@ -6169,12 +6398,95 @@ const FloorplanElevatorLayer = memo(function FloorplanElevatorLayer({
}
: undefined
}
onPointerDown={
canSelectElevators && isSelected
? (event) => {
if (event.button === 0) {
onElevatorPointerDown(elevator.id, event)
}
}
: undefined
}
points={entry.points}
pointerEvents={canSelectElevators ? 'all' : 'none'}
style={canSelectElevators ? { cursor: EDITOR_CURSOR } : undefined}
>
<title>{elevator.name || 'Elevator'}</title>
</polygon>
{isSelected && entry.servedLevels.length > 1 ? (
<g pointerEvents="none">
<line
stroke="#0ea5e9"
strokeLinecap="round"
strokeOpacity={0.52}
strokeWidth="1.6"
vectorEffect="non-scaling-stroke"
x1={toSvgX(rangeX)}
x2={toSvgX(rangeX)}
y1={toSvgY(rangeTopY)}
y2={toSvgY(rangeBottomY)}
/>
{entry.servedLevels.map((level, index) => {
const y = rangeBottomY - index * rangeStep
const isUnavailable = level.isDisabled || level.isServiceOnly
const markerFill = level.isCurrent
? '#22c55e'
: level.isTarget || level.isQueued
? '#38bdf8'
: isUnavailable
? '#94a3b8'
: '#ffffff'
const markerStroke = isUnavailable ? '#64748b' : '#0369a1'
return (
<g key={level.id}>
<circle
cx={toSvgX(rangeX)}
cy={toSvgY(y)}
fill={markerFill}
fillOpacity={isUnavailable ? 0.72 : 0.95}
r={0.055}
stroke={markerStroke}
strokeWidth="1.2"
vectorEffect="non-scaling-stroke"
/>
<text
dominantBaseline="middle"
fill={isUnavailable ? '#64748b' : '#075985'}
fontSize="0.13"
fontWeight={700}
textAnchor="start"
x={toSvgX(rangeX + 0.11)}
y={toSvgY(y)}
>
{index + 1}
</text>
</g>
)
})}
</g>
) : null}
{isSelected && canSelectElevators && !isDeleteMode
? resizeHandles.map((handle) => (
<circle
cx={toSvgX(handle.x)}
cy={toSvgY(handle.y)}
fill="#ffffff"
key={handle.handle}
onPointerCancel={onElevatorResizePointerUp}
onPointerDown={(event) =>
onElevatorResizePointerDown(entry, handle.handle, event)
}
onPointerMove={onElevatorResizePointerMove}
onPointerUp={onElevatorResizePointerUp}
r={0.075}
stroke="#0284c7"
strokeWidth="1.7"
style={{ cursor: handle.cursor }}
vectorEffect="non-scaling-stroke"
/>
))
: null}
</g>
)
})}
@@ -7821,6 +8133,8 @@ export function FloorplanPanel() {
const [hoveredSpawnId, setHoveredSpawnId] = useState<SpawnNode['id'] | null>(null)
const [hoveredStairId, setHoveredStairId] = useState<StairNode['id'] | null>(null)
const [hoveredElevatorId, setHoveredElevatorId] = useState<ElevatorNode['id'] | null>(null)
const [elevatorResizeDragState, setElevatorResizeDragState] =
useState<ElevatorResizeDragState | null>(null)
const [hoveredZoneId, setHoveredZoneId] = useState<ZoneNodeType['id'] | null>(null)
const [hoveredEndpointId, setHoveredEndpointId] = useState<string | null>(null)
const [hoveredWallCurveHandleId, setHoveredWallCurveHandleId] = useState<string | null>(null)
@@ -7868,6 +8182,29 @@ export function FloorplanPanel() {
[elevatorIds],
),
)
const elevatorLiveOverrideKey = useLiveNodeOverrides(
useCallback(
(state) =>
elevatorIds
.map((elevatorId) => {
const overrides = state.overrides.get(elevatorId)
if (!overrides) {
return `${elevatorId}:`
}
return [
elevatorId,
overrides.width ?? '',
overrides.depth ?? '',
overrides.shaftWidth ?? '',
overrides.shaftDepth ?? '',
overrides.shaftWallThickness ?? '',
].join(':')
})
.join('|'),
[elevatorIds],
),
)
const [stairBuildPreviewPoint, setStairBuildPreviewPoint] = useState<WallPlanPoint | null>(null)
const [stairBuildPreviewRotation, setStairBuildPreviewRotation] = useState(0)
const [isPanning, setIsPanning] = useState(false)
@@ -8495,6 +8832,11 @@ export function FloorplanPanel() {
})
}, [cursorPoint, floorplanItems, levelDescendantNodeById, movingFloorplanNodeRevision])
const floorplanElevatorEntries = useMemo<FloorplanElevatorEntry[]>(() => {
// These keys subscribe the memo to imperative floorplan stores read with getState().
void elevatorLiveOverrideKey
void elevatorRuntimeKey
void movingFloorplanNodeRevision
if (!levelNode) {
return []
}
@@ -8503,17 +8845,39 @@ export function FloorplanPanel() {
const interactiveElevators = useInteractive.getState().elevators
return elevators.flatMap((elevator) => {
const serviceLevelIds = resolveElevatorServiceLevelIds(elevator, nodes)
const liveOverrides = useLiveNodeOverrides.getState().get(elevator.id)
const displayElevator = liveOverrides
? ({ ...elevator, ...liveOverrides } as ElevatorNode)
: elevator
const serviceLevelIds = resolveElevatorServiceLevelIds(displayElevator, 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 live = useLiveTransforms.getState().get(displayElevator.id)
const position = live?.position ?? displayElevator.position
const rotation = live?.rotation ?? displayElevator.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 wallThickness = Math.max(displayElevator.shaftWallThickness ?? 0.09, 0.04)
const cabWidth = Math.max(displayElevator.width, 0.8)
const cabDepth = Math.max(displayElevator.depth, 0.8)
const shaftWidth = Math.max(
displayElevator.shaftWidth ?? displayElevator.width,
cabWidth,
0.8,
)
const shaftDepth = Math.max(
displayElevator.shaftDepth ?? displayElevator.depth,
cabDepth,
0.8,
)
const doorWidth = Math.min(
Math.max(displayElevator.doorWidth, 0.45),
cabWidth - 0.18,
shaftWidth - 0.18,
)
const halfWidth = Math.max(0.1, shaftWidth / 2 + wallThickness)
const halfDepth = Math.max(0.1, shaftDepth / 2 + wallThickness)
const footprintCorners: Array<readonly [number, number]> = [
[-halfWidth, -halfDepth],
[halfWidth, -halfDepth],
@@ -8533,12 +8897,37 @@ export function FloorplanPanel() {
return []
}
const [frontNormalX, frontNormalY] = rotatePlanVector(0, -1, rotation)
const runtime = interactiveElevators[elevator.id]
const runtime = interactiveElevators[displayElevator.id]
const disabledLevelIds = new Set(displayElevator.disabledLevelIds ?? [])
const serviceOnlyLevelIds = new Set(displayElevator.serviceOnlyLevelIds ?? [])
const servedLevels = serviceLevelIds.flatMap((levelId) => {
const level = nodes[levelId as AnyNodeId]
if (level?.type !== 'level') {
return []
}
return [
{
id: level.id,
isCurrent: runtime?.currentLevelId === level.id,
isDisabled: disabledLevelIds.has(level.id),
isQueued: runtime?.queue.includes(level.id) ?? false,
isServiceOnly: serviceOnlyLevelIds.has(level.id),
isTarget: runtime?.targetLevelId === level.id,
label: level.name || `L${level.level}`,
},
]
})
return [
{
cabCenterLocalY: -shaftDepth / 2 + cabDepth / 2,
cabDepth,
cabWidth,
center,
elevator,
doorStyle: displayElevator.doorStyle ?? 'center-opening',
doorWidth,
elevator: displayElevator,
frontEdge: {
start: frontStart,
end: frontEnd,
@@ -8550,12 +8939,25 @@ export function FloorplanPanel() {
isCarOnLevel: runtime?.currentLevelId === levelNode.id,
isQueuedLevel: runtime?.queue.includes(levelNode.id) ?? false,
isTargetLevel: runtime?.targetLevelId === levelNode.id,
outerHalfDepth: halfDepth,
outerHalfWidth: halfWidth,
points: formatPolygonPoints(polygon),
polygon,
rotation,
servedLevels,
shaftDepth,
shaftWallThickness: wallThickness,
shaftWidth,
},
]
})
}, [elevatorRuntimeKey, elevators, levelNode, movingFloorplanNodeRevision])
}, [
elevatorLiveOverrideKey,
elevatorRuntimeKey,
elevators,
levelNode,
movingFloorplanNodeRevision,
])
const referenceFloorLevel = useMemo(() => {
if (!(showReferenceFloor && levelNode)) {
return null
@@ -8915,6 +9317,13 @@ export function FloorplanPanel() {
return floorplanSpawnEntries.find(({ spawn }) => spawn.id === selectedIds[0]) ?? null
}, [floorplanSpawnEntries, selectedIds])
const selectedElevatorEntry = useMemo(() => {
if (selectedIds.length !== 1) {
return null
}
return floorplanElevatorEntries.find(({ elevator }) => elevator.id === selectedIds[0]) ?? null
}, [floorplanElevatorEntries, selectedIds])
const selectedItemClearanceMeasurements = useMemo(() => {
if (!selectedItemEntry) {
return [] as LinearMeasurementOverlay[]
@@ -9249,6 +9658,7 @@ export function FloorplanPanel() {
const isFenceMoveActive = movingNode?.type === 'fence'
const isWallMoveActive = movingNode?.type === 'wall'
const isSpawnMoveActive = movingNode?.type === 'spawn'
const isElevatorMoveActive = movingNode?.type === 'elevator'
const isWallCurveActive = curvingWall?.type === 'wall'
const isFenceCurveActive = curvingFence?.type === 'fence'
const isFenceEndpointMoveActive = movingFenceEndpoint !== null
@@ -9268,6 +9678,7 @@ export function FloorplanPanel() {
isFenceMoveActive ||
isWallMoveActive ||
isSpawnMoveActive ||
isElevatorMoveActive ||
isWallCurveActive ||
isFenceCurveActive ||
isFenceEndpointMoveActive ||
@@ -10328,6 +10739,18 @@ export function FloorplanPanel() {
floorplanSceneRotationDeg,
)
}, [floorplanSceneRotationDeg, selectedSpawnEntry, surfaceSize, viewBox])
const selectedElevatorActionMenuPosition = useMemo(
() =>
selectedElevatorEntry
? getFloorplanActionMenuPosition(
selectedElevatorEntry.polygon,
viewBox,
surfaceSize,
floorplanSceneRotationDeg,
)
: null,
[floorplanSceneRotationDeg, selectedElevatorEntry, surfaceSize, viewBox],
)
const selectedSlabActionMenuPosition = useMemo(() => {
if (slabHoleMoveDraft) {
return null
@@ -10899,6 +11322,115 @@ export function FloorplanPanel() {
},
[getSvgPointFromClientPoint, buildingRotationY],
)
const previewElevatorResize = useCallback(
(dragState: ElevatorResizeDragState, planPoint: WallPlanPoint) => {
const localDeltaX = planPoint[0] - dragState.center.x
const localDeltaY = planPoint[1] - dragState.center.y
const [localX, localY] = rotatePlanVector(localDeltaX, localDeltaY, -dragState.rotation)
const axis = getElevatorResizeAxis(dragState.handle)
const sign = getElevatorResizeSign(dragState.handle)
const localDistance = sign * (axis === 'width' ? localX : localY)
const nextOuterSize = Math.max(0.1, localDistance) * 2
if (axis === 'width') {
const nextShaftWidth = roundPlanMeters(
Math.max(0.8, nextOuterSize - dragState.shaftWallThickness * 2),
)
const nextCabWidth = nextShaftWidth
useLiveNodeOverrides
.getState()
.set(dragState.elevatorId, { shaftWidth: nextShaftWidth, width: nextCabWidth })
setCursorPoint(planPoint)
return { shaftWidth: nextShaftWidth, width: nextCabWidth } satisfies Partial<ElevatorNode>
}
const nextShaftDepth = roundPlanMeters(
Math.max(0.8, nextOuterSize - dragState.shaftWallThickness * 2),
)
const nextCabDepth = nextShaftDepth
useLiveNodeOverrides
.getState()
.set(dragState.elevatorId, { depth: nextCabDepth, shaftDepth: nextShaftDepth })
setCursorPoint(planPoint)
return { depth: nextCabDepth, shaftDepth: nextShaftDepth } satisfies Partial<ElevatorNode>
},
[],
)
const handleElevatorResizePointerDown = useCallback(
(
entry: FloorplanElevatorEntry,
handle: ElevatorResizeHandle,
event: ReactPointerEvent<SVGCircleElement>,
) => {
if (event.button !== 0 || mode !== 'select') {
return
}
event.preventDefault()
event.stopPropagation()
event.currentTarget.setPointerCapture(event.pointerId)
setHoveredElevatorId(null)
setSelection({ selectedIds: [entry.elevator.id] })
setElevatorResizeDragState({
center: entry.center,
elevatorId: entry.elevator.id,
handle,
pointerId: event.pointerId,
rotation: entry.rotation,
shaftWallThickness: entry.shaftWallThickness,
})
},
[mode, setSelection],
)
const handleElevatorResizePointerMove = useCallback(
(event: ReactPointerEvent<SVGCircleElement>) => {
const dragState = elevatorResizeDragState
if (!dragState || dragState.pointerId !== event.pointerId) {
return
}
const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY)
if (!planPoint) {
return
}
event.preventDefault()
event.stopPropagation()
previewElevatorResize(dragState, planPoint)
},
[elevatorResizeDragState, getPlanPointFromClientPoint, previewElevatorResize],
)
const handleElevatorResizePointerUp = useCallback(
(event: ReactPointerEvent<SVGCircleElement>) => {
const dragState = elevatorResizeDragState
if (!dragState || dragState.pointerId !== event.pointerId) {
return
}
const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY)
const updates = planPoint ? previewElevatorResize(dragState, planPoint) : {}
event.preventDefault()
event.stopPropagation()
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
useLiveNodeOverrides.getState().clear(dragState.elevatorId)
if (Object.keys(updates).length > 0) {
updateNode(dragState.elevatorId as AnyNodeId, updates)
}
setElevatorResizeDragState(null)
setCursorPoint(null)
},
[elevatorResizeDragState, getPlanPointFromClientPoint, previewElevatorResize, updateNode],
)
useEffect(() => {
siteBoundaryDraftRef.current = siteBoundaryDraft
}, [siteBoundaryDraft])
@@ -12916,6 +13448,10 @@ export function FloorplanPanel() {
return
}
if (elevatorResizeDragState?.pointerId === event.pointerId) {
return
}
if (wallEndpointDragRef.current?.pointerId === event.pointerId) {
return
}
@@ -13152,6 +13688,7 @@ export function FloorplanPanel() {
ceilingHoleMoveDraft,
ceilingHoleVertexDragState,
ceilingVertexDragState,
elevatorResizeDragState,
siteVertexDragState,
slabHoleMoveDraft,
slabHoleVertexDragState,
@@ -14181,6 +14718,36 @@ export function FloorplanPanel() {
},
[emitFloorplanNodeClick],
)
const handleElevatorPointerDown = useCallback(
(elevatorId: ElevatorNode['id'], event: ReactPointerEvent<SVGElement>) => {
if (event.button !== 0) {
return
}
const elevator = selectedElevatorEntry?.elevator
if (!elevator || elevator.id !== elevatorId) {
return
}
event.preventDefault()
event.stopPropagation()
const suppressClick = (clickEvent: MouseEvent) => {
clickEvent.stopImmediatePropagation()
clickEvent.preventDefault()
window.removeEventListener('click', suppressClick, true)
}
window.addEventListener('click', suppressClick, true)
requestAnimationFrame(() => {
window.removeEventListener('click', suppressClick, true)
})
sfxEmitter.emit('sfx:item-pick')
setMovingNode(elevator)
setSelection({ selectedIds: [] })
},
[selectedElevatorEntry, setMovingNode, setSelection],
)
const handleZoneLabelClick = useCallback(
(zoneId: ZoneNodeType['id'], _event: ReactMouseEvent<SVGElement>) => {
const currentZoneId = useViewer.getState().selection.zoneId
@@ -14280,6 +14847,36 @@ export function FloorplanPanel() {
},
[deleteNode, selectedSpawnEntry, setSelection],
)
const handleSelectedElevatorMove = useCallback(
(event: ReactMouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
const elevator = selectedElevatorEntry?.elevator
if (!elevator) {
return
}
sfxEmitter.emit('sfx:item-pick')
setMovingNode(elevator)
setSelection({ selectedIds: [] })
},
[selectedElevatorEntry, setMovingNode, setSelection],
)
const handleSelectedElevatorDelete = useCallback(
(event: ReactMouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
const elevator = selectedElevatorEntry?.elevator
if (!elevator) {
return
}
sfxEmitter.emit('sfx:item-delete')
deleteNode(elevator.id as AnyNodeId)
setSelection({ selectedIds: [] })
},
[deleteNode, selectedElevatorEntry, setSelection],
)
const handleItemPointerDown = useCallback(
(itemId: ItemNode['id'], event: ReactPointerEvent<SVGElement>) => {
if (event.button !== 0) {
@@ -16025,6 +16622,7 @@ export function FloorplanPanel() {
hasFloorplanCursorIndicator &&
!panStateRef.current &&
!guideInteractionRef.current &&
!elevatorResizeDragState &&
!wallEndpointDragRef.current &&
!ceilingVertexDragState &&
!ceilingHoleMoveDraft &&
@@ -16061,6 +16659,7 @@ export function FloorplanPanel() {
ceilingVertexDragState,
ceilingHoleMoveDraft,
ceilingHoleVertexDragState,
elevatorResizeDragState,
siteVertexDragState,
slabHoleMoveDraft,
slabHoleVertexDragState,
@@ -16535,6 +17134,11 @@ export function FloorplanPanel() {
/>
)}
<Editor2dFloorplanActionMenuLayer
elevator={{
position: selectedElevatorActionMenuPosition,
onDelete: handleSelectedElevatorDelete,
onMove: handleSelectedElevatorMove,
}}
ceiling={{
position: selectedCeilingActionMenuPosition,
onAddHole: selectedCeilingEditingHole ? undefined : handleSelectedCeilingAddHole,
@@ -16869,6 +17473,10 @@ export function FloorplanPanel() {
isDeleteMode={isDeleteMode}
onElevatorHoverChange={handleElevatorHoverChange}
onElevatorHoverEnter={handleFloorplanElevatorHoverEnter}
onElevatorPointerDown={handleElevatorPointerDown}
onElevatorResizePointerDown={handleElevatorResizePointerDown}
onElevatorResizePointerMove={handleElevatorResizePointerMove}
onElevatorResizePointerUp={handleElevatorResizePointerUp}
onElevatorSelect={handleElevatorSelect}
palette={palette}
selectedIdSet={selectedIdSet}
@@ -90,12 +90,60 @@ function stripDuplicateFlags(metadata: ElevatorNode['metadata']) {
return nextMeta as ElevatorNode['metadata']
}
type ElevatorMetricKey = 'width' | 'depth' | 'cabHeight' | 'doorWidth' | 'doorHeight'
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)
}
@@ -288,11 +336,53 @@ export function ElevatorPanel() {
const requestLevel = useCallback(
(levelId: LevelNode['id']) => {
if (!node) return
if ((node.disabledLevelIds ?? []).includes(levelId)) return
useInteractive.getState().requestElevator(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
@@ -336,10 +426,22 @@ export function ElevatorPanel() {
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 defaultLevelOptions = servedLevels.length > 0 ? servedLevels : levels
const servedLevelIdSet = new Set<string>(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,
)
@@ -520,7 +622,102 @@ export function ElevatorPanel() {
/>
</PanelSection>
<PanelSection title="Shaft">
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
Shaft Style
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
onChange={(event) =>
handleUpdate({ shaftStyle: event.target.value as ElevatorNode['shaftStyle'] })
}
value={displayNode.shaftStyle ?? 'solid'}
>
{SHAFT_STYLE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<MetricControl
label="Shaft Width"
max={5}
min={displayNode.width}
onChange={(value) => 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}
/>
<MetricControl
label="Shaft Depth"
max={5}
min={displayNode.depth}
onChange={(value) => 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}
/>
<MetricControl
label="Wall Thickness"
max={0.4}
min={0.04}
onChange={(value) => previewMetric('shaftWallThickness', value)}
onCommit={(value) => commitMetric('shaftWallThickness', value)}
precision={2}
restoreOnCommit={false}
step={0.01}
unit="m"
value={displayShaftWallThickness}
/>
</PanelSection>
<PanelSection title="Doors">
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
Opening Style
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
onChange={(event) =>
handleUpdate({ doorStyle: event.target.value as ElevatorNode['doorStyle'] })
}
value={displayNode.doorStyle ?? 'center-opening'}
>
{DOOR_STYLE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
Door Type
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
onChange={(event) =>
handleUpdate({
doorPanelStyle: event.target.value as ElevatorNode['doorPanelStyle'],
})
}
value={displayNode.doorPanelStyle ?? 'glass-frame'}
>
{DOOR_PANEL_STYLE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<MetricControl
label="Door Width"
max={Math.max(displayNode.width - 0.1, 0.5)}
@@ -604,28 +801,88 @@ export function ElevatorPanel() {
</div>
</PanelSection>
<PanelSection title="Access">
<div className="space-y-2">
{servedLevels.map((level) => {
const isDisabled = disabledLevelIds.has(level.id)
const isServiceOnly = serviceOnlyLevelIds.has(level.id)
return (
<div
className="flex items-center justify-between gap-2 rounded-lg border border-border/45 bg-[#2C2C2E] px-2.5 py-2"
key={level.id}
>
<span className="min-w-0 truncate text-sm">
{level.name || `Level ${level.level}`}
</span>
<div className="flex shrink-0 gap-1.5">
<button
className={`rounded-md border px-2 py-1 text-[11px] transition-colors ${
isServiceOnly
? 'border-sky-300/45 bg-sky-400/15 text-sky-100'
: 'border-border/50 bg-black/15 text-muted-foreground hover:text-foreground'
} ${isDisabled ? 'cursor-not-allowed opacity-45' : ''}`}
disabled={isDisabled}
onClick={() => toggleLevelAccess('serviceOnlyLevelIds', level.id)}
type="button"
>
Service
</button>
<button
className={`rounded-md border px-2 py-1 text-[11px] transition-colors ${
isDisabled
? 'border-red-300/45 bg-red-400/15 text-red-100'
: 'border-border/50 bg-black/15 text-muted-foreground hover:text-foreground'
}`}
onClick={() => toggleLevelAccess('disabledLevelIds', level.id)}
type="button"
>
Disabled
</button>
</div>
</div>
)
})}
</div>
</PanelSection>
<PanelSection title="Destination">
<div className="grid grid-cols-2 gap-1.5">
{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 (
<button
className={`flex min-h-11 items-center justify-between gap-2 rounded-lg border px-2.5 text-left transition-colors ${
isActive
? 'border-emerald-400/45 bg-emerald-400/15 text-emerald-100'
: 'border-border/50 bg-[#2C2C2E] text-foreground hover:bg-[#3e3e3e]'
isDisabled
? 'cursor-not-allowed border-border/35 bg-[#202024] text-muted-foreground/55'
: isActive
? 'border-emerald-400/45 bg-emerald-400/15 text-emerald-100'
: 'border-border/50 bg-[#2C2C2E] text-foreground hover:bg-[#3e3e3e]'
}`}
disabled={isDisabled}
key={level.id}
onClick={() => requestLevel(level.id)}
type="button"
>
<span className="flex min-w-0 flex-col">
<span className="truncate text-xs">{level.name || `Level ${level.level}`}</span>
{stopOrder && (
{isDisabled ? (
<span className="mt-0.5 text-[10px] font-medium uppercase tracking-[0.12em] text-current/65">
Stop {stopOrder}
Disabled
</span>
) : isServiceOnly ? (
<span className="mt-0.5 text-[10px] font-medium uppercase tracking-[0.12em] text-current/65">
Service
</span>
) : (
stopOrder && (
<span className="mt-0.5 text-[10px] font-medium uppercase tracking-[0.12em] text-current/65">
Stop {stopOrder}
</span>
)
)}
</span>
<span
@@ -633,7 +890,7 @@ export function ElevatorPanel() {
stopOrder ? 'px-1.5 font-mono text-[11px] font-semibold' : ''
}`}
>
{stopOrder ?? <Send className="h-3 w-3" />}
{isDisabled ? '×' : (stopOrder ?? <Send className="h-3 w-3" />)}
</span>
</button>
)
@@ -1,6 +1,7 @@
import {
type AnyNodeId,
type ElevatorNode,
resolveElevatorDispatchTarget,
useInteractive,
useLiveNodeOverrides,
useLiveTransforms,
@@ -29,6 +30,7 @@ const CAB_COLOR = '#d7dde5'
const GLASS_COLOR = '#f8fafc'
const DOOR_COLOR = '#8e98a6'
const PANEL_COLOR = '#1f2937'
const DEFAULT_SHAFT_WALL_THICKNESS = 0.09
type Vector3Tuple = [number, number, number]
@@ -39,6 +41,11 @@ 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 ElevatorDoorSide = 'left' | 'right'
type ElevatorDoorPanelStyleValue = ElevatorNode['doorPanelStyle']
type ElevatorDoorStyleValue = ElevatorNode['doorStyle']
type ElevatorShaftStyleValue = ElevatorNode['shaftStyle']
const SHAFT_WALL_MATERIAL = new MeshStandardMaterial({
color: SHAFT_WALL_COLOR,
metalness: 0.08,
@@ -64,6 +71,11 @@ const DOOR_MATERIAL = new MeshStandardMaterial({
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,
@@ -132,6 +144,11 @@ const BUTTON_FACE_MATERIALS = {
metalness: 0.22,
roughness: 0.3,
}),
disabled: new MeshStandardMaterial({
color: '#475569',
metalness: 0.12,
roughness: 0.52,
}),
}
const BUTTON_RING_MATERIALS = {
active: new MeshStandardMaterial({
@@ -153,6 +170,11 @@ const BUTTON_RING_MATERIALS = {
metalness: 0.48,
roughness: 0.28,
}),
disabled: new MeshStandardMaterial({
color: '#334155',
metalness: 0.28,
roughness: 0.5,
}),
}
const BUTTON_GLOW_MATERIALS = {
active: new MeshStandardMaterial({
@@ -183,6 +205,11 @@ const BUTTON_LABEL_MATERIALS = {
metalness: 0.12,
roughness: 0.34,
}),
disabled: new MeshStandardMaterial({
color: '#94a3b8',
metalness: 0.08,
roughness: 0.5,
}),
}
const QUEUE_STRIP_MATERIALS = {
queued: new MeshStandardMaterial({
@@ -490,6 +517,7 @@ function ElevatorMeshButton({
action = 'request-level',
active,
buttonKind,
disabled = false,
elevatorId,
faceSign = -1,
glyph,
@@ -502,6 +530,7 @@ function ElevatorMeshButton({
action?: ElevatorButtonAction
active: boolean
buttonKind: 'cab' | 'landing'
disabled?: boolean
elevatorId: AnyNodeId
faceSign?: -1 | 1
glyph?: 'door-open'
@@ -511,34 +540,51 @@ function ElevatorMeshButton({
queued: boolean
radius?: number
}) {
const state = active ? 'active' : queued ? 'queued' : 'idle'
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 = active || queued ? BUTTON_LABEL_MATERIALS.lit : BUTTON_LABEL_MATERIALS.idle
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, elevatorId, levelId],
[action, buttonKind, disabled, elevatorId, levelId],
)
const press = (event: ThreeEvent<PointerEvent>) => {
if (event.button !== 0) return
if (disabled) return
if (action === 'open-door') {
useInteractive.getState().openElevatorDoor(elevatorId)
return
}
if (levelId) useInteractive.getState().requestElevator(elevatorId, levelId)
if (levelId) {
const targetElevatorId =
buttonKind === 'landing'
? resolveElevatorDispatchTarget({
elevators: useInteractive.getState().elevators,
levelId,
nodes: useScene.getState().nodes,
requestedElevatorId: elevatorId,
})
: elevatorId
useInteractive.getState().requestElevator(targetElevatorId, levelId)
}
}
return (
<group onPointerDown={press} position={position} userData={userData}>
{(active || queued) && (
{!disabled && (active || queued) && (
<mesh
dispose={null}
geometry={BUTTON_GLOW_GEOMETRY}
@@ -582,9 +628,83 @@ function ElevatorMeshButton({
)
}
function getResolvedDoorStyle(
doorStyle: ElevatorDoorStyleValue | undefined,
): ElevatorDoorStyleValue {
return doorStyle ?? 'center-opening'
}
function getResolvedDoorPanelStyle(
doorPanelStyle: ElevatorDoorPanelStyleValue | undefined,
): ElevatorDoorPanelStyleValue {
return doorPanelStyle ?? 'glass-frame'
}
function getResolvedShaftStyle(
shaftStyle: ElevatorShaftStyleValue | undefined,
): ElevatorShaftStyleValue {
return shaftStyle ?? 'solid'
}
function getElevatorDoorLeafSides(
doorStyle: ElevatorDoorStyleValue | undefined,
): ElevatorDoorSide[] {
const resolvedDoorStyle = getResolvedDoorStyle(doorStyle)
if (resolvedDoorStyle === 'single-left') return ['left']
if (resolvedDoorStyle === 'single-right') return ['right']
return ['left', 'right']
}
function getElevatorDoorLeafX(
side: ElevatorDoorSide,
openingWidth: number,
doorOpen: number,
doorStyle: ElevatorDoorStyleValue | undefined,
) {
const resolvedDoorStyle = getResolvedDoorStyle(doorStyle)
if (resolvedDoorStyle === 'center-opening') {
const direction = side === 'left' ? -1 : 1
return direction * (openingWidth / 4 + doorOpen * openingWidth * 0.34)
}
const direction = resolvedDoorStyle === 'single-left' ? -1 : 1
return direction * doorOpen * openingWidth * 0.68
}
function getElevatorDoorLeafWidth(
openingWidth: number,
doorStyle: ElevatorDoorStyleValue | undefined,
) {
return getResolvedDoorStyle(doorStyle) === 'center-opening'
? Math.max(openingWidth / 2 - 0.018, 0.12)
: Math.max(openingWidth - 0.018, 0.18)
}
function getElevatorCabWidth(node: ElevatorNode) {
return Math.max(node.width, 0.8)
}
function getElevatorCabDepth(node: ElevatorNode) {
return Math.max(node.depth, 0.8)
}
function getElevatorShaftWallThickness(node: ElevatorNode) {
return Math.max(node.shaftWallThickness ?? DEFAULT_SHAFT_WALL_THICKNESS, 0.04)
}
function getElevatorShaftWidth(node: ElevatorNode, cabWidth = getElevatorCabWidth(node)) {
return Math.max(node.shaftWidth ?? cabWidth, cabWidth, 0.8)
}
function getElevatorShaftDepth(node: ElevatorNode, cabDepth = getElevatorCabDepth(node)) {
return Math.max(node.shaftDepth ?? cabDepth, cabDepth, 0.8)
}
function DoorLeaf({
animated,
doorOpen,
doorPanelStyle,
doorStyle,
height,
side,
width,
@@ -602,20 +722,26 @@ function DoorLeaf({
levelId: AnyNodeId
}
doorOpen: number
doorPanelStyle: ElevatorDoorPanelStyleValue
doorStyle: ElevatorDoorStyleValue
height: number
side: 'left' | 'right'
side: ElevatorDoorSide
width: number
y: number
z: number
}) {
const ref = useRef<Group>(null)
const direction = side === 'left' ? -1 : 1
const getLeafX = (openAmount: number) => direction * (width / 4 + openAmount * width * 0.34)
const leafWidth = Math.max(width / 2 - 0.018, 0.12)
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
@@ -631,43 +757,130 @@ function DoorLeaf({
return (
<group ref={ref} position={[getLeafX(doorOpen), y + height / 2, z]}>
<BoxPrimitive
castShadow
material={DOOR_MATERIAL}
position={[0, height / 2 - railHeight / 2, 0]}
receiveShadow
scale={[leafWidth, railHeight, 0.05]}
/>
<BoxPrimitive
castShadow
material={DOOR_MATERIAL}
position={[0, -height / 2 + railHeight / 2, 0]}
receiveShadow
scale={[leafWidth, railHeight, 0.05]}
/>
<BoxPrimitive
castShadow
material={DOOR_MATERIAL}
position={[-leafWidth / 2 + stileWidth / 2, 0, 0]}
receiveShadow
scale={[stileWidth, height, 0.05]}
/>
<BoxPrimitive
castShadow
material={DOOR_MATERIAL}
position={[leafWidth / 2 - stileWidth / 2, 0, 0]}
receiveShadow
scale={[stileWidth, height, 0.05]}
/>
<BoxPrimitive
material={GLASS_MATERIAL}
position={[0, 0, -0.004]}
scale={[glassWidth, glassHeight, 0.012]}
/>
{resolvedPanelStyle === 'glass-frame' ? (
<>
<BoxPrimitive
castShadow
material={DOOR_MATERIAL}
position={[0, height / 2 - railHeight / 2, 0]}
receiveShadow
scale={[leafWidth, railHeight, 0.05]}
/>
<BoxPrimitive
castShadow
material={DOOR_MATERIAL}
position={[0, -height / 2 + railHeight / 2, 0]}
receiveShadow
scale={[leafWidth, railHeight, 0.05]}
/>
<BoxPrimitive
castShadow
material={DOOR_MATERIAL}
position={[-leafWidth / 2 + stileWidth / 2, 0, 0]}
receiveShadow
scale={[stileWidth, height, 0.05]}
/>
<BoxPrimitive
castShadow
material={DOOR_MATERIAL}
position={[leafWidth / 2 - stileWidth / 2, 0, 0]}
receiveShadow
scale={[stileWidth, height, 0.05]}
/>
<BoxPrimitive
material={GLASS_MATERIAL}
position={[0, 0, -0.004]}
scale={[glassWidth, glassHeight, 0.012]}
/>
</>
) : (
<>
<BoxPrimitive
castShadow
material={DOOR_MATERIAL}
position={[0, 0, 0]}
receiveShadow
scale={[leafWidth, height, 0.05]}
/>
<BoxPrimitive
material={DOOR_GROOVE_MATERIAL}
position={[0, 0, -0.028]}
scale={[0.018, panelInsetHeight, 0.01]}
/>
{resolvedPanelStyle === 'segmented-panel'
? Array.from({ length: segmentCount - 1 }).map((_, index) => (
<BoxPrimitive
key={index}
material={DOOR_GROOVE_MATERIAL}
position={[0, -panelInsetHeight / 2 + segmentSpacing * (index + 1), -0.03]}
scale={[panelInsetWidth, 0.018, 0.012]}
/>
))
: null}
<BoxPrimitive
material={DOOR_GROOVE_MATERIAL}
position={[0, panelInsetHeight / 2, -0.029]}
scale={[panelInsetWidth, 0.012, 0.01]}
/>
<BoxPrimitive
material={DOOR_GROOVE_MATERIAL}
position={[0, -panelInsetHeight / 2, -0.029]}
scale={[panelInsetWidth, 0.012, 0.01]}
/>
</>
)}
</group>
)
}
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) => (
<DoorLeaf
animated={animated}
doorOpen={doorOpen}
doorPanelStyle={doorPanelStyle}
doorStyle={doorStyle}
height={height}
key={side}
side={side}
width={width}
y={y}
z={z}
/>
))}
</>
)
}
function LandingDoorFrame({
doorHeight,
doorWidth,
@@ -749,6 +962,8 @@ function LandingDoorFrame({
function LandingDoor({
animated,
doorPanelStyle,
doorStyle,
elevatorId,
doorOpen,
doorHeight,
@@ -758,6 +973,8 @@ function LandingDoor({
z,
}: {
animated: boolean
doorPanelStyle: ElevatorDoorPanelStyleValue
doorStyle: ElevatorDoorStyleValue
elevatorId: AnyNodeId
doorOpen: number
doorHeight: number
@@ -767,26 +984,16 @@ function LandingDoor({
z: number
}) {
return (
<>
<DoorLeaf
animated={animated ? { elevatorId, kind: 'landing', levelId } : undefined}
doorOpen={doorOpen}
height={doorHeight}
side="left"
width={doorWidth}
y={levelY}
z={z}
/>
<DoorLeaf
animated={animated ? { elevatorId, kind: 'landing', levelId } : undefined}
doorOpen={doorOpen}
height={doorHeight}
side="right"
width={doorWidth}
y={levelY}
z={z}
/>
</>
<ElevatorDoorLeaves
animated={animated ? { elevatorId, kind: 'landing', levelId } : undefined}
doorOpen={doorOpen}
doorPanelStyle={doorPanelStyle}
doorStyle={doorStyle}
height={doorHeight}
width={doorWidth}
y={levelY}
z={z}
/>
)
}
@@ -856,13 +1063,24 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
cabRef.current.position.y = runtime.carY
}, 2.6)
const shaftWidth = Math.max(renderNode.width, 0.8)
const shaftDepth = Math.max(renderNode.depth, 0.8)
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 doorWidth = Math.min(Math.max(renderNode.doorWidth, 0.45), shaftWidth - 0.18)
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 shaftWallThickness = 0.09
const shaftBodyHeight = Math.max(shaftHeight - shaftWallThickness, 0.01)
const shaftBodyCenterY = shaftBaseY + shaftBodyHeight / 2
const shaftTopCapBottomY = shaftBaseY + shaftHeight - shaftWallThickness
@@ -906,6 +1124,14 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
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 ||
@@ -916,8 +1142,9 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
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 cabPanelX = shaftWidth / 2 - 0.075
const cabPanelZ = -shaftDepth / 2 + 0.36
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
@@ -956,28 +1183,28 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
>
<BoxPrimitive
castShadow
material={SHAFT_SIDE_MATERIAL}
material={shaftShellMaterial}
position={[0, shaftBodyCenterY, shaftDepth / 2 + shaftWallThickness / 2]}
receiveShadow
scale={[shaftWidth + shaftWallThickness * 2, shaftBodyHeight, shaftWallThickness]}
/>
<BoxPrimitive
castShadow
material={SHAFT_SIDE_MATERIAL}
material={shaftShellMaterial}
position={[-shaftWidth / 2 - shaftWallThickness / 2, shaftBodyCenterY, 0]}
receiveShadow
scale={[shaftWallThickness, shaftBodyHeight, shaftDepth + shaftWallThickness * 2]}
/>
<BoxPrimitive
castShadow
material={SHAFT_SIDE_MATERIAL}
material={shaftShellMaterial}
position={[shaftWidth / 2 + shaftWallThickness / 2, shaftBodyCenterY, 0]}
receiveShadow
scale={[shaftWallThickness, shaftBodyHeight, shaftDepth + shaftWallThickness * 2]}
/>
<BoxPrimitive
castShadow
material={SHAFT_SIDE_MATERIAL}
material={shaftTopMaterial}
position={[0, shaftBaseY + shaftHeight - shaftWallThickness / 2, 0]}
receiveShadow
scale={[
@@ -991,57 +1218,49 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
<BoxPrimitive
castShadow
material={CAB_MATERIAL}
position={[0, 0.04, 0]}
position={[0, 0.04, cabCenterZ]}
receiveShadow
scale={[shaftWidth, 0.08, shaftDepth]}
scale={[cabWidth, 0.08, cabDepth]}
/>
<BoxPrimitive
castShadow
material={CAB_MATERIAL}
position={[0, cabHeight - 0.04, 0]}
position={[0, cabHeight - 0.04, cabCenterZ]}
receiveShadow
scale={[shaftWidth, 0.08, shaftDepth]}
scale={[cabWidth, 0.08, cabDepth]}
/>
<BoxPrimitive
castShadow
material={CAB_MATERIAL}
position={[0, cabHeight / 2, shaftDepth / 2 - 0.04]}
position={[0, cabHeight / 2, cabCenterZ + cabDepth / 2 - 0.04]}
receiveShadow
scale={[shaftWidth, cabHeight, 0.08]}
scale={[cabWidth, cabHeight, 0.08]}
/>
<BoxPrimitive
castShadow
material={CAB_MATERIAL}
position={[-shaftWidth / 2 + 0.04, cabHeight / 2, 0]}
position={[-cabWidth / 2 + 0.04, cabHeight / 2, cabCenterZ]}
receiveShadow
scale={[0.08, cabHeight, shaftDepth]}
scale={[0.08, cabHeight, cabDepth]}
/>
<BoxPrimitive
castShadow
material={CAB_MATERIAL}
position={[shaftWidth / 2 - 0.04, cabHeight / 2, 0]}
position={[cabWidth / 2 - 0.04, cabHeight / 2, cabCenterZ]}
receiveShadow
scale={[0.08, cabHeight, shaftDepth]}
scale={[0.08, cabHeight, cabDepth]}
/>
<DoorLeaf
<ElevatorDoorLeaves
animated={{ elevatorId, kind: 'cab' }}
doorOpen={doorOpen}
doorPanelStyle={doorPanelStyle}
doorStyle={doorStyle}
height={doorHeight}
side="left"
width={doorWidth}
y={0}
z={frontZ}
/>
<DoorLeaf
animated={{ elevatorId, kind: 'cab' }}
doorOpen={doorOpen}
height={doorHeight}
side="right"
width={doorWidth}
y={0}
z={frontZ}
@@ -1067,21 +1286,23 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
{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 (
<ElevatorMeshButton
active={activeLevelId === entry.id}
active={!isDisabledLevel && activeLevelId === entry.id}
buttonKind="cab"
disabled={isDisabledLevel}
elevatorId={elevatorId}
faceSign={1}
key={entry.id}
label={entry.label}
levelId={entry.id as AnyNodeId}
position={[x, y, 0.045]}
queued={queuedLevelIds.has(entry.id)}
queued={!isDisabledLevel && queuedLevelIds.has(entry.id)}
/>
)
})}
@@ -1101,7 +1322,9 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
{entrySpans.map(({ entry, levelTopY }) => {
const isCurrentLevel = activeLevelId === entry.id
const isQueuedLevel = queuedLevelIds.has(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
@@ -1117,6 +1340,8 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
/>
<LandingDoor
animated={isCurrentLevel}
doorPanelStyle={doorPanelStyle}
doorStyle={doorStyle}
elevatorId={elevatorId}
doorHeight={doorHeight}
doorOpen={isCurrentLevel ? doorOpen : 0}
@@ -1141,8 +1366,9 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
scale={[0.18, 0.42, 0.04]}
/>
<ElevatorMeshButton
active={isCurrentLevel && doorOpen > 0.5}
active={!isDisabledLevel && !isServiceOnlyLevel && isCurrentLevel && doorOpen > 0.5}
buttonKind="landing"
disabled={isDisabledLevel || isServiceOnlyLevel}
elevatorId={elevatorId}
levelId={entry.id as AnyNodeId}
position={[0, 0.06, -0.045]}