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
@@ -0,0 +1,161 @@
import { afterAll, afterEach, describe, expect, mock, spyOn, test } from 'bun:test'
import { LoadingManager } from 'three'
import {
cancelItemModelLoad,
classifyItemModelLoadFailure,
getUnavailableItemAsset,
ItemGLTFLoader,
} from './model-loader'
const originalFetch = globalThis.fetch
const originalProgressEvent = globalThis.ProgressEvent
if (typeof globalThis.ProgressEvent === 'undefined') {
globalThis.ProgressEvent = class TestProgressEvent extends Event {} as typeof ProgressEvent
}
afterAll(() => {
globalThis.ProgressEvent = originalProgressEvent
})
afterEach(() => {
globalThis.fetch = originalFetch
})
const load = (loader: ItemGLTFLoader, url: string) =>
new Promise<
| { kind: 'loaded'; unavailable: ReturnType<typeof getUnavailableItemAsset> }
| { error: unknown; kind: 'error' }
>((resolve) => {
loader.load(
url,
(gltf) => resolve({ kind: 'loaded', unavailable: getUnavailableItemAsset(gltf) }),
undefined,
(error) => resolve({ error, kind: 'error' }),
)
})
describe('classifyItemModelLoadFailure', () => {
test('distinguishes unavailable, retryable, and unexpected failures', () => {
expect(
classifyItemModelLoadFailure(
Object.assign(new Error('missing'), { response: { status: 404 } }),
),
).toBe('unavailable')
expect(
classifyItemModelLoadFailure(
Object.assign(new Error('temporary'), { response: { status: 503 } }),
),
).toBe('retryable')
expect(
classifyItemModelLoadFailure(
Object.assign(new Error('forbidden'), { response: { status: 403 } }),
),
).toBe('unavailable')
expect(classifyItemModelLoadFailure(new TypeError('Failed to fetch'))).toBe('retryable')
expect(classifyItemModelLoadFailure(new Error('Malformed glTF'))).toBe('unexpected')
})
})
describe('ItemGLTFLoader', () => {
test('resolves missing responses as an unavailable item instead of rejecting', async () => {
const consoleError = spyOn(console, 'error').mockImplementation(() => {})
try {
globalThis.fetch = mock(async () => new Response(null, { status: 404 })) as typeof fetch
const result = await load(
new ItemGLTFLoader(undefined, []),
'https://example.test/missing.glb',
)
expect(result.kind).toBe('loaded')
if (result.kind !== 'loaded') return
expect(result.unavailable).toMatchObject({ url: 'https://example.test/missing.glb' })
expect(consoleError).not.toHaveBeenCalled()
} finally {
consoleError.mockRestore()
}
})
test('resolves exhausted network failures as an unavailable item', async () => {
const consoleError = spyOn(console, 'error').mockImplementation(() => {})
try {
globalThis.fetch = mock(async () => {
throw new TypeError('Failed to fetch')
}) as typeof fetch
const result = await load(
new ItemGLTFLoader(undefined, []),
'https://example.test/offline.glb',
)
expect(result.kind).toBe('loaded')
if (result.kind !== 'loaded') return
expect(result.unavailable?.message).toBe('Failed to fetch')
expect(consoleError).not.toHaveBeenCalled()
} finally {
consoleError.mockRestore()
}
})
test('keeps malformed model data on the unexpected error path', async () => {
globalThis.fetch = mock(
async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
) as typeof fetch
const result = await load(new ItemGLTFLoader(undefined, []), 'https://example.test/broken.glb')
expect(result.kind).toBe('error')
})
test('retries a transient response and can recover', async () => {
const validGltf = JSON.stringify({ asset: { version: '2.0' }, scene: 0, scenes: [{}] })
let attempt = 0
globalThis.fetch = mock(async () => {
attempt += 1
return attempt === 1
? new Response(null, { status: 503 })
: new Response(validGltf, { status: 200 })
}) as typeof fetch
const manager = new LoadingManager()
let hostErrors = 0
let hostLoads = 0
manager.onError = () => {
hostErrors += 1
}
manager.onLoad = () => {
hostLoads += 1
}
const result = await load(new ItemGLTFLoader(manager, [0]), 'https://example.test/retry.glb')
expect(result).toEqual({ kind: 'loaded', unavailable: null })
expect(attempt).toBe(2)
expect(hostErrors).toBe(0)
expect(hostLoads).toBe(1)
})
test('does not retry after the last consumer cancels a missing asset', async () => {
const url = 'https://example.test/cancelled.glb'
const request = mock(async () => {
throw new TypeError('Failed to fetch')
})
globalThis.fetch = request as typeof fetch
const manager = new LoadingManager()
let hostLoads = 0
manager.onLoad = () => {
hostLoads += 1
}
new ItemGLTFLoader(manager, [10]).load(url, () => {
throw new Error('cancelled load must not resolve')
})
await Bun.sleep(0)
cancelItemModelLoad(url)
await Bun.sleep(20)
expect(request).toHaveBeenCalledTimes(1)
expect(hostLoads).toBe(1)
})
})
+156
View File
@@ -0,0 +1,156 @@
import { DefaultLoadingManager, Group, LoadingManager } from 'three'
import { type GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
const ITEM_ASSET_UNAVAILABLE_KEY = 'pascalItemAssetUnavailable'
const DEFAULT_RETRY_DELAYS_MS = [1_000, 3_000] as const
const itemLoadGenerations = new Map<string, number>()
type HttpErrorLike = Error & {
response?: { status?: number }
}
export type ItemAssetUnavailable = {
message: string
url: string
}
export type ItemModelLoadFailureKind = 'retryable' | 'unavailable' | 'unexpected'
export function classifyItemModelLoadFailure(error: unknown): ItemModelLoadFailureKind {
if (!(error instanceof Error)) return 'unexpected'
const status = (error as HttpErrorLike).response?.status
if (
status === 408 ||
status === 425 ||
status === 429 ||
(status !== undefined && status >= 500)
) {
return 'retryable'
}
if (status !== undefined && status >= 400 && status < 500) return 'unavailable'
if (error instanceof TypeError && /failed to fetch/i.test(error.message)) return 'retryable'
return 'unexpected'
}
export function createUnavailableItemGltf(url: string, error: unknown): GLTF {
const unavailable: ItemAssetUnavailable = {
message: error instanceof Error ? error.message : String(error),
url,
}
const scene = new Group()
scene.userData[ITEM_ASSET_UNAVAILABLE_KEY] = unavailable
return {
animations: [],
asset: { version: '2.0' },
cameras: [],
parser: null as never,
scene,
scenes: [scene],
userData: { [ITEM_ASSET_UNAVAILABLE_KEY]: unavailable },
}
}
export function getUnavailableItemAsset(gltf: GLTF): ItemAssetUnavailable | null {
const value = gltf.userData?.[ITEM_ASSET_UNAVAILABLE_KEY]
if (!value || typeof value !== 'object') return null
const candidate = value as Partial<ItemAssetUnavailable>
return typeof candidate.url === 'string' && typeof candidate.message === 'string'
? { url: candidate.url, message: candidate.message }
: null
}
export function cancelItemModelLoad(url: string) {
itemLoadGenerations.set(url, (itemLoadGenerations.get(url) ?? 0) + 1)
}
export class ItemGLTFLoader extends GLTFLoader {
readonly hostManager: LoadingManager
readonly retryDelaysMs: readonly number[]
constructor(manager?: LoadingManager, retryDelaysMs = DEFAULT_RETRY_DELAYS_MS) {
super(new LoadingManager())
this.hostManager = manager ?? DefaultLoadingManager
this.retryDelaysMs = retryDelaysMs
}
override load(
url: string,
onLoad: (gltf: GLTF) => void,
onProgress?: (event: ProgressEvent) => void,
onError?: (error: unknown) => void,
): void {
const generation = itemLoadGenerations.get(url) ?? 0
let retryCount = 0
let finished = false
const wasCancelled = () => (itemLoadGenerations.get(url) ?? 0) !== generation
const cancel = () => {
if (finished) return
finished = true
this.hostManager.itemEnd(url)
}
const complete = (gltf: GLTF) => {
if (finished) return
if (wasCancelled()) {
cancel()
return
}
finished = true
try {
onLoad(gltf)
} finally {
this.hostManager.itemEnd(url)
}
}
const fail = (error: unknown) => {
if (finished) return
if (wasCancelled()) {
cancel()
return
}
finished = true
try {
if (onError) onError(error)
else console.error(error)
} finally {
this.hostManager.itemError(url)
this.hostManager.itemEnd(url)
}
}
const attempt = () => {
if (wasCancelled()) {
cancel()
return
}
super.load(url, complete, onProgress, (error) => {
if (wasCancelled()) {
cancel()
return
}
const kind = classifyItemModelLoadFailure(error)
if (kind === 'unexpected') {
fail(error)
return
}
if (kind === 'unavailable' || retryCount >= this.retryDelaysMs.length) {
complete(createUnavailableItemGltf(url, error))
return
}
const delay = this.retryDelaysMs[retryCount] ?? 0
retryCount += 1
setTimeout(attempt, delay)
})
}
this.hostManager.itemStart(url)
attempt()
}
}
+129 -45
View File
@@ -34,13 +34,16 @@ import {
} from '@pascal-app/viewer'
import { useAnimations } from '@react-three/drei'
import { Clone } from '@react-three/drei/core/Clone'
import { useGLTF } from '@react-three/drei/core/Gltf'
import { useFrame } from '@react-three/fiber'
import { useFrame, useLoader } from '@react-three/fiber'
import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { AnimationAction, Group, Material, Mesh } from 'three'
import type { AnimationAction, Group, Material, Mesh, Object3D } from 'three'
import { MathUtils } from 'three'
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { positionLocal, smoothstep, time } from 'three/tsl'
import { RoofFaceHostFrame } from '../shared/roof-face-host'
import { cancelItemModelLoad, getUnavailableItemAsset, ItemGLTFLoader } from './model-loader'
type MutableMaterial = Material & {
depthTest?: boolean
@@ -182,7 +185,7 @@ const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
const handlers = useNodeEvents(node, 'item')
const shading = useViewer((s) => s.shading)
const isExporting = useViewer((s) => s.isExporting)
const [w, h, d] = node.asset.dimensions
const [w, h, d] = getScaledDimensions(node)
const material = useMemo(() => {
const next = createDefaultMaterial('#ef4444', 1, shading) as MutableMaterial
next.opacity = 0.6
@@ -204,16 +207,99 @@ const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
)
}
const MODEL_RETRY_DELAYS_MS = [1_000, 3_000]
let itemDracoLoader: DRACOLoader | null = null
const configureItemModelLoader = (loader: ItemGLTFLoader) => {
if (!itemDracoLoader) {
itemDracoLoader = new DRACOLoader(loader.manager)
itemDracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.5/')
}
loader.setDRACOLoader(itemDracoLoader)
loader.setMeshoptDecoder(MeshoptDecoder)
}
type LoadedItemGltf = GLTF & {
materials: Record<string, Material>
nodes: Record<string, Object3D>
}
const useItemGltf = (url: string): LoadedItemGltf =>
useLoader(ItemGLTFLoader, url, configureItemModelLoader) as LoadedItemGltf
type DeferredUnavailableCleanup = {
consumers: number
timer: ReturnType<typeof setTimeout> | null
}
const unavailableAssetConsumers = new Map<string, DeferredUnavailableCleanup>()
const unavailableFailureConsumers = new Map<string, DeferredUnavailableCleanup>()
const retainUnavailableConsumer = (
entries: Map<string, DeferredUnavailableCleanup>,
key: string,
) => {
const entry = entries.get(key) ?? { consumers: 0, timer: null }
if (entry.timer !== null) {
clearTimeout(entry.timer)
entry.timer = null
}
entry.consumers += 1
entries.set(key, entry)
}
const releaseUnavailableConsumer = (
entries: Map<string, DeferredUnavailableCleanup>,
key: string,
onLastRelease: () => void,
) => {
const entry = entries.get(key)
if (!entry) return
entry.consumers = Math.max(0, entry.consumers - 1)
if (entry.consumers > 0 || entry.timer !== null) return
// A zero-delay release distinguishes a real unmount from Strict Mode's
// immediate setup-cleanup-setup cycle and same-tick replacements.
entry.timer = setTimeout(() => {
if (entry.consumers > 0 || entries.get(key) !== entry) return
entries.delete(key)
onLastRelease()
}, 0)
}
const UnavailableItemModel = ({
markSettled,
node,
url,
}: {
markSettled: () => void
node: ItemNode
url: string
}) => {
useEffect(() => {
retainUnavailableConsumer(unavailableFailureConsumers, node.id)
if (url) retainUnavailableConsumer(unavailableAssetConsumers, url)
markSettled()
useViewer.getState().reportItemLoadFailure(node.id, url)
return () => {
releaseUnavailableConsumer(unavailableFailureConsumers, node.id, () =>
useViewer.getState().clearItemLoadFailure(node.id),
)
if (url) {
releaseUnavailableConsumer(unavailableAssetConsumers, url, () => {
cancelItemModelLoad(url)
useLoader.clear(ItemGLTFLoader, url)
})
}
}
}, [markSettled, node.id, url])
return <BrokenItemFallback node={node} />
}
/**
* Load the item model with bounded retries. drei's `useGLTF` caches a rejected
* load by URL, so a transient fetch failure (e.g. a storage 504 under the bake
* page's asset-request burst) would otherwise stay broken for the whole
* session — clear the cache entry and re-mount. After the retries are
* exhausted the item settles as SKIPPED: it renders the debug box (nothing
* during exports) and lands in `useViewer.itemLoadFailures` so a bake host can
* record which items are missing from the artifact.
* Expected network failures resolve through ItemGLTFLoader as an unavailable
* scene so they never become React render errors. Parse and renderer failures
* still reach this boundary and remain visible to developers.
*/
const ModelWithRetry = ({
node,
@@ -222,48 +308,30 @@ const ModelWithRetry = ({
node: ItemNode
setSettled: (value: boolean) => void
}) => {
// `failures` counts boundary catches; `epoch` bumps after each cache clear
// to reset the boundary and re-mount the loader. The retry timer is owned by
// an effect (not the error handler) so StrictMode's synthetic
// unmount/remount re-arms it instead of silently discarding it. The host
// keys this component by asset URL, so a model swap starts from a clean
// retry budget — and the mount effect below un-settles the item so the new
// load is awaited too.
const [failures, setFailures] = useState(0)
const [epoch, setEpoch] = useState(0)
const [renderFailed, setRenderFailed] = useState(false)
const url = resolveCdnUrl(node.asset.src) || ''
const gaveUp = !url || failures > MODEL_RETRY_DELAYS_MS.length
const markSettled = useCallback(() => setSettled(true), [setSettled])
const handleError = useCallback(() => setFailures((current) => current + 1), [])
useEffect(() => {
// Clear before child passive completion effects; a parent passive clear would run after them.
useLayoutEffect(() => {
setSettled(false)
}, [setSettled])
useEffect(() => {
if (failures === 0 || gaveUp) return
const delay = MODEL_RETRY_DELAYS_MS[failures - 1] ?? 0
const timer = setTimeout(() => {
console.log(`[item] retrying model load (${failures}/${MODEL_RETRY_DELAYS_MS.length}) ${url}`)
useGLTF.clear(url)
setEpoch((current) => current + 1)
}, delay)
return () => clearTimeout(timer)
}, [failures, gaveUp, url])
const markSettled = useCallback(() => setSettled(true), [setSettled])
useEffect(() => {
if (!gaveUp) return
if (!renderFailed) return
markSettled()
useViewer.getState().reportItemLoadFailure(node.id, url)
return () => useViewer.getState().clearItemLoadFailure(node.id)
}, [gaveUp, markSettled, node.id, url])
}, [markSettled, node.id, renderFailed, url])
if (gaveUp) return <BrokenItemFallback node={node} />
if (!url) return <UnavailableItemModel markSettled={markSettled} node={node} url={url} />
return (
<ErrorBoundary fallback={<PreviewModel node={node} />} onError={handleError} resetKey={epoch}>
<ErrorBoundary
fallback={<BrokenItemFallback node={node} />}
onError={() => setRenderFailed(true)}
scope="item-model"
>
<Suspense fallback={<PreviewModel node={node} />}>
<ModelRenderer markSettled={markSettled} node={node} />
</Suspense>
@@ -381,15 +449,31 @@ const multiplyScales = (
b: [number, number, number],
): [number, number, number] => [a[0] * b[0], a[1] * b[1], a[2] * b[2]]
const ModelRenderer = ({ node, markSettled }: { node: ItemNode; markSettled?: () => void }) => {
const { scene, nodes, animations } = useGLTF(resolveCdnUrl(node.asset.src) || '')
const ModelRenderer = ({ node, markSettled }: { node: ItemNode; markSettled: () => void }) => {
const gltf = useItemGltf(resolveCdnUrl(node.asset.src) || '')
const unavailable = getUnavailableItemAsset(gltf)
if (unavailable) {
return <UnavailableItemModel markSettled={markSettled} node={node} url={unavailable.url} />
}
return <LoadedModelRenderer gltf={gltf} markSettled={markSettled} node={node} />
}
const LoadedModelRenderer = ({
gltf: { scene, nodes, animations },
node,
markSettled,
}: {
gltf: LoadedItemGltf
node: ItemNode
markSettled: () => void
}) => {
const ref = useRef<Group>(null!)
const { actions } = useAnimations(animations, ref)
// Mounting past the suspense gate means the GLB resolved — the item's build
// work is done (`ItemSystem` may clear its dirty mark, scene-ready may fire).
useEffect(() => {
markSettled?.()
markSettled()
}, [markSettled])
const shading = useViewer((s) => s.shading)
const textures = useViewer((s) => s.textures)