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

정렬 스냅 자체는 있었다. 다만 축마다 선 하나만, 그것도 화면 끝에서 끝까지 긋는 무한 점선이라
무엇에 맞춘 것인지가 안 보였다. 판단을 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,4 +1,5 @@
using System.Windows;
using SheetMe.Core.Layout;
using SheetMe.Designer.ViewModels;
using SheetMe.Designer.ViewModels.Controls;
@@ -78,6 +79,9 @@ public sealed class InteractionController
mode = Mode.Resize;
resizeHandleIndex = handleIndex;
CaptureOrigBounds();
// 리사이즈도 후보를 모아야 한다. 예전에는 여기서 안 모아서, 이동 드래그를 한 번 한 뒤라야
// 리사이즈 스냅이 — 그것도 그때의 낡은 좌표로 — 동작했다.
BeginSnap();
return;
}
@@ -188,11 +192,13 @@ public sealed class InteractionController
case Mode.Move:
designer.ReassignPagesAfterMove(origBounds.Keys.ToList());
designer.Overlay.SetGuides(null, null);
designer.Overlay.ClearGuides();
designer.Snap.EndDrag();
break;
case Mode.Resize:
designer.Overlay.SetGuides(null, null);
designer.Overlay.ClearGuides();
designer.Snap.EndDrag();
break;
case Mode.Marquee:
@@ -236,7 +242,8 @@ public sealed class InteractionController
vm.Width = rect.Width;
vm.Height = rect.Height;
}
designer.Overlay.SetGuides(null, null);
designer.Overlay.ClearGuides();
designer.Snap.EndDrag();
mode = Mode.None;
origBounds.Clear();
designer.RefreshOverlay();
@@ -287,16 +294,60 @@ public sealed class InteractionController
#endregion
#region Methods - Private
/// <summary>리사이즈로 맞은 모서리를 선분 가이드로 — 이동 때와 같은 모양으로 보여야 한다</summary>
private static IReadOnlyList<SnapGuide> EdgeGuides(SnapEdgeOutcome? hitX, SnapEdgeOutcome? hitY, Rect rect)
{
if (hitX is not { Snapped: true } && hitY is not { Snapped: true })
{
return Array.Empty<SnapGuide>();
}
var list = new List<SnapGuide>(2);
if (hitX is { Snapped: true } x)
{
var start = rect.Top;
var end = rect.Bottom;
foreach (var source in x.Matched)
{
start = Math.Min(start, source.Top);
end = Math.Max(end, source.Bottom);
}
list.Add(new SnapGuide(true, x.Value, start, end, x.Kind));
}
if (hitY is { Snapped: true } y)
{
var start = rect.Left;
var end = rect.Right;
foreach (var source in y.Matched)
{
start = Math.Min(start, source.Left);
end = Math.Max(end, source.Right);
}
list.Add(new SnapGuide(false, y.Value, start, end, y.Kind));
}
return list;
}
private void BeginMove()
{
CaptureOrigBounds();
BeginSnap();
}
/// <summary>
/// 스냅 후보 수집 — 이동·리사이즈 공용.
///
/// 제외 집합은 선택 항목만으로는 모자라다. 끌려가는 컨테이너의 자식은 선택에 없지만 함께 움직이므로,
/// 빼지 않으면 컨테이너가 제 자식에게 붙어 그 자리에서 굳는다.
/// </summary>
private void BeginSnap()
{
// Primary 는 없을 수 있다(선택 항목은 있는데 주선택이 비는 경로가 있다) —
// ?! 로 넘기면 PageOf → RootAncestorOf 가 즉시 역참조해 이동 시작이 예외로 죽는다
var anchor = designer.Selection.Primary ?? designer.Selection.Items.FirstOrDefault();
var page = (anchor is null ? null : designer.PageOf(anchor)) ?? designer.Pages.FirstOrDefault();
if (page is not null)
{
designer.Snap.BeginDrag(page, designer.Selection.Items.ToHashSet());
designer.Snap.BeginDrag(designer, page, designer.SelectionWithDescendants());
}
}
@@ -307,15 +358,20 @@ public sealed class InteractionController
var dx = world.X - downPoint.X;
var dy = world.Y - downPoint.Y;
var snap = designer.Snap.SnapMove(groupBounds0, dx, dy, ctx.Alt);
designer.Overlay.SetGuides(snap.GuideX, snap.GuideY);
var snap = designer.Snap.SnapMove(groupBounds0, dx, dy, ctx.Alt, designer.Zoom);
designer.Overlay.SetGuides(snap.Guides, snap.Spacings);
// origBounds 는 월드 좌표 — 로컬 = (원본 월드 + 보정 델타) - 부모 체인 오프셋
foreach (var (vm, rect) in origBounds)
{
var offset = designer.ParentWorldOffset(vm);
vm.X = Math.Round(rect.X + snap.Dx - offset.X);
vm.Y = Math.Round(rect.Y + snap.Dy - offset.Y);
var x = rect.X + snap.Dx - offset.X;
var y = rect.Y + snap.Dy - offset.Y;
// 정합된 축은 반올림하지 않는다. 형제 폭이 홀수면 중심이 반정수라, 여기서 반올림하면
// 중앙에 맞췄는데 0.5px 어긋난 채로 놓이고 가이드선이 모서리에서 떠 보인다.
// 저장은 LegacyFormat.FormatPair 가 어차피 정수로 쓰므로 포맷은 상관없다.
vm.X = snap.SnappedX ? x : Math.Round(x);
vm.Y = snap.SnappedY ? y : Math.Round(y);
}
designer.Selection.NotifyBoundsChanged();
@@ -332,21 +388,27 @@ public sealed class InteractionController
var bottom = rect.Bottom;
// 핸들별 이동 모서리 (0=NW,1=N,2=NE,3=E,4=SE,5=S,6=SW,7=W)
SnapEdgeOutcome? hitX = null;
SnapEdgeOutcome? hitY = null;
if (resizeHandleIndex is 0 or 6 or 7)
{
left = designer.Snap.SnapEdge(world.X, isXAxis: true, ctx.Alt);
hitX = designer.Snap.SnapEdge(world.X, isXAxis: true, ctx.Alt, designer.Zoom);
left = hitX.Value.Value;
}
if (resizeHandleIndex is 2 or 3 or 4)
{
right = designer.Snap.SnapEdge(world.X, isXAxis: true, ctx.Alt);
hitX = designer.Snap.SnapEdge(world.X, isXAxis: true, ctx.Alt, designer.Zoom);
right = hitX.Value.Value;
}
if (resizeHandleIndex is 0 or 1 or 2)
{
top = designer.Snap.SnapEdge(world.Y, isXAxis: false, ctx.Alt);
hitY = designer.Snap.SnapEdge(world.Y, isXAxis: false, ctx.Alt, designer.Zoom);
top = hitY.Value.Value;
}
if (resizeHandleIndex is 4 or 5 or 6)
{
bottom = designer.Snap.SnapEdge(world.Y, isXAxis: false, ctx.Alt);
hitY = designer.Snap.SnapEdge(world.Y, isXAxis: false, ctx.Alt, designer.Zoom);
bottom = hitY.Value.Value;
}
var newRect = new Rect(
@@ -355,6 +417,10 @@ public sealed class InteractionController
Math.Max(MinSize, right - left),
Math.Max(MinSize, bottom - top));
// 선분은 최종 사각형이 정해진 뒤에 조립한다 — 교차축도 스냅으로 바뀔 수 있어서,
// 스냅 계산 시점의 좌표로 범위를 만들면 선이 어긋난 자리에 걸린다.
designer.Overlay.SetGuides(EdgeGuides(hitX, hitY, newRect), Array.Empty<SpacingMark>());
// 그룹 비례 스케일 — origBounds 기준 절대 재계산
var sx = newRect.Width / Math.Max(1, groupBounds0.Width);
var sy = newRect.Height / Math.Max(1, groupBounds0.Height);
@@ -409,7 +475,8 @@ public sealed class InteractionController
var positions = SelectionOverlayViewModel.HandlePositions(bbox);
// 반경 내 '최근접' 핸들 선택 — 작은 컨트롤에서 인접 핸들 오인 방지
const double hitRadius = 6;
// 화면 기준 6px — 월드 고정이면 줌 300%에서 그림은 8px 인데 판정이 18px 로 벌어진다
var hitRadius = 6 / designer.Zoom;
var best = -1;
var bestDistance = double.MaxValue;
for (var i = 0; i < positions.Length; i++)
+53 -92
View File
@@ -1,125 +1,86 @@
using System.Windows;
using SheetMe.Core.Layout;
using SheetMe.Designer.ViewModels;
using SheetMe.Designer.ViewModels.Controls;
namespace SheetMe.Designer.Services;
/// <summary>스냅 결과 — 보정된 이동량과 표시할 가이드 좌표(월드)</summary>
public readonly record struct SnapResult(double Dx, double Dy, double? GuideX, double? GuideY);
/// <summary>
/// 그리드/정렬 스냅 순수 계산.
/// 드래그 시작 시 후보(비선택 형제의 좌/중/우·상/중/하 + 용지 경계/중앙)를 1회 사전수집해
/// 프레임당 재수집을 피한다.
/// 스냅 — 뷰모델과 순수 계산(<see cref="SnapSolver"/>) 사이의 얇은 어댑터.
///
/// <b>왜 계산이 여기 없는가.</b> 스냅 규칙은 눈으로만 확인되는 종류의 코드다 — 후보 하나,
/// 열거 순서 하나가 바뀌면 붙는 자리가 조용히 달라지고 드래그해 보기 전엔 아무도 모른다.
/// 그래서 판단은 전부 SheetMe.Core 로 옮겨 단위 테스트로 못 박고, 여기에는
/// "어떤 사각형들이 후보인가"와 좌표 변환만 남긴다.
///
/// 허용 오차는 <b>화면 픽셀</b>이다. 월드 고정이면 줌 50%에서 두 배로 끈적이고 200%에서 절반으로
/// 미끄러진다 — 손끝 감각은 화면 거리로 정해진다.
/// </summary>
public sealed class SnapEngine
{
#region Member Fields
private readonly List<double> candidatesX = new();
private readonly List<double> candidatesY = new();
private readonly SnapSolver solver = new();
#endregion
#region Properties
/// <summary>그리드 간격(px)</summary>
/// <summary>정렬이 실패한 축의 폴백 격자(월드 px)</summary>
public double GridSize { get; set; } = 4;
/// <summary>정렬 스냅 허용 거리(px)</summary>
public double Tolerance { get; set; } = 6;
/// <summary>정렬 허용 거리 — <b>화면</b> px</summary>
public double ScreenTolerance { get; set; } = 6;
/// <summary>간격 균등 스냅 사용</summary>
public bool EnableSpacing { get; set; } = true;
#endregion
#region Methods
/// <summary>드래그 시작 — 스냅 후보 사전수집(월드 좌표)</summary>
public void BeginDrag(PageViewModel page, IReadOnlySet<ControlViewModel> moving)
/// <summary>
/// 드래그 시작 — 후보를 한 번만 모은다.
///
/// 후보에는 <b>컨테이너 자식까지</b> 넣는다. 히트테스트는 재귀하는데 스냅만 최상위를 보던 것은
/// 규칙 불일치였다 — 패널 안 라벨에 맞추려 해도 붙지 않았다.
/// 잠긴 컨트롤은 후보로 <b>남긴다</b>(고정 기준선으로 쓰는 것이 맞다). 숨긴 것만 뺀다.
/// </summary>
public void BeginDrag(DesignerViewModel designer, PageViewModel page,
IReadOnlySet<ControlViewModel> moving)
{
candidatesX.Clear();
candidatesY.Clear();
// 용지 경계·중앙
candidatesX.Add(0);
candidatesX.Add(page.WidthDip / 2);
candidatesX.Add(page.WidthDip);
candidatesY.Add(page.OffsetY);
candidatesY.Add(page.OffsetY + page.HeightDip / 2);
candidatesY.Add(page.OffsetY + page.HeightDip);
foreach (var sibling in page.Controls)
var siblings = new List<SnapRect>();
foreach (var vm in designer.ControlsOfPage(page))
{
if (moving.Contains(sibling) || sibling.Model.Hidden)
// 끌려가는 컨테이너의 자식은 선택 목록에 없지만 부모를 따라 움직인다 —
// 빼지 않으면 자기 자신에게 붙는다
if (moving.Contains(vm) || vm.Model.Hidden)
{
continue;
}
candidatesX.Add(sibling.X);
candidatesX.Add(sibling.X + sibling.Width / 2);
candidatesX.Add(sibling.X + sibling.Width);
var top = page.OffsetY + sibling.Y;
candidatesY.Add(top);
candidatesY.Add(top + sibling.Height / 2);
candidatesY.Add(top + sibling.Height);
siblings.Add(ToSnapRect(designer.WorldBoundsOf(vm)));
}
var paper = new SnapRect(0, page.OffsetY, page.WidthDip, page.HeightDip);
solver.Prepare(new SnapContext
{
Pages = new[] { paper },
Siblings = siblings,
// 페이지를 넘는 '균등 간격'은 뜻이 없다
SpacingPage = paper,
});
}
/// <summary>
/// 이동 스냅 — bbox(월드)에 (dx,dy) 적용 시 정렬 후보와 최근접 정합.
/// free=true(Alt)면 스냅 없이 원본 이동량 그대로.
/// </summary>
public SnapResult SnapMove(Rect bbox, double dx, double dy, bool free)
{
if (free)
{
return new SnapResult(dx, dy, null, null);
}
/// <summary>드래그 끝 — 후보를 버린다. 남겨 두면 다음 드래그가 낡은 좌표에 붙는다</summary>
public void EndDrag() => solver.Reset();
var movedLeft = bbox.X + dx;
var movedTop = bbox.Y + dy;
/// <summary>이동 스냅 — bbox(월드)에 (dx,dy) 를 적용한 자리에서 정렬·간격·격자 순으로 붙인다</summary>
public SnapOutcome SnapMove(Rect bbox, double dx, double dy, bool free, double zoom)
=> solver.SolveMove(ToSnapRect(bbox), dx, dy, OptionsFor(zoom), free);
var (adjustX, guideX) = SnapAxis(new[] { movedLeft, movedLeft + bbox.Width / 2, movedLeft + bbox.Width }, candidatesX);
var (adjustY, guideY) = SnapAxis(new[] { movedTop, movedTop + bbox.Height / 2, movedTop + bbox.Height }, candidatesY);
/// <summary>리사이즈 모서리 스냅</summary>
public SnapEdgeOutcome SnapEdge(double value, bool isXAxis, bool free, double zoom)
=> solver.SolveEdge(value, isXAxis, OptionsFor(zoom), free);
// 정합 실패 축은 그리드 스냅
var resultDx = guideX is not null ? dx + adjustX : Math.Round(movedLeft / GridSize) * GridSize - bbox.X;
var resultDy = guideY is not null ? dy + adjustY : Math.Round(movedTop / GridSize) * GridSize - bbox.Y;
private SnapOptions OptionsFor(double zoom)
=> SnapOptions.ForZoom(ScreenTolerance, GridSize, zoom, EnableSpacing);
return new SnapResult(resultDx, resultDy, guideX, guideY);
}
/// <summary>리사이즈 스냅 — 움직이는 모서리 좌표만 정합(간이: 그리드 우선)</summary>
public double SnapEdge(double value, bool isXAxis, bool free)
{
if (free)
{
return value;
}
var candidates = isXAxis ? candidatesX : candidatesY;
foreach (var candidate in candidates)
{
if (Math.Abs(candidate - value) <= Tolerance)
{
return candidate;
}
}
return Math.Round(value / GridSize) * GridSize;
}
private (double Adjust, double? Guide) SnapAxis(double[] edges, List<double> candidates)
{
var best = double.MaxValue;
double adjust = 0;
double? guide = null;
foreach (var edge in edges)
{
foreach (var candidate in candidates)
{
var distance = candidate - edge;
if (Math.Abs(distance) <= Tolerance && Math.Abs(distance) < Math.Abs(best))
{
best = distance;
adjust = distance;
guide = candidate;
}
}
}
return (adjust, guide);
}
private static SnapRect ToSnapRect(Rect rect)
=> new(rect.X, rect.Y, rect.Width, rect.Height);
#endregion
}