Newer
Older
SmTIAS-LightSim / tests / scene / test_lights.py
"""scene/lights.py の仕様ベーステスト.

仕様出典:
- docs/04_SPEC/SPEC_02_シーン定義仕様.md(lights セクション)
- docs/03_PLAN/PLAN_02_開発ステップ.md(Step 4: scene/lights.py の役割)

build_light_dict の戻り値 dict 構造のみを検証する.Mitsuba の load_dict は呼ばない.
ただし mi.ScalarTransform4f を内部で使うため mitsuba のインポートは発生する.
"""

from __future__ import annotations

import pytest

from sm_tias_lightsim.scene.lights import build_light_dict
from sm_tias_lightsim.scene.schema import AreaLight, DirectionalLight, PointLight

# ---------------------------------------------------------------------------
# PointLight
# ---------------------------------------------------------------------------


def test_point_light_emitter_type_is_point():
    """PointLight から生成される dict の type が "point" であること.

    仕様: PointLight → {"type": "point", ...} (SPEC_01 scene/lights 責務)
    """
    light = PointLight(
        id="led_1",
        type="point",
        position=(0.0, 0.0, 50.0),
        intensity=1.0,
        profile="lambertian",
    )
    result = build_light_dict(light, position_scale=0.001)
    assert result["type"] == "point"


def test_point_light_position_scaled_by_position_scale():
    """PointLight の position が position_scale で変換されること.

    仕様: mm→m なら 0.001 を掛ける (SPEC_02 lights セクション「position: mm 単位」)
    """
    light = PointLight(
        id="led_1",
        type="point",
        position=(10.0, 20.0, 30.0),
        intensity=1.0,
        profile="lambertian",
    )
    result = build_light_dict(light, position_scale=0.001)
    pos = result["position"]
    assert pos == pytest.approx([0.01, 0.02, 0.03])


def test_point_light_scalar_intensity_expands_to_rgb():
    """スカラ intensity が RGB 3 要素リストに展開されること.

    仕様: intensity スカラ可 (SPEC_02 lights セクション)
    """
    light = PointLight(
        id="led_1",
        type="point",
        position=(0.0, 0.0, 50.0),
        intensity=2.5,
        profile="lambertian",
    )
    result = build_light_dict(light, position_scale=0.001)
    assert result["intensity"]["type"] == "rgb"
    assert result["intensity"]["value"] == pytest.approx([2.5, 2.5, 2.5])


def test_point_light_rgb_intensity_preserved():
    """RGB intensity がそのまま保持されること.

    仕様: intensity: [R, G, B] 形式 (SPEC_02 lights セクション)
    """
    light = PointLight(
        id="led_1",
        type="point",
        position=(0.0, 0.0, 50.0),
        intensity=(1.0, 0.5, 0.2),
        profile="lambertian",
    )
    result = build_light_dict(light, position_scale=0.001)
    assert result["intensity"]["value"] == pytest.approx([1.0, 0.5, 0.2])


def test_point_light_ies_profile_not_accepted_by_schema():
    """profile="ies" は現在スキーマレベルで ValidationError になること.

    仕様: profile: ies は将来拡張(SPEC_02 lights セクション).
    現在の PointLight スキーマは lambertian / isotropic のみ受け付ける.
    build_light_dict 内の NotImplementedError ガードはスキーマ迂回時のフォールバック.

    注: SPEC_02 では profile: lambertian | isotropic | ies と記述されているが,
    現在の schema.py 実装では "ies" が Literal に含まれておらず ValidationError になる.
    将来 "ies" がスキーマに追加された際には,このテストも NotImplementedError 確認に変更する.
    """
    from pydantic import ValidationError

    with pytest.raises(ValidationError):
        PointLight(
            id="led_ies",
            type="point",
            position=(0.0, 0.0, 50.0),
            intensity=1.0,
            profile="ies",  # type: ignore[arg-type]
        )


def test_point_light_position_scale_one_leaves_position_unchanged():
    """position_scale=1.0 のとき position がそのまま保持されること.

    仕様: position_scale が 1.0 なら m 入力がそのまま Mitsuba へ渡る
    """
    light = PointLight(
        id="led_1",
        type="point",
        position=(1.0, 2.0, 3.0),
        intensity=1.0,
        profile="isotropic",
    )
    result = build_light_dict(light, position_scale=1.0)
    assert result["position"] == pytest.approx([1.0, 2.0, 3.0])


# ---------------------------------------------------------------------------
# AreaLight
# ---------------------------------------------------------------------------


def test_area_light_shape_is_rectangle():
    """AreaLight から生成される dict の type が "rectangle" であること.

    仕様: AreaLight → {"type": "rectangle", ...} (SPEC_01 scene/lights 責務)
    """
    light = AreaLight(
        id="led_area",
        type="area",
        shape="rectangle",
        center=(0.0, 0.0, 20.0),
        size=(10.0, 10.0),
        direction=(0.0, 0.0, -1.0),
        radiance=50.0,
    )
    result = build_light_dict(light, position_scale=0.001)
    assert result["type"] == "rectangle"


