PerfectVector
著者: Irene Kim

SVGのハッチング:ペンプロッター用の実パスを作る

閉じたSVG領域から開いたハッチングパスを生成します。独自のPaper.js例で、穴、間隔、角度、保存ファイルの確認を再現します。

4 分で読める
目次

ペンプロッター向けにSVGをハッチングで塗るには、意図した閉じた領域の中に開いた線を生成し、境界で分割して、残った線分を形状として書き出します。機械のジョブを準備する前に、保存したファイルで穴を横切る経路や形の外へ出る経路がないか確認します。

色付きの塗りや縞模様のプレビューだけでは、その描画経路は得られません。このチュートリアルでは、長方形の穴を持つ独自の五角形エンブレムを作り、Paper.jsで開いた線とその領域の交差を求め、保存したSVGを開き直して調べます。確認できるのはデジタルの中心線形状であり、実物のプロットの成功ではありません。

1. 取り戻す線ではなく、塗る領域を選ぶ

穴が形状の一部になっている閉じた領域が必要です。ベタの形の上に白い長方形を置くと、穴に見えても下の領域は残ったままかもしれません。GRBL-Plotterのハッチング塗りの文書は、重なったオブジェクトと1つのパス内の穴の違いを示しています。ここではGRBL-Plotterを実行していません。

太いラスター線の中央に1本のストロークを取り出したい場合は、単線SVGのガイドを使います。ハッチングは領域全体に複数の線を作るもので、元のペンの中心線を復元するものではありません。

塗りのある元データのコピーを残します。この例は、輪郭を描く走行を追加せず、ハッチング経路だけを意図的に生成します。外側の境界や穴の境界も描きたいなら、それらを別の意図した経路として準備します。

2. クリップした見た目と切り詰めたパスを区別する

SVGのパターンは、繰り返すタイルを描きます。クリッピングパスは、基になる形状を変えずに描画領域を制限します。どちらも画面上では有用ですが、その見た目をペンの経路にするには、アプリ側に変換方法が必要です。

クリップだけの対照例は、各200ユーザー単位の水平線を10本保存しています。クリップで外側の部分と長方形の穴を隠しますが、保存した線の長さの合計は2,000単位のままです。

切り詰めた粗い間隔の書き出しには、合計1,320単位の独立した開いたパスが14本あります。クリッピングパス、パターン、マスク、埋め込み画像、再利用シンボルはありません。ハッチングの4行は、穴の両側で2本ずつのパスに分かれます。クリップ版と切り詰め版のプレビューが似ていても、この違いは保存したSVGで確認できます。

実際に開き直したSVGの比較。クリップだけのハッチング表示と、2つの間隔および30度の角度で作った明示的な開いたハッチングパスを示す
独自のデジタルサンプルと実際のPaper.js書き出しです。3つの経路ファイルすべてで穴は開いています。検証対象は中心線であり、プロッターや実物のペンでのテストは行っていません。

繰り返す描画そのものを出力したい場合は、SVGパターンの単位ガイドを参照してください。ここで必要なのは、明示的な開いた形状です。

3. 完全なPaper.js例を実行する

ブラウザー、テキストエディター、公式リリースのPaper.js 0.12.18の完全版を使います。paper-full.min.js を library フォルダーに置きます。そのフォルダーの隣に、以下のHTMLを generate.html、続くJavaScriptを hatch.js として保存します。独自のエンブレムもコードに含まれています。

<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Generate original hatch routes</title>
<canvas id="work" width="200" height="140" hidden></canvas>
<p id="status">Running</p>
<pre id="export-receipt"></pre>
<h2>0 degrees, 10-unit spacing</h2>
<img id="preview-coarse" width="400" alt="Coarse hatch">
<pre id="svg-coarse"></pre>
<h2>0 degrees, 5-unit spacing</h2>
<img id="preview-dense" width="400" alt="Dense hatch">
<pre id="svg-dense"></pre>
<h2>30 degrees, 10-unit spacing</h2>
<img id="preview-angled" width="400" alt="Angled hatch">
<pre id="svg-angled"></pre>
<script src="library/paper-full.min.js"></script>
<script src="hatch.js"></script>
</html>
paper.setup(document.getElementById('work'));
 
// Original area: a five-sided boundary with a rectangular hole.
const areaData = 'M20 20 L140 20 L180 60 L180 120 L20 120 Z ' +
  'M60 50 L60 90 L110 90 L110 50 Z';
const configurations = [
  { id: 'coarse', angle: 0, spacing: 10 },
  { id: 'dense', angle: 0, spacing: 5 },
  { id: 'angled', angle: 30, spacing: 10 }
];
 
