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) ||