diff --git a/packages/core/src/lib/zone-quantities.test.ts b/packages/core/src/lib/zone-quantities.test.ts index d3a1a33fc..c8e529ce1 100644 --- a/packages/core/src/lib/zone-quantities.test.ts +++ b/packages/core/src/lib/zone-quantities.test.ts @@ -45,12 +45,12 @@ describe('deriveZoneQuantityReport', () => { expect(report.wallSurface).toEqual({ status: 'available', 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({ status: 'available', 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') if (report.volume.status === 'available') expect(report.volume.value).toBeCloseTo(29.4) @@ -90,7 +90,7 @@ describe('deriveZoneQuantityReport', () => { expect(report.floorSurface).toEqual({ status: 'available', 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({ status: 'available', 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({ status: 'available', 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({ status: 'available', 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({ status: 'available', 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) } }) + + 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) + }) }) diff --git a/packages/core/src/lib/zone-quantities.ts b/packages/core/src/lib/zone-quantities.ts index 3ac00a28b..f120ff02c 100644 --- a/packages/core/src/lib/zone-quantities.ts +++ b/packages/core/src/lib/zone-quantities.ts @@ -1,6 +1,6 @@ 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 { DEFAULT_WALL_HEIGHT, DEFAULT_WALL_THICKNESS } from '../systems/wall/wall-footprint' import { detectSpacesForLevel, type Space } from './space-detection' type Point2D = readonly [number, number] @@ -21,6 +21,10 @@ export type ZoneQuantityReport = { } 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 { 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 { if (outer.length < 3 || inner.length < 3) return false + if (polygonsDescribeSameRegion(outer, inner)) return true if ( !inner.every( (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 { 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 + if ( + 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 bIndex = 0; bIndex < b.length; bIndex += 1) { if ( @@ -162,6 +192,34 @@ function polygonsHaveInteriorOverlap(a: readonly Point2D[], b: readonly Point2D[ 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 { let best = Number.POSITIVE_INFINITY 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 } -function spansFromSpace(space: Space, wallsById: ReadonlyMap) { +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, +) { 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]!) + const seen = new Set() + for (const space of spaces) { + for (const boundary of space.boundaryFaces) { + const key = boundaryFaceKey(boundary) + if (seen.has(key)) continue + 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 } @@ -265,35 +397,67 @@ function spansFromZoneBoundary(zone: ZoneNode, wallPaths: readonly WallPath[]) { return spans.length > 0 ? spans : null } -type SurfaceCoverage = { - node: T - area: number | null - issue?: string -} +type SurfaceCoverage = + | { status: 'available'; area: number; datum: number } + | { status: 'unavailable'; reason: string } -function coveringSurfaceNodes( +function proveSurfaceCoverage( zone: ZoneNode, nodes: readonly T[], -): SurfaceCoverage[] { - const surfaces: SurfaceCoverage[] = [] - for (const node of nodes) { - if (!polygonContainsRegion(node.polygon, zone.polygon)) continue - let area = polygonArea(zone.polygon) - let issue: string | undefined + getDatum: (node: T) => number, + labels: { singular: string; plural: string; datum: string }, +): SurfaceCoverage { + const candidates = nodes.filter( + (node) => + 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() + for (const node of candidates) { 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)) { - issue = 'A surface opening removes this zone.' - break - } else if (polygonContainsRegion(zone.polygon, hole)) { + return { status: 'unavailable', reason: 'A surface opening removes this zone.' } + } + if (polygonContainsRegion(zone.polygon, hole)) { area -= polygonArea(hole) } else if (polygonsHaveInteriorOverlap(zone.polygon, hole)) { - issue = 'A surface opening crosses the zone boundary.' - break + return { status: 'unavailable', reason: 'A surface opening crosses the zone boundary.' } } } - 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 { @@ -315,24 +479,37 @@ export function deriveZoneQuantityReport( }) const footprintArea = polygonArea(zone.polygon) const perimeter = edgeLengths.reduce((sum, length) => sum + length, 0) - const slabs = coveringSurfaceNodes( + const slabCoverage = proveSurfaceCoverage( zone, 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, 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 matchingSpace = spaces.find((space) => - polygonsDescribeSameRegion(zone.polygon, space.polygon), + const overlappingSpaces = spaces.filter((space) => + 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 wallPaths = wallPathsFor(walls) - const wallSpans = - (matchingSpace ? spansFromSpace(matchingSpace, wallsById) : null) ?? - spansFromZoneBoundary(zone, wallPaths) + const topologyWallSpans = spansFromSpaces(overlappingSpaces, zone, wallsById) + const boundaryWallSpans = spansFromZoneBoundary(zone, wallPaths) + const wallSpans = topologyWallSpans ?? boundaryWallSpans const allWallsProven = Boolean(wallSpans) 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), 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 = - matchingSlab && matchingSlab.area !== null + slabCoverage.status === 'available' ? { status: 'available' as const, - value: matchingSlab.area, - note: 'Zone floor surface covered by one slab, after openings.', + value: slabCoverage.area, + note: 'Zone floor surface proven by compatible slab coverage, after openings.', } - : unavailable( - slabs.length > 1 - ? 'More than one slab covers this zone.' - : (matchingSlab?.issue ?? 'No slab covers this zone.'), - ) + : unavailable(slabCoverage.reason) - const matchingCeiling = ceilings.length === 1 ? ceilings[0] : undefined let volume: ZoneQuantityValue - 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 || matchingCeiling.area === null) { - volume = unavailable( - ceilings.length > 1 - ? 'More than one ceiling covers this zone.' - : (matchingCeiling?.issue ?? 'No ceiling covers this zone.'), - ) + if (slabCoverage.status === 'unavailable') { + volume = unavailable(slabCoverage.reason) + } else if (ceilingCoverage.status === 'unavailable') { + volume = unavailable(ceilingCoverage.reason) } else { - const clearHeight = matchingCeiling.node.height - matchingSlab.node.elevation + const clearHeight = ceilingCoverage.datum - slabCoverage.datum volume = Number.isFinite(clearHeight) && clearHeight > 0 ? { status: 'available', - value: matchingSlab.area * clearHeight, - note: 'Covered zone floor area multiplied by clear ceiling height.', + value: slabCoverage.area * clearHeight, + note: 'Proven zone floor area multiplied by clear ceiling height.', } : unavailable('The matching ceiling is not above the slab surface.') } return { - classification: wallSpans ? 'enclosed-room' : 'footprint', + classification: topologyEnclosesZone || boundaryWallSpans ? 'enclosed-room' : 'footprint', footprintArea, perimeter, edgeLengths, diff --git a/wiki/architecture/measurements.md b/wiki/architecture/measurements.md index 9b70b5e1e..29f810856 100644 --- a/wiki/architecture/measurements.md +++ b/wiki/architecture/measurements.md @@ -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. -`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.