ベジエ曲面のスライス断面抽出
任意の平面でベジエ曲面を切ったときの断面(等高線)を、ベジエクリッピング法の考え方を使って求める。
このページでは bezier_surface_slice.html が内部で行っている4段階の処理を、図とコードで順に説明する。
00概要・全体パイプライン
入力は4×4個の制御点からなる1枚のベジエパッチと、1枚の切断平面。出力はその平面上に落ちる断面を近似する線分(ポリライン)の集合。この2つの間を、次の4段階が繋いでいる。
肝心なのは、4分割の再帰と棄却判定を行っているのが1〜2、実際に交線を取り出しているのが3という役割分担。1〜2はどこに交差の可能性があるかを絞り込むだけで、実座標は生成しない。3で初めて座標を持った線分が生まれる。
bezier_clip4.html の clipBezier() は、視錐台6平面に対する凸包判定+4分割再帰で「画面内に入るパッチ」を絞り込むものだった。本ツールはこの枝刈り+再帰分割という骨格をそのまま流用し、判定対象を「6平面のAND」から「1枚の平面」に置き換え、さらに末端パッチでの交線抽出(マーチングスクエア)を新たに追加している。
01距離関数と凸包性質
交差判定の入口。切断平面からの符号付き距離を求め、パッチの制御点を調べるだけで「交差の可能性があるか」を安価に判定する。
切断平面を単位法線 n とオフセット d で表すと、曲面上の点 p の平面からの符号付き距離は次の一次式になる。
ここで使うのがベジエ曲面の凸包性質:曲面上のどんな点 S(u,v) も、必ずその16個の制御点が作る凸包の内側に収まる、という性質。
この性質から、次の判定がそのまま成り立つ。
- 全制御点が f>0(または全部 f<0)→ 凸包全体が平面の片側に収まる → 曲面はこの範囲では絶対に平面と交差しない
- f の符号が混在 → 凸包が平面をまたぐ → 曲面が交差する可能性がある(確定ではない)ので次の段階へ
実装では、パッチの16点を1つの配列に平坦化し、全点の f の符号を1回のループで走査するだけ。
// P: 4x4 の制御点配列, plane: {n, d} const pts = P[0].concat(P[1], P[2], P[3]); let allPos = true, allNeg = true; for (let i = 0; i < pts.length; i++) { const f = vdot(plane.n, pts[i]) - plane.d; if (f > 1e-9) allNeg = false; if (f < -1e-9) allPos = false; } if (allPos || allNeg) return; // ★ 凸包が片側に完全収容 → 交差なし(枝刈り)
02再帰的な4分割
符号が混在した「わからないパッチ」を de Casteljau 分割で4つに割り、それぞれへ同じ判定を繰り返す。棄却できた領域は二度と調べない。
符号が混在した場合、パッチを u=0.5・v=0.5 で4分割する(添付ファイルの split4() をそのまま使用)。4つの子パッチはそれぞれ元のUV範囲の 1/4 を担当し、深度 depth+1 として再び①の判定にかけられる。
これを既定の深度 maxDepth に達するまで繰り返す。片側判定で棄却されたパッチは以後まったく処理されないので、実際に細かく分割されるのは切断面の近くだけになる(下図でオレンジ色の部分だけがマーチングスクエアまで進む)。
function slicePatchRecursive(P, plane, depth, maxDepth, leafGridN, segsOut) { // …① 凸包の片側判定(前節)… if (depth >= maxDepth) { // ③ 末端パッチ: マーチングスクエアへ(次節) return; } const [tl, bl, tr, br] = split4(P); // de Casteljau で4分割 slicePatchRecursive(tl, plane, depth+1, maxDepth, leafGridN, segsOut); slicePatchRecursive(bl, plane, depth+1, maxDepth, leafGridN, segsOut); slicePatchRecursive(tr, plane, depth+1, maxDepth, leafGridN, segsOut); slicePatchRecursive(br, plane, depth+1, maxDepth, leafGridN, segsOut); }
bezier_clip4.html の clipBezier() と完全に同じ形(凸包判定 → IN/OUT/PARTの3値分岐 → PARTだけsplit4()で4分割再帰)。違いは判定対象を「視錐台6平面」から「1枚の切断平面」に変えた点と、末端到達時の処理(元のファイルはそのままパッチを採用するだけだったが、本ツールはここでさらにマーチングスクエアに進む)だけ。
03マーチングスクエア法で線分を取り出す
①②は「どこに交差があるか」を絞り込むだけで座標を作らない。実際の交線(座標つき線分)を作るのはここだけ。
再帰が maxDepth に達した末端パッチは、もう十分小さいとみなし、その内部を leafGridN × leafGridN 個の格子に分けて実座標を評価する。
隣接する4点(1セル)ごとに、4隅の符号パターンから交線がどの辺を横切るかを判定する——これがマーチングスクエア法。パターンは4隅の +/− の組み合わせで16通りあるが、本質的には次の3タイプに集約できる。
辺の両端で符号が反転していれば、その辺上のどこかに f=0 の点がある。位置は3D座標を直接線形補間して求める(座標の線形補間であって、距離関数の再計算はしない)。
function edgeInterp(pA, fA, pB, fB) { const t = fA / (fA - fB); // f=0 になる位置(0..1) return vlerp(pA, pB, t); // 3D座標を直接補間 }
1セルにつき出てくる線分は0〜2本。0本(全部同符号)/1本(①②)はそのまま処理できるが、③の鞍点(対角が同符号)だけは「どちらの対角同士を繋ぐか」が2通りあって決められない——古典的なマーチングスクエアのあいまいさである。ここではセル中心を追加サンプリングし、中心の符号がどちらの対角グループと同じかで自然な繋ぎ方を選ぶ。
const center = (f00 + f10 + f11 + f01) / 4; case 5: // (00,11)が+ / (10,01)が− の鞍点 if (center > 0) { /* 00-11 側で繋ぐ2本 */ } else { /* 10-01 側で繋ぐ2本 */ } break;
末端パッチ内の全セルについてこれを行い、得られた線分をすべて集めたものが、そのスライス面での断面全体になる。
04精度・限界・パラメータ
出力の正体、精度を決める2つのパラメータ、そして「本物の」ベジエクリッピング法との違いをまとめる。
出力は線分(ポリライン)であって曲線ではない
①②で絞り込み、③で座標を作る——この設計の帰結として、最終的に得られるのは [[p1,p2], [p3,p4], …] という3D線分の配列である。真の交線をスプラインなどで曲線としてフィッティングする後処理は行っていない。見た目が滑らかになるのは、線分が十分細かく敷き詰められて折れ目が視認できなくなるからにすぎない。
2つのパラメータ
maxDepth(再帰分割深度)— ②の4分割を何段まで許すか。大きいほど末端パッチが細かく切断面に密着するが、パッチ数は最悪 4^maxDepth 倍に増えうる(実際は片側判定の枝刈りでその大半は発生しない)。leafGridN(末端グリッド解像度)— 末端パッチ内をさらに何×何のマーチングスクエア格子に分けるか。大きいほど1本の末端パッチから出る線分が増え、境界の曲率をより滑らかに追える。
両者はどちらも「線分をどれだけ細かく刻むか」を制御する点で似ているが、maxDepthは境界へ収縮していく段数、leafGridNは収縮しきった末端パッチの中の解像度という役割分担になっている。
古典的な Bezier Clipping 法との違い
本実装(および元にした
bezier_clip4.html)は、それよりシンプルな「片側判定による枝刈り+一様な4分割再帰(quadtree)」という変種である。実装は単純だが、区間を積極的に収縮させるわけではないため、同じ精度を得るのに古典的な手法より多くの分割段数を要する場合がある。用途(インタラクティブな可視化)には十分な速度が出ているが、この違いは把握しておく価値がある。
退化ケース:接線状の切断面
対称な形状(トーラスなど)で切断面がちょうど制御パッチの境界と一致すると、全制御点の f が厳密に0になり「両側判定」が同時に真になって交差なしと誤判定されることがある。これを避けるため、スライス面群の中心位置に span×0.0007 程度のごく小さいオフセットを加え、厳密な対称一致を避けている。