Newer
Older
SmTIAS-LightSim / src / sm_tias_lightsim / cli / visualize.py
# pyright: reportCallIssue=none
# Mitsuba 3 は variant ベースの動的属性解決を行うため Pylance/Pyright が
# mi.* を object 型と推論し reportCallIssue を誤検知する(GUIDE_06 の方針).
"""sm-visualize コマンドラインエントリポイント.

YAML シーン定義を読み込み,3D 照度ベイクと PyVista インタラクティブビューアを提供する.

処理フロー:
    1. YAML シーン定義を読み込み SceneConfig を得る.
    2. --render が指定された場合,luminance.npy を読み込む
       (sm-visualize 自身はレンダリングを実行しない).
    3. --render 未指定かつ --no-bake 指定がない場合,bake_irradiance でベイクを計算する.
       既定(render 無し): **全メッシュ(評価板含む)**を path / direct ベイクで統一着色.
       --render 指定時(任意): 評価板はテクスチャ投影・壁は path ベイク(ハイブリッドモード).
         --render + --bake-mode path: 評価板=投影・壁=ベイク着色(ハイブリッド).
         --render + --bake-mode direct: 投影のみ(direct ベイクはしない、従来どおり).
    4. --bake-cache 指定時はキャッシュ npz を読み書きする(path ベイクのみ有効).
       キャッシュが存在すれば Mitsuba 起動なしで即時読込,なければベイク後に npz 保存.
    5. show_scene で PyVista 表示(または screenshot 保存)を行う.
       --style slide: 発表スライド用クリーン体裁(背景純白・座標軸/bounds 非表示・
           アンチエイリアス・黒文字スカラバー・センサマーカー非表示).
       --cmap NAME: カラーマップ名(未指定時は style に応じて default=viridis / slide=hot).
    6. --output 指定があれば per-face irradiance を npz 形式で保存する.
"""

from __future__ import annotations

import argparse
import sys
from pathlib import Path


