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

정렬 스냅 자체는 있었다. 다만 축마다 선 하나만, 그것도 화면 끝에서 끝까지 긋는 무한 점선이라
무엇에 맞춘 것인지가 안 보였다. 판단을 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
@@ -1093,6 +1093,122 @@ public static class EditSmoke
dirtyDoc.Undo.Undo();
Check("더티: 분기 후에는 저장 지점 소실로 계속 dirty", dirtyDoc.Undo.IsDirty);
// 24-2) 스냅 — 정렬 가이드와 간격 균등.
// 별도 문서에 붙인다. 메인 designer 에 붙이면 아래 '전체 Undo' 의 guard(80)를
// 드래그마다 잡아먹어 그 검사가 조용히 깨진다(UndoService 용량 100).
{
var snapDoc = new DesignerViewModel(business.CreateNew());
ControlViewModel Place(DesignerViewModel doc, string type, double x, double y, double w, double h)
{
doc.AddControlAt(type, new Point(x, y));
var vm = doc.Pages[0].Controls[^1];
vm.X = x;
vm.Y = y;
vm.Width = w;
vm.Height = h;
return vm;
}
static Point Middle(ControlViewModel vm) => new(vm.X + vm.Width / 2, vm.Y + vm.Height / 2);
// 형제 둘을 같은 좌변에 세로로 벌려 둔다 — 가이드 병합과 선분 범위를 함께 본다
var anchorTop = Place(snapDoc, "Label", 200, 100, 60, 20);
var anchorBottom = Place(snapDoc, "Label", 200, 400, 60, 20);
// 폭을 형제와 다르게 둔다 — 같으면 좌·중·우가 동시에 맞아 가이드가 3개 나온다
// (그것도 옳은 동작이지만 여기서 보려는 것은 '같은 좌표의 형제 둘이 하나로 합쳐지는가'다)
var mover = Place(snapDoc, "Label", 197, 250, 40, 20);
var grab = Middle(mover);
snapDoc.Interaction.PointerDown(grab, none);
snapDoc.Interaction.PointerMove(new Point(grab.X + 3, grab.Y), none);
Check("스냅: 형제 좌변에 붙는다", mover.X == anchorTop.X, $"실제 {mover.X}");
Check("스냅: 같은 좌표 형제 둘이 가이드 하나로 병합된다", snapDoc.Overlay.Guides.Count == 1,
$"실제 {snapDoc.Overlay.Guides.Count}");
var guide = snapDoc.Overlay.Guides.Count > 0 ? snapDoc.Overlay.Guides[0] : null;
Check("스냅: 가이드가 무한선이 아니라 선분이다",
guide is not null && guide.IsVertical && guide.Y2 - guide.Y1 < 1000,
guide is null ? "가이드 없음" : $"길이 {guide.Y2 - guide.Y1}");
Check("스냅: 선분이 관련된 형제 전부를 덮는다",
guide is not null && guide.Y1 <= anchorTop.Y && guide.Y2 >= anchorBottom.Y + anchorBottom.Height,
guide is null ? "가이드 없음" : $"{guide.Y1}~{guide.Y2}");
snapDoc.Interaction.PointerUp(new Point(grab.X + 3, grab.Y), none);
Check("스냅: 놓으면 표시가 사라진다",
snapDoc.Overlay.Guides.Count == 0 && snapDoc.Overlay.Spacings.Count == 0);
// Alt 는 격자까지 전부 끈다
var freeStart = Middle(mover);
var beforeX = mover.X;
snapDoc.Interaction.PointerDown(freeStart, none);
snapDoc.Interaction.PointerMove(new Point(freeStart.X + 3, freeStart.Y), alt);
Check("스냅: Alt 는 가이드를 내지 않는다", snapDoc.Overlay.Guides.Count == 0);
Check("스냅: Alt 는 정확히 그만큼 옮긴다", mover.X == beforeX + 3, $"실제 {mover.X}");
snapDoc.Interaction.PointerUp(new Point(freeStart.X + 3, freeStart.Y), alt);
// 간격 균등 — 30 간격 둘 뒤에 이어 붙는다
var spacingDoc = new DesignerViewModel(business.CreateNew());
Place(spacingDoc, "Label", 100, 600, 50, 20);
Place(spacingDoc, "Label", 180, 600, 50, 20); // 간격 30
var third = Place(spacingDoc, "Label", 257, 600, 40, 20); // 260 이면 균등
var spacingGrab = Middle(third);
spacingDoc.Interaction.PointerDown(spacingGrab, none);
spacingDoc.Interaction.PointerMove(new Point(spacingGrab.X + 3, spacingGrab.Y), none);
Check("스냅: 같은 간격으로 이어 붙는다", third.X == 260, $"실제 {third.X}");
Check("스냅: 간격 막대가 보인다", spacingDoc.Overlay.Spacings.Count >= 1,
$"실제 {spacingDoc.Overlay.Spacings.Count}");
spacingDoc.Interaction.PointerUp(new Point(spacingGrab.X + 3, spacingGrab.Y), none);
// 컨테이너 자식도 후보가 된다 / 컨테이너는 제 자식에게 붙지 않는다
var nestDoc = new DesignerViewModel(business.CreateNew());
var nestPanel = Place(nestDoc, "Panel", 100, 100, 300, 200) as ContainerViewModel;
if (nestPanel is not null)
{
nestDoc.Selection.SetSingle(nestPanel);
nestDoc.AddControlAt("Label", new Point(150, 150));
var nestChild = nestPanel.Children.Count > 0 ? nestPanel.Children[^1] : null;
if (nestChild is not null)
{
nestChild.X = 50; // 월드 150
nestChild.Y = 50;
nestChild.Width = 60;
nestChild.Height = 20;
var outsider = Place(nestDoc, "Label", 147, 500, 60, 20);
nestDoc.Selection.SetSingle(outsider);
var outGrab = Middle(outsider);
nestDoc.Interaction.PointerDown(outGrab, none);
nestDoc.Interaction.PointerMove(new Point(outGrab.X + 3, outGrab.Y), none);
Check("스냅: 컨테이너 자식도 후보가 된다", outsider.X == 150, $"실제 {outsider.X}");
nestDoc.Interaction.PointerUp(new Point(outGrab.X + 3, outGrab.Y), none);
var panelBefore = nestPanel.X;
var panelGrab = new Point(nestPanel.X + 5, nestPanel.Y + 5);
nestDoc.Selection.SetSingle(nestPanel);
nestDoc.Interaction.PointerDown(panelGrab, none);
nestDoc.Interaction.PointerMove(new Point(panelGrab.X + 40, panelGrab.Y), none);
Check("스냅: 컨테이너는 제 자식에게 붙지 않는다", nestPanel.X != panelBefore,
$"실제 {nestPanel.X} (시작 {panelBefore})");
nestDoc.Interaction.PointerUp(new Point(panelGrab.X + 40, panelGrab.Y), none);
}
}
// 리사이즈 — 이동 드래그를 한 번도 안 한 상태에서 바로 핸들을 잡아도 붙어야 한다
var resizeDoc = new DesignerViewModel(business.CreateNew());
var wall = Place(resizeDoc, "Label", 300, 100, 60, 200);
var box = Place(resizeDoc, "Label", 100, 150, 100, 40);
resizeDoc.Selection.SetSingle(box);
resizeDoc.RefreshOverlay();
var eastHandle = new Point(box.X + box.Width, box.Y + box.Height / 2);
resizeDoc.Interaction.PointerDown(eastHandle, none);
resizeDoc.Interaction.PointerMove(new Point(298, eastHandle.Y), none);
Check("스냅: 리사이즈가 첫 드래그부터 형제 모서리에 붙는다",
box.X + box.Width == wall.X, $"실제 우변 {box.X + box.Width}");
Check("스냅: 리사이즈에도 가이드가 보인다", resizeDoc.Overlay.Guides.Count >= 1,
$"실제 {resizeDoc.Overlay.Guides.Count}");
resizeDoc.Interaction.PointerUp(new Point(298, eastHandle.Y), none);
Check("스냅: 리사이즈 후에도 표시가 사라진다", resizeDoc.Overlay.Guides.Count == 0);
}
// 25) 연속 Undo 로 빈 문서까지
var guard = 0;
while (designer.Undo.CanUndo && guard++ < 80)