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

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