feat: Add AGV Map Editor and Simulator tools

- Add AGVMapEditor: Visual map editing with drag-and-drop node placement
  * RFID mapping separation (physical ID ↔ logical node mapping)
  * A* pathfinding algorithm with AGV directional constraints
  * JSON map data persistence with structured format
  * Interactive map canvas with zoom/pan functionality

- Add AGVSimulator: Real-time AGV movement simulation
  * Virtual AGV with state machine (Idle, Moving, Rotating, Docking, Charging, Error)
  * Path execution and visualization from calculated routes
  * Real-time position tracking and battery simulation
  * Integration with map editor data format

- Update solution structure and build configuration
- Add comprehensive documentation in CLAUDE.md
- Implement AGV-specific constraints (forward/backward docking, rotation limits)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ChiKyun Kim
2025-09-10 17:39:23 +09:00
parent 27dcc6befa
commit 7567602479
33 changed files with 6304 additions and 2 deletions

View File

@@ -1,7 +1,7 @@
 
Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Express 15 for Windows Desktop # Visual Studio Version 17
VisualStudioVersion = 15.0.28307.1000 VisualStudioVersion = 17.14.36310.24
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Sub", "Sub", "{C423C39A-44E7-4F09-B2F7-7943975FF948}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Sub", "Sub", "{C423C39A-44E7-4F09-B2F7-7943975FF948}"
EndProject EndProject
@@ -27,6 +27,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ENIGProtocol", "SubProject\
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AGV4", "Project\AGV4.csproj", "{D6B3880D-7D5C-44E2-B6A5-CF6D881A8A38}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AGV4", "Project\AGV4.csproj", "{D6B3880D-7D5C-44E2-B6A5-CF6D881A8A38}"
EndProject 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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|x64.Build.0 = Release|Any CPU
{D6B3880D-7D5C-44E2-B6A5-CF6D881A8A38}.Release|x86.ActiveCfg = Release|x86 {D6B3880D-7D5C-44E2-B6A5-CF6D881A8A38}.Release|x86.ActiveCfg = Release|x86
{D6B3880D-7D5C-44E2-B6A5-CF6D881A8A38}.Release|x86.Build.0 = 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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE

View File

@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}</ProjectGuid>
<OutputType>WinExe</OutputType>
<RootNamespace>AGVMapEditor</RootNamespace>
<AssemblyName>AGVMapEditor</AssemblyName>
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<UseWindowsForms>true</UseWindowsForms>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Drawing" />
<Reference Include="System.Windows.Forms" />
<Reference Include="System.Xml" />
<Reference Include="Newtonsoft.Json, Version=13.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed">
<HintPath>..\packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll</HintPath>
</Reference>
<Reference Include="Microsoft.VisualBasic" />
</ItemGroup>
<ItemGroup>
<Compile Include="Models\MapNode.cs" />
<Compile Include="Models\RfidMapping.cs" />
<Compile Include="Models\NodeResolver.cs" />
<Compile Include="Models\Enums.cs" />
<Compile Include="Models\PathNode.cs" />
<Compile Include="Models\PathResult.cs" />
<Compile Include="Models\PathCalculator.cs" />
<Compile Include="Forms\MainForm.cs">
<SubType>Form</SubType>
</Compile>
<Compile Include="Forms\MainForm.Designer.cs">
<DependentUpon>MainForm.cs</DependentUpon>
</Compile>
<Compile Include="Controls\MapCanvas.cs">
<SubType>UserControl</SubType>
</Compile>
<Compile Include="Controls\MapCanvas.Designer.cs">
<DependentUpon>MapCanvas.cs</DependentUpon>
</Compile>
<Compile Include="Program.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Forms\MainForm.resx">
<DependentUpon>MainForm.cs</DependentUpon>
</EmbeddedResource>
<EmbeddedResource Include="Controls\MapCanvas.resx">
<DependentUpon>MapCanvas.cs</DependentUpon>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<None Include="packages.config" />
</ItemGroup>
<ItemGroup>
<Folder Include="Data\" />
<Folder Include="Utils\" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>

View File

@@ -0,0 +1,59 @@
namespace AGVMapEditor.Controls
{
partial class MapCanvas
{
/// <summary>
/// 필수 디자이너 변수입니다.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// 사용 중인 모든 리소스를 정리합니다.
/// </summary>
/// <param name="disposing">관리되는 리소스를 삭제해야 하면 true이고, 그렇지 않으면 false입니다.</param>
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
/// <summary>
/// 디자이너 지원에 필요한 메서드입니다.
/// 이 메서드의 내용을 코드 편집기로 수정하지 마세요.
/// </summary>
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
}
}

View File

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

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>

View File

@@ -0,0 +1,422 @@
namespace AGVMapEditor.Forms
{
partial class MainForm
{
/// <summary>
/// 필수 디자이너 변수입니다.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// 사용 중인 모든 리소스를 정리합니다.
/// </summary>
/// <param name="disposing">관리되는 리소스를 삭제해야 하면 true이고, 그렇지 않으면 false입니다.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form
/// <summary>
/// 디자이너 지원에 필요한 메서드입니다.
/// 이 메서드의 내용을 코드 편집기로 수정하지 마세요.
/// </summary>
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;
}
}

View File

@@ -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
{
/// <summary>
/// AGV 맵 에디터 메인 폼
/// </summary>
public partial class MainForm : Form
{
#region Fields
private NodeResolver _nodeResolver;
private List<MapNode> _mapNodes;
private List<RfidMapping> _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<MapNode>();
_rfidMappings = new List<RfidMapping>();
_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<MapData>(json);
_mapNodes = mapData.Nodes ?? new List<MapNode>();
_rfidMappings = mapData.RfidMappings ?? new List<RfidMapping>();
_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<MapNode> Nodes { get; set; } = new List<MapNode>();
public List<RfidMapping> RfidMappings { get; set; } = new List<RfidMapping>();
public DateTime CreatedDate { get; set; }
public string Version { get; set; } = "1.0";
}
#endregion
}
}

View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<metadata name="menuStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>17, 17</value>
</metadata>
<metadata name="statusStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>132, 17</value>
</metadata>
</root>

View File

@@ -0,0 +1,64 @@
using System;
namespace AGVMapEditor.Models
{
/// <summary>
/// 노드 타입 열거형
/// </summary>
public enum NodeType
{
/// <summary>일반 경로 노드</summary>
Normal,
/// <summary>회전 가능 지점</summary>
Rotation,
/// <summary>도킹 스테이션</summary>
Docking,
/// <summary>충전 스테이션</summary>
Charging
}
/// <summary>
/// 도킹 방향 열거형
/// </summary>
public enum DockingDirection
{
/// <summary>전진 도킹 (충전기)</summary>
Forward,
/// <summary>후진 도킹 (로더, 클리너, 오프로더, 버퍼)</summary>
Backward
}
/// <summary>
/// AGV 이동 방향 열거형
/// </summary>
public enum AgvDirection
{
/// <summary>전진 (모니터 방향)</summary>
Forward,
/// <summary>후진 (리프트 방향)</summary>
Backward,
/// <summary>좌회전</summary>
Left,
/// <summary>우회전</summary>
Right,
/// <summary>정지</summary>
Stop
}
/// <summary>
/// 장비 타입 열거형
/// </summary>
public enum StationType
{
/// <summary>로더</summary>
Loader,
/// <summary>클리너</summary>
Cleaner,
/// <summary>오프로더</summary>
Offloader,
/// <summary>버퍼</summary>
Buffer,
/// <summary>충전기</summary>
Charger
}
}

