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

@@ -0,0 +1,21 @@
namespace AGVNavigationCore.Controls
{
#region Interfaces
/// <summary>
/// AGV 상태 열거형
/// </summary>
public enum AGVState
{
Idle, // 대기
Moving, // 이동 중
Rotating, // 회전 중
Docking, // 도킹 중
Charging, // 충전 중
Error // 오류
}
#endregion
}

View 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
}

View File

@@ -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)
@@ -154,7 +187,7 @@ namespace AGVNavigationCore.Controls
if (_currentPath != null)
{
DrawPath(g, _currentPath, Color.Purple);
// AGVPathResult의 모터방향 정보가 있다면 향상된 경로 그리기
// 현재는 기본 PathResult를 사용하므로 향후 AGVPathResult로 업그레이드 시 활성화
// TODO: AGVPathfinder 사용시 AGVPathResult로 업그레이드
@@ -179,14 +212,14 @@ namespace AGVNavigationCore.Controls
{
// 경로 선 그리기
g.DrawLine(pathPen, currentNode.Position, nextNode.Position);
// 경로 방향 표시 (계산된 경로의 경우에만 방향 화살표 표시)
var midPoint = new Point(
(currentNode.Position.X + nextNode.Position.X) / 2,
(currentNode.Position.Y + nextNode.Position.Y) / 2
);
var angle = Math.Atan2(nextNode.Position.Y - currentNode.Position.Y,
var angle = Math.Atan2(nextNode.Position.Y - currentNode.Position.Y,
nextNode.Position.X - currentNode.Position.X);
DrawDirectionArrow(g, midPoint, angle, AgvDirection.Forward);
}
@@ -218,7 +251,7 @@ namespace AGVNavigationCore.Controls
// 모터방향에 따른 색상 결정
var motorDirection = currentMotorInfo.MotorDirection;
Color pathColor = motorDirection == AgvDirection.Forward ? Color.Green : Color.Orange;
// 강조된 경로 선 그리기
var enhancedPen = new Pen(pathColor, 6) { DashStyle = DashStyle.Solid };
g.DrawLine(enhancedPen, currentNode.Position, nextNode.Position);
@@ -264,63 +297,77 @@ namespace AGVNavigationCore.Controls
{
string motorText = motorDirection == AgvDirection.Forward ? "전진" : "후진";
Color textColor = motorDirection == AgvDirection.Forward ? Color.DarkGreen : Color.DarkOrange;
var font = new Font("맑은 고딕", 8, FontStyle.Bold);
var brush = new SolidBrush(textColor);
// 노드 우측 상단에 모터방향 텍스트 표시
var textPosition = new Point(nodePosition.X + NODE_RADIUS + 2, nodePosition.Y - NODE_RADIUS - 2);
g.DrawString(motorText, font, brush, textPosition);
font.Dispose();
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,
@@ -344,7 +391,7 @@ namespace AGVNavigationCore.Controls
{
// 금색 테두리로 목적지 강조
g.DrawEllipse(_destinationNodePen, rect);
// 펄싱 효과를 위한 추가 원 그리기
var pulseRect = new Rectangle(rect.X - 3, rect.Y - 3, rect.Width + 6, rect.Height + 6);
g.DrawEllipse(new Pen(Color.Gold, 2) { DashStyle = DashStyle.Dash }, pulseRect);
@@ -357,14 +404,18 @@ 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;
// 5각형 꼭짓점 계산 (위쪽부터 시계방향)
var points = new Point[5];
for (int i = 0; i < 5; i++)
@@ -391,7 +442,7 @@ namespace AGVNavigationCore.Controls
{
// 금색 테두리로 목적지 강조
g.DrawPolygon(_destinationNodePen, points);
// 펄싱 효과를 위한 추가 오각형 그리기
var pulsePoints = new Point[5];
for (int i = 0; i < 5; i++)
@@ -421,14 +472,18 @@ 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;
// 삼각형 꼭짓점 계산 (위쪽 꼭짓점부터 시계방향)
var points = new Point[3];
for (int i = 0; i < 3; i++)
@@ -455,7 +510,7 @@ namespace AGVNavigationCore.Controls
{
// 금색 테두리로 목적지 강조
g.DrawPolygon(_destinationNodePen, points);
// 펄싱 효과를 위한 추가 삼각형 그리기
var pulsePoints = new Point[3];
for (int i = 0; i < 3; i++)
@@ -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)
@@ -493,10 +552,10 @@ namespace AGVNavigationCore.Controls
string displayText;
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,40 +570,80 @@ 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();
descFont.Dispose();
}
@@ -552,11 +651,11 @@ namespace AGVNavigationCore.Controls
private void DrawLabelNode(Graphics g, MapNode node)
{
var text = string.IsNullOrEmpty(node.LabelText) ? node.NodeId : node.LabelText;
// 폰트 설정
var font = new Font(node.FontFamily, node.FontSize, node.FontStyle);
var textBrush = new SolidBrush(node.ForeColor);
// 텍스트 크기 측정
var textSize = g.MeasureString(text, font);
var textPoint = new Point(
@@ -639,7 +738,7 @@ namespace AGVNavigationCore.Controls
g.TranslateTransform(node.Position.X, node.Position.Y);
g.RotateTransform(node.Rotation);
g.TranslateTransform(-node.Position.X, -node.Position.Y);
// 투명도 적용하여 이미지 그리기
if (node.Opacity < 1.0f)
{
@@ -656,7 +755,7 @@ namespace AGVNavigationCore.Controls
{
g.DrawImage(node.LoadedImage, imageRect);
}
g.Transform = oldTransform;
}
else
@@ -691,7 +790,7 @@ namespace AGVNavigationCore.Controls
var hoverRect = new Rectangle(imageRect.X - 2, imageRect.Y - 2, imageRect.Width + 4, imageRect.Height + 4);
g.DrawRectangle(new Pen(Color.Orange, 2), hoverRect);
}
}
else
{
@@ -776,7 +875,7 @@ namespace AGVNavigationCore.Controls
// AGV 색상 결정
var brush = GetAGVBrush(state);
// AGV 개선된 원형 디자인 그리기
var rect = new Rectangle(
position.X - AGV_SIZE / 2,
@@ -796,20 +895,20 @@ namespace AGVNavigationCore.Controls
{
gradientBrush.CenterPoint = new PointF(position.X, position.Y);
gradientBrush.CenterColor = GetAGVCenterColor(state);
gradientBrush.SurroundColors = new Color[] {
GetAGVOuterColor(state), GetAGVOuterColor(state),
GetAGVOuterColor(state), GetAGVOuterColor(state)
gradientBrush.SurroundColors = new Color[] {
GetAGVOuterColor(state), GetAGVOuterColor(state),
GetAGVOuterColor(state), GetAGVOuterColor(state)
};
// 원형으로 AGV 본체 그리기 (3D 효과)
g.FillEllipse(gradientBrush, rect);
// 외곽선 (두께 조절)
using (var outerPen = new Pen(Color.DarkGray, 3))
{
g.DrawEllipse(outerPen, rect);
}
// 내부 링 (입체감)
var innerSize = AGV_SIZE - 8;
var innerRect = new Rectangle(
@@ -822,7 +921,7 @@ namespace AGVNavigationCore.Controls
{
g.DrawEllipse(innerPen, innerRect);
}
// 중앙 상태 표시등 (더 크고 화려하게)
var indicatorSize = 10;
var indicatorRect = new Rectangle(
@@ -831,7 +930,7 @@ namespace AGVNavigationCore.Controls
indicatorSize,
indicatorSize
);
// 표시등 글로우 효과
using (var glowBrush = new System.Drawing.Drawing2D.PathGradientBrush(
new Point[] {
@@ -843,22 +942,22 @@ namespace AGVNavigationCore.Controls
{
glowBrush.CenterPoint = new PointF(position.X, position.Y);
glowBrush.CenterColor = Color.White;
glowBrush.SurroundColors = new Color[] {
glowBrush.SurroundColors = new Color[] {
GetStatusIndicatorColor(state), GetStatusIndicatorColor(state),
GetStatusIndicatorColor(state), GetStatusIndicatorColor(state)
};
g.FillEllipse(glowBrush, indicatorRect);
g.DrawEllipse(new Pen(Color.DarkGray, 1), indicatorRect);
}
// 원형 센서 패턴 (방사형 배치)
DrawCircularSensors(g, position, state);
}
// 리프트 그리기 (이동 경로 기반)
DrawAGVLiftAdvanced(g, agv);
// 모니터 그리기 (리프트 반대편)
DrawAGVMonitor(g, agv);
@@ -914,32 +1013,32 @@ namespace AGVNavigationCore.Controls
const int liftLength = 28; // 리프트 길이 (더욱 크게)
const int liftWidth = 16; // 리프트 너비 (더욱 크게)
const int liftDistance = AGV_SIZE / 2 + 2; // AGV 본체 면에 바로 붙도록
var currentPos = agv.CurrentPosition;
var targetPos = agv.TargetPosition;
var dockingDirection = agv.DockingDirection;
var currentDirection = agv.CurrentDirection;
// 경로 예측 기반 LiftCalculator 사용
Point fallbackTarget = targetPos ?? new Point(currentPos.X + 1, currentPos.Y); // 기본 타겟
var liftInfo = AGVNavigationCore.Utils.LiftCalculator.CalculateLiftInfoWithPathPrediction(
currentPos, fallbackTarget, currentDirection, _nodes);
float liftAngle = (float)liftInfo.AngleRadians;
// 리프트 위치 계산 (각도 기반)
Point liftPosition = new Point(
(int)(currentPos.X + liftDistance * Math.Cos(liftAngle)),
(int)(currentPos.Y + liftDistance * Math.Sin(liftAngle))
);
// 방향을 알 수 있는지 확인
bool hasDirection = targetPos.HasValue && targetPos.Value != currentPos;
// 방향에 따른 리프트 색상 및 스타일 결정
Color liftColor;
Color borderColor;
// 모터 방향과 경로 상태에 따른 색상 결정 (더 눈에 띄게)
switch (currentDirection)
{
@@ -956,7 +1055,7 @@ namespace AGVNavigationCore.Controls
borderColor = Color.Gray;
break;
}
// 경로 예측 결과에 따른 색상 조정
if (liftInfo.CalculationMethod.Contains("경로 예측"))
{
@@ -978,14 +1077,14 @@ namespace AGVNavigationCore.Controls
liftColor = Color.FromArgb(200, liftColor.R, liftColor.G, liftColor.B);
borderColor = Color.FromArgb(150, borderColor.R, borderColor.G, borderColor.B);
}
// Graphics 상태 저장
var oldTransform = g.Transform;
// 리프트 중심으로 회전 변환 적용
g.TranslateTransform(liftPosition.X, liftPosition.Y);
g.RotateTransform((float)(liftAngle * 180.0 / Math.PI));
// 회전된 좌표계에서 리프트 그리기 (중심이 원점)
var liftRect = new Rectangle(
-liftLength / 2, // 중심 기준 왼쪽
@@ -993,20 +1092,20 @@ namespace AGVNavigationCore.Controls
liftLength,
liftWidth
);
using (var liftBrush = new SolidBrush(liftColor))
using (var liftPen = new Pen(borderColor, 3)) // 두껍게 테두리
{
// 둥근 사각형으로 리프트 그리기
DrawRoundedRectangle(g, liftBrush, liftPen, liftRect, 4);
// 추가 강조 테두리 (더 잘 보이게)
using (var emphasisPen = new Pen(Color.Black, 1))
{
DrawRoundedRectangle(g, null, emphasisPen, liftRect, 4);
}
}
if (hasDirection)
{
// 리프트 세부 표현 (방향 표시용 선들)
@@ -1018,13 +1117,13 @@ namespace AGVNavigationCore.Controls
g.DrawLine(detailPen, i, -liftWidth / 2 + 2, i, liftWidth / 2 - 2);
}
}
// 이동 방향 표시 화살표 (리프트 끝쪽)
using (var arrowPen = new Pen(borderColor, 2))
{
var arrowSize = 3;
var arrowX = liftLength / 2 - 4;
// 화살표 모양
g.DrawLine(arrowPen, arrowX, 0, arrowX + arrowSize, -arrowSize);
g.DrawLine(arrowPen, arrowX, 0, arrowX + arrowSize, arrowSize);
@@ -1044,7 +1143,7 @@ namespace AGVNavigationCore.Controls
g.DrawString("?", questionFont, questionBrush, questionPoint);
}
}
// Graphics 상태 복원
g.Transform = oldTransform;
}
@@ -1084,7 +1183,7 @@ namespace AGVNavigationCore.Controls
{
var currentPos = agv.CurrentPosition;
var targetPos = agv.TargetPosition;
// 디버그 정보 (개발용)
if (targetPos.HasValue)
{
@@ -1172,14 +1271,14 @@ namespace AGVNavigationCore.Controls
var sensorSize = 3;
var sensorColor = state == AGVState.Error ? Color.Red : Color.Silver;
var radius = AGV_SIZE / 2 - 6;
// 8방향으로 센서 배치 (45도씩)
for (int i = 0; i < 8; i++)
{
double angle = i * Math.PI / 4; // 45도씩
int sensorX = (int)(center.X + radius * Math.Cos(angle));
int sensorY = (int)(center.Y + radius * Math.Sin(angle));
var sensorRect = new Rectangle(
sensorX - sensorSize / 2,
sensorY - sensorSize / 2,
@@ -1193,7 +1292,7 @@ namespace AGVNavigationCore.Controls
g.DrawEllipse(new Pen(Color.Black, 1), sensorRect);
}
}
// 내부 원형 패턴 (장식)
var innerRadius = AGV_SIZE / 2 - 12;
for (int i = 0; i < 4; i++)
@@ -1201,7 +1300,7 @@ namespace AGVNavigationCore.Controls
double angle = i * Math.PI / 2 + Math.PI / 4; // 45도 오프셋된 4방향
int dotX = (int)(center.X + innerRadius * Math.Cos(angle));
int dotY = (int)(center.Y + innerRadius * Math.Sin(angle));
var dotRect = new Rectangle(dotX - 1, dotY - 1, 2, 2);
using (var dotBrush = new SolidBrush(GetAGVInnerRingColor(state)))
{
@@ -1218,15 +1317,15 @@ namespace AGVNavigationCore.Controls
const int monitorWidth = 12; // 모니터 너비 (가로로 더 길게)
const int monitorHeight = 24; // 모니터 높이 (세로는 줄임)
const int monitorDistance = AGV_SIZE / 2 + 2; // AGV 본체에서 거리 (리프트와 동일)
var currentPos = agv.CurrentPosition;
var targetPos = agv.TargetPosition;
var dockingDirection = agv.DockingDirection;
var currentDirection = agv.CurrentDirection;
// 리프트 방향 계산 (모니터는 정반대)
Point fallbackTarget = targetPos ?? new Point(currentPos.X + 50, currentPos.Y);
var liftInfo = AGVNavigationCore.Utils.LiftCalculator.CalculateLiftInfoWithPathPrediction(
currentPos, fallbackTarget, currentDirection, _nodes);
@@ -1253,14 +1352,14 @@ namespace AGVNavigationCore.Controls
// 모니터 상태에 따른 색상
var monitorBackColor = GetMonitorBackColor(agv.CurrentState);
var monitorBorderColor = Color.DarkGray;
// 모니터 본체 (둥근 모서리)
using (var monitorBrush = new SolidBrush(monitorBackColor))
using (var borderPen = new Pen(monitorBorderColor, 2))
{
DrawRoundedRectangle(g, monitorBrush, borderPen, monitorRect, 2);
}
// 모니터 화면 (내부 사각형)
var screenRect = new Rectangle(
monitorRect.X + 2,
@@ -1268,15 +1367,15 @@ namespace AGVNavigationCore.Controls
monitorRect.Width - 4,
monitorRect.Height - 4
);
using (var screenBrush = new SolidBrush(GetMonitorScreenColor(agv.CurrentState)))
{
g.FillRectangle(screenBrush, screenRect);
}
// 화면 패턴 (상태별)
DrawMonitorScreen(g, screenRect, agv.CurrentState);
// 모니터 스탠드
var standRect = new Rectangle(
monitorDistance - 2,
@@ -1298,7 +1397,7 @@ namespace AGVNavigationCore.Controls
DrawRoundedRectangle(g, monitorBrush, borderPen, monitorRect, 2);
}
}
// Graphics 상태 복원
g.Transform = oldTransform;
}
@@ -1346,7 +1445,7 @@ namespace AGVNavigationCore.Controls
}
}
break;
case AGVState.Charging:
// 충전 중: 배터리 아이콘
var batteryRect = new Rectangle(
@@ -1358,15 +1457,15 @@ namespace AGVNavigationCore.Controls
g.DrawRectangle(patternPen, batteryRect);
g.DrawLine(patternPen, batteryRect.Right, batteryRect.Y + 1, batteryRect.Right, batteryRect.Bottom - 1);
break;
case AGVState.Error:
// 에러: X 마크
g.DrawLine(patternPen, screenRect.X + 2, screenRect.Y + 2,
g.DrawLine(patternPen, screenRect.X + 2, screenRect.Y + 2,
screenRect.Right - 2, screenRect.Bottom - 2);
g.DrawLine(patternPen, screenRect.Right - 2, screenRect.Y + 2,
g.DrawLine(patternPen, screenRect.Right - 2, screenRect.Y + 2,
screenRect.X + 2, screenRect.Bottom - 2);
break;
case AGVState.Docking:
// 도킹: 화살표
var centerX = screenRect.X + screenRect.Width / 2;
@@ -1375,10 +1474,10 @@ namespace AGVNavigationCore.Controls
g.DrawLine(patternPen, centerX, centerY - 2, centerX + 2, centerY);
g.DrawLine(patternPen, centerX, centerY + 2, centerX + 2, centerY);
break;
default:
// 대기: 중앙 점
g.DrawRectangle(patternPen,
g.DrawRectangle(patternPen,
screenRect.X + screenRect.Width / 2 - 1,
screenRect.Y + screenRect.Height / 2 - 1,
2, 2);
@@ -1402,7 +1501,7 @@ namespace AGVNavigationCore.Controls
var font = new Font("Arial", 9);
var textBrush = new SolidBrush(Color.Black);
var backgroundBrush = new SolidBrush(Color.FromArgb(200, Color.White));
var textSize = g.MeasureString(_measurementInfo, font);
var textRect = new Rectangle(
Width - (int)textSize.Width - 20,
@@ -1425,7 +1524,7 @@ namespace AGVNavigationCore.Controls
var zoomFont = new Font("Arial", 10, FontStyle.Bold);
var zoomSize = g.MeasureString(zoomText, zoomFont);
var zoomPoint = new Point(10, Height - (int)zoomSize.Height - 10);
g.FillRectangle(new SolidBrush(Color.FromArgb(200, Color.White)),
zoomPoint.X - 5, zoomPoint.Y - 5,
zoomSize.Width + 10, zoomSize.Height + 10);
@@ -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);

View File

@@ -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 = "";

View File

@@ -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
}