diff --git a/Cs_HMI/AGVNavigationCore/AGVNavigationCore.csproj b/Cs_HMI/AGVNavigationCore/AGVNavigationCore.csproj index 6def2d0..17ddb4e 100644 --- a/Cs_HMI/AGVNavigationCore/AGVNavigationCore.csproj +++ b/Cs_HMI/AGVNavigationCore/AGVNavigationCore.csproj @@ -71,6 +71,7 @@ + @@ -87,11 +88,9 @@ UnifiedAGVCanvas.cs UserControl + - - - diff --git a/Cs_HMI/AGVNavigationCore/Controls/UnifiedAGVCanvas.Events.cs b/Cs_HMI/AGVNavigationCore/Controls/UnifiedAGVCanvas.Events.cs index d042028..7b3d513 100644 --- a/Cs_HMI/AGVNavigationCore/Controls/UnifiedAGVCanvas.Events.cs +++ b/Cs_HMI/AGVNavigationCore/Controls/UnifiedAGVCanvas.Events.cs @@ -150,10 +150,14 @@ namespace AGVNavigationCore.Controls } } - // 현재 선택된 경로 그리기 + // 현재 선택된 경로 그리기 (AGVPathResult 활용) if (_currentPath != null) { DrawPath(g, _currentPath, Color.Purple); + + // AGVPathResult의 모터방향 정보가 있다면 향상된 경로 그리기 + // 현재는 기본 PathResult를 사용하므로 향후 AGVPathResult로 업그레이드 시 활성화 + // TODO: AGVPathfinder 사용시 AGVPathResult로 업그레이드 } } @@ -191,6 +195,87 @@ namespace AGVNavigationCore.Controls pathPen.Dispose(); } + /// + /// AGV 경로 및 모터방향 정보를 시각화 + /// + /// Graphics 객체 + /// AGV 경로 계산 결과 + private void DrawAGVPath(Graphics g, AGVPathResult agvResult) + { + if (agvResult?.NodeMotorInfos == null || agvResult.NodeMotorInfos.Count == 0) return; + + // 노드별 모터방향 정보를 기반으로 향상된 경로 표시 + for (int i = 0; i < agvResult.NodeMotorInfos.Count - 1; i++) + { + var currentMotorInfo = agvResult.NodeMotorInfos[i]; + var nextMotorInfo = agvResult.NodeMotorInfos[i + 1]; + + var currentNode = _nodes?.FirstOrDefault(n => n.NodeId == currentMotorInfo.NodeId); + var nextNode = _nodes?.FirstOrDefault(n => n.NodeId == nextMotorInfo.NodeId); + + if (currentNode != null && nextNode != null) + { + // 모터방향에 따른 색상 결정 + 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); + + // 중간점에 모터방향 화살표 표시 + 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, + nextNode.Position.X - currentNode.Position.X); + + // 모터방향별 화살표 그리기 + DrawDirectionArrow(g, midPoint, angle, motorDirection); + + // 노드 옆에 모터방향 텍스트 표시 + DrawMotorDirectionLabel(g, currentNode.Position, motorDirection); + + enhancedPen.Dispose(); + } + } + + // 마지막 노드의 모터방향 표시 + if (agvResult.NodeMotorInfos.Count > 0) + { + var lastMotorInfo = agvResult.NodeMotorInfos[agvResult.NodeMotorInfos.Count - 1]; + var lastNode = _nodes?.FirstOrDefault(n => n.NodeId == lastMotorInfo.NodeId); + if (lastNode != null) + { + DrawMotorDirectionLabel(g, lastNode.Position, lastMotorInfo.MotorDirection); + } + } + } + + /// + /// 모터방향 레이블 표시 + /// + /// Graphics 객체 + /// 노드 위치 + /// 모터방향 + private void DrawMotorDirectionLabel(Graphics g, Point nodePosition, AgvDirection motorDirection) + { + 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) { if (_nodes == null) return; @@ -254,6 +339,17 @@ namespace AGVNavigationCore.Controls g.DrawEllipse(_selectedNodePen, rect); } + // 목적지 노드 강조 + if (node == _destinationNode) + { + // 금색 테두리로 목적지 강조 + 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); + } + // 호버된 노드 강조 if (node == _hoveredNode) { @@ -290,6 +386,25 @@ namespace AGVNavigationCore.Controls g.DrawPolygon(_selectedNodePen, points); } + // 목적지 노드 강조 + if (node == _destinationNode) + { + // 금색 테두리로 목적지 강조 + g.DrawPolygon(_destinationNodePen, points); + + // 펄싱 효과를 위한 추가 오각형 그리기 + var pulsePoints = new Point[5]; + for (int i = 0; i < 5; i++) + { + var angle = (Math.PI * 2 * i / 5) - Math.PI / 2; + pulsePoints[i] = new Point( + (int)(center.X + (radius + 4) * Math.Cos(angle)), + (int)(center.Y + (radius + 4) * Math.Sin(angle)) + ); + } + g.DrawPolygon(new Pen(Color.Gold, 2) { DashStyle = DashStyle.Dash }, pulsePoints); + } + // 호버된 노드 강조 if (node == _hoveredNode) { @@ -335,6 +450,25 @@ namespace AGVNavigationCore.Controls g.DrawPolygon(_selectedNodePen, points); } + // 목적지 노드 강조 + if (node == _destinationNode) + { + // 금색 테두리로 목적지 강조 + g.DrawPolygon(_destinationNodePen, points); + + // 펄싱 효과를 위한 추가 삼각형 그리기 + var pulsePoints = new Point[3]; + for (int i = 0; i < 3; i++) + { + var angle = (Math.PI * 2 * i / 3) - Math.PI / 2; + pulsePoints[i] = new Point( + (int)(center.X + (radius + 4) * Math.Cos(angle)), + (int)(center.Y + (radius + 4) * Math.Sin(angle)) + ); + } + g.DrawPolygon(new Pen(Color.Gold, 2) { DashStyle = DashStyle.Dash }, pulsePoints); + } + // 호버된 노드 강조 if (node == _hoveredNode) { @@ -643,7 +777,7 @@ namespace AGVNavigationCore.Controls // AGV 색상 결정 var brush = GetAGVBrush(state); - // AGV 사각형 그리기 + // AGV 개선된 원형 디자인 그리기 var rect = new Rectangle( position.X - AGV_SIZE / 2, position.Y - AGV_SIZE / 2, @@ -651,11 +785,82 @@ namespace AGVNavigationCore.Controls AGV_SIZE ); - g.FillRectangle(brush, rect); - g.DrawRectangle(_agvPen, rect); + // 방사형 그라디언트 효과를 위한 브러시 생성 + using (var gradientBrush = new System.Drawing.Drawing2D.PathGradientBrush( + new Point[] { + new Point(rect.Left, rect.Top), + new Point(rect.Right, rect.Top), + new Point(rect.Right, rect.Bottom), + new Point(rect.Left, rect.Bottom) + })) + { + gradientBrush.CenterPoint = new PointF(position.X, position.Y); + gradientBrush.CenterColor = GetAGVCenterColor(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( + position.X - innerSize / 2, + position.Y - innerSize / 2, + innerSize, + innerSize + ); + using (var innerPen = new Pen(GetAGVInnerRingColor(state), 2)) + { + g.DrawEllipse(innerPen, innerRect); + } + + // 중앙 상태 표시등 (더 크고 화려하게) + var indicatorSize = 10; + var indicatorRect = new Rectangle( + position.X - indicatorSize / 2, + position.Y - indicatorSize / 2, + indicatorSize, + indicatorSize + ); + + // 표시등 글로우 효과 + using (var glowBrush = new System.Drawing.Drawing2D.PathGradientBrush( + new Point[] { + new Point(indicatorRect.Left, indicatorRect.Top), + new Point(indicatorRect.Right, indicatorRect.Top), + new Point(indicatorRect.Right, indicatorRect.Bottom), + new Point(indicatorRect.Left, indicatorRect.Bottom) + })) + { + glowBrush.CenterPoint = new PointF(position.X, position.Y); + glowBrush.CenterColor = Color.White; + 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); + } - // 방향 표시 (화살표) - DrawAGVDirection(g, position, direction); + // 리프트 그리기 (이동 경로 기반) + DrawAGVLiftAdvanced(g, agv); + + // 모니터 그리기 (리프트 반대편) + DrawAGVMonitor(g, agv); // AGV ID 표시 var font = new Font("Arial", 10, FontStyle.Bold); @@ -700,35 +905,194 @@ namespace AGVNavigationCore.Controls } } - private void DrawAGVDirection(Graphics g, Point position, AgvDirection direction) - { - var arrowSize = 10; - Point[] arrowPoints = null; - switch (direction) + /// + /// 이동 경로 기반 개선된 리프트 그리기 (각도 기반 동적 디자인) + /// + private void DrawAGVLiftAdvanced(Graphics g, IAGV agv) + { + 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) { case AgvDirection.Forward: - arrowPoints = new Point[] - { - new Point(position.X + arrowSize, position.Y), - new Point(position.X - arrowSize/2, position.Y - arrowSize/2), - new Point(position.X - arrowSize/2, position.Y + arrowSize/2) - }; + liftColor = Color.Yellow; // 전진 - 노란색 (잘 보임) + borderColor = Color.Orange; break; case AgvDirection.Backward: - arrowPoints = new Point[] - { - new Point(position.X - arrowSize, position.Y), - new Point(position.X + arrowSize/2, position.Y - arrowSize/2), - new Point(position.X + arrowSize/2, position.Y + arrowSize/2) - }; + liftColor = Color.Cyan; // 후진 - 시안색 (잘 보임) + borderColor = Color.DarkCyan; + break; + default: + liftColor = Color.LightGray; // 방향 불명 - 회색 + borderColor = Color.Gray; break; } - - if (arrowPoints != null) + + // 경로 예측 결과에 따른 색상 조정 + if (liftInfo.CalculationMethod.Contains("경로 예측")) { - g.FillPolygon(Brushes.White, arrowPoints); - g.DrawPolygon(Pens.Black, arrowPoints); + // 경로 예측이 성공한 경우 더 선명한 색상 + if (currentDirection == AgvDirection.Forward) + { + liftColor = Color.Gold; // 전진 예측 - 골드 + borderColor = Color.DarkGoldenrod; + } + else if (currentDirection == AgvDirection.Backward) + { + liftColor = Color.DeepSkyBlue; // 후진 예측 - 딥 스카이 블루 + borderColor = Color.Navy; + } + } + else if (liftInfo.CalculationMethod.Contains("갈래길")) + { + // 갈래길에서는 약간 흐린 색상 + 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, // 중심 기준 왼쪽 + -liftWidth / 2, // 중심 기준 위쪽 + 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) + { + // 리프트 세부 표현 (방향 표시용 선들) + using (var detailPen = new Pen(Color.Gray, 1)) + { + int lineSpacing = 4; + for (int i = -liftLength / 2 + 3; i < liftLength / 2 - 3; i += lineSpacing) + { + 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); + } + } + else + { + // 방향을 모를 때 '?' 표시 + using (var questionBrush = new SolidBrush(Color.DarkGray)) + using (var questionFont = new Font("Arial", 9, FontStyle.Bold)) + { + var questionSize = g.MeasureString("?", questionFont); + var questionPoint = new PointF( + -questionSize.Width / 2, + -questionSize.Height / 2 + ); + g.DrawString("?", questionFont, questionBrush, questionPoint); + } + } + + // Graphics 상태 복원 + g.Transform = oldTransform; + } + + /// + /// 둥근 모서리 사각형 그리기 + /// + private void DrawRoundedRectangle(Graphics g, Brush fillBrush, Pen borderPen, Rectangle rect, int cornerRadius) + { + if (cornerRadius <= 0) + { + if (fillBrush != null) + g.FillRectangle(fillBrush, rect); + if (borderPen != null) + g.DrawRectangle(borderPen, rect); + return; + } + + using (var path = new System.Drawing.Drawing2D.GraphicsPath()) + { + // 둥근 모서리 경로 생성 + path.AddArc(rect.X, rect.Y, cornerRadius * 2, cornerRadius * 2, 180, 90); + path.AddArc(rect.Right - cornerRadius * 2, rect.Y, cornerRadius * 2, cornerRadius * 2, 270, 90); + path.AddArc(rect.Right - cornerRadius * 2, rect.Bottom - cornerRadius * 2, cornerRadius * 2, cornerRadius * 2, 0, 90); + path.AddArc(rect.X, rect.Bottom - cornerRadius * 2, cornerRadius * 2, cornerRadius * 2, 90, 90); + path.CloseFigure(); + + // 채우기와 테두리 그리기 (null 체크 추가) + if (fillBrush != null) + g.FillPath(fillBrush, path); + if (borderPen != null) + g.DrawPath(borderPen, path); + } + } + + private void DrawAGVLiftDebugInfo(Graphics g, IAGV agv) + { + var currentPos = agv.CurrentPosition; + var targetPos = agv.TargetPosition; + + // 디버그 정보 (개발용) + if (targetPos.HasValue) + { + // 이동 방향 벡터 그리기 (얇은 점선) + using (var debugPen = new Pen(Color.Red, 1) { DashStyle = System.Drawing.Drawing2D.DashStyle.Dot }) + { + g.DrawLine(debugPen, currentPos, targetPos.Value); + } } } @@ -740,6 +1104,289 @@ namespace AGVNavigationCore.Controls } } + /// + /// AGV 중앙 색상 (방사형 그라디언트) + /// + private Color GetAGVCenterColor(AGVState state) + { + switch (state) + { + case AGVState.Moving: return Color.White; + case AGVState.Charging: return Color.LightCyan; + case AGVState.Error: return Color.LightPink; + case AGVState.Docking: return Color.LightYellow; + default: return Color.WhiteSmoke; + } + } + + /// + /// AGV 외곽 색상 (방사형 그라디언트) + /// + private Color GetAGVOuterColor(AGVState state) + { + switch (state) + { + case AGVState.Moving: return Color.DarkGreen; + case AGVState.Charging: return Color.DarkBlue; + case AGVState.Error: return Color.DarkRed; + case AGVState.Docking: return Color.DarkOrange; + default: return Color.DarkGray; + } + } + + /// + /// AGV 내부 링 색상 + /// + private Color GetAGVInnerRingColor(AGVState state) + { + switch (state) + { + case AGVState.Moving: return Color.Green; + case AGVState.Charging: return Color.Blue; + case AGVState.Error: return Color.Red; + case AGVState.Docking: return Color.Orange; + default: return Color.Gray; + } + } + + /// + /// AGV 중앙 표시등 색상 + /// + private Color GetStatusIndicatorColor(AGVState state) + { + switch (state) + { + case AGVState.Moving: return Color.Lime; + case AGVState.Charging: return Color.Blue; + case AGVState.Error: return Color.Red; + case AGVState.Docking: return Color.Yellow; + default: return Color.Gray; + } + } + + /// + /// AGV 원형 센서 패턴 (방사형 배치) + /// + private void DrawCircularSensors(Graphics g, Point center, AGVState state) + { + 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, + sensorSize, + sensorSize + ); + + using (var sensorBrush = new SolidBrush(sensorColor)) + { + g.FillEllipse(sensorBrush, sensorRect); + g.DrawEllipse(new Pen(Color.Black, 1), sensorRect); + } + } + + // 내부 원형 패턴 (장식) + var innerRadius = AGV_SIZE / 2 - 12; + for (int i = 0; i < 4; i++) + { + 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))) + { + g.FillEllipse(dotBrush, dotRect); + } + } + } + + /// + /// AGV 모니터 그리기 (리프트 반대편) + /// + private void DrawAGVMonitor(Graphics g, IAGV agv) + { + 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); + + bool hasDirection = liftInfo != null; + double monitorAngle = hasDirection ? liftInfo.AngleRadians + Math.PI : 0; // 리프트 반대 방향 (180도 회전) + + // Graphics 변환 설정 + var oldTransform = g.Transform; + var transform = oldTransform.Clone(); + transform.Translate(currentPos.X, currentPos.Y); + transform.Rotate((float)(monitorAngle * 180.0 / Math.PI)); + g.Transform = transform; + + // 모니터 위치 (AGV 면에 붙도록) + var monitorRect = new Rectangle( + monitorDistance - monitorWidth / 2, + -monitorHeight / 2, + monitorWidth, + monitorHeight + ); + + if (hasDirection) + { + // 모니터 상태에 따른 색상 + 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, + monitorRect.Y + 2, + 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, + monitorHeight / 2 - 1, + 4, + 3 + ); + using (var standBrush = new SolidBrush(Color.Gray)) + { + g.FillRectangle(standBrush, standRect); + } + } + else + { + // 방향을 모를 때 기본 모니터 (스카이블루로) + using (var monitorBrush = new SolidBrush(Color.SkyBlue)) + using (var borderPen = new Pen(Color.DarkGray, 2)) + { + DrawRoundedRectangle(g, monitorBrush, borderPen, monitorRect, 2); + } + } + + // Graphics 상태 복원 + g.Transform = oldTransform; + } + + /// + /// 모니터 배경 색상 (스카이블루 계통으로 통일) + /// + private Color GetMonitorBackColor(AGVState state) + { + // 모든 상태에서 스카이블루 계통으로 통일하여 AGV와 구분 + return Color.SkyBlue; + } + + /// + /// 모니터 화면 색상 (상태별로 구분) + /// + private Color GetMonitorScreenColor(AGVState state) + { + switch (state) + { + case AGVState.Moving: return Color.Navy; // 이동: 네이비 + case AGVState.Charging: return Color.DarkBlue; // 충전: 다크블루 + case AGVState.Error: return Color.DarkRed; // 에러: 다크레드 + case AGVState.Docking: return Color.DarkGreen; // 도킹: 다크그린 + default: return Color.DarkSlateGray; // 대기: 다크슬레이트그레이 + } + } + + /// + /// 모니터 화면 패턴 그리기 + /// + private void DrawMonitorScreen(Graphics g, Rectangle screenRect, AGVState state) + { + using (var patternPen = new Pen(Color.White, 1)) + { + switch (state) + { + case AGVState.Moving: + // 이동 중: 작은 점들 (데이터 표시) + for (int i = 1; i < screenRect.Width; i += 3) + { + for (int j = 1; j < screenRect.Height; j += 3) + { + g.DrawRectangle(patternPen, screenRect.X + i, screenRect.Y + j, 1, 1); + } + } + break; + + case AGVState.Charging: + // 충전 중: 배터리 아이콘 + var batteryRect = new Rectangle( + screenRect.X + screenRect.Width / 2 - 3, + screenRect.Y + screenRect.Height / 2 - 2, + 6, + 4 + ); + 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, + screenRect.Right - 2, screenRect.Bottom - 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; + var centerY = screenRect.Y + screenRect.Height / 2; + g.DrawLine(patternPen, centerX - 4, centerY, centerX + 2, centerY); + g.DrawLine(patternPen, centerX, centerY - 2, centerX + 2, centerY); + g.DrawLine(patternPen, centerX, centerY + 2, centerX + 2, centerY); + break; + + default: + // 대기: 중앙 점 + g.DrawRectangle(patternPen, + screenRect.X + screenRect.Width / 2 - 1, + screenRect.Y + screenRect.Height / 2 - 1, + 2, 2); + break; + } + } + } + private void DrawUIInfo(Graphics g) { // 회사 로고 diff --git a/Cs_HMI/AGVNavigationCore/Controls/UnifiedAGVCanvas.cs b/Cs_HMI/AGVNavigationCore/Controls/UnifiedAGVCanvas.cs index 91e0788..d9c30ac 100644 --- a/Cs_HMI/AGVNavigationCore/Controls/UnifiedAGVCanvas.cs +++ b/Cs_HMI/AGVNavigationCore/Controls/UnifiedAGVCanvas.cs @@ -22,7 +22,7 @@ namespace AGVNavigationCore.Controls private const int GRID_SIZE = 20; private const float CONNECTION_WIDTH = 2.0f; private const int SNAP_DISTANCE = 10; - private const int AGV_SIZE = 30; + private const int AGV_SIZE = 40; private const int CONNECTION_ARROW_SIZE = 8; #endregion @@ -64,6 +64,7 @@ namespace AGVNavigationCore.Controls private List _nodes; private MapNode _selectedNode; private MapNode _hoveredNode; + private MapNode _destinationNode; // AGV 관련 private List _agvList; @@ -105,6 +106,7 @@ namespace AGVNavigationCore.Controls private Brush _chargingNodeBrush; private Brush _selectedNodeBrush; private Brush _hoveredNodeBrush; + private Brush _destinationNodeBrush; private Brush _gridBrush; private Brush _agvBrush; private Brush _pathBrush; @@ -113,6 +115,7 @@ namespace AGVNavigationCore.Controls private Pen _gridPen; private Pen _tempConnectionPen; private Pen _selectedNodePen; + private Pen _destinationNodePen; private Pen _pathPen; private Pen _agvPen; @@ -239,6 +242,7 @@ namespace AGVNavigationCore.Controls set { _currentPath = value; + UpdateDestinationNode(); Invalidate(); } } @@ -324,6 +328,7 @@ namespace AGVNavigationCore.Controls _chargingNodeBrush = new SolidBrush(Color.Green); _selectedNodeBrush = new SolidBrush(Color.Red); _hoveredNodeBrush = new SolidBrush(Color.LightCyan); + _destinationNodeBrush = new SolidBrush(Color.Gold); // AGV 및 경로 브러쉬 _agvBrush = new SolidBrush(Color.Red); @@ -339,6 +344,7 @@ namespace AGVNavigationCore.Controls _gridPen = new Pen(Color.LightGray, 1); _tempConnectionPen = new Pen(Color.Orange, 2) { DashStyle = DashStyle.Dash }; _selectedNodePen = new Pen(Color.Red, 3); + _destinationNodePen = new Pen(Color.Orange, 4); _pathPen = new Pen(Color.Purple, 3); _agvPen = new Pen(Color.Red, 3); } @@ -464,6 +470,20 @@ namespace AGVNavigationCore.Controls Invalidate(); } + private void UpdateDestinationNode() + { + _destinationNode = null; + + if (_currentPath != null && _currentPath.Success && _currentPath.Path != null && _currentPath.Path.Count > 0) + { + // 경로의 마지막 노드가 목적지 + string destinationNodeId = _currentPath.Path[_currentPath.Path.Count - 1]; + + // 노드 목록에서 해당 노드 찾기 + _destinationNode = _nodes?.FirstOrDefault(n => n.NodeId == destinationNodeId); + } + } + #endregion #region Cleanup @@ -480,6 +500,7 @@ namespace AGVNavigationCore.Controls _chargingNodeBrush?.Dispose(); _selectedNodeBrush?.Dispose(); _hoveredNodeBrush?.Dispose(); + _destinationNodeBrush?.Dispose(); _gridBrush?.Dispose(); _agvBrush?.Dispose(); _pathBrush?.Dispose(); @@ -489,6 +510,7 @@ namespace AGVNavigationCore.Controls _gridPen?.Dispose(); _tempConnectionPen?.Dispose(); _selectedNodePen?.Dispose(); + _destinationNodePen?.Dispose(); _pathPen?.Dispose(); _agvPen?.Dispose(); @@ -517,6 +539,12 @@ namespace AGVNavigationCore.Controls AgvDirection CurrentDirection { get; } AGVState CurrentState { get; } float BatteryLevel { get; } + + // 이동 경로 정보 추가 + Point? TargetPosition { get; } + string CurrentNodeId { get; } + string TargetNodeId { get; } + DockingDirection DockingDirection { get; } } /// diff --git a/Cs_HMI/AGVNavigationCore/PathFinding/AGVPathResult.cs b/Cs_HMI/AGVNavigationCore/PathFinding/AGVPathResult.cs index 33a5b0a..a65f01f 100644 --- a/Cs_HMI/AGVNavigationCore/PathFinding/AGVPathResult.cs +++ b/Cs_HMI/AGVNavigationCore/PathFinding/AGVPathResult.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using AGVNavigationCore.Models; namespace AGVNavigationCore.PathFinding @@ -24,6 +25,11 @@ namespace AGVNavigationCore.PathFinding /// public List Commands { get; set; } + /// + /// 노드별 모터방향 정보 목록 + /// + public List NodeMotorInfos { get; set; } + /// /// 총 거리 /// @@ -57,6 +63,7 @@ namespace AGVNavigationCore.PathFinding Success = false; Path = new List(); Commands = new List(); + NodeMotorInfos = new List(); TotalDistance = 0; CalculationTimeMs = 0; EstimatedTimeSeconds = 0; @@ -87,6 +94,31 @@ namespace AGVNavigationCore.PathFinding return result; } + /// + /// 성공 결과 생성 (노드별 모터방향 정보 포함) + /// + /// 경로 + /// AGV 명령어 목록 + /// 노드별 모터방향 정보 + /// 총 거리 + /// 계산 시간 + /// 성공 결과 + public static AGVPathResult CreateSuccess(List path, List commands, List nodeMotorInfos, float totalDistance, long calculationTimeMs) + { + var result = new AGVPathResult + { + Success = true, + Path = new List(path), + Commands = new List(commands), + NodeMotorInfos = new List(nodeMotorInfos), + TotalDistance = totalDistance, + CalculationTimeMs = calculationTimeMs + }; + + result.CalculateMetrics(); + return result; + } + /// /// 실패 결과 생성 /// diff --git a/Cs_HMI/AGVNavigationCore/PathFinding/AGVPathfinder.cs b/Cs_HMI/AGVNavigationCore/PathFinding/AGVPathfinder.cs index 9aa0342..70c5d30 100644 --- a/Cs_HMI/AGVNavigationCore/PathFinding/AGVPathfinder.cs +++ b/Cs_HMI/AGVNavigationCore/PathFinding/AGVPathfinder.cs @@ -159,7 +159,8 @@ namespace AGVNavigationCore.PathFinding } var agvCommands = GenerateAGVCommands(result.Path, targetDirection ?? AgvDirection.Forward); - return AGVPathResult.CreateSuccess(result.Path, agvCommands, result.TotalDistance, stopwatch.ElapsedMilliseconds); + var nodeMotorInfos = GenerateNodeMotorInfos(result.Path); + return AGVPathResult.CreateSuccess(result.Path, agvCommands, nodeMotorInfos, result.TotalDistance, stopwatch.ElapsedMilliseconds); } /// @@ -182,7 +183,8 @@ namespace AGVNavigationCore.PathFinding } var agvCommands = GenerateAGVCommands(result.Path, actualTargetDirection); - return AGVPathResult.CreateSuccess(result.Path, agvCommands, result.TotalDistance, stopwatch.ElapsedMilliseconds); + var nodeMotorInfos = GenerateNodeMotorInfos(result.Path); + return AGVPathResult.CreateSuccess(result.Path, agvCommands, nodeMotorInfos, result.TotalDistance, stopwatch.ElapsedMilliseconds); } /// @@ -264,6 +266,92 @@ namespace AGVNavigationCore.PathFinding return AgvDirection.Right; } + /// + /// 노드별 모터방향 정보 생성 + /// + /// 경로 노드 목록 + /// 노드별 모터방향 정보 목록 + private List GenerateNodeMotorInfos(List path) + { + var nodeMotorInfos = new List(); + if (path.Count < 2) return nodeMotorInfos; + + for (int i = 0; i < path.Count; i++) + { + var currentNodeId = path[i]; + string nextNodeId = i < path.Count - 1 ? path[i + 1] : null; + + AgvDirection motorDirection; + + if (i == path.Count - 1) + { + // 마지막 노드: 도킹/충전 노드 타입에 따라 결정 + if (_nodeMap.ContainsKey(currentNodeId)) + { + var currentNode = _nodeMap[currentNodeId]; + motorDirection = GetRequiredDirectionForNode(currentNode); + } + else + { + motorDirection = AgvDirection.Forward; + } + } + else + { + // 중간 노드: 다음 노드와의 관계를 고려한 모터방향 결정 + motorDirection = CalculateMotorDirection(currentNodeId, nextNodeId); + } + + nodeMotorInfos.Add(new NodeMotorInfo(currentNodeId, motorDirection, nextNodeId)); + } + + return nodeMotorInfos; + } + + /// + /// 현재 노드에서 다음 노드로 이동할 때의 모터방향 계산 + /// + /// 현재 노드 ID + /// 다음 노드 ID + /// 모터방향 + private AgvDirection CalculateMotorDirection(string currentNodeId, string nextNodeId) + { + if (!_nodeMap.ContainsKey(currentNodeId) || !_nodeMap.ContainsKey(nextNodeId)) + { + return AgvDirection.Forward; + } + + var currentNode = _nodeMap[currentNodeId]; + var nextNode = _nodeMap[nextNodeId]; + + // 현재 노드와 다음 노드의 위치를 기반으로 이동 방향 계산 + var dx = nextNode.Position.X - currentNode.Position.X; + var dy = nextNode.Position.Y - currentNode.Position.Y; + var moveAngle = Math.Atan2(dy, dx); + + // AGV의 구조: 리프트 ↔ AGV 몸체 ↔ 모니터 + // 전진: 모니터 방향으로 이동 (리프트에서 멀어짐) + // 후진: 리프트 방향으로 이동 (리프트에 가까워짐) + + // 다음 노드가 특수 노드인지 확인 + if (nextNode.Type == NodeType.Charging) + { + // 충전기: 전진으로 도킹 + return AgvDirection.Forward; + } + else if (nextNode.Type == NodeType.Docking) + { + // 도킹 스테이션: 후진으로 도킹 + return AgvDirection.Backward; + } + else + { + // 일반 이동: 기본적으로 전진 + // 향후 경로 패턴 분석을 통해 더 정확한 방향 결정 가능 + return AgvDirection.Forward; + } + } + /// /// 경로 유효성 검증 /// diff --git a/Cs_HMI/AGVNavigationCore/PathFinding/NodeMotorInfo.cs b/Cs_HMI/AGVNavigationCore/PathFinding/NodeMotorInfo.cs new file mode 100644 index 0000000..387de43 --- /dev/null +++ b/Cs_HMI/AGVNavigationCore/PathFinding/NodeMotorInfo.cs @@ -0,0 +1,32 @@ +using AGVNavigationCore.Models; + +namespace AGVNavigationCore.PathFinding +{ + /// + /// 노드별 모터방향 정보 + /// + public class NodeMotorInfo + { + /// + /// 노드 ID + /// + public string NodeId { get; set; } + + /// + /// 해당 노드에서의 모터방향 + /// + public AgvDirection MotorDirection { get; set; } + + /// + /// 다음 노드 ID (경로예측용) + /// + public string NextNodeId { get; set; } + + public NodeMotorInfo(string nodeId, AgvDirection motorDirection, string nextNodeId = null) + { + NodeId = nodeId; + MotorDirection = motorDirection; + NextNodeId = nextNodeId; + } + } +} \ No newline at end of file diff --git a/Cs_HMI/AGVNavigationCore/Utils/LiftCalculator.cs b/Cs_HMI/AGVNavigationCore/Utils/LiftCalculator.cs new file mode 100644 index 0000000..640b043 --- /dev/null +++ b/Cs_HMI/AGVNavigationCore/Utils/LiftCalculator.cs @@ -0,0 +1,277 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using AGVNavigationCore.Models; + +namespace AGVNavigationCore.Utils +{ + /// + /// AGV 리프트 방향 계산 유틸리티 클래스 + /// 모든 리프트 방향 계산 로직을 중앙화하여 일관성 보장 + /// + public static class LiftCalculator + { + /// + /// AGV 이동 방향과 모터 방향을 기반으로 리프트 각도 계산 + /// + /// 현재 위치 + /// 목표 위치 + /// 모터 방향 + /// 리프트 각도 (라디안) + public static double CalculateLiftAngleRadians(Point currentPos, Point targetPos, AgvDirection motorDirection) + { + // 모터 방향에 따른 리프트 위치 계산 + if (motorDirection == AgvDirection.Forward) + { + // 전진 모터: AGV가 앞으로 가므로 리프트는 뒤쪽 (타겟 → 현재 방향) + var dx = currentPos.X - targetPos.X; + var dy = currentPos.Y - targetPos.Y; + return Math.Atan2(dy, dx); + } + else if (motorDirection == AgvDirection.Backward) + { + // 후진 모터: AGV가 리프트 쪽으로 이동 (현재 → 타겟 방향이 리프트 방향) + var dx = targetPos.X - currentPos.X; + var dy = targetPos.Y - currentPos.Y; + return Math.Atan2(dy, dx); + } + else + { + // 기본값: 전진 모터와 동일 + var dx = currentPos.X - targetPos.X; + var dy = currentPos.Y - targetPos.Y; + return Math.Atan2(dy, dx); + } + } + + /// + /// AGV 이동 방향과 모터 방향을 기반으로 리프트 각도 계산 (도 단위) + /// + /// 현재 위치 + /// 목표 위치 + /// 모터 방향 + /// 리프트 각도 (도) + public static double CalculateLiftAngleDegrees(Point currentPos, Point targetPos, AgvDirection motorDirection) + { + var radians = CalculateLiftAngleRadians(currentPos, targetPos, motorDirection); + var degrees = radians * 180.0 / Math.PI; + + // 0-360도 범위로 정규화 + while (degrees < 0) degrees += 360; + while (degrees >= 360) degrees -= 360; + + return degrees; + } + + /// + /// 각도를 8방향 문자열로 변환 (화면 좌표계 기준) + /// 화면 좌표계: 0°=동쪽, 90°=남쪽, 180°=서쪽, 270°=북쪽 + /// + /// 각도 (도) + /// 방향 문자열 + public static string AngleToDirectionString(double angleDegrees) + { + // 0-360도 범위로 정규화 + while (angleDegrees < 0) angleDegrees += 360; + while (angleDegrees >= 360) angleDegrees -= 360; + + // 8방향으로 분류 (화면 좌표계) + if (angleDegrees >= 337.5 || angleDegrees < 22.5) + return "동쪽(→)"; + else if (angleDegrees >= 22.5 && angleDegrees < 67.5) + return "남동쪽(↘)"; + else if (angleDegrees >= 67.5 && angleDegrees < 112.5) + return "남쪽(↓)"; + else if (angleDegrees >= 112.5 && angleDegrees < 157.5) + return "남서쪽(↙)"; + else if (angleDegrees >= 157.5 && angleDegrees < 202.5) + return "서쪽(←)"; + else if (angleDegrees >= 202.5 && angleDegrees < 247.5) + return "북서쪽(↖)"; + else if (angleDegrees >= 247.5 && angleDegrees < 292.5) + return "북쪽(↑)"; + else if (angleDegrees >= 292.5 && angleDegrees < 337.5) + return "북동쪽(↗)"; + else + return "알 수 없음"; + } + + /// + /// 리프트 계산 결과 정보 + /// + public class LiftCalculationResult + { + public double AngleRadians { get; set; } + public double AngleDegrees { get; set; } + public string DirectionString { get; set; } + public string CalculationMethod { get; set; } + public AgvDirection MotorDirection { get; set; } + } + + /// + /// 종합적인 리프트 계산 (모든 정보 포함) + /// + /// 현재 위치 + /// 목표 위치 + /// 모터 방향 + /// 리프트 계산 결과 + public static LiftCalculationResult CalculateLiftInfo(Point currentPos, Point targetPos, AgvDirection motorDirection) + { + var angleRadians = CalculateLiftAngleRadians(currentPos, targetPos, motorDirection); + var angleDegrees = angleRadians * 180.0 / Math.PI; + + // 0-360도 범위로 정규화 + while (angleDegrees < 0) angleDegrees += 360; + while (angleDegrees >= 360) angleDegrees -= 360; + + var directionString = AngleToDirectionString(angleDegrees); + + string calculationMethod; + if (motorDirection == AgvDirection.Forward) + calculationMethod = "이동방향 + 180도 (전진모터)"; + else if (motorDirection == AgvDirection.Backward) + calculationMethod = "이동방향과 동일 (후진모터)"; + else + calculationMethod = "기본값 (전진모터)"; + + return new LiftCalculationResult + { + AngleRadians = angleRadians, + AngleDegrees = angleDegrees, + DirectionString = directionString, + CalculationMethod = calculationMethod, + MotorDirection = motorDirection + }; + } + + /// + /// 경로 예측 기반 리프트 방향 계산 + /// 현재 노드에서 연결된 다음 노드들을 분석하여 리프트 방향 결정 + /// + /// 현재 위치 + /// 이전 위치 + /// 모터 방향 + /// 맵 노드 리스트 (경로 예측용) + /// 위치 허용 오차 + /// 리프트 계산 결과 + public static LiftCalculationResult CalculateLiftInfoWithPathPrediction( + Point currentPos, Point previousPos, AgvDirection motorDirection, + List mapNodes, int tolerance = 10) + { + if (mapNodes == null || mapNodes.Count == 0) + { + // 맵 노드 정보가 없으면 기존 방식 사용 + return CalculateLiftInfo(previousPos, currentPos, motorDirection); + } + + // 현재 위치에 해당하는 노드 찾기 + var currentNode = FindNodeByPosition(mapNodes, currentPos, tolerance); + + if (currentNode == null) + { + // 현재 노드를 찾을 수 없으면 기존 방식 사용 + return CalculateLiftInfo(previousPos, currentPos, motorDirection); + } + + // 이전 위치에 해당하는 노드 찾기 + var previousNode = FindNodeByPosition(mapNodes, previousPos, tolerance); + + Point targetPosition; + string calculationMethod; + + // 모터 방향에 따른 예측 방향 결정 + if (motorDirection == AgvDirection.Backward) + { + // 후진 모터: AGV가 리프트 쪽(목표 위치)으로 이동 + // 경로 예측 없이 단순히 현재→목표 방향 사용 + return CalculateLiftInfo(currentPos, previousPos, motorDirection); + } + else + { + // 전진 모터: 기존 로직 (다음 노드 예측) + var nextNodes = GetConnectedNodes(mapNodes, currentNode); + + // 이전 노드 제외 (되돌아가는 방향 제외) + if (previousNode != null) + { + nextNodes = nextNodes.Where(n => n.NodeId != previousNode.NodeId).ToList(); + } + + if (nextNodes.Count == 1) + { + // 직선 경로: 다음 노드 방향으로 예측 + targetPosition = nextNodes.First().Position; + calculationMethod = $"전진 경로 예측 ({currentNode.NodeId}→{nextNodes.First().NodeId})"; + } + else if (nextNodes.Count > 1) + { + // 갈래길: 이전 위치 기반 계산 사용 + var prevResult = CalculateLiftInfo(previousPos, currentPos, motorDirection); + prevResult.CalculationMethod += " (전진 갈래길)"; + return prevResult; + } + else + { + // 연결된 노드가 없으면 기존 방식 사용 + return CalculateLiftInfo(previousPos, currentPos, motorDirection); + } + } + + // 리프트 각도 계산 + var angleRadians = CalculateLiftAngleRadians(currentPos, targetPosition, motorDirection); + var angleDegrees = angleRadians * 180.0 / Math.PI; + + // 0-360도 범위로 정규화 + while (angleDegrees < 0) angleDegrees += 360; + while (angleDegrees >= 360) angleDegrees -= 360; + + var directionString = AngleToDirectionString(angleDegrees); + + return new LiftCalculationResult + { + AngleRadians = angleRadians, + AngleDegrees = angleDegrees, + DirectionString = directionString, + CalculationMethod = calculationMethod, + MotorDirection = motorDirection + }; + } + + /// + /// 위치 기반 노드 찾기 + /// + /// 맵 노드 리스트 + /// 찾을 위치 + /// 허용 오차 + /// 해당하는 노드 또는 null + private static MapNode FindNodeByPosition(List mapNodes, Point position, int tolerance) + { + return mapNodes.FirstOrDefault(node => + Math.Abs(node.Position.X - position.X) <= tolerance && + Math.Abs(node.Position.Y - position.Y) <= tolerance); + } + + /// + /// 노드에서 연결된 다른 노드들 찾기 + /// + /// 맵 노드 리스트 + /// 현재 노드 + /// 연결된 노드 리스트 + private static List GetConnectedNodes(List mapNodes, MapNode currentNode) + { + var connectedNodes = new List(); + + foreach (var nodeId in currentNode.ConnectedNodes) + { + var connectedNode = mapNodes.FirstOrDefault(n => n.NodeId == nodeId); + if (connectedNode != null) + { + connectedNodes.Add(connectedNode); + } + } + + return connectedNodes; + } + } +} \ No newline at end of file diff --git a/Cs_HMI/AGVSimulator/Forms/SimulatorForm.cs b/Cs_HMI/AGVSimulator/Forms/SimulatorForm.cs index 299d7c1..9e70878 100644 --- a/Cs_HMI/AGVSimulator/Forms/SimulatorForm.cs +++ b/Cs_HMI/AGVSimulator/Forms/SimulatorForm.cs @@ -8,6 +8,7 @@ using System.Windows.Forms; using AGVMapEditor.Models; using AGVNavigationCore.Models; using AGVNavigationCore.Controls; +using AGVNavigationCore.PathFinding; using AGVSimulator.Models; using Newtonsoft.Json; @@ -59,21 +60,24 @@ namespace AGVSimulator.Forms { // 설정 로드 _config = SimulatorConfig.Load(); - + // 데이터 초기화 _mapNodes = new List(); _agvList = new List(); _simulationState = new SimulationState(); _currentMapFilePath = string.Empty; - + // 시뮬레이터 캔버스 생성 (중앙 패널에만) CreateSimulatorCanvas(); - + // 타이머 초기화 _simulationTimer = new Timer(); _simulationTimer.Interval = 100; // 100ms 간격 _simulationTimer.Tick += OnSimulationTimer_Tick; - + + // 방향 콤보박스 초기화 + InitializeDirectionCombo(); + // 초기 상태 설정 UpdateUI(); } @@ -85,7 +89,7 @@ namespace AGVSimulator.Forms _simulatorCanvas = new UnifiedAGVCanvas(); _simulatorCanvas.Dock = DockStyle.Fill; _simulatorCanvas.Mode = UnifiedAGVCanvas.CanvasMode.ViewOnly; - + _canvasPanel.Controls.Add(_simulatorCanvas); } @@ -95,6 +99,24 @@ namespace AGVSimulator.Forms _canvasPanel.BringToFront(); } + /// + /// 모터 구동방향 콤보박스 초기화 + /// + private void InitializeDirectionCombo() + { + _directionCombo.Items.Clear(); + + // AgvDirection enum 값들을 콤보박스에 추가 + _directionCombo.Items.Add(new DirectionItem(AgvDirection.Forward, "전진 (모니터쪽)")); + _directionCombo.Items.Add(new DirectionItem(AgvDirection.Backward, "후진 (리프트쪽)")); + _directionCombo.Items.Add(new DirectionItem(AgvDirection.Left, "좌회전")); + _directionCombo.Items.Add(new DirectionItem(AgvDirection.Right, "우회전")); + _directionCombo.Items.Add(new DirectionItem(AgvDirection.Stop, "정지")); + + // 기본 선택: 전진 + _directionCombo.SelectedIndex = 0; + } + #endregion #region Event Handlers @@ -105,7 +127,7 @@ namespace AGVSimulator.Forms { openDialog.Filter = "AGV Map Files (*.agvmap)|*.agvmap|모든 파일 (*.*)|*.*"; openDialog.Title = "맵 파일 열기"; - + if (openDialog.ShowDialog() == DialogResult.OK) { try @@ -115,7 +137,7 @@ namespace AGVSimulator.Forms } catch (Exception ex) { - MessageBox.Show($"맵 파일을 로드할 수 없습니다:\n{ex.Message}", "오류", + MessageBox.Show($"맵 파일을 로드할 수 없습니다:\n{ex.Message}", "오류", MessageBoxButtons.OK, MessageBoxIcon.Error); } } @@ -131,10 +153,11 @@ namespace AGVSimulator.Forms { if (_simulationState.IsRunning) return; - + _simulationState.IsRunning = true; _simulationTimer.Start(); _statusLabel.Text = "시뮬레이션 실행 중"; + Console.WriteLine("시뮬레이션 실행"); UpdateUI(); } @@ -142,16 +165,17 @@ namespace AGVSimulator.Forms { if (!_simulationState.IsRunning) return; - + _simulationState.IsRunning = false; _simulationTimer.Stop(); _statusLabel.Text = "시뮬레이션 정지"; + Console.WriteLine("시뮬레이션 정지"); UpdateUI(); } private void OnReset_Click(object sender, EventArgs e) { - + } private void OnFitToMap_Click(object sender, EventArgs e) @@ -166,7 +190,7 @@ namespace AGVSimulator.Forms private void OnAbout_Click(object sender, EventArgs e) { - MessageBox.Show("AGV 시뮬레이터 v1.0\n\nENIG AGV 시스템용 시뮬레이터", "정보", + MessageBox.Show("AGV 시뮬레이터 v1.0\n\nENIG AGV 시스템용 시뮬레이터", "정보", MessageBoxButtons.OK, MessageBoxIcon.Information); } @@ -177,17 +201,26 @@ namespace AGVSimulator.Forms MessageBox.Show("먼저 맵을 로드해주세요.", "알림", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } - + var agvId = $"AGV{_agvList.Count + 1:D2}"; var startPosition = _mapNodes.First().Position; // 첫 번째 노드에서 시작 - + var newAGV = new VirtualAGV(agvId, startPosition); _agvList.Add(newAGV); _simulatorCanvas.AGVList = new List(_agvList.Cast()); - + + // 콘솔 출력 + + Program.WriteLine($"[SYSTEM] AGV 추가:"); + Program.WriteLine($" AGV ID: {agvId}"); + Program.WriteLine($" 시작 위치: ({startPosition.X}, {startPosition.Y})"); + Program.WriteLine($" 총 AGV 수: {_agvList.Count}"); + Program.WriteLine(""); + + UpdateAGVComboBox(); UpdateUI(); - + _statusLabel.Text = $"{agvId} 추가됨"; } @@ -195,16 +228,23 @@ namespace AGVSimulator.Forms { if (_agvListCombo.SelectedItem == null) return; - + var selectedAGV = _agvListCombo.SelectedItem as VirtualAGV; if (selectedAGV != null) { _agvList.Remove(selectedAGV); _simulatorCanvas.AGVList = new List(_agvList.Cast()); - + + // 콘솔 출력 + Console.WriteLine($"[SYSTEM] AGV 제거:"); + Console.WriteLine($" AGV ID: {selectedAGV.AgvId}"); + Console.WriteLine($" 남은 AGV 수: {_agvList.Count}"); + Console.WriteLine(""); + + UpdateAGVComboBox(); UpdateUI(); - + _statusLabel.Text = $"{selectedAGV.AgvId} 제거됨"; } } @@ -221,27 +261,41 @@ namespace AGVSimulator.Forms MessageBox.Show("시작 RFID와 목표 RFID를 선택해주세요.", "알림", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } + + var startItem = _startNodeCombo.SelectedItem as ComboBoxItem; + var targetItem = _targetNodeCombo.SelectedItem as ComboBoxItem; + var startNode = startItem?.Value; + var targetNode = targetItem?.Value; - var startNode = _startNodeCombo.SelectedItem as MapNode; - var targetNode = _targetNodeCombo.SelectedItem as MapNode; - + if (startNode == null || targetNode == null) + { + MessageBox.Show("선택한 노드 정보가 올바르지 않습니다.", "오류", MessageBoxButtons.OK, MessageBoxIcon.Error); + return; + } + if (_pathCalculator == null) { _pathCalculator = new PathCalculator(); _pathCalculator.SetMapData(_mapNodes); } - + var agvResult = _pathCalculator.FindAGVPath(startNode.NodeId, targetNode.NodeId); - + if (agvResult.Success) { _simulatorCanvas.CurrentPath = agvResult.ToPathResult(); _pathLengthLabel.Text = $"경로 길이: {agvResult.TotalDistance:F1}"; _statusLabel.Text = $"경로 계산 완료 ({agvResult.CalculationTimeMs}ms)"; + + // 경로 디버깅 정보 표시 + UpdatePathDebugInfo(agvResult); } else { - MessageBox.Show($"경로를 찾을 수 없습니다:\n{agvResult.ErrorMessage}", "경로 계산 실패", + // 경로 실패시 디버깅 정보 초기화 + _pathDebugLabel.Text = $"경로: 실패 - {agvResult.ErrorMessage}"; + + MessageBox.Show($"경로를 찾을 수 없습니다:\n{agvResult.ErrorMessage}", "경로 계산 실패", MessageBoxButtons.OK, MessageBoxIcon.Warning); } } @@ -254,13 +308,13 @@ namespace AGVSimulator.Forms MessageBox.Show("AGV를 선택해주세요.", "알림", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } - + if (_simulatorCanvas.CurrentPath == null || !_simulatorCanvas.CurrentPath.Success) { MessageBox.Show("먼저 경로를 계산해주세요.", "알림", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } - + selectedAGV.StartPath(_simulatorCanvas.CurrentPath, _mapNodes); _statusLabel.Text = $"{selectedAGV.AgvId} 경로 시작"; } @@ -275,6 +329,7 @@ namespace AGVSimulator.Forms private void OnSetPosition_Click(object sender, EventArgs e) { SetAGVPositionByRfid(); + _simulatorCanvas.FitToNodes(); } private void OnRfidTextBox_KeyPress(object sender, KeyPressEventArgs e) @@ -282,6 +337,7 @@ namespace AGVSimulator.Forms if (e.KeyChar == (char)Keys.Enter) { SetAGVPositionByRfid(); + _simulatorCanvas.FitToNodes(); e.Handled = true; } } @@ -318,17 +374,53 @@ namespace AGVSimulator.Forms var targetNode = _mapNodes?.FirstOrDefault(n => n.RfidId.Equals(rfidId, StringComparison.OrdinalIgnoreCase)); if (targetNode == null) { - MessageBox.Show($"RFID '{rfidId}'에 해당하는 노드를 찾을 수 없습니다.\n\n사용 가능한 RFID 목록:\n{GetAvailableRfidList()}", + MessageBox.Show($"RFID '{rfidId}'에 해당하는 노드를 찾을 수 없습니다.\n\n사용 가능한 RFID 목록:\n{GetAvailableRfidList()}", "RFID 찾기 실패", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } - // AGV 위치 설정 + // 선택된 방향 확인 + var selectedDirectionItem = _directionCombo.SelectedItem as DirectionItem; + var selectedDirection = selectedDirectionItem?.Direction ?? AgvDirection.Forward; + + // 콘솔 출력 (상세한 리프트 방향 계산 과정) + + Program.WriteLine($"[AGV-{selectedAGV.AgvId}] 위치 설정:"); + Program.WriteLine($" RFID: {rfidId} → 노드: {targetNode.NodeId}"); + Program.WriteLine($" 새로운 위치: ({targetNode.Position.X}, {targetNode.Position.Y})"); + Program.WriteLine($" 모터 방향: {selectedDirectionItem?.DisplayText ?? "전진"} ({selectedDirection})"); + + // SetPosition 호출 전 상태 + var oldTargetPos = selectedAGV.TargetPosition; + var oldCurrentPos = selectedAGV.CurrentPosition; + Program.WriteLine($" [BEFORE] 현재 CurrentPosition: ({oldCurrentPos.X}, {oldCurrentPos.Y})"); + Program.WriteLine($" [BEFORE] 이전 TargetPosition: {(oldTargetPos.HasValue ? $"({oldTargetPos.Value.X}, {oldTargetPos.Value.Y})" : "None")}"); + + + // AGV 위치 및 방향 설정 _simulatorCanvas.SetAGVPosition(selectedAGV.AgvId, targetNode.Position); - - _statusLabel.Text = $"{selectedAGV.AgvId} 위치를 RFID '{rfidId}' (노드: {targetNode.NodeId})로 설정했습니다."; + _simulatorCanvas.UpdateAGVDirection(selectedAGV.AgvId, selectedDirection); + + // VirtualAGV 객체의 위치와 방향 업데이트 + selectedAGV.SetPosition(targetNode.Position); // 이전 위치 기억하도록 + selectedAGV.SetDirection(selectedDirection); + + // SetPosition 호출 후 상태 확인 및 리프트 계산 + + var newTargetPos = selectedAGV.TargetPosition; + var newCurrentPos = selectedAGV.CurrentPosition; + Program.WriteLine($" [AFTER] 새로운 CurrentPosition: ({newCurrentPos.X}, {newCurrentPos.Y})"); + Program.WriteLine($" [AFTER] 새로운 TargetPosition: {(newTargetPos.HasValue ? $"({newTargetPos.Value.X}, {newTargetPos.Value.Y})" : "None")}"); + + // 리프트 방향 계산 과정 상세 출력 + Program.WriteLine($" [LIFT] 리프트 방향 계산:"); + CalculateLiftDirectionDetailed(selectedAGV); + Program.WriteLine(""); + + + _statusLabel.Text = $"{selectedAGV.AgvId} 위치를 RFID '{rfidId}' (노드: {targetNode.NodeId}), 방향: {selectedDirectionItem?.DisplayText ?? "전진"}로 설정했습니다."; _rfidTextBox.Text = ""; // 입력 필드 초기화 - + // 시뮬레이터 캔버스의 해당 노드로 이동 _simulatorCanvas.PanToNode(targetNode.NodeId); } @@ -345,10 +437,10 @@ namespace AGVSimulator.Forms // 처음 10개의 RFID만 표시 var rfidList = nodesWithRfid.Take(10).Select(n => $"- {n.RfidId} → {n.NodeId}"); var result = string.Join("\n", rfidList); - + if (nodesWithRfid.Count > 10) result += $"\n... 외 {nodesWithRfid.Count - 10}개"; - + return result; } @@ -357,29 +449,31 @@ namespace AGVSimulator.Forms try { var result = MapLoader.LoadMapFromFile(filePath); - + if (result.Success) { + Console.WriteLine($"Map File Load : {filePath}"); + _mapNodes = result.Nodes; _currentMapFilePath = filePath; - + // RFID가 없는 노드들에 자동 할당 MapLoader.AssignAutoRfidIds(_mapNodes); - + // 시뮬레이터 캔버스에 맵 설정 _simulatorCanvas.Nodes = _mapNodes; - + // 설정에 마지막 맵 파일 경로 저장 _config.LastMapFilePath = filePath; if (_config.AutoSave) { _config.Save(); } - + // UI 업데이트 UpdateNodeComboBoxes(); UpdateUI(); - + // 맵에 맞춤 _simulatorCanvas.FitToNodes(); } @@ -398,27 +492,31 @@ namespace AGVSimulator.Forms { _startNodeCombo.Items.Clear(); _targetNodeCombo.Items.Clear(); - + if (_mapNodes != null) { foreach (var node in _mapNodes) { if (node.IsActive && node.HasRfid()) { - _startNodeCombo.Items.Add(node); - _targetNodeCombo.Items.Add(node); + // {rfid} - [{node}] 형식으로 ComboBoxItem 생성 + var displayText = $"{node.RfidId} - [{node.NodeId}]"; + var item = new ComboBoxItem(node, displayText); + + _startNodeCombo.Items.Add(item); + _targetNodeCombo.Items.Add(item); } } } - - _startNodeCombo.DisplayMember = "RfidId"; - _targetNodeCombo.DisplayMember = "RfidId"; + + _startNodeCombo.DisplayMember = "DisplayText"; + _targetNodeCombo.DisplayMember = "DisplayText"; } private void UpdateAGVComboBox() { _agvListCombo.Items.Clear(); - + if (_agvList != null) { foreach (var agv in _agvList) @@ -426,9 +524,9 @@ namespace AGVSimulator.Forms _agvListCombo.Items.Add(agv); } } - + _agvListCombo.DisplayMember = "AgvId"; - + if (_agvListCombo.Items.Count > 0) { _agvListCombo.SelectedIndex = 0; @@ -439,47 +537,260 @@ namespace AGVSimulator.Forms { // 시뮬레이션 상태 _simulationStatusLabel.Text = _simulationState.IsRunning ? "시뮬레이션: 실행 중" : "시뮬레이션: 정지"; - + // AGV 수 _agvCountLabel.Text = $"AGV 수: {_agvList?.Count ?? 0}"; - + // 버튼 상태 _startSimulationButton.Enabled = !_simulationState.IsRunning && _agvList?.Count > 0; _stopSimulationButton.Enabled = _simulationState.IsRunning; - + _removeAgvButton.Enabled = _agvListCombo.SelectedItem != null; - _startPathButton.Enabled = _agvListCombo.SelectedItem != null && - _simulatorCanvas.CurrentPath != null && + _startPathButton.Enabled = _agvListCombo.SelectedItem != null && + _simulatorCanvas.CurrentPath != null && _simulatorCanvas.CurrentPath.Success; - - _calculatePathButton.Enabled = _startNodeCombo.SelectedItem != null && + + _calculatePathButton.Enabled = _startNodeCombo.SelectedItem != null && _targetNodeCombo.SelectedItem != null; - + // RFID 위치 설정 관련 var hasSelectedAGV = _agvListCombo.SelectedItem != null; var hasRfidNodes = _mapNodes != null && _mapNodes.Any(n => n.HasRfid()); - + _setPositionButton.Enabled = hasSelectedAGV && hasRfidNodes; _rfidTextBox.Enabled = hasSelectedAGV && hasRfidNodes; - + + // AGV 정보 패널 업데이트 + UpdateAGVInfoPanel(); + // 맵 다시열기 버튼 var hasCurrentMap = !string.IsNullOrEmpty(_currentMapFilePath); reloadMapToolStripMenuItem.Enabled = hasCurrentMap; reloadMapToolStripButton.Enabled = hasCurrentMap; } + /// + /// AGV 정보 패널 업데이트 + /// + private void UpdateAGVInfoPanel() + { + var selectedAGV = _agvListCombo.SelectedItem as VirtualAGV; + + if (selectedAGV == null) + { + _liftDirectionLabel.Text = "리프트 방향: -"; + _motorDirectionLabel.Text = "모터 방향: -"; + _agvInfoTitleLabel.Text = "AGV 상태 정보: (AGV 선택 안됨)"; + return; + } + + // AGV 선택됨 + _agvInfoTitleLabel.Text = $"AGV 상태 정보: {selectedAGV.AgvId}"; + + // 리프트 방향 계산 + var liftDirection = CalculateLiftDirection(selectedAGV); + _liftDirectionLabel.Text = $"리프트 방향: {liftDirection}"; + + // 모터 방향 + var motorDirection = GetMotorDirectionString(selectedAGV.CurrentDirection); + _motorDirectionLabel.Text = $"모터 방향: {motorDirection}"; + } + + /// + /// AGV의 리프트 방향 계산 (상세 출력 버전) + /// + private void CalculateLiftDirectionDetailed(VirtualAGV agv) + { + var currentPos = agv.CurrentPosition; + var targetPos = agv.TargetPosition; + var dockingDirection = agv.DockingDirection; + + Program.WriteLine($" 입력값: CurrentPos=({currentPos.X}, {currentPos.Y})"); + Program.WriteLine($" 입력값: TargetPos={(!targetPos.HasValue ? "None" : $"({targetPos.Value.X}, {targetPos.Value.Y})")}"); + Program.WriteLine($" 입력값: DockingDirection={dockingDirection}"); + + if (!targetPos.HasValue || targetPos.Value == currentPos) + { + Program.WriteLine($" 결과: 방향을 알 수 없음 (TargetPos 없음 또는 같은 위치)"); + return; + } + + // 이동 방향 계산 (이전 → 현재 = TargetPos → CurrentPos) + var dx = currentPos.X - targetPos.Value.X; + var dy = currentPos.Y - targetPos.Value.Y; + Program.WriteLine($" 이동 벡터: dx={dx}, dy={dy}"); + + if (Math.Abs(dx) < 1 && Math.Abs(dy) < 1) + { + Program.WriteLine($" 결과: 정지 상태 (이동거리 < 1픽셀)"); + return; + } + + // 경로 예측 기반 LiftCalculator를 사용하여 리프트 방향 계산 + var liftInfo = AGVNavigationCore.Utils.LiftCalculator.CalculateLiftInfoWithPathPrediction( + currentPos, targetPos.Value, agv.CurrentDirection, _mapNodes); + + // 이동 각도 계산 (표시용) + var moveAngleRad = Math.Atan2(dy, dx); + var moveAngleDeg = moveAngleRad * 180.0 / Math.PI; + while (moveAngleDeg < 0) moveAngleDeg += 360; + while (moveAngleDeg >= 360) moveAngleDeg -= 360; + + Program.WriteLine($" 이동 각도: {moveAngleDeg:F1}도 (라디안: {moveAngleRad:F3})"); + Program.WriteLine($" 모터 방향: {GetMotorDirectionString(liftInfo.MotorDirection)}"); + Program.WriteLine($" 리프트 각도: {liftInfo.AngleDegrees:F1}도 ({liftInfo.CalculationMethod})"); + + // 도킹 방향 정보 추가 + string dockingInfo = dockingDirection == DockingDirection.Forward ? "전진도킹" : "후진도킹"; + + Program.WriteLine($" 최종 결과: {liftInfo.DirectionString} ({liftInfo.AngleDegrees:F0}°) [{dockingInfo}]"); + } + + /// + /// AGV의 리프트 방향 계산 (간단한 버전) + /// + private string CalculateLiftDirection(VirtualAGV agv) + { + var currentPos = agv.CurrentPosition; + var targetPos = agv.TargetPosition; + var dockingDirection = agv.DockingDirection; + + if (!targetPos.HasValue || targetPos.Value == currentPos) + { + // 방향을 알 수 없는 경우 + return "알 수 없음 (?)"; + } + + // 이동 방향 계산 (현재 → 타겟, 실제로는 이전 → 현재) + var dx = currentPos.X - targetPos.Value.X; + var dy = currentPos.Y - targetPos.Value.Y; + + if (Math.Abs(dx) < 1 && Math.Abs(dy) < 1) + { + return "정지 상태"; + } + + // 경로 예측 기반 LiftCalculator를 사용하여 리프트 방향 계산 + var liftInfo = AGVNavigationCore.Utils.LiftCalculator.CalculateLiftInfoWithPathPrediction( + currentPos, targetPos.Value, agv.CurrentDirection, _mapNodes); + + // 도킹 방향 정보 추가 + string dockingInfo = dockingDirection == DockingDirection.Forward ? "전진도킹" : "후진도킹"; + + return $"{liftInfo.DirectionString} ({liftInfo.AngleDegrees:F0}°) [{dockingInfo}]"; + } + + /// + /// 모터 방향을 문자열로 변환 + /// + private string GetMotorDirectionString(AgvDirection direction) + { + switch (direction) + { + case AgvDirection.Forward: + return "전진 (F)"; + case AgvDirection.Backward: + return "후진 (B)"; + case AgvDirection.Left: + return "좌회전 (L)"; + case AgvDirection.Right: + return "우회전 (R)"; + default: + return "알 수 없음"; + } + } + + /// + /// 노드 ID를 RFID 값으로 변환 (NodeResolver 사용) + /// + private string GetRfidByNodeId(string nodeId) + { + var node = _mapNodes?.FirstOrDefault(n => n.NodeId == nodeId); + return node?.HasRfid() == true ? node.RfidId : nodeId; + } + + /// + /// 경로 디버깅 정보 업데이트 (RFID 값 표시, 모터방향 정보 포함) + /// + private void UpdatePathDebugInfo(AGVPathResult agvResult) + { + if (agvResult == null || !agvResult.Success) + { + _pathDebugLabel.Text = "경로: 설정되지 않음"; + return; + } + + // 노드 ID를 RFID로 변환한 경로 생성 + var pathWithRfid = agvResult.Path.Select(nodeId => GetRfidByNodeId(nodeId)).ToList(); + + // 콘솔 디버그 정보 출력 (RFID 기준) + Program.WriteLine($"[DEBUG] 경로 계산 완료:"); + Program.WriteLine($" 전체 경로 (RFID): [{string.Join(" → ", pathWithRfid)}]"); + Program.WriteLine($" 전체 경로 (NodeID): [{string.Join(" → ", agvResult.Path)}]"); + Program.WriteLine($" 경로 노드 수: {agvResult.Path.Count}"); + if (agvResult.NodeMotorInfos != null) + { + Program.WriteLine($" 모터정보 수: {agvResult.NodeMotorInfos.Count}"); + for (int i = 0; i < agvResult.NodeMotorInfos.Count; i++) + { + var info = agvResult.NodeMotorInfos[i]; + var rfidId = GetRfidByNodeId(info.NodeId); + var nextRfidId = info.NextNodeId != null ? GetRfidByNodeId(info.NextNodeId) : "END"; + Program.WriteLine($" {i}: {rfidId}({info.NodeId}) → {info.MotorDirection} → {nextRfidId}"); + } + } + + // 모터방향 정보가 있다면 이를 포함하여 경로 문자열 구성 (RFID 기준) + string pathString; + if (agvResult.NodeMotorInfos != null && agvResult.NodeMotorInfos.Count > 0) + { + // RFID별 모터방향 정보를 포함한 경로 문자열 생성 + var pathWithMotorInfo = new List(); + for (int i = 0; i < agvResult.NodeMotorInfos.Count; i++) + { + var motorInfo = agvResult.NodeMotorInfos[i]; + var rfidId = GetRfidByNodeId(motorInfo.NodeId); + string motorSymbol = motorInfo.MotorDirection == AgvDirection.Forward ? "[전진]" : "[후진]"; + pathWithMotorInfo.Add($"{rfidId}{motorSymbol}"); + } + pathString = string.Join(" → ", pathWithMotorInfo); + } + else + { + // 기본 경로 정보만 표시 (RFID 기준) + pathString = string.Join(" → ", pathWithRfid); + } + + // UI에 표시 (길이 제한) + if (pathString.Length > 120) + { + pathString = pathString.Substring(0, 117) + "..."; + } + + // 모터방향 통계 추가 + string motorStats = ""; + if (agvResult.NodeMotorInfos != null && agvResult.NodeMotorInfos.Count > 0) + { + var forwardCount = agvResult.NodeMotorInfos.Count(m => m.MotorDirection == AgvDirection.Forward); + var backwardCount = agvResult.NodeMotorInfos.Count(m => m.MotorDirection == AgvDirection.Backward); + motorStats = $", 전진: {forwardCount}, 후진: {backwardCount}"; + } + + _pathDebugLabel.Text = $"경로: {pathString} (총 {agvResult.Path.Count}개 노드, {agvResult.TotalDistance:F1}px{motorStats})"; + } + private void OnReloadMap_Click(object sender, EventArgs e) { if (string.IsNullOrEmpty(_currentMapFilePath)) { - MessageBox.Show("다시 로드할 맵 파일이 없습니다. 먼저 맵을 열어주세요.", "알림", + MessageBox.Show("다시 로드할 맵 파일이 없습니다. 먼저 맵을 열어주세요.", "알림", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } if (!File.Exists(_currentMapFilePath)) { - MessageBox.Show($"맵 파일을 찾을 수 없습니다:\n{_currentMapFilePath}", "오류", + MessageBox.Show($"맵 파일을 찾을 수 없습니다:\n{_currentMapFilePath}", "오류", MessageBoxButtons.OK, MessageBoxIcon.Error); return; } @@ -491,7 +802,7 @@ namespace AGVSimulator.Forms } catch (Exception ex) { - MessageBox.Show($"맵 파일을 다시 로드할 수 없습니다:\n{ex.Message}", "오류", + MessageBox.Show($"맵 파일을 다시 로드할 수 없습니다:\n{ex.Message}", "오류", MessageBoxButtons.OK, MessageBoxIcon.Error); } } @@ -515,7 +826,7 @@ namespace AGVSimulator.Forms if (openDialog.ShowDialog() == DialogResult.OK) { mapEditorPath = openDialog.FileName; - + // 설정에 저장 _config.MapEditorExecutablePath = mapEditorPath; if (_config.AutoSave) @@ -548,7 +859,7 @@ namespace AGVSimulator.Forms } catch (Exception ex) { - MessageBox.Show($"MapEditor를 실행할 수 없습니다:\n{ex.Message}", "오류", + MessageBox.Show($"MapEditor를 실행할 수 없습니다:\n{ex.Message}", "오류", MessageBoxButtons.OK, MessageBoxIcon.Error); } } @@ -578,4 +889,45 @@ namespace AGVSimulator.Forms _statusLabel.Text = "초기화 완료"; } } + + /// + /// 방향 콤보박스용 아이템 클래스 + /// + public class DirectionItem + { + public AgvDirection Direction { get; } + public string DisplayText { get; } + + public DirectionItem(AgvDirection direction, string displayText) + { + Direction = direction; + DisplayText = displayText; + } + + public override string ToString() + { + return DisplayText; + } + } + + /// + /// 제네릭 콤보박스 아이템 클래스 + /// + /// 값의 타입 + public class ComboBoxItem + { + public T Value { get; } + public string DisplayText { get; } + + public ComboBoxItem(T value, string displayText) + { + Value = value; + DisplayText = displayText; + } + + public override string ToString() + { + return DisplayText; + } + } } \ No newline at end of file diff --git a/Cs_HMI/AGVSimulator/Models/VirtualAGV.cs b/Cs_HMI/AGVSimulator/Models/VirtualAGV.cs index 1648b09..64318b7 100644 --- a/Cs_HMI/AGVSimulator/Models/VirtualAGV.cs +++ b/Cs_HMI/AGVSimulator/Models/VirtualAGV.cs @@ -59,6 +59,7 @@ namespace AGVSimulator.Models private List _remainingNodes; private int _currentNodeIndex; private string _currentNodeId; + private string _targetNodeId; // 이동 관련 private System.Windows.Forms.Timer _moveTimer; @@ -67,6 +68,9 @@ namespace AGVSimulator.Models private Point _moveTargetPosition; private float _moveProgress; + // 도킹 관련 + private DockingDirection _dockingDirection; + // 시뮬레이션 설정 private readonly float _moveSpeed = 50.0f; // 픽셀/초 private readonly float _rotationSpeed = 90.0f; // 도/초 @@ -114,13 +118,23 @@ namespace AGVSimulator.Models /// /// 목표 위치 /// - public Point TargetPosition => _targetPosition; + public Point? TargetPosition => _targetPosition; /// /// 배터리 레벨 (시뮬레이션) /// public float BatteryLevel { get; set; } = 100.0f; + /// + /// 목표 노드 ID + /// + public string TargetNodeId => _targetNodeId; + + /// + /// 도킹 방향 + /// + public DockingDirection DockingDirection => _dockingDirection; + #endregion #region Constructor @@ -138,6 +152,9 @@ namespace AGVSimulator.Models _currentDirection = startDirection; _currentState = AGVState.Idle; _currentSpeed = 0; + _dockingDirection = DockingDirection.Forward; // 기본값: 전진 도킹 + _currentNodeId = string.Empty; + _targetNodeId = string.Empty; InitializeTimer(); } @@ -175,13 +192,27 @@ namespace AGVSimulator.Models _remainingNodes = new List(path.Path); _currentNodeIndex = 0; - // 시작 노드 위치로 이동 + // 시작 노드와 목표 노드 설정 if (_remainingNodes.Count > 0) { var startNode = mapNodes.FirstOrDefault(n => n.NodeId == _remainingNodes[0]); if (startNode != null) { _currentNodeId = startNode.NodeId; + + // 목표 노드 설정 (경로의 마지막 노드) + if (_remainingNodes.Count > 1) + { + _targetNodeId = _remainingNodes[_remainingNodes.Count - 1]; + var targetNode = mapNodes.FirstOrDefault(n => n.NodeId == _targetNodeId); + + // 목표 노드의 타입에 따라 도킹 방향 결정 + if (targetNode != null) + { + _dockingDirection = GetDockingDirection(targetNode.Type); + } + } + StartMovement(); } else @@ -245,6 +276,35 @@ namespace AGVSimulator.Models SetState(AGVState.Idle); } + /// + /// AGV 방향 직접 설정 (시뮬레이터용) + /// + /// 설정할 방향 + public void SetDirection(AgvDirection direction) + { + _currentDirection = direction; + } + + /// + /// AGV 위치 직접 설정 (시뮬레이터용) + /// 이전 위치를 TargetPosition으로 저장하여 리프트 방향 계산이 가능하도록 함 + /// + /// 새로운 위치 + public void SetPosition(Point newPosition) + { + // 현재 위치를 이전 위치로 저장 (리프트 방향 계산용) + if (_currentPosition != Point.Empty) + { + _targetPosition = _currentPosition; + } + + // 새로운 위치 설정 + _currentPosition = newPosition; + + // 위치 변경 이벤트 발생 + PositionChanged?.Invoke(this, _currentPosition); + } + /// /// 충전 시작 (시뮬레이션) /// @@ -449,6 +509,19 @@ namespace AGVSimulator.Models } } + private DockingDirection GetDockingDirection(NodeType nodeType) + { + switch (nodeType) + { + case NodeType.Charging: + return DockingDirection.Forward; // 충전기: 전진 도킹 + case NodeType.Docking: + return DockingDirection.Backward; // 장비 (로더, 클리너, 오프로더, 버퍼): 후진 도킹 + default: + return DockingDirection.Forward; // 기본값: 전진 + } + } + private void OnError(string message) { SetState(AGVState.Error);