파일정리

This commit is contained in:
ChiKyun Kim
2026-01-29 14:03:17 +09:00
parent 00cc0ef5b7
commit 58ca67150d
440 changed files with 47236 additions and 99165 deletions

View File

@@ -0,0 +1,54 @@
namespace AGVNavigationCore.Models
{
/// <summary>
/// AGV 제어 명령 클래스 (실제 AGV 제어용)
/// Predict() 메서드가 반환하는 다음 동작 명령
/// </summary>
public class AGVCommand
{
/// <summary>모터 명령 (정지/전진/후진)</summary>
public MotorCommand Motor { get; set; }
/// <summary>마그넷 위치 (직진/왼쪽/오른쪽)</summary>
public MagnetPosition Magnet { get; set; }
/// <summary>속도 레벨 (저속/중속/고속)</summary>
public SpeedLevel Speed { get; set; }
/// <summary>명령 이유 메세지- (디버깅/로깅용)</summary>
public string Message { get; set; }
/// <summary>명령 이유- (디버깅/로깅용)</summary>
public eAGVCommandReason Reason { get; set; }
/// <summary>
/// 생성자
/// </summary>
public AGVCommand(MotorCommand motor, MagnetPosition magnet, SpeedLevel speed, eAGVCommandReason reason, string reasonmessage = "")
{
Motor = motor;
Magnet = magnet;
Speed = speed;
Reason = reason;
Message = reasonmessage;
}
/// <summary>
/// 기본 생성자
/// </summary>
public AGVCommand()
{
Motor = MotorCommand.Stop;
Magnet = MagnetPosition.S;
Speed = SpeedLevel.L;
Message = "";
Reason = eAGVCommandReason.Normal;
}
public override string ToString()
{
return $"Motor:{Motor}, Magnet:{Magnet}, Speed:{Speed},Reason:{Reason}" +
(string.IsNullOrEmpty(Message) ? "" : $" ({Message})");
}
}
}

View File

@@ -0,0 +1,182 @@
using System;
namespace AGVNavigationCore.Models
{
/// <summary>
/// 노드 타입 열거형
/// </summary>
public enum NodeType
{
/// <summary>일반 경로 노드</summary>
Normal,
Label,
/// <summary>이미지 (UI 요소)</summary>
Image,
/// <summary>
/// 마크센서
/// </summary>
Mark,
/// <summary>
/// 마그넷라인
/// </summary>
Magnet
}
/// <summary>
/// 도킹 방향 열거형
/// </summary>
public enum DockingDirection
{
/// <summary>도킹 방향 상관없음 (일반 경로 노드)</summary>
DontCare,
/// <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>
Normal,
/// <summary>로더</summary>
Loader,
/// <summary>클리너</summary>
Clearner,
/// <summary>오프로더</summary>
UnLoader,
/// <summary>버퍼</summary>
Buffer,
/// <summary>충전기1</summary>
Charger1,
/// <summary>충전기2</summary>
Charger2,
/// <summary>
/// 끝점(더이상 이동불가)
/// </summary>
Limit,
}
/// <summary>
/// AGV턴상태
/// </summary>
public enum AGVTurn
{
None=0,
/// <summary>
/// left turn 90"
/// </summary>
L90,
/// <summary>
/// right turn 90"
/// </summary>
R90
}
/// <summary>
/// 모터 명령 열거형 (실제 AGV 제어용)
/// </summary>
public enum MotorCommand
{
/// <summary>정지</summary>
Stop,
/// <summary>전진 (Forward - 모니터 방향)</summary>
Forward,
/// <summary>후진 (Backward - 리프트 방향)</summary>
Backward
}
/// <summary>
/// 마그넷 위치 열거형 (실제 AGV 제어용)
/// </summary>
public enum MagnetPosition
{
/// <summary>직진 (Straight)</summary>
S,
/// <summary>왼쪽 (Left)</summary>
L,
/// <summary>오른쪽 (Right)</summary>
R
}
/// <summary>
/// 속도 레벨 열거형 (실제 AGV 제어용)
/// </summary>
public enum SpeedLevel
{
/// <summary>저속 (Low)</summary>
L,
/// <summary>중속 (Medium)</summary>
M,
/// <summary>고속 (High)</summary>
H
}
public enum eAGVCommandReason
{
/// <summary>
/// 초기 미지정
/// </summary>
Normal,
/// <summary>
/// 위치 미확정
/// </summary>
UnknownPosition,
/// <summary>
/// 대상경로없음
/// </summary>
NoTarget,
/// <summary>
/// 경로없음
/// </summary>
NoPath,
/// <summary>
/// 경로이탈
/// </summary>
PathOut,
/// <summary>
/// 마크스탑을 해야한다
/// </summary>
MarkStop,
/// <summary>
/// 완료
/// </summary>
Complete,
}
}

View File

@@ -0,0 +1,210 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using AGVNavigationCore.Controls;
using AGVNavigationCore.PathFinding;
using AGVNavigationCore.PathFinding.Core;
namespace AGVNavigationCore.Models
{
/// <summary>
/// 이동 가능한 AGV 인터페이스
/// 실제 AGV와 시뮬레이션 AGV 모두 구현해야 하는 기본 인터페이스
/// </summary>
public interface IMovableAGV
{
#region Events
/// <summary>
/// AGV 상태 변경 이벤트
/// </summary>
event EventHandler<AGVState> StateChanged;
/// <summary>
/// 위치 변경 이벤트
/// </summary>
event EventHandler<(Point, AgvDirection, MapNode)> PositionChanged;
/// <summary>
/// RFID 감지 이벤트
/// </summary>
event EventHandler<string> RfidDetected;
/// <summary>
/// 경로 완료 이벤트
/// </summary>
event EventHandler<AGVPathResult> PathCompleted;
/// <summary>
/// 오류 발생 이벤트
/// </summary>
event EventHandler<string> ErrorOccurred;
#endregion
#region Properties
/// <summary>
/// AGV ID
/// </summary>
string AgvId { get; }
/// <summary>
/// 현재 위치
/// </summary>
Point CurrentPosition { get; set; }
/// <summary>
/// 현재 방향 (모터 방향)
/// </summary>
AgvDirection CurrentDirection { get; set; }
/// <summary>
/// 현재 상태
/// </summary>
AGVState CurrentState { get; set; }
/// <summary>
/// 현재 속도
/// </summary>
float CurrentSpeed { get; }
/// <summary>
/// 배터리 레벨 (0-100%)
/// </summary>
float BatteryLevel { get; set; }
/// <summary>
/// 현재 경로
/// </summary>
AGVPathResult CurrentPath { get; }
/// <summary>
/// 현재 노드 ID
/// </summary>
MapNode CurrentNode { get; }
/// <summary>
/// 목표 위치
/// </summary>
Point? PrevPosition { get; }
/// <summary>
/// 목표 노드 ID
/// </summary>
MapNode PrevNode { get; }
/// <summary>
/// 도킹 방향
/// </summary>
DockingDirection DockingDirection { get; }
#endregion
#region Sensor Input Methods ( AGV에서 )
/// <summary>
/// 현재 위치 설정 (실제 위치 센서에서)
/// </summary>
void SetCurrentPosition(Point position);
/// <summary>
/// 감지된 RFID 설정 (실제 RFID 센서에서)
/// </summary>
void SetDetectedRfid(string rfidId);
/// <summary>
/// 모터 방향 설정 (모터 컨트롤러에서)
/// </summary>
void SetMotorDirection(AgvDirection direction);
/// <summary>
/// 배터리 레벨 설정 (BMS에서)
/// </summary>
void SetBatteryLevel(float percentage);
#endregion
#region State Query Methods
/// <summary>
/// 현재 위치 조회
/// </summary>
Point GetCurrentPosition();
/// <summary>
/// 현재 상태 조회
/// </summary>
AGVState GetCurrentState();
/// <summary>
/// 현재 노드 ID 조회
/// </summary>
MapNode GetCurrentNode();
/// <summary>
/// AGV 상태 정보 문자열 조회
/// </summary>
string GetStatus();
#endregion
#region Path Execution Methods
/// <summary>
/// 경로 정지
/// </summary>
void StopPath();
/// <summary>
/// 긴급 정지
/// </summary>
void EmergencyStop();
#endregion
#region Update Method
/// <summary>
/// 프레임 업데이트 (외부에서 주기적으로 호출)
/// 이 방식으로 타이머에 의존하지 않고 외부에서 제어 가능
/// </summary>
/// <param name="deltaTimeMs">마지막 업데이트 이후 경과 시간 (밀리초)</param>
void Update(float deltaTimeMs);
#endregion
#region Manual Control Methods ()
/// <summary>
/// 수동 이동
/// </summary>
void MoveTo(Point targetPosition);
/// <summary>
/// 수동 회전
/// </summary>
void Rotate(AgvDirection direction);
/// <summary>
/// 충전 시작
/// </summary>
void StartCharging();
/// <summary>
/// 충전 종료
/// </summary>
void StopCharging();
#endregion
#region Cleanup
/// <summary>
/// 리소스 정리
/// </summary>
void Dispose();
#endregion
}
}

