"""scene/builder.py の仕様ベーステスト.
仕様出典:
- docs/04_SPEC/SPEC_02_シーン定義仕様.md(全セクション)
- docs/03_PLAN/PLAN_02_開発ステップ.md(Step 4: scene/builder.py の役割)
- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(scene/builder 層の責務)
build_scene の戻り値 dict 構造のみを検証する(実レンダリングは行わない).
build_scene で生成された tempdir は各テスト末尾で shutil.rmtree する.
"""
from __future__ import annotations
import shutil
from pathlib import Path
import pytest
import trimesh
import yaml
from sm_tias_lightsim.scene.builder import build_scene
from sm_tias_lightsim.scene.schema import SceneConfig
# ---------------------------------------------------------------------------
# フィクスチャ
# ---------------------------------------------------------------------------
@pytest.fixture
def sphere_stl(tmp_path: Path) -> Path:
"""球体 STL を tmp_path に書き出してそのパスを返す.
trimesh.creation.icosphere で生成する小球体(半径 10mm 相当).
"""
mesh = trimesh.creation.icosphere(subdivisions=1, radius=10.0)
path = tmp_path / "sphere.stl"
mesh.export(str(path))
return path
@pytest.fixture
def minimal_scene_config(sphere_stl: Path) -> SceneConfig:
"""球体メッシュ 1 つ+拡散材料+点光源+センサの最小 SceneConfig."""
data = yaml.safe_load(f"""\
name: test_scene
geometry:
meshes:
- id: sphere
path: {sphere_stl.as_posix()}
unit: mm
material: white
materials:
white:
type: diffuse
reflectance: 0.9
lights:
- id: led_top
type: point
position: [0.0, 0.0, 50.0]
intensity: 1.0
profile: lambertian
sensor:
type: perspective
position: [0.0, 0.0, 200.0]
target: [0.0, 0.0, 0.0]
up: [0.0, 1.0, 0.0]
fov_deg: 60.0
film:
width: 64
height: 64
rfilter: gaussian
render:
variant: llvm_ad_rgb
integrator: path
max_depth: 4
spp: 4
""")
return SceneConfig.model_validate(data)
# ---------------------------------------------------------------------------
# 戻り値の型
# ---------------------------------------------------------------------------
def test_build_scene_returns_tuple(minimal_scene_config: SceneConfig, tmp_path: Path):
"""build_scene が (dict, Path) のタプルを返すこと.
仕様: build_scene → (scene_dict, tempdir) (builder.py docstring)
"""
result = build_scene(minimal_scene_config, project_root=tmp_path)
assert isinstance(result, tuple)
assert len(result) == 2
scene_dict, tempdir = result
shutil.rmtree(tempdir, ignore_errors=True)
assert isinstance(scene_dict, dict)
assert isinstance(tempdir, Path)
def test_build_scene_tempdir_exists_before_cleanup(
minimal_scene_config: SceneConfig, tmp_path: Path
):
"""build_scene が返す tempdir が存在するディレクトリであること.
仕様: tempdir は一時 PLY ファイルを格納したディレクトリ (builder.py docstring)
"""
_, tempdir = build_scene(minimal_scene_config, project_root=tmp_path)
try:
assert tempdir.exists()
assert tempdir.is_dir()
finally:
shutil.rmtree(tempdir, ignore_errors=True)
# ---------------------------------------------------------------------------
# scene_dict のトップレベル構造
# ---------------------------------------------------------------------------
def test_build_scene_dict_type_is_scene(minimal_scene_config: SceneConfig, tmp_path: Path):
"""scene_dict["type"] == "scene" であること.
仕様: Mitsuba load_dict 互換の dict (builder.py docstring)
"""
scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path)
shutil.rmtree(tempdir, ignore_errors=True)
assert scene_dict["type"] == "scene"
def test_build_scene_dict_has_integrator(minimal_scene_config: SceneConfig, tmp_path: Path):
"""scene_dict に integrator キーが存在すること.
仕様: scene_dict["integrator"] (SPEC_01 scene/builder 責務)
"""
scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path)
shutil.rmtree(tempdir, ignore_errors=True)
assert "integrator" in scene_dict
def test_build_scene_dict_integrator_type(minimal_scene_config: SceneConfig, tmp_path: Path):
"""integrator の type が RenderConfig.integrator と一致すること.
仕様: render.integrator: path | direct | volpath (SPEC_02 render セクション)
"""
scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path)
shutil.rmtree(tempdir, ignore_errors=True)
assert scene_dict["integrator"]["type"] == "path"
def test_build_scene_dict_has_camera(minimal_scene_config: SceneConfig, tmp_path: Path):
"""scene_dict に camera キーが存在すること.
仕様: sensor → scene_dict["camera"] (builder.py 実装)
"""
scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path)
shutil.rmtree(tempdir, ignore_errors=True)
assert "camera" in scene_dict
def test_build_scene_dict_camera_type_is_perspective(
minimal_scene_config: SceneConfig, tmp_path: Path
):
"""camera の type が "perspective" であること.
仕様: sensor.type は perspective のみ (SPEC_02 sensor セクション)
"""
scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path)
shutil.rmtree(tempdir, ignore_errors=True)
assert scene_dict["camera"]["type"] == "perspective"
# ---------------------------------------------------------------------------
# 材料キー
# ---------------------------------------------------------------------------
def test_build_scene_dict_has_material_key(minimal_scene_config: SceneConfig, tmp_path: Path):
"""scene_dict に mat_<name> キーが存在すること.
仕様: materials → scene_dict["mat_<name>"] (builder.py 実装)
"""
scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path)
shutil.rmtree(tempdir, ignore_errors=True)
assert "mat_white" in scene_dict
def test_build_scene_dict_material_id_matches(minimal_scene_config: SceneConfig, tmp_path: Path):
"""mat_<name> の id フィールドが "mat_<name>" と一致すること.
仕様: mat_dict["id"] = f"mat_{name}" (builder.py 実装)
"""
scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path)
shutil.rmtree(tempdir, ignore_errors=True)
assert scene_dict["mat_white"]["id"] == "mat_white"
# ---------------------------------------------------------------------------
# メッシュキー
# ---------------------------------------------------------------------------
def test_build_scene_dict_has_mesh_key(minimal_scene_config: SceneConfig, tmp_path: Path):
"""scene_dict に mesh_<id> キーが存在すること.
仕様: meshes → scene_dict["mesh_<id>"] (builder.py 実装)
"""
scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path)
shutil.rmtree(tempdir, ignore_errors=True)
assert "mesh_sphere" in scene_dict
def test_build_scene_dict_mesh_type_is_ply(minimal_scene_config: SceneConfig, tmp_path: Path):
"""mesh の type が "ply" であること.
仕様: STL → PLY 変換して Mitsuba に渡す (builder.py 実装)
"""
scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path)
shutil.rmtree(tempdir, ignore_errors=True)
assert scene_dict["mesh_sphere"]["type"] == "ply"
def test_build_scene_dict_mesh_uses_face_normals(
minimal_scene_config: SceneConfig, tmp_path: Path
):
"""mesh の face_normals が True であること.
仕様: PLY に法線を書き出さないため,指定なしでは Mitsuba がスムーズ頂点法線を
自動生成し,シャープエッジ構成の機械部品 STL で評価面の輝度マップに偽の異方性を
生む(TEST_12).面法線の強制はこの系統誤差の再発防止.
"""
scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path)
shutil.rmtree(tempdir, ignore_errors=True)
assert scene_dict["mesh_sphere"]["face_normals"] is True
def test_build_scene_dict_mesh_bsdf_is_ref(minimal_scene_config: SceneConfig, tmp_path: Path):
"""mesh の bsdf が {"type": "ref", "id": "mat_<name>"} であること.
仕様: mesh から material への参照は id 参照で行う (builder.py 実装)
"""
scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path)
shutil.rmtree(tempdir, ignore_errors=True)
bsdf = scene_dict["mesh_sphere"]["bsdf"]
assert bsdf["type"] == "ref"
assert bsdf["id"] == "mat_white"
def test_build_scene_ply_file_written_to_tempdir(minimal_scene_config: SceneConfig, tmp_path: Path):
"""tempdir 配下に PLY ファイルが実際に書き出されていること.
仕様: 一時ディレクトリに PLY として書き出す (builder.py 実装)
"""
scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path)
try:
ply_files = list(tempdir.glob("*.ply"))
assert len(ply_files) >= 1
assert (tempdir / "sphere.ply").exists()
finally:
shutil.rmtree(tempdir, ignore_errors=True)
def test_build_scene_ply_filename_matches_mesh_id(
minimal_scene_config: SceneConfig, tmp_path: Path
):
"""scene_dict の filename が実在する PLY ファイルを指していること.
仕様: PLY ファイルを格納した tempdir (builder.py 実装)
"""
scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path)
try:
ply_path = Path(scene_dict["mesh_sphere"]["filename"])
assert ply_path.exists()
finally:
shutil.rmtree(tempdir, ignore_errors=True)
# ---------------------------------------------------------------------------
# 光源キー
# ---------------------------------------------------------------------------
def test_build_scene_dict_has_light_key(minimal_scene_config: SceneConfig, tmp_path: Path):
"""scene_dict に light_<id> キーが存在すること.
仕様: lights → scene_dict["light_<id>"] (builder.py 実装)
"""
scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path)
shutil.rmtree(tempdir, ignore_errors=True)
assert "light_led_top" in scene_dict
def test_build_scene_dict_light_type_is_point(minimal_scene_config: SceneConfig, tmp_path: Path):
"""point 光源の type が "point" であること.
仕様: PointLight → {"type": "point", ...} (SPEC_02 lights セクション)
"""
scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path)
shutil.rmtree(tempdir, ignore_errors=True)
assert scene_dict["light_led_top"]["type"] == "point"
# ---------------------------------------------------------------------------
# 複数材料・複数光源のシーン
# ---------------------------------------------------------------------------
def test_build_scene_with_multiple_materials(tmp_path: Path, sphere_stl: Path):
"""複数材料を持つシーンで全材料キーが scene_dict に揃うこと.
仕様: materials dict の全エントリが変換される (SPEC_02 materials セクション)
"""
data = yaml.safe_load(f"""\
name: multi_mat
geometry:
meshes:
- id: sphere1
path: {sphere_stl.as_posix()}
unit: mm
material: white
- id: sphere2
path: {sphere_stl.as_posix()}
unit: mm
material: pmma
materials:
white:
type: diffuse
reflectance: 0.9
pmma:
type: thindielectric
int_ior: 1.49
ext_ior: 1.0
lights:
- id: led_1
type: point
position: [0.0, 0.0, 50.0]
intensity: 1.0
profile: lambertian
sensor:
type: perspective
position: [0.0, 0.0, 200.0]
target: [0.0, 0.0, 0.0]
up: [0.0, 1.0, 0.0]
fov_deg: 60.0
film:
width: 64
height: 64
render:
variant: llvm_ad_rgb
integrator: path
max_depth: 4
spp: 4
""")
config = SceneConfig.model_validate(data)
scene_dict, tempdir = build_scene(config, project_root=tmp_path)
shutil.rmtree(tempdir, ignore_errors=True)
assert "mat_white" in scene_dict
assert "mat_pmma" in scene_dict
assert "mesh_sphere1" in scene_dict
assert "mesh_sphere2" in scene_dict
def test_build_scene_missing_stl_raises_file_not_found(tmp_path: Path):
"""存在しない STL パスを指定したとき FileNotFoundError が発生すること.
仕様: FileNotFoundError: STL ファイルが見つからない場合 (builder.py docstring)
"""
data = yaml.safe_load("""\
name: missing_stl
geometry:
meshes:
- id: ghost
path: nonexistent/ghost.stl
unit: mm
material: white
materials:
white:
type: diffuse
reflectance: 0.9
lights:
- id: led_1
type: point
position: [0.0, 0.0, 50.0]
intensity: 1.0
profile: lambertian
sensor:
type: perspective
position: [0.0, 0.0, 200.0]
target: [0.0, 0.0, 0.0]
up: [0.0, 1.0, 0.0]
fov_deg: 60.0
film:
width: 64
height: 64
render:
variant: llvm_ad_rgb
integrator: path
max_depth: 4
spp: 4
""")
config = SceneConfig.model_validate(data)
with pytest.raises(FileNotFoundError):
build_scene(config, project_root=tmp_path)
def test_build_scene_integrator_max_depth_matches_config(
minimal_scene_config: SceneConfig, tmp_path: Path
):
"""integrator の max_depth が RenderConfig.max_depth と一致すること.
仕様: render.max_depth (SPEC_02 render セクション)
"""
scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path)
shutil.rmtree(tempdir, ignore_errors=True)
assert scene_dict["integrator"]["max_depth"] == minimal_scene_config.render.max_depth