diff --git a/CLAUDE.md b/CLAUDE.md index e5453d8..5e36ff8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,8 +5,8 @@ ## 開発進捗 -現在の進捗: フェーズ 7(実装)— Step 1〜4 完了. -次のアクション: `/implement Step 5: render CLI`([PLAN_02 参照](docs/03_PLAN/PLAN_02_開発ステップ.md)) +現在の進捗: フェーズ 7(実装)— Step 1〜5 完了. +次のアクション: `/implement Step 6: evaluate CLI + 可視化`([PLAN_02 参照](docs/03_PLAN/PLAN_02_開発ステップ.md)) 実装済みモジュール: - `src/sm_tias_lightsim/uniformity.py` — Rec.709 輝度変換 + CoV/std/max-min(MiniTias-Evaluation コピー) @@ -19,6 +19,7 @@ - `src/sm_tias_lightsim/scene/builder.py` — SceneConfig → Mitsuba scene dict(PLY 一時ファイル経由) - `src/sm_tias_lightsim/render/runner.py` — variant 管理 + `mi.render` 実行 - `src/sm_tias_lightsim/render/output.py` — PNG / luminance.npy 出力 +- `src/sm_tias_lightsim/cli/render.py` — `sm-render` CLI(YAML → PNG / luminance.npy) ※ ステップ完了時にここを更新すること. ## 必須ルール(コード実装時) diff --git a/pyproject.toml b/pyproject.toml index 8dd8137..0215d06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ [project.scripts] sm-tias-lightsim = "sm_tias_lightsim:main" +sm-render = "sm_tias_lightsim.cli.render:main" [build-system] requires = ["uv_build>=0.11.7,<0.12.0"] diff --git a/src/sm_tias_lightsim/cli/__init__.py b/src/sm_tias_lightsim/cli/__init__.py new file mode 100644 index 0000000..65dc744 --- /dev/null +++ b/src/sm_tias_lightsim/cli/__init__.py @@ -0,0 +1 @@ +"""cli パッケージ — コマンドラインエントリポイント群.""" diff --git a/src/sm_tias_lightsim/cli/render.py b/src/sm_tias_lightsim/cli/render.py new file mode 100644 index 0000000..91ad61e --- /dev/null +++ b/src/sm_tias_lightsim/cli/render.py @@ -0,0 +1,123 @@ +# pyright: reportCallIssue=none +# Mitsuba 3 は variant ベースの動的属性解決を行うため Pylance/Pyright が +# mi.* を object 型と推論し reportCallIssue を誤検知する(GUIDE_06 の方針). +# 下記の Mitsuba 依存 import(load_scene_yaml / render_scene / save_png / save_luminance)を +# main() 内で遅延 import しているのは,--help だけ使う場面(Mitsuba 不在の軽量環境)でも +# 起動できるようにするためである. +"""sm-render コマンドラインエントリポイント. + +YAML シーン定義を読み込み,Mitsuba 3 でレンダリングを実行して +PNG と luminance.npy を出力ディレクトリに保存する. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +# Step 6 の evaluate CLI も同じファイルを読むため,名前を定数として一元管理する +_PNG_FILENAME = "render.png" +_NPY_FILENAME = "luminance.npy" + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """コマンドライン引数をパースして返す. + + Args: + argv: 引数リスト.None のとき sys.argv[1:] を使用. + + Returns: + パース済み Namespace. + """ + parser = argparse.ArgumentParser( + prog="sm-render", + description=( + "YAML シーン定義から Mitsuba 3 でレンダリングし PNG と luminance.npy を出力する." + ), + ) + parser.add_argument( + "--scene", + "-s", + required=True, + type=Path, + metavar="YAML_PATH", + help="シーン定義 YAML ファイルのパス.", + ) + parser.add_argument( + "--output", + "-o", + required=True, + type=Path, + metavar="OUTPUT_DIR", + help="出力先ディレクトリ(存在しない場合は親ごと作成する).", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """sm-render コマンドのメインエントリポイント. + + 処理フロー: + 1. コマンドライン引数をパースする. + 2. YAML シーン定義を load_scene_yaml で読み込み,SceneConfig を得る. + 3. 出力ディレクトリを作成する(親ごと,既存なら何もしない). + 4. render_scene で Mitsuba レンダリングを実行し,HDR 配列を得る. + 5. save_png で render.png を保存する. + 6. save_luminance で luminance.npy を保存する. + + Args: + argv: 引数リスト.None のとき sys.argv[1:] を使用. + + Returns: + 終了コード(成功時 0,失敗時 1). + """ + # argparse 自体のエラー(必須引数欠落等)は ArgumentParser が直接 sys.exit するため + # ここでは ValueError / FileNotFoundError 等の実行時エラーのみ捕捉する + args = _parse_args(argv) + + scene_path: Path = args.scene.resolve() + output_dir: Path = args.output.resolve() + + 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 + + try: + output_dir.mkdir(parents=True, exist_ok=True) + except OSError as exc: + print(f"エラー: 出力ディレクトリを作成できません — {exc}", file=sys.stderr) + return 1 + + # project_root を scene YAML の親ディレクトリとすることで, + # YAML 内の STL 相対パスが YAML ファイルを基点に解決される + project_root = scene_path.parent + try: + from sm_tias_lightsim.render.runner import render_scene + + hdr_image = render_scene(config, project_root=project_root) + except Exception as exc: # noqa: BLE001 + print(f"エラー: レンダリングに失敗しました — {exc}", file=sys.stderr) + return 1 + + png_path = output_dir / _PNG_FILENAME + npy_path = output_dir / _NPY_FILENAME + try: + from sm_tias_lightsim.render.output import save_luminance, save_png + + save_png(hdr_image, png_path) + save_luminance(hdr_image, npy_path) + except Exception as exc: # noqa: BLE001 + print(f"エラー: 出力ファイルの保存に失敗しました — {exc}", file=sys.stderr) + return 1 + + print(f"レンダリング完了: {png_path}") + print(f"輝度マップ保存: {npy_path}") + return 0 diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 0000000..fa5bbbb --- /dev/null +++ b/tests/cli/__init__.py @@ -0,0 +1 @@ +"""cli テストパッケージ.""" diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py new file mode 100644 index 0000000..51e6766 --- /dev/null +++ b/tests/cli/conftest.py @@ -0,0 +1,55 @@ +"""cli テスト共通フィクスチャ. + +不正 YAML など CLI 異常系テスト用の一時ファイルを提供する. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + + +@pytest.fixture +def invalid_yaml_file(tmp_path: Path) -> Path: + """必須フィールド(name)が欠落した不正 YAML ファイルを tmp_path に書き出す. + + load_scene_yaml が Pydantic ValidationError を投げる経路を踏ませるためのフィクスチャ. + FileNotFoundError とは異なる「読み込み失敗」経路のテスト用. + """ + content = """\ +# name フィールドを意図的に省略(必須フィールド欠落) +geometry: + meshes: + - id: sphere + path: meshes/sphere.stl + unit: mm + material: white +materials: + white: + type: diffuse + reflectance: 0.9 +lights: + - id: led_top + type: point + position: [0.0, 0.0, 50.0] + intensity: 10.0 + profile: lambertian +sensor: + type: perspective + position: [0.0, 0.0, 100.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: llvm_ad_rgb + integrator: path + max_depth: 4 + spp: 4 +""" + yaml_path = tmp_path / "invalid_scene.yaml" + yaml_path.write_text(content, encoding="utf-8") + return yaml_path diff --git a/tests/cli/test_render.py b/tests/cli/test_render.py new file mode 100644 index 0000000..32969db --- /dev/null +++ b/tests/cli/test_render.py @@ -0,0 +1,233 @@ +"""cli/render.py の仕様ベーステスト. + +仕様出典: +- docs/03_PLAN/PLAN_02_開発ステップ.md(Step 5: render CLI 完了条件) +- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(CLI 層の責務) +- docs/01_GUIDE/GUIDE_08_テスト方針.md(テスト方針) + +Step 5 完了条件: + `sm-render --scene --output ` で PNG と luminance.npy が生成されること + +テスト方針: + - CLI の振る舞いを main() を直接呼び出す形で検証する(subprocess 不使用) + - Mitsuba 実行を伴うテストは @pytest.mark.slow を付ける + - 軽量テスト(argparse / エラーハンドリング)は @pytest.mark.slow なし +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from sm_tias_lightsim.cli.render import main + +# --------------------------------------------------------------------------- +# テスト用定数 +# --------------------------------------------------------------------------- + +#: tests/fixtures/minimal.yaml の絶対パス +MINIMAL_YAML = Path(__file__).parent.parent / "fixtures" / "minimal.yaml" + + +# --------------------------------------------------------------------------- +# 1. 必須引数の欠落(軽量 — argparse 動作) +# --------------------------------------------------------------------------- + + +def test_missing_scene_arg_exits_with_error(): + """--scene を渡さないと SystemExit が発生すること. + + 仕様: --scene は必須引数(PLAN_02 Step 5「sm-render --scene 」) + argparse の required=True は SystemExit を発生させる. + """ + with pytest.raises(SystemExit) as exc_info: + main(["--output", "/tmp/out"]) + assert exc_info.value.code != 0 + + +def test_missing_output_arg_exits_with_error(): + """--output を渡さないと SystemExit が発生すること. + + 仕様: --output は必須引数(PLAN_02 Step 5「--output 」) + argparse の required=True は SystemExit を発生させる. + """ + with pytest.raises(SystemExit) as exc_info: + main(["--scene", str(MINIMAL_YAML)]) + assert exc_info.value.code != 0 + + +def test_no_args_exits_with_error(): + """引数なしで呼ぶと SystemExit が発生すること. + + 仕様: --scene と --output の両方が必須 + """ + with pytest.raises(SystemExit) as exc_info: + main([]) + assert exc_info.value.code != 0 + + +# --------------------------------------------------------------------------- +# 2. --scene が存在しないパス(軽量 — return code 1, stderr メッセージ) +# --------------------------------------------------------------------------- + + +def test_nonexistent_scene_returns_1(tmp_path: Path, capsys): + """存在しない YAML パスを渡すと return 1 かつ stderr にエラーメッセージが出ること. + + 仕様: FileNotFoundError 捕捉 → stderr「シーン YAML ファイルが見つかりません」→ return 1 + (cli/render.py 処理フロー ステップ 2) + """ + nonexistent = tmp_path / "does_not_exist.yaml" + result = main(["--scene", str(nonexistent), "--output", str(tmp_path / "out")]) + assert result == 1 + captured = capsys.readouterr() + assert "シーン YAML ファイルが見つかりません" in captured.err + + +def test_nonexistent_scene_error_message_contains_path(tmp_path: Path, capsys): + """存在しない YAML パスを渡したときのエラーメッセージにパス情報が含まれること. + + 仕様: stderr「エラー: シーン YAML ファイルが見つかりません — ...」 + (cli/render.py 処理フロー ステップ 2) + """ + nonexistent = tmp_path / "no_such_scene.yaml" + main(["--scene", str(nonexistent), "--output", str(tmp_path / "out")]) + captured = capsys.readouterr() + assert "エラー:" in captured.err + + +# --------------------------------------------------------------------------- +# 3. --scene が不正な YAML(ValidationError 等)(軽量 — return code 1) +# --------------------------------------------------------------------------- + + +def test_invalid_yaml_returns_1(invalid_yaml_file: Path, tmp_path: Path, capsys): + """必須フィールド欠落の YAML を渡すと return 1 が返ること. + + 仕様: Pydantic ValidationError 等の一般例外 → stderr「シーン YAML の読み込みに失敗しました」 + → return 1(FileNotFoundError と区別される経路) + (cli/render.py 処理フロー ステップ 2) + """ + result = main(["--scene", str(invalid_yaml_file), "--output", str(tmp_path / "out")]) + assert result == 1 + + +def test_invalid_yaml_stderr_message(invalid_yaml_file: Path, tmp_path: Path, capsys): + """不正 YAML のエラーメッセージに「シーン YAML の読み込みに失敗しました」が含まれること. + + 仕様: 一般例外捕捉 → stderr「エラー: シーン YAML の読み込みに失敗しました — ...」 + (cli/render.py 処理フロー ステップ 2) + """ + main(["--scene", str(invalid_yaml_file), "--output", str(tmp_path / "out")]) + captured = capsys.readouterr() + assert "シーン YAML の読み込みに失敗しました" in captured.err + + +def test_invalid_yaml_does_not_show_file_not_found_message( + invalid_yaml_file: Path, tmp_path: Path, capsys +): + """存在するが不正な YAML に対して「ファイルが見つかりません」メッセージが出ないこと. + + 仕様: FileNotFoundError と Pydantic ValidationError の経路が明確に分離されていること + (cli/render.py 処理フロー ステップ 2) + """ + main(["--scene", str(invalid_yaml_file), "--output", str(tmp_path / "out")]) + captured = capsys.readouterr() + assert "ファイルが見つかりません" not in captured.err + + +# --------------------------------------------------------------------------- +# 4 & 5 & 6. end-to-end 統合テスト(slow — Mitsuba 実行) +# --------------------------------------------------------------------------- + + +@pytest.mark.slow +def test_end_to_end_returns_0(tmp_path: Path): + """minimal.yaml を入力として main() が return 0 を返すこと. + + 仕様: Step 5 完了条件「sm-render --scene tests/fixtures/minimal.yaml --output が動く」 + (PLAN_02 Step 5 完了条件) + """ + result = main(["--scene", str(MINIMAL_YAML), "--output", str(tmp_path)]) + assert result == 0 + + +@pytest.mark.slow +def test_end_to_end_creates_render_png(tmp_path: Path): + """end-to-end 実行後に render.png が出力ディレクトリに存在すること. + + 仕様: sm-render は render.png を出力する (cli/render.py 処理フロー ステップ 5) + """ + main(["--scene", str(MINIMAL_YAML), "--output", str(tmp_path)]) + assert (tmp_path / "render.png").exists() + + +@pytest.mark.slow +def test_end_to_end_creates_luminance_npy(tmp_path: Path): + """end-to-end 実行後に luminance.npy が出力ディレクトリに存在すること. + + 仕様: sm-render は luminance.npy を出力する (cli/render.py 処理フロー ステップ 6) + """ + main(["--scene", str(MINIMAL_YAML), "--output", str(tmp_path)]) + assert (tmp_path / "luminance.npy").exists() + + +@pytest.mark.slow +def test_end_to_end_stdout_contains_render_complete(tmp_path: Path, capsys): + """end-to-end 実行後に stdout に「レンダリング完了」が含まれること. + + 仕様: 成功時 stdout に「レンダリング完了: ...」を出力する (cli/render.py 処理フロー ステップ 6) + """ + main(["--scene", str(MINIMAL_YAML), "--output", str(tmp_path)]) + captured = capsys.readouterr() + assert "レンダリング完了" in captured.out + + +@pytest.mark.slow +def test_end_to_end_stdout_contains_luminance_saved(tmp_path: Path, capsys): + """end-to-end 実行後に stdout に「輝度マップ保存」が含まれること. + + 仕様: 成功時 stdout に「輝度マップ保存: ...」を出力する (cli/render.py 処理フロー ステップ 6) + """ + main(["--scene", str(MINIMAL_YAML), "--output", str(tmp_path)]) + captured = capsys.readouterr() + assert "輝度マップ保存" in captured.out + + +# --------------------------------------------------------------------------- +# 5. 短縮引数 -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 の短縮形を提供する (cli/render.py _parse_args) + """ + result = main(["-s", str(MINIMAL_YAML), "-o", str(tmp_path)]) + assert result == 0 + assert (tmp_path / "render.png").exists() + assert (tmp_path / "luminance.npy").exists() + + +# --------------------------------------------------------------------------- +# 6. --output ディレクトリの自動作成(slow) +# --------------------------------------------------------------------------- + + +@pytest.mark.slow +def test_deep_nested_output_dir_is_created_automatically(tmp_path: Path): + """存在しない深いネストのディレクトリも mkdir(parents=True) で自動作成されること. + + 仕様: output_dir.mkdir(parents=True, exist_ok=True) で親ディレクトリも含めて作成する + (cli/render.py 処理フロー ステップ 3) + """ + deep_output = tmp_path / "deep" / "nested" / "out" + assert not deep_output.exists() + result = main(["--scene", str(MINIMAL_YAML), "--output", str(deep_output)]) + assert result == 0 + assert deep_output.exists() + assert (deep_output / "render.png").exists() + assert (deep_output / "luminance.npy").exists() diff --git a/tests/fixtures/meshes/sphere.stl b/tests/fixtures/meshes/sphere.stl new file mode 100644 index 0000000..644a832 --- /dev/null +++ b/tests/fixtures/meshes/sphere.stl Binary files differ diff --git a/tests/fixtures/minimal.yaml b/tests/fixtures/minimal.yaml new file mode 100644 index 0000000..f80074f --- /dev/null +++ b/tests/fixtures/minimal.yaml @@ -0,0 +1,35 @@ +# Step 5 完了条件 / 統合テスト用の最小シーン定義 +# uv run sm-render --scene tests/fixtures/minimal.yaml --output /tmp/test/ で動作確認する +# 64x64 低解像度・spp=4 で smoke 用途(高速完走優先) +name: smoke_test +geometry: + meshes: + - id: sphere + path: meshes/sphere.stl + unit: mm + material: white +materials: + white: + type: diffuse + reflectance: 0.9 +lights: + - id: led_top + type: point + position: [0.0, 0.0, 50.0] + intensity: 10.0 + profile: lambertian +sensor: + type: perspective + position: [0.0, 0.0, 100.0] + target: [0.0, 0.0, 0.0] + up: [0.0, 1.0, 0.0] + fov_deg: 60.0 + film: + width: 64 + height: 64 + rfilter: gaussian +render: + variant: llvm_ad_rgb + integrator: path + max_depth: 4 + spp: 4