fix: RFID duplicate validation and correct magnet direction calculation

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

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

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

View File

@@ -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))
{
@@ -54,14 +84,16 @@ namespace AGVMapEditor.Forms
}
else
{
MessageBox.Show($"지정된 파일을 찾을 수 없습니다: {filePath}", "파일 오류",
MessageBox.Show($"지정된 파일을 찾을 수 없습니다: {filePath}", "파일 오류",
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)
@@ -529,27 +628,55 @@ namespace AGVMapEditor.Forms
private void LoadMapFromFile(string filePath)
{
var result = MapLoader.LoadMapFromFile(filePath);
if (result.Success)
{
_mapNodes = result.Nodes;
// 맵 캔버스에 데이터 설정
_mapCanvas.Nodes = _mapNodes;
// RfidMappings 제거됨 - MapNode에 통합
// 현재 파일 경로 업데이트
_currentMapFile = filePath;
_hasChanges = false;
// 설정에 마지막 맵 파일 경로 저장
EditorSettings.Instance.UpdateLastMapFile(filePath);
UpdateTitle();
UpdateNodeList();
RefreshNodeConnectionList();
// 맵 로드 후 자동으로 맵에 맞춤
_mapCanvas.FitToNodes();
UpdateStatusBar($"맵 파일을 성공적으로 로드했습니다: {Path.GetFileName(filePath)}");
}
else
{
MessageBox.Show($"맵 파일 로딩 실패: {result.ErrorMessage}", "오류",
MessageBox.Show($"맵 파일 로딩 실패: {result.ErrorMessage}", "오류",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private void SaveMapToFile(string filePath)
{
if (!MapLoader.SaveMapToFile(filePath, _mapNodes))
if (MapLoader.SaveMapToFile(filePath, _mapNodes))
{
MessageBox.Show("맵 파일 저장 실패", "오류",
// 현재 파일 경로 업데이트
_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,10 +1016,27 @@ 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();
// 현재 선택된 노드를 기억
var currentSelectedNode = _selectedNode;
@@ -775,12 +1054,107 @@ 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
#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);
}
}
}
}