"""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])