정렬 스냅 자체는 있었다. 다만 축마다 선 하나만, 그것도 화면 끝에서 끝까지 긋는 무한 점선이라 무엇에 맞춘 것인지가 안 보였다. 판단을 SheetMe.Core 로 옮기고 후보의 출처를 끝까지 들고 간다. 왜 옮겼나. 스냅은 눈으로만 확인되는 종류의 코드다 — 후보 하나, 열거 순서 하나가 바뀌면 붙는 자리가 조용히 달라지고 드래그해 보기 전엔 아무도 모른다. Core/Layout 의 SnapSolver 는 순수 기하이고 단위 테스트 27건이 승자 규칙·가이드 개수·선분 범위·간격 계산을 못 박는다. SnapEngine 에는 "어떤 사각형이 후보인가"와 좌표 변환만 남았다. 달라진 것들: · 동시에 맞는 정렬은 전부 보인다. 폭이 같은 형제 옆에 대면 좌·중·우 세 줄이 함께 뜬다. 보정량은 승자 하나만 쓰되, 그 보정을 적용했을 때 실제로 맞는 후보만 선으로 낸다 — |거리|만 같고 부호가 반대인 후보까지 그리면 '붙었다고 그려 놓고 안 붙은' 선이 된다. · 선이 선분이 됐다. 형제 가이드는 관련된 것들의 합집합 범위까지만, 용지 가이드는 그 용지 전체. 같은 좌표의 형제 여럿은 한 선으로 합치고 범위만 넓힌다. 덤으로 대비 문제도 풀렸다 — 예전 분홍(#FF4FA3)은 종이 밖 회색 배경 위에서 2.1:1 로 죽었다. · 허용 오차가 화면 기준이 됐다. 월드 고정이면 줌 50%에서 두 배로 끈적이고 200%에서 절반으로 미끄러진다 — 손끝 감각은 화면 거리로 정해진다. 핸들 히트 반경도 같은 병이라 함께 고쳤다 (줌 300%에서 그림은 8px 인데 판정이 18px 였다). 선 두께도 화면 1px 로 고정한다. · 승자가 결정적이 됐다. 예전에는 컨트롤 열거 순서가 동률을 갈랐다. 이제 |거리| → 종류(형제 모서리 > 형제 중심 > 용지 경계 > 용지 중앙) → 좌표 → 모서리 순이다. · 간격 균등. 형제들이 이미 같은 간격으로 늘어서 있으면 그 리듬에 끼거나 이어 붙는 자리에 붙고, 같은 값이 된 빈틈 전부에 막대와 수치 칩을 놓는다. 양옆만 재면 "30"이 한 번 뜰 뿐이라 무엇과 같아졌는지 안 보인다. 같은 축에서 정렬과 동시에 사정권이면 정렬이 이긴다 — 거리로 겨루게 하면 한 픽셀 차이로 표시가 선↔막대로 튀어 종잡을 수 없다. · 후보 범위. 컨테이너 자식도 이제 후보다(히트테스트는 재귀하는데 스냅만 최상위를 보던 규칙 불일치). 대신 끌려가는 컨테이너의 자식은 빼야 한다 — 선택 목록엔 없지만 함께 움직이므로 빼지 않으면 컨테이너가 제 자식에게 붙어 그 자리에서 굳는다. · 리사이즈. 후보를 이동 드래그에서만 모으고 있어서, 앱을 켜자마자 핸들을 잡으면 스냅이 없고 이동을 한 번 한 뒤에는 그때의 낡은 좌표에 붙었다. 리사이즈 시작에도 모으고 놓을 때 버린다. 모서리 스냅이 '첫 매치'였던 것도 이동과 같은 최근접 규칙으로 통일했다. 가이드도 함께 보인다. · 정합된 축은 반올림하지 않는다. 형제 폭이 홀수면 중심이 반정수라, 반올림하면 중앙에 맞췄는데 0.5px 어긋난 채로 놓인다. 무한선일 땐 안 보였지만 선분이 되면 모서리에서 뜬 게 눈에 띈다. 저장은 LegacyFormat.FormatPair 가 어차피 정수로 쓰므로 포맷은 영향 없다. 성능 가드: 한 줄에 겹치는 형제가 64개 넘으면 간격 계산을 건너뛰고(정렬은 정상), 형제가 2000개 넘으면 간격을 끈다. 후보 정렬은 드래그 시작에 1회, 프레임 경로는 무할당. 가이드 내용이 이전과 같으면 컬렉션을 건드리지 않는다. 신규 진단 --snap-shots — 종이와 오버레이를 겹쳐 드래그 도중을 찍는다. 기존 --render-smoke/--db-render 는 종이만 찍어 오버레이가 안 나왔다. 선이 어디서 어디까지 그어지는지, 몇 개가 동시에 보이는지는 그림으로만 확인된다. 게이트는 함께 남기는 _report.txt 의 수치로 걸고 PNG 는 눈 검증 보조다(픽셀 비교는 테마·글꼴에 흔들려 게이트로 못 쓴다). 게이트: 테스트 257/257(신규 27), --edit-smoke 223건 0실패(스냅 검사 14건 신규), --query-popup 17/17, --maxrect 10/10, --db-smoke 3000 diff 0·예외 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일, --snap-shots 7종 0실패. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
350 lines
12 KiB
C#
350 lines
12 KiB
C#
using System.Collections.ObjectModel;
|
||
using System.Windows;
|
||
using SheetMe.Core.Layout;
|
||
|
||
namespace SheetMe.Designer.ViewModels;
|
||
|
||
/// <summary>
|
||
/// 오버레이 가이드선 1개 — 월드 좌표 <b>선분</b>(양끝).
|
||
///
|
||
/// 예전에는 위치 하나만 들고 화면 끝에서 끝까지(±100000) 그었다. 그러면 "무엇에 맞춘 것인지"가
|
||
/// 안 보인다 — 종이 밖 회색 배경까지 선이 뻗어 대비도 죽었다. 관련된 것들만 잇는다.
|
||
/// </summary>
|
||
public sealed class GuideLineInfo
|
||
{
|
||
public double X1 { get; init; }
|
||
public double Y1 { get; init; }
|
||
public double X2 { get; init; }
|
||
public double Y2 { get; init; }
|
||
|
||
/// <summary>수직선 여부 — 진단·테스트용 파생(뷰는 X1..Y2 만 쓴다)</summary>
|
||
public bool IsVertical => Math.Abs(X1 - X2) < 0.001;
|
||
|
||
/// <summary>정합 좌표 — 진단·테스트용 파생</summary>
|
||
public double Position => IsVertical ? X1 : Y1;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 간격 균등 표시 1개 — 막대 하나와 양끝 갈고리 둘. 전부 월드 좌표.
|
||
/// 좌표를 여기서 다 구워 두는 것은 이 저장소 규약이다(<see cref="HandleInfo"/> 와 같다) —
|
||
/// XAML 에서는 산술을 할 수 없다.
|
||
/// </summary>
|
||
public sealed class SpacingMarkInfo
|
||
{
|
||
public double X1 { get; init; }
|
||
public double Y1 { get; init; }
|
||
public double X2 { get; init; }
|
||
public double Y2 { get; init; }
|
||
|
||
/// <summary>시작 쪽 갈고리</summary>
|
||
public double CapAX1 { get; init; }
|
||
public double CapAY1 { get; init; }
|
||
public double CapAX2 { get; init; }
|
||
public double CapAY2 { get; init; }
|
||
|
||
/// <summary>끝 쪽 갈고리</summary>
|
||
public double CapBX1 { get; init; }
|
||
public double CapBY1 { get; init; }
|
||
public double CapBX2 { get; init; }
|
||
public double CapBY2 { get; init; }
|
||
|
||
/// <summary>간격값 표시</summary>
|
||
public string Text { get; init; } = string.Empty;
|
||
|
||
public double TextX { get; init; }
|
||
public double TextY { get; init; }
|
||
}
|
||
|
||
/// <summary>탭순서 배지 1개 — 월드 좌표(컨트롤 좌상단)</summary>
|
||
public sealed class TabBadgeInfo
|
||
{
|
||
/// <summary>배지 X(월드)</summary>
|
||
public double X { get; init; }
|
||
|
||
/// <summary>배지 Y(월드)</summary>
|
||
public double Y { get; init; }
|
||
|
||
/// <summary>표시 텍스트(순번 또는 "–")</summary>
|
||
public string Text { get; init; } = string.Empty;
|
||
|
||
/// <summary>순번 지정 여부(지정=파랑, 미지정=회색)</summary>
|
||
public bool IsAssigned { get; init; }
|
||
}
|
||
|
||
/// <summary>선택 핸들 1개 — 월드 좌표(중심점)</summary>
|
||
public sealed class HandleInfo
|
||
{
|
||
/// <summary>핸들 인덱스(0=NW,1=N,2=NE,3=E,4=SE,5=S,6=SW,7=W)</summary>
|
||
public int Index { get; init; }
|
||
|
||
/// <summary>핸들 좌상단 X(월드)</summary>
|
||
public double X { get; init; }
|
||
|
||
/// <summary>핸들 좌상단 Y(월드)</summary>
|
||
public double Y { get; init; }
|
||
}
|
||
|
||
/// <summary>
|
||
/// 선택/마퀴/가이드 오버레이 표시 상태 — 전부 월드 좌표.
|
||
/// 오버레이 뷰는 이 상태를 그리기만 한다(입력 처리 없음).
|
||
/// </summary>
|
||
public sealed class SelectionOverlayViewModel : ViewModelBase
|
||
{
|
||
#region Member Fields
|
||
/// <summary>핸들 한 변 크기(논리 px)</summary>
|
||
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
|
||
/// <summary>선택 박스 표시 여부</summary>
|
||
public bool HasSelection { get => hasSelection; set => SetProperty(ref hasSelection, value); }
|
||
|
||
/// <summary>선택 박스 X(월드)</summary>
|
||
public double SelX { get => selX; set => SetProperty(ref selX, value); }
|
||
|
||
/// <summary>선택 박스 Y(월드)</summary>
|
||
public double SelY { get => selY; set => SetProperty(ref selY, value); }
|
||
|
||
/// <summary>선택 박스 너비</summary>
|
||
public double SelW { get => selW; set => SetProperty(ref selW, value); }
|
||
|
||
/// <summary>선택 박스 높이</summary>
|
||
public double SelH { get => selH; set => SetProperty(ref selH, value); }
|
||
|
||
/// <summary>리사이즈 핸들 표시 여부(잠금 선택 시 숨김)</summary>
|
||
public bool ShowHandles { get => showHandles; set => SetProperty(ref showHandles, value); }
|
||
|
||
/// <summary>핸들 8개(월드 좌표)</summary>
|
||
public ObservableCollection<HandleInfo> Handles { get; } = new();
|
||
|
||
/// <summary>마퀴 표시 여부</summary>
|
||
public bool HasMarquee { get => hasMarquee; set => SetProperty(ref hasMarquee, value); }
|
||
|
||
/// <summary>마퀴 X(월드)</summary>
|
||
public double MarX { get => marX; set => SetProperty(ref marX, value); }
|
||
|
||
/// <summary>마퀴 Y(월드)</summary>
|
||
public double MarY { get => marY; set => SetProperty(ref marY, value); }
|
||
|
||
/// <summary>마퀴 너비</summary>
|
||
public double MarW { get => marW; set => SetProperty(ref marW, value); }
|
||
|
||
/// <summary>마퀴 높이</summary>
|
||
public double MarH { get => marH; set => SetProperty(ref marH, value); }
|
||
|
||
/// <summary>정렬 가이드선 목록</summary>
|
||
public ObservableCollection<GuideLineInfo> Guides { get; } = new();
|
||
|
||
/// <summary>간격 균등 표시 목록</summary>
|
||
public ObservableCollection<SpacingMarkInfo> Spacings { get; } = new();
|
||
|
||
/// <summary>
|
||
/// 캔버스 줌 — 오버레이는 줌이 걸린 층에 얹혀 있어서, 선을 화면 1px 로 유지하려면
|
||
/// 두께에 이 역수를 걸어야 한다. 갈고리 길이도 같은 이유로 이걸 쓴다.
|
||
/// </summary>
|
||
public double Zoom
|
||
{
|
||
get => zoom;
|
||
set
|
||
{
|
||
if (SetProperty(ref zoom, value <= 0 ? 1 : value))
|
||
{
|
||
OnPropertyChanged(nameof(InverseZoom));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>화면 1px 에 해당하는 월드 길이</summary>
|
||
public double InverseZoom => 1.0 / zoom;
|
||
|
||
/// <summary>탭순서 배지 목록(탭순서 편집 모드 전용)</summary>
|
||
public ObservableCollection<TabBadgeInfo> TabBadges { get; } = new();
|
||
#endregion
|
||
|
||
#region Methods
|
||
/// <summary>선택 박스/핸들 갱신 — bbox 는 월드 좌표</summary>
|
||
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 });
|
||
}
|
||
}
|
||
|
||
/// <summary>핸들 중심 좌표 8개(0=NW 시계방향)</summary>
|
||
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),
|
||
};
|
||
|
||
/// <summary>마퀴 갱신 — null 이면 숨김</summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>가이드·간격 표시 지우기</summary>
|
||
public void ClearGuides()
|
||
{
|
||
if (Guides.Count > 0)
|
||
{
|
||
Guides.Clear();
|
||
}
|
||
if (Spacings.Count > 0)
|
||
{
|
||
Spacings.Clear();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 스냅 결과를 표시형으로 — 계산은 <see cref="SheetMe.Core.Layout.SnapSolver"/> 가 끝냈고
|
||
/// 여기서는 그리기 좌표만 굽는다.
|
||
///
|
||
/// 내용이 이전과 같으면 컬렉션을 건드리지 않는다. 드래그는 프레임마다 들어오는데
|
||
/// 매번 Clear+Add 하면 ItemsControl 이 컨테이너를 통째로 다시 만든다.
|
||
/// </summary>
|
||
public void SetGuides(IReadOnlyList<SnapGuide> guides, IReadOnlyList<SpacingMark> 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<SnapGuide> 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<SpacingMark> 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
|
||
}
|