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