"""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