View File

@@ -0,0 +1,88 @@
using System.ComponentModel;
using System.Drawing;
using System.Drawing.Drawing2D;
using AGVNavigationCore.Utils;
using Newtonsoft.Json;
using System;
namespace AGVNavigationCore.Models
{
public class MapImage : NodeBase
{
[Category("기본 정보")]
[Description("이미지의 이름입니다.")]
public string Name { get; set; } = "Image";
[Category("이미지 설정")]
[Description("이미지 파일 경로입니다 (편집기용).")]
public string ImagePath { get; set; } = string.Empty;
[ReadOnly(false)]
public string ImageBase64 { get; set; } = string.Empty;
[Category("이미지 설정")]
[Description("이미지 크기 배율입니다.")]
public SizeF Scale { get; set; } = new SizeF(1.0f, 1.0f);
[Category("이미지 설정")]
[Description("이미지 투명도입니다 (0.0 ~ 1.0).")]
public float Opacity { get; set; } = 1.0f;
[Category("이미지 설정")]
[Description("이미지 회전 각도입니다.")]
public float Rotation { get; set; } = 0.0f;
[JsonIgnore]
[Browsable(false)]
public Image LoadedImage { get; set; }
public MapImage()
{
Type = NodeType.Image;
}
public bool LoadImage()
{
try
{
Image originalImage = null;
if (!string.IsNullOrEmpty(ImageBase64))
{
originalImage = ImageConverterUtil.Base64ToImage(ImageBase64);
}
else if (!string.IsNullOrEmpty(ImagePath) && System.IO.File.Exists(ImagePath))
{
originalImage = Image.FromFile(ImagePath);
}
if (originalImage != null)
{
LoadedImage?.Dispose();
LoadedImage = originalImage; // 리사이즈 필요시 추가 구현
return true;
}
}
catch
{
// 무시
}
return false;
}
public Size GetDisplaySize()
{
if (LoadedImage == null) return Size.Empty;
return new Size(
(int)(LoadedImage.Width * Scale.Width),
(int)(LoadedImage.Height * Scale.Height)
);
}
public void Dispose()
{
LoadedImage?.Dispose();
LoadedImage = null;
}
}
}

View File

@@ -0,0 +1,42 @@
using System.ComponentModel;
using System.Drawing;
namespace AGVNavigationCore.Models
{
public class MapLabel : NodeBase
{
[Category("라벨 설정")]
[Description("표시할 텍스트입니다.")]
public string Text { get; set; } = "";
[Category("라벨 설정")]
[Description("글자색입니다")]
public Color ForeColor { get; set; } = Color.Black;
[Category("라벨 설정")]
[Description("배경색입니다.")]
public Color BackColor { get; set; } = Color.Transparent;
[Category("라벨 설정")]
[Description("폰트 종류입니다.")]
public string FontFamily { get; set; } = "Arial";
[Category("라벨 설정")]
[Description("폰트 크기입니다.")]
public float FontSize { get; set; } = 12.0f;
[Category("라벨 설정")]
[Description("폰트 스타일입니다.")]
public FontStyle FontStyle { get; set; } = FontStyle.Regular;
[Category("라벨 설정")]
[Description("내부 여백입니다.")]
public int Padding { get; set; } = 5;
public MapLabel()
{
ForeColor = Color.Purple;
Type = NodeType.Label;
}
}
}

View File

