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

정렬 스냅 자체는 있었다. 다만 축마다 선 하나만, 그것도 화면 끝에서 끝까지 긋는 무한 점선이라
무엇에 맞춘 것인지가 안 보였다. 판단을 SheetMe.Core 로 옮기고 후보의 출처를 끝까지 들고 간다.

왜 옮겼나. 스냅은 눈으로만 확인되는 종류의 코드다 — 후보 하나, 열거 순서 하나가 바뀌면
붙는 자리가 조용히 달라지고 드래그해 보기 전엔 아무도 모른다. Core/Layout 의 SnapSolver 는
순수 기하이고 단위 테스트 27건이 승자 규칙·가이드 개수·선분 범위·간격 계산을 못 박는다.
SnapEngine 에는 "어떤 사각형이 후보인가"와 좌표 변환만 남았다.

달라진 것들:

· 동시에 맞는 정렬은 전부 보인다. 폭이 같은 형제 옆에 대면 좌·중·우 세 줄이 함께 뜬다.
  보정량은 승자 하나만 쓰되, 그 보정을 적용했을 때 실제로 맞는 후보만 선으로 낸다 —
  |거리|만 같고 부호가 반대인 후보까지 그리면 '붙었다고 그려 놓고 안 붙은' 선이 된다.

· 선이 선분이 됐다. 형제 가이드는 관련된 것들의 합집합 범위까지만, 용지 가이드는 그 용지 전체.
  같은 좌표의 형제 여럿은 한 선으로 합치고 범위만 넓힌다.
  덤으로 대비 문제도 풀렸다 — 예전 분홍(#FF4FA3)은 종이 밖 회색 배경 위에서 2.1:1 로 죽었다.

· 허용 오차가 화면 기준이 됐다. 월드 고정이면 줌 50%에서 두 배로 끈적이고 200%에서 절반으로
  미끄러진다 — 손끝 감각은 화면 거리로 정해진다. 핸들 히트 반경도 같은 병이라 함께 고쳤다
  (줌 300%에서 그림은 8px 인데 판정이 18px 였다). 선 두께도 화면 1px 로 고정한다.

· 승자가 결정적이 됐다. 예전에는 컨트롤 열거 순서가 동률을 갈랐다.
  이제 |거리| → 종류(형제 모서리 > 형제 중심 > 용지 경계 > 용지 중앙) → 좌표 → 모서리 순이다.

· 간격 균등. 형제들이 이미 같은 간격으로 늘어서 있으면 그 리듬에 끼거나 이어 붙는 자리에 붙고,
  같은 값이 된 빈틈 전부에 막대와 수치 칩을 놓는다. 양옆만 재면 "30"이 한 번 뜰 뿐이라
  무엇과 같아졌는지 안 보인다. 같은 축에서 정렬과 동시에 사정권이면 정렬이 이긴다 —
  거리로 겨루게 하면 한 픽셀 차이로 표시가 선↔막대로 튀어 종잡을 수 없다.

· 후보 범위. 컨테이너 자식도 이제 후보다(히트테스트는 재귀하는데 스냅만 최상위를 보던 규칙 불일치).
  대신 끌려가는 컨테이너의 자식은 빼야 한다 — 선택 목록엔 없지만 함께 움직이므로
  빼지 않으면 컨테이너가 제 자식에게 붙어 그 자리에서 굳는다.

· 리사이즈. 후보를 이동 드래그에서만 모으고 있어서, 앱을 켜자마자 핸들을 잡으면 스냅이 없고
  이동을 한 번 한 뒤에는 그때의 낡은 좌표에 붙었다. 리사이즈 시작에도 모으고 놓을 때 버린다.
  모서리 스냅이 '첫 매치'였던 것도 이동과 같은 최근접 규칙으로 통일했다. 가이드도 함께 보인다.

· 정합된 축은 반올림하지 않는다. 형제 폭이 홀수면 중심이 반정수라, 반올림하면 중앙에 맞췄는데
  0.5px 어긋난 채로 놓인다. 무한선일 땐 안 보였지만 선분이 되면 모서리에서 뜬 게 눈에 띈다.
  저장은 LegacyFormat.FormatPair 가 어차피 정수로 쓰므로 포맷은 영향 없다.

성능 가드: 한 줄에 겹치는 형제가 64개 넘으면 간격 계산을 건너뛰고(정렬은 정상),
형제가 2000개 넘으면 간격을 끈다. 후보 정렬은 드래그 시작에 1회, 프레임 경로는 무할당.
가이드 내용이 이전과 같으면 컬렉션을 건드리지 않는다.

신규 진단 --snap-shots — 종이와 오버레이를 겹쳐 드래그 도중을 찍는다.
기존 --render-smoke/--db-render 는 종이만 찍어 오버레이가 안 나왔다. 선이 어디서 어디까지
그어지는지, 몇 개가 동시에 보이는지는 그림으로만 확인된다. 게이트는 함께 남기는 _report.txt 의
수치로 걸고 PNG 는 눈 검증 보조다(픽셀 비교는 테마·글꼴에 흔들려 게이트로 못 쓴다).

게이트: 테스트 257/257(신규 27), --edit-smoke 223건 0실패(스냅 검사 14건 신규),
--query-popup 17/17, --maxrect 10/10, --db-smoke 3000 diff 0·예외 0,
--db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일, --snap-shots 7종 0실패.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-14 09:23:42 +09:00
co-authored by Claude Opus 5
parent 8a7cc65099
commit ba838ae54a
13 changed files with 1706 additions and 151 deletions
@@ -4,6 +4,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:SheetMe.Designer.ViewModels"
xmlns:v="clr-namespace:SheetMe.Designer.Views"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance vm:SelectionOverlayViewModel}"
IsHitTestVisible="False">
@@ -11,39 +12,6 @@
<!-- 선택/마퀴/가이드 표시 전용 오버레이 — 입력은 월드 파이프라인이 처리(기하 히트테스트) -->
<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}"
Width="{Binding SelW}" Height="{Binding SelH}"
@@ -110,5 +78,76 @@
</DataTemplate>
</ItemsControl.ItemTemplate>
</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>
</UserControl>