fix: RFID duplicate validation and correct magnet direction calculation

- Add real-time RFID duplicate validation in map editor with automatic rollback
- Remove RFID auto-assignment to maintain data consistency between editor and simulator
- Fix magnet direction calculation to use actual forward direction angles instead of arbitrary assignment
- Add node names to simulator combo boxes for better identification
- Improve UI layout by drawing connection lines before text for better visibility

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ChiKyun Kim
2025-09-15 16:31:40 +09:00
parent 1add9ed59a
commit 7f48253770
41 changed files with 4827 additions and 3649 deletions

View File

@@ -49,6 +49,7 @@
</ProjectReference> </ProjectReference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Compile Include="Models\EditorSettings.cs" />
<Compile Include="Models\MapData.cs" /> <Compile Include="Models\MapData.cs" />
<Compile Include="Models\MapImage.cs" /> <Compile Include="Models\MapImage.cs" />
<Compile Include="Models\MapLabel.cs" /> <Compile Include="Models\MapLabel.cs" />
@@ -63,11 +64,20 @@
</Compile> </Compile>
<Compile Include="Program.cs" /> <Compile Include="Program.cs" />
<Compile Include="Properties\AssemblyInfo.cs" /> <Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="Properties\Resources.Designer.cs">
<AutoGen>True</AutoGen>
<DesignTime>True</DesignTime>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="Forms\MainForm.resx"> <EmbeddedResource Include="Forms\MainForm.resx">
<DependentUpon>MainForm.cs</DependentUpon> <DependentUpon>MainForm.cs</DependentUpon>
</EmbeddedResource> </EmbeddedResource>
<EmbeddedResource Include="Properties\Resources.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Include="build.bat" /> <None Include="build.bat" />

View File

@@ -1,34 +0,0 @@
namespace AGVMapEditor.Controls
{
partial class MapCanvasInteractive
{
/// <summary>
/// 필수 디자이너 변수입니다.
/// </summary>
private System.ComponentModel.IContainer components = null;
#region
/// <summary>
/// 디자이너 지원에 필요한 메서드입니다.
/// 이 메서드의 내용을 코드 편집기로 수정하지 마세요.
/// </summary>
private void InitializeComponent()
{
this.SuspendLayout();
//
// MapCanvasInteractive
//
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 12F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.BackColor = System.Drawing.Color.White;
this.Name = "MapCanvasInteractive";
this.Size = new System.Drawing.Size(800, 600);
this.ResumeLayout(false);
}
#endregion
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -28,113 +28,38 @@ namespace AGVMapEditor.Forms
/// </summary> /// </summary>
private void InitializeComponent() private void InitializeComponent()
{ {
this.menuStrip1 = new System.Windows.Forms.MenuStrip(); System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(MainForm));
this.fileToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.newToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.openToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.toolStripSeparator1 = new System.Windows.Forms.ToolStripSeparator();
this.saveToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.saveAsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.closeToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.toolStripSeparator2 = new System.Windows.Forms.ToolStripSeparator();
this.exitToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.statusStrip1 = new System.Windows.Forms.StatusStrip(); this.statusStrip1 = new System.Windows.Forms.StatusStrip();
this.toolStripStatusLabel1 = new System.Windows.Forms.ToolStripStatusLabel(); this.toolStripStatusLabel1 = new System.Windows.Forms.ToolStripStatusLabel();
this.splitContainer1 = new System.Windows.Forms.SplitContainer(); this.splitContainer1 = new System.Windows.Forms.SplitContainer();
this.tabControl1 = new System.Windows.Forms.TabControl(); this.tabControl1 = new System.Windows.Forms.TabControl();
this.tabPageNodes = new System.Windows.Forms.TabPage(); this.tabPageNodes = new System.Windows.Forms.TabPage();
this.listBoxNodes = new System.Windows.Forms.ListBox(); this.listBoxNodes = new System.Windows.Forms.ListBox();
this._propertyGrid = new System.Windows.Forms.PropertyGrid();
this.label1 = new System.Windows.Forms.Label(); this.label1 = new System.Windows.Forms.Label();
this.menuStrip1.SuspendLayout(); this.tabPage1 = new System.Windows.Forms.TabPage();
this.lstNodeConnection = new System.Windows.Forms.ListBox();
this.toolStrip1 = new System.Windows.Forms.ToolStrip();
this.btNodeRemove = new System.Windows.Forms.ToolStripButton();
this._propertyGrid = new System.Windows.Forms.PropertyGrid();
this.toolStrip2 = new System.Windows.Forms.ToolStrip();
this.btnNew = new System.Windows.Forms.ToolStripButton();
this.btnOpen = new System.Windows.Forms.ToolStripButton();
this.btnReopen = new System.Windows.Forms.ToolStripButton();
this.btnClose = new System.Windows.Forms.ToolStripButton();
this.toolStripSeparator3 = new System.Windows.Forms.ToolStripSeparator();
this.btnSave = new System.Windows.Forms.ToolStripButton();
this.btnSaveAs = new System.Windows.Forms.ToolStripButton();
this.statusStrip1.SuspendLayout(); this.statusStrip1.SuspendLayout();
((System.ComponentModel.ISupportInitialize)(this.splitContainer1)).BeginInit(); ((System.ComponentModel.ISupportInitialize)(this.splitContainer1)).BeginInit();
this.splitContainer1.Panel1.SuspendLayout(); this.splitContainer1.Panel1.SuspendLayout();
this.splitContainer1.SuspendLayout(); this.splitContainer1.SuspendLayout();
this.tabControl1.SuspendLayout(); this.tabControl1.SuspendLayout();
this.tabPageNodes.SuspendLayout(); this.tabPageNodes.SuspendLayout();
this.tabPage1.SuspendLayout();
this.toolStrip1.SuspendLayout();
this.toolStrip2.SuspendLayout();
this.SuspendLayout(); this.SuspendLayout();
// //
// menuStrip1
//
this.menuStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] {
this.fileToolStripMenuItem});
this.menuStrip1.Location = new System.Drawing.Point(0, 0);
this.menuStrip1.Name = "menuStrip1";
this.menuStrip1.Size = new System.Drawing.Size(1200, 24);
this.menuStrip1.TabIndex = 0;
this.menuStrip1.Text = "menuStrip1";
//
// fileToolStripMenuItem
//
this.fileToolStripMenuItem.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] {
this.newToolStripMenuItem,
this.openToolStripMenuItem,
this.closeToolStripMenuItem,
this.toolStripSeparator1,
this.saveToolStripMenuItem,
this.saveAsToolStripMenuItem,
this.toolStripSeparator2,
this.exitToolStripMenuItem});
this.fileToolStripMenuItem.Name = "fileToolStripMenuItem";
this.fileToolStripMenuItem.Size = new System.Drawing.Size(57, 20);
this.fileToolStripMenuItem.Text = "파일(&F)";
//
// newToolStripMenuItem
//
this.newToolStripMenuItem.Name = "newToolStripMenuItem";
this.newToolStripMenuItem.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.N)));
this.newToolStripMenuItem.Size = new System.Drawing.Size(198, 22);
this.newToolStripMenuItem.Text = "새로 만들기(&N)";
this.newToolStripMenuItem.Click += new System.EventHandler(this.newToolStripMenuItem_Click);
//
// openToolStripMenuItem
//
this.openToolStripMenuItem.Name = "openToolStripMenuItem";
this.openToolStripMenuItem.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.O)));
this.openToolStripMenuItem.Size = new System.Drawing.Size(198, 22);
this.openToolStripMenuItem.Text = "열기(&O)";
this.openToolStripMenuItem.Click += new System.EventHandler(this.openToolStripMenuItem_Click);
//
// toolStripSeparator1
//
this.toolStripSeparator1.Name = "toolStripSeparator1";
this.toolStripSeparator1.Size = new System.Drawing.Size(195, 6);
//
// saveToolStripMenuItem
//
this.saveToolStripMenuItem.Name = "saveToolStripMenuItem";
this.saveToolStripMenuItem.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.S)));
this.saveToolStripMenuItem.Size = new System.Drawing.Size(198, 22);
this.saveToolStripMenuItem.Text = "저장(&S)";
this.saveToolStripMenuItem.Click += new System.EventHandler(this.saveToolStripMenuItem_Click);
//
// saveAsToolStripMenuItem
//
this.saveAsToolStripMenuItem.Name = "saveAsToolStripMenuItem";
this.saveAsToolStripMenuItem.Size = new System.Drawing.Size(198, 22);
this.saveAsToolStripMenuItem.Text = "다른 이름으로 저장(&A)";
this.saveAsToolStripMenuItem.Click += new System.EventHandler(this.saveAsToolStripMenuItem_Click);
//
// closeToolStripMenuItem
//
this.closeToolStripMenuItem.Name = "closeToolStripMenuItem";
this.closeToolStripMenuItem.Size = new System.Drawing.Size(198, 22);
this.closeToolStripMenuItem.Text = "닫기(&C)";
this.closeToolStripMenuItem.Click += new System.EventHandler(this.closeToolStripMenuItem_Click);
//
// toolStripSeparator2
//
this.toolStripSeparator2.Name = "toolStripSeparator2";
this.toolStripSeparator2.Size = new System.Drawing.Size(195, 6);
//
// exitToolStripMenuItem
//
this.exitToolStripMenuItem.Name = "exitToolStripMenuItem";
this.exitToolStripMenuItem.Size = new System.Drawing.Size(198, 22);
this.exitToolStripMenuItem.Text = "종료(&X)";
this.exitToolStripMenuItem.Click += new System.EventHandler(this.exitToolStripMenuItem_Click);
//
// statusStrip1 // statusStrip1
// //
this.statusStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { this.statusStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] {
@@ -154,36 +79,37 @@ namespace AGVMapEditor.Forms
// splitContainer1 // splitContainer1
// //
this.splitContainer1.Dock = System.Windows.Forms.DockStyle.Fill; this.splitContainer1.Dock = System.Windows.Forms.DockStyle.Fill;
this.splitContainer1.Location = new System.Drawing.Point(0, 24); this.splitContainer1.Location = new System.Drawing.Point(0, 25);
this.splitContainer1.Name = "splitContainer1"; this.splitContainer1.Name = "splitContainer1";
// //
// splitContainer1.Panel1 // splitContainer1.Panel1
// //
this.splitContainer1.Panel1.Controls.Add(this.tabControl1); this.splitContainer1.Panel1.Controls.Add(this.tabControl1);
this.splitContainer1.Panel1.Controls.Add(this._propertyGrid);
this.splitContainer1.Panel1MinSize = 300; this.splitContainer1.Panel1MinSize = 300;
this.splitContainer1.Size = new System.Drawing.Size(1200, 727); this.splitContainer1.Size = new System.Drawing.Size(1200, 726);
this.splitContainer1.SplitterDistance = 300; this.splitContainer1.SplitterDistance = 300;
this.splitContainer1.TabIndex = 2; this.splitContainer1.TabIndex = 2;
// //
// tabControl1 // tabControl1
// //
this.tabControl1.Controls.Add(this.tabPageNodes); this.tabControl1.Controls.Add(this.tabPageNodes);
this.tabControl1.Controls.Add(this.tabPage1);
this.tabControl1.Dock = System.Windows.Forms.DockStyle.Fill; this.tabControl1.Dock = System.Windows.Forms.DockStyle.Fill;
this.tabControl1.Location = new System.Drawing.Point(0, 0); this.tabControl1.Location = new System.Drawing.Point(0, 0);
this.tabControl1.Name = "tabControl1"; this.tabControl1.Name = "tabControl1";
this.tabControl1.SelectedIndex = 0; this.tabControl1.SelectedIndex = 0;
this.tabControl1.Size = new System.Drawing.Size(300, 727); this.tabControl1.Size = new System.Drawing.Size(300, 335);
this.tabControl1.TabIndex = 0; this.tabControl1.TabIndex = 0;
// //
// tabPageNodes // tabPageNodes
// //
this.tabPageNodes.Controls.Add(this.listBoxNodes); this.tabPageNodes.Controls.Add(this.listBoxNodes);
this.tabPageNodes.Controls.Add(this._propertyGrid);
this.tabPageNodes.Controls.Add(this.label1); this.tabPageNodes.Controls.Add(this.label1);
this.tabPageNodes.Location = new System.Drawing.Point(4, 22); this.tabPageNodes.Location = new System.Drawing.Point(4, 22);
this.tabPageNodes.Name = "tabPageNodes"; this.tabPageNodes.Name = "tabPageNodes";
this.tabPageNodes.Padding = new System.Windows.Forms.Padding(3); this.tabPageNodes.Padding = new System.Windows.Forms.Padding(3);
this.tabPageNodes.Size = new System.Drawing.Size(292, 701); this.tabPageNodes.Size = new System.Drawing.Size(292, 309);
this.tabPageNodes.TabIndex = 0; this.tabPageNodes.TabIndex = 0;
this.tabPageNodes.Text = "노드 관리"; this.tabPageNodes.Text = "노드 관리";
this.tabPageNodes.UseVisualStyleBackColor = true; this.tabPageNodes.UseVisualStyleBackColor = true;
@@ -195,17 +121,9 @@ namespace AGVMapEditor.Forms
this.listBoxNodes.ItemHeight = 12; this.listBoxNodes.ItemHeight = 12;
this.listBoxNodes.Location = new System.Drawing.Point(3, 3); this.listBoxNodes.Location = new System.Drawing.Point(3, 3);
this.listBoxNodes.Name = "listBoxNodes"; this.listBoxNodes.Name = "listBoxNodes";
this.listBoxNodes.Size = new System.Drawing.Size(286, 245); this.listBoxNodes.Size = new System.Drawing.Size(286, 303);
this.listBoxNodes.TabIndex = 1; this.listBoxNodes.TabIndex = 1;
// //
// _propertyGrid
//
this._propertyGrid.Dock = System.Windows.Forms.DockStyle.Bottom;
this._propertyGrid.Location = new System.Drawing.Point(3, 248);
this._propertyGrid.Name = "_propertyGrid";
this._propertyGrid.Size = new System.Drawing.Size(286, 450);
this._propertyGrid.TabIndex = 6;
//
// label1 // label1
// //
this.label1.AutoSize = true; this.label1.AutoSize = true;
@@ -215,6 +133,132 @@ namespace AGVMapEditor.Forms
this.label1.TabIndex = 0; this.label1.TabIndex = 0;
this.label1.Text = "노드 목록"; this.label1.Text = "노드 목록";
// //
// tabPage1
//
this.tabPage1.Controls.Add(this.lstNodeConnection);
this.tabPage1.Controls.Add(this.toolStrip1);
this.tabPage1.Location = new System.Drawing.Point(4, 22);
this.tabPage1.Name = "tabPage1";
this.tabPage1.Padding = new System.Windows.Forms.Padding(3);
this.tabPage1.Size = new System.Drawing.Size(292, 310);
this.tabPage1.TabIndex = 1;
this.tabPage1.Text = "연결 관리";
this.tabPage1.UseVisualStyleBackColor = true;
//
// lstNodeConnection
//
this.lstNodeConnection.Dock = System.Windows.Forms.DockStyle.Fill;
this.lstNodeConnection.FormattingEnabled = true;
this.lstNodeConnection.ItemHeight = 12;
this.lstNodeConnection.Location = new System.Drawing.Point(3, 3);
this.lstNodeConnection.Name = "lstNodeConnection";
this.lstNodeConnection.Size = new System.Drawing.Size(286, 279);
this.lstNodeConnection.TabIndex = 2;
//
// toolStrip1
//
this.toolStrip1.Dock = System.Windows.Forms.DockStyle.Bottom;
this.toolStrip1.GripStyle = System.Windows.Forms.ToolStripGripStyle.Hidden;
this.toolStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] {
this.btNodeRemove});
this.toolStrip1.Location = new System.Drawing.Point(3, 282);
this.toolStrip1.Name = "toolStrip1";
this.toolStrip1.Size = new System.Drawing.Size(286, 25);
this.toolStrip1.TabIndex = 3;
this.toolStrip1.Text = "toolStrip1";
//
// btNodeRemove
//
this.btNodeRemove.Image = ((System.Drawing.Image)(resources.GetObject("btNodeRemove.Image")));
this.btNodeRemove.ImageTransparentColor = System.Drawing.Color.Magenta;
this.btNodeRemove.Name = "btNodeRemove";
this.btNodeRemove.Size = new System.Drawing.Size(70, 22);
this.btNodeRemove.Text = "Remove";
this.btNodeRemove.Click += new System.EventHandler(this.btNodeRemove_Click);
//
// _propertyGrid
//
this._propertyGrid.Dock = System.Windows.Forms.DockStyle.Bottom;
this._propertyGrid.Location = new System.Drawing.Point(0, 335);
this._propertyGrid.Name = "_propertyGrid";
this._propertyGrid.Size = new System.Drawing.Size(300, 391);
this._propertyGrid.TabIndex = 6;
//
// toolStrip2
//
this.toolStrip2.Items.AddRange(new System.Windows.Forms.ToolStripItem[] {
this.btnNew,
this.btnOpen,
this.btnReopen,
this.btnClose,
this.toolStripSeparator3,
this.btnSave,
this.btnSaveAs});
this.toolStrip2.Location = new System.Drawing.Point(0, 0);
this.toolStrip2.Name = "toolStrip2";
this.toolStrip2.Size = new System.Drawing.Size(1200, 25);
this.toolStrip2.TabIndex = 0;
this.toolStrip2.Text = "toolStrip2";
//
// btnNew
//
this.btnNew.Image = ((System.Drawing.Image)(resources.GetObject("btnNew.Image")));
this.btnNew.Name = "btnNew";
this.btnNew.Size = new System.Drawing.Size(104, 22);
this.btnNew.Text = "새로만들기(&N)";
this.btnNew.ToolTipText = "새로 만들기 (Ctrl+N)";
this.btnNew.Click += new System.EventHandler(this.btnNew_Click);
//
// btnOpen
//
this.btnOpen.Image = ((System.Drawing.Image)(resources.GetObject("btnOpen.Image")));
this.btnOpen.Name = "btnOpen";
this.btnOpen.Size = new System.Drawing.Size(68, 22);
this.btnOpen.Text = "열기(&O)";
this.btnOpen.ToolTipText = "열기 (Ctrl+O)";
this.btnOpen.Click += new System.EventHandler(this.btnOpen_Click);
//
// btnReopen
//
this.btnReopen.Image = ((System.Drawing.Image)(resources.GetObject("btnReopen.Image")));
this.btnReopen.Name = "btnReopen";
this.btnReopen.Size = new System.Drawing.Size(90, 22);
this.btnReopen.Text = "다시열기(&R)";
this.btnReopen.ToolTipText = "현재 파일 다시 열기";
this.btnReopen.Click += new System.EventHandler(this.btnReopen_Click);
//
// btnClose
//
this.btnClose.Image = ((System.Drawing.Image)(resources.GetObject("btnClose.Image")));
this.btnClose.Name = "btnClose";
this.btnClose.Size = new System.Drawing.Size(75, 22);
this.btnClose.Text = "파일닫기";
this.btnClose.ToolTipText = "닫기";
this.btnClose.Click += new System.EventHandler(this.btnClose_Click);
//
// toolStripSeparator3
//
this.toolStripSeparator3.Name = "toolStripSeparator3";
this.toolStripSeparator3.Size = new System.Drawing.Size(6, 25);
//
// btnSave
//
this.btnSave.Image = ((System.Drawing.Image)(resources.GetObject("btnSave.Image")));
this.btnSave.Name = "btnSave";
this.btnSave.Size = new System.Drawing.Size(66, 22);
this.btnSave.Text = "저장(&S)";
this.btnSave.ToolTipText = "저장 (Ctrl+S)";
this.btnSave.Click += new System.EventHandler(this.btnSave_Click);
//
// btnSaveAs
//
this.btnSaveAs.Image = ((System.Drawing.Image)(resources.GetObject("btnSaveAs.Image")));
this.btnSaveAs.Name = "btnSaveAs";
this.btnSaveAs.Size = new System.Drawing.Size(123, 22);
this.btnSaveAs.Text = "다른이름으로저장";
this.btnSaveAs.ToolTipText = "다른 이름으로 저장";
this.btnSaveAs.Click += new System.EventHandler(this.btnSaveAs_Click);
//
// MainForm // MainForm
// //
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 12F); this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 12F);
@@ -222,15 +266,12 @@ namespace AGVMapEditor.Forms
this.ClientSize = new System.Drawing.Size(1200, 773); this.ClientSize = new System.Drawing.Size(1200, 773);
this.Controls.Add(this.splitContainer1); this.Controls.Add(this.splitContainer1);
this.Controls.Add(this.statusStrip1); this.Controls.Add(this.statusStrip1);
this.Controls.Add(this.menuStrip1); this.Controls.Add(this.toolStrip2);
this.MainMenuStrip = this.menuStrip1;
this.Name = "MainForm"; this.Name = "MainForm";
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen; this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen;
this.Text = "AGV Map Editor"; this.Text = "AGV Map Editor";
this.FormClosing += new System.Windows.Forms.FormClosingEventHandler(this.MainForm_FormClosing); this.FormClosing += new System.Windows.Forms.FormClosingEventHandler(this.MainForm_FormClosing);
this.Load += new System.EventHandler(this.MainForm_Load); this.Load += new System.EventHandler(this.MainForm_Load);
this.menuStrip1.ResumeLayout(false);
this.menuStrip1.PerformLayout();
this.statusStrip1.ResumeLayout(false); this.statusStrip1.ResumeLayout(false);
this.statusStrip1.PerformLayout(); this.statusStrip1.PerformLayout();
this.splitContainer1.Panel1.ResumeLayout(false); this.splitContainer1.Panel1.ResumeLayout(false);
@@ -239,6 +280,12 @@ namespace AGVMapEditor.Forms
this.tabControl1.ResumeLayout(false); this.tabControl1.ResumeLayout(false);
this.tabPageNodes.ResumeLayout(false); this.tabPageNodes.ResumeLayout(false);
this.tabPageNodes.PerformLayout(); this.tabPageNodes.PerformLayout();
this.tabPage1.ResumeLayout(false);
this.tabPage1.PerformLayout();
this.toolStrip1.ResumeLayout(false);
this.toolStrip1.PerformLayout();
this.toolStrip2.ResumeLayout(false);
this.toolStrip2.PerformLayout();
this.ResumeLayout(false); this.ResumeLayout(false);
this.PerformLayout(); this.PerformLayout();
@@ -246,16 +293,6 @@ namespace AGVMapEditor.Forms
#endregion #endregion
private System.Windows.Forms.MenuStrip menuStrip1;
private System.Windows.Forms.ToolStripMenuItem fileToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem newToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem openToolStripMenuItem;
private System.Windows.Forms.ToolStripSeparator toolStripSeparator1;
private System.Windows.Forms.ToolStripMenuItem saveToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem saveAsToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem closeToolStripMenuItem;
private System.Windows.Forms.ToolStripSeparator toolStripSeparator2;
private System.Windows.Forms.ToolStripMenuItem exitToolStripMenuItem;
private System.Windows.Forms.StatusStrip statusStrip1; private System.Windows.Forms.StatusStrip statusStrip1;
private System.Windows.Forms.ToolStripStatusLabel toolStripStatusLabel1; private System.Windows.Forms.ToolStripStatusLabel toolStripStatusLabel1;
private System.Windows.Forms.SplitContainer splitContainer1; private System.Windows.Forms.SplitContainer splitContainer1;
@@ -264,5 +301,17 @@ namespace AGVMapEditor.Forms
private System.Windows.Forms.ListBox listBoxNodes; private System.Windows.Forms.ListBox listBoxNodes;
private System.Windows.Forms.Label label1; private System.Windows.Forms.Label label1;
private System.Windows.Forms.PropertyGrid _propertyGrid; private System.Windows.Forms.PropertyGrid _propertyGrid;
private System.Windows.Forms.TabPage tabPage1;
private System.Windows.Forms.ListBox lstNodeConnection;
private System.Windows.Forms.ToolStrip toolStrip1;
private System.Windows.Forms.ToolStripButton btNodeRemove;
private System.Windows.Forms.ToolStrip toolStrip2;
private System.Windows.Forms.ToolStripButton btnNew;
private System.Windows.Forms.ToolStripButton btnOpen;
private System.Windows.Forms.ToolStripButton btnReopen;
private System.Windows.Forms.ToolStripButton btnClose;
private System.Windows.Forms.ToolStripSeparator toolStripSeparator3;
private System.Windows.Forms.ToolStripButton btnSave;
private System.Windows.Forms.ToolStripButton btnSaveAs;
} }
} }

View File

