using System.Collections.ObjectModel; using System.Windows; using SheetMe.Core.Layout; namespace SheetMe.Designer.ViewModels; /// /// 오버레이 가이드선 1개 — 월드 좌표 선분(양끝). /// /// 예전에는 위치 하나만 들고 화면 끝에서 끝까지(±100000) 그었다. 그러면 "무엇에 맞춘 것인지"가 /// 안 보인다 — 종이 밖 회색 배경까지 선이 뻗어 대비도 죽었다. 관련된 것들만 잇는다. /// public sealed class GuideLineInfo { public double X1 { get; init; } public double Y1 { get; init; } public double X2 { get; init; } public double Y2 { get; init; } /// 수직선 여부 — 진단·테스트용 파생(뷰는 X1..Y2 만 쓴다) public bool IsVertical => Math.Abs(X1 - X2) < 0.001; /// 정합 좌표 — 진단·테스트용 파생 public double Position => IsVertical ? X1 : Y1; } /// /// 간격 균등 표시 1개 — 막대 하나와 양끝 갈고리 둘. 전부 월드 좌표. /// 좌표를 여기서 다 구워 두는 것은 이 저장소 규약이다( 와 같다) — /// XAML 에서는 산술을 할 수 없다. /// public sealed class SpacingMarkInfo { public double X1 { get; init; } public double Y1 { get; init; } public double X2 { get; init; } public double Y2 { get; init; } /// 시작 쪽 갈고리 public double CapAX1 { get; init; } public double CapAY1 { get; init; } public double CapAX2 { get; init; } public double CapAY2 { get; init; } /// 끝 쪽 갈고리 public double CapBX1 { get; init; } public double CapBY1 { get; init; } public double CapBX2 { get; init; } public double CapBY2 { get; init; } /// 간격값 표시 public string Text { get; init; } = string.Empty; public double TextX { get; init; } public double TextY { get; init; } } /// 잠긴 컨트롤 하나의 자리 — 월드 좌표. 오버레이가 점선 테두리로 그린다. public sealed class LockMarkInfo { /// 좌상단 X(월드) public double X { get; init; } /// 좌상단 Y(월드) public double Y { get; init; } /// 너비(월드) public double W { get; init; } /// 높이(월드) public double H { get; init; } } /// 탭순서 배지 1개 — 월드 좌표(컨트롤 좌상단) public sealed class TabBadgeInfo { /// 배지 X(월드) public double X { get; init; } /// 배지 Y(월드) public double Y { get; init; } /// 표시 텍스트(순번 또는 "–") public string Text { get; init; } = string.Empty; /// 순번 지정 여부(지정=파랑, 미지정=회색) public bool IsAssigned { get; init; } } /// 선택 핸들 1개 — 월드 좌표(중심점) public sealed class HandleInfo { /// 핸들 인덱스(0=NW,1=N,2=NE,3=E,4=SE,5=S,6=SW,7=W) public int Index { get; init; } /// 핸들 좌상단 X(월드) public double X { get; init; } /// 핸들 좌상단 Y(월드) public double Y { get; init; } } /// /// 선택/마퀴/가이드 오버레이 표시 상태 — 전부 월드 좌표. /// 오버레이 뷰는 이 상태를 그리기만 한다(입력 처리 없음). /// public sealed class SelectionOverlayViewModel : ViewModelBase { #region Member Fields /// 핸들 한 변 크기(논리 px) public const double HandleSize = 8; private bool hasSelection; private double selX; private double selY; private double selW; private double selH; private bool showHandles; private bool hasMarquee; private double marX; private double marY; private double marW; private double marH; private double zoom = 1; #endregion #region Properties /// 선택 박스 표시 여부 public bool HasSelection { get => hasSelection; set => SetProperty(ref hasSelection, value); } /// 선택 박스 X(월드) public double SelX { get => selX; set => SetProperty(ref selX, value); } /// 선택 박스 Y(월드) public double SelY { get => selY; set => SetProperty(ref selY, value); } /// 선택 박스 너비 public double SelW { get => selW; set => SetProperty(ref selW, value); } /// 선택 박스 높이 public double SelH { get => selH; set => SetProperty(ref selH, value); } /// 리사이즈 핸들 표시 여부(잠금 선택 시 숨김) public bool ShowHandles { get => showHandles; set => SetProperty(ref showHandles, value); } /// 핸들 8개(월드 좌표) public ObservableCollection Handles { get; } = new(); /// 마퀴 표시 여부 public bool HasMarquee { get => hasMarquee; set => SetProperty(ref hasMarquee, value); } /// 마퀴 X(월드) public double MarX { get => marX; set => SetProperty(ref marX, value); } /// 마퀴 Y(월드) public double MarY { get => marY; set => SetProperty(ref marY, value); } /// 마퀴 너비 public double MarW { get => marW; set => SetProperty(ref marW, value); } /// 마퀴 높이 public double MarH { get => marH; set => SetProperty(ref marH, value); } /// 정렬 가이드선 목록 public ObservableCollection Guides { get; } = new(); /// 간격 균등 표시 목록 public ObservableCollection Spacings { get; } = new(); /// /// 캔버스 줌 — 오버레이는 줌이 걸린 층에 얹혀 있어서, 선을 화면 1px 로 유지하려면 /// 두께에 이 역수를 걸어야 한다. 갈고리 길이도 같은 이유로 이걸 쓴다. /// public double Zoom { get => zoom; set { if (SetProperty(ref zoom, value <= 0 ? 1 : value)) { OnPropertyChanged(nameof(InverseZoom)); } } } /// 화면 1px 에 해당하는 월드 길이 public double InverseZoom => 1.0 / zoom; /// 탭순서 배지 목록(탭순서 편집 모드 전용) public ObservableCollection TabBadges { get; } = new(); /// /// 잠긴 컨트롤의 테두리 — 편집 크롬이라 종이에는 그려지지 않는다. /// /// 이 층에 두는 이유가 있다. 종이 층(PageView)에 표시를 넣으면 --db-render 게이트가 /// 그것을 찍어 md5 가 깨진다 — 그 게이트는 레거시와 픽셀이 같다는 것을 잠그고 있다. /// /// 표시가 필요한 이유: 숨김은 0.25 투명이라 티가 나는데 잠금은 아무 표시가 없었다. /// 클릭도 드래그도 안 되는데 이유가 화면에 없으니 "왜 이것만 안 잡히지"만 남는다. /// public ObservableCollection LockMarks { get; } = new(); #endregion #region Methods /// 선택 박스/핸들 갱신 — bbox 는 월드 좌표 public void UpdateSelection(Rect? bbox, bool handlesVisible) { if (bbox is null || bbox.Value.IsEmpty) { HasSelection = false; ShowHandles = false; Handles.Clear(); return; } var rect = bbox.Value; HasSelection = true; SelX = rect.X; SelY = rect.Y; SelW = rect.Width; SelH = rect.Height; ShowHandles = handlesVisible; Handles.Clear(); if (!handlesVisible) { return; } var half = HandleSize / 2; var positions = HandlePositions(rect); for (var i = 0; i < positions.Length; i++) { Handles.Add(new HandleInfo { Index = i, X = positions[i].X - half, Y = positions[i].Y - half }); } } /// 핸들 중심 좌표 8개(0=NW 시계방향) public static Point[] HandlePositions(Rect rect) => new[] { new Point(rect.Left, rect.Top), new Point(rect.Left + rect.Width / 2, rect.Top), new Point(rect.Right, rect.Top), new Point(rect.Right, rect.Top + rect.Height / 2), new Point(rect.Right, rect.Bottom), new Point(rect.Left + rect.Width / 2, rect.Bottom), new Point(rect.Left, rect.Bottom), new Point(rect.Left, rect.Top + rect.Height / 2), }; /// 마퀴 갱신 — null 이면 숨김 public void UpdateMarquee(Rect? rect) { if (rect is null) { HasMarquee = false; return; } HasMarquee = true; MarX = rect.Value.X; MarY = rect.Value.Y; MarW = rect.Value.Width; MarH = rect.Value.Height; } /// 가이드·간격 표시 지우기 public void ClearGuides() { if (Guides.Count > 0) { Guides.Clear(); } if (Spacings.Count > 0) { Spacings.Clear(); } } /// /// 스냅 결과를 표시형으로 — 계산은 가 끝냈고 /// 여기서는 그리기 좌표만 굽는다. /// /// 내용이 이전과 같으면 컬렉션을 건드리지 않는다. 드래그는 프레임마다 들어오는데 /// 매번 Clear+Add 하면 ItemsControl 이 컨테이너를 통째로 다시 만든다. /// public void SetGuides(IReadOnlyList guides, IReadOnlyList spacings) { if (!SameGuides(guides)) { Guides.Clear(); foreach (var guide in guides) { Guides.Add(guide.IsVertical ? new GuideLineInfo { X1 = guide.Position, X2 = guide.Position, Y1 = guide.Start, Y2 = guide.End } : new GuideLineInfo { Y1 = guide.Position, Y2 = guide.Position, X1 = guide.Start, X2 = guide.End }); } } if (SameSpacings(spacings)) { return; } Spacings.Clear(); // 갈고리는 화면 4px — 줌이 걸린 층이라 월드 길이로 환산해 굽는다 var cap = 4 * InverseZoom; foreach (var mark in spacings) { var mid = (mark.Start + mark.End) / 2; var text = FormatGap(mark.Gap); // 칩은 좌상단 기준으로 놓이므로 중심 보정을 여기서 넣는다(뷰 설명 참조). // 폭은 글꼴을 재지 않고 글자 수로 어림한다 — 1~4자짜리 숫자라 오차가 눈에 안 띈다. var halfWidth = (4 + 3.4 * text.Length) * InverseZoom; var halfHeight = 7 * InverseZoom; Spacings.Add(mark.IsHorizontal ? new SpacingMarkInfo { X1 = mark.Start, Y1 = mark.Cross, X2 = mark.End, Y2 = mark.Cross, CapAX1 = mark.Start, CapAY1 = mark.Cross - cap, CapAX2 = mark.Start, CapAY2 = mark.Cross + cap, CapBX1 = mark.End, CapBY1 = mark.Cross - cap, CapBX2 = mark.End, CapBY2 = mark.Cross + cap, Text = text, TextX = mid - halfWidth, TextY = mark.Cross - halfHeight, } : new SpacingMarkInfo { X1 = mark.Cross, Y1 = mark.Start, X2 = mark.Cross, Y2 = mark.End, CapAX1 = mark.Cross - cap, CapAY1 = mark.Start, CapAX2 = mark.Cross + cap, CapAY2 = mark.Start, CapBX1 = mark.Cross - cap, CapBY1 = mark.End, CapBX2 = mark.Cross + cap, CapBY2 = mark.End, Text = text, TextX = mark.Cross - halfWidth, TextY = mid - halfHeight, }); } } private static string FormatGap(double gap) => $"{Math.Round(gap):0}"; private bool SameGuides(IReadOnlyList guides) { if (Guides.Count != guides.Count) { return false; } for (var i = 0; i < guides.Count; i++) { var a = Guides[i]; var b = guides[i]; var x1 = b.IsVertical ? b.Position : b.Start; var y1 = b.IsVertical ? b.Start : b.Position; var x2 = b.IsVertical ? b.Position : b.End; var y2 = b.IsVertical ? b.End : b.Position; if (Math.Abs(a.X1 - x1) > 0.001 || Math.Abs(a.Y1 - y1) > 0.001 || Math.Abs(a.X2 - x2) > 0.001 || Math.Abs(a.Y2 - y2) > 0.001) { return false; } } return true; } private bool SameSpacings(IReadOnlyList spacings) { if (Spacings.Count != spacings.Count) { return false; } for (var i = 0; i < spacings.Count; i++) { var a = Spacings[i]; var b = spacings[i]; var x1 = b.IsHorizontal ? b.Start : b.Cross; var y1 = b.IsHorizontal ? b.Cross : b.Start; if (Math.Abs(a.X1 - x1) > 0.001 || Math.Abs(a.Y1 - y1) > 0.001) { return false; } } return true; } #endregion }