Optimize elevator editing performance with live previews
This commit is contained in:
@@ -1,12 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import '../../three-types'
|
||||
import { type AnyNodeId, emitter, sceneRegistry, useInteractive, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type ElevatorNode,
|
||||
emitter,
|
||||
resolveElevatorBuildingLevels,
|
||||
resolveElevatorServiceLevels,
|
||||
sceneRegistry,
|
||||
useInteractive,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { KeyboardControls } from '@react-three/drei'
|
||||
import { useFrame, useThree } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Box3, Euler, Matrix4, Ray, Raycaster, Vector2, Vector3 } from 'three'
|
||||
import {
|
||||
BoxGeometry,
|
||||
Box3,
|
||||
Euler,
|
||||
type Group,
|
||||
Matrix4,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
type Object3D,
|
||||
Ray,
|
||||
Raycaster,
|
||||
Vector2,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
|
||||
import {
|
||||
closeDoorOpenState,
|
||||
DOOR_SWING_OPEN_ANGLE,
|
||||
@@ -34,6 +58,13 @@ const LOOK_SENSITIVITY = 0.002
|
||||
const CONTROLLER_CENTER_FROM_EYE = 0.85
|
||||
const DOOR_INTERACTION_DISTANCE = 2.5
|
||||
const DOOR_LEAF_INTERACTION_DEPTH = 0.08
|
||||
const ELEVATOR_RIDE_HORIZONTAL_PADDING = 0.18
|
||||
const ELEVATOR_COLLIDER_HORIZONTAL_PADDING = 0.14
|
||||
const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08
|
||||
const ELEVATOR_COLLIDER_WALL_THICKNESS = 0.16
|
||||
const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12
|
||||
const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72
|
||||
const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5
|
||||
const keyboardMap = [
|
||||
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] },
|
||||
{ name: 'backward', keys: ['ArrowDown', 'KeyS'] },
|
||||
@@ -59,13 +90,410 @@ const doorOpeningLocalHit = new Vector3()
|
||||
const doorOpeningLocalRay = new Ray()
|
||||
const doorOpeningMatrix = new Matrix4()
|
||||
const doorOpeningWorldHit = new Vector3()
|
||||
const elevatorLocalControllerPosition = new Vector3()
|
||||
const elevatorInteractionRaycaster = new Raycaster()
|
||||
const elevatorColliderMatrix = new Matrix4()
|
||||
const elevatorColliderLocalMatrix = new Matrix4()
|
||||
const elevatorLocalEyePosition = new Vector3()
|
||||
const elevatorWorldControllerPosition = new Vector3()
|
||||
const elevatorColliderMaterial = new MeshBasicMaterial({ visible: false })
|
||||
const spawnWorldPosition = new Vector3()
|
||||
const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
|
||||
const windowInteractionRaycaster = new Raycaster()
|
||||
|
||||
type FirstPersonInteractableTarget = {
|
||||
id: AnyNodeId
|
||||
type: 'door' | 'window'
|
||||
type ElevatorColliderKind =
|
||||
| 'cab-back'
|
||||
| 'cab-ceiling'
|
||||
| 'cab-door-left'
|
||||
| 'cab-door-right'
|
||||
| 'cab-door-gate'
|
||||
| 'cab-floor'
|
||||
| 'cab-left'
|
||||
| 'cab-right'
|
||||
| 'landing-door-gate'
|
||||
| 'landing-door-left'
|
||||
| 'landing-door-right'
|
||||
| 'shaft-back'
|
||||
| 'shaft-front-header'
|
||||
| 'shaft-front-left'
|
||||
| 'shaft-front-right'
|
||||
| 'shaft-left'
|
||||
| 'shaft-right'
|
||||
| 'shaft-top'
|
||||
|
||||
type ElevatorColliderUserData = {
|
||||
doorWidth?: number
|
||||
dynamic?: boolean
|
||||
elevatorId: AnyNodeId
|
||||
kind: ElevatorColliderKind
|
||||
levelId?: AnyNodeId
|
||||
localPosition: [number, number, number]
|
||||
matrixInitialized?: boolean
|
||||
side?: 'left' | 'right'
|
||||
}
|
||||
|
||||
type ElevatorColliderMesh = Mesh & {
|
||||
userData: Mesh['userData'] & ElevatorColliderUserData
|
||||
}
|
||||
|
||||
type FirstPersonInteractableTarget =
|
||||
| {
|
||||
id: AnyNodeId
|
||||
type: 'door' | 'window'
|
||||
}
|
||||
| {
|
||||
buttonKind: 'cab' | 'landing'
|
||||
id: AnyNodeId
|
||||
levelId: AnyNodeId
|
||||
type: 'elevator'
|
||||
}
|
||||
|
||||
type ElevatorButtonTarget = {
|
||||
buttonKind: 'cab' | 'landing'
|
||||
elevatorId: AnyNodeId
|
||||
levelId: AnyNodeId
|
||||
}
|
||||
|
||||
function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | null {
|
||||
let current: Object3D | null = object
|
||||
|
||||
while (current) {
|
||||
const candidate = (
|
||||
current.userData as {
|
||||
elevatorButton?: {
|
||||
elevatorId?: unknown
|
||||
kind?: unknown
|
||||
levelId?: unknown
|
||||
}
|
||||
}
|
||||
).elevatorButton
|
||||
|
||||
if (
|
||||
typeof candidate?.elevatorId === 'string' &&
|
||||
typeof candidate.levelId === 'string' &&
|
||||
(candidate.kind === 'cab' || candidate.kind === 'landing')
|
||||
) {
|
||||
return {
|
||||
buttonKind: candidate.kind,
|
||||
elevatorId: candidate.elevatorId as AnyNodeId,
|
||||
levelId: candidate.levelId as AnyNodeId,
|
||||
}
|
||||
}
|
||||
|
||||
current = current.parent
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getInteractableTargetKey(target: FirstPersonInteractableTarget | null) {
|
||||
if (!target) return null
|
||||
return target.type === 'elevator'
|
||||
? `${target.type}:${target.id}:${target.levelId}`
|
||||
: `${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 isDynamicElevatorCollider(kind: ElevatorColliderKind) {
|
||||
return kind.startsWith('cab-') || kind.startsWith('landing-door')
|
||||
}
|
||||
|
||||
function isInsideElevatorCab(
|
||||
elevator: ElevatorNode,
|
||||
runtime: NonNullable<ReturnType<typeof useInteractive.getState>['elevators'][AnyNodeId]>,
|
||||
localEyePosition: Vector3,
|
||||
) {
|
||||
const halfWidth = Math.max(elevator.width, 0.8) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
|
||||
const halfDepth = Math.max(elevator.depth, 0.8) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
|
||||
const 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) &&
|
||||
localEyePosition.y >= runtime.carY + 0.35 &&
|
||||
localEyePosition.y <= runtime.carY + cabHeight + 0.7
|
||||
)
|
||||
}
|
||||
|
||||
function getFirstPersonLevelHeight(levelId: string, nodes: Record<string, AnyNode>) {
|
||||
const level = nodes[levelId as AnyNodeId]
|
||||
if (level?.type !== 'level') return DEFAULT_ELEVATOR_LEVEL_HEIGHT
|
||||
|
||||
let maxTop = 0
|
||||
for (const childId of level.children) {
|
||||
const child = nodes[childId as AnyNodeId]
|
||||
if (!child) continue
|
||||
|
||||
if (child.type === 'ceiling') {
|
||||
maxTop = Math.max(maxTop, child.height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT)
|
||||
continue
|
||||
}
|
||||
|
||||
if (child.type === 'wall') {
|
||||
const meshY = Math.max(sceneRegistry.nodes.get(childId as AnyNodeId)?.position.y ?? 0, 0)
|
||||
maxTop = Math.max(maxTop, meshY + (child.height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT))
|
||||
}
|
||||
}
|
||||
|
||||
return maxTop > 0 ? maxTop : DEFAULT_ELEVATOR_LEVEL_HEIGHT
|
||||
}
|
||||
|
||||
function resolveElevatorColliderLevels(elevator: ElevatorNode, nodes: Record<string, AnyNode>) {
|
||||
const allLevels = resolveElevatorBuildingLevels(elevator, nodes)
|
||||
|
||||
const baseYByLevelId = new Map<string, number>()
|
||||
let cumulativeY = 0
|
||||
for (const level of allLevels) {
|
||||
baseYByLevelId.set(level.id, cumulativeY)
|
||||
cumulativeY += getFirstPersonLevelHeight(level.id, nodes)
|
||||
}
|
||||
|
||||
const serviceLevels = resolveElevatorServiceLevels(elevator, nodes)
|
||||
const entries = serviceLevels.map((level) => ({
|
||||
baseY: baseYByLevelId.get(level.id) ?? 0,
|
||||
id: level.id as AnyNodeId,
|
||||
}))
|
||||
const firstServedLevel = serviceLevels[0] ?? null
|
||||
const lastServedLevel = serviceLevels[serviceLevels.length - 1] ?? null
|
||||
const shaftBaseY = firstServedLevel ? (baseYByLevelId.get(firstServedLevel.id) ?? 0) : 0
|
||||
const lastServedIndex = lastServedLevel
|
||||
? allLevels.findIndex((level) => level.id === lastServedLevel.id)
|
||||
: -1
|
||||
const nextLevel = lastServedIndex >= 0 ? allLevels[lastServedIndex + 1] : null
|
||||
const shaftTopY = nextLevel
|
||||
? (baseYByLevelId.get(nextLevel.id) ?? cumulativeY)
|
||||
: lastServedLevel
|
||||
? cumulativeY
|
||||
: elevator.cabHeight + 0.3
|
||||
|
||||
return {
|
||||
entries,
|
||||
shaftBaseY,
|
||||
shaftTopY,
|
||||
totalHeight: Math.max(shaftTopY - shaftBaseY, elevator.cabHeight + 0.3),
|
||||
}
|
||||
}
|
||||
|
||||
function createElevatorColliderMesh(
|
||||
elevatorId: AnyNodeId,
|
||||
kind: ElevatorColliderKind,
|
||||
size: [number, number, number],
|
||||
localPosition: [number, number, number],
|
||||
userData: Partial<ElevatorColliderUserData> = {},
|
||||
) {
|
||||
const geometry = new BoxGeometry(size[0], size[1], size[2])
|
||||
|
||||
const bvhGeometry = geometry as typeof geometry & {
|
||||
computeBoundsTree?: typeof computeBoundsTree
|
||||
disposeBoundsTree?: typeof disposeBoundsTree
|
||||
}
|
||||
;(bvhGeometry as any).computeBoundsTree = computeBoundsTree
|
||||
;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree
|
||||
bvhGeometry.computeBoundsTree?.({
|
||||
maxLeafSize: 12,
|
||||
strategy: 0,
|
||||
} as never)
|
||||
bvhGeometry.computeBoundingBox()
|
||||
|
||||
const mesh = new Mesh(bvhGeometry, elevatorColliderMaterial) as unknown as ElevatorColliderMesh
|
||||
mesh.raycast = acceleratedRaycast
|
||||
mesh.matrixAutoUpdate = false
|
||||
mesh.visible = true
|
||||
mesh.userData = {
|
||||
...userData,
|
||||
dynamic: isDynamicElevatorCollider(kind),
|
||||
elevatorId,
|
||||
excludeCollisionCheck: false,
|
||||
excludeFloatHit: false,
|
||||
friction: 0.8,
|
||||
kind,
|
||||
localPosition,
|
||||
matrixInitialized: false,
|
||||
restitution: 0.03,
|
||||
type: 'ELEVATOR_COLLIDER',
|
||||
}
|
||||
return mesh
|
||||
}
|
||||
|
||||
function buildElevatorColliderMeshes(): ElevatorColliderMesh[] {
|
||||
const nodes = useScene.getState().nodes
|
||||
const meshes: ElevatorColliderMesh[] = []
|
||||
|
||||
for (const elevatorId of sceneRegistry.byType.elevator) {
|
||||
const typedElevatorId = elevatorId as AnyNodeId
|
||||
const node = nodes[typedElevatorId]
|
||||
if (node?.type !== 'elevator' || node.visible === false) continue
|
||||
|
||||
const { entries, shaftBaseY, shaftTopY, totalHeight } = resolveElevatorColliderLevels(
|
||||
node,
|
||||
nodes,
|
||||
)
|
||||
const shaftWidth = Math.max(node.width, 0.8)
|
||||
const shaftDepth = Math.max(node.depth, 0.8)
|
||||
const cabHeight = Math.max(node.cabHeight, 1.4)
|
||||
const doorWidth = Math.min(Math.max(node.doorWidth, 0.45), shaftWidth - 0.18)
|
||||
const doorHeight = Math.min(Math.max(node.doorHeight, 1.2), cabHeight - 0.1)
|
||||
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 frontWallZ = -shaftDepth / 2 - wallThickness / 2
|
||||
const frontZ = frontWallZ - wallThickness / 2 - 0.018
|
||||
const leafWidth = Math.max(doorWidth / 2 - 0.018, 0.12)
|
||||
const resolvedShaftTopY = Math.max(shaftTopY, shaftBaseY + shaftHeight)
|
||||
|
||||
meshes.push(
|
||||
createElevatorColliderMesh(
|
||||
typedElevatorId,
|
||||
'shaft-back',
|
||||
[shaftWidth + wallThickness * 2, shaftHeight, wallThickness],
|
||||
[0, shaftBaseY + shaftHeight / 2, shaftDepth / 2 + wallThickness / 2],
|
||||
),
|
||||
createElevatorColliderMesh(
|
||||
typedElevatorId,
|
||||
'shaft-left',
|
||||
[wallThickness, shaftHeight, shaftDepth + wallThickness * 2],
|
||||
[-shaftWidth / 2 - wallThickness / 2, shaftBaseY + shaftHeight / 2, 0],
|
||||
),
|
||||
createElevatorColliderMesh(
|
||||
typedElevatorId,
|
||||
'shaft-right',
|
||||
[wallThickness, shaftHeight, shaftDepth + wallThickness * 2],
|
||||
[shaftWidth / 2 + wallThickness / 2, shaftBaseY + shaftHeight / 2, 0],
|
||||
),
|
||||
createElevatorColliderMesh(
|
||||
typedElevatorId,
|
||||
'shaft-top',
|
||||
[shaftWidth + wallThickness * 2, wallThickness, shaftDepth + wallThickness * 2],
|
||||
[0, shaftBaseY + shaftHeight - wallThickness / 2, 0],
|
||||
),
|
||||
createElevatorColliderMesh(
|
||||
typedElevatorId,
|
||||
'cab-floor',
|
||||
[cabFloorWidth, ELEVATOR_COLLIDER_FLOOR_THICKNESS, cabFloorDepth],
|
||||
[0, ELEVATOR_COLLIDER_FLOOR_THICKNESS / 2, 0],
|
||||
),
|
||||
createElevatorColliderMesh(
|
||||
typedElevatorId,
|
||||
'cab-ceiling',
|
||||
[shaftWidth, wallThickness, shaftDepth],
|
||||
[0, cabHeight - wallThickness / 2, 0],
|
||||
),
|
||||
createElevatorColliderMesh(
|
||||
typedElevatorId,
|
||||
'cab-back',
|
||||
[shaftWidth, cabHeight, wallThickness],
|
||||
[0, cabHeight / 2, shaftDepth / 2 - wallThickness / 2],
|
||||
),
|
||||
createElevatorColliderMesh(
|
||||
typedElevatorId,
|
||||
'cab-left',
|
||||
[wallThickness, cabHeight, shaftDepth],
|
||||
[-shaftWidth / 2 + wallThickness / 2, cabHeight / 2, 0],
|
||||
),
|
||||
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' },
|
||||
),
|
||||
createElevatorColliderMesh(
|
||||
typedElevatorId,
|
||||
'cab-door-gate',
|
||||
[doorWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
|
||||
[0, doorHeight / 2, frontZ],
|
||||
{ doorWidth },
|
||||
),
|
||||
createElevatorColliderMesh(
|
||||
typedElevatorId,
|
||||
'cab-door-right',
|
||||
[leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
|
||||
[0, doorHeight / 2, frontZ],
|
||||
{ doorWidth, side: 'right' },
|
||||
),
|
||||
)
|
||||
|
||||
const entrySpans = entries.map((entry, index) => {
|
||||
const nextEntry = entries[index + 1]
|
||||
return {
|
||||
entry,
|
||||
levelTopY: Math.max(nextEntry?.baseY ?? resolvedShaftTopY, entry.baseY + doorHeight + 0.24),
|
||||
}
|
||||
})
|
||||
|
||||
for (const { entry, levelTopY } of entrySpans) {
|
||||
const wallDepth = wallThickness
|
||||
const levelHeight = Math.max(levelTopY - entry.baseY, doorHeight + 0.24)
|
||||
const jambWidth = Math.max((shaftWidth - doorWidth) / 2, 0.08)
|
||||
const jambCenterOffset = doorWidth / 2 + jambWidth / 2
|
||||
const headerHeight = Math.max(levelHeight - doorHeight, 0.14)
|
||||
|
||||
meshes.push(
|
||||
createElevatorColliderMesh(
|
||||
typedElevatorId,
|
||||
'shaft-front-left',
|
||||
[jambWidth, levelHeight, wallDepth],
|
||||
[-jambCenterOffset, entry.baseY + levelHeight / 2, frontWallZ],
|
||||
),
|
||||
createElevatorColliderMesh(
|
||||
typedElevatorId,
|
||||
'shaft-front-right',
|
||||
[jambWidth, levelHeight, wallDepth],
|
||||
[jambCenterOffset, entry.baseY + levelHeight / 2, frontWallZ],
|
||||
),
|
||||
createElevatorColliderMesh(
|
||||
typedElevatorId,
|
||||
'shaft-front-header',
|
||||
[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',
|
||||
[doorWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
|
||||
[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' },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return meshes
|
||||
}
|
||||
|
||||
function disposeElevatorColliderMeshes(meshes: ElevatorColliderMesh[]) {
|
||||
for (const mesh of meshes) {
|
||||
const geometry = mesh.geometry as typeof mesh.geometry & {
|
||||
disposeBoundsTree?: typeof disposeBoundsTree
|
||||
}
|
||||
geometry.disposeBoundsTree?.()
|
||||
geometry.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
const resolvePlacedSpawnNode = (
|
||||
@@ -86,8 +514,17 @@ export const FirstPersonControls = () => {
|
||||
const yawRef = useRef(0)
|
||||
const pitchRef = useRef(0)
|
||||
const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
|
||||
const [isElevatorRideLocked, setIsElevatorRideLocked] = useState(false)
|
||||
const ridingElevatorRef = useRef<{
|
||||
elevatorId: AnyNodeId
|
||||
localControllerY: number | null
|
||||
previousCarY: number
|
||||
} | null>(null)
|
||||
const rideLockedRef = useRef(false)
|
||||
const worldRef = useRef<FirstPersonColliderWorld | null>(null)
|
||||
const elevatorColliderMeshesRef = useRef<ElevatorColliderMesh[]>([])
|
||||
const [world, setWorld] = useState<FirstPersonColliderWorld | null>(null)
|
||||
const [elevatorColliderMeshes, setElevatorColliderMeshes] = useState<ElevatorColliderMesh[]>([])
|
||||
const [controllerStart, setControllerStart] = useState<{
|
||||
position: [number, number, number]
|
||||
yaw: number
|
||||
@@ -99,9 +536,22 @@ export const FirstPersonControls = () => {
|
||||
setWorld(nextWorld)
|
||||
}, [])
|
||||
|
||||
const replaceElevatorColliderMeshes = useCallback((nextMeshes: ElevatorColliderMesh[]) => {
|
||||
disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current)
|
||||
elevatorColliderMeshesRef.current = nextMeshes
|
||||
setElevatorColliderMeshes(nextMeshes)
|
||||
}, [])
|
||||
|
||||
const rebuildColliderWorld = useCallback(() => {
|
||||
replaceColliderWorld(buildFirstPersonColliderWorldFromRegistry())
|
||||
}, [replaceColliderWorld])
|
||||
replaceElevatorColliderMeshes(buildElevatorColliderMeshes())
|
||||
}, [replaceColliderWorld, replaceElevatorColliderMeshes])
|
||||
|
||||
const setElevatorRideLocked = useCallback((locked: boolean) => {
|
||||
if (rideLockedRef.current === locked) return
|
||||
rideLockedRef.current = locked
|
||||
setIsElevatorRideLocked(locked)
|
||||
}, [])
|
||||
|
||||
const resolveInteractableDoorId = useCallback((): AnyNodeId | null => {
|
||||
const nodes = useScene.getState().nodes
|
||||
@@ -230,7 +680,58 @@ export const FirstPersonControls = () => {
|
||||
return closestWindowId
|
||||
}, [camera])
|
||||
|
||||
const resolveInteractableElevatorTarget =
|
||||
useCallback((): FirstPersonInteractableTarget | null => {
|
||||
const nodes = useScene.getState().nodes
|
||||
camera.updateMatrixWorld(true)
|
||||
elevatorInteractionRaycaster.setFromCamera(centerScreenPoint, camera)
|
||||
|
||||
let closestTarget: FirstPersonInteractableTarget | null = null
|
||||
let closestDistance = DOOR_INTERACTION_DISTANCE
|
||||
|
||||
for (const elevatorId of sceneRegistry.byType.elevator) {
|
||||
const typedElevatorId = elevatorId as AnyNodeId
|
||||
const node = nodes[typedElevatorId]
|
||||
if (node?.type !== 'elevator') continue
|
||||
|
||||
const object = sceneRegistry.nodes.get(typedElevatorId)
|
||||
if (!object) continue
|
||||
|
||||
const runtime = useInteractive.getState().elevators[typedElevatorId]
|
||||
object.updateWorldMatrix(true, true)
|
||||
if (runtime) {
|
||||
elevatorLocalEyePosition.copy(camera.position)
|
||||
object.worldToLocal(elevatorLocalEyePosition)
|
||||
}
|
||||
const canUseCabButtons =
|
||||
runtime && isInsideElevatorCab(node, runtime, elevatorLocalEyePosition)
|
||||
|
||||
const intersections = elevatorInteractionRaycaster.intersectObject(object, true)
|
||||
for (const intersection of intersections) {
|
||||
if (intersection.distance > closestDistance) continue
|
||||
|
||||
const target = resolveElevatorButtonTarget(intersection.object)
|
||||
if (!target || target.elevatorId !== elevatorId) continue
|
||||
if (nodes[target.levelId]?.type !== 'level') continue
|
||||
if (target.buttonKind === 'cab' && !canUseCabButtons) continue
|
||||
|
||||
closestTarget = {
|
||||
buttonKind: target.buttonKind,
|
||||
id: target.elevatorId,
|
||||
levelId: target.levelId,
|
||||
type: 'elevator',
|
||||
}
|
||||
closestDistance = intersection.distance
|
||||
}
|
||||
}
|
||||
|
||||
return closestTarget
|
||||
}, [camera])
|
||||
|
||||
const resolveInteractableTarget = useCallback((): FirstPersonInteractableTarget | null => {
|
||||
const elevatorTarget = resolveInteractableElevatorTarget()
|
||||
if (elevatorTarget) return elevatorTarget
|
||||
|
||||
const doorId = resolveInteractableDoorId()
|
||||
if (doorId) return { id: doorId, type: 'door' }
|
||||
|
||||
@@ -238,12 +739,27 @@ export const FirstPersonControls = () => {
|
||||
if (windowId) return { id: windowId, type: 'window' }
|
||||
|
||||
return null
|
||||
}, [resolveInteractableDoorId, resolveInteractableWindowId])
|
||||
}, [resolveInteractableDoorId, resolveInteractableElevatorTarget, resolveInteractableWindowId])
|
||||
|
||||
const toggleInteractableTarget = useCallback(() => {
|
||||
const target = interactableTargetRef.current ?? resolveInteractableTarget()
|
||||
if (!target) return
|
||||
|
||||
if (target.type === 'elevator') {
|
||||
if (target.buttonKind === 'cab') {
|
||||
const state = useInteractive.getState().elevators[target.id]
|
||||
if (state) {
|
||||
ridingElevatorRef.current = {
|
||||
elevatorId: target.id,
|
||||
localControllerY: null,
|
||||
previousCarY: state.carY,
|
||||
}
|
||||
}
|
||||
}
|
||||
useInteractive.getState().requestElevator(target.id, target.levelId)
|
||||
return
|
||||
}
|
||||
|
||||
if (target.type === 'window') {
|
||||
const node = useScene.getState().nodes[target.id]
|
||||
if (
|
||||
@@ -270,6 +786,8 @@ export const FirstPersonControls = () => {
|
||||
const target = interactableTargetRef.current ?? resolveInteractableTarget()
|
||||
if (!target) return
|
||||
|
||||
if (target.type === 'elevator') return
|
||||
|
||||
if (target.type === 'window') {
|
||||
const node = useScene.getState().nodes[target.id]
|
||||
if (
|
||||
@@ -325,6 +843,9 @@ export const FirstPersonControls = () => {
|
||||
return () => {
|
||||
worldRef.current?.dispose()
|
||||
worldRef.current = null
|
||||
disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current)
|
||||
elevatorColliderMeshesRef.current = []
|
||||
setElevatorColliderMeshes([])
|
||||
setWorld(null)
|
||||
}
|
||||
}, [rebuildColliderWorld])
|
||||
@@ -373,17 +894,28 @@ export const FirstPersonControls = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
if (document.pointerLockElement !== canvas) return
|
||||
if (event.button !== 0) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleInteractableTarget()
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('click', handleClick)
|
||||
document.addEventListener('mousedown', handleMouseDown, true)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('click', handleClick)
|
||||
document.removeEventListener('mousedown', handleMouseDown, true)
|
||||
if (document.pointerLockElement === canvas) {
|
||||
document.exitPointerLock()
|
||||
}
|
||||
}
|
||||
}, [gl])
|
||||
}, [gl, toggleInteractableTarget])
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = gl.domElement
|
||||
@@ -417,7 +949,216 @@ export const FirstPersonControls = () => {
|
||||
}
|
||||
}, [closeInteractableTarget, gl, toggleInteractableTarget])
|
||||
|
||||
useFrame((_, delta) => {
|
||||
const syncElevatorColliderMeshes = useCallback(() => {
|
||||
const nodes = useScene.getState().nodes
|
||||
const interactive = useInteractive.getState()
|
||||
|
||||
for (const mesh of elevatorColliderMeshesRef.current) {
|
||||
const { doorWidth, dynamic, elevatorId, kind, levelId, localPosition, side } = mesh.userData
|
||||
const node = nodes[elevatorId]
|
||||
const runtime = interactive.elevators[elevatorId]
|
||||
const object = sceneRegistry.nodes.get(elevatorId)
|
||||
if (!(node?.type === 'elevator' && object && node.visible !== false)) {
|
||||
mesh.visible = false
|
||||
continue
|
||||
}
|
||||
|
||||
if (!dynamic && mesh.userData.matrixInitialized && mesh.visible) {
|
||||
continue
|
||||
}
|
||||
|
||||
let [localX, localY, localZ] = localPosition
|
||||
const isCabCollider = kind.startsWith('cab-')
|
||||
const isDoorCollider = kind === 'landing-door-left' || kind === 'landing-door-right'
|
||||
const isCabDoorGate = kind === 'cab-door-gate'
|
||||
const isLandingDoorGate = kind === 'landing-door-gate'
|
||||
|
||||
if (isCabCollider) {
|
||||
localY += runtime?.carY ?? 0
|
||||
}
|
||||
|
||||
if (kind === 'cab-door-left' || kind === 'cab-door-right') {
|
||||
if (!runtime) {
|
||||
mesh.visible = false
|
||||
continue
|
||||
}
|
||||
localX = getElevatorDoorLeafX(side ?? 'left', doorWidth ?? node.doorWidth, runtime.doorOpen)
|
||||
mesh.visible = true
|
||||
} else if (isCabDoorGate) {
|
||||
if (!runtime) {
|
||||
mesh.visible = false
|
||||
continue
|
||||
}
|
||||
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)
|
||||
mesh.visible = true
|
||||
} else if (isLandingDoorGate) {
|
||||
const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0
|
||||
mesh.visible = doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD
|
||||
} else {
|
||||
mesh.visible = true
|
||||
}
|
||||
|
||||
object.updateWorldMatrix(true, false)
|
||||
elevatorColliderMatrix.copy(object.matrixWorld)
|
||||
elevatorColliderMatrix.multiply(
|
||||
elevatorColliderLocalMatrix.makeTranslation(localX, localY, localZ),
|
||||
)
|
||||
mesh.matrix.copy(elevatorColliderMatrix)
|
||||
mesh.matrixWorld.copy(elevatorColliderMatrix)
|
||||
mesh.userData.matrixInitialized = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useFrame(() => {
|
||||
syncElevatorColliderMeshes()
|
||||
}, -1)
|
||||
|
||||
const syncElevatorRide = useCallback(
|
||||
(group: Group) => {
|
||||
const nodes = useScene.getState().nodes
|
||||
const interactive = useInteractive.getState()
|
||||
const activeRide = ridingElevatorRef.current
|
||||
let nextRide: {
|
||||
cabHeight: number
|
||||
carY: number
|
||||
doorOpen: number
|
||||
elevatorId: AnyNodeId
|
||||
halfDepth: number
|
||||
halfWidth: number
|
||||
object: Object3D
|
||||
phase: NonNullable<(typeof interactive.elevators)[AnyNodeId]>['phase']
|
||||
} | null = null
|
||||
|
||||
const elevatorIds = activeRide
|
||||
? [
|
||||
activeRide.elevatorId,
|
||||
...Array.from(sceneRegistry.byType.elevator).filter(
|
||||
(elevatorId) => elevatorId !== activeRide.elevatorId,
|
||||
),
|
||||
]
|
||||
: Array.from(sceneRegistry.byType.elevator)
|
||||
|
||||
for (const elevatorId of elevatorIds) {
|
||||
const typedElevatorId = elevatorId as AnyNodeId
|
||||
const node = nodes[typedElevatorId]
|
||||
if (node?.type !== 'elevator') continue
|
||||
|
||||
const runtime = interactive.elevators[typedElevatorId]
|
||||
const object = sceneRegistry.nodes.get(typedElevatorId)
|
||||
if (!(runtime && object)) continue
|
||||
|
||||
object.updateWorldMatrix(true, true)
|
||||
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 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)
|
||||
const insideCabHeight =
|
||||
elevatorLocalEyePosition.y >= runtime.carY + 0.35 &&
|
||||
elevatorLocalEyePosition.y <= runtime.carY + cabHeight + 0.7
|
||||
const continuingRide =
|
||||
activeRide?.elevatorId === typedElevatorId &&
|
||||
insideFootprint &&
|
||||
elevatorLocalEyePosition.y >= runtime.carY - 0.2 &&
|
||||
elevatorLocalEyePosition.y <= runtime.carY + cabHeight + 1.25
|
||||
|
||||
if ((insideFootprint && insideCabHeight) || continuingRide) {
|
||||
nextRide = {
|
||||
cabHeight,
|
||||
carY: runtime.carY,
|
||||
doorOpen: runtime.doorOpen,
|
||||
elevatorId: typedElevatorId,
|
||||
halfDepth: Math.max(halfDepth, 0.24),
|
||||
halfWidth: Math.max(halfWidth, 0.24),
|
||||
object,
|
||||
phase: runtime.phase,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!nextRide) {
|
||||
ridingElevatorRef.current = null
|
||||
setElevatorRideLocked(false)
|
||||
return
|
||||
}
|
||||
|
||||
const previousCarY =
|
||||
activeRide?.elevatorId === nextRide.elevatorId ? activeRide.previousCarY : nextRide.carY
|
||||
const deltaY = nextRide.carY - previousCarY
|
||||
nextRide.object.updateWorldMatrix(true, true)
|
||||
elevatorLocalControllerPosition.copy(group.position)
|
||||
nextRide.object.worldToLocal(elevatorLocalControllerPosition)
|
||||
const localControllerY =
|
||||
activeRide?.elevatorId === nextRide.elevatorId && activeRide.localControllerY !== null
|
||||
? activeRide.localControllerY
|
||||
: elevatorLocalControllerPosition.y - nextRide.carY
|
||||
|
||||
if (Math.abs(deltaY) > 0.0001) {
|
||||
group.position.y += deltaY
|
||||
controllerRef.current?.resetLinVel()
|
||||
}
|
||||
|
||||
const shouldLockToCab =
|
||||
nextRide.phase === 'closing' ||
|
||||
nextRide.phase === 'moving' ||
|
||||
(nextRide.phase === 'opening' && nextRide.doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD)
|
||||
if (shouldLockToCab) {
|
||||
elevatorLocalControllerPosition.copy(group.position)
|
||||
nextRide.object.worldToLocal(elevatorLocalControllerPosition)
|
||||
const desiredLocalY = nextRide.carY + localControllerY
|
||||
if (Math.abs(elevatorLocalControllerPosition.y - desiredLocalY) > 0.002) {
|
||||
elevatorLocalControllerPosition.y = desiredLocalY
|
||||
elevatorWorldControllerPosition.copy(elevatorLocalControllerPosition)
|
||||
nextRide.object.localToWorld(elevatorWorldControllerPosition)
|
||||
group.position.y = elevatorWorldControllerPosition.y
|
||||
controllerRef.current?.resetLinVel()
|
||||
elevatorLocalControllerPosition.copy(group.position)
|
||||
nextRide.object.worldToLocal(elevatorLocalControllerPosition)
|
||||
}
|
||||
|
||||
const clampedX = Math.max(
|
||||
-nextRide.halfWidth,
|
||||
Math.min(nextRide.halfWidth, elevatorLocalControllerPosition.x),
|
||||
)
|
||||
const clampedZ = Math.max(
|
||||
-nextRide.halfDepth,
|
||||
Math.min(nextRide.halfDepth, elevatorLocalControllerPosition.z),
|
||||
)
|
||||
|
||||
if (
|
||||
Math.abs(clampedX - elevatorLocalControllerPosition.x) > 0.0001 ||
|
||||
Math.abs(clampedZ - elevatorLocalControllerPosition.z) > 0.0001
|
||||
) {
|
||||
elevatorLocalControllerPosition.x = clampedX
|
||||
elevatorLocalControllerPosition.z = clampedZ
|
||||
elevatorWorldControllerPosition.copy(elevatorLocalControllerPosition)
|
||||
nextRide.object.localToWorld(elevatorWorldControllerPosition)
|
||||
group.position.x = elevatorWorldControllerPosition.x
|
||||
group.position.z = elevatorWorldControllerPosition.z
|
||||
controllerRef.current?.resetLinVel()
|
||||
}
|
||||
}
|
||||
|
||||
setElevatorRideLocked(shouldLockToCab)
|
||||
|
||||
ridingElevatorRef.current = {
|
||||
elevatorId: nextRide.elevatorId,
|
||||
localControllerY,
|
||||
previousCarY: nextRide.carY,
|
||||
}
|
||||
},
|
||||
[camera, setElevatorRideLocked],
|
||||
)
|
||||
|
||||
useFrame(() => {
|
||||
if (!controllerRef.current?.group) return
|
||||
|
||||
const group = controllerRef.current.group
|
||||
@@ -426,17 +1167,20 @@ export const FirstPersonControls = () => {
|
||||
cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
|
||||
camera.quaternion.setFromEuler(cameraEuler)
|
||||
camera.updateMatrixWorld(true)
|
||||
syncElevatorRide(group)
|
||||
camera.position.copy(group.position).add(cameraOffset)
|
||||
camera.updateMatrixWorld(true)
|
||||
|
||||
const nextInteractableTarget = resolveInteractableTarget()
|
||||
const previousInteractableTarget = interactableTargetRef.current
|
||||
if (
|
||||
previousInteractableTarget?.id !== nextInteractableTarget?.id ||
|
||||
previousInteractableTarget?.type !== nextInteractableTarget?.type
|
||||
getInteractableTargetKey(previousInteractableTarget) !==
|
||||
getInteractableTargetKey(nextInteractableTarget)
|
||||
) {
|
||||
interactableTargetRef.current = nextInteractableTarget
|
||||
useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null)
|
||||
}
|
||||
})
|
||||
}, 2.5)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -446,6 +1190,11 @@ export const FirstPersonControls = () => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const firstPersonColliderMeshes = useMemo(
|
||||
() => (world ? [world.mesh, ...elevatorColliderMeshes] : elevatorColliderMeshes),
|
||||
[world, elevatorColliderMeshes],
|
||||
)
|
||||
|
||||
if (!world) {
|
||||
return null
|
||||
}
|
||||
@@ -458,7 +1207,7 @@ export const FirstPersonControls = () => {
|
||||
ref={controllerRef}
|
||||
key="first-person-controller"
|
||||
colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
|
||||
colliderMeshes={[world.mesh]}
|
||||
colliderMeshes={firstPersonColliderMeshes}
|
||||
collisionCheckIteration={3}
|
||||
collisionPushBackDamping={0.1}
|
||||
collisionPushBackThreshold={0.001}
|
||||
@@ -476,6 +1225,7 @@ export const FirstPersonControls = () => {
|
||||
maxRunSpeed={5.5}
|
||||
maxSlope={1.2}
|
||||
maxWalkSpeed={4}
|
||||
paused={isElevatorRideLocked}
|
||||
position={controllerStart.position}
|
||||
acceleration={26}
|
||||
airDragFactor={0.3}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type CeilingNode,
|
||||
ColumnNode,
|
||||
DoorNode,
|
||||
ElevatorNode,
|
||||
FenceNode,
|
||||
generateId,
|
||||
ItemNode,
|
||||
@@ -35,6 +36,7 @@ const ALLOWED_TYPES = [
|
||||
'item',
|
||||
'door',
|
||||
'window',
|
||||
'elevator',
|
||||
'roof',
|
||||
'roof-segment',
|
||||
'stair',
|
||||
@@ -183,6 +185,7 @@ export function FloatingActionMenu() {
|
||||
node.type === 'item' ||
|
||||
node.type === 'window' ||
|
||||
node.type === 'door' ||
|
||||
node.type === 'elevator' ||
|
||||
node.type === 'wall' ||
|
||||
node.type === 'fence' ||
|
||||
node.type === 'column' ||
|
||||
@@ -263,6 +266,8 @@ export function FloatingActionMenu() {
|
||||
duplicate = WindowNode.parse(duplicateInfo)
|
||||
} else if (node.type === 'item') {
|
||||
duplicate = ItemNode.parse(duplicateInfo)
|
||||
} else if (node.type === 'elevator') {
|
||||
duplicate = ElevatorNode.parse(duplicateInfo)
|
||||
} else if (node.type === 'column') {
|
||||
duplicate = ColumnNode.parse(duplicateInfo)
|
||||
} else if (node.type === 'wall') {
|
||||
@@ -296,7 +301,11 @@ export function FloatingActionMenu() {
|
||||
}
|
||||
|
||||
if (duplicate) {
|
||||
if (duplicate.type === 'door' || duplicate.type === 'window') {
|
||||
if (
|
||||
duplicate.type === 'door' ||
|
||||
duplicate.type === 'window' ||
|
||||
duplicate.type === 'elevator'
|
||||
) {
|
||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||
} else if (duplicate.type === 'wall') {
|
||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||
@@ -325,6 +334,7 @@ export function FloatingActionMenu() {
|
||||
}
|
||||
if (
|
||||
duplicate.type === 'item' ||
|
||||
duplicate.type === 'elevator' ||
|
||||
duplicate.type === 'column' ||
|
||||
duplicate.type === 'wall' ||
|
||||
duplicate.type === 'fence' ||
|
||||
|
||||
@@ -14175,7 +14175,10 @@ export function FloorplanPanel() {
|
||||
}
|
||||
|
||||
const currentHoles = slab.holes ?? []
|
||||
if (!currentHoles[holeIndex] || slab.holeMetadata?.[holeIndex]?.source === 'stair') {
|
||||
if (
|
||||
!currentHoles[holeIndex] ||
|
||||
(slab.holeMetadata?.[holeIndex]?.source ?? 'manual') !== 'manual'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -14310,7 +14313,10 @@ export function FloorplanPanel() {
|
||||
}
|
||||
|
||||
const currentHoles = ceiling.holes ?? []
|
||||
if (!currentHoles[holeIndex] || ceiling.holeMetadata?.[holeIndex]?.source === 'stair') {
|
||||
if (
|
||||
!currentHoles[holeIndex] ||
|
||||
(ceiling.holeMetadata?.[holeIndex]?.source ?? 'manual') !== 'manual'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ import useEditor, {
|
||||
import { boxSelectHandled } from '../tools/select/box-select-tool'
|
||||
|
||||
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
||||
if (node.type === 'elevator') return true
|
||||
const currentLevelId = useViewer.getState().selection.levelId
|
||||
if (!currentLevelId) return true // No level selected, allow all
|
||||
const nodeLevelId = resolveLevelId(node, useScene.getState().nodes)
|
||||
@@ -68,6 +69,7 @@ type SelectableNodeType =
|
||||
| 'item'
|
||||
| 'column'
|
||||
| 'building'
|
||||
| 'elevator'
|
||||
| 'zone'
|
||||
| 'slab'
|
||||
| 'ceiling'
|
||||
@@ -565,6 +567,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
||||
'fence',
|
||||
'item',
|
||||
'column',
|
||||
'elevator',
|
||||
'zone',
|
||||
'slab',
|
||||
'ceiling',
|
||||
@@ -579,11 +582,18 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
||||
handleSelect: (node, nativeEvent, modifierKeys) => {
|
||||
const { selection, setSelection } = useViewer.getState()
|
||||
const nodes = useScene.getState().nodes
|
||||
const nodeLevelId = resolveLevelId(node, nodes)
|
||||
const buildingId = resolveBuildingId(nodeLevelId, nodes)
|
||||
const nodeLevelId = node.type === 'elevator' ? null : resolveLevelId(node, nodes)
|
||||
const buildingId =
|
||||
node.type === 'elevator' &&
|
||||
node.parentId &&
|
||||
nodes[node.parentId as AnyNodeId]?.type === 'building'
|
||||
? node.parentId
|
||||
: nodeLevelId
|
||||
? resolveBuildingId(nodeLevelId, nodes)
|
||||
: null
|
||||
|
||||
const updates: any = {}
|
||||
if (nodeLevelId !== 'default' && nodeLevelId !== selection.levelId) {
|
||||
if (nodeLevelId && nodeLevelId !== 'default' && nodeLevelId !== selection.levelId) {
|
||||
updates.levelId = nodeLevelId
|
||||
}
|
||||
if (buildingId && buildingId !== selection.buildingId) {
|
||||
@@ -619,6 +629,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
||||
node.type === 'wall' ||
|
||||
node.type === 'fence' ||
|
||||
node.type === 'column' ||
|
||||
node.type === 'elevator' ||
|
||||
node.type === 'slab' ||
|
||||
node.type === 'ceiling' ||
|
||||
node.type === 'roof' ||
|
||||
@@ -683,6 +694,7 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
|
||||
node.type === 'wall' ||
|
||||
node.type === 'fence' ||
|
||||
node.type === 'column' ||
|
||||
node.type === 'elevator' ||
|
||||
node.type === 'slab' ||
|
||||
node.type === 'ceiling' ||
|
||||
node.type === 'roof' ||
|
||||
@@ -1163,6 +1175,7 @@ export const SelectionManager = () => {
|
||||
'item',
|
||||
'column',
|
||||
'building',
|
||||
'elevator',
|
||||
'zone',
|
||||
'slab',
|
||||
'ceiling',
|
||||
@@ -1259,6 +1272,7 @@ export const SelectionManager = () => {
|
||||
node.type === 'wall' ||
|
||||
node.type === 'fence' ||
|
||||
node.type === 'column' ||
|
||||
node.type === 'elevator' ||
|
||||
node.type === 'slab' ||
|
||||
node.type === 'ceiling' ||
|
||||
node.type === 'roof' ||
|
||||
@@ -1313,6 +1327,7 @@ export const SelectionManager = () => {
|
||||
'item',
|
||||
'column',
|
||||
'building',
|
||||
'elevator',
|
||||
'slab',
|
||||
'ceiling',
|
||||
'roof',
|
||||
@@ -1386,6 +1401,7 @@ export const SelectionManager = () => {
|
||||
'fence',
|
||||
'item',
|
||||
'column',
|
||||
'elevator',
|
||||
'slab',
|
||||
'ceiling',
|
||||
'roof',
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export const DEFAULT_ELEVATOR_WIDTH = 1.6
|
||||
export const DEFAULT_ELEVATOR_DEPTH = 1.6
|
||||
export const DEFAULT_ELEVATOR_CAB_HEIGHT = 2.35
|
||||
export const DEFAULT_ELEVATOR_DOOR_WIDTH = 0.95
|
||||
export const DEFAULT_ELEVATOR_DOOR_HEIGHT = 2.1
|
||||
export const DEFAULT_ELEVATOR_SPEED = 2.2
|
||||
export const DEFAULT_ELEVATOR_DOOR_DURATION_MS = 900
|
||||
export const DEFAULT_ELEVATOR_DWELL_MS = 1400
|
||||
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type BuildingNode,
|
||||
ElevatorNode,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
import {
|
||||
DEFAULT_ELEVATOR_CAB_HEIGHT,
|
||||
DEFAULT_ELEVATOR_DEPTH,
|
||||
DEFAULT_ELEVATOR_DOOR_DURATION_MS,
|
||||
DEFAULT_ELEVATOR_DOOR_HEIGHT,
|
||||
DEFAULT_ELEVATOR_DOOR_WIDTH,
|
||||
DEFAULT_ELEVATOR_DWELL_MS,
|
||||
DEFAULT_ELEVATOR_SPEED,
|
||||
DEFAULT_ELEVATOR_WIDTH,
|
||||
} from './elevator-defaults'
|
||||
|
||||
const GRID_OFFSET = 0.02
|
||||
|
||||
function resolveCurrentBuildingId(): BuildingNode['id'] | null {
|
||||
const { buildingId, levelId } = useViewer.getState().selection
|
||||
if (buildingId) return buildingId as BuildingNode['id']
|
||||
if (!levelId) return null
|
||||
|
||||
const level = useScene.getState().nodes[levelId as AnyNodeId]
|
||||
if (level?.type === 'level' && level.parentId) {
|
||||
return level.parentId as BuildingNode['id']
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveDefaultServiceRange(buildingId: BuildingNode['id']): {
|
||||
defaultLevelId: LevelNode['id'] | null
|
||||
fromLevelId: LevelNode['id'] | null
|
||||
toLevelId: LevelNode['id'] | null
|
||||
} {
|
||||
const { levelId } = useViewer.getState().selection
|
||||
const nodes = useScene.getState().nodes
|
||||
const building = nodes[buildingId as AnyNodeId]
|
||||
if (building?.type !== 'building') {
|
||||
return { defaultLevelId: null, fromLevelId: null, toLevelId: null }
|
||||
}
|
||||
|
||||
const levels = building.children
|
||||
.map((childId) => nodes[childId as AnyNodeId])
|
||||
.filter((node): node is LevelNode => node?.type === 'level')
|
||||
.sort((left, right) => left.level - right.level)
|
||||
const selectedLevelIndex = levels.findIndex((level) => level.id === levelId)
|
||||
const fromIndex = selectedLevelIndex >= 0 ? selectedLevelIndex : 0
|
||||
const fromLevel = levels[fromIndex]
|
||||
const toLevel = levels[Math.min(fromIndex + 1, levels.length - 1)] ?? fromLevel
|
||||
|
||||
return {
|
||||
defaultLevelId: fromLevel?.id ?? null,
|
||||
fromLevelId: fromLevel?.id ?? null,
|
||||
toLevelId: toLevel?.id ?? fromLevel?.id ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function createElevatorPreviewGeometry(): THREE.BufferGeometry {
|
||||
return new THREE.BoxGeometry(
|
||||
DEFAULT_ELEVATOR_WIDTH,
|
||||
DEFAULT_ELEVATOR_CAB_HEIGHT,
|
||||
DEFAULT_ELEVATOR_DEPTH,
|
||||
)
|
||||
}
|
||||
|
||||
function commitElevatorPlacement(
|
||||
buildingId: BuildingNode['id'],
|
||||
position: [number, number, number],
|
||||
rotation: number,
|
||||
): void {
|
||||
const { createNode, nodes } = useScene.getState()
|
||||
const elevatorCount = Object.values(nodes).filter((node) => node.type === 'elevator').length
|
||||
const serviceRange = resolveDefaultServiceRange(buildingId)
|
||||
const elevator = ElevatorNode.parse({
|
||||
name: `Elevator ${elevatorCount + 1}`,
|
||||
parentId: buildingId,
|
||||
position,
|
||||
rotation,
|
||||
width: DEFAULT_ELEVATOR_WIDTH,
|
||||
depth: DEFAULT_ELEVATOR_DEPTH,
|
||||
cabHeight: DEFAULT_ELEVATOR_CAB_HEIGHT,
|
||||
doorWidth: DEFAULT_ELEVATOR_DOOR_WIDTH,
|
||||
doorHeight: DEFAULT_ELEVATOR_DOOR_HEIGHT,
|
||||
...serviceRange,
|
||||
speed: DEFAULT_ELEVATOR_SPEED,
|
||||
doorDurationMs: DEFAULT_ELEVATOR_DOOR_DURATION_MS,
|
||||
dwellMs: DEFAULT_ELEVATOR_DWELL_MS,
|
||||
})
|
||||
|
||||
createNode(elevator, buildingId)
|
||||
useViewer.getState().setSelection({ buildingId, selectedIds: [elevator.id] })
|
||||
sfxEmitter.emit('sfx:structure-build')
|
||||
}
|
||||
|
||||
export const ElevatorTool: React.FC = () => {
|
||||
const cursorRef = useRef<THREE.Group>(null)
|
||||
const previewRef = useRef<THREE.Group>(null)
|
||||
const rotationRef = useRef(0)
|
||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||
const buildingId = useViewer((state) => state.selection.buildingId)
|
||||
const levelId = useViewer((state) => state.selection.levelId)
|
||||
const previewGeometry = useMemo(() => createElevatorPreviewGeometry(), [])
|
||||
|
||||
useEffect(() => {
|
||||
const currentBuildingId =
|
||||
(buildingId as BuildingNode['id'] | null) ??
|
||||
(levelId
|
||||
? (() => {
|
||||
const level = useScene.getState().nodes[levelId as AnyNodeId]
|
||||
return level?.type === 'level' && level.parentId
|
||||
? (level.parentId as BuildingNode['id'])
|
||||
: null
|
||||
})()
|
||||
: null)
|
||||
if (!currentBuildingId) return
|
||||
|
||||
rotationRef.current = 0
|
||||
if (previewRef.current) previewRef.current.rotation.y = 0
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
||||
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
||||
const y = event.localPosition[1]
|
||||
|
||||
cursorRef.current?.position.set(gridX, y + GRID_OFFSET, gridZ)
|
||||
previewRef.current?.position.set(gridX, y + DEFAULT_ELEVATOR_CAB_HEIGHT / 2, gridZ)
|
||||
|
||||
if (
|
||||
previousGridPosRef.current &&
|
||||
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
|
||||
) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
|
||||
previousGridPosRef.current = [gridX, gridZ]
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
const latestBuildingId = resolveCurrentBuildingId()
|
||||
if (!latestBuildingId) return
|
||||
|
||||
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
||||
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
||||
commitElevatorPlacement(latestBuildingId, [gridX, 0, gridZ], rotationRef.current)
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||
return
|
||||
}
|
||||
|
||||
const ROTATION_STEP = Math.PI / 4
|
||||
let rotationDelta = 0
|
||||
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
|
||||
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
|
||||
|
||||
if (rotationDelta !== 0) {
|
||||
event.preventDefault()
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
rotationRef.current += rotationDelta
|
||||
if (previewRef.current) previewRef.current.rotation.y = rotationRef.current
|
||||
}
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => {
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [buildingId, levelId])
|
||||
|
||||
return (
|
||||
<group>
|
||||
<CursorSphere ref={cursorRef} />
|
||||
<group ref={previewRef}>
|
||||
<mesh castShadow geometry={previewGeometry}>
|
||||
<meshStandardMaterial color="#38bdf8" depthWrite={false} opacity={0.32} transparent />
|
||||
</mesh>
|
||||
<mesh position={[0, DEFAULT_ELEVATOR_CAB_HEIGHT / 2, -DEFAULT_ELEVATOR_DEPTH / 2 - 0.03]}>
|
||||
<boxGeometry args={[DEFAULT_ELEVATOR_DOOR_WIDTH, DEFAULT_ELEVATOR_DOOR_HEIGHT, 0.035]} />
|
||||
<meshStandardMaterial color="#e5e7eb" depthWrite={false} opacity={0.45} transparent />
|
||||
</mesh>
|
||||
</group>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type ElevatorNode,
|
||||
ElevatorNode as ElevatorNodeSchema,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
sceneRegistry,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
function stripMoveMetadata(metadata: ElevatorNode['metadata']) {
|
||||
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
|
||||
return metadata
|
||||
}
|
||||
|
||||
const nextMeta = { ...(metadata as Record<string, unknown>) }
|
||||
delete nextMeta.isNew
|
||||
delete nextMeta.isTransient
|
||||
return nextMeta as ElevatorNode['metadata']
|
||||
}
|
||||
|
||||
export function MoveElevatorTool({ node: movingNode }: { node: ElevatorNode }) {
|
||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||
const previewPositionRef = useRef<ElevatorNode['position']>([
|
||||
movingNode.position[0],
|
||||
movingNode.position[1],
|
||||
movingNode.position[2],
|
||||
])
|
||||
const [cursorPosition, setCursorPosition] = useState<[number, number, number]>(() => [
|
||||
movingNode.position[0],
|
||||
movingNode.position[1],
|
||||
movingNode.position[2],
|
||||
])
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
useScene.temporal.getState().pause()
|
||||
const movingNodeId = (movingNode as { id?: ElevatorNode['id'] }).id
|
||||
|
||||
const meta =
|
||||
typeof movingNode.metadata === 'object' && movingNode.metadata !== null
|
||||
? (movingNode.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
const isNew = !!meta.isNew
|
||||
const committedMeta = stripMoveMetadata(movingNode.metadata)
|
||||
const original = {
|
||||
position: [...movingNode.position] as ElevatorNode['position'],
|
||||
rotation: movingNode.rotation,
|
||||
metadata: movingNode.metadata,
|
||||
}
|
||||
|
||||
let wasCommitted = false
|
||||
let wasCancelled = false
|
||||
let pendingRotation = movingNode.rotation
|
||||
|
||||
const applyPreview = (
|
||||
position: ElevatorNode['position'],
|
||||
rotation: ElevatorNode['rotation'],
|
||||
) => {
|
||||
if (movingNodeId) {
|
||||
useLiveTransforms.getState().set(movingNodeId, { position, rotation })
|
||||
}
|
||||
|
||||
const object = movingNodeId ? sceneRegistry.nodes.get(movingNodeId) : null
|
||||
if (object) {
|
||||
object.position.set(position[0], position[1], position[2])
|
||||
object.rotation.y = rotation
|
||||
}
|
||||
}
|
||||
|
||||
const resetObject = (
|
||||
position: ElevatorNode['position'],
|
||||
rotation: ElevatorNode['rotation'],
|
||||
) => {
|
||||
const object = movingNodeId ? sceneRegistry.nodes.get(movingNodeId) : null
|
||||
if (object) {
|
||||
object.position.set(position[0], position[1], position[2])
|
||||
object.rotation.y = rotation
|
||||
}
|
||||
}
|
||||
|
||||
const clearPreview = () => {
|
||||
if (movingNodeId) {
|
||||
useLiveTransforms.getState().clear(movingNodeId)
|
||||
}
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
||||
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
||||
const y = event.localPosition[1]
|
||||
|
||||
if (
|
||||
previousGridPosRef.current &&
|
||||
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
|
||||
) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
|
||||
previousGridPosRef.current = [gridX, gridZ]
|
||||
setCursorPosition([gridX, y, gridZ])
|
||||
previewPositionRef.current = [gridX, movingNode.position[1], gridZ]
|
||||
applyPreview(previewPositionRef.current, pendingRotation)
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
||||
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
||||
|
||||
wasCommitted = true
|
||||
clearPreview()
|
||||
useScene.temporal.getState().resume()
|
||||
if (movingNodeId && useScene.getState().nodes[movingNodeId as AnyNodeId]) {
|
||||
useScene.getState().updateNode(movingNodeId as AnyNodeId, {
|
||||
position: [gridX, movingNode.position[1], gridZ],
|
||||
rotation: pendingRotation,
|
||||
metadata: committedMeta,
|
||||
})
|
||||
useViewer.getState().setSelection({ selectedIds: [movingNodeId] })
|
||||
} else if (movingNode.parentId) {
|
||||
const elevator = ElevatorNodeSchema.parse({
|
||||
...movingNode,
|
||||
id: undefined,
|
||||
position: [gridX, movingNode.position[1], gridZ],
|
||||
rotation: pendingRotation,
|
||||
metadata: committedMeta,
|
||||
})
|
||||
useScene.getState().createNode(elevator, movingNode.parentId as AnyNodeId)
|
||||
useViewer.getState().setSelection({ selectedIds: [elevator.id] })
|
||||
}
|
||||
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
exitMoveMode()
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
wasCancelled = true
|
||||
clearPreview()
|
||||
if (isNew && movingNodeId) {
|
||||
useScene.getState().deleteNode(movingNodeId as AnyNodeId)
|
||||
} else {
|
||||
if (movingNodeId) {
|
||||
useScene.getState().updateNode(movingNodeId as AnyNodeId, {
|
||||
position: original.position,
|
||||
rotation: original.rotation,
|
||||
metadata: original.metadata,
|
||||
})
|
||||
}
|
||||
}
|
||||
resetObject(original.position, original.rotation)
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||
return
|
||||
}
|
||||
|
||||
const ROTATION_STEP = Math.PI / 4
|
||||
let rotationDelta = 0
|
||||
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
|
||||
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
|
||||
|
||||
if (rotationDelta !== 0) {
|
||||
event.preventDefault()
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
pendingRotation += rotationDelta
|
||||
applyPreview(previewPositionRef.current, pendingRotation)
|
||||
}
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => {
|
||||
clearPreview()
|
||||
if (!(wasCommitted || wasCancelled || isNew) && movingNodeId) {
|
||||
useScene.getState().updateNode(movingNodeId as AnyNodeId, {
|
||||
position: original.position,
|
||||
rotation: original.rotation,
|
||||
metadata: original.metadata,
|
||||
})
|
||||
resetObject(original.position, original.rotation)
|
||||
}
|
||||
useScene.temporal.getState().resume()
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [movingNode, exitMoveMode])
|
||||
|
||||
return <CursorSphere position={cursorPosition} showTooltip={false} />
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CeilingNode,
|
||||
ColumnNode,
|
||||
DoorNode,
|
||||
ElevatorNode,
|
||||
FenceNode,
|
||||
ItemNode,
|
||||
RoofNode,
|
||||
@@ -21,6 +22,7 @@ import { MoveBuildingContent } from '../building/move-building-tool'
|
||||
import { MoveCeilingTool } from '../ceiling/move-ceiling-tool'
|
||||
import { MoveColumnTool } from '../column/move-column-tool'
|
||||
import { MoveDoorTool } from '../door/move-door-tool'
|
||||
import { MoveElevatorTool } from '../elevator/move-elevator-tool'
|
||||
import { MoveFenceTool } from '../fence/move-fence-tool'
|
||||
import { MoveRoofTool } from '../roof/move-roof-tool'
|
||||
import { MoveSlabTool } from '../slab/move-slab-tool'
|
||||
@@ -99,6 +101,7 @@ export const MoveTool: React.FC<{
|
||||
if (movingNode.type === 'building')
|
||||
return <MoveBuildingContent node={movingNode as BuildingNode} />
|
||||
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
|
||||
if (movingNode.type === 'elevator') return <MoveElevatorTool node={movingNode as ElevatorNode} />
|
||||
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
|
||||
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
|
||||
if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
|
||||
import { CeilingTool } from './ceiling/ceiling-tool'
|
||||
import { ColumnTool } from './column/column-tool'
|
||||
import { DoorTool } from './door/door-tool'
|
||||
import { ElevatorTool } from './elevator/elevator-tool'
|
||||
import { CurveFenceTool } from './fence/curve-fence-tool'
|
||||
import { FenceTool } from './fence/fence-tool'
|
||||
import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool'
|
||||
@@ -41,6 +42,7 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
|
||||
slab: SlabTool,
|
||||
ceiling: CeilingTool,
|
||||
roof: RoofTool,
|
||||
elevator: ElevatorTool,
|
||||
stair: StairTool,
|
||||
door: DoorTool,
|
||||
item: ItemTool,
|
||||
|
||||
@@ -25,6 +25,7 @@ export const tools: ToolConfig[] = [
|
||||
{ id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
|
||||
{ id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
|
||||
{ id: 'column', iconSrc: '/icons/column.png', label: 'Column' },
|
||||
{ id: 'elevator', iconSrc: '/icons/elevator.svg', label: 'Elevator' },
|
||||
{ id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' },
|
||||
{ id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
|
||||
{ id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
|
||||
|
||||
@@ -190,8 +190,11 @@ export function EditorCommands() {
|
||||
const { nodes } = useScene.getState()
|
||||
const building = Object.values(nodes).find((n) => n.type === 'building')
|
||||
if (!building) return
|
||||
const levelCount = building.children.filter(
|
||||
(childId) => nodes[childId as keyof typeof nodes]?.type === 'level',
|
||||
).length
|
||||
const newLevel = LevelNode.parse({
|
||||
level: building.children.length,
|
||||
level: levelCount,
|
||||
children: [],
|
||||
parentId: building.id,
|
||||
})
|
||||
|
||||
@@ -9,24 +9,28 @@ interface MetricControlProps {
|
||||
label: React.ReactNode
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
onCommit?: (value: number) => void
|
||||
min?: number
|
||||
max?: number
|
||||
precision?: number
|
||||
step?: number
|
||||
className?: string
|
||||
unit?: string
|
||||
restoreOnCommit?: boolean
|
||||
}
|
||||
|
||||
export function MetricControl({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
onCommit,
|
||||
min = Number.NEGATIVE_INFINITY,
|
||||
max = Number.POSITIVE_INFINITY,
|
||||
precision = 2,
|
||||
step = 1,
|
||||
className,
|
||||
unit = '',
|
||||
restoreOnCommit = true,
|
||||
}: MetricControlProps) {
|
||||
const viewerUnit = useViewer((state) => state.unit)
|
||||
const isImperial = viewerUnit === 'imperial' && unit === 'm'
|
||||
@@ -53,6 +57,17 @@ export function MetricControl({
|
||||
[min, max],
|
||||
)
|
||||
|
||||
const applyCommittedValue = useCallback(
|
||||
(nextValue: number) => {
|
||||
if (onCommit) {
|
||||
onCommit(nextValue)
|
||||
} else {
|
||||
onChange(nextValue)
|
||||
}
|
||||
},
|
||||
[onChange, onCommit],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setInputValue(displayValue.toFixed(precision))
|
||||
@@ -77,13 +92,13 @@ export function MetricControl({
|
||||
const finalValue = Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
|
||||
|
||||
if (Math.abs(finalValue - valueRef.current) > 1e-6) {
|
||||
onChange(finalValue)
|
||||
applyCommittedValue(finalValue)
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('wheel', handleWheel, { passive: false })
|
||||
return () => container.removeEventListener('wheel', handleWheel)
|
||||
}, [isEditing, step, clamp, onChange, precision, multiplier])
|
||||
}, [isEditing, step, clamp, applyCommittedValue, precision, multiplier])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHovered || isEditing) return
|
||||
@@ -104,14 +119,14 @@ export function MetricControl({
|
||||
Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
|
||||
|
||||
if (Math.abs(finalValue - valueRef.current) > 1e-6) {
|
||||
onChange(finalValue)
|
||||
applyCommittedValue(finalValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isHovered, isEditing, step, clamp, onChange, precision, multiplier])
|
||||
}, [isHovered, isEditing, step, clamp, applyCommittedValue, precision, multiplier])
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
@@ -148,7 +163,14 @@ export function MetricControl({
|
||||
document.removeEventListener('pointermove', handlePointerMove)
|
||||
document.removeEventListener('pointerup', handlePointerUp)
|
||||
|
||||
if (Math.abs(finalValue - startValueRef.current) > 1e-6) {
|
||||
const changed = Math.abs(finalValue - startValueRef.current) > 1e-6
|
||||
if (onCommit) {
|
||||
if (changed && restoreOnCommit) {
|
||||
onChange(startValueRef.current)
|
||||
}
|
||||
useScene.temporal.getState().resume()
|
||||
onCommit(finalValue)
|
||||
} else if (changed) {
|
||||
onChange(startValueRef.current)
|
||||
useScene.temporal.getState().resume()
|
||||
onChange(finalValue)
|
||||
@@ -160,7 +182,7 @@ export function MetricControl({
|
||||
document.addEventListener('pointermove', handlePointerMove)
|
||||
document.addEventListener('pointerup', handlePointerUp)
|
||||
},
|
||||
[isEditing, value, onChange, clamp, precision, step, multiplier],
|
||||
[isEditing, value, onChange, onCommit, restoreOnCommit, clamp, precision, step, multiplier],
|
||||
)
|
||||
|
||||
const handleValueClick = useCallback(() => {
|
||||
@@ -177,10 +199,10 @@ export function MetricControl({
|
||||
if (Number.isNaN(numValue)) {
|
||||
setInputValue((value * multiplier).toFixed(precision))
|
||||
} else {
|
||||
onChange(clamp(numValue / multiplier))
|
||||
applyCommittedValue(clamp(numValue / multiplier))
|
||||
}
|
||||
setIsEditing(false)
|
||||
}, [inputValue, onChange, clamp, multiplier, value, precision])
|
||||
}, [inputValue, applyCommittedValue, clamp, multiplier, value, precision])
|
||||
|
||||
const handleInputBlur = useCallback(() => {
|
||||
submitValue()
|
||||
@@ -196,16 +218,16 @@ export function MetricControl({
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
const newV = clamp(value + step / multiplier)
|
||||
onChange(newV)
|
||||
applyCommittedValue(newV)
|
||||
setInputValue((newV * multiplier).toFixed(precision))
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
const newV = clamp(value - step / multiplier)
|
||||
onChange(newV)
|
||||
applyCommittedValue(newV)
|
||||
setInputValue((newV * multiplier).toFixed(precision))
|
||||
}
|
||||
},
|
||||
[submitValue, value, multiplier, precision, step, clamp, onChange],
|
||||
[submitValue, value, multiplier, precision, step, clamp, applyCommittedValue],
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -91,7 +91,7 @@ export function CeilingPanel() {
|
||||
(index: number) => {
|
||||
if (!selectedId) return
|
||||
const currentHoles = node?.holes || []
|
||||
if (node?.holeMetadata?.[index]?.source === 'stair') return
|
||||
if ((node?.holeMetadata?.[index]?.source ?? 'manual') !== 'manual') return
|
||||
const newHoles = currentHoles.filter((_, i) => i !== index)
|
||||
const currentMetadata = currentHoles.map(
|
||||
(_, metadataIndex) => node?.holeMetadata?.[metadataIndex] ?? { source: 'manual' as const },
|
||||
@@ -172,7 +172,8 @@ export function CeilingPanel() {
|
||||
const isEditing =
|
||||
editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
|
||||
const source = node.holeMetadata?.[index]?.source ?? 'manual'
|
||||
const isAutoHole = source === 'stair'
|
||||
const isAutoHole = source !== 'manual'
|
||||
const autoLabel = source === 'elevator' ? 'Auto elevator cutout' : 'Auto stair cutout'
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
|
||||
@@ -190,7 +191,7 @@ export function CeilingPanel() {
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{holeArea.toFixed(2)} m² · {hole.length} pts ·{' '}
|
||||
{isAutoHole ? 'Auto stair cutout' : 'Manual'}
|
||||
{isAutoHole ? autoLabel : 'Manual'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type ElevatorNode,
|
||||
ElevatorNode as ElevatorNodeSchema,
|
||||
type LevelNode,
|
||||
useInteractive,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Copy, Move, Send, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { MetricControl } from '../controls/metric-control'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
function findLevelId(levels: LevelNode[], levelId: string | null | undefined) {
|
||||
if (!levelId) return null
|
||||
return levels.some((level) => level.id === levelId) ? levelId : null
|
||||
}
|
||||
|
||||
function getLegacyServedLevels(node: ElevatorNode | undefined, levels: LevelNode[]) {
|
||||
if (!node || node.fromLevelId || node.toLevelId || !node.servedLevelIds?.length) return []
|
||||
const servedIds = new Set(node.servedLevelIds)
|
||||
return levels.filter((level) => servedIds.has(level.id))
|
||||
}
|
||||
|
||||
function getResolvedFromLevelId(node: ElevatorNode | undefined, levels: LevelNode[]) {
|
||||
if (!node) return levels[0]?.id ?? ''
|
||||
const legacyServedLevels = getLegacyServedLevels(node, levels)
|
||||
return (
|
||||
findLevelId(levels, node.fromLevelId) ??
|
||||
legacyServedLevels[0]?.id ??
|
||||
findLevelId(levels, node.defaultLevelId) ??
|
||||
levels[0]?.id ??
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
function getResolvedToLevelId(
|
||||
node: ElevatorNode | undefined,
|
||||
levels: LevelNode[],
|
||||
fromLevelId: string,
|
||||
) {
|
||||
if (!node) return levels[0]?.id ?? ''
|
||||
|
||||
const explicitTo = findLevelId(levels, node.toLevelId)
|
||||
if (explicitTo) return explicitTo
|
||||
const legacyServedLevels = getLegacyServedLevels(node, levels)
|
||||
const legacyTo = legacyServedLevels[legacyServedLevels.length - 1]?.id
|
||||
if (legacyTo) return legacyTo
|
||||
|
||||
const fromIndex = levels.findIndex((level) => level.id === fromLevelId)
|
||||
const fallbackIndex = fromIndex >= 0 ? Math.min(fromIndex + 1, levels.length - 1) : 0
|
||||
return levels[fallbackIndex]?.id ?? fromLevelId
|
||||
}
|
||||
|
||||
function getServiceLevels(levels: LevelNode[], fromLevelId: string, toLevelId: string) {
|
||||
const fromIndex = levels.findIndex((level) => level.id === fromLevelId)
|
||||
const toIndex = levels.findIndex((level) => level.id === toLevelId)
|
||||
if (fromIndex < 0 && toIndex < 0) return []
|
||||
|
||||
const resolvedFromIndex = fromIndex >= 0 ? fromIndex : toIndex
|
||||
const resolvedToIndex =
|
||||
toIndex >= 0 ? toIndex : Math.min(Math.max(resolvedFromIndex, 0) + 1, levels.length - 1)
|
||||
const minIndex = Math.min(resolvedFromIndex, resolvedToIndex)
|
||||
const maxIndex = Math.max(resolvedFromIndex, resolvedToIndex)
|
||||
|
||||
return levels.slice(minIndex, maxIndex + 1)
|
||||
}
|
||||
|
||||
function stripDuplicateFlags(metadata: ElevatorNode['metadata']) {
|
||||
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
|
||||
return metadata
|
||||
}
|
||||
|
||||
const nextMeta = { ...(metadata as Record<string, unknown>) }
|
||||
delete nextMeta.isNew
|
||||
delete nextMeta.isTransient
|
||||
return nextMeta as ElevatorNode['metadata']
|
||||
}
|
||||
|
||||
type ElevatorMetricKey = 'width' | 'depth' | 'cabHeight' | 'doorWidth' | 'doorHeight'
|
||||
|
||||
export function ElevatorPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const createNode = useScene((s) => s.createNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const runtime = useInteractive(
|
||||
useShallow((s) => {
|
||||
const state = selectedId ? s.elevators[selectedId as AnyNodeId] : null
|
||||
if (!state) return null
|
||||
return {
|
||||
currentLevelId: state.currentLevelId,
|
||||
queue: state.queue,
|
||||
targetLevelId: state.targetLevelId,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as ElevatorNode | undefined) : undefined,
|
||||
)
|
||||
const liveOverrides = useLiveNodeOverrides((s) =>
|
||||
selectedId ? s.get(selectedId as AnyNodeId) : undefined,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (selectedId) useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
|
||||
}
|
||||
}, [selectedId])
|
||||
|
||||
const levels = useScene(
|
||||
useShallow((s) => {
|
||||
if (!(node?.parentId && s.nodes[node.parentId as AnyNodeId]?.type === 'building')) return []
|
||||
const building = s.nodes[node.parentId as AnyNodeId]
|
||||
if (building?.type !== 'building') return []
|
||||
return building.children
|
||||
.map((childId) => s.nodes[childId as AnyNodeId])
|
||||
.filter((entry): entry is LevelNode => entry?.type === 'level')
|
||||
.sort((left, right) => left.level - right.level)
|
||||
}),
|
||||
)
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<ElevatorNode>) => {
|
||||
if (!selectedId) return
|
||||
updateNode(selectedId as AnyNode['id'], updates)
|
||||
},
|
||||
[selectedId, updateNode],
|
||||
)
|
||||
|
||||
const clearLivePreview = useCallback(() => {
|
||||
if (selectedId) useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
|
||||
}, [selectedId])
|
||||
|
||||
const previewMetric = useCallback(
|
||||
<K extends ElevatorMetricKey>(key: K, value: ElevatorNode[K]) => {
|
||||
if (!selectedId) return
|
||||
useLiveNodeOverrides.getState().set(selectedId as AnyNodeId, { [key]: value })
|
||||
},
|
||||
[selectedId],
|
||||
)
|
||||
|
||||
const commitMetric = useCallback(
|
||||
<K extends ElevatorMetricKey>(key: K, value: ElevatorNode[K]) => {
|
||||
if (!selectedId) return
|
||||
|
||||
const hasChange = !(node && Math.abs(Number(node[key]) - Number(value)) <= 1e-6)
|
||||
if (hasChange) {
|
||||
updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial<ElevatorNode>)
|
||||
}
|
||||
useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
|
||||
},
|
||||
[node, selectedId, updateNode],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
clearLivePreview()
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [clearLivePreview, setSelection])
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (!node) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
clearLivePreview()
|
||||
setMovingNode(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [clearLivePreview, node, setMovingNode, setSelection])
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (!(node && node.parentId)) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
|
||||
const duplicate = ElevatorNodeSchema.parse({
|
||||
...structuredClone(node),
|
||||
id: undefined,
|
||||
name: node.name ? `${node.name} Copy` : 'Elevator Copy',
|
||||
position: [node.position[0] + 1, node.position[1], node.position[2] + 1],
|
||||
metadata: { ...(stripDuplicateFlags(node.metadata) as Record<string, unknown>), isNew: true },
|
||||
})
|
||||
|
||||
createNode(duplicate, node.parentId as AnyNodeId)
|
||||
clearLivePreview()
|
||||
setMovingNode(duplicate)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [clearLivePreview, node, createNode, setMovingNode, setSelection])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!(selectedId && node)) return
|
||||
sfxEmitter.emit('sfx:structure-delete')
|
||||
clearLivePreview()
|
||||
useScene.getState().deleteNode(selectedId as AnyNodeId)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [clearLivePreview, selectedId, node, setSelection])
|
||||
|
||||
const requestLevel = useCallback(
|
||||
(levelId: LevelNode['id']) => {
|
||||
if (!node) return
|
||||
useInteractive.getState().requestElevator(node.id as AnyNodeId, levelId as AnyNodeId)
|
||||
},
|
||||
[node],
|
||||
)
|
||||
|
||||
const handleServiceBoundaryChange = useCallback(
|
||||
(field: 'fromLevelId' | 'toLevelId', levelId: string) => {
|
||||
if (!node) return
|
||||
const nextFromLevelId =
|
||||
field === 'fromLevelId' ? levelId : getResolvedFromLevelId(node, levels)
|
||||
const nextToLevelId =
|
||||
field === 'toLevelId' ? levelId : getResolvedToLevelId(node, levels, nextFromLevelId)
|
||||
const nextServedLevels = getServiceLevels(levels, nextFromLevelId, nextToLevelId)
|
||||
const currentDefaultIsServed = nextServedLevels.some(
|
||||
(level) => level.id === node.defaultLevelId,
|
||||
)
|
||||
|
||||
handleUpdate({
|
||||
[field]: levelId || null,
|
||||
defaultLevelId: currentDefaultIsServed
|
||||
? node.defaultLevelId
|
||||
: nextFromLevelId || nextServedLevels[0]?.id || null,
|
||||
servedLevelIds: undefined,
|
||||
} as Partial<ElevatorNode>)
|
||||
},
|
||||
[node, levels, handleUpdate],
|
||||
)
|
||||
|
||||
if (!(node && node.type === 'elevator' && selectedId && selectedCount === 1)) return null
|
||||
|
||||
const displayNode = liveOverrides ? ({ ...node, ...liveOverrides } as ElevatorNode) : node
|
||||
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 selectedDefaultLevelId = defaultLevelOptions.some(
|
||||
(level) => level.id === node.defaultLevelId,
|
||||
)
|
||||
? (node.defaultLevelId ?? '')
|
||||
: fromLevelId
|
||||
const activeLevelId =
|
||||
runtime?.currentLevelId ??
|
||||
(servedLevels.some((level) => level.id === node.defaultLevelId)
|
||||
? node.defaultLevelId
|
||||
: fromLevelId || levels[0]?.id) ??
|
||||
null
|
||||
const queuedLevelIds = new Set<string>()
|
||||
for (const levelId of runtime?.queue ?? []) queuedLevelIds.add(levelId)
|
||||
if (runtime?.targetLevelId) queuedLevelIds.add(runtime.targetLevelId)
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
icon="/icons/elevator.svg"
|
||||
onClose={handleClose}
|
||||
title={node.name || 'Elevator'}
|
||||
width={300}
|
||||
>
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
<ActionButton
|
||||
icon={<Copy className="h-3.5 w-3.5" />}
|
||||
label="Duplicate"
|
||||
onClick={handleDuplicate}
|
||||
/>
|
||||
<ActionButton
|
||||
className="text-destructive hover:text-destructive"
|
||||
icon={<Trash2 className="h-3.5 w-3.5" />}
|
||||
label="Delete"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Cab">
|
||||
<MetricControl
|
||||
label="Width"
|
||||
max={4}
|
||||
min={0.8}
|
||||
onChange={(value) => previewMetric('width', value)}
|
||||
onCommit={(value) => commitMetric('width', value)}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={displayNode.width}
|
||||
/>
|
||||
<MetricControl
|
||||
label="Depth"
|
||||
max={4}
|
||||
min={0.8}
|
||||
onChange={(value) => previewMetric('depth', value)}
|
||||
onCommit={(value) => commitMetric('depth', value)}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={displayNode.depth}
|
||||
/>
|
||||
<MetricControl
|
||||
label="Cab Height"
|
||||
max={4}
|
||||
min={1.8}
|
||||
onChange={(value) => previewMetric('cabHeight', value)}
|
||||
onCommit={(value) => commitMetric('cabHeight', value)}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={displayNode.cabHeight}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Doors">
|
||||
<MetricControl
|
||||
label="Door Width"
|
||||
max={Math.max(displayNode.width - 0.1, 0.5)}
|
||||
min={0.45}
|
||||
onChange={(value) => previewMetric('doorWidth', value)}
|
||||
onCommit={(value) => commitMetric('doorWidth', value)}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={displayNode.doorWidth}
|
||||
/>
|
||||
<MetricControl
|
||||
label="Door Height"
|
||||
max={Math.max(displayNode.cabHeight - 0.1, 1.3)}
|
||||
min={1.2}
|
||||
onChange={(value) => previewMetric('doorHeight', value)}
|
||||
onCommit={(value) => commitMetric('doorHeight', value)}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={displayNode.doorHeight}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Service">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
|
||||
From
|
||||
</div>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-2 text-sm text-foreground"
|
||||
onChange={(event) =>
|
||||
handleServiceBoundaryChange('fromLevelId', event.target.value)
|
||||
}
|
||||
value={fromLevelId}
|
||||
>
|
||||
{levels.map((level) => (
|
||||
<option key={level.id} value={level.id}>
|
||||
{level.name || `Level ${level.level}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
|
||||
To
|
||||
</div>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-2 text-sm text-foreground"
|
||||
onChange={(event) => handleServiceBoundaryChange('toLevelId', event.target.value)}
|
||||
value={toLevelId}
|
||||
>
|
||||
{levels.map((level) => (
|
||||
<option key={level.id} value={level.id}>
|
||||
{level.name || `Level ${level.level}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Default Floor
|
||||
</div>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
|
||||
onChange={(event) => handleUpdate({ defaultLevelId: event.target.value || null })}
|
||||
value={selectedDefaultLevelId}
|
||||
>
|
||||
{defaultLevelOptions.map((level) => (
|
||||
<option key={level.id} value={level.id}>
|
||||
{level.name || `Level ${level.level}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Destination">
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{servedLevels.map((level) => {
|
||||
const isActive = activeLevelId === level.id
|
||||
const isQueued = queuedLevelIds.has(level.id)
|
||||
return (
|
||||
<button
|
||||
className={`flex min-h-11 items-center justify-between gap-2 rounded-lg border px-2.5 text-left transition-colors ${
|
||||
isActive
|
||||
? 'border-sky-400/45 bg-sky-400/15 text-sky-100'
|
||||
: isQueued
|
||||
? 'border-amber-300/45 bg-amber-300/15 text-amber-100'
|
||||
: 'border-border/50 bg-[#2C2C2E] text-foreground hover:bg-[#3e3e3e]'
|
||||
}`}
|
||||
key={level.id}
|
||||
onClick={() => requestLevel(level.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 truncate text-xs">
|
||||
{level.name || `Level ${level.level}`}
|
||||
</span>
|
||||
<span className="flex h-6 min-w-6 items-center justify-center rounded-full border border-white/15 bg-black/20">
|
||||
<Send className="h-3 w-3" />
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Motion">
|
||||
<SliderControl
|
||||
label="Speed"
|
||||
max={8}
|
||||
min={0.5}
|
||||
onChange={(value) => handleUpdate({ speed: value })}
|
||||
precision={1}
|
||||
step={0.1}
|
||||
unit="m/s"
|
||||
value={node.speed}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Door Time"
|
||||
max={2200}
|
||||
min={300}
|
||||
onChange={(value) => handleUpdate({ doorDurationMs: value })}
|
||||
step={50}
|
||||
unit="ms"
|
||||
value={node.doorDurationMs}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Dwell"
|
||||
max={5000}
|
||||
min={300}
|
||||
onChange={(value) => handleUpdate({ dwellMs: value })}
|
||||
step={100}
|
||||
unit="ms"
|
||||
value={node.dwellMs}
|
||||
/>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ const TYPE_DEFAULTS: Record<string, NodeDisplay> = {
|
||||
slab: { icon: '/icons/floor.png', label: 'Slab' },
|
||||
ceiling: { icon: '/icons/ceiling.png', label: 'Ceiling' },
|
||||
column: { icon: '/icons/column.png', label: 'Column' },
|
||||
elevator: { icon: '/icons/elevator.svg', label: 'Elevator' },
|
||||
fence: { icon: '/icons/fence.png', label: 'Fence' },
|
||||
roof: { icon: '/icons/roof.png', label: 'Roof' },
|
||||
'roof-segment': { icon: '/icons/roof.png', label: 'Roof segment' },
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type CeilingNode,
|
||||
type ColumnNode,
|
||||
type DoorNode,
|
||||
type ElevatorNode,
|
||||
type FenceNode,
|
||||
type ItemNode,
|
||||
type RoofNode,
|
||||
@@ -26,6 +27,7 @@ import useEditor from '../../../store/use-editor'
|
||||
import { CeilingPanel } from './ceiling-panel'
|
||||
import { ColumnPanel } from './column-panel'
|
||||
import { DoorPanel } from './door-panel'
|
||||
import { ElevatorPanel } from './elevator-panel'
|
||||
import { FencePanel } from './fence-panel'
|
||||
import { ItemPanel } from './item-panel'
|
||||
import { MobilePanelSheet } from './mobile-panel-sheet'
|
||||
@@ -46,6 +48,7 @@ type MovableNode =
|
||||
| ItemNode
|
||||
| WindowNode
|
||||
| DoorNode
|
||||
| ElevatorNode
|
||||
| CeilingNode
|
||||
| ColumnNode
|
||||
| SlabNode
|
||||
@@ -61,6 +64,7 @@ const MOVABLE_TYPES = new Set<string>([
|
||||
'item',
|
||||
'window',
|
||||
'door',
|
||||
'elevator',
|
||||
'ceiling',
|
||||
'column',
|
||||
'slab',
|
||||
@@ -102,6 +106,8 @@ function panelForType(type: string | null) {
|
||||
return <FencePanel />
|
||||
case 'door':
|
||||
return <DoorPanel />
|
||||
case 'elevator':
|
||||
return <ElevatorPanel />
|
||||
case 'window':
|
||||
return <WindowPanel />
|
||||
default:
|
||||
@@ -264,6 +270,8 @@ export function PanelManager() {
|
||||
return <FencePanel />
|
||||
case 'door':
|
||||
return <DoorPanel />
|
||||
case 'elevator':
|
||||
return <ElevatorPanel />
|
||||
case 'window':
|
||||
return <WindowPanel />
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ export function SlabPanel() {
|
||||
(index: number) => {
|
||||
if (!selectedId) return
|
||||
const currentHoles = node?.holes || []
|
||||
if (node?.holeMetadata?.[index]?.source === 'stair') return
|
||||
if ((node?.holeMetadata?.[index]?.source ?? 'manual') !== 'manual') return
|
||||
const newHoles = currentHoles.filter((_, i) => i !== index)
|
||||
const currentMetadata = currentHoles.map(
|
||||
(_, metadataIndex) => node?.holeMetadata?.[metadataIndex] ?? { source: 'manual' as const },
|
||||
@@ -173,7 +173,8 @@ export function SlabPanel() {
|
||||
const isEditing =
|
||||
editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
|
||||
const source = node.holeMetadata?.[index]?.source ?? 'manual'
|
||||
const isAutoHole = source === 'stair'
|
||||
const isAutoHole = source !== 'manual'
|
||||
const autoLabel = source === 'elevator' ? 'Auto elevator cutout' : 'Auto stair cutout'
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
|
||||
@@ -191,7 +192,7 @@ export function SlabPanel() {
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{holeArea.toFixed(2)} m² · {hole.length} pts ·{' '}
|
||||
{isAutoHole ? 'Auto stair cutout' : 'Manual'}
|
||||
{isAutoHole ? autoLabel : 'Manual'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -39,8 +39,10 @@ export const BuildingTreeNode = memo(function BuildingTreeNode({
|
||||
|
||||
const handleAddLevel = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const nodes = useScene.getState().nodes
|
||||
const levelCount = children.filter((childId) => nodes[childId]?.type === 'level').length
|
||||
const newLevel = LevelNode.parse({
|
||||
level: children.length,
|
||||
level: levelCount,
|
||||
children: [],
|
||||
parentId: nodeId,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { type AnyNodeId, type ElevatorNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import Image from 'next/image'
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface ElevatorTreeNodeProps {
|
||||
nodeId: AnyNodeId
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export const ElevatorTreeNode = memo(function ElevatorTreeNode({
|
||||
nodeId,
|
||||
depth,
|
||||
isLast,
|
||||
}: ElevatorTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||
const node = useScene((s) => s.nodes[nodeId] as ElevatorNode | undefined)
|
||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
||||
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
const handled = handleTreeSelection(
|
||||
event,
|
||||
nodeId,
|
||||
useViewer.getState().selection.selectedIds,
|
||||
setSelection,
|
||||
)
|
||||
if (!handled && useEditor.getState().phase === 'furnish') {
|
||||
useEditor.getState().setPhase('structure')
|
||||
}
|
||||
},
|
||||
[nodeId, setSelection],
|
||||
)
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
actions={<TreeNodeActions nodeId={nodeId} />}
|
||||
depth={depth}
|
||||
expanded={false}
|
||||
hasChildren={false}
|
||||
icon={
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/elevator.svg" width={14} />
|
||||
}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
isSelected={isSelected}
|
||||
isVisible={isVisible}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
defaultName={node?.name || 'Elevator'}
|
||||
isEditing={isEditing}
|
||||
nodeId={nodeId}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
/>
|
||||
}
|
||||
nodeId={nodeId}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={() => focusTreeNode(nodeId)}
|
||||
onMouseEnter={() => setHoveredId(nodeId)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onToggle={() => {}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -58,6 +58,7 @@ import { BuildingTreeNode } from './building-tree-node'
|
||||
import { CeilingTreeNode } from './ceiling-tree-node'
|
||||
import { ColumnTreeNode } from './column-tree-node'
|
||||
import { DoorTreeNode } from './door-tree-node'
|
||||
import { ElevatorTreeNode } from './elevator-tree-node'
|
||||
import { FenceTreeNode } from './fence-tree-node'
|
||||
import { ItemTreeNode } from './item-tree-node'
|
||||
import { LevelTreeNode } from './level-tree-node'
|
||||
@@ -87,6 +88,8 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr
|
||||
return <CeilingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'column':
|
||||
return <ColumnTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'elevator':
|
||||
return <ElevatorTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'level':
|
||||
return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `level_${string}`} />
|
||||
case 'slab':
|
||||
|
||||
@@ -28,6 +28,7 @@ export function useContextualTools() {
|
||||
'slab',
|
||||
'ceiling',
|
||||
'roof',
|
||||
'elevator',
|
||||
'door',
|
||||
'window',
|
||||
]
|
||||
|
||||
@@ -133,13 +133,19 @@ export const useKeyboard = ({
|
||||
const { buildingId, levelId } = useViewer.getState().selection
|
||||
if (buildingId) {
|
||||
const building = useScene.getState().nodes[buildingId]
|
||||
if (building && building.type === 'building' && building.children.length > 0) {
|
||||
const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1
|
||||
const nextIdx = currentIdx < building.children.length - 1 ? currentIdx + 1 : currentIdx
|
||||
const levels =
|
||||
building?.type === 'building'
|
||||
? building.children.filter(
|
||||
(childId) => useScene.getState().nodes[childId as AnyNodeId]?.type === 'level',
|
||||
)
|
||||
: []
|
||||
if (levels.length > 0) {
|
||||
const currentIdx = levelId ? levels.indexOf(levelId as any) : -1
|
||||
const nextIdx = currentIdx < levels.length - 1 ? currentIdx + 1 : currentIdx
|
||||
if (nextIdx !== -1 && nextIdx !== currentIdx) {
|
||||
useViewer.getState().setSelection({ levelId: building.children[nextIdx] as any })
|
||||
useViewer.getState().setSelection({ levelId: levels[nextIdx] as any })
|
||||
} else if (currentIdx === -1) {
|
||||
useViewer.getState().setSelection({ levelId: building.children[0] as any })
|
||||
useViewer.getState().setSelection({ levelId: levels[0] as any })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,15 +154,19 @@ export const useKeyboard = ({
|
||||
const { buildingId, levelId } = useViewer.getState().selection
|
||||
if (buildingId) {
|
||||
const building = useScene.getState().nodes[buildingId]
|
||||
if (building && building.type === 'building' && building.children.length > 0) {
|
||||
const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1
|
||||
const levels =
|
||||
building?.type === 'building'
|
||||
? building.children.filter(
|
||||
(childId) => useScene.getState().nodes[childId as AnyNodeId]?.type === 'level',
|
||||
)
|
||||
: []
|
||||
if (levels.length > 0) {
|
||||
const currentIdx = levelId ? levels.indexOf(levelId as any) : -1
|
||||
const prevIdx = currentIdx > 0 ? currentIdx - 1 : currentIdx
|
||||
if (prevIdx !== -1 && prevIdx !== currentIdx) {
|
||||
useViewer.getState().setSelection({ levelId: building.children[prevIdx] as any })
|
||||
useViewer.getState().setSelection({ levelId: levels[prevIdx] as any })
|
||||
} else if (currentIdx === -1) {
|
||||
useViewer
|
||||
.getState()
|
||||
.setSelection({ levelId: building.children[building.children.length - 1] as any })
|
||||
useViewer.getState().setSelection({ levelId: levels[levels.length - 1] as any })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useLiveTransforms, useScene } from '@pascal-app/core'
|
||||
import { useLiveNodeOverrides, useLiveTransforms, useScene } from '@pascal-app/core'
|
||||
|
||||
function refreshSceneAfterHistoryJump() {
|
||||
useLiveNodeOverrides.getState().clearAll()
|
||||
useLiveTransforms.getState().clearAll()
|
||||
|
||||
const state = useScene.getState()
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type CeilingNode,
|
||||
type ColumnNode,
|
||||
type DoorNode,
|
||||
type ElevatorNode,
|
||||
type FenceNode,
|
||||
type ItemNode,
|
||||
type LevelNode,
|
||||
@@ -58,6 +59,7 @@ export type StructureTool =
|
||||
| 'ceiling'
|
||||
| 'roof'
|
||||
| 'column'
|
||||
| 'elevator'
|
||||
| 'stair'
|
||||
| 'item'
|
||||
| 'zone'
|
||||
@@ -138,6 +140,7 @@ type EditorState = {
|
||||
| ItemNode
|
||||
| WindowNode
|
||||
| DoorNode
|
||||
| ElevatorNode
|
||||
| FenceNode
|
||||
| CeilingNode
|
||||
| ColumnNode
|
||||
@@ -155,6 +158,7 @@ type EditorState = {
|
||||
| ItemNode
|
||||
| WindowNode
|
||||
| DoorNode
|
||||
| ElevatorNode
|
||||
| FenceNode
|
||||
| CeilingNode
|
||||
| ColumnNode
|
||||
@@ -449,9 +453,14 @@ export function selectDefaultBuildingAndLevel() {
|
||||
})
|
||||
if (level0Id) {
|
||||
viewer.setSelection({ levelId: level0Id as LevelNode['id'] })
|
||||
} else if (buildingNode.children[0]) {
|
||||
} else {
|
||||
// Fallback to first level if level 0 doesn't exist
|
||||
viewer.setSelection({ levelId: buildingNode.children[0] as LevelNode['id'] })
|
||||
const firstLevelId = buildingNode.children.find(
|
||||
(childId) => scene.nodes[childId]?.type === 'level',
|
||||
)
|
||||
if (firstLevelId) {
|
||||
viewer.setSelection({ levelId: firstLevelId as LevelNode['id'] })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -580,6 +589,7 @@ const useEditor = create<EditorState>()(
|
||||
| ItemNode
|
||||
| WindowNode
|
||||
| DoorNode
|
||||
| ElevatorNode
|
||||
| FenceNode
|
||||
| CeilingNode
|
||||
| SlabNode
|
||||
|
||||
Reference in New Issue
Block a user