@@ -27,6 +27,35 @@ namespace AGVMapEditor.Forms
// 파일 경로 // 파일 경로
private string _currentMapFile = string.Empty; private string _currentMapFile = string.Empty;
private bool _hasChanges = false; private bool _hasChanges = false;
private bool _hasCommandLineArgs = false;
// 노드 연결 정보를 표현하는 클래스
public class NodeConnectionInfo
{
public string FromNodeId { get; set; }
public string FromNodeName { get; set; }
public string FromRfidId { get; set; }
public string ToNodeId { get; set; }
public string ToNodeName { get; set; }
public string ToRfidId { get; set; }
public string ConnectionType { get; set; }
public override string ToString()
{
// RFID가 있으면 RFID(노드이름), 없으면 NodeID(노드이름) 형태로 표시
string fromDisplay = !string.IsNullOrEmpty(FromRfidId)
? $"{FromRfidId}({FromNodeName})"
: $"---({FromNodeId})";
string toDisplay = !string.IsNullOrEmpty(ToRfidId)
? $"{ToRfidId}({ToNodeName})"
: $"---({ToNodeId})";
// 양방향 연결은 ↔ 기호 사용
string arrow = ConnectionType == "양방향" ? "↔" : "→";
return $"{fromDisplay} {arrow} {toDisplay}";
}
}
#endregion #endregion
@@ -47,6 +76,7 @@ namespace AGVMapEditor.Forms
// 명령줄 인수로 파일이 전달되었으면 자동으로 열기 // 명령줄 인수로 파일이 전달되었으면 자동으로 열기
if (args != null && args.Length > 0) if (args != null && args.Length > 0)
{ {
_hasCommandLineArgs = true;
string filePath = args[0]; string filePath = args[0];
if (System.IO.File.Exists(filePath)) if (System.IO.File.Exists(filePath))
{ {
@@ -58,10 +88,12 @@ namespace AGVMapEditor.Forms
MessageBoxButtons.OK, MessageBoxIcon.Warning); MessageBoxButtons.OK, MessageBoxIcon.Warning);
} }
} }
// 명령줄 인수가 없는 경우는 Form_Load에서 마지막 맵 파일 자동 로드 확인
} }
#endregion #endregion
#region Initialization #region Initialization
private void InitializeData() private void InitializeData()
@@ -82,6 +114,7 @@ namespace AGVMapEditor.Forms
_mapCanvas.NodeSelected += OnNodeSelected; _mapCanvas.NodeSelected += OnNodeSelected;
_mapCanvas.NodeMoved += OnNodeMoved; _mapCanvas.NodeMoved += OnNodeMoved;
_mapCanvas.NodeDeleted += OnNodeDeleted; _mapCanvas.NodeDeleted += OnNodeDeleted;
_mapCanvas.ConnectionDeleted += OnConnectionDeleted;
_mapCanvas.MapChanged += OnMapChanged; _mapCanvas.MapChanged += OnMapChanged;
// 스플리터 패널에 맵 캔버스 추가 // 스플리터 패널에 맵 캔버스 추가
@@ -148,31 +181,38 @@ namespace AGVMapEditor.Forms
btnDelete.Location = new Point(495, 3); btnDelete.Location = new Point(495, 3);
btnDelete.Click += (s, e) => _mapCanvas.CurrentEditMode = UnifiedAGVCanvas.EditMode.Delete; btnDelete.Click += (s, e) => _mapCanvas.CurrentEditMode = UnifiedAGVCanvas.EditMode.Delete;
// 연결 삭제 버튼
var btnDeleteConnection = new Button();
btnDeleteConnection.Text = "연결삭제 (X)";
btnDeleteConnection.Size = new Size(80, 28);
btnDeleteConnection.Location = new Point(570, 3);
btnDeleteConnection.Click += (s, e) => _mapCanvas.CurrentEditMode = UnifiedAGVCanvas.EditMode.DeleteConnection;
// 구분선 // 구분선
var separator1 = new Label(); var separator1 = new Label();
separator1.Text = "|"; separator1.Text = "|";
separator1.Size = new Size(10, 28); separator1.Size = new Size(10, 28);
separator1.Location = new Point(570, 3); separator1.Location = new Point(655, 3);
separator1.TextAlign = ContentAlignment.MiddleCenter; separator1.TextAlign = ContentAlignment.MiddleCenter;
// 그리드 토글 버튼 // 그리드 토글 버튼
var btnToggleGrid = new Button(); var btnToggleGrid = new Button();
btnToggleGrid.Text = "그리드"; btnToggleGrid.Text = "그리드";
btnToggleGrid.Size = new Size(60, 28); btnToggleGrid.Size = new Size(60, 28);
btnToggleGrid.Location = new Point(585, 3); btnToggleGrid.Location = new Point(670, 3);
btnToggleGrid.Click += (s, e) => _mapCanvas.ShowGrid = !_mapCanvas.ShowGrid; btnToggleGrid.Click += (s, e) => _mapCanvas.ShowGrid = !_mapCanvas.ShowGrid;
// 맵 맞춤 버튼 // 맵 맞춤 버튼
var btnFitMap = new Button(); var btnFitMap = new Button();
btnFitMap.Text = "맵 맞춤"; btnFitMap.Text = "맵 맞춤";
btnFitMap.Size = new Size(70, 28); btnFitMap.Size = new Size(70, 28);
btnFitMap.Location = new Point(650, 3); btnFitMap.Location = new Point(735, 3);
btnFitMap.Click += (s, e) => _mapCanvas.FitToNodes(); btnFitMap.Click += (s, e) => _mapCanvas.FitToNodes();
// 툴바에 버튼들 추가 // 툴바에 버튼들 추가
toolbarPanel.Controls.AddRange(new Control[] toolbarPanel.Controls.AddRange(new Control[]
{ {
btnSelect, btnMove, btnAddNode, btnAddLabel, btnAddImage, btnConnect, btnDelete, separator1, btnToggleGrid, btnFitMap btnSelect, btnMove, btnAddNode, btnAddLabel, btnAddImage, btnConnect, btnDelete, btnDeleteConnection, separator1, btnToggleGrid, btnFitMap
}); });
// 스플리터 패널에 툴바 추가 (맨 위에) // 스플리터 패널에 툴바 추가 (맨 위에)
@@ -186,9 +226,18 @@ namespace AGVMapEditor.Forms
private void MainForm_Load(object sender, EventArgs e) private void MainForm_Load(object sender, EventArgs e)
{ {
RefreshNodeList(); RefreshNodeList();
// 속성 변경 시 이벤트 연결 // 속성 변경 시 이벤트 연결
_propertyGrid.PropertyValueChanged += PropertyGrid_PropertyValueChanged; _propertyGrid.PropertyValueChanged += PropertyGrid_PropertyValueChanged;
// 명령줄 인수가 없는 경우에만 마지막 맵 파일 자동 로드 확인
if (!_hasCommandLineArgs)
{
this.Show();
Application.DoEvents();
CheckAndLoadLastMapFile();
}
} }
private void OnNodeAdded(object sender, MapNode node) private void OnNodeAdded(object sender, MapNode node)
@@ -228,6 +277,14 @@ namespace AGVMapEditor.Forms
UpdateNodeProperties(); // 연결 정보 업데이트 UpdateNodeProperties(); // 연결 정보 업데이트
} }
private void OnConnectionDeleted(object sender, (MapNode From, MapNode To) connection)
{
_hasChanges = true;
UpdateTitle();
RefreshNodeConnectionList();
UpdateNodeProperties(); // 연결 정보 업데이트
}
private void OnMapChanged(object sender, EventArgs e) private void OnMapChanged(object sender, EventArgs e)
{ {
_hasChanges = true; _hasChanges = true;
@@ -242,9 +299,9 @@ namespace AGVMapEditor.Forms
#endregion #endregion
#region Menu Event Handlers #region ToolStrip Button Event Handlers
private void newToolStripMenuItem_Click(object sender, EventArgs e) private void btnNew_Click(object sender, EventArgs e)
{ {
if (CheckSaveChanges()) if (CheckSaveChanges())
{ {
@@ -252,7 +309,7 @@ namespace AGVMapEditor.Forms
} }
} }
private void openToolStripMenuItem_Click(object sender, EventArgs e) private void btnOpen_Click(object sender, EventArgs e)
{ {
if (CheckSaveChanges()) if (CheckSaveChanges())
{ {
@@ -260,28 +317,70 @@ namespace AGVMapEditor.Forms
} }
} }
private void saveToolStripMenuItem_Click(object sender, EventArgs e) private void btnReopen_Click(object sender, EventArgs e)
{ {
SaveMap(); if (string.IsNullOrEmpty(_currentMapFile))
{
MessageBox.Show("다시 열 파일이 없습니다.", "알림", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
} }
private void saveAsToolStripMenuItem_Click(object sender, EventArgs e) if (!File.Exists(_currentMapFile))
{ {
SaveAsMap(); MessageBox.Show($"파일을 찾을 수 없습니다: {_currentMapFile}", "오류", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
} }
private void closeToolStripMenuItem_Click(object sender, EventArgs e) if (CheckSaveChanges())
{
LoadMapFromFile(_currentMapFile);
UpdateStatusBar($"파일을 다시 열었습니다: {Path.GetFileName(_currentMapFile)}");
}
}
private void btnClose_Click(object sender, EventArgs e)
{ {
CloseMap(); CloseMap();
} }
private void exitToolStripMenuItem_Click(object sender, EventArgs e) private void btnSave_Click(object sender, EventArgs e)
{
SaveMap();
}
private void btnSaveAs_Click(object sender, EventArgs e)
{
SaveAsMap();
}
private void btnExit_Click(object sender, EventArgs e)
{ {
this.Close(); this.Close();
} }
#endregion #endregion
#region Keyboard Shortcuts
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
switch (keyData)
{
case Keys.Control | Keys.N:
btnNew_Click(null, null);
return true;
case Keys.Control | Keys.O:
btnOpen_Click(null, null);
return true;
case Keys.Control | Keys.S:
btnSave_Click(null, null);
return true;
}
return base.ProcessCmdKey(ref msg, keyData);
}
#endregion
#region Button Event Handlers #region Button Event Handlers
private void btnAddNode_Click(object sender, EventArgs e) private void btnAddNode_Click(object sender, EventArgs e)
@@ -537,6 +636,22 @@ namespace AGVMapEditor.Forms
// 맵 캔버스에 데이터 설정 // 맵 캔버스에 데이터 설정
_mapCanvas.Nodes = _mapNodes; _mapCanvas.Nodes = _mapNodes;
// RfidMappings 제거됨 - MapNode에 통합 // RfidMappings 제거됨 - MapNode에 통합
// 현재 파일 경로 업데이트
_currentMapFile = filePath;
_hasChanges = false;
// 설정에 마지막 맵 파일 경로 저장
EditorSettings.Instance.UpdateLastMapFile(filePath);
UpdateTitle();
UpdateNodeList();
RefreshNodeConnectionList();
// 맵 로드 후 자동으로 맵에 맞춤
_mapCanvas.FitToNodes();
UpdateStatusBar($"맵 파일을 성공적으로 로드했습니다: {Path.GetFileName(filePath)}");
} }
else else
{ {
@@ -547,7 +662,19 @@ namespace AGVMapEditor.Forms
private void SaveMapToFile(string filePath) private void SaveMapToFile(string filePath)
{ {
if (!MapLoader.SaveMapToFile(filePath, _mapNodes)) if (MapLoader.SaveMapToFile(filePath, _mapNodes))
{
// 현재 파일 경로 업데이트
_currentMapFile = filePath;
_hasChanges = false;
// 설정에 마지막 맵 파일 경로 저장
EditorSettings.Instance.UpdateLastMapFile(filePath);
UpdateTitle();
UpdateStatusBar($"맵 파일을 성공적으로 저장했습니다: {Path.GetFileName(filePath)}");
}
else
{ {
MessageBox.Show("맵 파일 저장 실패", "오류", MessageBox.Show("맵 파일 저장 실패", "오류",
MessageBoxButtons.OK, MessageBoxIcon.Error); MessageBoxButtons.OK, MessageBoxIcon.Error);
@@ -559,8 +686,8 @@ namespace AGVMapEditor.Forms
/// </summary> /// </summary>
private void UpdateRfidMappings() private void UpdateRfidMappings()
{ {
// 네비게이션 노드들에 RFID 자동 할당 // RFID 자동 할당 제거 - 사용자가 직접 입력한 값 유지
MapLoader.AssignAutoRfidIds(_mapNodes); // MapLoader.AssignAutoRfidIds(_mapNodes);
} }
private bool CheckSaveChanges() private bool CheckSaveChanges()
@@ -584,6 +711,29 @@ namespace AGVMapEditor.Forms
return true; return true;
} }
/// <summary>
/// 마지막 맵 파일이 있는지 확인하고 사용자에게 로드할지 물어봄
/// </summary>
private void CheckAndLoadLastMapFile()
{
var settings = EditorSettings.Instance;
if (settings.AutoLoadLastMapFile && settings.HasValidLastMapFile())
{
string fileName = Path.GetFileName(settings.LastMapFilePath);
var result = MessageBox.Show(
$"마지막으로 사용한 맵 파일을 찾았습니다:\n\n{fileName}\n\n이 파일을 열까요?",
"마지막 맵 파일 로드",
MessageBoxButtons.YesNo,
MessageBoxIcon.Question);
if (result == DialogResult.Yes)
{
LoadMapFromFile(settings.LastMapFilePath);
}
}
}
#endregion #endregion
#region UI Updates #region UI Updates
@@ -591,6 +741,7 @@ namespace AGVMapEditor.Forms
private void RefreshAll() private void RefreshAll()
{ {
RefreshNodeList(); RefreshNodeList();
RefreshNodeConnectionList();
RefreshMapCanvas(); RefreshMapCanvas();
ClearNodeProperties(); ClearNodeProperties();
} }
@@ -672,12 +823,12 @@ namespace AGVMapEditor.Forms
e.Graphics.FillRectangle(brush, e.Bounds); e.Graphics.FillRectangle(brush, e.Bounds);
} }
// 텍스트 그리기 (노드ID - 명 - RFID 순서) // 텍스트 그리기 (노드ID - 노드명 - RFID 순서)
var displayText = node.NodeId; var displayText = node.NodeId;
if (!string.IsNullOrEmpty(node.Description)) if (!string.IsNullOrEmpty(node.Name))
{ {
displayText += $" - {node.Description}"; displayText += $" - {node.Name}";
} }
if (!string.IsNullOrEmpty(node.RfidId)) if (!string.IsNullOrEmpty(node.RfidId))
@@ -694,6 +845,92 @@ namespace AGVMapEditor.Forms
e.DrawFocusRectangle(); e.DrawFocusRectangle();
} }
private void RefreshNodeConnectionList()
{
var connections = new List<NodeConnectionInfo>();
var processedPairs = new HashSet<string>();
// 모든 노드의 연결 정보를 수집 (중복 방지)
foreach (var fromNode in _mapNodes)
{
foreach (var toNodeId in fromNode.ConnectedNodes)
{
var toNode = _mapNodes.FirstOrDefault(n => n.NodeId == toNodeId);
if (toNode != null)
{
// 중복 체크 (단일 연결만 표시)
string pairKey1 = $"{fromNode.NodeId}-{toNode.NodeId}";
string pairKey2 = $"{toNode.NodeId}-{fromNode.NodeId}";
if (!processedPairs.Contains(pairKey1) && !processedPairs.Contains(pairKey2))
{
// 사전 순으로 정렬하여 일관성 있게 표시
var (firstNode, secondNode) = string.Compare(fromNode.NodeId, toNode.NodeId) < 0
? (fromNode, toNode)
: (toNode, fromNode);
connections.Add(new NodeConnectionInfo
{
FromNodeId = firstNode.NodeId,
FromNodeName = firstNode.Name,
FromRfidId = firstNode.RfidId,
ToNodeId = secondNode.NodeId,
ToNodeName = secondNode.Name,
ToRfidId = secondNode.RfidId,
ConnectionType = "양방향" // 모든 연결이 양방향
});
processedPairs.Add(pairKey1);
processedPairs.Add(pairKey2);
}
}
}
}
// 리스트박스에 표시
lstNodeConnection.DataSource = null;
lstNodeConnection.DataSource = connections;
lstNodeConnection.DisplayMember = "ToString";
// 리스트박스 클릭 이벤트 연결
lstNodeConnection.SelectedIndexChanged -= LstNodeConnection_SelectedIndexChanged;
lstNodeConnection.SelectedIndexChanged += LstNodeConnection_SelectedIndexChanged;
// 더블클릭 이벤트 연결 (연결 삭제)
lstNodeConnection.DoubleClick -= LstNodeConnection_DoubleClick;
lstNodeConnection.DoubleClick += LstNodeConnection_DoubleClick;
}
private void LstNodeConnection_SelectedIndexChanged(object sender, EventArgs e)
{
if (lstNodeConnection.SelectedItem is NodeConnectionInfo connectionInfo)
{
// 캔버스에서 해당 연결선 강조 표시
_mapCanvas?.HighlightConnection(connectionInfo.FromNodeId, connectionInfo.ToNodeId);
// 연결된 노드들을 맵에서 하이라이트 표시 (선택적)
var fromNode = _mapNodes.FirstOrDefault(n => n.NodeId == connectionInfo.FromNodeId);
if (fromNode != null)
{
_selectedNode = fromNode;
UpdateNodeProperties();
_mapCanvas?.Invalidate();
}
}
else
{
// 선택 해제 시 강조 표시 제거
_mapCanvas?.ClearHighlightedConnection();
}
}
private void LstNodeConnection_DoubleClick(object sender, EventArgs e)
{
// 더블클릭으로 연결 삭제
DeleteSelectedConnection();
}
private void RefreshMapCanvas() private void RefreshMapCanvas()
{ {
_mapCanvas?.Invalidate(); _mapCanvas?.Invalidate();
@@ -735,6 +972,31 @@ namespace AGVMapEditor.Forms
this.Text = title; this.Text = title;
} }
/// <summary>
/// 노드 목록을 업데이트
/// </summary>
private void UpdateNodeList()
{
if (listBoxNodes != null)
{
listBoxNodes.DataSource = null;
listBoxNodes.DataSource = _mapNodes;
listBoxNodes.DisplayMember = "DisplayText";
}
}
/// <summary>
/// 상태바에 메시지 표시
/// </summary>
/// <param name="message">표시할 메시지</param>
private void UpdateStatusBar(string message)
{
if (toolStripStatusLabel1 != null)
{
toolStripStatusLabel1.Text = message;
}
}
#endregion #endregion
#region Form Events #region Form Events
@@ -754,6 +1016,23 @@ namespace AGVMapEditor.Forms
private void PropertyGrid_PropertyValueChanged(object s, PropertyValueChangedEventArgs e) private void PropertyGrid_PropertyValueChanged(object s, PropertyValueChangedEventArgs e)
{ {
// RFID 값 변경시 중복 검사
if (e.ChangedItem.PropertyDescriptor.Name == "RFID")
{
string newRfidValue = e.ChangedItem.Value?.ToString();
if (!string.IsNullOrEmpty(newRfidValue) && CheckRfidDuplicate(newRfidValue))
{
// 중복된 RFID 값 발견
MessageBox.Show($"RFID 값 '{newRfidValue}'이(가) 이미 다른 노드에서 사용 중입니다.\n입력값을 되돌립니다.",
"RFID 중복 오류", MessageBoxButtons.OK, MessageBoxIcon.Warning);
// 원래 값으로 되돌리기 - PropertyGrid의 SelectedObject 사용
e.ChangedItem.PropertyDescriptor.SetValue(_propertyGrid.SelectedObject, e.OldValue);
_propertyGrid.Refresh();
return;
}
}
// 속성이 변경되었을 때 자동으로 변경사항 표시 // 속성이 변경되었을 때 자동으로 변경사항 표시
_hasChanges = true; _hasChanges = true;
UpdateTitle(); UpdateTitle();
@@ -775,6 +1054,52 @@ namespace AGVMapEditor.Forms
} }
} }
/// <summary>
/// RFID 값 중복 검사
/// </summary>
/// <param name="rfidValue">검사할 RFID 값</param>
/// <returns>중복되면 true, 아니면 false</returns>
private bool CheckRfidDuplicate(string rfidValue)
{
if (string.IsNullOrEmpty(rfidValue) || _mapNodes == null)
return false;
// 현재 편집 중인 노드 제외하고 중복 검사
string currentNodeId = null;
var selectedObject = _propertyGrid.SelectedObject;
// 다양한 PropertyWrapper 타입 처리
if (selectedObject is NodePropertyWrapper nodeWrapper)
{
currentNodeId = nodeWrapper.WrappedNode?.NodeId;
}
else if (selectedObject is LabelNodePropertyWrapper labelWrapper)
{
currentNodeId = labelWrapper.WrappedNode?.NodeId;
}
else if (selectedObject is ImageNodePropertyWrapper imageWrapper)
{
currentNodeId = imageWrapper.WrappedNode?.NodeId;
}
int duplicateCount = 0;
foreach (var node in _mapNodes)
{
// 현재 편집 중인 노드는 제외
if (node.NodeId == currentNodeId)
continue;
// 같은 RFID 값을 가진 노드가 있는지 확인
if (!string.IsNullOrEmpty(node.RfidId) && node.RfidId.Equals(rfidValue, StringComparison.OrdinalIgnoreCase))
{
duplicateCount++;
break; // 하나라도 발견되면 중복
}
}
return duplicateCount > 0;
}
#endregion #endregion
#region Data Model for Serialization #region Data Model for Serialization
@@ -782,5 +1107,54 @@ namespace AGVMapEditor.Forms
#endregion #endregion
private void btNodeRemove_Click(object sender, EventArgs e)
{
DeleteSelectedConnection();
}
private void DeleteSelectedConnection()
{
if (lstNodeConnection.SelectedItem is NodeConnectionInfo connectionInfo)
{
var result = MessageBox.Show(
$"다음 연결을 삭제하시겠습니까?\n{connectionInfo}",
"연결 삭제 확인",
MessageBoxButtons.YesNo,
MessageBoxIcon.Question);
if (result == DialogResult.Yes)
{
// 단일 연결 삭제
var fromNode = _mapNodes.FirstOrDefault(n => n.NodeId == connectionInfo.FromNodeId);
var toNode = _mapNodes.FirstOrDefault(n => n.NodeId == connectionInfo.ToNodeId);
if (fromNode != null && toNode != null)
{
// 단일 연결 삭제 (어느 방향에 저장되어 있는지 확인 후 삭제)
if (fromNode.ConnectedNodes.Contains(toNode.NodeId))
{
fromNode.RemoveConnection(toNode.NodeId);
}
else if (toNode.ConnectedNodes.Contains(fromNode.NodeId))
{
toNode.RemoveConnection(fromNode.NodeId);
}
_hasChanges = true;
RefreshNodeConnectionList();
RefreshMapCanvas();
UpdateNodeProperties();
UpdateTitle();
toolStripStatusLabel1.Text = $"연결 삭제됨: {connectionInfo.FromNodeId} ↔ {connectionInfo.ToNodeId}";
}
}
}
else
{
MessageBox.Show("삭제할 연결을 선택하세요.", "알림", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
}
} }
} }

View File

@@ -117,10 +117,112 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </resheader>
<metadata name="menuStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>17, 17</value>
</metadata>
<metadata name="statusStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a"> <metadata name="statusStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>132, 17</value> <value>132, 17</value>
</metadata> </metadata>
<metadata name="toolStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>249, 17</value>
</metadata>
<metadata name="toolStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>249, 17</value>
</metadata>
<assembly alias="System.Drawing" name="System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" />
<data name="btNodeRemove.Image" type="System.Drawing.Bitmap, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8
YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAIDSURBVDhPpZLrS5NhGMb3j4SWh0oRQVExD4gonkDpg4hG
YKxG6WBogkMZKgPNCEVJFBGdGETEvgwyO9DJE5syZw3PIlPEE9pgBCLZ5XvdMB8Ew8gXbl54nuf63dd9
0OGSnwCahxbPRNPAPMw9Xpg6ZmF46kZZ0xSKzJPIrhpDWsVnpBhGkKx3nAX8Pv7z1zg8OoY/cITdn4fw
bf/C0kYAN3Ma/w3gWfZL5kzTKBxjWyK2DftwI9tyMYCZKXbNHaD91bLYJrDXsYbrWfUKwJrPE9M2M1Oc
VzOOpHI7Jr376Hi9ogHqFIANO0/MmmmbmSmm9a8ze+I4MrNWAdjtoJgWcx+PSzg166yZZ8xM8XvXDix9
c4jIqFYAjoriBV9AhEPv1mH/sonogha0afbZMMZz+yreTGyhpusHwtNNCsA5U1zS4BLxzJIfg299qO32
Ir7UJtZfftyATqeT+8o2D8JSjQrAJblrncYL7ZJ2+bfaFnC/1S1NjL3diRat7qrO7wLRP3HjWsojBeCo
mDEo5mNjuweFGvjWg2EBhCbpkW78htSHHwRyNdmgAFzPEee2iFkzayy2OLXzT4gr6UdUnlXrullsxxQ+
kx0g8BTA3aZlButjSTyjODq/WcQcW/B/Je4OQhLvKQDnzN1mp0nnkvAhR8VuMzNrpm1mpjgkoVwB/v8D
TgDQASA1MVpwzwAAAABJRU5ErkJggg==
</value>
</data>
<metadata name="toolStrip2.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>356, 17</value>
</metadata>
<data name="btnNew.Image" type="System.Drawing.Bitmap, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>
R0lGODlhEAAQAIUoAOLp8ElVa0NLXIivyJXK/D5FVYm77N7n7ykxQ5rA1svM0YS8O4C4OJXJSInAP5fL
S362N4/ERJLHR5DFRdXb5JbKStXn8HqzM4vAQJ7O+1Jhe1dwkezx9nKsLeXt9XaXtKTR+7HX/PL2+sDf
/bvc/avU+7ba/P///////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH/C05FVFNDQVBFMi4wAwEBAAAh+QQBAAAoACwAAAAAEAAQ
AAAIswBPCBxIcCCKgycsnOjQ4USCAQM+SPxwAqHABw0GetjowcCGigsrSIjgoOGIkyIMeBTYYQKGBRBM
kiAhoqYGggwuDBxBwoSJECJuOlxokqfPECWCChxAcOZPpEkDLBVxsufPEiVAgPAg9cQHDlWvZgWRwYMA
gV+dQtWaIQOAs145WEXKlgCBAwXQiui5tq1dDnlPbKAggsNGAIgBHOCgAAHaDZA1aAgQQICAAgUQCC3I
mWBAADs=
</value>
</data>
<data name="btnOpen.Image" type="System.Drawing.Bitmap, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29m
dHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAILSURBVDhPpY9LaxNRGIa7cO/On+CqFdFmRHQpiCs3
ohtvIAW1WUiUQkXQLnoBIVAaRUQshVLBFotGBJfWRTW17dhEM5mkteklNMFm7pecubx+mWomQhBLYZ75
zjnwPu85bQD2RMvD3dDycDcEv9zbTr44c5mtfbzaxBWWf3dyWXjTfoDY/xfJgH0NgZjkbN/3aOnTV2dn
7VgVbMx219Znu+0/lOZ7rc3UnWLu9ZHzoWA6IvuuBXsjDmNlGMbqKMziFHxHIpcO39UIZWfvVAGP1cRp
LhMKXnKqxxQKJ6AXRqB+68N26iYqc1FI6X5ISw8g8XdRXewhYnSzMsRXnVoomIwwx9yClEmgujCE8ofb
MMufqVX93V6fcngDep44yVmhYOIYC97v2YQJuEYQdE0BdikBa70f5o8eGPnr0IWLcPUsxPFIk2CMq/mu
CVf9BEd6D/ZzCrWtZxR8CHP1HsxCFEbuEvTvZ6GlT8HVeIijzYKnnO0xFWw7iVp5nFofwVqj1pV6axf0
7AVomTNQv56AsnAYjvoF4pNmQSJi+Uyi8BjszWFYxT4Kx2CI1yh8jlpPQ+WPQ5k/BHnuIAlSqGcaglz8
aInJBVlffuHp+efQxMfQhDi07ADUzH2o6V4oSzEofBTK4g3YlRmfMnJDIAy03xKGOiaEwY4KTeufDAZT
ocxIQ7AXWh7+P2j7BY3RGzIVTOkAAAAAAElFTkSuQmCC
</value>
</data>
<data name="btnReopen.Image" type="System.Drawing.Bitmap, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>
R0lGODlhEAAQAIQfAJfL/OTs9HWVsW6aUqnT+6bnZldwkYiux7TZ/O3z+UlVa/P2+ZfTW36wWJDLV4m7
69nn78bi/qjL3qDP+VJhe4rAVa7S40NLXJ3bYJrA1ikxQz5FVdDU22OPRf///////yH/C05FVFNDQVBF
Mi4wAwEBAAAh+QQBAAAfACwAAAAAEAAQAAAIwQA9CBxIcOCHgx4gWLAgIUOGAwcESBTgAaEFCAEGaBwQ
IGOABwYqerCQsYBJBho7JHgAUqCEDjAxYGBQgYHKBAsoCMzQIUIEmA6CdkCAIOfOBT5/MnBQYSgBozCj
SoVJ4KkCDx1MFhhKFEFVAhMCXM1aAANMoh2qTgh7AWvZmQ6igp0AIEDbDg0aLA06YC4AABA2eBjgYcHG
vmv/Akgg2IMBDgsSdJwcAEICDhoECjDAmQIFBQouXNiwQYPOgqgLBgQAOw==
</value>
</data>
<data name="btnClose.Image" type="System.Drawing.Bitmap, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29m
dHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHlSURBVDhPpZJbSxtBGIb3VhQULVoqUunRtEpCEmmM
0UJi2ZiE1qYHg1ZKbUsP/9MLD+DfKGZDScJmd7M783RmY9LdpjelFy/DDPO837zfNwbwXxo/NAwrPL6W
iMv6835so7ZNHj+Ab+/g6yHyywHycwP58S3yw2uCh3fwDaMTZcbh70dglpDZLDKdRqRSiLU1gkKO4OgF
/fu3cfTdmEEUPmzA5SWyXkckk4jVVYJqFXlxgV8r4+/v4t1dwr42GRoMnqwqa5huF1otglqNoFIBS0Xv
dJBnZ/STj+i/LNIN0YhBmDeTQezthfDQJJSGlUl/ext3YQHv+VPaUQPdYflp/3de9eQhGErB3tYW7vw8
ztwcbrXwF4PjN4O8iQRBuRwz0NW9fB5ndpbe9DRuOTduIN7XCVZWCExzlFmvGqbdRl5d4ajJ2JOTuM/W
+Rk1ULNFqBH5T9LI8/NB1WYTb3MTd2MjhKUyEScn9JZu4pQycQNP/TA9X79RwTeLiNPTENZ5ezMzOLq5
CnbWUzg7WTqLN2gZxo+RgZb6HJaeb//VDl7i3iivPTWFPTGBvagaqCq3I3DMQEt9DstevoVXK+Du5nHN
XJjXKWVxiukxWCtmoKW6a+kOD6WzailQS40mfj+2+Xdh/ALnlbiDsb03NQAAAABJRU5ErkJggg==
</value>
</data>
<data name="btnSave.Image" type="System.Drawing.Bitmap, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>
R0lGODlhEAAQAIQAAJXD9Iasxm6MqnSn2lZtjVaRyEpXbYu767TX/2KZztvr/4Gy5KrT/3ut32+gzlFh
e+r0/0RNX9/u/9Ln+8Xg//n8/4e36CkxQz9GVkSCvKjL35/N/Je91K7T5bDS4////yH/C05FVFNDQVBF
Mi4wAwEBAAAh+QQAAAAAACwAAAAAEAAQAAAIuQA/CBxIsKDACRwScggQwIGAhwIICBDYQcEEgwg+bNjw
QKCHCQgkQBgpQcKBCg0AEBCoAaRIkhIsVBigUiAHCgwkKNjJU8GAAx0/3NwIAMABCwsaDHCwIGgAChuK
HjiQdMDSAQYEPpWKtKqDBA6yfgiAwGhXpUsTJIgg0AGCo0nRfi1QgO0HAQyQNpCrtkAGDAIFbKi69GsC
un8FEohqdEFavxkyXAhMoPKDBwYMRIiAAcOFoAZDCwwIADs=
</value>
</data>
<data name="btnSaveAs.Image" type="System.Drawing.Bitmap, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29m
dHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAMPSURBVDhPZZL9S1NhFMfvT/Vv9BeEUURkRIQWZSGh
URRRRsZMM0UqzbSyWdq0d0gtVmlZJJHkC7FJS92rtd5cmzbnu1PX5tzutuduu3ffzi69aQ98OOc+3Ps5
5zzP5dIqeo/sVpm1mXXmBQIEy6wn6iwsU2ViGbUGW+oZ/a1Nio4VALjlcDuq9f3eIPMuhATRxwv4jTeY
hMHhDqLi9SRy7zrDqYqOlf8JMmoMnmAkJprGY7BMxmEej8MwFkWvU8AbewhCHOgciqCifQLHbjsjm/M7
l0i4XbXGSCAchXVKJOJ4L0ui6BsRoLGHSSDB6hZhmYlB2T2JrWW61iWCjFojSwo+keDTdFIiYmAiBr0r
ip7hCMbmQ/IoPItj3h9GemUf9bRMsBiKYpCqDM5K+CJLkl3E5C601EWXjUfH1wAWQwK2VenZEsHOGiPz
k8Axm8A3EtjcEr6SrPCeDeVqG4obPqPqiR2K61bk3bTidNNnlDR+wVGVGasOvFzP7SCBjwRDcwk4CDuJ
Bmck5NZ/QLuVpyJAh2lKjv+uxzoPjtRawG1XGpmXj8IufyzJJDs5eMUE9Tu//HLhnQ9yTC46CgQEoO7V
NHKumcClVxnwg+48WfmPgMY4VGOmKn7ERAlxMQFJSkAkYnQrcdqrfu5C1oVecFvO65l7kWFvp4DsLoYs
ilmvBRphAI1vPKh86sLFVhfOtzhx7tEwyojSh0MoVQ/hkFIHblNpL5vyMRT2SVA7gPt2oIFijsqC211u
XHo2SUygomUU5Y9HUPbQibPqYZQ02bCvUgtuQ7EuPPYjnFDo6ODexnHsrSiTfUmPe90eeHge7oCP8MO9
6McM5b4wj7IH35BW1A5ubV7PqCcgBMe8TBr1RDDyiz0X+tHU7ccc74FStwcNllNotBRTno350DTKH7iw
Me8FuHUntPkpuZqeNce1vpTjGhCMntney0ZoPiZvwQXTwlU4Qs1wRJopr6O9cVQ3u7D5RNvfP2o5axUa
w35lPxSqQRTdmEBB/QhOqr6j8JoDBSor0k62YfXhVvwE3mQsoPunpBAAAAAASUVORK5CYII=
</value>
</data>
</root> </root>

View File

@@ -0,0 +1,162 @@
using System;
using System.IO;
using Newtonsoft.Json;
namespace AGVMapEditor.Models
{
/// <summary>
/// AGV 맵 에디터의 환경설정을 관리하는 클래스
/// </summary>
public class EditorSettings
{
#region Properties
/// <summary>
/// 마지막으로 열었던 맵 파일의 경로
/// </summary>
public string LastMapFilePath { get; set; } = string.Empty;
/// <summary>
/// 프로그램 시작시 마지막 맵 파일을 자동으로 로드할지 여부
/// </summary>
public bool AutoLoadLastMapFile { get; set; } = true;
/// <summary>
/// 설정이 마지막으로 저장된 시간
/// </summary>
public DateTime LastSaved { get; set; } = DateTime.Now;
/// <summary>
/// 기본 맵 파일 저장 디렉토리
/// </summary>
public string DefaultMapDirectory { get; set; } = string.Empty;
#endregion
#region Constants
private static readonly string SettingsFileName = "EditorSettings.json";
private static readonly string SettingsDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AGVMapEditor");
private static readonly string SettingsFilePath = Path.Combine(SettingsDirectory, SettingsFileName);
#endregion
#region Static Instance
private static EditorSettings _instance;
private static readonly object _lock = new object();
/// <summary>
/// 싱글톤 인스턴스
/// </summary>
public static EditorSettings Instance
{
get
{
if (_instance == null)
{
lock (_lock)
{
if (_instance == null)
{
_instance = LoadSettings();
}
}
}
return _instance;
}
}
#endregion
#region Methods
/// <summary>
/// 설정을 파일에서 로드
/// </summary>
private static EditorSettings LoadSettings()
{
try
{
if (File.Exists(SettingsFilePath))
{
string jsonContent = File.ReadAllText(SettingsFilePath);
var settings = JsonConvert.DeserializeObject<EditorSettings>(jsonContent);
return settings ?? new EditorSettings();
}
}
catch (Exception ex)
{
// 설정 로드 실패시 기본 설정 사용
System.Diagnostics.Debug.WriteLine($"설정 로드 실패: {ex.Message}");
}
return new EditorSettings();
}
/// <summary>
/// 설정을 파일에 저장
/// </summary>
public void Save()
{
try
{
// 디렉토리가 없으면 생성
if (!Directory.Exists(SettingsDirectory))
{
Directory.CreateDirectory(SettingsDirectory);
}
LastSaved = DateTime.Now;
string jsonContent = JsonConvert.SerializeObject(this, Formatting.Indented);
File.WriteAllText(SettingsFilePath, jsonContent);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"설정 저장 실패: {ex.Message}");
}
}
/// <summary>
/// 마지막 맵 파일 경로 업데이트
/// </summary>
/// <param name="filePath">맵 파일 경로</param>
public void UpdateLastMapFile(string filePath)
{
if (!string.IsNullOrEmpty(filePath) && File.Exists(filePath))
{
LastMapFilePath = filePath;
// 기본 디렉토리도 업데이트
DefaultMapDirectory = Path.GetDirectoryName(filePath);
Save();
}
}
/// <summary>
/// 마지막 맵 파일이 존재하는지 확인
/// </summary>
public bool HasValidLastMapFile()
{
return !string.IsNullOrEmpty(LastMapFilePath) && File.Exists(LastMapFilePath);
}
/// <summary>
/// 설정 초기화
/// </summary>
public void Reset()
{
LastMapFilePath = string.Empty;
AutoLoadLastMapFile = true;
DefaultMapDirectory = string.Empty;
LastSaved = DateTime.Now;
Save();
}
#endregion
}
}

View File

@@ -1,68 +0,0 @@
using System;
namespace AGVMapEditor.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

@@ -1,422 +0,0 @@
using System;
using System.Collections.Generic;
using System.Drawing;
namespace AGVMapEditor.Models
{
/// <summary>
/// 맵 노드 정보를 관리하는 클래스
/// 논리적 노드로서 실제 맵의 위치와 속성을 정의
/// </summary>
public class MapNode
{
/// <summary>
/// 논리적 노드 ID (맵 에디터에서 관리하는 고유 ID)
/// 예: "N001", "N002", "LOADER1", "CHARGER1"
/// </summary>
public string NodeId { get; set; } = string.Empty;
/// <summary>
/// 노드 표시 이름 (사용자 친화적)
/// 예: "로더1", "충전기1", "교차점A", "회전지점1"
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 맵 상의 위치 좌표 (픽셀 단위)
/// </summary>
public Point Position { get; set; } = Point.Empty;
/// <summary>
/// 노드 타입
/// </summary>
public NodeType Type { get; set; } = NodeType.Normal;
/// <summary>
/// 도킹 방향 (도킹/충전 노드인 경우만 사용)
/// </summary>
public DockingDirection? DockDirection { get; set; } = null;
/// <summary>
/// 연결된 노드 ID 목록 (경로 정보)
/// </summary>
public List<string> ConnectedNodes { get; set; } = new List<string>();
/// <summary>
/// 회전 가능 여부 (180도 회전 가능한 지점)
/// </summary>
public bool CanRotate { get; set; } = false;
/// <summary>
/// 장비 ID (도킹/충전 스테이션인 경우)
/// 예: "LOADER1", "CLEANER1", "BUFFER1", "CHARGER1"
/// </summary>
public string StationId { get; set; } = string.Empty;
/// <summary>
/// 장비 타입 (도킹/충전 스테이션인 경우)
/// </summary>
public StationType? StationType { get; set; } = null;
/// <summary>
/// 노드 생성 일자
/// </summary>
public DateTime CreatedDate { get; set; } = DateTime.Now;
/// <summary>
/// 노드 수정 일자
/// </summary>
public DateTime ModifiedDate { get; set; } = DateTime.Now;
/// <summary>
/// 노드 설명 (추가 정보)
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// 노드 활성화 여부
/// </summary>
public bool IsActive { get; set; } = true;
/// <summary>
/// 노드 색상 (맵 에디터 표시용)
/// </summary>
public Color DisplayColor { get; set; } = Color.Blue;
/// <summary>
/// RFID 태그 ID (이 노드에 매핑된 RFID)
/// </summary>
public string RfidId { 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,
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 = System.Drawing.Drawing2D.CompositingQuality.HighQuality;
graphics.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
graphics.SmoothingMode = System.Drawing.Drawing2D.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;
}
}
}

View File

@@ -39,6 +39,11 @@ namespace AGVMapEditor.Models
_mapNodes = mapNodes; _mapNodes = mapNodes;
} }
/// <summary>
/// 래핑된 MapNode 인스턴스 접근
/// </summary>
public MapNode WrappedNode => _node;
[Category("기본 정보")] [Category("기본 정보")]
[DisplayName("노드 ID")] [DisplayName("노드 ID")]
[Description("노드의 고유 식별자")] [Description("노드의 고유 식별자")]
@@ -207,6 +212,11 @@ namespace AGVMapEditor.Models
_mapNodes = mapNodes; _mapNodes = mapNodes;
} }
/// <summary>
/// 래핑된 MapNode 인스턴스 접근
/// </summary>
public MapNode WrappedNode => _node;
[Category("기본 정보")] [Category("기본 정보")]
[DisplayName("노드 ID")] [DisplayName("노드 ID")]
[Description("노드의 고유 식별자")] [Description("노드의 고유 식별자")]
@@ -350,6 +360,11 @@ namespace AGVMapEditor.Models
_mapNodes = mapNodes; _mapNodes = mapNodes;
} }
/// <summary>
/// 래핑된 MapNode 인스턴스 접근
/// </summary>
public MapNode WrappedNode => _node;
[Category("기본 정보")] [Category("기본 정보")]
[DisplayName("노드 ID")] [DisplayName("노드 ID")]
[Description("노드의 고유 식별자")] [Description("노드의 고유 식별자")]
@@ -450,18 +465,6 @@ namespace AGVMapEditor.Models
[Category("고급")]
[DisplayName("설명")]
[Description("노드에 대한 추가 설명")]
public string Description
{
get => _node.Description;
set
{
_node.Description = value ?? "";
_node.ModifiedDate = DateTime.Now;
}
}
[Category("고급")] [Category("고급")]
[DisplayName("활성화")] [DisplayName("활성화")]

