Merge pull request #305 from sudhir9297/feat/elevator-system
Add procedural elevator system with floorplan, runtime, and first-person support
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Elevator">
|
||||||
|
<rect x="12" y="6" width="40" height="52" rx="5" fill="#d9e2ec"/>
|
||||||
|
<rect x="16" y="10" width="32" height="44" rx="2" fill="#64748b"/>
|
||||||
|
<path d="M32 10v44" stroke="#d9e2ec" stroke-width="2.5"/>
|
||||||
|
<rect x="20" y="15" width="10" height="15" rx="1.5" fill="#9bd7ff" opacity=".9"/>
|
||||||
|
<rect x="34" y="15" width="10" height="15" rx="1.5" fill="#9bd7ff" opacity=".9"/>
|
||||||
|
<circle cx="23" cy="41" r="2.2" fill="#fbbf24"/>
|
||||||
|
<circle cx="41" cy="41" r="2.2" fill="#fbbf24"/>
|
||||||
|
<path d="M26 48h12" stroke="#d9e2ec" stroke-linecap="round" stroke-width="3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 649 B |
@@ -6,6 +6,7 @@ import type {
|
|||||||
CeilingNode,
|
CeilingNode,
|
||||||
ColumnNode,
|
ColumnNode,
|
||||||
DoorNode,
|
DoorNode,
|
||||||
|
ElevatorNode,
|
||||||
FenceNode,
|
FenceNode,
|
||||||
GuideNode,
|
GuideNode,
|
||||||
ItemNode,
|
ItemNode,
|
||||||
@@ -66,6 +67,7 @@ export type StairEvent = NodeEvent<StairNode>
|
|||||||
export type StairSegmentEvent = NodeEvent<StairSegmentNode>
|
export type StairSegmentEvent = NodeEvent<StairSegmentNode>
|
||||||
export type WindowEvent = NodeEvent<WindowNode>
|
export type WindowEvent = NodeEvent<WindowNode>
|
||||||
export type DoorEvent = NodeEvent<DoorNode>
|
export type DoorEvent = NodeEvent<DoorNode>
|
||||||
|
export type ElevatorEvent = NodeEvent<ElevatorNode>
|
||||||
|
|
||||||
// Event suffixes - exported for use in hooks
|
// Event suffixes - exported for use in hooks
|
||||||
export const eventSuffixes = [
|
export const eventSuffixes = [
|
||||||
@@ -183,6 +185,7 @@ type EditorEvents = GridEvents &
|
|||||||
NodeEvents<'item', ItemEvent> &
|
NodeEvents<'item', ItemEvent> &
|
||||||
NodeEvents<'site', SiteEvent> &
|
NodeEvents<'site', SiteEvent> &
|
||||||
NodeEvents<'building', BuildingEvent> &
|
NodeEvents<'building', BuildingEvent> &
|
||||||
|
NodeEvents<'elevator', ElevatorEvent> &
|
||||||
NodeEvents<'level', LevelEvent> &
|
NodeEvents<'level', LevelEvent> &
|
||||||
NodeEvents<'zone', ZoneEvent> &
|
NodeEvents<'zone', ZoneEvent> &
|
||||||
NodeEvents<'slab', SlabEvent> &
|
NodeEvents<'slab', SlabEvent> &
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const sceneRegistry = {
|
|||||||
building: new Set<string>(),
|
building: new Set<string>(),
|
||||||
ceiling: new Set<string>(),
|
ceiling: new Set<string>(),
|
||||||
column: new Set<string>(),
|
column: new Set<string>(),
|
||||||
|
elevator: new Set<string>(),
|
||||||
level: new Set<string>(),
|
level: new Set<string>(),
|
||||||
wall: new Set<string>(),
|
wall: new Set<string>(),
|
||||||
fence: new Set<string>(),
|
fence: new Set<string>(),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type {
|
|||||||
CeilingEvent,
|
CeilingEvent,
|
||||||
ColumnEvent,
|
ColumnEvent,
|
||||||
DoorEvent,
|
DoorEvent,
|
||||||
|
ElevatorEvent,
|
||||||
EventSuffix,
|
EventSuffix,
|
||||||
FenceEvent,
|
FenceEvent,
|
||||||
GridEvent,
|
GridEvent,
|
||||||
@@ -73,13 +74,56 @@ export {
|
|||||||
type ControlValue,
|
type ControlValue,
|
||||||
type DoorAnimationState,
|
type DoorAnimationState,
|
||||||
type DoorInteractiveState,
|
type DoorInteractiveState,
|
||||||
|
type ElevatorInteractiveState,
|
||||||
|
type ElevatorPhase,
|
||||||
type ItemInteractiveState,
|
type ItemInteractiveState,
|
||||||
useInteractive,
|
useInteractive,
|
||||||
type WindowAnimationState,
|
type WindowAnimationState,
|
||||||
type WindowInteractiveState,
|
type WindowInteractiveState,
|
||||||
} from './store/use-interactive'
|
} 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 { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
|
||||||
export { clearSceneHistory, default as useScene } from './store/use-scene'
|
export { clearSceneHistory, default as useScene } from './store/use-scene'
|
||||||
|
export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch'
|
||||||
|
export {
|
||||||
|
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 { syncAutoStairOpenings } from './systems/stair/stair-opening-sync'
|
||||||
export {
|
export {
|
||||||
getClampedWallCurveOffset,
|
getClampedWallCurveOffset,
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ export {
|
|||||||
ColumnSupportStyle,
|
ColumnSupportStyle,
|
||||||
} from './nodes/column'
|
} from './nodes/column'
|
||||||
export { DoorNode, DoorSegment } from './nodes/door'
|
export { DoorNode, DoorSegment } from './nodes/door'
|
||||||
|
export {
|
||||||
|
ElevatorDoorPanelStyle,
|
||||||
|
ElevatorDoorStyle,
|
||||||
|
ElevatorNode,
|
||||||
|
ElevatorShaftStyle,
|
||||||
|
} from './nodes/elevator'
|
||||||
export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence'
|
export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence'
|
||||||
export { GuideNode, GuideScaleReference } from './nodes/guide'
|
export { GuideNode, GuideScaleReference } from './nodes/guide'
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import dedent from 'dedent'
|
import dedent from 'dedent'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { BaseNode, nodeType, objectId } from '../base'
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
|
import { ElevatorNode } from './elevator'
|
||||||
import { LevelNode } from './level'
|
import { LevelNode } from './level'
|
||||||
|
|
||||||
export const BuildingNode = BaseNode.extend({
|
export const BuildingNode = BaseNode.extend({
|
||||||
id: objectId('building'),
|
id: objectId('building'),
|
||||||
type: nodeType('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]),
|
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]),
|
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
}).describe(
|
}).describe(
|
||||||
@@ -14,7 +15,7 @@ export const BuildingNode = BaseNode.extend({
|
|||||||
Building node - used to represent a building
|
Building node - used to represent a building
|
||||||
- position: position in site coordinate system
|
- position: position in site coordinate system
|
||||||
- rotation: rotation 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
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const CeilingNode = BaseNode.extend({
|
|||||||
Ceiling node - used to represent a ceiling in the building
|
Ceiling node - used to represent a ceiling in the building
|
||||||
- polygon: array of [x, z] points defining the ceiling boundary
|
- polygon: array of [x, z] points defining the ceiling boundary
|
||||||
- holes: array of polygons representing holes in the ceiling
|
- 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
|
- autoFromWalls: whether the ceiling is automatically generated from a closed wall loop
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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<typeof ElevatorDoorPanelStyle>
|
||||||
|
export type ElevatorDoorStyle = z.infer<typeof ElevatorDoorStyle>
|
||||||
|
export type ElevatorShaftStyle = z.infer<typeof ElevatorShaftStyle>
|
||||||
|
|
||||||
|
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<typeof ElevatorNode>
|
||||||
@@ -19,7 +19,7 @@ export const SlabNode = BaseNode.extend({
|
|||||||
Slab node - used to represent a slab/floor in the building
|
Slab node - used to represent a slab/floor in the building
|
||||||
- polygon: array of [x, z] points defining the slab boundary
|
- polygon: array of [x, z] points defining the slab boundary
|
||||||
- holes: array of [x, z] polygons representing cutouts in the slab
|
- 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
|
- elevation: elevation in meters
|
||||||
- autoFromWalls: whether the slab is automatically generated from a closed wall loop
|
- autoFromWalls: whether the slab is automatically generated from a closed wall loop
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
export const SurfaceHoleMetadata = z.object({
|
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(),
|
stairId: z.string().optional(),
|
||||||
|
elevatorId: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type SurfaceHoleMetadata = z.infer<typeof SurfaceHoleMetadata>
|
export type SurfaceHoleMetadata = z.infer<typeof SurfaceHoleMetadata>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { BuildingNode } from './nodes/building'
|
|||||||
import { CeilingNode } from './nodes/ceiling'
|
import { CeilingNode } from './nodes/ceiling'
|
||||||
import { ColumnNode } from './nodes/column'
|
import { ColumnNode } from './nodes/column'
|
||||||
import { DoorNode } from './nodes/door'
|
import { DoorNode } from './nodes/door'
|
||||||
|
import { ElevatorNode } from './nodes/elevator'
|
||||||
import { FenceNode } from './nodes/fence'
|
import { FenceNode } from './nodes/fence'
|
||||||
import { GuideNode } from './nodes/guide'
|
import { GuideNode } from './nodes/guide'
|
||||||
import { ItemNode } from './nodes/item'
|
import { ItemNode } from './nodes/item'
|
||||||
@@ -22,6 +23,7 @@ import { ZoneNode } from './nodes/zone'
|
|||||||
export const AnyNode = z.discriminatedUnion('type', [
|
export const AnyNode = z.discriminatedUnion('type', [
|
||||||
SiteNode,
|
SiteNode,
|
||||||
BuildingNode,
|
BuildingNode,
|
||||||
|
ElevatorNode,
|
||||||
LevelNode,
|
LevelNode,
|
||||||
ColumnNode,
|
ColumnNode,
|
||||||
WallNode,
|
WallNode,
|
||||||
|
|||||||
@@ -39,12 +39,25 @@ export type WindowAnimationState = {
|
|||||||
persist: boolean
|
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 = {
|
type InteractiveStore = {
|
||||||
items: Record<AnyNodeId, ItemInteractiveState>
|
items: Record<AnyNodeId, ItemInteractiveState>
|
||||||
doors: Record<AnyNodeId, DoorInteractiveState>
|
doors: Record<AnyNodeId, DoorInteractiveState>
|
||||||
doorAnimations: Record<AnyNodeId, DoorAnimationState>
|
doorAnimations: Record<AnyNodeId, DoorAnimationState>
|
||||||
windows: Record<AnyNodeId, WindowInteractiveState>
|
windows: Record<AnyNodeId, WindowInteractiveState>
|
||||||
windowAnimations: Record<AnyNodeId, WindowAnimationState>
|
windowAnimations: Record<AnyNodeId, WindowAnimationState>
|
||||||
|
elevators: Record<AnyNodeId, ElevatorInteractiveState>
|
||||||
|
|
||||||
/** Initialize a node's interactive state from its asset definition (idempotent) */
|
/** Initialize a node's interactive state from its asset definition (idempotent) */
|
||||||
initItem: (itemId: AnyNodeId, interactive: Interactive) => void
|
initItem: (itemId: AnyNodeId, interactive: Interactive) => void
|
||||||
@@ -78,6 +91,15 @@ type InteractiveStore = {
|
|||||||
|
|
||||||
/** Cancel a queued window animation */
|
/** Cancel a queued window animation */
|
||||||
cancelWindowAnimation: (windowId: AnyNodeId) => void
|
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<ElevatorInteractiveState>) => void
|
||||||
|
|
||||||
|
/** Remove elevator runtime state when its renderer unmounts. */
|
||||||
|
removeElevator: (elevatorId: AnyNodeId) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultControlValue = (interactive: Interactive, index: number): ControlValue => {
|
const defaultControlValue = (interactive: Interactive, index: number): ControlValue => {
|
||||||
@@ -99,6 +121,7 @@ export const useInteractive = create<InteractiveStore>((set, get) => ({
|
|||||||
doorAnimations: {},
|
doorAnimations: {},
|
||||||
windows: {},
|
windows: {},
|
||||||
windowAnimations: {},
|
windowAnimations: {},
|
||||||
|
elevators: {},
|
||||||
|
|
||||||
initItem: (itemId, interactive) => {
|
initItem: (itemId, interactive) => {
|
||||||
const { controls } = interactive
|
const { controls } = interactive
|
||||||
@@ -203,4 +226,47 @@ export const useInteractive = create<InteractiveStore>((set, get) => ({
|
|||||||
return { windowAnimations: rest }
|
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 }
|
||||||
|
})
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
export type LiveNodeOverrides = Record<string, unknown>
|
||||||
|
|
||||||
|
type LiveNodeOverrideState = {
|
||||||
|
overrides: Map<string, LiveNodeOverrides>
|
||||||
|
set(nodeId: string, values: LiveNodeOverrides): void
|
||||||
|
get(nodeId: string): LiveNodeOverrides | undefined
|
||||||
|
clear(nodeId: string): void
|
||||||
|
clearAll(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const useLiveNodeOverrides = create<LiveNodeOverrideState>((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
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { AnyNode, AnyNodeId, ElevatorNode } from '../../schema'
|
||||||
|
import type { ElevatorInteractiveState } from '../../store/use-interactive'
|
||||||
|
import { resolveElevatorServiceLevels } from './elevator-service'
|
||||||
|
|
||||||
|
type ElevatorRuntimeMap = Record<AnyNodeId, ElevatorInteractiveState>
|
||||||
|
|
||||||
|
type ResolveElevatorDispatchTargetArgs = {
|
||||||
|
elevators: ElevatorRuntimeMap
|
||||||
|
levelId: AnyNodeId
|
||||||
|
nodes: Record<string, AnyNode>
|
||||||
|
requestedElevatorId: AnyNodeId
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRuntimeLevelId(runtime: ElevatorInteractiveState | undefined, elevator: ElevatorNode) {
|
||||||
|
return runtime?.currentLevelId ?? elevator.defaultLevelId ?? elevator.fromLevelId ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreDispatchCandidate({
|
||||||
|
elevator,
|
||||||
|
elevators,
|
||||||
|
levelId,
|
||||||
|
nodes,
|
||||||
|
}: {
|
||||||
|
elevator: ElevatorNode
|
||||||
|
elevators: ElevatorRuntimeMap
|
||||||
|
levelId: AnyNodeId
|
||||||
|
nodes: Record<string, AnyNode>
|
||||||
|
}) {
|
||||||
|
if ((elevator.disabledLevelIds ?? []).includes(levelId)) return null
|
||||||
|
if ((elevator.serviceOnlyLevelIds ?? []).includes(levelId)) return null
|
||||||
|
|
||||||
|
const serviceLevels = resolveElevatorServiceLevels(elevator, nodes)
|
||||||
|
const targetIndex = serviceLevels.findIndex((level) => level.id === levelId)
|
||||||
|
if (targetIndex < 0) return null
|
||||||
|
|
||||||
|
const runtime = elevators[elevator.id as AnyNodeId]
|
||||||
|
const runtimeLevelId = getRuntimeLevelId(runtime, elevator)
|
||||||
|
const currentIndex = runtimeLevelId
|
||||||
|
? serviceLevels.findIndex((level) => level.id === runtimeLevelId)
|
||||||
|
: -1
|
||||||
|
const resolvedCurrentIndex = currentIndex >= 0 ? currentIndex : 0
|
||||||
|
const distance = Math.abs(targetIndex - resolvedCurrentIndex)
|
||||||
|
const queuePenalty = (runtime?.queue.length ?? 0) * 2 + (runtime?.targetLevelId ? 1 : 0)
|
||||||
|
const motionPenalty = runtime?.phase === 'moving' || runtime?.phase === 'closing' ? 0.5 : 0
|
||||||
|
const openPenalty = runtime?.phase === 'open' || runtime?.phase === 'opening' ? 0.2 : 0
|
||||||
|
|
||||||
|
return distance + queuePenalty + motionPenalty + openPenalty
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveElevatorDispatchTarget({
|
||||||
|
elevators,
|
||||||
|
levelId,
|
||||||
|
nodes,
|
||||||
|
requestedElevatorId,
|
||||||
|
}: ResolveElevatorDispatchTargetArgs): AnyNodeId {
|
||||||
|
const requestedElevator = nodes[requestedElevatorId]
|
||||||
|
if (!(requestedElevator?.type === 'elevator' && requestedElevator.parentId)) {
|
||||||
|
return requestedElevatorId
|
||||||
|
}
|
||||||
|
|
||||||
|
const building = nodes[requestedElevator.parentId as AnyNodeId]
|
||||||
|
if (building?.type !== 'building') {
|
||||||
|
return requestedElevatorId
|
||||||
|
}
|
||||||
|
|
||||||
|
let bestElevatorId: AnyNodeId | null = null
|
||||||
|
let bestScore = Number.POSITIVE_INFINITY
|
||||||
|
|
||||||
|
for (const childId of building.children) {
|
||||||
|
const candidate = nodes[childId as AnyNodeId]
|
||||||
|
if (!(candidate?.type === 'elevator' && candidate.visible !== false)) continue
|
||||||
|
|
||||||
|
const score = scoreDispatchCandidate({
|
||||||
|
elevator: candidate,
|
||||||
|
elevators,
|
||||||
|
levelId,
|
||||||
|
nodes,
|
||||||
|
})
|
||||||
|
if (score === null) continue
|
||||||
|
|
||||||
|
const tieBreaker = candidate.id === requestedElevatorId ? -0.01 : 0
|
||||||
|
const finalScore = score + tieBreaker
|
||||||
|
if (finalScore < bestScore) {
|
||||||
|
bestScore = finalScore
|
||||||
|
bestElevatorId = candidate.id as AnyNodeId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestElevatorId ?? requestedElevatorId
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<string, AnyNode>
|
||||||
|
|
||||||
|
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<string, AnyNode>
|
||||||
|
|
||||||
|
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<string, AnyNode>
|
||||||
|
|
||||||
|
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' }])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string, AnyNode>) {
|
||||||
|
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<string, AnyNode>) {
|
||||||
|
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<string, AnyNode>,
|
||||||
|
) {
|
||||||
|
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<string, AnyNode>,
|
||||||
|
) {
|
||||||
|
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<string, AnyNode>) {
|
||||||
|
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<SlabNode | CeilingNode> }> = []
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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<string, AnyNode>,
|
||||||
|
prevNodes: Record<string, AnyNode>,
|
||||||
|
) {
|
||||||
|
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<typeof syncAutoElevatorOpenings>) => {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string, AnyNode>,
|
||||||
|
): 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<string, AnyNode>,
|
||||||
|
): 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, AnyNode>): 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<string, AnyNode>,
|
||||||
|
): LevelNode[] {
|
||||||
|
return getBuildingLevels(elevator, nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveElevatorServiceLevelIds(
|
||||||
|
elevator: ElevatorNode,
|
||||||
|
nodes: Record<string, AnyNode>,
|
||||||
|
): string[] {
|
||||||
|
return resolveElevatorServiceLevels(elevator, nodes).map((level) => level.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveElevatorServiceLevels(
|
||||||
|
elevator: ElevatorNode,
|
||||||
|
nodes: Record<string, AnyNode>,
|
||||||
|
): 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<string, AnyNode>): 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<string, AnyNode>,
|
||||||
|
): {
|
||||||
|
entries: ElevatorLevelEntry[]
|
||||||
|
defaultEntry: ElevatorLevelEntry | null
|
||||||
|
shaftBaseY: number
|
||||||
|
shaftTopY: number
|
||||||
|
totalHeight: number
|
||||||
|
} {
|
||||||
|
const allLevels = resolveElevatorBuildingLevels(elevator, nodes)
|
||||||
|
|
||||||
|
const baseYByLevelId = new Map<string, number>()
|
||||||
|
let cumulativeY = 0
|
||||||
|
for (const level of allLevels) {
|
||||||
|
baseYByLevelId.set(level.id, cumulativeY)
|
||||||
|
cumulativeY += 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
import type { AnyNode } from '../../schema'
|
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'
|
import { syncAutoStairOpenings } from './stair-opening-sync'
|
||||||
|
|
||||||
describe('syncAutoStairOpenings', () => {
|
describe('syncAutoStairOpenings', () => {
|
||||||
@@ -66,4 +73,190 @@ describe('syncAutoStairOpenings', () => {
|
|||||||
expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }])
|
expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }])
|
||||||
expect(bedroomUpdate).toBeUndefined()
|
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<string, AnyNode>
|
||||||
|
|
||||||
|
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<string, AnyNode>
|
||||||
|
|
||||||
|
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<string, AnyNode>
|
||||||
|
|
||||||
|
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' }])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,16 +6,12 @@ import type {
|
|||||||
SlabNode,
|
SlabNode,
|
||||||
StairNode,
|
StairNode,
|
||||||
StairSegmentNode,
|
StairSegmentNode,
|
||||||
|
SurfaceHoleMetadata,
|
||||||
} from '../../schema'
|
} from '../../schema'
|
||||||
import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint'
|
import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint'
|
||||||
|
|
||||||
type Point2D = [number, number]
|
type Point2D = [number, number]
|
||||||
|
|
||||||
type SurfaceHoleMetadata = {
|
|
||||||
source: 'manual' | 'stair'
|
|
||||||
stairId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type SegmentTransform = {
|
type SegmentTransform = {
|
||||||
position: [number, number, number]
|
position: [number, number, number]
|
||||||
rotation: number
|
rotation: number
|
||||||
@@ -66,6 +62,7 @@ function metadataEqual(left: SurfaceHoleMetadata[], right: SurfaceHoleMetadata[]
|
|||||||
return left.every(
|
return left.every(
|
||||||
(entry, index) =>
|
(entry, index) =>
|
||||||
entry.source === right[index]?.source &&
|
entry.source === right[index]?.source &&
|
||||||
|
(entry.elevatorId ?? null) === (right[index]?.elevatorId ?? null) &&
|
||||||
(entry.stairId ?? null) === (right[index]?.stairId ?? 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))
|
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 {
|
function getAxisAlignedRectFromPolygon(polygon: Point2D[]): AxisAlignedRect | null {
|
||||||
if (polygon.length < 4) return null
|
if (polygon.length < 4) return null
|
||||||
const xs = polygon.map(([x]) => x)
|
const xs = polygon.map(([x]) => x)
|
||||||
@@ -689,12 +690,10 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
|
|||||||
const slabLevelId = resolveLevelId(slab, nodes)
|
const slabLevelId = resolveLevelId(slab, nodes)
|
||||||
const existingHoles = slab.holes ?? []
|
const existingHoles = slab.holes ?? []
|
||||||
const existingMetadata = normalizeExistingMetadata(existingHoles, slab.holeMetadata)
|
const existingMetadata = normalizeExistingMetadata(existingHoles, slab.holeMetadata)
|
||||||
const manualHoles = existingHoles.filter(
|
const preservedHoles = existingHoles
|
||||||
(_hole, index) => existingMetadata[index]?.source !== 'stair',
|
.map((polygon, index) => ({ metadata: existingMetadata[index]!, polygon }))
|
||||||
)
|
.filter((entry) => entry.metadata.source !== 'stair')
|
||||||
const manualMetadata = existingMetadata
|
const preservedHolePolygons = preservedHoles.map((entry) => entry.polygon)
|
||||||
.filter((entry) => entry.source !== 'stair')
|
|
||||||
.map((entry) => ({ ...entry }))
|
|
||||||
|
|
||||||
const stairHoles = stairs
|
const stairHoles = stairs
|
||||||
.filter((stair) => shouldApplyStairToSlab(stair, slabLevelId, nodes))
|
.filter((stair) => shouldApplyStairToSlab(stair, slabLevelId, nodes))
|
||||||
@@ -718,9 +717,16 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.filter((hole) => polygonContainsPolygon(slab.polygon, hole.polygon))
|
.filter((hole) => polygonContainsPolygon(slab.polygon, hole.polygon))
|
||||||
|
.filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon))
|
||||||
|
|
||||||
const nextHoles = [...manualHoles, ...stairHoles.map((hole) => hole.polygon)]
|
const nextHoles = [
|
||||||
const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)]
|
...preservedHoles.map((hole) => hole.polygon),
|
||||||
|
...stairHoles.map((hole) => hole.polygon),
|
||||||
|
]
|
||||||
|
const nextMetadata = [
|
||||||
|
...preservedHoles.map((hole) => ({ ...hole.metadata })),
|
||||||
|
...stairHoles.map((hole) => hole.metadata),
|
||||||
|
]
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!(polygonsEqual(existingHoles, nextHoles) && metadataEqual(existingMetadata, nextMetadata))
|
!(polygonsEqual(existingHoles, nextHoles) && metadataEqual(existingMetadata, nextMetadata))
|
||||||
@@ -739,12 +745,10 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
|
|||||||
const ceilingLevelId = resolveLevelId(ceiling, nodes)
|
const ceilingLevelId = resolveLevelId(ceiling, nodes)
|
||||||
const existingHoles = ceiling.holes ?? []
|
const existingHoles = ceiling.holes ?? []
|
||||||
const existingMetadata = normalizeExistingMetadata(existingHoles, ceiling.holeMetadata)
|
const existingMetadata = normalizeExistingMetadata(existingHoles, ceiling.holeMetadata)
|
||||||
const manualHoles = existingHoles.filter(
|
const preservedHoles = existingHoles
|
||||||
(_hole, index) => existingMetadata[index]?.source !== 'stair',
|
.map((polygon, index) => ({ metadata: existingMetadata[index]!, polygon }))
|
||||||
)
|
.filter((entry) => entry.metadata.source !== 'stair')
|
||||||
const manualMetadata = existingMetadata
|
const preservedHolePolygons = preservedHoles.map((entry) => entry.polygon)
|
||||||
.filter((entry) => entry.source !== 'stair')
|
|
||||||
.map((entry) => ({ ...entry }))
|
|
||||||
|
|
||||||
const stairHoles = stairs
|
const stairHoles = stairs
|
||||||
.filter((stair) => shouldApplyStairToCeiling(stair, ceilingLevelId, nodes))
|
.filter((stair) => shouldApplyStairToCeiling(stair, ceilingLevelId, nodes))
|
||||||
@@ -768,9 +772,16 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.filter((hole) => polygonContainsPolygon(ceiling.polygon, hole.polygon))
|
.filter((hole) => polygonContainsPolygon(ceiling.polygon, hole.polygon))
|
||||||
|
.filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon))
|
||||||
|
|
||||||
const nextHoles = [...manualHoles, ...stairHoles.map((hole) => hole.polygon)]
|
const nextHoles = [
|
||||||
const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)]
|
...preservedHoles.map((hole) => hole.polygon),
|
||||||
|
...stairHoles.map((hole) => hole.polygon),
|
||||||
|
]
|
||||||
|
const nextMetadata = [
|
||||||
|
...preservedHoles.map((hole) => ({ ...hole.metadata })),
|
||||||
|
...stairHoles.map((hole) => hole.metadata),
|
||||||
|
]
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!(polygonsEqual(existingHoles, nextHoles) && metadataEqual(existingMetadata, nextMetadata))
|
!(polygonsEqual(existingHoles, nextHoles) && metadataEqual(existingMetadata, nextMetadata))
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export type FloorplanActionMenuEntry = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type FloorplanActionMenuLayerProps = {
|
type FloorplanActionMenuLayerProps = {
|
||||||
|
elevator: FloorplanActionMenuEntry
|
||||||
item: FloorplanActionMenuEntry
|
item: FloorplanActionMenuEntry
|
||||||
wall: FloorplanActionMenuEntry
|
wall: FloorplanActionMenuEntry
|
||||||
fence: FloorplanActionMenuEntry
|
fence: FloorplanActionMenuEntry
|
||||||
@@ -34,6 +35,7 @@ type FloorplanActionMenuLayerProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
|
export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
|
||||||
|
elevator,
|
||||||
item,
|
item,
|
||||||
wall,
|
wall,
|
||||||
fence,
|
fence,
|
||||||
@@ -56,6 +58,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const entries: FloorplanActionMenuEntry[] = [
|
const entries: FloorplanActionMenuEntry[] = [
|
||||||
|
elevator,
|
||||||
item,
|
item,
|
||||||
wall,
|
wall,
|
||||||
fence,
|
fence,
|
||||||
|
|||||||
@@ -1,12 +1,50 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import '../../three-types'
|
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 { useViewer } from '@pascal-app/viewer'
|
||||||
import { KeyboardControls } from '@react-three/drei'
|
import { KeyboardControls } from '@react-three/drei'
|
||||||
import { useFrame, useThree } from '@react-three/fiber'
|
import { useFrame, useThree } from '@react-three/fiber'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
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 {
|
import {
|
||||||
closeDoorOpenState,
|
closeDoorOpenState,
|
||||||
DOOR_SWING_OPEN_ANGLE,
|
DOOR_SWING_OPEN_ANGLE,
|
||||||
@@ -34,6 +72,12 @@ const LOOK_SENSITIVITY = 0.002
|
|||||||
const CONTROLLER_CENTER_FROM_EYE = 0.85
|
const CONTROLLER_CENTER_FROM_EYE = 0.85
|
||||||
const DOOR_INTERACTION_DISTANCE = 2.5
|
const DOOR_INTERACTION_DISTANCE = 2.5
|
||||||
const DOOR_LEAF_INTERACTION_DEPTH = 0.08
|
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 = [
|
const keyboardMap = [
|
||||||
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] },
|
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] },
|
||||||
{ name: 'backward', keys: ['ArrowDown', 'KeyS'] },
|
{ name: 'backward', keys: ['ArrowDown', 'KeyS'] },
|
||||||
@@ -59,14 +103,422 @@ const doorOpeningLocalHit = new Vector3()
|
|||||||
const doorOpeningLocalRay = new Ray()
|
const doorOpeningLocalRay = new Ray()
|
||||||
const doorOpeningMatrix = new Matrix4()
|
const doorOpeningMatrix = new Matrix4()
|
||||||
const doorOpeningWorldHit = new Vector3()
|
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 spawnWorldPosition = new Vector3()
|
||||||
const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
|
const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
|
||||||
const windowInteractionRaycaster = new Raycaster()
|
const windowInteractionRaycaster = new Raycaster()
|
||||||
|
|
||||||
type FirstPersonInteractableTarget = {
|
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
|
id: AnyNodeId
|
||||||
type: 'door' | 'window'
|
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<ReturnType<typeof useInteractive.getState>['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<string, AnyNode>) {
|
||||||
|
const level = nodes[levelId as AnyNodeId]
|
||||||
|
if (level?.type !== 'level') return DEFAULT_ELEVATOR_LEVEL_HEIGHT
|
||||||
|
|
||||||
|
let maxTop = 0
|
||||||
|
for (const childId of level.children) {
|
||||||
|
const child = nodes[childId as AnyNodeId]
|
||||||
|
if (!child) continue
|
||||||
|
|
||||||
|
if (child.type === 'ceiling') {
|
||||||
|
maxTop = Math.max(maxTop, child.height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child.type === 'wall') {
|
||||||
|
const meshY = Math.max(sceneRegistry.nodes.get(childId as AnyNodeId)?.position.y ?? 0, 0)
|
||||||
|
maxTop = Math.max(maxTop, meshY + (child.height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxTop > 0 ? maxTop : DEFAULT_ELEVATOR_LEVEL_HEIGHT
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveElevatorColliderLevels(elevator: ElevatorNode, nodes: Record<string, AnyNode>) {
|
||||||
|
const allLevels = resolveElevatorBuildingLevels(elevator, nodes)
|
||||||
|
|
||||||
|
const baseYByLevelId = new Map<string, number>()
|
||||||
|
let cumulativeY = 0
|
||||||
|
for (const level of allLevels) {
|
||||||
|
baseYByLevelId.set(level.id, cumulativeY)
|
||||||
|
cumulativeY += getFirstPersonLevelHeight(level.id, nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceLevels = resolveElevatorServiceLevels(elevator, nodes)
|
||||||
|
const entries = serviceLevels.map((level) => ({
|
||||||
|
baseY: baseYByLevelId.get(level.id) ?? 0,
|
||||||
|
id: level.id as AnyNodeId,
|
||||||
|
}))
|
||||||
|
const firstServedLevel = serviceLevels[0] ?? null
|
||||||
|
const lastServedLevel = serviceLevels[serviceLevels.length - 1] ?? null
|
||||||
|
const shaftBaseY = firstServedLevel ? (baseYByLevelId.get(firstServedLevel.id) ?? 0) : 0
|
||||||
|
const lastServedIndex = lastServedLevel
|
||||||
|
? allLevels.findIndex((level) => level.id === lastServedLevel.id)
|
||||||
|
: -1
|
||||||
|
const nextLevel = lastServedIndex >= 0 ? allLevels[lastServedIndex + 1] : null
|
||||||
|
const shaftTopY = nextLevel
|
||||||
|
? (baseYByLevelId.get(nextLevel.id) ?? cumulativeY)
|
||||||
|
: lastServedLevel
|
||||||
|
? cumulativeY
|
||||||
|
: elevator.cabHeight + 0.3
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries,
|
||||||
|
shaftBaseY,
|
||||||
|
shaftTopY,
|
||||||
|
totalHeight: Math.max(shaftTopY - shaftBaseY, elevator.cabHeight + 0.3),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createElevatorColliderMesh(
|
||||||
|
elevatorId: AnyNodeId,
|
||||||
|
kind: ElevatorColliderKind,
|
||||||
|
size: [number, number, number],
|
||||||
|
localPosition: [number, number, number],
|
||||||
|
userData: Partial<ElevatorColliderUserData> = {},
|
||||||
|
) {
|
||||||
|
const geometry = new BoxGeometry(size[0], size[1], size[2])
|
||||||
|
|
||||||
|
const bvhGeometry = geometry as typeof geometry & {
|
||||||
|
computeBoundsTree?: typeof computeBoundsTree
|
||||||
|
disposeBoundsTree?: typeof disposeBoundsTree
|
||||||
|
}
|
||||||
|
;(bvhGeometry as any).computeBoundsTree = computeBoundsTree
|
||||||
|
;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree
|
||||||
|
bvhGeometry.computeBoundsTree?.({
|
||||||
|
maxLeafSize: 12,
|
||||||
|
strategy: 0,
|
||||||
|
} as never)
|
||||||
|
bvhGeometry.computeBoundingBox()
|
||||||
|
|
||||||
|
const mesh = new Mesh(bvhGeometry, elevatorColliderMaterial) as unknown as ElevatorColliderMesh
|
||||||
|
mesh.raycast = acceleratedRaycast
|
||||||
|
mesh.matrixAutoUpdate = false
|
||||||
|
mesh.visible = true
|
||||||
|
mesh.userData = {
|
||||||
|
...userData,
|
||||||
|
dynamic: isDynamicElevatorCollider(kind),
|
||||||
|
elevatorId,
|
||||||
|
excludeCollisionCheck: false,
|
||||||
|
excludeFloatHit: false,
|
||||||
|
friction: 0.8,
|
||||||
|
kind,
|
||||||
|
localPosition,
|
||||||
|
matrixInitialized: false,
|
||||||
|
restitution: 0.03,
|
||||||
|
type: 'ELEVATOR_COLLIDER',
|
||||||
|
}
|
||||||
|
return mesh
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildElevatorColliderMeshes(): ElevatorColliderMesh[] {
|
||||||
|
const nodes = useScene.getState().nodes
|
||||||
|
const meshes: ElevatorColliderMesh[] = []
|
||||||
|
|
||||||
|
for (const elevatorId of sceneRegistry.byType.elevator) {
|
||||||
|
const typedElevatorId = elevatorId as AnyNodeId
|
||||||
|
const node = nodes[typedElevatorId]
|
||||||
|
if (node?.type !== 'elevator' || node.visible === false) continue
|
||||||
|
|
||||||
|
const { entries, shaftBaseY, shaftTopY, totalHeight } = resolveElevatorColliderLevels(
|
||||||
|
node,
|
||||||
|
nodes,
|
||||||
|
)
|
||||||
|
const 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 = (
|
const resolvePlacedSpawnNode = (
|
||||||
nodes: ReturnType<typeof useScene.getState>['nodes'],
|
nodes: ReturnType<typeof useScene.getState>['nodes'],
|
||||||
@@ -86,8 +538,17 @@ export const FirstPersonControls = () => {
|
|||||||
const yawRef = useRef(0)
|
const yawRef = useRef(0)
|
||||||
const pitchRef = useRef(0)
|
const pitchRef = useRef(0)
|
||||||
const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
|
const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
|
||||||
|
const [isElevatorRideLocked, setIsElevatorRideLocked] = useState(false)
|
||||||
|
const ridingElevatorRef = useRef<{
|
||||||
|
elevatorId: AnyNodeId
|
||||||
|
localControllerY: number | null
|
||||||
|
previousCarY: number
|
||||||
|
} | null>(null)
|
||||||
|
const rideLockedRef = useRef(false)
|
||||||
const worldRef = useRef<FirstPersonColliderWorld | null>(null)
|
const worldRef = useRef<FirstPersonColliderWorld | null>(null)
|
||||||
|
const elevatorColliderMeshesRef = useRef<ElevatorColliderMesh[]>([])
|
||||||
const [world, setWorld] = useState<FirstPersonColliderWorld | null>(null)
|
const [world, setWorld] = useState<FirstPersonColliderWorld | null>(null)
|
||||||
|
const [elevatorColliderMeshes, setElevatorColliderMeshes] = useState<ElevatorColliderMesh[]>([])
|
||||||
const [controllerStart, setControllerStart] = useState<{
|
const [controllerStart, setControllerStart] = useState<{
|
||||||
position: [number, number, number]
|
position: [number, number, number]
|
||||||
yaw: number
|
yaw: number
|
||||||
@@ -99,9 +560,22 @@ export const FirstPersonControls = () => {
|
|||||||
setWorld(nextWorld)
|
setWorld(nextWorld)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const replaceElevatorColliderMeshes = useCallback((nextMeshes: ElevatorColliderMesh[]) => {
|
||||||
|
disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current)
|
||||||
|
elevatorColliderMeshesRef.current = nextMeshes
|
||||||
|
setElevatorColliderMeshes(nextMeshes)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const rebuildColliderWorld = useCallback(() => {
|
const rebuildColliderWorld = useCallback(() => {
|
||||||
replaceColliderWorld(buildFirstPersonColliderWorldFromRegistry())
|
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 resolveInteractableDoorId = useCallback((): AnyNodeId | null => {
|
||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
@@ -230,7 +704,61 @@ export const FirstPersonControls = () => {
|
|||||||
return closestWindowId
|
return closestWindowId
|
||||||
}, [camera])
|
}, [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 resolveInteractableTarget = useCallback((): FirstPersonInteractableTarget | null => {
|
||||||
|
const elevatorTarget = resolveInteractableElevatorTarget()
|
||||||
|
if (elevatorTarget) return elevatorTarget
|
||||||
|
|
||||||
const doorId = resolveInteractableDoorId()
|
const doorId = resolveInteractableDoorId()
|
||||||
if (doorId) return { id: doorId, type: 'door' }
|
if (doorId) return { id: doorId, type: 'door' }
|
||||||
|
|
||||||
@@ -238,12 +766,42 @@ export const FirstPersonControls = () => {
|
|||||||
if (windowId) return { id: windowId, type: 'window' }
|
if (windowId) return { id: windowId, type: 'window' }
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}, [resolveInteractableDoorId, resolveInteractableWindowId])
|
}, [resolveInteractableDoorId, resolveInteractableElevatorTarget, resolveInteractableWindowId])
|
||||||
|
|
||||||
const toggleInteractableTarget = useCallback(() => {
|
const toggleInteractableTarget = useCallback(() => {
|
||||||
const target = interactableTargetRef.current ?? resolveInteractableTarget()
|
const target = interactableTargetRef.current ?? resolveInteractableTarget()
|
||||||
if (!target) return
|
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') {
|
if (target.type === 'window') {
|
||||||
const node = useScene.getState().nodes[target.id]
|
const node = useScene.getState().nodes[target.id]
|
||||||
if (
|
if (
|
||||||
@@ -270,6 +828,8 @@ export const FirstPersonControls = () => {
|
|||||||
const target = interactableTargetRef.current ?? resolveInteractableTarget()
|
const target = interactableTargetRef.current ?? resolveInteractableTarget()
|
||||||
if (!target) return
|
if (!target) return
|
||||||
|
|
||||||
|
if (target.type === 'elevator') return
|
||||||
|
|
||||||
if (target.type === 'window') {
|
if (target.type === 'window') {
|
||||||
const node = useScene.getState().nodes[target.id]
|
const node = useScene.getState().nodes[target.id]
|
||||||
if (
|
if (
|
||||||
@@ -325,6 +885,9 @@ export const FirstPersonControls = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
worldRef.current?.dispose()
|
worldRef.current?.dispose()
|
||||||
worldRef.current = null
|
worldRef.current = null
|
||||||
|
disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current)
|
||||||
|
elevatorColliderMeshesRef.current = []
|
||||||
|
setElevatorColliderMeshes([])
|
||||||
setWorld(null)
|
setWorld(null)
|
||||||
}
|
}
|
||||||
}, [rebuildColliderWorld])
|
}, [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('mousemove', handleMouseMove)
|
||||||
document.addEventListener('click', handleClick)
|
document.addEventListener('click', handleClick)
|
||||||
|
document.addEventListener('mousedown', handleMouseDown, true)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousemove', handleMouseMove)
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
document.removeEventListener('click', handleClick)
|
document.removeEventListener('click', handleClick)
|
||||||
|
document.removeEventListener('mousedown', handleMouseDown, true)
|
||||||
if (document.pointerLockElement === canvas) {
|
if (document.pointerLockElement === canvas) {
|
||||||
document.exitPointerLock()
|
document.exitPointerLock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [gl])
|
}, [gl, toggleInteractableTarget])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = gl.domElement
|
const canvas = gl.domElement
|
||||||
@@ -417,7 +991,229 @@ export const FirstPersonControls = () => {
|
|||||||
}
|
}
|
||||||
}, [closeInteractableTarget, gl, toggleInteractableTarget])
|
}, [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
|
if (!controllerRef.current?.group) return
|
||||||
|
|
||||||
const group = controllerRef.current.group
|
const group = controllerRef.current.group
|
||||||
@@ -426,17 +1222,20 @@ export const FirstPersonControls = () => {
|
|||||||
cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
|
cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
|
||||||
camera.quaternion.setFromEuler(cameraEuler)
|
camera.quaternion.setFromEuler(cameraEuler)
|
||||||
camera.updateMatrixWorld(true)
|
camera.updateMatrixWorld(true)
|
||||||
|
syncElevatorRide(group)
|
||||||
|
camera.position.copy(group.position).add(cameraOffset)
|
||||||
|
camera.updateMatrixWorld(true)
|
||||||
|
|
||||||
const nextInteractableTarget = resolveInteractableTarget()
|
const nextInteractableTarget = resolveInteractableTarget()
|
||||||
const previousInteractableTarget = interactableTargetRef.current
|
const previousInteractableTarget = interactableTargetRef.current
|
||||||
if (
|
if (
|
||||||
previousInteractableTarget?.id !== nextInteractableTarget?.id ||
|
getInteractableTargetKey(previousInteractableTarget) !==
|
||||||
previousInteractableTarget?.type !== nextInteractableTarget?.type
|
getInteractableTargetKey(nextInteractableTarget)
|
||||||
) {
|
) {
|
||||||
interactableTargetRef.current = nextInteractableTarget
|
interactableTargetRef.current = nextInteractableTarget
|
||||||
useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null)
|
useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null)
|
||||||
}
|
}
|
||||||
})
|
}, 2.5)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -446,6 +1245,11 @@ export const FirstPersonControls = () => {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const firstPersonColliderMeshes = useMemo(
|
||||||
|
() => (world ? [world.mesh, ...elevatorColliderMeshes] : elevatorColliderMeshes),
|
||||||
|
[world, elevatorColliderMeshes],
|
||||||
|
)
|
||||||
|
|
||||||
if (!world) {
|
if (!world) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -458,7 +1262,7 @@ export const FirstPersonControls = () => {
|
|||||||
acceleration={26}
|
acceleration={26}
|
||||||
airDragFactor={0.3}
|
airDragFactor={0.3}
|
||||||
colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
|
colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
|
||||||
colliderMeshes={[world.mesh]}
|
colliderMeshes={firstPersonColliderMeshes}
|
||||||
collisionCheckIteration={3}
|
collisionCheckIteration={3}
|
||||||
collisionPushBackDamping={0.1}
|
collisionPushBackDamping={0.1}
|
||||||
collisionPushBackThreshold={0.001}
|
collisionPushBackThreshold={0.001}
|
||||||
@@ -478,6 +1282,7 @@ export const FirstPersonControls = () => {
|
|||||||
maxRunSpeed={5.5}
|
maxRunSpeed={5.5}
|
||||||
maxSlope={1.2}
|
maxSlope={1.2}
|
||||||
maxWalkSpeed={4}
|
maxWalkSpeed={4}
|
||||||
|
paused={isElevatorRideLocked}
|
||||||
position={controllerStart.position}
|
position={controllerStart.position}
|
||||||
ref={controllerRef}
|
ref={controllerRef}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -764,12 +764,12 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
|||||||
|
|
||||||
const deltaTime = Math.min(1 / 45, delta) * slowMotionFactor
|
const deltaTime = Math.min(1 / 45, delta) * slowMotionFactor
|
||||||
const keys = getKeys() ?? presetKeys
|
const keys = getKeys() ?? presetKeys
|
||||||
const forward = forwardState.current || keys.forward
|
const forward = forwardState.current || (keys.forward ?? false)
|
||||||
const backward = backwardState.current || keys.backward
|
const backward = backwardState.current || (keys.backward ?? false)
|
||||||
const leftward = leftwardState.current || keys.leftward
|
const leftward = leftwardState.current || (keys.leftward ?? false)
|
||||||
const rightward = rightwardState.current || keys.rightward
|
const rightward = rightwardState.current || (keys.rightward ?? false)
|
||||||
const run = runState.current || keys.run
|
const run = runState.current || (keys.run ?? false)
|
||||||
const jump = jumpState.current || keys.jump
|
const jump = jumpState.current || (keys.jump ?? false)
|
||||||
|
|
||||||
setInputDirection({
|
setInputDirection({
|
||||||
forward,
|
forward,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type CeilingNode,
|
type CeilingNode,
|
||||||
ColumnNode,
|
ColumnNode,
|
||||||
DoorNode,
|
DoorNode,
|
||||||
|
ElevatorNode,
|
||||||
FenceNode,
|
FenceNode,
|
||||||
generateId,
|
generateId,
|
||||||
ItemNode,
|
ItemNode,
|
||||||
@@ -35,6 +36,7 @@ const ALLOWED_TYPES = [
|
|||||||
'item',
|
'item',
|
||||||
'door',
|
'door',
|
||||||
'window',
|
'window',
|
||||||
|
'elevator',
|
||||||
'roof',
|
'roof',
|
||||||
'roof-segment',
|
'roof-segment',
|
||||||
'stair',
|
'stair',
|
||||||
@@ -183,6 +185,7 @@ export function FloatingActionMenu() {
|
|||||||
node.type === 'item' ||
|
node.type === 'item' ||
|
||||||
node.type === 'window' ||
|
node.type === 'window' ||
|
||||||
node.type === 'door' ||
|
node.type === 'door' ||
|
||||||
|
node.type === 'elevator' ||
|
||||||
node.type === 'wall' ||
|
node.type === 'wall' ||
|
||||||
node.type === 'fence' ||
|
node.type === 'fence' ||
|
||||||
node.type === 'column' ||
|
node.type === 'column' ||
|
||||||
@@ -263,6 +266,8 @@ export function FloatingActionMenu() {
|
|||||||
duplicate = WindowNode.parse(duplicateInfo)
|
duplicate = WindowNode.parse(duplicateInfo)
|
||||||
} else if (node.type === 'item') {
|
} else if (node.type === 'item') {
|
||||||
duplicate = ItemNode.parse(duplicateInfo)
|
duplicate = ItemNode.parse(duplicateInfo)
|
||||||
|
} else if (node.type === 'elevator') {
|
||||||
|
duplicate = ElevatorNode.parse(duplicateInfo)
|
||||||
} else if (node.type === 'column') {
|
} else if (node.type === 'column') {
|
||||||
duplicate = ColumnNode.parse(duplicateInfo)
|
duplicate = ColumnNode.parse(duplicateInfo)
|
||||||
} else if (node.type === 'wall') {
|
} else if (node.type === 'wall') {
|
||||||
@@ -296,7 +301,11 @@ export function FloatingActionMenu() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (duplicate) {
|
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)
|
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||||
} else if (duplicate.type === 'wall') {
|
} else if (duplicate.type === 'wall') {
|
||||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||||
@@ -325,6 +334,7 @@ export function FloatingActionMenu() {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
duplicate.type === 'item' ||
|
duplicate.type === 'item' ||
|
||||||
|
duplicate.type === 'elevator' ||
|
||||||
duplicate.type === 'column' ||
|
duplicate.type === 'column' ||
|
||||||
duplicate.type === 'wall' ||
|
duplicate.type === 'wall' ||
|
||||||
duplicate.type === 'fence' ||
|
duplicate.type === 'fence' ||
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -607,7 +607,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
|
|||||||
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
||||||
<PresetThumbnailGenerator />
|
<PresetThumbnailGenerator />
|
||||||
{!isFirstPersonMode && <SiteEdgeLabels />}
|
{!isFirstPersonMode && <SiteEdgeLabels />}
|
||||||
{isFirstPersonMode && <InteractiveSystem />}
|
<InteractiveSystem />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ import useEditor, {
|
|||||||
import { boxSelectHandled } from '../tools/select/box-select-tool'
|
import { boxSelectHandled } from '../tools/select/box-select-tool'
|
||||||
|
|
||||||
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
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
|
const currentLevelId = useViewer.getState().selection.levelId
|
||||||
if (!currentLevelId) return true // No level selected, allow all
|
if (!currentLevelId) return true // No level selected, allow all
|
||||||
const nodeLevelId = resolveLevelId(node, useScene.getState().nodes)
|
const nodeLevelId = resolveLevelId(node, useScene.getState().nodes)
|
||||||
@@ -68,6 +70,7 @@ type SelectableNodeType =
|
|||||||
| 'item'
|
| 'item'
|
||||||
| 'column'
|
| 'column'
|
||||||
| 'building'
|
| 'building'
|
||||||
|
| 'elevator'
|
||||||
| 'zone'
|
| 'zone'
|
||||||
| 'slab'
|
| 'slab'
|
||||||
| 'ceiling'
|
| 'ceiling'
|
||||||
@@ -565,6 +568,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|||||||
'fence',
|
'fence',
|
||||||
'item',
|
'item',
|
||||||
'column',
|
'column',
|
||||||
|
'elevator',
|
||||||
'zone',
|
'zone',
|
||||||
'slab',
|
'slab',
|
||||||
'ceiling',
|
'ceiling',
|
||||||
@@ -579,11 +583,18 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|||||||
handleSelect: (node, nativeEvent, modifierKeys) => {
|
handleSelect: (node, nativeEvent, modifierKeys) => {
|
||||||
const { selection, setSelection } = useViewer.getState()
|
const { selection, setSelection } = useViewer.getState()
|
||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
const nodeLevelId = resolveLevelId(node, nodes)
|
const nodeLevelId = node.type === 'elevator' ? null : resolveLevelId(node, nodes)
|
||||||
const buildingId = resolveBuildingId(nodeLevelId, 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 = {}
|
const updates: any = {}
|
||||||
if (nodeLevelId !== 'default' && nodeLevelId !== selection.levelId) {
|
if (nodeLevelId && nodeLevelId !== 'default' && nodeLevelId !== selection.levelId) {
|
||||||
updates.levelId = nodeLevelId
|
updates.levelId = nodeLevelId
|
||||||
}
|
}
|
||||||
if (buildingId && buildingId !== selection.buildingId) {
|
if (buildingId && buildingId !== selection.buildingId) {
|
||||||
@@ -619,6 +630,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|||||||
node.type === 'wall' ||
|
node.type === 'wall' ||
|
||||||
node.type === 'fence' ||
|
node.type === 'fence' ||
|
||||||
node.type === 'column' ||
|
node.type === 'column' ||
|
||||||
|
node.type === 'elevator' ||
|
||||||
node.type === 'slab' ||
|
node.type === 'slab' ||
|
||||||
node.type === 'ceiling' ||
|
node.type === 'ceiling' ||
|
||||||
node.type === 'roof' ||
|
node.type === 'roof' ||
|
||||||
@@ -683,6 +695,7 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
|
|||||||
node.type === 'wall' ||
|
node.type === 'wall' ||
|
||||||
node.type === 'fence' ||
|
node.type === 'fence' ||
|
||||||
node.type === 'column' ||
|
node.type === 'column' ||
|
||||||
|
node.type === 'elevator' ||
|
||||||
node.type === 'slab' ||
|
node.type === 'slab' ||
|
||||||
node.type === 'ceiling' ||
|
node.type === 'ceiling' ||
|
||||||
node.type === 'roof' ||
|
node.type === 'roof' ||
|
||||||
@@ -1162,6 +1175,7 @@ export const SelectionManager = () => {
|
|||||||
'item',
|
'item',
|
||||||
'column',
|
'column',
|
||||||
'building',
|
'building',
|
||||||
|
'elevator',
|
||||||
'zone',
|
'zone',
|
||||||
'slab',
|
'slab',
|
||||||
'ceiling',
|
'ceiling',
|
||||||
@@ -1258,6 +1272,7 @@ export const SelectionManager = () => {
|
|||||||
node.type === 'wall' ||
|
node.type === 'wall' ||
|
||||||
node.type === 'fence' ||
|
node.type === 'fence' ||
|
||||||
node.type === 'column' ||
|
node.type === 'column' ||
|
||||||
|
node.type === 'elevator' ||
|
||||||
node.type === 'slab' ||
|
node.type === 'slab' ||
|
||||||
node.type === 'ceiling' ||
|
node.type === 'ceiling' ||
|
||||||
node.type === 'roof' ||
|
node.type === 'roof' ||
|
||||||
@@ -1312,6 +1327,7 @@ export const SelectionManager = () => {
|
|||||||
'item',
|
'item',
|
||||||
'column',
|
'column',
|
||||||
'building',
|
'building',
|
||||||
|
'elevator',
|
||||||
'slab',
|
'slab',
|
||||||
'ceiling',
|
'ceiling',
|
||||||
'roof',
|
'roof',
|
||||||
@@ -1385,6 +1401,7 @@ export const SelectionManager = () => {
|
|||||||
'fence',
|
'fence',
|
||||||
'item',
|
'item',
|
||||||
'column',
|
'column',
|
||||||
|
'elevator',
|
||||||
'slab',
|
'slab',
|
||||||
'ceiling',
|
'ceiling',
|
||||||
'roof',
|
'roof',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
CeilingNode,
|
CeilingNode,
|
||||||
ColumnNode,
|
ColumnNode,
|
||||||
DoorNode,
|
DoorNode,
|
||||||
|
ElevatorNode,
|
||||||
ItemNode,
|
ItemNode,
|
||||||
Point2D,
|
Point2D,
|
||||||
RoofNode,
|
RoofNode,
|
||||||
@@ -52,6 +53,11 @@ type ColumnPolygonEntry = {
|
|||||||
polygon: Point2D[]
|
polygon: Point2D[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ElevatorPolygonEntry = {
|
||||||
|
elevator: ElevatorNode
|
||||||
|
polygon: Point2D[]
|
||||||
|
}
|
||||||
|
|
||||||
type FloorplanRoofEntry = {
|
type FloorplanRoofEntry = {
|
||||||
roof: RoofNode
|
roof: RoofNode
|
||||||
segments: Array<{
|
segments: Array<{
|
||||||
@@ -81,6 +87,7 @@ type UseFloorplanHitTestingArgs = {
|
|||||||
columnPolygons: ColumnPolygonEntry[]
|
columnPolygons: ColumnPolygonEntry[]
|
||||||
displaySlabPolygons: SlabPolygonEntry[]
|
displaySlabPolygons: SlabPolygonEntry[]
|
||||||
displayWallPolygons: WallPolygonEntry[]
|
displayWallPolygons: WallPolygonEntry[]
|
||||||
|
floorplanElevatorEntries: ElevatorPolygonEntry[]
|
||||||
floorplanItemEntries: FloorplanItemEntry[]
|
floorplanItemEntries: FloorplanItemEntry[]
|
||||||
floorplanOpeningHitTolerance: number
|
floorplanOpeningHitTolerance: number
|
||||||
floorplanRoofEntries: FloorplanRoofEntry[]
|
floorplanRoofEntries: FloorplanRoofEntry[]
|
||||||
@@ -98,6 +105,7 @@ export function useFloorplanHitTesting({
|
|||||||
columnPolygons,
|
columnPolygons,
|
||||||
displaySlabPolygons,
|
displaySlabPolygons,
|
||||||
displayWallPolygons,
|
displayWallPolygons,
|
||||||
|
floorplanElevatorEntries,
|
||||||
floorplanItemEntries,
|
floorplanItemEntries,
|
||||||
floorplanOpeningHitTolerance,
|
floorplanOpeningHitTolerance,
|
||||||
floorplanRoofEntries,
|
floorplanRoofEntries,
|
||||||
@@ -121,6 +129,7 @@ export function useFloorplanHitTesting({
|
|||||||
openings: openingsPolygons,
|
openings: openingsPolygons,
|
||||||
roofs: floorplanRoofEntries,
|
roofs: floorplanRoofEntries,
|
||||||
stairs: floorplanStairEntries,
|
stairs: floorplanStairEntries,
|
||||||
|
elevators: floorplanElevatorEntries,
|
||||||
walls: displayWallPolygons,
|
walls: displayWallPolygons,
|
||||||
slabs: displaySlabPolygons,
|
slabs: displaySlabPolygons,
|
||||||
openingHitTolerance: floorplanOpeningHitTolerance,
|
openingHitTolerance: floorplanOpeningHitTolerance,
|
||||||
@@ -135,6 +144,7 @@ export function useFloorplanHitTesting({
|
|||||||
displaySlabPolygons,
|
displaySlabPolygons,
|
||||||
displayWallPolygons,
|
displayWallPolygons,
|
||||||
floorplanItemEntries,
|
floorplanItemEntries,
|
||||||
|
floorplanElevatorEntries,
|
||||||
floorplanOpeningHitTolerance,
|
floorplanOpeningHitTolerance,
|
||||||
floorplanRoofEntries,
|
floorplanRoofEntries,
|
||||||
floorplanStairEntries,
|
floorplanStairEntries,
|
||||||
@@ -160,6 +170,7 @@ export function useFloorplanHitTesting({
|
|||||||
roofs: floorplanRoofEntries,
|
roofs: floorplanRoofEntries,
|
||||||
slabs: displaySlabPolygons,
|
slabs: displaySlabPolygons,
|
||||||
columns: columnPolygons,
|
columns: columnPolygons,
|
||||||
|
elevators: floorplanElevatorEntries,
|
||||||
stairs: floorplanStairEntries,
|
stairs: floorplanStairEntries,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
@@ -168,6 +179,7 @@ export function useFloorplanHitTesting({
|
|||||||
displaySlabPolygons,
|
displaySlabPolygons,
|
||||||
displayWallPolygons,
|
displayWallPolygons,
|
||||||
floorplanItemEntries,
|
floorplanItemEntries,
|
||||||
|
floorplanElevatorEntries,
|
||||||
floorplanRoofEntries,
|
floorplanRoofEntries,
|
||||||
floorplanStairEntries,
|
floorplanStairEntries,
|
||||||
isFloorplanItemContextActive,
|
isFloorplanItemContextActive,
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export const DEFAULT_ELEVATOR_WIDTH = 1.6
|
||||||
|
export const DEFAULT_ELEVATOR_DEPTH = 1.6
|
||||||
|
export const DEFAULT_ELEVATOR_CAB_HEIGHT = 2.35
|
||||||
|
export const DEFAULT_ELEVATOR_DOOR_WIDTH = 0.95
|
||||||
|
export const DEFAULT_ELEVATOR_DOOR_HEIGHT = 2.1
|
||||||
|
export const DEFAULT_ELEVATOR_SPEED = 2.2
|
||||||
|
export const DEFAULT_ELEVATOR_DOOR_DURATION_MS = 900
|
||||||
|
export const DEFAULT_ELEVATOR_DWELL_MS = 1400
|
||||||
@@ -0,0 +1,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<ElevatorToolProps> = ({ buildingId, levelId, onPlaced }) => {
|
||||||
|
const cursorRef = useRef<THREE.Group>(null)
|
||||||
|
const previewRef = useRef<THREE.Group>(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 (
|
||||||
|
<group>
|
||||||
|
<CursorSphere ref={cursorRef} />
|
||||||
|
<group ref={previewRef}>
|
||||||
|
<mesh castShadow geometry={previewGeometry}>
|
||||||
|
<meshStandardMaterial color="#38bdf8" depthWrite={false} opacity={0.32} transparent />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, DEFAULT_ELEVATOR_CAB_HEIGHT / 2, -DEFAULT_ELEVATOR_DEPTH / 2 - 0.03]}>
|
||||||
|
<boxGeometry args={[DEFAULT_ELEVATOR_DOOR_WIDTH, DEFAULT_ELEVATOR_DOOR_HEIGHT, 0.035]} />
|
||||||
|
<meshStandardMaterial color="#e5e7eb" depthWrite={false} opacity={0.45} transparent />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,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<string, unknown>) }
|
||||||
|
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<ElevatorNode['position']>([
|
||||||
|
movingNode.position[0],
|
||||||
|
movingNode.position[1],
|
||||||
|
movingNode.position[2],
|
||||||
|
])
|
||||||
|
const [cursorPosition, setCursorPosition] = useState<[number, number, number]>(() => [
|
||||||
|
movingNode.position[0],
|
||||||
|
movingNode.position[1],
|
||||||
|
movingNode.position[2],
|
||||||
|
])
|
||||||
|
|
||||||
|
const exitMoveMode = useCallback(() => {
|
||||||
|
useEditor.getState().setMovingNode(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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<string, unknown>)
|
||||||
|
: {}
|
||||||
|
const isNew = !!meta.isNew
|
||||||
|
const committedMeta = stripMoveMetadata(movingNode.metadata)
|
||||||
|
const original = {
|
||||||
|
position: [...movingNode.position] as ElevatorNode['position'],
|
||||||
|
rotation: movingNode.rotation,
|
||||||
|
metadata: movingNode.metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
let wasCommitted = false
|
||||||
|
let wasCancelled = false
|
||||||
|
let pendingRotation = movingNode.rotation
|
||||||
|
const 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 <CursorSphere position={cursorPosition} showTooltip={false} />
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import type { AssetInput } from '@pascal-app/core'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { useDraftNode } from './use-draft-node'
|
import { useDraftNode } from './use-draft-node'
|
||||||
import { usePlacementCoordinator } from './use-placement-coordinator'
|
import { usePlacementCoordinator } from './use-placement-coordinator'
|
||||||
|
|
||||||
export const ItemTool: React.FC = () => {
|
function ItemPlacementContent({ selectedItem }: { selectedItem: AssetInput }) {
|
||||||
const selectedItem = useEditor((state) => state.selectedItem)
|
|
||||||
const draftNode = useDraftNode()
|
const draftNode = useDraftNode()
|
||||||
|
|
||||||
const cursor = usePlacementCoordinator({
|
const cursor = usePlacementCoordinator({
|
||||||
@@ -21,6 +21,12 @@ export const ItemTool: React.FC = () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!selectedItem) return null
|
|
||||||
return <>{cursor}</>
|
return <>{cursor}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ItemTool: React.FC = () => {
|
||||||
|
const selectedItem = useEditor((state) => state.selectedItem)
|
||||||
|
|
||||||
|
if (!selectedItem) return null
|
||||||
|
return <ItemPlacementContent selectedItem={selectedItem} />
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AnyNodeId,
|
||||||
BuildingNode,
|
BuildingNode,
|
||||||
CeilingNode,
|
CeilingNode,
|
||||||
ColumnNode,
|
ColumnNode,
|
||||||
DoorNode,
|
DoorNode,
|
||||||
|
ElevatorNode,
|
||||||
FenceNode,
|
FenceNode,
|
||||||
ItemNode,
|
ItemNode,
|
||||||
RoofNode,
|
RoofNode,
|
||||||
@@ -21,6 +23,7 @@ import { MoveBuildingContent } from '../building/move-building-tool'
|
|||||||
import { MoveCeilingTool } from '../ceiling/move-ceiling-tool'
|
import { MoveCeilingTool } from '../ceiling/move-ceiling-tool'
|
||||||
import { MoveColumnTool } from '../column/move-column-tool'
|
import { MoveColumnTool } from '../column/move-column-tool'
|
||||||
import { MoveDoorTool } from '../door/move-door-tool'
|
import { MoveDoorTool } from '../door/move-door-tool'
|
||||||
|
import { MoveElevatorTool } from '../elevator/move-elevator-tool'
|
||||||
import { MoveFenceTool } from '../fence/move-fence-tool'
|
import { MoveFenceTool } from '../fence/move-fence-tool'
|
||||||
import { MoveRoofTool } from '../roof/move-roof-tool'
|
import { MoveRoofTool } from '../roof/move-roof-tool'
|
||||||
import { MoveSlabTool } from '../slab/move-slab-tool'
|
import { MoveSlabTool } from '../slab/move-slab-tool'
|
||||||
@@ -90,13 +93,18 @@ function MoveItemContent({ movingNode }: { movingNode: ItemNode }) {
|
|||||||
return <>{cursor}</>
|
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)
|
const movingNode = useEditor((state) => state.movingNode)
|
||||||
|
|
||||||
if (!movingNode) return null
|
if (!movingNode) return null
|
||||||
if (movingNode.type === 'building')
|
if (movingNode.type === 'building')
|
||||||
return <MoveBuildingContent node={movingNode as BuildingNode} />
|
return <MoveBuildingContent node={movingNode as BuildingNode} />
|
||||||
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
|
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
|
||||||
|
if (movingNode.type === 'elevator')
|
||||||
|
return <MoveElevatorTool node={movingNode as ElevatorNode} onCommitted={onNodeMoved} />
|
||||||
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
|
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
|
||||||
if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
|
if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
|
||||||
if (movingNode.type === 'column') return <MoveColumnTool node={movingNode as ColumnNode} />
|
if (movingNode.type === 'column') return <MoveColumnTool node={movingNode as ColumnNode} />
|
||||||
@@ -105,7 +113,8 @@ export const MoveTool: React.FC = () => {
|
|||||||
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
|
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
|
||||||
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
|
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
|
||||||
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
|
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
|
||||||
if (movingNode.type === 'spawn') return <MoveSpawnTool node={movingNode as SpawnNode} />
|
if (movingNode.type === 'spawn')
|
||||||
|
return <MoveSpawnTool node={movingNode as SpawnNode} onCommitted={onSpawnMoved} />
|
||||||
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
|
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
|
||||||
return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
|
return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
|
||||||
return <MoveItemContent movingNode={movingNode as ItemNode} />
|
return <MoveItemContent movingNode={movingNode as ItemNode} />
|
||||||
|
|||||||
@@ -116,10 +116,10 @@ function expandBoundsToGrid(
|
|||||||
|
|
||||||
function getFallbackPreviewBounds(
|
function getFallbackPreviewBounds(
|
||||||
item: import('@pascal-app/core').ItemNode | null,
|
item: import('@pascal-app/core').ItemNode | null,
|
||||||
asset: AssetInput,
|
asset: AssetInput | null | undefined,
|
||||||
attachTo: AssetInput['attachTo'],
|
attachTo: AssetInput['attachTo'] | null | undefined,
|
||||||
): PreviewBounds {
|
): PreviewBounds {
|
||||||
const dims = item ? getScaledDimensions(item) : (asset.dimensions ?? DEFAULT_DIMENSIONS)
|
const dims = item ? getScaledDimensions(item) : (asset?.dimensions ?? DEFAULT_DIMENSIONS)
|
||||||
return {
|
return {
|
||||||
min: [-dims[0] / 2, 0, attachTo === 'wall-side' ? -dims[2] : -dims[2] / 2],
|
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],
|
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 dims = getGridAlignedDimensions(rawDims, initialAttachTo, gridSnapStep)
|
||||||
const wallSideZOffset = initialAttachTo === 'wall-side' ? -dims[2] / 2 : 0
|
const wallSideZOffset = initialAttachTo === 'wall-side' ? -dims[2] / 2 : 0
|
||||||
const initialDimensionBounds = expandBoundsToGrid(
|
const initialDimensionBounds = expandBoundsToGrid(
|
||||||
getFallbackPreviewBounds(initialDraft, config.asset!, initialAttachTo),
|
getFallbackPreviewBounds(initialDraft, config.asset, initialAttachTo),
|
||||||
initialAttachTo,
|
initialAttachTo,
|
||||||
gridSnapStep,
|
gridSnapStep,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
|
|||||||
import { CeilingTool } from './ceiling/ceiling-tool'
|
import { CeilingTool } from './ceiling/ceiling-tool'
|
||||||
import { ColumnTool } from './column/column-tool'
|
import { ColumnTool } from './column/column-tool'
|
||||||
import { DoorTool } from './door/door-tool'
|
import { DoorTool } from './door/door-tool'
|
||||||
|
import { ElevatorTool } from './elevator/elevator-tool'
|
||||||
import { CurveFenceTool } from './fence/curve-fence-tool'
|
import { CurveFenceTool } from './fence/curve-fence-tool'
|
||||||
import { FenceTool } from './fence/fence-tool'
|
import { FenceTool } from './fence/fence-tool'
|
||||||
import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool'
|
import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool'
|
||||||
@@ -45,7 +46,6 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
|
|||||||
door: DoorTool,
|
door: DoorTool,
|
||||||
item: ItemTool,
|
item: ItemTool,
|
||||||
zone: ZoneTool,
|
zone: ZoneTool,
|
||||||
spawn: SpawnTool,
|
|
||||||
window: WindowTool,
|
window: WindowTool,
|
||||||
},
|
},
|
||||||
furnish: {
|
furnish: {
|
||||||
@@ -67,6 +67,7 @@ export const ToolManager: React.FC = () => {
|
|||||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||||
const buildingId = useViewer((state) => state.selection.buildingId)
|
const buildingId = useViewer((state) => state.selection.buildingId)
|
||||||
const activeLevelId = useViewer((state) => state.selection.levelId)
|
const activeLevelId = useViewer((state) => state.selection.levelId)
|
||||||
|
const setSelection = useViewer((state) => state.setSelection)
|
||||||
const nodes = useScene((state) => state.nodes)
|
const nodes = useScene((state) => state.nodes)
|
||||||
|
|
||||||
// Building transform for the local group — all building-relative tools live inside this group
|
// 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 BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null
|
||||||
const handlePlacedNodeSelected = (nodeId: AnyNodeId) => {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* World-space tools: site boundary and building movement operate in world coordinates */}
|
{/* World-space tools: site boundary and building movement operate in world coordinates */}
|
||||||
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
||||||
{movingNode?.type === 'building' && <MoveTool onSpawnMoved={handlePlacedNodeSelected} />}
|
{movingNode?.type === 'building' && (
|
||||||
|
<MoveTool onNodeMoved={handlePlacedNodeSelected} onSpawnMoved={handlePlacedNodeSelected} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Building-local group: all other tools are relative to the selected 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
|
Cursor visuals set positions in building-local space; this group applies the
|
||||||
@@ -160,13 +169,29 @@ export const ToolManager: React.FC = () => {
|
|||||||
{curvingWall && <CurveWallTool node={curvingWall} />}
|
{curvingWall && <CurveWallTool node={curvingWall} />}
|
||||||
{curvingFence && <CurveFenceTool node={curvingFence} />}
|
{curvingFence && <CurveFenceTool node={curvingFence} />}
|
||||||
{movingNode && movingNode.type !== 'building' && (
|
{movingNode && movingNode.type !== 'building' && (
|
||||||
<MoveTool onSpawnMoved={handlePlacedNodeSelected} />
|
<MoveTool
|
||||||
|
onNodeMoved={handlePlacedNodeSelected}
|
||||||
|
onSpawnMoved={handlePlacedNodeSelected}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{!movingNode && BuildToolComponent && tool === 'spawn' ? (
|
{!movingNode && showBuildTool && tool === 'spawn' && (
|
||||||
<SpawnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
|
<SpawnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
|
||||||
) : !movingNode && showBuildTool && tool === 'column' ? (
|
)}
|
||||||
|
{!movingNode && showBuildTool && tool === 'column' && (
|
||||||
<ColumnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
|
<ColumnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
|
||||||
) : !movingNode && BuildToolComponent && tool !== 'column' ? (
|
)}
|
||||||
|
{!movingNode && showBuildTool && tool === 'elevator' && (
|
||||||
|
<ElevatorTool
|
||||||
|
buildingId={buildingId as BuildingNode['id'] | null}
|
||||||
|
levelId={activeLevelId ?? null}
|
||||||
|
onPlaced={handlePlacedElevatorSelected}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!movingNode &&
|
||||||
|
BuildToolComponent &&
|
||||||
|
tool !== 'spawn' &&
|
||||||
|
tool !== 'column' &&
|
||||||
|
tool !== 'elevator' ? (
|
||||||
<BuildToolComponent />
|
<BuildToolComponent />
|
||||||
) : null}
|
) : null}
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const tools: ToolConfig[] = [
|
|||||||
{ id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
|
{ id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
|
||||||
{ id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
|
{ id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
|
||||||
{ id: 'column', iconSrc: '/icons/column.png', label: 'Column' },
|
{ 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: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' },
|
||||||
{ id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
|
{ id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
|
||||||
{ id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
|
{ id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
|
||||||
|
|||||||
@@ -190,8 +190,11 @@ export function EditorCommands() {
|
|||||||
const { nodes } = useScene.getState()
|
const { nodes } = useScene.getState()
|
||||||
const building = Object.values(nodes).find((n) => n.type === 'building')
|
const building = Object.values(nodes).find((n) => n.type === 'building')
|
||||||
if (!building) return
|
if (!building) return
|
||||||
|
const levelCount = building.children.filter(
|
||||||
|
(childId) => nodes[childId as keyof typeof nodes]?.type === 'level',
|
||||||
|
).length
|
||||||
const newLevel = LevelNode.parse({
|
const newLevel = LevelNode.parse({
|
||||||
level: building.children.length,
|
level: levelCount,
|
||||||
children: [],
|
children: [],
|
||||||
parentId: building.id,
|
parentId: building.id,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
getCatalogMaterialById,
|
getCatalogMaterialById,
|
||||||
getLibraryMaterialIdFromRef,
|
getLibraryMaterialIdFromRef,
|
||||||
getMaterialsForCategory,
|
getMaterialsForCategory,
|
||||||
getMaterialsForTarget,
|
|
||||||
MATERIAL_CATEGORIES,
|
MATERIAL_CATEGORIES,
|
||||||
type MaterialSchema,
|
type MaterialSchema,
|
||||||
type MaterialTarget,
|
type MaterialTarget,
|
||||||
|
|||||||
@@ -9,24 +9,28 @@ interface MetricControlProps {
|
|||||||
label: React.ReactNode
|
label: React.ReactNode
|
||||||
value: number
|
value: number
|
||||||
onChange: (value: number) => void
|
onChange: (value: number) => void
|
||||||
|
onCommit?: (value: number) => void
|
||||||
min?: number
|
min?: number
|
||||||
max?: number
|
max?: number
|
||||||
precision?: number
|
precision?: number
|
||||||
step?: number
|
step?: number
|
||||||
className?: string
|
className?: string
|
||||||
unit?: string
|
unit?: string
|
||||||
|
restoreOnCommit?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MetricControl({
|
export function MetricControl({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
onCommit,
|
||||||
min = Number.NEGATIVE_INFINITY,
|
min = Number.NEGATIVE_INFINITY,
|
||||||
max = Number.POSITIVE_INFINITY,
|
max = Number.POSITIVE_INFINITY,
|
||||||
precision = 2,
|
precision = 2,
|
||||||
step = 1,
|
step = 1,
|
||||||
className,
|
className,
|
||||||
unit = '',
|
unit = '',
|
||||||
|
restoreOnCommit = true,
|
||||||
}: MetricControlProps) {
|
}: MetricControlProps) {
|
||||||
const viewerUnit = useViewer((state) => state.unit)
|
const viewerUnit = useViewer((state) => state.unit)
|
||||||
const isImperial = viewerUnit === 'imperial' && unit === 'm'
|
const isImperial = viewerUnit === 'imperial' && unit === 'm'
|
||||||
@@ -53,6 +57,17 @@ export function MetricControl({
|
|||||||
[min, max],
|
[min, max],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const applyCommittedValue = useCallback(
|
||||||
|
(nextValue: number) => {
|
||||||
|
if (onCommit) {
|
||||||
|
onCommit(nextValue)
|
||||||
|
} else {
|
||||||
|
onChange(nextValue)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChange, onCommit],
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEditing) {
|
if (!isEditing) {
|
||||||
setInputValue(displayValue.toFixed(precision))
|
setInputValue(displayValue.toFixed(precision))
|
||||||
@@ -77,13 +92,13 @@ export function MetricControl({
|
|||||||
const finalValue = Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
|
const finalValue = Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
|
||||||
|
|
||||||
if (Math.abs(finalValue - valueRef.current) > 1e-6) {
|
if (Math.abs(finalValue - valueRef.current) > 1e-6) {
|
||||||
onChange(finalValue)
|
applyCommittedValue(finalValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
container.addEventListener('wheel', handleWheel, { passive: false })
|
container.addEventListener('wheel', handleWheel, { passive: false })
|
||||||
return () => container.removeEventListener('wheel', handleWheel)
|
return () => container.removeEventListener('wheel', handleWheel)
|
||||||
}, [isEditing, step, clamp, onChange, precision, multiplier])
|
}, [isEditing, step, clamp, applyCommittedValue, precision, multiplier])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isHovered || isEditing) return
|
if (!isHovered || isEditing) return
|
||||||
@@ -104,14 +119,14 @@ export function MetricControl({
|
|||||||
Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
|
Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
|
||||||
|
|
||||||
if (Math.abs(finalValue - valueRef.current) > 1e-6) {
|
if (Math.abs(finalValue - valueRef.current) > 1e-6) {
|
||||||
onChange(finalValue)
|
applyCommittedValue(finalValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [isHovered, isEditing, step, clamp, onChange, precision, multiplier])
|
}, [isHovered, isEditing, step, clamp, applyCommittedValue, precision, multiplier])
|
||||||
|
|
||||||
const handlePointerDown = useCallback(
|
const handlePointerDown = useCallback(
|
||||||
(e: React.PointerEvent) => {
|
(e: React.PointerEvent) => {
|
||||||
@@ -148,7 +163,14 @@ export function MetricControl({
|
|||||||
document.removeEventListener('pointermove', handlePointerMove)
|
document.removeEventListener('pointermove', handlePointerMove)
|
||||||
document.removeEventListener('pointerup', handlePointerUp)
|
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)
|
onChange(startValueRef.current)
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
onChange(finalValue)
|
onChange(finalValue)
|
||||||
@@ -160,7 +182,7 @@ export function MetricControl({
|
|||||||
document.addEventListener('pointermove', handlePointerMove)
|
document.addEventListener('pointermove', handlePointerMove)
|
||||||
document.addEventListener('pointerup', handlePointerUp)
|
document.addEventListener('pointerup', handlePointerUp)
|
||||||
},
|
},
|
||||||
[isEditing, value, onChange, clamp, precision, step, multiplier],
|
[isEditing, value, onChange, onCommit, restoreOnCommit, clamp, precision, step, multiplier],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleValueClick = useCallback(() => {
|
const handleValueClick = useCallback(() => {
|
||||||
@@ -177,10 +199,10 @@ export function MetricControl({
|
|||||||
if (Number.isNaN(numValue)) {
|
if (Number.isNaN(numValue)) {
|
||||||
setInputValue((value * multiplier).toFixed(precision))
|
setInputValue((value * multiplier).toFixed(precision))
|
||||||
} else {
|
} else {
|
||||||
onChange(clamp(numValue / multiplier))
|
applyCommittedValue(clamp(numValue / multiplier))
|
||||||
}
|
}
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
}, [inputValue, onChange, clamp, multiplier, value, precision])
|
}, [inputValue, applyCommittedValue, clamp, multiplier, value, precision])
|
||||||
|
|
||||||
const handleInputBlur = useCallback(() => {
|
const handleInputBlur = useCallback(() => {
|
||||||
submitValue()
|
submitValue()
|
||||||
@@ -196,16 +218,16 @@ export function MetricControl({
|
|||||||
} else if (e.key === 'ArrowUp') {
|
} else if (e.key === 'ArrowUp') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const newV = clamp(value + step / multiplier)
|
const newV = clamp(value + step / multiplier)
|
||||||
onChange(newV)
|
applyCommittedValue(newV)
|
||||||
setInputValue((newV * multiplier).toFixed(precision))
|
setInputValue((newV * multiplier).toFixed(precision))
|
||||||
} else if (e.key === 'ArrowDown') {
|
} else if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const newV = clamp(value - step / multiplier)
|
const newV = clamp(value - step / multiplier)
|
||||||
onChange(newV)
|
applyCommittedValue(newV)
|
||||||
setInputValue((newV * multiplier).toFixed(precision))
|
setInputValue((newV * multiplier).toFixed(precision))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[submitValue, value, multiplier, precision, step, clamp, onChange],
|
[submitValue, value, multiplier, precision, step, clamp, applyCommittedValue],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function CeilingPanel() {
|
|||||||
(index: number) => {
|
(index: number) => {
|
||||||
if (!selectedId) return
|
if (!selectedId) return
|
||||||
const currentHoles = node?.holes || []
|
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 newHoles = currentHoles.filter((_, i) => i !== index)
|
||||||
const currentMetadata = currentHoles.map(
|
const currentMetadata = currentHoles.map(
|
||||||
(_, metadataIndex) => node?.holeMetadata?.[metadataIndex] ?? { source: 'manual' as const },
|
(_, metadataIndex) => node?.holeMetadata?.[metadataIndex] ?? { source: 'manual' as const },
|
||||||
@@ -171,7 +171,8 @@ export function CeilingPanel() {
|
|||||||
const isEditing =
|
const isEditing =
|
||||||
editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
|
editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
|
||||||
const source = node.holeMetadata?.[index]?.source ?? 'manual'
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
|
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
|
||||||
@@ -189,7 +190,7 @@ export function CeilingPanel() {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
{holeArea.toFixed(2)} m² · {hole.length} pts ·{' '}
|
{holeArea.toFixed(2)} m² · {hole.length} pts ·{' '}
|
||||||
{isAutoHole ? 'Auto stair cutout' : 'Manual'}
|
{isAutoHole ? autoLabel : 'Manual'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|||||||
@@ -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<string, unknown>) }
|
||||||
|
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<ElevatorNode>) => {
|
||||||
|
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(
|
||||||
|
<K extends ElevatorMetricKey>(key: K, value: ElevatorNode[K]) => {
|
||||||
|
if (!selectedId) return
|
||||||
|
useLiveNodeOverrides.getState().set(selectedId as AnyNodeId, { [key]: value })
|
||||||
|
},
|
||||||
|
[selectedId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const commitMetric = useCallback(
|
||||||
|
<K extends ElevatorMetricKey>(key: K, value: ElevatorNode[K]) => {
|
||||||
|
if (!selectedId) return
|
||||||
|
|
||||||
|
const hasChange = !(node && Math.abs(Number(node[key]) - Number(value)) <= 1e-6)
|
||||||
|
if (hasChange) {
|
||||||
|
updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial<ElevatorNode>)
|
||||||
|
}
|
||||||
|
useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
|
||||||
|
},
|
||||||
|
[node, selectedId, updateNode],
|
||||||
|
)
|
||||||
|
|
||||||
|
const 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<string, unknown>), isNew: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
createNode(duplicate, node.parentId as AnyNodeId)
|
||||||
|
clearLivePreview()
|
||||||
|
setMovingNode(duplicate)
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [clearLivePreview, node, createNode, setMovingNode, setSelection])
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
if (!(selectedId && node)) return
|
||||||
|
sfxEmitter.emit('sfx:structure-delete')
|
||||||
|
clearLivePreview()
|
||||||
|
useScene.getState().deleteNode(selectedId as AnyNodeId)
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [clearLivePreview, selectedId, node, setSelection])
|
||||||
|
|
||||||
|
const requestLevel = useCallback(
|
||||||
|
(levelId: LevelNode['id']) => {
|
||||||
|
if (!node) return
|
||||||
|
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<ElevatorNode>)
|
||||||
|
},
|
||||||
|
[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<string>(servedLevels.map((level) => level.id))
|
||||||
|
const disabledLevelIds = new Set(
|
||||||
|
(node.disabledLevelIds ?? []).filter((levelId) => servedLevelIdSet.has(levelId)),
|
||||||
|
)
|
||||||
|
const serviceOnlyLevelIds = new Set(
|
||||||
|
(node.serviceOnlyLevelIds ?? []).filter((levelId) => servedLevelIdSet.has(levelId)),
|
||||||
|
)
|
||||||
|
const enabledServedLevels = servedLevels.filter((level) => !disabledLevelIds.has(level.id))
|
||||||
|
const defaultLevelOptions =
|
||||||
|
enabledServedLevels.length > 0 ? enabledServedLevels : servedLevels.length > 0 ? servedLevels : levels
|
||||||
|
const selectedDefaultLevelId = defaultLevelOptions.some(
|
||||||
|
(level) => level.id === node.defaultLevelId,
|
||||||
|
)
|
||||||
|
? (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<string, number>()
|
||||||
|
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 (
|
||||||
|
<PanelWrapper
|
||||||
|
icon="/icons/elevator.svg"
|
||||||
|
onClose={handleClose}
|
||||||
|
title={node.name || 'Elevator'}
|
||||||
|
width={300}
|
||||||
|
>
|
||||||
|
<PanelSection title="Actions">
|
||||||
|
<ActionGroup>
|
||||||
|
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||||
|
<ActionButton
|
||||||
|
icon={<Copy className="h-3.5 w-3.5" />}
|
||||||
|
label="Duplicate"
|
||||||
|
onClick={handleDuplicate}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
icon={<Trash2 className="h-3.5 w-3.5" />}
|
||||||
|
label="Delete"
|
||||||
|
onClick={handleDelete}
|
||||||
|
/>
|
||||||
|
</ActionGroup>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Position">
|
||||||
|
<SliderControl
|
||||||
|
label="X"
|
||||||
|
max={50}
|
||||||
|
min={-50}
|
||||||
|
onChange={(value) => {
|
||||||
|
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])}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Y"
|
||||||
|
max={50}
|
||||||
|
min={-50}
|
||||||
|
onChange={(value) => {
|
||||||
|
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])}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Z"
|
||||||
|
max={50}
|
||||||
|
min={-50}
|
||||||
|
onChange={(value) => {
|
||||||
|
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])}
|
||||||
|
/>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Rotation">
|
||||||
|
<SliderControl
|
||||||
|
label="Yaw"
|
||||||
|
max={180}
|
||||||
|
min={-180}
|
||||||
|
onChange={(degrees) => previewTransform(displayPosition, degreesToRadians(degrees))}
|
||||||
|
onCommit={(degrees) => commitTransform(displayPosition, degreesToRadians(degrees))}
|
||||||
|
precision={0}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={1}
|
||||||
|
unit="°"
|
||||||
|
value={displayRotationDegrees}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1.5 px-1 pt-2 pb-1">
|
||||||
|
<ActionButton
|
||||||
|
label="-45°"
|
||||||
|
onClick={() => {
|
||||||
|
sfxEmitter.emit('sfx:item-rotate')
|
||||||
|
commitTransform(displayPosition, displayRotation - Math.PI / 4)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
label="+45°"
|
||||||
|
onClick={() => {
|
||||||
|
sfxEmitter.emit('sfx:item-rotate')
|
||||||
|
commitTransform(displayPosition, displayRotation + Math.PI / 4)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Cab">
|
||||||
|
<MetricControl
|
||||||
|
label="Width"
|
||||||
|
max={4}
|
||||||
|
min={0.8}
|
||||||
|
onChange={(value) => previewMetric('width', value)}
|
||||||
|
onCommit={(value) => commitMetric('width', value)}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={displayNode.width}
|
||||||
|
/>
|
||||||
|
<MetricControl
|
||||||
|
label="Depth"
|
||||||
|
max={4}
|
||||||
|
min={0.8}
|
||||||
|
onChange={(value) => previewMetric('depth', value)}
|
||||||
|
onCommit={(value) => commitMetric('depth', value)}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={displayNode.depth}
|
||||||
|
/>
|
||||||
|
<MetricControl
|
||||||
|
label="Cab Height"
|
||||||
|
max={4}
|
||||||
|
min={1.8}
|
||||||
|
onChange={(value) => previewMetric('cabHeight', value)}
|
||||||
|
onCommit={(value) => commitMetric('cabHeight', value)}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={displayNode.cabHeight}
|
||||||
|
/>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Shaft">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
Shaft Style
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
|
||||||
|
onChange={(event) =>
|
||||||
|
handleUpdate({ shaftStyle: event.target.value as ElevatorNode['shaftStyle'] })
|
||||||
|
}
|
||||||
|
value={displayNode.shaftStyle ?? 'solid'}
|
||||||
|
>
|
||||||
|
{SHAFT_STYLE_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<MetricControl
|
||||||
|
label="Shaft Width"
|
||||||
|
max={5}
|
||||||
|
min={displayNode.width}
|
||||||
|
onChange={(value) => previewMetric('shaftWidth', Math.max(value, displayNode.width))}
|
||||||
|
onCommit={(value) => commitMetric('shaftWidth', Math.max(value, displayNode.width))}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={displayShaftWidth}
|
||||||
|
/>
|
||||||
|
<MetricControl
|
||||||
|
label="Shaft Depth"
|
||||||
|
max={5}
|
||||||
|
min={displayNode.depth}
|
||||||
|
onChange={(value) => previewMetric('shaftDepth', Math.max(value, displayNode.depth))}
|
||||||
|
onCommit={(value) => commitMetric('shaftDepth', Math.max(value, displayNode.depth))}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={displayShaftDepth}
|
||||||
|
/>
|
||||||
|
<MetricControl
|
||||||
|
label="Wall Thickness"
|
||||||
|
max={0.4}
|
||||||
|
min={0.04}
|
||||||
|
onChange={(value) => previewMetric('shaftWallThickness', value)}
|
||||||
|
onCommit={(value) => commitMetric('shaftWallThickness', value)}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.01}
|
||||||
|
unit="m"
|
||||||
|
value={displayShaftWallThickness}
|
||||||
|
/>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Doors">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
Opening Style
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
|
||||||
|
onChange={(event) =>
|
||||||
|
handleUpdate({ doorStyle: event.target.value as ElevatorNode['doorStyle'] })
|
||||||
|
}
|
||||||
|
value={displayNode.doorStyle ?? 'center-opening'}
|
||||||
|
>
|
||||||
|
{DOOR_STYLE_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
Door Type
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
|
||||||
|
onChange={(event) =>
|
||||||
|
handleUpdate({
|
||||||
|
doorPanelStyle: event.target.value as ElevatorNode['doorPanelStyle'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
value={displayNode.doorPanelStyle ?? 'glass-frame'}
|
||||||
|
>
|
||||||
|
{DOOR_PANEL_STYLE_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<MetricControl
|
||||||
|
label="Door Width"
|
||||||
|
max={Math.max(displayNode.width - 0.1, 0.5)}
|
||||||
|
min={0.45}
|
||||||
|
onChange={(value) => previewMetric('doorWidth', value)}
|
||||||
|
onCommit={(value) => commitMetric('doorWidth', value)}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={displayNode.doorWidth}
|
||||||
|
/>
|
||||||
|
<MetricControl
|
||||||
|
label="Door Height"
|
||||||
|
max={Math.max(displayNode.cabHeight - 0.1, 1.3)}
|
||||||
|
min={1.2}
|
||||||
|
onChange={(value) => previewMetric('doorHeight', value)}
|
||||||
|
onCommit={(value) => commitMetric('doorHeight', value)}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={displayNode.doorHeight}
|
||||||
|
/>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Service">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
From
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-2 text-sm text-foreground"
|
||||||
|
onChange={(event) =>
|
||||||
|
handleServiceBoundaryChange('fromLevelId', event.target.value)
|
||||||
|
}
|
||||||
|
value={fromLevelId}
|
||||||
|
>
|
||||||
|
{levels.map((level) => (
|
||||||
|
<option key={level.id} value={level.id}>
|
||||||
|
{level.name || `Level ${level.level}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
To
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-2 text-sm text-foreground"
|
||||||
|
onChange={(event) => handleServiceBoundaryChange('toLevelId', event.target.value)}
|
||||||
|
value={toLevelId}
|
||||||
|
>
|
||||||
|
{levels.map((level) => (
|
||||||
|
<option key={level.id} value={level.id}>
|
||||||
|
{level.name || `Level ${level.level}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
Default Floor
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
|
||||||
|
onChange={(event) => handleUpdate({ defaultLevelId: event.target.value || null })}
|
||||||
|
value={selectedDefaultLevelId}
|
||||||
|
>
|
||||||
|
{defaultLevelOptions.map((level) => (
|
||||||
|
<option key={level.id} value={level.id}>
|
||||||
|
{level.name || `Level ${level.level}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Access">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{servedLevels.map((level) => {
|
||||||
|
const isDisabled = disabledLevelIds.has(level.id)
|
||||||
|
const isServiceOnly = serviceOnlyLevelIds.has(level.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between gap-2 rounded-lg border border-border/45 bg-[#2C2C2E] px-2.5 py-2"
|
||||||
|
key={level.id}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate text-sm">
|
||||||
|
{level.name || `Level ${level.level}`}
|
||||||
|
</span>
|
||||||
|
<div className="flex shrink-0 gap-1.5">
|
||||||
|
<button
|
||||||
|
className={`rounded-md border px-2 py-1 text-[11px] transition-colors ${
|
||||||
|
isServiceOnly
|
||||||
|
? 'border-sky-300/45 bg-sky-400/15 text-sky-100'
|
||||||
|
: 'border-border/50 bg-black/15 text-muted-foreground hover:text-foreground'
|
||||||
|
} ${isDisabled ? 'cursor-not-allowed opacity-45' : ''}`}
|
||||||
|
disabled={isDisabled}
|
||||||
|
onClick={() => toggleLevelAccess('serviceOnlyLevelIds', level.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Service
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`rounded-md border px-2 py-1 text-[11px] transition-colors ${
|
||||||
|
isDisabled
|
||||||
|
? 'border-red-300/45 bg-red-400/15 text-red-100'
|
||||||
|
: 'border-border/50 bg-black/15 text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleLevelAccess('disabledLevelIds', level.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Disabled
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Destination">
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{servedLevels.map((level) => {
|
||||||
|
const isActive = activeLevelId === level.id
|
||||||
|
const stopOrder = destinationOrderByLevelId.get(level.id)
|
||||||
|
const isDisabled = disabledLevelIds.has(level.id)
|
||||||
|
const isServiceOnly = serviceOnlyLevelIds.has(level.id)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`flex min-h-11 items-center justify-between gap-2 rounded-lg border px-2.5 text-left transition-colors ${
|
||||||
|
isDisabled
|
||||||
|
? 'cursor-not-allowed border-border/35 bg-[#202024] text-muted-foreground/55'
|
||||||
|
: isActive
|
||||||
|
? 'border-emerald-400/45 bg-emerald-400/15 text-emerald-100'
|
||||||
|
: 'border-border/50 bg-[#2C2C2E] text-foreground hover:bg-[#3e3e3e]'
|
||||||
|
}`}
|
||||||
|
disabled={isDisabled}
|
||||||
|
key={level.id}
|
||||||
|
onClick={() => requestLevel(level.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 flex-col">
|
||||||
|
<span className="truncate text-xs">{level.name || `Level ${level.level}`}</span>
|
||||||
|
{isDisabled ? (
|
||||||
|
<span className="mt-0.5 text-[10px] font-medium uppercase tracking-[0.12em] text-current/65">
|
||||||
|
Disabled
|
||||||
|
</span>
|
||||||
|
) : isServiceOnly ? (
|
||||||
|
<span className="mt-0.5 text-[10px] font-medium uppercase tracking-[0.12em] text-current/65">
|
||||||
|
Service
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
stopOrder && (
|
||||||
|
<span className="mt-0.5 text-[10px] font-medium uppercase tracking-[0.12em] text-current/65">
|
||||||
|
Stop {stopOrder}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`flex h-6 min-w-6 items-center justify-center rounded-full border border-white/15 bg-black/20 ${
|
||||||
|
stopOrder ? 'px-1.5 font-mono text-[11px] font-semibold' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isDisabled ? '×' : (stopOrder ?? <Send className="h-3 w-3" />)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Motion">
|
||||||
|
<SliderControl
|
||||||
|
label="Speed"
|
||||||
|
max={8}
|
||||||
|
min={0.5}
|
||||||
|
onChange={(value) => handleUpdate({ speed: value })}
|
||||||
|
precision={1}
|
||||||
|
step={0.1}
|
||||||
|
unit="m/s"
|
||||||
|
value={node.speed}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Door Time"
|
||||||
|
max={2200}
|
||||||
|
min={300}
|
||||||
|
onChange={(value) => handleUpdate({ doorDurationMs: value })}
|
||||||
|
step={50}
|
||||||
|
unit="ms"
|
||||||
|
value={node.doorDurationMs}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Dwell"
|
||||||
|
max={5000}
|
||||||
|
min={300}
|
||||||
|
onChange={(value) => handleUpdate({ dwellMs: value })}
|
||||||
|
step={100}
|
||||||
|
unit="ms"
|
||||||
|
value={node.dwellMs}
|
||||||
|
/>
|
||||||
|
</PanelSection>
|
||||||
|
</PanelWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ const TYPE_DEFAULTS: Record<string, NodeDisplay> = {
|
|||||||
slab: { icon: '/icons/floor.png', label: 'Slab' },
|
slab: { icon: '/icons/floor.png', label: 'Slab' },
|
||||||
ceiling: { icon: '/icons/ceiling.png', label: 'Ceiling' },
|
ceiling: { icon: '/icons/ceiling.png', label: 'Ceiling' },
|
||||||
column: { icon: '/icons/column.png', label: 'Column' },
|
column: { icon: '/icons/column.png', label: 'Column' },
|
||||||
|
elevator: { icon: '/icons/elevator.svg', label: 'Elevator' },
|
||||||
fence: { icon: '/icons/fence.png', label: 'Fence' },
|
fence: { icon: '/icons/fence.png', label: 'Fence' },
|
||||||
roof: { icon: '/icons/roof.png', label: 'Roof' },
|
roof: { icon: '/icons/roof.png', label: 'Roof' },
|
||||||
'roof-segment': { icon: '/icons/roof.png', label: 'Roof segment' },
|
'roof-segment': { icon: '/icons/roof.png', label: 'Roof segment' },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type CeilingNode,
|
type CeilingNode,
|
||||||
type ColumnNode,
|
type ColumnNode,
|
||||||
type DoorNode,
|
type DoorNode,
|
||||||
|
type ElevatorNode,
|
||||||
type FenceNode,
|
type FenceNode,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
type RoofNode,
|
type RoofNode,
|
||||||
@@ -26,6 +27,7 @@ import useEditor from '../../../store/use-editor'
|
|||||||
import { CeilingPanel } from './ceiling-panel'
|
import { CeilingPanel } from './ceiling-panel'
|
||||||
import { ColumnPanel } from './column-panel'
|
import { ColumnPanel } from './column-panel'
|
||||||
import { DoorPanel } from './door-panel'
|
import { DoorPanel } from './door-panel'
|
||||||
|
import { ElevatorPanel } from './elevator-panel'
|
||||||
import { FencePanel } from './fence-panel'
|
import { FencePanel } from './fence-panel'
|
||||||
import { ItemPanel } from './item-panel'
|
import { ItemPanel } from './item-panel'
|
||||||
import { MobilePanelSheet } from './mobile-panel-sheet'
|
import { MobilePanelSheet } from './mobile-panel-sheet'
|
||||||
@@ -46,6 +48,7 @@ type MovableNode =
|
|||||||
| ItemNode
|
| ItemNode
|
||||||
| WindowNode
|
| WindowNode
|
||||||
| DoorNode
|
| DoorNode
|
||||||
|
| ElevatorNode
|
||||||
| CeilingNode
|
| CeilingNode
|
||||||
| ColumnNode
|
| ColumnNode
|
||||||
| SlabNode
|
| SlabNode
|
||||||
@@ -61,6 +64,7 @@ const MOVABLE_TYPES = new Set<string>([
|
|||||||
'item',
|
'item',
|
||||||
'window',
|
'window',
|
||||||
'door',
|
'door',
|
||||||
|
'elevator',
|
||||||
'ceiling',
|
'ceiling',
|
||||||
'column',
|
'column',
|
||||||
'slab',
|
'slab',
|
||||||
@@ -104,6 +108,8 @@ function panelForType(type: string | null) {
|
|||||||
return <FencePanel />
|
return <FencePanel />
|
||||||
case 'door':
|
case 'door':
|
||||||
return <DoorPanel />
|
return <DoorPanel />
|
||||||
|
case 'elevator':
|
||||||
|
return <ElevatorPanel />
|
||||||
case 'window':
|
case 'window':
|
||||||
return <WindowPanel />
|
return <WindowPanel />
|
||||||
default:
|
default:
|
||||||
@@ -239,6 +245,5 @@ export function PanelManager() {
|
|||||||
return <PaintPanel />
|
return <PaintPanel />
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show appropriate panel based on selected node type
|
|
||||||
return panelForType(selectedNodeType)
|
return panelForType(selectedNodeType)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function SlabPanel() {
|
|||||||
(index: number) => {
|
(index: number) => {
|
||||||
if (!selectedId) return
|
if (!selectedId) return
|
||||||
const currentHoles = node?.holes || []
|
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 newHoles = currentHoles.filter((_, i) => i !== index)
|
||||||
const currentMetadata = currentHoles.map(
|
const currentMetadata = currentHoles.map(
|
||||||
(_, metadataIndex) => node?.holeMetadata?.[metadataIndex] ?? { source: 'manual' as const },
|
(_, metadataIndex) => node?.holeMetadata?.[metadataIndex] ?? { source: 'manual' as const },
|
||||||
@@ -173,7 +173,8 @@ export function SlabPanel() {
|
|||||||
const isEditing =
|
const isEditing =
|
||||||
editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
|
editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
|
||||||
const source = node.holeMetadata?.[index]?.source ?? 'manual'
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
|
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
|
||||||
@@ -191,7 +192,7 @@ export function SlabPanel() {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
{holeArea.toFixed(2)} m² · {hole.length} pts ·{' '}
|
{holeArea.toFixed(2)} m² · {hole.length} pts ·{' '}
|
||||||
{isAutoHole ? 'Auto stair cutout' : 'Manual'}
|
{isAutoHole ? autoLabel : 'Manual'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|||||||
@@ -39,8 +39,10 @@ export const BuildingTreeNode = memo(function BuildingTreeNode({
|
|||||||
|
|
||||||
const handleAddLevel = (e: React.MouseEvent) => {
|
const handleAddLevel = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
const nodes = useScene.getState().nodes
|
||||||
|
const levelCount = children.filter((childId) => nodes[childId]?.type === 'level').length
|
||||||
const newLevel = LevelNode.parse({
|
const newLevel = LevelNode.parse({
|
||||||
level: children.length,
|
level: levelCount,
|
||||||
children: [],
|
children: [],
|
||||||
parentId: nodeId,
|
parentId: nodeId,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { type AnyNodeId, type ElevatorNode, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { memo, useCallback, useState } from 'react'
|
||||||
|
import useEditor from './../../../../../store/use-editor'
|
||||||
|
import { InlineRenameInput } from './inline-rename-input'
|
||||||
|
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||||
|
import { TreeNodeActions } from './tree-node-actions'
|
||||||
|
|
||||||
|
interface ElevatorTreeNodeProps {
|
||||||
|
nodeId: AnyNodeId
|
||||||
|
depth: number
|
||||||
|
isLast?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ElevatorTreeNode = memo(function ElevatorTreeNode({
|
||||||
|
nodeId,
|
||||||
|
depth,
|
||||||
|
isLast,
|
||||||
|
}: ElevatorTreeNodeProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||||
|
const node = useScene((s) => s.nodes[nodeId] as ElevatorNode | undefined)
|
||||||
|
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
||||||
|
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
||||||
|
const setSelection = useViewer((state) => state.setSelection)
|
||||||
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
const handled = handleTreeSelection(
|
||||||
|
event,
|
||||||
|
nodeId,
|
||||||
|
useViewer.getState().selection.selectedIds,
|
||||||
|
setSelection,
|
||||||
|
)
|
||||||
|
if (!handled && useEditor.getState().phase === 'furnish') {
|
||||||
|
useEditor.getState().setPhase('structure')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[nodeId, setSelection],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TreeNodeWrapper
|
||||||
|
actions={<TreeNodeActions nodeId={nodeId} />}
|
||||||
|
depth={depth}
|
||||||
|
expanded={false}
|
||||||
|
hasChildren={false}
|
||||||
|
icon={
|
||||||
|
<Image alt="" className="object-contain" height={14} src="/icons/elevator.svg" width={14} />
|
||||||
|
}
|
||||||
|
isHovered={isHovered}
|
||||||
|
isLast={isLast}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isVisible={isVisible}
|
||||||
|
label={
|
||||||
|
<InlineRenameInput
|
||||||
|
defaultName={node?.name || 'Elevator'}
|
||||||
|
isEditing={isEditing}
|
||||||
|
nodeId={nodeId}
|
||||||
|
onStartEditing={() => setIsEditing(true)}
|
||||||
|
onStopEditing={() => setIsEditing(false)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
nodeId={nodeId}
|
||||||
|
onClick={handleClick}
|
||||||
|
onDoubleClick={() => focusTreeNode(nodeId)}
|
||||||
|
onMouseEnter={() => setHoveredId(nodeId)}
|
||||||
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
|
onToggle={() => {}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -58,6 +58,7 @@ import { BuildingTreeNode } from './building-tree-node'
|
|||||||
import { CeilingTreeNode } from './ceiling-tree-node'
|
import { CeilingTreeNode } from './ceiling-tree-node'
|
||||||
import { ColumnTreeNode } from './column-tree-node'
|
import { ColumnTreeNode } from './column-tree-node'
|
||||||
import { DoorTreeNode } from './door-tree-node'
|
import { DoorTreeNode } from './door-tree-node'
|
||||||
|
import { ElevatorTreeNode } from './elevator-tree-node'
|
||||||
import { FenceTreeNode } from './fence-tree-node'
|
import { FenceTreeNode } from './fence-tree-node'
|
||||||
import { ItemTreeNode } from './item-tree-node'
|
import { ItemTreeNode } from './item-tree-node'
|
||||||
import { LevelTreeNode } from './level-tree-node'
|
import { LevelTreeNode } from './level-tree-node'
|
||||||
@@ -89,6 +90,8 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr
|
|||||||
return <CeilingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
return <CeilingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||||
case 'column':
|
case 'column':
|
||||||
return <ColumnTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
return <ColumnTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||||
|
case 'elevator':
|
||||||
|
return <ElevatorTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||||
case 'level':
|
case 'level':
|
||||||
return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `level_${string}`} />
|
return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `level_${string}`} />
|
||||||
case 'slab':
|
case 'slab':
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export function useContextualTools() {
|
|||||||
'slab',
|
'slab',
|
||||||
'ceiling',
|
'ceiling',
|
||||||
'roof',
|
'roof',
|
||||||
|
'elevator',
|
||||||
'door',
|
'door',
|
||||||
'window',
|
'window',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -134,13 +134,19 @@ export const useKeyboard = ({
|
|||||||
const { buildingId, levelId } = useViewer.getState().selection
|
const { buildingId, levelId } = useViewer.getState().selection
|
||||||
if (buildingId) {
|
if (buildingId) {
|
||||||
const building = useScene.getState().nodes[buildingId]
|
const building = useScene.getState().nodes[buildingId]
|
||||||
if (building && building.type === 'building' && building.children.length > 0) {
|
const levels =
|
||||||
const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1
|
building?.type === 'building'
|
||||||
const nextIdx = currentIdx < building.children.length - 1 ? currentIdx + 1 : currentIdx
|
? 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) {
|
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) {
|
} 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
|
const { buildingId, levelId } = useViewer.getState().selection
|
||||||
if (buildingId) {
|
if (buildingId) {
|
||||||
const building = useScene.getState().nodes[buildingId]
|
const building = useScene.getState().nodes[buildingId]
|
||||||
if (building && building.type === 'building' && building.children.length > 0) {
|
const levels =
|
||||||
const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1
|
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
|
const prevIdx = currentIdx > 0 ? currentIdx - 1 : currentIdx
|
||||||
if (prevIdx !== -1 && prevIdx !== 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) {
|
} else if (currentIdx === -1) {
|
||||||
useViewer
|
useViewer.getState().setSelection({ levelId: levels[levels.length - 1] as any })
|
||||||
.getState()
|
|
||||||
.setSelection({ levelId: building.children[building.children.length - 1] as any })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, AnyNode>,
|
||||||
|
): 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<string, AnyNode>
|
||||||
|
}): 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],
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
CeilingNode,
|
CeilingNode,
|
||||||
ColumnNode,
|
ColumnNode,
|
||||||
DoorNode,
|
DoorNode,
|
||||||
|
ElevatorNode,
|
||||||
ItemNode,
|
ItemNode,
|
||||||
Point2D,
|
Point2D,
|
||||||
RoofNode,
|
RoofNode,
|
||||||
@@ -59,6 +60,11 @@ type ColumnEntry = {
|
|||||||
polygon: Point2D[]
|
polygon: Point2D[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ElevatorEntry = {
|
||||||
|
elevator: ElevatorNode
|
||||||
|
polygon: Point2D[]
|
||||||
|
}
|
||||||
|
|
||||||
type RoofEntry = {
|
type RoofEntry = {
|
||||||
roof: RoofNode
|
roof: RoofNode
|
||||||
segments: Array<{
|
segments: Array<{
|
||||||
@@ -78,6 +84,7 @@ type FloorplanSelectionToolContext = {
|
|||||||
slabs: SlabEntry[]
|
slabs: SlabEntry[]
|
||||||
ceilings: CeilingEntry[]
|
ceilings: CeilingEntry[]
|
||||||
columns: ColumnEntry[]
|
columns: ColumnEntry[]
|
||||||
|
elevators: ElevatorEntry[]
|
||||||
roofs: RoofEntry[]
|
roofs: RoofEntry[]
|
||||||
openingHitTolerance: number
|
openingHitTolerance: number
|
||||||
wallHitTolerance: number
|
wallHitTolerance: number
|
||||||
@@ -130,6 +137,13 @@ export function getFloorplanHitNodeId(context: FloorplanSelectionToolContext) {
|
|||||||
return stairHit.stair.id
|
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 }) =>
|
const columnHit = context.columns.find(({ polygon }) =>
|
||||||
isPointInsidePolygon(context.point, polygon),
|
isPointInsidePolygon(context.point, polygon),
|
||||||
)
|
)
|
||||||
@@ -181,6 +195,7 @@ type FloorplanSelectionBoundsContext = {
|
|||||||
slabs: SlabEntry[]
|
slabs: SlabEntry[]
|
||||||
ceilings: CeilingEntry[]
|
ceilings: CeilingEntry[]
|
||||||
columns: ColumnEntry[]
|
columns: ColumnEntry[]
|
||||||
|
elevators: ElevatorEntry[]
|
||||||
stairs: StairEntry[]
|
stairs: StairEntry[]
|
||||||
roofs: RoofEntry[]
|
roofs: RoofEntry[]
|
||||||
}
|
}
|
||||||
@@ -195,6 +210,7 @@ export function getFloorplanSelectionIdsInBounds({
|
|||||||
slabs,
|
slabs,
|
||||||
ceilings,
|
ceilings,
|
||||||
columns,
|
columns,
|
||||||
|
elevators,
|
||||||
stairs,
|
stairs,
|
||||||
roofs,
|
roofs,
|
||||||
}: FloorplanSelectionBoundsContext) {
|
}: FloorplanSelectionBoundsContext) {
|
||||||
@@ -223,6 +239,9 @@ export function getFloorplanSelectionIdsInBounds({
|
|||||||
const columnIds = columns
|
const columnIds = columns
|
||||||
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
|
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
|
||||||
.map(({ column }) => column.id)
|
.map(({ column }) => column.id)
|
||||||
|
const elevatorIds = elevators
|
||||||
|
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
|
||||||
|
.map(({ elevator }) => elevator.id)
|
||||||
const stairIds = stairs
|
const stairIds = stairs
|
||||||
.filter((stair) =>
|
.filter((stair) =>
|
||||||
getStairHitPolygons(stair).some((polygon) =>
|
getStairHitPolygons(stair).some((polygon) =>
|
||||||
@@ -244,6 +263,7 @@ export function getFloorplanSelectionIdsInBounds({
|
|||||||
...slabIds,
|
...slabIds,
|
||||||
...ceilingIds,
|
...ceilingIds,
|
||||||
...columnIds,
|
...columnIds,
|
||||||
|
...elevatorIds,
|
||||||
...stairIds,
|
...stairIds,
|
||||||
...roofIds,
|
...roofIds,
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useLiveTransforms, useScene } from '@pascal-app/core'
|
import { useLiveNodeOverrides, useLiveTransforms, useScene } from '@pascal-app/core'
|
||||||
|
|
||||||
function refreshSceneAfterHistoryJump() {
|
function refreshSceneAfterHistoryJump() {
|
||||||
|
useLiveNodeOverrides.getState().clearAll()
|
||||||
useLiveTransforms.getState().clearAll()
|
useLiveTransforms.getState().clearAll()
|
||||||
|
|
||||||
const state = useScene.getState()
|
const state = useScene.getState()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type CeilingNode,
|
type CeilingNode,
|
||||||
type ColumnNode,
|
type ColumnNode,
|
||||||
type DoorNode,
|
type DoorNode,
|
||||||
|
type ElevatorNode,
|
||||||
type FenceNode,
|
type FenceNode,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
type LevelNode,
|
type LevelNode,
|
||||||
@@ -27,7 +28,6 @@ import {
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { persist } from 'zustand/middleware'
|
import { persist } from 'zustand/middleware'
|
||||||
import { getDefaultCatalogItem } from '../components/ui/item-catalog/catalog-items'
|
|
||||||
import {
|
import {
|
||||||
type ActivePaintMaterial,
|
type ActivePaintMaterial,
|
||||||
type PaintableMaterialTarget,
|
type PaintableMaterialTarget,
|
||||||
@@ -58,6 +58,7 @@ export type StructureTool =
|
|||||||
| 'ceiling'
|
| 'ceiling'
|
||||||
| 'roof'
|
| 'roof'
|
||||||
| 'column'
|
| 'column'
|
||||||
|
| 'elevator'
|
||||||
| 'stair'
|
| 'stair'
|
||||||
| 'item'
|
| 'item'
|
||||||
| 'zone'
|
| 'zone'
|
||||||
@@ -138,6 +139,7 @@ type EditorState = {
|
|||||||
| ItemNode
|
| ItemNode
|
||||||
| WindowNode
|
| WindowNode
|
||||||
| DoorNode
|
| DoorNode
|
||||||
|
| ElevatorNode
|
||||||
| CeilingNode
|
| CeilingNode
|
||||||
| ColumnNode
|
| ColumnNode
|
||||||
| SlabNode
|
| SlabNode
|
||||||
@@ -155,6 +157,7 @@ type EditorState = {
|
|||||||
| ItemNode
|
| ItemNode
|
||||||
| WindowNode
|
| WindowNode
|
||||||
| DoorNode
|
| DoorNode
|
||||||
|
| ElevatorNode
|
||||||
| CeilingNode
|
| CeilingNode
|
||||||
| ColumnNode
|
| ColumnNode
|
||||||
| SlabNode
|
| SlabNode
|
||||||
@@ -453,9 +456,14 @@ export function selectDefaultBuildingAndLevel() {
|
|||||||
})
|
})
|
||||||
if (level0Id) {
|
if (level0Id) {
|
||||||
viewer.setSelection({ levelId: level0Id as LevelNode['id'] })
|
viewer.setSelection({ levelId: level0Id as LevelNode['id'] })
|
||||||
} else if (buildingNode.children[0]) {
|
} else {
|
||||||
// Fallback to first level if level 0 doesn't exist
|
// 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<EditorState>()(
|
|||||||
| ItemNode
|
| ItemNode
|
||||||
| WindowNode
|
| WindowNode
|
||||||
| DoorNode
|
| DoorNode
|
||||||
|
| ElevatorNode
|
||||||
| CeilingNode
|
| CeilingNode
|
||||||
|
| ColumnNode
|
||||||
| SlabNode
|
| SlabNode
|
||||||
| WallNode
|
| WallNode
|
||||||
| FenceNode
|
| FenceNode
|
||||||
| RoofNode
|
| RoofNode
|
||||||
| RoofSegmentNode
|
| RoofSegmentNode
|
||||||
|
| SpawnNode
|
||||||
| StairNode
|
| StairNode
|
||||||
| StairSegmentNode
|
| StairSegmentNode
|
||||||
| BuildingNode
|
| BuildingNode
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import { BuildingRenderer } from './building/building-renderer'
|
|||||||
import { CeilingRenderer } from './ceiling/ceiling-renderer'
|
import { CeilingRenderer } from './ceiling/ceiling-renderer'
|
||||||
import { ColumnRenderer } from './column/column-renderer'
|
import { ColumnRenderer } from './column/column-renderer'
|
||||||
import { DoorRenderer } from './door/door-renderer'
|
import { DoorRenderer } from './door/door-renderer'
|
||||||
|
import { ElevatorRenderer } from './elevator/elevator-renderer'
|
||||||
import { FenceRenderer } from './fence/fence-renderer'
|
import { FenceRenderer } from './fence/fence-renderer'
|
||||||
import { GuideRenderer } from './guide/guide-renderer'
|
import { GuideRenderer } from './guide/guide-renderer'
|
||||||
import { ItemRenderer } from './item/item-renderer'
|
import { ItemRenderer } from './item/item-renderer'
|
||||||
@@ -32,6 +33,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
|
|||||||
{node.type === 'building' && <BuildingRenderer node={node} />}
|
{node.type === 'building' && <BuildingRenderer node={node} />}
|
||||||
{node.type === 'ceiling' && <CeilingRenderer node={node} />}
|
{node.type === 'ceiling' && <CeilingRenderer node={node} />}
|
||||||
{node.type === 'column' && <ColumnRenderer node={node} />}
|
{node.type === 'column' && <ColumnRenderer node={node} />}
|
||||||
|
{node.type === 'elevator' && <ElevatorRenderer node={node} />}
|
||||||
{node.type === 'level' && <LevelRenderer node={node} />}
|
{node.type === 'level' && <LevelRenderer node={node} />}
|
||||||
{node.type === 'item' && <ItemRenderer node={node} />}
|
{node.type === 'item' && <ItemRenderer node={node} />}
|
||||||
{node.type === 'slab' && <SlabRenderer node={node} />}
|
{node.type === 'slab' && <SlabRenderer node={node} />}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useRegistry, useScene, type SiteNode, type SlabNode } from '@pascal-app
|
|||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
import { BufferGeometry, Float32BufferAttribute, Path, Shape, type Group } from 'three'
|
import { BufferGeometry, Float32BufferAttribute, Path, Shape, type Group } from 'three'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
|
import { unionPolygons } from '../../../lib/polygon-union'
|
||||||
import useViewer from '../../../store/use-viewer'
|
import useViewer from '../../../store/use-viewer'
|
||||||
import { NodeRenderer } from '../node-renderer'
|
import { NodeRenderer } from '../node-renderer'
|
||||||
|
|
||||||
@@ -87,17 +88,16 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
|||||||
for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i]![0], -pts[i]![1])
|
for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i]![0], -pts[i]![1])
|
||||||
shape.closePath()
|
shape.closePath()
|
||||||
|
|
||||||
for (const polygon of slabPolygons) {
|
if (slabPolygons.length > 0) {
|
||||||
if (polygon.length < 3) continue
|
for (const ring of unionPolygons(slabPolygons.map((p) => p.map((pt) => [pt[0], -pt[1]])))) {
|
||||||
|
if (ring.length < 3) continue
|
||||||
const hole = new Path()
|
const hole = new Path()
|
||||||
hole.moveTo(polygon[0]![0], -polygon[0]![1])
|
hole.moveTo(ring[0]![0], ring[0]![1])
|
||||||
for (let i = 1; i < polygon.length; i++) {
|
for (let i = 1; i < ring.length; i++) hole.lineTo(ring[i]![0], ring[i]![1])
|
||||||
hole.lineTo(polygon[i]![0], -polygon[i]![1])
|
|
||||||
}
|
|
||||||
hole.closePath()
|
hole.closePath()
|
||||||
shape.holes.push(hole)
|
shape.holes.push(hole)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return shape
|
return shape
|
||||||
}, [node?.polygon?.points, slabPolygons])
|
}, [node?.polygon?.points, slabPolygons])
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type LevelNode, useScene } from '@pascal-app/core'
|
import { type LevelNode, useScene } from '@pascal-app/core'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { unionPolygons } from '../../lib/polygon-union'
|
||||||
import useViewer from '../../store/use-viewer'
|
import useViewer from '../../store/use-viewer'
|
||||||
|
|
||||||
export const GroundOccluder = () => {
|
export const GroundOccluder = () => {
|
||||||
@@ -62,17 +63,19 @@ export const GroundOccluder = () => {
|
|||||||
polygons.push(node.polygon as [number, number][])
|
polygons.push(node.polygon as [number, number][])
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const polygon of polygons) {
|
if (polygons.length > 0) {
|
||||||
if (polygon.length < 3) continue
|
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.moveTo(ring[0]![0], ring[0]![1])
|
||||||
hole.lineTo(polygon[i]![0], -polygon[i]![1])
|
for (let i = 1; i < ring.length; i++) {
|
||||||
|
hole.lineTo(ring[i]![0], ring[i]![1])
|
||||||
}
|
}
|
||||||
hole.closePath()
|
hole.closePath()
|
||||||
s.holes.push(hole)
|
s.holes.push(hole)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return s
|
return s
|
||||||
}, [nodes])
|
}, [nodes])
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'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 { useEffect, useMemo, useRef } from 'react'
|
||||||
import * as THREE from 'three/webgpu'
|
import * as THREE from 'three/webgpu'
|
||||||
import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf'
|
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 { CeilingSystem } from '../../systems/ceiling/ceiling-system'
|
||||||
import { DoorAnimationSystem } from '../../systems/door/door-animation-system'
|
import { DoorAnimationSystem } from '../../systems/door/door-animation-system'
|
||||||
import { DoorSystem } from '../../systems/door/door-system'
|
import { DoorSystem } from '../../systems/door/door-system'
|
||||||
|
import { ElevatorInteractionSystem } from '../../systems/elevator/elevator-interaction-system'
|
||||||
import { FenceSystem } from '../../systems/fence/fence-system'
|
import { FenceSystem } from '../../systems/fence/fence-system'
|
||||||
import { GuideSystem } from '../../systems/guide/guide-system'
|
import { GuideSystem } from '../../systems/guide/guide-system'
|
||||||
import { ItemLightSystem } from '../../systems/item-light/item-light-system'
|
import { ItemLightSystem } from '../../systems/item-light/item-light-system'
|
||||||
@@ -106,7 +108,7 @@ function GPUDeviceWatcher() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const backend = (gl as any).backend
|
const backend = (gl as any).backend
|
||||||
const device: GPUDevice | undefined = backend?.device
|
const device = backend?.device as WebGPUDeviceLike | undefined
|
||||||
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
console.warn('[viewer] No WebGPU device on backend — running on a fallback renderer.', {
|
console.warn('[viewer] No WebGPU device on backend — running on a fallback renderer.', {
|
||||||
@@ -121,22 +123,22 @@ function GPUDeviceWatcher() {
|
|||||||
features: Array.from(device.features ?? []),
|
features: Array.from(device.features ?? []),
|
||||||
})
|
})
|
||||||
|
|
||||||
device.lost.then((info) => {
|
device.lost.then((info: WebGPUDeviceLossInfo) => {
|
||||||
console.error(
|
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.',
|
'The page must be reloaded to recover the GPU context.',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Uncaptured errors are normally silent (only console-warned by Chrome at
|
// Uncaptured errors are normally silent (only console-warned by Chrome at
|
||||||
// best). Pipe them to console.error so silent mobile crashes show up.
|
// best). Pipe them to console.error so silent mobile crashes show up.
|
||||||
const onUncapturedError = (event: GPUUncapturedErrorEvent) => {
|
const onUncapturedError = (event: any) => {
|
||||||
console.error('[viewer] WebGPU uncaptured error:', event.error.message, event.error)
|
console.error('[viewer] WebGPU uncaptured error:', event?.error?.message, event?.error)
|
||||||
}
|
}
|
||||||
device.addEventListener('uncapturederror', onUncapturedError as EventListener)
|
device.addEventListener?.('uncapturederror', onUncapturedError)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
device.removeEventListener('uncapturederror', onUncapturedError as EventListener)
|
device.removeEventListener?.('uncapturederror', onUncapturedError)
|
||||||
}
|
}
|
||||||
}, [gl])
|
}, [gl])
|
||||||
|
|
||||||
@@ -175,24 +177,12 @@ const Viewer: React.FC<ViewerProps> = ({
|
|||||||
const canvas = props.canvas
|
const canvas = props.canvas
|
||||||
const cached = canvas ? WEBGPU_RENDERER_CACHE.get(canvas) : undefined
|
const cached = canvas ? WEBGPU_RENDERER_CACHE.get(canvas) : undefined
|
||||||
if (cached) return cached
|
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 () => {
|
const promise = (async () => {
|
||||||
try {
|
try {
|
||||||
const renderer = new THREE.WebGPURenderer(props as any)
|
const renderer = new THREE.WebGPURenderer(props as any)
|
||||||
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
||||||
renderer.toneMappingExposure = 0.9
|
renderer.toneMappingExposure = 0.9
|
||||||
await renderer.init()
|
await renderer.init()
|
||||||
console.log('[viewer] WebGPURenderer ready', {
|
|
||||||
backend: (renderer as any).backend?.constructor?.name,
|
|
||||||
isWebGPU: (renderer as any).isWebGPURenderer === true,
|
|
||||||
})
|
|
||||||
return renderer
|
return renderer
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Drop the failed promise from the cache so a future Canvas
|
// Drop the failed promise from the cache so a future Canvas
|
||||||
@@ -240,6 +230,9 @@ const Viewer: React.FC<ViewerProps> = ({
|
|||||||
{/* Core systems */}
|
{/* Core systems */}
|
||||||
<CeilingSystem />
|
<CeilingSystem />
|
||||||
<DoorAnimationSystem />
|
<DoorAnimationSystem />
|
||||||
|
<ElevatorRuntimeSystem />
|
||||||
|
<ElevatorInteractionSystem />
|
||||||
|
<ElevatorOpeningSystem />
|
||||||
<WindowAnimationSystem />
|
<WindowAnimationSystem />
|
||||||
<DoorSystem />
|
<DoorSystem />
|
||||||
<FenceSystem />
|
<FenceSystem />
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ const PostProcessingPasses = ({
|
|||||||
|
|
||||||
// Subscribe to projectId so the pipeline rebuilds on project switch
|
// Subscribe to projectId so the pipeline rebuilds on project switch
|
||||||
const projectId = useViewer((s) => s.projectId)
|
const projectId = useViewer((s) => s.projectId)
|
||||||
|
const lastProjectIdRef = useRef(projectId)
|
||||||
|
|
||||||
// Bump this to force a pipeline rebuild (used by retry logic)
|
// Bump this to force a pipeline rebuild (used by retry logic)
|
||||||
const [pipelineVersion, setPipelineVersion] = useState(0)
|
const [pipelineVersion, setPipelineVersion] = useState(0)
|
||||||
@@ -169,6 +170,8 @@ const PostProcessingPasses = ({
|
|||||||
|
|
||||||
// Reset retry state when project changes
|
// Reset retry state when project changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (lastProjectIdRef.current === projectId) return
|
||||||
|
lastProjectIdRef.current = projectId
|
||||||
retryCountRef.current = 0
|
retryCountRef.current = 0
|
||||||
if (rebuildTimeoutRef.current !== null) {
|
if (rebuildTimeoutRef.current !== null) {
|
||||||
clearTimeout(rebuildTimeoutRef.current)
|
clearTimeout(rebuildTimeoutRef.current)
|
||||||
@@ -231,6 +234,21 @@ const PostProcessingPasses = ({
|
|||||||
|
|
||||||
hasPipelineErrorRef.current = false
|
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
|
// Clear outliner arrays synchronously to prevent stale Object3D refs
|
||||||
// from the previous project leaking into the new pipeline's outline passes.
|
// from the previous project leaking into the new pipeline's outline passes.
|
||||||
const outliner = useViewer.getState().outliner
|
const outliner = useViewer.getState().outliner
|
||||||
@@ -361,7 +379,6 @@ const PostProcessingPasses = ({
|
|||||||
renderPipeline.outputNode = finalOutput
|
renderPipeline.outputNode = finalOutput
|
||||||
renderPipelineRef.current = renderPipeline
|
renderPipelineRef.current = renderPipeline
|
||||||
retryCountRef.current = 0
|
retryCountRef.current = 0
|
||||||
console.log('[viewer/post-processing] Pipeline built OK', { version: pipelineVersion })
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
hasPipelineErrorRef.current = true
|
hasPipelineErrorRef.current = true
|
||||||
console.error(
|
console.error(
|
||||||
@@ -388,6 +405,7 @@ const PostProcessingPasses = ({
|
|||||||
}, [
|
}, [
|
||||||
camera,
|
camera,
|
||||||
hoverHiddenColor,
|
hoverHiddenColor,
|
||||||
|
hoverHighlightMode,
|
||||||
hoverPulseMix,
|
hoverPulseMix,
|
||||||
hoverStrength,
|
hoverStrength,
|
||||||
hoverVisibleColor,
|
hoverVisibleColor,
|
||||||
@@ -463,9 +481,6 @@ const PostProcessingPasses = ({
|
|||||||
if (retryCountRef.current < MAX_PIPELINE_RETRIES) {
|
if (retryCountRef.current < MAX_PIPELINE_RETRIES) {
|
||||||
// Auto-retry: schedule a pipeline rebuild if we haven't exceeded the retry limit
|
// Auto-retry: schedule a pipeline rebuild if we haven't exceeded the retry limit
|
||||||
retryCountRef.current++
|
retryCountRef.current++
|
||||||
console.warn(
|
|
||||||
`[viewer/post-processing] Scheduling pipeline rebuild (attempt ${retryCountRef.current}/${MAX_PIPELINE_RETRIES})`,
|
|
||||||
)
|
|
||||||
if (rebuildTimeoutRef.current !== null) {
|
if (rebuildTimeoutRef.current !== null) {
|
||||||
clearTimeout(rebuildTimeoutRef.current)
|
clearTimeout(rebuildTimeoutRef.current)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
type ColumnNode,
|
type ColumnNode,
|
||||||
type DoorEvent,
|
type DoorEvent,
|
||||||
type DoorNode,
|
type DoorNode,
|
||||||
|
type ElevatorEvent,
|
||||||
|
type ElevatorNode,
|
||||||
type EventSuffix,
|
type EventSuffix,
|
||||||
emitter,
|
emitter,
|
||||||
type FenceEvent,
|
type FenceEvent,
|
||||||
@@ -57,6 +59,7 @@ type NodeConfig = {
|
|||||||
'stair-segment': { node: StairSegmentNode; event: StairSegmentEvent }
|
'stair-segment': { node: StairSegmentNode; event: StairSegmentEvent }
|
||||||
window: { node: WindowNode; event: WindowEvent }
|
window: { node: WindowNode; event: WindowEvent }
|
||||||
door: { node: DoorNode; event: DoorEvent }
|
door: { node: DoorNode; event: DoorEvent }
|
||||||
|
elevator: { node: ElevatorNode; event: ElevatorEvent }
|
||||||
}
|
}
|
||||||
|
|
||||||
type NodeType = keyof NodeConfig
|
type NodeType = keyof NodeConfig
|
||||||
|
|||||||
@@ -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])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string, Segment[]>()
|
||||||
|
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<string, Segment[]>()
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type AnyNodeId, type CeilingNode, sceneRegistry, useScene } from '@pascal-app/core'
|
import { type AnyNodeId, type CeilingNode, sceneRegistry, useScene } from '@pascal-app/core'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { mergeSurfaceHolePolygons } from '../surface-hole-geometry'
|
||||||
|
|
||||||
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
|
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
|
||||||
const uv = geometry.getAttribute('uv')
|
const uv = geometry.getAttribute('uv')
|
||||||
@@ -82,7 +83,7 @@ export function generateCeilingGeometry(ceilingNode: CeilingNode): THREE.BufferG
|
|||||||
shape.closePath()
|
shape.closePath()
|
||||||
|
|
||||||
// Add holes to the shape
|
// Add holes to the shape
|
||||||
const holes = ceilingNode.holes || []
|
const holes = mergeSurfaceHolePolygons(ceilingNode.holes || [])
|
||||||
for (const holePolygon of holes) {
|
for (const holePolygon of holes) {
|
||||||
if (holePolygon.length < 3) continue
|
if (holePolygon.length < 3) continue
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { mergeSurfaceHolePolygons } from '../surface-hole-geometry'
|
||||||
|
|
||||||
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
|
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
|
||||||
const uv = geometry.getAttribute('uv')
|
const uv = geometry.getAttribute('uv')
|
||||||
@@ -86,6 +87,7 @@ export function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
|
|||||||
function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
|
function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
|
||||||
const polygon = getRenderableSlabPolygon(slabNode)
|
const polygon = getRenderableSlabPolygon(slabNode)
|
||||||
const elevation = slabNode.elevation ?? 0.05
|
const elevation = slabNode.elevation ?? 0.05
|
||||||
|
const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? [])
|
||||||
|
|
||||||
if (polygon.length < 3) return new THREE.BufferGeometry()
|
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])
|
for (let i = 1; i < polygon.length; i++) shape.lineTo(polygon[i]![0], -polygon[i]![1])
|
||||||
shape.closePath()
|
shape.closePath()
|
||||||
|
|
||||||
for (const holePolygon of slabNode.holes ?? []) {
|
for (const holePolygon of holePolygons) {
|
||||||
if (holePolygon.length < 3) continue
|
if (holePolygon.length < 3) continue
|
||||||
const holePath = new THREE.Path()
|
const holePath = new THREE.Path()
|
||||||
holePath.moveTo(holePolygon[0]![0], -holePolygon[0]![1])
|
holePath.moveTo(holePolygon[0]![0], -holePolygon[0]![1])
|
||||||
@@ -122,6 +124,7 @@ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry
|
|||||||
function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
|
function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
|
||||||
const polygon = getRenderableSlabPolygon(slabNode)
|
const polygon = getRenderableSlabPolygon(slabNode)
|
||||||
const depth = Math.abs(slabNode.elevation ?? 0.05)
|
const depth = Math.abs(slabNode.elevation ?? 0.05)
|
||||||
|
const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? [])
|
||||||
|
|
||||||
if (polygon.length < 3) return new THREE.BufferGeometry()
|
if (polygon.length < 3) return new THREE.BufferGeometry()
|
||||||
|
|
||||||
@@ -134,7 +137,7 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
|
|||||||
for (const [x, z] of polygon) {
|
for (const [x, z] of polygon) {
|
||||||
bounds.expandByPoint(new THREE.Vector2(x, z))
|
bounds.expandByPoint(new THREE.Vector2(x, z))
|
||||||
}
|
}
|
||||||
for (const hole of slabNode.holes ?? []) {
|
for (const hole of holePolygons) {
|
||||||
for (const [x, z] of hole) {
|
for (const [x, z] of hole) {
|
||||||
bounds.expandByPoint(new THREE.Vector2(x, z))
|
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!)
|
for (const [x, z] of polygon) pushFloorVertex(x!, 0, z!)
|
||||||
|
|
||||||
const pts2d = polygon.map(([x, z]) => new THREE.Vector2(x!, 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!)))
|
const holesPts2d = holePolygons.map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
|
||||||
for (const hole of slabNode.holes ?? []) {
|
for (const hole of holePolygons) {
|
||||||
for (const [x, z] of hole) pushFloorVertex(x!, 0, z!)
|
for (const [x, z] of hole) pushFloorVertex(x!, 0, z!)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { type Point2D, unionPolygons } from '../lib/polygon-union'
|
||||||
|
|
||||||
|
export function mergeSurfaceHolePolygons(holes: Point2D[][]): Point2D[][] {
|
||||||
|
return unionPolygons(holes)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user