(rontolisp) docs

ソリッドモデリング(geom)

geom パッケージは、名前で構築し、運動連鎖にぶら下げ、変換で動かし、計測でき る立体を提供します。実装は rontolisp 自身で書かれ、依存するのは linalg のカーネルだけです。外部呼び出しもファイルシステム も使わないため、インタプリタ、コンパイル済み .class、両方の WASM バックエンド、 ブラウザのプレイグラウンドのいずれでも動きます。インストールも require も不要で、 最初の使用時に読み込まれます。

3つの型

geom の型はちょうど3つ、それにバウンディングボックスが加わります。

transform は剛体運動です。並進3次元ベクトルと 3x3 回転から成ります。これは 値であり、親も同一性もキャッシュも持たず、破壊的に変更されることもありません。 したがって同じ変換を何個のノードのローカル変換にしても構いません。 geom:compose、geom:invert、geom:transform-point、 geom:inverse-transform-point はいずれも新しい値を作ります。

node はローカル変換で「ある」のではなく、ローカル変換を「持ち」ます。だから こそ、ソリッドもカメラ注視点も素の関節フレームも、余分なスロットなしにすべてノー ドとして表せます。geom:world-transform は祖先の変換を合成した結果をメモ化し、姿 勢が変わると部分木全体でそのメモを破棄します。

solid は境界表現を持つノードです。geom:vertices-of はモデル座標を並べたラ ンク2 (n 3) のパック配列、geom:facets-of はインデックスループのリストで、各 ループは外側から見て反時計回りに巻かれています。点のリストではなく1つの頂点配 列にしていることが、立体全体の変換を1回の linalg:matmul にしています。

