0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MeshLabをTypescriptに移植した話

0
Posted at

MeshLab を TypeScript に移植した — Python なしで STL 修復・点群再構築ができる meshlab-ts

3D スキャンした点群を印刷可能な STL にする後処理パイプラインを個人プロジェクトで運用しているのですが、メッシュ処理の部分だけがずっと PyMeshLab 頼みでした。TypeScript のプロジェクトの中に、その一箇所のためだけに Python 環境が居座っている。この状態を解消するために、MeshLab 本体を TypeScript に移植した独立ライブラリ meshlab-ts を作り、npm に公開しました

npm install meshlab-ts   # Node >= 22 / Bun >= 1.3

最初に明記しておくと、meshlab-ts は CNR-ISTI VCLab とは無関係の非公式移植です。MeshLab / VCGLib のソースコードは仕様書として読むだけでリンクもコピーもしていませんが、忠実な移植は派生物とみなされうるため、ライセンスは本家に合わせて GPL-3.0 にしています。

meshlab-ts とは

実装済みフィルタ 269 / 282(MeshLab の全フィルタをレジストリ登録、未実装 13 本は明示的に例外を投げる)
ランタイム Node ≥ 22 / Bun ≥ 1.3、ランタイム依存ゼロ(WASM もネイティブバイナリもなし)
対応フォーマット PLY / STL / OBJ / OFF、テクスチャは PNG
テスト 1,655 本 + 本家 PyMeshLab との差分テスト 24 ケース
バージョニング CalVer(2026.8.x)— MeshLab 自身が 2023.12 形式なのでそれに合わせています

できることは要するに「MeshLab / PyMeshLab のレシピで書けることは、だいたいそのまま動く」です。STL 修復(重複頂点・非多様体エッジ・穴埋め・法線の向き)、Screened Poisson による点群→水密メッシュ再構築、QEM 間引き、等方リメッシュ、細分割(Loop / Butterfly / Catmull-Clark)、曲率計算、UV パラメータ化、テクスチャアトラスのデフラグまで一通り入っています。

JS/TS エコシステムを調べた範囲では、穴埋め単機能・修復単機能のパッケージは存在するものの、Poisson 再構築を含む包括的なメッシュ処理系は他に見つかりませんでした。ここが空白だったのがこのライブラリを公開した理由です。

使ってみる

CLI

インストールなしでも npx で動きます。

npx meshlab-ts list --implemented        # フィルタ一覧
npx meshlab-ts info "Close Holes"        # パラメータのスキーマと既定値
npx meshlab-ts apply "Close Holes" in.ply -o out.ply --param MaxHoleSize=100

フィルタ名は MeshLab の表示名("Close Holes")と PyMeshLab 名(meshing_close_holes)のどちらでも通ります。既存の PyMeshLab スクリプトからの移行を考えて、パラメータ名も本家 C++ と同一です。

1 つ設計思想として重要な点: 未知のパラメータ名は黙って無視せず例外になります。タイポが静かに既定値で実行される、という PyMeshLab あるあるの事故をなくしたかったためです。同じ理由で、未実装の 13 フィルタも「何もしないで成功」ではなく MLNotImplementedException を投げます。

壊れた STL を印刷可能にする

3D スキャン由来の STL は、重複頂点・非多様体エッジ・穴だらけです。修復パイプラインを JSON(または MeshLab GUI からエクスポートした .mlx そのまま)で書いて一括実行できます。

cat > repair.json <<'EOF'
{ "filters": [
  { "filterName": "Remove Zero Area Faces", "params": {} },
  { "filterName": "Remove Duplicate Faces", "params": {} },
  { "filterName": "Remove Isolated pieces (wrt Diameter)", "params": { "MinComponentDiag": 0.5 } },
  { "filterName": "Remove Unreferenced Vertices", "params": {} },
  { "filterName": "Repair non Manifold Edges", "params": {} },
  { "filterName": "Re-Orient all faces coherently", "params": {} },
  { "filterName": "Close Holes", "params": { "MaxHoleSize": 100 } },
  { "filterName": "Invert Faces Orientation", "params": { "forceFlip": false } },
  { "filterName": "Select None", "params": {} }
] }
EOF
npx meshlab-ts script repair.json broken.stl -o fixed.ply

