피그마식 스냅 — 여러 선이 동시에, 관련된 것만 잇고, 같은 간격이면 그렇다고 말한다
정렬 스냅 자체는 있었다. 다만 축마다 선 하나만, 그것도 화면 끝에서 끝까지 긋는 무한 점선이라 무엇에 맞춘 것인지가 안 보였다. 판단을 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
@@ -1,16 +1,58 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Windows;
|
||||
using SheetMe.Core.Layout;
|
||||
|
||||
namespace SheetMe.Designer.ViewModels;
|
||||
|
||||
/// <summary>오버레이 가이드선 1개 — 월드 좌표 수직/수평선</summary>
|
||||
/// <summary>
|
||||
/// 오버레이 가이드선 1개 — 월드 좌표 <b>선분</b>(양끝).
|
||||
///
|
||||
/// 예전에는 위치 하나만 들고 화면 끝에서 끝까지(±100000) 그었다. 그러면 "무엇에 맞춘 것인지"가
|
||||
/// 안 보인다 — 종이 밖 회색 배경까지 선이 뻗어 대비도 죽었다. 관련된 것들만 잇는다.
|
||||
/// </summary>
|
||||
public sealed class GuideLineInfo
|
||||
{
|
||||
/// <summary>수직선 여부(false=수평선)</summary>
|
||||
public bool IsVertical { get; init; }
|
||||
public double X1 { get; init; }
|
||||
public double Y1 { get; init; }
|
||||
public double X2 { get; init; }
|
||||
public double Y2 { get; init; }
|
||||
|
||||
/// <summary>선 위치(월드 X 또는 Y)</summary>
|
||||
public double Position { get; init; }
|
||||
/// <summary>수직선 여부 — 진단·테스트용 파생(뷰는 X1..Y2 만 쓴다)</summary>
|
||||
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>
|
||||
@@ -63,6 +105,7 @@ public sealed class SelectionOverlayViewModel : ViewModelBase
|
||||
private double marY;
|
||||
private double marW;
|
||||
private double marH;
|
||||
private double zoom = 1;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
@@ -105,6 +148,28 @@ public sealed class SelectionOverlayViewModel : ViewModelBase
|
||||
/// <summary>정렬 가이드선 목록</summary>
|
||||
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>
|
||||
public ObservableCollection<TabBadgeInfo> TabBadges { get; } = new();
|
||||
#endregion
|
||||
@@ -170,18 +235,115 @@ public sealed class SelectionOverlayViewModel : ViewModelBase
|
||||
MarH = rect.Value.Height;
|
||||
}
|
||||
|
||||
/// <summary>가이드선 교체</summary>
|
||||
public void SetGuides(double? guideX, double? guideY)
|
||||
/// <summary>가이드·간격 표시 지우기</summary>
|
||||
public void ClearGuides()
|
||||
{
|
||||
Guides.Clear();
|
||||
if (guideX is not null)
|
||||
if (Guides.Count > 0)
|
||||
{
|
||||
Guides.Add(new GuideLineInfo { IsVertical = true, Position = guideX.Value });
|
||||
Guides.Clear();
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user