lithop 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -0
- data/Cargo.lock +284 -0
- data/Cargo.toml +6 -0
- data/README.md +25 -7
- data/RELEASING.md +24 -6
- data/docs/README.md +3 -1
- data/docs/artifacts.md +9 -2
- data/docs/bambu-studio.md +5 -1
- data/docs/command.md +25 -2
- data/docs/config.md +1 -1
- data/docs/getting-started.md +8 -5
- data/docs/images/cmyw-lithophane-preview.png +0 -0
- data/docs/local-ui.md +114 -0
- data/docs/tiling.md +4 -2
- data/docs/troubleshooting.md +10 -0
- data/ext/lithop_native/Cargo.toml +17 -0
- data/ext/lithop_native/build.rs +4 -0
- data/ext/lithop_native/extconf.rb +6 -0
- data/ext/lithop_native/src/column_mesh.rs +388 -0
- data/ext/lithop_native/src/lib.rs +37 -0
- data/ext/lithop_native/src/mesh.rs +187 -0
- data/ext/lithop_native/src/palette.rs +67 -0
- data/ext/lithop_native/src/xml.rs +134 -0
- data/lib/lithop/calibration/export.rb +2 -2
- data/lib/lithop/cli/options.rb +13 -0
- data/lib/lithop/cli.rb +28 -0
- data/lib/lithop/export/generation.rb +30 -17
- data/lib/lithop/export/job.rb +2 -2
- data/lib/lithop/export/metadata.rb +8 -3
- data/lib/lithop/export/previews.rb +25 -1
- data/lib/lithop/formats/three_mf/mesh.rb +72 -3
- data/lib/lithop/formats/three_mf/model_writer.rb +19 -16
- data/lib/lithop/formats/three_mf/part_writer.rb +37 -0
- data/lib/lithop/formats/three_mf.rb +1 -1
- data/lib/lithop/image/pipeline.rb +50 -11
- data/lib/lithop/image/svg_loader.rb +5 -5
- data/lib/lithop/job_execution.rb +132 -4
- data/lib/lithop/mesh/column_geometry.rb +8 -0
- data/lib/lithop/mesh/column_topology.rb +2 -0
- data/lib/lithop/model/material_model.rb +17 -5
- data/lib/lithop/model/palette.rb +21 -3
- data/lib/lithop/native.rb +39 -0
- data/lib/lithop/tiling.rb +3 -2
- data/lib/lithop/version.rb +1 -1
- data/lib/lithop/web/app.rb +513 -0
- data/lib/lithop/web/bambu_studio_launcher.rb +82 -0
- data/lib/lithop/web/job_launcher.rb +214 -0
- data/lib/lithop/web/job_store.rb +390 -0
- data/lib/lithop/web/preview_store.rb +182 -0
- data/lib/lithop/web/public/app.css +319 -0
- data/lib/lithop/web/public/app.js +1847 -0
- data/lib/lithop/web/server.rb +21 -0
- data/lib/lithop/web/settings_form.rb +64 -0
- data/lib/lithop/web/settings_library.rb +154 -0
- data/lib/lithop/web/views/index.erb +364 -0
- data/lib/lithop/web/worker.rb +27 -0
- data/lib/lithop.rb +1 -0
- data/sig/lithop.rbs +25 -0
- metadata +84 -2
data/docs/local-ui.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# ローカルブラウザUI
|
|
2
|
+
|
|
3
|
+
LithopのローカルUIは、画像選択から設定、変換、進捗確認、中止、プレビューと成果物の表示までをブラウザで行います。サーバーは`127.0.0.1`だけで待ち受け、画像や設定を外部サービスへ送信しません。
|
|
4
|
+
|
|
5
|
+
## 起動する
|
|
6
|
+
|
|
7
|
+
インストール済みのgemでは次を実行します。
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
lithop ui
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
ソースチェックアウトではBundlerを使います。
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
bundle exec lithop ui
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
端末に表示された`http://127.0.0.1:4567`をブラウザで開きます。別ポートまたは別プロジェクトを使う場合は次のように指定します。
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
lithop ui --port 4667 --project-dir /path/to/project
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
待受アドレスは変更できません。ブラウザは自動起動しないため、表示されたURLを明示的に開いてください。
|
|
26
|
+
|
|
27
|
+
## 画面を移動する
|
|
28
|
+
|
|
29
|
+
左の主メニューで「作成」「ジョブ」「プリセット」を切り替えます。「作成」は画像・変換設定と入力プレビュー、「ジョブ」は履歴・進捗・生成結果、「プリセット」は名前付き設定と下書きの管理を表示します。幅1439px以下では主メニューを左上のメニューボタンから開き、Escまたは背景の選択で閉じます。
|
|
30
|
+
|
|
31
|
+
画面を切り替えても、選択中の画像、編集値、設定区分の開閉、プレビューの倍率・位置、履歴の検索条件と選択状態を保持します。ブラウザの戻る・進むにも対応し、URLの`view`は表示画面、`job`は閲覧ジョブを表します。再読み込み後は画面とジョブを復元しますが、ブラウザの制約により入力画像は再選択が必要です。
|
|
32
|
+
|
|
33
|
+
上部ヘッダーにはプロジェクト名、現在の画面、実行中ジョブの入力名・ID・状態・進捗を表示します。過去ジョブを閲覧しても実行中ジョブを別に追跡するため、上部の停止対象は変わりません。ジョブ表示を選ぶと実行中ジョブの詳細へ戻ります。「使い方」は同梱された簡易説明を開くため、外部サイトへ接続しません。
|
|
34
|
+
|
|
35
|
+
## 画像と設定を選ぶ
|
|
36
|
+
|
|
37
|
+
JPEG、PNG、SVGを1ファイル選択します。画面では次の設定を変更できます。
|
|
38
|
+
|
|
39
|
+
- 完成幅・高さ、XYセル、任意の色セル
|
|
40
|
+
- `cover`/`contain`、横・縦位置、クロップ倍率
|
|
41
|
+
- 明るさ、コントラスト、彩度、ノズル径
|
|
42
|
+
- 最小・最大厚み、上下左右のWhite余白
|
|
43
|
+
- XYタイル数と配置、Z方向の別プレート化、色領域の分割数
|
|
44
|
+
|
|
45
|
+
画像はファイル選択欄へのドラッグ&ドロップにも対応します。設定は「基本設定」「画像配置」「画像調整」「分割」「詳細設定」に分かれ、幅と高さの交換、縦横比の固定、区分ごとの初期値への復元ができます。画像配置と画像調整はスライダーと数値入力が同期します。grid以外の行列数と、Z分割を使用しない場合の色領域分割数は送信されません。
|
|
46
|
+
|
|
47
|
+
開始ボタンの手前に、入力名、完成寸法、セル、画像調整、分割方法の要約を表示します。変換後に設定や画像を変更すると変更項目数を示し、「表示中の変換ジョブ」には編集値と異なる設定名を表示します。編集中の入力と履歴から開いたジョブは別に管理されるため、新しい画像を選んでも過去ジョブの「補正後入力」は置き換わりません。
|
|
48
|
+
|
|
49
|
+
初期値は選択したプロジェクトの`config/settings.json`と組み込み既定値から読みます。画面で確定した値はCLIと同じ`Settings`で検証され、White高さとCMYWレシピを作る共通の変換エンジンへ渡されます。
|
|
50
|
+
|
|
51
|
+
「プリセット」画面では、現在の変換設定を名前付きプリセットまたは1件の下書きとして保存できます。プリセットは読込、名前変更、削除ができ、過去ジョブの「この設定を編集へ読み込む」も同じ操作で現在値との差分を確認してから反映します。適用後は「作成」へ戻り、現在選択している画像は置き換えません。保存対象は画面で編集できる変換設定だけで、画像、一時入力ID、ジョブID、成果物パスは含みません。UI再起動後も設定は`config/ui-settings.json`から復元できますが、画像は再選択が必要です。このバージョン付きUI専用ファイルはGit追跡対象外で、`config/settings.json`を上書きしません。保存値は読込時にも共通`Settings`で検証し、未知の項目や非対応形式は理由を表示して適用しません。
|
|
52
|
+
|
|
53
|
+
JPEG/PNGは選択直後に簡易表示し、その後、共通画像処理で確定した入力プレビューへ置き換えます。SVGはサーバー側で既存の安全性検証を通してからPNGへラスター化するため、未検証のSVGをブラウザへ直接読み込みません。確定した入力プレビューにはEXIF回転、ICCからsRGBへの変換、透過部分のWhite合成、完成寸法と余白、`cover`/`contain`、クロップ、明るさ・コントラスト・彩度が反映されます。設定変更は約250 ms単位で反映し、古い処理結果は新しい表示を上書きしません。
|
|
54
|
+
|
|
55
|
+
入力プレビューは画面表示用の最大960 × 720画素に抑え、CMYW材料の選択やWhite厚みを予測しません。「タイル境界」を有効にすると、生成処理と同じXYセル境界計算による分割位置とIDを重ねて表示します。拡大後、または「100%」で画像が表示枠より大きいときは、マウスやタッチでドラッグして表示位置を移動できます。「全体表示」で倍率と位置を戻します。表示の拡大・縮小・移動と背景変更は画面だけに作用し、印刷寸法や生成設定には影響しません。
|
|
56
|
+
|
|
57
|
+
「大きく表示」では入力を画面内の専用表示で確認できます。「大きく比較」では補正後入力と生成結果を同じ倍率・位置で並べます。専用表示はドラッグに加えて矢印キーで移動し、`+`/`-`で拡大・縮小、`0`で全体表示へ戻せます。Escまたは「閉じる」で元の操作箇所へ戻ります。
|
|
58
|
+
|
|
59
|
+
15インチ級ノートの基準画角を1440 × 900 CSS pxとします。幅1440px以上では主メニューを左に常設し、「作成」の設定と入力プレビュー、「ジョブ」の履歴と詳細をそれぞれ左右に並べます。幅1439px以下では主メニューを折りたたみ、900pxを超える間は作成・ジョブの左右配置を維持します。幅900px以下では縦配置へ切り替え、ジョブ詳細の「一覧へ戻る」で履歴へ復帰します。物理インチはブラウザから一意に判定できないため、切り替えにはCSSピクセル幅を使います。
|
|
60
|
+
|
|
61
|
+
## 変換の進捗と中止
|
|
62
|
+
|
|
63
|
+
「変換を開始」を押すとページ遷移せず、選択画像、編集値、スクロール位置を保持したまま、PumaのUIプロセスとは別のRubyプロセスで変換します。開始後に設定を編集すると、実行した設定から変わったことを画面に表示します。画面は`build/.jobs/<job_id>.json`を読み、工程、進捗率、完了・失敗・中止を更新します。JavaScriptを利用できない場合は通常のフォーム送信へフォールバックします。
|
|
64
|
+
|
|
65
|
+
変換ジョブには最終更新時刻と実行中の経過時間を表示します。一時的に状態を取得できない場合は、変換失敗と区別して通信エラーを示し、自動再接続と「状態を再取得」を利用できます。再取得では変換開始や停止要求を重複送信しません。失敗・停止後も編集内容を保持し、画像の再選択、設定の確認、対象ジョブのローカルログ表示へ移動できます。
|
|
66
|
+
|
|
67
|
+
同じプロジェクトで同時に変換できる通常生成と校正生成は合計1件です。別の変換が動作中の場合、新しいジョブは失敗として表示されます。「停止」は最初に共通の協調停止を要求し、UIが起動した子プロセスが応答しない場合だけ、そのプロセスグループへ`TERM`、続いて`KILL`を送ります。UI再起動後のプロセスやCLIのプロセスなど、UIが所有を確認できない処理は強制終了しません。
|
|
68
|
+
|
|
69
|
+
成果物ディレクトリを原子的に公開する直前が停止と完了の境界です。公開前の停止は`cancelled`となり未完成データを回収します。公開後に停止要求が届いた場合は成果物を保持して`completed`とします。子プロセスが異常終了した場合も、UIが終了を確認して状態と一時データを再照合します。
|
|
70
|
+
|
|
71
|
+
選択後の画像はランダムIDの`build/.previews/`へ一時保存し、画像交換、画面離脱、変換開始時に回収します。異常終了で残ったものは24時間経過後のUI起動時に回収します。変換開始後は`build/.web/`へコピーし、子プロセスの終了後に回収します。完了済みの`build/<job_id>/`には入力画像をコピーしません。
|
|
72
|
+
|
|
73
|
+
## 成果物を確認する
|
|
74
|
+
|
|
75
|
+
完了すると、ジョブ詳細を「プレビュー」「成果物」「設定・ログ」のタブで表示します。左右矢印キーでもタブを移動できます。「プレビュー」では補正後入力と生成結果を並べ、変換時の完成幅と高さに追従した表示枠で確認します。表示種別は、仕上がりから材料、解析、配置へ確認を進められるように、`CMYW → Cyan → Magenta → Yellow → White → 透過明度 → 厚み → タイル`の順で表示します。利用できない成果物は飛ばし、残る項目の順序は変えません。拡大・縮小・移動と背景変更も利用できます。「成果物」ではXY分割の全体図からタイルを選び、行列位置、実寸、完成全体での配置座標、プレート数、タイルプレビュー、3MF保存リンクを確認できます。「設定・ログ」では生成時の設定とローカルログへのリンクを確認できます。値は`manifest.json`を基準にします。過去ジョブにタイルプレビューや補正後入力がない場合は理由を表示し、存在する全3MFは引き続き表示します。成果物リンクはモデル、画像・配置、設定・記録、印刷説明に分類します。3MFとSTLはダウンロードとして扱い、画像とテキストはブラウザで開けます。
|
|
76
|
+
|
|
77
|
+
macOSでは、完了ジョブの各3MFに「Bambu Studioで開く」を表示します。未分割の`lithop.3mf`とXY分割された各タイルを1ファイルずつ選択できます。ボタンは保存済み3MFを変更せず、macOSへBambu Studioで開く要求を渡します。成功表示は要求をOSへ渡せたことを示すため、Bambu Studio側で対象ファイル、材料、プレート、寸法を確認してください。Bambu Studioで既存プロジェクトを開いている場合、アプリの状態によって別の3MFが表示されないことがあります。その場合も現在の作業は置き換えず、同じ行の「3MFを保存」から開けます。起動できない場合もZIP取得を利用できます。未保存プロジェクトの確認には自動応答しません。
|
|
78
|
+
|
|
79
|
+
最近のジョブには入力名、状態、進捗、完成寸法、タイル数、作成日時を表示します。状態で絞り込み、12件単位で追加表示できます。履歴から別のジョブを開くと、そのジョブに保存された`corrected-input.png`を「補正後入力」へ表示し、編集中の画像と設定は保持します。履歴は`build/.jobs/`の記録から復元されるため、UIを再起動しても完了済み成果物を開けます。この成果物を持たない旧ジョブでは補正後入力を復元できません。
|
|
80
|
+
|
|
81
|
+
履歴は入力名・ジョブIDの検索、開始日・終了日、状態、作成日時または入力名による並び替えに対応します。検索と絞り込みはサーバー側で履歴全体へ適用し、12件単位で取得します。完了ジョブには`preview.png`を遅延読み込みするサムネイルを表示します。履歴を開くだけでは全画像を読み込まず、ZIPも生成しません。
|
|
82
|
+
|
|
83
|
+
完了ジョブを2件選択して「2件を比較」を押すと、入力名、ジョブID、日時、寸法と生成画像を左右に表示します。比較の表示種別も通常プレビューと同じ順序に統一し、倍率、移動位置、背景を同期します。設定差は寸法、セル、配置、画像調整、厚み、分割に限って表にします。異なる縦横比は各画像の比率を保ち、片方にしかない画像や削除済みジョブを別ジョブの画像で補いません。比較から採用する設定は差分確認後に編集欄へ読み込めます。画面上の色比較は実測透過色を保証しません。
|
|
84
|
+
|
|
85
|
+
完了ジョブの成果物欄では、全3MFまたは全成果物をZIPで取得できます。生成前に対象の相対パス、件数、容量を表示し、状態JSONの`files`に成果物として記録されたファイルだけを格納します。ZIPの一時ファイルは送信完了または失敗時に削除し、UI再起動時にも残存分を回収します。
|
|
86
|
+
|
|
87
|
+
`completed`、`failed`、`cancelled`のジョブは、画面に表示するファイル数と容量を確認して個別に削除できます。削除対象は選択したジョブの成果物、状態JSON、ログ、中止要求、終了を確認できるUI一時データです。実行中と停止要求中のジョブは削除できません。削除は元に戻せず、他のジョブ、元画像、`config/settings.json`には影響しません。
|
|
88
|
+
|
|
89
|
+
終了済みジョブは複数選択して一括削除もできます。実行前に件数、ID、合計容量を表示し、実行中ジョブは対象にできません。各ジョブに個別削除と同じ安全確認を適用し、一部だけ失敗した場合は削除できなかったIDと理由を残します。
|
|
90
|
+
|
|
91
|
+
印刷前の確認手順は[Bambu Studioでの確認と印刷](bambu-studio.md)、全ファイルの意味は[生成物と記録](artifacts.md)、分割出力は[3MFタイル分割とZ分割](tiling.md)を参照してください。
|
|
92
|
+
|
|
93
|
+
## 採用した構成
|
|
94
|
+
|
|
95
|
+
ローカルUIにはSinatra 4、ERB、Puma 7を採用しました。既存のRuby実装へ小さなHTTP層を追加でき、サーバー側の入力検証とテンプレートを共通管理しながら、重い変換を別プロセスへ分離できるためです。
|
|
96
|
+
|
|
97
|
+
ERBテンプレート、CSS、JavaScriptはRubyGemへ同梱します。画面は外部CDN、Webフォント、外部JavaScriptを読み込まないため、gemとlibvipsの導入後はオフラインで動作します。状態を変えるリクエストには起動ごとのトークンを要求し、成果物はジョブ状態に記録された`build/`内のファイルだけを配信します。
|
|
98
|
+
|
|
99
|
+
## 開発時のブラウザ試験
|
|
100
|
+
|
|
101
|
+
実ブラウザの回帰試験にはPlaywrightを使用します。Node.jsとPlaywrightは開発時だけの依存関係であり、インストール済みRubyGemの実行には不要です。Node.js 22を用意し、初回だけ依存関係と固定ブラウザを導入します。
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
npm install
|
|
105
|
+
npx playwright install chromium webkit
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
試験は一時プロジェクトと専用ポートでUIを起動し、通常の`build/`を変更しません。
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
npm run test:browser
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
ChromiumとWebKitで、画像選択、プレビュー準備中を含むジョブ作成、設定保持、タイル境界、Z分割項目、成果物切り替え、Bambu Studioへ渡す3MFの選択、拡大専用表示、同期した比較、全体図からのタイル選択、通信切断からの復帰、設定再利用、2ジョブ比較、履歴検索、ZIP取得、複数削除、停止、主メニュー、画面間の状態保持、戻る・進む、共通ヘッダーの停止、ジョブ詳細のキーボード操作を確認します。試験では起動処理を差し替え、Bambu Studioを実際には起動しません。失敗時のスクリーンショット、動画、トレースは`tmp/playwright-results/`へ保存します。受入ブラウザは実機Chromeとし、WebKitはブラウザエンジン間の回帰検証に使用します。
|
data/docs/tiling.md
CHANGED
|
@@ -13,7 +13,7 @@ Lithopは、1枚では印刷できない大きさのリトファンを、指定
|
|
|
13
13
|
| Z方向の分割 | `separate_z_parts: true` | 各3MFに、3つの`Color region`と`Top White`の4プレート |
|
|
14
14
|
| XYとZの併用 | 両方を指定 | タイルごとに4プレートを持つ3MF |
|
|
15
15
|
|
|
16
|
-
`cmyk_white.stl`、`preview.png`、`transmission.png`、`thickness.png`、材料マスクは、常に分割前の完成全体を表します。XY分割時だけカラー3MFは`tiles/`へ出力され、ルートに`lithop.3mf
|
|
16
|
+
`cmyk_white.stl`、`preview.png`、`transmission.png`、`thickness.png`、材料マスクは、常に分割前の完成全体を表します。XY分割時だけカラー3MFは`tiles/`へ出力され、ルートに`lithop.3mf`は作りません。各3MFに対応する`tiles/tile_rNN_cNN-preview.png`も生成し、ローカルUIの変換ジョブでは全タイルを同時に確認できます。
|
|
17
17
|
|
|
18
18
|
## XY方向のタイル分割
|
|
19
19
|
|
|
@@ -113,7 +113,9 @@ build/<job_id>/
|
|
|
113
113
|
├── tile-layout.svg
|
|
114
114
|
├── tiles/
|
|
115
115
|
│ ├── tile_r01_c01.3mf # Color region 1–3、Top Whiteの4プレート
|
|
116
|
-
│
|
|
116
|
+
│ ├── tile_r01_c01-preview.png
|
|
117
|
+
│ ├── tile_r01_c02.3mf # Color region 1–3、Top Whiteの4プレート
|
|
118
|
+
│ └── tile_r01_c02-preview.png
|
|
117
119
|
├── manifest.json
|
|
118
120
|
├── settings.json
|
|
119
121
|
└── preview.png ほか
|
data/docs/troubleshooting.md
CHANGED
|
@@ -21,6 +21,16 @@ bundle exec lithop --version
|
|
|
21
21
|
|
|
22
22
|
**関連:** [導入と基本操作](getting-started.md#インストール)
|
|
23
23
|
|
|
24
|
+
## Rustネイティブ拡張をビルドできない
|
|
25
|
+
|
|
26
|
+
**症状:** `gem install lithop`または`bundle exec rake compile`が、Cargo、Rustコンパイラ、`rb_sys`に関するエラーで終了します。
|
|
27
|
+
|
|
28
|
+
**原因候補:** Rustツールチェーンが未導入、またはRubyを切り替えた後にネイティブ拡張を再ビルドしていません。
|
|
29
|
+
|
|
30
|
+
**対処:** macOSでは`brew install rust`を実行し、`rustc --version`と`cargo --version`を確認してからLithopを再インストールします。ソースチェックアウトでは`bundle install`に続けて`bundle exec rake compile`を実行します。`lithop doctor`で`backend: rust`と拡張バージョンを確認できます。Ruby版との比較用に`LITHOP_DISABLE_NATIVE=1`を指定できますが、インストール時の拡張ビルドを省略する設定ではありません。
|
|
31
|
+
|
|
32
|
+
現在のCI対象はmacOSとRuby 4.0で、実測環境はApple siliconです。他のOS、CPU、Ruby ABI向けのビルド済みgemは配布せず、0.3.0は利用環境でRust拡張をビルドするソースgemとして扱います。
|
|
33
|
+
|
|
24
34
|
## 入力画像を読めない
|
|
25
35
|
|
|
26
36
|
**最初の確認:** ファイルが実在し、JPEG、PNG、またはSVGとして画像アプリで開けるか確認します。
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "lithop_native"
|
|
3
|
+
version = "0.3.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
publish = false
|
|
6
|
+
license = "MIT"
|
|
7
|
+
|
|
8
|
+
[lib]
|
|
9
|
+
crate-type = ["cdylib"]
|
|
10
|
+
|
|
11
|
+
[dependencies]
|
|
12
|
+
libc = "0.2"
|
|
13
|
+
magnus = "0.8.2"
|
|
14
|
+
rb-sys = { version = "0.9", features = ["stable-api-compiled-fallback"] }
|
|
15
|
+
|
|
16
|
+
[build-dependencies]
|
|
17
|
+
rb-sys-env = "0.2.2"
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
use crate::mesh::{index_mesh_vertex, MeshBuilder, MeshData, VertexKey};
|
|
2
|
+
use crate::COMPONENTS;
|
|
3
|
+
use magnus::{Error, Ruby};
|
|
4
|
+
|
|
5
|
+
struct ColumnMeshInput<'a> {
|
|
6
|
+
columns: usize,
|
|
7
|
+
rows: usize,
|
|
8
|
+
x_boundaries: &'a [f64],
|
|
9
|
+
y_boundaries: &'a [f64],
|
|
10
|
+
z_boundaries: &'a [f64],
|
|
11
|
+
intervals: Vec<Option<(usize, usize)>>,
|
|
12
|
+
group_ids: &'a [usize],
|
|
13
|
+
group_count: usize,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
impl ColumnMeshInput<'_> {
|
|
17
|
+
fn interval_at(&self, row: isize, column: isize) -> Option<(usize, usize)> {
|
|
18
|
+
if row < 0 || column < 0 || row as usize >= self.rows || column as usize >= self.columns {
|
|
19
|
+
return None;
|
|
20
|
+
}
|
|
21
|
+
self.intervals[row as usize * self.columns + column as usize]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
fn group_at(&self, row: isize, column: isize) -> usize {
|
|
25
|
+
if row < 0 || column < 0 || row as usize >= self.rows || column as usize >= self.columns {
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
self.group_ids[row as usize * self.columns + column as usize]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
fn grouped_interval(
|
|
32
|
+
&self,
|
|
33
|
+
row: usize,
|
|
34
|
+
column: usize,
|
|
35
|
+
neighbour_row: isize,
|
|
36
|
+
neighbour_column: isize,
|
|
37
|
+
) -> Option<(usize, usize)> {
|
|
38
|
+
if self.group_at(row as isize, column as isize)
|
|
39
|
+
!= self.group_at(neighbour_row, neighbour_column)
|
|
40
|
+
{
|
|
41
|
+
return None;
|
|
42
|
+
}
|
|
43
|
+
self.interval_at(neighbour_row, neighbour_column)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fn occupied(&self, layer: isize, row: isize, column: isize, group: usize) -> bool {
|
|
47
|
+
if layer < 0 || self.group_at(row, column) != group {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
self.interval_at(row, column)
|
|
51
|
+
.is_some_and(|(bottom, top)| layer as usize >= bottom && (layer as usize) < top)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
pub(crate) fn column_mesh(
|
|
56
|
+
ruby: &Ruby,
|
|
57
|
+
columns: usize,
|
|
58
|
+
rows: usize,
|
|
59
|
+
x_boundaries: Vec<f64>,
|
|
60
|
+
y_boundaries: Vec<f64>,
|
|
61
|
+
z_boundaries: Vec<f64>,
|
|
62
|
+
raw_intervals: Vec<i64>,
|
|
63
|
+
group_ids: Vec<usize>,
|
|
64
|
+
group_count: usize,
|
|
65
|
+
) -> Result<MeshBuilder, Error> {
|
|
66
|
+
let expected_cells = columns.saturating_mul(rows);
|
|
67
|
+
let valid = columns > 0
|
|
68
|
+
&& rows > 0
|
|
69
|
+
&& x_boundaries.len() == columns + 1
|
|
70
|
+
&& y_boundaries.len() == rows + 1
|
|
71
|
+
&& z_boundaries.len() >= 2
|
|
72
|
+
&& raw_intervals.len() == expected_cells * 2
|
|
73
|
+
&& group_ids.len() == expected_cells
|
|
74
|
+
&& group_count > 0
|
|
75
|
+
&& x_boundaries.iter().all(|value| value.is_finite())
|
|
76
|
+
&& y_boundaries.iter().all(|value| value.is_finite())
|
|
77
|
+
&& z_boundaries.iter().all(|value| value.is_finite());
|
|
78
|
+
if !valid {
|
|
79
|
+
return Err(Error::new(
|
|
80
|
+
ruby.exception_arg_error(),
|
|
81
|
+
"invalid native column mesh input",
|
|
82
|
+
));
|
|
83
|
+
}
|
|
84
|
+
let maximum_vertices_per_cell =
|
|
85
|
+
8_usize.saturating_add(16_usize.saturating_mul(z_boundaries.len().saturating_sub(1)));
|
|
86
|
+
if expected_cells.saturating_mul(maximum_vertices_per_cell) > u32::MAX as usize {
|
|
87
|
+
return Err(Error::new(
|
|
88
|
+
ruby.exception_arg_error(),
|
|
89
|
+
"native column mesh contains too many vertices",
|
|
90
|
+
));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let intervals = raw_intervals
|
|
94
|
+
.chunks_exact(2)
|
|
95
|
+
.map(|pair| match pair {
|
|
96
|
+
[-1, -1] => Ok(None),
|
|
97
|
+
[bottom, top] if *bottom >= 0 && *top > *bottom && *top < z_boundaries.len() as i64 => {
|
|
98
|
+
Ok(Some((*bottom as usize, *top as usize)))
|
|
99
|
+
}
|
|
100
|
+
_ => Err(Error::new(
|
|
101
|
+
ruby.exception_arg_error(),
|
|
102
|
+
"invalid native column interval",
|
|
103
|
+
)),
|
|
104
|
+
})
|
|
105
|
+
.collect::<Result<Vec<_>, _>>()?;
|
|
106
|
+
let input = ColumnMeshInput {
|
|
107
|
+
columns,
|
|
108
|
+
rows,
|
|
109
|
+
x_boundaries: &x_boundaries,
|
|
110
|
+
y_boundaries: &y_boundaries,
|
|
111
|
+
z_boundaries: &z_boundaries,
|
|
112
|
+
intervals,
|
|
113
|
+
group_ids: &group_ids,
|
|
114
|
+
group_count,
|
|
115
|
+
};
|
|
116
|
+
let mut data = MeshData::default();
|
|
117
|
+
data.triangles.reserve_exact(column_triangle_count(&input));
|
|
118
|
+
for row in 0..rows {
|
|
119
|
+
for column in 0..columns {
|
|
120
|
+
let Some(current) = input.interval_at(row as isize, column as isize) else {
|
|
121
|
+
continue;
|
|
122
|
+
};
|
|
123
|
+
emit_column_caps(&mut data, &input, row, column, current);
|
|
124
|
+
emit_column_sides(&mut data, &input, row, column, current);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
Ok(MeshBuilder::from_data(data))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
type ColumnOwner = (usize, usize, usize);
|
|
131
|
+
|
|
132
|
+
fn column_triangle_count(input: &ColumnMeshInput<'_>) -> usize {
|
|
133
|
+
let mut count = 0;
|
|
134
|
+
for row in 0..input.rows {
|
|
135
|
+
for column in 0..input.columns {
|
|
136
|
+
let Some(current) = input.interval_at(row as isize, column as isize) else {
|
|
137
|
+
continue;
|
|
138
|
+
};
|
|
139
|
+
count += 4;
|
|
140
|
+
count += 2 * [
|
|
141
|
+
(row as isize, column as isize - 1),
|
|
142
|
+
(row as isize, column as isize + 1),
|
|
143
|
+
(row as isize + 1, column as isize),
|
|
144
|
+
(row as isize - 1, column as isize),
|
|
145
|
+
]
|
|
146
|
+
.into_iter()
|
|
147
|
+
.map(|(neighbour_row, neighbour_column)| {
|
|
148
|
+
exposed_layer_count(
|
|
149
|
+
current,
|
|
150
|
+
input.grouped_interval(row, column, neighbour_row, neighbour_column),
|
|
151
|
+
)
|
|
152
|
+
})
|
|
153
|
+
.sum::<usize>();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
count
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
fn emit_column_caps(
|
|
160
|
+
data: &mut MeshData,
|
|
161
|
+
input: &ColumnMeshInput<'_>,
|
|
162
|
+
row: usize,
|
|
163
|
+
column: usize,
|
|
164
|
+
current: (usize, usize),
|
|
165
|
+
) {
|
|
166
|
+
let (bottom, top) = current;
|
|
167
|
+
let bottom_owner = (bottom, row, column);
|
|
168
|
+
let top_owner = (top - 1, row, column);
|
|
169
|
+
emit_column_quad(
|
|
170
|
+
data,
|
|
171
|
+
[
|
|
172
|
+
column_vertex(input, column, row + 1, bottom, bottom_owner),
|
|
173
|
+
column_vertex(input, column, row, bottom, bottom_owner),
|
|
174
|
+
column_vertex(input, column + 1, row, bottom, bottom_owner),
|
|
175
|
+
column_vertex(input, column + 1, row + 1, bottom, bottom_owner),
|
|
176
|
+
],
|
|
177
|
+
);
|
|
178
|
+
emit_column_quad(
|
|
179
|
+
data,
|
|
180
|
+
[
|
|
181
|
+
column_vertex(input, column, row + 1, top, top_owner),
|
|
182
|
+
column_vertex(input, column + 1, row + 1, top, top_owner),
|
|
183
|
+
column_vertex(input, column + 1, row, top, top_owner),
|
|
184
|
+
column_vertex(input, column, row, top, top_owner),
|
|
185
|
+
],
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
fn emit_column_sides(
|
|
190
|
+
data: &mut MeshData,
|
|
191
|
+
input: &ColumnMeshInput<'_>,
|
|
192
|
+
row: usize,
|
|
193
|
+
column: usize,
|
|
194
|
+
current: (usize, usize),
|
|
195
|
+
) {
|
|
196
|
+
for layer in exposed_layers(
|
|
197
|
+
current,
|
|
198
|
+
input.grouped_interval(row, column, row as isize, column as isize - 1),
|
|
199
|
+
) {
|
|
200
|
+
let owner = (layer, row, column);
|
|
201
|
+
emit_column_quad(
|
|
202
|
+
data,
|
|
203
|
+
[
|
|
204
|
+
column_vertex(input, column, row + 1, layer, owner),
|
|
205
|
+
column_vertex(input, column, row + 1, layer + 1, owner),
|
|
206
|
+
column_vertex(input, column, row, layer + 1, owner),
|
|
207
|
+
column_vertex(input, column, row, layer, owner),
|
|
208
|
+
],
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
for layer in exposed_layers(
|
|
212
|
+
current,
|
|
213
|
+
input.grouped_interval(row, column, row as isize, column as isize + 1),
|
|
214
|
+
) {
|
|
215
|
+
let owner = (layer, row, column);
|
|
216
|
+
emit_column_quad(
|
|
217
|
+
data,
|
|
218
|
+
[
|
|
219
|
+
column_vertex(input, column + 1, row + 1, layer, owner),
|
|
220
|
+
column_vertex(input, column + 1, row, layer, owner),
|
|
221
|
+
column_vertex(input, column + 1, row, layer + 1, owner),
|
|
222
|
+
column_vertex(input, column + 1, row + 1, layer + 1, owner),
|
|
223
|
+
],
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
for layer in exposed_layers(
|
|
227
|
+
current,
|
|
228
|
+
input.grouped_interval(row, column, row as isize + 1, column as isize),
|
|
229
|
+
) {
|
|
230
|
+
let owner = (layer, row, column);
|
|
231
|
+
emit_column_quad(
|
|
232
|
+
data,
|
|
233
|
+
[
|
|
234
|
+
column_vertex(input, column, row + 1, layer, owner),
|
|
235
|
+
column_vertex(input, column + 1, row + 1, layer, owner),
|
|
236
|
+
column_vertex(input, column + 1, row + 1, layer + 1, owner),
|
|
237
|
+
column_vertex(input, column, row + 1, layer + 1, owner),
|
|
238
|
+
],
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
for layer in exposed_layers(
|
|
242
|
+
current,
|
|
243
|
+
input.grouped_interval(row, column, row as isize - 1, column as isize),
|
|
244
|
+
) {
|
|
245
|
+
let owner = (layer, row, column);
|
|
246
|
+
emit_column_quad(
|
|
247
|
+
data,
|
|
248
|
+
[
|
|
249
|
+
column_vertex(input, column, row, layer, owner),
|
|
250
|
+
column_vertex(input, column, row, layer + 1, owner),
|
|
251
|
+
column_vertex(input, column + 1, row, layer + 1, owner),
|
|
252
|
+
column_vertex(input, column + 1, row, layer, owner),
|
|
253
|
+
],
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
fn exposed_layers(current: (usize, usize), neighbour: Option<(usize, usize)>) -> Vec<usize> {
|
|
259
|
+
let (bottom, top) = current;
|
|
260
|
+
let Some((neighbour_bottom, neighbour_top)) = neighbour else {
|
|
261
|
+
return (bottom..top).collect();
|
|
262
|
+
};
|
|
263
|
+
if neighbour_top <= bottom || neighbour_bottom >= top {
|
|
264
|
+
return (bottom..top).collect();
|
|
265
|
+
}
|
|
266
|
+
let mut layers = Vec::new();
|
|
267
|
+
if neighbour_bottom > bottom {
|
|
268
|
+
layers.extend(bottom..top.min(neighbour_bottom));
|
|
269
|
+
}
|
|
270
|
+
if neighbour_top < top {
|
|
271
|
+
layers.extend(bottom.max(neighbour_top)..top);
|
|
272
|
+
}
|
|
273
|
+
layers
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
fn exposed_layer_count(current: (usize, usize), neighbour: Option<(usize, usize)>) -> usize {
|
|
277
|
+
let (bottom, top) = current;
|
|
278
|
+
let Some((neighbour_bottom, neighbour_top)) = neighbour else {
|
|
279
|
+
return top - bottom;
|
|
280
|
+
};
|
|
281
|
+
if neighbour_top <= bottom || neighbour_bottom >= top {
|
|
282
|
+
return top - bottom;
|
|
283
|
+
}
|
|
284
|
+
neighbour_bottom.saturating_sub(bottom).min(top - bottom)
|
|
285
|
+
+ top.saturating_sub(neighbour_top).min(top - bottom)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
fn column_vertex(
|
|
289
|
+
input: &ColumnMeshInput<'_>,
|
|
290
|
+
x_index: usize,
|
|
291
|
+
row_boundary: usize,
|
|
292
|
+
z_index: usize,
|
|
293
|
+
owner: ColumnOwner,
|
|
294
|
+
) -> ([f64; COMPONENTS], VertexKey) {
|
|
295
|
+
let (_, owner_row, owner_column) = owner;
|
|
296
|
+
let group = input.group_at(owner_row as isize, owner_column as isize);
|
|
297
|
+
let coordinate_key =
|
|
298
|
+
((x_index * (input.rows + 1) + row_boundary) * input.z_boundaries.len()) + z_index;
|
|
299
|
+
let cache_key = coordinate_key * input.group_count + group;
|
|
300
|
+
let mask = occupancy_mask(input, x_index, row_boundary, z_index, group);
|
|
301
|
+
let owner_bit = owner_bit(owner, x_index, row_boundary, z_index);
|
|
302
|
+
let component = component_root(mask, owner_bit);
|
|
303
|
+
let topology = cache_key * 8 + component;
|
|
304
|
+
let point = [
|
|
305
|
+
input.x_boundaries[x_index],
|
|
306
|
+
input.y_boundaries[input.y_boundaries.len() - 1] - input.y_boundaries[row_boundary],
|
|
307
|
+
input.z_boundaries[z_index],
|
|
308
|
+
];
|
|
309
|
+
(point, VertexKey::Topology(topology as i64))
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
fn occupancy_mask(
|
|
313
|
+
input: &ColumnMeshInput<'_>,
|
|
314
|
+
x_index: usize,
|
|
315
|
+
row_boundary: usize,
|
|
316
|
+
z_index: usize,
|
|
317
|
+
group: usize,
|
|
318
|
+
) -> u8 {
|
|
319
|
+
let mut mask = 0;
|
|
320
|
+
for layer_offset in 0..2 {
|
|
321
|
+
for row_offset in 0..2 {
|
|
322
|
+
for column_offset in 0..2 {
|
|
323
|
+
let bit = layer_offset * 4 + row_offset * 2 + column_offset;
|
|
324
|
+
if input.occupied(
|
|
325
|
+
z_index as isize - 1 + layer_offset as isize,
|
|
326
|
+
row_boundary as isize - 1 + row_offset as isize,
|
|
327
|
+
x_index as isize - 1 + column_offset as isize,
|
|
328
|
+
group,
|
|
329
|
+
) {
|
|
330
|
+
mask |= 1 << bit;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
mask
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
fn owner_bit(owner: ColumnOwner, x_index: usize, row_boundary: usize, z_index: usize) -> usize {
|
|
339
|
+
let (layer, row, column) = owner;
|
|
340
|
+
((layer as isize - z_index as isize + 1) * 4
|
|
341
|
+
+ (row as isize - row_boundary as isize + 1) * 2
|
|
342
|
+
+ column as isize
|
|
343
|
+
- x_index as isize
|
|
344
|
+
+ 1) as usize
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
fn component_root(mask: u8, start: usize) -> usize {
|
|
348
|
+
let mut visited = [false; 8];
|
|
349
|
+
let mut pending = [0; 8];
|
|
350
|
+
pending[0] = start;
|
|
351
|
+
let mut pending_count = 1;
|
|
352
|
+
visited[start] = true;
|
|
353
|
+
let mut root = start;
|
|
354
|
+
while pending_count > 0 {
|
|
355
|
+
pending_count -= 1;
|
|
356
|
+
let current = pending[pending_count];
|
|
357
|
+
root = root.min(current);
|
|
358
|
+
for candidate in 0..8 {
|
|
359
|
+
let difference = current ^ candidate;
|
|
360
|
+
if !visited[candidate]
|
|
361
|
+
&& mask & (1 << candidate) != 0
|
|
362
|
+
&& matches!(difference, 1 | 2 | 4)
|
|
363
|
+
{
|
|
364
|
+
visited[candidate] = true;
|
|
365
|
+
pending[pending_count] = candidate;
|
|
366
|
+
pending_count += 1;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
root
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
fn emit_column_quad(data: &mut MeshData, vertices: [([f64; COMPONENTS], VertexKey); 4]) {
|
|
374
|
+
let indices = vertices.map(|(point, key)| index_mesh_vertex(data, point, key));
|
|
375
|
+
data.triangles.push([indices[0], indices[1], indices[2]]);
|
|
376
|
+
data.triangles.push([indices[0], indices[2], indices[3]]);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
#[cfg(test)]
|
|
380
|
+
mod tests {
|
|
381
|
+
use super::component_root;
|
|
382
|
+
|
|
383
|
+
#[test]
|
|
384
|
+
fn finds_face_connected_voxel_components_without_joining_diagonals() {
|
|
385
|
+
assert_eq!(component_root(0b0000_0011, 1), 0);
|
|
386
|
+
assert_eq!(component_root(0b1000_0001, 7), 7);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
mod column_mesh;
|
|
2
|
+
mod mesh;
|
|
3
|
+
mod palette;
|
|
4
|
+
mod xml;
|
|
5
|
+
|
|
6
|
+
use column_mesh::column_mesh;
|
|
7
|
+
use magnus::{function, method, prelude::*, Error, Ruby};
|
|
8
|
+
use mesh::MeshBuilder;
|
|
9
|
+
use palette::palette_indices;
|
|
10
|
+
use xml::{triangle_xml, vertex_xml};
|
|
11
|
+
|
|
12
|
+
pub(crate) const COMPONENTS: usize = 3;
|
|
13
|
+
|
|
14
|
+
fn extension_version() -> &'static str {
|
|
15
|
+
env!("CARGO_PKG_VERSION")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
#[magnus::init]
|
|
19
|
+
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
20
|
+
let lithop = ruby.define_module("Lithop")?;
|
|
21
|
+
let native = lithop.define_module("Native")?;
|
|
22
|
+
native.define_singleton_method("palette_indices", function!(palette_indices, 2))?;
|
|
23
|
+
native.define_singleton_method("vertex_xml", function!(vertex_xml, 2))?;
|
|
24
|
+
native.define_singleton_method("triangle_xml", function!(triangle_xml, 1))?;
|
|
25
|
+
native.define_singleton_method("extension_version", function!(extension_version, 0))?;
|
|
26
|
+
native.define_singleton_method("column_mesh", function!(column_mesh, 8))?;
|
|
27
|
+
let mesh_builder = native.define_class("MeshBuilder", ruby.class_object())?;
|
|
28
|
+
mesh_builder.define_singleton_method("new", function!(MeshBuilder::new, 0))?;
|
|
29
|
+
mesh_builder.define_method("add_batch", method!(MeshBuilder::add_batch, 2))?;
|
|
30
|
+
mesh_builder.define_method("vertex_count", method!(MeshBuilder::vertex_count, 0))?;
|
|
31
|
+
mesh_builder.define_method("triangle_count", method!(MeshBuilder::triangle_count, 0))?;
|
|
32
|
+
mesh_builder.define_method("center", method!(MeshBuilder::center, 0))?;
|
|
33
|
+
mesh_builder.define_method("vertex_xml", method!(MeshBuilder::vertex_xml, 3))?;
|
|
34
|
+
mesh_builder.define_method("triangle_xml", method!(MeshBuilder::triangle_xml, 2))?;
|
|
35
|
+
mesh_builder.define_method("memory_bytes", method!(MeshBuilder::memory_bytes, 0))?;
|
|
36
|
+
Ok(())
|
|
37
|
+
}
|