Lulucat

지우개 두 가지, 그리고 지우기가 스트로크에 속하는 이유

Gaoge ZhangGaoge Zhang

지우개 스트로크를 독립 요소로 두는 것은 자연스러운 설계지만 객체 이동이 이를 깨뜨린다. Lulucat Notes에서는 잉크를 지우는 스트로크가 지우기를 소유한다.

업데이트일

노트에서 지울 때 사람들은 서로 다른 두 가지를 뜻한다. 어떤 때는 표시 자체가 잘못되어 안내선, 취소선이 그어진 단어, 손을 올려둔 데서 생긴 튀는 스트로크처럼 표시 전체를 없애야 한다. 어떤 때는 표시의 일부만 잘못되어 글자의 절반, 다이어그램의 한 갈래, 문장을 지나간 형광펜 쓸기의 꼬리만 지워야 한다. 이제 Lulucat Notes는 서로 다른 두 도구로 이 두 가지를 모두 제공한다.

더 어려운 설계 문제는 지우기를 어디에 둘 것이냐이다.

스트로크 지우개가 먼저 나왔다

첫 번째 지우개는 스트로크 전체를 삭제했다. 터치가 들어올 때마다 모든 스트로크를 검사한다. 바운딩 박스가 거의 전부를 걸러 내고, 남은 스트로크에는 지우개 반지름과 해당 선분을 따라 보간한 스트로크 반지름을 더한 값에 대한 점-선분 제곱 거리 테스트를 적용한다. 너비는 압력에 따라 달라지므로, 명목 너비 하나만 사용하면 사용자가 건드리지 않은 가는 스트로크를 삭제하고 건드린 굵은 스트로크를 놓치게 된다.

한 번의 제스처는 하나의 실행 취소 항목으로 합쳐지고, 이 항목에는 삭제된 각 스트로크와 원래 인덱스가 함께 저장된다. 실행 취소할 때는 이를 역순으로 다시 삽입하므로, 겹침 순서가 정확히 원래대로 돌아온다.

이 지우개는 빠르고 예측 가능하지만 단어의 절반만 지울 수는 없다.

MaLiang은 지우개를 브러시로 만든다

우리가 이미 형광펜 스탬프 모델을 가져온 MIT 라이선스 드로잉 프레임워크 MaLiang은 24줄 만에 부분 지우기를 해결한다. 그 EraserBrush의 하위 클래스로, 혼합 옵션이라는 한 가지를 재정의해 더하는 대신 빼도록 한다. 지우개 스트로크는 다른 모든 스트로크와 같은 목록에 있는 요소다. 실행 취소는 그 요소를 제거한다. 기존 스트로크는 수정되지 않는다. 지우기는 목록 순서에 따라 재생할 때만 일어나기 때문이다.

우리는 이 모델을 출시했고, 두 번의 기기 세션에서 모델이 깨졌다.

객체 이동이 빠진 소유자를 드러낸다

첫 번째 보고는 이랬다. 올가미로 스트로크를 선택해 끌어 옮기면, 구멍은 그 자리에 남는다.

두 가지 지우개 모델에서 스트로크를 옮기기 전후를 비교한 네 패널. 지우개가 독립 요소인 경우 빈틈이 원래 위치에 남고, 지우기를 스트로크가 소유하는 경우 빈틈이 잉크와 함께 이동한다.

같은 지워진 스트로크를 오른쪽으로 옮긴 모습. 위: 지우개가 독립 요소다. 아래: 지우기가 스트로크에 속한다.

선택하면 문제가 더 심해졌다. 선택한 스트로크는 비트맵에서 오버레이로 들어 올려져 지우개 요소 없이 직접 그려진다. 그래서 지워진 스트로크를 선택하는 순간 다시 온전한 모습으로 나타났다.

두 번째 보고는 지우개가 형광펜 위에 떠 있다는 것이었다. 우리 렌더러는 두 번의 패스로 재생한다. 형광펜을 먼저 그리고 펜을 나중에 그린다. 형광펜 잉크가 일반 잉크 아래에 있어야 하기 때문이다. 지우개 요소가 두 패스에 모두 참여하지 않으면 두 종류의 잉크를 모두 지울 수 없다. 펜 패스에 참여한다는 것은 모든 형광펜을 그린 뒤에 실행된다는 뜻이므로, 지우개가 시간상 나중에 그려진 형광펜 스트로크까지 잘라 내게 된다.

