# サービスロボット導入シミュレーション Log#45 | 受け渡しを寄りで見せる、ハコブくんと看護師の3D

- 記録: LOG496（サービスロボット導入シミュレーション）
- 公開: 2026/11/13
- 記事のページ: https://pixelstomotors.com/record/hakobu-nurse-3d-models-for-handover-closeups
- サイト: Pixels to Motors（ADAPT DESIGN / 島津 栄一） https://pixelstomotors.com/
- 要約: 病院の搬送ロボットと看護師の受け渡しを近くで見せるため、ハコブくんと看護師を Unity で動かせる実寸の3Dにした。看護師には骨45本と口5つを入れ、腕を上げても袖が裂けない形にした。歩く動きは Mixamo から33本借りた。

> AI が読むための記事の全文です（コード・実行ログを含む）。引用するときは上の「記事のページ」の住所を使ってください。

---

病院の搬送ロボットが看護師と薬を受け渡す場面を、シミュレーションの中で近くから見せるために、4コママンガのロボット「ハコブくん」と看護師の大城さんを、Unity で動かせる実寸の3Dモデルにした。**看護師には Unity の人型の決まりどおりの骨45本と、5つの口の形を入れ、引き出しを開ける・両手で受け取る・おじぎする・困るといった姿勢を、服や肌を裂かずに付けられるようにした。** どちらも実物のロボットや人ではなく、3DCG の中の話です。

この回で良くなったこと、できるようになったことです。

- 受け渡しの手元と顔が見せられるようになった。これまでの病院の1日のシミュレーションでは、人は青い筒、ロボットは白い箱で、真上から見るだけだった。今回の2つのモデルは同じ見た目の決まりで作ったので、横に並べても浮かない
- 形を言葉で直せるようになった。体の寸法はすべて Python のコードの中の数字で、骨の位置もその数字から決まる。「首が長い」「腕が長い」と言われたら数字を変えて作り直すだけで、骨も一緒に合う。看護師の形は、この方法で15回作り直した
- 歩く動きを自分で作らずにすむ。Adobe の Mixamo から、歩く・曲がる・うなずく・話す・おじぎなど33本の動きを借りた（Adobe の決まりで、動きのファイルは公開しない）
- 作業の時間は、前の方法と比べられる数字を測っていない。Unity で場面を撮る依頼（依頼162）を回している途中で、かかった時間はその報告で確かめる

困っていたのは、人とロボットのやり取りが、今までの見せ方では伝わらないことでした。病院の1日のシミュレーションでは、ロボットが何回止まったか、だれとすれ違ったかは数えられます。けれど、看護師が引き出しのどこを持って、どんな顔でロボットに話しかけるのかは、筒と箱では分かりません。

> 試算、全体のシミュレーション、シチュエーションの細部表現、実際のロボットでリアルな動画構築といった具合に全体が構築されていければより伝わりやすくなると考えます。

この回は、その3段目（場面の細部）の始まりです。

