refactor: Consolidate RFID mapping and add bidirectional pathfinding

Major improvements to AGV navigation system:

• Consolidated RFID management into MapNode, removing duplicate RfidMapping class
• Enhanced MapNode with RFID metadata fields (RfidStatus, RfidDescription)
• Added automatic bidirectional connection generation in pathfinding algorithms
• Updated all components to use unified MapNode-based RFID system
• Added command line argument support for AGVMapEditor auto-loading files
• Fixed pathfinding failures by ensuring proper node connectivity

Technical changes:
- Removed RfidMapping class and dependencies across all projects
- Updated AStarPathfinder with EnsureBidirectionalConnections() method
- Modified MapLoader to use AssignAutoRfidIds() for RFID automation
- Enhanced UnifiedAGVCanvas, SimulatorForm, and MainForm for MapNode integration
- Improved data consistency and reduced memory footprint

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ChiKyun Kim
2025-09-11 16:41:52 +09:00
parent 7567602479
commit de0e39e030
50 changed files with 9578 additions and 1854 deletions

View File

@@ -0,0 +1,101 @@
<?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>{C5F7A8B2-8D3E-4A1B-9C6E-7F4D5E2A9B1C}</ProjectGuid>
<OutputType>Library</OutputType>
<AppDesignerFolder>Properties</AppDesignerFolder>
<RootNamespace>AGVNavigationCore</RootNamespace>
<AssemblyName>AGVNavigationCore</AssemblyName>
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<Deterministic>true</Deterministic>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<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' ">
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x86'">
<DebugSymbols>true</DebugSymbols>
<OutputPath>bin\x86\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<DebugType>full</DebugType>
<PlatformTarget>x86</PlatformTarget>
<ErrorReport>prompt</ErrorReport>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x86'">
<OutputPath>bin\x86\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<Optimize>true</Optimize>
<DebugType>pdbonly</DebugType>
<PlatformTarget>x86</PlatformTarget>
<ErrorReport>prompt</ErrorReport>
</PropertyGroup>
<ItemGroup>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Drawing" />
<Reference Include="System.Windows.Forms" />
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="System.Data" />
<Reference Include="System.Net.Http" />
<Reference Include="System.Xml" />
<Reference Include="Newtonsoft.Json, Version=13.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
<HintPath>..\packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<Compile Include="Models\Enums.cs" />
<Compile Include="Models\MapLoader.cs" />
<Compile Include="Models\MapNode.cs" />
<Compile Include="Models\RfidMapping.cs" />
<Compile Include="PathFinding\PathNode.cs" />
<Compile Include="PathFinding\PathResult.cs" />
<Compile Include="PathFinding\AStarPathfinder.cs" />
<Compile Include="PathFinding\AGVPathfinder.cs" />
<Compile Include="PathFinding\AGVPathResult.cs" />
<Compile Include="PathFinding\RfidBasedPathfinder.cs" />
<Compile Include="PathFinding\RfidPathResult.cs" />
<Compile Include="Controls\UnifiedAGVCanvas.cs">
<SubType>UserControl</SubType>
</Compile>
<Compile Include="Controls\UnifiedAGVCanvas.Designer.cs">
<DependentUpon>UnifiedAGVCanvas.cs</DependentUpon>
</Compile>
<Compile Include="Controls\UnifiedAGVCanvas.Events.cs">
<DependentUpon>UnifiedAGVCanvas.cs</DependentUpon>
<SubType>UserControl</SubType>
</Compile>
<Compile Include="Controls\UnifiedAGVCanvas.Mouse.cs">
<DependentUpon>UnifiedAGVCanvas.cs</DependentUpon>
<SubType>UserControl</SubType>
</Compile>
<Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup>
<ItemGroup>
<Folder Include="Utils\" />
</ItemGroup>
<ItemGroup>
<None Include="build.bat" />
<None Include="packages.config" />
<None Include="README.md" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>

View File

@@ -0,0 +1,41 @@
namespace AGVNavigationCore.Controls
{
partial class UnifiedAGVCanvas
{
/// <summary>
/// 필수 디자이너 변수입니다.
/// </summary>
private System.ComponentModel.IContainer components = null;
#region
/// <summary>
/// 디자이너 지원에 필요한 메서드입니다.
/// 이 메서드의 내용을 코드 편집기로 수정하지 마세요.
/// </summary>
private void InitializeComponent()
{
this.SuspendLayout();
//
// UnifiedAGVCanvas
//
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 12F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.BackColor = System.Drawing.Color.White;
this.Name = "UnifiedAGVCanvas";
this.Size = new System.Drawing.Size(800, 600);
this.Paint += new System.Windows.Forms.PaintEventHandler(this.UnifiedAGVCanvas_Paint);
this.MouseClick += new System.Windows.Forms.MouseEventHandler(this.UnifiedAGVCanvas_MouseClick);
this.MouseDoubleClick += new System.Windows.Forms.MouseEventHandler(this.UnifiedAGVCanvas_MouseDoubleClick);
this.MouseDown += new System.Windows.Forms.MouseEventHandler(this.UnifiedAGVCanvas_MouseDown);
this.MouseMove += new System.Windows.Forms.MouseEventHandler(this.UnifiedAGVCanvas_MouseMove);
this.MouseUp += new System.Windows.Forms.MouseEventHandler(this.UnifiedAGVCanvas_MouseUp);
this.MouseWheel += new System.Windows.Forms.MouseEventHandler(this.UnifiedAGVCanvas_MouseWheel);
this.ResumeLayout(false);
}
#endregion
}
}

View File

