CSS Graphics Geometry
Guide
Reference
GitHub
Guide
Reference
GitHub
  • Part 0. Math foundations

    • 좌표계와 CSS pixel
    • 점, 벡터, 거리와 기본 용어
    • 각도, 라디안, atan2
  • Part 1. Transform linear algebra

    • translate, scale, rotate
    • CSS matrix(a,b,c,d,e,f)
    • 변환 순서와 transform-origin
    • local, world, screen 좌표계
    • inverse matrix와 포인터 입력
    • 좌표와 transform 디버깅
  • Part 2. Infinite canvas math and architecture

    • viewport와 camera 모델
    • cursor anchored zoom
    • ruler와 tick 계산
    • 주기 함수와 grid step
    • content, overlay, controls layer
    • scene graph와 nested transform
  • Part 3. Editor tool math

    • Pointer Events와 드래그
    • DOMRect와 getBoundingClientRect
    • hit testing과 bounding box
    • selection rectangle과 marquee
    • selection bounds와 handles
    • resize와 rotation handle
    • snapping과 smart guides
    • group, frame, clipping
  • Part 4. CSS graphics properties as math

    • CSS box로 도형 만들기
    • clip-path, radius, shadow
    • 선형보간과 linear-gradient
    • 거리 함수와 radial/conic-gradient
    • stacking context와 합성
    • layout, paint, composite
  • Part 5. Editor state and persistence

    • 레이어 모델과 z-index
    • 이동, 복제, 삭제, 잠금
    • undo/redo command model
    • JSON export/import
  • Part 6. SVG overlay and vector editing

    • SVG viewBox와 좌표계
    • getScreenCTM과 inverse
    • SVG pointer-events와 stroke hit testing
    • cubic bezier path editing
    • mask, clipPath, marker
  • Part 7. Figma to CSS translation

    • Figma node와 CSS DOM 모델
    • Frame constraints와 Auto Layout
    • Fills, strokes, effects를 CSS로
    • Vector와 text 변환
    • gradient 밖의 CSS 수학
    • Figma gradient를 CSS gradient로 변환하기
    • 수학이 필요한 Figma to CSS 속성들
    • Figma VectorNetwork 정리
  • Appendix A. Canvas renderer transition

    • Canvas로 넘어가는 기준
  • Appendix B. Motion and timing

    • 모션 수학과 timing 함수
    • Keyframe timeline 엔진
    • Motion path 수학
    • Bezier curve 길이 구하기

group, frame, clipping

이번에는 group, frame, clipping입니다.

그룹과 프레임은 둘 다 자식을 담습니다. 그런데 프레임은 조금 더 까다롭습니다. 좌표계를 제공할 뿐 아니라 보이는 영역까지 제한할 수 있습니다.

Figma의 frame을 떠올리면 됩니다. 자식이 frame 밖으로 나가면 안 보이게 할 수 있고, frame 자체가 layout container처럼 동작할 수도 있습니다.

데모의 왼쪽은 group, 오른쪽은 frame입니다. group, group 내부 child, frame, frame 밖/안 child를 직접 클릭하면 selection outline과 resize handle이 붙습니다. group은 묶음 bounds와 내부 child geometry를 함께 조작하고, frame은 clipping/container bounds를 조작하고, child는 자기 geometry를 조작합니다. 이제 group 안의 local A, local B도 직접 끌어서 group, frame, root 사이를 오갈 수 있습니다. 셋 다 비슷한 핸들을 쓰지만 바뀌는 모델 값은 다릅니다. 여기서 편집기 맛이 나기 시작합니다.

group은 부모 좌표계다

group은 자식에게 부모 transform을 제공합니다.

childWorld = groupWorld * childLocal
group parent matrix and child local matrix diagramworldgroupWorldchildLocalchildWorld= groupWorld* childLocallocal은 보존
group은 자식의 좌표를 직접 덮어쓰는 상자가 아니라, 자식 local matrix 앞에 곱해지는 부모 world matrix입니다.

그룹을 움직이면 자식의 world 위치는 바뀌지만, 자식의 local 좌표는 유지됩니다. 그래서 그룹 이동은 parent transform을 바꾸는 동작입니다.