function makeHatch(angle, spacing) {
  const area = new paper.CompoundPath({
    pathData: areaData,
    fillRule: 'nonzero',
    insert: false
  });
  const radians = angle * Math.PI / 180;
  const direction = new paper.Point(Math.cos(radians), Math.sin(radians));
  const normal = new paper.Point(-direction.y, direction.x);
  const center = new paper.Point(100, 70);
  const delivery = new paper.Group({ insert: false });
 
  for (let offset = -200 + spacing / 2; offset < 200; offset += spacing) {
    const origin = center.add(normal.multiply(offset));
    const line = new paper.Path.Line({
      from: origin.subtract(direction.multiply(300)),
      to: origin.add(direction.multiply(300)),
      insert: false
    });
    const kept = line.intersect(area, { trace: false, insert: false });
    const parts = kept instanceof paper.CompoundPath ? [...kept.children] : [kept];
    const routes = parts.filter(path => path.length > 1e-7);
    for (const path of routes) {
      if (path.closed || path.segments.length !== 2) {
        throw new Error('Expected one open straight segment');
      }
      if (path.firstSegment.point.dot(direction) > path.lastSegment.point.dot(direction)) {
        path.reverse();
      }
    }
    routes.sort((a, b) => a.firstSegment.point.dot(direction) - b.firstSegment.point.dot(direction));
    for (const path of routes) {
      path.fillColor = null;
      path.strokeColor = '#2f6977';
      path.strokeWidth = 1;
      path.strokeCap = 'butt';
      delivery.addChild(path);
    }
    if (kept instanceof paper.CompoundPath || routes.length === 0) {
      kept.remove();
    }
    line.remove();
  }
  area.remove();
  const geometry = delivery.exportSVG({ asString: true, precision: 6 });
  const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="100mm" height="70mm" ' +
    'viewBox="0 0 200 140">' + geometry + '</svg>';
  const routeCount = delivery.children.length;
  const drawnLength = delivery.children.reduce((sum, path) => sum + path.length, 0);
  let transitionLength = 0;
  for (let index = 1; index < routeCount; index += 1) {
    transitionLength += delivery.children[index - 1].lastSegment.point
      .getDistance(delivery.children[index].firstSegment.point);
  }
  delivery.remove();
  return { svg, routeCount, drawnLength, transitionLength };
}
 
const receipt = { paper: paper.version, units: 'viewBox user units; 0.5 mm per unit', rows: [] };
for (const configuration of configurations) {
  const result = makeHatch(configuration.angle, configuration.spacing);
  document.getElementById('svg-' + configuration.id).textContent = result.svg;
  const preview = document.getElementById('preview-' + configuration.id);
  preview.src = URL.createObjectURL(new Blob([result.svg], { type: 'image/svg+xml' }));
  receipt.rows.push({ ...configuration, route_count: result.routeCount,
    drawn_length: result.drawnLength, transition_length: result.transitionLength });
}
document.getElementById('export-receipt').textContent = JSON.stringify(receipt, null, 2);
document.getElementById('status').textContent = 'Generated. Save each exact SVG shown below, then reopen those saved files for independent checks.';

ブラウザーで generate.html を開きます。検証ではChromium 155でPaper.js 0.12.18を使いました。各結果にプレビューと正確なSVGマークアップが表示されます。3つの完全なSVG文字列を hatch-coarse.svg、hatch-dense.svg、hatch-angled.svg にコピーし、それぞれの保存ファイルを個別に開きます。

重要な呼び出しは line.intersect(area, { trace: false, insert: false }) です。Paper.jsは trace: false を、最初のパスを交点で分け、2つ目のパスの領域内にある曲線を残すものとして説明しています。これにより、開いたハッチング線分を保ちます。既定の領域トレースモードは別のブール演算の問いに答えるもので、Paper.jsの塗り付き図形ガイドがその手順を扱います。

関数は fillColor をnullにし、各経路にストロークを与えます。書き出し前に、2点からなる開いた結果であることも確認します。閉じた領域は作成用の補助にとどめ、納品グループには含めません。

4. 間隔・角度・実物の縮尺を一緒に確認する

3つの書き出しはすべて width="100mm"、height="70mm"、viewBox="0 0 200 140" を宣言します。SVG内の1ユーザー単位は0.5 mmに対応します。そのため、10単位のハッチング間隔は5 mm、5単位は2.5 mmです。

これらの例の間隔は、各経路を調べやすいよう意図的に広くしています。インクで隙間なく塗るための推奨値ではありません。画面上のストローク幅は1ユーザー単位ですが、その見た目は実際の素材上のペン跡の測定にはなりません。

オフセットのループは、ハッチングの位相を間隔の半分に固定します。位相を変えると、線が境界のどの特徴に当たるかも変わる場合があります。このSVG座標系では、正の30°は右下へ向かいます。間隔は画面上の縦方向の距離ではなく、平行な線に垂直な方向で測ります。

保存ファイル角度間隔開いた経路数描いた中心線の長さ
hatch-coarse.svg0°10単位 / 5 mm141,320単位 / 660 mm
hatch-dense.svg0°5単位 / 2.5 mm282,640単位 / 1,320 mm
hatch-angled.svg30°10単位 / 5 mm20約1,318.564単位 / 659.282 mm

予想される直線と多角形の交点を独立に計算し、0.000002ユーザー単位の許容差で保存座標と比べました。すべての書き出し経路は、想定した端点と一致しました。各経路は開いており、サンプルを取った中心線の点は、許容差を超えて穴や外部を横切りませんでした。保存した3つのSVGすべてで、指定した実物サイズとの対応が保たれました。

