Dwie gumki i dlaczego wymazywanie należy do pociągnięcia
Pociągnięcie gumki jako osobny element wydaje się oczywistym rozwiązaniem. Przenoszenie obiektów je łamie, więc w Lulucat Notes wymazywanie należy do pociągnięcia, z którego usuwa tusz.
Kiedy ludzie wymazują coś w notesie, mają na myśli dwie różne rzeczy. Czasem błędny jest cały ślad i cały powinien zniknąć: linia pomocnicza, przekreślone słowo, przypadkowe pociągnięcie od opartej dłoni. Czasem błędna jest tylko część śladu: połowa znaku, jedna gałąź diagramu, ogon pociągnięcia zakreślacza, które wyszło poza zdanie. Lulucat Notes oferuje teraz oba warianty jako dwa osobne narzędzia.
Trudniejsze pytanie projektowe brzmi: gdzie należy wymazywanie.
Najpierw powstała gumka wymazująca pociągnięcia
Pierwsza gumka usuwała całe pociągnięcia. Każde dotknięcie sprawdza każde pociągnięcie: prostokąty ograniczające odrzucają niemal wszystkie, a dla pozostałych wykonywany jest test kwadratu odległości punkt–odcinek względem sumy promienia gumki i promienia pociągnięcia interpolowanego wzdłuż tego odcinka. Szerokość zmienia się wraz z naciskiem, więc pojedyncza nominalna szerokość usuwałaby cienkie pociągnięcia, których użytkownik nie dotknął, i pomijała grube pociągnięcia, których dotknął.
Jeden gest trafia do jednego wpisu cofania, a wpis przechowuje każde usunięte pociągnięcie wraz z jego pierwotnym indeksem. Cofnięcie wstawia je z powrotem w odwrotnej kolejności, więc kolejność nakładania wraca dokładnie do poprzedniego stanu.
Ta gumka jest szybka, przewidywalna i nie potrafi usunąć połowy słowa.
MaLiang zmienia gumkę w pędzel
MaLiang, objęty licencją MIT framework do rysowania, z którego wcześniej zapożyczyliśmy model stempli zakreślacza, rozwiązuje częściowe wymazywanie w 24 wierszach. Jego Eraser jest podklasą Brush, która nadpisuje jedną rzecz — opcje mieszania — aby odejmować zamiast dodawać. Pociągnięcie gumki jest elementem na tej samej liście co wszystkie pozostałe pociągnięcia. Cofnięcie usuwa ten element. Żadne istniejące pociągnięcie nie jest modyfikowane, ponieważ wymazywanie zachodzi wyłącznie podczas odtwarzania, w kolejności listy.
Wdrożyliśmy ten model, a dwie sesje na urządzeniach go zepsuły.
Przenoszenie obiektów ujawnia brak właściciela
Pierwsze zgłoszenie brzmiało: zaznacz pociągnięcia za pomocą lassa, przeciągnij je, a przerwa zostaje w starym miejscu.

