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 で直接照度を計算する.
    4. show_scene で PyVista 表示(または screenshot 保存)を行う.
    5. --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 自身はレンダリングを実行しない)."
            "指定時は --no-bake が暗黙で有効になる(直接ベイクをスキップ)."
        ),
    )
    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(
        "--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 専用モード).",
    )
    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. --render 未指定かつ --no-bake がなければ bake_irradiance で照度を計算する.
        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])
    # --render 指定時は直接ベイクをスキップ(LLVM 不要)
    no_bake: bool = args.no_bake or (render_path is not None)
    off_screen: bool = args.off_screen

    # 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:
        print("照度ベイクを開始します(直接照度のみ)...")
        try:
            from sm_tias_lightsim.visualize.baker import bake_irradiance

            irradiance = bake_irradiance(config, project_root)
            print("照度ベイク完了.")
        except Exception as exc:  # noqa: BLE001
            print(f"エラー: 照度ベイクに失敗しました — {exc}", file=sys.stderr)
            return 1
    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,
        )
    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