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