"""アブラゼミ（Graptopsaltria nigrofuscata）の成虫。

セミの体は「頭部・胸部・腹部」の 3 部が 1 本の軸に並び、そこに複眼・単眼・
口吻・触角・3 対の脚・2 対の翅が付く。この構造をそのままコードの構成にしてある。

胴体は体軸に沿った断面表（`STATIONS`）1 枚のロフト。**体節の段と前胸背板の
肩は断面表そのものが持つ**（`_body_stations` が段の断面を差し込む）。あとから
溝を彫る作り方だと、節が瓦のように重なる形にならないうえ、胴体の面が節の数だけ
割れて STEP が数倍に膨らむ。表面の彫刻——X 字状隆起・中胸背板の稜と側板痕・
前胸背板の縦溝・腹弁・鼓室蓋——だけを「太らせた胴体と細らせた胴体で挟む」
やり方（`_shell_patch` と `_relief`）で体表の曲率に沿わせて切り出す。

脚は関節座標を並べてテーパ丸棒でつなぎ、翅は平面上に翅脈を描いてから
休止姿勢へ回して置く。

座標系は +X が前（頭）、−X が後（腹端）、+Z が背側、左右対称面は XZ。
単位はすべて mm。
"""

from __future__ import annotations

import math
from functools import lru_cache
from typing import NamedTuple

import cadquery as cq

# 基準寸法: 写真からは絶対寸法が出ないので、図鑑値である
# 「体長（頭端〜腹端）36 mm・翅端まで全長 53〜60 mm」を基準に置き、
# 各部の比率を 7 面の写真から読み取っている。体長 36 mm は下の断面表が
# 直接持っていて、頭端 +18.0 から腹端 −18.0 までがそれにあたる。

# ---- 胴体の断面表 ------------------------------------------------------
# (x, 半幅, 背側の高さ, 腹側の深さ)。x は前が正。腹端 −18 から頭端 +18 まで。
# 上面図で幅を実測した結果、セミは思ったよりずんぐりしていて、**いちばん広いのは
# 腹部の付け根**（胸部より広い）。そこから腹部の 6 割ほどまで幅を保ち、
# 後半で一気にすぼまる。ここを紡錘形にしてしまうと、途端にハチのように見える。
# 頭部は前後に短く横に広い板で、幅の大半は両端に付く複眼が受け持つ。
STATIONS: tuple[tuple[float, float, float, float], ...] = (
    (-18.00, 0.30, 0.30, 0.30),   # 腹端
    (-17.10, 1.20, 1.00, 0.90),
    (-15.80, 2.45, 1.95, 1.75),
    (-14.10, 3.80, 2.85, 2.50),
    (-11.90, 5.05, 3.65, 3.15),
    (-9.30, 6.05, 4.20, 3.60),
    (-6.60, 6.70, 4.50, 3.95),
    (-4.00, 6.95, 4.60, 4.10),    # 腹部の最大幅
    (-0.90, 6.35, 4.55, 4.05),    # 胸部と腹部の境
    (1.00, 6.25, 4.95, 4.20),
    (4.00, 6.60, 5.10, 4.25),     # 中胸背板。ここが最も高い
    (7.20, 6.40, 4.85, 4.10),
    (8.70, 6.30, 4.60, 3.90),     # 前胸背板との境
    (9.30, 6.70, 4.60, 3.80),     # 後縁の隆起帯（肩）。ここで幅と高さが一段増す
    (9.90, 6.95, 4.45, 3.65),     # 前胸背板の側角。胸部の最大幅
    (10.60, 6.30, 4.15, 3.50),    # 隆起帯の前のくびれ
    (11.40, 5.90, 3.90, 3.35),
    (13.30, 4.65, 2.95, 3.00),    # 後頭。ここでいったんくびれる
    (14.40, 4.75, 2.45, 3.15),    # 頭頂。平たい板の上に 3 単眼が並ぶ
    (15.60, 4.50, 2.15, 3.25),    # 複眼の付く側角
    (16.70, 3.40, 1.75, 3.10),    # 後唇基節の張り出し
    (17.60, 2.15, 1.15, 2.20),
    (18.00, 1.10, 0.70, 1.30),    # 頭端
)
SECTION_POINTS = 20    # 断面スプラインの分割数。増やすほど STEP が重くなる
SECTION_EXPONENT = 3.0  # 断面の角ばり。2 で楕円、大きいほど側面が立ち上がる

# ---- 体節の段 ----------------------------------------------------------
# 昆虫の体節は、後ろの節の前縁が前の節の後縁の下へ潜り込む「瓦葺き」になっている。
# なので溝を彫るのではなく、断面表に段を差し込んで輪郭そのものに持たせる。
# 彫ると重なりが出ないうえ、胴体の面が節の数だけ割れて STEP が数倍になる。
# (境の x, その境で細くなる量)。首と胸腹境は節間より深くくびれる。
SEGMENT_JOINTS = (
    (13.35, 0.80),   # 頭部と前胸背板（首のくびれ）
    (8.55, 0.50),    # 前胸背板と中胸背板
    (-0.90, 0.50),   # 胸部と腹部
    (-2.60, 0.34), (-4.90, 0.34), (-7.10, 0.32), (-9.30, 0.30),
    (-11.30, 0.28), (-13.10, 0.26), (-14.60, 0.22), (-15.90, 0.18),
)
SEGMENT_EDGE = 0.16    # 段の立ち上がり幅。狭いほど縁が鋭く出る
SEGMENT_RISE = 0.75    # 段のうしろで全幅に戻るまでの距離。節の背が平らになる

