Separate screen and plane box selection

This commit is contained in:
Aymeric Rabot
2026-06-07 00:43:34 -04:00
parent 2b3d3d06b5
commit fe0a1056f2
6 changed files with 753 additions and 163 deletions
@@ -57,7 +57,7 @@ import useEditor, {
type Phase,
type StructureLayer,
} from './../../store/use-editor'
import { boxSelectHandled } from '../tools/select/box-select-tool'
import { boxSelectHandled } from '../tools/select/box-select-state'
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
// Elevators are building-scoped, so they stay selectable across level filters.
@@ -0,0 +1,22 @@
export let boxSelectHandled = false
let resetTimeout: ReturnType<typeof setTimeout> | null = null
export function markBoxSelectHandled() {
boxSelectHandled = true
if (resetTimeout) {
clearTimeout(resetTimeout)
}
resetTimeout = setTimeout(() => {
boxSelectHandled = false
resetTimeout = null
}, 50)
}
export function clearBoxSelectHandled() {
if (resetTimeout) {
clearTimeout(resetTimeout)
resetTimeout = null
}
boxSelectHandled = false
}
@@ -1,25 +1,12 @@
import {
type AnyNode,
type AnyNodeId,
isRegistrySelectable,
type LevelNode,
nodeRegistry,
resolveBuildingForLevel,
sceneRegistry,
useScene,
type ZoneNode,
} from '@pascal-app/core'
import { sceneRegistry, type ZoneNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useRef } from 'react'
import { Box3, type Camera, type Object3D, Vector3 } from 'three'
import useEditor from '../../../store/use-editor'
/**
* 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
import { clearBoxSelectHandled, markBoxSelectHandled } from './box-select-state'
import { PlaneBoxSelectTool } from './plane-box-select-tool'
import { collectSelectableCandidateIds } from './select-candidates'
type ScreenRect = { minX: number; minY: number; maxX: number; maxY: number }
@@ -193,122 +180,6 @@ function isObjectVisible(object: Object3D): boolean {
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<string>()
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,
@@ -356,14 +227,19 @@ function commitBoxSelection(ids: string[], event: PointerEvent) {
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' && (phase === 'structure' || phase === 'furnish')
if (!isActive) return null
return <BoxSelectToolInner />
if (selectionTool === 'marquee') {
return <PlaneBoxSelectTool />
}
return <ScreenRectangleSelectTool />
}
const BoxSelectToolInner: React.FC = () => {
const ScreenRectangleSelectTool: React.FC = () => {
const { camera, gl } = useThree()
const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds)
const elementRef = useRef<HTMLDivElement | null>(null)
@@ -376,20 +252,8 @@ const BoxSelectToolInner: React.FC = () => {
const startClientYRef = useRef(0)
const currentClientXRef = useRef(0)
const currentClientYRef = useRef(0)
const handledResetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const spaceDownRef = useRef(false)
const markBoxSelectHandled = useCallback(() => {
boxSelectHandled = true
if (handledResetTimeoutRef.current) {
clearTimeout(handledResetTimeoutRef.current)
}
handledResetTimeoutRef.current = setTimeout(() => {
boxSelectHandled = false
handledResetTimeoutRef.current = null
}, 50)
}, [])
const syncPreviewSelectedIds = useCallback(
(nextIds: string[]) => {
if (haveSameIds(previewSelectedIdsRef.current, nextIds)) return
@@ -456,7 +320,7 @@ const BoxSelectToolInner: React.FC = () => {
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onBlur)
}
}, [markBoxSelectHandled, resetDrag])
}, [resetDrag])
useEffect(() => {
const canvas = gl.domElement
@@ -489,6 +353,9 @@ const BoxSelectToolInner: React.FC = () => {
ownsInputDraggingRef.current = true
useViewer.getState().setInputDragging(true)
markBoxSelectHandled()
try {
canvas.setPointerCapture(event.pointerId)
} catch {}
}
if (!isDraggingRef.current) return
@@ -565,10 +432,6 @@ const BoxSelectToolInner: React.FC = () => {
currentClientXRef.current = event.clientX
currentClientYRef.current = event.clientY
syncPreviewSelectedIds([])
try {
canvas.setPointerCapture(event.pointerId)
} catch {}
}
const onPointerCancel = (event: PointerEvent) => {
@@ -588,14 +451,11 @@ const BoxSelectToolInner: React.FC = () => {
window.removeEventListener('pointercancel', onPointerCancel)
resetDrag()
}
}, [camera, gl, markBoxSelectHandled, resetDrag, syncPreviewSelectedIds])
}, [camera, gl, resetDrag, syncPreviewSelectedIds])
useEffect(() => {
return () => {
if (handledResetTimeoutRef.current) {
clearTimeout(handledResetTimeoutRef.current)
}
boxSelectHandled = false
clearBoxSelectHandled()
resetDrag()
}
}, [resetDrag])
@@ -0,0 +1,566 @@
import '../../../three-types'
import { Icon } from '@iconify/react'
import {
type AnyNodeId,
emitter,
type GridEvent,
sceneRegistry,
useScene,
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 { useCallback, 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'
import { markBoxSelectHandled } from './box-select-state'
import { collectSelectableCandidateIds } from './select-candidates'
declare module 'react/jsx-runtime' {
namespace JSX {
interface IntrinsicElements extends ThreeElements {}
}
}
type Bounds = { minX: number; maxX: number; minZ: number; maxZ: number }
const BOX_SELECT_ACCENT_COLOR = '#818cf8'
const DRAG_THRESHOLD_PX = 4
const tempVec = new Vector3()
const tempBox = new Box3()
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 && onSegment(bx1, bz1, bx2, bz2, ax1, az1)) return true
if (d2 === 0 && onSegment(bx1, bz1, bx2, bz2, ax2, az2)) return true
if (d3 === 0 && onSegment(ax1, az1, ax2, az2, bx1, bz1)) return true
if (d4 === 0 && onSegment(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 onSegment(
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
}
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
)
}
function collectNodeIdsInPlaneBounds(bounds: Bounds | null): string[] {
const candidateIds = collectSelectableCandidateIds()
if (!bounds) return candidateIds
const { nodes } = useScene.getState()
return candidateIds.filter((id) => {
const node = nodes[id as AnyNodeId]
if (!node) return false
if (node.type === 'wall' || node.type === 'fence') {
return segmentIntersectsBounds(node.start[0], node.start[1], node.end[0], node.end[1], bounds)
}
if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') {
return polygonIntersectsBounds(node.polygon, bounds)
}
return objectBoundsIntersectsBounds(id, bounds)
})
}
function haveSameIds(currentIds: string[], nextIds: string[]): boolean {
return (
currentIds.length === nextIds.length &&
currentIds.every((currentId, index) => currentId === nextIds[index])
)
}
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
}
fillMesh.visible = true
fillMesh.position.set(cx, y + 0.02, cz)
fillMesh.scale.set(w, h, 1)
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
pos.setXYZ(0, x0, oy, z0)
pos.setXYZ(1, x1, oy, z0)
pos.setXYZ(2, x1, oy, z0)
pos.setXYZ(3, x1, oy, z1)
pos.setXYZ(4, x1, oy, z1)
pos.setXYZ(5, x0, oy, z1)
pos.setXYZ(6, x0, oy, z1)
pos.setXYZ(7, x0, oy, z0)
pos.needsUpdate = true
}
function createOutlineSegments(): LineSegments {
const geometry = new BufferGeometry()
geometry.setAttribute('position', new BufferAttribute(new Float32Array(8 * 3), 3))
const material = new LineBasicMaterial({
color: BOX_SELECT_ACCENT_COLOR,
depthTest: false,
depthWrite: false,
transparent: true,
opacity: 0.85,
})
const segments = new LineSegments(geometry, material)
segments.layers.set(EDITOR_LAYER)
segments.renderOrder = 2
segments.visible = false
segments.frustumCulled = false
return segments
}
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)
}
const BOX_SELECT_TOOLTIP = (
<Icon
color="currentColor"
height={24}
icon="mdi:select-drag"
style={{ filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))' }}
width={24}
/>
)
export const PlaneBoxSelectTool: React.FC = () => {
const { camera, gl } = useThree()
const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds)
const cursorRef = useRef<Group>(null)
const rectFillRef = useRef<Mesh>(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<string[]>([])
const spaceDownRef = useRef(false)
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())
const syncPreviewSelectedIds = useCallback(
(nextIds: string[]) => {
if (haveSameIds(previewSelectedIdsRef.current, nextIds)) return
previewSelectedIdsRef.current = nextIds
setPreviewSelectedIds(nextIds)
},
[setPreviewSelectedIds],
)
const resetDrag = useCallback(() => {
pointerDown.current = false
isDragging.current = false
rectFillRef.current.visible = false
outlineRef.current.visible = false
syncPreviewSelectedIds([])
}, [syncPreviewSelectedIds])
const raycastToGround = useCallback(
(event: PointerEvent): Vector3 | null => {
const rect = gl.domElement.getBoundingClientRect()
pointerNDC.current.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
pointerNDC.current.y = -((event.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
},
[camera, gl],
)
useEffect(() => {
const outline = outlineRef.current
return () => {
previewSelectedIdsRef.current = []
setPreviewSelectedIds([])
outline.geometry.dispose()
;(outline.material as LineBasicMaterial).dispose()
}
}, [setPreviewSelectedIds])
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
})
const levelId = useViewer.getState().selection.levelId
if (levelId) {
const obj = sceneRegistry.nodes.get(levelId)
if (obj) groundPlane.current.constant = -obj.position.y
}
return unsubscribe
}, [])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.code !== 'Space') return
spaceDownRef.current = true
if (pointerDown.current) {
resetDrag()
}
}
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)
}
}, [resetDrag])
useEffect(() => {
const canvas = gl.domElement
const onCanvasPointerDown = (event: PointerEvent) => {
if (event.button !== 0) return
if (spaceDownRef.current) return
if (useViewer.getState().cameraDragging) return
if (useViewer.getState().inputDragging) return
const point = raycastToGround(event)
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 = event.clientX
startClientY.current = event.clientY
syncPreviewSelectedIds([])
}
const onCanvasPointerUp = (event: PointerEvent) => {
if (event.button !== 0) return
if (useViewer.getState().inputDragging) {
resetDrag()
return
}
if (!pointerDown.current) return
if (isDragging.current) {
const point = raycastToGround(event)
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 = collectNodeIdsInPlaneBounds(bounds)
const shouldAppend = event.metaKey || event.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
useViewer.getState().setSelection({
selectedIds: Array.from(new Set([...currentIds, ...ids])),
})
} else {
const allOnLevel = collectNodeIdsInPlaneBounds(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 })
}
}
markBoxSelectHandled()
}
resetDrag()
}
canvas.addEventListener('pointerdown', onCanvasPointerDown)
canvas.addEventListener('pointerup', onCanvasPointerUp)
return () => {
canvas.removeEventListener('pointerdown', onCanvasPointerDown)
canvas.removeEventListener('pointerup', onCanvasPointerUp)
}
}, [gl, raycastToGround, resetDrag, syncPreviewSelectedIds])
useEffect(() => {
const onMove = (event: GridEvent) => {
const [snappedX, snappedZ] = getSnappedGridPosition(event.position[0], event.position[2])
if (cursorRef.current) {
cursorRef.current.position.set(snappedX, event.position[1], snappedZ)
}
if (!pointerDown.current) return
if (spaceDownRef.current || useViewer.getState().inputDragging) return
currentPoint.current.set(snappedX, event.position[1], snappedZ)
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(collectNodeIdsInPlaneBounds(bounds))
}
}
emitter.on('grid:move', onMove)
return () => {
emitter.off('grid:move', onMove)
}
}, [syncPreviewSelectedIds])
return (
<group>
<CursorSphere ref={cursorRef} tooltipContent={BOX_SELECT_TOOLTIP} />
<mesh
layers={EDITOR_LAYER}
ref={rectFillRef}
renderOrder={1}
rotation={[-Math.PI / 2, 0, 0]}
visible={false}
>
<planeGeometry args={[1, 1]} />
<meshBasicMaterial
color={BOX_SELECT_ACCENT_COLOR}
depthTest={false}
depthWrite={false}
opacity={0.14}
side={DoubleSide}
transparent
/>
</mesh>
<primitive object={outlineRef.current} />
</group>
)
}
@@ -0,0 +1,127 @@
import {
type AnyNode,
type AnyNodeId,
isRegistrySelectable,
type LevelNode,
nodeRegistry,
resolveBuildingForLevel,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import useEditor from '../../../store/use-editor'
export 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)
}
export 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)
}
export function collectSelectableCandidateIds(): string[] {
const { levelId } = useViewer.getState().selection
const { nodes } = useScene.getState()
const { phase, structureLayer } = useEditor.getState()
const result: string[] = []
const seen = new Set<string>()
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
}
@@ -1,5 +1,5 @@
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
import { SCENE_LAYER } from '@pascal-app/viewer'
import { SCENE_LAYER, useViewer } from '@pascal-app/viewer'
import { createPortal, type ThreeEvent } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
@@ -317,6 +317,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const [dragState, setDragState] = useState<DragState | null>(null)
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
const previewPolygonRef = useRef<Array<[number, number]> | null>(null)
const previousInputDraggingRef = useRef(false)
const onPolygonPreviewRef = useRef(onPolygonPreview)
useEffect(() => {
@@ -346,6 +347,20 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const lineRef = useRef<Line>(null!)
const previousPositionRef = useRef<[number, number] | null>(null)
const startDrag = useCallback((nextDragState: DragState) => {
previousInputDraggingRef.current = useViewer.getState().inputDragging
useViewer.getState().setInputDragging(true)
setDragState(nextDragState)
}, [])
useEffect(() => {
if (!dragState?.isDragging) return
return () => {
useViewer.getState().setInputDragging(previousInputDraggingRef.current)
}
}, [dragState?.isDragging])
// Track the last polygon prop to detect external changes (undo/redo) or
// our own post-commit prop update arriving while a preview is still in
// flight. Either way, drop the stale preview/drag.
@@ -687,7 +702,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
if (e.button !== 0) return
e.stopPropagation()
setHoveredEdge(null)
setDragState({
startDrag({
isDragging: true,
mode: 'vertex',
vertexIndex: index,
@@ -721,7 +736,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
if (e.button !== 0) return
e.stopPropagation()
setHoveredEdge(null)
setDragState({
startDrag({
isDragging: true,
mode: 'polygon',
vertexIndex: null,
@@ -750,7 +765,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
if (!edgeNormal) return
setHoveredEdge(null)
setDragState({
startDrag({
isDragging: true,
mode: 'edge',
vertexIndex: null,
@@ -835,7 +850,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
e.stopPropagation()
const insertedVertex = handleAddVertex(index, [x!, z!])
if (insertedVertex.vertexIndex >= 0) {
setDragState({
startDrag({
isDragging: true,
mode: 'vertex',
vertexIndex: insertedVertex.vertexIndex,