View File

@@ -0,0 +1,225 @@
using System;
using System.Collections.Generic;
using System.Drawing;
namespace AGVMapEditor.Models
{
/// <summary>
/// 맵 노드 정보를 관리하는 클래스
/// 논리적 노드로서 실제 맵의 위치와 속성을 정의
/// </summary>
public class MapNode
{
/// <summary>
/// 논리적 노드 ID (맵 에디터에서 관리하는 고유 ID)
/// 예: "N001", "N002", "LOADER1", "CHARGER1"
/// </summary>
public string NodeId { get; set; } = string.Empty;
/// <summary>
/// 노드 표시 이름 (사용자 친화적)
/// 예: "로더1", "충전기1", "교차점A", "회전지점1"
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 맵 상의 위치 좌표 (픽셀 단위)
/// </summary>
public Point Position { get; set; } = Point.Empty;
/// <summary>
/// 노드 타입
/// </summary>
public NodeType Type { get; set; } = NodeType.Normal;
/// <summary>
/// 도킹 방향 (도킹/충전 노드인 경우만 사용)
/// </summary>
public DockingDirection? DockDirection { get; set; } = null;
/// <summary>
/// 연결된 노드 ID 목록 (경로 정보)
/// </summary>
public List<string> ConnectedNodes { get; set; } = new List<string>();
/// <summary>
/// 회전 가능 여부 (180도 회전 가능한 지점)
/// </summary>
public bool CanRotate { get; set; } = false;
/// <summary>
/// 장비 ID (도킹/충전 스테이션인 경우)
/// 예: "LOADER1", "CLEANER1", "BUFFER1", "CHARGER1"
/// </summary>
public string StationId { get; set; } = string.Empty;
/// <summary>
/// 장비 타입 (도킹/충전 스테이션인 경우)
/// </summary>
public StationType? StationType { get; set; } = null;
/// <summary>
/// 노드 생성 일자
/// </summary>
public DateTime CreatedDate { get; set; } = DateTime.Now;
/// <summary>
/// 노드 수정 일자
/// </summary>
public DateTime ModifiedDate { get; set; } = DateTime.Now;
/// <summary>
/// 노드 설명 (추가 정보)
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// 노드 활성화 여부
/// </summary>
public bool IsActive { get; set; } = true;
/// <summary>
/// 노드 색상 (맵 에디터 표시용)
/// </summary>
public Color DisplayColor { get; set; } = Color.Blue;
/// <summary>
/// 기본 생성자
/// </summary>
public MapNode()
{
}
/// <summary>
/// 매개변수 생성자
/// </summary>
/// <param name="nodeId">노드 ID</param>
/// <param name="name">노드 이름</param>
/// <param name="position">위치</param>
/// <param name="type">노드 타입</param>
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);
}
/// <summary>
/// 노드 타입에 따른 기본 색상 설정
/// </summary>
/// <param name="type">노드 타입</param>
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;
}
}
/// <summary>
/// 다른 노드와의 연결 추가
/// </summary>
/// <param name="nodeId">연결할 노드 ID</param>
public void AddConnection(string nodeId)
{
if (!ConnectedNodes.Contains(nodeId))
{
ConnectedNodes.Add(nodeId);
ModifiedDate = DateTime.Now;
}
}
/// <summary>
/// 다른 노드와의 연결 제거
/// </summary>
/// <param name="nodeId">연결 해제할 노드 ID</param>
public void RemoveConnection(string nodeId)
{
if (ConnectedNodes.Remove(nodeId))
{
ModifiedDate = DateTime.Now;
}
}
/// <summary>
/// 도킹 스테이션 설정
/// </summary>
/// <param name="stationId">장비 ID</param>
/// <param name="stationType">장비 타입</param>
/// <param name="dockDirection">도킹 방향</param>
public void SetDockingStation(string stationId, StationType stationType, DockingDirection dockDirection)
{
Type = NodeType.Docking;
StationId = stationId;
StationType = stationType;
DockDirection = dockDirection;
SetDefaultColorByType(NodeType.Docking);
ModifiedDate = DateTime.Now;
}
/// <summary>
/// 충전 스테이션 설정
/// </summary>
/// <param name="stationId">충전기 ID</param>
public void SetChargingStation(string stationId)
{
Type = NodeType.Charging;
StationId = stationId;
StationType = Models.StationType.Charger;
DockDirection = DockingDirection.Forward; // 충전기는 항상 전진 도킹
SetDefaultColorByType(NodeType.Charging);
ModifiedDate = DateTime.Now;
}
/// <summary>
/// 문자열 표현
/// </summary>
public override string ToString()
{
return $"{NodeId}: {Name} ({Type}) at ({Position.X}, {Position.Y})";
}
/// <summary>
/// 노드 복사
/// </summary>
/// <returns>복사된 노드</returns>
public MapNode Clone()
{
var clone = new MapNode
{
NodeId = NodeId,
Name = Name,
Position = Position,
Type = Type,
DockDirection = DockDirection,
ConnectedNodes = new List<string>(ConnectedNodes),
CanRotate = CanRotate,
StationId = StationId,
StationType = StationType,
CreatedDate = CreatedDate,
ModifiedDate = ModifiedDate,
Description = Description,
IsActive = IsActive,
DisplayColor = DisplayColor
};
return clone;
}
}
}

View File

