import { useScene, type ZoneNode } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { BufferGeometry, Float32BufferAttribute, type Mesh, Plane, Raycaster, Vector2, Vector3, } from 'three' const Y_OFFSET = 0.02 type DragState = { isDragging: boolean vertexIndex: number initialPosition: [number, number] pointerId: number } /** * Zone boundary editor - allows editing zone polygon vertices when a zone is selected * Uses the event emitter system for grid interactions */ export const ZoneBoundaryEditor: React.FC = () => { const { gl, camera } = useThree() const selectedZoneId = useViewer((state) => state.selection.zoneId) const zoneNode = useScene((state) => (selectedZoneId ? state.nodes[selectedZoneId] : null)) const zone = zoneNode?.type === 'zone' ? (zoneNode as ZoneNode) : null const updateNode = useScene((state) => state.updateNode) // Local state for dragging const [dragState, setDragState] = useState(null) const [previewPolygon, setPreviewPolygon] = useState | null>(null) const [hoveredVertex, setHoveredVertex] = useState(null) const [hoveredMidpoint, setHoveredMidpoint] = useState(null) // Refs for raycasting during drag const dragPlane = useRef(new Plane(new Vector3(0, 1, 0), -Y_OFFSET)) const raycaster = useRef(new Raycaster()) const lineRef = useRef(null!) // The polygon to display (preview during drag, or actual zone polygon) const displayPolygon = previewPolygon ?? zone?.polygon ?? [] // Calculate midpoints for adding new vertices const midpoints = useMemo(() => { if (displayPolygon.length < 2) return [] return displayPolygon.map(([x1, z1], index) => { const nextIndex = (index + 1) % displayPolygon.length const [x2, z2] = displayPolygon[nextIndex]! return [(x1! + x2) / 2, (z1! + z2) / 2] as [number, number] }) }, [displayPolygon]) // Handle vertex drag const handleVertexDrag = useCallback( (clientX: number, clientY: number, vertexIndex: number) => { if (!zone) return const canvas = gl.domElement const rect = canvas.getBoundingClientRect() const x = ((clientX - rect.left) / rect.width) * 2 - 1 const y = -((clientY - rect.top) / rect.height) * 2 + 1 raycaster.current.setFromCamera(new Vector2(x, y), camera) const intersection = new Vector3() raycaster.current.ray.intersectPlane(dragPlane.current, intersection) if (intersection) { // Snap to 0.5 grid const gridX = Math.round(intersection.x * 2) / 2 const gridZ = Math.round(intersection.z * 2) / 2 const basePolygon = previewPolygon ?? zone.polygon const newPolygon = [...basePolygon] newPolygon[vertexIndex] = [gridX, gridZ] setPreviewPolygon(newPolygon) } }, [zone, gl, camera, previewPolygon], ) // Commit polygon changes const commitPolygonChange = useCallback(() => { if (previewPolygon && selectedZoneId) { updateNode(selectedZoneId, { polygon: previewPolygon }) } setPreviewPolygon(null) setDragState(null) }, [previewPolygon, selectedZoneId, updateNode]) // Handle adding a new vertex at midpoint const handleAddVertex = useCallback( (afterIndex: number, position: [number, number]) => { if (!zone) return -1 const basePolygon = previewPolygon ?? zone.polygon const newPolygon = [ ...basePolygon.slice(0, afterIndex + 1), position, ...basePolygon.slice(afterIndex + 1), ] setPreviewPolygon(newPolygon) return afterIndex + 1 // Return new vertex index }, [zone, previewPolygon], ) // Handle deleting a vertex const handleDeleteVertex = useCallback( (index: number) => { if (!zone || !selectedZoneId) return const basePolygon = previewPolygon ?? zone.polygon if (basePolygon.length <= 3) return // Need at least 3 points const newPolygon = basePolygon.filter((_, i) => i !== index) updateNode(selectedZoneId, { polygon: newPolygon }) setPreviewPolygon(null) }, [zone, selectedZoneId, previewPolygon, updateNode], ) // Set up pointer move/up listeners for dragging with pointer capture useEffect(() => { if (!dragState?.isDragging) return const canvas = gl.domElement const pointerId = dragState.pointerId // Capture pointer to prevent R3F events from firing on other objects (like the grid) canvas.setPointerCapture(pointerId) const handlePointerMove = (e: PointerEvent) => { handleVertexDrag(e.clientX, e.clientY, dragState.vertexIndex) } const handlePointerUp = (e: PointerEvent) => { // Release pointer capture if (canvas.hasPointerCapture(e.pointerId)) { canvas.releasePointerCapture(e.pointerId) } commitPolygonChange() } canvas.addEventListener('pointermove', handlePointerMove) canvas.addEventListener('pointerup', handlePointerUp) return () => { // Release capture on cleanup if (canvas.hasPointerCapture(pointerId)) { canvas.releasePointerCapture(pointerId) } canvas.removeEventListener('pointermove', handlePointerMove) canvas.removeEventListener('pointerup', handlePointerUp) } }, [dragState, gl, handleVertexDrag, commitPolygonChange]) // Update line geometry when polygon changes useEffect(() => { if (!lineRef.current || displayPolygon.length < 2) return const positions: number[] = [] for (const [x, z] of displayPolygon) { positions.push(x!, Y_OFFSET + 0.01, z!) } // Close the loop const first = displayPolygon[0]! positions.push(first[0]!, Y_OFFSET + 0.01, first[1]!) const geometry = new BufferGeometry() geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) lineRef.current.geometry.dispose() lineRef.current.geometry = geometry }, [displayPolygon]) if (!zone || displayPolygon.length < 3) return null const canDelete = displayPolygon.length > 3 const zoneColor = zone.color || '#3b82f6' return ( {/* Border line */} {/* @ts-ignore */} {/* Vertex handles */} {displayPolygon.map(([x, z], index) => { const isHovered = hoveredVertex === index const isDragging = dragState?.vertexIndex === index return ( { e.stopPropagation() setHoveredVertex(index) }} onPointerLeave={(e) => { e.stopPropagation() setHoveredVertex(null) }} onPointerDown={(e) => { e.stopPropagation() setDragState({ isDragging: true, vertexIndex: index, initialPosition: [x!, z!], pointerId: e.nativeEvent.pointerId, }) }} onClick={(e) => { e.stopPropagation() }} onDoubleClick={(e) => { e.stopPropagation() if (canDelete) { handleDeleteVertex(index) } }} > ) })} {/* Midpoint handles for adding vertices (hidden while dragging) */} {!dragState && midpoints.map(([x, z], index) => { const isHovered = hoveredMidpoint === index return ( { e.stopPropagation() setHoveredMidpoint(index) }} onPointerLeave={(e) => { e.stopPropagation() setHoveredMidpoint(null) }} onPointerDown={(e) => { e.stopPropagation() const newVertexIndex = handleAddVertex(index, [x!, z!]) if (newVertexIndex >= 0) { setDragState({ isDragging: true, vertexIndex: newVertexIndex, initialPosition: [x!, z!], pointerId: e.nativeEvent.pointerId, }) setHoveredMidpoint(null) } }} onClick={(e) => { e.stopPropagation() }} > ) })} ) }