diff --git a/Cs_HMI/AGVCSharp.sln b/Cs_HMI/AGVCSharp.sln index c4910f1..4cfe026 100644 --- a/Cs_HMI/AGVCSharp.sln +++ b/Cs_HMI/AGVCSharp.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Express 15 for Windows Desktop -VisualStudioVersion = 15.0.28307.1000 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36310.24 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Sub", "Sub", "{C423C39A-44E7-4F09-B2F7-7943975FF948}" EndProject @@ -27,6 +27,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ENIGProtocol", "SubProject\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AGV4", "Project\AGV4.csproj", "{D6B3880D-7D5C-44E2-B6A5-CF6D881A8A38}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AGVMapEditor", "AGVMapEditor\AGVMapEditor.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AGVSimulator", "AGVSimulator\AGVSimulator.csproj", "{B2C3D4E5-0000-0000-0000-000000000000}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "솔루션 항목", "솔루션 항목", "{2A3A057F-5D22-31FD-628C-DF5EF75AEF1E}" + ProjectSection(SolutionItems) = preProject + build.bat = build.bat + CLAUDE.md = CLAUDE.md + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -157,6 +167,30 @@ Global {D6B3880D-7D5C-44E2-B6A5-CF6D881A8A38}.Release|x64.Build.0 = Release|Any CPU {D6B3880D-7D5C-44E2-B6A5-CF6D881A8A38}.Release|x86.ActiveCfg = Release|x86 {D6B3880D-7D5C-44E2-B6A5-CF6D881A8A38}.Release|x86.Build.0 = Release|x86 + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.Build.0 = Release|Any CPU + {B2C3D4E5-0000-0000-0000-000000000000}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2C3D4E5-0000-0000-0000-000000000000}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-0000-0000-0000-000000000000}.Debug|x64.ActiveCfg = Debug|Any CPU + {B2C3D4E5-0000-0000-0000-000000000000}.Debug|x64.Build.0 = Debug|Any CPU + {B2C3D4E5-0000-0000-0000-000000000000}.Debug|x86.ActiveCfg = Debug|Any CPU + {B2C3D4E5-0000-0000-0000-000000000000}.Debug|x86.Build.0 = Debug|Any CPU + {B2C3D4E5-0000-0000-0000-000000000000}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2C3D4E5-0000-0000-0000-000000000000}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-0000-0000-0000-000000000000}.Release|x64.ActiveCfg = Release|Any CPU + {B2C3D4E5-0000-0000-0000-000000000000}.Release|x64.Build.0 = Release|Any CPU + {B2C3D4E5-0000-0000-0000-000000000000}.Release|x86.ActiveCfg = Release|Any CPU + {B2C3D4E5-0000-0000-0000-000000000000}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Cs_HMI/AGVMapEditor/AGVMapEditor.csproj b/Cs_HMI/AGVMapEditor/AGVMapEditor.csproj new file mode 100644 index 0000000..3440faf --- /dev/null +++ b/Cs_HMI/AGVMapEditor/AGVMapEditor.csproj @@ -0,0 +1,84 @@ + + + + + Debug + AnyCPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890} + WinExe + AGVMapEditor + AGVMapEditor + v4.8 + 512 + true + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + ..\packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll + + + + + + + + + + + + + Form + + + MainForm.cs + + + UserControl + + + MapCanvas.cs + + + + + + + MainForm.cs + + + MapCanvas.cs + + + + + + + + + + + \ No newline at end of file diff --git a/Cs_HMI/AGVMapEditor/Controls/MapCanvas.Designer.cs b/Cs_HMI/AGVMapEditor/Controls/MapCanvas.Designer.cs new file mode 100644 index 0000000..da7a559 --- /dev/null +++ b/Cs_HMI/AGVMapEditor/Controls/MapCanvas.Designer.cs @@ -0,0 +1,59 @@ +namespace AGVMapEditor.Controls +{ + partial class MapCanvas + { + /// + /// 필수 디자이너 변수입니다. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// 사용 중인 모든 리소스를 정리합니다. + /// + /// 관리되는 리소스를 삭제해야 하면 true이고, 그렇지 않으면 false입니다. + protected override void Dispose(bool disposing) + { + if (disposing) + { + if (components != null) + { + components.Dispose(); + } + + // MapCanvas의 추가 리소스 정리 + _normalNodeBrush?.Dispose(); + _rotationNodeBrush?.Dispose(); + _dockingNodeBrush?.Dispose(); + _chargingNodeBrush?.Dispose(); + _selectedNodeBrush?.Dispose(); + _hoveredNodeBrush?.Dispose(); + _connectionPen?.Dispose(); + _gridPen?.Dispose(); + } + base.Dispose(disposing); + } + + #region 구성 요소 디자이너에서 생성한 코드 + + /// + /// 디자이너 지원에 필요한 메서드입니다. + /// 이 메서드의 내용을 코드 편집기로 수정하지 마세요. + /// + private void InitializeComponent() + { + this.SuspendLayout(); + // + // MapCanvas + // + this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 12F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.BackColor = System.Drawing.Color.White; + this.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; + this.Name = "MapCanvas"; + this.ResumeLayout(false); + + } + + #endregion + } +} \ No newline at end of file diff --git a/Cs_HMI/AGVMapEditor/Controls/MapCanvas.cs b/Cs_HMI/AGVMapEditor/Controls/MapCanvas.cs new file mode 100644 index 0000000..3769f22 --- /dev/null +++ b/Cs_HMI/AGVMapEditor/Controls/MapCanvas.cs @@ -0,0 +1,608 @@ +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 +{ + /// + /// 맵 편집을 위한 그래픽 캔버스 컨트롤 + /// + 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 _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 + + /// + /// 노드가 선택되었을 때 발생하는 이벤트 + /// + public event EventHandler NodeSelected; + + /// + /// 노드가 이동되었을 때 발생하는 이벤트 + /// + public event EventHandler NodeMoved; + + /// + /// 배경이 클릭되었을 때 발생하는 이벤트 + /// + public event EventHandler BackgroundClicked; + + #endregion + + #region Constructor + + public MapCanvas() : this(new List()) + { + } + + public MapCanvas(List nodes) + { + InitializeComponent(); + InitializeGraphics(); + SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.DoubleBuffer | ControlStyles.ResizeRedraw, true); + + _nodes = nodes ?? new List(); + + // 이벤트 연결 + 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 + + /// + /// 그리드 표시 여부 + /// + public bool ShowGrid + { + get { return _showGrid; } + set + { + _showGrid = value; + Invalidate(); + } + } + + /// + /// 줌 팩터 + /// + public float ZoomFactor + { + get { return _zoomFactor; } + set + { + _zoomFactor = Math.Max(0.1f, Math.Min(5.0f, value)); + Invalidate(); + } + } + + /// + /// 선택된 노드 + /// + 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 + + /// + /// 스크린 좌표를 월드 좌표로 변환 + /// + 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); + } + + /// + /// 월드 좌표를 스크린 좌표로 변환 + /// + 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 + + /// + /// 지정된 위치의 노드 검색 + /// + 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; + } + + /// + /// 노드 선택 + /// + private void SelectNode(MapNode node) + { + if (_selectedNode != node) + { + _selectedNode = node; + NodeSelected?.Invoke(this, node); + Invalidate(); + } + } + + /// + /// 우클릭 컨텍스트 메뉴 표시 + /// + 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); + } + + /// + /// 노드 타입 변경 + /// + 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 + + /// + /// 현재 보이는 월드 영역 계산 + /// + 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 + + } +} \ No newline at end of file diff --git a/Cs_HMI/AGVMapEditor/Controls/MapCanvas.resx b/Cs_HMI/AGVMapEditor/Controls/MapCanvas.resx new file mode 100644 index 0000000..b45c916 --- /dev/null +++ b/Cs_HMI/AGVMapEditor/Controls/MapCanvas.resx @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/Cs_HMI/AGVMapEditor/Forms/MainForm.Designer.cs b/Cs_HMI/AGVMapEditor/Forms/MainForm.Designer.cs new file mode 100644 index 0000000..55ef0af --- /dev/null +++ b/Cs_HMI/AGVMapEditor/Forms/MainForm.Designer.cs @@ -0,0 +1,422 @@ +namespace AGVMapEditor.Forms +{ + partial class MainForm + { + /// + /// 필수 디자이너 변수입니다. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// 사용 중인 모든 리소스를 정리합니다. + /// + /// 관리되는 리소스를 삭제해야 하면 true이고, 그렇지 않으면 false입니다. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form 디자이너에서 생성한 코드 + + /// + /// 디자이너 지원에 필요한 메서드입니다. + /// 이 메서드의 내용을 코드 편집기로 수정하지 마세요. + /// + private void InitializeComponent() + { + this.menuStrip1 = new System.Windows.Forms.MenuStrip(); + this.fileToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.newToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.openToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.toolStripSeparator1 = new System.Windows.Forms.ToolStripSeparator(); + this.saveToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.saveAsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.toolStripSeparator2 = new System.Windows.Forms.ToolStripSeparator(); + this.exitToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.statusStrip1 = new System.Windows.Forms.StatusStrip(); + this.toolStripStatusLabel1 = new System.Windows.Forms.ToolStripStatusLabel(); + this.splitContainer1 = new System.Windows.Forms.SplitContainer(); + this.tabControl1 = new System.Windows.Forms.TabControl(); + this.tabPageNodes = new System.Windows.Forms.TabPage(); + this.btnRemoveConnection = new System.Windows.Forms.Button(); + this.btnAddConnection = new System.Windows.Forms.Button(); + this.btnDeleteNode = new System.Windows.Forms.Button(); + this.btnAddNode = new System.Windows.Forms.Button(); + this.listBoxNodes = new System.Windows.Forms.ListBox(); + this.label1 = new System.Windows.Forms.Label(); + this.tabPageRfid = new System.Windows.Forms.TabPage(); + this.btnDeleteRfidMapping = new System.Windows.Forms.Button(); + this.btnAddRfidMapping = new System.Windows.Forms.Button(); + this.listBoxRfidMappings = new System.Windows.Forms.ListBox(); + this.label2 = new System.Windows.Forms.Label(); + this.tabPageProperties = new System.Windows.Forms.TabPage(); + this.labelSelectedNode = new System.Windows.Forms.Label(); + this.label3 = new System.Windows.Forms.Label(); + this.menuStrip1.SuspendLayout(); + this.statusStrip1.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)(this.splitContainer1)).BeginInit(); + this.splitContainer1.Panel1.SuspendLayout(); + this.splitContainer1.SuspendLayout(); + this.tabControl1.SuspendLayout(); + this.tabPageNodes.SuspendLayout(); + this.tabPageRfid.SuspendLayout(); + this.tabPageProperties.SuspendLayout(); + this.SuspendLayout(); + // + // menuStrip1 + // + this.menuStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { + this.fileToolStripMenuItem}); + this.menuStrip1.Location = new System.Drawing.Point(0, 0); + this.menuStrip1.Name = "menuStrip1"; + this.menuStrip1.Size = new System.Drawing.Size(1200, 24); + this.menuStrip1.TabIndex = 0; + this.menuStrip1.Text = "menuStrip1"; + // + // fileToolStripMenuItem + // + this.fileToolStripMenuItem.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { + this.newToolStripMenuItem, + this.openToolStripMenuItem, + this.toolStripSeparator1, + this.saveToolStripMenuItem, + this.saveAsToolStripMenuItem, + this.toolStripSeparator2, + this.exitToolStripMenuItem}); + this.fileToolStripMenuItem.Name = "fileToolStripMenuItem"; + this.fileToolStripMenuItem.Size = new System.Drawing.Size(57, 20); + this.fileToolStripMenuItem.Text = "파일(&F)"; + // + // newToolStripMenuItem + // + this.newToolStripMenuItem.Name = "newToolStripMenuItem"; + this.newToolStripMenuItem.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.N))); + this.newToolStripMenuItem.Size = new System.Drawing.Size(180, 22); + this.newToolStripMenuItem.Text = "새로 만들기(&N)"; + this.newToolStripMenuItem.Click += new System.EventHandler(this.newToolStripMenuItem_Click); + // + // openToolStripMenuItem + // + this.openToolStripMenuItem.Name = "openToolStripMenuItem"; + this.openToolStripMenuItem.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.O))); + this.openToolStripMenuItem.Size = new System.Drawing.Size(180, 22); + this.openToolStripMenuItem.Text = "열기(&O)"; + this.openToolStripMenuItem.Click += new System.EventHandler(this.openToolStripMenuItem_Click); + // + // toolStripSeparator1 + // + this.toolStripSeparator1.Name = "toolStripSeparator1"; + this.toolStripSeparator1.Size = new System.Drawing.Size(177, 6); + // + // saveToolStripMenuItem + // + this.saveToolStripMenuItem.Name = "saveToolStripMenuItem"; + this.saveToolStripMenuItem.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.S))); + this.saveToolStripMenuItem.Size = new System.Drawing.Size(180, 22); + this.saveToolStripMenuItem.Text = "저장(&S)"; + this.saveToolStripMenuItem.Click += new System.EventHandler(this.saveToolStripMenuItem_Click); + // + // saveAsToolStripMenuItem + // + this.saveAsToolStripMenuItem.Name = "saveAsToolStripMenuItem"; + this.saveAsToolStripMenuItem.Size = new System.Drawing.Size(180, 22); + this.saveAsToolStripMenuItem.Text = "다른 이름으로 저장(&A)"; + this.saveAsToolStripMenuItem.Click += new System.EventHandler(this.saveAsToolStripMenuItem_Click); + // + // toolStripSeparator2 + // + this.toolStripSeparator2.Name = "toolStripSeparator2"; + this.toolStripSeparator2.Size = new System.Drawing.Size(177, 6); + // + // exitToolStripMenuItem + // + this.exitToolStripMenuItem.Name = "exitToolStripMenuItem"; + this.exitToolStripMenuItem.Size = new System.Drawing.Size(180, 22); + this.exitToolStripMenuItem.Text = "종료(&X)"; + this.exitToolStripMenuItem.Click += new System.EventHandler(this.exitToolStripMenuItem_Click); + // + // statusStrip1 + // + this.statusStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { + this.toolStripStatusLabel1}); + this.statusStrip1.Location = new System.Drawing.Point(0, 751); + this.statusStrip1.Name = "statusStrip1"; + this.statusStrip1.Size = new System.Drawing.Size(1200, 22); + this.statusStrip1.TabIndex = 1; + this.statusStrip1.Text = "statusStrip1"; + // + // toolStripStatusLabel1 + // + this.toolStripStatusLabel1.Name = "toolStripStatusLabel1"; + this.toolStripStatusLabel1.Size = new System.Drawing.Size(39, 17); + this.toolStripStatusLabel1.Text = "Ready"; + // + // splitContainer1 + // + this.splitContainer1.Dock = System.Windows.Forms.DockStyle.Fill; + this.splitContainer1.Location = new System.Drawing.Point(0, 24); + this.splitContainer1.Name = "splitContainer1"; + // + // splitContainer1.Panel1 + // + this.splitContainer1.Panel1.Controls.Add(this.tabControl1); + this.splitContainer1.Panel1MinSize = 300; + this.splitContainer1.Size = new System.Drawing.Size(1200, 727); + this.splitContainer1.SplitterDistance = 300; + this.splitContainer1.TabIndex = 2; + // + // tabControl1 + // + this.tabControl1.Controls.Add(this.tabPageNodes); + this.tabControl1.Controls.Add(this.tabPageRfid); + this.tabControl1.Controls.Add(this.tabPageProperties); + this.tabControl1.Dock = System.Windows.Forms.DockStyle.Fill; + this.tabControl1.Location = new System.Drawing.Point(0, 0); + this.tabControl1.Name = "tabControl1"; + this.tabControl1.SelectedIndex = 0; + this.tabControl1.Size = new System.Drawing.Size(300, 727); + this.tabControl1.TabIndex = 0; + // + // tabPageNodes + // + this.tabPageNodes.Controls.Add(this.btnRemoveConnection); + this.tabPageNodes.Controls.Add(this.btnAddConnection); + this.tabPageNodes.Controls.Add(this.btnDeleteNode); + this.tabPageNodes.Controls.Add(this.btnAddNode); + this.tabPageNodes.Controls.Add(this.listBoxNodes); + this.tabPageNodes.Controls.Add(this.label1); + this.tabPageNodes.Location = new System.Drawing.Point(4, 22); + this.tabPageNodes.Name = "tabPageNodes"; + this.tabPageNodes.Padding = new System.Windows.Forms.Padding(3); + this.tabPageNodes.Size = new System.Drawing.Size(292, 701); + this.tabPageNodes.TabIndex = 0; + this.tabPageNodes.Text = "노드 관리"; + this.tabPageNodes.UseVisualStyleBackColor = true; + // + // btnRemoveConnection + // + this.btnRemoveConnection.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.btnRemoveConnection.Location = new System.Drawing.Point(6, 637); + this.btnRemoveConnection.Name = "btnRemoveConnection"; + this.btnRemoveConnection.Size = new System.Drawing.Size(280, 25); + this.btnRemoveConnection.TabIndex = 5; + this.btnRemoveConnection.Text = "연결 제거"; + this.btnRemoveConnection.UseVisualStyleBackColor = true; + this.btnRemoveConnection.Click += new System.EventHandler(this.btnRemoveConnection_Click); + // + // btnAddConnection + // + this.btnAddConnection.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.btnAddConnection.Location = new System.Drawing.Point(6, 606); + this.btnAddConnection.Name = "btnAddConnection"; + this.btnAddConnection.Size = new System.Drawing.Size(280, 25); + this.btnAddConnection.TabIndex = 4; + this.btnAddConnection.Text = "연결 추가"; + this.btnAddConnection.UseVisualStyleBackColor = true; + this.btnAddConnection.Click += new System.EventHandler(this.btnAddConnection_Click); + // + // btnDeleteNode + // + this.btnDeleteNode.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.btnDeleteNode.Location = new System.Drawing.Point(148, 670); + this.btnDeleteNode.Name = "btnDeleteNode"; + this.btnDeleteNode.Size = new System.Drawing.Size(138, 25); + this.btnDeleteNode.TabIndex = 3; + this.btnDeleteNode.Text = "노드 삭제"; + this.btnDeleteNode.UseVisualStyleBackColor = true; + this.btnDeleteNode.Click += new System.EventHandler(this.btnDeleteNode_Click); + // + // btnAddNode + // + this.btnAddNode.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.btnAddNode.Location = new System.Drawing.Point(6, 670); + this.btnAddNode.Name = "btnAddNode"; + this.btnAddNode.Size = new System.Drawing.Size(138, 25); + this.btnAddNode.TabIndex = 2; + this.btnAddNode.Text = "노드 추가"; + this.btnAddNode.UseVisualStyleBackColor = true; + this.btnAddNode.Click += new System.EventHandler(this.btnAddNode_Click); + // + // listBoxNodes + // + this.listBoxNodes.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.listBoxNodes.FormattingEnabled = true; + this.listBoxNodes.ItemHeight = 12; + this.listBoxNodes.Location = new System.Drawing.Point(6, 25); + this.listBoxNodes.Name = "listBoxNodes"; + this.listBoxNodes.Size = new System.Drawing.Size(280, 568); + this.listBoxNodes.TabIndex = 1; + // + // label1 + // + this.label1.AutoSize = true; + this.label1.Location = new System.Drawing.Point(6, 6); + this.label1.Name = "label1"; + this.label1.Size = new System.Drawing.Size(53, 12); + this.label1.TabIndex = 0; + this.label1.Text = "노드 목록"; + // + // tabPageRfid + // + this.tabPageRfid.Controls.Add(this.btnDeleteRfidMapping); + this.tabPageRfid.Controls.Add(this.btnAddRfidMapping); + this.tabPageRfid.Controls.Add(this.listBoxRfidMappings); + this.tabPageRfid.Controls.Add(this.label2); + this.tabPageRfid.Location = new System.Drawing.Point(4, 22); + this.tabPageRfid.Name = "tabPageRfid"; + this.tabPageRfid.Padding = new System.Windows.Forms.Padding(3); + this.tabPageRfid.Size = new System.Drawing.Size(292, 701); + this.tabPageRfid.TabIndex = 1; + this.tabPageRfid.Text = "RFID 매핑"; + this.tabPageRfid.UseVisualStyleBackColor = true; + // + // btnDeleteRfidMapping + // + this.btnDeleteRfidMapping.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.btnDeleteRfidMapping.Location = new System.Drawing.Point(148, 670); + this.btnDeleteRfidMapping.Name = "btnDeleteRfidMapping"; + this.btnDeleteRfidMapping.Size = new System.Drawing.Size(138, 25); + this.btnDeleteRfidMapping.TabIndex = 3; + this.btnDeleteRfidMapping.Text = "매핑 삭제"; + this.btnDeleteRfidMapping.UseVisualStyleBackColor = true; + this.btnDeleteRfidMapping.Click += new System.EventHandler(this.btnDeleteRfidMapping_Click); + // + // btnAddRfidMapping + // + this.btnAddRfidMapping.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.btnAddRfidMapping.Location = new System.Drawing.Point(6, 670); + this.btnAddRfidMapping.Name = "btnAddRfidMapping"; + this.btnAddRfidMapping.Size = new System.Drawing.Size(138, 25); + this.btnAddRfidMapping.TabIndex = 2; + this.btnAddRfidMapping.Text = "매핑 추가"; + this.btnAddRfidMapping.UseVisualStyleBackColor = true; + this.btnAddRfidMapping.Click += new System.EventHandler(this.btnAddRfidMapping_Click); + // + // listBoxRfidMappings + // + this.listBoxRfidMappings.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.listBoxRfidMappings.FormattingEnabled = true; + this.listBoxRfidMappings.ItemHeight = 12; + this.listBoxRfidMappings.Location = new System.Drawing.Point(6, 25); + this.listBoxRfidMappings.Name = "listBoxRfidMappings"; + this.listBoxRfidMappings.Size = new System.Drawing.Size(280, 628); + this.listBoxRfidMappings.TabIndex = 1; + // + // label2 + // + this.label2.AutoSize = true; + this.label2.Location = new System.Drawing.Point(6, 6); + this.label2.Name = "label2"; + this.label2.Size = new System.Drawing.Size(77, 12); + this.label2.TabIndex = 0; + this.label2.Text = "RFID 매핑 목록"; + // + // tabPageProperties + // + this.tabPageProperties.Controls.Add(this.labelSelectedNode); + this.tabPageProperties.Controls.Add(this.label3); + this.tabPageProperties.Location = new System.Drawing.Point(4, 22); + this.tabPageProperties.Name = "tabPageProperties"; + this.tabPageProperties.Size = new System.Drawing.Size(292, 701); + this.tabPageProperties.TabIndex = 2; + this.tabPageProperties.Text = "속성"; + this.tabPageProperties.UseVisualStyleBackColor = true; + // + // labelSelectedNode + // + this.labelSelectedNode.AutoSize = true; + this.labelSelectedNode.Location = new System.Drawing.Point(8, 35); + this.labelSelectedNode.Name = "labelSelectedNode"; + this.labelSelectedNode.Size = new System.Drawing.Size(93, 12); + this.labelSelectedNode.TabIndex = 1; + this.labelSelectedNode.Text = "선택된 노드: 없음"; + // + // label3 + // + this.label3.AutoSize = true; + this.label3.Location = new System.Drawing.Point(8, 12); + this.label3.Name = "label3"; + this.label3.Size = new System.Drawing.Size(53, 12); + this.label3.TabIndex = 0; + this.label3.Text = "노드 속성"; + // + // MainForm + // + this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 12F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(1200, 773); + this.Controls.Add(this.splitContainer1); + this.Controls.Add(this.statusStrip1); + this.Controls.Add(this.menuStrip1); + this.MainMenuStrip = this.menuStrip1; + this.Name = "MainForm"; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen; + this.Text = "AGV Map Editor"; + this.FormClosing += new System.Windows.Forms.FormClosingEventHandler(this.MainForm_FormClosing); + this.Load += new System.EventHandler(this.MainForm_Load); + this.menuStrip1.ResumeLayout(false); + this.menuStrip1.PerformLayout(); + this.statusStrip1.ResumeLayout(false); + this.statusStrip1.PerformLayout(); + this.splitContainer1.Panel1.ResumeLayout(false); + ((System.ComponentModel.ISupportInitialize)(this.splitContainer1)).EndInit(); + this.splitContainer1.ResumeLayout(false); + this.tabControl1.ResumeLayout(false); + this.tabPageNodes.ResumeLayout(false); + this.tabPageNodes.PerformLayout(); + this.tabPageRfid.ResumeLayout(false); + this.tabPageRfid.ResumeLayout(false); + this.tabPageRfid.PerformLayout(); + this.tabPageProperties.ResumeLayout(false); + this.tabPageProperties.PerformLayout(); + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + + private System.Windows.Forms.MenuStrip menuStrip1; + private System.Windows.Forms.ToolStripMenuItem fileToolStripMenuItem; + private System.Windows.Forms.ToolStripMenuItem newToolStripMenuItem; + private System.Windows.Forms.ToolStripMenuItem openToolStripMenuItem; + private System.Windows.Forms.ToolStripSeparator toolStripSeparator1; + private System.Windows.Forms.ToolStripMenuItem saveToolStripMenuItem; + private System.Windows.Forms.ToolStripMenuItem saveAsToolStripMenuItem; + private System.Windows.Forms.ToolStripSeparator toolStripSeparator2; + private System.Windows.Forms.ToolStripMenuItem exitToolStripMenuItem; + private System.Windows.Forms.StatusStrip statusStrip1; + private System.Windows.Forms.ToolStripStatusLabel toolStripStatusLabel1; + private System.Windows.Forms.SplitContainer splitContainer1; + private System.Windows.Forms.TabControl tabControl1; + private System.Windows.Forms.TabPage tabPageNodes; + private System.Windows.Forms.TabPage tabPageRfid; + private System.Windows.Forms.Button btnDeleteNode; + private System.Windows.Forms.Button btnAddNode; + private System.Windows.Forms.ListBox listBoxNodes; + private System.Windows.Forms.Label label1; + private System.Windows.Forms.Button btnDeleteRfidMapping; + private System.Windows.Forms.Button btnAddRfidMapping; + private System.Windows.Forms.ListBox listBoxRfidMappings; + private System.Windows.Forms.Label label2; + private System.Windows.Forms.TabPage tabPageProperties; + private System.Windows.Forms.Label labelSelectedNode; + private System.Windows.Forms.Label label3; + private System.Windows.Forms.Button btnRemoveConnection; + private System.Windows.Forms.Button btnAddConnection; + } +} \ No newline at end of file diff --git a/Cs_HMI/AGVMapEditor/Forms/MainForm.cs b/Cs_HMI/AGVMapEditor/Forms/MainForm.cs new file mode 100644 index 0000000..5c0ab43 --- /dev/null +++ b/Cs_HMI/AGVMapEditor/Forms/MainForm.cs @@ -0,0 +1,586 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Windows.Forms; +using AGVMapEditor.Models; +using AGVMapEditor.Controls; +using Newtonsoft.Json; + +namespace AGVMapEditor.Forms +{ + /// + /// AGV 맵 에디터 메인 폼 + /// + public partial class MainForm : Form + { + #region Fields + + private NodeResolver _nodeResolver; + private List _mapNodes; + private List _rfidMappings; + private MapCanvas _mapCanvas; + + // 현재 선택된 노드 + private MapNode _selectedNode; + + // 파일 경로 + private string _currentMapFile = string.Empty; + private bool _hasChanges = false; + + #endregion + + #region Constructor + + public MainForm() + { + InitializeComponent(); + InitializeData(); + InitializeMapCanvas(); + UpdateTitle(); + } + + #endregion + + #region Initialization + + private void InitializeData() + { + _mapNodes = new List(); + _rfidMappings = new List(); + _nodeResolver = new NodeResolver(_rfidMappings, _mapNodes); + } + + private void InitializeMapCanvas() + { + _mapCanvas = new MapCanvas(_mapNodes); + _mapCanvas.Dock = DockStyle.Fill; + _mapCanvas.NodeSelected += OnNodeSelected; + _mapCanvas.NodeMoved += OnNodeMoved; + _mapCanvas.BackgroundClicked += OnBackgroundClicked; + + // 스플리터 패널에 맵 캔버스 추가 + splitContainer1.Panel2.Controls.Add(_mapCanvas); + } + + #endregion + + #region Event Handlers + + private void MainForm_Load(object sender, EventArgs e) + { + RefreshNodeList(); + RefreshRfidMappingList(); + } + + private void OnNodeSelected(object sender, MapNode node) + { + _selectedNode = node; + UpdateNodeProperties(); + } + + private void OnNodeMoved(object sender, MapNode node) + { + _hasChanges = true; + UpdateTitle(); + RefreshNodeList(); + } + + private void OnBackgroundClicked(object sender, Point location) + { + _selectedNode = null; + ClearNodeProperties(); + } + + #endregion + + #region Menu Event Handlers + + private void newToolStripMenuItem_Click(object sender, EventArgs e) + { + if (CheckSaveChanges()) + { + NewMap(); + } + } + + private void openToolStripMenuItem_Click(object sender, EventArgs e) + { + if (CheckSaveChanges()) + { + OpenMap(); + } + } + + private void saveToolStripMenuItem_Click(object sender, EventArgs e) + { + SaveMap(); + } + + private void saveAsToolStripMenuItem_Click(object sender, EventArgs e) + { + SaveAsMap(); + } + + private void exitToolStripMenuItem_Click(object sender, EventArgs e) + { + this.Close(); + } + + #endregion + + #region Button Event Handlers + + private void btnAddNode_Click(object sender, EventArgs e) + { + AddNewNode(); + } + + private void btnDeleteNode_Click(object sender, EventArgs e) + { + DeleteSelectedNode(); + } + + private void btnAddConnection_Click(object sender, EventArgs e) + { + AddConnectionToSelectedNode(); + } + + private void btnRemoveConnection_Click(object sender, EventArgs e) + { + RemoveConnectionFromSelectedNode(); + } + + private void btnAddRfidMapping_Click(object sender, EventArgs e) + { + AddNewRfidMapping(); + } + + private void btnDeleteRfidMapping_Click(object sender, EventArgs e) + { + DeleteSelectedRfidMapping(); + } + + #endregion + + #region Node Management + + private void AddNewNode() + { + var nodeId = GenerateNodeId(); + var nodeName = $"노드{_mapNodes.Count + 1}"; + var position = new Point(100 + _mapNodes.Count * 50, 100 + _mapNodes.Count * 50); + + var node = new MapNode(nodeId, nodeName, position, NodeType.Normal); + + _mapNodes.Add(node); + _hasChanges = true; + + RefreshNodeList(); + RefreshMapCanvas(); + UpdateTitle(); + } + + private void DeleteSelectedNode() + { + if (_selectedNode == null) + { + MessageBox.Show("삭제할 노드를 선택하세요.", "알림", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + var result = MessageBox.Show($"노드 '{_selectedNode.Name}'를 삭제하시겠습니까?\n연결된 RFID 매핑도 함께 삭제됩니다.", + "삭제 확인", MessageBoxButtons.YesNo, MessageBoxIcon.Question); + + if (result == DialogResult.Yes) + { + _nodeResolver.RemoveMapNode(_selectedNode.NodeId); + _selectedNode = null; + _hasChanges = true; + + RefreshNodeList(); + RefreshRfidMappingList(); + RefreshMapCanvas(); + ClearNodeProperties(); + UpdateTitle(); + } + } + + private void AddConnectionToSelectedNode() + { + if (_selectedNode == null) + { + MessageBox.Show("연결을 추가할 노드를 선택하세요.", "알림", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + // 다른 노드들 중에서 선택 + var availableNodes = _mapNodes.Where(n => n.NodeId != _selectedNode.NodeId && + !_selectedNode.ConnectedNodes.Contains(n.NodeId)).ToList(); + + if (availableNodes.Count == 0) + { + MessageBox.Show("연결 가능한 노드가 없습니다.", "알림", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + // 간단한 선택 다이얼로그 (실제로는 별도 폼을 만들어야 함) + var nodeNames = availableNodes.Select(n => $"{n.NodeId}: {n.Name}").ToArray(); + var input = Microsoft.VisualBasic.Interaction.InputBox("연결할 노드를 선택하세요:", "노드 연결", nodeNames[0]); + + var targetNode = availableNodes.FirstOrDefault(n => input.StartsWith(n.NodeId)); + if (targetNode != null) + { + _selectedNode.AddConnection(targetNode.NodeId); + _hasChanges = true; + RefreshMapCanvas(); + UpdateNodeProperties(); + UpdateTitle(); + } + } + + private void RemoveConnectionFromSelectedNode() + { + if (_selectedNode == null || _selectedNode.ConnectedNodes.Count == 0) + { + MessageBox.Show("연결을 제거할 노드를 선택하거나 연결된 노드가 없습니다.", "알림", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + // 연결된 노드들 중에서 선택 + var connectedNodeNames = _selectedNode.ConnectedNodes.Select(connectedNodeId => + { + var node = _mapNodes.FirstOrDefault(n => n.NodeId == connectedNodeId); + return node != null ? $"{node.NodeId}: {node.Name}" : connectedNodeId; + }).ToArray(); + + var input = Microsoft.VisualBasic.Interaction.InputBox("제거할 연결을 선택하세요:", "연결 제거", connectedNodeNames[0]); + + var targetNodeId = input.Split(':')[0]; + if (_selectedNode.ConnectedNodes.Contains(targetNodeId)) + { + _selectedNode.RemoveConnection(targetNodeId); + _hasChanges = true; + RefreshMapCanvas(); + UpdateNodeProperties(); + UpdateTitle(); + } + } + + private string GenerateNodeId() + { + int counter = 1; + string nodeId; + + do + { + nodeId = $"N{counter:D3}"; + counter++; + } while (_mapNodes.Any(n => n.NodeId == nodeId)); + + return nodeId; + } + + #endregion + + #region RFID Mapping Management + + private void AddNewRfidMapping() + { + if (_mapNodes.Count == 0) + { + MessageBox.Show("매핑할 노드가 없습니다. 먼저 노드를 추가하세요.", "알림", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + var unmappedNodes = _nodeResolver.GetUnmappedNodes(); + if (unmappedNodes.Count == 0) + { + MessageBox.Show("모든 노드가 이미 RFID에 매핑되어 있습니다.", "알림", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + // RFID 값 입력 + var rfidValue = Microsoft.VisualBasic.Interaction.InputBox("RFID 값을 입력하세요:", "RFID 매핑 추가"); + if (string.IsNullOrEmpty(rfidValue)) + return; + + // 노드 선택 + var nodeNames = unmappedNodes.Select(n => $"{n.NodeId}: {n.Name}").ToArray(); + var selectedNode = Microsoft.VisualBasic.Interaction.InputBox("매핑할 노드를 선택하세요:", "노드 선택", nodeNames[0]); + + var nodeId = selectedNode.Split(':')[0]; + var description = Microsoft.VisualBasic.Interaction.InputBox("설명을 입력하세요 (선택사항):", "설명"); + + if (_nodeResolver.AddRfidMapping(rfidValue, nodeId, description)) + { + _hasChanges = true; + RefreshRfidMappingList(); + UpdateTitle(); + MessageBox.Show("RFID 매핑이 추가되었습니다.", "성공", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + else + { + MessageBox.Show("RFID 매핑 추가에 실패했습니다. 중복된 RFID이거나 노드가 존재하지 않습니다.", "오류", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private void DeleteSelectedRfidMapping() + { + if (listBoxRfidMappings.SelectedItem == null) + { + MessageBox.Show("삭제할 RFID 매핑을 선택하세요.", "알림", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + var mapping = listBoxRfidMappings.SelectedItem as RfidMapping; + var result = MessageBox.Show($"RFID 매핑 '{mapping.RfidId} → {mapping.LogicalNodeId}'를 삭제하시겠습니까?", + "삭제 확인", MessageBoxButtons.YesNo, MessageBoxIcon.Question); + + if (result == DialogResult.Yes) + { + if (_nodeResolver.RemoveRfidMapping(mapping.RfidId)) + { + _hasChanges = true; + RefreshRfidMappingList(); + UpdateTitle(); + MessageBox.Show("RFID 매핑이 삭제되었습니다.", "성공", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + else + { + MessageBox.Show("RFID 매핑 삭제에 실패했습니다.", "오류", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + } + + #endregion + + #region File Operations + + private void NewMap() + { + _mapNodes.Clear(); + _rfidMappings.Clear(); + _nodeResolver = new NodeResolver(_rfidMappings, _mapNodes); + _selectedNode = null; + _currentMapFile = string.Empty; + _hasChanges = false; + + RefreshAll(); + UpdateTitle(); + } + + private void OpenMap() + { + var openFileDialog = new OpenFileDialog + { + Filter = "AGV Map Files (*.agvmap)|*.agvmap|JSON Files (*.json)|*.json|All Files (*.*)|*.*", + DefaultExt = "agvmap" + }; + + if (openFileDialog.ShowDialog() == DialogResult.OK) + { + try + { + LoadMapFromFile(openFileDialog.FileName); + _currentMapFile = openFileDialog.FileName; + _hasChanges = false; + RefreshAll(); + UpdateTitle(); + MessageBox.Show("맵이 성공적으로 로드되었습니다.", "성공", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception ex) + { + MessageBox.Show($"맵 로드 중 오류가 발생했습니다: {ex.Message}", "오류", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + } + + private void SaveMap() + { + if (string.IsNullOrEmpty(_currentMapFile)) + { + SaveAsMap(); + } + else + { + try + { + SaveMapToFile(_currentMapFile); + _hasChanges = false; + UpdateTitle(); + MessageBox.Show("맵이 성공적으로 저장되었습니다.", "성공", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception ex) + { + MessageBox.Show($"맵 저장 중 오류가 발생했습니다: {ex.Message}", "오류", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + } + + private void SaveAsMap() + { + var saveFileDialog = new SaveFileDialog + { + Filter = "AGV Map Files (*.agvmap)|*.agvmap|JSON Files (*.json)|*.json", + DefaultExt = "agvmap", + FileName = "NewMap.agvmap" + }; + + if (saveFileDialog.ShowDialog() == DialogResult.OK) + { + try + { + SaveMapToFile(saveFileDialog.FileName); + _currentMapFile = saveFileDialog.FileName; + _hasChanges = false; + UpdateTitle(); + MessageBox.Show("맵이 성공적으로 저장되었습니다.", "성공", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception ex) + { + MessageBox.Show($"맵 저장 중 오류가 발생했습니다: {ex.Message}", "오류", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + } + + private void LoadMapFromFile(string filePath) + { + var json = File.ReadAllText(filePath); + var mapData = JsonConvert.DeserializeObject(json); + + _mapNodes = mapData.Nodes ?? new List(); + _rfidMappings = mapData.RfidMappings ?? new List(); + _nodeResolver = new NodeResolver(_rfidMappings, _mapNodes); + } + + private void SaveMapToFile(string filePath) + { + var mapData = new MapData + { + Nodes = _mapNodes, + RfidMappings = _rfidMappings, + CreatedDate = DateTime.Now, + Version = "1.0" + }; + + var json = JsonConvert.SerializeObject(mapData, Formatting.Indented); + File.WriteAllText(filePath, json); + } + + private bool CheckSaveChanges() + { + if (_hasChanges) + { + var result = MessageBox.Show("변경사항이 있습니다. 저장하시겠습니까?", "변경사항 저장", + MessageBoxButtons.YesNoCancel, MessageBoxIcon.Question); + + if (result == DialogResult.Yes) + { + SaveMap(); + return !_hasChanges; // 저장이 성공했으면 true + } + else if (result == DialogResult.Cancel) + { + return false; + } + } + + return true; + } + + #endregion + + #region UI Updates + + private void RefreshAll() + { + RefreshNodeList(); + RefreshRfidMappingList(); + RefreshMapCanvas(); + ClearNodeProperties(); + } + + private void RefreshNodeList() + { + listBoxNodes.DataSource = null; + listBoxNodes.DataSource = _mapNodes; + listBoxNodes.DisplayMember = "Name"; + listBoxNodes.ValueMember = "NodeId"; + } + + private void RefreshRfidMappingList() + { + listBoxRfidMappings.DataSource = null; + listBoxRfidMappings.DataSource = _rfidMappings; + listBoxRfidMappings.DisplayMember = "ToString"; + } + + private void RefreshMapCanvas() + { + _mapCanvas?.Invalidate(); + } + + private void UpdateNodeProperties() + { + if (_selectedNode == null) + { + ClearNodeProperties(); + return; + } + + // 선택된 노드의 속성을 프로퍼티 패널에 표시 + // (실제로는 PropertyGrid나 별도 컨트롤 사용) + labelSelectedNode.Text = $"선택된 노드: {_selectedNode.Name} ({_selectedNode.NodeId})"; + } + + private void ClearNodeProperties() + { + labelSelectedNode.Text = "선택된 노드: 없음"; + } + + private void UpdateTitle() + { + var title = "AGV Map Editor"; + + if (!string.IsNullOrEmpty(_currentMapFile)) + { + title += $" - {Path.GetFileName(_currentMapFile)}"; + } + + if (_hasChanges) + { + title += " *"; + } + + this.Text = title; + } + + #endregion + + #region Form Events + + private void MainForm_FormClosing(object sender, FormClosingEventArgs e) + { + if (!CheckSaveChanges()) + { + e.Cancel = true; + } + } + + #endregion + + #region Data Model for Serialization + + private class MapData + { + public List Nodes { get; set; } = new List(); + public List RfidMappings { get; set; } = new List(); + public DateTime CreatedDate { get; set; } + public string Version { get; set; } = "1.0"; + } + + #endregion + } +} \ No newline at end of file diff --git a/Cs_HMI/AGVMapEditor/Forms/MainForm.resx b/Cs_HMI/AGVMapEditor/Forms/MainForm.resx new file mode 100644 index 0000000..9564200 --- /dev/null +++ b/Cs_HMI/AGVMapEditor/Forms/MainForm.resx @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 17, 17 + + + 132, 17 + + \ No newline at end of file diff --git a/Cs_HMI/AGVMapEditor/Models/Enums.cs b/Cs_HMI/AGVMapEditor/Models/Enums.cs new file mode 100644 index 0000000..741f2f8 --- /dev/null +++ b/Cs_HMI/AGVMapEditor/Models/Enums.cs @@ -0,0 +1,64 @@ +using System; + +namespace AGVMapEditor.Models +{ + /// + /// 노드 타입 열거형 + /// + public enum NodeType + { + /// 일반 경로 노드 + Normal, + /// 회전 가능 지점 + Rotation, + /// 도킹 스테이션 + Docking, + /// 충전 스테이션 + Charging + } + + /// + /// 도킹 방향 열거형 + /// + public enum DockingDirection + { + /// 전진 도킹 (충전기) + Forward, + /// 후진 도킹 (로더, 클리너, 오프로더, 버퍼) + Backward + } + + /// + /// AGV 이동 방향 열거형 + /// + public enum AgvDirection + { + /// 전진 (모니터 방향) + Forward, + /// 후진 (리프트 방향) + Backward, + /// 좌회전 + Left, + /// 우회전 + Right, + /// 정지 + Stop + } + + /// + /// 장비 타입 열거형 + /// + public enum StationType + { + /// 로더 + Loader, + /// 클리너 + Cleaner, + /// 오프로더 + Offloader, + /// 버퍼 + Buffer, + /// 충전기 + Charger + } +} \ No newline at end of file diff --git a/Cs_HMI/AGVMapEditor/Models/MapNode.cs b/Cs_HMI/AGVMapEditor/Models/MapNode.cs new file mode 100644 index 0000000..267509b --- /dev/null +++ b/Cs_HMI/AGVMapEditor/Models/MapNode.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; +using System.Drawing; + +namespace AGVMapEditor.Models +{ + /// + /// 맵 노드 정보를 관리하는 클래스 + /// 논리적 노드로서 실제 맵의 위치와 속성을 정의 + /// + public class MapNode + { + /// + /// 논리적 노드 ID (맵 에디터에서 관리하는 고유 ID) + /// 예: "N001", "N002", "LOADER1", "CHARGER1" + /// + public string NodeId { get; set; } = string.Empty; + + /// + /// 노드 표시 이름 (사용자 친화적) + /// 예: "로더1", "충전기1", "교차점A", "회전지점1" + /// + public string Name { get; set; } = string.Empty; + + /// + /// 맵 상의 위치 좌표 (픽셀 단위) + /// + public Point Position { get; set; } = Point.Empty; + + /// + /// 노드 타입 + /// + public NodeType Type { get; set; } = NodeType.Normal; + + /// + /// 도킹 방향 (도킹/충전 노드인 경우만 사용) + /// + public DockingDirection? DockDirection { get; set; } = null; + + /// + /// 연결된 노드 ID 목록 (경로 정보) + /// + public List ConnectedNodes { get; set; } = new List(); + + /// + /// 회전 가능 여부 (180도 회전 가능한 지점) + /// + public bool CanRotate { get; set; } = false; + + /// + /// 장비 ID (도킹/충전 스테이션인 경우) + /// 예: "LOADER1", "CLEANER1", "BUFFER1", "CHARGER1" + /// + public string StationId { get; set; } = string.Empty; + + /// + /// 장비 타입 (도킹/충전 스테이션인 경우) + /// + public StationType? StationType { get; set; } = null; + + /// + /// 노드 생성 일자 + /// + public DateTime CreatedDate { get; set; } = DateTime.Now; + + /// + /// 노드 수정 일자 + /// + public DateTime ModifiedDate { get; set; } = DateTime.Now; + + /// + /// 노드 설명 (추가 정보) + /// + public string Description { get; set; } = string.Empty; + + /// + /// 노드 활성화 여부 + /// + public bool IsActive { get; set; } = true; + + /// + /// 노드 색상 (맵 에디터 표시용) + /// + public Color DisplayColor { get; set; } = Color.Blue; + + /// + /// 기본 생성자 + /// + public MapNode() + { + } + + /// + /// 매개변수 생성자 + /// + /// 노드 ID + /// 노드 이름 + /// 위치 + /// 노드 타입 + public MapNode(string nodeId, string name, Point position, NodeType type) + { + NodeId = nodeId; + Name = name; + Position = position; + Type = type; + CreatedDate = DateTime.Now; + ModifiedDate = DateTime.Now; + + // 타입별 기본 색상 설정 + SetDefaultColorByType(type); + } + + /// + /// 노드 타입에 따른 기본 색상 설정 + /// + /// 노드 타입 + public void SetDefaultColorByType(NodeType type) + { + switch (type) + { + case NodeType.Normal: + DisplayColor = Color.Blue; + break; + case NodeType.Rotation: + DisplayColor = Color.Orange; + break; + case NodeType.Docking: + DisplayColor = Color.Green; + break; + case NodeType.Charging: + DisplayColor = Color.Red; + break; + } + } + + /// + /// 다른 노드와의 연결 추가 + /// + /// 연결할 노드 ID + public void AddConnection(string nodeId) + { + if (!ConnectedNodes.Contains(nodeId)) + { + ConnectedNodes.Add(nodeId); + ModifiedDate = DateTime.Now; + } + } + + /// + /// 다른 노드와의 연결 제거 + /// + /// 연결 해제할 노드 ID + public void RemoveConnection(string nodeId) + { + if (ConnectedNodes.Remove(nodeId)) + { + ModifiedDate = DateTime.Now; + } + } + + /// + /// 도킹 스테이션 설정 + /// + /// 장비 ID + /// 장비 타입 + /// 도킹 방향 + public void SetDockingStation(string stationId, StationType stationType, DockingDirection dockDirection) + { + Type = NodeType.Docking; + StationId = stationId; + StationType = stationType; + DockDirection = dockDirection; + SetDefaultColorByType(NodeType.Docking); + ModifiedDate = DateTime.Now; + } + + /// + /// 충전 스테이션 설정 + /// + /// 충전기 ID + public void SetChargingStation(string stationId) + { + Type = NodeType.Charging; + StationId = stationId; + StationType = Models.StationType.Charger; + DockDirection = DockingDirection.Forward; // 충전기는 항상 전진 도킹 + SetDefaultColorByType(NodeType.Charging); + ModifiedDate = DateTime.Now; + } + + /// + /// 문자열 표현 + /// + public override string ToString() + { + return $"{NodeId}: {Name} ({Type}) at ({Position.X}, {Position.Y})"; + } + + /// + /// 노드 복사 + /// + /// 복사된 노드 + public MapNode Clone() + { + var clone = new MapNode + { + NodeId = NodeId, + Name = Name, + Position = Position, + Type = Type, + DockDirection = DockDirection, + ConnectedNodes = new List(ConnectedNodes), + CanRotate = CanRotate, + StationId = StationId, + StationType = StationType, + CreatedDate = CreatedDate, + ModifiedDate = ModifiedDate, + Description = Description, + IsActive = IsActive, + DisplayColor = DisplayColor + }; + return clone; + } + } +} \ No newline at end of file diff --git a/Cs_HMI/AGVMapEditor/Models/NodeResolver.cs b/Cs_HMI/AGVMapEditor/Models/NodeResolver.cs new file mode 100644 index 0000000..8b350d7 --- /dev/null +++ b/Cs_HMI/AGVMapEditor/Models/NodeResolver.cs @@ -0,0 +1,308 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace AGVMapEditor.Models +{ + /// + /// RFID 값을 논리적 노드로 변환하는 클래스 + /// 실제 AGV 시스템에서 RFID 리더가 읽은 값을 맵 노드 정보로 변환 + /// + public class NodeResolver + { + private List _rfidMappings; + private List _mapNodes; + + /// + /// 기본 생성자 + /// + public NodeResolver() + { + _rfidMappings = new List(); + _mapNodes = new List(); + } + + /// + /// 매개변수 생성자 + /// + /// RFID 매핑 목록 + /// 맵 노드 목록 + public NodeResolver(List rfidMappings, List mapNodes) + { + _rfidMappings = rfidMappings ?? new List(); + _mapNodes = mapNodes ?? new List(); + } + + /// + /// RFID 값으로 맵 노드 검색 + /// + /// RFID 리더에서 읽은 값 + /// 해당하는 맵 노드, 없으면 null + public MapNode GetNodeByRfid(string rfidValue) + { + if (string.IsNullOrEmpty(rfidValue)) + return null; + + // 1. RFID 매핑에서 논리적 노드 ID 찾기 + var mapping = _rfidMappings.FirstOrDefault(m => + m.RfidId.Equals(rfidValue, StringComparison.OrdinalIgnoreCase) && m.IsActive); + + if (mapping == null) + return null; + + // 2. 논리적 노드 ID로 실제 맵 노드 찾기 + var mapNode = _mapNodes.FirstOrDefault(n => + n.NodeId.Equals(mapping.LogicalNodeId, StringComparison.OrdinalIgnoreCase) && n.IsActive); + + return mapNode; + } + + /// + /// 논리적 노드 ID로 맵 노드 검색 + /// + /// 논리적 노드 ID + /// 해당하는 맵 노드, 없으면 null + public MapNode GetNodeById(string nodeId) + { + if (string.IsNullOrEmpty(nodeId)) + return null; + + return _mapNodes.FirstOrDefault(n => + n.NodeId.Equals(nodeId, StringComparison.OrdinalIgnoreCase) && n.IsActive); + } + + /// + /// 맵 노드로 연결된 RFID 값 검색 + /// + /// 논리적 노드 ID + /// 연결된 RFID 값, 없으면 null + public string GetRfidByNodeId(string nodeId) + { + if (string.IsNullOrEmpty(nodeId)) + return null; + + var mapping = _rfidMappings.FirstOrDefault(m => + m.LogicalNodeId.Equals(nodeId, StringComparison.OrdinalIgnoreCase) && m.IsActive); + + return mapping?.RfidId; + } + + /// + /// RFID 매핑 정보 검색 + /// + /// RFID 값 + /// 매핑 정보, 없으면 null + public RfidMapping GetRfidMapping(string rfidValue) + { + if (string.IsNullOrEmpty(rfidValue)) + return null; + + return _rfidMappings.FirstOrDefault(m => + m.RfidId.Equals(rfidValue, StringComparison.OrdinalIgnoreCase) && m.IsActive); + } + + /// + /// 새로운 RFID 매핑 추가 + /// + /// RFID 값 + /// 논리적 노드 ID + /// 설명 + /// 추가 성공 여부 + public bool AddRfidMapping(string rfidId, string nodeId, string description = "") + { + if (string.IsNullOrEmpty(rfidId) || string.IsNullOrEmpty(nodeId)) + return false; + + // 중복 RFID 체크 + if (_rfidMappings.Any(m => m.RfidId.Equals(rfidId, StringComparison.OrdinalIgnoreCase))) + return false; + + // 해당 노드 존재 체크 + if (!_mapNodes.Any(n => n.NodeId.Equals(nodeId, StringComparison.OrdinalIgnoreCase))) + return false; + + var mapping = new RfidMapping(rfidId, nodeId, description); + _rfidMappings.Add(mapping); + return true; + } + + /// + /// RFID 매핑 제거 + /// + /// 제거할 RFID 값 + /// 제거 성공 여부 + public bool RemoveRfidMapping(string rfidId) + { + if (string.IsNullOrEmpty(rfidId)) + return false; + + var mapping = _rfidMappings.FirstOrDefault(m => + m.RfidId.Equals(rfidId, StringComparison.OrdinalIgnoreCase)); + + if (mapping != null) + { + _rfidMappings.Remove(mapping); + return true; + } + + return false; + } + + /// + /// 맵 노드 추가 + /// + /// 추가할 맵 노드 + /// 추가 성공 여부 + public bool AddMapNode(MapNode node) + { + if (node == null || string.IsNullOrEmpty(node.NodeId)) + return false; + + // 중복 노드 ID 체크 + if (_mapNodes.Any(n => n.NodeId.Equals(node.NodeId, StringComparison.OrdinalIgnoreCase))) + return false; + + _mapNodes.Add(node); + return true; + } + + /// + /// 맵 노드 제거 + /// + /// 제거할 노드 ID + /// 제거 성공 여부 + public bool RemoveMapNode(string nodeId) + { + if (string.IsNullOrEmpty(nodeId)) + return false; + + var node = _mapNodes.FirstOrDefault(n => + n.NodeId.Equals(nodeId, StringComparison.OrdinalIgnoreCase)); + + if (node != null) + { + // 연관된 RFID 매핑도 함께 제거 + var associatedMappings = _rfidMappings.Where(m => + m.LogicalNodeId.Equals(nodeId, StringComparison.OrdinalIgnoreCase)).ToList(); + + foreach (var mapping in associatedMappings) + { + _rfidMappings.Remove(mapping); + } + + // 다른 노드의 연결 정보에서도 제거 + foreach (var otherNode in _mapNodes.Where(n => n.ConnectedNodes.Contains(nodeId))) + { + otherNode.RemoveConnection(nodeId); + } + + _mapNodes.Remove(node); + return true; + } + + return false; + } + + /// + /// 특정 타입의 노드들 검색 + /// + /// 노드 타입 + /// 해당 타입의 노드 목록 + public List GetNodesByType(NodeType nodeType) + { + return _mapNodes.Where(n => n.Type == nodeType && n.IsActive).ToList(); + } + + /// + /// 장비 ID로 노드 검색 + /// + /// 장비 ID + /// 해당 장비의 노드, 없으면 null + public MapNode GetNodeByStationId(string stationId) + { + if (string.IsNullOrEmpty(stationId)) + return null; + + return _mapNodes.FirstOrDefault(n => + n.StationId.Equals(stationId, StringComparison.OrdinalIgnoreCase) && n.IsActive); + } + + /// + /// 매핑되지 않은 노드들 검색 (RFID가 연결되지 않은 노드) + /// + /// 매핑되지 않은 노드 목록 + public List GetUnmappedNodes() + { + var mappedNodeIds = _rfidMappings.Where(m => m.IsActive) + .Select(m => m.LogicalNodeId) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + return _mapNodes.Where(n => n.IsActive && !mappedNodeIds.Contains(n.NodeId)).ToList(); + } + + /// + /// 사용되지 않는 RFID 매핑들 검색 (노드가 삭제된 매핑) + /// + /// 사용되지 않는 매핑 목록 + public List GetOrphanedMappings() + { + var activeNodeIds = _mapNodes.Where(n => n.IsActive) + .Select(n => n.NodeId) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + return _rfidMappings.Where(m => m.IsActive && !activeNodeIds.Contains(m.LogicalNodeId)).ToList(); + } + + /// + /// 데이터 초기화 + /// + public void Clear() + { + _rfidMappings.Clear(); + _mapNodes.Clear(); + } + + /// + /// 데이터 유효성 검증 + /// + /// 검증 결과 메시지 목록 + public List ValidateData() + { + var errors = new List(); + + // 중복 RFID 체크 + var duplicateRfids = _rfidMappings.GroupBy(m => m.RfidId.ToLower()) + .Where(g => g.Count() > 1) + .Select(g => g.Key); + foreach (var rfid in duplicateRfids) + { + errors.Add($"중복된 RFID: {rfid}"); + } + + // 중복 노드 ID 체크 + var duplicateNodeIds = _mapNodes.GroupBy(n => n.NodeId.ToLower()) + .Where(g => g.Count() > 1) + .Select(g => g.Key); + foreach (var nodeId in duplicateNodeIds) + { + errors.Add($"중복된 노드 ID: {nodeId}"); + } + + // 고아 매핑 체크 + var orphanedMappings = GetOrphanedMappings(); + foreach (var mapping in orphanedMappings) + { + errors.Add($"존재하지 않는 노드를 참조하는 RFID 매핑: {mapping.RfidId} → {mapping.LogicalNodeId}"); + } + + // 매핑되지 않은 노드 경고 (에러는 아님) + var unmappedNodes = GetUnmappedNodes(); + foreach (var node in unmappedNodes) + { + errors.Add($"RFID가 매핑되지 않은 노드: {node.NodeId} ({node.Name})"); + } + + return errors; + } + } +} \ No newline at end of file diff --git a/Cs_HMI/AGVMapEditor/Models/PathCalculator.cs b/Cs_HMI/AGVMapEditor/Models/PathCalculator.cs new file mode 100644 index 0000000..e702140 --- /dev/null +++ b/Cs_HMI/AGVMapEditor/Models/PathCalculator.cs @@ -0,0 +1,469 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.Linq; + +namespace AGVMapEditor.Models +{ + /// + /// AGV 전용 경로 계산기 (A* 알고리즘 기반) + /// AGV의 방향성, 도킹 제약, 회전 제약을 고려한 경로 계산 + /// + public class PathCalculator + { + #region Constants + + private const float BASE_MOVE_COST = 1.0f; // 기본 이동 비용 + private const float ROTATION_COST = 0.5f; // 회전 비용 + private const float DOCKING_APPROACH_COST = 0.2f; // 도킹 접근 추가 비용 + private const float HEURISTIC_WEIGHT = 1.0f; // 휴리스틱 가중치 + + #endregion + + #region Fields + + private List _mapNodes; + private NodeResolver _nodeResolver; + + #endregion + + #region Constructor + + /// + /// 생성자 + /// + /// 맵 노드 목록 + /// 노드 해결기 + public PathCalculator(List mapNodes, NodeResolver nodeResolver) + { + _mapNodes = mapNodes ?? throw new ArgumentNullException(nameof(mapNodes)); + _nodeResolver = nodeResolver ?? throw new ArgumentNullException(nameof(nodeResolver)); + } + + #endregion + + #region Public Methods + + /// + /// 경로 계산 (메인 메서드) + /// + /// 시작 노드 ID + /// 목표 노드 ID + /// 현재 AGV 방향 + /// 경로 계산 결과 + public PathResult CalculatePath(string startNodeId, string targetNodeId, AgvDirection currentDirection) + { + var stopwatch = Stopwatch.StartNew(); + + try + { + // 입력 검증 + var validationResult = ValidateInput(startNodeId, targetNodeId, currentDirection); + if (!validationResult.Success) + { + stopwatch.Stop(); + validationResult.CalculationTime = stopwatch.ElapsedMilliseconds; + return validationResult; + } + + // A* 알고리즘 실행 + var result = ExecuteAStar(startNodeId, targetNodeId, currentDirection); + + stopwatch.Stop(); + result.CalculationTime = stopwatch.ElapsedMilliseconds; + + return result; + } + catch (Exception ex) + { + stopwatch.Stop(); + return new PathResult($"경로 계산 중 오류 발생: {ex.Message}") + { + CalculationTime = stopwatch.ElapsedMilliseconds + }; + } + } + + /// + /// 경로 유효성 검증 (RFID 이탈 감지시 사용) + /// + /// 현재 경로 + /// 현재 감지된 RFID + /// 경로 유효성 여부 + public bool ValidateCurrentPath(PathResult currentPath, string currentRfidId) + { + if (currentPath == null || !currentPath.Success) + return false; + + var currentNode = _nodeResolver.GetNodeByRfid(currentRfidId); + if (currentNode == null) + return false; + + // 현재 노드가 계획된 경로에 포함되어 있는지 확인 + return currentPath.NodeSequence.Contains(currentNode.NodeId); + } + + /// + /// 동적 경로 재계산 (경로 이탈시 사용) + /// + /// 현재 RFID 위치 + /// 목표 노드 ID + /// 현재 방향 + /// 원래 경로 (참고용) + /// 새로운 경로 + public PathResult RecalculatePath(string currentRfidId, string targetNodeId, + AgvDirection currentDirection, PathResult originalPath = null) + { + var currentNode = _nodeResolver.GetNodeByRfid(currentRfidId); + if (currentNode == null) + { + return new PathResult("현재 위치를 확인할 수 없습니다."); + } + + // 새로운 경로 계산 + var result = CalculatePath(currentNode.NodeId, targetNodeId, currentDirection); + + // 원래 경로와 비교 (로그용) + if (originalPath != null && result.Success) + { + // TODO: 경로 변경 로그 기록 + } + + return result; + } + + #endregion + + #region Private Methods - Input Validation + + /// + /// 입력 값 검증 + /// + private PathResult ValidateInput(string startNodeId, string targetNodeId, AgvDirection currentDirection) + { + if (string.IsNullOrEmpty(startNodeId)) + return new PathResult("시작 노드가 지정되지 않았습니다."); + + if (string.IsNullOrEmpty(targetNodeId)) + return new PathResult("목표 노드가 지정되지 않았습니다."); + + var startNode = _mapNodes.FirstOrDefault(n => n.NodeId == startNodeId); + if (startNode == null) + return new PathResult($"시작 노드를 찾을 수 없습니다: {startNodeId}"); + + var targetNode = _mapNodes.FirstOrDefault(n => n.NodeId == targetNodeId); + if (targetNode == null) + return new PathResult($"목표 노드를 찾을 수 없습니다: {targetNodeId}"); + + if (startNodeId == targetNodeId) + return new PathResult("시작점과 목표점이 동일합니다."); + + return new PathResult { Success = true }; + } + + #endregion + + #region Private Methods - A* Algorithm + + /// + /// A* 알고리즘 실행 + /// + private PathResult ExecuteAStar(string startNodeId, string targetNodeId, AgvDirection currentDirection) + { + var openSet = new SortedSet(); + var closedSet = new HashSet(); + var gScore = new Dictionary(); + + // 시작 노드 설정 + var startPathNode = new PathNode(startNodeId, currentDirection) + { + GCost = 0, + HCost = CalculateHeuristic(startNodeId, targetNodeId) + }; + + openSet.Add(startPathNode); + gScore[startPathNode.GetKey()] = 0; + + while (openSet.Count > 0) + { + // 가장 낮은 F 비용을 가진 노드 선택 + var current = openSet.Min; + openSet.Remove(current); + + // 목표 도달 확인 + if (current.NodeId == targetNodeId) + { + // 도킹 방향 검증 + if (IsValidDockingApproach(targetNodeId, current.Direction)) + { + return ReconstructPath(current, startNodeId, targetNodeId, currentDirection); + } + // 도킹 방향이 맞지 않으면 계속 탐색 + } + + closedSet.Add(current.GetKey()); + + // 인접 노드들 처리 + ProcessNeighbors(current, targetNodeId, openSet, closedSet, gScore); + } + + return new PathResult("경로를 찾을 수 없습니다."); + } + + /// + /// 인접 노드들 처리 + /// + private void ProcessNeighbors(PathNode current, string targetNodeId, + SortedSet openSet, HashSet closedSet, + Dictionary gScore) + { + var currentMapNode = _mapNodes.FirstOrDefault(n => n.NodeId == current.NodeId); + if (currentMapNode == null) return; + + foreach (var neighborId in currentMapNode.ConnectedNodes) + { + var neighborMapNode = _mapNodes.FirstOrDefault(n => n.NodeId == neighborId); + if (neighborMapNode == null) continue; + + // 가능한 모든 방향으로 이웃 노드 방문 + foreach (var direction in GetPossibleDirections(current, neighborMapNode)) + { + var neighborPathNode = new PathNode(neighborId, direction); + var neighborKey = neighborPathNode.GetKey(); + + if (closedSet.Contains(neighborKey)) + continue; + + // 이동 비용 계산 + var moveCost = CalculateMoveCost(current, neighborPathNode, neighborMapNode); + var tentativeGScore = current.GCost + moveCost; + + // 더 좋은 경로인지 확인 + if (!gScore.ContainsKey(neighborKey) || tentativeGScore < gScore[neighborKey]) + { + // 경로 정보 업데이트 + neighborPathNode.Parent = current; + neighborPathNode.GCost = tentativeGScore; + neighborPathNode.HCost = CalculateHeuristic(neighborId, targetNodeId); + neighborPathNode.RotationCount = current.RotationCount + + (current.Direction != direction ? 1 : 0); + + // 이동 명령 시퀀스 구성 + neighborPathNode.MovementSequence = GenerateMovementSequence(current, neighborPathNode); + + gScore[neighborKey] = tentativeGScore; + + // openSet에 추가 (중복 제거) + openSet.RemoveWhere(n => n.GetKey() == neighborKey); + openSet.Add(neighborPathNode); + } + } + } + } + + /// + /// 가능한 방향들 계산 + /// + private List GetPossibleDirections(PathNode current, MapNode neighborNode) + { + var directions = new List(); + + // 기본적으로 전진/후진 가능 + directions.Add(AgvDirection.Forward); + directions.Add(AgvDirection.Backward); + + // 회전 가능한 노드에서만 방향 전환 가능 + if (CanRotateAt(current.NodeId)) + { + // 현재 방향과 다른 방향도 고려 + if (current.Direction == AgvDirection.Forward) + directions.Add(AgvDirection.Backward); + else if (current.Direction == AgvDirection.Backward) + directions.Add(AgvDirection.Forward); + } + + return directions; + } + + /// + /// 이동 비용 계산 + /// + private float CalculateMoveCost(PathNode from, PathNode to, MapNode toMapNode) + { + float cost = BASE_MOVE_COST; + + // 방향 전환 비용 + if (from.Direction != to.Direction) + { + cost += ROTATION_COST; + } + + // 도킹 스테이션 접근 비용 + if (toMapNode.Type == NodeType.Docking || toMapNode.Type == NodeType.Charging) + { + cost += DOCKING_APPROACH_COST; + } + + // 실제 거리 기반 비용 (좌표가 있는 경우) + var fromMapNode = _mapNodes.FirstOrDefault(n => n.NodeId == from.NodeId); + if (fromMapNode != null && toMapNode != null) + { + var distance = CalculateDistance(fromMapNode.Position, toMapNode.Position); + cost *= (distance / 100.0f); // 좌표 단위를 거리 단위로 조정 + } + + return cost; + } + + /// + /// 휴리스틱 함수 (목표까지의 추정 거리) + /// + private float CalculateHeuristic(string fromNodeId, string toNodeId) + { + var fromNode = _mapNodes.FirstOrDefault(n => n.NodeId == fromNodeId); + var toNode = _mapNodes.FirstOrDefault(n => n.NodeId == toNodeId); + + if (fromNode == null || toNode == null) + return 0; + + // 유클리드 거리 계산 + var distance = CalculateDistance(fromNode.Position, toNode.Position); + return distance * HEURISTIC_WEIGHT / 100.0f; // 좌표 단위 조정 + } + + /// + /// 두 점 사이의 거리 계산 + /// + 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 bool CanRotateAt(string nodeId) + { + var node = _mapNodes.FirstOrDefault(n => n.NodeId == nodeId); + return node != null && (node.CanRotate || node.Type == NodeType.Rotation); + } + + /// + /// 도킹 접근 방향이 유효한지 확인 + /// + private bool IsValidDockingApproach(string nodeId, AgvDirection approachDirection) + { + var node = _mapNodes.FirstOrDefault(n => n.NodeId == nodeId); + if (node == null) return true; + + // 도킹/충전 스테이션이 아니면 방향 제약 없음 + if (node.Type != NodeType.Docking && node.Type != NodeType.Charging) + return true; + + // 도킹 방향 확인 + if (node.DockDirection == null) + return true; + + // 충전기는 전진으로만, 다른 장비는 후진으로만 + if (node.Type == NodeType.Charging) + return approachDirection == AgvDirection.Forward; + else + return approachDirection == AgvDirection.Backward; + } + + /// + /// 이동 명령 시퀀스 생성 + /// + private List GenerateMovementSequence(PathNode from, PathNode to) + { + var sequence = new List(); + + // 방향 전환이 필요한 경우 + if (from.Direction != to.Direction) + { + if (from.Direction == AgvDirection.Forward && to.Direction == AgvDirection.Backward) + { + sequence.Add(AgvDirection.Right); // 180도 회전 (마크센서까지) + } + else if (from.Direction == AgvDirection.Backward && to.Direction == AgvDirection.Forward) + { + sequence.Add(AgvDirection.Right); // 180도 회전 (마크센서까지) + } + } + + // 이동 명령 + sequence.Add(to.Direction); + + return sequence; + } + + /// + /// 경로 재구성 + /// + private PathResult ReconstructPath(PathNode goalNode, string startNodeId, string targetNodeId, AgvDirection startDirection) + { + var path = new List(); + var current = goalNode; + + // 역순으로 경로 구성 + while (current != null) + { + path.Insert(0, current); + current = current.Parent; + } + + return new PathResult(path, startNodeId, targetNodeId, startDirection); + } + + #endregion + + #region Public Utility Methods + + /// + /// 노드 간 직선 거리 계산 + /// + public float GetDistance(string fromNodeId, string toNodeId) + { + var fromNode = _mapNodes.FirstOrDefault(n => n.NodeId == fromNodeId); + var toNode = _mapNodes.FirstOrDefault(n => n.NodeId == toNodeId); + + if (fromNode == null || toNode == null) + return float.MaxValue; + + return CalculateDistance(fromNode.Position, toNode.Position); + } + + /// + /// 특정 노드에서 가능한 다음 노드들 조회 + /// + public List GetPossibleNextNodes(string currentNodeId, AgvDirection currentDirection) + { + var currentNode = _mapNodes.FirstOrDefault(n => n.NodeId == currentNodeId); + if (currentNode == null) + return new List(); + + return currentNode.ConnectedNodes.ToList(); + } + + /// + /// 경로 최적화 (선택적 기능) + /// + public PathResult OptimizePath(PathResult originalPath) + { + if (originalPath == null || !originalPath.Success) + return originalPath; + + // TODO: 경로 최적화 로직 구현 + // - 불필요한 중간 정지점 제거 + // - 회전 최소화 + // - 경로 단순화 + + return originalPath; + } + + #endregion + } +} \ No newline at end of file diff --git a/Cs_HMI/AGVMapEditor/Models/PathNode.cs b/Cs_HMI/AGVMapEditor/Models/PathNode.cs new file mode 100644 index 0000000..983cde8 --- /dev/null +++ b/Cs_HMI/AGVMapEditor/Models/PathNode.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; + +namespace AGVMapEditor.Models +{ + /// + /// A* 알고리즘에서 사용되는 경로 노드 + /// + public class PathNode : IComparable + { + /// + /// 맵 노드 ID + /// + public string NodeId { get; set; } = string.Empty; + + /// + /// AGV의 현재 방향 (이 노드에 도달했을 때의 방향) + /// + public AgvDirection Direction { get; set; } = AgvDirection.Forward; + + /// + /// 시작점에서 이 노드까지의 실제 비용 (G) + /// + public float GCost { get; set; } = float.MaxValue; + + /// + /// 이 노드에서 목표까지의 추정 비용 (H) + /// + public float HCost { get; set; } = 0; + + /// + /// 총 비용 (F = G + H) + /// + public float FCost => GCost + HCost; + + /// + /// 이전 노드 (경로 추적용) + /// + public PathNode Parent { get; set; } = null; + + /// + /// 회전 횟수 (방향 전환 비용 계산용) + /// + public int RotationCount { get; set; } = 0; + + /// + /// 이 노드에 도달하기 위한 이동 명령 시퀀스 + /// + public List MovementSequence { get; set; } = new List(); + + /// + /// 기본 생성자 + /// + public PathNode() + { + } + + /// + /// 매개변수 생성자 + /// + /// 노드 ID + /// AGV 방향 + public PathNode(string nodeId, AgvDirection direction) + { + NodeId = nodeId; + Direction = direction; + } + + /// + /// 우선순위 큐를 위한 비교 (FCost 기준) + /// + public int CompareTo(PathNode other) + { + if (other == null) return 1; + + int compare = FCost.CompareTo(other.FCost); + if (compare == 0) + { + // FCost가 같으면 HCost가 낮은 것을 우선 + compare = HCost.CompareTo(other.HCost); + } + if (compare == 0) + { + // 그것도 같으면 회전 횟수가 적은 것을 우선 + compare = RotationCount.CompareTo(other.RotationCount); + } + + return compare; + } + + /// + /// 노드 상태 복사 + /// + public PathNode Clone() + { + return new PathNode + { + NodeId = NodeId, + Direction = Direction, + GCost = GCost, + HCost = HCost, + Parent = Parent, + RotationCount = RotationCount, + MovementSequence = new List(MovementSequence) + }; + } + + /// + /// 고유 키 생성 (노드ID + 방향) + /// + public string GetKey() + { + return $"{NodeId}_{Direction}"; + } + + /// + /// 문자열 표현 + /// + public override string ToString() + { + return $"{NodeId}({Direction}) F:{FCost:F1} G:{GCost:F1} H:{HCost:F1} R:{RotationCount}"; + } + + /// + /// 해시코드 (딕셔너리 키용) + /// + public override int GetHashCode() + { + return GetKey().GetHashCode(); + } + + /// + /// 동등성 비교 + /// + public override bool Equals(object obj) + { + if (obj is PathNode other) + { + return NodeId == other.NodeId && Direction == other.Direction; + } + return false; + } + } +} \ No newline at end of file diff --git a/Cs_HMI/AGVMapEditor/Models/PathResult.cs b/Cs_HMI/AGVMapEditor/Models/PathResult.cs new file mode 100644 index 0000000..993c2c7 --- /dev/null +++ b/Cs_HMI/AGVMapEditor/Models/PathResult.cs @@ -0,0 +1,277 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace AGVMapEditor.Models +{ + /// + /// 경로 계산 결과 + /// + public class PathResult + { + /// + /// 경로 계산 성공 여부 + /// + public bool Success { get; set; } = false; + + /// + /// 경로상의 노드 ID 시퀀스 + /// + public List NodeSequence { get; set; } = new List(); + + /// + /// AGV 이동 명령 시퀀스 + /// + public List MovementSequence { get; set; } = new List(); + + /// + /// 총 이동 거리 (비용) + /// + public float TotalDistance { get; set; } = 0; + + /// + /// 총 회전 횟수 + /// + public int TotalRotations { get; set; } = 0; + + /// + /// 예상 소요 시간 (초) + /// + public float EstimatedTime { get; set; } = 0; + + /// + /// 시작 노드 ID + /// + public string StartNodeId { get; set; } = string.Empty; + + /// + /// 목표 노드 ID + /// + public string TargetNodeId { get; set; } = string.Empty; + + /// + /// 시작시 AGV 방향 + /// + public AgvDirection StartDirection { get; set; } = AgvDirection.Forward; + + /// + /// 도착시 AGV 방향 + /// + public AgvDirection EndDirection { get; set; } = AgvDirection.Forward; + + /// + /// 경로 계산에 걸린 시간 (밀리초) + /// + public long CalculationTime { get; set; } = 0; + + /// + /// 오류 메시지 (실패시) + /// + public string ErrorMessage { get; set; } = string.Empty; + + /// + /// 경로상의 상세 정보 (디버깅용) + /// + public List DetailedPath { get; set; } = new List(); + + /// + /// 회전이 발생하는 노드들 + /// + public List RotationNodes { get; set; } = new List(); + + /// + /// 기본 생성자 + /// + public PathResult() + { + } + + /// + /// 성공 결과 생성자 + /// + public PathResult(List path, string startNodeId, string targetNodeId, AgvDirection startDirection) + { + if (path == null || path.Count == 0) + { + Success = false; + ErrorMessage = "빈 경로입니다."; + return; + } + + Success = true; + StartNodeId = startNodeId; + TargetNodeId = targetNodeId; + StartDirection = startDirection; + DetailedPath = new List(path); + + // 노드 시퀀스 구성 + NodeSequence = path.Select(p => p.NodeId).ToList(); + + // 이동 명령 시퀀스 구성 + MovementSequence = new List(); + for (int i = 0; i < path.Count; i++) + { + MovementSequence.AddRange(path[i].MovementSequence); + } + + // 통계 계산 + if (path.Count > 0) + { + TotalDistance = path[path.Count - 1].GCost; + EndDirection = path[path.Count - 1].Direction; + } + + TotalRotations = MovementSequence.Count(cmd => + cmd == AgvDirection.Left || cmd == AgvDirection.Right); + + // 회전 노드 추출 + var previousDirection = startDirection; + for (int i = 0; i < path.Count; i++) + { + if (path[i].Direction != previousDirection) + { + RotationNodes.Add(path[i].NodeId); + } + previousDirection = path[i].Direction; + } + + // 예상 소요 시간 계산 (단순 추정) + EstimatedTime = CalculateEstimatedTime(); + } + + /// + /// 실패 결과 생성자 + /// + public PathResult(string errorMessage) + { + Success = false; + ErrorMessage = errorMessage; + } + + /// + /// 예상 소요 시간 계산 + /// + private float CalculateEstimatedTime() + { + // 기본 이동 속도 및 회전 시간 가정 + const float MOVE_SPEED = 1.0f; // 단위/초 + const float ROTATION_TIME = 2.0f; // 초/회전 + + float moveTime = TotalDistance / MOVE_SPEED; + float rotationTime = TotalRotations * ROTATION_TIME; + + return moveTime + rotationTime; + } + + /// + /// 경로 요약 정보 + /// + public string GetSummary() + { + if (!Success) + { + return $"경로 계산 실패: {ErrorMessage}"; + } + + return $"경로: {NodeSequence.Count}개 노드, " + + $"거리: {TotalDistance:F1}, " + + $"회전: {TotalRotations}회, " + + $"예상시간: {EstimatedTime:F1}초"; + } + + /// + /// 상세 경로 정보 + /// + public List GetDetailedSteps() + { + var steps = new List(); + + if (!Success) + { + steps.Add($"경로 계산 실패: {ErrorMessage}"); + return steps; + } + + steps.Add($"시작: {StartNodeId} (방향: {StartDirection})"); + + for (int i = 0; i < DetailedPath.Count; i++) + { + var node = DetailedPath[i]; + var step = $"{i + 1}. {node.NodeId}"; + + if (node.MovementSequence.Count > 0) + { + step += $" [명령: {string.Join(",", node.MovementSequence)}]"; + } + + step += $" (F:{node.FCost:F1}, 방향:{node.Direction})"; + steps.Add(step); + } + + steps.Add($"도착: {TargetNodeId} (최종 방향: {EndDirection})"); + + return steps; + } + + /// + /// RFID 시퀀스 추출 (실제 AGV 제어용) + /// + public List GetRfidSequence(NodeResolver nodeResolver) + { + var rfidSequence = new List(); + + foreach (var nodeId in NodeSequence) + { + var rfidId = nodeResolver.GetRfidByNodeId(nodeId); + if (!string.IsNullOrEmpty(rfidId)) + { + rfidSequence.Add(rfidId); + } + } + + return rfidSequence; + } + + /// + /// 경로 유효성 검증 + /// + public bool ValidatePath(List mapNodes) + { + if (!Success || NodeSequence.Count == 0) + return false; + + // 모든 노드가 존재하는지 확인 + foreach (var nodeId in NodeSequence) + { + if (!mapNodes.Any(n => n.NodeId == nodeId)) + { + ErrorMessage = $"존재하지 않는 노드: {nodeId}"; + return false; + } + } + + // 연결성 확인 + for (int i = 0; i < NodeSequence.Count - 1; i++) + { + var currentNode = mapNodes.FirstOrDefault(n => n.NodeId == NodeSequence[i]); + var nextNodeId = NodeSequence[i + 1]; + + if (currentNode != null && !currentNode.ConnectedNodes.Contains(nextNodeId)) + { + ErrorMessage = $"연결되지 않은 노드: {currentNode.NodeId} → {nextNodeId}"; + return false; + } + } + + return true; + } + + /// + /// JSON 직렬화를 위한 문자열 변환 + /// + public override string ToString() + { + return GetSummary(); + } + } +} \ No newline at end of file diff --git a/Cs_HMI/AGVMapEditor/Models/RfidMapping.cs b/Cs_HMI/AGVMapEditor/Models/RfidMapping.cs new file mode 100644 index 0000000..9f09b5f --- /dev/null +++ b/Cs_HMI/AGVMapEditor/Models/RfidMapping.cs @@ -0,0 +1,79 @@ +using System; + +namespace AGVMapEditor.Models +{ + /// + /// RFID와 논리적 노드 ID를 매핑하는 클래스 + /// 물리적 RFID는 의미없는 고유값, 논리적 노드는 맵 에디터에서 관리 + /// + public class RfidMapping + { + /// + /// 물리적 RFID 값 (의미 없는 고유 식별자) + /// 예: "1234567890", "ABCDEF1234" 등 + /// + public string RfidId { get; set; } = string.Empty; + + /// + /// 논리적 노드 ID (맵 에디터에서 관리) + /// 예: "N001", "N002", "LOADER1", "CHARGER1" 등 + /// + public string LogicalNodeId { get; set; } = string.Empty; + + /// + /// 매핑 생성 일자 + /// + public DateTime CreatedDate { get; set; } = DateTime.Now; + + /// + /// 마지막 수정 일자 + /// + public DateTime ModifiedDate { get; set; } = DateTime.Now; + + /// + /// 설치 위치 설명 (현장 작업자용) + /// 예: "로더1번 앞", "충전기2번 입구", "복도 교차점" 등 + /// + public string Description { get; set; } = string.Empty; + + /// + /// RFID 상태 (정상, 손상, 교체예정 등) + /// + public string Status { get; set; } = "정상"; + + /// + /// 매핑 활성화 여부 + /// + public bool IsActive { get; set; } = true; + + /// + /// 기본 생성자 + /// + public RfidMapping() + { + } + + /// + /// 매개변수 생성자 + /// + /// 물리적 RFID ID + /// 논리적 노드 ID + /// 설치 위치 설명 + public RfidMapping(string rfidId, string logicalNodeId, string description = "") + { + RfidId = rfidId; + LogicalNodeId = logicalNodeId; + Description = description; + CreatedDate = DateTime.Now; + ModifiedDate = DateTime.Now; + } + + /// + /// 문자열 표현 + /// + public override string ToString() + { + return $"{RfidId} → {LogicalNodeId} ({Description})"; + } + } +} \ No newline at end of file diff --git a/Cs_HMI/AGVMapEditor/Program.cs b/Cs_HMI/AGVMapEditor/Program.cs new file mode 100644 index 0000000..4d11b9e --- /dev/null +++ b/Cs_HMI/AGVMapEditor/Program.cs @@ -0,0 +1,26 @@ +using System; +using System.Windows.Forms; +using AGVMapEditor.Forms; + +namespace AGVMapEditor +{ + /// + /// 애플리케이션 진입점 + /// + internal static class Program + { + /// + /// 애플리케이션의 기본 진입점입니다. + /// + [STAThread] + static void Main() + { + // Windows Forms 애플리케이션 초기화 + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + + // 메인 폼 실행 + Application.Run(new MainForm()); + } + } +} \ No newline at end of file diff --git a/Cs_HMI/AGVMapEditor/Properties/AssemblyInfo.cs b/Cs_HMI/AGVMapEditor/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..7ef9156 --- /dev/null +++ b/Cs_HMI/AGVMapEditor/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// 어셈블리에 대한 일반 정보는 다음 특성 집합을 통해 +// 제어됩니다. 어셈블리와 관련된 정보를 수정하려면 +// 이러한 특성 값을 변경하세요. +[assembly: AssemblyTitle("AGV Map Editor")] +[assembly: AssemblyDescription("AGV Navigation Map Editor Tool")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("ENIG AGV")] +[assembly: AssemblyProduct("AGV Map Editor")] +[assembly: AssemblyCopyright("Copyright © ENIG AGV 2025")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// ComVisible을 false로 설정하면 이 어셈블리의 형식이 COM 구성 요소에 +// 표시되지 않습니다. COM에서 이 어셈블리의 형식에 액세스하려면 +// 해당 형식에 대해 ComVisible 특성을 true로 설정하세요. +[assembly: ComVisible(false)] + +// 이 프로젝트가 COM에 노출되는 경우 다음 GUID는 typelib의 ID를 나타냅니다. +[assembly: Guid("a1b2c3d4-e5f6-7890-abcd-ef1234567890")] + +// 어셈블리의 버전 정보는 다음 네 개의 값으로 구성됩니다. +// +// 주 버전 +// 부 버전 +// 빌드 번호 +// 수정 버전 +// +// 모든 값을 지정하거나 아래와 같이 '*'를 사용하여 빌드 번호 및 수정 번호를 +// 기본값으로 할 수 있습니다. +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] \ No newline at end of file diff --git a/Cs_HMI/AGVMapEditor/packages.config b/Cs_HMI/AGVMapEditor/packages.config new file mode 100644 index 0000000..8b1a8d0 --- /dev/null +++ b/Cs_HMI/AGVMapEditor/packages.config @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/Cs_HMI/AGVSimulator/AGVSimulator.csproj b/Cs_HMI/AGVSimulator/AGVSimulator.csproj new file mode 100644 index 0000000..c9f0cdc --- /dev/null +++ b/Cs_HMI/AGVSimulator/AGVSimulator.csproj @@ -0,0 +1,80 @@ + + + + + Debug + AnyCPU + {B2C3D4E5-F6G7-8901-BCDE-F23456789012} + WinExe + AGVSimulator + AGVSimulator + v4.8 + 512 + true + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + ..\packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll + + + + + + + UserControl + + + SimulatorCanvas.cs + + + Form + + + SimulatorForm.cs + + + + + + + SimulatorCanvas.cs + + + SimulatorForm.cs + + + + + + + + {a1b2c3d4-e5f6-7890-abcd-ef1234567890} + AGVMapEditor + + + + \ No newline at end of file diff --git a/Cs_HMI/AGVSimulator/Controls/SimulatorCanvas.Designer.cs b/Cs_HMI/AGVSimulator/Controls/SimulatorCanvas.Designer.cs new file mode 100644 index 0000000..1a38fbb --- /dev/null +++ b/Cs_HMI/AGVSimulator/Controls/SimulatorCanvas.Designer.cs @@ -0,0 +1,52 @@ +namespace AGVSimulator.Controls +{ + partial class SimulatorCanvas + { + /// + /// 필수 디자이너 변수입니다. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// 사용 중인 모든 리소스를 정리합니다. + /// + /// 관리되는 리소스를 삭제해야 하면 true이고, 그렇지 않으면 false입니다. + protected override void Dispose(bool disposing) + { + if (disposing) + { + if (components != null) + { + components.Dispose(); + } + + // 커스텀 리소스 정리 + CleanupResources(); + } + base.Dispose(disposing); + } + + #region 구성 요소 디자이너에서 생성한 코드 + + /// + /// 디자이너 지원에 필요한 메서드입니다. + /// 이 메서드의 내용을 코드 편집기로 수정하지 마세요. + /// + private void InitializeComponent() + { + this.SuspendLayout(); + // + // SimulatorCanvas + // + this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 12F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.BackColor = System.Drawing.Color.White; + this.Name = "SimulatorCanvas"; + this.Size = new System.Drawing.Size(800, 600); + this.ResumeLayout(false); + + } + + #endregion + } +} \ No newline at end of file diff --git a/Cs_HMI/AGVSimulator/Controls/SimulatorCanvas.cs b/Cs_HMI/AGVSimulator/Controls/SimulatorCanvas.cs new file mode 100644 index 0000000..dfd2402 --- /dev/null +++ b/Cs_HMI/AGVSimulator/Controls/SimulatorCanvas.cs @@ -0,0 +1,620 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Windows.Forms; +using AGVMapEditor.Models; +using AGVSimulator.Models; + +namespace AGVSimulator.Controls +{ + /// + /// AGV 시뮬레이션 시각화 캔버스 + /// + public partial class SimulatorCanvas : UserControl + { + #region Fields + + private List _mapNodes; + private List _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 + + /// + /// 맵 노드 목록 + /// + public List MapNodes + { + get => _mapNodes; + set + { + _mapNodes = value; + Invalidate(); + } + } + + /// + /// AGV 목록 + /// + public List AGVList + { + get => _agvList; + set + { + _agvList = value; + Invalidate(); + } + } + + /// + /// 현재 경로 + /// + 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(); + _agvList = new List(); + + 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 + + /// + /// AGV 추가 + /// + public void AddAGV(VirtualAGV agv) + { + if (_agvList == null) + _agvList = new List(); + + _agvList.Add(agv); + + // AGV 이벤트 연결 + agv.PositionChanged += OnAGVPositionChanged; + agv.StateChanged += OnAGVStateChanged; + + Invalidate(); + } + + /// + /// AGV 제거 + /// + 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(); + } + } + + /// + /// 모든 AGV 제거 + /// + public void ClearAGVs() + { + if (_agvList != null) + { + foreach (var agv in _agvList) + { + agv.PositionChanged -= OnAGVPositionChanged; + agv.StateChanged -= OnAGVStateChanged; + } + _agvList.Clear(); + Invalidate(); + } + } + + /// + /// 확대/축소 초기화 + /// + public void ResetZoom() + { + _zoom = 1.0f; + _panOffset = Point.Empty; + Invalidate(); + } + + /// + /// 맵 전체 맞춤 + /// + 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?.NodeSequence == null || _currentPath.NodeSequence.Count < 2) + return; + + for (int i = 0; i < _currentPath.NodeSequence.Count - 1; i++) + { + var currentNodeId = _currentPath.NodeSequence[i]; + var nextNodeId = _currentPath.NodeSequence[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.NodeSequence.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.CalculationTime}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 + } +} \ No newline at end of file diff --git a/Cs_HMI/AGVSimulator/Controls/SimulatorCanvas.resx b/Cs_HMI/AGVSimulator/Controls/SimulatorCanvas.resx new file mode 100644 index 0000000..c50400a --- /dev/null +++ b/Cs_HMI/AGVSimulator/Controls/SimulatorCanvas.resx @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/Cs_HMI/AGVSimulator/Forms/SimulatorForm.Designer.cs b/Cs_HMI/AGVSimulator/Forms/SimulatorForm.Designer.cs new file mode 100644 index 0000000..89ce9bb --- /dev/null +++ b/Cs_HMI/AGVSimulator/Forms/SimulatorForm.Designer.cs @@ -0,0 +1,64 @@ +namespace AGVSimulator.Forms +{ + partial class SimulatorForm + { + /// + /// 필수 디자이너 변수입니다. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// 사용 중인 모든 리소스를 정리합니다. + /// + /// 관리되는 리소스를 삭제해야 하면 true이고, 그렇지 않으면 false입니다. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + + // 시뮬레이션 정지 + if (_simulationTimer != null) + { + _simulationTimer.Stop(); + _simulationTimer.Dispose(); + } + + // AGV 정리 + if (_agvList != null) + { + foreach (var agv in _agvList) + { + agv.Dispose(); + } + } + + base.Dispose(disposing); + } + + #region Windows Form 디자이너에서 생성한 코드 + + /// + /// 디자이너 지원에 필요한 메서드입니다. + /// 이 메서드의 내용을 코드 편집기로 수정하지 마세요. + /// + private void InitializeComponent() + { + this.SuspendLayout(); + // + // SimulatorForm + // + this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 12F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(1200, 800); + this.Name = "SimulatorForm"; + this.Text = "AGV 시뮬레이터"; + this.WindowState = System.Windows.Forms.FormWindowState.Maximized; + this.ResumeLayout(false); + + } + + #endregion + } +} \ No newline at end of file diff --git a/Cs_HMI/AGVSimulator/Forms/SimulatorForm.cs b/Cs_HMI/AGVSimulator/Forms/SimulatorForm.cs new file mode 100644 index 0000000..b0c3baa --- /dev/null +++ b/Cs_HMI/AGVSimulator/Forms/SimulatorForm.cs @@ -0,0 +1,688 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Windows.Forms; +using AGVMapEditor.Models; +using AGVSimulator.Controls; +using AGVSimulator.Models; +using Newtonsoft.Json; + +namespace AGVSimulator.Forms +{ + /// + /// AGV 시뮬레이터 메인 폼 + /// + public partial class SimulatorForm : Form + { + #region Fields + + private SimulatorCanvas _simulatorCanvas; + private List _mapNodes; + private List _rfidMappings; + private NodeResolver _nodeResolver; + private PathCalculator _pathCalculator; + private List _agvList; + private SimulationState _simulationState; + private Timer _simulationTimer; + + // UI Controls + private MenuStrip _menuStrip; + private ToolStrip _toolStrip; + private StatusStrip _statusStrip; + private Panel _controlPanel; + private Panel _canvasPanel; + + // Control Panel Controls + private GroupBox _agvControlGroup; + private ComboBox _agvListCombo; + private Button _addAgvButton; + private Button _removeAgvButton; + private Button _startSimulationButton; + private Button _stopSimulationButton; + private Button _resetButton; + + private GroupBox _pathGroup; + private ComboBox _startNodeCombo; + private ComboBox _targetNodeCombo; + private Button _calculatePathButton; + private Button _startPathButton; + private Button _clearPathButton; + + private GroupBox _viewGroup; + private Button _fitToMapButton; + private Button _resetZoomButton; + + private GroupBox _statusGroup; + private Label _simulationStatusLabel; + private Label _agvCountLabel; + private Label _pathLengthLabel; + + // Status Labels + private ToolStripStatusLabel _statusLabel; + private ToolStripStatusLabel _coordLabel; + + #endregion + + #region Properties + + /// + /// 시뮬레이션 상태 + /// + public SimulationState SimulationState => _simulationState; + + #endregion + + #region Constructor + + public SimulatorForm() + { + InitializeComponent(); + InitializeForm(); + } + + #endregion + + #region Initialization + + private void InitializeForm() + { + // 폼 설정 + Text = "AGV 시뮬레이터"; + Size = new Size(1200, 800); + StartPosition = FormStartPosition.CenterScreen; + + // 데이터 초기화 + _mapNodes = new List(); + _rfidMappings = new List(); + _agvList = new List(); + _simulationState = new SimulationState(); + + // UI 컨트롤 생성 + CreateMenuStrip(); + CreateToolStrip(); + CreateStatusStrip(); + CreateControlPanel(); + CreateSimulatorCanvas(); + + // 레이아웃 설정 + SetupLayout(); + + // 타이머 초기화 + _simulationTimer = new Timer(); + _simulationTimer.Interval = 100; // 100ms 간격 + _simulationTimer.Tick += OnSimulationTimer_Tick; + + // 초기 상태 설정 + UpdateUI(); + } + + private void CreateMenuStrip() + { + _menuStrip = new MenuStrip(); + + // 파일 메뉴 + var fileMenu = new ToolStripMenuItem("파일(&F)"); + fileMenu.DropDownItems.Add(new ToolStripMenuItem("맵 열기(&O)...", null, OnOpenMap_Click) { ShortcutKeys = Keys.Control | Keys.O }); + fileMenu.DropDownItems.Add(new ToolStripSeparator()); + fileMenu.DropDownItems.Add(new ToolStripMenuItem("종료(&X)", null, OnExit_Click) { ShortcutKeys = Keys.Alt | Keys.F4 }); + + // 시뮬레이션 메뉴 + var simMenu = new ToolStripMenuItem("시뮬레이션(&S)"); + simMenu.DropDownItems.Add(new ToolStripMenuItem("시작(&S)", null, OnStartSimulation_Click) { ShortcutKeys = Keys.F5 }); + simMenu.DropDownItems.Add(new ToolStripMenuItem("정지(&T)", null, OnStopSimulation_Click) { ShortcutKeys = Keys.F6 }); + simMenu.DropDownItems.Add(new ToolStripMenuItem("초기화(&R)", null, OnReset_Click) { ShortcutKeys = Keys.F7 }); + + // 보기 메뉴 + var viewMenu = new ToolStripMenuItem("보기(&V)"); + viewMenu.DropDownItems.Add(new ToolStripMenuItem("맵 맞춤(&F)", null, OnFitToMap_Click) { ShortcutKeys = Keys.Control | Keys.F }); + viewMenu.DropDownItems.Add(new ToolStripMenuItem("줌 초기화(&Z)", null, OnResetZoom_Click) { ShortcutKeys = Keys.Control | Keys.D0 }); + + // 도움말 메뉴 + var helpMenu = new ToolStripMenuItem("도움말(&H)"); + helpMenu.DropDownItems.Add(new ToolStripMenuItem("정보(&A)...", null, OnAbout_Click)); + + _menuStrip.Items.AddRange(new ToolStripItem[] { fileMenu, simMenu, viewMenu, helpMenu }); + Controls.Add(_menuStrip); + MainMenuStrip = _menuStrip; + } + + private void CreateToolStrip() + { + _toolStrip = new ToolStrip(); + + _toolStrip.Items.Add(new ToolStripButton("맵 열기", null, OnOpenMap_Click) { ToolTipText = "맵 파일을 엽니다" }); + _toolStrip.Items.Add(new ToolStripSeparator()); + _toolStrip.Items.Add(new ToolStripButton("시뮬레이션 시작", null, OnStartSimulation_Click) { ToolTipText = "시뮬레이션을 시작합니다" }); + _toolStrip.Items.Add(new ToolStripButton("시뮬레이션 정지", null, OnStopSimulation_Click) { ToolTipText = "시뮬레이션을 정지합니다" }); + _toolStrip.Items.Add(new ToolStripButton("초기화", null, OnReset_Click) { ToolTipText = "시뮬레이션을 초기화합니다" }); + _toolStrip.Items.Add(new ToolStripSeparator()); + _toolStrip.Items.Add(new ToolStripButton("맵 맞춤", null, OnFitToMap_Click) { ToolTipText = "맵 전체를 화면에 맞춥니다" }); + _toolStrip.Items.Add(new ToolStripButton("줌 초기화", null, OnResetZoom_Click) { ToolTipText = "줌을 초기화합니다" }); + + Controls.Add(_toolStrip); + } + + private void CreateStatusStrip() + { + _statusStrip = new StatusStrip(); + + _statusLabel = new ToolStripStatusLabel("준비"); + _coordLabel = new ToolStripStatusLabel(); + + _statusStrip.Items.AddRange(new ToolStripItem[] { _statusLabel, _coordLabel }); + Controls.Add(_statusStrip); + } + + private void CreateControlPanel() + { + _controlPanel = new Panel(); + _controlPanel.Width = 250; + _controlPanel.Dock = DockStyle.Right; + _controlPanel.BackColor = SystemColors.Control; + + // AGV 제어 그룹 + CreateAGVControlGroup(); + + // 경로 제어 그룹 + CreatePathControlGroup(); + + // 뷰 제어 그룹 + CreateViewControlGroup(); + + // 상태 그룹 + CreateStatusGroup(); + + Controls.Add(_controlPanel); + } + + private void CreateAGVControlGroup() + { + _agvControlGroup = new GroupBox(); + _agvControlGroup.Text = "AGV 제어"; + _agvControlGroup.Location = new Point(10, 10); + _agvControlGroup.Size = new Size(230, 120); + + _agvListCombo = new ComboBox(); + _agvListCombo.DropDownStyle = ComboBoxStyle.DropDownList; + _agvListCombo.Location = new Point(10, 25); + _agvListCombo.Size = new Size(210, 21); + _agvListCombo.SelectedIndexChanged += OnAGVList_SelectedIndexChanged; + + _addAgvButton = new Button(); + _addAgvButton.Text = "AGV 추가"; + _addAgvButton.Location = new Point(10, 55); + _addAgvButton.Size = new Size(100, 25); + _addAgvButton.Click += OnAddAGV_Click; + + _removeAgvButton = new Button(); + _removeAgvButton.Text = "AGV 제거"; + _removeAgvButton.Location = new Point(120, 55); + _removeAgvButton.Size = new Size(100, 25); + _removeAgvButton.Click += OnRemoveAGV_Click; + + _startSimulationButton = new Button(); + _startSimulationButton.Text = "시뮬레이션 시작"; + _startSimulationButton.Location = new Point(10, 85); + _startSimulationButton.Size = new Size(100, 25); + _startSimulationButton.Click += OnStartSimulation_Click; + + _stopSimulationButton = new Button(); + _stopSimulationButton.Text = "시뮬레이션 정지"; + _stopSimulationButton.Location = new Point(120, 85); + _stopSimulationButton.Size = new Size(100, 25); + _stopSimulationButton.Click += OnStopSimulation_Click; + + _agvControlGroup.Controls.AddRange(new Control[] { + _agvListCombo, _addAgvButton, _removeAgvButton, _startSimulationButton, _stopSimulationButton + }); + + _controlPanel.Controls.Add(_agvControlGroup); + } + + private void CreatePathControlGroup() + { + _pathGroup = new GroupBox(); + _pathGroup.Text = "경로 제어"; + _pathGroup.Location = new Point(10, 140); + _pathGroup.Size = new Size(230, 150); + + var startLabel = new Label(); + startLabel.Text = "시작 노드:"; + startLabel.Location = new Point(10, 25); + startLabel.Size = new Size(70, 15); + + _startNodeCombo = new ComboBox(); + _startNodeCombo.DropDownStyle = ComboBoxStyle.DropDownList; + _startNodeCombo.Location = new Point(10, 45); + _startNodeCombo.Size = new Size(210, 21); + + var targetLabel = new Label(); + targetLabel.Text = "목표 노드:"; + targetLabel.Location = new Point(10, 75); + targetLabel.Size = new Size(70, 15); + + _targetNodeCombo = new ComboBox(); + _targetNodeCombo.DropDownStyle = ComboBoxStyle.DropDownList; + _targetNodeCombo.Location = new Point(10, 95); + _targetNodeCombo.Size = new Size(210, 21); + + _calculatePathButton = new Button(); + _calculatePathButton.Text = "경로 계산"; + _calculatePathButton.Location = new Point(10, 120); + _calculatePathButton.Size = new Size(65, 25); + _calculatePathButton.Click += OnCalculatePath_Click; + + _startPathButton = new Button(); + _startPathButton.Text = "경로 시작"; + _startPathButton.Location = new Point(80, 120); + _startPathButton.Size = new Size(65, 25); + _startPathButton.Click += OnStartPath_Click; + + _clearPathButton = new Button(); + _clearPathButton.Text = "경로 지우기"; + _clearPathButton.Location = new Point(150, 120); + _clearPathButton.Size = new Size(70, 25); + _clearPathButton.Click += OnClearPath_Click; + + _pathGroup.Controls.AddRange(new Control[] { + startLabel, _startNodeCombo, targetLabel, _targetNodeCombo, + _calculatePathButton, _startPathButton, _clearPathButton + }); + + _controlPanel.Controls.Add(_pathGroup); + } + + private void CreateViewControlGroup() + { + _viewGroup = new GroupBox(); + _viewGroup.Text = "화면 제어"; + _viewGroup.Location = new Point(10, 300); + _viewGroup.Size = new Size(230, 60); + + _fitToMapButton = new Button(); + _fitToMapButton.Text = "맵 맞춤"; + _fitToMapButton.Location = new Point(10, 25); + _fitToMapButton.Size = new Size(100, 25); + _fitToMapButton.Click += OnFitToMap_Click; + + _resetZoomButton = new Button(); + _resetZoomButton.Text = "줌 초기화"; + _resetZoomButton.Location = new Point(120, 25); + _resetZoomButton.Size = new Size(100, 25); + _resetZoomButton.Click += OnResetZoom_Click; + + _resetButton = new Button(); + _resetButton.Text = "전체 초기화"; + _resetButton.Location = new Point(65, 55); + _resetButton.Size = new Size(100, 25); + _resetButton.Click += OnReset_Click; + + _viewGroup.Controls.AddRange(new Control[] { _fitToMapButton, _resetZoomButton }); + + _controlPanel.Controls.Add(_viewGroup); + } + + private void CreateStatusGroup() + { + _statusGroup = new GroupBox(); + _statusGroup.Text = "상태 정보"; + _statusGroup.Location = new Point(10, 370); + _statusGroup.Size = new Size(230, 100); + + _simulationStatusLabel = new Label(); + _simulationStatusLabel.Text = "시뮬레이션: 정지"; + _simulationStatusLabel.Location = new Point(10, 25); + _simulationStatusLabel.Size = new Size(210, 15); + + _agvCountLabel = new Label(); + _agvCountLabel.Text = "AGV 수: 0"; + _agvCountLabel.Location = new Point(10, 45); + _agvCountLabel.Size = new Size(210, 15); + + _pathLengthLabel = new Label(); + _pathLengthLabel.Text = "경로 길이: -"; + _pathLengthLabel.Location = new Point(10, 65); + _pathLengthLabel.Size = new Size(210, 15); + + _statusGroup.Controls.AddRange(new Control[] { + _simulationStatusLabel, _agvCountLabel, _pathLengthLabel + }); + + _controlPanel.Controls.Add(_statusGroup); + } + + private void CreateSimulatorCanvas() + { + _canvasPanel = new Panel(); + _canvasPanel.Dock = DockStyle.Fill; + + _simulatorCanvas = new SimulatorCanvas(); + _simulatorCanvas.Dock = DockStyle.Fill; + + _canvasPanel.Controls.Add(_simulatorCanvas); + Controls.Add(_canvasPanel); + } + + private void SetupLayout() + { + // Z-Order 설정 + _canvasPanel.BringToFront(); + _controlPanel.BringToFront(); + _toolStrip.BringToFront(); + _menuStrip.BringToFront(); + } + + #endregion + + #region Event Handlers + + private void OnOpenMap_Click(object sender, EventArgs e) + { + using (var openDialog = new OpenFileDialog()) + { + openDialog.Filter = "맵 파일 (*.json)|*.json|모든 파일 (*.*)|*.*"; + openDialog.Title = "맵 파일 열기"; + + if (openDialog.ShowDialog() == DialogResult.OK) + { + try + { + LoadMapFile(openDialog.FileName); + _statusLabel.Text = $"맵 로드 완료: {Path.GetFileName(openDialog.FileName)}"; + } + catch (Exception ex) + { + MessageBox.Show($"맵 파일을 로드할 수 없습니다:\n{ex.Message}", "오류", + MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + } + } + + private void OnExit_Click(object sender, EventArgs e) + { + Close(); + } + + private void OnStartSimulation_Click(object sender, EventArgs e) + { + if (_simulationState.IsRunning) + return; + + _simulationState.IsRunning = true; + _simulationTimer.Start(); + _statusLabel.Text = "시뮬레이션 실행 중"; + UpdateUI(); + } + + private void OnStopSimulation_Click(object sender, EventArgs e) + { + if (!_simulationState.IsRunning) + return; + + _simulationState.IsRunning = false; + _simulationTimer.Stop(); + _statusLabel.Text = "시뮬레이션 정지"; + UpdateUI(); + } + + private void OnReset_Click(object sender, EventArgs e) + { + // 시뮬레이션 정지 + if (_simulationState.IsRunning) + { + OnStopSimulation_Click(sender, e); + } + + // AGV 초기화 + _simulatorCanvas.ClearAGVs(); + _agvList.Clear(); + + // 경로 초기화 + _simulatorCanvas.CurrentPath = null; + + // UI 업데이트 + UpdateAGVComboBox(); + UpdateNodeComboBoxes(); + UpdateUI(); + + _statusLabel.Text = "초기화 완료"; + } + + private void OnFitToMap_Click(object sender, EventArgs e) + { + _simulatorCanvas.FitToMap(); + } + + private void OnResetZoom_Click(object sender, EventArgs e) + { + _simulatorCanvas.ResetZoom(); + } + + private void OnAbout_Click(object sender, EventArgs e) + { + MessageBox.Show("AGV 시뮬레이터 v1.0\n\nENIG AGV 시스템용 시뮬레이터", "정보", + MessageBoxButtons.OK, MessageBoxIcon.Information); + } + + private void OnAddAGV_Click(object sender, EventArgs e) + { + if (_mapNodes == null || _mapNodes.Count == 0) + { + MessageBox.Show("먼저 맵을 로드해주세요.", "알림", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + var agvId = $"AGV{_agvList.Count + 1:D2}"; + var startPosition = _mapNodes.First().Position; // 첫 번째 노드에서 시작 + + var newAGV = new VirtualAGV(agvId, startPosition); + _agvList.Add(newAGV); + _simulatorCanvas.AddAGV(newAGV); + + UpdateAGVComboBox(); + UpdateUI(); + + _statusLabel.Text = $"{agvId} 추가됨"; + } + + private void OnRemoveAGV_Click(object sender, EventArgs e) + { + if (_agvListCombo.SelectedItem == null) + return; + + var selectedAGV = _agvListCombo.SelectedItem as VirtualAGV; + if (selectedAGV != null) + { + _simulatorCanvas.RemoveAGV(selectedAGV.AgvId); + _agvList.Remove(selectedAGV); + + UpdateAGVComboBox(); + UpdateUI(); + + _statusLabel.Text = $"{selectedAGV.AgvId} 제거됨"; + } + } + + private void OnAGVList_SelectedIndexChanged(object sender, EventArgs e) + { + UpdateUI(); + } + + private void OnCalculatePath_Click(object sender, EventArgs e) + { + if (_startNodeCombo.SelectedItem == null || _targetNodeCombo.SelectedItem == null) + { + MessageBox.Show("시작 노드와 목표 노드를 선택해주세요.", "알림", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + var startNode = _startNodeCombo.SelectedItem as MapNode; + var targetNode = _targetNodeCombo.SelectedItem as MapNode; + + if (_pathCalculator == null) + { + _pathCalculator = new PathCalculator(_mapNodes, _nodeResolver); + } + + var result = _pathCalculator.CalculatePath(startNode.NodeId, targetNode.NodeId, AgvDirection.Forward); + + if (result.Success) + { + _simulatorCanvas.CurrentPath = result; + _pathLengthLabel.Text = $"경로 길이: {result.TotalDistance:F1}"; + _statusLabel.Text = $"경로 계산 완료 ({result.CalculationTime}ms)"; + } + else + { + MessageBox.Show($"경로를 찾을 수 없습니다:\n{result.ErrorMessage}", "경로 계산 실패", + MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } + + private void OnStartPath_Click(object sender, EventArgs e) + { + var selectedAGV = _agvListCombo.SelectedItem as VirtualAGV; + if (selectedAGV == null) + { + MessageBox.Show("AGV를 선택해주세요.", "알림", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + if (_simulatorCanvas.CurrentPath == null || !_simulatorCanvas.CurrentPath.Success) + { + MessageBox.Show("먼저 경로를 계산해주세요.", "알림", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + selectedAGV.StartPath(_simulatorCanvas.CurrentPath, _mapNodes); + _statusLabel.Text = $"{selectedAGV.AgvId} 경로 시작"; + } + + private void OnClearPath_Click(object sender, EventArgs e) + { + _simulatorCanvas.CurrentPath = null; + _pathLengthLabel.Text = "경로 길이: -"; + _statusLabel.Text = "경로 지움"; + } + + private void OnSimulationTimer_Tick(object sender, EventArgs e) + { + // 시뮬레이션 업데이트는 각 AGV의 내부 타이머에서 처리됨 + UpdateUI(); + } + + #endregion + + #region Private Methods + + private void LoadMapFile(string filePath) + { + try + { + var json = File.ReadAllText(filePath); + + // 구조체로 직접 역직렬화 + var mapData = JsonConvert.DeserializeObject(json); + + if (mapData != null) + { + _mapNodes = mapData.MapNodes ?? new List(); + _rfidMappings = mapData.RfidMappings ?? new List(); + } + else + { + _mapNodes = new List(); + _rfidMappings = new List(); + } + + // NodeResolver 초기화 + _nodeResolver = new NodeResolver(_rfidMappings, _mapNodes); + + // 시뮬레이터 캔버스에 맵 설정 + _simulatorCanvas.MapNodes = _mapNodes; + + // UI 업데이트 + UpdateNodeComboBoxes(); + UpdateUI(); + + // 맵에 맞춤 + _simulatorCanvas.FitToMap(); + } + catch (Exception ex) + { + throw new InvalidOperationException($"맵 파일 로드 실패: {ex.Message}", ex); + } + } + + // 맵 파일 데이터 구조체 + private class MapFileData + { + public List MapNodes { get; set; } + public List RfidMappings { get; set; } + } + + private void UpdateNodeComboBoxes() + { + _startNodeCombo.Items.Clear(); + _targetNodeCombo.Items.Clear(); + + if (_mapNodes != null) + { + foreach (var node in _mapNodes) + { + _startNodeCombo.Items.Add(node); + _targetNodeCombo.Items.Add(node); + } + } + + _startNodeCombo.DisplayMember = "NodeId"; + _targetNodeCombo.DisplayMember = "NodeId"; + } + + private void UpdateAGVComboBox() + { + _agvListCombo.Items.Clear(); + + if (_agvList != null) + { + foreach (var agv in _agvList) + { + _agvListCombo.Items.Add(agv); + } + } + + _agvListCombo.DisplayMember = "AgvId"; + + if (_agvListCombo.Items.Count > 0) + { + _agvListCombo.SelectedIndex = 0; + } + } + + private void UpdateUI() + { + // 시뮬레이션 상태 + _simulationStatusLabel.Text = _simulationState.IsRunning ? "시뮬레이션: 실행 중" : "시뮬레이션: 정지"; + + // AGV 수 + _agvCountLabel.Text = $"AGV 수: {_agvList?.Count ?? 0}"; + + // 버튼 상태 + _startSimulationButton.Enabled = !_simulationState.IsRunning && _agvList?.Count > 0; + _stopSimulationButton.Enabled = _simulationState.IsRunning; + + _removeAgvButton.Enabled = _agvListCombo.SelectedItem != null; + _startPathButton.Enabled = _agvListCombo.SelectedItem != null && + _simulatorCanvas.CurrentPath != null && + _simulatorCanvas.CurrentPath.Success; + + _calculatePathButton.Enabled = _startNodeCombo.SelectedItem != null && + _targetNodeCombo.SelectedItem != null; + } + + #endregion + } +} \ No newline at end of file diff --git a/Cs_HMI/AGVSimulator/Forms/SimulatorForm.resx b/Cs_HMI/AGVSimulator/Forms/SimulatorForm.resx new file mode 100644 index 0000000..b45c916 --- /dev/null +++ b/Cs_HMI/AGVSimulator/Forms/SimulatorForm.resx @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/Cs_HMI/AGVSimulator/Models/SimulationState.cs b/Cs_HMI/AGVSimulator/Models/SimulationState.cs new file mode 100644 index 0000000..1c05bd3 --- /dev/null +++ b/Cs_HMI/AGVSimulator/Models/SimulationState.cs @@ -0,0 +1,135 @@ +using System; + +namespace AGVSimulator.Models +{ + /// + /// 시뮬레이션 상태 관리 클래스 + /// + public class SimulationState + { + #region Properties + + /// + /// 시뮬레이션 실행 중 여부 + /// + public bool IsRunning { get; set; } + + /// + /// 시뮬레이션 시작 시간 + /// + public DateTime? StartTime { get; set; } + + /// + /// 시뮬레이션 경과 시간 + /// + public TimeSpan ElapsedTime => StartTime.HasValue ? DateTime.Now - StartTime.Value : TimeSpan.Zero; + + /// + /// 시뮬레이션 속도 배율 (1.0 = 실시간, 2.0 = 2배속) + /// + public float SpeedMultiplier { get; set; } = 1.0f; + + /// + /// 총 처리된 이벤트 수 + /// + public int TotalEvents { get; set; } + + /// + /// 총 이동 거리 (모든 AGV 합계) + /// + public float TotalDistance { get; set; } + + /// + /// 발생한 오류 수 + /// + public int ErrorCount { get; set; } + + #endregion + + #region Constructor + + /// + /// 기본 생성자 + /// + public SimulationState() + { + Reset(); + } + + #endregion + + #region Public Methods + + /// + /// 시뮬레이션 시작 + /// + public void Start() + { + if (!IsRunning) + { + IsRunning = true; + StartTime = DateTime.Now; + } + } + + /// + /// 시뮬레이션 정지 + /// + public void Stop() + { + IsRunning = false; + } + + /// + /// 시뮬레이션 상태 초기화 + /// + public void Reset() + { + IsRunning = false; + StartTime = null; + SpeedMultiplier = 1.0f; + TotalEvents = 0; + TotalDistance = 0; + ErrorCount = 0; + } + + /// + /// 이벤트 발생 시 호출 + /// + public void RecordEvent() + { + TotalEvents++; + } + + /// + /// 이동 거리 추가 + /// + /// 이동한 거리 + public void AddDistance(float distance) + { + TotalDistance += distance; + } + + /// + /// 오류 발생 시 호출 + /// + public void RecordError() + { + ErrorCount++; + } + + /// + /// 통계 정보 조회 + /// + /// 통계 정보 문자열 + public string GetStatistics() + { + return $"실행시간: {ElapsedTime:hh\\:mm\\:ss}, " + + $"이벤트: {TotalEvents}, " + + $"총거리: {TotalDistance:F1}, " + + $"오류: {ErrorCount}"; + } + + #endregion + } +} \ No newline at end of file diff --git a/Cs_HMI/AGVSimulator/Models/VirtualAGV.cs b/Cs_HMI/AGVSimulator/Models/VirtualAGV.cs new file mode 100644 index 0000000..ca3cfd8 --- /dev/null +++ b/Cs_HMI/AGVSimulator/Models/VirtualAGV.cs @@ -0,0 +1,482 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using AGVMapEditor.Models; + +namespace AGVSimulator.Models +{ + /// + /// 가상 AGV 상태 + /// + public enum AGVState + { + Idle, // 대기 + Moving, // 이동 중 + Rotating, // 회전 중 + Docking, // 도킹 중 + Charging, // 충전 중 + Error // 오류 + } + + /// + /// 가상 AGV 클래스 + /// 실제 AGV의 동작을 시뮬레이션 + /// + public class VirtualAGV + { + #region Events + + /// + /// AGV 상태 변경 이벤트 + /// + public event EventHandler StateChanged; + + /// + /// 위치 변경 이벤트 + /// + public event EventHandler PositionChanged; + + /// + /// RFID 감지 이벤트 + /// + public event EventHandler RfidDetected; + + /// + /// 경로 완료 이벤트 + /// + public event EventHandler PathCompleted; + + /// + /// 오류 발생 이벤트 + /// + public event EventHandler ErrorOccurred; + + #endregion + + #region Fields + + private string _agvId; + private Point _currentPosition; + private Point _targetPosition; + private AgvDirection _currentDirection; + private AGVState _currentState; + private float _currentSpeed; + + // 경로 관련 + private PathResult _currentPath; + private List _remainingNodes; + private int _currentNodeIndex; + private string _currentNodeId; + + // 이동 관련 + private System.Windows.Forms.Timer _moveTimer; + private DateTime _lastMoveTime; + private Point _moveStartPosition; + private Point _moveTargetPosition; + private float _moveProgress; + + // 시뮬레이션 설정 + private readonly float _moveSpeed = 50.0f; // 픽셀/초 + private readonly float _rotationSpeed = 90.0f; // 도/초 + private readonly int _updateInterval = 50; // ms + + #endregion + + #region Properties + + /// + /// AGV ID + /// + public string AgvId => _agvId; + + /// + /// 현재 위치 + /// + public Point CurrentPosition => _currentPosition; + + /// + /// 현재 방향 + /// + public AgvDirection CurrentDirection => _currentDirection; + + /// + /// 현재 상태 + /// + public AGVState CurrentState => _currentState; + + /// + /// 현재 속도 + /// + public float CurrentSpeed => _currentSpeed; + + /// + /// 현재 경로 + /// + public PathResult CurrentPath => _currentPath; + + /// + /// 현재 노드 ID + /// + public string CurrentNodeId => _currentNodeId; + + /// + /// 목표 위치 + /// + public Point TargetPosition => _targetPosition; + + /// + /// 배터리 레벨 (시뮬레이션) + /// + public float BatteryLevel { get; set; } = 100.0f; + + #endregion + + #region Constructor + + /// + /// 생성자 + /// + /// AGV ID + /// 시작 위치 + /// 시작 방향 + public VirtualAGV(string agvId, Point startPosition, AgvDirection startDirection = AgvDirection.Forward) + { + _agvId = agvId; + _currentPosition = startPosition; + _currentDirection = startDirection; + _currentState = AGVState.Idle; + _currentSpeed = 0; + + InitializeTimer(); + } + + #endregion + + #region Initialization + + private void InitializeTimer() + { + _moveTimer = new System.Windows.Forms.Timer(); + _moveTimer.Interval = _updateInterval; + _moveTimer.Tick += OnMoveTimer_Tick; + _lastMoveTime = DateTime.Now; + } + + #endregion + + #region Public Methods + + /// + /// 경로 실행 시작 + /// + /// 실행할 경로 + /// 맵 노드 목록 + public void StartPath(PathResult path, List mapNodes) + { + if (path == null || !path.Success) + { + OnError("유효하지 않은 경로입니다."); + return; + } + + _currentPath = path; + _remainingNodes = new List(path.NodeSequence); + _currentNodeIndex = 0; + + // 시작 노드 위치로 이동 + if (_remainingNodes.Count > 0) + { + var startNode = mapNodes.FirstOrDefault(n => n.NodeId == _remainingNodes[0]); + if (startNode != null) + { + _currentNodeId = startNode.NodeId; + StartMovement(); + } + else + { + OnError($"시작 노드를 찾을 수 없습니다: {_remainingNodes[0]}"); + } + } + } + + /// + /// 경로 정지 + /// + public void StopPath() + { + _moveTimer.Stop(); + _currentPath = null; + _remainingNodes?.Clear(); + SetState(AGVState.Idle); + _currentSpeed = 0; + } + + /// + /// 긴급 정지 + /// + public void EmergencyStop() + { + StopPath(); + OnError("긴급 정지가 실행되었습니다."); + } + + /// + /// 수동 이동 (테스트용) + /// + /// 목표 위치 + public void MoveTo(Point targetPosition) + { + _targetPosition = targetPosition; + _moveStartPosition = _currentPosition; + _moveTargetPosition = targetPosition; + _moveProgress = 0; + + SetState(AGVState.Moving); + _moveTimer.Start(); + } + + /// + /// 수동 회전 (테스트용) + /// + /// 회전 방향 + public void Rotate(AgvDirection direction) + { + if (_currentState != AGVState.Idle) + return; + + SetState(AGVState.Rotating); + + // 시뮬레이션: 즉시 방향 변경 (실제로는 시간이 걸림) + _currentDirection = direction; + + System.Threading.Thread.Sleep(500); // 회전 시간 시뮬레이션 + SetState(AGVState.Idle); + } + + /// + /// 충전 시작 (시뮬레이션) + /// + public void StartCharging() + { + if (_currentState == AGVState.Idle) + { + SetState(AGVState.Charging); + // 충전 시뮬레이션 시작 + } + } + + /// + /// 충전 종료 + /// + public void StopCharging() + { + if (_currentState == AGVState.Charging) + { + SetState(AGVState.Idle); + } + } + + /// + /// AGV 정보 조회 + /// + public string GetStatus() + { + return $"AGV[{_agvId}] 위치:({_currentPosition.X},{_currentPosition.Y}) " + + $"방향:{_currentDirection} 상태:{_currentState} " + + $"속도:{_currentSpeed:F1} 배터리:{BatteryLevel:F1}%"; + } + + /// + /// 현재 RFID 시뮬레이션 (현재 위치 기준) + /// + public string SimulateRfidReading(List mapNodes, List rfidMappings) + { + // 현재 위치에서 가장 가까운 노드 찾기 + var closestNode = FindClosestNode(_currentPosition, mapNodes); + if (closestNode == null) + return null; + + // 해당 노드의 RFID 매핑 찾기 + var mapping = rfidMappings.FirstOrDefault(m => m.LogicalNodeId == closestNode.NodeId); + return mapping?.RfidId; + } + + #endregion + + #region Private Methods + + private void StartMovement() + { + SetState(AGVState.Moving); + _moveTimer.Start(); + _lastMoveTime = DateTime.Now; + } + + private void OnMoveTimer_Tick(object sender, EventArgs e) + { + var now = DateTime.Now; + var deltaTime = (float)(now - _lastMoveTime).TotalSeconds; + _lastMoveTime = now; + + UpdateMovement(deltaTime); + UpdateBattery(deltaTime); + + // 위치 변경 이벤트 발생 + PositionChanged?.Invoke(this, _currentPosition); + } + + private void UpdateMovement(float deltaTime) + { + if (_currentState != AGVState.Moving) + return; + + // 목표 위치까지의 거리 계산 + var distance = CalculateDistance(_currentPosition, _moveTargetPosition); + + if (distance < 5.0f) // 도달 임계값 + { + // 목표 도달 + _currentPosition = _moveTargetPosition; + _currentSpeed = 0; + + // 다음 노드로 이동 + ProcessNextNode(); + } + else + { + // 계속 이동 + var moveDistance = _moveSpeed * deltaTime; + var direction = new PointF( + _moveTargetPosition.X - _currentPosition.X, + _moveTargetPosition.Y - _currentPosition.Y + ); + + // 정규화 + var length = (float)Math.Sqrt(direction.X * direction.X + direction.Y * direction.Y); + if (length > 0) + { + direction.X /= length; + direction.Y /= length; + } + + // 새 위치 계산 + _currentPosition = new Point( + (int)(_currentPosition.X + direction.X * moveDistance), + (int)(_currentPosition.Y + direction.Y * moveDistance) + ); + + _currentSpeed = _moveSpeed; + } + } + + private void UpdateBattery(float deltaTime) + { + // 배터리 소모 시뮬레이션 + if (_currentState == AGVState.Moving) + { + BatteryLevel -= 0.1f * deltaTime; // 이동시 소모 + } + else if (_currentState == AGVState.Charging) + { + BatteryLevel += 5.0f * deltaTime; // 충전 + BatteryLevel = Math.Min(100.0f, BatteryLevel); + } + + BatteryLevel = Math.Max(0, BatteryLevel); + + // 배터리 부족 경고 + if (BatteryLevel < 20.0f && _currentState != AGVState.Charging) + { + OnError($"배터리 부족: {BatteryLevel:F1}%"); + } + } + + private void ProcessNextNode() + { + if (_remainingNodes == null || _currentNodeIndex >= _remainingNodes.Count - 1) + { + // 경로 완료 + _moveTimer.Stop(); + SetState(AGVState.Idle); + PathCompleted?.Invoke(this, _currentPath); + return; + } + + // 다음 노드로 이동 + _currentNodeIndex++; + var nextNodeId = _remainingNodes[_currentNodeIndex]; + + // RFID 감지 시뮬레이션 + RfidDetected?.Invoke(this, $"RFID_{nextNodeId}"); + _currentNodeId = nextNodeId; + + // 다음 목표 위치 설정 (실제로는 맵에서 좌표 가져와야 함) + // 여기서는 간단히 현재 위치에서 랜덤 오프셋으로 설정 + var random = new Random(); + _moveTargetPosition = new Point( + _currentPosition.X + random.Next(-100, 100), + _currentPosition.Y + random.Next(-100, 100) + ); + } + + private MapNode FindClosestNode(Point position, List mapNodes) + { + if (mapNodes == null || mapNodes.Count == 0) + return null; + + MapNode closestNode = null; + float closestDistance = float.MaxValue; + + foreach (var node in mapNodes) + { + var distance = CalculateDistance(position, node.Position); + if (distance < closestDistance) + { + closestDistance = distance; + closestNode = node; + } + } + + // 일정 거리 내에 있는 노드만 반환 + return closestDistance < 50.0f ? closestNode : null; + } + + 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 SetState(AGVState newState) + { + if (_currentState != newState) + { + _currentState = newState; + StateChanged?.Invoke(this, newState); + } + } + + private void OnError(string message) + { + SetState(AGVState.Error); + ErrorOccurred?.Invoke(this, message); + } + + #endregion + + #region Cleanup + + /// + /// 리소스 정리 + /// + public void Dispose() + { + _moveTimer?.Stop(); + _moveTimer?.Dispose(); + } + + #endregion + } +} \ No newline at end of file diff --git a/Cs_HMI/AGVSimulator/Program.cs b/Cs_HMI/AGVSimulator/Program.cs new file mode 100644 index 0000000..5e86016 --- /dev/null +++ b/Cs_HMI/AGVSimulator/Program.cs @@ -0,0 +1,32 @@ +using System; +using System.Windows.Forms; +using AGVSimulator.Forms; + +namespace AGVSimulator +{ + /// + /// AGV 시뮬레이터 프로그램 진입점 + /// + static class Program + { + /// + /// 애플리케이션의 주 진입점입니다. + /// + [STAThread] + static void Main() + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + + try + { + Application.Run(new SimulatorForm()); + } + catch (Exception ex) + { + MessageBox.Show($"시뮬레이터 실행 중 오류가 발생했습니다:\n{ex.Message}", + "시스템 오류", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + } +} \ No newline at end of file diff --git a/Cs_HMI/AGVSimulator/Properties/AssemblyInfo.cs b/Cs_HMI/AGVSimulator/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..c13094c --- /dev/null +++ b/Cs_HMI/AGVSimulator/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// 어셈블리에 대한 일반 정보는 다음 특성 집합을 통해 +// 제어됩니다. 어셈블리와 관련된 정보를 수정하려면 +// 이러한 특성 값을 변경하세요. +[assembly: AssemblyTitle("AGV Simulator")] +[assembly: AssemblyDescription("ENIG AGV System Simulator")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("ENIG")] +[assembly: AssemblyProduct("AGV HMI System")] +[assembly: AssemblyCopyright("Copyright © ENIG 2024")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// ComVisible을 false로 설정하면 이 어셈블리의 형식이 COM 구성 요소에 +// 표시되지 않습니다. COM에서 이 어셈블리의 형식에 액세스하려면 +// 해당 형식에 대해 ComVisible 특성을 true로 설정하세요. +[assembly: ComVisible(false)] + +// 이 프로젝트가 COM에 노출되는 경우 다음 GUID는 typelib의 ID를 나타냅니다. +[assembly: Guid("b2c3d4e5-f6a7-4901-bcde-f23456789012")] + +// 어셈블리의 버전 정보는 다음 네 개의 값으로 구성됩니다. +// +// 주 버전 +// 부 버전 +// 빌드 번호 +// 수정 버전 +// +// 모든 값을 지정하거나 아래와 같이 '*'를 사용하여 빌드 번호 및 수정 번호를 +// 기본값으로 할 수 있습니다. +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] \ No newline at end of file diff --git a/Cs_HMI/AGVSimulator/packages.config b/Cs_HMI/AGVSimulator/packages.config new file mode 100644 index 0000000..8b1a8d0 --- /dev/null +++ b/Cs_HMI/AGVSimulator/packages.config @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/Cs_HMI/CLAUDE.md b/Cs_HMI/CLAUDE.md new file mode 100644 index 0000000..3264393 --- /dev/null +++ b/Cs_HMI/CLAUDE.md @@ -0,0 +1,141 @@ +# CLAUDE.md + +이 파일은 이 저장소의 코드로 작업할 때 Claude Code (claude.ai/code)를 위한 지침을 제공합니다. + +## 빌드 및 개발 명령어 + +### 프로젝트 빌드 및 실행 +- **메인 빌드**: build.bat 파일 참고 +- **빌드후 이벤트 예제) rem xcopy "$(TargetDir)*.exe" "\\192.168.1.80\Amkor\AGV2" /Y + +### 프로젝트 구조 및 빌드 설정 +- **메인 애플리케이션**: `Project/AGV4.csproj` - "Amkor"라는 실행 파일명으로 컴파일 +- **타겟 플랫폼**: .NET Framework 4.8, x86/x64 아키텍처 +- **출력 경로**: + - Debug: `..\..\..\..\..\Amkor\AGV4\` + - Release: `..\..\..\ManualMapEditor\` + +## 고수준 코드 아키텍처 + +### 솔루션 구조 +이 프로젝트는 ENIG AGV (자동 유도 차량) 시스템을 위한 C# HMI (Human-Machine Interface) 애플리케이션입니다. + +``` +AGVCSharp.sln (메인 솔루션) +├── Project/AGV4.csproj (메인 HMI 애플리케이션) +├── StateMachine/ (상태 머신 라이브러리) +├── AGVMapEditor/ (맵 에디터 - 2024.09 추가) +├── AGVSimulator/ (AGV 시뮬레이터 - 2024.09 추가) +└── SubProject/ (서브 프로젝트 모듈들) + ├── AGVControl/ (AGV 제어) + ├── BMS/ (배터리 관리 시스템) + ├── NARUMI/ (AGV 하드웨어) + ├── CommData/ (통신 데이터) + ├── ENIGProtocol/ (ENIG 프로토콜) + └── 기타 모듈들 +``` + +### 메인 애플리케이션 아키텍처 (Project/) + +#### 핵심 폼 구조 +- **fMain.cs**: 메인 UI 폼 - AGV 시스템의 중앙 제어판 +- **fSetup.cs**: 설정 화면 - 시스템 파라미터 및 구성 +- **ViewForm/**: 각종 모니터링 화면들 + - `fAuto.cs` - 자동 모드 화면 + - `fManual.cs` - 수동 모드 화면 + - `fAgv.cs` - AGV 상태 화면 + - `fBms.cs` - 배터리 상태 화면 + - `fIO.cs` - I/O 상태 화면 + +#### 상태 머신 시스템 (StateMachine/) +AGV의 동작을 제어하는 상태 기반 시스템: +- **_Loop.cs**: 메인 상태 머신 루프 +- **_AGV.cs**: AGV 제어 로직 +- **_BMS.cs**: 배터리 관리 상태 +- **_SPS.cs**: SPS(Stored Program Sequencer) 제어 +- **Step/**: 각 상태별 구현 클래스들 + - `_SM_RUN_*.cs` - 실행 상태들 (INIT, READY, GOTO, CHARGE 등) + +#### 핵심 클래스들 +- **PUB.cs**: 전역 변수 및 공통 함수 +- **CSetting.cs**: 설정 데이터 관리 +- **Manager/DataBaseManager.cs**: 데이터베이스 관리 +- **Device/**: 하드웨어 인터페이스 클래스들 + +### 통신 및 프로토콜 +- **XBee 무선 통신**: call button 및 충전기 통신 +- **ENIG Protocol**: 자체 정의 프로토콜 +- **Socket 통신**: 네트워크 기반 데이터 교환 +- **Database**: 운영 데이터 저장 및 관리 + +### 의존성 및 라이브러리 +- **arCommUtil**: AR 통신 유틸리티 +- **arControl.Net4**: AR 제어 라이브러리 +- **Newtonsoft.Json**: JSON 데이터 처리 +- **Microsoft.Speech**: 음성 처리 +- **System.Management**: 시스템 관리 + +### 빌드 후 처리 +프로젝트는 빌드 후 네트워크 위치로 파일을 자동 복사하는 설정이 있습니다 (현재 주석 처리됨). + +### Git 업데이트 +SubProject 내의 GitUpdate.bat을 사용하여 모든 하위 프로젝트를 일괄 업데이트할 수 있습니다. + +## 새로 추가된 AGV 개발 도구 (2024.09) + +### AGVMapEditor (맵 에디터) +**위치**: `AGVMapEditor/AGVMapEditor.csproj` +**실행파일**: `AGVMapEditor/bin/Debug/AGVMapEditor.exe` + +#### 핵심 기능 +- **맵 노드 관리**: 논리적 노드 생성, 연결, 속성 설정 +- **RFID 매핑 분리**: 물리적 RFID ID ↔ 논리적 노드 ID 매핑 관리 +- **시각적 맵 편집**: 드래그앤드롭으로 노드 배치 및 연결 +- **JSON 파일 저장**: 맵 데이터를 JSON 형식으로 저장/로드 + +#### 핵심 클래스 +- **MapNode**: 논리적 맵 노드 (NodeId, 위치, 타입, 연결 정보) +- **RfidMapping**: RFID 물리적 ID ↔ 논리적 노드 매핑 +- **NodeResolver**: RFID ID를 통한 노드 해석기 +- **PathCalculator**: A* 알고리즘 기반 AGV 경로 계산 +- **MapCanvas**: 시각적 맵 편집 컨트롤 + +#### AGV 특화 제약사항 +- **방향성 제약**: 전진/후진, 회전은 마크센서 위치에서만 가능 +- **도킹 제약**: 충전기(전진 도킹), 장비(후진 도킹) +- **RFID 기반 네비게이션**: 물리적 RFID와 논리적 의미 분리 + +### AGVSimulator (AGV 시뮬레이터) +**위치**: `AGVSimulator/AGVSimulator.csproj` +**실행파일**: `AGVSimulator/bin/Debug/AGVSimulator.exe` + +#### 핵심 기능 +- **가상 AGV 시뮬레이션**: 실시간 AGV 움직임 및 상태 관리 +- **맵 시각화**: 맵 에디터에서 생성한 맵 파일 로드 및 표시 +- **경로 실행**: 계산된 경로를 따라 AGV 시뮬레이션 +- **상태 모니터링**: AGV 상태, 위치, 배터리 등 실시간 모니터링 + +#### 핵심 클래스 +- **VirtualAGV**: 가상 AGV 동작 시뮬레이션 (이동, 회전, 도킹, 충전) +- **SimulatorCanvas**: AGV 및 맵 시각화 캔버스 +- **SimulatorForm**: 시뮬레이터 메인 인터페이스 +- **SimulationState**: 시뮬레이션 상태 관리 + +#### AGV 상태 +- **Idle**: 대기, **Moving**: 이동 중, **Rotating**: 회전 중 +- **Docking**: 도킹 중, **Charging**: 충전 중, **Error**: 오류 + +### 개발 워크플로우 +1. **맵 생성**: AGVMapEditor로 맵 노드 배치 및 RFID 매핑 설정 +2. **경로 테스트**: AGVSimulator로 AGV 동작 시뮬레이션 및 검증 +3. **실제 적용**: 검증된 맵 데이터를 실제 AGV 시스템에 적용 + +### RFID 매핑 아키텍처 원칙 +**중요**: 물리적 RFID ID는 의미없는 고유값으로 관리하고, 논리적 노드 ID와 별도 매핑 테이블로 분리하여 현장 유지보수성 향상 + +## 개발시 주의사항 +- 메인 애플리케이션은 Windows Forms 기반의 터치 인터페이스로 설계됨 +- 실시간 AGV 제어 시스템이므로 상태 머신 로직 수정시 신중히 접근 +- 통신 관련 코드 변경시 하드웨어 호환성 고려 필요 +- **맵 에디터/시뮬레이터**: AGVMapEditor 프로젝트에 의존성이 있으므로 먼저 빌드 필요 +- **JSON 파일 형식**: 맵 데이터는 MapNodes, RfidMappings 두 섹션으로 구성 \ No newline at end of file diff --git a/Cs_HMI/TODO.md b/Cs_HMI/TODO.md new file mode 100644 index 0000000..e08e9fc --- /dev/null +++ b/Cs_HMI/TODO.md @@ -0,0 +1,217 @@ +# AGV 이동 시스템 개발 TODO + +## 프로젝트 개요 +AGV 이동 시스템 설계 및 개발 - RFID 기반 네비게이션 시스템 + +--- + +## 1. 요구사항 분석 및 시스템 구성 요소 + +### 📍 AGV 기본 사양 +- **이동 기능**: 전진, 후진, 좌회전, 우회전, 마크스탑, 정지 +- **방향성**: 전면(모니터), 후면(리프트) +- **주요 기능**: 카트 이동 및 장비 도킹 + +### 🏭 장비 구성 (총 10대) +``` +도킹 장비 (8대): +├── 1번 로더 (후진 도킹) +├── 2번 클리너 (후진 도킹) +├── 3번 오프로더 (후진 도킹) +└── 4번 버퍼 (후진 도킹) + ├── 4-1 ~ 4-5 (5대) + +충전 장비 (2대): +├── 충전기 1 (전진 도킹) +└── 충전기 2 (전진 도킹) +``` + +### 🗺️ 네비게이션 시스템 +- **위치 인식**: RFID 기반 +- **경로 계산**: 다이나믹 라우팅 +- **방향 제어**: 도킹 방향별 접근 전략 + +--- + +## 2. 시스템 아키텍처 설계 + +### 📊 핵심 컴포넌트 구조 +``` +AGV Navigation System +├── Map Management +│ ├── RFID Node Manager +│ ├── Path Calculator +│ └── Route Optimizer +├── Movement Control +│ ├── Motion Controller +│ ├── Direction Manager +│ └── Docking Controller +├── Position Tracking +│ ├── RFID Reader Interface +│ ├── Position Monitor +│ └── Route Validator +└── Station Management + ├── Equipment Interface + ├── Charging Controller + └── Operation Scheduler +``` + +--- + +## 3. RFID 기반 맵 시스템 설계 + +### 🏷️ RFID 관리 방식 (실용적 접근) +```csharp +// RFID ID는 순수한 식별자로만 사용 (의미 없는 값) +// 예시 RFID 값들: +"1234567890", "9876543210", "5555666677" // 임의의 고유값 + +// 실제 의미는 별도 매핑 데이터에서 관리 +// RFID Writer 작업 최소화 - 물리적 교체/추가만 수행 +``` + +### 🗺️ 맵 데이터 구조 (RFID 매핑 분리) +```csharp +// RFID-맵노드 매핑 테이블 +public class RfidMapping +{ + public string RfidId { get; set; } // 물리적 RFID 값 (의미 없음) + public string LogicalNodeId { get; set; } // 논리적 노드 ID + public DateTime CreatedDate { get; set; } + public string Description { get; set; } // 설치 위치 설명 +} + +// 맵 노드 정보 (논리적) +public class MapNode +{ + public string NodeId { get; set; } // 논리적 ID (N001, N002...) + public string Name { get; set; } // 노드 이름 (로더1, 충전기1...) + public Point Position { get; set; } + public NodeType Type { get; set; } + public DockingDirection? DockDirection { get; set; } + public List ConnectedNodes { get; set; } + public bool CanRotate { get; set; } + public string StationId { get; set; } // 장비 ID (LOADER1, CHARGER1...) +} + +// RFID 리더에서 읽은 값을 논리적 노드로 변환 +public class NodeResolver +{ + public MapNode GetNodeByRfid(string rfidValue) + { + var mapping = GetRfidMapping(rfidValue); + return GetNodeById(mapping?.LogicalNodeId); + } +} + +public enum NodeType { Normal, Rotation, Docking, Charging } +public enum DockingDirection { Forward, Backward } +public enum AgvDirection { Forward, Backward, Left, Right } +``` + +--- + +## 4. 개발 순서 및 단계별 계획 + +### 🎯 Phase 1: 기반 시스템 개발 +1. **맵 에디터 도구** (.NET Framework 4.8 WinForms) + - [ ] RFID 노드 배치 및 편집 + - [ ] 연결선 설정 및 시각화 + - [ ] 도킹 방향 설정 인터페이스 + +2. **경로 계산 엔진** + - [ ] A* 알고리즘 기반 최단 경로 + - [ ] 방향성 고려 라우팅 + - [ ] 동적 경로 재계산 + +### 🎯 Phase 2: 이동 제어 시스템 +3. **AGV 모션 컨트롤러** + - [ ] 기존 AGV 컨트롤러 인터페이스 + - [ ] 방향 전환 로직 + - [ ] 도킹 시퀀스 제어 + +4. **위치 추적 시스템** + - [ ] RFID 리더 인터페이스 + - [ ] 실시간 위치 모니터링 + - [ ] 경로 이탈 감지 및 보정 + +### 🎯 Phase 3: 통합 및 테스트 +5. **시뮬레이션 테스트 도구** + - [ ] 가상 AGV 시뮬레이터 + - [ ] 경로 시각화 및 디버깅 + - [ ] 시나리오 기반 테스트 + +--- + +## 5. 테스트 프로그램 우선 개발 + +### 🧪 AGV Navigation Test Suite (.NET Framework 4.8) +``` +TestProgram Solution +├── AGVNavigationCore // 핵심 라이브러리 +├── MapEditorTool // 맵 편집 도구 +├── SimulatorApp // AGV 시뮬레이터 +└── UnitTestProjects // 단위 테스트 +``` + +### 📋 첫 번째 구현 목표 (우선순위) +1. **맵 에디터**: RFID 노드 배치 및 연결선 설정 +2. **경로 계산기**: A* 알고리즘 구현 및 테스트 +3. **시뮬레이터**: 가상 AGV로 경로 추적 테스트 +4. **RFID 매니저**: 노드 정보 관리 및 검색 + +--- + +## 🚀 다음 단계 + +**현재 권장사항**: 맵 에디터 테스트 프로그램부터 시작 + +### 맵 에디터 우선 개발 이유: +1. **시각적 확인**: RFID 노드와 경로를 시각적으로 배치하고 확인 가능 +2. **기초 검증**: 경로 계산 알고리즘을 테스트할 수 있는 기반 제공 +3. **점진적 개발**: 복잡한 AGV 제어 없이 핵심 로직부터 검증 + +### 첫 번째 단계: +``` +AGV MapEditor Test Program (.NET Framework 4.8) +├── 그래픽 맵 편집기 (WinForms) +├── RFID 노드 관리 시스템 +├── 경로 계산 엔진 (A*) +└── 맵 데이터 저장/로드 (JSON/XML) +``` + +--- + +## 📝 상세 기능 요구사항 + +### AGV 동작 특성 +- **전진**: 모니터 방향으로 이동 +- **후진**: 리프트(모니터 반대) 방향으로 이동 +- **도킹**: 장비별 지정된 방향으로 접근 필요 + - 로더/클리너/오프로더/버퍼: 후진 도킹 + - 충전기: 전진 도킹 +- **회전**: 180도 회전, 마크센서 감지시 정지 + +### 경로 관리 요구사항 +- **동적 경로 계산**: 현재 위치에서 목적지까지 +- **경로 보정**: RFID 감지시 경로 유효성 검증 및 재계산 +- **방향 최적화**: 목적지 도킹 방향 고려한 접근 경로 +- **회전 지점**: 특정 RFID에서만 180도 회전 가능 + +### RFID 시스템 요구사항 (매핑 분리 방식) +- **물리적 RFID**: 의미 없는 고유값, Writer 작업 최소화 +- **논리적 매핑**: 소프트웨어에서 RFID ↔ 노드정보 매핑 관리 +- **유지보수성**: + - RFID 손상시 → 새 RFID 설치 후 매핑만 변경 + - 장비 추가시 → 논리적 노드 추가, RFID 매핑 연결 +- **편집 용이성**: 맵 에디터에서 의미있는 이름으로 작업 +- **현장 작업 최소화**: RFID Writer 사용 빈도 대폭 감소 + +### 💡 실무 장점 +``` +기존 방식 (RFID에 의미 부여): +RFID 손상 → Writer 들고가서 → 의미있는 값 재작성 → 테스트 + +새 방식 (매핑 분리): +RFID 손상 → 임의 RFID 설치 → 소프트웨어에서 매핑만 변경 → 완료 +``` \ No newline at end of file diff --git a/Cs_HMI/build.bat b/Cs_HMI/build.bat new file mode 100644 index 0000000..de49611 --- /dev/null +++ b/Cs_HMI/build.bat @@ -0,0 +1,18 @@ +@echo off +echo Building V2GDecoder VC++ Project... + +REM Check if Visual Studio 2022 is installed +if not exist "C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe" ( + echo Visual Studio 2022 Professional not found! + echo Please install Visual Studio 2022 Professional or update the MSBuild path. + pause + exit /b 1 +) + +REM Set MSBuild path +set MSBUILD="C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe" + +REM Build Debug x64 configuration +%MSBUILD% AGVCSharp.sln -property:Configuration=Debug -property:Platform=x86 -verbosity:quiet -nologo + +pause \ No newline at end of file