"""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 が生成されること"
# ---------------------------------------------------------------------------
# 6. bake_opacity クランプ — [0, 1] に制限されること(純ロジック)
# ---------------------------------------------------------------------------
class TestBakeOpacityClamp:
"""show_scene の bake_opacity クランプ仕様を検証する(純ロジック・引数チェック).
仕様(viewer.py show_scene):
bake_opacity = max(0.0, min(1.0, bake_opacity))
0.0 より小さいと 0.0 に、1.0 より大きいと 1.0 にクランプする.
"""
@pytest.mark.slow
def test_bake_opacity_above_1_is_clamped(
self,
tmp_path: Path,
minimal_scene_config: tuple[SceneConfig, Path],
):
"""bake_opacity > 1.0 を渡しても例外なく正常終了すること(1.0 にクランプ).
仕様: bake_opacity = max(0.0, min(1.0, bake_opacity))
"""
config, project_root = minimal_scene_config
screenshot_path = tmp_path / "opacity_clamp.png"
show_scene(
config,
project_root,
irradiance=None,
screenshot=screenshot_path,
bake_opacity=2.5, # 1.0 にクランプされること
)
assert screenshot_path.exists()
@pytest.mark.slow
def test_bake_opacity_below_0_is_clamped(
self,
tmp_path: Path,
minimal_scene_config: tuple[SceneConfig, Path],
):
"""bake_opacity < 0.0 を渡しても例外なく正常終了すること(0.0 にクランプ).
仕様: bake_opacity = max(0.0, min(1.0, bake_opacity))
"""
config, project_root = minimal_scene_config
screenshot_path = tmp_path / "opacity_below0.png"
show_scene(
config,
project_root,
irradiance=None,
screenshot=screenshot_path,
bake_opacity=-0.5, # 0.0 にクランプされること
)
assert screenshot_path.exists()
# ---------------------------------------------------------------------------
# 7. bake_smooth クランプ — 負値が 0 にクランプされること(純ロジック)
# ---------------------------------------------------------------------------
class TestBakeSmoothClamp:
"""show_scene の bake_smooth クランプ仕様を検証する.
仕様(viewer.py show_scene):
bake_smooth = max(0, bake_smooth)
負値は 0 にクランプする
"""
@pytest.mark.slow
def test_bake_smooth_negative_is_clamped_to_0(
self,
tmp_path: Path,
minimal_scene_config: tuple[SceneConfig, Path],
):
"""bake_smooth < 0 を渡しても例外なく正常終了すること(0 にクランプ).
仕様: bake_smooth = max(0, bake_smooth)
"""
config, project_root = minimal_scene_config
screenshot_path = tmp_path / "smooth_neg.png"
show_scene(
config,
project_root,
irradiance=None,
screenshot=screenshot_path,
bake_smooth=-3, # 0 にクランプされること
)
assert screenshot_path.exists()
# ---------------------------------------------------------------------------
# 8. bake_scale 不正値フォールバック("log" にフォールバック)
# ---------------------------------------------------------------------------
class TestBakeScaleFallback:
"""show_scene の bake_scale 不正値フォールバック仕様を検証する.
仕様(viewer.py show_scene):
if bake_scale not in ("linear", "log"):
bake_scale = "log"
未知の bake_scale は "log" にフォールバックする
"""
@pytest.mark.slow
def test_unknown_bake_scale_falls_back_to_log(
self,
tmp_path: Path,
minimal_scene_config: tuple[SceneConfig, Path],
):
"""bake_scale に不正値を渡しても例外なく正常終了すること("log" フォールバック).
仕様: bake_scale not in ("linear", "log") → "log" にフォールバック
"""
config, project_root = minimal_scene_config
screenshot_path = tmp_path / "scale_fallback.png"
show_scene(
config,
project_root,
irradiance=None,
screenshot=screenshot_path,
bake_scale="gamma", # 不正値 → "log" フォールバック
)
assert screenshot_path.exists()
# ---------------------------------------------------------------------------
# 9. E = π·L/ρ 換算ロジック(viewer._get_eval_board_reflectance)
# ---------------------------------------------------------------------------
class TestEvalBoardIrradianceConversion:
"""評価板の照度換算 E = π * L / ρ の仕様を検証する(純関数テスト).
仕様(SPEC_03「筐体(材料)」):
Lambertian 拡散面: L = ρ * E / π → E = π * L / ρ
ρ: 評価板材料の拡散反射率(既定 0.85、SPEC_03 printer_white)
viewer._get_eval_board_reflectance が:
- 正常時: 評価板 material の reflectance をスカラ値で返す
- material が DiffuseMaterial でない時: 0.85 フォールバック
- reflectance が範囲外(<=0 or >1): 0.85 フォールバック
"""
def _make_config_with_eval_board_material(
self, reflectance
) -> tuple[SceneConfig, str]:
"""評価板メッシュを含む SceneConfig と eval_board_id を返す."""
from sm_tias_lightsim.scene.schema import SceneConfig
data = {
"name": "test_e_board",
"geometry": {"meshes": [
{"id": "evaluation_board", "path": "board.stl", "unit": "mm",
"material": "printer_white"},
{"id": "housing", "path": "housing.stl", "unit": "mm",
"material": "printer_white"},
]},
"materials": {
"printer_white": {"type": "diffuse", "reflectance": reflectance},
},
"lights": [{"id": "led", "type": "point", "position": [0.0, 0.0, 100.0],
"intensity": 1.0, "profile": "lambertian"}],
"sensor": {
"type": "orthographic",
"position": [0.0, 0.0, -50.0],
"target": [0.0, 0.0, 0.0],
"up": [0.0, 1.0, 0.0],
"ortho_scale": [50.0, 52.0],
"film": {"width": 64, "height": 64},
},
"render": {"variant": "scalar_rgb", "integrator": "path", "max_depth": 2, "spp": 1},
}
return SceneConfig.model_validate(data), "evaluation_board"
def test_get_eval_board_reflectance_scalar(self):
"""scalar reflectance が正しくスカラ値で返ること.
仕様(viewer._get_eval_board_reflectance):
DiffuseMaterial の reflectance がスカラのとき float で返す
"""
from sm_tias_lightsim.visualize.viewer import _get_eval_board_reflectance
config, eval_id = self._make_config_with_eval_board_material(0.85)
rho = _get_eval_board_reflectance(config, eval_id)
assert rho == pytest.approx(0.85, abs=1e-6)
def test_get_eval_board_reflectance_baseline_spec(self):
"""SPEC_03 基準値 ρ=0.85 が正しく取得されること."""
from sm_tias_lightsim.visualize.viewer import _get_eval_board_reflectance
config, eval_id = self._make_config_with_eval_board_material(0.85)
rho = _get_eval_board_reflectance(config, eval_id)
assert rho == pytest.approx(0.85, abs=1e-6), "SPEC_03 基準設計値 ρ=0.85"
def test_get_eval_board_reflectance_fallback_for_missing_mesh(self):
"""評価板メッシュが存在しない場合に ρ=0.85 にフォールバックすること.
仕様: 評価板メッシュが見つからない場合は 0.85 をフォールバック値とする
"""
from sm_tias_lightsim.visualize.viewer import _get_eval_board_reflectance
config, _ = self._make_config_with_eval_board_material(0.90)
# 存在しない id を渡す
rho = _get_eval_board_reflectance(config, "nonexistent_board")
assert rho == pytest.approx(0.85, abs=1e-6), "存在しないメッシュでは 0.85 フォールバック"
def test_e_board_conversion_formula(self):
"""E = π * L / ρ の換算式が正しいこと(数値検証).
仕様(SPEC_03): Lambertian 拡散面 E = π * L / ρ
L=1.0, ρ=0.85 のとき E = π / 0.85 ≈ 3.6934
"""
lum = np.ones((10, 10), dtype=np.float64)
rho = 0.85
e_board = np.pi * lum / rho
expected = np.pi / 0.85
np.testing.assert_allclose(
e_board, expected,
rtol=1e-9,
err_msg="E = π * L / ρ の換算式が正しいこと"
)
def test_e_board_larger_than_luminance_for_rho_less_than_pi(self):
"""ρ < π のとき E > L であること(Lambertian の物理的性質).
仕様: ρ=0.85 < π なので E = π * L / ρ > L(照度は輝度より大きい)
"""
lum = np.full((5, 5), 100.0)
rho = 0.85
e_board = np.pi * lum / rho
assert np.all(e_board > lum), "E = π*L/ρ は ρ < π のとき E > L"
def test_get_eval_board_reflectance_rgb_tuple_uses_rec709(self):
"""RGB タプルの reflectance が Rec.709 輝度でスカラ化されること.
仕様(viewer._get_eval_board_reflectance):
RGB タプル → Rec.709 輝度でスカラ化: Y = 0.2126R + 0.7152G + 0.0722B
"""
from sm_tias_lightsim.scene.schema import SceneConfig
from sm_tias_lightsim.visualize.viewer import _get_eval_board_reflectance
r, g, b = 0.85, 0.85, 0.85
expected_luma = 0.2126 * r + 0.7152 * g + 0.0722 * b
data = {
"name": "test_rgb_rho",
"geometry": {"meshes": [
{"id": "evaluation_board", "path": "board.stl", "unit": "mm",
"material": "rgb_white"},
]},
"materials": {
"rgb_white": {"type": "diffuse", "reflectance": [r, g, b]},
},
"lights": [{"id": "led", "type": "point", "position": [0.0, 0.0, 100.0],
"intensity": 1.0, "profile": "lambertian"}],
"sensor": {
"type": "orthographic",
"position": [0.0, 0.0, -50.0],
"target": [0.0, 0.0, 0.0],
"up": [0.0, 1.0, 0.0],
"ortho_scale": [50.0, 52.0],
"film": {"width": 64, "height": 64},
},
"render": {"variant": "scalar_rgb", "integrator": "path", "max_depth": 2, "spp": 1},
}
config = SceneConfig.model_validate(data)
rho = _get_eval_board_reflectance(config, "evaluation_board")
assert rho == pytest.approx(expected_luma, abs=1e-6), (
f"RGB (r,g,b)=({r},{g},{b}) の Rec.709 輝度 {expected_luma:.6f} と一致すること"
)