Te same wymazane pociągnięcia przeniesione w prawo. U góry: gumka jest osobnym elementem. Na dole: wymazywanie należy do pociągnięć.
Zaznaczenie pogarszało sprawę. Zaznaczone pociągnięcia są wyjmowane z bitmapy do nakładki, która rysuje je bezpośrednio, bez elementu gumki, więc w chwili zaznaczenia wymazanego pociągnięcia znów pojawiało się ono w całości.
Drugie zgłoszenie: gumka unosiła się nad zakreślaczem. Nasz renderer odtwarza zawartość w dwóch przebiegach — najpierw zakreślacze, potem pióra — ponieważ tusz zakreślacza musi znajdować się pod tuszem. Element gumki musi brać udział w obu przebiegach, inaczej nie może wymazywać obu rodzajów. Udział w przebiegu pióra oznacza wykonywanie gumki po narysowaniu każdego zakreślacza, więc gumka wycinała także pociągnięcia zakreślacza narysowane później.
Oba błędy mają to samo źródło. MaLiang nie ma lassa, przenoszenia ani kolejności warstw między pędzlami, więc element bez właściciela wystarcza tam. Nasze płótno ma wszystkie trzy funkcje, a wymazywanie bez właściciela nie daje jednoznacznej odpowiedzi na pytania: „co dzieje się, gdy tusz się przesuwa?” oraz „na której warstwie działa ta operacja?”.
Wymazywanie należy do pociągnięcia
Każdy Stroke przechowuje teraz własne ścieżki wymazywania. Gumka sprawdza, których pociągnięć dotyka, i dopisuje ścieżkę do każdego z nich, w układzie współrzędnych płótna. Renderowanie jednego pociągnięcia rysuje jego tusz i własne wymazania wewnątrz warstwy przezroczystości:
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 zachowuje miejsce docelowe i usuwa je proporcjonalnie do alfy źródła:
Wewnątrz warstwy przezroczystości miejscem docelowym jest to jedno pociągnięcie, więc operacja nie może dotrzeć do papieru, pociągnięć pod nim ani pociągnięć narysowanych później. W przypadku pociągnięć bez wymazań — a dotyczy to prawie wszystkich — warstwa nie generuje kosztu: korzystają one z oryginalnej ścieżki.
Przypisanie wymazywania do pociągnięcia odpowiada bezpośrednio na oba problemy. Przesunięcie przenosi ścieżki wymazywania wraz z punktami próbek, więc przesunięte pociągnięcie niesie ze sobą swoje przerwy. Nakładka zaznaczenia rysuje ten sam obiekt pociągnięcia, więc zaznaczone pociągnięcie pokazuje te same przerwy. Kolejność warstw przestaje mieć znaczenie, ponieważ wymazywanie nigdy nie działa na niczym poza własnym pociągnięciem.
Cofanie działa na poziomie gestu: jeden wpis zawiera każdą parę (stroke, path) utworzoną przez ten gest, cofnięcie usuwa te ścieżki, a ponowienie dopisuje je z powrotem. Tusz pod wymazaniem nigdy nie jest modyfikowany, więc niczego nie trzeba rekonstruować.
Podgląd na żywo przechodzi przez bitmapę kafelkową
Rysowane pociągnięcie jest zwykle renderowane jako nakładka nad kompozytowaną stroną. Wymazywanie nie może działać w tym miejscu, ponieważ ta nakładka leży nad papierem tak samo jak nad tuszem, a destination-out przebiłoby również papier.
Dlatego gumka od razu zapisuje zmiany w pociągnięciach, a każda partia nowych próbek uruchamia lokalne odtworzenie wyłącznie dotkniętego prostokąta. Potok kafelkowy już czyści prostokąt i odtwarza przecinające go pociągnięcia; gumka ponownie wykorzystuje tę ścieżkę i nie wprowadza drugiego sposobu rysowania.
Obie gumki obok siebie
Pasek narzędzi ma dwa przyciski gumki zamiast jednego przycisku z ukrytym trybem. Gumka do częściowego wymazywania usuwa fragment pociągnięcia. Gumka wymazująca pociągnięcia usuwa całe pociągnięcia przy kontakcie. Ich promienie są osobnymi stałymi i obecnie oba wynoszą 4 punkty.
Usunięcie całego pociągnięcia zabiera ze sobą jego ścieżki wymazywania, ponieważ są częścią tego pociągnięcia, a cofnięcie przywraca oba elementy razem. Ścieżki wymazywania są także częścią zapisanego pliku i, jak wszystko inne, są kwantyzowane: promień i współrzędne do dziesiątych części punktu.
Pozostaje jedno ograniczenie. Pociągnięcie wymazane do zera nadal istnieje jako dane. Renderuje się jako nic, ale lasso nadal może je zaznaczyć, gumka wymazująca pociągnięcia może je usunąć, a ono nadal zajmuje miejsce w dokumencie. Usuwanie całkowicie wymazanych pociągnięć wymaga testu pokrycia, którego jeszcze nie napisaliśmy, więc na razie pozostają.
Gdy model z innego projektu przestaje działać w twoim, różnica zwykle nie leży w skopiowanym kodzie. Leży w funkcjach, których tamten projekt nie ma.