From b7d6038348eb79886bcef622afdb04fe010dd890 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 7 Jun 2026 00:24:02 -0400 Subject: [PATCH] Add 3D box selection --- .../tools/select/box-select-tool.tsx | 1136 ++++++++--------- 1 file changed, 520 insertions(+), 616 deletions(-) diff --git a/packages/editor/src/components/tools/select/box-select-tool.tsx b/packages/editor/src/components/tools/select/box-select-tool.tsx index 43a647b5..76e0853a 100644 --- a/packages/editor/src/components/tools/select/box-select-tool.tsx +++ b/packages/editor/src/components/tools/select/box-select-tool.tsx @@ -1,50 +1,19 @@ -import '../../../three-types' - -import { Icon } from '@iconify/react' import { + type AnyNode, type AnyNodeId, - type CeilingNode, - type ColumnNode, - emitter, - type GridEvent, - type ItemNode, isRegistrySelectable, type LevelNode, + nodeRegistry, resolveBuildingForLevel, - type SlabNode, sceneRegistry, useScene, - type WallNode, type ZoneNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import type { ThreeElements } from '@react-three/fiber' import { useThree } from '@react-three/fiber' -import { useEffect, useRef } from 'react' -import { - Box3, - BufferAttribute, - BufferGeometry, - DoubleSide, - type Group, - LineBasicMaterial, - LineSegments, - type Mesh, - Plane, - Raycaster, - Vector2, - Vector3, -} from 'three' -import { EDITOR_LAYER } from '../../../lib/constants' -import { sfxEmitter } from '../../../lib/sfx-bus' +import { useCallback, useEffect, useRef } from 'react' +import { Box3, type Camera, type Object3D, Vector3 } from 'three' import useEditor from '../../../store/use-editor' -import { CursorSphere } from '../shared/cursor-sphere' - -declare module 'react/jsx-runtime' { - namespace JSX { - interface IntrinsicElements extends ThreeElements {} - } -} /** * Module-level flag to prevent the SelectionManager from deselecting @@ -52,268 +21,26 @@ declare module 'react/jsx-runtime' { */ export let boxSelectHandled = false -// ── Geometry helpers ──────────────────────────────────────────────────────── +type ScreenRect = { minX: number; minY: number; maxX: number; maxY: number } -type Bounds = { minX: number; maxX: number; minZ: number; maxZ: number } +const BOX_SELECT_FILL_COLOR = 'rgba(129, 140, 248, 0.14)' +const BOX_SELECT_BORDER_COLOR = 'rgba(129, 140, 248, 0.9)' +const BOX_SELECT_SHADOW_COLOR = 'rgba(129, 140, 248, 0.28)' +const DRAG_THRESHOLD_PX = 4 -function pointInBounds(x: number, z: number, b: Bounds): boolean { - return x >= b.minX && x <= b.maxX && z >= b.minZ && z <= b.maxZ -} - -function segmentsIntersect( - ax1: number, - az1: number, - ax2: number, - az2: number, - bx1: number, - bz1: number, - bx2: number, - bz2: number, -): boolean { - const d1 = cross(bx1, bz1, bx2, bz2, ax1, az1) - const d2 = cross(bx1, bz1, bx2, bz2, ax2, az2) - const d3 = cross(ax1, az1, ax2, az2, bx1, bz1) - const d4 = cross(ax1, az1, ax2, az2, bx2, bz2) - - if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) { - return true - } - - if (d1 === 0 && onSeg(bx1, bz1, bx2, bz2, ax1, az1)) return true - if (d2 === 0 && onSeg(bx1, bz1, bx2, bz2, ax2, az2)) return true - if (d3 === 0 && onSeg(ax1, az1, ax2, az2, bx1, bz1)) return true - if (d4 === 0 && onSeg(ax1, az1, ax2, az2, bx2, bz2)) return true - - return false -} - -function cross(ax: number, az: number, bx: number, bz: number, cx: number, cz: number): number { - return (bx - ax) * (cz - az) - (bz - az) * (cx - ax) -} - -function onSeg(ax: number, az: number, bx: number, bz: number, cx: number, cz: number): boolean { - return ( - Math.min(ax, bx) <= cx && - cx <= Math.max(ax, bx) && - Math.min(az, bz) <= cz && - cz <= Math.max(az, bz) - ) -} - -function segmentIntersectsBounds( - x1: number, - z1: number, - x2: number, - z2: number, - b: Bounds, -): boolean { - if (pointInBounds(x1, z1, b) || pointInBounds(x2, z2, b)) return true - - const edges: [number, number, number, number][] = [ - [b.minX, b.minZ, b.maxX, b.minZ], - [b.maxX, b.minZ, b.maxX, b.maxZ], - [b.maxX, b.maxZ, b.minX, b.maxZ], - [b.minX, b.maxZ, b.minX, b.minZ], - ] - for (const [ex1, ez1, ex2, ez2] of edges) { - if (segmentsIntersect(x1, z1, x2, z2, ex1, ez1, ex2, ez2)) return true - } - return false -} - -function polygonIntersectsBounds(polygon: [number, number][], b: Bounds): boolean { - if (polygon.some(([x, z]) => pointInBounds(x, z, b))) return true - - const corners: [number, number][] = [ - [b.minX, b.minZ], - [b.maxX, b.minZ], - [b.maxX, b.maxZ], - [b.minX, b.maxZ], - ] - if (corners.some(([cx, cz]) => pointInPolygon(cx, cz, polygon))) return true - - const edges: [number, number, number, number][] = [ - [b.minX, b.minZ, b.maxX, b.minZ], - [b.maxX, b.minZ, b.maxX, b.maxZ], - [b.maxX, b.maxZ, b.minX, b.maxZ], - [b.minX, b.maxZ, b.minX, b.minZ], - ] - for (let i = 0; i < polygon.length; i++) { - const [px1, pz1] = polygon[i]! - const [px2, pz2] = polygon[(i + 1) % polygon.length]! - for (const [ex1, ez1, ex2, ez2] of edges) { - if (segmentsIntersect(px1, pz1, px2, pz2, ex1, ez1, ex2, ez2)) return true - } - } - - return false -} - -function pointInPolygon(x: number, z: number, polygon: [number, number][]): boolean { - let inside = false - for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { - const [xi, zi] = polygon[i]! - const [xj, zj] = polygon[j]! - if (zi > z !== zj > z && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi) { - inside = !inside - } - } - return inside -} - -// ── Node-in-bounds checks ─────────────────────────────────────────────────── - -const _tempVec = new Vector3() -const _tempBox = new Box3() - -function getNodeWorldXZ(nodeId: string): [number, number] | null { - const obj = sceneRegistry.nodes.get(nodeId) - if (!obj) return null - obj.getWorldPosition(_tempVec) - return [_tempVec.x, _tempVec.z] -} - -function objectBoundsIntersectsBounds(nodeId: string, bounds: Bounds): boolean { - const obj = sceneRegistry.nodes.get(nodeId) - if (!obj) return false - - obj.updateWorldMatrix(true, true) - _tempBox.setFromObject(obj) - - if (_tempBox.isEmpty()) { - const xz = getNodeWorldXZ(nodeId) - return Boolean(xz && pointInBounds(xz[0], xz[1], bounds)) - } - - return !( - _tempBox.max.x < bounds.minX || - _tempBox.min.x > bounds.maxX || - _tempBox.max.z < bounds.minZ || - _tempBox.min.z > bounds.maxZ - ) -} - -/** - * Collect the ids of selectable nodes on the active level. When `bounds` is a - * rectangle, only nodes intersecting it are returned (a box-select hit-test). - * When `bounds` is `null`, every selectable node on the level is returned — - * the full set the box-select could ever capture — which lets the caller tell - * whether a drag selected the entire level. - */ -function collectNodeIdsInBounds(bounds: Bounds | null): string[] { - const { levelId } = useViewer.getState().selection - const { nodes } = useScene.getState() - const { phase, structureLayer } = useEditor.getState() - - if (!levelId) return [] - const levelNode = nodes[levelId] as LevelNode | undefined - if (!levelNode || levelNode.type !== 'level') return [] - - const result: string[] = [] - - if (phase === 'structure' && structureLayer === 'zones') { - for (const childId of levelNode.children) { - const node = nodes[childId as AnyNodeId] - if (!node || node.type !== 'zone') continue - const zone = node as ZoneNode - if (!bounds || polygonIntersectsBounds(zone.polygon, bounds)) { - result.push(zone.id) - } - } - } else { - // structure (elements) and furnish: collect all node types - for (const childId of levelNode.children) { - const node = nodes[childId as AnyNodeId] - if (!node) continue - - if (node.type === 'wall' || node.type === 'fence') { - const wall = node as WallNode - if ( - !bounds || - segmentIntersectsBounds(wall.start[0], wall.start[1], wall.end[0], wall.end[1], bounds) - ) { - result.push(wall.id) - } - // Check wall children (doors/windows) - for (const itemId of Array.isArray(wall.children) ? wall.children : []) { - const child = nodes[itemId as AnyNodeId] - if (!child) continue - if ( - child.type === 'window' || - child.type === 'door' || - (child.type === 'item' && - ((child as ItemNode).asset.category === 'door' || - (child as ItemNode).asset.category === 'window')) - ) { - const xz = getNodeWorldXZ(child.id) - if (!bounds || (xz && pointInBounds(xz[0], xz[1], bounds))) { - result.push(child.id) - } - } - } - } else if (node.type === 'slab') { - const slab = node as SlabNode - if (!bounds || polygonIntersectsBounds(slab.polygon, bounds)) { - result.push(slab.id) - } - } else if (node.type === 'ceiling') { - const ceiling = node as CeilingNode - if (!bounds || polygonIntersectsBounds(ceiling.polygon, bounds)) { - result.push(ceiling.id) - } - } else if (node.type === 'roof') { - const xz = getNodeWorldXZ(node.id) - if (!bounds || (xz && pointInBounds(xz[0], xz[1], bounds))) { - result.push(node.id) - } - } else if (node.type === 'stair') { - if (!bounds || objectBoundsIntersectsBounds(node.id, bounds)) { - result.push(node.id) - } - } else if (node.type === 'column') { - const column = node as ColumnNode - if (!bounds || objectBoundsIntersectsBounds(column.id, bounds)) { - result.push(column.id) - } - } else if (node.type === 'item') { - const item = node as ItemNode - if (item.asset.category === 'door' || item.asset.category === 'window') continue - const xz = getNodeWorldXZ(item.id) - if (!bounds || (xz && pointInBounds(xz[0], xz[1], bounds))) { - result.push(item.id) - } - } else if (isRegistrySelectable(node.type)) { - // Registry-driven selectable kinds (shelf + future furnish/structure - // kinds) aren't in the hardcoded list above; pick them up by their - // rendered bounding box, the same path column/stair use. - if (!bounds || objectBoundsIntersectsBounds(node.id, bounds)) { - result.push(node.id) - } - } - } - - // Building-scoped selectable nodes (e.g. elevator) are siblings of the - // level — children of the building, not the level — so the loop above - // never reaches them. Walk the active level's building children and - // box-test any registry-selectable kind by its rendered bounds, the same - // path column/stair/shelf use. - const buildingId = resolveBuildingForLevel(levelId as AnyNodeId, nodes) - const buildingNode = buildingId ? nodes[buildingId] : undefined - const buildingChildren = - buildingNode && 'children' in buildingNode && Array.isArray(buildingNode.children) - ? (buildingNode.children as AnyNodeId[]) - : [] - for (const childId of buildingChildren) { - const node = nodes[childId] - if (!node || node.type === 'level' || !isRegistrySelectable(node.type)) continue - if (!bounds || objectBoundsIntersectsBounds(node.id, bounds)) { - result.push(node.id) - } - } - } - - return result -} +const tempBox = new Box3() +const tempWorldPoint = new Vector3() +const tempScreenPoint = new Vector3() +const boxCorners = [ + new Vector3(), + new Vector3(), + new Vector3(), + new Vector3(), + new Vector3(), + new Vector3(), + new Vector3(), + new Vector3(), +] function haveSameIds(currentIds: string[], nextIds: string[]): boolean { return ( @@ -322,379 +49,556 @@ function haveSameIds(currentIds: string[], nextIds: string[]): boolean { ) } -// ── Visual helpers ────────────────────────────────────────────────────────── +function createSelectionElement(): HTMLDivElement { + const element = document.createElement('div') + element.style.position = 'fixed' + element.style.display = 'none' + element.style.pointerEvents = 'none' + element.style.zIndex = '2147483647' + element.style.border = `1px solid ${BOX_SELECT_BORDER_COLOR}` + element.style.background = BOX_SELECT_FILL_COLOR + element.style.boxShadow = `0 0 0 1px ${BOX_SELECT_SHADOW_COLOR} inset` + element.style.contain = 'layout paint style' + return element +} -function updateRectVisuals( - fillMesh: Mesh, - outline: LineSegments, - start: Vector3, - end: Vector3, - y: number, -) { - const cx = (start.x + end.x) / 2 - const cz = (start.z + end.z) / 2 - const w = Math.abs(end.x - start.x) - const h = Math.abs(end.z - start.z) +function normalizeScreenRect( + startX: number, + startY: number, + endX: number, + endY: number, +): ScreenRect { + return { + minX: Math.min(startX, endX), + minY: Math.min(startY, endY), + maxX: Math.max(startX, endX), + maxY: Math.max(startY, endY), + } +} - if (w < 0.01 && h < 0.01) { - fillMesh.visible = false - outline.visible = false +function updateSelectionElement(element: HTMLDivElement, rect: ScreenRect) { + element.style.display = 'block' + element.style.left = `${rect.minX}px` + element.style.top = `${rect.minY}px` + element.style.width = `${Math.max(0, rect.maxX - rect.minX)}px` + element.style.height = `${Math.max(0, rect.maxY - rect.minY)}px` +} + +function hideSelectionElement(element: HTMLDivElement | null) { + if (!element) return + element.style.display = 'none' + element.style.width = '0px' + element.style.height = '0px' +} + +function screenRectsIntersect(a: ScreenRect, b: ScreenRect): boolean { + return !(b.maxX < a.minX || b.minX > a.maxX || b.maxY < a.minY || b.minY > a.maxY) +} + +function screenRectFromDomRect(rect: DOMRect): ScreenRect { + return { + minX: rect.left, + minY: rect.top, + maxX: rect.right, + maxY: rect.bottom, + } +} + +function intersectScreenRects(a: ScreenRect, b: ScreenRect): ScreenRect | null { + const rect = { + minX: Math.max(a.minX, b.minX), + minY: Math.max(a.minY, b.minY), + maxX: Math.min(a.maxX, b.maxX), + maxY: Math.min(a.maxY, b.maxY), + } + + if (rect.maxX <= rect.minX || rect.maxY <= rect.minY) { + return null + } + + return rect +} + +function projectWorldPointToScreen( + point: Vector3, + camera: Camera, + canvasRect: DOMRect, +): [number, number] | null { + tempScreenPoint.copy(point).project(camera) + if (tempScreenPoint.z < -1 || tempScreenPoint.z > 1) return null + + return [ + canvasRect.left + (tempScreenPoint.x * 0.5 + 0.5) * canvasRect.width, + canvasRect.top + (-tempScreenPoint.y * 0.5 + 0.5) * canvasRect.height, + ] +} + +function getObjectScreenRect( + object: Object3D, + camera: Camera, + canvasRect: DOMRect, +): ScreenRect | null { + object.updateWorldMatrix(true, true) + tempBox.setFromObject(object) + + if (tempBox.isEmpty()) { + object.getWorldPosition(tempWorldPoint) + const projected = projectWorldPointToScreen(tempWorldPoint, camera, canvasRect) + if (!projected) return null + const [x, y] = projected + return { minX: x, minY: y, maxX: x, maxY: y } + } + + boxCorners[0]!.set(tempBox.min.x, tempBox.min.y, tempBox.min.z) + boxCorners[1]!.set(tempBox.min.x, tempBox.min.y, tempBox.max.z) + boxCorners[2]!.set(tempBox.min.x, tempBox.max.y, tempBox.min.z) + boxCorners[3]!.set(tempBox.min.x, tempBox.max.y, tempBox.max.z) + boxCorners[4]!.set(tempBox.max.x, tempBox.min.y, tempBox.min.z) + boxCorners[5]!.set(tempBox.max.x, tempBox.min.y, tempBox.max.z) + boxCorners[6]!.set(tempBox.max.x, tempBox.max.y, tempBox.min.z) + boxCorners[7]!.set(tempBox.max.x, tempBox.max.y, tempBox.max.z) + + let minX = Number.POSITIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + + for (const corner of boxCorners) { + const projected = projectWorldPointToScreen(corner, camera, canvasRect) + if (!projected) continue + const [x, y] = projected + minX = Math.min(minX, x) + minY = Math.min(minY, y) + maxX = Math.max(maxX, x) + maxY = Math.max(maxY, y) + } + + if (minX !== Number.POSITIVE_INFINITY) { + return { minX, minY, maxX, maxY } + } + + object.getWorldPosition(tempWorldPoint) + const projected = projectWorldPointToScreen(tempWorldPoint, camera, canvasRect) + if (!projected) return null + const [x, y] = projected + return { minX: x, minY: y, maxX: x, maxY: y } +} + +function isObjectVisible(object: Object3D): boolean { + let current: Object3D | null = object + while (current) { + if (!current.visible) return false + current = current.parent + } + return true +} + +function isFurnishSelectableCandidate(node: AnyNode): boolean { + if (node.type === 'item') { + return node.asset.category !== 'door' && node.asset.category !== 'window' + } + + const def = nodeRegistry.get(node.type) + return Boolean(def?.category === 'furnish' && def.capabilities.selectable) +} + +function isStructureSelectableCandidate(node: AnyNode): boolean { + if ( + node.type === 'wall' || + node.type === 'fence' || + node.type === 'column' || + node.type === 'elevator' || + node.type === 'slab' || + node.type === 'ceiling' || + node.type === 'roof' || + node.type === 'stair' || + node.type === 'spawn' || + node.type === 'window' || + node.type === 'door' + ) { + return true + } + + if (node.type === 'item') { + return node.asset.category === 'door' || node.asset.category === 'window' + } + + const def = nodeRegistry.get(node.type) + return Boolean(def && def.category !== 'furnish' && def.capabilities.selectable) +} + +function collectSelectableCandidateIds(): string[] { + const { levelId } = useViewer.getState().selection + const { nodes } = useScene.getState() + const { phase, structureLayer } = useEditor.getState() + const result: string[] = [] + const seen = new Set() + const addNode = (node: AnyNode | undefined) => { + if (!node || seen.has(node.id)) return + seen.add(node.id) + result.push(node.id) + } + + if (phase === 'site') { + for (const node of Object.values(nodes)) { + if (node.type === 'building') addNode(node) + } + return result + } + + if (!levelId) return [] + const levelNode = nodes[levelId as AnyNodeId] as LevelNode | undefined + if (!levelNode || levelNode.type !== 'level') return [] + + if (phase === 'structure' && structureLayer === 'zones') { + for (const childId of levelNode.children) { + const node = nodes[childId as AnyNodeId] + if (node?.type === 'zone') addNode(node) + } + return result + } + + for (const childId of levelNode.children) { + const node = nodes[childId as AnyNodeId] + if (!node) continue + + if (phase === 'furnish') { + if (isFurnishSelectableCandidate(node)) addNode(node) + continue + } + + if (node.type === 'wall' || node.type === 'fence') { + addNode(node) + const hostedChildren = 'children' in node && Array.isArray(node.children) ? node.children : [] + for (const hostedChildId of hostedChildren) { + const child = nodes[hostedChildId as AnyNodeId] + if (!child) continue + if ( + child.type === 'window' || + child.type === 'door' || + (child.type === 'item' && + (child.asset.category === 'door' || child.asset.category === 'window')) + ) { + addNode(child) + } + } + continue + } + + if (isStructureSelectableCandidate(node)) { + addNode(node) + } + } + + const buildingId = resolveBuildingForLevel(levelId as AnyNodeId, nodes) + const buildingNode = buildingId ? nodes[buildingId] : undefined + const buildingChildren = + buildingNode && 'children' in buildingNode && Array.isArray(buildingNode.children) + ? (buildingNode.children as AnyNodeId[]) + : [] + for (const childId of buildingChildren) { + const node = nodes[childId] + if (!node || node.type === 'level' || !isRegistrySelectable(node.type)) continue + if (phase === 'furnish') { + if (isFurnishSelectableCandidate(node)) addNode(node) + } else if (isStructureSelectableCandidate(node)) { + addNode(node) + } + } + + return result +} + +function collectNodeIdsInScreenRect( + rect: ScreenRect, + camera: Camera, + canvas: HTMLCanvasElement, +): string[] { + const canvasRect = canvas.getBoundingClientRect() + const result: string[] = [] + + for (const id of collectSelectableCandidateIds()) { + const object = sceneRegistry.nodes.get(id) + if (!object || !isObjectVisible(object)) continue + const objectRect = getObjectScreenRect(object, camera, canvasRect) + if (objectRect && screenRectsIntersect(rect, objectRect)) { + result.push(id) + } + } + + return result +} + +function commitBoxSelection(ids: string[], event: PointerEvent) { + const shouldAppend = event.metaKey || event.ctrlKey + const { phase, structureLayer } = useEditor.getState() + const viewer = useViewer.getState() + + if (phase === 'structure' && structureLayer === 'zones') { + if (ids.length > 0) { + viewer.setSelection({ zoneId: ids[0] as ZoneNode['id'] }) + } else if (!shouldAppend) { + viewer.setSelection({ zoneId: null }) + } return } - // Fill rect (unit plane scaled) - fillMesh.visible = true - fillMesh.position.set(cx, y + 0.02, cz) - fillMesh.scale.set(w, h, 1) + if (shouldAppend) { + viewer.setSelection({ + selectedIds: Array.from(new Set([...viewer.selection.selectedIds, ...ids])), + }) + return + } - // Outline — 4 edges as line segment pairs (8 vertices) - outline.visible = true - const oy = y + 0.03 - const x0 = cx - w / 2 - const x1 = cx + w / 2 - const z0 = cz - h / 2 - const z1 = cz + h / 2 - const pos = outline.geometry.attributes.position as BufferAttribute - // bottom: (x0,z0)→(x1,z0) - pos.setXYZ(0, x0, oy, z0) - pos.setXYZ(1, x1, oy, z0) - // right: (x1,z0)→(x1,z1) - pos.setXYZ(2, x1, oy, z0) - pos.setXYZ(3, x1, oy, z1) - // top: (x1,z1)→(x0,z1) - pos.setXYZ(4, x1, oy, z1) - pos.setXYZ(5, x0, oy, z1) - // left: (x0,z1)→(x0,z0) - pos.setXYZ(6, x0, oy, z1) - pos.setXYZ(7, x0, oy, z0) - pos.needsUpdate = true + viewer.setSelection({ selectedIds: ids }) } -// ── Outline geometry (allocated once, reused) ─────────────────────────────── - -function createOutlineSegments(): LineSegments { - const geo = new BufferGeometry() - // 4 edges × 2 vertices each = 8 vertices - const positions = new Float32Array(8 * 3) - geo.setAttribute('position', new BufferAttribute(positions, 3)) - - const mat = new LineBasicMaterial({ - color: BOX_SELECT_ACCENT_COLOR, - depthTest: false, - depthWrite: false, - transparent: true, - opacity: 0.85, - }) - - const segments = new LineSegments(geo, mat) - segments.layers.set(EDITOR_LAYER) - segments.renderOrder = 2 - segments.visible = false - segments.frustumCulled = false - - return segments -} - -// ── Drag threshold (pixels) ───────────────────────────────────────────────── - -const BOX_SELECT_ACCENT_COLOR = '#818cf8' -const DRAG_THRESHOLD_PX = 4 - -function getSnappedGridPosition(x: number, z: number): [number, number] { - return [Math.round(x * 2) / 2, Math.round(z * 2) / 2] -} - -function setSnappedPoint(target: Vector3, x: number, y: number, z: number) { - const [snappedX, snappedZ] = getSnappedGridPosition(x, z) - target.set(snappedX, y, snappedZ) -} - -// ── Component ─────────────────────────────────────────────────────────────── - export const BoxSelectTool: React.FC = () => { + const phase = useEditor((s) => s.phase) const mode = useEditor((s) => s.mode) - const selectionTool = useEditor((s) => s.floorplanSelectionTool) - const isActive = mode === 'select' && selectionTool === 'marquee' + const isActive = mode === 'select' && (phase === 'structure' || phase === 'furnish') if (!isActive) return null return } -const BOX_SELECT_TOOLTIP = ( - -) - const BoxSelectToolInner: React.FC = () => { const { camera, gl } = useThree() const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds) - const cursorRef = useRef(null) - const rectFillRef = useRef(null!) - const outlineRef = useRef(createOutlineSegments()) - const startPoint = useRef(new Vector3()) - const currentPoint = useRef(new Vector3()) - const pointerDown = useRef(false) - const isDragging = useRef(false) - const startClientX = useRef(0) - const startClientY = useRef(0) - const gridY = useRef(0) - const previousGridPosition = useRef<[number, number] | null>(null) + const elementRef = useRef(null) const previewSelectedIdsRef = useRef([]) + const pointerDownRef = useRef(false) + const isDraggingRef = useRef(false) + const ownsInputDraggingRef = useRef(false) + const pointerIdRef = useRef(null) + const startClientXRef = useRef(0) + const startClientYRef = useRef(0) + const currentClientXRef = useRef(0) + const currentClientYRef = useRef(0) + const handledResetTimeoutRef = useRef | null>(null) + const spaceDownRef = useRef(false) - // Raycasting helpers (same technique as useGridEvents) - const raycasterRef = useRef(new Raycaster()) - const pointerNDC = useRef(new Vector2()) - const groundPlane = useRef(new Plane(new Vector3(0, 1, 0), 0)) - const hitPoint = useRef(new Vector3()) - - // Cleanup outline geometry on unmount - useEffect(() => { - const outline = outlineRef.current - return () => { - previewSelectedIdsRef.current = [] - setPreviewSelectedIds([]) - outline.geometry.dispose() - ;(outline.material as LineBasicMaterial).dispose() + const markBoxSelectHandled = useCallback(() => { + boxSelectHandled = true + if (handledResetTimeoutRef.current) { + clearTimeout(handledResetTimeoutRef.current) } - }, [setPreviewSelectedIds]) - - const syncPreviewSelectedIds = (nextIds: string[]) => { - if (haveSameIds(previewSelectedIdsRef.current, nextIds)) { - return - } - - previewSelectedIdsRef.current = nextIds - setPreviewSelectedIds(nextIds) - } - - // Sync ground plane Y with the current level - useEffect(() => { - const unsubscribe = useViewer.subscribe((state) => { - const levelId = state.selection.levelId - if (!levelId) return - const obj = sceneRegistry.nodes.get(levelId) - if (obj) groundPlane.current.constant = -obj.position.y - }) - // Set initial value - const levelId = useViewer.getState().selection.levelId - if (levelId) { - const obj = sceneRegistry.nodes.get(levelId) - if (obj) groundPlane.current.constant = -obj.position.y - } - return unsubscribe + handledResetTimeoutRef.current = setTimeout(() => { + boxSelectHandled = false + handledResetTimeoutRef.current = null + }, 50) }, []) - const raycastToGround = (e: PointerEvent): Vector3 | null => { - const rect = gl.domElement.getBoundingClientRect() - pointerNDC.current.x = ((e.clientX - rect.left) / rect.width) * 2 - 1 - pointerNDC.current.y = -((e.clientY - rect.top) / rect.height) * 2 + 1 - raycasterRef.current.setFromCamera(pointerNDC.current, camera) - if (raycasterRef.current.ray.intersectPlane(groundPlane.current, hitPoint.current)) { - return hitPoint.current + const syncPreviewSelectedIds = useCallback( + (nextIds: string[]) => { + if (haveSameIds(previewSelectedIdsRef.current, nextIds)) return + + previewSelectedIdsRef.current = nextIds + setPreviewSelectedIds(nextIds) + }, + [setPreviewSelectedIds], + ) + + const resetDrag = useCallback(() => { + pointerDownRef.current = false + isDraggingRef.current = false + pointerIdRef.current = null + hideSelectionElement(elementRef.current) + syncPreviewSelectedIds([]) + + if (ownsInputDraggingRef.current) { + useViewer.getState().setInputDragging(false) + ownsInputDraggingRef.current = false } - return null - } + }, [syncPreviewSelectedIds]) + + useEffect(() => { + const element = createSelectionElement() + document.body.appendChild(element) + elementRef.current = element + + return () => { + element.remove() + elementRef.current = null + } + }, []) + + useEffect(() => { + const cancelForSpace = () => { + if (!pointerDownRef.current) return + markBoxSelectHandled() + resetDrag() + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.code !== 'Space') return + spaceDownRef.current = true + cancelForSpace() + } + + const onKeyUp = (event: KeyboardEvent) => { + if (event.code !== 'Space') return + spaceDownRef.current = false + } + + const onBlur = () => { + spaceDownRef.current = false + resetDrag() + } + + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + window.addEventListener('blur', onBlur) + + return () => { + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + window.removeEventListener('blur', onBlur) + } + }, [markBoxSelectHandled, resetDrag]) useEffect(() => { const canvas = gl.domElement - const onCanvasPointerDown = (e: PointerEvent) => { - if (e.button !== 0) return - if (useViewer.getState().cameraDragging) return - // A gizmo/handle drag is underway (group rotate/move, resize arrows, …). - // Those use raw window listeners too, so without this guard box-select - // would run in parallel and clobber the selection on release. - if (useViewer.getState().inputDragging) return + const updateDrag = (event: PointerEvent) => { + if (!pointerDownRef.current) return + if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.current) return - const point = raycastToGround(e) - if (!point) return + const viewer = useViewer.getState() + if ( + spaceDownRef.current || + viewer.cameraDragging || + (viewer.inputDragging && !ownsInputDraggingRef.current) + ) { + markBoxSelectHandled() + resetDrag() + return + } - setSnappedPoint(startPoint.current, point.x, point.y, point.z) - setSnappedPoint(currentPoint.current, point.x, point.y, point.z) - gridY.current = point.y - pointerDown.current = true - isDragging.current = false - previousGridPosition.current = getSnappedGridPosition(point.x, point.z) - startClientX.current = e.clientX - startClientY.current = e.clientY - syncPreviewSelectedIds([]) - } + currentClientXRef.current = event.clientX + currentClientYRef.current = event.clientY - const onCanvasPointerUp = (e: PointerEvent) => { - if (e.button !== 0) return - // If a gizmo/handle drag is in progress, don't let box-select replace the - // selection. Canvas listeners fire before the gizmo's window pointer-up - // (which clears `inputDragging`), so this still reads true here. Reset our - // own state and bail. - if (useViewer.getState().inputDragging) { - pointerDown.current = false - isDragging.current = false - if (rectFillRef.current) rectFillRef.current.visible = false - if (outlineRef.current) outlineRef.current.visible = false + const dragDistance = Math.hypot( + currentClientXRef.current - startClientXRef.current, + currentClientYRef.current - startClientYRef.current, + ) + + if (!isDraggingRef.current && dragDistance >= DRAG_THRESHOLD_PX) { + isDraggingRef.current = true + ownsInputDraggingRef.current = true + useViewer.getState().setInputDragging(true) + markBoxSelectHandled() + } + + if (!isDraggingRef.current) return + + event.preventDefault() + const rect = normalizeScreenRect( + startClientXRef.current, + startClientYRef.current, + currentClientXRef.current, + currentClientYRef.current, + ) + const clampedRect = intersectScreenRects( + rect, + screenRectFromDomRect(canvas.getBoundingClientRect()), + ) + if (!clampedRect) { + hideSelectionElement(elementRef.current) syncPreviewSelectedIds([]) return } - if (!pointerDown.current) return - if (isDragging.current) { - const point = raycastToGround(e) - if (point) setSnappedPoint(currentPoint.current, point.x, point.y, point.z) + updateSelectionElement(elementRef.current!, clampedRect) + syncPreviewSelectedIds(collectNodeIdsInScreenRect(clampedRect, camera, canvas)) + } - const bounds: Bounds = { - minX: Math.min(startPoint.current.x, currentPoint.current.x), - maxX: Math.max(startPoint.current.x, currentPoint.current.x), - minZ: Math.min(startPoint.current.z, currentPoint.current.z), - maxZ: Math.max(startPoint.current.z, currentPoint.current.z), - } + const finishDrag = (event: PointerEvent) => { + if (!pointerDownRef.current) return + if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.current) return - const ids = collectNodeIdsInBounds(bounds) - - const shouldAppend = e.metaKey || e.ctrlKey - const { phase, structureLayer } = useEditor.getState() - - if (phase === 'structure' && structureLayer === 'zones') { - if (ids.length > 0) { - useViewer.getState().setSelection({ zoneId: ids[0] as ZoneNode['id'] }) - } else if (!shouldAppend) { - useViewer.getState().setSelection({ zoneId: null }) - } - } else if (shouldAppend) { - const currentIds = useViewer.getState().selection.selectedIds - const merged = Array.from(new Set([...currentIds, ...ids])) - useViewer.getState().setSelection({ selectedIds: merged }) - } else { - // If the box captured every selectable node on the level, promote to - // selecting the parent building — same as clicking it in the side menu. - const allOnLevel = collectNodeIdsInBounds(null) - const { buildingId } = useViewer.getState().selection - const selectedEntireLevel = allOnLevel.length > 0 && ids.length === allOnLevel.length - - if (selectedEntireLevel && buildingId) { - useViewer.getState().setSelection({ buildingId }) - } else { - useViewer.getState().setSelection({ selectedIds: ids }) - } - } - - // Prevent the subsequent grid:click from deselecting - boxSelectHandled = true - setTimeout(() => { - boxSelectHandled = false - }, 50) + if (useViewer.getState().inputDragging && !ownsInputDraggingRef.current) { + markBoxSelectHandled() + resetDrag() + return } - // NOTE: Short clicks (no drag) fall through to the SelectionManager's - // existing grid:click / node:click handlers — no extra logic needed here. - // Hide visuals - if (rectFillRef.current) rectFillRef.current.visible = false - if (outlineRef.current) outlineRef.current.visible = false + if (isDraggingRef.current) { + event.preventDefault() + event.stopPropagation() + markBoxSelectHandled() + + const rect = normalizeScreenRect( + startClientXRef.current, + startClientYRef.current, + event.clientX, + event.clientY, + ) + const clampedRect = intersectScreenRects( + rect, + screenRectFromDomRect(canvas.getBoundingClientRect()), + ) + const ids = clampedRect ? collectNodeIdsInScreenRect(clampedRect, camera, canvas) : [] + commitBoxSelection(ids, event) + } + + try { + canvas.releasePointerCapture(event.pointerId) + } catch {} + + resetDrag() + } + + const onCanvasPointerDown = (event: PointerEvent) => { + if (event.button !== 0) return + if (spaceDownRef.current) return + + const viewer = useViewer.getState() + if (viewer.cameraDragging || viewer.inputDragging) return + + pointerDownRef.current = true + isDraggingRef.current = false + pointerIdRef.current = event.pointerId + startClientXRef.current = event.clientX + startClientYRef.current = event.clientY + currentClientXRef.current = event.clientX + currentClientYRef.current = event.clientY syncPreviewSelectedIds([]) - // Reset - pointerDown.current = false - isDragging.current = false + try { + canvas.setPointerCapture(event.pointerId) + } catch {} + } + + const onPointerCancel = (event: PointerEvent) => { + if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.current) return + resetDrag() } canvas.addEventListener('pointerdown', onCanvasPointerDown) - canvas.addEventListener('pointerup', onCanvasPointerUp) + window.addEventListener('pointermove', updateDrag, { passive: false }) + window.addEventListener('pointerup', finishDrag) + window.addEventListener('pointercancel', onPointerCancel) return () => { canvas.removeEventListener('pointerdown', onCanvasPointerDown) - canvas.removeEventListener('pointerup', onCanvasPointerUp) + window.removeEventListener('pointermove', updateDrag) + window.removeEventListener('pointerup', finishDrag) + window.removeEventListener('pointercancel', onPointerCancel) + resetDrag() } - }, [gl, raycastToGround, syncPreviewSelectedIds]) + }, [camera, gl, markBoxSelectHandled, resetDrag, syncPreviewSelectedIds]) - // grid:move for cursor tracking + rectangle update during drag useEffect(() => { - const onMove = (event: GridEvent) => { - const [snappedX, snappedZ] = getSnappedGridPosition(event.position[0], event.position[2]) - - // Always update cursor position - if (cursorRef.current) { - cursorRef.current.position.set(snappedX, event.position[1], snappedZ) - } - - if (!pointerDown.current) return - // A gizmo/handle drag took over — don't draw a selection box underneath it. - if (useViewer.getState().inputDragging) return - - currentPoint.current.set(snappedX, event.position[1], snappedZ) - - // Check drag threshold (screen pixels) - const nativeEvent = event.nativeEvent as unknown as PointerEvent - const dx = nativeEvent.clientX - startClientX.current - const dy = nativeEvent.clientY - startClientY.current - if (!isDragging.current && Math.hypot(dx, dy) >= DRAG_THRESHOLD_PX) { - isDragging.current = true - } - - if (isDragging.current && rectFillRef.current && outlineRef.current) { - updateRectVisuals( - rectFillRef.current, - outlineRef.current, - startPoint.current, - currentPoint.current, - gridY.current, - ) - - const nextGridPosition: [number, number] = [snappedX, snappedZ] - if ( - previousGridPosition.current && - (nextGridPosition[0] !== previousGridPosition.current[0] || - nextGridPosition[1] !== previousGridPosition.current[1]) - ) { - sfxEmitter.emit('sfx:grid-snap') - } - previousGridPosition.current = nextGridPosition - - const bounds: Bounds = { - minX: Math.min(startPoint.current.x, currentPoint.current.x), - maxX: Math.max(startPoint.current.x, currentPoint.current.x), - minZ: Math.min(startPoint.current.z, currentPoint.current.z), - maxZ: Math.max(startPoint.current.z, currentPoint.current.z), - } - syncPreviewSelectedIds(collectNodeIdsInBounds(bounds)) - } - } - - emitter.on('grid:move', onMove) return () => { - emitter.off('grid:move', onMove) + if (handledResetTimeoutRef.current) { + clearTimeout(handledResetTimeoutRef.current) + } + boxSelectHandled = false + resetDrag() } - }, [syncPreviewSelectedIds]) + }, [resetDrag]) - return ( - - {/* Cursor indicator */} - - - {/* Selection rectangle fill */} - - - - - - {/* Outline (LineLoop added as primitive — allocated once in ref) */} - - - ) + return null }