Newer
Older
SmTIAS-LightSim / scripts / generate_sphere_housing.py
# 球体筐体実験(TEST_11)用の球殻メッシュ生成スクリプト.
#
# 現行筐体(前側/後側)を理想化した「完全閉球」の白色キャビティに置き換え,
# 幾何レバーの理想極限(漏れゼロの積分球)で照明均一性の上限を測る実験に使う.
# 評価板(照明ムラ補正ボード)を内包できる中心・半径を引数で指定する.
"""球殻(integrating sphere 状の閉じた白キャビティ)STL を生成する.

内半径 r の icosphere(法線を内向きに反転)と外半径 r+t の icosphere を
連結した watertight な厚み付き殻を作り,バイナリ STL として出力する.
単位は mm(プロジェクトのシーン YAML 規約に合わせる).

使用例:
    uv run python scripts/generate_sphere_housing.py \
        --radius 100 --thickness 3 --center 0 0 -40 \
        --output configs/meshes/球殻_r100.stl
"""

from __future__ import annotations

import argparse
from pathlib import Path

import numpy as np
import trimesh


def build_sphere_shell(
    radius: float,
    thickness: float,
    center: tuple[float, float, float],
    subdivisions: int,
) -> trimesh.Trimesh:
    """厚み付き球殻メッシュを生成する.

    内面(半径 radius)は法線をキャビティ側(中心向き)に反転させ,
    外面(半径 radius + thickness)は外向きのまま連結する.
    2 つの閉曲面から成るため全体として watertight な実体殻になる.

    Args:
        radius: 内半径(mm).キャビティの半径.
        thickness: 殻厚(mm).
        center: 球中心座標 (x, y, z)(mm).
        subdivisions: icosphere の分割回数.6 で辺長 ≈ radius * 0.03 程度.

    Returns:
        球殻メッシュ(mm 単位).

    Raises:
        ValueError: radius または thickness が正でない場合.
    """
    if radius <= 0:
        raise ValueError(f"内半径は正の値を指定してください: {radius}")
    if thickness <= 0:
        raise ValueError(f"殻厚は正の値を指定してください: {thickness}")

    inner = trimesh.creation.icosphere(subdivisions=subdivisions, radius=radius)
    # 内面の法線は実体(殻)から見て外側=キャビティ中心向きが正しいため反転する
    inner.invert()
    outer = trimesh.creation.icosphere(subdivisions=subdivisions, radius=radius + thickness)

    shell = trimesh.util.concatenate([outer, inner])
    shell.apply_translation(np.asarray(center, dtype=np.float64))
    return shell


def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    """コマンドライン引数をパースして返す."""
    parser = argparse.ArgumentParser(
        prog="generate_sphere_housing",
        description="球体筐体実験用の閉じた球殻 STL(mm 単位)を生成する.",
    )
    parser.add_argument("--radius", type=float, default=100.0, help="内半径(mm).既定 100")
    parser.add_argument("--thickness", type=float, default=3.0, help="殻厚(mm).既定 3")
    parser.add_argument(
        "--center",
        type=float,
        nargs=3,
        default=(0.0, 0.0, -40.0),
        metavar=("X", "Y", "Z"),
        help="球中心座標(mm).既定 0 0 -40(評価板を内包する位置)",
    )
    parser.add_argument(
        "--subdivisions",
        type=int,
        default=6,
        help="icosphere 分割回数.既定 6(面数 81920 x 2,辺長 ≈ 3mm)",
    )
    parser.add_argument(
        "--output",
        type=Path,
        default=Path("configs/meshes/球殻_r100.stl"),
        help="出力 STL パス.既定 configs/meshes/球殻_r100.stl",
    )
    return parser.parse_args(argv)


def main(argv: list[str] | None = None) -> int:
    """球殻を生成して STL 出力し,検証情報を表示する."""
    args = _parse_args(argv)

    shell = build_sphere_shell(
        radius=args.radius,
        thickness=args.thickness,
        center=tuple(args.center),
        subdivisions=args.subdivisions,
    )

    args.output.parent.mkdir(parents=True, exist_ok=True)
    shell.export(args.output)

    # 期待体積 = (4/3)π((r+t)^3 - r^3).メッシュ体積との一致で内面反転の正しさを確認できる
    expected_volume = (
        4.0 / 3.0 * np.pi * ((args.radius + args.thickness) ** 3 - args.radius**3)
    )
    print(f"出力: {args.output}")
    print(f"faces: {len(shell.faces)}, watertight: {shell.is_watertight}")
    print(f"bounds (mm): {shell.bounds.round(2).tolist()}")
    print(f"体積 (mm^3): {shell.volume:.0f}(理論値 {expected_volume:.0f})")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())