Newer
Older
SmTIAS-LightSim / scripts / generate_sphere_loft_housing.py
# TEST_11 改訂(球体筐体実験)用: 評価板の矩形輪郭と球を一体化した筐体メッシュ生成.
#
# 球単体では矩形の評価板と断面円の形が合わず,四隅の貫通か縁の隙間のどちらかが生じる.
# Fusion 360 のロフトに相当する「矩形 → 円」の遷移スカートをスクリプトで生成し,
# 球のお椀部分と 1 つの watertight ソリッドに縫合することで,評価板がフタとして
# 隙間なく嵌まる筐体を作る.
"""球+ロフト一体型筐体(評価板輪郭に整合する閉キャビティ)STL を生成する.

構成(上から下へ,単位 mm):
    1. リム矩形: 評価板輪郭から rim_inset だけ内側の矩形.z=rim_z(板の実体に
       食い込む高さ)に置き,板とのオーバーラップで隙間なくシールする
    2. ロフト部: 矩形 → 球断面円(z=cut_z)への極座標 Hermite ブレンド.
       球側の端点勾配を球面の接線に一致させ滑らかに接続する
    3. 球部: 半径 radius・中心 (0,0,center_z) の球の z ≤ cut_z 部分(お椀)

内面と外面(thickness だけオフセット)を生成し,リム帯で縫合した閉ソリッドを出力する.

使用例:
    uv run python scripts/generate_sphere_loft_housing.py \
        --output configs/meshes/球ロフト_r100.stl
"""

from __future__ import annotations

import argparse
from pathlib import Path

import numpy as np
import trimesh


def _rect_radius(theta: np.ndarray, half_x: float, half_y: float) -> np.ndarray:
    """角度 theta ごとの矩形境界までの半径(極座標表現)を返す."""
    cos_t = np.abs(np.cos(theta))
    sin_t = np.abs(np.sin(theta))
    with np.errstate(divide="ignore"):
        rx = np.where(cos_t > 1e-12, half_x / cos_t, np.inf)
        ry = np.where(sin_t > 1e-12, half_y / sin_t, np.inf)
    return np.minimum(rx, ry)


# blend="radial" のリム接線の張り出し強さ(弦長に対する比).
# 大きいほど天井が板の外へ大きく張り出し,装置が扁平になる.
RADIAL_TANGENT_SCALE = 0.6


def _loft_ring(
    u: float,
    rect_r: np.ndarray,
    cut_r: float,
    cut_z: float,
    rim_z: float,
    cut_d: float,
    radius: float,
    blend: str,
) -> tuple[np.ndarray, np.ndarray]:
    """ロフト部のパラメータ u における 1 リングの (半径, Z) を返す.

    "cubic" / "quintic" は z を等間隔に進めながら半径 ρ(z) を決める形なので,
    リムでの傾きは dρ/dz = 0(=板に垂直に立ち上がる)しか表現できない.これが
    板直下に「喉」を作り,半径が大きいほどくびれて見える原因になる.
    "radial" は (ρ, z) の 2 次元パラメトリック Hermite に切り替え,リムの接線を
    **半径方向(水平)**に取ることで喉を無くす(接続点側は従来どおり球の接線に一致).

    Args:
        u: 0(リム矩形)→ 1(球との接続円)のパラメータ.
        rect_r: リム矩形の角度ごとの半径.
        cut_r: 接続円の半径.
        cut_z: 接続平面の Z.
        rim_z: リムの Z.
        cut_d: 接続平面の球中心からの高さ(cut_z - center_z).
        radius: 球内半径.
        blend: "cubic" / "quintic" / "radial".

    Returns:
        (半径, Z) の組.いずれも角度ごとの配列.
    """
    span = cut_z - rim_z
    if blend == "radial":
        # 弦長に比例した接線ベクトルを両端に置く 2 次元 3 次 Hermite
        chord = np.hypot(cut_r - rect_r, span)
        scale = RADIAL_TANGENT_SCALE * chord
        h00 = 2 * u**3 - 3 * u**2 + 1
        h10 = u**3 - 2 * u**2 + u
        h01 = -2 * u**3 + 3 * u**2
        h11 = u**3 - u**2
        rho = h00 * rect_r + h10 * scale + h01 * cut_r + h11 * scale * (cut_d / radius)
        z = h00 * rim_z + h01 * cut_z + h11 * scale * (-cut_r / radius)
        return rho, z
    m1 = (-cut_d / cut_r) * span
    m2 = (-(radius**2) / cut_r**3) * span**2
    return _blend_radius(u, rect_r, cut_r, m1, m2, blend), np.full_like(
        rect_r, rim_z + u * span
    )


