- Add AGVMapEditor: Visual map editing with drag-and-drop node placement * RFID mapping separation (physical ID ↔ logical node mapping) * A* pathfinding algorithm with AGV directional constraints * JSON map data persistence with structured format * Interactive map canvas with zoom/pan functionality - Add AGVSimulator: Real-time AGV movement simulation * Virtual AGV with state machine (Idle, Moving, Rotating, Docking, Charging, Error) * Path execution and visualization from calculated routes * Real-time position tracking and battery simulation * Integration with map editor data format - Update solution structure and build configuration - Add comprehensive documentation in CLAUDE.md - Implement AGV-specific constraints (forward/backward docking, rotation limits) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
608 lines
19 KiB
C#
608 lines
19 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Drawing;
|
|
using System.Drawing.Drawing2D;
|
|
using System.Linq;
|
|
using System.Windows.Forms;
|
|
using AGVMapEditor.Models;
|
|
|
|
namespace AGVMapEditor.Controls
|
|
{
|
|
/// <summary>
|
|
/// 맵 편집을 위한 그래픽 캔버스 컨트롤
|
|
/// </summary>
|
|
public partial class MapCanvas : UserControl
|
|
{
|
|
#region Constants
|
|
|
|
private const int NODE_SIZE = 20;
|
|
private const int NODE_RADIUS = NODE_SIZE / 2;
|
|
private const int GRID_SIZE = 20;
|
|
private const float CONNECTION_WIDTH = 2.0f;
|
|
|
|
#endregion
|
|
|
|
#region Fields
|
|
|
|
private List<MapNode> _nodes;
|
|
private MapNode _selectedNode;
|
|
private MapNode _hoveredNode;
|
|
private bool _isDragging;
|
|
private Point _dragOffset;
|
|
private Point _lastMousePosition;
|
|
|
|
// 그리드 및 줌 관련
|
|
private bool _showGrid = true;
|
|
private float _zoomFactor = 1.0f;
|
|
private Point _panOffset = Point.Empty;
|
|
|
|
// 브러쉬 및 펜
|
|
private Brush _normalNodeBrush;
|
|
private Brush _rotationNodeBrush;
|
|
private Brush _dockingNodeBrush;
|
|
private Brush _chargingNodeBrush;
|
|
private Brush _selectedNodeBrush;
|
|
private Brush _hoveredNodeBrush;
|
|
private Pen _connectionPen;
|
|
private Pen _gridPen;
|
|
|
|
#endregion
|
|
|
|
#region Events
|
|
|
|
/// <summary>
|
|
/// 노드가 선택되었을 때 발생하는 이벤트
|
|
/// </summary>
|
|
public event EventHandler<MapNode> NodeSelected;
|
|
|
|
/// <summary>
|
|
/// 노드가 이동되었을 때 발생하는 이벤트
|
|
/// </summary>
|
|
public event EventHandler<MapNode> NodeMoved;
|
|
|
|
/// <summary>
|
|
/// 배경이 클릭되었을 때 발생하는 이벤트
|
|
/// </summary>
|
|
public event EventHandler<Point> BackgroundClicked;
|
|
|
|
#endregion
|
|
|
|
#region Constructor
|
|
|
|
public MapCanvas() : this(new List<MapNode>())
|
|
{
|
|
}
|
|
|
|
public MapCanvas(List<MapNode> nodes)
|
|
{
|
|
InitializeComponent();
|
|
InitializeGraphics();
|
|
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.DoubleBuffer | ControlStyles.ResizeRedraw, true);
|
|
|
|
_nodes = nodes ?? new List<MapNode>();
|
|
|
|
// 이벤트 연결
|
|
this.MouseDown += MapCanvas_MouseDown;
|
|
this.MouseMove += MapCanvas_MouseMove;
|
|
this.MouseUp += MapCanvas_MouseUp;
|
|
this.MouseWheel += MapCanvas_MouseWheel;
|
|
this.KeyDown += MapCanvas_KeyDown;
|
|
|
|
// 포커스를 받을 수 있도록 설정
|
|
this.TabStop = true;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Graphics Initialization
|
|
|
|
private void InitializeGraphics()
|
|
{
|
|
_normalNodeBrush = new SolidBrush(Color.Blue);
|
|
_rotationNodeBrush = new SolidBrush(Color.Orange);
|
|
_dockingNodeBrush = new SolidBrush(Color.Green);
|
|
_chargingNodeBrush = new SolidBrush(Color.Red);
|
|
_selectedNodeBrush = new SolidBrush(Color.Yellow);
|
|
_hoveredNodeBrush = new SolidBrush(Color.LightBlue);
|
|
|
|
_connectionPen = new Pen(Color.Gray, CONNECTION_WIDTH);
|
|
_gridPen = new Pen(Color.LightGray, 1.0f) { DashStyle = DashStyle.Dot };
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Properties
|
|
|
|
/// <summary>
|
|
/// 그리드 표시 여부
|
|
/// </summary>
|
|
public bool ShowGrid
|
|
{
|
|
get { return _showGrid; }
|
|
set
|
|
{
|
|
_showGrid = value;
|
|
Invalidate();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 줌 팩터
|
|
/// </summary>
|
|
public float ZoomFactor
|
|
{
|
|
get { return _zoomFactor; }
|
|
set
|
|
{
|
|
_zoomFactor = Math.Max(0.1f, Math.Min(5.0f, value));
|
|
Invalidate();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 선택된 노드
|
|
/// </summary>
|
|
public MapNode SelectedNode
|
|
{
|
|
get { return _selectedNode; }
|
|
set
|
|
{
|
|
if (_selectedNode != value)
|
|
{
|
|
_selectedNode = value;
|
|
Invalidate();
|
|
}
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Mouse Events
|
|
|
|
private void MapCanvas_MouseDown(object sender, MouseEventArgs e)
|
|
{
|
|
this.Focus(); // 키보드 이벤트를 받기 위해 포커스 설정
|
|
|
|
var worldPoint = ScreenToWorld(e.Location);
|
|
var hitNode = GetNodeAt(worldPoint);
|
|
|
|
if (e.Button == MouseButtons.Left)
|
|
{
|
|
if (hitNode != null)
|
|
{
|
|
// 노드 선택 및 드래그 시작
|
|
SelectNode(hitNode);
|
|
_isDragging = true;
|
|
_dragOffset = new Point(worldPoint.X - hitNode.Position.X, worldPoint.Y - hitNode.Position.Y);
|
|
}
|
|
else
|
|
{
|
|
// 배경 클릭
|
|
SelectNode(null);
|
|
BackgroundClicked?.Invoke(this, worldPoint);
|
|
}
|
|
}
|
|
else if (e.Button == MouseButtons.Right)
|
|
{
|
|
// 우클릭 메뉴 (추후 구현)
|
|
ShowContextMenu(worldPoint, hitNode);
|
|
}
|
|
|
|
_lastMousePosition = e.Location;
|
|
}
|
|
|
|
private void MapCanvas_MouseMove(object sender, MouseEventArgs e)
|
|
{
|
|
var worldPoint = ScreenToWorld(e.Location);
|
|
|
|
if (_isDragging && _selectedNode != null)
|
|
{
|
|
// 노드 드래그
|
|
var newPosition = new Point(worldPoint.X - _dragOffset.X, worldPoint.Y - _dragOffset.Y);
|
|
|
|
// 그리드에 맞춤 (Ctrl 키를 누르지 않은 경우)
|
|
if (!ModifierKeys.HasFlag(Keys.Control))
|
|
{
|
|
newPosition.X = (newPosition.X / GRID_SIZE) * GRID_SIZE;
|
|
newPosition.Y = (newPosition.Y / GRID_SIZE) * GRID_SIZE;
|
|
}
|
|
|
|
_selectedNode.Position = newPosition;
|
|
Invalidate();
|
|
}
|
|
else if (e.Button == MouseButtons.Middle || (e.Button == MouseButtons.Left && ModifierKeys.HasFlag(Keys.Space)))
|
|
{
|
|
// 팬 (화면 이동)
|
|
var deltaX = e.X - _lastMousePosition.X;
|
|
var deltaY = e.Y - _lastMousePosition.Y;
|
|
_panOffset.X += deltaX;
|
|
_panOffset.Y += deltaY;
|
|
Invalidate();
|
|
}
|
|
else
|
|
{
|
|
// 호버 효과
|
|
var hitNode = GetNodeAt(worldPoint);
|
|
if (_hoveredNode != hitNode)
|
|
{
|
|
_hoveredNode = hitNode;
|
|
Invalidate();
|
|
}
|
|
}
|
|
|
|
_lastMousePosition = e.Location;
|
|
}
|
|
|
|
private void MapCanvas_MouseUp(object sender, MouseEventArgs e)
|
|
{
|
|
if (_isDragging && _selectedNode != null)
|
|
{
|
|
NodeMoved?.Invoke(this, _selectedNode);
|
|
}
|
|
|
|
_isDragging = false;
|
|
}
|
|
|
|
private void MapCanvas_MouseWheel(object sender, MouseEventArgs e)
|
|
{
|
|
// 줌
|
|
var delta = e.Delta > 0 ? 1.1f : 0.9f;
|
|
ZoomFactor *= delta;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Keyboard Events
|
|
|
|
private void MapCanvas_KeyDown(object sender, KeyEventArgs e)
|
|
{
|
|
switch (e.KeyCode)
|
|
{
|
|
case Keys.Delete:
|
|
// 선택된 노드 삭제 (메인 폼에서 처리하도록 이벤트 발생)
|
|
if (_selectedNode != null)
|
|
{
|
|
// 삭제 확인 후 처리는 메인 폼에서
|
|
}
|
|
break;
|
|
|
|
case Keys.G:
|
|
// 그리드 토글
|
|
ShowGrid = !ShowGrid;
|
|
break;
|
|
|
|
case Keys.Home:
|
|
// 뷰 리셋
|
|
ZoomFactor = 1.0f;
|
|
_panOffset = Point.Empty;
|
|
Invalidate();
|
|
break;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Coordinate Conversion
|
|
|
|
/// <summary>
|
|
/// 스크린 좌표를 월드 좌표로 변환
|
|
/// </summary>
|
|
private Point ScreenToWorld(Point screenPoint)
|
|
{
|
|
var worldX = (int)((screenPoint.X - _panOffset.X) / _zoomFactor);
|
|
var worldY = (int)((screenPoint.Y - _panOffset.Y) / _zoomFactor);
|
|
return new Point(worldX, worldY);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 월드 좌표를 스크린 좌표로 변환
|
|
/// </summary>
|
|
private Point WorldToScreen(Point worldPoint)
|
|
{
|
|
var screenX = (int)(worldPoint.X * _zoomFactor + _panOffset.X);
|
|
var screenY = (int)(worldPoint.Y * _zoomFactor + _panOffset.Y);
|
|
return new Point(screenX, screenY);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Node Management
|
|
|
|
/// <summary>
|
|
/// 지정된 위치의 노드 검색
|
|
/// </summary>
|
|
private MapNode GetNodeAt(Point worldPoint)
|
|
{
|
|
foreach (var node in _nodes)
|
|
{
|
|
var distance = Math.Sqrt(Math.Pow(worldPoint.X - node.Position.X, 2) + Math.Pow(worldPoint.Y - node.Position.Y, 2));
|
|
if (distance <= NODE_RADIUS)
|
|
{
|
|
return node;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 노드 선택
|
|
/// </summary>
|
|
private void SelectNode(MapNode node)
|
|
{
|
|
if (_selectedNode != node)
|
|
{
|
|
_selectedNode = node;
|
|
NodeSelected?.Invoke(this, node);
|
|
Invalidate();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 우클릭 컨텍스트 메뉴 표시
|
|
/// </summary>
|
|
private void ShowContextMenu(Point worldPoint, MapNode node)
|
|
{
|
|
var contextMenu = new ContextMenuStrip();
|
|
|
|
if (node != null)
|
|
{
|
|
contextMenu.Items.Add("노드 삭제", null, (s, e) => { /* 삭제 처리 */ });
|
|
contextMenu.Items.Add(new ToolStripSeparator());
|
|
contextMenu.Items.Add("일반 노드로 변경", null, (s, e) => ChangeNodeType(node, NodeType.Normal));
|
|
contextMenu.Items.Add("회전 지점으로 변경", null, (s, e) => ChangeNodeType(node, NodeType.Rotation));
|
|
contextMenu.Items.Add("도킹 스테이션으로 변경", null, (s, e) => ChangeNodeType(node, NodeType.Docking));
|
|
contextMenu.Items.Add("충전 스테이션으로 변경", null, (s, e) => ChangeNodeType(node, NodeType.Charging));
|
|
}
|
|
else
|
|
{
|
|
contextMenu.Items.Add("새 노드 추가", null, (s, e) => { /* 노드 추가 처리 */ });
|
|
}
|
|
|
|
var screenPoint = WorldToScreen(worldPoint);
|
|
contextMenu.Show(this, screenPoint);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 노드 타입 변경
|
|
/// </summary>
|
|
private void ChangeNodeType(MapNode node, NodeType newType)
|
|
{
|
|
node.Type = newType;
|
|
node.SetDefaultColorByType(newType);
|
|
Invalidate();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Painting
|
|
|
|
protected override void OnPaint(PaintEventArgs e)
|
|
{
|
|
base.OnPaint(e);
|
|
|
|
var g = e.Graphics;
|
|
g.SmoothingMode = SmoothingMode.AntiAlias;
|
|
|
|
// 변환 매트릭스 적용
|
|
g.ResetTransform();
|
|
g.TranslateTransform(_panOffset.X, _panOffset.Y);
|
|
g.ScaleTransform(_zoomFactor, _zoomFactor);
|
|
|
|
// 배경 그리드 그리기
|
|
if (_showGrid)
|
|
{
|
|
DrawGrid(g);
|
|
}
|
|
|
|
// 연결선 그리기
|
|
DrawConnections(g);
|
|
|
|
// 노드 그리기
|
|
DrawNodes(g);
|
|
|
|
// 선택된 노드의 연결 정보 강조 표시
|
|
if (_selectedNode != null)
|
|
{
|
|
DrawSelectedNodeConnections(g);
|
|
}
|
|
}
|
|
|
|
private void DrawGrid(Graphics g)
|
|
{
|
|
var bounds = GetVisibleWorldBounds();
|
|
|
|
// 수직선
|
|
for (int x = (bounds.Left / GRID_SIZE) * GRID_SIZE; x <= bounds.Right; x += GRID_SIZE)
|
|
{
|
|
g.DrawLine(_gridPen, x, bounds.Top, x, bounds.Bottom);
|
|
}
|
|
|
|
// 수평선
|
|
for (int y = (bounds.Top / GRID_SIZE) * GRID_SIZE; y <= bounds.Bottom; y += GRID_SIZE)
|
|
{
|
|
g.DrawLine(_gridPen, bounds.Left, y, bounds.Right, y);
|
|
}
|
|
}
|
|
|
|
private void DrawConnections(Graphics g)
|
|
{
|
|
foreach (var node in _nodes)
|
|
{
|
|
foreach (var connectedNodeId in node.ConnectedNodes)
|
|
{
|
|
var connectedNode = _nodes.FirstOrDefault(n => n.NodeId == connectedNodeId);
|
|
if (connectedNode != null)
|
|
{
|
|
g.DrawLine(_connectionPen, node.Position, connectedNode.Position);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private void DrawNodes(Graphics g)
|
|
{
|
|
foreach (var node in _nodes)
|
|
{
|
|
DrawNode(g, node);
|
|
}
|
|
}
|
|
|
|
private void DrawNode(Graphics g, MapNode node)
|
|
{
|
|
var brush = GetNodeBrush(node);
|
|
var rect = new Rectangle(
|
|
node.Position.X - NODE_RADIUS,
|
|
node.Position.Y - NODE_RADIUS,
|
|
NODE_SIZE,
|
|
NODE_SIZE);
|
|
|
|
// 노드 그리기
|
|
switch (node.Type)
|
|
{
|
|
case NodeType.Normal:
|
|
g.FillEllipse(brush, rect);
|
|
break;
|
|
case NodeType.Rotation:
|
|
g.FillRectangle(brush, rect);
|
|
break;
|
|
case NodeType.Docking:
|
|
g.FillRectangle(brush, rect);
|
|
// 도킹 방향 표시
|
|
DrawDockingDirection(g, node);
|
|
break;
|
|
case NodeType.Charging:
|
|
g.FillEllipse(brush, rect);
|
|
// 충전 표시 (+)
|
|
DrawChargingSymbol(g, node);
|
|
break;
|
|
}
|
|
|
|
// 노드 테두리
|
|
g.DrawEllipse(Pens.Black, rect);
|
|
|
|
// 노드 이름 표시
|
|
if (_zoomFactor > 0.5f) // 줌이 충분히 큰 경우만 텍스트 표시
|
|
{
|
|
var font = new Font("Arial", 8 * _zoomFactor);
|
|
var textRect = new RectangleF(
|
|
node.Position.X - 30,
|
|
node.Position.Y + NODE_RADIUS + 2,
|
|
60, 15);
|
|
|
|
var format = new StringFormat
|
|
{
|
|
Alignment = StringAlignment.Center,
|
|
LineAlignment = StringAlignment.Near
|
|
};
|
|
|
|
g.DrawString(node.Name, font, Brushes.Black, textRect, format);
|
|
}
|
|
}
|
|
|
|
private Brush GetNodeBrush(MapNode node)
|
|
{
|
|
if (node == _selectedNode)
|
|
return _selectedNodeBrush;
|
|
|
|
if (node == _hoveredNode)
|
|
return _hoveredNodeBrush;
|
|
|
|
switch (node.Type)
|
|
{
|
|
case NodeType.Normal:
|
|
return _normalNodeBrush;
|
|
case NodeType.Rotation:
|
|
return _rotationNodeBrush;
|
|
case NodeType.Docking:
|
|
return _dockingNodeBrush;
|
|
case NodeType.Charging:
|
|
return _chargingNodeBrush;
|
|
default:
|
|
return _normalNodeBrush;
|
|
}
|
|
}
|
|
|
|
private void DrawDockingDirection(Graphics g, MapNode node)
|
|
{
|
|
if (node.DockDirection == null)
|
|
return;
|
|
|
|
var arrowSize = 8;
|
|
var arrowPen = new Pen(Color.White, 2);
|
|
|
|
Point arrowStart, arrowEnd;
|
|
|
|
if (node.DockDirection == DockingDirection.Forward)
|
|
{
|
|
arrowStart = new Point(node.Position.X - arrowSize/2, node.Position.Y);
|
|
arrowEnd = new Point(node.Position.X + arrowSize/2, node.Position.Y);
|
|
}
|
|
else // Backward
|
|
{
|
|
arrowStart = new Point(node.Position.X + arrowSize/2, node.Position.Y);
|
|
arrowEnd = new Point(node.Position.X - arrowSize/2, node.Position.Y);
|
|
}
|
|
|
|
g.DrawLine(arrowPen, arrowStart, arrowEnd);
|
|
|
|
// 화살표 머리
|
|
var headSize = 3;
|
|
var headPoints = new Point[]
|
|
{
|
|
arrowEnd,
|
|
new Point(arrowEnd.X - headSize, arrowEnd.Y - headSize),
|
|
new Point(arrowEnd.X - headSize, arrowEnd.Y + headSize)
|
|
};
|
|
g.FillPolygon(Brushes.White, headPoints);
|
|
}
|
|
|
|
private void DrawChargingSymbol(Graphics g, MapNode node)
|
|
{
|
|
var symbolSize = 6;
|
|
var symbolPen = new Pen(Color.White, 2);
|
|
|
|
// + 모양
|
|
g.DrawLine(symbolPen,
|
|
node.Position.X - symbolSize/2, node.Position.Y,
|
|
node.Position.X + symbolSize/2, node.Position.Y);
|
|
g.DrawLine(symbolPen,
|
|
node.Position.X, node.Position.Y - symbolSize/2,
|
|
node.Position.X, node.Position.Y + symbolSize/2);
|
|
}
|
|
|
|
private void DrawSelectedNodeConnections(Graphics g)
|
|
{
|
|
var highlightPen = new Pen(Color.Yellow, CONNECTION_WIDTH + 2);
|
|
|
|
foreach (var connectedNodeId in _selectedNode.ConnectedNodes)
|
|
{
|
|
var connectedNode = _nodes.FirstOrDefault(n => n.NodeId == connectedNodeId);
|
|
if (connectedNode != null)
|
|
{
|
|
g.DrawLine(highlightPen, _selectedNode.Position, connectedNode.Position);
|
|
}
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Helper Methods
|
|
|
|
/// <summary>
|
|
/// 현재 보이는 월드 영역 계산
|
|
/// </summary>
|
|
private Rectangle GetVisibleWorldBounds()
|
|
{
|
|
var topLeft = ScreenToWorld(new Point(0, 0));
|
|
var bottomRight = ScreenToWorld(new Point(Width, Height));
|
|
|
|
return new Rectangle(
|
|
topLeft.X, topLeft.Y,
|
|
bottomRight.X - topLeft.X,
|
|
bottomRight.Y - topLeft.Y);
|
|
}
|
|
|
|
#endregion
|
|
|
|
}
|
|
} |