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' } from './store/use-live-node-overrides'
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
export { clearSceneHistory, default as useScene } from './store/use-scene' 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 { syncAutoElevatorOpenings } from './systems/elevator/elevator-opening-sync'
export { export {
resolveElevatorBuildingLevels, resolveElevatorBuildingLevels,
+6 -1
View File
@@ -42,7 +42,12 @@ export {
ColumnSupportStyle, ColumnSupportStyle,
} from './nodes/column' } from './nodes/column'
export { DoorNode, DoorSegment } from './nodes/door' 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 { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence'
export { GuideNode, GuideScaleReference } from './nodes/guide' export { GuideNode, GuideScaleReference } from './nodes/guide'
export type { export type {
+19 -2
View File
@@ -4,8 +4,12 @@ import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material' import { MaterialSchema } from '../material'
export const ElevatorDoorStyle = z.enum(['center-opening', 'single-left', 'single-right']) 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 ElevatorDoorStyle = z.infer<typeof ElevatorDoorStyle>
export type ElevatorShaftStyle = z.infer<typeof ElevatorShaftStyle>
export const ElevatorNode = BaseNode.extend({ export const ElevatorNode = BaseNode.extend({
id: objectId('elevator'), id: objectId('elevator'),
@@ -17,13 +21,20 @@ export const ElevatorNode = BaseNode.extend({
rotation: z.number().default(0), rotation: z.number().default(0),
width: z.number().default(1.6), width: z.number().default(1.6),
depth: 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), cabHeight: z.number().default(2.35),
doorWidth: z.number().default(0.95), doorWidth: z.number().default(0.95),
doorHeight: z.number().default(2.1), doorHeight: z.number().default(2.1),
doorStyle: ElevatorDoorStyle.default('center-opening'), doorStyle: ElevatorDoorStyle.default('center-opening'),
doorPanelStyle: ElevatorDoorPanelStyle.default('glass-frame'),
fromLevelId: z.string().nullable().default(null), fromLevelId: z.string().nullable().default(null),
toLevelId: z.string().nullable().default(null), toLevelId: z.string().nullable().default(null),
servedLevelIds: z.array(z.string()).optional(), servedLevelIds: z.array(z.string()).optional(),
disabledLevelIds: z.array(z.string()).default([]),
serviceOnlyLevelIds: z.array(z.string()).default([]),
defaultLevelId: z.string().nullable().default(null), defaultLevelId: z.string().nullable().default(null),
speed: z.number().default(2.2), speed: z.number().default(2.2),
doorDurationMs: z.number().default(900), doorDurationMs: z.number().default(900),
@@ -34,11 +45,17 @@ export const ElevatorNode = BaseNode.extend({
- parentId: building that owns this elevator - parentId: building that owns this elevator
- position: building-local shaft center on the X/Z plane - position: building-local shaft center on the X/Z plane
- rotation: rotation around the Y axis - 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 - 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 - 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 - 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 - defaultLevelId: starting/resting level, falling back to the lowest served level
- speed/doorDurationMs/dwellMs: runtime animation defaults - 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 ELEVATOR_OPENING_PADDING = 0.08
const DEFAULT_ELEVATOR_SHAFT_WALL_THICKNESS = 0.09
function pointsEqual(a: Point2D, b: Point2D, tolerance = 1e-5) { function pointsEqual(a: Point2D, b: Point2D, tolerance = 1e-5) {
const dx = a[0] - b[0] const dx = a[0] - b[0]
@@ -141,8 +142,14 @@ function shouldApplyElevatorToCeiling(
} }
function getElevatorOpeningPolygon(elevator: ElevatorNode): Point2D[] { function getElevatorOpeningPolygon(elevator: ElevatorNode): Point2D[] {
const halfWidth = Math.max(elevator.width, 0.8) / 2 + ELEVATOR_OPENING_PADDING const wallThickness = Math.max(
const halfDepth = Math.max(elevator.depth, 0.8) / 2 + ELEVATOR_OPENING_PADDING 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[] = [ const corners: Point2D[] = [
[-halfWidth, -halfDepth], [-halfWidth, -halfDepth],
[halfWidth, -halfDepth], [halfWidth, -halfDepth],
@@ -20,6 +20,7 @@ export type FloorplanActionMenuEntry = {
} }
type FloorplanActionMenuLayerProps = { type FloorplanActionMenuLayerProps = {
elevator: FloorplanActionMenuEntry
item: FloorplanActionMenuEntry item: FloorplanActionMenuEntry
wall: FloorplanActionMenuEntry wall: FloorplanActionMenuEntry
fence: FloorplanActionMenuEntry fence: FloorplanActionMenuEntry
@@ -33,6 +34,7 @@ type FloorplanActionMenuLayerProps = {
} }
export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
elevator,
item, item,
wall, wall,
fence, fence,
@@ -55,6 +57,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
} }
const entries: FloorplanActionMenuEntry[] = [ const entries: FloorplanActionMenuEntry[] = [
elevator,
item, item,
wall, wall,
fence, fence,
@@ -7,6 +7,7 @@ import {
type ElevatorNode, type ElevatorNode,
emitter, emitter,
resolveElevatorBuildingLevels, resolveElevatorBuildingLevels,
resolveElevatorDispatchTarget,
resolveElevatorServiceLevels, resolveElevatorServiceLevels,
sceneRegistry, sceneRegistry,
useInteractive, useInteractive,
@@ -129,9 +130,12 @@ type ElevatorColliderUserData = {
levelId?: AnyNodeId levelId?: AnyNodeId
localPosition: [number, number, number] localPosition: [number, number, number]
matrixInitialized?: boolean matrixInitialized?: boolean
side?: 'left' | 'right' side?: ElevatorDoorSide
} }
type ElevatorDoorSide = 'left' | 'right'
type ElevatorDoorStyleValue = ElevatorNode['doorStyle']
type ElevatorColliderMesh = Mesh & { type ElevatorColliderMesh = Mesh & {
userData: Mesh['userData'] & ElevatorColliderUserData userData: Mesh['userData'] & ElevatorColliderUserData
} }
@@ -164,6 +168,7 @@ function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | n
current.userData as { current.userData as {
elevatorButton?: { elevatorButton?: {
action?: unknown action?: unknown
disabled?: unknown
elevatorId?: unknown elevatorId?: unknown
kind?: unknown kind?: unknown
levelId?: unknown levelId?: unknown
@@ -171,6 +176,10 @@ function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | n
} }
).elevatorButton ).elevatorButton
if (candidate?.disabled === true) {
return null
}
if (typeof candidate?.elevatorId === 'string' && candidate.kind === 'cab') { if (typeof candidate?.elevatorId === 'string' && candidate.kind === 'cab') {
const action = candidate.action === 'open-door' ? 'open-door' : 'request-level' const action = candidate.action === 'open-door' ? 'open-door' : 'request-level'
if (action === 'open-door') { if (action === 'open-door') {
@@ -208,9 +217,65 @@ function getInteractableTargetKey(target: FirstPersonInteractableTarget | null)
: `${target.type}:${target.id}` : `${target.type}:${target.id}`
} }
function getElevatorDoorLeafX(side: 'left' | 'right', width: number, doorOpen: number) { function getResolvedElevatorDoorStyle(
const direction = side === 'left' ? -1 : 1 doorStyle: ElevatorDoorStyleValue | undefined,
return direction * (width / 4 + doorOpen * width * 0.34) ): 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) { function isDynamicElevatorCollider(kind: ElevatorColliderKind) {
@@ -222,13 +287,14 @@ function isInsideElevatorCab(
runtime: NonNullable<ReturnType<typeof useInteractive.getState>['elevators'][AnyNodeId]>, runtime: NonNullable<ReturnType<typeof useInteractive.getState>['elevators'][AnyNodeId]>,
localEyePosition: Vector3, localEyePosition: Vector3,
) { ) {
const halfWidth = Math.max(elevator.width, 0.8) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING const halfWidth = getElevatorCabWidth(elevator) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
const halfDepth = Math.max(elevator.depth, 0.8) / 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) const cabHeight = Math.max(elevator.cabHeight, 1.4)
return ( return (
Math.abs(localEyePosition.x) <= Math.max(halfWidth, 0.24) && 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 + 0.35 &&
localEyePosition.y <= runtime.carY + cabHeight + 0.7 localEyePosition.y <= runtime.carY + cabHeight + 0.7
) )
@@ -347,18 +413,23 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] {
node, node,
nodes, nodes,
) )
const shaftWidth = Math.max(node.width, 0.8) const cabWidth = getElevatorCabWidth(node)
const shaftDepth = Math.max(node.depth, 0.8) const cabDepth = getElevatorCabDepth(node)
const shaftWidth = getElevatorShaftWidth(node, cabWidth)
const shaftDepth = getElevatorShaftDepth(node, cabDepth)
const cabHeight = Math.max(node.cabHeight, 1.4) 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 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 shaftHeight = Math.max(totalHeight, cabHeight + 0.3)
const wallThickness = ELEVATOR_COLLIDER_WALL_THICKNESS const wallThickness = getElevatorShaftWallThickness(node)
const cabFloorWidth = Math.max(shaftWidth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48) const cabFloorWidth = Math.max(cabWidth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48)
const cabFloorDepth = Math.max(shaftDepth - 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 frontWallZ = -shaftDepth / 2 - wallThickness / 2
const frontZ = frontWallZ - wallThickness / 2 - 0.018 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) const resolvedShaftTopY = Math.max(shaftTopY, shaftBaseY + shaftHeight)
meshes.push( meshes.push(
@@ -390,38 +461,31 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] {
typedElevatorId, typedElevatorId,
'cab-floor', 'cab-floor',
[cabFloorWidth, ELEVATOR_COLLIDER_FLOOR_THICKNESS, cabFloorDepth], [cabFloorWidth, ELEVATOR_COLLIDER_FLOOR_THICKNESS, cabFloorDepth],
[0, ELEVATOR_COLLIDER_FLOOR_THICKNESS / 2, 0], [0, ELEVATOR_COLLIDER_FLOOR_THICKNESS / 2, cabCenterZ],
), ),
createElevatorColliderMesh( createElevatorColliderMesh(
typedElevatorId, typedElevatorId,
'cab-ceiling', 'cab-ceiling',
[shaftWidth, wallThickness, shaftDepth], [cabWidth, wallThickness, cabDepth],
[0, cabHeight - wallThickness / 2, 0], [0, cabHeight - wallThickness / 2, cabCenterZ],
), ),
createElevatorColliderMesh( createElevatorColliderMesh(
typedElevatorId, typedElevatorId,
'cab-back', 'cab-back',
[shaftWidth, cabHeight, wallThickness], [cabWidth, cabHeight, wallThickness],
[0, cabHeight / 2, shaftDepth / 2 - wallThickness / 2], [0, cabHeight / 2, cabCenterZ + cabDepth / 2 - wallThickness / 2],
), ),
createElevatorColliderMesh( createElevatorColliderMesh(
typedElevatorId, typedElevatorId,
'cab-left', 'cab-left',
[wallThickness, cabHeight, shaftDepth], [wallThickness, cabHeight, cabDepth],
[-shaftWidth / 2 + wallThickness / 2, cabHeight / 2, 0], [-cabWidth / 2 + wallThickness / 2, cabHeight / 2, cabCenterZ],
), ),
createElevatorColliderMesh( createElevatorColliderMesh(
typedElevatorId, typedElevatorId,
'cab-right', 'cab-right',
[wallThickness, cabHeight, shaftDepth], [wallThickness, cabHeight, cabDepth],
[shaftWidth / 2 - wallThickness / 2, cabHeight / 2, 0], [cabWidth / 2 - wallThickness / 2, cabHeight / 2, cabCenterZ],
),
createElevatorColliderMesh(
typedElevatorId,
'cab-door-left',
[leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
[0, doorHeight / 2, frontZ],
{ doorWidth, side: 'left' },
), ),
createElevatorColliderMesh( createElevatorColliderMesh(
typedElevatorId, typedElevatorId,
@@ -430,12 +494,14 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] {
[0, doorHeight / 2, frontZ], [0, doorHeight / 2, frontZ],
{ doorWidth }, { doorWidth },
), ),
createElevatorColliderMesh( ...doorLeafSides.map((side) =>
typedElevatorId, createElevatorColliderMesh(
'cab-door-right', typedElevatorId,
[leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], side === 'left' ? 'cab-door-left' : 'cab-door-right',
[0, doorHeight / 2, frontZ], [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
{ doorWidth, side: 'right' }, [0, doorHeight / 2, frontZ],
{ doorWidth, side },
),
), ),
) )
@@ -473,13 +539,6 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] {
[shaftWidth, headerHeight, wallDepth], [shaftWidth, headerHeight, wallDepth],
[0, entry.baseY + doorHeight + headerHeight / 2, frontWallZ], [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( createElevatorColliderMesh(
typedElevatorId, typedElevatorId,
'landing-door-gate', 'landing-door-gate',
@@ -487,12 +546,14 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] {
[0, entry.baseY + doorHeight / 2, frontZ - 0.02], [0, entry.baseY + doorHeight / 2, frontZ - 0.02],
{ doorWidth, levelId: entry.id }, { doorWidth, levelId: entry.id },
), ),
createElevatorColliderMesh( ...doorLeafSides.map((side) =>
typedElevatorId, createElevatorColliderMesh(
'landing-door-right', typedElevatorId,
[leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], side === 'left' ? 'landing-door-left' : 'landing-door-right',
[0, entry.baseY + doorHeight / 2, frontZ - 0.02], [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
{ doorWidth, levelId: entry.id, side: 'right' }, [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) useInteractive.getState().openElevatorDoor(target.id)
return 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 return
} }
@@ -1004,7 +1076,12 @@ export const FirstPersonControls = () => {
mesh.visible = false mesh.visible = false
continue continue
} }
localX = getElevatorDoorLeafX(side ?? 'left', doorWidth ?? node.doorWidth, runtime.doorOpen) localX = getElevatorDoorLeafX(
side ?? 'left',
doorWidth ?? node.doorWidth,
runtime.doorOpen,
node.doorStyle,
)
mesh.visible = true mesh.visible = true
} else if (isCabDoorGate) { } else if (isCabDoorGate) {
if (!runtime) { if (!runtime) {
@@ -1014,7 +1091,12 @@ export const FirstPersonControls = () => {
mesh.visible = runtime.doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD mesh.visible = runtime.doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD
} else if (isDoorCollider) { } else if (isDoorCollider) {
const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0 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 mesh.visible = true
} else if (isLandingDoorGate) { } else if (isLandingDoorGate) {
const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0 const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0
@@ -1045,6 +1127,7 @@ export const FirstPersonControls = () => {
const activeRide = ridingElevatorRef.current const activeRide = ridingElevatorRef.current
let nextRide: { let nextRide: {
cabHeight: number cabHeight: number
cabCenterZ: number
carY: number carY: number
doorOpen: number doorOpen: number
elevatorId: AnyNodeId elevatorId: AnyNodeId
@@ -1076,12 +1159,13 @@ export const FirstPersonControls = () => {
elevatorLocalEyePosition.copy(camera.position) elevatorLocalEyePosition.copy(camera.position)
object.worldToLocal(elevatorLocalEyePosition) object.worldToLocal(elevatorLocalEyePosition)
const halfWidth = Math.max(node.width, 0.8) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING const halfWidth = getElevatorCabWidth(node) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
const halfDepth = Math.max(node.depth, 0.8) / 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 cabHeight = Math.max(node.cabHeight, 1.4)
const insideFootprint = const insideFootprint =
Math.abs(elevatorLocalEyePosition.x) <= Math.max(halfWidth, 0.24) && 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 = const insideCabHeight =
elevatorLocalEyePosition.y >= runtime.carY + 0.35 && elevatorLocalEyePosition.y >= runtime.carY + 0.35 &&
elevatorLocalEyePosition.y <= runtime.carY + cabHeight + 0.7 elevatorLocalEyePosition.y <= runtime.carY + cabHeight + 0.7
@@ -1094,6 +1178,7 @@ export const FirstPersonControls = () => {
if ((insideFootprint && insideCabHeight) || continuingRide) { if ((insideFootprint && insideCabHeight) || continuingRide) {
nextRide = { nextRide = {
cabHeight, cabHeight,
cabCenterZ,
carY: runtime.carY, carY: runtime.carY,
doorOpen: runtime.doorOpen, doorOpen: runtime.doorOpen,
elevatorId: typedElevatorId, elevatorId: typedElevatorId,
@@ -1151,8 +1236,8 @@ export const FirstPersonControls = () => {
Math.min(nextRide.halfWidth, elevatorLocalControllerPosition.x), Math.min(nextRide.halfWidth, elevatorLocalControllerPosition.x),
) )
const clampedZ = Math.max( const clampedZ = Math.max(
-nextRide.halfDepth, nextRide.cabCenterZ - nextRide.halfDepth,
Math.min(nextRide.halfDepth, elevatorLocalControllerPosition.z), Math.min(nextRide.cabCenterZ + nextRide.halfDepth, elevatorLocalControllerPosition.z),
) )
if ( if (
@@ -40,6 +40,7 @@ import {
sampleWallCenterline, sampleWallCenterline,
sceneRegistry, sceneRegistry,
useInteractive, useInteractive,
useLiveNodeOverrides,
useLiveTransforms, useLiveTransforms,
useScene, useScene,
type WallNode, type WallNode,
@@ -346,6 +347,21 @@ type PendingFenceDragState = {
startClientY: number 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 const GUIDE_CORNERS = ['nw', 'ne', 'se', 'sw'] as const
type GuideCorner = (typeof GUIDE_CORNERS)[number] type GuideCorner = (typeof GUIDE_CORNERS)[number]
@@ -634,16 +650,38 @@ type FloorplanColumnEntry = {
polygon: Point2D[] polygon: Point2D[]
} }
type FloorplanElevatorServedLevel = {
id: LevelNode['id']
isCurrent: boolean
isDisabled: boolean
isQueued: boolean
isServiceOnly: boolean
isTarget: boolean
label: string
}
type FloorplanElevatorEntry = { type FloorplanElevatorEntry = {
cabCenterLocalY: number
cabDepth: number
cabWidth: number
center: Point2D center: Point2D
doorStyle: ElevatorNode['doorStyle']
doorWidth: number
elevator: ElevatorNode elevator: ElevatorNode
frontEdge: FloorplanLineSegment frontEdge: FloorplanLineSegment
frontNormal: Point2D frontNormal: Point2D
isCarOnLevel: boolean isCarOnLevel: boolean
isQueuedLevel: boolean isQueuedLevel: boolean
isTargetLevel: boolean isTargetLevel: boolean
outerHalfDepth: number
outerHalfWidth: number
points: string points: string
polygon: Point2D[] polygon: Point2D[]
rotation: number
servedLevels: FloorplanElevatorServedLevel[]
shaftDepth: number
shaftWallThickness: number
shaftWidth: number
} }
type ReferenceFloorData = { type ReferenceFloorData = {
@@ -822,6 +860,18 @@ function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max) 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 }) { function getSelectionModifierKeys(event?: { metaKey?: boolean; ctrlKey?: boolean }) {
return { return {
meta: Boolean(event?.metaKey), meta: Boolean(event?.metaKey),
@@ -6029,6 +6079,10 @@ const FloorplanElevatorLayer = memo(function FloorplanElevatorLayer({
isDeleteMode, isDeleteMode,
onElevatorHoverChange, onElevatorHoverChange,
onElevatorHoverEnter, onElevatorHoverEnter,
onElevatorPointerDown,
onElevatorResizePointerDown,
onElevatorResizePointerMove,
onElevatorResizePointerUp,
onElevatorSelect, onElevatorSelect,
palette, palette,
selectedIdSet, selectedIdSet,
@@ -6041,6 +6095,17 @@ const FloorplanElevatorLayer = memo(function FloorplanElevatorLayer({
isDeleteMode: boolean isDeleteMode: boolean
onElevatorHoverChange: (elevatorId: ElevatorNode['id'] | null) => void onElevatorHoverChange: (elevatorId: ElevatorNode['id'] | null) => void
onElevatorHoverEnter: (elevatorId: ElevatorNode['id']) => 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 onElevatorSelect: (elevator: ElevatorNode, event: ReactMouseEvent<SVGElement>) => void
palette: FloorplanPalette palette: FloorplanPalette
selectedIdSet: ReadonlySet<string> selectedIdSet: ReadonlySet<string>
@@ -6060,37 +6125,100 @@ const FloorplanElevatorLayer = memo(function FloorplanElevatorLayer({
const isDeleteHovered = isDeleteMode && isHovered const isDeleteHovered = isDeleteMode && isHovered
const isActive = isSelected || isHighlighted const isActive = isSelected || isHighlighted
const showChrome = isActive || isHovered const showChrome = isActive || isHovered
const fill = isDeleteHovered const isGlassShaft = elevator.shaftStyle === 'glass'
const shaftShellFill = isDeleteHovered
? palette.deleteFill ? palette.deleteFill
: isActive : isActive
? `url(#${wallSelectionHatchId})` ? `url(#${wallSelectionHatchId})`
: '#dbeafe' : isGlassShaft
const fillOpacity = isDeleteHovered ? 0.38 : isActive ? 0.84 : isHovered ? 0.78 : 0.64 ? '#dff6ff'
: '#e5e7eb'
const shaftClearFill = isGlassShaft ? '#ecfeff' : '#f8fafc'
const shaftShellOpacity = isDeleteHovered ? 0.38 : isActive ? 0.9 : isHovered ? 0.86 : 0.76
const stroke = isDeleteHovered const stroke = isDeleteHovered
? palette.deleteStroke ? palette.deleteStroke
: isActive : isActive
? palette.selectedStroke ? palette.selectedStroke
: isHovered : isHovered
? palette.wallHoverStroke ? palette.wallHoverStroke
: '#2563eb' : isGlassShaft
? '#0891b2'
: '#475569'
const doorStroke = isDeleteHovered const doorStroke = isDeleteHovered
? palette.deleteStroke ? palette.deleteStroke
: isActive : isActive
? palette.selectedStroke ? palette.selectedStroke
: '#0284c7' : '#0369a1'
const centerX = toSvgX(entry.center.x) const centerX = toSvgX(entry.center.x)
const centerY = toSvgY(entry.center.y) const centerY = toSvgY(entry.center.y)
const frontCenter = { const rotationDeg = (-entry.rotation * 180) / Math.PI
x: (entry.frontEdge.start.x + entry.frontEdge.end.x) / 2, const shaftWidth = entry.outerHalfWidth * 2
y: (entry.frontEdge.start.y + entry.frontEdge.end.y) / 2, const shaftDepth = entry.outerHalfDepth * 2
} const shaftClearX = -entry.shaftWidth / 2
const doorIndicatorEnd = { const shaftClearY = -entry.shaftDepth / 2
x: frontCenter.x + entry.frontNormal.x * 0.34, const cabX = -entry.cabWidth / 2
y: frontCenter.y + entry.frontNormal.y * 0.34, 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 showCarMarker = entry.isCarOnLevel || entry.isTargetLevel || entry.isQueuedLevel
const carFill = entry.isCarOnLevel ? '#22c55e' : '#ffffff' const carFill = entry.isCarOnLevel ? '#22c55e' : '#ffffff'
const carStroke = entry.isCarOnLevel ? '#15803d' : '#0ea5e9' 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 ( return (
<g <g
@@ -6112,53 +6240,154 @@ const FloorplanElevatorLayer = memo(function FloorplanElevatorLayer({
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
) : null} ) : null}
<polygon <g
fill={fill}
fillOpacity={fillOpacity}
points={entry.points}
pointerEvents="none" pointerEvents="none"
stroke={stroke} transform={`translate(${centerX} ${centerY}) rotate(${rotationDeg})`}
strokeLinejoin="round" >
strokeWidth={isActive ? '2.4' : isHovered ? '2.1' : '1.45'} <rect
vectorEffect="non-scaling-stroke" fill={shaftShellFill}
/> fillOpacity={shaftShellOpacity}
<line height={shaftDepth}
pointerEvents="none" stroke={stroke}
stroke={doorStroke} strokeLinejoin="round"
strokeLinecap="round" strokeWidth={isActive ? '2.4' : isHovered ? '2.1' : '1.45'}
strokeWidth={isActive ? '3.2' : '2.4'} vectorEffect="non-scaling-stroke"
vectorEffect="non-scaling-stroke" width={shaftWidth}
x1={toSvgX(entry.frontEdge.start.x)} x={-entry.outerHalfWidth}
x2={toSvgX(entry.frontEdge.end.x)} y={-entry.outerHalfDepth}
y1={toSvgY(entry.frontEdge.start.y)} />
y2={toSvgY(entry.frontEdge.end.y)} <rect
/> fill={shaftClearFill}
<line fillOpacity={isGlassShaft ? 0.46 : 0.94}
pointerEvents="none" height={entry.shaftDepth}
stroke={doorStroke} stroke={isGlassShaft ? '#67e8f9' : '#cbd5e1'}
strokeLinecap="round" strokeDasharray={isGlassShaft ? '2 2' : undefined}
strokeOpacity={0.84} strokeWidth="1"
strokeWidth="1.7" vectorEffect="non-scaling-stroke"
vectorEffect="non-scaling-stroke" width={entry.shaftWidth}
x1={toSvgX(frontCenter.x)} x={shaftClearX}
x2={toSvgX(doorIndicatorEnd.x)} y={shaftClearY}
y1={toSvgY(frontCenter.y)} />
y2={toSvgY(doorIndicatorEnd.y)} <rect
/> fill={cabFill}
{showCarMarker ? ( 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 <circle
cx={centerX} cx={callStationX}
cy={centerY} cy={callStationY - 0.025}
fill={carFill} fill={entry.isQueuedLevel || entry.isTargetLevel ? '#22c55e' : '#e2e8f0'}
fillOpacity={entry.isCarOnLevel ? 0.92 : 0.82} r={0.025}
pointerEvents="none" stroke="#f8fafc"
r={entry.isCarOnLevel ? 0.14 : 0.11} strokeWidth="0.5"
stroke={carStroke}
strokeDasharray={entry.isCarOnLevel ? undefined : '3 2'}
strokeWidth="1.8"
vectorEffect="non-scaling-stroke" 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 <polygon
fill="transparent" fill="transparent"
onClick={ onClick={
@@ -6169,12 +6398,95 @@ const FloorplanElevatorLayer = memo(function FloorplanElevatorLayer({
} }
: undefined : undefined
} }
onPointerDown={
canSelectElevators && isSelected
? (event) => {
if (event.button === 0) {
onElevatorPointerDown(elevator.id, event)
}
}
: undefined
}
points={entry.points} points={entry.points}
pointerEvents={canSelectElevators ? 'all' : 'none'} pointerEvents={canSelectElevators ? 'all' : 'none'}
style={canSelectElevators ? { cursor: EDITOR_CURSOR } : undefined} style={canSelectElevators ? { cursor: EDITOR_CURSOR } : undefined}
> >
<title>{elevator.name || 'Elevator'}</title> <title>{elevator.name || 'Elevator'}</title>
</polygon> </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> </g>
) )
})} })}
@@ -7821,6 +8133,8 @@ export function FloorplanPanel() {
const [hoveredSpawnId, setHoveredSpawnId] = useState<SpawnNode['id'] | null>(null) const [hoveredSpawnId, setHoveredSpawnId] = useState<SpawnNode['id'] | null>(null)
const [hoveredStairId, setHoveredStairId] = useState<StairNode['id'] | null>(null) const [hoveredStairId, setHoveredStairId] = useState<StairNode['id'] | null>(null)
const [hoveredElevatorId, setHoveredElevatorId] = useState<ElevatorNode['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 [hoveredZoneId, setHoveredZoneId] = useState<ZoneNodeType['id'] | null>(null)
const [hoveredEndpointId, setHoveredEndpointId] = useState<string | null>(null) const [hoveredEndpointId, setHoveredEndpointId] = useState<string | null>(null)
const [hoveredWallCurveHandleId, setHoveredWallCurveHandleId] = useState<string | null>(null) const [hoveredWallCurveHandleId, setHoveredWallCurveHandleId] = useState<string | null>(null)
@@ -7868,6 +8182,29 @@ export function FloorplanPanel() {
[elevatorIds], [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 [stairBuildPreviewPoint, setStairBuildPreviewPoint] = useState<WallPlanPoint | null>(null)
const [stairBuildPreviewRotation, setStairBuildPreviewRotation] = useState(0) const [stairBuildPreviewRotation, setStairBuildPreviewRotation] = useState(0)
const [isPanning, setIsPanning] = useState(false) const [isPanning, setIsPanning] = useState(false)
@@ -8495,6 +8832,11 @@ export function FloorplanPanel() {
}) })
}, [cursorPoint, floorplanItems, levelDescendantNodeById, movingFloorplanNodeRevision]) }, [cursorPoint, floorplanItems, levelDescendantNodeById, movingFloorplanNodeRevision])
const floorplanElevatorEntries = useMemo<FloorplanElevatorEntry[]>(() => { const floorplanElevatorEntries = useMemo<FloorplanElevatorEntry[]>(() => {
// These keys subscribe the memo to imperative floorplan stores read with getState().
void elevatorLiveOverrideKey
void elevatorRuntimeKey
void movingFloorplanNodeRevision
if (!levelNode) { if (!levelNode) {
return [] return []
} }
@@ -8503,17 +8845,39 @@ export function FloorplanPanel() {
const interactiveElevators = useInteractive.getState().elevators const interactiveElevators = useInteractive.getState().elevators
return elevators.flatMap((elevator) => { 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)) { if (!serviceLevelIds.includes(levelNode.id)) {
return [] return []
} }
const live = useLiveTransforms.getState().get(elevator.id) const live = useLiveTransforms.getState().get(displayElevator.id)
const position = live?.position ?? elevator.position const position = live?.position ?? displayElevator.position
const rotation = live?.rotation ?? elevator.rotation const rotation = live?.rotation ?? displayElevator.rotation
const center = { x: position[0], y: position[2] } const center = { x: position[0], y: position[2] }
const halfWidth = Math.max(0.1, elevator.width / 2) const wallThickness = Math.max(displayElevator.shaftWallThickness ?? 0.09, 0.04)
const halfDepth = Math.max(0.1, elevator.depth / 2) 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]> = [ const footprintCorners: Array<readonly [number, number]> = [
[-halfWidth, -halfDepth], [-halfWidth, -halfDepth],
[halfWidth, -halfDepth], [halfWidth, -halfDepth],
@@ -8533,12 +8897,37 @@ export function FloorplanPanel() {
return [] return []
} }
const [frontNormalX, frontNormalY] = rotatePlanVector(0, -1, rotation) 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 [ return [
{ {
cabCenterLocalY: -shaftDepth / 2 + cabDepth / 2,
cabDepth,
cabWidth,
center, center,
elevator, doorStyle: displayElevator.doorStyle ?? 'center-opening',
doorWidth,
elevator: displayElevator,
frontEdge: { frontEdge: {
start: frontStart, start: frontStart,
end: frontEnd, end: frontEnd,
@@ -8550,12 +8939,25 @@ export function FloorplanPanel() {
isCarOnLevel: runtime?.currentLevelId === levelNode.id, isCarOnLevel: runtime?.currentLevelId === levelNode.id,
isQueuedLevel: runtime?.queue.includes(levelNode.id) ?? false, isQueuedLevel: runtime?.queue.includes(levelNode.id) ?? false,
isTargetLevel: runtime?.targetLevelId === levelNode.id, isTargetLevel: runtime?.targetLevelId === levelNode.id,
outerHalfDepth: halfDepth,
outerHalfWidth: halfWidth,
points: formatPolygonPoints(polygon), points: formatPolygonPoints(polygon),
polygon, polygon,
rotation,
servedLevels,
shaftDepth,
shaftWallThickness: wallThickness,
shaftWidth,
}, },
] ]
}) })
}, [elevatorRuntimeKey, elevators, levelNode, movingFloorplanNodeRevision]) }, [
elevatorLiveOverrideKey,
elevatorRuntimeKey,
elevators,
levelNode,
movingFloorplanNodeRevision,
])
const referenceFloorLevel = useMemo(() => { const referenceFloorLevel = useMemo(() => {
if (!(showReferenceFloor && levelNode)) { if (!(showReferenceFloor && levelNode)) {
return null return null
@@ -8915,6 +9317,13 @@ export function FloorplanPanel() {
return floorplanSpawnEntries.find(({ spawn }) => spawn.id === selectedIds[0]) ?? null return floorplanSpawnEntries.find(({ spawn }) => spawn.id === selectedIds[0]) ?? null
}, [floorplanSpawnEntries, selectedIds]) }, [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(() => { const selectedItemClearanceMeasurements = useMemo(() => {
if (!selectedItemEntry) { if (!selectedItemEntry) {
return [] as LinearMeasurementOverlay[] return [] as LinearMeasurementOverlay[]
@@ -9249,6 +9658,7 @@ export function FloorplanPanel() {
const isFenceMoveActive = movingNode?.type === 'fence' const isFenceMoveActive = movingNode?.type === 'fence'
const isWallMoveActive = movingNode?.type === 'wall' const isWallMoveActive = movingNode?.type === 'wall'
const isSpawnMoveActive = movingNode?.type === 'spawn' const isSpawnMoveActive = movingNode?.type === 'spawn'
const isElevatorMoveActive = movingNode?.type === 'elevator'
const isWallCurveActive = curvingWall?.type === 'wall' const isWallCurveActive = curvingWall?.type === 'wall'
const isFenceCurveActive = curvingFence?.type === 'fence' const isFenceCurveActive = curvingFence?.type === 'fence'
const isFenceEndpointMoveActive = movingFenceEndpoint !== null const isFenceEndpointMoveActive = movingFenceEndpoint !== null
@@ -9268,6 +9678,7 @@ export function FloorplanPanel() {
isFenceMoveActive || isFenceMoveActive ||
isWallMoveActive || isWallMoveActive ||
isSpawnMoveActive || isSpawnMoveActive ||
isElevatorMoveActive ||
isWallCurveActive || isWallCurveActive ||
isFenceCurveActive || isFenceCurveActive ||
isFenceEndpointMoveActive || isFenceEndpointMoveActive ||
@@ -10328,6 +10739,18 @@ export function FloorplanPanel() {
floorplanSceneRotationDeg, floorplanSceneRotationDeg,
) )
}, [floorplanSceneRotationDeg, selectedSpawnEntry, surfaceSize, viewBox]) }, [floorplanSceneRotationDeg, selectedSpawnEntry, surfaceSize, viewBox])
const selectedElevatorActionMenuPosition = useMemo(
() =>
selectedElevatorEntry
? getFloorplanActionMenuPosition(
selectedElevatorEntry.polygon,
viewBox,
surfaceSize,
floorplanSceneRotationDeg,
)
: null,
[floorplanSceneRotationDeg, selectedElevatorEntry, surfaceSize, viewBox],
)
const selectedSlabActionMenuPosition = useMemo(() => { const selectedSlabActionMenuPosition = useMemo(() => {
if (slabHoleMoveDraft) { if (slabHoleMoveDraft) {
return null return null
@@ -10899,6 +11322,115 @@ export function FloorplanPanel() {
}, },
[getSvgPointFromClientPoint, buildingRotationY], [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(() => { useEffect(() => {
siteBoundaryDraftRef.current = siteBoundaryDraft siteBoundaryDraftRef.current = siteBoundaryDraft
}, [siteBoundaryDraft]) }, [siteBoundaryDraft])
@@ -12916,6 +13448,10 @@ export function FloorplanPanel() {
return return
} }
if (elevatorResizeDragState?.pointerId === event.pointerId) {
return
}
if (wallEndpointDragRef.current?.pointerId === event.pointerId) { if (wallEndpointDragRef.current?.pointerId === event.pointerId) {
return return
} }
@@ -13152,6 +13688,7 @@ export function FloorplanPanel() {
ceilingHoleMoveDraft, ceilingHoleMoveDraft,
ceilingHoleVertexDragState, ceilingHoleVertexDragState,
ceilingVertexDragState, ceilingVertexDragState,
elevatorResizeDragState,
siteVertexDragState, siteVertexDragState,
slabHoleMoveDraft, slabHoleMoveDraft,
slabHoleVertexDragState, slabHoleVertexDragState,
@@ -14181,6 +14718,36 @@ export function FloorplanPanel() {
}, },
[emitFloorplanNodeClick], [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( const handleZoneLabelClick = useCallback(
(zoneId: ZoneNodeType['id'], _event: ReactMouseEvent<SVGElement>) => { (zoneId: ZoneNodeType['id'], _event: ReactMouseEvent<SVGElement>) => {
const currentZoneId = useViewer.getState().selection.zoneId const currentZoneId = useViewer.getState().selection.zoneId
@@ -14280,6 +14847,36 @@ export function FloorplanPanel() {
}, },
[deleteNode, selectedSpawnEntry, setSelection], [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( const handleItemPointerDown = useCallback(
(itemId: ItemNode['id'], event: ReactPointerEvent<SVGElement>) => { (itemId: ItemNode['id'], event: ReactPointerEvent<SVGElement>) => {
if (event.button !== 0) { if (event.button !== 0) {
@@ -16025,6 +16622,7 @@ export function FloorplanPanel() {
hasFloorplanCursorIndicator && hasFloorplanCursorIndicator &&
!panStateRef.current && !panStateRef.current &&
!guideInteractionRef.current && !guideInteractionRef.current &&
!elevatorResizeDragState &&
!wallEndpointDragRef.current && !wallEndpointDragRef.current &&
!ceilingVertexDragState && !ceilingVertexDragState &&
!ceilingHoleMoveDraft && !ceilingHoleMoveDraft &&
@@ -16061,6 +16659,7 @@ export function FloorplanPanel() {
ceilingVertexDragState, ceilingVertexDragState,
ceilingHoleMoveDraft, ceilingHoleMoveDraft,
ceilingHoleVertexDragState, ceilingHoleVertexDragState,
elevatorResizeDragState,
siteVertexDragState, siteVertexDragState,
slabHoleMoveDraft, slabHoleMoveDraft,
slabHoleVertexDragState, slabHoleVertexDragState,
@@ -16535,6 +17134,11 @@ export function FloorplanPanel() {
/> />
)} )}
<Editor2dFloorplanActionMenuLayer <Editor2dFloorplanActionMenuLayer
elevator={{
position: selectedElevatorActionMenuPosition,
onDelete: handleSelectedElevatorDelete,
onMove: handleSelectedElevatorMove,
}}
ceiling={{ ceiling={{
position: selectedCeilingActionMenuPosition, position: selectedCeilingActionMenuPosition,
onAddHole: selectedCeilingEditingHole ? undefined : handleSelectedCeilingAddHole, onAddHole: selectedCeilingEditingHole ? undefined : handleSelectedCeilingAddHole,
@@ -16869,6 +17473,10 @@ export function FloorplanPanel() {
isDeleteMode={isDeleteMode} isDeleteMode={isDeleteMode}
onElevatorHoverChange={handleElevatorHoverChange} onElevatorHoverChange={handleElevatorHoverChange}
onElevatorHoverEnter={handleFloorplanElevatorHoverEnter} onElevatorHoverEnter={handleFloorplanElevatorHoverEnter}
onElevatorPointerDown={handleElevatorPointerDown}
onElevatorResizePointerDown={handleElevatorResizePointerDown}
onElevatorResizePointerMove={handleElevatorResizePointerMove}
onElevatorResizePointerUp={handleElevatorResizePointerUp}
onElevatorSelect={handleElevatorSelect} onElevatorSelect={handleElevatorSelect}
palette={palette} palette={palette}
selectedIdSet={selectedIdSet} selectedIdSet={selectedIdSet}
@@ -90,12 +90,60 @@ function stripDuplicateFlags(metadata: ElevatorNode['metadata']) {
return nextMeta as 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) { function roundMeters(value: number) {
return Math.round(value * 100) / 100 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) { function radiansToDegrees(radians: number) {
return Math.round((radians * 180) / Math.PI) return Math.round((radians * 180) / Math.PI)
} }
@@ -288,11 +336,53 @@ export function ElevatorPanel() {
const requestLevel = useCallback( const requestLevel = useCallback(
(levelId: LevelNode['id']) => { (levelId: LevelNode['id']) => {
if (!node) return if (!node) return
if ((node.disabledLevelIds ?? []).includes(levelId)) return
useInteractive.getState().requestElevator(node.id as AnyNodeId, levelId as AnyNodeId) useInteractive.getState().requestElevator(node.id as AnyNodeId, levelId as AnyNodeId)
}, },
[node], [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( const handleServiceBoundaryChange = useCallback(
(field: 'fromLevelId' | 'toLevelId', levelId: string) => { (field: 'fromLevelId' | 'toLevelId', levelId: string) => {
if (!node) return if (!node) return
@@ -336,10 +426,22 @@ export function ElevatorPanel() {
const displayPosition = liveTransform?.position ?? displayNode.position const displayPosition = liveTransform?.position ?? displayNode.position
const displayRotation = liveTransform?.rotation ?? displayNode.rotation const displayRotation = liveTransform?.rotation ?? displayNode.rotation
const displayRotationDegrees = radiansToDegrees(displayRotation) const displayRotationDegrees = radiansToDegrees(displayRotation)
const displayShaftWidth = getResolvedShaftWidth(displayNode)
const displayShaftDepth = getResolvedShaftDepth(displayNode)
const displayShaftWallThickness = getResolvedShaftWallThickness(displayNode)
const fromLevelId = getResolvedFromLevelId(node, levels) const fromLevelId = getResolvedFromLevelId(node, levels)
const toLevelId = getResolvedToLevelId(node, levels, fromLevelId) const toLevelId = getResolvedToLevelId(node, levels, fromLevelId)
const servedLevels = getServiceLevels(levels, fromLevelId, toLevelId) 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( const selectedDefaultLevelId = defaultLevelOptions.some(
(level) => level.id === node.defaultLevelId, (level) => level.id === node.defaultLevelId,
) )
@@ -520,7 +622,102 @@ export function ElevatorPanel() {
/> />
</PanelSection> </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"> <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 <MetricControl
label="Door Width" label="Door Width"
max={Math.max(displayNode.width - 0.1, 0.5)} max={Math.max(displayNode.width - 0.1, 0.5)}
@@ -604,28 +801,88 @@ export function ElevatorPanel() {
</div> </div>
</PanelSection> </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"> <PanelSection title="Destination">
<div className="grid grid-cols-2 gap-1.5"> <div className="grid grid-cols-2 gap-1.5">
{servedLevels.map((level) => { {servedLevels.map((level) => {
const isActive = activeLevelId === level.id const isActive = activeLevelId === level.id
const stopOrder = destinationOrderByLevelId.get(level.id) const stopOrder = destinationOrderByLevelId.get(level.id)
const isDisabled = disabledLevelIds.has(level.id)
const isServiceOnly = serviceOnlyLevelIds.has(level.id)
return ( return (
<button <button
className={`flex min-h-11 items-center justify-between gap-2 rounded-lg border px-2.5 text-left transition-colors ${ className={`flex min-h-11 items-center justify-between gap-2 rounded-lg border px-2.5 text-left transition-colors ${
isActive isDisabled
? 'border-emerald-400/45 bg-emerald-400/15 text-emerald-100' ? 'cursor-not-allowed border-border/35 bg-[#202024] text-muted-foreground/55'
: 'border-border/50 bg-[#2C2C2E] text-foreground hover:bg-[#3e3e3e]' : 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} key={level.id}
onClick={() => requestLevel(level.id)} onClick={() => requestLevel(level.id)}
type="button" type="button"
> >
<span className="flex min-w-0 flex-col"> <span className="flex min-w-0 flex-col">
<span className="truncate text-xs">{level.name || `Level ${level.level}`}</span> <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"> <span className="mt-0.5 text-[10px] font-medium uppercase tracking-[0.12em] text-current/65">
Stop {stopOrder} Disabled
</span> </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>
<span <span
@@ -633,7 +890,7 @@ export function ElevatorPanel() {
stopOrder ? 'px-1.5 font-mono text-[11px] font-semibold' : '' 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> </span>
</button> </button>
) )
@@ -1,6 +1,7 @@
import { import {
type AnyNodeId, type AnyNodeId,
type ElevatorNode, type ElevatorNode,
resolveElevatorDispatchTarget,
useInteractive, useInteractive,
useLiveNodeOverrides, useLiveNodeOverrides,
useLiveTransforms, useLiveTransforms,
@@ -29,6 +30,7 @@ const CAB_COLOR = '#d7dde5'
const GLASS_COLOR = '#f8fafc' const GLASS_COLOR = '#f8fafc'
const DOOR_COLOR = '#8e98a6' const DOOR_COLOR = '#8e98a6'
const PANEL_COLOR = '#1f2937' const PANEL_COLOR = '#1f2937'
const DEFAULT_SHAFT_WALL_THICKNESS = 0.09
type Vector3Tuple = [number, number, number] 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 LABEL_MATRIX_DUMMY = new Object3D()
const SHAFT_TOP_FRAME_CLEARANCE = 0.006 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({ const SHAFT_WALL_MATERIAL = new MeshStandardMaterial({
color: SHAFT_WALL_COLOR, color: SHAFT_WALL_COLOR,
metalness: 0.08, metalness: 0.08,
@@ -64,6 +71,11 @@ const DOOR_MATERIAL = new MeshStandardMaterial({
metalness: 0.34, metalness: 0.34,
roughness: 0.34, roughness: 0.34,
}) })
const DOOR_GROOVE_MATERIAL = new MeshStandardMaterial({
color: '#5f6978',
metalness: 0.28,
roughness: 0.42,
})
const GLASS_MATERIAL = new MeshStandardMaterial({ const GLASS_MATERIAL = new MeshStandardMaterial({
color: GLASS_COLOR, color: GLASS_COLOR,
depthWrite: false, depthWrite: false,
@@ -132,6 +144,11 @@ const BUTTON_FACE_MATERIALS = {
metalness: 0.22, metalness: 0.22,
roughness: 0.3, roughness: 0.3,
}), }),
disabled: new MeshStandardMaterial({
color: '#475569',
metalness: 0.12,
roughness: 0.52,
}),
} }
const BUTTON_RING_MATERIALS = { const BUTTON_RING_MATERIALS = {
active: new MeshStandardMaterial({ active: new MeshStandardMaterial({
@@ -153,6 +170,11 @@ const BUTTON_RING_MATERIALS = {
metalness: 0.48, metalness: 0.48,
roughness: 0.28, roughness: 0.28,
}), }),
disabled: new MeshStandardMaterial({
color: '#334155',
metalness: 0.28,
roughness: 0.5,
}),
} }
const BUTTON_GLOW_MATERIALS = { const BUTTON_GLOW_MATERIALS = {
active: new MeshStandardMaterial({ active: new MeshStandardMaterial({
@@ -183,6 +205,11 @@ const BUTTON_LABEL_MATERIALS = {
metalness: 0.12, metalness: 0.12,
roughness: 0.34, roughness: 0.34,
}), }),
disabled: new MeshStandardMaterial({
color: '#94a3b8',
metalness: 0.08,
roughness: 0.5,
}),
} }
const QUEUE_STRIP_MATERIALS = { const QUEUE_STRIP_MATERIALS = {
queued: new MeshStandardMaterial({ queued: new MeshStandardMaterial({
@@ -490,6 +517,7 @@ function ElevatorMeshButton({
action = 'request-level', action = 'request-level',
active, active,
buttonKind, buttonKind,
disabled = false,
elevatorId, elevatorId,
faceSign = -1, faceSign = -1,
glyph, glyph,
@@ -502,6 +530,7 @@ function ElevatorMeshButton({
action?: ElevatorButtonAction action?: ElevatorButtonAction
active: boolean active: boolean
buttonKind: 'cab' | 'landing' buttonKind: 'cab' | 'landing'
disabled?: boolean
elevatorId: AnyNodeId elevatorId: AnyNodeId
faceSign?: -1 | 1 faceSign?: -1 | 1
glyph?: 'door-open' glyph?: 'door-open'
@@ -511,34 +540,51 @@ function ElevatorMeshButton({
queued: boolean queued: boolean
radius?: number 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 depth = active ? 0.028 : 0.04
const faceZ = faceSign * (depth / 2 + 0.004) 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( const userData = useMemo(
() => ({ () => ({
elevatorButton: { elevatorButton: {
action, action,
disabled,
elevatorId, elevatorId,
kind: buttonKind, kind: buttonKind,
levelId, levelId,
}, },
}), }),
[action, buttonKind, elevatorId, levelId], [action, buttonKind, disabled, elevatorId, levelId],
) )
const press = (event: ThreeEvent<PointerEvent>) => { const press = (event: ThreeEvent<PointerEvent>) => {
if (event.button !== 0) return if (event.button !== 0) return
if (disabled) return
if (action === 'open-door') { if (action === 'open-door') {
useInteractive.getState().openElevatorDoor(elevatorId) useInteractive.getState().openElevatorDoor(elevatorId)
return 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 ( return (
<group onPointerDown={press} position={position} userData={userData}> <group onPointerDown={press} position={position} userData={userData}>
{(active || queued) && ( {!disabled && (active || queued) && (
<mesh <mesh
dispose={null} dispose={null}
geometry={BUTTON_GLOW_GEOMETRY} 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({ function DoorLeaf({
animated, animated,
doorOpen, doorOpen,
doorPanelStyle,
doorStyle,
height, height,
side, side,
width, width,
@@ -602,20 +722,26 @@ function DoorLeaf({
levelId: AnyNodeId levelId: AnyNodeId
} }
doorOpen: number doorOpen: number
doorPanelStyle: ElevatorDoorPanelStyleValue
doorStyle: ElevatorDoorStyleValue
height: number height: number
side: 'left' | 'right' side: ElevatorDoorSide
width: number width: number
y: number y: number
z: number z: number
}) { }) {
const ref = useRef<Group>(null) const ref = useRef<Group>(null)
const direction = side === 'left' ? -1 : 1 const getLeafX = (openAmount: number) => getElevatorDoorLeafX(side, width, openAmount, doorStyle)
const getLeafX = (openAmount: number) => direction * (width / 4 + openAmount * width * 0.34) const leafWidth = getElevatorDoorLeafWidth(width, doorStyle)
const leafWidth = Math.max(width / 2 - 0.018, 0.12) const resolvedPanelStyle = getResolvedDoorPanelStyle(doorPanelStyle)
const railHeight = Math.min(0.09, Math.max(0.055, height * 0.04)) 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 stileWidth = Math.min(0.07, Math.max(0.04, leafWidth * 0.18))
const glassWidth = Math.max(leafWidth - stileWidth * 2.2, 0.03) const glassWidth = Math.max(leafWidth - stileWidth * 2.2, 0.03)
const glassHeight = Math.max(height - railHeight * 3, 0.2) 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(() => { useFrame(() => {
if (!(animated && ref.current)) return if (!(animated && ref.current)) return
@@ -631,43 +757,130 @@ function DoorLeaf({
return ( return (
<group ref={ref} position={[getLeafX(doorOpen), y + height / 2, z]}> <group ref={ref} position={[getLeafX(doorOpen), y + height / 2, z]}>
<BoxPrimitive {resolvedPanelStyle === 'glass-frame' ? (
castShadow <>
material={DOOR_MATERIAL} <BoxPrimitive
position={[0, height / 2 - railHeight / 2, 0]} castShadow
receiveShadow material={DOOR_MATERIAL}
scale={[leafWidth, railHeight, 0.05]} position={[0, height / 2 - railHeight / 2, 0]}
/> receiveShadow
<BoxPrimitive scale={[leafWidth, railHeight, 0.05]}
castShadow />
material={DOOR_MATERIAL} <BoxPrimitive
position={[0, -height / 2 + railHeight / 2, 0]} castShadow
receiveShadow material={DOOR_MATERIAL}
scale={[leafWidth, railHeight, 0.05]} position={[0, -height / 2 + railHeight / 2, 0]}
/> receiveShadow
<BoxPrimitive scale={[leafWidth, railHeight, 0.05]}
castShadow />
material={DOOR_MATERIAL} <BoxPrimitive
position={[-leafWidth / 2 + stileWidth / 2, 0, 0]} castShadow
receiveShadow material={DOOR_MATERIAL}
scale={[stileWidth, height, 0.05]} position={[-leafWidth / 2 + stileWidth / 2, 0, 0]}
/> receiveShadow
<BoxPrimitive scale={[stileWidth, height, 0.05]}
castShadow />
material={DOOR_MATERIAL} <BoxPrimitive
position={[leafWidth / 2 - stileWidth / 2, 0, 0]} castShadow
receiveShadow material={DOOR_MATERIAL}
scale={[stileWidth, height, 0.05]} position={[leafWidth / 2 - stileWidth / 2, 0, 0]}
/> receiveShadow
<BoxPrimitive scale={[stileWidth, height, 0.05]}
material={GLASS_MATERIAL} />
position={[0, 0, -0.004]} <BoxPrimitive
scale={[glassWidth, glassHeight, 0.012]} 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> </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({ function LandingDoorFrame({
doorHeight, doorHeight,
doorWidth, doorWidth,
@@ -749,6 +962,8 @@ function LandingDoorFrame({
function LandingDoor({ function LandingDoor({
animated, animated,
doorPanelStyle,
doorStyle,
elevatorId, elevatorId,
doorOpen, doorOpen,
doorHeight, doorHeight,
@@ -758,6 +973,8 @@ function LandingDoor({
z, z,
}: { }: {
animated: boolean animated: boolean
doorPanelStyle: ElevatorDoorPanelStyleValue
doorStyle: ElevatorDoorStyleValue
elevatorId: AnyNodeId elevatorId: AnyNodeId
doorOpen: number doorOpen: number
doorHeight: number doorHeight: number
@@ -767,26 +984,16 @@ function LandingDoor({
z: number z: number
}) { }) {
return ( return (
<> <ElevatorDoorLeaves
<DoorLeaf animated={animated ? { elevatorId, kind: 'landing', levelId } : undefined}
animated={animated ? { elevatorId, kind: 'landing', levelId } : undefined} doorOpen={doorOpen}
doorOpen={doorOpen} doorPanelStyle={doorPanelStyle}
height={doorHeight} doorStyle={doorStyle}
side="left" height={doorHeight}
width={doorWidth} width={doorWidth}
y={levelY} y={levelY}
z={z} z={z}
/> />
<DoorLeaf
animated={animated ? { elevatorId, kind: 'landing', levelId } : undefined}
doorOpen={doorOpen}
height={doorHeight}
side="right"
width={doorWidth}
y={levelY}
z={z}
/>
</>
) )
} }
@@ -856,13 +1063,24 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
cabRef.current.position.y = runtime.carY cabRef.current.position.y = runtime.carY
}, 2.6) }, 2.6)
const shaftWidth = Math.max(renderNode.width, 0.8) const cabWidth = getElevatorCabWidth(renderNode)
const shaftDepth = Math.max(renderNode.depth, 0.8) const cabDepth = getElevatorCabDepth(renderNode)
const shaftWidth = getElevatorShaftWidth(renderNode, cabWidth)
const shaftDepth = getElevatorShaftDepth(renderNode, cabDepth)
const cabHeight = Math.max(renderNode.cabHeight, 1.4) 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 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 shaftHeight = Math.max(totalHeight, cabHeight + 0.3)
const shaftWallThickness = 0.09
const shaftBodyHeight = Math.max(shaftHeight - shaftWallThickness, 0.01) const shaftBodyHeight = Math.max(shaftHeight - shaftWallThickness, 0.01)
const shaftBodyCenterY = shaftBaseY + shaftBodyHeight / 2 const shaftBodyCenterY = shaftBaseY + shaftBodyHeight / 2
const shaftTopCapBottomY = shaftBaseY + shaftHeight - shaftWallThickness const shaftTopCapBottomY = shaftBaseY + shaftHeight - shaftWallThickness
@@ -906,6 +1124,14 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
runtimeStatus?.queue, runtimeStatus?.queue,
runtimeStatus?.targetLevelId, 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 doorOpen = runtimeSnapshot?.doorOpen ?? 0
const doorOpenButtonActive = const doorOpenButtonActive =
doorOpen > 0.12 || doorOpen > 0.12 ||
@@ -916,8 +1142,9 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
const frontWallZ = -shaftDepth / 2 - shaftWallThickness / 2 const frontWallZ = -shaftDepth / 2 - shaftWallThickness / 2
const frontZ = frontWallZ - shaftWallThickness / 2 - 0.018 const frontZ = frontWallZ - shaftWallThickness / 2 - 0.018
const landingPanelX = Math.min(shaftWidth / 2 - 0.16, doorWidth / 2 + 0.18) const landingPanelX = Math.min(shaftWidth / 2 - 0.16, doorWidth / 2 + 0.18)
const cabPanelX = shaftWidth / 2 - 0.075 const cabCenterZ = -shaftDepth / 2 + cabDepth / 2
const cabPanelZ = -shaftDepth / 2 + 0.36 const cabPanelX = cabWidth / 2 - 0.075
const cabPanelZ = cabCenterZ - cabDepth / 2 + 0.36
const cabButtonColumns = entries.length > 1 ? 2 : 1 const cabButtonColumns = entries.length > 1 ? 2 : 1
const cabButtonRows = Math.max(1, Math.ceil(entries.length / cabButtonColumns)) const cabButtonRows = Math.max(1, Math.ceil(entries.length / cabButtonColumns))
const cabButtonSpacingX = 0.14 const cabButtonSpacingX = 0.14
@@ -956,28 +1183,28 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
> >
<BoxPrimitive <BoxPrimitive
castShadow castShadow
material={SHAFT_SIDE_MATERIAL} material={shaftShellMaterial}
position={[0, shaftBodyCenterY, shaftDepth / 2 + shaftWallThickness / 2]} position={[0, shaftBodyCenterY, shaftDepth / 2 + shaftWallThickness / 2]}
receiveShadow receiveShadow
scale={[shaftWidth + shaftWallThickness * 2, shaftBodyHeight, shaftWallThickness]} scale={[shaftWidth + shaftWallThickness * 2, shaftBodyHeight, shaftWallThickness]}
/> />
<BoxPrimitive <BoxPrimitive
castShadow castShadow
material={SHAFT_SIDE_MATERIAL} material={shaftShellMaterial}
position={[-shaftWidth / 2 - shaftWallThickness / 2, shaftBodyCenterY, 0]} position={[-shaftWidth / 2 - shaftWallThickness / 2, shaftBodyCenterY, 0]}
receiveShadow receiveShadow
scale={[shaftWallThickness, shaftBodyHeight, shaftDepth + shaftWallThickness * 2]} scale={[shaftWallThickness, shaftBodyHeight, shaftDepth + shaftWallThickness * 2]}
/> />
<BoxPrimitive <BoxPrimitive
castShadow castShadow
material={SHAFT_SIDE_MATERIAL} material={shaftShellMaterial}
position={[shaftWidth / 2 + shaftWallThickness / 2, shaftBodyCenterY, 0]} position={[shaftWidth / 2 + shaftWallThickness / 2, shaftBodyCenterY, 0]}
receiveShadow receiveShadow
scale={[shaftWallThickness, shaftBodyHeight, shaftDepth + shaftWallThickness * 2]} scale={[shaftWallThickness, shaftBodyHeight, shaftDepth + shaftWallThickness * 2]}
/> />
<BoxPrimitive <BoxPrimitive
castShadow castShadow
material={SHAFT_SIDE_MATERIAL} material={shaftTopMaterial}
position={[0, shaftBaseY + shaftHeight - shaftWallThickness / 2, 0]} position={[0, shaftBaseY + shaftHeight - shaftWallThickness / 2, 0]}
receiveShadow receiveShadow
scale={[ scale={[
@@ -991,57 +1218,49 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
<BoxPrimitive <BoxPrimitive
castShadow castShadow
material={CAB_MATERIAL} material={CAB_MATERIAL}
position={[0, 0.04, 0]} position={[0, 0.04, cabCenterZ]}
receiveShadow receiveShadow
scale={[shaftWidth, 0.08, shaftDepth]} scale={[cabWidth, 0.08, cabDepth]}
/> />
<BoxPrimitive <BoxPrimitive
castShadow castShadow
material={CAB_MATERIAL} material={CAB_MATERIAL}
position={[0, cabHeight - 0.04, 0]} position={[0, cabHeight - 0.04, cabCenterZ]}
receiveShadow receiveShadow
scale={[shaftWidth, 0.08, shaftDepth]} scale={[cabWidth, 0.08, cabDepth]}
/> />
<BoxPrimitive <BoxPrimitive
castShadow castShadow
material={CAB_MATERIAL} material={CAB_MATERIAL}
position={[0, cabHeight / 2, shaftDepth / 2 - 0.04]} position={[0, cabHeight / 2, cabCenterZ + cabDepth / 2 - 0.04]}
receiveShadow receiveShadow
scale={[shaftWidth, cabHeight, 0.08]} scale={[cabWidth, cabHeight, 0.08]}
/> />
<BoxPrimitive <BoxPrimitive
castShadow castShadow
material={CAB_MATERIAL} material={CAB_MATERIAL}
position={[-shaftWidth / 2 + 0.04, cabHeight / 2, 0]} position={[-cabWidth / 2 + 0.04, cabHeight / 2, cabCenterZ]}
receiveShadow receiveShadow
scale={[0.08, cabHeight, shaftDepth]} scale={[0.08, cabHeight, cabDepth]}
/> />
<BoxPrimitive <BoxPrimitive
castShadow castShadow
material={CAB_MATERIAL} material={CAB_MATERIAL}
position={[shaftWidth / 2 - 0.04, cabHeight / 2, 0]} position={[cabWidth / 2 - 0.04, cabHeight / 2, cabCenterZ]}
receiveShadow receiveShadow
scale={[0.08, cabHeight, shaftDepth]} scale={[0.08, cabHeight, cabDepth]}
/> />
<DoorLeaf <ElevatorDoorLeaves
animated={{ elevatorId, kind: 'cab' }} animated={{ elevatorId, kind: 'cab' }}
doorOpen={doorOpen} doorOpen={doorOpen}
doorPanelStyle={doorPanelStyle}
doorStyle={doorStyle}
height={doorHeight} height={doorHeight}
side="left"
width={doorWidth}
y={0}
z={frontZ}
/>
<DoorLeaf
animated={{ elevatorId, kind: 'cab' }}
doorOpen={doorOpen}
height={doorHeight}
side="right"
width={doorWidth} width={doorWidth}
y={0} y={0}
z={frontZ} z={frontZ}
@@ -1067,21 +1286,23 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
{entries.map((entry, index) => { {entries.map((entry, index) => {
const column = index % cabButtonColumns const column = index % cabButtonColumns
const row = Math.floor(index / cabButtonColumns) const row = Math.floor(index / cabButtonColumns)
const isDisabledLevel = disabledLevelIds.has(entry.id)
const x = const x =
cabFloorButtonOffsetX + (column - (cabButtonColumns - 1) / 2) * cabButtonSpacingX cabFloorButtonOffsetX + (column - (cabButtonColumns - 1) / 2) * cabButtonSpacingX
const y = (row - (cabButtonRows - 1) / 2) * cabButtonSpacingY const y = (row - (cabButtonRows - 1) / 2) * cabButtonSpacingY
return ( return (
<ElevatorMeshButton <ElevatorMeshButton
active={activeLevelId === entry.id} active={!isDisabledLevel && activeLevelId === entry.id}
buttonKind="cab" buttonKind="cab"
disabled={isDisabledLevel}
elevatorId={elevatorId} elevatorId={elevatorId}
faceSign={1} faceSign={1}
key={entry.id} key={entry.id}
label={entry.label} label={entry.label}
levelId={entry.id as AnyNodeId} levelId={entry.id as AnyNodeId}
position={[x, y, 0.045]} 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 }) => { {entrySpans.map(({ entry, levelTopY }) => {
const isCurrentLevel = activeLevelId === entry.id 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 isPendingLevel = pendingLevelId === entry.id
const showLandingReadout = isCurrentLevel || isPendingLevel || isQueuedLevel const showLandingReadout = isCurrentLevel || isPendingLevel || isQueuedLevel
@@ -1117,6 +1340,8 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
/> />
<LandingDoor <LandingDoor
animated={isCurrentLevel} animated={isCurrentLevel}
doorPanelStyle={doorPanelStyle}
doorStyle={doorStyle}
elevatorId={elevatorId} elevatorId={elevatorId}
doorHeight={doorHeight} doorHeight={doorHeight}
doorOpen={isCurrentLevel ? doorOpen : 0} doorOpen={isCurrentLevel ? doorOpen : 0}
@@ -1141,8 +1366,9 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
scale={[0.18, 0.42, 0.04]} scale={[0.18, 0.42, 0.04]}
/> />
<ElevatorMeshButton <ElevatorMeshButton
active={isCurrentLevel && doorOpen > 0.5} active={!isDisabledLevel && !isServiceOnlyLevel && isCurrentLevel && doorOpen > 0.5}
buttonKind="landing" buttonKind="landing"
disabled={isDisabledLevel || isServiceOnlyLevel}
elevatorId={elevatorId} elevatorId={elevatorId}
levelId={entry.id as AnyNodeId} levelId={entry.id as AnyNodeId}
position={[0, 0.06, -0.045]} position={[0, 0.06, -0.045]}