import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core' import { createPortal } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { BufferGeometry, Float32BufferAttribute, type Mesh } from 'three' import { sfxEmitter } from '@/lib/sfx-bus' const Y_OFFSET = 0.02 type DragState = { isDragging: boolean vertexIndex: number initialPosition: [number, number] pointerId: number } export interface PolygonEditorProps { polygon: Array<[number, number]> color?: string onPolygonChange: (polygon: Array<[number, number]>) => void minVertices?: number /** Level ID to mount the editor to. If provided, uses createPortal for automatic level animation following. */ levelId?: string /** Height of the surface being edited (e.g. slab elevation). Handles adapt to this. */ surfaceHeight?: number } /** * Generic polygon editor component for editing polygon vertices * Used by zone and site boundary editors */ const MIN_HANDLE_HEIGHT = 0.15 export const PolygonEditor: React.FC = ({ polygon, color = '#3b82f6', onPolygonChange, minVertices = 3, levelId, surfaceHeight = 0, }) => { // Get level node from registry if levelId is provided const levelNode = levelId ? sceneRegistry.nodes.get(levelId) : null // When using portal, edit at Y_OFFSET (local to level) // When not using portal, edit at world origin const editY = levelNode ? Y_OFFSET : 0 // 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) const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0]) const lineRef = useRef(null!) const previousPositionRef = useRef<[number, number] | null>(null) // Track the last polygon prop to detect external changes (undo/redo) const lastPolygonRef = useRef(polygon) if (polygon !== lastPolygonRef.current) { lastPolygonRef.current = polygon // External change (e.g. undo/redo) — clear any stale preview/drag state if (previewPolygon) setPreviewPolygon(null) if (dragState) setDragState(null) } // The polygon to display (preview during drag, or actual polygon) const displayPolygon = previewPolygon ?? 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]) // Update vertex position using grid cursor position const handleVertexDrag = useCallback( (vertexIndex: number) => { const basePolygon = previewPolygon ?? polygon const newPolygon = [...basePolygon] newPolygon[vertexIndex] = cursorPosition setPreviewPolygon(newPolygon) }, [cursorPosition, previewPolygon, polygon], ) // Commit polygon changes const commitPolygonChange = useCallback(() => { if (previewPolygon) { onPolygonChange(previewPolygon) } setPreviewPolygon(null) setDragState(null) }, [previewPolygon, onPolygonChange]) // Handle adding a new vertex at midpoint const handleAddVertex = useCallback( (afterIndex: number, position: [number, number]) => { const basePolygon = previewPolygon ?? polygon const newPolygon = [ ...basePolygon.slice(0, afterIndex + 1), position, ...basePolygon.slice(afterIndex + 1), ] setPreviewPolygon(newPolygon) return afterIndex + 1 // Return new vertex index }, [polygon, previewPolygon], ) // Handle deleting a vertex const handleDeleteVertex = useCallback( (index: number) => { const basePolygon = previewPolygon ?? polygon if (basePolygon.length <= minVertices) return // Need at least minVertices points const newPolygon = basePolygon.filter((_, i) => i !== index) onPolygonChange(newPolygon) setPreviewPolygon(null) }, [polygon, previewPolygon, onPolygonChange, minVertices], ) // Listen to grid:move events to track cursor position useEffect(() => { const onGridMove = (event: GridEvent) => { const gridX = Math.round(event.position[0] * 2) / 2 const gridZ = Math.round(event.position[2] * 2) / 2 const newPosition: [number, number] = [gridX, gridZ] // Play snap sound when cursor moves to a new grid cell during drag if (dragState?.isDragging && previousPositionRef.current && (newPosition[0] !== previousPositionRef.current[0] || newPosition[1] !== previousPositionRef.current[1])) { sfxEmitter.emit('sfx:grid-snap') } previousPositionRef.current = newPosition setCursorPosition(newPosition) // Update vertex position during drag if (dragState?.isDragging) { handleVertexDrag(dragState.vertexIndex) } } emitter.on('grid:move', onGridMove) return () => { emitter.off('grid:move', onGridMove) } }, [dragState, handleVertexDrag]) // Set up pointer up listener for ending drag useEffect(() => { if (!dragState?.isDragging) return const handlePointerUp = (e: PointerEvent) => { // Only handle the specific pointer that started the drag if (e.pointerId !== dragState.pointerId) return // Stop the event from propagating to prevent grid click e.stopImmediatePropagation() e.preventDefault() // Suppress the follow-up click event that browsers fire after pointerup const suppressClick = (ce: MouseEvent) => { ce.stopImmediatePropagation() ce.preventDefault() window.removeEventListener('click', suppressClick, true) } window.addEventListener('click', suppressClick, true) // Safety cleanup in case no click fires requestAnimationFrame(() => { window.removeEventListener('click', suppressClick, true) }) commitPolygonChange() } window.addEventListener('pointerup', handlePointerUp, true) return () => { window.removeEventListener('pointerup', handlePointerUp, true) } }, [dragState, 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!, editY + 0.01, z!) } // Close the loop const first = displayPolygon[0]! positions.push(first[0]!, editY + 0.01, first[1]!) const geometry = new BufferGeometry() geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) lineRef.current.geometry.dispose() lineRef.current.geometry = geometry }, [displayPolygon, editY]) if (displayPolygon.length < minVertices) return null const canDelete = displayPolygon.length > minVertices const editorContent = ( {/* Border line */} {/* @ts-ignore */} {}}> {/* Vertex handles - blue cylinders that match surface height */} {displayPolygon.map(([x, z], index) => { const isHovered = hoveredVertex === index const isDragging = dragState?.vertexIndex === index const radius = 0.1 const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02) return ( { e.stopPropagation() setHoveredVertex(index) }} onPointerLeave={(e) => { e.stopPropagation() setHoveredVertex(null) }} onPointerDown={(e) => { if (e.button !== 0) return e.stopPropagation() setDragState({ isDragging: true, vertexIndex: index, initialPosition: [x!, z!], pointerId: e.nativeEvent.pointerId, }) }} onClick={(e) => { if (e.button !== 0) return e.stopPropagation() }} onDoubleClick={(e) => { if (e.button !== 0) return e.stopPropagation() if (canDelete) { handleDeleteVertex(index) } }} > ) })} {/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */} {!dragState && midpoints.map(([x, z], index) => { const isHovered = hoveredMidpoint === index const radius = 0.06 const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02) return ( { e.stopPropagation() setHoveredMidpoint(index) }} onPointerLeave={(e) => { e.stopPropagation() setHoveredMidpoint(null) }} onPointerDown={(e) => { if (e.button !== 0) return 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) => { if (e.button !== 0) return e.stopPropagation() }} > ) })} ) // Mount to level node if available, otherwise render at world origin return levelNode ? createPortal(editorContent, levelNode) : editorContent }