@@ -0,0 +1,801 @@
using System;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Linq;
using System.Windows.Forms;
using AGVNavigationCore.Models;
using AGVNavigationCore.PathFinding;
namespace AGVNavigationCore.Controls
{
public partial class UnifiedAGVCanvas
{
#region Paint Events
private void UnifiedAGVCanvas_Paint(object sender, PaintEventArgs e)
{
var g = e.Graphics;
g.SmoothingMode = SmoothingMode.AntiAlias;
g.InterpolationMode = InterpolationMode.High;
// 변환 행렬 설정 (줌 및 팬)
var transform = new Matrix();
transform.Scale(_zoomFactor, _zoomFactor);
transform.Translate(_panOffset.X, _panOffset.Y);
g.Transform = transform;
try
{
// 그리드 그리기
if (_showGrid)
{
DrawGrid(g);
}
// 노드 연결선 그리기
DrawConnections(g);
// 경로 그리기
DrawPaths(g);
// 노드 그리기
DrawNodes(g);
// AGV 그리기
DrawAGVs(g);
// 임시 연결선 그리기 (편집 모드)
if (_canvasMode == CanvasMode.Edit && _isConnectionMode)
{
DrawTemporaryConnection(g);
}
}
finally
{
g.Transform = new Matrix(); // 변환 행렬 리셋
}
// UI 정보 그리기 (변환 없이)
DrawUIInfo(g);
}
private void DrawGrid(Graphics g)
{
if (!_showGrid) return;
var bounds = GetVisibleBounds();
var gridSize = (int)(GRID_SIZE * _zoomFactor);
if (gridSize < 5) return; // 너무 작으면 그리지 않음
for (int x = bounds.Left; x < bounds.Right; x += GRID_SIZE)
{
if (x % (GRID_SIZE * 5) == 0)
g.DrawLine(new Pen(Color.Gray, 1), x, bounds.Top, x, bounds.Bottom);
else
g.DrawLine(_gridPen, x, bounds.Top, x, bounds.Bottom);
}
for (int y = bounds.Top; y < bounds.Bottom; y += GRID_SIZE)
{
if (y % (GRID_SIZE * 5) == 0)
g.DrawLine(new Pen(Color.Gray, 1), bounds.Left, y, bounds.Right, y);
else
g.DrawLine(_gridPen, bounds.Left, y, bounds.Right, y);
}
}
private void DrawConnections(Graphics g)
{
if (_nodes == null) return;
foreach (var node in _nodes)
{
if (node.ConnectedNodes == null) continue;
foreach (var connectedNodeId in node.ConnectedNodes)
{
var targetNode = _nodes.FirstOrDefault(n => n.NodeId == connectedNodeId);
if (targetNode == null) continue;
DrawConnection(g, node, targetNode);
}
}
}
private void DrawConnection(Graphics g, MapNode fromNode, MapNode toNode)
{
var startPoint = fromNode.Position;
var endPoint = toNode.Position;
// 연결선만 그리기 (단순한 도로 연결, 방향성 없음)
g.DrawLine(_connectionPen, startPoint, endPoint);
}
private void DrawDirectionArrow(Graphics g, Point point, double angle, AgvDirection direction)
{
var arrowSize = CONNECTION_ARROW_SIZE;
var arrowAngle = Math.PI / 6; // 30도
var cos = Math.Cos(angle);
var sin = Math.Sin(angle);
var arrowPoint1 = new Point(
(int)(point.X - arrowSize * Math.Cos(angle - arrowAngle)),
(int)(point.Y - arrowSize * Math.Sin(angle - arrowAngle))
);
var arrowPoint2 = new Point(
(int)(point.X - arrowSize * Math.Cos(angle + arrowAngle)),
(int)(point.Y - arrowSize * Math.Sin(angle + arrowAngle))
);
var arrowColor = direction == AgvDirection.Forward ? Color.Blue : Color.Red;
var arrowPen = new Pen(arrowColor, 2);
g.DrawLine(arrowPen, point, arrowPoint1);
g.DrawLine(arrowPen, point, arrowPoint2);
arrowPen.Dispose();
}
private void DrawPaths(Graphics g)
{
// 모든 경로 그리기
if (_allPaths != null)
{
foreach (var path in _allPaths)
{
DrawPath(g, path, Color.LightBlue);
}
}
// 현재 선택된 경로 그리기
if (_currentPath != null)
{
DrawPath(g, _currentPath, Color.Purple);
}
}
private void DrawPath(Graphics g, PathResult path, Color color)
{
if (path?.Path == null || path.Path.Count < 2) return;
var pathPen = new Pen(color, 4) { DashStyle = DashStyle.Dash };
for (int i = 0; i < path.Path.Count - 1; i++)
{
var currentNodeId = path.Path[i];
var nextNodeId = path.Path[i + 1];
var currentNode = _nodes?.FirstOrDefault(n => n.NodeId == currentNodeId);
var nextNode = _nodes?.FirstOrDefault(n => n.NodeId == nextNodeId);
if (currentNode != null && nextNode != null)
{
// 경로 선 그리기
g.DrawLine(pathPen, currentNode.Position, nextNode.Position);
// 경로 방향 표시 (계산된 경로의 경우에만 방향 화살표 표시)
var midPoint = new Point(
(currentNode.Position.X + nextNode.Position.X) / 2,
(currentNode.Position.Y + nextNode.Position.Y) / 2
);
var angle = Math.Atan2(nextNode.Position.Y - currentNode.Position.Y,
nextNode.Position.X - currentNode.Position.X);
DrawDirectionArrow(g, midPoint, angle, AgvDirection.Forward);
}
}
pathPen.Dispose();
}
private void DrawNodes(Graphics g)
{
if (_nodes == null) return;
foreach (var node in _nodes)
{
DrawNode(g, node);
}
}
private void DrawNode(Graphics g, MapNode node)
{
switch (node.Type)
{
case NodeType.Label:
DrawLabelNode(g, node);
break;
case NodeType.Image:
DrawImageNode(g, node);
break;
default:
DrawCircularNode(g, node);
break;
}
}
private void DrawCircularNode(Graphics g, MapNode node)
{
var brush = GetNodeBrush(node);
switch (node.Type)
{
case NodeType.Docking:
DrawPentagonNode(g, node, brush);
break;
case NodeType.Charging:
DrawTriangleNode(g, node, brush);
break;
default:
DrawCircleNode(g, node, brush);
break;
}
}
private void DrawCircleNode(Graphics g, MapNode node, Brush brush)
{
var rect = new Rectangle(
node.Position.X - NODE_RADIUS,
node.Position.Y - NODE_RADIUS,
NODE_SIZE,
NODE_SIZE
);
// 노드 그리기
g.FillEllipse(brush, rect);
g.DrawEllipse(Pens.Black, rect);
// 선택된 노드 강조
if (node == _selectedNode)
{
g.DrawEllipse(_selectedNodePen, rect);
}
// 호버된 노드 강조
if (node == _hoveredNode)
{
var hoverRect = new Rectangle(rect.X - 2, rect.Y - 2, rect.Width + 4, rect.Height + 4);
g.DrawEllipse(new Pen(Color.Orange, 2), hoverRect);
}
DrawNodeLabel(g, node);
}
private void DrawPentagonNode(Graphics g, MapNode node, Brush brush)
{
var radius = NODE_RADIUS;
var center = node.Position;
// 5각형 꼭짓점 계산 (위쪽부터 시계방향)
var points = new Point[5];
for (int i = 0; i < 5; i++)
{
var angle = (Math.PI * 2 * i / 5) - Math.PI / 2; // -90도부터 시작 (위쪽)
points[i] = new Point(
(int)(center.X + radius * Math.Cos(angle)),
(int)(center.Y + radius * Math.Sin(angle))
);
}
// 5각형 그리기
g.FillPolygon(brush, points);
g.DrawPolygon(Pens.Black, points);
// 선택된 노드 강조
if (node == _selectedNode)
{
g.DrawPolygon(_selectedNodePen, points);
}
// 호버된 노드 강조
if (node == _hoveredNode)
{
// 확장된 5각형 계산
var hoverPoints = new Point[5];
for (int i = 0; i < 5; i++)
{
var angle = (Math.PI * 2 * i / 5) - Math.PI / 2;
hoverPoints[i] = new Point(
(int)(center.X + (radius + 3) * Math.Cos(angle)),
(int)(center.Y + (radius + 3) * Math.Sin(angle))
);
}
g.DrawPolygon(new Pen(Color.Orange, 2), hoverPoints);
}
DrawNodeLabel(g, node);
}
private void DrawTriangleNode(Graphics g, MapNode node, Brush brush)
{
var radius = NODE_RADIUS;
var center = node.Position;
// 삼각형 꼭짓점 계산 (위쪽 꼭짓점부터 시계방향)
var points = new Point[3];
for (int i = 0; i < 3; i++)
{
var angle = (Math.PI * 2 * i / 3) - Math.PI / 2; // -90도부터 시작 (위쪽)
points[i] = new Point(
(int)(center.X + radius * Math.Cos(angle)),
(int)(center.Y + radius * Math.Sin(angle))
);
}
// 삼각형 그리기
g.FillPolygon(brush, points);
g.DrawPolygon(Pens.Black, points);
// 선택된 노드 강조
if (node == _selectedNode)
{
g.DrawPolygon(_selectedNodePen, points);
}
// 호버된 노드 강조
if (node == _hoveredNode)
{
// 확장된 삼각형 계산
var hoverPoints = new Point[3];
for (int i = 0; i < 3; i++)
{
var angle = (Math.PI * 2 * i / 3) - Math.PI / 2;
hoverPoints[i] = new Point(
(int)(center.X + (radius + 3) * Math.Cos(angle)),
(int)(center.Y + (radius + 3) * Math.Sin(angle))
);
}
g.DrawPolygon(new Pen(Color.Orange, 2), hoverPoints);
}
DrawNodeLabel(g, node);
}
private void DrawNodeLabel(Graphics g, MapNode node)
{
string displayText;
Color textColor;
string descriptionText;
// 위쪽에 표시할 설명 (노드의 Description 속성)
descriptionText = string.IsNullOrEmpty(node.Description) ? "" : node.Description;
// 아래쪽에 표시할 값 (RFID 우선, 없으면 노드ID)
if (node.HasRfid())
{
// RFID가 있는 경우: 순수 RFID 값만 표시 (진한 색상)
displayText = node.RfidId;
textColor = Color.Black;
}
else
{
// RFID가 없는 경우: 노드 ID 표시 (연한 색상)
displayText = node.NodeId;
textColor = Color.Gray;
}
var font = new Font("Arial", 8, FontStyle.Bold);
var descFont = new Font("Arial", 6, FontStyle.Regular);
// 메인 텍스트 크기 측정
var textSize = g.MeasureString(displayText, font);
var descSize = g.MeasureString(descriptionText, descFont);
// 설명 텍스트 위치 (노드 위쪽)
var descPoint = new Point(
(int)(node.Position.X - descSize.Width / 2),
(int)(node.Position.Y - NODE_RADIUS - descSize.Height - 2)
);
// 메인 텍스트 위치 (노드 아래쪽)
var textPoint = new Point(
(int)(node.Position.X - textSize.Width / 2),
(int)(node.Position.Y + NODE_RADIUS + 2)
);
// 설명 텍스트 그리기 (설명이 있는 경우에만)
if (!string.IsNullOrEmpty(descriptionText))
{
using (var descBrush = new SolidBrush(Color.FromArgb(120, Color.Black)))
{
g.DrawString(descriptionText, descFont, descBrush, descPoint);
}
}
// 메인 텍스트 그리기
using (var textBrush = new SolidBrush(textColor))
{
g.DrawString(displayText, font, textBrush, textPoint);
}
font.Dispose();
descFont.Dispose();
}
private void DrawLabelNode(Graphics g, MapNode node)
{
var text = string.IsNullOrEmpty(node.LabelText) ? node.NodeId : node.LabelText;
// 폰트 설정
var font = new Font(node.FontFamily, node.FontSize, node.FontStyle);
var textBrush = new SolidBrush(node.ForeColor);
// 텍스트 크기 측정
var textSize = g.MeasureString(text, font);
var textPoint = new Point(
(int)(node.Position.X - textSize.Width / 2),
(int)(node.Position.Y - textSize.Height / 2)
);
// 배경 그리기 (설정된 경우)
if (node.ShowBackground)
{
var backgroundBrush = new SolidBrush(node.BackColor);
var backgroundRect = new Rectangle(
textPoint.X - 2,
textPoint.Y - 2,
(int)textSize.Width + 4,
(int)textSize.Height + 4
);
g.FillRectangle(backgroundBrush, backgroundRect);
g.DrawRectangle(Pens.Black, backgroundRect);
backgroundBrush.Dispose();
}
// 텍스트 그리기
g.DrawString(text, font, textBrush, textPoint);
// 선택된 노드 강조
if (node == _selectedNode)
{
var selectionRect = new Rectangle(
textPoint.X - 4,
textPoint.Y - 4,
(int)textSize.Width + 8,
(int)textSize.Height + 8
);
g.DrawRectangle(_selectedNodePen, selectionRect);
}
// 호버된 노드 강조
if (node == _hoveredNode)
{
var hoverRect = new Rectangle(
textPoint.X - 6,
textPoint.Y - 6,
(int)textSize.Width + 12,
(int)textSize.Height + 12
);
g.DrawRectangle(new Pen(Color.Orange, 2), hoverRect);
}
font.Dispose();
textBrush.Dispose();
}
private void DrawImageNode(Graphics g, MapNode node)
{
// 이미지 로드 (필요시)
if (node.LoadedImage == null && !string.IsNullOrEmpty(node.ImagePath))
{
node.LoadImage();
}
if (node.LoadedImage != null)
{
// 실제 표시 크기 계산
var displaySize = node.GetDisplaySize();
if (displaySize.IsEmpty)
displaySize = new Size(50, 50); // 기본 크기
var imageRect = new Rectangle(
node.Position.X - displaySize.Width / 2,
node.Position.Y - displaySize.Height / 2,
displaySize.Width,
displaySize.Height
);
// 회전이 있는 경우
if (node.Rotation != 0)
{
var oldTransform = g.Transform;
g.TranslateTransform(node.Position.X, node.Position.Y);
g.RotateTransform(node.Rotation);
g.TranslateTransform(-node.Position.X, -node.Position.Y);
// 투명도 적용하여 이미지 그리기
if (node.Opacity < 1.0f)
{
var imageAttributes = new System.Drawing.Imaging.ImageAttributes();
var colorMatrix = new System.Drawing.Imaging.ColorMatrix();
colorMatrix.Matrix33 = node.Opacity;
imageAttributes.SetColorMatrix(colorMatrix, System.Drawing.Imaging.ColorMatrixFlag.Default,
System.Drawing.Imaging.ColorAdjustType.Bitmap);
g.DrawImage(node.LoadedImage, imageRect, 0, 0, node.LoadedImage.Width, node.LoadedImage.Height,
GraphicsUnit.Pixel, imageAttributes);
imageAttributes.Dispose();
}
else
{
g.DrawImage(node.LoadedImage, imageRect);
}
g.Transform = oldTransform;
}
else
{
// 투명도 적용하여 이미지 그리기
if (node.Opacity < 1.0f)
{
var imageAttributes = new System.Drawing.Imaging.ImageAttributes();
var colorMatrix = new System.Drawing.Imaging.ColorMatrix();
colorMatrix.Matrix33 = node.Opacity;
imageAttributes.SetColorMatrix(colorMatrix, System.Drawing.Imaging.ColorMatrixFlag.Default,
System.Drawing.Imaging.ColorAdjustType.Bitmap);
g.DrawImage(node.LoadedImage, imageRect, 0, 0, node.LoadedImage.Width, node.LoadedImage.Height,
GraphicsUnit.Pixel, imageAttributes);
imageAttributes.Dispose();
}
else
{
g.DrawImage(node.LoadedImage, imageRect);
}
}
// 선택된 노드 강조
if (node == _selectedNode)
{
g.DrawRectangle(_selectedNodePen, imageRect);
}
// 호버된 노드 강조
if (node == _hoveredNode)
{
var hoverRect = new Rectangle(imageRect.X - 2, imageRect.Y - 2, imageRect.Width + 4, imageRect.Height + 4);
g.DrawRectangle(new Pen(Color.Orange, 2), hoverRect);
}
}
else
{
// 이미지가 없는 경우 기본 사각형으로 표시
var rect = new Rectangle(
node.Position.X - 25,
node.Position.Y - 25,
50,
50
);
g.FillRectangle(Brushes.LightGray, rect);
g.DrawRectangle(Pens.Black, rect);
// "이미지 없음" 텍스트
var font = new Font("Arial", 8);
var text = "No Image";
var textSize = g.MeasureString(text, font);
var textPoint = new Point(
(int)(node.Position.X - textSize.Width / 2),
(int)(node.Position.Y - textSize.Height / 2)
);
g.DrawString(text, font, Brushes.Black, textPoint);
font.Dispose();
// 선택된 노드 강조
if (node == _selectedNode)
{
g.DrawRectangle(_selectedNodePen, rect);
}
// 호버된 노드 강조
if (node == _hoveredNode)
{
var hoverRect = new Rectangle(rect.X - 2, rect.Y - 2, rect.Width + 4, rect.Height + 4);
g.DrawRectangle(new Pen(Color.Orange, 2), hoverRect);
}
}
}
private Brush GetNodeBrush(MapNode node)
{
switch (node.Type)
{
case NodeType.Normal:
return _normalNodeBrush;
case NodeType.Rotation:
return _rotationNodeBrush;
case NodeType.Docking:
return _dockingNodeBrush;
case NodeType.Charging:
return _chargingNodeBrush;
case NodeType.Label:
return new SolidBrush(Color.Purple);
case NodeType.Image:
return new SolidBrush(Color.Brown);
default:
return _normalNodeBrush;
}
}
private void DrawAGVs(Graphics g)
{
if (_agvList == null) return;
foreach (var agv in _agvList)
{
if (_agvPositions.ContainsKey(agv.AgvId))
{
DrawAGV(g, agv);
}
}
}
private void DrawAGV(Graphics g, IAGV agv)
{
if (!_agvPositions.ContainsKey(agv.AgvId)) return;
var position = _agvPositions[agv.AgvId];
var direction = _agvDirections.ContainsKey(agv.AgvId) ? _agvDirections[agv.AgvId] : AgvDirection.Forward;
var state = _agvStates.ContainsKey(agv.AgvId) ? _agvStates[agv.AgvId] : AGVState.Idle;
// AGV 색상 결정
var brush = GetAGVBrush(state);
// AGV 사각형 그리기
var rect = new Rectangle(
position.X - AGV_SIZE / 2,
position.Y - AGV_SIZE / 2,
AGV_SIZE,
AGV_SIZE
);
g.FillRectangle(brush, rect);
g.DrawRectangle(_agvPen, rect);
// 방향 표시 (화살표)
DrawAGVDirection(g, position, direction);
// AGV ID 표시
var font = new Font("Arial", 10, FontStyle.Bold);
var textSize = g.MeasureString(agv.AgvId, font);
var textPoint = new Point(
(int)(position.X - textSize.Width / 2),
(int)(position.Y - AGV_SIZE / 2 - textSize.Height - 2)
);
g.DrawString(agv.AgvId, font, Brushes.Black, textPoint);
// 배터리 레벨 표시
var batteryText = $"{agv.BatteryLevel:F0}%";
var batterySize = g.MeasureString(batteryText, font);
var batteryPoint = new Point(
(int)(position.X - batterySize.Width / 2),
(int)(position.Y + AGV_SIZE / 2 + 2)
);
g.DrawString(batteryText, font, Brushes.Black, batteryPoint);
font.Dispose();
}
private Brush GetAGVBrush(AGVState state)
{
switch (state)
{
case AGVState.Idle:
return Brushes.LightGray;
case AGVState.Moving:
return Brushes.LightGreen;
case AGVState.Rotating:
return Brushes.Yellow;
case AGVState.Docking:
return Brushes.Orange;
case AGVState.Charging:
return Brushes.Blue;
case AGVState.Error:
return Brushes.Red;
default:
return Brushes.LightGray;
}
}
private void DrawAGVDirection(Graphics g, Point position, AgvDirection direction)
{
var arrowSize = 10;
Point[] arrowPoints = null;
switch (direction)
{
case AgvDirection.Forward:
arrowPoints = new Point[]
{
new Point(position.X + arrowSize, position.Y),
new Point(position.X - arrowSize/2, position.Y - arrowSize/2),
new Point(position.X - arrowSize/2, position.Y + arrowSize/2)
};
break;
case AgvDirection.Backward:
arrowPoints = new Point[]
{
new Point(position.X - arrowSize, position.Y),
new Point(position.X + arrowSize/2, position.Y - arrowSize/2),
new Point(position.X + arrowSize/2, position.Y + arrowSize/2)
};
break;
}
if (arrowPoints != null)
{
g.FillPolygon(Brushes.White, arrowPoints);
g.DrawPolygon(Pens.Black, arrowPoints);
}
}
private void DrawTemporaryConnection(Graphics g)
{
if (_connectionStartNode != null && _connectionEndPoint != Point.Empty)
{
g.DrawLine(_tempConnectionPen, _connectionStartNode.Position, _connectionEndPoint);
}
}
private void DrawUIInfo(Graphics g)
{
// 회사 로고
if (_companyLogo != null)
{
var logoRect = new Rectangle(10, 10, 100, 50);
g.DrawImage(_companyLogo, logoRect);
}
// 측정 정보
if (!string.IsNullOrEmpty(_measurementInfo))
{
var font = new Font("Arial", 9);
var textBrush = new SolidBrush(Color.Black);
var backgroundBrush = new SolidBrush(Color.FromArgb(200, Color.White));
var textSize = g.MeasureString(_measurementInfo, font);
var textRect = new Rectangle(
Width - (int)textSize.Width - 20,
Height - (int)textSize.Height - 20,
(int)textSize.Width + 10,
(int)textSize.Height + 10
);
g.FillRectangle(backgroundBrush, textRect);
g.DrawRectangle(Pens.Gray, textRect);
g.DrawString(_measurementInfo, font, textBrush, textRect.X + 5, textRect.Y + 5);
font.Dispose();
textBrush.Dispose();
backgroundBrush.Dispose();
}
// 줌 정보
var zoomText = $"Zoom: {_zoomFactor:P0}";
var zoomFont = new Font("Arial", 10, FontStyle.Bold);
var zoomSize = g.MeasureString(zoomText, zoomFont);
var zoomPoint = new Point(10, Height - (int)zoomSize.Height - 10);
g.FillRectangle(new SolidBrush(Color.FromArgb(200, Color.White)),
zoomPoint.X - 5, zoomPoint.Y - 5,
zoomSize.Width + 10, zoomSize.Height + 10);
g.DrawString(zoomText, zoomFont, Brushes.Black, zoomPoint);
zoomFont.Dispose();
}
private Rectangle GetVisibleBounds()
{
var left = (int)(-_panOffset.X / _zoomFactor);
var top = (int)(-_panOffset.Y / _zoomFactor);
var right = (int)((Width - _panOffset.X) / _zoomFactor);
var bottom = (int)((Height - _panOffset.Y) / _zoomFactor);
return new Rectangle(left, top, right - left, bottom - top);
}
#endregion
}
}

View File

