fix: Add motor direction parameter to magnet direction calculation in pathfinding

- Fixed critical issue in ConvertToDetailedPath where motor direction was not passed to GetRequiredMagnetDirection
- Motor direction is essential for backward movement as Left/Right directions must be inverted
- Modified AGVPathfinder.cs line 280 to pass currentDirection parameter
- Ensures backward motor direction properly inverts magnet sensor directions

feat: Add waypoint support to pathfinding system

- Added FindPath overload with params string[] waypointNodeIds in AStarPathfinder
- Supports sequential traversal through multiple intermediate nodes
- Validates waypoints and prevents duplicates in sequence
- Returns combined path result with aggregated metrics

feat: Implement path result merging with DetailedPath preservation

- Added CombineResults method in AStarPathfinder for intelligent path merging
- Automatically deduplicates nodes when last of previous path equals first of current
- Preserves DetailedPath information including motor and magnet directions
- Essential for multi-segment path operations

feat: Integrate magnet direction with motor direction awareness

- Modified JunctionAnalyzer.GetRequiredMagnetDirection to accept AgvDirection parameter
- Inverts Left/Right magnet directions when moving Backward
- Properly handles motor direction context throughout pathfinding

feat: Add automatic start node selection in simulator

- Added SetStartNodeToCombo method to SimulatorForm
- Automatically selects start node combo box when AGV position is set via RFID
- Improves UI usability and workflow efficiency

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
backuppc
2025-10-24 15:46:16 +09:00
parent 3ddecf63ed
commit d932b8d332
47 changed files with 7473 additions and 1088 deletions

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
@@ -49,7 +49,11 @@
</ProjectReference>
</ItemGroup>
<ItemGroup>
<Compile Include="Forms\ImageEditorForm.cs">
<SubType>Form</SubType>
</Compile>
<Compile Include="Models\EditorSettings.cs" />
<Compile Include="Models\ImagePathEditor.cs" />
<Compile Include="Models\MapImage.cs" />
<Compile Include="Models\MapLabel.cs" />
<Compile Include="Models\NodePropertyWrapper.cs" />

View File

