fix: harden measurement geometry (#507)
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
import { type AnyNode, CeilingNode, SlabNode, WallNode, ZoneNode } from '../schema'
|
import { type AnyNode, CeilingNode, SlabNode, WallNode, ZoneNode } from '../schema'
|
||||||
|
import { detectSpacesForLevel } from './space-detection'
|
||||||
import { deriveZoneQuantityReport } from './zone-quantities'
|
import { deriveZoneQuantityReport } from './zone-quantities'
|
||||||
|
|
||||||
const polygon: Array<[number, number]> = [
|
const polygon: Array<[number, number]> = [
|
||||||
@@ -44,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 before openings.',
|
note: "Gross interior wall face using each boundary wall's height.",
|
||||||
})
|
})
|
||||||
expect(report.floorSurface).toEqual({
|
expect(report.floorSurface).toEqual({
|
||||||
status: 'available',
|
status: 'available',
|
||||||
value: 12,
|
value: 12,
|
||||||
note: 'Matching slab surface after openings.',
|
note: 'Zone floor surface covered by one slab, 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)
|
||||||
@@ -89,7 +90,197 @@ describe('deriveZoneQuantityReport', () => {
|
|||||||
expect(report.floorSurface).toEqual({
|
expect(report.floorSurface).toEqual({
|
||||||
status: 'available',
|
status: 'available',
|
||||||
value: 11,
|
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 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 } from '../systems/wall/wall-footprint'
|
||||||
import { detectSpacesForLevel } from './space-detection'
|
import { detectSpacesForLevel, type Space } from './space-detection'
|
||||||
|
|
||||||
type Point2D = readonly [number, number]
|
type Point2D = readonly [number, number]
|
||||||
|
|
||||||
@@ -65,17 +65,6 @@ function polygonArea(polygon: readonly Point2D[]): number {
|
|||||||
return Math.abs(signedPolygonArea(polygon))
|
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 {
|
function polygonsDescribeSameRegion(a: readonly Point2D[], b: readonly Point2D[]): boolean {
|
||||||
if (a.length < 3 || b.length < 3) return false
|
if (a.length < 3 || b.length < 3) return false
|
||||||
|
|
||||||
@@ -90,11 +79,87 @@ function polygonsDescribeSameRegion(a: readonly Point2D[], b: readonly Point2D[]
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function polygonSurfaceArea(
|
function pointInPolygon(point: Point2D, polygon: readonly Point2D[], includeBoundary = true) {
|
||||||
polygon: readonly Point2D[],
|
if (polygon.length < 3) return false
|
||||||
holes: readonly (readonly Point2D[])[] = [],
|
if (pointToPolygonBoundaryDistance(point, polygon) <= 1e-6) return includeBoundary
|
||||||
): number {
|
|
||||||
return Math.max(0, polygonArea(polygon) - holes.reduce((sum, hole) => sum + polygonArea(hole), 0))
|
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 {
|
function pointToPolylineDistance(point: Point2D, polyline: readonly Point2D[]): number {
|
||||||
@@ -108,19 +173,19 @@ function pointToPolylineDistance(point: Point2D, polyline: readonly Point2D[]):
|
|||||||
return best
|
return best
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WallPath = { wall: WallNode; points: Point2D[] }
|
||||||
|
type BoundaryWallSpan = { wall: WallNode; length: number }
|
||||||
|
|
||||||
function wallForBoundarySegment(
|
function wallForBoundarySegment(
|
||||||
start: Point2D,
|
start: Point2D,
|
||||||
end: Point2D,
|
end: Point2D,
|
||||||
walls: readonly WallNode[],
|
wallPaths: readonly WallPath[],
|
||||||
): WallNode | null {
|
): WallNode | null {
|
||||||
const midpoint: Point2D = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2]
|
const midpoint: Point2D = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2]
|
||||||
let best: { wall: WallNode; distance: number } | null = null
|
let best: { wall: WallNode; distance: number } | null = null
|
||||||
|
|
||||||
for (const wall of walls) {
|
for (const { wall, points } of wallPaths) {
|
||||||
const centerline = sampleWallCenterline(wall, 32).map((point) => [point.x, point.y] as Point2D)
|
const distances = [start, midpoint, end].map((point) => pointToPolylineDistance(point, points))
|
||||||
const distances = [start, midpoint, end].map((point) =>
|
|
||||||
pointToPolylineDistance(point, centerline),
|
|
||||||
)
|
|
||||||
if (distances.some((distance) => distance > BOUNDARY_TOLERANCE)) continue
|
if (distances.some((distance) => distance > BOUNDARY_TOLERANCE)) continue
|
||||||
|
|
||||||
const distance = distances.reduce((sum, value) => sum + value, 0)
|
const distance = distances.reduce((sum, value) => sum + value, 0)
|
||||||
@@ -130,11 +195,105 @@ function wallForBoundarySegment(
|
|||||||
return best?.wall ?? null
|
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,
|
zone: ZoneNode,
|
||||||
nodes: readonly T[],
|
nodes: readonly T[],
|
||||||
): T[] {
|
): SurfaceCoverage<T>[] {
|
||||||
return nodes.filter((node) => polygonsDescribeSameRegion(zone.polygon, node.polygon))
|
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 {
|
function unavailable(reason: string): ZoneQuantityValue {
|
||||||
@@ -150,99 +309,84 @@ export function deriveZoneQuantityReport(
|
|||||||
? Object.values(sceneNodes).filter((node) => node.parentId === levelId)
|
? Object.values(sceneNodes).filter((node) => node.parentId === levelId)
|
||||||
: []
|
: []
|
||||||
const walls = levelNodes.filter((node): node is WallNode => node.type === 'wall')
|
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 edgeLengths = zone.polygon.map((start, index) => {
|
||||||
const end = zone.polygon[(index + 1) % zone.polygon.length]
|
const end = zone.polygon[(index + 1) % zone.polygon.length]
|
||||||
return end ? pointDistance(start, end) : 0
|
return end ? pointDistance(start, end) : 0
|
||||||
})
|
})
|
||||||
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 matchingRoom = levelId
|
zone,
|
||||||
? detectSpacesForLevel(levelId, walls).roomPolygons.find((polygon) =>
|
levelNodes.filter((node): node is SlabNode => node.type === 'slab'),
|
||||||
polygonsDescribeSameRegion(
|
)
|
||||||
zone.polygon,
|
const ceilings = coveringSurfaceNodes(
|
||||||
polygon.map((point) => [point.x, point.y]),
|
zone,
|
||||||
),
|
levelNodes.filter((node): node is CeilingNode => node.type === 'ceiling'),
|
||||||
)
|
)
|
||||||
: undefined
|
|
||||||
|
|
||||||
const wallMatches = matchingRoom?.map((start, index) => {
|
const spaces = levelId ? detectSpacesForLevel(levelId, walls).spaces : []
|
||||||
const end = matchingRoom[(index + 1) % matchingRoom.length]
|
const matchingSpace = spaces.find((space) =>
|
||||||
if (!end) return null
|
polygonsDescribeSameRegion(zone.polygon, space.polygon),
|
||||||
const tupleStart: Point2D = [start.x, start.y]
|
)
|
||||||
const tupleEnd: Point2D = [end.x, end.y]
|
const wallsById = new Map(walls.map((wall) => [wall.id, wall]))
|
||||||
const wall = wallForBoundarySegment(tupleStart, tupleEnd, walls)
|
const wallPaths = wallPathsFor(walls)
|
||||||
if (!wall) return null
|
const wallSpans =
|
||||||
return { wall, length: pointDistance(tupleStart, tupleEnd) }
|
(matchingSpace ? spansFromSpace(matchingSpace, wallsById) : null) ??
|
||||||
})
|
spansFromZoneBoundary(zone, wallPaths)
|
||||||
const allWallsProven = !!wallMatches && wallMatches.length > 0 && wallMatches.every(Boolean)
|
const allWallsProven = Boolean(wallSpans)
|
||||||
const boundaryWallIds = allWallsProven
|
const boundaryWallIds = wallSpans ? [...new Set(wallSpans.map((span) => span.wall.id))] : []
|
||||||
? [...new Set(wallMatches.map((match) => match!.wall.id))]
|
|
||||||
: []
|
|
||||||
|
|
||||||
const wallSurface = allWallsProven
|
const wallSurface = allWallsProven
|
||||||
? {
|
? {
|
||||||
status: 'available' as const,
|
status: 'available' as const,
|
||||||
value: wallMatches.reduce(
|
value: wallSpans!.reduce(
|
||||||
(sum, match) => sum + match!.length * (match!.wall.height ?? DEFAULT_WALL_HEIGHT),
|
(sum, span) => sum + span.length * (span.wall.height ?? DEFAULT_WALL_HEIGHT),
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
note: 'Gross interior wall face before openings.',
|
note: "Gross interior wall face using each boundary wall's height.",
|
||||||
}
|
}
|
||||||
: unavailable(
|
: unavailable('The zone boundary is not fully backed by walls.')
|
||||||
matchingRoom
|
|
||||||
? 'The closed boundary could not be assigned to every wall segment.'
|
|
||||||
: 'No matching closed wall loop was detected.',
|
|
||||||
)
|
|
||||||
|
|
||||||
const matchingSlab = slabs.length === 1 ? slabs[0] : undefined
|
const matchingSlab = slabs.length === 1 ? slabs[0] : undefined
|
||||||
const floorSurface = matchingSlab
|
const floorSurface =
|
||||||
|
matchingSlab && matchingSlab.area !== null
|
||||||
? {
|
? {
|
||||||
status: 'available' as const,
|
status: 'available' as const,
|
||||||
value: polygonSurfaceArea(matchingSlab.polygon, matchingSlab.holes),
|
value: matchingSlab.area,
|
||||||
note: 'Matching slab surface after openings.',
|
note: 'Zone floor surface covered by one slab, after openings.',
|
||||||
}
|
}
|
||||||
: unavailable(
|
: unavailable(
|
||||||
slabs.length > 1
|
slabs.length > 1
|
||||||
? 'More than one slab matches this boundary.'
|
? 'More than one slab covers this zone.'
|
||||||
: 'No slab matches this zone boundary.',
|
: (matchingSlab?.issue ?? 'No slab covers this zone.'),
|
||||||
)
|
)
|
||||||
|
|
||||||
const matchingCeiling = ceilings.length === 1 ? ceilings[0] : undefined
|
const matchingCeiling = ceilings.length === 1 ? ceilings[0] : undefined
|
||||||
let volume: ZoneQuantityValue
|
let volume: ZoneQuantityValue
|
||||||
if (!matchingRoom) {
|
if (!wallSpans) {
|
||||||
volume = unavailable('No matching closed wall loop was detected.')
|
volume = unavailable('The zone boundary is not fully backed by walls.')
|
||||||
} else if (!matchingSlab) {
|
} else if (!matchingSlab || matchingSlab.area === null) {
|
||||||
volume = unavailable(floorSurface.status === 'unavailable' ? floorSurface.reason : 'No floor.')
|
volume = unavailable(floorSurface.status === 'unavailable' ? floorSurface.reason : 'No floor.')
|
||||||
} else if (!matchingCeiling) {
|
} else if (!matchingCeiling || matchingCeiling.area === null) {
|
||||||
volume = unavailable(
|
volume = unavailable(
|
||||||
ceilings.length > 1
|
ceilings.length > 1
|
||||||
? 'More than one ceiling matches this boundary.'
|
? 'More than one ceiling covers this zone.'
|
||||||
: 'No ceiling matches this zone boundary.',
|
: (matchingCeiling?.issue ?? 'No ceiling covers this zone.'),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
const clearHeight = matchingCeiling.height - matchingSlab.elevation
|
const clearHeight = matchingCeiling.node.height - matchingSlab.node.elevation
|
||||||
volume =
|
volume =
|
||||||
Number.isFinite(clearHeight) && clearHeight > 0
|
Number.isFinite(clearHeight) && clearHeight > 0
|
||||||
? {
|
? {
|
||||||
status: 'available',
|
status: 'available',
|
||||||
value: polygonSurfaceArea(matchingSlab.polygon, matchingSlab.holes) * clearHeight,
|
value: matchingSlab.area * clearHeight,
|
||||||
note: 'Matching slab area multiplied by clear ceiling height.',
|
note: 'Covered 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: matchingRoom ? 'enclosed-room' : 'footprint',
|
classification: wallSpans ? 'enclosed-room' : 'footprint',
|
||||||
footprintArea,
|
footprintArea,
|
||||||
perimeter,
|
perimeter,
|
||||||
edgeLengths,
|
edgeLengths,
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ describe('measurement draft transitions', () => {
|
|||||||
}),
|
}),
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
expect(draft.finishVertexDrag('3d')).toBe(true)
|
expect(draft.finishVertexDrag('3d')).toBe(true)
|
||||||
|
expect(useMeasurementDraft.getState().points[0]).toEqual(point(0, 0, 0))
|
||||||
expect(useMeasurementDraft.getState().collectionPlane).toEqual({
|
expect(useMeasurementDraft.getState().collectionPlane).toEqual({
|
||||||
point: point(0, 0, 0),
|
point: point(0, 0, 0),
|
||||||
normal: point(0, 1, 0),
|
normal: point(0, 1, 0),
|
||||||
@@ -208,6 +209,34 @@ describe('measurement draft transitions', () => {
|
|||||||
expect(useMeasurementDraft.getState().collectionPlane).toBeNull()
|
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', () => {
|
test('closes a volume base before accepting explicit extrusion', () => {
|
||||||
const draft = useMeasurementDraft.getState()
|
const draft = useMeasurementDraft.getState()
|
||||||
draft.setKind('volume')
|
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
|
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(
|
export function measurementPolygonMidpoints(
|
||||||
points: readonly MeasurementPoint[],
|
points: readonly MeasurementPoint[],
|
||||||
): Array<{ edgeIndex: number; point: MeasurementPoint }> {
|
): Array<{ edgeIndex: number; point: MeasurementPoint }> {
|
||||||
@@ -277,17 +300,18 @@ export const useMeasurementDraft = create<MeasurementDraftState>((set, get) => (
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const projectedPoint = projectPointToPlane(hover.point, state.collectionPlane)
|
||||||
const points = state.points.map((point, index) =>
|
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) =>
|
const anchors = state.anchors.map((anchor, index) =>
|
||||||
index === drag.index ? (hover.anchor ?? null) : anchor,
|
index === drag.index ? anchorWithFallback(hover.anchor, projectedPoint) : anchor,
|
||||||
)
|
)
|
||||||
set({
|
set({
|
||||||
points,
|
points,
|
||||||
anchors,
|
anchors,
|
||||||
hover: {
|
hover: {
|
||||||
point: clonePoint(hover.point),
|
point: clonePoint(projectedPoint),
|
||||||
normal: clonePoint(hover.normal),
|
normal: clonePoint(hover.normal),
|
||||||
targetNodeId: hover.targetNodeId,
|
targetNodeId: hover.targetNodeId,
|
||||||
anchor: hover.anchor,
|
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 === 'distance' && state.points.length >= 2) return false
|
||||||
if (state.kind === 'angle' && state.points.length >= 3) 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 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 planeNormal = polygon && state.points.length === 0 ? normalizePoint(surfaceNormal) : null
|
||||||
const ready =
|
const ready =
|
||||||
(state.kind === 'distance' && points.length === 2) ||
|
(state.kind === 'distance' && points.length === 2) ||
|
||||||
@@ -365,7 +392,7 @@ export const useMeasurementDraft = create<MeasurementDraftState>((set, get) => (
|
|||||||
points,
|
points,
|
||||||
anchors,
|
anchors,
|
||||||
collectionPlane: planeNormal
|
collectionPlane: planeNormal
|
||||||
? { point: clonePoint(point), normal: planeNormal }
|
? { point: clonePoint(projectedPoint), normal: planeNormal }
|
||||||
: state.collectionPlane,
|
: state.collectionPlane,
|
||||||
stage: ready ? 'ready' : 'collecting',
|
stage: ready ? 'ready' : 'collecting',
|
||||||
hover: null,
|
hover: null,
|
||||||
|
|||||||
@@ -185,4 +185,61 @@ describe('polygon measurement surface intent', () => {
|
|||||||
tableTop.geometry.dispose()
|
tableTop.geometry.dispose()
|
||||||
material.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
|
const nearest = hits[0] ?? null
|
||||||
if (!(nearest && preference)) return nearest
|
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 (preference.kind === 'horizontal') {
|
||||||
if (
|
if (
|
||||||
Math.abs(toLocalSurfaceHit(nearest, levelObject).normal[1]) >=
|
Math.abs(toLocalSurfaceHit(nearest, levelObject).normal[1]) >=
|
||||||
@@ -443,6 +438,11 @@ export function selectMeasurementSurfaceHit(
|
|||||||
return nearest
|
return nearest
|
||||||
}
|
}
|
||||||
const nodes = useScene.getState().nodes as Record<string, { type: string } | undefined>
|
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 (
|
return (
|
||||||
nearby.find((hit) => {
|
nearby.find((hit) => {
|
||||||
const type = hit.targetNodeId ? nodes[hit.targetNodeId]?.type : undefined
|
const type = hit.targetNodeId ? nodes[hit.targetNodeId]?.type : undefined
|
||||||
@@ -455,11 +455,11 @@ export function selectMeasurementSurfaceHit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const preferredNormal = new Vector3(...preference.normal)
|
const preferredNormal = new Vector3(...preference.normal)
|
||||||
if (preferredNormal.lengthSq() <= 1e-12) return nearest
|
if (preferredNormal.lengthSq() <= 1e-12) return null
|
||||||
preferredNormal.normalize()
|
preferredNormal.normalize()
|
||||||
const preferredPoint = new Vector3(...preference.point)
|
const preferredPoint = new Vector3(...preference.point)
|
||||||
return (
|
return (
|
||||||
nearby.find((hit) => {
|
hits.find((hit) => {
|
||||||
const localHit = toLocalSurfaceHit(hit, levelObject)
|
const localHit = toLocalSurfaceHit(hit, levelObject)
|
||||||
const normalAlignment = Math.abs(preferredNormal.dot(new Vector3(...localHit.normal)))
|
const normalAlignment = Math.abs(preferredNormal.dot(new Vector3(...localHit.normal)))
|
||||||
const planeDistance = Math.abs(
|
const planeDistance = Math.abs(
|
||||||
@@ -469,7 +469,7 @@ export function selectMeasurementSurfaceHit(
|
|||||||
normalAlignment >= SURFACE_INTENT_MIN_NORMAL_ALIGNMENT &&
|
normalAlignment >= SURFACE_INTENT_MIN_NORMAL_ALIGNMENT &&
|
||||||
planeDistance <= SURFACE_INTENT_PLANE_TOLERANCE
|
planeDistance <= SURFACE_INTENT_PLANE_TOLERANCE
|
||||||
)
|
)
|
||||||
}) ?? nearest
|
}) ?? null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
isMeasurementSurfaceMaterialVisible,
|
isMeasurementSurfaceMaterialVisible,
|
||||||
localNormalToPreviewFrame,
|
localNormalToPreviewFrame,
|
||||||
measurementIntersectionWorldNormal,
|
measurementIntersectionWorldNormal,
|
||||||
|
measurementPolygonSurfacePreference,
|
||||||
measurementVertexSnapAnchors,
|
measurementVertexSnapAnchors,
|
||||||
parseMeasurementExtrusionHeight,
|
parseMeasurementExtrusionHeight,
|
||||||
projectMeasurementPointToAxes,
|
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', () => {
|
describe('measurement draft vertex affordances', () => {
|
||||||
test('selects the closest handle inside its screen threshold', () => {
|
test('selects the closest handle inside its screen threshold', () => {
|
||||||
expect(selectClosestMeasurementVertexIndex([18, 7, 10])).toBe(1)
|
expect(selectClosestMeasurementVertexIndex([18, 7, 10])).toBe(1)
|
||||||
|
|||||||
@@ -288,14 +288,17 @@ function isMeasurementKind(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function polygonSurfacePreference(
|
export function measurementPolygonSurfacePreference(
|
||||||
kind: MeasurementKind,
|
kind: MeasurementKind,
|
||||||
plane: { point: MeasurementPoint; normal: MeasurementPoint } | null,
|
plane: { point: MeasurementPoint; normal: MeasurementPoint } | null,
|
||||||
|
applyMagneticSnap: boolean,
|
||||||
): MeasurementSurfacePreference | null {
|
): MeasurementSurfacePreference | null {
|
||||||
if (kind !== 'area' && kind !== 'perimeter' && kind !== 'volume') return null
|
if (kind !== 'area' && kind !== 'perimeter' && kind !== 'volume') return null
|
||||||
return plane
|
return plane
|
||||||
? { kind: 'plane', point: plane.point, normal: plane.normal }
|
? { kind: 'plane', point: plane.point, normal: plane.normal }
|
||||||
: { kind: 'horizontal' }
|
: applyMagneticSnap
|
||||||
|
? { kind: 'horizontal' }
|
||||||
|
: null
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEffectivelyVisible(object: Object3D): boolean {
|
function isEffectivelyVisible(object: Object3D): boolean {
|
||||||
@@ -1952,9 +1955,11 @@ export const MeasurementTool: FC = () => {
|
|||||||
lockedGuide:
|
lockedGuide:
|
||||||
applyMagneticSnap && activeDraft.axisGuide?.snapped ? activeDraft.axisGuide : null,
|
applyMagneticSnap && activeDraft.axisGuide?.snapped ? activeDraft.axisGuide : null,
|
||||||
planarProximityAnchors: getPlanarProximityAnchors(),
|
planarProximityAnchors: getPlanarProximityAnchors(),
|
||||||
surfacePreference: applyMagneticSnap
|
surfacePreference: measurementPolygonSurfacePreference(
|
||||||
? polygonSurfacePreference(activeDraft.kind, activeDraft.collectionPlane)
|
activeDraft.kind,
|
||||||
: null,
|
activeDraft.collectionPlane,
|
||||||
|
applyMagneticSnap,
|
||||||
|
),
|
||||||
applyMagneticSnap,
|
applyMagneticSnap,
|
||||||
showAlignmentGuides: isAlignmentGuideActive(),
|
showAlignmentGuides: isAlignmentGuideActive(),
|
||||||
})
|
})
|
||||||
@@ -2003,9 +2008,11 @@ export const MeasurementTool: FC = () => {
|
|||||||
anchorOrAnchors: draft.points[draft.points.length - 1] ?? null,
|
anchorOrAnchors: draft.points[draft.points.length - 1] ?? null,
|
||||||
lockedGuide: applyMagneticSnap && draft.axisGuide?.snapped ? draft.axisGuide : null,
|
lockedGuide: applyMagneticSnap && draft.axisGuide?.snapped ? draft.axisGuide : null,
|
||||||
planarProximityAnchors: getPlanarProximityAnchors(),
|
planarProximityAnchors: getPlanarProximityAnchors(),
|
||||||
surfacePreference: applyMagneticSnap
|
surfacePreference: measurementPolygonSurfacePreference(
|
||||||
? polygonSurfacePreference(draft.kind, draft.collectionPlane)
|
draft.kind,
|
||||||
: null,
|
draft.collectionPlane,
|
||||||
|
applyMagneticSnap,
|
||||||
|
),
|
||||||
applyMagneticSnap,
|
applyMagneticSnap,
|
||||||
showAlignmentGuides: isAlignmentGuideActive(),
|
showAlignmentGuides: isAlignmentGuideActive(),
|
||||||
})
|
})
|
||||||
@@ -2089,9 +2096,11 @@ export const MeasurementTool: FC = () => {
|
|||||||
anchorOrAnchors: draft.points[draft.points.length - 1] ?? null,
|
anchorOrAnchors: draft.points[draft.points.length - 1] ?? null,
|
||||||
lockedGuide: applyMagneticSnap && draft.axisGuide?.snapped ? draft.axisGuide : null,
|
lockedGuide: applyMagneticSnap && draft.axisGuide?.snapped ? draft.axisGuide : null,
|
||||||
planarProximityAnchors: getPlanarProximityAnchors(),
|
planarProximityAnchors: getPlanarProximityAnchors(),
|
||||||
surfacePreference: applyMagneticSnap
|
surfacePreference: measurementPolygonSurfacePreference(
|
||||||
? polygonSurfacePreference(draft.kind, draft.collectionPlane)
|
draft.kind,
|
||||||
: null,
|
draft.collectionPlane,
|
||||||
|
applyMagneticSnap,
|
||||||
|
),
|
||||||
applyMagneticSnap,
|
applyMagneticSnap,
|
||||||
showAlignmentGuides: isAlignmentGuideActive(),
|
showAlignmentGuides: isAlignmentGuideActive(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ 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 a matching detected wall loop; gross wall face surface comes only from that boundary. Floor surface requires exactly one matching slab and subtracts its holes. Volume additionally requires one matching 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 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.
|
||||||
|
|
||||||
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 opening subtraction, retained partial wall spans, 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.
|
||||||
|
|
||||||
An exact room-footprint zone may be procedural. Space detection retains the wall IDs traversed by the proving half-edge cycle; the zone stores those IDs in `boundaryWallIds` with `autoFromWalls`. Its 2D/3D geometry, Smart report, semantic features, and quantity report resolve the current polygon from those effective walls, including per-wall live overrides. Pointer movement does not write the scene. The wall commit refreshes the stored polygon as a fallback while scene history is paused. Moving or editing the zone itself clears the association, so a deliberate manual boundary cannot be snapped back by room reconciliation. Site/lawn zones that do not exactly match a detected enclosure remain manual.
|
An exact room-footprint zone may be procedural. Space detection retains the wall IDs traversed by the proving half-edge cycle; the zone stores those IDs in `boundaryWallIds` with `autoFromWalls`. Its 2D/3D geometry, Smart report, semantic features, and quantity report resolve the current polygon from those effective walls, including per-wall live overrides. Pointer movement does not write the scene. The wall commit refreshes the stored polygon as a fallback while scene history is paused. Moving or editing the zone itself clears the association, so a deliberate manual boundary cannot be snapped back by room reconciliation. Site/lawn zones that do not exactly match a detected enclosure remain manual.
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ Measurement snapping is always magnetic. The construction snapping-mode chip (`g
|
|||||||
|
|
||||||
The registered 3D tool raycasts visible geometry under registered scene roots and converts the winning world-space point and normal into the active level frame. A system-rendered mesh outside those roots must opt in with `userData.measurementSurface = true`; this is the contract used by collective instanced plant meshes. An editor-helper root nested under registered geometry must opt out with `userData.measurementSurface = false`, which is inherited by its descendants. Measurement activation clears object selection, and measurement/guide/scan nodes, invisible objects, zero-opacity or non-depth-tested materials, and `colorWrite: false` colliders are excluded. Instanced hits must include the intersected instance matrix when their normals are transformed.
|
The registered 3D tool raycasts visible geometry under registered scene roots and converts the winning world-space point and normal into the active level frame. A system-rendered mesh outside those roots must opt in with `userData.measurementSurface = true`; this is the contract used by collective instanced plant meshes. An editor-helper root nested under registered geometry must opt out with `userData.measurementSurface = false`, which is inherited by its descendants. Measurement activation clears object selection, and measurement/guide/scan nodes, invisible objects, zero-opacity or non-depth-tested materials, and `colorWrite: false` colliders are excluded. Instanced hits must include the intersected instance matrix when their normals are transformed.
|
||||||
|
|
||||||
Area, perimeter, and volume drafting treat the first surface as intent. Before the first point, a horizontal slab, ceiling, or site surface may outrank a wall that occludes it by no more than 0.45 metres along the pointer ray, which makes floor corners stable without turning a mid-wall hover into a floor pick. The first committed contact then supplies a preferred plane; nearby hits with a matching normal and plane outrank incidental wall edges for the rest of that polygon. Holding Alt bypasses this preference and restores the raw nearest visible surface. Distance and angle remain nearest-surface tools.
|
Area, perimeter, and volume drafting treat the first surface as a hard reference plane. Before the first point, a horizontal slab, ceiling, or site surface may outrank a wall that occludes it by no more than 0.45 metres along the pointer ray, which makes floor corners stable without turning a mid-wall hover into a floor pick. The first committed contact then supplies the plane for every later vertex and draft edit. Pointer queries accept only hits on that plane, even through a nearer occluder; when the pointer ray has no matching surface hit, no candidate is offered. Alt still releases magnetic axes and feature attraction, but never releases the captured polygon plane. The shared draft store projects accepted points and feature fallbacks onto the plane as a final invariant. Distance and angle remain nearest-surface tools.
|
||||||
|
|
||||||
Axis assistance starts from the previous vertex. X, Y, or Z becomes a snap only when the projected candidate is verified on the hit surface within the screen-space threshold. Otherwise the raw surface hit remains authoritative and the nearest axis is shown as a passive guide. A magnetic lock enters at 16 screen pixels and retains the same axis and anchor until 24 pixels; it must release immediately if that candidate no longer verifies on the surface.
|
Axis assistance starts from the previous vertex. X, Y, or Z becomes a snap only when the projected candidate is verified on the hit surface within the screen-space threshold. Otherwise the raw surface hit remains authoritative and the nearest axis is shown as a passive guide. A magnetic lock enters at 16 screen pixels and retains the same axis and anchor until 24 pixels; it must release immediately if that candidate no longer verifies on the surface.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user