2024-06-07 ,

複数サブパス(複合パス)への対応

別のプロジェクトで簡単な絵が必要になったので自作の作図ツールを使って描いていたら色々不満があったので最近はちょくちょくいじっていました。そのプロジェクトはそっちのけで(笑)。

misohena/el-easydraw: Embedded drawing tool for Emacs

特に複数のサブパスを含むパスへの対応をいい加減やらなきゃな、と。

複数のサブパスとは

複数のサブパスというのは、一つのパスデータ(path要素のd属性:<path d=>)の中に複数の内部的なパス(サブパス)が表現されているような状況のことです。例えば <path d="M0,-100 L100,100 L-100,100 Z M0,-50 L-50,50 L50,50 Z" /> と書くと一つのpath要素で大小二つの三角形が表現できます。実際に表示してみるとこんな感じになります。

図1: d="M0,-100 L100,100 L-100,100 Z M0,-50 L-50,50 L50,50 Z"

複数のパスを表現したいなら複数のpath要素を使えばいいじゃないかと思うかもしれませんが、上のような「穴あき」を表現するには一つのパスデータの中の複数のパス(サブパス)が必要になります。複数のpath要素を重ねて配置しただけでは塗りつぶした大きな三角形の上に小さな三角形を塗りつぶすことしか出来ません。一つのパスの中に大小の三角形が一緒に入っていてはじめてこのような「穴あき」が作れます。

一つのパスの中に二つのパスが並んでいるだけでなぜ中が抜けるんだろうかと疑問に思う方もいると思いますが、これはこの図形をレンダリングすることを考えれば分かります。レンダリングの基本は画像を上から下へ一行ずつ、そして一行の中を左から右へ1ピクセルずつ色を決めて点を打っていくことです。例えばこの図形の真ん中らへんの一行(1ライン)をレンダリングするとします。画像の左端から右端へ向かって1ピクセルごと処理していきます。このとき各線分(segment)との交差判定をしながら進んでいくことがポイントです。一番最初は図形の外なので点を打ちません。右へ向かっていくと、最初の線分を跨ぎます。なので、それ以降は灰色のfill色を打ちます。さらに進むとまた線分を跨ぎます。なので、それ以降は図形の外にあると認識して点を打ちません。さらに進むとまた線分を跨ぐ(奇数回目)ので色を塗り始めます。そしてまた線分を跨いだら(偶数回目)塗るのを止めます。と、このように図形の内外を判定しながらレンダリングしていくのですが、このアルゴリズムで真ん中に穴あきがあるドーナツ形を作るには外側の線も内側の線も同じ判定対象(線分集合)の中に存在していなければなりません。凹形なら一つのパスでも作れるのでその上側をくっつけてしまうという回避策もありますが、strokeを指定するとボロが出たりと問題もあります。

ということで、一つのパスの中に複数のパス(サブパス)が必要になるわけです。

対応してこなかった理由

これまで対応してこなかった理由はひとえにデータ構造の悪さからでした。SVGのd=属性を解析して内部的な表現に変換してから編集するのですが、その内部的な表現の構造が悪すぎました。その表現は、d属性を解析したほぼそのままのコマンドリストでした。なので、一つアンカーを打てばMコマンド(最初の点を指定する)、Lコマンド(直線)、Cコマンド(カーブ)、Zコマンド(閉じる)を前後の状況に応じて判別して追加するような複雑なことをしなければなりませんでした。もちろんその複雑さを緩和するような層があって、上の層は下の層に処理を投げるだけなのですが、複数のサブパスに対応するには下の層を改善しなければならず、そしてそれは複雑なのでやりたくなかったわけです。

この構造のまま複数のサブパスにも(ユーザーからの様々な操作に対してちゃんと)対応しようとすると細かい条件分けが複雑すぎて大変でした。実は実際最後まで書き切ったのですが、どこかにバグがあっても不思議ではない、これからの改善も全くしたくないようなコードにしかなりませんでした。ウンザリしてすぐにその辺りのを構造(edraw-path.el)を書き直すことにしました。

新しい構造と内部・外部表現の変換

新しい構造はパス(パスデータ)、サブパス、アンカー、ハンドルを素直に表現したものとなりました。これによって様々な処理が驚くほどシンプルに素直に書けるようになりました。