@@ -0,0 +1,605 @@
using System;
using System.Drawing;
using System.Linq;
using System.Windows.Forms;
using AGVNavigationCore.Models;
namespace AGVNavigationCore.Controls
{
public partial class UnifiedAGVCanvas
{
#region Mouse Events
private void UnifiedAGVCanvas_MouseClick(object sender, MouseEventArgs e)
{
Focus(); // 포커스 설정
if (_canvasMode == CanvasMode.ViewOnly) return;
var worldPoint = ScreenToWorld(e.Location);
var hitNode = GetNodeAt(worldPoint);
switch (_editMode)
{
case EditMode.Select:
HandleSelectClick(hitNode);
break;
case EditMode.AddNode:
HandleAddNodeClick(worldPoint);
break;
case EditMode.Connect:
HandleConnectClick(hitNode);
break;
case EditMode.Delete:
HandleDeleteClick(hitNode);
break;
}
}
private void UnifiedAGVCanvas_MouseDoubleClick(object sender, MouseEventArgs e)
{
if (_canvasMode == CanvasMode.ViewOnly) return;
var worldPoint = ScreenToWorld(e.Location);
var hitNode = GetNodeAt(worldPoint);
if (hitNode != null)
{
// 노드 속성 편집 (이벤트 발생)
NodeSelected?.Invoke(this, hitNode);
}
}
private void UnifiedAGVCanvas_MouseDown(object sender, MouseEventArgs e)
{
var worldPoint = ScreenToWorld(e.Location);
if (e.Button == MouseButtons.Left)
{
if (_canvasMode == CanvasMode.Edit && _editMode == EditMode.Move)
{
var hitNode = GetNodeAt(worldPoint);
if (hitNode != null)
{
_isDragging = true;
_selectedNode = hitNode;
_dragOffset = new Point(
worldPoint.X - hitNode.Position.X,
worldPoint.Y - hitNode.Position.Y
);
Cursor = Cursors.SizeAll;
Invalidate();
return;
}
}
// 팬 시작 (우클릭 또는 중간버튼)
_isPanning = true;
_lastMousePosition = e.Location;
Cursor = Cursors.SizeAll;
}
else if (e.Button == MouseButtons.Right)
{
// 컨텍스트 메뉴 (편집 모드에서만)
if (_canvasMode == CanvasMode.Edit)
{
var hitNode = GetNodeAt(worldPoint);
ShowContextMenu(e.Location, hitNode);
}
}
}
private void UnifiedAGVCanvas_MouseMove(object sender, MouseEventArgs e)
{
var worldPoint = ScreenToWorld(e.Location);
// 호버 노드 업데이트
var newHoveredNode = GetNodeAt(worldPoint);
if (newHoveredNode != _hoveredNode)
{
_hoveredNode = newHoveredNode;
Invalidate();
}
if (_isPanning)
{
// 팬 처리
var deltaX = e.X - _lastMousePosition.X;
var deltaY = e.Y - _lastMousePosition.Y;
_panOffset.X += deltaX;
_panOffset.Y += deltaY;
_lastMousePosition = e.Location;
Invalidate();
}
else if (_isDragging && _canvasMode == CanvasMode.Edit)
{
// 노드 드래그
if (_selectedNode != null)
{
var newPosition = new Point(
worldPoint.X - _dragOffset.X,
worldPoint.Y - _dragOffset.Y
);
// 그리드 스냅
if (ModifierKeys.HasFlag(Keys.Control))
{
newPosition.X = (newPosition.X / GRID_SIZE) * GRID_SIZE;
newPosition.Y = (newPosition.Y / GRID_SIZE) * GRID_SIZE;
}
_selectedNode.Position = newPosition;
NodeMoved?.Invoke(this, _selectedNode);
MapChanged?.Invoke(this, EventArgs.Empty);
Invalidate();
}
}
else if (_isConnectionMode && _canvasMode == CanvasMode.Edit)
{
// 임시 연결선 업데이트
_connectionEndPoint = worldPoint;
Invalidate();
}
// 툴팁 표시 (호버된 노드/AGV 정보)
UpdateTooltip(worldPoint);
}
private void UnifiedAGVCanvas_MouseUp(object sender, MouseEventArgs e)
{
if (e.Button == MouseButtons.Left)
{
if (_isDragging && _canvasMode == CanvasMode.Edit)
{
_isDragging = false;
Cursor = GetCursorForMode(_editMode);
}
if (_isPanning)
{
_isPanning = false;
Cursor = Cursors.Default;
}
}
}
private void UnifiedAGVCanvas_MouseWheel(object sender, MouseEventArgs e)
{
// 줌 처리
var mouseWorldPoint = ScreenToWorld(e.Location);
var oldZoom = _zoomFactor;
if (e.Delta > 0)
_zoomFactor = Math.Min(_zoomFactor * 1.2f, 5.0f);
else
_zoomFactor = Math.Max(_zoomFactor / 1.2f, 0.1f);
// 마우스 위치를 중심으로 줌
var zoomRatio = _zoomFactor / oldZoom;
_panOffset.X = (int)(e.X - (e.X - _panOffset.X) * zoomRatio);
_panOffset.Y = (int)(e.Y - (e.Y - _panOffset.Y) * zoomRatio);
Invalidate();
}
#endregion
#region Mouse Helper Methods
private Point ScreenToWorld(Point screenPoint)
{
return new Point(
(int)((screenPoint.X - _panOffset.X) / _zoomFactor),
(int)((screenPoint.Y - _panOffset.Y) / _zoomFactor)
);
}
private Point WorldToScreen(Point worldPoint)
{
return new Point(
(int)(worldPoint.X * _zoomFactor + _panOffset.X),
(int)(worldPoint.Y * _zoomFactor + _panOffset.Y)
);
}
private MapNode GetNodeAt(Point worldPoint)
{
if (_nodes == null) return null;
// 역순으로 검사하여 위에 그려진 노드부터 확인
for (int i = _nodes.Count - 1; i >= 0; i--)
{
var node = _nodes[i];
if (IsPointInNode(worldPoint, node))
return node;
}
return null;
}
private bool IsPointInNode(Point point, MapNode node)
{
switch (node.Type)
{
case NodeType.Label:
return IsPointInLabelNode(point, node);
case NodeType.Image:
return IsPointInImageNode(point, node);
default:
return IsPointInCircularNode(point, node);
}
}
private bool IsPointInCircularNode(Point point, MapNode node)
{
switch (node.Type)
{
case NodeType.Docking:
return IsPointInPentagon(point, node);
case NodeType.Charging:
return IsPointInTriangle(point, node);
default:
return IsPointInCircle(point, node);
}
}
private bool IsPointInCircle(Point point, MapNode node)
{
var hitRadius = Math.Max(NODE_RADIUS, 10 / _zoomFactor);
var distance = Math.Sqrt(
Math.Pow(node.Position.X - point.X, 2) +
Math.Pow(node.Position.Y - point.Y, 2)
);
return distance <= hitRadius;
}
private bool IsPointInPentagon(Point point, MapNode node)
{
var radius = NODE_RADIUS;
var center = node.Position;
// 5각형 꼭짓점 계산
var points = new Point[5];
for (int i = 0; i < 5; i++)
{
var angle = (Math.PI * 2 * i / 5) - Math.PI / 2;
points[i] = new Point(
(int)(center.X + radius * Math.Cos(angle)),
(int)(center.Y + radius * Math.Sin(angle))
);
}
return IsPointInPolygon(point, points);
}
private bool IsPointInTriangle(Point point, MapNode node)
{
var radius = NODE_RADIUS;
var center = node.Position;
// 삼각형 꼭짓점 계산
var points = new Point[3];
for (int i = 0; i < 3; i++)
{
var angle = (Math.PI * 2 * i / 3) - Math.PI / 2;
points[i] = new Point(
(int)(center.X + radius * Math.Cos(angle)),
(int)(center.Y + radius * Math.Sin(angle))
);
}
return IsPointInPolygon(point, points);
}
private bool IsPointInPolygon(Point point, Point[] polygon)
{
// Ray casting 알고리즘 사용
bool inside = false;
int j = polygon.Length - 1;
for (int i = 0; i < polygon.Length; i++)
{
var xi = polygon[i].X;
var yi = polygon[i].Y;
var xj = polygon[j].X;
var yj = polygon[j].Y;
if (((yi > point.Y) != (yj > point.Y)) &&
(point.X < (xj - xi) * (point.Y - yi) / (yj - yi) + xi))
{
inside = !inside;
}
j = i;
}
return inside;
}
private bool IsPointInLabelNode(Point point, MapNode node)
{
var text = string.IsNullOrEmpty(node.LabelText) ? node.NodeId : node.LabelText;
// 임시 Graphics로 텍스트 크기 측정
using (var tempBitmap = new Bitmap(1, 1))
using (var tempGraphics = Graphics.FromImage(tempBitmap))
{
var font = new Font(node.FontFamily, node.FontSize, node.FontStyle);
var textSize = tempGraphics.MeasureString(text, font);
var textRect = new Rectangle(
(int)(node.Position.X - textSize.Width / 2),
(int)(node.Position.Y - textSize.Height / 2),
(int)textSize.Width,
(int)textSize.Height
);
font.Dispose();
return textRect.Contains(point);
}
}
private bool IsPointInImageNode(Point point, MapNode node)
{
var displaySize = node.GetDisplaySize();
if (displaySize.IsEmpty)
displaySize = new Size(50, 50); // 기본 크기
var imageRect = new Rectangle(
node.Position.X - displaySize.Width / 2,
node.Position.Y - displaySize.Height / 2,
displaySize.Width,
displaySize.Height
);
return imageRect.Contains(point);
}
private IAGV GetAGVAt(Point worldPoint)
{
if (_agvList == null) return null;
var hitRadius = Math.Max(AGV_SIZE / 2, 15 / _zoomFactor);
return _agvList.FirstOrDefault(agv =>
{
if (!_agvPositions.ContainsKey(agv.AgvId)) return false;
var agvPos = _agvPositions[agv.AgvId];
var distance = Math.Sqrt(
Math.Pow(agvPos.X - worldPoint.X, 2) +
Math.Pow(agvPos.Y - worldPoint.Y, 2)
);
return distance <= hitRadius;
});
}
private void HandleSelectClick(MapNode hitNode)
{
if (hitNode != _selectedNode)
{
_selectedNode = hitNode;
NodeSelected?.Invoke(this, hitNode);
Invalidate();
}
}
private void HandleAddNodeClick(Point worldPoint)
{
// 그리드 스냅
if (ModifierKeys.HasFlag(Keys.Control))
{
worldPoint.X = (worldPoint.X / GRID_SIZE) * GRID_SIZE;
worldPoint.Y = (worldPoint.Y / GRID_SIZE) * GRID_SIZE;
}
var newNode = new MapNode
{
NodeId = $"N{_nodeCounter:D3}",
Position = worldPoint,
Type = NodeType.Normal
};
_nodeCounter++;
_nodes.Add(newNode);
NodeAdded?.Invoke(this, newNode);
MapChanged?.Invoke(this, EventArgs.Empty);
Invalidate();
}
private void HandleConnectClick(MapNode hitNode)
{
if (hitNode == null) return;
if (!_isConnectionMode)
{
// 연결 시작
_isConnectionMode = true;
_connectionStartNode = hitNode;
_selectedNode = hitNode;
}
else
{
// 연결 완료
if (_connectionStartNode != null && _connectionStartNode != hitNode)
{
CreateConnection(_connectionStartNode, hitNode);
}
CancelConnection();
}
Invalidate();
}
private void HandleDeleteClick(MapNode hitNode)
{
if (hitNode == null) return;
// 연결된 모든 연결선도 제거
foreach (var node in _nodes)
{
node.RemoveConnection(hitNode.NodeId);
}
_nodes.Remove(hitNode);
if (_selectedNode == hitNode)
_selectedNode = null;
NodeDeleted?.Invoke(this, hitNode);
MapChanged?.Invoke(this, EventArgs.Empty);
Invalidate();
}
private void CreateConnection(MapNode fromNode, MapNode toNode)
{
// 중복 연결 체크
if (fromNode.ConnectedNodes.Contains(toNode.NodeId))
return;
fromNode.AddConnection(toNode.NodeId);
MapChanged?.Invoke(this, EventArgs.Empty);
}
private float CalculateDistance(Point from, Point to)
{
var dx = to.X - from.X;
var dy = to.Y - from.Y;
return (float)Math.Sqrt(dx * dx + dy * dy);
}
private void ShowContextMenu(Point location, MapNode hitNode)
{
_contextMenu.Items.Clear();
if (hitNode != null)
{
_contextMenu.Items.Add("노드 속성...", null, (s, e) => NodeSelected?.Invoke(this, hitNode));
_contextMenu.Items.Add("노드 삭제", null, (s, e) => HandleDeleteClick(hitNode));
_contextMenu.Items.Add("-");
}
_contextMenu.Items.Add("노드 추가", null, (s, e) =>
{
var worldPoint = ScreenToWorld(location);
HandleAddNodeClick(worldPoint);
});
_contextMenu.Items.Add("전체 맞춤", null, (s, e) => FitToNodes());
_contextMenu.Items.Add("줌 리셋", null, (s, e) => ResetZoom());
_contextMenu.Show(this, location);
}
private void UpdateTooltip(Point worldPoint)
{
string tooltipText = "";
// 노드 툴팁
var hitNode = GetNodeAt(worldPoint);
if (hitNode != null)
{
tooltipText = $"노드: {hitNode.NodeId}\n타입: {hitNode.Type}\n위치: ({hitNode.Position.X}, {hitNode.Position.Y})";
}
else
{
// AGV 툴팁
var hitAGV = GetAGVAt(worldPoint);
if (hitAGV != null)
{
var state = _agvStates.ContainsKey(hitAGV.AgvId) ? _agvStates[hitAGV.AgvId] : AGVState.Idle;
tooltipText = $"AGV: {hitAGV.AgvId}\n상태: {state}\n배터리: {hitAGV.BatteryLevel:F1}%\n위치: ({hitAGV.CurrentPosition.X}, {hitAGV.CurrentPosition.Y})";
}
}
// 툴팁 업데이트 (기존 ToolTip 컨트롤 사용)
if (!string.IsNullOrEmpty(tooltipText))
{
// ToolTip 설정 (필요시 추가 구현)
}
}
#endregion
#region View Control Methods
/// <summary>
/// 모든 노드가 보이도록 뷰 조정
/// </summary>
public void FitToNodes()
{
if (_nodes == null || _nodes.Count == 0) return;
var minX = _nodes.Min(n => n.Position.X);
var maxX = _nodes.Max(n => n.Position.X);
var minY = _nodes.Min(n => n.Position.Y);
var maxY = _nodes.Max(n => n.Position.Y);
var margin = 50;
var contentWidth = maxX - minX + margin * 2;
var contentHeight = maxY - minY + margin * 2;
var zoomX = (float)Width / contentWidth;
var zoomY = (float)Height / contentHeight;
_zoomFactor = Math.Min(zoomX, zoomY) * 0.9f;
var centerX = (minX + maxX) / 2;
var centerY = (minY + maxY) / 2;
_panOffset.X = (int)(Width / 2 - centerX * _zoomFactor);
_panOffset.Y = (int)(Height / 2 - centerY * _zoomFactor);
Invalidate();
}
/// <summary>
/// 줌 리셋
/// </summary>
public void ResetZoom()
{
_zoomFactor = 1.0f;
_panOffset = Point.Empty;
Invalidate();
}
/// <summary>
/// 특정 위치로 이동
/// </summary>
public void PanTo(Point worldPoint)
{
_panOffset.X = (int)(Width / 2 - worldPoint.X * _zoomFactor);
_panOffset.Y = (int)(Height / 2 - worldPoint.Y * _zoomFactor);
Invalidate();
}
/// <summary>
/// 특정 노드로 이동
/// </summary>
public void PanToNode(string nodeId)
{
var node = _nodes?.FirstOrDefault(n => n.NodeId == nodeId);
if (node != null)
{
PanTo(node.Position);
}
}
/// <summary>
/// 특정 AGV로 이동
/// </summary>
public void PanToAGV(string agvId)
{
if (_agvPositions.ContainsKey(agvId))
{
PanTo(_agvPositions[agvId]);
}
}
#endregion
}
}

View File

