diff --git a/apps/editor/public/icons/elevator.svg b/apps/editor/public/icons/elevator.svg new file mode 100644 index 000000000..ddc9563e1 --- /dev/null +++ b/apps/editor/public/icons/elevator.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 5e2898091..02fcf1e68 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -6,6 +6,7 @@ import type { CeilingNode, ColumnNode, DoorNode, + ElevatorNode, FenceNode, GuideNode, ItemNode, @@ -66,6 +67,7 @@ export type StairEvent = NodeEvent export type StairSegmentEvent = NodeEvent export type WindowEvent = NodeEvent export type DoorEvent = NodeEvent +export type ElevatorEvent = NodeEvent // Event suffixes - exported for use in hooks export const eventSuffixes = [ @@ -183,6 +185,7 @@ type EditorEvents = GridEvents & NodeEvents<'item', ItemEvent> & NodeEvents<'site', SiteEvent> & NodeEvents<'building', BuildingEvent> & + NodeEvents<'elevator', ElevatorEvent> & NodeEvents<'level', LevelEvent> & NodeEvents<'zone', ZoneEvent> & NodeEvents<'slab', SlabEvent> & diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index 62efe95d5..ab1a1a89b 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -14,6 +14,7 @@ export const sceneRegistry = { building: new Set(), ceiling: new Set(), column: new Set(), + elevator: new Set(), level: new Set(), wall: new Set(), fence: new Set(), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bca40c4dc..9d50139f4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,6 +5,7 @@ export type { CeilingEvent, ColumnEvent, DoorEvent, + ElevatorEvent, EventSuffix, FenceEvent, GridEvent, @@ -73,13 +74,56 @@ export { type ControlValue, type DoorAnimationState, type DoorInteractiveState, + type ElevatorInteractiveState, + type ElevatorPhase, type ItemInteractiveState, useInteractive, type WindowAnimationState, type WindowInteractiveState, } from './store/use-interactive' +export { + default as useLiveNodeOverrides, + type LiveNodeOverrides, +} from './store/use-live-node-overrides' export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' export { clearSceneHistory, default as useScene } from './store/use-scene' +export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch' +export { + getElevatorCabCenterZ, + getElevatorCabDepth, + getElevatorCabWidth, + getElevatorDoorLeafSides, + getElevatorDoorLeafWidth, + getElevatorDoorLeafX, + getElevatorShaftDepth, + getElevatorShaftWallThickness, + getElevatorShaftWidth, + getResolvedElevatorDoorPanelStyle, + getResolvedElevatorDoorStyle, + getResolvedElevatorShaftStyle, + type ElevatorDoorSide, +} from './systems/elevator/elevator-geometry' +export { syncAutoElevatorOpenings } from './systems/elevator/elevator-opening-sync' +export { ElevatorOpeningSystem } from './systems/elevator/elevator-opening-system' +export { + createElevatorInteractiveState, + openElevatorDoor, + openElevatorDoorState, + queueElevatorRequest, + requestElevatorLevel, + stepElevatorRuntimeState, + stepElevatorRuntimes, +} from './systems/elevator/elevator-runtime' +export { ElevatorRuntimeSystem } from './systems/elevator/elevator-runtime-system' +export { + DEFAULT_ELEVATOR_LEVEL_HEIGHT, + type ElevatorLevelEntry, + getElevatorLevelHeight, + resolveElevatorBuildingLevels, + resolveElevatorLevels, + resolveElevatorServiceLevelIds, + resolveElevatorServiceLevels, +} from './systems/elevator/elevator-service' export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync' export { getClampedWallCurveOffset, diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index b088079a5..aede4b6aa 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -42,6 +42,12 @@ export { ColumnSupportStyle, } from './nodes/column' export { DoorNode, DoorSegment } from './nodes/door' +export { + ElevatorDoorPanelStyle, + ElevatorDoorStyle, + ElevatorNode, + ElevatorShaftStyle, +} from './nodes/elevator' export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence' export { GuideNode, GuideScaleReference } from './nodes/guide' export type { diff --git a/packages/core/src/schema/nodes/building.ts b/packages/core/src/schema/nodes/building.ts index c4a86f5d0..e4a4e470f 100644 --- a/packages/core/src/schema/nodes/building.ts +++ b/packages/core/src/schema/nodes/building.ts @@ -1,12 +1,13 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' +import { ElevatorNode } from './elevator' import { LevelNode } from './level' export const BuildingNode = BaseNode.extend({ id: objectId('building'), type: nodeType('building'), - children: z.array(LevelNode.shape.id).default([]), + children: z.array(z.union([LevelNode.shape.id, ElevatorNode.shape.id])).default([]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), }).describe( @@ -14,7 +15,7 @@ export const BuildingNode = BaseNode.extend({ Building node - used to represent a building - position: position in site coordinate system - rotation: rotation in site coordinate system - - children: array of level nodes (each level is a tree of floor and wall nodes) + - children: array of level nodes and building-level systems such as elevators `, ) diff --git a/packages/core/src/schema/nodes/ceiling.ts b/packages/core/src/schema/nodes/ceiling.ts index 599422a68..724bb0fde 100644 --- a/packages/core/src/schema/nodes/ceiling.ts +++ b/packages/core/src/schema/nodes/ceiling.ts @@ -21,7 +21,7 @@ export const CeilingNode = BaseNode.extend({ Ceiling node - used to represent a ceiling in the building - polygon: array of [x, z] points defining the ceiling boundary - holes: array of polygons representing holes in the ceiling - - holeMetadata: metadata parallel to holes, used to preserve manual and stair-managed cutouts + - holeMetadata: metadata parallel to holes, used to preserve manual and auto-managed cutouts - autoFromWalls: whether the ceiling is automatically generated from a closed wall loop `, ) diff --git a/packages/core/src/schema/nodes/elevator.ts b/packages/core/src/schema/nodes/elevator.ts new file mode 100644 index 000000000..37d275729 --- /dev/null +++ b/packages/core/src/schema/nodes/elevator.ts @@ -0,0 +1,64 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const ElevatorDoorStyle = z.enum(['center-opening', 'single-left', 'single-right']) +export const ElevatorDoorPanelStyle = z.enum(['glass-frame', 'solid-panel', 'segmented-panel']) +export const ElevatorShaftStyle = z.enum(['solid', 'glass']) + +export type ElevatorDoorPanelStyle = z.infer +export type ElevatorDoorStyle = z.infer +export type ElevatorShaftStyle = z.infer + +export const ElevatorNode = BaseNode.extend({ + id: objectId('elevator'), + type: nodeType('elevator'), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + // Rotation around the Y axis in radians. + rotation: z.number().default(0), + width: z.number().default(1.6), + depth: z.number().default(1.6), + shaftWidth: z.number().optional(), + shaftDepth: z.number().optional(), + shaftWallThickness: z.number().default(0.09), + shaftStyle: ElevatorShaftStyle.default('solid'), + cabHeight: z.number().default(2.35), + doorWidth: z.number().default(0.95), + doorHeight: z.number().default(2.1), + doorStyle: ElevatorDoorStyle.default('center-opening'), + doorPanelStyle: ElevatorDoorPanelStyle.default('glass-frame'), + fromLevelId: z.string().nullable().default(null), + toLevelId: z.string().nullable().default(null), + servedLevelIds: z.array(z.string()).optional(), + disabledLevelIds: z.array(z.string()).default([]), + serviceOnlyLevelIds: z.array(z.string()).default([]), + defaultLevelId: z.string().nullable().default(null), + speed: z.number().default(2.2), + doorDurationMs: z.number().default(900), + dwellMs: z.number().default(1400), +}).describe( + dedent` + Elevator node - a vertical transport core attached to a building. + - parentId: building that owns this elevator + - position: building-local shaft center on the X/Z plane + - rotation: rotation around the Y axis + - width/depth: cab footprint + - shaftWidth/shaftDepth: optional clear shaft footprint; falls back to cab footprint + - shaftWallThickness: visible shaft shell thickness + - shaftStyle: solid or glass shaft shell presentation + - cabHeight: visible elevator cab height + - doorWidth/doorHeight/doorStyle: landing and cab door movement/opening presentation + - doorPanelStyle: visual leaf style for glass-frame, solid-panel, or segmented-panel doors + - fromLevelId / toLevelId: source and destination levels used for service range and auto cutouts + - servedLevelIds: legacy optional explicit level list; used only when from/to are missing + - disabledLevelIds: stops visible in the service range but unavailable for public/cab requests + - serviceOnlyLevelIds: stops unavailable from landing calls but available from cab/admin controls + - defaultLevelId: starting/resting level, falling back to the lowest served level + - speed/doorDurationMs/dwellMs: runtime animation defaults + `, +) + +export type ElevatorNode = z.infer diff --git a/packages/core/src/schema/nodes/slab.ts b/packages/core/src/schema/nodes/slab.ts index a13b303f7..5232eaaf8 100644 --- a/packages/core/src/schema/nodes/slab.ts +++ b/packages/core/src/schema/nodes/slab.ts @@ -19,7 +19,7 @@ export const SlabNode = BaseNode.extend({ Slab node - used to represent a slab/floor in the building - polygon: array of [x, z] points defining the slab boundary - holes: array of [x, z] polygons representing cutouts in the slab - - holeMetadata: metadata parallel to holes, used to preserve manual and stair-managed cutouts + - holeMetadata: metadata parallel to holes, used to preserve manual and auto-managed cutouts - elevation: elevation in meters - autoFromWalls: whether the slab is automatically generated from a closed wall loop `, diff --git a/packages/core/src/schema/nodes/surface-hole-metadata.ts b/packages/core/src/schema/nodes/surface-hole-metadata.ts index 6e5358cb5..a3d560e83 100644 --- a/packages/core/src/schema/nodes/surface-hole-metadata.ts +++ b/packages/core/src/schema/nodes/surface-hole-metadata.ts @@ -1,8 +1,10 @@ import { z } from 'zod' export const SurfaceHoleMetadata = z.object({ - source: z.enum(['manual', 'stair']).default('manual'), + // Stair/elevator auto-openings use stairId/elevatorId so sync can replace only its own holes. + source: z.enum(['manual', 'stair', 'elevator']).default('manual'), stairId: z.string().optional(), + elevatorId: z.string().optional(), }) export type SurfaceHoleMetadata = z.infer diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index a4977b5d4..d04c4be3a 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -3,6 +3,7 @@ import { BuildingNode } from './nodes/building' import { CeilingNode } from './nodes/ceiling' import { ColumnNode } from './nodes/column' import { DoorNode } from './nodes/door' +import { ElevatorNode } from './nodes/elevator' import { FenceNode } from './nodes/fence' import { GuideNode } from './nodes/guide' import { ItemNode } from './nodes/item' @@ -22,6 +23,7 @@ import { ZoneNode } from './nodes/zone' export const AnyNode = z.discriminatedUnion('type', [ SiteNode, BuildingNode, + ElevatorNode, LevelNode, ColumnNode, WallNode, diff --git a/packages/core/src/store/use-interactive.ts b/packages/core/src/store/use-interactive.ts index fa72904e8..22997afc0 100644 --- a/packages/core/src/store/use-interactive.ts +++ b/packages/core/src/store/use-interactive.ts @@ -39,12 +39,25 @@ export type WindowAnimationState = { persist: boolean } +export type ElevatorPhase = 'idle' | 'closing' | 'moving' | 'opening' | 'open' + +export type ElevatorInteractiveState = { + currentLevelId: AnyNodeId | null + targetLevelId: AnyNodeId | null + carY: number + doorOpen: number + phase: ElevatorPhase + phaseStartedAt: number | null + queue: AnyNodeId[] +} + type InteractiveStore = { items: Record doors: Record doorAnimations: Record windows: Record windowAnimations: Record + elevators: Record /** Initialize a node's interactive state from its asset definition (idempotent) */ initItem: (itemId: AnyNodeId, interactive: Interactive) => void @@ -78,6 +91,15 @@ type InteractiveStore = { /** Cancel a queued window animation */ cancelWindowAnimation: (windowId: AnyNodeId) => void + + /** Initialize an elevator's runtime state from its default served level. */ + initElevator: (elevatorId: AnyNodeId, levelId: AnyNodeId, carY: number) => void + + /** Merge runtime elevator state. */ + setElevatorState: (elevatorId: AnyNodeId, value: Partial) => void + + /** Remove elevator runtime state when its renderer unmounts. */ + removeElevator: (elevatorId: AnyNodeId) => void } const defaultControlValue = (interactive: Interactive, index: number): ControlValue => { @@ -99,6 +121,7 @@ export const useInteractive = create((set, get) => ({ doorAnimations: {}, windows: {}, windowAnimations: {}, + elevators: {}, initItem: (itemId, interactive) => { const { controls } = interactive @@ -203,4 +226,47 @@ export const useInteractive = create((set, get) => ({ return { windowAnimations: rest } }) }, + + initElevator: (elevatorId, levelId, carY) => { + if (get().elevators[elevatorId]) return + + set((state) => ({ + elevators: { + ...state.elevators, + [elevatorId]: { + currentLevelId: levelId, + targetLevelId: null, + carY, + doorOpen: 0, + phase: 'idle', + phaseStartedAt: null, + queue: [], + }, + }, + })) + }, + + setElevatorState: (elevatorId, value) => { + set((state) => { + const current = state.elevators[elevatorId] + if (!current) return state + + return { + elevators: { + ...state.elevators, + [elevatorId]: { + ...current, + ...value, + }, + }, + } + }) + }, + + removeElevator: (elevatorId) => { + set((state) => { + const { [elevatorId]: _, ...rest } = state.elevators + return { elevators: rest } + }) + }, })) diff --git a/packages/core/src/store/use-live-node-overrides.ts b/packages/core/src/store/use-live-node-overrides.ts new file mode 100644 index 000000000..04f952727 --- /dev/null +++ b/packages/core/src/store/use-live-node-overrides.ts @@ -0,0 +1,31 @@ +import { create } from 'zustand' + +export type LiveNodeOverrides = Record + +type LiveNodeOverrideState = { + overrides: Map + set(nodeId: string, values: LiveNodeOverrides): void + get(nodeId: string): LiveNodeOverrides | undefined + clear(nodeId: string): void + clearAll(): void +} + +const useLiveNodeOverrides = create((set, get) => ({ + overrides: new Map(), + set: (nodeId, values) => + set((state) => { + const next = new Map(state.overrides) + next.set(nodeId, { ...(next.get(nodeId) ?? {}), ...values }) + return { overrides: next } + }), + get: (nodeId) => get().overrides.get(nodeId), + clear: (nodeId) => + set((state) => { + const next = new Map(state.overrides) + next.delete(nodeId) + return { overrides: next } + }), + clearAll: () => set({ overrides: new Map() }), +})) + +export default useLiveNodeOverrides diff --git a/packages/core/src/systems/elevator/elevator-dispatch.ts b/packages/core/src/systems/elevator/elevator-dispatch.ts new file mode 100644 index 000000000..e3f0f46d4 --- /dev/null +++ b/packages/core/src/systems/elevator/elevator-dispatch.ts @@ -0,0 +1,90 @@ +import type { AnyNode, AnyNodeId, ElevatorNode } from '../../schema' +import type { ElevatorInteractiveState } from '../../store/use-interactive' +import { resolveElevatorServiceLevels } from './elevator-service' + +type ElevatorRuntimeMap = Record + +type ResolveElevatorDispatchTargetArgs = { + elevators: ElevatorRuntimeMap + levelId: AnyNodeId + nodes: Record + requestedElevatorId: AnyNodeId +} + +function getRuntimeLevelId(runtime: ElevatorInteractiveState | undefined, elevator: ElevatorNode) { + return runtime?.currentLevelId ?? elevator.defaultLevelId ?? elevator.fromLevelId ?? null +} + +function scoreDispatchCandidate({ + elevator, + elevators, + levelId, + nodes, +}: { + elevator: ElevatorNode + elevators: ElevatorRuntimeMap + levelId: AnyNodeId + nodes: Record +}) { + if ((elevator.disabledLevelIds ?? []).includes(levelId)) return null + if ((elevator.serviceOnlyLevelIds ?? []).includes(levelId)) return null + + const serviceLevels = resolveElevatorServiceLevels(elevator, nodes) + const targetIndex = serviceLevels.findIndex((level) => level.id === levelId) + if (targetIndex < 0) return null + + const runtime = elevators[elevator.id as AnyNodeId] + const runtimeLevelId = getRuntimeLevelId(runtime, elevator) + const currentIndex = runtimeLevelId + ? serviceLevels.findIndex((level) => level.id === runtimeLevelId) + : -1 + const resolvedCurrentIndex = currentIndex >= 0 ? currentIndex : 0 + const distance = Math.abs(targetIndex - resolvedCurrentIndex) + const queuePenalty = (runtime?.queue.length ?? 0) * 2 + (runtime?.targetLevelId ? 1 : 0) + const motionPenalty = runtime?.phase === 'moving' || runtime?.phase === 'closing' ? 0.5 : 0 + const openPenalty = runtime?.phase === 'open' || runtime?.phase === 'opening' ? 0.2 : 0 + + return distance + queuePenalty + motionPenalty + openPenalty +} + +export function resolveElevatorDispatchTarget({ + elevators, + levelId, + nodes, + requestedElevatorId, +}: ResolveElevatorDispatchTargetArgs): AnyNodeId { + const requestedElevator = nodes[requestedElevatorId] + if (!(requestedElevator?.type === 'elevator' && requestedElevator.parentId)) { + return requestedElevatorId + } + + const building = nodes[requestedElevator.parentId as AnyNodeId] + if (building?.type !== 'building') { + return requestedElevatorId + } + + let bestElevatorId: AnyNodeId | null = null + let bestScore = Number.POSITIVE_INFINITY + + for (const childId of building.children) { + const candidate = nodes[childId as AnyNodeId] + if (!(candidate?.type === 'elevator' && candidate.visible !== false)) continue + + const score = scoreDispatchCandidate({ + elevator: candidate, + elevators, + levelId, + nodes, + }) + if (score === null) continue + + const tieBreaker = candidate.id === requestedElevatorId ? -0.01 : 0 + const finalScore = score + tieBreaker + if (finalScore < bestScore) { + bestScore = finalScore + bestElevatorId = candidate.id as AnyNodeId + } + } + + return bestElevatorId ?? requestedElevatorId +} diff --git a/packages/core/src/systems/elevator/elevator-geometry.ts b/packages/core/src/systems/elevator/elevator-geometry.ts new file mode 100644 index 000000000..c08b296bf --- /dev/null +++ b/packages/core/src/systems/elevator/elevator-geometry.ts @@ -0,0 +1,92 @@ +import type { + ElevatorDoorPanelStyle, + ElevatorDoorStyle, + ElevatorNode, + ElevatorShaftStyle, +} from '../../schema' + +export type ElevatorDoorSide = 'left' | 'right' + +const DEFAULT_ELEVATOR_SHAFT_WALL_THICKNESS = 0.09 + +export function getResolvedElevatorDoorStyle( + doorStyle: ElevatorNode['doorStyle'] | undefined, +): ElevatorDoorStyle { + return doorStyle ?? 'center-opening' +} + +export function getResolvedElevatorDoorPanelStyle( + doorPanelStyle: ElevatorNode['doorPanelStyle'] | undefined, +): ElevatorDoorPanelStyle { + return doorPanelStyle ?? 'glass-frame' +} + +export function getResolvedElevatorShaftStyle( + shaftStyle: ElevatorNode['shaftStyle'] | undefined, +): ElevatorShaftStyle { + return shaftStyle ?? 'solid' +} + +export function getElevatorDoorLeafSides( + doorStyle: ElevatorNode['doorStyle'] | undefined, +): ElevatorDoorSide[] { + const resolvedDoorStyle = getResolvedElevatorDoorStyle(doorStyle) + if (resolvedDoorStyle === 'single-left') return ['left'] + if (resolvedDoorStyle === 'single-right') return ['right'] + return ['left', 'right'] +} + +export function getElevatorDoorLeafX( + side: ElevatorDoorSide, + openingWidth: number, + doorOpen: number, + doorStyle: ElevatorNode['doorStyle'] | undefined, +) { + const resolvedDoorStyle = getResolvedElevatorDoorStyle(doorStyle) + if (resolvedDoorStyle === 'center-opening') { + const direction = side === 'left' ? -1 : 1 + return direction * (openingWidth / 4 + doorOpen * openingWidth * 0.34) + } + + const direction = resolvedDoorStyle === 'single-left' ? -1 : 1 + return direction * doorOpen * openingWidth * 0.68 +} + +export function getElevatorDoorLeafWidth( + openingWidth: number, + doorStyle: ElevatorNode['doorStyle'] | undefined, +) { + return getResolvedElevatorDoorStyle(doorStyle) === 'center-opening' + ? Math.max(openingWidth / 2 - 0.018, 0.12) + : Math.max(openingWidth - 0.018, 0.18) +} + +export function getElevatorCabWidth(node: ElevatorNode) { + return Math.max(node.width, 0.8) +} + +export function getElevatorCabDepth(node: ElevatorNode) { + return Math.max(node.depth, 0.8) +} + +export function getElevatorShaftWallThickness(node: ElevatorNode) { + return Math.max(node.shaftWallThickness ?? DEFAULT_ELEVATOR_SHAFT_WALL_THICKNESS, 0.04) +} + +export function getElevatorShaftWidth( + node: ElevatorNode, + cabWidth = getElevatorCabWidth(node), +) { + return Math.max(node.shaftWidth ?? cabWidth, cabWidth, 0.8) +} + +export function getElevatorShaftDepth( + node: ElevatorNode, + cabDepth = getElevatorCabDepth(node), +) { + return Math.max(node.shaftDepth ?? cabDepth, cabDepth, 0.8) +} + +export function getElevatorCabCenterZ(node: ElevatorNode) { + return -getElevatorShaftDepth(node) / 2 + getElevatorCabDepth(node) / 2 +} diff --git a/packages/core/src/systems/elevator/elevator-opening-sync.test.ts b/packages/core/src/systems/elevator/elevator-opening-sync.test.ts new file mode 100644 index 000000000..ca476072f --- /dev/null +++ b/packages/core/src/systems/elevator/elevator-opening-sync.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode } from '../../schema' +import { BuildingNode, CeilingNode, ElevatorNode, LevelNode, SlabNode } from '../../schema' +import { syncAutoElevatorOpenings } from './elevator-opening-sync' + +describe('syncAutoElevatorOpenings', () => { + test('does not add elevator holes when a manual surface hole already covers them', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const elevator = ElevatorNode.parse({ + name: 'Elevator', + parentId: building.id, + position: [2, 0, 1.5], + width: 1.6, + depth: 1.6, + }) + const buildingWithChildren = { + ...building, + children: [ground.id, upper.id, elevator.id], + } + const manualOpening: Array<[number, number]> = [ + [1, 0.5], + [3, 0.5], + [3, 2.5], + [1, 2.5], + ] + const sourceCeiling = CeilingNode.parse({ + name: 'Source Ceiling', + parentId: ground.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [manualOpening], + holeMetadata: [{ source: 'manual' }], + }) + const upperSlab = SlabNode.parse({ + name: 'Upper Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [manualOpening], + holeMetadata: [{ source: 'manual' }], + }) + const nodes = Object.fromEntries( + [buildingWithChildren, ground, upper, elevator, sourceCeiling, upperSlab].map((node) => [ + node.id, + node, + ]), + ) as Record + + const updates = syncAutoElevatorOpenings(nodes) + + expect(updates.find((update) => update.id === upperSlab.id)).toBeUndefined() + expect(updates.find((update) => update.id === sourceCeiling.id)).toBeUndefined() + }) + + test('adds elevator holes when an existing manual hole is too small', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const elevator = ElevatorNode.parse({ + name: 'Elevator', + parentId: building.id, + position: [2, 0, 1.5], + width: 1.6, + depth: 1.6, + }) + const buildingWithChildren = { + ...building, + children: [ground.id, upper.id, elevator.id], + } + const smallManualOpening: Array<[number, number]> = [ + [1.7, 1.2], + [2.3, 1.2], + [2.3, 1.8], + [1.7, 1.8], + ] + const upperSlab = SlabNode.parse({ + name: 'Upper Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [smallManualOpening], + holeMetadata: [{ source: 'manual' }], + }) + const nodes = Object.fromEntries( + [buildingWithChildren, ground, upper, elevator, upperSlab].map((node) => [node.id, node]), + ) as Record + + const updates = syncAutoElevatorOpenings(nodes) + const slabUpdate = updates.find((update) => update.id === upperSlab.id) + + expect(slabUpdate?.data.holes).toHaveLength(2) + expect(slabUpdate?.data.holes?.[0]).toEqual(smallManualOpening) + expect(slabUpdate?.data.holeMetadata).toEqual([ + { source: 'manual' }, + { source: 'elevator', elevatorId: elevator.id }, + ]) + }) + + test('removes stale auto elevator holes when a manual hole overlaps the elevator opening', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const elevator = ElevatorNode.parse({ + name: 'Elevator', + parentId: building.id, + position: [2, 0, 1.5], + width: 1.6, + depth: 1.6, + }) + const buildingWithChildren = { + ...building, + children: [ground.id, upper.id, elevator.id], + } + const manualOpening: Array<[number, number]> = [ + [1, 0.5], + [3, 0.5], + [3, 2.5], + [1, 2.5], + ] + const staleAutoOpening: Array<[number, number]> = [ + [1.12, 0.62], + [2.88, 0.62], + [2.88, 2.38], + [1.12, 2.38], + ] + const upperSlab = SlabNode.parse({ + name: 'Upper Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [manualOpening, staleAutoOpening], + holeMetadata: [{ source: 'manual' }, { source: 'elevator', elevatorId: elevator.id }], + }) + const nodes = Object.fromEntries( + [buildingWithChildren, ground, upper, elevator, upperSlab].map((node) => [node.id, node]), + ) as Record + + const updates = syncAutoElevatorOpenings(nodes) + const slabUpdate = updates.find((update) => update.id === upperSlab.id) + + expect(slabUpdate?.data.holes).toEqual([manualOpening]) + expect(slabUpdate?.data.holeMetadata).toEqual([{ source: 'manual' }]) + }) +}) diff --git a/packages/core/src/systems/elevator/elevator-opening-sync.ts b/packages/core/src/systems/elevator/elevator-opening-sync.ts new file mode 100644 index 000000000..9fe4adf91 --- /dev/null +++ b/packages/core/src/systems/elevator/elevator-opening-sync.ts @@ -0,0 +1,274 @@ +import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' +import type { + AnyNode, + AnyNodeId, + CeilingNode, + ElevatorNode, + SlabNode, + SurfaceHoleMetadata, +} from '../../schema' +import { resolveElevatorServiceLevels } from './elevator-service' + +type Point2D = [number, number] + +const ELEVATOR_OPENING_PADDING = 0.08 +const DEFAULT_ELEVATOR_SHAFT_WALL_THICKNESS = 0.09 + +function pointsEqual(a: Point2D, b: Point2D, tolerance = 1e-5) { + const dx = a[0] - b[0] + const dz = a[1] - b[1] + return dx * dx + dz * dz <= tolerance * tolerance +} + +function polygonsEqual(left: Point2D[][], right: Point2D[][]) { + if (left.length !== right.length) return false + return left.every((polygon, polygonIndex) => { + const other = right[polygonIndex] + if (!(other && polygon.length === other.length)) return false + return polygon.every((point, pointIndex) => { + const otherPoint = other[pointIndex] + if (!otherPoint) return false + return pointsEqual(point, otherPoint) + }) + }) +} + +function metadataEqual(left: SurfaceHoleMetadata[], right: SurfaceHoleMetadata[]) { + if (left.length !== right.length) return false + return left.every( + (entry, index) => + entry.source === right[index]?.source && + (entry.elevatorId ?? null) === (right[index]?.elevatorId ?? null) && + (entry.stairId ?? null) === (right[index]?.stairId ?? null), + ) +} + +function normalizeExistingMetadata( + holes: Point2D[][], + metadata: SurfaceHoleMetadata[] | undefined, +): SurfaceHoleMetadata[] { + return holes.map((_, index) => metadata?.[index] ?? { source: 'manual' }) +} + +function rotateXZ(x: number, z: number, angle: number): [number, number] { + const cos = Math.cos(angle) + const sin = Math.sin(angle) + return [x * cos + z * sin, -x * sin + z * cos] +} + +function pointOnSegment(point: Point2D, a: Point2D, b: Point2D, tolerance = 1e-6) { + const cross = (point[1] - a[1]) * (b[0] - a[0]) - (point[0] - a[0]) * (b[1] - a[1]) + if (Math.abs(cross) > tolerance) return false + const dot = (point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1]) + if (dot < -tolerance) return false + const lenSq = (b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2 + return dot <= lenSq + tolerance +} + +function pointInPolygon(point: Point2D, polygon: Point2D[]) { + if (polygon.length < 3) return false + let inside = false + const [x, z] = point + + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const a = polygon[i]! + const b = polygon[j]! + if (pointOnSegment(point, a, b)) return true + const intersects = + a[1] > z !== b[1] > z && x < ((b[0] - a[0]) * (z - a[1])) / (b[1] - a[1]) + a[0] + if (intersects) inside = !inside + } + + return inside +} + +function polygonContainsPolygon(outer: Point2D[], inner: Point2D[]) { + return inner.every((point) => pointInPolygon(point, outer)) +} + +function isCoveredByExistingHole(existingHoles: Point2D[][], autoHole: Point2D[]) { + return existingHoles.some((existingHole) => polygonContainsPolygon(existingHole, autoHole)) +} + +function getServedLevelRange(elevator: ElevatorNode, nodes: Record) { + const servedLevels = resolveElevatorServiceLevels(elevator, nodes) + if (servedLevels.length < 2) return null + + const levelNumbers = servedLevels.map((level) => level.level) + return { + max: Math.max(...levelNumbers), + min: Math.min(...levelNumbers), + sortedServedLevels: servedLevels, + } +} + +function getLevelNumber(levelId: string | null, nodes: Record) { + if (!levelId) return undefined + const node = nodes[levelId as AnyNodeId] + return node?.type === 'level' ? node.level : undefined +} + +function shouldApplyElevatorToSlab( + elevator: ElevatorNode, + slabLevelId: string, + nodes: Record, +) { + const range = getServedLevelRange(elevator, nodes) + if (!range) return false + + const slabLevel = getLevelNumber(slabLevelId, nodes) + if (slabLevel !== undefined) { + return slabLevel > range.min && slabLevel <= range.max + } + + const servedIndex = range.sortedServedLevels.findIndex((level) => level.id === slabLevelId) + return servedIndex > 0 +} + +function shouldApplyElevatorToCeiling( + elevator: ElevatorNode, + ceilingLevelId: string, + nodes: Record, +) { + const range = getServedLevelRange(elevator, nodes) + if (!range) return false + + const ceilingLevel = getLevelNumber(ceilingLevelId, nodes) + if (ceilingLevel !== undefined) { + return ceilingLevel >= range.min && ceilingLevel < range.max + } + + const servedIndex = range.sortedServedLevels.findIndex((level) => level.id === ceilingLevelId) + return servedIndex >= 0 && servedIndex < range.sortedServedLevels.length - 1 +} + +function getElevatorOpeningPolygon(elevator: ElevatorNode): Point2D[] { + const wallThickness = Math.max( + elevator.shaftWallThickness ?? DEFAULT_ELEVATOR_SHAFT_WALL_THICKNESS, + 0.04, + ) + const shaftWidth = Math.max(elevator.shaftWidth ?? elevator.width, elevator.width, 0.8) + const shaftDepth = Math.max(elevator.shaftDepth ?? elevator.depth, elevator.depth, 0.8) + const halfWidth = shaftWidth / 2 + wallThickness + ELEVATOR_OPENING_PADDING + const halfDepth = shaftDepth / 2 + wallThickness + ELEVATOR_OPENING_PADDING + const corners: Point2D[] = [ + [-halfWidth, -halfDepth], + [halfWidth, -halfDepth], + [halfWidth, halfDepth], + [-halfWidth, halfDepth], + ] + + return corners.map(([x, z]) => { + const [rotatedX, rotatedZ] = rotateXZ(x, z, elevator.rotation ?? 0) + return [elevator.position[0] + rotatedX, elevator.position[2] + rotatedZ] + }) +} + +export function syncAutoElevatorOpenings(nodes: Record) { + const elevators = Object.values(nodes).filter( + (node): node is ElevatorNode => node.type === 'elevator' && node.visible !== false, + ) + const slabs = Object.values(nodes).filter((node): node is SlabNode => node.type === 'slab') + const ceilings = Object.values(nodes).filter( + (node): node is CeilingNode => node.type === 'ceiling', + ) + const updates: Array<{ id: AnyNodeId; data: Partial }> = [] + + for (const slab of slabs) { + const slabLevelId = resolveLevelId(slab, nodes) + const existingHoles = slab.holes ?? [] + const existingMetadata = normalizeExistingMetadata(existingHoles, slab.holeMetadata) + const preservedHoles = existingHoles + .map((polygon, index) => ({ metadata: existingMetadata[index]!, polygon })) + .filter((entry) => entry.metadata.source !== 'elevator') + const manualHoles = preservedHoles.filter((entry) => entry.metadata.source !== 'stair') + const stairHoles = preservedHoles.filter((entry) => entry.metadata.source === 'stair') + const preservedHolePolygons = preservedHoles.map((entry) => entry.polygon) + + const elevatorHoles = elevators + .filter((elevator) => shouldApplyElevatorToSlab(elevator, slabLevelId, nodes)) + .map((elevator) => ({ + polygon: getElevatorOpeningPolygon(elevator), + metadata: { + elevatorId: elevator.id, + source: 'elevator' as const, + }, + })) + .filter((hole) => polygonContainsPolygon(slab.polygon, hole.polygon)) + .filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon)) + + const nextHoles = [ + ...manualHoles.map((hole) => hole.polygon), + ...elevatorHoles.map((hole) => hole.polygon), + ...stairHoles.map((hole) => hole.polygon), + ] + const nextMetadata = [ + ...manualHoles.map((hole) => ({ ...hole.metadata })), + ...elevatorHoles.map((hole) => hole.metadata), + ...stairHoles.map((hole) => ({ ...hole.metadata })), + ] + + if ( + !polygonsEqual(existingHoles, nextHoles) || + !metadataEqual(existingMetadata, nextMetadata) + ) { + updates.push({ + id: slab.id, + data: { + holes: nextHoles, + holeMetadata: nextMetadata, + }, + }) + } + } + + for (const ceiling of ceilings) { + const ceilingLevelId = resolveLevelId(ceiling, nodes) + const existingHoles = ceiling.holes ?? [] + const existingMetadata = normalizeExistingMetadata(existingHoles, ceiling.holeMetadata) + const preservedHoles = existingHoles + .map((polygon, index) => ({ metadata: existingMetadata[index]!, polygon })) + .filter((entry) => entry.metadata.source !== 'elevator') + const manualHoles = preservedHoles.filter((entry) => entry.metadata.source !== 'stair') + const stairHoles = preservedHoles.filter((entry) => entry.metadata.source === 'stair') + const preservedHolePolygons = preservedHoles.map((entry) => entry.polygon) + + const elevatorHoles = elevators + .filter((elevator) => shouldApplyElevatorToCeiling(elevator, ceilingLevelId, nodes)) + .map((elevator) => ({ + polygon: getElevatorOpeningPolygon(elevator), + metadata: { + elevatorId: elevator.id, + source: 'elevator' as const, + }, + })) + .filter((hole) => polygonContainsPolygon(ceiling.polygon, hole.polygon)) + .filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon)) + + const nextHoles = [ + ...manualHoles.map((hole) => hole.polygon), + ...elevatorHoles.map((hole) => hole.polygon), + ...stairHoles.map((hole) => hole.polygon), + ] + const nextMetadata = [ + ...manualHoles.map((hole) => ({ ...hole.metadata })), + ...elevatorHoles.map((hole) => hole.metadata), + ...stairHoles.map((hole) => ({ ...hole.metadata })), + ] + + if ( + !polygonsEqual(existingHoles, nextHoles) || + !metadataEqual(existingMetadata, nextMetadata) + ) { + updates.push({ + id: ceiling.id, + data: { + holes: nextHoles, + holeMetadata: nextMetadata, + }, + }) + } + } + + return updates +} diff --git a/packages/core/src/systems/elevator/elevator-opening-system.tsx b/packages/core/src/systems/elevator/elevator-opening-system.tsx new file mode 100644 index 000000000..5f8c6344b --- /dev/null +++ b/packages/core/src/systems/elevator/elevator-opening-system.tsx @@ -0,0 +1,56 @@ +import { useEffect, useRef } from 'react' +import type { AnyNode } from '../../schema' +import useScene from '../../store/use-scene' +import { syncAutoElevatorOpenings } from './elevator-opening-sync' + +function isOpeningRelevantNode(node: AnyNode | undefined) { + return ( + node?.type === 'building' || + node?.type === 'ceiling' || + node?.type === 'elevator' || + node?.type === 'level' || + node?.type === 'slab' + ) +} + +function hasOpeningRelevantNodeChange( + nextNodes: Record, + prevNodes: Record, +) { + if (nextNodes === prevNodes) return false + + const ids = new Set([...Object.keys(nextNodes), ...Object.keys(prevNodes)]) + for (const id of ids) { + const nextNode = nextNodes[id] + const prevNode = prevNodes[id] + if (nextNode === prevNode) continue + if (isOpeningRelevantNode(nextNode) || isOpeningRelevantNode(prevNode)) return true + } + + return false +} + +export const ElevatorOpeningSystem = () => { + const syncingAutoOpeningsRef = useRef(false) + + useEffect(() => { + const applyUpdates = (updates: ReturnType) => { + if (updates.length === 0) return + syncingAutoOpeningsRef.current = true + useScene.getState().updateNodes(updates) + queueMicrotask(() => { + syncingAutoOpeningsRef.current = false + }) + } + + applyUpdates(syncAutoElevatorOpenings(useScene.getState().nodes)) + + return useScene.subscribe((state, prevState) => { + if (syncingAutoOpeningsRef.current) return + if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return + applyUpdates(syncAutoElevatorOpenings(state.nodes)) + }) + }, []) + + return null +} diff --git a/packages/core/src/systems/elevator/elevator-runtime-system.tsx b/packages/core/src/systems/elevator/elevator-runtime-system.tsx new file mode 100644 index 000000000..91c897a23 --- /dev/null +++ b/packages/core/src/systems/elevator/elevator-runtime-system.tsx @@ -0,0 +1,10 @@ +import { useFrame } from '@react-three/fiber' +import { stepElevatorRuntimes } from './elevator-runtime' + +export function ElevatorRuntimeSystem() { + useFrame(({ clock }, delta) => { + stepElevatorRuntimes(clock.getElapsedTime() * 1000, delta) + }, 2) + + return null +} diff --git a/packages/core/src/systems/elevator/elevator-runtime.test.ts b/packages/core/src/systems/elevator/elevator-runtime.test.ts new file mode 100644 index 000000000..959dd681a --- /dev/null +++ b/packages/core/src/systems/elevator/elevator-runtime.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNodeId } from '../../schema' +import { ElevatorNode } from '../../schema' +import { + createElevatorInteractiveState, + openElevatorDoorState, + queueElevatorRequest, + stepElevatorRuntimeState, +} from './elevator-runtime' +import type { ElevatorLevelEntry } from './elevator-service' + +const groundLevelId = 'level_ground' as AnyNodeId +const upperLevelId = 'level_upper' as AnyNodeId + +const entries: ElevatorLevelEntry[] = [ + { id: groundLevelId as ElevatorLevelEntry['id'], label: '0', baseY: 0 }, + { id: upperLevelId as ElevatorLevelEntry['id'], label: '1', baseY: 2.5 }, +] + +const elevator = ElevatorNode.parse({ + speed: 10, + doorDurationMs: 100, + dwellMs: 0, +}) + +describe('elevator runtime helpers', () => { + test('queues level requests without duplicating the target', () => { + const state = createElevatorInteractiveState(groundLevelId, 0) + const queued = queueElevatorRequest(state, upperLevelId) + const duplicated = queueElevatorRequest(queued, upperLevelId) + + expect(queued.queue).toEqual([upperLevelId]) + expect(duplicated.queue).toEqual([upperLevelId]) + }) + + test('opens doors only when the elevator is not moving', () => { + const idle = createElevatorInteractiveState(groundLevelId, 0) + const moving = { ...idle, phase: 'moving' as const } + + expect(openElevatorDoorState(idle).phase).toBe('opening') + expect(openElevatorDoorState(moving)).toBe(moving) + }) + + test('moves to a queued level and clears the served request on arrival', () => { + const queued = queueElevatorRequest(createElevatorInteractiveState(groundLevelId, 0), upperLevelId) + const moving = stepElevatorRuntimeState({ + defaultEntry: entries[0]!, + delta: 0.016, + elevator, + entries, + now: 0, + state: queued, + }) + + const arrived = stepElevatorRuntimeState({ + defaultEntry: entries[0]!, + delta: 1, + elevator, + entries, + now: 100, + state: moving, + }) + + const open = stepElevatorRuntimeState({ + defaultEntry: entries[0]!, + delta: 1, + elevator, + entries, + now: 200, + state: arrived, + }) + + expect(moving.phase).toBe('moving') + expect(arrived.currentLevelId).toBe(upperLevelId) + expect(arrived.phase).toBe('opening') + expect(open.phase).toBe('open') + expect(open.queue).toEqual([]) + }) +}) diff --git a/packages/core/src/systems/elevator/elevator-runtime.ts b/packages/core/src/systems/elevator/elevator-runtime.ts new file mode 100644 index 000000000..c75a30bc9 --- /dev/null +++ b/packages/core/src/systems/elevator/elevator-runtime.ts @@ -0,0 +1,265 @@ +import type { AnyNode, AnyNodeId, ElevatorNode } from '../../schema' +import { type ElevatorInteractiveState, useInteractive } from '../../store/use-interactive' +import useScene from '../../store/use-scene' +import { resolveElevatorLevels, type ElevatorLevelEntry } from './elevator-service' + +const EPSILON = 0.001 + +function moveToward(current: number, target: number, maxDelta: number) { + const delta = target - current + if (Math.abs(delta) <= maxDelta) return target + return current + Math.sign(delta) * maxDelta +} + +export function createElevatorInteractiveState( + levelId: AnyNodeId, + carY: number, +): ElevatorInteractiveState { + return { + currentLevelId: levelId, + targetLevelId: null, + carY, + doorOpen: 0, + phase: 'idle', + phaseStartedAt: null, + queue: [], + } +} + +function getInitialElevatorState( + elevatorId: AnyNodeId, + nodes: Record, +): ElevatorInteractiveState | null { + const node = nodes[elevatorId] + if (node?.type !== 'elevator') return null + + const { defaultEntry } = resolveElevatorLevels(node, nodes) + if (!defaultEntry) return null + + return createElevatorInteractiveState(defaultEntry.id as AnyNodeId, defaultEntry.baseY) +} + +function ensureElevatorState( + elevatorId: AnyNodeId, + nodes: Record, +): ElevatorInteractiveState | null { + const interactive = useInteractive.getState() + const existing = interactive.elevators[elevatorId] + if (existing) return existing + + const initial = getInitialElevatorState(elevatorId, nodes) + if (!initial) return null + + interactive.initElevator(elevatorId, initial.currentLevelId as AnyNodeId, initial.carY) + return initial +} + +export function queueElevatorRequest( + state: ElevatorInteractiveState, + levelId: AnyNodeId, +): ElevatorInteractiveState { + const isAlreadyQueued = state.queue.includes(levelId) || state.targetLevelId === levelId + if (isAlreadyQueued) return state + + return { + ...state, + queue: [...state.queue, levelId], + } +} + +export function openElevatorDoorState( + state: ElevatorInteractiveState, +): ElevatorInteractiveState { + if (!state.currentLevelId || state.phase === 'moving') return state + + return { + ...state, + phase: 'opening', + phaseStartedAt: null, + } +} + +export function requestElevatorLevel(elevatorId: AnyNodeId, levelId: AnyNodeId) { + const nodes = useScene.getState().nodes + const current = ensureElevatorState(elevatorId, nodes) + if (!current) return + + const next = queueElevatorRequest(current, levelId) + if (next === current) return + + useInteractive.getState().setElevatorState(elevatorId, next) +} + +export function openElevatorDoor(elevatorId: AnyNodeId) { + const nodes = useScene.getState().nodes + const current = ensureElevatorState(elevatorId, nodes) + if (!current) return + + const next = openElevatorDoorState(current) + if (next === current) return + + useInteractive.getState().setElevatorState(elevatorId, next) +} + +export function stepElevatorRuntimeState({ + defaultEntry, + delta, + elevator, + entries, + now, + state, +}: { + defaultEntry: ElevatorLevelEntry + delta: number + elevator: ElevatorNode + entries: ElevatorLevelEntry[] + now: number + state: ElevatorInteractiveState +}): ElevatorInteractiveState { + const currentEntry = entries.find((entry) => entry.id === state.currentLevelId) ?? defaultEntry + if (currentEntry.id !== state.currentLevelId) { + return { + ...state, + currentLevelId: currentEntry.id as AnyNodeId, + carY: currentEntry.baseY, + targetLevelId: null, + phase: 'idle', + phaseStartedAt: null, + queue: [], + doorOpen: 0, + } + } + + const targetEntry = state.targetLevelId + ? entries.find((entry) => entry.id === state.targetLevelId) + : state.queue[0] + ? entries.find((entry) => entry.id === state.queue[0]) + : null + + const doorDurationMs = Math.max(elevator.doorDurationMs ?? 900, 1) + const doorStep = (delta * 1000) / doorDurationMs + + switch (state.phase) { + case 'idle': { + const nextLevelId = state.queue[0] ?? null + if (!nextLevelId) { + if (state.doorOpen > EPSILON) { + return { + ...state, + doorOpen: Math.max(0, state.doorOpen - doorStep), + } + } + return state + } + + return { + ...state, + targetLevelId: nextLevelId, + phase: + state.doorOpen > EPSILON + ? 'closing' + : nextLevelId === state.currentLevelId + ? 'opening' + : 'moving', + phaseStartedAt: now, + } + } + + case 'closing': { + const doorOpen = Math.max(0, state.doorOpen - doorStep) + return { + ...state, + doorOpen, + phase: doorOpen <= EPSILON ? (state.targetLevelId ? 'moving' : 'idle') : 'closing', + phaseStartedAt: doorOpen <= EPSILON ? now : state.phaseStartedAt, + } + } + + case 'moving': { + if (!targetEntry) { + return { + ...state, + targetLevelId: null, + phase: 'idle', + queue: [], + } + } + + const speed = Math.max(elevator.speed ?? 2.2, 0.1) + const nextY = moveToward(state.carY, targetEntry.baseY, speed * delta) + const arrived = Math.abs(nextY - targetEntry.baseY) <= EPSILON + return { + ...state, + carY: nextY, + currentLevelId: arrived ? (targetEntry.id as AnyNodeId) : state.currentLevelId, + phase: arrived ? 'opening' : 'moving', + phaseStartedAt: arrived ? now : state.phaseStartedAt, + } + } + + case 'opening': { + const doorOpen = Math.min(1, state.doorOpen + doorStep) + return { + ...state, + doorOpen, + phase: doorOpen >= 1 - EPSILON ? 'open' : 'opening', + phaseStartedAt: doorOpen >= 1 - EPSILON ? now : state.phaseStartedAt, + targetLevelId: doorOpen >= 1 - EPSILON ? null : state.targetLevelId, + queue: + doorOpen >= 1 - EPSILON && state.queue[0] === state.currentLevelId + ? state.queue.slice(1) + : state.queue, + } + } + + case 'open': { + const elapsed = now - (state.phaseStartedAt ?? now) + if (elapsed < Math.max(elevator.dwellMs ?? 1400, 0)) return state + + return { + ...state, + phase: 'closing', + phaseStartedAt: now, + targetLevelId: state.queue[0] ?? null, + } + } + } +} + +export function stepElevatorRuntimes(now: number, delta: number) { + const nodes = useScene.getState().nodes + const interactive = useInteractive.getState() + + for (const elevatorId of Object.keys(interactive.elevators)) { + const typedElevatorId = elevatorId as AnyNodeId + if (nodes[typedElevatorId]?.type !== 'elevator') { + interactive.removeElevator(typedElevatorId) + } + } + + for (const node of Object.values(nodes)) { + if (node.type !== 'elevator') continue + + const elevatorId = node.id as AnyNodeId + const { entries, defaultEntry } = resolveElevatorLevels(node, nodes) + if (!defaultEntry) continue + + const state = useInteractive.getState().elevators[elevatorId] + if (!state) { + useInteractive.getState().initElevator(elevatorId, defaultEntry.id as AnyNodeId, defaultEntry.baseY) + continue + } + + const next = stepElevatorRuntimeState({ + defaultEntry, + delta, + elevator: node, + entries, + now, + state, + }) + if (next !== state) { + useInteractive.getState().setElevatorState(elevatorId, next) + } + } +} diff --git a/packages/core/src/systems/elevator/elevator-service.ts b/packages/core/src/systems/elevator/elevator-service.ts new file mode 100644 index 000000000..59798da5d --- /dev/null +++ b/packages/core/src/systems/elevator/elevator-service.ts @@ -0,0 +1,150 @@ +import type { AnyNode, AnyNodeId, CeilingNode, ElevatorNode, LevelNode, WallNode } from '../../schema' + +export const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5 + +export type ElevatorLevelEntry = { + id: LevelNode['id'] + label: string + baseY: number +} + +function getBuildingLevels(elevator: ElevatorNode, nodes: Record): LevelNode[] { + const building = + elevator.parentId && nodes[elevator.parentId as AnyNodeId]?.type === 'building' + ? nodes[elevator.parentId as AnyNodeId] + : null + + if (building?.type !== 'building') return [] + + return building.children + .map((childId) => nodes[childId as AnyNodeId]) + .filter((node): node is LevelNode => node?.type === 'level') + .sort((left, right) => left.level - right.level) +} + +function findLevelIndex(levels: LevelNode[], levelId: string | null | undefined) { + if (!levelId) return -1 + return levels.findIndex((level) => level.id === levelId) +} + +function getDefaultToIndex(levels: LevelNode[], fromIndex: number) { + if (levels.length === 0) return -1 + if (fromIndex < 0) return Math.min(1, levels.length - 1) + return Math.min(fromIndex + 1, levels.length - 1) +} + +export function resolveElevatorBuildingLevels( + elevator: ElevatorNode, + nodes: Record, +): LevelNode[] { + return getBuildingLevels(elevator, nodes) +} + +export function resolveElevatorServiceLevelIds( + elevator: ElevatorNode, + nodes: Record, +): string[] { + return resolveElevatorServiceLevels(elevator, nodes).map((level) => level.id) +} + +export function resolveElevatorServiceLevels( + elevator: ElevatorNode, + nodes: Record, +): LevelNode[] { + const levels = getBuildingLevels(elevator, nodes) + if (levels.length === 0) return [] + + const hasServiceBounds = Boolean(elevator.fromLevelId || elevator.toLevelId) + let legacyServedLevels: LevelNode[] = [] + if (!hasServiceBounds && elevator.servedLevelIds && elevator.servedLevelIds.length > 0) { + const servedIds = new Set(elevator.servedLevelIds) + legacyServedLevels = levels.filter((level) => servedIds.has(level.id)) + } + + const legacyFromLevelId = legacyServedLevels[0]?.id ?? null + const legacyToLevelId = legacyServedLevels[legacyServedLevels.length - 1]?.id ?? null + const explicitFromIndex = findLevelIndex(levels, elevator.fromLevelId ?? legacyFromLevelId) + const defaultFromIndex = findLevelIndex(levels, elevator.defaultLevelId) + const fromIndex = explicitFromIndex >= 0 ? explicitFromIndex : Math.max(defaultFromIndex, 0) + const toIndex = findLevelIndex(levels, elevator.toLevelId ?? legacyToLevelId) + const resolvedToIndex = toIndex >= 0 ? toIndex : getDefaultToIndex(levels, fromIndex) + const minIndex = Math.min(fromIndex, resolvedToIndex) + const maxIndex = Math.max(fromIndex, resolvedToIndex) + + return levels.slice(minIndex, maxIndex + 1) +} + +export function getElevatorLevelHeight(levelId: string, nodes: Record): number { + const level = nodes[levelId as AnyNodeId] as LevelNode | undefined + if (!level || 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') { + const height = (child as CeilingNode).height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT + if (height > maxTop) maxTop = height + } else if (child.type === 'wall') { + const height = (child as WallNode).height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT + if (height > maxTop) maxTop = height + } + } + + return maxTop > 0 ? maxTop : DEFAULT_ELEVATOR_LEVEL_HEIGHT +} + +export function resolveElevatorLevels( + elevator: ElevatorNode, + nodes: Record, +): { + entries: ElevatorLevelEntry[] + defaultEntry: ElevatorLevelEntry | null + shaftBaseY: number + shaftTopY: number + totalHeight: number +} { + const allLevels = resolveElevatorBuildingLevels(elevator, nodes) + + const baseYByLevelId = new Map() + let cumulativeY = 0 + for (const level of allLevels) { + baseYByLevelId.set(level.id, cumulativeY) + cumulativeY += getElevatorLevelHeight(level.id, nodes) + } + + const serviceLevels = resolveElevatorServiceLevels(elevator, nodes) + const entries = serviceLevels.map((level) => ({ + id: level.id, + label: String(level.level), + baseY: baseYByLevelId.get(level.id) ?? 0, + })) + + const defaultEntry = + entries.find((entry) => entry.id === elevator.defaultLevelId) ?? + entries.find((entry) => entry.id === elevator.fromLevelId) ?? + entries[0] ?? + null + 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, + defaultEntry, + shaftBaseY, + shaftTopY, + totalHeight: Math.max(shaftTopY - shaftBaseY, elevator.cabHeight + 0.3), + } +} diff --git a/packages/core/src/systems/stair/stair-opening-sync.test.ts b/packages/core/src/systems/stair/stair-opening-sync.test.ts index 6d96b272f..f3a194f63 100644 --- a/packages/core/src/systems/stair/stair-opening-sync.test.ts +++ b/packages/core/src/systems/stair/stair-opening-sync.test.ts @@ -1,6 +1,13 @@ import { describe, expect, test } from 'bun:test' import type { AnyNode } from '../../schema' -import { BuildingNode, LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema' +import { + BuildingNode, + CeilingNode, + LevelNode, + SlabNode, + StairNode, + StairSegmentNode, +} from '../../schema' import { syncAutoStairOpenings } from './stair-opening-sync' describe('syncAutoStairOpenings', () => { @@ -66,4 +73,190 @@ describe('syncAutoStairOpenings', () => { expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }]) expect(bedroomUpdate).toBeUndefined() }) + + test('does not add stair holes when a manual surface hole already covers them', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const manualOpening: Array<[number, number]> = [ + [1.2, 0.8], + [2.8, 0.8], + [2.8, 2.9], + [1.2, 2.9], + ] + const sourceCeiling = CeilingNode.parse({ + name: 'Source Ceiling', + parentId: ground.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [manualOpening], + holeMetadata: [{ source: 'manual' }], + }) + const landingSlab = SlabNode.parse({ + name: 'Landing Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [manualOpening], + holeMetadata: [{ source: 'manual' }], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_main', + width: 1, + length: 2.6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_main', + name: 'Main Stair', + parentId: ground.id, + position: [2, 0, 0.2], + stairType: 'straight', + fromLevelId: ground.id, + toLevelId: upper.id, + slabOpeningMode: 'destination', + children: [segment.id], + }) + const nodes = Object.fromEntries( + [ + building, + ground, + upper, + sourceCeiling, + landingSlab, + stair, + { ...segment, parentId: stair.id }, + ].map((node) => [node.id, node]), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + + expect(updates.find((update) => update.id === landingSlab.id)).toBeUndefined() + expect(updates.find((update) => update.id === sourceCeiling.id)).toBeUndefined() + }) + + test('adds stair holes when an existing manual hole is too small', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const smallManualOpening: Array<[number, number]> = [ + [1.8, 1.6], + [2.2, 1.6], + [2.2, 2.1], + [1.8, 2.1], + ] + const landingSlab = SlabNode.parse({ + name: 'Landing Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [smallManualOpening], + holeMetadata: [{ source: 'manual' }], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_main', + width: 1, + length: 2.6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_main', + name: 'Main Stair', + parentId: ground.id, + position: [2, 0, 0.2], + stairType: 'straight', + fromLevelId: ground.id, + toLevelId: upper.id, + slabOpeningMode: 'destination', + children: [segment.id], + }) + const nodes = Object.fromEntries( + [building, ground, upper, landingSlab, stair, { ...segment, parentId: stair.id }].map( + (node) => [node.id, node], + ), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + const landingUpdate = updates.find((update) => update.id === landingSlab.id) + + expect(landingUpdate?.data.holes).toHaveLength(2) + expect(landingUpdate?.data.holes?.[0]).toEqual(smallManualOpening) + expect(landingUpdate?.data.holeMetadata).toEqual([ + { source: 'manual' }, + { source: 'stair', stairId: stair.id }, + ]) + }) + + test('removes stale auto stair holes when a manual hole overlaps the stair opening', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const manualOpening: Array<[number, number]> = [ + [1.2, 0.8], + [2.8, 0.8], + [2.8, 2.9], + [1.2, 2.9], + ] + const staleAutoOpening: Array<[number, number]> = [ + [1.5, 1], + [2.5, 1], + [2.5, 2.8], + [1.5, 2.8], + ] + const landingSlab = SlabNode.parse({ + name: 'Landing Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [manualOpening, staleAutoOpening], + holeMetadata: [{ source: 'manual' }, { source: 'stair', stairId: 'stair_main' }], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_main', + width: 1, + length: 2.6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_main', + name: 'Main Stair', + parentId: ground.id, + position: [2, 0, 0.2], + stairType: 'straight', + fromLevelId: ground.id, + toLevelId: upper.id, + slabOpeningMode: 'destination', + children: [segment.id], + }) + const nodes = Object.fromEntries( + [building, ground, upper, landingSlab, stair, { ...segment, parentId: stair.id }].map( + (node) => [node.id, node], + ), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + const landingUpdate = updates.find((update) => update.id === landingSlab.id) + + expect(landingUpdate?.data.holes).toEqual([manualOpening]) + expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'manual' }]) + }) }) diff --git a/packages/core/src/systems/stair/stair-opening-sync.ts b/packages/core/src/systems/stair/stair-opening-sync.ts index 684083f51..6f4c9f7ae 100644 --- a/packages/core/src/systems/stair/stair-opening-sync.ts +++ b/packages/core/src/systems/stair/stair-opening-sync.ts @@ -6,16 +6,12 @@ import type { SlabNode, StairNode, StairSegmentNode, + SurfaceHoleMetadata, } from '../../schema' import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint' type Point2D = [number, number] -type SurfaceHoleMetadata = { - source: 'manual' | 'stair' - stairId?: string -} - type SegmentTransform = { position: [number, number, number] rotation: number @@ -66,6 +62,7 @@ function metadataEqual(left: SurfaceHoleMetadata[], right: SurfaceHoleMetadata[] return left.every( (entry, index) => entry.source === right[index]?.source && + (entry.elevatorId ?? null) === (right[index]?.elevatorId ?? null) && (entry.stairId ?? null) === (right[index]?.stairId ?? null), ) } @@ -313,6 +310,10 @@ function polygonContainsPolygon(outer: Point2D[], inner: Point2D[]) { return inner.every((point) => pointInPolygon(point, outer)) } +function isCoveredByExistingHole(existingHoles: Point2D[][], autoHole: Point2D[]) { + return existingHoles.some((existingHole) => polygonContainsPolygon(existingHole, autoHole)) +} + function getAxisAlignedRectFromPolygon(polygon: Point2D[]): AxisAlignedRect | null { if (polygon.length < 4) return null const xs = polygon.map(([x]) => x) @@ -689,12 +690,10 @@ export function syncAutoStairOpenings(nodes: Record) { const slabLevelId = resolveLevelId(slab, nodes) const existingHoles = slab.holes ?? [] const existingMetadata = normalizeExistingMetadata(existingHoles, slab.holeMetadata) - const manualHoles = existingHoles.filter( - (_hole, index) => existingMetadata[index]?.source !== 'stair', - ) - const manualMetadata = existingMetadata - .filter((entry) => entry.source !== 'stair') - .map((entry) => ({ ...entry })) + const preservedHoles = existingHoles + .map((polygon, index) => ({ metadata: existingMetadata[index]!, polygon })) + .filter((entry) => entry.metadata.source !== 'stair') + const preservedHolePolygons = preservedHoles.map((entry) => entry.polygon) const stairHoles = stairs .filter((stair) => shouldApplyStairToSlab(stair, slabLevelId, nodes)) @@ -718,9 +717,16 @@ export function syncAutoStairOpenings(nodes: Record) { })), ) .filter((hole) => polygonContainsPolygon(slab.polygon, hole.polygon)) + .filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon)) - const nextHoles = [...manualHoles, ...stairHoles.map((hole) => hole.polygon)] - const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)] + const nextHoles = [ + ...preservedHoles.map((hole) => hole.polygon), + ...stairHoles.map((hole) => hole.polygon), + ] + const nextMetadata = [ + ...preservedHoles.map((hole) => ({ ...hole.metadata })), + ...stairHoles.map((hole) => hole.metadata), + ] if ( !(polygonsEqual(existingHoles, nextHoles) && metadataEqual(existingMetadata, nextMetadata)) @@ -739,12 +745,10 @@ export function syncAutoStairOpenings(nodes: Record) { const ceilingLevelId = resolveLevelId(ceiling, nodes) const existingHoles = ceiling.holes ?? [] const existingMetadata = normalizeExistingMetadata(existingHoles, ceiling.holeMetadata) - const manualHoles = existingHoles.filter( - (_hole, index) => existingMetadata[index]?.source !== 'stair', - ) - const manualMetadata = existingMetadata - .filter((entry) => entry.source !== 'stair') - .map((entry) => ({ ...entry })) + const preservedHoles = existingHoles + .map((polygon, index) => ({ metadata: existingMetadata[index]!, polygon })) + .filter((entry) => entry.metadata.source !== 'stair') + const preservedHolePolygons = preservedHoles.map((entry) => entry.polygon) const stairHoles = stairs .filter((stair) => shouldApplyStairToCeiling(stair, ceilingLevelId, nodes)) @@ -768,9 +772,16 @@ export function syncAutoStairOpenings(nodes: Record) { })), ) .filter((hole) => polygonContainsPolygon(ceiling.polygon, hole.polygon)) - - const nextHoles = [...manualHoles, ...stairHoles.map((hole) => hole.polygon)] - const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)] + .filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon)) + + const nextHoles = [ + ...preservedHoles.map((hole) => hole.polygon), + ...stairHoles.map((hole) => hole.polygon), + ] + const nextMetadata = [ + ...preservedHoles.map((hole) => ({ ...hole.metadata })), + ...stairHoles.map((hole) => hole.metadata), + ] if ( !(polygonsEqual(existingHoles, nextHoles) && metadataEqual(existingMetadata, nextMetadata)) diff --git a/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx index 6ad90876b..97e64e755 100644 --- a/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx @@ -21,6 +21,7 @@ export type FloorplanActionMenuEntry = { } type FloorplanActionMenuLayerProps = { + elevator: FloorplanActionMenuEntry item: FloorplanActionMenuEntry wall: FloorplanActionMenuEntry fence: FloorplanActionMenuEntry @@ -34,6 +35,7 @@ type FloorplanActionMenuLayerProps = { } export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({ + elevator, item, wall, fence, @@ -56,6 +58,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({ } const entries: FloorplanActionMenuEntry[] = [ + elevator, item, wall, fence, diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index c2f2fdf1c..2b0074fde 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -1,12 +1,50 @@ 'use client' import '../../three-types' -import { type AnyNodeId, emitter, sceneRegistry, useInteractive, useScene } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + type ElevatorNode, + type ElevatorDoorSide, + emitter, + getElevatorCabCenterZ, + getElevatorCabDepth, + getElevatorCabWidth, + getElevatorDoorLeafSides, + getElevatorDoorLeafWidth, + getElevatorDoorLeafX, + getElevatorShaftDepth, + getElevatorShaftWallThickness, + getElevatorShaftWidth, + getResolvedElevatorDoorStyle, + openElevatorDoor, + resolveElevatorBuildingLevels, + resolveElevatorDispatchTarget, + resolveElevatorServiceLevels, + requestElevatorLevel, + 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 { + Box3, + BoxGeometry, + 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 +72,12 @@ 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_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 +103,421 @@ 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?: ElevatorDoorSide +} + +type ElevatorColliderMesh = Mesh & { + userData: Mesh['userData'] & ElevatorColliderUserData +} + +type FirstPersonInteractableTarget = + | { + id: AnyNodeId + type: 'door' | 'window' + } + | { + action: 'open-door' | 'request-level' + buttonKind: 'cab' | 'landing' + id: AnyNodeId + levelId?: AnyNodeId + type: 'elevator' + } + +type ElevatorButtonTarget = { + action: 'open-door' | 'request-level' + 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?: { + action?: unknown + disabled?: unknown + elevatorId?: unknown + kind?: unknown + levelId?: unknown + } + } + ).elevatorButton + + if (candidate?.disabled === true) { + return null + } + + if (typeof candidate?.elevatorId === 'string' && candidate.kind === 'cab') { + const action = candidate.action === 'open-door' ? 'open-door' : 'request-level' + if (action === 'open-door') { + return { + action, + buttonKind: candidate.kind, + elevatorId: candidate.elevatorId as AnyNodeId, + } + } + } + + if ( + typeof candidate?.elevatorId === 'string' && + typeof candidate.levelId === 'string' && + (candidate.kind === 'cab' || candidate.kind === 'landing') + ) { + return { + action: 'request-level', + 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 isDynamicElevatorCollider(kind: ElevatorColliderKind) { + return kind.startsWith('cab-') || kind.startsWith('landing-door') +} + +function isInsideElevatorCab( + elevator: ElevatorNode, + runtime: NonNullable['elevators'][AnyNodeId]>, + localEyePosition: Vector3, +) { + const halfWidth = getElevatorCabWidth(elevator) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING + const halfDepth = getElevatorCabDepth(elevator) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING + const cabCenterZ = getElevatorCabCenterZ(elevator) + const cabHeight = Math.max(elevator.cabHeight, 1.4) + + return ( + Math.abs(localEyePosition.x) <= Math.max(halfWidth, 0.24) && + Math.abs(localEyePosition.z - cabCenterZ) <= Math.max(halfDepth, 0.24) && + localEyePosition.y >= runtime.carY + 0.35 && + localEyePosition.y <= runtime.carY + cabHeight + 0.7 + ) +} + +function getFirstPersonLevelHeight(levelId: string, nodes: Record) { + 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) { + const allLevels = resolveElevatorBuildingLevels(elevator, nodes) + + const baseYByLevelId = new Map() + 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 = {}, +) { + 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 cabWidth = getElevatorCabWidth(node) + const cabDepth = getElevatorCabDepth(node) + const shaftWidth = getElevatorShaftWidth(node, cabWidth) + const shaftDepth = getElevatorShaftDepth(node, cabDepth) + const cabHeight = Math.max(node.cabHeight, 1.4) + const doorWidth = Math.min(Math.max(node.doorWidth, 0.45), cabWidth - 0.18, shaftWidth - 0.18) + const doorHeight = Math.min(Math.max(node.doorHeight, 1.2), cabHeight - 0.1) + const doorStyle = getResolvedElevatorDoorStyle(node.doorStyle) + const shaftHeight = Math.max(totalHeight, cabHeight + 0.3) + const wallThickness = getElevatorShaftWallThickness(node) + const cabFloorWidth = Math.max(cabWidth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48) + const cabFloorDepth = Math.max(cabDepth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48) + const frontWallZ = -shaftDepth / 2 - wallThickness / 2 + const frontZ = frontWallZ - wallThickness / 2 - 0.018 + const cabCenterZ = -shaftDepth / 2 + cabDepth / 2 + const leafWidth = getElevatorDoorLeafWidth(doorWidth, doorStyle) + const doorLeafSides = getElevatorDoorLeafSides(doorStyle) + 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, cabCenterZ], + ), + createElevatorColliderMesh( + typedElevatorId, + 'cab-ceiling', + [cabWidth, wallThickness, cabDepth], + [0, cabHeight - wallThickness / 2, cabCenterZ], + ), + createElevatorColliderMesh( + typedElevatorId, + 'cab-back', + [cabWidth, cabHeight, wallThickness], + [0, cabHeight / 2, cabCenterZ + cabDepth / 2 - wallThickness / 2], + ), + createElevatorColliderMesh( + typedElevatorId, + 'cab-left', + [wallThickness, cabHeight, cabDepth], + [-cabWidth / 2 + wallThickness / 2, cabHeight / 2, cabCenterZ], + ), + createElevatorColliderMesh( + typedElevatorId, + 'cab-right', + [wallThickness, cabHeight, cabDepth], + [cabWidth / 2 - wallThickness / 2, cabHeight / 2, cabCenterZ], + ), + createElevatorColliderMesh( + typedElevatorId, + 'cab-door-gate', + [doorWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], + [0, doorHeight / 2, frontZ], + { doorWidth }, + ), + ...doorLeafSides.map((side) => + createElevatorColliderMesh( + typedElevatorId, + side === 'left' ? 'cab-door-left' : 'cab-door-right', + [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], + [0, doorHeight / 2, frontZ], + { doorWidth, side }, + ), + ), + ) + + 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-gate', + [doorWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], + [0, entry.baseY + doorHeight / 2, frontZ - 0.02], + { doorWidth, levelId: entry.id }, + ), + ...doorLeafSides.map((side) => + createElevatorColliderMesh( + typedElevatorId, + side === 'left' ? 'landing-door-left' : 'landing-door-right', + [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], + [0, entry.baseY + doorHeight / 2, frontZ - 0.02], + { doorWidth, levelId: entry.id, side }, + ), + ), + ) + } + } + + 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 +538,17 @@ export const FirstPersonControls = () => { const yawRef = useRef(0) const pitchRef = useRef(0) const interactableTargetRef = useRef(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(null) + const elevatorColliderMeshesRef = useRef([]) const [world, setWorld] = useState(null) + const [elevatorColliderMeshes, setElevatorColliderMeshes] = useState([]) const [controllerStart, setControllerStart] = useState<{ position: [number, number, number] yaw: number @@ -99,9 +560,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 +704,61 @@ 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 (target.action === 'request-level') { + if (!target.levelId || nodes[target.levelId]?.type !== 'level') continue + } + if (target.buttonKind === 'cab' && !canUseCabButtons) continue + + closestTarget = { + action: target.action, + 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 +766,42 @@ 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, + } + } + } + if (target.action === 'open-door') { + openElevatorDoor(target.id) + return + } + if (target.levelId) { + const targetElevatorId = + target.buttonKind === 'landing' + ? resolveElevatorDispatchTarget({ + elevators: useInteractive.getState().elevators, + levelId: target.levelId, + nodes: useScene.getState().nodes, + requestedElevatorId: target.id, + }) + : target.id + requestElevatorLevel(targetElevatorId, target.levelId) + } + return + } + if (target.type === 'window') { const node = useScene.getState().nodes[target.id] if ( @@ -270,6 +828,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 +885,9 @@ export const FirstPersonControls = () => { return () => { worldRef.current?.dispose() worldRef.current = null + disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current) + elevatorColliderMeshesRef.current = [] + setElevatorColliderMeshes([]) setWorld(null) } }, [rebuildColliderWorld]) @@ -373,17 +936,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 +991,229 @@ 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, + node.doorStyle, + ) + 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, + node.doorStyle, + ) + 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 + cabCenterZ: 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 = getElevatorCabWidth(node) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING + const halfDepth = getElevatorCabDepth(node) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING + const cabCenterZ = getElevatorCabCenterZ(node) + const cabHeight = Math.max(node.cabHeight, 1.4) + const insideFootprint = + Math.abs(elevatorLocalEyePosition.x) <= Math.max(halfWidth, 0.24) && + Math.abs(elevatorLocalEyePosition.z - cabCenterZ) <= 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, + cabCenterZ, + 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.cabCenterZ - nextRide.halfDepth, + Math.min(nextRide.cabCenterZ + 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 +1222,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 +1245,11 @@ export const FirstPersonControls = () => { } }, []) + const firstPersonColliderMeshes = useMemo( + () => (world ? [world.mesh, ...elevatorColliderMeshes] : elevatorColliderMeshes), + [world, elevatorColliderMeshes], + ) + if (!world) { return null } @@ -458,7 +1262,7 @@ export const FirstPersonControls = () => { acceleration={26} airDragFactor={0.3} colliderCapsuleArgs={[0.25, 0.8, 4, 8]} - colliderMeshes={[world.mesh]} + colliderMeshes={firstPersonColliderMeshes} collisionCheckIteration={3} collisionPushBackDamping={0.1} collisionPushBackThreshold={0.001} @@ -478,6 +1282,7 @@ export const FirstPersonControls = () => { maxRunSpeed={5.5} maxSlope={1.2} maxWalkSpeed={4} + paused={isElevatorRideLocked} position={controllerStart.position} ref={controllerRef} /> diff --git a/packages/editor/src/components/editor/first-person/bvh-ecctrl.tsx b/packages/editor/src/components/editor/first-person/bvh-ecctrl.tsx index 1f579a543..8525f4a1e 100644 --- a/packages/editor/src/components/editor/first-person/bvh-ecctrl.tsx +++ b/packages/editor/src/components/editor/first-person/bvh-ecctrl.tsx @@ -764,12 +764,12 @@ const BVHEcctrl = forwardRef( const deltaTime = Math.min(1 / 45, delta) * slowMotionFactor const keys = getKeys() ?? presetKeys - const forward = forwardState.current || keys.forward - const backward = backwardState.current || keys.backward - const leftward = leftwardState.current || keys.leftward - const rightward = rightwardState.current || keys.rightward - const run = runState.current || keys.run - const jump = jumpState.current || keys.jump + const forward = forwardState.current || (keys.forward ?? false) + const backward = backwardState.current || (keys.backward ?? false) + const leftward = leftwardState.current || (keys.leftward ?? false) + const rightward = rightwardState.current || (keys.rightward ?? false) + const run = runState.current || (keys.run ?? false) + const jump = jumpState.current || (keys.jump ?? false) setInputDirection({ forward, diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 93f81a5df..b9fb6bdaa 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -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' || diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index d4f9ff2c6..6ceb95068 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -9,6 +9,7 @@ import { type ColumnNode, calculateLevelMiters, DoorNode, + type ElevatorNode, emitter, type FenceNode, type GridEvent, @@ -28,6 +29,7 @@ import { type Point2D, type RoofNode, type RoofSegmentNode, + resolveElevatorServiceLevelIds, type SiteNode, SlabNode, type SpawnNode, @@ -37,6 +39,8 @@ import { StairSegmentNode as StairSegmentNodeSchema, sampleWallCenterline, sceneRegistry, + useInteractive, + useLiveNodeOverrides, useLiveTransforms, useScene, WallNode as WallNodeSchema, @@ -344,6 +348,21 @@ type PendingFenceDragState = { startClientY: number } +type ElevatorResizeHandle = + | 'width-negative' + | 'width-positive' + | 'depth-negative' + | 'depth-positive' + +type ElevatorResizeDragState = { + center: Point2D + elevatorId: ElevatorNode['id'] + handle: ElevatorResizeHandle + pointerId: number + rotation: number + shaftWallThickness: number +} + const GUIDE_CORNERS = ['nw', 'ne', 'se', 'sw'] as const type GuideCorner = (typeof GUIDE_CORNERS)[number] @@ -632,6 +651,40 @@ type FloorplanColumnEntry = { polygon: Point2D[] } +type FloorplanElevatorServedLevel = { + id: LevelNode['id'] + isCurrent: boolean + isDisabled: boolean + isQueued: boolean + isServiceOnly: boolean + isTarget: boolean + label: string +} + +type FloorplanElevatorEntry = { + cabCenterLocalY: number + cabDepth: number + cabWidth: number + center: Point2D + doorStyle: ElevatorNode['doorStyle'] + doorWidth: number + elevator: ElevatorNode + frontEdge: FloorplanLineSegment + frontNormal: Point2D + isCarOnLevel: boolean + isQueuedLevel: boolean + isTargetLevel: boolean + outerHalfDepth: number + outerHalfWidth: number + points: string + polygon: Point2D[] + rotation: number + servedLevels: FloorplanElevatorServedLevel[] + shaftDepth: number + shaftWallThickness: number + shaftWidth: number +} + type ReferenceFloorData = { ceilingPolygons: CeilingPolygonEntry[] columnEntries: ReferenceFloorColumnEntry[] @@ -808,6 +861,18 @@ function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max) } +function roundPlanMeters(value: number) { + return Math.round(value * 100) / 100 +} + +function getElevatorResizeAxis(handle: ElevatorResizeHandle) { + return handle.startsWith('width') ? 'width' : 'depth' +} + +function getElevatorResizeSign(handle: ElevatorResizeHandle) { + return handle.endsWith('positive') ? 1 : -1 +} + function getSelectionModifierKeys(event?: { metaKey?: boolean; ctrlKey?: boolean }) { return { meta: Boolean(event?.metaKey), @@ -6007,6 +6072,429 @@ const FloorplanFenceLayer = memo(function FloorplanFenceLayer({ ) }) +const FloorplanElevatorLayer = memo(function FloorplanElevatorLayer({ + canSelectElevators, + elevatorEntries, + highlightedIdSet, + hoveredElevatorId, + isDeleteMode, + onElevatorHoverChange, + onElevatorHoverEnter, + onElevatorPointerDown, + onElevatorResizePointerDown, + onElevatorResizePointerMove, + onElevatorResizePointerUp, + onElevatorSelect, + palette, + selectedIdSet, + wallSelectionHatchId, +}: { + canSelectElevators: boolean + elevatorEntries: FloorplanElevatorEntry[] + highlightedIdSet: ReadonlySet + hoveredElevatorId: ElevatorNode['id'] | null + isDeleteMode: boolean + onElevatorHoverChange: (elevatorId: ElevatorNode['id'] | null) => void + onElevatorHoverEnter: (elevatorId: ElevatorNode['id']) => void + onElevatorPointerDown: ( + elevatorId: ElevatorNode['id'], + event: ReactPointerEvent, + ) => void + onElevatorResizePointerDown: ( + entry: FloorplanElevatorEntry, + handle: ElevatorResizeHandle, + event: ReactPointerEvent, + ) => void + onElevatorResizePointerMove: (event: ReactPointerEvent) => void + onElevatorResizePointerUp: (event: ReactPointerEvent) => void + onElevatorSelect: (elevator: ElevatorNode, event: ReactMouseEvent) => void + palette: FloorplanPalette + selectedIdSet: ReadonlySet + wallSelectionHatchId: string +}) { + if (elevatorEntries.length === 0) { + return null + } + + return ( + + {elevatorEntries.map((entry) => { + const { elevator } = entry + const isSelected = selectedIdSet.has(elevator.id) + const isHighlighted = highlightedIdSet.has(elevator.id) + const isHovered = hoveredElevatorId === elevator.id + const isDeleteHovered = isDeleteMode && isHovered + const isActive = isSelected || isHighlighted + const showChrome = isActive || isHovered + const isGlassShaft = elevator.shaftStyle === 'glass' + const shaftShellFill = isDeleteHovered + ? palette.deleteFill + : isActive + ? `url(#${wallSelectionHatchId})` + : isGlassShaft + ? '#dff6ff' + : '#e5e7eb' + const shaftClearFill = isGlassShaft ? '#ecfeff' : '#f8fafc' + const shaftShellOpacity = isDeleteHovered ? 0.38 : isActive ? 0.9 : isHovered ? 0.86 : 0.76 + const stroke = isDeleteHovered + ? palette.deleteStroke + : isActive + ? palette.selectedStroke + : isHovered + ? palette.wallHoverStroke + : isGlassShaft + ? '#0891b2' + : '#475569' + const doorStroke = isDeleteHovered + ? palette.deleteStroke + : isActive + ? palette.selectedStroke + : '#0369a1' + const centerX = toSvgX(entry.center.x) + const centerY = toSvgY(entry.center.y) + const rotationDeg = (-entry.rotation * 180) / Math.PI + const shaftWidth = entry.outerHalfWidth * 2 + const shaftDepth = entry.outerHalfDepth * 2 + const shaftClearX = -entry.shaftWidth / 2 + const shaftClearY = -entry.shaftDepth / 2 + const cabX = -entry.cabWidth / 2 + const cabY = entry.cabCenterLocalY - entry.cabDepth / 2 + const frontLocalY = -entry.outerHalfDepth + const doorHalfWidth = entry.doorWidth / 2 + const doorTrackY = frontLocalY - 0.075 + const callStationX = Math.min(entry.outerHalfWidth - 0.12, doorHalfWidth + 0.2) + const callStationY = frontLocalY - 0.16 + const cabFill = entry.isCarOnLevel + ? '#dcfce7' + : entry.isTargetLevel || entry.isQueuedLevel + ? '#e0f2fe' + : '#f8fafc' + const cabStroke = entry.isCarOnLevel + ? '#16a34a' + : entry.isTargetLevel || entry.isQueuedLevel + ? '#0ea5e9' + : '#64748b' + const showCarMarker = entry.isCarOnLevel || entry.isTargetLevel || entry.isQueuedLevel + const carFill = entry.isCarOnLevel ? '#22c55e' : '#ffffff' + const carStroke = entry.isCarOnLevel ? '#15803d' : '#0ea5e9' + const resizeHandles = [ + { + cursor: 'ew-resize', + handle: 'width-negative' as const, + localX: -entry.outerHalfWidth, + localY: 0, + }, + { + cursor: 'ew-resize', + handle: 'width-positive' as const, + localX: entry.outerHalfWidth, + localY: 0, + }, + { + cursor: 'ns-resize', + handle: 'depth-negative' as const, + localX: 0, + localY: -entry.outerHalfDepth, + }, + { + cursor: 'ns-resize', + handle: 'depth-positive' as const, + localX: 0, + localY: entry.outerHalfDepth, + }, + ].map((handle) => { + const [offsetX, offsetY] = rotatePlanVector(handle.localX, handle.localY, entry.rotation) + return { + ...handle, + x: entry.center.x + offsetX, + y: entry.center.y + offsetY, + } + }) + const rangeStep = 0.18 + const rangeHeight = Math.max(0, (entry.servedLevels.length - 1) * rangeStep) + const [rangeOffsetX, rangeOffsetY] = rotatePlanVector( + entry.outerHalfWidth + 0.38, + 0, + entry.rotation, + ) + const rangeX = entry.center.x + rangeOffsetX + const rangeTopY = entry.center.y + rangeOffsetY - rangeHeight / 2 + const rangeBottomY = entry.center.y + rangeOffsetY + rangeHeight / 2 + + return ( + onElevatorHoverEnter(elevator.id) : undefined + } + onPointerLeave={canSelectElevators ? () => onElevatorHoverChange(null) : undefined} + > + {showChrome ? ( + + ) : null} + + + + + + + + + {entry.doorStyle === 'center-opening' ? ( + <> + + + + ) : ( + + )} + + + {showCarMarker ? ( + + ) : null} + + { + event.stopPropagation() + onElevatorSelect(elevator, event) + } + : undefined + } + onPointerDown={ + canSelectElevators && isSelected + ? (event) => { + if (event.button === 0) { + onElevatorPointerDown(elevator.id, event) + } + } + : undefined + } + points={entry.points} + pointerEvents={canSelectElevators ? 'all' : 'none'} + style={canSelectElevators ? { cursor: EDITOR_CURSOR } : undefined} + > + {elevator.name || 'Elevator'} + + {isSelected && entry.servedLevels.length > 1 ? ( + + + {entry.servedLevels.map((level, index) => { + const y = rangeBottomY - index * rangeStep + const isUnavailable = level.isDisabled || level.isServiceOnly + const markerFill = level.isCurrent + ? '#22c55e' + : level.isTarget || level.isQueued + ? '#38bdf8' + : isUnavailable + ? '#94a3b8' + : '#ffffff' + const markerStroke = isUnavailable ? '#64748b' : '#0369a1' + + return ( + + + + {index + 1} + + + ) + })} + + ) : null} + {isSelected && canSelectElevators && !isDeleteMode + ? resizeHandles.map((handle) => ( + + onElevatorResizePointerDown(entry, handle.handle, event) + } + onPointerMove={onElevatorResizePointerMove} + onPointerUp={onElevatorResizePointerUp} + r={0.075} + stroke="#0284c7" + strokeWidth="1.7" + style={{ cursor: handle.cursor }} + vectorEffect="non-scaling-stroke" + /> + )) + : null} + + ) + })} + + ) +}) + // Renders an item's 2D floor-plan image (top-down view, object-fit:contain) // inside its footprint rectangle. Placed at the same scene position/rotation // as the polygon so it lines up exactly. @@ -7583,6 +8071,19 @@ export function FloorplanPanel() { walls, zones, } = useFloorplanSceneData({ buildingId, levelId }) + const elevators = useScene( + useShallow((state) => { + const building = currentBuildingId ? state.nodes[currentBuildingId] : null + if (!building || building.type !== 'building') { + return [] as ElevatorNode[] + } + + return building.children.flatMap((childId) => { + const node = state.nodes[childId] + return node?.type === 'elevator' && node.visible !== false ? [node] : [] + }) + }), + ) const buildingRotationDeg = (buildingRotationY * 180) / Math.PI const floorplanSceneRotationDeg = FLOORPLAN_VIEW_ROTATION_DEG - buildingRotationDeg @@ -7640,6 +8141,9 @@ export function FloorplanPanel() { const [hoveredItemId, setHoveredItemId] = useState(null) const [hoveredSpawnId, setHoveredSpawnId] = useState(null) const [hoveredStairId, setHoveredStairId] = useState(null) + const [hoveredElevatorId, setHoveredElevatorId] = useState(null) + const [elevatorResizeDragState, setElevatorResizeDragState] = + useState(null) const [hoveredZoneId, setHoveredZoneId] = useState(null) const [hoveredEndpointId, setHoveredEndpointId] = useState(null) const [hoveredWallCurveHandleId, setHoveredWallCurveHandleId] = useState(null) @@ -7664,6 +8168,52 @@ export function FloorplanPanel() { const [rotationModifierPressed, setRotationModifierPressed] = useState(false) const [movingFloorplanNodeRevision, setMovingFloorplanNodeRevision] = useState(0) const movingFloorplanNodeRefreshFrameRef = useRef(null) + const elevatorIds = useMemo(() => elevators.map((elevator) => elevator.id), [elevators]) + const elevatorRuntimeKey = useInteractive( + useCallback( + (state) => + elevatorIds + .map((elevatorId) => { + const runtime = state.elevators[elevatorId] + if (!runtime) { + return `${elevatorId}:` + } + + return [ + elevatorId, + runtime.currentLevelId ?? '', + runtime.targetLevelId ?? '', + runtime.phase, + runtime.queue.join(','), + ].join(':') + }) + .join('|'), + [elevatorIds], + ), + ) + const elevatorLiveOverrideKey = useLiveNodeOverrides( + useCallback( + (state) => + elevatorIds + .map((elevatorId) => { + const overrides = state.overrides.get(elevatorId) + if (!overrides) { + return `${elevatorId}:` + } + + return [ + elevatorId, + overrides.width ?? '', + overrides.depth ?? '', + overrides.shaftWidth ?? '', + overrides.shaftDepth ?? '', + overrides.shaftWallThickness ?? '', + ].join(':') + }) + .join('|'), + [elevatorIds], + ), + ) const [stairBuildPreviewPoint, setStairBuildPreviewPoint] = useState(null) const [stairBuildPreviewRotation, setStairBuildPreviewRotation] = useState(0) const [isPanning, setIsPanning] = useState(false) @@ -8290,6 +8840,133 @@ export function FloorplanPanel() { ] }) }, [cursorPoint, floorplanItems, levelDescendantNodeById, movingFloorplanNodeRevision]) + const floorplanElevatorEntries = useMemo(() => { + // These keys subscribe the memo to imperative floorplan stores read with getState(). + void elevatorLiveOverrideKey + void elevatorRuntimeKey + void movingFloorplanNodeRevision + + if (!levelNode) { + return [] + } + + const nodes = useScene.getState().nodes + const interactiveElevators = useInteractive.getState().elevators + + return elevators.flatMap((elevator) => { + const liveOverrides = useLiveNodeOverrides.getState().get(elevator.id) + const displayElevator = liveOverrides + ? ({ ...elevator, ...liveOverrides } as ElevatorNode) + : elevator + const serviceLevelIds = resolveElevatorServiceLevelIds(displayElevator, nodes) + if (!serviceLevelIds.includes(levelNode.id)) { + return [] + } + + const live = useLiveTransforms.getState().get(displayElevator.id) + const position = live?.position ?? displayElevator.position + const rotation = live?.rotation ?? displayElevator.rotation + const center = { x: position[0], y: position[2] } + const wallThickness = Math.max(displayElevator.shaftWallThickness ?? 0.09, 0.04) + const cabWidth = Math.max(displayElevator.width, 0.8) + const cabDepth = Math.max(displayElevator.depth, 0.8) + const shaftWidth = Math.max( + displayElevator.shaftWidth ?? displayElevator.width, + cabWidth, + 0.8, + ) + const shaftDepth = Math.max( + displayElevator.shaftDepth ?? displayElevator.depth, + cabDepth, + 0.8, + ) + const doorWidth = Math.min( + Math.max(displayElevator.doorWidth, 0.45), + cabWidth - 0.18, + shaftWidth - 0.18, + ) + const halfWidth = Math.max(0.1, shaftWidth / 2 + wallThickness) + const halfDepth = Math.max(0.1, shaftDepth / 2 + wallThickness) + const footprintCorners: Array = [ + [-halfWidth, -halfDepth], + [halfWidth, -halfDepth], + [halfWidth, halfDepth], + [-halfWidth, halfDepth], + ] + const polygon = footprintCorners.map(([localX, localY]) => { + const [offsetX, offsetY] = rotatePlanVector(localX, localY, rotation) + return { + x: center.x + offsetX, + y: center.y + offsetY, + } + }) + const frontStart = polygon[0] + const frontEnd = polygon[1] + if (!(frontStart && frontEnd)) { + return [] + } + const [frontNormalX, frontNormalY] = rotatePlanVector(0, -1, rotation) + const runtime = interactiveElevators[displayElevator.id] + const disabledLevelIds = new Set(displayElevator.disabledLevelIds ?? []) + const serviceOnlyLevelIds = new Set(displayElevator.serviceOnlyLevelIds ?? []) + const servedLevels = serviceLevelIds.flatMap((levelId) => { + const level = nodes[levelId as AnyNodeId] + if (level?.type !== 'level') { + return [] + } + + return [ + { + id: level.id, + isCurrent: runtime?.currentLevelId === level.id, + isDisabled: disabledLevelIds.has(level.id), + isQueued: runtime?.queue.includes(level.id) ?? false, + isServiceOnly: serviceOnlyLevelIds.has(level.id), + isTarget: runtime?.targetLevelId === level.id, + label: level.name || `L${level.level}`, + }, + ] + }) + + return [ + { + cabCenterLocalY: -shaftDepth / 2 + cabDepth / 2, + cabDepth, + cabWidth, + center, + doorStyle: displayElevator.doorStyle ?? 'center-opening', + doorWidth, + elevator: displayElevator, + frontEdge: { + start: frontStart, + end: frontEnd, + }, + frontNormal: { + x: frontNormalX, + y: frontNormalY, + }, + isCarOnLevel: runtime?.currentLevelId === levelNode.id, + isQueuedLevel: runtime?.queue.includes(levelNode.id) ?? false, + isTargetLevel: runtime?.targetLevelId === levelNode.id, + outerHalfDepth: halfDepth, + outerHalfWidth: halfWidth, + points: formatPolygonPoints(polygon), + polygon, + rotation, + servedLevels, + shaftDepth, + shaftWallThickness: wallThickness, + shaftWidth, + }, + ] + }) + }, [ + elevatorLiveOverrideKey, + elevatorRuntimeKey, + elevators, + levelNode, + movingFloorplanNodeRevision, + ]) const referenceFloorLevel = useMemo(() => { if (!(showReferenceFloor && levelNode)) { return null @@ -8649,6 +9326,13 @@ export function FloorplanPanel() { return floorplanSpawnEntries.find(({ spawn }) => spawn.id === selectedIds[0]) ?? null }, [floorplanSpawnEntries, selectedIds]) + const selectedElevatorEntry = useMemo(() => { + if (selectedIds.length !== 1) { + return null + } + + return floorplanElevatorEntries.find(({ elevator }) => elevator.id === selectedIds[0]) ?? null + }, [floorplanElevatorEntries, selectedIds]) const selectedItemClearanceMeasurements = useMemo(() => { if (!selectedItemEntry) { return [] as LinearMeasurementOverlay[] @@ -8983,6 +9667,7 @@ export function FloorplanPanel() { const isFenceMoveActive = movingNode?.type === 'fence' const isWallMoveActive = movingNode?.type === 'wall' const isSpawnMoveActive = movingNode?.type === 'spawn' + const isElevatorMoveActive = movingNode?.type === 'elevator' const isWallCurveActive = curvingWall?.type === 'wall' const isFenceCurveActive = curvingFence?.type === 'fence' const isFenceEndpointMoveActive = movingFenceEndpoint !== null @@ -9002,6 +9687,7 @@ export function FloorplanPanel() { isFenceMoveActive || isWallMoveActive || isSpawnMoveActive || + isElevatorMoveActive || isWallCurveActive || isFenceCurveActive || isFenceEndpointMoveActive || @@ -9123,6 +9809,7 @@ export function FloorplanPanel() { !movingFenceEndpoint && isFloorplanStructureContextActive) || isDeleteMode + const canSelectFloorplanElevators = canSelectFloorplanStairs const canSelectFloorplanSpawns = canSelectFloorplanStairs const canSelectFloorplanItems = (mode === 'select' && @@ -9798,6 +10485,7 @@ export function FloorplanPanel() { ...(visibleSitePolygon ? visibleSitePolygon.polygon : []), ...displayCeilingPolygons.flatMap((entry) => entry.polygon), ...displaySlabPolygons.flatMap((entry) => entry.polygon), + ...floorplanElevatorEntries.flatMap((entry) => entry.polygon), ...floorplanFenceEntries.flatMap((entry) => entry.centerline), ...floorplanItemEntries.flatMap((entry) => entry.polygon), ...floorplanRoofEntries.flatMap((entry) => @@ -9845,6 +10533,7 @@ export function FloorplanPanel() { }, [ displayCeilingPolygons, displaySlabPolygons, + floorplanElevatorEntries, floorplanFenceEntries, floorplanItemEntries, floorplanRoofEntries, @@ -10060,6 +10749,18 @@ export function FloorplanPanel() { floorplanSceneRotationDeg, ) }, [floorplanSceneRotationDeg, selectedSpawnEntry, surfaceSize, viewBox]) + const selectedElevatorActionMenuPosition = useMemo( + () => + selectedElevatorEntry + ? getFloorplanActionMenuPosition( + selectedElevatorEntry.polygon, + viewBox, + surfaceSize, + floorplanSceneRotationDeg, + ) + : null, + [floorplanSceneRotationDeg, selectedElevatorEntry, surfaceSize, viewBox], + ) const selectedSlabActionMenuPosition = useMemo(() => { if (slabHoleMoveDraft) { return null @@ -10631,6 +11332,115 @@ export function FloorplanPanel() { }, [getSvgPointFromClientPoint, buildingRotationY], ) + + const previewElevatorResize = useCallback( + (dragState: ElevatorResizeDragState, planPoint: WallPlanPoint) => { + const localDeltaX = planPoint[0] - dragState.center.x + const localDeltaY = planPoint[1] - dragState.center.y + const [localX, localY] = rotatePlanVector(localDeltaX, localDeltaY, -dragState.rotation) + const axis = getElevatorResizeAxis(dragState.handle) + const sign = getElevatorResizeSign(dragState.handle) + const localDistance = sign * (axis === 'width' ? localX : localY) + const nextOuterSize = Math.max(0.1, localDistance) * 2 + + if (axis === 'width') { + const nextShaftWidth = roundPlanMeters( + Math.max(0.8, nextOuterSize - dragState.shaftWallThickness * 2), + ) + const nextCabWidth = nextShaftWidth + useLiveNodeOverrides + .getState() + .set(dragState.elevatorId, { shaftWidth: nextShaftWidth, width: nextCabWidth }) + setCursorPoint(planPoint) + return { shaftWidth: nextShaftWidth, width: nextCabWidth } satisfies Partial + } + + const nextShaftDepth = roundPlanMeters( + Math.max(0.8, nextOuterSize - dragState.shaftWallThickness * 2), + ) + const nextCabDepth = nextShaftDepth + useLiveNodeOverrides + .getState() + .set(dragState.elevatorId, { depth: nextCabDepth, shaftDepth: nextShaftDepth }) + setCursorPoint(planPoint) + return { depth: nextCabDepth, shaftDepth: nextShaftDepth } satisfies Partial + }, + [], + ) + + const handleElevatorResizePointerDown = useCallback( + ( + entry: FloorplanElevatorEntry, + handle: ElevatorResizeHandle, + event: ReactPointerEvent, + ) => { + if (event.button !== 0 || mode !== 'select') { + return + } + + event.preventDefault() + event.stopPropagation() + event.currentTarget.setPointerCapture(event.pointerId) + setHoveredElevatorId(null) + setSelection({ selectedIds: [entry.elevator.id] }) + + setElevatorResizeDragState({ + center: entry.center, + elevatorId: entry.elevator.id, + handle, + pointerId: event.pointerId, + rotation: entry.rotation, + shaftWallThickness: entry.shaftWallThickness, + }) + }, + [mode, setSelection], + ) + + const handleElevatorResizePointerMove = useCallback( + (event: ReactPointerEvent) => { + const dragState = elevatorResizeDragState + if (!dragState || dragState.pointerId !== event.pointerId) { + return + } + + const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) + if (!planPoint) { + return + } + + event.preventDefault() + event.stopPropagation() + previewElevatorResize(dragState, planPoint) + }, + [elevatorResizeDragState, getPlanPointFromClientPoint, previewElevatorResize], + ) + + const handleElevatorResizePointerUp = useCallback( + (event: ReactPointerEvent) => { + const dragState = elevatorResizeDragState + if (!dragState || dragState.pointerId !== event.pointerId) { + return + } + + const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) + const updates = planPoint ? previewElevatorResize(dragState, planPoint) : {} + + event.preventDefault() + event.stopPropagation() + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + + useLiveNodeOverrides.getState().clear(dragState.elevatorId) + if (Object.keys(updates).length > 0) { + updateNode(dragState.elevatorId as AnyNodeId, updates) + } + setElevatorResizeDragState(null) + setCursorPoint(null) + }, + [elevatorResizeDragState, getPlanPointFromClientPoint, previewElevatorResize, updateNode], + ) + useEffect(() => { siteBoundaryDraftRef.current = siteBoundaryDraft }, [siteBoundaryDraft]) @@ -12648,6 +13458,10 @@ export function FloorplanPanel() { return } + if (elevatorResizeDragState?.pointerId === event.pointerId) { + return + } + if (wallEndpointDragRef.current?.pointerId === event.pointerId) { return } @@ -12884,6 +13698,7 @@ export function FloorplanPanel() { ceilingHoleMoveDraft, ceilingHoleVertexDragState, ceilingVertexDragState, + elevatorResizeDragState, siteVertexDragState, slabHoleMoveDraft, slabHoleVertexDragState, @@ -13057,6 +13872,7 @@ export function FloorplanPanel() { columnPolygons: floorplanColumnEntries, displaySlabPolygons, displayWallPolygons, + floorplanElevatorEntries, floorplanItemEntries, floorplanOpeningHitTolerance, floorplanRoofEntries, @@ -13435,6 +14251,14 @@ export function FloorplanPanel() { [syncDeleteHoveredId], ) + const handleElevatorHoverChange = useCallback( + (elevatorId: ElevatorNode['id'] | null) => { + setHoveredElevatorId(elevatorId) + syncDeleteHoveredId(elevatorId) + }, + [syncDeleteHoveredId], + ) + const handleZoneHoverChange = useCallback( (zoneId: ZoneNodeType['id'] | null) => { setHoveredZoneId(zoneId) @@ -13450,11 +14274,13 @@ export function FloorplanPanel() { handleSlabHoverChange(null) handleCeilingHoverChange(null) handleStairHoverChange(null) + handleElevatorHoverChange(null) handleSpawnHoverChange(null) handleZoneHoverChange(null) handleItemHoverChange(itemId) }, [ + handleElevatorHoverChange, handleFenceHoverChange, handleItemHoverChange, handleOpeningHoverChange, @@ -13474,11 +14300,13 @@ export function FloorplanPanel() { handleSlabHoverChange(null) handleCeilingHoverChange(null) handleStairHoverChange(null) + handleElevatorHoverChange(null) handleSpawnHoverChange(null) handleZoneHoverChange(null) handleFenceHoverChange(fenceId) }, [ + handleElevatorHoverChange, handleFenceHoverChange, handleItemHoverChange, handleOpeningHoverChange, @@ -13499,10 +14327,12 @@ export function FloorplanPanel() { handleCeilingHoverChange(null) handleWallHoverChange(null) handleSpawnHoverChange(null) + handleElevatorHoverChange(null) handleZoneHoverChange(null) handleStairHoverChange(stairId) }, [ + handleElevatorHoverChange, handleFenceHoverChange, handleItemHoverChange, handleOpeningHoverChange, @@ -13523,11 +14353,39 @@ export function FloorplanPanel() { handleCeilingHoverChange(null) handleWallHoverChange(null) handleStairHoverChange(null) + handleElevatorHoverChange(null) handleZoneHoverChange(null) handleSpawnHoverChange(spawnId) }, [ handleCeilingHoverChange, + handleElevatorHoverChange, + handleFenceHoverChange, + handleItemHoverChange, + handleOpeningHoverChange, + handleSlabHoverChange, + handleSpawnHoverChange, + handleStairHoverChange, + handleWallHoverChange, + handleZoneHoverChange, + ], + ) + const handleFloorplanElevatorHoverEnter = useCallback( + (elevatorId: ElevatorNode['id']) => { + handleItemHoverChange(null) + handleFenceHoverChange(null) + handleOpeningHoverChange(null) + handleSlabHoverChange(null) + handleCeilingHoverChange(null) + handleWallHoverChange(null) + handleStairHoverChange(null) + handleSpawnHoverChange(null) + handleZoneHoverChange(null) + handleElevatorHoverChange(elevatorId) + }, + [ + handleCeilingHoverChange, + handleElevatorHoverChange, handleFenceHoverChange, handleItemHoverChange, handleOpeningHoverChange, @@ -13625,6 +14483,7 @@ export function FloorplanPanel() { | OpeningNode['id'] | SlabNode['id'] | CeilingNode['id'] + | ElevatorNode['id'] | SpawnNode['id'] | StairNode['id'] | ZoneNodeType['id'], @@ -13639,6 +14498,7 @@ export function FloorplanPanel() { node.type === 'ceiling' || node.type === 'door' || node.type === 'window' || + node.type === 'elevator' || node.type === 'item' || node.type === 'spawn' || node.type === 'stair' || @@ -13862,6 +14722,42 @@ export function FloorplanPanel() { }, [emitFloorplanNodeClick], ) + const handleElevatorSelect = useCallback( + (elevator: ElevatorNode, event: ReactMouseEvent) => { + emitFloorplanNodeClick(elevator.id, 'click', event) + }, + [emitFloorplanNodeClick], + ) + const handleElevatorPointerDown = useCallback( + (elevatorId: ElevatorNode['id'], event: ReactPointerEvent) => { + if (event.button !== 0) { + return + } + + const elevator = selectedElevatorEntry?.elevator + if (!elevator || elevator.id !== elevatorId) { + return + } + + event.preventDefault() + event.stopPropagation() + + const suppressClick = (clickEvent: MouseEvent) => { + clickEvent.stopImmediatePropagation() + clickEvent.preventDefault() + window.removeEventListener('click', suppressClick, true) + } + window.addEventListener('click', suppressClick, true) + requestAnimationFrame(() => { + window.removeEventListener('click', suppressClick, true) + }) + + sfxEmitter.emit('sfx:item-pick') + setMovingNode(elevator) + setSelection({ selectedIds: [] }) + }, + [selectedElevatorEntry, setMovingNode, setSelection], + ) const handleZoneLabelClick = useCallback( (zoneId: ZoneNodeType['id'], _event: ReactMouseEvent) => { const currentZoneId = useViewer.getState().selection.zoneId @@ -13961,6 +14857,36 @@ export function FloorplanPanel() { }, [deleteNode, selectedSpawnEntry, setSelection], ) + const handleSelectedElevatorMove = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const elevator = selectedElevatorEntry?.elevator + if (!elevator) { + return + } + + sfxEmitter.emit('sfx:item-pick') + setMovingNode(elevator) + setSelection({ selectedIds: [] }) + }, + [selectedElevatorEntry, setMovingNode, setSelection], + ) + const handleSelectedElevatorDelete = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const elevator = selectedElevatorEntry?.elevator + if (!elevator) { + return + } + + sfxEmitter.emit('sfx:item-delete') + deleteNode(elevator.id as AnyNodeId) + setSelection({ selectedIds: [] }) + }, + [deleteNode, selectedElevatorEntry, setSelection], + ) const handleItemPointerDown = useCallback( (itemId: ItemNode['id'], event: ReactPointerEvent) => { if (event.button !== 0) { @@ -14236,7 +15162,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 } @@ -14371,7 +15300,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 } @@ -15703,6 +16635,7 @@ export function FloorplanPanel() { handleCeilingHoverChange(null) handleSpawnHoverChange(null) handleStairHoverChange(null) + handleElevatorHoverChange(null) handleZoneHoverChange(null) setHoveredEndpointId(null) setHoveredSiteHandleId(null) @@ -15716,6 +16649,7 @@ export function FloorplanPanel() { }, [ emitFloorplanWallLeave, handleCeilingHoverChange, + handleElevatorHoverChange, handleItemHoverChange, handleOpeningHoverChange, handleSlabHoverChange, @@ -15749,6 +16683,7 @@ export function FloorplanPanel() { hasFloorplanCursorIndicator && !panStateRef.current && !guideInteractionRef.current && + !elevatorResizeDragState && !wallEndpointDragRef.current && !ceilingVertexDragState && !ceilingHoleMoveDraft && @@ -15785,6 +16720,7 @@ export function FloorplanPanel() { ceilingVertexDragState, ceilingHoleMoveDraft, ceilingHoleVertexDragState, + elevatorResizeDragState, siteVertexDragState, slabHoleMoveDraft, slabHoleVertexDragState, @@ -15827,6 +16763,7 @@ export function FloorplanPanel() { handleSlabHoverChange(null) handleSpawnHoverChange(null) handleStairHoverChange(null) + handleElevatorHoverChange(null) handleZoneHoverChange(null) setHoveredEndpointId(null) floorplanMarqueeSnapPointRef.current = snappedPoint @@ -15843,6 +16780,7 @@ export function FloorplanPanel() { }, [ getPlanPointFromClientPoint, + handleElevatorHoverChange, handleItemHoverChange, handleOpeningHoverChange, handleSlabHoverChange, @@ -16267,6 +17205,11 @@ export function FloorplanPanel() { /> )} + + {!isFirstPersonMode && } - {isFirstPersonMode && } + ) }) diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 7e239f28e..81a6059cc 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -56,6 +56,8 @@ import useEditor, { import { boxSelectHandled } from '../tools/select/box-select-tool' const isNodeInCurrentLevel = (node: AnyNode): boolean => { + // Elevators are building-scoped, so they stay selectable across level filters. + 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 +70,7 @@ type SelectableNodeType = | 'item' | 'column' | 'building' + | 'elevator' | 'zone' | 'slab' | 'ceiling' @@ -565,6 +568,7 @@ const SELECTION_STRATEGIES: Record = { 'fence', 'item', 'column', + 'elevator', 'zone', 'slab', 'ceiling', @@ -579,11 +583,18 @@ const SELECTION_STRATEGIES: Record = { 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 +630,7 @@ const SELECTION_STRATEGIES: Record = { node.type === 'wall' || node.type === 'fence' || node.type === 'column' || + node.type === 'elevator' || node.type === 'slab' || node.type === 'ceiling' || node.type === 'roof' || @@ -683,6 +695,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' || @@ -1162,6 +1175,7 @@ export const SelectionManager = () => { 'item', 'column', 'building', + 'elevator', 'zone', 'slab', 'ceiling', @@ -1258,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' || @@ -1312,6 +1327,7 @@ export const SelectionManager = () => { 'item', 'column', 'building', + 'elevator', 'slab', 'ceiling', 'roof', @@ -1385,6 +1401,7 @@ export const SelectionManager = () => { 'fence', 'item', 'column', + 'elevator', 'slab', 'ceiling', 'roof', diff --git a/packages/editor/src/components/editor/use-floorplan-hit-testing.ts b/packages/editor/src/components/editor/use-floorplan-hit-testing.ts index 09bc830cb..6894e7f48 100644 --- a/packages/editor/src/components/editor/use-floorplan-hit-testing.ts +++ b/packages/editor/src/components/editor/use-floorplan-hit-testing.ts @@ -5,6 +5,7 @@ import type { CeilingNode, ColumnNode, DoorNode, + ElevatorNode, ItemNode, Point2D, RoofNode, @@ -52,6 +53,11 @@ type ColumnPolygonEntry = { polygon: Point2D[] } +type ElevatorPolygonEntry = { + elevator: ElevatorNode + polygon: Point2D[] +} + type FloorplanRoofEntry = { roof: RoofNode segments: Array<{ @@ -81,6 +87,7 @@ type UseFloorplanHitTestingArgs = { columnPolygons: ColumnPolygonEntry[] displaySlabPolygons: SlabPolygonEntry[] displayWallPolygons: WallPolygonEntry[] + floorplanElevatorEntries: ElevatorPolygonEntry[] floorplanItemEntries: FloorplanItemEntry[] floorplanOpeningHitTolerance: number floorplanRoofEntries: FloorplanRoofEntry[] @@ -98,6 +105,7 @@ export function useFloorplanHitTesting({ columnPolygons, displaySlabPolygons, displayWallPolygons, + floorplanElevatorEntries, floorplanItemEntries, floorplanOpeningHitTolerance, floorplanRoofEntries, @@ -121,6 +129,7 @@ export function useFloorplanHitTesting({ openings: openingsPolygons, roofs: floorplanRoofEntries, stairs: floorplanStairEntries, + elevators: floorplanElevatorEntries, walls: displayWallPolygons, slabs: displaySlabPolygons, openingHitTolerance: floorplanOpeningHitTolerance, @@ -135,6 +144,7 @@ export function useFloorplanHitTesting({ displaySlabPolygons, displayWallPolygons, floorplanItemEntries, + floorplanElevatorEntries, floorplanOpeningHitTolerance, floorplanRoofEntries, floorplanStairEntries, @@ -160,6 +170,7 @@ export function useFloorplanHitTesting({ roofs: floorplanRoofEntries, slabs: displaySlabPolygons, columns: columnPolygons, + elevators: floorplanElevatorEntries, stairs: floorplanStairEntries, }), [ @@ -168,6 +179,7 @@ export function useFloorplanHitTesting({ displaySlabPolygons, displayWallPolygons, floorplanItemEntries, + floorplanElevatorEntries, floorplanRoofEntries, floorplanStairEntries, isFloorplanItemContextActive, diff --git a/packages/editor/src/components/tools/elevator/elevator-defaults.ts b/packages/editor/src/components/tools/elevator/elevator-defaults.ts new file mode 100644 index 000000000..d7cfa0e0b --- /dev/null +++ b/packages/editor/src/components/tools/elevator/elevator-defaults.ts @@ -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 diff --git a/packages/editor/src/components/tools/elevator/elevator-tool.tsx b/packages/editor/src/components/tools/elevator/elevator-tool.tsx new file mode 100644 index 000000000..409628f27 --- /dev/null +++ b/packages/editor/src/components/tools/elevator/elevator-tool.tsx @@ -0,0 +1,214 @@ +import { + type AnyNodeId, + type BuildingNode, + ElevatorNode, + emitter, + type GridEvent, + type LevelNode, + useScene, +} from '@pascal-app/core' +import { useEffect, useMemo, useRef } from 'react' +import * as THREE from 'three' +import { resolveCurrentBuildingId, resolveElevatorSupportY } from '../../../lib/elevator-support' +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 + +type ElevatorToolProps = { + buildingId: BuildingNode['id'] | null + levelId: LevelNode['id'] | null + onPlaced?: (elevatorId: AnyNodeId, buildingId: BuildingNode['id']) => void +} + +function resolveDefaultServiceRange( + buildingId: BuildingNode['id'], + selectedLevelId: LevelNode['id'] | null, +): { + defaultLevelId: LevelNode['id'] | null + fromLevelId: LevelNode['id'] | null + toLevelId: LevelNode['id'] | null +} { + 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 === selectedLevelId) + 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'], + selectedLevelId: LevelNode['id'] | null, + x: number, + z: number, + rotation: number, + onPlaced: ElevatorToolProps['onPlaced'], +): void { + const { createNode, nodes } = useScene.getState() + const elevatorCount = Object.values(nodes).filter((node) => node.type === 'elevator').length + const serviceRange = resolveDefaultServiceRange(buildingId, selectedLevelId) + const supportY = resolveElevatorSupportY({ + buildingId, + preferredLevelId: serviceRange.fromLevelId ?? serviceRange.defaultLevelId, + x, + z, + }) + const elevator = ElevatorNode.parse({ + name: `Elevator ${elevatorCount + 1}`, + parentId: buildingId, + position: [x, supportY, z], + 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) + onPlaced?.(elevator.id as AnyNodeId, buildingId) + sfxEmitter.emit('sfx:structure-build') +} + +export const ElevatorTool: React.FC = ({ buildingId, levelId, onPlaced }) => { + const cursorRef = useRef(null) + const previewRef = useRef(null) + const rotationRef = useRef(0) + const previousGridPosRef = useRef<[number, number] | null>(null) + const previewGeometry = useMemo(() => createElevatorPreviewGeometry(), []) + + useEffect(() => { + const currentBuildingId = resolveCurrentBuildingId({ + buildingId, + levelId, + nodes: useScene.getState().nodes, + }) + 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 supportY = resolveElevatorSupportY({ + buildingId: currentBuildingId, + preferredLevelId: levelId as LevelNode['id'] | null, + x: gridX, + z: gridZ, + }) + + cursorRef.current?.position.set(gridX, supportY + GRID_OFFSET, gridZ) + previewRef.current?.position.set(gridX, supportY + 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({ + buildingId, + levelId, + nodes: useScene.getState().nodes, + }) + if (!latestBuildingId) return + + const gridX = Math.round(event.localPosition[0] * 2) / 2 + const gridZ = Math.round(event.localPosition[2] * 2) / 2 + commitElevatorPlacement( + latestBuildingId, + levelId, + gridX, + gridZ, + rotationRef.current, + onPlaced, + ) + } + + 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, onPlaced]) + + return ( + + + + + + + + + + + + + ) +} diff --git a/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx b/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx new file mode 100644 index 000000000..5a82db0ca --- /dev/null +++ b/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx @@ -0,0 +1,253 @@ +import { + type AnyNodeId, + type BuildingNode, + type ElevatorNode, + ElevatorNode as ElevatorNodeSchema, + emitter, + type GridEvent, + type LevelNode, + pauseSceneHistory, + resumeSceneHistory, + sceneRegistry, + useLiveTransforms, + useScene, +} from '@pascal-app/core' +import { useCallback, useEffect, useRef, useState } from 'react' +import { markToolCancelConsumed } from '../../../hooks/use-keyboard' +import { resolveElevatorSupportY } from '../../../lib/elevator-support' +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) } + delete nextMeta.isNew + delete nextMeta.isTransient + return nextMeta as ElevatorNode['metadata'] +} + +export function MoveElevatorTool({ + node: movingNode, + onCommitted, +}: { + node: ElevatorNode + onCommitted?: (nodeId: AnyNodeId) => void +}) { + const onCommittedRef = useRef(onCommitted) + const historyPausedRef = useRef(false) + const previousGridPosRef = useRef<[number, number] | null>(null) + const previewPositionRef = useRef([ + 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(() => { + onCommittedRef.current = onCommitted + }, [onCommitted]) + + useEffect(() => { + const pauseHistory = () => { + const temporal = useScene.temporal.getState() + if (historyPausedRef.current || !temporal.isTracking) return + pauseSceneHistory(useScene) + historyPausedRef.current = true + } + const resumeHistory = () => { + if (!historyPausedRef.current) return + resumeSceneHistory(useScene) + historyPausedRef.current = false + } + + pauseHistory() + const movingNodeId = (movingNode as { id?: ElevatorNode['id'] }).id + + const meta = + typeof movingNode.metadata === 'object' && movingNode.metadata !== null + ? (movingNode.metadata as Record) + : {} + 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 supportBuildingId = movingNode.parentId as BuildingNode['id'] | null | undefined + const supportLevelId = (movingNode.fromLevelId ?? movingNode.defaultLevelId) as + | LevelNode['id'] + | null + + 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 supportY = resolveElevatorSupportY({ + buildingId: supportBuildingId, + preferredLevelId: supportLevelId, + x: gridX, + z: gridZ, + }) + + if ( + previousGridPosRef.current && + (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) + ) { + sfxEmitter.emit('sfx:grid-snap') + } + + previousGridPosRef.current = [gridX, gridZ] + setCursorPosition([gridX, supportY, gridZ]) + previewPositionRef.current = [gridX, supportY, 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 + const supportY = resolveElevatorSupportY({ + buildingId: supportBuildingId, + preferredLevelId: supportLevelId, + x: gridX, + z: gridZ, + }) + const nextPosition: ElevatorNode['position'] = [gridX, supportY, gridZ] + + wasCommitted = true + clearPreview() + resumeHistory() + if (movingNodeId && useScene.getState().nodes[movingNodeId as AnyNodeId]) { + useScene.getState().updateNode(movingNodeId as AnyNodeId, { + position: nextPosition, + rotation: pendingRotation, + metadata: committedMeta, + }) + onCommittedRef.current?.(movingNodeId as AnyNodeId) + } else if (movingNode.parentId) { + const elevator = ElevatorNodeSchema.parse({ + ...movingNode, + id: undefined, + position: nextPosition, + rotation: pendingRotation, + metadata: committedMeta, + }) + useScene.getState().createNode(elevator, movingNode.parentId as AnyNodeId) + onCommittedRef.current?.(elevator.id as AnyNodeId) + } + + 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) + resumeHistory() + 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) + } + resumeHistory() + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('tool:cancel', onCancel) + window.removeEventListener('keydown', onKeyDown) + } + }, [movingNode, exitMoveMode]) + + return +} diff --git a/packages/editor/src/components/tools/item/item-tool.tsx b/packages/editor/src/components/tools/item/item-tool.tsx index 28589ec5e..6bb1ffaeb 100644 --- a/packages/editor/src/components/tools/item/item-tool.tsx +++ b/packages/editor/src/components/tools/item/item-tool.tsx @@ -1,10 +1,10 @@ +import type { AssetInput } from '@pascal-app/core' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { useDraftNode } from './use-draft-node' import { usePlacementCoordinator } from './use-placement-coordinator' -export const ItemTool: React.FC = () => { - const selectedItem = useEditor((state) => state.selectedItem) +function ItemPlacementContent({ selectedItem }: { selectedItem: AssetInput }) { const draftNode = useDraftNode() const cursor = usePlacementCoordinator({ @@ -21,6 +21,12 @@ export const ItemTool: React.FC = () => { }, }) - if (!selectedItem) return null return <>{cursor} } + +export const ItemTool: React.FC = () => { + const selectedItem = useEditor((state) => state.selectedItem) + + if (!selectedItem) return null + return +} diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index baa9a999f..5b017ed20 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -1,8 +1,10 @@ import type { + AnyNodeId, BuildingNode, CeilingNode, ColumnNode, DoorNode, + ElevatorNode, FenceNode, ItemNode, RoofNode, @@ -21,6 +23,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' @@ -90,13 +93,18 @@ function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { return <>{cursor} } -export const MoveTool: React.FC = () => { +export const MoveTool: React.FC<{ + onNodeMoved?: (nodeId: AnyNodeId) => void + onSpawnMoved?: (nodeId: SpawnNode['id']) => void +}> = ({ onNodeMoved, onSpawnMoved }) => { const movingNode = useEditor((state) => state.movingNode) if (!movingNode) return null if (movingNode.type === 'building') return if (movingNode.type === 'door') return + if (movingNode.type === 'elevator') + return if (movingNode.type === 'window') return if (movingNode.type === 'ceiling') return if (movingNode.type === 'column') return @@ -105,7 +113,8 @@ export const MoveTool: React.FC = () => { if (movingNode.type === 'fence') return if (movingNode.type === 'roof' || movingNode.type === 'roof-segment') return - if (movingNode.type === 'spawn') return + if (movingNode.type === 'spawn') + return if (movingNode.type === 'stair' || movingNode.type === 'stair-segment') return return diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index fcc477672..fdafe3635 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -116,10 +116,10 @@ function expandBoundsToGrid( function getFallbackPreviewBounds( item: import('@pascal-app/core').ItemNode | null, - asset: AssetInput, - attachTo: AssetInput['attachTo'], + asset: AssetInput | null | undefined, + attachTo: AssetInput['attachTo'] | null | undefined, ): PreviewBounds { - const dims = item ? getScaledDimensions(item) : (asset.dimensions ?? DEFAULT_DIMENSIONS) + const dims = item ? getScaledDimensions(item) : (asset?.dimensions ?? DEFAULT_DIMENSIONS) return { min: [-dims[0] / 2, 0, attachTo === 'wall-side' ? -dims[2] : -dims[2] / 2], max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ? 0 : dims[2] / 2], @@ -1341,7 +1341,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const dims = getGridAlignedDimensions(rawDims, initialAttachTo, gridSnapStep) const wallSideZOffset = initialAttachTo === 'wall-side' ? -dims[2] / 2 : 0 const initialDimensionBounds = expandBoundsToGrid( - getFallbackPreviewBounds(initialDraft, config.asset!, initialAttachTo), + getFallbackPreviewBounds(initialDraft, config.asset, initialAttachTo), initialAttachTo, gridSnapStep, ) diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 1778baeab..cd9518e68 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -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' @@ -45,7 +46,6 @@ const tools: Record>> = { door: DoorTool, item: ItemTool, zone: ZoneTool, - spawn: SpawnTool, window: WindowTool, }, furnish: { @@ -67,6 +67,7 @@ export const ToolManager: React.FC = () => { const selectedIds = useViewer((state) => state.selection.selectedIds) const buildingId = useViewer((state) => state.selection.buildingId) const activeLevelId = useViewer((state) => state.selection.levelId) + const setSelection = useViewer((state) => state.setSelection) const nodes = useScene((state) => state.nodes) // Building transform for the local group — all building-relative tools live inside this group @@ -128,14 +129,22 @@ export const ToolManager: React.FC = () => { const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null const handlePlacedNodeSelected = (nodeId: AnyNodeId) => { - useViewer.getState().setSelection({ selectedIds: [nodeId] }) + setSelection({ selectedIds: [nodeId] }) + } + const handlePlacedElevatorSelected = ( + nodeId: AnyNodeId, + elevatorBuildingId: BuildingNode['id'], + ) => { + setSelection({ buildingId: elevatorBuildingId, selectedIds: [nodeId] }) } return ( <> {/* World-space tools: site boundary and building movement operate in world coordinates */} {showSiteBoundaryEditor && } - {movingNode?.type === 'building' && } + {movingNode?.type === 'building' && ( + + )} {/* Building-local group: all other tools are relative to the selected building. Cursor visuals set positions in building-local space; this group applies the @@ -160,13 +169,29 @@ export const ToolManager: React.FC = () => { {curvingWall && } {curvingFence && } {movingNode && movingNode.type !== 'building' && ( - + )} - {!movingNode && BuildToolComponent && tool === 'spawn' ? ( + {!movingNode && showBuildTool && tool === 'spawn' && ( - ) : !movingNode && showBuildTool && tool === 'column' ? ( + )} + {!movingNode && showBuildTool && tool === 'column' && ( - ) : !movingNode && BuildToolComponent && tool !== 'column' ? ( + )} + {!movingNode && showBuildTool && tool === 'elevator' && ( + + )} + {!movingNode && + BuildToolComponent && + tool !== 'spawn' && + tool !== 'column' && + tool !== 'elevator' ? ( ) : null} diff --git a/packages/editor/src/components/ui/action-menu/structure-tools.tsx b/packages/editor/src/components/ui/action-menu/structure-tools.tsx index cc060af61..352820c72 100644 --- a/packages/editor/src/components/ui/action-menu/structure-tools.tsx +++ b/packages/editor/src/components/ui/action-menu/structure-tools.tsx @@ -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' }, diff --git a/packages/editor/src/components/ui/command-palette/editor-commands.tsx b/packages/editor/src/components/ui/command-palette/editor-commands.tsx index 8b09a1670..d88d21a33 100644 --- a/packages/editor/src/components/ui/command-palette/editor-commands.tsx +++ b/packages/editor/src/components/ui/command-palette/editor-commands.tsx @@ -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, }) diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx index 4cbc39bdd..91bff54d6 100644 --- a/packages/editor/src/components/ui/controls/material-picker.tsx +++ b/packages/editor/src/components/ui/controls/material-picker.tsx @@ -4,7 +4,6 @@ import { getCatalogMaterialById, getLibraryMaterialIdFromRef, getMaterialsForCategory, - getMaterialsForTarget, MATERIAL_CATEGORIES, type MaterialSchema, type MaterialTarget, diff --git a/packages/editor/src/components/ui/controls/metric-control.tsx b/packages/editor/src/components/ui/controls/metric-control.tsx index e12ee12a3..14e152fa9 100644 --- a/packages/editor/src/components/ui/controls/metric-control.tsx +++ b/packages/editor/src/components/ui/controls/metric-control.tsx @@ -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 ( diff --git a/packages/editor/src/components/ui/panels/ceiling-panel.tsx b/packages/editor/src/components/ui/panels/ceiling-panel.tsx index dfb086590..314f23800 100644 --- a/packages/editor/src/components/ui/panels/ceiling-panel.tsx +++ b/packages/editor/src/components/ui/panels/ceiling-panel.tsx @@ -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 }, @@ -171,7 +171,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 (

{holeArea.toFixed(2)} m² · {hole.length} pts ·{' '} - {isAutoHole ? 'Auto stair cutout' : 'Manual'} + {isAutoHole ? autoLabel : 'Manual'}

diff --git a/packages/editor/src/components/ui/panels/elevator-panel.tsx b/packages/editor/src/components/ui/panels/elevator-panel.tsx new file mode 100644 index 000000000..02bbc4aa1 --- /dev/null +++ b/packages/editor/src/components/ui/panels/elevator-panel.tsx @@ -0,0 +1,934 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + type ElevatorNode, + ElevatorNode as ElevatorNodeSchema, + type LevelNode, + requestElevatorLevel, + useInteractive, + useLiveNodeOverrides, + useLiveTransforms, + 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 { resolveElevatorNodeSupportY, resolveElevatorSupportY } from '../../../lib/elevator-support' +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) } + delete nextMeta.isNew + delete nextMeta.isTransient + return nextMeta as ElevatorNode['metadata'] +} + +type ElevatorMetricKey = + | 'width' + | 'depth' + | 'shaftWidth' + | 'shaftDepth' + | 'shaftWallThickness' + | 'cabHeight' + | 'doorWidth' + | 'doorHeight' + +type ElevatorAccessField = 'disabledLevelIds' | 'serviceOnlyLevelIds' + +const DOOR_STYLE_OPTIONS: Array<{ + label: string + value: ElevatorNode['doorStyle'] +}> = [ + { label: 'Center opening', value: 'center-opening' }, + { label: 'Single left', value: 'single-left' }, + { label: 'Single right', value: 'single-right' }, +] + +const DOOR_PANEL_STYLE_OPTIONS: Array<{ + label: string + value: ElevatorNode['doorPanelStyle'] +}> = [ + { label: 'Glass frame', value: 'glass-frame' }, + { label: 'Solid panel', value: 'solid-panel' }, + { label: 'Segmented panel', value: 'segmented-panel' }, +] + +const SHAFT_STYLE_OPTIONS: Array<{ + label: string + value: ElevatorNode['shaftStyle'] +}> = [ + { label: 'Solid', value: 'solid' }, + { label: 'Glass', value: 'glass' }, +] + +function roundMeters(value: number) { + return Math.round(value * 100) / 100 +} + +function getResolvedShaftWidth(node: ElevatorNode) { + return Math.max(node.shaftWidth ?? node.width, node.width, 0.8) +} + +function getResolvedShaftDepth(node: ElevatorNode) { + return Math.max(node.shaftDepth ?? node.depth, node.depth, 0.8) +} + +function getResolvedShaftWallThickness(node: ElevatorNode) { + return Math.max(node.shaftWallThickness ?? 0.09, 0.04) +} + +function radiansToDegrees(radians: number) { + return Math.round((radians * 180) / Math.PI) +} + +function degreesToRadians(degrees: number) { + return (degrees * Math.PI) / 180 +} + +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, + ) + const liveTransform = useLiveTransforms((s) => + selectedId ? s.get(selectedId as AnyNodeId) : undefined, + ) + + useEffect(() => { + return () => { + if (!selectedId) return + useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId) + useLiveTransforms.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) => { + if (!selectedId) return + updateNode(selectedId as AnyNode['id'], updates) + }, + [selectedId, updateNode], + ) + + const clearLivePreview = useCallback(() => { + if (!selectedId) return + useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId) + useLiveTransforms.getState().clear(selectedId as AnyNodeId) + }, [selectedId]) + + useEffect(() => { + if (!(selectedId && node?.type === 'elevator')) return + const supportY = resolveElevatorNodeSupportY(node) + if (node.position[1] >= supportY - 1e-4) return + + updateNode(selectedId as AnyNode['id'], { + position: [node.position[0], supportY, node.position[2]], + }) + }, [ + node?.defaultLevelId, + node?.fromLevelId, + node?.id, + node?.parentId, + node?.position[0], + node?.position[1], + node?.position[2], + node?.type, + selectedId, + updateNode, + ]) + + const previewMetric = useCallback( + (key: K, value: ElevatorNode[K]) => { + if (!selectedId) return + useLiveNodeOverrides.getState().set(selectedId as AnyNodeId, { [key]: value }) + }, + [selectedId], + ) + + const commitMetric = useCallback( + (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) + } + useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId) + }, + [node, selectedId, updateNode], + ) + + const previewTransform = useCallback( + (position: ElevatorNode['position'], rotation: ElevatorNode['rotation']) => { + if (!selectedId) return + useLiveTransforms.getState().set(selectedId as AnyNodeId, { position, rotation }) + }, + [selectedId], + ) + + const commitTransform = useCallback( + (position: ElevatorNode['position'], rotation: ElevatorNode['rotation']) => { + if (!(selectedId && node)) return + useLiveTransforms.getState().clear(selectedId as AnyNodeId) + const positionChanged = node.position.some( + (value, index) => Math.abs(value - position[index]!) > 1e-6, + ) + const rotationChanged = Math.abs(node.rotation - rotation) > 1e-6 + if (positionChanged || rotationChanged) { + updateNode(selectedId as AnyNode['id'], { position, rotation }) + } + }, + [node, selectedId, updateNode], + ) + + const getSupportedPosition = useCallback( + (x: number, z: number): ElevatorNode['position'] => { + if (!node) return [x, 0, z] + const supportY = resolveElevatorSupportY({ + buildingId: node.parentId, + preferredLevelId: node.fromLevelId ?? node.defaultLevelId, + x, + z, + }) + return [x, supportY, z] + }, + [node], + ) + + 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), 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 + if ((node.disabledLevelIds ?? []).includes(levelId)) return + requestElevatorLevel(node.id as AnyNodeId, levelId as AnyNodeId) + }, + [node], + ) + + const toggleLevelAccess = useCallback( + (field: ElevatorAccessField, levelId: LevelNode['id']) => { + if (!node) return + const disabledIds = new Set(node.disabledLevelIds ?? []) + const serviceOnlyIds = new Set(node.serviceOnlyLevelIds ?? []) + const targetSet = field === 'disabledLevelIds' ? disabledIds : serviceOnlyIds + + if (targetSet.has(levelId)) { + targetSet.delete(levelId) + } else { + targetSet.add(levelId) + } + + if (field === 'disabledLevelIds' && disabledIds.has(levelId)) { + serviceOnlyIds.delete(levelId) + } + if (field === 'serviceOnlyLevelIds' && serviceOnlyIds.has(levelId)) { + disabledIds.delete(levelId) + } + + const nextServiceLevels = getServiceLevels( + levels, + getResolvedFromLevelId(node, levels), + getResolvedToLevelId(node, levels, getResolvedFromLevelId(node, levels)), + ) + const nextDefaultLevelId = + node.defaultLevelId && !disabledIds.has(node.defaultLevelId) + ? node.defaultLevelId + : (nextServiceLevels.find((level) => !disabledIds.has(level.id))?.id ?? + nextServiceLevels[0]?.id ?? + null) + + handleUpdate({ + defaultLevelId: nextDefaultLevelId, + disabledLevelIds: Array.from(disabledIds), + serviceOnlyLevelIds: Array.from(serviceOnlyIds), + }) + }, + [handleUpdate, levels, node], + ) + + const handleServiceBoundaryChange = useCallback( + (field: 'fromLevelId' | 'toLevelId', levelId: string) => { + if (!node) return + 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, + ...(field === 'fromLevelId' + ? { + position: [ + node.position[0], + resolveElevatorSupportY({ + buildingId: node.parentId, + preferredLevelId: nextFromLevelId, + x: node.position[0], + z: node.position[2], + }), + node.position[2], + ] as ElevatorNode['position'], + } + : {}), + servedLevelIds: undefined, + } as Partial) + }, + [node, levels, handleUpdate], + ) + + if (!(node && node.type === 'elevator' && selectedId && selectedCount === 1)) return null + + const displayNode = liveOverrides ? ({ ...node, ...liveOverrides } as ElevatorNode) : node + const displayPosition = liveTransform?.position ?? displayNode.position + const displayRotation = liveTransform?.rotation ?? displayNode.rotation + const displayRotationDegrees = radiansToDegrees(displayRotation) + const displayShaftWidth = getResolvedShaftWidth(displayNode) + const displayShaftDepth = getResolvedShaftDepth(displayNode) + const displayShaftWallThickness = getResolvedShaftWallThickness(displayNode) + const fromLevelId = getResolvedFromLevelId(node, levels) + const toLevelId = getResolvedToLevelId(node, levels, fromLevelId) + const servedLevels = getServiceLevels(levels, fromLevelId, toLevelId) + const servedLevelIdSet = new Set(servedLevels.map((level) => level.id)) + const disabledLevelIds = new Set( + (node.disabledLevelIds ?? []).filter((levelId) => servedLevelIdSet.has(levelId)), + ) + const serviceOnlyLevelIds = new Set( + (node.serviceOnlyLevelIds ?? []).filter((levelId) => servedLevelIdSet.has(levelId)), + ) + const enabledServedLevels = servedLevels.filter((level) => !disabledLevelIds.has(level.id)) + const defaultLevelOptions = + enabledServedLevels.length > 0 ? enabledServedLevels : servedLevels.length > 0 ? servedLevels : levels + const selectedDefaultLevelId = defaultLevelOptions.some( + (level) => level.id === node.defaultLevelId, + ) + ? (node.defaultLevelId ?? '') + : fromLevelId + const activeLevelId = + runtime?.currentLevelId ?? + (servedLevels.some((level) => level.id === node.defaultLevelId) + ? node.defaultLevelId + : fromLevelId || levels[0]?.id) ?? + null + const destinationOrderByLevelId = new Map() + const orderedDestinationIds: string[] = [] + if (runtime?.targetLevelId) orderedDestinationIds.push(runtime.targetLevelId) + for (const levelId of runtime?.queue ?? []) { + if (!orderedDestinationIds.includes(levelId)) orderedDestinationIds.push(levelId) + } + orderedDestinationIds.forEach((levelId, index) => { + destinationOrderByLevelId.set(levelId, index + 1) + }) + + return ( + + + + } label="Move" onClick={handleMove} /> + } + label="Duplicate" + onClick={handleDuplicate} + /> + } + label="Delete" + onClick={handleDelete} + /> + + + + + { + const position = getSupportedPosition(value, displayPosition[2]) + previewTransform(position, displayRotation) + }} + onCommit={(value) => { + const position = getSupportedPosition(value, displayPosition[2]) + commitTransform(position, displayRotation) + }} + precision={2} + restoreOnCommit={false} + step={0.05} + unit="m" + value={roundMeters(displayPosition[0])} + /> + { + const position: ElevatorNode['position'] = [ + displayPosition[0], + value, + displayPosition[2], + ] + previewTransform(position, displayRotation) + }} + onCommit={(value) => { + const position: ElevatorNode['position'] = [ + displayPosition[0], + value, + displayPosition[2], + ] + commitTransform(position, displayRotation) + }} + precision={2} + restoreOnCommit={false} + step={0.05} + unit="m" + value={roundMeters(displayPosition[1])} + /> + { + const position = getSupportedPosition(displayPosition[0], value) + previewTransform(position, displayRotation) + }} + onCommit={(value) => { + const position = getSupportedPosition(displayPosition[0], value) + commitTransform(position, displayRotation) + }} + precision={2} + restoreOnCommit={false} + step={0.05} + unit="m" + value={roundMeters(displayPosition[2])} + /> + + + + previewTransform(displayPosition, degreesToRadians(degrees))} + onCommit={(degrees) => commitTransform(displayPosition, degreesToRadians(degrees))} + precision={0} + restoreOnCommit={false} + step={1} + unit="°" + value={displayRotationDegrees} + /> +
+ { + sfxEmitter.emit('sfx:item-rotate') + commitTransform(displayPosition, displayRotation - Math.PI / 4) + }} + /> + { + sfxEmitter.emit('sfx:item-rotate') + commitTransform(displayPosition, displayRotation + Math.PI / 4) + }} + /> +
+
+ + + previewMetric('width', value)} + onCommit={(value) => commitMetric('width', value)} + precision={2} + restoreOnCommit={false} + step={0.05} + unit="m" + value={displayNode.width} + /> + previewMetric('depth', value)} + onCommit={(value) => commitMetric('depth', value)} + precision={2} + restoreOnCommit={false} + step={0.05} + unit="m" + value={displayNode.depth} + /> + previewMetric('cabHeight', value)} + onCommit={(value) => commitMetric('cabHeight', value)} + precision={2} + restoreOnCommit={false} + step={0.05} + unit="m" + value={displayNode.cabHeight} + /> + + + +
+
+ Shaft Style +
+ +
+ previewMetric('shaftWidth', Math.max(value, displayNode.width))} + onCommit={(value) => commitMetric('shaftWidth', Math.max(value, displayNode.width))} + precision={2} + restoreOnCommit={false} + step={0.05} + unit="m" + value={displayShaftWidth} + /> + previewMetric('shaftDepth', Math.max(value, displayNode.depth))} + onCommit={(value) => commitMetric('shaftDepth', Math.max(value, displayNode.depth))} + precision={2} + restoreOnCommit={false} + step={0.05} + unit="m" + value={displayShaftDepth} + /> + previewMetric('shaftWallThickness', value)} + onCommit={(value) => commitMetric('shaftWallThickness', value)} + precision={2} + restoreOnCommit={false} + step={0.01} + unit="m" + value={displayShaftWallThickness} + /> +
+ + +
+
+ Opening Style +
+ +
+
+
+ Door Type +
+ +
+ previewMetric('doorWidth', value)} + onCommit={(value) => commitMetric('doorWidth', value)} + precision={2} + restoreOnCommit={false} + step={0.05} + unit="m" + value={displayNode.doorWidth} + /> + previewMetric('doorHeight', value)} + onCommit={(value) => commitMetric('doorHeight', value)} + precision={2} + restoreOnCommit={false} + step={0.05} + unit="m" + value={displayNode.doorHeight} + /> +
+ + +
+
+
+ From +
+ +
+ +
+
+ To +
+ +
+
+ +
+
+ Default Floor +
+ +
+
+ + +
+ {servedLevels.map((level) => { + const isDisabled = disabledLevelIds.has(level.id) + const isServiceOnly = serviceOnlyLevelIds.has(level.id) + + return ( +
+ + {level.name || `Level ${level.level}`} + +
+ + +
+
+ ) + })} +
+
+ + +
+ {servedLevels.map((level) => { + const isActive = activeLevelId === level.id + const stopOrder = destinationOrderByLevelId.get(level.id) + const isDisabled = disabledLevelIds.has(level.id) + const isServiceOnly = serviceOnlyLevelIds.has(level.id) + return ( + + ) + })} +
+
+ + + handleUpdate({ speed: value })} + precision={1} + step={0.1} + unit="m/s" + value={node.speed} + /> + handleUpdate({ doorDurationMs: value })} + step={50} + unit="ms" + value={node.doorDurationMs} + /> + handleUpdate({ dwellMs: value })} + step={100} + unit="ms" + value={node.dwellMs} + /> + +
+ ) +} diff --git a/packages/editor/src/components/ui/panels/node-display.ts b/packages/editor/src/components/ui/panels/node-display.ts index 3b5456801..16ee20d9b 100644 --- a/packages/editor/src/components/ui/panels/node-display.ts +++ b/packages/editor/src/components/ui/panels/node-display.ts @@ -13,6 +13,7 @@ const TYPE_DEFAULTS: Record = { 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' }, diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index 76b63371a..7836b2e31 100644 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -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([ 'item', 'window', 'door', + 'elevator', 'ceiling', 'column', 'slab', @@ -104,6 +108,8 @@ function panelForType(type: string | null) { return case 'door': return + case 'elevator': + return case 'window': return default: @@ -239,6 +245,5 @@ export function PanelManager() { return } - // Show appropriate panel based on selected node type return panelForType(selectedNodeType) } diff --git a/packages/editor/src/components/ui/panels/slab-panel.tsx b/packages/editor/src/components/ui/panels/slab-panel.tsx index b44b1a64a..0034c7ca8 100644 --- a/packages/editor/src/components/ui/panels/slab-panel.tsx +++ b/packages/editor/src/components/ui/panels/slab-panel.tsx @@ -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 (

{holeArea.toFixed(2)} m² · {hole.length} pts ·{' '} - {isAutoHole ? 'Auto stair cutout' : 'Manual'} + {isAutoHole ? autoLabel : 'Manual'}

diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx index fdbae4e90..5407b9299 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx @@ -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, }) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx new file mode 100644 index 000000000..0f3451717 --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx @@ -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 ( + } + depth={depth} + expanded={false} + hasChildren={false} + icon={ + + } + isHovered={isHovered} + isLast={isLast} + isSelected={isSelected} + isVisible={isVisible} + label={ + setIsEditing(true)} + onStopEditing={() => setIsEditing(false)} + /> + } + nodeId={nodeId} + onClick={handleClick} + onDoubleClick={() => focusTreeNode(nodeId)} + onMouseEnter={() => setHoveredId(nodeId)} + onMouseLeave={() => setHoveredId(null)} + onToggle={() => {}} + /> + ) +}) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx index 95e6ba397..2a3107492 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -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' @@ -89,6 +90,8 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr return case 'column': return + case 'elevator': + return case 'level': return case 'slab': diff --git a/packages/editor/src/hooks/use-contextual-tools.ts b/packages/editor/src/hooks/use-contextual-tools.ts index ebb288a61..e51121b19 100644 --- a/packages/editor/src/hooks/use-contextual-tools.ts +++ b/packages/editor/src/hooks/use-contextual-tools.ts @@ -28,6 +28,7 @@ export function useContextualTools() { 'slab', 'ceiling', 'roof', + 'elevator', 'door', 'window', ] diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 734dfc622..e7f9146f8 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -134,13 +134,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 }) } } } @@ -149,15 +155,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 }) } } } diff --git a/packages/editor/src/lib/elevator-support.ts b/packages/editor/src/lib/elevator-support.ts new file mode 100644 index 000000000..711f2594b --- /dev/null +++ b/packages/editor/src/lib/elevator-support.ts @@ -0,0 +1,96 @@ +import { + type AnyNode, + type AnyNodeId, + type BuildingNode, + type ElevatorNode, + type LevelNode, + spatialGridManager, + useScene, +} from '@pascal-app/core' + +function getBuildingLevels( + buildingId: string | null | undefined, + nodes: Record, +): LevelNode[] { + if (!buildingId) return [] + const building = nodes[buildingId as AnyNodeId] + if (building?.type !== 'building') return [] + + return building.children + .map((childId) => nodes[childId as AnyNodeId]) + .filter((entry): entry is LevelNode => entry?.type === 'level') + .sort((left, right) => left.level - right.level) +} + +export function resolveCurrentBuildingId({ + buildingId, + levelId, + nodes, +}: { + buildingId: BuildingNode['id'] | null + levelId: LevelNode['id'] | null + nodes: Record +}): BuildingNode['id'] | null { + if (buildingId) return buildingId + if (!levelId) return null + + const level = nodes[levelId as AnyNodeId] + if ( + level?.type === 'level' && + level.parentId && + nodes[level.parentId as AnyNodeId]?.type === 'building' + ) { + return level.parentId as BuildingNode['id'] + } + + return null +} + +export function resolveElevatorSupportLevelId({ + buildingId, + preferredLevelId, +}: { + buildingId: string | null | undefined + preferredLevelId?: string | null +}): LevelNode['id'] | null { + const nodes = useScene.getState().nodes + const preferred = preferredLevelId ? nodes[preferredLevelId as AnyNodeId] : undefined + const levels = getBuildingLevels(buildingId, nodes) + const preferredInBuilding = preferredLevelId + ? levels.find((level) => level.id === preferredLevelId) + : undefined + + if (preferredInBuilding) return preferredInBuilding.id + if (levels.length === 0) return preferred?.type === 'level' ? preferred.id : null + + return levels[0]?.id ?? null +} + +export function resolveElevatorSupportY({ + buildingId, + preferredLevelId, + x, + z, +}: { + buildingId: string | null | undefined + preferredLevelId?: string | null + x: number + z: number +}): number { + const levelId = resolveElevatorSupportLevelId({ buildingId, preferredLevelId }) + if (!levelId) return 0 + + return Math.max(0, spatialGridManager.getSlabElevationAt(levelId, x, z)) +} + +export function resolveElevatorNodeSupportY( + node: ElevatorNode, + position: [number, number, number] = node.position, +): number { + return resolveElevatorSupportY({ + buildingId: node.parentId, + preferredLevelId: node.fromLevelId ?? node.defaultLevelId, + x: position[0], + z: position[2], + }) +} diff --git a/packages/editor/src/lib/floorplan/selection-tool.ts b/packages/editor/src/lib/floorplan/selection-tool.ts index 15e133f03..56e7aa48b 100644 --- a/packages/editor/src/lib/floorplan/selection-tool.ts +++ b/packages/editor/src/lib/floorplan/selection-tool.ts @@ -2,6 +2,7 @@ import type { CeilingNode, ColumnNode, DoorNode, + ElevatorNode, ItemNode, Point2D, RoofNode, @@ -59,6 +60,11 @@ type ColumnEntry = { polygon: Point2D[] } +type ElevatorEntry = { + elevator: ElevatorNode + polygon: Point2D[] +} + type RoofEntry = { roof: RoofNode segments: Array<{ @@ -78,6 +84,7 @@ type FloorplanSelectionToolContext = { slabs: SlabEntry[] ceilings: CeilingEntry[] columns: ColumnEntry[] + elevators: ElevatorEntry[] roofs: RoofEntry[] openingHitTolerance: number wallHitTolerance: number @@ -130,6 +137,13 @@ export function getFloorplanHitNodeId(context: FloorplanSelectionToolContext) { return stairHit.stair.id } + const elevatorHit = context.elevators.find(({ polygon }) => + isPointInsidePolygon(context.point, polygon), + ) + if (elevatorHit) { + return elevatorHit.elevator.id + } + const columnHit = context.columns.find(({ polygon }) => isPointInsidePolygon(context.point, polygon), ) @@ -181,6 +195,7 @@ type FloorplanSelectionBoundsContext = { slabs: SlabEntry[] ceilings: CeilingEntry[] columns: ColumnEntry[] + elevators: ElevatorEntry[] stairs: StairEntry[] roofs: RoofEntry[] } @@ -195,6 +210,7 @@ export function getFloorplanSelectionIdsInBounds({ slabs, ceilings, columns, + elevators, stairs, roofs, }: FloorplanSelectionBoundsContext) { @@ -223,6 +239,9 @@ export function getFloorplanSelectionIdsInBounds({ const columnIds = columns .filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds)) .map(({ column }) => column.id) + const elevatorIds = elevators + .filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds)) + .map(({ elevator }) => elevator.id) const stairIds = stairs .filter((stair) => getStairHitPolygons(stair).some((polygon) => @@ -244,6 +263,7 @@ export function getFloorplanSelectionIdsInBounds({ ...slabIds, ...ceilingIds, ...columnIds, + ...elevatorIds, ...stairIds, ...roofIds, ]), diff --git a/packages/editor/src/lib/history.ts b/packages/editor/src/lib/history.ts index bdd37fbbc..281a476e3 100644 --- a/packages/editor/src/lib/history.ts +++ b/packages/editor/src/lib/history.ts @@ -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() diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 21a735f10..03d963c08 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -7,6 +7,7 @@ import { type CeilingNode, type ColumnNode, type DoorNode, + type ElevatorNode, type FenceNode, type ItemNode, type LevelNode, @@ -27,7 +28,6 @@ import { import { useViewer } from '@pascal-app/viewer' import { create } from 'zustand' import { persist } from 'zustand/middleware' -import { getDefaultCatalogItem } from '../components/ui/item-catalog/catalog-items' import { type ActivePaintMaterial, type PaintableMaterialTarget, @@ -58,6 +58,7 @@ export type StructureTool = | 'ceiling' | 'roof' | 'column' + | 'elevator' | 'stair' | 'item' | 'zone' @@ -138,6 +139,7 @@ type EditorState = { | ItemNode | WindowNode | DoorNode + | ElevatorNode | CeilingNode | ColumnNode | SlabNode @@ -155,6 +157,7 @@ type EditorState = { | ItemNode | WindowNode | DoorNode + | ElevatorNode | CeilingNode | ColumnNode | SlabNode @@ -453,9 +456,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'] }) + } } } } @@ -559,12 +567,15 @@ const useEditor = create()( | ItemNode | WindowNode | DoorNode + | ElevatorNode | CeilingNode + | ColumnNode | SlabNode | WallNode | FenceNode | RoofNode | RoofSegmentNode + | SpawnNode | StairNode | StairSegmentNode | BuildingNode diff --git a/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx b/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx new file mode 100644 index 000000000..facfe5c82 --- /dev/null +++ b/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx @@ -0,0 +1,1310 @@ +import { + type AnyNode, + type AnyNodeId, + type ElevatorDoorSide, + type ElevatorNode, + getElevatorCabDepth, + getElevatorCabWidth, + getElevatorDoorLeafSides, + getElevatorDoorLeafWidth, + getElevatorDoorLeafX, + getElevatorShaftDepth, + getElevatorShaftWallThickness, + getElevatorShaftWidth, + getResolvedElevatorDoorPanelStyle as getResolvedDoorPanelStyle, + getResolvedElevatorDoorStyle as getResolvedDoorStyle, + getResolvedElevatorShaftStyle as getResolvedShaftStyle, + resolveElevatorLevels, + useInteractive, + useLiveNodeOverrides, + useLiveTransforms, + useRegistry, + useScene, +} from '@pascal-app/core' +import { useFrame } from '@react-three/fiber' +import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' +import { + BoxGeometry, + CylinderGeometry, + type Group, + type InstancedMesh, + MeshStandardMaterial, + Object3D, + TorusGeometry, +} from 'three' +import { useShallow } from 'zustand/react/shallow' +import { useNodeEvents } from '../../../hooks/use-node-events' + +const SHAFT_WALL_COLOR = '#d7dce4' +const SHAFT_SIDE_COLOR = '#4b5563' +const SHAFT_TRIM_COLOR = '#eef2f7' +const CAB_COLOR = '#d7dde5' +const GLASS_COLOR = '#f8fafc' +const DOOR_COLOR = '#8e98a6' +const PANEL_COLOR = '#1f2937' + +type Vector3Tuple = [number, number, number] + +const UNIT_BOX_GEOMETRY = new BoxGeometry(1, 1, 1) +const BUTTON_FACE_GEOMETRY = new CylinderGeometry(1, 0.92, 1, 24) +const BUTTON_GLOW_GEOMETRY = new CylinderGeometry(1.42, 1.42, 1, 24) +const BUTTON_RING_GEOMETRY = new TorusGeometry(1.12, 0.12, 8, 24) +const LABEL_MATRIX_DUMMY = new Object3D() +const SHAFT_TOP_FRAME_CLEARANCE = 0.006 + +type ElevatorDoorPanelStyleValue = ElevatorNode['doorPanelStyle'] +type ElevatorDoorStyleValue = ElevatorNode['doorStyle'] + +const SHAFT_WALL_MATERIAL = new MeshStandardMaterial({ + color: SHAFT_WALL_COLOR, + metalness: 0.08, + roughness: 0.56, +}) +const SHAFT_SIDE_MATERIAL = new MeshStandardMaterial({ + color: SHAFT_SIDE_COLOR, + metalness: 0.12, + roughness: 0.58, +}) +const SHAFT_TRIM_MATERIAL = new MeshStandardMaterial({ + color: SHAFT_TRIM_COLOR, + metalness: 0.2, + roughness: 0.38, +}) +const CAB_MATERIAL = new MeshStandardMaterial({ + color: CAB_COLOR, + metalness: 0.2, + roughness: 0.48, +}) +const DOOR_MATERIAL = new MeshStandardMaterial({ + color: DOOR_COLOR, + metalness: 0.34, + roughness: 0.34, +}) +const DOOR_GROOVE_MATERIAL = new MeshStandardMaterial({ + color: '#5f6978', + metalness: 0.28, + roughness: 0.42, +}) +const GLASS_MATERIAL = new MeshStandardMaterial({ + color: GLASS_COLOR, + depthWrite: false, + metalness: 0, + opacity: 0.2, + roughness: 0.08, + transparent: true, +}) +const PANEL_MATERIAL = new MeshStandardMaterial({ + color: PANEL_COLOR, + metalness: 0.32, + roughness: 0.36, +}) +const LANDING_PANEL_MATERIAL = new MeshStandardMaterial({ + color: PANEL_COLOR, + metalness: 0.25, + roughness: 0.4, +}) +const INDICATOR_SCREEN_MATERIALS = { + active: new MeshStandardMaterial({ + color: '#041f2f', + emissive: '#0ea5e9', + emissiveIntensity: 0.16, + metalness: 0.12, + roughness: 0.38, + }), + idle: new MeshStandardMaterial({ + color: '#111827', + metalness: 0.12, + roughness: 0.38, + }), +} +const INDICATOR_GLYPH_MATERIALS = { + active: new MeshStandardMaterial({ + color: '#38bdf8', + emissive: '#38bdf8', + emissiveIntensity: 0.36, + metalness: 0.08, + roughness: 0.32, + }), + idle: new MeshStandardMaterial({ + color: '#94a3b8', + emissive: '#94a3b8', + emissiveIntensity: 0.18, + metalness: 0.08, + roughness: 0.32, + }), +} +const BUTTON_FACE_MATERIALS = { + active: new MeshStandardMaterial({ + color: '#38bdf8', + emissive: '#38bdf8', + emissiveIntensity: 0.28, + metalness: 0.22, + roughness: 0.3, + }), + queued: new MeshStandardMaterial({ + color: '#fbbf24', + emissive: '#fbbf24', + emissiveIntensity: 0.18, + metalness: 0.22, + roughness: 0.3, + }), + idle: new MeshStandardMaterial({ + color: '#d6dde7', + metalness: 0.22, + roughness: 0.3, + }), + disabled: new MeshStandardMaterial({ + color: '#475569', + metalness: 0.12, + roughness: 0.52, + }), +} +const BUTTON_RING_MATERIALS = { + active: new MeshStandardMaterial({ + color: '#0ea5e9', + emissive: '#0ea5e9', + emissiveIntensity: 0.16, + metalness: 0.48, + roughness: 0.28, + }), + queued: new MeshStandardMaterial({ + color: '#f59e0b', + emissive: '#f59e0b', + emissiveIntensity: 0.1, + metalness: 0.48, + roughness: 0.28, + }), + idle: new MeshStandardMaterial({ + color: '#64748b', + metalness: 0.48, + roughness: 0.28, + }), + disabled: new MeshStandardMaterial({ + color: '#334155', + metalness: 0.28, + roughness: 0.5, + }), +} +const BUTTON_GLOW_MATERIALS = { + active: new MeshStandardMaterial({ + color: '#38bdf8', + depthWrite: false, + emissive: '#38bdf8', + emissiveIntensity: 0.28, + opacity: 0.58, + transparent: true, + }), + queued: new MeshStandardMaterial({ + color: '#fbbf24', + depthWrite: false, + emissive: '#fbbf24', + emissiveIntensity: 0.18, + opacity: 0.58, + transparent: true, + }), +} +const BUTTON_LABEL_MATERIALS = { + lit: new MeshStandardMaterial({ + color: '#111827', + metalness: 0.12, + roughness: 0.34, + }), + idle: new MeshStandardMaterial({ + color: '#334155', + metalness: 0.12, + roughness: 0.34, + }), + disabled: new MeshStandardMaterial({ + color: '#94a3b8', + metalness: 0.08, + roughness: 0.5, + }), +} +const QUEUE_STRIP_MATERIALS = { + queued: new MeshStandardMaterial({ + color: '#fbbf24', + emissive: '#fbbf24', + emissiveIntensity: 0.16, + metalness: 0.18, + roughness: 0.42, + }), + idle: new MeshStandardMaterial({ + color: '#64748b', + metalness: 0.18, + roughness: 0.42, + }), +} + +type ElevatorButtonAction = 'open-door' | 'request-level' + +type SegmentName = + | 'bottom' + | 'lowerLeft' + | 'lowerRight' + | 'middle' + | 'top' + | 'upperLeft' + | 'upperRight' + +const DIGIT_SEGMENTS: Record = { + '0': ['top', 'upperLeft', 'upperRight', 'lowerLeft', 'lowerRight', 'bottom'], + '1': ['upperRight', 'lowerRight'], + '2': ['top', 'upperRight', 'middle', 'lowerLeft', 'bottom'], + '3': ['top', 'upperRight', 'middle', 'lowerRight', 'bottom'], + '4': ['upperLeft', 'upperRight', 'middle', 'lowerRight'], + '5': ['top', 'upperLeft', 'middle', 'lowerRight', 'bottom'], + '6': ['top', 'upperLeft', 'middle', 'lowerLeft', 'lowerRight', 'bottom'], + '7': ['top', 'upperRight', 'lowerRight'], + '8': ['top', 'upperLeft', 'upperRight', 'middle', 'lowerLeft', 'lowerRight', 'bottom'], + '9': ['top', 'upperLeft', 'upperRight', 'middle', 'lowerRight', 'bottom'], + '-': ['middle'], +} + +const SEGMENT_PROPS: Record< + SegmentName, + { position: [number, number, number]; size: [number, number, number] } +> = { + bottom: { position: [0, -0.44, 0], size: [0.56, 0.11, 0.018] }, + lowerLeft: { position: [-0.32, -0.22, 0], size: [0.11, 0.42, 0.018] }, + lowerRight: { position: [0.32, -0.22, 0], size: [0.11, 0.42, 0.018] }, + middle: { position: [0, 0, 0], size: [0.52, 0.1, 0.018] }, + top: { position: [0, 0.44, 0], size: [0.56, 0.11, 0.018] }, + upperLeft: { position: [-0.32, 0.22, 0], size: [0.11, 0.42, 0.018] }, + upperRight: { position: [0.32, 0.22, 0], size: [0.11, 0.42, 0.018] }, +} + +function BoxPrimitive({ + castShadow = false, + material, + position, + receiveShadow = false, + rotation, + scale, +}: { + castShadow?: boolean + material: MeshStandardMaterial + position?: Vector3Tuple + receiveShadow?: boolean + rotation?: Vector3Tuple + scale: Vector3Tuple +}) { + return ( + + ) +} + +function MeshButtonLabel({ + faceSign = 1, + label, + material, + position, + scale, +}: { + faceSign?: -1 | 1 + label: string + material: MeshStandardMaterial + position: [number, number, number] + scale: number +}) { + const ref = useRef(null) + const instances = useMemo(() => { + const characters = label.split('').filter((character) => DIGIT_SEGMENTS[character]) + const spacing = 0.72 * scale + const startX = -((characters.length - 1) * spacing) / 2 + + return characters.flatMap((character, charIndex) => + (DIGIT_SEGMENTS[character] ?? []).map((segment) => { + const props = SEGMENT_PROPS[segment] + return { + position: [ + faceSign * (startX + charIndex * spacing + props.position[0] * scale), + props.position[1] * scale, + props.position[2], + ] as Vector3Tuple, + scale: [props.size[0] * scale, props.size[1] * scale, props.size[2]] as Vector3Tuple, + } + }), + ) + }, [faceSign, label, scale]) + + const applyInstanceMatrices = useCallback( + (mesh: InstancedMesh) => { + for (let index = 0; index < instances.length; index += 1) { + const instance = instances[index] + if (!instance) continue + LABEL_MATRIX_DUMMY.position.set(...instance.position) + LABEL_MATRIX_DUMMY.rotation.set(0, 0, 0) + LABEL_MATRIX_DUMMY.scale.set(...instance.scale) + LABEL_MATRIX_DUMMY.updateMatrix() + mesh.setMatrixAt(index, LABEL_MATRIX_DUMMY.matrix) + } + mesh.instanceMatrix.needsUpdate = true + }, + [instances], + ) + + useLayoutEffect(() => { + const mesh = ref.current + if (!mesh) return + applyInstanceMatrices(mesh) + }, [applyInstanceMatrices]) + + if (instances.length === 0) return null + + return ( + + ) +} + +function ElevatorDirectionGlyph({ + direction, + material, + position, + scale, +}: { + direction: 'down' | 'up' | null + material: MeshStandardMaterial + position: [number, number, number] + scale: number +}) { + if (!direction) { + return ( + + ) + } + + const ySign = direction === 'up' ? -1 : 1 + return ( + + + + + ) +} + +function ElevatorFloorIndicator({ + active, + direction, + faceSign = -1, + label, + position, + scale = 1, + showReadout = true, +}: { + active: boolean + direction: 'down' | 'up' | null + faceSign?: -1 | 1 + label: string + position: [number, number, number] + scale?: number + showReadout?: boolean +}) { + const glyphMaterial = active ? INDICATOR_GLYPH_MATERIALS.active : INDICATOR_GLYPH_MATERIALS.idle + const screenMaterial = active + ? INDICATOR_SCREEN_MATERIALS.active + : INDICATOR_SCREEN_MATERIALS.idle + const displayLabel = label || '-' + const screenZ = faceSign * 0.026 * scale + const glyphZ = faceSign * 0.041 * scale + + return ( + + + + {showReadout ? ( + <> + + + + ) : ( + + )} + + ) +} + +function DoorOpenGlyph({ + material, + positionZ, + scale, +}: { + material: MeshStandardMaterial + positionZ: number + scale: number +}) { + return ( + + + + + + + + + ) +} + +function ElevatorMeshButton({ + action = 'request-level', + active, + buttonKind, + disabled = false, + elevatorId, + faceSign = -1, + glyph, + label, + levelId, + position, + queued, + radius = 0.055, +}: { + action?: ElevatorButtonAction + active: boolean + buttonKind: 'cab' | 'landing' + disabled?: boolean + elevatorId: AnyNodeId + faceSign?: -1 | 1 + glyph?: 'door-open' + label?: string + levelId?: AnyNodeId + position: [number, number, number] + queued: boolean + radius?: number +}) { + const state = disabled ? 'disabled' : active ? 'active' : queued ? 'queued' : 'idle' + const depth = active ? 0.028 : 0.04 + const faceZ = faceSign * (depth / 2 + 0.004) + const labelMaterial = disabled + ? BUTTON_LABEL_MATERIALS.disabled + : active || queued + ? BUTTON_LABEL_MATERIALS.lit + : BUTTON_LABEL_MATERIALS.idle + const userData = useMemo( + () => ({ + elevatorButton: { + action, + disabled, + elevatorId, + kind: buttonKind, + levelId, + }, + }), + [action, buttonKind, disabled, elevatorId, levelId], + ) + + return ( + + {!disabled && (active || queued) && ( + + )} + + + {label && ( + + )} + {glyph === 'door-open' && ( + + )} + + ) +} + +function getElevatorLevelContextNodes( + elevator: ElevatorNode, + nodes: ReturnType['nodes'], +): Record { + const result: Record = {} + const building = elevator.parentId ? nodes[elevator.parentId as AnyNodeId] : null + if (building?.type !== 'building') return result as Record + + result[building.id] = building + + for (const childId of building.children) { + const level = nodes[childId as AnyNodeId] + if (level?.type !== 'level') continue + + result[level.id] = level + for (const levelChildId of level.children) { + const child = nodes[levelChildId as AnyNodeId] + if (child?.type === 'ceiling' || child?.type === 'wall') { + result[child.id] = child + } + } + } + + return result as Record +} + +function DoorLeaf({ + animated, + doorOpen, + doorPanelStyle, + doorStyle, + height, + side, + width, + y, + z, +}: { + animated?: + | { + elevatorId: AnyNodeId + kind: 'cab' + } + | { + elevatorId: AnyNodeId + kind: 'landing' + levelId: AnyNodeId + } + doorOpen: number + doorPanelStyle: ElevatorDoorPanelStyleValue + doorStyle: ElevatorDoorStyleValue + height: number + side: ElevatorDoorSide + width: number + y: number + z: number +}) { + const ref = useRef(null) + const getLeafX = (openAmount: number) => getElevatorDoorLeafX(side, width, openAmount, doorStyle) + const leafWidth = getElevatorDoorLeafWidth(width, doorStyle) + const resolvedPanelStyle = getResolvedDoorPanelStyle(doorPanelStyle) + const railHeight = Math.min(0.09, Math.max(0.055, height * 0.04)) + const stileWidth = Math.min(0.07, Math.max(0.04, leafWidth * 0.18)) + const glassWidth = Math.max(leafWidth - stileWidth * 2.2, 0.03) + const glassHeight = Math.max(height - railHeight * 3, 0.2) + const panelInsetWidth = Math.max(leafWidth - 0.12, 0.05) + const panelInsetHeight = Math.max(height - 0.26, 0.2) + const segmentCount = 4 + const segmentSpacing = panelInsetHeight / segmentCount + + useFrame(() => { + if (!(animated && ref.current)) return + const runtime = useInteractive.getState().elevators[animated.elevatorId] + const nextDoorOpen = + animated.kind === 'cab' + ? (runtime?.doorOpen ?? 0) + : runtime?.currentLevelId === animated.levelId + ? (runtime?.doorOpen ?? 0) + : 0 + ref.current.position.x = getLeafX(nextDoorOpen) + }, 2.6) + + return ( + + {resolvedPanelStyle === 'glass-frame' ? ( + <> + + + + + + + ) : ( + <> + + + {resolvedPanelStyle === 'segmented-panel' + ? Array.from({ length: segmentCount - 1 }).map((_, index) => ( + + )) + : null} + + + + )} + + ) +} + +function ElevatorDoorLeaves({ + animated, + doorOpen, + doorPanelStyle, + doorStyle, + height, + width, + y, + z, +}: { + animated?: + | { + elevatorId: AnyNodeId + kind: 'cab' + } + | { + elevatorId: AnyNodeId + kind: 'landing' + levelId: AnyNodeId + } + doorOpen: number + doorPanelStyle: ElevatorDoorPanelStyleValue + doorStyle: ElevatorDoorStyleValue + height: number + width: number + y: number + z: number +}) { + return ( + <> + {getElevatorDoorLeafSides(doorStyle).map((side) => ( + + ))} + + ) +} + +function LandingDoorFrame({ + doorHeight, + doorWidth, + levelTopY, + levelY, + shaftWidth, + z, +}: { + doorHeight: number + doorWidth: number + levelTopY: number + levelY: number + shaftWidth: number + z: number +}) { + const wallDepth = 0.09 + const levelHeight = Math.max(levelTopY - levelY, 0.01) + const jambWidth = Math.max((shaftWidth - doorWidth) / 2, 0.08) + const jambCenterOffset = doorWidth / 2 + jambWidth / 2 + const headerHeight = Math.max(levelTopY - (levelY + doorHeight), 0) + const trim = 0.055 + + return ( + <> + + + {headerHeight > 0.01 && ( + + )} + + + + + + ) +} + +function LandingDoor({ + animated, + doorPanelStyle, + doorStyle, + elevatorId, + doorOpen, + doorHeight, + doorWidth, + levelId, + levelY, + z, +}: { + animated: boolean + doorPanelStyle: ElevatorDoorPanelStyleValue + doorStyle: ElevatorDoorStyleValue + elevatorId: AnyNodeId + doorOpen: number + doorHeight: number + doorWidth: number + levelId: AnyNodeId + levelY: number + z: number +}) { + return ( + + ) +} + +export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { + const ref = useRef(null!) + const cabRef = useRef(null) + const handlers = useNodeEvents(node, 'elevator') + const liveOverrides = useLiveNodeOverrides((state) => state.get(node.id)) + const liveTransform = useLiveTransforms((state) => state.get(node.id)) + const renderNode = useMemo( + () => (liveOverrides ? ({ ...node, ...liveOverrides } as ElevatorNode) : node), + [liveOverrides, node], + ) + const levelContextNodes = useScene( + useShallow((state) => getElevatorLevelContextNodes(renderNode, state.nodes)), + ) + + useRegistry(node.id, 'elevator', ref) + + const { entries, defaultEntry, shaftBaseY, totalHeight } = useMemo( + () => resolveElevatorLevels(renderNode, levelContextNodes), + [renderNode, levelContextNodes], + ) + const elevatorId = node.id as AnyNodeId + const runtimeStatus = useInteractive( + useShallow((state) => { + const runtime = state.elevators[elevatorId] + if (!runtime) return null + return { + currentLevelId: runtime.currentLevelId, + phase: runtime.phase, + queue: runtime.queue, + targetLevelId: runtime.targetLevelId, + } + }), + ) + + useFrame(() => { + if (!cabRef.current) return + const runtime = useInteractive.getState().elevators[elevatorId] + if (!runtime) return + cabRef.current.position.y = runtime.carY + }, 2.6) + + const cabWidth = getElevatorCabWidth(renderNode) + const cabDepth = getElevatorCabDepth(renderNode) + const shaftWidth = getElevatorShaftWidth(renderNode, cabWidth) + const shaftDepth = getElevatorShaftDepth(renderNode, cabDepth) + const cabHeight = Math.max(renderNode.cabHeight, 1.4) + const shaftWallThickness = getElevatorShaftWallThickness(renderNode) + const doorWidth = Math.min( + Math.max(renderNode.doorWidth, 0.45), + cabWidth - 0.18, + shaftWidth - 0.18, + ) + const doorHeight = Math.min(Math.max(renderNode.doorHeight, 1.2), cabHeight - 0.1) + const doorPanelStyle = getResolvedDoorPanelStyle(renderNode.doorPanelStyle) + const doorStyle = getResolvedDoorStyle(renderNode.doorStyle) + const shaftStyle = getResolvedShaftStyle(renderNode.shaftStyle) + const shaftShellMaterial = shaftStyle === 'glass' ? GLASS_MATERIAL : SHAFT_SIDE_MATERIAL + const shaftTopMaterial = shaftStyle === 'glass' ? SHAFT_TRIM_MATERIAL : SHAFT_SIDE_MATERIAL + const shaftHeight = Math.max(totalHeight, cabHeight + 0.3) + const shaftBodyHeight = Math.max(shaftHeight - shaftWallThickness, 0.01) + const shaftBodyCenterY = shaftBaseY + shaftBodyHeight / 2 + const shaftTopCapBottomY = shaftBaseY + shaftHeight - shaftWallThickness + const shaftFrameTopY = Math.max(shaftBaseY, shaftTopCapBottomY - SHAFT_TOP_FRAME_CLEARANCE) + const runtimeSnapshot = useInteractive.getState().elevators[elevatorId] + const cabBaseY = runtimeSnapshot?.carY ?? defaultEntry?.baseY ?? 0 + const activeLevelId = + runtimeStatus?.currentLevelId ?? runtimeSnapshot?.currentLevelId ?? defaultEntry?.id ?? null + const pendingLevelId = + runtimeStatus?.targetLevelId ?? + runtimeSnapshot?.targetLevelId ?? + runtimeStatus?.queue[0] ?? + runtimeSnapshot?.queue[0] ?? + null + const currentEntry = + entries.find((entry) => entry.id === activeLevelId) ?? defaultEntry ?? entries[0] ?? null + const pendingEntry = pendingLevelId ? entries.find((entry) => entry.id === pendingLevelId) : null + const indicatorEntry = pendingEntry ?? currentEntry + const indicatorDirection = + currentEntry && pendingEntry && Math.abs(pendingEntry.baseY - currentEntry.baseY) > 0.001 + ? pendingEntry.baseY > currentEntry.baseY + ? 'up' + : 'down' + : null + const indicatorActive = Boolean( + pendingEntry || + runtimeStatus?.phase === 'moving' || + runtimeSnapshot?.phase === 'moving' || + runtimeStatus?.phase === 'opening' || + runtimeSnapshot?.phase === 'opening', + ) + const queuedLevelIds = useMemo(() => { + const next = new Set() + for (const levelId of runtimeStatus?.queue ?? runtimeSnapshot?.queue ?? []) next.add(levelId) + const targetLevelId = runtimeStatus?.targetLevelId ?? runtimeSnapshot?.targetLevelId + if (targetLevelId) next.add(targetLevelId) + return next + }, [ + runtimeSnapshot?.queue, + runtimeSnapshot?.targetLevelId, + runtimeStatus?.queue, + runtimeStatus?.targetLevelId, + ]) + const disabledLevelIds = useMemo( + () => new Set(renderNode.disabledLevelIds ?? []), + [renderNode.disabledLevelIds], + ) + const serviceOnlyLevelIds = useMemo( + () => new Set(renderNode.serviceOnlyLevelIds ?? []), + [renderNode.serviceOnlyLevelIds], + ) + const doorOpen = runtimeSnapshot?.doorOpen ?? 0 + const doorOpenButtonActive = + doorOpen > 0.12 || + runtimeStatus?.phase === 'opening' || + runtimeSnapshot?.phase === 'opening' || + runtimeStatus?.phase === 'open' || + runtimeSnapshot?.phase === 'open' + const frontWallZ = -shaftDepth / 2 - shaftWallThickness / 2 + const frontZ = frontWallZ - shaftWallThickness / 2 - 0.018 + const landingPanelX = Math.min(shaftWidth / 2 - 0.16, doorWidth / 2 + 0.18) + const cabCenterZ = -shaftDepth / 2 + cabDepth / 2 + const cabPanelX = cabWidth / 2 - 0.075 + const cabPanelZ = cabCenterZ - cabDepth / 2 + 0.36 + const cabButtonColumns = entries.length > 1 ? 2 : 1 + const cabButtonRows = Math.max(1, Math.ceil(entries.length / cabButtonColumns)) + const cabButtonSpacingX = 0.14 + const cabButtonSpacingY = 0.15 + const cabDoorButtonOffsetX = 0.17 + const cabFloorButtonOffsetX = entries.length > 0 ? -cabDoorButtonOffsetX / 2 : 0 + const cabDoorButtonX = + cabFloorButtonOffsetX + ((cabButtonColumns - 1) / 2) * cabButtonSpacingX + cabDoorButtonOffsetX + const cabDoorButtonY = -((cabButtonRows - 1) / 2) * cabButtonSpacingY + const cabPanelWidth = cabButtonColumns * cabButtonSpacingX + 0.13 + cabDoorButtonOffsetX + const cabPanelHeight = cabButtonRows * cabButtonSpacingY + 0.12 + const panelRelativeY = Math.min(Math.max(doorHeight * 0.6, 0.95), cabHeight - 0.35) + const cabPanelY = panelRelativeY + const entrySpans = useMemo( + () => + entries.map((entry, index) => { + const nextEntry = entries[index + 1] + const minDoorFrameTopY = entry.baseY + doorHeight + 0.12 + const targetTopY = Math.max(nextEntry?.baseY ?? shaftFrameTopY, minDoorFrameTopY) + + return { + entry, + levelTopY: nextEntry ? targetTopY : Math.min(targetTopY, shaftFrameTopY), + } + }), + [doorHeight, entries, shaftFrameTopY], + ) + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + {entries.map((entry, index) => { + const column = index % cabButtonColumns + const row = Math.floor(index / cabButtonColumns) + const isDisabledLevel = disabledLevelIds.has(entry.id) + const x = + cabFloorButtonOffsetX + (column - (cabButtonColumns - 1) / 2) * cabButtonSpacingX + const y = (row - (cabButtonRows - 1) / 2) * cabButtonSpacingY + + return ( + + ) + })} + + + + + {entrySpans.map(({ entry, levelTopY }) => { + const isCurrentLevel = activeLevelId === entry.id + const isDisabledLevel = disabledLevelIds.has(entry.id) + const isServiceOnlyLevel = serviceOnlyLevelIds.has(entry.id) + const isQueuedLevel = !isDisabledLevel && queuedLevelIds.has(entry.id) + const isPendingLevel = pendingLevelId === entry.id + const showLandingReadout = isCurrentLevel || isPendingLevel || isQueuedLevel + + return ( + + + + + + + 0.5} + buttonKind="landing" + disabled={isDisabledLevel || isServiceOnlyLevel} + elevatorId={elevatorId} + levelId={entry.id as AnyNodeId} + position={[0, 0.06, -0.045]} + queued={isQueuedLevel} + radius={0.045} + /> + + + + ) + })} + + ) +} diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx index adf2abc1c..f2045fb49 100644 --- a/packages/viewer/src/components/renderers/node-renderer.tsx +++ b/packages/viewer/src/components/renderers/node-renderer.tsx @@ -5,6 +5,7 @@ import { BuildingRenderer } from './building/building-renderer' import { CeilingRenderer } from './ceiling/ceiling-renderer' import { ColumnRenderer } from './column/column-renderer' import { DoorRenderer } from './door/door-renderer' +import { ElevatorRenderer } from './elevator/elevator-renderer' import { FenceRenderer } from './fence/fence-renderer' import { GuideRenderer } from './guide/guide-renderer' import { ItemRenderer } from './item/item-renderer' @@ -32,6 +33,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => { {node.type === 'building' && } {node.type === 'ceiling' && } {node.type === 'column' && } + {node.type === 'elevator' && } {node.type === 'level' && } {node.type === 'item' && } {node.type === 'slab' && } diff --git a/packages/viewer/src/components/renderers/site/site-renderer.tsx b/packages/viewer/src/components/renderers/site/site-renderer.tsx index 9f77b8789..fc66eea62 100644 --- a/packages/viewer/src/components/renderers/site/site-renderer.tsx +++ b/packages/viewer/src/components/renderers/site/site-renderer.tsx @@ -2,6 +2,7 @@ import { useRegistry, useScene, type SiteNode, type SlabNode } from '@pascal-app import { useMemo, useRef } from 'react' import { BufferGeometry, Float32BufferAttribute, Path, Shape, type Group } from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' +import { unionPolygons } from '../../../lib/polygon-union' import useViewer from '../../../store/use-viewer' import { NodeRenderer } from '../node-renderer' @@ -87,16 +88,15 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i]![0], -pts[i]![1]) shape.closePath() - for (const polygon of slabPolygons) { - if (polygon.length < 3) continue - - const hole = new Path() - hole.moveTo(polygon[0]![0], -polygon[0]![1]) - for (let i = 1; i < polygon.length; i++) { - hole.lineTo(polygon[i]![0], -polygon[i]![1]) + if (slabPolygons.length > 0) { + for (const ring of unionPolygons(slabPolygons.map((p) => p.map((pt) => [pt[0], -pt[1]])))) { + if (ring.length < 3) continue + const hole = new Path() + hole.moveTo(ring[0]![0], ring[0]![1]) + for (let i = 1; i < ring.length; i++) hole.lineTo(ring[i]![0], ring[i]![1]) + hole.closePath() + shape.holes.push(hole) } - hole.closePath() - shape.holes.push(hole) } return shape diff --git a/packages/viewer/src/components/viewer/ground-occluder.tsx b/packages/viewer/src/components/viewer/ground-occluder.tsx index c90cafa67..a4c91dfbd 100644 --- a/packages/viewer/src/components/viewer/ground-occluder.tsx +++ b/packages/viewer/src/components/viewer/ground-occluder.tsx @@ -1,6 +1,7 @@ import { type LevelNode, useScene } from '@pascal-app/core' import { useMemo } from 'react' import * as THREE from 'three' +import { unionPolygons } from '../../lib/polygon-union' import useViewer from '../../store/use-viewer' export const GroundOccluder = () => { @@ -62,16 +63,18 @@ export const GroundOccluder = () => { polygons.push(node.polygon as [number, number][]) }) - for (const polygon of polygons) { - if (polygon.length < 3) continue + if (polygons.length > 0) { + for (const ring of unionPolygons(polygons.map((pts) => pts.map((p) => [p[0], -p[1]])))) { + if (ring.length < 3) continue + const hole = new THREE.Path() - const hole = new THREE.Path() - hole.moveTo(polygon[0]![0], -polygon[0]![1]) - for (let i = 1; i < polygon.length; i++) { - hole.lineTo(polygon[i]![0], -polygon[i]![1]) + hole.moveTo(ring[0]![0], ring[0]![1]) + for (let i = 1; i < ring.length; i++) { + hole.lineTo(ring[i]![0], ring[i]![1]) + } + hole.closePath() + s.holes.push(hole) } - hole.closePath() - s.holes.push(hole) } return s diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index c7b64c96c..e1aee9b1b 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -1,6 +1,7 @@ 'use client' -import { Canvas, extend, useFrame, useThree, type ThreeToJSXElements } from '@react-three/fiber' +import { ElevatorOpeningSystem, ElevatorRuntimeSystem } from '@pascal-app/core' +import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three/webgpu' import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf' @@ -8,6 +9,7 @@ import useViewer from '../../store/use-viewer' import { CeilingSystem } from '../../systems/ceiling/ceiling-system' import { DoorAnimationSystem } from '../../systems/door/door-animation-system' import { DoorSystem } from '../../systems/door/door-system' +import { ElevatorInteractionSystem } from '../../systems/elevator/elevator-interaction-system' import { FenceSystem } from '../../systems/fence/fence-system' import { GuideSystem } from '../../systems/guide/guide-system' import { ItemLightSystem } from '../../systems/item-light/item-light-system' @@ -106,7 +108,7 @@ function GPUDeviceWatcher() { useEffect(() => { const backend = (gl as any).backend - const device: GPUDevice | undefined = backend?.device + const device = backend?.device as WebGPUDeviceLike | undefined if (!device) { console.warn('[viewer] No WebGPU device on backend — running on a fallback renderer.', { @@ -121,22 +123,22 @@ function GPUDeviceWatcher() { features: Array.from(device.features ?? []), }) - device.lost.then((info) => { + device.lost.then((info: WebGPUDeviceLossInfo) => { console.error( - `[viewer] WebGPU device lost: reason="${info.reason}", message="${info.message}". ` + + `[viewer] WebGPU device lost: reason="${info.reason ?? 'unknown'}", message="${info.message ?? ''}". ` + 'The page must be reloaded to recover the GPU context.', ) }) // Uncaptured errors are normally silent (only console-warned by Chrome at // best). Pipe them to console.error so silent mobile crashes show up. - const onUncapturedError = (event: GPUUncapturedErrorEvent) => { - console.error('[viewer] WebGPU uncaptured error:', event.error.message, event.error) + const onUncapturedError = (event: any) => { + console.error('[viewer] WebGPU uncaptured error:', event?.error?.message, event?.error) } - device.addEventListener('uncapturederror', onUncapturedError as EventListener) + device.addEventListener?.('uncapturederror', onUncapturedError) return () => { - device.removeEventListener('uncapturederror', onUncapturedError as EventListener) + device.removeEventListener?.('uncapturederror', onUncapturedError) } }, [gl]) @@ -175,24 +177,12 @@ const Viewer: React.FC = ({ const canvas = props.canvas const cached = canvas ? WEBGPU_RENDERER_CACHE.get(canvas) : undefined if (cached) return cached - // Surface the env we're about to ask WebGPU for — catches "no - // navigator.gpu" / "adapter request failed" silently failing in - // mobile WebViews where WebGPU is gated behind flags. - const hasGpu = typeof navigator !== 'undefined' && 'gpu' in navigator - console.log('[viewer] Creating WebGPURenderer', { - hasNavigatorGPU: hasGpu, - ua: typeof navigator !== 'undefined' ? navigator.userAgent : 'n/a', - }) const promise = (async () => { try { const renderer = new THREE.WebGPURenderer(props as any) renderer.toneMapping = THREE.ACESFilmicToneMapping renderer.toneMappingExposure = 0.9 await renderer.init() - console.log('[viewer] WebGPURenderer ready', { - backend: (renderer as any).backend?.constructor?.name, - isWebGPU: (renderer as any).isWebGPURenderer === true, - }) return renderer } catch (err) { // Drop the failed promise from the cache so a future Canvas @@ -240,6 +230,9 @@ const Viewer: React.FC = ({ {/* Core systems */} + + + diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 36d84755a..b64ffabfa 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -154,6 +154,7 @@ const PostProcessingPasses = ({ // Subscribe to projectId so the pipeline rebuilds on project switch const projectId = useViewer((s) => s.projectId) + const lastProjectIdRef = useRef(projectId) // Bump this to force a pipeline rebuild (used by retry logic) const [pipelineVersion, setPipelineVersion] = useState(0) @@ -169,6 +170,8 @@ const PostProcessingPasses = ({ // Reset retry state when project changes useEffect(() => { + if (lastProjectIdRef.current === projectId) return + lastProjectIdRef.current = projectId retryCountRef.current = 0 if (rebuildTimeoutRef.current !== null) { clearTimeout(rebuildTimeoutRef.current) @@ -231,6 +234,21 @@ const PostProcessingPasses = ({ hasPipelineErrorRef.current = false + // WebGPU availability check: SSGI, denoise, and RenderPipeline are all + // WebGPU-only APIs. When the browser falls back to WebGL2 (no + // `navigator.gpu`, or the device couldn't be created), building the + // pipeline either throws silently or produces a broken output where + // the scene renders for a few frames and then goes black as the retry + // loop fights the direct-render fallback path. Short-circuit here so + // `useFrame` uses the direct `renderer.render(scene, camera)` path + // exclusively and never attempts the TSL pipeline. + const hasWebGPU = typeof navigator !== 'undefined' && 'gpu' in navigator + if (!hasWebGPU) { + hasPipelineErrorRef.current = true + renderPipelineRef.current = null + return + } + // Clear outliner arrays synchronously to prevent stale Object3D refs // from the previous project leaking into the new pipeline's outline passes. const outliner = useViewer.getState().outliner @@ -361,7 +379,6 @@ const PostProcessingPasses = ({ renderPipeline.outputNode = finalOutput renderPipelineRef.current = renderPipeline retryCountRef.current = 0 - console.log('[viewer/post-processing] Pipeline built OK', { version: pipelineVersion }) } catch (error) { hasPipelineErrorRef.current = true console.error( @@ -388,6 +405,7 @@ const PostProcessingPasses = ({ }, [ camera, hoverHiddenColor, + hoverHighlightMode, hoverPulseMix, hoverStrength, hoverVisibleColor, @@ -463,9 +481,6 @@ const PostProcessingPasses = ({ if (retryCountRef.current < MAX_PIPELINE_RETRIES) { // Auto-retry: schedule a pipeline rebuild if we haven't exceeded the retry limit retryCountRef.current++ - console.warn( - `[viewer/post-processing] Scheduling pipeline rebuild (attempt ${retryCountRef.current}/${MAX_PIPELINE_RETRIES})`, - ) if (rebuildTimeoutRef.current !== null) { clearTimeout(rebuildTimeoutRef.current) } diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index 18487347e..e1e105039 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -7,6 +7,8 @@ import { type ColumnNode, type DoorEvent, type DoorNode, + type ElevatorEvent, + type ElevatorNode, type EventSuffix, emitter, type FenceEvent, @@ -57,6 +59,7 @@ type NodeConfig = { 'stair-segment': { node: StairSegmentNode; event: StairSegmentEvent } window: { node: WindowNode; event: WindowEvent } door: { node: DoorNode; event: DoorEvent } + elevator: { node: ElevatorNode; event: ElevatorEvent } } type NodeType = keyof NodeConfig diff --git a/packages/viewer/src/lib/polygon-union.test.ts b/packages/viewer/src/lib/polygon-union.test.ts new file mode 100644 index 000000000..6bbaaa0e5 --- /dev/null +++ b/packages/viewer/src/lib/polygon-union.test.ts @@ -0,0 +1,77 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// depend on @types/bun so the import type is unresolved at compile time. +import { describe, expect, test } from 'bun:test' +import { type Point2D, unionPolygons } from './polygon-union' + +function polygonArea(points: Point2D[]) { + let area = 0 + for (let i = 0; i < points.length; i++) { + const current = points[i]! + const next = points[(i + 1) % points.length]! + area += current[0] * next[1] - next[0] * current[1] + } + return Math.abs(area / 2) +} + +describe('unionPolygons', () => { + test('collapses a contained polygon into the containing polygon', () => { + const small: Point2D[] = [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], + ] + const large: Point2D[] = [ + [-1, -1], + [2, -1], + [2, 2], + [-1, 2], + ] + + const result = unionPolygons([small, large]) + + expect(result).toHaveLength(1) + expect(polygonArea(result[0]!)).toBeCloseTo(9) + }) + + test('combines overlapping rectangles into one boundary', () => { + const left: Point2D[] = [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ] + const right: Point2D[] = [ + [1, 1], + [3, 1], + [3, 3], + [1, 3], + ] + + const result = unionPolygons([left, right]) + + expect(result).toHaveLength(1) + expect(result[0]).toHaveLength(8) + expect(polygonArea(result[0]!)).toBeCloseTo(7) + }) + + test('keeps disjoint polygons as separate boundaries', () => { + const left: Point2D[] = [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], + ] + const right: Point2D[] = [ + [2, 0], + [3, 0], + [3, 1], + [2, 1], + ] + + const result = unionPolygons([left, right]) + + expect(result).toHaveLength(2) + expect(result.map(polygonArea)).toEqual([1, 1]) + }) +}) diff --git a/packages/viewer/src/lib/polygon-union.ts b/packages/viewer/src/lib/polygon-union.ts new file mode 100644 index 000000000..f078ccdd2 --- /dev/null +++ b/packages/viewer/src/lib/polygon-union.ts @@ -0,0 +1,294 @@ +export type Point2D = [number, number] + +const EPSILON = 1e-7 +const KEY_SCALE = 1e6 + +type Edge = { + start: Point2D + end: Point2D + polygonIndex: number + splits: number[] +} + +type Segment = { + start: Point2D + end: Point2D + used: boolean +} + +function pointsEqual(a: Point2D, b: Point2D, tolerance = EPSILON) { + return Math.hypot(a[0] - b[0], a[1] - b[1]) <= tolerance +} + +function pointKey(point: Point2D) { + return `${Math.round(point[0] * KEY_SCALE)}:${Math.round(point[1] * KEY_SCALE)}` +} + +function interpolate(a: Point2D, b: Point2D, t: number): Point2D { + return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t] +} + +function cross(ax: number, ay: number, bx: number, by: number) { + return ax * by - ay * bx +} + +function polygonArea(points: Point2D[]) { + let area = 0 + for (let i = 0; i < points.length; i++) { + const current = points[i]! + const next = points[(i + 1) % points.length]! + area += current[0] * next[1] - next[0] * current[1] + } + return area / 2 +} + +function pointOnSegment(point: Point2D, start: Point2D, end: Point2D) { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const crossValue = cross(point[0] - start[0], point[1] - start[1], dx, dz) + if (Math.abs(crossValue) > EPSILON) return false + + const dot = + (point[0] - start[0]) * (point[0] - end[0]) + (point[1] - start[1]) * (point[1] - end[1]) + return dot <= EPSILON +} + +function pointInPolygon(point: Point2D, polygon: Point2D[]) { + let inside = false + + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const pi = polygon[i]! + const pj = polygon[j]! + + if (pointOnSegment(point, pj, pi)) return false + + const intersects = + pi[1] > point[1] !== pj[1] > point[1] && + point[0] < ((pj[0] - pi[0]) * (point[1] - pi[1])) / (pj[1] - pi[1]) + pi[0] + + if (intersects) inside = !inside + } + + return inside +} + +function normalizeRing(ring: Point2D[]) { + const normalized: Point2D[] = [] + + for (const [x, z] of ring) { + if (!Number.isFinite(x) || !Number.isFinite(z)) continue + + const point: Point2D = [x, z] + const previous = normalized[normalized.length - 1] + if (!previous || !pointsEqual(previous, point)) { + normalized.push(point) + } + } + + const first = normalized[0] + const last = normalized[normalized.length - 1] + if (first && last && pointsEqual(first, last)) { + normalized.pop() + } + + if (normalized.length < 3 || Math.abs(polygonArea(normalized)) <= EPSILON) return [] + return polygonArea(normalized) < 0 ? [...normalized].reverse() : normalized +} + +function addSplit(edge: Edge, t: number) { + if (t < -EPSILON || t > 1 + EPSILON) return + const clamped = Math.max(0, Math.min(1, t)) + if (edge.splits.some((split) => Math.abs(split - clamped) <= EPSILON)) return + edge.splits.push(clamped) +} + +function parameterOnEdge(point: Point2D, edge: Edge) { + const dx = edge.end[0] - edge.start[0] + const dz = edge.end[1] - edge.start[1] + const lengthSquared = dx * dx + dz * dz + if (lengthSquared <= EPSILON) return 0 + return ((point[0] - edge.start[0]) * dx + (point[1] - edge.start[1]) * dz) / lengthSquared +} + +function addIntersectionSplits(left: Edge, right: Edge) { + const rx = left.end[0] - left.start[0] + const rz = left.end[1] - left.start[1] + const sx = right.end[0] - right.start[0] + const sz = right.end[1] - right.start[1] + const qpx = right.start[0] - left.start[0] + const qpz = right.start[1] - left.start[1] + const denominator = cross(rx, rz, sx, sz) + const numerator = cross(qpx, qpz, rx, rz) + + if (Math.abs(denominator) <= EPSILON) { + if (Math.abs(numerator) > EPSILON) return + + for (const point of [left.start, left.end, right.start, right.end]) { + if ( + pointOnSegment(point, left.start, left.end) && + pointOnSegment(point, right.start, right.end) + ) { + addSplit(left, parameterOnEdge(point, left)) + addSplit(right, parameterOnEdge(point, right)) + } + } + return + } + + const t = cross(qpx, qpz, sx, sz) / denominator + const u = cross(qpx, qpz, rx, rz) / denominator + if (t < -EPSILON || t > 1 + EPSILON || u < -EPSILON || u > 1 + EPSILON) return + + addSplit(left, t) + addSplit(right, u) +} + +function buildEdges(polygons: Point2D[][]) { + const edges: Edge[] = [] + + polygons.forEach((polygon, polygonIndex) => { + for (let i = 0; i < polygon.length; i++) { + edges.push({ + start: polygon[i]!, + end: polygon[(i + 1) % polygon.length]!, + polygonIndex, + splits: [0, 1], + }) + } + }) + + for (let i = 0; i < edges.length; i++) { + for (let j = i + 1; j < edges.length; j++) { + const left = edges[i]! + const right = edges[j]! + if (left.polygonIndex === right.polygonIndex) continue + addIntersectionSplits(left, right) + } + } + + return edges +} + +function buildBoundarySegments(edges: Edge[], polygons: Point2D[][]) { + const segments: Segment[] = [] + + for (const edge of edges) { + const splits = [...edge.splits].sort((a, b) => a - b) + + for (let i = 0; i < splits.length - 1; i++) { + const startT = splits[i]! + const endT = splits[i + 1]! + if (endT - startT <= EPSILON) continue + + const start = interpolate(edge.start, edge.end, startT) + const end = interpolate(edge.start, edge.end, endT) + const mid = interpolate(edge.start, edge.end, (startT + endT) / 2) + const insideAnother = polygons.some( + (polygon, index) => index !== edge.polygonIndex && pointInPolygon(mid, polygon), + ) + + if (!insideAnother) { + segments.push({ start, end, used: false }) + } + } + } + + return removeDuplicateInteriorSegments(segments) +} + +function segmentKey(segment: Segment) { + const start = pointKey(segment.start) + const end = pointKey(segment.end) + return start < end ? `${start}|${end}` : `${end}|${start}` +} + +function removeDuplicateInteriorSegments(segments: Segment[]) { + const groups = new Map() + for (const segment of segments) { + const key = segmentKey(segment) + const group = groups.get(key) + if (group) { + group.push(segment) + } else { + groups.set(key, [segment]) + } + } + + const result: Segment[] = [] + for (const group of groups.values()) { + if (group.length === 1) { + result.push(group[0]!) + continue + } + + const firstStart = pointKey(group[0]!.start) + const firstEnd = pointKey(group[0]!.end) + const hasOppositeDirection = group.some( + (segment) => pointKey(segment.start) === firstEnd && pointKey(segment.end) === firstStart, + ) + + if (!hasOppositeDirection) { + result.push(group[0]!) + } + } + + return result +} + +function assembleRings(segments: Segment[]) { + const byStart = new Map() + for (const segment of segments) { + const key = pointKey(segment.start) + const group = byStart.get(key) + if (group) { + group.push(segment) + } else { + byStart.set(key, [segment]) + } + } + + const rings: Point2D[][] = [] + + for (const firstSegment of segments) { + if (firstSegment.used) continue + + firstSegment.used = true + const ring: Point2D[] = [firstSegment.start, firstSegment.end] + const startKey = pointKey(firstSegment.start) + let currentKey = pointKey(firstSegment.end) + + while (currentKey !== startKey) { + const next = byStart.get(currentKey)?.find((segment) => !segment.used) + if (!next) break + + next.used = true + ring.push(next.end) + currentKey = pointKey(next.end) + } + + if (currentKey !== startKey) continue + + const last = ring[ring.length - 1] + if (last && pointsEqual(ring[0]!, last)) { + ring.pop() + } + + const normalized = normalizeRing(ring) + if (normalized.length >= 3) { + rings.push(normalized) + } + } + + return rings +} + +export function unionPolygons(polygons: Point2D[][]): Point2D[][] { + const validPolygons = polygons.map(normalizeRing).filter((polygon) => polygon.length >= 3) + if (validPolygons.length <= 1) return validPolygons + + const edges = buildEdges(validPolygons) + const segments = buildBoundarySegments(edges, validPolygons) + const rings = assembleRings(segments) + + return rings.length > 0 ? rings : validPolygons +} diff --git a/packages/viewer/src/systems/ceiling/ceiling-system.tsx b/packages/viewer/src/systems/ceiling/ceiling-system.tsx index c15d15258..d6fc0a0a1 100644 --- a/packages/viewer/src/systems/ceiling/ceiling-system.tsx +++ b/packages/viewer/src/systems/ceiling/ceiling-system.tsx @@ -1,6 +1,7 @@ import { type AnyNodeId, type CeilingNode, sceneRegistry, useScene } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import * as THREE from 'three' +import { mergeSurfaceHolePolygons } from '../surface-hole-geometry' function ensureUv2Attribute(geometry: THREE.BufferGeometry) { const uv = geometry.getAttribute('uv') @@ -82,7 +83,7 @@ export function generateCeilingGeometry(ceilingNode: CeilingNode): THREE.BufferG shape.closePath() // Add holes to the shape - const holes = ceilingNode.holes || [] + const holes = mergeSurfaceHolePolygons(ceilingNode.holes || []) for (const holePolygon of holes) { if (holePolygon.length < 3) continue diff --git a/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx b/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx new file mode 100644 index 000000000..ded3d4999 --- /dev/null +++ b/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx @@ -0,0 +1,93 @@ +import { + type AnyNodeId, + openElevatorDoor, + resolveElevatorDispatchTarget, + requestElevatorLevel, + useInteractive, + useScene, +} from '@pascal-app/core' +import { useThree } from '@react-three/fiber' +import { useEffect, useRef } from 'react' +import { type Object3D, Raycaster, Vector2 } from 'three' + +type ElevatorButtonAction = 'open-door' | 'request-level' + +type ElevatorButtonUserData = { + action: ElevatorButtonAction + disabled: boolean + elevatorId: AnyNodeId + kind: 'cab' | 'landing' + levelId?: AnyNodeId +} + +function getElevatorButtonData(object: Object3D): ElevatorButtonUserData | null { + let current: Object3D | null = object + + while (current) { + const data = (current.userData as { elevatorButton?: ElevatorButtonUserData }).elevatorButton + if (data) return data + current = current.parent + } + + return null +} + +export function ElevatorInteractionSystem() { + const camera = useThree((state) => state.camera) + const gl = useThree((state) => state.gl) + const scene = useThree((state) => state.scene) + const raycasterRef = useRef(new Raycaster()) + const pointerRef = useRef(new Vector2()) + + useEffect(() => { + const canvas = gl.domElement + + const handlePointerDown = (event: PointerEvent) => { + if (event.button !== 0) return + + const rect = canvas.getBoundingClientRect() + const pointer = pointerRef.current + pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1 + pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1 + + const raycaster = raycasterRef.current + raycaster.setFromCamera(pointer, camera) + + const button = raycaster + .intersectObjects(scene.children, true) + .map((intersection) => getElevatorButtonData(intersection.object)) + .find((data): data is ElevatorButtonUserData => data !== null) + + if (!button || button.disabled) return + + event.preventDefault() + event.stopPropagation() + + if (button.action === 'open-door') { + openElevatorDoor(button.elevatorId) + return + } + + if (!button.levelId) return + + const targetElevatorId = + button.kind === 'landing' + ? resolveElevatorDispatchTarget({ + elevators: useInteractive.getState().elevators, + levelId: button.levelId, + nodes: useScene.getState().nodes, + requestedElevatorId: button.elevatorId, + }) + : button.elevatorId + + requestElevatorLevel(targetElevatorId, button.levelId) + } + + canvas.addEventListener('pointerdown', handlePointerDown, true) + return () => { + canvas.removeEventListener('pointerdown', handlePointerDown, true) + } + }, [camera, gl, scene]) + + return null +} diff --git a/packages/viewer/src/systems/slab/slab-system.tsx b/packages/viewer/src/systems/slab/slab-system.tsx index 3d866445e..403f0438e 100644 --- a/packages/viewer/src/systems/slab/slab-system.tsx +++ b/packages/viewer/src/systems/slab/slab-system.tsx @@ -8,6 +8,7 @@ import { import { useFrame } from '@react-three/fiber' import { useEffect } from 'react' import * as THREE from 'three' +import { mergeSurfaceHolePolygons } from '../surface-hole-geometry' function ensureUv2Attribute(geometry: THREE.BufferGeometry) { const uv = geometry.getAttribute('uv') @@ -86,6 +87,7 @@ export function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry { function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry { const polygon = getRenderableSlabPolygon(slabNode) const elevation = slabNode.elevation ?? 0.05 + const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? []) if (polygon.length < 3) return new THREE.BufferGeometry() @@ -94,7 +96,7 @@ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry for (let i = 1; i < polygon.length; i++) shape.lineTo(polygon[i]![0], -polygon[i]![1]) shape.closePath() - for (const holePolygon of slabNode.holes ?? []) { + for (const holePolygon of holePolygons) { if (holePolygon.length < 3) continue const holePath = new THREE.Path() holePath.moveTo(holePolygon[0]![0], -holePolygon[0]![1]) @@ -122,6 +124,7 @@ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry { const polygon = getRenderableSlabPolygon(slabNode) const depth = Math.abs(slabNode.elevation ?? 0.05) + const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? []) if (polygon.length < 3) return new THREE.BufferGeometry() @@ -134,7 +137,7 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry { for (const [x, z] of polygon) { bounds.expandByPoint(new THREE.Vector2(x, z)) } - for (const hole of slabNode.holes ?? []) { + for (const hole of holePolygons) { for (const [x, z] of hole) { bounds.expandByPoint(new THREE.Vector2(x, z)) } @@ -157,8 +160,8 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry { for (const [x, z] of polygon) pushFloorVertex(x!, 0, z!) const pts2d = polygon.map(([x, z]) => new THREE.Vector2(x!, z!)) - const holesPts2d = (slabNode.holes ?? []).map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!))) - for (const hole of slabNode.holes ?? []) { + const holesPts2d = holePolygons.map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!))) + for (const hole of holePolygons) { for (const [x, z] of hole) pushFloorVertex(x!, 0, z!) } diff --git a/packages/viewer/src/systems/surface-hole-geometry.ts b/packages/viewer/src/systems/surface-hole-geometry.ts new file mode 100644 index 000000000..bd514ea00 --- /dev/null +++ b/packages/viewer/src/systems/surface-hole-geometry.ts @@ -0,0 +1,5 @@ +import { type Point2D, unionPolygons } from '../lib/polygon-union' + +export function mergeSurfaceHolePolygons(holes: Point2D[][]): Point2D[][] { + return unionPolygons(holes) +}