import '../../../three-types' import { Icon } from '@iconify/react' import { type AnyNodeId, type CeilingNode, type ColumnNode, emitter, type GridEvent, type ItemNode, isRegistrySelectable, type LevelNode, 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 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 * on the grid:click that fires right after a box-select drag completes. */ export let boxSelectHandled = false // ── Geometry helpers ──────────────────────────────────────────────────────── type Bounds = { minX: number; maxX: number; minZ: number; maxZ: number } 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 } function haveSameIds(currentIds: string[], nextIds: string[]): boolean { return ( currentIds.length === nextIds.length && currentIds.every((currentId, index) => currentId === nextIds[index]) ) } // ── Visual helpers ────────────────────────────────────────────────────────── 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) if (w < 0.01 && h < 0.01) { fillMesh.visible = false outline.visible = false return } // Fill rect (unit plane scaled) fillMesh.visible = true fillMesh.position.set(cx, y + 0.02, cz) fillMesh.scale.set(w, h, 1) // 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 } // ── 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 mode = useEditor((s) => s.mode) const selectionTool = useEditor((s) => s.floorplanSelectionTool) const isActive = mode === 'select' && selectionTool === 'marquee' 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 previewSelectedIdsRef = useRef([]) // 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() } }, [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 }, []) 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 } return null } 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 point = raycastToGround(e) if (!point) 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([]) } 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 syncPreviewSelectedIds([]) return } if (!pointerDown.current) return if (isDragging.current) { const point = raycastToGround(e) if (point) setSnappedPoint(currentPoint.current, point.x, point.y, point.z) 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 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) } // 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 syncPreviewSelectedIds([]) // Reset pointerDown.current = false isDragging.current = false } canvas.addEventListener('pointerdown', onCanvasPointerDown) canvas.addEventListener('pointerup', onCanvasPointerUp) return () => { canvas.removeEventListener('pointerdown', onCanvasPointerDown) canvas.removeEventListener('pointerup', onCanvasPointerUp) } }, [gl, raycastToGround, 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) } }, [syncPreviewSelectedIds]) return ( {/* Cursor indicator */} {/* Selection rectangle fill */} {/* Outline (LineLoop added as primitive — allocated once in ref) */} ) }