fix: harden measurement geometry (#507)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user