Optimize elevator editing performance with live previews
This commit is contained in:
@@ -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