@@ -0,0 +1,287 @@
using System;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Linq;
using System.Windows.Forms;
using AGVNavigationCore.Models;
using AGVNavigationCore.Utils;
namespace AGVMapEditor.Forms
{
/// <summary>
/// 이미지 노드의 이미지를 편집하기 위한 간단한 그림판
/// 불러오기, 저장, 크기 조정, 기본 드로잉 기능 제공
/// </summary>
public partial class ImageEditorForm : Form
{
private Bitmap _editingImage;
private Graphics _graphics;
private bool _isDrawing = false;
private Point _lastPoint = Point.Empty;
private Color _drawColor = Color.Black;
private int _brushSize = 3;
private MapNode _targetNode;
public ImageEditorForm(MapNode imageNode = null)
{
//InitializeComponent();
_targetNode = imageNode;
SetupUI();
if (imageNode != null && imageNode.LoadedImage != null)
{
LoadImageFromNode(imageNode);
}
}
private void SetupUI()
{
this.Text = "이미지 편집기";
this.Size = new Size(800, 600);
this.StartPosition = FormStartPosition.CenterScreen;
// 패널: 도구 모음
var toolPanel = new Panel { Dock = DockStyle.Top, Height = 50, BackColor = Color.LightGray };
// 버튼: 이미지 열기
var btnOpen = new Button { Text = "이미지 열기", Width = 100, Left = 10, Top = 10 };
btnOpen.Click += BtnOpen_Click;
toolPanel.Controls.Add(btnOpen);
// 버튼: 크기 조정
var btnResize = new Button { Text = "크기 조정", Width = 100, Left = 120, Top = 10 };
btnResize.Click += BtnResize_Click;
toolPanel.Controls.Add(btnResize);
// 버튼: 저장
var btnSave = new Button { Text = "저장 및 닫기", Width = 100, Left = 230, Top = 10 };
btnSave.Click += BtnSave_Click;
toolPanel.Controls.Add(btnSave);
// 라벨: 브러시 크기
var lblBrush = new Label { Text = "브러시:", Left = 350, Top = 15, Width = 50 };
toolPanel.Controls.Add(lblBrush);
// 트랙바: 브러시 크기 조절
var trackBrush = new TrackBar { Left = 410, Top = 10, Width = 100, Minimum = 1, Maximum = 20, Value = 3 };
trackBrush.ValueChanged += (s, e) => _brushSize = trackBrush.Value;
toolPanel.Controls.Add(trackBrush);
// 버튼: 색상 선택
var btnColor = new Button { Text = "색상", Width = 60, Left = 520, Top = 10, BackColor = Color.Black, ForeColor = Color.White };
btnColor.Click += BtnColor_Click;
toolPanel.Controls.Add(btnColor);
// 패널: 캔버스
var canvasPanel = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.White };
var pictureBox = new PictureBox { SizeMode = PictureBoxSizeMode.AutoSize };
pictureBox.Name = "pictureBoxCanvas";
pictureBox.MouseDown += PictureBox_MouseDown;
pictureBox.MouseMove += PictureBox_MouseMove;
pictureBox.MouseUp += PictureBox_MouseUp;
canvasPanel.Controls.Add(pictureBox);
this.Controls.Add(canvasPanel);
this.Controls.Add(toolPanel);
}
private void LoadImageFromNode(MapNode node)
{
if (node.LoadedImage != null)
{
_editingImage?.Dispose();
_graphics?.Dispose();
_editingImage = new Bitmap(node.LoadedImage);
_graphics = Graphics.FromImage(_editingImage);
UpdateCanvas();
}
}
private void BtnOpen_Click(object sender, EventArgs e)
{
using (var ofd = new OpenFileDialog { Filter = "이미지|*.jpg;*.png;*.bmp|모든 파일|*.*" })
{
if (ofd.ShowDialog() == DialogResult.OK)
{
LoadImageFromFile(ofd.FileName);
}
}
}
private void LoadImageFromFile(string filePath)
{
try
{
_editingImage?.Dispose();
_graphics?.Dispose();
var loadedImage = Image.FromFile(filePath);
// 이미지 크기가 크면 자동 축소 (최대 512x512)
if (loadedImage.Width > 512 || loadedImage.Height > 512)
{
_editingImage = ResizeImage(loadedImage, 512, 512);
loadedImage.Dispose();
}
else
{
_editingImage = new Bitmap(loadedImage);
loadedImage.Dispose();
}
_graphics = Graphics.FromImage(_editingImage);
UpdateCanvas();
}
catch (Exception ex)
{
MessageBox.Show($"이미지 로드 실패: {ex.Message}");
}
}
private void BtnResize_Click(object sender, EventArgs e)
{
if (_editingImage == null)
{
MessageBox.Show("먼저 이미지를 로드하세요.");
return;
}
using (var form = new Form())
{
form.Text = "이미지 크기 조정";
form.Size = new Size(300, 150);
form.StartPosition = FormStartPosition.CenterParent;
var lblWidth = new Label { Text = "너비:", Left = 10, Top = 10, Width = 50 };
var txtWidth = new TextBox { Left = 70, Top = 10, Width = 100, Text = _editingImage.Width.ToString() };
var lblHeight = new Label { Text = "높이:", Left = 10, Top = 40, Width = 50 };
var txtHeight = new TextBox { Left = 70, Top = 40, Width = 100, Text = _editingImage.Height.ToString() };
var btnOk = new Button { Text = "적용", DialogResult = DialogResult.OK, Left = 70, Top = 70, Width = 100 };
var btnCancel = new Button { Text = "취소", DialogResult = DialogResult.Cancel, Left = 180, Top = 70, Width = 70 };
form.Controls.Add(lblWidth);
form.Controls.Add(txtWidth);
form.Controls.Add(lblHeight);
form.Controls.Add(txtHeight);
form.Controls.Add(btnOk);
form.Controls.Add(btnCancel);
if (form.ShowDialog(this) == DialogResult.OK)
{
if (int.TryParse(txtWidth.Text, out int width) && int.TryParse(txtHeight.Text, out int height))
{
if (width > 0 && height > 0)
{
_graphics?.Dispose();
var resized = new Bitmap(_editingImage, width, height);
_editingImage.Dispose();
_editingImage = resized;
_graphics = Graphics.FromImage(_editingImage);
UpdateCanvas();
}
}
}
}
}
private void BtnColor_Click(object sender, EventArgs e)
{
using (var cfd = new ColorDialog { Color = _drawColor })
{
if (cfd.ShowDialog() == DialogResult.OK)
{
_drawColor = cfd.Color;
(sender as Button).BackColor = _drawColor;
}
}
}
private void BtnSave_Click(object sender, EventArgs e)
{
if (_editingImage == null)
{
MessageBox.Show("저장할 이미지가 없습니다.");
return;
}
if (_targetNode != null && _targetNode.Type == NodeType.Image)
{
// 이미지를 Base64로 변환하여 저장
_targetNode.ImageBase64 = ImageConverterUtil.ImageToBase64(_editingImage, System.Drawing.Imaging.ImageFormat.Png);
_targetNode.LoadedImage?.Dispose();
_targetNode.LoadedImage = new Bitmap(_editingImage);
MessageBox.Show("이미지가 저장되었습니다.");
this.DialogResult = DialogResult.OK;
this.Close();
}
}
private void PictureBox_MouseDown(object sender, MouseEventArgs e)
{
if (_editingImage != null && e.Button == MouseButtons.Left)
{
_isDrawing = true;
_lastPoint = e.Location;
}
}
private void PictureBox_MouseMove(object sender, MouseEventArgs e)
{
if (_isDrawing && _lastPoint != Point.Empty)
{
_graphics.DrawLine(new Pen(_drawColor, _brushSize), _lastPoint, e.Location);
_lastPoint = e.Location;
UpdateCanvas();
}
}
private void PictureBox_MouseUp(object sender, MouseEventArgs e)
{
_isDrawing = false;
_lastPoint = Point.Empty;
}
private void UpdateCanvas()
{
var pictureBox = this.Controls.Find("pictureBoxCanvas", true).FirstOrDefault() as PictureBox;
if (pictureBox != null)
{
pictureBox.Image?.Dispose();
pictureBox.Image = new Bitmap(_editingImage);
}
}
private Bitmap 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 resized = new Bitmap(newWidth, newHeight);
using (var g = Graphics.FromImage(resized))
{
g.CompositingQuality = CompositingQuality.HighQuality;
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
g.SmoothingMode = SmoothingMode.HighQuality;
g.DrawImage(image, 0, 0, newWidth, newHeight);
}
return resized;
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_editingImage?.Dispose();
_graphics?.Dispose();
}
base.Dispose(disposing);
}
}
}