別の形では、このサンプルの本数がそのまま当てはまると考えず、実際に書き出した経路を確認してください。曲線、極端に小さな隙間、自己交差、変形したグループ、任意の読み込みSVG文書は、この例の検証範囲外です。

5. 経路間の隙間と最適化した移動計画を分ける

粗い間隔のファイルには14本の開いた経路があり、保存順で隣り合う経路の間には13か所の隙間があります。ペンを下ろしたままそこをつなぐと、不要な線が付きます。描く前に、プロット手順で移動を意図どおりに扱う必要があります。

スクリプトは単純な一方向の順序を保ち、ハッチングのオフセットを順にたどり、すべての線分を同じ方向へ進みます。各経路の終点から次の始点までの直線距離を合計すると、粗いファイルでは約1,562.997ユーザー単位になります。密なファイルは27か所の隙間を持ち、接続距離の合計は約3,281.578単位です。

この合計には、最初の接近と最後の戻りを含みません。この順序による幾何学的な結果であり、機械の実測移動距離、時間見積もり、最適化した計画ではありません。プロットアプリが経路を反転・並べ替えする場合もあります。間隔を半分にすると、このサンプルの描画長と経路数は2倍になりましたが、ジョブ時間が常に2倍になると分かったわけではありません。

プロッターを制御するアプリでファイルを開き直し、寸法を確認して、経路プレビューとペンを上げる動作を調べます。自分の環境で、ペン、素材、間隔、境界からの余白、移動設定をテストしてください。今回はプロッターのジョブ実行、機械コード生成、Cricut Draw/Attachの検証はしていません。

よくある形状の不具合を直す

症状確認・変更すること
ハッチングが意図した穴を横切る塗り領域を調べる。交差を求める前に、白い重ね合わせを複合形状の実際の穴に置き換える。
プレビューは正しいが全長の線が残るクリップやパターンへの依存を探す。描画されたシルエットに頼らず、保存した経路の座標を調べる。
線が閉じた形になる最初の対象が開いた線で、trace: false があるか確認する。この例は閉じた結果や複数線分の結果を拒否する。
インクが境界の外へはみ出す中心線とストロークが占める面を区別する。この例はペン先の半径分だけ領域を内側に狭めていないため、適切な余白を準備して実際の道具で試す。
塗りが疎ら、または表面が濡れすぎる条件を揃えたペンと素材のサンプルで調整する。デジタルの間隔だけでは塗りの密度や乾燥は分からない。
ファイルが予想外の飛び方をする使用先ソフトで順序とペン上げ移動を確認する。最適化の前に元の経路を残し、穴と境界を再確認する。

元データがフラットなラスターのシルエットだけなら、PerfectVectorのクリップアートからSVGへの手順で、まず閉じたデザインを取り出せます。輪郭とすべての穴を確認し、採用した形状からハッチング経路を生成します。きれいな既存のベクターデータは、そのままハッチングの準備へ進めます。

シルエットのベクター化とハッチング経路の生成は、別の工程です。この記事の独自のエンブレムはベクター形状として作ったもので、PerfectVectorの変換例ではありません。

よくある質問

SVGの塗りは自動的にペンのハッチングになりますか? いいえ。塗りは描画上の見た目を示します。プロット手順には、その領域から描くストロークを作るためのハッチング経路か、他の対応する方法が必要です。

ハッチングのプレビューに、穴を通る線が残るのはなぜですか? クリップは、保存した形状を切り詰めずに、全長の線の一部を隠せます。保存座標を確認し、明示的な経路を生成する前に、穴を実際の領域の一部にしてください。

どのハッチング間隔を使うべきですか? 予定する縮尺で、ペンと素材のテストをして選びます。このデジタル例は経路を見やすくするため5 mmと2.5 mmの間隔を使っていますが、隙間のない塗りや共通のペン設定を確立するものではありません。

これは1本の連続したパスですか? いいえ。粗い結果には14本の独立した開いた経路があります。穴が4行を分割し、描画の工程で経路間の隙間を扱い、不要な線を付けないようにする必要があります。

出典

  1. Paper.jsのPathItemの交差 — trace falseによる開いたパスの分割動作を説明。
  2. SVGのパターン — 明示的な経路形状とは別に、繰り返す描画を定義。
  3. CSSのマスクとクリッピングパス — 基になる形を切り詰めずに、クリップが描画を変える理由を説明。
  4. GRBL-Plotterのハッチング塗り — 重ねたオブジェクトと、1つのパス内の穴の違いを示す。
  5. Paper.js 0.12.18のリリース — 実行可能な例に使ったライブラリの版を特定。

ラスターしかないシルエットに編集可能な境界が必要なら、クリップアートのSVG候補を準備し、穴を調べ、ハッチング経路を別に生成・検証してから実物のジョブを試してください。

ブログのその他の記事

PerfectVector

カットや制作に使える
きれいなSVGパスを準備

クレジットカード不要