点群 → 水密なソリッド

npx meshlab-ts apply "Compute normals for point sets" cloud.ply -o oriented.ply --save normals
npx meshlab-ts apply "Surface Reconstruction: Screened Poisson" oriented.ply -o solid.ply

ライブラリとして

複数ステップの処理はシェルより API 直叩きの方が扱いやすいです。フィルタの戻り値は PyMeshLab と同じキーのプレーンオブジェクトです。

import { MeshLabKernel, MeshDocument } from "meshlab-ts";

const kernel = MeshLabKernel.default();
const doc = new MeshDocument();
kernel.loadMesh(doc, "broken.stl");
kernel.applyFilter(doc, "Close Holes", { MaxHoleSize: 100 });
const out = kernel.applyFilter(doc, "Compute Geometric Measures");
console.log(out.mesh_volume);   // 水密でなければ undefined — それ自体が診断になる
kernel.saveMesh(doc, "fixed.ply");

Node とバンドラはビルド済みの dist/(ESM + .d.ts)を、Bun は exports map の "bun" condition 経由で TypeScript ソースを直接読みます。おまけとして、パッケージには AI コーディングエージェント向けのスキル(.agents/skills/meshlab-ts/SKILL.md)も同梱してあります。

アーキテクチャ: PyMeshLab ではなく MeshLab C++ を写す

互換対象は PyMeshLab の Python API ではなく、MeshLab の C++ アーキテクチャそのものにしました。FilterPlugin / MeshDocument / RichParameterList / PluginManager / CMeshO が同じ名前・同じ役割で存在します。

互換性を「主張」ではなく「測定」する

ここからが後半、この移植で一番こだわった部分です。

数学的に正しいことと、本家と互換であることは別の主張です。2 つの実装が両方とも正しくて、それでも違う答えを出すことはあり得ます。「meshlab-ts」と名乗る以上、後者を言えなければ意味がない — では、どう言えばいいのか。

答えは差分テストでした。本物の PyMeshLab(2025.7.post1)で (メッシュ, フィルタ, パラメータ) の三つ組を実行して結果の要約を JSON に保存し(golden)、通常のテスト実行時に同じ三つ組を meshlab-ts で実行して突き合わせる。golden はリポジトリにコミットされているので、CI にも利用者にも Python は一切不要です。

期待レベルを宣言する

全フィルタに bit 一致を求めるのは嘘になります。ケースごとに約束レベルを宣言し、そのレベルちょうどで検証します。

レベル 意味 対象
exact 座標ダイジェスト自体が一致 位相操作、決定的な変換(現在 24 ケース中 16)
equivalent 位相量は全一致、スカラは相対 1e-6 浮動小数の加算順序が正当に異なるもの(平滑化など)
loose 数量が数%以内・閉性が一致 ヒープのタイブレークに依存するもの(間引きなど)

初回実行は 17 ケース失敗し、うち 4 つが本物のバグだった

このハーネスを初めて回したとき、24 ケース中 17 が落ちました。許容誤差の調整で片付くものを除くと、4 つは本物の互換性バグでした。1,655 本の数学的不変量テストが全部緑の状態で、です。

  1. STL が soup のまま読まれていた — MeshLab は STL 読込時に重複頂点を溶接する(unify_vertices=true が既定)。読込層の互換バグは全フィルタに波及するので、これが一番大きい発見でした。
  2. ラプラシアン平滑化に自己項がなかった — VCGLib は P ← (P + Σ近傍) / (n + 1) と、頂点自身を重み 1 で平均に含めます。純粋な近傍平均だと 1 ステップにつき約 1/価数 だけ強く縮み、3 ステップで表面積が 0.5% ずれていました。
  3. Boundary フラグの意味を誤解していた — 「境界をピン留め」と実装していましたが、MeshLab にピン留めは存在しません。on なら境界曲線に沿った 1D 平滑化、off なら境界フラグを消して内部同様に平滑化、です。修正後は同じグリッドの寸法が本家と float32 精度で一致しました。
  4. クラスタリング間引きの格子が違った — VCGLib は bbox を 1 セル分膨張させ、セル数を切り捨てで決め、ボクセル寸法を逆算します。生の bbox から刻むと、すべてのビン境界がずれます。