# ---- 複眼・単眼 --------------------------------------------------------
# 複眼は頭部の前側角から前外側へ張り出す。上から見ると頭の幅の大半は複眼。
EYE_CENTER = (15.55, 4.45, 0.75)
EYE_RADII = (2.35, 2.20, 1.95)   # x（前後）・y（左右）・z（上下）の半径

OCELLUS_DIA = 0.75     # 単眼は 3 個。頭頂に正三角形に並ぶ（セミの特徴）
OCELLUS_MEDIAN = (15.10, 0.00, 2.35)
OCELLUS_LATERAL = (14.20, 1.15, 2.45)

# ---- 後唇基節（口の上のふくらみ）--------------------------------------
# 前から見て顔の中央を占める大きな隆起。横向きの溝が並ぶのがセミの顔の特徴。
CLYPEUS_CENTER = (15.90, 0.00, -1.70)
CLYPEUS_LEN = 4.60     # x 方向の半径 × 2
CLYPEUS_WIDTH = 5.20
CLYPEUS_DEPTH = 3.60
CLYPEUS_GROOVES = 7
CLYPEUS_GROOVE_W = 0.30
CLYPEUS_GROOVE_D = 0.30

# ---- 口吻（吸汁するための嘴）------------------------------------------
# 頭部の腹面から後方へ伸び、静止時は前脚・中脚の間の正中線に沿って後脚基節まで届く。
ROSTRUM_PATH = ((15.60, 0.0, -3.30), (13.00, 0.0, -4.60), (7.00, 0.0, -4.30), (2.20, 0.0, -3.40))
ROSTRUM_DIA = (1.90, 1.60, 1.10, 0.55)

# ---- 触角 --------------------------------------------------------------
# セミの触角は短い剛毛状。複眼の内側前方から前外側へ出る。
ANTENNA_PATH = ((16.90, 2.20, -0.60), (18.30, 3.30, 0.30), (19.40, 4.10, 0.35))
ANTENNA_DIA = (0.55, 0.32, 0.12)

# ---- 前胸背板の彫り -----------------------------------------------------
# 後縁の隆起帯（肩）は断面表そのものが持っている。板を貼って作ると
# 側面から見たときに角ばった棚になってしまい、生き物の丸みが消える。
# ここでは前胸背板の正中線をはさむ 1 対の縦溝だけを彫る。
PRONOTUM_FURROW_X = 11.30      # 溝の中心
PRONOTUM_FURROW_LEN = 3.20
PRONOTUM_FURROW_Y = 2.10
PRONOTUM_FURROW_W = 0.95
PRONOTUM_FURROW_DEPTH = 0.45

# ---- 中胸背板の彫り -----------------------------------------------------
# 正中線に沿った稜と、それをはさむ 1 対の大きな窪み（側板痕）。
MESONOTUM_RIDGE_X = 4.60
MESONOTUM_RIDGE_LEN = 6.80
MESONOTUM_RIDGE_W = 1.10
MESONOTUM_RIDGE_PROUD = 0.25

SIGILLUM_X = 4.60
SIGILLUM_LEN = 5.60
SIGILLUM_Y = 3.10
SIGILLUM_W = 2.60
SIGILLUM_ANGLE = 8.0           # 後ろへ向かってすぼまる向き
SIGILLUM_DEPTH = 0.32

DORSAL_BASE_Z = 2.40   # 背面の彫りを切り出す柱の下端。側面を拾わない高さ
PATCH_BITE = 0.30      # 隆起を体表より内側へ食い込ませる量（融合を確実にする）

# ---- 中胸背板の X 字状隆起 --------------------------------------------
# 中胸背板の後端にある十字（X 字）形の隆起。セミ科をひと目で見分ける特徴。
CRUX_X = 0.20
CRUX_ARM_LEN = 7.60
CRUX_ARM_WIDTH = 1.60   # 腕の太さ。slot2D なので端は丸くなる
CRUX_ANGLE = 36.0      # 体軸からの開き角
CRUX_PROUD = 0.55      # 体表からの出っ張り
CRUX_BASE_Z = 2.60     # 柱の下端。ここより下（側面）を拾わないための高さ

# ---- 発音器の蓋（鼓室蓋）と腹弁 ---------------------------------------
TYMBAL_CENTER = (-1.80, 5.60, 1.20)   # 腹部第 1 節の側面。中に発音膜がある
TYMBAL_DIA = 2.90
TYMBAL_PROUD = 0.45

# 腹弁。雄の腹面第 1〜2 節にある一対の板で、聴器（鼓膜）を覆う。
# アブラゼミの腹弁はクマゼミなどに比べて小さく、腹部を大きく覆わない。
OPERCULUM_CENTER = (-2.90, 2.40)
OPERCULUM_LEN = 4.40
OPERCULUM_WIDTH = 3.20
OPERCULUM_PROUD = 0.45
OPERCULUM_THICK = 0.75

# ---- 脚 ----------------------------------------------------------------
# 各脚は基節→転節→腿節→脛節→跗節の関節座標で置く（右半身のみ。左は鏡像）。
# 前脚の腿節は太く、掘るための歯（棘）を腹側に持つ。中脚・後脚は細く、後脚が最も長い。
TARSUS_Z = -10.50      # 6 本の跗節先端が揃う高さ＝止まっている面

FORE_LEG = ((10.80, 1.80, -3.30), (11.80, 3.40, -5.20), (13.80, 5.40, -8.40),
            (11.40, 6.60, -10.00), (8.80, 6.60, TARSUS_Z))
FORE_LEG_DIA = (2.90, 2.30, 3.50, 2.50, 1.90, 1.30, 1.10, 0.65)

