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>
622 lines
20 KiB
C#
622 lines
20 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Drawing;
|
|
using System.Linq;
|
|
using System.Windows.Forms;
|
|
using AGVMapEditor.Models;
|
|
using AGVNavigationCore.Models;
|
|
using AGVNavigationCore.PathFinding;
|
|
using AGVSimulator.Models;
|
|
|
|
namespace AGVSimulator.Controls
|
|
{
|
|
/// <summary>
|
|
/// AGV 시뮬레이션 시각화 캔버스
|
|
/// </summary>
|
|
public partial class SimulatorCanvas : UserControl
|
|
{
|
|
#region Fields
|
|
|
|
private List<MapNode> _mapNodes;
|
|
private List<VirtualAGV> _agvList;
|
|
private PathResult _currentPath;
|
|
|
|
// 그래픽 설정
|
|
private float _zoom = 1.0f;
|
|
private Point _panOffset = Point.Empty;
|
|
private bool _isPanning = false;
|
|
private Point _lastMousePos = Point.Empty;
|
|
|
|
// 색상 설정
|
|
private readonly Brush _normalNodeBrush = Brushes.LightBlue;
|
|
private readonly Brush _rotationNodeBrush = Brushes.Yellow;
|
|
private readonly Brush _dockingNodeBrush = Brushes.Orange;
|
|
private readonly Brush _chargingNodeBrush = Brushes.Green;
|
|
private readonly Brush _agvBrush = Brushes.Red;
|
|
private readonly Brush _pathBrush = Brushes.Purple;
|
|
|
|
private readonly Pen _connectionPen = new Pen(Color.Gray, 2);
|
|
private readonly Pen _pathPen = new Pen(Color.Purple, 3);
|
|
private readonly Pen _agvPen = new Pen(Color.Red, 3);
|
|
|
|
// 크기 설정
|
|
private const int NODE_SIZE = 20;
|
|
private const int AGV_SIZE = 30;
|
|
private const int CONNECTION_ARROW_SIZE = 8;
|
|
|
|
#endregion
|
|
|
|
#region Properties
|
|
|
|
/// <summary>
|
|
/// 맵 노드 목록
|
|
/// </summary>
|
|
public List<MapNode> MapNodes
|
|
{
|
|
get => _mapNodes;
|
|
set
|
|
{
|
|
_mapNodes = value;
|
|
Invalidate();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// AGV 목록
|
|
/// </summary>
|
|
public List<VirtualAGV> AGVList
|
|
{
|
|
get => _agvList;
|
|
set
|
|
{
|
|
_agvList = value;
|
|
Invalidate();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 현재 경로
|
|
/// </summary>
|
|
public PathResult CurrentPath
|
|
{
|
|
get => _currentPath;
|
|
set
|
|
{
|
|
_currentPath = value;
|
|
Invalidate();
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Constructor
|
|
|
|
public SimulatorCanvas()
|
|
{
|
|
InitializeComponent();
|
|
InitializeCanvas();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Initialization
|
|
|
|
private void InitializeCanvas()
|
|
{
|
|
_mapNodes = new List<MapNode>();
|
|
_agvList = new List<VirtualAGV>();
|
|
|
|
SetStyle(ControlStyles.AllPaintingInWmPaint |
|
|
ControlStyles.UserPaint |
|
|
ControlStyles.DoubleBuffer |
|
|
ControlStyles.ResizeRedraw, true);
|
|
|
|
BackColor = Color.White;
|
|
|
|
// 마우스 이벤트 연결
|
|
MouseDown += OnMouseDown;
|
|
MouseMove += OnMouseMove;
|
|
MouseUp += OnMouseUp;
|
|
MouseWheel += OnMouseWheel;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Public Methods
|
|
|
|
/// <summary>
|
|
/// AGV 추가
|
|
/// </summary>
|
|
public void AddAGV(VirtualAGV agv)
|
|
{
|
|
if (_agvList == null)
|
|
_agvList = new List<VirtualAGV>();
|
|
|
|
_agvList.Add(agv);
|
|
|
|
// AGV 이벤트 연결
|
|
agv.PositionChanged += OnAGVPositionChanged;
|
|
agv.StateChanged += OnAGVStateChanged;
|
|
|
|
Invalidate();
|
|
}
|
|
|
|
/// <summary>
|
|
/// AGV 제거
|
|
/// </summary>
|
|
public void RemoveAGV(string agvId)
|
|
{
|
|
var agv = _agvList?.FirstOrDefault(a => a.AgvId == agvId);
|
|
if (agv != null)
|
|
{
|
|
// 이벤트 연결 해제
|
|
agv.PositionChanged -= OnAGVPositionChanged;
|
|
agv.StateChanged -= OnAGVStateChanged;
|
|
|
|
_agvList.Remove(agv);
|
|
Invalidate();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 모든 AGV 제거
|
|
/// </summary>
|
|
public void ClearAGVs()
|
|
{
|
|
if (_agvList != null)
|
|
{
|
|
foreach (var agv in _agvList)
|
|
{
|
|
agv.PositionChanged -= OnAGVPositionChanged;
|
|
agv.StateChanged -= OnAGVStateChanged;
|
|
}
|
|
_agvList.Clear();
|
|
Invalidate();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 확대/축소 초기화
|
|
/// </summary>
|
|
public void ResetZoom()
|
|
{
|
|
_zoom = 1.0f;
|
|
_panOffset = Point.Empty;
|
|
Invalidate();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 맵 전체 맞춤
|
|
/// </summary>
|
|
public void FitToMap()
|
|
{
|
|
if (_mapNodes == null || _mapNodes.Count == 0)
|
|
return;
|
|
|
|
var minX = _mapNodes.Min(n => n.Position.X);
|
|
var maxX = _mapNodes.Max(n => n.Position.X);
|
|
var minY = _mapNodes.Min(n => n.Position.Y);
|
|
var maxY = _mapNodes.Max(n => n.Position.Y);
|
|
|
|
var mapWidth = maxX - minX + 100; // 여백 추가
|
|
var mapHeight = maxY - minY + 100;
|
|
|
|
var zoomX = (float)Width / mapWidth;
|
|
var zoomY = (float)Height / mapHeight;
|
|
_zoom = Math.Min(zoomX, zoomY) * 0.9f; // 약간의 여백
|
|
|
|
_panOffset = new Point(
|
|
(int)((Width - mapWidth * _zoom) / 2 - minX * _zoom),
|
|
(int)((Height - mapHeight * _zoom) / 2 - minY * _zoom)
|
|
);
|
|
|
|
Invalidate();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Event Handlers
|
|
|
|
private void OnAGVPositionChanged(object sender, Point newPosition)
|
|
{
|
|
Invalidate(); // AGV 위치 변경시 화면 갱신
|
|
}
|
|
|
|
private void OnAGVStateChanged(object sender, AGVState newState)
|
|
{
|
|
Invalidate(); // AGV 상태 변경시 화면 갱신
|
|
}
|
|
|
|
private void OnMouseDown(object sender, MouseEventArgs e)
|
|
{
|
|
if (e.Button == MouseButtons.Right)
|
|
{
|
|
_isPanning = true;
|
|
_lastMousePos = e.Location;
|
|
Cursor = Cursors.Hand;
|
|
}
|
|
}
|
|
|
|
private void OnMouseMove(object sender, MouseEventArgs e)
|
|
{
|
|
if (_isPanning)
|
|
{
|
|
var deltaX = e.X - _lastMousePos.X;
|
|
var deltaY = e.Y - _lastMousePos.Y;
|
|
|
|
_panOffset = new Point(
|
|
_panOffset.X + deltaX,
|
|
_panOffset.Y + deltaY
|
|
);
|
|
|
|
_lastMousePos = e.Location;
|
|
Invalidate();
|
|
}
|
|
}
|
|
|
|
private void OnMouseUp(object sender, MouseEventArgs e)
|
|
{
|
|
if (e.Button == MouseButtons.Right)
|
|
{
|
|
_isPanning = false;
|
|
Cursor = Cursors.Default;
|
|
}
|
|
}
|
|
|
|
private void OnMouseWheel(object sender, MouseEventArgs e)
|
|
{
|
|
var zoomFactor = e.Delta > 0 ? 1.1f : 0.9f;
|
|
var newZoom = _zoom * zoomFactor;
|
|
|
|
if (newZoom >= 0.1f && newZoom <= 10.0f)
|
|
{
|
|
// 마우스 위치 기준으로 줌
|
|
var mouseX = e.X - _panOffset.X;
|
|
var mouseY = e.Y - _panOffset.Y;
|
|
|
|
_panOffset = new Point(
|
|
(int)(_panOffset.X - mouseX * (zoomFactor - 1)),
|
|
(int)(_panOffset.Y - mouseY * (zoomFactor - 1))
|
|
);
|
|
|
|
_zoom = newZoom;
|
|
Invalidate();
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Painting
|
|
|
|
protected override void OnPaint(PaintEventArgs e)
|
|
{
|
|
base.OnPaint(e);
|
|
|
|
var g = e.Graphics;
|
|
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
|
|
|
|
// 변환 행렬 설정
|
|
g.TranslateTransform(_panOffset.X, _panOffset.Y);
|
|
g.ScaleTransform(_zoom, _zoom);
|
|
|
|
// 배경 그리드 그리기
|
|
DrawGrid(g);
|
|
|
|
// 맵 노드 연결선 그리기
|
|
DrawNodeConnections(g);
|
|
|
|
// 경로 그리기
|
|
if (_currentPath != null && _currentPath.Success)
|
|
{
|
|
DrawPath(g);
|
|
}
|
|
|
|
// 맵 노드 그리기
|
|
DrawMapNodes(g);
|
|
|
|
// AGV 그리기
|
|
DrawAGVs(g);
|
|
|
|
// 정보 표시 (변환 해제)
|
|
g.ResetTransform();
|
|
DrawInfo(g);
|
|
}
|
|
|
|
private void DrawGrid(Graphics g)
|
|
{
|
|
var gridSize = 50;
|
|
var pen = new Pen(Color.LightGray, 1);
|
|
|
|
var startX = -(int)(_panOffset.X / _zoom / gridSize) * gridSize;
|
|
var startY = -(int)(_panOffset.Y / _zoom / gridSize) * gridSize;
|
|
var endX = startX + (int)(Width / _zoom) + gridSize;
|
|
var endY = startY + (int)(Height / _zoom) + gridSize;
|
|
|
|
for (int x = startX; x <= endX; x += gridSize)
|
|
{
|
|
g.DrawLine(pen, x, startY, x, endY);
|
|
}
|
|
|
|
for (int y = startY; y <= endY; y += gridSize)
|
|
{
|
|
g.DrawLine(pen, startX, y, endX, y);
|
|
}
|
|
|
|
pen.Dispose();
|
|
}
|
|
|
|
private void DrawNodeConnections(Graphics g)
|
|
{
|
|
if (_mapNodes == null) return;
|
|
|
|
foreach (var node in _mapNodes)
|
|
{
|
|
if (node.ConnectedNodes != null)
|
|
{
|
|
foreach (var connectedNodeId in node.ConnectedNodes)
|
|
{
|
|
var connectedNode = _mapNodes.FirstOrDefault(n => n.NodeId == connectedNodeId);
|
|
if (connectedNode != null)
|
|
{
|
|
DrawConnection(g, node.Position, connectedNode.Position);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private void DrawConnection(Graphics g, Point from, Point to)
|
|
{
|
|
g.DrawLine(_connectionPen, from, to);
|
|
|
|
// 방향 화살표 그리기
|
|
var angle = Math.Atan2(to.Y - from.Y, to.X - from.X);
|
|
var arrowX = to.X - CONNECTION_ARROW_SIZE * Math.Cos(angle);
|
|
var arrowY = to.Y - CONNECTION_ARROW_SIZE * Math.Sin(angle);
|
|
|
|
var arrowPoint1 = new PointF(
|
|
(float)(arrowX - CONNECTION_ARROW_SIZE * Math.Cos(angle - Math.PI / 6)),
|
|
(float)(arrowY - CONNECTION_ARROW_SIZE * Math.Sin(angle - Math.PI / 6))
|
|
);
|
|
|
|
var arrowPoint2 = new PointF(
|
|
(float)(arrowX - CONNECTION_ARROW_SIZE * Math.Cos(angle + Math.PI / 6)),
|
|
(float)(arrowY - CONNECTION_ARROW_SIZE * Math.Sin(angle + Math.PI / 6))
|
|
);
|
|
|
|
g.DrawLine(_connectionPen, to, arrowPoint1);
|
|
g.DrawLine(_connectionPen, to, arrowPoint2);
|
|
}
|
|
|
|
private void DrawPath(Graphics g)
|
|
{
|
|
if (_currentPath?.Path == null || _currentPath.Path.Count < 2)
|
|
return;
|
|
|
|
for (int i = 0; i < _currentPath.Path.Count - 1; i++)
|
|
{
|
|
var currentNodeId = _currentPath.Path[i];
|
|
var nextNodeId = _currentPath.Path[i + 1];
|
|
|
|
var currentNode = _mapNodes?.FirstOrDefault(n => n.NodeId == currentNodeId);
|
|
var nextNode = _mapNodes?.FirstOrDefault(n => n.NodeId == nextNodeId);
|
|
|
|
if (currentNode != null && nextNode != null)
|
|
{
|
|
g.DrawLine(_pathPen, currentNode.Position, nextNode.Position);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void DrawMapNodes(Graphics g)
|
|
{
|
|
if (_mapNodes == null) return;
|
|
|
|
foreach (var node in _mapNodes)
|
|
{
|
|
DrawMapNode(g, node);
|
|
}
|
|
}
|
|
|
|
private void DrawMapNode(Graphics g, MapNode node)
|
|
{
|
|
var brush = GetNodeBrush(node.Type);
|
|
var rect = new Rectangle(
|
|
node.Position.X - NODE_SIZE / 2,
|
|
node.Position.Y - NODE_SIZE / 2,
|
|
NODE_SIZE,
|
|
NODE_SIZE
|
|
);
|
|
|
|
// 노드 그리기
|
|
if (node.Type == NodeType.Rotation)
|
|
{
|
|
g.FillEllipse(brush, rect); // 회전 노드는 원형
|
|
}
|
|
else
|
|
{
|
|
g.FillRectangle(brush, rect); // 일반 노드는 사각형
|
|
}
|
|
|
|
g.DrawRectangle(Pens.Black, rect);
|
|
|
|
// 노드 ID 표시
|
|
var font = new Font("Arial", 8);
|
|
var textSize = g.MeasureString(node.NodeId, font);
|
|
var textPos = new PointF(
|
|
node.Position.X - textSize.Width / 2,
|
|
node.Position.Y + NODE_SIZE / 2 + 2
|
|
);
|
|
|
|
g.DrawString(node.NodeId, font, Brushes.Black, textPos);
|
|
font.Dispose();
|
|
}
|
|
|
|
private Brush GetNodeBrush(NodeType nodeType)
|
|
{
|
|
switch (nodeType)
|
|
{
|
|
case NodeType.Rotation: return _rotationNodeBrush;
|
|
case NodeType.Docking: return _dockingNodeBrush;
|
|
case NodeType.Charging: return _chargingNodeBrush;
|
|
default: return _normalNodeBrush;
|
|
}
|
|
}
|
|
|
|
private void DrawAGVs(Graphics g)
|
|
{
|
|
if (_agvList == null) return;
|
|
|
|
foreach (var agv in _agvList)
|
|
{
|
|
DrawAGV(g, agv);
|
|
}
|
|
}
|
|
|
|
private void DrawAGV(Graphics g, VirtualAGV agv)
|
|
{
|
|
var position = agv.CurrentPosition;
|
|
var rect = new Rectangle(
|
|
position.X - AGV_SIZE / 2,
|
|
position.Y - AGV_SIZE / 2,
|
|
AGV_SIZE,
|
|
AGV_SIZE
|
|
);
|
|
|
|
// AGV 상태에 따른 색상 변경
|
|
var brush = GetAGVBrush(agv.CurrentState);
|
|
|
|
// AGV 본체 그리기
|
|
g.FillEllipse(brush, rect);
|
|
g.DrawEllipse(_agvPen, rect);
|
|
|
|
// 방향 표시
|
|
DrawAGVDirection(g, position, agv.CurrentDirection);
|
|
|
|
// AGV ID 표시
|
|
var font = new Font("Arial", 10, FontStyle.Bold);
|
|
var textSize = g.MeasureString(agv.AgvId, font);
|
|
var textPos = new PointF(
|
|
position.X - textSize.Width / 2,
|
|
position.Y + AGV_SIZE / 2 + 5
|
|
);
|
|
|
|
g.DrawString(agv.AgvId, font, Brushes.Black, textPos);
|
|
font.Dispose();
|
|
}
|
|
|
|
private Brush GetAGVBrush(AGVState state)
|
|
{
|
|
switch (state)
|
|
{
|
|
case AGVState.Moving: return Brushes.Blue;
|
|
case AGVState.Rotating: return Brushes.Yellow;
|
|
case AGVState.Docking: return Brushes.Orange;
|
|
case AGVState.Charging: return Brushes.Green;
|
|
case AGVState.Error: return Brushes.Red;
|
|
default: return Brushes.Gray; // Idle
|
|
}
|
|
}
|
|
|
|
private void DrawAGVDirection(Graphics g, Point position, AgvDirection direction)
|
|
{
|
|
var arrowSize = 10;
|
|
var pen = new Pen(Color.White, 2);
|
|
|
|
switch (direction)
|
|
{
|
|
case AgvDirection.Forward:
|
|
// 위쪽 화살표
|
|
g.DrawLine(pen, position.X, position.Y - arrowSize, position.X, position.Y + arrowSize);
|
|
g.DrawLine(pen, position.X, position.Y - arrowSize, position.X - 5, position.Y - arrowSize + 5);
|
|
g.DrawLine(pen, position.X, position.Y - arrowSize, position.X + 5, position.Y - arrowSize + 5);
|
|
break;
|
|
|
|
case AgvDirection.Backward:
|
|
// 아래쪽 화살표
|
|
g.DrawLine(pen, position.X, position.Y - arrowSize, position.X, position.Y + arrowSize);
|
|
g.DrawLine(pen, position.X, position.Y + arrowSize, position.X - 5, position.Y + arrowSize - 5);
|
|
g.DrawLine(pen, position.X, position.Y + arrowSize, position.X + 5, position.Y + arrowSize - 5);
|
|
break;
|
|
|
|
case AgvDirection.Left:
|
|
// 왼쪽 화살표
|
|
g.DrawLine(pen, position.X - arrowSize, position.Y, position.X + arrowSize, position.Y);
|
|
g.DrawLine(pen, position.X - arrowSize, position.Y, position.X - arrowSize + 5, position.Y - 5);
|
|
g.DrawLine(pen, position.X - arrowSize, position.Y, position.X - arrowSize + 5, position.Y + 5);
|
|
break;
|
|
|
|
case AgvDirection.Right:
|
|
// 오른쪽 화살표
|
|
g.DrawLine(pen, position.X - arrowSize, position.Y, position.X + arrowSize, position.Y);
|
|
g.DrawLine(pen, position.X + arrowSize, position.Y, position.X + arrowSize - 5, position.Y - 5);
|
|
g.DrawLine(pen, position.X + arrowSize, position.Y, position.X + arrowSize - 5, position.Y + 5);
|
|
break;
|
|
}
|
|
|
|
pen.Dispose();
|
|
}
|
|
|
|
private void DrawInfo(Graphics g)
|
|
{
|
|
var font = new Font("Arial", 10);
|
|
var brush = Brushes.Black;
|
|
var y = 10;
|
|
|
|
// 줌 레벨 표시
|
|
g.DrawString($"줌: {_zoom:P0}", font, brush, new PointF(10, y));
|
|
y += 20;
|
|
|
|
// AGV 정보 표시
|
|
if (_agvList != null)
|
|
{
|
|
g.DrawString($"AGV 수: {_agvList.Count}", font, brush, new PointF(10, y));
|
|
y += 20;
|
|
|
|
foreach (var agv in _agvList)
|
|
{
|
|
var info = $"{agv.AgvId}: {agv.CurrentState} ({agv.CurrentPosition.X},{agv.CurrentPosition.Y})";
|
|
g.DrawString(info, font, brush, new PointF(10, y));
|
|
y += 15;
|
|
}
|
|
}
|
|
|
|
// 경로 정보 표시
|
|
if (_currentPath != null && _currentPath.Success)
|
|
{
|
|
y += 10;
|
|
g.DrawString($"경로: {_currentPath.Path.Count}개 노드", font, brush, new PointF(10, y));
|
|
y += 15;
|
|
g.DrawString($"거리: {_currentPath.TotalDistance:F1}", font, brush, new PointF(10, y));
|
|
y += 15;
|
|
g.DrawString($"계산시간: {_currentPath.CalculationTimeMs}ms", font, brush, new PointF(10, y));
|
|
}
|
|
|
|
font.Dispose();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Cleanup
|
|
|
|
private void CleanupResources()
|
|
{
|
|
// AGV 이벤트 연결 해제
|
|
if (_agvList != null)
|
|
{
|
|
foreach (var agv in _agvList)
|
|
{
|
|
agv.PositionChanged -= OnAGVPositionChanged;
|
|
agv.StateChanged -= OnAGVStateChanged;
|
|
}
|
|
}
|
|
|
|
// 리소스 정리
|
|
_connectionPen?.Dispose();
|
|
_pathPen?.Dispose();
|
|
_agvPen?.Dispose();
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
} |