Newer
Older
SmTIAS-LightSim / tests / scene / test_materials_twosided.py
"""scene/materials.py の twosided ラップ仕様のテスト.

仕様出典:
- docs/06_TEST/TEST_13_物理モデル健全性の一括監査.md(片面 BSDF による無警告の黒化)

Mitsuba の diffuse / roughconductor は片面 BSDF であり,法線が裏返ったメッシュ面は
入射光を一切反射せずエラーも警告もなく真っ黒になる.SmTIAS は LED 直接光が評価板に
届かない間接照明のみの構成(SPEC_03)なので,内壁が 1 面でも黒化すると輝度分布が
静かに壊れる.build_scene_bsdf_dict はこれを防ぐため不透明反射材を twosided で包む.

積分球の解析解による検証(TEST_13):

| 法線 | BSDF | 解析解比 |
| --- | --- | --- |
| 内向き(正常) | diffuse | 0.9996 |
| 内向き(正常) | twosided | 0.9996(完全一致=無影響) |
| 外向き(裏返り) | diffuse | 0.0000(無警告で真っ黒) |
| 外向き(裏返り) | twosided | 1.0001(救済) |

透過を伴う誘電体は両面挙動が本質なのでラップしない.
"""

from __future__ import annotations

import pytest

from sm_tias_lightsim.scene.materials import build_material_dict, build_scene_bsdf_dict
from sm_tias_lightsim.scene.schema import (
    BlendBSDFConfig,
    DielectricMaterial,
    DiffuseMaterial,
    RoughConductorBSDFConfig,
    ThinDielectricMaterial,
)

# ---------------------------------------------------------------------------
# 不透明反射材は twosided でラップされる
# ---------------------------------------------------------------------------


def test_diffuse_is_wrapped_in_twosided():
    """diffuse が twosided で包まれること.

    片面 BSDF のままだと法線が裏返った面が無警告で真っ黒になるため.
    """
    result = build_scene_bsdf_dict(DiffuseMaterial(type="diffuse", reflectance=0.85))
    assert result["type"] == "twosided"


def test_wrapped_diffuse_preserves_inner_bsdf():
    """ラップしても内側の BSDF が素の変換結果と同一であること.

    twosided は面の向きの扱いだけを変え,反射特性そのものは変えない.
    """
    mat = DiffuseMaterial(type="diffuse", reflectance=(0.1, 0.2, 0.3))
    result = build_scene_bsdf_dict(mat)
    assert result["bsdf"] == build_material_dict(mat)


def test_roughconductor_is_wrapped_in_twosided():
    """roughconductor も片面 BSDF なので twosided で包まれること."""
    mat = RoughConductorBSDFConfig(type="roughconductor", alpha=0.1, distribution="ggx")
    result = build_scene_bsdf_dict(mat)
    assert result["type"] == "twosided"
    assert result["bsdf"]["type"] == "roughconductor"


def test_blend_of_opaque_components_is_wrapped():
    """全構成要素が不透明な blendbsdf は twosided で包まれること."""
    mat = BlendBSDFConfig(
        type="blendbsdf",
        weight=0.05,
        components=[
            DiffuseMaterial(type="diffuse", reflectance=0.85),
            RoughConductorBSDFConfig(type="roughconductor", alpha=0.1, distribution="ggx"),
        ],
    )
    result = build_scene_bsdf_dict(mat)
    assert result["type"] == "twosided"
    assert result["bsdf"]["type"] == "blendbsdf"


def test_blend_components_are_not_double_wrapped():
    """blendbsdf の構成要素は個別にはラップされないこと(二重ラップ防止)."""
    mat = BlendBSDFConfig(
        type="blendbsdf",
        weight=0.05,
        components=[
            DiffuseMaterial(type="diffuse", reflectance=0.85),
            DiffuseMaterial(type="diffuse", reflectance=0.5),
        ],
    )
    result = build_scene_bsdf_dict(mat)
    assert result["bsdf"]["bsdf_0"]["type"] == "diffuse"
    assert result["bsdf"]["bsdf_1"]["type"] == "diffuse"


# ---------------------------------------------------------------------------
# 誘電体はラップされない
# ---------------------------------------------------------------------------


def test_dielectric_is_not_wrapped():
    """dielectric は透過を伴い両面挙動が本質なのでラップされないこと.

    twosided で包むと透過が壊れる.
    """
    result = build_scene_bsdf_dict(DielectricMaterial(type="dielectric", int_ior=1.5, ext_ior=1.0))
    assert result["type"] == "dielectric"


def test_thindielectric_is_not_wrapped():
    """thindielectric もラップされないこと."""
    result = build_scene_bsdf_dict(
        ThinDielectricMaterial(type="thindielectric", int_ior=1.5, ext_ior=1.0)
    )
    assert result["type"] == "thindielectric"


