Due gomme, e perché la cancellazione appartiene al tratto
Una gomma come elemento autonomo sembra la scelta più ovvia, ma lo spostamento degli oggetti la manda in crisi; in Lulucat Notes la cancellazione appartiene al tratto da cui rimuove l'inchiostro.
Quando si cancella in un quaderno, si possono intendere due cose diverse. A volte un segno è sbagliato e deve sparire interamente: una linea guida, una parola barrata, o un tratto sfuggito al palmo della mano appoggiato. Altre volte è sbagliata solo una parte del segno: metà di un carattere, un ramo di un diagramma, o la coda di una passata di evidenziatore che è andata oltre la frase. Lulucat Notes ora offre entrambe le possibilità, come due strumenti separati.
La domanda di progettazione più difficile è dove debba stare la cancellazione.
La gomma per tratti è arrivata per prima
La prima gomma eliminava interi tratti. A ogni tocco vengono esaminati tutti i tratti: i bounding box ne scartano quasi tutti, e sui rimanenti viene eseguito un test della distanza quadratica punto-segmento rispetto al raggio della gomma più il raggio del tratto interpolato lungo quel segmento. La larghezza varia con la pressione, quindi una singola larghezza nominale eliminerebbe tratti sottili che l’utente non ha toccato e mancherebbe quelli spessi che invece ha toccato.
Un gesto confluisce in una sola voce di annullamento, che memorizza ogni tratto rimosso insieme al suo indice originale. L’annullamento li reinserisce in ordine inverso, così l’ordine di sovrapposizione torna esattamente quello di prima.
Questa gomma è veloce e prevedibile, ma non può cancellare metà di una parola.
MaLiang trasforma la gomma in un pennello
MaLiang, il framework per il disegno con licenza MIT da cui avevamo già preso in prestito il modello a timbri dell’evidenziatore, risolve la cancellazione parziale in 24 righe. Il suo Eraser è una sottoclasse di Brush che sovrascrive una sola cosa, le opzioni di fusione, per sottrarre invece di aggiungere. Un tratto di gomma è un elemento nella stessa lista di ogni altro tratto. L’annullamento rimuove quell’elemento. Nessun tratto esistente viene modificato, perché la cancellazione avviene solo durante la riproduzione, nell’ordine della lista.
Abbiamo distribuito quel modello, e due sessioni su dispositivi lo hanno fatto fallire.
Lo spostamento degli oggetti rivela il proprietario mancante
La prima segnalazione: seleziona i tratti con il lasso, trascinali e il buco resta indietro.

Gli stessi tratti cancellati, spostati verso destra. Sopra: la gomma è un elemento autonomo. Sotto: la cancellazione appartiene ai tratti.
La selezione peggiorava le cose. I tratti selezionati vengono sollevati dal bitmap in una sovrapposizione che li disegna direttamente, senza l’elemento gomma; così, nel momento in cui selezioni un tratto cancellato, questo appare di nuovo intero.
La seconda segnalazione: la gomma fluttuava sopra l’evidenziatore. Il nostro renderer riproduce in due passaggi, prima gli evidenziatori e poi le penne, perché l’inchiostro dell’evidenziatore deve stare sotto l’inchiostro. Un elemento gomma deve partecipare a entrambi i passaggi, altrimenti non può cancellare entrambi i tipi. Partecipare al passaggio delle penne significa eseguirlo dopo ogni evidenziatore già disegnato, quindi una gomma tagliava anche i tratti di evidenziatore disegnati più tardi nel tempo.
Entrambi i problemi hanno la stessa origine. MaLiang non ha lasso, spostamento né un ordine dei livelli tra pennelli, quindi lì basta un elemento senza proprietario. La nostra tela ha tutte e tre le cose, e una cancellazione senza proprietario non dà una risposta definita a «che cosa succede quando l’inchiostro si sposta» o «su quale livello sta operando».
La cancellazione appartiene al tratto
Ogni Stroke ora porta con sé i propri percorsi di cancellazione. La gomma verifica quali tratti tocca e aggiunge un percorso a ciascuno di essi, in coordinate della tela. Il rendering di un tratto disegna il suo inchiostro e le sue cancellazioni all’interno di un transparency layer:
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 mantiene la destinazione e la rimuove in proporzione all’alfa della sorgente:
All’interno di un transparency layer la destinazione è quel singolo tratto, quindi l’operazione non può raggiungere la carta, i tratti sottostanti o quelli disegnati in seguito. Il layer non comporta alcun costo per i tratti senza cancellazioni, che sono quasi tutti: per loro si usa il percorso originale.
Il fatto che la cancellazione abbia un proprietario risponde direttamente ai due problemi. La traslazione sposta i percorsi di cancellazione insieme ai punti campionati, così un tratto spostato porta con sé i propri vuoti. La sovrapposizione della selezione disegna lo stesso oggetto del tratto, quindi un tratto selezionato mostra gli stessi vuoti. L’ordine dei livelli smette di contare, perché una cancellazione non si applica mai a qualcosa che non sia il proprio tratto.
L’annullamento opera sul gesto: una voce elenca ogni coppia (stroke, path) prodotta da quel gesto; l’annullamento rimuove quei percorsi, mentre il ripristino li aggiunge di nuovo. L’inchiostro sotto una cancellazione non viene mai modificato, quindi non c’è nulla da ricostruire.
Il feedback in tempo reale passa attraverso il bitmap delle tile
Il tratto in corso viene normalmente renderizzato come una sovrapposizione sopra la pagina compositata. Una cancellazione non può funzionare lì, perché quella sovrapposizione sta sopra la carta oltre che sopra l’inchiostro, e destination-out perforerebbe anche la carta.
Perciò la gomma scrive immediatamente nei tratti, e ogni nuovo gruppo di campioni attiva una riproduzione locale della sola porzione interessata. La pipeline delle tile cancella già un rettangolo e riproduce i tratti che lo intersecano; la gomma riutilizza quel percorso e non introduce una seconda semantica di disegno.
Le due gomme, affiancate
La barra degli strumenti ha due pulsanti per la gomma invece di un solo pulsante con una modalità nascosta. La gomma parziale rimuove una parte di un tratto. La gomma per tratti rimuove interi tratti al contatto. I loro raggi sono costanti separate, entrambi attualmente pari a 4 punti.
Eliminare un intero tratto elimina con sé i suoi percorsi di cancellazione, perché fanno parte di quel tratto, e l’annullamento ripristina entrambi insieme. I percorsi di cancellazione fanno anche parte del file salvato, quantizzati come tutto il resto: raggio e coordinate in decimi di punto.
Resta una limitazione. Un tratto cancellato fino a sparire esiste ancora come dato. Viene renderizzato come nulla, ma il lasso può ancora selezionarlo e la gomma per tratti può ancora eliminarlo, e continua a occupare spazio nel documento. Raccogliere i tratti completamente cancellati richiede un test di copertura che non abbiamo ancora scritto, quindi per ora restano.
Quando un modello proveniente da un altro progetto smette di funzionare nel tuo, la differenza di solito non sta nel codice che hai copiato. Sta nelle funzionalità che quell’altro progetto non ha.