Newer
Older
SmTIAS-LightSim / scripts / show_led_geometry.py
"""LED 配置・サイズ・向きを 3D で確認するビューア.

筐体を半透明にして,各 LED を実サイズ(light.size)の長方形+発光方向の矢印で
グループ色(下=赤 / 左=緑 / 右=青)に描く.評価板(シアン)・センサ(黒+視線矢印)・
XYZ 向き軸も表示する.render の変換とは独立に config の center/size/direction を
そのまま描くので「config が意図している LED 幾何」の目視検証に使える.

使い方:
    # インタラクティブ表示(マウスで回転・ズーム)
    python scripts/show_led_geometry.py
    python scripts/show_led_geometry.py --scene configs/scene_baseline.yaml

    # スクリーンショットを 4 アングル保存(画面を開かない)
    python scripts/show_led_geometry.py --screenshot output/_slide_panels/led_geom
"""

from __future__ import annotations

import argparse
import os

import numpy as np
import pyvista as pv

from sm_tias_lightsim.geometry.loader import load_stl
from sm_tias_lightsim.scene.schema import SceneConfig, load_scene_yaml

_GROUP_COLORS = {"bottom": "red", "left": "green", "right": "blue"}
_ARROW_LEN_MM = 12.0

# バグ修正前の縦ストリップ高さ(mm).バグ時 scale の Y が 1.0(m)=±1m のままだったため
# world Y 方向に ±1000mm = 高さ 2000mm に伸びていた(幅は light.size[0] のまま).
_BUGGY_STRIP_HALF_Y_MM = 1000.0


def _led_polydata(light, *, buggy: bool) -> pv.PolyData:
    """LED の発光面 PolyData を返す.

    fixed: light.size どおりの実サイズ長方形(修正後).
    buggy: 修正前の変換が実際に作っていた形=幅 light.size[0] × 高さ 2000mm の縦ストリップ
        (全 LED が direction=(0,0,-1)/up=(0,1,0) のため local X→world X・local Y→world Y で
        Y 方向に伸びる).忠実に 4 隅から構成する.
    """
    center = np.array(light.center, dtype=float)
    direction = np.array(light.direction, dtype=float)
    direction = direction / np.linalg.norm(direction)
    if not buggy:
        return pv.Plane(
            center=center.tolist(),
            direction=direction.tolist(),
            i_size=light.size[0],
            j_size=light.size[1],
        )
    half_x = light.size[0] / 2.0
    x_hat = np.array([1.0, 0.0, 0.0])
    y_hat = np.array([0.0, 1.0, 0.0])
    corners = np.array(
        [
            center - half_x * x_hat - _BUGGY_STRIP_HALF_Y_MM * y_hat,
            center + half_x * x_hat - _BUGGY_STRIP_HALF_Y_MM * y_hat,
            center + half_x * x_hat + _BUGGY_STRIP_HALF_Y_MM * y_hat,
            center - half_x * x_hat + _BUGGY_STRIP_HALF_Y_MM * y_hat,
        ]
    )
    return pv.PolyData(corners, np.array([4, 0, 1, 2, 3]))


def _resolve_mesh_path(path: str) -> str:
    """scene YAML のメッシュパスを解決する(絶対 → そのまま,無ければ configs/meshes/basename)."""
    if os.path.isfile(path):
        return path
    base = os.path.basename(path.replace(chr(92), "/"))
    return os.path.join("configs", "meshes", base)


