Optimize elevator editing performance with live previews

This commit is contained in:
sudhir
2026-05-09 19:09:48 +05:30
parent 0514363136
commit 2480be91b1
59 changed files with 4299 additions and 176 deletions
+3
View File
@@ -6,6 +6,7 @@ import type {
CeilingNode,
ColumnNode,
DoorNode,
ElevatorNode,
FenceNode,
GuideNode,
ItemNode,
@@ -65,6 +66,7 @@ export type StairEvent = NodeEvent<StairNode>
export type StairSegmentEvent = NodeEvent<StairSegmentNode>
export type WindowEvent = NodeEvent<WindowNode>
export type DoorEvent = NodeEvent<DoorNode>
export type ElevatorEvent = NodeEvent<ElevatorNode>
// Event suffixes, exported for use in hooks
export const eventSuffixes = [
@@ -180,6 +182,7 @@ type EditorEvents = GridEvents &
NodeEvents<'item', ItemEvent> &
NodeEvents<'site', SiteEvent> &
NodeEvents<'building', BuildingEvent> &
NodeEvents<'elevator', ElevatorEvent> &
NodeEvents<'level', LevelEvent> &
NodeEvents<'zone', ZoneEvent> &
NodeEvents<'slab', SlabEvent> &
@@ -14,6 +14,7 @@ export const sceneRegistry = {
building: new Set<string>(),
ceiling: new Set<string>(),
column: new Set<string>(),
elevator: new Set<string>(),
level: new Set<string>(),
wall: new Set<string>(),
fence: new Set<string>(),
+13
View File
@@ -5,6 +5,7 @@ export type {
CeilingEvent,
ColumnEvent,
DoorEvent,
ElevatorEvent,
EventSuffix,
FenceEvent,
GridEvent,
@@ -71,13 +72,25 @@ export {
type ControlValue,
type DoorAnimationState,
type DoorInteractiveState,
type ElevatorInteractiveState,
type ElevatorPhase,
type ItemInteractiveState,
useInteractive,
type WindowAnimationState,
type WindowInteractiveState,
} from './store/use-interactive'
export {
default as useLiveNodeOverrides,
type LiveNodeOverrides,
} from './store/use-live-node-overrides'
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
export { clearSceneHistory, default as useScene } from './store/use-scene'
export { syncAutoElevatorOpenings } from './systems/elevator/elevator-opening-sync'
export {
resolveElevatorBuildingLevels,
resolveElevatorServiceLevelIds,
resolveElevatorServiceLevels,
} from './systems/elevator/elevator-service'
export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync'
export {
getClampedWallCurveOffset,
+1
View File
@@ -42,6 +42,7 @@ export {
ColumnSupportStyle,
} from './nodes/column'
export { DoorNode, DoorSegment } from './nodes/door'
export { ElevatorDoorStyle, ElevatorNode } from './nodes/elevator'
export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence'
export { GuideNode, GuideScaleReference } from './nodes/guide'
export type {
+3 -2
View File
@@ -1,12 +1,13 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { ElevatorNode } from './elevator'
import { LevelNode } from './level'
export const BuildingNode = BaseNode.extend({
id: objectId('building'),
type: nodeType('building'),
children: z.array(LevelNode.shape.id).default([]),
children: z.array(z.union([LevelNode.shape.id, ElevatorNode.shape.id])).default([]),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
}).describe(
@@ -14,7 +15,7 @@ export const BuildingNode = BaseNode.extend({
Building node - used to represent a building
- position: position in site coordinate system
- rotation: rotation in site coordinate system
- children: array of level nodes (each level is a tree of floor and wall nodes)
- children: array of level nodes and building-level systems such as elevators
`,
)
+1 -1
View File
@@ -21,7 +21,7 @@ export const CeilingNode = BaseNode.extend({
Ceiling node - used to represent a ceiling in the building
- polygon: array of [x, z] points defining the ceiling boundary
- holes: array of polygons representing holes in the ceiling
- holeMetadata: metadata parallel to holes, used to preserve manual and stair-managed cutouts
- holeMetadata: metadata parallel to holes, used to preserve manual and auto-managed cutouts
- autoFromWalls: whether the ceiling is automatically generated from a closed wall loop
`,
)
@@ -0,0 +1,47 @@
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 type ElevatorDoorStyle = z.infer<typeof ElevatorDoorStyle>
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),
cabHeight: z.number().default(2.35),
doorWidth: z.number().default(0.95),
doorHeight: z.number().default(2.1),
doorStyle: ElevatorDoorStyle.default('center-opening'),
fromLevelId: z.string().nullable().default(null),
toLevelId: z.string().nullable().default(null),
servedLevelIds: z.array(z.string()).optional(),
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: shaft and cab footprint
- cabHeight: visible elevator cab height
- doorWidth/doorHeight/doorStyle: landing and cab door presentation
- 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
- defaultLevelId: starting/resting level, falling back to the lowest served level
- speed/doorDurationMs/dwellMs: runtime animation defaults
`,
)
export type ElevatorNode = z.infer<typeof ElevatorNode>
+1 -1
View File
@@ -19,7 +19,7 @@ export const SlabNode = BaseNode.extend({
Slab node - used to represent a slab/floor in the building
- polygon: array of [x, z] points defining the slab boundary
- holes: array of [x, z] polygons representing cutouts in the slab
- holeMetadata: metadata parallel to holes, used to preserve manual and stair-managed cutouts
- holeMetadata: metadata parallel to holes, used to preserve manual and auto-managed cutouts
- elevation: elevation in meters
- autoFromWalls: whether the slab is automatically generated from a closed wall loop
`,
@@ -1,8 +1,9 @@
import { z } from 'zod'
export const SurfaceHoleMetadata = z.object({
source: z.enum(['manual', 'stair']).default('manual'),
source: z.enum(['manual', 'stair', 'elevator']).default('manual'),
stairId: z.string().optional(),
elevatorId: z.string().optional(),
})
export type SurfaceHoleMetadata = z.infer<typeof SurfaceHoleMetadata>
+2
View File
@@ -3,6 +3,7 @@ import { BuildingNode } from './nodes/building'
import { CeilingNode } from './nodes/ceiling'
import { ColumnNode } from './nodes/column'
import { DoorNode } from './nodes/door'
import { ElevatorNode } from './nodes/elevator'
import { FenceNode } from './nodes/fence'
import { GuideNode } from './nodes/guide'
import { ItemNode } from './nodes/item'
@@ -22,6 +23,7 @@ import { ZoneNode } from './nodes/zone'
export const AnyNode = z.discriminatedUnion('type', [
SiteNode,
BuildingNode,
ElevatorNode,
LevelNode,
ColumnNode,
WallNode,
@@ -39,12 +39,25 @@ export type WindowAnimationState = {
persist: boolean
}
export type ElevatorPhase = 'idle' | 'closing' | 'moving' | 'opening' | 'open'
export type ElevatorInteractiveState = {
currentLevelId: AnyNodeId | null
targetLevelId: AnyNodeId | null
carY: number
doorOpen: number
phase: ElevatorPhase
phaseStartedAt: number | null
queue: AnyNodeId[]
}
type InteractiveStore = {
items: Record<AnyNodeId, ItemInteractiveState>
doors: Record<AnyNodeId, DoorInteractiveState>
doorAnimations: Record<AnyNodeId, DoorAnimationState>
windows: Record<AnyNodeId, WindowInteractiveState>
windowAnimations: Record<AnyNodeId, WindowAnimationState>
elevators: Record<AnyNodeId, ElevatorInteractiveState>
/** Initialize a node's interactive state from its asset definition (idempotent) */
initItem: (itemId: AnyNodeId, interactive: Interactive) => void
@@ -78,6 +91,18 @@ type InteractiveStore = {
/** Cancel a queued window animation */
cancelWindowAnimation: (windowId: AnyNodeId) => void
/** Initialize an elevator's runtime state from its default served level. */
initElevator: (elevatorId: AnyNodeId, levelId: AnyNodeId, carY: number) => void
/** Queue a request for an elevator to travel to a level. */
requestElevator: (elevatorId: AnyNodeId, levelId: AnyNodeId) => 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 => {
@@ -99,6 +124,7 @@ export const useInteractive = create<InteractiveStore>((set, get) => ({
doorAnimations: {},
windows: {},
windowAnimations: {},
elevators: {},
initItem: (itemId, interactive) => {
const { controls } = interactive
@@ -203,4 +229,72 @@ export const useInteractive = create<InteractiveStore>((set, get) => ({
return { windowAnimations: rest }
})
},
initElevator: (elevatorId, levelId, carY) => {
if (get().elevators[elevatorId]) return
set((state) => ({
elevators: {
...state.elevators,
[elevatorId]: {
currentLevelId: levelId,
targetLevelId: null,
carY,
doorOpen: 0,
phase: 'idle',
phaseStartedAt: null,
queue: [],
},
},
}))
},
requestElevator: (elevatorId, levelId) => {
set((state) => {
const elevator = state.elevators[elevatorId] ?? {
currentLevelId: null,
targetLevelId: null,
carY: 0,
doorOpen: 0,
phase: 'idle' as const,
phaseStartedAt: null,
queue: [],
}
const isAlreadyQueued = elevator.queue.includes(levelId) || elevator.targetLevelId === levelId
return {
elevators: {
...state.elevators,
[elevatorId]: {
...elevator,
queue: isAlreadyQueued ? elevator.queue : [...elevator.queue, levelId],
},
},
}
})
},
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,164 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; core does not
// depend on @types/bun so the import type is unresolved at compile time.
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,266 @@
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import type { AnyNode, AnyNodeId, CeilingNode, ElevatorNode, SlabNode } from '../../schema'
import { resolveElevatorServiceLevels } from './elevator-service'
type Point2D = [number, number]
type SurfaceHoleMetadata = {
source: 'manual' | 'stair' | 'elevator'
elevatorId?: string
stairId?: string
}
const ELEVATOR_OPENING_PADDING = 0.08
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 halfWidth = Math.max(elevator.width, 0.8) / 2 + ELEVATOR_OPENING_PADDING
const halfDepth = Math.max(elevator.depth, 0.8) / 2 + 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,67 @@
import type { AnyNode, AnyNodeId, ElevatorNode, LevelNode } from '../../schema'
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)
}
@@ -2,7 +2,14 @@
// depend on @types/bun so the import type is unresolved at compile time.
import { describe, expect, test } from 'bun:test'
import type { AnyNode } from '../../schema'
import { BuildingNode, LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema'
import {
BuildingNode,
CeilingNode,
LevelNode,
SlabNode,
StairNode,
StairSegmentNode,
} from '../../schema'
import { syncAutoStairOpenings } from './stair-opening-sync'
describe('syncAutoStairOpenings', () => {
@@ -68,4 +75,190 @@ describe('syncAutoStairOpenings', () => {
expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }])
expect(bedroomUpdate).toBeUndefined()
})
test('does not add stair holes when a manual surface hole already covers them', () => {
const building = BuildingNode.parse({ name: 'Building' })
const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id })
const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id })
const manualOpening: Array<[number, number]> = [
[1.2, 0.8],
[2.8, 0.8],
[2.8, 2.9],
[1.2, 2.9],
]
const sourceCeiling = CeilingNode.parse({
name: 'Source Ceiling',
parentId: ground.id,
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
holes: [manualOpening],
holeMetadata: [{ source: 'manual' }],
})
const landingSlab = SlabNode.parse({
name: 'Landing Slab',
parentId: upper.id,
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
holes: [manualOpening],
holeMetadata: [{ source: 'manual' }],
})
const segment = StairSegmentNode.parse({
parentId: 'stair_main',
width: 1,
length: 2.6,
height: 2.5,
stepCount: 12,
})
const stair = StairNode.parse({
id: 'stair_main',
name: 'Main Stair',
parentId: ground.id,
position: [2, 0, 0.2],
stairType: 'straight',
fromLevelId: ground.id,
toLevelId: upper.id,
slabOpeningMode: 'destination',
children: [segment.id],
})
const nodes = Object.fromEntries(
[
building,
ground,
upper,
sourceCeiling,
landingSlab,
stair,
{ ...segment, parentId: stair.id },
].map((node) => [node.id, node]),
) as Record<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' }])
})
})
@@ -1,20 +1,19 @@
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import type {
AnyNode,
AnyNodeId,
CeilingNode,
LevelNode,
SlabNode,
StairNode,
StairSegmentNode,
} from '../../schema'
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint'
type Point2D = [number, number]
type SurfaceHoleMetadata = {
source: 'manual' | 'stair'
source: 'manual' | 'stair' | 'elevator'
elevatorId?: string
stairId?: string
}
@@ -69,6 +68,7 @@ function metadataEqual(left: SurfaceHoleMetadata[], right: SurfaceHoleMetadata[]
return left.every(
(entry, index) =>
entry.source === right[index]?.source &&
(entry.elevatorId ?? null) === (right[index]?.elevatorId ?? null) &&
(entry.stairId ?? null) === (right[index]?.stairId ?? null),
)
}
@@ -316,6 +316,10 @@ function polygonContainsPolygon(outer: Point2D[], inner: Point2D[]) {
return inner.every((point) => pointInPolygon(point, outer))
}
function isCoveredByExistingHole(existingHoles: Point2D[][], autoHole: Point2D[]) {
return existingHoles.some((existingHole) => polygonContainsPolygon(existingHole, autoHole))
}
function getAxisAlignedRectFromPolygon(polygon: Point2D[]): AxisAlignedRect | null {
if (polygon.length < 4) return null
const xs = polygon.map(([x]) => x)
@@ -741,12 +745,10 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
const slabLevelId = resolveLevelId(slab, nodes)
const existingHoles = slab.holes ?? []
const existingMetadata = normalizeExistingMetadata(existingHoles, slab.holeMetadata)
const manualHoles = existingHoles.filter(
(_hole, index) => existingMetadata[index]?.source !== 'stair',
)
const manualMetadata = existingMetadata
.filter((entry) => entry.source !== 'stair')
.map((entry) => ({ ...entry }))
const preservedHoles = existingHoles
.map((polygon, index) => ({ metadata: existingMetadata[index]!, polygon }))
.filter((entry) => entry.metadata.source !== 'stair')
const preservedHolePolygons = preservedHoles.map((entry) => entry.polygon)
const stairHoles = stairs
.filter((stair) => shouldApplyStairToSlab(stair, slabLevelId, nodes))
@@ -770,9 +772,16 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
})),
)
.filter((hole) => polygonContainsPolygon(slab.polygon, hole.polygon))
.filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon))
const nextHoles = [...manualHoles, ...stairHoles.map((hole) => hole.polygon)]
const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)]
const nextHoles = [
...preservedHoles.map((hole) => hole.polygon),
...stairHoles.map((hole) => hole.polygon),
]
const nextMetadata = [
...preservedHoles.map((hole) => ({ ...hole.metadata })),
...stairHoles.map((hole) => hole.metadata),
]
if (
!polygonsEqual(existingHoles, nextHoles) ||
@@ -792,12 +801,10 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
const ceilingLevelId = resolveLevelId(ceiling, nodes)
const existingHoles = ceiling.holes ?? []
const existingMetadata = normalizeExistingMetadata(existingHoles, ceiling.holeMetadata)
const manualHoles = existingHoles.filter(
(_hole, index) => existingMetadata[index]?.source !== 'stair',
)
const manualMetadata = existingMetadata
.filter((entry) => entry.source !== 'stair')
.map((entry) => ({ ...entry }))
const preservedHoles = existingHoles
.map((polygon, index) => ({ metadata: existingMetadata[index]!, polygon }))
.filter((entry) => entry.metadata.source !== 'stair')
const preservedHolePolygons = preservedHoles.map((entry) => entry.polygon)
const stairHoles = stairs
.filter((stair) => shouldApplyStairToCeiling(stair, ceilingLevelId, nodes))
@@ -821,9 +828,16 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
})),
)
.filter((hole) => polygonContainsPolygon(ceiling.polygon, hole.polygon))
.filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon))
const nextHoles = [...manualHoles, ...stairHoles.map((hole) => hole.polygon)]
const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)]
const nextHoles = [
...preservedHoles.map((hole) => hole.polygon),
...stairHoles.map((hole) => hole.polygon),
]
const nextMetadata = [
...preservedHoles.map((hole) => ({ ...hole.metadata })),
...stairHoles.map((hole) => hole.metadata),
]
if (
!polygonsEqual(existingHoles, nextHoles) ||
@@ -1,12 +1,36 @@
'use client'
import '../../three-types'
import { type AnyNodeId, emitter, sceneRegistry, useInteractive, useScene } from '@pascal-app/core'
import {
type AnyNode,
type AnyNodeId,
type ElevatorNode,
emitter,
resolveElevatorBuildingLevels,
resolveElevatorServiceLevels,
sceneRegistry,
useInteractive,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { KeyboardControls } from '@react-three/drei'
import { useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Box3, Euler, Matrix4, Ray, Raycaster, Vector2, Vector3 } from 'three'
import {
BoxGeometry,
Box3,
Euler,
type Group,
Matrix4,
Mesh,
MeshBasicMaterial,
type Object3D,
Ray,
Raycaster,
Vector2,
Vector3,
} from 'three'
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
import {
closeDoorOpenState,
DOOR_SWING_OPEN_ANGLE,
@@ -34,6 +58,13 @@ const LOOK_SENSITIVITY = 0.002
const CONTROLLER_CENTER_FROM_EYE = 0.85
const DOOR_INTERACTION_DISTANCE = 2.5
const DOOR_LEAF_INTERACTION_DEPTH = 0.08
const ELEVATOR_RIDE_HORIZONTAL_PADDING = 0.18
const ELEVATOR_COLLIDER_HORIZONTAL_PADDING = 0.14
const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08
const ELEVATOR_COLLIDER_WALL_THICKNESS = 0.16
const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12
const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72
const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5
const keyboardMap = [
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] },
{ name: 'backward', keys: ['ArrowDown', 'KeyS'] },
@@ -59,13 +90,410 @@ const doorOpeningLocalHit = new Vector3()
const doorOpeningLocalRay = new Ray()
const doorOpeningMatrix = new Matrix4()
const doorOpeningWorldHit = new Vector3()
const elevatorLocalControllerPosition = new Vector3()
const elevatorInteractionRaycaster = new Raycaster()
const elevatorColliderMatrix = new Matrix4()
const elevatorColliderLocalMatrix = new Matrix4()
const elevatorLocalEyePosition = new Vector3()
const elevatorWorldControllerPosition = new Vector3()
const elevatorColliderMaterial = new MeshBasicMaterial({ visible: false })
const spawnWorldPosition = new Vector3()
const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
const windowInteractionRaycaster = new Raycaster()
type FirstPersonInteractableTarget = {
id: AnyNodeId
type: 'door' | 'window'
type ElevatorColliderKind =
| 'cab-back'
| 'cab-ceiling'
| 'cab-door-left'
| 'cab-door-right'
| 'cab-door-gate'
| 'cab-floor'
| 'cab-left'
| 'cab-right'
| 'landing-door-gate'
| 'landing-door-left'
| 'landing-door-right'
| 'shaft-back'
| 'shaft-front-header'
| 'shaft-front-left'
| 'shaft-front-right'
| 'shaft-left'
| 'shaft-right'
| 'shaft-top'
type ElevatorColliderUserData = {
doorWidth?: number
dynamic?: boolean
elevatorId: AnyNodeId
kind: ElevatorColliderKind
levelId?: AnyNodeId
localPosition: [number, number, number]
matrixInitialized?: boolean
side?: 'left' | 'right'
}
type ElevatorColliderMesh = Mesh & {
userData: Mesh['userData'] & ElevatorColliderUserData
}
type FirstPersonInteractableTarget =
| {
id: AnyNodeId
type: 'door' | 'window'
}
| {
buttonKind: 'cab' | 'landing'
id: AnyNodeId
levelId: AnyNodeId
type: 'elevator'
}
type ElevatorButtonTarget = {
buttonKind: 'cab' | 'landing'
elevatorId: AnyNodeId
levelId: AnyNodeId
}
function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | null {
let current: Object3D | null = object
while (current) {
const candidate = (
current.userData as {
elevatorButton?: {
elevatorId?: unknown
kind?: unknown
levelId?: unknown
}
}
).elevatorButton
if (
typeof candidate?.elevatorId === 'string' &&
typeof candidate.levelId === 'string' &&
(candidate.kind === 'cab' || candidate.kind === 'landing')
) {
return {
buttonKind: candidate.kind,
elevatorId: candidate.elevatorId as AnyNodeId,
levelId: candidate.levelId as AnyNodeId,
}
}
current = current.parent
}
return null
}
function getInteractableTargetKey(target: FirstPersonInteractableTarget | null) {
if (!target) return null
return target.type === 'elevator'
? `${target.type}:${target.id}:${target.levelId}`
: `${target.type}:${target.id}`
}
function getElevatorDoorLeafX(side: 'left' | 'right', width: number, doorOpen: number) {
const direction = side === 'left' ? -1 : 1
return direction * (width / 4 + doorOpen * width * 0.34)
}
function isDynamicElevatorCollider(kind: ElevatorColliderKind) {
return kind.startsWith('cab-') || kind.startsWith('landing-door')
}
function isInsideElevatorCab(
elevator: ElevatorNode,
runtime: NonNullable<ReturnType<typeof useInteractive.getState>['elevators'][AnyNodeId]>,
localEyePosition: Vector3,
) {
const halfWidth = Math.max(elevator.width, 0.8) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
const halfDepth = Math.max(elevator.depth, 0.8) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
const cabHeight = Math.max(elevator.cabHeight, 1.4)
return (
Math.abs(localEyePosition.x) <= Math.max(halfWidth, 0.24) &&
Math.abs(localEyePosition.z) <= Math.max(halfDepth, 0.24) &&
localEyePosition.y >= runtime.carY + 0.35 &&
localEyePosition.y <= runtime.carY + cabHeight + 0.7
)
}
function getFirstPersonLevelHeight(levelId: string, nodes: Record<string, AnyNode>) {
const level = nodes[levelId as AnyNodeId]
if (level?.type !== 'level') return DEFAULT_ELEVATOR_LEVEL_HEIGHT
let maxTop = 0
for (const childId of level.children) {
const child = nodes[childId as AnyNodeId]
if (!child) continue
if (child.type === 'ceiling') {
maxTop = Math.max(maxTop, child.height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT)
continue
}
if (child.type === 'wall') {
const meshY = Math.max(sceneRegistry.nodes.get(childId as AnyNodeId)?.position.y ?? 0, 0)
maxTop = Math.max(maxTop, meshY + (child.height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT))
}
}
return maxTop > 0 ? maxTop : DEFAULT_ELEVATOR_LEVEL_HEIGHT
}
function resolveElevatorColliderLevels(elevator: ElevatorNode, nodes: Record<string, AnyNode>) {
const allLevels = resolveElevatorBuildingLevels(elevator, nodes)
const baseYByLevelId = new Map<string, number>()
let cumulativeY = 0
for (const level of allLevels) {
baseYByLevelId.set(level.id, cumulativeY)
cumulativeY += getFirstPersonLevelHeight(level.id, nodes)
}
const serviceLevels = resolveElevatorServiceLevels(elevator, nodes)
const entries = serviceLevels.map((level) => ({
baseY: baseYByLevelId.get(level.id) ?? 0,
id: level.id as AnyNodeId,
}))
const firstServedLevel = serviceLevels[0] ?? null
const lastServedLevel = serviceLevels[serviceLevels.length - 1] ?? null
const shaftBaseY = firstServedLevel ? (baseYByLevelId.get(firstServedLevel.id) ?? 0) : 0
const lastServedIndex = lastServedLevel
? allLevels.findIndex((level) => level.id === lastServedLevel.id)
: -1
const nextLevel = lastServedIndex >= 0 ? allLevels[lastServedIndex + 1] : null
const shaftTopY = nextLevel
? (baseYByLevelId.get(nextLevel.id) ?? cumulativeY)
: lastServedLevel
? cumulativeY
: elevator.cabHeight + 0.3
return {
entries,
shaftBaseY,
shaftTopY,
totalHeight: Math.max(shaftTopY - shaftBaseY, elevator.cabHeight + 0.3),
}
}
function createElevatorColliderMesh(
elevatorId: AnyNodeId,
kind: ElevatorColliderKind,
size: [number, number, number],
localPosition: [number, number, number],
userData: Partial<ElevatorColliderUserData> = {},
) {
const geometry = new BoxGeometry(size[0], size[1], size[2])
const bvhGeometry = geometry as typeof geometry & {
computeBoundsTree?: typeof computeBoundsTree
disposeBoundsTree?: typeof disposeBoundsTree
}
;(bvhGeometry as any).computeBoundsTree = computeBoundsTree
;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree
bvhGeometry.computeBoundsTree?.({
maxLeafSize: 12,
strategy: 0,
} as never)
bvhGeometry.computeBoundingBox()
const mesh = new Mesh(bvhGeometry, elevatorColliderMaterial) as unknown as ElevatorColliderMesh
mesh.raycast = acceleratedRaycast
mesh.matrixAutoUpdate = false
mesh.visible = true
mesh.userData = {
...userData,
dynamic: isDynamicElevatorCollider(kind),
elevatorId,
excludeCollisionCheck: false,
excludeFloatHit: false,
friction: 0.8,
kind,
localPosition,
matrixInitialized: false,
restitution: 0.03,
type: 'ELEVATOR_COLLIDER',
}
return mesh
}
function buildElevatorColliderMeshes(): ElevatorColliderMesh[] {
const nodes = useScene.getState().nodes
const meshes: ElevatorColliderMesh[] = []
for (const elevatorId of sceneRegistry.byType.elevator) {
const typedElevatorId = elevatorId as AnyNodeId
const node = nodes[typedElevatorId]
if (node?.type !== 'elevator' || node.visible === false) continue
const { entries, shaftBaseY, shaftTopY, totalHeight } = resolveElevatorColliderLevels(
node,
nodes,
)
const shaftWidth = Math.max(node.width, 0.8)
const shaftDepth = Math.max(node.depth, 0.8)
const cabHeight = Math.max(node.cabHeight, 1.4)
const doorWidth = Math.min(Math.max(node.doorWidth, 0.45), shaftWidth - 0.18)
const doorHeight = Math.min(Math.max(node.doorHeight, 1.2), cabHeight - 0.1)
const shaftHeight = Math.max(totalHeight, cabHeight + 0.3)
const wallThickness = ELEVATOR_COLLIDER_WALL_THICKNESS
const cabFloorWidth = Math.max(shaftWidth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48)
const cabFloorDepth = Math.max(shaftDepth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48)
const frontWallZ = -shaftDepth / 2 - wallThickness / 2
const frontZ = frontWallZ - wallThickness / 2 - 0.018
const leafWidth = Math.max(doorWidth / 2 - 0.018, 0.12)
const resolvedShaftTopY = Math.max(shaftTopY, shaftBaseY + shaftHeight)
meshes.push(
createElevatorColliderMesh(
typedElevatorId,
'shaft-back',
[shaftWidth + wallThickness * 2, shaftHeight, wallThickness],
[0, shaftBaseY + shaftHeight / 2, shaftDepth / 2 + wallThickness / 2],
),
createElevatorColliderMesh(
typedElevatorId,
'shaft-left',
[wallThickness, shaftHeight, shaftDepth + wallThickness * 2],
[-shaftWidth / 2 - wallThickness / 2, shaftBaseY + shaftHeight / 2, 0],
),
createElevatorColliderMesh(
typedElevatorId,
'shaft-right',
[wallThickness, shaftHeight, shaftDepth + wallThickness * 2],
[shaftWidth / 2 + wallThickness / 2, shaftBaseY + shaftHeight / 2, 0],
),
createElevatorColliderMesh(
typedElevatorId,
'shaft-top',
[shaftWidth + wallThickness * 2, wallThickness, shaftDepth + wallThickness * 2],
[0, shaftBaseY + shaftHeight - wallThickness / 2, 0],
),
createElevatorColliderMesh(
typedElevatorId,
'cab-floor',
[cabFloorWidth, ELEVATOR_COLLIDER_FLOOR_THICKNESS, cabFloorDepth],
[0, ELEVATOR_COLLIDER_FLOOR_THICKNESS / 2, 0],
),
createElevatorColliderMesh(
typedElevatorId,
'cab-ceiling',
[shaftWidth, wallThickness, shaftDepth],
[0, cabHeight - wallThickness / 2, 0],
),
createElevatorColliderMesh(
typedElevatorId,
'cab-back',
[shaftWidth, cabHeight, wallThickness],
[0, cabHeight / 2, shaftDepth / 2 - wallThickness / 2],
),
createElevatorColliderMesh(
typedElevatorId,
'cab-left',
[wallThickness, cabHeight, shaftDepth],
[-shaftWidth / 2 + wallThickness / 2, cabHeight / 2, 0],
),
createElevatorColliderMesh(
typedElevatorId,
'cab-right',
[wallThickness, cabHeight, shaftDepth],
[shaftWidth / 2 - wallThickness / 2, cabHeight / 2, 0],
),
createElevatorColliderMesh(
typedElevatorId,
'cab-door-left',
[leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
[0, doorHeight / 2, frontZ],
{ doorWidth, side: 'left' },
),
createElevatorColliderMesh(
typedElevatorId,
'cab-door-gate',
[doorWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
[0, doorHeight / 2, frontZ],
{ doorWidth },
),
createElevatorColliderMesh(
typedElevatorId,
'cab-door-right',
[leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
[0, doorHeight / 2, frontZ],
{ doorWidth, side: 'right' },
),
)
const entrySpans = entries.map((entry, index) => {
const nextEntry = entries[index + 1]
return {
entry,
levelTopY: Math.max(nextEntry?.baseY ?? resolvedShaftTopY, entry.baseY + doorHeight + 0.24),
}
})
for (const { entry, levelTopY } of entrySpans) {
const wallDepth = wallThickness
const levelHeight = Math.max(levelTopY - entry.baseY, doorHeight + 0.24)
const jambWidth = Math.max((shaftWidth - doorWidth) / 2, 0.08)
const jambCenterOffset = doorWidth / 2 + jambWidth / 2
const headerHeight = Math.max(levelHeight - doorHeight, 0.14)
meshes.push(
createElevatorColliderMesh(
typedElevatorId,
'shaft-front-left',
[jambWidth, levelHeight, wallDepth],
[-jambCenterOffset, entry.baseY + levelHeight / 2, frontWallZ],
),
createElevatorColliderMesh(
typedElevatorId,
'shaft-front-right',
[jambWidth, levelHeight, wallDepth],
[jambCenterOffset, entry.baseY + levelHeight / 2, frontWallZ],
),
createElevatorColliderMesh(
typedElevatorId,
'shaft-front-header',
[shaftWidth, headerHeight, wallDepth],
[0, entry.baseY + doorHeight + headerHeight / 2, frontWallZ],
),
createElevatorColliderMesh(
typedElevatorId,
'landing-door-left',
[leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
[0, entry.baseY + doorHeight / 2, frontZ - 0.02],
{ doorWidth, levelId: entry.id, side: 'left' },
),
createElevatorColliderMesh(
typedElevatorId,
'landing-door-gate',
[doorWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
[0, entry.baseY + doorHeight / 2, frontZ - 0.02],
{ doorWidth, levelId: entry.id },
),
createElevatorColliderMesh(
typedElevatorId,
'landing-door-right',
[leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
[0, entry.baseY + doorHeight / 2, frontZ - 0.02],
{ doorWidth, levelId: entry.id, side: 'right' },
),
)
}
}
return meshes
}
function disposeElevatorColliderMeshes(meshes: ElevatorColliderMesh[]) {
for (const mesh of meshes) {
const geometry = mesh.geometry as typeof mesh.geometry & {
disposeBoundsTree?: typeof disposeBoundsTree
}
geometry.disposeBoundsTree?.()
geometry.dispose()
}
}
const resolvePlacedSpawnNode = (
@@ -86,8 +514,17 @@ export const FirstPersonControls = () => {
const yawRef = useRef(0)
const pitchRef = useRef(0)
const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
const [isElevatorRideLocked, setIsElevatorRideLocked] = useState(false)
const ridingElevatorRef = useRef<{
elevatorId: AnyNodeId
localControllerY: number | null
previousCarY: number
} | null>(null)
const rideLockedRef = useRef(false)
const worldRef = useRef<FirstPersonColliderWorld | null>(null)
const elevatorColliderMeshesRef = useRef<ElevatorColliderMesh[]>([])
const [world, setWorld] = useState<FirstPersonColliderWorld | null>(null)
const [elevatorColliderMeshes, setElevatorColliderMeshes] = useState<ElevatorColliderMesh[]>([])
const [controllerStart, setControllerStart] = useState<{
position: [number, number, number]
yaw: number
@@ -99,9 +536,22 @@ export const FirstPersonControls = () => {
setWorld(nextWorld)
}, [])
const replaceElevatorColliderMeshes = useCallback((nextMeshes: ElevatorColliderMesh[]) => {
disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current)
elevatorColliderMeshesRef.current = nextMeshes
setElevatorColliderMeshes(nextMeshes)
}, [])
const rebuildColliderWorld = useCallback(() => {
replaceColliderWorld(buildFirstPersonColliderWorldFromRegistry())
}, [replaceColliderWorld])
replaceElevatorColliderMeshes(buildElevatorColliderMeshes())
}, [replaceColliderWorld, replaceElevatorColliderMeshes])
const setElevatorRideLocked = useCallback((locked: boolean) => {
if (rideLockedRef.current === locked) return
rideLockedRef.current = locked
setIsElevatorRideLocked(locked)
}, [])
const resolveInteractableDoorId = useCallback((): AnyNodeId | null => {
const nodes = useScene.getState().nodes
@@ -230,7 +680,58 @@ export const FirstPersonControls = () => {
return closestWindowId
}, [camera])
const resolveInteractableElevatorTarget =
useCallback((): FirstPersonInteractableTarget | null => {
const nodes = useScene.getState().nodes
camera.updateMatrixWorld(true)
elevatorInteractionRaycaster.setFromCamera(centerScreenPoint, camera)
let closestTarget: FirstPersonInteractableTarget | null = null
let closestDistance = DOOR_INTERACTION_DISTANCE
for (const elevatorId of sceneRegistry.byType.elevator) {
const typedElevatorId = elevatorId as AnyNodeId
const node = nodes[typedElevatorId]
if (node?.type !== 'elevator') continue
const object = sceneRegistry.nodes.get(typedElevatorId)
if (!object) continue
const runtime = useInteractive.getState().elevators[typedElevatorId]
object.updateWorldMatrix(true, true)
if (runtime) {
elevatorLocalEyePosition.copy(camera.position)
object.worldToLocal(elevatorLocalEyePosition)
}
const canUseCabButtons =
runtime && isInsideElevatorCab(node, runtime, elevatorLocalEyePosition)
const intersections = elevatorInteractionRaycaster.intersectObject(object, true)
for (const intersection of intersections) {
if (intersection.distance > closestDistance) continue
const target = resolveElevatorButtonTarget(intersection.object)
if (!target || target.elevatorId !== elevatorId) continue
if (nodes[target.levelId]?.type !== 'level') continue
if (target.buttonKind === 'cab' && !canUseCabButtons) continue
closestTarget = {
buttonKind: target.buttonKind,
id: target.elevatorId,
levelId: target.levelId,
type: 'elevator',
}
closestDistance = intersection.distance
}
}
return closestTarget
}, [camera])
const resolveInteractableTarget = useCallback((): FirstPersonInteractableTarget | null => {
const elevatorTarget = resolveInteractableElevatorTarget()
if (elevatorTarget) return elevatorTarget
const doorId = resolveInteractableDoorId()
if (doorId) return { id: doorId, type: 'door' }
@@ -238,12 +739,27 @@ export const FirstPersonControls = () => {
if (windowId) return { id: windowId, type: 'window' }
return null
}, [resolveInteractableDoorId, resolveInteractableWindowId])
}, [resolveInteractableDoorId, resolveInteractableElevatorTarget, resolveInteractableWindowId])
const toggleInteractableTarget = useCallback(() => {
const target = interactableTargetRef.current ?? resolveInteractableTarget()
if (!target) return
if (target.type === 'elevator') {
if (target.buttonKind === 'cab') {
const state = useInteractive.getState().elevators[target.id]
if (state) {
ridingElevatorRef.current = {
elevatorId: target.id,
localControllerY: null,
previousCarY: state.carY,
}
}
}
useInteractive.getState().requestElevator(target.id, target.levelId)
return
}
if (target.type === 'window') {
const node = useScene.getState().nodes[target.id]
if (
@@ -270,6 +786,8 @@ export const FirstPersonControls = () => {
const target = interactableTargetRef.current ?? resolveInteractableTarget()
if (!target) return
if (target.type === 'elevator') return
if (target.type === 'window') {
const node = useScene.getState().nodes[target.id]
if (
@@ -325,6 +843,9 @@ export const FirstPersonControls = () => {
return () => {
worldRef.current?.dispose()
worldRef.current = null
disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current)
elevatorColliderMeshesRef.current = []
setElevatorColliderMeshes([])
setWorld(null)
}
}, [rebuildColliderWorld])
@@ -373,17 +894,28 @@ export const FirstPersonControls = () => {
}
}
const handleMouseDown = (event: MouseEvent) => {
if (document.pointerLockElement !== canvas) return
if (event.button !== 0) return
event.preventDefault()
event.stopPropagation()
toggleInteractableTarget()
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('click', handleClick)
document.addEventListener('mousedown', handleMouseDown, true)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('click', handleClick)
document.removeEventListener('mousedown', handleMouseDown, true)
if (document.pointerLockElement === canvas) {
document.exitPointerLock()
}
}
}, [gl])
}, [gl, toggleInteractableTarget])
useEffect(() => {
const canvas = gl.domElement
@@ -417,7 +949,216 @@ export const FirstPersonControls = () => {
}
}, [closeInteractableTarget, gl, toggleInteractableTarget])
useFrame((_, delta) => {
const syncElevatorColliderMeshes = useCallback(() => {
const nodes = useScene.getState().nodes
const interactive = useInteractive.getState()
for (const mesh of elevatorColliderMeshesRef.current) {
const { doorWidth, dynamic, elevatorId, kind, levelId, localPosition, side } = mesh.userData
const node = nodes[elevatorId]
const runtime = interactive.elevators[elevatorId]
const object = sceneRegistry.nodes.get(elevatorId)
if (!(node?.type === 'elevator' && object && node.visible !== false)) {
mesh.visible = false
continue
}
if (!dynamic && mesh.userData.matrixInitialized && mesh.visible) {
continue
}
let [localX, localY, localZ] = localPosition
const isCabCollider = kind.startsWith('cab-')
const isDoorCollider = kind === 'landing-door-left' || kind === 'landing-door-right'
const isCabDoorGate = kind === 'cab-door-gate'
const isLandingDoorGate = kind === 'landing-door-gate'
if (isCabCollider) {
localY += runtime?.carY ?? 0
}
if (kind === 'cab-door-left' || kind === 'cab-door-right') {
if (!runtime) {
mesh.visible = false
continue
}
localX = getElevatorDoorLeafX(side ?? 'left', doorWidth ?? node.doorWidth, runtime.doorOpen)
mesh.visible = true
} else if (isCabDoorGate) {
if (!runtime) {
mesh.visible = false
continue
}
mesh.visible = runtime.doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD
} else if (isDoorCollider) {
const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0
localX = getElevatorDoorLeafX(side ?? 'left', doorWidth ?? node.doorWidth, doorOpen)
mesh.visible = true
} else if (isLandingDoorGate) {
const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0
mesh.visible = doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD
} else {
mesh.visible = true
}
object.updateWorldMatrix(true, false)
elevatorColliderMatrix.copy(object.matrixWorld)
elevatorColliderMatrix.multiply(
elevatorColliderLocalMatrix.makeTranslation(localX, localY, localZ),
)
mesh.matrix.copy(elevatorColliderMatrix)
mesh.matrixWorld.copy(elevatorColliderMatrix)
mesh.userData.matrixInitialized = true
}
}, [])
useFrame(() => {
syncElevatorColliderMeshes()
}, -1)
const syncElevatorRide = useCallback(
(group: Group) => {
const nodes = useScene.getState().nodes
const interactive = useInteractive.getState()
const activeRide = ridingElevatorRef.current
let nextRide: {
cabHeight: number
carY: number
doorOpen: number
elevatorId: AnyNodeId
halfDepth: number
halfWidth: number
object: Object3D
phase: NonNullable<(typeof interactive.elevators)[AnyNodeId]>['phase']
} | null = null
const elevatorIds = activeRide
? [
activeRide.elevatorId,
...Array.from(sceneRegistry.byType.elevator).filter(
(elevatorId) => elevatorId !== activeRide.elevatorId,
),
]
: Array.from(sceneRegistry.byType.elevator)
for (const elevatorId of elevatorIds) {
const typedElevatorId = elevatorId as AnyNodeId
const node = nodes[typedElevatorId]
if (node?.type !== 'elevator') continue
const runtime = interactive.elevators[typedElevatorId]
const object = sceneRegistry.nodes.get(typedElevatorId)
if (!(runtime && object)) continue
object.updateWorldMatrix(true, true)
elevatorLocalEyePosition.copy(camera.position)
object.worldToLocal(elevatorLocalEyePosition)
const halfWidth = Math.max(node.width, 0.8) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
const halfDepth = Math.max(node.depth, 0.8) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
const cabHeight = Math.max(node.cabHeight, 1.4)
const insideFootprint =
Math.abs(elevatorLocalEyePosition.x) <= Math.max(halfWidth, 0.24) &&
Math.abs(elevatorLocalEyePosition.z) <= Math.max(halfDepth, 0.24)
const insideCabHeight =
elevatorLocalEyePosition.y >= runtime.carY + 0.35 &&
elevatorLocalEyePosition.y <= runtime.carY + cabHeight + 0.7
const continuingRide =
activeRide?.elevatorId === typedElevatorId &&
insideFootprint &&
elevatorLocalEyePosition.y >= runtime.carY - 0.2 &&
elevatorLocalEyePosition.y <= runtime.carY + cabHeight + 1.25
if ((insideFootprint && insideCabHeight) || continuingRide) {
nextRide = {
cabHeight,
carY: runtime.carY,
doorOpen: runtime.doorOpen,
elevatorId: typedElevatorId,
halfDepth: Math.max(halfDepth, 0.24),
halfWidth: Math.max(halfWidth, 0.24),
object,
phase: runtime.phase,
}
break
}
}
if (!nextRide) {
ridingElevatorRef.current = null
setElevatorRideLocked(false)
return
}
const previousCarY =
activeRide?.elevatorId === nextRide.elevatorId ? activeRide.previousCarY : nextRide.carY
const deltaY = nextRide.carY - previousCarY
nextRide.object.updateWorldMatrix(true, true)
elevatorLocalControllerPosition.copy(group.position)
nextRide.object.worldToLocal(elevatorLocalControllerPosition)
const localControllerY =
activeRide?.elevatorId === nextRide.elevatorId && activeRide.localControllerY !== null
? activeRide.localControllerY
: elevatorLocalControllerPosition.y - nextRide.carY
if (Math.abs(deltaY) > 0.0001) {
group.position.y += deltaY
controllerRef.current?.resetLinVel()
}
const shouldLockToCab =
nextRide.phase === 'closing' ||
nextRide.phase === 'moving' ||
(nextRide.phase === 'opening' && nextRide.doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD)
if (shouldLockToCab) {
elevatorLocalControllerPosition.copy(group.position)
nextRide.object.worldToLocal(elevatorLocalControllerPosition)
const desiredLocalY = nextRide.carY + localControllerY
if (Math.abs(elevatorLocalControllerPosition.y - desiredLocalY) > 0.002) {
elevatorLocalControllerPosition.y = desiredLocalY
elevatorWorldControllerPosition.copy(elevatorLocalControllerPosition)
nextRide.object.localToWorld(elevatorWorldControllerPosition)
group.position.y = elevatorWorldControllerPosition.y
controllerRef.current?.resetLinVel()
elevatorLocalControllerPosition.copy(group.position)
nextRide.object.worldToLocal(elevatorLocalControllerPosition)
}
const clampedX = Math.max(
-nextRide.halfWidth,
Math.min(nextRide.halfWidth, elevatorLocalControllerPosition.x),
)
const clampedZ = Math.max(
-nextRide.halfDepth,
Math.min(nextRide.halfDepth, elevatorLocalControllerPosition.z),
)
if (
Math.abs(clampedX - elevatorLocalControllerPosition.x) > 0.0001 ||
Math.abs(clampedZ - elevatorLocalControllerPosition.z) > 0.0001
) {
elevatorLocalControllerPosition.x = clampedX
elevatorLocalControllerPosition.z = clampedZ
elevatorWorldControllerPosition.copy(elevatorLocalControllerPosition)
nextRide.object.localToWorld(elevatorWorldControllerPosition)
group.position.x = elevatorWorldControllerPosition.x
group.position.z = elevatorWorldControllerPosition.z
controllerRef.current?.resetLinVel()
}
}
setElevatorRideLocked(shouldLockToCab)
ridingElevatorRef.current = {
elevatorId: nextRide.elevatorId,
localControllerY,
previousCarY: nextRide.carY,
}
},
[camera, setElevatorRideLocked],
)
useFrame(() => {
if (!controllerRef.current?.group) return
const group = controllerRef.current.group
@@ -426,17 +1167,20 @@ export const FirstPersonControls = () => {
cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
camera.quaternion.setFromEuler(cameraEuler)
camera.updateMatrixWorld(true)
syncElevatorRide(group)
camera.position.copy(group.position).add(cameraOffset)
camera.updateMatrixWorld(true)
const nextInteractableTarget = resolveInteractableTarget()
const previousInteractableTarget = interactableTargetRef.current
if (
previousInteractableTarget?.id !== nextInteractableTarget?.id ||
previousInteractableTarget?.type !== nextInteractableTarget?.type
getInteractableTargetKey(previousInteractableTarget) !==
getInteractableTargetKey(nextInteractableTarget)
) {
interactableTargetRef.current = nextInteractableTarget
useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null)
}
})
}, 2.5)
useEffect(() => {
return () => {
@@ -446,6 +1190,11 @@ export const FirstPersonControls = () => {
}
}, [])
const firstPersonColliderMeshes = useMemo(
() => (world ? [world.mesh, ...elevatorColliderMeshes] : elevatorColliderMeshes),
[world, elevatorColliderMeshes],
)
if (!world) {
return null
}
@@ -458,7 +1207,7 @@ export const FirstPersonControls = () => {
ref={controllerRef}
key="first-person-controller"
colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
colliderMeshes={[world.mesh]}
colliderMeshes={firstPersonColliderMeshes}
collisionCheckIteration={3}
collisionPushBackDamping={0.1}
collisionPushBackThreshold={0.001}
@@ -476,6 +1225,7 @@ export const FirstPersonControls = () => {
maxRunSpeed={5.5}
maxSlope={1.2}
maxWalkSpeed={4}
paused={isElevatorRideLocked}
position={controllerStart.position}
acceleration={26}
airDragFactor={0.3}
@@ -6,6 +6,7 @@ import {
type CeilingNode,
ColumnNode,
DoorNode,
ElevatorNode,
FenceNode,
generateId,
ItemNode,
@@ -35,6 +36,7 @@ const ALLOWED_TYPES = [
'item',
'door',
'window',
'elevator',
'roof',
'roof-segment',
'stair',
@@ -183,6 +185,7 @@ export function FloatingActionMenu() {
node.type === 'item' ||
node.type === 'window' ||
node.type === 'door' ||
node.type === 'elevator' ||
node.type === 'wall' ||
node.type === 'fence' ||
node.type === 'column' ||
@@ -263,6 +266,8 @@ export function FloatingActionMenu() {
duplicate = WindowNode.parse(duplicateInfo)
} else if (node.type === 'item') {
duplicate = ItemNode.parse(duplicateInfo)
} else if (node.type === 'elevator') {
duplicate = ElevatorNode.parse(duplicateInfo)
} else if (node.type === 'column') {
duplicate = ColumnNode.parse(duplicateInfo)
} else if (node.type === 'wall') {
@@ -296,7 +301,11 @@ export function FloatingActionMenu() {
}
if (duplicate) {
if (duplicate.type === 'door' || duplicate.type === 'window') {
if (
duplicate.type === 'door' ||
duplicate.type === 'window' ||
duplicate.type === 'elevator'
) {
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
} else if (duplicate.type === 'wall') {
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
@@ -325,6 +334,7 @@ export function FloatingActionMenu() {
}
if (
duplicate.type === 'item' ||
duplicate.type === 'elevator' ||
duplicate.type === 'column' ||
duplicate.type === 'wall' ||
duplicate.type === 'fence' ||
@@ -14175,7 +14175,10 @@ export function FloorplanPanel() {
}
const currentHoles = slab.holes ?? []
if (!currentHoles[holeIndex] || slab.holeMetadata?.[holeIndex]?.source === 'stair') {
if (
!currentHoles[holeIndex] ||
(slab.holeMetadata?.[holeIndex]?.source ?? 'manual') !== 'manual'
) {
return
}
@@ -14310,7 +14313,10 @@ export function FloorplanPanel() {
}
const currentHoles = ceiling.holes ?? []
if (!currentHoles[holeIndex] || ceiling.holeMetadata?.[holeIndex]?.source === 'stair') {
if (
!currentHoles[holeIndex] ||
(ceiling.holeMetadata?.[holeIndex]?.source ?? 'manual') !== 'manual'
) {
return
}
@@ -56,6 +56,7 @@ import useEditor, {
import { boxSelectHandled } from '../tools/select/box-select-tool'
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
if (node.type === 'elevator') return true
const currentLevelId = useViewer.getState().selection.levelId
if (!currentLevelId) return true // No level selected, allow all
const nodeLevelId = resolveLevelId(node, useScene.getState().nodes)
@@ -68,6 +69,7 @@ type SelectableNodeType =
| 'item'
| 'column'
| 'building'
| 'elevator'
| 'zone'
| 'slab'
| 'ceiling'
@@ -565,6 +567,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
'fence',
'item',
'column',
'elevator',
'zone',
'slab',
'ceiling',
@@ -579,11 +582,18 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
handleSelect: (node, nativeEvent, modifierKeys) => {
const { selection, setSelection } = useViewer.getState()
const nodes = useScene.getState().nodes
const nodeLevelId = resolveLevelId(node, nodes)
const buildingId = resolveBuildingId(nodeLevelId, nodes)
const nodeLevelId = node.type === 'elevator' ? null : resolveLevelId(node, nodes)
const buildingId =
node.type === 'elevator' &&
node.parentId &&
nodes[node.parentId as AnyNodeId]?.type === 'building'
? node.parentId
: nodeLevelId
? resolveBuildingId(nodeLevelId, nodes)
: null
const updates: any = {}
if (nodeLevelId !== 'default' && nodeLevelId !== selection.levelId) {
if (nodeLevelId && nodeLevelId !== 'default' && nodeLevelId !== selection.levelId) {
updates.levelId = nodeLevelId
}
if (buildingId && buildingId !== selection.buildingId) {
@@ -619,6 +629,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
node.type === 'wall' ||
node.type === 'fence' ||
node.type === 'column' ||
node.type === 'elevator' ||
node.type === 'slab' ||
node.type === 'ceiling' ||
node.type === 'roof' ||
@@ -683,6 +694,7 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
node.type === 'wall' ||
node.type === 'fence' ||
node.type === 'column' ||
node.type === 'elevator' ||
node.type === 'slab' ||
node.type === 'ceiling' ||
node.type === 'roof' ||
@@ -1163,6 +1175,7 @@ export const SelectionManager = () => {
'item',
'column',
'building',
'elevator',
'zone',
'slab',
'ceiling',
@@ -1259,6 +1272,7 @@ export const SelectionManager = () => {
node.type === 'wall' ||
node.type === 'fence' ||
node.type === 'column' ||
node.type === 'elevator' ||
node.type === 'slab' ||
node.type === 'ceiling' ||
node.type === 'roof' ||
@@ -1313,6 +1327,7 @@ export const SelectionManager = () => {
'item',
'column',
'building',
'elevator',
'slab',
'ceiling',
'roof',
@@ -1386,6 +1401,7 @@ export const SelectionManager = () => {
'fence',
'item',
'column',
'elevator',
'slab',
'ceiling',
'roof',
@@ -0,0 +1,8 @@
export const DEFAULT_ELEVATOR_WIDTH = 1.6
export const DEFAULT_ELEVATOR_DEPTH = 1.6
export const DEFAULT_ELEVATOR_CAB_HEIGHT = 2.35
export const DEFAULT_ELEVATOR_DOOR_WIDTH = 0.95
export const DEFAULT_ELEVATOR_DOOR_HEIGHT = 2.1
export const DEFAULT_ELEVATOR_SPEED = 2.2
export const DEFAULT_ELEVATOR_DOOR_DURATION_MS = 900
export const DEFAULT_ELEVATOR_DWELL_MS = 1400
@@ -0,0 +1,201 @@
import {
type AnyNodeId,
type BuildingNode,
ElevatorNode,
emitter,
type GridEvent,
type LevelNode,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
import {
DEFAULT_ELEVATOR_CAB_HEIGHT,
DEFAULT_ELEVATOR_DEPTH,
DEFAULT_ELEVATOR_DOOR_DURATION_MS,
DEFAULT_ELEVATOR_DOOR_HEIGHT,
DEFAULT_ELEVATOR_DOOR_WIDTH,
DEFAULT_ELEVATOR_DWELL_MS,
DEFAULT_ELEVATOR_SPEED,
DEFAULT_ELEVATOR_WIDTH,
} from './elevator-defaults'
const GRID_OFFSET = 0.02
function resolveCurrentBuildingId(): BuildingNode['id'] | null {
const { buildingId, levelId } = useViewer.getState().selection
if (buildingId) return buildingId as BuildingNode['id']
if (!levelId) return null
const level = useScene.getState().nodes[levelId as AnyNodeId]
if (level?.type === 'level' && level.parentId) {
return level.parentId as BuildingNode['id']
}
return null
}
function resolveDefaultServiceRange(buildingId: BuildingNode['id']): {
defaultLevelId: LevelNode['id'] | null
fromLevelId: LevelNode['id'] | null
toLevelId: LevelNode['id'] | null
} {
const { levelId } = useViewer.getState().selection
const nodes = useScene.getState().nodes
const building = nodes[buildingId as AnyNodeId]
if (building?.type !== 'building') {
return { defaultLevelId: null, fromLevelId: null, toLevelId: null }
}
const levels = building.children
.map((childId) => nodes[childId as AnyNodeId])
.filter((node): node is LevelNode => node?.type === 'level')
.sort((left, right) => left.level - right.level)
const selectedLevelIndex = levels.findIndex((level) => level.id === levelId)
const fromIndex = selectedLevelIndex >= 0 ? selectedLevelIndex : 0
const fromLevel = levels[fromIndex]
const toLevel = levels[Math.min(fromIndex + 1, levels.length - 1)] ?? fromLevel
return {
defaultLevelId: fromLevel?.id ?? null,
fromLevelId: fromLevel?.id ?? null,
toLevelId: toLevel?.id ?? fromLevel?.id ?? null,
}
}
function createElevatorPreviewGeometry(): THREE.BufferGeometry {
return new THREE.BoxGeometry(
DEFAULT_ELEVATOR_WIDTH,
DEFAULT_ELEVATOR_CAB_HEIGHT,
DEFAULT_ELEVATOR_DEPTH,
)
}
function commitElevatorPlacement(
buildingId: BuildingNode['id'],
position: [number, number, number],
rotation: number,
): void {
const { createNode, nodes } = useScene.getState()
const elevatorCount = Object.values(nodes).filter((node) => node.type === 'elevator').length
const serviceRange = resolveDefaultServiceRange(buildingId)
const elevator = ElevatorNode.parse({
name: `Elevator ${elevatorCount + 1}`,
parentId: buildingId,
position,
rotation,
width: DEFAULT_ELEVATOR_WIDTH,
depth: DEFAULT_ELEVATOR_DEPTH,
cabHeight: DEFAULT_ELEVATOR_CAB_HEIGHT,
doorWidth: DEFAULT_ELEVATOR_DOOR_WIDTH,
doorHeight: DEFAULT_ELEVATOR_DOOR_HEIGHT,
...serviceRange,
speed: DEFAULT_ELEVATOR_SPEED,
doorDurationMs: DEFAULT_ELEVATOR_DOOR_DURATION_MS,
dwellMs: DEFAULT_ELEVATOR_DWELL_MS,
})
createNode(elevator, buildingId)
useViewer.getState().setSelection({ buildingId, selectedIds: [elevator.id] })
sfxEmitter.emit('sfx:structure-build')
}
export const ElevatorTool: React.FC = () => {
const cursorRef = useRef<THREE.Group>(null)
const previewRef = useRef<THREE.Group>(null)
const rotationRef = useRef(0)
const previousGridPosRef = useRef<[number, number] | null>(null)
const buildingId = useViewer((state) => state.selection.buildingId)
const levelId = useViewer((state) => state.selection.levelId)
const previewGeometry = useMemo(() => createElevatorPreviewGeometry(), [])
useEffect(() => {
const currentBuildingId =
(buildingId as BuildingNode['id'] | null) ??
(levelId
? (() => {
const level = useScene.getState().nodes[levelId as AnyNodeId]
return level?.type === 'level' && level.parentId
? (level.parentId as BuildingNode['id'])
: null
})()
: null)
if (!currentBuildingId) return
rotationRef.current = 0
if (previewRef.current) previewRef.current.rotation.y = 0
const onGridMove = (event: GridEvent) => {
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
const y = event.localPosition[1]
cursorRef.current?.position.set(gridX, y + GRID_OFFSET, gridZ)
previewRef.current?.position.set(gridX, y + DEFAULT_ELEVATOR_CAB_HEIGHT / 2, gridZ)
if (
previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = [gridX, gridZ]
}
const onGridClick = (event: GridEvent) => {
const latestBuildingId = resolveCurrentBuildingId()
if (!latestBuildingId) return
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
commitElevatorPlacement(latestBuildingId, [gridX, 0, gridZ], rotationRef.current)
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
const ROTATION_STEP = Math.PI / 4
let rotationDelta = 0
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
if (rotationDelta !== 0) {
event.preventDefault()
sfxEmitter.emit('sfx:item-rotate')
rotationRef.current += rotationDelta
if (previewRef.current) previewRef.current.rotation.y = rotationRef.current
}
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
window.addEventListener('keydown', onKeyDown)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
window.removeEventListener('keydown', onKeyDown)
}
}, [buildingId, levelId])
return (
<group>
<CursorSphere ref={cursorRef} />
<group ref={previewRef}>
<mesh castShadow geometry={previewGeometry}>
<meshStandardMaterial color="#38bdf8" depthWrite={false} opacity={0.32} transparent />
</mesh>
<mesh position={[0, DEFAULT_ELEVATOR_CAB_HEIGHT / 2, -DEFAULT_ELEVATOR_DEPTH / 2 - 0.03]}>
<boxGeometry args={[DEFAULT_ELEVATOR_DOOR_WIDTH, DEFAULT_ELEVATOR_DOOR_HEIGHT, 0.035]} />
<meshStandardMaterial color="#e5e7eb" depthWrite={false} opacity={0.45} transparent />
</mesh>
</group>
</group>
)
}
@@ -0,0 +1,209 @@
import {
type AnyNodeId,
type ElevatorNode,
ElevatorNode as ElevatorNodeSchema,
emitter,
type GridEvent,
sceneRegistry,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
function stripMoveMetadata(metadata: ElevatorNode['metadata']) {
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
return metadata
}
const nextMeta = { ...(metadata as Record<string, unknown>) }
delete nextMeta.isNew
delete nextMeta.isTransient
return nextMeta as ElevatorNode['metadata']
}
export function MoveElevatorTool({ node: movingNode }: { node: ElevatorNode }) {
const previousGridPosRef = useRef<[number, number] | null>(null)
const previewPositionRef = useRef<ElevatorNode['position']>([
movingNode.position[0],
movingNode.position[1],
movingNode.position[2],
])
const [cursorPosition, setCursorPosition] = useState<[number, number, number]>(() => [
movingNode.position[0],
movingNode.position[1],
movingNode.position[2],
])
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
useScene.temporal.getState().pause()
const movingNodeId = (movingNode as { id?: ElevatorNode['id'] }).id
const meta =
typeof movingNode.metadata === 'object' && movingNode.metadata !== null
? (movingNode.metadata as Record<string, unknown>)
: {}
const isNew = !!meta.isNew
const committedMeta = stripMoveMetadata(movingNode.metadata)
const original = {
position: [...movingNode.position] as ElevatorNode['position'],
rotation: movingNode.rotation,
metadata: movingNode.metadata,
}
let wasCommitted = false
let wasCancelled = false
let pendingRotation = movingNode.rotation
const applyPreview = (
position: ElevatorNode['position'],
rotation: ElevatorNode['rotation'],
) => {
if (movingNodeId) {
useLiveTransforms.getState().set(movingNodeId, { position, rotation })
}
const object = movingNodeId ? sceneRegistry.nodes.get(movingNodeId) : null
if (object) {
object.position.set(position[0], position[1], position[2])
object.rotation.y = rotation
}
}
const resetObject = (
position: ElevatorNode['position'],
rotation: ElevatorNode['rotation'],
) => {
const object = movingNodeId ? sceneRegistry.nodes.get(movingNodeId) : null
if (object) {
object.position.set(position[0], position[1], position[2])
object.rotation.y = rotation
}
}
const clearPreview = () => {
if (movingNodeId) {
useLiveTransforms.getState().clear(movingNodeId)
}
}
const onGridMove = (event: GridEvent) => {
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
const y = event.localPosition[1]
if (
previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = [gridX, gridZ]
setCursorPosition([gridX, y, gridZ])
previewPositionRef.current = [gridX, movingNode.position[1], gridZ]
applyPreview(previewPositionRef.current, pendingRotation)
}
const onGridClick = (event: GridEvent) => {
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
wasCommitted = true
clearPreview()
useScene.temporal.getState().resume()
if (movingNodeId && useScene.getState().nodes[movingNodeId as AnyNodeId]) {
useScene.getState().updateNode(movingNodeId as AnyNodeId, {
position: [gridX, movingNode.position[1], gridZ],
rotation: pendingRotation,
metadata: committedMeta,
})
useViewer.getState().setSelection({ selectedIds: [movingNodeId] })
} else if (movingNode.parentId) {
const elevator = ElevatorNodeSchema.parse({
...movingNode,
id: undefined,
position: [gridX, movingNode.position[1], gridZ],
rotation: pendingRotation,
metadata: committedMeta,
})
useScene.getState().createNode(elevator, movingNode.parentId as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [elevator.id] })
}
sfxEmitter.emit('sfx:item-place')
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
wasCancelled = true
clearPreview()
if (isNew && movingNodeId) {
useScene.getState().deleteNode(movingNodeId as AnyNodeId)
} else {
if (movingNodeId) {
useScene.getState().updateNode(movingNodeId as AnyNodeId, {
position: original.position,
rotation: original.rotation,
metadata: original.metadata,
})
}
}
resetObject(original.position, original.rotation)
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
const ROTATION_STEP = Math.PI / 4
let rotationDelta = 0
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
if (rotationDelta !== 0) {
event.preventDefault()
sfxEmitter.emit('sfx:item-rotate')
pendingRotation += rotationDelta
applyPreview(previewPositionRef.current, pendingRotation)
}
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
return () => {
clearPreview()
if (!(wasCommitted || wasCancelled || isNew) && movingNodeId) {
useScene.getState().updateNode(movingNodeId as AnyNodeId, {
position: original.position,
rotation: original.rotation,
metadata: original.metadata,
})
resetObject(original.position, original.rotation)
}
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
}
}, [movingNode, exitMoveMode])
return <CursorSphere position={cursorPosition} showTooltip={false} />
}
@@ -3,6 +3,7 @@ import type {
CeilingNode,
ColumnNode,
DoorNode,
ElevatorNode,
FenceNode,
ItemNode,
RoofNode,
@@ -21,6 +22,7 @@ import { MoveBuildingContent } from '../building/move-building-tool'
import { MoveCeilingTool } from '../ceiling/move-ceiling-tool'
import { MoveColumnTool } from '../column/move-column-tool'
import { MoveDoorTool } from '../door/move-door-tool'
import { MoveElevatorTool } from '../elevator/move-elevator-tool'
import { MoveFenceTool } from '../fence/move-fence-tool'
import { MoveRoofTool } from '../roof/move-roof-tool'
import { MoveSlabTool } from '../slab/move-slab-tool'
@@ -99,6 +101,7 @@ export const MoveTool: React.FC<{
if (movingNode.type === 'building')
return <MoveBuildingContent node={movingNode as BuildingNode} />
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
if (movingNode.type === 'elevator') return <MoveElevatorTool node={movingNode as ElevatorNode} />
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
@@ -12,6 +12,7 @@ import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
import { CeilingTool } from './ceiling/ceiling-tool'
import { ColumnTool } from './column/column-tool'
import { DoorTool } from './door/door-tool'
import { ElevatorTool } from './elevator/elevator-tool'
import { CurveFenceTool } from './fence/curve-fence-tool'
import { FenceTool } from './fence/fence-tool'
import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool'
@@ -41,6 +42,7 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
slab: SlabTool,
ceiling: CeilingTool,
roof: RoofTool,
elevator: ElevatorTool,
stair: StairTool,
door: DoorTool,
item: ItemTool,
@@ -25,6 +25,7 @@ export const tools: ToolConfig[] = [
{ id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
{ id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
{ id: 'column', iconSrc: '/icons/column.png', label: 'Column' },
{ id: 'elevator', iconSrc: '/icons/elevator.svg', label: 'Elevator' },
{ id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' },
{ id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
{ id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
@@ -190,8 +190,11 @@ export function EditorCommands() {
const { nodes } = useScene.getState()
const building = Object.values(nodes).find((n) => n.type === 'building')
if (!building) return
const levelCount = building.children.filter(
(childId) => nodes[childId as keyof typeof nodes]?.type === 'level',
).length
const newLevel = LevelNode.parse({
level: building.children.length,
level: levelCount,
children: [],
parentId: building.id,
})
@@ -9,24 +9,28 @@ interface MetricControlProps {
label: React.ReactNode
value: number
onChange: (value: number) => void
onCommit?: (value: number) => void
min?: number
max?: number
precision?: number
step?: number
className?: string
unit?: string
restoreOnCommit?: boolean
}
export function MetricControl({
label,
value,
onChange,
onCommit,
min = Number.NEGATIVE_INFINITY,
max = Number.POSITIVE_INFINITY,
precision = 2,
step = 1,
className,
unit = '',
restoreOnCommit = true,
}: MetricControlProps) {
const viewerUnit = useViewer((state) => state.unit)
const isImperial = viewerUnit === 'imperial' && unit === 'm'
@@ -53,6 +57,17 @@ export function MetricControl({
[min, max],
)
const applyCommittedValue = useCallback(
(nextValue: number) => {
if (onCommit) {
onCommit(nextValue)
} else {
onChange(nextValue)
}
},
[onChange, onCommit],
)
useEffect(() => {
if (!isEditing) {
setInputValue(displayValue.toFixed(precision))
@@ -77,13 +92,13 @@ export function MetricControl({
const finalValue = Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
if (Math.abs(finalValue - valueRef.current) > 1e-6) {
onChange(finalValue)
applyCommittedValue(finalValue)
}
}
container.addEventListener('wheel', handleWheel, { passive: false })
return () => container.removeEventListener('wheel', handleWheel)
}, [isEditing, step, clamp, onChange, precision, multiplier])
}, [isEditing, step, clamp, applyCommittedValue, precision, multiplier])
useEffect(() => {
if (!isHovered || isEditing) return
@@ -104,14 +119,14 @@ export function MetricControl({
Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
if (Math.abs(finalValue - valueRef.current) > 1e-6) {
onChange(finalValue)
applyCommittedValue(finalValue)
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isHovered, isEditing, step, clamp, onChange, precision, multiplier])
}, [isHovered, isEditing, step, clamp, applyCommittedValue, precision, multiplier])
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
@@ -148,7 +163,14 @@ export function MetricControl({
document.removeEventListener('pointermove', handlePointerMove)
document.removeEventListener('pointerup', handlePointerUp)
if (Math.abs(finalValue - startValueRef.current) > 1e-6) {
const changed = Math.abs(finalValue - startValueRef.current) > 1e-6
if (onCommit) {
if (changed && restoreOnCommit) {
onChange(startValueRef.current)
}
useScene.temporal.getState().resume()
onCommit(finalValue)
} else if (changed) {
onChange(startValueRef.current)
useScene.temporal.getState().resume()
onChange(finalValue)
@@ -160,7 +182,7 @@ export function MetricControl({
document.addEventListener('pointermove', handlePointerMove)
document.addEventListener('pointerup', handlePointerUp)
},
[isEditing, value, onChange, clamp, precision, step, multiplier],
[isEditing, value, onChange, onCommit, restoreOnCommit, clamp, precision, step, multiplier],
)
const handleValueClick = useCallback(() => {
@@ -177,10 +199,10 @@ export function MetricControl({
if (Number.isNaN(numValue)) {
setInputValue((value * multiplier).toFixed(precision))
} else {
onChange(clamp(numValue / multiplier))
applyCommittedValue(clamp(numValue / multiplier))
}
setIsEditing(false)
}, [inputValue, onChange, clamp, multiplier, value, precision])
}, [inputValue, applyCommittedValue, clamp, multiplier, value, precision])
const handleInputBlur = useCallback(() => {
submitValue()
@@ -196,16 +218,16 @@ export function MetricControl({
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const newV = clamp(value + step / multiplier)
onChange(newV)
applyCommittedValue(newV)
setInputValue((newV * multiplier).toFixed(precision))
} else if (e.key === 'ArrowDown') {
e.preventDefault()
const newV = clamp(value - step / multiplier)
onChange(newV)
applyCommittedValue(newV)
setInputValue((newV * multiplier).toFixed(precision))
}
},
[submitValue, value, multiplier, precision, step, clamp, onChange],
[submitValue, value, multiplier, precision, step, clamp, applyCommittedValue],
)
return (
@@ -91,7 +91,7 @@ export function CeilingPanel() {
(index: number) => {
if (!selectedId) return
const currentHoles = node?.holes || []
if (node?.holeMetadata?.[index]?.source === 'stair') return
if ((node?.holeMetadata?.[index]?.source ?? 'manual') !== 'manual') return
const newHoles = currentHoles.filter((_, i) => i !== index)
const currentMetadata = currentHoles.map(
(_, metadataIndex) => node?.holeMetadata?.[metadataIndex] ?? { source: 'manual' as const },
@@ -172,7 +172,8 @@ export function CeilingPanel() {
const isEditing =
editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
const source = node.holeMetadata?.[index]?.source ?? 'manual'
const isAutoHole = source === 'stair'
const isAutoHole = source !== 'manual'
const autoLabel = source === 'elevator' ? 'Auto elevator cutout' : 'Auto stair cutout'
return (
<div
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
@@ -190,7 +191,7 @@ export function CeilingPanel() {
</p>
<p className="text-[10px] text-muted-foreground">
{holeArea.toFixed(2)} m² · {hole.length} pts ·{' '}
{isAutoHole ? 'Auto stair cutout' : 'Manual'}
{isAutoHole ? autoLabel : 'Manual'}
</p>
</div>
<div className="flex items-center gap-1">
@@ -0,0 +1,471 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type ElevatorNode,
ElevatorNode as ElevatorNodeSchema,
type LevelNode,
useInteractive,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Send, Trash2 } from 'lucide-react'
import { useCallback, useEffect } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
function findLevelId(levels: LevelNode[], levelId: string | null | undefined) {
if (!levelId) return null
return levels.some((level) => level.id === levelId) ? levelId : null
}
function getLegacyServedLevels(node: ElevatorNode | undefined, levels: LevelNode[]) {
if (!node || node.fromLevelId || node.toLevelId || !node.servedLevelIds?.length) return []
const servedIds = new Set(node.servedLevelIds)
return levels.filter((level) => servedIds.has(level.id))
}
function getResolvedFromLevelId(node: ElevatorNode | undefined, levels: LevelNode[]) {
if (!node) return levels[0]?.id ?? ''
const legacyServedLevels = getLegacyServedLevels(node, levels)
return (
findLevelId(levels, node.fromLevelId) ??
legacyServedLevels[0]?.id ??
findLevelId(levels, node.defaultLevelId) ??
levels[0]?.id ??
''
)
}
function getResolvedToLevelId(
node: ElevatorNode | undefined,
levels: LevelNode[],
fromLevelId: string,
) {
if (!node) return levels[0]?.id ?? ''
const explicitTo = findLevelId(levels, node.toLevelId)
if (explicitTo) return explicitTo
const legacyServedLevels = getLegacyServedLevels(node, levels)
const legacyTo = legacyServedLevels[legacyServedLevels.length - 1]?.id
if (legacyTo) return legacyTo
const fromIndex = levels.findIndex((level) => level.id === fromLevelId)
const fallbackIndex = fromIndex >= 0 ? Math.min(fromIndex + 1, levels.length - 1) : 0
return levels[fallbackIndex]?.id ?? fromLevelId
}
function getServiceLevels(levels: LevelNode[], fromLevelId: string, toLevelId: string) {
const fromIndex = levels.findIndex((level) => level.id === fromLevelId)
const toIndex = levels.findIndex((level) => level.id === toLevelId)
if (fromIndex < 0 && toIndex < 0) return []
const resolvedFromIndex = fromIndex >= 0 ? fromIndex : toIndex
const resolvedToIndex =
toIndex >= 0 ? toIndex : Math.min(Math.max(resolvedFromIndex, 0) + 1, levels.length - 1)
const minIndex = Math.min(resolvedFromIndex, resolvedToIndex)
const maxIndex = Math.max(resolvedFromIndex, resolvedToIndex)
return levels.slice(minIndex, maxIndex + 1)
}
function stripDuplicateFlags(metadata: ElevatorNode['metadata']) {
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
return metadata
}
const nextMeta = { ...(metadata as Record<string, unknown>) }
delete nextMeta.isNew
delete nextMeta.isTransient
return nextMeta as ElevatorNode['metadata']
}
type ElevatorMetricKey = 'width' | 'depth' | 'cabHeight' | 'doorWidth' | 'doorHeight'
export function ElevatorPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const runtime = useInteractive(
useShallow((s) => {
const state = selectedId ? s.elevators[selectedId as AnyNodeId] : null
if (!state) return null
return {
currentLevelId: state.currentLevelId,
queue: state.queue,
targetLevelId: state.targetLevelId,
}
}),
)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as ElevatorNode | undefined) : undefined,
)
const liveOverrides = useLiveNodeOverrides((s) =>
selectedId ? s.get(selectedId as AnyNodeId) : undefined,
)
useEffect(() => {
return () => {
if (selectedId) useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
}
}, [selectedId])
const levels = useScene(
useShallow((s) => {
if (!(node?.parentId && s.nodes[node.parentId as AnyNodeId]?.type === 'building')) return []
const building = s.nodes[node.parentId as AnyNodeId]
if (building?.type !== 'building') return []
return building.children
.map((childId) => s.nodes[childId as AnyNodeId])
.filter((entry): entry is LevelNode => entry?.type === 'level')
.sort((left, right) => left.level - right.level)
}),
)
const handleUpdate = useCallback(
(updates: Partial<ElevatorNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
},
[selectedId, updateNode],
)
const clearLivePreview = useCallback(() => {
if (selectedId) useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
}, [selectedId])
const previewMetric = useCallback(
<K extends ElevatorMetricKey>(key: K, value: ElevatorNode[K]) => {
if (!selectedId) return
useLiveNodeOverrides.getState().set(selectedId as AnyNodeId, { [key]: value })
},
[selectedId],
)
const commitMetric = useCallback(
<K extends ElevatorMetricKey>(key: K, value: ElevatorNode[K]) => {
if (!selectedId) return
const hasChange = !(node && Math.abs(Number(node[key]) - Number(value)) <= 1e-6)
if (hasChange) {
updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial<ElevatorNode>)
}
useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
},
[node, selectedId, updateNode],
)
const handleClose = useCallback(() => {
clearLivePreview()
setSelection({ selectedIds: [] })
}, [clearLivePreview, setSelection])
const handleMove = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
clearLivePreview()
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [clearLivePreview, node, setMovingNode, setSelection])
const handleDuplicate = useCallback(() => {
if (!(node && node.parentId)) return
sfxEmitter.emit('sfx:item-pick')
const duplicate = ElevatorNodeSchema.parse({
...structuredClone(node),
id: undefined,
name: node.name ? `${node.name} Copy` : 'Elevator Copy',
position: [node.position[0] + 1, node.position[1], node.position[2] + 1],
metadata: { ...(stripDuplicateFlags(node.metadata) as Record<string, unknown>), isNew: true },
})
createNode(duplicate, node.parentId as AnyNodeId)
clearLivePreview()
setMovingNode(duplicate)
setSelection({ selectedIds: [] })
}, [clearLivePreview, node, createNode, setMovingNode, setSelection])
const handleDelete = useCallback(() => {
if (!(selectedId && node)) return
sfxEmitter.emit('sfx:structure-delete')
clearLivePreview()
useScene.getState().deleteNode(selectedId as AnyNodeId)
setSelection({ selectedIds: [] })
}, [clearLivePreview, selectedId, node, setSelection])
const requestLevel = useCallback(
(levelId: LevelNode['id']) => {
if (!node) return
useInteractive.getState().requestElevator(node.id as AnyNodeId, levelId as AnyNodeId)
},
[node],
)
const handleServiceBoundaryChange = useCallback(
(field: 'fromLevelId' | 'toLevelId', levelId: string) => {
if (!node) return
const nextFromLevelId =
field === 'fromLevelId' ? levelId : getResolvedFromLevelId(node, levels)
const nextToLevelId =
field === 'toLevelId' ? levelId : getResolvedToLevelId(node, levels, nextFromLevelId)
const nextServedLevels = getServiceLevels(levels, nextFromLevelId, nextToLevelId)
const currentDefaultIsServed = nextServedLevels.some(
(level) => level.id === node.defaultLevelId,
)
handleUpdate({
[field]: levelId || null,
defaultLevelId: currentDefaultIsServed
? node.defaultLevelId
: nextFromLevelId || nextServedLevels[0]?.id || null,
servedLevelIds: undefined,
} as Partial<ElevatorNode>)
},
[node, levels, handleUpdate],
)
if (!(node && node.type === 'elevator' && selectedId && selectedCount === 1)) return null
const displayNode = liveOverrides ? ({ ...node, ...liveOverrides } as ElevatorNode) : node
const fromLevelId = getResolvedFromLevelId(node, levels)
const toLevelId = getResolvedToLevelId(node, levels, fromLevelId)
const servedLevels = getServiceLevels(levels, fromLevelId, toLevelId)
const defaultLevelOptions = servedLevels.length > 0 ? servedLevels : levels
const selectedDefaultLevelId = defaultLevelOptions.some(
(level) => level.id === node.defaultLevelId,
)
? (node.defaultLevelId ?? '')
: fromLevelId
const activeLevelId =
runtime?.currentLevelId ??
(servedLevels.some((level) => level.id === node.defaultLevelId)
? node.defaultLevelId
: fromLevelId || levels[0]?.id) ??
null
const queuedLevelIds = new Set<string>()
for (const levelId of runtime?.queue ?? []) queuedLevelIds.add(levelId)
if (runtime?.targetLevelId) queuedLevelIds.add(runtime.targetLevelId)
return (
<PanelWrapper
icon="/icons/elevator.svg"
onClose={handleClose}
title={node.name || 'Elevator'}
width={300}
>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
<ActionButton
icon={<Copy className="h-3.5 w-3.5" />}
label="Duplicate"
onClick={handleDuplicate}
/>
<ActionButton
className="text-destructive hover:text-destructive"
icon={<Trash2 className="h-3.5 w-3.5" />}
label="Delete"
onClick={handleDelete}
/>
</ActionGroup>
</PanelSection>
<PanelSection title="Cab">
<MetricControl
label="Width"
max={4}
min={0.8}
onChange={(value) => previewMetric('width', value)}
onCommit={(value) => commitMetric('width', value)}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={displayNode.width}
/>
<MetricControl
label="Depth"
max={4}
min={0.8}
onChange={(value) => previewMetric('depth', value)}
onCommit={(value) => commitMetric('depth', value)}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={displayNode.depth}
/>
<MetricControl
label="Cab Height"
max={4}
min={1.8}
onChange={(value) => previewMetric('cabHeight', value)}
onCommit={(value) => commitMetric('cabHeight', value)}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={displayNode.cabHeight}
/>
</PanelSection>
<PanelSection title="Doors">
<MetricControl
label="Door Width"
max={Math.max(displayNode.width - 0.1, 0.5)}
min={0.45}
onChange={(value) => previewMetric('doorWidth', value)}
onCommit={(value) => commitMetric('doorWidth', value)}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={displayNode.doorWidth}
/>
<MetricControl
label="Door Height"
max={Math.max(displayNode.cabHeight - 0.1, 1.3)}
min={1.2}
onChange={(value) => previewMetric('doorHeight', value)}
onCommit={(value) => commitMetric('doorHeight', value)}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={displayNode.doorHeight}
/>
</PanelSection>
<PanelSection title="Service">
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
From
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-2 text-sm text-foreground"
onChange={(event) =>
handleServiceBoundaryChange('fromLevelId', event.target.value)
}
value={fromLevelId}
>
{levels.map((level) => (
<option key={level.id} value={level.id}>
{level.name || `Level ${level.level}`}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
To
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-2 text-sm text-foreground"
onChange={(event) => handleServiceBoundaryChange('toLevelId', event.target.value)}
value={toLevelId}
>
{levels.map((level) => (
<option key={level.id} value={level.id}>
{level.name || `Level ${level.level}`}
</option>
))}
</select>
</div>
</div>
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
Default Floor
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
onChange={(event) => handleUpdate({ defaultLevelId: event.target.value || null })}
value={selectedDefaultLevelId}
>
{defaultLevelOptions.map((level) => (
<option key={level.id} value={level.id}>
{level.name || `Level ${level.level}`}
</option>
))}
</select>
</div>
</PanelSection>
<PanelSection title="Destination">
<div className="grid grid-cols-2 gap-1.5">
{servedLevels.map((level) => {
const isActive = activeLevelId === level.id
const isQueued = queuedLevelIds.has(level.id)
return (
<button
className={`flex min-h-11 items-center justify-between gap-2 rounded-lg border px-2.5 text-left transition-colors ${
isActive
? 'border-sky-400/45 bg-sky-400/15 text-sky-100'
: isQueued
? 'border-amber-300/45 bg-amber-300/15 text-amber-100'
: 'border-border/50 bg-[#2C2C2E] text-foreground hover:bg-[#3e3e3e]'
}`}
key={level.id}
onClick={() => requestLevel(level.id)}
type="button"
>
<span className="min-w-0 truncate text-xs">
{level.name || `Level ${level.level}`}
</span>
<span className="flex h-6 min-w-6 items-center justify-center rounded-full border border-white/15 bg-black/20">
<Send className="h-3 w-3" />
</span>
</button>
)
})}
</div>
</PanelSection>
<PanelSection title="Motion">
<SliderControl
label="Speed"
max={8}
min={0.5}
onChange={(value) => handleUpdate({ speed: value })}
precision={1}
step={0.1}
unit="m/s"
value={node.speed}
/>
<SliderControl
label="Door Time"
max={2200}
min={300}
onChange={(value) => handleUpdate({ doorDurationMs: value })}
step={50}
unit="ms"
value={node.doorDurationMs}
/>
<SliderControl
label="Dwell"
max={5000}
min={300}
onChange={(value) => handleUpdate({ dwellMs: value })}
step={100}
unit="ms"
value={node.dwellMs}
/>
</PanelSection>
</PanelWrapper>
)
}
@@ -13,6 +13,7 @@ const TYPE_DEFAULTS: Record<string, NodeDisplay> = {
slab: { icon: '/icons/floor.png', label: 'Slab' },
ceiling: { icon: '/icons/ceiling.png', label: 'Ceiling' },
column: { icon: '/icons/column.png', label: 'Column' },
elevator: { icon: '/icons/elevator.svg', label: 'Elevator' },
fence: { icon: '/icons/fence.png', label: 'Fence' },
roof: { icon: '/icons/roof.png', label: 'Roof' },
'roof-segment': { icon: '/icons/roof.png', label: 'Roof segment' },
@@ -7,6 +7,7 @@ import {
type CeilingNode,
type ColumnNode,
type DoorNode,
type ElevatorNode,
type FenceNode,
type ItemNode,
type RoofNode,
@@ -26,6 +27,7 @@ import useEditor from '../../../store/use-editor'
import { CeilingPanel } from './ceiling-panel'
import { ColumnPanel } from './column-panel'
import { DoorPanel } from './door-panel'
import { ElevatorPanel } from './elevator-panel'
import { FencePanel } from './fence-panel'
import { ItemPanel } from './item-panel'
import { MobilePanelSheet } from './mobile-panel-sheet'
@@ -46,6 +48,7 @@ type MovableNode =
| ItemNode
| WindowNode
| DoorNode
| ElevatorNode
| CeilingNode
| ColumnNode
| SlabNode
@@ -61,6 +64,7 @@ const MOVABLE_TYPES = new Set<string>([
'item',
'window',
'door',
'elevator',
'ceiling',
'column',
'slab',
@@ -102,6 +106,8 @@ function panelForType(type: string | null) {
return <FencePanel />
case 'door':
return <DoorPanel />
case 'elevator':
return <ElevatorPanel />
case 'window':
return <WindowPanel />
default:
@@ -264,6 +270,8 @@ export function PanelManager() {
return <FencePanel />
case 'door':
return <DoorPanel />
case 'elevator':
return <ElevatorPanel />
case 'window':
return <WindowPanel />
}
@@ -91,7 +91,7 @@ export function SlabPanel() {
(index: number) => {
if (!selectedId) return
const currentHoles = node?.holes || []
if (node?.holeMetadata?.[index]?.source === 'stair') return
if ((node?.holeMetadata?.[index]?.source ?? 'manual') !== 'manual') return
const newHoles = currentHoles.filter((_, i) => i !== index)
const currentMetadata = currentHoles.map(
(_, metadataIndex) => node?.holeMetadata?.[metadataIndex] ?? { source: 'manual' as const },
@@ -173,7 +173,8 @@ export function SlabPanel() {
const isEditing =
editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
const source = node.holeMetadata?.[index]?.source ?? 'manual'
const isAutoHole = source === 'stair'
const isAutoHole = source !== 'manual'
const autoLabel = source === 'elevator' ? 'Auto elevator cutout' : 'Auto stair cutout'
return (
<div
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
@@ -191,7 +192,7 @@ export function SlabPanel() {
</p>
<p className="text-[10px] text-muted-foreground">
{holeArea.toFixed(2)} m² · {hole.length} pts ·{' '}
{isAutoHole ? 'Auto stair cutout' : 'Manual'}
{isAutoHole ? autoLabel : 'Manual'}
</p>
</div>
<div className="flex items-center gap-1">
@@ -39,8 +39,10 @@ export const BuildingTreeNode = memo(function BuildingTreeNode({
const handleAddLevel = (e: React.MouseEvent) => {
e.stopPropagation()
const nodes = useScene.getState().nodes
const levelCount = children.filter((childId) => nodes[childId]?.type === 'level').length
const newLevel = LevelNode.parse({
level: children.length,
level: levelCount,
children: [],
parentId: nodeId,
})
@@ -0,0 +1,75 @@
import { type AnyNodeId, type ElevatorNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
import { memo, useCallback, useState } from 'react'
import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
import { TreeNodeActions } from './tree-node-actions'
interface ElevatorTreeNodeProps {
nodeId: AnyNodeId
depth: number
isLast?: boolean
}
export const ElevatorTreeNode = memo(function ElevatorTreeNode({
nodeId,
depth,
isLast,
}: ElevatorTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false)
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
const node = useScene((s) => s.nodes[nodeId] as ElevatorNode | undefined)
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
const isHovered = useViewer((state) => state.hoveredId === nodeId)
const setSelection = useViewer((state) => state.setSelection)
const setHoveredId = useViewer((state) => state.setHoveredId)
const handleClick = useCallback(
(event: React.MouseEvent) => {
event.stopPropagation()
const handled = handleTreeSelection(
event,
nodeId,
useViewer.getState().selection.selectedIds,
setSelection,
)
if (!handled && useEditor.getState().phase === 'furnish') {
useEditor.getState().setPhase('structure')
}
},
[nodeId, setSelection],
)
return (
<TreeNodeWrapper
actions={<TreeNodeActions nodeId={nodeId} />}
depth={depth}
expanded={false}
hasChildren={false}
icon={
<Image alt="" className="object-contain" height={14} src="/icons/elevator.svg" width={14} />
}
isHovered={isHovered}
isLast={isLast}
isSelected={isSelected}
isVisible={isVisible}
label={
<InlineRenameInput
defaultName={node?.name || 'Elevator'}
isEditing={isEditing}
nodeId={nodeId}
onStartEditing={() => setIsEditing(true)}
onStopEditing={() => setIsEditing(false)}
/>
}
nodeId={nodeId}
onClick={handleClick}
onDoubleClick={() => focusTreeNode(nodeId)}
onMouseEnter={() => setHoveredId(nodeId)}
onMouseLeave={() => setHoveredId(null)}
onToggle={() => {}}
/>
)
})
@@ -58,6 +58,7 @@ import { BuildingTreeNode } from './building-tree-node'
import { CeilingTreeNode } from './ceiling-tree-node'
import { ColumnTreeNode } from './column-tree-node'
import { DoorTreeNode } from './door-tree-node'
import { ElevatorTreeNode } from './elevator-tree-node'
import { FenceTreeNode } from './fence-tree-node'
import { ItemTreeNode } from './item-tree-node'
import { LevelTreeNode } from './level-tree-node'
@@ -87,6 +88,8 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr
return <CeilingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'column':
return <ColumnTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'elevator':
return <ElevatorTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'level':
return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `level_${string}`} />
case 'slab':
@@ -28,6 +28,7 @@ export function useContextualTools() {
'slab',
'ceiling',
'roof',
'elevator',
'door',
'window',
]
+21 -11
View File
@@ -133,13 +133,19 @@ export const useKeyboard = ({
const { buildingId, levelId } = useViewer.getState().selection
if (buildingId) {
const building = useScene.getState().nodes[buildingId]
if (building && building.type === 'building' && building.children.length > 0) {
const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1
const nextIdx = currentIdx < building.children.length - 1 ? currentIdx + 1 : currentIdx
const levels =
building?.type === 'building'
? building.children.filter(
(childId) => useScene.getState().nodes[childId as AnyNodeId]?.type === 'level',
)
: []
if (levels.length > 0) {
const currentIdx = levelId ? levels.indexOf(levelId as any) : -1
const nextIdx = currentIdx < levels.length - 1 ? currentIdx + 1 : currentIdx
if (nextIdx !== -1 && nextIdx !== currentIdx) {
useViewer.getState().setSelection({ levelId: building.children[nextIdx] as any })
useViewer.getState().setSelection({ levelId: levels[nextIdx] as any })
} else if (currentIdx === -1) {
useViewer.getState().setSelection({ levelId: building.children[0] as any })
useViewer.getState().setSelection({ levelId: levels[0] as any })
}
}
}
@@ -148,15 +154,19 @@ export const useKeyboard = ({
const { buildingId, levelId } = useViewer.getState().selection
if (buildingId) {
const building = useScene.getState().nodes[buildingId]
if (building && building.type === 'building' && building.children.length > 0) {
const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1
const levels =
building?.type === 'building'
? building.children.filter(
(childId) => useScene.getState().nodes[childId as AnyNodeId]?.type === 'level',
)
: []
if (levels.length > 0) {
const currentIdx = levelId ? levels.indexOf(levelId as any) : -1
const prevIdx = currentIdx > 0 ? currentIdx - 1 : currentIdx
if (prevIdx !== -1 && prevIdx !== currentIdx) {
useViewer.getState().setSelection({ levelId: building.children[prevIdx] as any })
useViewer.getState().setSelection({ levelId: levels[prevIdx] as any })
} else if (currentIdx === -1) {
useViewer
.getState()
.setSelection({ levelId: building.children[building.children.length - 1] as any })
useViewer.getState().setSelection({ levelId: levels[levels.length - 1] as any })
}
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { useLiveTransforms, useScene } from '@pascal-app/core'
import { useLiveNodeOverrides, useLiveTransforms, useScene } from '@pascal-app/core'
function refreshSceneAfterHistoryJump() {
useLiveNodeOverrides.getState().clearAll()
useLiveTransforms.getState().clearAll()
const state = useScene.getState()
+12 -2
View File
@@ -7,6 +7,7 @@ import {
type CeilingNode,
type ColumnNode,
type DoorNode,
type ElevatorNode,
type FenceNode,
type ItemNode,
type LevelNode,
@@ -58,6 +59,7 @@ export type StructureTool =
| 'ceiling'
| 'roof'
| 'column'
| 'elevator'
| 'stair'
| 'item'
| 'zone'
@@ -138,6 +140,7 @@ type EditorState = {
| ItemNode
| WindowNode
| DoorNode
| ElevatorNode
| FenceNode
| CeilingNode
| ColumnNode
@@ -155,6 +158,7 @@ type EditorState = {
| ItemNode
| WindowNode
| DoorNode
| ElevatorNode
| FenceNode
| CeilingNode
| ColumnNode
@@ -449,9 +453,14 @@ export function selectDefaultBuildingAndLevel() {
})
if (level0Id) {
viewer.setSelection({ levelId: level0Id as LevelNode['id'] })
} else if (buildingNode.children[0]) {
} else {
// Fallback to first level if level 0 doesn't exist
viewer.setSelection({ levelId: buildingNode.children[0] as LevelNode['id'] })
const firstLevelId = buildingNode.children.find(
(childId) => scene.nodes[childId]?.type === 'level',
)
if (firstLevelId) {
viewer.setSelection({ levelId: firstLevelId as LevelNode['id'] })
}
}
}
}
@@ -580,6 +589,7 @@ const useEditor = create<EditorState>()(
| ItemNode
| WindowNode
| DoorNode
| ElevatorNode
| FenceNode
| CeilingNode
| SlabNode
-1
View File
@@ -29,7 +29,6 @@
"three": "^0.184"
},
"dependencies": {
"polygon-clipping": "^0.15.7",
"three-bvh-csg": "^0.0.18",
"three-mesh-bvh": "^0.9.8",
"zustand": "^5"
@@ -0,0 +1,811 @@
import {
type AnyNodeId,
type ElevatorNode,
useInteractive,
useLiveNodeOverrides,
useRegistry,
useScene,
} from '@pascal-app/core'
import { useFrame, type ThreeEvent } from '@react-three/fiber'
import { useEffect, useMemo, useRef } from 'react'
import type { Group } from 'three'
import { useShallow } from 'zustand/react/shallow'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { resolveElevatorLevels } from '../../../systems/elevator/elevator-utils'
const SHAFT_WALL_COLOR = '#d7dce4'
const SHAFT_SIDE_COLOR = '#4b5563'
const SHAFT_TRIM_COLOR = '#eef2f7'
const CAB_COLOR = '#d7dde5'
const GLASS_COLOR = '#f8fafc'
const DOOR_COLOR = '#8e98a6'
const PANEL_COLOR = '#1f2937'
type SegmentName =
| 'bottom'
| 'lowerLeft'
| 'lowerRight'
| 'middle'
| 'top'
| 'upperLeft'
| 'upperRight'
const DIGIT_SEGMENTS: Record<string, readonly SegmentName[]> = {
'0': ['top', 'upperLeft', 'upperRight', 'lowerLeft', 'lowerRight', 'bottom'],
'1': ['upperRight', 'lowerRight'],
'2': ['top', 'upperRight', 'middle', 'lowerLeft', 'bottom'],
'3': ['top', 'upperRight', 'middle', 'lowerRight', 'bottom'],
'4': ['upperLeft', 'upperRight', 'middle', 'lowerRight'],
'5': ['top', 'upperLeft', 'middle', 'lowerRight', 'bottom'],
'6': ['top', 'upperLeft', 'middle', 'lowerLeft', 'lowerRight', 'bottom'],
'7': ['top', 'upperRight', 'lowerRight'],
'8': ['top', 'upperLeft', 'upperRight', 'middle', 'lowerLeft', 'lowerRight', 'bottom'],
'9': ['top', 'upperLeft', 'upperRight', 'middle', 'lowerRight', 'bottom'],
'-': ['middle'],
}
const SEGMENT_PROPS: Record<
SegmentName,
{ position: [number, number, number]; size: [number, number, number] }
> = {
bottom: { position: [0, -0.44, 0], size: [0.56, 0.11, 0.018] },
lowerLeft: { position: [-0.32, -0.22, 0], size: [0.11, 0.42, 0.018] },
lowerRight: { position: [0.32, -0.22, 0], size: [0.11, 0.42, 0.018] },
middle: { position: [0, 0, 0], size: [0.52, 0.1, 0.018] },
top: { position: [0, 0.44, 0], size: [0.56, 0.11, 0.018] },
upperLeft: { position: [-0.32, 0.22, 0], size: [0.11, 0.42, 0.018] },
upperRight: { position: [0.32, 0.22, 0], size: [0.11, 0.42, 0.018] },
}
function MeshButtonLabel({
color,
label,
position,
scale,
}: {
color: string
label: string
position: [number, number, number]
scale: number
}) {
const characters = label.split('').filter((character) => DIGIT_SEGMENTS[character])
const spacing = 0.72 * scale
const startX = -((characters.length - 1) * spacing) / 2
if (characters.length === 0) return null
return (
<group position={position}>
{characters.map((character, charIndex) => (
<group key={`${character}-${charIndex}`} position={[startX + charIndex * spacing, 0, 0]}>
{(DIGIT_SEGMENTS[character] ?? []).map((segment) => {
const props = SEGMENT_PROPS[segment]
return (
<mesh
key={segment}
position={[props.position[0] * scale, props.position[1] * scale, props.position[2]]}
>
<boxGeometry args={[props.size[0] * scale, props.size[1] * scale, props.size[2]]} />
<meshStandardMaterial color={color} metalness={0.12} roughness={0.34} />
</mesh>
)
})}
</group>
))}
</group>
)
}
function ElevatorDirectionGlyph({
color,
direction,
position,
scale,
}: {
color: string
direction: 'down' | 'up' | null
position: [number, number, number]
scale: number
}) {
if (!direction) {
return (
<mesh position={position}>
<boxGeometry args={[0.08 * scale, 0.08 * scale, 0.018]} />
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={0.28}
metalness={0.08}
roughness={0.32}
/>
</mesh>
)
}
const ySign = direction === 'up' ? 1 : -1
return (
<group position={position}>
<mesh
position={[-0.04 * scale, -0.02 * ySign * scale, 0]}
rotation-z={(-ySign * Math.PI) / 4}
>
<boxGeometry args={[0.16 * scale, 0.035 * scale, 0.018]} />
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={0.36}
metalness={0.08}
roughness={0.32}
/>
</mesh>
<mesh position={[0.04 * scale, -0.02 * ySign * scale, 0]} rotation-z={(ySign * Math.PI) / 4}>
<boxGeometry args={[0.16 * scale, 0.035 * scale, 0.018]} />
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={0.36}
metalness={0.08}
roughness={0.32}
/>
</mesh>
</group>
)
}
function ElevatorFloorIndicator({
active,
direction,
faceSign = -1,
label,
position,
scale = 1,
}: {
active: boolean
direction: 'down' | 'up' | null
faceSign?: -1 | 1
label: string
position: [number, number, number]
scale?: number
}) {
const glowColor = active ? '#38bdf8' : '#94a3b8'
const screenColor = active ? '#041f2f' : '#111827'
const displayLabel = label || '-'
const screenZ = faceSign * 0.026 * scale
const glyphZ = faceSign * 0.041 * scale
return (
<group position={position}>
<mesh castShadow receiveShadow>
<boxGeometry args={[0.42 * scale, 0.16 * scale, 0.045 * scale]} />
<meshStandardMaterial color={PANEL_COLOR} metalness={0.36} roughness={0.34} />
</mesh>
<mesh position={[0, 0, screenZ]}>
<boxGeometry args={[0.34 * scale, 0.095 * scale, 0.012 * scale]} />
<meshStandardMaterial
color={screenColor}
emissive={active ? '#0ea5e9' : '#000000'}
emissiveIntensity={active ? 0.16 : 0}
metalness={0.12}
roughness={0.38}
/>
</mesh>
<ElevatorDirectionGlyph
color={glowColor}
direction={direction}
position={[-0.115 * scale, 0, glyphZ]}
scale={scale}
/>
<MeshButtonLabel
color={glowColor}
label={displayLabel}
position={[0.075 * scale, 0, glyphZ]}
scale={0.055 * scale}
/>
</group>
)
}
function ElevatorMeshButton({
active,
buttonKind,
elevatorId,
faceSign = -1,
label,
levelId,
onRequest,
position,
queued,
radius = 0.055,
}: {
active: boolean
buttonKind: 'cab' | 'landing'
elevatorId: AnyNodeId
faceSign?: -1 | 1
label?: string
levelId: AnyNodeId
onRequest: () => void
position: [number, number, number]
queued: boolean
radius?: number
}) {
const buttonColor = active ? '#38bdf8' : queued ? '#fbbf24' : '#d6dde7'
const labelColor = active || queued ? '#111827' : '#334155'
const ringColor = active ? '#0ea5e9' : queued ? '#f59e0b' : '#64748b'
const depth = active ? 0.028 : 0.04
const faceZ = faceSign * (depth / 2 + 0.004)
const userData = useMemo(
() => ({
elevatorButton: {
elevatorId,
kind: buttonKind,
levelId,
},
}),
[buttonKind, elevatorId, levelId],
)
const press = (event: ThreeEvent<PointerEvent>) => {
if (event.button !== 0) return
onRequest()
}
return (
<group onPointerDown={press} position={position} userData={userData}>
{(active || queued) && (
<mesh position={[0, 0, faceSign * (depth + 0.004)]} receiveShadow rotation-x={Math.PI / 2}>
<cylinderGeometry args={[radius * 1.42, radius * 1.42, 0.012, 32]} />
<meshStandardMaterial
color={buttonColor}
depthWrite={false}
emissive={buttonColor}
emissiveIntensity={active ? 0.28 : 0.18}
opacity={0.58}
transparent
/>
</mesh>
)}
<mesh castShadow position={[0, 0, faceSign * (depth / 2 + 0.003)]} receiveShadow>
<torusGeometry args={[radius * 1.12, radius * 0.12, 8, 32]} />
<meshStandardMaterial
color={ringColor}
emissive={active || queued ? ringColor : '#000000'}
emissiveIntensity={active ? 0.16 : queued ? 0.1 : 0}
metalness={0.48}
roughness={0.28}
/>
</mesh>
<mesh castShadow receiveShadow rotation-x={Math.PI / 2}>
<cylinderGeometry args={[radius, radius * 0.92, depth, 32]} />
<meshStandardMaterial
color={buttonColor}
emissive={active || queued ? buttonColor : '#000000'}
emissiveIntensity={active ? 0.28 : queued ? 0.18 : 0}
metalness={0.22}
roughness={0.3}
/>
</mesh>
{label && (
<MeshButtonLabel
color={labelColor}
label={label}
position={[0, 0, faceZ]}
scale={radius * 0.72}
/>
)}
</group>
)
}
function DoorLeaf({
animated,
doorOpen,
height,
side,
width,
y,
z,
}: {
animated?:
| {
elevatorId: AnyNodeId
kind: 'cab'
}
| {
elevatorId: AnyNodeId
kind: 'landing'
levelId: AnyNodeId
}
doorOpen: number
height: number
side: 'left' | 'right'
width: number
y: number
z: number
}) {
const ref = useRef<Group>(null)
const direction = side === 'left' ? -1 : 1
const getLeafX = (openAmount: number) => direction * (width / 4 + openAmount * width * 0.34)
const leafWidth = Math.max(width / 2 - 0.018, 0.12)
const railHeight = Math.min(0.09, Math.max(0.055, height * 0.04))
const stileWidth = Math.min(0.07, Math.max(0.04, leafWidth * 0.18))
const glassWidth = Math.max(leafWidth - stileWidth * 2.2, 0.03)
const glassHeight = Math.max(height - railHeight * 3, 0.2)
useFrame(() => {
if (!(animated && ref.current)) return
const runtime = useInteractive.getState().elevators[animated.elevatorId]
const nextDoorOpen =
animated.kind === 'cab'
? (runtime?.doorOpen ?? 0)
: runtime?.currentLevelId === animated.levelId
? (runtime?.doorOpen ?? 0)
: 0
ref.current.position.x = getLeafX(nextDoorOpen)
}, 2.6)
return (
<group ref={ref} position={[getLeafX(doorOpen), y + height / 2, z]}>
<mesh castShadow position={[0, height / 2 - railHeight / 2, 0]} receiveShadow>
<boxGeometry args={[leafWidth, railHeight, 0.05]} />
<meshStandardMaterial color={DOOR_COLOR} metalness={0.34} roughness={0.34} />
</mesh>
<mesh castShadow position={[0, -height / 2 + railHeight / 2, 0]} receiveShadow>
<boxGeometry args={[leafWidth, railHeight, 0.05]} />
<meshStandardMaterial color={DOOR_COLOR} metalness={0.34} roughness={0.34} />
</mesh>
<mesh castShadow position={[-leafWidth / 2 + stileWidth / 2, 0, 0]} receiveShadow>
<boxGeometry args={[stileWidth, height, 0.05]} />
<meshStandardMaterial color={DOOR_COLOR} metalness={0.34} roughness={0.34} />
</mesh>
<mesh castShadow position={[leafWidth / 2 - stileWidth / 2, 0, 0]} receiveShadow>
<boxGeometry args={[stileWidth, height, 0.05]} />
<meshStandardMaterial color={DOOR_COLOR} metalness={0.34} roughness={0.34} />
</mesh>
<mesh position={[0, 0, -0.004]}>
<boxGeometry args={[glassWidth, glassHeight, 0.012]} />
<meshStandardMaterial
color={GLASS_COLOR}
depthWrite={false}
metalness={0}
opacity={0.2}
roughness={0.08}
transparent
/>
</mesh>
</group>
)
}
function LandingDoorFrame({
doorHeight,
doorWidth,
levelTopY,
levelY,
shaftWidth,
z,
}: {
doorHeight: number
doorWidth: number
levelTopY: number
levelY: number
shaftWidth: number
z: number
}) {
const wallDepth = 0.09
const levelHeight = Math.max(levelTopY - levelY, 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)
const trim = 0.055
return (
<>
<mesh castShadow position={[-jambCenterOffset, levelY + levelHeight / 2, z]} receiveShadow>
<boxGeometry args={[jambWidth, levelHeight, wallDepth]} />
<meshStandardMaterial color={SHAFT_WALL_COLOR} metalness={0.08} roughness={0.56} />
</mesh>
<mesh castShadow position={[jambCenterOffset, levelY + levelHeight / 2, z]} receiveShadow>
<boxGeometry args={[jambWidth, levelHeight, wallDepth]} />
<meshStandardMaterial color={SHAFT_WALL_COLOR} metalness={0.08} roughness={0.56} />
</mesh>
<mesh castShadow position={[0, levelY + doorHeight + headerHeight / 2, z]} receiveShadow>
<boxGeometry args={[shaftWidth, headerHeight, wallDepth]} />
<meshStandardMaterial color={SHAFT_WALL_COLOR} metalness={0.08} roughness={0.56} />
</mesh>
<mesh castShadow position={[0, levelY + trim / 2, z - 0.006]} receiveShadow>
<boxGeometry args={[doorWidth + trim * 2, trim, wallDepth * 1.12]} />
<meshStandardMaterial color={SHAFT_TRIM_COLOR} metalness={0.2} roughness={0.38} />
</mesh>
<mesh
castShadow
position={[-doorWidth / 2 - trim / 2, levelY + doorHeight / 2, z - 0.006]}
receiveShadow
>
<boxGeometry args={[trim, doorHeight, wallDepth * 1.12]} />
<meshStandardMaterial color={SHAFT_TRIM_COLOR} metalness={0.2} roughness={0.38} />
</mesh>
<mesh
castShadow
position={[doorWidth / 2 + trim / 2, levelY + doorHeight / 2, z - 0.006]}
receiveShadow
>
<boxGeometry args={[trim, doorHeight, wallDepth * 1.12]} />
<meshStandardMaterial color={SHAFT_TRIM_COLOR} metalness={0.2} roughness={0.38} />
</mesh>
<mesh castShadow position={[0, levelY + doorHeight + trim / 2, z - 0.006]} receiveShadow>
<boxGeometry args={[doorWidth + trim * 2, trim, wallDepth * 1.12]} />
<meshStandardMaterial color={SHAFT_TRIM_COLOR} metalness={0.2} roughness={0.38} />
</mesh>
</>
)
}
function LandingDoor({
animated,
elevatorId,
doorOpen,
doorHeight,
doorWidth,
levelId,
levelY,
z,
}: {
animated: boolean
elevatorId: AnyNodeId
doorOpen: number
doorHeight: number
doorWidth: number
levelId: AnyNodeId
levelY: number
z: number
}) {
return (
<>
<DoorLeaf
animated={animated ? { elevatorId, kind: 'landing', levelId } : undefined}
doorOpen={doorOpen}
height={doorHeight}
side="left"
width={doorWidth}
y={levelY}
z={z}
/>
<DoorLeaf
animated={animated ? { elevatorId, kind: 'landing', levelId } : undefined}
doorOpen={doorOpen}
height={doorHeight}
side="right"
width={doorWidth}
y={levelY}
z={z}
/>
</>
)
}
export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
const ref = useRef<Group>(null!)
const cabRef = useRef<Group>(null)
const nodes = useScene((state) => state.nodes)
const handlers = useNodeEvents(node, 'elevator')
const liveOverrides = useLiveNodeOverrides((state) => state.get(node.id))
const renderNode = useMemo(
() => (liveOverrides ? ({ ...node, ...liveOverrides } as ElevatorNode) : node),
[liveOverrides, node],
)
useRegistry(node.id, 'elevator', ref)
const { entries, defaultEntry, shaftBaseY, shaftTopY, totalHeight } = useMemo(
() => resolveElevatorLevels(renderNode, nodes),
[renderNode, nodes],
)
const elevatorId = node.id as AnyNodeId
const runtimeStatus = useInteractive(
useShallow((state) => {
const runtime = state.elevators[elevatorId]
if (!runtime) return null
return {
currentLevelId: runtime.currentLevelId,
phase: runtime.phase,
queue: runtime.queue,
targetLevelId: runtime.targetLevelId,
}
}),
)
useEffect(() => {
if (!defaultEntry) return
const elevatorId = node.id as AnyNodeId
const interactive = useInteractive.getState()
const current = interactive.elevators[elevatorId]
if (!current) {
interactive.initElevator(elevatorId, defaultEntry.id as AnyNodeId, defaultEntry.baseY)
} else if (!entries.some((entry) => entry.id === current.currentLevelId)) {
interactive.setElevatorState(elevatorId, {
carY: defaultEntry.baseY,
currentLevelId: defaultEntry.id as AnyNodeId,
doorOpen: 0,
phase: 'idle',
phaseStartedAt: null,
queue: [],
targetLevelId: null,
})
}
}, [defaultEntry, entries, node.id])
useEffect(() => {
return () => {
useInteractive.getState().removeElevator(elevatorId)
}
}, [elevatorId])
useFrame(() => {
if (!cabRef.current) return
const runtime = useInteractive.getState().elevators[elevatorId]
if (!runtime) return
cabRef.current.position.y = runtime.carY
}, 2.6)
const shaftWidth = Math.max(renderNode.width, 0.8)
const shaftDepth = Math.max(renderNode.depth, 0.8)
const cabHeight = Math.max(renderNode.cabHeight, 1.4)
const doorWidth = Math.min(Math.max(renderNode.doorWidth, 0.45), shaftWidth - 0.18)
const doorHeight = Math.min(Math.max(renderNode.doorHeight, 1.2), cabHeight - 0.1)
const shaftHeight = Math.max(totalHeight, cabHeight + 0.3)
const resolvedShaftTopY = Math.max(shaftTopY, shaftBaseY + shaftHeight)
const shaftWallThickness = 0.09
const runtimeSnapshot = useInteractive.getState().elevators[elevatorId]
const cabBaseY = runtimeSnapshot?.carY ?? defaultEntry?.baseY ?? 0
const activeLevelId =
runtimeStatus?.currentLevelId ?? runtimeSnapshot?.currentLevelId ?? defaultEntry?.id ?? null
const pendingLevelId =
runtimeStatus?.targetLevelId ??
runtimeSnapshot?.targetLevelId ??
runtimeStatus?.queue[0] ??
runtimeSnapshot?.queue[0] ??
null
const currentEntry =
entries.find((entry) => entry.id === activeLevelId) ?? defaultEntry ?? entries[0] ?? null
const pendingEntry = pendingLevelId ? entries.find((entry) => entry.id === pendingLevelId) : null
const indicatorEntry = pendingEntry ?? currentEntry
const indicatorDirection =
currentEntry && pendingEntry && Math.abs(pendingEntry.baseY - currentEntry.baseY) > 0.001
? pendingEntry.baseY > currentEntry.baseY
? 'up'
: 'down'
: null
const indicatorActive = Boolean(
pendingEntry ||
runtimeStatus?.phase === 'moving' ||
runtimeSnapshot?.phase === 'moving' ||
runtimeStatus?.phase === 'opening' ||
runtimeSnapshot?.phase === 'opening',
)
const queuedLevelIds = new Set<string>()
for (const levelId of runtimeStatus?.queue ?? runtimeSnapshot?.queue ?? [])
queuedLevelIds.add(levelId)
if (runtimeStatus?.targetLevelId ?? runtimeSnapshot?.targetLevelId) {
queuedLevelIds.add((runtimeStatus?.targetLevelId ?? runtimeSnapshot?.targetLevelId)!)
}
const doorOpen = runtimeSnapshot?.doorOpen ?? 0
const frontWallZ = -shaftDepth / 2 - shaftWallThickness / 2
const frontZ = frontWallZ - shaftWallThickness / 2 - 0.018
const landingPanelX = Math.min(shaftWidth / 2 - 0.16, doorWidth / 2 + 0.18)
const cabPanelX = shaftWidth / 2 - 0.075
const cabPanelZ = -shaftDepth / 2 + 0.36
const cabButtonColumns = entries.length > 1 ? 2 : 1
const cabButtonRows = Math.max(1, Math.ceil(entries.length / cabButtonColumns))
const cabButtonSpacingX = 0.14
const cabButtonSpacingY = 0.15
const cabPanelWidth = cabButtonColumns * cabButtonSpacingX + 0.13
const cabPanelHeight = cabButtonRows * cabButtonSpacingY + 0.12
const panelRelativeY = Math.min(Math.max(doorHeight * 0.6, 0.95), cabHeight - 0.35)
const cabPanelY = panelRelativeY
const entrySpans = entries.map((entry, index) => {
const nextEntry = entries[index + 1]
return {
entry,
levelTopY: Math.max(nextEntry?.baseY ?? resolvedShaftTopY, entry.baseY + doorHeight + 0.24),
}
})
const requestLevel = (levelId: AnyNodeId) => {
useInteractive.getState().requestElevator(elevatorId, levelId)
}
return (
<group
position={renderNode.position}
ref={ref}
rotation-y={renderNode.rotation}
visible={renderNode.visible}
{...handlers}
>
<mesh
castShadow
position={[0, shaftBaseY + shaftHeight / 2, shaftDepth / 2 + shaftWallThickness / 2]}
receiveShadow
>
<boxGeometry
args={[shaftWidth + shaftWallThickness * 2, shaftHeight, shaftWallThickness]}
/>
<meshStandardMaterial color={SHAFT_SIDE_COLOR} metalness={0.12} roughness={0.58} />
</mesh>
<mesh
castShadow
position={[-shaftWidth / 2 - shaftWallThickness / 2, shaftBaseY + shaftHeight / 2, 0]}
receiveShadow
>
<boxGeometry
args={[shaftWallThickness, shaftHeight, shaftDepth + shaftWallThickness * 2]}
/>
<meshStandardMaterial color={SHAFT_SIDE_COLOR} metalness={0.12} roughness={0.58} />
</mesh>
<mesh
castShadow
position={[shaftWidth / 2 + shaftWallThickness / 2, shaftBaseY + shaftHeight / 2, 0]}
receiveShadow
>
<boxGeometry
args={[shaftWallThickness, shaftHeight, shaftDepth + shaftWallThickness * 2]}
/>
<meshStandardMaterial color={SHAFT_SIDE_COLOR} metalness={0.12} roughness={0.58} />
</mesh>
<mesh
castShadow
position={[0, shaftBaseY + shaftHeight - shaftWallThickness / 2, 0]}
receiveShadow
>
<boxGeometry
args={[
shaftWidth + shaftWallThickness * 2,
shaftWallThickness,
shaftDepth + shaftWallThickness * 2,
]}
/>
<meshStandardMaterial color={SHAFT_SIDE_COLOR} metalness={0.12} roughness={0.58} />
</mesh>
<group ref={cabRef} position={[0, cabBaseY, 0]}>
<mesh castShadow position={[0, 0.04, 0]} receiveShadow>
<boxGeometry args={[shaftWidth, 0.08, shaftDepth]} />
<meshStandardMaterial color={CAB_COLOR} metalness={0.18} roughness={0.45} />
</mesh>
<mesh castShadow position={[0, cabHeight - 0.04, 0]} receiveShadow>
<boxGeometry args={[shaftWidth, 0.08, shaftDepth]} />
<meshStandardMaterial color={CAB_COLOR} metalness={0.18} roughness={0.45} />
</mesh>
<mesh castShadow position={[0, cabHeight / 2, shaftDepth / 2 - 0.04]} receiveShadow>
<boxGeometry args={[shaftWidth, cabHeight, 0.08]} />
<meshStandardMaterial color={CAB_COLOR} metalness={0.2} roughness={0.48} />
</mesh>
<mesh castShadow position={[-shaftWidth / 2 + 0.04, cabHeight / 2, 0]} receiveShadow>
<boxGeometry args={[0.08, cabHeight, shaftDepth]} />
<meshStandardMaterial color={CAB_COLOR} metalness={0.2} roughness={0.48} />
</mesh>
<mesh castShadow position={[shaftWidth / 2 - 0.04, cabHeight / 2, 0]} receiveShadow>
<boxGeometry args={[0.08, cabHeight, shaftDepth]} />
<meshStandardMaterial color={CAB_COLOR} metalness={0.2} roughness={0.48} />
</mesh>
<DoorLeaf
animated={{ elevatorId, kind: 'cab' }}
doorOpen={doorOpen}
height={doorHeight}
side="left"
width={doorWidth}
y={0}
z={frontZ}
/>
<DoorLeaf
animated={{ elevatorId, kind: 'cab' }}
doorOpen={doorOpen}
height={doorHeight}
side="right"
width={doorWidth}
y={0}
z={frontZ}
/>
<ElevatorFloorIndicator
active={indicatorActive}
direction={indicatorDirection}
faceSign={1}
label={indicatorEntry?.label ?? '-'}
position={[0, doorHeight + 0.13, frontZ + 0.055]}
scale={0.78}
/>
<group position={[cabPanelX, cabPanelY, cabPanelZ]} rotation-y={-Math.PI / 2}>
<mesh castShadow receiveShadow>
<boxGeometry args={[cabPanelWidth, cabPanelHeight, 0.045]} />
<meshStandardMaterial color={PANEL_COLOR} metalness={0.32} roughness={0.36} />
</mesh>
{entries.map((entry, index) => {
const column = index % cabButtonColumns
const row = Math.floor(index / cabButtonColumns)
const x = (column - (cabButtonColumns - 1) / 2) * cabButtonSpacingX
const y = ((cabButtonRows - 1) / 2 - row) * cabButtonSpacingY
return (
<ElevatorMeshButton
active={activeLevelId === entry.id}
buttonKind="cab"
elevatorId={elevatorId}
faceSign={1}
key={entry.id}
label={entry.label}
levelId={entry.id as AnyNodeId}
onRequest={() => requestLevel(entry.id as AnyNodeId)}
position={[x, y, 0.045]}
queued={queuedLevelIds.has(entry.id)}
/>
)
})}
</group>
</group>
{entrySpans.map(({ entry, levelTopY }) => (
<group key={entry.id}>
<LandingDoorFrame
doorHeight={doorHeight}
doorWidth={doorWidth}
levelTopY={levelTopY}
levelY={entry.baseY}
shaftWidth={shaftWidth}
z={frontWallZ}
/>
<LandingDoor
animated={activeLevelId === entry.id}
elevatorId={elevatorId}
doorHeight={doorHeight}
doorOpen={activeLevelId === entry.id ? doorOpen : 0}
doorWidth={doorWidth}
levelId={entry.id as AnyNodeId}
levelY={entry.baseY}
z={frontZ - 0.02}
/>
<ElevatorFloorIndicator
active={indicatorActive || activeLevelId === entry.id || queuedLevelIds.has(entry.id)}
direction={indicatorDirection}
label={indicatorEntry?.label ?? entry.label}
position={[0, entry.baseY + doorHeight + 0.16, frontZ - 0.055]}
scale={0.62}
/>
<group position={[landingPanelX, entry.baseY + panelRelativeY, frontZ - 0.035]}>
<mesh castShadow receiveShadow>
<boxGeometry args={[0.18, 0.42, 0.04]} />
<meshStandardMaterial color={PANEL_COLOR} metalness={0.25} roughness={0.4} />
</mesh>
<ElevatorMeshButton
active={activeLevelId === entry.id && doorOpen > 0.5}
buttonKind="landing"
elevatorId={elevatorId}
levelId={entry.id as AnyNodeId}
onRequest={() => requestLevel(entry.id as AnyNodeId)}
position={[0, 0.06, -0.045]}
queued={queuedLevelIds.has(entry.id)}
radius={0.045}
/>
<mesh position={[0, -0.12, -0.035]}>
<boxGeometry args={[0.095, 0.025, 0.012]} />
<meshStandardMaterial
color={queuedLevelIds.has(entry.id) ? '#fbbf24' : '#64748b'}
emissive={queuedLevelIds.has(entry.id) ? '#fbbf24' : '#000000'}
emissiveIntensity={queuedLevelIds.has(entry.id) ? 0.16 : 0}
metalness={0.18}
roughness={0.42}
/>
</mesh>
</group>
</group>
))}
</group>
)
}
@@ -5,6 +5,7 @@ import { BuildingRenderer } from './building/building-renderer'
import { CeilingRenderer } from './ceiling/ceiling-renderer'
import { ColumnRenderer } from './column/column-renderer'
import { DoorRenderer } from './door/door-renderer'
import { ElevatorRenderer } from './elevator/elevator-renderer'
import { FenceRenderer } from './fence/fence-renderer'
import { GuideRenderer } from './guide/guide-renderer'
import { ItemRenderer } from './item/item-renderer'
@@ -32,6 +33,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
{node.type === 'building' && <BuildingRenderer node={node} />}
{node.type === 'ceiling' && <CeilingRenderer node={node} />}
{node.type === 'column' && <ColumnRenderer node={node} />}
{node.type === 'elevator' && <ElevatorRenderer node={node} />}
{node.type === 'level' && <LevelRenderer node={node} />}
{node.type === 'item' && <ItemRenderer node={node} />}
{node.type === 'slab' && <SlabRenderer node={node} />}
@@ -1,8 +1,8 @@
import { type SiteNode, type SlabNode, useRegistry, useScene } from '@pascal-app/core'
import polygonClipping from 'polygon-clipping'
import { useMemo, useRef } from 'react'
import { BufferGeometry, Float32BufferAttribute, type Group, Path, Shape } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { unionPolygons } from '../../../lib/polygon-union'
import useViewer from '../../../store/use-viewer'
import { NodeRenderer } from '../node-renderer'
@@ -89,22 +89,12 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
shape.closePath()
if (slabPolygons.length > 0) {
const multiPolygons = slabPolygons.map((p) => [
p.map((pt) => [pt[0], -pt[1]] as [number, number]),
])
const unioned = polygonClipping.union(
multiPolygons[0] as polygonClipping.Polygon,
...(multiPolygons.slice(1) as polygonClipping.Polygon[]),
)
for (const geom of unioned) {
const ring = geom[0]
if (ring && ring.length > 0) {
const hole = new Path()
hole.moveTo(ring[0]![0], ring[0]![1])
for (let i = 1; i < ring.length; i++) hole.lineTo(ring[i]![0], ring[i]![1])
hole.closePath()
shape.holes.push(hole)
}
for (const ring of unionPolygons(slabPolygons.map((p) => p.map((pt) => [pt[0], -pt[1]])))) {
const hole = new Path()
hole.moveTo(ring[0]![0], ring[0]![1])
for (let i = 1; i < ring.length; i++) hole.lineTo(ring[i]![0], ring[i]![1])
hole.closePath()
shape.holes.push(hole)
}
}
@@ -1,7 +1,7 @@
import { type LevelNode, useScene } from '@pascal-app/core'
import polygonClipping from 'polygon-clipping'
import { useMemo } from 'react'
import * as THREE from 'three'
import { unionPolygons } from '../../lib/polygon-union'
import useViewer from '../../store/use-viewer'
export const GroundOccluder = () => {
@@ -64,31 +64,15 @@ export const GroundOccluder = () => {
})
if (polygons.length > 0) {
// Format for polygon-clipping: [[[x, y], [x, y], ...]]
const multiPolygons = polygons.map((pts) => {
const ring = pts.map((p) => [p[0], -p[1]] as [number, number]) // Negate Y (which was Z)
return [ring]
})
for (const ring of unionPolygons(polygons.map((pts) => pts.map((p) => [p[0], -p[1]])))) {
const hole = new THREE.Path()
// Union all polygons together to prevent artifacts from overlapping
const unionedPolygons = polygonClipping.union(multiPolygons[0]!, ...multiPolygons.slice(1))
// Add each resulting unioned polygon as a hole
for (const geom of unionedPolygons) {
// First ring in each geometry is the exterior ring
if (geom.length > 0) {
const ring = geom[0]!
const hole = new THREE.Path()
if (ring.length > 0) {
hole.moveTo(ring[0]![0], ring[0]![1])
for (let i = 1; i < ring.length; i++) {
hole.lineTo(ring[i]![0], ring[i]![1])
}
hole.closePath()
s.holes.push(hole)
}
hole.moveTo(ring[0]![0], ring[0]![1])
for (let i = 1; i < ring.length; i++) {
hole.lineTo(ring[i]![0], ring[i]![1])
}
hole.closePath()
s.holes.push(hole)
}
}
@@ -8,6 +8,8 @@ import useViewer from '../../store/use-viewer'
import { CeilingSystem } from '../../systems/ceiling/ceiling-system'
import { DoorAnimationSystem } from '../../systems/door/door-animation-system'
import { DoorSystem } from '../../systems/door/door-system'
import { ElevatorAnimationSystem } from '../../systems/elevator/elevator-animation-system'
import { ElevatorOpeningSystem } from '../../systems/elevator/elevator-opening-system'
import { FenceSystem } from '../../systems/fence/fence-system'
import { GuideSystem } from '../../systems/guide/guide-system'
import { ItemSystem } from '../../systems/item/item-system'
@@ -94,8 +96,6 @@ type WebGPUDeviceLossInfo = {
type WebGPUDeviceLike = {
lost: Promise<WebGPUDeviceLossInfo>
label?: string
features?: Set<string>
addEventListener?: (type: string, listener: EventListener) => void
removeEventListener?: (type: string, listener: EventListener) => void
}
@@ -108,18 +108,9 @@ function GPUDeviceWatcher() {
const device = backend?.device as WebGPUDeviceLike | undefined
if (!device) {
console.warn('[viewer] No WebGPU device on backend — running on a fallback renderer.', {
backend: backend?.constructor?.name ?? 'unknown',
rendererType: (gl as any).constructor?.name ?? 'unknown',
})
return
}
console.log('[viewer] WebGPU device ready', {
label: device.label,
features: device.features ? Array.from(device.features) : [],
})
device.lost.then((info: WebGPUDeviceLossInfo) => {
console.error(
`[viewer] WebGPU device lost: reason="${info.reason ?? 'unknown'}", message="${info.message ?? ''}". ` +
@@ -167,24 +158,12 @@ const Viewer: React.FC<ViewerProps> = ({
const canvas = props.canvas
const cached = canvas ? WEBGPU_RENDERER_CACHE.get(canvas) : undefined
if (cached) return cached
// Surface the env we're about to ask WebGPU for — catches "no
// navigator.gpu" / "adapter request failed" silently failing in
// mobile WebViews where WebGPU is gated behind flags.
const hasGpu = typeof navigator !== 'undefined' && 'gpu' in navigator
console.log('[viewer] Creating WebGPURenderer', {
hasNavigatorGPU: hasGpu,
ua: typeof navigator !== 'undefined' ? navigator.userAgent : 'n/a',
})
const promise = (async () => {
try {
const renderer = new THREE.WebGPURenderer(props as any)
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 0.9
await renderer.init()
console.log('[viewer] WebGPURenderer ready', {
backend: (renderer as any).backend?.constructor?.name,
isWebGPU: (renderer as any).isWebGPURenderer === true,
})
return renderer
} catch (err) {
// Drop the failed promise from the cache so a future Canvas
@@ -228,6 +207,8 @@ const Viewer: React.FC<ViewerProps> = ({
{/* Core systems */}
<CeilingSystem />
<DoorAnimationSystem />
<ElevatorAnimationSystem />
<ElevatorOpeningSystem />
<WindowAnimationSystem />
<DoorSystem />
<FenceSystem />
@@ -174,22 +174,9 @@ const PostProcessingPasses = ({
void pipelineVersion
if (!(renderer && scene && camera)) {
console.warn('[viewer/post-processing] Skipping pipeline build — missing dependency.', {
hasRenderer: !!renderer,
hasScene: !!scene,
hasCamera: !!camera,
})
return
}
console.log('[viewer/post-processing] Building pipeline', {
version: pipelineVersion,
ssgi: SSGI_PARAMS.enabled,
hoverHighlightMode,
projectId,
rendererCtor: (renderer as any).constructor?.name,
})
hasPipelineErrorRef.current = false
// WebGPU availability check: SSGI, denoise, and RenderPipeline are all
@@ -202,9 +189,6 @@ const PostProcessingPasses = ({
// exclusively and never attempts the TSL pipeline.
const hasWebGPU = typeof navigator !== 'undefined' && 'gpu' in navigator
if (!hasWebGPU) {
console.warn(
'[viewer] WebGPU unavailable — rendering without post-processing (SSGI, outlines, denoise).',
)
hasPipelineErrorRef.current = true
renderPipelineRef.current = null
return
@@ -331,7 +315,6 @@ const PostProcessingPasses = ({
renderPipeline.outputNode = finalOutput
renderPipelineRef.current = renderPipeline
retryCountRef.current = 0
console.log('[viewer/post-processing] Pipeline built OK', { version: pipelineVersion })
} catch (error) {
hasPipelineErrorRef.current = true
console.error(
@@ -410,9 +393,6 @@ const PostProcessingPasses = ({
if (retryCountRef.current < MAX_PIPELINE_RETRIES) {
// Auto-retry: schedule a pipeline rebuild if we haven't exceeded the retry limit
retryCountRef.current++
console.warn(
`[viewer/post-processing] Scheduling pipeline rebuild (attempt ${retryCountRef.current}/${MAX_PIPELINE_RETRIES})`,
)
if (rebuildTimeoutRef.current !== null) {
clearTimeout(rebuildTimeoutRef.current)
}
@@ -7,6 +7,8 @@ import {
type ColumnNode,
type DoorEvent,
type DoorNode,
type ElevatorEvent,
type ElevatorNode,
type EventSuffix,
emitter,
type FenceEvent,
@@ -57,6 +59,7 @@ type NodeConfig = {
'stair-segment': { node: StairSegmentNode; event: StairSegmentEvent }
window: { node: WindowNode; event: WindowEvent }
door: { node: DoorNode; event: DoorEvent }
elevator: { node: ElevatorNode; event: ElevatorEvent }
}
type NodeType = keyof NodeConfig
@@ -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])
})
})
+294
View File
@@ -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 { useFrame } from '@react-three/fiber'
import { type AnyNodeId, type CeilingNode, sceneRegistry, useScene } from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import { mergeSurfaceHolePolygons } from '../surface-hole-geometry'
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
const uv = geometry.getAttribute('uv')
@@ -82,7 +83,7 @@ export function generateCeilingGeometry(ceilingNode: CeilingNode): THREE.BufferG
shape.closePath()
// Add holes to the shape
const holes = ceilingNode.holes || []
const holes = mergeSurfaceHolePolygons(ceilingNode.holes || [])
for (const holePolygon of holes) {
if (holePolygon.length < 3) continue
@@ -0,0 +1,155 @@
import {
type AnyNodeId,
type ElevatorNode,
sceneRegistry,
useInteractive,
useScene,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import { resolveElevatorLevels } from './elevator-utils'
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 ElevatorAnimationSystem() {
useFrame(({ clock }, delta) => {
const interactive = useInteractive.getState()
const nodes = useScene.getState().nodes
const now = clock.getElapsedTime() * 1000
for (const elevatorId of sceneRegistry.byType.elevator) {
const typedElevatorId = elevatorId as AnyNodeId
const node = nodes[typedElevatorId]
if (node?.type !== 'elevator') {
interactive.removeElevator(typedElevatorId)
continue
}
const elevator = node as ElevatorNode
const { entries, defaultEntry } = resolveElevatorLevels(elevator, nodes)
if (!defaultEntry) continue
const state = interactive.elevators[typedElevatorId]
if (!state) {
interactive.initElevator(typedElevatorId, defaultEntry.id as AnyNodeId, defaultEntry.baseY)
continue
}
const currentEntry =
entries.find((entry) => entry.id === state.currentLevelId) ?? defaultEntry
if (currentEntry.id !== state.currentLevelId) {
interactive.setElevatorState(typedElevatorId, {
currentLevelId: currentEntry.id as AnyNodeId,
carY: currentEntry.baseY,
targetLevelId: null,
phase: 'idle',
phaseStartedAt: null,
queue: [],
doorOpen: 0,
})
continue
}
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) {
interactive.setElevatorState(typedElevatorId, {
doorOpen: Math.max(0, state.doorOpen - doorStep),
})
}
break
}
interactive.setElevatorState(typedElevatorId, {
targetLevelId: nextLevelId,
phase:
state.doorOpen > EPSILON
? 'closing'
: nextLevelId === state.currentLevelId
? 'opening'
: 'moving',
phaseStartedAt: now,
})
break
}
case 'closing': {
const doorOpen = Math.max(0, state.doorOpen - doorStep)
interactive.setElevatorState(typedElevatorId, {
doorOpen,
phase: doorOpen <= EPSILON ? (state.targetLevelId ? 'moving' : 'idle') : 'closing',
phaseStartedAt: doorOpen <= EPSILON ? now : state.phaseStartedAt,
})
break
}
case 'moving': {
if (!targetEntry) {
interactive.setElevatorState(typedElevatorId, {
targetLevelId: null,
phase: 'idle',
queue: [],
})
break
}
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
interactive.setElevatorState(typedElevatorId, {
carY: nextY,
currentLevelId: arrived ? (targetEntry.id as AnyNodeId) : state.currentLevelId,
phase: arrived ? 'opening' : 'moving',
phaseStartedAt: arrived ? now : state.phaseStartedAt,
})
break
}
case 'opening': {
const doorOpen = Math.min(1, state.doorOpen + doorStep)
interactive.setElevatorState(typedElevatorId, {
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,
})
break
}
case 'open': {
const elapsed = now - (state.phaseStartedAt ?? now)
if (elapsed < Math.max(elevator.dwellMs ?? 1400, 0)) break
interactive.setElevatorState(typedElevatorId, {
phase: 'closing',
phaseStartedAt: now,
targetLevelId: state.queue[0] ?? null,
})
break
}
}
}
}, 2)
return null
}
@@ -0,0 +1,54 @@
import { type AnyNode, syncAutoElevatorOpenings, useScene } from '@pascal-app/core'
import { useEffect, useRef } from 'react'
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,68 @@
import {
resolveElevatorBuildingLevels,
resolveElevatorServiceLevels,
type AnyNode,
type AnyNodeId,
type ElevatorNode,
type LevelNode,
} from '@pascal-app/core'
import { getLevelHeight } from '../level/level-utils'
export type ElevatorLevelEntry = {
id: LevelNode['id']
label: string
baseY: number
}
export function resolveElevatorLevels(
elevator: ElevatorNode,
nodes: Record<AnyNodeId, 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 += getLevelHeight(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),
}
}
@@ -8,6 +8,7 @@ import {
import { useFrame } from '@react-three/fiber'
import { useEffect } from 'react'
import * as THREE from 'three'
import { mergeSurfaceHolePolygons } from '../surface-hole-geometry'
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
const uv = geometry.getAttribute('uv')
@@ -86,6 +87,7 @@ export function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
const polygon = getRenderableSlabPolygon(slabNode)
const elevation = slabNode.elevation ?? 0.05
const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? [])
if (polygon.length < 3) return new THREE.BufferGeometry()
@@ -94,7 +96,7 @@ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry
for (let i = 1; i < polygon.length; i++) shape.lineTo(polygon[i]![0], -polygon[i]![1])
shape.closePath()
for (const holePolygon of slabNode.holes ?? []) {
for (const holePolygon of holePolygons) {
if (holePolygon.length < 3) continue
const holePath = new THREE.Path()
holePath.moveTo(holePolygon[0]![0], -holePolygon[0]![1])
@@ -122,6 +124,7 @@ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry
function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
const polygon = getRenderableSlabPolygon(slabNode)
const depth = Math.abs(slabNode.elevation ?? 0.05)
const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? [])
if (polygon.length < 3) return new THREE.BufferGeometry()
@@ -134,7 +137,7 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
for (const [x, z] of polygon) {
bounds.expandByPoint(new THREE.Vector2(x, z))
}
for (const hole of slabNode.holes ?? []) {
for (const hole of holePolygons) {
for (const [x, z] of hole) {
bounds.expandByPoint(new THREE.Vector2(x, z))
}
@@ -157,8 +160,8 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
for (const [x, z] of polygon) pushFloorVertex(x!, 0, z!)
const pts2d = polygon.map(([x, z]) => new THREE.Vector2(x!, z!))
const holesPts2d = (slabNode.holes ?? []).map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
for (const hole of slabNode.holes ?? []) {
const holesPts2d = holePolygons.map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
for (const hole of holePolygons) {
for (const [x, z] of hole) pushFloorVertex(x!, 0, z!)
}
@@ -0,0 +1,5 @@
import { type Point2D, unionPolygons } from '../lib/polygon-union'
export function mergeSurfaceHolePolygons(holes: Point2D[][]): Point2D[][] {
return unionPolygons(holes)
}