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) */}
)
}