Newer
Older
SmTIAS-LightSim / tests / scene / test_lights_axis_mapping.py
"""面光源のサイズ→世界座標軸マッピングの再発防止テスト.

仕様出典:
- docs/06_TEST/TEST_08_面光源スケール軸バグの発見と修正.md
- docs/06_TEST/TEST_13_物理モデル健全性の一括監査.md

TEST_08 のバグは `scale([sw / 2.0, 1.0, sh / 2.0])` と,発光面の高さを
縦(Y) ではなく奥行き(Z) に入れていたもの.LED の実寸 2.8 x 3.5 mm が
2.8 x (潰れた厚み) になり,下灯だけ 2.1 倍明るいという不一致を生んだ.
エラーは出ず,しかも TEST_07 でその出力が「物理的に正しい」と一度は正当化された.

既存の tests/scene/test_lights.py は to_world キーの存在と,position_scale を
変えたときに文字列表現が変わることしか検証しておらず,**軸を入れ替えても検出できない**.
本モジュールは単位図形の頂点を実際に to_world で変換し,世界座標での寸法を検証する.
"""

from __future__ import annotations

import numpy as np
import pytest

from sm_tias_lightsim.scene.lights import build_light_dict
from sm_tias_lightsim.scene.schema import AreaLight

# Mitsuba の rectangle は XY 平面上の ±1 正方形(法線 +Z).
# 発光面は direction=(0,0,-1) で世界座標の XY 平面に載るので,
# 変換後の X / Y 方向の広がりがそのまま実寸になる.
_UNIT_RECTANGLE_CORNERS = [(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)]

_MM_TO_M = 0.001


def _world_extents(light: AreaLight) -> tuple[float, float, float]:
    """発光面の単位図形を to_world で変換し,世界座標での XYZ の広がりを返す.

    Args:
        light: 検査対象の AreaLight.

    Returns:
        (X の広がり, Y の広がり, Z の広がり).単位は m.
    """
    import mitsuba as mi

    to_world = build_light_dict(light, position_scale=_MM_TO_M)["to_world"]
    points = np.array(
        [np.asarray(to_world @ mi.ScalarPoint3f(*c)).reshape(3) for c in _UNIT_RECTANGLE_CORNERS]
    )
    return (
        float(np.ptp(points[:, 0])),
        float(np.ptp(points[:, 1])),
        float(np.ptp(points[:, 2])),
    )


def _rectangle(size: tuple[float, float]) -> AreaLight:
    """XY 平面に載る矩形発光面を作る(direction は評価板と反対向きの基準設計と同じ)."""
    return AreaLight(
        id="led",
        type="area",
        shape="rectangle",
        center=(0.0, 0.0, 0.0),
        size=size,
        direction=(0.0, 0.0, -1.0),
        radiance=1.0,
    )


def test_size_width_maps_to_world_x_extent():
    """size[0](幅)が世界座標 X 方向の広がりになること.

    仕様: SPEC_02 の size は [幅, 高さ].TEST_08 で高さが Z に入る取り違えがあった.
    """
    extent_x, _, _ = _world_extents(_rectangle((2.8, 3.5)))
    assert extent_x == pytest.approx(2.8 * _MM_TO_M)


def test_size_height_maps_to_world_y_extent():
    """size[1](高さ)が世界座標 Y 方向の広がりになること.

    これが TEST_08 のバグそのもの(高さが Y ではなく Z に入っていた).
    """
    _, extent_y, _ = _world_extents(_rectangle((2.8, 3.5)))
    assert extent_y == pytest.approx(3.5 * _MM_TO_M)


def test_emitter_has_no_thickness_in_normal_direction():
    """発光面は法線方向(Z)に厚みを持たないこと.

    高さが Z に入っていると,この広がりが 0 でなくなる.
    """
    _, _, extent_z = _world_extents(_rectangle((2.8, 3.5)))
    assert extent_z == pytest.approx(0.0, abs=1e-12)


def test_swapping_size_swaps_world_extents():
    """size を入れ替えると世界座標の広がりも入れ替わること.

    軸マッピングが size に実際に追随していることの確認(定数を返す実装を排除する).
    """
    x1, y1, _ = _world_extents(_rectangle((2.8, 3.5)))
    x2, y2, _ = _world_extents(_rectangle((3.5, 2.8)))
    assert x2 == pytest.approx(y1)
    assert y2 == pytest.approx(x1)


def test_emitter_area_matches_physical_size():
    """発光面の面積が実寸どおりであること.

    面積は放射束 Phi = pi * L * A を決めるので,ここがずれると
    絶対輝度が系統的にずれる(TEST_08 の「下灯 2.1 倍」の正体).
    """
    extent_x, extent_y, _ = _world_extents(_rectangle((2.8, 3.5)))
    assert extent_x * extent_y == pytest.approx(2.8 * 3.5 * _MM_TO_M**2)


@pytest.mark.parametrize(("width", "height"), [(1.0, 1.0), (2.8, 3.5), (3.5, 2.8), (10.0, 0.5)])
def test_extents_match_size_for_various_aspect_ratios(width, height):
    """様々なアスペクト比で幅・高さが正しく対応すること."""
    extent_x, extent_y, _ = _world_extents(_rectangle((width, height)))
    assert extent_x == pytest.approx(width * _MM_TO_M)
    assert extent_y == pytest.approx(height * _MM_TO_M)


def test_disk_extents_are_the_diameter_on_both_axes():
    """円板発光面は両軸とも直径 2r の広がりを持つこと."""
    light = AreaLight(
        id="disk",
        type="area",
        shape="disk",
        center=(0.0, 0.0, 0.0),
        radius=5.0,
        direction=(0.0, 0.0, -1.0),
        radiance=1.0,
    )
    # 単位円板の外接正方形(±1)が半径でスケールされる
    extent_x, extent_y, _ = _world_extents(light)
    assert extent_x == pytest.approx(2 * 5.0 * _MM_TO_M)
    assert extent_y == pytest.approx(2 * 5.0 * _MM_TO_M)