Lulucat

Два ластика и почему стирание принадлежит штриху

Gaoge ZhangGaoge Zhang

Штрих ластика как отдельный элемент кажется очевидным решением. Перемещение объектов ломает эту модель, поэтому в Lulucat Notes стирание принадлежит штриху, из которого удаляются чернила.

Обновлено

Когда люди стирают в блокноте, они имеют в виду две разные вещи. Иногда отметка ошибочна, и от неё нужно избавиться целиком: направляющая линия, зачёркнутое слово, случайный штрих от лежащей на странице ладони. Иногда ошибочна только часть отметки: половина символа, одна ветвь схемы, хвост мазка текстовыделителя, вышедший за пределы предложения. Lulucat Notes теперь предоставляет оба варианта — как два отдельных инструмента.

Более сложный вопрос проектирования — где находится стирание.

Сначала появился ластик штрихов

Первый ластик удалял штрихи целиком. При касании проверяется каждый штрих: ограничивающие прямоугольники отбрасывают почти все, а для оставшихся выполняется проверка квадрата расстояния от точки до отрезка с порогом, равным сумме радиуса ластика и интерполированного вдоль этого отрезка радиуса штриха. Ширина меняется вместе с давлением, поэтому одна номинальная ширина удаляла бы тонкие штрихи, которых пользователь не касался, и пропускала бы толстые, которых пользователь коснулся.

Один жест объединяется в одну запись отмены, и в этой записи каждый удалённый штрих хранится вместе со своим исходным индексом. При отмене они вставляются обратно в обратном порядке, поэтому порядок наложения восстанавливается в точности.

Этот ластик работает быстро, предсказуемо и не умеет удалить половину слова.

MaLiang превращает ластик в кисть

MaLiang — фреймворк для рисования под лицензией MIT, у которого мы уже заимствовали модель штампов текстовыделителя, — отвечает на частичное стирание в 24 строках. Его Eraser — подкласс Brush, который переопределяет одну вещь, параметры смешивания, чтобы вычитать, а не складывать. Штрих ластика — это элемент в том же списке, что и любой другой штрих. Отмена удаляет этот элемент. Ни один существующий штрих не изменяется, поскольку стирание происходит только во время повторного воспроизведения, в порядке списка.

Мы выпустили эту модель, и в двух сеансах на устройствах она сломалась.

Перемещение объектов показывает, кому принадлежит стирание

Первый отчёт: выделите штрихи лассо, перетащите их — и пробел остаётся позади.

Четыре панели сравнивают две модели ластика до и после перемещения штрихов. Когда ластик — отдельный элемент, зазор остаётся в старой позиции; когда стирание принадлежит штриху, зазор перемещается вместе с чернилами.

Одни и те же стёртые штрихи перемещены вправо. Вверху: ластик — отдельный элемент. Внизу: стирание принадлежит штрихам.

Выделение усугубило ситуацию. Выбранные штрихи извлекаются из растрового изображения в оверлей, который отрисовывает их напрямую, без элемента ластика, поэтому в момент выбора стёртого штриха он снова становился целым.

Второй отчёт: ластик всплывал над текстовыделителем. Наш рендерер воспроизводит изображение в два прохода: сначала текстовыделители, затем перья, потому что чернила текстовыделителя должны находиться под чернилами. Элемент ластика должен участвовать в обоих проходах, иначе он не сможет стереть оба вида. Участие в проходе пера означает выполнение после отрисовки каждого текстовыделителя, поэтому элемент ластика также вырезал штрихи текстовыделителя, нарисованные позднее.

У обеих ошибок один источник. В 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 сохраняет область назначения и удаляет её пропорционально альфа-каналу источника:

Внутри слоя прозрачности назначением является только этот штрих, поэтому операция не может затронуть бумагу, штрихи под ним или штрихи, нарисованные позже. Для штрихов без стираний слой ничего не стоит, а это почти все штрихи: они используют исходный путь.

Принадлежность напрямую отвечает на обе ошибки. Перемещение переносит пути стирания вместе с точками выборки, поэтому перемещённый штрих несёт свои пробелы с собой. Оверлей выделения отрисовывает тот же объект штриха, поэтому выбранный штрих показывает те же пробелы. Порядок слоёв больше не важен, поскольку стирание никогда не применяется ни к чему, кроме собственного штриха.

Отмена работает на уровне жеста: одна запись перечисляет все пары (stroke, path), созданные этим жестом; отмена удаляет эти пути, повтор добавляет их снова. Чернила под стиранием никогда не редактируются, поэтому ничего не нужно восстанавливать.

Живая обратная связь проходит через тайловый растр

Рисуемый штрих обычно отрисовывается как оверлей над скомпонованной страницей. Стирание не может работать там, поскольку этот оверлей находится поверх бумаги и чернил, а destination-out пробил бы бумагу.

Поэтому ластик сразу записывает изменения в штрихи, а каждая порция новых образцов запускает локальное повторное воспроизведение только затронутого прямоугольника. Тайловый конвейер уже очищает прямоугольник и повторно воспроизводит штрихи, пересекающие его; ластик использует этот путь и не добавляет второй семантики рисования.

Оба ластика рядом

На панели инструментов две кнопки ластика, а не одна кнопка со скрытым режимом. Ластик частичного стирания удаляет часть штриха. Ластик штрихов удаляет целые штрихи при касании. Их радиусы заданы отдельными константами, и обе сейчас равны 4 пунктам.

Удаление целого штриха удаляет вместе с ним пути стирания, поскольку они являются частью этого штриха, а отмена возвращает их вместе. Пути стирания также входят в сохранённый файл и квантуются, как и всё остальное: радиус и координаты — до десятых долей пункта.

Одно ограничение остаётся. Штрих, стёртый до полного исчезновения, всё ещё существует как данные. Он ничего не отрисовывает, но лассо по-прежнему может выбрать его, а ластик штрихов — удалить, и он всё ещё занимает место в документе. Для удаления полностью стёртых штрихов нужен тест покрытия, которого мы ещё не написали, поэтому пока они остаются.

Когда модель из другого проекта перестаёт работать в вашем, различие обычно не в скопированном коде. Оно в возможностях, которых в том проекте нет.