![引き出しを開けたハコブくんの前で、看護師の大城さんが少しかがんでいる3DCG。明るい灰色の地に2人だけが立っている](https://pixelstomotors.com/media/LOG496_01_handover_scene.png)

| 何を | なぜ | どうなったか |
|---|---|---|
| ハコブくんの体の角とタイヤを丸め、横に厚い板を足した | 人の3Dと並べると、三面図どおりの角が硬く見えた | 三角形2,728 から5,804。頭と顔の画面は三面図のまま |
| 看護師の大城さんの3Dを、マンガの三面図から作った | 受け渡しの手元と顔を見せる人がいなかった | 身長1.65m・三角形52,592。粘土のような簡略化した見た目 |
| 骨45本・目の骨・口5つを入れた | Unity で姿勢と表情を付けて動かすため | 8つの姿勢を骨で付けて、服と肌が破れないかを確かめた |
| 袖を別の部品にし、まるごと上腕の骨に付けた | 腕を前に出すと袖が裂け、上げると袖の山がゆがんだ | 腕を上げても袖の形が変わらない |
| Mixamo から動きを33本借りた | 歩く動きの元がなかった | 歩く・曲がる・うなずく・話す・おじぎなど。公開はしない |
| Unity で、薬を積む場面と受け取る場面を作る依頼を出した | 3Dを実際の場面で動かして撮るため | 依頼162 を回している途中。歩かせる依頼163 は下書き |

[図解: LOG496_sleeve_weights.html](https://pixelstomotors.com/embeds/LOG496_sleeve_weights.html)

病院の搬送ロボットのほかに、配膳ロボットを自分の店に入れたらどうなるかは、このシリーズの試算をもとにした別のページで試せます。

[配膳ロボットの導入前シミュレーション](https://pixelstomotors.com/serving-robot-one-less-staff)


## 作業ログ ― 病院の場面を「試算・1日・寄り・実物」の4段で見せる

サービスロボット導入シミュレーションは、配膳ロボットのファミレスのあと、病院で検体や薬を運ぶ搬送ロボットの1日を Unity で動かしてきました。前の回（[LOG491](https://pixelstomotors.com/record/hospital-transport-robot-conflicts-came-from-placeholder-staff-moves)）では、職員141人の動かし方を持ち場ごとの型に替え、ロボットが走っている間の問題を1日68件から19件にしました。

病院の1日は、真上から見て数を数えるための場面です。人は青い筒、ロボットは白い箱で描いています。数は出せても、その1件1件で何が起きているのかは見えません。そこで、場面を切り取って近くで見せる段を、1日のシミュレーションとは別に作ることにしました。

> この3Dでリアルな形は、今までのシミュレーションより、現場のより具体的なシチュエーションの場面を切り取って見せるために使おうと思います。モノを渡す、受け取る、トラブルの時の動き、コミュニケーションの姿などを表現できたらと思います。

| 段 | 見せること | 道具 | いまの状態 |
|---|---|---|---|
| 1. 試算 | 何台で、何人分の手間が浮くか | Python の試算（tools/hospital_sim/） | 済み（前の回まで） |
| 2. 全体のシミュレーション | 1日の動きと、問題の数 | Unity の病院の1日（真上から） | 回している |
| 3. 場面の細部 | 渡す・受け取る・トラブル・話しかける | Unity の場面の寄り（Closeups） | この回で始めた |
| 4. 実物の動画 | 実際のロボットで同じ場面 | 実機 | まだ |

3段目に使うのは、マンガ『ハコブくん』の2人です。ロボットのハコブくんと、看護師の大城さん。マンガの三面図がすでにあるので、形をゼロから決めずにすみます。


## 作業ログ ― ハコブくんの3Dを、人と並ぶ形に丸めた

### Blender は、3DCG を作る無料のソフト

[Blender](https://www.blender.org/) は、3Dの形を作り、色を付け、絵に描き出す（レンダリングする）ための無料のソフトです。この回では画面を開かず、Python から Blender を動かしています（`pip install bpy` で入る、Blender を Python の部品にしたもの）。

| 言葉 | 意味 |
|---|---|
| メッシュ | 三角形をつないだ形。三角形の数が多いほど細かいが、Unity で重くなる |
| FBX | 形と骨と動きを1つにまとめるファイルの形式。Unity がそのまま読める |
| モディファイア | 形に後からかける加工（三角形を減らす・角を丸めるなど） |
| Cycles | Blender の中の、光の当たり方を計算して絵を描く仕組み |

このプロジェクトでは、形も確認用の絵もすべてコードで作り、コードを GitHub（p2m-site の tools/）に置いています。同じコードを動かせば、同じ形ができます。

### ハコブくんの3Dモデルは、三面図から測った実寸の箱だった

ハコブくんの3Dは、先に三面図から作ってありました（p2m-site の 92e9b80）。三面図の外形を測り、幅0.653・奥行0.760・高さ1.150m の実寸で、三角形は2,728。軽く、Unity の病院でそのまま走らせられる形です。

ところが、看護師の3D（次の節）が粘土のような丸い見た目になると、並べた時にハコブくんの角だけが硬く見えました。そこで体の角を丸めることにしました。

- ねらい：人と並べた時に、同じ世界の物に見えるようにする
- 変わったこと：胴の縦の角を三面図の3.5倍の半径に丸め、台・黒い帯・引き出しの前の板・トレーの角も丸めた。タイヤは太くして角を丸め、上半分を台の中に隠した。横に、輪の付いた厚い板（厚さ約2cm）を足した。三角形は5,804
- 見込める効果：寄りの場面で、ロボットだけが別の絵から切り抜いたように見えなくなる（見た目の話なので、数字では測っていない。Unity で撮った静止画を見て確かめる）

![丸める前（左）と丸めた後（右）のハコブくん。右は体の角とタイヤが丸く、横の輪の所に厚い板がある](https://pixelstomotors.com/media/LOG496_02_hakobu_before_after.png)

### 顔の画面のまわりは、丸めずに三面図のまま残した

最初は頭も含めて全体を丸めました。すると頭の上の丸みが画面の上の縁を押して、画面の上に白い線が出ました。画面が体から浮いて、立体の板のようにも見えました。

見直しで受けた指摘は3つです。顔の画面には手を入れないこと（上の線は消し、画面に立体感を付けない）、顔のまわりまで丸くすると変に見えること、画面の所は斜めにスパッと切れていること。三面図のハコブくんは、頭の前が斜めに切り落とされ、そこに画面がはまっています。

そこで、丸めるのは体だけにしました。高さの6.4割から下は丸め、7割までで三面図の角に戻します。頭の上を丸くする処理（dome）は止めました。

### お腹の横の丸みは、マンガのハコブくんから足した

次に、お腹のあたりの丸みを足すよう指示がありました。マンガのページ（pixelstomotors.com/manga-archive ）を見直すと、ハコブくんの横の、輪が付いている所がふくらんで描かれています。三面図の横の板の輪郭を測った線（`F["pod"]`）に沿って、厚さ0.022m・ふちの丸み0.02m の板を両側に足しました。輪は板の表面に乗せています。横の板の所だけ、幅は0.674m になります。

タイヤも直しました。三面図どおりの細い車輪は、丸めた体の下では棒のように見えました。半径0.046m・幅0.052m・20角の太いタイヤにして、角を0.013m 丸め、上半分を台の中に隠しています。

この形を、ハコブくんのもとの形にすることにしました（p2m-site の fae15fa）。

![ハコブくんの三面図（左）と3Dモデル（右）、斜めから見た形、人（1.6m の筒）との大きさ、引き出しを開けた所。三角形5,804](https://pixelstomotors.com/media/LOG496_03_hakobu_preview.png)


## 作業ログ ― 看護師の大城さんを、式で書いた形から作った

### 形を「表面からの距離」の式で書き、格子で面にした

看護師の形は、部品ごとに「その点が表面からどれだけ離れているか」を返す式で書きました。この式を SDF（符号付き距離関数）と呼びます。点が形の中にあれば負、外にあれば正の数を返します。頭は楕円体、腕や指は両端の丸い円柱（カプセル）、胴は角を丸めた箱、というように簡単な形の式を書き、なめらかに足したり削ったりして組み合わせます。

式で書いた形は、そのままでは Unity に入りません。空間を2.5mm 角の格子に区切って式の値を調べ、値が0になる所（表面）に三角形を張ります。これをマーチングキューブ法と呼び、[scikit-image の marching_cubes](https://scikit-image.org/docs/stable/api/skimage.measure.html#skimage.measure.marching_cubes) を使いました。できた細かい面を、Blender のモディファイアで三角形を減らして軽くしています。

| 言葉 | 意味 |
|---|---|
| SDF | 点から形の表面までの距離を返す式。中は負、外は正 |
| smin / smax | 2つの形を、つなぎ目がなめらかになるように足す・削る式 |
| マーチングキューブ法 | 格子の上で式の値が0になる所に三角形を張り、面にする方法 |
| 格子の大きさ | 細かいほど形は正確だが、三角形が増える。今回は2.5mm |

この作り方を選んだのは、形を言葉で直せるからです。首の長さも肩の丸みも、コードの中の1つの数字です。骨の位置も同じ数字から決めているので、形を直すと骨も一緒に合います。

### 平らな面に穴があいたのは、格子の点がちょうど表面に乗ったため

途中で2つつまずきました。1つは、胴を楕円体の式で書いたら、胴がふくらみすぎたことです。楕円体の正しい距離は簡単な式では出せず、近似の式は細長い形ほどずれます。胴は角を丸めた箱の式に替えました。

もう1つは、胴の平らな面に穴があいたことです。格子の点がちょうど平らな面の上に乗ると、式の値が0ぴったりになり、面を張る向きが決まらなくなります。格子全体を、格子の幅の0.37倍だけずらして解決しました（`nurse_build.py` の `mesh_of` の最初の行）。

### 看護師の見た目は、マンガの三面図と「デフォルメしすぎない」で決めた

![マンガ『ハコブくん』の看護師、大城さんの三面図（正面・背中・横）。V ネックのスクラブ、胸ポケット、すそのポケット2つ、低いお団子の髪](https://pixelstomotors.com/media/LOG496_04_oshiro_ref.png)

見た目の決まりは次のとおりです。

- 粘土のような簡略化した見た目。ただしデフォルメしすぎない。頭は身長の約7分の1
- 服はマンガのとおり。V ネックのスクラブ、胸ポケット1つ、すそのポケット2つ、まっすぐなパンツ、白い靴。ナースキャップはなし。色はロイヤルブルー
- 髪は低いお団子と、横に流した前髪
- 目はハコブくんの縦長の目に少し寄せる。同じにはしない

最初の形を見て返ってきた指摘です。

> 首長すぎ、肩がいかつすぎ、後ろの首元が変。Vネックが変。体が長すぎる？のかな。

ここから、首を短くし、肩をなで肩にし、腕を短くし、目を大きくしました。V ネックは浅くし、襟元のつなぎ目を整えました。直した所と、変えた数字の主なものです。

| 直した所 | 変えたこと |
|---|---|
| 首が長い | 頭の中心を1.516m に下げ、首の円柱を短くした |
| 肩がいかつい | 肩の線を、首から外へ下がる斜めの面で削った（`1.372 - 0.25*clip(|x|-0.05,0,0.09) - 0.16*max(|x|-0.14,0)`、なめらかさ0.06） |
| 腕が長い | 肩の高さ1.300m、上腕0.250m、前腕0.220m |
| 目が小さい | 目を大きくし、縦長の丸（ハコブくんの目）と楕円の間の形にした |
| 襟元のつなぎ目が変 | V の切り込みと首まわりの切り込みを、なめらかに合わせた（smin） |
| V ネックが深い | V の底を1.288m に上げた（`1.288 + 1.15|x| - z`） |

![最初の形（左の2つ）と、直した形（右の2つ）。最初は首が長く肩が角ばり、V ネックが深い。直した形は首が短く、なで肩で、胸ポケットとすそのポケットがある](https://pixelstomotors.com/media/LOG496_05_nurse_first_final.png)


## 作業ログ ― 骨と口を入れ、腕を動かしても袖が裂けない形にした

### Unity の Humanoid は、人型の骨に決まった名前を付ける仕組み

Unity は、人型の骨に決まった名前（Hips・Spine・Chest・Neck・Head・LeftUpperArm など）が付いていると、[Humanoid（人型）](https://docs.unity3d.com/Manual/AvatarCreationandSetup.html)として読みます。Humanoid として読めれば、ほかの人型のために作られた歩く・手を振るといった動きを、骨の長さが違っても当てはめられます（リターゲット）。

| 言葉 | 意味 |
|---|---|
| 骨（ボーン） | 形の中に入れる、動かすための見えない棒。親子につながる |
| 重み（ウェイト） | 形の頂点が、どの骨にどれだけついて動くかの割合。合計は1 |
| Avatar | Unity が、骨の名前と人型の部位の対応を覚えておくもの |
| リターゲット | 別の人型のために作った動きを、この人型の骨に当てはめること |

看護師の骨は45本です。体（Hips → Spine → Chest → Neck → Head）、左右の腕（Shoulder → UpperArm → LowerArm → Hand）、左右の指5本（Proximal → Intermediate の2節）、左右の脚（UpperLeg → LowerLeg → Foot → Toes）。このほかに、Humanoid に入れない顔の骨として、目（Eye_L・Eye_R。上下につぶすとまばたき）と眉（Brow_L・Brow_R。回すと困り顔）を入れました。口は骨ではなく、笑顔・まっすぐ・困り・話す・驚くの5つの部品を作り、表示を切り替えます。

重みは、骨ごとに「その骨が受け持つ範囲」の式を書き、頂点からその範囲までの距離が近いほど重くなるように決めました。1つの頂点につく骨は、重い順に4本までです。

### 8つの姿勢を骨で付けて、服と肌が破れないかを確かめた

骨と重みが合っているかは、姿勢を付けて描いて確かめます。立つ・片手で差し出す・両手で受け取る・引き出しの物をつかむ・指で示す（話す口）・おじぎ・困った（眉と口）・話しかける、の8つです（`nurse_pose.py`）。指の骨は、x 軸のまわりに回すと手のひらのほうへ曲がる向きにしてあります。

![骨で姿勢を付けた看護師の8つの姿勢。立つ、片手で差し出す、両手で受け取る、引き出しの物をつかむ、指で示す、おじぎ、困った、話しかける](https://pixelstomotors.com/media/LOG496_06_nurse_poses.png)

### 袖は、裂けた・まだ裂けた・ゆがんだ、の3回で直った

いちばん手間がかかったのは袖です。

> 体が動いた時に服の袖が切れてるよ。修正してください。

1回目は、腕を前に出すと、袖と胴の継ぎ目が裂けて、すき間から肌が見えました。袖が胴と1つの面になっていて、胴の側は胸の骨、袖の側は腕の骨について動くので、その間が引きのばされて切れていました。袖を胴から切り離し、別の部品（Nurse_Sleeves）にしました。

2回目は、まだ裂けて見えると言われました。袖を離しても、袖の中に隠れている腕の付け根の肌が、腕を上げると袖の外へ突き出ていました。袖の中に入る所の肌は作らないことにしました（腕の骨の向きに沿って0.134m より付け根の肌を消した）。

3回目は、腕を上げた所で袖の山がゆがんでいました。2回目までは、袖の付け根4cm を肩の骨と腕の骨で分け合い、そこから先を腕の骨だけで動かしていました。腕を上げると、肩の骨についた袖の山は動かず、腕の骨についた所だけが回るので、その間の4cm がねじれます。**袖をまるごと上腕の骨に付けると、腕を上げても袖の形が変わらなくなりました。** 袖の山は肩の関節のすぐ外にあるので、腕と一緒に回しても肩から離れません。上の図解で、2つの付け方を比べられます。

- ねらい：腕をどこへ動かしても、袖が裂けたりゆがんだりしないようにする
- 変わったこと：袖を胴から離した別の部品にし、袖の中の肌を消し、袖の重みをすべて上腕の骨にした
- 見込める効果：Unity で Mixamo の動き（腕を大きく振る・手を振る・物を拾う）を当てても、袖を直さずにすむ。Unity で腕を上げた所の静止画を見て確かめる（依頼163 の確かめ (g)）

![腕を上げた姿勢の袖の寄り（直したあと）。袖の山が肩につながったまま、腕と一緒に回っている](https://pixelstomotors.com/media/LOG496_07_sleeve_fixed.png)

袖を直した版は、この記事の時点ではまだ p2m-site に入れていません。Unity の依頼162 が、直す前の FBX（md5 09c296c50f88d39156fbcd62223b7d3e）を読んで回っている途中だからです。依頼162 が終わってから入れ替えます（直した版の md5 は 351b47d08b4a6c6fcbfa90a6d39479fc）。


## 作業ログ ― 歩く動きは Mixamo から借りた

### Mixamo は、人型の動きを無料で借りられる Adobe のサービス

[Mixamo](https://www.mixamo.com/) は、人型の動き（歩く・走る・手を振る・おじぎなど）を無料で借りられる Adobe のサービスです。使うには Adobe のアカウントが要ります。動きは FBX でダウンロードでき、Unity の Humanoid として読めば、この看護師の骨に当てはめられます。

| 言葉 | 意味 |
|---|---|
| Without Skin | 形を付けず、骨と動きだけをダウンロードする設定 |
| パック | 歩く・曲がる・止まるなど、組で使う動きをまとめたもの |
| Root Motion | 動きの中の、体ごとの前進や回転。これで歩かせると足が床をすべりにくい |

歩く動きの元がないことは、Unity の依頼162 を書いた時点で分かっていました。依頼162 では「足を交互に出す簡単な動きで作り、不自然なら足元を外して撮る」としてあり、どこから借りるかは決めていませんでした。Mixamo を使ってよいことになったので、借りました。

ダウンロードは、Mac の Claude のアプリの中のブラウザで行いました。Adobe へのログインは、パスワードを Claude が入れずに、画面の上で本人が入れています。動きはどれも骨だけ（Without Skin）・30fps で、Mixamo の見本の人型（Sporty Granny）の骨で書き出されています。

| まとまり | 本数 | 中身 |
|---|---|---|
| Female Locomotion Pack | 10 | 立って待つ・歩く・走る・左右に曲がる・横歩き・跳ぶ |
| Gestures Pack Basic | 15 | うなずく・首を横に振る・考えながら首を振る・ほっと息をつく・目をそらす・体重を移す など |
| 1本ずつ | 8 | 歩き始め・歩き終わり・話す・軽いおじぎ・物を拾う・前を指す・不安そうに見回す・手を振る |

Mixamo の動きのファイルは、Adobe の決まりにより、そのままの形では配りません。置く場所は Unity の作業場所（ServiceSim の Assets/ServiceSim/Models/Animations/）だけにし、GitHub・iCloud・KIOXIA には写しません。この記事に載せるのは、ファイルの名前と md5 の一覧だけです。置き場所は MASTER の保存先の表に足しました。

#### mixamo_20261113/md5.txt（ダウンロードした33本の一覧）

```text
a687bcbe21d11888301796f6404ba956  gestures/acknowledging.fbx
76c1f889a4fea5670842a25dd1fa3483  gestures/angry gesture.fbx
be0df202946df81566d4ce9bf08dbb0d  gestures/annoyed head shake.fbx
782d4a7b69b0ba5c63c855cb8285a7de  gestures/being cocky.fbx
f640659145f102aa9223cfb5a300ea0b  gestures/dismissing gesture.fbx
891a60cadbb619866ff96cde0cbac366  gestures/happy hand gesture.fbx
b473e16c4ce55bb5ee8d941498dc7925  gestures/hard head nod.fbx
138945177361dea073417b4212702d92  gestures/head nod yes.fbx
417ba747f380ed6bd1bed3bff139b73e  gestures/lengthy head nod.fbx
3bb10d47206d3e56b75f4c9c1a7fad0f  gestures/look away gesture.fbx
2827de3c6f40bfe2a77cb4360a40b209  gestures/relieved sigh.fbx
a3597439d1da0271adc2c4a6aeea6ee3  gestures/sarcastic head nod.fbx
45968fbaa4bca607eadef28f2096a09c  gestures/shaking head no.fbx
f4eef6b7ebc44ed8f9253937bc7b4dae  gestures/thoughtful head shake.fbx
d6cb0a523f1ff47545bc01defabac1f1  gestures/weight shift.fbx
6915a6ee6c64f41fd379b09d3ed3add3  locomotion/idle.fbx
d550b4b10d2a4502b70dc14bd4455d1f  locomotion/jump.fbx
bf0013704cf4b1aa26c43400b79b522c  locomotion/left strafe walk.fbx
39c8c7aa23e6050adfc1d023c02c84ab  locomotion/left strafe.fbx
4567284397e012d67a840e234fd9d9a9  locomotion/left turn.fbx
c150aa446d6c8e5c933ca64f0264e34b  locomotion/right strafe walk.fbx
957b87b93e6de4e38f90b1d85fe0284e  locomotion/right strafe.fbx
38d2bc6524dc6e304ba69926f44452b9  locomotion/right turn.fbx
44ee41279a2428a4f78a63b67b7c9e96  locomotion/running.fbx
6ddfaa7e1d451e82ce623d06f9b71e59  locomotion/walking.fbx
4f96afbdb76c8c710fe1b22a20587473  single/Female Start Walking.fbx
3673cc1eecf1d164c9a4e6a7daca04eb  single/Female Stop Walking.fbx
dc02bbfc1b6d1d544cec9708ef798d90  single/Nervously Look Around.fbx
da8f339e8ceb2061bdfca8451d4bdd4b  single/Picking Up Object.fbx
fb1f6e0442d397f77b6f62a2c253d06f  single/Pointing Forward.fbx
2a5012f95a55621c4697b398f47d8964  single/Quick Formal Bow.fbx
4ff8215763b5e78ac63ddf1ac6b4fb1d  single/Talking.fbx
4f7d12264ca079d78530109f4628911a  single/Waving Gesture.fbx
```

### 依頼163 で、看護師を歩かせて撮り直す

借りた動きを Unity に入れ、看護師を歩かせる依頼（依頼163）を下書きしました。依頼162 の結果を読んでから出します。中の `{COMMIT}` と `{NURSE_MD5}` は、袖を直した版を p2m-site に入れたあとに埋めます。歩く所では、足が床に着いている間に動いた距離が3cm 以内であることを確かめます。

#### 依頼163（下書き）

```markdown
---
作業フォルダ: /Users/shimazueiichi/Robotics_UX/ServiceSim
上限時間: 300
---
# 依頼163：場面の寄りに Mixamo の動きを入れ、看護師を歩かせて①積む・②受け取るを撮り直す。袖を直した看護師に差し替える

依頼162 の報告（claude_code_reports/20261113_closeup_162.txt）を読んでから始める。162 で作った場面（Assets/ServiceSim/Closeups/Closeup_Handover.unity）と台本の JSON を土台にする。

エイイチと決めたこと（2026-11-13）：
- 人の動きは Mixamo から借りる（エイイチの Adobe のアカウントで借りた）。**Adobe の決まりで、Mixamo の FBX はそのまま公開しない**。置き場所は ServiceSim の Assets/ServiceSim/Models/Animations/ だけ（MASTER の保存先テーブル）。GitHub・iCloud・KIOXIA には写さない
- 看護師のモデルを、袖を直した版に差し替える（腕を上げた時に袖の山がゆがんでいた。袖をまるごと上腕の骨に付けた）

**病院の1日の場面（Assets/ServiceSim/Hospital/）・配膳の場面・それぞれの実行ファイルと台帳・共通の部品には触らない。** 触るのは Assets/ServiceSim/Closeups/・Assets/ServiceSim/Models/ の中だけ。Packages にも触らない。
**エイイチは見ていない。人に聞かないと進めない所に当たったら、その項目は止めて報告に書き、ほかは続ける。**

## 正（読むもの）
- 看護師：p2m-site main（{COMMIT}）の `tools/sim_people/nurse_oshiro/nurse_oshiro.fbx`（md5 {NURSE_MD5}）。骨の名前・作りは依頼162 と同じ。Nurse_Sleeves の重みだけが変わった
- 動き：Assets/ServiceSim/Models/Animations/Mixamo_20261113/ に Claude が写した 33 本（元は Mac の ~/Downloads/mixamo_20261113/。md5 の一覧は同じフォルダの md5.txt）
  - locomotion/：walking・idle・left turn・right turn・left strafe walk・right strafe walk ほか（Female Locomotion Pack）
  - gestures/：head nod yes・lengthy head nod・acknowledging・thoughtful head shake・shaking head no・relieved sigh・look away gesture・weight shift ほか（Gestures Pack Basic）
  - single/：Female Start Walking・Female Stop Walking・Talking（ふつうの会話）・Quick Formal Bow・Picking Up Object・Pointing Forward・Nervously Look Around・Waving Gesture
  - どれも骨だけ（肌なし）・30fps・Mixamo の骨の名前（mixamorig:）

## 始める前に確かめること（違えば作らずに報告して終わる）
作業場所の保留 0・Unity と回し係が動いていない・KIOXIA がマウント済み・看護師の md5・動きの md5（md5.txt と合う）・依頼162 の判定

## 1. 読み込む
- 看護師の FBX を Assets/ServiceSim/Models/NurseOshiro/ で差し替える（Avatar の設定は依頼162 と同じ）
- 動きの FBX：Rig を Humanoid、Avatar は「このモデルから作る」。Root Transform の向き・高さは元の動きに合わせる（Bake Into Pose の設定は報告に書く）
- walking・idle・talking・weight shift はループにする

## 2. 場面を直す（台本の JSON の動きを、Mixamo の動きに置きかえる）
- 歩く：Female Start Walking → walking（ループ）→ Female Stop Walking。曲がる所は left turn・right turn。歩く距離は台本の位置から決め、足がすべらないこと（Root Motion で歩かせる）
- 立って待つ：idle と weight shift
- かがんで引き出しを開ける・箱を置く／取る：Picking Up Object を土台にして、手は依頼162 の IK で取っ手・トレー・箱に合わせる
- ① 積み終わったら head nod yes で軽くうなずく。② 受け取ったら Talking で話しかけ（口は talk と smile を交互に出す）、最後に Quick Formal Bow で軽くおじぎ
- まばたきは依頼162 のまま
- 一場面の長さは 25〜40秒

## 3. 確かめ（違えば 4 は撮らずに止める）
- 依頼162 の (a)〜(e) と同じ
- (f) 歩く所：足が床をすべった長さ（足が床に着いている間に動いた距離の最大）が 3cm 以内。足が床に沈む・浮くが 2cm 以内
- (g) 袖：腕を上げた所の静止画を目で見て、袖がゆがんだり裂けたりしていないか

## 4. 撮る
- 依頼162 と同じカメラ・大きさで、①と②を1本ずつ撮り直す。静止画も依頼162 と同じ種類に、歩いている全身（横から）を足す
- 出力：/Volumes/KIOXIA/r2r2r-data/service_sim/unity/closeup/20261113_closeup163/。動画と静止画は iCloud の …/サービスロボット導入シミュレーション/hospital/closeup163/ にも写し、md5 を確かめる（Mixamo の FBX は写さない）
- check-in（保留0）。実行ファイルは作らない

## 報告
claude_code_reports/20261113_closeup_163.txt に：始める前の確かめ、1 の読み込み（Humanoid の割り当て・Root Motion の設定）、2 で直したもの、3 の確かめ（表）、4 のカメラの値と動画の長さ、出力の場所、check-in の番号、決めてほしいこと（直し方の候補は作らない）。
**報告の最後の1行は必ず次のどれか**：「判定：合格」／「判定：止めた（決めてほしいことあり）」／「判定：失敗」
```


## 作業ログ ― Unity で、薬を積む場面と受け取る場面を作る依頼を出した

### 依頼162 は、場面の寄りの最初の2つを作る

ハコブくんと看護師の FBX ができたので、Unity の場面を作る依頼を、依頼箱から Claude Code に回しました。依頼箱は、iCloud のフォルダに依頼の文を置くと、Mac の Claude Code が1件ずつ読んで実行し、報告を書く仕組みです。

作るのは、病院の1日の場面とは別の新しい場面（`Assets/ServiceSim/Closeups/Closeup_Handover.unity`）で、撮るのは2つです。

- ① 薬剤部で、看護師がハコブくんの引き出しに薬の箱を積む（25〜35秒）
- ② 届け先で、看護師がハコブくんの引き出しから箱を受け取り、話しかける（25〜35秒）

確かめる所も決めました。引き出しを開ける時に手のひらの中心と取っ手の中心が2cm 以内か、箱がトレーから浮いたりめり込んだりしていないか（1cm 以内）、看護師の手や体がハコブくんの体を突き抜けていないか、袖・首元・指が裂けていないか。どれかが外れたら撮らずに止めます。

この記事の時点で、依頼162 は回っている途中です（7時49分に始まった）。結果は次の回で書きます。

![引き出しを閉めたハコブくんに、看護師が手のひらを見せて話しかけている3DCG。② の終わりの姿を、Blender で先に描いたもの](https://pixelstomotors.com/media/LOG496_08_talk_scene.png)

#### 依頼162（Claude Code に回した全文）

```markdown
---
作業フォルダ: /Users/shimazueiichi/Robotics_UX/ServiceSim
上限時間: 300
---
# 依頼162：病院の「場面の寄り」を新しく作る。看護師がハコブくんに薬を積む場面と、届け先で受け取る場面を撮る

## ねらい
サービスロボット導入シミュレーションは、これから次の4段で見せる（エイイチ、2026-11-13）：
**試算 → 全体のシミュレーション（今の病院の1日）→ 場面の細部（今回から）→ 実際のロボットの動画**。
今回はその3段目の最初。今の病院の1日の場面とは別に、**現場の具体的な一場面を短く切り取って見せる場面**を作る。人とロボットのやり取り（渡す・受け取る、あとでトラブルの時の動き・話しかける姿）が、近くで見て分かることがねらい。
今回撮るのは2つ：**① 薬剤部で看護師がハコブくんの引き出しに薬を積む ② 届け先でハコブくんが待ち、看護師が引き出しから受け取る**。

**病院の1日の場面（Assets/ServiceSim/Hospital/）・配膳の場面・それぞれの実行ファイルと台帳・共通の部品には触らない。** 新しく作るのは Assets/ServiceSim/Closeups/ の中と、モデルを写す Assets/ServiceSim/Models/ の中だけ。Packages（manifest.json）にも触らない。
**エイイチは見ていない。人に聞かないと進めない所に当たったら、その項目は止めて報告に書き、ほかは続ける。**

## 正（読むもの）
p2m-site main（e3daa10）
- ハコブくん：`tools/sim_robots/hakobu/`（README.md を読む）
  - hakobu.fbx（md5 364baa19a7809321d3f635c7fe17fa94）・hakobu_albedo.png（397877afe090d0d7e5c591b0454fb26f）・hakobu_emission.png（4dc8ec34c9af0c3003ba00be6fe250b3）
  - 実寸 幅0.653・奥行0.760・高さ1.150m。根は床の中心、顔は +Z。部品：Body・Face・Ring・Drawer・Tray_1〜4。引き出しは前（+Z）へ 0.26m まで出る。Tray_1〜4 は FBX では根の直下にあるので、プレハブで Drawer の子にする
  - 取っ手の高さは床から約 0.61〜0.66m、引き出しの中のトレーの上の面は約 0.43m
- 看護師（大城さん）：`tools/sim_people/nurse_oshiro/`（README.md を読む）
  - nurse_oshiro.fbx（md5 09c296c50f88d39156fbcd62223b7d3e）。身長1.65m。根は床の中心、顔は +Z
  - 骨は Unity の Humanoid の名前（Hips・Spine・Chest・Neck・Head・左右の Shoulder〜Hand・指の Proximal と Intermediate・UpperLeg〜Toes）。作った時の姿勢は腕を下ろした形
  - 顔の骨 Eye_L/R（上下につぶすとまばたき）・Brow_L/R は Humanoid に入れない
  - 口は Nurse_Mouth_smile／flat／worried／talk／surprised の5つの部品。表示を切り替えて使う（既定は smile）
  - 確認用の絵：iCloud の …/サービスロボット導入シミュレーション/hospital/people_3d/nurse_oshiro_poses.png（骨で姿勢を付けた8つ。この形に近い姿勢が Unity でも出ればよい）

## 始める前に確かめること（違えば作らずに報告して終わる）
作業場所の保留 0・Unity と回し係が動いていない・KIOXIA がマウント済み・上の md5

## 1. 読み込む
- 2つの FBX と画像を Assets/ServiceSim/Models/Hakobu/・Assets/ServiceSim/Models/NurseOshiro/ に写す
- ハコブくん：材質に albedo と emission を付ける（目・口・輪・背中の帯が光る）。プレハブを作り、Tray_1〜4 を Drawer の子にする
- 看護師：Rig を Humanoid にして、骨の割り当てを確かめる（自動で全部割り当たるはず。割り当たらない骨があれば報告に書く）。T ポーズに直して Avatar を作る
- 大きさの確かめ：ハコブくんの高さ 1.15m・看護師 1.642m が Unity の目盛りで合うこと

## 2. 作る：場面の寄り（Closeups）
- 新しい場面 `Assets/ServiceSim/Closeups/Closeup_Handover.unity`。床・壁・薬剤部のカウンター（腰の高さ 0.9m・奥行 0.6m）・廊下の突き当たりの扉くらいの簡単な部屋でよい。色は依頼153 の病院の色に合わせる
- 動きは動画用の台本で決める（時刻と動きの表を JSON で Closeups/ に置き、そこを読む。値は決め打ちにしない）
- 看護師の手は Animator の IK（OnAnimatorIK）で、取っ手・トレー・荷物に合わせる。かがむのは Spine と Chest を前へ（取っ手が低いので腰を曲げる）。指はつかむ時に曲げる（指の骨の Proximal・Intermediate）
- 荷物：小さい薬の箱（約 0.12×0.08×0.05m・白に色の帯）と、検体の袋。トレーに置く
- **① 積む（約 25〜35秒）**：ハコブくんが薬剤部のカウンターの前に止まっている → 看護師がカウンターの薬の箱を手に取る → ハコブくんの前に来て少しかがむ → 取っ手を持って引き出しを開ける → 箱をトレーに置く → 引き出しを閉める → 体を起こして、ハコブくんに軽くうなずく → ハコブくんが走り出す
- **② 受け取る（約 25〜35秒）**：届け先（病棟のナースステーションの前）にハコブくんが来て止まる → 看護師が気づいて歩いて来る → かがんで引き出しを開ける → 箱を取り出す → 閉める → 体を起こしてハコブくんに口を talk にして話しかける → ハコブくんが戻っていく
- 看護師が歩く所：今回は**数歩だけ**（1〜2m）。歩く動きの元がないので、足を交互に出す簡単な動き（Humanoid の筋肉の値を時間で動かす）で作る。不自然なら、歩く所を短くして、カメラで足元を外して撮る。歩き方の元をどうするか（ほかから借りるか）は「決めてほしいこと」に書く
- まばたき：3〜5秒に1回、Eye_L/R を縦に 0.1 までつぶして戻す（0.15秒）
- 服や肌が裂ける所が出たら、骨の重みは触らずに報告に書く（直すのは Claude が作る側でやる）

## 3. 確かめ（違えば 4 は撮らずに止める）
- (a) 引き出しを開ける時：看護師の手のひらの中心と取っ手の中心の距離（コマごとの最大）が 2cm 以内
- (b) 箱をトレーに置いた時、箱がトレーの面から浮いていない・めり込んでいない（1cm 以内）
- (c) 引き出しの開き：0〜0.26m の間で、開けて閉めること
- (d) 看護師の手や体がハコブくんの体を突き抜けていないこと（引き出しの前の板を除く）。突き抜けたコマがあれば、その時刻と場所
- (e) 静止画を目で見て、袖・首元・指が裂けたりつぶれたりしていないか

## 4. 撮る
- 動画：①と②を1本ずつ。1920×1080・30fps。カメラは3つくらいを切り替える（全体・手元の寄り・ハコブくんの顔と看護師の顔が入る斜め）。字幕・パネルは入れない
- 静止画：①で引き出しを開けた瞬間・箱を置いた瞬間、②で箱を取り出した瞬間・話しかけている顔の寄り。ほかに看護師が立った全身（正面・横）
- 出力：/Volumes/KIOXIA/r2r2r-data/service_sim/unity/closeup/20261113_closeup162/。動画と静止画は iCloud の …/サービスロボット導入シミュレーション/hospital/closeup162/ にも写し、md5 を確かめる
- check-in（保留0）。実行ファイルは作らない

## 報告
claude_code_reports/20261113_closeup_162.txt に：始める前の確かめ、1 の読み込み（Humanoid の割り当ての結果）、2 で作ったもの、3 の確かめ（表）、4 のカメラの値と動画の長さ、出力の場所、check-in の番号、決めてほしいこと（直し方の候補は作らない）。
**報告の最後の1行は必ず次のどれか**：「判定：合格」／「判定：止めた（決めてほしいことあり）」／「判定：失敗」
```


## 作業ログ ― コードと出力の全文

### 看護師の作り方：nurse_build.py（袖を直した版）

形・骨・重みを作り、FBX と blend を書き出すコードです。`python3 nurse_build.py <出力フォルダ> --vox 0.0025` で動きます。袖の重みの直し（`weight_sleeves`）が入った版で、p2m-site には依頼162 のあとで入れます。

```python
"""粘土のような簡略化した看護師（大城さん）を、形の式（距離の場）から作る。
python3 nurse_build.py <out_dir> [--vox 0.0025]
前が -y、上が z、単位は m。身長 1.65 m。
出力：nurse_clay.blend・nurse_oshiro.fbx・nurse_report.json（部品ごとの三角形の数・骨の名前）
FBX は Unity 向け（顔が Unity の +Z、上が +Y）。骨は Unity の Humanoid の名前（Hips・LeftUpperArm など）。
口は5つの形（smile・flat・worried・talk・surprised）を別の部品で入れてあり、Unity で表示を切り替える。
"""
import math
import os
import sys

import bpy  # noqa: I001  bpy を先に読む
import numpy as np
from skimage.measure import marching_cubes

OUT = sys.argv[1]
os.makedirs(OUT, exist_ok=True)
VOX = float(sys.argv[sys.argv.index("--vox") + 1]) if "--vox" in sys.argv else 0.0025


# ---------- 形の式 ----------
def length(v):
    return np.sqrt(np.sum(v * v, axis=0))


def ellipsoid(p, c, r):
    q = (p - np.array(c).reshape(3, 1)) / np.array(r).reshape(3, 1)
    k0 = length(q)
    k1 = length(q / np.array(r).reshape(3, 1))
    return k0 * (k0 - 1.0) / np.maximum(k1, 1e-9)


def capsule(p, a, b, ra, rb):
    a = np.array(a, float).reshape(3, 1)
    b = np.array(b, float).reshape(3, 1)
    ba = b - a
    h = np.clip(np.sum((p - a) * ba, axis=0) / np.sum(ba * ba), 0, 1)
    return length(p - a - ba * h) - (ra + (rb - ra) * h)


def smin(a, b, k):
    h = np.clip(0.5 + 0.5 * (b - a) / k, 0, 1)
    return b + (a - b) * h - k * h * (1 - h)


def smax(a, b, k):
    return -smin(-a, -b, k)


def union(ds, k):
    d = ds[0]
    for e in ds[1:]:
        d = smin(d, e, k)
    return d


def shell(d, t):
    return np.maximum(d, -(d + t))


# ---------- 寸法 ----------
HC = np.array([0.0, 0.0, 1.516])          # 頭の中心


def H(x, y, z):
    return (HC + np.array([x, y, z])).tolist()


SH = 0.150                                  # 肩の関節の横の位置
ARM_OUT = math.radians(9)                   # 腕の開き
ARM_FWD = math.radians(4)


def arm_points(side):
    s = 1 if side > 0 else -1
    sh = np.array([s * SH, 0.01, 1.300])
    d1 = np.array([s * math.sin(ARM_OUT), -math.sin(ARM_FWD), -math.cos(ARM_OUT)])
    d1 /= np.linalg.norm(d1)
    el = sh + d1 * 0.250
    d2 = np.array([s * math.sin(ARM_OUT * 0.6), -math.sin(math.radians(14)), -1.0])
    d2 /= np.linalg.norm(d2)
    wr = el + d2 * 0.220
    return sh, el, wr, d1, d2


def hand_geo(side):
    """手の形の骨組み（手首・手のひら・指の関節の位置）。形と骨の両方で使う。
    u＝手首から指先の向き、n＝手のひらの向き（体のほう）、w＝親指のほう（前）"""
    s = 1 if side > 0 else -1
    _, _, wr, _, u = arm_points(side)
    n = np.array([-s, 0.0, 0.0])            # 手のひらは体のほうを向く
    n = n - u * np.dot(n, u)
    n /= np.linalg.norm(n)
    w = np.cross(u, n) * s                  # 前（-y）向きにそろえる
    if w[1] > 0:
        w = -w
    base = wr + u * 0.085
    fingers = {}
    for name, off, ln, r in (("Index", 0.026, 0.070, 0.0105), ("Middle", 0.009, 0.078, 0.0108),
                             ("Ring", -0.009, 0.074, 0.0105), ("Little", -0.025, 0.060, 0.0095)):
        a = base + w * off
        d_1 = u * math.cos(math.radians(14)) + n * math.sin(math.radians(14))
        b = a + d_1 * ln * 0.52
        d_2 = u * math.cos(math.radians(38)) + n * math.sin(math.radians(38))
        c = b + d_2 * ln * 0.48
        fingers[name] = (a, b, c, r)
    ta = wr + u * 0.03 + w * 0.024 + n * 0.006
    td = u * 0.55 + w * 0.65 + n * 0.45
    td /= np.linalg.norm(td)
    tb = ta + td * 0.04
    td2 = u * 0.75 + w * 0.3 + n * 0.6
    td2 /= np.linalg.norm(td2)
    tc = tb + td2 * 0.032
    return {"wr": wr, "u": u, "n": n, "w": w, "palm_end": base, "fingers": fingers, "thumb": (ta, tb, tc)}


def palm(p, side):
    g = hand_geo(side)
    wr, u, w = g["wr"], g["u"], g["w"]
    ds = [ellipsoid(p, (wr + u * 0.048).tolist(), (0.024, 0.024, 0.024))]
    # 手のひらは平たい楕円。軸に沿って並べた小さい楕円で作る
    for t in (0.02, 0.045, 0.07):
        c = wr + u * t
        ds.append(capsule(p, (c - w * 0.022).tolist(), (c + w * 0.022).tolist(), 0.019, 0.019))
    return union(ds, 0.010)


def finger_segs(p, side):
    """指の節ごとの形：{骨の名前の後ろ半分: 形}"""
    g = hand_geo(side)
    out = {}
    for name, (a, b, c, r) in g["fingers"].items():
        out[name + "Proximal"] = capsule(p, a.tolist(), b.tolist(), r, r * 0.95)
        out[name + "Intermediate"] = capsule(p, b.tolist(), c.tolist(), r * 0.95, r * 0.88)
    ta, tb, tc = g["thumb"]
    out["ThumbProximal"] = capsule(p, ta.tolist(), tb.tolist(), 0.0135, 0.012)
    out["ThumbIntermediate"] = capsule(p, tb.tolist(), tc.tolist(), 0.012, 0.0105)
    return out


def hand(p, side):
    return union([palm(p, side)] + list(finger_segs(p, side).values()), 0.010)


def head_solid(p):
    cr = ellipsoid(p, H(0, 0, 0), (0.098, 0.106, 0.108))
    jaw = ellipsoid(p, H(0, -0.010, -0.055), (0.080, 0.082, 0.072))
    d = smin(cr, jaw, 0.03)
    cheeks = [ellipsoid(p, H(s * 0.044, -0.055, -0.045), (0.040, 0.036, 0.035)) for s in (-1, 1)]
    d = union([d] + cheeks, 0.03)
    nose = ellipsoid(p, H(0, -0.099, -0.026), (0.011, 0.011, 0.014))
    d = smin(d, nose, 0.010)
    ears = [ellipsoid(p, H(s * 0.096, 0.008, -0.020), (0.014, 0.021, 0.029)) for s in (-1, 1)]
    return union([d] + ears, 0.008)


def skin(p):
    ds = [head_solid(p)]
    ds.append(capsule(p, (0, 0.012, 1.30), (0, 0.006, 1.45), 0.044, 0.041))
    ds.append(ellipsoid(p, (0, 0.012, 1.27), (0.10, 0.066, 0.08)))
    for sd in (-1, 1):   # 首の付け根から肩へ（服の内側を肌で埋める）
        ds.append(capsule(p, (sd * 0.045, 0.015, 1.335), (sd * 0.12, 0.015, 1.31), 0.024, 0.022))
    d = union(ds, 0.03)
    arms = []
    for side in (-1, 1):
        sh, el, wr, _, _ = arm_points(side)
        a = smin(capsule(p, sh.tolist(), el.tolist(), 0.040, 0.037),
                 capsule(p, el.tolist(), wr.tolist(), 0.036, 0.029), 0.02)
        arms.append(smin(a, hand(p, side), 0.012))
    d = union([d] + arms, 0.01)
    # 袖の中に隠れる腕の付け根の肌は作らない（腕を上げて服がのびたとき、肌が服から突き出ないように）
    for side in (-1, 1):
        sh, _, _, d1, _ = arm_points(side)
        q = p - sh.reshape(3, 1)
        proj = np.sum(q * d1.reshape(3, 1), axis=0)
        radial = length(q - d1.reshape(3, 1) * proj)
        inside = np.maximum(np.maximum(-0.12 - proj, proj - 0.134), radial - 0.065)
        d = smax(d, -inside, 0.005)
    return d


def hairline(p):
    """頭の中心から見た向きごとの、髪の下の端の高さ（頭の中心からの差）。"""
    x = p[0] - HC[0]
    y = p[1] - (HC[1] + 0.005)
    th = np.degrees(np.arctan2(x, -y))
    a = np.abs(th)
    z = np.interp(a, [0, 35, 58, 68, 78, 88, 104, 120, 180],
                  [0.056, 0.046, 0.030, 0.000, -0.012, 0.024, 0.024, -0.080, -0.100])
    # 横に流した前髪：右（+x）が上がり、左（-x）へ下がる
    front = np.clip(np.cos(np.radians(th)), 0, 1) ** 2
    z = z + 0.025 * np.sin(np.radians(th)) * front
    return z + HC[2]


def hair(p):
    s = ellipsoid(p, H(0, 0.010, 0.006), (0.110, 0.118, 0.120))
    nape = ellipsoid(p, H(0, 0.040, -0.062), (0.090, 0.078, 0.070))
    s = smin(s, nape, 0.03)
    d = smax(s, hairline(p) - p[2], 0.010)
    # 耳の前に下りる短い毛束
    bun = ellipsoid(p, H(0, 0.116, -0.088), (0.047, 0.041, 0.044))
    return smin(d, bun, 0.012)


def top(p):
    rr = 0.068
    c = np.array([0.0, 0.010, 1.085]).reshape(3, 1)
    b = np.array([0.158 - rr, 0.098 - rr, 0.30 - rr]).reshape(3, 1)
    qq = np.abs(p - c) - b
    d = length(np.maximum(qq, 0)) + np.minimum(qq.max(axis=0), 0) - rr
    # 首から肩へのなで肩の線
    ax = np.abs(p[0])
    slope = (p[2] - (1.372 - 0.25 * np.clip(ax - 0.05, 0, 0.09) - 0.16 * np.maximum(ax - 0.14, 0))) / 1.03
    d = smax(d, slope, 0.06)
    bust = [ellipsoid(p, (s * 0.060, -0.056, 1.175), (0.058, 0.055, 0.052)) for s in (-1, 1)]
    d = union([d] + bust, 0.045)
    outer = d
    d = shell(d, 0.007)
    d = smax(d, 0.80 - p[2], 0.004)                         # すそ
    neck = np.sqrt(p[0] ** 2 + (p[1] - 0.010) ** 2) - 0.051
    vee = np.maximum(p[1] + 0.02, (1.288 + 1.15 * np.abs(p[0])) - p[2]) / 1.5
    cut = smin(np.maximum(neck, 1.30 - p[2]), vee, 0.02)    # 丸い首まわりと V を角なしでつなぐ
    d = smax(d, -cut, 0.009)                                 # えりぐり（後ろは丸く、前は V）
    # ポケット：表面に貼った薄い板（胸の左に1つ、すその左右に1つずつ）
    for cx, cz, hw, hh in ((0.068, 1.19, 0.034, 0.038), (0.088, 0.875, 0.046, 0.046), (-0.088, 0.875, 0.046, 0.046)):
        rect = np.maximum(np.abs(p[0] - cx) - hw, np.abs(p[2] - cz) - hh)
        layer = np.maximum(outer - 0.0035, -outer - 0.004)
        d = np.minimum(d, np.maximum(np.maximum(rect, layer), p[1]))
    return d


def sleeves(p):
    """袖は胴とつなげず、別の部品にする（胴とつなげると、腕を動かしたとき継ぎ目が裂ける）。
    袖の上の丸みは胴の肩の中に入っていて、腕を上げても袖の筒がそのまま腕といっしょに回る"""
    sl = []
    for side in (-1, 1):
        sh, el, _, d1, _ = arm_points(side)
        # 袖の上の丸みは胴の肩より低くして、肩の線から飛び出さないようにする
        c = capsule(p, (sh + d1 * 0.035).tolist(), (sh + d1 * 0.2).tolist(), 0.051, 0.051)
        c = shell(c, 0.006)
        proj = np.sum((p - sh.reshape(3, 1)) * d1.reshape(3, 1), axis=0)
        sl.append(smax(c, proj - 0.14, 0.004))
    return np.minimum(sl[0], sl[1])


def pants(p):
    ds = [ellipsoid(p, (0, 0.012, 0.86), (0.148, 0.086, 0.10))]
    for s in (-1, 1):
        ds.append(capsule(p, (s * 0.080, 0.01, 0.82), (s * 0.095, 0.0, 0.08), 0.074, 0.062))
    d = union(ds, 0.03)
    return smax(d, 0.075 - p[2], 0.004)


def shoes(p):
    ds = []
    for s in (-1, 1):
        ds.append(ellipsoid(p, (s * 0.096, -0.035, 0.028), (0.048, 0.115, 0.052)))
    d = union(ds, 0.01)
    return smax(d, -p[2], 0.006)


def surface_y(x, z):
    """顔の正面の表面の y を、前から光線を進めて求める。"""
    y = -0.2
    for _ in range(200):
        d = float(head_solid(np.array([[x], [y], [z]]))[0])
        if d < 1e-5:
            break
        y += max(d * 0.8, 1e-5)
    return y


EYE_Z = HC[2] - 0.012


def eye_center(side):
    x = side * 0.038
    return np.array([x, surface_y(x, EYE_Z) + 0.0035, EYE_Z])


def eyes(p):
    ds = []
    for s in (-1, 1):
        x, yc, z = eye_center(s)
        # ハコブくんの縦長の丸い目に寄せる：縦のカプセル（側面がまっすぐ）と楕円の中間の形
        k = 0.0118 / 0.0062
        ps = p.copy()
        ps[1] = (p[1] - yc) * k + yc
        pill = capsule(ps, (x, yc, z - 0.0072), (x, yc, z + 0.0072), 0.0118, 0.0118) / k
        ell = ellipsoid(p, (x, yc, z), (0.0128, 0.0062, 0.0188))
        ds.append(0.65 * pill + 0.35 * ell)
    return union(ds, 0.002)


def brow_points(side):
    xs = np.linspace(0.022, 0.056, 8)
    pts = [(side * x0, 0.0275 + 0.003 * (1 - ((x0 - 0.037) / 0.019) ** 2)) for x0 in xs]
    q = [(x0, surface_y(x0, HC[2] + z0) + 0.0012, HC[2] + z0) for x0, z0 in pts]
    rs = [0.0021 + 0.0005 * (1 - abs(x0 - 0.034) / 0.022) for x0 in xs]
    return q, rs


def brows(p):
    ds = []
    for s in (-1, 1):
        q, rs = brow_points(s)
        for i in range(len(q) - 1):
            ds.append(capsule(p, q[i], q[i + 1], rs[i], rs[i + 1]))
    return union(ds, 0.002)


MOUTH_Z = HC[2] - 0.068


def mouth_line(p, half_w, curve, z0=MOUTH_Z, r=0.0017):
    """横の線の口。curve＞0 で端が上がる（笑顔）、＜0 で端が下がる（困り顔）"""
    xs = np.linspace(-half_w, half_w, 13)
    q = []
    for x0 in xs:
        z = z0 + curve * (x0 / half_w) ** 2
        q.append((x0, surface_y(x0, z) + 0.0012, z))
    ds = [capsule(p, q[i], q[i + 1], r, r) for i in range(len(q) - 1)]
    return union(ds, 0.002)


def mouth_open(p, rx, rz, flat_top=True, z0=MOUTH_Z):
    """開いた口（話す・驚く）。flat_top で上がまっすぐ・下が丸い形"""
    y = surface_y(0.0, z0)
    zc = z0 + (rz * 0.35 if flat_top else 0.0)
    d = ellipsoid(p, (0.0, y + 0.0028, zc), (rx, 0.0045, rz))
    if flat_top:
        d = smax(d, p[2] - (zc - rz * 0.05), 0.0015)
    return d


MOUTHS = {   # 口の形（Unity で表示を切り替える。既定は smile）
    "smile": lambda p: mouth_line(p, 0.015, 0.006),
    "flat": lambda p: mouth_line(p, 0.012, 0.0),
    "worried": lambda p: mouth_line(p, 0.011, -0.004, z0=MOUTH_Z - 0.002),
    "talk": lambda p: mouth_open(p, 0.011, 0.0085),
    "surprised": lambda p: mouth_open(p, 0.0065, 0.0085, flat_top=False),
}


# ---------- 格子で面にする ----------
def mesh_of(fn, lo, hi, vox):
    lo, hi = np.array(lo, float) + vox * 0.37, np.array(hi, float)   # 格子の点が平らな面にちょうど乗らないようにずらす
    n = np.ceil((hi - lo) / vox).astype(int) + 1
    xs = lo[0] + np.arange(n[0]) * vox
    ys = lo[1] + np.arange(n[1]) * vox
    zs = lo[2] + np.arange(n[2]) * vox
    vol = np.empty((n[0], n[1], n[2]), np.float32)
    gx, gy = np.meshgrid(xs, ys, indexing="ij")
    step = 24
    for k0 in range(0, n[2], step):
        zz = zs[k0:k0 + step]
        P = np.stack([np.repeat(gx[..., None], len(zz), 2), np.repeat(gy[..., None], len(zz), 2),
                      np.broadcast_to(zz, (n[0], n[1], len(zz)))]).reshape(3, -1)
        vol[:, :, k0:k0 + step] = fn(P).reshape(n[0], n[1], len(zz))
    v, f, _, _ = marching_cubes(vol, 0.0, spacing=(vox, vox, vox))
    return v + lo, f


def to_blender(name, v, f, col, rough=0.6, sss=0.0, ratio=None):
    me = bpy.data.meshes.new(name)
    me.from_pydata(v.tolist(), [], f[:, ::-1].tolist())
    me.update()
    ob = bpy.data.objects.new(name, me)
    bpy.context.scene.collection.objects.link(ob)
    if ratio:
        m = ob.modifiers.new("dec", "DECIMATE")
        m.ratio = ratio
        bpy.context.view_layer.objects.active = ob
        bpy.ops.object.modifier_apply(modifier="dec")
    for poly in ob.data.polygons:
        poly.use_smooth = True
    mat = bpy.data.materials.new(name + "_Mat")
    mat.use_nodes = True
    b = mat.node_tree.nodes["Principled BSDF"]
    b.inputs["Base Color"].default_value = (*[(c / 255) ** 2.2 for c in col], 1)
    b.inputs["Roughness"].default_value = rough
    if sss:
        b.inputs["Subsurface Weight"].default_value = sss
        b.inputs["Subsurface Radius"].default_value = (0.02, 0.008, 0.005)
    ob.data.materials.append(mat)
    print(name, len(ob.data.polygons), "faces")
    return ob


SKIN = (232, 180, 142)
HAIR = (38, 30, 27)
ROYAL = (44, 84, 170)
WHITE = (238, 238, 234)
DARK = (44, 34, 30)
LIP = (150, 72, 66)

# ---------- 骨 ----------
# Unity の Humanoid がそのまま読み取れる名前にする（Left＝本人の左＝+x。前は -y）。
# 目・眉の骨は Humanoid に入れない追加の骨（まばたき・眉の上げ下げをスクリプトで動かす）。
def leg_points(side):
    s = 1 if side > 0 else -1
    hip = np.array([s * 0.085, 0.010, 0.860])
    knee = np.array([s * 0.090, 0.000, 0.470])
    ankle = np.array([s * 0.095, 0.004, 0.085])
    ball = np.array([s * 0.096, -0.075, 0.025])
    tip = np.array([s * 0.096, -0.140, 0.022])
    return hip, knee, ankle, ball, tip


FWD = (0, -1, 0)


def bone_layout():
    """{骨の名前: (根元, 先, 親, 骨の z 軸を向ける向き)}"""
    B = {
        "Hips": ((0, 0.012, 0.90), (0, 0.012, 0.98), None, FWD),
        "Spine": ((0, 0.012, 0.98), (0, 0.012, 1.11), "Hips", FWD),
        "Chest": ((0, 0.012, 1.11), (0, 0.012, 1.30), "Spine", FWD),
        "Neck": ((0, 0.012, 1.31), (0, 0.006, 1.42), "Chest", FWD),
        "Head": ((0, 0.006, 1.42), (0, 0.0, 1.62), "Neck", FWD),
    }
    for side, L, tag in ((1, "Left", "L"), (-1, "Right", "R")):
        s = side
        sh, el, wr, _, _ = arm_points(side)
        g = hand_geo(side)
        n = g["n"]
        B[L + "Shoulder"] = ((s * 0.030, 0.012, 1.325), sh, "Chest", FWD)
        B[L + "UpperArm"] = (sh, el, L + "Shoulder", FWD)
        B[L + "LowerArm"] = (el, wr, L + "UpperArm", FWD)
        B[L + "Hand"] = (wr, g["palm_end"], L + "LowerArm", n)
        for fname, (a, b, c, _) in g["fingers"].items():
            B[L + fname + "Proximal"] = (a, b, L + "Hand", n)
            B[L + fname + "Intermediate"] = (b, c, L + fname + "Proximal", n)
        ta, tb, tc = g["thumb"]
        B[L + "ThumbProximal"] = (ta, tb, L + "Hand", n)
        B[L + "ThumbIntermediate"] = (tb, tc, L + "ThumbProximal", n)
        hip, knee, ankle, ball, tip = leg_points(side)
        B[L + "UpperLeg"] = (hip, knee, "Hips", FWD)
        B[L + "LowerLeg"] = (knee, ankle, L + "UpperLeg", FWD)
        B[L + "Foot"] = (ankle, ball, L + "LowerLeg", (0, 0, 1))
        B[L + "Toes"] = (ball, tip, L + "Foot", (0, 0, 1))
        ec = eye_center(side)
        B["Eye_" + tag] = (ec, ec + np.array([0, -0.015, 0]), "Head", (0, 0, 1))
        q, _ = brow_points(side)
        bc = np.mean(np.array(q), axis=0)
        B["Brow_" + tag] = (bc, bc + np.array([0, -0.012, 0]), "Head", (0, 0, 1))
    return B


def band(p, z0, z1):
    return np.maximum(z0 - p[2], p[2] - z1)


def regions(kind, p):
    """部品の種類ごとに、骨ごとの「その骨の受け持つ形までの距離」を返す。重みはここから決める"""
    R = {}
    if kind == "skin":
        R["Head"] = head_solid(p)
        R["Neck"] = capsule(p, (0, 0.012, 1.30), (0, 0.006, 1.45), 0.044, 0.041)
        R["Chest"] = ellipsoid(p, (0, 0.012, 1.27), (0.10, 0.066, 0.08))
        for side, L in ((1, "Left"), (-1, "Right")):
            sh, el, wr, _, _ = arm_points(side)
            R[L + "Shoulder"] = capsule(p, (side * 0.045, 0.015, 1.335), (side * 0.12, 0.015, 1.31), 0.024, 0.022)
            R[L + "UpperArm"] = capsule(p, sh.tolist(), el.tolist(), 0.040, 0.037)
            R[L + "LowerArm"] = capsule(p, el.tolist(), wr.tolist(), 0.036, 0.029)
            R[L + "Hand"] = palm(p, side)
            for k, v in finger_segs(p, side).items():
                R[L + k] = v
    elif kind == "top":
        rr = 0.068
        c = np.array([0.0, 0.010, 1.085]).reshape(3, 1)
        b = np.array([0.158 - rr, 0.098 - rr, 0.30 - rr]).reshape(3, 1)
        qq = np.abs(p - c) - b
        torso = length(np.maximum(qq, 0)) + np.minimum(qq.max(axis=0), 0) - rr
        R["Hips"] = np.maximum(torso, band(p, 0.60, 0.94))
        R["Spine"] = np.maximum(torso, band(p, 0.94, 1.10))
        R["Chest"] = np.maximum(torso, band(p, 1.10, 1.45))
        for side, L in ((1, "Left"), (-1, "Right")):
            sh, _, _, _, _ = arm_points(side)
            R[L + "Shoulder"] = capsule(p, (side * 0.045, 0.012, 1.33), sh.tolist(), 0.045, 0.045)
    elif kind == "pants":
        R["Hips"] = ellipsoid(p, (0, 0.012, 0.86), (0.148, 0.086, 0.10))
        for side, L in ((1, "Left"), (-1, "Right")):
            hip, knee, ankle, _, _ = leg_points(side)
            R[L + "UpperLeg"] = capsule(p, hip.tolist(), knee.tolist(), 0.074, 0.066)
            R[L + "LowerLeg"] = capsule(p, knee.tolist(), ankle.tolist(), 0.066, 0.062)
    elif kind == "shoes":
        for side, L in ((1, "Left"), (-1, "Right")):
            _, _, ankle, ball, tip = leg_points(side)
            R[L + "Foot"] = capsule(p, ankle.tolist(), ball.tolist(), 0.05, 0.05)
            R[L + "Toes"] = capsule(p, ball.tolist(), tip.tolist(), 0.045, 0.04)
    return R


def tau_of(bone):
    if any(k in bone for k in ("Index", "Middle", "Ring", "Little")):
        return 0.0022
    if "Thumb" in bone:
        return 0.0025
    if bone.endswith("Hand"):
        return 0.005
    if bone in ("Hips", "Spine", "Chest"):
        return 0.018
    if "Leg" in bone or "Foot" in bone or "Toes" in bone:
        return 0.018
    return 0.011


def weight_mesh(ob, kind):
    P = np.array([v.co for v in ob.data.vertices]).T          # 根は原点・回転なしなので形の座標そのまま
    R = regions(kind, P)
    names = list(R)
    D = np.stack([R[k] for k in names])
    dmin = D.min(axis=0)
    W = np.stack([np.exp(-np.maximum(D[i] - dmin, 0) / tau_of(k)) for i, k in enumerate(names)])
    W = W / W.sum(axis=0)
    top4 = np.argsort(-W, axis=0)[:4]
    groups = {k: ob.vertex_groups.new(name=k) for k in names}
    for vi in range(P.shape[1]):
        idx = top4[:, vi]
        w = W[idx, vi]
        w = w / w.sum()
        for j, ww in zip(idx, w):
            if ww > 0.01:
                groups[names[j]].add([vi], float(ww), "REPLACE")


def weight_sleeves(ob):
    """袖はまるごと上腕の骨に付ける（付け根を肩の骨と分け合うと、腕を上げた時に袖の山がゆがんだ）。
    袖の山は肩の関節のすぐ外にあるので、腕と一緒に回しても肩から離れない"""
    for side, L in ((1, "Left"), (-1, "Right")):
        g = ob.vertex_groups.new(name=L + "UpperArm")
        g.add([v.index for v in ob.data.vertices if v.co.x * side > 0], 1.0, "REPLACE")


def weight_whole(ob, bone):
    g = ob.vertex_groups.new(name=bone)
    g.add(list(range(len(ob.data.vertices))), 1.0, "REPLACE")


def weight_by_side(ob, prefix):
    for tag, sgn in (("L", 1), ("R", -1)):
        g = ob.vertex_groups.new(name=prefix + tag)
        g.add([v.index for v in ob.data.vertices if v.co.x * sgn > 0], 1.0, "REPLACE")


def build_armature():
    from mathutils import Vector
    arm = bpy.data.armatures.new("Nurse_Oshiro_Rig")
    rig = bpy.data.objects.new("Nurse_Oshiro", arm)          # 根：床の中心
    bpy.context.scene.collection.objects.link(rig)
    bpy.context.view_layer.objects.active = rig
    bpy.ops.object.mode_set(mode="EDIT")
    B = bone_layout()
    eb = {}
    for name, (h, t, _, _) in B.items():
        b = arm.edit_bones.new(name)
        b.head = Vector([float(x) for x in h])
        b.tail = Vector([float(x) for x in t])
        eb[name] = b
    for name, (_, _, parent, zdir) in B.items():
        if parent:
            eb[name].parent = eb[parent]
        eb[name].align_roll(Vector([float(x) for x in zdir]))
    bpy.ops.object.mode_set(mode="OBJECT")
    return rig


SKIN = (232, 180, 142)
HAIR = (38, 30, 27)
ROYAL = (44, 84, 170)
WHITE = (238, 238, 234)
DARK = (44, 34, 30)
LIP = (150, 72, 66)
MOUTH_IN = (120, 48, 46)

bpy.ops.wm.read_factory_settings(use_empty=True)
FACE_LO, FACE_HI = (-0.08, -0.14, HC[2] - 0.04), (0.08, -0.06, HC[2] + 0.05)
MOUTH_LO, MOUTH_HI = (-0.03, -0.13, HC[2] - 0.095), (0.03, -0.07, HC[2] - 0.05)
# (名前, 形, 範囲, 格子, 色, 粗さ, 肌の透け, 三角形を残す割合, 重みの付け方)
parts = [
    ("Skin", skin, (-0.34, -0.18, 0.58), (0.34, 0.15, 1.67), VOX, SKIN, 0.55, 0.12, 0.07, ("mesh", "skin")),
    ("Hair", hair, (-0.15, -0.16, 1.32), (0.15, 0.21, 1.69), VOX, HAIR, 0.5, 0, 0.06, ("whole", "Head")),
    ("Top", top, (-0.30, -0.15, 0.78), (0.30, 0.15, 1.40), VOX, ROYAL, 0.75, 0, 0.025, ("mesh", "top")),
    ("Sleeves", sleeves, (-0.30, -0.12, 1.08), (0.30, 0.12, 1.40), VOX, ROYAL, 0.75, 0, 0.05, ("sleeves", None)),
    ("Pants", pants, (-0.20, -0.13, 0.05), (0.20, 0.14, 1.0), VOX * 1.4, ROYAL, 0.75, 0, 0.04, ("mesh", "pants")),
    ("Shoes", shoes, (-0.17, -0.17, -0.01), (0.17, 0.10, 0.11), VOX, WHITE, 0.5, 0, 0.05, ("mesh", "shoes")),
    ("Eyes", eyes, FACE_LO, FACE_HI, 0.0008, DARK, 0.35, 0, 0.15, ("side", "Eye_")),
    ("Brows", brows, FACE_LO, FACE_HI, 0.0008, DARK, 0.35, 0, 0.15, ("side", "Brow_")),
]
for k, fn in MOUTHS.items():
    col = MOUTH_IN if k in ("talk", "surprised") else LIP
    parts.append(("Mouth_" + k, fn, MOUTH_LO, MOUTH_HI, 0.0006, col, 0.5, 0, 0.3, ("whole", "Head")))

rig = build_armature()
tris = {}
for name, fn, lo, hi, vox, col, rough, sss, ratio, how in parts:
    v, f = mesh_of(fn, lo, hi, vox)
    ob = to_blender("Nurse_" + name, v, f, col, rough, sss, ratio)
    if how[0] == "mesh":
        weight_mesh(ob, how[1])
    elif how[0] == "sleeves":
        weight_sleeves(ob)
    elif how[0] == "whole":
        weight_whole(ob, how[1])
    else:
        weight_by_side(ob, how[1])
    ob.parent = rig
    mod = ob.modifiers.new("Armature", "ARMATURE")
    mod.object = rig
    if name.startswith("Mouth_") and name != "Mouth_smile":
        ob.hide_render = True                                  # 既定の顔は笑顔。ほかの口は隠しておく
        ob.hide_viewport = True
    tris[ob.name] = sum(len(p.vertices) - 2 for p in ob.data.polygons)
tris["_total_triangles"] = sum(tris.values())
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUT, "nurse_clay.blend"))
for ob in bpy.data.objects:      # FBX には隠した口も入れる（Unity で切り替える）
    ob.hide_viewport = False
bpy.ops.export_scene.fbx(
    filepath=os.path.join(OUT, "nurse_oshiro.fbx"), use_selection=False, object_types={"ARMATURE", "MESH"},
    apply_unit_scale=True, apply_scale_options="FBX_SCALE_ALL",
    axis_forward="-Z", axis_up="Y", bake_space_transform=False,
    mesh_smooth_type="FACE", use_mesh_modifiers=False, use_triangles=True,
    add_leaf_bones=False, primary_bone_axis="Y", secondary_bone_axis="X", armature_nodetype="NULL",
    bake_anim=False, path_mode="STRIP", embed_textures=False,
)
import json  # noqa: E402
json.dump({"height_m": 1.65, "triangles": tris, "bones": list(bone_layout())},
          open(os.path.join(OUT, "nurse_report.json"), "w"), ensure_ascii=False, indent=1)
print(json.dumps(tris, ensure_ascii=False))
```

#### 袖の直しの差分（e3daa10 との差）

```diff
diff --git a/tools/sim_people/nurse_oshiro/nurse_build.py b/tools/sim_people/nurse_oshiro/nurse_build.py
index b1ada99..fe796f3 100644
--- a/tools/sim_people/nurse_oshiro/nurse_build.py
+++ b/tools/sim_people/nurse_oshiro/nurse_build.py
@@ -527,22 +527,11 @@ def weight_mesh(ob, kind):
 
 
 def weight_sleeves(ob):
-    """袖：付け根（腕の骨の根元から 4cm）だけ肩の骨と分け合い、そこから先は腕の骨だけ"""
-    gs = {}
+    """袖はまるごと上腕の骨に付ける（付け根を肩の骨と分け合うと、腕を上げた時に袖の山がゆがんだ）。
+    袖の山は肩の関節のすぐ外にあるので、腕と一緒に回しても肩から離れない"""
     for side, L in ((1, "Left"), (-1, "Right")):
-        sh, _, _, d1, _ = arm_points(side)
-        ga = ob.vertex_groups.new(name=L + "UpperArm")
-        gsh = ob.vertex_groups.new(name=L + "Shoulder")
-        for v in ob.data.vertices:
-            if v.co.x * side <= 0:
-                continue
-            proj = float(np.dot(np.array(v.co) - sh, d1))
-            w = min(max((proj + 0.01) / 0.04, 0.0), 1.0)
-            if w > 0:
-                ga.add([v.index], w, "REPLACE")
-            if w < 1:
-                gsh.add([v.index], 1 - w, "REPLACE")
-    return gs
+        g = ob.vertex_groups.new(name=L + "UpperArm")
+        g.add([v.index for v in ob.data.vertices if v.co.x * side > 0], 1.0, "REPLACE")
 
 
 def weight_whole(ob, bone):
```

### 看護師の姿勢の確かめ：nurse_pose.py

```python
"""骨の動きを確かめる：決めた姿勢を付けて描く。
python3 nurse_pose.py <nurse_clay.blend> <出力フォルダ> [姿勢の名前...]
姿勢：rest give receive drawer point bow worried talk
（Unity では Humanoid の動き・腕の IK で動かす。ここの姿勢は、骨と重みで服や肌が破れないかの確認用）
"""
import math
import os
import sys

import bpy  # noqa: I001
from mathutils import Matrix, Vector

src, out = sys.argv[1], sys.argv[2]
want = sys.argv[3:]
os.makedirs(out, exist_ok=True)


def setup_scene():
    sc = bpy.context.scene
    sc.render.engine = "CYCLES"
    sc.cycles.device = "CPU"
    sc.cycles.samples = 20
    sc.cycles.use_denoising = True
    sc.view_settings.view_transform = "Standard"
    w = bpy.data.worlds.new("W")
    sc.world = w
    w.use_nodes = True
    bg = w.node_tree.nodes["Background"]
    bg.inputs["Color"].default_value = (0.80, 0.90, 0.89, 1)
    bg.inputs["Strength"].default_value = 0.65
    for nm, en, sz, loc in (("K", 380, 3, (-2.2, -2.8, 3.0)), ("F", 130, 4, (2.8, -2.0, 1.4))):
        L = bpy.data.lights.new(nm, "AREA")
        L.energy, L.size = en, sz
        o = bpy.data.objects.new(nm, L)
        o.location = loc
        o.rotation_euler = (Vector((0, 0, 1.0)) - Vector(loc)).to_track_quat("-Z", "Y").to_euler()
        sc.collection.objects.link(o)
    return sc


def rot_world(rig, bone, axis, deg, about=None):
    """骨を、ワールドの軸 axis のまわりに deg 度回す（回す中心は骨の根元）"""
    pb = rig.pose.bones[bone]
    h = Vector(about) if about is not None else pb.head.copy()
    R = Matrix.Rotation(math.radians(deg), 4, Vector(axis).normalized())
    pb.matrix = Matrix.Translation(h) @ R @ Matrix.Translation(-h) @ pb.matrix
    bpy.context.view_layer.update()


def twist(rig, bone, deg):
    """骨を自分の向きのまわりにねじる"""
    pb = rig.pose.bones[bone]
    rot_world(rig, bone, pb.tail - pb.head, deg)


def curl(rig, side, deg, fingers=("Index", "Middle", "Ring", "Little"), thumb=None):
    """指を手のひらのほうへ曲げる（骨の z 軸を手のひらに向けてあるので、x 軸まわりに回す）"""
    for f in fingers:
        for seg, k in (("Proximal", 1.0), ("Intermediate", 1.2)):
            pb = rig.pose.bones[side + f + seg]
            pb.rotation_mode = "XYZ"
            pb.rotation_euler.x = math.radians(deg * k)
    if thumb is not None:
        for seg in ("Proximal", "Intermediate"):
            pb = rig.pose.bones[side + "Thumb" + seg]
            pb.rotation_mode = "XYZ"
            pb.rotation_euler.x = math.radians(thumb)
    bpy.context.view_layer.update()


def mouth(kind):
    for ob in bpy.data.objects:
        if ob.name.startswith("Nurse_Mouth_"):
            on = ob.name == "Nurse_Mouth_" + kind
            ob.hide_render = not on
            ob.hide_viewport = not on


def brows(rig, deg, lift=0.0):
    """眉：deg＞0 で眉頭が上がる（困り顔）。lift は上へ（m）"""
    for tag, sgn in (("L", 1), ("R", -1)):
        pb = rig.pose.bones["Brow_" + tag]
        rot_world(rig, "Brow_" + tag, (0, 1, 0), deg * sgn)
        pb.matrix = Matrix.Translation((0, 0, lift)) @ pb.matrix
        bpy.context.view_layer.update()


def reset(rig):
    for pb in rig.pose.bones:
        pb.matrix_basis = Matrix.Identity(4)
    bpy.context.view_layer.update()
    mouth("smile")


X, Y, Z = (1, 0, 0), (0, 1, 0), (0, 0, 1)


def arm_forward(rig, S, up_deg, elbow_deg, palm_up=0.0):
    """腕を前へ上げる（up_deg）。ひじを曲げて前腕をさらに上げる（elbow_deg）。palm_up で手のひらを上へ"""
    rot_world(rig, S + "UpperArm", X, -up_deg)
    rot_world(rig, S + "LowerArm", X, -elbow_deg)
    if palm_up:
        sgn = 1 if S == "Right" else -1
        twist(rig, S + "LowerArm", sgn * palm_up * 0.5)
        twist(rig, S + "Hand", sgn * palm_up * 0.5)


POSES = {}


def pose(fn):
    POSES[fn.__name__] = fn
    return fn


@pose
def rest(rig):
    pass


@pose
def give(rig):      # 片手で物を差し出す
    arm_forward(rig, "Right", 55, 30, palm_up=80)
    curl(rig, "Right", 12, thumb=5)
    rot_world(rig, "Head", X, 8)
    rot_world(rig, "LeftUpperArm", Y, 4)


@pose
def receive(rig):   # 両手で受け取る
    for S in ("Left", "Right"):
        arm_forward(rig, S, 40, 35, palm_up=80)
        curl(rig, S, 15, thumb=5)
    rot_world(rig, "Chest", X, 6)
    rot_world(rig, "Head", X, 10)


@pose
def drawer(rig):    # 前かがみで引き出しの中の物をつかむ
    rot_world(rig, "Spine", X, 12)
    rot_world(rig, "Chest", X, 12)
    for S in ("Left", "Right"):
        arm_forward(rig, S, 35, 10)
        curl(rig, S, 55, thumb=30)
    rot_world(rig, "Head", X, 12)


@pose
def point(rig):     # 指で示す（話しながら）
    arm_forward(rig, "Right", 75, 5)
    rot_world(rig, "RightUpperArm", Z, -18)
    curl(rig, "Right", 85, fingers=("Middle", "Ring", "Little"), thumb=40)
    rot_world(rig, "Head", Z, -12)
    mouth("talk")


@pose
def bow(rig):       # おじぎ
    rot_world(rig, "Spine", X, 18)
    rot_world(rig, "Chest", X, 14)
    rot_world(rig, "Head", X, 6)
    for S, sgn in (("Left", -1), ("Right", 1)):
        rot_world(rig, S + "UpperArm", Y, sgn * -6)
        rot_world(rig, S + "UpperArm", X, -12)


@pose
def worried(rig):   # 困った：首をかしげ、手を口元へ
    rot_world(rig, "Head", Y, 10)
    rot_world(rig, "Head", X, 6)
    arm_forward(rig, "Right", 25, 115)
    rot_world(rig, "RightUpperArm", Z, -8)
    curl(rig, "Right", 30, thumb=10)
    brows(rig, 16, lift=0.002)
    mouth("worried")


@pose
def talk(rig):      # 話しかける：手のひらを見せる
    arm_forward(rig, "Left", 30, 55, palm_up=60)
    curl(rig, "Left", 10, thumb=0)
    rot_world(rig, "Head", Z, 8)
    brows(rig, 0, lift=0.003)
    mouth("talk")


bpy.ops.wm.open_mainfile(filepath=src)
rig = bpy.data.objects["Nurse_Oshiro"]
sc = setup_scene()
H = 1.65
views = {"full": ((1.6, -3.0, 1.15), (0, -0.1, 0.95), 50, (520, 820)),
         "close": ((0.75, -1.25, 1.42), (0, -0.15, 1.22), 50, (560, 560))}
for name in (want or list(POSES)):
    reset(rig)
    POSES[name](rig)
    for vname, (loc, tgt, lens, res) in views.items():
        for o in [o for o in bpy.data.objects if o.type == "CAMERA"]:
            bpy.data.objects.remove(o)
        cam = bpy.data.cameras.new("C")
        cam.lens = lens
        co = bpy.data.objects.new("C", cam)
        co.location = loc
        co.rotation_euler = (Vector(tgt) - Vector(loc)).to_track_quat("-Z", "Y").to_euler()
        sc.collection.objects.link(co)
        sc.camera = co
        sc.render.resolution_x, sc.render.resolution_y = res
        sc.render.filepath = os.path.join(out, f"pose_{name}_{vname}.png")
        bpy.ops.render.render(write_still=True)
    print("wrote", name)
```

### 看護師の確認用の絵：nurse_render.py

```python
"""粘土の人を描く。python3 nurse_render.py <blend> <out_dir> [views...]"""
import math
import os
import sys

import bpy  # noqa: I001
from mathutils import Vector

bpy.ops.wm.open_mainfile(filepath=sys.argv[1])
out = sys.argv[2]
want = sys.argv[3:]
os.makedirs(out, exist_ok=True)
H = 1.65
sc = bpy.context.scene
sc.render.engine = "CYCLES"
sc.cycles.device = "CPU"
sc.cycles.samples = 24
sc.cycles.use_denoising = True
sc.view_settings.view_transform = "Standard"
w = bpy.data.worlds.new("W")
sc.world = w
w.use_nodes = True
bg = w.node_tree.nodes["Background"]
bg.inputs["Color"].default_value = (0.80, 0.90, 0.89, 1)
bg.inputs["Strength"].default_value = 0.65
key = bpy.data.lights.new("K", "AREA")
key.energy = 380
key.size = 3
ko = bpy.data.objects.new("K", key)
ko.location = (-2.2, -2.8, 3.0)
ko.rotation_euler = (Vector((0, 0, 1.0)) - Vector(ko.location)).to_track_quat("-Z", "Y").to_euler()
sc.collection.objects.link(ko)
fill = bpy.data.lights.new("F", "AREA")
fill.energy = 130
fill.size = 4
fo = bpy.data.objects.new("F", fill)
fo.location = (2.8, -2.0, 1.4)
fo.rotation_euler = (Vector((0, 0, 1.0)) - Vector(fo.location)).to_track_quat("-Z", "Y").to_euler()
sc.collection.objects.link(fo)
views = {
    "front": ((0, -6, H / 2), (0, 0, H / 2), H * 1.08, (520, 1000)),
    "side": ((-6, 0, H / 2), (0, 0, H / 2), H * 1.08, (520, 1000)),
    "back": ((0, 6, H / 2), (0, 0, H / 2), H * 1.08, (520, 1000)),
    "persp": ((1.5, -3.2, 1.25), (0, 0, H * 0.52), None, (600, 1000)),
    "face": ((0.32, -1.3, 1.53), (0, 0, 1.49), 0.36, (640, 640)),
    "neck": ((0.55, -0.75, 1.42), (0.03, 0, 1.34), 0.26, (640, 640)),
    "neckback": ((-0.5, 0.9, 1.45), (0, 0, 1.34), 0.30, (640, 640)),
    "upper": ((-0.9, -2.2, 1.45), (0, 0, 1.2), None, (700, 800)),
}
for name, (loc, tgt, ortho, res) in views.items():
    if want and name not in want:
        continue
    cam = bpy.data.cameras.new("C")
    if ortho:
        cam.type = "ORTHO"
        cam.ortho_scale = ortho
    else:
        cam.lens = 55 if name == "persp" else 70
    co = bpy.data.objects.new("C", cam)
    co.location = loc
    co.rotation_euler = (Vector(tgt) - Vector(loc)).to_track_quat("-Z", "Y").to_euler()
    sc.collection.objects.link(co)
    sc.camera = co
    sc.render.resolution_x, sc.render.resolution_y = res
    sc.render.filepath = os.path.join(out, f"{name}.png")
    bpy.ops.render.render(write_still=True)
    print("wrote", name)
```

### 看護師の出力：nurse_report.json（部品ごとの三角形と骨の名前）

```json
{
 "height_m": 1.65,
 "triangles": {
  "Nurse_Skin": 15258,
  "Nurse_Hair": 5006,
  "Nurse_Top": 9998,
  "Nurse_Sleeves": 4222,
  "Nurse_Pants": 6556,
  "Nurse_Shoes": 2520,
  "Nurse_Eyes": 2616,
  "Nurse_Brows": 1004,
  "Nurse_Mouth_smile": 1190,
  "Nurse_Mouth_flat": 878,
  "Nurse_Mouth_worried": 938,
  "Nurse_Mouth_talk": 1120,
  "Nurse_Mouth_surprised": 1286,
  "_total_triangles": 52592
 },
 "bones": [
  "Hips",
  "Spine",
  "Chest",
  "Neck",
  "Head",
  "LeftShoulder",
  "LeftUpperArm",
  "LeftLowerArm",
  "LeftHand",
  "LeftIndexProximal",
  "LeftIndexIntermediate",
  "LeftMiddleProximal",
  "LeftMiddleIntermediate",
  "LeftRingProximal",
  "LeftRingIntermediate",
  "LeftLittleProximal",
  "LeftLittleIntermediate",
  "LeftThumbProximal",
  "LeftThumbIntermediate",
  "LeftUpperLeg",
  "LeftLowerLeg",
  "LeftFoot",
  "LeftToes",
  "Eye_L",
  "Brow_L",
  "RightShoulder",
  "RightUpperArm",
  "RightLowerArm",
  "RightHand",
  "RightIndexProximal",
  "RightIndexIntermediate",
  "RightMiddleProximal",
  "RightMiddleIntermediate",
  "RightRingProximal",
  "RightRingIntermediate",
  "RightLittleProximal",
  "RightLittleIntermediate",
  "RightThumbProximal",
  "RightThumbIntermediate",
  "RightUpperLeg",
  "RightLowerLeg",
  "RightFoot",
  "RightToes",
  "Eye_R",
  "Brow_R"
 ]
}
```

### 看護師の README.md（p2m-site の tools/sim_people/nurse_oshiro/）

```markdown
# 看護師（大城さん）の3Dモデル（サービスロボット導入シミュレーション・病院の場面の人）

ハコブくんと人のやり取りを、現場の具体的な場面として短く切り取って見せるための人（2026-11-13）。物を渡す・受け取る、トラブルの時の動き、話しかける姿などを Unity で動かす。マンガの大城さんの三面図（oshiro_ref.png）を元に、粘土のような簡略化した見た目で作った。

- 見た目の決まり（エイイチの指示、2026-11-13）：リアルにしすぎない。デフォルメもしすぎない（頭は身長の約7分の1）。服はマンガのとおり（Vネックのスクラブ・胸ポケット1つ・すそのポケット2つ・まっすぐなパンツ・白い靴、ナースキャップなし）。色はロイヤルブルー。髪は低いお団子と横に流した前髪。目はハコブくんの縦長の目に少し寄せた形（同じにはしない）
- 大きさ：身長1.65m（髪のてっぺんまで1.642m）
- 向き：根（骨組みの Nurse_Oshiro）は床の中心。顔が Unity の +Z を向く

## 作り方

1. `pip install bpy==5.3.0 scikit-image`（Blender を Python から使う・面を作る）
2. `python3 nurse_build.py <出力フォルダ> --vox 0.0025`：nurse_oshiro.fbx・nurse_clay.blend・nurse_report.json（部品ごとの三角形の数・骨の名前）を作る
3. 確認用の絵（GitHub に入れない。iCloud の サービスロボット導入シミュレーション/hospital/people_3d/）
   - `python3 nurse_render.py <出力>/nurse_clay.blend <絵のフォルダ> front side back persp face upper`：立った姿
   - `python3 nurse_pose.py <出力>/nurse_clay.blend <絵のフォルダ>`：骨で姿勢を付けた絵（立つ・片手で差し出す・両手で受け取る・引き出しの物をつかむ・指で示す・おじぎ・困った・話しかける）。服や肌が破れないかの確認用

形は、部品ごとに「表面からの距離」を式で書き（頭・首・腕・手・指・服・髪）、格子で面にしてから三角形を減らしている。寸法はすべて nurse_build.py の中の数字で、直すときはそこを変えて作り直す。骨の位置も同じ数字から決めているので、形を直せば骨も一緒に合う。

## 中身（三角形 52,592・材質は部品ごと）

| 名前 | 中身 | 三角形 |
|---|---|---|
| Nurse_Oshiro | 骨組み（45本）。床の中心 | – |
| Nurse_Skin | 頭・首・胸元・腕・手（指5本ずつ） | 15,258 |
| Nurse_Hair | 髪（前髪・お団子） | 5,006 |
| Nurse_Top | スクラブの上の胴（Vネック・ポケット3つ） | 9,998 |
| Nurse_Sleeves | 袖（両方） | 4,222 |
| Nurse_Pants | パンツ | 6,556 |
| Nurse_Shoes | 白い靴 | 2,520 |
| Nurse_Eyes | 目 | 2,616 |
| Nurse_Brows | 眉 | 1,004 |
| Nurse_Mouth_smile | 口：笑顔（既定で表示） | 1,190 |
| Nurse_Mouth_flat / worried / talk / surprised | 口：まっすぐ・困り・話す・驚く（既定では隠してある） | 878 / 938 / 1,120 / 1,286 |

- 色は画像を使わず、部品ごとの材質の色だけ（肌・髪・服・靴・目・口）

## 骨（Unity の Humanoid にそのまま割り当てられる名前）

- 体：Hips → Spine → Chest → Neck → Head
- 腕：Left/Right の Shoulder → UpperArm → LowerArm → Hand
- 指：Left/Right の Index・Middle・Ring・Little・Thumb の Proximal → Intermediate（指先の節はない）
- 脚：Left/Right の UpperLeg → LowerLeg → Foot → Toes
- Left は本人の左。作った時の姿勢は、腕を体の横に少し開いて下ろした形（Unity の Humanoid では T ポーズに直して読ませる）
- 顔の骨（Humanoid には入れない）：Eye_L・Eye_R（目を上下につぶすとまばたき）、Brow_L・Brow_R（回すと眉頭が上がって困り顔、上へ動かすと驚き顔）
- 口は骨ではなく、5つの口の部品の表示を切り替える（話す時は talk と smile／flat を交互に出す）

## 動かした時に気をつけたこと

- 袖は胴とつなげず、別の部品にしてある。つなげると、腕を前に出した時に袖と胴の継ぎ目が裂けて、すき間から肌が見えた。袖は付け根の4cmだけ肩の骨と分け合い、そこから先は上腕の骨だけで動く
- 袖の中に隠れる腕の付け根の肌は作っていない（服がのびた時に肌が服から突き出ないように）
- 指の骨の z 軸は手のひらに向けてある。骨の x 軸まわりに回すと、指が手のひらのほうへ曲がる（握る・つかむ）
- 胴の服の重みは高さで Hips・Spine・Chest に分け、境目はなめらかにつないである（おじぎで胴を曲げても段にならない）

ハコブくん（tools/sim_robots/hakobu/）と並べて使う。ハコブくんの体の角も、この人の見た目に合わせて丸めてある。
```

### ハコブくんの作り方：hakobu_build.py（体の角を丸めた版・fae15fa）

`python3 hakobu_build.py --out <出力フォルダ>` で動きます。丸めの既定は `--soft 3.5`、横の板は既定で付きます（`--no-pods` で外す）。

```python
"""ハコブくんの軽い3Dモデルを Blender（bpy）で作り、Unity 用に書き出す。

使い方:
  python3 hakobu_build.py --out <出力フォルダ> [--W 0.459 --D 0.549 --H 1.081] [--render]

大きさの既定は、病院の搬送ロボットのシミュレーションのロボット
（p2m-site tools/hospital_sim/hospital-unity-params.json の robot.size_m）。
外形は hakobu_profiles.json（三面図から測った外形）から作る。
向き: Blender では前が -Y・上が +Z。FBX は Unity で前が +Z・上が +Y になるように書き出す。
"""
import argparse
import json
import math
import os
import sys

import bpy  # noqa: I001  bpy を先に読む（bmesh・mathutils は bpy が用意する）
import bmesh
from mathutils import Vector
from PIL import Image, ImageDraw, ImageFilter

HERE = os.path.dirname(os.path.abspath(__file__))

# ---------------------------------------------------------------- 色（画像の中の色の区画）
COL = {
    "white": (236, 234, 229),
    "band": (214, 212, 207),      # 頭の横の帯
    "seam": (186, 184, 179),      # 継ぎ目の線
    "door": (222, 220, 215),
    "base": (78, 79, 82),         # 台
    "black": (30, 31, 33),        # バンパー・取っ手・口
    "screen": (14, 14, 16),
    "green": (96, 236, 140),
    "drawer_in": (196, 195, 190),
    "tray": (206, 228, 240),
    "eye": (250, 250, 250),
    "mouth": (84, 170, 236),
}
EMIT = {"green": (54, 227, 122)}
ATLAS = 1024
# 区画（左上の x, y, 幅, 高さ）
R_FACE = (0, 0, 512, 488)
R_SIDE = (512, 0, 256, 512)
R_FRONT = (768, 0, 256, 512)
R_BACK = (0, 512, 256, 512)
SW_ORIGIN = (256, 512)
SW_CELL = 64
SWATCH_NAMES = ["white", "band", "base", "black", "screen", "green", "drawer_in", "tray", "door"]


def swatch_px(name):
    i = SWATCH_NAMES.index(name)
    cols = (ATLAS - SW_ORIGIN[0]) // SW_CELL
    x = SW_ORIGIN[0] + (i % cols) * SW_CELL
    y = SW_ORIGIN[1] + (i // cols) * SW_CELL
    return x, y


def swatch_uv(name):
    x, y = swatch_px(name)
    return ((x + SW_CELL / 2) / ATLAS, 1 - (y + SW_CELL / 2) / ATLAS)


# ---------------------------------------------------------------- 三面図から測った位置（正規化）
# 正面: 幅は全幅=1（中心0）、高さは床0〜てっぺん1。横: 奥行きは全奥行き=1（前が負）。
F = {
    "screen_hw": 0.3094, "screen_t": (0.7216, 0.9639),
    "eye_dx": 0.1596, "eye_t": 0.8473,
    "drawer_hw": 0.4247, "drawer_t": (0.3286, 0.6203),
    "handle_hw": 0.1161, "handle_t": (0.5292, 0.5773),
    "bumper_t": (0.174, 0.235), "bumper_back": -0.303,
    "slot_hw": 0.0798, "slot_t": (0.1426, 0.1649),
    "base_t": (0.058, 0.113),
    "wheel_x": 0.3525, "wheel_y": (-0.3412, 0.3135),
    "ring_c": (0.0217, 0.4661),
    "pod": {"d": (-0.3428, 0.4224), "t": (0.173, 0.762),
            "r": {"ft": (0.203, 0.112), "bt": (0.03, 0.02), "bb": (0.139, 0.129), "fb": (0.116, 0.118)}},
    "band_t": (0.7915, 0.895),
    "head_seam_t": 0.703,
    # 後ろ（見る人から見た左右。u は左端0〜右端1）
    "back": {
        "notch": (0.0534, (0.8724, 0.8892)),
        "ctrl": (0.1988, (0.6279, 0.7213)),
        "door": (0.3352, (0.3323, 0.6248)),
        "panel": (0.3412, (0.1718, 0.3375)),
        "strip": (0.2829, (0.2882, 0.3107)),
        "grill_l": ((-0.299, -0.0463), (0.1804, 0.2648)),
        "grill_r": ((0.0411, 0.3049), (0.1804, 0.2648)),
    },
}


# ---------------------------------------------------------------- 2D の形
def rrect(x0, x1, y0, y1, radii, seg=3):
    """角ごとに半径 (rx, ry) を持つ角丸の四角。反時計回り。
    radii: dict br/tr/tl/bl → (rx, ry)。y0 側を下とする。"""
    w, h = x1 - x0, y1 - y0
    out = []
    spec = [("br", x1, y0, -90), ("tr", x1, y1, 0), ("tl", x0, y1, 90), ("bl", x0, y0, 180)]
    for key, cx, cy, a0 in spec:
        rx, ry = radii[key]
        rx = max(1e-5, min(rx, w * 0.49))
        ry = max(1e-5, min(ry, h * 0.49))
        ccx = cx - rx if key in ("br", "tr") else cx + rx
        ccy = cy + ry if key in ("br", "bl") else cy - ry
        for k in range(seg + 1):
            a = math.radians(a0 + 90 * k / seg)
            out.append((ccx + rx * math.cos(a), ccy + ry * math.sin(a)))
    return out


def ellipse(cx, cy, rx, ry, n):
    return [(cx + rx * math.cos(2 * math.pi * k / n), cy + ry * math.sin(2 * math.pi * k / n)) for k in range(n)]


def signed_area(pts):
    return 0.5 * sum(pts[i][0] * pts[(i + 1) % len(pts)][1] - pts[(i + 1) % len(pts)][0] * pts[i][1] for i in range(len(pts)))


# ---------------------------------------------------------------- bmesh の部品
def prism(bm, pts, axis, lo, hi):
    """2D の形を axis の向きに lo〜hi まで押し出した柱。面の向きは最後にそろえる。"""
    if signed_area(pts) < 0:
        pts = pts[::-1]

    def v3(p, s):
        if axis == "z":
            return (p[0], p[1], s)
        if axis == "x":
            return (s, p[0], p[1])
        return (p[0], s, p[1])  # axis == "y": p=(x, z)

    a = [bm.verts.new(v3(p, lo)) for p in pts]
    b = [bm.verts.new(v3(p, hi)) for p in pts]
    faces = [bm.faces.new(a[::-1]), bm.faces.new(b)]
    n = len(pts)
    for i in range(n):
        j = (i + 1) % n
        faces.append(bm.faces.new((a[i], a[j], b[j], b[i])))
    return faces


def cap_edges(faces, coord_axis, value, eps=1e-6):
    """押し出した柱の、片方のふたの輪郭の辺"""
    idx = "xyz".index(coord_axis)
    edges = set()
    for f in faces:
        for e in f.edges:
            if all(abs(v.co[idx] - value) < eps for v in e.verts):
                if len(e.link_faces) == 2 and any(abs(lf.normal[idx]) < 0.9 for lf in e.link_faces):
                    edges.add(e)
    return list(edges)


def finish(bm):
    bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-6)
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)


def new_object(name, bm, parent=None, mat=None):
    me = bpy.data.meshes.new(name)
    bm.to_mesh(me)
    bm.free()
    for p in me.polygons:
        p.use_smooth = True
    me.set_sharp_from_angle(angle=math.radians(35))
    if mat:
        me.materials.append(mat)
    ob = bpy.data.objects.new(name, me)
    bpy.context.scene.collection.objects.link(ob)
    if parent:
        ob.parent = parent
    return ob


def set_uv_all(bm, faces, uv):
    lay = bm.loops.layers.uv.verify()
    for f in faces:
        for l in f.loops:
            l[lay].uv = uv


# ---------------------------------------------------------------- 外形の段を選ぶ
def douglas_peucker(pts, tol):
    if len(pts) < 3:
        return pts
    a, b = Vector(pts[0]), Vector(pts[-1])
    ab = b - a
    best, idx = -1, 0
    for i in range(1, len(pts) - 1):
        p = Vector(pts[i])
        t = max(0, min(1, (p - a).dot(ab) / max(ab.dot(ab), 1e-12)))
        d = (p - (a + ab * t)).length
        if d > best:
            best, idx = d, i
    if best > tol:
        return douglas_peucker(pts[: idx + 1], tol)[:-1] + douglas_peucker(pts[idx:], tol)
    return [pts[0], pts[-1]]


def shell_levels(prof, W, D, H, t0=0.105, t1=0.996, tol=0.0022):
    rows = [r for r in prof["levels"] if t0 <= r[0] <= t1]
    bz = F["bumper_t"]
    # バンパーの帯の所は、殻をバンパーの上の溝の線までへこませる（帯は別の部品で前に出す）
    groove = min(rows, key=lambda r: abs(r[0] - 0.232))[2]
    pts = []
    for t, w, df, db in rows:
        if bz[0] - 0.006 <= t <= bz[1] + 0.012:
            df = max(df, groove)
        pts.append((t * H, w * W, df * D, db * D))
    sel = douglas_peucker(pts, tol)
    # 色や部品の境目は必ず段にする
    must = [bz[0] - 0.006, bz[1] + 0.012, F["drawer_t"][0], F["drawer_t"][1]]
    have = {round(p[0], 5) for p in sel}
    for mt in must:
        p = min(pts, key=lambda q: abs(q[0] - mt * H))
        if round(p[0], 5) not in have:
            sel.append(p)
    sel.sort(key=lambda p: p[0])
    return sel


BELLY = 1.0  # 胴の角だけに掛ける追加の倍率（--belly）
PODS = False  # 横の丸いふくらみ（--pods）
SOFT = 1.0   # 角の丸さの倍率（人の粘土の見た目に合わせるときは --soft で大きくする）


def corner_r(z, H, a, depth, front):
    t = z / H
    if t < 0.70:
        r = 0.022 if front else 0.05
    elif t < 0.80:
        k = (t - 0.70) / 0.10
        r = (0.022 * (1 - k) + 0.026 * k) if front else (0.05 * (1 - k) + 0.045 * k)
    else:
        r = 0.026 if front else 0.045
    # 丸めるのは体だけ。顔の画面のある頭（0.70 から上）は元のまま（画面が浮いて見えないように）
    w = 1.0 if t < 0.64 else (0.0 if t > 0.70 else (0.70 - t) / 0.06)
    soft = 1 + (SOFT - 1) * w
    r *= soft if front else (1 + (soft - 1) * 0.6)
    r *= 1 + (BELLY - 1) * w   # お腹（胴）の角をさらに丸める
    return min(r, a * 0.45, depth * 0.45)


def dome_levels(levels):
    """てっぺんの縁を丸める：上の端から半径 r の範囲を、円に沿って内側へ寄せた段に置きかえる"""
    if True:   # 頭のてっぺんは丸めない（顔のまわりが変わるため）
        return levels
    z_top = levels[-1][0]
    r0 = 0.022 * SOFT
    base = min(levels, key=lambda l: abs(l[0] - (z_top - r0)))   # 丸め始める高さの段の幅と奥行きを使う
    _, a, df, db = base
    r = min(r0, a * 0.35, (db - df) * 0.3)
    out = [l for l in levels if l[0] < z_top - r - 1e-6]
    zs = [l[0] for l in levels]

    import numpy as np

    def at(z):   # 元の段を高さで補間する（頭の前の面は傾いているので、段ごとの前の位置を使う）
        return [float(np.interp(z, zs, [l[i] for l in levels])) for i in (1, 2, 3)]
    n = 6
    for k in range(1, n + 1):
        th = math.radians(90 * k / n)
        inset = r * (1 - math.cos(th))
        z = z_top - r + r * math.sin(th)
        a_, df_, db_ = at(min(z, z_top - 1e-4))
        out.append((z, a_ - inset, df_ + inset, db_ - inset))
    return out


# ---------------------------------------------------------------- 部品を作る
def build_shell(bm, levels, W, D, H, seg=3):
    seg = seg + (2 if SOFT > 1 else 0)
    levels = dome_levels(levels)
    rings = []
    for z, a, df, db in levels:
        rf = corner_r(z, H, a, db - df, True)
        rb = corner_r(z, H, a, db - df, False)
        pts = rrect(-a, a, df, db, {"br": (rf, rf), "tr": (rb, rb), "tl": (rb, rb), "bl": (rf, rf)}, seg)
        rings.append([bm.verts.new((x, y, z)) for x, y in pts])
    faces = []
    n = len(rings[0])
    for i in range(len(rings) - 1):
        A, B = rings[i], rings[i + 1]
        for k in range(n):
            j = (k + 1) % n
            faces.append(bm.faces.new((A[k], A[j], B[j], B[k])))
    faces.append(bm.faces.new(rings[-1]))
    faces.append(bm.faces.new(rings[0][::-1]))
    return faces


def uv_shell(bm, faces, W, D, H):
    lay = bm.loops.layers.uv.verify()
    bm.normal_update()
    for f in faces:
        n = f.normal
        ax = max(range(3), key=lambda i: abs(n[i]))
        for l in f.loops:
            x, y, z = l.vert.co
            if ax == 2:
                l[lay].uv = swatch_uv("white")
                continue
            if ax == 0:      # 横
                rx, ry, rw, rh = R_SIDE
                u = rx + (y / D + 0.5) * rw
            elif n[1] < 0:   # 前
                rx, ry, rw, rh = R_FRONT
                u = rx + (x / W + 0.5) * rw
            else:            # 後ろ（見る人の右が -X）
                rx, ry, rw, rh = R_BACK
                u = rx + (0.5 - x / W) * rw
            v = ry + (1 - z / H) * rh
            l[lay].uv = (u / ATLAS, 1 - v / ATLAS)


def build_base(bm, W, D, H):
    z0, z1 = F["base_t"][0] * H, F["base_t"][1] * H
    rb = 0.075 * (1 + (SOFT - 1) * 0.5)
    pts = rrect(-W / 2, W / 2, -D / 2, D / 2, {k: (rb, rb) for k in ("br", "tr", "tl", "bl")}, 4 + (2 if SOFT > 1 else 0))
    faces = prism(bm, pts, "z", z0, z1)
    bm.normal_update()
    top = cap_edges(faces, "z", z1)
    bot = cap_edges(faces, "z", z0)
    bmesh.ops.bevel(bm, geom=bot, offset=0.016 * SOFT, segments=2 + (1 if SOFT > 1 else 0), profile=0.5, affect="EDGES", clamp_overlap=True)
    bm.normal_update()
    top = [e for e in bm.edges if e.is_valid and all(abs(v.co.z - z1) < 1e-6 for v in e.verts) and len(e.link_faces) == 2
           and any(abs(f.normal.z) < 0.9 for f in e.link_faces)]
    bmesh.ops.bevel(bm, geom=top, offset=0.006 * SOFT, segments=1 + (2 if SOFT > 1 else 0), profile=0.5, affect="EDGES", clamp_overlap=True)
    return list(bm.faces)


def build_wheels(bm, W, D, H, r=0.036, width=0.036, seg=12):
    if SOFT > 1:   # 丸い見た目のとき：少し大きく太いタイヤを、上半分を台の中に隠して置き、角を丸める
        r, width, seg = 0.046, 0.052, 20
    faces = []
    for sx in (-1, 1):
        for wy in F["wheel_y"]:
            cx, cy = sx * F["wheel_x"] * W, wy * D
            pts = ellipse(cy, r, r, r, seg)  # (y, z) 平面の円（床に接する）
            fs = prism(bm, pts, "x", cx - width / 2, cx + width / 2)
            if SOFT > 1:
                bm.normal_update()
                es = cap_edges(fs, "x", cx - width / 2) + cap_edges(fs, "x", cx + width / 2)
                bmesh.ops.bevel(bm, geom=es, offset=0.013, segments=3, profile=0.5, affect="EDGES", clamp_overlap=True)
            faces += fs
    return faces


def build_bumper(bm, shell_lv, W, D, H):
    z0, z1 = F["bumper_t"][0] * H, F["bumper_t"][1] * H
    near = min(shell_lv, key=lambda l: abs(l[0] - (z0 + z1) / 2))
    a = near[1] + 0.006
    front = near[2] - 0.012
    # 前の角は殻の角より少しだけ大きく（殻の角が帯から突き出ないように）
    rf = max(0.045, corner_r((z0 + z1) / 2, H, near[1], near[3] - near[2], True) + 0.010)
    pts = rrect(-a, a, front, D * 0.3, {"br": (rf, rf), "tr": (0.02, 0.02), "tl": (0.02, 0.02), "bl": (rf, rf)}, 4 + (2 if SOFT > 1 else 0))
    faces = prism(bm, pts, "z", z0, z1)
    bm.normal_update()
    for zz in (z0, z1):
        es = [e for e in bm.edges if all(abs(v.co.z - zz) < 1e-6 for v in e.verts) and len(e.link_faces) == 2
              and any(abs(f.normal.z) < 0.9 for f in e.link_faces) and e.verts[0].co.y < 0]
        bmesh.ops.bevel(bm, geom=es, offset=min(0.009 * SOFT, 0.016), segments=2 + (1 if SOFT > 1 else 0), profile=0.5, affect="EDGES", clamp_overlap=True)
        bm.normal_update()
    cut_y = F["bumper_back"] * D
    geom = list(bm.verts) + list(bm.edges) + list(bm.faces)
    res = bmesh.ops.bisect_plane(bm, geom=geom, plane_co=(0, cut_y, 0), plane_no=(0, 1, 0), clear_outer=True)
    edges = [e for e in res["geom_cut"] if isinstance(e, bmesh.types.BMEdge)]
    bmesh.ops.holes_fill(bm, edges=edges, sides=0)
    return list(bm.faces)


def build_face_plate(bm, prof, W, D, H):
    """頭の傾いた前の面に乗る画面の板。返り値は前の面の向きの情報"""
    t0, t1 = F["screen_t"]
    lv = prof["levels"]
    p0 = min(lv, key=lambda r: abs(r[0] - t0))
    p1 = min(lv, key=lambda r: abs(r[0] - t1 - 0.004))
    a = Vector((0, p0[2] * D, t0 * H))
    b = Vector((0, p1[2] * D, t1 * H))
    up = (b - a).normalized()
    nrm = Vector((0, -up.z, up.y))  # 前（-Y 寄り）を向く
    if nrm.y > 0:
        nrm = -nrm
    hw = F["screen_hw"] * W
    L = (b - a).length
    pts = rrect(-hw, hw, 0, L, {k: (0.016, 0.016) for k in ("br", "tr", "tl", "bl")}, 3)   # 画面は丸めの対象にしない
    thick = 0.004
    base = a + nrm * 0.0055

    def P(u, v, s):
        return base + Vector((u, 0, 0)) + up * v + nrm * s

    if signed_area(pts) < 0:
        pts = pts[::-1]
    back = [bm.verts.new(P(u, v, -thick)) for u, v in pts]
    front = [bm.verts.new(P(u, v, 0.0)) for u, v in pts]
    ff = bm.faces.new(front[::-1])
    faces = [ff, bm.faces.new(back)]
    n = len(pts)
    for i in range(n):
        j = (i + 1) % n
        faces.append(bm.faces.new((back[i], back[j], front[j], front[i])))
    bm.normal_update()
    if ff.normal.dot(nrm) < 0:
        ff.normal_flip()
    lay = bm.loops.layers.uv.verify()
    fx, fy, fw, fh = R_FACE
    for f in faces:
        is_front = f is ff
        for l in f.loops:
            if is_front:
                rel = l.vert.co - base
                u = rel.x / (2 * hw) + 0.5
                v = rel.dot(up) / L
                l[lay].uv = ((fx + u * fw) / ATLAS, 1 - (fy + (1 - v) * fh) / ATLAS)
            else:
                l[lay].uv = swatch_uv("screen")
    return faces, (a, b, nrm, hw, L)


def build_ring(bm, W, D, H, x_side, r_out=0.122, r_in=0.1015, seg=36, proud=0.0006, depth=0.006):
    cy, cz = F["ring_c"][0] * D, F["ring_c"][1] * H
    faces, walls = [], []
    for sx in (-1, 1):
        xo = sx * (x_side + proud)
        xi = sx * (x_side - depth)
        outer = [(cy + r_out * math.cos(2 * math.pi * k / seg), cz + r_out * math.sin(2 * math.pi * k / seg)) for k in range(seg)]
        inner = [(cy + r_in * math.cos(2 * math.pi * k / seg), cz + r_in * math.sin(2 * math.pi * k / seg)) for k in range(seg)]
        vo = [bm.verts.new((xo, y, z)) for y, z in outer]
        vi = [bm.verts.new((xo, y, z)) for y, z in inner]
        wo = [bm.verts.new((xi, y, z)) for y, z in outer]
        wi = [bm.verts.new((xi, y, z)) for y, z in inner]
        for k in range(seg):
            j = (k + 1) % seg
            faces.append(bm.faces.new((vo[k], vo[j], vi[j], vi[k])))   # 光る面
            walls.append(bm.faces.new((wo[k], wo[j], vo[j], vo[k])))   # 外の縁
            walls.append(bm.faces.new((vi[k], vi[j], wi[j], wi[k])))   # 内の縁
    return faces, walls


def build_pods(bm, shell_lv, W, D, H, thick=0.022, bevel=0.020):
    """横の丸いふくらみ（輪の付いた横板）。マンガのハコブくんの、お腹の横の厚い丸い板。
    返り値は板の外の面の x（輪をここに乗せる）"""
    pod = F["pod"]
    z0, z1 = pod["t"][0] * H, pod["t"][1] * H
    near = [l for l in shell_lv if z0 <= l[0] <= z1]
    x_in = min(l[1] for l in near) - 0.005
    x_out = max(l[1] for l in near) + thick
    rr = {k: (pod["r"][src][0] * D, pod["r"][src][1] * H) for k, src in (("tl", "ft"), ("tr", "bt"), ("br", "bb"), ("bl", "fb"))}
    pts = rrect(pod["d"][0] * D, pod["d"][1] * D, z0, z1, rr, 8)   # (y, z) 平面
    faces = []
    for sx in (-1, 1):
        lo, hi = (x_in, x_out) if sx > 0 else (-x_out, -x_in)
        fs = prism(bm, pts, "x", lo, hi)
        bm.normal_update()
        outer = sx * x_out
        es = cap_edges(fs, "x", outer)
        bmesh.ops.bevel(bm, geom=es, offset=bevel, segments=4, profile=0.5, affect="EDGES", clamp_overlap=True)
        bm.normal_update()
        faces += fs
    return x_out


def build_drawer(bm, shell_lv, W, D, H):
    t0, t1 = F["drawer_t"]
    z0, z1 = t0 * H, t1 * H
    near = [l for l in shell_lv if z0 <= l[0] <= z1]
    front = min(l[2] for l in near)
    hw = F["drawer_hw"] * W
    # 前の板（殻の前の面より 4mm 前に出す）
    y_face = front - 0.0045
    r_lo, r_hi = 0.03 * (1 + (SOFT - 1) * 0.5), 0.012 * SOFT
    pts = rrect(-hw, hw, z0, z1, {"br": (r_lo, r_lo), "bl": (r_lo, r_lo), "tr": (r_hi, r_hi), "tl": (r_hi, r_hi)}, 3 + (2 if SOFT > 1 else 0))
    panel = prism(bm, pts, "y", y_face, front + 0.012)
    set_uv_all(bm, panel, swatch_uv("white"))
    bm.normal_update()
    edges = cap_edges(panel, "y", y_face)
    bmesh.ops.bevel(bm, geom=edges, offset=0.004 * (1 + (SOFT - 1) * 0.5), segments=1 + (1 if SOFT > 1 else 0), profile=0.5, affect="EDGES", clamp_overlap=True)
    # 取っ手（黒い横長の丸い板）
    h0, h1 = F["handle_t"][0] * H, F["handle_t"][1] * H
    hh = F["handle_hw"] * W
    rr = (h1 - h0) / 2
    hp = rrect(-hh, hh, h0, h1, {k: (rr, rr) for k in ("br", "tr", "tl", "bl")}, 3)
    handle = prism(bm, hp, "y", y_face - 0.0025, y_face + 0.002)
    set_uv_all(bm, handle, swatch_uv("black"))
    # 中の箱（ふたなし）
    bx0, bx1 = -hw + 0.012, hw - 0.012
    by0, by1 = front + 0.010, front + 0.30
    bz0, bz1 = z0 + 0.018, z1 - 0.035
    th = 0.006
    box = []
    box += prism(bm, rrect(bx0, bx1, by0, by1, {k: (0.004, 0.004) for k in ("br", "tr", "tl", "bl")}, 1), "z", bz0, bz0 + th)
    for (xa, xb, ya, yb) in [(bx0, bx1, by1 - th, by1), (bx0, bx0 + th, by0, by1), (bx1 - th, bx1, by0, by1)]:
        box += prism(bm, [(xa, ya), (xb, ya), (xb, yb), (xa, yb)], "z", bz0, bz1)
    set_uv_all(bm, box, swatch_uv("drawer_in"))
    return {"box": (bx0, bx1, by0, by1, bz0 + th), "y_face": y_face}


def build_tray(bm, cx, cy, z0, sx=0.150, sy=0.122, sz=0.032):
    rt = 0.008 * SOFT
    pts = rrect(cx - sx / 2, cx + sx / 2, cy - sy / 2, cy + sy / 2, {k: (rt, rt) for k in ("br", "tr", "tl", "bl")}, 2 + (1 if SOFT > 1 else 0))
    faces = prism(bm, pts, "z", z0, z0 + sz)
    set_uv_all(bm, faces, swatch_uv("tray"))
    return faces



# ---------------------------------------------------------------- 顔（表情集の「基本」）
# 動画の制作ベース hakobu.py の表情集と同じ形：目＝縦長の角丸（open）、口＝青い笑顔（mid）。
# 寸法は hakobu.py の値（表情集の画面の高さ 149 を単位にした長さ）。目の間隔だけは画面の幅に対する比で置く。
FACE_SCREEN_H = 149.0
FACE_EYE_WH = (22.5, 47.0)
FACE_EYE_GAP = 0.486      # 目の中心どうしの間隔（画面の幅に対する比）
FACE_EYE_Y = 0.483        # 目の中心の高さ（画面の上から、高さに対する比）
FACE_MOUTH = {"hw": 27, "depth": 25, "sag": 5, "rr": 5, "top": 0.671}
FACE_WHITE = (246, 248, 252)
FACE_BLUE = (87, 181, 248)
FACE_SCREEN = (19, 19, 17)


def draw_face_base(sw, sh, out_w, out_h, SS=8):
    import numpy as np
    import cv2
    Hs = FACE_SCREEN_H
    Ws = sw / sh * Hs
    W, Hh = int(Ws * SS), int(Hs * SS)
    white = np.zeros((Hh, W), np.uint8)
    blue = np.zeros((Hh, W), np.uint8)
    cx0 = Ws / 2
    ew, eh = FACE_EYE_WH
    ey = FACE_EYE_Y * Hs
    img = Image.fromarray(white)
    d = ImageDraw.Draw(img)
    for sgn in (-1, 1):
        ex = cx0 + sgn * FACE_EYE_GAP * Ws / 2
        d.rounded_rectangle([(ex - ew / 2) * SS, (ey - eh / 2) * SS, (ex + ew / 2) * SS, (ey + eh / 2) * SS], radius=ew / 2 * SS, fill=255)
    white = np.array(img)
    m = FACE_MOUTH
    t = np.linspace(0, 1, 80)
    top = m["top"] * Hs
    xs = cx0 - m["hw"] + 2 * m["hw"] * t
    upper = np.stack([xs, top + m["sag"] * (1 - (2 * t - 1) ** 2)], 1)
    lower = np.stack([xs[::-1], (top + m["depth"] * np.sqrt(np.clip(1 - (2 * t - 1) ** 2, 0, 1)))[::-1]], 1)
    poly = (np.concatenate([upper, lower]) * SS).astype(np.int32)
    cv2.fillPoly(blue, [poly], 255)
    k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * m["rr"] * SS + 1, 2 * m["rr"] * SS + 1))
    blue = cv2.morphologyEx(blue, cv2.MORPH_OPEN, k)
    # 目のまわりのにじむ光（表情集と同じく、白い目だけ）
    glow = cv2.GaussianBlur(white.astype(np.float32) / 255, (0, 0), 2.6 * SS) * 0.75
    alb = np.zeros((Hh, W, 3), np.float32) + np.array(FACE_SCREEN, np.float32)
    gcol = np.array((205, 225, 255), np.float32)
    alb = alb * (1 - glow[..., None]) + gcol * glow[..., None]
    a = white.astype(np.float32)[..., None] / 255
    alb = alb * (1 - a) + np.array(FACE_WHITE, np.float32) * a
    b = blue.astype(np.float32)[..., None] / 255
    alb = alb * (1 - b) + np.array(FACE_BLUE, np.float32) * b
    emi = gcol * glow[..., None] * 0.6
    emi = emi * (1 - a) + np.array(FACE_WHITE, np.float32) * 0.92 * a
    emi = emi * (1 - b) + np.array(FACE_BLUE, np.float32) * 0.85 * b
    A = Image.fromarray(np.clip(alb, 0, 255).astype(np.uint8)).resize((out_w, out_h), Image.LANCZOS)
    E = Image.fromarray(np.clip(emi, 0, 255).astype(np.uint8)).resize((out_w, out_h), Image.LANCZOS)
    return A, E


# ---------------------------------------------------------------- 色の画像
def draw_atlas(W, D, H, face_info, ss=2):
    S = ATLAS * ss
    alb = Image.new("RGB", (S, S), COL["white"])
    emi = Image.new("RGB", (S, S), (0, 0, 0))
    da, de = ImageDraw.Draw(alb), ImageDraw.Draw(emi)

    def box(r):
        return [c * ss for c in r]

    # 色の区画
    for name in SWATCH_NAMES:
        x, y = swatch_px(name)
        da.rectangle(box((x, y, x + SW_CELL, y + SW_CELL)), fill=COL[name] if name in COL else COL["white"])
        if name in EMIT:
            de.rectangle(box((x, y, x + SW_CELL, y + SW_CELL)), fill=EMIT[name])

    # 顔（画面）
    fx, fy, fw, fh = R_FACE
    da.rectangle(box((fx, fy, fx + fw, fy + fh)), fill=COL["screen"])
    a, b, nrm, hw, L = face_info
    sw, sh = 2 * hw, L  # 画面の実寸（m）

    def fpx(u_m, v_m):  # 画面の中心からの m（右・上が正）→ 画像の px
        return (fx + (u_m / sw + 0.5) * fw, fy + (0.5 - v_m / sh) * fh)

    face_alb, face_emi = draw_face_base(sw, sh, fw, fh)
    alb.paste(face_alb.resize((fw * ss, fh * ss), Image.LANCZOS), (fx * ss, fy * ss))
    emi.paste(face_emi.resize((fw * ss, fh * ss), Image.LANCZOS), (fx * ss, fy * ss))

    # 横の面（u: 前0〜後ろ1、v: 上0〜下1）
    sx0, sy0, sw_, sh_ = R_SIDE

    def spx(dn, t):
        return (sx0 + (dn + 0.5) * sw_, sy0 + (1 - t) * sh_)

    bt = F["band_t"]
    p0, p1 = spx(-0.5, bt[1]), spx(0.5, bt[0])
    da.rectangle(box((p0[0], p0[1], p1[0], p1[1])), fill=COL["band"])
    da.line(box((p0[0], p0[1], p1[0], p0[1])), fill=COL["seam"], width=2 * ss)
    pod = F["pod"]
    pts = rrect(pod["d"][0], pod["d"][1], pod["t"][0], pod["t"][1],
                {"tl": pod["r"]["ft"], "tr": pod["r"]["bt"], "br": pod["r"]["bb"], "bl": pod["r"]["fb"]}, 12)
    poly = [tuple(c * ss for c in spx(d, t)) for d, t in pts]
    da.line(poly + [poly[0]], fill=(176, 174, 169), width=3 * ss, joint="curve")

    # 前の面
    fx0, fy0, fw_, fh_ = R_FRONT

    def frpx(xn, t):
        return (fx0 + (xn + 0.5) * fw_, fy0 + (1 - t) * fh_)

    g = 0.0035 / W
    d0, d1 = F["drawer_t"]
    p0 = frpx(-F["drawer_hw"] - g, d1 + 0.0035 / H)
    p1 = frpx(F["drawer_hw"] + g, d0 - 0.0035 / H)
    da.rounded_rectangle(box((p0[0], p0[1], p1[0], p1[1])), radius=6 * ss, fill=(52, 53, 56))
    s0, s1 = F["slot_t"]
    p0, p1 = frpx(-F["slot_hw"], s1), frpx(F["slot_hw"], s0)
    da.rounded_rectangle(box((p0[0], p0[1], p1[0], p1[1])), radius=5 * ss, fill=COL["black"])
    hs = F["head_seam_t"]
    p0, p1 = frpx(-0.5, hs), frpx(0.5, hs)
    da.line(box((p0[0], p0[1], p1[0], p1[1])), fill=COL["seam"], width=2 * ss)

    # 後ろの面（見る人から見た向きで描く）
    bx0, by0, bw_, bh_ = R_BACK

    def bpx(un, t):
        return (bx0 + (un + 0.5) * bw_, by0 + (1 - t) * bh_)

    B = F["back"]
    hw_, (t0, t1) = B["notch"]
    p0, p1 = bpx(-hw_, t1), bpx(hw_, t0)
    da.rectangle(box((p0[0], p0[1], p1[0], p1[1])), fill=(70, 71, 74))
    p0, p1 = bpx(-0.5, F["band_t"][1]), bpx(0.5, F["band_t"][1])
    da.line(box((p0[0], p0[1], p1[0], p1[1])), fill=COL["seam"], width=2 * ss)
    hw_, (t0, t1) = B["door"]
    p0, p1 = bpx(-hw_, t1), bpx(hw_, t0)
    da.rounded_rectangle(box((p0[0], p0[1], p1[0], p1[1])), radius=4 * ss, fill=COL["door"], outline=COL["seam"], width=2 * ss)
    k0, k1 = bpx(-0.03, t1 - 0.003), bpx(0.03, t1 - 0.012)
    da.rectangle(box((k0[0], k0[1], k1[0], k1[1])), fill=(90, 91, 94))
    h0, h1 = bpx(hw_ - 0.022, 0.50), bpx(hw_ - 0.006, 0.44)
    da.rectangle(box((h0[0], h0[1], h1[0], h1[1])), fill=(90, 91, 94))
    hw_, (t0, t1) = B["ctrl"]
    p0, p1 = bpx(-hw_, t1), bpx(hw_, t0)
    da.rounded_rectangle(box((p0[0], p0[1], p1[0], p1[1])), radius=6 * ss, fill=COL["black"])
    cy = (p0[1] + p1[1]) / 2
    cx = bpx(-0.1114, 0)[0]
    r = 0.040 * bw_
    da.ellipse(box((cx - r, cy - r, cx + r, cy + r)), fill=(70, 71, 74), outline=(120, 121, 124), width=ss)
    cx = bpx(0.002, 0)[0]
    r = 0.026 * bw_
    da.rectangle(box((cx - r, cy - r, cx + r, cy + r)), fill=(70, 71, 74))
    cx = bpx(0.135, 0)[0]
    da.rectangle(box((cx - 0.036 * bw_, cy - 0.028 * bh_, cx + 0.036 * bw_, cy + 0.028 * bh_)), fill=(70, 71, 74))
    hw_, (t0, t1) = B["panel"]
    p0, p1 = bpx(-hw_, t1), bpx(hw_, t0)
    da.rounded_rectangle(box((p0[0], p0[1], p1[0], p1[1])), radius=5 * ss, fill=(40, 41, 44))
    hw_, (t0, t1) = B["strip"]
    p0, p1 = bpx(-hw_, t1), bpx(hw_, t0)
    rad = (p1[1] - p0[1]) / 2
    da.rounded_rectangle(box((p0[0], p0[1], p1[0], p1[1])), radius=rad * ss, fill=COL["green"])
    de.rounded_rectangle(box((p0[0], p0[1], p1[0], p1[1])), radius=rad * ss, fill=EMIT["green"])
    for key in ("grill_l", "grill_r"):
        (u0, u1), (t0, t1) = B[key]
        p0, p1 = bpx(u0, t1), bpx(u1, t0)
        rows_n = 6
        for i in range(rows_n):
            yy = p0[1] + (i + 0.5) * (p1[1] - p0[1]) / rows_n
            off = 0.5 if i % 2 else 0
            n = 5
            for j in range(n):
                xa = p0[0] + (j + off * 0.5 + 0.1) * (p1[0] - p0[0]) / (n + 0.5)
                xb = xa + 0.7 * (p1[0] - p0[0]) / (n + 0.5)
                da.rounded_rectangle(box((xa, yy - 1.0, xb, yy + 1.0)), radius=ss, fill=(14, 14, 15))

    alb = alb.resize((ATLAS, ATLAS), Image.LANCZOS)
    emi = emi.resize((ATLAS, ATLAS), Image.LANCZOS)
    return alb, emi


# ---------------------------------------------------------------- 材質
def make_material(alb_path, emi_path, strength=1.0):
    mat = bpy.data.materials.new("Hakobu_Mat")
    mat.use_nodes = True
    nt = mat.node_tree
    bsdf = nt.nodes.get("Principled BSDF")
    ta = nt.nodes.new("ShaderNodeTexImage")
    ta.image = bpy.data.images.load(alb_path)
    te = nt.nodes.new("ShaderNodeTexImage")
    te.image = bpy.data.images.load(emi_path)
    te.image.colorspace_settings.name = "sRGB"
    nt.links.new(ta.outputs["Color"], bsdf.inputs["Base Color"])
    nt.links.new(te.outputs["Color"], bsdf.inputs["Emission Color"])
    bsdf.inputs["Emission Strength"].default_value = strength
    bsdf.inputs["Roughness"].default_value = 0.55
    return mat


# ---------------------------------------------------------------- 全体
def build(W, D, H, out_dir):
    prof = json.load(open(os.path.join(HERE, "hakobu_profiles.json")))
    bpy.ops.wm.read_factory_settings(use_empty=True)
    sc = bpy.context.scene
    sc.unit_settings.system = "METRIC"
    sc.unit_settings.scale_length = 1.0

    root = bpy.data.objects.new("Hakobu", None)
    sc.collection.objects.link(root)

    levels = shell_levels(prof, W, D, H)

    # 画面の位置を先に決める（色の画像に使う）
    bm_face = bmesh.new()
    face_faces, face_info = build_face_plate(bm_face, prof, W, D, H)
    finish(bm_face)

    tex_dir = out_dir
    alb, emi = draw_atlas(W, D, H, face_info)
    alb_path = os.path.join(tex_dir, "hakobu_albedo.png")
    emi_path = os.path.join(tex_dir, "hakobu_emission.png")
    alb.save(alb_path)
    emi.save(emi_path)
    mat = make_material(alb_path, emi_path)

    # 本体: 殻・台・車輪・バンパー
    bm = bmesh.new()
    shell = build_shell(bm, levels, W, D, H)
    finish(bm)
    uv_shell(bm, [f for f in bm.faces], W, D, H)
    body_main = new_object("Hakobu_Shell_tmp", bm, None, None)

    bm = bmesh.new()
    build_base(bm, W, D, H)
    finish(bm)
    set_uv_all(bm, bm.faces, swatch_uv("base"))
    ob_base = new_object("Hakobu_Base_tmp", bm)

    bm = bmesh.new()
    build_wheels(bm, W, D, H)
    finish(bm)
    set_uv_all(bm, bm.faces, swatch_uv("black"))
    ob_wheels = new_object("Hakobu_Wheels_tmp", bm)

    bm = bmesh.new()
    build_bumper(bm, levels, W, D, H)
    finish(bm)
    set_uv_all(bm, bm.faces, swatch_uv("black"))
    ob_bump = new_object("Hakobu_Bumper_tmp", bm)

    # まとめて1つの本体にする
    body = join_objects("Hakobu_Body", [body_main, ob_base, ob_wheels, ob_bump])
    body.parent = root
    body.data.materials.clear()
    body.data.materials.append(mat)

    face = new_object("Hakobu_Face", bm_face, root, mat)

    # 輪: 横の面の外側（殻の横幅の一番広い所に合わせる）
    ring_lv = [l for l in levels if 0.38 * H <= l[0] <= 0.62 * H]
    x_side = max(l[1] for l in ring_lv)
    if PODS:   # 横のふくらみを付けたときは、輪をふくらみの外の面に乗せる
        bm = bmesh.new()
        x_side = build_pods(bm, levels, W, D, H)
        finish(bm)
        set_uv_all(bm, bm.faces, swatch_uv("door"))
        pods = new_object("Hakobu_Pods_tmp", bm)
        body2 = join_objects("Hakobu_Body", [body, pods])
        body2.parent = root
        body2.data.materials.append(mat)
        body = body2
    r_out = 0.5 * (313 / 2312 * H + 313 / 1529 * D)   # 三面図の輪の外の半径（高さと奥行きの間をとる）
    bm = bmesh.new()
    glow, walls = build_ring(bm, W, D, H, x_side, r_out=r_out, r_in=r_out * 261 / 313)
    set_uv_all(bm, glow, swatch_uv("green"))
    set_uv_all(bm, walls, swatch_uv("white"))
    finish(bm)
    ring = new_object("Hakobu_Ring", bm, root, mat)

    # 引き出しと中のトレー4枚
    bm = bmesh.new()
    dinfo = build_drawer(bm, levels, W, D, H)
    finish(bm)
    drawer = new_object("Hakobu_Drawer", bm, root, mat)
    bx0, bx1, by0, by1, bz = dinfo["box"]
    cxs = [(bx0 + (bx0 + bx1) / 2) / 2 + 0.004, ((bx0 + bx1) / 2 + bx1) / 2 - 0.004]
    cys = [by0 + (by1 - by0) * 0.27, by0 + (by1 - by0) * 0.71]
    k = 1
    for cy in cys:
        for cx in cxs:
            bm = bmesh.new()
            build_tray(bm, cx, cy, bz + 0.001)
            finish(bm)
            new_object(f"Hakobu_Tray_{k}", bm, root, mat)  # 入れ子にすると FBX の向きの変換が二重になるので根の直下に置く
            k += 1

    return {"root": root, "levels": levels, "face_info": face_info, "mat": mat, "drawer": drawer}


def join_objects(name, objs):
    bm = bmesh.new()
    for ob in objs:
        tmp = bmesh.new()
        tmp.from_mesh(ob.data)
        me = bpy.data.meshes.new("tmp")
        tmp.to_mesh(me)
        tmp.free()
        bm.from_mesh(me)
        bpy.data.meshes.remove(me)
    for ob in objs:
        me = ob.data
        bpy.data.objects.remove(ob)
        bpy.data.meshes.remove(me)
    me = bpy.data.meshes.new(name)
    bm.to_mesh(me)
    bm.free()
    for p in me.polygons:
        p.use_smooth = True
    me.set_sharp_from_angle(angle=math.radians(35))
    ob = bpy.data.objects.new(name, me)
    bpy.context.scene.collection.objects.link(ob)
    return ob


def stats():
    out = {}
    tot = 0
    for ob in bpy.data.objects:
        if ob.type != "MESH":
            continue
        n = sum(len(p.vertices) - 2 for p in ob.data.polygons)
        out[ob.name] = n
        tot += n
    out["_total_triangles"] = tot
    return out


def export(out_dir):
    fbx = os.path.join(out_dir, "hakobu.fbx")
    bpy.ops.export_scene.fbx(
        filepath=fbx, use_selection=False, object_types={"EMPTY", "MESH"},
        apply_unit_scale=True, apply_scale_options="FBX_SCALE_ALL",
        axis_forward="-Z", axis_up="Y", bake_space_transform=True,
        mesh_smooth_type="OFF", use_mesh_modifiers=True, use_triangles=True,
        add_leaf_bones=False, bake_anim=False, path_mode="STRIP", embed_textures=False,
    )
    glb = os.path.join(out_dir, "hakobu.glb")
    bpy.ops.export_scene.gltf(filepath=glb, export_format="GLB", export_yup=True, export_apply=True)
    return fbx, glb


def main():
    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else sys.argv[1:]
    ap = argparse.ArgumentParser()
    ap.add_argument("--out", required=True)
    ap.add_argument("--W", type=float, default=0.653)
    ap.add_argument("--D", type=float, default=0.760)
    ap.add_argument("--H", type=float, default=1.150)
    ap.add_argument("--render", action="store_true")
    ap.add_argument("--no-pods", dest="pods", action="store_false", help="横の丸いふくらみ（輪の付いた横板）を付けない")
    ap.add_argument("--belly", type=float, default=1.0, help="胴の角だけ、さらに丸める倍率")
    ap.add_argument("--soft", type=float, default=3.5, help="体の角の丸さの倍率（1 で三面図どおり。頭は常に三面図どおり）")
    args = ap.parse_args(argv)
    global SOFT, BELLY, PODS
    PODS = args.pods
    SOFT = args.soft
    BELLY = args.belly
    os.makedirs(args.out, exist_ok=True)
    info = build(args.W, args.D, args.H, args.out)
    st = stats()
    fbx, glb = export(args.out)
    bpy.ops.wm.save_as_mainfile(filepath=os.path.join(args.out, "hakobu.blend"))
    report = {"size_m": {"W": args.W, "D": args.D, "H": args.H}, "triangles": st,
              "shell_levels": len(info["levels"]), "files": [os.path.basename(fbx), os.path.basename(glb)]}
    json.dump(report, open(os.path.join(args.out, "hakobu_report.json"), "w"), ensure_ascii=False, indent=1)
    print(json.dumps(report, ensure_ascii=False, indent=1))


if __name__ == "__main__":
    main()
```

### ハコブくんの確認用の絵：hakobu_render.py

```python
"""hakobu.blend から確認用の絵を書き出す（Cycles・CPU）。
使い方: python3 hakobu_render.py <hakobu.blend> <出力フォルダ> [views...] [--drawer-open 0.28]
views: front side back persp sim sim_open top
"""
import math
import os
import sys

import bpy  # noqa: I001
from mathutils import Vector


def setup(samples=24):
    sc = bpy.context.scene
    sc.render.engine = "CYCLES"
    sc.cycles.device = "CPU"
    sc.cycles.samples = samples
    sc.cycles.use_denoising = True
    try:
        sc.cycles.denoiser = "OPENIMAGEDENOISE"
    except Exception:
        pass
    sc.view_settings.view_transform = "Standard"
    sc.render.film_transparent = False
    world = bpy.data.worlds.new("W")
    sc.world = world
    world.use_nodes = True
    bg = world.node_tree.nodes["Background"]
    bg.inputs["Color"].default_value = (0.93, 0.93, 0.92, 1)
    bg.inputs["Strength"].default_value = 0.9
    sun = bpy.data.lights.new("Sun", "SUN")
    sun.energy = 1.8
    sun.angle = math.radians(25)
    so = bpy.data.objects.new("Sun", sun)
    so.rotation_euler = (math.radians(50), math.radians(-15), math.radians(-35))
    sc.collection.objects.link(so)
    return sc


def floor(sc, size=8, color=(0.80, 0.80, 0.78)):
    me = bpy.data.meshes.new("Floor")
    s = size / 2
    me.from_pydata([(-s, -s, 0), (s, -s, 0), (s, s, 0), (-s, s, 0)], [], [(0, 1, 2, 3)])
    ob = bpy.data.objects.new("Floor", me)
    mat = bpy.data.materials.new("FloorMat")
    mat.use_nodes = True
    mat.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = (*color, 1)
    mat.node_tree.nodes["Principled BSDF"].inputs["Roughness"].default_value = 0.8
    me.materials.append(mat)
    sc.collection.objects.link(ob)
    return ob


def person(sc, h=1.60, x=0.62, y=0.05):
    """大きさの目安の人（身長1.60m）"""
    mat = bpy.data.materials.new("PersonMat")
    mat.use_nodes = True
    mat.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = (0.42, 0.62, 0.78, 1)
    bpy.ops.mesh.primitive_cylinder_add(radius=0.17, depth=h - 0.24, location=(x, y, (h - 0.24) / 2))
    body = bpy.context.active_object
    bpy.ops.mesh.primitive_uv_sphere_add(radius=0.11, location=(x, y, h - 0.11))
    head = bpy.context.active_object
    for o in (body, head):
        o.data.materials.append(mat)
        o.name = "Person"


def camera(sc, loc, target, ortho=None, lens=50):
    cam = bpy.data.cameras.new("Cam")
    if ortho:
        cam.type = "ORTHO"
        cam.ortho_scale = ortho
    else:
        cam.lens = lens
    co = bpy.data.objects.new("Cam", cam)
    co.location = loc
    d = Vector(target) - Vector(loc)
    co.rotation_euler = d.to_track_quat("-Z", "Y").to_euler()
    sc.collection.objects.link(co)
    sc.camera = co
    return co


def main():
    args = sys.argv[1:]
    blend, out = args[0], args[1]
    views = [a for a in args[2:] if not a.startswith("--")]
    bpy.ops.wm.open_mainfile(filepath=blend)
    sc = setup()
    H = max((ob.matrix_world @ Vector(c)).z for ob in bpy.data.objects if ob.type == "MESH" for c in ob.bound_box)
    os.makedirs(out, exist_ok=True)
    fl = None
    for v in views or ["front", "side", "back", "persp"]:
        for ob in list(bpy.data.objects):
            if ob.type == "CAMERA":
                bpy.data.objects.remove(ob)
        for o in bpy.data.objects:
            if o.name.startswith(("Hakobu_Drawer", "Hakobu_Tray")):
                o.location.y = -0.26 if v.endswith("_open") else 0.0
        if v.startswith("sim") and fl is None:
            fl = floor(sc, color=(0.74, 0.66, 0.55))
            person(sc)
        if v == "people" and fl is None:
            fl = floor(sc, color=(0.74, 0.66, 0.55))
            person(sc)
        if fl:
            fl.hide_render = not (v.startswith("sim") or v == "people")
        for o in bpy.data.objects:
            if o.name.startswith("Person"):
                o.hide_render = not (v.startswith("sim") or v == "people")
        m = H * 1.12
        if v == "front":
            camera(sc, (0, -5, H / 2), (0, 0, H / 2), ortho=m)
            sc.render.resolution_x, sc.render.resolution_y = 640, 1000
        elif v == "side":
            camera(sc, (5, 0, H / 2), (0, 0, H / 2), ortho=m)
            sc.render.resolution_x, sc.render.resolution_y = 640, 1000
        elif v == "back":
            camera(sc, (0, 5, H / 2), (0, 0, H / 2), ortho=m)
            sc.render.resolution_x, sc.render.resolution_y = 640, 1000
        elif v == "top":
            camera(sc, (0, 0, 5), (0, 0.0001, 0), ortho=m * 0.7)
            sc.render.resolution_x, sc.render.resolution_y = 800, 800
        elif v == "persp":
            camera(sc, (1.25, -2.3, 1.25), (0, 0, H * 0.48), lens=55)
            sc.render.resolution_x, sc.render.resolution_y = 800, 1000
        elif v == "face":
            camera(sc, (0, -3, H * 0.84), (0, 0, H * 0.84), ortho=0.42)
            sc.render.resolution_x, sc.render.resolution_y = 800, 700
        elif v == "people":
            camera(sc, (1.9, -4.2, 1.35), (0.3, 0, 0.75), lens=50)
            sc.render.resolution_x, sc.render.resolution_y = 1000, 800
        elif v.startswith("sim"):
            # シミュレーションの寄り（左30°・75°見下ろし）に近い角度
            dist = 3.2
            el = math.radians(75)
            az = math.radians(-90 - 30)
            loc = (dist * math.cos(el) * math.cos(az), dist * math.cos(el) * math.sin(az), dist * math.sin(el))
            camera(sc, loc, (0, -0.05, H * 0.35), lens=45)
            sc.render.resolution_x, sc.render.resolution_y = 1000, 800
        sc.render.filepath = os.path.join(out, f"render_{v}.png")
        bpy.ops.render.render(write_still=True)
        print("wrote", sc.render.filepath)


if __name__ == "__main__":
    main()
```

### ハコブくんの出力：hakobu_report.json

```json
{
 "size_m": {
  "W": 0.653,
  "D": 0.76,
  "H": 1.15
 },
 "triangles": {
  "Hakobu_Body": 4760,
  "Hakobu_Drawer": 312,
  "Hakobu_Face": 60,
  "Hakobu_Ring": 432,
  "Hakobu_Tray_1": 60,
  "Hakobu_Tray_2": 60,
  "Hakobu_Tray_3": 60,
  "Hakobu_Tray_4": 60,
  "_total_triangles": 5804
 },
 "shell_levels": 42,
 "files": [
  "hakobu.fbx",
  "hakobu.glb"
 ]
}
```

### ハコブくんの README.md（p2m-site の tools/sim_robots/hakobu/）

```markdown
# ハコブくんの3Dモデル（サービスロボット導入シミュレーション・病院の搬送ロボットの見た目）

ハコブくんの三面図から、Blender を Python で動かして作った軽いモデル。Unity の病院の場面で、搬送ロボットの見た目として使う（2026-11-13）。

同じ場面に出る人（粘土のような簡略化した人）に合わせて、体の角を丸めた形をもとにする（2026-11-13、エイイチの決定）。頭（画面のまわり）は三面図どおりで、画面の面は斜めにスパッと切れたまま。

- 元にした絵：iCloud の ハコブくん/4コママンガ作成データ/キャラ/hakobu_front.png・hakobu_side.png・hakobu_back.png
- 大きさ：三面図のハコブくんの実寸（幅0.653・奥行0.760・高さ1.150m。幅は台の一番広い所。横の厚い板の所だけ幅0.674m）。エイイチの指示（2026-11-13）。病院の試算のロボット（hospital-unity-params.json の robot.size_m＝KEENON W3 の幅0.459・奥行0.549・高さ1.081m）より大きい
- 向き：根（Hakobu）は床の中心。顔が Unity の +Z（進む向き）を向く

## 作り方

1. `python3 hakobu_profiles.py <キャラのフォルダ> hakobu_profiles.json`：三面図から外形を測る（済んでいる。三面図が変わった時だけ）
2. `pip install bpy==5.3.0`（Blender を Python から使う）→ `python3 hakobu_build.py --out <出力フォルダ>`：hakobu.fbx・hakobu_albedo.png・hakobu_emission.png・hakobu_report.json を作る。大きさの既定は実寸。`--W --D --H`（m）で変えられる。丸めの既定は `--soft 3.5`（体の角を三面図の3.5倍の半径に。`--soft 1` で三面図どおりの角）。横の厚い板は既定で付く（`--no-pods` で外す）
3. `python3 hakobu_render.py <出力>/hakobu.blend <絵のフォルダ> front side back persp sim sim_open`：確認用の絵（GitHub に入れない。iCloud の サービスロボット導入シミュレーション/hospital/hakobu_3d/）

## 中身（三角形 5,804・材質1つ）

| 名前 | 中身 | 三角形 |
|---|---|---|
| Hakobu | 根（空の物）。床の中心 | – |
| Hakobu_Body | 胴・頭・台・車輪・バンパー・横の厚い板（両側） | 4,760 |
| Hakobu_Face | 画面（顔） | 60 |
| Hakobu_Ring | 横の輪（両側） | 432 |
| Hakobu_Drawer | 引き出し（前の板・取っ手・中の箱）。前へ 0.26m まで引き出せる | 312 |
| Hakobu_Tray_1〜4 | 引き出しの中のトレー（田の字に4枚） | 60 ×4 |

- 色は1枚の画像（hakobu_albedo.png・1024px）にまとめた。光る所（目・口・輪・背中の帯）は hakobu_emission.png。材質は Hakobu_Mat の1つだけ
- 丸めたのは体だけ：胴の縦の角（高さの6.4割から下。7割までで三面図の角に戻す）、台と黒い帯の角、引き出しの前の板とトレー、タイヤ（太くして角を丸め、上半分を台の中に隠した）
- 横の厚い板：三面図の横の板（輪の付いた所）の輪郭どおりに、厚さ約2cm・ふちの丸み2cmの板を両側に足した（マンガ ep147 のハコブくんの、お腹の横のふくらみ）。輪はこの板の表面に乗る
- 横の継ぎ目・頭の帯・背中の扉と操作箱・前の引き出しのすき間は、形ではなく画像で描いている（三角形を増やさないため）
- Tray_1〜4 は FBX では根の直下にある。引き出しの子にすると、FBX の向きの変換が二重にかかってトレーが90°回る（Blender で読み直して確かめた）。Unity のプレハブで Drawer の子にする
- 輪と顔を別の部品にしてあるのは、あとで状態（移動中・止まった・完了）で色や表情を変えられるようにするため
- 4コマのチェック項目と同じ決まりを守っている：腕・脚なし、体に文字なし、輪は平らで真ん中に穴・点なし
```

### この記事の画像の作り方：scene_render.py（看護師とハコブくんを同じ床に置いて描く）

記事の最初の画像とサムネイルは、看護師の blend にハコブくんを読み込み、姿勢を付けて描きました。`python3 scene_render.py <nurse_clay.blend> <hakobu.blend> <出力> drawer 1920 1080` で動きます。背景は透明で描き、`onto_bg.py` でサイトのカードと同じ明るい灰色（#eceef2）に重ねています。Unity で撮った絵ではありません。

```python
"""LOG496 用：看護師（大城さん）とハコブくんを同じ床に置いて描く（記事の画像とサムネイルの素材）。
python3 scene_render.py <nurse_clay.blend> <hakobu.blend> <出力png> <場面> [幅 高さ]
場面：drawer（引き出しの前でかがむ）・talk（立って話しかける）
背景は透明で描き、あとで #eceef2 の地に重ねる（影は床の影だけ残す）"""
import math
import os
import sys

import bpy
from mathutils import Matrix, Vector

nurse_blend, hakobu_blend, out, scene = sys.argv[1:5]
W, H = (int(sys.argv[5]), int(sys.argv[6])) if len(sys.argv) > 6 else (1920, 1080)

bpy.ops.wm.open_mainfile(filepath=nurse_blend)
for o in [o for o in bpy.data.objects if o.type in ("CAMERA", "LIGHT")]:
    bpy.data.objects.remove(o)
with bpy.data.libraries.load(hakobu_blend) as (src, dst):
    dst.objects = [n for n in src.objects if n.startswith("Hakobu")]
sc = bpy.context.scene
for o in dst.objects:
    sc.collection.objects.link(o)
hk = bpy.data.objects["Hakobu"]
for img in bpy.data.images:  # ハコブくんの画像は blend と同じフォルダにある
    if img.name.startswith("hakobu_"):
        img.filepath = os.path.join(os.path.dirname(os.path.abspath(hakobu_blend)), os.path.basename(img.filepath))
rig = bpy.data.objects["Nurse_Oshiro"]
X, Y, Z = (1, 0, 0), (0, 1, 0), (0, 0, 1)


def upd():
    bpy.context.view_layer.update()


def rot_world(bone, axis, deg):
    pb = rig.pose.bones[bone]
    h = pb.head.copy()
    R = Matrix.Rotation(math.radians(deg), 4, Vector(axis).normalized())
    pb.matrix = Matrix.Translation(h) @ R @ Matrix.Translation(-h) @ pb.matrix
    upd()


def twist(bone, deg):
    pb = rig.pose.bones[bone]
    rot_world(bone, pb.tail - pb.head, deg)


def curl(side, deg, thumb):
    for f in ("Index", "Middle", "Ring", "Little"):
        for seg, k in (("Proximal", 1.0), ("Intermediate", 1.2)):
            pb = rig.pose.bones[side + f + seg]
            pb.rotation_mode = "XYZ"
            pb.rotation_euler.x = math.radians(deg * k)
    for seg in ("Proximal", "Intermediate"):
        pb = rig.pose.bones[side + "Thumb" + seg]
        pb.rotation_mode = "XYZ"
        pb.rotation_euler.x = math.radians(thumb)
    upd()


def mouth(kind):
    for ob in bpy.data.objects:
        if ob.name.startswith("Nurse_Mouth_"):
            ob.hide_render = ob.name != "Nurse_Mouth_" + kind


# 姿勢はすべて、看護師が -Y を向いた作った時の向きで付けてから、体ごと回す
if scene == "drawer":
    drawer_open = 0.26
    rot_world("Spine", X, 16)
    rot_world("Chest", X, 14)
    rot_world("RightUpperArm", X, -48)
    rot_world("RightLowerArm", X, -18)
    rot_world("LeftUpperArm", X, -30)
    rot_world("LeftLowerArm", X, -30)
    curl("Right", 50, 30)
    curl("Left", 20, 10)
    rot_world("Head", X, 14)
    mouth("smile")
    nurse_xy, nurse_rot = (0.98, -0.30), -60
    cam = ((0.55, -3.5, 1.5), (0.5, -0.2, 0.80), 36)
else:  # talk
    drawer_open = 0.0
    rot_world("LeftUpperArm", X, -30)
    rot_world("LeftLowerArm", X, -55)
    twist("LeftLowerArm", -30)
    twist("LeftHand", -30)
    curl("Left", 10, 0)
    rot_world("Head", X, 10)
    rot_world("Head", Z, -6)
    mouth("talk")
    nurse_xy, nurse_rot = (1.05, -0.25), -60
    cam = ((0.55, -3.5, 1.5), (0.5, -0.2, 0.84), 36)

for o in bpy.data.objects:
    if o.name.startswith(("Hakobu_Drawer", "Hakobu_Tray")):
        o.location.y = -drawer_open
# ハコブくんの顔（-Y）を右手前へ、看護師の顔を左手前へ。向かい合いつつ、両方とも 30° カメラへ向ける
hk.rotation_euler = (0, 0, math.radians(60))
rig.location = (*nurse_xy, 0)
rig.rotation_euler = (0, 0, math.radians(nurse_rot))
upd()

# 薬の箱（白に青の帯）をトレーの上に
if drawer_open > 0:
    bpy.ops.mesh.primitive_cube_add(size=1, location=(0.04, -0.50, 0.455))
    box = bpy.context.object
    box.parent = hk
    box.scale = (0.12, 0.08, 0.05)
    box.rotation_euler = (0, 0, math.radians(8))
    mw = bpy.data.materials.new("Box")
    mw.use_nodes = True
    mw.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = (0.92, 0.92, 0.9, 1)
    box.data.materials.append(mw)
    bpy.ops.mesh.primitive_cube_add(size=1, location=(0.04, -0.50, 0.455))
    band = bpy.context.object
    band.parent = hk
    band.scale = (0.03, 0.082, 0.052)
    band.rotation_euler = box.rotation_euler
    mb = bpy.data.materials.new("Band")
    mb.use_nodes = True
    mb.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = (0.05, 0.25, 0.75, 1)
    band.data.materials.append(mb)

# 床：影だけを受ける
bpy.ops.mesh.primitive_plane_add(size=12, location=(0, 0, 0))
floor = bpy.context.object
floor.is_shadow_catcher = True

sc.render.engine = "CYCLES"
sc.cycles.device = "CPU"
sc.cycles.samples = 48
sc.cycles.use_denoising = True
sc.render.film_transparent = True
sc.view_settings.view_transform = "Standard"
w = bpy.data.worlds.new("W")
sc.world = w
w.use_nodes = True
bg = w.node_tree.nodes["Background"]
bg.inputs["Color"].default_value = (0.86, 0.88, 0.92, 1)
bg.inputs["Strength"].default_value = 0.8
sun = bpy.data.lights.new("Sun", "SUN")
sun.energy = 1.6
sun.angle = math.radians(25)
so = bpy.data.objects.new("Sun", sun)
so.rotation_euler = (math.radians(50), math.radians(-15), math.radians(-35))
sc.collection.objects.link(so)
L = bpy.data.lights.new("F", "AREA")
L.energy, L.size = 120, 4
o = bpy.data.objects.new("F", L)
o.location = (0.6, -3.0, 1.6)
o.rotation_euler = (Vector((0.5, 0, 0.8)) - Vector(o.location)).to_track_quat("-Z", "Y").to_euler()
sc.collection.objects.link(o)
loc, tgt, lens = cam
c = bpy.data.cameras.new("C")
c.lens = lens
co = bpy.data.objects.new("C", c)
co.location = loc
co.rotation_euler = (Vector(tgt) - Vector(loc)).to_track_quat("-Z", "Y").to_euler()
sc.collection.objects.link(co)
sc.camera = co
sc.render.resolution_x, sc.render.resolution_y = W, H
sc.render.image_settings.color_mode = "RGBA"
sc.render.filepath = out
bpy.ops.render.render(write_still=True)
print("wrote", out)
```

#### onto_bg.py

```python
"""透明で描いた絵を #eceef2 の地に重ねる。python3 onto_bg.py <入力png> <出力>"""
import sys
from PIL import Image
a = Image.open(sys.argv[1]).convert("RGBA")
bg = Image.new("RGBA", a.size, (236, 238, 242, 255))
bg.alpha_composite(a)
bg.convert("RGB").save(sys.argv[2], quality=95)
```

#### compose.py（前と後を並べる画像）

```python
"""LOG496 の比べる画像を組む（前と後を左右に並べ、下に短い名前を書く）"""
from PIL import Image, ImageDraw, ImageFont
F = ImageFont.truetype("/usr/share/fonts/opentype/noto/NotoSansCJK-Medium.ttc", 30, index=0)
BG = (247, 247, 247)


def row(paths, labels, out, h=760, gap=24):
    ims = [Image.open(p).convert("RGB") for p in paths]
    ims = [im.resize((int(im.width * h / im.height), h), Image.LANCZOS) for im in ims]
    W = sum(i.width for i in ims) + gap * (len(ims) + 1)
    c = Image.new("RGB", (W, h + 90), BG)
    d = ImageDraw.Draw(c)
    x = gap
    for im, lb in zip(ims, labels):
        c.paste(im, (x, 0))
        if lb:
            d.text((x + 8, h + 22), lb, font=F, fill=(36, 36, 36))
        x += im.width + gap
    c.save(out)
    print(out, c.size)


H = "/home/claude/hakobu"
P = "/home/claude/people/clay"
row([f"{H}/out_real/renders/render_persp.png", f"{H}/out_base/renders/render_persp.png"],
    ["前：三面図の角のまま（三角形 2,728）", "後：体の角・タイヤを丸め、横に板（5,804）"],
    "LOG496_images/LOG496_02_hakobu_before_after.png")
row([f"{P}/out_v1/r/front.png", f"{P}/out_v1/r/side.png", f"{P}/out_final/r/front.png", f"{P}/out_final/r/side.png"],
    ["最初の形", "", "直した形", ""],
    "LOG496_images/LOG496_03_nurse_first_final.png")
```
