# -*- coding: utf-8 -*-
"""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
import sm_tias_lightsim.geometry.loader as G
import sm_tias_lightsim.scene.schema as S
_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: S.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 = G.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 = S.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()