# 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)
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,
) -> 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))
# 球面の cut_z における dρ/dz(下向きに進むと ρ が増える側)を Hermite 端点勾配に使う
drho_dz = -cut_d / cut_r
# u: 0(リム)→ 1(カット面).dz/du = cut_z - rim_z
m1 = drho_dz * (cut_z - rim_z)
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):
h00 = 2 * u**3 - 3 * u**2 + 1
h01 = -2 * u**3 + 3 * u**2
h11 = u**3 - u**2
rho = h00 * rect_r + h01 * cut_r + h11 * m1
rings_list.append(rho)
z_list.append(np.full(n_theta, rim_z + u * (cut_z - rim_z)))
# 球部(カット面 → 極直前).角度パラメータで均等分割
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,
) -> 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: 球部の軸方向リング数.
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
)
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,
)
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(
"--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,
)
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())