すべては float32(:element-type 'single-float)です。パックされた単精度配列はそ のまま GPU の頂点バッファのバイト列だからです。geom のメッシュは objc:data を 通して変換なしで Metal に届き、linalg の変換は幅を保ちます。

コンストラクタ

キーワードを取る名詞のコンストラクタです。形状を決める唯一の寸法だけが位置引数に なり得ます。

コンストラクタ作られるもの
(geom:box '(100 200 300))原点を中心とする直方体(スカラーなら立方体)
(geom:cylinder :radius 50 :height 100)z = 0 の上に立つ円柱
(geom:cone :radius 50 :height 120)z = 0 上の円を底面とする錐。:apex で斜錐
(geom:sphere :radius 50 :sides 32 :stacks 24)原点を中心とする球
(geom:torus :radius 60 :tube 20)xy 平面上のトーラス
(geom:extrusion profile :along 10)閉じた輪郭をベクトルに沿って掃引した一般の角柱
(geom:revolution profile :sides 64)輪郭を z 軸まわりに回転させた立体。軸を離れる端に蓋が付く (閉じた輪郭には付かない)
(geom:polyhedron points facets)生の点列とインデックスループ(逃げ道)
(geom:arrow :length 200 :radius 6)軸と尖った頭を1つのソリッドにした矢印。向きは :direction
(geom:triad :at (geom:vec3 0 0 0))それを3本(+x 赤、+y 緑、+z 青)束ねたリスト

:sides と :stacks は分割数です。分割された基本形状は滑らかな理想形に内接す るので、体積は閉形式に下から収束します。

pi r^2 h = 785398 に対して 0.16% 小さい値です。

geom:arrow だけは幾何の教科書に載る形ではありませんが、ビューア側ではなくここに あるのには理由があります。3本の線分で描いた原点指示子には太さを与えられず(線の プリミティブに幅はありません)、先端を尖らせることもできません。ソリッドである矢印 なら両方できますし、バウンディングボックス、体積、運動連鎖への組み込み、CSG 演算、 4つのバックエンドすべて、そしてブラウザ側のレンダラまで、レンダラのコードを1行も 書かずに手に入ります。尾がモデル原点、先端は :direction 方向に :length の位置 で、指定しなかった寸法はすべて長さに対する比率なので (geom:arrow :length 200) の 1呼び出しで済みます。geom:triad はそれを慣例の3色で3本返すもので、呼び出し側が 所有するソリッドのリストです。

分割された基本形状と違い、この体積は実際に作られた形状の閉形式(同じ正 n 角形の 角柱と角錐の和)と厳密に一致します。これが全バックエンドで巻き方を固定します。

シーングラフ

geom:attach はノードを別のノードにぶら下げ、geom:detach は外します。変更子は geom:translate、geom:rotate、geom:place、geom:reorient で、それぞれ位置引数のフラ グではなく名前付きの :frame を取ります。:local(ノード自身の軸、既定)か :parent(接続先の軸)です。:frame :parent と書かれた呼び出しはマニュアルなしで 読めます。

:local はオフセットや軸を、ノードが「現在」向いている姿勢で読みます。すでに z まわりに90度回っているノードでは、(geom:translate n (geom:vec3 10 0 0)) はワールド の +x ではなく +y へ運びます。これは前進の一歩を書きたいときに欲しい挙動であり、 ワールド座標で置きたいときは :frame :parent です。

geom:translate と geom:rotate は積み重ね、geom:place は姿勢を直接設定しま す。geom:rotate の差分を繰り返すとドリフトするため、アニメーションループでは geom:place を使います。

メッシュと、それをキャッシュする理由

geom:mesh はモデル空間でのソリッドの三角形を返します。パックされた単精度配列で、 1三角形あたり 18 float(3頂点分の位置+法線)です。各面を扇状に三角形分割し、法線 は Newell 法で求めます。これは1度だけ計算されてソリッド上に保持され、 geom:wireframe(1線分あたり 6 float、各辺1回)も同様です。

このキャッシュは最適化ではなく、設計の要です。剛体の三角形は決して変わらず、変わ るのは姿勢だけだからです。13,800 三角形からなる 60 立体の多関節モデルでは、毎フ レーム全頂点をワールド空間に変換するレンダラは 1フレーム 380 ms を費やし、モ デル空間のメッシュを1度アップロードしてワールド変換をドローごとのユニフォームとし て渡すレンダラは 9.0 ms です。したがって geom:mesh はレンダラの内部事情では なく公開 API であり、そこから作った GPU バッファを利用側が置く場所が geom:user-data です。

スケーリングはモデル頂点を書き換えます。姿勢のミューテータがノードの配置だけを 変えるのに対し、これは部品そのものを変える操作です。そのため CL 自身の関数的/ 破壊的の慣習(reverse/nreverse、union/nunion)に従います。geom:scale は ブーリアン演算と同じく新しいソリッドを作り、history に (:scale s factor) を記録します。geom:nscale はその場で書き換えます。これがパッケージが提供する唯 一の頂点変更であり、したがって両方のキャッシュと geom:user-data を破棄する唯一 の箇所です。係数は数値、または非一様スケール用の3次元ベクトルかリストです。鏡映 になる係数では巻き方が外向きのままになるよう面を反転し、成分 0 は拒否します。

計測

geom:bounds はソリッド、またはソリッドのリストの軸並行ボックスをワールド座標 で返すので、シーングラフに追従します。geom:bounds-center、geom:bounds-extent、 geom:bounds-union、geom:lower-of、geom:upper-of で読み出します。

geom:volume はメッシュ三角形に発散定理を適用して積分するので、巻き方の検査も 兼ねます。逆向きに巻かれた面は減算するため、巻き方を間違えた geom:polyhedron は わずかに小さい値ではなく大きく外れた値を返します。geom:centroid は同じ符号付き四 面体の総和、geom:surface-area は三角形の面積の合計です。

ブーリアン演算

geom:union、geom:difference、geom:intersection が風景を部品に変えます: 4 つ のボルト穴が開いた板、溝を削り出したブロック、外形から内形を引いた筐体。各演算はオ ペランドをワールド座標で扱い ((geom:difference plate hole) は両方を配置した 後の見た目そのもの)、オペランドを変更せず、頂点がワールド座標の新しいルート立体を 返します。体積が検算になります: 任意の組について、基本形状が元々持つ分割誤差の範囲 内で vol(A ∪ B) + vol(A ∩ B) = vol(A) + vol(B) が成り立ちます。

穴の深さは板の厚みとちょうど同じで、完全に貫通します。同一平面上の面、面の上にちょ うど乗った頂点や辺、ちょうど接する 2 つの立体は、いずれも扱えるケースです。交わら ない立体の積はエラーではなく空の立体 (面なし、体積 0.0) になり、結果は自分を 作ったものを記録します -- (geom:history result) は変更されていないオペランドを含 む (op a b) を返すので、プログラムはモデルを別のパラメータで作り直せます。

パイプラインは BSP クリッピングで、float64 で実行され、結果の頂点配列でのみ float32 に戻ります。分類の許容誤差は geom:*tolerance* (既定 1.0e-5) で、オペランドを合 わせたバウンディングボックスに対する相対値です -- geom には長さの単位がないの で、絶対のイプシロンでは 0.001 スケールと 1000 スケールのモデルの両方を正しく扱えま せん。1 つの演算だけ緩めたり締めたりするには、呼び出しの周りで再束縛します。

geom:section は同じ分類問題の片オペランドが自明な場合です: 平面が立体を切るルー プを、ワールド座標の点を並べた rank-2 (n 3) packed 配列として返します -- 断面図 が 1 回の呼び出しで得られます。

赤道はチューブを 2 回切ります: 境界と穴です。外側のループは法線の正の側から見て反 時計回り、穴は時計回りに巻かれます。

見る: scene ビューア

geom は何も描きません。すべてのバックエンドで動き、その多くには画面がないからで す。macOS では scene パッケージがもう半分を担います。Metal サーフェスを載せた ウィンドウ、軌道回転・パン・ドリーのカメラ、地面グリッド、座標軸の三つ組み。geom と同じくインタプリタに同梱されているので、素の REPL から 3 行で絵になります。

CL-USER> (defvar *v* (scene:viewer :title "arm" :width 900 :height 640))
CL-USER> (scene:add *v* (geom:cylinder :radius 60 :height 140))
CL-USER> (scene:fit *v*)
CL-USER> (scene:refresh *v*)

ドラッグで軌道回転、shift+ドラッグでパン、スクロールでドリー、ウィンドウのリサイズ も可能です。カメラ操作は自分で再描画しますが、ミューテータはしません。60 個のソリッ ドを追加するループが 60 フレーム描いてはいけないからで、まとめて呼んだ後の手順が scene:refresh、動くシーンなら scene:animate です。ビューアはグローバル変数の集 合ではなく CLOS インスタンスなので、1 つのイメージに 2 つのウィンドウが同時に存在 し、それぞれ独立に回せます。

上のメッシュキャッシュはこのためにあります。 各ソリッドのモデル空間メッシュは最 初に描画されたときに専用の GPU バッファに入り(そのソリッドの geom:user-data に保 持されます)、フレームはソリッドごとに 4x4 のモデル行列と色を 1 つずつ設定して描画 コールを 1 回発行します。フレーム中に Lisp は三角形に一切触れません。それが上で計測 したモデルにおける 9.0 ms と 380 ms の差です。動く関節のコストは行列 1 つなので、 scene:animate のフックは毎フレーム連鎖全体の姿勢を付け直しても構いません。

scene:shading は :solid / :wireframe / :both を選び、scene:axes は nil(既定。何も描かない)/ :world / :bodies(各ソリッド自身の座標系。運動連鎖 を読み取れるようにするものです)/ :both を選びます。これらはビューア自身の備品で、 太さのない線の三つ組みです。ワールドのものはズームしても読めるよう視距離に比例して 拡大されます。置きたい場所に置け、軸に太さがあり先端が尖ったオブジェクトとしての 原点指示子は上の (geom:triad) で、他のソリッドと同じように追加します。scene:add はリスト引数を展開するので (scene:add *v* (geom:triad)) の 1 呼び出しです。だから こそ、頼まれない限りビューアは三つ組みを描きません。scene:add はソリッドでないもの もその場で名前を挙げて拒否し、1 フレーム後に描画コールバックが見つけることはありま せん。scene:window-of と scene:context-of は appkit: と、その下の metal 描画サーフェスへの抜け道です。

examples/macos/scene-solids.lisp は全プリミティブを並べたもの、 examples/macos/scene-robot-arm.lisp は動く目標に対して逆運動学を解く 4 関節アーム です。後者は形状を手で組み立てる examples/macos/metal-robot-arm.lisp と同じ機械で あり、2 つを並べて読む価値があります。どちらもディスプレイが必要なので examples.yaml には入っていません。scene と metal はどちらも macOS 専用で、こ れらを参照するプログラムの .wasm 出力は、objc: のプログラムと同様に名前を挙げて 拒否されます。

ウィンドウを持たないビューア

scene:offscreen は、ドローアブルの代わりにテクスチャに描く同じビューアで、 scene:snapshot がそのピクセルを返します -- width * height * 4 バイト、BGRA、行 0 が上です。似て非なる第二の描画関数ではなく同じ描画関数なので、絵そのものを検査で きます。赤い箱はフレーム中央で赤い、別の立体の背後にあるものは隠れる、巻き方が逆の 面はカリングされる、scene:fit はバウンディングボックス全体をフレーム内に収める、 といった具合です。metal:offscreen と metal:pixels はその一段下で、geom を使わ ない metal: プログラム向けです。

CL-USER> (defvar *v* (scene:offscreen :width 320 :height 240))
CL-USER> (scene:add *v* (geom:box 200 :color (geom:vec3 1.0 0.2 0.2)))
CL-USER> (scene:fit *v*)
CL-USER> (length (scene:snapshot *v*))
307200

どこでも見る: ブラウザの双子

geom は rontolisp が動く場所ならどこでも動き、そのレンダラも同じです。 examples/browser/webgl-solids/ は scene の設計を WebGL2 に移植したもので、立体ご とに 1 つの頂点バッファを一度だけアップロードし、描画ごとにモデル行列のユニフォーム を渡し、立体 1 つにつき 1 回の描画コールを出し、geom:mesh と geom:world-transform をそのまま利用します。実質的な違いは射影だけで、OpenGL のク リップ空間は z を [-1, 1] に、Metal は [0, 1] に置きます。第二のモデリング層は意図的 に置いていません -- それこそが geom にブラウザ方言を生やすものだからです。

モデルファイルを読む

他人が作ったメッシュこそ、この規模のソリッドがプログラムに入ってくる普通の経路です。 そこで geom はそれを読みます。

リーダー読むもの
(geom:read-obj "bunny.obj")Wavefront OBJ。v 行と f 行、面の頂点数は任意、v/vt/vn トークンと負のインデックスに対応
(geom:read-stl "part.stl")STL の両方言。どちらかはファイル自身の形から判定
(geom:read-ply "scan.ply")PLY。ASCII とバイナリ(リトルエンディアン)、プロパティはヘッダから取得
(geom:read-gltf "duck.glb")glTF 2.0。.glb と .gltf — メッシュではなくシーンで、ソリッドのリストを返す
(geom:read-model "whatever")フォーマットをバイト列から判定。:format で直接指定も可

返るのはごく普通の geom:solid なので、上に書いたことがそのまま当てはまります。 geom:volume、geom:bounds、ブーリアン、ビューア。隣にある他のコンストラクタと 同じく :color と :label を取ります。形が違う唯一の例外は glTF で、これは メッシュではなくシーンです。geom:read-gltf はノードが姿勢づけたソリッドの リストを返します。scene:add はリストをそのまま展開し、各ソリッドの geom:world-transform はノード階層を引き連れるので、複数パーツのモデルは ノードの言うとおりの場所に配置されます。ノードのスケールは読み込み時に頂点へ 焼き込まれ(変換は剛体のまま)、測定にもそのまま現れます。

CL-USER> (defvar *bunny* (geom:read-obj "bunny.obj" :color (geom:vec3 0.85 0.72 0.5)))
CL-USER> (geom:mesh-triangle-count *bunny*)
69451
CL-USER> (defvar *v* (scene:viewer))
CL-USER> (scene:grid *v* :extent nil)
CL-USER> (scene:shading *v* :solid)
CL-USER> (scene:add *v* *bunny*)
CL-USER> (scene:fit *v*)

実ファイルが教えてくれることが 3 つあります。どれもバグではありません。

  • ファイルはそれ自身の単位を持ちます。 上のバニーはメートル単位で 0.2、 3D プリント用の部品はミリメートル単位で 200 です。scene:fit はどちらも枠に 収めますし、小さいものの横ではたいてい (scene:grid v :extent nil) が要ります。 グリッドの既定の広がりは、geom の単位なき世界での 600 だからです。
  • 密なメッシュに欲しいシェーディングは :solid です。 既定の :both は三角形の 上にワイヤフレームを重ねますが、69,451 三角形ではそれは暗い斑点になります。
  • 巻き方はファイルのものであり、geom:volume がその検査です。 体積が負なら メッシュは外から見て時計回りに巻かれています。リーダーが代わりに直せない唯一の ものです。

どのリーダーも保持しないもの。1 色を超えるマテリアル、テクスチャ座標、頂点法線、 頂点カラー。geom:solid の色は 1 つで、面法線は形状から計算した Newell 法のもの なので、これらのレコードは中途半端に保持せず読み飛ばします。

ここにないもの

凸包、オフセット、フィレット、メッシュ修復、頂点の溶接、そして描画に関わるものは含 まれません。描画は上に書いた scene の担当で、それはこのパッケージの一部ではなく利 用者です。メッシュのファイル形式のうち OBJ と STL は読めますが(上の「モデルファイ ルを読む」)、PLY と glTF は認識するだけでまだ読めません。書き出しはどの形式も できません。ファイル由来の面を持つ立体は、単なる geom:polyhedron です。