View File

@@ -49,6 +49,8 @@ namespace AGVMapEditor.Models
_astarPathfinder.SetMapNodes(mapNodes); _astarPathfinder.SetMapNodes(mapNodes);
// RfidPathfinder는 MapNode의 RFID 정보를 직접 사용 // RfidPathfinder는 MapNode의 RFID 정보를 직접 사용
_rfidPathfinder.SetMapNodes(mapNodes); _rfidPathfinder.SetMapNodes(mapNodes);
// 도킹 조건 검색용 내부 노드 목록 업데이트
UpdateInternalMapNodes(mapNodes);
} }
/// <summary> /// <summary>
@@ -63,6 +65,32 @@ namespace AGVMapEditor.Models
return _agvPathfinder.FindAGVPath(startNodeId, endNodeId, targetDirection); return _agvPathfinder.FindAGVPath(startNodeId, endNodeId, targetDirection);
} }
/// <summary>
/// AGV 경로 계산 (옵션 지정 가능)
/// </summary>
/// <param name="startNodeId">시작 노드 ID</param>
/// <param name="endNodeId">목적지 노드 ID</param>
/// <param name="targetDirection">목적지 도착 방향</param>
/// <param name="options">경로 탐색 옵션</param>
/// <returns>AGV 경로 계산 결과</returns>
public AGVPathResult FindAGVPath(string startNodeId, string endNodeId, AgvDirection? targetDirection, PathfindingOptions options)
{
return _agvPathfinder.FindAGVPath(startNodeId, endNodeId, targetDirection, options);
}
/// <summary>
/// AGV 경로 계산 (현재 방향 및 PathfindingOptions 지원)
/// </summary>
/// <param name="startNodeId">시작 노드 ID</param>
/// <param name="endNodeId">목적지 노드 ID</param>
/// <param name="currentDirection">현재 AGV 방향</param>
/// <param name="targetDirection">목적지 도착 방향</param>
/// <param name="options">경로 탐색 옵션</param>
/// <returns>AGV 경로 계산 결과</returns>
public AGVPathResult FindAGVPath(string startNodeId, string endNodeId, AgvDirection? currentDirection, AgvDirection? targetDirection, PathfindingOptions options)
{
return _agvPathfinder.FindAGVPath(startNodeId, endNodeId, currentDirection, targetDirection, options);
}
/// <summary> /// <summary>
/// 충전 스테이션으로의 경로 찾기 /// 충전 스테이션으로의 경로 찾기
@@ -264,5 +292,141 @@ namespace AGVMapEditor.Models
{ {
_rfidPathfinder.RotationCostWeight = weight; _rfidPathfinder.RotationCostWeight = weight;
} }
#region
// 내부 노드 목록 저장
private List<MapNode> _mapNodes;
/// <summary>
/// 맵 노드 설정 (도킹 조건 검색용)
/// </summary>
private void UpdateInternalMapNodes(List<MapNode> mapNodes)
{
_mapNodes = mapNodes;
}
/// <summary>
/// 도킹 방향 기반 노드 검색
/// </summary>
/// <param name="dockingDirection">도킹 방향</param>
/// <returns>해당 도킹 방향의 노드 목록</returns>
public List<MapNode> GetNodesByDockingDirection(DockingDirection dockingDirection)
{
if (_mapNodes == null) return new List<MapNode>();
var result = new List<MapNode>();
foreach (var node in _mapNodes)
{
if (!node.IsActive) continue;
var nodeDockingDirection = GetNodeDockingDirection(node);
if (nodeDockingDirection == dockingDirection)
{
result.Add(node);
}
}
return result;
}
/// <summary>
/// 노드의 도킹 방향 결정
/// </summary>
/// <param name="node">노드</param>
/// <returns>도킹 방향</returns>
public DockingDirection GetNodeDockingDirection(MapNode node)
{
switch (node.Type)
{
case NodeType.Charging:
return DockingDirection.Forward; // 충전기: 전진 도킹
case NodeType.Docking:
return DockingDirection.Backward; // 장비: 후진 도킹
default:
return DockingDirection.Forward; // 기본값: 전진
}
}
/// <summary>
/// 도킹 방향과 장비 타입에 맞는 노드들로의 경로 검색
/// </summary>
/// <param name="startRfidId">시작 RFID</param>
/// <param name="dockingDirection">필요한 도킹 방향</param>
/// <param name="stationType">장비 타입 (선택사항)</param>
/// <returns>경로 계산 결과 목록 (거리 순 정렬)</returns>
public List<RfidPathResult> FindPathsByDockingCondition(string startRfidId, DockingDirection dockingDirection, StationType? stationType = null)
{
var targetNodes = GetNodesByDockingDirection(dockingDirection);
var results = new List<RfidPathResult>();
// 장비 타입 필터링 (필요시)
if (stationType.HasValue && dockingDirection == DockingDirection.Backward)
{
// 후진 도킹이면서 특정 장비 타입이 지정된 경우
// 이 부분은 추후 StationMapping 정보가 있을 때 구현
// 현재는 모든 도킹 노드를 대상으로 함
}
foreach (var targetNode in targetNodes)
{
if (!targetNode.HasRfid()) continue;
try
{
var pathResult = _rfidPathfinder.FindAGVPath(startRfidId, targetNode.RfidId);
if (pathResult.Success)
{
results.Add(pathResult);
}
}
catch (Exception ex)
{
// 개별 경로 계산 실패는 무시하고 계속 진행
System.Diagnostics.Debug.WriteLine($"Path calculation failed from {startRfidId} to {targetNode.RfidId}: {ex.Message}");
}
}
// 거리 순으로 정렬
return results.OrderBy(r => r.TotalDistance).ToList();
}
/// <summary>
/// 가장 가까운 충전기 경로 찾기 (전진 도킹)
/// </summary>
/// <param name="startRfidId">시작 RFID</param>
/// <returns>가장 가까운 충전기로의 경로</returns>
public RfidPathResult FindNearestChargingStationPath(string startRfidId)
{
var chargingPaths = FindPathsByDockingCondition(startRfidId, DockingDirection.Forward);
var chargingNodes = chargingPaths.Where(p => p.Success).ToList();
return chargingNodes.FirstOrDefault() ?? new RfidPathResult
{
Success = false,
ErrorMessage = "충전 가능한 충전기를 찾을 수 없습니다."
};
}
/// <summary>
/// 가장 가까운 장비 도킹 경로 찾기 (후진 도킹)
/// </summary>
/// <param name="startRfidId">시작 RFID</param>
/// <param name="stationType">장비 타입 (선택사항)</param>
/// <returns>가장 가까운 장비로의 경로</returns>
public RfidPathResult FindNearestEquipmentPath(string startRfidId, StationType? stationType = null)
{
var equipmentPaths = FindPathsByDockingCondition(startRfidId, DockingDirection.Backward, stationType);
var equipmentNodes = equipmentPaths.Where(p => p.Success).ToList();
return equipmentNodes.FirstOrDefault() ?? new RfidPathResult
{
Success = false,
ErrorMessage = $"도킹 가능한 장비를 찾을 수 없습니다. ({stationType?.ToString() ?? " "})"
};
}
#endregion
} }
} }

View File

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

View File

@@ -1,277 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace AGVMapEditor.Models
{
/// <summary>
/// 경로 계산 결과
/// </summary>
public class PathResult
{
/// <summary>
/// 경로 계산 성공 여부
/// </summary>
public bool Success { get; set; } = false;
/// <summary>
/// 경로상의 노드 ID 시퀀스
/// </summary>
public List<string> NodeSequence { get; set; } = new List<string>();
/// <summary>
/// AGV 이동 명령 시퀀스
/// </summary>
public List<AgvDirection> MovementSequence { get; set; } = new List<AgvDirection>();
/// <summary>
/// 총 이동 거리 (비용)
/// </summary>
public float TotalDistance { get; set; } = 0;
/// <summary>
/// 총 회전 횟수
/// </summary>
public int TotalRotations { get; set; } = 0;
/// <summary>
/// 예상 소요 시간 (초)
/// </summary>
public float EstimatedTime { get; set; } = 0;
/// <summary>
/// 시작 노드 ID
/// </summary>
public string StartNodeId { get; set; } = string.Empty;
/// <summary>
/// 목표 노드 ID
/// </summary>
public string TargetNodeId { get; set; } = string.Empty;
/// <summary>
/// 시작시 AGV 방향
/// </summary>
public AgvDirection StartDirection { get; set; } = AgvDirection.Forward;
/// <summary>
/// 도착시 AGV 방향
/// </summary>
public AgvDirection EndDirection { get; set; } = AgvDirection.Forward;
/// <summary>
/// 경로 계산에 걸린 시간 (밀리초)
/// </summary>
public long CalculationTime { get; set; } = 0;
/// <summary>
/// 오류 메시지 (실패시)
/// </summary>
public string ErrorMessage { get; set; } = string.Empty;
/// <summary>
/// 경로상의 상세 정보 (디버깅용)
/// </summary>
public List<PathNode> DetailedPath { get; set; } = new List<PathNode>();
/// <summary>
/// 회전이 발생하는 노드들
/// </summary>
public List<string> RotationNodes { get; set; } = new List<string>();
/// <summary>
/// 기본 생성자
/// </summary>
public PathResult()
{
}
/// <summary>
/// 성공 결과 생성자
/// </summary>
public PathResult(List<PathNode> path, string startNodeId, string targetNodeId, AgvDirection startDirection)
{
if (path == null || path.Count == 0)
{
Success = false;
ErrorMessage = "빈 경로입니다.";
return;
}
Success = true;
StartNodeId = startNodeId;
TargetNodeId = targetNodeId;
StartDirection = startDirection;
DetailedPath = new List<PathNode>(path);
// 노드 시퀀스 구성
NodeSequence = path.Select(p => p.NodeId).ToList();
// 이동 명령 시퀀스 구성
MovementSequence = new List<AgvDirection>();
for (int i = 0; i < path.Count; i++)
{
MovementSequence.AddRange(path[i].MovementSequence);
}
// 통계 계산
if (path.Count > 0)
{
TotalDistance = path[path.Count - 1].GCost;
EndDirection = path[path.Count - 1].Direction;
}
TotalRotations = MovementSequence.Count(cmd =>
cmd == AgvDirection.Left || cmd == AgvDirection.Right);
// 회전 노드 추출
var previousDirection = startDirection;
for (int i = 0; i < path.Count; i++)
{
if (path[i].Direction != previousDirection)
{
RotationNodes.Add(path[i].NodeId);
}
previousDirection = path[i].Direction;
}
// 예상 소요 시간 계산 (단순 추정)
EstimatedTime = CalculateEstimatedTime();
}
/// <summary>
/// 실패 결과 생성자
/// </summary>
public PathResult(string errorMessage)
{
Success = false;
ErrorMessage = errorMessage;
}
/// <summary>
/// 예상 소요 시간 계산
/// </summary>
private float CalculateEstimatedTime()
{
// 기본 이동 속도 및 회전 시간 가정
const float MOVE_SPEED = 1.0f; // 단위/초
const float ROTATION_TIME = 2.0f; // 초/회전
float moveTime = TotalDistance / MOVE_SPEED;
float rotationTime = TotalRotations * ROTATION_TIME;
return moveTime + rotationTime;
}
/// <summary>
/// 경로 요약 정보
/// </summary>
public string GetSummary()
{
if (!Success)
{
return $"경로 계산 실패: {ErrorMessage}";
}
return $"경로: {NodeSequence.Count}개 노드, " +
$"거리: {TotalDistance:F1}, " +
$"회전: {TotalRotations}회, " +
$"예상시간: {EstimatedTime:F1}초";
}
/// <summary>
/// 상세 경로 정보
/// </summary>
public List<string> GetDetailedSteps()
{
var steps = new List<string>();
if (!Success)
{
steps.Add($"경로 계산 실패: {ErrorMessage}");
return steps;
}
steps.Add($"시작: {StartNodeId} (방향: {StartDirection})");
for (int i = 0; i < DetailedPath.Count; i++)
{
var node = DetailedPath[i];
var step = $"{i + 1}. {node.NodeId}";
if (node.MovementSequence.Count > 0)
{
step += $" [명령: {string.Join(",", node.MovementSequence)}]";
}
step += $" (F:{node.FCost:F1}, 방향:{node.Direction})";
steps.Add(step);
}
steps.Add($"도착: {TargetNodeId} (최종 방향: {EndDirection})");
return steps;
}
/// <summary>
/// RFID 시퀀스 추출 (실제 AGV 제어용)
/// </summary>
public List<string> GetRfidSequence(NodeResolver nodeResolver)
{
var rfidSequence = new List<string>();
foreach (var nodeId in NodeSequence)
{
var rfidId = nodeResolver.GetRfidByNodeId(nodeId);
if (!string.IsNullOrEmpty(rfidId))
{
rfidSequence.Add(rfidId);
}
}
return rfidSequence;
}
/// <summary>
/// 경로 유효성 검증
/// </summary>
public bool ValidatePath(List<MapNode> mapNodes)
{
if (!Success || NodeSequence.Count == 0)
return false;
// 모든 노드가 존재하는지 확인
foreach (var nodeId in NodeSequence)
{
if (!mapNodes.Any(n => n.NodeId == nodeId))
{
ErrorMessage = $"존재하지 않는 노드: {nodeId}";
return false;
}
}
// 연결성 확인
for (int i = 0; i < NodeSequence.Count - 1; i++)
{
var currentNode = mapNodes.FirstOrDefault(n => n.NodeId == NodeSequence[i]);
var nextNodeId = NodeSequence[i + 1];
if (currentNode != null && !currentNode.ConnectedNodes.Contains(nextNodeId))
{
ErrorMessage = $"연결되지 않은 노드: {currentNode.NodeId} → {nextNodeId}";
return false;
}
}
return true;
}
/// <summary>
/// JSON 직렬화를 위한 문자열 변환
/// </summary>
public override string ToString()
{
return GetSummary();
}
}
}

View File

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

View File

@@ -0,0 +1,63 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 이 코드는 도구를 사용하여 생성되었습니다.
// 런타임 버전:4.0.30319.42000
//
// 파일 내용을 변경하면 잘못된 동작이 발생할 수 있으며, 코드를 다시 생성하면
// 이러한 변경 내용이 손실됩니다.
// </auto-generated>
//------------------------------------------------------------------------------
namespace AGVMapEditor.Properties {
using System;
/// <summary>
/// 지역화된 문자열 등을 찾기 위한 강력한 형식의 리소스 클래스입니다.
/// </summary>
// 이 클래스는 ResGen 또는 Visual Studio와 같은 도구를 통해 StronglyTypedResourceBuilder
// 클래스에서 자동으로 생성되었습니다.
// 멤버를 추가하거나 제거하려면 .ResX 파일을 편집한 다음 /str 옵션을 사용하여 ResGen을
// 다시 실행하거나 VS 프로젝트를 다시 빌드하십시오.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Resources {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Resources() {
}
/// <summary>
/// 이 클래스에서 사용하는 캐시된 ResourceManager 인스턴스를 반환합니다.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("AGVMapEditor.Properties.Resources", typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// 이 강력한 형식의 리소스 클래스를 사용하여 모든 리소스 조회에 대해 현재 스레드의 CurrentUICulture 속성을
/// 재정의합니다.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
}
}

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<root> <root>
<!-- <!--
Microsoft ResX Schema Microsoft ResX Schema
@@ -32,8 +32,9 @@
Each data row contains a name, and value. The row also contains a Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support type or mimetype. Type corresponds to a .NET class that support
text/value conversion. Classes that don't support this are text/value conversion through the TypeConverter architecture.
serialized and stored with the mimetype set. Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not ResXResourceReader how to depersist the object. This is currently not
@@ -86,9 +87,9 @@
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence> </xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" /> <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" /> <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" /> <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" /> <xsd:attribute ref="xml:space" />
</xsd:complexType> </xsd:complexType>
</xsd:element> </xsd:element>
@@ -97,7 +98,7 @@
<xsd:sequence> <xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence> </xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" /> <xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType> </xsd:complexType>
</xsd:element> </xsd:element>
</xsd:choice> </xsd:choice>

View File

@@ -62,12 +62,18 @@
</Reference> </Reference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Compile Include="Controls\AGVState.cs" />
<Compile Include="Controls\IAGV.cs" />
<Compile Include="Models\Enums.cs" /> <Compile Include="Models\Enums.cs" />
<Compile Include="Models\MapLoader.cs" /> <Compile Include="Models\MapLoader.cs" />
<Compile Include="Models\MapNode.cs" /> <Compile Include="Models\MapNode.cs" />
<Compile Include="Models\RfidMapping.cs" /> <Compile Include="Models\RfidMapping.cs" />
<Compile Include="PathFinding\AdvancedAGVPathfinder.cs" />
<Compile Include="PathFinding\DirectionChangePlanner.cs" />
<Compile Include="PathFinding\JunctionAnalyzer.cs" />
<Compile Include="PathFinding\PathNode.cs" /> <Compile Include="PathFinding\PathNode.cs" />
<Compile Include="PathFinding\PathResult.cs" /> <Compile Include="PathFinding\PathResult.cs" />
<Compile Include="PathFinding\PathfindingOptions.cs" />
<Compile Include="PathFinding\AStarPathfinder.cs" /> <Compile Include="PathFinding\AStarPathfinder.cs" />
<Compile Include="PathFinding\AGVPathfinder.cs" /> <Compile Include="PathFinding\AGVPathfinder.cs" />
<Compile Include="PathFinding\AGVPathResult.cs" /> <Compile Include="PathFinding\AGVPathResult.cs" />

View File

@@ -0,0 +1,21 @@
namespace AGVNavigationCore.Controls
{
#region Interfaces
/// <summary>
/// AGV 상태 열거형
/// </summary>
public enum AGVState
{
Idle, // 대기
Moving, // 이동 중
Rotating, // 회전 중
Docking, // 도킹 중
Charging, // 충전 중
Error // 오류
}
#endregion
}

View File

@@ -0,0 +1,29 @@
using System.Drawing;
using AGVNavigationCore.Models;
namespace AGVNavigationCore.Controls
{
#region Interfaces
/// <summary>
/// AGV 인터페이스 (가상/실제 AGV 통합)
/// </summary>
public interface IAGV
{
string AgvId { get; }
Point CurrentPosition { get; }
AgvDirection CurrentDirection { get; }
AGVState CurrentState { get; }
float BatteryLevel { get; }
// 이동 경로 정보 추가
Point? TargetPosition { get; }
string CurrentNodeId { get; }
string TargetNodeId { get; }
DockingDirection DockingDirection { get; }
}
#endregion
}

View File

