피그마식 스냅 — 여러 선이 동시에, 관련된 것만 잇고, 같은 간격이면 그렇다고 말한다
정렬 스냅 자체는 있었다. 다만 축마다 선 하나만, 그것도 화면 끝에서 끝까지 긋는 무한 점선이라 무엇에 맞춘 것인지가 안 보였다. 판단을 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:
co-authored by
Claude Opus 5
parent
8a7cc65099
commit
ba838ae54a
@@ -0,0 +1,481 @@
|
||||
namespace SheetMe.Core.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// 스냅 계산 — 순수 기하. 화면도 뷰모델도 모른다.
|
||||
///
|
||||
/// <b>왜 후보를 좌표 목록으로 평탄화하지 않는가.</b> 예전에는 후보를 <c>List<double></c> 로 모아
|
||||
/// 최근접 하나를 골랐다. 그러면 "이 좌표가 <i>어느 객체</i>에서 왔는가"를 잃는다 — 그래서 가이드를
|
||||
/// 화면 끝에서 끝까지 긋는 무한선으로밖에 못 그렸고, 간격 균등은 아예 계산할 수 없었다.
|
||||
/// 여기서는 <see cref="SnapRect"/> 원본을 끝까지 들고 간다.
|
||||
///
|
||||
/// <b>결정성.</b> 같은 입력이면 항상 같은 답이어야 한다(단위 테스트가 그걸 고정한다).
|
||||
/// 동률 후보의 승자는 <see cref="SnapGuideKind"/> → 좌표 → 모서리 순서로 완전히 정해진다 —
|
||||
/// 예전에는 컨트롤 열거 순서가 승자를 갈랐다.
|
||||
///
|
||||
/// <b>할당.</b> 드래그 중 매 프레임 불린다. 스크래치 버퍼를 재사용하고 이 경로에서 LINQ 를 쓰지 않는다.
|
||||
/// </summary>
|
||||
public sealed class SnapSolver
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>교차축이 겹치는 형제가 이보다 많으면 간격 균등을 포기한다 — 정렬 스냅은 그대로 돈다</summary>
|
||||
private const int SpacingRowLimit = 64;
|
||||
|
||||
/// <summary>형제가 이보다 많으면 간격 균등을 아예 끈다</summary>
|
||||
private const int SpacingTotalLimit = 2000;
|
||||
|
||||
/// <summary>막대가 화면을 덮지 않게 — 균등 구간이 아주 길어도 여기서 끊는다</summary>
|
||||
private const int SpacingMarkLimit = 8;
|
||||
|
||||
private const double Epsilon = 1e-6;
|
||||
|
||||
private SnapContext context = new();
|
||||
|
||||
/// <summary>Left 오름차순 형제 — BeginDrag 에서 한 번 정렬한다(프레임당 정렬 금지)</summary>
|
||||
private SnapRect[] byLeft = Array.Empty<SnapRect>();
|
||||
|
||||
/// <summary>Top 오름차순 형제</summary>
|
||||
private SnapRect[] byTop = Array.Empty<SnapRect>();
|
||||
|
||||
private readonly List<Match> matches = new();
|
||||
private readonly List<SnapGuide> guides = new();
|
||||
private readonly List<SpacingMark> spacings = new();
|
||||
private readonly List<SnapRect> row = new();
|
||||
private readonly List<SnapRect> matched = new();
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>드래그 시작 — 후보를 한 번만 모으고 정렬해 둔다</summary>
|
||||
public void Prepare(SnapContext value)
|
||||
{
|
||||
context = value;
|
||||
byLeft = new SnapRect[value.Siblings.Count];
|
||||
byTop = new SnapRect[value.Siblings.Count];
|
||||
for (var i = 0; i < value.Siblings.Count; i++)
|
||||
{
|
||||
byLeft[i] = value.Siblings[i];
|
||||
byTop[i] = value.Siblings[i];
|
||||
}
|
||||
Array.Sort(byLeft, static (a, b) => a.Left.CompareTo(b.Left));
|
||||
Array.Sort(byTop, static (a, b) => a.Top.CompareTo(b.Top));
|
||||
}
|
||||
|
||||
/// <summary>드래그 끝 — 후보를 버린다. 드래그 밖에서 낡은 후보가 살아 있으면 유령 스냅이 된다</summary>
|
||||
public void Reset()
|
||||
{
|
||||
context = new SnapContext();
|
||||
byLeft = Array.Empty<SnapRect>();
|
||||
byTop = Array.Empty<SnapRect>();
|
||||
}
|
||||
|
||||
/// <summary>이동 스냅 — bbox 에 (dx,dy) 를 적용한 자리에서 정렬·간격·격자 순으로 붙인다</summary>
|
||||
public SnapOutcome SolveMove(SnapRect bbox, double dx, double dy, SnapOptions options, bool free)
|
||||
{
|
||||
if (free)
|
||||
{
|
||||
return SnapOutcome.Free(dx, dy);
|
||||
}
|
||||
|
||||
guides.Clear();
|
||||
spacings.Clear();
|
||||
|
||||
var moved = new SnapRect(bbox.X + dx, bbox.Y + dy, bbox.W, bbox.H);
|
||||
var (adjustX, snappedX) = SolveAxis(moved, vertical: true, options);
|
||||
var (adjustY, snappedY) = SolveAxis(moved, vertical: false, options);
|
||||
|
||||
// 정렬이 못 잡은 축만 간격 균등을 본다 — 같은 축에서 둘 다 사정권이면 정렬이 이긴다.
|
||||
// 거리로 겨루게 하면 한 픽셀 차이로 표시가 정렬선↔간격막대로 튀어 종잡을 수 없다.
|
||||
var canSpace = options.EnableSpacing && context.Siblings.Count <= SpacingTotalLimit;
|
||||
if (canSpace && !snappedX)
|
||||
{
|
||||
(adjustX, snappedX) = SolveSpacing(moved, horizontal: true, options);
|
||||
}
|
||||
if (canSpace && !snappedY)
|
||||
{
|
||||
(adjustY, snappedY) = SolveSpacing(moved, horizontal: false, options);
|
||||
}
|
||||
|
||||
var resultDx = snappedX ? dx + adjustX : Grid(moved.Left, options.GridSize) - bbox.X;
|
||||
var resultDy = snappedY ? dy + adjustY : Grid(moved.Top, options.GridSize) - bbox.Y;
|
||||
|
||||
return new SnapOutcome(resultDx, resultDy, snappedX, snappedY,
|
||||
guides.ToArray(), spacings.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 리사이즈 모서리 스냅 — 움직이는 모서리 하나를 후보에 맞춘다.
|
||||
/// 이동과 <b>같은 최근접 규칙</b>을 쓴다(예전에는 여기만 '첫 매치'라 둘이 어긋났다).
|
||||
/// </summary>
|
||||
public SnapEdgeOutcome SolveEdge(double value, bool isXAxis, SnapOptions options, bool free)
|
||||
{
|
||||
if (free)
|
||||
{
|
||||
return SnapEdgeOutcome.None(value);
|
||||
}
|
||||
|
||||
matches.Clear();
|
||||
CollectCandidates(isXAxis, value, value, value, options.Tolerance, singleEdge: true);
|
||||
if (matches.Count == 0)
|
||||
{
|
||||
return SnapEdgeOutcome.None(Grid(value, options.GridSize));
|
||||
}
|
||||
|
||||
var winner = Winner();
|
||||
matched.Clear();
|
||||
foreach (var match in matches)
|
||||
{
|
||||
if (Math.Abs(match.Distance - winner.Distance) < Epsilon
|
||||
&& Math.Abs(match.Position - winner.Position) < Epsilon
|
||||
&& match.Source is { } source)
|
||||
{
|
||||
matched.Add(source);
|
||||
}
|
||||
}
|
||||
return new SnapEdgeOutcome(winner.Position, true, matched.ToArray(), winner.Kind);
|
||||
}
|
||||
|
||||
private static double Grid(double value, double grid)
|
||||
=> grid <= 0 ? value : Math.Round(value / grid) * grid;
|
||||
|
||||
/// <summary>한 축의 정렬 스냅 — 보정량은 승자 하나, 가이드는 '실제로 맞은 것' 전부</summary>
|
||||
private (double Adjust, bool Snapped) SolveAxis(SnapRect moved, bool vertical, SnapOptions options)
|
||||
{
|
||||
var (a, b, c) = vertical
|
||||
? (moved.Left, moved.CenterX, moved.Right)
|
||||
: (moved.Top, moved.CenterY, moved.Bottom);
|
||||
|
||||
matches.Clear();
|
||||
CollectCandidates(vertical, a, b, c, options.Tolerance, singleEdge: false);
|
||||
if (matches.Count == 0)
|
||||
{
|
||||
return (0, false);
|
||||
}
|
||||
|
||||
var winner = Winner();
|
||||
EmitGuides(moved, vertical, winner.Distance);
|
||||
return (winner.Distance, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 승자 — |거리| 최소 → 종류 → 좌표 → 모서리 순.
|
||||
/// 네 단계를 모두 두는 이유는 어느 하나라도 빠지면 입력 순서가 답을 가르기 때문이다.
|
||||
/// </summary>
|
||||
private Match Winner()
|
||||
{
|
||||
var best = matches[0];
|
||||
for (var i = 1; i < matches.Count; i++)
|
||||
{
|
||||
if (Better(matches[i], best))
|
||||
{
|
||||
best = matches[i];
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private static bool Better(Match candidate, Match current)
|
||||
{
|
||||
var da = Math.Abs(candidate.Distance);
|
||||
var db = Math.Abs(current.Distance);
|
||||
if (Math.Abs(da - db) > Epsilon)
|
||||
{
|
||||
return da < db;
|
||||
}
|
||||
if (candidate.Kind != current.Kind)
|
||||
{
|
||||
return candidate.Kind < current.Kind;
|
||||
}
|
||||
if (Math.Abs(candidate.Position - current.Position) > Epsilon)
|
||||
{
|
||||
return candidate.Position < current.Position;
|
||||
}
|
||||
return candidate.EdgeIndex < current.EdgeIndex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보정을 적용했을 때 <b>실제로 맞는</b> 매치만 선으로 낸다.
|
||||
///
|
||||
/// |거리| 만 같고 부호가 반대인 후보(+3 과 −3)를 함께 그리면 '붙었다고 그려 놓고 안 붙은' 선이 된다.
|
||||
/// 그래서 부호까지 같은 것만 남긴다.
|
||||
/// </summary>
|
||||
private void EmitGuides(SnapRect moved, bool vertical, double adjust)
|
||||
{
|
||||
var settled = vertical
|
||||
? new SnapRect(moved.X + adjust, moved.Y, moved.W, moved.H)
|
||||
: new SnapRect(moved.X, moved.Y + adjust, moved.W, moved.H);
|
||||
|
||||
foreach (var match in matches)
|
||||
{
|
||||
if (Math.Abs(match.Distance - adjust) > Epsilon)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
double start, end;
|
||||
if (match.Source is { } source)
|
||||
{
|
||||
// 형제 가이드는 관련된 것들의 교차축 합집합만큼만 — 관계가 눈에 보이는 길이다
|
||||
start = vertical ? Math.Min(source.Top, settled.Top) : Math.Min(source.Left, settled.Left);
|
||||
end = vertical ? Math.Max(source.Bottom, settled.Bottom) : Math.Max(source.Right, settled.Right);
|
||||
}
|
||||
else if (match.Page is { } page)
|
||||
{
|
||||
// 용지 가이드는 그 용지 전체. 종이 밖으로 나가면 회색 배경 위에서 대비가 죽는다
|
||||
start = vertical ? page.Top : page.Left;
|
||||
end = vertical ? page.Bottom : page.Right;
|
||||
}
|
||||
else
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Merge(new SnapGuide(vertical, match.Position, start, end, match.Kind));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>같은 축·같은 좌표의 가이드는 하나로 합치고 범위를 넓힌다 — 같은 자리에 선을 겹쳐 긋지 않는다</summary>
|
||||
private void Merge(SnapGuide guide)
|
||||
{
|
||||
for (var i = 0; i < guides.Count; i++)
|
||||
{
|
||||
var existing = guides[i];
|
||||
if (existing.IsVertical != guide.IsVertical
|
||||
|| Math.Abs(existing.Position - guide.Position) > Epsilon)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
guides[i] = new SnapGuide(
|
||||
existing.IsVertical, existing.Position,
|
||||
Math.Min(existing.Start, guide.Start),
|
||||
Math.Max(existing.End, guide.End),
|
||||
existing.Kind <= guide.Kind ? existing.Kind : guide.Kind);
|
||||
return;
|
||||
}
|
||||
guides.Add(guide);
|
||||
}
|
||||
|
||||
/// <param name="singleEdge">리사이즈처럼 모서리가 하나뿐이면 a 만 본다</param>
|
||||
private void CollectCandidates(bool vertical, double a, double b, double c,
|
||||
double tolerance, bool singleEdge)
|
||||
{
|
||||
var limit = singleEdge ? 1 : 3;
|
||||
foreach (var sibling in context.Siblings)
|
||||
{
|
||||
var (p0, p1, p2) = vertical
|
||||
? (sibling.Left, sibling.CenterX, sibling.Right)
|
||||
: (sibling.Top, sibling.CenterY, sibling.Bottom);
|
||||
for (var e = 0; e < limit; e++)
|
||||
{
|
||||
var edge = e == 0 ? a : e == 1 ? b : c;
|
||||
Consider(edge, p0, e, SnapGuideKind.SiblingEdge, sibling, null, tolerance);
|
||||
Consider(edge, p1, e, SnapGuideKind.SiblingCenter, sibling, null, tolerance);
|
||||
Consider(edge, p2, e, SnapGuideKind.SiblingEdge, sibling, null, tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var page in context.Pages)
|
||||
{
|
||||
var (p0, p1, p2) = vertical
|
||||
? (page.Left, page.CenterX, page.Right)
|
||||
: (page.Top, page.CenterY, page.Bottom);
|
||||
for (var e = 0; e < limit; e++)
|
||||
{
|
||||
var edge = e == 0 ? a : e == 1 ? b : c;
|
||||
Consider(edge, p0, e, SnapGuideKind.PageEdge, null, page, tolerance);
|
||||
Consider(edge, p1, e, SnapGuideKind.PageCenter, null, page, tolerance);
|
||||
Consider(edge, p2, e, SnapGuideKind.PageEdge, null, page, tolerance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Consider(double edge, double candidate, int edgeIndex, SnapGuideKind kind,
|
||||
SnapRect? source, SnapRect? page, double tolerance)
|
||||
{
|
||||
var distance = candidate - edge;
|
||||
if (Math.Abs(distance) <= tolerance)
|
||||
{
|
||||
matches.Add(new Match(distance, candidate, kind, edgeIndex, source, page));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 간격 균등 — 형제들이 이미 같은 간격으로 늘어서 있으면 그 리듬에 끼거나 이어 붙는 자리에 스냅한다.
|
||||
///
|
||||
/// 가로(X)로 설명한다. 세로는 축만 바꾼 대칭이다.
|
||||
/// ① 이동체와 교차축이 겹치는 형제만 한 줄로 본다(겹치지 않으면 '나란히'가 아니다).
|
||||
/// ② 줄의 끝(또는 앞)에서부터 같은 간격이 이어지는 최대 구간을 찾아 그 값을 g 로 쓴다.
|
||||
/// ③ 붙을 자리는 셋 — 두 형제 사이 가운데, 줄 뒤에 g 만큼, 줄 앞에 g 만큼.
|
||||
/// </summary>
|
||||
private (double Adjust, bool Snapped) SolveSpacing(SnapRect moved, bool horizontal, SnapOptions options)
|
||||
{
|
||||
if (context.SpacingPage is not { } page)
|
||||
{
|
||||
return (0, false);
|
||||
}
|
||||
|
||||
BuildRow(moved, horizontal);
|
||||
if (row.Count < 2 || row.Count > SpacingRowLimit)
|
||||
{
|
||||
return (0, false);
|
||||
}
|
||||
|
||||
var size = horizontal ? moved.W : moved.H;
|
||||
var start = horizontal ? moved.Left : moved.Top;
|
||||
var lower = horizontal ? page.Left : page.Top;
|
||||
var upper = horizontal ? page.Right : page.Bottom;
|
||||
|
||||
var bestDistance = double.MaxValue;
|
||||
var bestStart = 0.0;
|
||||
var bestRank = int.MaxValue;
|
||||
var found = false;
|
||||
|
||||
void Offer(double candidateStart, int rank)
|
||||
{
|
||||
if (candidateStart < lower - Epsilon || candidateStart + size > upper + Epsilon)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var distance = candidateStart - start;
|
||||
if (Math.Abs(distance) > options.Tolerance)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var better = !found
|
||||
|| Math.Abs(distance) < Math.Abs(bestDistance) - Epsilon
|
||||
|| (Math.Abs(Math.Abs(distance) - Math.Abs(bestDistance)) <= Epsilon
|
||||
&& (rank < bestRank || (rank == bestRank && candidateStart < bestStart)));
|
||||
if (better)
|
||||
{
|
||||
found = true;
|
||||
bestDistance = distance;
|
||||
bestStart = candidateStart;
|
||||
bestRank = rank;
|
||||
}
|
||||
}
|
||||
|
||||
// ③-1 두 형제 사이에 끼우기 — 남는 틈을 반씩 나눈다
|
||||
for (var i = 0; i < row.Count - 1; i++)
|
||||
{
|
||||
var gapStart = horizontal ? row[i].Right : row[i].Bottom;
|
||||
var gapEnd = horizontal ? row[i + 1].Left : row[i + 1].Top;
|
||||
var gap = gapEnd - gapStart;
|
||||
if (gap - size >= -Epsilon)
|
||||
{
|
||||
Offer(gapStart + (gap - size) / 2, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ③-2 뒤에 이어 붙이기 / ③-3 앞에 이어 붙이기
|
||||
var tail = RunGap(horizontal, fromEnd: true);
|
||||
if (tail is { } tailGap && tailGap >= -Epsilon)
|
||||
{
|
||||
Offer((horizontal ? row[^1].Right : row[^1].Bottom) + tailGap, 1);
|
||||
}
|
||||
var head = RunGap(horizontal, fromEnd: false);
|
||||
if (head is { } headGap && headGap >= -Epsilon)
|
||||
{
|
||||
Offer((horizontal ? row[0].Left : row[0].Top) - headGap - size, 2);
|
||||
}
|
||||
|
||||
if (!found)
|
||||
{
|
||||
return (0, false);
|
||||
}
|
||||
|
||||
var settled = horizontal
|
||||
? new SnapRect(bestStart, moved.Y, moved.W, moved.H)
|
||||
: new SnapRect(moved.X, bestStart, moved.W, moved.H);
|
||||
EmitSpacings(settled, horizontal);
|
||||
return (bestDistance, true);
|
||||
}
|
||||
|
||||
/// <summary>이동체와 교차축이 겹치는 형제만, 주축 오름차순으로</summary>
|
||||
private void BuildRow(SnapRect moved, bool horizontal)
|
||||
{
|
||||
row.Clear();
|
||||
var source = horizontal ? byLeft : byTop;
|
||||
foreach (var sibling in source)
|
||||
{
|
||||
var overlaps = horizontal
|
||||
? sibling.Top < moved.Bottom && moved.Top < sibling.Bottom
|
||||
: sibling.Left < moved.Right && moved.Left < sibling.Right;
|
||||
if (overlaps)
|
||||
{
|
||||
row.Add(sibling);
|
||||
if (row.Count > SpacingRowLimit)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>줄의 끝(또는 앞)에서부터 같은 값이 이어지는 간격 — 형제가 둘뿐이면 그 하나가 곧 답이다</summary>
|
||||
private double? RunGap(bool horizontal, bool fromEnd)
|
||||
{
|
||||
if (row.Count < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
double Gap(int i) => horizontal
|
||||
? row[i + 1].Left - row[i].Right
|
||||
: row[i + 1].Top - row[i].Bottom;
|
||||
|
||||
var index = fromEnd ? row.Count - 2 : 0;
|
||||
var value = Gap(index);
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 확정된 자리를 기준으로 <b>같은 값이 된 빈틈 전부</b>를 막대로 낸다.
|
||||
///
|
||||
/// 이동체 양옆만 재면 "30"이 한 번 뜰 뿐이라 무엇과 같아졌는지 안 보인다.
|
||||
/// 같은 간격에 전부 막대를 그어야 '균등해졌다'가 한눈에 읽힌다.
|
||||
/// </summary>
|
||||
private void EmitSpacings(SnapRect settled, bool horizontal)
|
||||
{
|
||||
row.Add(settled);
|
||||
row.Sort((a, b) => horizontal ? a.Left.CompareTo(b.Left) : a.Top.CompareTo(b.Top));
|
||||
|
||||
var at = -1;
|
||||
for (var i = 0; i < row.Count; i++)
|
||||
{
|
||||
if (row[i].Equals(settled))
|
||||
{
|
||||
at = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (at < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 기준 간격 = 이동체에 붙은 빈틈. 앞에 끼었으면 오른쪽 것을 쓴다
|
||||
var target = at > 0 ? GapAt(at - 1) : GapAt(at);
|
||||
for (var i = 0; i < row.Count - 1 && spacings.Count < SpacingMarkLimit; i++)
|
||||
{
|
||||
var gap = GapAt(i);
|
||||
if (gap < -Epsilon || Math.Abs(gap - target) > Epsilon)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var first = row[i];
|
||||
var second = row[i + 1];
|
||||
var from = horizontal ? first.Right : first.Bottom;
|
||||
var to = horizontal ? second.Left : second.Top;
|
||||
// 막대는 두 사각형이 교차축에서 겹치는 구간의 한가운데에 놓는다
|
||||
var lo = horizontal ? Math.Max(first.Top, second.Top) : Math.Max(first.Left, second.Left);
|
||||
var hi = horizontal ? Math.Min(first.Bottom, second.Bottom) : Math.Min(first.Right, second.Right);
|
||||
spacings.Add(new SpacingMark(horizontal, from, to, (lo + hi) / 2, gap));
|
||||
}
|
||||
|
||||
double GapAt(int i) => horizontal
|
||||
? row[i + 1].Left - row[i].Right
|
||||
: row[i + 1].Top - row[i].Bottom;
|
||||
}
|
||||
|
||||
private readonly record struct Match(
|
||||
double Distance, double Position, SnapGuideKind Kind, int EdgeIndex,
|
||||
SnapRect? Source, SnapRect? Page);
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
namespace SheetMe.Core.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// 스냅 계산용 사각형 — 월드 좌표(px).
|
||||
///
|
||||
/// <b>왜 System.Windows.Rect 를 안 쓰는가.</b> 이 프로젝트(SheetMe.Core)는 net10.0 이고
|
||||
/// System.Windows 참조가 0건이다. Rect 를 들이면 Core 가 Windows 전용이 되고
|
||||
/// 단위 테스트 프로젝트까지 같이 끌려간다 — 스냅 계산을 여기로 옮긴 이유가 사라진다.
|
||||
/// </summary>
|
||||
public readonly record struct SnapRect(double X, double Y, double W, double H)
|
||||
{
|
||||
public double Left => X;
|
||||
public double Top => Y;
|
||||
public double Right => X + W;
|
||||
public double Bottom => Y + H;
|
||||
public double CenterX => X + W / 2;
|
||||
public double CenterY => Y + H / 2;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 가이드선 종류 — 동률 후보의 승자를 결정적으로 가르고, 선분 범위 규칙도 이걸로 갈린다.
|
||||
/// 값의 <b>순서가 곧 우선순위</b>다(작을수록 이긴다).
|
||||
/// </summary>
|
||||
public enum SnapGuideKind
|
||||
{
|
||||
/// <summary>형제의 좌/우/상/하 모서리</summary>
|
||||
SiblingEdge = 0,
|
||||
|
||||
/// <summary>형제의 중심</summary>
|
||||
SiblingCenter = 1,
|
||||
|
||||
/// <summary>용지 경계</summary>
|
||||
PageEdge = 2,
|
||||
|
||||
/// <summary>용지 중앙</summary>
|
||||
PageCenter = 3,
|
||||
|
||||
/// <summary>간격 균등으로 정해진 자리 — 선은 긋지 않고 간격 막대로만 보인다</summary>
|
||||
Spacing = 4,
|
||||
}
|
||||
|
||||
/// <summary>가이드선 1개 — 월드 좌표 선분</summary>
|
||||
/// <param name="IsVertical">true = 세로선(X 정합), false = 가로선(Y 정합)</param>
|
||||
/// <param name="Position">정합된 좌표 — 세로선이면 X</param>
|
||||
/// <param name="Start">교차축 시작 — 세로선이면 Y 최소</param>
|
||||
/// <param name="End">교차축 끝</param>
|
||||
public readonly record struct SnapGuide(
|
||||
bool IsVertical, double Position, double Start, double End, SnapGuideKind Kind);
|
||||
|
||||
/// <summary>간격 균등 표시 1개 — 빈틈 하나를 재는 막대(월드 좌표)</summary>
|
||||
/// <param name="IsHorizontal">true = 가로 간격(막대가 수평)</param>
|
||||
/// <param name="Start">주축 시작 — 가로면 X</param>
|
||||
/// <param name="End">주축 끝</param>
|
||||
/// <param name="Cross">교차축 위치 — 막대가 놓이는 선</param>
|
||||
/// <param name="Gap">간격값 — 표시 텍스트용</param>
|
||||
public readonly record struct SpacingMark(
|
||||
bool IsHorizontal, double Start, double End, double Cross, double Gap);
|
||||
|
||||
/// <summary>스냅 계산 결과</summary>
|
||||
/// <param name="SnappedX">X 가 정렬/간격으로 붙었는가 — 그리드로 떨어진 것은 false</param>
|
||||
public readonly record struct SnapOutcome(
|
||||
double Dx, double Dy, bool SnappedX, bool SnappedY,
|
||||
IReadOnlyList<SnapGuide> Guides, IReadOnlyList<SpacingMark> Spacings)
|
||||
{
|
||||
public static SnapOutcome Free(double dx, double dy)
|
||||
=> new(dx, dy, false, false, Array.Empty<SnapGuide>(), Array.Empty<SpacingMark>());
|
||||
}
|
||||
|
||||
/// <summary>리사이즈 모서리 스냅 결과 — 선분 범위는 호출부가 최종 사각형을 확정한 뒤 조립한다</summary>
|
||||
public readonly record struct SnapEdgeOutcome(
|
||||
double Value, bool Snapped, IReadOnlyList<SnapRect> Matched, SnapGuideKind Kind)
|
||||
{
|
||||
public static SnapEdgeOutcome None(double value)
|
||||
=> new(value, false, Array.Empty<SnapRect>(), SnapGuideKind.SiblingEdge);
|
||||
}
|
||||
|
||||
/// <summary>프레임 단위 스냅 파라미터 — 허용 오차는 줌에 따라 매 프레임 달라진다</summary>
|
||||
/// <param name="Tolerance">월드 단위 허용 오차</param>
|
||||
/// <param name="GridSize">정렬이 실패한 축의 폴백 격자(월드)</param>
|
||||
public readonly record struct SnapOptions(double Tolerance, double GridSize, bool EnableSpacing)
|
||||
{
|
||||
/// <summary>
|
||||
/// 화면 px 허용 오차를 월드 허용 오차로.
|
||||
///
|
||||
/// 월드 고정이면 줌 50%에서 두 배로 끈적이고 200%에서 절반으로 미끄러진다 —
|
||||
/// 손끝 감각은 화면 거리로 정해지므로 화면 기준이 맞다.
|
||||
/// 줌은 0.25~3.0 으로 묶는다(디자이너가 허용하는 범위 밖 값이 들어와도 오차가 폭주하지 않게).
|
||||
/// </summary>
|
||||
public static SnapOptions ForZoom(double screenTolerance, double gridSize, double zoom, bool enableSpacing)
|
||||
=> new(screenTolerance / Math.Clamp(zoom, 0.25, 3.0), gridSize, enableSpacing);
|
||||
}
|
||||
|
||||
/// <summary>드래그 한 번 동안 고정되는 후보 집합 — 프레임마다 다시 모으지 않는다</summary>
|
||||
public sealed class SnapContext
|
||||
{
|
||||
/// <summary>용지 사각형(월드)</summary>
|
||||
public IReadOnlyList<SnapRect> Pages { get; init; } = Array.Empty<SnapRect>();
|
||||
|
||||
/// <summary>움직이지 않는 형제(월드) — 컨테이너 자식까지 평탄화해서 넘어온다</summary>
|
||||
public IReadOnlyList<SnapRect> Siblings { get; init; } = Array.Empty<SnapRect>();
|
||||
|
||||
/// <summary>간격 균등 계산의 기준 용지 — 페이지를 넘는 '균등 간격'은 뜻이 없다</summary>
|
||||
public SnapRect? SpacingPage { get; init; }
|
||||
}
|
||||
Reference in New Issue
Block a user