View File

@@ -4,6 +4,7 @@ using System.Drawing;
using System.IO;
using System.Linq;
using System.Windows.Forms;
using AGVMapEditor.Models;
using AGVNavigationCore.Controls;
using AGVNavigationCore.Models;
using Newtonsoft.Json;
@@ -165,52 +166,61 @@ namespace AGVMapEditor.Forms
btnAddImage.Location = new Point(325, 3);
btnAddImage.Click += (s, e) => _mapCanvas.CurrentEditMode = UnifiedAGVCanvas.EditMode.AddImage;
// 이미지 편집 버튼
var btnEditImage = new Button();
btnEditImage.Name = "btnToolbarEditImage";
btnEditImage.Text = "이미지 편집";
btnEditImage.Size = new Size(80, 28);
btnEditImage.Location = new Point(420, 3);
btnEditImage.Enabled = false; // 처음에는 비활성화
btnEditImage.Click += BtnToolbarEditImage_Click;
// 연결 모드 버튼
var btnConnect = new Button();
btnConnect.Text = "연결 (C)";
btnConnect.Size = new Size(70, 28);
btnConnect.Location = new Point(420, 3);
btnConnect.Location = new Point(505, 3);
btnConnect.Click += (s, e) => _mapCanvas.CurrentEditMode = UnifiedAGVCanvas.EditMode.Connect;
// 삭제 모드 버튼
var btnDelete = new Button();
btnDelete.Text = "삭제 (D)";
btnDelete.Size = new Size(70, 28);
btnDelete.Location = new Point(495, 3);
btnDelete.Location = new Point(580, 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.Location = new Point(655, 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(655, 3);
separator1.Location = new Point(740, 3);
separator1.TextAlign = ContentAlignment.MiddleCenter;
// 그리드 토글 버튼
var btnToggleGrid = new Button();
btnToggleGrid.Text = "그리드";
btnToggleGrid.Size = new Size(60, 28);
btnToggleGrid.Location = new Point(670, 3);
btnToggleGrid.Location = new Point(755, 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(735, 3);
btnFitMap.Location = new Point(820, 3);
btnFitMap.Click += (s, e) => _mapCanvas.FitToNodes();
// 툴바에 버튼들 추가
toolbarPanel.Controls.AddRange(new Control[]
{
btnSelect, btnMove, btnAddNode, btnAddLabel, btnAddImage, btnConnect, btnDelete, btnDeleteConnection, separator1, btnToggleGrid, btnFitMap
btnSelect, btnMove, btnAddNode, btnAddLabel, btnAddImage, btnEditImage, btnConnect, btnDelete, btnDeleteConnection, separator1, btnToggleGrid, btnFitMap
});
// 스플리터 패널에 툴바 추가 (맨 위에)
@@ -946,11 +956,59 @@ namespace AGVMapEditor.Forms
var nodeWrapper = NodePropertyWrapperFactory.CreateWrapper(_selectedNode, _mapNodes);
_propertyGrid.SelectedObject = nodeWrapper;
_propertyGrid.Focus();
// 이미지 노드인 경우 편집 버튼 활성화
UpdateImageEditButton();
}
private void ClearNodeProperties()
{
_propertyGrid.SelectedObject = null;
DisableImageEditButton();
}
/// <summary>
/// 선택된 노드가 이미지 노드이면 편집 버튼 활성화
/// </summary>
private void UpdateImageEditButton()
{
var btn = this.Controls.Find("btnToolbarEditImage", true).FirstOrDefault() as Button;
if (btn != null)
{
btn.Enabled = (_selectedNode != null && _selectedNode.Type == NodeType.Image);
}
}
/// <summary>
/// 이미지 편집 버튼 비활성화
/// </summary>
private void DisableImageEditButton()
{
var btn = this.Controls.Find("btnToolbarEditImage", true).FirstOrDefault() as Button;
if (btn != null)
{
btn.Enabled = false;
}
}
/// <summary>
/// 상단 툴바의 이미지 편집 버튼 클릭 이벤트
/// </summary>
private void BtnToolbarEditImage_Click(object sender, EventArgs e)
{
if (_selectedNode != null && _selectedNode.Type == NodeType.Image)
{
using (var editor = new ImageEditorForm(_selectedNode))
{
if (editor.ShowDialog(this) == DialogResult.OK)
{
_hasChanges = true;
UpdateTitle();
_mapCanvas.Invalidate(); // 캔버스 다시 그리기
UpdateNodeProperties(); // 속성 업데이트
}
}
}
}
private void UpdateTitle()

View File

@@ -0,0 +1,77 @@
using System;
using System.ComponentModel;
using System.Drawing.Design;
using System.Windows.Forms;
using System.Windows.Forms.Design;
namespace AGVMapEditor.Models
{
/// <summary>
/// PropertyGrid에서 이미지 파일 경로를 선택하기 위한 커스텀 UITypeEditor
/// PropertyGrid에 "..." 버튼을 표시하고, 클릭 시 파일 열기 대화상자를 표시
/// </summary>
public class ImagePathEditor : UITypeEditor
{
/// <summary>
/// PropertyGrid에서 이 에디터의 UI 스타일 반환
/// DropDown 스타일을 사용하여 "..." 버튼을 표시
/// </summary>
public override UITypeEditorEditStyle GetEditStyle(ITypeDescriptorContext context)
{
return UITypeEditorEditStyle.Modal;
}
/// <summary>
/// 사용자가 "..." 버튼을 클릭할 때 호출되는 메서드
/// </summary>
public override object EditValue(ITypeDescriptorContext context, IServiceProvider provider, object value)
{
// IWindowsFormsEditorService를 얻어서 대화상자를 표시
var editorService = provider?.GetService(typeof(IWindowsFormsEditorService)) as IWindowsFormsEditorService;
if (editorService == null)
return value;
// 파일 열기 대화상자 생성
using (var ofd = new OpenFileDialog())
{
ofd.Title = "이미지 파일 선택";
ofd.Filter = "이미지 파일|*.jpg;*.jpeg;*.png;*.bmp;*.gif|모든 파일|*.*";
ofd.CheckFileExists = true;
// 현재 경로가 있으면 해당 위치에서 시작
if (!string.IsNullOrEmpty(value?.ToString()))
{
try
{
string currentPath = value.ToString();
if (System.IO.File.Exists(currentPath))
{
ofd.InitialDirectory = System.IO.Path.GetDirectoryName(currentPath);
ofd.FileName = System.IO.Path.GetFileName(currentPath);
}
}
catch { }
}
// 대화상자 표시
if (ofd.ShowDialog() == DialogResult.OK)
{
// 선택된 파일 경로를 Base64로 변환하고 반환
string filePath = ofd.FileName;
return filePath; // MapNode의 ConvertImageToBase64는 setter에서 호출됨
}
}
return value;
}
/// <summary>
/// PropertyGrid에서 이 타입의 값을 표시하는 방법
/// 파일 경로를 파일명만 표시하도록 처리
/// </summary>
public override bool GetPaintValueSupported(ITypeDescriptorContext context)
{
return false;
}
}
}

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing;
using System.Drawing.Design;
using AGVNavigationCore.Models;
namespace AGVMapEditor.Models
@@ -237,15 +238,30 @@ namespace AGVMapEditor.Models
[Category("이미지")]
[DisplayName("이미지 경로")]
[Description("이미지 파일 경로")]
// 파일 선택 에디터는 나중에 구현
[Description("이미지 파일 경로 (... 버튼으로 파일 선택)")]
[Editor(typeof(ImagePathEditor), typeof(UITypeEditor))]
public string ImagePath
{
get => _node.ImagePath;
set
{
_node.ImagePath = value ?? "";
_node.LoadImage(); // 이미지 다시 로드
if (string.IsNullOrEmpty(value))
{
_node.ImagePath = "";
return;
}
// 파일이 존재하면 Base64로 변환하여 저장
if (System.IO.File.Exists(value))
{
_node.ConvertImageToBase64(value);
_node.LoadImage(); // 이미지 다시 로드
}
else
{
_node.ImagePath = value;
}
_node.ModifiedDate = DateTime.Now;
}
}