snapping과 smart guides
이번에는 snapping과 smart guides입니다.
오브젝트를 옮기다가 다른 오브젝트의 왼쪽 선, 가운데 선, 간격에 착 붙는 그 느낌 말입니다. 잘 만들면 “오, 똑똑한데?” 싶고, 잘못 만들면 자꾸 엉뚱한 데 달라붙어서 짜증이 납니다.
스냅은 결국 후보선과 현재선 사이의 거리 비교입니다.
노란 도형을 잡고 파란 도형 가까이 끌어보세요. 어느 순간 빨간 guide가 나타나고, 노란 도형의 left, center, right, top, middle, bottom 중 하나가 파란 도형의 기준선에 착 붙습니다. 편집기가 “이쯤이면 맞춰주면 좋겠군” 하고 끼어드는 순간입니다. 너무 멀리서 끼어들면 참견쟁이고, 너무 늦게 끼어들면 눈치가 없는 녀석이 됩니다.
후보선을 만든다
오브젝트마다 기준선을 만들 수 있습니다.
- left
- center
- right
- top
- middle
- bottom
움직이는 오브젝트의 기준선과 주변 후보선의 거리를 비교합니다.
distance = candidate - movingEdge
if abs(distance) < thresholdPx then snap
correctedPosition = position + distance
지금 데모는 canvas 안의 screen 좌표에서 바로 계산하므로 distance가 곧 픽셀 거리입니다. infinite canvas처럼 world 좌표와 zoom이 있는 편집기라면 한 단계가 더 붙습니다.
worldDistance = candidateWorld - movingEdgeWorld
screenDistance = worldDistance * zoom
if abs(screenDistance) < thresholdPx then snap
correctedWorldPosition = worldPosition + worldDistance
사용자는 world 단위가 아니라 화면 픽셀 감도로 느낍니다. zoom in/out을 해도 “몇 픽셀 가까워졌을 때 붙는가”가 비슷해야 자연스럽습니다.
드래그 중에는 두 위치가 있다
스냅을 만들 때 자주 헷갈리는 부분이 있습니다. 포인터가 만든 원래 위치와 실제로 적용할 위치를 분리해야 합니다.
rawPosition = pointer - dragOffset
snapDeltaX = bestCandidateX - movingEdgeX
snapDeltaY = bestCandidateY - movingEdgeY
appliedPosition = rawPosition + (snapDeltaX, snapDeltaY)
rawPosition은 마우스가 말하는 위치입니다. appliedPosition은 편집기가 판단해서 화면에 그리는 위치입니다. 이 둘을 섞어버리면 드래그가 끈적하게 느껴지거나, 스냅 근처에서 도형이 미세하게 떨립니다. 교수님이 칠판에 자를 대고 선을 긋는다고 해서 손의 위치가 사라지는 건 아니죠. 손 위치와 자가 만든 선 위치를 따로 봐야 합니다.
x축과 y축은 따로 판단한다
보통 smart guide는 x축 후보와 y축 후보를 따로 고릅니다.
x candidates: left, center, right
y candidates: top, middle, bottom
bestX = shortest distance within threshold
bestY = shortest distance within threshold
이렇게 하면 세로 guide만 뜨는 상황, 가로 guide만 뜨는 상황, 둘 다 뜨면서 모서리처럼 붙는 상황을 자연스럽게 처리할 수 있습니다. “가장 가까운 후보 하나만 전역으로 고른다”로 만들면 x축 스냅 때문에 y축 스냅이 밀리거나, 반대로 y축이 x축을 방해하는 이상한 일이 생깁니다.
guide는 판단 과정을 보여준다
smart guide는 단순한 장식이 아닙니다. 스냅이 왜 일어났는지 사용자에게 설명하는 시각화입니다.
너무 많은 guide를 한 번에 보여주면 화면이 시끄러워집니다. 보통 가장 가까운 후보 하나, 또는 x축/y축별 후보 하나만 선택합니다.
정렬 가이드, 간격 가이드, ruler guide, grid snap은 모두 후보 생성 방식만 다릅니다. 구조는 비슷합니다.
generate candidates
measure distance
choose best
apply correction
show guide
이 다섯 줄을 실제 편집기 알고리즘으로 풀면 이렇게 됩니다.
1. generate candidates
먼저 움직이지 않는 주변 오브젝트에서 “붙을 수 있는 선”을 뽑습니다. 여기서 후보는 선 하나를 뜻합니다. 사각형 하나가 있으면 x축 후보 3개, y축 후보 3개가 나옵니다.
target rect = { x, y, width, height }
x candidates
- left = x
- center = x + width / 2
- right = x + width
y candidates
- top = y
- middle = y + height / 2
- bottom = y + height
후보는 값만 있으면 부족합니다. 나중에 guide를 그려야 하니까 “어느 오브젝트의 어떤 선인지”도 같이 들고 다녀야 합니다.
candidate = {
axis: "x",
name: "center",
value: 184,
target: targetObject
}
이 단계는 편집기가 칠판에 보이지 않는 자를 잔뜩 올려두는 단계입니다. 아직 스냅은 안 합니다. “혹시 붙을 만한 선들이 뭐가 있지?” 하고 후보 명단만 만드는 겁니다.
2. measure distance
그다음 움직이는 오브젝트에서도 같은 기준선을 뽑습니다. 단, 움직이는 오브젝트는 현재 포인터가 만든 rawPosition 기준으로 계산해야 합니다.
moving left = rawX
moving center = rawX + movingWidth / 2
moving right = rawX + movingWidth
이제 후보선과 움직이는 선을 모두 비교합니다.
delta = candidate.value - movingEdge.value
distance = abs(delta)
예를 들어 움직이는 도형의 right가 246px이고, 타깃 도형의 left가 250px이면:
delta = 250 - 246 = 4
distance = 4
threshold가 10px라면 이 후보는 스냅 가능입니다. delta가 양수라는 건 움직이는 도형을 오른쪽으로 4px 밀면 붙는다는 뜻입니다. 반대로 delta = -6이면 왼쪽으로 6px 당기면 됩니다. 오, 여기서 부호가 은근 중요합니다. abs(distance)만 기억하고 부호를 버리면 “얼마나 가까운지”는 알아도 “어느 방향으로 고쳐야 하는지”를 잃어버립니다.
3. choose best
스냅 가능한 후보가 여러 개일 수 있습니다. 이때 보통은 x축에서 하나, y축에서 하나를 따로 고릅니다.
bestX = null
for each xCandidate:
for each movingXEdge:
delta = xCandidate.value - movingXEdge.value
distance = abs(delta)
if distance <= threshold:
if bestX is null or distance < bestX.distance:
bestX = { candidate, movingEdge, delta, distance }
y축도 같은 방식으로 bestY를 구합니다.
bestY = shortest y snap within threshold
왜 축별로 따로 고를까요? 도형을 끌다 보면 x축은 가운데 정렬에 가까운데 y축은 위쪽 정렬에 가까울 수 있습니다. 이때 x 후보와 y 후보가 서로 싸우면 안 됩니다. x는 x대로, y는 y대로 “이번 축에서는 네가 제일 가깝구나” 하고 뽑아야 합니다. 그래야 세로 guide와 가로 guide가 독립적으로 뜹니다.
4. apply correction
선택된 후보가 있으면 raw position에 보정값을 더합니다.
appliedX = rawX + bestX.delta
appliedY = rawY + bestY.delta
후보가 없으면 보정하지 않습니다.
appliedX = rawX
appliedY = rawY
중요한 점은 오브젝트의 모델 좌표에는 appliedPosition을 넣고, readout이나 디버깅에는 rawPosition도 남겨둔다는 겁니다.
rawPosition = pointer - dragOffset
appliedPosition = rawPosition + snapDelta
model.position = appliedPosition
rawPosition을 잃어버리면 포인터가 실제로 어디를 가리키는지 추적하기 어렵습니다. 스냅이 튀거나 덜컥거릴 때 디버깅하기가 아주 고약해집니다. 칠판 앞에서 “방금 내 손이 어디 있었더라?” 하는 상황이 되는 거죠.
5. show guide
guide는 bestX, bestY가 있을 때만 그립니다. 세로 guide는 x축 후보가 선택됐다는 뜻이고, 가로 guide는 y축 후보가 선택됐다는 뜻입니다.
if bestX exists:
draw vertical guide at bestX.candidate.value
if bestY exists:
draw horizontal guide at bestY.candidate.value
guide의 길이는 보통 움직이는 오브젝트와 타깃 오브젝트를 모두 덮을 만큼만 잡습니다.
verticalGuide.x = candidateX
verticalGuide.top = min(moving.top, target.top) - padding
verticalGuide.bottom = max(moving.bottom, target.bottom) + padding
이렇게 하면 guide가 화면 전체를 가르는 긴 빨간 막대가 아니라, “이 두 오브젝트가 지금 이 선으로 관계를 맺고 있어요”라고 말하는 선이 됩니다. smart guide라는 이름값은 여기서 나옵니다. 그냥 선을 보여주는 게 아니라, 편집기의 판단 근거를 보여주는 겁니다.
데모에서 볼 것
데모에서는 노란 도형을 직접 드래그합니다. readout의 raw pointer result는 포인터가 만든 원래 위치이고, applied position은 스냅 보정이 들어간 실제 위치입니다. 빨간 세로선은 x축 후보가 선택됐다는 뜻이고, 빨간 가로선은 y축 후보가 선택됐다는 뜻입니다.
오늘의 핵심은 snapping이 “가까우면 붙인다”가 아니라, 후보 생성과 거리 비교, 보정, guide 표시가 합쳐진 편집 의사결정이라는 점입니다.