diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 49f5063e..b5996efd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -85,6 +85,7 @@ export { } from './store/use-live-node-overrides' export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' export { clearSceneHistory, default as useScene } from './store/use-scene' +export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch' export { syncAutoElevatorOpenings } from './systems/elevator/elevator-opening-sync' export { resolveElevatorBuildingLevels, diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 14c8db61..aede4b6a 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -42,7 +42,12 @@ export { ColumnSupportStyle, } from './nodes/column' export { DoorNode, DoorSegment } from './nodes/door' -export { ElevatorDoorStyle, ElevatorNode } from './nodes/elevator' +export { + ElevatorDoorPanelStyle, + ElevatorDoorStyle, + ElevatorNode, + ElevatorShaftStyle, +} from './nodes/elevator' export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence' export { GuideNode, GuideScaleReference } from './nodes/guide' export type { diff --git a/packages/core/src/schema/nodes/elevator.ts b/packages/core/src/schema/nodes/elevator.ts index 01a142b4..37d27572 100644 --- a/packages/core/src/schema/nodes/elevator.ts +++ b/packages/core/src/schema/nodes/elevator.ts @@ -4,8 +4,12 @@ import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' export const ElevatorDoorStyle = z.enum(['center-opening', 'single-left', 'single-right']) +export const ElevatorDoorPanelStyle = z.enum(['glass-frame', 'solid-panel', 'segmented-panel']) +export const ElevatorShaftStyle = z.enum(['solid', 'glass']) +export type ElevatorDoorPanelStyle = z.infer export type ElevatorDoorStyle = z.infer +export type ElevatorShaftStyle = z.infer export const ElevatorNode = BaseNode.extend({ id: objectId('elevator'), @@ -17,13 +21,20 @@ export const ElevatorNode = BaseNode.extend({ rotation: z.number().default(0), width: z.number().default(1.6), depth: z.number().default(1.6), + shaftWidth: z.number().optional(), + shaftDepth: z.number().optional(), + shaftWallThickness: z.number().default(0.09), + shaftStyle: ElevatorShaftStyle.default('solid'), cabHeight: z.number().default(2.35), doorWidth: z.number().default(0.95), doorHeight: z.number().default(2.1), doorStyle: ElevatorDoorStyle.default('center-opening'), + doorPanelStyle: ElevatorDoorPanelStyle.default('glass-frame'), fromLevelId: z.string().nullable().default(null), toLevelId: z.string().nullable().default(null), servedLevelIds: z.array(z.string()).optional(), + disabledLevelIds: z.array(z.string()).default([]), + serviceOnlyLevelIds: z.array(z.string()).default([]), defaultLevelId: z.string().nullable().default(null), speed: z.number().default(2.2), doorDurationMs: z.number().default(900), @@ -34,11 +45,17 @@ export const ElevatorNode = BaseNode.extend({ - parentId: building that owns this elevator - position: building-local shaft center on the X/Z plane - rotation: rotation around the Y axis - - width/depth: shaft and cab footprint + - width/depth: cab footprint + - shaftWidth/shaftDepth: optional clear shaft footprint; falls back to cab footprint + - shaftWallThickness: visible shaft shell thickness + - shaftStyle: solid or glass shaft shell presentation - cabHeight: visible elevator cab height - - doorWidth/doorHeight/doorStyle: landing and cab door presentation + - doorWidth/doorHeight/doorStyle: landing and cab door movement/opening presentation + - doorPanelStyle: visual leaf style for glass-frame, solid-panel, or segmented-panel doors - fromLevelId / toLevelId: source and destination levels used for service range and auto cutouts - servedLevelIds: legacy optional explicit level list; used only when from/to are missing + - disabledLevelIds: stops visible in the service range but unavailable for public/cab requests + - serviceOnlyLevelIds: stops unavailable from landing calls but available from cab/admin controls - defaultLevelId: starting/resting level, falling back to the lowest served level - speed/doorDurationMs/dwellMs: runtime animation defaults `, diff --git a/packages/core/src/systems/elevator/elevator-dispatch.ts b/packages/core/src/systems/elevator/elevator-dispatch.ts new file mode 100644 index 00000000..e3f0f46d --- /dev/null +++ b/packages/core/src/systems/elevator/elevator-dispatch.ts @@ -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 + +type ResolveElevatorDispatchTargetArgs = { + elevators: ElevatorRuntimeMap + levelId: AnyNodeId + nodes: Record + 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 +}) { + 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 +} diff --git a/packages/core/src/systems/elevator/elevator-opening-sync.ts b/packages/core/src/systems/elevator/elevator-opening-sync.ts index b1ef1bd0..c02d1ee8 100644 --- a/packages/core/src/systems/elevator/elevator-opening-sync.ts +++ b/packages/core/src/systems/elevator/elevator-opening-sync.ts @@ -11,6 +11,7 @@ type SurfaceHoleMetadata = { } const ELEVATOR_OPENING_PADDING = 0.08 +const DEFAULT_ELEVATOR_SHAFT_WALL_THICKNESS = 0.09 function pointsEqual(a: Point2D, b: Point2D, tolerance = 1e-5) { const dx = a[0] - b[0] @@ -141,8 +142,14 @@ function shouldApplyElevatorToCeiling( } function getElevatorOpeningPolygon(elevator: ElevatorNode): Point2D[] { - const halfWidth = Math.max(elevator.width, 0.8) / 2 + ELEVATOR_OPENING_PADDING - const halfDepth = Math.max(elevator.depth, 0.8) / 2 + ELEVATOR_OPENING_PADDING + const wallThickness = Math.max( + elevator.shaftWallThickness ?? DEFAULT_ELEVATOR_SHAFT_WALL_THICKNESS, + 0.04, + ) + const shaftWidth = Math.max(elevator.shaftWidth ?? elevator.width, elevator.width, 0.8) + const shaftDepth = Math.max(elevator.shaftDepth ?? elevator.depth, elevator.depth, 0.8) + const halfWidth = shaftWidth / 2 + wallThickness + ELEVATOR_OPENING_PADDING + const halfDepth = shaftDepth / 2 + wallThickness + ELEVATOR_OPENING_PADDING const corners: Point2D[] = [ [-halfWidth, -halfDepth], [halfWidth, -halfDepth], diff --git a/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx index fefaf949..5d2e64a5 100644 --- a/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx @@ -20,6 +20,7 @@ export type FloorplanActionMenuEntry = { } type FloorplanActionMenuLayerProps = { + elevator: FloorplanActionMenuEntry item: FloorplanActionMenuEntry wall: FloorplanActionMenuEntry fence: FloorplanActionMenuEntry @@ -33,6 +34,7 @@ type FloorplanActionMenuLayerProps = { } export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({ + elevator, item, wall, fence, @@ -55,6 +57,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({ } const entries: FloorplanActionMenuEntry[] = [ + elevator, item, wall, fence, diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 3b0bb1e5..a356699f 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -7,6 +7,7 @@ import { type ElevatorNode, emitter, resolveElevatorBuildingLevels, + resolveElevatorDispatchTarget, resolveElevatorServiceLevels, sceneRegistry, useInteractive, @@ -129,9 +130,12 @@ type ElevatorColliderUserData = { levelId?: AnyNodeId localPosition: [number, number, number] matrixInitialized?: boolean - side?: 'left' | 'right' + side?: ElevatorDoorSide } +type ElevatorDoorSide = 'left' | 'right' +type ElevatorDoorStyleValue = ElevatorNode['doorStyle'] + type ElevatorColliderMesh = Mesh & { userData: Mesh['userData'] & ElevatorColliderUserData } @@ -164,6 +168,7 @@ function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | n current.userData as { elevatorButton?: { action?: unknown + disabled?: unknown elevatorId?: unknown kind?: unknown levelId?: unknown @@ -171,6 +176,10 @@ function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | n } ).elevatorButton + if (candidate?.disabled === true) { + return null + } + if (typeof candidate?.elevatorId === 'string' && candidate.kind === 'cab') { const action = candidate.action === 'open-door' ? 'open-door' : 'request-level' if (action === 'open-door') { @@ -208,9 +217,65 @@ function getInteractableTargetKey(target: FirstPersonInteractableTarget | null) : `${target.type}:${target.id}` } -function getElevatorDoorLeafX(side: 'left' | 'right', width: number, doorOpen: number) { - const direction = side === 'left' ? -1 : 1 - return direction * (width / 4 + doorOpen * width * 0.34) +function getResolvedElevatorDoorStyle( + doorStyle: ElevatorDoorStyleValue | undefined, +): ElevatorDoorStyleValue { + return doorStyle ?? 'center-opening' +} + +function getElevatorDoorLeafSides( + doorStyle: ElevatorDoorStyleValue | undefined, +): ElevatorDoorSide[] { + const resolvedDoorStyle = getResolvedElevatorDoorStyle(doorStyle) + if (resolvedDoorStyle === 'single-left') return ['left'] + if (resolvedDoorStyle === 'single-right') return ['right'] + return ['left', 'right'] +} + +function getElevatorDoorLeafWidth(width: number, doorStyle: ElevatorDoorStyleValue | undefined) { + return getResolvedElevatorDoorStyle(doorStyle) === 'center-opening' + ? Math.max(width / 2 - 0.018, 0.12) + : Math.max(width - 0.018, 0.18) +} + +function getElevatorDoorLeafX( + side: ElevatorDoorSide, + width: number, + doorOpen: number, + doorStyle: ElevatorDoorStyleValue | undefined, +) { + const resolvedDoorStyle = getResolvedElevatorDoorStyle(doorStyle) + if (resolvedDoorStyle === 'center-opening') { + const direction = side === 'left' ? -1 : 1 + return direction * (width / 4 + doorOpen * width * 0.34) + } + + const direction = resolvedDoorStyle === 'single-left' ? -1 : 1 + return direction * doorOpen * width * 0.68 +} + +function getElevatorCabWidth(elevator: ElevatorNode) { + return Math.max(elevator.width, 0.8) +} + +function getElevatorCabDepth(elevator: ElevatorNode) { + return Math.max(elevator.depth, 0.8) +} + +function getElevatorShaftWallThickness(elevator: ElevatorNode) { + return Math.max(elevator.shaftWallThickness ?? ELEVATOR_COLLIDER_WALL_THICKNESS, 0.04) +} + +function getElevatorShaftWidth(elevator: ElevatorNode, cabWidth = getElevatorCabWidth(elevator)) { + return Math.max(elevator.shaftWidth ?? cabWidth, cabWidth, 0.8) +} + +function getElevatorShaftDepth(elevator: ElevatorNode, cabDepth = getElevatorCabDepth(elevator)) { + return Math.max(elevator.shaftDepth ?? cabDepth, cabDepth, 0.8) +} + +function getElevatorCabCenterZ(elevator: ElevatorNode) { + return -getElevatorShaftDepth(elevator) / 2 + getElevatorCabDepth(elevator) / 2 } function isDynamicElevatorCollider(kind: ElevatorColliderKind) { @@ -222,13 +287,14 @@ function isInsideElevatorCab( runtime: NonNullable['elevators'][AnyNodeId]>, localEyePosition: Vector3, ) { - const halfWidth = Math.max(elevator.width, 0.8) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING - const halfDepth = Math.max(elevator.depth, 0.8) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING + const halfWidth = getElevatorCabWidth(elevator) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING + const halfDepth = getElevatorCabDepth(elevator) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING + const cabCenterZ = getElevatorCabCenterZ(elevator) const cabHeight = Math.max(elevator.cabHeight, 1.4) return ( Math.abs(localEyePosition.x) <= Math.max(halfWidth, 0.24) && - Math.abs(localEyePosition.z) <= Math.max(halfDepth, 0.24) && + Math.abs(localEyePosition.z - cabCenterZ) <= Math.max(halfDepth, 0.24) && localEyePosition.y >= runtime.carY + 0.35 && localEyePosition.y <= runtime.carY + cabHeight + 0.7 ) @@ -347,18 +413,23 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] { node, nodes, ) - const shaftWidth = Math.max(node.width, 0.8) - const shaftDepth = Math.max(node.depth, 0.8) + const cabWidth = getElevatorCabWidth(node) + const cabDepth = getElevatorCabDepth(node) + const shaftWidth = getElevatorShaftWidth(node, cabWidth) + const shaftDepth = getElevatorShaftDepth(node, cabDepth) const cabHeight = Math.max(node.cabHeight, 1.4) - const doorWidth = Math.min(Math.max(node.doorWidth, 0.45), shaftWidth - 0.18) + const doorWidth = Math.min(Math.max(node.doorWidth, 0.45), cabWidth - 0.18, shaftWidth - 0.18) const doorHeight = Math.min(Math.max(node.doorHeight, 1.2), cabHeight - 0.1) + const doorStyle = getResolvedElevatorDoorStyle(node.doorStyle) const shaftHeight = Math.max(totalHeight, cabHeight + 0.3) - const wallThickness = ELEVATOR_COLLIDER_WALL_THICKNESS - const cabFloorWidth = Math.max(shaftWidth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48) - const cabFloorDepth = Math.max(shaftDepth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48) + const wallThickness = getElevatorShaftWallThickness(node) + const cabFloorWidth = Math.max(cabWidth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48) + const cabFloorDepth = Math.max(cabDepth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48) const frontWallZ = -shaftDepth / 2 - wallThickness / 2 const frontZ = frontWallZ - wallThickness / 2 - 0.018 - const leafWidth = Math.max(doorWidth / 2 - 0.018, 0.12) + const cabCenterZ = -shaftDepth / 2 + cabDepth / 2 + const leafWidth = getElevatorDoorLeafWidth(doorWidth, doorStyle) + const doorLeafSides = getElevatorDoorLeafSides(doorStyle) const resolvedShaftTopY = Math.max(shaftTopY, shaftBaseY + shaftHeight) meshes.push( @@ -390,38 +461,31 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] { typedElevatorId, 'cab-floor', [cabFloorWidth, ELEVATOR_COLLIDER_FLOOR_THICKNESS, cabFloorDepth], - [0, ELEVATOR_COLLIDER_FLOOR_THICKNESS / 2, 0], + [0, ELEVATOR_COLLIDER_FLOOR_THICKNESS / 2, cabCenterZ], ), createElevatorColliderMesh( typedElevatorId, 'cab-ceiling', - [shaftWidth, wallThickness, shaftDepth], - [0, cabHeight - wallThickness / 2, 0], + [cabWidth, wallThickness, cabDepth], + [0, cabHeight - wallThickness / 2, cabCenterZ], ), createElevatorColliderMesh( typedElevatorId, 'cab-back', - [shaftWidth, cabHeight, wallThickness], - [0, cabHeight / 2, shaftDepth / 2 - wallThickness / 2], + [cabWidth, cabHeight, wallThickness], + [0, cabHeight / 2, cabCenterZ + cabDepth / 2 - wallThickness / 2], ), createElevatorColliderMesh( typedElevatorId, 'cab-left', - [wallThickness, cabHeight, shaftDepth], - [-shaftWidth / 2 + wallThickness / 2, cabHeight / 2, 0], + [wallThickness, cabHeight, cabDepth], + [-cabWidth / 2 + wallThickness / 2, cabHeight / 2, cabCenterZ], ), createElevatorColliderMesh( typedElevatorId, 'cab-right', - [wallThickness, cabHeight, shaftDepth], - [shaftWidth / 2 - wallThickness / 2, cabHeight / 2, 0], - ), - createElevatorColliderMesh( - typedElevatorId, - 'cab-door-left', - [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], - [0, doorHeight / 2, frontZ], - { doorWidth, side: 'left' }, + [wallThickness, cabHeight, cabDepth], + [cabWidth / 2 - wallThickness / 2, cabHeight / 2, cabCenterZ], ), createElevatorColliderMesh( typedElevatorId, @@ -430,12 +494,14 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] { [0, doorHeight / 2, frontZ], { doorWidth }, ), - createElevatorColliderMesh( - typedElevatorId, - 'cab-door-right', - [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], - [0, doorHeight / 2, frontZ], - { doorWidth, side: 'right' }, + ...doorLeafSides.map((side) => + createElevatorColliderMesh( + typedElevatorId, + side === 'left' ? 'cab-door-left' : 'cab-door-right', + [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], + [0, doorHeight / 2, frontZ], + { doorWidth, side }, + ), ), ) @@ -473,13 +539,6 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] { [shaftWidth, headerHeight, wallDepth], [0, entry.baseY + doorHeight + headerHeight / 2, frontWallZ], ), - createElevatorColliderMesh( - typedElevatorId, - 'landing-door-left', - [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], - [0, entry.baseY + doorHeight / 2, frontZ - 0.02], - { doorWidth, levelId: entry.id, side: 'left' }, - ), createElevatorColliderMesh( typedElevatorId, 'landing-door-gate', @@ -487,12 +546,14 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] { [0, entry.baseY + doorHeight / 2, frontZ - 0.02], { doorWidth, levelId: entry.id }, ), - createElevatorColliderMesh( - typedElevatorId, - 'landing-door-right', - [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], - [0, entry.baseY + doorHeight / 2, frontZ - 0.02], - { doorWidth, levelId: entry.id, side: 'right' }, + ...doorLeafSides.map((side) => + createElevatorColliderMesh( + typedElevatorId, + side === 'left' ? 'landing-door-left' : 'landing-door-right', + [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], + [0, entry.baseY + doorHeight / 2, frontZ - 0.02], + { doorWidth, levelId: entry.id, side }, + ), ), ) } @@ -778,7 +839,18 @@ export const FirstPersonControls = () => { useInteractive.getState().openElevatorDoor(target.id) return } - if (target.levelId) useInteractive.getState().requestElevator(target.id, target.levelId) + if (target.levelId) { + const targetElevatorId = + target.buttonKind === 'landing' + ? resolveElevatorDispatchTarget({ + elevators: useInteractive.getState().elevators, + levelId: target.levelId, + nodes: useScene.getState().nodes, + requestedElevatorId: target.id, + }) + : target.id + useInteractive.getState().requestElevator(targetElevatorId, target.levelId) + } return } @@ -1004,7 +1076,12 @@ export const FirstPersonControls = () => { mesh.visible = false continue } - localX = getElevatorDoorLeafX(side ?? 'left', doorWidth ?? node.doorWidth, runtime.doorOpen) + localX = getElevatorDoorLeafX( + side ?? 'left', + doorWidth ?? node.doorWidth, + runtime.doorOpen, + node.doorStyle, + ) mesh.visible = true } else if (isCabDoorGate) { if (!runtime) { @@ -1014,7 +1091,12 @@ export const FirstPersonControls = () => { mesh.visible = runtime.doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD } else if (isDoorCollider) { const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0 - localX = getElevatorDoorLeafX(side ?? 'left', doorWidth ?? node.doorWidth, doorOpen) + localX = getElevatorDoorLeafX( + side ?? 'left', + doorWidth ?? node.doorWidth, + doorOpen, + node.doorStyle, + ) mesh.visible = true } else if (isLandingDoorGate) { const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0 @@ -1045,6 +1127,7 @@ export const FirstPersonControls = () => { const activeRide = ridingElevatorRef.current let nextRide: { cabHeight: number + cabCenterZ: number carY: number doorOpen: number elevatorId: AnyNodeId @@ -1076,12 +1159,13 @@ export const FirstPersonControls = () => { elevatorLocalEyePosition.copy(camera.position) object.worldToLocal(elevatorLocalEyePosition) - const halfWidth = Math.max(node.width, 0.8) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING - const halfDepth = Math.max(node.depth, 0.8) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING + const halfWidth = getElevatorCabWidth(node) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING + const halfDepth = getElevatorCabDepth(node) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING + const cabCenterZ = getElevatorCabCenterZ(node) const cabHeight = Math.max(node.cabHeight, 1.4) const insideFootprint = Math.abs(elevatorLocalEyePosition.x) <= Math.max(halfWidth, 0.24) && - Math.abs(elevatorLocalEyePosition.z) <= Math.max(halfDepth, 0.24) + Math.abs(elevatorLocalEyePosition.z - cabCenterZ) <= Math.max(halfDepth, 0.24) const insideCabHeight = elevatorLocalEyePosition.y >= runtime.carY + 0.35 && elevatorLocalEyePosition.y <= runtime.carY + cabHeight + 0.7 @@ -1094,6 +1178,7 @@ export const FirstPersonControls = () => { if ((insideFootprint && insideCabHeight) || continuingRide) { nextRide = { cabHeight, + cabCenterZ, carY: runtime.carY, doorOpen: runtime.doorOpen, elevatorId: typedElevatorId, @@ -1151,8 +1236,8 @@ export const FirstPersonControls = () => { Math.min(nextRide.halfWidth, elevatorLocalControllerPosition.x), ) const clampedZ = Math.max( - -nextRide.halfDepth, - Math.min(nextRide.halfDepth, elevatorLocalControllerPosition.z), + nextRide.cabCenterZ - nextRide.halfDepth, + Math.min(nextRide.cabCenterZ + nextRide.halfDepth, elevatorLocalControllerPosition.z), ) if ( diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 3ba8d9e0..8c04eb72 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -40,6 +40,7 @@ import { sampleWallCenterline, sceneRegistry, useInteractive, + useLiveNodeOverrides, useLiveTransforms, useScene, type WallNode, @@ -346,6 +347,21 @@ type PendingFenceDragState = { startClientY: number } +type ElevatorResizeHandle = + | 'width-negative' + | 'width-positive' + | 'depth-negative' + | 'depth-positive' + +type ElevatorResizeDragState = { + center: Point2D + elevatorId: ElevatorNode['id'] + handle: ElevatorResizeHandle + pointerId: number + rotation: number + shaftWallThickness: number +} + const GUIDE_CORNERS = ['nw', 'ne', 'se', 'sw'] as const type GuideCorner = (typeof GUIDE_CORNERS)[number] @@ -634,16 +650,38 @@ type FloorplanColumnEntry = { polygon: Point2D[] } +type FloorplanElevatorServedLevel = { + id: LevelNode['id'] + isCurrent: boolean + isDisabled: boolean + isQueued: boolean + isServiceOnly: boolean + isTarget: boolean + label: string +} + type FloorplanElevatorEntry = { + cabCenterLocalY: number + cabDepth: number + cabWidth: number center: Point2D + doorStyle: ElevatorNode['doorStyle'] + doorWidth: number elevator: ElevatorNode frontEdge: FloorplanLineSegment frontNormal: Point2D isCarOnLevel: boolean isQueuedLevel: boolean isTargetLevel: boolean + outerHalfDepth: number + outerHalfWidth: number points: string polygon: Point2D[] + rotation: number + servedLevels: FloorplanElevatorServedLevel[] + shaftDepth: number + shaftWallThickness: number + shaftWidth: number } type ReferenceFloorData = { @@ -822,6 +860,18 @@ function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max) } +function roundPlanMeters(value: number) { + return Math.round(value * 100) / 100 +} + +function getElevatorResizeAxis(handle: ElevatorResizeHandle) { + return handle.startsWith('width') ? 'width' : 'depth' +} + +function getElevatorResizeSign(handle: ElevatorResizeHandle) { + return handle.endsWith('positive') ? 1 : -1 +} + function getSelectionModifierKeys(event?: { metaKey?: boolean; ctrlKey?: boolean }) { return { meta: Boolean(event?.metaKey), @@ -6029,6 +6079,10 @@ const FloorplanElevatorLayer = memo(function FloorplanElevatorLayer({ isDeleteMode, onElevatorHoverChange, onElevatorHoverEnter, + onElevatorPointerDown, + onElevatorResizePointerDown, + onElevatorResizePointerMove, + onElevatorResizePointerUp, onElevatorSelect, palette, selectedIdSet, @@ -6041,6 +6095,17 @@ const FloorplanElevatorLayer = memo(function FloorplanElevatorLayer({ isDeleteMode: boolean onElevatorHoverChange: (elevatorId: ElevatorNode['id'] | null) => void onElevatorHoverEnter: (elevatorId: ElevatorNode['id']) => void + onElevatorPointerDown: ( + elevatorId: ElevatorNode['id'], + event: ReactPointerEvent, + ) => void + onElevatorResizePointerDown: ( + entry: FloorplanElevatorEntry, + handle: ElevatorResizeHandle, + event: ReactPointerEvent, + ) => void + onElevatorResizePointerMove: (event: ReactPointerEvent) => void + onElevatorResizePointerUp: (event: ReactPointerEvent) => void onElevatorSelect: (elevator: ElevatorNode, event: ReactMouseEvent) => void palette: FloorplanPalette selectedIdSet: ReadonlySet @@ -6060,37 +6125,100 @@ const FloorplanElevatorLayer = memo(function FloorplanElevatorLayer({ const isDeleteHovered = isDeleteMode && isHovered const isActive = isSelected || isHighlighted const showChrome = isActive || isHovered - const fill = isDeleteHovered + const isGlassShaft = elevator.shaftStyle === 'glass' + const shaftShellFill = isDeleteHovered ? palette.deleteFill : isActive ? `url(#${wallSelectionHatchId})` - : '#dbeafe' - const fillOpacity = isDeleteHovered ? 0.38 : isActive ? 0.84 : isHovered ? 0.78 : 0.64 + : isGlassShaft + ? '#dff6ff' + : '#e5e7eb' + const shaftClearFill = isGlassShaft ? '#ecfeff' : '#f8fafc' + const shaftShellOpacity = isDeleteHovered ? 0.38 : isActive ? 0.9 : isHovered ? 0.86 : 0.76 const stroke = isDeleteHovered ? palette.deleteStroke : isActive ? palette.selectedStroke : isHovered ? palette.wallHoverStroke - : '#2563eb' + : isGlassShaft + ? '#0891b2' + : '#475569' const doorStroke = isDeleteHovered ? palette.deleteStroke : isActive ? palette.selectedStroke - : '#0284c7' + : '#0369a1' const centerX = toSvgX(entry.center.x) const centerY = toSvgY(entry.center.y) - const frontCenter = { - x: (entry.frontEdge.start.x + entry.frontEdge.end.x) / 2, - y: (entry.frontEdge.start.y + entry.frontEdge.end.y) / 2, - } - const doorIndicatorEnd = { - x: frontCenter.x + entry.frontNormal.x * 0.34, - y: frontCenter.y + entry.frontNormal.y * 0.34, - } + const rotationDeg = (-entry.rotation * 180) / Math.PI + const shaftWidth = entry.outerHalfWidth * 2 + const shaftDepth = entry.outerHalfDepth * 2 + const shaftClearX = -entry.shaftWidth / 2 + const shaftClearY = -entry.shaftDepth / 2 + const cabX = -entry.cabWidth / 2 + const cabY = entry.cabCenterLocalY - entry.cabDepth / 2 + const frontLocalY = -entry.outerHalfDepth + const doorHalfWidth = entry.doorWidth / 2 + const doorTrackY = frontLocalY - 0.075 + const callStationX = Math.min(entry.outerHalfWidth - 0.12, doorHalfWidth + 0.2) + const callStationY = frontLocalY - 0.16 + const cabFill = entry.isCarOnLevel + ? '#dcfce7' + : entry.isTargetLevel || entry.isQueuedLevel + ? '#e0f2fe' + : '#f8fafc' + const cabStroke = entry.isCarOnLevel + ? '#16a34a' + : entry.isTargetLevel || entry.isQueuedLevel + ? '#0ea5e9' + : '#64748b' const showCarMarker = entry.isCarOnLevel || entry.isTargetLevel || entry.isQueuedLevel const carFill = entry.isCarOnLevel ? '#22c55e' : '#ffffff' const carStroke = entry.isCarOnLevel ? '#15803d' : '#0ea5e9' + const resizeHandles = [ + { + cursor: 'ew-resize', + handle: 'width-negative' as const, + localX: -entry.outerHalfWidth, + localY: 0, + }, + { + cursor: 'ew-resize', + handle: 'width-positive' as const, + localX: entry.outerHalfWidth, + localY: 0, + }, + { + cursor: 'ns-resize', + handle: 'depth-negative' as const, + localX: 0, + localY: -entry.outerHalfDepth, + }, + { + cursor: 'ns-resize', + handle: 'depth-positive' as const, + localX: 0, + localY: entry.outerHalfDepth, + }, + ].map((handle) => { + const [offsetX, offsetY] = rotatePlanVector(handle.localX, handle.localY, entry.rotation) + return { + ...handle, + x: entry.center.x + offsetX, + y: entry.center.y + offsetY, + } + }) + const rangeStep = 0.18 + const rangeHeight = Math.max(0, (entry.servedLevels.length - 1) * rangeStep) + const [rangeOffsetX, rangeOffsetY] = rotatePlanVector( + entry.outerHalfWidth + 0.38, + 0, + entry.rotation, + ) + const rangeX = entry.center.x + rangeOffsetX + const rangeTopY = entry.center.y + rangeOffsetY - rangeHeight / 2 + const rangeBottomY = entry.center.y + rangeOffsetY + rangeHeight / 2 return ( ) : null} - - - - {showCarMarker ? ( + transform={`translate(${centerX} ${centerY}) rotate(${rotationDeg})`} + > + + + + + + + + {entry.doorStyle === 'center-opening' ? ( + <> + + + + ) : ( + + )} + - ) : null} + {showCarMarker ? ( + + ) : null} + { + if (event.button === 0) { + onElevatorPointerDown(elevator.id, event) + } + } + : undefined + } points={entry.points} pointerEvents={canSelectElevators ? 'all' : 'none'} style={canSelectElevators ? { cursor: EDITOR_CURSOR } : undefined} > {elevator.name || 'Elevator'} + {isSelected && entry.servedLevels.length > 1 ? ( + + + {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 ( + + + + {index + 1} + + + ) + })} + + ) : null} + {isSelected && canSelectElevators && !isDeleteMode + ? resizeHandles.map((handle) => ( + + 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} ) })} @@ -7821,6 +8133,8 @@ export function FloorplanPanel() { const [hoveredSpawnId, setHoveredSpawnId] = useState(null) const [hoveredStairId, setHoveredStairId] = useState(null) const [hoveredElevatorId, setHoveredElevatorId] = useState(null) + const [elevatorResizeDragState, setElevatorResizeDragState] = + useState(null) const [hoveredZoneId, setHoveredZoneId] = useState(null) const [hoveredEndpointId, setHoveredEndpointId] = useState(null) const [hoveredWallCurveHandleId, setHoveredWallCurveHandleId] = useState(null) @@ -7868,6 +8182,29 @@ export function FloorplanPanel() { [elevatorIds], ), ) + const elevatorLiveOverrideKey = useLiveNodeOverrides( + useCallback( + (state) => + elevatorIds + .map((elevatorId) => { + const overrides = state.overrides.get(elevatorId) + if (!overrides) { + return `${elevatorId}:` + } + + return [ + elevatorId, + overrides.width ?? '', + overrides.depth ?? '', + overrides.shaftWidth ?? '', + overrides.shaftDepth ?? '', + overrides.shaftWallThickness ?? '', + ].join(':') + }) + .join('|'), + [elevatorIds], + ), + ) const [stairBuildPreviewPoint, setStairBuildPreviewPoint] = useState(null) const [stairBuildPreviewRotation, setStairBuildPreviewRotation] = useState(0) const [isPanning, setIsPanning] = useState(false) @@ -8495,6 +8832,11 @@ export function FloorplanPanel() { }) }, [cursorPoint, floorplanItems, levelDescendantNodeById, movingFloorplanNodeRevision]) const floorplanElevatorEntries = useMemo(() => { + // These keys subscribe the memo to imperative floorplan stores read with getState(). + void elevatorLiveOverrideKey + void elevatorRuntimeKey + void movingFloorplanNodeRevision + if (!levelNode) { return [] } @@ -8503,17 +8845,39 @@ export function FloorplanPanel() { const interactiveElevators = useInteractive.getState().elevators return elevators.flatMap((elevator) => { - const serviceLevelIds = resolveElevatorServiceLevelIds(elevator, nodes) + const liveOverrides = useLiveNodeOverrides.getState().get(elevator.id) + const displayElevator = liveOverrides + ? ({ ...elevator, ...liveOverrides } as ElevatorNode) + : elevator + const serviceLevelIds = resolveElevatorServiceLevelIds(displayElevator, nodes) if (!serviceLevelIds.includes(levelNode.id)) { return [] } - const live = useLiveTransforms.getState().get(elevator.id) - const position = live?.position ?? elevator.position - const rotation = live?.rotation ?? elevator.rotation + const live = useLiveTransforms.getState().get(displayElevator.id) + const position = live?.position ?? displayElevator.position + const rotation = live?.rotation ?? displayElevator.rotation const center = { x: position[0], y: position[2] } - const halfWidth = Math.max(0.1, elevator.width / 2) - const halfDepth = Math.max(0.1, elevator.depth / 2) + const wallThickness = Math.max(displayElevator.shaftWallThickness ?? 0.09, 0.04) + const cabWidth = Math.max(displayElevator.width, 0.8) + const cabDepth = Math.max(displayElevator.depth, 0.8) + const shaftWidth = Math.max( + displayElevator.shaftWidth ?? displayElevator.width, + cabWidth, + 0.8, + ) + const shaftDepth = Math.max( + displayElevator.shaftDepth ?? displayElevator.depth, + cabDepth, + 0.8, + ) + const doorWidth = Math.min( + Math.max(displayElevator.doorWidth, 0.45), + cabWidth - 0.18, + shaftWidth - 0.18, + ) + const halfWidth = Math.max(0.1, shaftWidth / 2 + wallThickness) + const halfDepth = Math.max(0.1, shaftDepth / 2 + wallThickness) const footprintCorners: Array = [ [-halfWidth, -halfDepth], [halfWidth, -halfDepth], @@ -8533,12 +8897,37 @@ export function FloorplanPanel() { return [] } const [frontNormalX, frontNormalY] = rotatePlanVector(0, -1, rotation) - const runtime = interactiveElevators[elevator.id] + const runtime = interactiveElevators[displayElevator.id] + const disabledLevelIds = new Set(displayElevator.disabledLevelIds ?? []) + const serviceOnlyLevelIds = new Set(displayElevator.serviceOnlyLevelIds ?? []) + const servedLevels = serviceLevelIds.flatMap((levelId) => { + const level = nodes[levelId as AnyNodeId] + if (level?.type !== 'level') { + return [] + } + + return [ + { + id: level.id, + isCurrent: runtime?.currentLevelId === level.id, + isDisabled: disabledLevelIds.has(level.id), + isQueued: runtime?.queue.includes(level.id) ?? false, + isServiceOnly: serviceOnlyLevelIds.has(level.id), + isTarget: runtime?.targetLevelId === level.id, + label: level.name || `L${level.level}`, + }, + ] + }) return [ { + cabCenterLocalY: -shaftDepth / 2 + cabDepth / 2, + cabDepth, + cabWidth, center, - elevator, + doorStyle: displayElevator.doorStyle ?? 'center-opening', + doorWidth, + elevator: displayElevator, frontEdge: { start: frontStart, end: frontEnd, @@ -8550,12 +8939,25 @@ export function FloorplanPanel() { isCarOnLevel: runtime?.currentLevelId === levelNode.id, isQueuedLevel: runtime?.queue.includes(levelNode.id) ?? false, isTargetLevel: runtime?.targetLevelId === levelNode.id, + outerHalfDepth: halfDepth, + outerHalfWidth: halfWidth, points: formatPolygonPoints(polygon), polygon, + rotation, + servedLevels, + shaftDepth, + shaftWallThickness: wallThickness, + shaftWidth, }, ] }) - }, [elevatorRuntimeKey, elevators, levelNode, movingFloorplanNodeRevision]) + }, [ + elevatorLiveOverrideKey, + elevatorRuntimeKey, + elevators, + levelNode, + movingFloorplanNodeRevision, + ]) const referenceFloorLevel = useMemo(() => { if (!(showReferenceFloor && levelNode)) { return null @@ -8915,6 +9317,13 @@ export function FloorplanPanel() { return floorplanSpawnEntries.find(({ spawn }) => spawn.id === selectedIds[0]) ?? null }, [floorplanSpawnEntries, selectedIds]) + const selectedElevatorEntry = useMemo(() => { + if (selectedIds.length !== 1) { + return null + } + + return floorplanElevatorEntries.find(({ elevator }) => elevator.id === selectedIds[0]) ?? null + }, [floorplanElevatorEntries, selectedIds]) const selectedItemClearanceMeasurements = useMemo(() => { if (!selectedItemEntry) { return [] as LinearMeasurementOverlay[] @@ -9249,6 +9658,7 @@ export function FloorplanPanel() { const isFenceMoveActive = movingNode?.type === 'fence' const isWallMoveActive = movingNode?.type === 'wall' const isSpawnMoveActive = movingNode?.type === 'spawn' + const isElevatorMoveActive = movingNode?.type === 'elevator' const isWallCurveActive = curvingWall?.type === 'wall' const isFenceCurveActive = curvingFence?.type === 'fence' const isFenceEndpointMoveActive = movingFenceEndpoint !== null @@ -9268,6 +9678,7 @@ export function FloorplanPanel() { isFenceMoveActive || isWallMoveActive || isSpawnMoveActive || + isElevatorMoveActive || isWallCurveActive || isFenceCurveActive || isFenceEndpointMoveActive || @@ -10328,6 +10739,18 @@ export function FloorplanPanel() { floorplanSceneRotationDeg, ) }, [floorplanSceneRotationDeg, selectedSpawnEntry, surfaceSize, viewBox]) + const selectedElevatorActionMenuPosition = useMemo( + () => + selectedElevatorEntry + ? getFloorplanActionMenuPosition( + selectedElevatorEntry.polygon, + viewBox, + surfaceSize, + floorplanSceneRotationDeg, + ) + : null, + [floorplanSceneRotationDeg, selectedElevatorEntry, surfaceSize, viewBox], + ) const selectedSlabActionMenuPosition = useMemo(() => { if (slabHoleMoveDraft) { return null @@ -10899,6 +11322,115 @@ export function FloorplanPanel() { }, [getSvgPointFromClientPoint, buildingRotationY], ) + + const previewElevatorResize = useCallback( + (dragState: ElevatorResizeDragState, planPoint: WallPlanPoint) => { + const localDeltaX = planPoint[0] - dragState.center.x + const localDeltaY = planPoint[1] - dragState.center.y + const [localX, localY] = rotatePlanVector(localDeltaX, localDeltaY, -dragState.rotation) + const axis = getElevatorResizeAxis(dragState.handle) + const sign = getElevatorResizeSign(dragState.handle) + const localDistance = sign * (axis === 'width' ? localX : localY) + const nextOuterSize = Math.max(0.1, localDistance) * 2 + + if (axis === 'width') { + const nextShaftWidth = roundPlanMeters( + Math.max(0.8, nextOuterSize - dragState.shaftWallThickness * 2), + ) + const nextCabWidth = nextShaftWidth + useLiveNodeOverrides + .getState() + .set(dragState.elevatorId, { shaftWidth: nextShaftWidth, width: nextCabWidth }) + setCursorPoint(planPoint) + return { shaftWidth: nextShaftWidth, width: nextCabWidth } satisfies Partial + } + + 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 + }, + [], + ) + + const handleElevatorResizePointerDown = useCallback( + ( + entry: FloorplanElevatorEntry, + handle: ElevatorResizeHandle, + event: ReactPointerEvent, + ) => { + 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) => { + 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) => { + const dragState = elevatorResizeDragState + if (!dragState || dragState.pointerId !== event.pointerId) { + return + } + + const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) + const updates = planPoint ? previewElevatorResize(dragState, planPoint) : {} + + event.preventDefault() + event.stopPropagation() + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + + useLiveNodeOverrides.getState().clear(dragState.elevatorId) + if (Object.keys(updates).length > 0) { + updateNode(dragState.elevatorId as AnyNodeId, updates) + } + setElevatorResizeDragState(null) + setCursorPoint(null) + }, + [elevatorResizeDragState, getPlanPointFromClientPoint, previewElevatorResize, updateNode], + ) + useEffect(() => { siteBoundaryDraftRef.current = siteBoundaryDraft }, [siteBoundaryDraft]) @@ -12916,6 +13448,10 @@ export function FloorplanPanel() { return } + if (elevatorResizeDragState?.pointerId === event.pointerId) { + return + } + if (wallEndpointDragRef.current?.pointerId === event.pointerId) { return } @@ -13152,6 +13688,7 @@ export function FloorplanPanel() { ceilingHoleMoveDraft, ceilingHoleVertexDragState, ceilingVertexDragState, + elevatorResizeDragState, siteVertexDragState, slabHoleMoveDraft, slabHoleVertexDragState, @@ -14181,6 +14718,36 @@ export function FloorplanPanel() { }, [emitFloorplanNodeClick], ) + const handleElevatorPointerDown = useCallback( + (elevatorId: ElevatorNode['id'], event: ReactPointerEvent) => { + if (event.button !== 0) { + return + } + + const elevator = selectedElevatorEntry?.elevator + if (!elevator || elevator.id !== elevatorId) { + return + } + + event.preventDefault() + event.stopPropagation() + + const suppressClick = (clickEvent: MouseEvent) => { + clickEvent.stopImmediatePropagation() + clickEvent.preventDefault() + window.removeEventListener('click', suppressClick, true) + } + window.addEventListener('click', suppressClick, true) + requestAnimationFrame(() => { + window.removeEventListener('click', suppressClick, true) + }) + + sfxEmitter.emit('sfx:item-pick') + setMovingNode(elevator) + setSelection({ selectedIds: [] }) + }, + [selectedElevatorEntry, setMovingNode, setSelection], + ) const handleZoneLabelClick = useCallback( (zoneId: ZoneNodeType['id'], _event: ReactMouseEvent) => { const currentZoneId = useViewer.getState().selection.zoneId @@ -14280,6 +14847,36 @@ export function FloorplanPanel() { }, [deleteNode, selectedSpawnEntry, setSelection], ) + const handleSelectedElevatorMove = useCallback( + (event: ReactMouseEvent) => { + 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) => { + event.stopPropagation() + + const elevator = selectedElevatorEntry?.elevator + if (!elevator) { + return + } + + sfxEmitter.emit('sfx:item-delete') + deleteNode(elevator.id as AnyNodeId) + setSelection({ selectedIds: [] }) + }, + [deleteNode, selectedElevatorEntry, setSelection], + ) const handleItemPointerDown = useCallback( (itemId: ItemNode['id'], event: ReactPointerEvent) => { if (event.button !== 0) { @@ -16025,6 +16622,7 @@ export function FloorplanPanel() { hasFloorplanCursorIndicator && !panStateRef.current && !guideInteractionRef.current && + !elevatorResizeDragState && !wallEndpointDragRef.current && !ceilingVertexDragState && !ceilingHoleMoveDraft && @@ -16061,6 +16659,7 @@ export function FloorplanPanel() { ceilingVertexDragState, ceilingHoleMoveDraft, ceilingHoleVertexDragState, + elevatorResizeDragState, siteVertexDragState, slabHoleMoveDraft, slabHoleVertexDragState, @@ -16535,6 +17134,11 @@ export function FloorplanPanel() { /> )} = [ + { label: 'Center opening', value: 'center-opening' }, + { label: 'Single left', value: 'single-left' }, + { label: 'Single right', value: 'single-right' }, +] + +const DOOR_PANEL_STYLE_OPTIONS: Array<{ + label: string + value: ElevatorNode['doorPanelStyle'] +}> = [ + { label: 'Glass frame', value: 'glass-frame' }, + { label: 'Solid panel', value: 'solid-panel' }, + { label: 'Segmented panel', value: 'segmented-panel' }, +] + +const SHAFT_STYLE_OPTIONS: Array<{ + label: string + value: ElevatorNode['shaftStyle'] +}> = [ + { label: 'Solid', value: 'solid' }, + { label: 'Glass', value: 'glass' }, +] function roundMeters(value: number) { return Math.round(value * 100) / 100 } +function getResolvedShaftWidth(node: ElevatorNode) { + return Math.max(node.shaftWidth ?? node.width, node.width, 0.8) +} + +function getResolvedShaftDepth(node: ElevatorNode) { + return Math.max(node.shaftDepth ?? node.depth, node.depth, 0.8) +} + +function getResolvedShaftWallThickness(node: ElevatorNode) { + return Math.max(node.shaftWallThickness ?? 0.09, 0.04) +} + function radiansToDegrees(radians: number) { return Math.round((radians * 180) / Math.PI) } @@ -288,11 +336,53 @@ export function ElevatorPanel() { const requestLevel = useCallback( (levelId: LevelNode['id']) => { if (!node) return + if ((node.disabledLevelIds ?? []).includes(levelId)) return useInteractive.getState().requestElevator(node.id as AnyNodeId, levelId as AnyNodeId) }, [node], ) + const toggleLevelAccess = useCallback( + (field: ElevatorAccessField, levelId: LevelNode['id']) => { + if (!node) return + const disabledIds = new Set(node.disabledLevelIds ?? []) + const serviceOnlyIds = new Set(node.serviceOnlyLevelIds ?? []) + const targetSet = field === 'disabledLevelIds' ? disabledIds : serviceOnlyIds + + if (targetSet.has(levelId)) { + targetSet.delete(levelId) + } else { + targetSet.add(levelId) + } + + if (field === 'disabledLevelIds' && disabledIds.has(levelId)) { + serviceOnlyIds.delete(levelId) + } + if (field === 'serviceOnlyLevelIds' && serviceOnlyIds.has(levelId)) { + disabledIds.delete(levelId) + } + + const nextServiceLevels = getServiceLevels( + levels, + getResolvedFromLevelId(node, levels), + getResolvedToLevelId(node, levels, getResolvedFromLevelId(node, levels)), + ) + const nextDefaultLevelId = + node.defaultLevelId && !disabledIds.has(node.defaultLevelId) + ? node.defaultLevelId + : (nextServiceLevels.find((level) => !disabledIds.has(level.id))?.id ?? + nextServiceLevels[0]?.id ?? + null) + + handleUpdate({ + defaultLevelId: nextDefaultLevelId, + disabledLevelIds: Array.from(disabledIds), + serviceOnlyLevelIds: Array.from(serviceOnlyIds), + }) + }, + [handleUpdate, levels, node], + ) + const handleServiceBoundaryChange = useCallback( (field: 'fromLevelId' | 'toLevelId', levelId: string) => { if (!node) return @@ -336,10 +426,22 @@ export function ElevatorPanel() { const displayPosition = liveTransform?.position ?? displayNode.position const displayRotation = liveTransform?.rotation ?? displayNode.rotation const displayRotationDegrees = radiansToDegrees(displayRotation) + const displayShaftWidth = getResolvedShaftWidth(displayNode) + const displayShaftDepth = getResolvedShaftDepth(displayNode) + const displayShaftWallThickness = getResolvedShaftWallThickness(displayNode) const fromLevelId = getResolvedFromLevelId(node, levels) const toLevelId = getResolvedToLevelId(node, levels, fromLevelId) const servedLevels = getServiceLevels(levels, fromLevelId, toLevelId) - const defaultLevelOptions = servedLevels.length > 0 ? servedLevels : levels + const servedLevelIdSet = new Set(servedLevels.map((level) => level.id)) + const disabledLevelIds = new Set( + (node.disabledLevelIds ?? []).filter((levelId) => servedLevelIdSet.has(levelId)), + ) + const serviceOnlyLevelIds = new Set( + (node.serviceOnlyLevelIds ?? []).filter((levelId) => servedLevelIdSet.has(levelId)), + ) + const enabledServedLevels = servedLevels.filter((level) => !disabledLevelIds.has(level.id)) + const defaultLevelOptions = + enabledServedLevels.length > 0 ? enabledServedLevels : servedLevels.length > 0 ? servedLevels : levels const selectedDefaultLevelId = defaultLevelOptions.some( (level) => level.id === node.defaultLevelId, ) @@ -520,7 +622,102 @@ export function ElevatorPanel() { /> + +
+
+ Shaft Style +
+ +
+ 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} + /> + 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} + /> + previewMetric('shaftWallThickness', value)} + onCommit={(value) => commitMetric('shaftWallThickness', value)} + precision={2} + restoreOnCommit={false} + step={0.01} + unit="m" + value={displayShaftWallThickness} + /> +
+ +
+
+ Opening Style +
+ +
+
+
+ Door Type +
+ +
+ +
+ {servedLevels.map((level) => { + const isDisabled = disabledLevelIds.has(level.id) + const isServiceOnly = serviceOnlyLevelIds.has(level.id) + + return ( +
+ + {level.name || `Level ${level.level}`} + +
+ + +
+
+ ) + })} +
+
+
{servedLevels.map((level) => { const isActive = activeLevelId === level.id const stopOrder = destinationOrderByLevelId.get(level.id) + const isDisabled = disabledLevelIds.has(level.id) + const isServiceOnly = serviceOnlyLevelIds.has(level.id) return ( ) diff --git a/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx b/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx index 136159f9..088fe259 100644 --- a/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx +++ b/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx @@ -1,6 +1,7 @@ import { type AnyNodeId, type ElevatorNode, + resolveElevatorDispatchTarget, useInteractive, useLiveNodeOverrides, useLiveTransforms, @@ -29,6 +30,7 @@ const CAB_COLOR = '#d7dde5' const GLASS_COLOR = '#f8fafc' const DOOR_COLOR = '#8e98a6' const PANEL_COLOR = '#1f2937' +const DEFAULT_SHAFT_WALL_THICKNESS = 0.09 type Vector3Tuple = [number, number, number] @@ -39,6 +41,11 @@ const BUTTON_RING_GEOMETRY = new TorusGeometry(1.12, 0.12, 8, 24) const LABEL_MATRIX_DUMMY = new Object3D() const SHAFT_TOP_FRAME_CLEARANCE = 0.006 +type ElevatorDoorSide = 'left' | 'right' +type ElevatorDoorPanelStyleValue = ElevatorNode['doorPanelStyle'] +type ElevatorDoorStyleValue = ElevatorNode['doorStyle'] +type ElevatorShaftStyleValue = ElevatorNode['shaftStyle'] + const SHAFT_WALL_MATERIAL = new MeshStandardMaterial({ color: SHAFT_WALL_COLOR, metalness: 0.08, @@ -64,6 +71,11 @@ const DOOR_MATERIAL = new MeshStandardMaterial({ metalness: 0.34, roughness: 0.34, }) +const DOOR_GROOVE_MATERIAL = new MeshStandardMaterial({ + color: '#5f6978', + metalness: 0.28, + roughness: 0.42, +}) const GLASS_MATERIAL = new MeshStandardMaterial({ color: GLASS_COLOR, depthWrite: false, @@ -132,6 +144,11 @@ const BUTTON_FACE_MATERIALS = { metalness: 0.22, roughness: 0.3, }), + disabled: new MeshStandardMaterial({ + color: '#475569', + metalness: 0.12, + roughness: 0.52, + }), } const BUTTON_RING_MATERIALS = { active: new MeshStandardMaterial({ @@ -153,6 +170,11 @@ const BUTTON_RING_MATERIALS = { metalness: 0.48, roughness: 0.28, }), + disabled: new MeshStandardMaterial({ + color: '#334155', + metalness: 0.28, + roughness: 0.5, + }), } const BUTTON_GLOW_MATERIALS = { active: new MeshStandardMaterial({ @@ -183,6 +205,11 @@ const BUTTON_LABEL_MATERIALS = { metalness: 0.12, roughness: 0.34, }), + disabled: new MeshStandardMaterial({ + color: '#94a3b8', + metalness: 0.08, + roughness: 0.5, + }), } const QUEUE_STRIP_MATERIALS = { queued: new MeshStandardMaterial({ @@ -490,6 +517,7 @@ function ElevatorMeshButton({ action = 'request-level', active, buttonKind, + disabled = false, elevatorId, faceSign = -1, glyph, @@ -502,6 +530,7 @@ function ElevatorMeshButton({ action?: ElevatorButtonAction active: boolean buttonKind: 'cab' | 'landing' + disabled?: boolean elevatorId: AnyNodeId faceSign?: -1 | 1 glyph?: 'door-open' @@ -511,34 +540,51 @@ function ElevatorMeshButton({ queued: boolean radius?: number }) { - const state = active ? 'active' : queued ? 'queued' : 'idle' + const state = disabled ? 'disabled' : active ? 'active' : queued ? 'queued' : 'idle' const depth = active ? 0.028 : 0.04 const faceZ = faceSign * (depth / 2 + 0.004) - const labelMaterial = active || queued ? BUTTON_LABEL_MATERIALS.lit : BUTTON_LABEL_MATERIALS.idle + const labelMaterial = disabled + ? BUTTON_LABEL_MATERIALS.disabled + : active || queued + ? BUTTON_LABEL_MATERIALS.lit + : BUTTON_LABEL_MATERIALS.idle const userData = useMemo( () => ({ elevatorButton: { action, + disabled, elevatorId, kind: buttonKind, levelId, }, }), - [action, buttonKind, elevatorId, levelId], + [action, buttonKind, disabled, elevatorId, levelId], ) const press = (event: ThreeEvent) => { if (event.button !== 0) return + if (disabled) return if (action === 'open-door') { useInteractive.getState().openElevatorDoor(elevatorId) return } - if (levelId) useInteractive.getState().requestElevator(elevatorId, levelId) + if (levelId) { + const targetElevatorId = + buttonKind === 'landing' + ? resolveElevatorDispatchTarget({ + elevators: useInteractive.getState().elevators, + levelId, + nodes: useScene.getState().nodes, + requestedElevatorId: elevatorId, + }) + : elevatorId + useInteractive.getState().requestElevator(targetElevatorId, levelId) + } } return ( - {(active || queued) && ( + {!disabled && (active || queued) && ( (null) - const direction = side === 'left' ? -1 : 1 - const getLeafX = (openAmount: number) => direction * (width / 4 + openAmount * width * 0.34) - const leafWidth = Math.max(width / 2 - 0.018, 0.12) + const getLeafX = (openAmount: number) => getElevatorDoorLeafX(side, width, openAmount, doorStyle) + const leafWidth = getElevatorDoorLeafWidth(width, doorStyle) + const resolvedPanelStyle = getResolvedDoorPanelStyle(doorPanelStyle) const railHeight = Math.min(0.09, Math.max(0.055, height * 0.04)) const stileWidth = Math.min(0.07, Math.max(0.04, leafWidth * 0.18)) const glassWidth = Math.max(leafWidth - stileWidth * 2.2, 0.03) const glassHeight = Math.max(height - railHeight * 3, 0.2) + const panelInsetWidth = Math.max(leafWidth - 0.12, 0.05) + const panelInsetHeight = Math.max(height - 0.26, 0.2) + const segmentCount = 4 + const segmentSpacing = panelInsetHeight / segmentCount useFrame(() => { if (!(animated && ref.current)) return @@ -631,43 +757,130 @@ function DoorLeaf({ return ( - - - - - + {resolvedPanelStyle === 'glass-frame' ? ( + <> + + + + + + + ) : ( + <> + + + {resolvedPanelStyle === 'segmented-panel' + ? Array.from({ length: segmentCount - 1 }).map((_, index) => ( + + )) + : null} + + + + )} ) } +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) => ( + + ))} + + ) +} + function LandingDoorFrame({ doorHeight, doorWidth, @@ -749,6 +962,8 @@ function LandingDoorFrame({ function LandingDoor({ animated, + doorPanelStyle, + doorStyle, elevatorId, doorOpen, doorHeight, @@ -758,6 +973,8 @@ function LandingDoor({ z, }: { animated: boolean + doorPanelStyle: ElevatorDoorPanelStyleValue + doorStyle: ElevatorDoorStyleValue elevatorId: AnyNodeId doorOpen: number doorHeight: number @@ -767,26 +984,16 @@ function LandingDoor({ z: number }) { return ( - <> - - - + ) } @@ -856,13 +1063,24 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { cabRef.current.position.y = runtime.carY }, 2.6) - const shaftWidth = Math.max(renderNode.width, 0.8) - const shaftDepth = Math.max(renderNode.depth, 0.8) + const cabWidth = getElevatorCabWidth(renderNode) + const cabDepth = getElevatorCabDepth(renderNode) + const shaftWidth = getElevatorShaftWidth(renderNode, cabWidth) + const shaftDepth = getElevatorShaftDepth(renderNode, cabDepth) const cabHeight = Math.max(renderNode.cabHeight, 1.4) - const doorWidth = Math.min(Math.max(renderNode.doorWidth, 0.45), shaftWidth - 0.18) + const shaftWallThickness = getElevatorShaftWallThickness(renderNode) + const doorWidth = Math.min( + Math.max(renderNode.doorWidth, 0.45), + cabWidth - 0.18, + shaftWidth - 0.18, + ) const doorHeight = Math.min(Math.max(renderNode.doorHeight, 1.2), cabHeight - 0.1) + const doorPanelStyle = getResolvedDoorPanelStyle(renderNode.doorPanelStyle) + const doorStyle = getResolvedDoorStyle(renderNode.doorStyle) + const shaftStyle = getResolvedShaftStyle(renderNode.shaftStyle) + const shaftShellMaterial = shaftStyle === 'glass' ? GLASS_MATERIAL : SHAFT_SIDE_MATERIAL + const shaftTopMaterial = shaftStyle === 'glass' ? SHAFT_TRIM_MATERIAL : SHAFT_SIDE_MATERIAL const shaftHeight = Math.max(totalHeight, cabHeight + 0.3) - const shaftWallThickness = 0.09 const shaftBodyHeight = Math.max(shaftHeight - shaftWallThickness, 0.01) const shaftBodyCenterY = shaftBaseY + shaftBodyHeight / 2 const shaftTopCapBottomY = shaftBaseY + shaftHeight - shaftWallThickness @@ -906,6 +1124,14 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { runtimeStatus?.queue, runtimeStatus?.targetLevelId, ]) + const disabledLevelIds = useMemo( + () => new Set(renderNode.disabledLevelIds ?? []), + [renderNode.disabledLevelIds], + ) + const serviceOnlyLevelIds = useMemo( + () => new Set(renderNode.serviceOnlyLevelIds ?? []), + [renderNode.serviceOnlyLevelIds], + ) const doorOpen = runtimeSnapshot?.doorOpen ?? 0 const doorOpenButtonActive = doorOpen > 0.12 || @@ -916,8 +1142,9 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { const frontWallZ = -shaftDepth / 2 - shaftWallThickness / 2 const frontZ = frontWallZ - shaftWallThickness / 2 - 0.018 const landingPanelX = Math.min(shaftWidth / 2 - 0.16, doorWidth / 2 + 0.18) - const cabPanelX = shaftWidth / 2 - 0.075 - const cabPanelZ = -shaftDepth / 2 + 0.36 + const cabCenterZ = -shaftDepth / 2 + cabDepth / 2 + const cabPanelX = cabWidth / 2 - 0.075 + const cabPanelZ = cabCenterZ - cabDepth / 2 + 0.36 const cabButtonColumns = entries.length > 1 ? 2 : 1 const cabButtonRows = Math.max(1, Math.ceil(entries.length / cabButtonColumns)) const cabButtonSpacingX = 0.14 @@ -956,28 +1183,28 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { > { - - { {entries.map((entry, index) => { const column = index % cabButtonColumns const row = Math.floor(index / cabButtonColumns) + const isDisabledLevel = disabledLevelIds.has(entry.id) const x = cabFloorButtonOffsetX + (column - (cabButtonColumns - 1) / 2) * cabButtonSpacingX const y = (row - (cabButtonRows - 1) / 2) * cabButtonSpacingY return ( ) })} @@ -1101,7 +1322,9 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { {entrySpans.map(({ entry, levelTopY }) => { const isCurrentLevel = activeLevelId === entry.id - const isQueuedLevel = queuedLevelIds.has(entry.id) + const isDisabledLevel = disabledLevelIds.has(entry.id) + const isServiceOnlyLevel = serviceOnlyLevelIds.has(entry.id) + const isQueuedLevel = !isDisabledLevel && queuedLevelIds.has(entry.id) const isPendingLevel = pendingLevelId === entry.id const showLandingReadout = isCurrentLevel || isPendingLevel || isQueuedLevel @@ -1117,6 +1340,8 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { /> { scale={[0.18, 0.42, 0.04]} /> 0.5} + active={!isDisabledLevel && !isServiceOnlyLevel && isCurrentLevel && doorOpen > 0.5} buttonKind="landing" + disabled={isDisabledLevel || isServiceOnlyLevel} elevatorId={elevatorId} levelId={entry.id as AnyNodeId} position={[0, 0.06, -0.045]}