@@ -0,0 +1,536 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Linq;
using System.Windows.Forms;
using AGVNavigationCore.Models;
using AGVNavigationCore.PathFinding;
namespace AGVNavigationCore.Controls
{
/// <summary>
/// 통합 AGV 캔버스 컨트롤
/// 맵 편집, AGV 시뮬레이션, 실시간 모니터링을 모두 지원
/// </summary>
public partial class UnifiedAGVCanvas : UserControl
{
#region Constants
private const int NODE_SIZE = 24;
private const int NODE_RADIUS = NODE_SIZE / 2;
private const int GRID_SIZE = 20;
private const float CONNECTION_WIDTH = 2.0f;
private const int SNAP_DISTANCE = 10;
private const int AGV_SIZE = 30;
private const int CONNECTION_ARROW_SIZE = 8;
#endregion
#region Enums
/// <summary>
/// 캔버스 모드
/// </summary>
public enum CanvasMode
{
ViewOnly, // 읽기 전용 (시뮬레이터, 모니터링)
Edit // 편집 가능 (맵 에디터)
}
/// <summary>
/// 편집 모드 (CanvasMode.Edit일 때만 적용)
/// </summary>
public enum EditMode
{
Select, // 선택 모드
Move, // 이동 모드
AddNode, // 노드 추가 모드
Connect, // 연결 모드
Delete, // 삭제 모드
AddLabel, // 라벨 추가 모드
AddImage // 이미지 추가 모드
}
#endregion
#region Fields
// 캔버스 모드
private CanvasMode _canvasMode = CanvasMode.ViewOnly;
private EditMode _editMode = EditMode.Select;
// 맵 데이터
private List<MapNode> _nodes;
private MapNode _selectedNode;
private MapNode _hoveredNode;
// AGV 관련
private List<IAGV> _agvList;
private Dictionary<string, Point> _agvPositions;
private Dictionary<string, AgvDirection> _agvDirections;
private Dictionary<string, AGVState> _agvStates;
// 경로 관련
private PathResult _currentPath;
private List<PathResult> _allPaths;
// UI 요소들
private Image _companyLogo;
private string _companyLogoPath = string.Empty;
private string _measurementInfo = "스케일: 1:100\n면적: 1000㎡\n최종 수정: " + DateTime.Now.ToString("yyyy-MM-dd");
// 편집 관련 (EditMode에서만 사용)
private bool _isDragging;
private Point _dragOffset;
private Point _lastMousePosition;
private bool _isConnectionMode;
private MapNode _connectionStartNode;
private Point _connectionEndPoint;
// 그리드 및 줌 관련
private bool _showGrid = true;
private float _zoomFactor = 1.0f;
private Point _panOffset = Point.Empty;
private bool _isPanning;
// 자동 증가 카운터
private int _nodeCounter = 1;
// 브러쉬 및 펜
private Brush _normalNodeBrush;
private Brush _rotationNodeBrush;
private Brush _dockingNodeBrush;
private Brush _chargingNodeBrush;
private Brush _selectedNodeBrush;
private Brush _hoveredNodeBrush;
private Brush _gridBrush;
private Brush _agvBrush;
private Brush _pathBrush;
private Pen _connectionPen;
private Pen _gridPen;
private Pen _tempConnectionPen;
private Pen _selectedNodePen;
private Pen _pathPen;
private Pen _agvPen;
// 컨텍스트 메뉴
private ContextMenuStrip _contextMenu;
#endregion
#region Events
// 맵 편집 이벤트
public event EventHandler<MapNode> NodeAdded;
public event EventHandler<MapNode> NodeSelected;
public event EventHandler<MapNode> NodeDeleted;
public event EventHandler<MapNode> NodeMoved;
public event EventHandler MapChanged;
// AGV 이벤트
public event EventHandler<IAGV> AGVSelected;
public event EventHandler<IAGV> AGVStateChanged;
#endregion
#region Properties
/// <summary>
/// 캔버스 모드
/// </summary>
public CanvasMode Mode
{
get => _canvasMode;
set
{
_canvasMode = value;
UpdateModeUI();
Invalidate();
}
}
/// <summary>
/// 편집 모드 (CanvasMode.Edit일 때만 적용)
/// </summary>
public EditMode CurrentEditMode
{
get => _editMode;
set
{
if (_canvasMode != CanvasMode.Edit) return;
_editMode = value;
if (_editMode != EditMode.Connect)
{
CancelConnection();
}
Cursor = GetCursorForMode(_editMode);
Invalidate();
}
}
/// <summary>
/// 그리드 표시 여부
/// </summary>
public bool ShowGrid
{
get => _showGrid;
set
{
_showGrid = value;
Invalidate();
}
}
/// <summary>
/// 줌 팩터
/// </summary>
public float ZoomFactor
{
get => _zoomFactor;
set
{
_zoomFactor = Math.Max(0.1f, Math.Min(5.0f, value));
Invalidate();
}
}
/// <summary>
/// 선택된 노드
/// </summary>
public MapNode SelectedNode => _selectedNode;
/// <summary>
/// 노드 목록
/// </summary>
public List<MapNode> Nodes
{
get => _nodes ?? new List<MapNode>();
set
{
_nodes = value ?? new List<MapNode>();
Invalidate();
}
}
/// <summary>
/// AGV 목록
/// </summary>
public List<IAGV> AGVList
{
get => _agvList ?? new List<IAGV>();
set
{
_agvList = value ?? new List<IAGV>();
UpdateAGVData();
Invalidate();
}
}
/// <summary>
/// 현재 표시할 경로
/// </summary>
public PathResult CurrentPath
{
get => _currentPath;
set
{
_currentPath = value;
Invalidate();
}
}
/// <summary>
/// 모든 경로 목록 (다중 AGV 경로 표시용)
/// </summary>
public List<PathResult> AllPaths
{
get => _allPaths ?? new List<PathResult>();
set
{
_allPaths = value ?? new List<PathResult>();
Invalidate();
}
}
/// <summary>
/// 회사 로고 이미지
/// </summary>
public Image CompanyLogo
{
get => _companyLogo;
set
{
_companyLogo = value;
Invalidate();
}
}
/// <summary>
/// 측정 정보 텍스트
/// </summary>
public string MeasurementInfo
{
get => _measurementInfo;
set
{
_measurementInfo = value;
Invalidate();
}
}
#endregion
#region Constructor
public UnifiedAGVCanvas()
{
InitializeComponent();
InitializeCanvas();
}
#endregion
#region Initialization
private void InitializeCanvas()
{
SetStyle(ControlStyles.AllPaintingInWmPaint |
ControlStyles.UserPaint |
ControlStyles.DoubleBuffer |
ControlStyles.ResizeRedraw, true);
_nodes = new List<MapNode>();
_agvList = new List<IAGV>();
_agvPositions = new Dictionary<string, Point>();
_agvDirections = new Dictionary<string, AgvDirection>();
_agvStates = new Dictionary<string, AGVState>();
_allPaths = new List<PathResult>();
InitializeBrushesAndPens();
CreateContextMenu();
}
private void InitializeBrushesAndPens()
{
// 노드 브러쉬
_normalNodeBrush = new SolidBrush(Color.LightBlue);
_rotationNodeBrush = new SolidBrush(Color.Yellow);
_dockingNodeBrush = new SolidBrush(Color.Orange);
_chargingNodeBrush = new SolidBrush(Color.Green);
_selectedNodeBrush = new SolidBrush(Color.Red);
_hoveredNodeBrush = new SolidBrush(Color.LightCyan);
// AGV 및 경로 브러쉬
_agvBrush = new SolidBrush(Color.Red);
_pathBrush = new SolidBrush(Color.Purple);
// 그리드 브러쉬
_gridBrush = new SolidBrush(Color.LightGray);
// 펜
_connectionPen = new Pen(Color.DarkBlue, CONNECTION_WIDTH);
_connectionPen.EndCap = LineCap.ArrowAnchor;
_gridPen = new Pen(Color.LightGray, 1);
_tempConnectionPen = new Pen(Color.Orange, 2) { DashStyle = DashStyle.Dash };
_selectedNodePen = new Pen(Color.Red, 3);
_pathPen = new Pen(Color.Purple, 3);
_agvPen = new Pen(Color.Red, 3);
}
private void CreateContextMenu()
{
_contextMenu = new ContextMenuStrip();
// 컨텍스트 메뉴는 EditMode에서만 사용
}
private void UpdateModeUI()
{
// 모드에 따른 UI 업데이트
if (_canvasMode == CanvasMode.ViewOnly)
{
Cursor = Cursors.Default;
_contextMenu.Enabled = false;
}
else
{
_contextMenu.Enabled = true;
Cursor = GetCursorForMode(_editMode);
}
}
#endregion
#region AGV Management
/// <summary>
/// AGV 위치 업데이트
/// </summary>
public void UpdateAGVPosition(string agvId, Point position)
{
if (_agvPositions.ContainsKey(agvId))
_agvPositions[agvId] = position;
else
_agvPositions.Add(agvId, position);
Invalidate();
}
/// <summary>
/// AGV 방향 업데이트
/// </summary>
public void UpdateAGVDirection(string agvId, AgvDirection direction)
{
if (_agvDirections.ContainsKey(agvId))
_agvDirections[agvId] = direction;
else
_agvDirections.Add(agvId, direction);
Invalidate();
}
/// <summary>
/// AGV 상태 업데이트
/// </summary>
public void UpdateAGVState(string agvId, AGVState state)
{
if (_agvStates.ContainsKey(agvId))
_agvStates[agvId] = state;
else
_agvStates.Add(agvId, state);
Invalidate();
}
/// <summary>
/// AGV 위치 설정 (시뮬레이터용)
/// </summary>
/// <param name="agvId">AGV ID</param>
/// <param name="position">새로운 위치</param>
public void SetAGVPosition(string agvId, Point position)
{
UpdateAGVPosition(agvId, position);
}
/// <summary>
/// AGV 데이터 동기화
/// </summary>
private void UpdateAGVData()
{
if (_agvList == null) return;
foreach (var agv in _agvList)
{
UpdateAGVPosition(agv.AgvId, agv.CurrentPosition);
UpdateAGVDirection(agv.AgvId, agv.CurrentDirection);
UpdateAGVState(agv.AgvId, agv.CurrentState);
}
}
#endregion
#region Helper Methods
private Cursor GetCursorForMode(EditMode mode)
{
if (_canvasMode != CanvasMode.Edit)
return Cursors.Default;
switch (mode)
{
case EditMode.AddNode:
return Cursors.Cross;
case EditMode.Move:
return Cursors.SizeAll;
case EditMode.Connect:
return Cursors.Hand;
case EditMode.Delete:
return Cursors.No;
default:
return Cursors.Default;
}
}
private void CancelConnection()
{
_isConnectionMode = false;
_connectionStartNode = null;
_connectionEndPoint = Point.Empty;
Invalidate();
}
#endregion
#region Cleanup
protected override void Dispose(bool disposing)
{
if (disposing)
{
// 브러쉬 정리
_normalNodeBrush?.Dispose();
_rotationNodeBrush?.Dispose();
_dockingNodeBrush?.Dispose();
_chargingNodeBrush?.Dispose();
_selectedNodeBrush?.Dispose();
_hoveredNodeBrush?.Dispose();
_gridBrush?.Dispose();
_agvBrush?.Dispose();
_pathBrush?.Dispose();
// 펜 정리
_connectionPen?.Dispose();
_gridPen?.Dispose();
_tempConnectionPen?.Dispose();
_selectedNodePen?.Dispose();
_pathPen?.Dispose();
_agvPen?.Dispose();
// 컨텍스트 메뉴 정리
_contextMenu?.Dispose();
// 이미지 정리
_companyLogo?.Dispose();
}
base.Dispose(disposing);
}
#endregion
}
#region Interfaces
/// <summary>
/// AGV 인터페이스 (가상/실제 AGV 통합)
/// </summary>
public interface IAGV
{
string AgvId { get; }
Point CurrentPosition { get; }
AgvDirection CurrentDirection { get; }
AGVState CurrentState { get; }
float BatteryLevel { get; }
}
/// <summary>
/// AGV 상태 열거형
/// </summary>
public enum AGVState
{
Idle, // 대기
Moving, // 이동 중
Rotating, // 회전 중
Docking, // 도킹 중
Charging, // 충전 중
Error // 오류
}
#endregion
}

View File

