fix: derive zone quantities from room topology (#512)

This commit is contained in:
Aymeric Rabot
2026-07-18 14:35:12 +02:00
committed by GitHub
parent cddb73b232
commit 2084892383
3 changed files with 528 additions and 73 deletions
+297 -7
View File
@@ -45,12 +45,12 @@ describe('deriveZoneQuantityReport', () => {
expect(report.wallSurface).toEqual({ expect(report.wallSurface).toEqual({
status: 'available', status: 'available',
value: 35, value: 35,
note: "Gross interior wall face using each boundary wall's height.", note: 'Gross indoor-facing wall surface within this zone, including both sides of interior partitions.',
}) })
expect(report.floorSurface).toEqual({ expect(report.floorSurface).toEqual({
status: 'available', status: 'available',
value: 12, value: 12,
note: 'Zone floor surface covered by one slab, after openings.', note: 'Zone floor surface proven by compatible slab coverage, after openings.',
}) })
expect(report.volume.status).toBe('available') expect(report.volume.status).toBe('available')
if (report.volume.status === 'available') expect(report.volume.value).toBeCloseTo(29.4) if (report.volume.status === 'available') expect(report.volume.value).toBeCloseTo(29.4)
@@ -90,7 +90,7 @@ describe('deriveZoneQuantityReport', () => {
expect(report.floorSurface).toEqual({ expect(report.floorSurface).toEqual({
status: 'available', status: 'available',
value: 11, value: 11,
note: 'Zone floor surface covered by one slab, after openings.', note: 'Zone floor surface proven by compatible slab coverage, after openings.',
}) })
}) })
@@ -149,17 +149,17 @@ describe('deriveZoneQuantityReport', () => {
expect(report.wallSurface).toEqual({ expect(report.wallSurface).toEqual({
status: 'available', status: 'available',
value: 79, value: 79,
note: "Gross interior wall face using each boundary wall's height.", note: 'Gross indoor-facing wall surface within this zone, including both sides of interior partitions.',
}) })
expect(report.floorSurface).toEqual({ expect(report.floorSurface).toEqual({
status: 'available', status: 'available',
value: 49, value: 49,
note: 'Zone floor surface covered by one slab, after openings.', note: 'Zone floor surface proven by compatible slab coverage, after openings.',
}) })
expect(report.volume).toEqual({ expect(report.volume).toEqual({
status: 'available', status: 'available',
value: 147, value: 147,
note: 'Covered zone floor area multiplied by clear ceiling height.', note: 'Proven zone floor area multiplied by clear ceiling height.',
}) })
}) })
@@ -209,7 +209,7 @@ describe('deriveZoneQuantityReport', () => {
expect(report.floorSurface).toEqual({ expect(report.floorSurface).toEqual({
status: 'available', status: 'available',
value: 11, value: 11,
note: 'Zone floor surface covered by one slab, after openings.', note: 'Zone floor surface proven by compatible slab coverage, after openings.',
}) })
}) })
@@ -283,4 +283,294 @@ describe('deriveZoneQuantityReport', () => {
expect(report.wallSurface.value).toBeCloseTo(19.6) expect(report.wallSurface.value).toBeCloseTo(19.6)
} }
}) })
test('does not mistake an adjacent notched slab for a second covering slab', () => {
const zonePolygon: Array<[number, number]> = [
[-1, 0],
[0, 0],
[0, 1],
[-1, 1],
]
const zone = ZoneNode.parse({
id: 'zone_notch',
name: 'Laundry',
parentId: 'level_main',
polygon: zonePolygon,
})
const exactSlab = SlabNode.parse({
id: 'slab_laundry',
parentId: 'level_main',
polygon: zonePolygon,
})
const adjacentSlab = SlabNode.parse({
id: 'slab_adjacent',
parentId: 'level_main',
polygon: [
[-1, -1],
[2, -1],
[2, 2],
[-1, 2],
[-1, 1],
[0, 1],
[0, 0],
[-1, 0],
],
})
const report = deriveZoneQuantityReport(zone, sceneRecord([zone, exactSlab, adjacentSlab]))
expect(report.floorSurface).toEqual({
status: 'available',
value: 1,
note: 'Zone floor surface proven by compatible slab coverage, after openings.',
})
})
test('combines compatible slabs while rejecting conflicting floor elevations', () => {
const zone = ZoneNode.parse({
id: 'zone_tiled',
name: 'Combined room',
parentId: 'level_main',
polygon,
})
const left = SlabNode.parse({
id: 'slab_left',
parentId: 'level_main',
polygon: [
[0, 0],
[2, 0],
[2, 3],
[0, 3],
],
elevation: 0.05,
})
const right = SlabNode.parse({
id: 'slab_right',
parentId: 'level_main',
polygon: [
[2, 0],
[4, 0],
[4, 3],
[2, 3],
],
elevation: 0.05,
})
const compatible = deriveZoneQuantityReport(zone, sceneRecord([zone, left, right]))
expect(compatible.floorSurface).toEqual({
status: 'available',
value: 12,
note: 'Zone floor surface proven by compatible slab coverage, after openings.',
})
const raisedRight = SlabNode.parse({ ...right, elevation: 0.25 })
const conflicting = deriveZoneQuantityReport(zone, sceneRecord([zone, left, raisedRight]))
expect(conflicting.floorSurface).toEqual({
status: 'unavailable',
reason: 'Slabs covering this zone have different elevations.',
})
})
test('counts both indoor-facing sides of a separator inside a multi-room zone', () => {
const widePolygon: Array<[number, number]> = [
[0, 0],
[8, 0],
[8, 4],
[0, 4],
]
const zone = ZoneNode.parse({
id: 'zone_multi_room',
name: 'Bedroom suite',
parentId: 'level_main',
polygon: widePolygon,
})
const outerWalls = widePolygon.map((start, index) =>
WallNode.parse({
id: `wall_suite_${index}`,
parentId: 'level_main',
start,
end: widePolygon[(index + 1) % widePolygon.length],
height: 2.5,
}),
)
const separator = WallNode.parse({
id: 'wall_suite_separator',
parentId: 'level_main',
start: [4, 0],
end: [4, 4],
height: 2.5,
})
const report = deriveZoneQuantityReport(
zone,
sceneRecord([zone, ...outerWalls, separator] as AnyNode[]),
)
expect(report.classification).toBe('enclosed-room')
expect(new Set(report.boundaryWallIds)).toEqual(
new Set([...outerWalls.map((wall) => wall.id), separator.id]),
)
expect(report.wallSurface.status).toBe('available')
if (report.wallSurface.status === 'available') {
expect(report.wallSurface.value).toBeCloseTo(80)
}
})
test('reports only real indoor wall faces for a semantic sub-zone of a larger room', () => {
const roomPolygon: Array<[number, number]> = [
[0, 0],
[8, 0],
[8, 4],
[0, 4],
]
const subZonePolygon: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
]
const zone = ZoneNode.parse({
id: 'zone_open_half',
name: 'Living area',
parentId: 'level_main',
polygon: subZonePolygon,
})
const slab = SlabNode.parse({
id: 'slab_open_room',
parentId: 'level_main',
polygon: roomPolygon,
})
const ceiling = CeilingNode.parse({
id: 'ceiling_open_room',
parentId: 'level_main',
polygon: roomPolygon,
height: 2.55,
})
const walls = roomPolygon.map((start, index) =>
WallNode.parse({
id: `wall_open_${index}`,
parentId: 'level_main',
start,
end: roomPolygon[(index + 1) % roomPolygon.length],
height: 2.5,
}),
)
const report = deriveZoneQuantityReport(
zone,
sceneRecord([zone, slab, ceiling, ...walls] as AnyNode[]),
)
expect(report.classification).toBe('footprint')
expect(report.wallSurface.status).toBe('available')
if (report.wallSurface.status === 'available') {
expect(report.wallSurface.value).toBeCloseTo(30)
}
expect(report.floorSurface.status).toBe('available')
if (report.floorSurface.status === 'available') {
expect(report.floorSurface.value).toBeCloseTo(16)
}
expect(report.volume.status).toBe('available')
if (report.volume.status === 'available') expect(report.volume.value).toBeCloseTo(40)
})
test('supports legacy manual zones offset from detected room boundaries', () => {
const roomPolygon: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
]
const legacyZonePolygon: Array<[number, number]> = [
[0.1, 0],
[4, 0],
[4, 3],
[0.1, 3],
]
const zone = ZoneNode.parse({
id: 'zone_legacy_offset',
name: 'Legacy room',
parentId: 'level_main',
polygon: legacyZonePolygon,
})
const slab = SlabNode.parse({
id: 'slab_legacy_offset',
parentId: 'level_main',
polygon: roomPolygon,
})
const ceiling = CeilingNode.parse({
id: 'ceiling_legacy_offset',
parentId: 'level_main',
polygon: roomPolygon,
height: 2.55,
})
const walls = roomPolygon.map((start, index) =>
WallNode.parse({
id: `wall_legacy_offset_${index}`,
parentId: 'level_main',
start,
end: roomPolygon[(index + 1) % roomPolygon.length],
height: 2.5,
}),
)
const report = deriveZoneQuantityReport(
zone,
sceneRecord([zone, slab, ceiling, ...walls] as AnyNode[]),
)
expect(report.classification).toBe('enclosed-room')
expect(report.wallSurface.status).toBe('available')
if (report.wallSurface.status === 'available') {
expect(report.wallSurface.value).toBeCloseTo(35)
}
expect(report.floorSurface.status).toBe('available')
if (report.floorSurface.status === 'available') {
expect(report.floorSurface.value).toBeCloseTo(11.7)
}
expect(report.volume.status).toBe('available')
if (report.volume.status === 'available') expect(report.volume.value).toBeCloseTo(29.25)
})
test('accepts legacy face-aligned surfaces with small perimeter drift', () => {
const zonePolygon: Array<[number, number]> = [
[0, 0],
[7, 0],
[7, 6.5],
[0, 6.5],
]
const legacySurfacePolygon: Array<[number, number]> = [
[0, 0],
[6.75, 0],
[6.75, 6.5],
[0, 6.5],
]
const zone = ZoneNode.parse({
id: 'zone_legacy_surface',
name: 'Legacy open room',
parentId: 'level_main',
polygon: zonePolygon,
})
const slab = SlabNode.parse({
id: 'slab_legacy_surface',
parentId: 'level_main',
polygon: legacySurfacePolygon,
})
const ceiling = CeilingNode.parse({
id: 'ceiling_legacy_surface',
parentId: 'level_main',
polygon: legacySurfacePolygon,
height: 2.55,
})
const report = deriveZoneQuantityReport(zone, sceneRecord([zone, slab, ceiling]))
expect(report.classification).toBe('footprint')
expect(report.floorSurface.status).toBe('available')
if (report.floorSurface.status === 'available') {
expect(report.floorSurface.value).toBeCloseTo(45.5)
}
expect(report.volume.status).toBe('available')
if (report.volume.status === 'available') expect(report.volume.value).toBeCloseTo(113.75)
})
}) })
+230 -65
View File
@@ -1,6 +1,6 @@
import type { AnyNode, CeilingNode, SlabNode, WallNode, ZoneNode } from '../schema' import type { AnyNode, CeilingNode, SlabNode, WallNode, ZoneNode } from '../schema'
import { sampleWallCenterline } from '../systems/wall/wall-curve' import { sampleWallCenterline } from '../systems/wall/wall-curve'
import { DEFAULT_WALL_HEIGHT } from '../systems/wall/wall-footprint' import { DEFAULT_WALL_HEIGHT, DEFAULT_WALL_THICKNESS } from '../systems/wall/wall-footprint'
import { detectSpacesForLevel, type Space } from './space-detection' import { detectSpacesForLevel, type Space } from './space-detection'
type Point2D = readonly [number, number] type Point2D = readonly [number, number]
@@ -21,6 +21,10 @@ export type ZoneQuantityReport = {
} }
const BOUNDARY_TOLERANCE = 0.08 const BOUNDARY_TOLERANCE = 0.08
const SURFACE_COVERAGE_THRESHOLD = 0.95
const SPACE_CONTAINMENT_THRESHOLD = 0.95
const COVERAGE_SAMPLE_STEPS = 24
const SURFACE_DATUM_EPSILON = 1e-4
function pointDistance(a: Point2D, b: Point2D): number { function pointDistance(a: Point2D, b: Point2D): number {
return Math.hypot(a[0] - b[0], a[1] - b[1]) return Math.hypot(a[0] - b[0], a[1] - b[1])
@@ -112,6 +116,7 @@ function segmentsCrossProperly(a: Point2D, b: Point2D, c: Point2D, d: Point2D) {
function polygonContainsRegion(outer: readonly Point2D[], inner: readonly Point2D[]): boolean { function polygonContainsRegion(outer: readonly Point2D[], inner: readonly Point2D[]): boolean {
if (outer.length < 3 || inner.length < 3) return false if (outer.length < 3 || inner.length < 3) return false
if (polygonsDescribeSameRegion(outer, inner)) return true
if ( if (
!inner.every( !inner.every(
(point) => (point) =>
@@ -138,13 +143,38 @@ function polygonContainsRegion(outer: readonly Point2D[], inner: readonly Point2
} }
} }
} }
return true
return inner.some((start, index) => {
const end = inner[(index + 1) % inner.length]!
const midpoint: Point2D = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2]
return pointInPolygon(start, outer, false) || pointInPolygon(midpoint, outer, false)
})
} }
function polygonsHaveInteriorOverlap(a: readonly Point2D[], b: readonly Point2D[]): boolean { function polygonsHaveInteriorOverlap(a: readonly Point2D[], b: readonly Point2D[]): boolean {
if (polygonsDescribeSameRegion(a, b)) return true if (polygonsDescribeSameRegion(a, b)) return true
if (a.some((point) => pointInPolygon(point, b, false))) return true if (
if (b.some((point) => pointInPolygon(point, a, false))) return true a.some((point, index) => {
const end = a[(index + 1) % a.length]!
return (
pointInPolygon(point, b, false) ||
pointInPolygon([(point[0] + end[0]) / 2, (point[1] + end[1]) / 2], b, false)
)
})
) {
return true
}
if (
b.some((point, index) => {
const end = b[(index + 1) % b.length]!
return (
pointInPolygon(point, a, false) ||
pointInPolygon([(point[0] + end[0]) / 2, (point[1] + end[1]) / 2], a, false)
)
})
) {
return true
}
for (let aIndex = 0; aIndex < a.length; aIndex += 1) { for (let aIndex = 0; aIndex < a.length; aIndex += 1) {
for (let bIndex = 0; bIndex < b.length; bIndex += 1) { for (let bIndex = 0; bIndex < b.length; bIndex += 1) {
if ( if (
@@ -162,6 +192,34 @@ function polygonsHaveInteriorOverlap(a: readonly Point2D[], b: readonly Point2D[
return false return false
} }
function polygonCoverageRatio(subject: readonly Point2D[], covers: readonly Point2D[][]): number {
if (subject.length < 3 || covers.length === 0) return 0
const xs = subject.map((point) => point[0])
const ys = subject.map((point) => point[1])
const minX = Math.min(...xs)
const maxX = Math.max(...xs)
const minY = Math.min(...ys)
const maxY = Math.max(...ys)
if (maxX - minX <= 1e-9 || maxY - minY <= 1e-9) return 0
let inside = 0
let covered = 0
for (let xIndex = 0; xIndex < COVERAGE_SAMPLE_STEPS; xIndex += 1) {
for (let yIndex = 0; yIndex < COVERAGE_SAMPLE_STEPS; yIndex += 1) {
const point: Point2D = [
minX + ((xIndex + 0.5) / COVERAGE_SAMPLE_STEPS) * (maxX - minX),
minY + ((yIndex + 0.5) / COVERAGE_SAMPLE_STEPS) * (maxY - minY),
]
if (!pointInPolygon(point, subject)) continue
inside += 1
if (covers.some((cover) => pointInPolygon(point, cover))) covered += 1
}
}
return inside > 0 ? covered / inside : 0
}
function pointToPolylineDistance(point: Point2D, polyline: readonly Point2D[]): number { function pointToPolylineDistance(point: Point2D, polyline: readonly Point2D[]): number {
let best = Number.POSITIVE_INFINITY let best = Number.POSITIVE_INFINITY
for (let index = 0; index < polyline.length - 1; index += 1) { for (let index = 0; index < polyline.length - 1; index += 1) {
@@ -215,17 +273,91 @@ function segmentParameter(point: Point2D, start: Point2D, end: Point2D): number
: ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / lengthSquared : ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / lengthSquared
} }
function spansFromSpace(space: Space, wallsById: ReadonlyMap<string, WallNode>) { function segmentLengthInsideOrNearPolygon(
start: Point2D,
end: Point2D,
polygon: readonly Point2D[],
tolerance: number,
): number {
const dx = end[0] - start[0]
const dy = end[1] - start[1]
const length = Math.hypot(dx, dy)
if (length <= 1e-9) return 0
const breaks = [0, 1]
for (let index = 0; index < polygon.length; index += 1) {
const polygonStart = polygon[index]!
const polygonEnd = polygon[(index + 1) % polygon.length]!
const edgeX = polygonEnd[0] - polygonStart[0]
const edgeY = polygonEnd[1] - polygonStart[1]
const denominator = dx * edgeY - dy * edgeX
if (Math.abs(denominator) > 1e-12) {
const t =
((polygonStart[0] - start[0]) * edgeY - (polygonStart[1] - start[1]) * edgeX) / denominator
const edgeT =
((polygonStart[0] - start[0]) * dy - (polygonStart[1] - start[1]) * dx) / denominator
if (t > 0 && t < 1 && edgeT >= -1e-9 && edgeT <= 1 + 1e-9) breaks.push(t)
}
if (pointToSegmentDistance(polygonStart, start, end) <= tolerance) {
const projected = segmentParameter(polygonStart, start, end)
if (projected > 0 && projected < 1) breaks.push(projected)
}
}
breaks.sort((a, b) => a - b)
const uniqueBreaks = breaks.filter(
(value, index) => index === 0 || value - breaks[index - 1]! > 1e-7,
)
let insideLength = 0
for (let index = 0; index < uniqueBreaks.length - 1; index += 1) {
const t0 = uniqueBreaks[index]!
const t1 = uniqueBreaks[index + 1]!
const midpoint = pointAlongSegment(start, end, (t0 + t1) / 2)
if (
pointInPolygon(midpoint, polygon) ||
pointToPolygonBoundaryDistance(midpoint, polygon) <= tolerance
) {
insideLength += length * (t1 - t0)
}
}
return insideLength
}
function boundaryFaceKey(boundary: Space['boundaryFaces'][number]): string {
const pointKey = (point: Point2D) => `${point[0].toFixed(6)},${point[1].toFixed(6)}`
const forward = boundary.points.map(pointKey).join('|')
const reverse = [...boundary.points].reverse().map(pointKey).join('|')
return `${boundary.wallId}:${boundary.face}:${forward < reverse ? forward : reverse}`
}
function spansFromSpaces(
spaces: readonly Space[],
zone: ZoneNode,
wallsById: ReadonlyMap<string, WallNode>,
) {
const spans: BoundaryWallSpan[] = [] const spans: BoundaryWallSpan[] = []
for (const boundary of space.boundaryFaces) { const seen = new Set<string>()
const wall = wallsById.get(boundary.wallId) for (const space of spaces) {
if (!wall) return null for (const boundary of space.boundaryFaces) {
let length = 0 const key = boundaryFaceKey(boundary)
for (let index = 0; index < boundary.points.length - 1; index += 1) { if (seen.has(key)) continue
length += pointDistance(boundary.points[index]!, boundary.points[index + 1]!) seen.add(key)
const wall = wallsById.get(boundary.wallId)
if (!wall) return null
const tolerance = (wall.thickness ?? DEFAULT_WALL_THICKNESS) / 2 + BOUNDARY_TOLERANCE
let length = 0
for (let index = 0; index < boundary.points.length - 1; index += 1) {
length += segmentLengthInsideOrNearPolygon(
boundary.points[index]!,
boundary.points[index + 1]!,
zone.polygon,
tolerance,
)
}
if (length > 1e-6) spans.push({ wall, length })
} }
if (length <= 1e-6) return null
spans.push({ wall, length })
} }
return spans.length > 0 ? spans : null return spans.length > 0 ? spans : null
} }
@@ -265,35 +397,67 @@ function spansFromZoneBoundary(zone: ZoneNode, wallPaths: readonly WallPath[]) {
return spans.length > 0 ? spans : null return spans.length > 0 ? spans : null
} }
type SurfaceCoverage<T extends SlabNode | CeilingNode> = { type SurfaceCoverage<T extends SlabNode | CeilingNode> =
node: T | { status: 'available'; area: number; datum: number }
area: number | null | { status: 'unavailable'; reason: string }
issue?: string
}
function coveringSurfaceNodes<T extends SlabNode | CeilingNode>( function proveSurfaceCoverage<T extends SlabNode | CeilingNode>(
zone: ZoneNode, zone: ZoneNode,
nodes: readonly T[], nodes: readonly T[],
): SurfaceCoverage<T>[] { getDatum: (node: T) => number,
const surfaces: SurfaceCoverage<T>[] = [] labels: { singular: string; plural: string; datum: string },
for (const node of nodes) { ): SurfaceCoverage<T> {
if (!polygonContainsRegion(node.polygon, zone.polygon)) continue const candidates = nodes.filter(
let area = polygonArea(zone.polygon) (node) =>
let issue: string | undefined polygonsHaveInteriorOverlap(zone.polygon, node.polygon) &&
polygonCoverageRatio(zone.polygon, [node.polygon]) > 0,
)
if (
candidates.length === 0 ||
polygonCoverageRatio(
zone.polygon,
candidates.map((node) => node.polygon),
) < SURFACE_COVERAGE_THRESHOLD
) {
return { status: 'unavailable', reason: `No ${labels.singular} coverage proves this zone.` }
}
const datum = getDatum(candidates[0]!)
if (
!Number.isFinite(datum) ||
candidates.some((node) => Math.abs(getDatum(node) - datum) > SURFACE_DATUM_EPSILON)
) {
return {
status: 'unavailable',
reason: `${labels.plural} covering this zone have different ${labels.datum}.`,
}
}
let area = polygonArea(zone.polygon)
const seenHoles = new Set<string>()
for (const node of candidates) {
for (const hole of node.holes) { for (const hole of node.holes) {
const forward = hole.map((point) => `${point[0].toFixed(6)},${point[1].toFixed(6)}`).join('|')
const reverse = [...hole]
.reverse()
.map((point) => `${point[0].toFixed(6)},${point[1].toFixed(6)}`)
.join('|')
const key = forward < reverse ? forward : reverse
if (seenHoles.has(key)) continue
seenHoles.add(key)
if (polygonContainsRegion(hole, zone.polygon)) { if (polygonContainsRegion(hole, zone.polygon)) {
issue = 'A surface opening removes this zone.' return { status: 'unavailable', reason: 'A surface opening removes this zone.' }
break }
} else if (polygonContainsRegion(zone.polygon, hole)) { if (polygonContainsRegion(zone.polygon, hole)) {
area -= polygonArea(hole) area -= polygonArea(hole)
} else if (polygonsHaveInteriorOverlap(zone.polygon, hole)) { } else if (polygonsHaveInteriorOverlap(zone.polygon, hole)) {
issue = 'A surface opening crosses the zone boundary.' return { status: 'unavailable', reason: 'A surface opening crosses the zone boundary.' }
break
} }
} }
surfaces.push(issue ? { node, area: null, issue } : { node, area: Math.max(0, area) })
} }
return surfaces
return { status: 'available', area: Math.max(0, area), datum }
} }
function unavailable(reason: string): ZoneQuantityValue { function unavailable(reason: string): ZoneQuantityValue {
@@ -315,24 +479,37 @@ export function deriveZoneQuantityReport(
}) })
const footprintArea = polygonArea(zone.polygon) const footprintArea = polygonArea(zone.polygon)
const perimeter = edgeLengths.reduce((sum, length) => sum + length, 0) const perimeter = edgeLengths.reduce((sum, length) => sum + length, 0)
const slabs = coveringSurfaceNodes( const slabCoverage = proveSurfaceCoverage(
zone, zone,
levelNodes.filter((node): node is SlabNode => node.type === 'slab'), levelNodes.filter((node): node is SlabNode => node.type === 'slab'),
(node) => node.elevation,
{ singular: 'slab', plural: 'Slabs', datum: 'elevations' },
) )
const ceilings = coveringSurfaceNodes( const ceilingCoverage = proveSurfaceCoverage(
zone, zone,
levelNodes.filter((node): node is CeilingNode => node.type === 'ceiling'), levelNodes.filter((node): node is CeilingNode => node.type === 'ceiling'),
(node) => node.height,
{ singular: 'ceiling', plural: 'Ceilings', datum: 'heights' },
) )
const spaces = levelId ? detectSpacesForLevel(levelId, walls).spaces : [] const spaces = levelId ? detectSpacesForLevel(levelId, walls).spaces : []
const matchingSpace = spaces.find((space) => const overlappingSpaces = spaces.filter((space) =>
polygonsDescribeSameRegion(zone.polygon, space.polygon), polygonsHaveInteriorOverlap(zone.polygon, space.polygon),
) )
const topologyEnclosesZone =
overlappingSpaces.length > 0 &&
polygonCoverageRatio(
zone.polygon,
overlappingSpaces.map((space) => space.polygon),
) >= SPACE_CONTAINMENT_THRESHOLD &&
overlappingSpaces.every(
(space) => polygonCoverageRatio(space.polygon, [zone.polygon]) >= SPACE_CONTAINMENT_THRESHOLD,
)
const wallsById = new Map(walls.map((wall) => [wall.id, wall])) const wallsById = new Map(walls.map((wall) => [wall.id, wall]))
const wallPaths = wallPathsFor(walls) const wallPaths = wallPathsFor(walls)
const wallSpans = const topologyWallSpans = spansFromSpaces(overlappingSpaces, zone, wallsById)
(matchingSpace ? spansFromSpace(matchingSpace, wallsById) : null) ?? const boundaryWallSpans = spansFromZoneBoundary(zone, wallPaths)
spansFromZoneBoundary(zone, wallPaths) const wallSpans = topologyWallSpans ?? boundaryWallSpans
const allWallsProven = Boolean(wallSpans) const allWallsProven = Boolean(wallSpans)
const boundaryWallIds = wallSpans ? [...new Set(wallSpans.map((span) => span.wall.id))] : [] const boundaryWallIds = wallSpans ? [...new Set(wallSpans.map((span) => span.wall.id))] : []
@@ -343,50 +520,38 @@ export function deriveZoneQuantityReport(
(sum, span) => sum + span.length * (span.wall.height ?? DEFAULT_WALL_HEIGHT), (sum, span) => sum + span.length * (span.wall.height ?? DEFAULT_WALL_HEIGHT),
0, 0,
), ),
note: "Gross interior wall face using each boundary wall's height.", note: 'Gross indoor-facing wall surface within this zone, including both sides of interior partitions.',
} }
: unavailable('The zone boundary is not fully backed by walls.') : unavailable('No indoor-facing wall surface is proven within this zone.')
const matchingSlab = slabs.length === 1 ? slabs[0] : undefined
const floorSurface = const floorSurface =
matchingSlab && matchingSlab.area !== null slabCoverage.status === 'available'
? { ? {
status: 'available' as const, status: 'available' as const,
value: matchingSlab.area, value: slabCoverage.area,
note: 'Zone floor surface covered by one slab, after openings.', note: 'Zone floor surface proven by compatible slab coverage, after openings.',
} }
: unavailable( : unavailable(slabCoverage.reason)
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 let volume: ZoneQuantityValue
if (!wallSpans) { if (slabCoverage.status === 'unavailable') {
volume = unavailable('The zone boundary is not fully backed by walls.') volume = unavailable(slabCoverage.reason)
} else if (!matchingSlab || matchingSlab.area === null) { } else if (ceilingCoverage.status === 'unavailable') {
volume = unavailable(floorSurface.status === 'unavailable' ? floorSurface.reason : 'No floor.') volume = unavailable(ceilingCoverage.reason)
} else if (!matchingCeiling || matchingCeiling.area === null) {
volume = unavailable(
ceilings.length > 1
? 'More than one ceiling covers this zone.'
: (matchingCeiling?.issue ?? 'No ceiling covers this zone.'),
)
} else { } else {
const clearHeight = matchingCeiling.node.height - matchingSlab.node.elevation const clearHeight = ceilingCoverage.datum - slabCoverage.datum
volume = volume =
Number.isFinite(clearHeight) && clearHeight > 0 Number.isFinite(clearHeight) && clearHeight > 0
? { ? {
status: 'available', status: 'available',
value: matchingSlab.area * clearHeight, value: slabCoverage.area * clearHeight,
note: 'Covered zone floor area multiplied by clear ceiling height.', note: 'Proven zone floor area multiplied by clear ceiling height.',
} }
: unavailable('The matching ceiling is not above the slab surface.') : unavailable('The matching ceiling is not above the slab surface.')
} }
return { return {
classification: wallSpans ? 'enclosed-room' : 'footprint', classification: topologyEnclosesZone || boundaryWallSpans ? 'enclosed-room' : 'footprint',
footprintArea, footprintArea,
perimeter, perimeter,
edgeLengths, edgeLengths,
+1 -1
View File
@@ -28,7 +28,7 @@ Smart pointer handling is latest-event-wins. One shared animation-frame schedule
Zone visuals intentionally unmount outside zone presentation, so a 3D Smart floor hit cannot depend only on a zone mesh. When the visible hit is an upward/downward slab face, Smart checks active-level zone polygons in level-local plan space and resolves the smallest containing zone. It never replaces a wall hit. When zone geometry is mounted, a nearly coplanar zone may also win over its slab within the bounded surface tolerance. Zone visuals intentionally unmount outside zone presentation, so a 3D Smart floor hit cannot depend only on a zone mesh. When the visible hit is an upward/downward slab face, Smart checks active-level zone polygons in level-local plan space and resolves the smallest containing zone. It never replaces a wall hit. When zone geometry is mounted, a nearly coplanar zone may also win over its slab within the bounded surface tolerance.
`deriveZoneQuantityReport` is a pure, conservative Core report. Footprint area, perimeter, and individual edge lengths are always available. Enclosed-room classification requires either a matching detected wall loop or complete wall coverage of the closed zone boundary within the modeling tolerance. Detected loops contribute their ordered boundary-face polylines, so concave rooms, curved segments, T-junction spans, and unequal per-wall heights do not have to be reconstructed from a simplified polygon. Floor surface requires exactly one slab that covers the zone and subtracts holes contained by the zone; the slab may serve a larger level footprint. Volume additionally requires one covering flat upper surface and a positive clear height. Missing or ambiguous evidence returns a user-facing unavailable reason rather than a plausible estimate. `deriveZoneQuantityReport` is a pure, conservative Core report. Footprint area, perimeter, and individual edge lengths are always available. Enclosed-room classification requires the zone to be substantially covered by detected spaces that are themselves contained by the zone, or complete wall coverage of the closed zone boundary within the modeling tolerance. Every overlapping detected space contributes its ordered indoor-facing boundary-face spans clipped to the zone: an exterior wall contributes only its room-facing side, an internal separator contributes both room-facing sides, and a semantic sub-zone never invents a wall along an open boundary. Wall surface may therefore be available even when the zone remains classified as a footprint. Floor and ceiling evidence may combine multiple surfaces only when their union covers at least 95% of the zone and their elevations or heights agree; contained holes subtract, crossing holes remain unavailable. Flat volume uses the proven floor area and positive clear height without requiring a fabricated wall enclosure. Missing or conflicting evidence returns a user-facing unavailable reason rather than a plausible estimate.
The zone parametric inspector derives this report from the current zone polygon and scene nodes, then renders a compact top-view SVG with every edge dimension plus wall, floor, and volume rows. None of these values are persisted in `ZoneNode.metadata`, represented by hidden measurement nodes, or written during rendering. Net wall-opening subtraction, normalized persisted span parameters, and sloped upper surfaces remain future topology work. The zone parametric inspector derives this report from the current zone polygon and scene nodes, then renders a compact top-view SVG with every edge dimension plus wall, floor, and volume rows. None of these values are persisted in `ZoneNode.metadata`, represented by hidden measurement nodes, or written during rendering. Net wall-opening subtraction, normalized persisted span parameters, and sloped upper surfaces remain future topology work.