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