Files
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

374 lines
16 KiB
C#
Raw Permalink 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 SheetMe.Core.Layout;
namespace SheetMe.Core.Tests;
/// <summary>
/// 스냅 계산 고정 — 손에 닿는 감각을 숫자로 못 박는다.
///
/// 스냅은 "어쩐지 잘 붙는다"로는 지킬 수 없다. 후보가 하나 늘거나 열거 순서가 바뀌는 것만으로
/// 붙는 자리가 조용히 달라지고, 그건 드래그해 보기 전엔 아무도 모른다.
/// 그래서 승자 규칙·가이드 개수·선분 범위·간격 균등을 전부 여기서 고정한다.
/// </summary>
[TestClass]
public sealed class SnapSolverTests
{
#region Member Fields
private static readonly SnapRect Page = new(0, 0, 720, 856);
private static readonly SnapOptions Options = new(Tolerance: 6, GridSize: 4, EnableSpacing: true);
private static readonly SnapOptions NoSpacing = Options with { EnableSpacing = false };
#endregion
#region Methods
private static SnapSolver Solver(params SnapRect[] siblings)
{
var solver = new SnapSolver();
solver.Prepare(new SnapContext
{
Pages = new[] { Page },
Siblings = siblings,
SpacingPage = Page,
});
return solver;
}
// ── 정렬 ───────────────────────────────────────────────────────────
[TestMethod]
public void 형제_좌변에_붙는다()
{
// 형제 폭을 81 로 잡아 중심(140.5)이 이동체 우변(137)과 '같은 거리'가 되지 않게 한다 —
// 80 이면 중심이 140 이라 우변도 정확히 +3 이 되어 가이드가 둘 나온다(그건 그것대로 옳다)
var solver = Solver(new SnapRect(100, 50, 81, 20));
var result = solver.SolveMove(new SnapRect(0, 200, 40, 10), 97, 0, NoSpacing, free: false);
Assert.IsTrue(result.SnappedX);
Assert.AreEqual(100, 0 + result.Dx, 1e-9, "좌변이 형제 좌변에 맞아야 한다");
Assert.AreEqual(1, result.Guides.Count);
var guide = result.Guides[0];
Assert.IsTrue(guide.IsVertical);
Assert.AreEqual(100, guide.Position, 1e-9);
Assert.AreEqual(SnapGuideKind.SiblingEdge, guide.Kind);
// 선분은 형제와 이동체를 모두 덮는 만큼만 — 화면을 가로지르지 않는다
Assert.AreEqual(50, guide.Start, 1e-9);
Assert.AreEqual(210, guide.End, 1e-9);
}
[TestMethod]
public void 허용오차_경계는_포함이고_그_바깥은_격자로_떨어진다()
{
var solver = Solver(new SnapRect(100, 50, 80, 20));
var onEdge = solver.SolveMove(new SnapRect(0, 200, 40, 10), 94, 0, NoSpacing, free: false);
Assert.IsTrue(onEdge.SnappedX, "거리 6은 붙는다");
var justOutside = solver.SolveMove(new SnapRect(0, 200, 40, 10), 93.999, 0, NoSpacing, free: false);
Assert.IsFalse(justOutside.SnappedX, "거리 6.001 은 안 붙는다");
Assert.AreEqual(0, result4(justOutside.Dx) % 4, "정합 실패 축은 4px 격자");
Assert.AreEqual(0, justOutside.Guides.Count);
static double result4(double dx) => Math.Round(dx, 6);
}
[TestMethod]
public void 같은_좌표의_형제_둘은_가이드_하나로_합쳐지고_범위가_넓어진다()
{
var solver = Solver(new SnapRect(100, 0, 50, 20), new SnapRect(100, 300, 50, 20));
var result = solver.SolveMove(new SnapRect(0, 200, 40, 10), 97, 0, NoSpacing, free: false);
Assert.AreEqual(1, result.Guides.Count, "같은 자리에 선을 겹쳐 긋지 않는다");
Assert.AreEqual(0, result.Guides[0].Start, 1e-9);
Assert.AreEqual(320, result.Guides[0].End, 1e-9);
}
[TestMethod]
public void 서로_다른_자리에_동시에_맞으면_가이드가_둘이다()
{
// 이동체 폭 40. Left=100 에 형제 A 의 좌변, Right=140 에 형제 B 의 좌변
var solver = Solver(new SnapRect(100, 0, 50, 20), new SnapRect(140, 400, 50, 20));
var result = solver.SolveMove(new SnapRect(0, 200, 40, 10), 97, 0, NoSpacing, free: false);
Assert.IsTrue(result.SnappedX);
Assert.AreEqual(2, result.Guides.Count);
CollectionAssert.AreEquivalent(
new[] { 100.0, 140.0 },
result.Guides.Select(g => g.Position).ToArray());
}
[TestMethod]
public void 거리가_같아도_부호가_반대면_한쪽만_그린다()
{
// 이동 후 Left=100 기준: 형제 A 좌변 103(+3), 형제 B 좌변 97(3).
// 형제를 넓게 잡아 중심·우변이 사정권에 안 들어오게 한다(들어오면 더 가까운 매치가 승자가 된다)
var solver = Solver(new SnapRect(103, 0, 200, 20), new SnapRect(97, 400, 200, 20));
var result = solver.SolveMove(new SnapRect(0, 200, 40, 10), 100, 0, NoSpacing, free: false);
Assert.IsTrue(result.SnappedX);
Assert.AreEqual(1, result.Guides.Count,
"보정 후 실제로 맞는 것만 그린다 — 반대편에 선을 그으면 '붙었다고 그려 놓고 안 붙은' 선이 된다");
Assert.AreEqual(97, result.Guides[0].Position, 1e-9, "동률이면 좌표가 작은 쪽이 이긴다");
}
[TestMethod]
public void 중심_정합은_반정수를_보존한다()
{
var solver = Solver(new SnapRect(100, 0, 81, 20)); // CenterX = 140.5
var result = solver.SolveMove(new SnapRect(0, 200, 40, 10), 120, 0, NoSpacing, free: false);
Assert.IsTrue(result.SnappedX);
Assert.AreEqual(120.5, 0 + result.Dx, 1e-9, "반올림하면 중심이 0.5px 어긋난다");
Assert.AreEqual(SnapGuideKind.SiblingCenter, result.Guides[0].Kind);
Assert.AreEqual(140.5, result.Guides[0].Position, 1e-9);
}
[TestMethod]
public void 용지_가이드는_용지_전체로_긋는다()
{
var solver = Solver();
var result = solver.SolveMove(new SnapRect(3, 200, 40, 10), 0, 0, NoSpacing, free: false);
Assert.IsTrue(result.SnappedX);
Assert.AreEqual(SnapGuideKind.PageEdge, result.Guides[0].Kind);
Assert.AreEqual(0, result.Guides[0].Start, 1e-9);
Assert.AreEqual(856, result.Guides[0].End, 1e-9);
}
[TestMethod]
public void 용지_중앙에_붙는다()
{
var solver = Solver();
// 폭 40 이 용지 중앙(360)에 오려면 Left=340
var result = solver.SolveMove(new SnapRect(337, 200, 40, 10), 0, 0, NoSpacing, free: false);
Assert.IsTrue(result.SnappedX);
Assert.AreEqual(340, 337 + result.Dx, 1e-9);
Assert.AreEqual(SnapGuideKind.PageCenter, result.Guides[0].Kind);
Assert.AreEqual(360, result.Guides[0].Position, 1e-9);
}
[TestMethod]
public void 후보가_없으면_격자로_떨어지고_가이드는_없다()
{
var solver = Solver(new SnapRect(500, 500, 20, 20));
var result = solver.SolveMove(new SnapRect(101, 200, 40, 10), 0, 0, NoSpacing, free: false);
Assert.IsFalse(result.SnappedX);
Assert.AreEqual(100, 101 + result.Dx, 1e-9);
Assert.AreEqual(0, result.Guides.Count);
}
[TestMethod]
public void 형제_모서리가_용지_중앙보다_이긴다()
{
// 이동체 Left=337 → 형제 좌변 340 이 +3, 중심 357 → 용지 중앙 360 도 +3.
// 둘 다 실제로 맞으므로 선은 둘 다 그린다 — 갈리는 것은 '어느 쪽으로 보정하는가'다.
var solver = Solver(new SnapRect(340, 0, 200, 20));
var result = solver.SolveMove(new SnapRect(337, 200, 40, 10), 0, 0, NoSpacing, free: false);
Assert.AreEqual(340, 337 + result.Dx, 1e-9, "거리가 같으면 형제 모서리가 용지 중앙보다 이긴다");
Assert.AreEqual(2, result.Guides.Count);
Assert.IsTrue(result.Guides.Any(g => g.Kind == SnapGuideKind.SiblingEdge));
Assert.IsTrue(result.Guides.Any(g => g.Kind == SnapGuideKind.PageCenter));
}
[TestMethod]
public void _축만_붙어도_다른_축은_격자로_간다()
{
var solver = Solver(new SnapRect(100, 0, 50, 20));
var result = solver.SolveMove(new SnapRect(97, 201, 40, 10), 0, 0, NoSpacing, free: false);
Assert.IsTrue(result.SnappedX);
Assert.IsFalse(result.SnappedY);
Assert.AreEqual(200, 201 + result.Dy, 1e-9);
}
[TestMethod]
public void Alt_는_격자까지_전부_끈다()
{
var solver = Solver(new SnapRect(100, 50, 80, 20));
var result = solver.SolveMove(new SnapRect(0, 200, 40, 10), 97.3, 11.7, Options, free: true);
Assert.AreEqual(97.3, result.Dx, 1e-9);
Assert.AreEqual(11.7, result.Dy, 1e-9);
Assert.IsFalse(result.SnappedX);
Assert.AreEqual(0, result.Guides.Count);
Assert.AreEqual(0, result.Spacings.Count);
}
[TestMethod]
public void 같은_입력을_두_번_풀어도_결과가_같다()
{
var solver = Solver(new SnapRect(100, 0, 50, 20), new SnapRect(140, 400, 50, 20));
var first = solver.SolveMove(new SnapRect(0, 200, 40, 10), 97, 0, Options, free: false);
var second = solver.SolveMove(new SnapRect(0, 200, 40, 10), 97, 0, Options, free: false);
Assert.AreEqual(first.Dx, second.Dx, 1e-9);
Assert.AreEqual(first.Guides.Count, second.Guides.Count, "스크래치 버퍼가 새면 두 번째가 불어난다");
Assert.AreEqual(first.Spacings.Count, second.Spacings.Count);
}
[TestMethod]
public void 페이지가_멀리_떨어져_있어도_정밀도가_유지된다()
{
var solver = new SnapSolver();
solver.Prepare(new SnapContext
{
Pages = new[] { new SnapRect(0, 10000, 720, 856) },
Siblings = new[] { new SnapRect(100, 10050, 80, 20) },
});
var result = solver.SolveMove(new SnapRect(0, 10200, 40, 10), 97, 0, NoSpacing, free: false);
Assert.AreEqual(100, 0 + result.Dx, 1e-9);
}
// ── 간격 균등 ──────────────────────────────────────────────────────
[TestMethod]
public void _형제_사이에_끼면_남는_틈을_반씩_나눈다()
{
// 틈 [50,200] = 150, 이동 폭 40 → 양쪽 55 씩 → Left = 105
var solver = Solver(new SnapRect(0, 0, 50, 20), new SnapRect(200, 0, 50, 20));
var result = solver.SolveMove(new SnapRect(103, 0, 40, 20), 0, 0, Options, free: false);
Assert.IsTrue(result.SnappedX);
Assert.AreEqual(105, 103 + result.Dx, 1e-9);
Assert.AreEqual(2, result.Spacings.Count);
Assert.AreEqual(55, result.Spacings[0].Gap, 1e-9);
Assert.AreEqual(55, result.Spacings[1].Gap, 1e-9);
}
[TestMethod]
public void _뒤에_같은_간격으로_이어_붙는다()
{
// 간격 30 (50→80). 뒤에 붙으면 Left = 130 + 30 = 160
var solver = Solver(new SnapRect(0, 0, 50, 20), new SnapRect(80, 0, 50, 20));
var result = solver.SolveMove(new SnapRect(157, 0, 40, 20), 0, 0, Options, free: false);
Assert.IsTrue(result.SnappedX);
Assert.AreEqual(160, 157 + result.Dx, 1e-9);
Assert.AreEqual(30, result.Spacings[^1].Gap, 1e-9);
}
[TestMethod]
public void _앞에_같은_간격으로_이어_붙는다()
{
// 간격 30 (150→180). 앞에 붙으면 Left = 100 30 40 = 30
var solver = Solver(new SnapRect(100, 0, 50, 20), new SnapRect(180, 0, 50, 20));
var result = solver.SolveMove(new SnapRect(33, 0, 40, 20), 0, 0, Options, free: false);
Assert.IsTrue(result.SnappedX);
Assert.AreEqual(30, 33 + result.Dx, 1e-9);
}
[TestMethod]
public void 교차축이_안_겹치면_같은_줄이_아니다()
{
var solver = Solver(new SnapRect(0, 500, 50, 20), new SnapRect(80, 500, 50, 20));
var result = solver.SolveMove(new SnapRect(157, 0, 40, 20), 0, 0, Options, free: false);
Assert.AreEqual(0, result.Spacings.Count);
}
[TestMethod]
public void 형제가_하나면_간격_후보가_없다()
{
var solver = Solver(new SnapRect(0, 0, 50, 20));
var result = solver.SolveMove(new SnapRect(300, 0, 40, 20), 0, 0, Options, free: false);
Assert.AreEqual(0, result.Spacings.Count);
}
[TestMethod]
public void 정렬이_되면_간격은_보지_않는다()
{
// 뒤에 붙는 자리(160)와 형제 좌변 정합(158)이 둘 다 사정권
var solver = Solver(new SnapRect(0, 0, 50, 20), new SnapRect(80, 0, 50, 20), new SnapRect(158, 300, 20, 20));
var result = solver.SolveMove(new SnapRect(157, 0, 40, 20), 0, 0, Options, free: false);
Assert.IsTrue(result.SnappedX);
Assert.AreEqual(158, 157 + result.Dx, 1e-9, "정렬이 이긴다");
Assert.AreEqual(0, result.Spacings.Count);
}
[TestMethod]
public void 용지_밖으로_나가는_간격_자리는_버린다()
{
// 앞에 붙는 자리가 음수가 되는 배치
var solver = Solver(new SnapRect(10, 0, 50, 20), new SnapRect(90, 0, 50, 20));
var result = solver.SolveMove(new SnapRect(-58, 0, 40, 20), 0, 0, Options, free: false);
Assert.AreEqual(0, result.Spacings.Count);
}
[TestMethod]
public void 겹치는_형제가_너무_많으면_간격을_포기하고_정렬만_한다()
{
var many = new SnapRect[70];
for (var i = 0; i < many.Length; i++)
{
many[i] = new SnapRect(i * 60, 0, 50, 20);
}
var solver = Solver(many);
var result = solver.SolveMove(new SnapRect(58, 0, 40, 20), 0, 0, Options, free: false);
Assert.AreEqual(0, result.Spacings.Count, "성능 가드");
Assert.IsTrue(result.SnappedX, "정렬 스냅은 그대로 돈다");
}
// ── 리사이즈 · 줌 ──────────────────────────────────────────────────
[TestMethod]
public void 모서리_스냅은_첫_매치가_아니라_최근접이다()
{
var solver = Solver(new SnapRect(100, 0, 0, 20), new SnapRect(104, 0, 0, 20));
var result = solver.SolveEdge(102.5, isXAxis: true, Options, free: false);
Assert.IsTrue(result.Snapped);
Assert.AreEqual(104, result.Value, 1e-9, "이동 스냅과 같은 규칙이어야 한다");
}
[TestMethod]
public void 모서리_후보가_없으면_격자로_떨어진다()
{
var solver = Solver(new SnapRect(500, 0, 10, 20));
var result = solver.SolveEdge(102, isXAxis: true, Options, free: false);
Assert.IsFalse(result.Snapped);
Assert.AreEqual(104, result.Value, 1e-9);
}
[TestMethod]
public void 모서리_스냅도_Alt_로_꺼진다()
{
var solver = Solver(new SnapRect(100, 0, 0, 20));
var result = solver.SolveEdge(102.5, isXAxis: true, Options, free: true);
Assert.IsFalse(result.Snapped);
Assert.AreEqual(102.5, result.Value, 1e-9);
}
[TestMethod]
public void 허용오차는_화면_기준이라_줌에_반비례한다()
{
Assert.AreEqual(3, SnapOptions.ForZoom(6, 4, 2.0, true).Tolerance, 1e-9);
Assert.AreEqual(12, SnapOptions.ForZoom(6, 4, 0.5, true).Tolerance, 1e-9);
Assert.AreEqual(6, SnapOptions.ForZoom(6, 4, 1.0, true).Tolerance, 1e-9);
// 범위 밖 줌이 들어와도 오차가 폭주하지 않는다
Assert.AreEqual(24, SnapOptions.ForZoom(6, 4, 0.01, true).Tolerance, 1e-9);
Assert.AreEqual(2, SnapOptions.ForZoom(6, 4, 99, true).Tolerance, 1e-9);
}
[TestMethod]
public void 같은_거리라도_줌에_따라_붙고_안_붙는다()
{
var solver = Solver(new SnapRect(104, 0, 50, 20));
var bbox = new SnapRect(100, 200, 40, 10);
var zoomedIn = solver.SolveMove(bbox, 0, 0, SnapOptions.ForZoom(6, 4, 2.0, false), free: false);
Assert.IsFalse(zoomedIn.SnappedX, "줌 200% 에서 허용오차는 3 — 거리 4는 안 붙는다");
var zoomedOut = solver.SolveMove(bbox, 0, 0, SnapOptions.ForZoom(6, 4, 0.5, false), free: false);
Assert.IsTrue(zoomedOut.SnappedX, "줌 50% 에서 허용오차는 12 — 붙는다");
}
#endregion
}