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