def test_area_light_has_nested_area_emitter():
    """AreaLight の dict に type="area" のネスト emitter が含まれること.

    仕様: AreaLight → {"type": "rectangle", ..., "emitter": {"type": "area", ...}}
    (SPEC_01 scene/lights 責務)
    """
    light = AreaLight(
        id="led_area",
        type="area",
        shape="rectangle",
        center=(0.0, 0.0, 20.0),
        size=(10.0, 10.0),
        direction=(0.0, 0.0, -1.0),
        radiance=50.0,
    )
    result = build_light_dict(light, position_scale=0.001)
    assert "emitter" in result
    assert result["emitter"]["type"] == "area"


def test_area_light_radiance_rgb_in_nested_emitter():
    """AreaLight の radiance が nested emitter の rgb dict に格納されること.

    仕様: AreaLight の emitter dict に radiance (SPEC_01 scene/lights 責務)
    """
    light = AreaLight(
        id="led_area",
        type="area",
        shape="rectangle",
        center=(0.0, 0.0, 20.0),
        size=(10.0, 10.0),
        direction=(0.0, 0.0, -1.0),
        radiance=(50.0, 50.0, 50.0),
    )
    result = build_light_dict(light, position_scale=0.001)
    radiance = result["emitter"]["radiance"]
    assert radiance["type"] == "rgb"
    assert radiance["value"] == pytest.approx([50.0, 50.0, 50.0])


def test_area_light_scalar_radiance_expands_to_rgb():
    """スカラ radiance が RGB 3 要素リストに展開されること.

    仕様: radiance スカラ可 (SPEC_02 lights セクション)
    """
    light = AreaLight(
        id="led_area",
        type="area",
        shape="rectangle",
        center=(0.0, 0.0, 20.0),
        size=(10.0, 10.0),
        direction=(0.0, 0.0, -1.0),
        radiance=100.0,
    )
    result = build_light_dict(light, position_scale=0.001)
    assert result["emitter"]["radiance"]["value"] == pytest.approx([100.0, 100.0, 100.0])


def test_area_light_to_world_key_exists():
    """AreaLight の dict に to_world キーが存在すること.

    仕様: AreaLight → {"type": "rectangle", "to_world": ..., "emitter": ...}
    (SPEC_01 scene/lights 責務)
    """
    light = AreaLight(
        id="led_area",
        type="area",
        shape="rectangle",
        center=(0.0, 0.0, 20.0),
        size=(10.0, 10.0),
        direction=(0.0, 0.0, -1.0),
        radiance=50.0,
    )
    result = build_light_dict(light, position_scale=0.001)
    assert "to_world" in result


def test_area_light_position_scale_affects_to_world():
    """position_scale が異なると to_world が変わること(文字列表現で差分確認).

    仕様: position_scale で center/size の座標が変換される (SPEC_01 scene/lights 責務)
    """
    light = AreaLight(
        id="led_area",
        type="area",
        shape="rectangle",
        center=(100.0, 0.0, 0.0),
        size=(10.0, 10.0),
        direction=(0.0, 0.0, -1.0),
        radiance=50.0,
    )
    result_mm = build_light_dict(light, position_scale=0.001)
    result_m = build_light_dict(light, position_scale=1.0)
    # to_world は同じ center でも scale が違えば異なるはず
    assert str(result_mm["to_world"]) != str(result_m["to_world"])


# ---------------------------------------------------------------------------
# DirectionalLight
# ---------------------------------------------------------------------------


def test_directional_light_emitter_type_is_directional():
    """DirectionalLight から生成される dict の type が "directional" であること.

    仕様: DirectionalLight → {"type": "directional", ...} (SPEC_01 scene/lights 責務)
    """
    light = DirectionalLight(
        id="sun",
        type="directional",
        direction=(0.0, 0.0, -1.0),
        irradiance=1.0,
    )
    result = build_light_dict(light, position_scale=0.001)
    assert result["type"] == "directional"


def test_directional_light_direction_preserved():
    """DirectionalLight の direction がそのまま dict に保持されること.

    仕様: DirectionalLight → {"type": "directional", "direction": [...], ...}
    (SPEC_02 lights セクション)
    """
    light = DirectionalLight(
        id="sun",
        type="directional",
        direction=(0.0, -1.0, -1.0),
        irradiance=1.0,
    )
    result = build_light_dict(light, position_scale=0.001)
    assert result["direction"] == pytest.approx([0.0, -1.0, -1.0])


def test_directional_light_scalar_irradiance_expands_to_rgb():
    """スカラ irradiance が RGB 3 要素リストに展開されること.

    仕様: irradiance スカラ可 (SPEC_02 lights セクション)
    """
    light = DirectionalLight(
        id="sun",
        type="directional",
        direction=(0.0, 0.0, -1.0),
        irradiance=3.0,
    )
    result = build_light_dict(light, position_scale=0.001)
    assert result["irradiance"]["type"] == "rgb"
    assert result["irradiance"]["value"] == pytest.approx([3.0, 3.0, 3.0])


def test_directional_light_rgb_irradiance_preserved():
    """RGB irradiance がそのまま保持されること.

    仕様: irradiance: [R, G, B] (SPEC_02 lights セクション)
    """
    light = DirectionalLight(
        id="sun",
        type="directional",
        direction=(0.0, 0.0, -1.0),
        irradiance=(1.0, 0.8, 0.6),
    )
    result = build_light_dict(light, position_scale=0.001)
    assert result["irradiance"]["value"] == pytest.approx([1.0, 0.8, 0.6])