@@ -0,0 +1,68 @@
using System;
namespace AGVNavigationCore.Models
{
/// <summary>
/// 노드 타입 열거형
/// </summary>
public enum NodeType
{
/// <summary>일반 경로 노드</summary>
Normal,
/// <summary>회전 가능 지점</summary>
Rotation,
/// <summary>도킹 스테이션</summary>
Docking,
/// <summary>충전 스테이션</summary>
Charging,
/// <summary>라벨 (UI 요소)</summary>
Label,
/// <summary>이미지 (UI 요소)</summary>
Image
}
/// <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,144 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Newtonsoft.Json;
namespace AGVNavigationCore.Models
{
/// <summary>
/// AGV 맵 파일 로딩/저장을 위한 공용 유틸리티 클래스
/// AGVMapEditor와 AGVSimulator에서 공통으로 사용
/// </summary>
public static class MapLoader
{
/// <summary>
/// 맵 파일 로딩 결과
/// </summary>
public class MapLoadResult
{
public bool Success { get; set; }
public List<MapNode> Nodes { get; set; } = new List<MapNode>();
public string ErrorMessage { get; set; } = string.Empty;
public string Version { get; set; } = string.Empty;
public DateTime CreatedDate { get; set; }
}
/// <summary>
/// 맵 파일 저장용 데이터 구조
/// </summary>
public class MapFileData
{
public List<MapNode> Nodes { get; set; } = new List<MapNode>();
public DateTime CreatedDate { get; set; }
public string Version { get; set; } = "1.0";
}
/// <summary>
/// 맵 파일을 로드하여 노드를 반환
/// </summary>
/// <param name="filePath">맵 파일 경로</param>
/// <returns>로딩 결과</returns>
public static MapLoadResult LoadMapFromFile(string filePath)
{
var result = new MapLoadResult();
try
{
if (!File.Exists(filePath))
{
result.ErrorMessage = $"파일을 찾을 수 없습니다: {filePath}";
return result;
}
var json = File.ReadAllText(filePath);
var mapData = JsonConvert.DeserializeObject<MapFileData>(json);
if (mapData != null)
{
result.Nodes = mapData.Nodes ?? new List<MapNode>();
result.Version = mapData.Version ?? "1.0";
result.CreatedDate = mapData.CreatedDate;
// 이미지 노드들의 이미지 로드
LoadImageNodes(result.Nodes);
result.Success = true;
}
else
{
result.ErrorMessage = "맵 데이터 파싱에 실패했습니다.";
}
}
catch (Exception ex)
{
result.ErrorMessage = $"맵 파일 로딩 중 오류 발생: {ex.Message}";
}
return result;
}
/// <summary>
/// 맵 데이터를 파일로 저장
/// </summary>
/// <param name="filePath">저장할 파일 경로</param>
/// <param name="nodes">맵 노드 목록</param>
/// <returns>저장 성공 여부</returns>
public static bool SaveMapToFile(string filePath, List<MapNode> nodes)
{
try
{
var mapData = new MapFileData
{
Nodes = nodes,
CreatedDate = DateTime.Now,
Version = "1.0"
};
var json = JsonConvert.SerializeObject(mapData, Formatting.Indented);
File.WriteAllText(filePath, json);
return true;
}
catch (Exception)
{
return false;
}
}
/// <summary>
/// 이미지 노드들의 이미지 로드
/// </summary>
/// <param name="nodes">노드 목록</param>
private static void LoadImageNodes(List<MapNode> nodes)
{
foreach (var node in nodes)
{
if (node.Type == NodeType.Image)
{
node.LoadImage();
}
}
}
/// <summary>
/// MapNode 목록에서 RFID가 없는 노드들에 자동으로 RFID ID를 할당합니다.
/// </summary>
/// <param name="mapNodes">맵 노드 목록</param>
public static void AssignAutoRfidIds(List<MapNode> mapNodes)
{
foreach (var node in mapNodes)
{
// 네비게이션 가능한 노드이면서 RFID가 없는 경우에만 자동 할당
if (node.IsNavigationNode() && !node.HasRfid())
{
// 기본 RFID ID 생성 (N001 -> 001)
var rfidId = node.NodeId.Replace("N", "").PadLeft(3, '0');
node.SetRfidInfo(rfidId, "", "정상");
}
}
}
}
}

View File

@@ -0,0 +1,486 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Drawing2D;
namespace AGVNavigationCore.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>
/// RFID 태그 ID (이 노드에 매핑된 RFID)
/// </summary>
public string RfidId { get; set; } = string.Empty;
/// <summary>
/// RFID 상태 (정상, 손상, 교체예정 등)
/// </summary>
public string RfidStatus { get; set; } = "정상";
/// <summary>
/// RFID 설치 위치 설명 (현장 작업자용)
/// 예: "로더1번 앞", "충전기2번 입구", "복도 교차점" 등
/// </summary>
public string RfidDescription { get; set; } = string.Empty;
/// <summary>
/// 라벨 텍스트 (NodeType.Label인 경우 사용)
/// </summary>
public string LabelText { get; set; } = string.Empty;
/// <summary>
/// 라벨 폰트 패밀리 (NodeType.Label인 경우 사용)
/// </summary>
public string FontFamily { get; set; } = "Arial";
/// <summary>
/// 라벨 폰트 크기 (NodeType.Label인 경우 사용)
/// </summary>
public float FontSize { get; set; } = 12.0f;
/// <summary>
/// 라벨 폰트 스타일 (NodeType.Label인 경우 사용)
/// </summary>
public FontStyle FontStyle { get; set; } = FontStyle.Regular;
/// <summary>
/// 라벨 전경색 (NodeType.Label인 경우 사용)
/// </summary>
public Color ForeColor { get; set; } = Color.Black;
/// <summary>
/// 라벨 배경색 (NodeType.Label인 경우 사용)
/// </summary>
public Color BackColor { get; set; } = Color.Transparent;
/// <summary>
/// 라벨 배경 표시 여부 (NodeType.Label인 경우 사용)
/// </summary>
public bool ShowBackground { get; set; } = false;
/// <summary>
/// 이미지 파일 경로 (NodeType.Image인 경우 사용)
/// </summary>
public string ImagePath { get; set; } = string.Empty;
/// <summary>
/// 이미지 크기 배율 (NodeType.Image인 경우 사용)
/// </summary>
public SizeF Scale { get; set; } = new SizeF(1.0f, 1.0f);
/// <summary>
/// 이미지 투명도 (NodeType.Image인 경우 사용, 0.0~1.0)
/// </summary>
public float Opacity { get; set; } = 1.0f;
/// <summary>
/// 이미지 회전 각도 (NodeType.Image인 경우 사용, 도 단위)
/// </summary>
public float Rotation { get; set; } = 0.0f;
/// <summary>
/// 로딩된 이미지 (런타임에서만 사용, JSON 직렬화 제외)
/// </summary>
[Newtonsoft.Json.JsonIgnore]
public Image LoadedImage { get; set; }
/// <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;
case NodeType.Label:
DisplayColor = Color.Purple;
break;
case NodeType.Image:
DisplayColor = Color.Brown;
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>
/// 리스트박스 표시용 텍스트 (노드ID - 설명 - RFID 순서)
/// </summary>
public string DisplayText
{
get
{
var displayText = NodeId;
if (!string.IsNullOrEmpty(Description))
{
displayText += $" - {Description}";
}
if (!string.IsNullOrEmpty(RfidId))
{
displayText += $" - [{RfidId}]";
}
return displayText;
}
}
/// <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,
RfidId = RfidId,
RfidStatus = RfidStatus,
RfidDescription = RfidDescription,
LabelText = LabelText,
FontFamily = FontFamily,
FontSize = FontSize,
FontStyle = FontStyle,
ForeColor = ForeColor,
BackColor = BackColor,
ShowBackground = ShowBackground,
ImagePath = ImagePath,
Scale = Scale,
Opacity = Opacity,
Rotation = Rotation
};
return clone;
}
/// <summary>
/// 이미지 로드 (256x256 이상일 경우 자동 리사이즈)
/// </summary>
/// <returns>로드 성공 여부</returns>
public bool LoadImage()
{
if (Type != NodeType.Image) return false;
try
{
if (!string.IsNullOrEmpty(ImagePath) && System.IO.File.Exists(ImagePath))
{
LoadedImage?.Dispose();
var originalImage = Image.FromFile(ImagePath);
// 이미지 크기 체크 및 리사이즈
if (originalImage.Width > 256 || originalImage.Height > 256)
{
LoadedImage = ResizeImage(originalImage, 256, 256);
originalImage.Dispose();
}
else
{
LoadedImage = originalImage;
}
return true;
}
}
catch (Exception)
{
// 이미지 로드 실패
}
return false;
}
/// <summary>
/// 이미지 리사이즈 (비율 유지)
/// </summary>
/// <param name="image">원본 이미지</param>
/// <param name="maxWidth">최대 너비</param>
/// <param name="maxHeight">최대 높이</param>
/// <returns>리사이즈된 이미지</returns>
private Image ResizeImage(Image image, int maxWidth, int maxHeight)
{
// 비율 계산
double ratioX = (double)maxWidth / image.Width;
double ratioY = (double)maxHeight / image.Height;
double ratio = Math.Min(ratioX, ratioY);
// 새로운 크기 계산
int newWidth = (int)(image.Width * ratio);
int newHeight = (int)(image.Height * ratio);
// 리사이즈된 이미지 생성
var resizedImage = new Bitmap(newWidth, newHeight);
using (var graphics = Graphics.FromImage(resizedImage))
{
graphics.CompositingQuality = CompositingQuality.HighQuality;
graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
graphics.SmoothingMode = SmoothingMode.HighQuality;
graphics.DrawImage(image, 0, 0, newWidth, newHeight);
}
return resizedImage;
}
/// <summary>
/// 실제 표시될 크기 계산 (이미지 노드인 경우)
/// </summary>
/// <returns>실제 크기</returns>
public Size GetDisplaySize()
{
if (Type != NodeType.Image || LoadedImage == null) return Size.Empty;
return new Size(
(int)(LoadedImage.Width * Scale.Width),
(int)(LoadedImage.Height * Scale.Height)
);
}
/// <summary>
/// 리소스 정리
/// </summary>
public void Dispose()
{
LoadedImage?.Dispose();
LoadedImage = null;
}
/// <summary>
/// 경로 찾기에 사용 가능한 노드인지 확인
/// (라벨, 이미지 노드는 경로 찾기에서 제외)
/// </summary>
public bool IsNavigationNode()
{
return Type != NodeType.Label && Type != NodeType.Image && IsActive;
}
/// <summary>
/// RFID가 할당되어 있는지 확인
/// </summary>
public bool HasRfid()
{
return !string.IsNullOrEmpty(RfidId);
}
/// <summary>
/// RFID 정보 설정
/// </summary>
/// <param name="rfidId">RFID ID</param>
/// <param name="rfidDescription">설치 위치 설명</param>
/// <param name="rfidStatus">RFID 상태</param>
public void SetRfidInfo(string rfidId, string rfidDescription = "", string rfidStatus = "정상")
{
RfidId = rfidId;
RfidDescription = rfidDescription;
RfidStatus = rfidStatus;
ModifiedDate = DateTime.Now;
}
/// <summary>
/// RFID 정보 삭제
/// </summary>
public void ClearRfidInfo()
{
RfidId = string.Empty;
RfidDescription = string.Empty;
RfidStatus = "정상";
ModifiedDate = DateTime.Now;
}
/// <summary>
/// RFID 기반 표시 텍스트 (RFID ID 우선, 없으면 노드ID)
/// </summary>
public string GetRfidDisplayText()
{
return HasRfid() ? RfidId : NodeId;
}
}
}

View File

@@ -0,0 +1,79 @@
using System;
namespace AGVNavigationCore.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,244 @@
using System;
using System.Collections.Generic;
using AGVNavigationCore.Models;
namespace AGVNavigationCore.PathFinding
{
/// <summary>
/// AGV 경로 계산 결과 (방향성 및 명령어 포함)
/// </summary>
public class AGVPathResult
{
/// <summary>
/// 경로 찾기 성공 여부
/// </summary>
public bool Success { get; set; }
/// <summary>
/// 경로 노드 ID 목록 (시작 → 목적지 순서)
/// </summary>
public List<string> Path { get; set; }
/// <summary>
/// AGV 명령어 목록 (이동 방향 시퀀스)
/// </summary>
public List<AgvDirection> Commands { get; set; }
/// <summary>
/// 총 거리
/// </summary>
public float TotalDistance { get; set; }
/// <summary>
/// 계산 소요 시간 (밀리초)
/// </summary>
public long CalculationTimeMs { get; set; }
/// <summary>
/// 예상 소요 시간 (초)
/// </summary>
public float EstimatedTimeSeconds { get; set; }
/// <summary>
/// 회전 횟수
/// </summary>
public int RotationCount { get; set; }
/// <summary>
/// 오류 메시지 (실패시)
/// </summary>
public string ErrorMessage { get; set; }
/// <summary>
/// 기본 생성자
/// </summary>
public AGVPathResult()
{
Success = false;
Path = new List<string>();
Commands = new List<AgvDirection>();
TotalDistance = 0;
CalculationTimeMs = 0;
EstimatedTimeSeconds = 0;
RotationCount = 0;
ErrorMessage = string.Empty;
}
/// <summary>
/// 성공 결과 생성
/// </summary>
/// <param name="path">경로</param>
/// <param name="commands">AGV 명령어 목록</param>
/// <param name="totalDistance">총 거리</param>
/// <param name="calculationTimeMs">계산 시간</param>
/// <returns>성공 결과</returns>
public static AGVPathResult CreateSuccess(List<string> path, List<AgvDirection> commands, float totalDistance, long calculationTimeMs)
{
var result = new AGVPathResult
{
Success = true,
Path = new List<string>(path),
Commands = new List<AgvDirection>(commands),
TotalDistance = totalDistance,
CalculationTimeMs = calculationTimeMs
};
result.CalculateMetrics();
return result;
}
/// <summary>
/// 실패 결과 생성
/// </summary>
/// <param name="errorMessage">오류 메시지</param>
/// <param name="calculationTimeMs">계산 시간</param>
/// <returns>실패 결과</returns>
public static AGVPathResult CreateFailure(string errorMessage, long calculationTimeMs)
{
return new AGVPathResult
{
Success = false,
ErrorMessage = errorMessage,
CalculationTimeMs = calculationTimeMs
};
}
/// <summary>
/// 경로 메트릭 계산
/// </summary>
private void CalculateMetrics()
{
RotationCount = CountRotations();
EstimatedTimeSeconds = CalculateEstimatedTime();
}
/// <summary>
/// 회전 횟수 계산
/// </summary>
private int CountRotations()
{
int count = 0;
foreach (var command in Commands)
{
if (command == AgvDirection.Left || command == AgvDirection.Right)
{
count++;
}
}
return count;
}
/// <summary>
/// 예상 소요 시간 계산
/// </summary>
/// <param name="agvSpeed">AGV 속도 (픽셀/초, 기본값: 100)</param>
/// <param name="rotationTime">회전 시간 (초, 기본값: 3)</param>
/// <returns>예상 소요 시간 (초)</returns>
private float CalculateEstimatedTime(float agvSpeed = 100.0f, float rotationTime = 3.0f)
{
float moveTime = TotalDistance / agvSpeed;
float totalRotationTime = RotationCount * rotationTime;
return moveTime + totalRotationTime;
}
/// <summary>
/// 명령어 요약 생성
/// </summary>
/// <returns>명령어 요약 문자열</returns>
public string GetCommandSummary()
{
if (!Success) return "실패";
var summary = new List<string>();
var currentCommand = AgvDirection.Stop;
var count = 0;
foreach (var command in Commands)
{
if (command == currentCommand)
{
count++;
}
else
{
if (count > 0)
{
summary.Add($"{GetCommandText(currentCommand)}×{count}");
}
currentCommand = command;
count = 1;
}
}
if (count > 0)
{
summary.Add($"{GetCommandText(currentCommand)}×{count}");
}
return string.Join(" → ", summary);
}
/// <summary>
/// 명령어 텍스트 반환
/// </summary>
private string GetCommandText(AgvDirection command)
{
switch (command)
{
case AgvDirection.Forward: return "전진";
case AgvDirection.Backward: return "후진";
case AgvDirection.Left: return "좌회전";
case AgvDirection.Right: return "우회전";
case AgvDirection.Stop: return "정지";
default: return command.ToString();
}
}
/// <summary>
/// 상세 경로 정보 반환
/// </summary>
/// <returns>상세 정보 문자열</returns>
public string GetDetailedInfo()
{
if (!Success)
{
return $"경로 계산 실패: {ErrorMessage} (계산시간: {CalculationTimeMs}ms)";
}
return $"경로: {Path.Count}개 노드, 거리: {TotalDistance:F1}px, " +
$"회전: {RotationCount}회, 예상시간: {EstimatedTimeSeconds:F1}초, " +
$"계산시간: {CalculationTimeMs}ms";
}
/// <summary>
/// PathResult로 변환 (호환성을 위해)
/// </summary>
/// <returns>PathResult 객체</returns>
public PathResult ToPathResult()
{
if (Success)
{
return PathResult.CreateSuccess(Path, TotalDistance, CalculationTimeMs, 0);
}
else
{
return PathResult.CreateFailure(ErrorMessage, CalculationTimeMs, 0);
}
}
/// <summary>
/// 문자열 표현
/// </summary>
public override string ToString()
{
if (Success)
{
return $"Success: {Path.Count} nodes, {TotalDistance:F1}px, {RotationCount} rotations, {EstimatedTimeSeconds:F1}s";
}
else
{
return $"Failed: {ErrorMessage}";
}
}
}
}

View File