@@ -0,0 +1,308 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace AGVMapEditor.Models
{
/// <summary>
/// RFID 값을 논리적 노드로 변환하는 클래스
/// 실제 AGV 시스템에서 RFID 리더가 읽은 값을 맵 노드 정보로 변환
/// </summary>
public class NodeResolver
{
private List<RfidMapping> _rfidMappings;
private List<MapNode> _mapNodes;
/// <summary>
/// 기본 생성자
/// </summary>
public NodeResolver()
{
_rfidMappings = new List<RfidMapping>();
_mapNodes = new List<MapNode>();
}
/// <summary>
/// 매개변수 생성자
/// </summary>
/// <param name="rfidMappings">RFID 매핑 목록</param>
/// <param name="mapNodes">맵 노드 목록</param>
public NodeResolver(List<RfidMapping> rfidMappings, List<MapNode> mapNodes)
{
_rfidMappings = rfidMappings ?? new List<RfidMapping>();
_mapNodes = mapNodes ?? new List<MapNode>();
}
/// <summary>
/// RFID 값으로 맵 노드 검색
/// </summary>
/// <param name="rfidValue">RFID 리더에서 읽은 값</param>
/// <returns>해당하는 맵 노드, 없으면 null</returns>
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;
}
/// <summary>
/// 논리적 노드 ID로 맵 노드 검색
/// </summary>
/// <param name="nodeId">논리적 노드 ID</param>
/// <returns>해당하는 맵 노드, 없으면 null</returns>
public MapNode GetNodeById(string nodeId)
{
if (string.IsNullOrEmpty(nodeId))
return null;
return _mapNodes.FirstOrDefault(n =>
n.NodeId.Equals(nodeId, StringComparison.OrdinalIgnoreCase) && n.IsActive);
}
/// <summary>
/// 맵 노드로 연결된 RFID 값 검색
/// </summary>
/// <param name="nodeId">논리적 노드 ID</param>
/// <returns>연결된 RFID 값, 없으면 null</returns>
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;
}
/// <summary>
/// RFID 매핑 정보 검색
/// </summary>
/// <param name="rfidValue">RFID 값</param>
/// <returns>매핑 정보, 없으면 null</returns>
public RfidMapping GetRfidMapping(string rfidValue)
{
if (string.IsNullOrEmpty(rfidValue))
return null;
return _rfidMappings.FirstOrDefault(m =>
m.RfidId.Equals(rfidValue, StringComparison.OrdinalIgnoreCase) && m.IsActive);
}
/// <summary>
/// 새로운 RFID 매핑 추가
/// </summary>
/// <param name="rfidId">RFID 값</param>
/// <param name="nodeId">논리적 노드 ID</param>
/// <param name="description">설명</param>
/// <returns>추가 성공 여부</returns>
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;
}
/// <summary>
/// RFID 매핑 제거
/// </summary>
/// <param name="rfidId">제거할 RFID 값</param>
/// <returns>제거 성공 여부</returns>
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;
}
/// <summary>
/// 맵 노드 추가
/// </summary>
/// <param name="node">추가할 맵 노드</param>
/// <returns>추가 성공 여부</returns>
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;
}
/// <summary>
/// 맵 노드 제거
/// </summary>
/// <param name="nodeId">제거할 노드 ID</param>
/// <returns>제거 성공 여부</returns>
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;
}
/// <summary>
/// 특정 타입의 노드들 검색
/// </summary>
/// <param name="nodeType">노드 타입</param>
/// <returns>해당 타입의 노드 목록</returns>
public List<MapNode> GetNodesByType(NodeType nodeType)
{
return _mapNodes.Where(n => n.Type == nodeType && n.IsActive).ToList();
}
/// <summary>
/// 장비 ID로 노드 검색
/// </summary>
/// <param name="stationId">장비 ID</param>
/// <returns>해당 장비의 노드, 없으면 null</returns>
public MapNode GetNodeByStationId(string stationId)
{
if (string.IsNullOrEmpty(stationId))
return null;
return _mapNodes.FirstOrDefault(n =>
n.StationId.Equals(stationId, StringComparison.OrdinalIgnoreCase) && n.IsActive);
}
/// <summary>
/// 매핑되지 않은 노드들 검색 (RFID가 연결되지 않은 노드)
/// </summary>
/// <returns>매핑되지 않은 노드 목록</returns>
public List<MapNode> 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();
}
/// <summary>
/// 사용되지 않는 RFID 매핑들 검색 (노드가 삭제된 매핑)
/// </summary>
/// <returns>사용되지 않는 매핑 목록</returns>
public List<RfidMapping> 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();
}
/// <summary>
/// 데이터 초기화
/// </summary>
public void Clear()
{
_rfidMappings.Clear();
_mapNodes.Clear();
}
/// <summary>
/// 데이터 유효성 검증
/// </summary>
/// <returns>검증 결과 메시지 목록</returns>
public List<string> ValidateData()
{
var errors = new List<string>();
// 중복 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;
}
}
}

View File

@@ -0,0 +1,469 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Linq;
namespace AGVMapEditor.Models
{
/// <summary>
/// AGV 전용 경로 계산기 (A* 알고리즘 기반)
/// AGV의 방향성, 도킹 제약, 회전 제약을 고려한 경로 계산
/// </summary>
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<MapNode> _mapNodes;
private NodeResolver _nodeResolver;
#endregion
#region Constructor
/// <summary>
/// 생성자
/// </summary>
/// <param name="mapNodes">맵 노드 목록</param>
/// <param name="nodeResolver">노드 해결기</param>
public PathCalculator(List<MapNode> mapNodes, NodeResolver nodeResolver)
{
_mapNodes = mapNodes ?? throw new ArgumentNullException(nameof(mapNodes));
_nodeResolver = nodeResolver ?? throw new ArgumentNullException(nameof(nodeResolver));
}
#endregion
#region Public Methods
/// <summary>
/// 경로 계산 (메인 메서드)
/// </summary>
/// <param name="startNodeId">시작 노드 ID</param>
/// <param name="targetNodeId">목표 노드 ID</param>
/// <param name="currentDirection">현재 AGV 방향</param>
/// <returns>경로 계산 결과</returns>
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
};
}
}
/// <summary>
/// 경로 유효성 검증 (RFID 이탈 감지시 사용)
/// </summary>
/// <param name="currentPath">현재 경로</param>
/// <param name="currentRfidId">현재 감지된 RFID</param>
/// <returns>경로 유효성 여부</returns>
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);
}
/// <summary>
/// 동적 경로 재계산 (경로 이탈시 사용)
/// </summary>
/// <param name="currentRfidId">현재 RFID 위치</param>
/// <param name="targetNodeId">목표 노드 ID</param>
/// <param name="currentDirection">현재 방향</param>
/// <param name="originalPath">원래 경로 (참고용)</param>
/// <returns>새로운 경로</returns>
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
/// <summary>
/// 입력 값 검증
/// </summary>
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
/// <summary>
/// A* 알고리즘 실행
/// </summary>
private PathResult ExecuteAStar(string startNodeId, string targetNodeId, AgvDirection currentDirection)
{
var openSet = new SortedSet<PathNode>();
var closedSet = new HashSet<string>();
var gScore = new Dictionary<string, float>();
// 시작 노드 설정
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("경로를 찾을 수 없습니다.");
}
/// <summary>
/// 인접 노드들 처리
/// </summary>
private void ProcessNeighbors(PathNode current, string targetNodeId,
SortedSet<PathNode> openSet, HashSet<string> closedSet,
Dictionary<string, float> 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);
}
}
}
}
/// <summary>
/// 가능한 방향들 계산
/// </summary>
private List<AgvDirection> GetPossibleDirections(PathNode current, MapNode neighborNode)
{
var directions = new List<AgvDirection>();
// 기본적으로 전진/후진 가능
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;
}
/// <summary>
/// 이동 비용 계산
/// </summary>
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;
}
/// <summary>
/// 휴리스틱 함수 (목표까지의 추정 거리)
/// </summary>
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; // 좌표 단위 조정
}
/// <summary>
/// 두 점 사이의 거리 계산
/// </summary>
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);
}
/// <summary>
/// 회전 가능한 위치인지 확인
/// </summary>
private bool CanRotateAt(string nodeId)
{
var node = _mapNodes.FirstOrDefault(n => n.NodeId == nodeId);
return node != null && (node.CanRotate || node.Type == NodeType.Rotation);
}
/// <summary>
/// 도킹 접근 방향이 유효한지 확인
/// </summary>
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;
}
/// <summary>
/// 이동 명령 시퀀스 생성
/// </summary>
private List<AgvDirection> GenerateMovementSequence(PathNode from, PathNode to)
{
var sequence = new List<AgvDirection>();
// 방향 전환이 필요한 경우
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;
}
/// <summary>
/// 경로 재구성
/// </summary>
private PathResult ReconstructPath(PathNode goalNode, string startNodeId, string targetNodeId, AgvDirection startDirection)
{
var path = new List<PathNode>();
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
/// <summary>
/// 노드 간 직선 거리 계산
/// </summary>
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);
}
/// <summary>
/// 특정 노드에서 가능한 다음 노드들 조회
/// </summary>
public List<string> GetPossibleNextNodes(string currentNodeId, AgvDirection currentDirection)
{
var currentNode = _mapNodes.FirstOrDefault(n => n.NodeId == currentNodeId);
if (currentNode == null)
return new List<string>();
return currentNode.ConnectedNodes.ToList();
}
/// <summary>
/// 경로 최적화 (선택적 기능)
/// </summary>
public PathResult OptimizePath(PathResult originalPath)
{
if (originalPath == null || !originalPath.Success)
return originalPath;
// TODO: 경로 최적화 로직 구현
// - 불필요한 중간 정지점 제거
// - 회전 최소화
// - 경로 단순화
return originalPath;
}
#endregion
}
}

