refactor: Move AGV development projects to separate AGVLogic folder
- Reorganized AGVMapEditor, AGVNavigationCore, AGVSimulator into AGVLogic folder - Removed deleted project files from root folder tracking - Updated CLAUDE.md with AGVLogic-specific development guidelines - Clean separation of independent project development from main codebase - Projects now ready for independent development and future integration 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
21
Cs_HMI/AGVLogic/AGVNavigationCore/Controls/AGVState.cs
Normal file
21
Cs_HMI/AGVLogic/AGVNavigationCore/Controls/AGVState.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace AGVNavigationCore.Controls
|
||||
{
|
||||
#region Interfaces
|
||||
|
||||
/// <summary>
|
||||
/// AGV 상태 열거형
|
||||
/// </summary>
|
||||
public enum AGVState
|
||||
{
|
||||
Idle, // 대기
|
||||
Moving, // 이동 중
|
||||
Rotating, // 회전 중
|
||||
Docking, // 도킹 중
|
||||
Charging, // 충전 중
|
||||
Error // 오류
|
||||
}
|
||||
|
||||
|
||||
|
||||
#endregion
|
||||
}
|
||||
30
Cs_HMI/AGVLogic/AGVNavigationCore/Controls/IAGV.cs
Normal file
30
Cs_HMI/AGVLogic/AGVNavigationCore/Controls/IAGV.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System.Drawing;
|
||||
using AGVNavigationCore.Models;
|
||||
|
||||
namespace AGVNavigationCore.Controls
|
||||
{
|
||||
#region Interfaces
|
||||
|
||||
/// <summary>
|
||||
/// AGV 인터페이스 (가상/실제 AGV 통합)
|
||||
/// </summary>
|
||||
public interface IAGV
|
||||
{
|
||||
string AgvId { get; }
|
||||
Point CurrentPosition { get; set; }
|
||||
AgvDirection CurrentDirection { get; set; }
|
||||
AGVState CurrentState { get; set; }
|
||||
float BatteryLevel { get; }
|
||||
|
||||
// 이동 경로 정보 추가
|
||||
Point? TargetPosition { get; }
|
||||
string CurrentNodeId { get; }
|
||||
string TargetNodeId { get; }
|
||||
DockingDirection DockingDirection { get; }
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
#endregion
|
||||
}
|
||||
41
Cs_HMI/AGVLogic/AGVNavigationCore/Controls/UnifiedAGVCanvas.Designer.cs
generated
Normal file
41
Cs_HMI/AGVLogic/AGVNavigationCore/Controls/UnifiedAGVCanvas.Designer.cs
generated
Normal file
@@ -0,0 +1,41 @@
|
||||
namespace AGVNavigationCore.Controls
|
||||
{
|
||||
partial class UnifiedAGVCanvas
|
||||
{
|
||||
/// <summary>
|
||||
/// 필수 디자이너 변수입니다.
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
|
||||
#region 구성 요소 디자이너에서 생성한 코드
|
||||
|
||||
/// <summary>
|
||||
/// 디자이너 지원에 필요한 메서드입니다.
|
||||
/// 이 메서드의 내용을 코드 편집기로 수정하지 마세요.
|
||||
/// </summary>
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// UnifiedAGVCanvas
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 12F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.BackColor = System.Drawing.Color.White;
|
||||
this.Name = "UnifiedAGVCanvas";
|
||||
this.Size = new System.Drawing.Size(800, 600);
|
||||
this.Paint += new System.Windows.Forms.PaintEventHandler(this.UnifiedAGVCanvas_Paint);
|
||||
this.MouseClick += new System.Windows.Forms.MouseEventHandler(this.UnifiedAGVCanvas_MouseClick);
|
||||
this.MouseDoubleClick += new System.Windows.Forms.MouseEventHandler(this.UnifiedAGVCanvas_MouseDoubleClick);
|
||||
this.MouseDown += new System.Windows.Forms.MouseEventHandler(this.UnifiedAGVCanvas_MouseDown);
|
||||
this.MouseMove += new System.Windows.Forms.MouseEventHandler(this.UnifiedAGVCanvas_MouseMove);
|
||||
this.MouseUp += new System.Windows.Forms.MouseEventHandler(this.UnifiedAGVCanvas_MouseUp);
|
||||
this.MouseWheel += new System.Windows.Forms.MouseEventHandler(this.UnifiedAGVCanvas_MouseWheel);
|
||||
this.ResumeLayout(false);
|
||||
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,789 @@
|
||||
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(); // 포커스 설정
|
||||
|
||||
var worldPoint = ScreenToWorld(e.Location);
|
||||
var hitNode = GetNodeAt(worldPoint);
|
||||
|
||||
switch (_editMode)
|
||||
{
|
||||
case EditMode.Select:
|
||||
HandleSelectClick(hitNode, worldPoint);
|
||||
break;
|
||||
|
||||
case EditMode.AddNode:
|
||||
HandleAddNodeClick(worldPoint);
|
||||
break;
|
||||
|
||||
case EditMode.Connect:
|
||||
HandleConnectClick(hitNode);
|
||||
break;
|
||||
|
||||
case EditMode.Delete:
|
||||
HandleDeleteClick(hitNode);
|
||||
break;
|
||||
|
||||
case EditMode.DeleteConnection:
|
||||
HandleDeleteConnectionClick(worldPoint);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void UnifiedAGVCanvas_MouseDoubleClick(object sender, MouseEventArgs e)
|
||||
{
|
||||
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 (_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 mouseWorldBefore = ScreenToWorld(e.Location);
|
||||
|
||||
float oldZoom = _zoomFactor;
|
||||
|
||||
// 줌 팩터 계산 (휠 델타 기반) - 더 부드러운 줌
|
||||
if (e.Delta > 0)
|
||||
_zoomFactor = Math.Min(_zoomFactor * 1.15f, 5.0f); // 확대 (더 부드러움)
|
||||
else
|
||||
_zoomFactor = Math.Max(_zoomFactor / 1.15f, 0.1f); // 축소 (더 부드러움)
|
||||
|
||||
// 줌 후 마우스 위치의 월드 좌표
|
||||
var mouseWorldAfter = ScreenToWorld(e.Location);
|
||||
|
||||
// 마우스 위치가 같은 월드 좌표를 가리키도록 팬 오프셋 조정
|
||||
_panOffset.X += (int)((mouseWorldBefore.X - mouseWorldAfter.X) * _zoomFactor);
|
||||
_panOffset.Y += (int)((mouseWorldBefore.Y - mouseWorldAfter.Y) * _zoomFactor);
|
||||
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mouse Helper Methods
|
||||
|
||||
private Point ScreenToWorld(Point screenPoint)
|
||||
{
|
||||
// 변환 행렬 생성 (렌더링과 동일)
|
||||
var transform = new System.Drawing.Drawing2D.Matrix();
|
||||
transform.Scale(_zoomFactor, _zoomFactor);
|
||||
transform.Translate(_panOffset.X, _panOffset.Y);
|
||||
|
||||
// 역변환 행렬로 화면 좌표를 월드 좌표로 변환
|
||||
transform.Invert();
|
||||
var points = new System.Drawing.PointF[] { new System.Drawing.PointF(screenPoint.X, screenPoint.Y) };
|
||||
transform.TransformPoints(points);
|
||||
|
||||
return new Point((int)points[0].X, (int)points[0].Y);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
// 화면에서 최소 20픽셀 정도의 히트 영역을 확보하되, 노드 크기보다 작아지지 않게 함
|
||||
var minHitRadiusInScreen = 20;
|
||||
var hitRadius = Math.Max(NODE_RADIUS, minHitRadiusInScreen / _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)
|
||||
{
|
||||
// 화면에서 최소 20픽셀 정도의 히트 영역을 확보
|
||||
var minHitRadiusInScreen = 20;
|
||||
var radius = Math.Max(NODE_RADIUS, minHitRadiusInScreen / _zoomFactor);
|
||||
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)
|
||||
{
|
||||
// 화면에서 최소 20픽셀 정도의 히트 영역을 확보하되, 노드 크기보다 작아지지 않게 함
|
||||
var minHitRadiusInScreen = 20;
|
||||
var radius = Math.Max(NODE_RADIUS, minHitRadiusInScreen / _zoomFactor);
|
||||
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, Point worldPoint)
|
||||
{
|
||||
if (hitNode != null)
|
||||
{
|
||||
// 노드 선택
|
||||
if (hitNode != _selectedNode)
|
||||
{
|
||||
_selectedNode = hitNode;
|
||||
NodeSelected?.Invoke(this, hitNode);
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 노드가 없으면 연결선 체크
|
||||
var connection = GetConnectionAt(worldPoint);
|
||||
if (connection != null)
|
||||
{
|
||||
// 연결선을 클릭했을 때 삭제 확인
|
||||
var (fromNode, toNode) = connection.Value;
|
||||
string fromDisplay = !string.IsNullOrEmpty(fromNode.RfidId) ? fromNode.RfidId : fromNode.NodeId;
|
||||
string toDisplay = !string.IsNullOrEmpty(toNode.RfidId) ? toNode.RfidId : toNode.NodeId;
|
||||
|
||||
var result = MessageBox.Show(
|
||||
$"연결을 삭제하시겠습니까?\n\n{fromDisplay} ↔ {toDisplay}",
|
||||
"연결 삭제 확인",
|
||||
MessageBoxButtons.YesNo,
|
||||
MessageBoxIcon.Question);
|
||||
|
||||
if (result == DialogResult.Yes)
|
||||
{
|
||||
// 단일 연결 삭제 (어느 방향에 저장되어 있는지 확인 후 삭제)
|
||||
if (fromNode.ConnectedNodes.Contains(toNode.NodeId))
|
||||
{
|
||||
fromNode.RemoveConnection(toNode.NodeId);
|
||||
}
|
||||
else if (toNode.ConnectedNodes.Contains(fromNode.NodeId))
|
||||
{
|
||||
toNode.RemoveConnection(fromNode.NodeId);
|
||||
}
|
||||
|
||||
// 이벤트 발생
|
||||
ConnectionDeleted?.Invoke(this, (fromNode, toNode));
|
||||
MapChanged?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 빈 공간 클릭 시 선택 해제
|
||||
if (_selectedNode != null)
|
||||
{
|
||||
_selectedNode = null;
|
||||
NodeSelected?.Invoke(this, null);
|
||||
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;
|
||||
}
|
||||
|
||||
// 고유한 NodeId 생성
|
||||
string newNodeId = GenerateUniqueNodeId();
|
||||
|
||||
var newNode = new MapNode
|
||||
{
|
||||
NodeId = newNodeId,
|
||||
Position = worldPoint,
|
||||
Type = NodeType.Normal
|
||||
};
|
||||
|
||||
_nodes.Add(newNode);
|
||||
|
||||
NodeAdded?.Invoke(this, newNode);
|
||||
MapChanged?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 중복되지 않는 고유한 NodeId 생성
|
||||
/// </summary>
|
||||
private string GenerateUniqueNodeId()
|
||||
{
|
||||
string nodeId;
|
||||
int counter = _nodeCounter;
|
||||
|
||||
do
|
||||
{
|
||||
nodeId = $"N{counter:D3}";
|
||||
counter++;
|
||||
}
|
||||
while (_nodes.Any(n => n.NodeId == nodeId));
|
||||
|
||||
_nodeCounter = counter;
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
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) ||
|
||||
toNode.ConnectedNodes.Contains(fromNode.NodeId))
|
||||
return;
|
||||
|
||||
// 단일 연결 생성 (사전순으로 정렬하여 일관성 유지)
|
||||
if (string.Compare(fromNode.NodeId, toNode.NodeId, StringComparison.Ordinal) < 0)
|
||||
{
|
||||
fromNode.AddConnection(toNode.NodeId);
|
||||
}
|
||||
else
|
||||
{
|
||||
toNode.AddConnection(fromNode.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 HandleDeleteConnectionClick(Point worldPoint)
|
||||
{
|
||||
// 클릭한 위치 근처의 연결선을 찾아 삭제
|
||||
var connection = GetConnectionAt(worldPoint);
|
||||
if (connection != null)
|
||||
{
|
||||
var (fromNode, toNode) = connection.Value;
|
||||
|
||||
// 단일 연결 삭제 (어느 방향에 저장되어 있는지 확인 후 삭제)
|
||||
if (fromNode.ConnectedNodes.Contains(toNode.NodeId))
|
||||
{
|
||||
fromNode.RemoveConnection(toNode.NodeId);
|
||||
}
|
||||
else if (toNode.ConnectedNodes.Contains(fromNode.NodeId))
|
||||
{
|
||||
toNode.RemoveConnection(fromNode.NodeId);
|
||||
}
|
||||
|
||||
// 이벤트 발생
|
||||
ConnectionDeleted?.Invoke(this, (fromNode, toNode));
|
||||
MapChanged?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
private (MapNode From, MapNode To)? GetConnectionAt(Point worldPoint)
|
||||
{
|
||||
const int CONNECTION_HIT_TOLERANCE = 10;
|
||||
|
||||
// 모든 연결선을 확인하여 클릭한 위치와 가장 가까운 연결선 찾기
|
||||
foreach (var fromNode in _nodes)
|
||||
{
|
||||
foreach (var toNodeId in fromNode.ConnectedNodes)
|
||||
{
|
||||
var toNode = _nodes.FirstOrDefault(n => n.NodeId == toNodeId);
|
||||
if (toNode != null)
|
||||
{
|
||||
// 연결선과 클릭 위치 간의 거리 계산
|
||||
var distance = CalculatePointToLineDistance(worldPoint, fromNode.Position, toNode.Position);
|
||||
if (distance <= CONNECTION_HIT_TOLERANCE / _zoomFactor)
|
||||
{
|
||||
return (fromNode, toNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private float CalculatePointToLineDistance(Point point, Point lineStart, Point lineEnd)
|
||||
{
|
||||
// 점에서 선분까지의 거리 계산
|
||||
var A = point.X - lineStart.X;
|
||||
var B = point.Y - lineStart.Y;
|
||||
var C = lineEnd.X - lineStart.X;
|
||||
var D = lineEnd.Y - lineStart.Y;
|
||||
|
||||
var dot = A * C + B * D;
|
||||
var lenSq = C * C + D * D;
|
||||
|
||||
if (lenSq == 0) return CalculateDistance(point, lineStart);
|
||||
|
||||
var param = dot / lenSq;
|
||||
|
||||
Point xx, yy;
|
||||
if (param < 0)
|
||||
{
|
||||
xx = lineStart;
|
||||
yy = lineStart;
|
||||
}
|
||||
else if (param > 1)
|
||||
{
|
||||
xx = lineEnd;
|
||||
yy = lineEnd;
|
||||
}
|
||||
else
|
||||
{
|
||||
xx = new Point((int)(lineStart.X + param * C), (int)(lineStart.Y + param * D));
|
||||
yy = xx;
|
||||
}
|
||||
|
||||
return CalculateDistance(point, xx);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
691
Cs_HMI/AGVLogic/AGVNavigationCore/Controls/UnifiedAGVCanvas.cs
Normal file
691
Cs_HMI/AGVLogic/AGVNavigationCore/Controls/UnifiedAGVCanvas.cs
Normal file
@@ -0,0 +1,691 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Linq;
|
||||
using System.Windows.Forms;
|
||||
using AGVNavigationCore.Models;
|
||||
using AGVNavigationCore.PathFinding;
|
||||
using AGVNavigationCore.PathFinding.Core;
|
||||
|
||||
namespace AGVNavigationCore.Controls
|
||||
{
|
||||
/// <summary>
|
||||
/// 통합 AGV 캔버스 컨트롤
|
||||
/// 맵 편집, AGV 시뮬레이션, 실시간 모니터링을 모두 지원
|
||||
/// </summary>
|
||||
public partial class UnifiedAGVCanvas : UserControl
|
||||
{
|
||||
#region Constants
|
||||
|
||||
private const int NODE_SIZE = 24;
|
||||
private const int NODE_RADIUS = NODE_SIZE / 2;
|
||||
private const int GRID_SIZE = 20;
|
||||
private const float CONNECTION_WIDTH = 2.0f;
|
||||
private const int SNAP_DISTANCE = 10;
|
||||
private const int AGV_SIZE = 40;
|
||||
private const int CONNECTION_ARROW_SIZE = 8;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Enums
|
||||
|
||||
/// <summary>
|
||||
/// 캔버스 모드
|
||||
/// </summary>
|
||||
public enum CanvasMode
|
||||
{
|
||||
Edit // 편집 가능 (맵 에디터)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 편집 모드 (CanvasMode.Edit일 때만 적용)
|
||||
/// </summary>
|
||||
public enum EditMode
|
||||
{
|
||||
Select, // 선택 모드
|
||||
Move, // 이동 모드
|
||||
AddNode, // 노드 추가 모드
|
||||
Connect, // 연결 모드
|
||||
Delete, // 삭제 모드
|
||||
DeleteConnection, // 연결 삭제 모드
|
||||
AddLabel, // 라벨 추가 모드
|
||||
AddImage, // 이미지 추가 모드
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fields
|
||||
|
||||
// 캔버스 모드
|
||||
private CanvasMode _canvasMode = CanvasMode.Edit;
|
||||
private EditMode _editMode = EditMode.Select;
|
||||
|
||||
// 맵 데이터
|
||||
private List<MapNode> _nodes;
|
||||
private MapNode _selectedNode;
|
||||
private MapNode _hoveredNode;
|
||||
private MapNode _destinationNode;
|
||||
|
||||
// AGV 관련
|
||||
private List<IAGV> _agvList;
|
||||
private Dictionary<string, Point> _agvPositions;
|
||||
private Dictionary<string, AgvDirection> _agvDirections;
|
||||
private Dictionary<string, AGVState> _agvStates;
|
||||
|
||||
// 경로 관련
|
||||
private AGVPathResult _currentPath;
|
||||
private List<AGVPathResult> _allPaths;
|
||||
|
||||
// 도킹 검증 관련
|
||||
private Dictionary<string, bool> _dockingErrors;
|
||||
|
||||
// UI 요소들
|
||||
private Image _companyLogo;
|
||||
private string _companyLogoPath = string.Empty;
|
||||
private string _measurementInfo = "스케일: 1:100\n면적: 1000㎡\n최종 수정: " + DateTime.Now.ToString("yyyy-MM-dd");
|
||||
|
||||
// 편집 관련 (EditMode에서만 사용)
|
||||
private bool _isDragging;
|
||||
private Point _dragOffset;
|
||||
private Point _lastMousePosition;
|
||||
private bool _isConnectionMode;
|
||||
private MapNode _connectionStartNode;
|
||||
private Point _connectionEndPoint;
|
||||
|
||||
// 그리드 및 줌 관련
|
||||
private bool _showGrid = true;
|
||||
private float _zoomFactor = 1.0f;
|
||||
private Point _panOffset = Point.Empty;
|
||||
private bool _isPanning;
|
||||
|
||||
// 자동 증가 카운터
|
||||
private int _nodeCounter = 1;
|
||||
|
||||
// 강조 연결
|
||||
private (string FromNodeId, string ToNodeId)? _highlightedConnection = null;
|
||||
|
||||
// RFID 중복 검사
|
||||
private HashSet<string> _duplicateRfidNodes = new HashSet<string>();
|
||||
|
||||
// 브러쉬 및 펜
|
||||
private Brush _normalNodeBrush;
|
||||
private Brush _rotationNodeBrush;
|
||||
private Brush _dockingNodeBrush;
|
||||
private Brush _chargingNodeBrush;
|
||||
private Brush _selectedNodeBrush;
|
||||
private Brush _hoveredNodeBrush;
|
||||
private Brush _destinationNodeBrush;
|
||||
private Brush _gridBrush;
|
||||
private Brush _agvBrush;
|
||||
private Brush _pathBrush;
|
||||
|
||||
private Pen _connectionPen;
|
||||
private Pen _gridPen;
|
||||
private Pen _tempConnectionPen;
|
||||
private Pen _selectedNodePen;
|
||||
private Pen _destinationNodePen;
|
||||
private Pen _pathPen;
|
||||
private Pen _agvPen;
|
||||
private Pen _highlightedConnectionPen;
|
||||
|
||||
// 컨텍스트 메뉴
|
||||
private ContextMenuStrip _contextMenu;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Events
|
||||
|
||||
// 맵 편집 이벤트
|
||||
public event EventHandler<MapNode> NodeAdded;
|
||||
public event EventHandler<MapNode> NodeSelected;
|
||||
public event EventHandler<MapNode> NodeDeleted;
|
||||
public event EventHandler<MapNode> NodeMoved;
|
||||
public event EventHandler<(MapNode From, MapNode To)> ConnectionDeleted;
|
||||
public event EventHandler MapChanged;
|
||||
|
||||
// AGV 이벤트
|
||||
public event EventHandler<IAGV> AGVSelected;
|
||||
public event EventHandler<IAGV> AGVStateChanged;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// 캔버스 모드
|
||||
/// </summary>
|
||||
public CanvasMode Mode
|
||||
{
|
||||
get => _canvasMode;
|
||||
set
|
||||
{
|
||||
_canvasMode = value;
|
||||
UpdateModeUI();
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 편집 모드 (CanvasMode.Edit일 때만 적용)
|
||||
/// </summary>
|
||||
public EditMode CurrentEditMode
|
||||
{
|
||||
get => _editMode;
|
||||
set
|
||||
{
|
||||
if (_canvasMode != CanvasMode.Edit) return;
|
||||
|
||||
_editMode = value;
|
||||
if (_editMode != EditMode.Connect)
|
||||
{
|
||||
CancelConnection();
|
||||
}
|
||||
Cursor = GetCursorForMode(_editMode);
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 그리드 표시 여부
|
||||
/// </summary>
|
||||
public bool ShowGrid
|
||||
{
|
||||
get => _showGrid;
|
||||
set
|
||||
{
|
||||
_showGrid = value;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 줌 팩터
|
||||
/// </summary>
|
||||
public float ZoomFactor
|
||||
{
|
||||
get => _zoomFactor;
|
||||
set
|
||||
{
|
||||
_zoomFactor = Math.Max(0.1f, Math.Min(5.0f, value));
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 선택된 노드
|
||||
/// </summary>
|
||||
public MapNode SelectedNode => _selectedNode;
|
||||
|
||||
/// <summary>
|
||||
/// 노드 목록
|
||||
/// </summary>
|
||||
public List<MapNode> Nodes
|
||||
{
|
||||
get => _nodes ?? new List<MapNode>();
|
||||
set
|
||||
{
|
||||
_nodes = value ?? new List<MapNode>();
|
||||
|
||||
// 기존 노드들의 최대 번호를 찾아서 _nodeCounter 설정
|
||||
UpdateNodeCounter();
|
||||
|
||||
// RFID 중복값 검사
|
||||
DetectDuplicateRfidNodes();
|
||||
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AGV 목록
|
||||
/// </summary>
|
||||
public List<IAGV> AGVList
|
||||
{
|
||||
get => _agvList ?? new List<IAGV>();
|
||||
set
|
||||
{
|
||||
_agvList = value ?? new List<IAGV>();
|
||||
UpdateAGVData();
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 현재 표시할 경로
|
||||
/// </summary>
|
||||
public AGVPathResult CurrentPath
|
||||
{
|
||||
get => _currentPath;
|
||||
set
|
||||
{
|
||||
_currentPath = value;
|
||||
UpdateDestinationNode();
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 모든 경로 목록 (다중 AGV 경로 표시용)
|
||||
/// </summary>
|
||||
public List<AGVPathResult> AllPaths
|
||||
{
|
||||
get => _allPaths ?? new List<AGVPathResult>();
|
||||
set
|
||||
{
|
||||
_allPaths = value ?? new List<AGVPathResult>();
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 회사 로고 이미지
|
||||
/// </summary>
|
||||
public Image CompanyLogo
|
||||
{
|
||||
get => _companyLogo;
|
||||
set
|
||||
{
|
||||
_companyLogo = value;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 측정 정보 텍스트
|
||||
/// </summary>
|
||||
public string MeasurementInfo
|
||||
{
|
||||
get => _measurementInfo;
|
||||
set
|
||||
{
|
||||
_measurementInfo = value;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Connection Highlighting
|
||||
|
||||
/// <summary>
|
||||
/// 특정 연결을 강조 표시
|
||||
/// </summary>
|
||||
/// <param name="fromNodeId">시작 노드 ID</param>
|
||||
/// <param name="toNodeId">끝 노드 ID</param>
|
||||
public void HighlightConnection(string fromNodeId, string toNodeId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fromNodeId) || string.IsNullOrEmpty(toNodeId))
|
||||
{
|
||||
_highlightedConnection = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 사전순으로 정렬하여 저장 (연결이 단일 방향으로 저장되므로)
|
||||
if (string.Compare(fromNodeId, toNodeId, StringComparison.Ordinal) <= 0)
|
||||
{
|
||||
_highlightedConnection = (fromNodeId, toNodeId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_highlightedConnection = (toNodeId, fromNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 연결 강조 표시 해제
|
||||
/// </summary>
|
||||
public void ClearHighlightedConnection()
|
||||
{
|
||||
_highlightedConnection = null;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
|
||||
public UnifiedAGVCanvas()
|
||||
{
|
||||
InitializeComponent();
|
||||
InitializeCanvas();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Initialization
|
||||
|
||||
private void InitializeCanvas()
|
||||
{
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint |
|
||||
ControlStyles.UserPaint |
|
||||
ControlStyles.DoubleBuffer |
|
||||
ControlStyles.ResizeRedraw, true);
|
||||
|
||||
_nodes = new List<MapNode>();
|
||||
_agvList = new List<IAGV>();
|
||||
_agvPositions = new Dictionary<string, Point>();
|
||||
_agvDirections = new Dictionary<string, AgvDirection>();
|
||||
_agvStates = new Dictionary<string, AGVState>();
|
||||
_allPaths = new List<AGVPathResult>();
|
||||
_dockingErrors = new Dictionary<string, bool>();
|
||||
|
||||
InitializeBrushesAndPens();
|
||||
CreateContextMenu();
|
||||
}
|
||||
|
||||
private void InitializeBrushesAndPens()
|
||||
{
|
||||
// 노드 브러쉬
|
||||
_normalNodeBrush = new SolidBrush(Color.LightBlue);
|
||||
_rotationNodeBrush = new SolidBrush(Color.Yellow);
|
||||
_dockingNodeBrush = new SolidBrush(Color.Orange);
|
||||
_chargingNodeBrush = new SolidBrush(Color.Green);
|
||||
_selectedNodeBrush = new SolidBrush(Color.Red);
|
||||
_hoveredNodeBrush = new SolidBrush(Color.LightCyan);
|
||||
_destinationNodeBrush = new SolidBrush(Color.Gold);
|
||||
|
||||
// AGV 및 경로 브러쉬
|
||||
_agvBrush = new SolidBrush(Color.Red);
|
||||
_pathBrush = new SolidBrush(Color.Purple);
|
||||
|
||||
// 그리드 브러쉬
|
||||
_gridBrush = new SolidBrush(Color.LightGray);
|
||||
|
||||
// 펜
|
||||
_connectionPen = new Pen(Color.DarkBlue, CONNECTION_WIDTH);
|
||||
_connectionPen.EndCap = LineCap.ArrowAnchor;
|
||||
|
||||
_gridPen = new Pen(Color.LightGray, 1);
|
||||
_tempConnectionPen = new Pen(Color.Orange, 2) { DashStyle = DashStyle.Dash };
|
||||
_selectedNodePen = new Pen(Color.Red, 3);
|
||||
_destinationNodePen = new Pen(Color.Orange, 4);
|
||||
_pathPen = new Pen(Color.Purple, 3);
|
||||
_agvPen = new Pen(Color.Red, 3);
|
||||
_highlightedConnectionPen = new Pen(Color.Red, 4) { DashStyle = DashStyle.Solid };
|
||||
}
|
||||
|
||||
private void CreateContextMenu()
|
||||
{
|
||||
_contextMenu = new ContextMenuStrip();
|
||||
// 컨텍스트 메뉴는 EditMode에서만 사용
|
||||
}
|
||||
|
||||
private void UpdateModeUI()
|
||||
{
|
||||
// 모드에 따른 UI 업데이트
|
||||
_contextMenu.Enabled = true;
|
||||
Cursor = GetCursorForMode(_editMode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AGV Management
|
||||
|
||||
/// <summary>
|
||||
/// AGV 위치 업데이트
|
||||
/// </summary>
|
||||
public void UpdateAGVPosition(string agvId, Point position)
|
||||
{
|
||||
if (_agvPositions.ContainsKey(agvId))
|
||||
_agvPositions[agvId] = position;
|
||||
else
|
||||
_agvPositions.Add(agvId, position);
|
||||
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AGV 방향 업데이트
|
||||
/// </summary>
|
||||
public void UpdateAGVDirection(string agvId, AgvDirection direction)
|
||||
{
|
||||
if (_agvDirections.ContainsKey(agvId))
|
||||
_agvDirections[agvId] = direction;
|
||||
else
|
||||
_agvDirections.Add(agvId, direction);
|
||||
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AGV 상태 업데이트
|
||||
/// </summary>
|
||||
public void UpdateAGVState(string agvId, AGVState state)
|
||||
{
|
||||
if (_agvStates.ContainsKey(agvId))
|
||||
_agvStates[agvId] = state;
|
||||
else
|
||||
_agvStates.Add(agvId, state);
|
||||
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AGV 위치 설정 (시뮬레이터용)
|
||||
/// </summary>
|
||||
/// <param name="agvId">AGV ID</param>
|
||||
/// <param name="position">새로운 위치</param>
|
||||
public void SetAGVPosition(string agvId, Point position)
|
||||
{
|
||||
UpdateAGVPosition(agvId, position);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AGV 데이터 동기화
|
||||
/// </summary>
|
||||
private void UpdateAGVData()
|
||||
{
|
||||
if (_agvList == null) return;
|
||||
|
||||
foreach (var agv in _agvList)
|
||||
{
|
||||
UpdateAGVPosition(agv.AgvId, agv.CurrentPosition);
|
||||
UpdateAGVDirection(agv.AgvId, agv.CurrentDirection);
|
||||
UpdateAGVState(agv.AgvId, agv.CurrentState);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private Cursor GetCursorForMode(EditMode mode)
|
||||
{
|
||||
if (_canvasMode != CanvasMode.Edit)
|
||||
return Cursors.Default;
|
||||
|
||||
switch (mode)
|
||||
{
|
||||
case EditMode.AddNode:
|
||||
return Cursors.Cross;
|
||||
case EditMode.Move:
|
||||
return Cursors.SizeAll;
|
||||
case EditMode.Connect:
|
||||
return Cursors.Hand;
|
||||
case EditMode.Delete:
|
||||
return Cursors.No;
|
||||
default:
|
||||
return Cursors.Default;
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelConnection()
|
||||
{
|
||||
_isConnectionMode = false;
|
||||
_connectionStartNode = null;
|
||||
_connectionEndPoint = Point.Empty;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private void UpdateDestinationNode()
|
||||
{
|
||||
_destinationNode = null;
|
||||
|
||||
if (_currentPath != null && _currentPath.Success && _currentPath.Path != null && _currentPath.Path.Count > 0)
|
||||
{
|
||||
// 경로의 마지막 노드가 목적지
|
||||
string destinationNodeId = _currentPath.Path[_currentPath.Path.Count - 1];
|
||||
|
||||
// 노드 목록에서 해당 노드 찾기
|
||||
_destinationNode = _nodes?.FirstOrDefault(n => n.NodeId == destinationNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cleanup
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
|
||||
// 브러쉬 정리
|
||||
_normalNodeBrush?.Dispose();
|
||||
_rotationNodeBrush?.Dispose();
|
||||
_dockingNodeBrush?.Dispose();
|
||||
_chargingNodeBrush?.Dispose();
|
||||
_selectedNodeBrush?.Dispose();
|
||||
_hoveredNodeBrush?.Dispose();
|
||||
_destinationNodeBrush?.Dispose();
|
||||
_gridBrush?.Dispose();
|
||||
_agvBrush?.Dispose();
|
||||
_pathBrush?.Dispose();
|
||||
|
||||
// 펜 정리
|
||||
_connectionPen?.Dispose();
|
||||
_gridPen?.Dispose();
|
||||
_tempConnectionPen?.Dispose();
|
||||
_selectedNodePen?.Dispose();
|
||||
_destinationNodePen?.Dispose();
|
||||
_pathPen?.Dispose();
|
||||
_agvPen?.Dispose();
|
||||
_highlightedConnectionPen?.Dispose();
|
||||
|
||||
// 컨텍스트 메뉴 정리
|
||||
_contextMenu?.Dispose();
|
||||
|
||||
// 이미지 정리
|
||||
_companyLogo?.Dispose();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// RFID 중복값을 가진 노드들을 감지하고 표시
|
||||
/// 나중에 추가된 노드(인덱스가 더 큰)를 중복으로 간주
|
||||
/// </summary>
|
||||
private void DetectDuplicateRfidNodes()
|
||||
{
|
||||
_duplicateRfidNodes.Clear();
|
||||
|
||||
if (_nodes == null || _nodes.Count == 0)
|
||||
return;
|
||||
|
||||
// RFID값과 해당 노드의 인덱스를 저장
|
||||
var rfidToNodeIndex = new Dictionary<string, List<int>>();
|
||||
|
||||
// 모든 노드의 RFID값 수집
|
||||
for (int i = 0; i < _nodes.Count; i++)
|
||||
{
|
||||
var node = _nodes[i];
|
||||
if (!string.IsNullOrEmpty(node.RfidId))
|
||||
{
|
||||
if (!rfidToNodeIndex.ContainsKey(node.RfidId))
|
||||
{
|
||||
rfidToNodeIndex[node.RfidId] = new List<int>();
|
||||
}
|
||||
rfidToNodeIndex[node.RfidId].Add(i);
|
||||
}
|
||||
}
|
||||
|
||||
// 중복된 RFID를 가진 노드들을 찾아서 나중에 추가된 것들을 표시
|
||||
foreach (var kvp in rfidToNodeIndex)
|
||||
{
|
||||
if (kvp.Value.Count > 1)
|
||||
{
|
||||
// 첫 번째 노드는 원본으로 유지, 나머지는 중복으로 표시
|
||||
for (int i = 1; i < kvp.Value.Count; i++)
|
||||
{
|
||||
int duplicateNodeIndex = kvp.Value[i];
|
||||
_duplicateRfidNodes.Add(_nodes[duplicateNodeIndex].NodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 기존 노드들의 최대 번호를 찾아서 _nodeCounter를 업데이트
|
||||
/// </summary>
|
||||
private void UpdateNodeCounter()
|
||||
{
|
||||
if (_nodes == null || _nodes.Count == 0)
|
||||
{
|
||||
_nodeCounter = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
int maxNumber = 0;
|
||||
foreach (var node in _nodes)
|
||||
{
|
||||
// NodeId에서 숫자 부분 추출 (예: "N001" -> 1)
|
||||
if (node.NodeId.StartsWith("N") && int.TryParse(node.NodeId.Substring(1), out int number))
|
||||
{
|
||||
maxNumber = Math.Max(maxNumber, number);
|
||||
}
|
||||
}
|
||||
|
||||
_nodeCounter = maxNumber + 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 특정 노드에 도킹 오류 표시를 설정/해제합니다.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">노드 ID</param>
|
||||
/// <param name="hasError">오류 여부</param>
|
||||
public void SetDockingError(string nodeId, bool hasError)
|
||||
{
|
||||
if (string.IsNullOrEmpty(nodeId))
|
||||
return;
|
||||
|
||||
if (hasError)
|
||||
{
|
||||
_dockingErrors[nodeId] = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_dockingErrors.Remove(nodeId);
|
||||
}
|
||||
|
||||
Invalidate(); // 화면 다시 그리기
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 특정 노드에 도킹 오류가 있는지 확인합니다.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">노드 ID</param>
|
||||
/// <returns>도킹 오류 여부</returns>
|
||||
public bool HasDockingError(string nodeId)
|
||||
{
|
||||
return _dockingErrors.ContainsKey(nodeId) && _dockingErrors[nodeId];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 모든 도킹 오류를 초기화합니다.
|
||||
/// </summary>
|
||||
public void ClearDockingErrors()
|
||||
{
|
||||
_dockingErrors.Clear();
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user