데모에서 group 위쪽의 동그란 rotation handle을 드래그하면 group의 회전만 바뀝니다. 자식 A/B의 local transform은 그대로인데, 화면에서 보이는 world 위치와 방향은 같이 바뀝니다.

groupWorld changes
childLocal stays
childWorld changes

이게 group의 핵심입니다. group은 “폴더”라기보다 자식들이 공유하는 좌표계입니다. 폴더처럼 생각하면 transform 전파를 놓치기 쉽습니다. 편집기에서는 이 차이가 꽤 큽니다.

rotation handle의 수식은 앞에서 본 selection handle과 같습니다. group의 중심을 기준으로 시작 포인터 각도와 현재 포인터 각도의 차이를 구합니다.

startAngle = atan2(pointerStart - groupCenter)
currentAngle = atan2(pointerCurrent - groupCenter)
groupRotate = startRotate + (currentAngle - startAngle)

여기서 바뀌는 값은 child가 아니라 group의 parent matrix입니다. 그래서 child A/B는 자기 local 좌표를 지키면서도 화면에서는 같이 빙글 돌아갑니다. 제법 얌전한 애들입니다.

group을 드래그하면 groupWorld의 translation이 바뀝니다. group의 corner handle을 잡아 resize하면 group bounds가 바뀌고, group 내부 child들의 local geometry도 같은 비율로 다시 계산됩니다.

groupWorld = T(center) * R(rotation) * T(-size/2)
group resize local geometry bake diagrambeforeparent = groupscaleX, scaleYafter: geometry rewritex/y/width/height가 같이 변한다parent가 frame이면 group resize 대상이 아니다
group resize를 부모 scale로 남기지 않으면, resize 시작 시점에 parent가 group인 child의 local geometry를 다시 써야 합니다.

여기서 일부러 S(scaleX, scaleY)를 group matrix에 남기지 않습니다. group 내부 child를 계속 편집할 수 있게 하려면, 자식들이 자기 x/y/width/height를 가진 상태로 남아야 하기 때문입니다.

텍스트, border, radius, shadow가 전부 부모 scale에 같이 눌립니다. DOM에서는 특히 transform: scale(...)이 걸린 것처럼 보이기 때문에 child가 자기 원래 geometry를 잃어버린 것처럼 느껴집니다. “아니 내가 박스를 키웠는데 글자까지 납작해졌네?” 하는 그 순간입니다. 별로 반갑지 않습니다.

그래서 DOM 기반 데모에서는 group resize 중에도 child local geometry를 바로 다시 씁니다.

scaleX = nextGroupWidth / startGroupWidth
scaleY = nextGroupHeight / startGroupHeight

for each child whose parent is group:
  child.x = startChild.x * scaleX
  child.y = startChild.y * scaleY
  child.width = startChild.width * scaleX
  child.height = startChild.height * scaleY

여기서 중요한 단어는 whose parent is group입니다. local A를 frame으로 옮겨둔 상태라면 group resize가 더는 A를 건드리면 안 됩니다. A는 이제 group의 자식이 아니라 frame의 자식이니까요. 이름표는 아직 local A여도, 수학적으로는 새 부모의 좌표계를 따릅니다. 편집기에서 이름보다 parent id가 더 센 녀석입니다.

반대로 green child를 frame에서 group으로 옮겨왔다면 이제 그 child도 group의 자식입니다. 그러면 group resize의 영향을 받아야 합니다. 이때도 원리는 같습니다. group local 좌표 안에서 child의 x/y/width/height를 같이 다시 계산합니다.

if child.parent is group:
  child.x = startChild.x * scaleX
  child.y = startChild.y * scaleY
  child.width = startChild.width * scaleX
  child.height = startChild.height * scaleY

그러니까 group resize의 대상은 “처음부터 group 안에 있던 애들”이 아니라 “resize 시작 시점에 parent가 group인 모든 child”입니다. 방금 전입 신고한 child도 예외 없습니다.

