"""面光源のサイズ→世界座標軸マッピングの再発防止テスト.
仕様出典:
- 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)