MID_LEG = ((5.60, 2.00, -3.60), (6.20, 3.60, -5.40), (7.20, 6.20, -8.20),
           (4.60, 8.00, -10.00), (2.20, 8.20, TARSUS_Z))
MID_LEG_DIA = (2.60, 2.10, 2.30, 1.70, 1.55, 1.10, 0.95, 0.60)

HIND_LEG = ((0.80, 2.20, -3.60), (0.20, 3.80, -5.40), (-1.40, 6.80, -8.40),
            (-5.40, 8.80, -10.00), (-8.20, 8.80, TARSUS_Z))
HIND_LEG_DIA = (2.70, 2.20, 2.50, 1.85, 1.65, 1.20, 1.00, 0.60)

FEMUR_TOOTH_AT = (0.35, 0.70)   # 前脚腿節上の位置（0=転節側, 1=腿節端）
FEMUR_TOOTH_LEN = 1.60
FEMUR_TOOTH_DIA = 1.10
CLAW_LEN = 1.10                 # 跗節先端の 2 本爪
CLAW_DIA = 0.45
CLAW_SPREAD = 0.55

# ---- 翅 ----------------------------------------------------------------
# 膜と翅脈の厚みは実物（膜 0.02 mm 前後）よりずっと厚い。造形として成り立つ
# 最小限に寄せた値で、薄くしたいときはこの 2 つを下げる。
MEMBRANE_T = 0.35
VEIN_T = 0.95
VEIN_WIDTH = 0.42

AMBIENT_INSET = 1.10   # 縁紋脈を外縁からどれだけ内側に通すか

# 翅は表が多いので 1 枚ぶんをまとめて持つ。
class Wing(NamedTuple):
    """翅 1 枚ぶんの平面図。座標はすべて基部を原点とした局所座標。"""

    costa: tuple      # 前縁の輪郭。基部→翅端
    anal: tuple       # 後縁の輪郭。翅端→基部
    arc: tuple        # 端室を仕切る横脈の弧。両端が縁に達する
    apical: tuple     # 弧の内側の節から外へ出る脈。縁紋脈上の位置を割合で持つ
    stems: tuple      # 基部から伸びる縦脈
    branches: tuple   # 縦脈の先から弧の節へつなぐ分岐
    cross: tuple      # 基半部の横脈
    ambient: tuple    # 縁紋脈が通る範囲 (costa の開始 index, anal の終了 index)


# 前翅。基部を原点に、翅端を −X、前縁（前方の縁）を +Y に向けた平面図で描く。
# 輪郭は前縁と後縁の 2 本に分けて持つ。1 本の閉じたスプラインにすると
# 基部の折れ角でスプラインが行き過ぎて、ありもしない突起ができる。
FOREWING_COSTA = (
    (0.00, 1.80), (-4.00, 3.80), (-9.00, 5.40), (-15.00, 6.50), (-22.00, 7.20),
    (-28.00, 7.40), (-33.00, 7.00), (-37.50, 6.00), (-41.00, 4.30), (-43.50, 2.20),
    (-44.60, 0.00),
)
FOREWING_ANAL = (
    (-44.60, 0.00), (-43.40, -2.60), (-40.50, -5.00), (-36.00, -6.80),
    (-30.00, -7.80), (-23.00, -8.00), (-16.00, -7.40), (-10.00, -6.00),
    (-5.00, -4.00), (-1.50, -1.80),
)

# 端室（翅端の閉じた室）を仕切る横脈の弧。両端は縁に達し、内側 7 点が分岐の節になる。
FOREWING_ARC = (
    (-33.00, 7.00), (-32.00, 5.50), (-30.80, 3.90), (-29.40, 2.10),
    (-27.80, 0.30), (-26.00, -1.60), (-24.00, -3.60), (-21.60, -5.70), (-19.50, -7.70),
)
# 弧の内側の節から外へ出る脈。終点は縁紋脈上の位置（脈に沿った 0〜1 の割合）で
# 指定する。等間隔に並べれば端室 8 室が同じ幅で並ぶ。
FOREWING_APICAL = (0.12, 0.26, 0.39, 0.50, 0.61, 0.74, 0.87)
# 基部から伸びる縦脈。前縁脈・径脈・中脈・肘脈・臀脈 2 本。
FOREWING_STEMS = (
    ((0.30, 1.30), (-5.00, 3.40), (-11.00, 5.10), (-18.00, 6.30), (-25.00, 6.90), (-33.00, 7.00)),
    ((-1.00, 0.70), (-8.00, 2.60), (-16.00, 4.50), (-23.00, 5.80)),
    ((-1.20, 0.00), (-8.00, 0.70), (-16.00, 1.60), (-22.00, 2.30)),
    ((-1.20, -0.70), (-7.00, -1.90), (-13.00, -3.00), (-19.00, -3.90)),
    ((-1.20, -1.10), (-6.00, -3.60), (-11.00, -5.60), (-16.00, -7.20)),
    ((-1.00, -1.50), (-4.00, -3.50), (-7.50, -5.30), (-9.50, -5.90)),
)
# 縦脈の先から弧の節へつなぐ分岐（径脈が 3 本、中脈が 3 本、肘脈が 2 本）。
FOREWING_BRANCHES = (
    ((-23.00, 5.80), (-27.50, 5.60), (-32.00, 5.50)),
    ((-23.00, 5.80), (-27.00, 4.70), (-30.80, 3.90)),
    ((-22.00, 2.30), (-26.00, 2.20), (-29.40, 2.10)),
    ((-22.00, 2.30), (-25.00, 1.30), (-27.80, 0.30)),
    ((-22.00, 2.30), (-24.00, 0.30), (-26.00, -1.60)),
    ((-19.00, -3.90), (-21.50, -3.70), (-24.00, -3.60)),
    ((-19.00, -3.90), (-20.30, -4.80), (-21.60, -5.70)),
)
# 基半部の横脈。ここで基室・径室・中室が閉じる。
FOREWING_CROSS = (
    ((-8.00, 2.60), (-8.00, 0.70)),
    ((-16.00, 4.50), (-16.00, 1.60)),
    ((-17.00, 1.70), (-17.00, -3.60)),
)

