アブラゼミ
7 面の標本写真から起こした、体長 36 mm・翅端まで 59 mm の実寸モデル。
制作メモ
読み取り
展翅した標本の 7 方向写真から、セミ科の体制をひととおり読み取れた。 上・下面図で平面の輪郭と翅脈を、側面図で背面の隆起と腹部の断面を、 斜めの 2 枚で翅の畳み方を取っている。
- 体は頭部・胸部(前胸背板+中胸背板)・腹部の 3 部。最も高く広いのは中胸背板
- 前胸背板の後縁が帯状に隆起して側方へ張り出し、「肩」を作る。上面図で いちばん目を引く構造。その前には正中線をはさむ 1 対の縦溝
- 中胸背板は大きく盛り上がり、正中線の稜と、それをはさむ 1 対の大きな窪み (側板痕)がある。後端に X 字状隆起。セミ科をひと目で見分ける特徴
- 頭部は前後に短く横に広い板。複眼は前側角から前外側へ張り出し、上から見ると 頭幅の大半を占める。頭頂の複眼の間に 3 個の単眼が三角形に並ぶ
- 腹部の各節は瓦のように重なる。後ろの節の前縁が前の節の後縁の下へ潜り込み、 節の境が段になって見える
- 下面図の顔の中央を占める 後唇基節の隆起と、そこに並ぶ横溝
- 下面図の正中線を後方へ走る 口吻。後脚基節のあたりまで届く
- 触角は短い剛毛状で、複眼の内側前方から出る
- 腹部は 8 節が見え、末端に向かって細くなる。第 1 節の側面に発音器の蓋、 腹面の付け根に一対の 腹弁(=雄個体)
- 前脚の腿節が太く、腹側に歯を持つ(幼虫時代に土を掘る構造の名残)。後脚が最も長い
- 前翅は基部から径脈・中脈・肘脈・臀脈が出て、外側 1/3 で横脈の弧に集まり、 その外に 端室が 8 室並ぶ。さらに外縁に沿って縁紋脈が 1 本走る
- 後翅は前翅の 6 割ほどの長さで、臀域が大きく張り出す。端室は 6 室
写真から読み取れないのは内部構造(気管・筋肉・発音膜そのもの)と、体表の 淡色斑の分布。前者はモデル化せず、後者は色ではなく形だけを扱った。
基準寸法
写真には寸法が写っていないので、図鑑値の 体長(頭端〜腹端)36 mm を 基準に置き、残りは写真上の比率で決めた。幅は上面図の輪郭を 1 行ずつ実測して 比を取っている。そこで分かったのは、セミのいちばん広いところは腹部の 付け根で、胸部より広いこと。ここを紡錘形にすると、細部をどれだけ作り込んでも ハチのように見えてしまう。
出来上がりは 60.3 × 24.6 × 22.4 mm、頭端から翅端までが 58.9 mm、 胸部・腹部の最大幅が 13.9 mm。アブラゼミの全長 53〜60 mm に収まる。 縮尺を変えるときは、体軸の座標をまとめて掛け直せばよい。
モデリング方針
- 胴体は断面表 1 枚。体軸に沿った
(x, 半幅, 背側の高さ, 腹側の深さ)を 並べてロフトする。断面は背が高く腹が浅い超楕円で、側面が立ち上がる。 真円でも楕円でも、昆虫の「肩の張った」断面にはならない - 体節の段と前胸背板の肩は断面表そのものに持たせた。境ごとに 3 断面 (後縁=全幅/前縁=細い/少し後ろで全幅に戻る)を差し込むと、節が瓦のように 重なった形がロフトから直接出てくる。あとから溝を彫る作り方だと、 重なりが出ないうえ胴体の面が節の数だけ割れて STEP が数倍に膨らむ
- 断面表には太らせ量
growを渡せるようにしてあり、少し大きい/小さい 胴体をいつでも作れる。これで表面の彫刻は柱 ∩ 胴体(+出っ張り) − 胴体(−食い込み)という同じ式 1 つで書ける。X 字状隆起・中胸背板の稜・前胸背板の縦溝・ 側板痕・鼓室蓋・腹弁がすべてこの式(_shell_patch/_relief)で出てくる - 脚は基節→転節→腿節→脛節→跗節の関節座標を並べ、テーパ丸棒でつなぐ。 姿勢を変えたいときは座標を動かすだけで済む。6 本の跗節先端は同じ高さに 揃えてあるので、モデルは自立する
- 翅は平面図として設計した。前縁・後縁の 2 本のスプラインで輪郭を閉じ、 翅脈は経路を幅 0.42 mm の帯に太らせて膜より厚く押し出す。端室を作る 分岐脈の終点は「縁紋脈に沿った割合」で指定してあるので、数字を等間隔に 並べれば室の幅も揃う
- 出来た翅は体軸まわりに傾け(屋根形)、鉛直軸まわりに振ってから胸に置く。
FOREWING_TENTを 0 に近づければ、元画像のように展翅した姿勢になる - 離れたソリッド同士に真偽演算はかけない(
_group)。OCCT のマルチ融合は 触れていないソリッドが混ざると結果を空にすることがあり、実際に翅脈が まるごと消えた。融合は重なり合うものだけに使う(_fuse)。 隆起も体表にぴったり接するだけだと同じ理由で落ちるので、_reliefが 0.3 mm だけ体に食い込ませている。_fuseは結果が空なら例外を投げる - 胴体・複眼・脚/口吻/触角・翅の膜・翅脈の 5 パーツに分けて
cq.Assemblyで返す
出力を軽くするために直したこと
胴体のロフトを最初は ruled=False(断面の間を滑らかに通す)で作っていたが、
これだと胴体全体が 1 枚の巨大な NURBS 曲面になる。そこへ体節の溝を切ると
分割された面の 1 枚ごとに元の曲面全体が写し取られ、STEP が 71 MB まで
膨らんだ。ステーション間を直線でつなぐ ruled=True に変えて 15 MB になり、
ステーションの間隔は 2 mm 前後なので見た目は変わらない。
脚のテーパも、2 つの円のロフト(NURBS 面)から円錐台(厳密な曲面 1 枚)に
置き換えている。
割り切ったところ
- 翅の厚みは膜 0.35 mm・翅脈 0.95 mm と、実物(膜 0.02 mm 前後)より
15 倍以上厚い。1 mm 未満の膜を実寸で持たせても造形にならないため。
薄くしたいときは
MEMBRANE_TとVEIN_Tの 2 つを下げればよい - アブラゼミの体表を彩る淡色斑(中胸背板の W 字状の白斑など)は入れていない。 形ではなく色の模様なので、単色のモデルでは表現できない
- 翅は平らな板として作った。実物はわずかに反り、翅脈に沿って波打つ
- 脛節の小さな棘、跗節の節分け、腹端の交尾器はモデル化していない。 外形の印象を変えず、細かすぎて造形にも残らないため
- 発音器は蓋(鼓室蓋)の形だけで、中の発音膜と共鳴室は作っていない
- 静止姿勢のみ。翅の関節は形だけで、可動機構は持たせていない
元画像
CADQuery ソース
"""アブラゼミ(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")
