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:
co-authored by
Claude Fable 5
parent
22c9472066
commit
ae87ca5475
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user