# 後翅。前翅の 6 割ほどの長さで、臀域（後ろ寄りの膜）が大きく張り出す。
HINDWING_COSTA = (
    (0.00, 1.40), (-3.50, 3.80), (-8.00, 5.80), (-13.00, 6.60), (-18.00, 6.30),
    (-22.00, 5.10), (-25.00, 3.00), (-26.50, 0.40),
)
HINDWING_ANAL = (
    (-26.50, 0.40), (-25.30, -2.40), (-22.30, -5.20), (-18.00, -7.60),
    (-12.50, -9.20), (-7.00, -9.00), (-3.00, -6.40), (-1.00, -3.20),
)
HINDWING_ARC = (
    (-20.00, 5.90), (-19.30, 4.00), (-18.30, 2.00), (-17.00, 0.00),
    (-15.40, -2.20), (-13.50, -4.60), (-13.00, -8.80),
)
HINDWING_APICAL = (0.15, 0.31, 0.47, 0.63, 0.80)
HINDWING_STEMS = (
    ((0.20, 1.10), (-4.00, 3.40), (-9.00, 5.30), (-14.00, 6.10), (-20.00, 5.90)),
    ((-0.80, 0.50), (-6.00, 2.30), (-12.00, 3.90), (-16.50, 4.50)),
    ((-1.00, 0.00), (-6.00, 0.50), (-12.00, 1.30), (-16.00, 1.70)),
    ((-1.00, -0.80), (-6.00, -2.30), (-11.00, -3.70), (-14.00, -4.40)),
    ((-1.00, -1.30), (-5.00, -4.10), (-8.50, -6.90), (-10.50, -8.80)),
    ((-0.80, -1.70), (-3.50, -4.50), (-6.20, -8.50)),
)
HINDWING_BRANCHES = (
    ((-16.50, 4.50), (-18.00, 4.20), (-19.30, 4.00)),
    ((-16.00, 1.70), (-17.20, 1.90), (-18.30, 2.00)),
    ((-16.00, 1.70), (-16.60, 0.90), (-17.00, 0.00)),
    ((-14.00, -4.40), (-14.80, -3.30), (-15.40, -2.20)),
    ((-14.00, -4.40), (-13.80, -4.50), (-13.50, -4.60)),
)
HINDWING_CROSS = (
    ((-8.00, 2.83), (-8.00, 0.70)),
    ((-11.00, 1.22), (-11.00, -3.70)),
)

FOREWING = Wing(FOREWING_COSTA, FOREWING_ANAL, FOREWING_ARC, FOREWING_APICAL,
                FOREWING_STEMS, FOREWING_BRANCHES, FOREWING_CROSS, ambient=(6, 6))
HINDWING = Wing(HINDWING_COSTA, HINDWING_ANAL, HINDWING_ARC, HINDWING_APICAL,
                HINDWING_STEMS, HINDWING_BRANCHES, HINDWING_CROSS, ambient=(4, 5))

# 休止姿勢。翅は屋根形（テント状）に畳んで腹部の上にかぶせる。
# 展翅した標本のように広げたいときは FOREWING_TENT を 0 付近まで下げる。
FOREWING_BASE = (3.60, 4.70, 3.40)
FOREWING_TENT = 48.0     # 体軸まわりの傾き（度）。前縁側が下がる
FOREWING_SPLAY = 4.0     # 翅端を外側へ振る角（度）
HINDWING_BASE = (1.60, 5.00, 3.20)
HINDWING_TENT = 56.0
HINDWING_SPLAY = 1.0

# ---- 色 ----------------------------------------------------------------
# アブラゼミは全身が黒褐色で、翅も半透明の褐色を帯びる（セミには珍しく不透明）。
BODY_COLOR = cq.Color(0.17, 0.15, 0.12)
EYE_COLOR = cq.Color(0.09, 0.07, 0.06)
LEG_COLOR = cq.Color(0.36, 0.27, 0.16)
MEMBRANE_COLOR = cq.Color(0.74, 0.62, 0.45, 0.55)
VEIN_COLOR = cq.Color(0.43, 0.31, 0.17)


# ---- 汎用 --------------------------------------------------------------
def _solids(parts: list[cq.Workplane]) -> list[cq.Shape]:
    """Workplane の並びから中身のソリッドを取り出して平らに並べる。"""
    solids: list[cq.Shape] = []
    for part in parts:
        for shape in part.vals():
            if isinstance(shape, cq.Shape):
                solids.extend(shape.Solids() or [shape])
    return solids


def _fuse(parts: list[cq.Workplane]) -> cq.Workplane:
    """重なり合うソリッドを 1 つに融合する。

    `union` を数珠つなぎにすると部品数に対して時間が跳ね上がるので、
    ソリッドを集めて OCCT のマルチ融合に 1 度だけ渡す。

    **離れたソリッドをここに渡してはいけない。** OCCT のマルチ融合は
    触れていないソリッドが混じると結果を空にすることがある（翅脈 100 個で
    実際に消えた）。並べるだけでよいときは `_group` を使う。
    """
    solids = _solids(parts)
    if len(solids) == 1:
        return cq.Workplane().newObject(solids)
    fused = solids[0].fuse(*solids[1:]).clean()
    if not fused.Solids():
        # 黙って空を返されると形が消えたことに気づけないので、ここで止める。
        raise RuntimeError(
            f"{len(solids)} 個のソリッドの融合が空になりました。"
            "離れたものを渡していないか（→ _group）、接するだけで重なっていないか"
            "（→ _relief）を確認してください"
        )
    return cq.Workplane().newObject([fused])


