fix: harden measurement geometry (#507)
This commit is contained in:
@@ -185,4 +185,61 @@ describe('polygon measurement surface intent', () => {
|
||||
tableTop.geometry.dispose()
|
||||
material.dispose()
|
||||
})
|
||||
|
||||
test('keeps later polygon points on the first plane through a distant occluder', () => {
|
||||
const level = new Group()
|
||||
const material = new MeshBasicMaterial({ side: DoubleSide })
|
||||
const floor = new Mesh(new PlaneGeometry(8, 8), material)
|
||||
const table = new Mesh(new PlaneGeometry(8, 8), material)
|
||||
floor.rotation.x = -Math.PI / 2
|
||||
table.rotation.x = -Math.PI / 2
|
||||
table.position.y = 0.8
|
||||
level.add(floor, table)
|
||||
level.updateMatrixWorld(true)
|
||||
|
||||
const hits = new Raycaster(new Vector3(0, 2, 0), new Vector3(0, -1, 0))
|
||||
.intersectObjects([floor, table])
|
||||
.map((intersection) => ({
|
||||
intersection,
|
||||
targetNodeId: intersection.object === floor ? 'slab_1' : 'item_1',
|
||||
}))
|
||||
|
||||
expect(hits[0]?.targetNodeId).toBe('item_1')
|
||||
expect(
|
||||
selectMeasurementSurfaceHit(hits, level, {
|
||||
kind: 'plane',
|
||||
point: [0, 0, 0],
|
||||
normal: [0, 1, 0],
|
||||
})?.targetNodeId,
|
||||
).toBe('slab_1')
|
||||
|
||||
floor.geometry.dispose()
|
||||
table.geometry.dispose()
|
||||
material.dispose()
|
||||
})
|
||||
|
||||
test('returns no candidate instead of falling through to a different plane', () => {
|
||||
const level = new Group()
|
||||
const material = new MeshBasicMaterial({ side: DoubleSide })
|
||||
const table = new Mesh(new PlaneGeometry(8, 8), material)
|
||||
table.rotation.x = -Math.PI / 2
|
||||
table.position.y = 0.8
|
||||
level.add(table)
|
||||
level.updateMatrixWorld(true)
|
||||
|
||||
const hits = new Raycaster(new Vector3(0, 2, 0), new Vector3(0, -1, 0))
|
||||
.intersectObject(table)
|
||||
.map((intersection) => ({ intersection, targetNodeId: 'item_1' }))
|
||||
|
||||
expect(
|
||||
selectMeasurementSurfaceHit(hits, level, {
|
||||
kind: 'plane',
|
||||
point: [0, 0, 0],
|
||||
normal: [0, 1, 0],
|
||||
}),
|
||||
).toBeNull()
|
||||
|
||||
table.geometry.dispose()
|
||||
material.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -430,11 +430,6 @@ export function selectMeasurementSurfaceHit(
|
||||
const nearest = hits[0] ?? null
|
||||
if (!(nearest && preference)) return nearest
|
||||
|
||||
const nearby = hits.filter(
|
||||
(hit) =>
|
||||
hit.intersection.distance <=
|
||||
nearest.intersection.distance + SURFACE_INTENT_MAX_OCCLUSION_DISTANCE,
|
||||
)
|
||||
if (preference.kind === 'horizontal') {
|
||||
if (
|
||||
Math.abs(toLocalSurfaceHit(nearest, levelObject).normal[1]) >=
|
||||
@@ -443,6 +438,11 @@ export function selectMeasurementSurfaceHit(
|
||||
return nearest
|
||||
}
|
||||
const nodes = useScene.getState().nodes as Record<string, { type: string } | undefined>
|
||||
const nearby = hits.filter(
|
||||
(hit) =>
|
||||
hit.intersection.distance <=
|
||||
nearest.intersection.distance + SURFACE_INTENT_MAX_OCCLUSION_DISTANCE,
|
||||
)
|
||||
return (
|
||||
nearby.find((hit) => {
|
||||
const type = hit.targetNodeId ? nodes[hit.targetNodeId]?.type : undefined
|
||||
@@ -455,11 +455,11 @@ export function selectMeasurementSurfaceHit(
|
||||
}
|
||||
|
||||
const preferredNormal = new Vector3(...preference.normal)
|
||||
if (preferredNormal.lengthSq() <= 1e-12) return nearest
|
||||
if (preferredNormal.lengthSq() <= 1e-12) return null
|
||||
preferredNormal.normalize()
|
||||
const preferredPoint = new Vector3(...preference.point)
|
||||
return (
|
||||
nearby.find((hit) => {
|
||||
hits.find((hit) => {
|
||||
const localHit = toLocalSurfaceHit(hit, levelObject)
|
||||
const normalAlignment = Math.abs(preferredNormal.dot(new Vector3(...localHit.normal)))
|
||||
const planeDistance = Math.abs(
|
||||
@@ -469,7 +469,7 @@ export function selectMeasurementSurfaceHit(
|
||||
normalAlignment >= SURFACE_INTENT_MIN_NORMAL_ALIGNMENT &&
|
||||
planeDistance <= SURFACE_INTENT_PLANE_TOLERANCE
|
||||
)
|
||||
}) ?? nearest
|
||||
}) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
isMeasurementSurfaceMaterialVisible,
|
||||
localNormalToPreviewFrame,
|
||||
measurementIntersectionWorldNormal,
|
||||
measurementPolygonSurfacePreference,
|
||||
measurementVertexSnapAnchors,
|
||||
parseMeasurementExtrusionHeight,
|
||||
projectMeasurementPointToAxes,
|
||||
@@ -526,6 +527,23 @@ describe('measurement axis projection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('polygon measurement plane locking', () => {
|
||||
test('keeps the captured plane when magnetic snapping is bypassed', () => {
|
||||
const plane = { point: [0, 1, 0], normal: [0, 1, 0] } as const
|
||||
|
||||
expect(measurementPolygonSurfacePreference('area', plane, false)).toEqual({
|
||||
kind: 'plane',
|
||||
point: plane.point,
|
||||
normal: plane.normal,
|
||||
})
|
||||
expect(measurementPolygonSurfacePreference('area', null, false)).toBeNull()
|
||||
expect(measurementPolygonSurfacePreference('area', null, true)).toEqual({
|
||||
kind: 'horizontal',
|
||||
})
|
||||
expect(measurementPolygonSurfacePreference('distance', plane, true)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('measurement draft vertex affordances', () => {
|
||||
test('selects the closest handle inside its screen threshold', () => {
|
||||
expect(selectClosestMeasurementVertexIndex([18, 7, 10])).toBe(1)
|
||||
|
||||
@@ -288,14 +288,17 @@ function isMeasurementKind(
|
||||
)
|
||||
}
|
||||
|
||||
function polygonSurfacePreference(
|
||||
export function measurementPolygonSurfacePreference(
|
||||
kind: MeasurementKind,
|
||||
plane: { point: MeasurementPoint; normal: MeasurementPoint } | null,
|
||||
applyMagneticSnap: boolean,
|
||||
): MeasurementSurfacePreference | null {
|
||||
if (kind !== 'area' && kind !== 'perimeter' && kind !== 'volume') return null
|
||||
return plane
|
||||
? { kind: 'plane', point: plane.point, normal: plane.normal }
|
||||
: { kind: 'horizontal' }
|
||||
: applyMagneticSnap
|
||||
? { kind: 'horizontal' }
|
||||
: null
|
||||
}
|
||||
|
||||
function isEffectivelyVisible(object: Object3D): boolean {
|
||||
@@ -1952,9 +1955,11 @@ export const MeasurementTool: FC = () => {
|
||||
lockedGuide:
|
||||
applyMagneticSnap && activeDraft.axisGuide?.snapped ? activeDraft.axisGuide : null,
|
||||
planarProximityAnchors: getPlanarProximityAnchors(),
|
||||
surfacePreference: applyMagneticSnap
|
||||
? polygonSurfacePreference(activeDraft.kind, activeDraft.collectionPlane)
|
||||
: null,
|
||||
surfacePreference: measurementPolygonSurfacePreference(
|
||||
activeDraft.kind,
|
||||
activeDraft.collectionPlane,
|
||||
applyMagneticSnap,
|
||||
),
|
||||
applyMagneticSnap,
|
||||
showAlignmentGuides: isAlignmentGuideActive(),
|
||||
})
|
||||
@@ -2003,9 +2008,11 @@ export const MeasurementTool: FC = () => {
|
||||
anchorOrAnchors: draft.points[draft.points.length - 1] ?? null,
|
||||
lockedGuide: applyMagneticSnap && draft.axisGuide?.snapped ? draft.axisGuide : null,
|
||||
planarProximityAnchors: getPlanarProximityAnchors(),
|
||||
surfacePreference: applyMagneticSnap
|
||||
? polygonSurfacePreference(draft.kind, draft.collectionPlane)
|
||||
: null,
|
||||
surfacePreference: measurementPolygonSurfacePreference(
|
||||
draft.kind,
|
||||
draft.collectionPlane,
|
||||
applyMagneticSnap,
|
||||
),
|
||||
applyMagneticSnap,
|
||||
showAlignmentGuides: isAlignmentGuideActive(),
|
||||
})
|
||||
@@ -2089,9 +2096,11 @@ export const MeasurementTool: FC = () => {
|
||||
anchorOrAnchors: draft.points[draft.points.length - 1] ?? null,
|
||||
lockedGuide: applyMagneticSnap && draft.axisGuide?.snapped ? draft.axisGuide : null,
|
||||
planarProximityAnchors: getPlanarProximityAnchors(),
|
||||
surfacePreference: applyMagneticSnap
|
||||
? polygonSurfacePreference(draft.kind, draft.collectionPlane)
|
||||
: null,
|
||||
surfacePreference: measurementPolygonSurfacePreference(
|
||||
draft.kind,
|
||||
draft.collectionPlane,
|
||||
applyMagneticSnap,
|
||||
),
|
||||
applyMagneticSnap,
|
||||
showAlignmentGuides: isAlignmentGuideActive(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user