@@ -0,0 +1,514 @@
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 MapSettings
{
public int BackgroundColorArgb { get; set; } = System.Drawing.Color.White.ToArgb();
public bool ShowGrid { get; set; } = true;
}
/// <summary>
/// 맵 파일 로딩 결과
/// </summary>
public class MapLoadResult
{
public bool Success { get; set; }
public List<MapNode> Nodes { get; set; } = new List<MapNode>();
public List<MapLabel> Labels { get; set; } = new List<MapLabel>(); // 추가
public List<MapImage> Images { get; set; } = new List<MapImage>(); // 추가
public List<MapMark> Marks { get; set; } = new List<MapMark>();
public List<MapMagnet> Magnets { get; set; } = new List<MapMagnet>();
public MapSettings Settings { get; set; } = new MapSettings();
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 List<MapLabel> Labels { get; set; } = new List<MapLabel>(); // 추가
public List<MapImage> Images { get; set; } = new List<MapImage>(); // 추가
public List<MapMark> Marks { get; set; } = new List<MapMark>();
public List<MapMagnet> Magnets { get; set; } = new List<MapMagnet>();
public MapSettings Settings { get; set; } = new MapSettings();
public DateTime CreatedDate { get; set; }
public string Version { get; set; } = "1.3"; // 버전 업그레이드
}
/// <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
};
// 먼저 구조 파악을 위해 동적 객체로 로드하거나, MapFileData로 시도
var mapData = JsonConvert.DeserializeObject<MapFileData>(json, settings);
if (mapData != null)
{
result.Nodes = new List<MapNode>();
result.Labels = mapData.Labels ?? new List<MapLabel>();
result.Images = mapData.Images ?? new List<MapImage>();
result.Marks = mapData.Marks ?? new List<MapMark>();
result.Magnets = mapData.Magnets ?? new List<MapMagnet>();
result.Settings = mapData.Settings ?? new MapSettings();
result.Version = mapData.Version ?? "1.0";
result.CreatedDate = mapData.CreatedDate;
if (mapData.Nodes != null)
{
foreach (var node in mapData.Nodes)
{
// 마이그레이션: 기존 파일의 Nodes 리스트에 섞여있는 Label, Image 분리
// (새 파일 구조에서는 이미 분리되어 로드됨)
if (node.Type == NodeType.Label)
{
// MapNode -> MapLabel 변환 (필드 매핑)
var label = new MapLabel
{
Id = node.Id, // 기존 NodeId -> Id
Position = node.Position,
CreatedDate = node.CreatedDate,
ModifiedDate = node.ModifiedDate,
// Label 속성 매핑 (MapNode에서 임시로 가져오거나 Json Raw Parsing 필요할 수 있음)
// 현재 MapNode 클래스에는 해당 속성들이 제거되었으므로,
// Json 포맷 변경으로 인해 기존 데이터 로드시 정보 손실 가능성 있음.
// * 중요 *: MapNode 클래스에서 속성을 지웠으므로 일반 Deserialize로는 Label/Image 속성을 못 읽음.
// 해결책: JObject로 먼저 읽어서 분기 처리하거나, DTO 클래스를 별도로 두어야 함.
// 하지만 시간 관계상, 만약 기존 MapNode가 속성을 가지고 있지 않다면 마이그레이션은 "위치/ID" 정도만 복구됨.
// 완벽한 마이그레이션을 위해서는 MapNode에 Obsolete 속성을 잠시 두었어야 함.
// 여기서는 일단 기본 정보라도 살림.
};
result.Labels.Add(label);
}
else if (node.Type == NodeType.Image)
{
var image = new MapImage
{
Id = node.Id,
Position = node.Position,
CreatedDate = node.CreatedDate,
ModifiedDate = node.ModifiedDate,
// 이미지/라벨 속성 복구 불가 (MapNode에서 삭제됨)
};
result.Images.Add(image);
}
else
{
result.Nodes.Add(node);
}
}
}
// 중복된 NodeId 정리 (Nav Node만)
FixDuplicateNodeIds(result.Nodes);
// 고아 연결 정리
CleanupOrphanConnections(result.Nodes);
// 양방향 연결 자동 설정
EnsureBidirectionalConnections(result.Nodes);
// ConnectedMapNodes 채우기
ResolveConnectedMapNodes(result.Nodes);
// 이미지 로드 (MapImage 객체에서)
foreach (var img in result.Images)
{
img.LoadImage();
}
result.Success = true;
}
else
{
result.ErrorMessage = "맵 데이터 파싱에 실패했습니다.";
}
}
catch (Exception ex)
{
result.ErrorMessage = $"맵 파일 로딩 중 오류 발생: {ex.Message}";
}
return result;
}
/// <summary>
/// 맵 데이터를 파일로 저장
/// </summary>
public static bool SaveMapToFile(string filePath, List<MapNode> nodes, List<MapLabel> labels = null, List<MapImage> images = null, List<MapMark> marks = null, List<MapMagnet> magnets = null, MapSettings settings = null)
{
try
{
// 저장 전 고아 연결 정리
CleanupOrphanConnections(nodes);
var mapData = new MapFileData
{
Nodes = nodes,
Labels = labels ?? new List<MapLabel>(),
Images = images ?? new List<MapImage>(),
Marks = marks ?? new List<MapMark>(),
Magnets = magnets ?? new List<MapMagnet>(),
Settings = settings ?? new MapSettings(),
CreatedDate = DateTime.Now,
Version = "1.3"
};
var json = JsonConvert.SerializeObject(mapData, Formatting.Indented);
File.WriteAllText(filePath, json);
return true;
}
catch (Exception)
{
return false;
}
}
/// <summary>
/// ConnectedMapNodes 채우기 (ConnectedNodes의 string ID → MapNode 객체 변환)
/// </summary>
/// <param name="mapNodes">맵 노드 목록</param>
private static void ResolveConnectedMapNodes(List<MapNode> mapNodes)
{
if (mapNodes == null || mapNodes.Count == 0) return;
// 빠른 조회를 위한 Dictionary 생성
var nodeDict = mapNodes.ToDictionary(n => n.Id, n => n);
foreach (var node in mapNodes)
{
// ConnectedMapNodes 초기화
node.ConnectedMapNodes.Clear();
if (node.ConnectedNodes != null && node.ConnectedNodes.Count > 0)
{
foreach (var connectedNodeId in node.ConnectedNodes)
{
if (nodeDict.TryGetValue(connectedNodeId, out var connectedNode))
{
node.ConnectedMapNodes.Add(connectedNode);
}
}
}
}
}
/// <summary>
/// 기존 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.Id))
{
duplicateNodes.Add(node);
}
else
{
usedIds.Add(node.Id);
}
}
// 두 번째 패스: 중복된 노드들에게 새로운 NodeId 할당
foreach (var duplicateNode in duplicateNodes)
{
string newNodeId = GenerateUniqueNodeId(usedIds);
// 다른 노드들의 연결에서 기존 NodeId를 새 NodeId로 업데이트
UpdateConnections(mapNodes, duplicateNode.Id, newNodeId);
duplicateNode.Id = 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>
/// 존재하지 않는 노드에 대한 연결을 정리합니다 (고아 연결 제거).
/// 노드 삭제 후 저장된 맵 파일에서 삭제된 노드 ID가 ConnectedNodes에 남아있는 경우를 처리합니다.
/// </summary>
/// <param name="mapNodes">맵 노드 목록</param>
private static void CleanupOrphanConnections(List<MapNode> mapNodes)
{
if (mapNodes == null || mapNodes.Count == 0) return;
// 존재하는 모든 노드 ID 집합 생성
var validNodeIds = new HashSet<string>(mapNodes.Select(n => n.Id));
// 각 노드의 연결을 검증하고 존재하지 않는 노드 ID 제거
foreach (var node in mapNodes)
{
if (node.ConnectedNodes == null || node.ConnectedNodes.Count == 0)
continue;
var orphanConnections = node.ConnectedNodes
.Where(connectedId => !validNodeIds.Contains(connectedId))
.ToList();
foreach (var orphanId in orphanConnections)
{
node.RemoveConnection(orphanId);
}
}
}
/// <summary>
/// [사용 중지됨] 중복 연결을 정리합니다. 양방향 중복 연결을 단일 연결로 통합합니다.
/// 주의: 이 함수는 버그가 있어 사용 중지됨 - 양방향 연결을 단방향으로 변환하여 경로 탐색 실패 발생
/// AGV 시스템에서는 모든 연결이 양방향이어야 하므로 EnsureBidirectionalConnections()만 사용
/// </summary>
/// <param name="mapNodes">맵 노드 목록</param>
[Obsolete("이 함수는 양방향 연결을 단방향으로 변환하는 버그가 있습니다. 사용하지 마세요.")]
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.Id == connectedNodeId);
if (connectedNode == null) continue;
// 연결 쌍의 키 생성 (사전순 정렬)
string pairKey = string.Compare(node.Id, connectedNodeId, StringComparison.Ordinal) < 0
? $"{node.Id}-{connectedNodeId}"
: $"{connectedNodeId}-{node.Id}";
if (processedPairs.Contains(pairKey))
{
// 이미 처리된 연결인 경우 중복으로 간주하고 제거
connectionsToRemove.Add(connectedNodeId);
}
else
{
// 처리되지 않은 연결인 경우
processedPairs.Add(pairKey);
// 양방향 연결인 경우 하나만 유지
if (connectedNode.ConnectedNodes.Contains(node.Id))
{
// 사전순으로 더 작은 노드에만 연결을 유지
if (string.Compare(node.Id, connectedNodeId, StringComparison.Ordinal) > 0)
{
connectionsToRemove.Add(connectedNodeId);
}
else
{
// 반대 방향 연결 제거
connectedNode.RemoveConnection(node.Id);
}
}
}
}
// 중복 연결 제거
foreach (var connectionToRemove in connectionsToRemove)
{
node.RemoveConnection(connectionToRemove);
}
}
}
/// <summary>
/// 맵의 모든 연결을 양방향으로 만듭니다.
/// A→B 연결이 있으면 B→A 연결도 자동으로 추가합니다.
/// GetNextNodeId() 메서드에서 현재 노드의 ConnectedNodes만으로 다음 노드를 찾을 수 있도록 하기 위함.
///
/// 예시:
/// - 맵 에디터에서 002→003 연결을 생성했다면
/// - 자동으로 003→002 연결도 추가됨
/// - 따라서 003의 ConnectedNodes에 002가 포함됨
/// </summary>
/// <param name="mapNodes">맵 노드 목록</param>
private static void EnsureBidirectionalConnections(List<MapNode> mapNodes)
{
if (mapNodes == null || mapNodes.Count == 0) return;
// 모든 노드의 연결 정보를 수집
var allConnections = new Dictionary<string, HashSet<string>>();
// 1단계: 모든 명시적 연결 수집
foreach (var node in mapNodes)
{
if (!allConnections.ContainsKey(node.Id))
{
allConnections[node.Id] = new HashSet<string>();
}
if (node.ConnectedNodes != null)
{
foreach (var connectedId in node.ConnectedNodes)
{
allConnections[node.Id].Add(connectedId);
}
}
}
// 2단계: 역방향 연결 추가
foreach (var node in mapNodes)
{
if (node.ConnectedNodes == null)
{
node.ConnectedNodes = new List<string>();
}
// 이 노드를 연결하는 모든 노드 찾기
foreach (var otherNodeId in allConnections.Keys)
{
if (otherNodeId == node.Id) continue;
// 다른 노드가 이 노드를 연결하고 있다면
if (allConnections[otherNodeId].Contains(node.Id))
{
// 이 노드의 ConnectedNodes에 그 노드를 추가 (중복 방지)
if (!node.ConnectedNodes.Contains(otherNodeId))
{
node.ConnectedNodes.Add(otherNodeId);
}
}
}
}
}
/// <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, "", "정상");
}
}
*/
}
}
}