4 つ目の修正結果は象徴的でした。同じ球を間引くと、本家は非多様体エッジを 12 本作ります(クラスタリングは多様体性を保証しない操作です)。修正後の meshlab-ts は 頂点 114・面 224・非多様体エッジ 12 本まで、本家と完全に一致します。移植の忠実さとは、いぼまで含めて再現することでした。

ちなみに MeshLab 本体にはテストスイートが存在しません(テストディレクトリなし、CI はビルド確認のみ)。なので、この golden 群と数学的不変量テストは、ある意味で本家にもない資産になっています。

golden で測れないものは数学で縛る

golden は「本家と同じか」しか教えてくれません。それ以前の「そもそも正しいか」は、記録済み出力ではなく数学的不変量で検証しています。いくつか例を挙げると:

  • 修復後のメッシュはオイラー標数が理論値と一致する(球なら 2、トーラスなら 0)
  • 展開可能性エネルギーは、本当に展開可能な面(折り板・円筒・円錐)で厳密にゼロになる
  • ARAP のフィッティングエネルギーは毎イテレーション単調減少する
  • 球面上の主曲率は縮退しているので、k1/k2 の分割はノイズであり、検証すべき不変量は平均曲率 (k1+k2)/2 の方

最後の例は Linux CI だけが落ちる問題の原因でもありました。libm(sin/cos)の ulp 差が縮退固有値の分割を揺らし、macOS では閾値を 0.001 差で偶然通過していた、というものです。プラットフォーム差を許容誤差で誤魔化すのではなく、ノイズが相殺される不変量に書き換えることで解決しています。

できないこと・やらないこと

誠実さのために、限界も書いておきます(詳細は README の Known gaps に列挙してあります)。

  • bit 互換は目標ではありません。float32/float64 の差、ヒープのタイブレーク、再現性のために意図的に選んだ乖離(例: 実行結果がマシン速度に依存する timelimit パラメータは拒否し、決定的な代替を提供)があります。すべて文書化してあり、「出力を diff しないと発見できない差異はバグ」という方針で運用しています。
  • 13 フィルタは意図的に未実装です。GL レンダリング前提のもの、ネットワークアップロード、特殊フォーマットなど。
  • 性能は C++ の数倍遅いです。修復パイプライン(数秒オーダー)には十分ですが、数千万三角形のリアルタイム処理は対象外です。
  • ブーリアン演算は体積的な実装で、厳密 CSG が必要な用途(嵌合部品の減算など)には Manifold のような専用ライブラリの方が適しています。実際、自分のパイプラインでも CSG だけは manifold-3d に任せています。

AI エージェントとの協働について

この移植は Claude(AI コーディングエージェント)との協働で開発しました。分担としては、方針判断 — 互換対象を C++ 層にする、bit 互換を非目標と宣言する、乖離を許容するかどうかの個別判断、リリースの可否 — を人間が持ち、実装とテスト作成をエージェントが進める形です。

うまく機能した最大の理由は、テストがエージェント自身の出力も検証する構造にあったと思っています。実際、差分ハーネスや不変量テストは、エージェントが書いたコードのバグをその場で何度も捕まえました(2×2 SVD の回転角の入れ替わり、スパース行列の Dirichlet 固定を同じ行列に 2 回適用すると 2 回目が何もしない、など)。「AI が書いたから信頼できない」でも「AI が書いたから正しい」でもなく、検証可能性を先に設計しておけば誰が書いたかは問題でなくなる、というのが一番の学びでした。

おわりに

「MeshLab でやっていたあれ、TypeScript だけで済ませたい」という場面があれば試してみてください。PyMeshLab のスクリプトや MeshLab GUI の .mlx エクスポートがだいたいそのまま動くはずです。動かなかったら、それは互換性バグなので issue をいただけると助かります — 差分ハーネスに 1 ケース足すだけで再現テストになります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?