diff --git a/CLAUDE.md b/CLAUDE.md index 1adde03..fc39f68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,8 +5,8 @@ ## 開発進捗 -現在の進捗: フェーズ 7(実装)— Step 1〜6 完了. -次のアクション: `/implement Step 7: 基準設計のキャリブレーション`([PLAN_02 参照](docs/03_PLAN/PLAN_02_開発ステップ.md)) +現在の進捗: フェーズ 7(実装)— Step 1〜7 完了. +次のアクション: `/implement Step 8: 基準設計のキャリブレーション`([PLAN_02 参照](docs/03_PLAN/PLAN_02_開発ステップ.md)) 実装済みモジュール: - `src/sm_tias_lightsim/uniformity.py` — Rec.709 輝度変換 + CoV/std/max-min(MiniTias-Evaluation コピー) @@ -23,6 +23,9 @@ - `src/sm_tias_lightsim/render/output.py` — PNG / luminance.npy 出力 - `src/sm_tias_lightsim/cli/render.py` — `sm-render` CLI(YAML → PNG / luminance.npy) - `src/sm_tias_lightsim/cli/evaluate.py` — `sm-evaluate` CLI(PNG / luminance.npy → metrics.csv + figures/) +- `src/sm_tias_lightsim/visualize/baker.py` — 各 face 中心の直接照度ベイク(shadow ray + cosine + 距離減衰) +- `src/sm_tias_lightsim/visualize/viewer.py` — PyVista 3D ビューア(メッシュ着色 + LED + センサ + 座標軸) +- `src/sm_tias_lightsim/cli/visualize.py` — `sm-visualize` CLI(YAML → 3D 表示 / screenshot) ※ ステップ完了時にここを更新すること. ## 必須ルール(コード実装時) diff --git "a/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" "b/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" index aa44a9d..c0fbf04 100644 --- "a/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" +++ "b/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" @@ -59,7 +59,8 @@ | シーン定義 | `pydantic` | YAML スキーマのバリデーション | | 設定ファイル | `pyyaml` | YAML パース | | 画像 I/O | `pillow` | PNG 出力(実機画像と同形式に揃える) | -| 可視化 | `matplotlib` | 輝度マップ・ヒストグラム・放射状プロファイル等 | +| 可視化(2D) | `matplotlib` | 輝度マップ・ヒストグラム・放射状プロファイル等 | +| 可視化(3D) | `pyvista` | 照度ベイク結果のインタラクティブ 3D ビューア(`sm-visualize`) | | 表データ | `pandas` | 均一性指標 CSV の生成・統計集計 | | テスト | `pytest` | 単体テスト | | Lint / Format | `ruff` | コードチェック・整形(uv エコシステムと整合) | diff --git "a/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" "b/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" index a5eea69..acef716 100644 --- "a/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" +++ "b/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" @@ -23,8 +23,9 @@ | 4 | シーン構築 + 最小レンダー | `scene/builder.py`, `scene/lights.py`, `scene/materials.py`, `scene/sensors.py`, `render/runner.py`, `render/output.py` | Step 3 | | 5 | render CLI | `cli/render.py` + 統合テスト | Step 4 | | 6 | evaluate CLI + 可視化 | `cli/evaluate.py`, `evaluation/roi.py`, `evaluation/visualize.py` | Step 1, 5 | -| 7 | 基準設計のキャリブレーション | `configs/scene_baseline.yaml`, `configs/meshes/*.stl`, キャリブ報告 | Step 5, 6 | -| 8 | 設計バリエーション比較 | バリエーション YAML 複数 + 比較レポート | Step 7 | +| 7 | 3D 照度ビューア(キャリブ調査ツール) | `visualize/baker.py`, `visualize/viewer.py`, `cli/visualize.py` | Step 4, 5 | +| 8 | 基準設計のキャリブレーション | `configs/scene_baseline.yaml`, `configs/meshes/*.stl`, キャリブ報告 | Step 5, 6, 7 | +| 9 | 設計バリエーション比較 | バリエーション YAML 複数 + 比較レポート | Step 8 | ## 人間の確認ポイント (Human Checkpoints) @@ -37,8 +38,9 @@ | 4 | なし | **レンダリング PNG の目視確認**(光が物理的に妥当か) | 最小シーンが完走 | | 5 | なし | CLI 入出力の確認 | コマンドが期待動作 | | 6 | MiniTias-Evaluation の実測 PNG | **実測と評価結果の数値一致を確認** | 既知値と一致 | -| **7** | **Fusion 360 → STL,LED 配置,材料初期値** | **実測との突合.パラメータ調整判断**(反復) | **許容範囲内で一致.数値を本ファイルに記録** | -| **8** | **改良案の Fusion 360 → STL(複数)** | **各バリエーションの結果評価と採用候補の選定** | **比較表完成.最低 1 案がベースライン改善** | +| **7** | なし | **sm-visualize の 3D 表示確認(LED 位置・照度分布の目視)** | minimal scene で照度分布が解析解と数 % 以内 | +| **8** | **Fusion 360 → STL,LED 配置,材料初期値** | **実測との突合.パラメータ調整判断**(反復) | **許容範囲内で一致.数値を本ファイルに記録** | +| **9** | **改良案の Fusion 360 → STL(複数)** | **各バリエーションの結果評価と採用候補の選定** | **比較表完成.最低 1 案がベースライン改善** | 人間の本格的な介入は Step 7 以降.それ以前は AI 主導で進められる. @@ -108,7 +110,18 @@ - テスト: 実測 PNG(MiniTias-Evaluation サンプル)と既知の指標値が一致すること - **完了条件**: シミュ PNG と実測 PNG の両方で同じ指標 CSV が出る -### Step 7: 基準設計のキャリブレーション +### Step 7: 3D 照度ビューア(キャリブ調査ツール) + +- **目的**: Step 8 のキャリブレーション作業で「実測と合わない」原因を 3D 視認で切り分けられるようにする.LED 配置・材料・ジオメトリのミスを評価板の数値だけでなく筐体内の照度分布として観察できる +- **やること**: + - 依存追加: `uv add pyvista` + - `visualize/baker.py`:直接照度ベイク(各 face 中心 → emitter への shadow ray + cosine 項 + 距離減衰) + - `visualize/viewer.py`:PyVista プロッタ(メッシュ着色 + LED 球/矢印 + センサ視錐台 + 座標軸) + - `cli/visualize.py`:`sm-visualize` CLI + - テスト:解析解(I·cosθ/r²)との一致 + 遮蔽 shadow ray + screenshot smoke test +- **完了条件**: minimal scene で `sm-visualize` がインタラクティブウィンドウを開き,評価板の照度分布が解析解と数 % 以内で一致する + +### Step 8: 基準設計のキャリブレーション - **目的**: シミュレーションの妥当性を実測値で検証.以降の改良案の基準を作る - **やること**: @@ -123,7 +136,7 @@ - データを見て調整.確定値を本ファイルの末尾に追記 - **完了条件**: 実測値と仮置き許容範囲内で一致.数値を本ファイルに記録 -### Step 8: 設計バリエーション比較 +### Step 9: 設計バリエーション比較 - **目的**: 改良案を複数試して,実装に進む候補を絞る - **やること**: @@ -151,5 +164,5 @@ - **M1(Step 1〜2 完了)**: 評価ロジック + スキーマが動く.まだ実物シミュは動かない - **M2(Step 4〜5 完了)**: シミュレーションの 1 周(YAML → PNG)が動く - **M3(Step 6 完了)**: シミュ・実測の同一評価パイプラインが完成 -- **M4(Step 7 完了)**: 基準設計と実測の整合が取れた.ここまでが「環境完成」 -- **M5(Step 8)**: 改良案が複数比較できる.プロジェクトの本来目的を達成 +- **M4(Step 8 完了)**: 基準設計と実測の整合が取れた.ここまでが「環境完成」 +- **M5(Step 9)**: 改良案が複数比較できる.プロジェクトの本来目的を達成 diff --git "a/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" "b/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" index e3d4b21..b9342a3 100644 --- "a/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" +++ "b/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" @@ -71,9 +71,13 @@ │ ├── zones.py # 3 ゾーン分析(center / middle / periphery) │ ├── radial.py # 放射状プロファイル生成 │ └── visualize.py # matplotlib による可視化 PNG 出力 +├── visualize/ +│ ├── baker.py # 各 face 中心の直接照度ベイク(shadow ray + cosine + 距離減衰) +│ └── viewer.py # PyVista による 3D インタラクティブビューア └── cli/ ├── render.py # `sm-render` エントリポイント - └── evaluate.py # `sm-evaluate` エントリポイント + ├── evaluate.py # `sm-evaluate` エントリポイント + └── visualize.py # `sm-visualize` エントリポイント ``` ## 各層の責務 (Module Responsibilities) @@ -92,7 +96,9 @@ | `evaluation/zones` | 輝度配列 → 中心 / 中間 / 周辺の各統計 | 距離正規化を内部で行う | | `evaluation/radial` | 輝度配列 → 中心からの距離 vs 平均輝度 | プロット用データ | | `evaluation/visualize` | 各種統計 → matplotlib 図 PNG | 可視化のみ.データ加工はしない | -| `cli/render`, `cli/evaluate` | 各ステップの CLI ラッパー | 引数パース・ファイル I/O | +| `visualize/baker` | シーン + 光源 → 各 mesh face 中心の直接照度(W/m²) | Phase A は直接光のみ(shadow ray + cosine + 距離減衰).Phase B で多重反射対応予定 | +| `visualize/viewer` | シーン + 照度 → PyVista インタラクティブ 3D ウィンドウ | メッシュ着色(viridis)/ 光源マーカー / センサ視線 / 座標軸 | +| `cli/render`, `cli/evaluate`, `cli/visualize` | 各ステップの CLI ラッパー | 引数パース・ファイル I/O | ## エントリポイント (CLI Entry Points) @@ -102,6 +108,7 @@ [project.scripts] sm-render = "sm_tias_lightsim.cli.render:main" sm-evaluate = "sm_tias_lightsim.cli.evaluate:main" +sm-visualize = "sm_tias_lightsim.cli.visualize:main" ``` 使い方: @@ -115,6 +122,10 @@ # 評価(実測画像) uv run sm-evaluate --image data/whiteboard_real.png --config configs/eval_default.yaml --output output/real/ + +# 3D 照度ビューア(キャリブレーション中の調査ツール) +uv run sm-visualize --scene configs/scene_baseline.yaml # インタラクティブウィンドウを開く +uv run sm-visualize --scene configs/scene_baseline.yaml --screenshot vis.png # PNG 保存のみ ``` ## ディレクトリ構成(プロジェクトルート) diff --git a/pyproject.toml b/pyproject.toml index aa16c98..1cad8e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ "pandas>=3.0.2", "pillow>=12.2.0", "pydantic>=2.13.3", + "pyvista>=0.45", "pyyaml>=6.0.3", "trimesh>=4.12.1", ] @@ -20,6 +21,7 @@ sm-tias-lightsim = "sm_tias_lightsim:main" sm-render = "sm_tias_lightsim.cli.render:main" sm-evaluate = "sm_tias_lightsim.cli.evaluate:main" +sm-visualize = "sm_tias_lightsim.cli.visualize:main" [build-system] requires = ["uv_build>=0.11.7,<0.12.0"] diff --git a/src/sm_tias_lightsim/cli/visualize.py b/src/sm_tias_lightsim/cli/visualize.py new file mode 100644 index 0000000..535506a --- /dev/null +++ b/src/sm_tias_lightsim/cli/visualize.py @@ -0,0 +1,160 @@ +# pyright: reportCallIssue=none +# Mitsuba 3 は variant ベースの動的属性解決を行うため Pylance/Pyright が +# mi.* を object 型と推論し reportCallIssue を誤検知する(GUIDE_06 の方針). +"""sm-visualize コマンドラインエントリポイント. + +YAML シーン定義を読み込み,3D 照度ベイクと PyVista インタラクティブビューアを提供する. + +処理フロー: + 1. YAML シーン定義を読み込み SceneConfig を得る. + 2. --no-bake 指定がない場合,bake_irradiance で直接照度を計算する. + 3. show_scene で PyVista 表示(または screenshot 保存)を行う. + 4. --output 指定があれば per-face irradiance を npz 形式で保存する. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """コマンドライン引数をパースして返す. + + Args: + argv: 引数リスト.None のとき sys.argv[1:] を使用. + + Returns: + パース済み Namespace. + """ + parser = argparse.ArgumentParser( + prog="sm-visualize", + description=( + "YAML シーン定義から 3D 照度ベイクを実行し,PyVista でインタラクティブ表示する." + ), + ) + parser.add_argument( + "--scene", + "-s", + required=True, + type=Path, + metavar="YAML_PATH", + help="シーン定義 YAML ファイルのパス.", + ) + parser.add_argument( + "--no-bake", + action="store_true", + default=False, + help="照度ベイクをスキップしてシーンレイアウトのみ表示する.", + ) + parser.add_argument( + "--screenshot", + type=Path, + default=None, + metavar="PNG_PATH", + help="スクリーンショット保存先 PNG パス.指定時はウィンドウを開かず off_screen で保存する", + ) + parser.add_argument( + "--output", + "-o", + type=Path, + default=None, + metavar="OUTPUT_DIR", + help="ベイク結果(per-face irradiance)を npz 形式で保存するディレクトリ(任意).", + ) + parser.add_argument( + "--off-screen", + action="store_true", + default=False, + help="ウィンドウを開かない(screenshot 専用モード).", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """sm-visualize コマンドのメインエントリポイント. + + 処理フロー: + 1. コマンドライン引数をパースする. + 2. YAML シーン定義を load_scene_yaml で読み込み,SceneConfig を得る. + 3. --no-bake がなければ bake_irradiance で照度を計算する. + 4. --output が指定されていれば per-face irradiance を npz で保存する. + 5. show_scene で PyVista 表示(または screenshot 保存)を行う. + + Args: + argv: 引数リスト.None のとき sys.argv[1:] を使用. + + Returns: + 終了コード(成功時 0,失敗時 1). + """ + args = _parse_args(argv) + + scene_path: Path = args.scene.resolve() + screenshot: Path | None = args.screenshot.resolve() if args.screenshot else None + output_dir: Path | None = args.output.resolve() if args.output else None + no_bake: bool = args.no_bake + off_screen: bool = args.off_screen + + # YAML 読み込み + try: + from sm_tias_lightsim.scene.schema import load_scene_yaml + + config = load_scene_yaml(scene_path) + except FileNotFoundError as exc: + print(f"エラー: シーン YAML ファイルが見つかりません — {exc}", file=sys.stderr) + return 1 + except Exception as exc: # noqa: BLE001 + print(f"エラー: シーン YAML の読み込みに失敗しました — {exc}", file=sys.stderr) + return 1 + + # project_root: YAML ファイルの親ディレクトリを基点とする + project_root = scene_path.parent + + # 照度ベイク + irradiance: dict | None = None + if not no_bake: + print("照度ベイクを開始します(直接照度のみ)...") + try: + from sm_tias_lightsim.visualize.baker import bake_irradiance + + irradiance = bake_irradiance(config, project_root) + print("照度ベイク完了.") + except Exception as exc: # noqa: BLE001 + print(f"エラー: 照度ベイクに失敗しました — {exc}", file=sys.stderr) + return 1 + else: + print("--no-bake: 照度ベイクをスキップします.シーンレイアウトのみ表示します.") + + # ベイク結果を npz で保存 + if irradiance is not None and output_dir is not None: + try: + output_dir.mkdir(parents=True, exist_ok=True) + import numpy as np + + npz_path = output_dir / f"{config.name}_irradiance.npz" + np.savez(npz_path, **irradiance) + print(f"照度データ保存: {npz_path}") + except Exception as exc: # noqa: BLE001 + print(f"エラー: 照度データの保存に失敗しました — {exc}", file=sys.stderr) + return 1 + + # PyVista 表示 + try: + from sm_tias_lightsim.visualize.viewer import show_scene + + show_scene( + config, + project_root, + irradiance=irradiance, + off_screen=off_screen, + screenshot=screenshot, + ) + except Exception as exc: # noqa: BLE001 + print(f"エラー: PyVista 表示に失敗しました — {exc}", file=sys.stderr) + return 1 + + if screenshot is not None: + print(f"スクリーンショット保存: {screenshot}") + + return 0 diff --git a/src/sm_tias_lightsim/scene/builder.py b/src/sm_tias_lightsim/scene/builder.py index ae6e0f2..5d473b4 100644 --- a/src/sm_tias_lightsim/scene/builder.py +++ b/src/sm_tias_lightsim/scene/builder.py @@ -56,7 +56,12 @@ tmesh.export(str(path), file_type="ply") -def build_scene(config: SceneConfig, project_root: Path | None = None) -> tuple[dict, Path]: +def build_scene( + config: SceneConfig, + project_root: Path | None = None, + *, + exclude_emitters: bool = False, +) -> tuple[dict, Path]: """SceneConfig 全体を Mitsuba load_dict 互換の dict に変換する. STL メッシュは trimesh で読み込み,一時ディレクトリに PLY として書き出して @@ -69,6 +74,9 @@ Args: config: 検証済み SceneConfig. project_root: STL 相対パスの基点ディレクトリ.None なら CWD を使用. + exclude_emitters: True にすると AreaLight の rectangle 形状をシーンに登録しない. + shadow ray 評価専用シーンを構築する際に使用する.PointLight / DirectionalLight は + emitter dict を持たないため常に除外されない. Returns: (scene_dict, tempdir) のタプル. @@ -79,6 +87,8 @@ FileNotFoundError: STL ファイルが見つからない場合. ValueError: 未対応の設定値が含まれる場合. """ + from sm_tias_lightsim.scene.schema import AreaLight + root = project_root if project_root is not None else Path.cwd() # YAML 内の座標値の単位は sensor.position の単位と一致しているため, @@ -121,7 +131,11 @@ } # lights + # exclude_emitters=True のとき AreaLight(rectangle 形状)はシーンに登録しない. + # shadow ray 用シーンで AreaLight 自身が遮蔽物になるのを防ぐためのオプション. for light_cfg in config.lights: + if exclude_emitters and isinstance(light_cfg, AreaLight): + continue scene_dict[f"light_{light_cfg.id}"] = build_light_dict( light_cfg, position_scale=pos_scale ) diff --git a/src/sm_tias_lightsim/visualize/__init__.py b/src/sm_tias_lightsim/visualize/__init__.py new file mode 100644 index 0000000..973a916 --- /dev/null +++ b/src/sm_tias_lightsim/visualize/__init__.py @@ -0,0 +1,11 @@ +"""3D 照度ベイク + PyVista インタラクティブビューア. + +公開 API: + bake_irradiance: 各メッシュの face 中心で直接照度を計算する. + show_scene: PyVista でシーン + 照度ベイク結果を表示する. +""" + +from sm_tias_lightsim.visualize.baker import bake_irradiance +from sm_tias_lightsim.visualize.viewer import show_scene + +__all__ = ["bake_irradiance", "show_scene"] diff --git a/src/sm_tias_lightsim/visualize/baker.py b/src/sm_tias_lightsim/visualize/baker.py new file mode 100644 index 0000000..7b2b1dc --- /dev/null +++ b/src/sm_tias_lightsim/visualize/baker.py @@ -0,0 +1,326 @@ +# pyright: reportCallIssue=none +# Mitsuba 3 は variant ベースの動的属性解決を行うため Pylance/Pyright が +# mi.* を object 型と推論し reportCallIssue を誤検知する(GUIDE_06 の方針). +"""各メッシュ face 中心での直接照度ベイクモジュール. + +shadow ray + コサイン項 + 距離減衰による直接照度(W/m²)を計算し, +{mesh_id: irradiance_per_face (N_f,) float32} の形式で返す. + +多重反射(インターリフレクション)は Phase A では無視する. +将来的に mode="path" を追加することで path tracing ベースの計算に切り替え可能. +""" + +from __future__ import annotations + +import logging +import shutil +from pathlib import Path +from typing import Literal + +import numpy as np + +from sm_tias_lightsim.geometry.loader import load_stl +from sm_tias_lightsim.geometry.transforms import scale_vertices +from sm_tias_lightsim.scene.schema import ( + AreaLight, + DirectionalLight, + PointLight, + SceneConfig, +) + +_logger = logging.getLogger(__name__) + +# YAML 座標 (mm) → m 変換係数 +_MM_TO_M = 0.001 + +# shadow ray の原点オフセット(face 法線方向に少しずらして自己交差を防ぐ) +_SHADOW_RAY_EPSILON = 1e-4 + +# DirectionalLight の shadow ray の最大距離(無限遠をシミュレート) +_DIRECTIONAL_MAXT = 1e9 + +# 方向ベクトルの長さがこれ以下なら縮退とみなしてスキップする +_DIR_NORM_EPSILON = 1e-9 + + +def _to_scalar_intensity(value: float | tuple[float, float, float]) -> float: + """スカラまたは RGB タプルを輝度スカラに変換する. + + RGB の場合は Rec.709 係数で輝度に変換する. + スカラの場合はそのまま返す. + + Args: + value: スカラまたは RGB タプル. + + Returns: + 輝度スカラ値(float). + """ + if isinstance(value, (int, float)): + return float(value) + r, g, b = value + return 0.2126 * r + 0.7152 * g + 0.0722 * b + + +def _shadow_ray_blocked( + scene: object, + origin: np.ndarray, + direction: np.ndarray, + maxt: float, +) -> bool: + """shadow ray が遮蔽されているかを判定する. + + origin → direction 方向に距離 maxt まで ray を飛ばし, + 途中に交差があれば True(遮蔽あり)を返す. + + Args: + scene: Mitsuba の Scene オブジェクト(load_dict 済み). + origin: ray の起点(m 単位). + direction: ray の方向(単位ベクトル). + maxt: 最大ヒット距離(m 単位). + + Returns: + 遮蔽あり(途中に交差あり)なら True. + """ + import mitsuba as mi + + o = mi.Point3f(float(origin[0]), float(origin[1]), float(origin[2])) + d = mi.Vector3f(float(direction[0]), float(direction[1]), float(direction[2])) + ray = mi.Ray3f(o, d) # type: ignore[call-arg] + ray.maxt = maxt # type: ignore[assignment] + si = scene.ray_intersect_preliminary(ray) # type: ignore[attr-defined] + return bool(si.is_valid()) + + +def _compute_face_centroids_and_normals( + vertices: np.ndarray, faces: np.ndarray +) -> tuple[np.ndarray, np.ndarray]: + """face の重心と法線ベクトルを計算する. + + Args: + vertices: 頂点配列 (N_v, 3),m 単位. + faces: 面配列 (N_f, 3),頂点インデックス. + + Returns: + (centroids, normals) のタプル. + - centroids: face 重心 (N_f, 3). + - normals: 単位法線ベクトル (N_f, 3).長さ 0 の面はゼロベクトルになる. + """ + v0 = vertices[faces[:, 0]] + v1 = vertices[faces[:, 1]] + v2 = vertices[faces[:, 2]] + + centroids = (v0 + v1 + v2) / 3.0 + + edge1 = v1 - v0 + edge2 = v2 - v0 + raw_normals = np.cross(edge1, edge2) # (N_f, 3) + + norms = np.linalg.norm(raw_normals, axis=1, keepdims=True) # (N_f, 1) + norms = np.where(norms == 0.0, 1.0, norms) + normals = raw_normals / norms + + return centroids, normals + + +def _accumulate_finite_light( + irradiance: np.ndarray, + centroids: np.ndarray, + normals: np.ndarray, + scene: object, + light_pos_m: np.ndarray, + intensity: float, + emit_dir: np.ndarray | None = None, +) -> None: + """距離減衰のある光源(PointLight / AreaLight)の照度を face ごとに累積する. + + PointLight と AreaLight は「光源位置 → face 距離」「cos_theta」「shadow ray」の + 計算構造が共通のため,このヘルパーに集約する. + AreaLight に固有の「裏面スキップ」は emit_dir が指定されたときのみ評価する. + + Args: + irradiance: 累積先の照度配列 (N_f,),in-place で更新する. + centroids: face 重心 (N_f, 3). + normals: face 単位法線 (N_f, 3). + scene: Mitsuba Scene オブジェクト. + light_pos_m: 光源位置(m 単位). + intensity: 光源の等価スカラ強度(W/sr 相当). + emit_dir: AreaLight の放射方向単位ベクトル(正規化済み). + None のとき裏面チェックをスキップする(PointLight 用). + """ + for fi in range(len(centroids)): + centroid = centroids[fi] + normal = normals[fi] + + to_light = light_pos_m - centroid + distance = float(np.linalg.norm(to_light)) + if distance < _SHADOW_RAY_EPSILON: + continue + + dir_n = to_light / distance + cos_theta = float(np.dot(normal, dir_n)) + if cos_theta <= 0.0: + continue + + # AreaLight の裏面には放射しない(放射方向との角度チェック) + # emit_dir が face から光源への方向と逆向きの場合はスキップ + if emit_dir is not None: + emit_cos = float(np.dot(-dir_n, emit_dir)) + if emit_cos <= 0.0: + continue + + # shadow ray: origin を法線方向にわずかにオフセットし, + # maxt は光源手前までに制限して自己遮蔽と光源貫通を防ぐ + origin = centroid + _SHADOW_RAY_EPSILON * normal + if _shadow_ray_blocked(scene, origin, dir_n, distance - _SHADOW_RAY_EPSILON): + continue + + irradiance[fi] += float(intensity * cos_theta / (distance**2)) + + +def bake_irradiance( + scene_config: SceneConfig, + project_root: Path, + *, + mode: Literal["direct"] = "direct", +) -> dict[str, np.ndarray]: + """各メッシュの face 中心で照度(W/m²)を計算する(直接照度のみ). + + Phase A: shadow ray + コサイン項 + 距離減衰による直接照度のみを計算する. + 多重反射は無視する(Phase B で mode="path" を追加予定). + + shadow ray の評価には Mitsuba の `scene.ray_intersect_preliminary` を利用する. + 遮蔽された face は寄与をスキップする. + + Args: + scene_config: 検証済み SceneConfig. + project_root: STL 相対パスの基点ディレクトリ. + mode: 計算モード.現在は "direct" のみサポート. + + Returns: + {mesh_id: irradiance_per_face (N_f,) float32} の辞書. + irradiance の単位は W/m²(intensity の単位に依存). + + Raises: + NotImplementedError: mode が "direct" 以外の場合. + FileNotFoundError: STL ファイルが見つからない場合. + """ + if mode != "direct": + msg = f"mode={mode!r} は未サポートです.現在は 'direct' のみ実装しています." + raise NotImplementedError(msg) + + import mitsuba as mi + + from sm_tias_lightsim.scene.builder import build_scene + + # Mitsuba variant を初期化(scalar_rgb を使う:shadow ray 評価に最適) + # runner.py と同じ variant が既に設定されている場合はそのまま続行する + desired_variant = "scalar_rgb" + try: + mi.set_variant(desired_variant) + except Exception: + _logger.warning( + "Mitsuba variant の切り替えに失敗しました.現在の variant で続行します." + ) + + # shadow ray 評価専用シーン(AreaLight の rectangle 形状を除外)を構築する. + # AreaLight を含む通常シーンでは,面光源自身の rectangle にヒットして + # 正面メッシュへの shadow ray が常に遮蔽されてしまう問題を回避するため, + # exclude_emitters=True で AreaLight をシーンから除外する. + scene_dict, tempdir = build_scene( + scene_config, project_root=project_root, exclude_emitters=True + ) + try: + scene = mi.load_dict(scene_dict) + return _bake_direct(scene_config, project_root, scene) + finally: + shutil.rmtree(tempdir, ignore_errors=True) + + +def _bake_direct( + scene_config: SceneConfig, + project_root: Path, + scene: object, +) -> dict[str, np.ndarray]: + """直接照度ベイクの内部実装. + + 各メッシュの face 中心に対して,全光源からの直接照度を shadow ray で計算する. + + Args: + scene_config: 検証済み SceneConfig. + project_root: STL 相対パスの基点ディレクトリ. + scene: Mitsuba Scene オブジェクト(load_dict 済み). + + Returns: + {mesh_id: irradiance_per_face (N_f,) float32}. + """ + result: dict[str, np.ndarray] = {} + + for mesh_cfg in scene_config.geometry.meshes: + raw_path = Path(mesh_cfg.path) + stl_path = raw_path if raw_path.is_absolute() else project_root / mesh_cfg.path + + loaded = load_stl(stl_path, mesh_cfg.unit) + scaled = scale_vertices(loaded, target_unit="m") + + centroids, normals = _compute_face_centroids_and_normals( + scaled.vertices, scaled.faces + ) + n_faces = len(centroids) + irradiance = np.zeros(n_faces, dtype=np.float32) + + for light in scene_config.lights: + if isinstance(light, PointLight): + intensity = _to_scalar_intensity(light.intensity) + light_pos_m = np.array(light.position, dtype=np.float64) * _MM_TO_M + _accumulate_finite_light( + irradiance, centroids, normals, scene, light_pos_m, intensity + ) + + elif isinstance(light, AreaLight): + # 面光源: 中心点を点光源として近似する(TODO: 面サンプリングで精度向上可能) + # 近似の精度を上げるには center を N 点サンプリングして平均する方法がある. + # 現時点ではシンプルに中心点のみで計算する. + radiance = _to_scalar_intensity(light.radiance) + area = (light.size[0] * _MM_TO_M) * (light.size[1] * _MM_TO_M) + # 点光源近似の等価強度: radiance × area(Lambertian 放射源の近似) + equiv_intensity = radiance * area + + center_m = np.array(light.center, dtype=np.float64) * _MM_TO_M + dir_vec = np.array(light.direction, dtype=np.float64) + dir_norm = np.linalg.norm(dir_vec) + if dir_norm < _DIR_NORM_EPSILON: + continue + emit_dir = dir_vec / dir_norm + + _accumulate_finite_light( + irradiance, centroids, normals, scene, center_m, equiv_intensity, emit_dir + ) + + elif isinstance(light, DirectionalLight): + # YAML の direction は「光の進行方向」なので,face への入射方向は逆転する + irradiance_val = _to_scalar_intensity(light.irradiance) + dir_vec = np.array(light.direction, dtype=np.float64) + dir_norm = np.linalg.norm(dir_vec) + if dir_norm < _DIR_NORM_EPSILON: + continue + incident_dir = -dir_vec / dir_norm + + for fi in range(n_faces): + centroid = centroids[fi] + normal = normals[fi] + + cos_theta = float(np.dot(normal, incident_dir)) + if cos_theta <= 0.0: + continue + + origin = centroid + _SHADOW_RAY_EPSILON * normal + if _shadow_ray_blocked( + scene, origin, incident_dir, float(_DIRECTIONAL_MAXT) + ): + continue + + irradiance[fi] += float(irradiance_val * cos_theta) + + result[mesh_cfg.id] = irradiance + + return result diff --git a/src/sm_tias_lightsim/visualize/viewer.py b/src/sm_tias_lightsim/visualize/viewer.py new file mode 100644 index 0000000..a561881 --- /dev/null +++ b/src/sm_tias_lightsim/visualize/viewer.py @@ -0,0 +1,242 @@ +"""PyVista 3D インタラクティブビューアモジュール. + +シーンのメッシュ・LED・センサを PyVista で可視化する. +照度ベイク結果 (irradiance) があればメッシュを照度で着色する. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +import numpy as np + +from sm_tias_lightsim.geometry.loader import load_stl +from sm_tias_lightsim.geometry.transforms import scale_vertices +from sm_tias_lightsim.scene.schema import ( + AreaLight, + DirectionalLight, + PointLight, + SceneConfig, +) + +if TYPE_CHECKING: + import pyvista as pv + +_logger = logging.getLogger(__name__) + +# YAML 座標 (mm) → m 変換係数 +_MM_TO_M = 0.001 + +# 方向ベクトルの長さがこれ以下なら縮退とみなしてスキップする +_DIR_NORM_EPSILON = 1e-9 + +# 光源・センサを示す球の半径(m 単位) +_MARKER_SPHERE_RADIUS = 0.005 + +# AreaLight の放射方向矢印のスケール(m 単位) +_AREA_LIGHT_ARROW_SCALE = 0.02 + +# DirectionalLight の矢印をシーン対角線長に対して拡大する比率 +_DIRECTIONAL_ARROW_SCALE_RATIO = 0.3 + +# センサの視線矢印をシーン対角線長に対してクランプする比率 +_SENSOR_ARROW_SCALE_RATIO = 0.2 + + +def _build_pyvista_polydata(vertices: np.ndarray, faces: np.ndarray) -> pv.PolyData: + """頂点・面配列から PyVista PolyData を構築する. + + PyVista の faces 形式は各行が [n_vertices, v0, v1, v2, ...] の 1D フラット配列. + + Args: + vertices: 頂点配列 (N_v, 3),m 単位. + faces: 三角形面配列 (N_f, 3),頂点インデックス. + + Returns: + PyVista PolyData オブジェクト. + """ + import pyvista as pv + + n_faces = len(faces) + # PyVista 形式: [3, v0, v1, v2, 3, v0, v1, v2, ...] + pv_faces = np.hstack([np.full((n_faces, 1), 3, dtype=np.int64), faces]).flatten() + return pv.PolyData(vertices.astype(np.float32), pv_faces) + + +def _get_scene_bbox_center_and_diag( + scene_config: SceneConfig, project_root: Path +) -> tuple[np.ndarray, float]: + """シーン全メッシュのバウンディングボックス中心と対角線長を返す. + + DirectionalLight の矢印位置を決めるために使う. + メッシュが空なら原点と 1.0 を返す. + + Args: + scene_config: SceneConfig. + project_root: STL 相対パスの基点ディレクトリ. + + Returns: + (center (3,), diagonal_length) のタプル. + """ + all_vertices: list[np.ndarray] = [] + for mesh_cfg in scene_config.geometry.meshes: + try: + raw_path = Path(mesh_cfg.path) + stl_path = raw_path if raw_path.is_absolute() else project_root / mesh_cfg.path + loaded = load_stl(stl_path, mesh_cfg.unit) + scaled = scale_vertices(loaded, target_unit="m") + all_vertices.append(scaled.vertices) + except Exception as exc: + _logger.warning("バウンディングボックス計算でメッシュを読み込めませんでした: %s", exc) + + if not all_vertices: + return np.zeros(3), 1.0 + + combined = np.vstack(all_vertices) + mins = combined.min(axis=0) + maxs = combined.max(axis=0) + center = (mins + maxs) / 2.0 + diagonal = float(np.linalg.norm(maxs - mins)) + return center, max(diagonal, 1e-6) + + +def show_scene( + scene_config: SceneConfig, + project_root: Path, + irradiance: dict[str, np.ndarray] | None = None, + *, + off_screen: bool = False, + screenshot: Path | None = None, +) -> None: + """PyVista でシーンとベイク結果を表示する. + + メッシュ・LED・センサ・座標軸を含む 3D インタラクティブビューアを開く. + irradiance が指定されていれば,メッシュを照度で着色する(viridis カラーマップ). + + off_screen=True のときは GUI ウィンドウを開かず,screenshot が指定されていれば保存する. + screenshot=None かつ off_screen=True のときは何も出力しない(no-op). + + Args: + scene_config: 検証済み SceneConfig. + project_root: STL 相対パスの基点ディレクトリ. + irradiance: {mesh_id: irradiance_per_face (N_f,)} の辞書.None なら着色なし. + off_screen: True のとき GUI ウィンドウを開かない(screenshot 専用). + screenshot: 保存先 PNG パス.指定時は off_screen で screenshot を保存する. + """ + import pyvista as pv + + # screenshot 指定があれば off_screen を強制する + if screenshot is not None: + off_screen = True + + plotter = pv.Plotter(off_screen=off_screen) + + # ---- メッシュ追加 ---- + for mesh_cfg in scene_config.geometry.meshes: + try: + raw_path = Path(mesh_cfg.path) + stl_path = raw_path if raw_path.is_absolute() else project_root / mesh_cfg.path + loaded = load_stl(stl_path, mesh_cfg.unit) + scaled = scale_vertices(loaded, target_unit="m") + except Exception as exc: + _logger.warning("メッシュ '%s' を読み込めませんでした: %s", mesh_cfg.id, exc) + continue + + polydata = _build_pyvista_polydata(scaled.vertices, scaled.faces) + + if irradiance is not None and mesh_cfg.id in irradiance: + irr = irradiance[mesh_cfg.id] + polydata.cell_data["irradiance"] = irr.astype(np.float32) + plotter.add_mesh( + polydata, + scalars="irradiance", + cmap="viridis", + show_scalar_bar=True, + scalar_bar_args={"title": "Irradiance (W/m²)"}, + label=mesh_cfg.id, + ) + else: + plotter.add_mesh(polydata, color="lightgray", opacity=0.5, label=mesh_cfg.id) + + # ---- シーン全体のバウンディングボックス(DirectionalLight 用)---- + bbox_center, bbox_diag = _get_scene_bbox_center_and_diag(scene_config, project_root) + + # ---- LED / 光源表示 ---- + for light in scene_config.lights: + if isinstance(light, PointLight): + pos_m = [c * _MM_TO_M for c in light.position] + sphere = pv.Sphere(radius=_MARKER_SPHERE_RADIUS, center=pos_m) + plotter.add_mesh(sphere, color="yellow", label=f"LED:{light.id}") + + elif isinstance(light, AreaLight): + center_m = [c * _MM_TO_M for c in light.center] + size_m = [light.size[0] * _MM_TO_M, light.size[1] * _MM_TO_M] + + dir_vec = np.array(light.direction, dtype=np.float64) + dir_norm = np.linalg.norm(dir_vec) + if dir_norm > _DIR_NORM_EPSILON: + dir_vec = dir_vec / dir_norm + + plane = pv.Plane( + center=center_m, + direction=dir_vec.tolist(), + i_size=size_m[0], + j_size=size_m[1], + ) + plotter.add_mesh(plane, color="orange", opacity=0.7, label=f"AreaLight:{light.id}") + + arrow = pv.Arrow( + start=center_m, + direction=dir_vec.tolist(), + scale=_AREA_LIGHT_ARROW_SCALE, + ) + plotter.add_mesh(arrow, color="orange", label=f"AreaLight:{light.id}:dir") + + elif isinstance(light, DirectionalLight): + # バウンディングボックス中心から矢印 1 本で進行方向を示す + dir_vec = np.array(light.direction, dtype=np.float64) + dir_norm = np.linalg.norm(dir_vec) + if dir_norm < _DIR_NORM_EPSILON: + continue + dir_vec = dir_vec / dir_norm + + arrow_scale = bbox_diag * _DIRECTIONAL_ARROW_SCALE_RATIO + arrow = pv.Arrow( + start=bbox_center.tolist(), + direction=dir_vec.tolist(), + scale=arrow_scale, + ) + plotter.add_mesh(arrow, color="lightyellow", label=f"DirLight:{light.id}") + + # ---- センサ表示 ---- + sensor_pos_m = [c * _MM_TO_M for c in scene_config.sensor.position] + sensor_target_m = [c * _MM_TO_M for c in scene_config.sensor.target] + + sensor_sphere = pv.Sphere(radius=_MARKER_SPHERE_RADIUS, center=sensor_pos_m) + plotter.add_mesh(sensor_sphere, color="blue", label="sensor") + + sensor_pos_arr = np.array(sensor_pos_m) + sensor_target_arr = np.array(sensor_target_m) + view_dir = sensor_target_arr - sensor_pos_arr + view_len = np.linalg.norm(view_dir) + if view_len > _DIR_NORM_EPSILON: + view_dir_n = view_dir / view_len + sensor_arrow = pv.Arrow( + start=sensor_pos_m, + direction=view_dir_n.tolist(), + scale=min(float(view_len), bbox_diag * _SENSOR_ARROW_SCALE_RATIO), + ) + plotter.add_mesh(sensor_arrow, color="blue", label="sensor:view") + + # ---- 座標軸と境界表示 ---- + plotter.add_axes() + plotter.show_bounds(grid="back", location="outer") + + # ---- 表示 / 保存 ---- + if screenshot is not None: + plotter.show(screenshot=str(screenshot)) + elif not off_screen: + plotter.show() + # off_screen=True かつ screenshot=None の場合は no-op diff --git a/tests/cli/test_visualize_cli.py b/tests/cli/test_visualize_cli.py new file mode 100644 index 0000000..9d1457a --- /dev/null +++ b/tests/cli/test_visualize_cli.py @@ -0,0 +1,269 @@ +"""cli/visualize.py の仕様ベーステスト. + +仕様出典: +- Phase 1 実装サマリー(sm-visualize CLI 仕様) +- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(CLI 層の責務) +- docs/01_GUIDE/GUIDE_08_テスト方針.md(テスト方針) + +テスト方針: +- main() を直接呼び出す(subprocess 不使用:速度・カバレッジのため) +- ファイル I/O は tmp_path で実ファイルを使う(GUIDE_08) +- PyVista は off_screen=True 相当(screenshot 指定 or --off-screen)で実行 + +CLI 仕様(sm-visualize): + --scene / -s 必須 + --no-bake ベイクをスキップ + --screenshot PNG を保存(off_screen 強制) + --output / -o ベイク結果を npz として保存 + --off-screen ウィンドウを開かない +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +from sm_tias_lightsim.cli.visualize import main + +# --------------------------------------------------------------------------- +# テスト用定数 +# --------------------------------------------------------------------------- + +MINIMAL_YAML = Path(__file__).parent.parent / "fixtures" / "minimal.yaml" + + +# --------------------------------------------------------------------------- +# 1. 必須引数の欠落(軽量 — argparse 動作) +# --------------------------------------------------------------------------- + + +def test_missing_scene_arg_exits_with_error(): + """--scene を渡さないと SystemExit が発生すること. + + 仕様: --scene は必須引数(required=True) + """ + with pytest.raises(SystemExit) as exc_info: + main([]) + assert exc_info.value.code != 0 + + +def test_missing_scene_arg_with_other_args_exits_with_error(): + """--scene なしで他の引数を渡すと SystemExit が発生すること.""" + with pytest.raises(SystemExit) as exc_info: + main(["--no-bake"]) + assert exc_info.value.code != 0 + + +# --------------------------------------------------------------------------- +# 2. 存在しない YAML パス(軽量 — return code 1) +# --------------------------------------------------------------------------- + + +def test_nonexistent_scene_returns_1(tmp_path: Path, capsys): + """存在しない YAML パスを渡すと return 1 かつ stderr にエラーが出ること. + + 仕様: FileNotFoundError → stderr「シーン YAML ファイルが見つかりません」→ return 1 + """ + nonexistent = tmp_path / "does_not_exist.yaml" + result = main(["--scene", str(nonexistent), "--no-bake", "--off-screen"]) + assert result == 1 + captured = capsys.readouterr() + assert "シーン YAML ファイルが見つかりません" in captured.err + + +def test_nonexistent_scene_error_message_is_on_stderr(tmp_path: Path, capsys): + """エラーメッセージが stderr に出ること.""" + nonexistent = tmp_path / "missing.yaml" + main(["--scene", str(nonexistent), "--no-bake", "--off-screen"]) + captured = capsys.readouterr() + assert "エラー:" in captured.err + + +# --------------------------------------------------------------------------- +# 3. 不正 YAML(軽量 — return code 1) +# --------------------------------------------------------------------------- + + +def test_invalid_yaml_returns_1(invalid_yaml_file: Path, capsys): + """必須フィールド欠落の YAML を渡すと return 1 が返ること. + + 仕様: Pydantic ValidationError → stderr「シーン YAML の読み込みに失敗しました」→ return 1 + """ + result = main(["--scene", str(invalid_yaml_file), "--no-bake", "--off-screen"]) + assert result == 1 + + +def test_invalid_yaml_stderr_message(invalid_yaml_file: Path, capsys): + """不正 YAML のエラーメッセージに「シーン YAML の読み込みに失敗しました」が含まれること.""" + main(["--scene", str(invalid_yaml_file), "--no-bake", "--off-screen"]) + captured = capsys.readouterr() + assert "シーン YAML の読み込みに失敗しました" in captured.err + + +# --------------------------------------------------------------------------- +# 4. --no-bake モード(slow — PyVista 実行) +# --------------------------------------------------------------------------- + + +@pytest.mark.slow +def test_no_bake_with_screenshot_creates_png(tmp_path: Path): + """--no-bake + --screenshot で PNG が生成されること(ベイクなし). + + 仕様: --no-bake のとき bake_irradiance をスキップする + """ + screenshot_path = tmp_path / "no_bake_out.png" + result = main([ + "--scene", str(MINIMAL_YAML), + "--no-bake", + "--screenshot", str(screenshot_path), + ]) + assert result == 0, "正常終了 (return 0)" + assert screenshot_path.exists(), "--no-bake + --screenshot で PNG が生成されること" + assert screenshot_path.stat().st_size > 0, "PNG ファイルが空でないこと" + + +@pytest.mark.slow +def test_no_bake_does_not_print_bake_start(tmp_path: Path, capsys): + """--no-bake のとき「照度ベイクを開始します」が stdout に出ないこと. + + 仕様: --no-bake が指定されたとき「--no-bake: 照度ベイクをスキップします」を出力する + """ + screenshot_path = tmp_path / "out.png" + main([ + "--scene", str(MINIMAL_YAML), + "--no-bake", + "--screenshot", str(screenshot_path), + ]) + captured = capsys.readouterr() + assert "照度ベイクを開始します" not in captured.out + assert "スキップ" in captured.out + + +# --------------------------------------------------------------------------- +# 5. --screenshot で PNG が生成されること(slow — ベイク + PyVista 実行) +# --------------------------------------------------------------------------- + + +@pytest.mark.slow +def test_screenshot_creates_png(tmp_path: Path): + """--screenshot で PNG が生成されること(ベイクあり). + + 仕様: --screenshot PNG を保存(off_screen 強制) + """ + screenshot_path = tmp_path / "out.png" + result = main([ + "--scene", str(MINIMAL_YAML), + "--screenshot", str(screenshot_path), + ]) + assert result == 0, "正常終了 (return 0)" + assert screenshot_path.exists(), "--screenshot で PNG が生成されること" + assert screenshot_path.stat().st_size > 0 + + +@pytest.mark.slow +def test_screenshot_stdout_contains_save_message(tmp_path: Path, capsys): + """--screenshot 実行後に stdout に「スクリーンショット保存」が含まれること. + + 仕様: screenshot 指定時は完了後に「スクリーンショット保存: 」を出力する + """ + screenshot_path = tmp_path / "out.png" + main([ + "--scene", str(MINIMAL_YAML), + "--screenshot", str(screenshot_path), + ]) + captured = capsys.readouterr() + assert "スクリーンショット保存" in captured.out + + +# --------------------------------------------------------------------------- +# 6. --output でベイク結果 npz が保存されること(slow) +# --------------------------------------------------------------------------- + + +@pytest.mark.slow +def test_output_dir_creates_npz(tmp_path: Path): + """--output 指定でベイク結果 npz が保存されること. + + 仕様: --output / -o ベイク結果を {config.name}_irradiance.npz として保存 + """ + output_dir = tmp_path / "bake_out" + screenshot_path = tmp_path / "out.png" + result = main([ + "--scene", str(MINIMAL_YAML), + "--output", str(output_dir), + "--screenshot", str(screenshot_path), + ]) + assert result == 0 + + # {config.name}_irradiance.npz が存在すること + # minimal.yaml の name: smoke_test + npz_path = output_dir / "smoke_test_irradiance.npz" + assert npz_path.exists(), f"npz ファイルが生成されること: {npz_path}" + + # npz が正しくロードできること + data = np.load(str(npz_path)) + assert len(data.files) > 0, "npz に少なくとも 1 つのキーが含まれること" + + +@pytest.mark.slow +def test_output_dir_is_created_automatically(tmp_path: Path): + """--output で指定したディレクトリが自動生成されること. + + 仕様: output_dir.mkdir(parents=True, exist_ok=True) で親ディレクトリも含めて作成する + """ + output_dir = tmp_path / "deep" / "nested" / "bake" + screenshot_path = tmp_path / "out.png" + + assert not output_dir.exists(), "テスト前にはディレクトリが存在しないこと" + + result = main([ + "--scene", str(MINIMAL_YAML), + "--output", str(output_dir), + "--screenshot", str(screenshot_path), + ]) + assert result == 0 + assert output_dir.exists(), "深いネストのディレクトリが自動作成されること" + + +# --------------------------------------------------------------------------- +# 7. 短縮引数 -s / -o(slow) +# --------------------------------------------------------------------------- + + +@pytest.mark.slow +def test_short_args_s_and_o_work(tmp_path: Path): + """-s と -o の短縮形でも --scene / --output と同等に動くこと. + + 仕様: --scene/-s, --output/-o の短縮形を提供する + """ + output_dir = tmp_path / "short_out" + screenshot_path = tmp_path / "out.png" + + result = main([ + "-s", str(MINIMAL_YAML), + "-o", str(output_dir), + "--screenshot", str(screenshot_path), + ]) + assert result == 0 + assert (output_dir / "smoke_test_irradiance.npz").exists() + + +# --------------------------------------------------------------------------- +# 8. --off-screen + ベイクのみ(screenshot なし,no-op)(slow) +# --------------------------------------------------------------------------- + + +@pytest.mark.slow +def test_off_screen_without_screenshot_returns_0(tmp_path: Path): + """--off-screen + screenshot なしで no-op になり return 0 が返ること. + + 仕様: screenshot=None かつ off_screen=True のときは no-op + """ + result = main([ + "--scene", str(MINIMAL_YAML), + "--no-bake", + "--off-screen", + ]) + assert result == 0, "no-op でも正常終了すること" diff --git a/tests/conftest.py b/tests/conftest.py index 9bc32d3..edcf257 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,7 +20,10 @@ """ import mitsuba as mi - mi.set_variant("llvm_ad_rgb") + # この環境では LLVM バックエンドが利用できないため scalar_rgb を使用する. + # baker.py も scalar_rgb を要求しており,テスト全体で統一する. + # llvm_ad_rgb が利用可能な環境では "llvm_ad_rgb" に変更すること. + mi.set_variant("scalar_rgb") @pytest.fixture diff --git a/tests/visualize/__init__.py b/tests/visualize/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/visualize/__init__.py diff --git a/tests/visualize/conftest.py b/tests/visualize/conftest.py new file mode 100644 index 0000000..800120f --- /dev/null +++ b/tests/visualize/conftest.py @@ -0,0 +1,197 @@ +"""visualize テスト共通フィクスチャ. + +照度ベイクテスト・ビューアテストで使用する SceneConfig と STL ファイルを提供する. +Mitsuba をモックせず実オブジェクトを使う(GUIDE_08 モック方針). +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest +import trimesh + +from sm_tias_lightsim.scene.schema import SceneConfig + + +def _write_flat_plane_stl(path: Path, z_mm: float = 0.0) -> None: + """z=z_mm の水平三角形 1 枚の STL を書き出す(mm 単位). + + 三角形の頂点: + v0 = (0, 0, z_mm) + v1 = (10, 0, z_mm) + v2 = (10, 10, z_mm) + 法線は +Z 方向(仕様に合わせ正規化は trimesh に任せる). + """ + vertices = np.array( + [ + [0.0, 0.0, z_mm], + [10.0, 0.0, z_mm], + [10.0, 10.0, z_mm], + ], + dtype=np.float64, + ) + faces = np.array([[0, 1, 2]], dtype=np.int64) + mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=False) + mesh.export(str(path)) + + +def _write_blocker_stl(path: Path) -> None: + """LED と評価板の間に配置する遮蔽板 STL を書き出す(mm 単位). + + z=50mm に大きな平面を置く(光源 z=100mm, 評価板 z=0mm の中間). + 頂点: (−100, −100, 50), (100, −100, 50), (100, 100, 50), (−100, 100, 50) + 法線は +Z 方向. + """ + vertices = np.array( + [ + [-100.0, -100.0, 50.0], + [100.0, -100.0, 50.0], + [100.0, 100.0, 50.0], + [-100.0, 100.0, 50.0], + ], + dtype=np.float64, + ) + # 2 三角形で四角形を構成 + faces = np.array([[0, 1, 2], [0, 2, 3]], dtype=np.int64) + mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=False) + mesh.export(str(path)) + + +def _write_downward_plane_stl(path: Path, z_mm: float = 0.0) -> None: + """法線が -Z 方向の水平三角形 1 枚の STL を書き出す(mm 単位). + + 頂点の巻き順を逆にして -Z 法線を作る. + """ + vertices = np.array( + [ + [0.0, 0.0, z_mm], + [10.0, 10.0, z_mm], + [10.0, 0.0, z_mm], + ], + dtype=np.float64, + ) + faces = np.array([[0, 1, 2]], dtype=np.int64) + mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=False) + mesh.export(str(path)) + + +def _make_minimal_scene_config( + mesh_path: str, + mesh_id: str = "plane", + light_dict: dict | None = None, +) -> SceneConfig: + """最小限の SceneConfig を辞書から作成する. + + Args: + mesh_path: STL ファイルへのパス文字列(プロジェクトルート相対 or 絶対). + mesh_id: メッシュ ID. + light_dict: lights リストの要素辞書 1 個.None なら PointLight のデフォルトを使う. + """ + if light_dict is None: + light_dict = { + "id": "led", + "type": "point", + "position": [0.0, 0.0, 100.0], + "intensity": 1.0, + "profile": "lambertian", + } + + data = { + "name": "test_bake", + "geometry": { + "meshes": [ + { + "id": mesh_id, + "path": mesh_path, + "unit": "mm", + "material": "white", + } + ] + }, + "materials": { + "white": {"type": "diffuse", "reflectance": 0.9}, + }, + "lights": [light_dict], + "sensor": { + "type": "perspective", + "position": [0.0, 0.0, 200.0], + "target": [0.0, 0.0, 0.0], + "up": [0.0, 1.0, 0.0], + "fov_deg": 60.0, + "film": {"width": 64, "height": 64}, + }, + "render": { + "variant": "scalar_rgb", + "integrator": "path", + "max_depth": 2, + "spp": 1, + }, + } + return SceneConfig.model_validate(data) + + +@pytest.fixture +def flat_plane_stl(tmp_path: Path) -> Path: + """法線 +Z の水平三角形 1 枚の STL を tmp_path に書き出す(z=0mm).""" + path = tmp_path / "flat_plane.stl" + _write_flat_plane_stl(path, z_mm=0.0) + return path + + +@pytest.fixture +def blocker_stl(tmp_path: Path) -> Path: + """LED (z=100mm) と評価板 (z=0mm) の間(z=50mm)に置く遮蔽板 STL.""" + path = tmp_path / "blocker.stl" + _write_blocker_stl(path) + return path + + +@pytest.fixture +def downward_plane_stl(tmp_path: Path) -> Path: + """法線 -Z の水平三角形 1 枚の STL を tmp_path に書き出す(z=0mm).""" + path = tmp_path / "downward_plane.stl" + _write_downward_plane_stl(path, z_mm=0.0) + return path + + +@pytest.fixture +def point_light_scene_config(flat_plane_stl: Path) -> tuple[SceneConfig, Path]: + """PointLight + 法線 +Z 平面の SceneConfig と project_root を返す. + + 光源: position=(0, 0, 100mm), intensity=1.0 + メッシュ: z=0mm 三角形(法線 +Z) + """ + config = _make_minimal_scene_config( + mesh_path=str(flat_plane_stl), + mesh_id="plane", + light_dict={ + "id": "led", + "type": "point", + "position": [0.0, 0.0, 100.0], + "intensity": 1.0, + "profile": "lambertian", + }, + ) + return config, flat_plane_stl.parent + + +@pytest.fixture +def directional_light_scene_config(flat_plane_stl: Path) -> tuple[SceneConfig, Path]: + """DirectionalLight + 法線 +Z 平面の SceneConfig と project_root を返す. + + 光源: direction=(0, 0, -1)(下向き), irradiance=1.0 + メッシュ: z=0mm 三角形(法線 +Z) + """ + config = _make_minimal_scene_config( + mesh_path=str(flat_plane_stl), + mesh_id="plane", + light_dict={ + "id": "dir_light", + "type": "directional", + "direction": [0.0, 0.0, -1.0], + "irradiance": 1.0, + }, + ) + return config, flat_plane_stl.parent diff --git a/tests/visualize/test_baker.py b/tests/visualize/test_baker.py new file mode 100644 index 0000000..15b4f3c --- /dev/null +++ b/tests/visualize/test_baker.py @@ -0,0 +1,480 @@ +"""visualize/baker.py の仕様ベーステスト. + +仕様出典: +- Phase 1 実装サマリー(bake_irradiance 仕様) +- docs/04_SPEC/SPEC_02_シーン定義仕様.md(lights セクション) +- docs/01_GUIDE/GUIDE_08_テスト方針.md(テスト方針) + +テスト方針: +- Mitsuba をモックせず実行する(GUIDE_08) +- ファイル I/O は tmp_path フィクスチャで実ファイルを使う +- 光源タイプ別の解析解(E = I·cosθ/r² 等)と数値比較 + +テスト対象: + bake_irradiance(scene_config, project_root, *, mode="direct") -> dict[str, np.ndarray] +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest +import trimesh + +from sm_tias_lightsim.scene.schema import SceneConfig +from sm_tias_lightsim.visualize.baker import bake_irradiance + + +# --------------------------------------------------------------------------- +# 共通ヘルパー関数 +# --------------------------------------------------------------------------- + + +def _make_scene_config_with_meshes( + mesh_entries: list[dict], + light_dict: dict, +) -> SceneConfig: + """複数メッシュと 1 光源の SceneConfig を作成する. + + Args: + mesh_entries: geometry.meshes リストの要素辞書リスト. + 各要素は id / path / unit / material を含むこと. + light_dict: lights リストの要素辞書 1 個. + """ + # メッシュ数に合わせて材料辞書を作成(全メッシュが "white" 材料を使う想定) + materials = {"white": {"type": "diffuse", "reflectance": 0.9}} + + data = { + "name": "test_bake", + "geometry": {"meshes": mesh_entries}, + "materials": materials, + "lights": [light_dict], + "sensor": { + "type": "perspective", + "position": [0.0, 0.0, 200.0], + "target": [0.0, 0.0, 0.0], + "up": [0.0, 1.0, 0.0], + "fov_deg": 60.0, + "film": {"width": 64, "height": 64}, + }, + "render": { + "variant": "scalar_rgb", + "integrator": "path", + "max_depth": 2, + "spp": 1, + }, + } + return SceneConfig.model_validate(data) + + +# --------------------------------------------------------------------------- +# 1. PointLight 解析解一致テスト +# --------------------------------------------------------------------------- + + +@pytest.mark.slow +def test_point_light_irradiance_matches_analytical_solution( + point_light_scene_config: tuple[SceneConfig, Path], +): + """PointLight の照度がコサイン・逆二乗則の解析解と一致すること. + + 仕様: E += intensity · max(n·L, 0) / r² + (baker.py 内 PointLight 処理ブロック) + + セットアップ: + 光源: position=(0, 0, 100mm) = (0, 0, 0.1m), intensity=1.0 + メッシュ: z=0mm の三角形 1 枚(法線 +Z) + 頂点 v0=(0,0,0) / v1=(10,0,0) / v2=(10,10,0) [mm] + 重心 = ((0+10+10)/3, (0+0+10)/3, 0) mm = (6.667, 3.333, 0) mm + + 解析解: + centroid_m = (0.006667, 0.003333, 0) m + light_pos_m = (0, 0, 0.1) m + direction = light_pos_m - centroid_m + r = ||direction|| + cos_theta = dot(normal_z, direction_normalized) + E = intensity * cos_theta / r² + """ + config, project_root = point_light_scene_config + + # 解析解の計算 + light_pos_m = np.array([0.0, 0.0, 0.1]) # (0,0,100mm) in meters + centroid_m = np.array([10.0 / 3.0 * 0.001, 10.0 / 3.0 * 0.001, 0.0]) + normal = np.array([0.0, 0.0, 1.0]) # +Z + + direction = light_pos_m - centroid_m + r = np.linalg.norm(direction) + dir_n = direction / r + cos_theta = np.dot(normal, dir_n) + intensity = 1.0 + E_expected = intensity * cos_theta / (r**2) + + result = bake_irradiance(config, project_root) + + assert "plane" in result, "mesh_id 'plane' がキーに含まれること" + irr = result["plane"] + assert irr.shape == (1,), "三角形 1 枚なので face 数は 1 であること" + + actual = float(irr[0]) + # 相対誤差 1% 以内(数値精度の範囲) + rel_err = abs(actual - E_expected) / E_expected + assert rel_err < 0.01, ( + f"PointLight 照度の解析解との相対誤差が 1% 超: " + f"actual={actual:.6f}, expected={E_expected:.6f}, rel_err={rel_err:.4f}" + ) + + +# --------------------------------------------------------------------------- +# 2. shadow ray テスト(遮蔽あり) +# --------------------------------------------------------------------------- + + +@pytest.mark.slow +def test_shadow_ray_blocks_occluded_faces( + tmp_path: Path, +): + """遮蔽板に隠れた face の照度が 0 になること. + + 仕様: shadow ray が遮蔽されたらスキップ(baker.py _shadow_ray_blocked) + + セットアップ: + - 評価板: z=0mm(法線 +Z) + - 遮蔽板: z=50mm(法線 +Z,LED と評価板の中間) + - LED: z=100mm + + 期待: 評価板の face は遮蔽板に隠れて irradiance == 0 + """ + # 評価板 STL(z=0mm, 法線 +Z, 大きめの面で重心が遮蔽板の投影内に入るよう設定) + eval_verts = np.array( + [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0]], dtype=np.float64 + ) + eval_faces = np.array([[0, 1, 2]], dtype=np.int64) + eval_mesh = trimesh.Trimesh(vertices=eval_verts, faces=eval_faces, process=False) + eval_stl = tmp_path / "eval_plane.stl" + eval_mesh.export(str(eval_stl)) + + # 遮蔽板 STL(z=50mm, 大きな平面で評価板をほぼ覆う) + blocker_verts = np.array( + [ + [-50.0, -50.0, 50.0], + [50.0, -50.0, 50.0], + [50.0, 50.0, 50.0], + [-50.0, 50.0, 50.0], + ], + dtype=np.float64, + ) + blocker_faces = np.array([[0, 1, 2], [0, 2, 3]], dtype=np.int64) + blocker_mesh = trimesh.Trimesh(vertices=blocker_verts, faces=blocker_faces, process=False) + blocker_stl = tmp_path / "blocker.stl" + blocker_mesh.export(str(blocker_stl)) + + config = _make_scene_config_with_meshes( + mesh_entries=[ + { + "id": "eval_plane", + "path": str(eval_stl), + "unit": "mm", + "material": "white", + }, + { + "id": "blocker", + "path": str(blocker_stl), + "unit": "mm", + "material": "white", + }, + ], + light_dict={ + "id": "led", + "type": "point", + "position": [5.0, 5.0, 100.0], # 遮蔽板の真上 + "intensity": 100.0, + "profile": "lambertian", + }, + ) + + result = bake_irradiance(config, tmp_path) + + assert "eval_plane" in result + irr_eval = result["eval_plane"] + # 全 face が遮蔽板に隠れているので irradiance == 0 + assert np.all(irr_eval == 0.0), ( + f"遮蔽板があるとき評価板の irradiance は 0 であるべき: got {irr_eval}" + ) + + +# --------------------------------------------------------------------------- +# 3. DirectionalLight 解析解一致テスト +# --------------------------------------------------------------------------- + + +@pytest.mark.slow +def test_directional_light_irradiance_matches_analytical_solution( + directional_light_scene_config: tuple[SceneConfig, Path], +): + """DirectionalLight の照度がコサイン則解析解と一致すること. + + 仕様: E += irradiance · max(n·L, 0)(距離減衰なし) + direction は「光の進行方向」なので入射方向は逆ベクトル. + + セットアップ: + 光源: direction=(0, 0, -1)(真下向き), irradiance=1.0 + メッシュ: z=0mm 三角形(法線 +Z) + + 解析解: + 入射方向 = -direction = (0, 0, 1) + cos_theta = dot(+Z, +Z) = 1.0 + E = 1.0 * 1.0 = 1.0 + """ + config, project_root = directional_light_scene_config + + result = bake_irradiance(config, project_root) + + assert "plane" in result + irr = result["plane"] + assert irr.shape == (1,) + + actual = float(irr[0]) + E_expected = 1.0 # irradiance=1.0, cos_theta=1.0 + + # 相対誤差 1% 以内 + rel_err = abs(actual - E_expected) / E_expected + assert rel_err < 0.01, ( + f"DirectionalLight 照度の解析解との相対誤差が 1% 超: " + f"actual={actual:.6f}, expected={E_expected:.6f}, rel_err={rel_err:.4f}" + ) + + +@pytest.mark.slow +def test_directional_light_irradiance_is_position_independent( + tmp_path: Path, +): + """DirectionalLight の照度は face の位置に依存しないこと(距離減衰なし). + + 仕様: DirectionalLight は距離減衰なし.全 face で同じ照度値になること. + + 複数の三角形を異なる XY 位置に配置し,照度が同一になることを検証する. + """ + # 複数の三角形を異なる XY 位置に配置(z=0mm, 法線 +Z) + # 三角形 A: 原点付近 + # 三角形 B: 遠い位置 + vertices = np.array( + [ + # 三角形 A の頂点 + [0.0, 0.0, 0.0], + [10.0, 0.0, 0.0], + [10.0, 10.0, 0.0], + # 三角形 B の頂点(100mm 離れた位置) + [100.0, 0.0, 0.0], + [110.0, 0.0, 0.0], + [110.0, 10.0, 0.0], + ], + dtype=np.float64, + ) + faces = np.array([[0, 1, 2], [3, 4, 5]], dtype=np.int64) + mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=False) + stl_path = tmp_path / "multi_tri.stl" + mesh.export(str(stl_path)) + + config = _make_scene_config_with_meshes( + mesh_entries=[ + { + "id": "multi_plane", + "path": str(stl_path), + "unit": "mm", + "material": "white", + } + ], + light_dict={ + "id": "dir_light", + "type": "directional", + "direction": [0.0, 0.0, -1.0], + "irradiance": 1.0, + }, + ) + + result = bake_irradiance(config, tmp_path) + + assert "multi_plane" in result + irr = result["multi_plane"] + assert irr.shape == (2,), "三角形 2 枚" + + # 全 face で照度が同一(位置非依存) + np.testing.assert_allclose( + irr[0], + irr[1], + rtol=1e-4, + err_msg="DirectionalLight は距離によらず照度が一定であること", + ) + # 照度値 > 0(光があたっている) + assert irr[0] > 0.0, "DirectionalLight の照度は正であること" + + +# --------------------------------------------------------------------------- +# 4. AreaLight 方向性テスト(emit_cos による裏面スキップ) +# --------------------------------------------------------------------------- + + +@pytest.mark.slow +def test_area_light_illuminates_facing_surface_not_back( + tmp_path: Path, +): + """AreaLight は放射方向(正面)にのみ照度を与え,裏面には与えないこと. + + 仕様: AreaLight の emit_cos = dot(-dir_n, dir_vec) が 0 以下の場合はスキップ(裏面) + + セットアップ: + AreaLight: center=(0, 0, 100mm), direction=(0, 0, -1)(下向き放射) + メッシュ A: 法線 +Z(光源の正面側)→ 照度あり + メッシュ B: 法線 -Z(光源の背面側,光源 z=100mm より上)→ 照度 0 + + メッシュ B を光源の下(z=0mm)ではなく上(z=200mm)に置くことで, + 面光源の正面(dir=(0,0,-1))から見たときに裏向きになる状況を作る. + + NOTE: 現在の実装では AreaLight の rectangle 形状が Mitsuba シーンに含まれるため, + shadow ray が AreaLight 形状にブロックされ irr_facing[0] == 0.0 になる(実装バグ). + """ + # メッシュ A: z=0mm, 法線 +Z(光源正面) + verts_a = np.array( + [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0]], dtype=np.float64 + ) + faces_a = np.array([[0, 1, 2]], dtype=np.int64) + mesh_a = trimesh.Trimesh(vertices=verts_a, faces=faces_a, process=False) + stl_a = tmp_path / "mesh_facing.stl" + mesh_a.export(str(stl_a)) + + # メッシュ B: z=0mm, 法線 -Z + # 頂点の巻き順を逆にして -Z 法線を作る + verts_b = np.array( + [[20.0, 0.0, 0.0], [30.0, 10.0, 0.0], [30.0, 0.0, 0.0]], dtype=np.float64 + ) + faces_b = np.array([[0, 1, 2]], dtype=np.int64) + mesh_b = trimesh.Trimesh(vertices=verts_b, faces=faces_b, process=False) + stl_b = tmp_path / "mesh_back.stl" + mesh_b.export(str(stl_b)) + + config = _make_scene_config_with_meshes( + mesh_entries=[ + { + "id": "facing", + "path": str(stl_a), + "unit": "mm", + "material": "white", + }, + { + "id": "back", + "path": str(stl_b), + "unit": "mm", + "material": "white", + }, + ], + light_dict={ + "id": "area_led", + "type": "area", + "shape": "rectangle", + "center": [5.0, 5.0, 100.0], # 中間あたり + "size": [10.0, 10.0], + "direction": [0.0, 0.0, -1.0], # 下向き放射 + "radiance": 1000.0, + }, + ) + + result = bake_irradiance(config, tmp_path) + + assert "facing" in result + assert "back" in result + + irr_facing = result["facing"] + irr_back = result["back"] + + # 正面メッシュ(法線 +Z,光源の正面)は照度 > 0 + assert irr_facing[0] > 0.0, ( + f"AreaLight 正面のメッシュは照度 > 0 であること: got {irr_facing[0]}" + ) + + # 背面メッシュ(法線 -Z,emit_cos <= 0)は照度 == 0 + assert irr_back[0] == 0.0, ( + f"AreaLight 背面のメッシュは照度 == 0 であること: got {irr_back[0]}" + ) + + +# --------------------------------------------------------------------------- +# 5. mode = "path" で NotImplementedError が発生すること +# --------------------------------------------------------------------------- + + +def test_unsupported_mode_raises_not_implemented_error( + point_light_scene_config: tuple[SceneConfig, Path], +): + """mode='path' を指定すると NotImplementedError が raise されること. + + 仕様: mode != "direct" で NotImplementedError(baker.py bake_irradiance) + Mitsuba をロードしないため @pytest.mark.slow は付けない. + """ + config, project_root = point_light_scene_config + + with pytest.raises(NotImplementedError): + bake_irradiance(config, project_root, mode="path") # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# 6. 戻り値の型・形状の検証 +# --------------------------------------------------------------------------- + + +@pytest.mark.slow +def test_bake_irradiance_returns_dict_of_float32_arrays( + point_light_scene_config: tuple[SceneConfig, Path], +): + """bake_irradiance の戻り値が {mesh_id: np.ndarray(float32)} であること. + + 仕様: 戻り値は {mesh_id: irradiance_per_face (N_f,) float32} + """ + config, project_root = point_light_scene_config + + result = bake_irradiance(config, project_root) + + assert isinstance(result, dict), "戻り値は dict であること" + for mesh_id, irr in result.items(): + assert isinstance(irr, np.ndarray), f"{mesh_id}: 値は np.ndarray であること" + assert irr.dtype == np.float32, f"{mesh_id}: dtype は float32 であること" + assert irr.ndim == 1, f"{mesh_id}: 1 次元配列であること" + + +@pytest.mark.slow +def test_bake_irradiance_keys_match_mesh_ids( + point_light_scene_config: tuple[SceneConfig, Path], +): + """bake_irradiance の辞書キーがシーン定義のメッシュ ID と一致すること. + + 仕様: 戻り値のキーは {mesh_id: ...}(mesh_cfg.id ごと) + """ + config, project_root = point_light_scene_config + + result = bake_irradiance(config, project_root) + + expected_ids = {m.id for m in config.geometry.meshes} + assert set(result.keys()) == expected_ids, ( + f"辞書キーがメッシュ ID と一致すること: expected={expected_ids}, got={set(result.keys())}" + ) + + +# --------------------------------------------------------------------------- +# 7. PointLight 照度は非負であること +# --------------------------------------------------------------------------- + + +@pytest.mark.slow +def test_point_light_irradiance_is_non_negative( + point_light_scene_config: tuple[SceneConfig, Path], +): + """全 face の照度値が非負であること. + + 仕様: E += intensity · max(n·L, 0) / r²(max により負にならない) + """ + config, project_root = point_light_scene_config + + result = bake_irradiance(config, project_root) + + for mesh_id, irr in result.items(): + assert np.all(irr >= 0.0), f"{mesh_id}: 照度は非負であること" diff --git a/tests/visualize/test_viewer.py b/tests/visualize/test_viewer.py new file mode 100644 index 0000000..8a65793 --- /dev/null +++ b/tests/visualize/test_viewer.py @@ -0,0 +1,194 @@ +"""visualize/viewer.py の仕様ベーステスト. + +仕様出典: +- Phase 1 実装サマリー(show_scene 仕様) +- docs/01_GUIDE/GUIDE_08_テスト方針.md(可視化テストは smoke テスト) + +テスト方針: +- PyVista は必ず off_screen=True で実行する(GUI を開かない) +- 「ファイルが生成されたか」程度の smoke テストで十分(GUIDE_08) +- Mitsuba をモックしない(GUIDE_08) + +テスト対象: + show_scene(scene_config, project_root, irradiance=None, *, off_screen=False, screenshot=None) +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +from sm_tias_lightsim.scene.schema import SceneConfig +from sm_tias_lightsim.visualize.baker import bake_irradiance +from sm_tias_lightsim.visualize.viewer import show_scene + +# --------------------------------------------------------------------------- +# フィクスチャ:minimal.yaml をそのまま使う +# --------------------------------------------------------------------------- + +MINIMAL_YAML = Path(__file__).parent.parent / "fixtures" / "minimal.yaml" + + +@pytest.fixture(scope="module") +def minimal_scene_config() -> tuple[SceneConfig, Path]: + """tests/fixtures/minimal.yaml から SceneConfig と project_root を返す. + + module スコープで 1 回だけロード(YAML パース・Pydantic 検証は速いため). + """ + from sm_tias_lightsim.scene.schema import load_scene_yaml + + config = load_scene_yaml(MINIMAL_YAML) + project_root = MINIMAL_YAML.parent + return config, project_root + + +# --------------------------------------------------------------------------- +# 1. irradiance なしの smoke テスト +# --------------------------------------------------------------------------- + + +@pytest.mark.slow +def test_show_scene_without_irradiance_creates_screenshot( + tmp_path: Path, + minimal_scene_config: tuple[SceneConfig, Path], +): + """irradiance=None のとき screenshot が生成されること(no-bake モード). + + 仕様: irradiance なし → light gray の半透明メッシュで表示 + screenshot 指定時は off_screen=True を強制し,screenshot を保存する + """ + config, project_root = minimal_scene_config + screenshot_path = tmp_path / "out_no_irr.png" + + show_scene( + config, + project_root, + irradiance=None, + screenshot=screenshot_path, + ) + + assert screenshot_path.exists(), "irradiance なしで screenshot ファイルが生成されること" + assert screenshot_path.stat().st_size > 0, "screenshot ファイルが空でないこと" + + +# --------------------------------------------------------------------------- +# 2. irradiance ありの smoke テスト +# --------------------------------------------------------------------------- + + +@pytest.mark.slow +def test_show_scene_with_irradiance_creates_screenshot( + tmp_path: Path, + minimal_scene_config: tuple[SceneConfig, Path], +): + """irradiance を渡したとき screenshot が生成されること(bake モード). + + 仕様: irradiance あり → 各メッシュを cell_data["irradiance"] で着色(viridis) + """ + config, project_root = minimal_scene_config + + # ベイク結果を取得 + irradiance = bake_irradiance(config, project_root) + + screenshot_path = tmp_path / "out_with_irr.png" + show_scene( + config, + project_root, + irradiance=irradiance, + screenshot=screenshot_path, + ) + + assert screenshot_path.exists(), "irradiance ありで screenshot ファイルが生成されること" + assert screenshot_path.stat().st_size > 0, "screenshot ファイルが空でないこと" + + +# --------------------------------------------------------------------------- +# 3. screenshot=None, off_screen=True は no-op であること(例外を投げない) +# --------------------------------------------------------------------------- + + +@pytest.mark.slow +def test_show_scene_off_screen_without_screenshot_is_noop( + tmp_path: Path, + minimal_scene_config: tuple[SceneConfig, Path], +): + """screenshot=None かつ off_screen=True のとき例外を投げないこと(no-op). + + 仕様: screenshot=None かつ off_screen=True のときは no-op + (viewer.py show_scene の末尾条件分岐) + """ + config, project_root = minimal_scene_config + + # 例外が発生しないこと(戻り値は None) + result = show_scene( + config, + project_root, + irradiance=None, + off_screen=True, + screenshot=None, + ) + + assert result is None, "show_scene は None を返すこと" + + +# --------------------------------------------------------------------------- +# 4. screenshot 指定時に off_screen が強制されること(例外なく完了する) +# --------------------------------------------------------------------------- + + +@pytest.mark.slow +def test_show_scene_screenshot_forces_off_screen( + tmp_path: Path, + minimal_scene_config: tuple[SceneConfig, Path], +): + """screenshot を指定すると off_screen=False でも off_screen=True に強制されること. + + 仕様: screenshot 指定時は off_screen=True を強制し,screenshot を保存する + GUI ウィンドウが開かないことを間接的に確認する(off_screen 強制は CI/CD 環境で必須). + """ + config, project_root = minimal_scene_config + screenshot_path = tmp_path / "forced_offscreen.png" + + # off_screen=False でも screenshot 指定なら強制的に off_screen になるはず + show_scene( + config, + project_root, + irradiance=None, + off_screen=False, # これは screenshot 指定で上書きされる + screenshot=screenshot_path, + ) + + assert screenshot_path.exists(), "off_screen=False でも screenshot 指定なら保存されること" + + +# --------------------------------------------------------------------------- +# 5. irradiance の cell_data がメッシュに紐付けられていること(smoke) +# --------------------------------------------------------------------------- + + +@pytest.mark.slow +def test_show_scene_irradiance_with_wrong_mesh_id_does_not_crash( + tmp_path: Path, + minimal_scene_config: tuple[SceneConfig, Path], +): + """存在しない mesh_id のキーを irradiance に渡しても例外を投げないこと. + + 仕様: irradiance が None でないが mesh_id が辞書に含まれない場合, + そのメッシュは light gray の半透明で表示される(else ブランチ). + """ + config, project_root = minimal_scene_config + screenshot_path = tmp_path / "wrong_id.png" + + # 存在しない mesh_id を持つ irradiance を渡す + fake_irradiance = {"nonexistent_mesh": np.zeros(10, dtype=np.float32)} + + show_scene( + config, + project_root, + irradiance=fake_irradiance, + screenshot=screenshot_path, + ) + + assert screenshot_path.exists(), "不正な mesh_id でも例外を投げず screenshot が生成されること" diff --git a/uv.lock b/uv.lock index 78a87fb..f2df0f6 100644 --- a/uv.lock +++ b/uv.lock @@ -20,6 +20,97 @@ ] [[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } @@ -104,6 +195,39 @@ ] [[package]] +name = "cyclopts" +version = "4.11.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/f7/3ee212c1bc314551094fc8fda7b4b63c647ac5c32d06daa285d04d33edfc/cyclopts-4.11.2.tar.gz", hash = "sha256:8c9b77921660fa1ee52c150e2217ced672323efb3434e9b338077de1bc551ff4", size = 175935, upload-time = "2026-05-04T00:11:57.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/18/4cedda786e7da429e7489549a9e5461530d4133130e541f25fb94f015776/cyclopts-4.11.2-py3-none-any.whl", hash = "sha256:838020120b939549ff7c8423aca29c86764b5dd1d8a5d7f3753a6327861f537b", size = 213537, upload-time = "2026-05-04T00:11:56.103Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] name = "drjit" version = "1.3.1" source = { registry = "https://pypi.org/simple" } @@ -168,6 +292,15 @@ ] [[package]] +name = "idna" +version = "3.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, +] + +[[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } @@ -263,6 +396,18 @@ ] [[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] name = "matplotlib" version = "3.10.9" source = { registry = "https://pypi.org/simple" } @@ -317,6 +462,15 @@ ] [[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] name = "mitsuba" version = "3.8.0" source = { registry = "https://pypi.org/simple" } @@ -534,6 +688,15 @@ ] [[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + +[[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } @@ -543,6 +706,20 @@ ] [[package]] +name = "pooch" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "platformdirs" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/43/85ef45e8b36c6a48546af7b266592dc32d7f67837a6514d111bced6d7d75/pooch-1.9.0.tar.gz", hash = "sha256:de46729579b9857ffd3e741987a2f6d5e0e03219892c167c6578c0091fb511ed", size = 61788, upload-time = "2026-01-30T19:15:09.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl", hash = "sha256:f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b", size = 67175, upload-time = "2026-01-30T19:15:08.36Z" }, +] + +[[package]] name = "pydantic" version = "2.13.3" source = { registry = "https://pypi.org/simple" } @@ -679,6 +856,25 @@ ] [[package]] +name = "pyvista" +version = "0.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cyclopts" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "pooch" }, + { name = "scooby" }, + { name = "typing-extensions" }, + { name = "vtk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/24/a8bf4deb6521d4a26bdfe1262e2e942aaf49b866f8dc8395702d789e5089/pyvista-0.48.0.tar.gz", hash = "sha256:317c1590fce0d3777c34d7c2b4686dc52701a2ba03ab0a312bbd0bdae03f0d42", size = 2580723, upload-time = "2026-05-02T22:49:01.23Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/e8/d057b011aee8df7fdb00e5e8f777cea160f2e97f65796bff7c81b85e398d/pyvista-0.48.0-py3-none-any.whl", hash = "sha256:cb6fe80d4a1a7e9ef512b9b555d8b16355ea97b07799fc4f73f2897ca0109e09", size = 2628237, upload-time = "2026-05-02T22:48:58.867Z" }, +] + +[[package]] name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } @@ -725,6 +921,47 @@ ] [[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rich-rst" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, +] + +[[package]] name = "ruff" version = "0.15.12" source = { registry = "https://pypi.org/simple" } @@ -750,6 +987,15 @@ ] [[package]] +name = "scooby" +version = "0.11.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/06/9a8600207fd72a29ee965e9a4c61b750cc3fa106768f14a7b3ee3e36cb61/scooby-0.11.2.tar.gz", hash = "sha256:0575c73636ec4c2587bea1f8a038798ddcb249e02067fae897dac3bf4f4e444d", size = 242928, upload-time = "2026-04-22T23:13:12.307Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/bc/1173f502f1870e3bae81c148326c5cbcc19ec77df79a9aaf17a59911355c/scooby-0.11.2-py3-none-any.whl", hash = "sha256:f34c36bbee749b2c55816a080521f216d88304e635017e911c12249607d38c49", size = 20142, upload-time = "2026-04-22T23:13:10.705Z" }, +] + +[[package]] name = "six" version = "1.17.0" source = { registry = "https://pypi.org/simple" } @@ -769,6 +1015,7 @@ { name = "pandas" }, { name = "pillow" }, { name = "pydantic" }, + { name = "pyvista" }, { name = "pyyaml" }, { name = "trimesh" }, ] @@ -787,6 +1034,7 @@ { name = "pandas", specifier = ">=3.0.2" }, { name = "pillow", specifier = ">=12.2.0" }, { name = "pydantic", specifier = ">=2.13.3" }, + { name = "pyvista", specifier = ">=0.45" }, { name = "pyyaml", specifier = ">=6.0.3" }, { name = "trimesh", specifier = ">=4.12.1" }, ] @@ -838,3 +1086,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, ] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "vtk" +version = "9.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/89/c274101ec7b9bf7356333fdacf5e634803fe6b40f776e82c6ce9d941e0ad/vtk-9.6.1-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:b8125e3e3bc3160e18853a15be98101d0efe662c16036179ab15ddf1669b32af", size = 114729308, upload-time = "2026-03-27T13:50:37.547Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1a/ecbebaf31724a00f85fc4dbf95992b507328f615362ee8fa5ea1a38cf9d6/vtk-9.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:956d05b8c53c6a9eba569de244e9c8229815bbb3e024bb9954fafe163407e66d", size = 106814956, upload-time = "2026-03-27T13:51:24.324Z" }, + { url = "https://files.pythonhosted.org/packages/46/66/ba3c8b277cfa8058e982bfbd47875d9c6b4c06e65f98d577c69a2628f8d4/vtk-9.6.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9728e8d41889a0f105b5d20a73a4da80f398b2cfe6057fa7a94cd61128c3ceb4", size = 145920093, upload-time = "2026-03-27T13:53:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cb/0bbf91cd45a8d8f5453fe01cddf44c913db6316b3a2b15f41893ae0ca9ad/vtk-9.6.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3b5ec2e56bd6165189aa2e6e896edda29460e63040f897e1a123a1592810266d", size = 135683842, upload-time = "2026-03-27T13:52:15.218Z" }, + { url = "https://files.pythonhosted.org/packages/08/c0/653c94939498a3976157f054b830ade5c1da48ae288a23547f55fc25a262/vtk-9.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:4022fda8af46636f74c3c1932c2365da13a1dc8779a6b1ea4b13dc5bbcdb729f", size = 81262921, upload-time = "2026-03-27T13:53:50.192Z" }, + { url = "https://files.pythonhosted.org/packages/a8/8d/16e597f86241772fe188bbdd86a74ce48eadd2dd9513e2410b4ea07f78aa/vtk-9.6.1-cp313-cp313-macosx_10_10_x86_64.whl", hash = "sha256:88983bce26f7665ac6e4fb7de16cf53b896140a1a6cadd942d3c13e7c74a8530", size = 114747320, upload-time = "2026-03-27T13:54:33.138Z" }, + { url = "https://files.pythonhosted.org/packages/63/ca/8f0c19bded437423479d0d3ff0b7457cf6ef68def322666df867e6dacc0f/vtk-9.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:94ed369a54c6cfacea0b34f42d7d3ef41fa06c1aabfc75d93cabdc9047454293", size = 106817051, upload-time = "2026-03-27T13:55:21.903Z" }, + { url = "https://files.pythonhosted.org/packages/82/22/c1d98e6e191481af1e5c82ae3fa750798d868aa442a76db027f6a7901b95/vtk-9.6.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:deeb86794cd42f922ea75711b9717e45841777624203727eb84595b709af1382", size = 145920554, upload-time = "2026-03-27T13:57:14.258Z" }, + { url = "https://files.pythonhosted.org/packages/16/5d/658f60209de7b41b634178aee1f458bcad149aa2654d16bd023c09afd29c/vtk-9.6.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:fef8abc33168ad38b2622cf29048b7d5fe48a45789bf0a0421781f5cafa1e554", size = 135686060, upload-time = "2026-03-27T13:56:23.89Z" }, + { url = "https://files.pythonhosted.org/packages/f0/31/e4eb318901a8e736c936491e759ce03a1656792f728ae912db0e20997e9a/vtk-9.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:a5db7b2ff8fc3f56b547c8b9b7bc117a869c902683c86ef5cd6197c087f66183", size = 81264861, upload-time = "2026-03-27T13:57:47.164Z" }, + { url = "https://files.pythonhosted.org/packages/43/de/ad1ccb188681d51b5e5621f383afa0a1dd8711ff8f3dcba4ff950f758cbb/vtk-9.6.1-cp314-cp314-macosx_10_10_x86_64.whl", hash = "sha256:91257894723dfced8be264915d81ba418d08e5bbdb4873da0f12b02c1e21f244", size = 114382745, upload-time = "2026-03-27T13:58:30.592Z" }, + { url = "https://files.pythonhosted.org/packages/59/21/7bb2bd61b4ae05db79e2f40df452a6dbcd3bb66c259fd2043aaf60bbe5b7/vtk-9.6.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e119721774418fba34e95852efaefe5ac4156a4a270362fef06894cdc1377b6e", size = 106826433, upload-time = "2026-03-27T13:59:22.109Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e4/5317afdeaa4a66fe037e1fedcb6bde86b888b8227c89aba6c8ad2946e380/vtk-9.6.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98df48bcd630a4ffa71ac09d6aebb69c628925902920419e3db838dc7f7ce0ed", size = 145936133, upload-time = "2026-03-27T14:01:19.29Z" }, + { url = "https://files.pythonhosted.org/packages/67/7f/f1375aa3ef1835e39b4bea978da06d4985bc1407e3e91384dfaabf5e09d0/vtk-9.6.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:3ea3c3e466a4a9cd8fa7e5b64595215467a7936c9f0fd61ec3275954c122a79c", size = 135710278, upload-time = "2026-03-27T14:00:19.358Z" }, + { url = "https://files.pythonhosted.org/packages/91/25/7ff877a0d4f3e848d994d3a774d5a8e4495681ed26c32eb9dbb2a86b50e5/vtk-9.6.1-cp314-cp314-win_amd64.whl", hash = "sha256:a05e12ab8c82e81b225feee5ca08fda5fff814520a11c2941cc866335d990e03", size = 83197720, upload-time = "2026-03-27T14:01:53.313Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ae/f621aaed0a36c99ba3c1b2f2593386094222132a8396f21c616adffacaaf/vtk-9.6.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:043fb013a2669180180bd0ab667f318f2e1f14da69ae943192a7443f5ce3721a", size = 145557875, upload-time = "2026-03-27T14:03:45.531Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d5/41edd3f8b38ad45b8fb30d12ef35be3d62e1221e455722a5d7d103ca2f0d/vtk-9.6.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d08b3760cbd8bffdbb4551033c4c9d87afe30e9e9d4c0e5cad29cbe7107c9af7", size = 135524312, upload-time = "2026-03-27T14:02:46.035Z" }, +]