diff --git a/packages/core/src/lib/zone-quantities.test.ts b/packages/core/src/lib/zone-quantities.test.ts index 22acbad6..d3a1a33f 100644 --- a/packages/core/src/lib/zone-quantities.test.ts +++ b/packages/core/src/lib/zone-quantities.test.ts @@ -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) + } + }) }) diff --git a/packages/core/src/lib/zone-quantities.ts b/packages/core/src/lib/zone-quantities.ts index ba7677a1..3ac00a28 100644 --- a/packages/core/src/lib/zone-quantities.ts +++ b/packages/core/src/lib/zone-quantities.ts @@ -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( +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) { + 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 = { + node: T + area: number | null + issue?: string +} + +function coveringSurfaceNodes( zone: ZoneNode, nodes: readonly T[], -): T[] { - return nodes.filter((node) => polygonsDescribeSameRegion(zone.polygon, node.polygon)) +): SurfaceCoverage[] { + const surfaces: SurfaceCoverage[] = [] + for (const node of nodes) { + if (!polygonContainsRegion(node.polygon, zone.polygon)) continue + let area = polygonArea(zone.polygon) + let issue: string | undefined + 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, diff --git a/packages/editor/src/store/use-measurement-draft.test.ts b/packages/editor/src/store/use-measurement-draft.test.ts index 5a8c5b76..3c248b20 100644 --- a/packages/editor/src/store/use-measurement-draft.test.ts +++ b/packages/editor/src/store/use-measurement-draft.test.ts @@ -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') diff --git a/packages/editor/src/store/use-measurement-draft.ts b/packages/editor/src/store/use-measurement-draft.ts index 0a4a51a0..4a00e844 100644 --- a/packages/editor/src/store/use-measurement-draft.ts +++ b/packages/editor/src/store/use-measurement-draft.ts @@ -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((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((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((set, get) => ( points, anchors, collectionPlane: planeNormal - ? { point: clonePoint(point), normal: planeNormal } + ? { point: clonePoint(projectedPoint), normal: planeNormal } : state.collectionPlane, stage: ready ? 'ready' : 'collecting', hover: null, diff --git a/packages/nodes/src/measurement/surface-query.test.ts b/packages/nodes/src/measurement/surface-query.test.ts index 8047fc06..098dba99 100644 --- a/packages/nodes/src/measurement/surface-query.test.ts +++ b/packages/nodes/src/measurement/surface-query.test.ts @@ -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() + }) }) diff --git a/packages/nodes/src/measurement/surface-query.ts b/packages/nodes/src/measurement/surface-query.ts index ac9319fb..a101bf2c 100644 --- a/packages/nodes/src/measurement/surface-query.ts +++ b/packages/nodes/src/measurement/surface-query.ts @@ -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 + 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 ) } diff --git a/packages/nodes/src/measurement/tool.test.ts b/packages/nodes/src/measurement/tool.test.ts index 57eab8af..347d74cc 100644 --- a/packages/nodes/src/measurement/tool.test.ts +++ b/packages/nodes/src/measurement/tool.test.ts @@ -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) diff --git a/packages/nodes/src/measurement/tool.tsx b/packages/nodes/src/measurement/tool.tsx index 5aaa5791..789f7cf6 100644 --- a/packages/nodes/src/measurement/tool.tsx +++ b/packages/nodes/src/measurement/tool.tsx @@ -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(), }) diff --git a/wiki/architecture/measurements.md b/wiki/architecture/measurements.md index 380ce43d..9b70b5e1 100644 --- a/wiki/architecture/measurements.md +++ b/wiki/architecture/measurements.md @@ -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. -`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. @@ -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. -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.