@@ -0,0 +1,287 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AGVNavigationCore.Models;
namespace AGVNavigationCore.PathFinding
{
/// <summary>
/// AGV 특화 경로 탐색기 (방향성 및 도킹 제약 고려)
/// </summary>
public class AGVPathfinder
{
private AStarPathfinder _pathfinder;
private Dictionary<string, MapNode> _nodeMap;
/// <summary>
/// AGV 현재 방향
/// </summary>
public AgvDirection CurrentDirection { get; set; } = AgvDirection.Forward;
/// <summary>
/// 회전 비용 가중치 (회전이 비싼 동작임을 반영)
/// </summary>
public float RotationCostWeight { get; set; } = 50.0f;
/// <summary>
/// 도킹 접근 거리 (픽셀 단위)
/// </summary>
public float DockingApproachDistance { get; set; } = 100.0f;
/// <summary>
/// 생성자
/// </summary>
public AGVPathfinder()
{
_pathfinder = new AStarPathfinder();
_nodeMap = new Dictionary<string, MapNode>();
}
/// <summary>
/// 맵 노드 설정
/// </summary>
/// <param name="mapNodes">맵 노드 목록</param>
public void SetMapNodes(List<MapNode> mapNodes)
{
_pathfinder.SetMapNodes(mapNodes);
_nodeMap.Clear();
foreach (var node in mapNodes ?? new List<MapNode>())
{
_nodeMap[node.NodeId] = node;
}
}
/// <summary>
/// AGV 경로 계산 (방향성 및 도킹 제약 고려)
/// </summary>
/// <param name="startNodeId">시작 노드 ID</param>
/// <param name="endNodeId">목적지 노드 ID</param>
/// <param name="targetDirection">목적지 도착 방향 (null이면 자동 결정)</param>
/// <returns>AGV 경로 계산 결과</returns>
public AGVPathResult FindAGVPath(string startNodeId, string endNodeId, AgvDirection? targetDirection = null)
{
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
try
{
if (!_nodeMap.ContainsKey(startNodeId))
{
return AGVPathResult.CreateFailure($"시작 노드를 찾을 수 없습니다: {startNodeId}", stopwatch.ElapsedMilliseconds);
}
if (!_nodeMap.ContainsKey(endNodeId))
{
return AGVPathResult.CreateFailure($"목적지 노드를 찾을 수 없습니다: {endNodeId}", stopwatch.ElapsedMilliseconds);
}
var endNode = _nodeMap[endNodeId];
if (IsSpecialNode(endNode))
{
return FindPathToSpecialNode(startNodeId, endNode, targetDirection, stopwatch);
}
else
{
return FindNormalPath(startNodeId, endNodeId, targetDirection, stopwatch);
}
}
catch (Exception ex)
{
return AGVPathResult.CreateFailure($"AGV 경로 계산 중 오류: {ex.Message}", stopwatch.ElapsedMilliseconds);
}
}
/// <summary>
/// 충전 스테이션으로의 경로 찾기
/// </summary>
/// <param name="startNodeId">시작 노드 ID</param>
/// <returns>AGV 경로 계산 결과</returns>
public AGVPathResult FindPathToChargingStation(string startNodeId)
{
var chargingStations = _nodeMap.Values
.Where(n => n.Type == NodeType.Charging && n.IsActive)
.Select(n => n.NodeId)
.ToList();
if (chargingStations.Count == 0)
{
return AGVPathResult.CreateFailure("사용 가능한 충전 스테이션이 없습니다", 0);
}
var nearestResult = _pathfinder.FindNearestPath(startNodeId, chargingStations);
if (!nearestResult.Success)
{
return AGVPathResult.CreateFailure("충전 스테이션으로의 경로를 찾을 수 없습니다", nearestResult.CalculationTimeMs);
}
var targetNodeId = nearestResult.Path.Last();
return FindAGVPath(startNodeId, targetNodeId, AgvDirection.Forward);
}
/// <summary>
/// 특정 타입의 도킹 스테이션으로의 경로 찾기
/// </summary>
/// <param name="startNodeId">시작 노드 ID</param>
/// <param name="stationType">장비 타입</param>
/// <returns>AGV 경로 계산 결과</returns>
public AGVPathResult FindPathToDockingStation(string startNodeId, StationType stationType)
{
var dockingStations = _nodeMap.Values
.Where(n => n.Type == NodeType.Docking && n.StationType == stationType && n.IsActive)
.Select(n => n.NodeId)
.ToList();
if (dockingStations.Count == 0)
{
return AGVPathResult.CreateFailure($"{stationType} 타입의 사용 가능한 도킹 스테이션이 없습니다", 0);
}
var nearestResult = _pathfinder.FindNearestPath(startNodeId, dockingStations);
if (!nearestResult.Success)
{
return AGVPathResult.CreateFailure($"{stationType} 도킹 스테이션으로의 경로를 찾을 수 없습니다", nearestResult.CalculationTimeMs);
}
var targetNodeId = nearestResult.Path.Last();
return FindAGVPath(startNodeId, targetNodeId, AgvDirection.Backward);
}
/// <summary>
/// 일반 노드로의 경로 계산
/// </summary>
private AGVPathResult FindNormalPath(string startNodeId, string endNodeId, AgvDirection? targetDirection, System.Diagnostics.Stopwatch stopwatch)
{
var result = _pathfinder.FindPath(startNodeId, endNodeId);
if (!result.Success)
{
return AGVPathResult.CreateFailure(result.ErrorMessage, stopwatch.ElapsedMilliseconds);
}
var agvCommands = GenerateAGVCommands(result.Path, targetDirection ?? AgvDirection.Forward);
return AGVPathResult.CreateSuccess(result.Path, agvCommands, result.TotalDistance, stopwatch.ElapsedMilliseconds);
}
/// <summary>
/// 특수 노드(도킹/충전)로의 경로 계산
/// </summary>
private AGVPathResult FindPathToSpecialNode(string startNodeId, MapNode endNode, AgvDirection? targetDirection, System.Diagnostics.Stopwatch stopwatch)
{
var requiredDirection = GetRequiredDirectionForNode(endNode);
var actualTargetDirection = targetDirection ?? requiredDirection;
var result = _pathfinder.FindPath(startNodeId, endNode.NodeId);
if (!result.Success)
{
return AGVPathResult.CreateFailure(result.ErrorMessage, stopwatch.ElapsedMilliseconds);
}
if (actualTargetDirection != requiredDirection)
{
return AGVPathResult.CreateFailure($"{endNode.NodeId}는 {requiredDirection} 방향으로만 접근 가능합니다", stopwatch.ElapsedMilliseconds);
}
var agvCommands = GenerateAGVCommands(result.Path, actualTargetDirection);
return AGVPathResult.CreateSuccess(result.Path, agvCommands, result.TotalDistance, stopwatch.ElapsedMilliseconds);
}
/// <summary>
/// 노드가 특수 노드(도킹/충전)인지 확인
/// </summary>
private bool IsSpecialNode(MapNode node)
{
return node.Type == NodeType.Docking || node.Type == NodeType.Charging;
}
/// <summary>
/// 노드에 필요한 접근 방향 반환
/// </summary>
private AgvDirection GetRequiredDirectionForNode(MapNode node)
{
switch (node.Type)
{
case NodeType.Charging:
return AgvDirection.Forward;
case NodeType.Docking:
return node.DockDirection == DockingDirection.Forward ? AgvDirection.Forward : AgvDirection.Backward;
default:
return AgvDirection.Forward;
}
}
/// <summary>
/// 경로에서 AGV 명령어 생성
/// </summary>
private List<AgvDirection> GenerateAGVCommands(List<string> path, AgvDirection targetDirection)
{
var commands = new List<AgvDirection>();
if (path.Count < 2) return commands;
var currentDir = CurrentDirection;
for (int i = 0; i < path.Count - 1; i++)
{
var currentNodeId = path[i];
var nextNodeId = path[i + 1];
if (_nodeMap.ContainsKey(currentNodeId) && _nodeMap.ContainsKey(nextNodeId))
{
var currentNode = _nodeMap[currentNodeId];
var nextNode = _nodeMap[nextNodeId];
if (currentNode.CanRotate && ShouldRotate(currentDir, targetDirection))
{
commands.Add(GetRotationCommand(currentDir, targetDirection));
currentDir = targetDirection;
}
commands.Add(currentDir);
}
}
return commands;
}
/// <summary>
/// 회전이 필요한지 판단
/// </summary>
private bool ShouldRotate(AgvDirection current, AgvDirection target)
{
return current != target && (current == AgvDirection.Forward && target == AgvDirection.Backward ||
current == AgvDirection.Backward && target == AgvDirection.Forward);
}
/// <summary>
/// 회전 명령어 반환
/// </summary>
private AgvDirection GetRotationCommand(AgvDirection from, AgvDirection to)
{
if (from == AgvDirection.Forward && to == AgvDirection.Backward)
return AgvDirection.Right;
if (from == AgvDirection.Backward && to == AgvDirection.Forward)
return AgvDirection.Right;
return AgvDirection.Right;
}
/// <summary>
/// 경로 유효성 검증
/// </summary>
/// <param name="path">검증할 경로</param>
/// <returns>유효성 검증 결과</returns>
public bool ValidatePath(List<string> path)
{
if (path == null || path.Count < 2) return true;
for (int i = 0; i < path.Count - 1; i++)
{
if (!_pathfinder.AreNodesConnected(path[i], path[i + 1]))
{
return false;
}
}
return true;
}
}
}

View File

@@ -0,0 +1,291 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using AGVNavigationCore.Models;
namespace AGVNavigationCore.PathFinding
{
/// <summary>
/// A* 알고리즘 기반 경로 탐색기
/// </summary>
public class AStarPathfinder
{
private Dictionary<string, PathNode> _nodeMap;
private List<MapNode> _mapNodes;
/// <summary>
/// 휴리스틱 가중치 (기본값: 1.0)
/// 값이 클수록 목적지 방향을 우선시하나 최적 경로를 놓칠 수 있음
/// </summary>
public float HeuristicWeight { get; set; } = 1.0f;
/// <summary>
/// 최대 탐색 노드 수 (무한 루프 방지)
/// </summary>
public int MaxSearchNodes { get; set; } = 1000;
/// <summary>
/// 생성자
/// </summary>
public AStarPathfinder()
{
_nodeMap = new Dictionary<string, PathNode>();
_mapNodes = new List<MapNode>();
}
/// <summary>
/// 맵 노드 설정
/// </summary>
/// <param name="mapNodes">맵 노드 목록</param>
public void SetMapNodes(List<MapNode> mapNodes)
{
_mapNodes = mapNodes ?? new List<MapNode>();
_nodeMap.Clear();
// 1단계: 모든 네비게이션 노드를 PathNode로 변환
foreach (var mapNode in _mapNodes)
{
if (mapNode.IsNavigationNode())
{
var pathNode = new PathNode(mapNode.NodeId, mapNode.Position);
pathNode.ConnectedNodes = new List<string>(mapNode.ConnectedNodes);
_nodeMap[mapNode.NodeId] = pathNode;
}
}
// 2단계: 양방향 연결 자동 생성 (A→B 연결이 있으면 B→A도 추가)
EnsureBidirectionalConnections();
}
/// <summary>
/// 단방향 연결을 양방향으로 자동 변환
/// A→B 연결이 있으면 B→A 연결도 자동 생성
/// </summary>
private void EnsureBidirectionalConnections()
{
foreach (var nodeId in _nodeMap.Keys.ToList())
{
var node = _nodeMap[nodeId];
foreach (var connectedNodeId in node.ConnectedNodes.ToList())
{
// 연결된 노드가 존재하고 네비게이션 가능한 노드인지 확인
if (_nodeMap.ContainsKey(connectedNodeId))
{
var connectedNode = _nodeMap[connectedNodeId];
// 역방향 연결이 없으면 추가
if (!connectedNode.ConnectedNodes.Contains(nodeId))
{
connectedNode.ConnectedNodes.Add(nodeId);
}
}
}
}
}
/// <summary>
/// 경로 찾기 (A* 알고리즘)
/// </summary>
/// <param name="startNodeId">시작 노드 ID</param>
/// <param name="endNodeId">목적지 노드 ID</param>
/// <returns>경로 계산 결과</returns>
public PathResult FindPath(string startNodeId, string endNodeId)
{
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
try
{
if (!_nodeMap.ContainsKey(startNodeId))
{
return PathResult.CreateFailure($"시작 노드를 찾을 수 없습니다: {startNodeId}", stopwatch.ElapsedMilliseconds, 0);
}
if (!_nodeMap.ContainsKey(endNodeId))
{
return PathResult.CreateFailure($"목적지 노드를 찾을 수 없습니다: {endNodeId}", stopwatch.ElapsedMilliseconds, 0);
}
if (startNodeId == endNodeId)
{
return PathResult.CreateSuccess(new List<string> { startNodeId }, 0, stopwatch.ElapsedMilliseconds, 1);
}
var startNode = _nodeMap[startNodeId];
var endNode = _nodeMap[endNodeId];
var openSet = new List<PathNode>();
var closedSet = new HashSet<string>();
var exploredCount = 0;
startNode.GCost = 0;
startNode.HCost = CalculateHeuristic(startNode, endNode);
startNode.Parent = null;
openSet.Add(startNode);
while (openSet.Count > 0 && exploredCount < MaxSearchNodes)
{
var currentNode = GetLowestFCostNode(openSet);
openSet.Remove(currentNode);
closedSet.Add(currentNode.NodeId);
exploredCount++;
if (currentNode.NodeId == endNodeId)
{
var path = ReconstructPath(currentNode);
var totalDistance = CalculatePathDistance(path);
return PathResult.CreateSuccess(path, totalDistance, stopwatch.ElapsedMilliseconds, exploredCount);
}
foreach (var neighborId in currentNode.ConnectedNodes)
{
if (closedSet.Contains(neighborId) || !_nodeMap.ContainsKey(neighborId))
continue;
var neighbor = _nodeMap[neighborId];
var tentativeGCost = currentNode.GCost + currentNode.DistanceTo(neighbor);
if (!openSet.Contains(neighbor))
{
neighbor.Parent = currentNode;
neighbor.GCost = tentativeGCost;
neighbor.HCost = CalculateHeuristic(neighbor, endNode);
openSet.Add(neighbor);
}
else if (tentativeGCost < neighbor.GCost)
{
neighbor.Parent = currentNode;
neighbor.GCost = tentativeGCost;
}
}
}
return PathResult.CreateFailure("경로를 찾을 수 없습니다", stopwatch.ElapsedMilliseconds, exploredCount);
}
catch (Exception ex)
{
return PathResult.CreateFailure($"경로 계산 중 오류: {ex.Message}", stopwatch.ElapsedMilliseconds, 0);
}
}
/// <summary>
/// 여러 목적지 중 가장 가까운 노드로의 경로 찾기
/// </summary>
/// <param name="startNodeId">시작 노드 ID</param>
/// <param name="targetNodeIds">목적지 후보 노드 ID 목록</param>
/// <returns>경로 계산 결과</returns>
public PathResult FindNearestPath(string startNodeId, List<string> targetNodeIds)
{
if (targetNodeIds == null || targetNodeIds.Count == 0)
{
return PathResult.CreateFailure("목적지 노드가 지정되지 않았습니다", 0, 0);
}
PathResult bestResult = null;
foreach (var targetId in targetNodeIds)
{
var result = FindPath(startNodeId, targetId);
if (result.Success && (bestResult == null || result.TotalDistance < bestResult.TotalDistance))
{
bestResult = result;
}
}
return bestResult ?? PathResult.CreateFailure("모든 목적지로의 경로를 찾을 수 없습니다", 0, 0);
}
/// <summary>
/// 휴리스틱 거리 계산 (유클리드 거리)
/// </summary>
private float CalculateHeuristic(PathNode from, PathNode to)
{
return from.DistanceTo(to) * HeuristicWeight;
}
/// <summary>
/// F cost가 가장 낮은 노드 선택
/// </summary>
private PathNode GetLowestFCostNode(List<PathNode> nodes)
{
PathNode lowest = nodes[0];
foreach (var node in nodes)
{
if (node.FCost < lowest.FCost ||
(Math.Abs(node.FCost - lowest.FCost) < 0.001f && node.HCost < lowest.HCost))
{
lowest = node;
}
}
return lowest;
}
/// <summary>
/// 경로 재구성 (부모 노드를 따라 역추적)
/// </summary>
private List<string> ReconstructPath(PathNode endNode)
{
var path = new List<string>();
var current = endNode;
while (current != null)
{
path.Add(current.NodeId);
current = current.Parent;
}
path.Reverse();
return path;
}
/// <summary>
/// 경로의 총 거리 계산
/// </summary>
private float CalculatePathDistance(List<string> path)
{
if (path.Count < 2) return 0;
float totalDistance = 0;
for (int i = 0; i < path.Count - 1; i++)
{
if (_nodeMap.ContainsKey(path[i]) && _nodeMap.ContainsKey(path[i + 1]))
{
totalDistance += _nodeMap[path[i]].DistanceTo(_nodeMap[path[i + 1]]);
}
}
return totalDistance;
}
/// <summary>
/// 두 노드가 연결되어 있는지 확인
/// </summary>
/// <param name="nodeId1">노드 1 ID</param>
/// <param name="nodeId2">노드 2 ID</param>
/// <returns>연결 여부</returns>
public bool AreNodesConnected(string nodeId1, string nodeId2)
{
if (!_nodeMap.ContainsKey(nodeId1) || !_nodeMap.ContainsKey(nodeId2))
return false;
return _nodeMap[nodeId1].ConnectedNodes.Contains(nodeId2);
}
/// <summary>
/// 네비게이션 가능한 노드 목록 반환
/// </summary>
/// <returns>노드 ID 목록</returns>
public List<string> GetNavigationNodes()
{
return _nodeMap.Keys.ToList();
}
/// <summary>
/// 노드 정보 반환
/// </summary>
/// <param name="nodeId">노드 ID</param>
/// <returns>노드 정보 또는 null</returns>
public PathNode GetNode(string nodeId)
{
return _nodeMap.ContainsKey(nodeId) ? _nodeMap[nodeId] : null;
}
}
}

View File

