diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 8cdbc2ef..e41007e1 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -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. diff --git a/packages/editor/src/components/tools/select/box-select-state.ts b/packages/editor/src/components/tools/select/box-select-state.ts new file mode 100644 index 00000000..cacdb50f --- /dev/null +++ b/packages/editor/src/components/tools/select/box-select-state.ts @@ -0,0 +1,22 @@ +export let boxSelectHandled = false + +let resetTimeout: ReturnType | 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 +} 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 76e0853a..62f29992 100644 --- a/packages/editor/src/components/tools/select/box-select-tool.tsx +++ b/packages/editor/src/components/tools/select/box-select-tool.tsx @@ -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() - 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 + if (selectionTool === 'marquee') { + return + } + + return } -const BoxSelectToolInner: React.FC = () => { +const ScreenRectangleSelectTool: React.FC = () => { const { camera, gl } = useThree() const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds) const elementRef = useRef(null) @@ -376,20 +252,8 @@ const BoxSelectToolInner: React.FC = () => { const startClientYRef = useRef(0) const currentClientXRef = useRef(0) const currentClientYRef = useRef(0) - const handledResetTimeoutRef = useRef | 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]) diff --git a/packages/editor/src/components/tools/select/plane-box-select-tool.tsx b/packages/editor/src/components/tools/select/plane-box-select-tool.tsx new file mode 100644 index 00000000..63b75cf2 --- /dev/null +++ b/packages/editor/src/components/tools/select/plane-box-select-tool.tsx @@ -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 = ( + +) + +export const PlaneBoxSelectTool: 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([]) + 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 ( + + + + + + + + + ) +} diff --git a/packages/editor/src/components/tools/select/select-candidates.ts b/packages/editor/src/components/tools/select/select-candidates.ts new file mode 100644 index 00000000..2ef4d371 --- /dev/null +++ b/packages/editor/src/components/tools/select/select-candidates.ts @@ -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() + 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 +} diff --git a/packages/editor/src/components/tools/shared/polygon-editor.tsx b/packages/editor/src/components/tools/shared/polygon-editor.tsx index 32bcf0d9..ce8c4b6f 100644 --- a/packages/editor/src/components/tools/shared/polygon-editor.tsx +++ b/packages/editor/src/components/tools/shared/polygon-editor.tsx @@ -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 = ({ const [dragState, setDragState] = useState(null) const [previewPolygon, setPreviewPolygon] = useState | null>(null) const previewPolygonRef = useRef | null>(null) + const previousInputDraggingRef = useRef(false) const onPolygonPreviewRef = useRef(onPolygonPreview) useEffect(() => { @@ -346,6 +347,20 @@ export const PolygonEditor: React.FC = ({ const lineRef = useRef(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 = ({ 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 = ({ 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 = ({ if (!edgeNormal) return setHoveredEdge(null) - setDragState({ + startDrag({ isDragging: true, mode: 'edge', vertexIndex: null, @@ -835,7 +850,7 @@ export const PolygonEditor: React.FC = ({ e.stopPropagation() const insertedVertex = handleAddVertex(index, [x!, z!]) if (insertedVertex.vertexIndex >= 0) { - setDragState({ + startDrag({ isDragging: true, mode: 'vertex', vertexIndex: insertedVertex.vertexIndex,