Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 71 additions & 43 deletions packages/editor/src/components/tools/shared/drag-bounding-box.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, LineBasicNodeMaterial>()
const planeMaterials = new Map<number, MeshBasicNodeMaterial>()

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] }

/**
Expand Down Expand Up @@ -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
Expand All @@ -176,19 +201,22 @@ export function DragBoundingBox({
return (
<group position={position} rotation={[0, rotationY, 0]}>
<mesh
geometry={planeGeometry}
geometry={UNIT_PLANE}
layers={EDITOR_LAYER}
material={planeMaterial}
position={[cx, groundY, cz]}
raycast={NO_RAYCAST}
renderOrder={999}
scale={[w, 1, d]}
/>
<lineSegments
geometry={edgeGeometry}
geometry={UNIT_EDGES}
layers={EDITOR_LAYER}
material={edgeMaterial}
position={[cx, cy, cz]}
raycast={NO_RAYCAST}
renderOrder={999}
scale={[w, h, d]}
/>
</group>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
18 changes: 17 additions & 1 deletion packages/editor/src/lib/local-guide-image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
}
})
8 changes: 8 additions & 0 deletions packages/editor/src/lib/local-guide-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down