Newer
Older
SmTIAS-LightSim / src / sm_tias_lightsim / visualize / viewer.py
"""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