"""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 本(単位: Irradiance (W/m²))
- 輝度マップは E_board = π * luminance_map / ρ で照度 (W/m²) に換算する
(ρ = 評価板材料の拡散反射率、Lambertian 拡散面前提)
- 他メッシュは半透明ニュートラル(グレー, opacity=0.2)
- 配色は percentile クリップで高ダイナミックレンジ潰れを防止
## ハイブリッドモード(luminance_map + irradiance 同時指定時)
luminance_map と irradiance dict が**両方**指定された場合:
- 評価板メッシュ(id 特定は projection.find_eval_board_mesh_id):
投影輝度を E_board = π * L / ρ で照度 (W/m²) に換算し、
壁ベイク irradiance と**共有スケール・共有カラーバー 1 本**で着色する.
- 評価板以外の全メッシュ(壁・筐体等):
irradiance dict の per-face 照度で着色(viridis・半透明 bake_opacity).
評価板 E_board を含めた全データで算出した共有 clim を適用する.
- depth peeling 有効化(半透明メッシュ混在のため).
## ベイクモード(irradiance dict のみ指定時)
irradiance dict が指定された場合は各メッシュの per-face 照度で着色する.
**全メッシュ共有カラーマップ(共有 clim)**: 全メッシュ全 face の値を結合して
percentile クリップ(p1–p99)で [vmin, vmax] を算出し、全 add_mesh に渡す.
これにより全メッシュが同一スケールで着色される(path ベイクで壁面と評価板を比較可能).
スカラバーは 1 本のみ(単位: Irradiance (W/m²)).
mode="direct"(shadow ray)でも mode="path"(多重反射込み)でも共通して使用する.
## log スケール着色(bake_scale="log"、既定)
壁面ベイクの irradiance は高ダイナミックレンジ(LED 至近: ~4400 W/m²、壁一般: 数十〜数百)を
持つため、linear + percentile クリップでは少数の超高輝度 face が clim を支配し
大多数の壁が viridis 最下色(濃紫)に圧縮される.
bake_scale="log" 時の処理順序(ベイク着色経路のみ):
1. hide_below フィルタ: irradiance <= hide_below の cell を除去(linear 値基準).
2. log 変換: log10(max(irradiance, eps)) を全残 face に適用(eps=1e-3).
3. log 空間で共有 clim 算出(percentile クリップ)
→ scalar bar タイトル「Irradiance (W/m², log10)」.
4. bake_smooth 平滑化: log 空間スカラに対して Laplacian 拡散(N 回 往復).
5. cell → point 補間(Gouraud シェーディング用).
6. add_mesh.
bake_scale="linear" 時は従来どおり(後方互換).
## 評価板照度換算(E_board)
評価板の輝度マップ L(W/(m²·sr))は Lambertian 拡散面の関係式により
入射照度 E(W/m²)に換算できる:
E = π * L / ρ
ρ: 評価板材料の拡散反射率(scene_config の materials から取得、既定 0.85)
Lambertian 拡散面では L = ρ * E / π が成立(SPEC_03「筐体(材料)」参照).
この換算により評価板テクスチャと壁ベイク照度が同一物理量(W/m²)になり、
共有スケール・単一カラーバーで直接比較できる.
"""
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
# 評価板反射率のフォールバック値(SPEC_03 基準設計値 printer_white)
_FALLBACK_RHO = 0.85
# log10 変換の下限:irradiance が 0 や負の face を log10 で -3 に揃える
_LOG10_EPS = 1e-3
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 _get_eval_board_reflectance(
scene_config: SceneConfig,
eval_board_id: str,
) -> float:
"""評価板メッシュの拡散反射率 ρ をスカラ値で返す.
scene_config の materials から評価板の material を特定し、
DiffuseMaterial の reflectance を Rec.709 輝度でスカラ化して返す.
取得できない場合は 0.85 をフォールバック値とする(SPEC_03 基準設計値).
Args:
scene_config: 検証済み SceneConfig.
eval_board_id: 評価板メッシュの id 文字列.
Returns:
拡散反射率 ρ(スカラ, 0 < ρ ≤ 1).
"""
from sm_tias_lightsim.scene.schema import DiffuseMaterial
try:
eval_mesh_cfg = next(m for m in scene_config.geometry.meshes if m.id == eval_board_id)
material = scene_config.materials.get(eval_mesh_cfg.material)
if material is None or not isinstance(material, DiffuseMaterial):
_logger.warning(
"評価板材料が DiffuseMaterial でないため ρ=%.2f にフォールバックします"
"(material=%s)",
_FALLBACK_RHO,
eval_mesh_cfg.material,
)
return _FALLBACK_RHO
ref = material.reflectance
if isinstance(ref, (int, float)):
rho = float(ref)
else:
# RGB タプル → Rec.709 輝度でスカラ化
# Rec.709: Y = 0.2126 R + 0.7152 G + 0.0722 B
r, g, b = float(ref[0]), float(ref[1]), float(ref[2])
rho = 0.2126 * r + 0.7152 * g + 0.0722 * b
if rho <= 0.0 or rho > 1.0:
_logger.warning(
"評価板の reflectance=%.4f が有効範囲外のため ρ=%.2f にフォールバックします",
rho,
_FALLBACK_RHO,
)
return _FALLBACK_RHO
_logger.info(
"評価板 reflectance 取得: material='%s' raw_ref=%s ρ=%.4f",
eval_mesh_cfg.material,
ref,
rho,
)
return rho
except StopIteration:
_logger.warning(
"評価板メッシュ '%s' が見つかりません.ρ=%.2f にフォールバックします",
eval_board_id,
_FALLBACK_RHO,
)
return _FALLBACK_RHO
except Exception as exc:
_logger.warning(
"評価板反射率の取得中にエラー: %s.ρ=%.2f にフォールバックします",
exc,
_FALLBACK_RHO,
)
return _FALLBACK_RHO
def _make_irradiance_texture(
irradiance_map: np.ndarray,
vmin: float,
vmax: float,
bake_scale: str,
log10_eps: float,
) -> pv.Texture:
"""照度マップ(W/m²)を共有スケールで viridis 着色した PyVista Texture を生成する.
評価板の E_board(照度マップ, W/m²)を壁ベイクと同一の [vmin, vmax] および
log/linear 変換規約で RGB uint8 画像化し pv.Texture として返す.
共有 clim(壁ベイクと評価板の結合 percentile)は呼び出し側で算出済みの
vmin/vmax をそのまま受け取る(この関数は再算出しない).
処理手順:
1. bake_scale=="log" のとき log10(max(irradiance_map, log10_eps)) を適用する.
vmin/vmax も log 空間の値(呼び出し側が変換済み)であること.
2. [vmin, vmax] で [0, 1] 正規化する.
3. matplotlib viridis で RGBA → RGB uint8 (H, W, 3) 画像化する.
4. pv.Texture を生成して返す.
テクスチャ画像の行列方向は irradiance_map の (row, col) と同一(PyVista が VTK の
左下原点 UV 系でサンプリングするため、UV 側で v 反転を行う).
Args:
irradiance_map: 2D 照度マップ (H, W)(W/m²).
vmin: 正規化の下限(log 空間のとき log10 済み値).
vmax: 正規化の上限(log 空間のとき log10 済み値).
bake_scale: "log" または "linear".壁ベイクと同一の変換規約を適用する.
log10_eps: log 変換時のゼロ除算防止下限値(bake_scale=="log" 時のみ使用).
Returns:
pv.Texture(viridis 着色・RGB uint8).
"""
import matplotlib
import pyvista as pv
irr2d = irradiance_map.astype(np.float64)
if bake_scale == "log":
# 壁ベイクと同一の log10 変換
values = np.log10(np.maximum(irr2d, log10_eps))
else:
values = irr2d
# [vmin, vmax] → [0, 1] 正規化(vmin==vmax の場合はゼロ除算防止)
if vmax <= vmin:
normalized = np.zeros_like(values)
else:
normalized = np.clip((values - vmin) / (vmax - vmin), 0.0, 1.0)
# viridis で RGBA (H, W, 4) → RGB uint8 (H, W, 3)
# matplotlib 3.7+ では colormaps[] を使う(get_cmap は非推奨)
viridis = matplotlib.colormaps["viridis"]
rgba = viridis(normalized) # (H, W, 4) float64
rgb = (rgba[:, :, :3] * 255).astype(np.uint8)
texture = pv.Texture(rgb)
_logger.info(
"E_board テクスチャ生成: shape=%s bake_scale=%s "
"E_board 値範囲=[%.4e, %.4e] 正規化 clim=[%.4f, %.4f]",
rgb.shape,
bake_scale,
float(irr2d.min()),
float(irr2d.max()),
vmin,
vmax,
)
return texture
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, np.ndarray | None, np.ndarray | None]:
"""投影モード用に評価板 id・per-vertex 輝度スカラ・UV 座標・カメラ向き face マスクを準備する.
評価板メッシュを特定して STL をロードし、orthographic 投影で
per-vertex 輝度スカラ配列と UV テクスチャ座標を計算して返す.
また、各 face がカメラ(センサ)に面しているかを判定する bool マスクも返す.
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, vertex_uv, camera_face_mask) のタプル.
projected_scalars は per-vertex 輝度スカラ (N_v,)(フォールバック用).
vertex_uv は per-vertex UV テクスチャ座標 (N_v, 2) float32.
camera_face_mask は per-face bool マスク (N_f,)(True = カメラ向き内側面).
失敗時は (None, None, None, None) を返す(例外は投げない).
"""
from sm_tias_lightsim.visualize.projection import (
compute_face_camera_mask,
compute_orthographic_uv,
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")
# 輝度マップの形状を取得(UV 算出に使用)
lum_shape = (luminance_map.shape[0], luminance_map.shape[1])
projected_scalars = project_luminance_to_vertices(
eval_scaled.vertices,
luminance_map,
scene_config.sensor,
clip_low=clip_low,
clip_high=clip_high,
)
vertex_uv = compute_orthographic_uv(
eval_scaled.vertices,
lum_shape,
scene_config.sensor,
)
camera_face_mask = compute_face_camera_mask(
eval_scaled.vertices,
eval_scaled.faces,
scene_config.sensor,
)
_logger.info(
"投影スカラ: min=%.4f, max=%.4f, std=%.4f",
float(projected_scalars.min()),
float(projected_scalars.max()),
float(projected_scalars.std()),
)
_logger.info(
"UV 座標: u=[%.4f, %.4f] v=[%.4f, %.4f]",
float(vertex_uv[:, 0].min()),
float(vertex_uv[:, 0].max()),
float(vertex_uv[:, 1].min()),
float(vertex_uv[:, 1].max()),
)
n_camera = int(camera_face_mask.sum())
n_total = len(camera_face_mask)
_logger.info(
"評価板 face 判定: カメラ向き(内側)=%d / 全 face=%d(外側 face=%d は除外)",
n_camera,
n_total,
n_total - n_camera,
)
print(
f" 評価板 face: カメラ向き(内側・投影着色)={n_camera} / "
f"全 face={n_total}(外側={n_total - n_camera} はニュートラル)"
)
print(
f" UV: u=[{vertex_uv[:, 0].min():.4f}, {vertex_uv[:, 0].max():.4f}]"
f" v=[{vertex_uv[:, 1].min():.4f}, {vertex_uv[:, 1].max():.4f}]"
)
except Exception as exc:
_logger.warning("投影計算に失敗しました: %s.シーンレイアウトのみ表示します.", exc)
return None, None, None, None
return eval_board_id, projected_scalars, vertex_uv, camera_face_mask
def _smooth_scalar_on_polydata(
polydata: pv.PolyData,
scalar_name: str,
n_iter: int,
) -> pv.PolyData:
"""メッシュ上のスカラ値を Laplacian 平滑化する(頂点座標は変えない).
cell_data → point_data 変換(隣接セル平均)を n_iter 回繰り返すことで
スカラの Laplacian 平滑化に相当する効果を得る.
毎回 point_data → cell_data → point_data と往復することで拡散が進む.
Args:
polydata: 入力 PolyData.cell_data[scalar_name] が存在すること.
scalar_name: 平滑化対象のスカラ名.cell_data に格納されている前提.
n_iter: 平滑化イテレーション数.0 の場合は point_data 化のみ行う(生).
Returns:
point_data[scalar_name] を持つ新しい PolyData(座標は入力と同一).
cell_data のスカラは削除される(point_data のみ残す).
"""
# 入力は cell_data にスカラが格納されていると仮定する
# まず cell_data → point_data に変換する(隣接セル平均)
pd = polydata.cell_data_to_point_data()
for _ in range(n_iter):
# point_data → cell_data(隣接頂点平均)→ point_data(隣接セル平均)
# この往復でスカラが隣接方向へ 1 ステップ拡散する
pd = pd.point_data_to_cell_data()
pd = pd.cell_data_to_point_data()
return pd
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,
bake_opacity: float = 0.5,
hide_below: float = 0.0,
bake_smooth: int = 4,
bake_scale: str = "log",
cull_outward: bool = True,
hide_sensor_marker: bool = False,
hide_led_markers: bool = False,
) -> None:
"""PyVista でシーンとベイク結果を表示する.
メッシュ・LED・センサ・座標軸を含む 3D インタラクティブビューアを開く.
表示モードの優先順位:
1. irradiance のみ指定(luminance_map=None)の場合(ベイクモード・既定):
**全メッシュ(評価板含む)**を per-face 照度で統一着色(viridis).
全メッシュに bake_opacity を一律適用して内部の光分布が透けて見える.
全メッシュ共有 clim(percentile クリップ)・単一 scalar bar.
2. luminance_map と irradiance が**両方**指定された場合(ハイブリッドモード):
評価板メッシュ: 実レンダ輝度を orthographic 投影・不透明
評価板以外の全メッシュ: irradiance per-face 照度で着色(viridis・bake_opacity 半透明)
壁ベイクと評価板 E_board の共有 clim(percentile クリップ)・単一 scalar bar
depth peeling 有効化(半透明メッシュ混在のため).
3. luminance_map のみ指定(irradiance=None)の場合(投影モード):
評価板メッシュに 2D 輝度マップを orthographic 投影して per-vertex 着色.
他メッシュは半透明ニュートラル(opacity=0.2).
scalar bar は評価板の 1 本のみ(単位: Luminance).
bake_opacity は影響しない(投影モード専用の透明度制御がある).
4. 両方 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.
bake_opacity: ベイクモードでのメッシュ不透明度(0=透明〜1=不透明).既定 0.5.
投影モード(luminance_map 指定時)には影響しない.
hide_below: ベイク着色で irradiance がこの値以下の face を描画から除外する閾値.
既定 0.0(irradiance <= 0.0 の face を非表示=無照度の外殻を消す).
負値指定で実質全 face を表示(後方互換的に全面表示したい場合).
投影モード・ベイク計算ロジックには影響しない.
bake_smooth: ベイク着色メッシュのスカラ Laplacian 平滑化イテレーション数.
N>0 のとき cell_data_to_point_data / point_data_to_cell_data の往復を N 回繰り返し
スカラを拡散させて三角形モザイクノイズを抑制する.
0 のとき平滑化なし(cell_data → point_data 変換のみ行い Gouraud 補間は有効).
負値は 0 にクランプする.
bake_scale="log" 時は log 変換後のスカラに対して平滑化する.
投影モード・ベイク計算ロジックには影響しない(評価板投影は常に per-vertex).
bake_scale: ベイク着色の輝度正規化スケール.
"log"(既定): log10 変換後に共有 clim / percentile を算出する.
高ダイナミックレンジ潰れを防ぎ、壁面にコントラストが付く.
eps=1e-3 を下限として log10(max(irradiance, eps)) を適用.
scalar bar タイトルは「Irradiance (W/m², log10)」.
"linear": 従来どおり(後方互換).
scalar bar タイトルは「Irradiance (W/m²)」.
評価板の実レンダ投影(luminance_map)には影響しない.
cull_outward: True(既定)のとき、ベイク着色経路の各メッシュについて
「シーン中心側を向く内向き face のみ」を描画し、外向き face はカリングする.
外向き(≈0 の外殻)を除くことで暗殻の濁り/見かけ段差を解消する
(PoC poc_cull.py で検証済み).
共有 clim もカリング後の内向き lit 値で percentile を算出する(PoC と同方式).
False のとき全面描画(後方互換).
--render の評価板テクスチャ投影には影響しない(壁ベイク側にのみ適用).
scene_center は描画対象メッシュ頂点全体の平均を 1 回算出して全メッシュ共通に使う.
巻き順依存の注意: 法線は STL の巻き順から算出するため、巻き順が
揃っていない STL では内向き/外向きが反転する可能性がある
(poc_cull.py 検証 STL では妥当).
hide_sensor_marker: True のとき、センサマーカー(青い球+青い視線矢印)の add_mesh を
スキップする(デモ撮影用).メッシュ着色・scalar bar・座標軸・bounds はそのまま残す.
False(既定)のとき従来どおりセンサマーカーを表示する(後方互換).
hide_led_markers: True のとき、LED マーカー(PointLight 球 / AreaLight plane+矢印 /
DirectionalLight 矢印)の add_mesh をスキップする(デモ撮影用).
False(既定)のとき従来どおり LED マーカーを表示する(後方互換).
"""
import pyvista as pv
# bake_opacity を [0, 1] にクランプ
bake_opacity = max(0.0, min(1.0, bake_opacity))
# bake_smooth を 0 以上にクランプ
bake_smooth = max(0, bake_smooth)
# bake_scale を正規化(未知値は "log" にフォールバック)
if bake_scale not in ("linear", "log"):
_logger.warning("未知の bake_scale='%s'、'log' にフォールバックします.", bake_scale)
bake_scale = "log"
# screenshot 指定があれば off_screen を強制する
if screenshot is not None:
off_screen = True
plotter = pv.Plotter(off_screen=off_screen)
# ベイクモード / ハイブリッドモード時に depth peeling を有効化
# (半透明メッシュの深度ブレンド崩れ対策)
# 例外が出る環境もあるため try/except でフォールバックする
if irradiance is not None:
try:
plotter.enable_depth_peeling()
_logger.info("depth peeling を有効化しました.")
except Exception as exc:
_logger.warning("depth peeling の有効化に失敗しました(フォールバック): %s", exc)
# モード判定
# use_projection: luminance_map を評価板に投影するか
# use_bake_walls: irradiance で評価板以外の壁メッシュを着色するか
use_projection = luminance_map is not None
# ハイブリッドモード(luminance_map + irradiance 同時指定)では
# 投影モードの評価板着色+ベイクの壁着色を同時に行う
use_bake_walls = irradiance is not None
# 投影モードの場合:評価板メッシュ id・per-vertex スカラ・UV・カメラ向き face マスクを準備する
projected_scalars: np.ndarray | None = None
vertex_uv: np.ndarray | None = None
camera_face_mask: np.ndarray | None = None
eval_board_id: str | None = None
proj_texture: pv.Texture | None = None
# 評価板照度マップ E_board (W/m²):輝度 L を E = π*L/ρ で変換したもの
e_board_map: np.ndarray | None = None
eval_board_rho: float | None = None
if use_projection:
eval_board_id, projected_scalars, vertex_uv, camera_face_mask = _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
else:
# 評価板の反射率 ρ を scene_config から取得する
# ρ が取得できない場合は 0.85(SPEC_03 基準設計値)にフォールバックする
eval_board_rho = _get_eval_board_reflectance(scene_config, eval_board_id)
_eval_material = next(
m.material for m in scene_config.geometry.meshes if m.id == eval_board_id
)
print(f" 評価板 reflectance: ρ={eval_board_rho:.4f} (material='{_eval_material}')")
# 輝度マップ L (W/(m²·sr)) → 照度マップ E_board (W/m²)
# Lambertian 拡散面: L = ρ * E / π → E = π * L / ρ
# (SPEC_03「筐体(材料)」の E ≈ pixel × π / 0.85 に準拠)
if luminance_map is not None:
if luminance_map.ndim == 3:
lum2d = luminance_map[:, :, 0].astype(np.float64)
else:
lum2d = luminance_map.astype(np.float64)
e_board_map = (np.pi * lum2d / eval_board_rho).astype(np.float64)
_logger.info(
"E_board 換算: ρ=%.4f L=[%.4e, %.4e] E_board=[%.4e, %.4e] mean=%.4e",
eval_board_rho,
float(lum2d.min()),
float(lum2d.max()),
float(e_board_map.min()),
float(e_board_map.max()),
float(e_board_map.mean()),
)
print(
f" E_board 換算 (E=πL/ρ): "
f"min={e_board_map.min():.4e} max={e_board_map.max():.4e} "
f"mean={e_board_map.mean():.4e} (W/m^2)"
)
# ---- cull_outward プレパス: scene_center と内向き face マスクを事前計算 ----
# cull_outward=True のとき、ベイク着色の各メッシュについて内向き face マスクを計算し
# inward_masks に保持する(clim 算出とメッシュ描画の両方で再利用する).
# scene_center は全描画対象メッシュ頂点の平均を 1 回だけ計算して共有する(PoC と同方式).
inward_masks: dict[str, np.ndarray] = {} # {mesh_id: bool (N_f,)} — cull_outward 用
# {mesh_id: (vertices_m, faces)} — プレパスで読み込んだ頂点・面のキャッシュ
_prepass_vf: dict[str, tuple[np.ndarray, np.ndarray]] = {}
if cull_outward and use_bake_walls and irradiance is not None:
from sm_tias_lightsim.visualize.projection import compute_inward_face_mask
# Step 1: 全ベイク対象メッシュの頂点を集めて scene_center を計算する
_all_verts_for_center: list[np.ndarray] = []
for mesh_cfg in scene_config.geometry.meshes:
if mesh_cfg.id not in irradiance:
continue
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")
_prepass_vf[mesh_cfg.id] = (scaled.vertices, scaled.faces)
_all_verts_for_center.append(scaled.vertices)
except Exception as exc:
_logger.warning(
"cull_outward プレパス: メッシュ '%s' 読み込みエラー(スキップ): %s",
mesh_cfg.id,
exc,
)
if _all_verts_for_center:
_scene_center = np.vstack(_all_verts_for_center).mean(axis=0)
_logger.info(
"cull_outward: scene_center (m)=[%.4f, %.4f, %.4f]",
float(_scene_center[0]),
float(_scene_center[1]),
float(_scene_center[2]),
)
print(
f" cull_outward: scene_center (m)=["
f"{_scene_center[0]:.4f}, {_scene_center[1]:.4f}, {_scene_center[2]:.4f}]"
)
else:
_scene_center = np.zeros(3)
_logger.warning("cull_outward: 全頂点収集失敗、scene_center=原点にフォールバック")
# Step 2: 各メッシュの内向き face マスクを計算する
for mesh_cfg in scene_config.geometry.meshes:
if mesh_cfg.id not in irradiance:
continue
if mesh_cfg.id not in _prepass_vf:
continue
verts_m, faces = _prepass_vf[mesh_cfg.id]
try:
mask = compute_inward_face_mask(verts_m, faces, _scene_center)
inward_masks[mesh_cfg.id] = mask
n_inward = int(mask.sum())
n_total = len(faces)
_logger.info(
"cull_outward: mesh='%s' inward(描画)=%d outward(カリング)=%d / total=%d",
mesh_cfg.id,
n_inward,
n_total - n_inward,
n_total,
)
print(
f" cull_outward: {mesh_cfg.id}:"
f" inward(描画)={n_inward} outward(カリング)={n_total - n_inward}"
f" / total={n_total}"
)
except Exception as exc:
_logger.warning(
"cull_outward: mesh='%s' のマスク計算に失敗しました"
"(全面描画にフォールバック): %s",
mesh_cfg.id,
exc,
)
# ---- 共有 clim の算出(ベイクモード / ハイブリッドモード / 投影モード単独)----
# 算出に使うデータ:
# ハイブリッドモード(use_bake_walls + use_projection):
# 壁ベイク全 face irradiance ∪ E_board 全画素 → 共有 [vmin, vmax]
# ベイクモード(use_bake_walls のみ):
# 壁ベイク全 face irradiance のみ
# 投影モード単独(use_projection のみ、use_bake_walls=False):
# E_board のみ → 評価板用の [vmin, vmax]
# cull_outward=True のとき壁ベイクの clim 算出は内向き face のみを使う(PoC と同方式).
# bake_scale="log" 時は log 変換後(log 空間)の値で算出する.
shared_vmin: float | None = None
shared_vmax: float | None = None
bake_scalar_bar_added = False # 共有 scalar bar を 1 本だけ追加するフラグ
# clim 算出用の値リスト(壁ベイク + E_board を結合する)
_clim_value_chunks: list[np.ndarray] = []
if use_bake_walls and irradiance is not None:
# 壁ベイク face irradiance を追加
# cull_outward=True のとき内向き face のみを使う(カリング後の lit 値で締める)
for mesh_id, irr in irradiance.items():
if len(irr) == 0:
continue
if cull_outward and mesh_id in inward_masks:
# カリング後の内向き face のみで clim を算出する(PoC と同方式)
mask = inward_masks[mesh_id]
if mask.sum() > 0:
_clim_value_chunks.append(irr[mask].astype(np.float64))
else:
_clim_value_chunks.append(irr.astype(np.float64))
if use_projection and e_board_map is not None:
# 評価板の E_board(照度マップ全画素)を追加
_clim_value_chunks.append(e_board_map.ravel().astype(np.float64))
if _clim_value_chunks:
all_values_combined = np.concatenate(_clim_value_chunks)
if bake_scale == "log":
# log 変換後の値で clim を算出する(log 空間の percentile)
all_values_for_clim = np.log10(np.maximum(all_values_combined, _LOG10_EPS))
else:
all_values_for_clim = all_values_combined
# ---- lit フィルタ: 無照度(floor 値)の face を clim 算出から除外 ----
# log モード: log 変換後の値が log10(_LOG10_EPS) に張り付いた face は
# 実質ゼロ照度(無照度 floor)であり、percentile を歪める原因になる.
# log10(_LOG10_EPS) = -3.0 より僅かに大きい値のみを lit とみなし clim 算出に使う.
# linear モード: linear 値が _LOG10_EPS(=1e-3 W/m²)より大きい face のみを lit とする.
# 一貫性のため log モードと同じ絶対閾値を基準とする.
# いずれも _LOG10_EPS を閾値の根拠とすることでハードコードを避ける.
if bake_scale == "log":
_lit_threshold_log = np.log10(_LOG10_EPS)
_lit_mask = all_values_for_clim > _lit_threshold_log
else:
_lit_mask = all_values_for_clim > _LOG10_EPS
_n_lit = int(_lit_mask.sum())
_n_total = len(_lit_mask)
_logger.info(
"lit フィルタ: lit=%d / total=%d (%.1f%%)",
_n_lit,
_n_total,
100.0 * _n_lit / max(_n_total, 1),
)
if _n_lit >= 2:
# lit 値のみで percentile を算出(無照度 floor による clim 過大を防ぐ)
_values_for_percentile = all_values_for_clim[_lit_mask]
_clim_source = "lit のみ"
else:
# lit 要素が極端に少ない場合は全結合で算出(ゼロ除算/空配列回避)
_values_for_percentile = all_values_for_clim
_clim_source = "全結合(lit 不足によるフォールバック)"
_logger.warning(
"lit face 数が不足(%d 個)のため全結合で clim を算出します(フォールバック).",
_n_lit,
)
shared_vmin = float(np.percentile(_values_for_percentile, clip_low))
shared_vmax = float(np.percentile(_values_for_percentile, clip_high))
_logger.info(
"共有 clim (%s 空間, %s): vmin=%.4e vmax=%.4e (percentile %.1f–%.1f)",
bake_scale,
_clim_source,
shared_vmin,
shared_vmax,
clip_low,
clip_high,
)
print(
f" 共有 clim ({bake_scale} 空間, {_clim_source}): "
f"vmin={shared_vmin:.4e} vmax={shared_vmax:.4e} "
f"(p{clip_low:.0f}-p{clip_high:.0f})"
)
# 投影モードのテクスチャは共有 clim が確定した後に生成する
if use_projection and e_board_map is not None and shared_vmin is not None:
try:
proj_texture = _make_irradiance_texture(
e_board_map,
shared_vmin,
shared_vmax, # type: ignore[arg-type]
bake_scale,
_LOG10_EPS,
)
except Exception as exc:
_logger.warning(
"E_board テクスチャ生成に失敗しました(フォールバック: per-vertex): %s", exc
)
proj_texture = None
# ---- メッシュ追加 ----
# scalar bar タイトルを bake_scale に応じて決定(投影・ベイク共通)
if bake_scale == "log":
_scalar_bar_title = "Irradiance (W/m^2, log10)"
else:
_scalar_bar_title = "Irradiance (W/m^2)"
for mesh_cfg in scene_config.geometry.meshes:
# プレパスでキャッシュ済みの場合はそれを再利用し、STL の二度読みを回避する
if mesh_cfg.id in _prepass_vf:
_cached_verts, _cached_faces = _prepass_vf[mesh_cfg.id]
else:
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")
_cached_verts, _cached_faces = scaled.vertices, scaled.faces
except Exception as exc:
_logger.warning("メッシュ '%s' を読み込めませんでした: %s", mesh_cfg.id, exc)
continue
polydata = _build_pyvista_polydata(_cached_verts, _cached_faces)
if use_projection and mesh_cfg.id == eval_board_id and projected_scalars is not None:
# ハイブリッドモード(--render 指定時のみ): 評価板をカメラ向き内側 face のみ投影着色
# render 未指定(全面統一ベイク)時はここには来ない(use_projection=False)
# カメラ向き face(内側面)と非カメラ向き face(外側面)に分割して別々に描画する
if camera_face_mask is not None and len(camera_face_mask) == polydata.n_cells:
# cell インデックスリストで分割する
all_cell_ids = np.arange(polydata.n_cells, dtype=np.int64)
inner_ids = all_cell_ids[camera_face_mask]
outer_ids = all_cell_ids[~camera_face_mask]
# ---- 内側 face(カメラ向き): UV テクスチャで E_board 照度マップを貼る ----
if len(inner_ids) > 0:
inner_poly = polydata.extract_cells(inner_ids)
if proj_texture is not None and vertex_uv is not None:
# ---- テクスチャ方式(主経路)----
# extract_cells 後の頂点インデックスを vtkOriginalPointIds で取得し、
# 元メッシュ全体の vertex_uv から対応 UV を抽出する
orig_ids = inner_poly.point_data["vtkOriginalPointIds"]
inner_uv = vertex_uv[orig_ids] # (N_inner_v, 2)
inner_poly.point_data["TextureCoordinates"] = inner_uv
inner_poly.active_texture_coordinates = inner_uv
plotter.add_mesh(
inner_poly,
texture=proj_texture,
show_scalar_bar=False,
label=mesh_cfg.id,
opacity=1.0,
)
# カラーバーを別途追加(テクスチャは自動 colorbar を作らない)
# 壁ベイクと共有の単一カラーバー(E_board + 壁 irradiance 同一スケール)
# ハイブリッドモードでは壁ベイクが先に追加する場合があるため未追加のみ
both_clim = shared_vmin is not None and shared_vmax is not None
if not bake_scalar_bar_added and both_clim:
# ダミースカラ付き不可視アクターで viridis カラーバーを 1 本追加
_dummy = inner_poly.copy()
_dummy.point_data["_irr_bar"] = np.linspace(
shared_vmin, shared_vmax, _dummy.n_points, dtype=np.float32
)
plotter.add_mesh(
_dummy,
scalars="_irr_bar",
cmap="viridis",
clim=[shared_vmin, shared_vmax],
show_scalar_bar=True,
scalar_bar_args={"title": _scalar_bar_title},
opacity=0.0,
)
bake_scalar_bar_added = True
else:
# ---- per-vertex スカラ方式(テクスチャ生成失敗時フォールバック)----
# E_board を per-vertex にサンプリングして着色する
_logger.warning(
"テクスチャが無効のため per-vertex E_board スカラに"
"フォールバックします."
)
orig_ids = inner_poly.point_data["vtkOriginalPointIds"]
# E_board_map から projected_scalars と同じ bilinear サンプリングで
# per-vertex 値を取得する(近似: projected_scalars に π/ρ をスケール)
if e_board_map is not None and eval_board_rho is not None:
_e_board_scalars = projected_scalars * (np.pi / eval_board_rho)
else:
_e_board_scalars = projected_scalars
inner_poly.point_data["irradiance"] = _e_board_scalars[orig_ids]
show_bar = not bake_scalar_bar_added
_kwargs_fb: dict = {
"scalars": "irradiance",
"cmap": "viridis",
"show_scalar_bar": show_bar,
"scalar_bar_args": {"title": _scalar_bar_title},
"label": mesh_cfg.id,
"opacity": 1.0,
}
if shared_vmin is not None and shared_vmax is not None:
_kwargs_fb["clim"] = [shared_vmin, shared_vmax]
plotter.add_mesh(inner_poly, **_kwargs_fb)
if show_bar:
bake_scalar_bar_added = True
# ---- 外側 face(非カメラ向き): ニュートラルグレーで描画 ----
if len(outer_ids) > 0:
outer_poly = polydata.extract_cells(outer_ids)
plotter.add_mesh(
outer_poly,
color="lightgray",
opacity=_BACKGROUND_MESH_OPACITY,
label=f"{mesh_cfg.id}:outer",
)
else:
# マスクが取得できなかった場合: 全面を per-vertex E_board スカラで描画
_logger.warning(
"カメラ向き face マスクが無効です(全面を投影着色). face_mask=%s, n_cells=%d",
camera_face_mask is None,
polydata.n_cells,
)
if e_board_map is not None and eval_board_rho is not None:
_e_board_scalars_full = projected_scalars * (np.pi / eval_board_rho)
else:
_e_board_scalars_full = projected_scalars
polydata.point_data["irradiance"] = _e_board_scalars_full
show_bar = not bake_scalar_bar_added
_kwargs_fb2: dict = {
"scalars": "irradiance",
"cmap": "viridis",
"show_scalar_bar": show_bar,
"scalar_bar_args": {"title": _scalar_bar_title},
"label": mesh_cfg.id,
"opacity": 1.0,
}
if shared_vmin is not None and shared_vmax is not None:
_kwargs_fb2["clim"] = [shared_vmin, shared_vmax]
plotter.add_mesh(polydata, **_kwargs_fb2)
if show_bar:
bake_scalar_bar_added = True
elif use_bake_walls and irradiance is not None and mesh_cfg.id in irradiance:
# ベイクモード(全面統一)/ ハイブリッドモード: per-face 照度で着色
# 全面統一ベイク: 評価板を含む全メッシュがここに来る
# ハイブリッドモード: 評価板以外のメッシュがここに来る
# (評価板は上の if ブランチで投影着色済み or irradiance に含まれない)
# bake_opacity を一律適用して内部の光分布が透けて見えるようにする
irr = irradiance[mesh_cfg.id].astype(np.float32)
# ---- cull_outward: 内向き face のみを extract_cells して描画 ----
# 外向き face(シーン外を向く ≈0 の外殻)をカリングし濁り/暗殻を除去する.
# 値閾値(hide_below)でなく幾何的向きで判定するため、
# ノイズで暗い lit 面に穴が開かない(PoC poc_cull.py で確認済み).
if cull_outward and mesh_cfg.id in inward_masks:
_cull_mask = inward_masks[mesh_cfg.id]
_inner_ids = np.where(_cull_mask)[0].astype(np.int64)
if len(_inner_ids) == 0:
_logger.info(
"cull_outward: メッシュ '%s' は内向き face が 0 のため非表示.",
mesh_cfg.id,
)
continue
polydata = polydata.extract_cells(_inner_ids)
irr = irr[_cull_mask]
polydata.cell_data["irradiance"] = irr
# ---- 処理順序(bake_scale="log" 時)----
# 1. hide_below フィルタ(linear 値基準)
# 2. log 変換(log10(max(irr, eps)))
# 3. bake_smooth 平滑化(log 空間スカラに対して)
# 4. cell → point 補間 → add_mesh
# bake_scale="linear" 時は従来どおり(平滑化 → hide_below → cell→point)
if bake_scale == "log":
# ---- Step 1: hide_below フィルタ(linear 値基準、cell_data 上で実行)----
n_faces_before = polydata.n_cells
if hide_below >= 0.0:
filtered_cd = polydata.threshold(
value=hide_below, scalars="irradiance", invert=False
)
else:
filtered_cd = polydata
n_faces_after = filtered_cd.n_cells
_logger.info(
"threshold フィルタ: mesh='%s' hide_below=%.4e 残 face=%d / 全 face=%d",
mesh_cfg.id,
hide_below,
n_faces_after,
n_faces_before,
)
if n_faces_after == 0:
_logger.info(
"メッシュ '%s' は全 face が閾値以下のため非表示にします.", mesh_cfg.id
)
continue
# ---- Step 2: log 変換(log 空間スカラを cell_data に上書き)----
irr_after_filter = filtered_cd.cell_data["irradiance"].astype(np.float32)
log_irr = np.log10(np.maximum(irr_after_filter, _LOG10_EPS)).astype(np.float32)
filtered_cd.cell_data["irradiance"] = log_irr
_logger.info(
"log 変換: mesh='%s' log10 irradiance: min=%.4f max=%.4f std=%.4f",
mesh_cfg.id,
float(log_irr.min()),
float(log_irr.max()),
float(log_irr.std()),
)
# ---- Step 3: bake_smooth 平滑化(log 空間スカラ・cell_data を入力)----
smoothed_pd = _smooth_scalar_on_polydata(filtered_cd, "irradiance", bake_smooth)
_logger.info(
"スカラ平滑化 (log): mesh='%s' bake_smooth=%d "
"point_data irradiance: min=%.4f max=%.4f std=%.4f",
mesh_cfg.id,
bake_smooth,
float(smoothed_pd.point_data["irradiance"].min()),
float(smoothed_pd.point_data["irradiance"].max()),
float(smoothed_pd.point_data["irradiance"].std()),
)
filtered = smoothed_pd
else:
# linear モード: 従来どおり(平滑化 → hide_below → cell→point)
# 平滑化はフルメッシュ(threshold 前)のスカラに対して行う
# (threshold 後に行うと欠損セルの境界で近傍平均が歪むため)
smoothed_pd = _smooth_scalar_on_polydata(polydata, "irradiance", bake_smooth)
_logger.info(
"スカラ平滑化: mesh='%s' bake_smooth=%d "
"point_data irradiance: min=%.4e max=%.4e std=%.4e",
mesh_cfg.id,
bake_smooth,
float(smoothed_pd.point_data["irradiance"].min()),
float(smoothed_pd.point_data["irradiance"].max()),
float(smoothed_pd.point_data["irradiance"].std()),
)
n_faces_before = smoothed_pd.n_cells
if hide_below >= 0.0:
pd_for_threshold = smoothed_pd.point_data_to_cell_data()
filtered_cd = pd_for_threshold.threshold(
value=hide_below, scalars="irradiance", invert=False
)
if filtered_cd.n_cells > 0:
filtered = filtered_cd.cell_data_to_point_data()
else:
filtered = filtered_cd
else:
filtered = smoothed_pd
n_faces_after = filtered.n_cells
_logger.info(
"threshold フィルタ: mesh='%s' hide_below=%.4e 残 face=%d / 全 face=%d",
mesh_cfg.id,
hide_below,
n_faces_after,
n_faces_before,
)
if n_faces_after == 0:
_logger.info(
"メッシュ '%s' は全 face が閾値以下のため非表示にします.", mesh_cfg.id
)
continue
# _scalar_bar_title はメッシュループ前に bake_scale に応じて定義済み
show_bar = not bake_scalar_bar_added
add_mesh_kwargs: dict = {
"scalars": "irradiance",
"cmap": "viridis",
"show_scalar_bar": show_bar,
"scalar_bar_args": {"title": _scalar_bar_title},
"label": mesh_cfg.id,
"opacity": bake_opacity,
# Gouraud(頂点補間)シェーディングで三角形境界の硬いエッジを除去する
"smooth_shading": True,
# カラーマップを頂点間で線形補間する(フラット単色塗りを防ぐ)
"interpolate_before_map": True,
}
# 共有 clim がある場合は全壁メッシュで同一スケールを適用する
if shared_vmin is not None and shared_vmax is not None:
add_mesh_kwargs["clim"] = [shared_vmin, shared_vmax]
plotter.add_mesh(filtered, **add_mesh_kwargs)
if show_bar:
bake_scalar_bar_added = True
elif use_projection:
# 投影モード(ハイブリッドでない)または
# ハイブリッドモードで irradiance に含まれないメッシュ:
# 評価板以外は半透明ニュートラルグレー(背景扱い)
plotter.add_mesh(
polydata,
color="lightgray",
opacity=_BACKGROUND_MESH_OPACITY,
label=mesh_cfg.id,
)
else:
# 着色なし: グレー半透明(--no-bake または bake 未完了メッシュ)
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 / 光源表示 ----
if not hide_led_markers:
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]
if not hide_sensor_marker:
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