Lulucat

Dos borradores y por qué el borrado pertenece al trazo

Gaoge ZhangGaoge Zhang

Un trazo de borrador como elemento propio es el diseño obvio. Mover objetos lo rompe, así que en Lulucat Notes el borrado pertenece al trazo cuya tinta elimina.

Actualizado

Cuando alguien borra en un cuaderno, puede estar hablando de dos cosas distintas. A veces una marca está mal y debe desaparecer entera: una línea guía, una palabra tachada, un trazo suelto de una palma apoyada. A veces solo está mal una parte de la marca: la mitad de un carácter, una rama de un diagrama, la cola de un barrido de resaltador que se pasó de la frase. Lulucat Notes ofrece ahora ambas opciones, como dos herramientas separadas.

La pregunta de diseño más difícil es dónde vive el borrado.

El borrador de trazos fue lo primero

El primer borrador eliminaba trazos completos. Un toque comprueba todos los trazos: las cajas delimitadoras rechazan casi todos, y los que sobreviven pasan una prueba de distancia cuadrática entre un punto y un segmento, contra el radio del borrador más el radio del trazo interpolado a lo largo de ese segmento. La anchura varía con la presión, así que una única anchura nominal eliminaría trazos finos que el usuario no tocó y no detectaría los gruesos que sí tocó.

Un gesto se fusiona en una sola entrada de deshacer, y la entrada guarda cada trazo eliminado junto con su índice original. Deshacer los vuelve a insertar en orden inverso, de modo que el orden de apilado recupera exactamente su estado anterior.

Este borrador es rápido, predecible e incapaz de eliminar media palabra.

MaLiang convierte el borrador en un pincel

MaLiang, el framework de dibujo con licencia MIT del que ya habíamos tomado el modelo de sellos del resaltador, resuelve el borrado parcial en veinticuatro líneas. Su Eraser es una subclase de Brush que sobrescribe una sola cosa: las opciones de mezcla, para sustraer en vez de sumar. Un trazo de borrador es un elemento de la misma lista que todos los demás trazos. Deshacer elimina ese elemento. Ningún trazo existente se modifica, porque el borrado solo ocurre durante la reproducción, en el orden de la lista.

Lanzamos ese modelo, y dos sesiones en dispositivos lo rompieron.

Mover objetos deja al descubierto al propietario que falta

El primer informe fue: selecciona trazos con el lazo, arrástralos y el hueco se queda atrás.

Cuatro paneles comparan dos modelos de borrador antes y después de mover los trazos. Cuando el borrador es su propio elemento, el hueco permanece en la posición antigua; cuando el borrado pertenece al trazo, el hueco se desplaza con la tinta.

Los mismos trazos borrados, desplazados a la derecha. Arriba: el borrador es un elemento independiente. Abajo: el borrado pertenece a los trazos.

La selección lo empeoraba. Los trazos seleccionados salen del mapa de bits y pasan a una superposición que los dibuja directamente, sin el elemento de borrador, así que en cuanto seleccionabas un trazo borrado volvía a aparecer completo.

El segundo informe fue: el borrador flotaba por encima del resaltador. Nuestro renderizador reproduce en dos pasadas, primero los resaltadores y después los bolígrafos, porque la tinta del resaltador debe quedar debajo de la tinta. Un elemento de borrador tiene que participar en ambas pasadas; de lo contrario no puede borrar los dos tipos. Tomar parte en la pasada de bolígrafos significa ejecutarse después de que se haya dibujado cada resaltador, así que un borrador también cortaba trazos de resaltador que se habían dibujado más tarde en el tiempo.

Ambos fallos tienen el mismo origen. MaLiang no tiene lazo, movimiento ni orden de capas entre pinceles, así que allí basta con un elemento sin propietario. Nuestro lienzo tiene las tres cosas, y un borrado sin propietario no tiene una respuesta definida a «qué ocurre cuando se mueve la tinta» ni a «en qué capa está operando esto».

El borrado pertenece al trazo

Ahora cada Stroke lleva sus propias rutas de borrado. El borrador comprueba qué trazos toca y añade una ruta a cada uno, en coordenadas del lienzo. Al renderizar un trazo se dibuja su tinta y sus propios borrados dentro de una capa de transparencia:

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 conserva el destino y lo elimina en proporción al alfa de la fuente:

Dentro de una capa de transparencia, el destino es ese único trazo, así que la operación no puede alcanzar el papel, los trazos que están debajo ni los trazos dibujados después. La capa no supone ningún coste para los trazos sin borrados, que son casi todos: esos siguen usando la ruta original.

La propiedad responde directamente a los dos fallos. La traslación mueve las rutas de borrado junto con los puntos de muestra, así que un trazo desplazado lleva consigo sus huecos. La superposición de selección dibuja el mismo objeto Stroke, así que un trazo seleccionado muestra los mismos huecos. El orden de capas deja de importar, porque un borrado nunca se aplica a nada excepto a su propio trazo.

Deshacer funciona por gesto: una entrada enumera cada par (stroke, path) que produjo ese gesto; deshacer elimina esas rutas y rehacer las vuelve a añadir. La tinta bajo un borrado nunca se edita, así que no hay que reconstruir nada.

La respuesta en directo pasa por el mapa de bits de teselas

El trazo que se está dibujando normalmente se renderiza como una superposición por encima de la página compuesta. Un borrado no puede funcionar ahí, porque esa superposición está encima tanto del papel como de la tinta, y destination-out atravesaría el papel.

Por eso el borrador escribe inmediatamente en los trazos, y cada lote de muestras nuevas activa una reproducción local únicamente del rectángulo afectado. El pipeline de teselas ya borra un rectángulo y reproduce los trazos que lo intersectan; el borrador reutiliza esa ruta y no añade una segunda semántica de dibujo.

Los dos borradores, uno junto al otro

La barra de herramientas tiene dos botones de borrador en lugar de uno con un modo oculto. El borrador parcial elimina parte de un trazo. El borrador de trazos elimina trazos completos al contacto. Sus radios son constantes independientes, ambos actualmente de cuatro puntos.

Eliminar un trazo completo se lleva sus rutas de borrado, ya que forman parte de ese trazo, y deshacer lo devuelve todo junto. Las rutas de borrado también forman parte del archivo guardado, cuantizadas como todo lo demás: el radio y las coordenadas en décimas de punto.

Queda una limitación. Un trazo borrado hasta desaparecer por completo sigue existiendo como datos. No se renderiza, pero el lazo aún puede seleccionarlo y el borrador de trazos puede eliminarlo, y sigue ocupando espacio en el documento. Recopilar los trazos completamente borrados requiere una prueba de cobertura que todavía no hemos escrito, así que por ahora se quedan.

Cuando un modelo de otro proyecto deja de funcionar en el tuyo, la diferencia normalmente no está en el código que copiaste. Está en las funciones que ese proyecto no tiene.