Lulucat

Dua Penghapus, dan Mengapa Penghapusan Menjadi Milik Sapuan

Gaoge ZhangGaoge Zhang

Penghapus sebagai elemen tersendiri tampak seperti pilihan yang jelas. Pemindahan objek membuatnya gagal, jadi di Lulucat Notes penghapusan menjadi milik sapuan yang tintanya dihapus.

Diperbarui

Ketika orang menghapus di buku catatan, mereka bisa bermaksud dua hal yang berbeda. Kadang sebuah tanda salah dan seluruhnya harus dihilangkan: garis panduan, kata yang dicoret, atau sapuan liar akibat telapak tangan yang sedang bertumpu. Kadang hanya sebagian tanda yang salah: separuh karakter, satu cabang diagram, atau ekor sapuan highlighter yang melewati kalimat. Lulucat Notes kini menyediakan keduanya sebagai dua alat terpisah.

Pertanyaan desain yang lebih sulit adalah di mana penghapusan itu berada.

Penghapus sapuan muncul lebih dulu

Penghapus pertama menghapus seluruh sapuan. Setiap sentuhan menguji semua sapuan: bounding box menolak hampir semuanya, dan yang tersisa menjalani uji jarak kuadrat titik-ke-segmen terhadap radius penghapus ditambah radius sapuan yang diinterpolasi sepanjang segmen tersebut. Lebarnya berubah mengikuti tekanan, jadi satu lebar nominal akan menghapus sapuan tipis yang tidak disentuh pengguna dan melewatkan sapuan tebal yang disentuh.

Satu gestur menjadi satu entri undo, dan entri tersebut menyimpan setiap sapuan yang dihapus beserta indeks aslinya. Undo memasukkannya kembali dalam urutan terbalik, sehingga urutan penumpukan pulih persis seperti semula.

Penghapus ini cepat, dapat diprediksi, tetapi tidak bisa menghapus separuh kata.

MaLiang menjadikan penghapus sebagai kuas

MaLiang, framework gambar berlisensi MIT yang sebelumnya kami pinjam model stempel highlighter-nya, menjawab penghapusan sebagian dalam 24 baris. Eraser-nya adalah subclass Brush yang meng-override satu hal saja—blend options—agar mengurangi, bukan menambahkan. Sapuan penghapus adalah elemen dalam daftar yang sama dengan semua sapuan lain. Undo menghapus elemen tersebut. Tidak ada sapuan yang sudah ada yang dimodifikasi, karena penghapusan hanya berlangsung saat replay mengikuti urutan daftar.

Kami merilis model itu, lalu dua sesi perangkat membuatnya gagal.

Pemindahan objek mengungkap pemilik yang hilang

Laporan pertama: pilih sapuan dengan lasso, seret, dan lubangnya tertinggal.

Empat panel yang membandingkan dua model penghapus sebelum dan sesudah sapuan dipindahkan. Saat penghapus menjadi elemennya sendiri, celah tetap berada di posisi lama; saat penghapusan dimiliki sapuan, celah ikut bergerak bersama tinta.

Sapuan yang sama setelah dipindahkan ke kanan. Di atas: penghapus adalah elemen tersendiri. Di bawah: penghapusan menjadi milik sapuan.

Seleksi memperburuk keadaan. Sapuan yang dipilih diangkat dari bitmap ke overlay yang menggambarnya langsung, tanpa elemen penghapus, sehingga begitu Anda memilih sapuan yang sudah dihapus, sapuan itu kembali tampak utuh.

Laporan kedua: penghapus mengambang di atas highlighter. Renderer kami melakukan replay dalam dua pass, highlighter lebih dulu lalu pena, karena tinta highlighter harus berada di bawah tinta. Elemen penghapus harus ikut dalam kedua pass; jika tidak, ia tidak bisa menghapus kedua jenis tinta. Ikut dalam pass pena berarti berjalan setelah setiap highlighter digambar, sehingga penghapus juga memotong sapuan highlighter yang digambar belakangan secara waktu.

