피그마식 스냅 — 여러 선이 동시에, 관련된 것만 잇고, 같은 간격이면 그렇다고 말한다
정렬 스냅 자체는 있었다. 다만 축마다 선 하나만, 그것도 화면 끝에서 끝까지 긋는 무한 점선이라 무엇에 맞춘 것인지가 안 보였다. 판단을 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; }
|
||||||
|
}
|
||||||
@@ -270,6 +270,11 @@ public partial class App : Application
|
|||||||
return Diagnostics.DialogShots.Run(args[1]);
|
return Diagnostics.DialogShots.Run(args[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.Length >= 2 && args[0] == "--snap-shots")
|
||||||
|
{
|
||||||
|
return Diagnostics.SnapShots.Run(args[1]);
|
||||||
|
}
|
||||||
|
|
||||||
if (args.Length >= 1 && args[0] == "--maxrect")
|
if (args.Length >= 1 && args[0] == "--maxrect")
|
||||||
{
|
{
|
||||||
return Diagnostics.MaximizeCheck.Run(args.Length >= 2 ? args[1] : null);
|
return Diagnostics.MaximizeCheck.Run(args.Length >= 2 ? args[1] : null);
|
||||||
|
|||||||
@@ -1093,6 +1093,122 @@ public static class EditSmoke
|
|||||||
dirtyDoc.Undo.Undo();
|
dirtyDoc.Undo.Undo();
|
||||||
Check("더티: 분기 후에는 저장 지점 소실로 계속 dirty", dirtyDoc.Undo.IsDirty);
|
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 로 빈 문서까지
|
// 25) 연속 Undo 로 빈 문서까지
|
||||||
var guard = 0;
|
var guard = 0;
|
||||||
while (designer.Undo.CanUndo && guard++ < 80)
|
while (designer.Undo.CanUndo && guard++ < 80)
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using SheetMe.Designer.DataBusiness;
|
||||||
|
using SheetMe.Designer.Services;
|
||||||
|
using SheetMe.Designer.ViewModels;
|
||||||
|
using SheetMe.Designer.ViewModels.Controls;
|
||||||
|
using SheetMe.Designer.Views;
|
||||||
|
|
||||||
|
namespace SheetMe.Designer.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 스냅 표시 오프스크린 스냅샷 — <c>--snap-shots <출력폴더></c>.
|
||||||
|
///
|
||||||
|
/// <b>왜 필요한가.</b> 기존 <c>--render-smoke</c>·<c>--db-render</c> 는 종이(PageView)만 찍어
|
||||||
|
/// 오버레이가 안 나온다. 스냅은 "붙는 좌표"만 맞아서는 쓸 수 없다 — 선이 어디서 어디까지 그어지는지,
|
||||||
|
/// 여러 개가 동시에 보이는지, 간격 막대가 어떻게 생겼는지는 그림으로만 확인된다.
|
||||||
|
///
|
||||||
|
/// <b>게이트는 수치로 건다.</b> 같이 남기는 <c>_report.txt</c> 의 좌표·개수가 판정 기준이고
|
||||||
|
/// PNG 는 눈 검증 보조다. 픽셀 비교는 테마·글꼴에 흔들려 회귀 게이트로 쓸 수 없다.
|
||||||
|
///
|
||||||
|
/// 드래그 <b>도중</b>을 찍는다 — PointerUp 이 표시를 지우기 때문에 캡처는 반드시 그 전이다.
|
||||||
|
/// </summary>
|
||||||
|
public static class SnapShots
|
||||||
|
{
|
||||||
|
#region Member Fields
|
||||||
|
private static readonly PointerContext Plain = new(false, false, false, 1);
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Methods
|
||||||
|
public static int Run(string outputDirectory)
|
||||||
|
{
|
||||||
|
var lines = new List<string>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(outputDirectory);
|
||||||
|
Application.Current.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||||
|
ThemeManager.Apply(light: true);
|
||||||
|
|
||||||
|
foreach (var (name, zoom, build) in Scenarios())
|
||||||
|
{
|
||||||
|
var designer = new DesignerViewModel(new FormDesignDataBusiness().CreateNew());
|
||||||
|
designer.Zoom = zoom;
|
||||||
|
var grab = build(designer);
|
||||||
|
|
||||||
|
designer.Interaction.PointerDown(grab.From, Plain);
|
||||||
|
designer.Interaction.PointerMove(grab.To, Plain);
|
||||||
|
|
||||||
|
Capture(designer, Path.Combine(outputDirectory, $"{name}.png"));
|
||||||
|
lines.Add($"{name} 줌={zoom:0.##} 가이드={designer.Overlay.Guides.Count} 간격={designer.Overlay.Spacings.Count}");
|
||||||
|
foreach (var guide in designer.Overlay.Guides)
|
||||||
|
{
|
||||||
|
lines.Add($" 가이드 {(guide.IsVertical ? "세로" : "가로")} {guide.Position:0.##}"
|
||||||
|
+ $" ({guide.X1:0.##},{guide.Y1:0.##})~({guide.X2:0.##},{guide.Y2:0.##})");
|
||||||
|
}
|
||||||
|
foreach (var mark in designer.Overlay.Spacings)
|
||||||
|
{
|
||||||
|
lines.Add($" 간격 {mark.Text} ({mark.X1:0.##},{mark.Y1:0.##})~({mark.X2:0.##},{mark.Y2:0.##})");
|
||||||
|
}
|
||||||
|
|
||||||
|
designer.Interaction.PointerUp(grab.To, Plain);
|
||||||
|
if (designer.Overlay.Guides.Count != 0 || designer.Overlay.Spacings.Count != 0)
|
||||||
|
{
|
||||||
|
lines.Add($" FAIL 놓은 뒤에도 표시가 남았다");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var failed = lines.Count(l => l.Contains("FAIL", StringComparison.Ordinal));
|
||||||
|
lines.Add($"결과: 실패 {failed}건");
|
||||||
|
File.WriteAllText(Path.Combine(outputDirectory, "_report.txt"),
|
||||||
|
string.Join(Environment.NewLine, lines), Encoding.UTF8);
|
||||||
|
Console.Error.WriteLine(string.Join(Environment.NewLine, lines));
|
||||||
|
return failed == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"스냅 스냅샷 실패 — {ex}");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct Grab(Point From, Point To);
|
||||||
|
|
||||||
|
private static IEnumerable<(string Name, double Zoom, Func<DesignerViewModel, Grab> Build)> Scenarios()
|
||||||
|
{
|
||||||
|
yield return ("01-형제-좌변", 1.0, d =>
|
||||||
|
{
|
||||||
|
Place(d, "Label", 200, 100, 60, 20);
|
||||||
|
Place(d, "Label", 200, 400, 60, 20);
|
||||||
|
var mover = Place(d, "Label", 197, 250, 40, 20);
|
||||||
|
return Drag(d, mover, 3, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return ("02-좌중우-동시", 1.0, d =>
|
||||||
|
{
|
||||||
|
Place(d, "Label", 200, 120, 60, 20);
|
||||||
|
var mover = Place(d, "Label", 197, 300, 60, 20);
|
||||||
|
return Drag(d, mover, 3, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return ("03-용지-중앙", 1.0, d =>
|
||||||
|
{
|
||||||
|
var mover = Place(d, "Label", 334, 300, 40, 20);
|
||||||
|
return Drag(d, mover, 3, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return ("04-간격-이어붙이기", 1.0, d =>
|
||||||
|
{
|
||||||
|
Place(d, "Label", 100, 300, 50, 20);
|
||||||
|
Place(d, "Label", 180, 300, 50, 20);
|
||||||
|
var mover = Place(d, "Label", 257, 300, 40, 20);
|
||||||
|
return Drag(d, mover, 3, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return ("05-간격-사이끼우기", 1.0, d =>
|
||||||
|
{
|
||||||
|
Place(d, "Label", 100, 300, 50, 20);
|
||||||
|
Place(d, "Label", 300, 300, 50, 20);
|
||||||
|
var mover = Place(d, "Label", 202, 300, 40, 20);
|
||||||
|
return Drag(d, mover, 3, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return ("06-줌-절반", 0.5, d =>
|
||||||
|
{
|
||||||
|
// 컨트롤을 높게 잡는다 — 줌 0.5 에서는 핸들 판정 반경이 12 라
|
||||||
|
// 높이 20 짜리를 가운데서 잡으면 상단 핸들에 먼저 걸려 리사이즈로 빠진다
|
||||||
|
Place(d, "Label", 200, 100, 60, 40);
|
||||||
|
Place(d, "Label", 200, 400, 60, 40);
|
||||||
|
var mover = Place(d, "Label", 190, 250, 40, 40);
|
||||||
|
return Drag(d, mover, 4, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return ("07-줌-두배", 2.0, d =>
|
||||||
|
{
|
||||||
|
Place(d, "Label", 200, 100, 60, 20);
|
||||||
|
Place(d, "Label", 200, 400, 60, 20);
|
||||||
|
var mover = Place(d, "Label", 198, 250, 40, 20);
|
||||||
|
return Drag(d, mover, 3, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ControlViewModel Place(DesignerViewModel designer, string type,
|
||||||
|
double x, double y, double w, double h)
|
||||||
|
{
|
||||||
|
designer.AddControlAt(type, new Point(x, y));
|
||||||
|
var vm = designer.Pages[0].Controls[^1];
|
||||||
|
vm.X = x;
|
||||||
|
vm.Y = y;
|
||||||
|
vm.Width = w;
|
||||||
|
vm.Height = h;
|
||||||
|
return vm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Grab Drag(DesignerViewModel designer, ControlViewModel mover, double dx, double dy)
|
||||||
|
{
|
||||||
|
designer.Selection.SetSingle(mover);
|
||||||
|
designer.RefreshOverlay();
|
||||||
|
var from = new Point(mover.X + mover.Width / 2, mover.Y + mover.Height / 2);
|
||||||
|
return new Grab(from, new Point(from.X + dx, from.Y + dy));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>종이 위에 오버레이를 그대로 겹쳐 찍는다 — 1페이지 문서라 월드 좌표가 곧 종이 좌표다</summary>
|
||||||
|
private static void Capture(DesignerViewModel designer, string pngPath)
|
||||||
|
{
|
||||||
|
var page = designer.Pages[0];
|
||||||
|
var width = page.WidthDip;
|
||||||
|
var height = page.HeightDip;
|
||||||
|
|
||||||
|
var root = new Grid { Width = width, Height = height, Background = Brushes.White };
|
||||||
|
root.Children.Add(new PageView { DataContext = page });
|
||||||
|
root.Children.Add(new SelectionOverlayView { DataContext = designer.Overlay });
|
||||||
|
|
||||||
|
root.Measure(new Size(width, height));
|
||||||
|
root.Arrange(new Rect(0, 0, width, height));
|
||||||
|
root.UpdateLayout();
|
||||||
|
|
||||||
|
var bitmap = new RenderTargetBitmap(
|
||||||
|
(int)Math.Ceiling(width), (int)Math.Ceiling(height), 96, 96, PixelFormats.Pbgra32);
|
||||||
|
bitmap.Render(root);
|
||||||
|
|
||||||
|
var encoder = new PngBitmapEncoder();
|
||||||
|
encoder.Frames.Add(BitmapFrame.Create(bitmap));
|
||||||
|
using var stream = File.Create(pngPath);
|
||||||
|
encoder.Save(stream);
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using SheetMe.Core.Layout;
|
||||||
using SheetMe.Designer.ViewModels;
|
using SheetMe.Designer.ViewModels;
|
||||||
using SheetMe.Designer.ViewModels.Controls;
|
using SheetMe.Designer.ViewModels.Controls;
|
||||||
|
|
||||||
@@ -78,6 +79,9 @@ public sealed class InteractionController
|
|||||||
mode = Mode.Resize;
|
mode = Mode.Resize;
|
||||||
resizeHandleIndex = handleIndex;
|
resizeHandleIndex = handleIndex;
|
||||||
CaptureOrigBounds();
|
CaptureOrigBounds();
|
||||||
|
// 리사이즈도 후보를 모아야 한다. 예전에는 여기서 안 모아서, 이동 드래그를 한 번 한 뒤라야
|
||||||
|
// 리사이즈 스냅이 — 그것도 그때의 낡은 좌표로 — 동작했다.
|
||||||
|
BeginSnap();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,11 +192,13 @@ public sealed class InteractionController
|
|||||||
|
|
||||||
case Mode.Move:
|
case Mode.Move:
|
||||||
designer.ReassignPagesAfterMove(origBounds.Keys.ToList());
|
designer.ReassignPagesAfterMove(origBounds.Keys.ToList());
|
||||||
designer.Overlay.SetGuides(null, null);
|
designer.Overlay.ClearGuides();
|
||||||
|
designer.Snap.EndDrag();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Mode.Resize:
|
case Mode.Resize:
|
||||||
designer.Overlay.SetGuides(null, null);
|
designer.Overlay.ClearGuides();
|
||||||
|
designer.Snap.EndDrag();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Mode.Marquee:
|
case Mode.Marquee:
|
||||||
@@ -236,7 +242,8 @@ public sealed class InteractionController
|
|||||||
vm.Width = rect.Width;
|
vm.Width = rect.Width;
|
||||||
vm.Height = rect.Height;
|
vm.Height = rect.Height;
|
||||||
}
|
}
|
||||||
designer.Overlay.SetGuides(null, null);
|
designer.Overlay.ClearGuides();
|
||||||
|
designer.Snap.EndDrag();
|
||||||
mode = Mode.None;
|
mode = Mode.None;
|
||||||
origBounds.Clear();
|
origBounds.Clear();
|
||||||
designer.RefreshOverlay();
|
designer.RefreshOverlay();
|
||||||
@@ -287,16 +294,60 @@ public sealed class InteractionController
|
|||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Methods - Private
|
#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()
|
private void BeginMove()
|
||||||
{
|
{
|
||||||
CaptureOrigBounds();
|
CaptureOrigBounds();
|
||||||
|
BeginSnap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 스냅 후보 수집 — 이동·리사이즈 공용.
|
||||||
|
///
|
||||||
|
/// 제외 집합은 선택 항목만으로는 모자라다. 끌려가는 컨테이너의 자식은 선택에 없지만 함께 움직이므로,
|
||||||
|
/// 빼지 않으면 컨테이너가 제 자식에게 붙어 그 자리에서 굳는다.
|
||||||
|
/// </summary>
|
||||||
|
private void BeginSnap()
|
||||||
|
{
|
||||||
// Primary 는 없을 수 있다(선택 항목은 있는데 주선택이 비는 경로가 있다) —
|
// Primary 는 없을 수 있다(선택 항목은 있는데 주선택이 비는 경로가 있다) —
|
||||||
// ?! 로 넘기면 PageOf → RootAncestorOf 가 즉시 역참조해 이동 시작이 예외로 죽는다
|
// ?! 로 넘기면 PageOf → RootAncestorOf 가 즉시 역참조해 이동 시작이 예외로 죽는다
|
||||||
var anchor = designer.Selection.Primary ?? designer.Selection.Items.FirstOrDefault();
|
var anchor = designer.Selection.Primary ?? designer.Selection.Items.FirstOrDefault();
|
||||||
var page = (anchor is null ? null : designer.PageOf(anchor)) ?? designer.Pages.FirstOrDefault();
|
var page = (anchor is null ? null : designer.PageOf(anchor)) ?? designer.Pages.FirstOrDefault();
|
||||||
if (page is not null)
|
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 dx = world.X - downPoint.X;
|
||||||
var dy = world.Y - downPoint.Y;
|
var dy = world.Y - downPoint.Y;
|
||||||
|
|
||||||
var snap = designer.Snap.SnapMove(groupBounds0, dx, dy, ctx.Alt);
|
var snap = designer.Snap.SnapMove(groupBounds0, dx, dy, ctx.Alt, designer.Zoom);
|
||||||
designer.Overlay.SetGuides(snap.GuideX, snap.GuideY);
|
designer.Overlay.SetGuides(snap.Guides, snap.Spacings);
|
||||||
|
|
||||||
// origBounds 는 월드 좌표 — 로컬 = (원본 월드 + 보정 델타) - 부모 체인 오프셋
|
// origBounds 는 월드 좌표 — 로컬 = (원본 월드 + 보정 델타) - 부모 체인 오프셋
|
||||||
foreach (var (vm, rect) in origBounds)
|
foreach (var (vm, rect) in origBounds)
|
||||||
{
|
{
|
||||||
var offset = designer.ParentWorldOffset(vm);
|
var offset = designer.ParentWorldOffset(vm);
|
||||||
vm.X = Math.Round(rect.X + snap.Dx - offset.X);
|
var x = rect.X + snap.Dx - offset.X;
|
||||||
vm.Y = Math.Round(rect.Y + snap.Dy - offset.Y);
|
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();
|
designer.Selection.NotifyBoundsChanged();
|
||||||
@@ -332,21 +388,27 @@ public sealed class InteractionController
|
|||||||
var bottom = rect.Bottom;
|
var bottom = rect.Bottom;
|
||||||
|
|
||||||
// 핸들별 이동 모서리 (0=NW,1=N,2=NE,3=E,4=SE,5=S,6=SW,7=W)
|
// 핸들별 이동 모서리 (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)
|
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)
|
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)
|
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)
|
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(
|
var newRect = new Rect(
|
||||||
@@ -355,6 +417,10 @@ public sealed class InteractionController
|
|||||||
Math.Max(MinSize, right - left),
|
Math.Max(MinSize, right - left),
|
||||||
Math.Max(MinSize, bottom - top));
|
Math.Max(MinSize, bottom - top));
|
||||||
|
|
||||||
|
// 선분은 최종 사각형이 정해진 뒤에 조립한다 — 교차축도 스냅으로 바뀔 수 있어서,
|
||||||
|
// 스냅 계산 시점의 좌표로 범위를 만들면 선이 어긋난 자리에 걸린다.
|
||||||
|
designer.Overlay.SetGuides(EdgeGuides(hitX, hitY, newRect), Array.Empty<SpacingMark>());
|
||||||
|
|
||||||
// 그룹 비례 스케일 — origBounds 기준 절대 재계산
|
// 그룹 비례 스케일 — origBounds 기준 절대 재계산
|
||||||
var sx = newRect.Width / Math.Max(1, groupBounds0.Width);
|
var sx = newRect.Width / Math.Max(1, groupBounds0.Width);
|
||||||
var sy = newRect.Height / Math.Max(1, groupBounds0.Height);
|
var sy = newRect.Height / Math.Max(1, groupBounds0.Height);
|
||||||
@@ -409,7 +475,8 @@ public sealed class InteractionController
|
|||||||
var positions = SelectionOverlayViewModel.HandlePositions(bbox);
|
var positions = SelectionOverlayViewModel.HandlePositions(bbox);
|
||||||
|
|
||||||
// 반경 내 '최근접' 핸들 선택 — 작은 컨트롤에서 인접 핸들 오인 방지
|
// 반경 내 '최근접' 핸들 선택 — 작은 컨트롤에서 인접 핸들 오인 방지
|
||||||
const double hitRadius = 6;
|
// 화면 기준 6px — 월드 고정이면 줌 300%에서 그림은 8px 인데 판정이 18px 로 벌어진다
|
||||||
|
var hitRadius = 6 / designer.Zoom;
|
||||||
var best = -1;
|
var best = -1;
|
||||||
var bestDistance = double.MaxValue;
|
var bestDistance = double.MaxValue;
|
||||||
for (var i = 0; i < positions.Length; i++)
|
for (var i = 0; i < positions.Length; i++)
|
||||||
|
|||||||
@@ -1,125 +1,86 @@
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using SheetMe.Core.Layout;
|
||||||
using SheetMe.Designer.ViewModels;
|
using SheetMe.Designer.ViewModels;
|
||||||
using SheetMe.Designer.ViewModels.Controls;
|
using SheetMe.Designer.ViewModels.Controls;
|
||||||
|
|
||||||
namespace SheetMe.Designer.Services;
|
namespace SheetMe.Designer.Services;
|
||||||
|
|
||||||
/// <summary>스냅 결과 — 보정된 이동량과 표시할 가이드 좌표(월드)</summary>
|
|
||||||
public readonly record struct SnapResult(double Dx, double Dy, double? GuideX, double? GuideY);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 그리드/정렬 스냅 순수 계산.
|
/// 스냅 — 뷰모델과 순수 계산(<see cref="SnapSolver"/>) 사이의 얇은 어댑터.
|
||||||
/// 드래그 시작 시 후보(비선택 형제의 좌/중/우·상/중/하 + 용지 경계/중앙)를 1회 사전수집해
|
///
|
||||||
/// 프레임당 재수집을 피한다.
|
/// <b>왜 계산이 여기 없는가.</b> 스냅 규칙은 눈으로만 확인되는 종류의 코드다 — 후보 하나,
|
||||||
|
/// 열거 순서 하나가 바뀌면 붙는 자리가 조용히 달라지고 드래그해 보기 전엔 아무도 모른다.
|
||||||
|
/// 그래서 판단은 전부 SheetMe.Core 로 옮겨 단위 테스트로 못 박고, 여기에는
|
||||||
|
/// "어떤 사각형들이 후보인가"와 좌표 변환만 남긴다.
|
||||||
|
///
|
||||||
|
/// 허용 오차는 <b>화면 픽셀</b>이다. 월드 고정이면 줌 50%에서 두 배로 끈적이고 200%에서 절반으로
|
||||||
|
/// 미끄러진다 — 손끝 감각은 화면 거리로 정해진다.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SnapEngine
|
public sealed class SnapEngine
|
||||||
{
|
{
|
||||||
#region Member Fields
|
#region Member Fields
|
||||||
private readonly List<double> candidatesX = new();
|
private readonly SnapSolver solver = new();
|
||||||
private readonly List<double> candidatesY = new();
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Properties
|
#region Properties
|
||||||
/// <summary>그리드 간격(px)</summary>
|
/// <summary>정렬이 실패한 축의 폴백 격자(월드 px)</summary>
|
||||||
public double GridSize { get; set; } = 4;
|
public double GridSize { get; set; } = 4;
|
||||||
|
|
||||||
/// <summary>정렬 스냅 허용 거리(px)</summary>
|
/// <summary>정렬 허용 거리 — <b>화면</b> px</summary>
|
||||||
public double Tolerance { get; set; } = 6;
|
public double ScreenTolerance { get; set; } = 6;
|
||||||
|
|
||||||
|
/// <summary>간격 균등 스냅 사용</summary>
|
||||||
|
public bool EnableSpacing { get; set; } = true;
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Methods
|
#region Methods
|
||||||
/// <summary>드래그 시작 — 스냅 후보 사전수집(월드 좌표)</summary>
|
/// <summary>
|
||||||
public void BeginDrag(PageViewModel page, IReadOnlySet<ControlViewModel> moving)
|
/// 드래그 시작 — 후보를 한 번만 모은다.
|
||||||
|
///
|
||||||
|
/// 후보에는 <b>컨테이너 자식까지</b> 넣는다. 히트테스트는 재귀하는데 스냅만 최상위를 보던 것은
|
||||||
|
/// 규칙 불일치였다 — 패널 안 라벨에 맞추려 해도 붙지 않았다.
|
||||||
|
/// 잠긴 컨트롤은 후보로 <b>남긴다</b>(고정 기준선으로 쓰는 것이 맞다). 숨긴 것만 뺀다.
|
||||||
|
/// </summary>
|
||||||
|
public void BeginDrag(DesignerViewModel designer, PageViewModel page,
|
||||||
|
IReadOnlySet<ControlViewModel> moving)
|
||||||
{
|
{
|
||||||
candidatesX.Clear();
|
var siblings = new List<SnapRect>();
|
||||||
candidatesY.Clear();
|
foreach (var vm in designer.ControlsOfPage(page))
|
||||||
|
|
||||||
// 용지 경계·중앙
|
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
if (moving.Contains(sibling) || sibling.Model.Hidden)
|
// 끌려가는 컨테이너의 자식은 선택 목록에 없지만 부모를 따라 움직인다 —
|
||||||
|
// 빼지 않으면 자기 자신에게 붙는다
|
||||||
|
if (moving.Contains(vm) || vm.Model.Hidden)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
candidatesX.Add(sibling.X);
|
siblings.Add(ToSnapRect(designer.WorldBoundsOf(vm)));
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
var paper = new SnapRect(0, page.OffsetY, page.WidthDip, page.HeightDip);
|
||||||
/// 이동 스냅 — bbox(월드)에 (dx,dy) 적용 시 정렬 후보와 최근접 정합.
|
solver.Prepare(new SnapContext
|
||||||
/// free=true(Alt)면 스냅 없이 원본 이동량 그대로.
|
|
||||||
/// </summary>
|
|
||||||
public SnapResult SnapMove(Rect bbox, double dx, double dy, bool free)
|
|
||||||
{
|
{
|
||||||
if (free)
|
Pages = new[] { paper },
|
||||||
{
|
Siblings = siblings,
|
||||||
return new SnapResult(dx, dy, null, null);
|
// 페이지를 넘는 '균등 간격'은 뜻이 없다
|
||||||
|
SpacingPage = paper,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var movedLeft = bbox.X + dx;
|
/// <summary>드래그 끝 — 후보를 버린다. 남겨 두면 다음 드래그가 낡은 좌표에 붙는다</summary>
|
||||||
var movedTop = bbox.Y + dy;
|
public void EndDrag() => solver.Reset();
|
||||||
|
|
||||||
var (adjustX, guideX) = SnapAxis(new[] { movedLeft, movedLeft + bbox.Width / 2, movedLeft + bbox.Width }, candidatesX);
|
/// <summary>이동 스냅 — bbox(월드)에 (dx,dy) 를 적용한 자리에서 정렬·간격·격자 순으로 붙인다</summary>
|
||||||
var (adjustY, guideY) = SnapAxis(new[] { movedTop, movedTop + bbox.Height / 2, movedTop + bbox.Height }, candidatesY);
|
public SnapOutcome SnapMove(Rect bbox, double dx, double dy, bool free, double zoom)
|
||||||
|
=> solver.SolveMove(ToSnapRect(bbox), dx, dy, OptionsFor(zoom), free);
|
||||||
|
|
||||||
// 정합 실패 축은 그리드 스냅
|
/// <summary>리사이즈 모서리 스냅</summary>
|
||||||
var resultDx = guideX is not null ? dx + adjustX : Math.Round(movedLeft / GridSize) * GridSize - bbox.X;
|
public SnapEdgeOutcome SnapEdge(double value, bool isXAxis, bool free, double zoom)
|
||||||
var resultDy = guideY is not null ? dy + adjustY : Math.Round(movedTop / GridSize) * GridSize - bbox.Y;
|
=> solver.SolveEdge(value, isXAxis, OptionsFor(zoom), free);
|
||||||
|
|
||||||
return new SnapResult(resultDx, resultDy, guideX, guideY);
|
private SnapOptions OptionsFor(double zoom)
|
||||||
}
|
=> SnapOptions.ForZoom(ScreenTolerance, GridSize, zoom, EnableSpacing);
|
||||||
|
|
||||||
/// <summary>리사이즈 스냅 — 움직이는 모서리 좌표만 정합(간이: 그리드 우선)</summary>
|
private static SnapRect ToSnapRect(Rect rect)
|
||||||
public double SnapEdge(double value, bool isXAxis, bool free)
|
=> new(rect.X, rect.Y, rect.Width, rect.Height);
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,11 @@
|
|||||||
<SolidColorBrush x:Key="B.Danger" Color="#E04A2B" />
|
<SolidColorBrush x:Key="B.Danger" Color="#E04A2B" />
|
||||||
<SolidColorBrush x:Key="B.Ok" Color="#5BC46E" />
|
<SolidColorBrush x:Key="B.Ok" Color="#5BC46E" />
|
||||||
|
|
||||||
|
<!-- 캔버스 오버레이 — 종이는 양 테마 모두 흰색이라(PageView 의 PaperBrush) 값이 같다.
|
||||||
|
테마별로 가르면 흰 종이 위 대비가 무너진다. -->
|
||||||
|
<SolidColorBrush x:Key="B.CanvasGuide" Color="#F24822" /><!-- 정렬 가이드 -->
|
||||||
|
<SolidColorBrush x:Key="B.CanvasSpacing" Color="#F24822" /><!-- 간격 균등 막대 -->
|
||||||
|
|
||||||
<!-- SQL 구문 색 — 어두운 배경 대비 4.5:1 이상 -->
|
<!-- SQL 구문 색 — 어두운 배경 대비 4.5:1 이상 -->
|
||||||
<SolidColorBrush x:Key="B.SqlKeyword" Color="#6FB6F2" />
|
<SolidColorBrush x:Key="B.SqlKeyword" Color="#6FB6F2" />
|
||||||
<SolidColorBrush x:Key="B.SqlFunction" Color="#C89BE8" />
|
<SolidColorBrush x:Key="B.SqlFunction" Color="#C89BE8" />
|
||||||
|
|||||||
@@ -83,6 +83,11 @@
|
|||||||
<SolidColorBrush x:Key="B.Danger" Color="#C4321A" />
|
<SolidColorBrush x:Key="B.Danger" Color="#C4321A" />
|
||||||
<SolidColorBrush x:Key="B.Ok" Color="#1A7F37" />
|
<SolidColorBrush x:Key="B.Ok" Color="#1A7F37" />
|
||||||
|
|
||||||
|
<!-- 캔버스 오버레이 — 종이는 양 테마 모두 흰색이라(PageView 의 PaperBrush) 값이 같다.
|
||||||
|
테마별로 가르면 흰 종이 위 대비가 무너진다. -->
|
||||||
|
<SolidColorBrush x:Key="B.CanvasGuide" Color="#F24822" /><!-- 정렬 가이드 -->
|
||||||
|
<SolidColorBrush x:Key="B.CanvasSpacing" Color="#F24822" /><!-- 간격 균등 막대 -->
|
||||||
|
|
||||||
<!-- SQL 구문 색 — 흰 배경 대비 4.5:1 이상.
|
<!-- SQL 구문 색 — 흰 배경 대비 4.5:1 이상.
|
||||||
치환 변수는 이 편집기에서 가장 중요한 조각이라 가장 진하게 둔다. -->
|
치환 변수는 이 편집기에서 가장 중요한 조각이라 가장 진하게 둔다. -->
|
||||||
<SolidColorBrush x:Key="B.SqlKeyword" Color="#0B5FA5" />
|
<SolidColorBrush x:Key="B.SqlKeyword" Color="#0B5FA5" />
|
||||||
|
|||||||
@@ -239,6 +239,8 @@ public sealed class DesignerViewModel : ViewModelBase
|
|||||||
if (SetProperty(ref zoom, clamped))
|
if (SetProperty(ref zoom, clamped))
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(ZoomPercentText));
|
OnPropertyChanged(nameof(ZoomPercentText));
|
||||||
|
// 오버레이 선 두께를 화면 1px 로 유지하려면 오버레이가 배율을 알아야 한다
|
||||||
|
Overlay.Zoom = clamped;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,7 +272,7 @@ public sealed class DesignerViewModel : ViewModelBase
|
|||||||
Snap = new SnapEngine
|
Snap = new SnapEngine
|
||||||
{
|
{
|
||||||
GridSize = ConfigService.Current.GridSize,
|
GridSize = ConfigService.Current.GridSize,
|
||||||
Tolerance = ConfigService.Current.SnapThreshold,
|
ScreenTolerance = ConfigService.Current.SnapThreshold,
|
||||||
};
|
};
|
||||||
Undo = new UndoService(() => Document, ReplaceDocument);
|
Undo = new UndoService(() => Document, ReplaceDocument);
|
||||||
Interaction = new InteractionController(this);
|
Interaction = new InteractionController(this);
|
||||||
@@ -953,6 +955,51 @@ public sealed class DesignerViewModel : ViewModelBase
|
|||||||
public IReadOnlyList<string> ControlNames()
|
public IReadOnlyList<string> ControlNames()
|
||||||
=> AllControls().Select(c => c.Id).Where(id => id.Length > 0).Distinct(StringComparer.Ordinal).ToList();
|
=> AllControls().Select(c => c.Id).Where(id => id.Length > 0).Distinct(StringComparer.Ordinal).ToList();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 한 페이지의 컨트롤 순회(컨테이너 자식 포함) — 스냅 후보 수집용.
|
||||||
|
/// 스냅은 앵커 페이지 안에서만 본다. 전 페이지를 넣으면 후보가 폭발하고,
|
||||||
|
/// 다른 종이의 좌표에 붙는 것은 뜻도 없다.
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<ControlViewModel> ControlsOfPage(PageViewModel page)
|
||||||
|
{
|
||||||
|
foreach (var vm in page.Controls)
|
||||||
|
{
|
||||||
|
foreach (var descendant in Descend(vm))
|
||||||
|
{
|
||||||
|
yield return descendant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>선택 항목과 그 자손 전부 — 끌려가는 컨테이너의 자식을 스냅 후보에서 빼는 데 쓴다</summary>
|
||||||
|
public HashSet<ControlViewModel> SelectionWithDescendants()
|
||||||
|
{
|
||||||
|
var set = new HashSet<ControlViewModel>();
|
||||||
|
foreach (var item in Selection.Items)
|
||||||
|
{
|
||||||
|
foreach (var vm in Descend(item))
|
||||||
|
{
|
||||||
|
set.Add(vm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<ControlViewModel> Descend(ControlViewModel viewModel)
|
||||||
|
{
|
||||||
|
yield return viewModel;
|
||||||
|
if (viewModel is ContainerViewModel container)
|
||||||
|
{
|
||||||
|
foreach (var child in container.Children)
|
||||||
|
{
|
||||||
|
foreach (var descendant in Descend(child))
|
||||||
|
{
|
||||||
|
yield return descendant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>문서 전체 컨트롤 순회(컨테이너 자식 포함, 모델 순서)</summary>
|
/// <summary>문서 전체 컨트롤 순회(컨테이너 자식 포함, 모델 순서)</summary>
|
||||||
private IEnumerable<ControlViewModel> AllControls()
|
private IEnumerable<ControlViewModel> AllControls()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,16 +1,58 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using SheetMe.Core.Layout;
|
||||||
|
|
||||||
namespace SheetMe.Designer.ViewModels;
|
namespace SheetMe.Designer.ViewModels;
|
||||||
|
|
||||||
/// <summary>오버레이 가이드선 1개 — 월드 좌표 수직/수평선</summary>
|
/// <summary>
|
||||||
|
/// 오버레이 가이드선 1개 — 월드 좌표 <b>선분</b>(양끝).
|
||||||
|
///
|
||||||
|
/// 예전에는 위치 하나만 들고 화면 끝에서 끝까지(±100000) 그었다. 그러면 "무엇에 맞춘 것인지"가
|
||||||
|
/// 안 보인다 — 종이 밖 회색 배경까지 선이 뻗어 대비도 죽었다. 관련된 것들만 잇는다.
|
||||||
|
/// </summary>
|
||||||
public sealed class GuideLineInfo
|
public sealed class GuideLineInfo
|
||||||
{
|
{
|
||||||
/// <summary>수직선 여부(false=수평선)</summary>
|
public double X1 { get; init; }
|
||||||
public bool IsVertical { get; init; }
|
public double Y1 { get; init; }
|
||||||
|
public double X2 { get; init; }
|
||||||
|
public double Y2 { get; init; }
|
||||||
|
|
||||||
/// <summary>선 위치(월드 X 또는 Y)</summary>
|
/// <summary>수직선 여부 — 진단·테스트용 파생(뷰는 X1..Y2 만 쓴다)</summary>
|
||||||
public double Position { get; init; }
|
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>
|
/// <summary>탭순서 배지 1개 — 월드 좌표(컨트롤 좌상단)</summary>
|
||||||
@@ -63,6 +105,7 @@ public sealed class SelectionOverlayViewModel : ViewModelBase
|
|||||||
private double marY;
|
private double marY;
|
||||||
private double marW;
|
private double marW;
|
||||||
private double marH;
|
private double marH;
|
||||||
|
private double zoom = 1;
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Properties
|
#region Properties
|
||||||
@@ -105,6 +148,28 @@ public sealed class SelectionOverlayViewModel : ViewModelBase
|
|||||||
/// <summary>정렬 가이드선 목록</summary>
|
/// <summary>정렬 가이드선 목록</summary>
|
||||||
public ObservableCollection<GuideLineInfo> Guides { get; } = new();
|
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>
|
/// <summary>탭순서 배지 목록(탭순서 편집 모드 전용)</summary>
|
||||||
public ObservableCollection<TabBadgeInfo> TabBadges { get; } = new();
|
public ObservableCollection<TabBadgeInfo> TabBadges { get; } = new();
|
||||||
#endregion
|
#endregion
|
||||||
@@ -170,18 +235,115 @@ public sealed class SelectionOverlayViewModel : ViewModelBase
|
|||||||
MarH = rect.Value.Height;
|
MarH = rect.Value.Height;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>가이드선 교체</summary>
|
/// <summary>가이드·간격 표시 지우기</summary>
|
||||||
public void SetGuides(double? guideX, double? guideY)
|
public void ClearGuides()
|
||||||
|
{
|
||||||
|
if (Guides.Count > 0)
|
||||||
{
|
{
|
||||||
Guides.Clear();
|
Guides.Clear();
|
||||||
if (guideX is not null)
|
|
||||||
{
|
|
||||||
Guides.Add(new GuideLineInfo { IsVertical = true, Position = guideX.Value });
|
|
||||||
}
|
}
|
||||||
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
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:vm="clr-namespace:SheetMe.Designer.ViewModels"
|
xmlns:vm="clr-namespace:SheetMe.Designer.ViewModels"
|
||||||
|
xmlns:v="clr-namespace:SheetMe.Designer.Views"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
d:DataContext="{d:DesignInstance vm:SelectionOverlayViewModel}"
|
d:DataContext="{d:DesignInstance vm:SelectionOverlayViewModel}"
|
||||||
IsHitTestVisible="False">
|
IsHitTestVisible="False">
|
||||||
@@ -11,39 +12,6 @@
|
|||||||
<!-- 선택/마퀴/가이드 표시 전용 오버레이 — 입력은 월드 파이프라인이 처리(기하 히트테스트) -->
|
<!-- 선택/마퀴/가이드 표시 전용 오버레이 — 입력은 월드 파이프라인이 처리(기하 히트테스트) -->
|
||||||
<Canvas>
|
<Canvas>
|
||||||
|
|
||||||
<!-- 정렬 가이드선 -->
|
|
||||||
<ItemsControl ItemsSource="{Binding Guides}">
|
|
||||||
<ItemsControl.ItemsPanel>
|
|
||||||
<ItemsPanelTemplate>
|
|
||||||
<Canvas/>
|
|
||||||
</ItemsPanelTemplate>
|
|
||||||
</ItemsControl.ItemsPanel>
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate>
|
|
||||||
<Line Stroke="#FF4FA3" StrokeThickness="1" StrokeDashArray="4 3">
|
|
||||||
<Line.Style>
|
|
||||||
<Style TargetType="Line">
|
|
||||||
<Style.Triggers>
|
|
||||||
<DataTrigger Binding="{Binding IsVertical}" Value="True">
|
|
||||||
<Setter Property="X1" Value="{Binding Position}"/>
|
|
||||||
<Setter Property="X2" Value="{Binding Position}"/>
|
|
||||||
<Setter Property="Y1" Value="-100000"/>
|
|
||||||
<Setter Property="Y2" Value="100000"/>
|
|
||||||
</DataTrigger>
|
|
||||||
<DataTrigger Binding="{Binding IsVertical}" Value="False">
|
|
||||||
<Setter Property="Y1" Value="{Binding Position}"/>
|
|
||||||
<Setter Property="Y2" Value="{Binding Position}"/>
|
|
||||||
<Setter Property="X1" Value="-100000"/>
|
|
||||||
<Setter Property="X2" Value="100000"/>
|
|
||||||
</DataTrigger>
|
|
||||||
</Style.Triggers>
|
|
||||||
</Style>
|
|
||||||
</Line.Style>
|
|
||||||
</Line>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
|
|
||||||
<!-- 선택 박스 -->
|
<!-- 선택 박스 -->
|
||||||
<Rectangle Canvas.Left="{Binding SelX}" Canvas.Top="{Binding SelY}"
|
<Rectangle Canvas.Left="{Binding SelX}" Canvas.Top="{Binding SelY}"
|
||||||
Width="{Binding SelW}" Height="{Binding SelH}"
|
Width="{Binding SelW}" Height="{Binding SelH}"
|
||||||
@@ -110,5 +78,76 @@
|
|||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
|
|
||||||
|
<!-- ══ 스냅 표시 ══
|
||||||
|
선택 박스·핸들보다 뒤에 둔다. 앞에 두면 선택한 컨트롤 밑에 깔려 정작 지금 붙은 자리가 안 보인다.
|
||||||
|
선 두께와 대시는 화면 1px 로 고정한다 — 줌이 걸린 층이라 InverseZoom 을 곱한다.
|
||||||
|
(StrokeDashArray 는 두께의 배수 단위라 자동으로 따라온다.)
|
||||||
|
EdgeMode=Aliased: 월드 좌표가 화면에서 비정수로 떨어지면 1px 선이 2px 회색으로 번진다.
|
||||||
|
전부 축정렬 직선이라 부작용이 없다. -->
|
||||||
|
|
||||||
|
<!-- 정렬 가이드선 -->
|
||||||
|
<ItemsControl ItemsSource="{Binding Guides}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<Canvas/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Line X1="{Binding X1}" Y1="{Binding Y1}" X2="{Binding X2}" Y2="{Binding Y2}"
|
||||||
|
Stroke="{DynamicResource B.CanvasGuide}" RenderOptions.EdgeMode="Aliased"
|
||||||
|
StrokeThickness="{Binding DataContext.InverseZoom,
|
||||||
|
RelativeSource={RelativeSource AncestorType=v:SelectionOverlayView}}"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<!-- 간격 균등 — 막대 하나에 양끝 갈고리, 가운데 수치 -->
|
||||||
|
<ItemsControl ItemsSource="{Binding Spacings}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<Canvas/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Canvas>
|
||||||
|
<Line X1="{Binding X1}" Y1="{Binding Y1}" X2="{Binding X2}" Y2="{Binding Y2}"
|
||||||
|
Stroke="{DynamicResource B.CanvasSpacing}" RenderOptions.EdgeMode="Aliased"
|
||||||
|
StrokeThickness="{Binding DataContext.InverseZoom,
|
||||||
|
RelativeSource={RelativeSource AncestorType=v:SelectionOverlayView}}"/>
|
||||||
|
<Line X1="{Binding CapAX1}" Y1="{Binding CapAY1}" X2="{Binding CapAX2}" Y2="{Binding CapAY2}"
|
||||||
|
Stroke="{DynamicResource B.CanvasSpacing}" RenderOptions.EdgeMode="Aliased"
|
||||||
|
StrokeThickness="{Binding DataContext.InverseZoom,
|
||||||
|
RelativeSource={RelativeSource AncestorType=v:SelectionOverlayView}}"/>
|
||||||
|
<Line X1="{Binding CapBX1}" Y1="{Binding CapBY1}" X2="{Binding CapBX2}" Y2="{Binding CapBY2}"
|
||||||
|
Stroke="{DynamicResource B.CanvasSpacing}" RenderOptions.EdgeMode="Aliased"
|
||||||
|
StrokeThickness="{Binding DataContext.InverseZoom,
|
||||||
|
RelativeSource={RelativeSource AncestorType=v:SelectionOverlayView}}"/>
|
||||||
|
|
||||||
|
<!-- 수치 칩. 막대가 정렬 가이드선과 같은 자리에 놓이는 경우가 흔해서
|
||||||
|
막대만으로는 '간격을 재고 있다'가 안 읽힌다 — 숫자가 그걸 가른다.
|
||||||
|
크기 0 Canvas 에 담아 그 점을 중심으로 놓고, 줌이 걸린 층이라 역배율로 되돌려
|
||||||
|
글자 크기를 화면 고정으로 만든다. -->
|
||||||
|
<!-- 좌상단 기준으로 놓는다. 가운데 정렬을 XAML 로 하려면 0 크기 컨테이너에 담아야 하는데,
|
||||||
|
그러면 자식이 폭 0 으로 측정돼 글자가 통째로 사라진다(실제로 그렇게 만들어 봤다).
|
||||||
|
그래서 중심 보정은 뷰모델이 좌표에 미리 넣어 둔다. -->
|
||||||
|
<Border Canvas.Left="{Binding TextX}" Canvas.Top="{Binding TextY}"
|
||||||
|
Background="{DynamicResource B.CanvasSpacing}" CornerRadius="2" Padding="3,0"
|
||||||
|
RenderTransformOrigin="0,0">
|
||||||
|
<Border.RenderTransform>
|
||||||
|
<ScaleTransform
|
||||||
|
ScaleX="{Binding DataContext.InverseZoom,
|
||||||
|
RelativeSource={RelativeSource AncestorType=v:SelectionOverlayView}}"
|
||||||
|
ScaleY="{Binding DataContext.InverseZoom,
|
||||||
|
RelativeSource={RelativeSource AncestorType=v:SelectionOverlayView}}"/>
|
||||||
|
</Border.RenderTransform>
|
||||||
|
<TextBlock Text="{Binding Text}" Foreground="White" FontSize="10" FontWeight="Bold"/>
|
||||||
|
</Border>
|
||||||
|
</Canvas>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user