SVGだけでアイソメトリック図を描く: 投影・4階調の陰影・描画順の落とし穴
アイソメトリック図を SVG で書くと、こんな不具合に出会います。部品を「手前のものほど後に描く」順に並べたはずなのに、土台が最後に描かれて、タンクの下半分と配管が消える。
原因は 1 行のソートです。そして、この規模の図なら 3D エンジンは要りません。約 45 行の JavaScript と SVG の polygon だけで、台の上にタンク、配管、ポンプ、バルブが並ぶ機械の図が描けます。
平らな箱と矢印の図と違って、どの部品がどの部品の上にあり、どこでつながっているかが奥行きとして読めるので、ハードウェアやシステムの説明に向いています。仕上がりを大きく左右するのは、次の 3 つです。
1. 投影
典型的なアイソメトリックでは、x 軸と y 軸が水平から 30 度下がり、z 軸は真上を向きます。ワールド座標 (x, y, z) の点は、画面上の次の位置に来ます。
const COS30 = Math.cos(Math.PI / 6), SIN30 = 0.5;
function project([x, y, z], s = 20, ox = 280, oy = 190) {
return [ox + (x - y) * COS30 * s, oy + (x + y) * SIN30 * s - z * s];
}
s はワールド 1 単位のピクセル数、(ox, oy) はワールド原点が画面に来る位置です。画面上で +x は右下、+y は左下、+z は上に進みます。つまり同じ高さなら x + y が大きいほど見る人に近いということで、これが 3 章で効いてきます。
2. 箱は 3 面、面ごとに別の階調
このカメラから見える箱の面は常に 3 つです。上面、x + w の面(画面右)、y + d の面(画面左)。この 3 枚だけを polygon で描き、残りは省きます。
単色で塗るとシールを貼ったように見えます。コツは、1 つの基準色から 4 階調を作ることです。上面を最も明るく(光を受ける面)、右面を基準色に、左面を暗く、輪郭線をさらに暗くすると、隣り合う面や部品の境目がはっきりします。
function tones(hex) {
const n = parseInt(hex.slice(1), 16), c = [n >> 16, (n >> 8) & 255, n & 255];
const mix = k => '#' + c.map(v => Math.round(Math.min(255, v * k)).toString(16).padStart(2, '0')).join('');
return { top: mix(1.25), right: mix(1.0), left: mix(0.75), line: mix(0.45) };
}
全素材で同じ 4 つの係数を使えば、図全体が 1 方向から照らされているように見えます。
3. 描画順(最初にはまる不具合)
SVG は文書の順に描画するので、後の要素が前の要素を覆います。遠いものから順に出力する必要があります。
最初に思いつくのは、全部の箱を手前の角 (x + w) + (y + d) で並べる方法です。理屈は通っていそうですが、この図ではすぐに破綻します。土台は最も広い部品なので手前の角の値が最大になり、最後に描かれ、配管とバルブ、タンクの下半分、ポンプの大部分を塗りつぶします。
台の上に部品が立つような層で組む図なら、キーを 2 つにすれば直ります。まず底面の z、同じ層の中では手前の角です。
const order = (a, b) => (a.z - b.z) || ((a.x + a.w + a.y + a.d) - (b.x + b.w + b.y + b.d));
ただし限界があります。これが成り立つのは、大きさの近いコンパクトな部品のときです。長い部材があると、何も接触していなくても壊れます。たとえば (x, y) = (0, 0)、w = 10、d = 1 の長い配管は、その手前にある (4, 2)、w = 2、d = 2、h = 3 の短いポンプ(どちらも底面の z は同じ)より手前の角の値が大きくなります(11 と 10)。配管は y + d <= pump.y でポンプの奥にあるのに、後に描かれて上に重なります。別の層から浮いている部品でも同じことが起きます。
単純な層構造を超える図では、分離判定を使います。このカメラでは、A.x + A.w <= B.x、A.y + A.d <= B.y、A.z + A.h <= B.z のいずれかが成り立てば、箱 A は箱 B の奥にあります。画面上の輪郭が重なるペアごとに辺を張り、トポロジカルソートします。グラフに閉路ができたら(風車のように 3 本の棒がそれぞれ次の棒の手前にある場合)、箱を 1 つ 2 分割して閉路を切ります。
スクリプト全体
iso.mjs として保存し、node iso.mjs > machine.svg で実行して、SVG をブラウザで開いてください。依存パッケージはなく、動作は Node v26.13.0 で確認しました。
// iso.mjs: ライブラリなしの SVG でアイソメトリックの箱を描く。実行: node iso.mjs > machine.svg
const COS30 = Math.cos(Math.PI / 6), SIN30 = 0.5;
// ワールド座標 -> 画面座標。画面上で +x は右下、+y は左下、+z は真上。1 単位 = `s` px。
function project([x, y, z], s = 20, ox = 280, oy = 190) {
return [ox + (x - y) * COS30 * s, oy + (x + y) * SIN30 * s - z * s];
}
// 素材ごとの 4 階調: 上面が最も明るく、右面が基準色、左面が暗く、輪郭線が最も暗い。
function tones(hex) {
const n = parseInt(hex.slice(1), 16), c = [n >> 16, (n >> 8) & 255, n & 255];
const mix = k => '#' + c.map(v => Math.round(Math.min(255, v * k)).toString(16).padStart(2, '0')).join('');
return { top: mix(1.25), right: mix(1.0), left: mix(0.75), line: mix(0.45) };
}
const pts = p => p.map(q => project(q).map(v => v.toFixed(1)).join(',')).join(' ');
// (x, y, z) にあるサイズ (w, d, h) の箱。見える 3 面だけを描く。
function box({ x, y, z, w, d, h, color }) {
const t = tones(color);
const top = [[x, y, z + h], [x + w, y, z + h], [x + w, y + d, z + h], [x, y + d, z + h]];
const right = [[x + w, y, z], [x + w, y + d, z], [x + w, y + d, z + h], [x + w, y, z + h]];
const left = [[x, y + d, z], [x + w, y + d, z], [x + w, y + d, z + h], [x, y + d, z + h]];
const face = (p, fill) => `<polygon points="${pts(p)}" fill="${fill}" stroke="${t.line}" stroke-width="1" stroke-linejoin="round"/>`;
return face(left, t.left) + face(right, t.right) + face(top, t.top);
}
// 描画順: 遠いものから描く。層で組む図(台の上に部品が立つ)では、まず底面の z で並べ、
// 同じ層の中は手前の角 (x + w) + (y + d) で並べる。
const order = (a, b) => (a.z - b.z) || ((a.x + a.w + a.y + a.d) - (b.x + b.w + b.y + b.d));
const parts = [
{ x: 0, y: 0, z: 0, w: 10, d: 6, h: 1, color: '#5b6b82' }, // 台
{ x: 1, y: 1, z: 1, w: 3, d: 3, h: 5, color: '#2f7de1' }, // タンク
{ x: 4, y: 2, z: 1, w: 3, d: 1, h: 1, color: '#9aa7b8' }, // 配管
{ x: 7, y: 1, z: 1, w: 2, d: 3, h: 3, color: '#e2a03f' }, // ポンプ
{ x: 7.5, y: 4.5, z: 1, w: 1, d: 1, h: 0.6, color: '#d9534f' } // バルブ
];
const body = [...parts].sort(order).map(box).join('\n ');
process.stdout.write(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 460" width="600" height="460">
<rect width="600" height="460" fill="#0f1420"/>
${body}
</svg>
`);
部品を増やすときは parts に 1 行足すだけです。見た目を変えるときは tones の 4 つの係数を変えます。基準色は中間の明るさを選んでください。tones は 255 で頭打ちになるので、明るすぎる色は上面と右面の差がなくなります。Windows PowerShell 5.1 では node iso.mjs | Out-File -Encoding utf8 machine.svg とすると UTF-16 のファイルになるのを防げます。
開示: 筆者は Homingo の創業者で、下記の製品は Homingo が販売しています。この記事は AI の支援を受けて書き、コードは公開前に実行して確認しました。ここまでの内容は製品なしで使えます。(Disclosure: I'm the founder of Homingo; written with AI assistance, code run before publishing.)
Isometric Explainers は、Claude Code がこうした機械の図を描き、1 コマンドで 1080x1920 の解説リールにする私たちの有料スキルです。スキル Rs 1,499(約 17 米ドル)、Complete Kit Rs 2,499(約 28 米ドル)、買い切り、インドルピー建てで海外発行のカードも使えます。Claude Code を使える Claude のアクセスと Node.js 20.10 以上が必要で、Claude のプランは含まれません。製品ページは英語です: Isometric Explainers
Comments
Post a Comment