@@ -32,23 +32,26 @@ namespace AGVNavigationCore.Controls
DrawGrid(g); DrawGrid(g);
} }
// 노드 연결선 그리기 // 노드 연결선 그리기 (가장 먼저 - 텍스트가 가려지지 않게)
DrawConnections(g); DrawConnections(g);
// 경로 그리기 // 경로 그리기
DrawPaths(g); DrawPaths(g);
// 노드 그리기
DrawNodes(g);
// AGV 그리기
DrawAGVs(g);
// 임시 연결선 그리기 (편집 모드) // 임시 연결선 그리기 (편집 모드)
if (_canvasMode == CanvasMode.Edit && _isConnectionMode) if (_canvasMode == CanvasMode.Edit && _isConnectionMode)
{ {
DrawTemporaryConnection(g); DrawTemporaryConnection(g);
} }
// 노드 그리기 (라벨 제외)
DrawNodesOnly(g);
// AGV 그리기
DrawAGVs(g);
// 노드 라벨 그리기 (가장 나중 - 선이 텍스트를 가리지 않게)
DrawNodeLabels(g);
} }
finally finally
{ {
@@ -108,8 +111,38 @@ namespace AGVNavigationCore.Controls
var startPoint = fromNode.Position; var startPoint = fromNode.Position;
var endPoint = toNode.Position; var endPoint = toNode.Position;
// 연결선만 그리기 (단순한 도로 연결, 방향성 없음) // 강조된 연결인지 확인
g.DrawLine(_connectionPen, startPoint, endPoint); bool isHighlighted = IsConnectionHighlighted(fromNode.NodeId, toNode.NodeId);
// 강조된 연결은 다른 색상으로 그리기
var pen = isHighlighted ? _highlightedConnectionPen : _connectionPen;
g.DrawLine(pen, startPoint, endPoint);
}
/// <summary>
/// 연결이 강조 표시되어야 하는지 확인
/// </summary>
private bool IsConnectionHighlighted(string nodeId1, string nodeId2)
{
if (!_highlightedConnection.HasValue)
return false;
var highlighted = _highlightedConnection.Value;
// 사전순으로 정렬하여 비교 (연결이 단일 방향으로 저장되므로)
string from, to;
if (string.Compare(nodeId1, nodeId2, StringComparison.Ordinal) <= 0)
{
from = nodeId1;
to = nodeId2;
}
else
{
from = nodeId2;
to = nodeId1;
}
return highlighted.FromNodeId == from && highlighted.ToNodeId == to;
} }
private void DrawDirectionArrow(Graphics g, Point point, double angle, AgvDirection direction) private void DrawDirectionArrow(Graphics g, Point point, double angle, AgvDirection direction)
@@ -276,51 +309,65 @@ namespace AGVNavigationCore.Controls
brush.Dispose(); brush.Dispose();
} }
private void DrawNodes(Graphics g) private void DrawNodesOnly(Graphics g)
{ {
if (_nodes == null) return; if (_nodes == null) return;
foreach (var node in _nodes) foreach (var node in _nodes)
{ {
DrawNode(g, node); DrawNodeShape(g, node);
} }
} }
private void DrawNode(Graphics g, MapNode node) private void DrawNodeLabels(Graphics g)
{
if (_nodes == null) return;
foreach (var node in _nodes)
{
// Label과 Image 노드는 자체적으로 텍스트 포함, 다른 노드는 별도 라벨
if (node.Type != NodeType.Label && node.Type != NodeType.Image)
{
DrawNodeLabel(g, node);
}
}
}
private void DrawNodeShape(Graphics g, MapNode node)
{ {
switch (node.Type) switch (node.Type)
{ {
case NodeType.Label: case NodeType.Label:
DrawLabelNode(g, node); DrawLabelNode(g, node); // Label 노드는 텍스트 포함
break; break;
case NodeType.Image: case NodeType.Image:
DrawImageNode(g, node); DrawImageNode(g, node); // Image 노드는 텍스트 포함
break; break;
default: default:
DrawCircularNode(g, node); DrawCircularNodeShape(g, node); // 다른 노드는 도형만
break; break;
} }
} }
private void DrawCircularNode(Graphics g, MapNode node) private void DrawCircularNodeShape(Graphics g, MapNode node)
{ {
var brush = GetNodeBrush(node); var brush = GetNodeBrush(node);
switch (node.Type) switch (node.Type)
{ {
case NodeType.Docking: case NodeType.Docking:
DrawPentagonNode(g, node, brush); DrawPentagonNodeShape(g, node, brush);
break; break;
case NodeType.Charging: case NodeType.Charging:
DrawTriangleNode(g, node, brush); DrawTriangleNodeShape(g, node, brush);
break; break;
default: default:
DrawCircleNode(g, node, brush); DrawCircleNodeShape(g, node, brush);
break; break;
} }
} }
private void DrawCircleNode(Graphics g, MapNode node, Brush brush) private void DrawCircleNodeShape(Graphics g, MapNode node, Brush brush)
{ {
var rect = new Rectangle( var rect = new Rectangle(
node.Position.X - NODE_RADIUS, node.Position.X - NODE_RADIUS,
@@ -357,10 +404,14 @@ namespace AGVNavigationCore.Controls
g.DrawEllipse(new Pen(Color.Orange, 2), hoverRect); g.DrawEllipse(new Pen(Color.Orange, 2), hoverRect);
} }
DrawNodeLabel(g, node); // RFID 중복 노드 표시 (빨간 X자)
if (_duplicateRfidNodes.Contains(node.NodeId))
{
DrawDuplicateRfidMarker(g, node);
}
} }
private void DrawPentagonNode(Graphics g, MapNode node, Brush brush) private void DrawPentagonNodeShape(Graphics g, MapNode node, Brush brush)
{ {
var radius = NODE_RADIUS; var radius = NODE_RADIUS;
var center = node.Position; var center = node.Position;
@@ -421,10 +472,14 @@ namespace AGVNavigationCore.Controls
g.DrawPolygon(new Pen(Color.Orange, 2), hoverPoints); g.DrawPolygon(new Pen(Color.Orange, 2), hoverPoints);
} }
DrawNodeLabel(g, node); // RFID 중복 노드 표시 (빨간 X자)
if (_duplicateRfidNodes.Contains(node.NodeId))
{
DrawDuplicateRfidMarker(g, node);
}
} }
private void DrawTriangleNode(Graphics g, MapNode node, Brush brush) private void DrawTriangleNodeShape(Graphics g, MapNode node, Brush brush)
{ {
var radius = NODE_RADIUS; var radius = NODE_RADIUS;
var center = node.Position; var center = node.Position;
@@ -485,7 +540,11 @@ namespace AGVNavigationCore.Controls
g.DrawPolygon(new Pen(Color.Orange, 2), hoverPoints); g.DrawPolygon(new Pen(Color.Orange, 2), hoverPoints);
} }
DrawNodeLabel(g, node); // RFID 중복 노드 표시 (빨간 X자)
if (_duplicateRfidNodes.Contains(node.NodeId))
{
DrawDuplicateRfidMarker(g, node);
}
} }
private void DrawNodeLabel(Graphics g, MapNode node) private void DrawNodeLabel(Graphics g, MapNode node)
@@ -494,8 +553,8 @@ namespace AGVNavigationCore.Controls
Color textColor; Color textColor;
string descriptionText; string descriptionText;
// 위쪽에 표시할 설명 (노드의 Description 속성) // 위쪽에 표시할 이름 (노드의 Name 속성)
descriptionText = string.IsNullOrEmpty(node.Description) ? "" : node.Description; descriptionText = node.Name.EndsWith(node.NodeId) ? string.Empty : node.Name;
// 아래쪽에 표시할 값 (RFID 우선, 없으면 노드ID) // 아래쪽에 표시할 값 (RFID 우선, 없으면 노드ID)
if (node.HasRfid()) if (node.HasRfid())
@@ -511,39 +570,79 @@ namespace AGVNavigationCore.Controls
textColor = Color.Gray; textColor = Color.Gray;
} }
var font = new Font("Arial", 8, FontStyle.Bold); var font = new Font("Arial", 7, FontStyle.Bold);
var descFont = new Font("Arial", 6, FontStyle.Regular); var descFont = new Font("Arial", 8, FontStyle.Bold);
// 메인 텍스트 크기 측정 // 메인 텍스트 크기 측정
var textSize = g.MeasureString(displayText, font); var textSize = g.MeasureString(displayText, font);
var descSize = g.MeasureString(descriptionText, descFont); var descSize = g.MeasureString(descriptionText, descFont);
// 설명 텍스트 위치 (노드 위쪽) // 메인 텍스트 위치 (RFID는 노드 위쪽)
var descPoint = new Point(
(int)(node.Position.X - descSize.Width / 2),
(int)(node.Position.Y - NODE_RADIUS - descSize.Height - 2)
);
// 메인 텍스트 위치 (노드 아래쪽)
var textPoint = new Point( var textPoint = new Point(
(int)(node.Position.X - textSize.Width / 2), (int)(node.Position.X - textSize.Width / 2),
(int)(node.Position.Y - NODE_RADIUS - textSize.Height - 2)
);
// 설명 텍스트 위치 (설명은 노드 아래쪽)
var descPoint = new Point(
(int)(node.Position.X - descSize.Width / 2),
(int)(node.Position.Y + NODE_RADIUS + 2) (int)(node.Position.Y + NODE_RADIUS + 2)
); );
// 설명 텍스트 그리기 (설명이 있는 경우에만) // 설명 텍스트 그리기 (설명이 있는 경우에만)
if (!string.IsNullOrEmpty(descriptionText)) if (!string.IsNullOrEmpty(descriptionText))
{ {
using (var descBrush = new SolidBrush(Color.FromArgb(120, Color.Black))) // 노드 이름 입력 여부에 따라 색상 구분
// 입력된 경우: 진한 색상 (잘 보이게)
// 기본값인 경우: 흐린 색상 (현재처럼)
Color descColor = string.IsNullOrEmpty(node.Name)
? Color.FromArgb(120, Color.Black) // 입력 안됨: 흐린 색상
: Color.FromArgb(200, Color.Black); // 입력됨: 진한 색상
var rectpaddingx = 4;
var rectpaddingy = 2;
var roundRect = new Rectangle((int)(descPoint.X - rectpaddingx),
(int)(descPoint.Y),
(int)descSize.Width + rectpaddingx * 2,
(int)descSize.Height + rectpaddingy * 2);
// 라운드 사각형 그리기 (빨간 배경)
using (var backgroundBrush = new SolidBrush(Color.Gold))
{ {
g.DrawString(descriptionText, descFont, descBrush, descPoint); DrawRoundedRectangle(g, backgroundBrush, roundRect, 3); // 모서리 반지름 6px
}
// 라운드 사각형 테두리 그리기 (진한 빨간색)
using (var borderPen = new Pen(Color.DarkRed, 1))
{
DrawRoundedRectangleBorder(g, borderPen, roundRect, 3);
}
using (var descBrush = new SolidBrush(descColor))
{
g.DrawString(descriptionText, descFont, descBrush, roundRect, new StringFormat
{
Alignment = StringAlignment.Center,
LineAlignment = StringAlignment.Center,
});
} }
} }
// 메인 텍스트 그리기 // 메인 텍스트 그리기 (RFID 중복인 경우 특별 처리)
if (node.HasRfid() && _duplicateRfidNodes.Contains(node.NodeId))
{
// 중복 RFID 노드: 빨간 배경의 라운드 사각형
DrawDuplicateRfidLabel(g, displayText, textPoint, font);
}
else
{
// 일반 텍스트 그리기
using (var textBrush = new SolidBrush(textColor)) using (var textBrush = new SolidBrush(textColor))
{ {
g.DrawString(displayText, font, textBrush, textPoint); g.DrawString(displayText, font, textBrush, textPoint);
} }
}
font.Dispose(); font.Dispose();
descFont.Dispose(); descFont.Dispose();
@@ -1433,6 +1532,104 @@ namespace AGVNavigationCore.Controls
zoomFont.Dispose(); zoomFont.Dispose();
} }
/// <summary>
/// RFID 중복 노드에 빨간 X자 표시를 그림
/// </summary>
private void DrawDuplicateRfidMarker(Graphics g, MapNode node)
{
// X자를 그리기 위한 펜 (빨간색, 굵기 3)
using (var pen = new Pen(Color.Red, 3))
{
var center = node.Position;
var size = NODE_RADIUS; // 노드 반지름 크기로 X자 크기 설정
// X자의 두 대각선 그리기
// 좌상 → 우하 대각선
g.DrawLine(pen,
center.X - size, center.Y - size,
center.X + size, center.Y + size);
// 우상 → 좌하 대각선
g.DrawLine(pen,
center.X + size, center.Y - size,
center.X - size, center.Y + size);
}
}
/// <summary>
/// 중복 RFID 값을 빨간 배경의 라운드 사각형으로 표시
/// </summary>
private void DrawDuplicateRfidLabel(Graphics g, string text, Point position, Font font)
{
// 텍스트 크기 측정
var textSize = g.MeasureString(text, font);
// 라운드 사각형 영역 계산 (텍스트보다 약간 크게)
var padding = 2;
var rectWidth = (int)textSize.Width + padding * 2;
var rectHeight = (int)textSize.Height + padding * 2;
var rectX = position.X - padding;
var rectY = position.Y - padding - 5;
var roundRect = new Rectangle(rectX, rectY, rectWidth, rectHeight);
// 라운드 사각형 그리기 (빨간 배경)
using (var backgroundBrush = new SolidBrush(Color.Red))
{
DrawRoundedRectangle(g, backgroundBrush, roundRect, 6); // 모서리 반지름 6px
}
// 라운드 사각형 테두리 그리기 (진한 빨간색)
using (var borderPen = new Pen(Color.DarkRed, 1))
{
DrawRoundedRectangleBorder(g, borderPen, roundRect, 6);
}
// 흰색 텍스트 그리기
using (var textBrush = new SolidBrush(Color.White))
{
g.DrawString(text, font, textBrush, roundRect, new StringFormat
{
Alignment = StringAlignment.Center,
LineAlignment = StringAlignment.Center,
});
}
}
/// <summary>
/// 라운드 사각형 채우기
/// </summary>
private void DrawRoundedRectangle(Graphics g, Brush brush, Rectangle rect, int radius)
{
using (var path = new GraphicsPath())
{
path.AddArc(rect.X, rect.Y, radius * 2, radius * 2, 180, 90);
path.AddArc(rect.Right - radius * 2, rect.Y, radius * 2, radius * 2, 270, 90);
path.AddArc(rect.Right - radius * 2, rect.Bottom - radius * 2, radius * 2, radius * 2, 0, 90);
path.AddArc(rect.X, rect.Bottom - radius * 2, radius * 2, radius * 2, 90, 90);
path.CloseFigure();
g.FillPath(brush, path);
}
}
/// <summary>
/// 라운드 사각형 테두리 그리기
/// </summary>
private void DrawRoundedRectangleBorder(Graphics g, Pen pen, Rectangle rect, int radius)
{
using (var path = new GraphicsPath())
{
path.AddArc(rect.X, rect.Y, radius * 2, radius * 2, 180, 90);
path.AddArc(rect.Right - radius * 2, rect.Y, radius * 2, radius * 2, 270, 90);
path.AddArc(rect.Right - radius * 2, rect.Bottom - radius * 2, radius * 2, radius * 2, 0, 90);
path.AddArc(rect.X, rect.Bottom - radius * 2, radius * 2, radius * 2, 90, 90);
path.CloseFigure();
g.DrawPath(pen, path);
}
}
private Rectangle GetVisibleBounds() private Rectangle GetVisibleBounds()
{ {
var left = (int)(-_panOffset.X / _zoomFactor); var left = (int)(-_panOffset.X / _zoomFactor);

View File

@@ -22,7 +22,7 @@ namespace AGVNavigationCore.Controls
switch (_editMode) switch (_editMode)
{ {
case EditMode.Select: case EditMode.Select:
HandleSelectClick(hitNode); HandleSelectClick(hitNode, worldPoint);
break; break;
case EditMode.AddNode: case EditMode.AddNode:
@@ -36,6 +36,10 @@ namespace AGVNavigationCore.Controls
case EditMode.Delete: case EditMode.Delete:
HandleDeleteClick(hitNode); HandleDeleteClick(hitNode);
break; break;
case EditMode.DeleteConnection:
HandleDeleteConnectionClick(worldPoint);
break;
} }
} }
@@ -250,7 +254,10 @@ namespace AGVNavigationCore.Controls
private bool IsPointInCircle(Point point, MapNode node) private bool IsPointInCircle(Point point, MapNode node)
{ {
var hitRadius = Math.Max(NODE_RADIUS, 10 / _zoomFactor); // 화면에서 최소 20픽셀 정도의 히트 영역을 확보하되, 노드 크기보다 작아지지 않게 함
var minHitRadiusInScreen = 20;
var hitRadius = Math.Max(NODE_RADIUS, minHitRadiusInScreen / _zoomFactor);
var distance = Math.Sqrt( var distance = Math.Sqrt(
Math.Pow(node.Position.X - point.X, 2) + Math.Pow(node.Position.X - point.X, 2) +
Math.Pow(node.Position.Y - point.Y, 2) Math.Pow(node.Position.Y - point.Y, 2)
@@ -260,7 +267,9 @@ namespace AGVNavigationCore.Controls
private bool IsPointInPentagon(Point point, MapNode node) private bool IsPointInPentagon(Point point, MapNode node)
{ {
var radius = NODE_RADIUS; // 화면에서 최소 20픽셀 정도의 히트 영역을 확보
var minHitRadiusInScreen = 20;
var radius = Math.Max(NODE_RADIUS, minHitRadiusInScreen / _zoomFactor);
var center = node.Position; var center = node.Position;
// 5각형 꼭짓점 계산 // 5각형 꼭짓점 계산
@@ -279,7 +288,9 @@ namespace AGVNavigationCore.Controls
private bool IsPointInTriangle(Point point, MapNode node) private bool IsPointInTriangle(Point point, MapNode node)
{ {
var radius = NODE_RADIUS; // 화면에서 최소 20픽셀 정도의 히트 영역을 확보하되, 노드 크기보다 작아지지 않게 함
var minHitRadiusInScreen = 20;
var radius = Math.Max(NODE_RADIUS, minHitRadiusInScreen / _zoomFactor);
var center = node.Position; var center = node.Position;
// 삼각형 꼭짓점 계산 // 삼각형 꼭짓점 계산
@@ -378,8 +389,11 @@ namespace AGVNavigationCore.Controls
}); });
} }
private void HandleSelectClick(MapNode hitNode) private void HandleSelectClick(MapNode hitNode, Point worldPoint)
{ {
if (hitNode != null)
{
// 노드 선택
if (hitNode != _selectedNode) if (hitNode != _selectedNode)
{ {
_selectedNode = hitNode; _selectedNode = hitNode;
@@ -387,6 +401,53 @@ namespace AGVNavigationCore.Controls
Invalidate(); Invalidate();
} }
} }
else
{
// 노드가 없으면 연결선 체크
var connection = GetConnectionAt(worldPoint);
if (connection != null)
{
// 연결선을 클릭했을 때 삭제 확인
var (fromNode, toNode) = connection.Value;
string fromDisplay = !string.IsNullOrEmpty(fromNode.RfidId) ? fromNode.RfidId : fromNode.NodeId;
string toDisplay = !string.IsNullOrEmpty(toNode.RfidId) ? toNode.RfidId : toNode.NodeId;
var result = MessageBox.Show(
$"연결을 삭제하시겠습니까?\n\n{fromDisplay} ↔ {toDisplay}",
"연결 삭제 확인",
MessageBoxButtons.YesNo,
MessageBoxIcon.Question);
if (result == DialogResult.Yes)
{
// 단일 연결 삭제 (어느 방향에 저장되어 있는지 확인 후 삭제)
if (fromNode.ConnectedNodes.Contains(toNode.NodeId))
{
fromNode.RemoveConnection(toNode.NodeId);
}
else if (toNode.ConnectedNodes.Contains(fromNode.NodeId))
{
toNode.RemoveConnection(fromNode.NodeId);
}
// 이벤트 발생
ConnectionDeleted?.Invoke(this, (fromNode, toNode));
MapChanged?.Invoke(this, EventArgs.Empty);
Invalidate();
}
}
else
{
// 빈 공간 클릭 시 선택 해제
if (_selectedNode != null)
{
_selectedNode = null;
NodeSelected?.Invoke(this, null);
Invalidate();
}
}
}
}
private void HandleAddNodeClick(Point worldPoint) private void HandleAddNodeClick(Point worldPoint)
{ {
@@ -397,14 +458,16 @@ namespace AGVNavigationCore.Controls
worldPoint.Y = (worldPoint.Y / GRID_SIZE) * GRID_SIZE; worldPoint.Y = (worldPoint.Y / GRID_SIZE) * GRID_SIZE;
} }
// 고유한 NodeId 생성
string newNodeId = GenerateUniqueNodeId();
var newNode = new MapNode var newNode = new MapNode
{ {
NodeId = $"N{_nodeCounter:D3}", NodeId = newNodeId,
Position = worldPoint, Position = worldPoint,
Type = NodeType.Normal Type = NodeType.Normal
}; };
_nodeCounter++;
_nodes.Add(newNode); _nodes.Add(newNode);
NodeAdded?.Invoke(this, newNode); NodeAdded?.Invoke(this, newNode);
@@ -412,6 +475,25 @@ namespace AGVNavigationCore.Controls
Invalidate(); Invalidate();
} }
/// <summary>
/// 중복되지 않는 고유한 NodeId 생성
/// </summary>
private string GenerateUniqueNodeId()
{
string nodeId;
int counter = _nodeCounter;
do
{
nodeId = $"N{counter:D3}";
counter++;
}
while (_nodes.Any(n => n.NodeId == nodeId));
_nodeCounter = counter;
return nodeId;
}
private void HandleConnectClick(MapNode hitNode) private void HandleConnectClick(MapNode hitNode)
{ {
if (hitNode == null) return; if (hitNode == null) return;
@@ -458,11 +540,21 @@ namespace AGVNavigationCore.Controls
private void CreateConnection(MapNode fromNode, MapNode toNode) private void CreateConnection(MapNode fromNode, MapNode toNode)
{ {
// 중복 연결 체크 // 중복 연결 체크 (양방향)
if (fromNode.ConnectedNodes.Contains(toNode.NodeId)) if (fromNode.ConnectedNodes.Contains(toNode.NodeId) ||
toNode.ConnectedNodes.Contains(fromNode.NodeId))
return; return;
// 단일 연결 생성 (사전순으로 정렬하여 일관성 유지)
if (string.Compare(fromNode.NodeId, toNode.NodeId, StringComparison.Ordinal) < 0)
{
fromNode.AddConnection(toNode.NodeId); fromNode.AddConnection(toNode.NodeId);
}
else
{
toNode.AddConnection(fromNode.NodeId);
}
MapChanged?.Invoke(this, EventArgs.Empty); MapChanged?.Invoke(this, EventArgs.Empty);
} }
@@ -496,6 +588,91 @@ namespace AGVNavigationCore.Controls
_contextMenu.Show(this, location); _contextMenu.Show(this, location);
} }
private void HandleDeleteConnectionClick(Point worldPoint)
{
// 클릭한 위치 근처의 연결선을 찾아 삭제
var connection = GetConnectionAt(worldPoint);
if (connection != null)
{
var (fromNode, toNode) = connection.Value;
// 단일 연결 삭제 (어느 방향에 저장되어 있는지 확인 후 삭제)
if (fromNode.ConnectedNodes.Contains(toNode.NodeId))
{
fromNode.RemoveConnection(toNode.NodeId);
}
else if (toNode.ConnectedNodes.Contains(fromNode.NodeId))
{
toNode.RemoveConnection(fromNode.NodeId);
}
// 이벤트 발생
ConnectionDeleted?.Invoke(this, (fromNode, toNode));
MapChanged?.Invoke(this, EventArgs.Empty);
Invalidate();
}
}
private (MapNode From, MapNode To)? GetConnectionAt(Point worldPoint)
{
const int CONNECTION_HIT_TOLERANCE = 10;
// 모든 연결선을 확인하여 클릭한 위치와 가장 가까운 연결선 찾기
foreach (var fromNode in _nodes)
{
foreach (var toNodeId in fromNode.ConnectedNodes)
{
var toNode = _nodes.FirstOrDefault(n => n.NodeId == toNodeId);
if (toNode != null)
{
// 연결선과 클릭 위치 간의 거리 계산
var distance = CalculatePointToLineDistance(worldPoint, fromNode.Position, toNode.Position);
if (distance <= CONNECTION_HIT_TOLERANCE / _zoomFactor)
{
return (fromNode, toNode);
}
}
}
}
return null;
}
private float CalculatePointToLineDistance(Point point, Point lineStart, Point lineEnd)
{
// 점에서 선분까지의 거리 계산
var A = point.X - lineStart.X;
var B = point.Y - lineStart.Y;
var C = lineEnd.X - lineStart.X;
var D = lineEnd.Y - lineStart.Y;
var dot = A * C + B * D;
var lenSq = C * C + D * D;
if (lenSq == 0) return CalculateDistance(point, lineStart);
var param = dot / lenSq;
Point xx, yy;
if (param < 0)
{
xx = lineStart;
yy = lineStart;
}
else if (param > 1)
{
xx = lineEnd;
yy = lineEnd;
}
else
{
xx = new Point((int)(lineStart.X + param * C), (int)(lineStart.Y + param * D));
yy = xx;
}
return CalculateDistance(point, xx);
}
private void UpdateTooltip(Point worldPoint) private void UpdateTooltip(Point worldPoint)
{ {
string tooltipText = ""; string tooltipText = "";

View File

@@ -48,6 +48,7 @@ namespace AGVNavigationCore.Controls
AddNode, // 노드 추가 모드 AddNode, // 노드 추가 모드
Connect, // 연결 모드 Connect, // 연결 모드
Delete, // 삭제 모드 Delete, // 삭제 모드
DeleteConnection, // 연결 삭제 모드
AddLabel, // 라벨 추가 모드 AddLabel, // 라벨 추가 모드
AddImage // 이미지 추가 모드 AddImage // 이미지 추가 모드
} }
@@ -98,6 +99,11 @@ namespace AGVNavigationCore.Controls
// 자동 증가 카운터 // 자동 증가 카운터
private int _nodeCounter = 1; private int _nodeCounter = 1;
// 강조 연결
private (string FromNodeId, string ToNodeId)? _highlightedConnection = null;
// RFID 중복 검사
private HashSet<string> _duplicateRfidNodes = new HashSet<string>();
// 브러쉬 및 펜 // 브러쉬 및 펜
private Brush _normalNodeBrush; private Brush _normalNodeBrush;
@@ -118,6 +124,7 @@ namespace AGVNavigationCore.Controls
private Pen _destinationNodePen; private Pen _destinationNodePen;
private Pen _pathPen; private Pen _pathPen;
private Pen _agvPen; private Pen _agvPen;
private Pen _highlightedConnectionPen;
// 컨텍스트 메뉴 // 컨텍스트 메뉴
private ContextMenuStrip _contextMenu; private ContextMenuStrip _contextMenu;
@@ -131,6 +138,7 @@ namespace AGVNavigationCore.Controls
public event EventHandler<MapNode> NodeSelected; public event EventHandler<MapNode> NodeSelected;
public event EventHandler<MapNode> NodeDeleted; public event EventHandler<MapNode> NodeDeleted;
public event EventHandler<MapNode> NodeMoved; public event EventHandler<MapNode> NodeMoved;
public event EventHandler<(MapNode From, MapNode To)> ConnectionDeleted;
public event EventHandler MapChanged; public event EventHandler MapChanged;
// AGV 이벤트 // AGV 이벤트
@@ -215,6 +223,13 @@ namespace AGVNavigationCore.Controls
set set
{ {
_nodes = value ?? new List<MapNode>(); _nodes = value ?? new List<MapNode>();
// 기존 노드들의 최대 번호를 찾아서 _nodeCounter 설정
UpdateNodeCounter();
// RFID 중복값 검사
DetectDuplicateRfidNodes();
Invalidate(); Invalidate();
} }
} }
@@ -286,6 +301,45 @@ namespace AGVNavigationCore.Controls
} }
} }
#endregion
#region Connection Highlighting
/// <summary>
/// 특정 연결을 강조 표시
/// </summary>
/// <param name="fromNodeId">시작 노드 ID</param>
/// <param name="toNodeId">끝 노드 ID</param>
public void HighlightConnection(string fromNodeId, string toNodeId)
{
if (string.IsNullOrEmpty(fromNodeId) || string.IsNullOrEmpty(toNodeId))
{
_highlightedConnection = null;
}
else
{
// 사전순으로 정렬하여 저장 (연결이 단일 방향으로 저장되므로)
if (string.Compare(fromNodeId, toNodeId, StringComparison.Ordinal) <= 0)
{
_highlightedConnection = (fromNodeId, toNodeId);
}
else
{
_highlightedConnection = (toNodeId, fromNodeId);
}
}
Invalidate();
}
/// <summary>
/// 연결 강조 표시 해제
/// </summary>
public void ClearHighlightedConnection()
{
_highlightedConnection = null;
Invalidate();
}
#endregion #endregion
@@ -347,6 +401,7 @@ namespace AGVNavigationCore.Controls
_destinationNodePen = new Pen(Color.Orange, 4); _destinationNodePen = new Pen(Color.Orange, 4);
_pathPen = new Pen(Color.Purple, 3); _pathPen = new Pen(Color.Purple, 3);
_agvPen = new Pen(Color.Red, 3); _agvPen = new Pen(Color.Red, 3);
_highlightedConnectionPen = new Pen(Color.Red, 4) { DashStyle = DashStyle.Solid };
} }
private void CreateContextMenu() private void CreateContextMenu()
@@ -513,6 +568,7 @@ namespace AGVNavigationCore.Controls
_destinationNodePen?.Dispose(); _destinationNodePen?.Dispose();
_pathPen?.Dispose(); _pathPen?.Dispose();
_agvPen?.Dispose(); _agvPen?.Dispose();
_highlightedConnectionPen?.Dispose();
// 컨텍스트 메뉴 정리 // 컨텍스트 메뉴 정리
_contextMenu?.Dispose(); _contextMenu?.Dispose();
@@ -525,40 +581,74 @@ namespace AGVNavigationCore.Controls
} }
#endregion #endregion
}
#region Interfaces
/// <summary> /// <summary>
/// AGV 인터페이스 (가상/실제 AGV 통합) /// RFID 중복값을 가진 노드들을 감지하고 표시
/// 나중에 추가된 노드(인덱스가 더 큰)를 중복으로 간주
/// </summary> /// </summary>
public interface IAGV private void DetectDuplicateRfidNodes()
{ {
string AgvId { get; } _duplicateRfidNodes.Clear();
Point CurrentPosition { get; }
AgvDirection CurrentDirection { get; }
AGVState CurrentState { get; }
float BatteryLevel { get; }
// 이동 경로 정보 추가 if (_nodes == null || _nodes.Count == 0)
Point? TargetPosition { get; } return;
string CurrentNodeId { get; }
string TargetNodeId { get; } // RFID값과 해당 노드의 인덱스를 저장
DockingDirection DockingDirection { get; } var rfidToNodeIndex = new Dictionary<string, List<int>>();
// 모든 노드의 RFID값 수집
for (int i = 0; i < _nodes.Count; i++)
{
var node = _nodes[i];
if (!string.IsNullOrEmpty(node.RfidId))
{
if (!rfidToNodeIndex.ContainsKey(node.RfidId))
{
rfidToNodeIndex[node.RfidId] = new List<int>();
}
rfidToNodeIndex[node.RfidId].Add(i);
}
}
// 중복된 RFID를 가진 노드들을 찾아서 나중에 추가된 것들을 표시
foreach (var kvp in rfidToNodeIndex)
{
if (kvp.Value.Count > 1)
{
// 첫 번째 노드는 원본으로 유지, 나머지는 중복으로 표시
for (int i = 1; i < kvp.Value.Count; i++)
{
int duplicateNodeIndex = kvp.Value[i];
_duplicateRfidNodes.Add(_nodes[duplicateNodeIndex].NodeId);
}
}
}
} }
/// <summary> /// <summary>
/// AGV 상태 열거형 /// 기존 노드들의 최대 번호를 찾아서 _nodeCounter를 업데이트
/// </summary> /// </summary>
public enum AGVState private void UpdateNodeCounter()
{ {
Idle, // 대기 if (_nodes == null || _nodes.Count == 0)
Moving, // 이동 중 {
Rotating, // 회전 중 _nodeCounter = 1;
Docking, // 도킹 중 return;
Charging, // 충전 중 }
Error // 오류
int maxNumber = 0;
foreach (var node in _nodes)
{
// NodeId에서 숫자 부분 추출 (예: "N001" -> 1)
if (node.NodeId.StartsWith("N") && int.TryParse(node.NodeId.Substring(1), out int number))
{
maxNumber = Math.Max(maxNumber, number);
}
}
_nodeCounter = maxNumber + 1;
}
} }
#endregion
} }

View File

@@ -60,6 +60,15 @@ namespace AGVNavigationCore.Models
result.Version = mapData.Version ?? "1.0"; result.Version = mapData.Version ?? "1.0";
result.CreatedDate = mapData.CreatedDate; result.CreatedDate = mapData.CreatedDate;
// 기존 Description 데이터를 Name으로 마이그레이션
MigrateDescriptionToName(result.Nodes);
// 중복된 NodeId 정리
FixDuplicateNodeIds(result.Nodes);
// 중복 연결 정리 (양방향 중복 제거)
CleanupDuplicateConnections(result.Nodes);
// 이미지 노드들의 이미지 로드 // 이미지 노드들의 이미지 로드
LoadImageNodes(result.Nodes); LoadImageNodes(result.Nodes);
@@ -122,11 +131,170 @@ namespace AGVNavigationCore.Models
} }
/// <summary> /// <summary>
/// MapNode 목록에서 RFID가 없는 노드들에 자동으로 RFID ID를 할당합니다. /// 기존 Description 데이터를 Name 필드로 마이그레이션
/// JSON 파일에서 Description 필드가 있는 경우 Name으로 이동
/// </summary> /// </summary>
/// <param name="mapNodes">맵 노드 목록</param> /// <param name="mapNodes">맵 노드 목록</param>
private static void MigrateDescriptionToName(List<MapNode> mapNodes)
{
// JSON에서 Description이 있던 기존 파일들을 위한 마이그레이션
// 현재 MapNode 클래스에는 Description 속성이 제거되었으므로
// 이 메서드는 호환성을 위해 유지되지만 실제로는 작동하지 않음
// 기존 파일들은 다시 저장될 때 Description 없이 저장됨
}
/// <summary>
/// 중복된 NodeId를 가진 노드들을 고유한 NodeId로 수정
/// </summary>
/// <param name="mapNodes">맵 노드 목록</param>
private static void FixDuplicateNodeIds(List<MapNode> mapNodes)
{
if (mapNodes == null || mapNodes.Count == 0) return;
var usedIds = new HashSet<string>();
var duplicateNodes = new List<MapNode>();
// 첫 번째 패스: 중복된 노드들 식별
foreach (var node in mapNodes)
{
if (usedIds.Contains(node.NodeId))
{
duplicateNodes.Add(node);
}
else
{
usedIds.Add(node.NodeId);
}
}
// 두 번째 패스: 중복된 노드들에게 새로운 NodeId 할당
foreach (var duplicateNode in duplicateNodes)
{
string newNodeId = GenerateUniqueNodeId(usedIds);
// 다른 노드들의 연결에서 기존 NodeId를 새 NodeId로 업데이트
UpdateConnections(mapNodes, duplicateNode.NodeId, newNodeId);
duplicateNode.NodeId = newNodeId;
usedIds.Add(newNodeId);
}
}
/// <summary>
/// 사용되지 않는 고유한 NodeId 생성
/// </summary>
/// <param name="usedIds">이미 사용된 NodeId 목록</param>
/// <returns>고유한 NodeId</returns>
private static string GenerateUniqueNodeId(HashSet<string> usedIds)
{
int counter = 1;
string nodeId;
do
{
nodeId = $"N{counter:D3}";
counter++;
}
while (usedIds.Contains(nodeId));
return nodeId;
}
/// <summary>
/// 노드 연결에서 NodeId 변경사항 반영
/// </summary>
/// <param name="mapNodes">맵 노드 목록</param>
/// <param name="oldNodeId">기존 NodeId</param>
/// <param name="newNodeId">새로운 NodeId</param>
private static void UpdateConnections(List<MapNode> mapNodes, string oldNodeId, string newNodeId)
{
foreach (var node in mapNodes)
{
if (node.ConnectedNodes != null)
{
for (int i = 0; i < node.ConnectedNodes.Count; i++)
{
if (node.ConnectedNodes[i] == oldNodeId)
{
node.ConnectedNodes[i] = newNodeId;
}
}
}
}
}
/// <summary>
/// 중복 연결을 정리합니다. 양방향 중복 연결을 단일 연결로 통합합니다.
/// </summary>
/// <param name="mapNodes">맵 노드 목록</param>
private static void CleanupDuplicateConnections(List<MapNode> mapNodes)
{
if (mapNodes == null || mapNodes.Count == 0) return;
var processedPairs = new HashSet<string>();
foreach (var node in mapNodes)
{
var connectionsToRemove = new List<string>();
foreach (var connectedNodeId in node.ConnectedNodes.ToList())
{
var connectedNode = mapNodes.FirstOrDefault(n => n.NodeId == connectedNodeId);
if (connectedNode == null) continue;
// 연결 쌍의 키 생성 (사전순 정렬)
string pairKey = string.Compare(node.NodeId, connectedNodeId, StringComparison.Ordinal) < 0
? $"{node.NodeId}-{connectedNodeId}"
: $"{connectedNodeId}-{node.NodeId}";
if (processedPairs.Contains(pairKey))
{
// 이미 처리된 연결인 경우 중복으로 간주하고 제거
connectionsToRemove.Add(connectedNodeId);
}
else
{
// 처리되지 않은 연결인 경우
processedPairs.Add(pairKey);
// 양방향 연결인 경우 하나만 유지
if (connectedNode.ConnectedNodes.Contains(node.NodeId))
{
// 사전순으로 더 작은 노드에만 연결을 유지
if (string.Compare(node.NodeId, connectedNodeId, StringComparison.Ordinal) > 0)
{
connectionsToRemove.Add(connectedNodeId);
}
else
{
// 반대 방향 연결 제거
connectedNode.RemoveConnection(node.NodeId);
}
}
}
}
// 중복 연결 제거
foreach (var connectionToRemove in connectionsToRemove)
{
node.RemoveConnection(connectionToRemove);
}
}
}
/// <summary>
/// MapNode 목록에서 RFID가 없는 노드들에 자동으로 RFID ID를 할당합니다.
/// *** 에디터와 시뮬레이터 데이터 불일치 방지를 위해 비활성화됨 ***
/// </summary>
/// <param name="mapNodes">맵 노드 목록</param>
[Obsolete("RFID 자동 할당은 에디터와 시뮬레이터 간 데이터 불일치를 야기하므로 사용하지 않음")]
public static void AssignAutoRfidIds(List<MapNode> mapNodes) public static void AssignAutoRfidIds(List<MapNode> mapNodes)
{ {
// 에디터에서 설정한 RFID 값을 그대로 사용하기 위해 자동 할당 기능 비활성화
// 에디터와 시뮬레이터 간 데이터 일관성 유지를 위함
return;
/*
foreach (var node in mapNodes) foreach (var node in mapNodes)
{ {
// 네비게이션 가능한 노드이면서 RFID가 없는 경우에만 자동 할당 // 네비게이션 가능한 노드이면서 RFID가 없는 경우에만 자동 할당
@@ -138,6 +306,7 @@ namespace AGVNavigationCore.Models
node.SetRfidInfo(rfidId, "", "정상"); node.SetRfidInfo(rfidId, "", "정상");
} }
} }
*/
} }
} }

View File