View File

@@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
namespace AGVMapEditor.Models
{
/// <summary>
/// A* 알고리즘에서 사용되는 경로 노드
/// </summary>
public class PathNode : IComparable<PathNode>
{
/// <summary>
/// 맵 노드 ID
/// </summary>
public string NodeId { get; set; } = string.Empty;
/// <summary>
/// AGV의 현재 방향 (이 노드에 도달했을 때의 방향)
/// </summary>
public AgvDirection Direction { get; set; } = AgvDirection.Forward;
/// <summary>
/// 시작점에서 이 노드까지의 실제 비용 (G)
/// </summary>
public float GCost { get; set; } = float.MaxValue;
/// <summary>
/// 이 노드에서 목표까지의 추정 비용 (H)
/// </summary>
public float HCost { get; set; } = 0;
/// <summary>
/// 총 비용 (F = G + H)
/// </summary>
public float FCost => GCost + HCost;
/// <summary>
/// 이전 노드 (경로 추적용)
/// </summary>
public PathNode Parent { get; set; } = null;
/// <summary>
/// 회전 횟수 (방향 전환 비용 계산용)
/// </summary>
public int RotationCount { get; set; } = 0;
/// <summary>
/// 이 노드에 도달하기 위한 이동 명령 시퀀스
/// </summary>
public List<AgvDirection> MovementSequence { get; set; } = new List<AgvDirection>();
/// <summary>
/// 기본 생성자
/// </summary>
public PathNode()
{
}
/// <summary>
/// 매개변수 생성자
/// </summary>
/// <param name="nodeId">노드 ID</param>
/// <param name="direction">AGV 방향</param>
public PathNode(string nodeId, AgvDirection direction)
{
NodeId = nodeId;
Direction = direction;
}
/// <summary>
/// 우선순위 큐를 위한 비교 (FCost 기준)
/// </summary>
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;
}
/// <summary>
/// 노드 상태 복사
/// </summary>
public PathNode Clone()
{
return new PathNode
{
NodeId = NodeId,
Direction = Direction,
GCost = GCost,
HCost = HCost,
Parent = Parent,
RotationCount = RotationCount,
MovementSequence = new List<AgvDirection>(MovementSequence)
};
}
/// <summary>
/// 고유 키 생성 (노드ID + 방향)
/// </summary>
public string GetKey()
{
return $"{NodeId}_{Direction}";
}
/// <summary>
/// 문자열 표현
/// </summary>
public override string ToString()
{
return $"{NodeId}({Direction}) F:{FCost:F1} G:{GCost:F1} H:{HCost:F1} R:{RotationCount}";
}
/// <summary>
/// 해시코드 (딕셔너리 키용)
/// </summary>
public override int GetHashCode()
{
return GetKey().GetHashCode();
}
/// <summary>
/// 동등성 비교
/// </summary>
public override bool Equals(object obj)
{
if (obj is PathNode other)
{
return NodeId == other.NodeId && Direction == other.Direction;
}
return false;
}
}
}

View File