두 실패의 원인은 같다. MaLiang에는 올가미 선택도, 이동도, 브러시 사이의 레이어 순서도 없으므로 소유자가 없는 요소로 충분하다. 하지만 우리 캔버스에는 세 가지가 모두 있다. 소유자가 없는 지우기는 “잉크가 이동할 때 어떻게 되는가”와 “이 작업은 어느 레이어에서 수행되는가”라는 질문에 정의된 답이 없다.

스트로크가 지우기를 소유한다

이제 모든 Stroke가 자체 지우기 경로를 가진다. 지우개는 어떤 스트로크에 닿았는지 검사하고, 각 스트로크에 캔버스 좌표의 경로를 하나씩 추가한다. 스트로크 하나를 렌더링할 때는 투명도 레이어 안에서 잉크와 자체 지우기를 그린다:

context.beginTransparencyLayer(auxiliaryInfo: nil)
drawInk(stroke, in: context, clip: clip, colorOverride: colorOverride)
context.setBlendMode(.destinationOut)
for path in stroke.erasePaths { fillErasePath(path, in: context) }
context.endTransparencyLayer()

destination-out은 대상(destination)을 유지하고 소스 알파에 비례해 대상에서 제거한다.

투명도 레이어 안에서 대상은 해당 스트로크 하나이므로, 이 연산은 종이나 아래에 있는 스트로크, 나중에 그려지는 스트로크에 닿을 수 없다. 지우기 경로가 없는 스트로크에는 레이어 비용도 없다. 그런 스트로크가 거의 전부이므로, 이들은 원래 경로를 그대로 사용한다.

소유권은 두 실패에 직접 답한다. 이동할 때 샘플 지점과 함께 지우기 경로도 이동하므로, 이동한 스트로크가 빈틈을 함께 가져간다. 선택 오버레이도 같은 스트로크 객체를 그리므로, 선택한 스트로크에도 같은 빈틈이 보인다. 지우기는 자기 스트로크 외에는 아무것에도 적용되지 않으므로 레이어 순서는 더 이상 중요하지 않다.

실행 취소는 제스처 단위로 작동한다. 하나의 항목에는 해당 제스처가 만든 모든 (stroke, path) 쌍이 기록되고, 실행 취소는 그 경로들을 제거하며, 다시 실행은 이를 다시 추가한다. 지우기 아래의 잉크는 한 번도 수정되지 않으므로 무엇인가를 재구성할 필요가 없다.

실시간 피드백은 타일 비트맵을 거친다

그리는 중인 스트로크는 보통 합성된 페이지 위의 오버레이로 렌더링된다. 지우기는 그곳에서 작동할 수 없다. 오버레이가 종이와 잉크 모두의 위에 놓이므로, destination-out이 종이까지 뚫어 버리기 때문이다.

그래서 지우개는 즉시 스트로크에 기록하고, 새 샘플 묶음이 들어올 때마다 영향을 받는 사각형만 국소적으로 재생한다. 타일 파이프라인은 이미 사각형을 지우고 그 영역과 교차하는 스트로크를 재생한다. 지우개는 이 경로를 재사용하며 별도의 그리기 의미를 추가하지 않는다.

두 지우개를 나란히

도구 모음에는 숨겨진 모드가 있는 버튼 하나 대신 지우개 버튼이 두 개 있다. 부분 지우개는 스트로크의 일부를 없앤다. 스트로크 지우개는 닿은 스트로크 전체를 제거한다. 두 지우개의 반지름은 별도의 상수이며, 현재 둘 다 4포인트다.

스트로크 전체를 삭제하면 그 스트로크의 일부인 지우기 경로도 함께 사라지고, 실행 취소하면 둘이 함께 돌아온다. 지우기 경로도 저장 파일의 일부이며, 다른 모든 것과 마찬가지로 양자화된다. 반지름과 좌표는 0.1포인트 단위로 저장된다.

한 가지 제한은 남아 있다. 완전히 지워져 아무것도 남지 않은 스트로크도 데이터로는 여전히 존재한다. 화면에는 아무것도 렌더링되지 않지만, 올가미로 선택하거나 스트로크 지우개로 삭제할 수 있고 문서 안의 공간도 차지한다. 완전히 지워진 스트로크를 수집하려면 아직 작성하지 않은 커버리지 테스트가 필요하므로, 현재는 그대로 둔다.

다른 프로젝트의 모델이 내 프로젝트에서 작동하지 않기 시작할 때, 차이는 대개 복사한 코드에 있지 않다. 그 프로젝트에 없는 기능에 있다.