@@ -69,10 +69,6 @@ namespace AGVNavigationCore.Models
/// </summary> /// </summary>
public DateTime ModifiedDate { get; set; } = DateTime.Now; public DateTime ModifiedDate { get; set; } = DateTime.Now;
/// <summary>
/// 노드 설명 (추가 정보)
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary> /// <summary>
/// 노드 활성화 여부 /// 노드 활성화 여부
@@ -289,9 +285,9 @@ namespace AGVNavigationCore.Models
{ {
var displayText = NodeId; var displayText = NodeId;
if (!string.IsNullOrEmpty(Description)) if (!string.IsNullOrEmpty(Name))
{ {
displayText += $" - {Description}"; displayText += $" - {Name}";
} }
if (!string.IsNullOrEmpty(RfidId)) if (!string.IsNullOrEmpty(RfidId))
@@ -322,7 +318,6 @@ namespace AGVNavigationCore.Models
StationType = StationType, StationType = StationType,
CreatedDate = CreatedDate, CreatedDate = CreatedDate,
ModifiedDate = ModifiedDate, ModifiedDate = ModifiedDate,
Description = Description,
IsActive = IsActive, IsActive = IsActive,
DisplayColor = DisplayColor, DisplayColor = DisplayColor,
RfidId = RfidId, RfidId = RfidId,

View File

@@ -40,6 +40,11 @@ namespace AGVNavigationCore.PathFinding
/// </summary> /// </summary>
public long CalculationTimeMs { get; set; } public long CalculationTimeMs { get; set; }
/// <summary>
/// 탐색된 노드 수
/// </summary>
public int ExploredNodes { get; set; }
/// <summary> /// <summary>
/// 예상 소요 시간 (초) /// 예상 소요 시간 (초)
/// </summary> /// </summary>
@@ -66,6 +71,7 @@ namespace AGVNavigationCore.PathFinding
NodeMotorInfos = new List<NodeMotorInfo>(); NodeMotorInfos = new List<NodeMotorInfo>();
TotalDistance = 0; TotalDistance = 0;
CalculationTimeMs = 0; CalculationTimeMs = 0;
ExploredNodes = 0;
EstimatedTimeSeconds = 0; EstimatedTimeSeconds = 0;
RotationCount = 0; RotationCount = 0;
ErrorMessage = string.Empty; ErrorMessage = string.Empty;

View File

@@ -28,6 +28,11 @@ namespace AGVNavigationCore.PathFinding
/// </summary> /// </summary>
public float DockingApproachDistance { get; set; } = 100.0f; public float DockingApproachDistance { get; set; } = 100.0f;
/// <summary>
/// 경로 탐색 옵션
/// </summary>
public PathfindingOptions Options { get; set; } = PathfindingOptions.Default;
/// <summary> /// <summary>
/// 생성자 /// 생성자
/// </summary> /// </summary>
@@ -60,6 +65,46 @@ namespace AGVNavigationCore.PathFinding
/// <param name="targetDirection">목적지 도착 방향 (null이면 자동 결정)</param> /// <param name="targetDirection">목적지 도착 방향 (null이면 자동 결정)</param>
/// <returns>AGV 경로 계산 결과</returns> /// <returns>AGV 경로 계산 결과</returns>
public AGVPathResult FindAGVPath(string startNodeId, string endNodeId, AgvDirection? targetDirection = null) public AGVPathResult FindAGVPath(string startNodeId, string endNodeId, AgvDirection? targetDirection = null)
{
return FindAGVPath(startNodeId, endNodeId, targetDirection, Options);
}
/// <summary>
/// AGV 경로 계산 (현재 방향 및 옵션 지정 가능)
/// </summary>
/// <param name="startNodeId">시작 노드 ID</param>
/// <param name="endNodeId">목적지 노드 ID</param>
/// <param name="currentDirection">현재 AGV 방향</param>
/// <param name="targetDirection">목적지 도착 방향 (null이면 자동 결정)</param>
/// <param name="options">경로 탐색 옵션</param>
/// <returns>AGV 경로 계산 결과</returns>
public AGVPathResult FindAGVPath(string startNodeId, string endNodeId, AgvDirection? currentDirection, AgvDirection? targetDirection, PathfindingOptions options)
{
var result = FindAGVPath(startNodeId, endNodeId, targetDirection, options);
if (!result.Success || currentDirection == null || result.Commands.Count == 0)
return result;
// 경로의 첫 번째 방향과 현재 방향 비교
var firstDirection = result.Commands[0];
if (RequiresDirectionChange(currentDirection.Value, firstDirection))
{
return InsertDirectionChangeCommands(result, currentDirection.Value, firstDirection);
}
return result;
}
/// <summary>
/// AGV 경로 계산 (옵션 지정 가능)
/// </summary>
/// <param name="startNodeId">시작 노드 ID</param>
/// <param name="endNodeId">목적지 노드 ID</param>
/// <param name="targetDirection">목적지 도착 방향 (null이면 자동 결정)</param>
/// <param name="options">경로 탐색 옵션</param>
/// <returns>AGV 경로 계산 결과</returns>
public AGVPathResult FindAGVPath(string startNodeId, string endNodeId, AgvDirection? targetDirection, PathfindingOptions options)
{ {
var stopwatch = System.Diagnostics.Stopwatch.StartNew(); var stopwatch = System.Diagnostics.Stopwatch.StartNew();
@@ -79,11 +124,11 @@ namespace AGVNavigationCore.PathFinding
if (IsSpecialNode(endNode)) if (IsSpecialNode(endNode))
{ {
return FindPathToSpecialNode(startNodeId, endNode, targetDirection, stopwatch); return FindPathToSpecialNode(startNodeId, endNode, targetDirection, options, stopwatch);
} }
else else
{ {
return FindNormalPath(startNodeId, endNodeId, targetDirection, stopwatch); return FindNormalPath(startNodeId, endNodeId, targetDirection, options, stopwatch);
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -150,9 +195,26 @@ namespace AGVNavigationCore.PathFinding
/// <summary> /// <summary>
/// 일반 노드로의 경로 계산 /// 일반 노드로의 경로 계산
/// </summary> /// </summary>
private AGVPathResult FindNormalPath(string startNodeId, string endNodeId, AgvDirection? targetDirection, System.Diagnostics.Stopwatch stopwatch) private AGVPathResult FindNormalPath(string startNodeId, string endNodeId, AgvDirection? targetDirection, PathfindingOptions options, System.Diagnostics.Stopwatch stopwatch)
{ {
var result = _pathfinder.FindPath(startNodeId, endNodeId); PathResult result;
// 회전 회피 옵션이 활성화되어 있으면 회전 노드 회피 시도
if (options.AvoidRotationNodes)
{
result = FindPathAvoidingRotation(startNodeId, endNodeId, options);
// 회전 회피 경로를 찾지 못하면 일반 경로 계산
if (!result.Success)
{
result = _pathfinder.FindPath(startNodeId, endNodeId);
}
}
else
{
result = _pathfinder.FindPath(startNodeId, endNodeId);
}
if (!result.Success) if (!result.Success)
{ {
return AGVPathResult.CreateFailure(result.ErrorMessage, stopwatch.ElapsedMilliseconds); return AGVPathResult.CreateFailure(result.ErrorMessage, stopwatch.ElapsedMilliseconds);
@@ -166,12 +228,29 @@ namespace AGVNavigationCore.PathFinding
/// <summary> /// <summary>
/// 특수 노드(도킹/충전)로의 경로 계산 /// 특수 노드(도킹/충전)로의 경로 계산
/// </summary> /// </summary>
private AGVPathResult FindPathToSpecialNode(string startNodeId, MapNode endNode, AgvDirection? targetDirection, System.Diagnostics.Stopwatch stopwatch) private AGVPathResult FindPathToSpecialNode(string startNodeId, MapNode endNode, AgvDirection? targetDirection, PathfindingOptions options, System.Diagnostics.Stopwatch stopwatch)
{ {
var requiredDirection = GetRequiredDirectionForNode(endNode); var requiredDirection = GetRequiredDirectionForNode(endNode);
var actualTargetDirection = targetDirection ?? requiredDirection; var actualTargetDirection = targetDirection ?? requiredDirection;
var result = _pathfinder.FindPath(startNodeId, endNode.NodeId); PathResult result;
// 회전 회피 옵션이 활성화되어 있으면 회전 노드 회피 시도
if (options.AvoidRotationNodes)
{
result = FindPathAvoidingRotation(startNodeId, endNode.NodeId, options);
// 회전 회피 경로를 찾지 못하면 일반 경로 계산
if (!result.Success)
{
result = _pathfinder.FindPath(startNodeId, endNode.NodeId);
}
}
else
{
result = _pathfinder.FindPath(startNodeId, endNode.NodeId);
}
if (!result.Success) if (!result.Success)
{ {
return AGVPathResult.CreateFailure(result.ErrorMessage, stopwatch.ElapsedMilliseconds); return AGVPathResult.CreateFailure(result.ErrorMessage, stopwatch.ElapsedMilliseconds);
@@ -267,7 +346,7 @@ namespace AGVNavigationCore.PathFinding
} }
/// <summary> /// <summary>
/// 노드별 모터방향 정보 생성 /// 노드별 모터방향 정보 생성 (방향 전환 로직 개선)
/// </summary> /// </summary>
/// <param name="path">경로 노드 목록</param> /// <param name="path">경로 노드 목록</param>
/// <returns>노드별 모터방향 정보 목록</returns> /// <returns>노드별 모터방향 정보 목록</returns>
@@ -276,40 +355,236 @@ namespace AGVNavigationCore.PathFinding
var nodeMotorInfos = new List<NodeMotorInfo>(); var nodeMotorInfos = new List<NodeMotorInfo>();
if (path.Count < 2) return nodeMotorInfos; if (path.Count < 2) return nodeMotorInfos;
// 전체 경로에 대한 방향 전환 계획 수립
var directionPlan = PlanDirectionChanges(path);
for (int i = 0; i < path.Count; i++) for (int i = 0; i < path.Count; i++)
{ {
var currentNodeId = path[i]; var currentNodeId = path[i];
string nextNodeId = i < path.Count - 1 ? path[i + 1] : null; string nextNodeId = i < path.Count - 1 ? path[i + 1] : null;
AgvDirection motorDirection; // 계획된 방향 사용
var motorDirection = directionPlan.ContainsKey(currentNodeId)
? directionPlan[currentNodeId]
: AgvDirection.Forward;
// 노드 특성 정보 수집
bool canRotate = false;
bool isDirectionChangePoint = false;
bool requiresSpecialAction = false;
string specialActionDescription = "";
if (i == path.Count - 1)
{
// 마지막 노드: 도킹/충전 노드 타입에 따라 결정
if (_nodeMap.ContainsKey(currentNodeId)) if (_nodeMap.ContainsKey(currentNodeId))
{ {
var currentNode = _nodeMap[currentNodeId]; var currentNode = _nodeMap[currentNodeId];
motorDirection = GetRequiredDirectionForNode(currentNode); canRotate = currentNode.CanRotate;
}
else // 방향 전환 감지
if (i > 0 && directionPlan.ContainsKey(path[i - 1]))
{ {
motorDirection = AgvDirection.Forward; var prevDirection = directionPlan[path[i - 1]];
} isDirectionChangePoint = prevDirection != motorDirection;
}
else
{
// 중간 노드: 다음 노드와의 관계를 고려한 모터방향 결정
motorDirection = CalculateMotorDirection(currentNodeId, nextNodeId);
} }
nodeMotorInfos.Add(new NodeMotorInfo(currentNodeId, motorDirection, nextNodeId)); // 특수 동작 필요 여부 감지
if (!canRotate && isDirectionChangePoint)
{
requiresSpecialAction = true;
specialActionDescription = "갈림길 전진/후진 반복";
}
else if (canRotate && isDirectionChangePoint)
{
specialActionDescription = "회전 노드 방향전환";
}
}
var nodeMotorInfo = new NodeMotorInfo(currentNodeId, motorDirection, nextNodeId,
canRotate, isDirectionChangePoint, MagnetDirection.Straight, requiresSpecialAction, specialActionDescription);
nodeMotorInfos.Add(nodeMotorInfo);
} }
return nodeMotorInfos; return nodeMotorInfos;
} }
/// <summary> /// <summary>
/// 현재 노드에서 다음 노드로 이동할 때의 모터방향 계산 /// 경로 전체에 대한 방향 전환 계획 수립
/// </summary>
/// <param name="path">경로 노드 목록</param>
/// <returns>노드별 모터 방향 계획</returns>
private Dictionary<string, AgvDirection> PlanDirectionChanges(List<string> path)
{
var directionPlan = new Dictionary<string, AgvDirection>();
if (path.Count < 2) return directionPlan;
// 1단계: 목적지 노드의 요구사항 분석
var targetNodeId = path[path.Count - 1];
var targetDirection = AgvDirection.Forward;
if (_nodeMap.ContainsKey(targetNodeId))
{
var targetNode = _nodeMap[targetNodeId];
targetDirection = GetRequiredDirectionForNode(targetNode);
}
// 2단계: 역방향으로 방향 전환점 찾기
var currentDirection = targetDirection;
directionPlan[targetNodeId] = currentDirection;
// 마지막에서 두 번째부터 역순으로 처리
for (int i = path.Count - 2; i >= 0; i--)
{
var currentNodeId = path[i];
var nextNodeId = path[i + 1];
if (_nodeMap.ContainsKey(currentNodeId))
{
var currentNode = _nodeMap[currentNodeId];
// 방법1: 회전 가능 노드에서 방향 전환
if (currentNode.CanRotate && NeedDirectionChange(currentNodeId, nextNodeId, currentDirection))
{
// 회전 가능한 노드에서 방향 전환 수행
var optimalDirection = CalculateOptimalDirection(currentNodeId, nextNodeId, path, i);
directionPlan[currentNodeId] = optimalDirection;
currentDirection = optimalDirection;
}
else
{
// 일반 노드: 연속성 유지
directionPlan[currentNodeId] = currentDirection;
}
}
else
{
directionPlan[currentNodeId] = currentDirection;
}
}
// 3단계: 방법2 적용 - 불가능한 방향 전환 감지 및 수정
ApplyDirectionChangeCorrection(path, directionPlan);
return directionPlan;
}
/// <summary>
/// 방향 전환이 필요한지 판단
/// </summary>
private bool NeedDirectionChange(string currentNodeId, string nextNodeId, AgvDirection currentDirection)
{
if (!_nodeMap.ContainsKey(nextNodeId)) return false;
var nextNode = _nodeMap[nextNodeId];
var requiredDirection = GetRequiredDirectionForNode(nextNode);
return currentDirection != requiredDirection;
}
/// <summary>
/// 회전 가능 노드에서 최적 방향 계산
/// </summary>
private AgvDirection CalculateOptimalDirection(string nodeId, string nextNodeId, List<string> path, int nodeIndex)
{
if (!_nodeMap.ContainsKey(nextNodeId)) return AgvDirection.Forward;
var nextNode = _nodeMap[nextNodeId];
// 목적지까지의 경로를 고려한 최적 방향 결정
if (nextNode.Type == NodeType.Charging)
{
return AgvDirection.Forward; // 충전기는 전진 접근
}
else if (nextNode.Type == NodeType.Docking)
{
return AgvDirection.Backward; // 도킹은 후진 접근
}
else
{
// 경로 패턴 분석을 통한 방향 결정
return AnalyzePathPattern(path, nodeIndex);
}
}
/// <summary>
/// 경로 패턴 분석을 통한 방향 결정
/// </summary>
private AgvDirection AnalyzePathPattern(List<string> path, int startIndex)
{
// 남은 경로에서 도킹/충전 스테이션이 있는지 확인
for (int i = startIndex + 1; i < path.Count; i++)
{
if (_nodeMap.ContainsKey(path[i]))
{
var node = _nodeMap[path[i]];
if (node.Type == NodeType.Docking)
{
return AgvDirection.Backward; // 도킹 준비를 위해 후진 방향
}
else if (node.Type == NodeType.Charging)
{
return AgvDirection.Forward; // 충전 준비를 위해 전진 방향
}
}
}
return AgvDirection.Forward; // 기본값
}
/// <summary>
/// 방법2: 불가능한 방향 전환 감지 및 보정 (갈림길 전진/후진 반복)
/// </summary>
private void ApplyDirectionChangeCorrection(List<string> path, Dictionary<string, AgvDirection> directionPlan)
{
for (int i = 0; i < path.Count - 1; i++)
{
var currentNodeId = path[i];
var nextNodeId = path[i + 1];
if (directionPlan.ContainsKey(currentNodeId) && directionPlan.ContainsKey(nextNodeId))
{
var currentDir = directionPlan[currentNodeId];
var nextDir = directionPlan[nextNodeId];
// 급격한 방향 전환 감지 (전진→후진, 후진→전진)
if (IsImpossibleDirectionChange(currentDir, nextDir))
{
var currentNode = _nodeMap.ContainsKey(currentNodeId) ? _nodeMap[currentNodeId] : null;
if (currentNode != null && !currentNode.CanRotate)
{
// 회전 불가능한 노드에서 방향 전환 시도 → 특수 동작 추가
AddTurnAroundSequence(currentNodeId, directionPlan);
}
}
}
}
}
/// <summary>
/// 불가능한 방향 전환인지 확인
/// </summary>
private bool IsImpossibleDirectionChange(AgvDirection current, AgvDirection next)
{
return (current == AgvDirection.Forward && next == AgvDirection.Backward) ||
(current == AgvDirection.Backward && next == AgvDirection.Forward);
}
/// <summary>
/// 회전 불가능한 노드에서 방향 전환을 위한 특수 동작 시퀀스 추가
/// </summary>
private void AddTurnAroundSequence(string nodeId, Dictionary<string, AgvDirection> directionPlan)
{
// 갈림길에서 전진/후진 반복 작업으로 방향 변경
// 실제 구현시에는 NodeMotorInfo에 특수 플래그나 추가 동작 정보 포함 필요
// 현재는 안전한 방향으로 보정
if (directionPlan.ContainsKey(nodeId))
{
// 일단 연속성을 유지하도록 보정 (실제로는 특수 회전 동작 필요)
directionPlan[nodeId] = AgvDirection.Forward;
}
}
/// <summary>
/// 현재 노드에서 다음 노드로 이동할 때의 모터방향 계산 (레거시 - 새로운 PlanDirectionChanges 사용)
/// </summary> /// </summary>
/// <param name="currentNodeId">현재 노드 ID</param> /// <param name="currentNodeId">현재 노드 ID</param>
/// <param name="nextNodeId">다음 노드 ID</param> /// <param name="nextNodeId">다음 노드 ID</param>
@@ -321,18 +596,8 @@ namespace AGVNavigationCore.PathFinding
return AgvDirection.Forward; return AgvDirection.Forward;
} }
var currentNode = _nodeMap[currentNodeId];
var nextNode = _nodeMap[nextNodeId]; var nextNode = _nodeMap[nextNodeId];
// 현재 노드와 다음 노드의 위치를 기반으로 이동 방향 계산
var dx = nextNode.Position.X - currentNode.Position.X;
var dy = nextNode.Position.Y - currentNode.Position.Y;
var moveAngle = Math.Atan2(dy, dx);
// AGV의 구조: 리프트 ↔ AGV 몸체 ↔ 모니터
// 전진: 모니터 방향으로 이동 (리프트에서 멀어짐)
// 후진: 리프트 방향으로 이동 (리프트에 가까워짐)
// 다음 노드가 특수 노드인지 확인 // 다음 노드가 특수 노드인지 확인
if (nextNode.Type == NodeType.Charging) if (nextNode.Type == NodeType.Charging)
{ {
@@ -346,12 +611,56 @@ namespace AGVNavigationCore.PathFinding
} }
else else
{ {
// 일반 이동: 기본적으로 전진 // 일반 이동: 기본적으로 전진 (실제로는 PlanDirectionChanges에서 결정됨)
// 향후 경로 패턴 분석을 통해 더 정확한 방향 결정 가능
return AgvDirection.Forward; return AgvDirection.Forward;
} }
} }
/// <summary>
/// 회전 노드를 회피하는 경로 탐색
/// </summary>
/// <param name="startNodeId">시작 노드 ID</param>
/// <param name="endNodeId">목적지 노드 ID</param>
/// <param name="options">경로 탐색 옵션</param>
/// <returns>회전 회피 경로 결과</returns>
private PathResult FindPathAvoidingRotation(string startNodeId, string endNodeId, PathfindingOptions options)
{
try
{
// 회전 가능한 노드들을 필터링하여 임시로 비활성화
var rotationNodes = _nodeMap.Values.Where(n => n.CanRotate).Select(n => n.NodeId).ToList();
var originalConnections = new Dictionary<string, List<string>>();
// 시작과 끝 노드가 회전 노드인 경우는 제외
var nodesToDisable = rotationNodes.Where(nodeId => nodeId != startNodeId && nodeId != endNodeId).ToList();
foreach (var nodeId in nodesToDisable)
{
// 임시로 연결 해제 (실제로는 pathfinder에서 해당 노드를 높은 비용으로 설정해야 함)
originalConnections[nodeId] = _nodeMap[nodeId].ConnectedNodes.ToList();
}
// 회전 노드 회피 경로 계산 시도
var result = _pathfinder.FindPath(startNodeId, endNodeId);
// 결과에서 회전 노드가 포함되어 있으면 실패로 간주
if (result.Success && result.Path != null)
{
var pathContainsRotation = result.Path.Any(nodeId => nodesToDisable.Contains(nodeId));
if (pathContainsRotation)
{
return PathResult.CreateFailure("회전 노드를 회피하는 경로를 찾을 수 없습니다.", 0, 0);
}
}
return result;
}
catch (Exception ex)
{
return PathResult.CreateFailure($"회전 회피 경로 계산 중 오류: {ex.Message}", 0, 0);
}
}
/// <summary> /// <summary>
/// 경로 유효성 검증 /// 경로 유효성 검증
/// </summary> /// </summary>
@@ -371,5 +680,149 @@ namespace AGVNavigationCore.PathFinding
return true; return true;
} }
/// <summary>
/// 현재 방향에서 목표 방향으로 변경하기 위해 방향 전환이 필요한지 판단
/// </summary>
/// <param name="currentDirection">현재 방향</param>
/// <param name="targetDirection">목표 방향</param>
/// <returns>방향 전환 필요 여부</returns>
private bool RequiresDirectionChange(AgvDirection currentDirection, AgvDirection targetDirection)
{
// 같은 방향이면 변경 불필요
if (currentDirection == targetDirection)
return false;
// 전진 <-> 후진 전환은 방향 전환 필요
return (currentDirection == AgvDirection.Forward && targetDirection == AgvDirection.Backward) ||
(currentDirection == AgvDirection.Backward && targetDirection == AgvDirection.Forward);
}
/// <summary>
/// 방향 전환을 위한 명령어를 기존 경로에 삽입
/// </summary>
/// <param name="originalResult">원래 경로 결과</param>
/// <param name="currentDirection">현재 AGV 방향</param>
/// <param name="targetDirection">목표 방향</param>
/// <returns>방향 전환 명령이 포함된 경로 결과</returns>
private AGVPathResult InsertDirectionChangeCommands(AGVPathResult originalResult, AgvDirection currentDirection, AgvDirection targetDirection)
{
if (originalResult.Path.Count < 1)
return originalResult;
// 시작 노드를 찾아서 회전 가능한지 확인
var startNodeId = originalResult.Path[0];
if (!_nodeMap.ContainsKey(startNodeId))
return originalResult;
var startNode = _nodeMap[startNodeId];
// 시작 노드에서 회전이 불가능하면 회전 가능한 가까운 노드 찾기
if (!startNode.CanRotate)
{
var rotationNode = FindNearestRotationNode(startNodeId);
if (rotationNode == null)
{
return AGVPathResult.CreateFailure("방향 전환을 위한 회전 가능 노드를 찾을 수 없습니다.", originalResult.CalculationTimeMs);
}
// 회전 노드로의 경로 추가
var pathToRotationNode = _pathfinder.FindPath(startNodeId, rotationNode.NodeId);
if (!pathToRotationNode.Success)
{
return AGVPathResult.CreateFailure("방향 전환 노드로의 경로를 찾을 수 없습니다.", originalResult.CalculationTimeMs);
}
// 회전 노드에서 원래 목적지로의 경로 계산
var pathFromRotationNode = _pathfinder.FindPath(rotationNode.NodeId, originalResult.Path.Last());
if (!pathFromRotationNode.Success)
{
return AGVPathResult.CreateFailure("방향 전환 후 목적지로의 경로를 찾을 수 없습니다.", originalResult.CalculationTimeMs);
}
// 전체 경로 조합
var combinedPath = new List<string>();
combinedPath.AddRange(pathToRotationNode.Path);
combinedPath.AddRange(pathFromRotationNode.Path.Skip(1)); // 중복 노드 제거
var combinedDistance = pathToRotationNode.TotalDistance + pathFromRotationNode.TotalDistance;
var combinedCommands = GenerateAGVCommandsWithDirectionChange(combinedPath, currentDirection, targetDirection, rotationNode.NodeId);
var nodeMotorInfos = GenerateNodeMotorInfos(combinedPath);
return AGVPathResult.CreateSuccess(combinedPath, combinedCommands, nodeMotorInfos, combinedDistance, originalResult.CalculationTimeMs);
}
else
{
// 시작 노드에서 바로 방향 전환
var commandsWithRotation = GenerateAGVCommandsWithDirectionChange(originalResult.Path, currentDirection, targetDirection, startNodeId);
return AGVPathResult.CreateSuccess(originalResult.Path, commandsWithRotation, originalResult.NodeMotorInfos, originalResult.TotalDistance, originalResult.CalculationTimeMs);
}
}
/// <summary>
/// 가장 가까운 회전 가능 노드 찾기
/// </summary>
/// <param name="fromNodeId">시작 노드 ID</param>
/// <returns>가장 가까운 회전 가능 노드</returns>
private MapNode FindNearestRotationNode(string fromNodeId)
{
var rotationNodes = _nodeMap.Values.Where(n => n.CanRotate && n.IsActive).ToList();
MapNode nearestNode = null;
var shortestDistance = float.MaxValue;
foreach (var rotationNode in rotationNodes)
{
var pathResult = _pathfinder.FindPath(fromNodeId, rotationNode.NodeId);
if (pathResult.Success && pathResult.TotalDistance < shortestDistance)
{
shortestDistance = pathResult.TotalDistance;
nearestNode = rotationNode;
}
}
return nearestNode;
}
/// <summary>
/// 방향 전환을 포함한 AGV 명령어 생성
/// </summary>
/// <param name="path">경로</param>
/// <param name="currentDirection">현재 방향</param>
/// <param name="targetDirection">목표 방향</param>
/// <param name="rotationNodeId">방향 전환 노드 ID</param>
/// <returns>AGV 명령어 목록</returns>
private List<AgvDirection> GenerateAGVCommandsWithDirectionChange(List<string> path, AgvDirection currentDirection, AgvDirection targetDirection, string rotationNodeId)
{
var commands = new List<AgvDirection>();
int rotationIndex = path.IndexOf(rotationNodeId);
// 회전 노드까지는 현재 방향으로 이동
for (int i = 0; i < rotationIndex; i++)
{
commands.Add(currentDirection);
}
// 회전 명령 추가 (전진->후진 또는 후진->전진)
if (currentDirection == AgvDirection.Forward && targetDirection == AgvDirection.Backward)
{
commands.Add(AgvDirection.Left);
commands.Add(AgvDirection.Left); // 180도 회전
}
else if (currentDirection == AgvDirection.Backward && targetDirection == AgvDirection.Forward)
{
commands.Add(AgvDirection.Right);
commands.Add(AgvDirection.Right); // 180도 회전
}
// 회전 노드부터 목적지까지는 목표 방향으로 이동
for (int i = rotationIndex; i < path.Count - 1; i++)
{
commands.Add(targetDirection);
}
return commands;
}
} }
} }

View File

@@ -43,40 +43,38 @@ namespace AGVNavigationCore.PathFinding
_mapNodes = mapNodes ?? new List<MapNode>(); _mapNodes = mapNodes ?? new List<MapNode>();
_nodeMap.Clear(); _nodeMap.Clear();
// 1단계: 모든 네비게이션 노드를 PathNode로 변환 // 모든 네비게이션 노드를 PathNode로 변환하고 양방향 연결 생성
foreach (var mapNode in _mapNodes) foreach (var mapNode in _mapNodes)
{ {
if (mapNode.IsNavigationNode()) if (mapNode.IsNavigationNode())
{ {
var pathNode = new PathNode(mapNode.NodeId, mapNode.Position); var pathNode = new PathNode(mapNode.NodeId, mapNode.Position);
pathNode.ConnectedNodes = new List<string>(mapNode.ConnectedNodes);
_nodeMap[mapNode.NodeId] = pathNode; _nodeMap[mapNode.NodeId] = pathNode;
} }
} }
// 2단계: 양방향 연결 자동 생성 (A→B 연결이 있으면 B→A도 추가) // 단일 연결을 양방향으로 확장
EnsureBidirectionalConnections(); foreach (var mapNode in _mapNodes)
} {
if (mapNode.IsNavigationNode() && _nodeMap.ContainsKey(mapNode.NodeId))
{
var pathNode = _nodeMap[mapNode.NodeId];
/// <summary> foreach (var connectedNodeId in mapNode.ConnectedNodes)
/// 단방향 연결을 양방향으로 자동 변환
/// 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)) if (_nodeMap.ContainsKey(connectedNodeId))
{ {
var connectedNode = _nodeMap[connectedNodeId]; // 양방향 연결 생성 (단일 연결이 양방향을 의미)
// 역방향 연결이 없으면 추가 if (!pathNode.ConnectedNodes.Contains(connectedNodeId))
if (!connectedNode.ConnectedNodes.Contains(nodeId))
{ {
connectedNode.ConnectedNodes.Add(nodeId); pathNode.ConnectedNodes.Add(connectedNodeId);
}
var connectedPathNode = _nodeMap[connectedNodeId];
if (!connectedPathNode.ConnectedNodes.Contains(mapNode.NodeId))
{
connectedPathNode.ConnectedNodes.Add(mapNode.NodeId);
}
} }
} }
} }

View File

@@ -0,0 +1,391 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AGVNavigationCore.Models;
namespace AGVNavigationCore.PathFinding
{
/// <summary>
/// 고급 AGV 경로 계획기
/// 물리적 제약사항과 마그넷 센서를 고려한 실제 AGV 경로 생성
/// </summary>
public class AdvancedAGVPathfinder
{
/// <summary>
/// 고급 AGV 경로 계산 결과
/// </summary>
public class AdvancedPathResult
{
public bool Success { get; set; }
public List<NodeMotorInfo> DetailedPath { get; set; }
public float TotalDistance { get; set; }
public long CalculationTimeMs { get; set; }
public int ExploredNodeCount { get; set; }
public string ErrorMessage { get; set; }
public string PlanDescription { get; set; }
public bool RequiredDirectionChange { get; set; }
public string DirectionChangeNode { get; set; }
public AdvancedPathResult()
{
DetailedPath = new List<NodeMotorInfo>();
ErrorMessage = string.Empty;
PlanDescription = string.Empty;
}
public static AdvancedPathResult CreateSuccess(List<NodeMotorInfo> path, float distance, long time, int explored, string description, bool directionChange = false, string changeNode = null)
{
return new AdvancedPathResult
{
Success = true,
DetailedPath = path,
TotalDistance = distance,
CalculationTimeMs = time,
ExploredNodeCount = explored,
PlanDescription = description,
RequiredDirectionChange = directionChange,
DirectionChangeNode = changeNode
};
}
public static AdvancedPathResult CreateFailure(string error, long time, int explored)
{
return new AdvancedPathResult
{
Success = false,
ErrorMessage = error,
CalculationTimeMs = time,
ExploredNodeCount = explored
};
}
/// <summary>
/// 단순 경로 목록 반환 (호환성용)
/// </summary>
public List<string> GetSimplePath()
{
return DetailedPath.Select(n => n.NodeId).ToList();
}
}
private readonly List<MapNode> _mapNodes;
private readonly AStarPathfinder _basicPathfinder;
private readonly JunctionAnalyzer _junctionAnalyzer;
private readonly DirectionChangePlanner _directionChangePlanner;
public AdvancedAGVPathfinder(List<MapNode> mapNodes)
{
_mapNodes = mapNodes ?? new List<MapNode>();
_basicPathfinder = new AStarPathfinder();
_basicPathfinder.SetMapNodes(_mapNodes);
_junctionAnalyzer = new JunctionAnalyzer(_mapNodes);
_directionChangePlanner = new DirectionChangePlanner(_mapNodes);
}
/// <summary>
/// 고급 AGV 경로 계산
/// </summary>
public AdvancedPathResult FindAdvancedPath(string startNodeId, string targetNodeId, AgvDirection currentDirection = AgvDirection.Forward)
{
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
try
{
// 1. 목적지 도킹 방향 요구사항 확인
var requiredDirection = _directionChangePlanner.GetRequiredDockingDirection(targetNodeId);
// 2. 방향 전환이 필요한지 확인
bool needDirectionChange = (currentDirection != requiredDirection);
AdvancedPathResult result;
if (needDirectionChange)
{
// 방향 전환이 필요한 경우
result = PlanPathWithDirectionChange(startNodeId, targetNodeId, currentDirection, requiredDirection);
}
else
{
// 직접 경로 계산
result = PlanDirectPath(startNodeId, targetNodeId, currentDirection);
}
result.CalculationTimeMs = stopwatch.ElapsedMilliseconds;
return result;
}
catch (Exception ex)
{
return AdvancedPathResult.CreateFailure($"경로 계산 중 오류: {ex.Message}", stopwatch.ElapsedMilliseconds, 0);
}
}
/// <summary>
/// 직접 경로 계획
/// </summary>
private AdvancedPathResult PlanDirectPath(string startNodeId, string targetNodeId, AgvDirection currentDirection)
{
var basicResult = _basicPathfinder.FindPath(startNodeId, targetNodeId);
if (!basicResult.Success)
{
return AdvancedPathResult.CreateFailure(basicResult.ErrorMessage, basicResult.CalculationTimeMs, basicResult.ExploredNodeCount);
}
// 기본 경로를 상세 경로로 변환
var detailedPath = ConvertToDetailedPath(basicResult.Path, currentDirection);
return AdvancedPathResult.CreateSuccess(
detailedPath,
basicResult.TotalDistance,
basicResult.CalculationTimeMs,
basicResult.ExploredNodeCount,
"직접 경로 - 방향 전환 불필요"
);
}
/// <summary>
/// 방향 전환을 포함한 경로 계획
/// </summary>
private AdvancedPathResult PlanPathWithDirectionChange(string startNodeId, string targetNodeId, AgvDirection currentDirection, AgvDirection requiredDirection)
{
var directionChangePlan = _directionChangePlanner.PlanDirectionChange(startNodeId, targetNodeId, currentDirection, requiredDirection);
if (!directionChangePlan.Success)
{
return AdvancedPathResult.CreateFailure(directionChangePlan.ErrorMessage, 0, 0);
}
// 방향 전환 경로를 상세 경로로 변환
var detailedPath = ConvertDirectionChangePath(directionChangePlan, currentDirection, requiredDirection);
// 거리 계산
float totalDistance = CalculatePathDistance(detailedPath);
return AdvancedPathResult.CreateSuccess(
detailedPath,
totalDistance,
0,
0,
directionChangePlan.PlanDescription,
true,
directionChangePlan.DirectionChangeNode
);
}
/// <summary>
/// 기본 경로를 상세 경로로 변환
/// </summary>
private List<NodeMotorInfo> ConvertToDetailedPath(List<string> simplePath, AgvDirection initialDirection)
{
var detailedPath = new List<NodeMotorInfo>();
var currentDirection = initialDirection;
for (int i = 0; i < simplePath.Count; i++)
{
string currentNodeId = simplePath[i];
string nextNodeId = (i + 1 < simplePath.Count) ? simplePath[i + 1] : null;
// 마그넷 방향 계산
MagnetDirection magnetDirection = MagnetDirection.Straight;
if (i > 0 && nextNodeId != null)
{
string prevNodeId = simplePath[i - 1];
magnetDirection = _junctionAnalyzer.GetRequiredMagnetDirection(prevNodeId, currentNodeId, nextNodeId);
}
// 노드 정보 생성
var nodeMotorInfo = new NodeMotorInfo(
currentNodeId,
currentDirection,
nextNodeId,
magnetDirection
);
// 회전 가능 노드 설정
var mapNode = _mapNodes.FirstOrDefault(n => n.NodeId == currentNodeId);
if (mapNode != null)
{
nodeMotorInfo.CanRotate = mapNode.CanRotate;
}
detailedPath.Add(nodeMotorInfo);
}
return detailedPath;
}
/// <summary>
/// 방향 전환 경로를 상세 경로로 변환
/// </summary>
private List<NodeMotorInfo> ConvertDirectionChangePath(DirectionChangePlanner.DirectionChangePlan plan, AgvDirection startDirection, AgvDirection endDirection)
{
var detailedPath = new List<NodeMotorInfo>();
var currentDirection = startDirection;
for (int i = 0; i < plan.DirectionChangePath.Count; i++)
{
string currentNodeId = plan.DirectionChangePath[i];
string nextNodeId = (i + 1 < plan.DirectionChangePath.Count) ? plan.DirectionChangePath[i + 1] : null;
// 방향 전환 노드에서 방향 변경
if (currentNodeId == plan.DirectionChangeNode && currentDirection != endDirection)
{
currentDirection = endDirection;
}
// 마그넷 방향 계산
MagnetDirection magnetDirection = MagnetDirection.Straight;
if (i > 0 && nextNodeId != null)
{
string prevNodeId = plan.DirectionChangePath[i - 1];
magnetDirection = _junctionAnalyzer.GetRequiredMagnetDirection(prevNodeId, currentNodeId, nextNodeId);
}
// 특수 동작 확인
bool requiresSpecialAction = false;
string specialActionDescription = "";
if (currentNodeId == plan.DirectionChangeNode)
{
requiresSpecialAction = true;
specialActionDescription = $"방향전환: {startDirection} → {endDirection}";
}
// 노드 정보 생성
var nodeMotorInfo = new NodeMotorInfo(
currentNodeId,
currentDirection,
nextNodeId,
true, // 방향 전환 경로의 경우 회전 가능으로 설정
currentNodeId == plan.DirectionChangeNode,
magnetDirection,
requiresSpecialAction,
specialActionDescription
);
detailedPath.Add(nodeMotorInfo);
}
return detailedPath;
}
/// <summary>
/// 경로 총 거리 계산
/// </summary>
private float CalculatePathDistance(List<NodeMotorInfo> detailedPath)
{
float totalDistance = 0;
for (int i = 0; i < detailedPath.Count - 1; i++)
{
var currentNode = _mapNodes.FirstOrDefault(n => n.NodeId == detailedPath[i].NodeId);
var nextNode = _mapNodes.FirstOrDefault(n => n.NodeId == detailedPath[i + 1].NodeId);
if (currentNode != null && nextNode != null)
{
float dx = nextNode.Position.X - currentNode.Position.X;
float dy = nextNode.Position.Y - currentNode.Position.Y;
totalDistance += (float)Math.Sqrt(dx * dx + dy * dy);
}
}
return totalDistance;
}
/// <summary>
/// 경로 유효성 검증
/// </summary>
public bool ValidatePath(List<NodeMotorInfo> detailedPath)
{
if (detailedPath == null || detailedPath.Count == 0)
return false;
// 1. 모든 노드가 존재하는지 확인
foreach (var nodeInfo in detailedPath)
{
if (!_mapNodes.Any(n => n.NodeId == nodeInfo.NodeId))
return false;
}
// 2. 연결성 확인
for (int i = 0; i < detailedPath.Count - 1; i++)
{
string currentId = detailedPath[i].NodeId;
string nextId = detailedPath[i + 1].NodeId;
if (!_basicPathfinder.AreNodesConnected(currentId, nextId))
return false;
}
// 3. 물리적 제약사항 확인
return ValidatePhysicalConstraints(detailedPath);
}
/// <summary>
/// 물리적 제약사항 검증
/// </summary>
private bool ValidatePhysicalConstraints(List<NodeMotorInfo> detailedPath)
{
for (int i = 1; i < detailedPath.Count; i++)
{
var prevNode = detailedPath[i - 1];
var currentNode = detailedPath[i];
// 급작스러운 방향 전환 검증
if (prevNode.MotorDirection != currentNode.MotorDirection)
{
// 방향 전환은 반드시 회전 가능 노드에서만
if (!currentNode.CanRotate && !currentNode.IsDirectionChangePoint)
{
return false;
}
}
}
return true;
}
/// <summary>
/// 경로 최적화
/// </summary>
public AdvancedPathResult OptimizePath(AdvancedPathResult originalResult)
{
if (!originalResult.Success)
return originalResult;
// TODO: 경로 최적화 로직 구현
// - 불필요한 중간 노드 제거
// - 마그넷 방향 최적화
// - 방향 전환 최소화
return originalResult;
}
/// <summary>
/// 디버깅용 경로 정보
/// </summary>
public string GetPathSummary(AdvancedPathResult result)
{
if (!result.Success)
return $"경로 계산 실패: {result.ErrorMessage}";
var summary = new List<string>
{
$"=== AGV 고급 경로 계획 결과 ===",
$"총 노드 수: {result.DetailedPath.Count}",
$"총 거리: {result.TotalDistance:F1}px",
$"계산 시간: {result.CalculationTimeMs}ms",
$"방향 전환: {(result.RequiredDirectionChange ? $" (: {result.DirectionChangeNode})" : "")}",
$"설명: {result.PlanDescription}",
"",
"=== 상세 경로 ===",
};
foreach (var nodeInfo in result.DetailedPath)
{
summary.Add(nodeInfo.ToString());
}
return string.Join("\n", summary);
}
}
}

View File