def _group(parts: list[cq.Workplane]) -> cq.Workplane:
    """離れたソリッドを 1 つの形にまとめる（融合はしない）。

    左右の脚や 4 枚の翅のように触れ合わないもの同士に真偽演算をかける意味はない。
    作品は最後にコンパウンドとして書き出されるので、束ねるだけで足りる。
    """
    return cq.Workplane().newObject([cq.Compound.makeCompound(_solids(parts))])


def _mirrored(part: cq.Workplane) -> cq.Workplane:
    """右半身のパーツを左右対称にする（対称面は XZ）。"""
    return _group([part, part.mirror("XZ")])


def _taper(p1, p2, d1: float, d2: float) -> cq.Workplane:
    """2 点を結ぶテーパ丸棒。脚の各節や口吻のように先細りする部分に使う。

    2 つの円をロフトせず円錐台で作る。ロフトの結果は NURBS 面になって
    STEP が重くなるが、円錐なら厳密な曲面 1 枚で済む。
    """
    a, b = cq.Vector(*p1), cq.Vector(*p2)
    axis = b - a
    return cq.Workplane().newObject(
        [cq.Solid.makeCone(d1 / 2, d2 / 2, axis.Length, a, axis)]
    )


def _ball(p, dia: float) -> cq.Workplane:
    return cq.Workplane("XY").sphere(dia / 2).translate(p)


def _ellipsoid(center, rx: float, ry: float, rz: float, sections: int = 9) -> cq.Workplane:
    """3 軸半径の異なる楕円体。X 方向に楕円断面を積んでロフトする。

    断面の x は等間隔ではなく角度で刻む。等間隔にすると両端が円錐状に尖る。
    """
    cx, cy, cz = center
    wp = cq.Workplane("YZ", origin=(cx - rx, cy, cz))
    previous = None
    for i in range(sections):
        angle = math.pi * i / (sections - 1)
        x = -rx * math.cos(angle)
        scale = max(0.04, math.sin(angle))
        if previous is not None:
            wp = wp.workplane(offset=x - previous)
        previous = x
        wp = wp.ellipse(ry * scale, rz * scale)
    return wp.loft(ruled=True)


# ---- 胴体 --------------------------------------------------------------
def _section(half_w: float, up: float, down: float) -> list[tuple[float, float]]:
    """胴体の断面。背側と腹側で高さの違う超楕円にする。

    セミの断面は楕円ではない。背が高く腹が浅く、しかも側面が立ち上がって
    肩が張る。上半分と下半分で半径を変えたうえ、`|y/a|^n + |z/b|^n = 1`
    の超楕円（n = SECTION_EXPONENT）にして側面を起こしている。
    """
    power = 2.0 / SECTION_EXPONENT
    points = []
    for i in range(SECTION_POINTS):
        angle = 2 * math.pi * i / SECTION_POINTS
        cos_a, sin_a = math.cos(angle), math.sin(angle)
        radius = up if sin_a >= 0 else down
        points.append((
            half_w * math.copysign(abs(cos_a) ** power, cos_a),
            radius * math.copysign(abs(sin_a) ** power, sin_a),
        ))
    return points


def _profile_at(x: float) -> tuple[float, ...]:
    """断面表を線形補間して、x での（半幅, 背側の高さ, 腹側の深さ）を返す。"""
    for near, far in zip(STATIONS, STATIONS[1:]):
        if near[0] <= x <= far[0]:
            k = (x - near[0]) / (far[0] - near[0])
            return tuple(a + (b - a) * k for a, b in zip(near[1:], far[1:]))
    raise ValueError(f"x={x:.2f} は断面表の範囲外")


@lru_cache(maxsize=None)
def _body_stations() -> tuple[tuple[float, float, float, float], ...]:
    """断面表に体節の段を差し込み、x の昇順に並べ直す。

    境ごとに 3 断面を差し込む。「前の節の後縁（全幅）」と、SEGMENT_EDGE だけ
    後ろの「次の節の前縁（細い）」で鋭い段差ができ、節が瓦のように重なる。
    さらに SEGMENT_RISE だけ後ろに全幅の断面を置いて、節の背が手前で
    立ち上がりきり、そこから先は平らになるようにする。これがないと節ぜんぶが
    円錐になって、蛇腹のように見えてしまう。段に飲み込まれる元の
    ステーションは捨てる。
    """
    inserted = []
    for joint, step in SEGMENT_JOINTS:
        for x, drop in (
            (joint + SEGMENT_EDGE / 2, 0.0),
            (joint - SEGMENT_EDGE / 2, step),
            (joint - SEGMENT_EDGE / 2 - SEGMENT_RISE, 0.0),
        ):
            inserted.append((x, *(v - drop for v in _profile_at(x))))
    kept = [s for s in STATIONS if all(abs(s[0] - i[0]) > SEGMENT_EDGE for i in inserted)]
    return tuple(sorted(kept + inserted))


