"""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 バリデーション
# ---------------------------------------------------------------------------
def test_invalid_mode_raises_value_error(
point_light_scene_config: tuple[SceneConfig, Path],
):
"""不正な mode 文字列を渡すと ValueError が raise されること.
仕様(baker.py bake_irradiance):
mode が "direct" でも "path" でもない場合 ValueError を raise する.
Mitsuba をロードしないため @pytest.mark.slow は付けない.
"""
config, project_root = point_light_scene_config
with pytest.raises(ValueError, match="mode="):
bake_irradiance(config, project_root, mode="invalid") # type: ignore[arg-type]
def test_valid_modes_do_not_raise_on_mode_check(
point_light_scene_config: tuple[SceneConfig, Path],
):
"""mode="direct" と mode="path" は ValueError を raise しないこと(mode チェック層).
仕様: mode は "direct" または "path" のみ有効(ValueError は不正値のみ).
この関数は Mitsuba ロードが必要なため slow マーカーを付ける.
"""
# "direct" は既存テスト(test_bake_irradiance_returns_dict_of_float32_arrays)でカバー済み.
# ここでは ValueError が mode チェック段階でのみ発生することを確認する.
config, project_root = point_light_scene_config
# "direct" で ValueError が出ないことを確認(正常呼び出し — 結果は別テストで検証)
import pytest as _pytest
with _pytest.raises(Exception) as exc_info:
bake_irradiance(config, project_root, mode="invalid_xyz") # type: ignore[arg-type]
assert isinstance(exc_info.value, ValueError), (
f"mode バリデーションは ValueError であること: got {type(exc_info.value)}"
)
# ---------------------------------------------------------------------------
# 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}: 照度は非負であること"
# ---------------------------------------------------------------------------
# 8. exclude_mesh_ids — 除外されたメッシュが戻り値に含まれないこと
# ---------------------------------------------------------------------------
@pytest.mark.slow
def test_exclude_mesh_ids_removes_mesh_from_result(
tmp_path: Path,
):
"""exclude_mesh_ids に指定したメッシュ ID が戻り値の辞書に含まれないこと.
仕様(baker.py bake_irradiance):
exclude_mesh_ids に指定された id はキーに含まれない.
2 メッシュのシーンで 1 つを除外し、除外した id が result に入っていないことを検証する.
"""
# 2 つのメッシュを用意する
verts = np.array(
[[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0]], dtype=np.float64
)
faces = np.array([[0, 1, 2]], dtype=np.int64)
stl_a = tmp_path / "mesh_a.stl"
stl_b = tmp_path / "mesh_b.stl"
mesh = trimesh.Trimesh(vertices=verts, faces=faces, process=False)
mesh.export(str(stl_a))
mesh.export(str(stl_b))
config = _make_scene_config_with_meshes(
mesh_entries=[
{"id": "mesh_a", "path": str(stl_a), "unit": "mm", "material": "white"},
{"id": "mesh_b", "path": str(stl_b), "unit": "mm", "material": "white"},
],
light_dict={
"id": "led",
"type": "point",
"position": [5.0, 5.0, 100.0],
"intensity": 1.0,
"profile": "lambertian",
},
)
# "mesh_b" を除外
result = bake_irradiance(config, tmp_path, exclude_mesh_ids={"mesh_b"})
assert "mesh_a" in result, "除外していないメッシュは戻り値に含まれること"
assert "mesh_b" not in result, "exclude_mesh_ids に指定したメッシュは戻り値に含まれないこと"
@pytest.mark.slow
def test_exclude_mesh_ids_empty_set_does_not_exclude_anything(
point_light_scene_config: tuple[SceneConfig, Path],
):
"""exclude_mesh_ids=set()(空集合)のとき全メッシュが戻り値に含まれること.
仕様(baker.py): exclude_mesh_ids が空の場合は全メッシュを処理する.
"""
config, project_root = point_light_scene_config
result_all = bake_irradiance(config, project_root, exclude_mesh_ids=set())
result_no_exclude = bake_irradiance(config, project_root)
assert set(result_all.keys()) == set(result_no_exclude.keys()), (
"exclude_mesh_ids=set() は除外なしと同一のキーセットを返すこと"
)
@pytest.mark.slow
def test_exclude_mesh_ids_none_behaves_same_as_empty_set(
point_light_scene_config: tuple[SceneConfig, Path],
):
"""exclude_mesh_ids=None のとき全メッシュが戻り値に含まれること(None は空集合と等価).
仕様(baker.py):
if exclude_mesh_ids is None:
exclude_mesh_ids = set()
"""
config, project_root = point_light_scene_config
result_none = bake_irradiance(config, project_root, exclude_mesh_ids=None)
expected_ids = {m.id for m in config.geometry.meshes}
assert set(result_none.keys()) == expected_ids, (
"exclude_mesh_ids=None は全メッシュを返すこと(空集合と等価)"
)