"""PyVista 3D インタラクティブビューアモジュール.
シーンのメッシュ・LED・センサを PyVista で可視化する.
照度ベイク結果 (irradiance) があればメッシュを照度で着色する.
## 投影モード(--render 引数)
luminance_map(2D 輝度マップ, np.ndarray)が指定された場合、
評価板メッシュ(id="evaluation_board")の各頂点を orthographic センサ規約で
2D 輝度マップへ射影し、per-vertex scalar で着色する.
投影規約の詳細は visualize/projection.py の docstring を参照.
投影モードでは:
- 評価板が主役:viridis カラーマップ + scalar bar 1 本(単位: Luminance)
- 他メッシュは半透明ニュートラル(グレー, opacity=0.2)
- 配色は percentile クリップで高ダイナミックレンジ潰れを防止
## 直接ベイクモード(既存動作)
irradiance dict が指定された場合は各メッシュの per-face 照度で着色する(後方互換).
irradiance の scalar bar は 1 本に統合(重複 scalar bar のバグを解消).
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import TYPE_CHECKING
import numpy as np
from sm_tias_lightsim.geometry.loader import load_stl
from sm_tias_lightsim.geometry.transforms import scale_vertices
from sm_tias_lightsim.scene.schema import (
AreaLight,
DirectionalLight,
PointLight,
SceneConfig,
)
if TYPE_CHECKING:
import pyvista as pv
_logger = logging.getLogger(__name__)
# YAML 座標 (mm) → m 変換係数
_MM_TO_M = 0.001
# 方向ベクトルの長さがこれ以下なら縮退とみなしてスキップする
_DIR_NORM_EPSILON = 1e-9
# 光源・センサを示す球の半径(m 単位)
_MARKER_SPHERE_RADIUS = 0.005
# AreaLight の放射方向矢印のスケール(m 単位)
_AREA_LIGHT_ARROW_SCALE = 0.02
# DirectionalLight の矢印をシーン対角線長に対して拡大する比率
_DIRECTIONAL_ARROW_SCALE_RATIO = 0.3
# センサの視線矢印をシーン対角線長に対してクランプする比率
_SENSOR_ARROW_SCALE_RATIO = 0.2
# 投影モードで評価板以外のメッシュの不透明度
_BACKGROUND_MESH_OPACITY = 0.2
# 直接ベイクモードで着色メッシュ以外の不透明度
_UNBAKED_MESH_OPACITY = 0.5
def _build_pyvista_polydata(vertices: np.ndarray, faces: np.ndarray) -> pv.PolyData:
"""頂点・面配列から PyVista PolyData を構築する.
PyVista の faces 形式は各行が [n_vertices, v0, v1, v2, ...] の 1D フラット配列.
Args:
vertices: 頂点配列 (N_v, 3),m 単位.
faces: 三角形面配列 (N_f, 3),頂点インデックス.
Returns:
PyVista PolyData オブジェクト.
"""
import pyvista as pv
n_faces = len(faces)
# PyVista 形式: [3, v0, v1, v2, 3, v0, v1, v2, ...]
pv_faces = np.hstack([np.full((n_faces, 1), 3, dtype=np.int64), faces]).flatten()
return pv.PolyData(vertices.astype(np.float32), pv_faces)
def _get_scene_bbox_center_and_diag(
scene_config: SceneConfig, project_root: Path
) -> tuple[np.ndarray, float]:
"""シーン全メッシュのバウンディングボックス中心と対角線長を返す.
DirectionalLight の矢印位置を決めるために使う.
メッシュが空なら原点と 1.0 を返す.
Args:
scene_config: SceneConfig.
project_root: STL 相対パスの基点ディレクトリ.
Returns:
(center (3,), diagonal_length) のタプル.
"""
all_vertices: list[np.ndarray] = []
for mesh_cfg in scene_config.geometry.meshes:
try:
raw_path = Path(mesh_cfg.path)
stl_path = raw_path if raw_path.is_absolute() else project_root / mesh_cfg.path
loaded = load_stl(stl_path, mesh_cfg.unit)
scaled = scale_vertices(loaded, target_unit="m")
all_vertices.append(scaled.vertices)
except Exception as exc:
_logger.warning("バウンディングボックス計算でメッシュを読み込めませんでした: %s", exc)
if not all_vertices:
return np.zeros(3), 1.0
combined = np.vstack(all_vertices)
mins = combined.min(axis=0)
maxs = combined.max(axis=0)
center = (mins + maxs) / 2.0
diagonal = float(np.linalg.norm(maxs - mins))
return center, max(diagonal, 1e-6)
def _apply_percentile_clip(
values: np.ndarray, clip_low: float, clip_high: float
) -> np.ndarray:
"""percentile クリップを適用して高 DR 潰れを防ぐ.
Args:
values: スカラ配列.
clip_low: 下限 percentile(%).
clip_high: 上限 percentile(%).
Returns:
クリップ済みの配列(元と同 dtype).
"""
p_low = float(np.percentile(values, clip_low))
p_high = float(np.percentile(values, clip_high))
return np.clip(values, p_low, p_high)
def _prepare_projection_scalars(
scene_config: SceneConfig,
project_root: Path,
luminance_map: np.ndarray,
clip_low: float,
clip_high: float,
) -> tuple[str | None, np.ndarray | None]:
"""投影モード用に評価板 id とper-vertex 輝度スカラを準備する.
評価板メッシュを特定して STL をロードし、orthographic 投影で
per-vertex 輝度スカラ配列を計算して返す.
Args:
scene_config: 検証済み SceneConfig.
project_root: STL 相対パスの基点ディレクトリ.
luminance_map: sm-render 出力の 2D 輝度マップ (H, W) または (H, W, C).
clip_low: percentile クリップの下限(%).
clip_high: percentile クリップの上限(%).
Returns:
(eval_board_id, projected_scalars) のタプル.
失敗時は (None, None) を返す(例外は投げない).
"""
from sm_tias_lightsim.visualize.projection import (
find_eval_board_mesh_id,
project_luminance_to_vertices,
)
eval_board_id = find_eval_board_mesh_id(scene_config)
_logger.info("投影モード: 評価板メッシュ id = '%s'", eval_board_id)
eval_mesh_cfg = next(m for m in scene_config.geometry.meshes if m.id == eval_board_id)
try:
raw_path = Path(eval_mesh_cfg.path)
stl_path = raw_path if raw_path.is_absolute() else project_root / eval_mesh_cfg.path
eval_loaded = load_stl(stl_path, eval_mesh_cfg.unit)
eval_scaled = scale_vertices(eval_loaded, target_unit="m")
projected_scalars = project_luminance_to_vertices(
eval_scaled.vertices,
luminance_map,
scene_config.sensor,
clip_low=clip_low,
clip_high=clip_high,
)
_logger.info(
"投影スカラ: min=%.4f, max=%.4f, std=%.4f",
float(projected_scalars.min()),
float(projected_scalars.max()),
float(projected_scalars.std()),
)
except Exception as exc:
_logger.warning("投影計算に失敗しました: %s.シーンレイアウトのみ表示します.", exc)
return None, None
return eval_board_id, projected_scalars
def show_scene(
scene_config: SceneConfig,
project_root: Path,
irradiance: dict[str, np.ndarray] | None = None,
luminance_map: np.ndarray | None = None,
*,
off_screen: bool = False,
screenshot: Path | None = None,
clip_low: float = 1.0,
clip_high: float = 99.0,
) -> None:
"""PyVista でシーンとベイク結果を表示する.
メッシュ・LED・センサ・座標軸を含む 3D インタラクティブビューアを開く.
表示モードの優先順位:
1. luminance_map が指定された場合(投影モード):
評価板メッシュに 2D 輝度マップを orthographic 投影して per-vertex 着色.
他メッシュは半透明ニュートラル(opacity=0.2).
scalar bar は評価板の 1 本のみ(単位: Luminance).
2. irradiance が指定された場合(直接ベイクモード):
各メッシュを per-face 照度で着色(viridis).
scalar bar は 1 本に統合(旧実装の重複バグを解消).
3. 両方 None の場合: シーンレイアウトのみ(--no-bake 相当).
off_screen=True のときは GUI ウィンドウを開かず,screenshot が指定されていれば保存する.
screenshot=None かつ off_screen=True のときは何も出力しない(no-op).
Args:
scene_config: 検証済み SceneConfig.
project_root: STL 相対パスの基点ディレクトリ.
irradiance: {mesh_id: irradiance_per_face (N_f,)} の辞書.
None かつ luminance_map も None なら着色なし(後方互換).
luminance_map: sm-render 出力の 2D 輝度マップ (H, W) または (H, W, C).
指定時は投影モードで評価板に投影する(irradiance より優先).
off_screen: True のとき GUI ウィンドウを開かない(screenshot 専用).
screenshot: 保存先 PNG パス.指定時は off_screen で screenshot を保存する.
clip_low: percentile クリップの下限(%).既定 1.
clip_high: percentile クリップの上限(%).既定 99.
"""
import pyvista as pv
# screenshot 指定があれば off_screen を強制する
if screenshot is not None:
off_screen = True
plotter = pv.Plotter(off_screen=off_screen)
# 投影モードか否かを決定
use_projection = luminance_map is not None
# 投影モードの場合:評価板メッシュ id と per-vertex スカラを準備する
projected_scalars: np.ndarray | None = None
eval_board_id: str | None = None
if use_projection:
eval_board_id, projected_scalars = _prepare_projection_scalars(
scene_config, project_root, luminance_map, clip_low, clip_high # type: ignore[arg-type]
)
if eval_board_id is None:
# 投影計算失敗 → レイアウト表示のみにフォールバック
use_projection = False
# ---- メッシュ追加 ----
scalar_bar_added = False # scalar bar を 1 本だけ追加するフラグ
for mesh_cfg in scene_config.geometry.meshes:
try:
raw_path = Path(mesh_cfg.path)
stl_path = raw_path if raw_path.is_absolute() else project_root / mesh_cfg.path
loaded = load_stl(stl_path, mesh_cfg.unit)
scaled = scale_vertices(loaded, target_unit="m")
except Exception as exc:
_logger.warning("メッシュ '%s' を読み込めませんでした: %s", mesh_cfg.id, exc)
continue
polydata = _build_pyvista_polydata(scaled.vertices, scaled.faces)
if use_projection and mesh_cfg.id == eval_board_id and projected_scalars is not None:
# 投影モード: 評価板を per-vertex 輝度で着色(viridis, 知覚均等)
# point_data に格納して per-vertex スカラとして扱う
polydata.point_data["luminance"] = projected_scalars
show_bar = not scalar_bar_added
plotter.add_mesh(
polydata,
scalars="luminance",
cmap="viridis",
show_scalar_bar=show_bar,
scalar_bar_args={"title": "Luminance (a.u.)"},
label=mesh_cfg.id,
)
if show_bar:
scalar_bar_added = True
elif use_projection:
# 投影モード: 評価板以外は半透明ニュートラルグレー(背景扱い)
plotter.add_mesh(
polydata,
color="lightgray",
opacity=_BACKGROUND_MESH_OPACITY,
label=mesh_cfg.id,
)
elif irradiance is not None and mesh_cfg.id in irradiance:
# 直接ベイクモード: per-face 照度で着色
irr = irradiance[mesh_cfg.id]
# percentile クリップを適用
irr_clipped = _apply_percentile_clip(irr.astype(np.float32), clip_low, clip_high)
polydata.cell_data["irradiance"] = irr_clipped
show_bar = not scalar_bar_added
plotter.add_mesh(
polydata,
scalars="irradiance",
cmap="viridis",
show_scalar_bar=show_bar,
scalar_bar_args={"title": "Irradiance (W/m²)"},
label=mesh_cfg.id,
)
if show_bar:
scalar_bar_added = True
else:
# 着色なし: グレー半透明
plotter.add_mesh(
polydata,
color="lightgray",
opacity=_UNBAKED_MESH_OPACITY,
label=mesh_cfg.id,
)
# ---- シーン全体のバウンディングボックス(DirectionalLight 用)----
bbox_center, bbox_diag = _get_scene_bbox_center_and_diag(scene_config, project_root)
# ---- LED / 光源表示 ----
for light in scene_config.lights:
if isinstance(light, PointLight):
pos_m = [c * _MM_TO_M for c in light.position]
sphere = pv.Sphere(radius=_MARKER_SPHERE_RADIUS, center=pos_m)
plotter.add_mesh(sphere, color="yellow", label=f"LED:{light.id}")
elif isinstance(light, AreaLight):
center_m = [c * _MM_TO_M for c in light.center]
size_m = [light.size[0] * _MM_TO_M, light.size[1] * _MM_TO_M]
dir_vec = np.array(light.direction, dtype=np.float64)
dir_norm = np.linalg.norm(dir_vec)
if dir_norm > _DIR_NORM_EPSILON:
dir_vec = dir_vec / dir_norm
plane = pv.Plane(
center=center_m,
direction=dir_vec.tolist(),
i_size=size_m[0],
j_size=size_m[1],
)
plotter.add_mesh(plane, color="orange", opacity=0.7, label=f"AreaLight:{light.id}")
arrow = pv.Arrow(
start=center_m,
direction=dir_vec.tolist(),
scale=_AREA_LIGHT_ARROW_SCALE,
)
plotter.add_mesh(arrow, color="orange", label=f"AreaLight:{light.id}:dir")
elif isinstance(light, DirectionalLight):
# バウンディングボックス中心から矢印 1 本で進行方向を示す
dir_vec = np.array(light.direction, dtype=np.float64)
dir_norm = np.linalg.norm(dir_vec)
if dir_norm < _DIR_NORM_EPSILON:
continue
dir_vec = dir_vec / dir_norm
arrow_scale = bbox_diag * _DIRECTIONAL_ARROW_SCALE_RATIO
arrow = pv.Arrow(
start=bbox_center.tolist(),
direction=dir_vec.tolist(),
scale=arrow_scale,
)
plotter.add_mesh(arrow, color="lightyellow", label=f"DirLight:{light.id}")
# ---- センサ表示 ----
sensor_pos_m = [c * _MM_TO_M for c in scene_config.sensor.position]
sensor_target_m = [c * _MM_TO_M for c in scene_config.sensor.target]
sensor_sphere = pv.Sphere(radius=_MARKER_SPHERE_RADIUS, center=sensor_pos_m)
plotter.add_mesh(sensor_sphere, color="blue", label="sensor")
sensor_pos_arr = np.array(sensor_pos_m)
sensor_target_arr = np.array(sensor_target_m)
view_dir = sensor_target_arr - sensor_pos_arr
view_len = np.linalg.norm(view_dir)
if view_len > _DIR_NORM_EPSILON:
view_dir_n = view_dir / view_len
sensor_arrow = pv.Arrow(
start=sensor_pos_m,
direction=view_dir_n.tolist(),
scale=min(float(view_len), bbox_diag * _SENSOR_ARROW_SCALE_RATIO),
)
plotter.add_mesh(sensor_arrow, color="blue", label="sensor:view")
# ---- 座標軸と境界表示 ----
plotter.add_axes()
plotter.show_bounds(grid="back", location="outer")
# ---- 投影モード: 評価板正面が見えるカメラ視点を設定 ----
if use_projection:
# センサ位置から評価板を見る視点に設定
# センサは -Z 側から +Z 方向を向いているので、
# ビューワは少し引いて斜め上から俯瞰する位置に設定する
sensor_pos = np.array(sensor_pos_m)
sensor_tgt = np.array(sensor_target_m)
# センサ位置からさらに -Z 方向(センサの裏側)に引いた位置
cam_offset = sensor_pos - sensor_tgt
cam_dist = np.linalg.norm(cam_offset)
if cam_dist < 1e-9:
cam_dist = 0.1
# カメラを評価板正面かつ斜め上から
cam_pos = sensor_pos + cam_offset / cam_dist * 0.25
cam_pos[1] += 0.05 # 少し上から
plotter.camera.position = cam_pos.tolist()
plotter.camera.focal_point = sensor_tgt.tolist()
plotter.camera.up = (0.0, 1.0, 0.0)
plotter.reset_camera()
# ---- 表示 / 保存 ----
if screenshot is not None:
plotter.show(screenshot=str(screenshot))
elif not off_screen:
plotter.show()
# off_screen=True かつ screenshot=None の場合は no-op