ベジェ曲線のオフセットを計算する
年の瀬ですね。Adobe Illustrator に代表されるドローイングソフトウェアでは、「パスのオフセット」「分割・拡張」等の機能を用いて、ベジェ曲線にオフセットを与えた後、変形後のパスを取得することができます。この機能を用いることで、太さを持たない線に対して太さを与えたり、既存のフォントを太らせたりすること(疑似ボールド)が可能となります。
オフセット曲線は平行曲線(Parallel curve)と呼ばれることもあり、ある曲線に対して垂直方向に一定距離で存在する曲線を指します。平行曲線は単なる平行移動で求めることはできないため、座標を求めるための様々な手法が提案されています。本記事ではその中でも実装が容易な Tiller, Hanson ら[1]が提案したアルゴリズムを用いて、3 次ベジェ曲線のオフセットを求める処理を TypeScript を用いて実装します。
文字の骨格に対してオフセットを求め、文字を縁取って SVG で表示したサンプル
ベジェ曲線
3 次ベジェ曲線は、制御点
Tiller, Hanson の アルゴリズムでは、以下の手順で平行曲線を求めます。
- 移動前のハンドル間に直線
を引き、その直線を自身に対する法線方向にl 移動するd - アンカーポイントを曲線に対して法線方向に
移動する。ハンドルも同時に移動するd - 移動後のアンカーポイントとハンドルに直線
を引くl1, l2 -
ならびにl, l1 の交点を新たなハンドルの座標とするl, l2
実装
TypeScript を用いて実装します。SVG や Canvas への描画工程に関しては省略します。
ここでは、3次ベジェ曲線を簡素に扱うために、予め Arc 型を以下の通り定義します。Arc は 4 つの制御点 p0
, p1
, p2
, p3
から構成されます。
interface Point {
x: number;
y: number;
}
export interface Arc {
p0: Point; // 始点のアンカーポイント
p1: Point; // 始点から生えるハンドル
p2: Point; // 終点から生えるハンドル
p3: Point; // 終点のアンカーポイント
}
法線を求める
アンカーポイントを移動するために、ベジェ曲線の位置
先述したベジェ曲線の式は、微分すると t における接線の式
const getArcNormal = (arc: Arc, t: number): Point => {
const differentiate = (p0: number, p1: number, p2: number, p3: number) =>
-3 * (1 - t) ** 2 * p0 +
(9 * t ** 2 - 12 * t + 3) * p1 +
(-9 * t ** 2 + 6 * t) * p2 +
3 * t ** 2 * p3;
const dx = differentiate(arc.p0.x, arc.p1.x, arc.p2.x, arc.p3.x);
const dy = differentiate(arc.p0.y, arc.p1.y, arc.p2.y, arc.p3.y);
return { x: dy, y: dx };
};
アンカーポイントを求める
アンカーポイントにおける法線は、getArcNormal を t=0, 1(始点、終点)で呼び出すと求まります。この値を三角関数に与えて、始点・終点を法線方向に d 動かした際の移動距離 fromDelta
, toDelta
を求め、それぞれを arc.p0
, arc.p3
に加えた値を新たに p0
, p3
と定義します。
const movePoint = (point: Point, delta: Point): Point => ({
x: point.x + delta.x,
y: point.y + delta.y,
});
const n0 = getArcNormal(arc, 0);
const n1 = getArcNormal(arc, 1);
const getDelta = (n: Point) => ({
x: Math.cos(Math.atan2(n.y, n.x)) * d,
y: Math.sin(Math.atan2(n.y, n.x)) * -d,
});
const fromDelta = getDelta(n0);
const toDelta = getDelta(n1);
const p0 = movePoint(arc.p0, fromDelta);
const p3 = movePoint(arc.p3, toDelta);
ハンドルを求める
続いてハンドルの座標を求めていきます。初めに、2 つのハンドルを通る直線 handleLine
を取得し、自身に対する法線方向に d だけ移動します。
const handleLine = getLine(arc.p1, arc.p2);
// 法線方向に d 移動する
if (handleLine.type === "vertical") {
handleLine.x += d;
}
if (handleLine.type === "normal") {
const y = arc.p1.y > arc.p2.y ? -1 : 1;
const tan = Math.atan2((1 / handleLine.a) * y, 1);
handleLine.b += d / -Math.sin(tan);
}
p0
, arc.p1 + fromDelta
を通る直線 l1
、p3
, arc.p2 + toDelta
を通る直線 l2
を求めます。その結果、handleLine
, l1
の交点が新たな 1 つ目のハンドル p1
になります。同様に、handleLine
, l2
の交点は p2
となります。かくして、arc
を d 移動させた平行曲線 newArc
が求まりました。
const l1 = getLine(p0, movePoint(arc.p1, fromDelta));
const l2 = getLine(p3, movePoint(arc.p2, toDelta));
const newArc = {
p0,
p1: getIntersection(handleLine, l1)!,
p2: getIntersection(handleLine, l2)!,
p3,
};
なお、途中で登場した Line
, getLine
, getIntersection
の実装は以下の通りです。
Line, getLine, getIntersection の実装
type Line = {
// y = ax + b
type: "normal";
a: number;
b: number;
} | {
// y 軸に平行
type: "vertical";
x: number;
};
// 2 点を通る直線の式を求める
const getLine = (p0: Point, p1: Point): Line => {
const dx = p1.x - p0.x;
// y 軸に平行な場合
if (dx === 0) {
return { type: "vertical", x: p0.x };
}
const a = (p1.y - p0.y) / dx;
const b = -a * p0.x + p0.y;
return { type: "normal", a, b };
};
// 2 直線の交点を求める
const getIntersection = (line0: Line, line1: Line): Point | null => {
// 両方の直線が垂直な場合、交点は存在しない
if (line0.type === "vertical" && line1.type === "vertical") {
return null;
}
// 一方の直線が垂直である場合
if (line0.type === "vertical" && line1.type === "normal") {
return { x: line0.x, y: line1.a * line0.x + line1.b };
}
if (line0.type === "normal" && line1.type === "vertical") {
return { x: line1.x, y: line0.a * line1.x + line0.b };
}
if (line0.type === "normal" && line1.type === "normal") {
// 両方の直線が水平な場合、交点は存在しない
if (line0.a === 0 && line1.a === 0) {
return null;
}
// 一方の直線が水平である場合
if (line0.a === Infinity) {
return { x: line1.b - line0.b / line0.a, y: line1.b };
}
if (line1.a === Infinity) {
return { x: line0.b - line1.b / line1.a, y: line0.b };
}
const x = (line1.b - line0.b) / (line0.a - line1.a);
return { x, y: line0.a * x + line0.b };
}
return null;
};
冒頭に示した図のように、線の骨格に対して太さ
追記(2023/12/16)
Bezier.js の offset(d)
を用いると、上記の計算を経ずとも精度の高い平行曲線を求めることが可能です。
Discussion