固定形状を経路に沿って動かして幅広ペンのストロークを作る方法
幅広ペンのストロークを作るには、細長い長方形のペン先を定義し、その角度を一定に保ちながら経路に沿って移動させます。ストロークは、その長方形が占めるすべての位置の和集合です。ペン先自体の大きさは変わりませんが、移動方向によってストロークの太さは変化します。
このガイドでは、ベクター描画や文字のアクセントに使う形状の作り方を説明します。ペン先を定義し、折れ線に沿って連続的に移動させて、完全な Python プログラムで SVG を生成します。この基本的な構成では、ブラシを個別のスタンプで再現したときに生じる隙間を防げます。
固定形状を動かすとはどういう意味ですか?
描画を2つの入力に分けます。1つはペン先の基準点が移動する位置を示す経路、もう1つはペン先を1か所に置いたときに覆う領域を表す形状です。形状の基準点を原点に置きます。経路上の各位置について、その位置を形状内のすべての点に加えます。
数学的には、得られる集合は S = {p + n | p belongs to P, n belongs to N} です。ここで P は経路、N はペン先です。これは、CGAL 2D Minkowski Sums ユーザーマニュアルにある点ごとの定義に従ったミンコフスキー和です。
この構成で「固定」とは、形状とその向きの両方が描画座標上で一定に保たれることを意味します。ペン先は経路に合わせて回転せず、平行移動します。基準点を長方形の中心に置くと、経路が移動するペン先の中心を示します。
ペン先の角度と移動方向は、どのように太さを決めますか?
長方形の長辺 w、短辺 h、長辺の角度 α の3つのパラメーターを選びます。小さな正の h を指定すると、ペン先に目に見える最小の太さが生まれます。一方、理想的な線分状のペン先は太さがゼロで、その向きとまったく同じ方向に動かすと、面積のない跡しか残らないことがあります。
方向角 θ の直線経路について、移動方向に垂直に測った長方形のペン先の幅は次のとおりです。
width across the stroke = w × |sin(θ − α)| + h × |cos(θ − α)|
この式は、長方形の互いに垂直な2辺を経路の法線に投影して得られます。直線区間、または滑らかな曲線に沿った局所的な形状の投影を表します。曲線ストロークの重なり合う部分によって、全体のシルエットがさらに広くなることがあります。
例として、24単位×3単位のペン先を考えます。長辺と平行に移動させると幅3単位の帯になり、垂直に移動させると幅24単位の帯になります。45°の角度差がある場合、幅はおよそ (24 + 3) / √2、つまり19.09単位です。これらは計算による寸法であり、ブラシの実測結果ではありません。
複雑な曲線を描く前に、これら3つの方向で調整テストを行います。複数の曲がりが重なった形を読み解かなくても、角度と寸法が意図どおりに働くか確認できます。
スタンプの隙間を作らずに連続的に動かすには?
凸形状のペン先を1本の直線区間に沿って動かす場合、両端にペン先を配置し、その2つの位置にある頂点を合わせた集合の凸包を求めます。この凸包は、その区間に沿って平行移動したときに掃かれる領域と正確に一致します。
理由は単純です。途中の配置はすべて、両端の配置を線形補間したものです。ペン先が凸形状なので、凸包にはその補間位置が過不足なく含まれます。この区間ごとの構成はミンコフスキー和の定義から直接導かれるもので、独立したブラシ機能ではありません。
長方形の場合、各区間で必要な候補点は8つだけです。始点の4つの角と終点の4つの角です。その凸包を計算し、経路上の連続する点のすべての組について繰り返し、得られたポリゴンをまとめます。隣接する掃引領域は共通の端点でペン先の位置全体を共有するため、形状は途切れなく覆われます。
経路全体を囲む1つの凸包を計算してはいけません。曲がりの内側が塗りつぶされ、意図したくぼみが消えてしまいます。凸包は個々の区間ごとに求め、ストローク全体はそれらの和集合にします。
凹形状のペン先には別の処理が必要です。凸包を取ると、くぼみが塗りつぶされます。形状を凸部分に分解し、それぞれを掃引して、結果をまとめてください。CGAL のドキュメントでは、凹多角形のミンコフスキー和に関連する分解と和集合の手法が説明されています。
Python で掃引を実装するには?
次の完全なプログラムは Python 3 と標準ライブラリだけを必要とします。5点からなるサンプル経路を定義し、長方形のペン先を作成して、4つの塗りつぶされた掃引ポリゴンを含む SVG ドキュメントを出力します。broad_edge.py という名前で保存し、python broad_edge.py > stroke.svg を実行して描画を作成します。
ここで使う座標は SVG の規則に従い、x は右方向、y は下方向に増加します。そのため、このプログラムでは正の角度を指定すると、画面上でペン先が時計回りに傾きます。他の描画インターフェースでは、角度の規則が異なる場合があります。
from math import cos, sin, radians
def hull(points):
pts = sorted(set(points))
if len(pts) <= 1:
return pts
def cross(o, a, b):
return ((a[0] - o[0]) * (b[1] - o[1])
- (a[1] - o[1]) * (b[0] - o[0]))
def half(sequence):
out = []
for p in sequence:
while len(out) >= 2 and cross(out[-2], out[-1], p) <= 0:
out.pop()
out.append(p)
return out
return half(pts)[:-1] + half(reversed(pts))[:-1]
def sweep(path, width=24, thickness=3, angle=30):
if not path or width <= 0 or thickness <= 0:
raise ValueError(
'Provide a nonempty path and positive nib dimensions'
)
a = radians(angle)
u = (cos(a), sin(a))
v = (-sin(a), cos(a))
nib = [
(s * width * u[0] / 2 + t * thickness * v[0] / 2,
s * width * u[1] / 2 + t * thickness * v[1] / 2)
for s, t in [(-1, -1), (1, -1), (1, 1), (-1, 1)]
]
pairs = list(zip(path, path[1:])) or [(path[0], path[0])]
return [
hull([(p[0] + q[0], p[1] + q[1])
for p in (start, end) for q in nib])
for start, end in pairs
]
path = [(40, 45), (85, 125), (140, 150), (195, 110), (245, 45)]
polygons = sweep(path)
all_points = [p for polygon in polygons for p in polygon]
x0, y0 = (min(p[i] for p in all_points) - 5 for i in (0, 1))
x1, y1 = (max(p[i] for p in all_points) + 5 for i in (0, 1))
svg = [
'<svg xmlns="http://www.w3.org/2000/svg" '
f'viewBox="{x0} {y0} {x1 - x0} {y1 - y0}">',
'<g fill="#23364d" stroke="none">'
]
for polygon in polygons:
coordinates = ' '.join(f'{x:.6f},{y:.6f}' for x, y in polygon)
svg.append(f'<polygon points="{coordinates}"/>')
svg.extend(['</g>', '</svg>'])
print('\n'.join(svg))
プログラムは経路が1点だけの場合、そのペン先の形状を描画します。連続して同じ点が並ぶ場合も、方向の計算は必要なく、形状が描画されます。空の経路や、0以下のペン先寸法はエラーになります。有限の数値からなる座標ペアを指定してください。この簡潔な例では、読み込んだ経路データの検証は行いません。
サンプル座標を使ってこの構成を実行したところ、6頂点の掃引ポリゴンが4つ生成され、生成された SVG は XML として正しく解析されました。1点だけの経路と、同じ点が繰り返されるケースも確認しました。これらの確認で検証できるのは構成とドキュメントの構造であり、実際に表示した結果のテストではありません。
曲線、角、端点はどのように扱えばよいですか?
浮動小数点演算と座標の丸め誤差を除けば、この例は折れ線に対して正確です。ベジェ曲線に使うには、まず直線区間で近似し、同じ方法で掃引します。曲線パラメーター全体に均等にサンプルを配置するのではなく、曲がりが急な部分で区間を増やします。
近似の許容誤差は、予定している出力サイズに合わせて選びます。たとえば、表示上の目標として、ずれをまず4分の1ピクセル以内に設定し、その後、予定している最大倍率で確認できます。これは設計上の目安であり、普遍的な品質基準ではありません。また、すでに直線である区間に分割点を追加しても、掃引領域は変わりません。
ペンを離した別々の経路は分けたままにします。つながっていない2つのストロークの点を1回の呼び出しに渡すと、不要な接続部分が掃引されます。ストロークごとに別々に生成し、出力にポリゴンをまとめます。
端点でも、ペン先は形状全体を保ちます。角では、隣り合う2つの掃引領域が重なります。このモデルには追加のマイター結合や丸い端点はありません。SVG の通常のストローク属性では、端点と結合部が独自の方法で構築されるため、それらを加えると、この固定ペン先の掃引を超える形状が導入されます。SVG 2 におけるストロークの端点と結合部の定義を参照してください。
ポリゴンを結合する必要があるのはどんなときですか?
このプログラムは、重なり合う塗りつぶしポリゴンを出力します。単色のプレビューなら、ポリゴン結合ライブラリを使わなくても、これらで掃引領域を表現できます。ただし、個別に編集可能なオブジェクトのままであり、プログラムが1つに統合された境界を計算するわけではありません。
輪郭の編集に使えるきれいなシルエットが必要なら、すべての区間ポリゴンのブール和を計算します。結果に穴が残る場合は、その穴を保持してください。ループする経路は、塗られていない領域を囲むことがあります。囲まれた輪郭をすべて塗りつぶすと、ストロークの形が変わってしまいます。
透明度も意識して扱う必要があります。重なり合うポリゴンごとに部分的な fill-opacity を適用すると、重なった領域が濃くなることがあります。透過度を均一にするには、全体を囲むグループに不透明度を適用するか、塗りの不透明度を設定する前に形状を結合します。SVG の描画仕様では、オブジェクトの塗りの不透明度とグループの不透明度が区別されています。
| 目的 | 適切な次の手順 |
|---|---|
| 単色のプレビュー | 塗りつぶした掃引ポリゴンをそのまま使う |
| 均一な透過プレビュー | 全体を囲むグループに不透明度を適用する |
| 編集可能な単一のシルエット | 掃引ポリゴンのブール和を計算する |
| カスタムの凹形ペン先 | 掃引前にペン先を分解する |
| 滑らかな曲線ストローク | 曲線を近似してから区間を掃引する |
予想外のストロークをどう診断しますか?
まず調整用の方向で確認し、次に経路と出力の構成を調べます。目に見える変化の原因を把握できるよう、入力は一度に1つだけ変更してください。
- 太さがほとんど一定のまま: ペン先が経路に合わせて回転していないか確認します。描画座標上で角度を一定に保つ必要があります。
- 跡の間に隙間が見える: 個別のスタンプを、区間ごとの凸包による構成に置き換えます。
- 曲がりの内側が大きなくさび形に塗られる: 複数の経路区間をまとめて凸包にしていないか確認します。
- 端が丸く見える: 通常のストロークが追加されていないか、別のペン先形状が使われていないか確認します。
- 滑らかな元の曲線が角張って見える: 曲がり部分の折れ線近似を細かくします。
- 予想外のつなぎ目が現れる: つながっていない経路を別々の掃引呼び出しに分けます。
結果を Inkscape の「カリグラフィ」ツールと比較する場合は、ドキュメントにある設定が参考になります。Fixation 100 は角度を固定し、Thinning 0 は速度に基づく幅の変化をなくし、筆圧や傾きの設定は幅と角度を変化させます。固定ペン先の動きを調べるときは、こうした変化をもたらす入力を無効にしてください。これらの設定はツールの操作項目を説明するもので、この長方形を使った構成と同じ出力が得られることを示すものではありません。Inkscape カリグラフィツールバーのドキュメントを参照してください。
信頼できる幅広ペンのストロークは、単純な調整用経路と明確に定義された形状から始まります。平行、垂直、斜めのテストが正しく動作したら、同じ区間掃引を選んだ経路に適用し、予定する出力サイズに合わせて曲線部分を細かくし、次の編集作業で単一のシルエットが必要ならポリゴンを結合します。
