〰️

ベジェ曲線のオフセットを計算する

2023/12/12に公開

年の瀬ですね。Adobe Illustrator に代表されるドローイングソフトウェアでは、「パスのオフセット」「分割・拡張」等の機能を用いて、ベジェ曲線にオフセットを与えた後、変形後のパスを取得することができます。この機能を用いることで、太さを持たない線に対して太さを与えたり、既存のフォントを太らせたりすること(疑似ボールド)が可能となります。

オフセット曲線は平行曲線(Parallel curve)と呼ばれることもあり、ある曲線に対して垂直方向に一定距離で存在する曲線を指します。平行曲線は単なる平行移動で求めることはできないため、座標を求めるための様々な手法が提案されています。本記事ではその中でも実装が容易な Tiller, Hanson ら[1]が提案したアルゴリズムを用いて、3 次ベジェ曲線のオフセットを求める処理を TypeScript を用いて実装します。


文字の骨格に対してオフセットを求め、文字を縁取って SVG で表示したサンプル

ベジェ曲線

3 次ベジェ曲線は、制御点 P_0, P_1, P_2, P_3 について、媒介変数 t を用いて以下の式 B(t) で表されます。以後、P_0 を始点のアンカーポイント、P_3 を終点のアンカーポイント、P_1, P_2 をハンドルと呼びます。

B(t) = (1-t)^3 P_0 + 3(1-t)^2t P_1 + 3(1-t)t^2 P_2 + t^3 P_3

Tiller, Hanson の アルゴリズムでは、以下の手順で平行曲線を求めます。

  1. 移動前のハンドル間に直線 l を引き、その直線を自身に対する法線方向に d 移動する
  2. アンカーポイントを曲線に対して法線方向に d 移動する。ハンドルも同時に移動する
  3. 移動後のアンカーポイントとハンドルに直線 l1, l2 を引く
  4. 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\ (0 \leq t \leq 1) における法線を求める関数 getArcNormal を定義します。

先述したベジェ曲線の式は、微分すると t における接線の式 B'(t)が得られます。このとき、B'(t) に垂直な線が t における法線となります。

B'(t) = 3(1-t)^2 (P_1 - P_0) + 6(1 - t)t(P_2 - P_1) + 3t^2(P_3 - P_2)
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 を通る直線 l1p3, 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;
};

冒頭に示した図のように、線の骨格に対して太さ w を与える場合は d=w/2, -w/n のときの平行曲線を求め、得られた 2 つの曲線を上手く繋げてあげると解決します。

追記(2023/12/16)
Bezier.jsoffset(d) を用いると、上記の計算を経ずとも精度の高い平行曲線を求めることが可能です。

参考文献

[1] Wayne Tiller, Eric Hanson: Offsets of Two-Dimensional Profiles, IEEE Computer Graphics and Applications vol. 4 Issue 9, pp 36–46, 1984.

Discussion