def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    """コマンドライン引数をパースして返す.

    Args:
        argv: 引数リスト.None のとき sys.argv[1:] を使用.

    Returns:
        パース済み Namespace.
    """
    parser = argparse.ArgumentParser(
        prog="sm-visualize",
        description=(
            "YAML シーン定義から 3D 照度ベイクを実行し,PyVista でインタラクティブ表示する."
            " --render を指定すると sm-render 出力の 2D 輝度マップを評価板に投影表示する"
            "(レンダリングは実行しない)."
        ),
    )
    parser.add_argument(
        "--scene",
        "-s",
        required=True,
        type=Path,
        metavar="YAML_PATH",
        help="シーン定義 YAML ファイルのパス.",
    )
    parser.add_argument(
        "--render",
        type=Path,
        default=None,
        metavar="LUMINANCE_NPY",
        help=(
            "sm-render が出力した luminance.npy のパス(任意オプション)."
            "指定時は評価板メッシュに 2D 輝度マップを orthographic 投影して表示する"
            "(sm-visualize 自身はレンダリングを実行しない)."
            "未指定(既定): 全メッシュ(評価板含む)を bake_irradiance で統一着色する."
            "--render + --bake-mode path: ハイブリッドモード(評価板=投影・壁=ベイク)."
            "--render + --bake-mode direct: 投影のみ(direct ベイクはスキップ)."
        ),
    )
    parser.add_argument(
        "--clip-percentile",
        nargs=2,
        type=float,
        default=[1.0, 99.0],
        metavar=("LOW", "HIGH"),
        help=(
            "スカラ表示の percentile クリップ範囲(%%)."
            "高ダイナミックレンジ潰れ対策.既定: 1 99 ."
        ),
    )
    parser.add_argument(
        "--no-bake",
        action="store_true",
        default=False,
        help="照度ベイクをスキップしてシーンレイアウトのみ表示する.",
    )
    parser.add_argument(
        "--bake-mode",
        choices=["direct", "path"],
        default="direct",
        metavar="MODE",
        help=(
            "照度ベイクのモード."
            "'direct'(既定): shadow ray による直接照度のみ(後方互換)."
            "'path': コサイン重点サンプリング + Mitsuba path tracer による多重反射込み照度."
            " scalar_rgb variant を使用(LLVM / DRJIT_LIBLLVM_PATH 不要)."
            " --render 未指定時(既定): 全メッシュ(評価板含む)をベイクで統一着色する."
            " --render + path: ハイブリッドモード(評価板=投影・壁=ベイク)."
            " --render + direct: direct ベイクはスキップし投影のみ(従来どおり)."
        ),
    )
    parser.add_argument(
        "--bake-samples",
        type=int,
        default=64,
        metavar="N",
        help=(
            "--bake-mode path 時の face あたりサンプル数."
            "大きいほど低ノイズ・高コスト.既定: 64."
            "高速確認は 16 程度,本番は 256 以上推奨."
            "--bake-mode direct では無視される."
        ),
    )
    parser.add_argument(
        "--bake-cache",
        type=Path,
        default=None,
        metavar="NPZ_PATH",
        help=(
            "path ベイク結果のキャッシュ npz ファイルパス(任意)."
            " 指定パスが存在すれば npz を読み込んでベイクをスキップ(Mitsuba 起動なし・即時)."
            " 存在しなければ path ベイクを実行し結果を npz に保存して次回以降キャッシュを利用."
            " --bake-mode path のときのみ有効(direct では無視)."
        ),
    )
    parser.add_argument(
        "--screenshot",
        type=Path,
        default=None,
        metavar="PNG_PATH",
        help="スクリーンショット保存先 PNG パス.指定時はウィンドウを開かず off_screen で保存する",
    )
    parser.add_argument(
        "--output",
        "-o",
        type=Path,
        default=None,
        metavar="OUTPUT_DIR",
        help="ベイク結果(per-face irradiance)を npz 形式で保存するディレクトリ(任意).",
    )
    parser.add_argument(
        "--off-screen",
        action="store_true",
        default=False,
        help="ウィンドウを開かない(screenshot 専用モード).",
    )
    parser.add_argument(
        "--opacity",
        type=float,
        default=0.5,
        metavar="O",
        help=(
            "ベイク着色モードのメッシュ不透明度(0=透明〜1=不透明、既定 0.5)."
            " 1.0 指定で完全不透明になる."
            " --render 投影モードには影響しない."
        ),
    )
    parser.add_argument(
        "--hide-below",
        type=float,
        default=0.0,
        metavar="VALUE",
        help=(
            "ベイク着色で irradiance がこの値以下の面を非表示にする"
            "(既定 0.0=光が当たっていない面を描かない)."
            " 負値指定で全面表示(ベイク着色の全 face を描画)."
            " 投影モード・ベイク計算ロジックには影響しない."
        ),
    )
    parser.add_argument(
        "--bake-smooth",
        type=int,
        default=4,
        metavar="N",
        help=(
            "ベイク着色メッシュのスカラ Laplacian 平滑化イテレーション数(既定 4)."
            " N>0 のとき cell↔point_data 変換を N 回往復してノイズを拡散・抑制する"
            "(per-face モンテカルロノイズによる三角形モザイクを軽減)."
            " N=0 で平滑化なし(cell_data → point_data 変換のみ行い Gouraud 補間は有効)."
            " 負値は 0 にクランプ."
            " 投影モード・ベイク計算ロジックには影響しない."
        ),
    )
    parser.add_argument(
        "--bake-smooth-radius",
        type=float,
        default=None,
        metavar="MM",
        help=(
            "ベイク着色を物理半径 MM mm のガウス重み付き平均で平滑化する(既定: なし)."
            " 三角形スケールの MC ノイズを消し,半径より大きい照明構造は保つ."
            " --bake-denoise の後・--bake-smooth の前に適用する."
            " 投影モード・ベイク計算ロジックには影響しない."
        ),
    )
    parser.add_argument(
        "--bake-denoise",
        type=int,
        default=0,
        metavar="N",
        help=(
            "ベイク着色のメディアンデノイズ反復回数(既定 0=なし)."
            " MC ノイズ(firefly)をエッジを保ったまま除去する."
            " --bake-smooth の拡散平滑化より先に適用される."
            " 投影モード・ベイク計算ロジックには影響しない."
        ),
    )
    parser.add_argument(
        "--bake-scale",
        choices=["linear", "log"],
        default="log",
        metavar="SCALE",
        help=(
            "ベイク着色の輝度正規化."
            "'log'(既定): log10 変換後に共有 clim / percentile を算出する."
            " 高ダイナミックレンジ潰れを防ぎ壁面にコントラストが付く."
            "'linear': 従来どおり(後方互換)."
            " 評価板の実レンダ投影には影響しない."
        ),
    )
    parser.add_argument(
        "--bake-max-depth",
        type=int,
        default=16,
        metavar="N",
        help=(
            "path ベイクの ray 最大反射回数(render 設定から独立、既定 16)."
            " 大きいほど多重反射の均一化を正確に積分する"
            "(SPEC_03: depth≥12 推奨.depth=8 は後段多重反射を取りこぼし勾配を過大評価)."
            " --bake-mode direct では無視される(direct ベイクは path integrator を使わない)."
        ),
    )
    parser.add_argument(
        "--bake-subdivide",
        type=float,
        default=None,
        metavar="MM",
        help=(
            "ベイク前に三角形の最大辺長を指定 mm 以下まで均一分割する(既定: 分割なし)."
            " 巨大三角形のブロック感を解消する."
            " --bake-cache 使用時はキャッシュに設定値を記録し,次回以降の読み込み時に"
            " 設定が変わっていれば警告を出して再ベイクする."
        ),
    )
    # BooleanOptionalAction が Windows CP932 環境で --help 表示時に ↔ を出力し
    # UnicodeEncodeError になる問題を回避するため、store_true / store_false で実装する.
    _cull_group = parser.add_mutually_exclusive_group()
    _cull_group.add_argument(
        "--cull-outward",
        action="store_true",
        default=True,
        dest="cull_outward",
        help=(
            "外向き面(シーン外を向く ≈0 の外殻)を描画しない."
            " 暗殻の濁り/見かけ段差を解消する.既定有効."
            " --render の評価板テクスチャには影響しない(壁ベイク側にのみ適用)."
        ),
    )
    _cull_group.add_argument(
        "--no-cull-outward",
        action="store_false",
        dest="cull_outward",
        help="外向き面を含む全面を描画する(後方互換).",
    )
    parser.add_argument(
        "--no-sensor-marker",
        action="store_true",
        default=False,
        help=(
            "センサマーカー(青い球+視線矢印)のみ非表示にする(デモ撮影用)."
            " LED マーカーは残す.メッシュ着色とカラーバーは影響しない."
        ),
    )
    parser.add_argument(
        "--no-led-markers",
        action="store_true",
        default=False,
        help=(
            "LED マーカー(黄色球・AreaLight plane・矢印等)のみ非表示にする(デモ撮影用)."
            " センサマーカーは残す.メッシュ着色とカラーバーは影響しない."
        ),
    )
    parser.add_argument(
        "--no-markers",
        action="store_true",
        default=False,
        help=(
            "LED/センサ両方のマーカー・矢印を非表示にするショートカット(デモ撮影用)."
            " --no-sensor-marker --no-led-markers を同時指定したのと等価."
            " メッシュ着色とカラーバーは残す."
        ),
    )
    parser.add_argument(
        "--style",
        choices=["default", "slide"],
        default="default",
        metavar="STYLE",
        help=(
            "表示スタイルプリセット."
            "'default'(既定): 現行表示そのまま(後方互換)."
            "'slide': 発表スライド用クリーン体裁(背景純白・座標軸/bounds 非表示・"
            "アンチエイリアス有効・scalar bar 黒文字・センサマーカー非表示)."
            " screenshot 保存(--screenshot / --off-screen)にも反映される."
        ),
    )
    parser.add_argument(
        "--cmap",
        default=None,
        metavar="NAME",
        help=(
            "カラーマップ名(matplotlib 互換)."
            "未指定(既定): style に応じて自動決定する"
            "(default → viridis、slide → hot)."
            "明示指定時は全スタイルでそのカラーマップを使用する"
            "(壁ベイク・評価板テクスチャ・スカラバーの配色が一致する)."
            "不正な名前は warning を出して style 既定にフォールバックする."
        ),
    )
    parser.add_argument(
        "--backface-cull",
        action="store_true",
        default=False,
        help=(
            "カメラに背を向けた面を描画しない(背面カリング)."
            " --opacity 1.0 と併用すると,手前の壁が自動的に隠れて内部が混色なしで見える"
            "(スライド/デモ向き)."
            " 既定 OFF(後方互換)."
        ),
    )
    return parser.parse_args(argv)