@@ -0,0 +1,518 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AGVNavigationCore.Models;
namespace AGVNavigationCore.PathFinding
{
/// <summary>
/// AGV 방향 전환 경로 계획 시스템
/// 물리적 제약사항을 고려한 방향 전환 경로 생성
/// </summary>
public class DirectionChangePlanner
{
/// <summary>
/// 방향 전환 계획 결과
/// </summary>
public class DirectionChangePlan
{
public bool Success { get; set; }
public List<string> DirectionChangePath { get; set; }
public string DirectionChangeNode { get; set; }
public string ErrorMessage { get; set; }
public string PlanDescription { get; set; }
public DirectionChangePlan()
{
DirectionChangePath = new List<string>();
ErrorMessage = string.Empty;
PlanDescription = string.Empty;
}
public static DirectionChangePlan CreateSuccess(List<string> path, string changeNode, string description)
{
return new DirectionChangePlan
{
Success = true,
DirectionChangePath = path,
DirectionChangeNode = changeNode,
PlanDescription = description
};
}
public static DirectionChangePlan CreateFailure(string error)
{
return new DirectionChangePlan
{
Success = false,
ErrorMessage = error
};
}
}
private readonly List<MapNode> _mapNodes;
private readonly JunctionAnalyzer _junctionAnalyzer;
private readonly AStarPathfinder _pathfinder;
public DirectionChangePlanner(List<MapNode> mapNodes)
{
_mapNodes = mapNodes ?? new List<MapNode>();
_junctionAnalyzer = new JunctionAnalyzer(_mapNodes);
_pathfinder = new AStarPathfinder();
_pathfinder.SetMapNodes(_mapNodes);
}
/// <summary>
/// 방향 전환이 필요한 경로 계획
/// </summary>
public DirectionChangePlan PlanDirectionChange(string startNodeId, string targetNodeId, AgvDirection currentDirection, AgvDirection requiredDirection)
{
// 방향이 같으면 직접 경로 계산
if (currentDirection == requiredDirection)
{
var directPath = _pathfinder.FindPath(startNodeId, targetNodeId);
if (directPath.Success)
{
return DirectionChangePlan.CreateSuccess(
directPath.Path,
null,
"방향 전환 불필요 - 직접 경로 사용"
);
}
}
// 방향 전환이 필요한 경우
return PlanDirectionChangeRoute(startNodeId, targetNodeId, currentDirection, requiredDirection);
}
/// <summary>
/// 방향 전환 경로 계획
/// </summary>
private DirectionChangePlan PlanDirectionChangeRoute(string startNodeId, string targetNodeId, AgvDirection currentDirection, AgvDirection requiredDirection)
{
// 1. 방향 전환 가능한 갈림길 찾기
var changeJunctions = FindSuitableChangeJunctions(startNodeId, targetNodeId, currentDirection, requiredDirection);
if (changeJunctions.Count == 0)
{
return DirectionChangePlan.CreateFailure("방향 전환 가능한 갈림길을 찾을 수 없습니다.");
}
// 2. 각 갈림길에 대해 경로 계획 시도
foreach (var junction in changeJunctions)
{
var plan = TryDirectionChangeAtJunction(startNodeId, targetNodeId, junction, currentDirection, requiredDirection);
if (plan.Success)
{
return plan;
}
}
return DirectionChangePlan.CreateFailure("모든 갈림길에서 방향 전환 경로 계획이 실패했습니다.");
}
/// <summary>
/// 방향 전환에 적합한 갈림길 검색
/// </summary>
private List<string> FindSuitableChangeJunctions(string startNodeId, string targetNodeId, AgvDirection currentDirection, AgvDirection requiredDirection)
{
var suitableJunctions = new List<string>();
// 시작점과 목표점 사이의 경로에 있는 갈림길들 우선 검색
var directPath = _pathfinder.FindPath(startNodeId, targetNodeId);
if (directPath.Success)
{
foreach (var nodeId in directPath.Path)
{
var junctionInfo = _junctionAnalyzer.GetJunctionInfo(nodeId);
if (junctionInfo != null && junctionInfo.IsJunction)
{
suitableJunctions.Add(nodeId);
}
}
}
// 추가로 시작점 주변의 갈림길들도 검색
var nearbyJunctions = FindNearbyJunctions(startNodeId, 3); // 3단계 내의 갈림길
foreach (var junction in nearbyJunctions)
{
if (!suitableJunctions.Contains(junction))
{
suitableJunctions.Add(junction);
}
}
// 거리순으로 정렬 (시작점에서 가까운 순)
return SortJunctionsByDistance(startNodeId, suitableJunctions);
}
/// <summary>
/// 특정 노드 주변의 갈림길 검색
/// </summary>
private List<string> FindNearbyJunctions(string nodeId, int maxSteps)
{
var junctions = new List<string>();
var visited = new HashSet<string>();
var queue = new Queue<(string NodeId, int Steps)>();
queue.Enqueue((nodeId, 0));
visited.Add(nodeId);
while (queue.Count > 0)
{
var (currentNodeId, steps) = queue.Dequeue();
if (steps > maxSteps) continue;
var junctionInfo = _junctionAnalyzer.GetJunctionInfo(currentNodeId);
if (junctionInfo != null && junctionInfo.IsJunction && currentNodeId != nodeId)
{
junctions.Add(currentNodeId);
}
// 연결된 노드들을 큐에 추가
var connectedNodes = GetAllConnectedNodes(currentNodeId);
foreach (var connectedId in connectedNodes)
{
if (!visited.Contains(connectedId))
{
visited.Add(connectedId);
queue.Enqueue((connectedId, steps + 1));
}
}
}
return junctions;
}
/// <summary>
/// 양방향 연결을 고려한 연결 노드 검색
/// </summary>
private List<string> GetAllConnectedNodes(string nodeId)
{
var node = _mapNodes.FirstOrDefault(n => n.NodeId == nodeId);
if (node == null) return new List<string>();
var connected = new HashSet<string>();
// 직접 연결
foreach (var connectedId in node.ConnectedNodes)
{
connected.Add(connectedId);
}
// 역방향 연결
foreach (var otherNode in _mapNodes)
{
if (otherNode.NodeId != nodeId && otherNode.ConnectedNodes.Contains(nodeId))
{
connected.Add(otherNode.NodeId);
}
}
return connected.ToList();
}
/// <summary>
/// 갈림길을 거리순으로 정렬
/// </summary>
private List<string> SortJunctionsByDistance(string startNodeId, List<string> junctions)
{
var distances = new List<(string NodeId, double Distance)>();
foreach (var junction in junctions)
{
var path = _pathfinder.FindPath(startNodeId, junction);
double distance = path.Success ? path.TotalDistance : double.MaxValue;
distances.Add((junction, distance));
}
return distances.OrderBy(d => d.Distance).Select(d => d.NodeId).ToList();
}
/// <summary>
/// 특정 갈림길에서 방향 전환 시도
/// </summary>
private DirectionChangePlan TryDirectionChangeAtJunction(string startNodeId, string targetNodeId, string junctionNodeId, AgvDirection currentDirection, AgvDirection requiredDirection)
{
try
{
// 방향 전환 경로 생성
var changePath = GenerateDirectionChangePath(startNodeId, targetNodeId, junctionNodeId, currentDirection, requiredDirection);
if (changePath.Count > 0)
{
// 실제 방향 전환 노드 찾기 (우회 노드)
string actualDirectionChangeNode = FindActualDirectionChangeNode(changePath, junctionNodeId);
string description = $"갈림길 {junctionNodeId}를 통해 {actualDirectionChangeNode}에서 방향 전환: {currentDirection} → {requiredDirection}";
return DirectionChangePlan.CreateSuccess(changePath, actualDirectionChangeNode, description);
}
return DirectionChangePlan.CreateFailure($"갈림길 {junctionNodeId}에서 방향 전환 경로 생성 실패");
}
catch (Exception ex)
{
return DirectionChangePlan.CreateFailure($"갈림길 {junctionNodeId}에서 오류: {ex.Message}");
}
}
/// <summary>
/// 방향 전환 경로 생성
/// </summary>
private List<string> GenerateDirectionChangePath(string startNodeId, string targetNodeId, string junctionNodeId, AgvDirection currentDirection, AgvDirection requiredDirection)
{
var fullPath = new List<string>();
// 1. 시작점에서 갈림길까지의 경로
var toJunctionPath = _pathfinder.FindPath(startNodeId, junctionNodeId);
if (!toJunctionPath.Success)
return fullPath;
fullPath.AddRange(toJunctionPath.Path);
// 2. 갈림길에서 방향 전환 처리
if (currentDirection != requiredDirection)
{
// AGV가 어느 노드에서 갈림길로 왔는지 파악
string fromNodeId = toJunctionPath.Path.Count >= 2 ?
toJunctionPath.Path[toJunctionPath.Path.Count - 2] : startNodeId;
var changeSequence = GenerateDirectionChangeSequence(junctionNodeId, fromNodeId, currentDirection, requiredDirection);
if (changeSequence.Count > 1) // 첫 번째는 갈림길 자체이므로 제외
{
fullPath.AddRange(changeSequence.Skip(1));
}
}
// 3. 갈림길에서 목표점까지의 경로
string lastNode = fullPath.LastOrDefault() ?? junctionNodeId;
var fromJunctionPath = _pathfinder.FindPath(lastNode, targetNodeId);
if (fromJunctionPath.Success && fromJunctionPath.Path.Count > 1)
{
fullPath.AddRange(fromJunctionPath.Path.Skip(1)); // 중복 노드 제거
}
return fullPath;
}
/// <summary>
/// 갈림길에서 방향 전환 시퀀스 생성
/// 물리적으로 실현 가능한 방향 전환 경로 생성
/// </summary>
private List<string> GenerateDirectionChangeSequence(string junctionNodeId, string fromNodeId, AgvDirection currentDirection, AgvDirection requiredDirection)
{
var sequence = new List<string> { junctionNodeId };
// 방향이 같으면 변경 불필요
if (currentDirection == requiredDirection)
return sequence;
var junctionInfo = _junctionAnalyzer.GetJunctionInfo(junctionNodeId);
if (junctionInfo == null || !junctionInfo.IsJunction)
return sequence;
// 물리적으로 실현 가능한 방향 전환 시퀀스 생성
// 핵심 원리: AGV는 RFID 태그를 읽자마자 바로 방향전환하면 안됨
// 왔던 길로 되돌아가지 않도록 다른 노드로 우회한 후 방향전환
var connectedNodes = junctionInfo.ConnectedNodes;
// 왔던 노드(fromNodeId)를 제외한 연결 노드들만 후보로 선택
// 이렇게 해야 AGV가 되돌아가는 것을 방지할 수 있음
var availableNodes = connectedNodes.Where(nodeId => nodeId != fromNodeId).ToList();
if (availableNodes.Count > 0)
{
// 방향 전환을 위한 우회 경로 생성
// 예시: 003→004(전진) 상태에서 후진 필요한 경우
// 잘못된 방법: 004→003 (왔던 길로 되돌아감)
// 올바른 방법: 004→005→004 (005로 우회하여 방향전환)
// 가장 적합한 우회 노드 선택 (직진 방향 우선, 각도 변화 최소)
string detourNode = FindBestDetourNode(junctionNodeId, availableNodes, fromNodeId);
if (!string.IsNullOrEmpty(detourNode))
{
// 1단계: 갈림길에서 우회 노드로 이동 (현재 방향 유지)
// AGV는 계속 전진하여 한 태그 더 지나감
sequence.Add(detourNode);
// 2단계: 우회 노드에서 갈림길로 다시 돌아옴 (요구 방향으로 변경)
// 이때 AGV는 안전한 위치에서 방향을 전환할 수 있음
sequence.Add(junctionNodeId);
}
}
else
{
// 사용 가능한 우회 노드가 없는 경우 (2갈래 길목)
// 이 경우 물리적으로 방향 전환이 불가능할 수 있음
// 별도의 처리 로직이 필요할 수 있음
return sequence;
}
return sequence;
}
/// <summary>
/// 방향 전환을 위한 최적의 우회 노드 선택
/// AGV의 물리적 특성을 고려한 각도 기반 선택
/// </summary>
private string FindBestDetourNode(string junctionNodeId, List<string> availableNodes, string excludeNodeId)
{
// 왔던 길(excludeNodeId)를 제외한 노드 중에서 최적의 우회 노드 선택
// 우선순위: 1) 직진방향 2) 가장 작은 각도 변화 3) 막다른 길이 아닌 노드
var junctionNode = _mapNodes.FirstOrDefault(n => n.NodeId == junctionNodeId);
var fromNode = _mapNodes.FirstOrDefault(n => n.NodeId == excludeNodeId);
if (junctionNode == null || fromNode == null)
return availableNodes.FirstOrDefault();
string bestNode = null;
double minAngleChange = double.MaxValue;
bool foundNonDeadEnd = false;
// AGV가 들어온 방향 벡터 계산 (fromNode → junctionNode)
double incomingAngle = CalculateAngle(fromNode.Position, junctionNode.Position);
foreach (var nodeId in availableNodes)
{
if (nodeId == excludeNodeId) continue; // 왔던 길 제외
var candidateNode = _mapNodes.FirstOrDefault(n => n.NodeId == nodeId);
if (candidateNode == null) continue;
// 갈림길에서 후보 노드로의 방향 벡터 계산 (junctionNode → candidateNode)
double outgoingAngle = CalculateAngle(junctionNode.Position, candidateNode.Position);
// 방향 변화 각도 계산 (0도가 직진, 180도가 유턴)
double angleChange = CalculateAngleChange(incomingAngle, outgoingAngle);
// 막다른 길 여부 확인
var nodeConnections = GetAllConnectedNodes(nodeId);
bool isDeadEnd = nodeConnections.Count <= 1;
// 최적 노드 선택 로직
bool shouldUpdate = false;
if (!foundNonDeadEnd && !isDeadEnd)
{
// 첫 번째 막다른 길이 아닌 노드 발견
shouldUpdate = true;
foundNonDeadEnd = true;
}
else if (foundNonDeadEnd && isDeadEnd)
{
// 이미 막다른 길이 아닌 노드를 찾았으므로 막다른 길은 제외
continue;
}
else if (foundNonDeadEnd == isDeadEnd)
{
// 같은 조건(둘 다 막다른길 or 둘 다 아님)에서는 각도가 작은 것 선택
shouldUpdate = angleChange < minAngleChange;
}
if (shouldUpdate)
{
minAngleChange = angleChange;
bestNode = nodeId;
}
}
return bestNode ?? availableNodes.FirstOrDefault(n => n != excludeNodeId);
}
/// <summary>
/// 두 점 사이의 각도 계산 (라디안 단위)
/// </summary>
private double CalculateAngle(System.Drawing.Point from, System.Drawing.Point to)
{
double dx = to.X - from.X;
double dy = to.Y - from.Y;
return Math.Atan2(dy, dx);
}
/// <summary>
/// 두 방향 사이의 각도 변화량 계산 (0~180도 범위)
/// 0도에 가까울수록 직진, 180도에 가까울수록 유턴
/// </summary>
private double CalculateAngleChange(double fromAngle, double toAngle)
{
// 각도 차이 계산
double angleDiff = Math.Abs(toAngle - fromAngle);
// 0~π 범위로 정규화 (0~180도)
if (angleDiff > Math.PI)
{
angleDiff = 2 * Math.PI - angleDiff;
}
return angleDiff;
}
/// <summary>
/// 실제 방향 전환이 일어나는 노드 찾기
/// </summary>
private string FindActualDirectionChangeNode(List<string> changePath, string junctionNodeId)
{
// 방향전환 경로 구조: [start...junction, detourNode, junction...target]
// 실제 방향전환은 detourNode에서 일어남 (AGV가 한 태그 더 지나간 후)
if (changePath.Count < 3)
return junctionNodeId; // 기본값으로 갈림길 반환
// 갈림길이 두 번 나타나는 위치 찾기
int firstJunctionIndex = changePath.IndexOf(junctionNodeId);
int lastJunctionIndex = changePath.LastIndexOf(junctionNodeId);
// 갈림길이 두 번 나타나고, 그 사이에 노드가 있는 경우
if (firstJunctionIndex != lastJunctionIndex && lastJunctionIndex - firstJunctionIndex == 2)
{
// 첫 번째와 두 번째 갈림길 사이에 있는 노드가 실제 방향전환 노드
string detourNode = changePath[firstJunctionIndex + 1];
return detourNode;
}
// 방향전환 구조를 찾지 못한 경우 기본값 반환
return junctionNodeId;
}
/// <summary>
/// 두 점 사이의 거리 계산
/// </summary>
private float CalculateDistance(System.Drawing.Point p1, System.Drawing.Point p2)
{
float dx = p2.X - p1.X;
float dy = p2.Y - p1.Y;
return (float)Math.Sqrt(dx * dx + dy * dy);
}
/// <summary>
/// 목적지 도킹 방향 요구사항 확인
/// </summary>
public AgvDirection GetRequiredDockingDirection(string targetNodeId)
{
var targetNode = _mapNodes.FirstOrDefault(n => n.NodeId == targetNodeId);
if (targetNode == null)
return AgvDirection.Forward;
switch (targetNode.Type)
{
case NodeType.Charging:
return AgvDirection.Forward; // 충전기는 전진 도킹
case NodeType.Docking:
return AgvDirection.Backward; // 일반 도킹은 후진 도킹
default:
return AgvDirection.Forward; // 기본은 전진
}
}
/// <summary>
/// 경로 계획 요약 정보
/// </summary>
public string GetPlanSummary()
{
var junctions = _junctionAnalyzer.GetJunctionSummary();
return string.Join("\n", junctions);
}
}
}

View File

@@ -0,0 +1,304 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using AGVNavigationCore.Models;
namespace AGVNavigationCore.PathFinding
{
/// <summary>
/// AGV 갈림길 분석 및 마그넷 센서 방향 계산 시스템
/// </summary>
public class JunctionAnalyzer
{
/// <summary>
/// 갈림길 정보
/// </summary>
public class JunctionInfo
{
public string NodeId { get; set; }
public List<string> ConnectedNodes { get; set; }
public Dictionary<string, MagnetDirection> PathDirections { get; set; }
public bool IsJunction => ConnectedNodes.Count > 2;
public JunctionInfo(string nodeId)
{
NodeId = nodeId;
ConnectedNodes = new List<string>();
PathDirections = new Dictionary<string, MagnetDirection>();
}
public override string ToString()
{
if (!IsJunction)
return $"{NodeId}: 일반노드 ({ConnectedNodes.Count}연결)";
var paths = string.Join(", ", PathDirections.Select(p => $"{p.Key}({p.Value})"));
return $"{NodeId}: 갈림길 - {paths}";
}
}
private readonly List<MapNode> _mapNodes;
private readonly Dictionary<string, JunctionInfo> _junctions;
public JunctionAnalyzer(List<MapNode> mapNodes)
{
_mapNodes = mapNodes ?? new List<MapNode>();
_junctions = new Dictionary<string, JunctionInfo>();
AnalyzeJunctions();
}
/// <summary>
/// 모든 갈림길 분석
/// </summary>
private void AnalyzeJunctions()
{
foreach (var node in _mapNodes)
{
if (node.IsNavigationNode())
{
var junctionInfo = AnalyzeNode(node);
_junctions[node.NodeId] = junctionInfo;
}
}
}
/// <summary>
/// 개별 노드의 갈림길 정보 분석
/// </summary>
private JunctionInfo AnalyzeNode(MapNode node)
{
var junction = new JunctionInfo(node.NodeId);
// 양방향 연결을 고려하여 모든 연결된 노드 찾기
var connectedNodes = GetAllConnectedNodes(node);
junction.ConnectedNodes = connectedNodes;
if (connectedNodes.Count > 2)
{
// 갈림길인 경우 각 방향별 마그넷 센서 방향 계산
CalculateMagnetDirections(node, connectedNodes, junction);
}
return junction;
}
/// <summary>
/// 양방향 연결을 고려한 모든 연결 노드 검색
/// </summary>
private List<string> GetAllConnectedNodes(MapNode node)
{
var connected = new HashSet<string>();
// 직접 연결된 노드들
foreach (var connectedId in node.ConnectedNodes)
{
connected.Add(connectedId);
}
// 역방향 연결된 노드들 (다른 노드에서 이 노드로 연결)
foreach (var otherNode in _mapNodes)
{
if (otherNode.NodeId != node.NodeId && otherNode.ConnectedNodes.Contains(node.NodeId))
{
connected.Add(otherNode.NodeId);
}
}
return connected.ToList();
}
/// <summary>
/// 갈림길에서 각 방향별 마그넷 센서 방향 계산
/// </summary>
private void CalculateMagnetDirections(MapNode junctionNode, List<string> connectedNodes, JunctionInfo junction)
{
if (connectedNodes.Count < 3) return;
// 각 연결 노드의 각도 계산
var nodeAngles = new List<(string NodeId, double Angle)>();
foreach (var connectedId in connectedNodes)
{
var connectedNode = _mapNodes.FirstOrDefault(n => n.NodeId == connectedId);
if (connectedNode != null)
{
double angle = CalculateAngle(junctionNode.Position, connectedNode.Position);
nodeAngles.Add((connectedId, angle));
}
}
// 각도순으로 정렬
nodeAngles.Sort((a, b) => a.Angle.CompareTo(b.Angle));
// 마그넷 방향 할당
AssignMagnetDirections(nodeAngles, junction);
}
/// <summary>
/// 두 점 사이의 각도 계산 (라디안)
/// </summary>
private double CalculateAngle(Point from, Point to)
{
double deltaX = to.X - from.X;
double deltaY = to.Y - from.Y;
double angle = Math.Atan2(deltaY, deltaX);
// 0~2π 범위로 정규화
if (angle < 0)
angle += 2 * Math.PI;
return angle;
}
/// <summary>
/// 갈림길에서 마그넷 센서 방향 할당
/// </summary>
private void AssignMagnetDirections(List<(string NodeId, double Angle)> sortedNodes, JunctionInfo junction)
{
int nodeCount = sortedNodes.Count;
for (int i = 0; i < nodeCount; i++)
{
string nodeId = sortedNodes[i].NodeId;
MagnetDirection direction;
if (nodeCount == 3)
{
// 3갈래: 직진, 좌측, 우측
switch (i)
{
case 0: direction = MagnetDirection.Straight; break;
case 1: direction = MagnetDirection.Left; break;
case 2: direction = MagnetDirection.Right; break;
default: direction = MagnetDirection.Straight; break;
}
}
else if (nodeCount == 4)
{
// 4갈래: 교차로
switch (i)
{
case 0: direction = MagnetDirection.Straight; break;
case 1: direction = MagnetDirection.Left; break;
case 2: direction = MagnetDirection.Straight; break; // 반대편
case 3: direction = MagnetDirection.Right; break;
default: direction = MagnetDirection.Straight; break;
}
}
else
{
// 5갈래 이상: 각도 기반 배정
double angleStep = 2 * Math.PI / nodeCount;
double normalizedIndex = (double)i / nodeCount;
if (normalizedIndex < 0.33)
direction = MagnetDirection.Left;
else if (normalizedIndex < 0.67)
direction = MagnetDirection.Straight;
else
direction = MagnetDirection.Right;
}
junction.PathDirections[nodeId] = direction;
}
}
/// <summary>
/// 특정 경로에서 요구되는 마그넷 방향 계산 (전진 방향 기준)
/// </summary>
public MagnetDirection GetRequiredMagnetDirection(string fromNodeId, string currentNodeId, string toNodeId)
{
if (!_junctions.ContainsKey(currentNodeId))
return MagnetDirection.Straight;
var junction = _junctions[currentNodeId];
if (!junction.IsJunction)
return MagnetDirection.Straight;
// 실제 각도 기반으로 마그넷 방향 계산
var fromNode = _mapNodes.FirstOrDefault(n => n.NodeId == fromNodeId);
var currentNode = _mapNodes.FirstOrDefault(n => n.NodeId == currentNodeId);
var toNode = _mapNodes.FirstOrDefault(n => n.NodeId == toNodeId);
if (fromNode == null || currentNode == null || toNode == null)
return MagnetDirection.Straight;
// 전진 방향(진행 방향) 계산
double incomingAngle = CalculateAngle(fromNode.Position, currentNode.Position);
// 목표 방향 계산
double outgoingAngle = CalculateAngle(currentNode.Position, toNode.Position);
// 각도 차이 계산 (전진 방향 기준)
double angleDiff = outgoingAngle - incomingAngle;
// 각도를 -π ~ π 범위로 정규화
while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
// 전진 방향 기준으로 마그넷 방향 결정
// 각도 차이가 작으면 직진, 음수면 왼쪽, 양수면 오른쪽
if (Math.Abs(angleDiff) < Math.PI / 6) // 30도 이내는 직진
return MagnetDirection.Straight;
else if (angleDiff < 0) // 음수면 왼쪽 회전
return MagnetDirection.Left;
else // 양수면 오른쪽 회전
return MagnetDirection.Right;
}
/// <summary>
/// 방향 전환 가능한 갈림길 검색
/// </summary>
public List<string> FindDirectionChangeJunctions(AgvDirection currentDirection, AgvDirection targetDirection)
{
var availableJunctions = new List<string>();
if (currentDirection == targetDirection)
return availableJunctions;
foreach (var junction in _junctions.Values)
{
if (junction.IsJunction)
{
// 갈림길에서 방향 전환이 가능한지 확인
// (실제로는 더 복잡한 로직이 필요하지만, 일단 모든 갈림길을 후보로 함)
availableJunctions.Add(junction.NodeId);
}
}
return availableJunctions;
}
/// <summary>
/// 갈림길 정보 반환
/// </summary>
public JunctionInfo GetJunctionInfo(string nodeId)
{
return _junctions.ContainsKey(nodeId) ? _junctions[nodeId] : null;
}
/// <summary>
/// 모든 갈림길 목록 반환
/// </summary>
public List<JunctionInfo> GetAllJunctions()
{
return _junctions.Values.Where(j => j.IsJunction).ToList();
}
/// <summary>
/// 디버깅용 갈림길 정보 출력
/// </summary>
public List<string> GetJunctionSummary()
{
var summary = new List<string>();
foreach (var junction in _junctions.Values.Where(j => j.IsJunction))
{
summary.Add(junction.ToString());
}
return summary;
}
}
}

View File

@@ -3,7 +3,28 @@ using AGVNavigationCore.Models;
namespace AGVNavigationCore.PathFinding namespace AGVNavigationCore.PathFinding
{ {
/// <summary> /// <summary>
/// 노드별 모터방향 정보 /// AGV 마그넷 센서 방향 제어
/// </summary>
public enum MagnetDirection
{
/// <summary>
/// 직진 - 기본 마그넷 라인 추종
/// </summary>
Straight = 0,
/// <summary>
/// 좌측 - 마그넷 센서 가중치를 좌측으로 조정
/// </summary>
Left = 1,
/// <summary>
/// 우측 - 마그넷 센서 가중치를 우측으로 조정
/// </summary>
Right = 2
}
/// <summary>
/// 노드별 모터방향 정보 (방향 전환 지원 포함)
/// </summary> /// </summary>
public class NodeMotorInfo public class NodeMotorInfo
{ {
@@ -17,16 +38,84 @@ namespace AGVNavigationCore.PathFinding
/// </summary> /// </summary>
public AgvDirection MotorDirection { get; set; } public AgvDirection MotorDirection { get; set; }
/// <summary>
/// 마그넷 센서 방향 제어 (갈림길 처리용)
/// </summary>
public MagnetDirection MagnetDirection { get; set; }
/// <summary> /// <summary>
/// 다음 노드 ID (경로예측용) /// 다음 노드 ID (경로예측용)
/// </summary> /// </summary>
public string NextNodeId { get; set; } public string NextNodeId { get; set; }
public NodeMotorInfo(string nodeId, AgvDirection motorDirection, string nextNodeId = null) /// <summary>
/// 회전 가능 노드 여부
/// </summary>
public bool CanRotate { get; set; }
/// <summary>
/// 방향 전환이 발생하는 노드 여부
/// </summary>
public bool IsDirectionChangePoint { get; set; }
/// <summary>
/// 특수 동작이 필요한 노드 여부 (갈림길 전진/후진 반복)
/// </summary>
public bool RequiresSpecialAction { get; set; }
/// <summary>
/// 특수 동작 설명
/// </summary>
public string SpecialActionDescription { get; set; }
public NodeMotorInfo(string nodeId, AgvDirection motorDirection, string nextNodeId = null, MagnetDirection magnetDirection = MagnetDirection.Straight)
{ {
NodeId = nodeId; NodeId = nodeId;
MotorDirection = motorDirection; MotorDirection = motorDirection;
MagnetDirection = magnetDirection;
NextNodeId = nextNodeId; NextNodeId = nextNodeId;
CanRotate = false;
IsDirectionChangePoint = false;
RequiresSpecialAction = false;
SpecialActionDescription = string.Empty;
}
/// <summary>
/// 방향 전환 정보를 포함한 생성자
/// </summary>
public NodeMotorInfo(string nodeId, AgvDirection motorDirection, string nextNodeId, bool canRotate, bool isDirectionChangePoint, MagnetDirection magnetDirection = MagnetDirection.Straight, bool requiresSpecialAction = false, string specialActionDescription = "")
{
NodeId = nodeId;
MotorDirection = motorDirection;
MagnetDirection = magnetDirection;
NextNodeId = nextNodeId;
CanRotate = canRotate;
IsDirectionChangePoint = isDirectionChangePoint;
RequiresSpecialAction = requiresSpecialAction;
SpecialActionDescription = specialActionDescription ?? string.Empty;
}
/// <summary>
/// 디버깅용 문자열 표현
/// </summary>
public override string ToString()
{
var result = $"{NodeId}:{MotorDirection}";
// 마그넷 방향이 직진이 아닌 경우 표시
if (MagnetDirection != MagnetDirection.Straight)
result += $"({MagnetDirection})";
if (IsDirectionChangePoint)
result += " [방향전환]";
if (CanRotate)
result += " [회전가능]";
if (RequiresSpecialAction)
result += $" [특수동작:{SpecialActionDescription}]";
return result;
} }
} }
} }

View File

@@ -0,0 +1,67 @@
using System;
namespace AGVNavigationCore.PathFinding
{
/// <summary>
/// 경로 탐색 옵션 설정
/// </summary>
public class PathfindingOptions
{
/// <summary>
/// 회전 가능 노드 회피 여부 (기본값: false - 회전 허용)
/// </summary>
public bool AvoidRotationNodes { get; set; } = false;
/// <summary>
/// 회전 회피 시 추가 비용 가중치 (회전 노드를 완전 차단하지 않고 높은 비용으로 설정)
/// </summary>
public float RotationAvoidanceCost { get; set; } = 1000.0f;
/// <summary>
/// 회전 비용 가중치 (기존 회전 비용)
/// </summary>
public float RotationCostWeight { get; set; } = 50.0f;
/// <summary>
/// 도킹 접근 거리
/// </summary>
public float DockingApproachDistance { get; set; } = 100.0f;
/// <summary>
/// 기본 옵션 생성
/// </summary>
public static PathfindingOptions Default => new PathfindingOptions();
/// <summary>
/// 회전 회피 옵션 생성
/// </summary>
public static PathfindingOptions AvoidRotation => new PathfindingOptions
{
AvoidRotationNodes = true
};
/// <summary>
/// 옵션 복사
/// </summary>
public PathfindingOptions Clone()
{
return new PathfindingOptions
{
AvoidRotationNodes = this.AvoidRotationNodes,
RotationAvoidanceCost = this.RotationAvoidanceCost,
RotationCostWeight = this.RotationCostWeight,
DockingApproachDistance = this.DockingApproachDistance
};
}
/// <summary>
/// 설정 정보 문자열
/// </summary>
public override string ToString()
{
return $"회전회피: {(AvoidRotationNodes ? "ON" : "OFF")}, " +
$"회전비용: {RotationCostWeight}, " +
$"회피비용: {RotationAvoidanceCost}";
}
}
}

View File

@@ -5,7 +5,7 @@
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform> <Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{B2C3D4E5-0000-0000-0000-000000000000}</ProjectGuid> <ProjectGuid>{B2C3D4E5-0000-0000-0000-000000000000}</ProjectGuid>
<OutputType>WinExe</OutputType> <OutputType>Exe</OutputType>
<RootNamespace>AGVSimulator</RootNamespace> <RootNamespace>AGVSimulator</RootNamespace>
<AssemblyName>AGVSimulator</AssemblyName> <AssemblyName>AGVSimulator</AssemblyName>
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion> <TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
@@ -31,6 +31,9 @@
<ErrorReport>prompt</ErrorReport> <ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel> <WarningLevel>4</WarningLevel>
</PropertyGroup> </PropertyGroup>
<PropertyGroup>
<StartupObject />
</PropertyGroup>
<ItemGroup> <ItemGroup>
<Reference Include="System" /> <Reference Include="System" />
<Reference Include="System.Core" /> <Reference Include="System.Core" />

View File

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

View File

