# pyright: reportCallIssue=none
# Mitsuba 3 は variant ベースの動的属性解決を行うため Pylance/Pyright が
# mi.* を object 型と推論し reportCallIssue を誤検知する(coding-style.md の方針).
"""SceneConfig 全体を Mitsuba load_dict 互換 dict に変換する統合モジュール."""
from __future__ import annotations
import shutil
import tempfile
from pathlib import Path
import trimesh
from sm_tias_lightsim.geometry.loader import LoadedMesh, load_stl
from sm_tias_lightsim.geometry.transforms import scale_vertices
from sm_tias_lightsim.scene.lights import build_light_dict
from sm_tias_lightsim.scene.materials import build_material_dict
from sm_tias_lightsim.scene.schema import SceneConfig
from sm_tias_lightsim.scene.sensors import build_sensor_dict
# mm→m の変換係数
_MM_TO_M = 0.001
def _position_scale(unit: str) -> float:
"""単位文字列に対応する座標スケール係数を返す.
YAML 内の座標値はすべて同一単位で記述されているため,
scene 全体での単位を sensor/light の位置変換に統一して使う.
現状は mm と m のみサポート.
Args:
unit: "mm" または "m".
Returns:
座標をメートルに変換するスケール係数.
"""
if unit == "mm":
return _MM_TO_M
if unit == "m":
return 1.0
msg = f"Unsupported position unit: {unit!r}"
raise ValueError(msg)
def _save_loaded_mesh_to_ply(mesh: LoadedMesh, path: Path) -> None:
"""LoadedMesh を PLY ファイルとして書き出す.
trimesh 経由で書き出すことで Mitsuba の PLY パーサに渡せる形式にする.
Args:
mesh: 書き出す LoadedMesh(頂点・面が既に目的単位に変換済みであること).
path: 書き出し先の PLY ファイルパス.
"""
tmesh = trimesh.Trimesh(vertices=mesh.vertices, faces=mesh.faces, process=False)
tmesh.export(str(path), file_type="ply")
def build_scene(
config: SceneConfig,
project_root: Path | None = None,
*,
exclude_emitters: bool = False,
) -> tuple[dict, Path]:
"""SceneConfig 全体を Mitsuba load_dict 互換の dict に変換する.
STL メッシュは trimesh で読み込み,一時ディレクトリに PLY として書き出して
Mitsuba の "ply" shape type に渡す.
呼び出し側(runner.py)は返された tempdir を try/finally で shutil.rmtree すること.
Mitsuba の load_dict が PLY ファイルを読むまでファイルを保持する必要があるため,
builder 内では tempdir を削除しない.
Args:
config: 検証済み SceneConfig.
project_root: STL 相対パスの基点ディレクトリ.None なら CWD を使用.
exclude_emitters: True にすると AreaLight の rectangle 形状をシーンに登録しない.
shadow ray 評価専用シーンを構築する際に使用する.PointLight / DirectionalLight は
emitter dict を持たないため常に除外されない.
Returns:
(scene_dict, tempdir) のタプル.
- scene_dict: mi.load_dict() に渡せる dict.
- tempdir: 一時 PLY ファイルを格納したディレクトリ(呼び出し側が削除する).
Raises:
FileNotFoundError: STL ファイルが見つからない場合.
ValueError: 未対応の設定値が含まれる場合.
"""
from sm_tias_lightsim.scene.schema import AreaLight
root = project_root if project_root is not None else Path.cwd()
# YAML 内の座標値の単位は sensor.position の単位と一致しているため,
# sensor の位置座標を基準に scale を決める
# SPEC_02: lights の position も mm 単位で記述する前提
# ここでは固定で mm→m スケールを使う(YAML 上の座標は常に mm 基準)
pos_scale = _MM_TO_M
tempdir = Path(tempfile.mkdtemp(prefix="sm_tias_scene_"))
try:
scene_dict: dict = {"type": "scene"}
# integrator
scene_dict["integrator"] = {
"type": config.render.integrator,
"max_depth": config.render.max_depth,
}
# sensor
scene_dict["camera"] = build_sensor_dict(config.sensor, position_scale=pos_scale)
# materials(トップレベルに配置し mesh から id 参照する)
for name, mat in config.materials.items():
mat_dict = build_material_dict(mat)
mat_dict["id"] = f"mat_{name}"
scene_dict[f"mat_{name}"] = mat_dict
# meshes
for mesh_cfg in config.geometry.meshes:
raw_path = Path(mesh_cfg.path)
stl_path = raw_path if raw_path.is_absolute() else root / mesh_cfg.path
loaded = load_stl(stl_path, mesh_cfg.unit)
scaled = scale_vertices(loaded, target_unit="m")
ply_path = tempdir / f"{mesh_cfg.id}.ply"
_save_loaded_mesh_to_ply(scaled, ply_path)
# face_normals=True でシェーディングに面法線を使う(TEST_12 バグ修正).
# PLY に法線を書き出していないため,指定なしでは Mitsuba がスムーズ頂点法線を
# 自動生成する.機械部品 STL は平面+シャープエッジ構成で,エッジ頂点の平均化
# 法線(約 45° 傾く)が巨大三角形の全面に補間され,評価板の輝度マップに
# 大規模な偽の異方性(CoV を倍増させる系統誤差)を生んでいた.
scene_dict[f"mesh_{mesh_cfg.id}"] = {
"type": "ply",
"filename": str(ply_path),
"face_normals": True,
"bsdf": {"type": "ref", "id": f"mat_{mesh_cfg.material}"},
}
# lights
# exclude_emitters=True のとき AreaLight(rectangle 形状)はシーンに登録しない.
# shadow ray 用シーンで AreaLight 自身が遮蔽物になるのを防ぐためのオプション.
for light_cfg in config.lights:
if exclude_emitters and isinstance(light_cfg, AreaLight):
continue
scene_dict[f"light_{light_cfg.id}"] = build_light_dict(
light_cfg, position_scale=pos_scale
)
return scene_dict, tempdir
except Exception:
shutil.rmtree(tempdir, ignore_errors=True)
raise