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,
|
||||
|
||||
@@ -195,6 +195,7 @@ describe('measurement draft transitions', () => {
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(draft.finishVertexDrag('3d')).toBe(true)
|
||||
expect(useMeasurementDraft.getState().points[0]).toEqual(point(0, 0, 0))
|
||||
expect(useMeasurementDraft.getState().collectionPlane).toEqual({
|
||||
point: point(0, 0, 0),
|
||||
normal: point(0, 1, 0),
|
||||
@@ -208,6 +209,34 @@ describe('measurement draft transitions', () => {
|
||||
expect(useMeasurementDraft.getState().collectionPlane).toBeNull()
|
||||
})
|
||||
|
||||
test('projects every later polygon point and feature fallback onto the first surface plane', () => {
|
||||
const draft = useMeasurementDraft.getState()
|
||||
const anchor = {
|
||||
kind: 'feature' as const,
|
||||
reference: {
|
||||
nodeId: 'slab_host',
|
||||
featureId: 'slab:boundary',
|
||||
parameters: { t: 0.25 },
|
||||
},
|
||||
fallback: point(2, 0.4, 0),
|
||||
}
|
||||
draft.setKind('area')
|
||||
draft.addPoint('3d', point(0, 0, 0), undefined, point(0, 1, 0))
|
||||
draft.addPoint('3d', anchor.fallback, anchor, point(1, 0, 0))
|
||||
draft.addPoint('3d', point(2, -0.7, 2), undefined, point(0, -1, 0))
|
||||
|
||||
expect(useMeasurementDraft.getState().points).toEqual([
|
||||
point(0, 0, 0),
|
||||
point(2, 0, 0),
|
||||
point(2, 0, 2),
|
||||
])
|
||||
expect(useMeasurementDraft.getState().anchors[1]).toMatchObject({
|
||||
fallback: point(2, 0, 0),
|
||||
})
|
||||
expect(draft.closeBase('3d')).toBe(true)
|
||||
expect(useMeasurementDraft.getState().error).toBeNull()
|
||||
})
|
||||
|
||||
test('closes a volume base before accepting explicit extrusion', () => {
|
||||
const draft = useMeasurementDraft.getState()
|
||||
draft.setKind('volume')
|
||||
|
||||
@@ -108,6 +108,29 @@ function normalizePoint(point: MeasurementPoint | undefined): MeasurementPoint |
|
||||
return length > 1e-9 ? [point[0] / length, point[1] / length, point[2] / length] : null
|
||||
}
|
||||
|
||||
function projectPointToPlane(
|
||||
point: MeasurementPoint,
|
||||
plane: { point: MeasurementPoint; normal: MeasurementPoint } | null,
|
||||
): MeasurementPoint {
|
||||
if (!plane) return clonePoint(point)
|
||||
const distance =
|
||||
(point[0] - plane.point[0]) * plane.normal[0] +
|
||||
(point[1] - plane.point[1]) * plane.normal[1] +
|
||||
(point[2] - plane.point[2]) * plane.normal[2]
|
||||
return [
|
||||
point[0] - plane.normal[0] * distance,
|
||||
point[1] - plane.normal[1] * distance,
|
||||
point[2] - plane.normal[2] * distance,
|
||||
]
|
||||
}
|
||||
|
||||
function anchorWithFallback(
|
||||
anchor: MeasurementFeatureAnchor | undefined,
|
||||
fallback: MeasurementPoint,
|
||||
): MeasurementFeatureAnchor | null {
|
||||
return anchor ? { ...anchor, fallback: clonePoint(fallback) } : null
|
||||
}
|
||||
|
||||
export function measurementPolygonMidpoints(
|
||||
points: readonly MeasurementPoint[],
|
||||
): Array<{ edgeIndex: number; point: MeasurementPoint }> {
|
||||
@@ -277,17 +300,18 @@ export const useMeasurementDraft = create<MeasurementDraftState>((set, get) => (
|
||||
return false
|
||||
}
|
||||
|
||||
const projectedPoint = projectPointToPlane(hover.point, state.collectionPlane)
|
||||
const points = state.points.map((point, index) =>
|
||||
index === drag.index ? clonePoint(hover.point) : point,
|
||||
index === drag.index ? projectedPoint : point,
|
||||
)
|
||||
const anchors = state.anchors.map((anchor, index) =>
|
||||
index === drag.index ? (hover.anchor ?? null) : anchor,
|
||||
index === drag.index ? anchorWithFallback(hover.anchor, projectedPoint) : anchor,
|
||||
)
|
||||
set({
|
||||
points,
|
||||
anchors,
|
||||
hover: {
|
||||
point: clonePoint(hover.point),
|
||||
point: clonePoint(projectedPoint),
|
||||
normal: clonePoint(hover.normal),
|
||||
targetNodeId: hover.targetNodeId,
|
||||
anchor: hover.anchor,
|
||||
@@ -352,9 +376,12 @@ export const useMeasurementDraft = create<MeasurementDraftState>((set, get) => (
|
||||
if (state.kind === 'distance' && state.points.length >= 2) return false
|
||||
if (state.kind === 'angle' && state.points.length >= 3) return false
|
||||
|
||||
const points = [...state.points, clonePoint(point)]
|
||||
const anchors = [...state.anchors, anchor ?? null]
|
||||
const polygon = state.kind === 'area' || state.kind === 'perimeter' || state.kind === 'volume'
|
||||
const projectedPoint = polygon
|
||||
? projectPointToPlane(point, state.collectionPlane)
|
||||
: clonePoint(point)
|
||||
const points = [...state.points, projectedPoint]
|
||||
const anchors = [...state.anchors, anchorWithFallback(anchor, projectedPoint)]
|
||||
const planeNormal = polygon && state.points.length === 0 ? normalizePoint(surfaceNormal) : null
|
||||
const ready =
|
||||
(state.kind === 'distance' && points.length === 2) ||
|
||||
@@ -365,7 +392,7 @@ export const useMeasurementDraft = create<MeasurementDraftState>((set, get) => (
|
||||
points,
|
||||
anchors,
|
||||
collectionPlane: planeNormal
|
||||
? { point: clonePoint(point), normal: planeNormal }
|
||||
? { point: clonePoint(projectedPoint), normal: planeNormal }
|
||||
: state.collectionPlane,
|
||||
stage: ready ? 'ready' : 'collecting',
|
||||
hover: null,
|
||||
|
||||
@@ -185,4 +185,61 @@ describe('polygon measurement surface intent', () => {
|
||||
tableTop.geometry.dispose()
|
||||
material.dispose()
|
||||
})
|
||||
|
||||
test('keeps later polygon points on the first plane through a distant occluder', () => {
|
||||
const level = new Group()
|
||||
const material = new MeshBasicMaterial({ side: DoubleSide })
|
||||
const floor = new Mesh(new PlaneGeometry(8, 8), material)
|
||||
const table = new Mesh(new PlaneGeometry(8, 8), material)
|
||||
floor.rotation.x = -Math.PI / 2
|
||||
table.rotation.x = -Math.PI / 2
|
||||
table.position.y = 0.8
|
||||
level.add(floor, table)
|
||||
level.updateMatrixWorld(true)
|
||||
|
||||
const hits = new Raycaster(new Vector3(0, 2, 0), new Vector3(0, -1, 0))
|
||||
.intersectObjects([floor, table])
|
||||
.map((intersection) => ({
|
||||
intersection,
|
||||
targetNodeId: intersection.object === floor ? 'slab_1' : 'item_1',
|
||||
}))
|
||||
|
||||
expect(hits[0]?.targetNodeId).toBe('item_1')
|
||||
expect(
|
||||
selectMeasurementSurfaceHit(hits, level, {
|
||||
kind: 'plane',
|
||||
point: [0, 0, 0],
|
||||
normal: [0, 1, 0],
|
||||
})?.targetNodeId,
|
||||
).toBe('slab_1')
|
||||
|
||||
floor.geometry.dispose()
|
||||
table.geometry.dispose()
|
||||
material.dispose()
|
||||
})
|
||||
|
||||
test('returns no candidate instead of falling through to a different plane', () => {
|
||||
const level = new Group()
|
||||
const material = new MeshBasicMaterial({ side: DoubleSide })
|
||||
const table = new Mesh(new PlaneGeometry(8, 8), material)
|
||||
table.rotation.x = -Math.PI / 2
|
||||
table.position.y = 0.8
|
||||
level.add(table)
|
||||
level.updateMatrixWorld(true)
|
||||
|
||||
const hits = new Raycaster(new Vector3(0, 2, 0), new Vector3(0, -1, 0))
|
||||
.intersectObject(table)
|
||||
.map((intersection) => ({ intersection, targetNodeId: 'item_1' }))
|
||||
|
||||
expect(
|
||||
selectMeasurementSurfaceHit(hits, level, {
|
||||
kind: 'plane',
|
||||
point: [0, 0, 0],
|
||||
normal: [0, 1, 0],
|
||||
}),
|
||||
).toBeNull()
|
||||
|
||||
table.geometry.dispose()
|
||||
material.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -430,11 +430,6 @@ export function selectMeasurementSurfaceHit(
|
||||
const nearest = hits[0] ?? null
|
||||
if (!(nearest && preference)) return nearest
|
||||
|
||||
const nearby = hits.filter(
|
||||
(hit) =>
|
||||
hit.intersection.distance <=
|
||||
nearest.intersection.distance + SURFACE_INTENT_MAX_OCCLUSION_DISTANCE,
|
||||
)
|
||||
if (preference.kind === 'horizontal') {
|
||||
if (
|
||||
Math.abs(toLocalSurfaceHit(nearest, levelObject).normal[1]) >=
|
||||
@@ -443,6 +438,11 @@ export function selectMeasurementSurfaceHit(
|
||||
return nearest
|
||||
}
|
||||
const nodes = useScene.getState().nodes as Record<string, { type: string } | undefined>
|
||||
const nearby = hits.filter(
|
||||
(hit) =>
|
||||
hit.intersection.distance <=
|
||||
nearest.intersection.distance + SURFACE_INTENT_MAX_OCCLUSION_DISTANCE,
|
||||
)
|
||||
return (
|
||||
nearby.find((hit) => {
|
||||
const type = hit.targetNodeId ? nodes[hit.targetNodeId]?.type : undefined
|
||||
@@ -455,11 +455,11 @@ export function selectMeasurementSurfaceHit(
|
||||
}
|
||||
|
||||
const preferredNormal = new Vector3(...preference.normal)
|
||||
if (preferredNormal.lengthSq() <= 1e-12) return nearest
|
||||
if (preferredNormal.lengthSq() <= 1e-12) return null
|
||||
preferredNormal.normalize()
|
||||
const preferredPoint = new Vector3(...preference.point)
|
||||
return (
|
||||
nearby.find((hit) => {
|
||||
hits.find((hit) => {
|
||||
const localHit = toLocalSurfaceHit(hit, levelObject)
|
||||
const normalAlignment = Math.abs(preferredNormal.dot(new Vector3(...localHit.normal)))
|
||||
const planeDistance = Math.abs(
|
||||
@@ -469,7 +469,7 @@ export function selectMeasurementSurfaceHit(
|
||||
normalAlignment >= SURFACE_INTENT_MIN_NORMAL_ALIGNMENT &&
|
||||
planeDistance <= SURFACE_INTENT_PLANE_TOLERANCE
|
||||
)
|
||||
}) ?? nearest
|
||||
}) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
isMeasurementSurfaceMaterialVisible,
|
||||
localNormalToPreviewFrame,
|
||||
measurementIntersectionWorldNormal,
|
||||
measurementPolygonSurfacePreference,
|
||||
measurementVertexSnapAnchors,
|
||||
parseMeasurementExtrusionHeight,
|
||||
projectMeasurementPointToAxes,
|
||||
@@ -526,6 +527,23 @@ describe('measurement axis projection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('polygon measurement plane locking', () => {
|
||||
test('keeps the captured plane when magnetic snapping is bypassed', () => {
|
||||
const plane = { point: [0, 1, 0], normal: [0, 1, 0] } as const
|
||||
|
||||
expect(measurementPolygonSurfacePreference('area', plane, false)).toEqual({
|
||||
kind: 'plane',
|
||||
point: plane.point,
|
||||
normal: plane.normal,
|
||||
})
|
||||
expect(measurementPolygonSurfacePreference('area', null, false)).toBeNull()
|
||||
expect(measurementPolygonSurfacePreference('area', null, true)).toEqual({
|
||||
kind: 'horizontal',
|
||||
})
|
||||
expect(measurementPolygonSurfacePreference('distance', plane, true)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('measurement draft vertex affordances', () => {
|
||||
test('selects the closest handle inside its screen threshold', () => {
|
||||
expect(selectClosestMeasurementVertexIndex([18, 7, 10])).toBe(1)
|
||||
|
||||
@@ -288,14 +288,17 @@ function isMeasurementKind(
|
||||
)
|
||||
}
|
||||
|
||||
function polygonSurfacePreference(
|
||||
export function measurementPolygonSurfacePreference(
|
||||
kind: MeasurementKind,
|
||||
plane: { point: MeasurementPoint; normal: MeasurementPoint } | null,
|
||||
applyMagneticSnap: boolean,
|
||||
): MeasurementSurfacePreference | null {
|
||||
if (kind !== 'area' && kind !== 'perimeter' && kind !== 'volume') return null
|
||||
return plane
|
||||
? { kind: 'plane', point: plane.point, normal: plane.normal }
|
||||
: { kind: 'horizontal' }
|
||||
: applyMagneticSnap
|
||||
? { kind: 'horizontal' }
|
||||
: null
|
||||
}
|
||||
|
||||
function isEffectivelyVisible(object: Object3D): boolean {
|
||||
@@ -1952,9 +1955,11 @@ export const MeasurementTool: FC = () => {
|
||||
lockedGuide:
|
||||
applyMagneticSnap && activeDraft.axisGuide?.snapped ? activeDraft.axisGuide : null,
|
||||
planarProximityAnchors: getPlanarProximityAnchors(),
|
||||
surfacePreference: applyMagneticSnap
|
||||
? polygonSurfacePreference(activeDraft.kind, activeDraft.collectionPlane)
|
||||
: null,
|
||||
surfacePreference: measurementPolygonSurfacePreference(
|
||||
activeDraft.kind,
|
||||
activeDraft.collectionPlane,
|
||||
applyMagneticSnap,
|
||||
),
|
||||
applyMagneticSnap,
|
||||
showAlignmentGuides: isAlignmentGuideActive(),
|
||||
})
|
||||
@@ -2003,9 +2008,11 @@ export const MeasurementTool: FC = () => {
|
||||
anchorOrAnchors: draft.points[draft.points.length - 1] ?? null,
|
||||
lockedGuide: applyMagneticSnap && draft.axisGuide?.snapped ? draft.axisGuide : null,
|
||||
planarProximityAnchors: getPlanarProximityAnchors(),
|
||||
surfacePreference: applyMagneticSnap
|
||||
? polygonSurfacePreference(draft.kind, draft.collectionPlane)
|
||||
: null,
|
||||
surfacePreference: measurementPolygonSurfacePreference(
|
||||
draft.kind,
|
||||
draft.collectionPlane,
|
||||
applyMagneticSnap,
|
||||
),
|
||||
applyMagneticSnap,
|
||||
showAlignmentGuides: isAlignmentGuideActive(),
|
||||
})
|
||||
@@ -2089,9 +2096,11 @@ export const MeasurementTool: FC = () => {
|
||||
anchorOrAnchors: draft.points[draft.points.length - 1] ?? null,
|
||||
lockedGuide: applyMagneticSnap && draft.axisGuide?.snapped ? draft.axisGuide : null,
|
||||
planarProximityAnchors: getPlanarProximityAnchors(),
|
||||
surfacePreference: applyMagneticSnap
|
||||
? polygonSurfacePreference(draft.kind, draft.collectionPlane)
|
||||
: null,
|
||||
surfacePreference: measurementPolygonSurfacePreference(
|
||||
draft.kind,
|
||||
draft.collectionPlane,
|
||||
applyMagneticSnap,
|
||||
),
|
||||
applyMagneticSnap,
|
||||
showAlignmentGuides: isAlignmentGuideActive(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user