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
@@ -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(),
})