fix: harden measurement geometry (#507)

This commit is contained in:
Aymeric Rabot
2026-07-18 09:44:34 +02:00
committed by GitHub
parent 99cc537e25
commit cddb73b232
9 changed files with 592 additions and 117 deletions
+194 -3
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from 'bun:test'
import { type AnyNode, CeilingNode, SlabNode, WallNode, ZoneNode } from '../schema'
import { detectSpacesForLevel } from './space-detection'
import { deriveZoneQuantityReport } from './zone-quantities'
const polygon: Array<[number, number]> = [
@@ -44,12 +45,12 @@ describe('deriveZoneQuantityReport', () => {
expect(report.wallSurface).toEqual({
status: 'available',
value: 35,
note: 'Gross interior wall face before openings.',
note: "Gross interior wall face using each boundary wall's height.",
})
expect(report.floorSurface).toEqual({
status: 'available',
value: 12,
note: 'Matching slab surface after openings.',
note: 'Zone floor surface covered by one slab, after openings.',
})
expect(report.volume.status).toBe('available')
if (report.volume.status === 'available') expect(report.volume.value).toBeCloseTo(29.4)
@@ -89,7 +90,197 @@ describe('deriveZoneQuantityReport', () => {
expect(report.floorSurface).toEqual({
status: 'available',
value: 11,
note: 'Matching slab surface after openings.',
note: 'Zone floor surface covered by one slab, after openings.',
})
})
test('derives a concave room from covering surfaces and unequal boundary-wall heights', () => {
const lShape: Array<[number, number]> = [
[0, 0],
[6, 0],
[6, 5.5],
[8, 5.5],
[8, 7.5],
[0, 7.5],
]
const coveringPolygon: Array<[number, number]> = [
[-1, -1],
[9, -1],
[9, 8.5],
[-1, 8.5],
]
const heights = [2, 2.2, 2.4, 2.6, 2.8, 3]
const zone = ZoneNode.parse({
id: 'zone_l_shape',
name: 'L-shaped room',
parentId: 'level_main',
polygon: lShape,
})
const slab = SlabNode.parse({
id: 'slab_level',
parentId: 'level_main',
polygon: coveringPolygon,
})
const ceiling = CeilingNode.parse({
id: 'ceiling_level',
parentId: 'level_main',
polygon: coveringPolygon,
height: 3.05,
})
const walls = lShape.map((start, index) =>
WallNode.parse({
id: `wall_l_${index}`,
parentId: 'level_main',
start,
end: lShape[(index + 1) % lShape.length],
height: heights[index],
}),
)
const report = deriveZoneQuantityReport(
zone,
sceneRecord([zone, slab, ceiling, ...walls] as AnyNode[]),
)
expect(report.classification).toBe('enclosed-room')
expect(report.footprintArea).toBeCloseTo(49)
expect(report.perimeter).toBeCloseTo(31)
expect(report.boundaryWallIds).toHaveLength(6)
expect(report.wallSurface).toEqual({
status: 'available',
value: 79,
note: "Gross interior wall face using each boundary wall's height.",
})
expect(report.floorSurface).toEqual({
status: 'available',
value: 49,
note: 'Zone floor surface covered by one slab, after openings.',
})
expect(report.volume).toEqual({
status: 'available',
value: 147,
note: 'Covered zone floor area multiplied by clear ceiling height.',
})
})
test('subtracts only covering-slab openings that lie inside a concave zone', () => {
const lShape: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 2],
[2, 2],
[2, 4],
[0, 4],
]
const zone = ZoneNode.parse({
id: 'zone_l_hole',
name: 'L-shaped room',
parentId: 'level_main',
polygon: lShape,
})
const slab = SlabNode.parse({
id: 'slab_covering',
parentId: 'level_main',
polygon: [
[-1, -1],
[5, -1],
[5, 5],
[-1, 5],
],
holes: [
[
[0.5, 0.5],
[1.5, 0.5],
[1.5, 1.5],
[0.5, 1.5],
],
[
[3, 3],
[4, 3],
[4, 4],
[3, 4],
],
],
})
const report = deriveZoneQuantityReport(zone, sceneRecord([zone, slab]))
expect(report.footprintArea).toBeCloseTo(12)
expect(report.floorSurface).toEqual({
status: 'available',
value: 11,
note: 'Zone floor surface covered by one slab, after openings.',
})
})
test('uses the closed zone boundary when small wall-end seams prevent loop detection', () => {
const zone = ZoneNode.parse({
id: 'zone_seamed',
name: 'Room with modeling seams',
parentId: 'level_main',
polygon,
})
const walls = polygon.map((start, index) => {
const end = polygon[(index + 1) % polygon.length]!
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const length = Math.hypot(dx, dz)
const insetX = (dx / length) * 0.02
const insetZ = (dz / length) * 0.02
return WallNode.parse({
id: `wall_seamed_${index}`,
parentId: 'level_main',
start: [start[0] + insetX, start[1] + insetZ],
end: [end[0] - insetX, end[1] - insetZ],
height: 2.5,
})
})
const report = deriveZoneQuantityReport(zone, sceneRecord([zone, ...walls] as AnyNode[]))
expect(report.classification).toBe('enclosed-room')
expect(report.boundaryWallIds).toHaveLength(4)
expect(report.wallSurface.status).toBe('available')
if (report.wallSurface.status === 'available') expect(report.wallSurface.value).toBeCloseTo(35)
})
test('uses only the detected boundary-face span of a wall split by T-junctions', () => {
const wallInputs = [
{ start: [0, 0], end: [6, 0], height: 2 },
{ start: [6, 0], end: [6, 5], height: 2.1 },
{ start: [6, 5], end: [0, 5], height: 2.2 },
{ start: [0, 5], end: [0, 0], height: 2.3 },
{ start: [1, 0], end: [1, -2], height: 2.4 },
{ start: [1, -2], end: [3, -2], height: 2.6 },
{ start: [3, -2], end: [3, 0], height: 2.8 },
] as const
const walls = wallInputs.map((input, index) =>
WallNode.parse({
id: `wall_t_${index}`,
parentId: 'level_main',
...input,
}),
)
const smallSpace = detectSpacesForLevel('level_main', walls).spaces.find(
(space) => Math.max(...space.polygon.map((point) => point[1])) <= 0,
)
expect(smallSpace).toBeDefined()
const zone = ZoneNode.parse({
id: 'zone_t_junction',
name: 'T-junction room',
parentId: 'level_main',
polygon: smallSpace!.polygon,
})
const report = deriveZoneQuantityReport(zone, sceneRecord([zone, ...walls] as AnyNode[]))
expect(report.classification).toBe('enclosed-room')
expect(new Set(report.boundaryWallIds)).toEqual(
new Set(['wall_t_0', 'wall_t_4', 'wall_t_5', 'wall_t_6']),
)
expect(report.wallSurface.status).toBe('available')
if (report.wallSurface.status === 'available') {
expect(report.wallSurface.value).toBeCloseTo(19.6)
}
})
})
+230 -86
View File
@@ -1,7 +1,7 @@
import type { AnyNode, CeilingNode, SlabNode, WallNode, ZoneNode } from '../schema'
import { sampleWallCenterline } from '../systems/wall/wall-curve'
import { DEFAULT_WALL_HEIGHT } from '../systems/wall/wall-footprint'
import { detectSpacesForLevel } from './space-detection'
import { detectSpacesForLevel, type Space } from './space-detection'
type Point2D = readonly [number, number]
@@ -65,17 +65,6 @@ function polygonArea(polygon: readonly Point2D[]): number {
return Math.abs(signedPolygonArea(polygon))
}
function polygonPerimeter(polygon: readonly Point2D[]): number {
let perimeter = 0
for (let index = 0; index < polygon.length; index += 1) {
const start = polygon[index]
const end = polygon[(index + 1) % polygon.length]
if (!(start && end)) continue
perimeter += pointDistance(start, end)
}
return perimeter
}
function polygonsDescribeSameRegion(a: readonly Point2D[], b: readonly Point2D[]): boolean {
if (a.length < 3 || b.length < 3) return false
@@ -90,11 +79,87 @@ function polygonsDescribeSameRegion(a: readonly Point2D[], b: readonly Point2D[]
)
}
function polygonSurfaceArea(
polygon: readonly Point2D[],
holes: readonly (readonly Point2D[])[] = [],
): number {
return Math.max(0, polygonArea(polygon) - holes.reduce((sum, hole) => sum + polygonArea(hole), 0))
function pointInPolygon(point: Point2D, polygon: readonly Point2D[], includeBoundary = true) {
if (polygon.length < 3) return false
if (pointToPolygonBoundaryDistance(point, polygon) <= 1e-6) return includeBoundary
let inside = false
for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index++) {
const currentPoint = polygon[index]!
const previousPoint = polygon[previous]!
if (
currentPoint[1] > point[1] !== previousPoint[1] > point[1] &&
point[0] <
((previousPoint[0] - currentPoint[0]) * (point[1] - currentPoint[1])) /
(previousPoint[1] - currentPoint[1]) +
currentPoint[0]
) {
inside = !inside
}
}
return inside
}
function segmentsCrossProperly(a: Point2D, b: Point2D, c: Point2D, d: Point2D) {
const cross = (start: Point2D, end: Point2D, point: Point2D) =>
(end[0] - start[0]) * (point[1] - start[1]) - (end[1] - start[1]) * (point[0] - start[0])
const abC = cross(a, b, c)
const abD = cross(a, b, d)
const cdA = cross(c, d, a)
const cdB = cross(c, d, b)
return abC * abD < -1e-12 && cdA * cdB < -1e-12
}
function polygonContainsRegion(outer: readonly Point2D[], inner: readonly Point2D[]): boolean {
if (outer.length < 3 || inner.length < 3) return false
if (
!inner.every(
(point) =>
pointInPolygon(point, outer) ||
pointToPolygonBoundaryDistance(point, outer) <= BOUNDARY_TOLERANCE,
)
) {
return false
}
for (let innerIndex = 0; innerIndex < inner.length; innerIndex += 1) {
const innerStart = inner[innerIndex]!
const innerEnd = inner[(innerIndex + 1) % inner.length]!
for (let outerIndex = 0; outerIndex < outer.length; outerIndex += 1) {
if (
segmentsCrossProperly(
innerStart,
innerEnd,
outer[outerIndex]!,
outer[(outerIndex + 1) % outer.length]!,
)
) {
return false
}
}
}
return true
}
function polygonsHaveInteriorOverlap(a: readonly Point2D[], b: readonly Point2D[]): boolean {
if (polygonsDescribeSameRegion(a, b)) return true
if (a.some((point) => pointInPolygon(point, b, false))) return true
if (b.some((point) => pointInPolygon(point, a, false))) return true
for (let aIndex = 0; aIndex < a.length; aIndex += 1) {
for (let bIndex = 0; bIndex < b.length; bIndex += 1) {
if (
segmentsCrossProperly(
a[aIndex]!,
a[(aIndex + 1) % a.length]!,
b[bIndex]!,
b[(bIndex + 1) % b.length]!,
)
) {
return true
}
}
}
return false
}
function pointToPolylineDistance(point: Point2D, polyline: readonly Point2D[]): number {
@@ -108,19 +173,19 @@ function pointToPolylineDistance(point: Point2D, polyline: readonly Point2D[]):
return best
}
type WallPath = { wall: WallNode; points: Point2D[] }
type BoundaryWallSpan = { wall: WallNode; length: number }
function wallForBoundarySegment(
start: Point2D,
end: Point2D,
walls: readonly WallNode[],
wallPaths: readonly WallPath[],
): WallNode | null {
const midpoint: Point2D = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2]
let best: { wall: WallNode; distance: number } | null = null
for (const wall of walls) {
const centerline = sampleWallCenterline(wall, 32).map((point) => [point.x, point.y] as Point2D)
const distances = [start, midpoint, end].map((point) =>
pointToPolylineDistance(point, centerline),
)
for (const { wall, points } of wallPaths) {
const distances = [start, midpoint, end].map((point) => pointToPolylineDistance(point, points))
if (distances.some((distance) => distance > BOUNDARY_TOLERANCE)) continue
const distance = distances.reduce((sum, value) => sum + value, 0)
@@ -130,11 +195,105 @@ function wallForBoundarySegment(
return best?.wall ?? null
}
function matchingSurfaceNodes<T extends SlabNode | CeilingNode>(
function wallPathsFor(walls: readonly WallNode[]): WallPath[] {
return walls.map((wall) => ({
wall,
points: sampleWallCenterline(wall, 32).map((point) => [point.x, point.y] as Point2D),
}))
}
function pointAlongSegment(start: Point2D, end: Point2D, t: number): Point2D {
return [start[0] + (end[0] - start[0]) * t, start[1] + (end[1] - start[1]) * t]
}
function segmentParameter(point: Point2D, start: Point2D, end: Point2D): number {
const dx = end[0] - start[0]
const dy = end[1] - start[1]
const lengthSquared = dx * dx + dy * dy
return lengthSquared <= 1e-12
? 0
: ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / lengthSquared
}
function spansFromSpace(space: Space, wallsById: ReadonlyMap<string, WallNode>) {
const spans: BoundaryWallSpan[] = []
for (const boundary of space.boundaryFaces) {
const wall = wallsById.get(boundary.wallId)
if (!wall) return null
let length = 0
for (let index = 0; index < boundary.points.length - 1; index += 1) {
length += pointDistance(boundary.points[index]!, boundary.points[index + 1]!)
}
if (length <= 1e-6) return null
spans.push({ wall, length })
}
return spans.length > 0 ? spans : null
}
function spansFromZoneBoundary(zone: ZoneNode, wallPaths: readonly WallPath[]) {
const spans: BoundaryWallSpan[] = []
for (let edgeIndex = 0; edgeIndex < zone.polygon.length; edgeIndex += 1) {
const start = zone.polygon[edgeIndex]!
const end = zone.polygon[(edgeIndex + 1) % zone.polygon.length]!
const edgeLength = pointDistance(start, end)
if (edgeLength <= 1e-6) continue
const breaks = [0, 1]
for (const path of wallPaths) {
for (const point of path.points) {
if (pointToSegmentDistance(point, start, end) > BOUNDARY_TOLERANCE) continue
const t = segmentParameter(point, start, end)
if (t > 0 && t < 1) breaks.push(t)
}
}
breaks.sort((a, b) => a - b)
const uniqueBreaks = breaks.filter(
(value, index) => index === 0 || value - breaks[index - 1]! > 1e-6,
)
for (let index = 0; index < uniqueBreaks.length - 1; index += 1) {
const t0 = uniqueBreaks[index]!
const t1 = uniqueBreaks[index + 1]!
if (t1 - t0 <= 1e-6) continue
const spanStart = pointAlongSegment(start, end, t0)
const spanEnd = pointAlongSegment(start, end, t1)
const wall = wallForBoundarySegment(spanStart, spanEnd, wallPaths)
if (!wall) return null
spans.push({ wall, length: edgeLength * (t1 - t0) })
}
}
return spans.length > 0 ? spans : null
}
type SurfaceCoverage<T extends SlabNode | CeilingNode> = {
node: T
area: number | null
issue?: string
}
function coveringSurfaceNodes<T extends SlabNode | CeilingNode>(
zone: ZoneNode,
nodes: readonly T[],
): T[] {
return nodes.filter((node) => polygonsDescribeSameRegion(zone.polygon, node.polygon))
): SurfaceCoverage<T>[] {
const surfaces: SurfaceCoverage<T>[] = []
for (const node of nodes) {
if (!polygonContainsRegion(node.polygon, zone.polygon)) continue
let area = polygonArea(zone.polygon)
let issue: string | undefined
for (const hole of node.holes) {
if (polygonContainsRegion(hole, zone.polygon)) {
issue = 'A surface opening removes this zone.'
break
} else if (polygonContainsRegion(zone.polygon, hole)) {
area -= polygonArea(hole)
} else if (polygonsHaveInteriorOverlap(zone.polygon, hole)) {
issue = 'A surface opening crosses the zone boundary.'
break
}
}
surfaces.push(issue ? { node, area: null, issue } : { node, area: Math.max(0, area) })
}
return surfaces
}
function unavailable(reason: string): ZoneQuantityValue {
@@ -150,99 +309,84 @@ export function deriveZoneQuantityReport(
? Object.values(sceneNodes).filter((node) => node.parentId === levelId)
: []
const walls = levelNodes.filter((node): node is WallNode => node.type === 'wall')
const slabs = matchingSurfaceNodes(
zone,
levelNodes.filter((node): node is SlabNode => node.type === 'slab'),
)
const ceilings = matchingSurfaceNodes(
zone,
levelNodes.filter((node): node is CeilingNode => node.type === 'ceiling'),
)
const edgeLengths = zone.polygon.map((start, index) => {
const end = zone.polygon[(index + 1) % zone.polygon.length]
return end ? pointDistance(start, end) : 0
})
const footprintArea = polygonArea(zone.polygon)
const perimeter = edgeLengths.reduce((sum, length) => sum + length, 0)
const slabs = coveringSurfaceNodes(
zone,
levelNodes.filter((node): node is SlabNode => node.type === 'slab'),
)
const ceilings = coveringSurfaceNodes(
zone,
levelNodes.filter((node): node is CeilingNode => node.type === 'ceiling'),
)
const matchingRoom = levelId
? detectSpacesForLevel(levelId, walls).roomPolygons.find((polygon) =>
polygonsDescribeSameRegion(
zone.polygon,
polygon.map((point) => [point.x, point.y]),
),
)
: undefined
const wallMatches = matchingRoom?.map((start, index) => {
const end = matchingRoom[(index + 1) % matchingRoom.length]
if (!end) return null
const tupleStart: Point2D = [start.x, start.y]
const tupleEnd: Point2D = [end.x, end.y]
const wall = wallForBoundarySegment(tupleStart, tupleEnd, walls)
if (!wall) return null
return { wall, length: pointDistance(tupleStart, tupleEnd) }
})
const allWallsProven = !!wallMatches && wallMatches.length > 0 && wallMatches.every(Boolean)
const boundaryWallIds = allWallsProven
? [...new Set(wallMatches.map((match) => match!.wall.id))]
: []
const spaces = levelId ? detectSpacesForLevel(levelId, walls).spaces : []
const matchingSpace = spaces.find((space) =>
polygonsDescribeSameRegion(zone.polygon, space.polygon),
)
const wallsById = new Map(walls.map((wall) => [wall.id, wall]))
const wallPaths = wallPathsFor(walls)
const wallSpans =
(matchingSpace ? spansFromSpace(matchingSpace, wallsById) : null) ??
spansFromZoneBoundary(zone, wallPaths)
const allWallsProven = Boolean(wallSpans)
const boundaryWallIds = wallSpans ? [...new Set(wallSpans.map((span) => span.wall.id))] : []
const wallSurface = allWallsProven
? {
status: 'available' as const,
value: wallMatches.reduce(
(sum, match) => sum + match!.length * (match!.wall.height ?? DEFAULT_WALL_HEIGHT),
value: wallSpans!.reduce(
(sum, span) => sum + span.length * (span.wall.height ?? DEFAULT_WALL_HEIGHT),
0,
),
note: 'Gross interior wall face before openings.',
note: "Gross interior wall face using each boundary wall's height.",
}
: unavailable(
matchingRoom
? 'The closed boundary could not be assigned to every wall segment.'
: 'No matching closed wall loop was detected.',
)
: unavailable('The zone boundary is not fully backed by walls.')
const matchingSlab = slabs.length === 1 ? slabs[0] : undefined
const floorSurface = matchingSlab
? {
status: 'available' as const,
value: polygonSurfaceArea(matchingSlab.polygon, matchingSlab.holes),
note: 'Matching slab surface after openings.',
}
: unavailable(
slabs.length > 1
? 'More than one slab matches this boundary.'
: 'No slab matches this zone boundary.',
)
const floorSurface =
matchingSlab && matchingSlab.area !== null
? {
status: 'available' as const,
value: matchingSlab.area,
note: 'Zone floor surface covered by one slab, after openings.',
}
: unavailable(
slabs.length > 1
? 'More than one slab covers this zone.'
: (matchingSlab?.issue ?? 'No slab covers this zone.'),
)
const matchingCeiling = ceilings.length === 1 ? ceilings[0] : undefined
let volume: ZoneQuantityValue
if (!matchingRoom) {
volume = unavailable('No matching closed wall loop was detected.')
} else if (!matchingSlab) {
if (!wallSpans) {
volume = unavailable('The zone boundary is not fully backed by walls.')
} else if (!matchingSlab || matchingSlab.area === null) {
volume = unavailable(floorSurface.status === 'unavailable' ? floorSurface.reason : 'No floor.')
} else if (!matchingCeiling) {
} else if (!matchingCeiling || matchingCeiling.area === null) {
volume = unavailable(
ceilings.length > 1
? 'More than one ceiling matches this boundary.'
: 'No ceiling matches this zone boundary.',
? 'More than one ceiling covers this zone.'
: (matchingCeiling?.issue ?? 'No ceiling covers this zone.'),
)
} else {
const clearHeight = matchingCeiling.height - matchingSlab.elevation
const clearHeight = matchingCeiling.node.height - matchingSlab.node.elevation
volume =
Number.isFinite(clearHeight) && clearHeight > 0
? {
status: 'available',
value: polygonSurfaceArea(matchingSlab.polygon, matchingSlab.holes) * clearHeight,
note: 'Matching slab area multiplied by clear ceiling height.',
value: matchingSlab.area * clearHeight,
note: 'Covered zone floor area multiplied by clear ceiling height.',
}
: unavailable('The matching ceiling is not above the slab surface.')
}
return {
classification: matchingRoom ? 'enclosed-room' : 'footprint',
classification: wallSpans ? 'enclosed-room' : 'footprint',
footprintArea,
perimeter,
edgeLengths,