Files
ENIG/Cs_HMI/AGVNavigationCore/Controls/UnifiedAGVCanvas.Mouse.cs
ChiKyun Kim 8d5ddbe008 fix: 좌표 변환 시스템 수정 - Matrix 역변환 적용
ScreenToWorld 함수를 Matrix 역변환 방식으로 변경하여
줌/팬 상태에서도 정확한 마우스 좌표 변환 구현.
좌측 영역 객체 선택 문제 해결.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-16 15:55:28 +09:00

785 lines
26 KiB
C#

using System;
using System.Drawing;
using System.Linq;
using System.Windows.Forms;
using AGVNavigationCore.Models;
namespace AGVNavigationCore.Controls
{
public partial class UnifiedAGVCanvas
{
#region Mouse Events
private void UnifiedAGVCanvas_MouseClick(object sender, MouseEventArgs e)
{
Focus(); // 포커스 설정
var worldPoint = ScreenToWorld(e.Location);
var hitNode = GetNodeAt(worldPoint);
switch (_editMode)
{
case EditMode.Select:
HandleSelectClick(hitNode, worldPoint);
break;
case EditMode.AddNode:
HandleAddNodeClick(worldPoint);
break;
case EditMode.Connect:
HandleConnectClick(hitNode);
break;
case EditMode.Delete:
HandleDeleteClick(hitNode);
break;
case EditMode.DeleteConnection:
HandleDeleteConnectionClick(worldPoint);
break;
}
}
private void UnifiedAGVCanvas_MouseDoubleClick(object sender, MouseEventArgs e)
{
var worldPoint = ScreenToWorld(e.Location);
var hitNode = GetNodeAt(worldPoint);
if (hitNode != null)
{
// 노드 속성 편집 (이벤트 발생)
NodeSelected?.Invoke(this, hitNode);
}
}
private void UnifiedAGVCanvas_MouseDown(object sender, MouseEventArgs e)
{
var worldPoint = ScreenToWorld(e.Location);
if (e.Button == MouseButtons.Left)
{
if (_editMode == EditMode.Move)
{
var hitNode = GetNodeAt(worldPoint);
if (hitNode != null)
{
_isDragging = true;
_selectedNode = hitNode;
_dragOffset = new Point(
worldPoint.X - hitNode.Position.X,
worldPoint.Y - hitNode.Position.Y
);
Cursor = Cursors.SizeAll;
Invalidate();
return;
}
}
// 팬 시작 (우클릭 또는 중간버튼)
_isPanning = true;
_lastMousePosition = e.Location;
Cursor = Cursors.SizeAll;
}
else if (e.Button == MouseButtons.Right)
{
// 컨텍스트 메뉴 (편집 모드에서만)
if (_canvasMode == CanvasMode.Edit)
{
var hitNode = GetNodeAt(worldPoint);
ShowContextMenu(e.Location, hitNode);
}
}
}
private void UnifiedAGVCanvas_MouseMove(object sender, MouseEventArgs e)
{
var worldPoint = ScreenToWorld(e.Location);
// 호버 노드 업데이트
var newHoveredNode = GetNodeAt(worldPoint);
if (newHoveredNode != _hoveredNode)
{
_hoveredNode = newHoveredNode;
Invalidate();
}
if (_isPanning)
{
// 팬 처리
var deltaX = e.X - _lastMousePosition.X;
var deltaY = e.Y - _lastMousePosition.Y;
_panOffset.X += deltaX;
_panOffset.Y += deltaY;
_lastMousePosition = e.Location;
Invalidate();
}
else if (_isDragging && _canvasMode == CanvasMode.Edit)
{
// 노드 드래그
if (_selectedNode != null)
{
var newPosition = new Point(
worldPoint.X - _dragOffset.X,
worldPoint.Y - _dragOffset.Y
);
// 그리드 스냅
if (ModifierKeys.HasFlag(Keys.Control))
{
newPosition.X = (newPosition.X / GRID_SIZE) * GRID_SIZE;
newPosition.Y = (newPosition.Y / GRID_SIZE) * GRID_SIZE;
}
_selectedNode.Position = newPosition;
NodeMoved?.Invoke(this, _selectedNode);
MapChanged?.Invoke(this, EventArgs.Empty);
Invalidate();
}
}
else if (_isConnectionMode && _canvasMode == CanvasMode.Edit)
{
// 임시 연결선 업데이트
_connectionEndPoint = worldPoint;
Invalidate();
}
// 툴팁 표시 (호버된 노드/AGV 정보)
UpdateTooltip(worldPoint);
}
private void UnifiedAGVCanvas_MouseUp(object sender, MouseEventArgs e)
{
if (e.Button == MouseButtons.Left)
{
if (_isDragging && _canvasMode == CanvasMode.Edit)
{
_isDragging = false;
Cursor = GetCursorForMode(_editMode);
}
if (_isPanning)
{
_isPanning = false;
Cursor = Cursors.Default;
}
}
}
private void UnifiedAGVCanvas_MouseWheel(object sender, MouseEventArgs e)
{
// 줌 처리
var mouseWorldPoint = ScreenToWorld(e.Location);
var oldZoom = _zoomFactor;
if (e.Delta > 0)
_zoomFactor = Math.Min(_zoomFactor * 1.2f, 5.0f);
else
_zoomFactor = Math.Max(_zoomFactor / 1.2f, 0.1f);
// 마우스 위치를 중심으로 줌
var zoomRatio = _zoomFactor / oldZoom;
_panOffset.X = (int)(e.X - (e.X - _panOffset.X) * zoomRatio);
_panOffset.Y = (int)(e.Y - (e.Y - _panOffset.Y) * zoomRatio);
Invalidate();
}
#endregion
#region Mouse Helper Methods
private Point ScreenToWorld(Point screenPoint)
{
// 변환 행렬 생성 (렌더링과 동일)
var transform = new System.Drawing.Drawing2D.Matrix();
transform.Scale(_zoomFactor, _zoomFactor);
transform.Translate(_panOffset.X, _panOffset.Y);
// 역변환 행렬로 화면 좌표를 월드 좌표로 변환
transform.Invert();
var points = new System.Drawing.PointF[] { new System.Drawing.PointF(screenPoint.X, screenPoint.Y) };
transform.TransformPoints(points);
return new Point((int)points[0].X, (int)points[0].Y);
}
private Point WorldToScreen(Point worldPoint)
{
return new Point(
(int)(worldPoint.X * _zoomFactor + _panOffset.X),
(int)(worldPoint.Y * _zoomFactor + _panOffset.Y)
);
}
private MapNode GetNodeAt(Point worldPoint)
{
if (_nodes == null) return null;
// 역순으로 검사하여 위에 그려진 노드부터 확인
for (int i = _nodes.Count - 1; i >= 0; i--)
{
var node = _nodes[i];
if (IsPointInNode(worldPoint, node))
return node;
}
return null;
}
private bool IsPointInNode(Point point, MapNode node)
{
switch (node.Type)
{
case NodeType.Label:
return IsPointInLabelNode(point, node);
case NodeType.Image:
return IsPointInImageNode(point, node);
default:
return IsPointInCircularNode(point, node);
}
}
private bool IsPointInCircularNode(Point point, MapNode node)
{
switch (node.Type)
{
case NodeType.Docking:
return IsPointInPentagon(point, node);
case NodeType.Charging:
return IsPointInTriangle(point, node);
default:
return IsPointInCircle(point, node);
}
}
private bool IsPointInCircle(Point point, MapNode node)
{
// 화면에서 최소 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)
);
return distance <= hitRadius;
}
private bool IsPointInPentagon(Point point, MapNode node)
{
// 화면에서 최소 20픽셀 정도의 히트 영역을 확보
var minHitRadiusInScreen = 20;
var radius = Math.Max(NODE_RADIUS, minHitRadiusInScreen / _zoomFactor);
var center = node.Position;
// 5각형 꼭짓점 계산
var points = new Point[5];
for (int i = 0; i < 5; i++)
{
var angle = (Math.PI * 2 * i / 5) - Math.PI / 2;
points[i] = new Point(
(int)(center.X + radius * Math.Cos(angle)),
(int)(center.Y + radius * Math.Sin(angle))
);
}
return IsPointInPolygon(point, points);
}
private bool IsPointInTriangle(Point point, MapNode node)
{
// 화면에서 최소 20픽셀 정도의 히트 영역을 확보하되, 노드 크기보다 작아지지 않게 함
var minHitRadiusInScreen = 20;
var radius = Math.Max(NODE_RADIUS, minHitRadiusInScreen / _zoomFactor);
var center = node.Position;
// 삼각형 꼭짓점 계산
var points = new Point[3];
for (int i = 0; i < 3; i++)
{
var angle = (Math.PI * 2 * i / 3) - Math.PI / 2;
points[i] = new Point(
(int)(center.X + radius * Math.Cos(angle)),
(int)(center.Y + radius * Math.Sin(angle))
);
}
return IsPointInPolygon(point, points);
}
private bool IsPointInPolygon(Point point, Point[] polygon)
{
// Ray casting 알고리즘 사용
bool inside = false;
int j = polygon.Length - 1;
for (int i = 0; i < polygon.Length; i++)
{
var xi = polygon[i].X;
var yi = polygon[i].Y;
var xj = polygon[j].X;
var yj = polygon[j].Y;
if (((yi > point.Y) != (yj > point.Y)) &&
(point.X < (xj - xi) * (point.Y - yi) / (yj - yi) + xi))
{
inside = !inside;
}
j = i;
}
return inside;
}
private bool IsPointInLabelNode(Point point, MapNode node)
{
var text = string.IsNullOrEmpty(node.LabelText) ? node.NodeId : node.LabelText;
// 임시 Graphics로 텍스트 크기 측정
using (var tempBitmap = new Bitmap(1, 1))
using (var tempGraphics = Graphics.FromImage(tempBitmap))
{
var font = new Font(node.FontFamily, node.FontSize, node.FontStyle);
var textSize = tempGraphics.MeasureString(text, font);
var textRect = new Rectangle(
(int)(node.Position.X - textSize.Width / 2),
(int)(node.Position.Y - textSize.Height / 2),
(int)textSize.Width,
(int)textSize.Height
);
font.Dispose();
return textRect.Contains(point);
}
}
private bool IsPointInImageNode(Point point, MapNode node)
{
var displaySize = node.GetDisplaySize();
if (displaySize.IsEmpty)
displaySize = new Size(50, 50); // 기본 크기
var imageRect = new Rectangle(
node.Position.X - displaySize.Width / 2,
node.Position.Y - displaySize.Height / 2,
displaySize.Width,
displaySize.Height
);
return imageRect.Contains(point);
}
private IAGV GetAGVAt(Point worldPoint)
{
if (_agvList == null) return null;
var hitRadius = Math.Max(AGV_SIZE / 2, 15 / _zoomFactor);
return _agvList.FirstOrDefault(agv =>
{
if (!_agvPositions.ContainsKey(agv.AgvId)) return false;
var agvPos = _agvPositions[agv.AgvId];
var distance = Math.Sqrt(
Math.Pow(agvPos.X - worldPoint.X, 2) +
Math.Pow(agvPos.Y - worldPoint.Y, 2)
);
return distance <= hitRadius;
});
}
private void HandleSelectClick(MapNode hitNode, Point worldPoint)
{
if (hitNode != null)
{
// 노드 선택
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();
}
}
}
}
private void HandleAddNodeClick(Point worldPoint)
{
// 그리드 스냅
if (ModifierKeys.HasFlag(Keys.Control))
{
worldPoint.X = (worldPoint.X / GRID_SIZE) * GRID_SIZE;
worldPoint.Y = (worldPoint.Y / GRID_SIZE) * GRID_SIZE;
}
// 고유한 NodeId 생성
string newNodeId = GenerateUniqueNodeId();
var newNode = new MapNode
{
NodeId = newNodeId,
Position = worldPoint,
Type = NodeType.Normal
};
_nodes.Add(newNode);
NodeAdded?.Invoke(this, newNode);
MapChanged?.Invoke(this, EventArgs.Empty);
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;
if (!_isConnectionMode)
{
// 연결 시작
_isConnectionMode = true;
_connectionStartNode = hitNode;
_selectedNode = hitNode;
}
else
{
// 연결 완료
if (_connectionStartNode != null && _connectionStartNode != hitNode)
{
CreateConnection(_connectionStartNode, hitNode);
}
CancelConnection();
}
Invalidate();
}
private void HandleDeleteClick(MapNode hitNode)
{
if (hitNode == null) return;
// 연결된 모든 연결선도 제거
foreach (var node in _nodes)
{
node.RemoveConnection(hitNode.NodeId);
}
_nodes.Remove(hitNode);
if (_selectedNode == hitNode)
_selectedNode = null;
NodeDeleted?.Invoke(this, hitNode);
MapChanged?.Invoke(this, EventArgs.Empty);
Invalidate();
}
private void CreateConnection(MapNode fromNode, MapNode toNode)
{
// 중복 연결 체크 (양방향)
if (fromNode.ConnectedNodes.Contains(toNode.NodeId) ||
toNode.ConnectedNodes.Contains(fromNode.NodeId))
return;
// 단일 연결 생성 (사전순으로 정렬하여 일관성 유지)
if (string.Compare(fromNode.NodeId, toNode.NodeId, StringComparison.Ordinal) < 0)
{
fromNode.AddConnection(toNode.NodeId);
}
else
{
toNode.AddConnection(fromNode.NodeId);
}
MapChanged?.Invoke(this, EventArgs.Empty);
}
private float CalculateDistance(Point from, Point to)
{
var dx = to.X - from.X;
var dy = to.Y - from.Y;
return (float)Math.Sqrt(dx * dx + dy * dy);
}
private void ShowContextMenu(Point location, MapNode hitNode)
{
_contextMenu.Items.Clear();
if (hitNode != null)
{
_contextMenu.Items.Add("노드 속성...", null, (s, e) => NodeSelected?.Invoke(this, hitNode));
_contextMenu.Items.Add("노드 삭제", null, (s, e) => HandleDeleteClick(hitNode));
_contextMenu.Items.Add("-");
}
_contextMenu.Items.Add("노드 추가", null, (s, e) =>
{
var worldPoint = ScreenToWorld(location);
HandleAddNodeClick(worldPoint);
});
_contextMenu.Items.Add("전체 맞춤", null, (s, e) => FitToNodes());
_contextMenu.Items.Add("줌 리셋", null, (s, e) => ResetZoom());
_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 = "";
// 노드 툴팁
var hitNode = GetNodeAt(worldPoint);
if (hitNode != null)
{
tooltipText = $"노드: {hitNode.NodeId}\n타입: {hitNode.Type}\n위치: ({hitNode.Position.X}, {hitNode.Position.Y})";
}
else
{
// AGV 툴팁
var hitAGV = GetAGVAt(worldPoint);
if (hitAGV != null)
{
var state = _agvStates.ContainsKey(hitAGV.AgvId) ? _agvStates[hitAGV.AgvId] : AGVState.Idle;
tooltipText = $"AGV: {hitAGV.AgvId}\n상태: {state}\n배터리: {hitAGV.BatteryLevel:F1}%\n위치: ({hitAGV.CurrentPosition.X}, {hitAGV.CurrentPosition.Y})";
}
}
// 툴팁 업데이트 (기존 ToolTip 컨트롤 사용)
if (!string.IsNullOrEmpty(tooltipText))
{
// ToolTip 설정 (필요시 추가 구현)
}
}
#endregion
#region View Control Methods
/// <summary>
/// 모든 노드가 보이도록 뷰 조정
/// </summary>
public void FitToNodes()
{
if (_nodes == null || _nodes.Count == 0) return;
var minX = _nodes.Min(n => n.Position.X);
var maxX = _nodes.Max(n => n.Position.X);
var minY = _nodes.Min(n => n.Position.Y);
var maxY = _nodes.Max(n => n.Position.Y);
var margin = 50;
var contentWidth = maxX - minX + margin * 2;
var contentHeight = maxY - minY + margin * 2;
var zoomX = (float)Width / contentWidth;
var zoomY = (float)Height / contentHeight;
_zoomFactor = Math.Min(zoomX, zoomY) * 0.9f;
var centerX = (minX + maxX) / 2;
var centerY = (minY + maxY) / 2;
_panOffset.X = (int)(Width / 2 - centerX * _zoomFactor);
_panOffset.Y = (int)(Height / 2 - centerY * _zoomFactor);
Invalidate();
}
/// <summary>
/// 줌 리셋
/// </summary>
public void ResetZoom()
{
_zoomFactor = 1.0f;
_panOffset = Point.Empty;
Invalidate();
}
/// <summary>
/// 특정 위치로 이동
/// </summary>
public void PanTo(Point worldPoint)
{
_panOffset.X = (int)(Width / 2 - worldPoint.X * _zoomFactor);
_panOffset.Y = (int)(Height / 2 - worldPoint.Y * _zoomFactor);
Invalidate();
}
/// <summary>
/// 특정 노드로 이동
/// </summary>
public void PanToNode(string nodeId)
{
var node = _nodes?.FirstOrDefault(n => n.NodeId == nodeId);
if (node != null)
{
PanTo(node.Position);
}
}
/// <summary>
/// 특정 AGV로 이동
/// </summary>
public void PanToAGV(string agvId)
{
if (_agvPositions.ContainsKey(agvId))
{
PanTo(_agvPositions[agvId]);
}
}
#endregion
}
}