@lru_cache(maxsize=None)
def _body(grow: float = 0.0) -> cq.Workplane:
    """胴体の外形。grow だけ断面を太らせる（負なら細らせる）。

    表面の造作を「太らせた形と細らせた形で挟む」ために何度も呼ぶので
    キャッシュする。断面表を +X 方向へロフトして 1 つのソリッドにする。

    ロフトは `ruled=True`（ステーション間を直線でつなぐ）にしてある。
    滑らかに通す `ruled=False` だと胴体全体が 1 枚の巨大な NURBS 曲面になり、
    そこへ体節の溝を切ると分割された面の 1 枚ごとに元の曲面全体が写し取られて、
    STEP が 67 MB まで膨らんだ。ステーションの間隔は 2 mm 前後なので、
    帯に分けても稜は見た目に出ない。
    """
    stations = _body_stations()
    wp = cq.Workplane("YZ", origin=(stations[0][0], 0, 0))
    previous = None
    for x, half_w, up, down in stations:
        if previous is not None:
            wp = wp.workplane(offset=x - previous)
        previous = x
        wp = wp.spline(
            _section(
                max(0.08, half_w + grow),
                max(0.08, up + grow),
                max(0.08, down + grow),
            ),
            periodic=True,
        ).close()
    return wp.loft(ruled=True)


def _shell_patch(region: cq.Workplane, proud: float, thickness: float) -> cq.Workplane:
    """柱状の region を、体表に沿った薄板として切り出す。

    体表から proud だけ外側で止め、そこから thickness ぶん内側を抜く。
    proud = thickness なら体表から浮いた隆起になり、proud = 0 なら
    体表から thickness だけ内側の窪みになる（そのまま cut に渡せる）。
    X 字状隆起・肩の隆起帯・側板痕・腹弁・鼓室蓋がこれ 1 つで作れる。
    """
    return region.intersect(_body(proud)).cut(_body(proud - thickness))


def _relief(region: cq.Workplane, proud: float) -> cq.Workplane:
    """体表から proud だけ盛り上がった隆起。

    内側は体表で止めず、PATCH_BITE だけ体に食い込ませる。ぴったり接するだけの
    ソリッドは OCCT のマルチ融合が落とすことがあり、実際にこれで胴体が
    丸ごと消えた。少し重ねておけば確実に融合する。
    """
    return _shell_patch(region, proud, proud + PATCH_BITE)


def _dorsal_slot(x: float, length: float, y: float, width: float,
                 angle: float = 0.0) -> cq.Workplane:
    """背面の彫りを切り出すための、上から降ろした角丸の柱。

    下端を DORSAL_BASE_Z に置いて、体の側面を巻き込まないようにする。
    """
    return (
        cq.Workplane("XY", origin=(x, y, DORSAL_BASE_Z))
        .slot2D(length, width, angle)
        .extrude(12.0)
    )


def _pronotal_furrows() -> cq.Workplane:
    """前胸背板の正中線をはさむ 1 対の縦溝（cut で使う）。"""
    slots = [
        _dorsal_slot(PRONOTUM_FURROW_X, PRONOTUM_FURROW_LEN, sign * PRONOTUM_FURROW_Y,
                     PRONOTUM_FURROW_W)
        for sign in (1, -1)
    ]
    return _shell_patch(_group(slots), 0.0, PRONOTUM_FURROW_DEPTH)


def _mesonotal_ridge() -> cq.Workplane:
    """中胸背板の正中線を走る稜。"""
    region = _dorsal_slot(MESONOTUM_RIDGE_X, MESONOTUM_RIDGE_LEN, 0.0, MESONOTUM_RIDGE_W)
    return _relief(region, MESONOTUM_RIDGE_PROUD)


def _mesonotal_sigilla() -> cq.Workplane:
    """稜をはさむ 1 対の大きな窪み（側板痕）。後方へすぼまる（cut で使う）。"""
    slots = [
        _dorsal_slot(SIGILLUM_X, SIGILLUM_LEN, sign * SIGILLUM_Y, SIGILLUM_W,
                     sign * SIGILLUM_ANGLE)
        for sign in (1, -1)
    ]
    return _shell_patch(_group(slots), 0.0, SIGILLUM_DEPTH)


def _cruciform() -> cq.Workplane:
    """中胸背板後端の X 字状隆起。"""
    arms = [
        cq.Workplane("XY", origin=(CRUX_X, 0, CRUX_BASE_Z))
        .slot2D(CRUX_ARM_LEN, CRUX_ARM_WIDTH, sign * CRUX_ANGLE)
        .extrude(12.0)
        for sign in (1, -1)
    ]
    return _relief(_fuse(arms), CRUX_PROUD)


def _tymbal_covers() -> cq.Workplane:
    """発音器のある腹部第 1 節側面のふくらみ（鼓室蓋）。"""
    x, _, z = TYMBAL_CENTER
    # XZ 平面の押し出し方向は −Y なので、負の距離で右半身（+Y 側）だけを貫く柱にする。
    region = cq.Workplane("XZ", origin=(x, 0, z)).circle(TYMBAL_DIA / 2).extrude(-20.0)
    return _mirrored(_relief(region, TYMBAL_PROUD))


def _opercula() -> cq.Workplane:
    """雄の腹弁。腹面の付け根で聴器を覆う一対の板。"""
    x, y = OPERCULUM_CENTER
    region = (
        cq.Workplane("XY", origin=(x, y, 0))
        .ellipse(OPERCULUM_LEN / 2, OPERCULUM_WIDTH / 2)
        .extrude(-12.0)
    )
    return _mirrored(_shell_patch(region, OPERCULUM_PROUD, OPERCULUM_THICK))


# ---- 頭部 --------------------------------------------------------------
def _eyes() -> cq.Workplane:
    """左右の複眼。

    球ではなく前後にわずかに長い楕円体で、頭部の前側角から前外側へ張り出す。
    上から見たとき頭の幅の大半を複眼が占めるのが、セミの頭の見え方。
    """
    return _mirrored(_ellipsoid(EYE_CENTER, *EYE_RADII))