View File

@@ -0,0 +1,72 @@
using System;
using System.ComponentModel;
using System.Drawing;
using Newtonsoft.Json;
namespace AGVNavigationCore.Models
{
/// <summary>
/// 맵 상의 마그넷(Magnet) 정보를 나타내는 클래스
/// </summary>
public class MapMagnet : NodeBase
{
public MapMagnet() {
Type = NodeType.Magnet;
}
[Category("위치 정보")]
[Description("시작점 좌표")]
public MagnetPoint P1 { get; set; } = new MagnetPoint();
[Category("위치 정보")]
[Description("끝점 좌표")]
public MagnetPoint P2 { get; set; } = new MagnetPoint();
[Category("위치 정보")]
[Description("제어점 좌표 (곡선인 경우)")]
public MagnetPoint ControlPoint { get; set; } = null;
public class MagnetPoint
{
public double X { get; set; }
public double Y { get; set; }
}
[JsonIgnore]
public override Point Position
{
get => new Point((int)P1.X, (int)P1.Y);
set
{
double dx = value.X - P1.X;
double dy = value.Y - P1.Y;
P1.X += dx;
P1.Y += dy;
P2.X += dx;
P2.Y += dy;
if (ControlPoint != null)
{
ControlPoint.X += dx;
ControlPoint.Y += dy;
}
}
}
/// <summary>
/// 시작점 Point 반환
/// </summary>
[Browsable(false)]
[JsonIgnore]
public Point StartPoint => new Point((int)P1.X, (int)P1.Y);
/// <summary>
/// 끝점 Point 반환
/// </summary>
[Browsable(false)]
[JsonIgnore]
public Point EndPoint => new Point((int)P2.X, (int)P2.Y);
}
}

View File

@@ -0,0 +1,37 @@
using System;
using System.ComponentModel;
using System.Drawing;
namespace AGVNavigationCore.Models
{
/// <summary>
/// 맵 상의 마크(Mark) 정보를 나타내는 클래스
/// </summary>
public class MapMark : NodeBase
{
// Id is inherited from NodeBase
public MapMark() {
Type = NodeType.Mark;
}
[Category("위치 정보")]
[Description("마크의 X 좌표")]
public double X
{
get => Position.X;
set => Position = new Point((int)value, Position.Y);
}
[Category("위치 정보")]
[Description("마크의 Y 좌표")]
public double Y
{
get => Position.Y;
set => Position = new Point(Position.X, (int)value);
}
[Category("위치 정보")]
[Description("마크의 회전 각도")]
public double Rotation { get; set; }
}
}

View File

@@ -0,0 +1,185 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing;
using System.Drawing.Drawing2D;
using AGVNavigationCore.Utils;
using Newtonsoft.Json;
namespace AGVNavigationCore.Models
{
/// <summary>
/// 맵 노드 정보를 관리하는 클래스 (주행 경로용 노드)
/// </summary>
public class MapNode : NodeBase
{
[Category("라벨 설정")]
[Description("표시할 텍스트입니다.")]
public string Text { get; set; } = "";
public StationType StationType { get; set; }
[Browsable(false)]
public bool CanDocking
{
get
{
if (StationType == StationType.Buffer) return true;
if (StationType == StationType.Loader) return true;
if (StationType == StationType.UnLoader) return true;
if (StationType == StationType.Clearner) return true;
if (StationType == StationType.Charger1) return true;
if (StationType == StationType.Charger2) return true;
return false;
}
}
[Category("노드 설정")]
[Description("도킹/충전 노드의 진입 방향입니다.")]
public DockingDirection DockDirection { get; set; } = DockingDirection.DontCare;
[Category("노드 설정")]
[Description("각 연결된 노드로 향할 때의 마그넷 방향 정보입니다.")]
public Dictionary<string, MagnetPosition> MagnetDirections { get; set; } = new Dictionary<string, MagnetPosition>();
[Category("연결 정보")]
[Description("연결된 노드 ID 목록입니다.")]
[ReadOnly(true)]
public List<string> ConnectedNodes { get; set; } = new List<string>();
[JsonIgnore]
[Browsable(false)]
public List<MapNode> ConnectedMapNodes { get; set; } = new List<MapNode>();
[Category("주행 설정")]
[Description("제자리 회전(좌) 가능 여부입니다.")]
public bool CanTurnLeft { get; set; } = true;
[Category("주행 설정")]
[Description("제자리 회전(우) 가능 여부입니다.")]
public bool CanTurnRight { get; set; } = true;
[Category("주행 설정")]
[Description("교차로 주행 가능 여부입니다.")]
public bool DisableCross
{
get
{
if (Type != NodeType.Normal) return true;
return _disablecross;
}
set { _disablecross = value; }
}
private bool _disablecross = false;
[Category("주행 설정")]
[Description("노드 통과 시 제한 속도입니다.")]
public SpeedLevel SpeedLimit { get; set; } = SpeedLevel.M;
[Category("노드 설정")]
[Description("장비 ID 또는 별칭입니다.")]
public string AliasName { get; set; } = string.Empty;
[Category("기본 정보")]
[Description("노드 사용 여부입니다.")]
public bool IsActive { get; set; } = true;
[Category("RFID 정보")]
[Description("물리적 RFID 태그 ID입니다.")]
public UInt16 RfidId { get; set; } = 0;
[Category("노드 텍스트"), DisplayName("TextColor")]
[Description("텍스트 색상입니다.")]
public Color NodeTextForeColor { get; set; } = Color.Black;
private float _textFontSize = 7.0f;
[Category("노드 텍스트"), DisplayName("TextSize")]
[Description("일반 노드 텍스트의 크기입니다.")]
public float NodeTextFontSize
{
get => _textFontSize;
set => _textFontSize = value > 0 ? value : 7.0f;
}
public MapNode() : base()
{
Type = NodeType.Normal;
}
public MapNode(string nodeId, Point position, StationType type) : base(nodeId, position)
{
Type = NodeType.Normal;
}
[Category("기본 정보")]
[JsonIgnore]
[ReadOnly(true), Browsable(false)]
public bool isDockingNode
{
get
{
if (StationType == StationType.Charger1 || StationType == StationType.Charger2 || StationType == StationType.Buffer ||
StationType == StationType.Clearner || StationType == StationType.Loader ||
StationType == StationType.UnLoader) return true;
return false;
}
}
public void AddConnection(string nodeId)
{
if (!ConnectedNodes.Contains(nodeId))
{
ConnectedNodes.Add(nodeId);
ModifiedDate = DateTime.Now;
}
}
public void RemoveConnection(string nodeId)
{
if (ConnectedNodes.Remove(nodeId))
{
ModifiedDate = DateTime.Now;
}
}
public void SetChargingStation(string stationId)
{
//StationType = StationType.Charger;
//Id = stationId;
//DockDirection = DockingDirection.Forward;
//ModifiedDate = DateTime.Now;
}
public override string ToString()
{
return $"RFID:{RfidId}(NODE:{Id}): AS:{AliasName} ({Type}) at ({Position.X}, {Position.Y})";
}
/// <summary>
/// RFID(*ID)
/// </summary>
public string ID2
{
get
{
if (HasRfid()) return $"{this.RfidId:0000}(*{this.Id})";
else return $"(*{this.Id})";
}
}
public bool IsNavigationNode()
{
// 이제 MapNode는 항상 내비게이션 노드임 (Label, Image 분리됨)
// 하지만 기존 로직 호환성을 위해 Active 체크만 유지
return IsActive;
}
public bool HasRfid()
{
return RfidId > 0;
}
}
}