def _blend_radius(
    u: float,
    rect_r: np.ndarray,
    cut_r: float,
    m1: float,
    m2: float,
    blend: str,
) -> np.ndarray:
    """ロフト部のパラメータ u における半径を Hermite ブレンドで返す.

    Args:
        u: 0(リム矩形)→ 1(球との接続円)のパラメータ.
        rect_r: リム矩形の角度ごとの半径.
        cut_r: 接続円の半径.
        m1: 接続円における dρ/du(球面の接線に一致させる値).
        m2: 接続円における d²ρ/du²(球面の曲率に一致させる値.quintic のみ使用).
        blend: "cubic"(傾きのみ一致)または "quintic"(傾きと曲率を一致).

    Returns:
        角度ごとの半径配列.

    Raises:
        ValueError: 未対応の blend が指定された場合.
    """
    if blend == "cubic":
        return (2 * u**3 - 3 * u**2 + 1) * rect_r + (-2 * u**3 + 3 * u**2) * cut_r + (
            u**3 - u**2
        ) * m1
    if blend == "quintic":
        # 5 次 Hermite: p(0)=rect_r, p'(0)=p''(0)=0, p(1)=cut_r, p'(1)=m1, p''(1)=m2
        # 端点で曲率まで球に一致するので,接合部に曲率の段差(見た目の折れ)が出ない
        h0 = 1 - 10 * u**3 + 15 * u**4 - 6 * u**5
        h3 = 10 * u**3 - 15 * u**4 + 6 * u**5
        h4 = -4 * u**3 + 7 * u**4 - 3 * u**5
        h5 = 0.5 * (u**3 - 2 * u**4 + u**5)
        return h0 * rect_r + h3 * cut_r + h4 * m1 + h5 * m2
    msg = f"未対応の blend です: {blend!r}(cubic / quintic のいずれか)"
    raise ValueError(msg)


def _build_surface(
    radius: float,
    center_z: float,
    half_x: float,
    half_y: float,
    rim_z: float,
    cut_z: float,
    n_theta: int,
    n_loft: int,
    n_sphere: int,
    blend: str = "cubic",
) -> tuple[np.ndarray, np.ndarray]:
    """リム矩形 → ロフト → 球お椀の 1 枚曲面(頂点とリング構造)を生成する.

    Returns:
        (vertices, rings): vertices は (N, 3),rings は (n_ring, n_theta) の頂点 index.
        リングは上(リム)から下(極直前)の順.末尾に極頂点を 1 つ追加している.
    """
    theta = np.linspace(0.0, 2.0 * np.pi, n_theta, endpoint=False)
    rect_r = _rect_radius(theta, half_x, half_y)

    cut_d = cut_z - center_z
    cut_r = float(np.sqrt(radius**2 - cut_d**2))
    rings_list: list[np.ndarray] = []
    z_list: list[np.ndarray] = []

    # ロフト部(リム u=0 を含み,カット面 u=1 は球部の先頭リングに任せる)
    for u in np.linspace(0.0, 1.0, n_loft, endpoint=False):
        rho, z_ring = _loft_ring(u, rect_r, cut_r, cut_z, rim_z, cut_d, radius, blend)
        rings_list.append(rho)
        z_list.append(z_ring)

    # 球部(カット面 → 極直前).角度パラメータで均等分割
    phi_cut = float(np.arccos(cut_d / radius))
    for phi in np.linspace(phi_cut, np.pi, n_sphere, endpoint=False):
        rings_list.append(np.full(n_theta, radius * np.sin(phi)))
        z_list.append(np.full(n_theta, center_z + radius * np.cos(phi)))

    n_ring = len(rings_list)
    vertices = np.empty((n_ring * n_theta + 1, 3), dtype=np.float64)
    rings = np.arange(n_ring * n_theta).reshape(n_ring, n_theta)
    for i, (rho, z) in enumerate(zip(rings_list, z_list, strict=True)):
        vertices[rings[i], 0] = rho * np.cos(theta)
        vertices[rings[i], 1] = rho * np.sin(theta)
        vertices[rings[i], 2] = z
    vertices[-1] = (0.0, 0.0, center_z - radius)  # 極頂点
    return vertices, rings


def _stitch_rings(rings: np.ndarray, pole_index: int) -> np.ndarray:
    """隣接リング間を四角形 2 分割で三角形化し,最下リングは極頂点へファン接続する."""
    n_ring, n_theta = rings.shape
    faces: list[tuple[int, int, int]] = []
    for i in range(n_ring - 1):
        a, b = rings[i], rings[i + 1]
        for j in range(n_theta):
            k = (j + 1) % n_theta
            faces.append((a[j], b[j], b[k]))
            faces.append((a[j], b[k], a[k]))
    bottom = rings[-1]
    for j in range(n_theta):
        k = (j + 1) % n_theta
        faces.append((bottom[j], pole_index, bottom[k]))
    return np.asarray(faces, dtype=np.int64)