def _ocelli() -> cq.Workplane:
    """頭頂の 3 単眼。中央 1 個と左右 1 対が三角形に並ぶ。"""
    x, y, z = OCELLUS_LATERAL
    return _group([
        _ball(OCELLUS_MEDIAN, OCELLUS_DIA),
        _mirrored(_ball((x, y, z), OCELLUS_DIA)),
    ])


def _clypeus() -> tuple[cq.Workplane, cq.Workplane]:
    """後唇基節のふくらみと、その表面に並ぶ横溝を返す。"""
    bulge = _ellipsoid(
        CLYPEUS_CENTER, CLYPEUS_LEN / 2, CLYPEUS_WIDTH / 2, CLYPEUS_DEPTH / 2
    )
    x0 = CLYPEUS_CENTER[0]
    pitch = CLYPEUS_LEN / (CLYPEUS_GROOVES + 1)
    slabs = [
        cq.Workplane("YZ", origin=(x0 - CLYPEUS_LEN / 2 + pitch * (i + 1), 0, 0))
        .rect(20.0, 20.0)
        .extrude(CLYPEUS_GROOVE_W)
        for i in range(CLYPEUS_GROOVES)
    ]
    inner = _ellipsoid(
        CLYPEUS_CENTER,
        CLYPEUS_LEN / 2 - CLYPEUS_GROOVE_D,
        CLYPEUS_WIDTH / 2 - CLYPEUS_GROOVE_D,
        CLYPEUS_DEPTH / 2 - CLYPEUS_GROOVE_D,
    )
    grooves = _group(slabs).intersect(bulge).cut(inner)
    return bulge, grooves


def _rostrum() -> cq.Workplane:
    """口吻。頭部腹面から後方へ伸び、後脚基節のあたりまで届く。"""
    return _fuse([
        _taper(p, q, d1, d2)
        for p, q, d1, d2 in zip(ROSTRUM_PATH, ROSTRUM_PATH[1:], ROSTRUM_DIA, ROSTRUM_DIA[1:])
    ])


def _antennae() -> cq.Workplane:
    """短い剛毛状の触角。"""
    return _mirrored(_fuse([
        _taper(p, q, d1, d2)
        for p, q, d1, d2 in zip(ANTENNA_PATH, ANTENNA_PATH[1:], ANTENNA_DIA, ANTENNA_DIA[1:])
    ]))


# ---- 脚 ----------------------------------------------------------------
def _leg(joints, diameters, with_teeth: bool = False) -> cq.Workplane:
    """関節座標の列から 1 本の脚を作る。

    diameters は (節の根元径, 節の先端径) を節の数だけ並べたもの。
    関節には球を置いて、節どうしの継ぎ目を丸める。
    """
    parts: list[cq.Workplane] = []
    for i in range(len(joints) - 1):
        d1, d2 = diameters[2 * i], diameters[2 * i + 1]
        parts.append(_taper(joints[i], joints[i + 1], d1, d2))
        if i > 0:
            parts.append(_ball(joints[i], d1))

    if with_teeth:
        # 前脚腿節の腹側に並ぶ歯。幼虫時代に土を掘るための構造で、成虫にも残る。
        base, tip = cq.Vector(*joints[1]), cq.Vector(*joints[2])
        for t in FEMUR_TOOTH_AT:
            root = base + (tip - base) * t
            parts.append(
                _taper(
                    root.toTuple(),
                    (root.x + FEMUR_TOOTH_LEN * 0.5, root.y, root.z - FEMUR_TOOTH_LEN),
                    FEMUR_TOOTH_DIA,
                    0.12,
                )
            )

    # 跗節先端の 2 本爪
    tip, prev = cq.Vector(*joints[-1]), cq.Vector(*joints[-2])
    direction = (tip - prev).normalized()
    for sign in (1, -1):
        end = tip + direction * CLAW_LEN + cq.Vector(0, sign * CLAW_SPREAD, -CLAW_LEN * 0.4)
        parts.append(_taper(tip.toTuple(), end.toTuple(), CLAW_DIA, 0.10))

    return _fuse(parts)


def _legs() -> cq.Workplane:
    return _mirrored(
        _group([
            _leg(FORE_LEG, FORE_LEG_DIA, with_teeth=True),
            _leg(MID_LEG, MID_LEG_DIA),
            _leg(HIND_LEG, HIND_LEG_DIA),
        ])
    )


# ---- 翅 ----------------------------------------------------------------
def _inset_path(points, inset: float) -> list[tuple[float, float]]:
    """外縁の点列を、翅の内側へ inset だけ寄せた点列にする。

    輪郭は前縁→翅端→後縁の順に並ぶ（上を左へ、下を右へ回る＝反時計回り）ので、
    進行方向の左手が翅の内側になる。
    """
    inset_points = []
    for i, (x, y) in enumerate(points):
        px, py = points[max(i - 1, 0)]
        qx, qy = points[min(i + 1, len(points) - 1)]
        dx, dy = qx - px, qy - py
        length = math.hypot(dx, dy)
        inset_points.append((x - inset * dy / length, y + inset * dx / length))
    return inset_points


def _point_at(path, t: float) -> tuple[float, float]:
    """折れ線 path を全長の t（0〜1）だけ進んだ点。"""
    spans = [math.dist(a, b) for a, b in zip(path, path[1:])]
    target = sum(spans) * t
    for (x1, y1), (x2, y2), span in zip(path, path[1:], spans):
        if target <= span:
            k = target / span
            return (x1 + (x2 - x1) * k, y1 + (y2 - y1) * k)
        target -= span
    return path[-1]