def _build_plotter(
    scene_config: SceneConfig, *, off_screen: bool, buggy: bool = False
) -> pv.Plotter:
    pv.global_theme.allow_empty_mesh = True
    plotter = pv.Plotter(off_screen=off_screen, window_size=(1400, 1050))
    plotter.set_background("white")

    # 筐体(半透明)・評価板(シアン)・暗い phone/holder
    for mesh_config in scene_config.geometry.meshes:
        loaded = load_stl(_resolve_mesh_path(mesh_config.path), "mm")
        vertices = np.array(loaded.vertices)
        faces_arr = np.array(loaded.faces)
        faces = np.hstack([np.full((faces_arr.shape[0], 1), 3), faces_arr]).ravel()
        poly = pv.PolyData(vertices, faces)
        if mesh_config.id == "evaluation_board":
            plotter.add_mesh(poly, color="cyan", opacity=0.35)
        elif "phone" in mesh_config.id or "holder" in mesh_config.id:
            plotter.add_mesh(poly, color="dimgray", opacity=0.12)
        else:
            plotter.add_mesh(poly, color="lightgray", opacity=0.10)

    # LED(実サイズ長方形+発光方向矢印)
    for light in scene_config.lights:
        group = next((g for g in _GROUP_COLORS if g in light.id), "other")
        color = _GROUP_COLORS.get(group, "orange")
        center = np.array(light.center, dtype=float)
        direction = np.array(light.direction, dtype=float)
        direction = direction / np.linalg.norm(direction)
        plotter.add_mesh(
            _led_polydata(light, buggy=buggy), color=color, opacity=0.6 if buggy else 1.0
        )
        plotter.add_mesh(
            pv.Arrow(start=center.tolist(), direction=direction.tolist(), scale=_ARROW_LEN_MM),
            color=color,
        )

    # センサ(球+視線矢印)
    sensor_pos = np.array(scene_config.sensor.position, dtype=float)
    sensor_target = np.array(scene_config.sensor.target, dtype=float)
    plotter.add_mesh(
        pv.Sphere(radius=_ARROW_LEN_MM * 0.4, center=sensor_pos.tolist()), color="black"
    )
    view_dir = sensor_target - sensor_pos
    view_dir = view_dir / np.linalg.norm(view_dir)
    plotter.add_mesh(
        pv.Arrow(start=sensor_pos.tolist(), direction=view_dir.tolist(), scale=_ARROW_LEN_MM * 2.5),
        color="black",
    )

    plotter.add_legend(
        [
            ["bottom LED", "red"],
            ["left LED", "green"],
            ["right LED", "blue"],
            ["eval board", "cyan"],
            ["sensor->view", "black"],
        ],
        bcolor="white",
        size=(0.20, 0.20),
    )
    plotter.show_axes()
    plotter.show_bounds(
        color="gray", location="outer", xtitle="X(mm)", ytitle="Y(mm)", ztitle="Z(mm)"
    )
    return plotter


def main() -> None:
    parser = argparse.ArgumentParser(description="LED 配置・サイズ・向きを 3D で確認する")
    parser.add_argument(
        "--scene", "-s", default="configs/scene_baseline.yaml", help="シーン定義 YAML"
    )
    parser.add_argument(
        "--screenshot",
        default=None,
        help="指定時は画面を開かず {prefix}_{iso,front_XY,side_XZ,top_YZ}.png を保存する",
    )
    parser.add_argument(
        "--buggy",
        action="store_true",
        help="バグ修正前の形(幅 light.size[0] × 高さ 2000mm の縦ストリップ)を描く",
    )
    args = parser.parse_args()

    scene_config = load_scene_yaml(args.scene)
    mode = "BUGGY (2m strip)" if args.buggy else "FIXED"
    print(
        f"[{mode}] LED 数: {len(scene_config.lights)}  size(mm): {scene_config.lights[0].size}  "
        f"direction: {scene_config.lights[0].direction}"
    )

    if args.screenshot:
        plotter = _build_plotter(scene_config, off_screen=True, buggy=args.buggy)
        views = [
            ("iso", plotter.view_isometric),
            ("front_XY", plotter.view_xy),
            ("side_XZ", plotter.view_xz),
            ("top_YZ", plotter.view_yz),
        ]
        for name, view_fn in views:
            view_fn()
            out_path = f"{args.screenshot}_{name}.png"
            plotter.screenshot(out_path)
            print("saved", out_path)
        plotter.close()
    else:
        plotter = _build_plotter(scene_config, off_screen=False, buggy=args.buggy)
        plotter.view_isometric()
        plotter.show()


if __name__ == "__main__":
    main()