@@ -0,0 +1,277 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace AGVMapEditor.Models
{
/// <summary>
/// 경로 계산 결과
/// </summary>
public class PathResult
{
/// <summary>
/// 경로 계산 성공 여부
/// </summary>
public bool Success { get; set; } = false;
/// <summary>
/// 경로상의 노드 ID 시퀀스
/// </summary>
public List<string> NodeSequence { get; set; } = new List<string>();
/// <summary>
/// AGV 이동 명령 시퀀스
/// </summary>
public List<AgvDirection> MovementSequence { get; set; } = new List<AgvDirection>();
/// <summary>
/// 총 이동 거리 (비용)
/// </summary>
public float TotalDistance { get; set; } = 0;
/// <summary>
/// 총 회전 횟수
/// </summary>
public int TotalRotations { get; set; } = 0;
/// <summary>
/// 예상 소요 시간 (초)
/// </summary>
public float EstimatedTime { get; set; } = 0;
/// <summary>
/// 시작 노드 ID
/// </summary>
public string StartNodeId { get; set; } = string.Empty;
/// <summary>
/// 목표 노드 ID
/// </summary>
public string TargetNodeId { get; set; } = string.Empty;
/// <summary>
/// 시작시 AGV 방향
/// </summary>
public AgvDirection StartDirection { get; set; } = AgvDirection.Forward;
/// <summary>
/// 도착시 AGV 방향
/// </summary>
public AgvDirection EndDirection { get; set; } = AgvDirection.Forward;
/// <summary>
/// 경로 계산에 걸린 시간 (밀리초)
/// </summary>
public long CalculationTime { get; set; } = 0;
/// <summary>
/// 오류 메시지 (실패시)
/// </summary>
public string ErrorMessage { get; set; } = string.Empty;
/// <summary>
/// 경로상의 상세 정보 (디버깅용)
/// </summary>
public List<PathNode> DetailedPath { get; set; } = new List<PathNode>();
/// <summary>
/// 회전이 발생하는 노드들
/// </summary>
public List<string> RotationNodes { get; set; } = new List<string>();
/// <summary>
/// 기본 생성자
/// </summary>
public PathResult()
{
}
/// <summary>
/// 성공 결과 생성자
/// </summary>
public PathResult(List<PathNode> 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<PathNode>(path);
// 노드 시퀀스 구성
NodeSequence = path.Select(p => p.NodeId).ToList();
// 이동 명령 시퀀스 구성
MovementSequence = new List<AgvDirection>();
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();
}
/// <summary>
/// 실패 결과 생성자
/// </summary>
public PathResult(string errorMessage)
{
Success = false;
ErrorMessage = errorMessage;
}
/// <summary>
/// 예상 소요 시간 계산
/// </summary>
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;
}
/// <summary>
/// 경로 요약 정보
/// </summary>
public string GetSummary()
{
if (!Success)
{
return $"경로 계산 실패: {ErrorMessage}";
}
return $"경로: {NodeSequence.Count}개 노드, " +
$"거리: {TotalDistance:F1}, " +
$"회전: {TotalRotations}회, " +
$"예상시간: {EstimatedTime:F1}초";
}
/// <summary>
/// 상세 경로 정보
/// </summary>
public List<string> GetDetailedSteps()
{
var steps = new List<string>();
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;
}
/// <summary>
/// RFID 시퀀스 추출 (실제 AGV 제어용)
/// </summary>
public List<string> GetRfidSequence(NodeResolver nodeResolver)
{
var rfidSequence = new List<string>();
foreach (var nodeId in NodeSequence)
{
var rfidId = nodeResolver.GetRfidByNodeId(nodeId);
if (!string.IsNullOrEmpty(rfidId))
{
rfidSequence.Add(rfidId);
}
}
return rfidSequence;
}
/// <summary>
/// 경로 유효성 검증
/// </summary>
public bool ValidatePath(List<MapNode> 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;
}
/// <summary>
/// JSON 직렬화를 위한 문자열 변환
/// </summary>
public override string ToString()
{
return GetSummary();
}
}
}

View File

@@ -0,0 +1,79 @@
using System;
namespace AGVMapEditor.Models
{
/// <summary>
/// RFID와 논리적 노드 ID를 매핑하는 클래스
/// 물리적 RFID는 의미없는 고유값, 논리적 노드는 맵 에디터에서 관리
/// </summary>
public class RfidMapping
{
/// <summary>
/// 물리적 RFID 값 (의미 없는 고유 식별자)
/// 예: "1234567890", "ABCDEF1234" 등
/// </summary>
public string RfidId { get; set; } = string.Empty;
/// <summary>
/// 논리적 노드 ID (맵 에디터에서 관리)
/// 예: "N001", "N002", "LOADER1", "CHARGER1" 등
/// </summary>
public string LogicalNodeId { get; set; } = string.Empty;
/// <summary>
/// 매핑 생성 일자
/// </summary>
public DateTime CreatedDate { get; set; } = DateTime.Now;
/// <summary>
/// 마지막 수정 일자
/// </summary>
public DateTime ModifiedDate { get; set; } = DateTime.Now;
/// <summary>
/// 설치 위치 설명 (현장 작업자용)
/// 예: "로더1번 앞", "충전기2번 입구", "복도 교차점" 등
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// RFID 상태 (정상, 손상, 교체예정 등)
/// </summary>
public string Status { get; set; } = "정상";
/// <summary>
/// 매핑 활성화 여부
/// </summary>
public bool IsActive { get; set; } = true;
/// <summary>
/// 기본 생성자
/// </summary>
public RfidMapping()
{
}
/// <summary>
/// 매개변수 생성자
/// </summary>
/// <param name="rfidId">물리적 RFID ID</param>
/// <param name="logicalNodeId">논리적 노드 ID</param>
/// <param name="description">설치 위치 설명</param>
public RfidMapping(string rfidId, string logicalNodeId, string description = "")
{
RfidId = rfidId;
LogicalNodeId = logicalNodeId;
Description = description;
CreatedDate = DateTime.Now;
ModifiedDate = DateTime.Now;
}
/// <summary>
/// 문자열 표현
/// </summary>
public override string ToString()
{
return $"{RfidId} → {LogicalNodeId} ({Description})";
}
}
}

View File

@@ -0,0 +1,26 @@
using System;
using System.Windows.Forms;
using AGVMapEditor.Forms;
namespace AGVMapEditor
{
/// <summary>
/// 애플리케이션 진입점
/// </summary>
internal static class Program
{
/// <summary>
/// 애플리케이션의 기본 진입점입니다.
/// </summary>
[STAThread]
static void Main()
{
// Windows Forms 애플리케이션 초기화
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
// 메인 폼 실행
Application.Run(new MainForm());
}
}
}

View File

@@ -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")]

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" />
</packages>

View File

@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{B2C3D4E5-F6G7-8901-BCDE-F23456789012}</ProjectGuid>
<OutputType>WinExe</OutputType>
<RootNamespace>AGVSimulator</RootNamespace>
<AssemblyName>AGVSimulator</AssemblyName>
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<UseWindowsForms>true</UseWindowsForms>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Drawing" />
<Reference Include="System.Windows.Forms" />
<Reference Include="System.Xml" />
<Reference Include="Newtonsoft.Json, Version=13.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed">
<HintPath>..\packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<Compile Include="Models\VirtualAGV.cs" />
<Compile Include="Models\SimulationState.cs" />
<Compile Include="Controls\SimulatorCanvas.cs">
<SubType>UserControl</SubType>
</Compile>
<Compile Include="Controls\SimulatorCanvas.Designer.cs">
<DependentUpon>SimulatorCanvas.cs</DependentUpon>
</Compile>
<Compile Include="Forms\SimulatorForm.cs">
<SubType>Form</SubType>
</Compile>
<Compile Include="Forms\SimulatorForm.Designer.cs">
<DependentUpon>SimulatorForm.cs</DependentUpon>
</Compile>
<Compile Include="Program.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Controls\SimulatorCanvas.resx">
<DependentUpon>SimulatorCanvas.cs</DependentUpon>
</EmbeddedResource>
<EmbeddedResource Include="Forms\SimulatorForm.resx">
<DependentUpon>SimulatorForm.cs</DependentUpon>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<None Include="packages.config" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AGVMapEditor\AGVMapEditor.csproj">
<Project>{a1b2c3d4-e5f6-7890-abcd-ef1234567890}</Project>
<Name>AGVMapEditor</Name>
</ProjectReference>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>

View File