이렇게 하면 드래그 중에도 parent scale()로 눌리는 느낌이 줄어듭니다. child A/B를 직접 클릭해서 selection handle로 조정할 수도 있습니다. group 내부 child를 선택하면 overlay는 화면 좌표에 그려지지만, 저장되는 값은 현재 parent local 좌표입니다.

groupChildWorld = parentWorld * groupChildLocal

drag group child:
  desiredWorld = T(pointerDelta) * currentGroupChildWorld
  nextLocal = inverse(parentWorld) * desiredWorld

즉, group 내부 child도 frame 안의 child와 같은 원리입니다. 화면에서 조작하고, 마지막에 parent local로 되돌립니다. 부모가 frame이면 inverse(frameWorld)를 쓰고, 부모가 group이면 inverse(groupWorld)를 씁니다. root로 나와 있으면 parent matrix는 identity입니다. 수식이 은근 성실합니다. 같은 일을 계속 합니다.

Canvas/WebGL 기반 에디터라면 다른 선택도 가능합니다. 드래그 중에는 parent scale로 빠르게 preview하고, 포인터를 놓는 순간 child geometry에 bake할 수 있습니다.

preview:
  renderedChild = groupWorldWithScale * childLocal

commit:
  childLocalGeometry = scaled childLocalGeometry
  groupWorld = T * R

DOM에서는 preview scale도 바로 화면에 드러나서 child가 찌그러져 보이기 쉽습니다. 그래서 이번 데모는 live bake 쪽을 택했습니다. 실제 제품에서는 성능, 대상 타입, 텍스트 정책에 따라 preview와 commit 전략을 섞을 수 있습니다.

단, 여기에도 정책이 있습니다. child가 단순 rectangle이면 width/height를 바꾸면 됩니다. vector path라면 path point를 scale할 수 있습니다. 텍스트라면 font size를 키울지, text box만 키울지 결정해야 합니다. stroke와 radius도 같이 scale할지 유지할지 정책이 필요합니다. 그래서 group resize는 겉보기보다 까다롭습니다. 편집기 개발자를 은근히 시험하는 녀석입니다.

DOM과 Canvas/WebGL의 차이도 여기서 나옵니다. DOM에서는 부모 transform: scale()이 실제 DOM subtree 전체를 눌러 보이게 합니다. Canvas/WebGL에서는 렌더링할 때 matrix를 곱해 그리므로 preview scale을 쓰기 쉽습니다. 하지만 둘 다 저장 모델에서는 같은 질문을 피할 수 없습니다.

Do we keep parent scale?
Or do we bake scale into children?

디자인 에디터라면 보통 후자가 더 관리하기 쉽습니다. selection handle, hit test, radius, text editing을 계속 child 모델 기준으로 계산할 수 있기 때문입니다.

frame은 clipping 경계도 가진다

frame은 group처럼 좌표계를 제공하면서, 동시에 보이는 영역을 제한할 수 있습니다.

clip test = point inside frame local bounds

CSS에서는 overflow: hidden이나 clip-path로 구현할 수 있습니다. DOM 기반 에디터에서는 frame DOM을 clipping container로 쓰는 방식이 자연스럽습니다.

데모에서 Clipping: on/off를 눌러보면 같은 child라도 frame 밖으로 나간 부분이 보이거나 사라집니다.

.frame {
  overflow: hidden;
}
frame clipping local bounds diagramframe local boundsoverflow: hidden이면 점선 부분은 잘린다좌표는 그대로child local matrix는 바뀌지 않는다
frame의 clipping은 child 좌표를 바꾸지 않습니다. frame local bounds 밖의 픽셀만 렌더링에서 잘라낼 뿐입니다.

여기서 중요한 건 clipping이 자식의 좌표를 바꾸지 않는다는 점입니다. child의 local 좌표는 그대로인데, frame이 “내 bounds 밖은 안 보여줄게”라고 렌더링만 제한합니다. 좌표계 문제와 visibility 문제를 섞으면 안 됩니다. 섞는 순간 편집기 상태가 어질어질해집니다.

frame을 드래그하면 frame의 world 위치가 바뀝니다. frame의 corner handle을 잡아 resize하면 frame의 width/height가 바뀌고 clipping 영역이 커지거나 작아집니다.

