Newer
Older
SmTIAS-LightSim / tests / scene / test_builder.py
"""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_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