@@ -0,0 +1,52 @@
namespace AGVSimulator.Controls
{
partial class SimulatorCanvas
{
/// <summary>
/// 필수 디자이너 변수입니다.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// 사용 중인 모든 리소스를 정리합니다.
/// </summary>
/// <param name="disposing">관리되는 리소스를 삭제해야 하면 true이고, 그렇지 않으면 false입니다.</param>
protected override void Dispose(bool disposing)
{
if (disposing)
{
if (components != null)
{
components.Dispose();
}
// 커스텀 리소스 정리
CleanupResources();
}
base.Dispose(disposing);
}
#region
/// <summary>
/// 디자이너 지원에 필요한 메서드입니다.
/// 이 메서드의 내용을 코드 편집기로 수정하지 마세요.
/// </summary>
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
}
}

View File

@@ -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
{
/// <summary>
/// AGV 시뮬레이션 시각화 캔버스
/// </summary>
public partial class SimulatorCanvas : UserControl
{
#region Fields
private List<MapNode> _mapNodes;
private List<VirtualAGV> _agvList;
private PathResult _currentPath;
// 그래픽 설정
private float _zoom = 1.0f;
private Point _panOffset = Point.Empty;
private bool _isPanning = false;
private Point _lastMousePos = Point.Empty;
// 색상 설정
private readonly Brush _normalNodeBrush = Brushes.LightBlue;
private readonly Brush _rotationNodeBrush = Brushes.Yellow;
private readonly Brush _dockingNodeBrush = Brushes.Orange;
private readonly Brush _chargingNodeBrush = Brushes.Green;
private readonly Brush _agvBrush = Brushes.Red;
private readonly Brush _pathBrush = Brushes.Purple;
private readonly Pen _connectionPen = new Pen(Color.Gray, 2);
private readonly Pen _pathPen = new Pen(Color.Purple, 3);
private readonly Pen _agvPen = new Pen(Color.Red, 3);
// 크기 설정
private const int NODE_SIZE = 20;
private const int AGV_SIZE = 30;
private const int CONNECTION_ARROW_SIZE = 8;
#endregion
#region Properties
/// <summary>
/// 맵 노드 목록
/// </summary>
public List<MapNode> MapNodes
{
get => _mapNodes;
set
{
_mapNodes = value;
Invalidate();
}
}
/// <summary>
/// AGV 목록
/// </summary>
public List<VirtualAGV> AGVList
{
get => _agvList;
set
{
_agvList = value;
Invalidate();
}
}
/// <summary>
/// 현재 경로
/// </summary>
public PathResult CurrentPath
{
get => _currentPath;
set
{
_currentPath = value;
Invalidate();
}
}
#endregion
#region Constructor
public SimulatorCanvas()
{
InitializeComponent();
InitializeCanvas();
}
#endregion
#region Initialization
private void InitializeCanvas()
{
_mapNodes = new List<MapNode>();
_agvList = new List<VirtualAGV>();
SetStyle(ControlStyles.AllPaintingInWmPaint |
ControlStyles.UserPaint |
ControlStyles.DoubleBuffer |
ControlStyles.ResizeRedraw, true);
BackColor = Color.White;
// 마우스 이벤트 연결
MouseDown += OnMouseDown;
MouseMove += OnMouseMove;
MouseUp += OnMouseUp;
MouseWheel += OnMouseWheel;
}
#endregion
#region Public Methods
/// <summary>
/// AGV 추가
/// </summary>
public void AddAGV(VirtualAGV agv)
{
if (_agvList == null)
_agvList = new List<VirtualAGV>();
_agvList.Add(agv);
// AGV 이벤트 연결
agv.PositionChanged += OnAGVPositionChanged;
agv.StateChanged += OnAGVStateChanged;
Invalidate();
}
/// <summary>
/// AGV 제거
/// </summary>
public void RemoveAGV(string agvId)
{
var agv = _agvList?.FirstOrDefault(a => a.AgvId == agvId);
if (agv != null)
{
// 이벤트 연결 해제
agv.PositionChanged -= OnAGVPositionChanged;
agv.StateChanged -= OnAGVStateChanged;
_agvList.Remove(agv);
Invalidate();
}
}
/// <summary>
/// 모든 AGV 제거
/// </summary>
public void ClearAGVs()
{
if (_agvList != null)
{
foreach (var agv in _agvList)
{
agv.PositionChanged -= OnAGVPositionChanged;
agv.StateChanged -= OnAGVStateChanged;
}
_agvList.Clear();
Invalidate();
}
}
/// <summary>
/// 확대/축소 초기화
/// </summary>
public void ResetZoom()
{
_zoom = 1.0f;
_panOffset = Point.Empty;
Invalidate();
}
/// <summary>
/// 맵 전체 맞춤
/// </summary>
public void FitToMap()
{
if (_mapNodes == null || _mapNodes.Count == 0)
return;
var minX = _mapNodes.Min(n => n.Position.X);
var maxX = _mapNodes.Max(n => n.Position.X);
var minY = _mapNodes.Min(n => n.Position.Y);
var maxY = _mapNodes.Max(n => n.Position.Y);
var mapWidth = maxX - minX + 100; // 여백 추가
var mapHeight = maxY - minY + 100;
var zoomX = (float)Width / mapWidth;
var zoomY = (float)Height / mapHeight;
_zoom = Math.Min(zoomX, zoomY) * 0.9f; // 약간의 여백
_panOffset = new Point(
(int)((Width - mapWidth * _zoom) / 2 - minX * _zoom),
(int)((Height - mapHeight * _zoom) / 2 - minY * _zoom)
);
Invalidate();
}
#endregion
#region Event Handlers
private void OnAGVPositionChanged(object sender, Point newPosition)
{
Invalidate(); // AGV 위치 변경시 화면 갱신
}
private void OnAGVStateChanged(object sender, AGVState newState)
{
Invalidate(); // AGV 상태 변경시 화면 갱신
}
private void OnMouseDown(object sender, MouseEventArgs e)
{
if (e.Button == MouseButtons.Right)
{
_isPanning = true;
_lastMousePos = e.Location;
Cursor = Cursors.Hand;
}
}
private void OnMouseMove(object sender, MouseEventArgs e)
{
if (_isPanning)
{
var deltaX = e.X - _lastMousePos.X;
var deltaY = e.Y - _lastMousePos.Y;
_panOffset = new Point(
_panOffset.X + deltaX,
_panOffset.Y + deltaY
);
_lastMousePos = e.Location;
Invalidate();
}
}
private void OnMouseUp(object sender, MouseEventArgs e)
{
if (e.Button == MouseButtons.Right)
{
_isPanning = false;
Cursor = Cursors.Default;
}
}
private void OnMouseWheel(object sender, MouseEventArgs e)
{
var zoomFactor = e.Delta > 0 ? 1.1f : 0.9f;
var newZoom = _zoom * zoomFactor;
if (newZoom >= 0.1f && newZoom <= 10.0f)
{
// 마우스 위치 기준으로 줌
var mouseX = e.X - _panOffset.X;
var mouseY = e.Y - _panOffset.Y;
_panOffset = new Point(
(int)(_panOffset.X - mouseX * (zoomFactor - 1)),
(int)(_panOffset.Y - mouseY * (zoomFactor - 1))
);
_zoom = newZoom;
Invalidate();
}
}
#endregion
#region Painting
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
var g = e.Graphics;
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
// 변환 행렬 설정
g.TranslateTransform(_panOffset.X, _panOffset.Y);
g.ScaleTransform(_zoom, _zoom);
// 배경 그리드 그리기
DrawGrid(g);
// 맵 노드 연결선 그리기
DrawNodeConnections(g);
// 경로 그리기
if (_currentPath != null && _currentPath.Success)
{
DrawPath(g);
}
// 맵 노드 그리기
DrawMapNodes(g);
// AGV 그리기
DrawAGVs(g);
// 정보 표시 (변환 해제)
g.ResetTransform();
DrawInfo(g);
}
private void DrawGrid(Graphics g)
{
var gridSize = 50;
var pen = new Pen(Color.LightGray, 1);
var startX = -(int)(_panOffset.X / _zoom / gridSize) * gridSize;
var startY = -(int)(_panOffset.Y / _zoom / gridSize) * gridSize;
var endX = startX + (int)(Width / _zoom) + gridSize;
var endY = startY + (int)(Height / _zoom) + gridSize;
for (int x = startX; x <= endX; x += gridSize)
{
g.DrawLine(pen, x, startY, x, endY);
}
for (int y = startY; y <= endY; y += gridSize)
{
g.DrawLine(pen, startX, y, endX, y);
}
pen.Dispose();
}
private void DrawNodeConnections(Graphics g)
{
if (_mapNodes == null) return;
foreach (var node in _mapNodes)
{
if (node.ConnectedNodes != null)
{
foreach (var connectedNodeId in node.ConnectedNodes)
{
var connectedNode = _mapNodes.FirstOrDefault(n => n.NodeId == connectedNodeId);
if (connectedNode != null)
{
DrawConnection(g, node.Position, connectedNode.Position);
}
}
}
}
}
private void DrawConnection(Graphics g, Point from, Point to)
{
g.DrawLine(_connectionPen, from, to);
// 방향 화살표 그리기
var angle = Math.Atan2(to.Y - from.Y, to.X - from.X);
var arrowX = to.X - CONNECTION_ARROW_SIZE * Math.Cos(angle);
var arrowY = to.Y - CONNECTION_ARROW_SIZE * Math.Sin(angle);
var arrowPoint1 = new PointF(
(float)(arrowX - CONNECTION_ARROW_SIZE * Math.Cos(angle - Math.PI / 6)),
(float)(arrowY - CONNECTION_ARROW_SIZE * Math.Sin(angle - Math.PI / 6))
);
var arrowPoint2 = new PointF(
(float)(arrowX - CONNECTION_ARROW_SIZE * Math.Cos(angle + Math.PI / 6)),
(float)(arrowY - CONNECTION_ARROW_SIZE * Math.Sin(angle + Math.PI / 6))
);
g.DrawLine(_connectionPen, to, arrowPoint1);
g.DrawLine(_connectionPen, to, arrowPoint2);
}
private void DrawPath(Graphics g)
{
if (_currentPath?.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
}
}

View File

@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion. Classes that don't support this are
serialized and stored with the mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>

View File

@@ -0,0 +1,64 @@
namespace AGVSimulator.Forms
{
partial class SimulatorForm
{
/// <summary>
/// 필수 디자이너 변수입니다.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// 사용 중인 모든 리소스를 정리합니다.
/// </summary>
/// <param name="disposing">관리되는 리소스를 삭제해야 하면 true이고, 그렇지 않으면 false입니다.</param>
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
/// <summary>
/// 디자이너 지원에 필요한 메서드입니다.
/// 이 메서드의 내용을 코드 편집기로 수정하지 마세요.
/// </summary>
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
}
}

View File

@@ -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
{
/// <summary>
/// AGV 시뮬레이터 메인 폼
/// </summary>
public partial class SimulatorForm : Form
{
#region Fields
private SimulatorCanvas _simulatorCanvas;
private List<MapNode> _mapNodes;
private List<RfidMapping> _rfidMappings;
private NodeResolver _nodeResolver;
private PathCalculator _pathCalculator;
private List<VirtualAGV> _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
/// <summary>
/// 시뮬레이션 상태
/// </summary>
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<MapNode>();
_rfidMappings = new List<RfidMapping>();
_agvList = new List<VirtualAGV>();
_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<MapFileData>(json);
if (mapData != null)
{
_mapNodes = mapData.MapNodes ?? new List<MapNode>();
_rfidMappings = mapData.RfidMappings ?? new List<RfidMapping>();
}
else
{
_mapNodes = new List<MapNode>();
_rfidMappings = new List<RfidMapping>();
}
// 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<MapNode> MapNodes { get; set; }
public List<RfidMapping> 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
}
}

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>

View File

@@ -0,0 +1,135 @@
using System;
namespace AGVSimulator.Models
{
/// <summary>
/// 시뮬레이션 상태 관리 클래스
/// </summary>
public class SimulationState
{
#region Properties
/// <summary>
/// 시뮬레이션 실행 중 여부
/// </summary>
public bool IsRunning { get; set; }
/// <summary>
/// 시뮬레이션 시작 시간
/// </summary>
public DateTime? StartTime { get; set; }
/// <summary>
/// 시뮬레이션 경과 시간
/// </summary>
public TimeSpan ElapsedTime => StartTime.HasValue ? DateTime.Now - StartTime.Value : TimeSpan.Zero;
/// <summary>
/// 시뮬레이션 속도 배율 (1.0 = 실시간, 2.0 = 2배속)
/// </summary>
public float SpeedMultiplier { get; set; } = 1.0f;
/// <summary>
/// 총 처리된 이벤트 수
/// </summary>
public int TotalEvents { get; set; }
/// <summary>
/// 총 이동 거리 (모든 AGV 합계)
/// </summary>
public float TotalDistance { get; set; }
/// <summary>
/// 발생한 오류 수
/// </summary>
public int ErrorCount { get; set; }
#endregion
#region Constructor
/// <summary>
/// 기본 생성자
/// </summary>
public SimulationState()
{
Reset();
}
#endregion
#region Public Methods
/// <summary>
/// 시뮬레이션 시작
/// </summary>
public void Start()
{
if (!IsRunning)
{
IsRunning = true;
StartTime = DateTime.Now;
}
}
/// <summary>
/// 시뮬레이션 정지
/// </summary>
public void Stop()
{
IsRunning = false;
}
/// <summary>
/// 시뮬레이션 상태 초기화
/// </summary>
public void Reset()
{
IsRunning = false;
StartTime = null;
SpeedMultiplier = 1.0f;
TotalEvents = 0;
TotalDistance = 0;
ErrorCount = 0;
}
/// <summary>
/// 이벤트 발생 시 호출
/// </summary>
public void RecordEvent()
{
TotalEvents++;
}
/// <summary>
/// 이동 거리 추가
/// </summary>
/// <param name="distance">이동한 거리</param>
public void AddDistance(float distance)
{
TotalDistance += distance;
}
/// <summary>
/// 오류 발생 시 호출
/// </summary>
public void RecordError()
{
ErrorCount++;
}
/// <summary>
/// 통계 정보 조회
/// </summary>
/// <returns>통계 정보 문자열</returns>
public string GetStatistics()
{
return $"실행시간: {ElapsedTime:hh\\:mm\\:ss}, " +
$"이벤트: {TotalEvents}, " +
$"총거리: {TotalDistance:F1}, " +
$"오류: {ErrorCount}";
}
#endregion
}
}

View File

@@ -0,0 +1,482 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using AGVMapEditor.Models;
namespace AGVSimulator.Models
{
/// <summary>
/// 가상 AGV 상태
/// </summary>
public enum AGVState
{
Idle, // 대기
Moving, // 이동 중
Rotating, // 회전 중
Docking, // 도킹 중
Charging, // 충전 중
Error // 오류
}
/// <summary>
/// 가상 AGV 클래스
/// 실제 AGV의 동작을 시뮬레이션
/// </summary>
public class VirtualAGV
{
#region Events
/// <summary>
/// AGV 상태 변경 이벤트
/// </summary>
public event EventHandler<AGVState> StateChanged;
/// <summary>
/// 위치 변경 이벤트
/// </summary>
public event EventHandler<Point> PositionChanged;
/// <summary>
/// RFID 감지 이벤트
/// </summary>
public event EventHandler<string> RfidDetected;
/// <summary>
/// 경로 완료 이벤트
/// </summary>
public event EventHandler<PathResult> PathCompleted;
/// <summary>
/// 오류 발생 이벤트
/// </summary>
public event EventHandler<string> 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<string> _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
/// <summary>
/// AGV ID
/// </summary>
public string AgvId => _agvId;
/// <summary>
/// 현재 위치
/// </summary>
public Point CurrentPosition => _currentPosition;
/// <summary>
/// 현재 방향
/// </summary>
public AgvDirection CurrentDirection => _currentDirection;
/// <summary>
/// 현재 상태
/// </summary>
public AGVState CurrentState => _currentState;
/// <summary>
/// 현재 속도
/// </summary>
public float CurrentSpeed => _currentSpeed;
/// <summary>
/// 현재 경로
/// </summary>
public PathResult CurrentPath => _currentPath;
/// <summary>
/// 현재 노드 ID
/// </summary>
public string CurrentNodeId => _currentNodeId;
/// <summary>
/// 목표 위치
/// </summary>
public Point TargetPosition => _targetPosition;
/// <summary>
/// 배터리 레벨 (시뮬레이션)
/// </summary>
public float BatteryLevel { get; set; } = 100.0f;
#endregion
#region Constructor
/// <summary>
/// 생성자
/// </summary>
/// <param name="agvId">AGV ID</param>
/// <param name="startPosition">시작 위치</param>
/// <param name="startDirection">시작 방향</param>
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
/// <summary>
/// 경로 실행 시작
/// </summary>
/// <param name="path">실행할 경로</param>
/// <param name="mapNodes">맵 노드 목록</param>
public void StartPath(PathResult path, List<MapNode> mapNodes)
{
if (path == null || !path.Success)
{
OnError("유효하지 않은 경로입니다.");
return;
}
_currentPath = path;
_remainingNodes = new List<string>(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]}");
}
}
}
/// <summary>
/// 경로 정지
/// </summary>
public void StopPath()
{
_moveTimer.Stop();
_currentPath = null;
_remainingNodes?.Clear();
SetState(AGVState.Idle);
_currentSpeed = 0;
}
/// <summary>
/// 긴급 정지
/// </summary>
public void EmergencyStop()
{
StopPath();
OnError("긴급 정지가 실행되었습니다.");
}
/// <summary>
/// 수동 이동 (테스트용)
/// </summary>
/// <param name="targetPosition">목표 위치</param>
public void MoveTo(Point targetPosition)
{
_targetPosition = targetPosition;
_moveStartPosition = _currentPosition;
_moveTargetPosition = targetPosition;
_moveProgress = 0;
SetState(AGVState.Moving);
_moveTimer.Start();
}
/// <summary>
/// 수동 회전 (테스트용)
/// </summary>
/// <param name="direction">회전 방향</param>
public void Rotate(AgvDirection direction)
{
if (_currentState != AGVState.Idle)
return;
SetState(AGVState.Rotating);
// 시뮬레이션: 즉시 방향 변경 (실제로는 시간이 걸림)
_currentDirection = direction;
System.Threading.Thread.Sleep(500); // 회전 시간 시뮬레이션
SetState(AGVState.Idle);
}
/// <summary>
/// 충전 시작 (시뮬레이션)
/// </summary>
public void StartCharging()
{
if (_currentState == AGVState.Idle)
{
SetState(AGVState.Charging);
// 충전 시뮬레이션 시작
}
}
/// <summary>
/// 충전 종료
/// </summary>
public void StopCharging()
{
if (_currentState == AGVState.Charging)
{
SetState(AGVState.Idle);
}
}
/// <summary>
/// AGV 정보 조회
/// </summary>
public string GetStatus()
{
return $"AGV[{_agvId}] 위치:({_currentPosition.X},{_currentPosition.Y}) " +
$"방향:{_currentDirection} 상태:{_currentState} " +
$"속도:{_currentSpeed:F1} 배터리:{BatteryLevel:F1}%";
}
/// <summary>
/// 현재 RFID 시뮬레이션 (현재 위치 기준)
/// </summary>
public string SimulateRfidReading(List<MapNode> mapNodes, List<RfidMapping> 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<MapNode> 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
/// <summary>
/// 리소스 정리
/// </summary>
public void Dispose()
{
_moveTimer?.Stop();
_moveTimer?.Dispose();
}
#endregion
}
}

View File

@@ -0,0 +1,32 @@
using System;
using System.Windows.Forms;
using AGVSimulator.Forms;
namespace AGVSimulator
{
/// <summary>
/// AGV 시뮬레이터 프로그램 진입점
/// </summary>
static class Program
{
/// <summary>
/// 애플리케이션의 주 진입점입니다.
/// </summary>
[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);
}
}
}
}

View File

@@ -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")]

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" />
</packages>

141
Cs_HMI/CLAUDE.md Normal file
View File

@@ -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 두 섹션으로 구성

217
Cs_HMI/TODO.md Normal file
View File

@@ -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<string> 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 설치 → 소프트웨어에서 매핑만 변경 → 완료
```

18
Cs_HMI/build.bat Normal file
View File

@@ -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