Major improvements to AGV navigation system: • Consolidated RFID management into MapNode, removing duplicate RfidMapping class • Enhanced MapNode with RFID metadata fields (RfidStatus, RfidDescription) • Added automatic bidirectional connection generation in pathfinding algorithms • Updated all components to use unified MapNode-based RFID system • Added command line argument support for AGVMapEditor auto-loading files • Fixed pathfinding failures by ensuring proper node connectivity Technical changes: - Removed RfidMapping class and dependencies across all projects - Updated AStarPathfinder with EnsureBidirectionalConnections() method - Modified MapLoader to use AssignAutoRfidIds() for RFID automation - Enhanced UnifiedAGVCanvas, SimulatorForm, and MainForm for MapNode integration - Improved data consistency and reduced memory footprint 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
605 lines
19 KiB
C#
605 lines
19 KiB
C#
using System;
|
|
using System.Drawing;
|
|
using System.Linq;
|
|
using System.Windows.Forms;
|
|
using AGVNavigationCore.Models;
|
|
|
|
namespace AGVNavigationCore.Controls
|
|
{
|
|
public partial class UnifiedAGVCanvas
|
|
{
|
|
#region Mouse Events
|
|
|
|
private void UnifiedAGVCanvas_MouseClick(object sender, MouseEventArgs e)
|
|
{
|
|
Focus(); // 포커스 설정
|
|
|
|
if (_canvasMode == CanvasMode.ViewOnly) return;
|
|
|
|
var worldPoint = ScreenToWorld(e.Location);
|
|
var hitNode = GetNodeAt(worldPoint);
|
|
|
|
switch (_editMode)
|
|
{
|
|
case EditMode.Select:
|
|
HandleSelectClick(hitNode);
|
|
break;
|
|
|
|
case EditMode.AddNode:
|
|
HandleAddNodeClick(worldPoint);
|
|
break;
|
|
|
|
case EditMode.Connect:
|
|
HandleConnectClick(hitNode);
|
|
break;
|
|
|
|
case EditMode.Delete:
|
|
HandleDeleteClick(hitNode);
|
|
break;
|
|
}
|
|
}
|
|
|
|
private void UnifiedAGVCanvas_MouseDoubleClick(object sender, MouseEventArgs e)
|
|
{
|
|
if (_canvasMode == CanvasMode.ViewOnly) return;
|
|
|
|
var worldPoint = ScreenToWorld(e.Location);
|
|
var hitNode = GetNodeAt(worldPoint);
|
|
|
|
if (hitNode != null)
|
|
{
|
|
// 노드 속성 편집 (이벤트 발생)
|
|
NodeSelected?.Invoke(this, hitNode);
|
|
}
|
|
}
|
|
|
|
private void UnifiedAGVCanvas_MouseDown(object sender, MouseEventArgs e)
|
|
{
|
|
var worldPoint = ScreenToWorld(e.Location);
|
|
|
|
if (e.Button == MouseButtons.Left)
|
|
{
|
|
if (_canvasMode == CanvasMode.Edit && _editMode == EditMode.Move)
|
|
{
|
|
var hitNode = GetNodeAt(worldPoint);
|
|
if (hitNode != null)
|
|
{
|
|
_isDragging = true;
|
|
_selectedNode = hitNode;
|
|
_dragOffset = new Point(
|
|
worldPoint.X - hitNode.Position.X,
|
|
worldPoint.Y - hitNode.Position.Y
|
|
);
|
|
Cursor = Cursors.SizeAll;
|
|
Invalidate();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 팬 시작 (우클릭 또는 중간버튼)
|
|
_isPanning = true;
|
|
_lastMousePosition = e.Location;
|
|
Cursor = Cursors.SizeAll;
|
|
}
|
|
else if (e.Button == MouseButtons.Right)
|
|
{
|
|
// 컨텍스트 메뉴 (편집 모드에서만)
|
|
if (_canvasMode == CanvasMode.Edit)
|
|
{
|
|
var hitNode = GetNodeAt(worldPoint);
|
|
ShowContextMenu(e.Location, hitNode);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void UnifiedAGVCanvas_MouseMove(object sender, MouseEventArgs e)
|
|
{
|
|
var worldPoint = ScreenToWorld(e.Location);
|
|
|
|
// 호버 노드 업데이트
|
|
var newHoveredNode = GetNodeAt(worldPoint);
|
|
if (newHoveredNode != _hoveredNode)
|
|
{
|
|
_hoveredNode = newHoveredNode;
|
|
Invalidate();
|
|
}
|
|
|
|
if (_isPanning)
|
|
{
|
|
// 팬 처리
|
|
var deltaX = e.X - _lastMousePosition.X;
|
|
var deltaY = e.Y - _lastMousePosition.Y;
|
|
|
|
_panOffset.X += deltaX;
|
|
_panOffset.Y += deltaY;
|
|
|
|
_lastMousePosition = e.Location;
|
|
Invalidate();
|
|
}
|
|
else if (_isDragging && _canvasMode == CanvasMode.Edit)
|
|
{
|
|
// 노드 드래그
|
|
if (_selectedNode != null)
|
|
{
|
|
var newPosition = new Point(
|
|
worldPoint.X - _dragOffset.X,
|
|
worldPoint.Y - _dragOffset.Y
|
|
);
|
|
|
|
// 그리드 스냅
|
|
if (ModifierKeys.HasFlag(Keys.Control))
|
|
{
|
|
newPosition.X = (newPosition.X / GRID_SIZE) * GRID_SIZE;
|
|
newPosition.Y = (newPosition.Y / GRID_SIZE) * GRID_SIZE;
|
|
}
|
|
|
|
_selectedNode.Position = newPosition;
|
|
NodeMoved?.Invoke(this, _selectedNode);
|
|
MapChanged?.Invoke(this, EventArgs.Empty);
|
|
Invalidate();
|
|
}
|
|
}
|
|
else if (_isConnectionMode && _canvasMode == CanvasMode.Edit)
|
|
{
|
|
// 임시 연결선 업데이트
|
|
_connectionEndPoint = worldPoint;
|
|
Invalidate();
|
|
}
|
|
|
|
// 툴팁 표시 (호버된 노드/AGV 정보)
|
|
UpdateTooltip(worldPoint);
|
|
}
|
|
|
|
private void UnifiedAGVCanvas_MouseUp(object sender, MouseEventArgs e)
|
|
{
|
|
if (e.Button == MouseButtons.Left)
|
|
{
|
|
if (_isDragging && _canvasMode == CanvasMode.Edit)
|
|
{
|
|
_isDragging = false;
|
|
Cursor = GetCursorForMode(_editMode);
|
|
}
|
|
|
|
if (_isPanning)
|
|
{
|
|
_isPanning = false;
|
|
Cursor = Cursors.Default;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void UnifiedAGVCanvas_MouseWheel(object sender, MouseEventArgs e)
|
|
{
|
|
// 줌 처리
|
|
var mouseWorldPoint = ScreenToWorld(e.Location);
|
|
var oldZoom = _zoomFactor;
|
|
|
|
if (e.Delta > 0)
|
|
_zoomFactor = Math.Min(_zoomFactor * 1.2f, 5.0f);
|
|
else
|
|
_zoomFactor = Math.Max(_zoomFactor / 1.2f, 0.1f);
|
|
|
|
// 마우스 위치를 중심으로 줌
|
|
var zoomRatio = _zoomFactor / oldZoom;
|
|
_panOffset.X = (int)(e.X - (e.X - _panOffset.X) * zoomRatio);
|
|
_panOffset.Y = (int)(e.Y - (e.Y - _panOffset.Y) * zoomRatio);
|
|
|
|
Invalidate();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Mouse Helper Methods
|
|
|
|
private Point ScreenToWorld(Point screenPoint)
|
|
{
|
|
return new Point(
|
|
(int)((screenPoint.X - _panOffset.X) / _zoomFactor),
|
|
(int)((screenPoint.Y - _panOffset.Y) / _zoomFactor)
|
|
);
|
|
}
|
|
|
|
private Point WorldToScreen(Point worldPoint)
|
|
{
|
|
return new Point(
|
|
(int)(worldPoint.X * _zoomFactor + _panOffset.X),
|
|
(int)(worldPoint.Y * _zoomFactor + _panOffset.Y)
|
|
);
|
|
}
|
|
|
|
private MapNode GetNodeAt(Point worldPoint)
|
|
{
|
|
if (_nodes == null) return null;
|
|
|
|
// 역순으로 검사하여 위에 그려진 노드부터 확인
|
|
for (int i = _nodes.Count - 1; i >= 0; i--)
|
|
{
|
|
var node = _nodes[i];
|
|
if (IsPointInNode(worldPoint, node))
|
|
return node;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private bool IsPointInNode(Point point, MapNode node)
|
|
{
|
|
switch (node.Type)
|
|
{
|
|
case NodeType.Label:
|
|
return IsPointInLabelNode(point, node);
|
|
case NodeType.Image:
|
|
return IsPointInImageNode(point, node);
|
|
default:
|
|
return IsPointInCircularNode(point, node);
|
|
}
|
|
}
|
|
|
|
private bool IsPointInCircularNode(Point point, MapNode node)
|
|
{
|
|
switch (node.Type)
|
|
{
|
|
case NodeType.Docking:
|
|
return IsPointInPentagon(point, node);
|
|
case NodeType.Charging:
|
|
return IsPointInTriangle(point, node);
|
|
default:
|
|
return IsPointInCircle(point, node);
|
|
}
|
|
}
|
|
|
|
private bool IsPointInCircle(Point point, MapNode node)
|
|
{
|
|
var hitRadius = Math.Max(NODE_RADIUS, 10 / _zoomFactor);
|
|
var distance = Math.Sqrt(
|
|
Math.Pow(node.Position.X - point.X, 2) +
|
|
Math.Pow(node.Position.Y - point.Y, 2)
|
|
);
|
|
return distance <= hitRadius;
|
|
}
|
|
|
|
private bool IsPointInPentagon(Point point, MapNode node)
|
|
{
|
|
var radius = NODE_RADIUS;
|
|
var center = node.Position;
|
|
|
|
// 5각형 꼭짓점 계산
|
|
var points = new Point[5];
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
var angle = (Math.PI * 2 * i / 5) - Math.PI / 2;
|
|
points[i] = new Point(
|
|
(int)(center.X + radius * Math.Cos(angle)),
|
|
(int)(center.Y + radius * Math.Sin(angle))
|
|
);
|
|
}
|
|
|
|
return IsPointInPolygon(point, points);
|
|
}
|
|
|
|
private bool IsPointInTriangle(Point point, MapNode node)
|
|
{
|
|
var radius = NODE_RADIUS;
|
|
var center = node.Position;
|
|
|
|
// 삼각형 꼭짓점 계산
|
|
var points = new Point[3];
|
|
for (int i = 0; i < 3; i++)
|
|
{
|
|
var angle = (Math.PI * 2 * i / 3) - Math.PI / 2;
|
|
points[i] = new Point(
|
|
(int)(center.X + radius * Math.Cos(angle)),
|
|
(int)(center.Y + radius * Math.Sin(angle))
|
|
);
|
|
}
|
|
|
|
return IsPointInPolygon(point, points);
|
|
}
|
|
|
|
private bool IsPointInPolygon(Point point, Point[] polygon)
|
|
{
|
|
// Ray casting 알고리즘 사용
|
|
bool inside = false;
|
|
int j = polygon.Length - 1;
|
|
|
|
for (int i = 0; i < polygon.Length; i++)
|
|
{
|
|
var xi = polygon[i].X;
|
|
var yi = polygon[i].Y;
|
|
var xj = polygon[j].X;
|
|
var yj = polygon[j].Y;
|
|
|
|
if (((yi > point.Y) != (yj > point.Y)) &&
|
|
(point.X < (xj - xi) * (point.Y - yi) / (yj - yi) + xi))
|
|
{
|
|
inside = !inside;
|
|
}
|
|
j = i;
|
|
}
|
|
|
|
return inside;
|
|
}
|
|
|
|
private bool IsPointInLabelNode(Point point, MapNode node)
|
|
{
|
|
var text = string.IsNullOrEmpty(node.LabelText) ? node.NodeId : node.LabelText;
|
|
|
|
// 임시 Graphics로 텍스트 크기 측정
|
|
using (var tempBitmap = new Bitmap(1, 1))
|
|
using (var tempGraphics = Graphics.FromImage(tempBitmap))
|
|
{
|
|
var font = new Font(node.FontFamily, node.FontSize, node.FontStyle);
|
|
var textSize = tempGraphics.MeasureString(text, font);
|
|
|
|
var textRect = new Rectangle(
|
|
(int)(node.Position.X - textSize.Width / 2),
|
|
(int)(node.Position.Y - textSize.Height / 2),
|
|
(int)textSize.Width,
|
|
(int)textSize.Height
|
|
);
|
|
|
|
font.Dispose();
|
|
return textRect.Contains(point);
|
|
}
|
|
}
|
|
|
|
private bool IsPointInImageNode(Point point, MapNode node)
|
|
{
|
|
var displaySize = node.GetDisplaySize();
|
|
if (displaySize.IsEmpty)
|
|
displaySize = new Size(50, 50); // 기본 크기
|
|
|
|
var imageRect = new Rectangle(
|
|
node.Position.X - displaySize.Width / 2,
|
|
node.Position.Y - displaySize.Height / 2,
|
|
displaySize.Width,
|
|
displaySize.Height
|
|
);
|
|
|
|
return imageRect.Contains(point);
|
|
}
|
|
|
|
private IAGV GetAGVAt(Point worldPoint)
|
|
{
|
|
if (_agvList == null) return null;
|
|
|
|
var hitRadius = Math.Max(AGV_SIZE / 2, 15 / _zoomFactor);
|
|
|
|
return _agvList.FirstOrDefault(agv =>
|
|
{
|
|
if (!_agvPositions.ContainsKey(agv.AgvId)) return false;
|
|
|
|
var agvPos = _agvPositions[agv.AgvId];
|
|
var distance = Math.Sqrt(
|
|
Math.Pow(agvPos.X - worldPoint.X, 2) +
|
|
Math.Pow(agvPos.Y - worldPoint.Y, 2)
|
|
);
|
|
return distance <= hitRadius;
|
|
});
|
|
}
|
|
|
|
private void HandleSelectClick(MapNode hitNode)
|
|
{
|
|
if (hitNode != _selectedNode)
|
|
{
|
|
_selectedNode = hitNode;
|
|
NodeSelected?.Invoke(this, hitNode);
|
|
Invalidate();
|
|
}
|
|
}
|
|
|
|
private void HandleAddNodeClick(Point worldPoint)
|
|
{
|
|
// 그리드 스냅
|
|
if (ModifierKeys.HasFlag(Keys.Control))
|
|
{
|
|
worldPoint.X = (worldPoint.X / GRID_SIZE) * GRID_SIZE;
|
|
worldPoint.Y = (worldPoint.Y / GRID_SIZE) * GRID_SIZE;
|
|
}
|
|
|
|
var newNode = new MapNode
|
|
{
|
|
NodeId = $"N{_nodeCounter:D3}",
|
|
Position = worldPoint,
|
|
Type = NodeType.Normal
|
|
};
|
|
|
|
_nodeCounter++;
|
|
_nodes.Add(newNode);
|
|
|
|
NodeAdded?.Invoke(this, newNode);
|
|
MapChanged?.Invoke(this, EventArgs.Empty);
|
|
Invalidate();
|
|
}
|
|
|
|
private void HandleConnectClick(MapNode hitNode)
|
|
{
|
|
if (hitNode == null) return;
|
|
|
|
if (!_isConnectionMode)
|
|
{
|
|
// 연결 시작
|
|
_isConnectionMode = true;
|
|
_connectionStartNode = hitNode;
|
|
_selectedNode = hitNode;
|
|
}
|
|
else
|
|
{
|
|
// 연결 완료
|
|
if (_connectionStartNode != null && _connectionStartNode != hitNode)
|
|
{
|
|
CreateConnection(_connectionStartNode, hitNode);
|
|
}
|
|
CancelConnection();
|
|
}
|
|
|
|
Invalidate();
|
|
}
|
|
|
|
private void HandleDeleteClick(MapNode hitNode)
|
|
{
|
|
if (hitNode == null) return;
|
|
|
|
// 연결된 모든 연결선도 제거
|
|
foreach (var node in _nodes)
|
|
{
|
|
node.RemoveConnection(hitNode.NodeId);
|
|
}
|
|
|
|
_nodes.Remove(hitNode);
|
|
|
|
if (_selectedNode == hitNode)
|
|
_selectedNode = null;
|
|
|
|
NodeDeleted?.Invoke(this, hitNode);
|
|
MapChanged?.Invoke(this, EventArgs.Empty);
|
|
Invalidate();
|
|
}
|
|
|
|
private void CreateConnection(MapNode fromNode, MapNode toNode)
|
|
{
|
|
// 중복 연결 체크
|
|
if (fromNode.ConnectedNodes.Contains(toNode.NodeId))
|
|
return;
|
|
|
|
fromNode.AddConnection(toNode.NodeId);
|
|
MapChanged?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
private float CalculateDistance(Point from, Point to)
|
|
{
|
|
var dx = to.X - from.X;
|
|
var dy = to.Y - from.Y;
|
|
return (float)Math.Sqrt(dx * dx + dy * dy);
|
|
}
|
|
|
|
private void ShowContextMenu(Point location, MapNode hitNode)
|
|
{
|
|
_contextMenu.Items.Clear();
|
|
|
|
if (hitNode != null)
|
|
{
|
|
_contextMenu.Items.Add("노드 속성...", null, (s, e) => NodeSelected?.Invoke(this, hitNode));
|
|
_contextMenu.Items.Add("노드 삭제", null, (s, e) => HandleDeleteClick(hitNode));
|
|
_contextMenu.Items.Add("-");
|
|
}
|
|
|
|
_contextMenu.Items.Add("노드 추가", null, (s, e) =>
|
|
{
|
|
var worldPoint = ScreenToWorld(location);
|
|
HandleAddNodeClick(worldPoint);
|
|
});
|
|
|
|
_contextMenu.Items.Add("전체 맞춤", null, (s, e) => FitToNodes());
|
|
_contextMenu.Items.Add("줌 리셋", null, (s, e) => ResetZoom());
|
|
|
|
_contextMenu.Show(this, location);
|
|
}
|
|
|
|
private void UpdateTooltip(Point worldPoint)
|
|
{
|
|
string tooltipText = "";
|
|
|
|
// 노드 툴팁
|
|
var hitNode = GetNodeAt(worldPoint);
|
|
if (hitNode != null)
|
|
{
|
|
tooltipText = $"노드: {hitNode.NodeId}\n타입: {hitNode.Type}\n위치: ({hitNode.Position.X}, {hitNode.Position.Y})";
|
|
}
|
|
else
|
|
{
|
|
// AGV 툴팁
|
|
var hitAGV = GetAGVAt(worldPoint);
|
|
if (hitAGV != null)
|
|
{
|
|
var state = _agvStates.ContainsKey(hitAGV.AgvId) ? _agvStates[hitAGV.AgvId] : AGVState.Idle;
|
|
tooltipText = $"AGV: {hitAGV.AgvId}\n상태: {state}\n배터리: {hitAGV.BatteryLevel:F1}%\n위치: ({hitAGV.CurrentPosition.X}, {hitAGV.CurrentPosition.Y})";
|
|
}
|
|
}
|
|
|
|
// 툴팁 업데이트 (기존 ToolTip 컨트롤 사용)
|
|
if (!string.IsNullOrEmpty(tooltipText))
|
|
{
|
|
// ToolTip 설정 (필요시 추가 구현)
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region View Control Methods
|
|
|
|
/// <summary>
|
|
/// 모든 노드가 보이도록 뷰 조정
|
|
/// </summary>
|
|
public void FitToNodes()
|
|
{
|
|
if (_nodes == null || _nodes.Count == 0) return;
|
|
|
|
var minX = _nodes.Min(n => n.Position.X);
|
|
var maxX = _nodes.Max(n => n.Position.X);
|
|
var minY = _nodes.Min(n => n.Position.Y);
|
|
var maxY = _nodes.Max(n => n.Position.Y);
|
|
|
|
var margin = 50;
|
|
var contentWidth = maxX - minX + margin * 2;
|
|
var contentHeight = maxY - minY + margin * 2;
|
|
|
|
var zoomX = (float)Width / contentWidth;
|
|
var zoomY = (float)Height / contentHeight;
|
|
_zoomFactor = Math.Min(zoomX, zoomY) * 0.9f;
|
|
|
|
var centerX = (minX + maxX) / 2;
|
|
var centerY = (minY + maxY) / 2;
|
|
|
|
_panOffset.X = (int)(Width / 2 - centerX * _zoomFactor);
|
|
_panOffset.Y = (int)(Height / 2 - centerY * _zoomFactor);
|
|
|
|
Invalidate();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 줌 리셋
|
|
/// </summary>
|
|
public void ResetZoom()
|
|
{
|
|
_zoomFactor = 1.0f;
|
|
_panOffset = Point.Empty;
|
|
Invalidate();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 특정 위치로 이동
|
|
/// </summary>
|
|
public void PanTo(Point worldPoint)
|
|
{
|
|
_panOffset.X = (int)(Width / 2 - worldPoint.X * _zoomFactor);
|
|
_panOffset.Y = (int)(Height / 2 - worldPoint.Y * _zoomFactor);
|
|
Invalidate();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 특정 노드로 이동
|
|
/// </summary>
|
|
public void PanToNode(string nodeId)
|
|
{
|
|
var node = _nodes?.FirstOrDefault(n => n.NodeId == nodeId);
|
|
if (node != null)
|
|
{
|
|
PanTo(node.Position);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 특정 AGV로 이동
|
|
/// </summary>
|
|
public void PanToAGV(string agvId)
|
|
{
|
|
if (_agvPositions.ContainsKey(agvId))
|
|
{
|
|
PanTo(_agvPositions[agvId]);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
} |