fix: harden measurement geometry (#507)

This commit is contained in:
Aymeric Rabot
2026-07-18 09:44:34 +02:00
committed by GitHub
parent 99cc537e25
commit cddb73b232
9 changed files with 592 additions and 117 deletions
+194 -3
View File
@@ -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)
}
})
})
+230 -86
View File
@@ -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)
+20 -11
View File
@@ -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(),
})