def test_blend_containing_dielectric_is_not_wrapped():
    """透過成分を含む blendbsdf はラップされないこと."""
    mat = BlendBSDFConfig(
        type="blendbsdf",
        weight=0.5,
        components=[
            DiffuseMaterial(type="diffuse", reflectance=0.85),
            DielectricMaterial(type="dielectric", int_ior=1.5, ext_ior=1.0),
        ],
    )
    result = build_scene_bsdf_dict(mat)
    assert result["type"] == "blendbsdf"


# ---------------------------------------------------------------------------
# build_material_dict の後方互換
# ---------------------------------------------------------------------------


def test_build_material_dict_still_returns_bare_bsdf():
    """build_material_dict は素の BSDF を返し続けること(既存 API の後方互換)."""
    result = build_material_dict(DiffuseMaterial(type="diffuse", reflectance=0.85))
    assert result["type"] == "diffuse"


# ---------------------------------------------------------------------------
# builder への結線
# ---------------------------------------------------------------------------


def test_builder_registers_twosided_materials(tmp_path):
    """build_scene がシーンに登録する材料が twosided になっていること.

    build_material_dict を直接使う実装に戻ると片面 BSDF に逆戻りするため,
    結線そのものを検証する.
    """
    import shutil

    import yaml

    from sm_tias_lightsim.scene.builder import build_scene
    from sm_tias_lightsim.scene.schema import load_scene_yaml

    cfg = {
        "name": "t",
        "description": "t",
        "geometry": {
            "meshes": [
                {"id": "m", "path": "fixtures/meshes/sphere.stl", "unit": "mm", "material": "wall"}
            ]
        },
        "materials": {"wall": {"type": "diffuse", "reflectance": [0.85, 0.85, 0.85]}},
        "lights": [
            {
                "id": "src",
                "type": "point",
                "position": [0.0, 0.0, 0.0],
                "intensity": [1.0, 1.0, 1.0],
                "profile": "isotropic",
            }
        ],
        "sensor": {
            "type": "perspective",
            "position": [0.0, 0.0, -10.0],
            "target": [0.0, 0.0, 0.0],
            "up": [0.0, 1.0, 0.0],
            "fov_deg": 30.0,
            "film": {"width": 8, "height": 8, "rfilter": "box"},
        },
        "render": {"variant": "scalar_rgb", "integrator": "path", "max_depth": 64, "spp": 1},
    }
    p = tmp_path / "scene.yaml"
    p.write_text(yaml.safe_dump(cfg, allow_unicode=True), encoding="utf-8")

    from pathlib import Path

    scene_dict, tempdir = build_scene(load_scene_yaml(p), project_root=Path(__file__).parent.parent)
    try:
        assert scene_dict["mat_wall"]["type"] == "twosided"
    finally:
        shutil.rmtree(tempdir, ignore_errors=True)


def test_builder_keeps_material_id_on_the_wrapper(tmp_path):
    """材料 ID が twosided ラッパ側に付くこと(mesh からの ref 参照が解決するため)."""
    import shutil
    from pathlib import Path

    import yaml

    from sm_tias_lightsim.scene.builder import build_scene
    from sm_tias_lightsim.scene.schema import load_scene_yaml

    cfg = {
        "name": "t",
        "description": "t",
        "geometry": {
            "meshes": [
                {"id": "m", "path": "fixtures/meshes/sphere.stl", "unit": "mm", "material": "wall"}
            ]
        },
        "materials": {"wall": {"type": "diffuse", "reflectance": [0.85, 0.85, 0.85]}},
        "lights": [
            {
                "id": "src",
                "type": "point",
                "position": [0.0, 0.0, 0.0],
                "intensity": [1.0, 1.0, 1.0],
                "profile": "isotropic",
            }
        ],
        "sensor": {
            "type": "perspective",
            "position": [0.0, 0.0, -10.0],
            "target": [0.0, 0.0, 0.0],
            "up": [0.0, 1.0, 0.0],
            "fov_deg": 30.0,
            "film": {"width": 8, "height": 8, "rfilter": "box"},
        },
        "render": {"variant": "scalar_rgb", "integrator": "path", "max_depth": 64, "spp": 1},
    }
    p = tmp_path / "scene.yaml"
    p.write_text(yaml.safe_dump(cfg, allow_unicode=True), encoding="utf-8")

    scene_dict, tempdir = build_scene(load_scene_yaml(p), project_root=Path(__file__).parent.parent)
    try:
        assert scene_dict["mat_wall"]["id"] == "mat_wall"
        assert scene_dict["mesh_m"]["bsdf"] == {"type": "ref", "id": "mat_wall"}
    finally:
        shutil.rmtree(tempdir, ignore_errors=True)


@pytest.mark.parametrize("reflectance", [0.0, 0.5, 1.0])
def test_wrapping_is_independent_of_reflectance_value(reflectance):
    """反射率の値によらず一様にラップされること."""
    result = build_scene_bsdf_dict(DiffuseMaterial(type="diffuse", reflectance=reflectance))
    assert result["type"] == "twosided"