Files
SheetMe/src/SheetMe.Designer/ViewModels/SelectionOverlayViewModel.cs
T
MsystechandClaude Opus 5 ba838ae54a 피그마식 스냅 — 여러 선이 동시에, 관련된 것만 잇고, 같은 간격이면 그렇다고 말한다
정렬 스냅 자체는 있었다. 다만 축마다 선 하나만, 그것도 화면 끝에서 끝까지 긋는 무한 점선이라
무엇에 맞춘 것인지가 안 보였다. 판단을 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>
2026-08-14 09:23:42 +09:00

350 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}