两种橡皮擦,以及为什么擦除属于笔画
把橡皮擦笔画作为独立元素,是一种直观的设计;但对象移动会破坏它。因此,在 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 会保留目标,并按源 alpha 的比例移除目标:
在透明图层内,目标只有这一条笔画,因此操作不会触及纸张、下面的笔画或之后绘制的笔画。没有擦除路径的笔画无需承担图层开销,几乎所有笔画都属于这一类:它们仍走原来的路径。
归属关系直接解决了两个问题。平移会让擦除路径随采样点移动,所以移动后的笔画会带着缺口。选择叠加层绘制的是同一个笔画对象,因此选中的笔画也会显示相同的缺口。图层顺序不再重要,因为擦除永远只作用于自己的笔画。
撤销以手势为单位:一个条目列出该手势产生的每个 (stroke, path) 对,撤销会移除这些路径,重做会再次追加它们。擦除下方的墨迹从未被编辑,因此不必重建任何内容。
实时反馈通过图块位图完成
正在绘制的笔画通常作为叠加层显示在合成页面上方。擦除不能在那里工作,因为叠加层同时位于纸张和墨迹上方,destination-out 会把纸张也打穿。
所以橡皮擦会立即写入笔画;每批新采样只触发受影响矩形的局部重放。图块流水线本来就会清除矩形,并重放与其相交的笔画;橡皮擦复用这条路径,不需要增加第二套绘制语义。
两种橡皮擦并列工作
工具栏有两个橡皮擦按钮,而不是一个隐藏模式的按钮。局部橡皮擦擦除笔画的一部分;笔画橡皮擦接触到笔画就删除整条笔画。两者的半径是独立常量,目前都是 4 点。
删除整条笔画时,擦除路径也会随之删除,因为它们属于该笔画;撤销会把两者一起恢复。擦除路径也会保存到文件中,和其他数据一样量化:半径和坐标都以点的十分之一为单位。
还有一个限制。一条笔画即使被擦到什么都不剩,仍然作为数据存在。它渲染为空,但套索仍可选中它,笔画橡皮擦仍可删除它,而且它仍占用文档空间。清理完全擦除的笔画需要覆盖测试,而我们还没有编写这个测试,所以目前会保留这些笔画。
当另一个项目中的模型在你的项目里停止工作时,问题通常不在你复制的代码,而在那个项目没有的功能。