frameWorld = T(frameX, frameY) * R(frameRotation)
frameBounds = { width, height }

group resize처럼 자식을 scale하지 않습니다. frame은 “보이는 창”에 가깝습니다. 창 크기를 바꿨다고 창 안의 물건이 같이 커지지는 않죠. 물론 실제 디자인 툴에서는 constraints나 auto layout이 끼어들 수 있지만, 기본 수학 모델은 이렇게 분리해두는 편이 훨씬 덜 헷갈립니다.

parent 사이로 옮기기

오브젝트를 frame 안으로 드롭하거나 group 안으로 옮길 때 화면 위치를 유지하려면 world 좌표를 새 parent 기준 local 좌표로 바꿔야 합니다.

childLocal = inverse(nextParentWorld) * childWorld

반대로 frame 밖으로 빼내거나 group에서 frame으로 옮길 때도 world 위치를 보존해야 합니다. 사용자가 계층을 바꿨을 뿐인데 화면 위치까지 바뀌면 꽤 당황스럽습니다.

데모의 green child는 직접 드래그해서 parent를 바꿀 수 있습니다. group 안의 local A, local B도 같은 방식으로 움직입니다. child를 group이나 frame 위로 끌고 가서 놓으면 child의 중심점이 어느 parent bounds 안에 있는지 검사하고, 그 parent로 reparent합니다. To group, To frame, To root 버튼은 green child에 같은 계산을 명시적으로 실행하는 보조 장치입니다.

currentWorld = oldParentWorld * oldLocal
nextLocal = inverse(nextParentWorld) * currentWorld
reparent preserves world transform diagramold parent: groupoldLocalnext parent: framenextLocalworld 위치는 그대로nextLocal = inverse(nextParentWorld) * oldWorld
reparent는 DOM을 옮기기 전에 old world를 보존하고, 새 parent의 inverse를 곱해 next local을 계산하는 문제입니다.

root parent의 world matrix는 identity이므로 root로 빼낼 때는 child의 local matrix가 곧 world matrix가 됩니다.

nextLocal = inverse(rootWorld) * currentWorld
          = currentWorld

group으로 넣을 때는 inverse(groupWorld)를 쓰고, frame으로 넣을 때는 inverse(frameWorld)를 씁니다. 그래서 group에서 frame으로, frame에서 group으로 옮겨도 child가 화면에서 순간이동하지 않습니다. 계층은 바뀌었지만 world transform은 보존했기 때문입니다. 이게 Figma 같은 편집기에서 “frame 안에 넣기”, “group 안으로 넣기”, “밖으로 빼기”를 자연스럽게 만드는 기본기입니다.

local A, local B도 여기서는 특별 대우를 받지 않습니다. 원래 group 안에 있었을 뿐, reparent 순간에는 그냥 “현재 world transform을 가진 오브젝트”입니다.

localAWorld = oldParentWorld * localAMatrix
localA.parent = nextParent
localAMatrix = inverse(nextParentWorld) * localAWorld

이렇게 하면 A를 frame 안에 넣어도 화면 위치와 회전은 그대로이고, readout의 parent만 frame으로 바뀝니다. 다시 group으로 끌어 넣으면 같은 계산을 반대로 합니다. 수식이 착실하게 이삿짐을 옮겨주는 셈입니다.

드래그 drop 판단은 보통 이런 식입니다.

childCenterWorld = childWorld * childLocalCenter

if inverse(frameWorld) * childCenterWorld is inside frame bounds:
  nextParent = frame
else if inverse(groupWorld) * childCenterWorld is inside group bounds:
  nextParent = group
else:
  nextParent = root

여기서도 hit testing과 inverse matrix가 다시 나옵니다. 오늘 수식들 아주 재출연이 잦습니다. 그래도 이 반복 덕분에 편집기 기능들이 하나의 원리로 묶입니다.

child는 버튼으로 움직이는 대신 직접 드래그할 수 있습니다. green child뿐 아니라 local A, local B도 직접 드래그할 수 있습니다. 이때도 핵심은 world에서 먼저 생각하고, 마지막에 parent 기준 local로 되돌리는 것입니다.

