diff --git a/packages/editor/src/components/tools/shared/drag-bounding-box.tsx b/packages/editor/src/components/tools/shared/drag-bounding-box.tsx index f487fb4059..92ad2a9aa2 100644 --- a/packages/editor/src/components/tools/shared/drag-bounding-box.tsx +++ b/packages/editor/src/components/tools/shared/drag-bounding-box.tsx @@ -22,6 +22,70 @@ const NO_RAYCAST = () => null /** green-500 — matches the item placement box's "placeable" state. */ const DEFAULT_COLOR = 0x22_c5_5e +/** + * Module-level unit geometry, scaled per frame through the mesh transform — + * NEVER rebuilt or disposed mid-drag. + * + * The previous version minted a `BoxGeometry`/`PlaneGeometry` per dimension + * change and disposed the old one in an effect cleanup. During a resize drag + * that is once per frame, and WebGPU may still be executing the command + * buffer that references the disposed buffers — the renderer then drops the + * ENTIRE frame's command buffer ("Vertex buffer slot … was not set"), which + * blanks the whole scene for a frame, not just this overlay. Unit geometry + + * `scale` moves the per-frame change into the object matrix, where it + * belongs, and the shared buffers live for the session. + */ +const UNIT_EDGES = (() => { + const box = new BoxGeometry(1, 1, 1) + const edges = new EdgesGeometry(box) + box.dispose() + return edges +})() + +const UNIT_PLANE = (() => { + const plane = new PlaneGeometry(1, 1) + plane.rotateX(-Math.PI / 2) + return plane +})() + +/** + * Materials by colour — two in practice (placeable green / blocked red). + * Cached for the same reason the geometry is shared: disposing a material the + * in-flight pass still references is the same command-buffer drop, and a + * colour flip happens mid-drag by design. + */ +const edgeMaterials = new Map() +const planeMaterials = new Map() + +function getEdgeMaterial(color: number): LineBasicNodeMaterial { + let material = edgeMaterials.get(color) + if (!material) { + material = new LineBasicNodeMaterial({ + color, + linewidth: 3, + depthTest: false, + depthWrite: false, + }) + edgeMaterials.set(color, material) + } + return material +} + +function getPlaneMaterial(color: number): MeshBasicNodeMaterial { + let material = planeMaterials.get(color) + if (!material) { + material = new MeshBasicNodeMaterial({ + color, + transparent: true, + depthTest: false, + depthWrite: false, + }) + material.opacityNode = smoothstep(0, 0.7, distance(uv(), vec2(0.5, 0.5))).mul(0.6) + planeMaterials.set(color, material) + } + return material +} + type LocalBounds = { size: [number, number, number]; center: [number, number, number] } /** @@ -113,47 +177,8 @@ export function DragBoundingBox({ const minY = cy - h / 2 const groundY = minY + 0.01 - const edgeGeometry = useMemo(() => { - const box = new BoxGeometry(w, h, d) - const edges = new EdgesGeometry(box) - box.dispose() - return edges - }, [w, h, d]) - - // Flat on the ground (XZ) at the box's base, nudged up 0.01m to avoid - // z-fighting with slabs. - const planeGeometry = useMemo(() => { - const plane = new PlaneGeometry(w, d) - plane.rotateX(-Math.PI / 2) - plane.translate(cx, groundY, cz) - return plane - }, [w, d, cx, groundY, cz]) - - const edgeMaterial = useMemo( - () => new LineBasicNodeMaterial({ color, linewidth: 3, depthTest: false, depthWrite: false }), - [color], - ) - - const planeMaterial = useMemo(() => { - const material = new MeshBasicNodeMaterial({ - color, - transparent: true, - depthTest: false, - depthWrite: false, - }) - material.opacityNode = smoothstep(0, 0.7, distance(uv(), vec2(0.5, 0.5))).mul(0.6) - return material - }, [color]) - - useEffect( - () => () => { - edgeGeometry.dispose() - planeGeometry.dispose() - edgeMaterial.dispose() - planeMaterial.dispose() - }, - [edgeGeometry, planeGeometry, edgeMaterial, planeMaterial], - ) + const edgeMaterial = getEdgeMaterial(color) + const planeMaterial = getPlaneMaterial(color) // Publish the facing pose to the editor-side overlay (the single triangle // renderer) rather than drawing it here. The node origin is `position`; the @@ -176,19 +201,22 @@ export function DragBoundingBox({ return ( ) diff --git a/packages/editor/src/components/ui/action-menu/view-toggles.tsx b/packages/editor/src/components/ui/action-menu/view-toggles.tsx index e3023323cf..89cb199bcc 100644 --- a/packages/editor/src/components/ui/action-menu/view-toggles.tsx +++ b/packages/editor/src/components/ui/action-menu/view-toggles.tsx @@ -13,7 +13,7 @@ import { Check, ChevronDown, Eye, EyeOff, Layers2, Plus, Trash2, Waypoints } fro import { useCallback, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { getLevelDisplayName } from '@pascal-app/core' -import { createLocalGuideImage } from '../../../lib/local-guide-image' +import { createLocalGuideImage, guideImageErrorMessage } from '../../../lib/local-guide-image' import { cn } from '../../../lib/utils' import useEditor from '../../../store/use-editor' import { useUploadStore } from '../../../store/use-upload' @@ -119,8 +119,8 @@ function UploadButton({ onError }: { onError: (message: string | null) => void } setShowGuides(true) setSelectedReferenceId(guide.id) setSelection({ selectedIds: [], zoneId: null }) - } catch { - onError('Could not add that guide image.') + } catch (error) { + onError(guideImageErrorMessage(error)) } finally { setIsAddingGuide(false) } diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx index 1dadfe0662..891db9d47a 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx @@ -62,7 +62,11 @@ import { metersToLinearUnit, squareMetersToAreaUnit, } from './../../../../../lib/measurements' -import { createLocalGuideImage, createLocalScan } from './../../../../../lib/local-guide-image' +import { + createLocalGuideImage, + createLocalScan, + guideImageErrorMessage, +} from './../../../../../lib/local-guide-image' import { editorHostTreeChildrenRegistry } from './../../../../../lib/host-tree-children' import { requestRoomDeletion } from './../../../../../lib/room-structure-commands' import { @@ -582,8 +586,8 @@ const LevelReferences = memo(function LevelReferences({ setSelection({ selectedIds: [], zoneId: null }) useUploadStore.getState().setResult(levelId, guide.url) window.setTimeout(() => useUploadStore.getState().clearUpload(levelId), 600) - } catch { - useUploadStore.getState().setError(levelId, 'Could not add that guide image.') + } catch (error) { + useUploadStore.getState().setError(levelId, guideImageErrorMessage(error)) } return } diff --git a/packages/editor/src/lib/local-guide-image.test.ts b/packages/editor/src/lib/local-guide-image.test.ts index cc95488815..e6d35ebd48 100644 --- a/packages/editor/src/lib/local-guide-image.test.ts +++ b/packages/editor/src/lib/local-guide-image.test.ts @@ -5,7 +5,7 @@ const saveAsset = mock(async () => 'asset://stored-scan') mock.module('@pascal-app/core', () => ({ ...core, saveAsset })) -const { createLocalScan } = await import('./local-guide-image') +const { createLocalScan, guideImageErrorMessage } = await import('./local-guide-image') test('createLocalScan stores and attaches a scan asset to its level', async () => { const createNode = mock() @@ -22,3 +22,19 @@ test('createLocalScan stores and attaches a scan asset to its level', async () = expect(url).toBe('asset://stored-scan') expect(createNode).toHaveBeenCalledWith(scan, 'level_ground') }) + +test('guideImageErrorMessage keeps the failure reason when there is one', () => { + const consoleError = mock() + const original = console.error + console.error = consoleError + try { + expect(guideImageErrorMessage(new TypeError('quota exceeded'))).toBe( + 'Could not add that guide image: quota exceeded', + ) + expect(guideImageErrorMessage(new Error(''))).toBe('Could not add that guide image.') + expect(guideImageErrorMessage('boom')).toBe('Could not add that guide image.') + expect(consoleError).toHaveBeenCalledTimes(3) + } finally { + console.error = original + } +}) diff --git a/packages/editor/src/lib/local-guide-image.ts b/packages/editor/src/lib/local-guide-image.ts index c5111de3c7..9853d49cf5 100644 --- a/packages/editor/src/lib/local-guide-image.ts +++ b/packages/editor/src/lib/local-guide-image.ts @@ -51,6 +51,14 @@ export async function createLocalGuideImage({ return guide } +/** Logs a failed {@link createLocalGuideImage} and returns the message to show for it. */ +export function guideImageErrorMessage(error: unknown): string { + console.error('[guide-image]', error) + return error instanceof Error && error.message + ? `Could not add that guide image: ${error.message}` + : 'Could not add that guide image.' +} + export async function createLocalScan({ createNode, file,