- PathValidationResult 클래스를 Validation 폴더에 적절히 배치 - BacktrackingPattern 클래스로 A→B→A 패턴 상세 검출 - DirectionChangePlanner에서 되돌아가기 패턴 자동 검증 - CLAUDE.md에 AGVNavigationCore 프로젝트 구조 가이드 추가 - 빌드 시스템 오류 모두 해결 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
355 lines
13 KiB
C#
355 lines
13 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using Newtonsoft.Json;
|
|
|
|
namespace AGVNavigationCore.Models
|
|
{
|
|
/// <summary>
|
|
/// AGV 맵 파일 로딩/저장을 위한 공용 유틸리티 클래스
|
|
/// AGVMapEditor와 AGVSimulator에서 공통으로 사용
|
|
/// </summary>
|
|
public static class MapLoader
|
|
{
|
|
/// <summary>
|
|
/// 맵 파일 로딩 결과
|
|
/// </summary>
|
|
public class MapLoadResult
|
|
{
|
|
public bool Success { get; set; }
|
|
public List<MapNode> Nodes { get; set; } = new List<MapNode>();
|
|
public string ErrorMessage { get; set; } = string.Empty;
|
|
public string Version { get; set; } = string.Empty;
|
|
public DateTime CreatedDate { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 맵 파일 저장용 데이터 구조
|
|
/// </summary>
|
|
public class MapFileData
|
|
{
|
|
public List<MapNode> Nodes { get; set; } = new List<MapNode>();
|
|
public DateTime CreatedDate { get; set; }
|
|
public string Version { get; set; } = "1.0";
|
|
}
|
|
|
|
/// <summary>
|
|
/// 맵 파일을 로드하여 노드를 반환
|
|
/// </summary>
|
|
/// <param name="filePath">맵 파일 경로</param>
|
|
/// <returns>로딩 결과</returns>
|
|
public static MapLoadResult LoadMapFromFile(string filePath)
|
|
{
|
|
var result = new MapLoadResult();
|
|
|
|
try
|
|
{
|
|
if (!File.Exists(filePath))
|
|
{
|
|
result.ErrorMessage = $"파일을 찾을 수 없습니다: {filePath}";
|
|
return result;
|
|
}
|
|
|
|
var json = File.ReadAllText(filePath);
|
|
|
|
// JSON 역직렬화 설정: 누락된 속성 무시, 안전한 처리
|
|
var settings = new JsonSerializerSettings
|
|
{
|
|
MissingMemberHandling = MissingMemberHandling.Ignore,
|
|
NullValueHandling = NullValueHandling.Ignore,
|
|
DefaultValueHandling = DefaultValueHandling.Populate
|
|
};
|
|
|
|
var mapData = JsonConvert.DeserializeObject<MapFileData>(json, settings);
|
|
|
|
if (mapData != null)
|
|
{
|
|
result.Nodes = mapData.Nodes ?? new List<MapNode>();
|
|
result.Version = mapData.Version ?? "1.0";
|
|
result.CreatedDate = mapData.CreatedDate;
|
|
|
|
// 기존 Description 데이터를 Name으로 마이그레이션
|
|
MigrateDescriptionToName(result.Nodes);
|
|
|
|
// DockingDirection 마이그레이션 (기존 NodeType 기반으로 설정)
|
|
MigrateDockingDirection(result.Nodes);
|
|
|
|
// 중복된 NodeId 정리
|
|
FixDuplicateNodeIds(result.Nodes);
|
|
|
|
// 중복 연결 정리 (양방향 중복 제거)
|
|
CleanupDuplicateConnections(result.Nodes);
|
|
|
|
// 이미지 노드들의 이미지 로드
|
|
LoadImageNodes(result.Nodes);
|
|
|
|
result.Success = true;
|
|
}
|
|
else
|
|
{
|
|
result.ErrorMessage = "맵 데이터 파싱에 실패했습니다.";
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
result.ErrorMessage = $"맵 파일 로딩 중 오류 발생: {ex.Message}";
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 맵 데이터를 파일로 저장
|
|
/// </summary>
|
|
/// <param name="filePath">저장할 파일 경로</param>
|
|
/// <param name="nodes">맵 노드 목록</param>
|
|
/// <returns>저장 성공 여부</returns>
|
|
public static bool SaveMapToFile(string filePath, List<MapNode> nodes)
|
|
{
|
|
try
|
|
{
|
|
var mapData = new MapFileData
|
|
{
|
|
Nodes = nodes,
|
|
CreatedDate = DateTime.Now,
|
|
Version = "1.0"
|
|
};
|
|
|
|
var json = JsonConvert.SerializeObject(mapData, Formatting.Indented);
|
|
File.WriteAllText(filePath, json);
|
|
|
|
return true;
|
|
}
|
|
catch (Exception)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 이미지 노드들의 이미지 로드
|
|
/// </summary>
|
|
/// <param name="nodes">노드 목록</param>
|
|
private static void LoadImageNodes(List<MapNode> nodes)
|
|
{
|
|
foreach (var node in nodes)
|
|
{
|
|
if (node.Type == NodeType.Image)
|
|
{
|
|
node.LoadImage();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 기존 Description 데이터를 Name 필드로 마이그레이션
|
|
/// JSON 파일에서 Description 필드가 있는 경우 Name으로 이동
|
|
/// </summary>
|
|
/// <param name="mapNodes">맵 노드 목록</param>
|
|
private static void MigrateDescriptionToName(List<MapNode> mapNodes)
|
|
{
|
|
// JSON에서 Description이 있던 기존 파일들을 위한 마이그레이션
|
|
// 현재 MapNode 클래스에는 Description 속성이 제거되었으므로
|
|
// 이 메서드는 호환성을 위해 유지되지만 실제로는 작동하지 않음
|
|
// 기존 파일들은 다시 저장될 때 Description 없이 저장됨
|
|
}
|
|
|
|
/// <summary>
|
|
/// 기존 맵 파일의 DockingDirection을 NodeType 기반으로 마이그레이션
|
|
/// </summary>
|
|
/// <param name="mapNodes">맵 노드 목록</param>
|
|
private static void MigrateDockingDirection(List<MapNode> mapNodes)
|
|
{
|
|
if (mapNodes == null || mapNodes.Count == 0) return;
|
|
|
|
foreach (var node in mapNodes)
|
|
{
|
|
// 기존 파일에서 DockingDirection이 기본값(DontCare)인 경우에만 마이그레이션
|
|
if (node.DockDirection == DockingDirection.DontCare)
|
|
{
|
|
switch (node.Type)
|
|
{
|
|
case NodeType.Charging:
|
|
node.DockDirection = DockingDirection.Forward;
|
|
break;
|
|
case NodeType.Docking:
|
|
node.DockDirection = DockingDirection.Backward;
|
|
break;
|
|
default:
|
|
// Normal, Rotation, Label, Image는 DontCare 유지
|
|
node.DockDirection = DockingDirection.DontCare;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <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가 없는 경우에만 자동 할당
|
|
if (node.IsNavigationNode() && !node.HasRfid())
|
|
{
|
|
// 기본 RFID ID 생성 (N001 -> 001)
|
|
var rfidId = node.NodeId.Replace("N", "").PadLeft(3, '0');
|
|
|
|
node.SetRfidInfo(rfidId, "", "정상");
|
|
}
|
|
}
|
|
*/
|
|
}
|
|
|
|
}
|
|
} |