Lulucat

2つの消しゴムと、消去がストロークに属する理由

Gaoge ZhangGaoge Zhang

消しゴムを独立した要素にするのは自然な設計ですが、オブジェクトの移動で破綻します。Lulucat Notes では、消去はインクを取り除くストロークに属します。

更新日

ノートで消すとき、人が意味していることは2つある。マークそのものが間違っていて、全体を消すべき場合がある。ガイド線、取り消し線を引いた単語、手を置いたときに残った不要なストロークなどだ。別のときには、マークの一部だけが間違っている。文字の半分、図の枝の1本、文を通り過ぎたハイライターの払いの末尾などだ。Lulucat Notes は現在、その両方を2つの別々のツールとして提供している。

より難しい設計上の問いは、消去をどこに持たせるかだ。

ストローク消しゴムが先にできた

最初の消しゴムはストローク全体を削除した。タッチのたびにすべてのストロークを検査する。バウンディングボックスでほぼすべてを除外し、残ったものには、消しゴムの半径と、その線分に沿って補間したストローク半径の合計を使って、点と線分の距離の二乗テストを行う。筆圧で幅が変わるため、名目上の幅を1つ使うだけでは、ユーザーが触れていない細いストロークを削除したり、触れた太いストロークを取り逃したりする。

1つのジェスチャーは1つの取り消しエントリにまとまり、そのエントリには削除した各ストロークと元のインデックスが保存される。取り消しではそれらを逆順に戻すため、重なり順も完全に元どおりになる。

この消しゴムは高速で予測可能だが、単語の半分は消せない。

MaLiangが消しゴムをブラシにする

MaLiang は、すでにハイライターのスタンプモデルを借用した MIT ライセンスの描画フレームワークで、部分消去を24行で実現する。その EraserBrush のサブクラスで、ブレンドオプションという1つだけを上書きし、加える代わりに引く。消しゴムのストロークも、他のすべてのストロークと同じリストに入る要素だ。取り消しではその要素を削除する。既存のストロークは変更されない。消去が起きるのは、リスト順に再生するときだけだからだ。

このモデルを出荷したところ、実機での2回のセッションが問題を露呈させた。

オブジェクトの移動が、欠けていた所有者を明らかにする

最初の報告はこうだった。投げ縄でストロークを選択してドラッグすると、穴だけが後ろに残る。

2つの消しゴムモデルについて、ストロークを移動する前後を比較した4つのパネル。消しゴムを独立した要素にすると隙間は元の位置に残り、消去をストロークが所有すると隙間がインクと一緒に移動する。

同じ消去済みストロークを右へ移動したもの。上:消しゴムは独立した要素。下:消去はストロークに属する。

選択するとさらに悪化した。選択したストロークはビットマップからオーバーレイへ取り出され、消しゴム要素を介さず直接描画される。そのため、消去済みストロークを選択した瞬間、再び完全な状態に見えた。

2つ目の報告は、消しゴムがハイライターの上に浮いたというものだった。レンダラーは2パスで再生する。ハイライターを先、ペンを後にするのは、ハイライターのインクを下に置く必要があるためだ。消しゴム要素が両方のパスに参加しなければ、両方の種類を消せない。ペンのパスに参加させると、すべてのハイライターを描画した後に実行されることになる。そのため、消しゴムは時間的には後から描かれたハイライターのストロークまで切ってしまった。

両方の失敗の根は同じだ。MaLiangには投げ縄も移動もブラシ間のレイヤー順もないので、所有者を持たない要素でも十分だった。私たちのキャンバスにはその3つがあり、所有者のない消去には「インクが移動したら何が起きるのか」「これはどのレイヤーに対して動作するのか」という問いへの定義された答えがない。

ストロークが消去を所有する

すべての Stroke はいま、自分自身の消去パスを持つ。消しゴムは接触したストロークを判定し、キャンバス座標で各ストロークにパスを追加する。1つのストロークを描画するときは、透明レイヤーの内側でそのインクと自身の消去を描画する。

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 はデスティネーションを保持し、ソースのアルファに比例してそれを取り除く。

透明レイヤーの内側ではデスティネーションはその1つのストロークなので、この操作が紙や下にあるストローク、後から描くストロークに届くことはない。消去パスを持たないストロークではレイヤーのコストはゼロだ。ほとんどすべてがそうで、それらには元のパスが使われる。

所有権によって2つの失敗に直接答えられる。平行移動では消去パスもサンプル点と一緒に移るため、移動したストロークは隙間も運ぶ。選択オーバーレイは同じストロークオブジェクトを描画するので、選択されたストロークにも同じ隙間が現れる。消去は自身のストロークにしか適用されないため、レイヤー順も関係なくなる。

取り消しはジェスチャー単位で動作する。1つのエントリには、そのジェスチャーが生成したすべての (stroke, path) ペアが列挙され、取り消しはそれらのパスを取り除き、やり直しは再び追加する。消去の下にあるインクは一度も編集されないので、何かを再構築する必要はない。

ライブフィードバックはタイルビットマップを通る

描画中のストロークは通常、合成済みページの上のオーバーレイとして描画される。そこでは消去できない。オーバーレイは紙にもインクにも重なっており、destination-out は紙まで打ち抜いてしまうからだ。

そのため消しゴムはストロークへ即座に書き込み、新しいサンプルの各バッチが影響を受けた矩形だけを局所的に再生する。タイルパイプラインはすでに矩形をクリアし、その矩形と交差するストロークを再生している。消しゴムはその経路を再利用し、2つ目の描画セマンティクスを追加しない。

2つの消しゴムを並べる

ツールバーには、隠れたモードを持つ1つのボタンではなく、2つの消しゴムボタンがある。部分消しゴムはストロークの一部を取り除く。ストローク消しゴムは触れたストローク全体を削除する。半径は別々の定数で、どちらも現在は4ポイントだ。

ストローク全体を削除すると、そのストロークの一部である消去パスも一緒に削除される。取り消しでは両方が一緒に戻る。消去パスも保存ファイルの一部であり、他のものと同様に量子化される。半径と座標はポイントの10分の1単位だ。

1つの制限が残っている。完全に消去され、何も残っていないストロークもデータとしては存在し続ける。描画結果は何もないが、投げ縄で選択でき、ストローク消しゴムで削除もでき、ドキュメント内の領域も占有する。完全に消去されたストロークを収集するにはカバレッジテストが必要だが、まだ実装していないので、いまは残している。

別のプロジェクトのモデルが自分のプロジェクトで機能しなくなったとき、違いはたいていコピーしたコードにはない。そのプロジェクトに存在しない機能にある。