1428 lines
51 KiB
C#
1428 lines
51 KiB
C#
using AGVNavigationCore.Models;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Drawing;
|
|
using System.Linq;
|
|
using System.Windows.Forms;
|
|
using System.Xml.Linq;
|
|
|
|
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 = GetItemAt(worldPoint);
|
|
|
|
// 에뮬레이터 모드 처리
|
|
if (_canvasMode == CanvasMode.Emulator)
|
|
{
|
|
if (e.Button == MouseButtons.Right && hitNode != null)
|
|
{
|
|
NodeRightClicked?.Invoke(this, hitNode);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 🔥 어떤 모드에서든 노드/빈 공간 클릭 시 선택 이벤트 발생 (속성창 업데이트)
|
|
bool ctrlPressed = (ModifierKeys & Keys.Control) == Keys.Control;
|
|
|
|
if (hitNode != null)
|
|
{
|
|
// 노드 클릭
|
|
if (ctrlPressed && _editMode == EditMode.Select)
|
|
{
|
|
// Ctrl+클릭: 다중 선택 토글
|
|
if (_selectedNodes.Contains(hitNode))
|
|
{
|
|
_selectedNodes.Remove(hitNode);
|
|
}
|
|
else
|
|
{
|
|
_selectedNodes.Add(hitNode);
|
|
}
|
|
|
|
// 마지막 선택된 노드 업데이트 (단일 참조용)
|
|
_selectedNode = _selectedNodes.Count > 0 ? _selectedNodes[_selectedNodes.Count - 1] : null;
|
|
|
|
// 단일/다중 선택 이벤트 발생
|
|
if (_selectedNodes.Count == 1)
|
|
{
|
|
NodeSelect?.Invoke(this, _selectedNodes[0], e);
|
|
}
|
|
NodesSelected?.Invoke(this, _selectedNodes);
|
|
Invalidate();
|
|
}
|
|
else
|
|
{
|
|
// 일반 클릭: 단일 선택
|
|
_selectedNode = hitNode;
|
|
_selectedNodes.Clear();
|
|
_selectedNodes.Add(hitNode);
|
|
|
|
// 단일/다중 선택 이벤트 발생
|
|
NodeSelect?.Invoke(this, hitNode, e);
|
|
NodesSelected?.Invoke(this, _selectedNodes);
|
|
Invalidate();
|
|
}
|
|
}
|
|
else if (_editMode == EditMode.Select)
|
|
{
|
|
// 빈 공간 클릭 (Select 모드에서만) - 선택 해제
|
|
_selectedNode = null;
|
|
_selectedNodes.Clear();
|
|
|
|
// NodesSelected 이벤트만 발생 (OnNodesSelected에서 빈 리스트 처리)
|
|
NodesSelected?.Invoke(this, _selectedNodes);
|
|
Invalidate();
|
|
}
|
|
|
|
switch (_editMode)
|
|
{
|
|
case EditMode.Select:
|
|
HandleSelectClick(hitNode, worldPoint);
|
|
break;
|
|
|
|
case EditMode.AddNode:
|
|
HandleAddNodeClick(worldPoint);
|
|
break;
|
|
|
|
case EditMode.AddLabel:
|
|
HandleAddLabelClick(worldPoint);
|
|
break;
|
|
|
|
case EditMode.AddImage:
|
|
HandleAddImageClick(worldPoint);
|
|
break;
|
|
|
|
case EditMode.Connect:
|
|
HandleConnectClick(hitNode as MapNode);
|
|
break;
|
|
|
|
case EditMode.ConnectDirection:
|
|
HandleConnectDirectionClick(hitNode as MapNode);
|
|
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 = GetItemAt(worldPoint);
|
|
|
|
// 가동 모드에서는 더블클릭 편집 방지
|
|
if (_canvasMode == CanvasMode.Run) return;
|
|
|
|
if (hitNode == null) return;
|
|
|
|
if (hitNode.Type == NodeType.Normal)
|
|
{
|
|
HandleNormalNodeDoubleClick(hitNode as MapNode);
|
|
}
|
|
else if (hitNode.Type == NodeType.Label)
|
|
{
|
|
HandleLabelDoubleClick(hitNode as MapLabel);
|
|
}
|
|
else if (hitNode.Type == NodeType.Image)
|
|
{
|
|
HandleImageDoubleClick(hitNode as MapImage);
|
|
}
|
|
else if (hitNode.Type == NodeType.Mark)
|
|
{
|
|
HandleMarkDoubleClick(hitNode as MapMark);
|
|
}
|
|
else if (hitNode.Type == NodeType.Magnet)
|
|
{
|
|
HandleMagnetDoubleClick(hitNode as MapMagnet);
|
|
}
|
|
}
|
|
|
|
private void HandleNormalNodeDoubleClick(MapNode node)
|
|
{
|
|
// RFID 입력창 표시
|
|
var currentRfid = node.RfidId;
|
|
string newRfid = Microsoft.VisualBasic.Interaction.InputBox(
|
|
$"노드 '{node.RfidId}[{node.Id}]'의 RFID를 입력하세요:",
|
|
"RFID 설정",
|
|
currentRfid.ToString());
|
|
|
|
if (ushort.TryParse(newRfid, out ushort newrfidvalue) == false) return;
|
|
if (newrfidvalue < 1) return;
|
|
|
|
|
|
if (newrfidvalue != currentRfid)
|
|
{
|
|
node.RfidId = newrfidvalue;
|
|
MapChanged?.Invoke(this, EventArgs.Empty);
|
|
Invalidate();
|
|
}
|
|
|
|
// 더블클릭 시 해당 노드만 선택 (다중 선택 해제)
|
|
_selectedNode = node;
|
|
_selectedNodes.Clear();
|
|
_selectedNodes.Add(node);
|
|
NodesSelected?.Invoke(this, _selectedNodes);
|
|
}
|
|
private void HandleMarkDoubleClick(MapMark label)
|
|
{
|
|
//TODO:
|
|
}
|
|
private void HandleMagnetDoubleClick(MapMagnet label)
|
|
{
|
|
//TODO:
|
|
}
|
|
|
|
private void HandleLabelDoubleClick(MapLabel label)
|
|
{
|
|
// 라벨 텍스트 입력창 표시
|
|
string currentText = label.Text ?? "새 라벨";
|
|
string newText = Microsoft.VisualBasic.Interaction.InputBox(
|
|
"라벨 텍스트를 입력하세요:",
|
|
"라벨 편집",
|
|
currentText);
|
|
|
|
if (!string.IsNullOrWhiteSpace(newText) && newText != currentText)
|
|
{
|
|
label.Text = newText.Trim();
|
|
MapChanged?.Invoke(this, EventArgs.Empty);
|
|
Invalidate();
|
|
}
|
|
|
|
_selectedNode = label;
|
|
LabelDoubleClicked?.Invoke(this, label);
|
|
Invalidate();
|
|
}
|
|
|
|
private void HandleImageDoubleClick(MapImage image)
|
|
{
|
|
_selectedNode = image;
|
|
|
|
// 이미지 편집 이벤트 발생 (MainForm에서 처리)
|
|
ImageDoubleClicked?.Invoke(this, image);
|
|
|
|
Invalidate();
|
|
}
|
|
|
|
private void UnifiedAGVCanvas_MouseDown(object sender, MouseEventArgs e)
|
|
{
|
|
var worldPoint = ScreenToWorld(e.Location);
|
|
|
|
if (e.Button == MouseButtons.Left)
|
|
{
|
|
if (_editMode == EditMode.Move)
|
|
{
|
|
// 0. 핸들 선택 확인 (이미 선택된 노드가 있을 때)
|
|
if (_selectedNode != null)
|
|
{
|
|
int handleIdx = GetHandleAt(worldPoint);
|
|
if (handleIdx != -1)
|
|
{
|
|
_dragHandleIndex = handleIdx;
|
|
|
|
// 핸들 드래그 시 초기 오프셋 설정 (점프 현상 방지)
|
|
if (_selectedNode is MapMagnet magnet)
|
|
{
|
|
Point handlePos = Point.Empty;
|
|
if (handleIdx == 0) handlePos = magnet.StartPoint;
|
|
else if (handleIdx == 1) handlePos = magnet.EndPoint;
|
|
else if (handleIdx == 2 && magnet.ControlPoint != null)
|
|
handlePos = new Point((int)magnet.ControlPoint.X, (int)magnet.ControlPoint.Y);
|
|
|
|
_dragOffset = new Point(worldPoint.X - handlePos.X, worldPoint.Y - handlePos.Y);
|
|
}
|
|
else
|
|
{
|
|
_dragOffset = Point.Empty; // Mark 등은 오프셋 없이 마우스 포인터 기준 계산
|
|
}
|
|
|
|
_isDragging = true;
|
|
_isPanning = false;
|
|
Capture = true;
|
|
Invalidate();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 1. 노드 선택 확인
|
|
var hitNode = GetItemAt(worldPoint);
|
|
if (hitNode != null)
|
|
{
|
|
_isDragging = true;
|
|
_isPanning = false;
|
|
_dragHandleIndex = -1; // 노드 전체 드래그
|
|
_selectedNode = hitNode;
|
|
_dragStartPosition = hitNode.Position;
|
|
_dragOffset = new Point(worldPoint.X - hitNode.Position.X, worldPoint.Y - hitNode.Position.Y);
|
|
_mouseMoveCounter = 0;
|
|
Cursor = Cursors.SizeAll;
|
|
Capture = true;
|
|
Invalidate();
|
|
return;
|
|
}
|
|
|
|
|
|
}
|
|
|
|
// 팬 시작 (좌클릭 - 모드에 따라)
|
|
_isPanning = true;
|
|
_lastMousePosition = e.Location;
|
|
Cursor = Cursors.SizeAll;
|
|
Capture = true; // 🔥 마우스 캡처 활성화
|
|
}
|
|
else if (e.Button == MouseButtons.Middle)
|
|
{
|
|
// 중간 버튼: 항상 팬 (어떤 모드든 상관없이)
|
|
_isPanning = true;
|
|
_lastMousePosition = e.Location;
|
|
Cursor = Cursors.Hand;
|
|
Capture = true; // 🔥 마우스 캡처 활성화
|
|
}
|
|
else if (e.Button == MouseButtons.Right)
|
|
{
|
|
// 컨텍스트 메뉴 (편집 모드에서만)
|
|
if (_canvasMode == CanvasMode.Edit)
|
|
{
|
|
var hitNode = GetItemAt(worldPoint);
|
|
// TODO: 라벨/이미지에 대한 컨텍스트 메뉴도 지원하려면 여기서 hitLabel/hitImage 확인해서 전달
|
|
// 현재는 ShowContextMenu가 MapNode만 받으므로 노드만 처리
|
|
ShowContextMenu(e.Location, hitNode);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void UnifiedAGVCanvas_MouseMove(object sender, MouseEventArgs e)
|
|
{
|
|
var worldPoint = ScreenToWorld(e.Location);
|
|
|
|
// 디버그: MouseMove 카운터 증가
|
|
if (_isDragging)
|
|
{
|
|
_mouseMoveCounter++;
|
|
}
|
|
|
|
// 호버 업데이트
|
|
var newHoveredNode = GetItemAt(worldPoint);
|
|
|
|
bool hoverChanged = (newHoveredNode != _hoveredNode);
|
|
|
|
if (hoverChanged)
|
|
{
|
|
_hoveredNode = newHoveredNode;
|
|
Invalidate();
|
|
}
|
|
|
|
if (_isPanning)
|
|
{
|
|
// 팬 처리 - 줌 레벨 보정 적용
|
|
var deltaX = e.X - _lastMousePosition.X;
|
|
var deltaY = e.Y - _lastMousePosition.Y;
|
|
|
|
// 🔥 스크린 좌표 델타를 월드 좌표 델타로 변환 (줌 레벨 보정)
|
|
// PointF로 float 정밀도 유지 (소수점 손실 방지)
|
|
_panOffset.X += deltaX / _zoomFactor;
|
|
_panOffset.Y += deltaY / _zoomFactor;
|
|
|
|
_lastMousePosition = e.Location;
|
|
Invalidate();
|
|
Update(); // 팬도 실시간 업데이트
|
|
}
|
|
else if (_isDragging && _canvasMode == CanvasMode.Edit)
|
|
{
|
|
// 드래그 위치 계산
|
|
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;
|
|
}
|
|
|
|
bool moved = false;
|
|
|
|
// 노드 드래그
|
|
if (_selectedNode != null)
|
|
{
|
|
if (_dragHandleIndex != -1)
|
|
{
|
|
// 핸들 드래그 (포인트별 수정)
|
|
if (_selectedNode is MapMagnet magnet)
|
|
{
|
|
if (_dragHandleIndex == 0) magnet.StartPoint = newPosition;
|
|
else if (_dragHandleIndex == 1) magnet.EndPoint = newPosition;
|
|
else if (_dragHandleIndex == 2 && magnet.ControlPoint != null)
|
|
{
|
|
magnet.ControlPoint.X = newPosition.X;
|
|
magnet.ControlPoint.Y = newPosition.Y;
|
|
}
|
|
}
|
|
else if (_selectedNode is MapMark mark)
|
|
{
|
|
// 마크는 중심점 대비 각도와 길이를 계산하여 수정
|
|
var dx = newPosition.X - mark.Position.X;
|
|
var dy = newPosition.Y - mark.Position.Y;
|
|
|
|
// 핸들 인덱스에 따라 각도 반전 (p1 vs p2)
|
|
if (_dragHandleIndex == 0) { dx = -dx; dy = -dy; }
|
|
|
|
mark.Rotation = Math.Atan2(dy, dx) * 180.0 / Math.PI;
|
|
mark.Length = Math.Sqrt(dx * dx + dy * dy) * 2;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// 노드 전체 드래그
|
|
_selectedNode.Position = newPosition;
|
|
NodeMoved?.Invoke(this, _selectedNode);
|
|
}
|
|
moved = true;
|
|
}
|
|
|
|
if (moved)
|
|
{
|
|
MapChanged?.Invoke(this, EventArgs.Empty);
|
|
Invalidate();
|
|
Update(); // 🔥 즉시 Paint 이벤트 처리하여 화면 업데이트
|
|
}
|
|
}
|
|
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;
|
|
_dragHandleIndex = -1;
|
|
Capture = false; // 🔥 마우스 캡처 해제
|
|
Cursor = GetCursorForMode(_editMode);
|
|
}
|
|
|
|
if (_isPanning)
|
|
{
|
|
_isPanning = false;
|
|
Capture = false; // 🔥 마우스 캡처 해제
|
|
Cursor = Cursors.Default;
|
|
}
|
|
}
|
|
else if (e.Button == MouseButtons.Middle)
|
|
{
|
|
// 중간 버튼 드래그 종료
|
|
if (_isPanning)
|
|
{
|
|
_isPanning = false;
|
|
Capture = false; // 🔥 마우스 캡처 해제
|
|
Cursor = Cursors.Default;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void UnifiedAGVCanvas_MouseWheel(object sender, MouseEventArgs e)
|
|
{
|
|
// 마우스 커서 위치 (스크린 좌표)
|
|
Point mouseScreenPos = e.Location;
|
|
|
|
// 줌 전 마우스가 가리키는 월드 좌표
|
|
// Graphics Transform: Screen = (World + Pan) * Zoom
|
|
// 역변환: World = Screen / Zoom - Pan
|
|
float worldX_before = mouseScreenPos.X / _zoomFactor - _panOffset.X;
|
|
float worldY_before = mouseScreenPos.Y / _zoomFactor - _panOffset.Y;
|
|
|
|
// 이전 줌 팩터 저장
|
|
float oldZoom = _zoomFactor;
|
|
|
|
// 줌 팩터 계산 (휠 델타 기반)
|
|
if (e.Delta > 0)
|
|
_zoomFactor = Math.Min(_zoomFactor * 1.15f, 5.0f); // 확대
|
|
else
|
|
_zoomFactor = Math.Max(_zoomFactor / 1.15f, 0.1f); // 축소
|
|
|
|
// 줌 후에도 마우스가 같은 월드 좌표를 가리키도록 팬 오프셋 조정
|
|
// mouseScreen = (worldBefore + newPan) * newZoom
|
|
// newPan = mouseScreen / newZoom - worldBefore
|
|
_panOffset.X = (int)(mouseScreenPos.X / _zoomFactor - worldX_before);
|
|
_panOffset.Y = (int)(mouseScreenPos.Y / _zoomFactor - worldY_before);
|
|
|
|
Invalidate();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Mouse Helper Methods
|
|
|
|
private Point ScreenToWorld(Point screenPoint)
|
|
{
|
|
// 스크린 좌표를 월드 좌표로 변환
|
|
// Graphics Transform: Screen = (World + Pan) * Zoom
|
|
// 역변환: World = Screen / Zoom - Pan
|
|
float worldX = screenPoint.X / _zoomFactor - _panOffset.X;
|
|
float worldY = screenPoint.Y / _zoomFactor - _panOffset.Y;
|
|
|
|
return new Point((int)worldX, (int)worldY);
|
|
}
|
|
|
|
private Point WorldToScreen(Point worldPoint)
|
|
{
|
|
// 월드 좌표를 스크린 좌표로 변환
|
|
// Graphics Transform: Screen = (World + Pan) * Zoom
|
|
return new Point(
|
|
(int)((worldPoint.X + _panOffset.X) * _zoomFactor),
|
|
(int)((worldPoint.Y + _panOffset.Y) * _zoomFactor)
|
|
);
|
|
}
|
|
|
|
private NodeBase GetItemAt(Point worldPoint)
|
|
{
|
|
if (_labels != null)
|
|
{
|
|
// 역순으로 검사하여 위에 그려진 노드부터 확인
|
|
for (int i = _labels.Count - 1; i >= 0; i--)
|
|
{
|
|
var node = _labels[i];
|
|
if (IsPointInNode(worldPoint, node))
|
|
return node;
|
|
}
|
|
}
|
|
|
|
if (_nodes != null)
|
|
{
|
|
// 역순으로 검사하여 위에 그려진 노드부터 확인
|
|
for (int i = _nodes.Count - 1; i >= 0; i--)
|
|
{
|
|
var node = _nodes[i];
|
|
if (IsPointInNode(worldPoint, node))
|
|
return node;
|
|
}
|
|
}
|
|
if (_images != null)
|
|
{
|
|
// 역순으로 검사하여 위에 그려진 노드부터 확인
|
|
for (int i = _images.Count - 1; i >= 0; i--)
|
|
{
|
|
var node = _images[i];
|
|
if (IsPointInNode(worldPoint, node))
|
|
return node;
|
|
}
|
|
}
|
|
|
|
if (_marks != null)
|
|
{
|
|
for (int i = _marks.Count - 1; i >= 0; i--)
|
|
{
|
|
var node = _marks[i];
|
|
if (IsPointInNode(worldPoint, node))
|
|
return node;
|
|
}
|
|
}
|
|
|
|
if (_magnets != null)
|
|
{
|
|
for (int i = _magnets.Count - 1; i >= 0; i--)
|
|
{
|
|
var node = _magnets[i];
|
|
if (IsPointInNode(worldPoint, node))
|
|
return node;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private bool IsPointInNode(Point point, NodeBase node)
|
|
{
|
|
if (node is MapLabel label)
|
|
{
|
|
return IsPointInLabel(point, label);
|
|
}
|
|
if (node is MapImage image)
|
|
{
|
|
return IsPointInImage(point, image);
|
|
}
|
|
if (node is MapMark mark)
|
|
{
|
|
return IsPointInMark(point, mark);
|
|
}
|
|
if (node is MapMagnet magnet)
|
|
{
|
|
return IsPointInMagnet(point, magnet);
|
|
}
|
|
// 라벨과 이미지는 별도 리스트로 관리되므로 여기서 처리하지 않음
|
|
// 하지만 혹시 모를 하위 호환성을 위해 타입 체크는 유지하되,
|
|
// 실제 로직은 CircularNode 등으로 분기
|
|
return IsPointInCircularNode(point, node as MapNode);
|
|
}
|
|
|
|
private bool IsPointInCircularNode(Point point, MapNode node)
|
|
{
|
|
switch (node.StationType)
|
|
{
|
|
case Station.Loder:
|
|
case Station.Cleaner:
|
|
case Station.Plating:
|
|
case Station.Buffer:
|
|
return IsPointInPentagon(point, node);
|
|
case Station.Charger:
|
|
return IsPointInTriangle(point, node);
|
|
default:
|
|
return IsPointInCircle(point, node);
|
|
}
|
|
}
|
|
|
|
private bool IsPointInCircle(Point point, NodeBase 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, NodeBase 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, NodeBase 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 IsPointInLabel(Point point, MapLabel label)
|
|
{
|
|
var text = string.IsNullOrEmpty(label.Text) ? label.Id : label.Text;
|
|
|
|
// Graphics 객체 임시 생성 (Using CreateGraphics is faster than new Bitmap)
|
|
using (var g = this.CreateGraphics())
|
|
{
|
|
// Font 생성 로직: 사용자 정의 폰트가 있으면 생성, 없으면 기본 폰트 사용 (Dispose 주의)
|
|
Font fontToUse = null;
|
|
bool shouldDisposeFont = false;
|
|
|
|
try
|
|
{
|
|
if (string.IsNullOrEmpty(label.FontFamily) || label.FontSize <= 0)
|
|
{
|
|
fontToUse = this.Font;
|
|
shouldDisposeFont = false; // 컨트롤 폰트는 Dispose하면 안됨
|
|
}
|
|
else
|
|
{
|
|
try
|
|
{
|
|
fontToUse = new Font(label.FontFamily, label.FontSize, label.FontStyle);
|
|
shouldDisposeFont = true;
|
|
}
|
|
catch
|
|
{
|
|
fontToUse = this.Font;
|
|
shouldDisposeFont = false;
|
|
}
|
|
}
|
|
|
|
var textSize = g.MeasureString(text, fontToUse);
|
|
|
|
var textRect = new Rectangle(
|
|
(int)(label.Position.X - textSize.Width / 2),
|
|
(int)(label.Position.Y - textSize.Height / 2),
|
|
(int)textSize.Width,
|
|
(int)textSize.Height
|
|
);
|
|
|
|
return textRect.Contains(point);
|
|
}
|
|
finally
|
|
{
|
|
if (shouldDisposeFont && fontToUse != null)
|
|
{
|
|
fontToUse.Dispose();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private bool IsPointInImage(Point point, MapImage image)
|
|
{
|
|
var displaySize = image.GetDisplaySize();
|
|
if (displaySize.IsEmpty)
|
|
displaySize = new Size(50, 50); // 기본 크기
|
|
|
|
var imageRect = new Rectangle(
|
|
image.Position.X - displaySize.Width / 2,
|
|
image.Position.Y - displaySize.Height / 2,
|
|
displaySize.Width,
|
|
displaySize.Height
|
|
);
|
|
|
|
return imageRect.Contains(point);
|
|
}
|
|
|
|
private bool IsPointInMark(Point point, MapMark mark)
|
|
{
|
|
int lineLength = (int)mark.Length;
|
|
int halfLength = lineLength / 2;
|
|
double radians = mark.Rotation * Math.PI / 180.0;
|
|
int dx = (int)(halfLength * Math.Cos(radians));
|
|
int dy = (int)(halfLength * Math.Sin(radians));
|
|
|
|
Point p1 = new Point(mark.Position.X - dx, mark.Position.Y - dy);
|
|
Point p2 = new Point(mark.Position.X + dx, mark.Position.Y + dy);
|
|
|
|
// 마크 선택을 위해 약간 넉넉한 히트 영역 (7픽셀)
|
|
return CalculatePointToLineDistance(point, p1, p2) <= 7 / _zoomFactor;
|
|
}
|
|
|
|
private bool IsPointInMagnet(Point point, MapMagnet magnet)
|
|
{
|
|
// 마그넷은 두꺼우므로 (Pen Width 15) 절반인 7.5 정도를 히트 영역으로 잡음
|
|
float hitThreshold = Math.Max(8f, 12f / _zoomFactor);
|
|
|
|
if (magnet.ControlPoint != null)
|
|
{
|
|
// 베지어 곡선 정밀 샘플링 (10개 세그먼트)
|
|
Point prevPoint = magnet.StartPoint;
|
|
for (int i = 1; i <= 10; i++)
|
|
{
|
|
float t = i / 10f;
|
|
// Quadratic Bezier: (1-t)^2*P0 + 2(1-t)t*P1 + t^2*P2
|
|
float u = 1 - t;
|
|
float tt = t * t;
|
|
float uu = u * u;
|
|
|
|
float x = uu * magnet.StartPoint.X + 2 * u * t * (float)magnet.ControlPoint.X + tt * magnet.EndPoint.X;
|
|
float y = uu * magnet.StartPoint.Y + 2 * u * t * (float)magnet.ControlPoint.Y + tt * magnet.EndPoint.Y;
|
|
Point currentPoint = new Point((int)x, (int)y);
|
|
|
|
if (CalculatePointToLineDistance(point, prevPoint, currentPoint) <= hitThreshold)
|
|
return true;
|
|
|
|
prevPoint = currentPoint;
|
|
}
|
|
return false;
|
|
}
|
|
else
|
|
{
|
|
return CalculatePointToLineDistance(point, magnet.StartPoint, magnet.EndPoint) <= hitThreshold;
|
|
}
|
|
}
|
|
|
|
//private MapLabel GetLabelAt(Point worldPoint)
|
|
//{
|
|
// if (_labels == null) return null;
|
|
// // 역순으로 검사
|
|
// for (int i = _labels.Count - 1; i >= 0; i--)
|
|
// {
|
|
// var label = _labels[i];
|
|
// if (IsPointInLabel(worldPoint, label))
|
|
// return label;
|
|
// }
|
|
// return null;
|
|
//}
|
|
|
|
//private MapImage GetImageAt(Point worldPoint)
|
|
//{
|
|
// if (_images == null) return null;
|
|
// // 역순으로 검사
|
|
// for (int i = _images.Count - 1; i >= 0; i--)
|
|
// {
|
|
// var image = _images[i];
|
|
// if (IsPointInImage(worldPoint, image))
|
|
// return image;
|
|
// }
|
|
// return null;
|
|
//}
|
|
|
|
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(NodeBase hitNode, Point worldPoint)
|
|
{
|
|
if (hitNode != null)
|
|
{
|
|
// 노드 선택은 위쪽 MouseClick에서 이미 처리됨 (NodesSelected 이벤트 발생)
|
|
// 여기서는 추가 처리 없음
|
|
}
|
|
else
|
|
{
|
|
// 노드가 없으면 연결선 체크
|
|
var connection = GetConnectionAt(worldPoint);
|
|
if (connection != null)
|
|
{
|
|
// 연결선을 클릭했을 때 삭제 확인
|
|
var (fromNode, toNode) = connection.Value;
|
|
string fromDisplay = fromNode.HasRfid() ? fromNode.RfidId.ToString("0000") : fromNode.Id;
|
|
string toDisplay = toNode.HasRfid() ? toNode.RfidId.ToString("0000") : toNode.Id;
|
|
|
|
var result = MessageBox.Show(
|
|
$"연결을 삭제하시겠습니까?\n\n{fromDisplay} ↔ {toDisplay}",
|
|
"연결 삭제 확인",
|
|
MessageBoxButtons.YesNo,
|
|
MessageBoxIcon.Question);
|
|
|
|
if (result == DialogResult.Yes)
|
|
{
|
|
// 단일 연결 삭제 (어느 방향에 저장되어 있는지 확인 후 삭제)
|
|
if (fromNode.ConnectedNodes.Contains(toNode.Id))
|
|
{
|
|
fromNode.RemoveConnection(toNode.Id);
|
|
}
|
|
else if (toNode.ConnectedNodes.Contains(fromNode.Id))
|
|
{
|
|
toNode.RemoveConnection(fromNode.Id);
|
|
}
|
|
|
|
// 이벤트 발생
|
|
ConnectionDeleted?.Invoke(this, (fromNode, toNode));
|
|
MapChanged?.Invoke(this, EventArgs.Empty);
|
|
Invalidate();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// 빈 공간 클릭 시 선택 해제
|
|
if (_selectedNode != null || _selectedNodes.Count > 0)
|
|
{
|
|
_selectedNode = null;
|
|
_selectedNodes.Clear();
|
|
NodesSelected?.Invoke(this, _selectedNodes);
|
|
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
|
|
{
|
|
Id = newNodeId,
|
|
Position = worldPoint,
|
|
CanTurnLeft=false,
|
|
CanTurnRight= false,
|
|
};
|
|
|
|
_nodes.Add(newNode);
|
|
|
|
NodeAdded?.Invoke(this, newNode);
|
|
MapChanged?.Invoke(this, EventArgs.Empty);
|
|
Invalidate();
|
|
}
|
|
|
|
private void HandleAddLabelClick(Point worldPoint)
|
|
{
|
|
// 그리드 스냅
|
|
if (ModifierKeys.HasFlag(Keys.Control))
|
|
{
|
|
worldPoint.X = (worldPoint.X / GRID_SIZE) * GRID_SIZE;
|
|
worldPoint.Y = (worldPoint.Y / GRID_SIZE) * GRID_SIZE;
|
|
}
|
|
|
|
// 고유한 NodeId 생성 (라벨도 ID 공유 권장)
|
|
string newNodeId = GenerateUniqueNodeId();
|
|
|
|
var newLabel = new MapLabel
|
|
{
|
|
Id = newNodeId,
|
|
Position = worldPoint,
|
|
Text = "New Label",
|
|
FontSize = 10,
|
|
FontFamily = "Arial"
|
|
};
|
|
|
|
if (_labels == null) _labels = new List<MapLabel>();
|
|
_labels.Add(newLabel);
|
|
|
|
//NodeAdded?.Invoke(this, newNode); // TODO: 라벨 추가 이벤트 필요?
|
|
MapChanged?.Invoke(this, EventArgs.Empty);
|
|
Invalidate();
|
|
}
|
|
|
|
private void HandleAddImageClick(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 newImage = new MapImage
|
|
{
|
|
Id = newNodeId,
|
|
Position = worldPoint,
|
|
Name = "New Image"
|
|
};
|
|
|
|
if (_images == null) _images = new List<MapImage>();
|
|
_images.Add(newImage);
|
|
|
|
//NodeAdded?.Invoke(this, newNode); // TODO: 이미지 추가 이벤트 필요?
|
|
MapChanged?.Invoke(this, EventArgs.Empty);
|
|
Invalidate();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 중복되지 않는 고유한 NodeId 생성
|
|
/// </summary>
|
|
public string GenerateUniqueNodeId()
|
|
{
|
|
string nodeId;
|
|
int counter = _nodeCounter;
|
|
|
|
do
|
|
{
|
|
nodeId = $"N{counter:D3}";
|
|
counter++;
|
|
}
|
|
while (_nodes.Any(n => n.Id == nodeId) ||
|
|
(_labels != null && _labels.Any(l => l.Id == nodeId)) ||
|
|
(_images != null && _images.Any(i => i.Id == 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 HandleConnectDirectionClick(MapNode hitNode)
|
|
{
|
|
if (hitNode == null) return;
|
|
|
|
if (!_isConnectionMode)
|
|
{
|
|
// 연결 시작 (방향 설정)
|
|
_isConnectionMode = true;
|
|
_connectionStartNode = hitNode;
|
|
_selectedNode = hitNode;
|
|
}
|
|
else
|
|
{
|
|
// 연결 완료
|
|
if (_connectionStartNode != null && _connectionStartNode != hitNode)
|
|
{
|
|
// 기본값 S (Straight)로 방향 설정
|
|
SetMagnetDirection(_connectionStartNode, hitNode, MagnetPosition.S);
|
|
}
|
|
CancelConnection();
|
|
}
|
|
|
|
Invalidate();
|
|
}
|
|
|
|
private void HandleDeleteClick(MapNode hitNode)
|
|
{
|
|
if (hitNode == null) return;
|
|
|
|
// 연결된 모든 연결선도 제거
|
|
foreach (var node in _nodes)
|
|
{
|
|
node.RemoveConnection(hitNode.Id);
|
|
}
|
|
|
|
_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.Id) ||
|
|
toNode.ConnectedNodes.Contains(fromNode.Id))
|
|
return;
|
|
|
|
// 양방향 연결 생성 (AGV가 양쪽 방향으로 이동 가능하도록)
|
|
// 양방향 연결 생성 (AGV가 양쪽 방향으로 이동 가능하도록)
|
|
fromNode.AddConnection(toNode.Id);
|
|
toNode.AddConnection(fromNode.Id);
|
|
|
|
// 🔥 화면 표시용 ConnectedMapNodes 리스트도 즉시 갱신해야 함
|
|
if (!fromNode.ConnectedMapNodes.Contains(toNode))
|
|
fromNode.ConnectedMapNodes.Add(toNode);
|
|
|
|
if (!toNode.ConnectedMapNodes.Contains(fromNode))
|
|
toNode.ConnectedMapNodes.Add(fromNode);
|
|
|
|
ConnectionCreated?.Invoke(this, (fromNode, toNode));
|
|
MapChanged?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
public void SetMagnetDirection(MapNode fromNode, MapNode toNode, MagnetPosition direction)
|
|
{
|
|
if (fromNode == null || toNode == null) return;
|
|
|
|
// 이미 연결된 노드인지 확인 (연결되어 있어야 방향 설정 가능)
|
|
// 이미 연결된 노드인지 확인 (연결되어 있어야 방향 설정 가능)
|
|
if (!fromNode.ConnectedNodes.Contains(toNode.Id))
|
|
{
|
|
// 연결되어 있지 않으면 자동 연결
|
|
CreateConnection(fromNode, toNode);
|
|
}
|
|
|
|
if (fromNode.MagnetDirections == null)
|
|
fromNode.MagnetDirections = new Dictionary<string, MagnetPosition>();
|
|
|
|
if (fromNode.MagnetDirections.ContainsKey(toNode.Id))
|
|
{
|
|
fromNode.MagnetDirections[toNode.Id] = direction;
|
|
}
|
|
else
|
|
{
|
|
fromNode.MagnetDirections.Add(toNode.Id, direction);
|
|
}
|
|
|
|
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, NodeBase hitItem)
|
|
{
|
|
_contextMenu.Items.Clear();
|
|
|
|
if (hitItem != null)
|
|
{
|
|
string typeName = "항목";
|
|
if (hitItem is MapNode) typeName = "노드";
|
|
else if (hitItem is MapLabel) typeName = "라벨";
|
|
else if (hitItem is MapImage) typeName = "이미지";
|
|
|
|
_contextMenu.Items.Add($"{typeName} 속성...", null, (s, e) =>
|
|
{
|
|
_selectedNode = hitItem;
|
|
_selectedNodes.Clear();
|
|
_selectedNodes.Add(hitItem);
|
|
NodesSelected?.Invoke(this, _selectedNodes);
|
|
Invalidate();
|
|
});
|
|
_contextMenu.Items.Add($"{typeName} 삭제", null, (s, e) => HandleDeleteClick(hitItem));
|
|
_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.Id))
|
|
{
|
|
fromNode.RemoveConnection(toNode.Id);
|
|
}
|
|
else if (toNode.ConnectedNodes.Contains(fromNode.Id))
|
|
{
|
|
toNode.RemoveConnection(fromNode.Id);
|
|
}
|
|
|
|
// 이벤트 발생
|
|
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;
|
|
if (_nodes == null) return null;
|
|
|
|
// 모든 연결선을 확인하여 클릭한 위치와 가장 가까운 연결선 찾기
|
|
foreach (var fromNode in _nodes)
|
|
{
|
|
foreach (var toNodeId in fromNode.ConnectedNodes)
|
|
{
|
|
var toNode = _nodes.FirstOrDefault(n => n.Id == toNodeId);
|
|
if (toNode != null)
|
|
{
|
|
// 연결선과 클릭 위치 간의 거리 계산
|
|
var distance = CalculatePointToLineDistance(worldPoint, fromNode.Position, toNode.Position);
|
|
if (distance <= CONNECTION_HIT_TOLERANCE / _zoomFactor)
|
|
{
|
|
return (fromNode, toNode);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private void HandleDeleteClick(NodeBase item)
|
|
{
|
|
if (item == null) return;
|
|
|
|
if (item is MapNode hitNode)
|
|
{
|
|
// 연결된 모든 연결선도 제거
|
|
foreach (var node in _nodes)
|
|
{
|
|
node.RemoveConnection(hitNode.Id);
|
|
}
|
|
|
|
_nodes.Remove(hitNode);
|
|
|
|
if (_selectedNode == hitNode)
|
|
_selectedNode = null;
|
|
|
|
NodeDeleted?.Invoke(this, hitNode);
|
|
}
|
|
else if (item is MapLabel label)
|
|
{
|
|
if (_labels != null) _labels.Remove(label);
|
|
if (_selectedNode.Id.Equals(item.Id)) _selectedNode = null;
|
|
}
|
|
else if (item is MapImage image)
|
|
{
|
|
if (_images != null) _images.Remove(image);
|
|
if (_selectedNode.Id.Equals(item.Id)) _selectedNode = null;
|
|
}
|
|
else if (item is MapMark mark)
|
|
{
|
|
if (_marks != null) _marks.Remove(mark);
|
|
if (_selectedNode.Id.Equals(item.Id)) _selectedNode = null;
|
|
}
|
|
else if (item is MapMagnet magnet)
|
|
{
|
|
if (_magnets != null) _magnets.Remove(magnet);
|
|
if (_selectedNode.Id.Equals(item.Id)) _selectedNode = null;
|
|
}
|
|
MapChanged?.Invoke(this, EventArgs.Empty);
|
|
Invalidate();
|
|
}
|
|
|
|
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 = (double)A * C + (double)B * D;
|
|
var lenSq = (double)C * C + (double)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 = GetItemAt(worldPoint);
|
|
var hitAGV = GetAGVAt(worldPoint);
|
|
|
|
if (hitNode != null)
|
|
tooltipText = $"노드: {hitNode.Id}\n타입: {hitNode.Type}\n위치: ({hitNode.Position.X}, {hitNode.Position.Y})";
|
|
else 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})";
|
|
}
|
|
|
|
// 툴팁 텍스트 갱신 (변경되었을 때만)
|
|
if (_tooltip != null && _tooltip.GetToolTip(this) != tooltipText)
|
|
{
|
|
_tooltip.SetToolTip(this, tooltipText);
|
|
}
|
|
}
|
|
|
|
private int GetHandleAt(Point worldPoint)
|
|
{
|
|
if (_selectedNode == null) return -1;
|
|
|
|
float hitTolerance = (HANDLE_SIZE + 4) / _zoomFactor;
|
|
|
|
if (_selectedNode is MapMagnet magnet)
|
|
{
|
|
if (CalculateDistance(worldPoint, magnet.StartPoint) <= hitTolerance) return 0;
|
|
if (CalculateDistance(worldPoint, magnet.EndPoint) <= hitTolerance) return 1;
|
|
if (magnet.ControlPoint != null)
|
|
{
|
|
if (CalculateDistance(worldPoint, new Point((int)magnet.ControlPoint.X, (int)magnet.ControlPoint.Y)) <= hitTolerance) return 2;
|
|
}
|
|
}
|
|
else if (_selectedNode is MapMark mark)
|
|
{
|
|
int lineLength = (int)mark.Length;
|
|
int halfLength = lineLength / 2;
|
|
double radians = mark.Rotation * Math.PI / 180.0;
|
|
int dx = (int)(halfLength * Math.Cos(radians));
|
|
int dy = (int)(halfLength * Math.Sin(radians));
|
|
|
|
Point p1 = new Point(mark.Position.X - dx, mark.Position.Y - dy);
|
|
Point p2 = new Point(mark.Position.X + dx, mark.Position.Y + dy);
|
|
|
|
if (CalculateDistance(worldPoint, p1) <= hitTolerance) return 0;
|
|
if (CalculateDistance(worldPoint, p2) <= hitTolerance) return 1;
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
|
|
#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;
|
|
|
|
// Graphics Transform: Screen = (World + Pan) * Zoom
|
|
// 중앙에 위치: Width/2 = (centerX + Pan) * Zoom
|
|
// Pan = Width/2 / Zoom - centerX
|
|
_panOffset.X = (int)(Width / 2 / _zoomFactor - centerX);
|
|
_panOffset.Y = (int)(Height / 2 / _zoomFactor - centerY);
|
|
|
|
Invalidate();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 줌 리셋
|
|
/// </summary>
|
|
public void ResetZoom()
|
|
{
|
|
_zoomFactor = 1.0f;
|
|
_panOffset = PointF.Empty;
|
|
Invalidate();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 특정 위치로 이동
|
|
/// </summary>
|
|
public void PanTo(Point worldPoint)
|
|
{
|
|
// Graphics Transform: Screen = (World + Pan) * Zoom
|
|
// 중앙에 위치: Width/2 = (worldPoint + Pan) * Zoom
|
|
// Pan = Width/2 / Zoom - worldPoint
|
|
_panOffset.X = (int)(Width / 2 / _zoomFactor - worldPoint.X);
|
|
_panOffset.Y = (int)(Height / 2 / _zoomFactor - worldPoint.Y);
|
|
Invalidate();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 특정 노드로 이동
|
|
/// </summary>
|
|
public void PanToNode(string nodeId)
|
|
{
|
|
var node = _nodes?.FirstOrDefault(n => n.Id == nodeId);
|
|
if (node != null)
|
|
{
|
|
PanTo(node.Position);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 특정 AGV로 이동
|
|
/// </summary>
|
|
public void PanToAGV(string agvId)
|
|
{
|
|
if (_agvPositions.ContainsKey(agvId))
|
|
{
|
|
PanTo(_agvPositions[agvId]);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
} |