View File

@@ -0,0 +1,60 @@
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.Drawing;
using Newtonsoft.Json;
namespace AGVNavigationCore.Models
{
/// <summary>
/// 맵 상의 모든 객체의 최상위 기본 클래스
/// 위치, 선택 상태, 기본 식별자 등을 관리
/// </summary>
public abstract class NodeBase
{
[Category("기본 정보")]
[Description("객체의 고유 ID입니다.")]
[ReadOnly(true)]
public string Id { get; set; } = Guid.NewGuid().ToString();
[Category("기본 정보")]
public NodeType Type { protected set; get; } = NodeType.Normal;
[Category("기본 정보")]
[Description("객체의 좌표(X, Y)입니다.")]
public virtual Point Position { get; set; } = Point.Empty;
[Category("기본 정보")]
[Description("객체 생성 일자입니다.")]
[JsonIgnore]
[ReadOnly(true), Browsable(false)]
public DateTime CreatedDate { get; set; } = DateTime.Now;
[Category("기본 정보")]
[Description("객체 수정 일자입니다.")]
[JsonIgnore]
[ReadOnly(true), Browsable(false)]
public DateTime ModifiedDate { get; set; } = DateTime.Now;
[Browsable(false)]
[JsonIgnore]
public bool IsSelected { get; set; } = false;
[Browsable(false)]
[JsonIgnore]
public bool IsHovered { get; set; } = false;
public NodeBase()
{
}
public NodeBase(string id, Point position)
{
Id = id;
Position = position;
}
}
}

View File