@@ -1,622 +0,0 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Windows.Forms;
using AGVMapEditor.Models;
using AGVNavigationCore.Models;
using AGVNavigationCore.PathFinding;
using AGVSimulator.Models;
namespace AGVSimulator.Controls
{
/// <summary>
/// AGV 시뮬레이션 시각화 캔버스
/// </summary>
public partial class SimulatorCanvas : UserControl
{
#region Fields
private List<MapNode> _mapNodes;
private List<VirtualAGV> _agvList;
private PathResult _currentPath;
// 그래픽 설정
private float _zoom = 1.0f;
private Point _panOffset = Point.Empty;
private bool _isPanning = false;
private Point _lastMousePos = Point.Empty;
// 색상 설정
private readonly Brush _normalNodeBrush = Brushes.LightBlue;
private readonly Brush _rotationNodeBrush = Brushes.Yellow;
private readonly Brush _dockingNodeBrush = Brushes.Orange;
private readonly Brush _chargingNodeBrush = Brushes.Green;
private readonly Brush _agvBrush = Brushes.Red;
private readonly Brush _pathBrush = Brushes.Purple;
private readonly Pen _connectionPen = new Pen(Color.Gray, 2);
private readonly Pen _pathPen = new Pen(Color.Purple, 3);
private readonly Pen _agvPen = new Pen(Color.Red, 3);
// 크기 설정
private const int NODE_SIZE = 20;
private const int AGV_SIZE = 30;
private const int CONNECTION_ARROW_SIZE = 8;
#endregion
#region Properties
/// <summary>
/// 맵 노드 목록
/// </summary>
public List<MapNode> MapNodes
{
get => _mapNodes;
set
{
_mapNodes = value;
Invalidate();
}
}
/// <summary>
/// AGV 목록
/// </summary>
public List<VirtualAGV> AGVList
{
get => _agvList;
set
{
_agvList = value;
Invalidate();
}
}
/// <summary>
/// 현재 경로
/// </summary>
public PathResult CurrentPath
{
get => _currentPath;
set
{
_currentPath = value;
Invalidate();
}
}
#endregion
#region Constructor
public SimulatorCanvas()
{
InitializeComponent();
InitializeCanvas();
}
#endregion
#region Initialization
private void InitializeCanvas()
{
_mapNodes = new List<MapNode>();
_agvList = new List<VirtualAGV>();
SetStyle(ControlStyles.AllPaintingInWmPaint |
ControlStyles.UserPaint |
ControlStyles.DoubleBuffer |
ControlStyles.ResizeRedraw, true);
BackColor = Color.White;
// 마우스 이벤트 연결
MouseDown += OnMouseDown;
MouseMove += OnMouseMove;
MouseUp += OnMouseUp;
MouseWheel += OnMouseWheel;
}
#endregion
#region Public Methods
/// <summary>
/// AGV 추가
/// </summary>
public void AddAGV(VirtualAGV agv)
{
if (_agvList == null)
_agvList = new List<VirtualAGV>();
_agvList.Add(agv);
// AGV 이벤트 연결
agv.PositionChanged += OnAGVPositionChanged;
agv.StateChanged += OnAGVStateChanged;
Invalidate();
}
/// <summary>
/// AGV 제거
/// </summary>
public void RemoveAGV(string agvId)
{
var agv = _agvList?.FirstOrDefault(a => a.AgvId == agvId);
if (agv != null)
{
// 이벤트 연결 해제
agv.PositionChanged -= OnAGVPositionChanged;
agv.StateChanged -= OnAGVStateChanged;
_agvList.Remove(agv);
Invalidate();
}
}
/// <summary>
/// 모든 AGV 제거
/// </summary>
public void ClearAGVs()
{
if (_agvList != null)
{
foreach (var agv in _agvList)
{
agv.PositionChanged -= OnAGVPositionChanged;
agv.StateChanged -= OnAGVStateChanged;
}
_agvList.Clear();
Invalidate();
}
}
/// <summary>
/// 확대/축소 초기화
/// </summary>
public void ResetZoom()
{
_zoom = 1.0f;
_panOffset = Point.Empty;
Invalidate();
}
/// <summary>
/// 맵 전체 맞춤
/// </summary>
public void FitToMap()
{
if (_mapNodes == null || _mapNodes.Count == 0)
return;
var minX = _mapNodes.Min(n => n.Position.X);
var maxX = _mapNodes.Max(n => n.Position.X);
var minY = _mapNodes.Min(n => n.Position.Y);
var maxY = _mapNodes.Max(n => n.Position.Y);
var mapWidth = maxX - minX + 100; // 여백 추가
var mapHeight = maxY - minY + 100;
var zoomX = (float)Width / mapWidth;
var zoomY = (float)Height / mapHeight;
_zoom = Math.Min(zoomX, zoomY) * 0.9f; // 약간의 여백
_panOffset = new Point(
(int)((Width - mapWidth * _zoom) / 2 - minX * _zoom),
(int)((Height - mapHeight * _zoom) / 2 - minY * _zoom)
);
Invalidate();
}
#endregion
#region Event Handlers
private void OnAGVPositionChanged(object sender, Point newPosition)
{
Invalidate(); // AGV 위치 변경시 화면 갱신
}
private void OnAGVStateChanged(object sender, AGVState newState)
{
Invalidate(); // AGV 상태 변경시 화면 갱신
}
private void OnMouseDown(object sender, MouseEventArgs e)
{
if (e.Button == MouseButtons.Right)
{
_isPanning = true;
_lastMousePos = e.Location;
Cursor = Cursors.Hand;
}
}
private void OnMouseMove(object sender, MouseEventArgs e)
{
if (_isPanning)
{
var deltaX = e.X - _lastMousePos.X;
var deltaY = e.Y - _lastMousePos.Y;
_panOffset = new Point(
_panOffset.X + deltaX,
_panOffset.Y + deltaY
);
_lastMousePos = e.Location;
Invalidate();
}
}
private void OnMouseUp(object sender, MouseEventArgs e)
{
if (e.Button == MouseButtons.Right)
{
_isPanning = false;
Cursor = Cursors.Default;
}
}
private void OnMouseWheel(object sender, MouseEventArgs e)
{
var zoomFactor = e.Delta > 0 ? 1.1f : 0.9f;
var newZoom = _zoom * zoomFactor;
if (newZoom >= 0.1f && newZoom <= 10.0f)
{
// 마우스 위치 기준으로 줌
var mouseX = e.X - _panOffset.X;
var mouseY = e.Y - _panOffset.Y;
_panOffset = new Point(
(int)(_panOffset.X - mouseX * (zoomFactor - 1)),
(int)(_panOffset.Y - mouseY * (zoomFactor - 1))
);
_zoom = newZoom;
Invalidate();
}
}
#endregion
#region Painting
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
var g = e.Graphics;
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
// 변환 행렬 설정
g.TranslateTransform(_panOffset.X, _panOffset.Y);
g.ScaleTransform(_zoom, _zoom);
// 배경 그리드 그리기
DrawGrid(g);
// 맵 노드 연결선 그리기
DrawNodeConnections(g);
// 경로 그리기
if (_currentPath != null && _currentPath.Success)
{
DrawPath(g);
}
// 맵 노드 그리기
DrawMapNodes(g);
// AGV 그리기
DrawAGVs(g);
// 정보 표시 (변환 해제)
g.ResetTransform();
DrawInfo(g);
}
private void DrawGrid(Graphics g)
{
var gridSize = 50;
var pen = new Pen(Color.LightGray, 1);
var startX = -(int)(_panOffset.X / _zoom / gridSize) * gridSize;
var startY = -(int)(_panOffset.Y / _zoom / gridSize) * gridSize;
var endX = startX + (int)(Width / _zoom) + gridSize;
var endY = startY + (int)(Height / _zoom) + gridSize;
for (int x = startX; x <= endX; x += gridSize)
{
g.DrawLine(pen, x, startY, x, endY);
}
for (int y = startY; y <= endY; y += gridSize)
{
g.DrawLine(pen, startX, y, endX, y);
}
pen.Dispose();
}
private void DrawNodeConnections(Graphics g)
{
if (_mapNodes == null) return;
foreach (var node in _mapNodes)
{
if (node.ConnectedNodes != null)
{
foreach (var connectedNodeId in node.ConnectedNodes)
{
var connectedNode = _mapNodes.FirstOrDefault(n => n.NodeId == connectedNodeId);
if (connectedNode != null)
{
DrawConnection(g, node.Position, connectedNode.Position);
}
}
}
}
}
private void DrawConnection(Graphics g, Point from, Point to)
{
g.DrawLine(_connectionPen, from, to);
// 방향 화살표 그리기
var angle = Math.Atan2(to.Y - from.Y, to.X - from.X);
var arrowX = to.X - CONNECTION_ARROW_SIZE * Math.Cos(angle);
var arrowY = to.Y - CONNECTION_ARROW_SIZE * Math.Sin(angle);
var arrowPoint1 = new PointF(
(float)(arrowX - CONNECTION_ARROW_SIZE * Math.Cos(angle - Math.PI / 6)),
(float)(arrowY - CONNECTION_ARROW_SIZE * Math.Sin(angle - Math.PI / 6))
);
var arrowPoint2 = new PointF(
(float)(arrowX - CONNECTION_ARROW_SIZE * Math.Cos(angle + Math.PI / 6)),
(float)(arrowY - CONNECTION_ARROW_SIZE * Math.Sin(angle + Math.PI / 6))
);
g.DrawLine(_connectionPen, to, arrowPoint1);
g.DrawLine(_connectionPen, to, arrowPoint2);
}
private void DrawPath(Graphics g)
{
if (_currentPath?.Path == null || _currentPath.Path.Count < 2)
return;
for (int i = 0; i < _currentPath.Path.Count - 1; i++)
{
var currentNodeId = _currentPath.Path[i];
var nextNodeId = _currentPath.Path[i + 1];
var currentNode = _mapNodes?.FirstOrDefault(n => n.NodeId == currentNodeId);
var nextNode = _mapNodes?.FirstOrDefault(n => n.NodeId == nextNodeId);
if (currentNode != null && nextNode != null)
{
g.DrawLine(_pathPen, currentNode.Position, nextNode.Position);
}
}
}
private void DrawMapNodes(Graphics g)
{
if (_mapNodes == null) return;
foreach (var node in _mapNodes)
{
DrawMapNode(g, node);
}
}
private void DrawMapNode(Graphics g, MapNode node)
{
var brush = GetNodeBrush(node.Type);
var rect = new Rectangle(
node.Position.X - NODE_SIZE / 2,
node.Position.Y - NODE_SIZE / 2,
NODE_SIZE,
NODE_SIZE
);
// 노드 그리기
if (node.Type == NodeType.Rotation)
{
g.FillEllipse(brush, rect); // 회전 노드는 원형
}
else
{
g.FillRectangle(brush, rect); // 일반 노드는 사각형
}
g.DrawRectangle(Pens.Black, rect);
// 노드 ID 표시
var font = new Font("Arial", 8);
var textSize = g.MeasureString(node.NodeId, font);
var textPos = new PointF(
node.Position.X - textSize.Width / 2,
node.Position.Y + NODE_SIZE / 2 + 2
);
g.DrawString(node.NodeId, font, Brushes.Black, textPos);
font.Dispose();
}
private Brush GetNodeBrush(NodeType nodeType)
{
switch (nodeType)
{
case NodeType.Rotation: return _rotationNodeBrush;
case NodeType.Docking: return _dockingNodeBrush;
case NodeType.Charging: return _chargingNodeBrush;
default: return _normalNodeBrush;
}
}
private void DrawAGVs(Graphics g)
{
if (_agvList == null) return;
foreach (var agv in _agvList)
{
DrawAGV(g, agv);
}
}
private void DrawAGV(Graphics g, VirtualAGV agv)
{
var position = agv.CurrentPosition;
var rect = new Rectangle(
position.X - AGV_SIZE / 2,
position.Y - AGV_SIZE / 2,
AGV_SIZE,
AGV_SIZE
);
// AGV 상태에 따른 색상 변경
var brush = GetAGVBrush(agv.CurrentState);
// AGV 본체 그리기
g.FillEllipse(brush, rect);
g.DrawEllipse(_agvPen, rect);
// 방향 표시
DrawAGVDirection(g, position, agv.CurrentDirection);
// AGV ID 표시
var font = new Font("Arial", 10, FontStyle.Bold);
var textSize = g.MeasureString(agv.AgvId, font);
var textPos = new PointF(
position.X - textSize.Width / 2,
position.Y + AGV_SIZE / 2 + 5
);
g.DrawString(agv.AgvId, font, Brushes.Black, textPos);
font.Dispose();
}
private Brush GetAGVBrush(AGVState state)
{
switch (state)
{
case AGVState.Moving: return Brushes.Blue;
case AGVState.Rotating: return Brushes.Yellow;
case AGVState.Docking: return Brushes.Orange;
case AGVState.Charging: return Brushes.Green;
case AGVState.Error: return Brushes.Red;
default: return Brushes.Gray; // Idle
}
}
private void DrawAGVDirection(Graphics g, Point position, AgvDirection direction)
{
var arrowSize = 10;
var pen = new Pen(Color.White, 2);
switch (direction)
{
case AgvDirection.Forward:
// 위쪽 화살표
g.DrawLine(pen, position.X, position.Y - arrowSize, position.X, position.Y + arrowSize);
g.DrawLine(pen, position.X, position.Y - arrowSize, position.X - 5, position.Y - arrowSize + 5);
g.DrawLine(pen, position.X, position.Y - arrowSize, position.X + 5, position.Y - arrowSize + 5);
break;
case AgvDirection.Backward:
// 아래쪽 화살표
g.DrawLine(pen, position.X, position.Y - arrowSize, position.X, position.Y + arrowSize);
g.DrawLine(pen, position.X, position.Y + arrowSize, position.X - 5, position.Y + arrowSize - 5);
g.DrawLine(pen, position.X, position.Y + arrowSize, position.X + 5, position.Y + arrowSize - 5);
break;
case AgvDirection.Left:
// 왼쪽 화살표
g.DrawLine(pen, position.X - arrowSize, position.Y, position.X + arrowSize, position.Y);
g.DrawLine(pen, position.X - arrowSize, position.Y, position.X - arrowSize + 5, position.Y - 5);
g.DrawLine(pen, position.X - arrowSize, position.Y, position.X - arrowSize + 5, position.Y + 5);
break;
case AgvDirection.Right:
// 오른쪽 화살표
g.DrawLine(pen, position.X - arrowSize, position.Y, position.X + arrowSize, position.Y);
g.DrawLine(pen, position.X + arrowSize, position.Y, position.X + arrowSize - 5, position.Y - 5);
g.DrawLine(pen, position.X + arrowSize, position.Y, position.X + arrowSize - 5, position.Y + 5);
break;
}
pen.Dispose();
}
private void DrawInfo(Graphics g)
{
var font = new Font("Arial", 10);
var brush = Brushes.Black;
var y = 10;
// 줌 레벨 표시
g.DrawString($"줌: {_zoom:P0}", font, brush, new PointF(10, y));
y += 20;
// AGV 정보 표시
if (_agvList != null)
{
g.DrawString($"AGV 수: {_agvList.Count}", font, brush, new PointF(10, y));
y += 20;
foreach (var agv in _agvList)
{
var info = $"{agv.AgvId}: {agv.CurrentState} ({agv.CurrentPosition.X},{agv.CurrentPosition.Y})";
g.DrawString(info, font, brush, new PointF(10, y));
y += 15;
}
}
// 경로 정보 표시
if (_currentPath != null && _currentPath.Success)
{
y += 10;
g.DrawString($"경로: {_currentPath.Path.Count}개 노드", font, brush, new PointF(10, y));
y += 15;
g.DrawString($"거리: {_currentPath.TotalDistance:F1}", font, brush, new PointF(10, y));
y += 15;
g.DrawString($"계산시간: {_currentPath.CalculationTimeMs}ms", font, brush, new PointF(10, y));
}
font.Dispose();
}
#endregion
#region Cleanup
private void CleanupResources()
{
// AGV 이벤트 연결 해제
if (_agvList != null)
{
foreach (var agv in _agvList)
{
agv.PositionChanged -= OnAGVPositionChanged;
agv.StateChanged -= OnAGVStateChanged;
}
}
// 리소스 정리
_connectionPen?.Dispose();
_pathPen?.Dispose();
_agvPen?.Dispose();
}
#endregion
}
}

View File

@@ -70,6 +70,7 @@ namespace AGVSimulator.Forms
this.startSimulationToolStripButton = new System.Windows.Forms.ToolStripButton(); this.startSimulationToolStripButton = new System.Windows.Forms.ToolStripButton();
this.stopSimulationToolStripButton = new System.Windows.Forms.ToolStripButton(); this.stopSimulationToolStripButton = new System.Windows.Forms.ToolStripButton();
this.resetToolStripButton = new System.Windows.Forms.ToolStripButton(); this.resetToolStripButton = new System.Windows.Forms.ToolStripButton();
this.btAllReset = new System.Windows.Forms.ToolStripButton();
this.toolStripSeparator3 = new System.Windows.Forms.ToolStripSeparator(); this.toolStripSeparator3 = new System.Windows.Forms.ToolStripSeparator();
this.fitToMapToolStripButton = new System.Windows.Forms.ToolStripButton(); this.fitToMapToolStripButton = new System.Windows.Forms.ToolStripButton();
this.resetZoomToolStripButton = new System.Windows.Forms.ToolStripButton(); this.resetZoomToolStripButton = new System.Windows.Forms.ToolStripButton();
@@ -85,6 +86,7 @@ namespace AGVSimulator.Forms
this._clearPathButton = new System.Windows.Forms.Button(); this._clearPathButton = new System.Windows.Forms.Button();
this._startPathButton = new System.Windows.Forms.Button(); this._startPathButton = new System.Windows.Forms.Button();
this._calculatePathButton = new System.Windows.Forms.Button(); this._calculatePathButton = new System.Windows.Forms.Button();
this._avoidRotationCheckBox = new System.Windows.Forms.CheckBox();
this._targetNodeCombo = new System.Windows.Forms.ComboBox(); this._targetNodeCombo = new System.Windows.Forms.ComboBox();
this.targetNodeLabel = new System.Windows.Forms.Label(); this.targetNodeLabel = new System.Windows.Forms.Label();
this._startNodeCombo = new System.Windows.Forms.ComboBox(); this._startNodeCombo = new System.Windows.Forms.ComboBox();
@@ -93,13 +95,19 @@ namespace AGVSimulator.Forms
this._setPositionButton = new System.Windows.Forms.Button(); this._setPositionButton = new System.Windows.Forms.Button();
this._rfidTextBox = new System.Windows.Forms.TextBox(); this._rfidTextBox = new System.Windows.Forms.TextBox();
this._rfidLabel = new System.Windows.Forms.Label(); this._rfidLabel = new System.Windows.Forms.Label();
this._directionCombo = new System.Windows.Forms.ComboBox();
this._directionLabel = new System.Windows.Forms.Label();
this._stopSimulationButton = new System.Windows.Forms.Button(); this._stopSimulationButton = new System.Windows.Forms.Button();
this._startSimulationButton = new System.Windows.Forms.Button(); this._startSimulationButton = new System.Windows.Forms.Button();
this._removeAgvButton = new System.Windows.Forms.Button(); this._removeAgvButton = new System.Windows.Forms.Button();
this._addAgvButton = new System.Windows.Forms.Button(); this._addAgvButton = new System.Windows.Forms.Button();
this._agvListCombo = new System.Windows.Forms.ComboBox(); this._agvListCombo = new System.Windows.Forms.ComboBox();
this._canvasPanel = new System.Windows.Forms.Panel(); this._canvasPanel = new System.Windows.Forms.Panel();
this.btAllReset = new System.Windows.Forms.ToolStripButton(); this._agvInfoPanel = new System.Windows.Forms.Panel();
this._agvInfoTitleLabel = new System.Windows.Forms.Label();
this._liftDirectionLabel = new System.Windows.Forms.Label();
this._motorDirectionLabel = new System.Windows.Forms.Label();
this._pathDebugLabel = new System.Windows.Forms.Label();
this._menuStrip.SuspendLayout(); this._menuStrip.SuspendLayout();
this._toolStrip.SuspendLayout(); this._toolStrip.SuspendLayout();
this._statusStrip.SuspendLayout(); this._statusStrip.SuspendLayout();
@@ -107,6 +115,7 @@ namespace AGVSimulator.Forms
this._statusGroup.SuspendLayout(); this._statusGroup.SuspendLayout();
this._pathGroup.SuspendLayout(); this._pathGroup.SuspendLayout();
this._agvControlGroup.SuspendLayout(); this._agvControlGroup.SuspendLayout();
this._agvInfoPanel.SuspendLayout();
this.SuspendLayout(); this.SuspendLayout();
// //
// _menuStrip // _menuStrip
@@ -139,7 +148,7 @@ namespace AGVSimulator.Forms
// //
this.openMapToolStripMenuItem.Name = "openMapToolStripMenuItem"; this.openMapToolStripMenuItem.Name = "openMapToolStripMenuItem";
this.openMapToolStripMenuItem.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.O))); this.openMapToolStripMenuItem.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.O)));
this.openMapToolStripMenuItem.Size = new System.Drawing.Size(183, 22); this.openMapToolStripMenuItem.Size = new System.Drawing.Size(221, 22);
this.openMapToolStripMenuItem.Text = "맵 열기(&O)..."; this.openMapToolStripMenuItem.Text = "맵 열기(&O)...";
this.openMapToolStripMenuItem.Click += new System.EventHandler(this.OnOpenMap_Click); this.openMapToolStripMenuItem.Click += new System.EventHandler(this.OnOpenMap_Click);
// //
@@ -147,33 +156,33 @@ namespace AGVSimulator.Forms
// //
this.reloadMapToolStripMenuItem.Name = "reloadMapToolStripMenuItem"; this.reloadMapToolStripMenuItem.Name = "reloadMapToolStripMenuItem";
this.reloadMapToolStripMenuItem.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.R))); this.reloadMapToolStripMenuItem.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.R)));
this.reloadMapToolStripMenuItem.Size = new System.Drawing.Size(183, 22); this.reloadMapToolStripMenuItem.Size = new System.Drawing.Size(221, 22);
this.reloadMapToolStripMenuItem.Text = "맵 다시열기(&R)"; this.reloadMapToolStripMenuItem.Text = "맵 다시열기(&R)";
this.reloadMapToolStripMenuItem.Click += new System.EventHandler(this.OnReloadMap_Click); this.reloadMapToolStripMenuItem.Click += new System.EventHandler(this.OnReloadMap_Click);
// //
// toolStripSeparator1 // toolStripSeparator1
// //
this.toolStripSeparator1.Name = "toolStripSeparator1"; this.toolStripSeparator1.Name = "toolStripSeparator1";
this.toolStripSeparator1.Size = new System.Drawing.Size(180, 6); this.toolStripSeparator1.Size = new System.Drawing.Size(218, 6);
// //
// launchMapEditorToolStripMenuItem // launchMapEditorToolStripMenuItem
// //
this.launchMapEditorToolStripMenuItem.Name = "launchMapEditorToolStripMenuItem"; this.launchMapEditorToolStripMenuItem.Name = "launchMapEditorToolStripMenuItem";
this.launchMapEditorToolStripMenuItem.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.M))); this.launchMapEditorToolStripMenuItem.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.M)));
this.launchMapEditorToolStripMenuItem.Size = new System.Drawing.Size(183, 22); this.launchMapEditorToolStripMenuItem.Size = new System.Drawing.Size(221, 22);
this.launchMapEditorToolStripMenuItem.Text = "MapEditor 실행(&M)"; this.launchMapEditorToolStripMenuItem.Text = "MapEditor 실행(&M)";
this.launchMapEditorToolStripMenuItem.Click += new System.EventHandler(this.OnLaunchMapEditor_Click); this.launchMapEditorToolStripMenuItem.Click += new System.EventHandler(this.OnLaunchMapEditor_Click);
// //
// toolStripSeparator4 // toolStripSeparator4
// //
this.toolStripSeparator4.Name = "toolStripSeparator4"; this.toolStripSeparator4.Name = "toolStripSeparator4";
this.toolStripSeparator4.Size = new System.Drawing.Size(180, 6); this.toolStripSeparator4.Size = new System.Drawing.Size(218, 6);
// //
// exitToolStripMenuItem // exitToolStripMenuItem
// //
this.exitToolStripMenuItem.Name = "exitToolStripMenuItem"; this.exitToolStripMenuItem.Name = "exitToolStripMenuItem";
this.exitToolStripMenuItem.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Alt | System.Windows.Forms.Keys.F4))); this.exitToolStripMenuItem.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Alt | System.Windows.Forms.Keys.F4)));
this.exitToolStripMenuItem.Size = new System.Drawing.Size(183, 22); this.exitToolStripMenuItem.Size = new System.Drawing.Size(221, 22);
this.exitToolStripMenuItem.Text = "종료(&X)"; this.exitToolStripMenuItem.Text = "종료(&X)";
this.exitToolStripMenuItem.Click += new System.EventHandler(this.OnExit_Click); this.exitToolStripMenuItem.Click += new System.EventHandler(this.OnExit_Click);
// //
@@ -284,7 +293,7 @@ namespace AGVSimulator.Forms
// //
this.reloadMapToolStripButton.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text; this.reloadMapToolStripButton.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text;
this.reloadMapToolStripButton.Name = "reloadMapToolStripButton"; this.reloadMapToolStripButton.Name = "reloadMapToolStripButton";
this.reloadMapToolStripButton.Size = new System.Drawing.Size(63, 22); this.reloadMapToolStripButton.Size = new System.Drawing.Size(59, 22);
this.reloadMapToolStripButton.Text = "다시열기"; this.reloadMapToolStripButton.Text = "다시열기";
this.reloadMapToolStripButton.ToolTipText = "현재 맵을 다시 로드합니다"; this.reloadMapToolStripButton.ToolTipText = "현재 맵을 다시 로드합니다";
this.reloadMapToolStripButton.Click += new System.EventHandler(this.OnReloadMap_Click); this.reloadMapToolStripButton.Click += new System.EventHandler(this.OnReloadMap_Click);
@@ -293,7 +302,7 @@ namespace AGVSimulator.Forms
// //
this.launchMapEditorToolStripButton.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text; this.launchMapEditorToolStripButton.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text;
this.launchMapEditorToolStripButton.Name = "launchMapEditorToolStripButton"; this.launchMapEditorToolStripButton.Name = "launchMapEditorToolStripButton";
this.launchMapEditorToolStripButton.Size = new System.Drawing.Size(71, 22); this.launchMapEditorToolStripButton.Size = new System.Drawing.Size(66, 22);
this.launchMapEditorToolStripButton.Text = "MapEditor"; this.launchMapEditorToolStripButton.Text = "MapEditor";
this.launchMapEditorToolStripButton.ToolTipText = "MapEditor를 실행합니다"; this.launchMapEditorToolStripButton.ToolTipText = "MapEditor를 실행합니다";
this.launchMapEditorToolStripButton.Click += new System.EventHandler(this.OnLaunchMapEditor_Click); this.launchMapEditorToolStripButton.Click += new System.EventHandler(this.OnLaunchMapEditor_Click);
@@ -330,6 +339,15 @@ namespace AGVSimulator.Forms
this.resetToolStripButton.ToolTipText = "시뮬레이션을 초기화합니다"; this.resetToolStripButton.ToolTipText = "시뮬레이션을 초기화합니다";
this.resetToolStripButton.Click += new System.EventHandler(this.OnReset_Click); this.resetToolStripButton.Click += new System.EventHandler(this.OnReset_Click);
// //
// btAllReset
//
this.btAllReset.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text;
this.btAllReset.Name = "btAllReset";
this.btAllReset.Size = new System.Drawing.Size(71, 22);
this.btAllReset.Text = "전체초기화";
this.btAllReset.ToolTipText = "시뮬레이션을 초기화합니다";
this.btAllReset.Click += new System.EventHandler(this.btAllReset_Click);
//
// toolStripSeparator3 // toolStripSeparator3
// //
this.toolStripSeparator3.Name = "toolStripSeparator3"; this.toolStripSeparator3.Name = "toolStripSeparator3";
@@ -382,9 +400,9 @@ namespace AGVSimulator.Forms
this._controlPanel.Controls.Add(this._pathGroup); this._controlPanel.Controls.Add(this._pathGroup);
this._controlPanel.Controls.Add(this._agvControlGroup); this._controlPanel.Controls.Add(this._agvControlGroup);
this._controlPanel.Dock = System.Windows.Forms.DockStyle.Right; this._controlPanel.Dock = System.Windows.Forms.DockStyle.Right;
this._controlPanel.Location = new System.Drawing.Point(950, 49); this._controlPanel.Location = new System.Drawing.Point(967, 49);
this._controlPanel.Name = "_controlPanel"; this._controlPanel.Name = "_controlPanel";
this._controlPanel.Size = new System.Drawing.Size(250, 729); this._controlPanel.Size = new System.Drawing.Size(233, 729);
this._controlPanel.TabIndex = 3; this._controlPanel.TabIndex = 3;
// //
// _statusGroup // _statusGroup
@@ -392,9 +410,10 @@ namespace AGVSimulator.Forms
this._statusGroup.Controls.Add(this._pathLengthLabel); this._statusGroup.Controls.Add(this._pathLengthLabel);
this._statusGroup.Controls.Add(this._agvCountLabel); this._statusGroup.Controls.Add(this._agvCountLabel);
this._statusGroup.Controls.Add(this._simulationStatusLabel); this._statusGroup.Controls.Add(this._simulationStatusLabel);
this._statusGroup.Location = new System.Drawing.Point(10, 356); this._statusGroup.Dock = System.Windows.Forms.DockStyle.Top;
this._statusGroup.Location = new System.Drawing.Point(0, 404);
this._statusGroup.Name = "_statusGroup"; this._statusGroup.Name = "_statusGroup";
this._statusGroup.Size = new System.Drawing.Size(230, 100); this._statusGroup.Size = new System.Drawing.Size(233, 100);
this._statusGroup.TabIndex = 3; this._statusGroup.TabIndex = 3;
this._statusGroup.TabStop = false; this._statusGroup.TabStop = false;
this._statusGroup.Text = "상태 정보"; this._statusGroup.Text = "상태 정보";
@@ -431,20 +450,22 @@ namespace AGVSimulator.Forms
this._pathGroup.Controls.Add(this._clearPathButton); this._pathGroup.Controls.Add(this._clearPathButton);
this._pathGroup.Controls.Add(this._startPathButton); this._pathGroup.Controls.Add(this._startPathButton);
this._pathGroup.Controls.Add(this._calculatePathButton); this._pathGroup.Controls.Add(this._calculatePathButton);
this._pathGroup.Controls.Add(this._avoidRotationCheckBox);
this._pathGroup.Controls.Add(this._targetNodeCombo); this._pathGroup.Controls.Add(this._targetNodeCombo);
this._pathGroup.Controls.Add(this.targetNodeLabel); this._pathGroup.Controls.Add(this.targetNodeLabel);
this._pathGroup.Controls.Add(this._startNodeCombo); this._pathGroup.Controls.Add(this._startNodeCombo);
this._pathGroup.Controls.Add(this.startNodeLabel); this._pathGroup.Controls.Add(this.startNodeLabel);
this._pathGroup.Location = new System.Drawing.Point(10, 200); this._pathGroup.Dock = System.Windows.Forms.DockStyle.Top;
this._pathGroup.Location = new System.Drawing.Point(0, 214);
this._pathGroup.Name = "_pathGroup"; this._pathGroup.Name = "_pathGroup";
this._pathGroup.Size = new System.Drawing.Size(230, 150); this._pathGroup.Size = new System.Drawing.Size(233, 190);
this._pathGroup.TabIndex = 1; this._pathGroup.TabIndex = 1;
this._pathGroup.TabStop = false; this._pathGroup.TabStop = false;
this._pathGroup.Text = "경로 제어"; this._pathGroup.Text = "경로 제어";
// //
// _clearPathButton // _clearPathButton
// //
this._clearPathButton.Location = new System.Drawing.Point(150, 120); this._clearPathButton.Location = new System.Drawing.Point(150, 148);
this._clearPathButton.Name = "_clearPathButton"; this._clearPathButton.Name = "_clearPathButton";
this._clearPathButton.Size = new System.Drawing.Size(70, 25); this._clearPathButton.Size = new System.Drawing.Size(70, 25);
this._clearPathButton.TabIndex = 6; this._clearPathButton.TabIndex = 6;
@@ -454,7 +475,7 @@ namespace AGVSimulator.Forms
// //
// _startPathButton // _startPathButton
// //
this._startPathButton.Location = new System.Drawing.Point(80, 120); this._startPathButton.Location = new System.Drawing.Point(80, 148);
this._startPathButton.Name = "_startPathButton"; this._startPathButton.Name = "_startPathButton";
this._startPathButton.Size = new System.Drawing.Size(65, 25); this._startPathButton.Size = new System.Drawing.Size(65, 25);
this._startPathButton.TabIndex = 5; this._startPathButton.TabIndex = 5;
@@ -464,7 +485,7 @@ namespace AGVSimulator.Forms
// //
// _calculatePathButton // _calculatePathButton
// //
this._calculatePathButton.Location = new System.Drawing.Point(10, 120); this._calculatePathButton.Location = new System.Drawing.Point(10, 148);
this._calculatePathButton.Name = "_calculatePathButton"; this._calculatePathButton.Name = "_calculatePathButton";
this._calculatePathButton.Size = new System.Drawing.Size(65, 25); this._calculatePathButton.Size = new System.Drawing.Size(65, 25);
this._calculatePathButton.TabIndex = 4; this._calculatePathButton.TabIndex = 4;
@@ -472,10 +493,20 @@ namespace AGVSimulator.Forms
this._calculatePathButton.UseVisualStyleBackColor = true; this._calculatePathButton.UseVisualStyleBackColor = true;
this._calculatePathButton.Click += new System.EventHandler(this.OnCalculatePath_Click); this._calculatePathButton.Click += new System.EventHandler(this.OnCalculatePath_Click);
// //
// _avoidRotationCheckBox
//
this._avoidRotationCheckBox.AutoSize = true;
this._avoidRotationCheckBox.Location = new System.Drawing.Point(10, 126);
this._avoidRotationCheckBox.Name = "_avoidRotationCheckBox";
this._avoidRotationCheckBox.Size = new System.Drawing.Size(104, 16);
this._avoidRotationCheckBox.TabIndex = 7;
this._avoidRotationCheckBox.Text = "회전 구간 회피";
this._avoidRotationCheckBox.UseVisualStyleBackColor = true;
//
// _targetNodeCombo // _targetNodeCombo
// //
this._targetNodeCombo.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; this._targetNodeCombo.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
this._targetNodeCombo.Location = new System.Drawing.Point(10, 95); this._targetNodeCombo.Location = new System.Drawing.Point(10, 97);
this._targetNodeCombo.Name = "_targetNodeCombo"; this._targetNodeCombo.Name = "_targetNodeCombo";
this._targetNodeCombo.Size = new System.Drawing.Size(210, 20); this._targetNodeCombo.Size = new System.Drawing.Size(210, 20);
this._targetNodeCombo.TabIndex = 3; this._targetNodeCombo.TabIndex = 3;
@@ -511,14 +542,17 @@ namespace AGVSimulator.Forms
this._agvControlGroup.Controls.Add(this._setPositionButton); this._agvControlGroup.Controls.Add(this._setPositionButton);
this._agvControlGroup.Controls.Add(this._rfidTextBox); this._agvControlGroup.Controls.Add(this._rfidTextBox);
this._agvControlGroup.Controls.Add(this._rfidLabel); this._agvControlGroup.Controls.Add(this._rfidLabel);
this._agvControlGroup.Controls.Add(this._directionCombo);
this._agvControlGroup.Controls.Add(this._directionLabel);
this._agvControlGroup.Controls.Add(this._stopSimulationButton); this._agvControlGroup.Controls.Add(this._stopSimulationButton);
this._agvControlGroup.Controls.Add(this._startSimulationButton); this._agvControlGroup.Controls.Add(this._startSimulationButton);
this._agvControlGroup.Controls.Add(this._removeAgvButton); this._agvControlGroup.Controls.Add(this._removeAgvButton);
this._agvControlGroup.Controls.Add(this._addAgvButton); this._agvControlGroup.Controls.Add(this._addAgvButton);
this._agvControlGroup.Controls.Add(this._agvListCombo); this._agvControlGroup.Controls.Add(this._agvListCombo);
this._agvControlGroup.Location = new System.Drawing.Point(10, 10); this._agvControlGroup.Dock = System.Windows.Forms.DockStyle.Top;
this._agvControlGroup.Location = new System.Drawing.Point(0, 0);
this._agvControlGroup.Name = "_agvControlGroup"; this._agvControlGroup.Name = "_agvControlGroup";
this._agvControlGroup.Size = new System.Drawing.Size(230, 180); this._agvControlGroup.Size = new System.Drawing.Size(233, 214);
this._agvControlGroup.TabIndex = 0; this._agvControlGroup.TabIndex = 0;
this._agvControlGroup.TabStop = false; this._agvControlGroup.TabStop = false;
this._agvControlGroup.Text = "AGV 제어"; this._agvControlGroup.Text = "AGV 제어";
@@ -527,7 +561,7 @@ namespace AGVSimulator.Forms
// //
this._setPositionButton.Location = new System.Drawing.Point(160, 138); this._setPositionButton.Location = new System.Drawing.Point(160, 138);
this._setPositionButton.Name = "_setPositionButton"; this._setPositionButton.Name = "_setPositionButton";
this._setPositionButton.Size = new System.Drawing.Size(60, 25); this._setPositionButton.Size = new System.Drawing.Size(60, 67);
this._setPositionButton.TabIndex = 7; this._setPositionButton.TabIndex = 7;
this._setPositionButton.Text = "위치설정"; this._setPositionButton.Text = "위치설정";
this._setPositionButton.UseVisualStyleBackColor = true; this._setPositionButton.UseVisualStyleBackColor = true;
@@ -550,6 +584,24 @@ namespace AGVSimulator.Forms
this._rfidLabel.TabIndex = 5; this._rfidLabel.TabIndex = 5;
this._rfidLabel.Text = "RFID 현재위치:"; this._rfidLabel.Text = "RFID 현재위치:";
// //
// _directionCombo
//
this._directionCombo.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
this._directionCombo.FormattingEnabled = true;
this._directionCombo.Location = new System.Drawing.Point(10, 185);
this._directionCombo.Name = "_directionCombo";
this._directionCombo.Size = new System.Drawing.Size(140, 20);
this._directionCombo.TabIndex = 8;
//
// _directionLabel
//
this._directionLabel.AutoSize = true;
this._directionLabel.Location = new System.Drawing.Point(10, 165);
this._directionLabel.Name = "_directionLabel";
this._directionLabel.Size = new System.Drawing.Size(85, 12);
this._directionLabel.TabIndex = 9;
this._directionLabel.Text = "모터 구동방향:";
//
// _stopSimulationButton // _stopSimulationButton
// //
this._stopSimulationButton.Location = new System.Drawing.Point(120, 85); this._stopSimulationButton.Location = new System.Drawing.Point(120, 85);
@@ -602,19 +654,65 @@ namespace AGVSimulator.Forms
// _canvasPanel // _canvasPanel
// //
this._canvasPanel.Dock = System.Windows.Forms.DockStyle.Fill; this._canvasPanel.Dock = System.Windows.Forms.DockStyle.Fill;
this._canvasPanel.Location = new System.Drawing.Point(0, 49); this._canvasPanel.Location = new System.Drawing.Point(0, 109);
this._canvasPanel.Name = "_canvasPanel"; this._canvasPanel.Name = "_canvasPanel";
this._canvasPanel.Size = new System.Drawing.Size(950, 729); this._canvasPanel.Size = new System.Drawing.Size(967, 669);
this._canvasPanel.TabIndex = 4; this._canvasPanel.TabIndex = 4;
// //
// btAllReset // _agvInfoPanel
// //
this.btAllReset.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text; this._agvInfoPanel.BackColor = System.Drawing.Color.LightBlue;
this.btAllReset.Name = "btAllReset"; this._agvInfoPanel.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
this.btAllReset.Size = new System.Drawing.Size(71, 22); this._agvInfoPanel.Controls.Add(this._agvInfoTitleLabel);
this.btAllReset.Text = "전체초기화"; this._agvInfoPanel.Controls.Add(this._liftDirectionLabel);
this.btAllReset.ToolTipText = "시뮬레이션을 초기화합니다"; this._agvInfoPanel.Controls.Add(this._motorDirectionLabel);
this.btAllReset.Click += new System.EventHandler(this.btAllReset_Click); this._agvInfoPanel.Controls.Add(this._pathDebugLabel);
this._agvInfoPanel.Dock = System.Windows.Forms.DockStyle.Top;
this._agvInfoPanel.Location = new System.Drawing.Point(0, 49);
this._agvInfoPanel.Name = "_agvInfoPanel";
this._agvInfoPanel.Size = new System.Drawing.Size(967, 60);
this._agvInfoPanel.TabIndex = 5;
//
// _agvInfoTitleLabel
//
this._agvInfoTitleLabel.AutoSize = true;
this._agvInfoTitleLabel.Font = new System.Drawing.Font("맑은 고딕", 9F, System.Drawing.FontStyle.Bold, System.Drawing.GraphicsUnit.Point, ((byte)(129)));
this._agvInfoTitleLabel.Location = new System.Drawing.Point(10, 12);
this._agvInfoTitleLabel.Name = "_agvInfoTitleLabel";
this._agvInfoTitleLabel.Size = new System.Drawing.Size(91, 15);
this._agvInfoTitleLabel.TabIndex = 0;
this._agvInfoTitleLabel.Text = "AGV 상태 정보:";
//
// _liftDirectionLabel
//
this._liftDirectionLabel.AutoSize = true;
this._liftDirectionLabel.Font = new System.Drawing.Font("맑은 고딕", 9F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(129)));
this._liftDirectionLabel.Location = new System.Drawing.Point(120, 12);
this._liftDirectionLabel.Name = "_liftDirectionLabel";
this._liftDirectionLabel.Size = new System.Drawing.Size(83, 15);
this._liftDirectionLabel.TabIndex = 1;
this._liftDirectionLabel.Text = "리프트 방향: -";
//
// _motorDirectionLabel
//
this._motorDirectionLabel.AutoSize = true;
this._motorDirectionLabel.Font = new System.Drawing.Font("맑은 고딕", 9F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(129)));
this._motorDirectionLabel.Location = new System.Drawing.Point(250, 12);
this._motorDirectionLabel.Name = "_motorDirectionLabel";
this._motorDirectionLabel.Size = new System.Drawing.Size(71, 15);
this._motorDirectionLabel.TabIndex = 2;
this._motorDirectionLabel.Text = "모터 방향: -";
//
// _pathDebugLabel
//
this._pathDebugLabel.AutoSize = true;
this._pathDebugLabel.Font = new System.Drawing.Font("맑은 고딕", 9F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(129)));
this._pathDebugLabel.ForeColor = System.Drawing.Color.DarkBlue;
this._pathDebugLabel.Location = new System.Drawing.Point(10, 30);
this._pathDebugLabel.Name = "_pathDebugLabel";
this._pathDebugLabel.Size = new System.Drawing.Size(114, 15);
this._pathDebugLabel.TabIndex = 3;
this._pathDebugLabel.Text = "경로: 설정되지 않음";
// //
// SimulatorForm // SimulatorForm
// //
@@ -622,6 +720,7 @@ namespace AGVSimulator.Forms
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(1200, 800); this.ClientSize = new System.Drawing.Size(1200, 800);
this.Controls.Add(this._canvasPanel); this.Controls.Add(this._canvasPanel);
this.Controls.Add(this._agvInfoPanel);
this.Controls.Add(this._controlPanel); this.Controls.Add(this._controlPanel);
this.Controls.Add(this._statusStrip); this.Controls.Add(this._statusStrip);
this.Controls.Add(this._toolStrip); this.Controls.Add(this._toolStrip);
@@ -644,6 +743,8 @@ namespace AGVSimulator.Forms
this._pathGroup.PerformLayout(); this._pathGroup.PerformLayout();
this._agvControlGroup.ResumeLayout(false); this._agvControlGroup.ResumeLayout(false);
this._agvControlGroup.PerformLayout(); this._agvControlGroup.PerformLayout();
this._agvInfoPanel.ResumeLayout(false);
this._agvInfoPanel.PerformLayout();
this.ResumeLayout(false); this.ResumeLayout(false);
this.PerformLayout(); this.PerformLayout();
@@ -692,6 +793,7 @@ namespace AGVSimulator.Forms
private System.Windows.Forms.Button _calculatePathButton; private System.Windows.Forms.Button _calculatePathButton;
private System.Windows.Forms.Button _startPathButton; private System.Windows.Forms.Button _startPathButton;
private System.Windows.Forms.Button _clearPathButton; private System.Windows.Forms.Button _clearPathButton;
private System.Windows.Forms.CheckBox _avoidRotationCheckBox;
private System.Windows.Forms.GroupBox _statusGroup; private System.Windows.Forms.GroupBox _statusGroup;
private System.Windows.Forms.Label _simulationStatusLabel; private System.Windows.Forms.Label _simulationStatusLabel;
private System.Windows.Forms.Label _agvCountLabel; private System.Windows.Forms.Label _agvCountLabel;
@@ -700,11 +802,18 @@ namespace AGVSimulator.Forms
private System.Windows.Forms.Label _rfidLabel; private System.Windows.Forms.Label _rfidLabel;
private System.Windows.Forms.TextBox _rfidTextBox; private System.Windows.Forms.TextBox _rfidTextBox;
private System.Windows.Forms.Button _setPositionButton; private System.Windows.Forms.Button _setPositionButton;
private System.Windows.Forms.ComboBox _directionCombo;
private System.Windows.Forms.Label _directionLabel;
private System.Windows.Forms.ToolStripButton btAllReset; private System.Windows.Forms.ToolStripButton btAllReset;
private System.Windows.Forms.ToolStripMenuItem reloadMapToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem reloadMapToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem launchMapEditorToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem launchMapEditorToolStripMenuItem;
private System.Windows.Forms.ToolStripSeparator toolStripSeparator4; private System.Windows.Forms.ToolStripSeparator toolStripSeparator4;
private System.Windows.Forms.ToolStripButton reloadMapToolStripButton; private System.Windows.Forms.ToolStripButton reloadMapToolStripButton;
private System.Windows.Forms.ToolStripButton launchMapEditorToolStripButton; private System.Windows.Forms.ToolStripButton launchMapEditorToolStripButton;
private System.Windows.Forms.Panel _agvInfoPanel;
private System.Windows.Forms.Label _liftDirectionLabel;
private System.Windows.Forms.Label _motorDirectionLabel;
private System.Windows.Forms.Label _agvInfoTitleLabel;
private System.Windows.Forms.Label _pathDebugLabel;
} }
} }

