From 4d1f131d3e2212c2afa264ec9c88d5f5d51e1820 Mon Sep 17 00:00:00 2001 From: backuppc Date: Mon, 22 Dec 2025 13:46:01 +0900 Subject: [PATCH] feat: Enhance PropertyPanel, add RFID auto-gen, and fix node connections --- App.tsx | 29 + components/AgvStatusPanel.tsx | 10 +- components/PropertyPanel.tsx | 295 +++ components/SimulationCanvas.tsx | 3139 ++++++++++++++++--------------- services/serialService.ts | 25 +- types.ts | 12 + 6 files changed, 1995 insertions(+), 1515 deletions(-) create mode 100644 components/PropertyPanel.tsx diff --git a/App.tsx b/App.tsx index af3e75c..bf46ece 100644 --- a/App.tsx +++ b/App.tsx @@ -10,6 +10,7 @@ import AcsControls from './components/AcsControls'; import AgvStatusPanel from './components/AgvStatusPanel'; import AgvAutoRunControls from './components/AgvAutoRunControls'; import SystemLogPanel from './components/SystemLogPanel'; +import PropertyPanel from './components/PropertyPanel'; import { SerialPortHandler } from './services/serialService'; // --- ACS CRC16 Table Generation (Matches C# Logic) --- @@ -43,6 +44,7 @@ const calculateAcsCrc16 = (data: number[] | Uint8Array): number => { const App: React.FC = () => { // --- State --- const [activeTool, setActiveTool] = useState(ToolType.SELECT); + const [selectedItemIds, setSelectedItemIds] = useState>(new Set()); // Map Data const [mapData, setMapData] = useState(INITIAL_MAP); @@ -123,6 +125,20 @@ const App: React.FC = () => { const [bmsPortInfo, setBmsPortInfo] = useState(null); const [acsPortInfo, setAcsPortInfo] = useState(null); + const handlePropertyUpdate = (type: 'NODE' | 'MAGNET' | 'MARK', id: string, data: any) => { + setMapData(prev => { + const next = { ...prev }; + if (type === 'NODE') { + next.nodes = prev.nodes.map(n => n.id === id ? { ...n, ...data } : n); + } else if (type === 'MAGNET') { + next.magnets = prev.magnets.map(m => m.id === id ? { ...m, ...data } : m); + } else if (type === 'MARK') { + next.marks = prev.marks.map(m => m.id === id ? { ...m, ...data } : m); + } + return next; + }); + }; + // Serial Configuration const [agvBaudRate, setAgvBaudRate] = useState(57600); const [bmsBaudRate, setBmsBaudRate] = useState(9600); @@ -443,6 +459,11 @@ const App: React.FC = () => { barr[22] = s.sensorStatus.charCodeAt(0); barr.set(encodeHex(s.signalFlags, 2), 23); + // Fill bytes 25-30 with '0' padding + for (let i = 25; i <= 30; i++) { + barr[i] = 0x30; // '0' + } + barr[31] = '*'.charCodeAt(0); barr[32] = '*'.charCodeAt(0); barr[33] = 0x03; agvSerialRef.current.send(barr); @@ -1206,6 +1227,13 @@ const App: React.FC = () => { onDragOver={handleDragOver} onDrop={handleDrop} > + {/* Overlay: Property Panel (Floating) */} + + {/* 1. Far Left: Toolbar */}
{ agvState={agvState} setAgvState={setAgvState} onLog={(msg) => addLog('SYSTEM', 'INFO', msg)} + onSelectionChange={setSelectedItemIds} />
diff --git a/components/AgvStatusPanel.tsx b/components/AgvStatusPanel.tsx index a8f9c6e..b6b3930 100644 --- a/components/AgvStatusPanel.tsx +++ b/components/AgvStatusPanel.tsx @@ -13,7 +13,7 @@ const AgvStatusPanel: React.FC = ({ agvState }) => { const isOn = (value & (1 << bitIndex)) !== 0; // 필터링에 의해 isOn이 true인 것만 전달되지만 스타일 유지를 위해 체크 로직 유지 return ( -
+
{label.replace(/_/g, ' ')}
@@ -41,7 +41,7 @@ const AgvStatusPanel: React.FC = ({ agvState }) => {
{getActiveKeys(SystemFlag1, agvState.system1).length > 0 ? ( - getActiveKeys(SystemFlag1, agvState.system1).map((key) => + getActiveKeys(SystemFlag1, agvState.system1).map((key) => renderBit(key, agvState.system1, SystemFlag1[key as any] as unknown as number, 'bg-blue-500') ) ) : ( @@ -57,7 +57,7 @@ const AgvStatusPanel: React.FC = ({ agvState }) => {
{getActiveKeys(AgvSignal, agvState.signalFlags).length > 0 ? ( - getActiveKeys(AgvSignal, agvState.signalFlags).map((key) => + getActiveKeys(AgvSignal, agvState.signalFlags).map((key) => renderBit(key, agvState.signalFlags, AgvSignal[key as any] as unknown as number, 'bg-yellow-500') ) ) : ( @@ -73,7 +73,7 @@ const AgvStatusPanel: React.FC = ({ agvState }) => {
{getActiveKeys(AgvError, agvState.errorFlags).length > 0 ? ( - getActiveKeys(AgvError, agvState.errorFlags).map((key) => + getActiveKeys(AgvError, agvState.errorFlags).map((key) => renderBit(key, agvState.errorFlags, AgvError[key as any] as unknown as number, 'bg-red-600 shadow-[0_0_5px_rgba(220,38,38,0.5)]') ) ) : ( @@ -89,7 +89,7 @@ const AgvStatusPanel: React.FC = ({ agvState }) => {
{getActiveKeys(SystemFlag0, agvState.system0).length > 0 ? ( - getActiveKeys(SystemFlag0, agvState.system0).map((key) => + getActiveKeys(SystemFlag0, agvState.system0).map((key) => renderBit(key, agvState.system0, SystemFlag0[key as any] as unknown as number, 'bg-cyan-500') ) ) : ( diff --git a/components/PropertyPanel.tsx b/components/PropertyPanel.tsx new file mode 100644 index 0000000..7b920fd --- /dev/null +++ b/components/PropertyPanel.tsx @@ -0,0 +1,295 @@ + +import React, { useState, useEffect } from 'react'; +import { SimulationMap, MapNode, MagnetLine, FloorMark } from '../types'; +import { Save, Wand2 } from 'lucide-react'; + +interface PropertyPanelProps { + selectedItemIds: Set; + mapData: SimulationMap; + onUpdate: (type: 'NODE' | 'MAGNET' | 'MARK', id: string, data: any) => void; +} + +const PropertyPanel: React.FC = ({ selectedItemIds, mapData, onUpdate }) => { + const [formData, setFormData] = useState(null); + const [position, setPosition] = useState({ x: 1100, y: 100 }); + const [isDragging, setIsDragging] = useState(false); + const dragOffset = React.useRef({ x: 0, y: 0 }); + + // When selection changes, update form data + useEffect(() => { + if (selectedItemIds.size === 1) { + const id = Array.from(selectedItemIds)[0]; + const node = mapData.nodes.find(n => n.id === id); + const magnet = mapData.magnets.find(m => m.id === id); + const mark = mapData.marks.find(m => m.id === id); + + if (node) { + setFormData({ ...node, formType: 'NODE' }); + } else if (magnet) { + setFormData({ ...magnet, formType: 'MAGNET' }); + } else if (mark) { + setFormData({ ...mark, formType: 'MARK' }); + } else { + setFormData(null); + } + } else { + setFormData(null); + } + }, [selectedItemIds, mapData.nodes, mapData.magnets, mapData.marks]); + + const handleChange = (field: string, value: string | number | boolean) => { + if (!formData) return; + setFormData((prev: any) => ({ ...prev, [field]: value })); + }; + + const handleSave = () => { + if (!formData) return; + + // Validate RFID Duplication + if (formData.formType === 'NODE' && formData.rfidId && formData.rfidId !== '0000') { + const isDuplicate = mapData.nodes.some(n => + n.rfidId === formData.rfidId && n.id !== formData.id + ); + + if (isDuplicate) { + alert(`Error: RFID "${formData.rfidId}" is already used by another node.`); + return; + } + } + + onUpdate(formData.formType, formData.id, formData); + }; + + // Auto Generate RFID + const handleAutoRfid = () => { + const usedRfids = new Set(mapData.nodes.map(n => n.rfidId)); + let nextRfid = 1; // Start from 1, assuming 0000 is default/null + while (nextRfid < 10000) { + const candidate = nextRfid.toString().padStart(4, '0'); + if (!usedRfids.has(candidate)) { + handleChange('rfidId', candidate); + return; + } + nextRfid++; + } + alert('No available RFID found between 0001 and 9999'); + }; + + // Drag Logic + const handleMouseDown = (e: React.MouseEvent) => { + setIsDragging(true); + dragOffset.current = { + x: e.clientX - position.x, + y: e.clientY - position.y + }; + }; + + const handleMouseMove = React.useCallback((e: MouseEvent) => { + if (isDragging) { + setPosition({ + x: e.clientX - dragOffset.current.x, + y: e.clientY - dragOffset.current.y + }); + } + }, [isDragging]); + + const handleMouseUp = React.useCallback(() => { + setIsDragging(false); + }, []); + + useEffect(() => { + if (isDragging) { + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + } else { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + } + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [isDragging, handleMouseMove, handleMouseUp]); + + + if (!formData || selectedItemIds.size !== 1) return null; + + return ( +
+ {/* Header / Drag Handle */} +
+ Properties + +
+ +
+ {/* Common Fields */} +
+ + +
+ + {formData.formType === 'NODE' && ( + <> +
+ + handleChange('name', e.target.value)} + className="bg-gray-700 border border-gray-600 text-gray-200 text-sm p-1 rounded focus:border-blue-500 outline-none" + /> +
+
+ +
+ handleChange('rfidId', e.target.value)} + className="flex-1 bg-gray-700 border border-gray-600 text-gray-200 text-sm p-1 rounded focus:border-blue-500 outline-none font-mono" + /> + +
+
+ +
+
+ + handleChange('x', parseFloat(e.target.value))} + className="bg-gray-700 border border-gray-600 text-gray-200 text-sm p-1 rounded focus:border-blue-500 outline-none" + /> +
+
+ + handleChange('y', parseFloat(e.target.value))} + className="bg-gray-700 border border-gray-600 text-gray-200 text-sm p-1 rounded focus:border-blue-500 outline-none" + /> +
+
+ +
+ + +
+
+ + handleChange('stationType', parseInt(e.target.value))} + className="bg-gray-700 border border-gray-600 text-gray-200 text-xs p-1 rounded" + /> +
+
+ + handleChange('speedLimit', parseInt(e.target.value))} + className="bg-gray-700 border border-gray-600 text-gray-200 text-xs p-1 rounded" + /> +
+
+ + handleChange('dockDirection', parseInt(e.target.value))} + className="bg-gray-700 border border-gray-600 text-gray-200 text-xs p-1 rounded" + /> +
+
+ + handleChange('nodeTextFontSize', parseInt(e.target.value))} + className="bg-gray-700 border border-gray-600 text-gray-200 text-xs p-1 rounded" + /> +
+
+ +
+ +
+ handleChange('nodeTextForeColor', e.target.value)} + className="bg-transparent w-6 h-6 p-0 border-0" + /> + handleChange('nodeTextForeColor', e.target.value)} + className="flex-1 bg-gray-700 border border-gray-600 text-gray-200 text-xs p-1 rounded font-mono" + /> +
+
+ +
+ {[ + { k: 'canDocking', l: 'Can Docking' }, + { k: 'canTurnLeft', l: 'Can Turn Left' }, + { k: 'canTurnRight', l: 'Can Turn Right' }, + { k: 'disableCross', l: 'Disable Cross' }, + { k: 'isActive', l: 'Is Active' }, + ].map((item) => ( + + ))} +
+
+ + )} + +
+
+ ); +}; + +export default PropertyPanel; + + diff --git a/components/SimulationCanvas.tsx b/components/SimulationCanvas.tsx index 4025249..fdaf6e4 100644 --- a/components/SimulationCanvas.tsx +++ b/components/SimulationCanvas.tsx @@ -4,1558 +4,1697 @@ import { AGV_WIDTH, AGV_HEIGHT, SENSOR_OFFSET_FRONT, SENSOR_OFFSET_REAR, SENSOR_ import { Trash2, X, Move } from 'lucide-react'; interface SimulationCanvasProps { - activeTool: ToolType; - mapData: SimulationMap; - setMapData: (data: SimulationMap | ((prev: SimulationMap) => SimulationMap)) => void; - agvState: AgvState; - setAgvState: (state: AgvState | ((prev: AgvState) => AgvState)) => void; - onLog: (msg: string) => void; + activeTool: ToolType; + mapData: SimulationMap; + setMapData: (data: SimulationMap | ((prev: SimulationMap) => SimulationMap)) => void; + agvState: AgvState; + setAgvState: (state: AgvState | ((prev: AgvState) => AgvState)) => void; + onLog: (msg: string) => void; + onSelectionChange?: (selectedIds: Set) => void; } interface HitObject { - type: 'NODE' | 'EDGE' | 'MAGNET' | 'MARK'; - id: string; - name: string; // For display - dist: number; + type: 'NODE' | 'EDGE' | 'MAGNET' | 'MARK'; + id: string; + name: string; // For display + dist: number; } -const SimulationCanvas: React.FC = ({ activeTool, mapData, setMapData, agvState, setAgvState, onLog }) => { - const canvasRef = useRef(null); - const containerRef = useRef(null); - const [dimensions, setDimensions] = useState({ width: 800, height: 600 }); - - // --- View Transform (Zoom/Pan) --- - const viewRef = useRef({ x: 0, y: 0, scale: 1 }); - const [isPanning, setIsPanning] = useState(false); - const isSpacePressedRef = useRef(false); +const SimulationCanvas: React.FC = ({ activeTool, mapData, setMapData, agvState, setAgvState, onLog, onSelectionChange }) => { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [dimensions, setDimensions] = useState({ width: 800, height: 600 }); - // Interaction State - const [dragStartPos, setDragStartPos] = useState<{x:number, y:number} | null>(null); - const [startNodeId, setStartNodeId] = useState(null); - - // Selection & Editing State - const [selectedItemId, setSelectedItemId] = useState(null); - // dragHandle: 'p1'|'p2'|'control' for magnets, 'mark_p1'|'mark_p2' for marks - const [dragHandle, setDragHandle] = useState(null); + // --- View Transform (Zoom/Pan) --- + const viewRef = useRef({ x: 0, y: 0, scale: 1 }); + const [isPanning, setIsPanning] = useState(false); + const isSpacePressedRef = useRef(false); - // Move/Drag State for Select Tool (Whole Object) - const [dragTarget, setDragTarget] = useState<{ - type: 'AGV' | 'NODE' | 'MAGNET' | 'MARK'; - id?: string; - startMouse: { x: number, y: number }; - initialObjState: any; - } | null>(null); - - // Curve Drawing State - const [curvePhase, setCurvePhase] = useState<0 | 1 | 2>(0); - const [tempMagnet, setTempMagnet] = useState | null>(null); + // Interaction State + const [dragStartPos, setDragStartPos] = useState<{ x: number, y: number } | null>(null); + const [startNodeId, setStartNodeId] = useState(null); - // Eraser Context Menu State - const [contextMenu, setContextMenu] = useState<{ x: number, y: number, items: HitObject[] } | null>(null); + // Selection & Editing State + // Changed: Support multiple items + const [selectedItemIds, setSelectedItemIds] = useState>(new Set()); + // dragHandle: 'p1'|'p2'|'control' for magnets, 'mark_p1'|'mark_p2' for marks + const [dragHandle, setDragHandle] = useState(null); - // Image Cache - const imageCacheRef = useRef>({}); + // Box Selection State + const [selectionBox, setSelectionBox] = useState<{ start: { x: number, y: number }, current: { x: number, y: number } } | null>(null); - // Use ref for mouse position (Screen Coords) - const mouseScreenPosRef = useRef<{ x: number, y: number }>({ x: 0, y: 0 }); - // Use ref for mouse position (World Coords) - const mouseWorldPosRef = useRef<{ x: number, y: number }>({ x: 0, y: 0 }); + // Notify parent of selection change + useEffect(() => { + if (onSelectionChange) { + onSelectionChange(selectedItemIds); + } + }, [selectedItemIds, onSelectionChange]); - // Refs for loop access - const mapDataRef = useRef(mapData); - const agvStateRef = useRef(agvState); - - // Track previous sensor position for intersection checking - const prevMarkSensorPosRef = useRef<{ x: number, y: number } | null>(null); - // Track the ID of the magnet currently being followed to prevent jumping - const lastMagnetIdRef = useRef(null); - // Throttle log - const logThrottleRef = useRef(0); + // Move/Drag State for Select Tool (Whole Object) + const [dragTarget, setDragTarget] = useState<{ + type: 'MULTI' | 'AGV'; + startMouse: { x: number, y: number }; + // For MULTI: store map of {id: initialState} + initialStates?: Record; + // For AGV single move + initialAgvState?: { x: number, y: number }; + } | null>(null); - // Sync refs - useEffect(() => { mapDataRef.current = mapData; }, [mapData]); - useEffect(() => { agvStateRef.current = agvState; }, [agvState]); + // Curve Drawing State + const [curvePhase, setCurvePhase] = useState<0 | 1 | 2>(0); + const [tempMagnet, setTempMagnet] = useState | null>(null); - // Handle Resize - useEffect(() => { - const updateSize = () => { + // Eraser Context Menu State + const [contextMenu, setContextMenu] = useState<{ x: number, y: number, items: HitObject[] } | null>(null); + + // Image Cache + const imageCacheRef = useRef>({}); + + // Use ref for mouse position (Screen Coords) + const mouseScreenPosRef = useRef<{ x: number, y: number }>({ x: 0, y: 0 }); + // Use ref for mouse position (World Coords) + const mouseWorldPosRef = useRef<{ x: number, y: number }>({ x: 0, y: 0 }); + + // Refs for loop access + const mapDataRef = useRef(mapData); + const agvStateRef = useRef(agvState); + + // Track previous sensor position for intersection checking + const prevMarkSensorPosRef = useRef<{ x: number, y: number } | null>(null); + // Track the ID of the magnet currently being followed to prevent jumping + const lastMagnetIdRef = useRef(null); + // Throttle log + const logThrottleRef = useRef(0); + + // Sync refs + useEffect(() => { mapDataRef.current = mapData; }, [mapData]); + useEffect(() => { agvStateRef.current = agvState; }, [agvState]); + + // Handle Resize + useEffect(() => { + const updateSize = () => { + if (containerRef.current) { + setDimensions({ + width: containerRef.current.clientWidth, + height: containerRef.current.clientHeight + }); + } + }; + + updateSize(); + const observer = new ResizeObserver(updateSize); if (containerRef.current) { - setDimensions({ - width: containerRef.current.clientWidth, - height: containerRef.current.clientHeight - }); - } - }; - - updateSize(); - const observer = new ResizeObserver(updateSize); - if (containerRef.current) { - observer.observe(containerRef.current); - } - - return () => observer.disconnect(); - }, []); - - // Keyboard Event for Spacebar Panning - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.code === 'Space') isSpacePressedRef.current = true; - if (e.key === 'Escape') { - setSelectedItemId(null); - setDragHandle(null); - setDragTarget(null); - setContextMenu(null); - } - }; - const handleKeyUp = (e: KeyboardEvent) => { - if (e.code === 'Space') isSpacePressedRef.current = false; - }; - window.addEventListener('keydown', handleKeyDown); - window.addEventListener('keyup', handleKeyUp); - return () => { - window.removeEventListener('keydown', handleKeyDown); - window.removeEventListener('keyup', handleKeyUp); - }; - }, []); - - // Reset tool states when tool changes - useEffect(() => { - setCurvePhase(0); - setTempMagnet(null); - setDragStartPos(null); - setStartNodeId(null); - setContextMenu(null); - setDragTarget(null); - setDragHandle(null); - // Note: We might want to keep selection when switching tools, but often resetting is safer - if (activeTool !== ToolType.SELECT) setSelectedItemId(null); - }, [activeTool]); - - // Preload Images - useEffect(() => { - const cache = imageCacheRef.current; - mapData.nodes.forEach(node => { - if (node.type === NodeType.Image && node.imageBase64 && !cache[node.id]) { - const img = new Image(); - let src = node.imageBase64; - if (!src.startsWith('data:image')) { - src = `data:image/png;base64,${src}`; - } - img.src = src; - cache[node.id] = img; - } - }); - }, [mapData]); - - // --- Coordinate Transformation Helpers --- - const screenToWorld = (screenX: number, screenY: number) => { - const t = viewRef.current; - return { - x: (screenX - t.x) / t.scale, - y: (screenY - t.y) / t.scale - }; - }; - - const worldToScreen = (worldX: number, worldY: number) => { - const t = viewRef.current; - return { - x: worldX * t.scale + t.x, - y: worldY * t.scale + t.y - }; - }; - - // --- Physics Helper Functions --- - - const rotatePoint = (px: number, py: number, cx: number, cy: number, angleDeg: number) => { - const rad = (angleDeg * Math.PI) / 180; - const cos = Math.cos(rad); - const sin = Math.sin(rad); - const dx = px - cx; - const dy = py - cy; - return { - x: cx + (dx * cos - dy * sin), - y: cy + (dx * sin + dy * cos), - }; - }; - - const getDistance = (p1: {x:number, y:number}, p2: {x:number, y:number}) => { - return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); - }; - - // Distance from point P to line segment VW - const distanceToSegment = (p: {x:number, y:number}, v: {x:number, y:number}, w: {x:number, y:number}) => { - const l2 = Math.pow(getDistance(v, w), 2); - if (l2 === 0) return getDistance(p, v); - let t = ((p.x - v.x) * (w.x - v.x) + (p.y - v.y) * (w.y - v.y)) / l2; - t = Math.max(0, Math.min(1, t)); - const projection = { x: v.x + t * (w.x - v.x), y: v.y + t * (w.y - v.y) }; - return getDistance(p, projection); - }; - - // Get closest point on segment - const getClosestPointOnSegment = (p: {x:number, y:number}, v: {x:number, y:number}, w: {x:number, y:number}) => { - const l2 = Math.pow(getDistance(v, w), 2); - if (l2 === 0) return v; - let t = ((p.x - v.x) * (w.x - v.x) + (p.y - v.y) * (w.y - v.y)) / l2; - t = Math.max(0, Math.min(1, t)); - return { x: v.x + t * (w.x - v.x), y: v.y + t * (w.y - v.y) }; - }; - - // Quadratic Bezier Point Calculation - const getBezierPoint = (t: number, p0: {x:number, y:number}, p1: {x:number, y:number}, p2: {x:number, y:number}) => { - const u = 1 - t; - const tt = t * t; - const uu = u * u; - - return { - x: (uu * p0.x) + (2 * u * t * p1.x) + (tt * p2.x), - y: (uu * p0.y) + (2 * u * t * p1.y) + (tt * p2.y) - }; - }; - - // Calculate derivative (tangent vector) at t for Quadratic Bezier - const getBezierDerivative = (t: number, p0: {x:number, y:number}, p1: {x:number, y:number}, p2: {x:number, y:number}) => { - const u = 1 - t; - // B'(t) = 2(1-t)(P1 - P0) + 2t(P2 - P1) - const dx = 2 * u * (p1.x - p0.x) + 2 * t * (p2.x - p1.x); - const dy = 2 * u * (p1.y - p0.y) + 2 * t * (p2.y - p1.y); - return { x: dx, y: dy }; - }; - - // Approximate distance to a Quadratic Bezier Curve by subdividing it - // Now uses 50 segments for higher resolution to prevent "warping" - const distanceToBezier = (p: {x:number, y:number}, p0: {x:number, y:number}, p1: {x:number, y:number}, p2: {x:number, y:number}, segments = 50) => { - let minDist = Number.MAX_VALUE; - let prevPoint = p0; - let closestProj = p0; - let bestT = 0; - - // Scan segments to find approximate closest point - for (let i = 1; i <= segments; i++) { - const t = i / segments; - const currPoint = getBezierPoint(t, p0, p1, p2); - const dist = distanceToSegment(p, prevPoint, currPoint); - - if (dist < minDist) { - minDist = dist; - // Get linear projection on this segment to approximate t - const proj = getClosestPointOnSegment(p, prevPoint, currPoint); - closestProj = proj; - - // Approximate t at projection (Lerp) - const segLen = getDistance(prevPoint, currPoint); - const distOnSeg = getDistance(prevPoint, proj); - const tStep = 1 / segments; - const tStart = (i - 1) / segments; - bestT = tStart + (distOnSeg / segLen) * tStep; - } - prevPoint = currPoint; - } - - // Calculate Exact Tangent using Derivative at bestT - const deriv = getBezierDerivative(bestT, p0, p1, p2); - const tangentAngle = Math.atan2(deriv.y, deriv.x) * (180 / Math.PI); - - return { dist: minDist, proj: closestProj, angle: tangentAngle }; - }; - - const normalizeAngle = (angle: number) => { - let a = angle % 360; - if (a > 180) a -= 360; - if (a <= -180) a += 360; - return a; - }; - - const doSegmentsIntersect = (p1: {x:number, y:number}, p2: {x:number, y:number}, p3: {x:number, y:number}, p4: {x:number, y:number}) => { - const ccw = (a:{x:number,y:number}, b:{x:number,y:number}, c:{x:number,y:number}) => (c.y - a.y) * (b.x - a.x) > (b.y - a.y) * (c.x - a.x); - return (ccw(p1, p2, p3) !== ccw(p1, p2, p4)) && (ccw(p3, p4, p1) !== ccw(p3, p4, p2)); - }; - - // Helper to find closest magnet and its angle - const getClosestMagnetInfo = (x: number, y: number) => { - const map = mapDataRef.current; - const p = { x, y }; - let closestMag = null; - let minDist = 30; // Search radius - let angle = 0; - - map.magnets.forEach(mag => { - let dist = 999; - let magAngle = 0; - - if (mag.type === 'STRAIGHT') { - dist = distanceToSegment(p, mag.p1, mag.p2); - magAngle = Math.atan2(mag.p2.y - mag.p1.y, mag.p2.x - mag.p1.x) * (180/Math.PI); - } else if (mag.type === 'CURVE' && mag.controlPoint) { - const res = distanceToBezier(p, mag.p1, mag.controlPoint, mag.p2); - dist = res.dist; - magAngle = res.angle; - } - - if (dist < minDist) { - minDist = dist; - closestMag = mag; - angle = magAngle; - } - }); - return { magnet: closestMag, angle }; - }; - - // --- Hit Test Logic --- - const getObjectsAt = (x: number, y: number, tolerance = 15): HitObject[] => { - const hits: HitObject[] = []; - const map = mapDataRef.current; - const p = { x, y }; - - const worldTolerance = tolerance / viewRef.current.scale; - - // Type Priority Map: Smaller number = Higher Priority - const typePriority: Record = { - 'NODE': 1, - 'MARK': 2, - 'EDGE': 3, - 'MAGNET': 4 - }; - - // 1. Nodes - map.nodes.forEach(node => { - if (getDistance(p, node) < worldTolerance) { - hits.push({ type: 'NODE', id: node.id, name: node.name || node.id, dist: getDistance(p, node) }); - } - }); - - // 2. Marks (Calculate as segment for better hit detection) - map.marks.forEach(mark => { - // Visual width is 50px (-25 to 25). - const rad = (mark.rotation * Math.PI) / 180; - const dx = Math.cos(rad) * 25; - const dy = Math.sin(rad) * 25; - const p1 = { x: mark.x - dx, y: mark.y - dy }; - const p2 = { x: mark.x + dx, y: mark.y + dy }; - - const dist = distanceToSegment(p, p1, p2); - - if (dist < worldTolerance) { - hits.push({ type: 'MARK', id: mark.id, name: 'Mark', dist }); - } - }); - - // 3. Magnets - map.magnets.forEach(mag => { - let dist = 999; - if (mag.type === 'STRAIGHT') { - dist = distanceToSegment(p, mag.p1, mag.p2); - } else if (mag.type === 'CURVE' && mag.controlPoint) { - dist = distanceToBezier(p, mag.p1, mag.controlPoint, mag.p2).dist; - } - if (dist < worldTolerance) { - hits.push({ type: 'MAGNET', id: mag.id, name: `Magnet (${mag.type})`, dist }); - } - }); - - // 4. Edges - map.edges.forEach(edge => { - const start = map.nodes.find(n => n.id === edge.from); - const end = map.nodes.find(n => n.id === edge.to); - if (start && end) { - const dist = distanceToSegment(p, start, end); - if (dist < worldTolerance / 2) { - hits.push({ type: 'EDGE', id: edge.id, name: `Line ${edge.from}-${edge.to}`, dist }); - } - } - }); - - // Sort by Priority first, then Distance - return hits.sort((a,b) => { - const prioDiff = typePriority[a.type] - typePriority[b.type]; - if (prioDiff !== 0) return prioDiff; - return a.dist - b.dist; - }); - }; - - const deleteObject = (item: HitObject) => { - if (item.id === selectedItemId) setSelectedItemId(null); // Clear selection if deleted - setMapData(prev => { - const next = { ...prev }; - if (item.type === 'NODE') { - next.nodes = prev.nodes.filter(n => n.id !== item.id); - next.edges = prev.edges.filter(e => e.from !== item.id && e.to !== item.id); - } else if (item.type === 'MARK') { - next.marks = prev.marks.filter(m => m.id !== item.id); - } else if (item.type === 'MAGNET') { - next.magnets = prev.magnets.filter(m => m.id !== item.id); - } else if (item.type === 'EDGE') { - next.edges = prev.edges.filter(e => e.id !== item.id); - } - return next; - }); - setContextMenu(null); - }; - - // --- Main Simulation Loop --- - useEffect(() => { - let animationFrameId: number; - - const updatePhysics = () => { - const state = { ...agvStateRef.current }; - const map = mapDataRef.current; - let hasChanged = false; - logThrottleRef.current++; - - // Update sensors - const frontSensor = rotatePoint( - state.x + SENSOR_OFFSET_FRONT.x, - state.y + SENSOR_OFFSET_FRONT.y, - state.x, state.y, state.rotation - ); - const rearSensor = rotatePoint( - state.x + SENSOR_OFFSET_REAR.x, - state.y + SENSOR_OFFSET_REAR.y, - state.x, state.y, state.rotation - ); - const markSensor = rotatePoint( - state.x + SENSOR_OFFSET_MARK.x, - state.y + SENSOR_OFFSET_MARK.y, - state.x, state.y, state.rotation - ); - - if (!prevMarkSensorPosRef.current) prevMarkSensorPosRef.current = markSensor; - - let lineFrontDetected = false; - let lineRearDetected = false; - - // FIXED: Very tight threshold to ensure the sensor must physically be ON the line - // Reduced from 12 to 8 to prevent "grazing" false positives during tight turns - const DETECTION_THRESHOLD = 8; - const SEARCH_RADIUS = 30; - - // 1. Check Strict Sensor Status (for UI/State) - map.magnets.forEach(mag => { - let distFront = 999; - let distRear = 999; - - if (mag.type === 'STRAIGHT') { - distFront = distanceToSegment(frontSensor, mag.p1, mag.p2); - distRear = distanceToSegment(rearSensor, mag.p1, mag.p2); - } else if (mag.type === 'CURVE' && mag.controlPoint) { - distFront = distanceToBezier(frontSensor, mag.p1, mag.controlPoint, mag.p2).dist; - distRear = distanceToBezier(rearSensor, mag.p1, mag.controlPoint, mag.p2).dist; - } - - if (distFront < DETECTION_THRESHOLD) lineFrontDetected = true; - if (distRear < DETECTION_THRESHOLD) lineRearDetected = true; - }); - - // Handle Line Out Logic - const isAutoMove = state.motionState === AgvMotionState.RUNNING || state.motionState === AgvMotionState.MARK_STOPPING; - - if (state.motionState !== AgvMotionState.IDLE && !state.error) { - hasChanged = true; - let moveSpeed = state.speed; - - if (state.motionState === AgvMotionState.RUNNING) { - moveSpeed = state.runConfig.speedLevel === 'L' ? SPEED_L : state.runConfig.speedLevel === 'M' ? SPEED_M : SPEED_H; - } else if (state.motionState === AgvMotionState.MARK_STOPPING) { - moveSpeed = Math.min(state.speed, MARK_SEARCH_SPEED); + observer.observe(containerRef.current); } - // --- LINE TRACING --- - if (isAutoMove) { - const isFwd = state.runConfig.direction === 'FWD'; - const sensorPos = isFwd ? frontSensor : rearSensor; + return () => observer.disconnect(); + }, []); - let bestMagnet: { mag: MagnetLine, dist: number, targetAngle: number, proj: {x:number, y:number} } | null = null; - - // Collect all candidate lines - const potentialLines: { mag: MagnetLine, dist: number, proj: {x:number, y:number}, targetAngle: number, angleDiff: number, category: string, isSticky: boolean, isBehind: boolean, isConnected: boolean, isSharpTurn: boolean }[] = []; - - map.magnets.forEach(mag => { - let dist = 999; - let proj = { x: 0, y: 0 }; - let tangentAngle = 0; - - if (mag.type === 'STRAIGHT') { - dist = distanceToSegment(sensorPos, mag.p1, mag.p2); - proj = getClosestPointOnSegment(sensorPos, mag.p1, mag.p2); - tangentAngle = Math.atan2(mag.p2.y - mag.p1.y, mag.p2.x - mag.p1.x) * (180/Math.PI); - } else if (mag.type === 'CURVE' && mag.controlPoint) { - const res = distanceToBezier(sensorPos, mag.p1, mag.controlPoint, mag.p2); - dist = res.dist; - proj = res.proj; - tangentAngle = res.angle; - } - - if (dist < SEARCH_RADIUS) { - let heading = state.rotation; - if (!isFwd) heading += 180; - heading = normalizeAngle(heading); - - // Align tangent to heading for basic diff calc - let alignedTangent = tangentAngle; - if (mag.type === 'STRAIGHT') { - let diff = normalizeAngle(tangentAngle - heading); - if (Math.abs(diff) > 90) alignedTangent = normalizeAngle(tangentAngle + 180); - } else { - let diff = normalizeAngle(tangentAngle - heading); - if (Math.abs(diff) > 90) alignedTangent = normalizeAngle(tangentAngle + 180); - } - - let diff = normalizeAngle(alignedTangent - heading); - - // --- Node-based Lookahead / Category --- - let comparisonAngle = alignedTangent; - const d1 = getDistance(sensorPos, mag.p1); - const d2 = getDistance(sensorPos, mag.p2); - const farEnd = d1 > d2 ? mag.p1 : mag.p2; // Heuristic for segment end - const targetNode = map.nodes.find(n => getDistance(n, farEnd) < 40); - - if (targetNode) { - const nodeAngle = Math.atan2(targetNode.y - sensorPos.y, targetNode.x - sensorPos.x) * (180 / Math.PI); - comparisonAngle = normalizeAngle(nodeAngle); - } - - let finalDiff = normalizeAngle(comparisonAngle - heading); - let category = 'STRAIGHT'; - if (finalDiff < -45) category = 'LEFT'; - else if (finalDiff > 45) category = 'RIGHT'; - - // --- Behind Check --- - const dx = proj.x - sensorPos.x; - const dy = proj.y - sensorPos.y; - const hRad = (heading * Math.PI) / 180; - const hx = Math.cos(hRad); - const hy = Math.sin(hRad); - - let isSticky = lastMagnetIdRef.current === mag.id; - - // Release Sticky if End of Line Reached - if (isSticky) { - const distToP1 = getDistance(sensorPos, mag.p1); - const distToP2 = getDistance(sensorPos, mag.p2); - if (distToP1 < 25 || distToP2 < 25) { - isSticky = false; - } - } - - // Dot product check - const behindThreshold = isSticky ? -25 : -5; - const isBehind = (dx * hx + dy * hy) < behindThreshold; - - // --- STRICT CONNECTIVITY CHECK --- - const distToEndpoint = Math.min(getDistance(sensorPos, mag.p1), getDistance(sensorPos, mag.p2)); - const isConnected = distToEndpoint < 30; - - // --- PHYSICAL CONSTRAINT (SHARP TURN) --- - // If deflection is > 35 degrees (Internal angle < 145), it is too sharp for normal line tracking - // This forces the AGV to ignore 90-degree junctions unless there is a curve magnet - const isSharpTurn = Math.abs(diff) > 35; - - potentialLines.push({ mag, dist, proj, targetAngle: alignedTangent, angleDiff: diff, category, isSticky, isBehind, isConnected, isSharpTurn }); - } - }); - - // Priority Sort - const branchMode = state.runConfig.branch; - - potentialLines.sort((a, b) => { - // 1. Behind Check (Absolute disqualifier usually) - if (a.isBehind !== b.isBehind) return a.isBehind ? 1 : -1; - - // 2. PHYSICAL CONSTRAINT: Disqualify Sharp Turns - // A sharp turn is physically impossible in motion, so we deprioritize it heavily. - if (a.isSharpTurn !== b.isSharpTurn) return a.isSharpTurn ? 1 : -1; - - const aValid = a.isSticky || a.isConnected; - const bValid = b.isSticky || b.isConnected; - - // 3. Validity Check: We prefer lines that are physically anchored here - if (aValid !== bValid) return aValid ? -1 : 1; - - // 4. Branch Matching (Only among valid lines) - if (aValid && bValid) { - const aMatch = a.category === branchMode; - const bMatch = b.category === branchMode; - if (aMatch !== bMatch) return aMatch ? -1 : 1; - } - - // 5. Stickiness (Stability bias) - if (a.isSticky !== b.isSticky) return a.isSticky ? -1 : 1; - - // 6. Connectivity (Prefer line starts over mid-lines) - if (a.isConnected !== b.isConnected) return a.isConnected ? -1 : 1; - - // 7. Distance - return a.dist - b.dist; - }); - - // Debug Logging (Throttled ~ 30 frames) - if (logThrottleRef.current % 30 === 0 && isAutoMove) { - const logMsg = `Trk: found=${potentialLines.length} | ` + - potentialLines.map(p => `[${p.mag.id.substring(0,4)} D:${Math.round(p.dist)} S:${p.isSticky ? 'Y' : 'N'} M:${p.category === branchMode ? 'Y':'N'} !${p.isSharpTurn ? 'SHARP' : 'OK'}]`).join(' '); - onLog(logMsg); + // Keyboard Event for Spacebar Panning + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.code === 'Space') isSpacePressedRef.current = true; + if (e.key === 'Escape') { + setSelectedItemIds(new Set()); + setDragHandle(null); + setDragTarget(null); + setContextMenu(null); + setSelectionBox(null); } + }; + const handleKeyUp = (e: KeyboardEvent) => { + if (e.code === 'Space') isSpacePressedRef.current = false; + }; + window.addEventListener('keydown', handleKeyDown); + window.addEventListener('keyup', handleKeyUp); + return () => { + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('keyup', handleKeyUp); + }; + }, []); - if (potentialLines.length > 0) { - bestMagnet = potentialLines[0]; - lastMagnetIdRef.current = bestMagnet.mag.id; - } + // Reset tool states when tool changes + useEffect(() => { + setCurvePhase(0); + setTempMagnet(null); + setDragStartPos(null); + setStartNodeId(null); + setContextMenu(null); + setDragTarget(null); + setDragHandle(null); + // Note: We might want to keep selection when switching tools, but often resetting is safer + if (activeTool !== ToolType.SELECT) setSelectedItemIds(new Set()); + }, [activeTool]); - // *** LINE OUT CHECK INTEGRATED HERE *** - // Only error out if NO valid lines are found in search radius - if (!bestMagnet && isAutoMove) { - state.motionState = AgvMotionState.IDLE; - state.error = 'LINE_OUT'; - hasChanged = true; - onLog(`STOPPED: LINE_OUT. Sensors: F=${lineFrontDetected}, R=${lineRearDetected}`); - } else if (bestMagnet) { - // Adaptive Steering - const angleError = normalizeAngle(bestMagnet.targetAngle - (isFwd ? state.rotation : state.rotation + 180)); - - // Sharp turn handling - Reduced aggression to prevent jitter - const steerFactor = Math.abs(angleError) > 20 ? 0.1 : 0.05; - state.rotation += angleError * steerFactor; - - const driftX = bestMagnet.proj.x - sensorPos.x; - const driftY = bestMagnet.proj.y - sensorPos.y; - - // Position correction (Damping + Clamping) - // This prevents "Warping" when switching to lines that are found far away (e.g. 60px) - const driftFactor = 0.1; // Reduced from 0.3 for smoother path following - let moveX = driftX * driftFactor; - let moveY = driftY * driftFactor; - - // CLAMP: Max lateral snap per frame to prevent teleportation - const MAX_SNAP = 2.0; - const moveDist = Math.sqrt(moveX*moveX + moveY*moveY); - if (moveDist > MAX_SNAP) { - const ratio = MAX_SNAP / moveDist; - moveX *= ratio; - moveY *= ratio; + // Preload Images + useEffect(() => { + const cache = imageCacheRef.current; + mapData.nodes.forEach(node => { + if (node.type === NodeType.Image && node.imageBase64 && !cache[node.id]) { + const img = new Image(); + let src = node.imageBase64; + if (!src.startsWith('data:image')) { + src = `data:image/png;base64,${src}`; } - - state.x += moveX; - state.y += moveY; - - if (bestMagnet.mag.type === 'CURVE') { - // Extra rotation correction for curves is now handled better by Derivative angle - // But we keep a small correctional factor for drift - const newSensorX = sensorPos.x + driftX; - const newSensorY = sensorPos.y + driftY; - const otherSensor = isFwd ? rearSensor : frontSensor; - const dx = newSensorX - otherSensor.x; - const dy = newSensorY - otherSensor.y; - let newAngle = Math.atan2(dy, dx) * (180/Math.PI); - if (!isFwd) newAngle += 180; - let diff = normalizeAngle(newAngle - state.rotation); - state.rotation += diff * 0.1; // Reduced significantly as derivative is more accurate - } - - // Apply Movement - const rad = (state.rotation * Math.PI) / 180; - const dirMult = isFwd ? 1 : -1; - state.x += Math.cos(rad) * moveSpeed * dirMult; - state.y += Math.sin(rad) * moveSpeed * dirMult; - } - - } else { - // Manual Movements (unchanged) - const rad = (state.rotation * Math.PI) / 180; - if (state.motionState === AgvMotionState.FORWARD) { - state.x += Math.cos(rad) * moveSpeed; - state.y += Math.sin(rad) * moveSpeed; - } else if (state.motionState === AgvMotionState.BACKWARD) { - state.x -= Math.cos(rad) * moveSpeed; - state.y -= Math.sin(rad) * moveSpeed; - } else if (state.motionState === AgvMotionState.TURN_LEFT) state.rotation -= TURN_SPEED; - else if (state.motionState === AgvMotionState.TURN_RIGHT) state.rotation += TURN_SPEED; - else if (state.motionState === AgvMotionState.TURN_LEFT_180) { - state.rotation -= TURN_SPEED; - // 180 Turn Logic (L-Turn = CCW): - // Stop when REAR sensor detects a new line (e.g. 90 degree cross), but ignore first ~60 degrees to clear original line. - - const startRot = state.targetRotation !== null ? state.targetRotation + 180 : state.rotation; - const degreesTurned = startRot - state.rotation; // Positive increasing - - const ignoreSensors = degreesTurned < 60; // Increased from 40 to 60 to prevent early false detection - - // L-Turn: Check REAR sensor for stop condition - const stopCondition = !ignoreSensors && lineRearDetected; - const reachedLimit = (state.targetRotation !== null && state.rotation <= state.targetRotation); - - // Debug Log - if (logThrottleRef.current % 10 === 0) { - onLog(`Turn180 [L]: Deg=${degreesTurned.toFixed(1)}/180 Ignore=${ignoreSensors ? 'Y':'N'} RearLine=${lineRearDetected ? 'YES':'NO'}`); - } - - if (stopCondition || reachedLimit) { - if (reachedLimit && state.targetRotation !== null) state.rotation = state.targetRotation; - state.motionState = AgvMotionState.IDLE; state.targetRotation = null; - onLog(`Turn180 [L] Complete. Reason: ${stopCondition ? 'Rear Sensor Hit' : 'Angle Reached'}`); - } - } else if (state.motionState === AgvMotionState.TURN_RIGHT_180) { - state.rotation += TURN_SPEED; - // 180 Turn Logic (R-Turn = CW): - // Stop when FRONT sensor detects a new line, but ignore first ~60 degrees. - - const startRot = state.targetRotation !== null ? state.targetRotation - 180 : state.rotation; - const degreesTurned = state.rotation - startRot; // Positive increasing - - const ignoreSensors = degreesTurned < 60; // Increased from 40 to 60 - - // R-Turn: Check FRONT sensor for stop condition - const stopCondition = !ignoreSensors && lineFrontDetected; - const reachedLimit = (state.targetRotation !== null && state.rotation >= state.targetRotation); - - // Debug Log - if (logThrottleRef.current % 10 === 0) { - onLog(`Turn180 [R]: Deg=${degreesTurned.toFixed(1)}/180 Ignore=${ignoreSensors ? 'Y':'N'} FrontLine=${lineFrontDetected ? 'YES':'NO'}`); - } - - if (stopCondition || reachedLimit) { - if (reachedLimit && state.targetRotation !== null) state.rotation = state.targetRotation; - state.motionState = AgvMotionState.IDLE; state.targetRotation = null; - onLog(`Turn180 [R] Complete. Reason: ${stopCondition ? 'Front Sensor Hit' : 'Angle Reached'}`); - } - } - } - } - - state.sensorLineFront = lineFrontDetected; - state.sensorLineRear = lineRearDetected; - - let rfidFound: string | null = null; - map.nodes.forEach(node => { if (node.rfidId && getDistance({x: state.x, y: state.y}, node) < 15) rfidFound = node.rfidId; }); - state.detectedRfid = rfidFound; - - let markFound = false; - const prevSensorPos = prevMarkSensorPosRef.current; - if (prevSensorPos) { - map.marks.forEach(mark => { - const rad = (mark.rotation * Math.PI) / 180; - const dx = Math.cos(rad) * 25; const dy = Math.sin(rad) * 25; - const ms = { x: mark.x - dx, y: mark.y - dy }; const me = { x: mark.x + dx, y: mark.y + dy }; - - // Check 1: Dynamic Crossing (Intersection) - if (doSegmentsIntersect(prevSensorPos, markSensor, ms, me)) { - markFound = true; - } - - // Check 2: Static Proximity (Distance to line segment) - // This ensures sensor stays ON when sitting on the mark - if (!markFound) { - const distToMarkLine = distanceToSegment(markSensor, ms, me); - // If within 10px of the mark tape (tape width is technically line width but visually 3px, sensor range is wider) - if (distToMarkLine < 10) { - markFound = true; - } + img.src = src; + cache[node.id] = img; } }); - } - prevMarkSensorPosRef.current = { x: markSensor.x, y: markSensor.y }; - state.sensorMark = markFound; - if (markFound && state.motionState === AgvMotionState.MARK_STOPPING) { state.motionState = AgvMotionState.IDLE; hasChanged = true; } + }, [mapData]); - if (hasChanged || state.sensorLineFront !== agvStateRef.current.sensorLineFront || state.sensorMark !== agvStateRef.current.sensorMark || state.error !== agvStateRef.current.error || state.rotation !== agvStateRef.current.rotation) { - agvStateRef.current = state; - setAgvState(state); - } - }; - - const draw = () => { - const canvas = canvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - // Clear Screen - ctx.clearRect(0, 0, canvas.width, canvas.height); - - // --- Apply Zoom & Pan --- - const t = viewRef.current; - ctx.save(); - ctx.translate(t.x, t.y); - ctx.scale(t.scale, t.scale); - - // --- Draw Infinite Grid --- - const visibleLeft = -t.x / t.scale; - const visibleTop = -t.y / t.scale; - const visibleRight = (canvas.width - t.x) / t.scale; - const visibleBottom = (canvas.height - t.y) / t.scale; - - const startX = Math.floor(visibleLeft / GRID_SIZE) * GRID_SIZE; - const endX = Math.floor(visibleRight / GRID_SIZE) * GRID_SIZE + GRID_SIZE; - const startY = Math.floor(visibleTop / GRID_SIZE) * GRID_SIZE; - const endY = Math.floor(visibleBottom / GRID_SIZE) * GRID_SIZE + GRID_SIZE; - - ctx.strokeStyle = '#374151'; - ctx.lineWidth = 1; - ctx.beginPath(); - // Verticals - for (let x = startX; x <= endX; x += GRID_SIZE) { - ctx.moveTo(x, startY); ctx.lineTo(x, endY); - } - // Horizontals - for (let y = startY; y <= endY; y += GRID_SIZE) { - ctx.moveTo(startX, y); ctx.lineTo(endX, y); - } - ctx.stroke(); - - const map = mapDataRef.current; - - // 1. Logical Edges (Thin Gray) - ctx.strokeStyle = '#4B5563'; - ctx.lineWidth = 1; - ctx.setLineDash([2, 2]); - map.edges.forEach(edge => { - const start = map.nodes.find(n => n.id === edge.from); - const end = map.nodes.find(n => n.id === edge.to); - if (start && end) { - ctx.beginPath(); ctx.moveTo(start.x, start.y); ctx.lineTo(end.x, end.y); ctx.stroke(); - } - }); - ctx.setLineDash([]); - - // 2. Magnet Lines (Wide, Transparent Blue) - ctx.lineWidth = MAGNET_WIDTH; - ctx.lineCap = 'round'; - map.magnets.forEach(mag => { - const isSelected = mag.id === selectedItemId; - ctx.strokeStyle = isSelected ? 'rgba(59, 130, 246, 0.8)' : MAGNET_COLOR; // Brighter if selected - - ctx.beginPath(); - ctx.moveTo(mag.p1.x, mag.p1.y); - if (mag.type === 'STRAIGHT') { - ctx.lineTo(mag.p2.x, mag.p2.y); - } else if (mag.type === 'CURVE' && mag.controlPoint) { - ctx.quadraticCurveTo(mag.controlPoint.x, mag.controlPoint.y, mag.p2.x, mag.p2.y); - } - ctx.stroke(); - - // Draw Controls for Selected Curve - if (isSelected && mag.type === 'CURVE' && mag.controlPoint) { - // Helper Lines - ctx.strokeStyle = 'rgba(255, 255, 0, 0.5)'; - ctx.lineWidth = 1; - ctx.setLineDash([3, 3]); - ctx.beginPath(); - ctx.moveTo(mag.p1.x, mag.p1.y); - ctx.lineTo(mag.controlPoint.x, mag.controlPoint.y); - ctx.lineTo(mag.p2.x, mag.p2.y); - ctx.stroke(); - ctx.setLineDash([]); - - // Control Points - const drawHandle = (p: {x:number, y:number}, color: string) => { - ctx.fillStyle = color; - ctx.beginPath(); ctx.arc(p.x, p.y, 5 / t.scale, 0, Math.PI*2); ctx.fill(); // Scale handle size - ctx.strokeStyle = '#FFF'; ctx.lineWidth = 1; ctx.stroke(); - }; - - drawHandle(mag.p1, '#3B82F6'); // Start (Blue) - drawHandle(mag.p2, '#3B82F6'); // End (Blue) - drawHandle(mag.controlPoint, '#EAB308'); // Control (Yellow) - } else if (isSelected && mag.type === 'STRAIGHT') { - // Simple handles for straight lines - const drawHandle = (p: {x:number, y:number}, color: string) => { - ctx.fillStyle = color; - ctx.beginPath(); ctx.arc(p.x, p.y, 5 / t.scale, 0, Math.PI*2); ctx.fill(); - ctx.strokeStyle = '#FFF'; ctx.lineWidth = 1; ctx.stroke(); - }; - drawHandle(mag.p1, '#3B82F6'); - drawHandle(mag.p2, '#3B82F6'); - } - }); - - // Nodes (Unchanged) - const images = imageCacheRef.current; - map.nodes.forEach(node => { - if (node.type === NodeType.Image && images[node.id]) { - const img = images[node.id]; - if (img.complete) { - const w = 100; const h = (img.height / img.width) * w; - ctx.drawImage(img, node.x - w/2, node.y - h/2, w, h); - } - return; - } - if (node.type === NodeType.Label && node.labelText) { - ctx.font = 'bold 20px Arial'; - const tm = ctx.measureText(node.labelText); - const w = tm.width + 20; const h = 30; - if (node.backColor !== 'Transparent') { ctx.fillStyle = node.backColor!; ctx.fillRect(node.x - w/2, node.y - h/2, w, h); } - ctx.fillStyle = node.foreColor || 'White'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(node.labelText, node.x, node.y); - return; - } - let color = '#4B5563'; - if (node.type === NodeType.Loader || node.type === NodeType.UnLoader) color = 'green'; - else if (node.type === NodeType.Charging || node.type === NodeType.ChargerStation) color = 'magenta'; - else if (node.type === NodeType.Buffer) color = 'green'; - else if (node.displayColor) color = node.displayColor; - - // Highlight Selected Node - if (node.id === selectedItemId) { - ctx.strokeStyle = '#FDE047'; ctx.lineWidth = 2; - ctx.strokeRect(node.x - 10, node.y - 10, 20, 20); - } - - ctx.fillStyle = color; - if (node.type !== NodeType.Normal) ctx.fillRect(node.x - 6, node.y - 6, 12, 12); - else { ctx.beginPath(); ctx.arc(node.x, node.y, 4, 0, Math.PI * 2); ctx.fill(); } - ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; ctx.font = '10px monospace'; ctx.fillStyle = '#9CA3AF'; - const topText = node.rfidId ? `[${node.rfidId}]` : node.id; ctx.fillText(topText, node.x, node.y - 10); - if (node.name) { ctx.font = 'bold 10px Arial'; ctx.fillStyle = '#FFFFFF'; ctx.fillText(node.name, node.x, node.y + 18); } - }); - - // Marks - ctx.strokeStyle = '#FCD34D'; ctx.lineWidth = 3; - map.marks.forEach(mark => { - if (mark.id === selectedItemId) { - ctx.shadowColor = '#FDE047'; ctx.shadowBlur = 10; - } else { - ctx.shadowBlur = 0; - } - ctx.save(); ctx.translate(mark.x, mark.y); ctx.rotate((mark.rotation * Math.PI) / 180); - ctx.beginPath(); ctx.moveTo(-25, 0); ctx.lineTo(25, 0); ctx.stroke(); ctx.restore(); - ctx.shadowBlur = 0; - - // Draw handles if selected - if (mark.id === selectedItemId) { - - // Re-apply transform for simple drawing in local space - ctx.save(); ctx.translate(mark.x, mark.y); ctx.rotate((mark.rotation * Math.PI) / 180); - - // Handles - const drawHandle = (x: number, y: number) => { - ctx.fillStyle = '#EAB308'; - ctx.beginPath(); ctx.arc(x, y, 4 / t.scale, 0, Math.PI*2); ctx.fill(); - ctx.strokeStyle = '#FFF'; ctx.lineWidth = 1; ctx.stroke(); - }; - drawHandle(-25, 0); // Start - drawHandle(25, 0); // End - - ctx.restore(); - } - }); - - // Drawing Helpers - - // -- Straight Magnet -- - if (activeTool === ToolType.DRAW_MAGNET_STRAIGHT && dragStartPos) { - ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; - ctx.lineWidth = MAGNET_WIDTH; - ctx.beginPath(); - ctx.moveTo(dragStartPos.x, dragStartPos.y); - - // Use snapped mouse pos for preview - const curr = getSnappedPos(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y); - ctx.lineTo(curr.x, curr.y); - ctx.stroke(); - } - - // -- Logical Line -- - if (activeTool === ToolType.DRAW_LINE && dragStartPos) { - ctx.lineWidth = 1; ctx.strokeStyle = '#60A5FA'; ctx.setLineDash([5, 5]); - ctx.beginPath(); - ctx.moveTo(dragStartPos.x, dragStartPos.y); - const curr = getSnappedPos(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y); - ctx.lineTo(curr.x, curr.y); - ctx.stroke(); - ctx.setLineDash([]); - } - - // -- Curve Magnet (3-Step Drawing) -- - if (activeTool === ToolType.DRAW_MAGNET_CURVE && tempMagnet) { - const p1 = tempMagnet.p1!; - const p2 = tempMagnet.p2 || getSnappedPos(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y); - - // Visualize Construction - ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; - ctx.lineWidth = 1; - ctx.setLineDash([2, 2]); - ctx.beginPath(); - ctx.moveTo(p1.x, p1.y); - - let control = tempMagnet.controlPoint; - if (curvePhase === 1) { - // In phase 1, control is midpoint of straight line - control = { x: (p1.x + p2.x)/2, y: (p1.y + p2.y)/2 }; - ctx.lineTo(p2.x, p2.y); - } else if (curvePhase === 2) { - // In phase 2, control follows mouse (no snapping for control point usually feels smoother, but can apply if needed) - // Let's use raw mouse for control point smooth dragging - control = mouseWorldPosRef.current; - ctx.lineTo(control.x, control.y); - ctx.lineTo(p2.x, p2.y); - } - ctx.stroke(); - ctx.setLineDash([]); - - // Control Point Handle - if (control) { - ctx.fillStyle = '#60A5FA'; - ctx.beginPath(); ctx.arc(control.x, control.y, 4, 0, Math.PI * 2); ctx.fill(); - } - - // Render Final Curve Preview - ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)'; - ctx.lineWidth = MAGNET_WIDTH; - ctx.beginPath(); - ctx.moveTo(p1.x, p1.y); - if (control) { - ctx.quadraticCurveTo(control.x, control.y, p2.x, p2.y); - } else { - ctx.lineTo(p2.x, p2.y); - } - ctx.stroke(); - } - - // AGV Drawing (Same as before) - const state = agvStateRef.current; - ctx.save(); ctx.translate(state.x, state.y); ctx.rotate((state.rotation * Math.PI) / 180); - - // Draw LiDAR Scanner if active (Directional: Front or Rear based on Motor Direction) - if (state.lidarEnabled) { - const isFwd = state.runConfig.direction === 'FWD'; - const sensorPos = isFwd ? SENSOR_OFFSET_FRONT : SENSOR_OFFSET_REAR; - - ctx.save(); - // Arc Scanner - ctx.fillStyle = 'rgba(6, 182, 212, 0.2)'; // Cyan transparent - ctx.strokeStyle = 'rgba(6, 182, 212, 0.5)'; - ctx.lineWidth = 1; - ctx.beginPath(); - - const baseAngle = isFwd ? 0 : Math.PI; // 0 for Front, 180 for Rear - - ctx.moveTo(sensorPos.x, sensorPos.y); - ctx.arc(sensorPos.x, sensorPos.y, 60, baseAngle - Math.PI/6, baseAngle + Math.PI/6); // +/- 30 degrees - ctx.closePath(); - ctx.fill(); - ctx.stroke(); - - // Scanning line animation - const now = Date.now(); - const scanOffset = Math.sin(now / 200) * (Math.PI/6); - const currentAngle = baseAngle + scanOffset; - - ctx.beginPath(); - ctx.moveTo(sensorPos.x, sensorPos.y); - ctx.lineTo(sensorPos.x + Math.cos(currentAngle)*60, sensorPos.y + Math.sin(currentAngle)*60); - ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; - ctx.stroke(); - - ctx.restore(); - } - - ctx.fillStyle = '#1F2937'; ctx.strokeStyle = '#3B82F6'; ctx.lineWidth = 2; - ctx.fillRect(-AGV_WIDTH / 2, -AGV_HEIGHT / 2, AGV_WIDTH, AGV_HEIGHT); ctx.strokeRect(-AGV_WIDTH / 2, -AGV_HEIGHT / 2, AGV_WIDTH, AGV_HEIGHT); - if (state.error) { ctx.fillStyle = '#EF4444'; ctx.beginPath(); ctx.arc(0, 0, 15, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#FFF'; ctx.font = 'bold 8px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('ERR', 0, 0); } - ctx.fillStyle = '#000'; ctx.fillRect(-AGV_WIDTH / 2 - 4, -10, 4, 20); ctx.fillRect(AGV_WIDTH / 2, -10, 4, 20); - ctx.fillStyle = '#10B981'; ctx.fillRect(AGV_HEIGHT / 2 - 10, -AGV_WIDTH / 2 + 5, 10, AGV_WIDTH - 10); - ctx.fillStyle = '#EF4444'; ctx.fillRect(-AGV_HEIGHT / 2 + 5, -AGV_WIDTH / 2 + 10, 15, 20); - ctx.fillStyle = '#FFF'; ctx.font = '10px Arial'; ctx.textBaseline = 'alphabetic'; ctx.fillText(`Z:${state.liftHeight}`, -AGV_HEIGHT / 2 + 12, -AGV_WIDTH / 2 + 25); - - // Draw Status Info Panel (Rear) - ctx.save(); - ctx.translate(-12, 0); // Move towards rear center - - // Panel Background - ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'; - ctx.strokeStyle = '#374151'; - ctx.lineWidth = 1; - ctx.fillRect(-10, -18, 20, 36); - ctx.strokeRect(-10, -18, 20, 36); - - // Speed (Top) - const spd = state.runConfig.speedLevel; - ctx.fillStyle = spd === 'H' ? '#F87171' : spd === 'M' ? '#FACC15' : '#4ADE80'; // Red/Yellow/Green - ctx.font = 'bold 12px Arial'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(spd, 0, -8); - - // Divider - ctx.strokeStyle = '#4B5563'; - ctx.beginPath(); ctx.moveTo(-8, 0); ctx.lineTo(8, 0); ctx.stroke(); - - // Branch (Bottom) - ctx.fillStyle = '#FFF'; - const br = state.runConfig.branch; - if (br === 'LEFT') { - ctx.save(); ctx.translate(0, 10); ctx.rotate(-Math.PI/2); ctx.fillText('➜', 0, 0); ctx.restore(); - } else if (br === 'RIGHT') { - ctx.save(); ctx.translate(0, 10); ctx.rotate(Math.PI/2); ctx.fillText('➜', 0, 0); ctx.restore(); - } else { - ctx.fillText('➜', 0, 10); - } - ctx.restore(); - - // Draw Magnet Key Icon if ON (Shifted forward to avoid status panel) - if (state.magnetOn) { - ctx.save(); - ctx.translate(15, -6); // Position shifted forward - ctx.fillStyle = '#FACC15'; // Yellow/Gold - ctx.strokeStyle = '#FACC15'; - ctx.lineWidth = 1.5; - - // Key Head (Circle) - ctx.beginPath(); - ctx.arc(0, -3, 3.5, 0, Math.PI*2); - ctx.stroke(); - // Key Hole - ctx.beginPath(); ctx.arc(0, -3, 1, 0, Math.PI*2); ctx.fill(); - - // Key Shaft - ctx.beginPath(); - ctx.moveTo(0, 0.5); - ctx.lineTo(0, 8); - - // Key Teeth - ctx.moveTo(0, 4); ctx.lineTo(3, 4); - ctx.moveTo(0, 6.5); ctx.lineTo(3, 6.5); - ctx.stroke(); - ctx.restore(); - } - - // Sensors - ctx.fillStyle = state.sensorLineFront ? '#F87171' : '#4B5563'; ctx.beginPath(); ctx.arc(SENSOR_OFFSET_FRONT.x, SENSOR_OFFSET_FRONT.y, 3, 0, Math.PI * 2); ctx.fill(); - ctx.fillStyle = state.sensorLineRear ? '#F87171' : '#4B5563'; ctx.beginPath(); ctx.arc(SENSOR_OFFSET_REAR.x, SENSOR_OFFSET_REAR.y, 3, 0, Math.PI * 2); ctx.fill(); - ctx.fillStyle = state.sensorMark ? '#FFFF00' : '#4B5563'; ctx.beginPath(); ctx.arc(SENSOR_OFFSET_MARK.x, SENSOR_OFFSET_MARK.y, state.sensorMark ? 6 : 4, 0, Math.PI * 2); ctx.fill(); if(state.sensorMark) { ctx.strokeStyle='#FFF'; ctx.stroke(); } - - if (state.motionState === AgvMotionState.RUNNING) { - const activeOffset = state.runConfig.direction === 'FWD' ? SENSOR_OFFSET_FRONT : SENSOR_OFFSET_REAR; - ctx.strokeStyle = '#00FF00'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(activeOffset.x, activeOffset.y, 6, 0, Math.PI * 2); ctx.stroke(); - } - ctx.strokeStyle = '#FFF'; ctx.beginPath(); ctx.moveTo(-10, 0); ctx.lineTo(10, 0); ctx.lineTo(5, -5); ctx.moveTo(10, 0); ctx.lineTo(5, 5); ctx.stroke(); - ctx.restore(); - - // Restore Transform - ctx.restore(); - - animationFrameId = requestAnimationFrame(draw); - }; - - const interval = setInterval(updatePhysics, 16); - animationFrameId = requestAnimationFrame(draw); - return () => { clearInterval(interval); cancelAnimationFrame(animationFrameId); }; - }, [activeTool, dragStartPos, tempMagnet, curvePhase, selectedItemId, dimensions]); // Re-draw when dimensions change - - // --- Interaction Handlers --- - - const snapToGrid = (val: number) => Math.round(val / (GRID_SIZE / 2)) * (GRID_SIZE / 2); // Snap to half-grid - - // Intelligent Snapping: Prefer Nodes, else Raw Position - const getSnappedPos = (rawX: number, rawY: number) => { - const map = mapDataRef.current; - const SNAP_DIST = 20; - - // 1. Try snapping to Nodes - let closestNode: MapNode | null = null; - let minDist = SNAP_DIST; - - map.nodes.forEach(node => { - const d = Math.sqrt(Math.pow(node.x - rawX, 2) + Math.pow(node.y - rawY, 2)); - if (d < minDist) { - minDist = d; - closestNode = node; - } - }); - - if (closestNode) { - return { x: closestNode.x, y: closestNode.y }; - } - - // 2. Fallback to Raw (No Grid Snap as per user request) - return { x: rawX, y: rawY }; - }; - - const handleWheel = (e: React.WheelEvent) => { - e.preventDefault(); - const rect = canvasRef.current?.getBoundingClientRect(); - if (!rect) return; - - const screenX = e.clientX - rect.left; - const screenY = e.clientY - rect.top; - - // Zoom Logic - const zoomIntensity = 0.1; - const delta = e.deltaY < 0 ? 1 : -1; - let newScale = viewRef.current.scale + delta * zoomIntensity; - newScale = Math.min(Math.max(0.2, newScale), 5); // Limit zoom - - // World pos before zoom - const worldX = (screenX - viewRef.current.x) / viewRef.current.scale; - const worldY = (screenY - viewRef.current.y) / viewRef.current.scale; - - // Adjust offset so world pos remains at screen pos - const newX = screenX - worldX * newScale; - const newY = screenY - worldY * newScale; - - viewRef.current = { x: newX, y: newY, scale: newScale }; - }; - - const handleMouseMove = (e: React.MouseEvent) => { - const rect = canvasRef.current?.getBoundingClientRect(); - if (!rect) return; - const screenX = e.clientX - rect.left; - const screenY = e.clientY - rect.top; - - mouseScreenPosRef.current = { x: screenX, y: screenY }; - const worldPos = screenToWorld(screenX, screenY); - mouseWorldPosRef.current = worldPos; - - // Pan - if (isPanning) { - viewRef.current = { - ...viewRef.current, - x: viewRef.current.x + e.movementX, - y: viewRef.current.y + e.movementY + // --- Coordinate Transformation Helpers --- + const screenToWorld = (screenX: number, screenY: number) => { + const t = viewRef.current; + return { + x: (screenX - t.x) / t.scale, + y: (screenY - t.y) / t.scale }; - return; - } + }; - // Drag Handle (Editing) - if (dragHandle && selectedItemId) { - const snapped = getSnappedPos(worldPos.x, worldPos.y); - setMapData(prev => { - const next = { ...prev }; - if (dragHandle === 'p1' || dragHandle === 'p2' || dragHandle === 'control') { - const magIndex = next.magnets.findIndex(m => m.id === selectedItemId); - if (magIndex !== -1) { - const newMags = [...next.magnets]; - const mag = { ...newMags[magIndex] }; - if (dragHandle === 'p1') mag.p1 = snapped; - else if (dragHandle === 'p2') mag.p2 = snapped; - else if (dragHandle === 'control') mag.controlPoint = worldPos; // No snap for control - newMags[magIndex] = mag; - next.magnets = newMags; - } - } else if (dragHandle === 'mark_p1' || dragHandle === 'mark_p2') { - // Rotate Mark - const markIndex = next.marks.findIndex(m => m.id === selectedItemId); - if (markIndex !== -1) { - const newMarks = [...next.marks]; - const mark = { ...newMarks[markIndex] }; - const dx = worldPos.x - mark.x; - const dy = worldPos.y - mark.y; - let angle = Math.atan2(dy, dx) * (180 / Math.PI); - if (dragHandle === 'mark_p1') angle += 180; - mark.rotation = normalizeAngle(angle); - newMarks[markIndex] = mark; - next.marks = newMarks; - } + const worldToScreen = (worldX: number, worldY: number) => { + const t = viewRef.current; + return { + x: worldX * t.scale + t.x, + y: worldY * t.scale + t.y + }; + }; + + // --- Physics Helper Functions --- + + const rotatePoint = (px: number, py: number, cx: number, cy: number, angleDeg: number) => { + const rad = (angleDeg * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const dx = px - cx; + const dy = py - cy; + return { + x: cx + (dx * cos - dy * sin), + y: cy + (dx * sin + dy * cos), + }; + }; + + const getDistance = (p1: { x: number, y: number }, p2: { x: number, y: number }) => { + return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); + }; + + // Distance from point P to line segment VW + const distanceToSegment = (p: { x: number, y: number }, v: { x: number, y: number }, w: { x: number, y: number }) => { + const l2 = Math.pow(getDistance(v, w), 2); + if (l2 === 0) return getDistance(p, v); + let t = ((p.x - v.x) * (w.x - v.x) + (p.y - v.y) * (w.y - v.y)) / l2; + t = Math.max(0, Math.min(1, t)); + const projection = { x: v.x + t * (w.x - v.x), y: v.y + t * (w.y - v.y) }; + return getDistance(p, projection); + }; + + // Get closest point on segment + const getClosestPointOnSegment = (p: { x: number, y: number }, v: { x: number, y: number }, w: { x: number, y: number }) => { + const l2 = Math.pow(getDistance(v, w), 2); + if (l2 === 0) return v; + let t = ((p.x - v.x) * (w.x - v.x) + (p.y - v.y) * (w.y - v.y)) / l2; + t = Math.max(0, Math.min(1, t)); + return { x: v.x + t * (w.x - v.x), y: v.y + t * (w.y - v.y) }; + }; + + // Quadratic Bezier Point Calculation + const getBezierPoint = (t: number, p0: { x: number, y: number }, p1: { x: number, y: number }, p2: { x: number, y: number }) => { + const u = 1 - t; + const tt = t * t; + const uu = u * u; + + return { + x: (uu * p0.x) + (2 * u * t * p1.x) + (tt * p2.x), + y: (uu * p0.y) + (2 * u * t * p1.y) + (tt * p2.y) + }; + }; + + // Calculate derivative (tangent vector) at t for Quadratic Bezier + const getBezierDerivative = (t: number, p0: { x: number, y: number }, p1: { x: number, y: number }, p2: { x: number, y: number }) => { + const u = 1 - t; + // B'(t) = 2(1-t)(P1 - P0) + 2t(P2 - P1) + const dx = 2 * u * (p1.x - p0.x) + 2 * t * (p2.x - p1.x); + const dy = 2 * u * (p1.y - p0.y) + 2 * t * (p2.y - p1.y); + return { x: dx, y: dy }; + }; + + // Approximate distance to a Quadratic Bezier Curve by subdividing it + // Now uses 50 segments for higher resolution to prevent "warping" + const distanceToBezier = (p: { x: number, y: number }, p0: { x: number, y: number }, p1: { x: number, y: number }, p2: { x: number, y: number }, segments = 50) => { + let minDist = Number.MAX_VALUE; + let prevPoint = p0; + let closestProj = p0; + let bestT = 0; + + // Scan segments to find approximate closest point + for (let i = 1; i <= segments; i++) { + const t = i / segments; + const currPoint = getBezierPoint(t, p0, p1, p2); + const dist = distanceToSegment(p, prevPoint, currPoint); + + if (dist < minDist) { + minDist = dist; + // Get linear projection on this segment to approximate t + const proj = getClosestPointOnSegment(p, prevPoint, currPoint); + closestProj = proj; + + // Approximate t at projection (Lerp) + const segLen = getDistance(prevPoint, currPoint); + const distOnSeg = getDistance(prevPoint, proj); + const tStep = 1 / segments; + const tStart = (i - 1) / segments; + bestT = tStart + (distOnSeg / segLen) * tStep; } + prevPoint = currPoint; + } + + // Calculate Exact Tangent using Derivative at bestT + const deriv = getBezierDerivative(bestT, p0, p1, p2); + const tangentAngle = Math.atan2(deriv.y, deriv.x) * (180 / Math.PI); + + return { dist: minDist, proj: closestProj, angle: tangentAngle }; + }; + + const normalizeAngle = (angle: number) => { + let a = angle % 360; + if (a > 180) a -= 360; + if (a <= -180) a += 360; + return a; + }; + + const doSegmentsIntersect = (p1: { x: number, y: number }, p2: { x: number, y: number }, p3: { x: number, y: number }, p4: { x: number, y: number }) => { + const ccw = (a: { x: number, y: number }, b: { x: number, y: number }, c: { x: number, y: number }) => (c.y - a.y) * (b.x - a.x) > (b.y - a.y) * (c.x - a.x); + return (ccw(p1, p2, p3) !== ccw(p1, p2, p4)) && (ccw(p3, p4, p1) !== ccw(p3, p4, p2)); + }; + + // Helper to find closest magnet and its angle + const getClosestMagnetInfo = (x: number, y: number) => { + const map = mapDataRef.current; + const p = { x, y }; + let closestMag = null; + let minDist = 30; // Search radius + let angle = 0; + + map.magnets.forEach(mag => { + let dist = 999; + let magAngle = 0; + + if (mag.type === 'STRAIGHT') { + dist = distanceToSegment(p, mag.p1, mag.p2); + magAngle = Math.atan2(mag.p2.y - mag.p1.y, mag.p2.x - mag.p1.x) * (180 / Math.PI); + } else if (mag.type === 'CURVE' && mag.controlPoint) { + const res = distanceToBezier(p, mag.p1, mag.controlPoint, mag.p2); + dist = res.dist; + magAngle = res.angle; + } + + if (dist < minDist) { + minDist = dist; + closestMag = mag; + angle = magAngle; + } + }); + return { magnet: closestMag, angle }; + }; + + // --- Hit Test Logic --- + const getObjectsAt = (x: number, y: number, tolerance = 15): HitObject[] => { + const hits: HitObject[] = []; + const map = mapDataRef.current; + const p = { x, y }; + + const worldTolerance = tolerance / viewRef.current.scale; + + // Type Priority Map: Smaller number = Higher Priority + const typePriority: Record = { + 'NODE': 1, + 'MARK': 2, + 'EDGE': 3, + 'MAGNET': 4 + }; + + // 1. Nodes + map.nodes.forEach(node => { + if (getDistance(p, node) < worldTolerance) { + hits.push({ type: 'NODE', id: node.id, name: node.name || node.id, dist: getDistance(p, node) }); + } + }); + + // 2. Marks (Calculate as segment for better hit detection) + map.marks.forEach(mark => { + // Visual width is 50px (-25 to 25). + const rad = (mark.rotation * Math.PI) / 180; + const dx = Math.cos(rad) * 25; + const dy = Math.sin(rad) * 25; + const p1 = { x: mark.x - dx, y: mark.y - dy }; + const p2 = { x: mark.x + dx, y: mark.y + dy }; + + const dist = distanceToSegment(p, p1, p2); + + if (dist < worldTolerance) { + hits.push({ type: 'MARK', id: mark.id, name: 'Mark', dist }); + } + }); + + // 3. Magnets + map.magnets.forEach(mag => { + let dist = 999; + if (mag.type === 'STRAIGHT') { + dist = distanceToSegment(p, mag.p1, mag.p2); + } else if (mag.type === 'CURVE' && mag.controlPoint) { + dist = distanceToBezier(p, mag.p1, mag.controlPoint, mag.p2).dist; + } + if (dist < worldTolerance) { + hits.push({ type: 'MAGNET', id: mag.id, name: `Magnet (${mag.type})`, dist }); + } + }); + + // 4. Edges + map.edges.forEach(edge => { + const start = map.nodes.find(n => n.id === edge.from); + const end = map.nodes.find(n => n.id === edge.to); + if (start && end) { + const dist = distanceToSegment(p, start, end); + if (dist < worldTolerance / 2) { + hits.push({ type: 'EDGE', id: edge.id, name: `Line ${edge.from}-${edge.to}`, dist }); + } + } + }); + + // Sort by Priority first, then Distance + return hits.sort((a, b) => { + const prioDiff = typePriority[a.type] - typePriority[b.type]; + if (prioDiff !== 0) return prioDiff; + return a.dist - b.dist; + }); + }; + + const deleteObject = (item: HitObject) => { + // If the item clicked is part of selection, delete ALL selected. + // If not part of selection, delete just that item. + const idsToDelete = new Set(); + if (selectedItemIds.has(item.id)) { + selectedItemIds.forEach(id => idsToDelete.add(id)); + } else { + idsToDelete.add(item.id); + } + + setSelectedItemIds(prev => { + const next = new Set(prev); + idsToDelete.forEach(id => next.delete(id)); return next; }); - return; - } - // Drag Object (Moving) - if (dragTarget && dragTarget.initialObjState) { - const dx = worldPos.x - dragTarget.startMouse.x; - const dy = worldPos.y - dragTarget.startMouse.y; - - if (dragTarget.type === 'AGV') { - setAgvState(prev => ({ - ...prev, - x: dragTarget.initialObjState.x + dx, - y: dragTarget.initialObjState.y + dy - })); - } else if (dragTarget.type === 'NODE' && dragTarget.id) { - const snapped = getSnappedPos(dragTarget.initialObjState.x + dx, dragTarget.initialObjState.y + dy); - setMapData(prev => ({ - ...prev, - nodes: prev.nodes.map(n => n.id === dragTarget.id ? { ...n, x: snapped.x, y: snapped.y } : n) - })); - } else if (dragTarget.type === 'MARK' && dragTarget.id) { - setMapData(prev => ({ - ...prev, - marks: prev.marks.map(m => m.id === dragTarget.id ? { ...m, x: dragTarget.initialObjState.x + dx, y: dragTarget.initialObjState.y + dy } : m) - })); - } else if (dragTarget.type === 'MAGNET' && dragTarget.id) { - const m0 = dragTarget.initialObjState as MagnetLine; - const dxw = worldPos.x - dragTarget.startMouse.x; // World delta - const dyw = worldPos.y - dragTarget.startMouse.y; - - setMapData(prev => ({ - ...prev, - magnets: prev.magnets.map(m => { - if (m.id === dragTarget.id) { - const newM = { ...m }; - newM.p1 = { x: m0.p1.x + dxw, y: m0.p1.y + dyw }; - newM.p2 = { x: m0.p2.x + dxw, y: m0.p2.y + dyw }; - if (m0.controlPoint) newM.controlPoint = { x: m0.controlPoint.x + dxw, y: m0.controlPoint.y + dyw }; - return newM; - } - return m; - }) - })); - } - return; - } - }; - - const handleMouseUp = (e: React.MouseEvent) => { - setIsPanning(false); - setDragHandle(null); - setDragTarget(null); - - // Finish DRAW_LINE - if (activeTool === ToolType.DRAW_LINE && dragStartPos && startNodeId) { - const { x, y } = mouseWorldPosRef.current; - const existingNode = mapData.nodes.find(n => getDistance(n, {x, y}) < 20); - if (existingNode && existingNode.id !== startNodeId) { - const edgeExists = mapData.edges.some(e => - (e.from === startNodeId && e.to === existingNode.id) || - (e.from === existingNode.id && e.to === startNodeId) - ); - if (!edgeExists) { - const newEdge: MapEdge = { - id: crypto.randomUUID(), - from: startNodeId, - to: existingNode.id - }; - setMapData(prev => ({ ...prev, edges: [...prev.edges, newEdge] })); - } - } - } - - // Finish DRAW_MAGNET_STRAIGHT - if (activeTool === ToolType.DRAW_MAGNET_STRAIGHT && dragStartPos) { - const p1 = dragStartPos; - const p2 = getSnappedPos(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y); - if (getDistance(p1, p2) > 5) { - const newMag: MagnetLine = { - id: crypto.randomUUID(), - type: 'STRAIGHT', - p1, - p2 - }; - setMapData(prev => ({ ...prev, magnets: [...prev.magnets, newMag] })); - } - } - - setDragStartPos(null); - setStartNodeId(null); - }; - - const handleMouseDown = (e: React.MouseEvent) => { - if (contextMenu) { + setMapData(prev => { + const next = { ...prev }; + // Filter out all deleted IDs + next.nodes = prev.nodes.filter(n => !idsToDelete.has(n.id)); + // Remove edges connected to deleted nodes + next.edges = prev.edges.filter(e => !idsToDelete.has(e.id) && !idsToDelete.has(e.from) && !idsToDelete.has(e.to)); + next.marks = prev.marks.filter(m => !idsToDelete.has(m.id)); + next.magnets = prev.magnets.filter(m => !idsToDelete.has(m.id)); + return next; + }); setContextMenu(null); - return; - } + }; - const rect = canvasRef.current?.getBoundingClientRect(); - if (!rect) return; - const screenX = e.clientX - rect.left; - const screenY = e.clientY - rect.top; - - if (e.button === 1 || (e.button === 0 && isSpacePressedRef.current)) { - setIsPanning(true); - return; - } + // --- Main Simulation Loop --- + useEffect(() => { + let animationFrameId: number; - const { x, y } = screenToWorld(screenX, screenY); - const worldPos = { x, y }; + const updatePhysics = () => { + const state = { ...agvStateRef.current }; + const map = mapDataRef.current; + let hasChanged = false; + logThrottleRef.current++; - if (activeTool === ToolType.ERASER) { - const hits = getObjectsAt(x, y); - if (hits.length === 1) { - deleteObject(hits[0]); - } else if (hits.length > 1) { - setContextMenu({ x: screenX, y: screenY, items: hits }); - } - return; - } + // Update sensors + const frontSensor = rotatePoint( + state.x + SENSOR_OFFSET_FRONT.x, + state.y + SENSOR_OFFSET_FRONT.y, + state.x, state.y, state.rotation + ); + const rearSensor = rotatePoint( + state.x + SENSOR_OFFSET_REAR.x, + state.y + SENSOR_OFFSET_REAR.y, + state.x, state.y, state.rotation + ); + const markSensor = rotatePoint( + state.x + SENSOR_OFFSET_MARK.x, + state.y + SENSOR_OFFSET_MARK.y, + state.x, state.y, state.rotation + ); - if (activeTool === ToolType.SELECT) { - // Check handles - const worldTolerance = 15 / viewRef.current.scale; - - if (selectedItemId) { - const selectedMag = mapData.magnets.find(m => m.id === selectedItemId); - if (selectedMag) { - if (getDistance(worldPos, selectedMag.p1) < worldTolerance) { setDragHandle('p1'); return; } - if (getDistance(worldPos, selectedMag.p2) < worldTolerance) { setDragHandle('p2'); return; } - if (selectedMag.type === 'CURVE' && selectedMag.controlPoint && getDistance(worldPos, selectedMag.controlPoint) < worldTolerance) { setDragHandle('control'); return; } + if (!prevMarkSensorPosRef.current) prevMarkSensorPosRef.current = markSensor; + + let lineFrontDetected = false; + let lineRearDetected = false; + + // FIXED: Very tight threshold to ensure the sensor must physically be ON the line + // Reduced from 12 to 8 to prevent "grazing" false positives during tight turns + const DETECTION_THRESHOLD = 8; + const SEARCH_RADIUS = 30; + + // 1. Check Strict Sensor Status (for UI/State) + map.magnets.forEach(mag => { + let distFront = 999; + let distRear = 999; + + if (mag.type === 'STRAIGHT') { + distFront = distanceToSegment(frontSensor, mag.p1, mag.p2); + distRear = distanceToSegment(rearSensor, mag.p1, mag.p2); + } else if (mag.type === 'CURVE' && mag.controlPoint) { + distFront = distanceToBezier(frontSensor, mag.p1, mag.controlPoint, mag.p2).dist; + distRear = distanceToBezier(rearSensor, mag.p1, mag.controlPoint, mag.p2).dist; + } + + if (distFront < DETECTION_THRESHOLD) lineFrontDetected = true; + if (distRear < DETECTION_THRESHOLD) lineRearDetected = true; + }); + + // Handle Line Out Logic + const isAutoMove = state.motionState === AgvMotionState.RUNNING || state.motionState === AgvMotionState.MARK_STOPPING; + + if (state.motionState !== AgvMotionState.IDLE && !state.error) { + hasChanged = true; + let moveSpeed = state.speed; + + if (state.motionState === AgvMotionState.RUNNING) { + moveSpeed = state.runConfig.speedLevel === 'L' ? SPEED_L : state.runConfig.speedLevel === 'M' ? SPEED_M : SPEED_H; + } else if (state.motionState === AgvMotionState.MARK_STOPPING) { + moveSpeed = Math.min(state.speed, MARK_SEARCH_SPEED); + } + + // --- LINE TRACING --- + if (isAutoMove) { + const isFwd = state.runConfig.direction === 'FWD'; + const sensorPos = isFwd ? frontSensor : rearSensor; + + let bestMagnet: { mag: MagnetLine, dist: number, targetAngle: number, proj: { x: number, y: number } } | null = null; + + // Collect all candidate lines + const potentialLines: { mag: MagnetLine, dist: number, proj: { x: number, y: number }, targetAngle: number, angleDiff: number, category: string, isSticky: boolean, isBehind: boolean, isConnected: boolean, isSharpTurn: boolean }[] = []; + + map.magnets.forEach(mag => { + let dist = 999; + let proj = { x: 0, y: 0 }; + let tangentAngle = 0; + + if (mag.type === 'STRAIGHT') { + dist = distanceToSegment(sensorPos, mag.p1, mag.p2); + proj = getClosestPointOnSegment(sensorPos, mag.p1, mag.p2); + tangentAngle = Math.atan2(mag.p2.y - mag.p1.y, mag.p2.x - mag.p1.x) * (180 / Math.PI); + } else if (mag.type === 'CURVE' && mag.controlPoint) { + const res = distanceToBezier(sensorPos, mag.p1, mag.controlPoint, mag.p2); + dist = res.dist; + proj = res.proj; + tangentAngle = res.angle; + } + + if (dist < SEARCH_RADIUS) { + let heading = state.rotation; + if (!isFwd) heading += 180; + heading = normalizeAngle(heading); + + // Align tangent to heading for basic diff calc + let alignedTangent = tangentAngle; + if (mag.type === 'STRAIGHT') { + let diff = normalizeAngle(tangentAngle - heading); + if (Math.abs(diff) > 90) alignedTangent = normalizeAngle(tangentAngle + 180); + } else { + let diff = normalizeAngle(tangentAngle - heading); + if (Math.abs(diff) > 90) alignedTangent = normalizeAngle(tangentAngle + 180); + } + + let diff = normalizeAngle(alignedTangent - heading); + + // --- Node-based Lookahead / Category --- + let comparisonAngle = alignedTangent; + const d1 = getDistance(sensorPos, mag.p1); + const d2 = getDistance(sensorPos, mag.p2); + const farEnd = d1 > d2 ? mag.p1 : mag.p2; // Heuristic for segment end + const targetNode = map.nodes.find(n => getDistance(n, farEnd) < 40); + + if (targetNode) { + const nodeAngle = Math.atan2(targetNode.y - sensorPos.y, targetNode.x - sensorPos.x) * (180 / Math.PI); + comparisonAngle = normalizeAngle(nodeAngle); + } + + let finalDiff = normalizeAngle(comparisonAngle - heading); + let category = 'STRAIGHT'; + if (finalDiff < -45) category = 'LEFT'; + else if (finalDiff > 45) category = 'RIGHT'; + + // --- Behind Check --- + const dx = proj.x - sensorPos.x; + const dy = proj.y - sensorPos.y; + const hRad = (heading * Math.PI) / 180; + const hx = Math.cos(hRad); + const hy = Math.sin(hRad); + + let isSticky = lastMagnetIdRef.current === mag.id; + + // Release Sticky if End of Line Reached + if (isSticky) { + const distToP1 = getDistance(sensorPos, mag.p1); + const distToP2 = getDistance(sensorPos, mag.p2); + if (distToP1 < 25 || distToP2 < 25) { + isSticky = false; + } + } + + // Dot product check + const behindThreshold = isSticky ? -25 : -5; + const isBehind = (dx * hx + dy * hy) < behindThreshold; + + // --- STRICT CONNECTIVITY CHECK --- + const distToEndpoint = Math.min(getDistance(sensorPos, mag.p1), getDistance(sensorPos, mag.p2)); + const isConnected = distToEndpoint < 30; + + // --- PHYSICAL CONSTRAINT (SHARP TURN) --- + // If deflection is > 35 degrees (Internal angle < 145), it is too sharp for normal line tracking + // This forces the AGV to ignore 90-degree junctions unless there is a curve magnet + const isSharpTurn = Math.abs(diff) > 35; + + potentialLines.push({ mag, dist, proj, targetAngle: alignedTangent, angleDiff: diff, category, isSticky, isBehind, isConnected, isSharpTurn }); + } + }); + + // Priority Sort + const branchMode = state.runConfig.branch; + + potentialLines.sort((a, b) => { + // 1. Behind Check (Absolute disqualifier usually) + if (a.isBehind !== b.isBehind) return a.isBehind ? 1 : -1; + + // 2. PHYSICAL CONSTRAINT: Disqualify Sharp Turns + // A sharp turn is physically impossible in motion, so we deprioritize it heavily. + if (a.isSharpTurn !== b.isSharpTurn) return a.isSharpTurn ? 1 : -1; + + const aValid = a.isSticky || a.isConnected; + const bValid = b.isSticky || b.isConnected; + + // 3. Validity Check: We prefer lines that are physically anchored here + if (aValid !== bValid) return aValid ? -1 : 1; + + // 4. Branch Matching (Only among valid lines) + if (aValid && bValid) { + const aMatch = a.category === branchMode; + const bMatch = b.category === branchMode; + if (aMatch !== bMatch) return aMatch ? -1 : 1; + } + + // 5. Stickiness (Stability bias) + if (a.isSticky !== b.isSticky) return a.isSticky ? -1 : 1; + + // 6. Connectivity (Prefer line starts over mid-lines) + if (a.isConnected !== b.isConnected) return a.isConnected ? -1 : 1; + + // 7. Distance + return a.dist - b.dist; + }); + + // Debug Logging (Throttled ~ 30 frames) + if (logThrottleRef.current % 30 === 0 && isAutoMove) { + const logMsg = `Trk: found=${potentialLines.length} | ` + + potentialLines.map(p => `[${p.mag.id.substring(0, 4)} D:${Math.round(p.dist)} S:${p.isSticky ? 'Y' : 'N'} M:${p.category === branchMode ? 'Y' : 'N'} !${p.isSharpTurn ? 'SHARP' : 'OK'}]`).join(' '); + onLog(logMsg); + } + + if (potentialLines.length > 0) { + bestMagnet = potentialLines[0]; + lastMagnetIdRef.current = bestMagnet.mag.id; + } + + // *** LINE OUT CHECK INTEGRATED HERE *** + // Only error out if NO valid lines are found in search radius + if (!bestMagnet && isAutoMove) { + state.motionState = AgvMotionState.IDLE; + state.error = 'LINE_OUT'; + hasChanged = true; + onLog(`STOPPED: LINE_OUT. Sensors: F=${lineFrontDetected}, R=${lineRearDetected}`); + } else if (bestMagnet) { + // Adaptive Steering + const angleError = normalizeAngle(bestMagnet.targetAngle - (isFwd ? state.rotation : state.rotation + 180)); + + // Sharp turn handling - Reduced aggression to prevent jitter + const steerFactor = Math.abs(angleError) > 20 ? 0.1 : 0.05; + state.rotation += angleError * steerFactor; + + const driftX = bestMagnet.proj.x - sensorPos.x; + const driftY = bestMagnet.proj.y - sensorPos.y; + + // Position correction (Damping + Clamping) + // This prevents "Warping" when switching to lines that are found far away (e.g. 60px) + const driftFactor = 0.1; // Reduced from 0.3 for smoother path following + let moveX = driftX * driftFactor; + let moveY = driftY * driftFactor; + + // CLAMP: Max lateral snap per frame to prevent teleportation + const MAX_SNAP = 2.0; + const moveDist = Math.sqrt(moveX * moveX + moveY * moveY); + if (moveDist > MAX_SNAP) { + const ratio = MAX_SNAP / moveDist; + moveX *= ratio; + moveY *= ratio; + } + + state.x += moveX; + state.y += moveY; + + if (bestMagnet.mag.type === 'CURVE') { + // Extra rotation correction for curves is now handled better by Derivative angle + // But we keep a small correctional factor for drift + const newSensorX = sensorPos.x + driftX; + const newSensorY = sensorPos.y + driftY; + const otherSensor = isFwd ? rearSensor : frontSensor; + const dx = newSensorX - otherSensor.x; + const dy = newSensorY - otherSensor.y; + let newAngle = Math.atan2(dy, dx) * (180 / Math.PI); + if (!isFwd) newAngle += 180; + let diff = normalizeAngle(newAngle - state.rotation); + state.rotation += diff * 0.1; // Reduced significantly as derivative is more accurate + } + + // Apply Movement + const rad = (state.rotation * Math.PI) / 180; + const dirMult = isFwd ? 1 : -1; + state.x += Math.cos(rad) * moveSpeed * dirMult; + state.y += Math.sin(rad) * moveSpeed * dirMult; + } + + } else { + // Manual Movements (unchanged) + const rad = (state.rotation * Math.PI) / 180; + if (state.motionState === AgvMotionState.FORWARD) { + state.x += Math.cos(rad) * moveSpeed; + state.y += Math.sin(rad) * moveSpeed; + } else if (state.motionState === AgvMotionState.BACKWARD) { + state.x -= Math.cos(rad) * moveSpeed; + state.y -= Math.sin(rad) * moveSpeed; + } else if (state.motionState === AgvMotionState.TURN_LEFT) state.rotation -= TURN_SPEED; + else if (state.motionState === AgvMotionState.TURN_RIGHT) state.rotation += TURN_SPEED; + else if (state.motionState === AgvMotionState.TURN_LEFT_180) { + state.rotation -= TURN_SPEED; + // 180 Turn Logic (L-Turn = CCW): + // Stop when REAR sensor detects a new line (e.g. 90 degree cross), but ignore first ~60 degrees to clear original line. + + const startRot = state.targetRotation !== null ? state.targetRotation + 180 : state.rotation; + const degreesTurned = startRot - state.rotation; // Positive increasing + + const ignoreSensors = degreesTurned < 60; // Increased from 40 to 60 to prevent early false detection + + // L-Turn: Check REAR sensor for stop condition + const stopCondition = !ignoreSensors && lineRearDetected; + const reachedLimit = (state.targetRotation !== null && state.rotation <= state.targetRotation); + + // Debug Log + if (logThrottleRef.current % 10 === 0) { + onLog(`Turn180 [L]: Deg=${degreesTurned.toFixed(1)}/180 Ignore=${ignoreSensors ? 'Y' : 'N'} RearLine=${lineRearDetected ? 'YES' : 'NO'}`); + } + + if (stopCondition || reachedLimit) { + if (reachedLimit && state.targetRotation !== null) state.rotation = state.targetRotation; + state.motionState = AgvMotionState.IDLE; state.targetRotation = null; + onLog(`Turn180 [L] Complete. Reason: ${stopCondition ? 'Rear Sensor Hit' : 'Angle Reached'}`); + } + } else if (state.motionState === AgvMotionState.TURN_RIGHT_180) { + state.rotation += TURN_SPEED; + // 180 Turn Logic (R-Turn = CW): + // Stop when FRONT sensor detects a new line, but ignore first ~60 degrees. + + const startRot = state.targetRotation !== null ? state.targetRotation - 180 : state.rotation; + const degreesTurned = state.rotation - startRot; // Positive increasing + + const ignoreSensors = degreesTurned < 60; // Increased from 40 to 60 + + // R-Turn: Check FRONT sensor for stop condition + const stopCondition = !ignoreSensors && lineFrontDetected; + const reachedLimit = (state.targetRotation !== null && state.rotation >= state.targetRotation); + + // Debug Log + if (logThrottleRef.current % 10 === 0) { + onLog(`Turn180 [R]: Deg=${degreesTurned.toFixed(1)}/180 Ignore=${ignoreSensors ? 'Y' : 'N'} FrontLine=${lineFrontDetected ? 'YES' : 'NO'}`); + } + + if (stopCondition || reachedLimit) { + if (reachedLimit && state.targetRotation !== null) state.rotation = state.targetRotation; + state.motionState = AgvMotionState.IDLE; state.targetRotation = null; + onLog(`Turn180 [R] Complete. Reason: ${stopCondition ? 'Front Sensor Hit' : 'Angle Reached'}`); + } + } + } } - const selectedMark = mapData.marks.find(m => m.id === selectedItemId); - if (selectedMark) { - const rad = (selectedMark.rotation * Math.PI) / 180; - const dx = Math.cos(rad) * 25; const dy = Math.sin(rad) * 25; - const p1 = { x: selectedMark.x - dx, y: selectedMark.y - dy }; - const p2 = { x: selectedMark.x + dx, y: selectedMark.y + dy }; - if (getDistance(worldPos, p1) < worldTolerance) { setDragHandle('mark_p1'); return; } - if (getDistance(worldPos, p2) < worldTolerance) { setDragHandle('mark_p2'); return; } + + state.sensorLineFront = lineFrontDetected; + state.sensorLineRear = lineRearDetected; + + let rfidFound: string | null = null; + map.nodes.forEach(node => { if (node.rfidId && getDistance({ x: state.x, y: state.y }, node) < 15) rfidFound = node.rfidId; }); + state.detectedRfid = rfidFound; + + let markFound = false; + const prevSensorPos = prevMarkSensorPosRef.current; + if (prevSensorPos) { + map.marks.forEach(mark => { + const rad = (mark.rotation * Math.PI) / 180; + const dx = Math.cos(rad) * 25; const dy = Math.sin(rad) * 25; + const ms = { x: mark.x - dx, y: mark.y - dy }; const me = { x: mark.x + dx, y: mark.y + dy }; + + // Check 1: Dynamic Crossing (Intersection) + if (doSegmentsIntersect(prevSensorPos, markSensor, ms, me)) { + markFound = true; + } + + // Check 2: Static Proximity (Distance to line segment) + // This ensures sensor stays ON when sitting on the mark + if (!markFound) { + const distToMarkLine = distanceToSegment(markSensor, ms, me); + // If within 10px of the mark tape (tape width is technically line width but visually 3px, sensor range is wider) + if (distToMarkLine < 10) { + markFound = true; + } + } + }); } + prevMarkSensorPosRef.current = { x: markSensor.x, y: markSensor.y }; + state.sensorMark = markFound; + if (markFound && state.motionState === AgvMotionState.MARK_STOPPING) { state.motionState = AgvMotionState.IDLE; hasChanged = true; } + + if (hasChanged || state.sensorLineFront !== agvStateRef.current.sensorLineFront || state.sensorMark !== agvStateRef.current.sensorMark || state.error !== agvStateRef.current.error || state.rotation !== agvStateRef.current.rotation) { + setAgvState(prev => { + // Conflict Resolution: + // If the motionState has changed externally (e.g. STOP command from App.tsx), we must yield to it. + // We compare the 'prev' (latest React state) with the 'agvStateRef.current' (state at start of frame). + if (prev.motionState !== agvStateRef.current.motionState) { + return prev; + } + return state; + }); + } + }; + + const draw = () => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Clear Screen + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // --- Apply Zoom & Pan --- + const t = viewRef.current; + ctx.save(); + ctx.translate(t.x, t.y); + ctx.scale(t.scale, t.scale); + + // --- Draw Infinite Grid --- + const visibleLeft = -t.x / t.scale; + const visibleTop = -t.y / t.scale; + const visibleRight = (canvas.width - t.x) / t.scale; + const visibleBottom = (canvas.height - t.y) / t.scale; + + const startX = Math.floor(visibleLeft / GRID_SIZE) * GRID_SIZE; + const endX = Math.floor(visibleRight / GRID_SIZE) * GRID_SIZE + GRID_SIZE; + const startY = Math.floor(visibleTop / GRID_SIZE) * GRID_SIZE; + const endY = Math.floor(visibleBottom / GRID_SIZE) * GRID_SIZE + GRID_SIZE; + + ctx.strokeStyle = '#374151'; + ctx.lineWidth = 1; + ctx.beginPath(); + // Verticals + for (let x = startX; x <= endX; x += GRID_SIZE) { + ctx.moveTo(x, startY); ctx.lineTo(x, endY); + } + // Horizontals + for (let y = startY; y <= endY; y += GRID_SIZE) { + ctx.moveTo(startX, y); ctx.lineTo(endX, y); + } + ctx.stroke(); + + const map = mapDataRef.current; + + // 1. Logical Edges (Thin Gray) + ctx.strokeStyle = '#4B5563'; + ctx.lineWidth = 1; + ctx.setLineDash([2, 2]); + map.edges.forEach(edge => { + const start = map.nodes.find(n => n.id === edge.from); + const end = map.nodes.find(n => n.id === edge.to); + if (start && end) { + ctx.beginPath(); ctx.moveTo(start.x, start.y); ctx.lineTo(end.x, end.y); ctx.stroke(); + } + }); + ctx.setLineDash([]); + + // 2. Magnet Lines (Wide, Transparent Blue) + ctx.lineWidth = MAGNET_WIDTH; + ctx.lineCap = 'round'; + map.magnets.forEach(mag => { + const isSelected = selectedItemIds.has(mag.id); + ctx.strokeStyle = isSelected ? 'rgba(59, 130, 246, 0.8)' : MAGNET_COLOR; // Brighter if selected + + ctx.beginPath(); + ctx.moveTo(mag.p1.x, mag.p1.y); + if (mag.type === 'STRAIGHT') { + ctx.lineTo(mag.p2.x, mag.p2.y); + } else if (mag.type === 'CURVE' && mag.controlPoint) { + ctx.quadraticCurveTo(mag.controlPoint.x, mag.controlPoint.y, mag.p2.x, mag.p2.y); + } + ctx.stroke(); + + // Draw Controls for Selected Curve (Only if Single Selection for Simplicity handles) + if (isSelected && selectedItemIds.size === 1) { + if (mag.type === 'CURVE' && mag.controlPoint) { + // Helper Lines + ctx.strokeStyle = 'rgba(255, 255, 0, 0.5)'; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.beginPath(); + ctx.moveTo(mag.p1.x, mag.p1.y); + ctx.lineTo(mag.controlPoint.x, mag.controlPoint.y); + ctx.lineTo(mag.p2.x, mag.p2.y); + ctx.stroke(); + ctx.setLineDash([]); + + // Control Points + const drawHandle = (p: { x: number, y: number }, color: string) => { + ctx.fillStyle = color; + ctx.beginPath(); ctx.arc(p.x, p.y, 5 / t.scale, 0, Math.PI * 2); ctx.fill(); // Scale handle size + ctx.strokeStyle = '#FFF'; ctx.lineWidth = 1; ctx.stroke(); + }; + drawHandle(mag.p1, '#3B82F6'); // Start (Blue) + drawHandle(mag.p2, '#3B82F6'); // End (Blue) + drawHandle(mag.controlPoint, '#EAB308'); // Control (Yellow) + } else if (mag.type === 'STRAIGHT') { + // Simple handles for straight lines + const drawHandle = (p: { x: number, y: number }, color: string) => { + ctx.fillStyle = color; + ctx.beginPath(); ctx.arc(p.x, p.y, 5 / t.scale, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = '#FFF'; ctx.lineWidth = 1; ctx.stroke(); + }; + drawHandle(mag.p1, '#3B82F6'); + drawHandle(mag.p2, '#3B82F6'); + } + } + }); + + // Nodes (Unchanged) + const images = imageCacheRef.current; + map.nodes.forEach(node => { + if (node.type === NodeType.Image && images[node.id]) { + const img = images[node.id]; + if (img.complete) { + const w = 100; const h = (img.height / img.width) * w; + ctx.drawImage(img, node.x - w / 2, node.y - h / 2, w, h); + } + return; + } + if (node.type === NodeType.Label && node.labelText) { + ctx.font = 'bold 20px Arial'; + const tm = ctx.measureText(node.labelText); + const w = tm.width + 20; const h = 30; + if (node.backColor !== 'Transparent') { ctx.fillStyle = node.backColor!; ctx.fillRect(node.x - w / 2, node.y - h / 2, w, h); } + ctx.fillStyle = node.foreColor || 'White'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(node.labelText, node.x, node.y); + return; + } + let color = '#4B5563'; + if (node.type === NodeType.Loader || node.type === NodeType.UnLoader) color = 'green'; + else if (node.type === NodeType.Charging || node.type === NodeType.ChargerStation) color = 'magenta'; + else if (node.type === NodeType.Buffer) color = 'green'; + else if (node.displayColor) color = node.displayColor; + + // Highlight Selected Node + if (selectedItemIds.has(node.id)) { + ctx.strokeStyle = '#FDE047'; ctx.lineWidth = 2; + ctx.strokeRect(node.x - 10, node.y - 10, 20, 20); + } + + ctx.fillStyle = color; + if (node.type !== NodeType.Normal) ctx.fillRect(node.x - 6, node.y - 6, 12, 12); + else { ctx.beginPath(); ctx.arc(node.x, node.y, 4, 0, Math.PI * 2); ctx.fill(); } + ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; ctx.font = '10px monospace'; ctx.fillStyle = '#9CA3AF'; + const topText = node.rfidId ? `[${node.rfidId}]` : node.id; ctx.fillText(topText, node.x, node.y - 10); + if (node.name) { ctx.font = 'bold 10px Arial'; ctx.fillStyle = '#FFFFFF'; ctx.fillText(node.name, node.x, node.y + 18); } + }); + + // Marks + ctx.strokeStyle = '#FCD34D'; ctx.lineWidth = 3; + map.marks.forEach(mark => { + const isSelected = selectedItemIds.has(mark.id); + if (isSelected) { + ctx.shadowColor = '#FDE047'; ctx.shadowBlur = 10; + } else { + ctx.shadowBlur = 0; + } + ctx.save(); ctx.translate(mark.x, mark.y); ctx.rotate((mark.rotation * Math.PI) / 180); + ctx.beginPath(); ctx.moveTo(-25, 0); ctx.lineTo(25, 0); ctx.stroke(); ctx.restore(); + ctx.shadowBlur = 0; + + // Draw handles if selected + if (isSelected && selectedItemIds.size === 1) { + + // Re-apply transform for simple drawing in local space + ctx.save(); ctx.translate(mark.x, mark.y); ctx.rotate((mark.rotation * Math.PI) / 180); + + // Handles + const drawHandle = (x: number, y: number) => { + ctx.fillStyle = '#EAB308'; + ctx.beginPath(); ctx.arc(x, y, 4 / t.scale, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = '#FFF'; ctx.lineWidth = 1; ctx.stroke(); + }; + drawHandle(-25, 0); // Start + drawHandle(25, 0); // End + + ctx.restore(); + } + }); + + // Selection Box + if (selectionBox) { + const minX = Math.min(selectionBox.start.x, selectionBox.current.x); + const minY = Math.min(selectionBox.start.y, selectionBox.current.y); + const w = Math.abs(selectionBox.current.x - selectionBox.start.x); + const h = Math.abs(selectionBox.current.y - selectionBox.start.y); + + ctx.fillStyle = 'rgba(59, 130, 246, 0.2)'; + ctx.strokeStyle = '#3B82F6'; + ctx.lineWidth = 1; + ctx.fillRect(minX, minY, w, h); + ctx.strokeRect(minX, minY, w, h); + } + + // Drawing Helpers + // -- Logical Edge (Connection) -- + if (activeTool === ToolType.DRAW_LINE && dragStartPos) { + ctx.strokeStyle = '#9CA3AF'; // Gray + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); // Dashed + ctx.beginPath(); + ctx.moveTo(dragStartPos.x, dragStartPos.y); + const curr = mouseWorldPosRef.current; + ctx.lineTo(curr.x, curr.y); + ctx.stroke(); + ctx.setLineDash([]); + } + + // -- Straight Magnet -- + if (activeTool === ToolType.DRAW_MAGNET_STRAIGHT && dragStartPos) { + // ... (Keep existing drawing logic) + ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.lineWidth = MAGNET_WIDTH; + ctx.beginPath(); + ctx.moveTo(dragStartPos.x, dragStartPos.y); + + const curr = getSnappedPos(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y); + ctx.lineTo(curr.x, curr.y); + ctx.stroke(); + } + if (activeTool === ToolType.DRAW_MAGNET_CURVE && tempMagnet) { + // ... (Keep existing drawing logic - ensuring it's not lost) + const p1 = tempMagnet.p1!; + const p2 = tempMagnet.p2 || getSnappedPos(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y); + + // Visualize Construction + ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.lineWidth = 1; + ctx.setLineDash([2, 2]); + ctx.beginPath(); + ctx.moveTo(p1.x, p1.y); + + let control = tempMagnet.controlPoint; + if (curvePhase === 1) { + control = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }; + ctx.lineTo(p2.x, p2.y); + } else if (curvePhase === 2) { + control = mouseWorldPosRef.current; + ctx.lineTo(control.x, control.y); + ctx.lineTo(p2.x, p2.y); + } + ctx.stroke(); + ctx.setLineDash([]); + + if (control) { + ctx.fillStyle = '#60A5FA'; + ctx.beginPath(); ctx.arc(control.x, control.y, 4, 0, Math.PI * 2); ctx.fill(); + } + + ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)'; + ctx.lineWidth = MAGNET_WIDTH; + ctx.beginPath(); + ctx.moveTo(p1.x, p1.y); + if (control) { + ctx.quadraticCurveTo(control.x, control.y, p2.x, p2.y); + } else { + ctx.lineTo(p2.x, p2.y); + } + ctx.stroke(); + } + + // AGV Drawing (Same as before) + const state = agvStateRef.current; + ctx.save(); ctx.translate(state.x, state.y); ctx.rotate((state.rotation * Math.PI) / 180); + + // Draw LiDAR Scanner if active (Directional: Front or Rear based on Motor Direction) + if (state.lidarEnabled) { + const isFwd = state.runConfig.direction === 'FWD'; + const sensorPos = isFwd ? SENSOR_OFFSET_FRONT : SENSOR_OFFSET_REAR; + + ctx.save(); + // Arc Scanner + ctx.fillStyle = 'rgba(6, 182, 212, 0.2)'; // Cyan transparent + ctx.strokeStyle = 'rgba(6, 182, 212, 0.5)'; + ctx.lineWidth = 1; + ctx.beginPath(); + + const baseAngle = isFwd ? 0 : Math.PI; // 0 for Front, 180 for Rear + + ctx.moveTo(sensorPos.x, sensorPos.y); + ctx.arc(sensorPos.x, sensorPos.y, 60, baseAngle - Math.PI / 6, baseAngle + Math.PI / 6); // +/- 30 degrees + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + + // Scanning line animation + const now = Date.now(); + const scanOffset = Math.sin(now / 200) * (Math.PI / 6); + const currentAngle = baseAngle + scanOffset; + + ctx.beginPath(); + ctx.moveTo(sensorPos.x, sensorPos.y); + ctx.lineTo(sensorPos.x + Math.cos(currentAngle) * 60, sensorPos.y + Math.sin(currentAngle) * 60); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.stroke(); + + ctx.restore(); + } + + ctx.fillStyle = '#1F2937'; ctx.strokeStyle = '#3B82F6'; ctx.lineWidth = 2; + ctx.fillRect(-AGV_WIDTH / 2, -AGV_HEIGHT / 2, AGV_WIDTH, AGV_HEIGHT); ctx.strokeRect(-AGV_WIDTH / 2, -AGV_HEIGHT / 2, AGV_WIDTH, AGV_HEIGHT); + if (state.error) { ctx.fillStyle = '#EF4444'; ctx.beginPath(); ctx.arc(0, 0, 15, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#FFF'; ctx.font = 'bold 8px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('ERR', 0, 0); } + ctx.fillStyle = '#000'; ctx.fillRect(-AGV_WIDTH / 2 - 4, -10, 4, 20); ctx.fillRect(AGV_WIDTH / 2, -10, 4, 20); + ctx.fillStyle = '#10B981'; ctx.fillRect(AGV_HEIGHT / 2 - 10, -AGV_WIDTH / 2 + 5, 10, AGV_WIDTH - 10); + ctx.fillStyle = '#EF4444'; ctx.fillRect(-AGV_HEIGHT / 2 + 5, -AGV_WIDTH / 2 + 10, 15, 20); + ctx.fillStyle = '#FFF'; ctx.font = '10px Arial'; ctx.textBaseline = 'alphabetic'; ctx.fillText(`Z:${state.liftHeight}`, -AGV_HEIGHT / 2 + 12, -AGV_WIDTH / 2 + 25); + + // Draw Status Info Panel (Rear) + ctx.save(); + ctx.translate(-12, 0); // Move towards rear center + + // Panel Background + ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'; + ctx.strokeStyle = '#374151'; + ctx.lineWidth = 1; + ctx.fillRect(-10, -18, 20, 36); + ctx.strokeRect(-10, -18, 20, 36); + + // Speed (Top) + const spd = state.runConfig.speedLevel; + ctx.fillStyle = spd === 'H' ? '#F87171' : spd === 'M' ? '#FACC15' : '#4ADE80'; // Red/Yellow/Green + ctx.font = 'bold 12px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(spd, 0, -8); + + // Divider + ctx.strokeStyle = '#4B5563'; + ctx.beginPath(); ctx.moveTo(-8, 0); ctx.lineTo(8, 0); ctx.stroke(); + + // Branch (Bottom) + ctx.fillStyle = '#FFF'; + const br = state.runConfig.branch; + if (br === 'LEFT') { + ctx.save(); ctx.translate(0, 10); ctx.rotate(-Math.PI / 2); ctx.fillText('➜', 0, 0); ctx.restore(); + } else if (br === 'RIGHT') { + ctx.save(); ctx.translate(0, 10); ctx.rotate(Math.PI / 2); ctx.fillText('➜', 0, 0); ctx.restore(); + } else { + ctx.fillText('➜', 0, 10); + } + ctx.restore(); + + // Draw Magnet Key Icon if ON (Shifted forward to avoid status panel) + if (state.magnetOn) { + ctx.save(); + ctx.translate(15, -6); // Position shifted forward + ctx.fillStyle = '#FACC15'; // Yellow/Gold + ctx.strokeStyle = '#FACC15'; + ctx.lineWidth = 1.5; + + // Key Head (Circle) + ctx.beginPath(); + ctx.arc(0, -3, 3.5, 0, Math.PI * 2); + ctx.stroke(); + // Key Hole + ctx.beginPath(); ctx.arc(0, -3, 1, 0, Math.PI * 2); ctx.fill(); + + // Key Shaft + ctx.beginPath(); + ctx.moveTo(0, 0.5); + ctx.lineTo(0, 8); + + // Key Teeth + ctx.moveTo(0, 4); ctx.lineTo(3, 4); + ctx.moveTo(0, 6.5); ctx.lineTo(3, 6.5); + ctx.stroke(); + ctx.restore(); + } + + // Sensors + ctx.fillStyle = state.sensorLineFront ? '#F87171' : '#4B5563'; ctx.beginPath(); ctx.arc(SENSOR_OFFSET_FRONT.x, SENSOR_OFFSET_FRONT.y, 3, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = state.sensorLineRear ? '#F87171' : '#4B5563'; ctx.beginPath(); ctx.arc(SENSOR_OFFSET_REAR.x, SENSOR_OFFSET_REAR.y, 3, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = state.sensorMark ? '#FFFF00' : '#4B5563'; ctx.beginPath(); ctx.arc(SENSOR_OFFSET_MARK.x, SENSOR_OFFSET_MARK.y, state.sensorMark ? 6 : 4, 0, Math.PI * 2); ctx.fill(); if (state.sensorMark) { ctx.strokeStyle = '#FFF'; ctx.stroke(); } + + if (state.motionState === AgvMotionState.RUNNING) { + const activeOffset = state.runConfig.direction === 'FWD' ? SENSOR_OFFSET_FRONT : SENSOR_OFFSET_REAR; + ctx.strokeStyle = '#00FF00'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(activeOffset.x, activeOffset.y, 6, 0, Math.PI * 2); ctx.stroke(); + } + ctx.strokeStyle = '#FFF'; ctx.beginPath(); ctx.moveTo(-10, 0); ctx.lineTo(10, 0); ctx.lineTo(5, -5); ctx.moveTo(10, 0); ctx.lineTo(5, 5); ctx.stroke(); + ctx.restore(); + + // Restore Transform + ctx.restore(); + + animationFrameId = requestAnimationFrame(draw); + }; + + const interval = setInterval(updatePhysics, 16); + animationFrameId = requestAnimationFrame(draw); + return () => { clearInterval(interval); cancelAnimationFrame(animationFrameId); }; + }, [activeTool, dragStartPos, tempMagnet, curvePhase, selectedItemIds, dimensions]); // Re-draw when dimensions change + + // --- Interaction Handlers --- + + const snapToGrid = (val: number) => Math.round(val / (GRID_SIZE / 2)) * (GRID_SIZE / 2); // Snap to half-grid + + // Intelligent Snapping: Prefer Nodes, else Raw Position + const getSnappedPos = (rawX: number, rawY: number) => { + const map = mapDataRef.current; + const SNAP_DIST = 20; + + // 1. Try snapping to Nodes + let closestNode: MapNode | null = null; + let minDist = SNAP_DIST; + + map.nodes.forEach(node => { + const d = Math.sqrt(Math.pow(node.x - rawX, 2) + Math.pow(node.y - rawY, 2)); + if (d < minDist) { + minDist = d; + closestNode = node; + } + }); + + if (closestNode) { + return { x: closestNode.x, y: closestNode.y }; } - - // AGV - const agvDist = getDistance(worldPos, agvState); - if (agvDist < 30) { - setDragTarget({ type: 'AGV', startMouse: worldPos, initialObjState: { x: agvState.x, y: agvState.y } }); - setSelectedItemId(null); + + // 2. Fallback to Raw (No Grid Snap as per user request) + return { x: rawX, y: rawY }; + }; + + const handleWheel = (e: React.WheelEvent) => { + e.preventDefault(); + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + + const screenX = e.clientX - rect.left; + const screenY = e.clientY - rect.top; + + // Zoom Logic + const zoomIntensity = 0.1; + const delta = e.deltaY < 0 ? 1 : -1; + let newScale = viewRef.current.scale + delta * zoomIntensity; + newScale = Math.min(Math.max(0.2, newScale), 5); // Limit zoom + + // World pos before zoom + const worldX = (screenX - viewRef.current.x) / viewRef.current.scale; + const worldY = (screenY - viewRef.current.y) / viewRef.current.scale; + + // Adjust offset so world pos remains at screen pos + const newX = screenX - worldX * newScale; + const newY = screenY - worldY * newScale; + + viewRef.current = { x: newX, y: newY, scale: newScale }; + }; + + const handleMouseMove = (e: React.MouseEvent) => { + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + const screenX = e.clientX - rect.left; + const screenY = e.clientY - rect.top; + + mouseScreenPosRef.current = { x: screenX, y: screenY }; + const worldPos = screenToWorld(screenX, screenY); + mouseWorldPosRef.current = worldPos; + + // Pan + if (isPanning) { + viewRef.current = { + ...viewRef.current, + x: viewRef.current.x + e.movementX, + y: viewRef.current.y + e.movementY + }; return; } - // Map Objects - const hits = getObjectsAt(x, y); - const movableHits = hits.filter(h => h.type !== 'EDGE'); - if (movableHits.length > 0) { - const hit = movableHits[0]; - setSelectedItemId(hit.id); - let initialObjState = null; - if (hit.type === 'NODE') { - const n = mapData.nodes.find(o => o.id === hit.id); - if (n) initialObjState = { x: n.x, y: n.y }; - } else if (hit.type === 'MARK') { - const m = mapData.marks.find(o => o.id === hit.id); - if (m) initialObjState = { x: m.x, y: m.y }; - } else if (hit.type === 'MAGNET') { - const m = mapData.magnets.find(o => o.id === hit.id); - if (m) initialObjState = JSON.parse(JSON.stringify(m)); - } - if (initialObjState) { - setDragTarget({ type: hit.type as any, id: hit.id, startMouse: worldPos, initialObjState }); - } - } else { - setSelectedItemId(null); + // Box Selection Update + if (selectionBox) { + setSelectionBox(prev => prev ? { ...prev, current: worldPos } : null); + return; } - return; - } - if (activeTool === ToolType.DRAW_MAGNET_CURVE) { - if (curvePhase === 0) { - const start = getSnappedPos(x, y); - setTempMagnet({ id: crypto.randomUUID(), type: 'CURVE', p1: start, p2: start, controlPoint: start }); - setCurvePhase(1); - } else if (curvePhase === 2) { - // Finish curve - if (tempMagnet && tempMagnet.p1 && tempMagnet.p2) { - const finalMagnet: MagnetLine = { - id: tempMagnet.id!, - type: 'CURVE', - p1: tempMagnet.p1, - p2: tempMagnet.p2, - controlPoint: mouseWorldPosRef.current - }; - setMapData(prev => ({ ...prev, magnets: [...prev.magnets, finalMagnet] })); - setTempMagnet(null); - setCurvePhase(0); - } + // Drag Handle (Editing - Only if single selection) + if (dragHandle && selectedItemIds.size === 1) { + const singleId = Array.from(selectedItemIds)[0]; + const snapped = getSnappedPos(worldPos.x, worldPos.y); + setMapData(prev => { + const next = { ...prev }; + if (dragHandle === 'p1' || dragHandle === 'p2' || dragHandle === 'control') { + const magIndex = next.magnets.findIndex(m => m.id === singleId); + if (magIndex !== -1) { + const newMags = [...next.magnets]; + const mag = { ...newMags[magIndex] }; + if (dragHandle === 'p1') mag.p1 = snapped; + else if (dragHandle === 'p2') mag.p2 = snapped; + else if (dragHandle === 'control') mag.controlPoint = worldPos; // No snap for control + newMags[magIndex] = mag; + next.magnets = newMags; + } + } else if (dragHandle === 'mark_p1' || dragHandle === 'mark_p2') { + // Rotate Mark + const markIndex = next.marks.findIndex(m => m.id === singleId); + if (markIndex !== -1) { + const newMarks = [...next.marks]; + const mark = { ...newMarks[markIndex] }; + const dx = worldPos.x - mark.x; + const dy = worldPos.y - mark.y; + let angle = Math.atan2(dy, dx) * (180 / Math.PI); + if (dragHandle === 'mark_p1') angle += 180; + mark.rotation = normalizeAngle(angle); + newMarks[markIndex] = mark; + next.marks = newMarks; + } + } + return next; + }); + return; } - return; - } - if (activeTool === ToolType.DRAW_LINE) { - const hits = getObjectsAt(x, y); - const nodeHit = hits.find(h => h.type === 'NODE'); - if (nodeHit) { - const n = mapData.nodes.find(n => n.id === nodeHit.id); - if (n) { - setDragStartPos({ x: n.x, y: n.y }); - setStartNodeId(n.id); - } - } else { - const snapped = getSnappedPos(x, y); - const newNode: MapNode = { - id: `N${Date.now()}`, - x: snapped.x, y: snapped.y, - type: 0, - name: "", - rfidId: "", - connectedNodes: [] - }; - setMapData(prev => ({ ...prev, nodes: [...prev.nodes, newNode] })); - } - return; - } - - if (activeTool === ToolType.DRAW_MAGNET_STRAIGHT) { - const snapped = getSnappedPos(x, y); - setDragStartPos(snapped); - return; - } + // Drag Object (Moving) + if (dragTarget) { + const dx = worldPos.x - dragTarget.startMouse.x; + const dy = worldPos.y - dragTarget.startMouse.y; - if (activeTool === ToolType.ADD_MARK) { - const snapped = getSnappedPos(x, y); - const newMark: FloorMark = { - id: crypto.randomUUID(), - x: snapped.x, - y: snapped.y, - rotation: 0 - }; - setMapData(prev => ({ ...prev, marks: [...prev.marks, newMark] })); - return; - } - - if (activeTool === ToolType.ADD_RFID) { - const hits = getObjectsAt(x, y); - const nodeHit = hits.find(h => h.type === 'NODE'); - if (nodeHit) { - const rfid = prompt("Enter RFID ID:", "12345"); - if (rfid) { - setMapData(prev => ({ + if (dragTarget.type === 'AGV' && dragTarget.initialAgvState) { + setAgvState(prev => ({ ...prev, - nodes: prev.nodes.map(n => n.id === nodeHit.id ? { ...n, rfidId: rfid } : n) + x: dragTarget.initialAgvState!.x + dx, + y: dragTarget.initialAgvState!.y + dy })); + } else if (dragTarget.type === 'MULTI' && dragTarget.initialStates) { + const initialStates = dragTarget.initialStates; + setMapData(prev => { + const next = { ...prev }; + + // Update Nodes + next.nodes = prev.nodes.map(n => { + const init = initialStates[n.id]; + if (init) { + return { ...n, x: init.x + dx, y: init.y + dy }; + } + return n; + }); + + // Update Marks + next.marks = prev.marks.map(m => { + const init = initialStates[m.id]; + if (init) { + return { ...m, x: init.x + dx, y: init.y + dy }; + } + return m; + }); + + // Update Magnets + next.magnets = prev.magnets.map(m => { + const init = initialStates[m.id]; + if (init) { + const newM = { ...m }; + newM.p1 = { x: init.p1.x + dx, y: init.p1.y + dy }; + newM.p2 = { x: init.p2.x + dx, y: init.p2.y + dy }; + if (init.controlPoint && newM.controlPoint) { + newM.controlPoint = { x: init.controlPoint.x + dx, y: init.controlPoint.y + dy }; + } + return newM; + } + return m; + }); + + return next; + }); + } + return; + } + }; + + const handleMouseUp = (e: React.MouseEvent) => { + setIsPanning(false); + setDragHandle(null); + setDragTarget(null); + + // Box Selection End + if (selectionBox) { + const minX = Math.min(selectionBox.start.x, selectionBox.current.x); + const maxX = Math.max(selectionBox.start.x, selectionBox.current.x); + const minY = Math.min(selectionBox.start.y, selectionBox.current.y); + const maxY = Math.max(selectionBox.start.y, selectionBox.current.y); + + // Avoid accidental clicks being box selections + if (maxX - minX > 5 || maxY - minY > 5) { + const newSelection = new Set(); + if (e.shiftKey || e.ctrlKey) { + selectedItemIds.forEach(id => newSelection.add(id)); + } + + // Find items in box + mapData.nodes.forEach(n => { + if (n.x >= minX && n.x <= maxX && n.y >= minY && n.y <= maxY) newSelection.add(n.id); + }); + mapData.marks.forEach(m => { + if (m.x >= minX && m.x <= maxX && m.y >= minY && m.y <= maxY) newSelection.add(m.id); + }); + mapData.magnets.forEach(m => { + // Simple center point check for magnets for now + const cx = (m.p1.x + m.p2.x) / 2; + const cy = (m.p1.y + m.p2.y) / 2; + if (cx >= minX && cx <= maxX && cy >= minY && cy <= maxY) newSelection.add(m.id); + }); + + setSelectedItemIds(newSelection); + } + setSelectionBox(null); + } + + // Finish DRAW_LINE + if (activeTool === ToolType.DRAW_LINE && dragStartPos && startNodeId) { + const { x, y } = mouseWorldPosRef.current; + const existingNode = mapData.nodes.find(n => getDistance(n, { x, y }) < 20); + if (existingNode && existingNode.id !== startNodeId) { + const edgeExists = mapData.edges.some(e => + (e.from === startNodeId && e.to === existingNode.id) || + (e.from === existingNode.id && e.to === startNodeId) + ); + if (!edgeExists) { + const newEdge: MapEdge = { + id: crypto.randomUUID(), + from: startNodeId, + to: existingNode.id + }; + setMapData(prev => ({ ...prev, edges: [...prev.edges, newEdge] })); + } } } - } - }; - return ( -
- setIsPanning(false)} - className="block bg-gray-950 outline-none w-full h-full" - /> - - {/* HUD Info */} -
- Pos: {Math.round(agvState.x)}, {Math.round(agvState.y)} | Rot: {Math.round(agvState.rotation)}° - | Zoom: {Math.round(viewRef.current.scale * 100)}% - {activeTool === ToolType.DRAW_MAGNET_CURVE && curvePhase === 1 && Drag to set Endpoint} - {activeTool === ToolType.DRAW_MAGNET_CURVE && curvePhase === 2 && Move mouse to bend curve, Click to finish} -
- Middle Click / Space+Drag to Pan - {selectedItemId && Object Selected (Drag points to edit)} + // Finish DRAW_MAGNET_STRAIGHT + if (activeTool === ToolType.DRAW_MAGNET_STRAIGHT && dragStartPos) { + const p1 = dragStartPos; + const p2 = getSnappedPos(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y); + if (getDistance(p1, p2) > 5) { + const newMag: MagnetLine = { + id: crypto.randomUUID(), + type: 'STRAIGHT', + p1, + p2 + }; + setMapData(prev => ({ ...prev, magnets: [...prev.magnets, newMag] })); + } + } + + + + setDragStartPos(null); + setStartNodeId(null); + }; + + const handleMouseDown = (e: React.MouseEvent) => { + if (contextMenu) { + setContextMenu(null); + return; + } + + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + const screenX = e.clientX - rect.left; + const screenY = e.clientY - rect.top; + + if (e.button === 1 || (e.button === 0 && isSpacePressedRef.current)) { + setIsPanning(true); + return; + } + + const { x, y } = screenToWorld(screenX, screenY); + const worldPos = { x, y }; + + if (activeTool === ToolType.ERASER) { + const hits = getObjectsAt(x, y); + if (hits.length === 1) { + deleteObject(hits[0]); + } else if (hits.length > 1) { + setContextMenu({ x: screenX, y: screenY, items: hits }); + } + return; + } + + if (activeTool === ToolType.ADD_RFID) { + // Smart ID Generation + const ids = mapData.nodes.map(n => parseInt(n.id)).filter(n => !isNaN(n)); + const maxId = ids.length > 0 ? Math.max(...ids) : 0; + const newId = (maxId + 1).toString(); + + const newNode: MapNode = { + id: newId, + x: worldPos.x, + y: worldPos.y, + type: NodeType.Normal, + name: '', + rfidId: '0000', // Default RFID + connectedNodes: [], + + // Defaults + stationType: 0, + speedLimit: 0, + canDocking: false, + dockDirection: 0, + canTurnLeft: false, + canTurnRight: false, + disableCross: false, + isActive: true, + nodeTextForeColor: '#FFFFFF', + nodeTextFontSize: 12 + }; + setMapData(prev => ({ ...prev, nodes: [...prev.nodes, newNode] })); + return; + } + + if (activeTool === ToolType.SELECT) { + // Check handles (Only if SINGLE item selected for now, to avoid complexity) + const worldTolerance = 15 / viewRef.current.scale; + + if (selectedItemIds.size === 1) { + const singleId = Array.from(selectedItemIds)[0]; + const selectedMag = mapData.magnets.find(m => m.id === singleId); + if (selectedMag) { + if (getDistance(worldPos, selectedMag.p1) < worldTolerance) { setDragHandle('p1'); return; } + if (getDistance(worldPos, selectedMag.p2) < worldTolerance) { setDragHandle('p2'); return; } + if (selectedMag.type === 'CURVE' && selectedMag.controlPoint && getDistance(worldPos, selectedMag.controlPoint) < worldTolerance) { setDragHandle('control'); return; } + } + const selectedMark = mapData.marks.find(m => m.id === singleId); + if (selectedMark) { + const rad = (selectedMark.rotation * Math.PI) / 180; + const dx = Math.cos(rad) * 25; const dy = Math.sin(rad) * 25; + const p1 = { x: selectedMark.x - dx, y: selectedMark.y - dy }; + const p2 = { x: selectedMark.x + dx, y: selectedMark.y + dy }; + if (getDistance(worldPos, p1) < worldTolerance) { setDragHandle('mark_p1'); return; } + if (getDistance(worldPos, p2) < worldTolerance) { setDragHandle('mark_p2'); return; } + } + } + + // AGV + const agvDist = getDistance(worldPos, agvState); + if (agvDist < 30) { + setDragTarget({ type: 'AGV', startMouse: worldPos, initialAgvState: { x: agvState.x, y: agvState.y } }); + setSelectedItemIds(new Set()); + return; + } + + // Map Objects + const hits = getObjectsAt(x, y); + const movableHits = hits.filter(h => h.type !== 'EDGE'); + + if (movableHits.length > 0) { + const hit = movableHits[0]; + const isMultiSelect = e.shiftKey || e.ctrlKey; + + let newSelection = new Set(selectedItemIds); + + if (isMultiSelect) { + if (newSelection.has(hit.id)) newSelection.delete(hit.id); + else newSelection.add(hit.id); + } else { + if (!newSelection.has(hit.id)) { + newSelection = new Set([hit.id]); + } + // If already selected, keep selection (to allow drag) + } + setSelectedItemIds(newSelection); + + // Prepare Multi-Drag + const initialStates: Record = {}; + newSelection.forEach(id => { + const n = mapData.nodes.find(o => o.id === id); + if (n) initialStates[id] = { x: n.x, y: n.y }; + const m = mapData.marks.find(o => o.id === id); + if (m) initialStates[id] = { x: m.x, y: m.y }; + const mag = mapData.magnets.find(o => o.id === id); + if (mag) initialStates[id] = JSON.parse(JSON.stringify(mag)); + }); + + setDragTarget({ type: 'MULTI', startMouse: worldPos, initialStates }); + } else { + // Click on empty space + if (!e.shiftKey && !e.ctrlKey) { + setSelectedItemIds(new Set()); + } + // Start Box Selection + setSelectionBox({ start: worldPos, current: worldPos }); + } + return; + } + + if (activeTool === ToolType.DRAW_MAGNET_CURVE) { + if (curvePhase === 0) { + const start = getSnappedPos(x, y); + setTempMagnet({ id: crypto.randomUUID(), type: 'CURVE', p1: start, p2: start, controlPoint: start }); + setCurvePhase(1); + } else if (curvePhase === 1) { + // Set Endpoint + const end = getSnappedPos(x, y); + setTempMagnet(prev => prev ? { ...prev, p2: end } : null); + setCurvePhase(2); + } else if (curvePhase === 2) { + // Finish curve + if (tempMagnet && tempMagnet.p1 && tempMagnet.p2) { + const finalMagnet: MagnetLine = { + id: tempMagnet.id!, + type: 'CURVE', + p1: tempMagnet.p1, + p2: tempMagnet.p2, + controlPoint: mouseWorldPosRef.current + }; + setMapData(prev => ({ ...prev, magnets: [...prev.magnets, finalMagnet] })); + setTempMagnet(null); + setCurvePhase(0); + } + } + return; + } + + if (activeTool === ToolType.DRAW_LINE) { + const hits = getObjectsAt(x, y); + const nodeHit = hits.find(h => h.type === 'NODE'); + if (nodeHit) { + const n = mapData.nodes.find(n => n.id === nodeHit.id); + if (n) { + setDragStartPos({ x: n.x, y: n.y }); + setStartNodeId(n.id); + } + } else { + const snapped = getSnappedPos(x, y); + const newNode: MapNode = { + id: `N${Date.now()}`, + x: snapped.x, y: snapped.y, + type: 0, + name: "", + rfidId: "", + connectedNodes: [] + }; + setMapData(prev => ({ ...prev, nodes: [...prev.nodes, newNode] })); + } + return; + } + + if (activeTool === ToolType.DRAW_MAGNET_STRAIGHT) { + const snapped = getSnappedPos(x, y); + setDragStartPos(snapped); + return; + } + + if (activeTool === ToolType.ADD_MARK) { + const snapped = getSnappedPos(x, y); + const newMark: FloorMark = { + id: crypto.randomUUID(), + x: snapped.x, + y: snapped.y, + rotation: 0 + }; + setMapData(prev => ({ ...prev, marks: [...prev.marks, newMark] })); + return; + } + + + }; + + return ( +
+ setIsPanning(false)} + className="block bg-gray-950 outline-none w-full h-full" + /> + + {/* HUD Info */} +
+ Pos: {Math.round(agvState.x)}, {Math.round(agvState.y)} | Rot: {Math.round(agvState.rotation)}° + | Zoom: {Math.round(viewRef.current.scale * 100)}% + {activeTool === ToolType.DRAW_MAGNET_CURVE && curvePhase === 1 && Drag to set Endpoint} + {activeTool === ToolType.DRAW_MAGNET_CURVE && curvePhase === 2 && Move mouse to bend curve, Click to finish} +
+ Middle Click / Space+Drag to Pan + {selectedItemIds.size > 0 && {selectedItemIds.size} Object(s) Selected} +
+
+ + {/* Context Menu for Eraser */} + {contextMenu && ( +
+
+ Select object to delete + +
+
    + {contextMenu.items.map((item, idx) => ( +
  • deleteObject(item)} + className="px-2 py-1.5 hover:bg-red-900/50 hover:text-red-200 cursor-pointer flex items-center gap-2" + > + + {item.name} + {Math.round(item.dist)}px +
  • + ))} +
+
+ )}
-
- - {/* Context Menu for Eraser */} - {contextMenu && ( -
-
- Select object to delete - -
-
    - {contextMenu.items.map((item, idx) => ( -
  • deleteObject(item)} - className="px-2 py-1.5 hover:bg-red-900/50 hover:text-red-200 cursor-pointer flex items-center gap-2" - > - - {item.name} - {Math.round(item.dist)}px -
  • - ))} -
-
- )} -
- ); + ); }; export default SimulationCanvas; \ No newline at end of file diff --git a/services/serialService.ts b/services/serialService.ts index b8e4bf2..35bd407 100644 --- a/services/serialService.ts +++ b/services/serialService.ts @@ -45,9 +45,9 @@ export class SerialPortHandler { if (this.port.readable) { this.reader = this.port.readable.getReader(); } - + if (this.port.writable) { - this.writer = this.port.writable.getWriter(); + this.writer = this.port.writable.getWriter(); } this.isConnected = true; @@ -86,19 +86,24 @@ export class SerialPortHandler { } else { payload = data; } - await this.writer.write(payload); + try { + await this.writer.write(payload); + } catch (e) { + console.error("Serial Send Error", e); + throw e; // Re-throw to let caller know if needed, or handle gracefull + } } } getPortInfo(): string | null { - if (this.port) { - const info = this.port.getInfo(); - if (info.usbVendorId && info.usbProductId) { - return `ID:${info.usbVendorId.toString(16).padStart(4,'0').toUpperCase()}:${info.usbProductId.toString(16).padStart(4,'0').toUpperCase()}`; - } - return "USB Device"; + if (this.port) { + const info = this.port.getInfo(); + if (info.usbVendorId && info.usbProductId) { + return `ID:${info.usbVendorId.toString(16).padStart(4, '0').toUpperCase()}:${info.usbProductId.toString(16).padStart(4, '0').toUpperCase()}`; } - return null; + return "USB Device"; + } + return null; } private async readLoop() { diff --git a/types.ts b/types.ts index add5abc..c717b59 100644 --- a/types.ts +++ b/types.ts @@ -44,6 +44,18 @@ export interface MapNode extends Point { // New props preservation fontSize?: number; + + // Extended Properties (from C# Model) + stationType?: number; + speedLimit?: number; + canDocking?: boolean; + dockDirection?: number; + canTurnLeft?: boolean; + canTurnRight?: boolean; + disableCross?: boolean; + isActive?: boolean; + nodeTextForeColor?: string; + nodeTextFontSize?: number; } export interface MapEdge {