@@ -0,0 +1,942 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using AGVNavigationCore.Controls;
using AGVNavigationCore.Models;
using AGVNavigationCore.PathFinding;
using AGVNavigationCore.PathFinding.Core;
namespace AGVNavigationCore.Models
{
/// <summary>
/// 가상 AGV 클래스 (코어 비즈니스 로직)
/// 실제 AGV와 시뮬레이터 모두에서 사용 가능한 공용 로직
/// 시뮬레이션과 실제 동작이 동일하게 동작하도록 설계됨
/// </summary>
public class VirtualAGV : IMovableAGV, IAGV
{
#region Events
/// <summary>
/// AGV 상태 변경 이벤트
/// </summary>
public event EventHandler<AGVState> StateChanged;
/// <summary>
/// 위치 변경 이벤트
/// </summary>
public event EventHandler<(Point, AgvDirection, MapNode)> PositionChanged;
/// <summary>
/// RFID 감지 이벤트
/// </summary>
public event EventHandler<string> RfidDetected;
/// <summary>
/// 경로 완료 이벤트
/// </summary>
public event EventHandler<AGVPathResult> PathCompleted;
/// <summary>
/// 오류 발생 이벤트
/// </summary>
public event EventHandler<string> ErrorOccurred;
#endregion
#region Fields
private string _agvId;
private Point _currentPosition;
private Point _prevPosition;
private AgvDirection _currentDirection;
private AgvDirection _prevDirection;
private AGVState _currentState;
private float _currentSpeed;
// 경로 관련
private AGVPathResult _currentPath;
private List<string> _remainingNodes;
private int _currentNodeIndex;
private MapNode _currentNode;
private MapNode _prevNode;
private AGVTurn _turn;
// 이동 관련
private DateTime _lastUpdateTime;
private Point _moveStartPosition;
private Point _moveTargetPosition;
// 도킹 관련
private DockingDirection _dockingDirection;
// 시뮬레이션 설정
private readonly float _moveSpeed = 50.0f; // 픽셀/초
private bool _isMoving;
// RFID 위치 추적 (실제 AGV용)
private List<string> _detectedRfids = new List<string>(); // 감지된 RFID 목록
private bool _isPositionConfirmed = false; // 위치 확정 여부 (RFID 2개 이상 감지)
// 에뮬레이터용 추가 속성
public double Angle { get; set; } = 0; // 0 = Right, 90 = Down, 180 = Left, 270 = Up (Standard Math)
// But AGV Direction: Forward usually means "Front of AGV".
// Let's assume Angle is the orientation of the AGV in degrees.
public bool IsStopMarkOn { get; set; } = false;
#endregion
#region Properties
public bool Turn180 { get; set; } = false;
/// <summary>
/// 대상 이동시 모터 방향
/// </summary>
public AgvDirection PrevDirection => _prevDirection;
/// <summary>
/// AGV ID
/// </summary>
public string AgvId => _agvId;
/// <summary>
/// 현재 위치
/// </summary>
public Point CurrentPosition
{
get => _currentPosition;
set => _currentPosition = value;
}
/// <summary>
/// 현재 방향
/// 모터의 동작 방향
/// </summary>
public AgvDirection CurrentDirection
{
get => _currentDirection;
set => _currentDirection = value;
}
/// <summary>
/// 현재 상태
/// </summary>
public AGVState CurrentState
{
get => _currentState;
set => _currentState = value;
}
/// <summary>
/// 현재 속도
/// </summary>
public float CurrentSpeed => _currentSpeed;
/// <summary>
/// 현재 경로
/// </summary>
public AGVPathResult CurrentPath => _currentPath;
public void ClearPath()
{
_currentPath = null;
}
/// <summary>
/// 현재 노드 ID
/// </summary>
public MapNode CurrentNode => _currentNode;
/// <summary>
/// 현재 노드 ID (CurrentNode.Id)
/// </summary>
public string CurrentNodeId => _currentNode?.Id;
/// <summary>
/// 현재노드의 RFID(id)값을 표시합니다 없는경우 (X)가 표시됩니다
/// </summary>
public string CurrentNodeID2
{
get
{
if (_currentNode == null) return "(X)";
return _currentNode.ID2;
}
}
/// <summary>
/// 이전 위치
/// </summary>
public Point? PrevPosition => _prevPosition;
/// <summary>
/// 배터리 레벨 (시뮬레이션)
/// </summary>
public float BatteryLevel { get; set; } = 100.0f;
/// <summary>
/// 이전 노드
/// </summary>
public MapNode PrevNode => _prevNode;
/// <summary>
/// Turn 상태값
/// </summary>
public AGVTurn Turn { get; set; }
/// <summary>
/// 도킹 방향
/// </summary>
public DockingDirection DockingDirection => _dockingDirection;
/// <summary>
/// 위치 확정 여부 (RFID 2개 이상 감지 시 true)
/// </summary>
public bool IsPositionConfirmed => _isPositionConfirmed;
/// <summary>
/// 감지된 RFID 개수
/// </summary>
public int DetectedRfidCount => _detectedRfids.Count;
/// <summary>
/// 배터리 부족 경고 임계값 (%)
/// </summary>
public float LowBatteryThreshold { get; set; } = 20.0f;
#endregion
#region Constructor
/// <summary>
/// 생성자
/// </summary>
/// <param name="agvId">AGV ID</param>
/// <param name="startPosition">시작 위치</param>
/// <param name="startDirection">시작 방향</param>
public VirtualAGV(string agvId, Point startPosition, AgvDirection startDirection = AgvDirection.Forward)
{
_agvId = agvId;
_currentPosition = startPosition;
_currentDirection = startDirection;
_currentState = AGVState.Idle;
_currentSpeed = 0;
_dockingDirection = DockingDirection.Forward; // 기본값: 전진 도킹
_currentNode = null;
_prevNode = null;
_isMoving = false;
_lastUpdateTime = DateTime.Now;
}
#endregion
#region Public Methods - /RFID ( AGV에서 )
/// <summary>
/// 현재 위치 설정 (실제 AGV 센서에서)
/// </summary>
public void SetCurrentPosition(Point position)
{
_currentPosition = position;
}
/// <summary>
/// 감지된 RFID 설정 (실제 RFID 센서에서)
/// </summary>
public void SetDetectedRfid(string rfidId)
{
// RFID 목록에 추가 (중복 제거)
if (!_detectedRfids.Contains(rfidId))
{
_detectedRfids.Add(rfidId);
}
// RFID 2개 이상 감지 시 위치 확정
if (_detectedRfids.Count >= 2 && !_isPositionConfirmed)
{
_isPositionConfirmed = true;
}
RfidDetected?.Invoke(this, rfidId);
}
/// <summary>
/// 모터 방향 설정 (실제 모터 컨트롤러에서)
/// </summary>
public void SetMotorDirection(AgvDirection direction)
{
_currentDirection = direction;
}
/// <summary>
/// 배터리 레벨 설정 (실제 BMS에서)
/// </summary>
public void SetBatteryLevel(float percentage)
{
BatteryLevel = Math.Max(0, Math.Min(100, percentage));
// 배터리 부족 경고
if (BatteryLevel < LowBatteryThreshold && _currentState != AGVState.Charging)
{
OnError($"배터리 부족: {BatteryLevel:F1}% (기준: {LowBatteryThreshold}%)");
}
}
/// <summary>
/// 현재 노드id의 개체를 IsPass 로 설정합니다
/// </summary>
public bool SetCurrentNodeMarkStop()
{
if (_currentNode == null) return false;
if (_currentPath == null) return false;
var = _currentPath.DetailedPath.Where(t => t.IsPass == false).OrderBy(t => t.seq).FirstOrDefault();
if ( == null) return false;
.IsPass = true;
Console.WriteLine($"미완료된처음노드를 true러치합니다");
return true;
}
/// <summary>
/// 다음 동작 예측 (실제 AGV 제어용)
/// AGV가 지속적으로 호출하여 현재 상태와 예측 상태를 일치시킴
/// </summary>
/// <returns>다음에 수행할 모터/마그넷/속도 명령</returns>
public AGVCommand Predict()
{
// 1. 위치 미확정 상태 (RFID 2개 미만 감지)
if (!_isPositionConfirmed)
{
// 항상 전진 + 저속으로 이동 (RFID 감지 대기)
return new AGVCommand(
MotorCommand.Forward,
MagnetPosition.S, // 직진
SpeedLevel.L, // 저속
eAGVCommandReason.UnknownPosition,
$"위치 미확정 (RFID {_detectedRfids.Count}/2) - 전진하여 RFID 탐색"
);
}
// 2. 위치 확정됨 + 경로 없음 → 정지 (목적지 미설정 상태)
if (_currentPath == null || (_currentPath.DetailedPath?.Count ?? 0) < 1)
{
var curpos = "알수없음";
if (_currentNode != null)
{
curpos = _currentNode.HasRfid() ? $"RFID #{_currentNode.RfidId} (*{_currentNode.Id})" : $"(*{_currentNode.Id})";
}
return new AGVCommand(
MotorCommand.Stop,
MagnetPosition.S,
SpeedLevel.L,
eAGVCommandReason.NoPath,
$"(목적지 미설정) - 현재={curpos}"
);
}
// 3. 위치 확정됨 + 경로 있음 + 남은 노드 없음 → 정지 (목적지 도착)
var lastNode = _currentPath.DetailedPath.Last();
if (_currentPath.DetailedPath.Where(t => t.seq < lastNode.seq && t.IsPass == false).Any() == false)
{
// 마지막 노드에 도착했는지 확인 (현재 노드가 마지막 노드와 같은지) -
// 모터방향오 같아야한다. 간혹 방향전환 후 MARK STOP하는경우가있다. 260127
if (_currentNode != null && _currentNode.Id == lastNode.NodeId && lastNode.MotorDirection == CurrentDirection)
{
if (lastNode.IsPass) //이미완료되었다.
{
return new AGVCommand(
MotorCommand.Stop,
MagnetPosition.S,
SpeedLevel.L,
eAGVCommandReason.Complete,
$"목적지 도착 - 최종:{CurrentNodeID2}"
);
}
else
{
//도킹노드라면 markstop 을 나머지는 바로 스탑한다.
eAGVCommandReason reason = eAGVCommandReason.MarkStop;
if (_targetnode.StationType == StationType.Normal || _targetnode.StationType == StationType.Limit)
{
//일반노드는 마크스탑포인트가 없으니 바로 종료되도록 한다
reason = eAGVCommandReason.Complete;
}
//마지막노드는 일혔지만 완료되지 않았다. 마크스탑필요
return new AGVCommand(
MotorCommand.Stop,
MagnetPosition.S,
SpeedLevel.L,
reason,
$"목적지 도착 전(MarkStop) - 최종:{CurrentNodeID2}"
);
}
}
}
// 4. 경로이탈
var TargetNode = _currentPath.DetailedPath.Where(t => t.IsPass == false && t.NodeId.Equals(_currentNode.Id)).FirstOrDefault();
if (TargetNode == null)
{
return new AGVCommand(
MotorCommand.Stop,
MagnetPosition.S,
SpeedLevel.L,
eAGVCommandReason.PathOut,
$"(재탐색요청)경로이탈 현재위치:{CurrentNodeID2}"
);
}
return GetCommandFromPath(CurrentNode, "경로 실행 시작");
}
#endregion
#region Public Methods -
/// <summary>
/// 현재 위치 조회
/// </summary>
public Point GetCurrentPosition() => _currentPosition;
/// <summary>
/// 현재 상태 조회
/// </summary>
public AGVState GetCurrentState() => _currentState;
/// <summary>
/// 현재 노드 ID 조회
/// </summary>
public MapNode GetCurrentNode() => _currentNode;
/// <summary>
/// AGV 정보 조회
/// </summary>
public string GetStatus()
{
return $"AGV[{_agvId}] 위치:({_currentPosition.X},{_currentPosition.Y}) " +
$"방향:{_currentDirection} 상태:{_currentState} " +
$"속도:{_currentSpeed:F1} 배터리:{BatteryLevel:F1}%";
}
#endregion
#region Public Methods -
/// <summary>
/// 경로가 설정되어있는지?
/// </summary>
/// <returns></returns>
public bool HasPath()
{
if (_currentPath == null) return false;
if (_currentPath.DetailedPath == null) return false;
return _currentPath.DetailedPath.Any();
}
/// <summary>
/// 경로 설정 (실제 AGV 및 시뮬레이터에서 사용)
/// </summary>
/// <param name="path">실행할 경로</param>
public void SetPath(AGVPathResult path)
{
if (path == null)
{
_currentPath = null;
_remainingNodes.Clear();// = null;
_currentNodeIndex = 0;
OnError("경로가 null입니다.");
return;
}
_currentPath = path;
_remainingNodes = path.Path.Select(n => n.Id).ToList(); // MapNode → NodeId 변환
_currentNodeIndex = 0;
// 경로 시작 노드가 현재 노드와 다른 경우 경고
if (_currentNode != null && _remainingNodes.Count > 0 && _remainingNodes[0] != _currentNode.Id)
{
OnError($"경로 시작 노드({_remainingNodes[0]})와 현재 노드({_currentNode.Id})가 다릅니다.");
}
}
/// <summary>
/// 경로 정지
/// </summary>
public void StopPath()
{
_isMoving = false;
_currentPath = null;
_remainingNodes?.Clear();
SetState(AGVState.Idle);
_currentSpeed = 0;
}
/// <summary>
/// 긴급 정지
/// </summary>
public void EmergencyStop()
{
StopPath();
OnError("긴급 정지가 실행되었습니다.");
}
/// <summary>
/// 일시 정지 (경로 유지)
/// </summary>
public void Pause()
{
_isMoving = false;
_currentSpeed = 0;
}
/// <summary>
/// 이동 재개
/// </summary>
public void Resume()
{
if (_currentPath != null && _remainingNodes != null && _remainingNodes.Count > 0)
{
_isMoving = true;
SetState(AGVState.Moving);
}
}
#endregion
#region Public Methods - ( )
/// <summary>
/// 프레임 업데이트 (외부에서 주기적으로 호출)
/// 이 방식으로 타이머에 의존하지 않고 외부에서 제어 가능
/// </summary>
/// <param name="deltaTimeMs">마지막 업데이트 이후 경과 시간 (밀리초)</param>
public void Update(float deltaTimeMs)
{
var deltaTime = deltaTimeMs / 1000.0f; // 초 단위로 변환
UpdateMovement(deltaTime);
UpdateBattery(deltaTime);
// 위치 변경 이벤트 발생
PositionChanged?.Invoke(this, (_currentPosition, _currentDirection, _currentNode));
}
#endregion
#region Public Methods - ()
/// <summary>
/// 수동 이동 (테스트용)
/// </summary>
/// <param name="targetPosition">목표 위치</param>
public void MoveTo(Point targetPosition)
{
_prevPosition = targetPosition;
_moveStartPosition = _currentPosition;
_moveTargetPosition = targetPosition;
SetState(AGVState.Moving);
_isMoving = true;
Turn = AGVTurn.None;
}
/// <summary>
/// 수동 회전 (테스트용)
/// </summary>
/// <param name="direction">회전 방향</param>
public void Rotate(AgvDirection direction)
{
if (_currentState != AGVState.Idle)
return;
SetState(AGVState.Rotating);
_currentDirection = direction;
SetState(AGVState.Idle);
}
/// <summary>
/// 충전 시작
/// </summary>
public void StartCharging()
{
if (_currentState == AGVState.Idle)
{
SetState(AGVState.Charging);
}
}
/// <summary>
/// 충전 종료
/// </summary>
public void StopCharging()
{
if (_currentState == AGVState.Charging)
{
SetState(AGVState.Idle);
}
}
#endregion
#region Public Methods - AGV ()
/// <summary>
/// AGV 위치 직접 설정
/// PrevPosition을 이전 위치로 저장하여 리프트 방향 계산이 가능하도록 함
/// </summary>
/// <param name="node">현재 노드</param>
/// <param name="newPosition">새로운 위치</param>
/// <param name="motorDirection">모터이동방향</param>
public void SetPosition(MapNode node, AgvDirection motorDirection)
{
// 현재 위치를 이전 위치로 저장 (리프트 방향 계산용)
if (_currentNode != null && _currentNode.Id != node.Id)
{
_prevPosition = _currentPosition; // 이전 위치
_prevNode = _currentNode;
_prevDirection = _currentDirection;
}
////모터방향이 다르다면 적용한다
//if (_currentDirection != motorDirection)
//{
// _prevDirection = motorDirection;
//}
// 새로운 위치 설정
_currentPosition = node.Position;
_currentDirection = motorDirection;
_currentNode = node;
// 🔥 노드 ID를 RFID로 간주하여 감지 목록에 추가 (시뮬레이터용)
if (!string.IsNullOrEmpty(node.Id) && !_detectedRfids.Contains(node.Id))
{
_detectedRfids.Add(node.Id);
}
// 🔥 RFID 2개 이상 감지 시 위치 확정
if (_detectedRfids.Count >= 2 && !_isPositionConfirmed)
{
_isPositionConfirmed = true;
}
//현재 경로값이 있는지 확인한다.
if (CurrentPath != null && CurrentPath.DetailedPath != null && CurrentPath.DetailedPath.Any())
{
var item = CurrentPath.DetailedPath.FirstOrDefault(t => t.NodeId == node.Id && t.IsPass == false);
if (item != null)
{
// [PathJump Check] 점프한 노드 개수 확인
// 현재 노드(item)보다 이전인데 아직 IsPass가 안 된 노드의 개수
int skippedCount = CurrentPath.DetailedPath.Count(t => t.seq < item.seq && t.IsPass == false);
if (skippedCount > 2)
{
OnError($"PathJump: {skippedCount}개의 노드를 건너뛰었습니다. (허용: 2개, 현재노드: {node.Id})");
return;
}
//item.IsPass = true;
//이전노드는 모두 지나친걸로 한다
CurrentPath.DetailedPath.Where(t => t.seq < item.seq).ToList().ForEach(t => t.IsPass = true);
}
}
// 위치 변경 이벤트 발생
PositionChanged?.Invoke(this, (_currentPosition, _currentDirection, _currentNode));
}
#endregion
/// <summary>
/// 노드 ID를 RFID 값으로 변환 (NodeResolver 사용)
/// </summary>
public ushort GetRfidByNodeId(List<MapNode> _mapNodes, string nodeId)
{
var node = _mapNodes?.FirstOrDefault(n => n.Id == nodeId);
if ((node?.HasRfid() ?? false) == false) return 0;
return node.RfidId;
}
#region Private Methods
/// <summary>
/// DetailedPath에서 노드 정보를 찾아 AGVCommand 생성
/// </summary>
private AGVCommand GetCommandFromPath(MapNode targetNode, string actionDescription)
{
// DetailedPath가 없으면 기본 명령 반환
if (_currentPath == null || _currentPath.DetailedPath == null || _currentPath.DetailedPath.Count == 0)
{
// [Refactor] Predict와 일관성 유지: 경로가 없으면 정지
return new AGVCommand(
MotorCommand.Stop,
MagnetPosition.S,
SpeedLevel.L,
eAGVCommandReason.NoPath,
$"{actionDescription} (DetailedPath 없음)"
);
}
// DetailedPath에서 targetNodeId에 해당하는 NodeMotorInfo 찾기
// 지나가지 않은 경로를 찾는다
var nodeInfo = _currentPath.DetailedPath.FirstOrDefault(n => n.NodeId == targetNode.Id && n.IsPass == false);
if (nodeInfo == null)
{
// 못 찾으면 기본 명령 반환
var defaultMotor = _currentDirection == AgvDirection.Forward
? MotorCommand.Forward
: MotorCommand.Backward;
return new AGVCommand(
defaultMotor,
MagnetPosition.S,
SpeedLevel.M,
eAGVCommandReason.NoTarget,
$"{actionDescription} (노드 {targetNode.Id} 정보 없음)"
);
}
// MotorDirection → MotorCommand 변환
MotorCommand motorCmd;
switch (nodeInfo.MotorDirection)
{
case AgvDirection.Forward:
motorCmd = MotorCommand.Forward;
break;
case AgvDirection.Backward:
motorCmd = MotorCommand.Backward;
break;
default:
motorCmd = MotorCommand.Stop;
break;
}
// MagnetDirection → MagnetPosition 변换
MagnetPosition magnetPos;
switch (nodeInfo.MagnetDirection)
{
case PathFinding.Planning.MagnetDirection.Left:
magnetPos = MagnetPosition.L;
break;
case PathFinding.Planning.MagnetDirection.Right:
magnetPos = MagnetPosition.R;
break;
case PathFinding.Planning.MagnetDirection.Straight:
default:
magnetPos = MagnetPosition.S;
break;
}
// [Speed Control] NodeMotorInfo에 설정된 속도 사용
// 단, 회전 구간 등에서 안전을 위해 강제 감속이 필요한 경우 로직 추가 가능
// 현재는 사용자 설정 우선
SpeedLevel speed = nodeInfo.Speed;
// Optional: 회전 시 강제 감속 로직 (사용자 요청에 따라 주석 처리 또는 제거 가능)
// if (nodeInfo.CanRotate || nodeInfo.IsDirectionChangePoint) speed = SpeedLevel.L;
return new AGVCommand(
motorCmd,
magnetPos,
speed,
eAGVCommandReason.Normal,
$"{actionDescription} → {targetNode.Id} (Motor:{motorCmd}, Magnet:{magnetPos})"
);
}
private void StartMovement()
{
SetState(AGVState.Moving);
_isMoving = true;
_lastUpdateTime = DateTime.Now;
}
private void UpdateMovement(float deltaTime)
{
if (_currentState != AGVState.Moving || !_isMoving)
return;
// 목표 위치까지의 거리 계산
var distance = CalculateDistance(_currentPosition, _moveTargetPosition);
if (distance < 5.0f) // 도달 임계값
{
// 목표 도달
_currentPosition = _moveTargetPosition;
_currentSpeed = 0;
// 다음 노드로 이동
ProcessNextNode();
}
else
{
// 계속 이동
var moveDistance = _moveSpeed * deltaTime;
var direction = new PointF(
_moveTargetPosition.X - _currentPosition.X,
_moveTargetPosition.Y - _currentPosition.Y
);
// 정규화
var length = (float)Math.Sqrt(direction.X * direction.X + direction.Y * direction.Y);
if (length > 0)
{
direction.X /= length;
direction.Y /= length;
}
// 새 위치 계산
_currentPosition = new Point(
(int)(_currentPosition.X + direction.X * moveDistance),
(int)(_currentPosition.Y + direction.Y * moveDistance)
);
_currentSpeed = _moveSpeed;
}
}
private void UpdateBattery(float deltaTime)
{
// 배터리 소모 시뮬레이션
if (_currentState == AGVState.Moving)
{
BatteryLevel -= 0.1f * deltaTime; // 이동시 소모
}
else if (_currentState == AGVState.Charging)
{
BatteryLevel += 5.0f * deltaTime; // 충전
BatteryLevel = Math.Min(100.0f, BatteryLevel);
}
BatteryLevel = Math.Max(0, BatteryLevel);
}
public MapNode StartNode { get; set; } = null;
private MapNode _targetnode = null;
/// <summary>
/// 목적지를 설정합니다. 목적지가 변경되면 경로계산정보가 삭제 됩니다.
/// </summary>
public MapNode TargetNode
{
get
{
return _targetnode;
}
set
{
if (_targetnode != value)
{
_currentPath = null;
_targetnode = value;
}
}
}
private void ProcessNextNode()
{
if (_remainingNodes == null || _currentNodeIndex >= _remainingNodes.Count - 1)
{
// 경로 완료
_isMoving = false;
SetState(AGVState.Idle);
PathCompleted?.Invoke(this, _currentPath);
return;
}
// 다음 노드로 이동
_currentNodeIndex++;
var nextNodeId = _remainingNodes[_currentNodeIndex];
// RFID 감지 시뮬레이션
RfidDetected?.Invoke(this, $"RFID_{nextNodeId}");
// 다음 목표 위치 설정 (실제로는 맵에서 좌표 가져와야 함)
var random = new Random();
_moveTargetPosition = new Point(
_currentPosition.X + random.Next(-100, 100),
_currentPosition.Y + random.Next(-100, 100)
);
}
private MapNode FindClosestNode(Point position, List<MapNode> mapNodes)
{
if (mapNodes == null || mapNodes.Count == 0)
return null;
MapNode closestNode = null;
float closestDistance = float.MaxValue;
foreach (var node in mapNodes)
{
var distance = CalculateDistance(position, node.Position);
if (distance < closestDistance)
{
closestDistance = distance;
closestNode = node;
}
}
return closestDistance < 50.0f ? closestNode : null;
}
private float CalculateDistance(Point from, Point to)
{
var dx = to.X - from.X;
var dy = to.Y - from.Y;
return (float)Math.Sqrt(dx * dx + dy * dy);
}
private void SetState(AGVState newState)
{
if (_currentState != newState)
{
_currentState = newState;
StateChanged?.Invoke(this, newState);
}
}
private void OnError(string message)
{
SetState(AGVState.Error);
ErrorOccurred?.Invoke(this, message);
}
#endregion
#region Cleanup
/// <summary>
/// 리소스 정리
/// </summary>
public void Dispose()
{
StopPath();
}
#endregion
}
}