def main(argv: list[str] | None = None) -> int:
    """sm-visualize コマンドのメインエントリポイント.

    処理フロー:
        1. コマンドライン引数をパースする.
        2. YAML シーン定義を load_scene_yaml で読み込み,SceneConfig を得る.
        3. --render が指定された場合,luminance.npy を読み込む(レンダリング実行なし).
        4. --no-bake がなければ bake_irradiance で照度を計算する.
           --render 未指定(既定): 全メッシュ(評価板含む)を統一ベイク着色.
           --render + path: ハイブリッド(評価板=投影・壁=ベイク;評価板をベイクから除外).
           --render + direct: ベイクしない(投影のみ、従来どおり).
        5. --output が指定されていれば per-face irradiance を npz で保存する.
        6. show_scene で PyVista 表示(または screenshot 保存)を行う.

    Args:
        argv: 引数リスト.None のとき sys.argv[1:] を使用.

    Returns:
        終了コード(成功時 0,失敗時 1).
    """
    args = _parse_args(argv)

    scene_path: Path = args.scene.resolve()
    render_path: Path | None = args.render.resolve() if args.render else None
    screenshot: Path | None = args.screenshot.resolve() if args.screenshot else None
    output_dir: Path | None = args.output.resolve() if args.output else None
    clip_low: float = float(args.clip_percentile[0])
    clip_high: float = float(args.clip_percentile[1])
    bake_mode: str = args.bake_mode  # "direct" または "path"
    bake_samples: int = int(args.bake_samples)
    bake_cache_path: Path | None = args.bake_cache.resolve() if args.bake_cache else None
    bake_max_depth: int = int(args.bake_max_depth)
    # --bake-subdivide は mm 単位で受け取り、baker / viewer へは m 系で渡す(_MM_TO_M 規約)
    bake_subdivide_m: float | None = (
        float(args.bake_subdivide) * 0.001 if args.bake_subdivide is not None else None
    )
    # --bake-smooth-radius は mm 単位で受け取り viewer へは m 単位で渡す(_MM_TO_M 規約)
    bake_smooth_radius_m: float | None = (
        float(args.bake_smooth_radius) * 0.001 if args.bake_smooth_radius is not None else None
    )
    cull_outward: bool = bool(args.cull_outward)
    # --no-markers は --no-sensor-marker / --no-led-markers 両方の互換ショートカット
    hide_sensor_marker: bool = bool(args.no_sensor_marker) or bool(args.no_markers)
    hide_led_markers: bool = bool(args.no_led_markers) or bool(args.no_markers)
    # モード判定:
    #   render 無し(既定)        → 全メッシュ統一ベイク(評価板も含む)
    #   --render + --bake-mode path  → ハイブリッドモード(評価板=投影・壁=ベイク)
    #   --render + --bake-mode direct → ベイクしない(投影のみ、従来どおり)
    is_hybrid = (render_path is not None) and (bake_mode == "path")
    no_bake: bool = args.no_bake or (render_path is not None and not is_hybrid)
    off_screen: bool = args.off_screen
    bake_opacity: float = float(args.opacity)
    hide_below: float = float(args.hide_below)
    bake_smooth: int = int(args.bake_smooth)
    bake_denoise: int = int(args.bake_denoise)
    bake_scale: str = args.bake_scale
    style: str = args.style  # "default" または "slide"
    cmap: str | None = args.cmap  # None のとき style に応じて自動決定
    backface_cull: bool = bool(args.backface_cull)

    # YAML 読み込み
    try:
        from sm_tias_lightsim.scene.schema import load_scene_yaml

        config = load_scene_yaml(scene_path)
    except FileNotFoundError as exc:
        print(f"エラー: シーン YAML ファイルが見つかりません — {exc}", file=sys.stderr)
        return 1
    except Exception as exc:  # noqa: BLE001
        print(f"エラー: シーン YAML の読み込みに失敗しました — {exc}", file=sys.stderr)
        return 1

    # project_root: YAML ファイルの親ディレクトリを基点とする
    project_root = scene_path.parent

    # luminance.npy 読み込み(投影モード)
    luminance_map = None
    if render_path is not None:
        print(f"投影モード: luminance.npy を読み込みます: {render_path}")
        try:
            import numpy as np

            luminance_map = np.load(str(render_path))
            print(
                f"  shape={luminance_map.shape}, min={luminance_map.min():.3f},"
                f" max={luminance_map.max():.3f}, std={luminance_map.std():.3f}"
            )
        except FileNotFoundError:
            print(f"エラー: luminance.npy が見つかりません: {render_path}", file=sys.stderr)
            return 1
        except Exception as exc:  # noqa: BLE001
            print(f"エラー: luminance.npy の読み込みに失敗しました — {exc}", file=sys.stderr)
            return 1

    # 照度ベイク
    irradiance: dict | None = None
    if not no_bake:
        import numpy as np

        if bake_mode == "direct":
            if is_hybrid:
                mode_label = "直接照度(shadow ray)- ハイブリッド(評価板除外)"
            else:
                mode_label = "直接照度(shadow ray)- 全メッシュ統一"
        else:
            if is_hybrid:
                mode_label = (
                    f"path ベイク(ハイブリッドモード・壁のみ,"
                    f" samples={bake_samples}, max_depth={bake_max_depth})"
                )
            else:
                mode_label = (
                    f"path ベイク(多重反射込み・全メッシュ統一,"
                    f" samples={bake_samples}, max_depth={bake_max_depth})"
                )
        print(f"照度ベイクを開始します({mode_label})...")
        if bake_mode == "path":
            print("  path モードは計算が重いため時間がかかります.")
        if is_hybrid:
            print(
                "  ハイブリッドモード(--render 指定): "
                "評価板メッシュはベイクから除外します(投影で着色)."
            )
        else:
            print(
                "  全メッシュ統一ベイク(--render 未指定): "
                "評価板を含むすべてのメッシュをベイクします."
            )

        # ハイブリッドモード時のみ評価板メッシュをベイクから除外する
        # render 未指定(全面統一ベイク)時は評価板を除外しない
        exclude_ids: set[str] = set()
        if is_hybrid:
            from sm_tias_lightsim.visualize.projection import find_eval_board_mesh_id

            eval_board_id_for_bake = find_eval_board_mesh_id(config)
            exclude_ids = {eval_board_id_for_bake}
            print(
                f"  評価板メッシュ '{eval_board_id_for_bake}' を"
                "ベイクから除外します(投影着色のため)."
            )

        # キャッシュ対応(path モードのみ)
        cache_loaded = False
        if bake_mode == "path" and bake_cache_path is not None:
            if bake_cache_path.exists():
                print(f"キャッシュを読み込みます(ベイク再計算なし): {bake_cache_path}")
                try:
                    npz = np.load(str(bake_cache_path), allow_pickle=False)
                    irradiance = {k: npz[k] for k in npz.files if k != "_subdivide_setting"}
                    # 形状・型の簡易チェック
                    for k, v in irradiance.items():
                        if v.ndim != 1:
                            msg = f"キャッシュ '{k}' の配列形状が不正です: ndim={v.ndim}"
                            raise ValueError(msg)

                    # subdivide_max_edge 設定の整合チェック
                    # キャッシュに _subdivide_setting が無い場合は「分割なし」とみなす
                    if "_subdivide_setting" in npz.files:
                        cached_subdivide_m = float(npz["_subdivide_setting"])
                    else:
                        cached_subdivide_m = float("nan")  # 旧キャッシュ: 分割なし扱い

                    _cached_none = np.isnan(cached_subdivide_m)
                    _current_none = bake_subdivide_m is None
                    _mismatch = (_cached_none != _current_none) or (
                        not _cached_none
                        and not _current_none
                        and abs(cached_subdivide_m - bake_subdivide_m) > 1e-9  # type: ignore[operator]
                    )
                    if _mismatch:
                        _cached_str = (
                            "なし" if _cached_none else f"{cached_subdivide_m * 1000:.1f} mm"
                        )
                        _current_str = (
                            "なし"
                            if bake_subdivide_m is None
                            else f"{bake_subdivide_m * 1000:.1f} mm"
                        )
                        print(
                            f"警告: キャッシュの subdivide 設定({_cached_str})と"
                            f" 現在の指定({_current_str})が不一致です."
                            " キャッシュを無視して再ベイクします.",
                            file=sys.stderr,
                        )
                        irradiance = None
                        cache_loaded = False
                    else:
                        cache_loaded = True
                        print(f"キャッシュ読み込み完了.メッシュ数={len(irradiance)}")
                        for mesh_id, irr in irradiance.items():
                            print(
                                f"  {mesh_id}: shape={irr.shape}  min={irr.min():.4e}  "
                                f"max={irr.max():.4e}"
                            )
                except Exception as exc:  # noqa: BLE001
                    print(
                        "警告: キャッシュの読み込みに失敗しました"
                        f"(再計算にフォールバック)— {exc}",
                        file=sys.stderr,
                    )
                    irradiance = None
                    cache_loaded = False

        if not cache_loaded:
            try:
                from sm_tias_lightsim.visualize.baker import bake_irradiance

                if bake_mode == "path":
                    irradiance = bake_irradiance(
                        config,
                        project_root,
                        mode="path",
                        samples=bake_samples,
                        exclude_mesh_ids=exclude_ids,
                        max_depth=bake_max_depth,
                        subdivide_max_edge=bake_subdivide_m,
                    )
                else:
                    irradiance = bake_irradiance(
                        config,
                        project_root,
                        mode="direct",
                        exclude_mesh_ids=exclude_ids,
                        subdivide_max_edge=bake_subdivide_m,
                    )

                # path ベイク後にキャッシュを保存する
                # _subdivide_setting キーに設定値を記録して次回読み込み時の整合チェックに使う
                if bake_mode == "path" and bake_cache_path is not None and irradiance:
                    try:
                        bake_cache_path.parent.mkdir(parents=True, exist_ok=True)
                        # subdivide 設定を scalar float 配列として保存
                        # None(分割なし)の場合は nan で保存する(後方互換のため旧キャッシュは
                        # _subdivide_setting キーなし=分割なしとして読み込まれる)
                        _subdivide_setting_val = (
                            bake_subdivide_m if bake_subdivide_m is not None else float("nan")
                        )
                        np.savez(
                            str(bake_cache_path),
                            _subdivide_setting=np.float64(_subdivide_setting_val),
                            **irradiance,
                        )
                        print(f"ベイクキャッシュを保存しました: {bake_cache_path}")
                    except Exception as exc:  # noqa: BLE001
                        print(
                            f"警告: キャッシュの保存に失敗しました — {exc}",
                            file=sys.stderr,
                        )
            except Exception as exc:  # noqa: BLE001
                print(f"エラー: 照度ベイクに失敗しました — {exc}", file=sys.stderr)
                return 1

        if irradiance:
            all_vals = np.concatenate([v for v in irradiance.values() if len(v) > 0])
            print(
                f"照度ベイク完了.全メッシュ: min={all_vals.min():.4e}  "
                f"max={all_vals.max():.4e}  std={all_vals.std():.4e}"
            )
            for mesh_id, irr in irradiance.items():
                print(
                    f"  {mesh_id}: min={irr.min():.4e}  max={irr.max():.4e}  "
                    f"std={irr.std():.4e}  nonzero={int((irr > 0).sum())}/{len(irr)}"
                )
        else:
            print("照度ベイク完了(結果なし).")
    elif render_path is None:
        print("--no-bake: 照度ベイクをスキップします.シーンレイアウトのみ表示します.")

    # ベイク結果を npz で保存
    if irradiance is not None and output_dir is not None:
        try:
            output_dir.mkdir(parents=True, exist_ok=True)
            import numpy as np

            npz_path = output_dir / f"{config.name}_irradiance.npz"
            np.savez(npz_path, **irradiance)
            print(f"照度データ保存: {npz_path}")
        except Exception as exc:  # noqa: BLE001
            print(f"エラー: 照度データの保存に失敗しました — {exc}", file=sys.stderr)
            return 1

    # PyVista 表示
    try:
        from sm_tias_lightsim.visualize.viewer import show_scene

        show_scene(
            config,
            project_root,
            irradiance=irradiance,
            luminance_map=luminance_map,
            off_screen=off_screen,
            screenshot=screenshot,
            clip_low=clip_low,
            clip_high=clip_high,
            bake_opacity=bake_opacity,
            hide_below=hide_below,
            bake_denoise=bake_denoise,
            bake_smooth=bake_smooth,
            bake_scale=bake_scale,
            cull_outward=cull_outward,
            hide_sensor_marker=hide_sensor_marker,
            hide_led_markers=hide_led_markers,
            style=style,
            cmap=cmap,
            subdivide_max_edge=bake_subdivide_m,
            bake_smooth_radius=bake_smooth_radius_m,
            backface_cull=backface_cull,
        )
    except Exception as exc:  # noqa: BLE001
        print(f"エラー: PyVista 表示に失敗しました — {exc}", file=sys.stderr)
        return 1

    if screenshot is not None:
        print(f"スクリーンショット保存: {screenshot}")

    return 0