desiredChildWorld = T(pointerDelta) * currentChildWorld
nextChildLocal = inverse(parentWorld) * desiredChildWorld

child가 root에 있을 때는 parentWorld가 identity라서 거의 그대로 움직입니다. child가 frame 안에 있을 때는 frame이 회전되어 있으므로, 같은 화면 이동도 frame local 좌표에서는 살짝 다른 숫자가 됩니다. readout의 childLocal, local A, local B를 보면 이 차이가 바로 보입니다. “아니 왜 x만 움직였는데 y도 바뀌어?” 싶다면, 그게 바로 회전된 부모 좌표계의 존재감입니다.

child의 corner handle을 잡아 resize할 때도 비슷합니다. 사용자가 보는 화면에서는 OBB 핸들을 잡아 늘리는 동작이지만, child가 frame 안에 있다면 최종 저장 값은 frame local 좌표계로 다시 변환되어야 합니다.

desiredChildWorld = resize child in screen/world space
nextChildLocal = inverse(parentWorld) * desiredChildWorld

그래서 같은 resize라도 root child와 frame child의 local matrix가 다르게 보입니다. 편집기 내부에서는 “사용자가 화면에서 한 조작”과 “모델에 저장할 parent-local 값”이 늘 한 번 변환을 거칩니다.

DOM으로는 어떻게 만들까

DOM 기반 에디터에서는 frame을 실제 DOM parent로 쓰는 방식이 이해하기 쉽습니다.

<div class="canvas">
  <div class="frame">
    <div class="child"></div>
  </div>
</div>

이때 frame DOM에는 frame world transform을 주고, child DOM에는 frame 기준 local transform을 줍니다.

.frame {
  transform: matrix(...frameWorld);
  overflow: hidden;
}

.child {
  transform: matrix(...childLocal);
}

브라우저의 실제 렌더링도 결국 비슷하게 합성됩니다.

rendered child transform = parent transform * child transform

그러니까 우리가 수학에서 쓴 childWorld = parentWorld * childLocal과 DOM 렌더링의 계층 transform이 같은 그림을 보고 있는 셈입니다. 수식이 갑자기 친한 척을 하기 시작하죠.

reparent할 때는 DOM도 실제로 옮깁니다.

if nextParent is frame:
  frameElement.append(childElement)
else if nextParent is group:
  groupElement.append(childElement)
else:
  canvasElement.append(childElement)

다만 DOM을 먼저 옮기면 화면이 튈 수 있으니, 모델에서는 항상 world 보존 -> next local 계산 -> DOM parent 변경 -> local transform 적용 순서로 생각하는 편이 안전합니다.

데모에서 볼 것

데모에서는 세 가지를 봅니다.

  • group move/resize/rotate: group의 부모 transform이 자식 world transform을 바꿉니다. resize는 parent scale을 남기지 않고, resize 시작 시점에 group에 속한 모든 child local geometry를 다시 계산합니다.
  • group child move/resize: local A, local B도 selection handle로 조작하고, 결과는 inverse(parentWorld)로 현재 parent local 값에 저장합니다.
  • frame move/resize: frame의 위치와 clipping bounds를 바꿉니다. 자식을 scale하지 않습니다.
  • child move/resize: 화면에서 조작한 world transform을 현재 parent 기준 local matrix로 다시 계산합니다.
  • green child, local A, local B drag/drop: parent를 바꿔도 world 위치를 유지하려면 local matrix를 다시 계산해야 합니다.
  • To group / To frame / To root: green child용 버튼입니다. 같은 reparent 공식을 눈에 보이게 실행하는 장치입니다.
  • Clipping: on/off: clipping은 좌표를 바꾸는 기능이 아니라 frame bounds 밖의 렌더링을 제한하는 기능입니다.

오늘의 핵심은 group과 frame을 단순한 폴더가 아니라 좌표계와 clipping 규칙을 가진 컨테이너로 보는 것입니다.

Edit this page
최근 수정: 26. 5. 14. PM 5:45
Contributors: easylogic
Prev
snapping과 smart guides