def _vein_paths(wing: Wing) -> list:
    """1 枚ぶんの翅脈の経路をすべて並べる。

    弧（端室を仕切る横脈）、その節から外へ出る脈、基部からの縦脈、縦脈と弧を
    つなぐ分岐、基半部の横脈、そして外縁に沿う縁紋脈。この 6 種で翅脈網になる。
    """
    costa_from, anal_to = wing.ambient
    ambient = _inset_path(
        list(wing.costa[costa_from:]) + list(wing.anal[1:anal_to]), AMBIENT_INSET
    )

    paths: list = [wing.arc, ambient]
    paths.extend((wing.arc[i + 1], _point_at(ambient, t)) for i, t in enumerate(wing.apical))
    paths.extend(wing.stems)
    paths.extend(wing.branches)
    paths.extend(wing.cross)
    return paths


def _vein_wires(wp: cq.Workplane, paths, width: float) -> cq.Workplane:
    """翅脈の経路を、幅 width の帯（長方形＋節の円）として平面に描く。

    経路を太らせる `offset2D` は共線の点列で退化してしまうので、
    辺ごとに長方形を、折れ点に円を置くやり方にしてある。重なった閉輪郭は
    押し出しのときに 1 つのソリッドへ融合される。
    """
    for path in paths:
        for (x1, y1), (x2, y2) in zip(path, path[1:]):
            dx, dy = x2 - x1, y2 - y1
            length = math.hypot(dx, dy)
            nx, ny = -dy / length * width / 2, dx / length * width / 2
            wp = wp.polyline([
                (x1 + nx, y1 + ny), (x2 + nx, y2 + ny),
                (x2 - nx, y2 - ny), (x1 - nx, y1 - ny),
            ]).close()
        for x, y in path[1:-1]:
            wp = wp.moveTo(x, y).circle(width / 2)
    return wp


def _wing_outline(costa, anal) -> cq.Workplane:
    """前縁と後縁の 2 本のスプラインで翅の輪郭を閉じる。

    最後の直線は基部（胸との関節）にあたる。
    """
    return (
        cq.Workplane("XY")
        .moveTo(*costa[0])
        .spline(costa[1:])
        .spline(anal[1:])
        .close()
    )


def _wing(wing: Wing) -> tuple[cq.Workplane, cq.Workplane]:
    """平面図から翅を作り、（膜, 翅脈）を返す。

    膜は輪郭をそのまま押し出す。翅脈は膜より厚く押し出して、膜の両面へ
    等しく張り出させる。縁に達する脈（前縁脈・縁紋脈・臀脈）は帯の太さの
    半分だけ輪郭からはみ出すが、これは実物でも縁が脈で縁取られている形なので
    輪郭で切り取らない。切り取ると脈網が数百個のソリッドに割れてしまう。
    """
    membrane = _wing_outline(wing.costa, wing.anal).extrude(MEMBRANE_T)

    vein_z = -(VEIN_T - MEMBRANE_T) / 2
    veins = _vein_wires(
        cq.Workplane("XY", origin=(0, 0, vein_z)), _vein_paths(wing), VEIN_WIDTH
    ).extrude(VEIN_T)
    return membrane, veins


def _place_wing(part: cq.Workplane, base, tent: float, splay: float) -> cq.Workplane:
    """平面で作った翅を休止姿勢へ回して置く。

    体軸まわりに tent 傾けると屋根形になり、鉛直軸まわりに splay 振ると
    翅端が外へ開く。左右の翅は最後に鏡像で作る。
    """
    return (
        part.rotate((0, 0, 0), (1, 0, 0), -tent)
        .rotate((0, 0, 0), (0, 0, 1), -splay)
        .translate(base)
    )


def _wings() -> tuple[cq.Workplane, cq.Workplane]:
    """左右 2 対の翅を（膜, 翅脈）に分けて返す。"""
    fore_m, fore_v = _wing(FOREWING)
    hind_m, hind_v = _wing(HINDWING)

    membranes = [
        _place_wing(fore_m, FOREWING_BASE, FOREWING_TENT, FOREWING_SPLAY),
        _place_wing(hind_m, HINDWING_BASE, HINDWING_TENT, HINDWING_SPLAY),
    ]
    veins = [
        _place_wing(fore_v, FOREWING_BASE, FOREWING_TENT, FOREWING_SPLAY),
        _place_wing(hind_v, HINDWING_BASE, HINDWING_TENT, HINDWING_SPLAY),
    ]
    return _mirrored(_group(membranes)), _mirrored(_group(veins))


# ---- 組み立て ----------------------------------------------------------
def build() -> cq.Assembly:
    clypeus, clypeus_grooves = _clypeus()

    # 体節の段は断面表が持っているので、ここで彫るのは表面の彫刻だけ。
    body = _fuse([_body(0.0), clypeus])
    body = body.cut(_group([clypeus_grooves, _pronotal_furrows(), _mesonotal_sigilla()]))
    body = _fuse([
        body, _mesonotal_ridge(), _cruciform(), _tymbal_covers(), _opercula(),
    ])

    membranes, veins = _wings()

    assembly = (
        cq.Assembly(name="cicada")
        .add(body, name="body", color=BODY_COLOR)
        .add(_group([_eyes(), _ocelli()]), name="eyes", color=EYE_COLOR)
        .add(_group([_legs(), _rostrum(), _antennae()]), name="legs", color=LEG_COLOR)
        .add(membranes, name="wing-membrane", color=MEMBRANE_COLOR)
        .add(veins, name="wing-veins", color=VEIN_COLOR)
    )
    return assembly


if __name__ == "__main__":
    cq.exporters.export(build().toCompound(), "preview.stl")