@@ -0,0 +1,101 @@
using System;
using System.Drawing;
namespace AGVNavigationCore.PathFinding
{
/// <summary>
/// A* 알고리즘에서 사용하는 경로 노드
/// </summary>
public class PathNode
{
/// <summary>
/// 노드 ID
/// </summary>
public string NodeId { get; set; }
/// <summary>
/// 노드 위치
/// </summary>
public Point Position { get; set; }
/// <summary>
/// 시작점으로부터의 실제 거리 (G cost)
/// </summary>
public float GCost { get; set; }
/// <summary>
/// 목적지까지의 추정 거리 (H cost - 휴리스틱)
/// </summary>
public float HCost { get; set; }
/// <summary>
/// 총 비용 (F cost = G cost + H cost)
/// </summary>
public float FCost => GCost + HCost;
/// <summary>
/// 부모 노드 (경로 추적용)
/// </summary>
public PathNode Parent { get; set; }
/// <summary>
/// 연결된 노드 ID 목록
/// </summary>
public System.Collections.Generic.List<string> ConnectedNodes { get; set; }
/// <summary>
/// 생성자
/// </summary>
/// <param name="nodeId">노드 ID</param>
/// <param name="position">위치</param>
public PathNode(string nodeId, Point position)
{
NodeId = nodeId;
Position = position;
GCost = 0;
HCost = 0;
Parent = null;
ConnectedNodes = new System.Collections.Generic.List<string>();
}
/// <summary>
/// 다른 노드까지의 유클리드 거리 계산
/// </summary>
/// <param name="other">대상 노드</param>
/// <returns>거리</returns>
public float DistanceTo(PathNode other)
{
float dx = Position.X - other.Position.X;
float dy = Position.Y - other.Position.Y;
return (float)Math.Sqrt(dx * dx + dy * dy);
}
/// <summary>
/// 문자열 표현
/// </summary>
public override string ToString()
{
return $"{NodeId} - F:{FCost:F1} G:{GCost:F1} H:{HCost:F1}";
}
/// <summary>
/// 같음 비교 (NodeId 기준)
/// </summary>
public override bool Equals(object obj)
{
if (obj is PathNode other)
{
return NodeId == other.NodeId;
}
return false;
}
/// <summary>
/// 해시코드 (NodeId 기준)
/// </summary>
public override int GetHashCode()
{
return NodeId?.GetHashCode() ?? 0;
}
}
}

View File

@@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
namespace AGVNavigationCore.PathFinding
{
/// <summary>
/// 경로 계산 결과
/// </summary>
public class PathResult
{
/// <summary>
/// 경로 찾기 성공 여부
/// </summary>
public bool Success { get; set; }
/// <summary>
/// 경로 노드 ID 목록 (시작 → 목적지 순서)
/// </summary>
public List<string> Path { get; set; }
/// <summary>
/// 총 거리
/// </summary>
public float TotalDistance { get; set; }
/// <summary>
/// 계산 소요 시간 (밀리초)
/// </summary>
public long CalculationTimeMs { get; set; }
/// <summary>
/// 탐색한 노드 수
/// </summary>
public int ExploredNodeCount { get; set; }
/// <summary>
/// 오류 메시지 (실패시)
/// </summary>
public string ErrorMessage { get; set; }
/// <summary>
/// 기본 생성자
/// </summary>
public PathResult()
{
Success = false;
Path = new List<string>();
TotalDistance = 0;
CalculationTimeMs = 0;
ExploredNodeCount = 0;
ErrorMessage = string.Empty;
}
/// <summary>
/// 성공 결과 생성
/// </summary>
/// <param name="path">경로</param>
/// <param name="totalDistance">총 거리</param>
/// <param name="calculationTimeMs">계산 시간</param>
/// <param name="exploredNodeCount">탐색 노드 수</param>
/// <returns>성공 결과</returns>
public static PathResult CreateSuccess(List<string> path, float totalDistance, long calculationTimeMs, int exploredNodeCount)
{
return new PathResult
{
Success = true,
Path = new List<string>(path),
TotalDistance = totalDistance,
CalculationTimeMs = calculationTimeMs,
ExploredNodeCount = exploredNodeCount
};
}
/// <summary>
/// 실패 결과 생성
/// </summary>
/// <param name="errorMessage">오류 메시지</param>
/// <param name="calculationTimeMs">계산 시간</param>
/// <param name="exploredNodeCount">탐색 노드 수</param>
/// <returns>실패 결과</returns>
public static PathResult CreateFailure(string errorMessage, long calculationTimeMs, int exploredNodeCount)
{
return new PathResult
{
Success = false,
ErrorMessage = errorMessage,
CalculationTimeMs = calculationTimeMs,
ExploredNodeCount = exploredNodeCount
};
}
/// <summary>
/// 문자열 표현
/// </summary>
public override string ToString()
{
if (Success)
{
return $"Success: {Path.Count} nodes, {TotalDistance:F1}px, {CalculationTimeMs}ms";
}
else
{
return $"Failed: {ErrorMessage}, {CalculationTimeMs}ms";
}
}
}
}

View File

@@ -0,0 +1,275 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AGVNavigationCore.Models;
namespace AGVNavigationCore.PathFinding
{
/// <summary>
/// RFID 기반 AGV 경로 탐색기
/// 실제 현장에서 AGV가 RFID를 읽어서 위치를 파악하는 방식에 맞춤
/// </summary>
public class RfidBasedPathfinder
{
private AGVPathfinder _agvPathfinder;
private AStarPathfinder _astarPathfinder;
private Dictionary<string, string> _rfidToNodeMap; // RFID -> NodeId
private Dictionary<string, string> _nodeToRfidMap; // NodeId -> RFID
private List<MapNode> _mapNodes;
/// <summary>
/// AGV 현재 방향
/// </summary>
public AgvDirection CurrentDirection
{
get => _agvPathfinder.CurrentDirection;
set => _agvPathfinder.CurrentDirection = value;
}
/// <summary>
/// 회전 비용 가중치
/// </summary>
public float RotationCostWeight
{
get => _agvPathfinder.RotationCostWeight;
set => _agvPathfinder.RotationCostWeight = value;
}
/// <summary>
/// 생성자
/// </summary>
public RfidBasedPathfinder()
{
_agvPathfinder = new AGVPathfinder();
_astarPathfinder = new AStarPathfinder();
_rfidToNodeMap = new Dictionary<string, string>();
_nodeToRfidMap = new Dictionary<string, string>();
_mapNodes = new List<MapNode>();
}
/// <summary>
/// 맵 노드 설정 (MapNode의 RFID 정보 직접 사용)
/// </summary>
/// <param name="mapNodes">맵 노드 목록</param>
public void SetMapNodes(List<MapNode> mapNodes)
{
// 기존 pathfinder에 맵 노드 설정
_agvPathfinder.SetMapNodes(mapNodes);
_astarPathfinder.SetMapNodes(mapNodes);
// MapNode의 RFID 정보로 매핑 구성
_mapNodes = mapNodes ?? new List<MapNode>();
_rfidToNodeMap.Clear();
_nodeToRfidMap.Clear();
foreach (var node in _mapNodes.Where(n => n.IsActive && n.HasRfid()))
{
_rfidToNodeMap[node.RfidId] = node.NodeId;
_nodeToRfidMap[node.NodeId] = node.RfidId;
}
}
/// <summary>
/// RFID 기반 AGV 경로 계산
/// </summary>
/// <param name="startRfidId">시작 RFID</param>
/// <param name="endRfidId">목적지 RFID</param>
/// <param name="targetDirection">목적지 도착 방향</param>
/// <returns>RFID 기반 AGV 경로 계산 결과</returns>
public RfidPathResult FindAGVPath(string startRfidId, string endRfidId, AgvDirection? targetDirection = null)
{
try
{
// RFID를 NodeId로 변환
if (!_rfidToNodeMap.TryGetValue(startRfidId, out string startNodeId))
{
return RfidPathResult.CreateFailure($"시작 RFID를 찾을 수 없습니다: {startRfidId}", 0);
}
if (!_rfidToNodeMap.TryGetValue(endRfidId, out string endNodeId))
{
return RfidPathResult.CreateFailure($"목적지 RFID를 찾을 수 없습니다: {endRfidId}", 0);
}
// NodeId 기반으로 경로 계산
var nodeResult = _agvPathfinder.FindAGVPath(startNodeId, endNodeId, targetDirection);
// 결과를 RFID 기반으로 변환
return ConvertToRfidResult(nodeResult, startRfidId, endRfidId);
}
catch (Exception ex)
{
return RfidPathResult.CreateFailure($"RFID 기반 경로 계산 중 오류: {ex.Message}", 0);
}
}
/// <summary>
/// 가장 가까운 충전소로의 RFID 기반 경로 찾기
/// </summary>
/// <param name="startRfidId">시작 RFID</param>
/// <returns>RFID 기반 경로 계산 결과</returns>
public RfidPathResult FindPathToChargingStation(string startRfidId)
{
try
{
if (!_rfidToNodeMap.TryGetValue(startRfidId, out string startNodeId))
{
return RfidPathResult.CreateFailure($"시작 RFID를 찾을 수 없습니다: {startRfidId}", 0);
}
var nodeResult = _agvPathfinder.FindPathToChargingStation(startNodeId);
return ConvertToRfidResult(nodeResult, startRfidId, null);
}
catch (Exception ex)
{
return RfidPathResult.CreateFailure($"충전소 경로 계산 중 오류: {ex.Message}", 0);
}
}
/// <summary>
/// 특정 장비 타입의 도킹 스테이션으로의 RFID 기반 경로 찾기
/// </summary>
/// <param name="startRfidId">시작 RFID</param>
/// <param name="stationType">장비 타입</param>
/// <returns>RFID 기반 경로 계산 결과</returns>
public RfidPathResult FindPathToDockingStation(string startRfidId, StationType stationType)
{
try
{
if (!_rfidToNodeMap.TryGetValue(startRfidId, out string startNodeId))
{
return RfidPathResult.CreateFailure($"시작 RFID를 찾을 수 없습니다: {startRfidId}", 0);
}
var nodeResult = _agvPathfinder.FindPathToDockingStation(startNodeId, stationType);
return ConvertToRfidResult(nodeResult, startRfidId, null);
}
catch (Exception ex)
{
return RfidPathResult.CreateFailure($"도킹 스테이션 경로 계산 중 오류: {ex.Message}", 0);
}
}
/// <summary>
/// 여러 RFID 목적지 중 가장 가까운 곳으로의 경로 찾기
/// </summary>
/// <param name="startRfidId">시작 RFID</param>
/// <param name="targetRfidIds">목적지 후보 RFID 목록</param>
/// <returns>RFID 기반 경로 계산 결과</returns>
public RfidPathResult FindNearestPath(string startRfidId, List<string> targetRfidIds)
{
try
{
if (!_rfidToNodeMap.TryGetValue(startRfidId, out string startNodeId))
{
return RfidPathResult.CreateFailure($"시작 RFID를 찾을 수 없습니다: {startRfidId}", 0);
}
// RFID 목록을 NodeId 목록으로 변환
var targetNodeIds = new List<string>();
foreach (var rfidId in targetRfidIds)
{
if (_rfidToNodeMap.TryGetValue(rfidId, out string nodeId))
{
targetNodeIds.Add(nodeId);
}
}
if (targetNodeIds.Count == 0)
{
return RfidPathResult.CreateFailure("유효한 목적지 RFID가 없습니다", 0);
}
var pathResult = _astarPathfinder.FindNearestPath(startNodeId, targetNodeIds);
if (!pathResult.Success)
{
return RfidPathResult.CreateFailure(pathResult.ErrorMessage, pathResult.CalculationTimeMs);
}
// AGV 명령어 생성을 위해 AGV pathfinder 사용
var endNodeId = pathResult.Path.Last();
var agvResult = _agvPathfinder.FindAGVPath(startNodeId, endNodeId);
return ConvertToRfidResult(agvResult, startRfidId, null);
}
catch (Exception ex)
{
return RfidPathResult.CreateFailure($"최근접 경로 계산 중 오류: {ex.Message}", 0);
}
}
/// <summary>
/// RFID 매핑 상태 확인 (MapNode 기반)
/// </summary>
/// <param name="rfidId">확인할 RFID</param>
/// <returns>MapNode 또는 null</returns>
public MapNode GetRfidMapping(string rfidId)
{
return _mapNodes.FirstOrDefault(n => n.RfidId == rfidId && n.IsActive && n.HasRfid());
}
/// <summary>
/// RFID로 NodeId 조회
/// </summary>
/// <param name="rfidId">RFID</param>
/// <returns>NodeId 또는 null</returns>
public string GetNodeIdByRfid(string rfidId)
{
return _rfidToNodeMap.TryGetValue(rfidId, out string nodeId) ? nodeId : null;
}
/// <summary>
/// NodeId로 RFID 조회
/// </summary>
/// <param name="nodeId">NodeId</param>
/// <returns>RFID 또는 null</returns>
public string GetRfidByNodeId(string nodeId)
{
return _nodeToRfidMap.TryGetValue(nodeId, out string rfidId) ? rfidId : null;
}
/// <summary>
/// 활성화된 RFID 목록 반환
/// </summary>
/// <returns>활성화된 RFID 목록</returns>
public List<string> GetActiveRfidList()
{
return _mapNodes.Where(n => n.IsActive && n.HasRfid()).Select(n => n.RfidId).ToList();
}
/// <summary>
/// NodeId 기반 결과를 RFID 기반 결과로 변환
/// </summary>
private RfidPathResult ConvertToRfidResult(AGVPathResult nodeResult, string startRfidId, string endRfidId)
{
if (!nodeResult.Success)
{
return RfidPathResult.CreateFailure(nodeResult.ErrorMessage, nodeResult.CalculationTimeMs);
}
// NodeId 경로를 RFID 경로로 변환
var rfidPath = new List<string>();
foreach (var nodeId in nodeResult.Path)
{
if (_nodeToRfidMap.TryGetValue(nodeId, out string rfidId))
{
rfidPath.Add(rfidId);
}
else
{
// 매핑이 없는 경우 NodeId를 그대로 사용 (경고 로그 필요)
rfidPath.Add($"[{nodeId}]");
}
}
return RfidPathResult.CreateSuccess(
rfidPath,
nodeResult.Commands,
nodeResult.TotalDistance,
nodeResult.CalculationTimeMs,
nodeResult.EstimatedTimeSeconds,
nodeResult.RotationCount
);
}
}
}

View File