Kedua kegagalan itu memiliki sumber yang sama. MaLiang tidak memiliki lasso, pemindahan, atau urutan layer antar-brush, jadi elemen tanpa pemilik sudah cukup di sana. Kanvas kami memiliki ketiganya, dan penghapusan tanpa pemilik tidak punya jawaban yang terdefinisi untuk “apa yang terjadi saat tinta dipindahkan” atau “di layer mana operasi ini bekerja.”

Penghapusan menjadi milik sapuan

Setiap Stroke kini membawa path penghapusannya sendiri. Penghapus menguji sapuan mana yang disentuhnya, lalu menambahkan satu path ke masing-masing sapuan itu dalam koordinat kanvas. Saat satu sapuan dirender, tintanya dan penghapusannya sendiri digambar di dalam 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 mempertahankan destination dan menghapusnya sebanding dengan alpha source:

Di dalam transparency layer, destination adalah sapuan itu saja, sehingga operasi ini tidak dapat menjangkau kertas, sapuan di bawahnya, atau sapuan yang digambar setelahnya. Layer tidak menambah biaya untuk sapuan tanpa penghapusan, dan jumlahnya hampir semua: sapuan-sapuan itu memakai path asli.

Kepemilikan menjawab kedua kegagalan tersebut secara langsung. Translasi memindahkan path penghapusan bersama titik sampel, sehingga sapuan yang dipindahkan membawa celahnya. Overlay seleksi menggambar objek sapuan yang sama, jadi sapuan terpilih menampilkan celah yang sama. Urutan layer tidak lagi penting, karena penghapusan tidak pernah diterapkan pada apa pun selain sapuannya sendiri.

Undo bekerja pada gestur: satu entri mencatat setiap pasangan (stroke, path) yang dihasilkan gestur itu; undo menghapus path tersebut, redo menambahkannya lagi. Tinta di bawah penghapusan tidak pernah diedit, jadi tidak ada yang harus direkonstruksi.

Umpan balik langsung melalui bitmap tile

Sapuan yang sedang digambar biasanya dirender sebagai overlay di atas halaman yang telah dikompositkan. Penghapusan tidak bisa bekerja di sana, karena overlay tersebut berada di atas kertas sekaligus tinta, dan destination-out akan melubangi kertas.

Jadi penghapus langsung menulis ke sapuan, dan setiap batch sampel baru memicu replay lokal hanya pada persegi panjang yang terdampak. Tile pipeline sudah mengosongkan sebuah persegi panjang dan memutar ulang sapuan yang beririsan dengannya; penghapus menggunakan kembali jalur itu dan tidak menambahkan semantik gambar kedua.

Kedua penghapus, berdampingan

Toolbar memiliki dua tombol penghapus, bukan satu tombol dengan mode tersembunyi. Penghapus sebagian menghilangkan sebagian sapuan. Penghapus sapuan menghapus seluruh sapuan saat bersentuhan. Radiusnya adalah konstanta terpisah, keduanya saat ini 4 poin.

Menghapus seluruh sapuan ikut menghapus path penghapusannya, karena path tersebut merupakan bagian dari sapuan itu, dan undo mengembalikan keduanya bersama-sama. Path penghapusan juga merupakan bagian dari file tersimpan, dikuantisasi seperti yang lain: radius dan koordinat dalam sepersepuluh poin.

Satu keterbatasan masih ada. Sapuan yang dihapus sampai tak bersisa tetap ada sebagai data. Saat dirender, sapuan itu tidak menghasilkan apa-apa, tetapi lasso masih dapat memilihnya dan penghapus sapuan masih dapat menghapusnya, serta sapuan itu masih memakan ruang dalam dokumen. Mengumpulkan sapuan yang sepenuhnya terhapus memerlukan uji cakupan yang belum kami tulis, jadi untuk sekarang sapuan itu tetap dibiarkan.

Ketika sebuah model dari proyek lain berhenti bekerja di proyek Anda, perbedaannya biasanya bukan pada kode yang Anda salin. Perbedaannya ada pada fitur-fitur yang tidak dimiliki proyek tersebut.