Duas borrachas e por que razão o apagamento pertence ao traço
Um traço de borracha como elemento próprio parece óbvio. Mover objetos quebra este modelo, por isso, no Lulucat Notes, o apagamento pertence ao traço cuja tinta é removida.
Quando alguém apaga num caderno, pode querer dizer duas coisas diferentes. Por vezes, uma marca está errada e todo o traço deve desaparecer: uma linha-guia, uma palavra riscada, um traço solto de uma palma pousada. Por vezes, só uma parte de uma marca está errada: metade de um carácter, um ramo de um diagrama, a cauda de um traço de marcador fluorescente que passou para lá da frase. O Lulucat Notes inclui agora ambas, como duas ferramentas separadas.
A questão de design mais difícil é saber onde vive o apagamento.
A borracha de traços veio primeiro
A primeira borracha eliminava traços inteiros. Um toque testa todos os traços: as caixas delimitadoras rejeitam quase todos, e os sobreviventes passam por um teste da distância quadrática entre ponto e segmento contra o raio da borracha mais o raio do traço interpolado ao longo desse segmento. A largura varia com a pressão, pelo que uma única largura nominal eliminaria traços finos que o utilizador não tocou e falharia os grossos em que tocou.
Um gesto transforma-se numa única entrada de desfazer, e a entrada guarda cada traço removido juntamente com o seu índice original. Desfazer volta a inseri-los pela ordem inversa, pelo que a ordem de empilhamento regressa exatamente ao estado original.
Esta borracha é rápida, previsível e incapaz de remover metade de uma palavra.
MaLiang torna a borracha num pincel
MaLiang, a framework de desenho licenciada ao abrigo da licença MIT, de onde já tínhamos aproveitado o modelo de carimbos do marcador fluorescente, responde ao apagamento parcial em 24 linhas. A sua Eraser é uma subclasse de Brush que substitui uma única coisa, as opções de mistura, para subtrair em vez de adicionar. Um traço de borracha é um elemento na mesma lista que todos os outros traços. Desfazer remove esse elemento. Nenhum traço existente é modificado, porque o apagamento só acontece durante a reprodução, pela ordem da lista.
Colocámos esse modelo em produção, e duas sessões em dispositivos fizeram-no falhar.
A deslocação de objetos revela o proprietário em falta
A primeira queixa: selecione traços com o laço, arraste-os e o espaço vazio fica para trás.

Os mesmos traços apagados, movidos para a direita. Em cima: a borracha é um elemento independente. Em baixo: o apagamento pertence aos traços.
A seleção piorou as coisas. Os traços selecionados saem do bitmap para uma sobreposição que os desenha diretamente, sem o elemento da borracha; por isso, assim que se selecionava um traço apagado, ele voltava a parecer inteiro.
A segunda queixa: a borracha flutuava sobre o marcador fluorescente. O nosso renderizador reproduz em duas passagens, primeiro os marcadores fluorescentes e depois as canetas, porque a tinta do marcador deve ficar por baixo da tinta. Um elemento de borracha tem de participar nas duas passagens, caso contrário não consegue apagar ambos os tipos. Participar na passagem das canetas significa correr depois de cada marcador fluorescente ter sido desenhado, pelo que uma borracha também cortava traços de marcador desenhados mais tarde.
As duas falhas têm a mesma origem. A MaLiang não tem laço, movimento nem ordem de camadas entre pincéis, pelo que um elemento sem proprietário é suficiente nesse contexto. O nosso canvas tem as três coisas, e um apagamento sem proprietário não tem uma resposta definida para «o que acontece quando a tinta se move?» ou «em que camada está a operar?».
O apagamento pertence ao traço
Cada Stroke transporta agora os seus próprios caminhos de apagamento. A borracha testa em que traços toca e acrescenta um caminho a cada um deles, em coordenadas do canvas. A renderização de um traço desenha a sua tinta e os seus próprios apagamentos dentro de uma camada de transparência:
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 mantém o destino e remove-o na proporção do alfa da origem:
Dentro de uma camada de transparência, o destino é esse único traço, pelo que a operação não pode alcançar o papel, os traços por baixo nem os traços desenhados depois. A camada não tem custo para os traços sem apagamentos, que são quase todos: esses seguem pelo caminho original.
A atribuição ao traço responde diretamente às duas falhas. A translação move os caminhos de apagamento juntamente com os pontos amostrados, pelo que um traço movido leva consigo os seus espaços vazios. A sobreposição de seleção desenha o mesmo objeto de traço, pelo que um traço selecionado mostra os mesmos espaços vazios. A ordem das camadas deixa de importar, porque um apagamento nunca se aplica a nada além do seu próprio traço.
O desfazer atua sobre o gesto: uma entrada enumera cada par (stroke, path) produzido por esse gesto, o desfazer remove esses caminhos e o refazer volta a acrescentá-los. A tinta sob um apagamento nunca é editada, pelo que nada tem de ser reconstruído.
O feedback em direto passa pelo bitmap dos mosaicos
O traço que está a ser desenhado é normalmente renderizado como uma sobreposição acima da página composta. Um apagamento não pode funcionar aí, porque essa sobreposição fica por cima do papel e da tinta, e destination-out perfuraria o papel.
Por isso, a borracha escreve imediatamente nos traços, e cada lote de novas amostras desencadeia uma reprodução local apenas do retângulo afetado. O pipeline de mosaicos já limpa um retângulo e reproduz os traços que o intersectam; a borracha reutiliza esse caminho e não acrescenta uma segunda semântica de desenho.
As duas borrachas, lado a lado
A barra de ferramentas tem dois botões de borracha em vez de um botão com um modo oculto. A borracha parcial apaga parte de um traço. A borracha de traços remove traços inteiros ao tocar-lhes. Os seus raios são duas constantes separadas, ambas atualmente com 4 pontos.
Apagar um traço inteiro leva consigo os seus caminhos de apagamento, porque fazem parte desse traço, e o desfazer traz ambos de volta em conjunto. Os caminhos de apagamento também fazem parte do ficheiro guardado, quantificados como tudo o resto: raio e coordenadas em décimos de ponto.
Resta uma limitação. Um traço apagado até desaparecer por completo continua a existir como dados. Não renderiza nada, mas o laço ainda o pode selecionar e a borracha de traços ainda o pode apagar, e continua a ocupar espaço no documento. Recolher traços completamente apagados exige um teste de cobertura que ainda não escrevemos, por isso, para já, ficam.
Quando um modelo de outro projeto deixa de funcionar no seu, a diferença normalmente não está no código que copiou. Está nas funcionalidades que esse projeto não tem.