feat: add production measurement tools (#505)

* feat: add persistent measurement tools

* feat: make measurements associative

* fix: finish measurements with Escape

* feat: improve measurement snapping guides

* feat: clarify measurement axis feedback

* feat: smart measure lens, zone reports, and direct measurement editing

- Smart measurement lens: registry-owned wall/slab/zone hover reports with a
  single top-center HUD, click-to-pin, latest-event back pressure, and no
  scene writes
- Conservative derived zone quantities (footprint, perimeter, proven
  enclosure, gross wall/floor surface, flat-room volume) with the
  selected-zone blueprint panel
- Direct editing of committed measurements via selected-only 2D/3D vertex
  affordances with midpoint insertion, cancellation, and one-write history
- Shared measurement surface-query session; 2D tracing joins the
  slab/ceiling magnetic pipeline with registered-corner snapping
- Angle arcs on the smaller angle, indigo active/black resting hierarchy,
  screen-sized normal-aligned contact rings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: make measurement snapping always magnetic

Measurement drafting and committed-edit paths gated wall, semantic, and
axis magnetism on isMagneticSnapActive(), which is only true in the
'lines' snapping mode — in the default 'grid' mode corners and wall
intersections barely attracted (3D association fell to the 0.012 m
verify tolerance, 2D wall radii to the 0.05 m connect stick).

Measurement is an analysis tool whose anchors exist to bind real
geometry, so its snapping no longer consults the construction
snapping-mode chip: 2D/3D drafting and committed vertex edits are always
magnetic, Alt is the temporary bypass in both views (releasing the axis
pull, wall magnetism, and the 2D projected-geometry pull, and shrinking
association to contact tolerance). A discrete 2D wall snap (endpoint /
midpoint / crossing) now outranks the locked axis pull, and committed 2D
edits route the fallback through the raw pointer so free drags no longer
quantize to the construction grid. Volume extrusion height keeps its
mode-driven grid quantize.

Codex adversarial review confirmed the diagnosis and plumbing; its 2D
Alt-depth and grid-quantize findings are applied. New
surface-plan-snap tests pin the magnetic override seam.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(measurement): stabilize area surface intent

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-07-17 19:01:01 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 22c9472066
commit ae87ca5475
137 changed files with 15492 additions and 330 deletions
@@ -21,8 +21,10 @@ const DRAW_DISABLED =
).has('draw')
const FrameLimiter: React.FC<FrameLimiterProps> = ({ fps = 50 }) => {
const { advance, set, frameloop: initFrameloop, scene, clock } = useThree()
const { advance, set, frameloop: initFrameloop } = useThree()
const renderer = useThree((state) => state.gl)
const size = useThree((state) => state.size)
const dpr = useThree((state) => state.viewport.dpr)
// Fully covered canvas (e.g. studio gallery) → stop advancing frames
const renderPaused = useViewer((s) => s.renderPaused)
@@ -33,9 +35,15 @@ const FrameLimiter: React.FC<FrameLimiterProps> = ({ fps = 50 }) => {
let i = 0
let raf: number | null = null
let timer: ReturnType<typeof setInterval> | null = null
let sizeSynced = false
const interval = 1000 / fps
function tick(t: DOMHighResTimeStamp) {
raf = requestAnimationFrame(tick)
if (!sizeSynced) {
renderer.setPixelRatio(dpr)
renderer.setSize(size.width, size.height, false)
sizeSynced = true
}
elapsed = t - then
if (elapsed > interval) {
advance(i)
@@ -64,7 +72,7 @@ const FrameLimiter: React.FC<FrameLimiterProps> = ({ fps = 50 }) => {
}
set({ frameloop: initFrameloop })
}
}, [fps, advance, set, initFrameloop, renderPaused])
}, [advance, dpr, fps, initFrameloop, renderPaused, renderer, set, size.height, size.width])
return null
}
@@ -289,6 +289,7 @@ function SceneReadyTracker({
sceneReadyKey?: string | number | null
sceneReadyMaxWaitMs?: number
}) {
const invalidate = useThree((state) => state.invalidate)
const readyRef = useRef(false)
const settledFramesRef = useRef(0)
const waitedFramesRef = useRef(0)
@@ -306,7 +307,8 @@ function SceneReadyTracker({
waitedFramesRef.current = 0
waitStartRef.current = null
onSceneReadyChangeRef.current?.(false)
}, [sceneReadyKey])
invalidate()
}, [invalidate, sceneReadyKey])
useFrame(() => {
if (!(onSceneReadyChangeRef.current && !readyRef.current)) return
@@ -323,11 +325,15 @@ function SceneReadyTracker({
: waitedFramesRef.current >= SCENE_READY_MAX_WAIT_FRAMES
if (!capReached && (!hasCommittedSceneRoot() || hasPendingSceneBuildWork())) {
settledFramesRef.current = 0
invalidate()
return
}
settledFramesRef.current += 1
if (settledFramesRef.current < SCENE_READY_SETTLED_FRAMES) return
if (settledFramesRef.current < SCENE_READY_SETTLED_FRAMES) {
invalidate()
return
}
readyRef.current = true
onSceneReadyChangeRef.current(true)
@@ -712,8 +712,11 @@ const PostProcessingPasses = ({
!renderPipelineRef.current
) {
try {
if ((renderer as any).setClearAlpha) {
;(renderer as any).setClearAlpha(transparentBackground ? 0 : 1)
const clearAlpha = transparentBackground ? 0 : 1
if ((renderer as any).setClearColor) {
;(renderer as any).setClearColor(bgCurrent.current, clearAlpha)
} else if ((renderer as any).setClearAlpha) {
;(renderer as any).setClearAlpha(clearAlpha)
}
const submittedAt = PERF_OVERLAY_ENABLED ? performance.now() : 0
;(renderer as any).render(scene, camera)
@@ -0,0 +1,16 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
// include Bun ambient types in its production declaration build.
import { describe, expect, test } from 'bun:test'
import { Mesh } from 'three'
import { isSceneBvhExcluded } from './scene-bvh'
describe('scene BVH exclusions', () => {
test('keeps annotation meshes on their normal raycast path', () => {
expect(isSceneBvhExcluded(new Mesh())).toBe(false)
const annotation = new Mesh()
annotation.userData.excludeFromBvh = true
expect(isSceneBvhExcluded(annotation)).toBe(true)
})
})
@@ -24,6 +24,8 @@ type SceneBvhProps = {
const isMesh = (object: unknown): object is Mesh =>
!!object && typeof object === 'object' && (object as Mesh).isMesh === true
export const isSceneBvhExcluded = (object: Mesh) => object.userData.excludeFromBvh === true
const hasBvhCompatibleGeometry = (geometry?: BufferGeometry | null) => {
if (!geometry) return false
@@ -73,6 +75,7 @@ export const SceneBvh = forwardRef<Group, SceneBvhProps>(
group.traverse((child) => {
if (!isMesh(child)) return
if (isSceneBvhExcluded(child)) return
if (child.raycast === Mesh.prototype.raycast) {
child.raycast = acceleratedRaycast
@@ -0,0 +1,51 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
// include Bun ambient types in its production declaration build.
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import useViewer from './use-viewer'
const resetMeasurementPreferences = () => {
useViewer.setState({
projectId: null,
projectPreferences: {},
showMeasurements: true,
unit: 'metric',
})
}
beforeEach(resetMeasurementPreferences)
afterEach(resetMeasurementPreferences)
describe('measurement display preferences', () => {
test('stores global visibility per project and defaults new projects to visible', () => {
const viewer = useViewer.getState()
viewer.setProjectId('project-a')
viewer.setShowMeasurements(false)
expect(useViewer.getState()).toMatchObject({
projectId: 'project-a',
showMeasurements: false,
projectPreferences: {
'project-a': { showMeasurements: false },
},
})
useViewer.getState().setProjectId('project-b')
expect(useViewer.getState().showMeasurements).toBe(true)
useViewer.getState().setProjectId('project-a')
expect(useViewer.getState().showMeasurements).toBe(false)
})
test('changes display units without changing measurement visibility preferences', () => {
const viewer = useViewer.getState()
viewer.setProjectId('project-a')
viewer.setShowMeasurements(false)
const preferences = useViewer.getState().projectPreferences
useViewer.getState().setUnit('imperial')
expect(useViewer.getState().unit).toBe('imperial')
expect(useViewer.getState().projectPreferences).toEqual(preferences)
expect(useViewer.getState().showMeasurements).toBe(false)
})
})
+26 -1
View File
@@ -99,6 +99,9 @@ type ViewerState = {
showGrid: boolean
setShowGrid: (show: boolean) => void
showMeasurements: boolean
setShowMeasurements: (show: boolean) => void
// Presentation flag for parametric zones. When false the zone renderer
// unmounts its meshes AND its drei <Html> label (an <Html> costs per-frame
// matrix work + live DOM even at opacity 0, so hiding is not enough). The
@@ -118,7 +121,12 @@ type ViewerState = {
setProjectId: (id: string | null) => void
projectPreferences: Record<
string,
{ showScans?: boolean; showGuides?: boolean; showGrid?: boolean }
{
showScans?: boolean
showGuides?: boolean
showGrid?: boolean
showMeasurements?: boolean
}
>
// Smart selection update
@@ -267,6 +275,9 @@ function normalizeProjectPreferences(value: unknown): ViewerState['projectPrefer
...(typeof record.showScans === 'boolean' ? { showScans: record.showScans } : {}),
...(typeof record.showGuides === 'boolean' ? { showGuides: record.showGuides } : {}),
...(typeof record.showGrid === 'boolean' ? { showGrid: record.showGrid } : {}),
...(typeof record.showMeasurements === 'boolean'
? { showMeasurements: record.showMeasurements }
: {}),
}
}
return next
@@ -408,6 +419,19 @@ const useViewer = create<ViewerState>()(
return { showGrid: show, projectPreferences }
}),
showMeasurements: true,
setShowMeasurements: (show) =>
set((state) => {
const projectPreferences = { ...(state.projectPreferences || {}) }
if (state.projectId) {
projectPreferences[state.projectId] = {
...(projectPreferences[state.projectId] || {}),
showMeasurements: show,
}
}
return { showMeasurements: show, projectPreferences }
}),
showZones: true,
setShowZones: (show) => set({ showZones: show }),
@@ -427,6 +451,7 @@ const useViewer = create<ViewerState>()(
showScans: prefs.showScans ?? true,
showGuides: prefs.showGuides ?? true,
showGrid: prefs.showGrid ?? true,
showMeasurements: prefs.showMeasurements ?? true,
}
}),
projectPreferences: {},