fix(core): exclude dangling walls from room polygons (#499)

This commit is contained in:
Wassim SAMAD
2026-07-15 17:53:51 -04:00
committed by GitHub
parent a524da1574
commit 78223d61cf
2 changed files with 84 additions and 30 deletions
@@ -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
+48 -7
View File
@@ -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,15 +655,19 @@ 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
for (const simpleCycleEdgeIds of splitIntoSimpleCycles(cycleEdgeIds)) {
const polygon = dedupeSequentialPoints( const polygon = dedupeSequentialPoints(
cycleEdgeIds.flatMap((id, index) => { simpleCycleEdgeIds.flatMap((id, index) => {
const points = halfEdges.get(id)?.points ?? [] const points = halfEdges.get(id)?.points ?? []
return index === cycleEdgeIds.length - 1 ? points : points.slice(0, -1) return index === simpleCycleEdgeIds.length - 1 ? points : points.slice(0, -1)
}), }),
) )
@@ -642,7 +682,7 @@ function extractRooms(walls: WallNode[]): ExtractedRoom[] {
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 [
@@ -655,6 +695,7 @@ function extractRooms(walls: WallNode[]): ExtractedRoom[] {
}), }),
}) })
} }
}
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)))
return rooms return rooms