View File

@@ -25,6 +25,7 @@ namespace AGVSimulator.Forms
private List<MapNode> _mapNodes; private List<MapNode> _mapNodes;
private NodeResolver _nodeResolver; private NodeResolver _nodeResolver;
private PathCalculator _pathCalculator; private PathCalculator _pathCalculator;
private AdvancedAGVPathfinder _advancedPathfinder;
private List<VirtualAGV> _agvList; private List<VirtualAGV> _agvList;
private SimulationState _simulationState; private SimulationState _simulationState;
private Timer _simulationTimer; private Timer _simulationTimer;
@@ -50,6 +51,9 @@ namespace AGVSimulator.Forms
{ {
InitializeComponent(); InitializeComponent();
InitializeForm(); InitializeForm();
// Load 이벤트 연결
this.Load += SimulatorForm_Load;
} }
#endregion #endregion
@@ -80,6 +84,8 @@ namespace AGVSimulator.Forms
// 초기 상태 설정 // 초기 상태 설정
UpdateUI(); UpdateUI();
// 마지막 맵 파일 자동 로드 확인은 Form_Load에서 수행
} }
@@ -121,6 +127,12 @@ namespace AGVSimulator.Forms
#region Event Handlers #region Event Handlers
private void SimulatorForm_Load(object sender, EventArgs e)
{
// 폼이 완전히 로드된 후 마지막 맵 파일 자동 로드 확인
CheckAndLoadLastMapFile();
}
private void OnOpenMap_Click(object sender, EventArgs e) private void OnOpenMap_Click(object sender, EventArgs e)
{ {
using (var openDialog = new OpenFileDialog()) using (var openDialog = new OpenFileDialog())
@@ -279,7 +291,39 @@ namespace AGVSimulator.Forms
_pathCalculator.SetMapData(_mapNodes); _pathCalculator.SetMapData(_mapNodes);
} }
var agvResult = _pathCalculator.FindAGVPath(startNode.NodeId, targetNode.NodeId); if (_advancedPathfinder == null)
{
_advancedPathfinder = new AdvancedAGVPathfinder(_mapNodes);
}
// 현재 AGV 방향 가져오기
var selectedAGV = _agvListCombo.SelectedItem as VirtualAGV;
var currentDirection = selectedAGV?.CurrentDirection ?? AgvDirection.Forward;
// 고급 경로 계획 사용
var advancedResult = _advancedPathfinder.FindAdvancedPath(startNode.NodeId, targetNode.NodeId, currentDirection);
if (advancedResult.Success)
{
// 고급 경로 결과를 기존 AGVPathResult 형태로 변환
var agvResult1 = ConvertToAGVPathResult(advancedResult);
_simulatorCanvas.CurrentPath = agvResult1.ToPathResult();
_pathLengthLabel.Text = $"경로 길이: {advancedResult.TotalDistance:F1}";
_statusLabel.Text = $"고급 경로 계산 완료 ({advancedResult.CalculationTimeMs}ms)";
// 고급 경로 디버깅 정보 표시
UpdateAdvancedPathDebugInfo(advancedResult);
return;
}
// 고급 경로 실패시 기존 방식으로 fallback
// 회전 회피 옵션 설정
var options = _avoidRotationCheckBox.Checked
? PathfindingOptions.AvoidRotation
: PathfindingOptions.Default;
var agvResult = _pathCalculator.FindAGVPath(startNode.NodeId, targetNode.NodeId, null, options);
if (agvResult.Success) if (agvResult.Success)
{ {
@@ -434,8 +478,12 @@ namespace AGVSimulator.Forms
if (nodesWithRfid.Count == 0) if (nodesWithRfid.Count == 0)
return "RFID가 할당된 노드가 없습니다."; return "RFID가 할당된 노드가 없습니다.";
// 처음 10개의 RFID만 표시 // 처음 10개의 RFID만 표시 (노드 이름 포함)
var rfidList = nodesWithRfid.Take(10).Select(n => $"- {n.RfidId} → {n.NodeId}"); var rfidList = nodesWithRfid.Take(10).Select(n =>
{
var nodeNamePart = !string.IsNullOrEmpty(n.Name) ? $" {n.Name}" : "";
return $"- {n.RfidId} → {n.NodeId}{nodeNamePart}";
});
var result = string.Join("\n", rfidList); var result = string.Join("\n", rfidList);
if (nodesWithRfid.Count > 10) if (nodesWithRfid.Count > 10)
@@ -457,8 +505,7 @@ namespace AGVSimulator.Forms
_mapNodes = result.Nodes; _mapNodes = result.Nodes;
_currentMapFilePath = filePath; _currentMapFilePath = filePath;
// RFID가 없는 노드들에 자동 할당 // RFID 자동 할당 제거 - 에디터에서 설정한 값 그대로 사용
MapLoader.AssignAutoRfidIds(_mapNodes);
// 시뮬레이터 캔버스에 맵 설정 // 시뮬레이터 캔버스에 맵 설정
_simulatorCanvas.Nodes = _mapNodes; _simulatorCanvas.Nodes = _mapNodes;
@@ -488,6 +535,35 @@ namespace AGVSimulator.Forms
} }
} }
/// <summary>
/// 마지막 맵 파일이 있는지 확인하고 사용자에게 로드할지 물어봄
/// </summary>
private void CheckAndLoadLastMapFile()
{
if (_config.AutoLoadLastMapFile && _config.HasValidLastMapFile())
{
string fileName = Path.GetFileName(_config.LastMapFilePath);
var result = MessageBox.Show(
$"마지막으로 사용한 맵 파일을 찾았습니다:\n\n{fileName}\n\n이 파일을 열까요?",
"마지막 맵 파일 로드",
MessageBoxButtons.YesNo,
MessageBoxIcon.Question);
if (result == DialogResult.Yes)
{
try
{
LoadMapFile(_config.LastMapFilePath);
}
catch (Exception ex)
{
MessageBox.Show($"맵 파일 로드 중 오류가 발생했습니다:\n{ex.Message}",
"맵 파일 로드 오류", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
}
}
private void UpdateNodeComboBoxes() private void UpdateNodeComboBoxes()
{ {
_startNodeCombo.Items.Clear(); _startNodeCombo.Items.Clear();
@@ -499,8 +575,9 @@ namespace AGVSimulator.Forms
{ {
if (node.IsActive && node.HasRfid()) if (node.IsActive && node.HasRfid())
{ {
// {rfid} - [{node}] 형식으로 ComboBoxItem 생성 // {rfid} - [{node}] {name} 형식으로 ComboBoxItem 생성
var displayText = $"{node.RfidId} - [{node.NodeId}]"; var nodeNamePart = !string.IsNullOrEmpty(node.Name) ? $" {node.Name}" : "";
var displayText = $"{node.RfidId} - [{node.NodeId}]{nodeNamePart}";
var item = new ComboBoxItem<MapNode>(node, displayText); var item = new ComboBoxItem<MapNode>(node, displayText);
_startNodeCombo.Items.Add(item); _startNodeCombo.Items.Add(item);
@@ -709,6 +786,111 @@ namespace AGVSimulator.Forms
return node?.HasRfid() == true ? node.RfidId : nodeId; return node?.HasRfid() == true ? node.RfidId : nodeId;
} }
/// <summary>
/// 고급 경로 결과를 기존 AGVPathResult 형태로 변환
/// </summary>
private AGVPathResult ConvertToAGVPathResult(AdvancedAGVPathfinder.AdvancedPathResult advancedResult)
{
var agvResult = new AGVPathResult();
agvResult.Success = advancedResult.Success;
agvResult.Path = advancedResult.GetSimplePath();
agvResult.NodeMotorInfos = advancedResult.DetailedPath;
agvResult.TotalDistance = advancedResult.TotalDistance;
agvResult.CalculationTimeMs = advancedResult.CalculationTimeMs;
agvResult.ExploredNodes = advancedResult.ExploredNodeCount;
agvResult.ErrorMessage = advancedResult.ErrorMessage;
return agvResult;
}
/// <summary>
/// 고급 경로 디버깅 정보 업데이트
/// </summary>
private void UpdateAdvancedPathDebugInfo(AdvancedAGVPathfinder.AdvancedPathResult advancedResult)
{
if (advancedResult == null || !advancedResult.Success)
{
_pathDebugLabel.Text = "고급 경로: 설정되지 않음";
return;
}
// 노드 ID를 RFID로 변환한 경로 생성
var pathWithRfid = advancedResult.GetSimplePath().Select(nodeId => GetRfidByNodeId(nodeId)).ToList();
// 콘솔 디버그 정보 출력
Program.WriteLine($"[ADVANCED DEBUG] 고급 경로 계산 완료:");
Program.WriteLine($" 전체 경로 (RFID): [{string.Join(" ", pathWithRfid)}]");
Program.WriteLine($" 전체 경로 (NodeID): [{string.Join(" ", advancedResult.GetSimplePath())}]");
Program.WriteLine($" 경로 노드 수: {advancedResult.DetailedPath.Count}");
Program.WriteLine($" 방향 전환 필요: {advancedResult.RequiredDirectionChange}");
if (advancedResult.RequiredDirectionChange && !string.IsNullOrEmpty(advancedResult.DirectionChangeNode))
{
Program.WriteLine($" 방향 전환 노드: {advancedResult.DirectionChangeNode}");
}
Program.WriteLine($" 설명: {advancedResult.PlanDescription}");
// 상세 경로 정보 출력
for (int i = 0; i < advancedResult.DetailedPath.Count; i++)
{
var info = advancedResult.DetailedPath[i];
var rfidId = GetRfidByNodeId(info.NodeId);
var nextRfidId = info.NextNodeId != null ? GetRfidByNodeId(info.NextNodeId) : "END";
var flags = new List<string>();
if (info.CanRotate) flags.Add("회전가능");
if (info.IsDirectionChangePoint) flags.Add("방향전환");
if (info.RequiresSpecialAction) flags.Add($"특수동작:{info.SpecialActionDescription}");
if (info.MagnetDirection != MagnetDirection.Straight) flags.Add($"마그넷:{info.MagnetDirection}");
var flagsStr = flags.Count > 0 ? $" [{string.Join(", ", flags)}]" : "";
Program.WriteLine($" {i}: {rfidId}({info.NodeId}) → {info.MotorDirection} → {nextRfidId}{flagsStr}");
}
// 경로 문자열 구성 (마그넷 방향 포함)
var pathWithDetails = new List<string>();
for (int i = 0; i < advancedResult.DetailedPath.Count; i++)
{
var motorInfo = advancedResult.DetailedPath[i];
var rfidId = GetRfidByNodeId(motorInfo.NodeId);
string motorSymbol = motorInfo.MotorDirection == AgvDirection.Forward ? "[전진]" : "[후진]";
// 마그넷 방향 표시
if (motorInfo.MagnetDirection != MagnetDirection.Straight)
{
string magnetSymbol = motorInfo.MagnetDirection == MagnetDirection.Left ? "[←]" : "[→]";
motorSymbol += magnetSymbol;
}
// 특수 동작 표시
if (motorInfo.RequiresSpecialAction)
motorSymbol += "[🔄]";
else if (motorInfo.IsDirectionChangePoint && motorInfo.CanRotate)
motorSymbol += "[↻]";
pathWithDetails.Add($"{rfidId}{motorSymbol}");
}
string pathString = string.Join(" → ", pathWithDetails);
// UI에 표시 (길이 제한)
if (pathString.Length > 100)
{
pathString = pathString.Substring(0, 97) + "...";
}
// 통계 정보
var forwardCount = advancedResult.DetailedPath.Count(m => m.MotorDirection == AgvDirection.Forward);
var backwardCount = advancedResult.DetailedPath.Count(m => m.MotorDirection == AgvDirection.Backward);
var magnetDirectionChanges = advancedResult.DetailedPath.Count(m => m.MagnetDirection != MagnetDirection.Straight);
string stats = $"전진: {forwardCount}, 후진: {backwardCount}";
if (magnetDirectionChanges > 0)
stats += $", 마그넷제어: {magnetDirectionChanges}";
_pathDebugLabel.Text = $"고급경로: {pathString} (총 {advancedResult.DetailedPath.Count}개 노드, {advancedResult.TotalDistance:F1}px, {stats})";
}
/// <summary> /// <summary>
/// 경로 디버깅 정보 업데이트 (RFID 값 표시, 모터방향 정보 포함) /// 경로 디버깅 정보 업데이트 (RFID 값 표시, 모터방향 정보 포함)
/// </summary> /// </summary>
@@ -736,7 +918,14 @@ namespace AGVSimulator.Forms
var info = agvResult.NodeMotorInfos[i]; var info = agvResult.NodeMotorInfos[i];
var rfidId = GetRfidByNodeId(info.NodeId); var rfidId = GetRfidByNodeId(info.NodeId);
var nextRfidId = info.NextNodeId != null ? GetRfidByNodeId(info.NextNodeId) : "END"; var nextRfidId = info.NextNodeId != null ? GetRfidByNodeId(info.NextNodeId) : "END";
Program.WriteLine($" {i}: {rfidId}({info.NodeId}) → {info.MotorDirection} → {nextRfidId}");
var flags = new List<string>();
if (info.CanRotate) flags.Add("회전가능");
if (info.IsDirectionChangePoint) flags.Add("방향전환");
if (info.RequiresSpecialAction) flags.Add($"특수동작:{info.SpecialActionDescription}");
var flagsStr = flags.Count > 0 ? $" [{string.Join(", ", flags)}]" : "";
Program.WriteLine($" {i}: {rfidId}({info.NodeId}) → {info.MotorDirection} → {nextRfidId}{flagsStr}");
} }
} }
@@ -751,6 +940,13 @@ namespace AGVSimulator.Forms
var motorInfo = agvResult.NodeMotorInfos[i]; var motorInfo = agvResult.NodeMotorInfos[i];
var rfidId = GetRfidByNodeId(motorInfo.NodeId); var rfidId = GetRfidByNodeId(motorInfo.NodeId);
string motorSymbol = motorInfo.MotorDirection == AgvDirection.Forward ? "[전진]" : "[후진]"; string motorSymbol = motorInfo.MotorDirection == AgvDirection.Forward ? "[전진]" : "[후진]";
// 특수 동작 표시 추가
if (motorInfo.RequiresSpecialAction)
motorSymbol += "[🔄]";
else if (motorInfo.IsDirectionChangePoint && motorInfo.CanRotate)
motorSymbol += "[↻]";
pathWithMotorInfo.Add($"{rfidId}{motorSymbol}"); pathWithMotorInfo.Add($"{rfidId}{motorSymbol}");
} }
pathString = string.Join(" → ", pathWithMotorInfo); pathString = string.Join(" → ", pathWithMotorInfo);

View File

@@ -26,6 +26,11 @@ namespace AGVSimulator.Models
/// </summary> /// </summary>
public bool AutoSave { get; set; } = true; public bool AutoSave { get; set; } = true;
/// <summary>
/// 프로그램 시작시 마지막 맵 파일을 자동으로 로드할지 여부
/// </summary>
public bool AutoLoadLastMapFile { get; set; } = true;
#endregion #endregion
#region Static Methods #region Static Methods
@@ -109,6 +114,15 @@ namespace AGVSimulator.Models
File.Exists(MapEditorExecutablePath); File.Exists(MapEditorExecutablePath);
} }
/// <summary>
/// 마지막 맵 파일이 존재하는지 확인
/// </summary>
/// <returns>마지막 맵 파일이 유효한지 여부</returns>
public bool HasValidLastMapFile()
{
return !string.IsNullOrEmpty(LastMapFilePath) && File.Exists(LastMapFilePath);
}
#endregion #endregion
} }
} }

View File

@@ -9,21 +9,33 @@ namespace AGVSimulator
/// </summary> /// </summary>
static class Program static class Program
{ {
/// <summary>
/// 콘솔 출력 (타임스탬프 포함)
/// </summary>
public static void WriteLine(string message)
{
string timestampedMessage = $"[{DateTime.Now:HH:mm:ss.fff}] {message}";
Console.WriteLine(timestampedMessage);
}
/// <summary> /// <summary>
/// 애플리케이션의 주 진입점입니다. /// 애플리케이션의 주 진입점입니다.
/// </summary> /// </summary>
/// <param name="args">명령줄 인수</param>
[STAThread] [STAThread]
static void Main() static void Main(string[] args)
{
try
{ {
Application.EnableVisualStyles(); Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false); Application.SetCompatibleTextRenderingDefault(false);
try
{
Application.Run(new SimulatorForm()); Application.Run(new SimulatorForm());
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"[ERROR] 시뮬레이터 실행 중 오류: {ex.Message}");
Console.WriteLine($"[ERROR] 스택 트레이스: {ex.StackTrace}");
MessageBox.Show($"시뮬레이터 실행 중 오류가 발생했습니다:\n{ex.Message}", MessageBox.Show($"시뮬레이터 실행 중 오류가 발생했습니다:\n{ex.Message}",
"시스템 오류", MessageBoxButtons.OK, MessageBoxIcon.Error); "시스템 오류", MessageBoxButtons.OK, MessageBoxIcon.Error);
} }

View File

@@ -140,3 +140,64 @@ SubProject 내의 GitUpdate.bat을 사용하여 모든 하위 프로젝트를
- 통신 관련 코드 변경시 하드웨어 호환성 고려 필요 - 통신 관련 코드 변경시 하드웨어 호환성 고려 필요
- **맵 에디터/시뮬레이터**: AGVMapEditor 프로젝트에 의존성이 있으므로 먼저 빌드 필요 - **맵 에디터/시뮬레이터**: AGVMapEditor 프로젝트에 의존성이 있으므로 먼저 빌드 필요
- **JSON 파일 형식**: 맵 데이터는 MapNodes, RfidMappings 두 섹션으로 구성 - **JSON 파일 형식**: 맵 데이터는 MapNodes, RfidMappings 두 섹션으로 구성
## 최근 구현 완료 기능 및 중요사항 (2024.12.09)
### ✅ 회전 구간 회피 기능 (PathFinding)
**파일**: `AGVNavigationCore/PathFinding/PathfindingOptions.cs` (신규)
- **목적**: AGV 회전 오류를 피하기 위한 선택적 회전 구간 회피
- **구현**: PathfindingOptions 클래스로 회전 회피 설정 관리
- **UI**: AGVSimulator에 "회전 구간 회피" 체크박스 추가
- **알고리즘**: A* 경로탐색에서 회전 노드 가중치 증가 또는 필터링
### ✅ 맵 에디터 마우스 좌표 오차 수정
**파일**: `AGVNavigationCore/Controls/UnifiedAGVCanvas.Mouse.cs`
- **문제**: 줌 인/아웃 시 노드 선택 히트 영역이 너무 작아짐
- **해결**: 최소 화면 히트 영역(20픽셀) 보장으로 정확한 노드 선택 가능
- **적용**: 원형, 5각형, 삼각형 모든 노드 타입 히트 감지 개선
### ✅ 노드 연결 관리 시스템 (신규 구현)
**파일들**:
- `AGVMapEditor/Forms/MainForm.cs` - UI 및 이벤트 처리
- `AGVNavigationCore/Controls/UnifiedAGVCanvas.cs` - 편집 모드 및 이벤트 정의
- `AGVNavigationCore/Controls/UnifiedAGVCanvas.Mouse.cs` - 마우스 연결 삭제 기능
#### 주요 기능:
1. **노드 연결 목록 표시**: `lstNodeConnection` 리스트박스에 모든 연결 정보 표시
2. **버튼 연결 삭제**: `btNodeRemove` 버튼으로 선택된 연결 삭제
3. **더블클릭 연결 삭제**: 목록에서 더블클릭으로 빠른 삭제
4. **마우스 직접 삭제**: 맵에서 연결선 클릭으로 직접 삭제
#### 핵심 클래스:
- **NodeConnectionInfo**: 연결 정보 표현 클래스 (From/To 노드, 연결 타입)
- **EditMode.DeleteConnection**: 새로운 편집 모드 추가
- **ConnectionDeleted 이벤트**: 연결 삭제 시 발생하는 이벤트
### 🔧 빌드 환경 이슈
- **Visual Studio 2022**: MSBuild 경로 문제로 빌드 실패
- **권장**: Visual Studio Community/Professional 2022 설치 필요
- **대안**: 기존 빌드된 실행파일로 테스트 가능
### 📋 개발 우선순위 및 권장사항
#### 꼭 지켜야 할 사항:
1. **PathFinding 로직 변경시**: 반드시 시뮬레이터에서 테스트 후 적용
2. **노드 연결 관리**: 물리적 RFID와 논리적 노드 ID 분리 원칙 유지
3. **UI 편집 모드**: 동시에 여러 편집 모드 활성화하지 않도록 주의
4. **이벤트 처리**: MapChanged, NodeAdded/Deleted, ConnectionDeleted 이벤트 체인 확인
#### 다음 개발 우선순위:
1. **방향 전환 기능**: AGV 현재 방향과 목표 방향 불일치 시 회전 노드 경유 로직
2. **맵 검증 기능**: 연결 무결성, 고립된 노드, 순환 경로 등 검증
3. **성능 최적화**: 대형 맵에서 경로 계산 및 연결 목록 표시 성능 개선
4. **실시간 동기화**: 맵 에디터와 시뮬레이터 간 실시간 맵 동기화
#### 중요 개발 패턴:
- **이벤트 기반 아키텍처**: UI 업데이트는 이벤트를 통해 자동화
- **상태 관리**: _hasChanges 플래그로 변경사항 추적
- **에러 처리**: 사용자 확인 다이얼로그와 상태바 메시지 활용
- **코드 재사용**: UnifiedAGVCanvas를 맵에디터와 시뮬레이터에서 공통 사용
### 🚨 알려진 이슈
- **빌드 환경**: MSBuild 2022가 설치되지 않은 환경에서 빌드 불가
- **좌표 시스템**: 줌/팬 상태에서 좌표 변환 정확성 지속 모니터링 필요

File diff suppressed because it is too large Load Diff