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:
@@ -49,6 +49,7 @@
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="Models\EditorSettings.cs" />
|
||||
<Compile Include="Models\MapData.cs" />
|
||||
<Compile Include="Models\MapImage.cs" />
|
||||
<Compile Include="Models\MapLabel.cs" />
|
||||
@@ -63,11 +64,20 @@
|
||||
</Compile>
|
||||
<Compile Include="Program.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>
|
||||
<EmbeddedResource Include="Forms\MainForm.resx">
|
||||
<DependentUpon>MainForm.cs</DependentUpon>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Include="Properties\Resources.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="build.bat" />
|
||||
|
||||
@@ -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
289
Cs_HMI/AGVMapEditor/Forms/MainForm.Designer.cs
generated
289
Cs_HMI/AGVMapEditor/Forms/MainForm.Designer.cs
generated
@@ -28,113 +28,38 @@ namespace AGVMapEditor.Forms
|
||||
/// </summary>
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.menuStrip1 = new System.Windows.Forms.MenuStrip();
|
||||
this.fileToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
||||
this.newToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
||||
this.openToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
||||
this.toolStripSeparator1 = new System.Windows.Forms.ToolStripSeparator();
|
||||
this.saveToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
||||
this.saveAsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
||||
this.closeToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
||||
this.toolStripSeparator2 = new System.Windows.Forms.ToolStripSeparator();
|
||||
this.exitToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
||||
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(MainForm));
|
||||
this.statusStrip1 = new System.Windows.Forms.StatusStrip();
|
||||
this.toolStripStatusLabel1 = new System.Windows.Forms.ToolStripStatusLabel();
|
||||
this.splitContainer1 = new System.Windows.Forms.SplitContainer();
|
||||
this.tabControl1 = new System.Windows.Forms.TabControl();
|
||||
this.tabPageNodes = new System.Windows.Forms.TabPage();
|
||||
this.listBoxNodes = new System.Windows.Forms.ListBox();
|
||||
this._propertyGrid = new System.Windows.Forms.PropertyGrid();
|
||||
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();
|
||||
((System.ComponentModel.ISupportInitialize)(this.splitContainer1)).BeginInit();
|
||||
this.splitContainer1.Panel1.SuspendLayout();
|
||||
this.splitContainer1.SuspendLayout();
|
||||
this.tabControl1.SuspendLayout();
|
||||
this.tabPageNodes.SuspendLayout();
|
||||
this.tabPage1.SuspendLayout();
|
||||
this.toolStrip1.SuspendLayout();
|
||||
this.toolStrip2.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
|
||||
//
|
||||
this.statusStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] {
|
||||
@@ -154,36 +79,37 @@ namespace AGVMapEditor.Forms
|
||||
// splitContainer1
|
||||
//
|
||||
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";
|
||||
//
|
||||
// splitContainer1.Panel1
|
||||
//
|
||||
this.splitContainer1.Panel1.Controls.Add(this.tabControl1);
|
||||
this.splitContainer1.Panel1.Controls.Add(this._propertyGrid);
|
||||
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.TabIndex = 2;
|
||||
//
|
||||
// tabControl1
|
||||
//
|
||||
this.tabControl1.Controls.Add(this.tabPageNodes);
|
||||
this.tabControl1.Controls.Add(this.tabPage1);
|
||||
this.tabControl1.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
this.tabControl1.Location = new System.Drawing.Point(0, 0);
|
||||
this.tabControl1.Name = "tabControl1";
|
||||
this.tabControl1.SelectedIndex = 0;
|
||||
this.tabControl1.Size = new System.Drawing.Size(300, 727);
|
||||
this.tabControl1.Size = new System.Drawing.Size(300, 335);
|
||||
this.tabControl1.TabIndex = 0;
|
||||
//
|
||||
// tabPageNodes
|
||||
//
|
||||
this.tabPageNodes.Controls.Add(this.listBoxNodes);
|
||||
this.tabPageNodes.Controls.Add(this._propertyGrid);
|
||||
this.tabPageNodes.Controls.Add(this.label1);
|
||||
this.tabPageNodes.Location = new System.Drawing.Point(4, 22);
|
||||
this.tabPageNodes.Name = "tabPageNodes";
|
||||
this.tabPageNodes.Padding = new System.Windows.Forms.Padding(3);
|
||||
this.tabPageNodes.Size = new System.Drawing.Size(292, 701);
|
||||
this.tabPageNodes.Size = new System.Drawing.Size(292, 309);
|
||||
this.tabPageNodes.TabIndex = 0;
|
||||
this.tabPageNodes.Text = "노드 관리";
|
||||
this.tabPageNodes.UseVisualStyleBackColor = true;
|
||||
@@ -195,17 +121,9 @@ namespace AGVMapEditor.Forms
|
||||
this.listBoxNodes.ItemHeight = 12;
|
||||
this.listBoxNodes.Location = new System.Drawing.Point(3, 3);
|
||||
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;
|
||||
//
|
||||
// _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
|
||||
//
|
||||
this.label1.AutoSize = true;
|
||||
@@ -215,6 +133,132 @@ namespace AGVMapEditor.Forms
|
||||
this.label1.TabIndex = 0;
|
||||
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
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 12F);
|
||||
@@ -222,15 +266,12 @@ namespace AGVMapEditor.Forms
|
||||
this.ClientSize = new System.Drawing.Size(1200, 773);
|
||||
this.Controls.Add(this.splitContainer1);
|
||||
this.Controls.Add(this.statusStrip1);
|
||||
this.Controls.Add(this.menuStrip1);
|
||||
this.MainMenuStrip = this.menuStrip1;
|
||||
this.Controls.Add(this.toolStrip2);
|
||||
this.Name = "MainForm";
|
||||
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen;
|
||||
this.Text = "AGV Map Editor";
|
||||
this.FormClosing += new System.Windows.Forms.FormClosingEventHandler(this.MainForm_FormClosing);
|
||||
this.Load += new System.EventHandler(this.MainForm_Load);
|
||||
this.menuStrip1.ResumeLayout(false);
|
||||
this.menuStrip1.PerformLayout();
|
||||
this.statusStrip1.ResumeLayout(false);
|
||||
this.statusStrip1.PerformLayout();
|
||||
this.splitContainer1.Panel1.ResumeLayout(false);
|
||||
@@ -239,6 +280,12 @@ namespace AGVMapEditor.Forms
|
||||
this.tabControl1.ResumeLayout(false);
|
||||
this.tabPageNodes.ResumeLayout(false);
|
||||
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.PerformLayout();
|
||||
|
||||
@@ -246,16 +293,6 @@ namespace AGVMapEditor.Forms
|
||||
|
||||
#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.ToolStripStatusLabel toolStripStatusLabel1;
|
||||
private System.Windows.Forms.SplitContainer splitContainer1;
|
||||
@@ -264,5 +301,17 @@ namespace AGVMapEditor.Forms
|
||||
private System.Windows.Forms.ListBox listBoxNodes;
|
||||
private System.Windows.Forms.Label label1;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,35 @@ namespace AGVMapEditor.Forms
|
||||
// 파일 경로
|
||||
private string _currentMapFile = string.Empty;
|
||||
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
|
||||
@@ -47,6 +76,7 @@ namespace AGVMapEditor.Forms
|
||||
// 명령줄 인수로 파일이 전달되었으면 자동으로 열기
|
||||
if (args != null && args.Length > 0)
|
||||
{
|
||||
_hasCommandLineArgs = true;
|
||||
string filePath = args[0];
|
||||
if (System.IO.File.Exists(filePath))
|
||||
{
|
||||
@@ -58,10 +88,12 @@ namespace AGVMapEditor.Forms
|
||||
MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
// 명령줄 인수가 없는 경우는 Form_Load에서 마지막 맵 파일 자동 로드 확인
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
#region Initialization
|
||||
|
||||
private void InitializeData()
|
||||
@@ -82,6 +114,7 @@ namespace AGVMapEditor.Forms
|
||||
_mapCanvas.NodeSelected += OnNodeSelected;
|
||||
_mapCanvas.NodeMoved += OnNodeMoved;
|
||||
_mapCanvas.NodeDeleted += OnNodeDeleted;
|
||||
_mapCanvas.ConnectionDeleted += OnConnectionDeleted;
|
||||
_mapCanvas.MapChanged += OnMapChanged;
|
||||
|
||||
// 스플리터 패널에 맵 캔버스 추가
|
||||
@@ -148,31 +181,38 @@ namespace AGVMapEditor.Forms
|
||||
btnDelete.Location = new Point(495, 3);
|
||||
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();
|
||||
separator1.Text = "|";
|
||||
separator1.Size = new Size(10, 28);
|
||||
separator1.Location = new Point(570, 3);
|
||||
separator1.Location = new Point(655, 3);
|
||||
separator1.TextAlign = ContentAlignment.MiddleCenter;
|
||||
|
||||
// 그리드 토글 버튼
|
||||
var btnToggleGrid = new Button();
|
||||
btnToggleGrid.Text = "그리드";
|
||||
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;
|
||||
|
||||
// 맵 맞춤 버튼
|
||||
var btnFitMap = new Button();
|
||||
btnFitMap.Text = "맵 맞춤";
|
||||
btnFitMap.Size = new Size(70, 28);
|
||||
btnFitMap.Location = new Point(650, 3);
|
||||
btnFitMap.Location = new Point(735, 3);
|
||||
btnFitMap.Click += (s, e) => _mapCanvas.FitToNodes();
|
||||
|
||||
// 툴바에 버튼들 추가
|
||||
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)
|
||||
{
|
||||
|
||||
RefreshNodeList();
|
||||
// 속성 변경 시 이벤트 연결
|
||||
_propertyGrid.PropertyValueChanged += PropertyGrid_PropertyValueChanged;
|
||||
|
||||
// 명령줄 인수가 없는 경우에만 마지막 맵 파일 자동 로드 확인
|
||||
if (!_hasCommandLineArgs)
|
||||
{
|
||||
this.Show();
|
||||
Application.DoEvents();
|
||||
CheckAndLoadLastMapFile();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnNodeAdded(object sender, MapNode node)
|
||||
@@ -228,6 +277,14 @@ namespace AGVMapEditor.Forms
|
||||
UpdateNodeProperties(); // 연결 정보 업데이트
|
||||
}
|
||||
|
||||
private void OnConnectionDeleted(object sender, (MapNode From, MapNode To) connection)
|
||||
{
|
||||
_hasChanges = true;
|
||||
UpdateTitle();
|
||||
RefreshNodeConnectionList();
|
||||
UpdateNodeProperties(); // 연결 정보 업데이트
|
||||
}
|
||||
|
||||
private void OnMapChanged(object sender, EventArgs e)
|
||||
{
|
||||
_hasChanges = true;
|
||||
@@ -242,9 +299,9 @@ namespace AGVMapEditor.Forms
|
||||
|
||||
#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())
|
||||
{
|
||||
@@ -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())
|
||||
{
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
if (!File.Exists(_currentMapFile))
|
||||
{
|
||||
MessageBox.Show($"파일을 찾을 수 없습니다: {_currentMapFile}", "오류", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (CheckSaveChanges())
|
||||
{
|
||||
LoadMapFromFile(_currentMapFile);
|
||||
UpdateStatusBar($"파일을 다시 열었습니다: {Path.GetFileName(_currentMapFile)}");
|
||||
}
|
||||
}
|
||||
|
||||
private void saveAsToolStripMenuItem_Click(object sender, EventArgs e)
|
||||
{
|
||||
SaveAsMap();
|
||||
}
|
||||
|
||||
private void closeToolStripMenuItem_Click(object sender, EventArgs e)
|
||||
private void btnClose_Click(object sender, EventArgs e)
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
#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
|
||||
|
||||
private void btnAddNode_Click(object sender, EventArgs e)
|
||||
@@ -537,6 +636,22 @@ namespace AGVMapEditor.Forms
|
||||
// 맵 캔버스에 데이터 설정
|
||||
_mapCanvas.Nodes = _mapNodes;
|
||||
// RfidMappings 제거됨 - MapNode에 통합
|
||||
|
||||
// 현재 파일 경로 업데이트
|
||||
_currentMapFile = filePath;
|
||||
_hasChanges = false;
|
||||
|
||||
// 설정에 마지막 맵 파일 경로 저장
|
||||
EditorSettings.Instance.UpdateLastMapFile(filePath);
|
||||
|
||||
UpdateTitle();
|
||||
UpdateNodeList();
|
||||
RefreshNodeConnectionList();
|
||||
|
||||
// 맵 로드 후 자동으로 맵에 맞춤
|
||||
_mapCanvas.FitToNodes();
|
||||
|
||||
UpdateStatusBar($"맵 파일을 성공적으로 로드했습니다: {Path.GetFileName(filePath)}");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -547,7 +662,19 @@ namespace AGVMapEditor.Forms
|
||||
|
||||
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("맵 파일 저장 실패", "오류",
|
||||
MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
@@ -559,8 +686,8 @@ namespace AGVMapEditor.Forms
|
||||
/// </summary>
|
||||
private void UpdateRfidMappings()
|
||||
{
|
||||
// 네비게이션 노드들에 RFID 자동 할당
|
||||
MapLoader.AssignAutoRfidIds(_mapNodes);
|
||||
// RFID 자동 할당 제거 - 사용자가 직접 입력한 값 유지
|
||||
// MapLoader.AssignAutoRfidIds(_mapNodes);
|
||||
}
|
||||
|
||||
private bool CheckSaveChanges()
|
||||
@@ -584,6 +711,29 @@ namespace AGVMapEditor.Forms
|
||||
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
|
||||
|
||||
#region UI Updates
|
||||
@@ -591,6 +741,7 @@ namespace AGVMapEditor.Forms
|
||||
private void RefreshAll()
|
||||
{
|
||||
RefreshNodeList();
|
||||
RefreshNodeConnectionList();
|
||||
RefreshMapCanvas();
|
||||
ClearNodeProperties();
|
||||
}
|
||||
@@ -672,12 +823,12 @@ namespace AGVMapEditor.Forms
|
||||
e.Graphics.FillRectangle(brush, e.Bounds);
|
||||
}
|
||||
|
||||
// 텍스트 그리기 (노드ID - 설명 - RFID 순서)
|
||||
// 텍스트 그리기 (노드ID - 노드명 - RFID 순서)
|
||||
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))
|
||||
@@ -694,6 +845,92 @@ namespace AGVMapEditor.Forms
|
||||
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()
|
||||
{
|
||||
_mapCanvas?.Invalidate();
|
||||
@@ -735,6 +972,31 @@ namespace AGVMapEditor.Forms
|
||||
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
|
||||
|
||||
#region Form Events
|
||||
@@ -754,6 +1016,23 @@ namespace AGVMapEditor.Forms
|
||||
|
||||
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;
|
||||
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
|
||||
|
||||
#region Data Model for Serialization
|
||||
@@ -782,5 +1107,54 @@ namespace AGVMapEditor.Forms
|
||||
|
||||
#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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,10 +117,112 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<metadata name="menuStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
|
||||
<value>17, 17</value>
|
||||
</metadata>
|
||||
<metadata name="statusStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
|
||||
<value>132, 17</value>
|
||||
</metadata>
|
||||
<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>
|
||||
162
Cs_HMI/AGVMapEditor/Models/EditorSettings.cs
Normal file
162
Cs_HMI/AGVMapEditor/Models/EditorSettings.cs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,11 @@ namespace AGVMapEditor.Models
|
||||
_mapNodes = mapNodes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 래핑된 MapNode 인스턴스 접근
|
||||
/// </summary>
|
||||
public MapNode WrappedNode => _node;
|
||||
|
||||
[Category("기본 정보")]
|
||||
[DisplayName("노드 ID")]
|
||||
[Description("노드의 고유 식별자")]
|
||||
@@ -207,6 +212,11 @@ namespace AGVMapEditor.Models
|
||||
_mapNodes = mapNodes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 래핑된 MapNode 인스턴스 접근
|
||||
/// </summary>
|
||||
public MapNode WrappedNode => _node;
|
||||
|
||||
[Category("기본 정보")]
|
||||
[DisplayName("노드 ID")]
|
||||
[Description("노드의 고유 식별자")]
|
||||
@@ -350,6 +360,11 @@ namespace AGVMapEditor.Models
|
||||
_mapNodes = mapNodes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 래핑된 MapNode 인스턴스 접근
|
||||
/// </summary>
|
||||
public MapNode WrappedNode => _node;
|
||||
|
||||
[Category("기본 정보")]
|
||||
[DisplayName("노드 ID")]
|
||||
[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("고급")]
|
||||
[DisplayName("활성화")]
|
||||
|
||||
@@ -49,6 +49,8 @@ namespace AGVMapEditor.Models
|
||||
_astarPathfinder.SetMapNodes(mapNodes);
|
||||
// RfidPathfinder는 MapNode의 RFID 정보를 직접 사용
|
||||
_rfidPathfinder.SetMapNodes(mapNodes);
|
||||
// 도킹 조건 검색용 내부 노드 목록 업데이트
|
||||
UpdateInternalMapNodes(mapNodes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -63,6 +65,32 @@ namespace AGVMapEditor.Models
|
||||
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>
|
||||
/// 충전 스테이션으로의 경로 찾기
|
||||
@@ -264,5 +292,141 @@ namespace AGVMapEditor.Models
|
||||
{
|
||||
_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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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})";
|
||||
}
|
||||
}
|
||||
}
|
||||
63
Cs_HMI/AGVMapEditor/Properties/Resources.Designer.cs
generated
Normal file
63
Cs_HMI/AGVMapEditor/Properties/Resources.Designer.cs
generated
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
@@ -32,8 +32,9 @@
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion. Classes that don't support this are
|
||||
serialized and stored with the mimetype set.
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
@@ -86,9 +87,9 @@
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
@@ -97,7 +98,7 @@
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
@@ -62,12 +62,18 @@
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="Controls\AGVState.cs" />
|
||||
<Compile Include="Controls\IAGV.cs" />
|
||||
<Compile Include="Models\Enums.cs" />
|
||||
<Compile Include="Models\MapLoader.cs" />
|
||||
<Compile Include="Models\MapNode.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\PathResult.cs" />
|
||||
<Compile Include="PathFinding\PathfindingOptions.cs" />
|
||||
<Compile Include="PathFinding\AStarPathfinder.cs" />
|
||||
<Compile Include="PathFinding\AGVPathfinder.cs" />
|
||||
<Compile Include="PathFinding\AGVPathResult.cs" />
|
||||
|
||||
21
Cs_HMI/AGVNavigationCore/Controls/AGVState.cs
Normal file
21
Cs_HMI/AGVNavigationCore/Controls/AGVState.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace AGVNavigationCore.Controls
|
||||
{
|
||||
#region Interfaces
|
||||
|
||||
/// <summary>
|
||||
/// AGV 상태 열거형
|
||||
/// </summary>
|
||||
public enum AGVState
|
||||
{
|
||||
Idle, // 대기
|
||||
Moving, // 이동 중
|
||||
Rotating, // 회전 중
|
||||
Docking, // 도킹 중
|
||||
Charging, // 충전 중
|
||||
Error // 오류
|
||||
}
|
||||
|
||||
|
||||
|
||||
#endregion
|
||||
}
|
||||
29
Cs_HMI/AGVNavigationCore/Controls/IAGV.cs
Normal file
29
Cs_HMI/AGVNavigationCore/Controls/IAGV.cs
Normal 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
|
||||
}
|
||||
@@ -32,23 +32,26 @@ namespace AGVNavigationCore.Controls
|
||||
DrawGrid(g);
|
||||
}
|
||||
|
||||
// 노드 연결선 그리기
|
||||
// 노드 연결선 그리기 (가장 먼저 - 텍스트가 가려지지 않게)
|
||||
DrawConnections(g);
|
||||
|
||||
// 경로 그리기
|
||||
DrawPaths(g);
|
||||
|
||||
// 노드 그리기
|
||||
DrawNodes(g);
|
||||
|
||||
// AGV 그리기
|
||||
DrawAGVs(g);
|
||||
|
||||
// 임시 연결선 그리기 (편집 모드)
|
||||
if (_canvasMode == CanvasMode.Edit && _isConnectionMode)
|
||||
{
|
||||
DrawTemporaryConnection(g);
|
||||
}
|
||||
|
||||
// 노드 그리기 (라벨 제외)
|
||||
DrawNodesOnly(g);
|
||||
|
||||
// AGV 그리기
|
||||
DrawAGVs(g);
|
||||
|
||||
// 노드 라벨 그리기 (가장 나중 - 선이 텍스트를 가리지 않게)
|
||||
DrawNodeLabels(g);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -108,8 +111,38 @@ namespace AGVNavigationCore.Controls
|
||||
var startPoint = fromNode.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)
|
||||
@@ -276,51 +309,65 @@ namespace AGVNavigationCore.Controls
|
||||
brush.Dispose();
|
||||
}
|
||||
|
||||
private void DrawNodes(Graphics g)
|
||||
private void DrawNodesOnly(Graphics g)
|
||||
{
|
||||
if (_nodes == null) return;
|
||||
|
||||
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)
|
||||
{
|
||||
case NodeType.Label:
|
||||
DrawLabelNode(g, node);
|
||||
DrawLabelNode(g, node); // Label 노드는 텍스트 포함
|
||||
break;
|
||||
case NodeType.Image:
|
||||
DrawImageNode(g, node);
|
||||
DrawImageNode(g, node); // Image 노드는 텍스트 포함
|
||||
break;
|
||||
default:
|
||||
DrawCircularNode(g, node);
|
||||
DrawCircularNodeShape(g, node); // 다른 노드는 도형만
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawCircularNode(Graphics g, MapNode node)
|
||||
private void DrawCircularNodeShape(Graphics g, MapNode node)
|
||||
{
|
||||
var brush = GetNodeBrush(node);
|
||||
|
||||
switch (node.Type)
|
||||
{
|
||||
case NodeType.Docking:
|
||||
DrawPentagonNode(g, node, brush);
|
||||
DrawPentagonNodeShape(g, node, brush);
|
||||
break;
|
||||
case NodeType.Charging:
|
||||
DrawTriangleNode(g, node, brush);
|
||||
DrawTriangleNodeShape(g, node, brush);
|
||||
break;
|
||||
default:
|
||||
DrawCircleNode(g, node, brush);
|
||||
DrawCircleNodeShape(g, node, brush);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawCircleNode(Graphics g, MapNode node, Brush brush)
|
||||
private void DrawCircleNodeShape(Graphics g, MapNode node, Brush brush)
|
||||
{
|
||||
var rect = new Rectangle(
|
||||
node.Position.X - NODE_RADIUS,
|
||||
@@ -357,10 +404,14 @@ namespace AGVNavigationCore.Controls
|
||||
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 center = node.Position;
|
||||
@@ -421,10 +472,14 @@ namespace AGVNavigationCore.Controls
|
||||
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 center = node.Position;
|
||||
@@ -485,7 +540,11 @@ namespace AGVNavigationCore.Controls
|
||||
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)
|
||||
@@ -494,8 +553,8 @@ namespace AGVNavigationCore.Controls
|
||||
Color textColor;
|
||||
string descriptionText;
|
||||
|
||||
// 위쪽에 표시할 설명 (노드의 Description 속성)
|
||||
descriptionText = string.IsNullOrEmpty(node.Description) ? "" : node.Description;
|
||||
// 위쪽에 표시할 이름 (노드의 Name 속성)
|
||||
descriptionText = node.Name.EndsWith(node.NodeId) ? string.Empty : node.Name;
|
||||
|
||||
// 아래쪽에 표시할 값 (RFID 우선, 없으면 노드ID)
|
||||
if (node.HasRfid())
|
||||
@@ -511,38 +570,78 @@ namespace AGVNavigationCore.Controls
|
||||
textColor = Color.Gray;
|
||||
}
|
||||
|
||||
var font = new Font("Arial", 8, FontStyle.Bold);
|
||||
var descFont = new Font("Arial", 6, FontStyle.Regular);
|
||||
var font = new Font("Arial", 7, FontStyle.Bold);
|
||||
var descFont = new Font("Arial", 8, FontStyle.Bold);
|
||||
|
||||
// 메인 텍스트 크기 측정
|
||||
var textSize = g.MeasureString(displayText, font);
|
||||
var descSize = g.MeasureString(descriptionText, descFont);
|
||||
|
||||
// 설명 텍스트 위치 (노드 위쪽)
|
||||
var descPoint = new Point(
|
||||
(int)(node.Position.X - descSize.Width / 2),
|
||||
(int)(node.Position.Y - NODE_RADIUS - descSize.Height - 2)
|
||||
);
|
||||
|
||||
// 메인 텍스트 위치 (노드 아래쪽)
|
||||
// 메인 텍스트 위치 (RFID는 노드 위쪽)
|
||||
var textPoint = new Point(
|
||||
(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)
|
||||
);
|
||||
|
||||
// 설명 텍스트 그리기 (설명이 있는 경우에만)
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 메인 텍스트 그리기
|
||||
using (var textBrush = new SolidBrush(textColor))
|
||||
// 메인 텍스트 그리기 (RFID 중복인 경우 특별 처리)
|
||||
if (node.HasRfid() && _duplicateRfidNodes.Contains(node.NodeId))
|
||||
{
|
||||
g.DrawString(displayText, font, textBrush, textPoint);
|
||||
// 중복 RFID 노드: 빨간 배경의 라운드 사각형
|
||||
DrawDuplicateRfidLabel(g, displayText, textPoint, font);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 일반 텍스트 그리기
|
||||
using (var textBrush = new SolidBrush(textColor))
|
||||
{
|
||||
g.DrawString(displayText, font, textBrush, textPoint);
|
||||
}
|
||||
}
|
||||
|
||||
font.Dispose();
|
||||
@@ -1433,6 +1532,104 @@ namespace AGVNavigationCore.Controls
|
||||
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()
|
||||
{
|
||||
var left = (int)(-_panOffset.X / _zoomFactor);
|
||||
|
||||
@@ -22,7 +22,7 @@ namespace AGVNavigationCore.Controls
|
||||
switch (_editMode)
|
||||
{
|
||||
case EditMode.Select:
|
||||
HandleSelectClick(hitNode);
|
||||
HandleSelectClick(hitNode, worldPoint);
|
||||
break;
|
||||
|
||||
case EditMode.AddNode:
|
||||
@@ -36,6 +36,10 @@ namespace AGVNavigationCore.Controls
|
||||
case EditMode.Delete:
|
||||
HandleDeleteClick(hitNode);
|
||||
break;
|
||||
|
||||
case EditMode.DeleteConnection:
|
||||
HandleDeleteConnectionClick(worldPoint);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +254,10 @@ namespace AGVNavigationCore.Controls
|
||||
|
||||
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(
|
||||
Math.Pow(node.Position.X - point.X, 2) +
|
||||
Math.Pow(node.Position.Y - point.Y, 2)
|
||||
@@ -260,7 +267,9 @@ namespace AGVNavigationCore.Controls
|
||||
|
||||
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;
|
||||
|
||||
// 5각형 꼭짓점 계산
|
||||
@@ -279,7 +288,9 @@ namespace AGVNavigationCore.Controls
|
||||
|
||||
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;
|
||||
|
||||
// 삼각형 꼭짓점 계산
|
||||
@@ -378,13 +389,63 @@ namespace AGVNavigationCore.Controls
|
||||
});
|
||||
}
|
||||
|
||||
private void HandleSelectClick(MapNode hitNode)
|
||||
private void HandleSelectClick(MapNode hitNode, Point worldPoint)
|
||||
{
|
||||
if (hitNode != _selectedNode)
|
||||
if (hitNode != null)
|
||||
{
|
||||
_selectedNode = hitNode;
|
||||
NodeSelected?.Invoke(this, hitNode);
|
||||
Invalidate();
|
||||
// 노드 선택
|
||||
if (hitNode != _selectedNode)
|
||||
{
|
||||
_selectedNode = hitNode;
|
||||
NodeSelected?.Invoke(this, hitNode);
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,14 +458,16 @@ namespace AGVNavigationCore.Controls
|
||||
worldPoint.Y = (worldPoint.Y / GRID_SIZE) * GRID_SIZE;
|
||||
}
|
||||
|
||||
// 고유한 NodeId 생성
|
||||
string newNodeId = GenerateUniqueNodeId();
|
||||
|
||||
var newNode = new MapNode
|
||||
{
|
||||
NodeId = $"N{_nodeCounter:D3}",
|
||||
NodeId = newNodeId,
|
||||
Position = worldPoint,
|
||||
Type = NodeType.Normal
|
||||
};
|
||||
|
||||
_nodeCounter++;
|
||||
_nodes.Add(newNode);
|
||||
|
||||
NodeAdded?.Invoke(this, newNode);
|
||||
@@ -412,6 +475,25 @@ namespace AGVNavigationCore.Controls
|
||||
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)
|
||||
{
|
||||
if (hitNode == null) return;
|
||||
@@ -458,11 +540,21 @@ namespace AGVNavigationCore.Controls
|
||||
|
||||
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;
|
||||
|
||||
fromNode.AddConnection(toNode.NodeId);
|
||||
// 단일 연결 생성 (사전순으로 정렬하여 일관성 유지)
|
||||
if (string.Compare(fromNode.NodeId, toNode.NodeId, StringComparison.Ordinal) < 0)
|
||||
{
|
||||
fromNode.AddConnection(toNode.NodeId);
|
||||
}
|
||||
else
|
||||
{
|
||||
toNode.AddConnection(fromNode.NodeId);
|
||||
}
|
||||
|
||||
MapChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
@@ -496,6 +588,91 @@ namespace AGVNavigationCore.Controls
|
||||
_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)
|
||||
{
|
||||
string tooltipText = "";
|
||||
|
||||
@@ -43,13 +43,14 @@ namespace AGVNavigationCore.Controls
|
||||
/// </summary>
|
||||
public enum EditMode
|
||||
{
|
||||
Select, // 선택 모드
|
||||
Move, // 이동 모드
|
||||
AddNode, // 노드 추가 모드
|
||||
Connect, // 연결 모드
|
||||
Delete, // 삭제 모드
|
||||
AddLabel, // 라벨 추가 모드
|
||||
AddImage // 이미지 추가 모드
|
||||
Select, // 선택 모드
|
||||
Move, // 이동 모드
|
||||
AddNode, // 노드 추가 모드
|
||||
Connect, // 연결 모드
|
||||
Delete, // 삭제 모드
|
||||
DeleteConnection, // 연결 삭제 모드
|
||||
AddLabel, // 라벨 추가 모드
|
||||
AddImage // 이미지 추가 모드
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -98,6 +99,11 @@ namespace AGVNavigationCore.Controls
|
||||
// 자동 증가 카운터
|
||||
private int _nodeCounter = 1;
|
||||
|
||||
// 강조 연결
|
||||
private (string FromNodeId, string ToNodeId)? _highlightedConnection = null;
|
||||
|
||||
// RFID 중복 검사
|
||||
private HashSet<string> _duplicateRfidNodes = new HashSet<string>();
|
||||
|
||||
// 브러쉬 및 펜
|
||||
private Brush _normalNodeBrush;
|
||||
@@ -118,6 +124,7 @@ namespace AGVNavigationCore.Controls
|
||||
private Pen _destinationNodePen;
|
||||
private Pen _pathPen;
|
||||
private Pen _agvPen;
|
||||
private Pen _highlightedConnectionPen;
|
||||
|
||||
// 컨텍스트 메뉴
|
||||
private ContextMenuStrip _contextMenu;
|
||||
@@ -131,6 +138,7 @@ namespace AGVNavigationCore.Controls
|
||||
public event EventHandler<MapNode> NodeSelected;
|
||||
public event EventHandler<MapNode> NodeDeleted;
|
||||
public event EventHandler<MapNode> NodeMoved;
|
||||
public event EventHandler<(MapNode From, MapNode To)> ConnectionDeleted;
|
||||
public event EventHandler MapChanged;
|
||||
|
||||
// AGV 이벤트
|
||||
@@ -215,6 +223,13 @@ namespace AGVNavigationCore.Controls
|
||||
set
|
||||
{
|
||||
_nodes = value ?? new List<MapNode>();
|
||||
|
||||
// 기존 노드들의 최대 번호를 찾아서 _nodeCounter 설정
|
||||
UpdateNodeCounter();
|
||||
|
||||
// RFID 중복값 검사
|
||||
DetectDuplicateRfidNodes();
|
||||
|
||||
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
|
||||
|
||||
@@ -347,6 +401,7 @@ namespace AGVNavigationCore.Controls
|
||||
_destinationNodePen = new Pen(Color.Orange, 4);
|
||||
_pathPen = new Pen(Color.Purple, 3);
|
||||
_agvPen = new Pen(Color.Red, 3);
|
||||
_highlightedConnectionPen = new Pen(Color.Red, 4) { DashStyle = DashStyle.Solid };
|
||||
}
|
||||
|
||||
private void CreateContextMenu()
|
||||
@@ -513,6 +568,7 @@ namespace AGVNavigationCore.Controls
|
||||
_destinationNodePen?.Dispose();
|
||||
_pathPen?.Dispose();
|
||||
_agvPen?.Dispose();
|
||||
_highlightedConnectionPen?.Dispose();
|
||||
|
||||
// 컨텍스트 메뉴 정리
|
||||
_contextMenu?.Dispose();
|
||||
@@ -525,40 +581,74 @@ namespace AGVNavigationCore.Controls
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// RFID 중복값을 가진 노드들을 감지하고 표시
|
||||
/// 나중에 추가된 노드(인덱스가 더 큰)를 중복으로 간주
|
||||
/// </summary>
|
||||
private void DetectDuplicateRfidNodes()
|
||||
{
|
||||
_duplicateRfidNodes.Clear();
|
||||
|
||||
if (_nodes == null || _nodes.Count == 0)
|
||||
return;
|
||||
|
||||
// RFID값과 해당 노드의 인덱스를 저장
|
||||
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>
|
||||
/// 기존 노드들의 최대 번호를 찾아서 _nodeCounter를 업데이트
|
||||
/// </summary>
|
||||
private void UpdateNodeCounter()
|
||||
{
|
||||
if (_nodes == null || _nodes.Count == 0)
|
||||
{
|
||||
_nodeCounter = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AGV 상태 열거형
|
||||
/// </summary>
|
||||
public enum AGVState
|
||||
{
|
||||
Idle, // 대기
|
||||
Moving, // 이동 중
|
||||
Rotating, // 회전 중
|
||||
Docking, // 도킹 중
|
||||
Charging, // 충전 중
|
||||
Error // 오류
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -60,6 +60,15 @@ namespace AGVNavigationCore.Models
|
||||
result.Version = mapData.Version ?? "1.0";
|
||||
result.CreatedDate = mapData.CreatedDate;
|
||||
|
||||
// 기존 Description 데이터를 Name으로 마이그레이션
|
||||
MigrateDescriptionToName(result.Nodes);
|
||||
|
||||
// 중복된 NodeId 정리
|
||||
FixDuplicateNodeIds(result.Nodes);
|
||||
|
||||
// 중복 연결 정리 (양방향 중복 제거)
|
||||
CleanupDuplicateConnections(result.Nodes);
|
||||
|
||||
// 이미지 노드들의 이미지 로드
|
||||
LoadImageNodes(result.Nodes);
|
||||
|
||||
@@ -122,11 +131,170 @@ namespace AGVNavigationCore.Models
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MapNode 목록에서 RFID가 없는 노드들에 자동으로 RFID ID를 할당합니다.
|
||||
/// 기존 Description 데이터를 Name 필드로 마이그레이션
|
||||
/// JSON 파일에서 Description 필드가 있는 경우 Name으로 이동
|
||||
/// </summary>
|
||||
/// <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)
|
||||
{
|
||||
// 에디터에서 설정한 RFID 값을 그대로 사용하기 위해 자동 할당 기능 비활성화
|
||||
// 에디터와 시뮬레이터 간 데이터 일관성 유지를 위함
|
||||
return;
|
||||
|
||||
/*
|
||||
foreach (var node in mapNodes)
|
||||
{
|
||||
// 네비게이션 가능한 노드이면서 RFID가 없는 경우에만 자동 할당
|
||||
@@ -138,6 +306,7 @@ namespace AGVNavigationCore.Models
|
||||
node.SetRfidInfo(rfidId, "", "정상");
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -69,10 +69,6 @@ namespace AGVNavigationCore.Models
|
||||
/// </summary>
|
||||
public DateTime ModifiedDate { get; set; } = DateTime.Now;
|
||||
|
||||
/// <summary>
|
||||
/// 노드 설명 (추가 정보)
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 노드 활성화 여부
|
||||
@@ -289,9 +285,9 @@ namespace AGVNavigationCore.Models
|
||||
{
|
||||
var displayText = NodeId;
|
||||
|
||||
if (!string.IsNullOrEmpty(Description))
|
||||
if (!string.IsNullOrEmpty(Name))
|
||||
{
|
||||
displayText += $" - {Description}";
|
||||
displayText += $" - {Name}";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(RfidId))
|
||||
@@ -322,7 +318,6 @@ namespace AGVNavigationCore.Models
|
||||
StationType = StationType,
|
||||
CreatedDate = CreatedDate,
|
||||
ModifiedDate = ModifiedDate,
|
||||
Description = Description,
|
||||
IsActive = IsActive,
|
||||
DisplayColor = DisplayColor,
|
||||
RfidId = RfidId,
|
||||
|
||||
@@ -40,6 +40,11 @@ namespace AGVNavigationCore.PathFinding
|
||||
/// </summary>
|
||||
public long CalculationTimeMs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 탐색된 노드 수
|
||||
/// </summary>
|
||||
public int ExploredNodes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 예상 소요 시간 (초)
|
||||
/// </summary>
|
||||
@@ -66,6 +71,7 @@ namespace AGVNavigationCore.PathFinding
|
||||
NodeMotorInfos = new List<NodeMotorInfo>();
|
||||
TotalDistance = 0;
|
||||
CalculationTimeMs = 0;
|
||||
ExploredNodes = 0;
|
||||
EstimatedTimeSeconds = 0;
|
||||
RotationCount = 0;
|
||||
ErrorMessage = string.Empty;
|
||||
|
||||
@@ -28,6 +28,11 @@ namespace AGVNavigationCore.PathFinding
|
||||
/// </summary>
|
||||
public float DockingApproachDistance { get; set; } = 100.0f;
|
||||
|
||||
/// <summary>
|
||||
/// 경로 탐색 옵션
|
||||
/// </summary>
|
||||
public PathfindingOptions Options { get; set; } = PathfindingOptions.Default;
|
||||
|
||||
/// <summary>
|
||||
/// 생성자
|
||||
/// </summary>
|
||||
@@ -60,6 +65,46 @@ namespace AGVNavigationCore.PathFinding
|
||||
/// <param name="targetDirection">목적지 도착 방향 (null이면 자동 결정)</param>
|
||||
/// <returns>AGV 경로 계산 결과</returns>
|
||||
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();
|
||||
|
||||
@@ -79,11 +124,11 @@ namespace AGVNavigationCore.PathFinding
|
||||
|
||||
if (IsSpecialNode(endNode))
|
||||
{
|
||||
return FindPathToSpecialNode(startNodeId, endNode, targetDirection, stopwatch);
|
||||
return FindPathToSpecialNode(startNodeId, endNode, targetDirection, options, stopwatch);
|
||||
}
|
||||
else
|
||||
{
|
||||
return FindNormalPath(startNodeId, endNodeId, targetDirection, stopwatch);
|
||||
return FindNormalPath(startNodeId, endNodeId, targetDirection, options, stopwatch);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -150,9 +195,26 @@ namespace AGVNavigationCore.PathFinding
|
||||
/// <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)
|
||||
{
|
||||
return AGVPathResult.CreateFailure(result.ErrorMessage, stopwatch.ElapsedMilliseconds);
|
||||
@@ -166,12 +228,29 @@ namespace AGVNavigationCore.PathFinding
|
||||
/// <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 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)
|
||||
{
|
||||
return AGVPathResult.CreateFailure(result.ErrorMessage, stopwatch.ElapsedMilliseconds);
|
||||
@@ -267,7 +346,7 @@ namespace AGVNavigationCore.PathFinding
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 노드별 모터방향 정보 생성
|
||||
/// 노드별 모터방향 정보 생성 (방향 전환 로직 개선)
|
||||
/// </summary>
|
||||
/// <param name="path">경로 노드 목록</param>
|
||||
/// <returns>노드별 모터방향 정보 목록</returns>
|
||||
@@ -276,40 +355,236 @@ namespace AGVNavigationCore.PathFinding
|
||||
var nodeMotorInfos = new List<NodeMotorInfo>();
|
||||
if (path.Count < 2) return nodeMotorInfos;
|
||||
|
||||
// 전체 경로에 대한 방향 전환 계획 수립
|
||||
var directionPlan = PlanDirectionChanges(path);
|
||||
|
||||
for (int i = 0; i < path.Count; i++)
|
||||
{
|
||||
var currentNodeId = path[i];
|
||||
string nextNodeId = i < path.Count - 1 ? path[i + 1] : null;
|
||||
|
||||
AgvDirection motorDirection;
|
||||
// 계획된 방향 사용
|
||||
var motorDirection = directionPlan.ContainsKey(currentNodeId)
|
||||
? directionPlan[currentNodeId]
|
||||
: AgvDirection.Forward;
|
||||
|
||||
if (i == path.Count - 1)
|
||||
// 노드 특성 정보 수집
|
||||
bool canRotate = false;
|
||||
bool isDirectionChangePoint = false;
|
||||
bool requiresSpecialAction = false;
|
||||
string specialActionDescription = "";
|
||||
|
||||
if (_nodeMap.ContainsKey(currentNodeId))
|
||||
{
|
||||
// 마지막 노드: 도킹/충전 노드 타입에 따라 결정
|
||||
if (_nodeMap.ContainsKey(currentNodeId))
|
||||
var currentNode = _nodeMap[currentNodeId];
|
||||
canRotate = currentNode.CanRotate;
|
||||
|
||||
// 방향 전환 감지
|
||||
if (i > 0 && directionPlan.ContainsKey(path[i - 1]))
|
||||
{
|
||||
var currentNode = _nodeMap[currentNodeId];
|
||||
motorDirection = GetRequiredDirectionForNode(currentNode);
|
||||
var prevDirection = directionPlan[path[i - 1]];
|
||||
isDirectionChangePoint = prevDirection != motorDirection;
|
||||
}
|
||||
else
|
||||
|
||||
// 특수 동작 필요 여부 감지
|
||||
if (!canRotate && isDirectionChangePoint)
|
||||
{
|
||||
motorDirection = AgvDirection.Forward;
|
||||
requiresSpecialAction = true;
|
||||
specialActionDescription = "갈림길 전진/후진 반복";
|
||||
}
|
||||
else if (canRotate && isDirectionChangePoint)
|
||||
{
|
||||
specialActionDescription = "회전 노드 방향전환";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 중간 노드: 다음 노드와의 관계를 고려한 모터방향 결정
|
||||
motorDirection = CalculateMotorDirection(currentNodeId, nextNodeId);
|
||||
}
|
||||
|
||||
nodeMotorInfos.Add(new NodeMotorInfo(currentNodeId, motorDirection, nextNodeId));
|
||||
var nodeMotorInfo = new NodeMotorInfo(currentNodeId, motorDirection, nextNodeId,
|
||||
canRotate, isDirectionChangePoint, MagnetDirection.Straight, requiresSpecialAction, specialActionDescription);
|
||||
nodeMotorInfos.Add(nodeMotorInfo);
|
||||
}
|
||||
|
||||
return nodeMotorInfos;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <param name="currentNodeId">현재 노드 ID</param>
|
||||
/// <param name="nextNodeId">다음 노드 ID</param>
|
||||
@@ -321,18 +596,8 @@ namespace AGVNavigationCore.PathFinding
|
||||
return AgvDirection.Forward;
|
||||
}
|
||||
|
||||
var currentNode = _nodeMap[currentNodeId];
|
||||
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)
|
||||
{
|
||||
@@ -346,12 +611,56 @@ namespace AGVNavigationCore.PathFinding
|
||||
}
|
||||
else
|
||||
{
|
||||
// 일반 이동: 기본적으로 전진
|
||||
// 향후 경로 패턴 분석을 통해 더 정확한 방향 결정 가능
|
||||
// 일반 이동: 기본적으로 전진 (실제로는 PlanDirectionChanges에서 결정됨)
|
||||
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>
|
||||
@@ -371,5 +680,149 @@ namespace AGVNavigationCore.PathFinding
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,40 +43,38 @@ namespace AGVNavigationCore.PathFinding
|
||||
_mapNodes = mapNodes ?? new List<MapNode>();
|
||||
_nodeMap.Clear();
|
||||
|
||||
// 1단계: 모든 네비게이션 노드를 PathNode로 변환
|
||||
// 모든 네비게이션 노드를 PathNode로 변환하고 양방향 연결 생성
|
||||
foreach (var mapNode in _mapNodes)
|
||||
{
|
||||
if (mapNode.IsNavigationNode())
|
||||
{
|
||||
var pathNode = new PathNode(mapNode.NodeId, mapNode.Position);
|
||||
pathNode.ConnectedNodes = new List<string>(mapNode.ConnectedNodes);
|
||||
_nodeMap[mapNode.NodeId] = pathNode;
|
||||
}
|
||||
}
|
||||
|
||||
// 2단계: 양방향 연결 자동 생성 (A→B 연결이 있으면 B→A도 추가)
|
||||
EnsureBidirectionalConnections();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 단방향 연결을 양방향으로 자동 변환
|
||||
/// A→B 연결이 있으면 B→A 연결도 자동 생성
|
||||
/// </summary>
|
||||
private void EnsureBidirectionalConnections()
|
||||
{
|
||||
foreach (var nodeId in _nodeMap.Keys.ToList())
|
||||
// 단일 연결을 양방향으로 확장
|
||||
foreach (var mapNode in _mapNodes)
|
||||
{
|
||||
var node = _nodeMap[nodeId];
|
||||
foreach (var connectedNodeId in node.ConnectedNodes.ToList())
|
||||
if (mapNode.IsNavigationNode() && _nodeMap.ContainsKey(mapNode.NodeId))
|
||||
{
|
||||
// 연결된 노드가 존재하고 네비게이션 가능한 노드인지 확인
|
||||
if (_nodeMap.ContainsKey(connectedNodeId))
|
||||
var pathNode = _nodeMap[mapNode.NodeId];
|
||||
|
||||
foreach (var connectedNodeId in mapNode.ConnectedNodes)
|
||||
{
|
||||
var connectedNode = _nodeMap[connectedNodeId];
|
||||
// 역방향 연결이 없으면 추가
|
||||
if (!connectedNode.ConnectedNodes.Contains(nodeId))
|
||||
if (_nodeMap.ContainsKey(connectedNodeId))
|
||||
{
|
||||
connectedNode.ConnectedNodes.Add(nodeId);
|
||||
// 양방향 연결 생성 (단일 연결이 양방향을 의미)
|
||||
if (!pathNode.ConnectedNodes.Contains(connectedNodeId))
|
||||
{
|
||||
pathNode.ConnectedNodes.Add(connectedNodeId);
|
||||
}
|
||||
|
||||
var connectedPathNode = _nodeMap[connectedNodeId];
|
||||
if (!connectedPathNode.ConnectedNodes.Contains(mapNode.NodeId))
|
||||
{
|
||||
connectedPathNode.ConnectedNodes.Add(mapNode.NodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
391
Cs_HMI/AGVNavigationCore/PathFinding/AdvancedAGVPathfinder.cs
Normal file
391
Cs_HMI/AGVNavigationCore/PathFinding/AdvancedAGVPathfinder.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
518
Cs_HMI/AGVNavigationCore/PathFinding/DirectionChangePlanner.cs
Normal file
518
Cs_HMI/AGVNavigationCore/PathFinding/DirectionChangePlanner.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
304
Cs_HMI/AGVNavigationCore/PathFinding/JunctionAnalyzer.cs
Normal file
304
Cs_HMI/AGVNavigationCore/PathFinding/JunctionAnalyzer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,28 @@ using AGVNavigationCore.Models;
|
||||
namespace AGVNavigationCore.PathFinding
|
||||
{
|
||||
/// <summary>
|
||||
/// 노드별 모터방향 정보
|
||||
/// AGV 마그넷 센서 방향 제어
|
||||
/// </summary>
|
||||
public enum MagnetDirection
|
||||
{
|
||||
/// <summary>
|
||||
/// 직진 - 기본 마그넷 라인 추종
|
||||
/// </summary>
|
||||
Straight = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 좌측 - 마그넷 센서 가중치를 좌측으로 조정
|
||||
/// </summary>
|
||||
Left = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 우측 - 마그넷 센서 가중치를 우측으로 조정
|
||||
/// </summary>
|
||||
Right = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 노드별 모터방향 정보 (방향 전환 지원 포함)
|
||||
/// </summary>
|
||||
public class NodeMotorInfo
|
||||
{
|
||||
@@ -17,16 +38,84 @@ namespace AGVNavigationCore.PathFinding
|
||||
/// </summary>
|
||||
public AgvDirection MotorDirection { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 마그넷 센서 방향 제어 (갈림길 처리용)
|
||||
/// </summary>
|
||||
public MagnetDirection MagnetDirection { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 다음 노드 ID (경로예측용)
|
||||
/// </summary>
|
||||
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;
|
||||
MotorDirection = motorDirection;
|
||||
MagnetDirection = magnetDirection;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
67
Cs_HMI/AGVNavigationCore/PathFinding/PathfindingOptions.cs
Normal file
67
Cs_HMI/AGVNavigationCore/PathFinding/PathfindingOptions.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
||||
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
|
||||
<ProjectGuid>{B2C3D4E5-0000-0000-0000-000000000000}</ProjectGuid>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<OutputType>Exe</OutputType>
|
||||
<RootNamespace>AGVSimulator</RootNamespace>
|
||||
<AssemblyName>AGVSimulator</AssemblyName>
|
||||
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
|
||||
@@ -31,6 +31,9 @@
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<StartupObject />
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="System" />
|
||||
<Reference Include="System.Core" />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
171
Cs_HMI/AGVSimulator/Forms/SimulatorForm.Designer.cs
generated
171
Cs_HMI/AGVSimulator/Forms/SimulatorForm.Designer.cs
generated
@@ -70,6 +70,7 @@ namespace AGVSimulator.Forms
|
||||
this.startSimulationToolStripButton = new System.Windows.Forms.ToolStripButton();
|
||||
this.stopSimulationToolStripButton = 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.fitToMapToolStripButton = 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._startPathButton = 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.targetNodeLabel = new System.Windows.Forms.Label();
|
||||
this._startNodeCombo = new System.Windows.Forms.ComboBox();
|
||||
@@ -93,13 +95,19 @@ namespace AGVSimulator.Forms
|
||||
this._setPositionButton = new System.Windows.Forms.Button();
|
||||
this._rfidTextBox = new System.Windows.Forms.TextBox();
|
||||
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._startSimulationButton = new System.Windows.Forms.Button();
|
||||
this._removeAgvButton = new System.Windows.Forms.Button();
|
||||
this._addAgvButton = new System.Windows.Forms.Button();
|
||||
this._agvListCombo = new System.Windows.Forms.ComboBox();
|
||||
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._toolStrip.SuspendLayout();
|
||||
this._statusStrip.SuspendLayout();
|
||||
@@ -107,6 +115,7 @@ namespace AGVSimulator.Forms
|
||||
this._statusGroup.SuspendLayout();
|
||||
this._pathGroup.SuspendLayout();
|
||||
this._agvControlGroup.SuspendLayout();
|
||||
this._agvInfoPanel.SuspendLayout();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// _menuStrip
|
||||
@@ -139,7 +148,7 @@ namespace AGVSimulator.Forms
|
||||
//
|
||||
this.openMapToolStripMenuItem.Name = "openMapToolStripMenuItem";
|
||||
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.Click += new System.EventHandler(this.OnOpenMap_Click);
|
||||
//
|
||||
@@ -147,33 +156,33 @@ namespace AGVSimulator.Forms
|
||||
//
|
||||
this.reloadMapToolStripMenuItem.Name = "reloadMapToolStripMenuItem";
|
||||
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.Click += new System.EventHandler(this.OnReloadMap_Click);
|
||||
//
|
||||
// toolStripSeparator1
|
||||
//
|
||||
this.toolStripSeparator1.Name = "toolStripSeparator1";
|
||||
this.toolStripSeparator1.Size = new System.Drawing.Size(180, 6);
|
||||
this.toolStripSeparator1.Size = new System.Drawing.Size(218, 6);
|
||||
//
|
||||
// launchMapEditorToolStripMenuItem
|
||||
//
|
||||
this.launchMapEditorToolStripMenuItem.Name = "launchMapEditorToolStripMenuItem";
|
||||
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.Click += new System.EventHandler(this.OnLaunchMapEditor_Click);
|
||||
//
|
||||
// toolStripSeparator4
|
||||
//
|
||||
this.toolStripSeparator4.Name = "toolStripSeparator4";
|
||||
this.toolStripSeparator4.Size = new System.Drawing.Size(180, 6);
|
||||
this.toolStripSeparator4.Size = new System.Drawing.Size(218, 6);
|
||||
//
|
||||
// exitToolStripMenuItem
|
||||
//
|
||||
this.exitToolStripMenuItem.Name = "exitToolStripMenuItem";
|
||||
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.Click += new System.EventHandler(this.OnExit_Click);
|
||||
//
|
||||
@@ -284,7 +293,7 @@ namespace AGVSimulator.Forms
|
||||
//
|
||||
this.reloadMapToolStripButton.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text;
|
||||
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.ToolTipText = "현재 맵을 다시 로드합니다";
|
||||
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.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.ToolTipText = "MapEditor를 실행합니다";
|
||||
this.launchMapEditorToolStripButton.Click += new System.EventHandler(this.OnLaunchMapEditor_Click);
|
||||
@@ -330,6 +339,15 @@ namespace AGVSimulator.Forms
|
||||
this.resetToolStripButton.ToolTipText = "시뮬레이션을 초기화합니다";
|
||||
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
|
||||
//
|
||||
this.toolStripSeparator3.Name = "toolStripSeparator3";
|
||||
@@ -382,9 +400,9 @@ namespace AGVSimulator.Forms
|
||||
this._controlPanel.Controls.Add(this._pathGroup);
|
||||
this._controlPanel.Controls.Add(this._agvControlGroup);
|
||||
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.Size = new System.Drawing.Size(250, 729);
|
||||
this._controlPanel.Size = new System.Drawing.Size(233, 729);
|
||||
this._controlPanel.TabIndex = 3;
|
||||
//
|
||||
// _statusGroup
|
||||
@@ -392,9 +410,10 @@ namespace AGVSimulator.Forms
|
||||
this._statusGroup.Controls.Add(this._pathLengthLabel);
|
||||
this._statusGroup.Controls.Add(this._agvCountLabel);
|
||||
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.Size = new System.Drawing.Size(230, 100);
|
||||
this._statusGroup.Size = new System.Drawing.Size(233, 100);
|
||||
this._statusGroup.TabIndex = 3;
|
||||
this._statusGroup.TabStop = false;
|
||||
this._statusGroup.Text = "상태 정보";
|
||||
@@ -431,20 +450,22 @@ namespace AGVSimulator.Forms
|
||||
this._pathGroup.Controls.Add(this._clearPathButton);
|
||||
this._pathGroup.Controls.Add(this._startPathButton);
|
||||
this._pathGroup.Controls.Add(this._calculatePathButton);
|
||||
this._pathGroup.Controls.Add(this._avoidRotationCheckBox);
|
||||
this._pathGroup.Controls.Add(this._targetNodeCombo);
|
||||
this._pathGroup.Controls.Add(this.targetNodeLabel);
|
||||
this._pathGroup.Controls.Add(this._startNodeCombo);
|
||||
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.Size = new System.Drawing.Size(230, 150);
|
||||
this._pathGroup.Size = new System.Drawing.Size(233, 190);
|
||||
this._pathGroup.TabIndex = 1;
|
||||
this._pathGroup.TabStop = false;
|
||||
this._pathGroup.Text = "경로 제어";
|
||||
//
|
||||
// _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.Size = new System.Drawing.Size(70, 25);
|
||||
this._clearPathButton.TabIndex = 6;
|
||||
@@ -454,7 +475,7 @@ namespace AGVSimulator.Forms
|
||||
//
|
||||
// _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.Size = new System.Drawing.Size(65, 25);
|
||||
this._startPathButton.TabIndex = 5;
|
||||
@@ -464,7 +485,7 @@ namespace AGVSimulator.Forms
|
||||
//
|
||||
// _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.Size = new System.Drawing.Size(65, 25);
|
||||
this._calculatePathButton.TabIndex = 4;
|
||||
@@ -472,10 +493,20 @@ namespace AGVSimulator.Forms
|
||||
this._calculatePathButton.UseVisualStyleBackColor = true;
|
||||
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
|
||||
//
|
||||
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.Size = new System.Drawing.Size(210, 20);
|
||||
this._targetNodeCombo.TabIndex = 3;
|
||||
@@ -511,14 +542,17 @@ namespace AGVSimulator.Forms
|
||||
this._agvControlGroup.Controls.Add(this._setPositionButton);
|
||||
this._agvControlGroup.Controls.Add(this._rfidTextBox);
|
||||
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._startSimulationButton);
|
||||
this._agvControlGroup.Controls.Add(this._removeAgvButton);
|
||||
this._agvControlGroup.Controls.Add(this._addAgvButton);
|
||||
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.Size = new System.Drawing.Size(230, 180);
|
||||
this._agvControlGroup.Size = new System.Drawing.Size(233, 214);
|
||||
this._agvControlGroup.TabIndex = 0;
|
||||
this._agvControlGroup.TabStop = false;
|
||||
this._agvControlGroup.Text = "AGV 제어";
|
||||
@@ -527,7 +561,7 @@ namespace AGVSimulator.Forms
|
||||
//
|
||||
this._setPositionButton.Location = new System.Drawing.Point(160, 138);
|
||||
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.Text = "위치설정";
|
||||
this._setPositionButton.UseVisualStyleBackColor = true;
|
||||
@@ -550,6 +584,24 @@ namespace AGVSimulator.Forms
|
||||
this._rfidLabel.TabIndex = 5;
|
||||
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
|
||||
//
|
||||
this._stopSimulationButton.Location = new System.Drawing.Point(120, 85);
|
||||
@@ -602,19 +654,65 @@ namespace AGVSimulator.Forms
|
||||
// _canvasPanel
|
||||
//
|
||||
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.Size = new System.Drawing.Size(950, 729);
|
||||
this._canvasPanel.Size = new System.Drawing.Size(967, 669);
|
||||
this._canvasPanel.TabIndex = 4;
|
||||
//
|
||||
// btAllReset
|
||||
// _agvInfoPanel
|
||||
//
|
||||
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);
|
||||
this._agvInfoPanel.BackColor = System.Drawing.Color.LightBlue;
|
||||
this._agvInfoPanel.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
|
||||
this._agvInfoPanel.Controls.Add(this._agvInfoTitleLabel);
|
||||
this._agvInfoPanel.Controls.Add(this._liftDirectionLabel);
|
||||
this._agvInfoPanel.Controls.Add(this._motorDirectionLabel);
|
||||
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
|
||||
//
|
||||
@@ -622,6 +720,7 @@ namespace AGVSimulator.Forms
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.ClientSize = new System.Drawing.Size(1200, 800);
|
||||
this.Controls.Add(this._canvasPanel);
|
||||
this.Controls.Add(this._agvInfoPanel);
|
||||
this.Controls.Add(this._controlPanel);
|
||||
this.Controls.Add(this._statusStrip);
|
||||
this.Controls.Add(this._toolStrip);
|
||||
@@ -644,6 +743,8 @@ namespace AGVSimulator.Forms
|
||||
this._pathGroup.PerformLayout();
|
||||
this._agvControlGroup.ResumeLayout(false);
|
||||
this._agvControlGroup.PerformLayout();
|
||||
this._agvInfoPanel.ResumeLayout(false);
|
||||
this._agvInfoPanel.PerformLayout();
|
||||
this.ResumeLayout(false);
|
||||
this.PerformLayout();
|
||||
|
||||
@@ -692,6 +793,7 @@ namespace AGVSimulator.Forms
|
||||
private System.Windows.Forms.Button _calculatePathButton;
|
||||
private System.Windows.Forms.Button _startPathButton;
|
||||
private System.Windows.Forms.Button _clearPathButton;
|
||||
private System.Windows.Forms.CheckBox _avoidRotationCheckBox;
|
||||
private System.Windows.Forms.GroupBox _statusGroup;
|
||||
private System.Windows.Forms.Label _simulationStatusLabel;
|
||||
private System.Windows.Forms.Label _agvCountLabel;
|
||||
@@ -700,11 +802,18 @@ namespace AGVSimulator.Forms
|
||||
private System.Windows.Forms.Label _rfidLabel;
|
||||
private System.Windows.Forms.TextBox _rfidTextBox;
|
||||
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.ToolStripMenuItem reloadMapToolStripMenuItem;
|
||||
private System.Windows.Forms.ToolStripMenuItem launchMapEditorToolStripMenuItem;
|
||||
private System.Windows.Forms.ToolStripSeparator toolStripSeparator4;
|
||||
private System.Windows.Forms.ToolStripButton reloadMapToolStripButton;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ namespace AGVSimulator.Forms
|
||||
private List<MapNode> _mapNodes;
|
||||
private NodeResolver _nodeResolver;
|
||||
private PathCalculator _pathCalculator;
|
||||
private AdvancedAGVPathfinder _advancedPathfinder;
|
||||
private List<VirtualAGV> _agvList;
|
||||
private SimulationState _simulationState;
|
||||
private Timer _simulationTimer;
|
||||
@@ -50,6 +51,9 @@ namespace AGVSimulator.Forms
|
||||
{
|
||||
InitializeComponent();
|
||||
InitializeForm();
|
||||
|
||||
// Load 이벤트 연결
|
||||
this.Load += SimulatorForm_Load;
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -80,6 +84,8 @@ namespace AGVSimulator.Forms
|
||||
|
||||
// 초기 상태 설정
|
||||
UpdateUI();
|
||||
|
||||
// 마지막 맵 파일 자동 로드 확인은 Form_Load에서 수행
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +127,12 @@ namespace AGVSimulator.Forms
|
||||
|
||||
#region Event Handlers
|
||||
|
||||
private void SimulatorForm_Load(object sender, EventArgs e)
|
||||
{
|
||||
// 폼이 완전히 로드된 후 마지막 맵 파일 자동 로드 확인
|
||||
CheckAndLoadLastMapFile();
|
||||
}
|
||||
|
||||
private void OnOpenMap_Click(object sender, EventArgs e)
|
||||
{
|
||||
using (var openDialog = new OpenFileDialog())
|
||||
@@ -279,7 +291,39 @@ namespace AGVSimulator.Forms
|
||||
_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)
|
||||
{
|
||||
@@ -434,8 +478,12 @@ namespace AGVSimulator.Forms
|
||||
if (nodesWithRfid.Count == 0)
|
||||
return "RFID가 할당된 노드가 없습니다.";
|
||||
|
||||
// 처음 10개의 RFID만 표시
|
||||
var rfidList = nodesWithRfid.Take(10).Select(n => $"- {n.RfidId} → {n.NodeId}");
|
||||
// 처음 10개의 RFID만 표시 (노드 이름 포함)
|
||||
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);
|
||||
|
||||
if (nodesWithRfid.Count > 10)
|
||||
@@ -457,8 +505,7 @@ namespace AGVSimulator.Forms
|
||||
_mapNodes = result.Nodes;
|
||||
_currentMapFilePath = filePath;
|
||||
|
||||
// RFID가 없는 노드들에 자동 할당
|
||||
MapLoader.AssignAutoRfidIds(_mapNodes);
|
||||
// RFID 자동 할당 제거 - 에디터에서 설정한 값 그대로 사용
|
||||
|
||||
// 시뮬레이터 캔버스에 맵 설정
|
||||
_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()
|
||||
{
|
||||
_startNodeCombo.Items.Clear();
|
||||
@@ -499,8 +575,9 @@ namespace AGVSimulator.Forms
|
||||
{
|
||||
if (node.IsActive && node.HasRfid())
|
||||
{
|
||||
// {rfid} - [{node}] 형식으로 ComboBoxItem 생성
|
||||
var displayText = $"{node.RfidId} - [{node.NodeId}]";
|
||||
// {rfid} - [{node}] {name} 형식으로 ComboBoxItem 생성
|
||||
var nodeNamePart = !string.IsNullOrEmpty(node.Name) ? $" {node.Name}" : "";
|
||||
var displayText = $"{node.RfidId} - [{node.NodeId}]{nodeNamePart}";
|
||||
var item = new ComboBoxItem<MapNode>(node, displayText);
|
||||
|
||||
_startNodeCombo.Items.Add(item);
|
||||
@@ -709,6 +786,111 @@ namespace AGVSimulator.Forms
|
||||
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>
|
||||
/// 경로 디버깅 정보 업데이트 (RFID 값 표시, 모터방향 정보 포함)
|
||||
/// </summary>
|
||||
@@ -736,7 +918,14 @@ namespace AGVSimulator.Forms
|
||||
var info = agvResult.NodeMotorInfos[i];
|
||||
var rfidId = GetRfidByNodeId(info.NodeId);
|
||||
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 rfidId = GetRfidByNodeId(motorInfo.NodeId);
|
||||
string motorSymbol = motorInfo.MotorDirection == AgvDirection.Forward ? "[전진]" : "[후진]";
|
||||
|
||||
// 특수 동작 표시 추가
|
||||
if (motorInfo.RequiresSpecialAction)
|
||||
motorSymbol += "[🔄]";
|
||||
else if (motorInfo.IsDirectionChangePoint && motorInfo.CanRotate)
|
||||
motorSymbol += "[↻]";
|
||||
|
||||
pathWithMotorInfo.Add($"{rfidId}{motorSymbol}");
|
||||
}
|
||||
pathString = string.Join(" → ", pathWithMotorInfo);
|
||||
|
||||
@@ -26,6 +26,11 @@ namespace AGVSimulator.Models
|
||||
/// </summary>
|
||||
public bool AutoSave { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 프로그램 시작시 마지막 맵 파일을 자동으로 로드할지 여부
|
||||
/// </summary>
|
||||
public bool AutoLoadLastMapFile { get; set; } = true;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Static Methods
|
||||
@@ -109,6 +114,15 @@ namespace AGVSimulator.Models
|
||||
File.Exists(MapEditorExecutablePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 마지막 맵 파일이 존재하는지 확인
|
||||
/// </summary>
|
||||
/// <returns>마지막 맵 파일이 유효한지 여부</returns>
|
||||
public bool HasValidLastMapFile()
|
||||
{
|
||||
return !string.IsNullOrEmpty(LastMapFilePath) && File.Exists(LastMapFilePath);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -9,21 +9,33 @@ namespace AGVSimulator
|
||||
/// </summary>
|
||||
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>
|
||||
/// <param name="args">명령줄 인수</param>
|
||||
[STAThread]
|
||||
static void Main()
|
||||
static void Main(string[] args)
|
||||
{
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
|
||||
try
|
||||
{
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
Application.Run(new SimulatorForm());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[ERROR] 시뮬레이터 실행 중 오류: {ex.Message}");
|
||||
Console.WriteLine($"[ERROR] 스택 트레이스: {ex.StackTrace}");
|
||||
|
||||
MessageBox.Show($"시뮬레이터 실행 중 오류가 발생했습니다:\n{ex.Message}",
|
||||
"시스템 오류", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
}
|
||||
|
||||
@@ -140,3 +140,64 @@ SubProject 내의 GitUpdate.bat을 사용하여 모든 하위 프로젝트를
|
||||
- 통신 관련 코드 변경시 하드웨어 호환성 고려 필요
|
||||
- **맵 에디터/시뮬레이터**: AGVMapEditor 프로젝트에 의존성이 있으므로 먼저 빌드 필요
|
||||
- **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
Reference in New Issue
Block a user