Newer
Older
SmTIAS-LightSim / tests / visualize / test_baker.py
"""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}: 照度は非負であること"