そして新しい構造とSVGのd属性(コマンド文字列)との間の変換処理も用意して、必要に応じて変換します。これは従来の構造(コマンドリスト)でもそのような作りになっていました。

ただし、内部的な構造と実際のd属性の文字列(外部表現)との変換は必要最小限にする必要があります。それはどちらの方向でも失う情報があるからです。

SVGのd属性で表現できないもの

内→外で失うものとしては、開サブパスの端点の外側のハンドルがあります。何を言っているのかよく分からないかもしれませんが、次図の通りです。

閉(サブ)パス開(サブ)パス端点端点の外側のハンドル端点を持たない
図2: 開サブパスの端点の外側のハンドル

つまり、線の端っこのさらに外にあるハンドルです。実際のところこれは描画には全く影響を及ぼしません。なのでSVGのd属性で表す方法がありません。しかし編集においては、その端点の次にアンカー点を打ったときに、その端点と新しい端点とを結ぶ曲線の曲がり具合に影響します。描画側から見たら「まだ存在しない曲線の属性なんて知らん」ですが、編集側から見たら「いやいや、線がその端点を通過したときの出て行く先を示すんだからその端点の属性だろ」というわけです。というわけで、編集用の内部表現をd属性へ変換するとそのハンドルは失われます。まぁ、独自の属性に持たせるといった方法もありますが。

新しい構造で表現できないもの

外→内で失うものとしては、各コマンドの細かいニュアンスだと思います。

SVGのパスデータには沢山のコマンドが用意されています。

M m Z z L l H h V v C c S s Q q T t A a

これだけのコマンドがあります(Paths ― SVG 2を参照のこと)。

これだけあると同じ形を描画するのにも沢山の表現方法があります。これらのコマンドの使い分けは、ほとんどの場合データサイズを削減することが目的だと思われますが、誰かがそこにそれ以外の意図を込めていないと言い切れるでしょうか。相対表現(小文字)は前の点からの相対関係を維持してほしいという意図があったり、水平線や垂直線はその性質を維持してほしいと考えてはいないでしょうか。Aコマンドは特に意図が現れやすいです。しかし内部表現に変換すれば、それは全て無味乾燥な三次ベジェ曲線のアンカーとハンドルに集約されてしまいます。

私が一番頭を悩ませていたのは一つのMコマンドが複数のサブパスで共有されうることです。例えば次のようなd属性があった場合:

M0,0 L40,-20 L40,20 Z L20,40 L-20,40 Z L-40,20 L-40,-20 Z L0,-40

(ちなみにこれは分かりやすくするためにあえて無駄な書き方をしていますが、実際には次のように書けます)

M0 0 40-20V20ZL20 40H-20ZL-40 20V-20ZV-40
図3: 一つのMコマンドが複数のサブパスで共有されている例

これは上のような図形ですが、中央の点、つまり0,0の表記はd属性中に一つしか現れていません。このd属性の中には4つのサブパスが含まれています。しかしその開始点は最初に現れる M0,0 ただ一つにまとめられています。

これは単なるケチ表現なのかもしれません。しかし同じ点になっていることには何か必然的な理由があるのかもしれません。その点をドラッグしたとき、4つ全てのサブパスの開始点が動いた方が親切かもしれません。

まぁ、そんなことを全てのコマンドに対してやっていたら大変なので諦めることにしたわけです。

そして今

今では上のようなデータも正しく編集できるようになりました。もちろんバラバラの4つのサブパスとしてです。

私が間違った設計判断をしてしまった原因はこの割り切りをためらったからかもしれません。SVGパスデータの元々の表現の性質をできるだけ残しておきたかったわけです。しかしすでに相対表現やら二次ベジェ、水平線、垂直線などは絶対表現のLとCに変換してしまっていましたから、今更なわけですが。

実はこの問題は作り始めのかなり初期から気がついていました。でも頑張れば何とかなるだろうと思っていたわけです。

私は近年山を歩くことがありますが、登山道を歩いていると不意に道がおかしいな? と気がつくことがあります。妙に道が荒れている、障害となる草木や岩石が多い、越えられないことはないが一般登山道のレベルとは思えない、など。もちろんそこまで至るまでには「分岐など見当たらなかった」「正しい道を進んでいる」と思っているものです。しかしそういった異変に気がついたら地図を確認してすぐに引き返すことです。頑張れば行ける。そう思って先に行くと取り返しの付かないことになるかもしれません。