fix(core): exclude dangling walls from room polygons (#499)
This commit is contained in:
@@ -191,6 +191,19 @@ describe('detectSpacesForLevel', () => {
|
|||||||
).toEqual(walls.map((wall) => [wall.id, 'front']).sort())
|
).toEqual(walls.map((wall) => [wall.id, 'front']).sort())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('excludes dangling wall branches from a room boundary', () => {
|
||||||
|
const roomWalls = squareWalls()
|
||||||
|
const branch = WallNode.parse({ start: [0, 0], end: [1, 1] })
|
||||||
|
|
||||||
|
const { roomPolygons, spaces } = detectSpacesForLevel('level-1', [...roomWalls, branch])
|
||||||
|
|
||||||
|
expect(roomPolygons).toHaveLength(1)
|
||||||
|
expect(roomPolygons[0]).toHaveLength(4)
|
||||||
|
expect(areaOf(roomPolygons[0]!)).toBeCloseTo(12)
|
||||||
|
expect(spaces[0]?.wallIds.sort()).toEqual(roomWalls.map((wall) => wall.id).sort())
|
||||||
|
expect(spaces[0]?.boundaryFaces).toHaveLength(4)
|
||||||
|
})
|
||||||
|
|
||||||
test('detects a room closed against the middle of an existing wall (T-junction)', () => {
|
test('detects a room closed against the middle of an existing wall (T-junction)', () => {
|
||||||
// Big 6×5 room; a smaller room hangs below, its two verticals landing on the
|
// Big 6×5 room; a smaller room hangs below, its two verticals landing on the
|
||||||
// interior of the big room's bottom wall (x=1 and x=3, not endpoints). Before
|
// interior of the big room's bottom wall (x=1 and x=3, not endpoints). Before
|
||||||
|
|||||||
@@ -589,10 +589,45 @@ function extractRooms(walls: WallNode[]): ExtractedRoom[] {
|
|||||||
return outgoing[nextIdx] ?? null
|
return outgoing[nextIdx] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const splitIntoSimpleCycles = (walkEdgeIds: string[]) => {
|
||||||
|
const cycles: string[][] = []
|
||||||
|
const firstEdge = halfEdges.get(walkEdgeIds[0] ?? '')
|
||||||
|
if (!firstEdge) return cycles
|
||||||
|
|
||||||
|
const pathEdges: string[] = []
|
||||||
|
const pathVertices = [firstEdge.fromKey]
|
||||||
|
const vertexIndex = new Map([[firstEdge.fromKey, 0]])
|
||||||
|
|
||||||
|
for (const edgeId of walkEdgeIds) {
|
||||||
|
const edge = halfEdges.get(edgeId)
|
||||||
|
if (!edge || edge.fromKey !== pathVertices[pathVertices.length - 1]) return []
|
||||||
|
|
||||||
|
pathEdges.push(edgeId)
|
||||||
|
const repeatedIndex = vertexIndex.get(edge.toKey)
|
||||||
|
if (repeatedIndex === undefined) {
|
||||||
|
pathVertices.push(edge.toKey)
|
||||||
|
vertexIndex.set(edge.toKey, pathVertices.length - 1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycle = pathEdges.slice(repeatedIndex)
|
||||||
|
if (cycle.length >= 3) cycles.push(cycle)
|
||||||
|
|
||||||
|
for (let index = repeatedIndex + 1; index < pathVertices.length; index += 1) {
|
||||||
|
vertexIndex.delete(pathVertices[index]!)
|
||||||
|
}
|
||||||
|
pathVertices.length = repeatedIndex + 1
|
||||||
|
pathEdges.length = repeatedIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
return pathEdges.length === 0 && pathVertices.length === 1 ? cycles : []
|
||||||
|
}
|
||||||
|
|
||||||
const visitedDirected = new Set<string>()
|
const visitedDirected = new Set<string>()
|
||||||
const rooms: ExtractedRoom[] = []
|
const rooms: ExtractedRoom[] = []
|
||||||
// A single face cannot revisit a half-edge, so the half-edge count bounds the
|
// A face walk cannot revisit a half-edge, so the half-edge count bounds its
|
||||||
// longest possible cycle. Splitting at junctions can multiply edges per wall.
|
// length. It can revisit a vertex when dangling walls or other graph bridges
|
||||||
|
// are traced out and back; those excursions are removed below.
|
||||||
const maxSteps = Math.min(2000, halfEdges.size + 10)
|
const maxSteps = Math.min(2000, halfEdges.size + 10)
|
||||||
|
|
||||||
for (const edgeId of halfEdges.keys()) {
|
for (const edgeId of halfEdges.keys()) {
|
||||||
@@ -601,6 +636,7 @@ function extractRooms(walls: WallNode[]): ExtractedRoom[] {
|
|||||||
const cycleEdgeIds: string[] = []
|
const cycleEdgeIds: string[] = []
|
||||||
let currentEdgeId = edgeId
|
let currentEdgeId = edgeId
|
||||||
let valid = true
|
let valid = true
|
||||||
|
let closed = false
|
||||||
|
|
||||||
for (let step = 0; step < maxSteps; step += 1) {
|
for (let step = 0; step < maxSteps; step += 1) {
|
||||||
const currentEdge = halfEdges.get(currentEdgeId)
|
const currentEdge = halfEdges.get(currentEdgeId)
|
||||||
@@ -619,41 +655,46 @@ function extractRooms(walls: WallNode[]): ExtractedRoom[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentEdgeId = next
|
currentEdgeId = next
|
||||||
if (currentEdgeId === edgeId) break
|
if (currentEdgeId === edgeId) {
|
||||||
|
closed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!valid || cycleEdgeIds.length < 3) continue
|
if (!(valid && closed) || cycleEdgeIds.length < 3) continue
|
||||||
|
|
||||||
const polygon = dedupeSequentialPoints(
|
for (const simpleCycleEdgeIds of splitIntoSimpleCycles(cycleEdgeIds)) {
|
||||||
cycleEdgeIds.flatMap((id, index) => {
|
const polygon = dedupeSequentialPoints(
|
||||||
const points = halfEdges.get(id)?.points ?? []
|
simpleCycleEdgeIds.flatMap((id, index) => {
|
||||||
return index === cycleEdgeIds.length - 1 ? points : points.slice(0, -1)
|
const points = halfEdges.get(id)?.points ?? []
|
||||||
}),
|
return index === simpleCycleEdgeIds.length - 1 ? points : points.slice(0, -1)
|
||||||
)
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
if (polygon.length < 3) continue
|
if (polygon.length < 3) continue
|
||||||
|
|
||||||
const signedArea = polygonArea(polygon)
|
const signedArea = polygonArea(polygon)
|
||||||
if (signedArea <= 0) continue
|
if (signedArea <= 0) continue
|
||||||
if (signedArea < 0.5 || signedArea > 10_000) continue
|
if (signedArea < 0.5 || signedArea > 10_000) continue
|
||||||
|
|
||||||
const signature = polygonSignature(polygon)
|
const signature = polygonSignature(polygon)
|
||||||
if (rooms.some((room) => polygonSignature(room.polygon) === signature)) continue
|
if (rooms.some((room) => polygonSignature(room.polygon) === signature)) continue
|
||||||
|
|
||||||
rooms.push({
|
rooms.push({
|
||||||
polygon,
|
polygon,
|
||||||
boundaryFaces: cycleEdgeIds.flatMap((id) => {
|
boundaryFaces: simpleCycleEdgeIds.flatMap((id) => {
|
||||||
const edge = halfEdges.get(id)
|
const edge = halfEdges.get(id)
|
||||||
if (!edge) return []
|
if (!edge) return []
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
wallId: edge.wallId,
|
wallId: edge.wallId,
|
||||||
face: edge.face,
|
face: edge.face,
|
||||||
points: edge.points.map(pointToTuple),
|
points: edge.points.map(pointToTuple),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rooms.sort((a, b) => Math.abs(polygonArea(b.polygon)) - Math.abs(polygonArea(a.polygon)))
|
rooms.sort((a, b) => Math.abs(polygonArea(b.polygon)) - Math.abs(polygonArea(a.polygon)))
|
||||||
|
|||||||
Reference in New Issue
Block a user