def build_loft_housing(
    radius: float,
    thickness: float,
    center_z: float,
    board_half_x: float,
    board_half_y: float,
    rim_inset: float,
    rim_z: float,
    cut_z: float,
    n_theta: int = 256,
    n_loft: int = 48,
    n_sphere: int = 160,
    blend: str = "cubic",
) -> trimesh.Trimesh:
    """球+ロフト一体型筐体の閉ソリッド(内面+外面+リム帯)を生成する.

    Args:
        radius: 球内半径(mm).
        thickness: 殻厚(mm).外面は内面を径方向におおむね thickness オフセットした形.
        center_z: 球中心の Z 座標(mm).X, Y は 0 固定.
        board_half_x: 評価板輪郭の X 半幅(mm).
        board_half_y: 評価板輪郭の Y 半幅(mm).
        rim_inset: リム矩形を板輪郭から内側へ寄せる量(mm).板とのオーバーラップ代.
        rim_z: リム矩形の Z 座標(mm).板の実体に食い込む高さに置いてシールする.
        cut_z: ロフトと球の接続平面の Z 座標(mm).
        n_theta: 周方向分割数.
        n_loft: ロフト部の軸方向リング数.
        n_sphere: 球部の軸方向リング数.
        blend: ロフトの形状."cubic" は球の接線だけを一致させる(既定・後方互換).
            "quintic" は曲率まで一致させ,接合部の折れを無くす.いずれもリムでは
            板に垂直に立ち上がるため板直下に喉ができる."radial" はリムの接線を
            半径方向(水平)に取り,喉を無くす.

    Returns:
        watertight な筐体ソリッドメッシュ(mm 単位).

    Raises:
        ValueError: 生成メッシュが watertight にならなかった場合.
    """
    hx_in = board_half_x - rim_inset
    hy_in = board_half_y - rim_inset
    v_in, rings_in = _build_surface(
        radius, center_z, hx_in, hy_in, rim_z, cut_z, n_theta, n_loft, n_sphere, blend
    )
    v_out, rings_out = _build_surface(
        radius + thickness,
        center_z,
        hx_in + thickness,
        hy_in + thickness,
        rim_z,
        cut_z,
        n_theta,
        n_loft,
        n_sphere,
        blend,
    )

    f_in = _stitch_rings(rings_in, len(v_in) - 1)
    f_out = _stitch_rings(rings_out, len(v_out) - 1)

    offset = len(v_in)
    vertices = np.vstack([v_in, v_out])
    # リム帯: 内面上端リングと外面上端リングを接続して閉じる
    top_in, top_out = rings_in[0], rings_out[0] + offset
    band: list[tuple[int, int, int]] = []
    for j in range(n_theta):
        k = (j + 1) % n_theta
        band.append((top_in[j], top_out[j], top_out[k]))
        band.append((top_in[j], top_out[k], top_in[k]))
    faces = np.vstack([f_in, f_out + offset, np.asarray(band, dtype=np.int64)])

    mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=False)
    mesh.fix_normals()  # 閉ソリッドとして法線を外向きに統一(内面は自動的にキャビティ向き)
    if not mesh.is_watertight:
        raise ValueError("生成メッシュが watertight になりませんでした(縫合パラメータを確認)")
    return mesh


def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    """コマンドライン引数をパースして返す."""
    parser = argparse.ArgumentParser(
        prog="generate_sphere_loft_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-z", type=float, default=-92.0, help="球中心の Z 座標(mm).既定 -92"
    )
    parser.add_argument(
        "--board-half", type=float, nargs=2, default=(49.5, 51.0), metavar=("HX", "HY"),
        help="評価板輪郭の半幅 X Y(mm).既定 49.5 51(99×102mm 板)",
    )
    parser.add_argument(
        "--rim-inset",
        type=float,
        default=1.5,
        help="リムを板輪郭から内側へ寄せる量(mm).既定 1.5",
    )
    parser.add_argument(
        "--rim-z", type=float, default=0.0,
        help="リムの Z 座標(mm).既定 0(板裏面 -4.5 より 4.5mm 食い込みシール)",
    )
    parser.add_argument(
        "--cut-z", type=float, default=-40.0, help="ロフト⇄球の接続平面 Z(mm).既定 -40"
    )
    parser.add_argument(
        "--blend", choices=("cubic", "quintic", "radial"), default="cubic",
        help="ロフトの形状.quintic は接合部で曲率まで球に一致させる."
        "radial はリムで半径方向(水平)に出して板直下の喉を無くす.既定 cubic",
    )
    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)
    mesh = build_loft_housing(
        radius=args.radius,
        thickness=args.thickness,
        center_z=args.center_z,
        board_half_x=args.board_half[0],
        board_half_y=args.board_half[1],
        rim_inset=args.rim_inset,
        rim_z=args.rim_z,
        cut_z=args.cut_z,
        blend=args.blend,
    )
    args.output.parent.mkdir(parents=True, exist_ok=True)
    mesh.export(args.output)
    print(f"出力: {args.output}")
    print(f"faces: {len(mesh.faces)}, watertight: {mesh.is_watertight}")
    print(f"bounds (mm): {mesh.bounds.round(2).tolist()}")
    print(f"体積 (mm^3): {mesh.volume:.0f}(正値なら法線外向きで正常)")
    return 0


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