Optimize elevator editing performance with live previews
This commit is contained in:
@@ -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>(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
`,
|
||||
)
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
Reference in New Issue
Block a user