@@ -0,0 +1,229 @@
using System;
using System.Collections.Generic;
using AGVNavigationCore.Models;
namespace AGVNavigationCore.PathFinding
{
/// <summary>
/// RFID 기반 AGV 경로 계산 결과
/// 실제 현장에서 AGV가 RFID를 기준으로 이동하는 방식에 맞춤
/// </summary>
public class RfidPathResult
{
/// <summary>
/// 경로 찾기 성공 여부
/// </summary>
public bool Success { get; set; }
/// <summary>
/// RFID 경로 목록 (시작 → 목적지 순서)
/// </summary>
public List<string> RfidPath { get; set; }
/// <summary>
/// AGV 명령어 목록 (이동 방향 시퀀스)
/// </summary>
public List<AgvDirection> Commands { get; set; }
/// <summary>
/// 총 거리
/// </summary>
public float TotalDistance { get; set; }
/// <summary>
/// 계산 소요 시간 (밀리초)
/// </summary>
public long CalculationTimeMs { get; set; }
/// <summary>
/// 예상 소요 시간 (초)
/// </summary>
public float EstimatedTimeSeconds { get; set; }
/// <summary>
/// 회전 횟수
/// </summary>
public int RotationCount { get; set; }
/// <summary>
/// 오류 메시지 (실패시)
/// </summary>
public string ErrorMessage { get; set; }
/// <summary>
/// 기본 생성자
/// </summary>
public RfidPathResult()
{
Success = false;
RfidPath = new List<string>();
Commands = new List<AgvDirection>();
TotalDistance = 0;
CalculationTimeMs = 0;
EstimatedTimeSeconds = 0;
RotationCount = 0;
ErrorMessage = string.Empty;
}
/// <summary>
/// 성공 결과 생성
/// </summary>
/// <param name="rfidPath">RFID 경로</param>
/// <param name="commands">AGV 명령어 목록</param>
/// <param name="totalDistance">총 거리</param>
/// <param name="calculationTimeMs">계산 시간</param>
/// <param name="estimatedTimeSeconds">예상 소요 시간</param>
/// <param name="rotationCount">회전 횟수</param>
/// <returns>성공 결과</returns>
public static RfidPathResult CreateSuccess(
List<string> rfidPath,
List<AgvDirection> commands,
float totalDistance,
long calculationTimeMs,
float estimatedTimeSeconds,
int rotationCount)
{
return new RfidPathResult
{
Success = true,
RfidPath = new List<string>(rfidPath),
Commands = new List<AgvDirection>(commands),
TotalDistance = totalDistance,
CalculationTimeMs = calculationTimeMs,
EstimatedTimeSeconds = estimatedTimeSeconds,
RotationCount = rotationCount
};
}
/// <summary>
/// 실패 결과 생성
/// </summary>
/// <param name="errorMessage">오류 메시지</param>
/// <param name="calculationTimeMs">계산 시간</param>
/// <returns>실패 결과</returns>
public static RfidPathResult CreateFailure(string errorMessage, long calculationTimeMs)
{
return new RfidPathResult
{
Success = false,
ErrorMessage = errorMessage,
CalculationTimeMs = calculationTimeMs
};
}
/// <summary>
/// 명령어 요약 생성
/// </summary>
/// <returns>명령어 요약 문자열</returns>
public string GetCommandSummary()
{
if (!Success) return "실패";
var summary = new List<string>();
var currentCommand = AgvDirection.Stop;
var count = 0;
foreach (var command in Commands)
{
if (command == currentCommand)
{
count++;
}
else
{
if (count > 0)
{
summary.Add($"{GetCommandText(currentCommand)}×{count}");
}
currentCommand = command;
count = 1;
}
}
if (count > 0)
{
summary.Add($"{GetCommandText(currentCommand)}×{count}");
}
return string.Join(" → ", summary);
}
/// <summary>
/// 명령어 텍스트 반환
/// </summary>
private string GetCommandText(AgvDirection command)
{
switch (command)
{
case AgvDirection.Forward: return "전진";
case AgvDirection.Backward: return "후진";
case AgvDirection.Left: return "좌회전";
case AgvDirection.Right: return "우회전";
case AgvDirection.Stop: return "정지";
default: return command.ToString();
}
}
/// <summary>
/// RFID 경로 요약 생성
/// </summary>
/// <returns>RFID 경로 요약 문자열</returns>
public string GetRfidPathSummary()
{
if (!Success || RfidPath.Count == 0) return "경로 없음";
if (RfidPath.Count <= 3)
{
return string.Join(" → ", RfidPath);
}
else
{
return $"{RfidPath[0]} → ... ({RfidPath.Count - 2}개 경유) → {RfidPath[RfidPath.Count - 1]}";
}
}
/// <summary>
/// 상세 경로 정보 반환
/// </summary>
/// <returns>상세 정보 문자열</returns>
public string GetDetailedInfo()
{
if (!Success)
{
return $"RFID 경로 계산 실패: {ErrorMessage} (계산시간: {CalculationTimeMs}ms)";
}
return $"RFID 경로: {RfidPath.Count}개 지점, 거리: {TotalDistance:F1}px, " +
$"회전: {RotationCount}회, 예상시간: {EstimatedTimeSeconds:F1}초, " +
$"계산시간: {CalculationTimeMs}ms";
}
/// <summary>
/// AGV 운영자용 실행 정보 반환
/// </summary>
/// <returns>실행 정보 문자열</returns>
public string GetExecutionInfo()
{
if (!Success) return $"실행 불가: {ErrorMessage}";
return $"[실행준비] {GetRfidPathSummary()}\n" +
$"[명령어] {GetCommandSummary()}\n" +
$"[예상시간] {EstimatedTimeSeconds:F1}초";
}
/// <summary>
/// 문자열 표현
/// </summary>
public override string ToString()
{
if (Success)
{
return $"Success: {RfidPath.Count} RFIDs, {TotalDistance:F1}px, {RotationCount} rotations, {EstimatedTimeSeconds:F1}s";
}
else
{
return $"Failed: {ErrorMessage}";
}
}
}
}

View File

@@ -0,0 +1,36 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("AGVNavigationCore")]
[assembly: AssemblyDescription("AGV Navigation and Pathfinding Core Library")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("ENIG")]
[assembly: AssemblyProduct("AGV Navigation System")]
[assembly: AssemblyCopyright("Copyright © ENIG 2024")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
// Setting ComVisible to false makes the types in this assembly not visible
// to COM components. If you need to access a type in this assembly from
// COM, set the ComVisible attribute to true on that type.
[assembly: ComVisible(false)]
// The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("c5f7a8b2-8d3e-4a1b-9c6e-7f4d5e2a9b1c")]
// Version information for an assembly consists of the following four values:
//
// Major Version
// Minor Version
// Build Number
// Revision
//
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]

View File

@@ -0,0 +1,225 @@
# AGVNavigationCore 프로젝트 기능 설명
## 📋 개요
AGVNavigationCore는 AGV(Automated Guided Vehicle) 시스템을 위한 전문적인 경로 계산 및 네비게이션 라이브러리입니다. 실제 AGV의 물리적 제약사항과 운영 요구사항을 고려한 지능형 경로 탐색 기능을 제공합니다.
## 🏗️ 핵심 구조
### **Models 패키지**
- **MapNode**: 맵의 논리적 노드 정보 (위치, 타입, 연결 정보, RFID 매핑)
- **RfidMapping**: RFID와 논리적 노드 ID 간의 매핑 정보
- **Enums**: 노드 타입, AGV 방향, 도킹 방향, 장비 타입 등 열거형 정의
### **PathFinding 패키지**
경로 계산의 핵심 엔진들이 포함된 패키지
## 🎯 주요 기능
### 1. **기본 경로 탐색 (A* 알고리즘)**
**AStarPathfinder 클래스**:
- 표준 A* 알고리즘 구현으로 최적 경로 탐색
- 유클리드 거리 기반 휴리스틱 사용
- 설정 가능한 파라미터:
- `HeuristicWeight`: 휴리스틱 가중치 (기본 1.0)
- `MaxSearchNodes`: 최대 탐색 노드 수 (기본 1000개)
**제공 기능**:
```csharp
// 단일 경로 탐색
PathResult FindPath(string startNodeId, string endNodeId)
// 다중 목표 중 최단 경로 탐색
PathResult FindNearestPath(string startNodeId, List<string> targetNodeIds)
// 노드 연결 상태 확인
bool AreNodesConnected(string nodeId1, string nodeId2)
```
### 2. **AGV 전용 지능형 경로 계산**
**AGVPathfinder 클래스**:
AGV의 실제 움직임 제약사항을 고려한 전문 경로 계산기
**AGV 제약사항 고려**:
- **방향성 제약**: 전진/후진만 가능, 좌우 이동 불가
- **회전 제약**: 특정 노드(회전 가능 지점)에서만 180도 회전 가능
- **도킹 방향**:
- 충전기: 전진 도킹만 가능
- 장비 (로더, 클리너 등): 후진 도킹만 가능
**전용 기능**:
```csharp
// AGV 경로 계산 (방향성 고려)
AGVPathResult FindAGVPath(string startNodeId, string endNodeId, AgvDirection? targetDirection)
// 가장 가까운 충전소 경로
AGVPathResult FindPathToChargingStation(string startNodeId)
// 특정 장비 타입 도킹 스테이션 경로
AGVPathResult FindPathToDockingStation(string startNodeId, StationType stationType)
```
### 3. **RFID 기반 경로 계산 (실제 운영용)**
**RfidBasedPathfinder 클래스**:
실제 AGV가 RFID를 읽어서 위치를 파악하는 현장 운영 방식에 최적화
**RFID 기반 제약사항**:
- **물리적 RFID**: 의미 없는 고유 식별자 (현장 유지보수 편의성)
- **논리적 매핑**: RFID ↔ NodeId 분리로 맵 변경 시 유연성 확보
- **실시간 변환**: RFID 입력을 내부적으로 NodeId로 변환하여 처리
**RFID 전용 기능**:
```csharp
// RFID 기반 AGV 경로 계산
RfidPathResult FindAGVPath(string startRfidId, string endRfidId, AgvDirection? targetDirection)
// RFID 기반 충전소 경로
RfidPathResult FindPathToChargingStation(string startRfidId)
// RFID 기반 도킹 스테이션 경로
RfidPathResult FindPathToDockingStation(string startRfidId, StationType stationType)
// RFID 매핑 관리
RfidMapping GetRfidMapping(string rfidId)
string GetNodeIdByRfid(string rfidId)
string GetRfidByNodeId(string nodeId)
```
### 4. **상세한 결과 분석**
**PathResult (기본 결과)**:
- 성공/실패 여부
- 노드 ID 시퀀스
- 총 거리 및 계산 시간
- 탐색한 노드 수
**AGVPathResult (AGV 전용 결과)**:
- **실행 가능한 명령어 시퀀스**: `[전진, 전진, 우회전, 후진, 정지]`
- **상세 메트릭**:
- 회전 횟수 계산
- 예상 소요 시간 (이동 + 회전 시간)
- 명령어 요약 (`전진×3 → 우회전×1 → 후진×2`)
**RfidPathResult (RFID 기반 결과)**:
- **RFID 경로 시퀀스**: `[RFID001, RFID045, RFID067, RFID123]`
- **AGV 명령어**: NodeId 기반 결과와 동일한 명령어 시퀀스
- **현장 친화적 정보**:
- RFID 경로 요약 (`RFID001 → ... (2개 경유) → RFID123`)
- 실행 정보 (`[실행준비] → [명령어] → [예상시간]`)
- 운영자용 상세 정보
### 5. **실시간 검증 및 최적화**
**경로 검증**:
```csharp
// 경로 유효성 실시간 검증
bool ValidatePath(List<string> path)
// 네비게이션 가능 노드 필터링 (라벨/이미지 노드 제외)
List<string> GetNavigationNodes()
```
**성능 최적화**:
- 메모리 효율적인 노드 관리
- 조기 종료 조건으로 불필요한 탐색 방지
- 캐시된 거리 계산
## 🔧 설정 가능한 파라미터
**AGV 동작 파라미터**:
- `CurrentDirection`: AGV 현재 방향
- `RotationCostWeight`: 회전 비용 가중치 (기본 50.0)
- `DockingApproachDistance`: 도킹 접근 거리 (기본 100픽셀)
**알고리즘 파라미터**:
- `HeuristicWeight`: A* 휴리스틱 강도
- `MaxSearchNodes`: 탐색 제한으로 무한루프 방지
**RFID 매핑 파라미터**:
- `RfidMappings`: RFID ↔ NodeId 매핑 테이블
- `IsActive`: 매핑 활성화 상태
- `Status`: RFID 상태 (정상, 손상, 교체예정)
## 🎯 실제 활용 시나리오
### **시나리오 1: 일반 이동 (NodeId 기반)**
```
현재위치(N001) → 목적지(N010)
결과: [N001, N003, N007, N010] + [전진, 전진, 전진]
```
### **시나리오 2: 일반 이동 (RFID 기반)**
```
현재위치(RFID123) → 목적지(RFID789)
결과: [RFID123, RFID456, RFID789] + [전진, 우회전, 전진]
AGV는 RFID를 읽으면서 실제 위치 확인 후 이동
```
### **시나리오 3: 충전 필요**
```
배터리 부족 → 가장 가까운 충전소 자동 탐색
결과: 충전소까지 최단경로 + 전진 도킹 명령어
```
### **시나리오 4: 화물 적재**
```
로더 스테이션 접근 → 후진 도킹 필수
결과: 로더까지 경로 + 후진 도킹 시퀀스
```
## 🌟 차별화 포인트
1. **실제 AGV 제약사항 반영**: 이론적 경로가 아닌 실행 가능한 경로 제공
2. **명령어 레벨 출력**: 경로뿐만 아니라 실제 AGV 제어 명령어 생성
3. **RFID 기반 현장 운영**: 물리적 RFID와 논리적 노드 분리로 현장 유지보수성 향상
4. **다양한 장비 지원**: 충전기, 로더, 클리너, 버퍼 등 각각의 도킹 요구사항 처리
5. **이중 API 제공**: NodeId 기반(개발용) + RFID 기반(운영용) 동시 지원
6. **확장성**: 새로운 AGV 타입이나 제약사항 쉽게 추가 가능
7. **성능 최적화**: 실시간 운영에 적합한 빠른 응답속도
## 🚀 사용 방법
### 기본 사용법
```csharp
// 1. 경로 탐색기 초기화
var pathfinder = new AGVPathfinder();
pathfinder.SetMapNodes(mapNodes);
// 2. AGV 경로 계산
var result = pathfinder.FindAGVPath("N001", "N010");
// 3. 결과 확인
if (result.Success)
{
Console.WriteLine($"경로: {string.Join(" ", result.Path)}");
Console.WriteLine($"명령어: {result.GetCommandSummary()}");
Console.WriteLine($"예상시간: {result.EstimatedTimeSeconds}초");
}
```
### 충전소 경로 탐색
```csharp
var chargingResult = pathfinder.FindPathToChargingStation("N001");
if (chargingResult.Success)
{
// 충전소까지 자동 이동
ExecuteAGVCommands(chargingResult.Commands);
}
```
## 📦 의존성
- .NET Framework 4.8
- Newtonsoft.Json 13.0.3
- System.Drawing
## 🔗 통합 프로젝트
이 라이브러리는 다음 프로젝트에서 사용됩니다:
- **AGVMapEditor**: 맵 편집 및 경로 시뮬레이션
- **AGV4**: 메인 AGV 제어 시스템
- **AGVSimulator**: AGV 동작 시뮬레이터
---
*AGVNavigationCore는 ENIG AGV 시스템의 핵심 네비게이션 엔진입니다.*

View File

@@ -0,0 +1,29 @@
@echo off
echo Building V2GDecoder VC++ Project...
REM Check if Visual Studio 2022 is installed (Professional or Community)
set MSBUILD_PRO="C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe"
set MSBUILD_COM="C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe"
set MSBUILD_BT="F:\(VHD) Program Files\Microsoft Visual Studio\2022\MSBuild\Current\Bin\MSBuild.exe"
if exist %MSBUILD_PRO% (
echo "Found Visual Studio 2022 Professional"
set MSBUILD=%MSBUILD_PRO%
) else if exist %MSBUILD_COM% (
echo "Found Visual Studio 2022 Community"
set MSBUILD=%MSBUILD_COM%
) else if exist %MSBUILD_BT% (
echo "Found Visual Studio 2022 BuildTools"
set MSBUILD=%MSBUILD_BT%
) else (
echo "Visual Studio 2022 (Professional or Community) not found!"
echo "Please install Visual Studio 2022 or update the MSBuild path."
pause
exit /b 1
)
REM Build Debug x64 configuration
echo Building Debug x64 configuration...
%MSBUILD% AGVNavigationCore.csproj
pause

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>