Separate screen and plane box selection
This commit is contained in:
@@ -57,7 +57,7 @@ import useEditor, {
|
|||||||
type Phase,
|
type Phase,
|
||||||
type StructureLayer,
|
type StructureLayer,
|
||||||
} from './../../store/use-editor'
|
} 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 => {
|
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
||||||
// Elevators are building-scoped, so they stay selectable across level filters.
|
// 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 {
|
import { sceneRegistry, type ZoneNode } from '@pascal-app/core'
|
||||||
type AnyNode,
|
|
||||||
type AnyNodeId,
|
|
||||||
isRegistrySelectable,
|
|
||||||
type LevelNode,
|
|
||||||
nodeRegistry,
|
|
||||||
resolveBuildingForLevel,
|
|
||||||
sceneRegistry,
|
|
||||||
useScene,
|
|
||||||
type ZoneNode,
|
|
||||||
} from '@pascal-app/core'
|
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useThree } from '@react-three/fiber'
|
import { useThree } from '@react-three/fiber'
|
||||||
import { useCallback, useEffect, useRef } from 'react'
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
import { Box3, type Camera, type Object3D, Vector3 } from 'three'
|
import { Box3, type Camera, type Object3D, Vector3 } from 'three'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
|
import { clearBoxSelectHandled, markBoxSelectHandled } from './box-select-state'
|
||||||
/**
|
import { PlaneBoxSelectTool } from './plane-box-select-tool'
|
||||||
* Module-level flag to prevent the SelectionManager from deselecting
|
import { collectSelectableCandidateIds } from './select-candidates'
|
||||||
* on the grid:click that fires right after a box-select drag completes.
|
|
||||||
*/
|
|
||||||
export let boxSelectHandled = false
|
|
||||||
|
|
||||||
type ScreenRect = { minX: number; minY: number; maxX: number; maxY: number }
|
type ScreenRect = { minX: number; minY: number; maxX: number; maxY: number }
|
||||||
|
|
||||||
@@ -193,122 +180,6 @@ function isObjectVisible(object: Object3D): boolean {
|
|||||||
return true
|
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(
|
function collectNodeIdsInScreenRect(
|
||||||
rect: ScreenRect,
|
rect: ScreenRect,
|
||||||
camera: Camera,
|
camera: Camera,
|
||||||
@@ -356,14 +227,19 @@ function commitBoxSelection(ids: string[], event: PointerEvent) {
|
|||||||
export const BoxSelectTool: React.FC = () => {
|
export const BoxSelectTool: React.FC = () => {
|
||||||
const phase = useEditor((s) => s.phase)
|
const phase = useEditor((s) => s.phase)
|
||||||
const mode = useEditor((s) => s.mode)
|
const mode = useEditor((s) => s.mode)
|
||||||
|
const selectionTool = useEditor((s) => s.floorplanSelectionTool)
|
||||||
const isActive = mode === 'select' && (phase === 'structure' || phase === 'furnish')
|
const isActive = mode === 'select' && (phase === 'structure' || phase === 'furnish')
|
||||||
|
|
||||||
if (!isActive) return null
|
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 { camera, gl } = useThree()
|
||||||
const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds)
|
const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds)
|
||||||
const elementRef = useRef<HTMLDivElement | null>(null)
|
const elementRef = useRef<HTMLDivElement | null>(null)
|
||||||
@@ -376,20 +252,8 @@ const BoxSelectToolInner: React.FC = () => {
|
|||||||
const startClientYRef = useRef(0)
|
const startClientYRef = useRef(0)
|
||||||
const currentClientXRef = useRef(0)
|
const currentClientXRef = useRef(0)
|
||||||
const currentClientYRef = useRef(0)
|
const currentClientYRef = useRef(0)
|
||||||
const handledResetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
const spaceDownRef = useRef(false)
|
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(
|
const syncPreviewSelectedIds = useCallback(
|
||||||
(nextIds: string[]) => {
|
(nextIds: string[]) => {
|
||||||
if (haveSameIds(previewSelectedIdsRef.current, nextIds)) return
|
if (haveSameIds(previewSelectedIdsRef.current, nextIds)) return
|
||||||
@@ -456,7 +320,7 @@ const BoxSelectToolInner: React.FC = () => {
|
|||||||
window.removeEventListener('keyup', onKeyUp)
|
window.removeEventListener('keyup', onKeyUp)
|
||||||
window.removeEventListener('blur', onBlur)
|
window.removeEventListener('blur', onBlur)
|
||||||
}
|
}
|
||||||
}, [markBoxSelectHandled, resetDrag])
|
}, [resetDrag])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = gl.domElement
|
const canvas = gl.domElement
|
||||||
@@ -489,6 +353,9 @@ const BoxSelectToolInner: React.FC = () => {
|
|||||||
ownsInputDraggingRef.current = true
|
ownsInputDraggingRef.current = true
|
||||||
useViewer.getState().setInputDragging(true)
|
useViewer.getState().setInputDragging(true)
|
||||||
markBoxSelectHandled()
|
markBoxSelectHandled()
|
||||||
|
try {
|
||||||
|
canvas.setPointerCapture(event.pointerId)
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isDraggingRef.current) return
|
if (!isDraggingRef.current) return
|
||||||
@@ -565,10 +432,6 @@ const BoxSelectToolInner: React.FC = () => {
|
|||||||
currentClientXRef.current = event.clientX
|
currentClientXRef.current = event.clientX
|
||||||
currentClientYRef.current = event.clientY
|
currentClientYRef.current = event.clientY
|
||||||
syncPreviewSelectedIds([])
|
syncPreviewSelectedIds([])
|
||||||
|
|
||||||
try {
|
|
||||||
canvas.setPointerCapture(event.pointerId)
|
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onPointerCancel = (event: PointerEvent) => {
|
const onPointerCancel = (event: PointerEvent) => {
|
||||||
@@ -588,14 +451,11 @@ const BoxSelectToolInner: React.FC = () => {
|
|||||||
window.removeEventListener('pointercancel', onPointerCancel)
|
window.removeEventListener('pointercancel', onPointerCancel)
|
||||||
resetDrag()
|
resetDrag()
|
||||||
}
|
}
|
||||||
}, [camera, gl, markBoxSelectHandled, resetDrag, syncPreviewSelectedIds])
|
}, [camera, gl, resetDrag, syncPreviewSelectedIds])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (handledResetTimeoutRef.current) {
|
clearBoxSelectHandled()
|
||||||
clearTimeout(handledResetTimeoutRef.current)
|
|
||||||
}
|
|
||||||
boxSelectHandled = false
|
|
||||||
resetDrag()
|
resetDrag()
|
||||||
}
|
}
|
||||||
}, [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 { 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 { createPortal, type ThreeEvent } from '@react-three/fiber'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
@@ -317,6 +317,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
const [dragState, setDragState] = useState<DragState | null>(null)
|
const [dragState, setDragState] = useState<DragState | null>(null)
|
||||||
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
|
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
|
||||||
const previewPolygonRef = useRef<Array<[number, number]> | null>(null)
|
const previewPolygonRef = useRef<Array<[number, number]> | null>(null)
|
||||||
|
const previousInputDraggingRef = useRef(false)
|
||||||
|
|
||||||
const onPolygonPreviewRef = useRef(onPolygonPreview)
|
const onPolygonPreviewRef = useRef(onPolygonPreview)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -346,6 +347,20 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
const lineRef = useRef<Line>(null!)
|
const lineRef = useRef<Line>(null!)
|
||||||
const previousPositionRef = useRef<[number, number] | null>(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
|
// 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
|
// our own post-commit prop update arriving while a preview is still in
|
||||||
// flight. Either way, drop the stale preview/drag.
|
// flight. Either way, drop the stale preview/drag.
|
||||||
@@ -687,7 +702,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
if (e.button !== 0) return
|
if (e.button !== 0) return
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setHoveredEdge(null)
|
setHoveredEdge(null)
|
||||||
setDragState({
|
startDrag({
|
||||||
isDragging: true,
|
isDragging: true,
|
||||||
mode: 'vertex',
|
mode: 'vertex',
|
||||||
vertexIndex: index,
|
vertexIndex: index,
|
||||||
@@ -721,7 +736,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
if (e.button !== 0) return
|
if (e.button !== 0) return
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setHoveredEdge(null)
|
setHoveredEdge(null)
|
||||||
setDragState({
|
startDrag({
|
||||||
isDragging: true,
|
isDragging: true,
|
||||||
mode: 'polygon',
|
mode: 'polygon',
|
||||||
vertexIndex: null,
|
vertexIndex: null,
|
||||||
@@ -750,7 +765,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
if (!edgeNormal) return
|
if (!edgeNormal) return
|
||||||
|
|
||||||
setHoveredEdge(null)
|
setHoveredEdge(null)
|
||||||
setDragState({
|
startDrag({
|
||||||
isDragging: true,
|
isDragging: true,
|
||||||
mode: 'edge',
|
mode: 'edge',
|
||||||
vertexIndex: null,
|
vertexIndex: null,
|
||||||
@@ -835,7 +850,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
const insertedVertex = handleAddVertex(index, [x!, z!])
|
const insertedVertex = handleAddVertex(index, [x!, z!])
|
||||||
if (insertedVertex.vertexIndex >= 0) {
|
if (insertedVertex.vertexIndex >= 0) {
|
||||||
setDragState({
|
startDrag({
|
||||||
isDragging: true,
|
isDragging: true,
|
||||||
mode: 'vertex',
|
mode: 'vertex',
|
||||||
vertexIndex: insertedVertex.vertexIndex,
|
vertexIndex: insertedVertex.vertexIndex,
|
||||||
|
|||||||
Reference in New Issue
Block a user