피그마식 스냅 — 여러 선이 동시에, 관련된 것만 잇고, 같은 간격이면 그렇다고 말한다

정렬 스냅 자체는 있었다. 다만 축마다 선 하나만, 그것도 화면 끝에서 끝까지 긋는 무한 점선이라
무엇에 맞춘 것인지가 안 보였다. 판단을 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>
This commit is contained in:
Msystech
2026-08-14 09:23:42 +09:00
co-authored by Claude Opus 5
parent 8a7cc65099
commit ba838ae54a
13 changed files with 1706 additions and 151 deletions
@@ -1,16 +1,58 @@
using System.Collections.ObjectModel;
using System.Windows;
using SheetMe.Core.Layout;
namespace SheetMe.Designer.ViewModels;
/// <summary>오버레이 가이드선 1개 — 월드 좌표 수직/수평선</summary>
/// <summary>
/// 오버레이 가이드선 1개 — 월드 좌표 <b>선분</b>(양끝).
///
/// 예전에는 위치 하나만 들고 화면 끝에서 끝까지(±100000) 그었다. 그러면 "무엇에 맞춘 것인지"가
/// 안 보인다 — 종이 밖 회색 배경까지 선이 뻗어 대비도 죽었다. 관련된 것들만 잇는다.
/// </summary>
public sealed class GuideLineInfo
{
/// <summary>수직선 여부(false=수평선)</summary>
public bool IsVertical { get; init; }
public double X1 { get; init; }
public double Y1 { get; init; }
public double X2 { get; init; }
public double Y2 { get; init; }
/// <summary>선 위치(월드 X 또는 Y)</summary>
public double Position { 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>
@@ -63,6 +105,7 @@ public sealed class SelectionOverlayViewModel : ViewModelBase
private double marY;
private double marW;
private double marH;
private double zoom = 1;
#endregion
#region Properties
@@ -105,6 +148,28 @@ public sealed class SelectionOverlayViewModel : ViewModelBase
/// <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
@@ -170,18 +235,115 @@ public sealed class SelectionOverlayViewModel : ViewModelBase
MarH = rect.Value.Height;
}
/// <summary>가이드선 교체</summary>
public void SetGuides(double? guideX, double? guideY)
/// <summary>가이드·간격 표시 지우기</summary>
public void ClearGuides()
{
Guides.Clear();
if (guideX is not null)
if (Guides.Count > 0)
{
Guides.Add(new GuideLineInfo { IsVertical = true, Position = guideX.Value });
Guides.Clear();
}
if (guideY is not null)
if (Spacings.Count > 0)
{
Guides.Add(new GuideLineInfo { IsVertical = false, Position = guideY.Value });
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
}