---
title: "サービスロボット導入シミュレーション Log#7 | Unity の店をサイトの図面から建てた"
slug: unity-store-from-site-json
date: 2026-10-22
day: 362
meta_description: "配膳ロボットの試算が使う店の図面（JSON）から、Unity でファミレスを建てた。ロボットの道順もサイトが決めて渡す形にした。80cm通路の店で人まで21卓に入れなかった原因は、Unity に残っていた古い通れる床の計算だった。"
tags: [サービスロボット導入シミュレーション, 配膳ロボット, Unity, NavMesh, ファミレス, 平面図, Claude Code, codex, シミュレーション]
series: "導入シミュレーション"
---

配膳ロボットを入れると、ファミレスの入店待ちや料理が届くまでの時間がどう変わるか。それを計算する試算ページをサイトに置いています（ https://pixelstomotors.com/series8 ）。ただ、この計算には仮の数字が4つ入っています。ロボットが人とすれ違いながら実際に何m/秒で走れるか、何回止まるか、止まったら何秒動けないか、人に助けてもらわないと動けなくなるのは何回に1回か。どれも、店の中で客とスタッフとロボットを一緒に動かしてみないと分かりません。そこで、Unity（建物や人を3Dで置いて動かせるソフト）の中にファミレスを建てて、測ろうとしています。

これまで Unity の店は、Unity の中で形を組み立てていました。そのため、サイトの試算が使っている店とは、卓の数も通路の幅も違っていました。違う店で測った数字は、サイトの計算に戻せません。そこで、サイトの試算が使っている店の図面（卓・壁・通路の位置を座標で並べたデータ）を、Unity がそのまま読んで店を建てるように変えました。Unity は自分で店の形を作りません。建てた2つの店（通路1.2mの店と、同じ店の通路を80cmに詰めた店）は、どちらも卓42・席160・ロボットが止まる位置42か所がサイトと一致しました。

途中で一度、おかしな結果が出ました。通路80cmの店で、ロボットどころか人も、42卓のうち21卓の席にたどり着けないと出たのです。図面は1mmも変わっていませんでした。**原因は店ではなく、Unity の中に、前の作り方で計算した「通れる床」が消えずに残っていたことでした。** Unity は、人やロボットが歩ける床を NavMesh（三角形をつないだ面）として計算して持っています。プログラムを書き換えると Unity は読み込み直しをしますが、そのとき古い面を消す手がかりが失われ、新しい面と重なって残っていました。重なった2枚の面はつながっていないので、道を探すと途中で止まります。Unity を開き直すと21卓すべてに入れるようになり、面を計算する前に必ず全部消す1行を入れて直しました。

もう1つ、作業の終わり方を決めました。Unity をサイトに完全に合わせようとすると、ズレが見つかるたびに直す作業が続き、いつ何が達成できるのかが分かりません。そこで、Unity は Unity の作りのまま回し切って記録し、サイトとのズレは直す対象ではなく、調べる対象として扱うことにしました。

| やったこと | なぜ | どうなったか |
|---|---|---|
| 店の図面の確かめを codex にもう2回検証してもらい、穴をふさいだ | 図面を作った側が確かめると同じ所を見落とすため | L字席の受け取る側・待機場所の大きさ・トイレの囲みなどを直した。テストは30件から107件に |
| 記憶の店（②）に、パントリーから客席へ出る扉のない出入り口を2つ開けた | 従業員が通る口がなかった | ②だけ卓までの距離が変わり、試算を v2.5 として②だけ回し直した |
| Unity が建てるための細かい形（ベンチ・椅子・座る位置・並ぶ点など）をサイトの JSON に足した | Unity で形を作らないため | Unity が JSON から店を建て、卓・席・止まる位置がサイトと一致した |
| ロボットの道順をサイトが決めて JSON で渡した | 80cmの通路を Unity の中で扱おうとしたら、通れる床が割れた | 受け渡し口から42本・卓から卓へ861本。全部つながった |
| 21卓に入れない原因を突き止めた | 図面は同じなのに結果が変わった | 古い通れる床が残っていた。消してから計算する形に直した |
| ゴールを3段階で決めた | ズレを直す作業に終わりがなかった | 店を作る → 回して台帳に記録する → ズレを調べる。まず③⑦で一度通す |

下の図で、ボタンを押すと、古い通れる床が残っていると道探しがどこで止まるかが見られます。

【図解】LOG362_navmesh_leftover.html


## 作業ログ ― 店の番号と、この記事で扱う範囲

店の番号は、試算ページの番号（②〜⑦）で書きます。codex の検証結果やコードの一部には、作業中の番号（⓪〜⑤）が残っています。

| ページの番号 | 作業中の番号 | 名前 | コードの名前 |
|---|---|---|---|
| ② | ⓪ | 定番の郊外型（記憶の店を決まりに合わせた版） | p0_memory |
| ③ | ① | 効率重視型・入口が左下の角 | p1_efficient_corner |
| ④ | ② | 効率重視型・狭い店・入口が右の壁 | p2_efficient_narrow |
| ⑤ | ③ | ゆったり型・入口が正面の中央 | p3_relaxed_center |
| ⑥ | ④ | ゆったり型・入口が左の壁 | p4_relaxed_side |
| ⑦ | ⑤ | ③の列の間の通路を80cmに（卓と席数は③と同じ） | p5_crammed |

この記事は、LOG360（6つの店の図面を作って試算に入れた回）のあと、codex の2回目の検証結果を受け取ったところから始まります。Unity の作業は Claude Code（Unity のコードを書く AI）が実装し、ここでは Claude がサイト側の図面と依頼文を作り、Unity での確かめは、Unity のメニューを手で押して行いました。Unity の版は、Unity Version Control の Check-in の番号（cs:112〜cs:116）で書きます。


## codex の2回目の検証：数字は再現できるが、L字席と②の待機場所と確かめのすり抜けが残っていた

LOG360 の終わりに頼んだ2回目の検証（依頼文は LOG360 に全量）の結果は、「数字は再現できる。ただし、客が受け取れる卓の割合を試算する材料として、このまま採用するのは不可」でした。全文は後ろの「codex の2回目の検証結果」に載せています。

大きな指摘は3つです。

| 指摘 | 中身 |
|---|---|
| L字席のソファ側に止まった位置を「届く」と数えている | L字の6人席は、卓の外側の4辺すべてを止まってよい辺にしていた。②の T02 では、止まる位置がソファの側面の前にあり、天板の中心から約2.8m離れていた |
| ②の待機場所が1台0.8m×0.8mを満たさない | 3台分の場所が2.2m×0.7mしかなかった。1台ずつ出られるかも調べていなかった |
| 確かめをすり抜ける置き方がある | トイレの囲み・レジの有無・建物の輪郭などで、決まりに反した店でも確かめが通る反例が7つあった |

L字席で止まってよい所は、案を2つ出して選んでもらいました。a は「天板のある内側の角の、開いた2辺だけ」。b は「a に加えて、そこからソファの端に沿って0.6mまで」。

> b. a に加えて、端の席の人が受け取れる通路の端も含める / 3台のまま場所を広げる

b にしたのは、L字ソファの端に座った人は、内側の角まで手を伸ばさなくても、ソファの端の前の通路で料理を受け取れるためです。②の待機場所は、台数を減らさずに場所を広げることにしました。


## L字席・2人席で止まってよい辺を決め直し、②の待機場所を2.4m×0.8mに広げた

決まりは次の3つにしました（tools/sim_layouts/README.md の「2026-10-22 codex の2回目の検証を受けて」に書いた）。

- L字席で止まってよいのは、テーブルのある内側の角の開いた2辺と、そこからソファの端に沿って0.6mまで。ソファの横は使わない
- 2人席で止まってよいのは、椅子の側とテーブルの横。ベンチの側・ベンチの横は使わない
- 待機場所は1台分0.8m×0.8m以上、台数は正の整数。1台ずつ、ほかの台が止まったままで受け渡し口の前まで出られること

コードでは、`lsofa_sides`（L字席の止まってよい区間）と `t2_sides`（2人席）を足し、`standby_slots` で待機場所を1台分ずつの区画に分けました。1台ずつ出られるかは、出ようとする1台だけを障害物から外し、残りの台を障害物にしたまま、受け渡し口の前まで道があるかで確かめます。テストは30件から49件になりました。


## ②のパントリーの上の通路に、扉のない出入り口を2つ開けた

②（記憶の店）の図を見直すと、パントリーから客席へ出る口がありませんでした。

> この形だとパントリから右上のブースに出入りするための出入り口がないよ。緑の線の上に出入り口を開けて（従業員もここから出入りします）

【画像】LOG362_01_door_line_a.png ②の図のパントリーの上の部分。トイレの右の通路と洗い場の下の間に、出入り口を開けてほしい所が緑の線で引かれている

【画像】LOG362_02_door_line_b.png ②の図のパントリーの上の端。洗い場の下の左の壁ぎわに、もう1か所の出入り口の位置が緑の線で引かれている

出入り口を2つ開けました。A はパントリーの壁に1m、客席側とつながる口。B はパントリーの上の端の口です。最初は扉を付けていましたが、扉は要らないと言われました。

> Bのラインは扉とかはないからね。

> A も扉ないよ！（あったら誰か扉開けないとダメでしょ！）扉は洗い場とキッチンの出入り口のところだね

料理を持ったスタッフやロボットが通る口に扉があると、誰かが開けないと通れません。扉があるのは、上の通路から洗い場とキッチンへ入る所だけです。JSON の `layout.doors` に、出入り口ごとに扉の有無（`door`）を書くようにしました。doors は図と Unity のための情報で、ロボットの道の計算には使いません（パントリーなどの部屋の中は障害物のまま）。

同じ図で、仕切り壁の上の4人席2つにも指摘がありました。

【画像】LOG362_03_tables_by_wall.png ②の図の左の部分。真ん中の灰色の仕切り壁の上に4人席が2つあり、ロボットの道（オレンジの線）が卓と壁のすき間から卓に近づいている

> ロボットが壁側から入っていくのはNGだね。机をもっと壁に寄せてください。

卓と仕切り壁の間が空いていたので、ロボットの道が壁側から卓に近づいていました。4人席2つを y=10.0m から 9.6m に下げ、壁との間を0.6mにしました。0.6mはロボット（BellaBot は幅80cmの通路が要る）が入れない幅です。

【画像】LOG362_04_layout_2_fixed.png 直したあとの②の図。パントリーの左の壁と上の端に緑の出入り口が2つあり、仕切り壁の上の4人席は壁に寄って、ロボットの道は卓の下側から入る

直したあとの②は19卓88席で、19卓すべてにふだんの1.0mの道で届きます。卓までの平均は8.49mから8.56mに、最長は14.01mから13.78mに変わりました。


## 確かめの穴を7つふさいだ（トイレ・レジ・仕切り・輪郭・線の当たり・候補の打ち切り・違反時の出力）

codex が反例として出した7つの置き方を、全部テストにしてから直しました。

| 穴 | 直した形 |
|---|---|
| トイレの入口とトイレの対応を見ていなかった | トイレの4辺を、外壁・裏の部屋・建物の外・厚さ30cm以上の壁・入口のどれかで、すきまなく囲むこと（辺を区間として計算で確かめる） |
| トイレの入口の前が、名前によっては空いていなくても通った | 入口の前と入口そのものは、名前に関係なく何も置かない |
| トイレから1m以内の席の目隠しを確かめていなかった | 席のトイレ側の辺の両端からトイレの入口の両端へ引いた4本の線が、全部壁に当たること |
| レジとレジ内がなくても通った | レジ（2.5m以上）とレジ内（店員の立つ所）が両方あり、接していること |
| 仕切りが卓に食い込んでいても通った | 食い込んでよいのは、ボックス席のベンチの背の辺の上で深さ8cm以内のときだけ |
| 建物の輪郭と「建物の外」がずれていても通った | 輪郭は縦と横の辺だけの形にし、座標で区切ったマスごとに建物の外と照合する |
| 決まりに違反しても、出力先に途中の結果が残った | 出力はいったん作業用のフォルダに出し、6つ全部が通ったときだけ入れ替える |

あわせて、点を並べて当たりを確かめていた所を、線と矩形の交わりの計算に変えました（点の間隔より薄い壁を見逃していた）。止まる位置の候補を200点で打ち切っていたのもやめました。出力には版の番号（`generation`。作り方のコード・記憶の店の原本・設定から決まる12桁）を入れ、読む側が同じ版の一式かを確かめられるようにしました。テストは73件になり、6つの店の数字は前と同じでした。

4本の線の目隠しの決まりは、近似です。目隠しの壁に切れ目があると、4本は壁に当たっても間から入口が見える置き方を見落とし得ます。README に「目隠しは1枚の続いた壁で作る」と書きました。


## codex の3回目の検証と、どこまで直すかの線引き

3回目の検証も「正式に採用する前に直すことがある」という結論でした。指摘を受けて、出力の入れ替えを途中で失敗したら元に戻す形に変えました（summary.json は最後に入れ替える）。ほかに、トイレの4辺と入口を区間として確かめる、入口の前の奥行き1.2m以上を確かめる、輪郭を縦と横の辺だけの形に限る、出入り口に壁を重ねない、を直しました。テストは80件で、6つの店の数字はまた同じでした。

ここで、どこまでやるかの線引きを問い直しました。

> サイトの試算 v2.5は必要？どこまでやればいいかの線引きしてる？

codex の指摘は、回を重ねるごとに「確かめの仕組みにすり抜ける穴がある」という種類が中心になっていました。そうした穴をふさいでも、6つの店の数字は3回続けて変わっていません。そこで、次の線を引きました。

- 6つの店の数字（卓の分け方・距離・止まる位置）が変わらない指摘は、これ以上 codex とやり取りしてふさがない
- 数字が変わった店だけ、試算を回し直す。今回は②だけ


## 試算 v2.5：卓までの距離が変わった②だけを回し直した

16,425条件（店・スタッフ数・ロボット台数・来客のペースなどの組み合わせ）のうち、②の1,314条件だけを40回ずつ回し直しました。ほかの15,111条件は v2.4 の結果をそのまま使い、数字が1つも変わっていないことを確かめました。

| 比べたもの | v2.4 → v2.5 |
|---|---|
| ②の卓の数 | 19 → 19 |
| ②の卓までの平均 | 8.49m → 8.56m |
| ②の「店が回らない」条件の数 | 124 → 124 |
| 全体の「店が回らない」条件の数 | 3,007 → 3,007 |
| ②の入店待ちの差 | 平均 −0.09分（−4.3〜+3.9分） |

入店待ちの差の幅が±4分あるのは、距離が変わると乱数の出方も変わるためで、平均はほぼ同じです。比べた結果の全量は、後ろの「実行したコマンドと出力」に載せています。


## サイトを正にしたときの問題と、「何について正か」の表

ここから Unity の作業です。これまでの方針は「サイトの試算が正で、Unity はそれに合わせる」でした。その方針で何が困るのかを、先に整理しました。

> サイトを正にした時に問題はどんなところですか？整理して教えてください。

一番大きな問題は、サイトの数字の一部が、Unity で測るための仮の値だということです。ロボットの速さ（0.5m/秒）、止まる割合（往復の30%）、止まる長さ、動けなくなる割合（1%）は、Unity で測ってサイトに戻すはずの値です。全部をサイトに合わせると、測るはずの値を Unity に読み込ませることになり、話が逆になります。もう1つは、Unity にしかないもの（組の人数・2人席・ドリンクバー・トイレ・椅子を引く）がサイトにはないことです。

そこで、何について正かを分けました。

| 何について | 正 | 扱い |
|---|---|---|
| 店の形（卓・壁・部屋・止まる位置） | サイト | サイトの JSON を読んで建てる。Unity で形を作らない |
| 条件（スタッフ・来客・ロボットの台数） | サイト | 図面のある店の値をそのまま使う |
| 店の回し方の決まりと時間、数字の定義 | サイト | 比べる物差しをそろえる |
| 通路で起きること（実際の速さ・止まる回数と長さ・立ち往生） | Unity が測る | 測った値は、サイトを先に直して入れる |
| Unity が建てるための細かい形（通路の区画・椅子・座る位置・並ぶ点など） | サイトの店の形から、決まった規則で作る | 規則はサイト側に置き、JSON に書き出す |
| Unity にしかないもの | Unity | 「Unity だけの値」として残し、サイトとは比べない |

距離の測り方も違います。サイトは通路の真ん中に50cmの余裕を取った道のり、Unity は半径25cmの円が家具に寄って走る道のりです。そのため、Unity とサイトの数字は完全には合いません。合っているかは、向き（どちらが大きいか）と大きさで見ることにしました。この表は、あとで段階A〜Cを決めたときに、店の形と条件をそろえるところまでに絞っています（最後の節）。


## Unity が店を建てるための細かい形を、サイトの JSON に書き出した（unity_export.py）

サイトの図面の JSON には、卓の外形と止まる位置はあっても、Unity が店を建てるのに要る細かい形がありません。それを決まった規則で作る `unity_export.py` を書き、`public/sim/layouts/unity/{名前}.json` に書き出しました。公開されているので、誰でも同じ店を建てられます（ https://pixelstomotors.com/sim/layouts/unity/p5_crammed.json など）。

| 書き出すもの | 規則 |
|---|---|
| 床・外壁・壁 | 建物の外を除いた床。外壁は輪郭から作り、入口の所だけ空ける。壁には高さを付ける |
| 部屋 | 通れない箱。ただしレジ内は通れる部屋（スタッフだけ） |
| 細い通路 | 同じ向きのボックス席がすき間なく並んだ列の、隣り合う列のすき間（0.5〜1.5m）。両端の0.8m外に入口の点を置き、受け渡し口に近いほうを entrance にする。③⑦は3本（③1.2m、⑦0.8m） |
| 卓の部品 | 天板・ベンチ・椅子（2人席の通路側の椅子だけ引ける。引く向きつき）・座る位置（席数ぶん）・席に着くために立つ点（障害物から0.2m以上離れた所だけ） |
| 場所の点 | 受け渡し口の前・下げ膳の返却・入口・待合・レジとレジに並ぶ点・レジ内・トイレの入口の前・ドリンクバーの前と並ぶ点・ロボットの待機場所（1台ずつ）・スタッフが待つ点 |

卓の数・止まる位置・卓までの距離は、試算が使う解析結果（analysis）と同じであることをテストで確かめます。Claude Code が最初に建てたときの報告で、レジ内が通れない箱になっていてスタッフが立てない、席に着く点の一部が障害物に近すぎる、の2点が出たので、規則を直しました（テスト99件）。


## Unity が JSON から③⑦を建てた：卓42・席160・止まる位置42がサイトと一致した（cs:112）

Claude Code への依頼文（v4）は後ろに全量で載せています。Unity の中に `JsonStoreLayout` という店の形の種類を足し、JSON の座標を Unity の座標に置き換えて建てます（JSON の x → Unity の X、JSON の y → Unity の Z）。

【画像】LOG362_05_unity_store_3.png Unity で建てた③を真上から見た画面。左に4列のボックス席の島、右上に厨房、ロボットが入れる床が水色で塗られ、席の列の間の通路は1.2m

【画像】LOG362_06_unity_store_7.png Unity で建てた⑦を真上から見た画面。③と同じ卓の並びで、列の間の通路だけが0.8mに狭まっている

確かめの結果です（Claude Code の報告から）。

| 確かめたこと | ③（通路1.2m） | ⑦（通路0.8m） |
|---|---|---|
| 卓・席 | 42・160（JSON・summary と一致） | 42・160（同） |
| 止まる位置が通れる床の上 | 42 / 42 | 42 / 42 |
| 席に着く点・場所の点が通れる床の上 | 45 / 45・26 / 26 | 45 / 45・26 / 26 |
| 受け渡し口から卓までの道のり（Unity − JSON） | 平均 −0.71m・最大 1.86m 短い | 平均 −0.67m・最大 1.86m 短い |
| 入口から席に入れない卓 | 0 | 0 |

42卓すべてで、Unity の道のりのほうが短く出ました。サイトは通路の真ん中に余裕を取り、Unity は家具に寄って走るので、向きは見込みどおりです。

1つ、サイトと答えが違う所がありました。⑦では、サイトの決まりで「1.0mの道では届かず、0.8mの道なら届く」卓が24あります。サイトは、ロボットをふだん1.0m以上の道で走らせ、それで届かない卓だけ0.8mの道を使います。Unity は半径25cmの円が通れればどこでも走るので、1.0m以上の道で行ける卓にも、近道の0.8mの通路を通って行っていました。


## 試算の条件と店の回し方の値を Unity 用に書き出した（serving-robot-params.json）

Unity がサイトの条件で回せるように、条件と回し方の値を `public/sim/serving-robot-params.json` に書き出しました。手で写すと写し間違えるので、Unity はこのファイルを読みます。

- `common`：案内45秒・会計90秒・片付け40秒＋40秒など。値ごとに `role` を付けた。`site_rule` は Unity が使う値、`unity_measures`（robot_speed・pause_rate・pause_s・stuck_rate）は Unity が測る値で読まない、`site_only` は試算の中だけの値
- `stores`：③⑦の卓の数・来客のペース・スタッフの人数・食事の長さなど
- `unity_conditions`：Unity で回す6条件（③⑦ × ロボット0・1・2台。スタッフ4人・1時間42組・上げ膳だけ・1往復で3卓まで）と、同じ条件の試算の結果（`site_result`）

| 条件 | 試算の入店待ち（平均・分） | 料理が届くまで（平均・分） | ロボットが運んだ割合 |
|---|---|---|---|
| ③ ロボット0台 | 3.18 | 0.65 | 0% |
| ③ ロボット1台 | 1.96 | 0.55 | 47% |
| ③ ロボット2台 | 1.02 | 0.46 | 73% |
| ⑦ ロボット0台 | 3.50 | 0.66 | 0% |
| ⑦ ロボット1台 | 2.10 | 0.55 | 46% |
| ⑦ ロボット2台 | 1.23 | 0.49 | 73% |


## ⑦の0.8m通路をサイトの決まりにそろえようとして、通れる床が割れた

「ふだんは1.0m以上の道、届かない卓だけ0.8mの道」をサイトにそろえるため、Claude Code は⑦の0.8mの通路3本を NavMesh の別の区画（area 4）にし、そこを通る道に20倍の重み（コスト）を付けました。結果は、21卓で道が途中までしか引けない、でした。重みを使わず、すべての区画を通ってよいことにして探しても、11卓がつながりませんでした。

区画を書き込まなかった段階1では、42卓すべてにつながっていました。区画の箱の位置は JSON の通路と小数点以下まで同じでした。このときは、区画を書き込むと同じ床が細かく割れ、道がつながらない所が出る、と考えました。あとで分かった「古い床が残る」こと（2つ先の節）が、このときの結果にも効いていた可能性がありますが、確かめていません。

ここで、2つの案を比べました。Unity の中で狭い道を扱い続ける案と、ロボットの道順をサイトが決めて JSON で渡す案です。

> 道順をサイトから渡す案

サイトの試算は、卓ごとの道（1.0mの道か、届かなければ0.8mの道）をもう持っています。それを Unity に渡せば、Unity の中で同じ規則を作り直さずに済みます。人をよける・止まる・動けなくなる、は Unity の中で起きることなので、Unity で測る値はそのままです。


## ロボットの道順はサイトが決めて渡す形にした（routes）

`unity_export.py` に `build_routes` を足しました。やっていることは3つです。

1. 受け渡し口の前と各卓の止まる位置から、マス目（2.5cm）の上で最短の道を探す。ふだんの幅（1.0m）で通れるマスだけで行けるならその道、行けなければ通れる幅（0.8m）のマスで探す
2. マス目の道は細かい階段の形なので、通れる範囲でまっすぐにつなぎ、曲がり角の点だけを残す（点と点の間の見通しは、1/4マスおきに確かめる）
3. 受け渡し口の前→各卓（`from_pass`、42本）と、卓→卓（`between`、861本）を書き出す。逆向きは点を逆にたどる

【画像】LOG362_07_routes_7.png ⑦の道順の図。受け渡し口の前（黒い四角）から、青い線がふだんの1.0mの道、オレンジの線が0.8mの道。T17・T22 は青い線で店の上を回り、T21 はオレンジの線で狭い通路に上の口から入る。緑の点線は T09 から T21 への卓から卓の道

③は42本すべてふだんの道、⑦は24本が0.8mの道で、どちらもサイトの卓の分け方と全卓で一致しました（テスト107件）。⑦の T17・T22 は、近道の0.8mの通路を通らずに、1.0mの道で店の上を回ります。

Claude Code への依頼文（v5b）では、次を頼みました。

- 狭い通路の区画を NavMesh に書き込む作りを外し、段階1と同じ床に戻す
- ロボットは、受け取った道順の点を1つずつ行き先にしてたどる。止まる位置は寄せない
- 人にふさがれて道を引き直すときは、次の点までの区間だけを引き直す。全体の道順は変えない
- 確かめでは、点から点の区間がすべてつながるか、区間の道のりがまっすぐの1.05倍を超えていないか（店の形と NavMesh が合っていない印）を見る


## ⑦で人まで21卓に入れない：原因は Unity に残っていた古い通れる床だった

v5b を入れて、③⑦を続けて確かめました。③は全部通りました。⑦は通りませんでした。

| 確かめたこと | ③ | ⑦ |
|---|---|---|
| すべての区間がつながった卓 | 42 / 42 | 21 / 42 |
| つながらない区間 | 0 | 25 |
| まっすぐの1.05倍を超えた区間 | 0 | 2（T17・T22） |
| 0.8mの道を通る卓 | 0 | 24 |
| 入口から席に入れない卓（人） | 0 | 21 |

最後の行が手がかりでした。人の歩きは、サイトの道順を使いません。その人まで入れないので、道順ではなく、⑦の通れる床そのものが途切れていることになります。

まず、図面が変わっていないかを確かめました。段階1で全卓つながったときの⑦の JSON（版 e624bdf4bdd4）と、今の JSON（版 9628954a582f）を項目ごとに比べると、違うのは道順（routes）を足したことだけで、床・壁・部屋・卓・通路・場所の点は同じでした。図面が同じなら、段階1のあとに Unity の側で何かが変わっています。

次の疑いは、③を建てた直後に⑦を建てるときの片付け漏れでした。⑦だけを建てて確かめてもらうと、結果は同じでした（21卓に入れない）。片付け漏れではありません。

そこで、落ちた卓の並びを見直しました。落ちたのは2本目の通路の卓・3本目の通路の卓・T27〜T30で、1本目の通路の卓は通っていました。この並びは、狭い通路の区画を書き込んでいたときに落ちた卓の並びと同じです。区画を書き込む作りは外したのに、同じ並びで落ちています。

Unity は、焼いた（計算した）NavMesh を覚えておくために、プログラムの中に目印を持っています。プログラムを書き換えると Unity は読み込み直しをし、そのときこの目印が空になります。目印がないと、次に焼き直すときに古い面を消せません。古い面は Unity の中に残り、新しい面と重なります。重なった2枚の面はつながっていないので、道探しが古い面の上の点から始まると、区画で割れた所で止まります。段階1は、まだ何も重なっていない状態で確かめていました。

確かめ方は、コードを変えずにできました。Unity を一度終了して開き直すと、残っていた面はすべて消えます。開き直して⑦だけを確かめると、通りました。

| 確かめたこと | ⑦（開き直す前・20:50） | ⑦（開き直した後・20:52） |
|---|---|---|
| すべての区間がつながった卓 | 21 / 42 | 42 / 42 |
| つながらない区間 | 25 | 0 |
| まっすぐの1.05倍を超えた区間 | 2 | 0 |
| 入口から席に入れない卓 | 21 | 0 |

【画像】LOG362_08_unity_editor_7_pass.png Unity を開き直して⑦を確かめた画面。⑦の店が真上から写り、Console に「Build Store おわり」と「JSON Store の確かめ おわり（p5_crammed）」の2行が出ている

Claude Code に、焼く前に入っている NavMesh を全部外す1行（`NavMesh.RemoveAllNavMeshData()`）を、焼く所と三角形を読む所の2か所に入れてもらいました。あわせて、狭い通路の印（hardToPass）を Unity から外しました。狭いかどうかを決めるのはサイトの道順の `use` だけにするためです。プログラムを書き換えたあと、開き直さずに③⑦を続けて確かめると、両方通りました（cs:114）。

| 確かめたこと | ③（通路1.2m） | ⑦（通路0.8m） |
|---|---|---|
| 区間がつながった卓 | 42 / 42 | 42 / 42 |
| つながらない区間 | 0 | 0 |
| 1.05倍を超えた区間 | 0 | 0 |
| 0.8mの道を通る卓 | 0 | 24 |
| 入口から席に入れない卓 | 0 | 0 |
| サイトの距離との差（平均／最大） | 0.28m／0.67m 短い | 0.37m／0.87m 短い |

道順をたどると、サイトの距離より平均0.3m前後短くなります。サイトの距離はマス目の道の長さで、Unity は曲がり角を直線で結んで走るためです。サイトと比べるときは、Unity のほうがこの分だけ少し速く着くことになります。


## 回し方をサイトにそろえる作業（v5の3章）を入れた：まだ回していない

続けて Claude Code が、回し方をサイトにそろえる作業を入れました（cs:116。報告は後ろに全量）。

- サイトと同じ6条件を Unity のメニューから選べる。数字は serving-robot-params.json から読む
- 空の店から始め、120分だけ客が来て、全員が帰ったら終わる（客が来なくなってから600分で打ち切り）
- ロボットが60秒動けなくなったら、受け渡し口へ移す（warp）のをやめ、スタッフが歩いて行って15秒動かす。止まっていた所から道順の続きを走る
- あきらめた・移した・卓を空けた、の回数（逃がし弁）を記録する
- 往復ごとに、実際に走れた速さ・動けなくなった回数・スタッフを待った秒数・0.8mの道を使ったか・狭い通路の中と外で止まった回数と秒数を出す

報告には「サイトと定義が違うもの」が並んでいたので、試算のコード（haizen_sim.py）と1行ずつ照らし合わせました。

| 報告にあった違い | 試算のコードを見た結果 |
|---|---|
| 「席はあるのに待った時間」（staff_wait_mean）。Unity は案内の人が来るまでだけを測る | 違う。サイトは「卓は空いたが片付けが終わらず待った時間」＋「案内の人の手が空かず待った時間」。Unity は前半を「席が埋まっていて待った時間」に入れていた |
| 片付けの時間（turn_mean）。サイトは片付けと拭く時間だけ | 読み違い。サイトもスタッフの手が空くのを待つ時間・卓まで歩く時間・皿を戻す往復を含む。同じ定義 |
| ロボットに料理を載せる15秒（load_s）を Unity は形にしていない | 15秒は下げ膳のときだけ使う値。上げ膳だけの条件では、サイトは載せる時間を0秒にしている |

「席はあるのに待った時間」は、サイトがこの値が10分以上の条件を「店が回らない」と判定する数字です。Claude Code は、サイトと同じ数え方に変え、上げ膳のときの待ち時間を0秒にしていました。どちらも記録する数字か、もともと使われていない値の変更で、店の中の動きは変わりません。そのまま使うことにしました。


## Unity をサイトに合わせ続けるのをやめ、3段階のゴールを決めた

ここで、作業の終わりを先に決めることにしました。

> 最終的にはUnityのシミュレーションをサイトの「配膳ロボット導入シミュレーション」に完全に合わせることは難しいと思います。
> なのでどこまでズレを直せばゴールなのかあらかじめ決めてください。
> このままだと延々と作業を繰り返しているだけで、いつまで経っても何が達成できるのか分かりません。

最初に出した案は、「4つの値（速さ・止まる割合・止まる長さ・動けなくなる割合）を測ってサイトに戻すことをゴールにし、ズレは、ロボットのまわりを動く人やロボットの数・通る場所が変わるものだけ直す」でした。これに対して、進め方そのものの案が返ってきました。

> 最終的にはこういう手順でサイトとUnityのシミュレーションを擦り合わせする必要があると思ってはいます。
>
> まずはUnityでファミレス店舗で図面があるもののシミュレーションを大方作ってしまいます。
> 実際にUnityでシミュレーションで回してみて、どのようなことが店舗で起こるかをデータを記録します（実際に動くシミュレーションデータを取得する）
>
> そのデータを元に台帳にデータをまとめてUnity特有のロボット導入シミュレーションデータとして確率されます。この時にサイトのデータとUnityのデータが揃っていれば良いですが実際には難しいと思います。
>
> つまりサイトの導入シミュレーションとUnityシミュレーションのギャップが生まれるはずです。
> その差をどう埋めていくかを観察して、データの取得項目や部屋の構成の見直しなどを測る必要があります。

最初の案は「Unity を先にサイトに合わせ、ズレたら直す」の延長でした。これだと、ズレが見つかるたびに直す作業が続きます。返ってきた進め方では、**サイトとのズレは直す対象ではなく、Unity を回し切ったあとに調べる対象（結果）になります。** この進め方を3つの段階に分け、それぞれの終わりを決めました。

| 段階 | やること | 終わり |
|---|---|---|
| A. 店を作る | 図面のある店を Unity で建てる。店の形と条件（1時間の組数・スタッフ数・ロボット台数）だけはサイトと同じにする。仕事の決まりや数え方は Unity のままでよく、違いは一覧に書くだけ | どの店・どの条件でも最後まで回り切り、逃がし弁がほぼ0 |
| B. 回して台帳にまとめる | 1回回すごとに台帳に1行足す（店・条件・乱数の種・結果の数字・ロボットの4つの値・逃がし弁の回数）。台帳の置き場所は先に MASTER の保存先の表に足す | 決めた条件と回数が全部台帳に入る |
| C. ズレを調べる | 同じ店・同じ条件でサイトと Unity の数字を並べ、ズレごとに理由を分ける（数え方の違い／サイトにない動き＝すれ違い・混雑／Unity の作りの問題）。何を直すかを決める | ズレの一覧と、それぞれの理由・対処が書ける。対処を入れるのは次の版 |

店と条件だけをそろえるのは、そこが違うと、あとでズレの理由を読めなくなるからです。店の順番は、③⑦で A〜C を一度通してから、ほかの店（②④⑤⑥）を足すことにしました。③⑦で一度ズレの出方を見れば、ほかの店で何を記録すべきかが分かるためです。問題が出たときに直すのは1回だけにし、それでも直らなければ制約として書いて先へ進みます。この表は、設計書（unity_design_v0.md）の0.5章に書きました。

次は、③のロボット2台の条件を乱数の種1つで1回だけ回し、最後まで回り切るか、逃がし弁がほぼ0か、往復ごとの値が記録されるかを見ます。1回にかかる実時間を見て、何回ずつ回すかを決めます。


## データとコードの置き場所

| もの | 場所 |
|---|---|
| 店の形の作り方・確かめ・Unity 用の書き出し | GitHub `PixelstoMotors/p2m-site` の `tools/sim_layouts/`（famires_v3.py・famires_p0.py・unity_export.py・test_famires_v3.py・README.md） |
| 店の形のデータ（試算用・Unity 用） | p2m-site の `public/sim/layouts/`・`public/sim/layouts/unity/`（版 9628954a582f） |
| 試算の条件と店の回し方の値 | p2m-site の `public/sim/serving-robot-params.json` |
| 試算 v2.5 | iCloud `サービスロボット導入シミュレーション/simulation/v2_5/`（haizen_sim.py は v2.4 と同じ） |
| Unity の設計書・Claude Code への依頼文・Claude Code の報告 | iCloud `サービスロボット導入シミュレーション/unity/`（unity_design_v0.md・claude_code_request_v4/v5/v5b・claude_code_reports/） |
| Unity の確かめの出力 | iCloud `サービスロボット導入シミュレーション/unity/screens/` |
| Unity のコード | `/Users/shimazueiichi/Robotics_UX/Robotics_UX_New/Assets/ServiceSim/`（Unity Version Control cs:112〜cs:116） |


## 実行したコマンドと出力

### 確かめの仕組みのテスト（tools/sim_layouts/test_famires_v3.py・107件）
```
$ cd tools/sim_layouts
$ time python3 test_famires_v3.py
OK  80cmの通路はBellaBotで通れる（マス目0.025・ずらし0.0）
OK  77cmの通路はBellaBotで通れない（マス目0.025・ずらし0.0）
OK  75cmの通路はServiで通れる（マス目0.025・ずらし0.0）
OK  80cmの通路はBellaBotで通れる（マス目0.025・ずらし0.013）
OK  77cmの通路はBellaBotで通れない（マス目0.025・ずらし0.013）
OK  75cmの通路はServiで通れる（マス目0.025・ずらし0.013）
OK  80cmの通路はBellaBotで通れる（マス目0.025・ずらし0.031）
OK  77cmの通路はBellaBotで通れない（マス目0.025・ずらし0.031）
OK  75cmの通路はServiで通れる（マス目0.025・ずらし0.031）
OK  80cmの通路はBellaBotで通れる（マス目0.0125・ずらし0.0）
OK  77cmの通路はBellaBotで通れない（マス目0.0125・ずらし0.0）
OK  75cmの通路はServiで通れる（マス目0.0125・ずらし0.0）
OK  80cmの通路はBellaBotで通れる（マス目0.0125・ずらし0.013）
OK  77cmの通路はBellaBotで通れない（マス目0.0125・ずらし0.013）
OK  75cmの通路はServiで通れる（マス目0.0125・ずらし0.013）
OK  80cmの通路はBellaBotで通れる（マス目0.0125・ずらし0.031）
OK  77cmの通路はBellaBotで通れない（マス目0.0125・ずらし0.031）
OK  75cmの通路はServiで通れる（マス目0.0125・ずらし0.031）
OK  横向きのボックスの左右（ベンチの背）は座る側の候補にしない
OK  縦向きのボックスの上下（ベンチの背）は座る側の候補にしない
OK  店の外にはみ出した卓を見つける
OK  トイレの入口の前の壁を見つける
OK  トイレの壁でへだてた1mの席は違反にしない
OK  壁がなければ、トイレからちょうど1mの席を見つける
OK  卓に食い込んだ壁を見つける
OK  ゆったり型の仕切り（背もたれの上）は違反にしない
OK  座る側の前をふさがれた卓を見つける
OK  出発点がふさがれていたら止める
OK  待合がないのを見つける
OK  ⑦は③と同じ卓の数・種類・席数
OK  L字席（flip=False・mirror=False）の止まってよい辺は、開いた角とソファの端0.6mまで
OK  L字席（flip=False・mirror=True）の止まってよい辺は、開いた角とソファの端0.6mまで
OK  L字席（flip=True・mirror=False）の止まってよい辺は、開いた角とソファの端0.6mまで
OK  L字席（flip=True・mirror=True）の止まってよい辺は、開いた角とソファの端0.6mまで
OK  ⓪T02 の止まる位置は、開いた角か、そこからソファの端に沿って0.6m以内
OK  ⓪の待機場所は、1台ずつ受け渡し口の前まで出られる
OK  2人席（ベンチがbottom）は、ベンチの側とベンチの横を止まる辺にしない
OK  2人席（ベンチがtop）は、ベンチの側とベンチの横を止まる辺にしない
OK  2人席（ベンチがleft）は、ベンチの側とベンチの横を止まる辺にしない
OK  2人席（ベンチがright）は、ベンチの側とベンチの横を止まる辺にしない
OK  直す前の⓪の待機場所（2.2×0.7mに3台）を見つける
OK  待機場所の台数 100 を見つける
OK  待機場所の台数 0 を見つける
OK  待機場所の台数 -1 を見つける
OK  待機場所の台数 '3' を見つける
OK  待機場所の台数 2.5 を見つける
OK  待機場所の台数 True を見つける
OK  待機場所の前をふさぐと、真ん中の台だけ「出られない」を見つける
OK  待機場所の前と両横をふさぐと、3台とも「出られない」を見つける
OK  反例1：レジとレジ内を消すと見つける
OK  反例2：トイレの壁をほぼ全部外すと「切れ目」を見つける
OK  反例3：トイレの入口の前を「トイレの壁」という名前の壁でふさいでも見つける
OK  反例4：家具の真ん中に置いた接合扱いの仕切りを見つける
OK  反例5：待機場所100台を見つける
OK  反例6：⓪の建物の外を消すと、輪郭と合わないのを見つける
OK  反例7：輪郭を2m四方にすると見つける
OK  ⓪の輪郭から欠けを消すと、建物の外と合わないのを見つける
OK  今の店 p0_memory は決まりを守っている
OK  今の店 p1_efficient_corner は決まりを守っている
OK  今の店 p2_efficient_narrow は決まりを守っている
OK  今の店 p3_relaxed_center は決まりを守っている
OK  今の店 p4_relaxed_side は決まりを守っている
OK  今の店 p5_crammed は決まりを守っている
OK  ベンチの背に立つ仕切りは認める
OK  ベンチの背でも0.08mより深い食い込みは認めない
OK  トイレの目隠し：中心の線だけ当たる短い壁では違反にする（4本の線）
OK  トイレの目隠し：4本とも当たる壁なら違反にしない
OK  線分が厚さ2mmの壁を横切るのを見つける
OK  線分の端が壁に触れるだけなら、通ったとみなさない
OK  線分が壁から離れていれば、通ったとみなさない
OK  201番目だけ見通せる止まる位置も見つける
[OK] p2_efficient_narrow: {'name': '④ 効率重視型・狭い店・入口が右の壁', 'seats': 88, 'tables': 28, 'model': 'BellaBot', 'pass_width': 0.8, 'normal_width': 1.0, 'robot_tables': 28, 'robot_seats': 88, 'narrow_tables': 0, 'narrow_seats': 0, 'human_tables': 0, 'human_seats': 0, 'robot_dist_mean': 8.4, 'robot_dist_max': 14.02, 'human_walk_total': 0.0, 'human_walk_max': None}  マス目の差: []
OK  成功したときは、layout・analysis・summary に同じ版の番号が入る
[NG] p2_efficient_narrow: ④ 効率重視型・狭い店・入口が右の壁
   - 入口まわり: レジがない

違反があるので、/tmp/tmpgjtort40 には何も書いていない（前の版のまま）
OK  違反があると止まり、出力先のファイルは前の版のまま（作業用のフォルダも残らない）
[OK] p2_efficient_narrow: {'name': '④ 効率重視型・狭い店・入口が右の壁', 'seats': 88, 'tables': 28, 'model': 'BellaBot', 'pass_width': 0.8, 'normal_width': 1.0, 'robot_tables': 28, 'robot_seats': 88, 'narrow_tables': 0, 'narrow_seats': 0, 'human_tables': 0, 'human_seats': 0, 'robot_dist_mean': 8.4, 'robot_dist_max': 14.02, 'human_walk_total': 0.0, 'human_walk_max': None}  マス目の差: []

出力先へ入れ替える途中で失敗したので、/tmp/tmpgjtort40 を前の版に戻した
OK  入れ替えの途中で失敗すると、出力先は前の版に戻る（作業用のフォルダも残らない）
OK  反例A：トイレの壁の1cmの切れ目を見つける
OK  反例B：トイレの入口の内側の1cmのふさぎを見つける
OK  反例C：トイレの入口の前の奥行き0.1mを見つける
OK  反例D：輪郭の幅1cmの切れ込み（建物の外なし）を見つける
OK  輪郭に斜めの辺があれば止める
OK  出入り口Aを壁でふさぐと見つける
OK  p1：細い通路は3本で、幅はどれも1.2m
OK  p1：卓の数・止まる位置・距離が analysis と同じ
OK  p1：細い通路を通る卓は、止まる位置がその通路の区画の中
OK  p1：細い通路の入口は、受け渡し口の前から行ける端
OK  p1：全部の卓に部品（天板・座る位置）があり、座る位置の数が席数と同じ
OK  p1：場所の点がそろっている
OK  p1：スタッフが待つ点・並ぶ点・立つ点は、障害物から0.2m以上離れ、細い通路に入らない
OK  p1：席に着く点は障害物から0.2m以上離れ、どの卓にも1つ以上ある
OK  p1：レジ内は通れる部屋（スタッフだけ）、ほかの部屋は通れない箱
OK  p1：受け渡し口から全卓・卓から卓の全組に道順がある
OK  p1：ふだんの道か狭い道かは、試算の卓の分け方と同じ（robot→normal、narrow→narrow）
OK  p1：道順は受け渡し口の前から始まり、止まる位置で終わる
OK  p1：道順のどの区間も、その幅で通れるマス目だけを通る
OK  p5：細い通路は3本で、幅はどれも0.8m
OK  p5：卓の数・止まる位置・距離が analysis と同じ
OK  p5：細い通路を通る卓は、止まる位置がその通路の区画の中
OK  p5：細い通路の入口は、受け渡し口の前から行ける端
OK  p5：全部の卓に部品（天板・座る位置）があり、座る位置の数が席数と同じ
OK  p5：場所の点がそろっている
OK  p5：スタッフが待つ点・並ぶ点・立つ点は、障害物から0.2m以上離れ、細い通路に入らない
OK  p5：席に着く点は障害物から0.2m以上離れ、どの卓にも1つ以上ある
OK  p5：レジ内は通れる部屋（スタッフだけ）、ほかの部屋は通れない箱
OK  p5：受け渡し口から全卓・卓から卓の全組に道順がある
OK  p5：ふだんの道か狭い道かは、試算の卓の分け方と同じ（robot→normal、narrow→narrow）
OK  p5：道順は受け渡し口の前から始まり、止まる位置で終わる
OK  p5：道順のどの区間も、その幅で通れるマス目だけを通る
OK  ボックス席の列は、すき間なく並んだものだけをまとめる

すべて OK

real	1m1.048s
user	0m58.514s
sys	0m1.553s
```

### 6つの店を確かめて書き出す（famires_v3.py）。出力は公開中のデータとすべて同じだった
```
$ time python3 famires_v3.py --out /tmp/claude-0/runout/layouts
[OK] p0_memory: {'name': '② 定番の郊外型', 'seats': 88, 'tables': 19, 'model': 'BellaBot', 'pass_width': 0.8, 'normal_width': 1.0, 'robot_tables': 19, 'robot_seats': 88, 'narrow_tables': 0, 'narrow_seats': 0, 'human_tables': 0, 'human_seats': 0, 'robot_dist_mean': 8.6, 'robot_dist_max': 13.78, 'human_walk_total': 0.0, 'human_walk_max': None}  マス目の差: []
[OK] p1_efficient_corner: {'name': '③ 効率重視型・入口が左下の角', 'seats': 160, 'tables': 42, 'model': 'BellaBot', 'pass_width': 0.8, 'normal_width': 1.0, 'robot_tables': 42, 'robot_seats': 160, 'narrow_tables': 0, 'narrow_seats': 0, 'human_tables': 0, 'human_seats': 0, 'robot_dist_mean': 10.7, 'robot_dist_max': 20.14, 'human_walk_total': 0.0, 'human_walk_max': None}  マス目の差: []
[OK] p2_efficient_narrow: {'name': '④ 効率重視型・狭い店・入口が右の壁', 'seats': 88, 'tables': 28, 'model': 'BellaBot', 'pass_width': 0.8, 'normal_width': 1.0, 'robot_tables': 28, 'robot_seats': 88, 'narrow_tables': 0, 'narrow_seats': 0, 'human_tables': 0, 'human_seats': 0, 'robot_dist_mean': 8.4, 'robot_dist_max': 14.02, 'human_walk_total': 0.0, 'human_walk_max': None}  マス目の差: []
[OK] p3_relaxed_center: {'name': '⑤ ゆったり型・入口が正面の中央', 'seats': 200, 'tables': 46, 'model': 'BellaBot', 'pass_width': 0.8, 'normal_width': 1.0, 'robot_tables': 46, 'robot_seats': 200, 'narrow_tables': 0, 'narrow_seats': 0, 'human_tables': 0, 'human_seats': 0, 'robot_dist_mean': 13.7, 'robot_dist_max': 23.17, 'human_walk_total': 0.0, 'human_walk_max': None}  マス目の差: []
[OK] p4_relaxed_side: {'name': '⑥ ゆったり型・入口が左の壁', 'seats': 158, 'tables': 38, 'model': 'BellaBot', 'pass_width': 0.8, 'normal_width': 1.0, 'robot_tables': 38, 'robot_seats': 158, 'narrow_tables': 0, 'narrow_seats': 0, 'human_tables': 0, 'human_seats': 0, 'robot_dist_mean': 10.7, 'robot_dist_max': 18.38, 'human_walk_total': 0.0, 'human_walk_max': None}  マス目の差: []
[OK] p5_crammed: {'name': '⑦ ③の列の間の通路を80cmに（卓と席数は③と同じ）', 'seats': 160, 'tables': 42, 'model': 'BellaBot', 'pass_width': 0.8, 'normal_width': 1.0, 'robot_tables': 18, 'robot_seats': 64, 'narrow_tables': 24, 'narrow_seats': 96, 'human_tables': 0, 'human_seats': 0, 'robot_dist_mean': 11.0, 'robot_dist_max': 20.32, 'human_walk_total': 0.0, 'human_walk_max': None}  マス目の差: []

real	2m55.063s
user	2m42.507s
sys	0m9.891s
```
```
$ for f in *.json unity/*.json; do cmp -s $f <p2m-site>/public/sim/layouts/$f && echo "same $f" || echo "DIFF $f"; done
same p0_memory.json
same p0_memory_analysis.json
same p1_efficient_corner.json
same p1_efficient_corner_analysis.json
same p2_efficient_narrow.json
same p2_efficient_narrow_analysis.json
same p3_relaxed_center.json
same p3_relaxed_center_analysis.json
same p4_relaxed_side.json
same p4_relaxed_side_analysis.json
same p5_crammed.json
same p5_crammed_analysis.json
same summary.json
same unity/p0_memory.json
same unity/p1_efficient_corner.json
same unity/p2_efficient_narrow.json
same unity/p3_relaxed_center.json
same unity/p4_relaxed_side.json
same unity/p5_crammed.json
```

### 試算 v2.4 と v2.5 の比較（compare_v24_v25.txt）
```
v2.4 と v2.5 の比較（店の形の直しで ② 定番の郊外型（fam_p0）の卓までの距離が変わったため、② だけ回し直した）
条件の数 16425 16425 並びが同じ True
② 以外で数字が変わった条件 0 / 15111
② の条件 1314 変わった条件 1314
② の卓の数 19 -> 19  卓までの平均 8.49 -> 8.56
② の店が回らない 124 -> 124  全体 3007 -> 3007
② entry_wait_mean: 差の平均 -0.087  最小 -4.315  最大 +3.916
② staff_wait_mean: 差の平均 -0.020  最小 -2.310  最大 +2.848
② dish_wait_mean: 差の平均 +0.002  最小 -0.041  最大 +0.051
② walk_km_per_staff: 差の平均 +0.005  最小 -0.126  最大 +0.201
代表 ('fam_p0', 4, 2, 19, 'serve', 3, 'any') {'entry_wait_mean': (0.93, 0.53), 'staff_wait_mean': (0.21, 0.14), 'dish_wait_mean': (0.3, 0.3), 'walk_km_per_staff': (0.62, 0.62)}
```

### ⑦の確かめ：開き直す前（2026-10-22 20:50、⑦だけで建てた。20:33 に③⑦を続けて建てたときも、作った日時以外は同じ出力）
```
[ServiceSim] JSON Store の確かめ おわり（p5_crammed）
# JSON から建てた店の確かめ（段階1・回していない）
店の形: StoreLayout_p5_crammed / JsonStoreLayout(p5_crammed)
作った日時: 2026-10-22 20:50:17

JSON から建てた店（Unity では形を作っていない）
  ファイル: p5_crammed / 名前: ⑦ ③の列の間の通路を80cmに（卓と席数は③と同じ）
  版の番号 (meta.generation): 9628954a582f
  写した日時: 2026-10-22 20:23:50 JST（版 9628954a582f）
  写し元: https://pixelstomotors.com/sim/layouts/unity/p5_crammed.json
  卓 42 / 席 160 / 細い通路 3 本（幅 0.8 m）
  通れない部屋 9 / 通れる部屋 1（うちスタッフだけ 1）
  場所の点 26 / 壁 9 / 仕切り 0 / 椅子 8 / 席に着く点 45 / 座る位置 160
  狭い通路（幅 < normal_width 1 m）: 3 本（v5b: NavMesh の区画にはしない。道順はサイトの routes を使う）
  道順 (routes): 受け渡し口から 42 本 / 卓から卓 861 本
  doors: 0（壁の切れ目として、もう空いている）
  refuges（よけ場所）: 0（JSON にまだ無い。空のまま）
  止まる位置は JSON の stop をそのまま使う（deliveryOffsetFromAisleCenter と aisleEntranceSetback は使わない）
  合わないもの: なし

## 1. 数
  卓: Unity 42 / JSON 42 / summary.json 42  一致
  席: Unity 160 / JSON 160 / summary.json 160  一致
  細い通路: 3 本（A01 0.8 m / A02 0.8 m / A03 0.8 m）
  組み立てた幅 (plan.aisleWidth): 0.8 m
  外寸: 24 × 16 m / JSON outer 24 × 16 m

## 2. 点が NavMesh の上に乗るか（乗らないものだけ並べる）
  止まる位置 (deliveryPoint, ロボット用): 乗らない 0 / 42
  席に着く点 (spots, 人用): 乗らない 0
  座る位置 (seatPoints, 人用): 乗らない 126  ※ 座面やベンチの上の点は NavMesh に乗らないのがふつう（見た目に使う点）
    - T01#1 (0.3, 4.88)
    - T01#2 (0.9, 4.88)
    - T01#3 (0.3, 6.18)
    - T01#4 (0.9, 6.18)
    - T02#1 (0.3, 6.73)
    - T02#2 (0.9, 6.73)
    - T02#3 (0.3, 8.03)
    - T02#4 (0.9, 8.03)
    - ほか 118 件
  場所の点 (places): 乗らない 0

## 3. 道順の照合（受け渡し口の前 → 止まる位置。サイトの routes をたどる）
  道順: 受け渡し口から 42 本（うち 0.8 m の道 24 本）/ 卓から卓 861 本
  止まる位置を面へ寄せる半径: 1 m
  すべての区間がつながった卓: 21 / 42
  差（Unity − JSON）の平均: -0.3 m
  差の大きさの平均: 0.3 m / 最大: 0.62 m（T31）
  差が 30% を超えた卓: 0
  via_narrow（use=narrow）の卓: 24 / 42
  つながらない区間: 25
    - T09 #3 (5.11, 4.41)→(4.94, 4.61) 途中まで
    - T10 #3 (5.11, 4.41)→(4.94, 4.61) 途中まで
    - T11 #3 (5.11, 4.41)→(4.94, 4.61) 途中まで
    - T12 #3 (5.11, 4.41)→(4.94, 4.61) 途中まで
    - T13 #3 (5.11, 4.41)→(4.94, 4.61) 途中まで
    - T14 #3 (5.11, 4.41)→(4.94, 4.61) 途中まで
    - T15 #3 (5.11, 4.41)→(4.94, 4.61) 途中まで
    - T16 #3 (5.11, 4.41)→(4.94, 4.61) 途中まで
    - T18 #4 (8.41, 4.41)→(8.24, 4.61) 途中まで
    - T19 #4 (8.41, 4.41)→(8.24, 4.61) 途中まで
    - T20 #3 (8.41, 4.41)→(8.24, 4.61) 途中まで
    - T21 #4 (13.61, 6.41)→(11.44, 12.44) 途中まで
    - T21 #5 (11.44, 12.44)→(11.29, 12.56) 途中まで
    - T21 #7 (11.16, 12.59)→(8.41, 12.59) 途中まで
    - T22 #7 (11.16, 12.69)→(8.59, 12.69) 途中まで
    - T23 #4 (8.41, 4.41)→(8.24, 4.61) 途中まで
    - T24 #4 (8.41, 4.41)→(8.24, 4.61) 途中まで
    - T25 #3 (8.41, 4.41)→(8.24, 4.61) 途中まで
    - T26 #4 (13.61, 6.41)→(11.44, 12.44) 途中まで
    - T26 #5 (11.44, 12.44)→(11.29, 12.56) 途中まで
    - T26 #7 (11.16, 12.59)→(8.41, 12.59) 途中まで
    - T27 #1 (15.3, 5.95)→(11.89, 5.96) 途中まで
    - T28 #2 (12.61, 6.09)→(11.89, 6.81) 途中まで
    - T29 #3 (13.54, 6.26)→(11.89, 8.66) 途中まで
    - T30 #3 (13.54, 6.26)→(11.89, 10.51) 途中まで
  まっすぐの 1.05 倍を超えた区間: 2
    - T17 #4 まっすぐ 6.39 m → NavMesh 7.05 m（1.1 倍）
    - T22 #4 まっすぐ 6.39 m → NavMesh 7.05 m（1.1 倍）
  CSV: /Users/shimazueiichi/Library/Mobile Documents/com~apple~CloudDocs/デスクトップ/ADAPT DESIGN/じぶんの/PixelstoMotors/デジタルツイン学習R2R2R/サービスロボット導入シミュレーション/unity/screens/20261022_p5_crammed_table_dist.csv

## 4. 席に入れない卓（入口から、席に着く点まで歩けるか）
  出発点 (Entrance): (1.8, 0.5)
  確かめた点: 42 / 卓 42
  入れない卓: 21
    - T09 どの席に着く点へも歩けない
    - T10 どの席に着く点へも歩けない
    - T11 どの席に着く点へも歩けない
    - T12 どの席に着く点へも歩けない
    - T13 どの席に着く点へも歩けない
    - T14 どの席に着く点へも歩けない
    - T15 どの席に着く点へも歩けない
    - T16 どの席に着く点へも歩けない
    - T18 どの席に着く点へも歩けない
    - T19 どの席に着く点へも歩けない
    - T20 どの席に着く点へも歩けない
    - T21 どの席に着く点へも歩けない
    - T22 どの席に着く点へも歩けない
    - T23 どの席に着く点へも歩けない
    - T24 どの席に着く点へも歩けない
    - T25 どの席に着く点へも歩けない
    - T26 どの席に着く点へも歩けない
    - T27 どの席に着く点へも歩けない
    - T28 どの席に着く点へも歩けない
    - T29 どの席に着く点へも歩けない
    - T30 どの席に着く点へも歩けない

  書き出し: /Users/shimazueiichi/Library/Mobile Documents/com~apple~CloudDocs/デスクトップ/ADAPT DESIGN/じぶんの/PixelstoMotors/デジタルツイン学習R2R2R/サービスロボット導入シミュレーション/unity/screens/20261022_p5_crammed_json_check.txt
            /Users/shimazueiichi/Library/Mobile Documents/com~apple~CloudDocs/デスクトップ/ADAPT DESIGN/じぶんの/PixelstoMotors/デジタルツイン学習R2R2R/サービスロボット導入シミュレーション/unity/screens/20261022_p5_crammed_table_dist.csv
UnityEngine.Debug:Log (object)
ServiceSim.EditorTools.ServiceSimJsonStore:Run (string) (at Assets/ServiceSim/Editor/ServiceSimJsonStore.cs:225)
ServiceSim.EditorTools.ServiceSimJsonStore:CheckP5 () (at Assets/ServiceSim/Editor/ServiceSimJsonStore.cs:43)
```

### ③⑦の確かめ：NavMesh を消してから焼く直しのあと（2026-10-22 21:04、③⑦を続けて建てた）

#### 20261022_p1_efficient_corner_json_check.txt
```
# JSON から建てた店の確かめ（段階1・回していない）
店の形: StoreLayout_p1_efficient_corner / JsonStoreLayout(p1_efficient_corner)
作った日時: 2026-10-22 21:04:49

JSON から建てた店（Unity では形を作っていない）
  ファイル: p1_efficient_corner / 名前: ③ 効率重視型・入口が左下の角
  版の番号 (meta.generation): 9628954a582f
  写した日時: 2026-10-22 20:23:50 JST（版 9628954a582f）
  写し元: https://pixelstomotors.com/sim/layouts/unity/p1_efficient_corner.json
  卓 42 / 席 160 / 細い通路 3 本（幅 1.2 m）
  通れない部屋 9 / 通れる部屋 1（うちスタッフだけ 1）
  場所の点 26 / 壁 9 / 仕切り 0 / 椅子 8 / 席に着く点 45 / 座る位置 160
  道順 (routes): 受け渡し口から 42 本 / 卓から卓 861 本（狭い道を使うかは routes の use が正。Unity では印を持たない）
  doors: 0（壁の切れ目として、もう空いている）
  refuges（よけ場所）: 0（JSON にまだ無い。空のまま）
  止まる位置は JSON の stop をそのまま使う（deliveryOffsetFromAisleCenter と aisleEntranceSetback は使わない）
  合わないもの: なし

## 1. 数
  卓: Unity 42 / JSON 42 / summary.json 42  一致
  席: Unity 160 / JSON 160 / summary.json 160  一致
  細い通路: 3 本（A01 1.2 m / A02 1.2 m / A03 1.2 m）
  組み立てた幅 (plan.aisleWidth): 1.2 m
  外寸: 24 × 16 m / JSON outer 24 × 16 m

## 2. 点が NavMesh の上に乗るか（乗らないものだけ並べる）
  止まる位置 (deliveryPoint, ロボット用): 乗らない 0 / 42
  席に着く点 (spots, 人用): 乗らない 0
  座る位置 (seatPoints, 人用): 乗らない 160  ※ 座面やベンチの上の点は NavMesh に乗らないのがふつう（見た目に使う点）
    - T01#1 (0.3, 4.88)
    - T01#2 (0.9, 4.88)
    - T01#3 (0.3, 6.18)
    - T01#4 (0.9, 6.18)
    - T02#1 (0.3, 6.73)
    - T02#2 (0.9, 6.73)
    - T02#3 (0.3, 8.03)
    - T02#4 (0.9, 8.03)
    - ほか 152 件
  場所の点 (places): 乗らない 0

## 3. 道順の照合（受け渡し口の前 → 止まる位置。サイトの routes をたどる）
  道順: 受け渡し口から 42 本（うち 0.8 m の道 0 本）/ 卓から卓 861 本
  止まる位置を面へ寄せる半径: 1 m
  すべての区間がつながった卓: 42 / 42
  差（Unity − JSON）の平均: -0.28 m
  差の大きさの平均: 0.28 m / 最大: 0.67 m（T17）
  差が 30% を超えた卓: 0
  via_narrow（use=narrow）の卓: 0 / 42
  つながらない区間: 0
  まっすぐの 1.05 倍を超えた区間: 0
  CSV: /Users/shimazueiichi/Library/Mobile Documents/com~apple~CloudDocs/デスクトップ/ADAPT DESIGN/じぶんの/PixelstoMotors/デジタルツイン学習R2R2R/サービスロボット導入シミュレーション/unity/screens/20261022_p1_efficient_corner_table_dist.csv

## 4. 席に入れない卓（入口から、席に着く点まで歩けるか）
  出発点 (Entrance): (1.8, 0.5)
  確かめた点: 42 / 卓 42
  入れない卓: 0
```

#### 20261022_p1_efficient_corner_table_dist.csv
```csv
table,category,aisle,use,json_dist_m,route_len_m,unity_path_m,diff_m,diff_pct,via_narrow,segments,bad_segments,status
T01,robot,A01,normal,14.39,14.11,14.11,-0.28,-1.92,0,5,0,ok
T02,robot,A01,normal,16.24,15.96,15.96,-0.28,-1.72,0,6,0,ok
T03,robot,A01,normal,18.09,17.81,17.81,-0.28,-1.55,0,6,0,ok
T04,robot,A01,normal,19.94,19.66,19.66,-0.28,-1.38,0,5,0,ok
T05,robot,A01,normal,14.59,14.31,14.31,-0.28,-1.92,0,6,0,ok
T06,robot,A01,normal,16.44,16.16,16.16,-0.28,-1.7,0,6,0,ok
T07,robot,A01,normal,18.29,18.01,18.01,-0.28,-1.51,0,5,0,ok
T08,robot,A01,normal,20.14,19.86,19.86,-0.28,-1.38,0,6,0,ok
T09,robot,A02,normal,10.89,10.61,10.61,-0.28,-2.57,0,6,0,ok
T10,robot,A02,normal,12.74,12.46,12.46,-0.28,-2.17,0,5,0,ok
T11,robot,A02,normal,14.59,14.31,14.31,-0.28,-1.92,0,6,0,ok
T12,robot,A02,normal,16.44,16.16,16.16,-0.28,-1.69,0,6,0,ok
T13,robot,A02,normal,10.89,10.61,10.61,-0.28,-2.57,0,6,0,ok
T14,robot,A02,normal,12.74,12.46,12.46,-0.28,-2.17,0,5,0,ok
T15,robot,A02,normal,14.59,14.31,14.31,-0.28,-1.92,0,6,0,ok
T16,robot,A02,normal,16.44,16.16,16.16,-0.28,-1.69,0,6,0,ok
T17,robot,A02,normal,16.95,16.28,16.28,-0.67,-3.97,0,10,0,ok
T18,robot,A03,normal,7.19,6.91,6.91,-0.28,-3.83,0,5,0,ok
T19,robot,A03,normal,9.04,8.76,8.76,-0.28,-3.09,0,6,0,ok
T20,robot,A03,normal,10.89,10.61,10.61,-0.28,-2.56,0,6,0,ok
T21,robot,A03,normal,12.48,12.14,12.14,-0.34,-2.73,0,10,0,ok
T22,robot,A03,normal,11.9,11.58,11.58,-0.32,-2.72,0,7,0,ok
T23,robot,A03,normal,7.19,6.91,6.91,-0.28,-3.83,0,5,0,ok
T24,robot,A03,normal,9.04,8.76,8.76,-0.28,-3.09,0,6,0,ok
T25,robot,A03,normal,10.89,10.61,10.61,-0.28,-2.56,0,6,0,ok
T26,robot,A03,normal,12.48,12.14,12.14,-0.34,-2.73,0,10,0,ok
T27,robot,,normal,2.22,2.21,2.21,-0.01,-0.36,0,1,0,ok
T28,robot,,normal,2.65,2.58,2.58,-0.07,-2.47,0,3,0,ok
T29,robot,,normal,4.5,4.32,4.32,-0.18,-4.1,0,3,0,ok
T30,robot,,normal,6.35,6.15,6.15,-0.2,-3.17,0,4,0,ok
T31,robot,,normal,8.22,7.61,7.61,-0.61,-7.44,0,2,0,ok
T32,robot,,normal,6.37,5.99,5.99,-0.38,-5.91,0,1,0,ok
T33,robot,,normal,4.67,4.58,4.58,-0.09,-1.9,0,1,0,ok
T34,robot,,normal,3.81,3.76,3.76,-0.05,-1.25,0,1,0,ok
T35,robot,,normal,4,3.8,3.8,-0.2,-4.91,0,1,0,ok
T36,robot,,normal,4.62,4.29,4.29,-0.33,-7.12,0,3,0,ok
T37,robot,,normal,5.72,5.37,5.37,-0.35,-6.1,0,4,0,ok
T38,robot,,normal,6.82,6.47,6.47,-0.35,-5.16,0,4,0,ok
T39,robot,,normal,7.92,7.57,7.57,-0.35,-4.44,0,4,0,ok
T40,robot,,normal,9.02,8.67,8.67,-0.35,-3.92,0,4,0,ok
T41,robot,,normal,10.12,9.77,9.77,-0.35,-3.48,0,4,0,ok
T42,robot,,normal,5.43,5.38,5.38,-0.05,-0.93,0,3,0,ok
```

#### 20261022_p5_crammed_json_check.txt
```
# JSON から建てた店の確かめ（段階1・回していない）
店の形: StoreLayout_p5_crammed / JsonStoreLayout(p5_crammed)
作った日時: 2026-10-22 21:04:50

JSON から建てた店（Unity では形を作っていない）
  ファイル: p5_crammed / 名前: ⑦ ③の列の間の通路を80cmに（卓と席数は③と同じ）
  版の番号 (meta.generation): 9628954a582f
  写した日時: 2026-10-22 20:23:50 JST（版 9628954a582f）
  写し元: https://pixelstomotors.com/sim/layouts/unity/p5_crammed.json
  卓 42 / 席 160 / 細い通路 3 本（幅 0.8 m）
  通れない部屋 9 / 通れる部屋 1（うちスタッフだけ 1）
  場所の点 26 / 壁 9 / 仕切り 0 / 椅子 8 / 席に着く点 45 / 座る位置 160
  道順 (routes): 受け渡し口から 42 本 / 卓から卓 861 本（狭い道を使うかは routes の use が正。Unity では印を持たない）
  doors: 0（壁の切れ目として、もう空いている）
  refuges（よけ場所）: 0（JSON にまだ無い。空のまま）
  止まる位置は JSON の stop をそのまま使う（deliveryOffsetFromAisleCenter と aisleEntranceSetback は使わない）
  合わないもの: なし

## 1. 数
  卓: Unity 42 / JSON 42 / summary.json 42  一致
  席: Unity 160 / JSON 160 / summary.json 160  一致
  細い通路: 3 本（A01 0.8 m / A02 0.8 m / A03 0.8 m）
  組み立てた幅 (plan.aisleWidth): 0.8 m
  外寸: 24 × 16 m / JSON outer 24 × 16 m

## 2. 点が NavMesh の上に乗るか（乗らないものだけ並べる）
  止まる位置 (deliveryPoint, ロボット用): 乗らない 0 / 42
  席に着く点 (spots, 人用): 乗らない 0
  座る位置 (seatPoints, 人用): 乗らない 160  ※ 座面やベンチの上の点は NavMesh に乗らないのがふつう（見た目に使う点）
    - T01#1 (0.3, 4.88)
    - T01#2 (0.9, 4.88)
    - T01#3 (0.3, 6.18)
    - T01#4 (0.9, 6.18)
    - T02#1 (0.3, 6.73)
    - T02#2 (0.9, 6.73)
    - T02#3 (0.3, 8.03)
    - T02#4 (0.9, 8.03)
    - ほか 152 件
  場所の点 (places): 乗らない 0

## 3. 道順の照合（受け渡し口の前 → 止まる位置。サイトの routes をたどる）
  道順: 受け渡し口から 42 本（うち 0.8 m の道 24 本）/ 卓から卓 861 本
  止まる位置を面へ寄せる半径: 1 m
  すべての区間がつながった卓: 42 / 42
  差（Unity − JSON）の平均: -0.37 m
  差の大きさの平均: 0.37 m / 最大: 0.87 m（T17）
  差が 30% を超えた卓: 0
  via_narrow（use=narrow）の卓: 24 / 42
  つながらない区間: 0
  まっすぐの 1.05 倍を超えた区間: 0
  CSV: /Users/shimazueiichi/Library/Mobile Documents/com~apple~CloudDocs/デスクトップ/ADAPT DESIGN/じぶんの/PixelstoMotors/デジタルツイン学習R2R2R/サービスロボット導入シミュレーション/unity/screens/20261022_p5_crammed_table_dist.csv

## 4. 席に入れない卓（入口から、席に着く点まで歩けるか）
  出発点 (Entrance): (1.8, 0.5)
  確かめた点: 42 / 卓 42
  入れない卓: 0
```

#### 20261022_p5_crammed_table_dist.csv
```csv
table,category,aisle,use,json_dist_m,route_len_m,unity_path_m,diff_m,diff_pct,via_narrow,segments,bad_segments,status
T01,narrow,A01,narrow,14.57,14.18,14.18,-0.39,-2.65,1,5,0,ok
T02,narrow,A01,narrow,16.42,16.04,16.04,-0.38,-2.34,1,5,0,ok
T03,narrow,A01,narrow,18.27,17.88,17.88,-0.39,-2.12,1,6,0,ok
T04,narrow,A01,narrow,20.12,19.73,19.73,-0.39,-1.92,1,5,0,ok
T05,narrow,A01,narrow,14.77,14.38,14.38,-0.39,-2.62,1,6,0,ok
T06,narrow,A01,narrow,16.62,16.23,16.23,-0.39,-2.32,1,5,0,ok
T07,narrow,A01,narrow,18.47,18.08,18.08,-0.39,-2.09,1,5,0,ok
T08,narrow,A01,narrow,20.32,19.93,19.93,-0.39,-1.9,1,6,0,ok
T09,narrow,A02,narrow,11.47,11.08,11.08,-0.39,-3.37,1,5,0,ok
T10,narrow,A02,narrow,13.32,12.93,12.93,-0.39,-2.9,1,5,0,ok
T11,narrow,A02,narrow,15.17,14.78,14.78,-0.39,-2.54,1,5,0,ok
T12,narrow,A02,narrow,17.02,16.63,16.63,-0.39,-2.26,1,5,0,ok
T13,narrow,A02,narrow,11.47,11.08,11.08,-0.39,-3.37,1,5,0,ok
T14,narrow,A02,narrow,13.32,12.93,12.93,-0.39,-2.9,1,5,0,ok
T15,narrow,A02,narrow,15.17,14.78,14.78,-0.39,-2.54,1,5,0,ok
T16,narrow,A02,narrow,17.02,16.63,16.63,-0.39,-2.26,1,5,0,ok
T17,robot,A02,normal,17.05,16.18,16.18,-0.87,-5.12,0,11,0,ok
T18,narrow,A03,narrow,8.17,7.78,7.78,-0.39,-4.74,1,6,0,ok
T19,narrow,A03,narrow,10.02,9.63,9.63,-0.39,-3.86,1,6,0,ok
T20,narrow,A03,narrow,11.87,11.48,11.48,-0.39,-3.25,1,5,0,ok
T21,narrow,A03,narrow,12.59,11.96,11.96,-0.63,-4.99,1,10,0,ok
T22,robot,A03,normal,12,11.43,11.43,-0.57,-4.73,0,7,0,ok
T23,narrow,A03,narrow,8.17,7.78,7.78,-0.39,-4.74,1,6,0,ok
T24,narrow,A03,narrow,10.02,9.63,9.63,-0.39,-3.86,1,6,0,ok
T25,narrow,A03,narrow,11.87,11.48,11.48,-0.39,-3.25,1,5,0,ok
T26,narrow,A03,narrow,12.59,11.96,11.96,-0.63,-4.99,1,10,0,ok
T27,robot,,normal,3.42,3.41,3.41,-0.01,-0.23,0,1,0,ok
T28,robot,,normal,3.78,3.72,3.72,-0.06,-1.72,0,2,0,ok
T29,robot,,normal,5,4.79,4.79,-0.21,-4.27,0,3,0,ok
T30,robot,,normal,6.85,6.43,6.43,-0.42,-6.08,0,3,0,ok
T31,robot,,normal,8.22,7.6,7.6,-0.62,-7.59,0,1,0,ok
T32,robot,,normal,6.37,5.99,5.99,-0.38,-5.91,0,1,0,ok
T33,robot,,normal,4.67,4.58,4.58,-0.09,-1.9,0,1,0,ok
T34,robot,,normal,3.81,3.76,3.76,-0.05,-1.25,0,1,0,ok
T35,robot,,normal,4,3.8,3.8,-0.2,-4.91,0,1,0,ok
T36,robot,,normal,4.62,4.29,4.29,-0.33,-7.12,0,3,0,ok
T37,robot,,normal,5.72,5.37,5.37,-0.35,-6.1,0,4,0,ok
T38,robot,,normal,6.82,6.47,6.47,-0.35,-5.16,0,4,0,ok
T39,robot,,normal,7.92,7.57,7.57,-0.35,-4.44,0,4,0,ok
T40,robot,,normal,9.02,8.67,8.67,-0.35,-3.92,0,4,0,ok
T41,robot,,normal,10.12,9.77,9.77,-0.35,-3.48,0,4,0,ok
T42,robot,,normal,5.43,5.38,5.38,-0.05,-0.93,0,3,0,ok
```

### 試算の値を Unity で読んで確かめた出力（20261022_sim_params_check.txt）
```
# 試算の値の読み込み（v5・回していない）
作った日時: 2026-10-22 19:40:30

## p1_efficient_corner と突き合わせ → 合っている
  店の形の版 (meta.generation): e624bdf4bdd4
サイトの試算の値（serving-robot-params.json）
  ファイル: serving-robot-params
  写した日時: 2026-10-22 19:26:39 JST
  写し元: https://pixelstomotors.com/sim/serving-robot-params.json
  試算の版 (meta.model): v2.4 / 店の形の版 (meta.layout_generation): e624bdf4bdd4
  条件: 6 件
    fam_p1_s4_r0_a42  スタッフ 4 / ロボット 0 / 42 組毎時 / serve / 1往復 1 卓 / 倒れやすい物 any
    fam_p1_s4_r1_a42  スタッフ 4 / ロボット 1 / 42 組毎時 / serve / 1往復 3 卓 / 倒れやすい物 any
    fam_p1_s4_r2_a42  スタッフ 4 / ロボット 2 / 42 組毎時 / serve / 1往復 3 卓 / 倒れやすい物 any
    fam_p5_s4_r0_a42  スタッフ 4 / ロボット 0 / 42 組毎時 / serve / 1往復 1 卓 / 倒れやすい物 any
    fam_p5_s4_r1_a42  スタッフ 4 / ロボット 1 / 42 組毎時 / serve / 1往復 3 卓 / 倒れやすい物 any
    fam_p5_s4_r2_a42  スタッフ 4 / ロボット 2 / 42 組毎時 / serve / 1往復 3 卓 / 倒れやすい物 any
  Unity が読まない値 (unity_measures): robot_speed / pause_rate / pause_s / stuck_rate
  合わないもの: なし

## p5_crammed と突き合わせ → 合っている
  店の形の版 (meta.generation): e624bdf4bdd4
サイトの試算の値（serving-robot-params.json）
  ファイル: serving-robot-params
  写した日時: 2026-10-22 19:26:39 JST
  写し元: https://pixelstomotors.com/sim/serving-robot-params.json
  試算の版 (meta.model): v2.4 / 店の形の版 (meta.layout_generation): e624bdf4bdd4
  条件: 6 件
    fam_p1_s4_r0_a42  スタッフ 4 / ロボット 0 / 42 組毎時 / serve / 1往復 1 卓 / 倒れやすい物 any
    fam_p1_s4_r1_a42  スタッフ 4 / ロボット 1 / 42 組毎時 / serve / 1往復 3 卓 / 倒れやすい物 any
    fam_p1_s4_r2_a42  スタッフ 4 / ロボット 2 / 42 組毎時 / serve / 1往復 3 卓 / 倒れやすい物 any
    fam_p5_s4_r0_a42  スタッフ 4 / ロボット 0 / 42 組毎時 / serve / 1往復 1 卓 / 倒れやすい物 any
    fam_p5_s4_r1_a42  スタッフ 4 / ロボット 1 / 42 組毎時 / serve / 1往復 3 卓 / 倒れやすい物 any
    fam_p5_s4_r2_a42  スタッフ 4 / ロボット 2 / 42 組毎時 / serve / 1往復 3 卓 / 倒れやすい物 any
  Unity が読まない値 (unity_measures): robot_speed / pause_rate / pause_s / stuck_rate
  合わないもの: なし

## Unity が使う値（role=site_rule）
  guide_s = 45
  pay_s = 90
  call_s = 30
  staff_place_s = 10
  robot_handoff_s = 25
  clear_s = 40
  wipe_s = 40
  load_s = 15
  guest_load_s = 20
  rescue_s = 15
  staff_speed = 1
  cold_min = 3
  dish_extra_m = 0
  max_extra_min = 600
  fragile_cook = 2 / 4 / 7

## 店ごとの値（stores）
  fam_p1: 卓 42 / peak_min 120 / 来客（真ん中）42 組毎時 / スタッフ（真ん中）4 人 / eat_min (20.00, 30.00, 45.00) / call_rate 0.6 / bus_trips 2 / fragile 0.5
  fam_p5: 卓 42 / peak_min 120 / 来客（真ん中）42 組毎時 / スタッフ（真ん中）4 人 / eat_min (20.00, 30.00, 45.00) / call_rate 0.6 / bus_trips 2 / fragile 0.5

## 出す数字の名前と意味（meta.metrics）
  entry_wait_mean: 入店待ち：店に来てから案内されて卓に着くまで（分）
  full_wait_mean: そのうち満席で待った時間（分）
  staff_wait_mean: そのうち席は空いているのに片付け・案内が追いつかず待った時間（分）。ng_min 以上なら「店が回らない」
  dish_wait_mean: 料理ができてから卓に届くまで（分）
  dish_wait_p90: 同じく上位10%の境目（分）
  cold_share: cold_min 分を超えて届いた皿の割合
  turn_mean: 客が帰ってから卓が使えるまで（分）
  walk_km_per_staff: スタッフ1人が歩いた距離（km）
  staff_util: スタッフの手がふさがっている割合
  robot_util: ロボットがふさがっている割合
  robot_share: ロボットが運んだ皿の割合
  rescues: 立ち往生でスタッフが動かしに行った回数
  pauses: 一時停止の回数
  groups: 入れた組の数
```


## Claude Code への依頼文

### v4：サイトの店の形（JSON）から店を建てる（claude_code_request_v4_json_store.md）
```markdown
# Claude Code への依頼：サイトの店の形（JSON）から店を建てる（v4・段階1）

設計書：同じフォルダの unity_design_v0.md（「0. 何について正か」を 2026-10-22 に足した）
回している途中にスクリプトを変えない。Assets/ServiceSim/ の中だけで作る。ServiceSim の外は変えない。

## 0. 何をするか

いまの店（StoreLayout_Family の帯の組み立て）は、Unity で形を作っている。これを、サイトの店の形の JSON を読んで建てる形に変える。
最初に建てるのは2つ。③ 効率重視・入口が角（p1_efficient_corner）と、⑦ その列の間の通路を80cmにした店（p5_crammed）。卓と席数は同じで、細い通路の幅だけが違う（1.2m と 0.8m）。

この依頼は「店を建てて、正しく建ったかを確かめる」まで。スタッフの人数・来客・始め方などの回し方は次の依頼（段階2）でそろえる。ここでは回さない。

## 1. 読むデータ

- 正：サイトの `https://pixelstomotors.com/sim/layouts/unity/{名前}.json`（Unity が建てるための細かい形）と `https://pixelstomotors.com/sim/layouts/summary.json`
- 2つを `Assets/ServiceSim/Store/Layouts/` に写して使う（curl で取る）。写した日時と版の番号（`meta.generation`）を、Build Store の報告に必ず出す
- 版の番号が summary.json と違ったら、建てずに止める
- 座標：JSON の x → Unity の X、JSON の y → Unity の Z、高さ → Y。単位は m。原点は店の外壁の内側の左下の角
- 中身の説明は JSON の `meta.rules`（作り方の規則）と、サイトの `tools/sim_layouts/README.md`

## 2. 作るもの

`JsonStoreLayout : StoreLayout`（新しい .asset の種類）。`Compose()` で JSON を StorePlan にする。StoreBuilder・ロボット・人のコードは変えない。

| JSON | StorePlan |
|---|---|
| `floor`（矩形の並び） | `AddFloor` |
| `outer_walls` | Wall の箱（高さ `meta.heights.wall`） |
| `walls`（`height` 付き） | 高さ 1.2m 以下は Divider、それより高いものは Wall |
| `rooms` | PlanPlace（solid）。ラベルで PlaceKind に対応させる：パントリー→Pantry、キッチン・冷蔵・冷凍・保存・洗い場→Kitchen、更衣室・社員トイレ→Office、トイレ→Toilet、待合→Waiting、レジ→Register、レジ内→RegisterBack、ドリンクバー→DrinkBar。レジとドリンクバーは Counter の箱（高さ `meta.heights.counter`） |
| `tables[].parts.top` | Table の箱（高さ `meta.heights.table`） |
| `tables[].parts.benches` | Bench の箱（高さ `meta.heights.bench`）。座面の見た目（BenchSeat）は今の作りどおり |
| `tables[].parts.chairs` | PlanChair（`pullable`・`pull_dir` をそのまま） |
| `tables[].parts.seat_points` | PlanSeat.seatPoints |
| `tables[].spots` | PlanSeat.spots（`side` を名前に） |
| `tables[].stop` | PlanSeat.deliveryPoint（そのまま。ずらさない） |
| `tables[].aisle` | PlanSeat.aisleIndex（`aisles` の並びの番号に直す。null なら -1） |
| `tables[].type` | SeatKind：booth → WallBooth、t2 → WallTable、booth6L → LargeTable |
| `tables[].seats` | PlanSeat.seatCount |
| `aisles` | `AddAisle(id, rect, narrow, entrance)` |
| `places` | `AddPlace(kind, label, rect, …, point)`。kind の名前は PlaceKind と同じにしてある（FoodPass・DishReturn・Pantry・Entrance・Waiting・Register・RegisterQueue・RegisterBack・Toilet・DrinkBar・DrinkBarStand・DrinkBarQueue・RobotStandby・StaffStandby） |
| `doors` | 箱は置かない（壁の切れ目として、もう空いている）。報告に一覧だけ出す |

守ること：
- **Unity で店の形を作らない・直さない**。JSON に無いもの、合わないものが出たら、その場で埋めずに報告に書いて止める（例：家具が NavMesh を切ってしまう、点が NavMesh に乗らない、PlaceKind に無い kind）
- 今の StoreLayout_Family（帯）と StoreLayout_Grid は消さない・変えない。比べる用に残す
- `refuges`（人がロボットをよける逃げ場）は JSON にまだ無い。空のままで動くかを確かめて報告する。動かなければ止めて報告する
- .asset を2つ作る：`StoreLayout_p1_efficient_corner`、`StoreLayout_p5_crammed`

## 3. 確かめること（回さない。Build Store だけ）

③⑦それぞれで、次を store_report と CSV に出す。

1. 卓の数42・席の数160。細い通路3本（③は幅1.2m、⑦は0.8m）
2. 全部の止まる位置（deliveryPoint）・座る位置・席に着く点・場所の点が NavMesh の上に乗るか（乗らないものの一覧）
3. **卓ごとの道のりの照合**：受け渡し口の前（FoodPass の点）から各卓の止まる位置まで、ロボットの NavMesh で測った道のりと、JSON の `dist` を並べた CSV（卓・JSON の距離・Unity の道のり・差・差の割合）。Unity の方が短く出るのは分かっている（サイトは通路の真ん中に50cmの余裕を取った道のり、Unity は半径25cmで家具に寄って走る）。差の平均・最大と、差が30%を超える卓の一覧を出す
4. 席に入れない卓（今の Build Store の確かめ）がないか
5. 上から見た画面を1枚ずつ（Scene ビュー、ラベルあり）

## 4. 終わったら

- 報告を `claude_code_reports/` に置く（今までと同じ形）
- 確かめが通ったら VCS Check-in する
- 次（段階2）の前に、報告を見て相談する
```

### v5：回し方をサイトの試算にそろえる（claude_code_request_v5_align.md）
```markdown
# Claude Code への依頼：回し方をサイトの試算にそろえる（v5・段階2）

設計書：同じフォルダの unity_design_v0.md（0 章「何について正か」）
前の依頼：claude_code_request_v4_json_store.md（段階1。③⑦を JSON から建てた。cs:112）
回している途中にスクリプトを変えない。Assets/ServiceSim/ の中だけで作る。

## 0. 先にやること

1. **作業場所に残っている 10/21 までの変更（22件）を、今回の作業と分けて VCS Check-in する**。コメントは「10/21 までの作業分（あきらめる道・待機場所・字幕など）」。中身は変えない
2. 小さな直し2つ（店や数字には効かない）
   - `CheckAisleWidths` の誤報（細い通路の歩ける幅が 0 m と出る）：SamplePosition の探す半径を navVoxelSize 以上にする
   - store_report.txt の名前に店の名前を入れる（③⑦で上書きされないように）

## 1. 決まったこと（2026-10-22 エイイチ）

- **狭い通路はサイトにそろえる**：サイトの決まりは「ふだんは幅 `normal_width`（1.0m）以上の道を選び、それで届かない卓だけ狭い道を使う」。JSON の細い通路のうち、幅が `meta.settings.normal_width` 未満のもの（⑦の3本・0.8m）を、ロボットにとって通りにくい区画（NavMesh の area コストを大きく）にする。1.0m 以上の道で行ける卓には遠回りで行き、その通路の中にしか止まる位置がない卓だけ入る。③の通路（1.2m）は普通の床のまま
  - 確かめ：⑦の T17・T22 は、段階1では 0.8m の通路を通っていた。そろえたあとは、サイトと同じく 1.0m 以上の道で行くこと（道のりが JSON の dist に近づく）
- **refuges（人がロボットをよける逃げ場）は空のまま**。今の作り（通路のロボットと反対側の出口へ下がる）で進める
- **Unity にしかないもの（組の人数・2人席・ドリンクバー・トイレ・椅子を引く）は残す**。「Unity だけの値」と分かるように、設定と報告に印を付ける

## 2. 読む値（手で写さない）

- 正：`https://pixelstomotors.com/sim/serving-robot-params.json`（試算の条件と店の回し方の値）。`Assets/ServiceSim/` の中に写して使う。写した日時を報告に出す
- `meta.layout_generation` が、店の形の JSON の `meta.generation` と違ったら回さずに止める
- `common` の各値には `role` がある
  - `site_rule`：サイトが正。Unity はこの値を使う（案内45秒・会計90秒・皿を置く10秒・客がロボットから皿を取る25秒・片付け40秒＋40秒・スタッフの歩く速さ 1.0 m/秒 など）
  - `unity_measures`：**読まない**（robot_speed・pause_rate・pause_s・stuck_rate）。Unity で測ってサイトに戻すための仮の値。Unity のロボットの速さ・止まり方は、今の RobotSpec とセンサーのまま
  - `site_only`：使わない（試算の中だけの設定。hop_m など）
- `stores.fam_p1` / `stores.fam_p5`：卓の数・来客のペース（arrivals の真ん中＝普通）・スタッフの人数（staff の真ん中）・料理ができるまで・食事の長さ・呼び出し・倒れやすい物の数など。ServiceSpec と PeopleSpec に手で写してある値は、この JSON から読む形にする（.asset の値は使わない）

## 3. そろえること

| | サイト（正） | いまの Unity → こうする |
|---|---|---|
| 店 | ③ fam_p1・⑦ fam_p5（JSON の店） | 段階1の JsonStoreLayout |
| 条件 | `unity_conditions` の6つ（スタッフ4人・ロボット0/1/2台・1時間42組・上げ膳だけ・1往復で3卓・倒れやすい物もロボットが運ぶ。ロボット0台は使い方・段の数に関係ない） | 条件として選べるようにする。**スタッフの人数とロボット0台を必ず回せるように** |
| 始め方 | 空の店から、`peak_min`（120分）だけ来客し、全員が帰るまで回す（`max_extra_min` で打ち切り） | 8割埋まった店・慣らし15分をやめ、サイトと同じにする |
| 立ち往生 | ロボットが自分で動けなくなったら、スタッフが歩いて行き `rescue_s`（15秒）動かす。ほかの仕事より先 | 受け渡し口へ移す（warp）をやめ、スタッフを呼ぶ形にする。「動けなくなった」の判定は今の stuckAfterStoppedSec のまま |
| 逃がし弁 | なし | あきらめた・移した・卓を空けた、の回数を記録する（0回に近いことを確かめる。消さない） |
| 出す数字 | `meta.metrics` の名前と定義 | 同じ名前・同じ定義で出す。いまの出力と定義が違うもの（例：「客が帰ってから卓が使えるまで」）は、どこが違うかを報告に書く |
| Unity で測る値 | ― | 設計書2章の4つ（実際に走れる速さ・止まっていた長さの分布・動けなくなった割合・止まった割合）を往復ごとに出す。⑦は狭い通路の中と外を分けて出す |

## 4. 確かめること（長く回さない）

1. ③のロボット2台の条件を、乱数の種1つで1回だけ回す（倍速は今までどおり）
2. 出た数字を、`unity_conditions` の同じ条件の `site_result` と並べた表にする（入店待ち・その内訳・料理が届くまで・スタッフの歩いた距離・手がふさがっている割合・ロボットが運んだ割合）。合わなくてよい。どのくらい違うかを見るため
3. 1回にかかった時間（実時間）と、全員が帰るまでのシミュレーション上の時間
4. 逃がし弁の回数・立ち往生でスタッフが呼ばれた回数
5. ⑦で、T17・T22 への道のりが狭い通路を避けているか（道のりと、通った区画）

6条件を何回ずつ回すかは、1回にかかる時間を見てから相談する。

## 5. 出力と記録

- 出力は今までどおり KIOXIA の `r2r2r-data/service_sim/unity/` の下（日付と店の名前と条件が分かるフォルダ名）
- 報告は `claude_code_reports/` に置く
- 確かめが通ったら VCS Check-in する（0 の 22件とは別に）
- 店の形（JSON）が足りない・合わないときは、Unity で埋めずに報告に書いて止める
```

### v5b：ロボットの道順をサイトから受け取る（claude_code_request_v5b_routes.md）
```markdown
# Claude Code への依頼：ロボットの道順をサイトから受け取る（v5b）

前の依頼：claude_code_request_v5_align.md（段階2）。報告 20261022_2010_narrow_aisle_fix.txt のあと。
回している途中にスクリプトを変えない。Assets/ServiceSim/ の中だけで作る。

## 0. 何があったか・どうするか（2026-10-22 エイイチと決めた）

⑦の確かめで、コストを使わず全区画で探しても 11 卓がつながらなかった（all_status が「途中まで」）。原因はコストではなく、**狭い通路の区画を NavMesh に書き込んだこと**（段階1の区画なしでは 42 卓すべてつながっていた）。止まる位置を 0.29〜0.69 m 寄せて「着いた」ことにした卓も 8 卓あった。

そこで、**Unity で道を決めるのをやめ、ロボットの道順はサイトが決めて渡す**。サイトの試算は卓ごとの道（ふだん走らせる 1.0 m の道、届かなければ 0.8 m の道）をもう持っているので、それを曲がり角の点の並びにして JSON に入れた。Unity のロボットはその点を順にたどる。人をよける・止まる・立ち往生は今までどおり Unity の中で起きる（それを測るのが Unity の役目）。

## 1. 読むもの

- `https://pixelstomotors.com/sim/layouts/unity/{名前}.json` と `summary.json`、`https://pixelstomotors.com/sim/serving-robot-params.json` を写し直す。版の番号は **9628954a582f**（3つともそろっていること）
- 新しく `routes` がある（作り方は JSON の `meta.rules`）
  - `routes.from_pass["T05"]`：受け渡し口の前 → T05 の止まる位置。`points`（曲がり角の点。最初が受け渡し口の前、最後が止まる位置そのもの）、`use`（normal＝1.0 m の道 / narrow＝0.8 m の道）、`length`
  - `routes.between["T05|T12"]`：T05 の止まる位置 → T12 の止まる位置。逆向き（T12 → T05）は点を逆にたどる
  - 卓 → 受け渡し口は `from_pass` を逆にたどる
  - ③⑦とも、受け渡し口から 42 本・卓から卓 861 本。つながらない道は無い。use はサイトの卓の分け方（category）と全卓で一致（サイトのテストで確かめた）

## 2. 直すこと

1. **v5 で足した狭い通路の区画（NavMeshBaker の AddNarrowAisleAreas・ModifierBox・avoidNarrowAisles・2段の道探し）を外す**。NavMesh は段階1と同じにする（レジの内側の区画はそのまま）
2. ロボットの走り方：行き先の卓が決まったら、`routes` の点を1つずつ行き先にしてたどる（受け渡し口 → 1卓目 → 2卓目 → 3卓目 → 受け渡し口）。最後の点は止まる位置そのもので、寄せない（寄せる半径は 0.1 m 以下）
   - 点と点の間は、サイト側でまっすぐ通れることを確かめてある（0.8〜1.0 m の幅の道の真ん中）。NavMesh の道探しは、次の点までの短い区間だけに使う
   - 人にふさがれたときの止まる・待つ・よける・立ち往生は今のまま。道を引き直すとき（replanAfterStoppedSec）は、**次の点までの区間だけ**引き直す。全体の道順は変えない
   - 待機場所 ↔ 受け渡し口の前は、今までどおり Unity の道でよい（短いため）
3. 確かめ（JSON Store のメニュー）も同じにする
   - 卓ごとに、道順の長さ（点を結んだ長さ）・JSON の dist・Unity で点から点を NavMesh でつないだ長さ・`use` を CSV に出す
   - 点から点の区間が NavMesh で完全につながるか（つながらない区間の一覧）。区間の NavMesh の道のりが、まっすぐの長さの 1.05 倍を超える区間の一覧（店の形と NavMesh が合っていない印）
   - via_narrow は `use == "narrow"` の卓（⑦で 24 卓・③で 0 卓になるはず）
4. v5 の 3章（条件・始め方・立ち往生でスタッフを呼ぶ・逃がし弁の記録・出す数字・往復ごとの4つの値）は、このあとそのまま続ける

## 3. お願い（Unity で押してもらう）

③⑦ を続けて確かめる。見るのは、42 卓すべての区間がつながること、1.05 倍を超える区間がないこと、via_narrow が⑦ 24 卓・③ 0 卓。
通ったら VCS Check-in して、v5 の 3章に進む。
```


## Claude Code の報告（claude_code_reports/）

### 20261022_1830_json_store_v4.txt
```
v4（段階1）サイトの店の形の JSON から店を建てる — コードを書き終えました。Unity は押していません。

依頼：unity/claude_code_request_v4_json_store.md
設計：unity/unity_design_v0.md の 0 章「何について正か」（店の形はサイトが正。Unity は形を作らない）

────────────────────────────────────────────────────────
1. 作ったもの（Assets/ServiceSim/ の中だけ。外は触っていません）
────────────────────────────────────────────────────────

新規
  Store/Layouts/p1_efficient_corner.json      サイトから写したもの（43,889 バイト）
  Store/Layouts/p5_crammed.json               サイトから写したもの（43,877 バイト）
  Store/Layouts/summary.json                  一覧（3,042 バイト）
  Store/SimpleJson.cs                         小さな JSON 読み取り（読むだけ。書き出さない）
  Store/JsonStoreLayout.cs                    JSON → StorePlan（Compose）
  Store/StoreLayout_p1_efficient_corner.asset ③ 細い通路 1.2 m
  Store/StoreLayout_p5_crammed.asset          ⑦ 細い通路 0.8 m
  Editor/ServiceSimJsonStore.cs               確かめのメニュー

変えていないもの
  StoreBuilder.cs / StorePlan.cs / StoreLayout.cs / NavMeshBaker.cs
  ロボット・人・実験のコード
  StoreLayout_Family.asset / StoreLayout_Grid.asset（比べる用にそのまま）
  ServiceSimMenu.cs（Build Store はそのまま呼ぶだけ）

写した日時：2026-10-22 18:18:54 JST
写し元：https://pixelstomotors.com/sim/layouts/unity/{名前}.json
       https://pixelstomotors.com/sim/layouts/summary.json
版の番号（meta.generation）：b700048057af（③⑦と summary.json の3つとも同じ）
※ 日時・写し元・版の番号は .asset に持たせてあり、Build Store の報告に出ます。
※ 版の番号が summary.json と違ったら、建てずに止めます（Compose が null を返します）。

JsonStoreLayout が Unity で勝手に作らないもの（依頼の「Unity で店の形を作らない」）
  ・止まる位置は JSON の stop をそのまま使います（deliveryOffsetFromAisleCenter は使いません）
  ・細い通路に入る前に待つ点は JSON の entrance（aisleEntranceSetback は使いません）
  ・JSON に無いもの・対応表に無いものが出たら、埋めずに **建てずに止めて** 報告に並べます

────────────────────────────────────────────────────────
2. 対応づけ（依頼の表のとおり）
────────────────────────────────────────────────────────

floor                → AddFloor
outer_walls          → Wall の箱（高さ meta.heights.wall = 2.7）
walls（height 付き）  → 高さ meta.heights.partition（1.2）以下は Divider、それより高いものは Wall
rooms                → PlanPlace（solid）。レジ・ドリンクバーは高さ meta.heights.counter（1.0）、
                       ほかは壁の高さ。ラベル→PlaceKind は依頼の表どおり
                       （冷蔵・冷凍・保存／洗い場 → Kitchen、更衣室・社員トイレ → Office）
tables[].parts.top   → Table の箱（0.72）
tables[].parts.benches → Bench の箱（1.1）＋ 座面の見た目 BenchSeat（当たりなし・0.45 の上）
tables[].parts.chairs  → PlanChair（pullable・pull_dir をそのまま）
tables[].parts.seat_points → PlanSeat.seatPoints
tables[].spots       → PlanSeat.spots（side を名前に）
tables[].stop        → PlanSeat.deliveryPoint（そのまま）
tables[].aisle       → PlanSeat.aisleIndex（aisles の並び順。null は -1）
tables[].type        → booth→WallBooth / t2→WallTable / booth6L→LargeTable
tables[].seats       → PlanSeat.seatCount
aisles               → AddAisle(id, rect, narrow, entrance)
places               → AddPlace（点だけ。箱は置かない）
doors                → 箱を置かず、報告に一覧だけ（③⑦とも 0 件でした）

場所の点（places）を部屋（rooms）より先に入れています。StorePlan.PointOf は
最初に見つけた点を返すので、レジなどの「立つ点」が部屋の真ん中に上書きされないためです。

────────────────────────────────────────────────────────
3. Unity を押す前に確かめたこと（JSON の読み取りと対応づけを Python で写して照合）
────────────────────────────────────────────────────────

                         ③ p1_efficient_corner    ⑦ p5_crammed
  版の番号 vs summary     一致                      一致
  卓                      42（summary 42）          42（summary 42）
  席                      160（summary 160）        160（summary 160）
  細い通路                3 本・幅 1.2 m            3 本・幅 0.8 m
  壁 / 仕切り             9 / 0                     9 / 0
  部屋 / 場所の点         10 / 26                   10 / 26
  椅子 / 席に着く点       8 / 59                    8 / 59
  座る位置                160                       160
  卓の type               booth 33・t2 8・booth6L 1 （③と同じ）
  対応表に無いもの        0 件                      0 件

依頼の確かめ 1（卓 42・席 160・細い通路 3 本／③ 1.2 m・⑦ 0.8 m）は、JSON の中身としては
そろっています。Unity で建てた数は Build Store の確かめで突き合わせます。

点が通れない箱の中に入っていないか（NavMesh の確かめで何が出るかの下見）
  止まる位置（42）        ③⑦とも 0 件。家具まで 0.25 m 未満のものも 0 件
  席に着く点（59）        ③⑦とも 0 件（箱の中には入っていない）
  座る位置（160）         160 件すべてベンチ（152）・椅子（8）の上
                          → 人が座る位置なので当然で、NavMesh には乗りません
  場所の点（26）          1 件（下の「相談したいこと」）

────────────────────────────────────────────────────────
4. 相談したいこと（JSON と合わないもの。こちらで埋めていません）
────────────────────────────────────────────────────────

(A) レジ内（RegisterBack）の点が、通れない箱の中に入ります　← 要判断

  依頼の規則は「rooms → PlanPlace（solid）」です。そのとおりに建てると、
  部屋「レジ内」rect [3.3, 1.3, 6.1, 2.2] が通れない箱になります。
  ところが places の RegisterBack の点 [4.7, 1.75] は、その箱の **中** にあります。
  会計のスタッフはここに立つので、このままだと立てません
  （NavMesh にも乗らないので、依頼の確かめ 2 で × として出ます）。

  直し方は2つあって、どちらもサイト側（店の形が正）の判断だと思います。
    1. JSON の rooms に「通れる部屋」の印を足す（レジ内・待合・トイレのような、
       人が入る部屋を solid にしない）
    2. レジ内の点を、部屋の外（太い通路の側）に出す

  v0 の帯の店（BandStoreLayout）では、レジ内は「通れる区画」（solid=false）にして、
  スタッフがそこに立つ作りでした。①の形が近いと思いますが、決めるのはサイト側です。

(B) 壁ぞいの2人席（t2）の横からの spot が、隣の卓の天板まで 0.05 m しかありません

  ③⑦とも 14 点（T34〜T41 の left / right）。人の半径 0.2 m では NavMesh に乗らない見込みで、
  依頼の確かめ 2 に「乗らない点」として出ます。
  ただし同じ卓の "top" の spot は余裕 0.32 m あるので、**席に入れない卓は 0 件**の見込みです
  （42 卓すべてに、余裕 0.2 m 以上の spot が1つ以上あります）。
  このまま進めてよいか（横からの点は使わない扱いにするか）だけ決めてください。

(C) refuges（人がロボットをよける逃げ場）は JSON にまだ無いので空のままです

  空でも動きます。PersonRobotYield.FindRefuge は、よけ場所が1つも無ければ
  「その通路の、ロボットと反対側の出口」へ下がる作りになっています（コードで確認）。
  段階2で人を動かすときに、よけ方が変わる（通路の外へ出ずに出口まで下がる）ので、
  JSON に足すかどうかは段階2の前に決めたいです。

(D) ⑦ では、サイトの解析が 24 卓を category="narrow"（ロボットが行けない）としています

  ⑦の robot_tables は 18・robot_seats は 64（summary.json）。
  依頼の確かめ 3 の CSV には category の列を入れたので、
  narrow の卓で Unity の道のりがどう出るか（行けるのか）も見られます。

────────────────────────────────────────────────────────
5. Unity でしてほしいこと（私は押していません）
────────────────────────────────────────────────────────

メニューを足しました。どちらも「建てて確かめる」だけで、回しません。

  ServiceSim/JSON Store/③ p1_efficient_corner を建てて確かめる
  ServiceSim/JSON Store/⑦ p5_crammed を建てて確かめる
  ServiceSim/JSON Store/③⑦ を続けて確かめる

中でしていること
  1. StoreBuilder と NavMeshBaker の店の形を、その .asset に差し替える
  2. そのまま ServiceSim/Build Store を呼ぶ（組み立て・ベイク・今までの確かめ・上からの撮影）
  3. そのあとに、JSON と突き合わせる確かめを足して書き出す
       {日付}_{名前}_json_check.txt   数／点が NavMesh に乗るか／道のりの照合／席に入れない卓
       {日付}_{名前}_table_dist.csv   table, category, aisle, json_dist_m, unity_path_m,
                                      diff_m, diff_pct, status
     差の平均・大きさの平均・最大と、差が 30% を超えた卓の一覧も報告に出ます。
  書き出し先は ExperimentPlan.screenshotDir（Build Store と同じ場所）です。

お願い
  ・依頼の「Claude Code が書いたコードは、実行する前にエイイチが読む」に従って、
    JsonStoreLayout.cs（283 行）と ServiceSimJsonStore.cs（約 330 行）を先に読んでください
  ・Unity を開くと、まず C# のコンパイルが通るかが分かります（こちらでは Unity を開いていないので、
    文法の確認は目視までです）
  ・(A) のレジ内は、建てると必ず「立てない」側に出ます。先に決めていただくか、
    いったんこのまま建てて確かめの出方を見るか、どちらでもできます

VCS Check-in はまだしていません（依頼の「確かめが通ったら Check-in」のため）。
Build Store の結果を見てから、こちらで入れるか、エイイチが入れるかを決めてください。
```

### 20261022_1900_json_store_v4_regen.txt
```
v4（段階1）版 e624bdf4bdd4 を写し直して、walkable / staff_only を入れました。
建てるところで止まっています（Unity Editor が開いたままで、こちらから動かせません）。

────────────────────────────────────────────────────────
1. 写し直し（依頼 1）
────────────────────────────────────────────────────────

写した日時：2026-10-22 18:51:26 JST
版の番号：e624bdf4bdd4（③⑦と summary.json の3つとも同じ。前は b700048057af）

  Store/Layouts/p1_efficient_corner.json   43,889 → 43,270 バイト
  Store/Layouts/p5_crammed.json            43,877 → 43,258 バイト
  Store/Layouts/summary.json               3,042 バイト（変わらず）

.asset の fetchedAt を「2026-10-22 18:51:26 JST（版 e624bdf4bdd4）」に直しました。
版の番号は JSON の meta.generation から読むので、.asset には持たせていません
（写した日時と写し元だけ）。.meta の GUID は変えていないので、参照は切れていません。

────────────────────────────────────────────────────────
2. walkable / staff_only（依頼 2）
────────────────────────────────────────────────────────

JsonStoreLayout.cs の rooms の所に足しました。

  walkable == true   → 通れない箱にしない。場所の点だけ置く
                       （同じ種類の点が places にあれば、そちらが区画の広がりも持っているので足さない）
  staff_only == true → スタッフだけが入れる区画にする

staff_only の作り方は v0 の帯の店と同じで、新しく作っていません。
NavMeshBaker.AddRegisterBackArea() が store.RegisterBackRect（= PlaceKind.RegisterBack の rect）を
見て、その箱の中の NavMesh の区画の番号を registerBackArea（PeopleSpec では 3）に変えます。
客の NavMeshAgent は PeopleNav が `NavMesh.AllAreas & ~(1 << registerBackArea)` で
その区画を外して歩くので、客は入れず、スタッフだけが入れます。

  ③⑦の JSON では、レジ内 rect [3.3, 1.3, 6.1, 2.2] が walkable かつ staff_only。
  places の RegisterBack の点 [4.7, 1.75] と rect が同じものを指しているので、
  区画の指定はそのまま働きます。

今の作りでスタッフ専用にできるのは「レジの内側」だけです。ほかの部屋に staff_only が付いたら、
こちらで作らずに **建てずに止めて** 報告に出します（NavMeshBaker を変えないため）。

────────────────────────────────────────────────────────
3. Unity を押す前に確かめたこと（JSON の読み取りと対応づけを Python に写して照合）
────────────────────────────────────────────────────────

                              ③ p1_efficient_corner   ⑦ p5_crammed
  版の番号 vs summary.json     一致 e624bdf4bdd4        一致 e624bdf4bdd4
  卓                           42（summary 42）         42（summary 42）
  席                           160（summary 160）       160（summary 160）
  細い通路                     3 本・1.2 m              3 本・0.8 m
  .asset の幅と合っているか    合っている               合っている
  席に着く点                   45（前 59）              45（前 59）
  spot が 1 つも無い卓         0                        0
  通れる部屋                   レジ内（staff_only）     レジ内（staff_only）
  対応表に無いもの             0 件                     0 件

点と家具のすき間（NavMesh の確かめで何が出るかの下見）

                              ③                       ⑦
  止まる位置が箱の中            0 / 42                   0 / 42
  止まる位置の余裕 0.25 m 未満  0                        0
  席に着く点が箱の中            0 / 45                   0 / 45
  席に着く点の余裕 0.2 m 未満   0（前は 14）             0（前は 14）
  場所の点が箱の中              0 / 26（前は 1）         0 / 26（前は 1）
  座る位置が箱の中              160 / 160                160 / 160

  (A) レジ内の点が箱の中に入る件 … 直りました（通れる部屋になったため）
  (B) 席に着く点の 0.05 m … 直りました（45 点すべて 0.2 m 以上）
  座る位置の 160 件は、ベンチ（152）と椅子（8）の上なので、NavMesh に乗らないのがふつうです
  （人が座る位置で、歩く点ではありません）。確かめ 2 の一覧にはこの 160 件が出ます。

(C) refuges は空のままにしました（段階2の前に決める）。
(D) 卓ごとの道のりの照合では、CSV に category の列を入れてあります
    （⑦ は 24 卓が narrow＝サイトの解析ではロボットが行けない卓）。

────────────────────────────────────────────────────────
4. 建てるところで止まっています　← お願い
────────────────────────────────────────────────────────

Unity Editor が、このプロジェクトを開いたままです（PID 50780 / 1日20時間ほど起動中）。
同じプロジェクトを2つの Unity で開くことはできないので、こちらから
`-batchmode -executeMethod` で動かせません。
ロックを無視して開くと Library が壊れることがあるので、開いていません。

どちらかでお願いします。

  (ア) エイイチが Unity で押す（いつもの形）
       ServiceSim/JSON Store/③⑦ を続けて確かめる
       → Console の [ServiceSim] と、screens/ に出る
          {日付}_{名前}_json_check.txt と {日付}_{名前}_table_dist.csv を送ってください。
          こちらで読んで、次の報告にまとめます

  (イ) Unity を閉じてもらえれば、こちらで batch mode で回します
       その場合、こちらで動かすのは
         ServiceSim.EditorTools.ServiceSimJsonStore.CheckBoth
       だけです（店を建てて確かめるところまで。人もロボットも回しません）

どちらでも、先に JsonStoreLayout.cs の rooms の所（214〜258 行）だけ目を通してください。
今回変えたのはそこと、報告の行の 2 行だけです。

────────────────────────────────────────────────────────
5. いまのファイル
────────────────────────────────────────────────────────

変えたもの
  Store/Layouts/p1_efficient_corner.json      写し直し（e624bdf4bdd4）
  Store/Layouts/p5_crammed.json               写し直し（e624bdf4bdd4）
  Store/Layouts/summary.json                  写し直し
  Store/JsonStoreLayout.cs                    walkable / staff_only を足した
  Store/StoreLayout_p1_efficient_corner.asset fetchedAt
  Store/StoreLayout_p5_crammed.asset          fetchedAt

変えていないもの
  Store/SimpleJson.cs / Editor/ServiceSimJsonStore.cs（前回のまま）
  StoreBuilder.cs / StorePlan.cs / StoreLayout.cs / NavMeshBaker.cs
  ロボット・人・実験のコード / ServiceSimMenu.cs
  StoreLayout_Family.asset / StoreLayout_Grid.asset

VCS Check-in はまだしていません（確かめが通ってから）。
```

### 20261022_1920_json_store_v4_result.txt
```
v4（段階1）③⑦の結果を読みました。確かめはすべて通っています。VCS Check-in しました（cs:112）。

読んだもの（screens/）
  20261022_p1_efficient_corner_json_check.txt / _table_dist.csv
  20261022_p5_crammed_json_check.txt / _table_dist.csv
  20261022_family_v0_store_report.txt（③のもの。下の「気づいたこと 2」）
  20261022_family_v0_top_w120_people.png（③）/ _top_w080.png（⑦）

────────────────────────────────────────────────────────
1. 依頼の確かめ（③⑦とも通りました）
────────────────────────────────────────────────────────

                                    ③ p1（1.2 m）      ⑦ p5（0.8 m）
 1 卓                                42（JSON 42 / summary 42）  42（同）
   席                                160（同）           160（同）
   細い通路                          3 本 A01-A03 1.2 m  3 本 A01-A03 0.8 m
   組み立てた幅 plan.aisleWidth       1.2 m               0.8 m
   外寸                              24 × 16 m（JSON と同じ）    同
 2 止まる位置が NavMesh の上          42 / 42            42 / 42
   席に着く点が NavMesh の上          45 / 45            45 / 45
   場所の点が NavMesh の上            26 / 26            26 / 26
   座る位置                          160 が乗らない      160 が乗らない（下記）
 3 道のりを測れた卓                   42 / 42            42 / 42
   差（Unity − JSON）の平均           -0.71 m            -0.67 m
   差の大きさの平均 / 最大            0.71 m / 1.86 m    0.67 m / 1.86 m
   差が 30% を超えた卓                0                   0
 4 席に入れない卓                     0                   0
 5 上から見た画面                     top_w120_people.png top_w080.png

 版の番号 e624bdf4bdd4（③⑦と summary.json の3つとも一致）
 写した日時 2026-10-22 18:51:26 JST（.asset に入っています）
 合わないもの: なし（③⑦とも「合わないもの: なし」）

座る位置の 160 件は、ベンチ（152）と椅子（8）の座面の上の点で、歩く点ではありません。
NavMesh に乗らないのが正しい状態です（座っている客の形を出す場所に使います）。

Build Store 側の確かめも通っています（③の報告より）
  ロボットの経路 42 本・点 189 個が、ロボット用の面だけを通っている（外れ 0）
  レジの内側: スタッフが立つ点 (4.7, 1.75) は人用・ロボット用とも面の上。
             受け渡し口から 11.44 m / 11.45 m で行ける
  通れない場所（厨房・パントリー・トイレ・待合・レジ・ドリンクバーなど 9 か所）に NavMesh なし
  行き止まり: ロボット用・人用とも、細い通路 3 本をどれか1本ふさいでも全卓に届く
  焼いた面: ロボット用 136.87 m2 / 人用 147.8 m2。つながっていない面 0
  area の内訳: area0 135.05 m2 + **area3 1.82 m2**（ロボット用）、
               area0 145.84 m2 + **area3 1.96 m2**（人用）
               → staff_only（レジ内 2.8 × 0.9 m）が、別の区画として焼けています。
                 客の NavMeshAgent は PeopleNav がこの区画を外すので入れません

────────────────────────────────────────────────────────
2. 道のりの照合（依頼 3）を読んだところ
────────────────────────────────────────────────────────

③⑦とも、**42 卓すべてで Unity の方が短い**（Unity が長い卓は 0）。
これは分かっていたこと（サイトは通路の真ん中に 50 cm の余裕を取った道のり、
Unity は半径 25 cm で家具に寄って走る）と、向きが合っています。

  ③: 差の割合の平均 -7.0%。いちばん差が大きいのは T41（JSON 10.09 m → Unity 8.23 m・-18.4%）
  ⑦: 差の割合の平均 -5.3%（narrow 24 卓）/ -7.9%（robot 18 卓）

⑦ で category が narrow（サイトの解析では「ロボットが行けない」）の 24 卓について:
  Unity では **24 卓すべてに道のりが出ました**（測れなかった卓 0・行き止まりなし）。
  床の塗り分けの画（top_w080.png）でも、0.8 m の通路がロボットの入れる床（水色）で塗られています。
  理由は、サイトは「通路の真ん中に 50 cm の余裕」で通れるかを見ているのに対し、
  Unity は半径 25 cm（直径 50 cm）の円が通れるかで焼いているためだと思います。
  0.8 m の通路には、計算上 0.3 m の余裕が残ります。
  → ここは「サイトとUnityで答えが違う」ところなので、段階2で測るときに、
    ⑦の narrow 24 卓をロボットに行かせるかどうかを決めてから回したいです。
    （行かせるなら、実機の BellaBot の通過幅 70〜80 cm と突き合わせる話になります）

③→⑦ で Unity の道のりが変わった卓は 30 / 42（変わらない 12）。いちばん変わったのは
T27 +1.20 m・T28 +1.10 m・T19/T20/T24/T25 +1.06 m。通路が 1.2 m → 0.8 m になって
卓の位置が動いたぶんです。

────────────────────────────────────────────────────────
3. 気づいたこと（直していません。次の相談）
────────────────────────────────────────────────────────

(1) 「細い通路の、焼いた NavMesh の歩ける幅 0 m」は **前からある見かけの誤報** です

  ③⑦の store_report に、こう出ます。
    A01: 通路 1.2 m / 計算上 0.7 m / 焼いた面のいちばん狭いところ 0 m ← 0.7 m 足りない
    レジの内側: 歩ける幅 東西 0 m / 南北 0 m / 入口 0 m ← 入口が 0.7 m 未満

  これは JSON の店のせいではありません。**2026-10-21 の store_report（帯の店
  StoreLayout_Family）にも、同じ 0 m が全 10 本と レジの内側に出ています**。

  中身を見ると、CheckAisleWidths は y=0 の高さで、探す半径 0.02 m で
  NavMesh.SamplePosition を打っています。いっぽう焼けた面はいちばん高い所で y=0.03 m です。
  半径 0.02 m では 0.03 m 上の面に届かないので、どこを打っても「乗っていない」になり、
  幅が 0 m と出ます。

  面そのものは正しく焼けています（上の 136.87 m2・42/42 の経路・行き止まりなし・
  塗り分けの画）。なので「直す必要あり」は見かけだけです。
  直すなら、探す半径を navVoxelSize（0.05）以上にするか、y を少し上げて打つかの
  1 行です。ServiceSimMenu.cs（既存のコード）なので、今回は触っていません。

(2) store_report.txt が ③ と ⑦ で同じ名前になり、あとから回した方で上書きされます

  名前が run_name（family_v0）で決まるためです。③⑦を続けて回すと、
  20261022_family_v0_store_report.txt は最後に回した方だけが残ります
  （今回は 19:12 の ③ が残り、19:10 の ⑦ は消えました。消える前に読んだので、
   上の表の ⑦ の数字は控えてあります）。
  json_check.txt と table_dist.csv は店の名前が入るので、両方残っています。
  直すなら、JSON Store のメニューから呼ぶときだけ run_name に店の名前を足す形にします。
  （ExperimentPlan.runName を書き換えることになるので、先に相談したいです）

(3) refuges は空のまま進めています（段階2の前に決める）

────────────────────────────────────────────────────────
4. VCS Check-in
────────────────────────────────────────────────────────

cs:112@br:/main（2026-10-22 19:15:55）。私が作った 18 項目だけを個別指定で入れました。

  Assets/ServiceSim/Store/JsonStoreLayout.cs（+ .meta）
  Assets/ServiceSim/Store/SimpleJson.cs（+ .meta）
  Assets/ServiceSim/Store/StoreLayout_p1_efficient_corner.asset（+ .meta）
  Assets/ServiceSim/Store/StoreLayout_p5_crammed.asset（+ .meta）
  Assets/ServiceSim/Store/Layouts（+ .meta）
  Assets/ServiceSim/Store/Layouts/p1_efficient_corner.json（+ .meta）
  Assets/ServiceSim/Store/Layouts/p5_crammed.json（+ .meta）
  Assets/ServiceSim/Store/Layouts/summary.json（+ .meta）
  Assets/ServiceSim/Editor/ServiceSimJsonStore.cs（+ .meta）

ワークスペースには、これとは別に 22 件の変更（StoreBuilder.cs・NavMeshBaker.cs・
BandStoreLayout.cs・People/ など、10/21 までの作業分）が入ったままです。
私の作業ではないので、触っていません。入れるかどうかはエイイチの判断でお願いします。

────────────────────────────────────────────────────────
5. 次（段階2の前に決めたいこと）
────────────────────────────────────────────────────────

  a. ⑦の narrow 24 卓に、ロボットを行かせるかどうか（上の 2）
  b. refuges を JSON に足すかどうか
  c. 幅の誤報（上の 3-(1)）を直すかどうか。直すなら ServiceSimMenu.cs の 1 行
  d. store_report の名前（上の 3-(2)）

段階2（スタッフの人数・来客・始め方）の依頼をいただければ、そのまま進めます。
```

### 20261022_1935_align_v5_step0_1_2.txt
```
v5（段階2）0 章と、1 章の狭い通路・2 章の値の読み込みまで入れました。
3 章（回し方そのもの）はこれからです。Unity は押していません（Editor が開いたままのため）。

────────────────────────────────────────────────────────
0. 先にやること（両方おわり）
────────────────────────────────────────────────────────

(1) 10/21 までの変更を、今回の作業と分けて Check-in しました

  cs:113@br:/main  2026/10/22 19:25:30
  コメント「10/21 までの作業分（あきらめる道・待機場所・字幕など）」
  21 ファイル。すべて変更（C）で、中身は 1 文字も変えていません。

    C Assets/ServiceSim/Editor/ServiceSimMenu.cs
    C Assets/ServiceSim/Editor/ServiceSimRender.cs
    C Assets/ServiceSim/Editor/ServiceSimSetup.cs
    C Assets/ServiceSim/Experiment/ExperimentPlan.cs
    C Assets/ServiceSim/Experiment/TripLogger.cs
    C Assets/ServiceSim/People/GuestAgent.cs
    C Assets/ServiceSim/People/PeopleNav.cs
    C Assets/ServiceSim/People/PeopleSpawner.cs
    C Assets/ServiceSim/People/PeopleSpec.cs
    C Assets/ServiceSim/People/PeopleTargets.cs
    C Assets/ServiceSim/People/PersonBody.cs
    C Assets/ServiceSim/People/SeatedGuest.cs
    C Assets/ServiceSim/People/StaffAgent.cs
    C Assets/ServiceSim/Robot/FrontSensor.cs
    C Assets/ServiceSim/Robot/RobotSpec.cs
    C Assets/ServiceSim/Robot/ServingRobotController.cs
    C Assets/ServiceSim/Store/BandStoreLayout.cs
    C Assets/ServiceSim/Store/NavMeshBaker.cs
    C Assets/ServiceSim/Store/StoreBuilder.cs
    C Assets/ServiceSim/Store/StoreLayout.cs
    C Assets/ServiceSim/Store/StorePlan.cs

  ※ ServiceSimMenu.cs・NavMeshBaker.cs・StoreBuilder.cs・StorePlan.cs・RobotSpec.cs・
    ServingRobotController.cs は、この Check-in の **あと** に今回の v5 の直しを入れています。
    cs:113 に入っているのは 10/21 までの状態です。

(1b) .git は Check-in していません（ご指示のとおり）

  cs:112（段階1）と cs:113（10/21 分）に `.git` で始まる項目は **0 件** です（cm log で確認）。
  いま Pending に残っている .git は次の 7 件です。

    Changed  .git/index                      ← 過去に管理下に入ってしまっている（Plastic が持っている）
    Private  .git/objects/… 6 件             ← 管理外（入れていない）

  直すなら 2 段構えになります。**どちらもまだ実行していません。**

    ア) ignore.conf に 1 行足す（Private の 6 件が Pending から消える）
        ワークスペース直下の ignore.conf に `.git` を足すだけです。
        いまの ignore.conf は Library / Temp / .vs / .vscode / .idea …
        という書き方なので、同じ並びに入ります。

          .git

    イ) .git/index は管理下（Changed）なので、ignore.conf だけでは消えません
        リポジトリからだけ外す必要があります。ディスクの .git/index は残す形です。

          cm remove --nodisk .git/index
          cm checkin .git/index -c "git の内部ファイルを Plastic の管理から外す"

        --nodisk は「バージョン管理から外し、ディスクには残す」オプションです
        （cm remove controlled --help で確認）。ローカルの git は壊れません。
        Plastic が持っていた古いコピーが消えるだけです。

    ウ) 何もしない場合は、Pending に .git/index が 1 件ずっと残ります（害はありません）。

  ア・イを進めてよければ、言っていただければ入れます（相談してからにします）。

(2) 小さな直し 2 つ

  a. 細い通路の歩ける幅が 0 m と出る誤報（ServiceSimMenu.CheckAisleWidths）
     SamplePosition の探す半径を 0.02 m → navVoxelSize（0.05 m）以上にしました。
     焼けた面は床より少し上（この店では y=0.03 m）に出るので、0.02 m では届かず、
     どこを打っても「乗っていない」になっていました。
     横のずれの判定（刻み × 0.8）はそのままなので、測る細かさは変わりません。

     **同じ誤報がレジの内側の幅（WalkRun / CheckRegisterBack）にも出ていた**ので、
     そちらも同じ 1 行で直しました（「入口が 0.7 m 未満。registerBackDepthZ を広げる」
     も同じ原因の誤報でした）。依頼には CheckAisleWidths だけと書かれていましたが、
     原因も直し方も同じなので、あわせて直しています。要らなければ戻します。

  b. store_report.txt の名前に店の名前を入れました
     {日付}_{run_name}_{店の名前}_store_report.txt になります。③⑦を続けて回しても
     上書きされません。conditions.json と table_path_lengths_family.csv は
     名前がそのままなので、まだ上書きされます（必要なら次に直します）。

────────────────────────────────────────────────────────
1. 狭い通路をサイトにそろえる（入れました）
────────────────────────────────────────────────────────

サイトの決まり：「ふだんは幅 normal_width（1.0 m）以上の道を選び、
それで届かない卓だけ狭い道を使う」。

作り方（通れなくはしない。道探しのコストを上げるだけ）

  JsonStoreLayout  JSON の細い通路のうち、幅が meta.settings.normal_width 未満のものに
                   PlanAisle.hardToPass の印を付ける（③ 1.2 m は付かない／⑦ 0.8 m は 3 本とも付く）
  StoreBuilder     AisleInfo.hardToPass に渡す
  NavMeshBaker     narrowAisleArea（既定 4）の区画にする。形は足さず、箱の中の区画の
                   番号だけを変える（ModifierBox。レジの内側と同じ作り）
  RobotSpec        narrowAisleCost（既定 20）を足した。ふつうの床は 1
  ServingRobot     NavMeshAgent.SetAreaCost(narrowAisleArea, narrowAisleCost)

  ※ 区画 4 の名前は ProjectSettings/NavMeshAreas に入れていません
    （Assets/ServiceSim の外を触らないため。番号だけで動きます）
  ※ コスト 20 は仮です。「1.0 m 以上の道があればそちらを選ぶ」が成り立つ範囲で、
    小さいほど素直です。確かめの結果を見て decide したいです

確かめ方も足しました（JSON Store の確かめに列を 1 つ追加）
  table_dist.csv に **via_narrow**（0/1）を足し、受け渡し口→卓の経路が
  狭い通路の区画を通ったかを出します。経路の角と、角のあいだ 0.25 m ごとの点の
  区画を見て判定します。道のりも、同じコストを入れた道探しで測ります。
  → 依頼 4-5 の「⑦で T17・T22 が狭い通路を避けているか」は、この列で分かります。

────────────────────────────────────────────────────────
2. 試算の値を読む（入れました）
────────────────────────────────────────────────────────

写したもの：Assets/ServiceSim/Experiment/serving-robot-params.json
写した日時：2026-10-22 19:26:39 JST
写し元：https://pixelstomotors.com/sim/serving-robot-params.json
試算の版：meta.model v2.4 / meta.layout_generation **e624bdf4bdd4**
　→ ③⑦の店の形の meta.generation と同じです（合っています）

読む道具：Experiment/SimParams.cs ＋ SimParams.asset

  ・role=site_rule の値だけを返す。**unity_measures（robot_speed・pause_rate・
    pause_s・stuck_rate）を読もうとしたら、値を返さずに「読みません」と記録します**
    （まちがって使わないための歯止め）
  ・site_only（hop_m・warmup_min・sustain・util_sample_s・model）も返しません
  ・meta.layout_generation が店の形の版と違ったら Ready が false（回さない）
  ・unity_conditions の 6 件を Condition として持ちます
    fam_p1 × ロボット 0/1/2・fam_p5 × ロボット 0/1/2、どれもスタッフ 4 人・42 組毎時・
    serve（上げ膳だけ）・fragile_by=any。trays は robots=0 のとき 1、それ以外 3
  ・site_result（サイトの結果）も条件ごとに持ちます（4-2 の表に使います）

確かめのメニューを足しました（回しません。読めるかどうかだけ）
  ServiceSim/JSON Store/試算の値を読んで確かめる（v5）
  → ③⑦それぞれと版を突き合わせ、使う値・店ごとの値・metrics の名前と意味を
    screens/{日付}_sim_params_check.txt に書き出します

いまの .asset の値と JSON を、私の側で突き合わせた結果（違うのは 2 つだけ）

  ★ちがう arrivalsPerHourByCrowd : .asset [25, 35, 45] → JSON [30, 42, 54]
  ★ちがう trays                   : .asset 3（固定）→ 条件ごと（robots=0 は 1・それ以外 3）
  同じ    peak_min 120 / guide_s 45 / pay_s 90 / call_s 30 / call_rate 0.6 /
          first_dishes [5,8,12][8,12,18] / fragile 0.5 / fragile_cook [2,4,7] /
          cold_min 3 / robot_handoff_s 25 / staff_place_s 10 / eat_min [20,30,45] /
          clear_s 40 / wipe_s 40 / bus_trips 2

  → 手で写した値は、来客のペース以外はすべて合っていました。
    ServiceSpec・PeopleSpec を JSON から読む形にする作業（3 章）でも、
    実際に変わるのはこの 2 つだけの見込みです。

────────────────────────────────────────────────────────
3. まだ入れていないもの（3 章・4 章）
────────────────────────────────────────────────────────

依頼 3 章の表のうち、残りはここです。どれも「回し方」そのものに触ります。

  a. 条件        unity_conditions の 6 つを選べるようにする（いまは 通路幅×混み具合×台数 の
                 18 条件の作り）。スタッフの人数を条件にする。ロボット 0 台で回せるようにする
  b. 始め方      空の店から peak_min（120分）来客し、全員が帰るまで（max_extra_min で打ち切り）。
                 いまの「8割埋まった店・慣らし15分」をやめる
  c. 立ち往生    受け渡し口へ移す（warp）をやめ、スタッフが歩いて行って rescue_s（15秒）動かす。
                 ほかの仕事より先にする
  d. 逃がし弁    あきらめた・移した・卓を空けた の回数を数えて記録（消さない）
  e. 出す数字    meta.metrics の名前と定義にそろえる。定義が違うものは報告に書く
  f. 測る値      往復ごとに4つ（速さ・止まっていた長さ・動けなくなった割合・止まった割合）。
                 ⑦は狭い通路の中と外を分ける
  g. 値の差し替え ServiceSpec / PeopleSpec を JSON から読む形にする（上の 2 つが変わる）

  これらは ExperimentRunner（668 行）・ServiceOps（1078 行）・TripLogger（1044 行）・
  StaffAgent（403 行）・ServingRobotController（1135 行）に手を入れることになります。
  **Unity Editor が開いたままで、こちらではコンパイルも実行もできません。**
  読まずに大きく書いて渡すと、動かしてみるまで間違いが分からないので、
  ここで一度切って報告します。

────────────────────────────────────────────────────────
4. お願い
────────────────────────────────────────────────────────

(ア) いま入れた分を、Unity で読んでから押してください

  1. コンパイルが通るか（新しいのは SimParams.cs だけ。ほかは数行ずつの差し替えです）
  2. ServiceSim/JSON Store/試算の値を読んで確かめる（v5）
     → 版が合っているか・使う値・6 条件が読めているか
  3. ServiceSim/JSON Store/③⑦ を続けて確かめる
     → 幅の誤報が消えているか（「0 m ← 直す必要あり」が実際の幅になるか）
     → ⑦の table_dist.csv の via_narrow が 0 になっているか
       （狭い通路を避けて遠回りしているか。T17・T22 を特に見てください）
     → ③の via_narrow は 0 のまま（1.2 m なので区画を付けていません）

(ウ) そのあと、3 章 a〜g を続けます。**Unity を閉じていただければ**、
    こちらで batch mode にかけて、コンパイル→③ロボット2台を1回（4 章）まで
    回して報告できます。開けたままなら、同じように「書く→エイイチが押す」で進めます。

VCS Check-in は、今回入れた分（1 章・2 章・0-2 の直し）はまだしていません。
コンパイルと上の確かめが通ってから入れます（依頼 5 の「確かめが通ったら」のため）。

いま触ったファイル
  新規 Experiment/serving-robot-params.json（＋ .meta）
       Experiment/SimParams.cs（＋ .meta）/ Experiment/SimParams.asset（＋ .meta）
  変更 Editor/ServiceSimMenu.cs（幅の誤報 2 か所・store_report の名前）
       Editor/ServiceSimJsonStore.cs（試算の値のメニュー・via_narrow・コスト入りの道探し）
       Store/StorePlan.cs（PlanAisle.hardToPass）
       Store/StoreBuilder.cs（AisleInfo.hardToPass）
       Store/JsonStoreLayout.cs（normal_width で印を付ける・報告）
       Store/NavMeshBaker.cs（narrowAisleArea・AddNarrowAisleAreas）
       Robot/RobotSpec.cs（narrowAisleCost）
       Robot/ServingRobotController.cs（区画のコストを agent に入れる・測る用の filter）
```

### 20261022_2010_narrow_aisle_fix.txt
```
⑦で 21 卓が「経路がつながらない」になった件。原因しらべと、コストをやめて手順に直した分です。
Unity は押していません（Editor が開いたままのため）。

────────────────────────────────────────────────────────
1. 原因しらべ（私の側でできたところまで）
────────────────────────────────────────────────────────

(1) 区画の箱の位置は、通路の区画どおりでした　← 原因ではない

  AddNarrowAisleAreas が作る箱を、JSON の数字から計算して突き合わせました。

    A01  JSON rect [1.2, 4.6, 2.0, 12.2]   → 箱 x 1.20..2.00 / z 4.60..12.20（中心 y=0.5・高さ 2.0）
    A02  JSON rect [4.5, 4.8, 5.3, 14.05]  → 箱 x 4.50..5.30 / z 4.80..14.05
    A03  JSON rect [7.8, 4.8, 8.6, 14.05]  → 箱 x 7.80..8.60 / z 4.80..14.05

  3 本とも、JSON の通路の区画と小数点以下までぴったりです（ずれ 0）。

(2) 判定の厳しさは、段階1 と同じでした　← 原因ではない

  段階1 で使っていた NavMeshBaker.PathLength も、PathComplete でなければ -1 を返す作りです
  （「経路が途中まで」もエラー扱い）。段階1 は 42/42 が ok だったので、
  判定が厳しくなったのではなく、**道探しの結果そのものが変わっています**。
  変えたのは「区画 4 を焼いたこと」と「コスト 20」の 2 つだけです。

(3) いちばん疑わしいのは、コストの入れ方です（仮説。まだ確かめていません）

  エイイチの見立て（道探しが上限に当たって PathPartial）も考えましたが、
  落ちた卓の並びが合いません。

    落ちた: A02 の 9 卓・A03 の 9 卓・通路の外の T27〜T30（受け渡し口にいちばん近い側）
    通った: A01 の 8 卓（受け渡し口からいちばん遠い）

  探索の上限なら「遠いものから落ちる」はずで、逆になっています。
  また ⑦ のロボット用の面は三角形 148 枚しかないので、上限には当たりにくいはずです。

  疑っているのは NavMeshQueryFilter.SetAreaCost の使い方です。
  この構造体は agentTypeID を入れた時点で全区画のコストが入りますが、
  そこへ SetAreaCost を混ぜると、ほかの区画のコストが思ったとおりにならないことがあります
  （0 のまま、など）。そうなると A* の見積もり（直線距離）と実際のコストが合わなくなり、
  近い行き先でも途中で打ち切られて PathPartial になります。
  T27〜T30（狭い通路をまったく通らない卓）まで落ちたのは、これで説明が付きます。

  **確かめは、次の実行で自動的に出ます。** CSV に 2 列足しました。

    wide_status … 狭い通路を外して探したときの結果
    all_status  … 狭い通路も入れて、**コストを使わずに**探したときの結果

  all_status が全卓 ok なら、「コストを使わなければ段階1 と同じに戻る」＝コストが原因、と確定します。

────────────────────────────────────────────────────────
2. 直した内容（コストをやめて、サイトの決まりをそのまま手順に）
────────────────────────────────────────────────────────

  RobotSpec            narrowAisleCost（20）を削除 → avoidNarrowAisles（true/false）に
  ServingRobotController
                       ・SetAreaCost をやめた（agent の区画コストは触らない）
                       ・SetDestination を 2 段にした
                           ① 狭い通路の区画を外して探す（RobotFilterWithoutNarrow）
                              → 完全につながればそれを使って終わり
                           ② つながらない行き先だけ、狭い通路も入れて（コストはそのまま 1）
                              いちばん短い道を使う
                         どちらも引けないときは、今までどおり「途中までの道」で進みます
                         （あきらめる道の仕組みは変えていません）
                       ・UsedNarrowAisle（その道が ② だったか）を記録用に出せるようにした
  ServiceSimJsonStore  確かめも **まったく同じ 2 段** にした。via_narrow は ② のときだけ 1

  agent.areaMask は変えていません。狭い通路の中に立っているときに足もとの面を外すと、
  その場で動けなくなるためです（道を引くときだけ外します）。

  あわせて、走るときと確かめで「止まる位置を面へ寄せる半径」がずれていたのをそろえました。

    走行  spec.Radius * 4 = 1.0 m（前からこの値）
    確かめ 0.5 m（段階1 のまま）→ **1.0 m にそろえた**

  寄せた距離は CSV の snap_m に出します（0.3 m 以上動いた卓は報告にも並べます）。
  寄せる先は「いちばん近い面の点」なので、半径を広げても測る場所は変わりません。
  届くかどうかの判定だけが、走行と同じになります。

────────────────────────────────────────────────────────
3. こうなるはず（JSON の寸法から計算した予測）
────────────────────────────────────────────────────────

止まる位置から、障害物（卓・ベンチ・椅子・壁・通れない部屋）に 0.25 m 以上空けて
「狭い通路の区画の外」に出られるかを、0.02 m 刻みで調べました。

  via_narrow = 0（狭い通路を使わずに届く）: **24 卓**
  via_narrow = 1（狭い通路でしか届かない）: **18 卓**
      A01: T02 T03 T04 T06 T07 T08
      A02: T10 T11 T12 T14 T15 T16
      A03: T19 T20 T21 T24 T25 T26

  **T17 と T22 は via_narrow = 0 の側に入ります**（ご依頼の期待どおり）。
    T17 止まる位置 [4.812, 13.888] … A02 の北の口まで 0.16 m。口の外へ 0.18 m で出られる
    T22 止まる位置 [8.588, 12.688] … A03 の東のふちまで 0.012 m。
        T22 の東（z 12.2 以上）は卓が無く空き床なので、0.02 m 外へ出れば広い床

  サイトの「narrow（ロボットが行けない）24 卓」との差は 6 卓（T01・T05・T09・T13・T18・T23）。
  この 6 卓は止まる位置が通路の口から 0.16〜0.38 m しかなく、Unity では
  **口の外に立ったまま届く**ので、狭い通路に入らずに済みます。
  サイトは「通路の真ん中に 50 cm の余裕がある道のり」で見ているので、ここは食い違います。
  どちらに寄せるかは段階2 の前に決めたいです（Unity 側では埋めていません）。

────────────────────────────────────────────────────────
4. お願い
────────────────────────────────────────────────────────

  ServiceSim/JSON Store/⑦ p5_crammed を建てて確かめる

  を押して、{日付}_p5_crammed_table_dist.csv と _json_check.txt を送ってください。見たいのは

    1. all_status が全卓 ok か（＝コストが原因だったかの確定）
    2. via_narrow が 1 になった卓が、上の 18 卓と合っているか
    3. T17・T22 が via_narrow = 0 で、道のりが JSON の dist（17.05 / 12.0 m）に近いか
    4. snap_m が大きい卓（0.3 m 以上）がどれか

  ③ も一度押していただけると、via_narrow が全卓 0 のままか（1.2 m なので区画を付けていない）が
  確かめられます。

  触ったファイル
    Robot/RobotSpec.cs                  narrowAisleCost → avoidNarrowAisles
    Robot/ServingRobotController.cs     SetAreaCost をやめ、SetDestination を 2 段に（SolvePath）
    Editor/ServiceSimJsonStore.cs       確かめも 2 段に。wide_status / all_status / snap_m を追加

  NavMeshBaker（区画 4 を焼くところ）と JsonStoreLayout（normal_width で印を付けるところ）は
  そのままです。区画を焼くこと自体は、2 段の手順でもそのまま使います。
```

### 20261022_2040_routes_v5b.txt
```
v5b（ロボットの道順をサイトから受け取る）を入れました。Unity は押していません（Editor が開いたまま）。

────────────────────────────────────────────────────────
1. 写し直し
────────────────────────────────────────────────────────

写した日時：2026-10-22 20:23:50 JST
版の番号：**9628954a582f**（③・⑦・summary.json・serving-robot-params.json の 4 つとも同じ）

  Store/Layouts/p1_efficient_corner.json   43,270 → 360,040 バイト（routes が入ったため）
  Store/Layouts/p5_crammed.json            43,258 → 367,162 バイト
  Store/Layouts/summary.json               3,042 バイト
  Experiment/serving-robot-params.json     9,899 バイト

.asset の fetchedAt を 3 つとも「2026-10-22 20:23:50 JST（版 9628954a582f）」に直しました。

routes の中身（こちらで読んで確かめた）

  ③ from_pass 42 本（use は **normal 42・narrow 0**）/ between 861 本（抜け 0）
  ⑦ from_pass 42 本（use は **narrow 24・normal 18**）/ between 861 本（抜け 0）
  use と、サイトの卓の分け方（category）の食い違い: 0（③⑦とも）
  from_pass の最初の点が受け渡し口でないもの 0 / 最後が止まる位置でないもの 0
  between の端が合わないもの 0
  length と、点を結んだ長さの差: いちばん大きくて 0.0005 m

────────────────────────────────────────────────────────
2. 直したこと
────────────────────────────────────────────────────────

(1) v5 で足した狭い通路の区画を外しました

  NavMeshBaker       narrowAisleArea と AddNarrowAisleAreas を削除。ModifierBox を足さない
                     → **NavMesh は段階1 と同じ**（レジの内側の区画だけ残す）
  RobotSpec          avoidNarrowAisles を削除
  ServingRobot       2 段の道探し（RobotFilterWithoutNarrow）を削除。道探しは 1 段に戻した

  PlanAisle.hardToPass / AisleInfo.hardToPass（幅 < normal_width の印）は残しました。
  NavMesh には何もしません。報告に「狭い通路 3 本」と出すだけです。要らなければ外します。

(2) ロボットは routes の点をたどります

  Store/RouteBook.cs（新規）  from_pass / between を持ち、
                              卓→受け渡し口は from_pass を逆にたどる。
                              between に a|b が無ければ b|a を逆にたどる
  JsonStoreLayout             routes を読んで StorePlan.routes に入れる
  StoreBuilder                Routes で引けるようにした
  ServingRobotController      区間（受け渡し口→1卓目→2卓目→3卓目→受け渡し口）ごとに
                              点の並びを受け取り、**1 点ずつ行き先にしてたどる**
                                BeginLeg      区間の始め（いまいる所に近い先頭の点は飛ばす）
                                AdvanceWaypoint  点に着いたら次の点へ（CheckArrival の中）
                                ResumeLeg     譲り合い・あきらめから戻るとき、いまの点に戻す
                              NavMesh の道探しは **次の点までの区間だけ** に使います。
                              引き直し（replanAfterStoppedSec・EscapePartialPath）も
                              いまの点までの区間だけで、道順そのものは変えません。
  RobotSpec                   followSiteRoutes（既定 on。off で前の動き）
                              waypointReachDistance（0.25 m。途中の点に着いたとみなす距離）
                              routePointSnapM（0.1 m。**最後の点＝止まる位置は寄せない**。
                              この半径で面に乗らなければ広げて引き直し、回数を数える）

  人にふさがれたときの止まる・待つ・よける・立ち往生は、いっさい変えていません。
  待機場所 ↔ 受け渡し口は今までどおり Unity の道（GoStandby）です。

(3) 確かめ（JSON Store のメニュー）も同じにしました

  CSV の列（{日付}_{店}_table_dist.csv）
    table, category, aisle, **use**, json_dist_m, **route_len_m**, **unity_path_m**,
    diff_m, diff_pct, **via_narrow**, **segments**, **bad_segments**, status

    route_len_m  サイトの道順の点を結んだ長さ
    unity_path_m 点から点を Unity の NavMesh でつないだ長さの合計
    via_narrow   use == "narrow" なら 1
    segments     その卓の道順の区間の数 / bad_segments つながらない・遠回りの区間の数

  報告（_json_check.txt）に足したもの
    ・すべての区間がつながった卓の数
    ・つながらない区間の一覧（卓・何番目の区間・両端の点・理由）
    ・まっすぐの長さの **1.05 倍** を超えた区間の一覧（店の形と NavMesh が合っていない印）
    ・via_narrow（use=narrow）の卓の数

────────────────────────────────────────────────────────
3. こうなるはず（JSON の寸法から計算した予測）
────────────────────────────────────────────────────────

道順の各区間が、ロボットの半径 0.25 m を確保できるかを 0.05 m 刻みで調べました。

  ③ from_pass の区間 205 本 … 半径を確保できない区間 **0 本**
     区間の最小すき間 0.487 m（0.30 m 未満の区間 0 本）
  ⑦ from_pass の区間 195 本 … 半径を確保できない区間 **0 本**
     区間の最小すき間 0.387 m（0.8 m の通路の真ん中を通るため。0.30 m 未満 0 本）

  → 焼いた面（半径 0.25 m・ます目 0.05 m）でも、どの区間もまっすぐつながるはずです。
    つながらない区間 0・1.05 倍を超える区間 0 を見込んでいます。

  道順の長さ − JSON の dist は ③⑦とも -0.87 〜 -0.01 m（道順の方が少し短い）。
  dist はマス目の上の道のりなので、直線で結んだぶん短くなります。

  via_narrow は **⑦ 24 卓・③ 0 卓**（routes の use の数と同じ）。

────────────────────────────────────────────────────────
4. お願い
────────────────────────────────────────────────────────

  ServiceSim/JSON Store/③⑦ を続けて確かめる

  見ていただきたいのは、依頼のとおり 3 点です。

    1. 42 卓すべての区間がつながること（「つながらない区間: 0」）
    2. まっすぐの 1.05 倍を超える区間が無いこと
    3. via_narrow が ⑦ 24 卓・③ 0 卓

  通ったら、こちらで VCS Check-in して v5 の 3 章（条件・始め方・立ち往生でスタッフを呼ぶ・
  逃がし弁の記録・出す数字・往復ごとの4つの値）に進みます。

  触ったファイル
    新規 Store/RouteBook.cs（＋ .meta）
    写し Store/Layouts/*.json（3 つ）・Experiment/serving-robot-params.json
    変更 Store/JsonStoreLayout.cs（routes を読む・報告）
         Store/StorePlan.cs（routes を持つ）・Store/StoreBuilder.cs（Routes で引く）
         Store/NavMeshBaker.cs（狭い通路の区画を削除）
         Robot/RobotSpec.cs（avoidNarrowAisles 削除／followSiteRoutes 他 3 つ追加）
         Robot/ServingRobotController.cs（2 段の道探しを削除／道順をたどる）
         Editor/ServiceSimJsonStore.cs（確かめを道順の照合に）
         Store/StoreLayout_p1_efficient_corner.asset / p5_crammed.asset /
         Experiment/SimParams.asset（fetchedAt）

  なお、v5 の 0 章で入れた 2 つの直し（幅の誤報・store_report の名前）も、まだ
  Check-in していません。今回の分とまとめて、確かめが通ってから入れます。
```

### 20261022_2055_navmesh_clear.txt
```
⑦が通った件、ありがとうございます。ご指示の 1・2 を入れました。押していただく前の報告です。

────────────────────────────────────────────────────────
1. 焼く前に、入っている NavMesh を全部外す
────────────────────────────────────────────────────────

原因（エイイチのご指摘）：スクリプトの読み込み直し（ドメインリロード）をまたぐと、
前に焼いた NavMesh がシステムに残ったままになる。baker の _instance / _peopleInstance は
読み込み直しで空になるので、Remove() では外せない。
⑦でつながらなかったのは、v5 の「狭い通路の区画」入りの面が残っていたためでした。

直した場所は 2 か所です。

  Bake()
    NavMesh.RemoveAllNavMeshData();   ← 足した
    Remove();                          ← これまでどおり（この baker の分を片付ける）
    …ロボット用を焼く
    BakePeople(...)                    ← **人用はこのあと焼き直す**（元のまま）

  TriangulateFor(agentRadius)
    これまで：自分の 2 つの instance だけ外す → 一時の面を焼いて三角形を読む → 元に戻す
    これから：**NavMesh.RemoveAllNavMeshData()** で全部外す → 一時の面を焼いて読む →
              元に戻す（_data / _peopleData の参照は持っているので、そのまま AddNavMeshData で戻す）

  どちらも、焼き直しのたびに「入っている面はこの baker が今つくったものだけ」になります。
  塗り分け（FloorPaint）と「焼いた NavMesh のしらべ」も TriangulateFor を通るので、
  残った面が混ざった三角形を読むことはなくなります。

────────────────────────────────────────────────────────
2. hardToPass の印を外した
────────────────────────────────────────────────────────

  StorePlan.PlanAisle.hardToPass     削除
  StoreBuilder.AisleInfo.hardToPass  削除
  JsonStoreLayout                    normal_width と比べて印を付ける処理を削除。
                                     報告の「狭い通路 N 本」の行も消し、
                                     代わりに道順の本数を出すようにしました

  報告に出る行（JSON から建てた店…の節）
    道順 (routes): 受け渡し口から 42 本 / 卓から卓 861 本
    （狭い道を使うかは routes の use が正。Unity では印を持たない）

  これで「狭いかどうか」を持っているのは、サイトの routes の use だけになりました。
  Unity 側（NavMesh・通路の区画・ロボットの設定）には、狭い道の概念がありません。

────────────────────────────────────────────────────────
3. お願い
────────────────────────────────────────────────────────

  **ServiceSim/JSON Store/③⑦ を続けて確かめる** を、もう一度押してください。

  前回と同じ 3 点が出れば通りです（前回は焼き直しの前の面が混ざっていた可能性があるので、
  今回はきれいな状態での確かめになります）。

    1. 42 卓すべての区間がつながる（つながらない区間 0）
    2. まっすぐの 1.05 倍を超える区間 0
    3. via_narrow が ⑦ 24 卓・③ 0 卓

  通ったら、こちらで VCS Check-in して（v5 の 0 章の直し 2 つ・v5b の道順・今回の 2 つを
  まとめて入れます）、v5 の 3 章に進みます。

  今回触ったファイル
    Store/NavMeshBaker.cs   Bake() と TriangulateFor() で RemoveAllNavMeshData
    Store/StorePlan.cs      PlanAisle.hardToPass 削除
    Store/StoreBuilder.cs   AisleInfo.hardToPass 削除
    Store/JsonStoreLayout.cs 印を付ける処理と報告の行を削除（道順の本数に差し替え）
```

### 20261022_2140_align_v5_step3.txt
```
cs:114 で Check-in しました（v5b の道順・v5 の0章の直し2つ・NavMesh の焼き直し・hardToPass 削除）。
そのあと v5 の 3 章（回し方をサイトにそろえる）を入れました。Unity は押していません（Editor が開いたまま）。

────────────────────────────────────────────────────────
0. cs:114 の中身（21 項目）
────────────────────────────────────────────────────────

  新規  Store/RouteBook.cs（＋.meta）
        Experiment/serving-robot-params.json（＋.meta）
        Experiment/SimParams.cs（＋.meta）・SimParams.asset（＋.meta）
  変更  Store/JsonStoreLayout.cs・StorePlan.cs・StoreBuilder.cs・NavMeshBaker.cs
        Robot/RobotSpec.cs・Robot/ServingRobotController.cs
        Editor/ServiceSimJsonStore.cs・Editor/ServiceSimMenu.cs
        Store/Layouts/*.json（3つ）・StoreLayout_p1_efficient_corner.asset・p5_crammed.asset

  .git は入っていません（ignore.conf に足す案は、まだご相談のままです）。

────────────────────────────────────────────────────────
1. そろえたこと（v5 3章の表のとおり）
────────────────────────────────────────────────────────

(a) 条件：unity_conditions の6つを選べるようにした

  新しいメニュー **ServiceSim/Site Run/**
    条件を一覧する（回さない）
    ③ スタッフ4人・ロボット0台 / 1台 / 2台
    ⑦ スタッフ4人・ロボット0台 / 1台 / 2台
    出力フォルダを開く

  選ぶのは条件だけです。数字は ExperimentRunner が JSON から読みます。
  ロボット0台とスタッフ4人は、そのまま回せます（0台のときは走りが0なので、
  「走りがあるのに記録が0」の見張りは働きません）。

  置き場所は KIOXIA（v5 の5章）
    /Volumes/KIOXIA/r2r2r-data/service_sim/unity/{日付}_{店}_{条件}_s{種}_{時刻}/
  KIOXIA がつながっていないときは、回さずに止めます。

(b) 始め方：空の店から、来客 120 分 ＋ 全員が帰るまで

  これまで                これから（サイトの条件のとき）
  8割埋まった店で始める → 空の店から始める（慣らしの組を置かない）
  慣らし 15 分 ＋ 計測 120 分 → 来客 peak_min（120 分）→ そのあと新しい組は来ない
                              → 全員が帰ったら終わり
                              → 帰らないまま max_extra_min（600 分）を過ぎたら打ち切り

  打ち切りだったかは summary.json の site_run.everyone_left で分かります（true なら全員帰った）。
  これまでの回し方（慣らし＋計測）は、そのまま残してあります（Run One・Stage 3 は前のまま）。

(c) 立ち往生：受け渡し口へ移す（warp）をやめ、スタッフを呼ぶ形にした

  これまで：60 秒動けない → 15 秒待つ → **受け渡し口へ移して往復を終える**（stuck=1）
  これから：60 秒動けない → **スタッフを呼ぶ**（仕事板に優先度 0 で出す。ほかの仕事より先）
            → スタッフが歩いて来て rescue_s（15 秒）動かす
            → **止まっていた所から、同じ道順の続きを走る**（道順は変えない）

  ・「動けなくなった」の判定は今のまま（stuckAfterStoppedSec = 60 秒）
  ・1往復で何度でも起こりえます（動かしてもらった先でまた動けなくなる）。
    回数は trips.csv の stuck_count、待った秒数は stuck_wait_s に出ます
  ・「ほかの仕事より先」は、**次に取る仕事として先**という意味です
    （いま案内や片付けをしている人を、途中で引き離すことはしません）
  ・いつまでも来ないときの最後の保険は、今までどおり1往復 600 秒の打ち切り（timeout=1）です
  ・スタッフがロボットのところへ着けないときは、今までどおり B1 の
    「あきらめた → 3回で移した」が働きます（回数は give_ups.csv に残ります）

(d) 逃がし弁：回数を記録した（仕組みは消していません）

  summary.json の site_run に、あきらめた（gave_up）・移した（warped）・卓を空けた（table_freed）を
  並べて出します。立ち往生で呼んだ回数（rescues）・動かした回数（rescues_done）・
  呼んでから動き出すまでの秒数（rescue_wait_s。歩いた時間＋15 秒）も同じ欄です。

(e) 出す数字：meta.metrics と同じ名前で出した

  summary.json に **site_metrics** という欄を足しました。名前はサイトのままです。
    entry_wait_mean / full_wait_mean / staff_wait_mean / dish_wait_mean / dish_wait_p90 /
    cold_share / turn_mean / walk_km_per_staff / staff_util / robot_util / robot_share /
    rescues / pauses / groups
  同じ summary.json に **site_result**（その条件のサイトの答え）も写すので、
  並べた表はあとから手で写さずに作れます。

  **定義が違うもの（ご依頼のとおり報告します）**

    1. staff_wait_mean
       サイト … blocked_wait（卓が空くのを待った）＋ guide_wait の合計
       Unity … **案内の仕事が板に出てから、スタッフが取るまで**
       Unity は空き卓が出た瞬間に卓を割り当てるので、「卓は空いているのに待つ」のは
       案内の人が来ないあいだだけになります。そこを測っています。

    2. turn_mean（客が帰ってから卓が使えるまで）
       名前と定義は同じですが、中身が違います。Unity はそのあいだに
       **片付けのスタッフが歩く時間と、bus_trips（2 往復）の返却口への行き帰り**も入ります。
       サイトは clear_s＋wipe_s の時間だけです。Unity の方が長く出るはずです。

    3. entry_wait_mean の内訳が足し算になりません（サイト側も同じ）
       サイトの ③ロボット0台では entry 3.1814 ＝ full 0.1472 ＋ staff 2.8545 ＋ 0.18 で、
       ぴったりにはなりません（平均をとる母数が違うため）。
       Unity も entry ＝ full ＋ staff ＋（歩いた時間＋45 秒）で、足し算にはなりません。

    4. site_result にあって meta.metrics に無い名前が6つあります
       seat_wait_mean・blocked_wait_mean・guide_wait_mean・robot_bus_share・collects・fragile_robot
       このうち seat_wait_mean は Unity も前から出しています（sim_like）。
       ほかの5つは Unity では出していません（下げ膳と追加注文を作っていないため）。

    5. pauses と rescues は、サイト側が **unity_measures の仮の値**（pause_rate 0.3・
       stuck_rate 0.01）から出した数です。Unity はこれを読まずに実際に数えるので、
       site_result の pauses 41.6 / rescues 1.475 とは、そもそも出どころが違います。
       ここは「Unity で測ってサイトに返す」側の数字です。

(f) Unity で測る値：往復ごとに出した（設計書2章の4つ）

  trips.csv に足した列
    speed_mps                実際に走れた速さ ＝ distance_m /（total_s − stopped_s）
    stuck_count              動けなくなってスタッフを呼んだ回数
    stuck_wait_s             スタッフを待った秒数
    via_narrow               その往復でサイトの道順が 0.8 m の道を使ったか（routes の use）
    stops_in_narrow          狭い通路の中で止まった回数
    stops_out_narrow         外で止まった回数
    stopped_in_narrow_s      中で止まっていた秒数
    stopped_out_narrow_s     外で止まっていた秒数
  止まっていた長さの分布は、これまでどおり stop_durations_s（往復ごと）と stops.csv（1回ごと）です。
  stops.csv には **in_narrow** を足したので、分布も中と外に分けて読めます。

  「狭い」の決め方はサイトの決まりのままです（通路の幅 < meta.settings.normal_width = 1.0 m）。
    ③ 通路 1.2 m → 中は 0 回（ぜんぶ「外」）
    ⑦ 通路 0.8 m → A01・A02・A03 の中が「中」

(g) ServiceSpec・PeopleSpec を JSON から読む形にした

  新しい Experiment/SiteValues.cs が、その回だけ .asset に入れて、回し終わりに戻します
  （停止点（C1）と同じやり方。.asset は書き換わりません）。実際に入れた値は Console に出ます。

  入れる値
    条件から   arrivals_per_h → ServiceSpec.arrivalsPerHour（混み具合を使わなくなる）
               trays / fragile_by / staff（PeopleSpec.staffCount）/ robots
    common から guide_s・pay_s・call_s・staff_place_s・clear_s・wipe_s・rescue_s・
               max_extra_min・cold_min・fragile_cook・staff_speed・
               robot_handoff_s（RobotSpec.tableDwellSec）
    stores から peak_min・call_rate・fragile・bus_trips・eat_min・first_dishes[0][1]

  **.asset の値と実際に違っていたのは2つだけ**です（ほかは同じ値を手で写してありました）
    来客       35 組/時（混み具合「普通」）→ **42 組/時**（条件の arrivals_per_h）
    スタッフ   2 人 → **4 人**
    （ロボット0台の条件だけ trays が 3 → 1 になります）

  読まない値（unity_measures）: robot_speed・pause_rate・pause_s・stuck_rate
  サイトにあるが Unity で形にしていないもの（Console と報告に出します）
    common.load_s（スタッフがロボットに皿を載せる 15 秒）
      … Unity は受け渡し口で待つ 3 秒（staffDwellAtPassSec）だけ。**ここは差になります**
    common.guest_load_s（下げ膳で客が皿を載せる 20 秒）… mode=serve なので使わない
    common.dish_extra_m（0 分）… 0 なので差は出ない
    stores.extra_orders（0）・extra_cook … 追加注文が 0 なので使わない
    stores.staff_all・arrivals・dist_m・dist_list … 条件の範囲と道のりの確かめに使う値

  回す前に止める決まり（Unity で埋めない）
    ・版の番号が合わない（店の形の meta.generation ↔ 試算の meta.layout_generation）
    ・読めない値が1つでもある
    ・建てた卓の数が stores.tables（42）と違う
    ・条件の mode が serve でない
  いまの JSON で、上の4つはすべて通ります（私の側で JSON を読んで確かめました）。
    版の番号 9628954a582f（③・⑦・summary.json・試算の4つとも同じ）
    読めない値 0 件 / 卓 42 = 42 / mode すべて serve

  Unity にしか無いもの（組の人数・2人席・ドリンクバー・トイレ・椅子を引く）は、そのまま残しています。
  歩く客・待合に立つ客の人数だけは、まだ「混み具合」の値（普通）を使っています
  （サイトに人数が無いため。Unity だけの値です）。

────────────────────────────────────────────────────────
2. 触ったファイル
────────────────────────────────────────────────────────

  新規 Experiment/SiteValues.cs（＋.meta）        サイトの値をその回だけ入れて戻す
       Editor/ServiceSimSiteRun.cs（＋.meta）     Site Run のメニュー（6条件）

  変更 Experiment/ExperimentRunner.cs   Job にサイトの条件を足す／始め方と終わり方／
                                        往復ごとの測り／site_metrics に渡す値
       Experiment/TripLogger.cs         trips.csv に8列・stops.csv に1列・
                                        summary.json に site_metrics / site_run / site_result
       Experiment/SimParams.cs          条件を番号で引く・first_dishes を読む
       Service/ServiceOps.cs            Rescue の仕事・立ち往生の回数・robot_util・
                                        スタッフの歩いた距離・入店待ちの内訳
       Service/GroupManager.cs          来客を 120 分で止める・慣らしを置かない・
                                        満席で待った分を数える
       Service/ServiceSpec.cs           arrivalsPerHour・rescueSec・maxExtraMin を追加
       People/StaffAgent.cs             Rescue の仕事（歩いて行って rescue_s 動かす）・
                                        歩いた距離を残す
       Robot/ServingRobotController.cs  warp をやめて Rescued() で走りを続ける・
                                        stuck_count / stuck_wait_s / via_narrow
       Robot/RobotSpec.cs               stuckRecoveryWaitSec は使わなくなった（説明を直した）
       Store/JsonStoreLayout.cs         版の番号・卓の数・normal_width・試算の店の名前を読めるように
       Editor/ServiceSimSetup.cs        SimParams を ExperimentRunner に入れる
       Editor/ServiceSimStage2.cs       同じ（メニューから回すときの下ごしらえ）
       Editor/ServiceSimLabels.cs       字幕「動けない・人を待つ N 秒」

────────────────────────────────────────────────────────
3. お願い（押す順番）
────────────────────────────────────────────────────────

  1. **ServiceSim/Site Run/条件を一覧する（回さない）**
     Console に6条件と、meta.metrics の名前・意味が出ます。
     「合わないもの: なし」になっていることを見てください（ここで止まるなら回しても止まります）。

  2. **ServiceSim/Site Run/③ スタッフ4人・ロボット2台**（v5 の4章の確かめ）
     乱数の種は ExperimentPlan の trialSeed（いまの値）です。1回だけ回します。
     Console の「とちゅう」が1分ごとに出ます（組・帰った数・走り・立ち往生・実時間）。

  見ていただきたいのは、v5 の4章の5点です。
    1. 1回にかかった実時間と、全員が帰るまでの店の中の時間
       （summary.json の site_run.total_minutes と everyone_left）
    2. site_metrics と site_result を並べた数字
    3. 立ち往生で呼ばれた回数（rescues）と、逃がし弁の回数（gave_up・warped・table_freed）
    4. trips.csv の speed_mps と via_narrow
    5. ⑦は、そのあと同じように回して T17・T22 の道のりを見ます

  **注意**：来客 120 分 ＋ 全員が帰るまでなので、店の中の時間は 120 分より長くなります
  （打ち切りは 720 分）。これまでの 135 分より長い回になります。
  長すぎるときは Play を止めてください（そのときは何も書き出しません）。
  1回にかかった時間を見てから、6条件を何回ずつ回すかをご相談します。

  まだ Check-in していません（通ってからにします）。

════════════════════════════════════════════════════════
追記（2026-10-22 22:10）ご指示の4点を直して Check-in しました
════════════════════════════════════════════════════════

1. まだ入っていなかったファイルを、別の Check-in で先に入れました → **cs:115**（34 項目・.git なし）

   Service/（フォルダごと：ServiceOps.cs・GroupManager.cs・JamWatcher.cs・
            ServiceSpec.cs・ServiceSpec.asset）
   Experiment/ExperimentRunner.cs
   Editor/ServiceSimLabels.cs・ServiceSimLegend.cs・ServiceSimStage2.cs・ServiceSimStage3.cs
   People/PersonRobotYield.cs
   Store/FloorPaint.cs・Materials/Mat_PeopleFloor.mat・Mat_RobotFloor.mat・Prefabs/BenchSeat.prefab
   LABEL_RULES.md（＋それぞれの .meta）

   一度も Check-in されておらず「Added」のままだったものです。中身は変えていません。
   ただし、このうち 6 本（ServiceOps.cs・GroupManager.cs・ServiceSpec.cs・
   ExperimentRunner.cs・ServiceSimLabels.cs・ServiceSimStage2.cs）には、
   今日の v5 3章の変更がもう入っています（いちばん古い版が残っていないため、
   いまの中身のまま入れました）。Check-in のコメントにもそう書いています。

2. staff_wait_mean をサイトにそろえました

   分け方をサイトの試算（haizen_sim.py）と同じにしました。
     seat_wait    店に来てから案内が始まるまで
     blocked_wait そのうち、卓は空いたのに片付けが終わらず待った分
                  … **片付け待ちの卓の数だけ、待っている列の前から順に、
                     そのコマの時間を足す**（サイトの _acc と同じ）
     full_wait    seat_wait − blocked_wait
     guide_wait   案内の仕事が板に出てから、スタッフが取るまで
     staff_wait   blocked_wait ＋ guide_wait
   summary.json の site_metrics に blocked_wait_mean・guide_wait_mean・seat_wait_mean も出します。

   「片付け待ちの卓」は、客が帰ってから拭き終わって使えるようになるまでの卓です
   （ServiceOps.TableSlot.clearing）。
   サイトの試算には卓の大きさが無いので、ここも席数を見ずに列の前から数えます
   （2人席・4人席は Unity だけのものです）。

   確かめ：サイトの6条件すべてで、full = seat − blocked と staff = blocked + guide が
   小数4桁まで成り立ちました。分け方は合っています。

     条件        seat   blocked  full   seat−blocked  guide  staff  blocked+guide
     ③ 0台     2.6020  2.4548  0.1472     0.1472    0.3997 2.8545    2.8545
     ③ 1台     1.4919  1.1414  0.3505     0.3505    0.2909 1.4322    1.4323
     ③ 2台     0.6083  0.5116  0.0967     0.0967    0.2337 0.7453    0.7453
     ⑦ 0台     2.9121  2.5981  0.3140     0.3140    0.4008 2.9990    2.9989
     ⑦ 1台     1.6327  1.3084  0.3243     0.3243    0.2853 1.5937    1.5937
     ⑦ 2台     0.7970  0.6554  0.1416     0.1416    0.2469 0.9024    0.9023

3. turn_mean は直していません（サイトもスタッフ待ち・歩き・往復を含む。同じ定義）。
   前の報告に「定義が違う」と書いた分は取り下げ、summary.json の説明文からも外しました。

4. 上げ膳では、受け渡し口で載せる時間を 0 秒にしました

   サイトの条件で回すとき、PeopleSpec.staffDwellAtPassSec を 0 にします
   （SiteValues が覚えて、回し終わりに元の 3 秒へ戻します）。
   なお、この値は StaffAgent からは読まれていませんでした（料理を運ぶ仕事の1歩めは、
   前から dwell 0 秒）。ですので動きは変わらず、**0 秒という決まりが設定にも残る**という直しです。
   load_s（15 秒）は下げ膳だけで使う値、という扱いに書き換えました。

   → これで「サイトにあるが Unity で形にしていないもの」から load_s は外れました。
     残りは guest_load_s（下げ膳）・dish_extra_m（0）・extra_orders（0）・extra_cook です。

v5 3章ぶんは **cs:116**（14 項目）に入れました。
  変更 StaffAgent.cs・ServiceSimSetup.cs・TripLogger.cs・SimParams.cs・ExperimentRunner.cs・
       RobotSpec.cs・ServingRobotController.cs・JsonStoreLayout.cs・GroupManager.cs・ServiceOps.cs
  新規 Editor/ServiceSimSiteRun.cs・Experiment/SiteValues.cs（＋.meta）
Check-in していない .asset・prefab・シーン・ProjectSettings は、今回も触っていません。
```


## codex の検証

### 2回目の検証の結果（review_result_v3.md・codex が書いたもの）
```markdown
# 第3版・敵対的検証結果

2026-10-22再検証（初回2026-10-21）。対象は指定フォルダの依頼書、README、生成・解析・テストコード、6配置のJSON/analysis/summaryと図。行番号は今回の原本。添付の前回レビューや「対応済み」の説明は証拠として信用せず、コードと反例で確認した。

**総合：❌ 現状のまま「客が受け取れる卓の割合」を試算する材料として採用するのは不可。** 数値は再現できるが、L字席の受取側、⓪の駐機寸法、検査のすり抜けが残る。静的な幾何モデルの開発用データとしては利用可能。

依頼で対象外とされた厨房内部の人の動き、すれ違い・複数台運用・旋回、一時的な狭まり、壁高、サイトとUnityの共通スキーマは今回の不合格理由に含めない。

## 実行・再現結果 ✅ OK（数値に限定）

原本を作業フォルダにコピーし、そこで `python test_famires_v3.py` と `python famires_v3.py` を実行した。原本は変更していない。

- 同梱テスト **30件すべてOK、終了コード0**。
- 生成スクリプト **終了コード0**。6配置を2.5cm/1.25cmの両方で実行。
- 生成前の配置辞書は6件とも同梱layoutと一致（JSON化してtuple/listを正規化）。
- 再生成したsummaryは同梱summaryと完全一致。
- 6件のanalysisの `tables`（分類・距離・stop・side・narrowest・path_end等）は全件一致。`resolution_mismatch` はすべて空。
- 実行環境：Python 3.9.6 / NumPy 2.0.2 / SciPy 1.13.1 / Matplotlib 3.9.4。README指定環境とは異なる。Noto Sans CJK JPがなく、PNGは生成されたがフォント警告が出た。元PNGとの画素一致・日本語組版の再現は確認していない。

|配置|卓/席|通常robot 卓/席|narrow 卓/席|human 卓/席|平均m|最長m|
|---|---:|---:|---:|---:|---:|---:|
|⓪|19/88|19/88|0/0|0/0|8.5|14.01|
|①|42/160|42/160|0/0|0/0|10.7|20.14|
|②|28/88|28/88|0/0|0/0|8.4|14.02|
|③|46/200|46/200|0/0|0/0|13.7|23.17|
|④|38/158|38/158|0/0|0/0|10.7|18.38|
|⑤|42/160|18/64|24/96|0/0|11.0|20.32|

平均・最長はrobotとnarrowを合算する実装。human_walk_totalは全配置0。これらは「実装が出す数字」として正しいが、以下の欠陥を含むため、現実の到達率として正しいとは判定できない。

## 1. 前回指摘への対応

|内容|判定|確認結果|
|---|---|---|
|ソファ列の呼び方・向き|✅|今回READMEの定義では、2列の中央でテーブル端が向かい、外側が開く構造。前回の「形を変えるべき」という指摘はこの新しい明示仕様では撤回する。boothの配膳候補もaxisで左右/上下に限定された|
|①⑤レジ横0.1m|✅|6人席をx=6.8へ変更し、レジ右端6.1との間は0.7mになった。人の運用の十分性まで保証する判定ではない|
|②待合・③レジ長・右通路|✅|待合追加、レジ2.5m、右側通路1.5mをコード差分で確認|
|トイレちょうど1m|✅/⚠️|境界を含める処理と例外の新ルールは実装。ただし遮蔽の検査は不十分（後述）|
|半径切り上げ・斜め移動|✅/⚠️|矩形への距離計算と斜め両隣の確認に変更。整数円盤の40cm化は解消。ただし幅の許容誤差を含む計算であり、正確な通過保証と混同しないこと|
|出発点が不通なら停止|✅|同梱反例でValueError。mainでもNGとして記録|
|卓の停止点・path_end保存|✅|保存され再計算とも一致。ただし配膳側の意味に欠陥（後述）|
|待機場所を実寸・駐機込みに|❌|①〜⑤は拡張したが⓪は旧寸法。待機からの発車検査も内部経路を調べない|
|3分類・人の歩行距離|✅/⚠️|分類・集計コードは追加。今回全件human=0なので実データは人の歩行距離機能を検証しない|
|⑤を同じ卓数・席数に|✅|①⑤とも42卓160席、type/seatsと島の4/5/4卓構成一致。比較は固定外形内で列を圧縮するものなので、右側の余白も広がる点は解釈に含める|
|全組合せ検査・NGなら出力停止|❌|項目は増えたがすり抜けあり。NG時に既存出力が残る|
|依存・meta|✅/⚠️|READMEとmetaへの追加は確認。L字・丸卓等の解析形状と家具の受取側は未統一|

## 2. 実配置に残る問題

### ❌ NG [P1] L字席でソファ側への停止を「届く」とする

該当：`famires_v3.py:110–118,130–145,336–346,399–411`、`famires_p0.py:54–62`。

boothだけが候補辺を限定され、**booth6Lとt2には外接矩形の全4辺が返る**。周辺が空いていればソファ側もopen_sidesに残る。sightは対象家具の外形をskipし、辺までしか見ないので、対象家具自身のソファや天板は受け渡しを遮らない扱いになる。L字の天板を図に足しただけでは直っていない。

**現データの反例：⓪T02**

- 家具：x=0, y=9.4, w=2.6, h=3.6、flip/mirrorなし。
- L字ソファの下側の帯は `y=9.4〜11.02`（0.45h）。内側の天板の中心は描画定義から `(2.015,12.01)`、大きさ0.75×1.2m。
- 保存された停止点は `(3.363,9.562)`、side=right、robot、距離10.99m。
- 右辺上の受け渡し先は約 `(2.6,9.562)`。これは**下側ソファ帯の側面**であり、天板のある内側の開口を向いていない。停止点から天板中心までは約2.80m離れている。

これを「座る側で止まれた」とするのは今回の期待に反する。①④⑤のL字も同じ候補辺生成に依存する。ただし、すべてのL字の現停止点がソファ側だとは主張しない。

修正：型・flip・mirrorごとに合法な受取区間を家具座標で定義し、ソファ帯を除く。辺全体ではなく内側開口や天板への受取線を使う。t2もベンチ側・椅子側・側方の扱いを定義し、全4辺を機械的に許さない。候補点が自由なことと客が受け取れることを分けてテストする。

### ❌ NG [P1] ⓪の待機場所が1台0.8×0.8mを満たさない

⓪JSON：`robot_standby.rect=[12.2,5.2,14.4,5.9]`, slots=3。**2.2×0.7m**なので、3台分の2.4×0.8mに不足。`famires_p0.py:69`で旧JSONの待機場所をそのまま継承する。check_rulesは台数や寸法を検査しない。

またall_obstacles（121–127行）は待機矩形を丸ごと塞ぎ、analyze（393–397行）はその**下側の前までstartから到達できるか**を確認するだけ。内部の各機体位置から1台を抜き出し、残る機体を避けて出る経路は計算していない。これは複数台の動的運用以前に、今回「対応済み」とする駐機からの発車接続が未検証という問題。

修正：⓪の寸法修正、slotsに対する収容検査、各スロットの形状・発車姿勢を定義する。発車対象だけ除いた静的障害物でstartへの接続を確認する。現状の検査は「ドック前に近づける」に改称する。

### ⚠️ 要注意 ⓪の一般卓は椅子・着席位置を持たない

t4/r4は旧JSONの天板寸法を矩形として登録する。通常着席時の椅子と人体が解析に入らず、r4は描画が円なのに解析が外接正方形、配膳辺もその正方形である。天板だけで障害物を構成する⓪と、ベンチを含めて構成する①〜⑤では比較の基準が揃わない。これは対象外の「引かれた椅子」とは別に、通常状態の家具定義の不足。採用前に基準状態と配膳位置を明示する。

## 3. 検査をすり抜ける配置（実行で再現）

以下はprepare済み①、または⓪のコピーを変更し、`check_rules`を直接実行した。**すべて返り値は `[]`（違反なし）**。同梱30件の合格だけではこれらを排除できない。

|反例|変更内容|抜け・修正案|
|---|---|---|
|レジ全欠落|①roomsから「レジ」「レジ内」を削除|234–247行は存在するレジの長さだけ検査。存在と店員領域を必須にする|
|トイレ壁ほぼ全撤去|①のトイレ壁を全部削除し、壁1片 `[0,13.8,0.3,14.1]`だけ追加|227–233行は接する壁が1つあって厚ければ合格。客席側各辺の被覆を開口以外について検査する|
|トイレ入口を壁で塞ぐ|①toilet_doors[0]と同じ矩形を壁として追加しlabelを「トイレの壁」にする|221–224行の名前による全免除。壁の役割ではなく開口の幾何を照合する|
|家具中央の偽接合|①卓5の内部にx方向0.05m、y方向0.3mの壁を置きjoint=True|178行はmin(重なり幅)≤8cmなら家具中央でも許す。接するべき背板の境界・向き・位置を確認する|
|100台のドック|①slotsを100にする|寸法・slots整合検査なし。1台区画、収容台数、正の整数を検査する|
|建物外領域を削除|⓪roomsのkind=outsideを削除、outline維持|outsideとoutlineの整合検査がない。建物外が解析上の床になる|
|outlineを2m四方に|①outlineを `[[0,0],[2,0],[2,2],[0,2]]`にする|実際にはほぼ全家具がoutline外でも合格。outline内包を検査する|

レジの入口からの距離、ドックと受け渡し口の近さ、スタイル別通路幅、レジ内への入口なども数値検査はない。`STYLE_AISLE`は定義されるがcheck_rulesで使わない。「全部の組み合わせに広げた」との説明は過大。壁対部屋・壁対壁を一律禁止するのも不適切なので、接合・被覆等の意図を持つ検査に分けるべき。

### ❌ NG [P2] トイレ遮蔽は席中心から前方領域中心への1本だけ

211–218行のhiddenは、席中心から**toilet_doors（前方の空ける矩形）の中心**への線分が壁に当たるかを見る。実際の扉線や席の受取点・開口全体ではない。席の端から扉が見えていても中心線上の短い壁で合格し得る。複数トイレでは対象トイレとdoorの対応もなく、全doorへの遮蔽を要求する。

修正：トイレと開口のID対応を持たせ、席側区間と扉開口間の遮蔽の条件を決める。少なくとも端点を含む複数線を検査し、単なる1本の中心線を「へだてる壁」の証拠にしない。

### ❌ NG [P2] 見通しの1cmサンプリングは薄い壁を見逃す

94–101行のseg_hitsで、線分 `(0,0)→(1,0)` と壁矩形 `[0.004,-1,0.006,1]` を与えると **False**。線分は壁を横断している。sight（338–345行）も離散サンプルで、1cm未満の障害物や端点付近を見逃し得る。現在の生成壁が主に15/30cmだからといって検査関数の正しさは保証されない。線分と矩形の解析的交差を使う。

### ❌ NG [P2] best_stopは201番目以降の合法点を捨てる

349–363行、`order[:200]`で見通し判定を打ち切る。最短側200点が遮られ、その先だけ見通せると、本当は届く辺を届かないとする。候補201点・距離0〜200・最後の1点だけsight=Trueのダミーgridで、**best_stopはNone**になった。これは候補選択ロジックの単体反例であり、現6配置がこれで誤分類されたとまでは主張しない。上限を外すか、全候補の見通しを幾何的に一括判定する。

### ❌ NG [P2] NG時も過去の画像・JSONが残る

main:641–647行は出力をスキップするだけで既存ファイルを削除・隔離しない。成功出力のあるディレクトリで配置を不正にして再実行するとsummaryはNGでも古いlayout/analysis/PNGを読める。利用側がsummaryを確認しなければ不正な版を使用し得る。

修正：一時ディレクトリに全出力し、検証後に世代単位で切り替える。NG配置の既存成果物は隔離し、manifestの世代IDをlayout/analysisに持たせる。構造違反を発見しても重い解析に進む現順序も、検査後停止に変更するとよい。

## 4. ⓪の非矩形建物

### ✅ OK 現データはoutlineとoutsideで同じ欠け部分を表す

outlineは24×19.2mの外接矩形から左奥 `[0,17,7.6,19.2]` を除く形。outsideの矩形もこの部分で、all_obstaclesを経てGridに入るため、現データでこの欠け部分を自由床として扱っているわけではない。⓪の再生成・両解像度の分類も一致した。

### ❌ NG outlineは描画専用、検査と経路はoutsideの手入力依存

`check_rules:155–165`と`Grid:260–279`はouterの矩形とrooms等だけを見る。outlineはdraw:501–509行で使用する。上記2反例のように描画輪郭と解析の床が別々に変えられるため、「outline対応の検査」にはなっていない。

修正：outlineを形状の唯一の根拠とし、外領域マスクと境界距離を生成する。または少なくともoutsideの和集合とoutlineの補集合が一致することを自動検証し、全家具・ドック・start・入口・doorの内外を同じ形状に対して検査する。⓪固有のテストは現在test_famires_v3.pyにないので、この不一致を回帰テストに追加する。

## 5. 数字の解釈と仕様の注意

### ⚠️ 要注意 公表通過幅を半径として使うのはモデル化の仮定

pass_widthとnormal_widthは、Grid.freeでそれぞれ直径相当の円形余裕に変換する。これは公表幅そのものが任意の角・停止姿勢での円形通過を保証するという意味ではない。今回対象外の旋回等の再現を要求するものではなく、試算の数字にこの仮定を明記すべきという指摘。

基本値のBellaBot 80cmは指定の[Pudu Academyページ](https://academy.pudutech.com/en/docs/huan-le-song-chan-pin-jian-jie)に記載されるが、ページ見出しはPuduBotで、本文のBellaBot表記とも混在する。機種・型番に対応する一次資料を固定するとよい。[Keenbot T8のメーカー掲載ページ](https://www.irisohyama.co.jp/b2b/robotics/products/keenbot-t8/)では55cm以上とともに環境や利用方法で必要幅が異なる旨が示される。全6機種の価格・仕様・一次出典の網羅的検証は今回実施していない。

### ⚠️ 要注意 2解像度で分類が一致しても幅・距離の保証ではない

freeは `clear >= width/2-res/2`（282–284行）。意図的な許容誤差があり、pathのnarrowestは通常robotで0.97m、⑤narrowで0.77mという保存値が実際にある。READMEの境界付近を許す計算とは整合するが、「1.0m以上」「80cm以上」を厳密に満たした経路というラベルとは区別すべき。丸め前には0.975/0.775m付近もあるため0.97/0.77という丸め値だけから閾値違反と断定はしない。

648行で比較するのはcategoryだけ。距離、停止辺、停止位置、人の歩く距離、最狭幅が変わっても `resolution_mismatch=[]` となる。分類一致は確認できたが、図の「同じ結果」は「分類一致」と書くのが正確。境界幅・複雑な曲がり角・障害物位置のずらしを加え、経路線分全体の余裕も確認するとよい。

### ⚠️ 要注意 human_walkは最も都合のよい受渡位置からの片道距離

416–431行は通常ロボットが到達できる全セルを距離0の出発点とし、人の最短距離を求める。下限的な歩行距離であり、実際の受け渡し場所、そこまでのロボット走行距離、往復・頻度の総計ではない。narrowだけで届く領域は受渡候補にしない。どちらの運用を想定するか明記する。全6配置でhuman=0なので、今回の再現結果だけではこの枝を検証できない。

## 必要な追加テスト

現30件は過去の既知例と直線通路の3つのずらし位置に偏り、合格をもって一般的な正しさを主張できない。優先して次を追加する。

1. ⓪T02等のL字のflip/mirror全組合せで、ソファ側停止を排除し天板側停止を選ぶ。
2. ⓪のドック寸法、全slotからの静的発車接続、slots過大値を検査する。
3. 上表7つの改変を不合格にする。特に壁名変更で遮蔽物検査が無効にならないこと。
4. outline/outsideの欠落・矛盾、startやドックのoutline外配置を不合格にする。
5. 薄い壁の線分交差、201番目だけ合法な停止点、human経路あり/なしを単独で確かめる。
6. 既存出力のある場所で成功→NGを実行し、古いlayout/analysisを現行版として利用できないことを確かめる。

## 試算の材料に使ってよいか

**❌ 到達率や運用負担の比較値として、そのまま採用しない。** 6配置の数値は再現でき、前回の離散膨張と⑤の卓数差は直っている。一方、今回の中心である「客が受け取れる停止点」に実配置の反例があり、⓪は駐機ルールにも未適合。

修正順は **①L字/t2等の受取区間 → ②⓪のドックと発車接続 → ③トイレ・joint・必須設備・非矩形の検査 → ④停止点探索/見通しの完全性 → ⑤NG時の出力世代管理**。修正後に6配置を両解像度で再出力し、今回の反例が落ちることを確認してから採用する。

## 2026-10-22 再検証記録

指定のDownloads内の依頼書を再読し、tools内の原本と前回検証用コピーに差分がないことを確認した。コピー側のtools/sim_layoutsでtest_famires_v3.pyとfamires_v3.pyを再実行し、いずれも終了コード0。同梱テスト30件は再びすべて合格した。summary全体と6配置のanalysis.tablesは同梱値と一致し、分類の解像度差はすべて空だった。

上記の改変反例、薄壁を横切る線分、201番目だけ合法な停止点も再実行し、同じ見逃しを確認した。結論・優先順位は維持する。日本語フォント不足による描画警告は今回もあり、画素一致の検証はしていない。対象原本は変更していない。
```

### 3回目の依頼文（codex_review_request_v4.md）
```markdown
# 敵対的検証の依頼（3回目）：ファミレスの店の形 第3版

前回の検証結果（`review_result_v3.md`）を受けて直しました。前回と同じく、反例を探す立場で見てください。
決まりと計算の約束は `tools/sim_layouts/README.md` にまとめています（2026-10-22 の節が今回の変更）。

再現：`cd tools/sim_layouts && python3 test_famires_v3.py && python3 famires_v3.py`（出力は `public/sim/layouts/`。依存の版は README）

店の呼び方：ファイル名の p0〜p5 で書いてください。前回の結果の ⓪〜⑤ は p0〜p5 と同じです（サイトの画面では ②〜⑦ と表示しています）。

## 前回の指摘への対応

| 指摘 | 対応 |
|---|---|
| [P1] L字席でソファ側への停止を「届く」とする | L字席で止まってよい辺を、テーブルのある内側の角の開いた2辺と、そこからソファの端に沿って0.6m（端の席の人が受け取れる所）までに限った（`lsofa_sides`。flip・mirror の4通りをテストで確かめる）。p0 T02 は角の正面（右の辺の開いた部分）に止まるようになった |
| t2 の全4辺 | 椅子の側とテーブルの横だけにした。ベンチの側・ベンチの横は使わない（`t2_sides`） |
| [P1] p0 の待機場所 2.2×0.7m | 2.4×0.8m（3台）にした。1台分0.8×0.8m以上・台数が正の整数かを確かめる。1台ずつ、その台の区画だけを障害物から外し、ほかの台が止まったままで出発点までつながるかを確かめる（つながりは上下左右）。前だけふさぐと真ん中の台だけ、前と両横をふさぐと3台とも見つかることをテストで確かめる |
| 検査をすり抜ける7つの配置 | 7つとも違反として見つかることをテストで確かめる。レジとレジ内の有無と接していること／トイレの4辺がすきまなく囲まれていること（外壁・裏の部屋・建物の外・厚さ30cm以上の壁・入口のどれか）／入口の前は名前で免除しない・入口そのものを壁がふさがない／仕切りの接合はボックスのベンチの背の辺で深さ8cm以内だけ／台数／輪郭と建物の外の一致（5cmのマス目で照合）・輪郭の外に壁・待機場所・受け渡し口・出発点を置かない |
| [P2] トイレ遮蔽が中心の1本 | 入口の情報とトイレを「接しているか」で対応させ、トイレ1つに入口1つ。席のトイレ側の辺の両端から、入口（入口の前の矩形とトイレの境目の線分）の両端へ引いた4本の線が、全部壁（接合でない壁）に当たることにした |
| [P2] 1cmサンプリングが薄い壁を見逃す | 線と矩形の交わりを計算で求める（`seg_rect_hit`）。見通し（`sight`）も同じ |
| [P2] best_stop が201番目以降を捨てる | 打ち切りをなくした。201番目だけ見通せる偽のマス目でテストする |
| [P2] NG 時も過去の出力が残る | いったん作業用のフォルダに出し、6つ全部が通ったときだけ出力先へ入れ替える。違反があれば出力先には何も書かない（前の版の一式がそのまま残る）。決まりに違反があれば重い計算に進まない。layout・analysis・summary に同じ版の番号（`generation`）を入れる |
| ⚠️ 2解像度は分類一致だけ | 図の文言を「卓の分け方が同じ」に改め、README に「距離や止まる位置が同じとは限らない」「narrowest が 0.97m のように 1.0m をわずかに下回ることがある」と書いた |

## エイイチの指摘で直したこと（p0 の店の形）

- パントリーの上の通路に出入りできなかったので、出入り口を2つ開けた。A＝パントリーの壁に1m（客席側のトイレへの通路とつながる）、B＝パントリーの上の端。どちらも扉はない。扉は上の通路から洗い場とキッチンへ入る所（位置は仮）。JSON の `layout.doors`（`door` が扉の有無）
- 仕切り壁の上の4人席2つ（T07・T08）を、壁との間1.0mから0.6mに寄せた（ロボットが壁側から入っていたため）

数字が変わったのは p0 だけです（T01・T02・T03・T05・T06・T07・T08・T18 の距離。平均8.5→8.6m、最長14.01→13.78m）。p1〜p5 は卓・距離・止まる位置が前と同じです。

## まだ対応していないこと（今回の検証の対象外）

厨房の中の人の動き、すれ違い・複数台・旋回、通路の一時的な狭まり、壁の高さ（仮の値）、サイトと Unity の共通のデータの形、p0 の一般卓（t4・r4・long6）の椅子と着席位置（前回の ⚠️。次の段階で扱う）、公表通過幅を円の半径として使う仮定と機種の一次資料（前回の ⚠️）、human_walk の意味（全配置で0のため）。

## 見てほしいこと

1. 上の対応で、前回の指摘が本当に直っているか。直ったように見えて別の抜けが出ていないか
2. 新しく足した確かめ（トイレの囲み・4本の線・輪郭・接合・待機場所の発車・出力の入れ替え）を、すり抜ける置き方を作れるか
3. L字席の「開いた2辺＋ソファの端0.6m」と、2人席の「椅子の側とテーブルの横」の決め方に、止まれるが客が受け取れない反例があるか
4. 6つの数字（`public/sim/layouts/summary.json`）が、決まりと計算の約束から見て正しいか
5. p0 の出入り口 A・B と、寄せた4人席で、決まりやロボットの計算に抜けがないか

結果の書き方は前回と同じ（✅／⚠️／❌、最後に「試算の材料に使ってよいか」）。結果は `review_result_v4.md` に書いてください。
```


## コード全量

サイト側（GitHub `PixelstoMotors/p2m-site`、commit 298de98 の時点）と試算 v2.5 のコードです。

### tools/sim_layouts/famires_v3.py
```python
"""ファミレスの店の形 第3版：決まりの確かめ方とロボットの届き方を作り直した版。

codex の敵対的検証（review_result.md）を受けて直したこと
- ロボットの通れる幅は機種の公表値（初期値 BellaBot 80cm）。本体の幅とは別に扱う
- 「ふだん走らせる幅」（初期値 1.0m）を別に持ち、卓を3つに分ける
    ロボットが届く卓／狭い道なら届く卓／人が運ぶ卓（ロボットから受け取って人が運ぶ）
- 届くかは、卓の「座る側（開いた側）」の前で止まれるかで判定する。背中側・壁側・仕切り越しは数えない
- 幅の判定は、マス目の中心から障害物までの距離で行い、2つの細かさで同じ結果になるかを毎回確かめる
- 斜めのすり抜けを禁止する。出発点が通れない場所なら止める
- 待機・充電の場所は、止まっているロボットごと障害物として扱う
- 決まりの確かめを全部の組み合わせに広げ、違反があれば図とデータを出さずに止める

codex の2回目の検証（review_result_v3.md）を受けて直したこと（2026-10-22）
- L字席：止まってよいのは、内側の角の開いた2辺と、そこからソファの端に沿って0.6mまで（ソファの横は使わない）
- 2人席：止まってよいのは、椅子の側とテーブルの横（ベンチの横は使わない）
- 待機場所：1台分が0.8×0.8m以上か、台数が正しいかを確かめる。1台ずつ、ほかの台が止まったままで受け渡し口の前まで出られるかを確かめる

使い方:  python3 famires_v3.py            （出力はサイトの中なら public/sim/layouts/、外なら variants3/）
          python3 famires_v3.py Servi      （機種を替えて試算）
"""
import hashlib
import json
import math
import os
import shutil
import sys
import tempfile
from pathlib import Path

import numpy as np
from scipy import ndimage, sparse
from scipy.sparse.csgraph import dijkstra

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle, Circle, Polygon

import famires_gen2 as gen
import famires_p0

plt.rcParams["font.family"] = "Noto Sans CJK JP"
HERE = Path(__file__).parent

SCHEMA = "famires-layout/0.3"

# 機種ごとの公表値（最小通過幅）。出典は README と同じ
ROBOT_MODELS = {
    "BellaBot": dict(pass_width=0.80, body_width=0.50, maker="Pudu Robotics", source="https://academy.pudutech.com/en/docs/huan-le-song-chan-pin-jian-jie"),
    "KettyBot": dict(pass_width=0.55, body_width=0.435, maker="Pudu Robotics", source="https://academy.pudutech.com/en/docs/chan-pin-jian-jie"),
    "Keenbot T8": dict(pass_width=0.55, body_width=0.384, maker="KEENON", source="https://www.irisohyama.co.jp/b2b/robotics/products/keenbot-t8/"),
    "KEENON T10": dict(pass_width=0.585, body_width=None, maker="KEENON", source="https://meetsmore.com/products/keenon-t10"),
    "Servi": dict(pass_width=0.60, body_width=None, maker="Bear Robotics", source="https://innovatopia.jp/robot/robot-news/118331/"),
    "Servi Q": dict(pass_width=0.45, body_width=0.35, maker="Bear Robotics", source="https://innovatopia.jp/robot/robot-news/118331/"),
}

SETTINGS = dict(
    model="BellaBot",
    normal_width=1.0,     # ふだん走らせる通路の幅（店の運用の設定）
    human_width=0.5,      # 人が通れる幅（人が運ぶ卓の歩く距離を測るとき）
    res=0.025,            # 計算のマス目（m）。幅 w 以上の通路は必ず通れる、w−res 未満は必ず通れない
    check_res=0.0125,     # 確かめ用の細かいマス目（m）。2つで結果が分かれた卓は ? 印
    stop_reach=0.3,       # 通路の幅いっぱいから、さらに卓へ寄れる余裕（止まる位置の帯の厚み）
    side_end_margin=0.15, # 座る側の両端から、この長さは止まる位置に使わない
    toilet_min_gap=1.0,   # トイレからこの距離「以内」に席を置かない（ちょうど1mも不可）
    toilet_door_depth=1.2,
    entrance_depth=1.5,   # 入口から店の中へ、この奥行きは何も置かない
    dish_front_depth=1.2, # 料理の受け渡し口の前、この奥行きは何も置かない
    joint_max_depth=0.08, # 仕切りが家具に食い込んでよい深さ（背もたれの上に立つ仕切り）
    register_min_len=2.5,
    lsofa_end_reach=0.6,  # L字席：開いた辺から、ソファの端に沿ってこの長さまで止まってよい（端の席の人が受け取れる所）
    standby_slot=0.8,     # 待機場所の1台分の最小の大きさ（m×m）
)

FURNITURE_TYPES = {
    "booth": dict(desc="ボックス席。向かい合うベンチ2つと間のテーブル。座る側（開いた側）は長い2辺。短い2辺はベンチの背",
                  bench_depth=0.55, table_depth=0.75, length=1.85, bench_length={"4": 1.2, "6": 1.8},
                  axis={"v": "ベンチが上下（座る側は左右）", "h": "ベンチが左右（座る側は上下）"}),
    "t2": dict(desc="2人席。壁側がベンチ、通路側が椅子。止まってよいのは椅子の側と、テーブルの横（ベンチの横は使わない）",
               footprint=[0.8, 1.4], table=[0.7, 0.6], bench_depth=0.45, chair_radius=0.18),
    "booth6L": dict(desc="L字ソファの6人席。ソファの内側の角にテーブル（約0.75m×1.2m）。止まってよいのは、内側の角の開いた2辺と、"
                         "そこからソファの端に沿って lsofa_end_reach（端の席の人が受け取れる所）まで",
                    sofa_depth_ratio={"band": 0.45, "column": 0.55}),
    "walls": dict(height_m_仮={"壁": 2.7, "仕切り": 1.2, "トイレの壁": 2.7}),
}

STYLE_AISLE = {"効率重視": 1.2, "ゆったり": 1.5}


# ================================================================ 形の道具
def fp(t):
    return (t["x"], t["y"], t["x"] + t["w"], t["y"] + t["h"])


def inter(a, b):
    """2つの矩形の重なり（x方向の深さ, y方向の深さ）。重ならなければ None"""
    dx = min(a[2], b[2]) - max(a[0], b[0])
    dy = min(a[3], b[3]) - max(a[1], b[1])
    return (dx, dy) if dx > 1e-6 and dy > 1e-6 else None


def gap(a, b):
    dx = max(0.0, max(a[0], b[0]) - min(a[2], b[2]))
    dy = max(0.0, max(a[1], b[1]) - min(a[3], b[3]))
    return math.hypot(dx, dy)


def seg_rect_hit(a, b, r, open_rect=False, eps=1e-9):
    """線分 a-b が矩形 r に交わるか（計算で求める。点を並べて確かめると、薄い壁を見逃すため）
    線分の両端だけで触れる場合は数えない。open_rect=True なら、矩形の縁に触れるだけの線も数えない"""
    x0, y0, x1, y1 = r
    if open_rect:
        x0, y0, x1, y1 = x0 + eps, y0 + eps, x1 - eps, y1 - eps
    dx, dy = b[0] - a[0], b[1] - a[1]
    t0, t1 = 0.0, 1.0
    for p, q in ((-dx, a[0] - x0), (dx, x1 - a[0]), (-dy, a[1] - y0), (dy, y1 - a[1])):
        if abs(p) < 1e-15:
            if q < 0:
                return False
        else:
            t = q / p
            if p < 0:
                t0 = max(t0, t)
            else:
                t1 = min(t1, t)
            if t0 > t1:
                return False
    # 交わる区間 [t0, t1] が、線分の内側（両端を除く）にかかっているか
    return t1 > 1e-6 and t0 < 1 - 1e-6 and (t1 - t0 > 1e-9 or 1e-6 < t0 < 1 - 1e-6)


def seg_hits(a, b, rects):
    """線分 a-b が矩形のどれかを通るか（縁に触れる線も通るとみなす）"""
    return any(seg_rect_hit(a, b, r) for r in rects)


def touch_segment(a, b):
    """2つの矩形が辺で接しているとき、接している線分（((x0,y0),(x1,y1))）。接していなければ None"""
    x0, x1 = max(a[0], b[0]), min(a[2], b[2])
    y0, y1 = max(a[1], b[1]), min(a[3], b[3])
    if x1 < x0 - 1e-9 or y1 < y0 - 1e-9:
        return None
    if abs(x1 - x0) < 1e-9 and y1 - y0 > 1e-6:
        return ((x0, y0), (x0, y1))
    if abs(y1 - y0) < 1e-9 and x1 - x0 > 1e-6:
        return ((x0, y0), (x1, y0))
    return None


def contains(r, x, y, e=1e-9):
    return r[0] - e <= x <= r[2] + e and r[1] - e <= y <= r[3] + e


def entrance_zone(L, depth):
    e, W, D = L["entrance"], L["outer"]["w"], L["outer"]["d"]
    return {"bottom": (e["a0"], 0, e["a1"], depth), "top": (e["a0"], D - depth, e["a1"], D),
            "left": (0, e["a0"], depth, e["a1"]), "right": (W - depth, e["a0"], W, e["a1"])}[e["side"]]


def _seg(axis, c, lo, hi, n):
    """辺の一部。axis="v" は x=c の縦の辺（y が lo〜hi）、"h" は y=c の横の辺（x が lo〜hi）。n は外向き（+1/−1）"""
    if axis == "v":
        return ((c, lo), (c, hi), (n, 0))
    return ((lo, c), (hi, c), (0, n))


def lsofa_sides(t):
    """L字席の止まってよい辺（2026-10-22 エイイチと決めた：a＋b）
    a：テーブルのある内側の角の、開いた2辺
    b：そこからソファの端に沿って lsofa_end_reach まで（端の席の人が受け取れる所）
    変形前（flip・mirror なし）は、下の帯（高さ0.45h）と左の列（幅0.55w）がソファで、右上の角が開いている"""
    x, y, w, h = t["x"], t["y"], t["w"], t["h"]
    # 止まる位置の帯は両端から side_end_margin を除くので、b の側はその分を足して、止まる位置が角から reach 以内に来るようにする
    ext = SETTINGS["lsofa_end_reach"] + SETTINGS["side_end_margin"]
    segs = {"right": ["v", x + w, max(y, y + 0.45 * h - ext), y + h, 1],
            "top": ["h", y + h, max(x, x + 0.55 * w - ext), x + w, 1]}
    out = {}
    for k, (axis, c, lo, hi, n) in segs.items():
        if t.get("flip"):        # 上下を返す
            if axis == "v":
                lo, hi = 2 * y + h - hi, 2 * y + h - lo
            else:
                c, n, k = 2 * y + h - c, -n, "bottom"
        if t.get("mirror"):      # 左右を返す
            if axis == "h":
                lo, hi = 2 * x + w - hi, 2 * x + w - lo
            else:
                c, n, k = 2 * x + w - c, -n, "left"
        out[k] = _seg(axis, c, lo, hi, n)
    return out


def t2_sides(t):
    """2人席の止まってよい辺：椅子の側（ベンチの反対）と、両横のテーブル・椅子の部分。ベンチの横とベンチの側は使わない"""
    x0, y0, x1, y1 = fp(t)
    b = FURNITURE_TYPES["t2"]["bench_depth"]
    s = t["side"]   # ベンチ（壁）の側
    if s == "bottom":
        return {"top": _seg("h", y1, x0, x1, 1), "left": _seg("v", x0, y0 + b, y1, -1), "right": _seg("v", x1, y0 + b, y1, 1)}
    if s == "top":
        return {"bottom": _seg("h", y0, x0, x1, -1), "left": _seg("v", x0, y0, y1 - b, -1), "right": _seg("v", x1, y0, y1 - b, 1)}
    if s == "left":
        return {"right": _seg("v", x1, y0, y1, 1), "bottom": _seg("h", y0, x0 + b, x1, -1), "top": _seg("h", y1, x0 + b, x1, 1)}
    return {"left": _seg("v", x0, y0, y1, -1), "bottom": _seg("h", y0, x0, x1 - b, -1), "top": _seg("h", y1, x0, x1 - b, 1)}


def table_sides(t):
    """座る側（ロボットが止まってよい辺）の候補
    booth：長い2辺だけ（短い辺はベンチの背）／booth6L：lsofa_sides／t2：t2_sides／t4・r4・long6：4辺"""
    x0, y0, x1, y1 = fp(t)
    sides = {"left": ((x0, y0), (x0, y1), (-1, 0)), "right": ((x1, y0), (x1, y1), (1, 0)),
             "bottom": ((x0, y0), (x1, y0), (0, -1)), "top": ((x0, y1), (x1, y1), (0, 1))}
    if t["type"] == "booth":
        keep = ("left", "right") if t["axis"] == "v" else ("bottom", "top")
        return {k: v for k, v in sides.items() if k in keep}
    if t["type"] == "booth6L":
        return lsofa_sides(t)
    if t["type"] == "t2":
        return t2_sides(t)
    return sides


def standby_slots(sb):
    """待機場所を1台ずつの区画に分ける（長い方の向きに slots 等分）"""
    x0, y0, x1, y1 = sb["rect"]
    n = sb["slots"]
    if (x1 - x0) >= (y1 - y0):
        return [[x0 + (x1 - x0) * i / n, y0, x0 + (x1 - x0) * (i + 1) / n, y1] for i in range(n)]
    return [[x0, y0 + (y1 - y0) * i / n, x1, y0 + (y1 - y0) * (i + 1) / n] for i in range(n)]


def all_obstacles(L, skip=None, standby=None):
    """障害物の矩形と名前。skip は除く卓の番号。standby を渡すと、待機場所の矩形の代わりにそれ（区画の並び）を使う"""
    obs = [(r["rect"], "部屋:" + r["label"]) for r in L["rooms"]]
    obs += [(w["rect"], "壁:" + w["label"]) for w in L["walls"]]
    obs += [(fp(t), f"卓:{t['id']}") for i, t in enumerate(L["tables"]) if i != skip]
    if standby is None:
        obs += [(L["robot_standby"]["rect"], "待機場所")]
    else:
        obs += [(tuple(r), "待機場所") for r in standby]
    return obs


def open_sides(L, i, probe=0.2):
    """座る側のうち、外側 probe の帯に何もない辺（壁・仕切り・ほかの卓・店の外がない辺）"""
    t = L["tables"][i]
    W, D = L["outer"]["w"], L["outer"]["d"]
    obs = [o for o, _ in all_obstacles(L, skip=i)]
    out = {}
    for k, ((ax, ay), (bx, by), (nx, ny)) in table_sides(t).items():
        e = 0.1
        if nx:  # 縦の辺
            band = (min(ax, ax + nx * probe), ay + e, max(ax, ax + nx * probe), by - e)
        else:
            band = (ax + e, min(ay, ay + ny * probe), bx - e, max(ay, ay + ny * probe))
        outside = band[0] < -1e-6 or band[1] < -1e-6 or band[2] > W + 1e-6 or band[3] > D + 1e-6
        if not outside and not any(inter(band, o) for o in obs):
            out[k] = ((ax, ay), (bx, by), (nx, ny))
    return out


# ================================================================ 決まりの確かめ
def joint_ok(t, w):
    """仕切りが卓に食い込んでよいのは、ボックス席のベンチの背（axis=v は上下の辺、h は左右の辺）の上に立つときだけ。
    食い込みはその辺に接し、深さ joint_max_depth 以内（家具の真ん中に置いた仕切りは認めない）"""
    if not (w.get("joint") and t["type"] == "booth"):
        return False
    A, R = fp(t), w["rect"]
    ox0, oy0, ox1, oy1 = max(A[0], R[0]), max(A[1], R[1]), min(A[2], R[2]), min(A[3], R[3])
    dmax, e = SETTINGS["joint_max_depth"] + 1e-6, 1e-6
    if t["axis"] == "v":
        return (oy1 - oy0) <= dmax and (oy0 <= A[1] + e or oy1 >= A[3] - e)
    return (ox1 - ox0) <= dmax and (ox0 <= A[0] + e or ox1 >= A[2] - e)


def _edge_points(a, b, step=0.025, margin=0.025):
    """線分 a-b の上の点（両端から margin を除いて step ごと）"""
    n = math.hypot(b[0] - a[0], b[1] - a[1])
    k = max(1, int((n - 2 * margin) / step) + 1)
    return [(a[0] + (b[0] - a[0]) * (margin + i * step) / n, a[1] + (b[1] - a[1]) * (margin + i * step) / n)
            for i in range(k) if margin + i * step <= n - margin + 1e-9]


def facing_edge(r, p):
    """矩形 r の4辺のうち、点 p の方を向いている辺の両端"""
    x0, y0, x1, y1 = r
    edges = [((x0, y0), (x0, y1)), ((x1, y0), (x1, y1)), ((x0, y0), (x1, y0)), ((x0, y1), (x1, y1))]

    def dist(e):   # 点 p から辺までの距離
        (ax, ay), (bx, by) = e
        return math.hypot(max(ax - p[0], 0, p[0] - bx), max(ay - p[1], 0, p[1] - by))
    return min(edges, key=dist)


def _gaps(lo, hi, ivs):
    """区間 [lo, hi] のうち、区間の並び ivs のどれにも覆われていない所（長さ1e-6より長いもの）"""
    gaps, cur = [], lo
    for a, b in sorted((max(a, lo), min(b, hi)) for a, b in ivs if min(b, hi) > max(a, lo)):
        if a > cur + 1e-6:
            gaps.append((cur, a))
        cur = max(cur, b)
    if hi > cur + 1e-6:
        gaps.append((cur, hi))
    return gaps


def _line_cover(r, axis, c, lo, hi):
    """矩形 r が、線（axis="h" は y=c で x が lo〜hi、"v" は x=c で y が lo〜hi）のどの区間に重なるか。重ならなければ None"""
    x0, y0, x1, y1 = r
    if axis == "h":
        if y0 - 1e-9 <= c <= y1 + 1e-9 and min(x1, hi) > max(x0, lo) + 1e-9:
            return (max(x0, lo), min(x1, hi))
    else:
        if x0 - 1e-9 <= c <= x1 + 1e-9 and min(y1, hi) > max(y0, lo) + 1e-9:
            return (max(y0, lo), min(y1, hi))
    return None


def _seg_axis(seg):
    """軸に沿った線分を (axis, c, lo, hi) にする"""
    (ax, ay), (bx, by) = seg
    if abs(ay - by) < 1e-9:
        return "h", ay, min(ax, bx), max(ax, bx)
    return "v", ax, min(ay, by), max(ay, by)


def check_toilets(L):
    """トイレの決まり（点を並べて確かめると点の間を見逃すので、辺は区間として計算で確かめる）
    - 入口の情報（toilet_doors：入口の前の空ける所）は、どれか1つのトイレに接している。トイレ1つに入口は1つ
    - 入口の前の矩形は、トイレとの境目の幅いっぱいで、奥行き toilet_door_depth（1.2m）以上
    - 入口の前は何も置かない（名前で免除しない。トイレの部屋だけは接してよい）。入口そのもの（トイレとの境目）に壁が重ならない
    - トイレの4辺は、外壁・裏の部屋・建物の外・壁（厚さ0.3m以上）・入口のどれかで、すきまなく囲まれている
    - トイレから1m以内の席は、席のトイレ側の辺の両端から入口の両端へ引いた4本の線が、全部壁に当たること（目隠しの壁）。
      4本の線は近似で、壁の切れ目から入口が見える置き方は見落とし得る（README）"""
    S = SETTINGS
    W, D = L["outer"]["w"], L["outer"]["d"]
    v = []
    rooms, walls = L["rooms"], L["walls"]
    toilets = [r["rect"] for r in rooms if r["label"] == "トイレ"]
    doors = L.get("toilet_doors", [])
    door_of = {}
    for dz in doors:
        hit = [i for i, tr in enumerate(toilets) if touch_segment(dz, tr)]
        if not hit:
            v.append(("トイレ", f"入口の情報 {list(dz)} がどのトイレにも接していない"))
        for i in hit:
            door_of.setdefault(i, []).append(dz)
    for i, tr in enumerate(toilets):
        if len(door_of.get(i, [])) != 1:
            v.append(("トイレ", f"トイレ {list(tr)} の入口の情報が{len(door_of.get(i, []))}件（1件にする）"))

    # 入口の前・入口そのもの
    for dz in doors:
        for o, name in all_obstacles(L):
            if name == "部屋:トイレ":
                continue
            if inter(dz, o):
                v.append(("トイレ", f"{name} がトイレの入口の前をふさいでいる"))
    door_edge = {}
    for i, tr in enumerate(toilets):
        if len(door_of.get(i, [])) != 1:
            continue
        dz = door_of[i][0]
        seg = touch_segment(dz, tr)
        door_edge[i] = seg
        axis, c, lo, hi = _seg_axis(seg)
        # 入口の前の矩形：境目の幅いっぱい、奥行き1.2m以上
        width = (dz[2] - dz[0]) if axis == "h" else (dz[3] - dz[1])
        depth = (dz[3] - dz[1]) if axis == "h" else (dz[2] - dz[0])
        if abs(width - (hi - lo)) > 1e-6:
            v.append(("トイレ", f"トイレ {list(tr)} の入口の前の矩形が、入口の幅（{hi - lo:.2f}m）とそろっていない（{width:.2f}m）"))
        if depth < S["toilet_door_depth"] - 1e-6:
            v.append(("トイレ", f"トイレ {list(tr)} の入口の前の奥行きが{depth:.2f}m（{S['toilet_door_depth']}m以上が要る）"))
        # 入口そのものに壁が重ならない（端で接するだけはよい）
        for w in walls:
            if _line_cover(w["rect"], axis, c, lo, hi):
                v.append(("トイレ", f"トイレ {list(tr)} の入口に {w['label']} が重なっている"))

    # トイレの4辺がすきまなく囲まれているか（区間で計算）
    others = [r["rect"] for r in rooms if r["label"] != "トイレ" and r["kind"] in ("back", "outside")]
    th = lambda r: min(r[2] - r[0], r[3] - r[1])
    for i, tr in enumerate(toilets):
        x0, y0, x1, y1 = tr
        for axis, c, lo, hi in (("h", y0, x0, x1), ("h", y1, x0, x1), ("v", x0, y0, y1), ("v", x1, y0, y1)):
            if (axis == "h" and (abs(c) < 1e-9 or abs(c - D) < 1e-9)) or (axis == "v" and (abs(c) < 1e-9 or abs(c - W) < 1e-9)):
                continue   # 外壁
            ivs = [iv for r in others if (iv := _line_cover(r, axis, c, lo, hi))]
            ivs += [iv for w in walls if th(w["rect"]) >= 0.3 - 1e-6 and (iv := _line_cover(w["rect"], axis, c, lo, hi))]
            if i in door_edge:
                da, dc, dlo, dhi = _seg_axis(door_edge[i])
                if da == axis and abs(dc - c) < 1e-9:
                    ivs.append((dlo, dhi))
            gs = _gaps(lo, hi, ivs)
            if not gs:
                continue
            thin_ws = [w for w in walls if th(w["rect"]) < 0.3 - 1e-6 and _line_cover(w["rect"], axis, c, lo, hi)]
            still = _gaps(lo, hi, ivs + [_line_cover(w["rect"], axis, c, lo, hi) for w in thin_ws])
            if still:
                a, b = still[0]
                where = f"x={a:.3f}〜{b:.3f}, y={c:.3f}" if axis == "h" else f"x={c:.3f}, y={a:.3f}〜{b:.3f}"
                v.append(("トイレ", f"トイレ {list(tr)} の壁に切れ目がある（{where}）"))
            else:
                for w in thin_ws:
                    v.append(("トイレ", f"トイレを囲む壁が薄い：{w['label']}（{th(w['rect']):.2f}m）"))

    # トイレから1m以内の席は、4本の線が全部壁に当たること
    screens = [w["rect"] for w in walls if not w.get("joint")]
    for t in L["tables"]:
        for i, tr in enumerate(toilets):
            g = gap(fp(t), tr)
            if g > S["toilet_min_gap"] + 1e-6:
                continue
            seg = door_edge.get(i)
            hidden = False
            if seg:
                m = ((seg[0][0] + seg[1][0]) / 2, (seg[0][1] + seg[1][1]) / 2)
                ends = facing_edge(fp(t), m)
                hidden = all(seg_hits(s, d, screens) for s in ends for d in seg)
            if not hidden:
                v.append(("トイレ", f"{t['id']} がトイレから{g:.2f}m（{S['toilet_min_gap']}m以内に置くなら、席の両端からトイレの入口の両端への4本の線が全部壁に当たること）"))
    return v


def check_outline(L):
    """建物の形：outline（建物の輪郭）と、建物の外の部屋（kind=outside）が同じ所を表しているか。
    輪郭は縦と横の辺だけ（直交多角形）とし、輪郭・建物の外・外形の座標で区切ったマスごとに照合する（点を並べる照合と違い、細い切れ込みも見逃さない）。
    輪郭の外には、卓・部屋・壁・待機場所・出発点・受け渡し口を置かない"""
    v = []
    W, D = L["outer"]["w"], L["outer"]["d"]
    outs = [r["rect"] for r in L["rooms"] if r["kind"] == "outside"]
    ol = L.get("outline")
    if not ol:
        if outs:
            v.append(("建物の形", "建物の外があるのに、建物の輪郭（outline）がない"))
        return v
    pts = [tuple(map(float, p)) for p in ol]
    if any(not (-1e-6 <= x <= W + 1e-6 and -1e-6 <= y <= D + 1e-6) for x, y in pts):
        v.append(("建物の形", "建物の輪郭が外形（outer）からはみ出している"))
    if len(pts) < 4 or any(abs(a[0] - b[0]) > 1e-9 and abs(a[1] - b[1]) > 1e-9 for a, b in zip(pts, pts[1:] + pts[:1])):
        v.append(("建物の形", "建物の輪郭は、縦と横の辺だけでできた形にする"))
        return v
    from matplotlib.path import Path as MPath
    poly = MPath(np.array(pts))
    xs = sorted({0.0, W} | {p[0] for p in pts} | {c for o in outs for c in (o[0], o[2])})
    ys = sorted({0.0, D} | {p[1] for p in pts} | {c for o in outs for c in (o[1], o[3])})
    xs = [x for x in xs if -1e-9 <= x <= W + 1e-9]
    ys = [y for y in ys if -1e-9 <= y <= D + 1e-9]
    bad = 0.0
    for xa, xb in zip(xs, xs[1:]):
        for ya, yb in zip(ys, ys[1:]):
            if xb - xa < 1e-9 or yb - ya < 1e-9:
                continue
            cx, cy = (xa + xb) / 2, (ya + yb) / 2     # マスの中心は、どの辺の上にも来ない
            inside = poly.contains_point((cx, cy))
            in_out = any(o[0] < cx < o[2] and o[1] < cy < o[3] for o in outs)
            if inside == in_out:
                bad += (xb - xa) * (yb - ya)
    if bad > 0:
        v.append(("建物の形", f"建物の輪郭と建物の外が合っていない（{bad:.4f}m²）"))
    items = [(w["rect"], w["label"]) for w in L["walls"]] + [(L["robot_standby"]["rect"], "待機場所")] + \
            [(d["rect"], d["label"]) for d in L["dish_up"]]
    for r, name in items:
        if any(inter(r, o) for o in outs):
            v.append(("建物の形", f"{name} が建物の外にある"))
    if L.get("start") and any(o[0] < L["start"][0] < o[2] and o[1] < L["start"][1] < o[3] for o in outs):
        v.append(("建物の形", "出発点が建物の外にある"))
    return v


def check_doors(L):
    """店員の出入り口（layout.doors）：壁の切れ目として開けた所（door の有無に関係なく）に、壁が重ならないこと。
    doors は図と Unity のための情報で、ロボットの道の計算には使わない（パントリーなどの部屋の中は、いまも障害物）"""
    v = []
    for d in L.get("doors", []):
        for w in L["walls"]:
            if inter(d["rect"], w["rect"]):
                v.append(("出入り口", f"{d['label']} に {w['label']} が重なっている"))
    return v


def check_rules(L):
    S = SETTINGS
    W, D = L["outer"]["w"], L["outer"]["d"]
    v = []  # (区分, 内容)
    T = L["tables"]
    rooms, walls = L["rooms"], L["walls"]
    inside = lambda r: r[0] >= -1e-6 and r[1] >= -1e-6 and r[2] <= W + 1e-6 and r[3] <= D + 1e-6

    # 店の外にはみ出していないか
    for t in T:
        if not inside(fp(t)):
            v.append(("店の外", f"{t['id']} が店の外にはみ出している"))
    for r in rooms + walls:
        if not inside(r["rect"]):
            v.append(("店の外", f"{r['label']} が店の外にはみ出している"))
    if not inside(L["robot_standby"]["rect"]):
        v.append(("店の外", "待機場所が店の外にはみ出している"))

    # 待機場所：台数は正の整数、1台分は standby_slot×standby_slot 以上
    sb = L["robot_standby"]
    n = sb.get("slots")
    if not isinstance(n, int) or isinstance(n, bool) or n < 1:
        v.append(("待機場所", f"台数が正しくない（{n!r}）"))
    else:
        for r in standby_slots(sb):
            a, b = r[2] - r[0], r[3] - r[1]
            if min(a, b) < S["standby_slot"] - 1e-6:
                v.append(("待機場所", f"1台分が {a:.2f}×{b:.2f}m（{S['standby_slot']}×{S['standby_slot']}m 以上が要る）"))
                break

    # 卓どうし・卓と部屋・卓と壁・卓と待機場所
    for i, a in enumerate(T):
        A = fp(a)
        for b in T[i + 1:]:
            if inter(A, fp(b)):
                v.append(("重なり", f"{a['id']} と {b['id']}"))
        for r in rooms:
            if inter(A, r["rect"]):
                v.append(("重なり", f"{a['id']} と {r['label']}"))
        for w in walls:
            o = inter(A, w["rect"])
            if o and not joint_ok(a, w):
                v.append(("重なり", f"{a['id']} と {w['label']}（{min(o):.2f}m 食い込み）"))
        if inter(A, L["robot_standby"]["rect"]):
            v.append(("重なり", f"{a['id']} と 待機場所"))

    # 部屋どうし・部屋と待機場所
    for i, a in enumerate(rooms):
        for b in rooms[i + 1:]:
            if inter(a["rect"], b["rect"]):
                v.append(("重なり", f"{a['label']} と {b['label']}"))
        if inter(a["rect"], L["robot_standby"]["rect"]):
            v.append(("重なり", f"{a['label']} と 待機場所"))
    for w in walls:
        if inter(w["rect"], L["robot_standby"]["rect"]):
            v.append(("重なり", f"{w['label']} と 待機場所"))

    # 入口の前・受け渡し口の前は空ける
    ez = entrance_zone(L, S["entrance_depth"])
    for o, name in all_obstacles(L):
        if inter(ez, o):
            v.append(("入口の前", f"{name} が入口から{S['entrance_depth']}m以内にある"))
    for d in L["dish_up"]:
        x0, y0, x1, y1 = d["rect"]
        dz = (x0, y0 - S["dish_front_depth"], x1, y0)
        for o, name in all_obstacles(L):
            if inter(dz, o) and not name.startswith("部屋:パントリー"):
                v.append(("受け渡し口の前", f"{name} が受け渡し口の前{S['dish_front_depth']}m以内にある"))

    v += check_toilets(L)

    # 入口まわり：待合・レジ（カウンターと店員の立つ所が両方あり、接している）・ドリンクバー
    labels = [r["label"] for r in rooms]
    if "待合" not in labels:
        v.append(("入口まわり", "待合がない"))
    regs = [r["rect"] for r in rooms if r["label"] == "レジ"]
    staffs = [r["rect"] for r in rooms if r["label"] == "レジ内"]
    if not regs:
        v.append(("入口まわり", "レジがない"))
    if not staffs:
        v.append(("入口まわり", "レジの内側の店員の立つ所（レジ内）がない"))
    for x0, y0, x1, y1 in regs:
        if max(x1 - x0, y1 - y0) < S["register_min_len"] - 1e-6:
            v.append(("入口まわり", f"レジが短い（{max(x1 - x0, y1 - y0):.2f}m）"))
        if staffs and not any(touch_segment((x0, y0, x1, y1), s) for s in staffs):
            v.append(("入口まわり", "レジ内がレジに接していない"))
    if "ドリンクバー" not in labels:
        v.append(("入口まわり", "ドリンクバーがない"))

    v += check_outline(L)
    v += check_doors(L)

    # 座る側が1つもない卓
    for i, t in enumerate(T):
        if not open_sides(L, i):
            v.append(("座れない卓", f"{t['id']} の座る側がすべてふさがっている"))
    return v


# ================================================================ ロボットの届き方
class Grid:
    def __init__(self, L, res, standby=None):
        self.res = res
        W, D = L["outer"]["w"], L["outer"]["d"]
        self.nx, self.ny = int(math.ceil(W / res - 1e-9)), int(math.ceil(D / res - 1e-9))
        xs = (np.arange(self.nx) + 0.5) * res
        ys = (np.arange(self.ny) + 0.5) * res
        self.xs, self.ys = xs, ys
        # 障害物までの距離を矩形から正確に出す（マス目に塗ってから測ると、縁が最大1マスずれるため）
        X, Y = np.meshgrid(xs, ys)
        clear = np.minimum(np.minimum(X, W - X), np.minimum(Y, D - Y))   # 外壁まで
        self.obs = [o for o, _ in all_obstacles(L, standby=standby)]
        for x0, y0, x1, y1 in self.obs:
            dx = np.maximum(np.maximum(x0 - X, X - x1), 0.0)
            dy = np.maximum(np.maximum(y0 - Y, Y - y1), 0.0)
            np.minimum(clear, np.hypot(dx, dy), out=clear)
        self.clear = clear
        self.occ = clear <= 1e-9

    def free(self, width):
        # 幅 width 以上の通路は必ず通れる。width−res 未満は必ず通れない（マス目の中心が通路の真ん中に来るとは限らないため）
        return self.clear >= width / 2 - self.res / 2 - 1e-9

    def cell(self, x, y):
        return int(y / self.res), int(x / self.res)

    def graph(self, free):
        ny, nx = free.shape
        idx = np.arange(ny * nx).reshape(ny, nx)
        rows, cols, wts = [], [], []
        for dy, dx in ((0, 1), (1, 0), (1, 1), (1, -1)):
            a = free[max(0, -dy):ny - max(0, dy), max(0, -dx):nx - max(0, dx)]
            ys0, ys1 = max(0, -dy), ny - max(0, dy)
            xs0, xs1 = max(0, -dx), nx - max(0, dx)
            src = free[ys0:ys1, xs0:xs1]
            dst = free[ys0 + dy:ys1 + dy, xs0 + dx:xs1 + dx]
            ok = src & dst
            if dy and dx:   # 斜めは両隣が通れるときだけ（すり抜け禁止）
                ok &= free[ys0:ys1, xs0 + dx:xs1 + dx] & free[ys0 + dy:ys1 + dy, xs0:xs1]
            s = idx[ys0:ys1, xs0:xs1][ok]
            d = idx[ys0 + dy:ys1 + dy, xs0 + dx:xs1 + dx][ok]
            w = self.res * (math.sqrt(2) if dy and dx else 1.0)
            rows += [s, d]
            cols += [d, s]
            wts += [np.full(len(s), w), np.full(len(s), w)]
        n = ny * nx
        return sparse.csr_matrix((np.concatenate(wts), (np.concatenate(rows), np.concatenate(cols))), shape=(n, n))

    def run(self, free, sources):
        """sources はマス目の番号の並び。複数なら、そのどれからでも0として測る"""
        G = self.graph(free)
        n = G.shape[0]
        if len(sources) == 1:
            dist, pred = dijkstra(G, indices=sources[0], return_predecessors=True)
            return dist.reshape(free.shape), pred
        # 複数の出発点：仮の点を1つ足してつなぐ
        G = sparse.vstack([sparse.hstack([G, sparse.csr_matrix((n, 1))]),
                           sparse.csr_matrix((np.full(len(sources), 1e-9), (np.zeros(len(sources), int), sources)), shape=(1, n + 1))]).tocsr()
        dist, pred = dijkstra(G, indices=n, return_predecessors=True)
        return dist[:n].reshape(free.shape), pred[:n]

    def stop_cells(self, side, width, reach):
        """座る側の前で止まれるマス目：辺から width/2 −res 〜 width/2 + reach の帯で、辺の両端を除いた所"""
        (ax, ay), (bx, by), (nx, ny) = side
        m = SETTINGS["side_end_margin"]
        lo, hi = width / 2 - self.res, width / 2 + reach
        X, Y = np.meshgrid(self.xs, self.ys)
        if nx:
            d = (X - ax) * nx
            along = (Y >= ay + m) & (Y <= by - m)
        else:
            d = (Y - ay) * ny
            along = (X >= ax + m) & (X <= bx - m)
        return (d >= lo) & (d <= hi) & along, d

    def sight(self, cy, cx, side, skip_rect):
        """止まる位置から辺までの線の上に、ほかの障害物がないか（線と矩形の交わりを計算で求める。縁に触れるだけはよい）"""
        (ax, ay), (bx, by), (nx, ny) = side
        px, py = self.xs[cx], self.ys[cy]
        tx, ty = (ax, py) if nx else (px, ay)
        for o in self.obs:
            if tuple(o) == tuple(skip_rect or ()):
                continue
            if seg_rect_hit((px, py), (tx, ty), o, open_rect=True):
                return False
        return True


def best_stop(grid, dist, sides, width, reach, skip_rect=None):
    best = None
    for k, side in sides.items():
        band, _ = grid.stop_cells(side, width, reach)
        cand = np.where(band & np.isfinite(dist))
        if not len(cand[0]):
            continue
        order = np.argsort(dist[cand])
        for o in order:   # 近い順に全部の候補を見る（打ち切ると、遠い所だけ見通せる辺を見落とすため）
            cy, cx = cand[0][o], cand[1][o]
            if grid.sight(cy, cx, side, skip_rect):
                if best is None or dist[cy, cx] < best[0]:
                    best = (float(dist[cy, cx]), cy, cx, k)
                break
    return best


def trace(pred, grid, cy, cx):
    n = grid.nx
    i = cy * n + cx
    pts = []
    while i >= 0 and i < grid.nx * grid.ny:
        pts.append(((i % n + 0.5) * grid.res, (i // n + 0.5) * grid.res))
        i = pred[i]
    return pts[::-1]


def analyze(L, res, ctx=None):
    """ctx に dict を渡すと、計算に使ったマス目・道のり・座る側を入れて返す（Unity 用の書き出しに使う）"""
    S = SETTINGS
    model = ROBOT_MODELS[S["model"]]
    pw, nw, hw = model["pass_width"], S["normal_width"], S["human_width"]
    g = Grid(L, res)
    sx, sy = L["start"]
    cy, cx = g.cell(sx, sy)
    errs = []
    if not (0 <= cy < g.ny and 0 <= cx < g.nx):
        raise ValueError("出発点が店の外にある")
    f_pass, f_norm, f_hum = g.free(pw), g.free(nw), g.free(hw)
    if not f_pass[cy, cx]:
        raise ValueError(f"出発点（受け渡し口の前）がロボットの通れない場所にある（余裕 {g.clear[cy, cx]:.2f}m）")
    s0 = cy * g.nx + cx
    d_norm, p_norm = g.run(f_norm, [s0]) if f_norm[cy, cx] else (np.full(f_norm.shape, np.inf), None)
    d_pass, p_pass = g.run(f_pass, [s0])

    # 待機場所の1台ずつ：ほかの台が止まったまま、その台が受け渡し口の前（出発点）まで出られるか
    # その台の区画だけを障害物から外し、区画の真ん中から出発点まで、通れる幅の道がつながっているかを見る（つながりは上下左右）
    slots = standby_slots(L["robot_standby"])
    for k in range(len(slots)):
        gk = Grid(L, res, standby=[r for j, r in enumerate(slots) if j != k])
        fk = gk.free(pw)
        lab, _ = ndimage.label(fk)
        r = slots[k]
        ky, kx = gk.cell((r[0] + r[2]) / 2, (r[1] + r[3]) / 2)
        if not fk[ky, kx] or lab[ky, kx] != lab[cy, cx]:
            errs.append(("待機場所", f"待機場所の{k + 1}台目が、ほかの台が止まったままでは受け渡し口の前まで出られない"))

    sides = [open_sides(L, i) for i in range(len(L["tables"]))]
    res_t = []
    for i, t in enumerate(L["tables"]):
        r = dict(id=t["id"], seats=t["seats"], open_sides=list(sides[i]))
        b = best_stop(g, d_norm, sides[i], nw, S["stop_reach"], fp(t)) if p_norm is not None else None
        if b:
            r.update(category="robot", dist=round(b[0], 2), stop=[round(g.xs[b[2]], 3), round(g.ys[b[1]], 3)], side=b[3],
                     path=trace(p_norm, g, b[1], b[2]))
        else:
            b = best_stop(g, d_pass, sides[i], pw, S["stop_reach"], fp(t))
            if b:
                r.update(category="narrow", dist=round(b[0], 2), stop=[round(g.xs[b[2]], 3), round(g.ys[b[1]], 3)], side=b[3],
                         path=trace(p_pass, g, b[1], b[2]))
            else:
                r.update(category="human")
        res_t.append(r)

    # 人が運ぶ卓：ふだんの道でロボットが行ける所（受け取る所）から、人が歩く距離
    hum = [r for r in res_t if r["category"] == "human"]
    if hum:
        reach_cells = np.where(np.isfinite(d_norm).ravel() & f_hum.ravel())[0] if p_norm is not None else np.array([s0])
        if len(reach_cells) == 0:
            reach_cells = np.array([s0])
        d_h, p_h = g.run(f_hum, list(reach_cells))
        for r in hum:
            i = [t["id"] for t in L["tables"]].index(r["id"])
            b = best_stop(g, d_h, sides[i], hw, S["stop_reach"], fp(L["tables"][i]))
            if b is None:
                errs.append(("届かない卓", f"{r['id']} は人も運べない（通路がない）"))
                r.update(human_walk=None)
            else:
                r.update(human_walk=round(b[0], 2), stop=[round(g.xs[b[2]], 3), round(g.ys[b[1]], 3)], side=b[3],
                         path=trace(p_h, g, b[1], b[2]))
    # 道の一番狭い所（ロボットの道の上の余裕×2）
    for r in res_t:
        if r.get("path") and r["category"] in ("robot", "narrow"):
            r["narrowest"] = round(min(2 * g.clear[int(y / res), int(x / res)] for x, y in r["path"]), 2)
    if ctx is not None:
        ctx.update(grid=g, d_norm=d_norm if p_norm is not None else None, d_pass=d_pass, sides=sides)
    return res_t, errs


def summarize(L, res_t):
    S, model = SETTINGS, ROBOT_MODELS[SETTINGS["model"]]
    cat = lambda c: [r for r in res_t if r["category"] == c]
    rob, nar, hum = cat("robot"), cat("narrow"), cat("human")
    dists = [r["dist"] for r in rob + nar]
    walks = [r["human_walk"] for r in hum if r.get("human_walk") is not None]
    return dict(
        name=L["name"], seats=sum(t["seats"] for t in L["tables"]), tables=len(L["tables"]),
        model=S["model"], pass_width=model["pass_width"], normal_width=S["normal_width"],
        robot_tables=len(rob), robot_seats=sum(r["seats"] for r in rob),
        narrow_tables=len(nar), narrow_seats=sum(r["seats"] for r in nar),
        human_tables=len(hum), human_seats=sum(r["seats"] for r in hum),
        robot_dist_mean=round(sum(dists) / len(dists), 1) if dists else None,
        robot_dist_max=max(dists) if dists else None,
        human_walk_total=round(sum(walks), 1) if walks else 0.0,
        human_walk_max=max(walks) if walks else None,
    )


# ================================================================ 描く
INK, MUTED, BACK, SEAT, SOFA = gen.INK, gen.MUTED, gen.BACK, gen.SEAT, gen.SOFA
ROBOT, DRINK, DISH, NG = gen.ROBOT, gen.DRINK, gen.DISH, gen.NG
NARROW, HUMAN = "#b08800", "#7048e8"
CAT_COLOR = {"robot": INK, "narrow": NARROW, "human": HUMAN}


def draw_simple(ax, t):
    """記憶の店の家具：4人の角テーブル・丸テーブル・背中合わせのスツール席"""
    x, y, w, h = t["x"], t["y"], t["w"], t["h"]
    if t["type"] == "t4":
        ax.add_patch(Rectangle((x, y), w, h, fc=SEAT, ec=INK, lw=1.1, zorder=3))
    elif t["type"] == "r4":
        ax.add_patch(Circle((x + w / 2, y + h / 2), w / 2, fc=SEAT, ec=INK, lw=1.1, zorder=3))
    elif t["type"] == "long6":
        cx, cy = x + w / 2, y + h / 2
        ax.add_patch(Rectangle((cx - 1.6, cy - 0.35), 3.2, 0.7, fc=SEAT, ec=INK, lw=1.1, zorder=3))
        for dx in (-1.0, 0.0, 1.0):
            for dy in (-0.75, 0.75):
                ax.add_patch(Circle((cx + dx, cy + dy), 0.22, fc=SEAT, ec=INK, lw=0.9, zorder=3))
    ax.text(x + w / 2, y + h / 2, str(t["seats"]), ha="center", va="center", fontsize=8, color=INK, zorder=5)


def draw_lsofa_table(ax, t, ec):
    x, y, w, h = t["x"], t["y"], t["w"], t["h"]
    # 変形前の内側の角：x+0.55w〜x+w、y+0.45h〜y+h
    ox0, oy0, ox1, oy1 = x + 0.55 * w, y + 0.45 * h, x + w, y + h
    cx, cy = (ox0 + ox1) / 2, (oy0 + oy1) / 2
    if t.get("flip"):
        cy = 2 * y + h - cy
    if t.get("mirror"):
        cx = 2 * x + w - cx
    ax.add_patch(Rectangle((cx - 0.375, cy - 0.6), 0.75, 1.2, fc=SEAT, ec=ec, lw=0.9, zorder=3.5))


def draw(ax, L, res_t, summ, conv):
    W, D = L["outer"]["w"], L["outer"]["d"]
    ax.set_xlim(-1.2, W + 1.2)
    ax.set_ylim(-2.3, D + 0.6)
    ax.set_aspect("equal")
    ax.axis("off")
    # 外壁と入口
    e = L["entrance"]
    if L.get("outline"):
        pts = L["outline"] + [L["outline"][0]]
        for (xa, ya), (xb, yb) in zip(pts, pts[1:]):
            if e["side"] == "bottom" and ya == yb == 0:
                ax.plot([min(xa, xb), e["a0"]], [0, 0], color=INK, lw=3)
                ax.plot([e["a1"], max(xa, xb)], [0, 0], color=INK, lw=3)
            else:
                ax.plot([xa, xb], [ya, yb], color=INK, lw=3)
    edges = {} if L.get("outline") else {"bottom": ((0, 0), (W, 0)), "top": ((0, D), (W, D)), "left": ((0, 0), (0, D)), "right": ((W, 0), (W, D))}
    for side, (a, b) in edges.items():
        if side != e["side"]:
            ax.plot([a[0], b[0]], [a[1], b[1]], color=INK, lw=3)
        elif side in ("bottom", "top"):
            ax.plot([0, e["a0"]], [a[1], a[1]], color=INK, lw=3)
            ax.plot([e["a1"], W], [a[1], a[1]], color=INK, lw=3)
        else:
            ax.plot([a[0], a[0]], [0, e["a0"]], color=INK, lw=3)
            ax.plot([a[0], a[0]], [e["a1"], D], color=INK, lw=3)
    m = (e["a0"] + e["a1"]) / 2
    pos = {"bottom": (m, -0.5), "top": (m, D + 0.4), "left": (-0.7, m), "right": (W + 0.7, m)}[e["side"]]
    ax.text(*pos, "入口" if e["side"] in ("bottom", "top") else "入\n口", ha="center", va="center", fontsize=10, weight="bold")

    for r in L["rooms"]:
        if r["kind"] == "outside":
            continue
        fc = {"back": BACK, "guest": "#f8f9fa", "drink": "#d0ebff"}[r["kind"]]
        ec = DRINK if r["kind"] == "drink" else INK
        x0, y0, x1, y1 = r["rect"]
        ax.add_patch(Rectangle((x0, y0), x1 - x0, y1 - y0, fc=fc, ec=ec, lw=1.0, zorder=2))
        tall = (y1 - y0) > (x1 - x0) * 1.6
        text = "\n".join(r["label"]) if tall and len(r["label"]) <= 5 else r["label"]
        ax.text((x0 + x1) / 2, (y0 + y1) / 2, text, ha="center", va="center",
                fontsize=8 if len(r["label"]) > 5 else 9, color=DRINK if r["kind"] == "drink" else INK, zorder=5)
    for w in L["walls"]:
        x0, y0, x1, y1 = w["rect"]
        ax.add_patch(Rectangle((x0, y0), x1 - x0, y1 - y0, fc="#868e96", ec="#495057", lw=0.5, zorder=4))
    for dz in L.get("toilet_doors", []):
        x0, y0, x1, y1 = dz
        ax.add_patch(Rectangle((x0, y0), x1 - x0, y1 - y0, fc="none", ec=MUTED, lw=0.8, ls=":", zorder=2))
    for d in L["dish_up"]:
        x0, y0, x1, y1 = d["rect"]
        ax.add_patch(Rectangle((x0, y0), x1 - x0, y1 - y0, fc=DISH, ec=DISH, zorder=4))
    for d in L.get("doors", []):   # 店員の出入り口：扉あり＝白抜き＋点線、扉なし＝部屋の枠線を消して開いて見せる
        x0, y0, x1, y1 = d["rect"]
        if d.get("door", True):
            ax.add_patch(Rectangle((x0, y0), x1 - x0, y1 - y0, fc="white", ec=INK, lw=0.8, ls="--", zorder=4.5))
        else:
            ax.add_patch(Rectangle((x0 + 0.02, y0), x1 - x0 - 0.04, (y1 - y0) + 0.06, fc="white", ec="none", zorder=4.5))

    # 道
    for r in res_t:
        if r.get("path"):
            xs, ys = zip(*r["path"])
            if r["category"] == "human":
                ax.plot(xs, ys, color=HUMAN, lw=1.0, ls=(0, (3, 2)), alpha=0.7, zorder=2.6)
            else:
                ax.plot(xs, ys, color=ROBOT if r["category"] == "robot" else NARROW, lw=0.9, alpha=0.35, zorder=2.5)
    for t, r in zip(L["tables"], res_t):
        ec = CAT_COLOR[r["category"]]
        if t["type"] == "booth6L":
            gen.draw_seat(ax, t, False, False)
            draw_lsofa_table(ax, t, INK)
            # 色は外形の枠で示す
            ax.add_patch(Rectangle((t["x"], t["y"]), t["w"], t["h"], fc="none", ec=ec, lw=1.6, zorder=3.6))
        else:
            if t["type"] in ("t4", "r4", "long6"):
                draw_simple(ax, t)
            else:
                gen.draw_seat(ax, t, False, False)
            if r["category"] != "robot":
                ax.add_patch(Rectangle((t["x"], t["y"]), t["w"], t["h"], fc="none", ec=ec, lw=1.8, zorder=3.6))
        if r.get("stop"):
            ax.add_patch(Circle(tuple(r["stop"]), 0.09, fc=CAT_COLOR[r["category"]] if r["category"] != "robot" else ROBOT,
                                ec="white", lw=0.5, zorder=6))
        if t["id"] in conv:
            ax.text(t["x"] + t["w"] - 0.1, t["y"] + t["h"] - 0.15, "?", fontsize=9, color=NG, weight="bold", zorder=7)

    sb = L["robot_standby"]
    x0, y0, x1, y1 = sb["rect"]
    ax.add_patch(Rectangle((x0, y0), x1 - x0, y1 - y0, fc="#fff4e6", ec=ROBOT, lw=1.4, zorder=5))
    body = (ROBOT_MODELS[SETTINGS["model"]]["body_width"] or 0.5) / 2
    for i in range(sb["slots"]):
        ax.add_patch(Circle((x0 + (x1 - x0) * (i + 0.5) / sb["slots"], (y0 + y1) / 2), body, fc=ROBOT, ec="white", lw=0.8, zorder=6))
    ax.add_patch(Circle(tuple(L["start"]), 0.12, fc="white", ec=ROBOT, lw=1.5, zorder=6))

    s = summ
    ax.text(0, -1.0, f'{L["name"]}　{s["seats"]}席・{W:g}m×{D:g}m', fontsize=10.5, color=INK, va="center", weight="bold")
    ax.text(0, -1.45, f'機種 {s["model"]}（通れる幅 {s["pass_width"]*100:.0f}cm）・ふだん走らせる幅 {s["normal_width"]*100:.0f}cm　'
            f'ロボットが届く {s["robot_tables"]}卓・{s["robot_seats"]}席／狭い道なら届く {s["narrow_tables"]}卓・{s["narrow_seats"]}席／'
            f'人が運ぶ {s["human_tables"]}卓・{s["human_seats"]}席', fontsize=8.8, color=INK, va="center")
    walk = f'　人が運ぶ分の歩く距離 合計{s["human_walk_total"]}m・最長{s["human_walk_max"]}m' if s["human_tables"] else ""
    ok = "マス目2.5cmと1.25cmで卓の分け方が同じ" if not conv else f'マス目で分け方が変わる卓 {len(conv)}（?印）'
    ax.text(0, -1.85, f'受け渡し口の前（白丸）から止まる位置まで 平均{s["robot_dist_mean"]}m・最長{s["robot_dist_max"]}m{walk}　／　{ok}',
            fontsize=8.8, color=INK, va="center")
    ax.text(0, -2.2, '線：オレンジ＝ロボット（ふだんの道）　黄土＝ロボット（狭い道）　紫の点線＝人が運ぶ道　点＝止まる位置　寸法はすべて仮',
            fontsize=8, color=MUTED, va="center")


# ================================================================ 出力
BUILDS = [("p0_memory", famires_p0.p0_memory), ("p1_efficient_corner", gen.p1_efficient_corner), ("p2_efficient_narrow", gen.p2_efficient_narrow),
          ("p3_relaxed_center", gen.p3_relaxed_center), ("p4_relaxed_side", gen.p4_relaxed_side),
          ("p5_crammed", gen.p5_crammed)]


def prepare(L):
    for i, t in enumerate(L["tables"]):
        t["id"] = f"T{i + 1:02d}"
    d = L["dish_up"][0]["rect"]
    off = max(SETTINGS["normal_width"], ROBOT_MODELS[SETTINGS["model"]]["pass_width"]) / 2 + 0.05
    L["start"] = [round((d[0] + d[2]) / 2, 3), round(d[1] - off, 3)]   # 受け渡し口の前（通路の幅いっぱいに離れた所）
    return L


def export(L, res_t, summ, conv, nm, out, gen_id=None):
    meta = dict(schema_version=SCHEMA, generation=gen_id, units="m",
                coords="原点は店の外壁の内側の左下の角。x は右、y は奥（上）。Unity では x→X、y→Z、高さ→Y とする",
                doors="layout.doors は店員の出入り口（壁の切れ目・部屋の端の開口）。kind=staff。door=true は扉あり、false は扉のない開口",
                furniture_types=FURNITURE_TYPES, robot_model=dict(name=SETTINGS["model"], **ROBOT_MODELS[SETTINGS["model"]]),
                settings=SETTINGS)
    layout = {k: v for k, v in L.items()}
    json.dump(dict(meta=meta, layout=layout), open(out / f"{nm}.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
    slim = [{k: v for k, v in r.items() if k != "path"} | ({"path_end": r["path"][-1]} if r.get("path") else {}) for r in res_t]
    json.dump(dict(meta=meta, summary=summ, resolution_mismatch=conv, tables=slim),
              open(out / f"{nm}_analysis.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)


def export_unity(L, res_t, ctx, nm, out, gen_id):
    """Unity が店を建てるための細かい形（unity_export.py）を unity/{名前}.json に書き出す"""
    import unity_export
    meta = dict(schema_version="famires-unity/0.1", generation=gen_id, units="m",
                coords="原点は店の外壁の内側の左下の角。x は右、y は奥（上）。Unity では x→X、y→Z、高さ→Y とする",
                source=f"{nm}.json と {nm}_analysis.json（同じ generation）から決まった規則で作った。数字（卓・距離・止まる位置）は analysis と同じ",
                robot_model=dict(name=SETTINGS["model"], **ROBOT_MODELS[SETTINGS["model"]]), settings=SETTINGS)
    U = unity_export.build(L, res_t, ctx["grid"], ctx["d_norm"], ctx["d_pass"], ctx["sides"],
                           standby_slots(L["robot_standby"]), FURNITURE_TYPES, meta)
    (out / "unity").mkdir(exist_ok=True)
    json.dump(U, open(out / "unity" / f"{nm}.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
    return U


def generation_id():
    """出力の版の番号：作り方のコードと記憶の店の原本、設定から決まる（同じなら毎回同じ）。
    layout・analysis・summary に同じ番号を入れ、組み合わせが同じ版かを読む側で確かめられるようにする"""
    h = hashlib.sha256()
    for f in ("famires_v3.py", "famires_gen2.py", "famires_p0.py", "layout_v11.json", "unity_export.py"):
        h.update((HERE / f).read_bytes())
    h.update(json.dumps(SETTINGS, sort_keys=True, ensure_ascii=False).encode())
    return h.hexdigest()[:12]


def main():
    args = sys.argv[1:]
    out_arg = None
    if "--out" in args:
        i = args.index("--out")
        out_arg = Path(args[i + 1])
        args = args[:i] + args[i + 2:]
    if args:
        SETTINGS["model"] = args[0]
    # サイトのリポジトリの中なら public/sim/layouts/ に出す（サイトの試算が正。Unity はここを読む）
    base = HERE.parent.parent / "public" / "sim" / "layouts"
    if not base.parent.exists():
        base = HERE / "variants3"
    out = out_arg or (base if SETTINGS["model"] == "BellaBot" else base / SETTINGS["model"].replace(" ", "_"))
    out.mkdir(parents=True, exist_ok=True)
    gen_id = generation_id()
    # いったん作業用のフォルダに全部出し、6つ全部が決まりを守ったときだけ、出力先へまとめて入れ替える。
    # 1つでも違反があれば出力先には何も書かない（前の版の一式がそのまま残り、新旧が混ざらない）
    tmp = Path(tempfile.mkdtemp(prefix=".build_", dir=out))
    allsum, failed = [], False
    try:
        for nm, fn in BUILDS:
            L = prepare(fn())
            v = check_rules(L)
            if v:   # 決まりに違反があれば、重い計算に進まない
                res_a = None
            else:
                try:
                    ctx = {}
                    res_a, err_a = analyze(L, SETTINGS["res"], ctx)
                    res_b, err_b = analyze(L, SETTINGS["check_res"])
                    v += err_a + [e for e in err_b if e not in err_a]
                except ValueError as ex:
                    v.append(("出発点", str(ex)))
            if v:
                failed = True
                print(f"[NG] {nm}: {L['name']}")
                for k, msg in v:
                    print(f"   - {k}: {msg}")
                allsum.append(dict(file=nm, name=L["name"], status="NG", violations=[f"{k}: {m}" for k, m in v]))
                continue
            conv = [a["id"] for a, b in zip(res_a, res_b) if a["category"] != b["category"]]
            summ = summarize(L, res_a)
            fig, ax = plt.subplots(figsize=(14, 11), dpi=120)
            draw(ax, L, res_a, summ, conv)
            fig.savefig(tmp / f"{nm}.png", bbox_inches="tight", facecolor="white")
            plt.close(fig)
            export(L, res_a, summ, conv, nm, tmp, gen_id)
            export_unity(L, res_a, ctx, nm, tmp, gen_id)
            allsum.append(dict(file=nm, status="OK", generation=gen_id, resolution_mismatch=conv, **summ))
            print(f"[OK] {nm}: {summ}  マス目の差: {conv}")
        if failed:
            print(f"\n違反があるので、{out} には何も書いていない（前の版のまま）")
            sys.exit(1)
        json.dump(allsum, open(tmp / "summary.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
        publish(tmp, out)
    finally:
        shutil.rmtree(tmp, ignore_errors=True)


def publish(tmp, out):
    """作業用のフォルダ tmp の一式を、出力先 out へ入れ替える。途中で1つでも失敗したら、入れ替えた分を元に戻す（全部か、何もなしか）。
    summary.json は最後に入れ替える。読む側は summary の generation と、各ファイルの meta.generation が同じかを確かめること"""
    files = sorted((f.relative_to(tmp) for f in tmp.rglob("*") if f.is_file()), key=lambda f: (str(f) == "summary.json", str(f)))
    backup = tmp / "_backup"
    backup.mkdir()
    done = []   # (相対パス, 前にあったか)
    try:
        for rel in files:
            dst = out / rel
            dst.parent.mkdir(parents=True, exist_ok=True)
            existed = dst.exists()
            if existed:
                (backup / rel).parent.mkdir(parents=True, exist_ok=True)
                shutil.copy2(dst, backup / rel)
            os.replace(tmp / rel, dst)
            done.append((rel, existed))
    except BaseException:
        for rel, existed in reversed(done):
            if existed:
                os.replace(backup / rel, out / rel)
            else:
                (out / rel).unlink(missing_ok=True)
        print(f"\n出力先へ入れ替える途中で失敗したので、{out} を前の版に戻した")
        raise


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

### tools/sim_layouts/famires_p0.py
```python
"""記憶の店（昔働いていたファミレスを記憶から起こした店・layout_v11）を、5パターンと同じ決まりに合わせた版。

layout_v11.json（記憶のまま）は変えずに残し、ここで直した版を作る。
直したこと（2026-10-21 エイイチと決めたこと）
- 建物の形：客席の奥の壁はそのまま。トイレ・パントリーの出入り口・洗い場・物置の並びが、その外へ約2.2m出っ張る
- 空いた所は厨房が広がる（キッチンと冷蔵庫を奥へ延ばし、出っ張りとの間に裏の通路）
- トイレへは、パントリーの壁ぞいの通路を奥へ進んで入る。元のトイレの場所は通路。トイレの手前に短い仕切り壁（約2.6m）
- トイレの客席側（通路側）に厚い壁（30cm）、入口は下向き（通路の突き当たり）
- 上の並びの右端の4人席は残す（当時もあった）。トイレとの間は、トイレの厚い壁と通路の仕切り壁でへだてる
- 入口の右に待合を足した。レジを2.2mから2.5mにし、内側に店員の立つ所を付けた
- 待機場所を2.2×0.7mから2.4×0.8mに広げた（3台のまま。1台0.8×0.8m）（2026-10-22、codex の2回目の検証を受けて）
- パントリーの上の通路に出入りできなかったので、出入り口を2つ開けた（2026-10-22 エイイチの指摘。店員もここを通る）
  A：受け渡し口（上）のすぐ上の、パントリーの壁に1m（客席側のトイレへの通路とつながる）
  B：パントリーの上の端（受け渡し口（上）から上の通路へ）
  A・B とも扉はなく、開いているだけ。扉があるのは、上の通路から洗い場とキッチンへ入る所
- 仕切り壁の上の4人席2つを、壁との間が0.6mになるまで寄せた（ロボットが壁側から入らないように）
"""
import json
from pathlib import Path

import famires_gen2 as gen

HERE = Path(__file__).parent

WING_X0, WING_DEPTH = 7.6, 2.2   # 出っ張りの左端と奥行き（仮）


def p0_memory():
    B = json.load(open(HERE / "layout_v11.json", encoding="utf-8"))
    W, D0 = B["outer"]["w"], B["outer"]["d"]
    D = D0 + WING_DEPTH
    keep = {"パントリー", "冷凍庫", "保存庫", "社員トイレ", "更衣室", "こおり", "ドリンクバー"}
    rooms = [dict(r) for r in B["rooms"] if r["label"] in keep]
    rooms += [
        dict(label="キッチン", rect=[12.0, 6.0, 19.0, 16.0], kind="back"),
        dict(label="冷蔵庫", rect=[19.0, 10.5, 21.0, 16.0], kind="back"),
        # 出っ張り：トイレ・パントリーの出入り口（通路）・洗い場・物置
        dict(label="トイレ", rect=[WING_X0, D0, 9.5, D], kind="guest"),
        dict(label="洗い場", rect=[12.0, D0, 21.0, D], kind="back"),
        dict(label="物置", rect=[21.6, D0, W, D], kind="back"),
        # 建物の外（出っ張りの左）
        dict(label="建物の外", rect=[0.0, D0, WING_X0, D], kind="outside"),
        *gen.register([9.8, 2.2, 12.3, 2.9], [9.8, 2.9, 12.3, 3.7]),
        dict(label="待合", rect=[12.7, 0.0, 13.9, 0.5], kind="guest"),
    ]
    walls = [dict(w) for w in B["walls"] if w["label"] != "客席と裏の壁"]
    walls += [dict(label="客席と裏の壁", rect=[19.0, 6.0, W, 6.25])]
    # トイレの手前の短い仕切り壁（上の客席とトイレへの通路の間・約2.6m）
    walls += [dict(label="通路の仕切り壁", rect=[7.9, 14.4, 8.1, D0])]
    # 通路とバックヤード（パントリーの出入り口）の間の壁：パントリーの壁をトイレまで延ばす
    # 受け渡し口（上）のすぐ上を1m開けて、客席側（トイレへの通路）との出入り口にする（2026-10-22 エイイチと決めた）
    door_a = [9.5, 14.0, 9.8, 15.0]
    walls += [dict(label="パントリーの壁", rect=[9.5, door_a[3], 9.8, D0])]
    # 店員の出入り口：A＝パントリーの上の通路と客席側の間、B＝パントリーの上の端（受け渡し口（上）から上の通路へ）
    # door：扉があるか。A・B は扉のない、開いているだけの所（扉があると誰かが開けないといけないため）
    # 扉があるのは、上の通路から洗い場とキッチンへ入る所（位置は仮）
    doors = [dict(label="出入り口（パントリーの上の通路と客席）", rect=door_a, kind="staff", door=False),
             dict(label="出入り口（パントリーと上の通路）", rect=[9.8, 13.9, 12.0, 14.0], kind="staff", door=False),
             dict(label="洗い場の扉", rect=[12.2, D0, 13.1, D0 + 0.1], kind="staff", door=True),
             dict(label="キッチンの扉", rect=[12.2, 15.9, 13.1, 16.0], kind="staff", door=True)]
    # トイレの壁：通路側（下）は厚さ30cm、入口0.9m。右（パントリーの出入り口側）も壁
    door_x0, door_x1 = 8.3, 9.2
    walls += [dict(label="トイレの壁", rect=[WING_X0, D0, door_x0, D0 + 0.3]),
              dict(label="トイレの壁", rect=[door_x1, D0, 9.5, D0 + 0.3]),
              dict(label="トイレの壁", rect=[9.2, D0 + 0.3, 9.5, D])]
    toilet_door = [door_x0, D0 - 1.2, door_x1, D0]

    tables = []
    for t in B["tables"]:
        if t["type"] == "t4":
            tables.append(dict(type="t4", x=round(t["x"] - 0.6, 2), y=round(t["y"] - 0.6, 2), w=1.2, h=1.2, seats=4))
        elif t["type"] == "r4":
            tables.append(dict(type="r4", x=round(t["x"] - 0.55, 2), y=round(t["y"] - 0.55, 2), w=1.1, h=1.1, seats=4))
        elif t["type"] == "long6":
            tables.append(dict(type="long6", x=round(t["x"] - 1.6, 2), y=round(t["y"] - 0.97, 2), w=3.2, h=1.94, seats=6))
        else:
            tables.append({k: t[k] for k in ("type", "x", "y", "w", "h", "seats", "flip", "mirror") if k in t})
    # 仕切り壁（y 8.6〜9.0）の上の4人席2つ：壁との間が1.0mあり、ロボットが壁側から入っていたので、
    # 下の区画と同じ0.6m（ロボットが通れない幅）まで壁に寄せる（2026-10-22 エイイチの指摘）
    for t in tables:
        if t["type"] == "t4" and abs(t["y"] - 10.0) < 1e-6:
            t["y"] = 9.6

    # 待機場所：記憶のまま（2.2×0.7m）だと3台分（1台0.8×0.8m）に足りないので、右に0.2m・手前に0.1m広げる（2026-10-22 エイイチと決めた）
    sb0 = B["robot_standby"]
    standby = dict(label=sb0["label"], rect=[12.2, 5.1, 14.6, 5.9], slots=3)

    dish = [d for d in B["dish_up"] if "下" in d["label"]] + [d for d in B["dish_up"] if "下" not in d["label"]]
    outline = [(0, 0), (W, 0), (W, D), (WING_X0, D), (WING_X0, D0), (0, D0)]
    return dict(name="② 定番の郊外型", style="定番の郊外型", outer=dict(w=W, d=D), outline=outline,
                entrance=dict(side="bottom", a0=B["entrance"]["x0"], a1=B["entrance"]["x1"]),
                rooms=rooms, walls=walls, toilet_doors=[toilet_door], dish_up=dish, doors=doors,
                robot_standby=standby, tables=tables)
```

### tools/sim_layouts/unity_export.py
```python
"""Unity が店を建てるための細かい形を、店の形（layout）と解析（analysis）から決まった規則で作る。

2026-10-22 エイイチと決めた「何について正か」による（README）。Unity は店の形を自分で作らず、ここで書き出したものを読む。
サイトの試算（haizen_sim.py）はこれを使わない。数字（卓の数・卓までの距離・止まる位置）は analysis と同じものをそのまま渡す。

書き出すもの（public/sim/layouts/unity/{名前}.json）
- outer_walls：外壁（入口を空けた矩形の並び。外形の外側に壁の厚みぶん）
- floor：床を敷く矩形（建物の外を除く）
- aisles：細い通路（ボックス席の列と列の間。幅・区画・両端の入口と受け渡し口の前からの道のり）
- tables：卓ごとの部品（天板・ベンチ・椅子・座る位置・席に着くために立つ点）と、止まる位置・通る細い通路
- places：場所の点（受け渡し口の前・下げ膳の返却・パントリー・入口・待合・レジ・レジ内・トイレ・ドリンクバー・待機場所・スタッフの待つ点）

規則（寸法は famires_v3.FURNITURE_TYPES と同じ）
- 細い通路：同じ向きのボックス席がすき間なく並んだ「列」を見つけ、隣り合う2つの列のすき間（0.5m以上、1.5m以下）を細い通路とする。
  区画は2つの列の長さを合わせた範囲。両端の少し外（0.8m）に入口の点を置き、受け渡し口の前からの道のりが短い方を entrance とする
- 卓が通る細い通路：止まる位置がその通路の区画の中にあるもの
- 下げ膳の返却口は受け渡し口と同じ点（試算の dish_extra_m=0 と同じ前提）
- ロボットの道順（routes）：受け渡し口の前→各卓の止まる位置（from_pass）と、卓→卓（between。"T01|T02" は T01 から T02。逆向きは点を逆にたどる）。
  ふだん走らせる幅（normal_width）の道で行けるならその道、行けなければ通れる幅（pass_width）の道（試算と同じ規則。use が normal か narrow）。
  マス目の上の最短の道を、通れる範囲でまっすぐにつないで、曲がり角の点だけにした。卓→受け渡し口は from_pass を逆にたどる
- 部屋は通れない箱。ただし walkable=true の部屋（レジ内）は人が中に立つ所で、箱にしない（staff_only=true はスタッフだけが入れる）
- 席に着くために立つ点は、障害物から0.2m（人の半径）以上離れた所だけ
"""
import math

import numpy as np

SETBACK = 0.8            # 通路の端から、入口の点を外へ出す距離
NARROW_MAX = 1.5         # これ以下のすき間を細い通路とする（ゆったり型の通路幅）
NARROW_MIN = 0.5         # これより狭いすき間は通路にしない（人が通れる幅。島の中の背中合わせのすき間0.1mなどを除く）
SPOT_OUT = 0.3           # 席に着くために立つ点：座る側の辺から外へ
HEIGHTS = dict(wall=2.7, partition=1.2, toilet_wall=2.7, table=0.72, bench=1.1, bench_seat=0.45, chair=0.45, counter=1.0)


def r2(v):
    return round(float(v), 3)


def rect(*a):
    return [r2(v) for v in a]


# ---------------------------------------------------------------- 細い通路
def booth_columns(tables):
    """同じ向きのボックス席が、すき間なく（0.01m以内）並んだ列。axis=v は縦の列（x の範囲が同じ）"""
    cols = []
    for axis in ("v", "h"):
        groups = {}
        for i, t in enumerate(tables):
            if t["type"] != "booth" or t["axis"] != axis:
                continue
            k = (round(t["x"], 3), round(t["x"] + t["w"], 3)) if axis == "v" else (round(t["y"], 3), round(t["y"] + t["h"], 3))
            groups.setdefault(k, []).append(i)
        for k, idx in groups.items():
            idx.sort(key=lambda i: tables[i]["y"] if axis == "v" else tables[i]["x"])
            run = [idx[0]]
            for a, b in zip(idx, idx[1:]):
                ea = tables[a]["y"] + tables[a]["h"] if axis == "v" else tables[a]["x"] + tables[a]["w"]
                sb = tables[b]["y"] if axis == "v" else tables[b]["x"]
                if abs(sb - ea) <= 0.01:
                    run.append(b)
                else:
                    cols.append((axis, k, run))
                    run = [b]
            cols.append((axis, k, run))
    out = []
    for axis, (c0, c1), run in cols:
        if axis == "v":
            l0, l1 = tables[run[0]]["y"], tables[run[-1]]["y"] + tables[run[-1]]["h"]
        else:
            l0, l1 = tables[run[0]]["x"], tables[run[-1]]["x"] + tables[run[-1]]["w"]
        out.append(dict(axis=axis, c0=c0, c1=c1, l0=l0, l1=l1, tables=run))
    return out


def find_aisles(L):
    cols = booth_columns(L["tables"])
    aisles = []
    for a in cols:
        for b in cols:
            if a is b or a["axis"] != b["axis"] or b["c0"] <= a["c1"] - 1e-6:
                continue
            gap = b["c0"] - a["c1"]
            if not (NARROW_MIN - 1e-6 <= gap <= NARROW_MAX + 1e-6):
                continue
            if min(a["l1"], b["l1"]) - max(a["l0"], b["l0"]) <= 1e-6:
                continue
            # a と b の間に、別の列が挟まっていないか
            if any(c is not a and c is not b and c["axis"] == a["axis"] and c["c0"] >= a["c1"] - 1e-6 and c["c1"] <= b["c0"] + 1e-6
                   and min(c["l1"], a["l1"], b["l1"]) - max(c["l0"], a["l0"], b["l0"]) > 1e-6 for c in cols):
                continue
            l0, l1 = min(a["l0"], b["l0"]), max(a["l1"], b["l1"])
            if a["axis"] == "v":
                r = rect(a["c1"], l0, b["c0"], l1)
                ends = [(r2((a["c1"] + b["c0"]) / 2), r2(l0 - SETBACK)), (r2((a["c1"] + b["c0"]) / 2), r2(l1 + SETBACK))]
            else:
                r = rect(l0, a["c1"], l1, b["c0"])
                ends = [(r2(l0 - SETBACK), r2((a["c1"] + b["c0"]) / 2)), (r2(l1 + SETBACK), r2((a["c1"] + b["c0"]) / 2))]
            aisles.append(dict(rect=r, width=r2(gap), axis=a["axis"], narrow=True, ends=[list(e) for e in ends]))
    aisles.sort(key=lambda a: (a["axis"], a["rect"][0], a["rect"][1]))
    for i, a in enumerate(aisles):
        a["id"] = f"A{i + 1:02d}"
    return aisles


def aisle_entrances(aisles, grid, dist, dist_pass):
    """両端の入口の点の、受け渡し口の前からの道のり（ふだんの幅の道。行けなければ通れる幅の道）。短い方を entrance にする"""
    for a in aisles:
        ds = []
        for x, y in a["ends"]:
            cy, cx = grid.cell(x, y)
            d = None
            if 0 <= cy < grid.ny and 0 <= cx < grid.nx:
                for D in (dist, dist_pass):
                    if D is not None and np.isfinite(D[cy, cx]):
                        d = r2(D[cy, cx])
                        break
            ds.append(d)
        a["end_dist"] = ds
        ok = [i for i, d in enumerate(ds) if d is not None]
        a["entrance"] = a["ends"][min(ok, key=lambda i: ds[i])] if ok else None


# ---------------------------------------------------------------- 卓の部品
def booth_parts(t, FT):
    B = FT["booth"]
    bd, td = B["bench_depth"], B["table_depth"]
    x, y, w, h = t["x"], t["y"], t["w"], t["h"]
    n = t["seats"] // 2   # ベンチ1本あたりの人数
    if t["axis"] == "v":   # ベンチが上下、座る側は左右
        benches = [rect(x, y, x + w, y + bd), rect(x, y + h - bd, x + w, y + h)]
        top = rect(x, y + bd, x + w, y + bd + td)
        seats = [[r2(x + w * (k + 0.5) / n), r2(y + bd / 2)] for k in range(n)] + \
                [[r2(x + w * (k + 0.5) / n), r2(y + h - bd / 2)] for k in range(n)]
    else:
        benches = [rect(x, y, x + bd, y + h), rect(x + w - bd, y, x + w, y + h)]
        top = rect(x + bd, y, x + bd + td, y + h)
        seats = [[r2(x + bd / 2), r2(y + h * (k + 0.5) / n)] for k in range(n)] + \
                [[r2(x + w - bd / 2), r2(y + h * (k + 0.5) / n)] for k in range(n)]
    return dict(top=top, benches=benches, chairs=[], seat_points=seats)


def t2_parts(t, FT):
    x, y, w, h, s = t["x"], t["y"], t["w"], t["h"], t["side"]
    b = FT["t2"]["bench_depth"]
    cr = FT["t2"]["chair_radius"]
    if s == "bottom":
        bench, top, chair, pull = rect(x, y, x + w, y + b), rect(x + 0.05, y + 0.5, x + 0.75, y + 1.1), (x + w / 2, y + 1.2), (0, 1)
    elif s == "top":
        bench, top, chair, pull = rect(x, y + h - b, x + w, y + h), rect(x + 0.05, y + 0.3, x + 0.75, y + 0.9), (x + w / 2, y + 0.2), (0, -1)
    elif s == "left":
        bench, top, chair, pull = rect(x, y, x + b, y + h), rect(x + 0.5, y + 0.05, x + 1.1, y + 0.75), (x + 1.2, y + h / 2), (1, 0)
    else:
        bench, top, chair, pull = rect(x + w - b, y, x + w, y + h), rect(x + 0.3, y + 0.05, x + 0.9, y + 0.75), (x + 0.2, y + h / 2), (-1, 0)
    bc = [r2((bench[0] + bench[2]) / 2), r2((bench[1] + bench[3]) / 2)]
    ch = dict(rect=rect(chair[0] - cr, chair[1] - cr, chair[0] + cr, chair[1] + cr), pullable=True, pull_dir=list(pull))
    return dict(top=top, benches=[bench], chairs=[ch], seat_points=[bc, [r2(chair[0]), r2(chair[1])]])


def lsofa_parts(t):
    """L字ソファ：下の帯（高さ0.45h）と左の列（幅0.55w）がソファ。flip で上下、mirror で左右を返す"""
    x, y, w, h = t["x"], t["y"], t["w"], t["h"]

    def tf(px, py):
        if t.get("flip"):
            py = 2 * y + h - py
        if t.get("mirror"):
            px = 2 * x + w - px
        return px, py

    def tr(r):
        (ax, ay), (bx, by) = tf(r[0], r[1]), tf(r[2], r[3])
        return rect(min(ax, bx), min(ay, by), max(ax, bx), max(ay, by))
    band = tr([x, y, x + w, y + 0.45 * h])
    column = tr([x, y + 0.45 * h, x + 0.55 * w, y + h])
    cx, cy = (x + 0.55 * w + x + w) / 2, (y + 0.45 * h + y + h) / 2
    top = tr([cx - 0.375, cy - 0.6, cx + 0.375, cy + 0.6])
    sd = 0.55   # 座面の奥行き（ソファの内側の縁から）
    seats = [tf(x + 0.55 * w + (w * 0.45) * (k + 0.5) / 3, y + 0.45 * h - sd / 2) for k in range(3)] + \
            [tf(x + 0.55 * w - sd / 2, y + 0.45 * h + (h * 0.55) * (k + 0.5) / 3) for k in range(3)]
    return dict(top=top, benches=[band, column], chairs=[], seat_points=[[r2(a), r2(b)] for a, b in seats])


def table_parts(t, FT):
    if t["type"] == "booth":
        return booth_parts(t, FT)
    if t["type"] == "t2":
        return t2_parts(t, FT)
    if t["type"] == "booth6L":
        return lsofa_parts(t)
    return None   # t4・r4・long6（② のみ）は椅子と座る位置がまだない（README の「まだ入っていないこと」）


PERSON_R = 0.2           # 人の半径（席に着く点は、障害物からこれ以上離れた所だけ）


def seat_spots(sides, grid):
    """席に着くために立つ点：座る側の辺の真ん中から外へ SPOT_OUT。障害物から PERSON_R 未満の点は使わない
    （壁ぞいの2人席の横は隣の卓まで0.3mしかないため）"""
    out = []
    for k, ((ax, ay), (bx, by), (nx, ny)) in sides.items():
        x, y = (ax + bx) / 2 + nx * SPOT_OUT, (ay + by) / 2 + ny * SPOT_OUT
        cy, cx = grid.cell(x, y)
        if 0 <= cy < grid.ny and 0 <= cx < grid.nx and grid.clear[cy, cx] >= PERSON_R - 1e-9:
            out.append(dict(side=k, point=[r2(x), r2(y)]))
    return out


# ---------------------------------------------------------------- 外壁・床・場所
def outer_walls(L, thick=0.15):
    """外壁：外形（または輪郭）の各辺の、建物の外側に厚み thick の壁。角は厚みぶん延ばしてふさぐ。入口の所は空ける"""
    W, D = L["outer"]["w"], L["outer"]["d"]
    pts = [tuple(map(float, p)) for p in (L.get("outline") or [(0, 0), (W, 0), (W, D), (0, D)])]
    e = L["entrance"]
    segs = []
    for (xa, ya), (xb, yb) in zip(pts, pts[1:] + pts[:1]):
        if abs(ya - yb) < 1e-9:   # 横の辺
            lo, hi, c = min(xa, xb), max(xa, xb), ya
            inside_up = _inside(pts, (lo + hi) / 2, c + 0.01)
            a0, a1 = (c - thick, c) if inside_up else (c, c + thick)
            gate = (e["side"] == "bottom" and abs(c) < 1e-9) or (e["side"] == "top" and abs(c - D) < 1e-9)
            pieces = [(lo - thick, max(lo, e["a0"])), (min(hi, e["a1"]), hi + thick)] if gate else [(lo - thick, hi + thick)]
            segs += [rect(p, a0, q, a1) for p, q in pieces if q - p > 1e-6]
        else:
            lo, hi, c = min(ya, yb), max(ya, yb), xa
            inside_right = _inside(pts, c + 0.01, (lo + hi) / 2)
            a0, a1 = (c - thick, c) if inside_right else (c, c + thick)
            gate = (e["side"] == "left" and abs(c) < 1e-9) or (e["side"] == "right" and abs(c - W) < 1e-9)
            pieces = [(lo - thick, max(lo, e["a0"])), (min(hi, e["a1"]), hi + thick)] if gate else [(lo - thick, hi + thick)]
            segs += [rect(a0, p, a1, q) for p, q in pieces if q - p > 1e-6]
    return segs


def _inside(pts, x, y):
    from matplotlib.path import Path as MPath
    return MPath(np.array(pts, dtype=float)).contains_point((x, y))


def floor_rects(L):
    """床：外形から建物の外（kind=outside）を除いた矩形（縦に切り分ける）"""
    W, D = L["outer"]["w"], L["outer"]["d"]
    outs = [r["rect"] for r in L["rooms"] if r["kind"] == "outside"]
    if not outs:
        return [rect(0, 0, W, D)]
    xs = sorted({0.0, W} | {c for o in outs for c in (o[0], o[2])})
    out = []
    for xa, xb in zip(xs, xs[1:]):
        cx = (xa + xb) / 2
        ys = sorted({0.0, D} | {c for o in outs if o[0] < cx < o[2] for c in (o[1], o[3])})
        for ya, yb in zip(ys, ys[1:]):
            cy = (ya + yb) / 2
            if not any(o[0] < cx < o[2] and o[1] < cy < o[3] for o in outs):
                out.append(rect(xa, ya, xb, yb))
    return out


def front_point(r, floor_free, step=0.4):
    """部屋（カウンター）の前に立つ点：4辺のうち、外へ step 出た所が床として空いている辺の真ん中（空きの多い順）"""
    x0, y0, x1, y1 = r
    cand = [("bottom", ((x0 + x1) / 2, y0 - step)), ("top", ((x0 + x1) / 2, y1 + step)),
            ("left", (x0 - step, (y0 + y1) / 2)), ("right", (x1 + step, (y0 + y1) / 2))]
    best = max(cand, key=lambda c: floor_free(*c[1]))
    return best[0], [r2(best[1][0]), r2(best[1][1])]


REG_QUEUE = 3            # レジに並ぶ点の数
# 通れない箱にしない部屋（人が中に立つ所）。staff_only はスタッフだけが入れる（ロボットと客は入れない）
WALKABLE = {"レジ内": {"walkable": True, "staff_only": True}}
DRINK_QUEUE = 3          # ドリンクバーに並ぶ点の数
STAFF_STANDBY = 6        # スタッフが待つ点の数（図面のある店で選べる人数の最大まで）


def _side_point(r, side, out):
    """矩形 r の辺 side の真ん中から外へ out の点と、外向きの向き"""
    x0, y0, x1, y1 = r
    n = {"bottom": (0, -1), "top": (0, 1), "left": (-1, 0), "right": (1, 0)}[side]
    c = {"bottom": ((x0 + x1) / 2, y0), "top": ((x0 + x1) / 2, y1), "left": (x0, (y0 + y1) / 2), "right": (x1, (y0 + y1) / 2)}[side]
    return [r2(c[0] + n[0] * out), r2(c[1] + n[1] * out)], n


def _opposite_side(r, b):
    """矩形 r に接している矩形 b の、反対側の辺"""
    if abs(b[3] - r[1]) < 1e-6:
        return "top"
    if abs(b[1] - r[3]) < 1e-6:
        return "bottom"
    if abs(b[2] - r[0]) < 1e-6:
        return "right"
    return "left"


def staff_standby_points(L, free, aisles):
    """スタッフが待つ点：パントリー（と接する壁）の客席側の辺にそって、壁から 0.25m、0.6m おき。
    受け渡し口に近い端から並べ、その外側にロボットのふだんの道（1.0m）が残る所だけ使う（仮の規則）。
    受け渡し口の前（ロボットの出入り）と細い通路には置かない"""
    pan = [r["rect"] for r in L["rooms"] if r["label"] == "パントリー"]
    if not pan:
        return []
    x0, y0, x1, y1 = pan[0]
    for w in L["walls"]:   # パントリーに接する壁も含めた外形
        r = w["rect"]
        if touch_segment_simple(r, (x0, y0, x1, y1)):
            x0, y0, x1, y1 = min(x0, r[0]), min(y0, r[1]), max(x1, r[2]), max(y1, r[3])
    d = L["dish_up"][0]["rect"]
    dc = ((d[0] + d[2]) / 2, (d[1] + d[3]) / 2)
    sides = {"left": ((x0, y0), (x0, y1), (-1, 0)), "right": ((x1, y0), (x1, y1), (1, 0)),
             "bottom": ((x0, y0), (x1, y0), (0, -1)), "top": ((x0, y1), (x1, y1), (0, 1))}
    pts = []
    for k, ((ax, ay), (bx, by), (nx, ny)) in sides.items():
        wide = (d[2] - d[0]) >= (d[3] - d[1])   # 受け渡し口が横長なら上か下の辺、縦長なら左か右の辺にある
        if (k == "bottom" and wide and abs(d[1] - y0) < 0.05) or (k == "top" and wide and abs(d[3] - y1) < 0.05) or \
           (k == "left" and not wide and abs(d[0] - x0) < 0.05) or (k == "right" and not wide and abs(d[2] - x1) < 0.05):
            continue   # 受け渡し口のある辺
        n = int(math.hypot(bx - ax, by - ay) / 0.6)
        for q in range(n):
            f = (q + 0.5) / n
            px, py = ax + (bx - ax) * f + nx * 0.25, ay + (by - ay) * f + ny * 0.25
            lx, ly = px + nx * 0.75, py + ny * 0.75   # 外側に残る道の真ん中
            if free(px, py) >= 0.2 and free(lx, ly) >= 0.5 and not _in_aisle(aisles, px, py):
                pts.append([r2(px), r2(py)])
    pts.sort(key=lambda p: math.hypot(p[0] - dc[0], p[1] - dc[1]))   # 受け渡し口に近い順
    return pts[:STAFF_STANDBY]


def touch_segment_simple(a, b):
    """2つの矩形が接しているか重なっているか"""
    return not (a[2] < b[0] - 1e-6 or b[2] < a[0] - 1e-6 or a[3] < b[1] - 1e-6 or b[3] < a[1] - 1e-6)


def _in_aisle(aisles, x, y, m=0.0):
    return any(a["rect"][0] - m <= x <= a["rect"][2] + m and a["rect"][1] - m <= y <= a["rect"][3] + m for a in aisles)


def places(L, grid, standby_slots, aisles):
    W, D = L["outer"]["w"], L["outer"]["d"]

    def free(x, y):   # 障害物までの余裕（m）。店の外は −1
        cy, cx = grid.cell(x, y)
        if not (0 <= cy < grid.ny and 0 <= cx < grid.nx):
            return -1.0
        return float(grid.clear[cy, cx])
    P = []
    st = L["start"]
    P.append(dict(kind="FoodPass", label="受け渡し口の前（出発点）", rect=L["dish_up"][0]["rect"], point=list(st)))
    P.append(dict(kind="DishReturn", label="下げ膳の返却（受け渡し口と同じ点）", rect=L["dish_up"][0]["rect"], point=list(st)))
    e = L["entrance"]
    m = (e["a0"] + e["a1"]) / 2
    ep = {"bottom": (m, 0.5), "top": (m, D - 0.5), "left": (0.5, m), "right": (W - 0.5, m)}[e["side"]]
    P.append(dict(kind="Entrance", label="入口", point=[r2(ep[0]), r2(ep[1])]))
    kinds = {"パントリー": "Pantry", "待合": "Waiting", "レジ": "Register", "レジ内": "RegisterBack", "トイレ": "Toilet",
             "ドリンクバー": "DrinkBar", "キッチン": "Kitchen"}
    for r in L["rooms"]:
        k = kinds.get(r["label"])
        if not k:
            continue
        if k == "Toilet":
            dz = L["toilet_doors"][0]
            P.append(dict(kind=k, label="トイレの入口の前", rect=r["rect"], point=[r2((dz[0] + dz[2]) / 2), r2((dz[1] + dz[3]) / 2)]))
        elif k == "Pantry":
            P.append(dict(kind=k, label="パントリー（受け渡し口の前と同じ点）", rect=r["rect"], point=list(st)))
        elif k == "RegisterBack":
            x0, y0, x1, y1 = r["rect"]
            P.append(dict(kind=k, label="レジ内（店員が立つ所）", rect=r["rect"], point=[r2((x0 + x1) / 2), r2((y0 + y1) / 2)]))
        elif k == "Kitchen":
            continue
        elif k == "Register":
            # 客の側は、レジ内（店員の立つ所）の反対側
            back = [b["rect"] for b in L["rooms"] if b["label"] == "レジ内"]
            side = _opposite_side(r["rect"], back[0]) if back else front_point(r["rect"], free)[0]
            pt, nrm = _side_point(r["rect"], side, 0.4)
            P.append(dict(kind=k, label="レジの前（会計に立つ点）", rect=r["rect"], point=pt, front=side))
            for q in range(1, REG_QUEUE + 1):   # 並ぶ点：会計に立つ点から外へ 0.5m ずつ。障害物から0.25m未満・細い通路に入る所で打ち切る
                qx, qy = pt[0] + nrm[0] * 0.5 * q, pt[1] + nrm[1] * 0.5 * q
                if free(qx, qy) < 0.25 or _in_aisle(aisles, qx, qy):
                    break
                P.append(dict(kind="RegisterQueue", label=f"レジに並ぶ点 {q}", point=[r2(qx), r2(qy)]))
        elif k == "DrinkBar":
            side, _ = front_point(r["rect"], free)
            x0, y0, x1, y1 = r["rect"]
            horiz = side in ("bottom", "top")
            length = (x1 - x0) if horiz else (y1 - y0)
            n = max(1, int(length / 0.9))   # 前に立つ点：0.9m に1人
            pt, nrm = _side_point(r["rect"], side, 0.4)
            P.append(dict(kind=k, label="ドリンクバーの前", rect=r["rect"], point=pt, front=side))
            for q in range(n):
                f = (q + 0.5) / n
                base = (x0 + (x1 - x0) * f, y0 if side == "bottom" else y1) if horiz else (x0 if side == "left" else x1, y0 + (y1 - y0) * f)
                P.append(dict(kind="DrinkBarStand", label=f"ドリンクバーの前に立つ点 {q + 1}", point=[r2(base[0] + nrm[0] * 0.4), r2(base[1] + nrm[1] * 0.4)]))
            for q in range(1, DRINK_QUEUE + 1):   # 並ぶ点：真ん中の前から外へ。障害物から0.25m未満・細い通路に入る所で打ち切る
                qx, qy = pt[0] + nrm[0] * (0.4 + 0.5 * q), pt[1] + nrm[1] * (0.4 + 0.5 * q)
                if free(qx, qy) < 0.25 or _in_aisle(aisles, qx, qy):
                    break
                P.append(dict(kind="DrinkBarQueue", label=f"ドリンクバーに並ぶ点 {q}", point=[r2(qx), r2(qy)]))
        else:
            side, pt = front_point(r["rect"], free)
            P.append(dict(kind=k, label=r["label"] + "の前", rect=r["rect"], point=pt, front=side))
    # スタッフが待つ点：受け渡し口の前の横（待機場所と反対の側）に、パントリーの壁ぞいへ 0.6m おき（仮の規則）
    for i, pt in enumerate(staff_standby_points(L, free, aisles)):
        P.append(dict(kind="StaffStandby", label=f"スタッフが待つ点 {i + 1}", point=pt))
    for i, s in enumerate(standby_slots):
        P.append(dict(kind="RobotStandby", label=f"ロボットの待機場所 {i + 1}", rect=rect(*s), point=[r2((s[0] + s[2]) / 2), r2((s[1] + s[3]) / 2)]))
    return P



# ---------------------------------------------------------------- ロボットの道順（2026-10-22 エイイチと決めた）
def _trace_cells(pred, nx, src, dst):
    """dijkstra の predecessors から、src から dst までのマス目の番号の並び"""
    out, i = [], dst
    while i != src:
        if i < 0:
            return None
        out.append(i)
        i = pred[i]
    out.append(src)
    return out[::-1]


def _visible(free, grid, a, b):
    """点 a から b までの線分が、通れるマス目だけを通るか（マス目の4分の1の間隔で確かめる）"""
    n = max(2, int(math.hypot(b[0] - a[0], b[1] - a[1]) / (grid.res / 4)) + 1)
    xs = np.linspace(a[0], b[0], n)
    ys = np.linspace(a[1], b[1], n)
    cx = np.clip((xs / grid.res).astype(int), 0, grid.nx - 1)
    cy = np.clip((ys / grid.res).astype(int), 0, grid.ny - 1)
    return bool(free[cy, cx].all())


def _pull(points, free, grid):
    """マス目の道を、通れる範囲でまっすぐにつないだ曲がり角の点だけにする（始点と終点は残す）"""
    out, i, n = [points[0]], 0, len(points)
    while i < n - 1:
        lo, hi = i + 1, n - 1
        if _visible(free, grid, points[i], points[hi]):
            j = hi
        else:   # 見通せるいちばん遠い点を二分で探す（i+1 は必ず見通せる）
            while hi - lo > 1:
                mid = (lo + hi) // 2
                if _visible(free, grid, points[i], points[mid]):
                    lo = mid
                else:
                    hi = mid
            j = lo
        out.append(points[j])
        i = j
    return out


def _poly_len(pts):
    return sum(math.hypot(b[0] - a[0], b[1] - a[1]) for a, b in zip(pts, pts[1:]))


def build_routes(L, analysis_tables, grid, normal_width, pass_width):
    """受け渡し口の前→各卓の止まる位置、卓→卓 の道順。
    規則：ふだん走らせる幅（normal_width）の道で行けるならその道、行けなければ通れる幅（pass_width）の道（試算と同じ）。
    道はマス目の上の最短の道を、通れる範囲でまっすぐにつないで曲がり角の点だけにする。終点は止まる位置そのもの"""
    from scipy.sparse.csgraph import dijkstra
    f_norm, f_pass = grid.free(normal_width), grid.free(pass_width)
    G_norm, G_pass = grid.graph(f_norm), grid.graph(f_pass)
    cell = lambda x, y: (lambda c: c[0] * grid.nx + c[1])(grid.cell(x, y))
    xy = lambda i: ((i % grid.nx + 0.5) * grid.res, (i // grid.nx + 0.5) * grid.res)
    nodes = [("PASS", tuple(L["start"]))] + [(t["id"], tuple(a["stop"])) for t, a in zip(L["tables"], analysis_tables) if a.get("stop")]
    ids = [cell(*p) for _, p in nodes]
    out = {"from_pass": {}, "between": {}}
    for si, (sname, sp) in enumerate(nodes):
        dn, pn = dijkstra(G_norm, indices=ids[si], return_predecessors=True) if f_norm.ravel()[ids[si]] else (None, None)
        dp, pp = dijkstra(G_pass, indices=ids[si], return_predecessors=True)
        for ti in range(si + 1, len(nodes)):
            tname, tp = nodes[ti]
            use = None
            if dn is not None and np.isfinite(dn[ids[ti]]):
                use, free, pred, width = "normal", f_norm, pn, normal_width
            elif np.isfinite(dp[ids[ti]]):
                use, free, pred, width = "narrow", f_pass, pp, pass_width
            if use is None:
                rec = None
            else:
                cells = _trace_cells(pred, grid.nx, ids[si], ids[ti])
                pts = [(r2(x), r2(y)) for x, y in [sp] + [xy(c) for c in cells[1:-1]] + [tp]]   # 書き出す座標（mm）で確かめる
                pr = _pull(pts, free, grid)
                rec = dict(use=use, width=width, points=[[x, y] for x, y in pr],
                           length=r2(_poly_len(pr)), grid_length=r2(_poly_len(pts)))
            if sname == "PASS":
                out["from_pass"][tname] = rec
            else:
                out["between"][f"{sname}|{tname}"] = rec
    return out


def build(L, analysis_tables, grid, dist, dist_pass, sides, standby_slots, FT, meta):
    aisles = find_aisles(L)
    aisle_entrances(aisles, grid, dist, dist_pass)
    tables = []
    for t, a, sd in zip(L["tables"], analysis_tables, sides):
        parts = table_parts(t, FT)
        stop = a.get("stop")
        ai = None
        if stop:
            for x in aisles:
                r = x["rect"]
                if r[0] - 0.05 <= stop[0] <= r[2] + 0.05 and r[1] - 0.05 <= stop[1] <= r[3] + 0.05:
                    ai = x["id"]
                    break
        tables.append(dict(id=t["id"], type=t["type"], seats=t["seats"], footprint=rect(t["x"], t["y"], t["x"] + t["w"], t["y"] + t["h"]),
                           category=a["category"], stop=stop, side=a.get("side"), dist=a.get("dist"), aisle=ai,
                           parts=parts, spots=seat_spots(sd, grid)))
    walls = [dict(label=w["label"], rect=w["rect"], height=HEIGHTS["partition"] if w.get("joint") or w["label"] in ("仕切り", "目隠し") else
                  (HEIGHTS["toilet_wall"] if w["label"] == "トイレの壁" else HEIGHTS["wall"])) for w in L["walls"]]
    st = (meta or {}).get("settings") or {}
    model = (meta or {}).get("robot_model") or {}
    routes = build_routes(L, analysis_tables, grid, st.get("normal_width", 1.0), model.get("pass_width", 0.8)) if st else None
    return dict(meta=dict(meta, heights=HEIGHTS, rules=__doc__.strip()), routes=routes,
                name=L["name"], outer=L["outer"], outline=L.get("outline"), entrance=L["entrance"],
                floor=floor_rects(L), outer_walls=outer_walls(L), walls=walls,
                rooms=[dict(label=r["label"], kind=r["kind"], rect=r["rect"], **WALKABLE.get(r["label"], {"walkable": False}))
                       for r in L["rooms"] if r["kind"] != "outside"],
                doors=L.get("doors", []), aisles=aisles, tables=tables, places=places(L, grid, standby_slots, aisles))
```

### tools/sim_layouts/test_famires_v3.py
```python
"""famires_v3 の確かめ（codex の反例を含む）。使い方: python3 test_famires_v3.py"""
import copy
import famires_v3 as v
import famires_gen2 as gen

ok = True


def expect(name, cond):
    global ok
    print(("OK  " if cond else "NG  ") + name)
    ok &= bool(cond)


def base():
    return v.prepare(gen.p1_efficient_corner())


def mini(aisle, offset=0.0, model="BellaBot"):
    """幅 aisle の通路1本の奥に、座る側が通路に向いたボックスが1つある店"""
    v.SETTINGS["model"] = model
    W, D = 8.0, 6.0
    x_wall = 3.0 + offset
    L = dict(name="test", outer=dict(w=W, d=D), entrance=dict(side="bottom", a0=0.2, a1=1.0),
             rooms=[dict(label="パントリー", rect=[0.0, 4.0, 2.0, 6.0], kind="back"),
                    dict(label="待合", rect=[7.5, 0.0, 8.0, 1.0], kind="guest"),
                    dict(label="ドリンクバー", rect=[7.0, 5.5, 8.0, 6.0], kind="drink")],
             walls=[dict(label="壁", rect=[x_wall, 0.0, x_wall + 0.2, 4.6]),          # 通路の左の壁
                    dict(label="壁", rect=[x_wall + 0.2 + aisle, 0.0, 8.0, 3.0])],    # 通路の右の壁
             dish_up=[dict(label="受け渡し口", rect=[0.0, 4.0, 2.0, 4.4])],
             robot_standby=dict(label="待機", rect=[6.0, 5.2, 6.8, 6.0], slots=1),
             tables=[], toilet_doors=[])
    # 通路の奥（y<3）の右側の壁に、座る側を通路に向けたボックス（壁の切り欠きに置く）
    bx = x_wall + 0.2 + aisle
    L["walls"][1]["rect"] = [bx + 1.2, 0.0, 8.0, 3.0]
    L["walls"].append(dict(label="壁", rect=[bx, 0.0, bx + 1.2, 0.4]))
    L["tables"] = [dict(type="booth", axis="v", x=bx, y=0.6, w=1.2, h=1.85, seats=4)]
    v.prepare(L)
    L["start"] = [1.0, 3.45]
    return L


for res in (0.025, 0.0125):
    for off in (0.0, 0.013, 0.031):
        r, e = v.analyze(mini(0.80, off), res)
        expect(f"80cmの通路はBellaBotで通れる（マス目{res}・ずらし{off}）", r[0]["category"] in ("robot", "narrow"))
        r, e = v.analyze(mini(0.77, off), res)
        expect(f"77cmの通路はBellaBotで通れない（マス目{res}・ずらし{off}）", r[0]["category"] == "human")
        r, e = v.analyze(mini(0.75, off, model="Servi"), res)
        expect(f"75cmの通路はServiで通れる（マス目{res}・ずらし{off}）", r[0]["category"] in ("robot", "narrow"))
v.SETTINGS["model"] = "BellaBot"

# 背中側（ベンチの背）は座る側に数えない
L = mini(1.2)
L["tables"][0].update(axis="h", w=1.85, h=1.2)
expect("横向きのボックスの左右（ベンチの背）は座る側の候補にしない", set(v.table_sides(L["tables"][0])) == {"bottom", "top"})
L = mini(1.2)
expect("縦向きのボックスの上下（ベンチの背）は座る側の候補にしない", set(v.table_sides(L["tables"][0])) == {"left", "right"})

# codex の反例：卓を店の外へ
L = base()
L["tables"][0]["x"] = -2.0
expect("店の外にはみ出した卓を見つける", any(k == "店の外" for k, _ in v.check_rules(L)))

# codex の反例：トイレの入口の前に壁
L = base()
L["walls"].append(dict(label="壁", rect=list(L["toilet_doors"][0])))
expect("トイレの入口の前の壁を見つける", any(k == "トイレ" for k, _ in v.check_rules(L)))

# ちょうど1mも「1m以内」。壁でへだてていれば置いてよい
L = v.prepare(gen.p2_efficient_narrow())
tr = [r["rect"] for r in L["rooms"] if r["label"] == "トイレ"][0]
t = [t for t in L["tables"] if t["type"] == "t2" and t["side"] == "top"][0]
t["x"] = tr[2] + 1.0
expect("トイレの壁でへだてた1mの席は違反にしない", not any("トイレから1.00m" in m for _, m in v.check_rules(L)))
L["walls"] = [w for w in L["walls"] if w["label"] != "トイレの壁"]
expect("壁がなければ、トイレからちょうど1mの席を見つける", any("トイレから1.00m" in m for _, m in v.check_rules(L)))

# 卓と壁の重なり（仕切りの接合は許し、それ以外は見つける）
L = base()
L["walls"].append(dict(label="壁", rect=[L["tables"][5]["x"] + 0.3, L["tables"][5]["y"] + 0.3, L["tables"][5]["x"] + 0.6, L["tables"][5]["y"] + 0.6]))
expect("卓に食い込んだ壁を見つける", any(k == "重なり" and "壁" in m for k, m in v.check_rules(L)))
L = v.prepare(gen.p3_relaxed_center())
expect("ゆったり型の仕切り（背もたれの上）は違反にしない", not v.check_rules(L))

# 待合をふさいだ6人席（⑤で実際に起きた）
L = v.prepare(gen.p3_relaxed_center())
for r in L["rooms"]:
    if r["label"] == "待合":
        r["rect"] = [15.6, 2.2, 18.2, 2.8]
res, err = v.analyze(L, 0.025)
expect("座る側の前をふさがれた卓を見つける", any(k == "届かない卓" for k, _ in err))

# 出発点がふさがれていたら止める
L = base()
L["walls"].append(dict(label="壁", rect=[L["start"][0] - 0.2, L["start"][1] - 0.2, L["start"][0] + 0.2, L["start"][1] + 0.2]))
try:
    v.analyze(L, 0.025)
    expect("出発点がふさがれていたら止める", False)
except ValueError:
    expect("出発点がふさがれていたら止める", True)

# 待合・トイレの壁がない
L = base()
L["rooms"] = [r for r in L["rooms"] if r["label"] != "待合"]
expect("待合がないのを見つける", any("待合" in m for _, m in v.check_rules(L)))

# ⑦ は③と同じ卓・同じ席数
a, b = v.prepare(gen.p1_efficient_corner()), v.prepare(gen.p5_crammed())
expect("⑦は③と同じ卓の数・種類・席数", [(t["type"], t["seats"]) for t in a["tables"]] == [(t["type"], t["seats"]) for t in b["tables"]])

# ---- codex の2回目の検証（review_result_v3.md）を受けて足した確かめ（2026-10-22）
import famires_p0

# L字席：flip・mirror の4通りすべてで、止まってよい辺は「内側の角の開いた2辺」＋「ソファの端に沿って reach＋余白まで」だけ
ext = v.SETTINGS["lsofa_end_reach"] + v.SETTINGS["side_end_margin"]
for flip in (False, True):
    for mirror in (False, True):
        t = dict(type="booth6L", x=10.0, y=10.0, w=2.6, h=3.6, seats=6, flip=flip, mirror=mirror)
        x, y, w, h = 10.0, 10.0, 2.6, 3.6
        qx = (x, x + 0.45 * w) if mirror else (x + 0.55 * w, x + w)       # 開いた角の x の範囲（mirror なら左側）
        qy = (y, y + 0.55 * h) if flip else (y + 0.45 * h, y + h)         # 開いた角の y の範囲（flip なら下側）
        S = v.table_sides(t)
        good = len(S) == 2
        for k, ((ax, ay), (bx, by), (nx, ny)) in S.items():
            if nx:   # 縦の辺：開いた角の側の縦の外形線の上で、y の範囲は角の y の範囲＋ソファ側へ ext まで
                good &= abs(ax - (x if mirror else x + w)) < 1e-9 and nx == (-1 if mirror else 1)
                lo, hi = sorted((ay, by))
                good &= (qy[0] - ext - 1e-9 <= lo) and (hi <= qy[1] + ext + 1e-9)
                good &= (lo <= qy[0] + 1e-9 and hi >= qy[1] - 1e-9)                       # 開いた辺は全部含む
                outside = (qy[0] - lo) + (hi - qy[1])
                good &= outside <= ext + 1e-9                                              # ソファの横は ext まで
            else:    # 横の辺
                good &= abs(ay - (y if flip else y + h)) < 1e-9 and ny == (-1 if flip else 1)
                lo, hi = sorted((ax, bx))
                good &= (lo <= qx[0] + 1e-9 and hi >= qx[1] - 1e-9)
                good &= (qx[0] - lo) + (hi - qx[1]) <= ext + 1e-9
        expect(f"L字席（flip={flip}・mirror={mirror}）の止まってよい辺は、開いた角とソファの端{v.SETTINGS['lsofa_end_reach']}mまで", good)

# ⓪T02（codex の反例）：止まる位置がソファの横（角から0.6mより先）にならない
L0 = v.prepare(famires_p0.p0_memory())
r0, e0 = v.analyze(L0, 0.025)
t02 = L0["tables"][1]
a02 = r0[1]
corner_y = t02["y"] + 0.45 * t02["h"]
expect("⓪T02 の止まる位置は、開いた角か、そこからソファの端に沿って0.6m以内",
       a02.get("stop") is not None and (a02["side"] == "top" or a02["stop"][1] >= corner_y - v.SETTINGS["lsofa_end_reach"] - 1e-6))
expect("⓪の待機場所は、1台ずつ受け渡し口の前まで出られる", not any(k == "待機場所" for k, _ in e0))

# 2人席：ベンチの側・ベンチの横は使わない
for side in ("bottom", "top", "left", "right"):
    t = dict(type="t2", x=5.0, y=5.0, w=1.4 if side in ("left", "right") else 0.8, h=0.8 if side in ("left", "right") else 1.4, seats=2, side=side)
    S = v.table_sides(t)
    b = v.FURNITURE_TYPES["t2"]["bench_depth"]
    x0, y0, x1, y1 = v.fp(t)
    bench = {"bottom": (x0, y0, x1, y0 + b), "top": (x0, y1 - b, x1, y1), "left": (x0, y0, x0 + b, y1), "right": (x1 - b, y0, x1, y1)}[side]
    good = side not in S and len(S) == 3
    for k, ((ax, ay), (bx, by), (nx, ny)) in S.items():
        if nx:
            good &= min(ay, by) >= bench[3] - 1e-9 or max(ay, by) <= bench[1] + 1e-9 or side in ("left", "right")
        else:
            good &= min(ax, bx) >= bench[2] - 1e-9 or max(ax, bx) <= bench[0] + 1e-9 or side in ("bottom", "top")
    expect(f"2人席（ベンチが{side}）は、ベンチの側とベンチの横を止まる辺にしない", good)

# 待機場所の寸法・台数
L = v.prepare(famires_p0.p0_memory())
L["robot_standby"]["rect"] = [12.2, 5.2, 14.4, 5.9]      # 直す前の⓪（2.2×0.7m に3台）
expect("直す前の⓪の待機場所（2.2×0.7mに3台）を見つける", any(k == "待機場所" for k, _ in v.check_rules(L)))
for bad in (100, 0, -1, "3", 2.5, True):
    L = base()
    L["robot_standby"]["slots"] = bad
    expect(f"待機場所の台数 {bad!r} を見つける", any(k == "待機場所" for k, _ in v.check_rules(L)))

# 待機場所の前を壁でふさぐと、両端の台は横から出られるが、真ん中の台は出られない
L = base()
sb = L["robot_standby"]["rect"]
L["walls"].append(dict(label="壁", rect=[sb[0], sb[1] - 0.3, sb[2], sb[1]]))
res, err = v.analyze(L, 0.025)
msgs = [m for k, m in err if k == "待機場所"]
expect("待機場所の前をふさぐと、真ん中の台だけ「出られない」を見つける", len(msgs) == 1 and "2台目" in msgs[0])
# 前と両横をふさぐと、どの台も出られない
L["walls"] += [dict(label="壁", rect=[sb[0] - 0.3, sb[1] - 0.3, sb[0], sb[3]]), dict(label="壁", rect=[sb[2], sb[1] - 0.3, sb[2] + 0.3, sb[3]])]
res, err = v.analyze(L, 0.025)
expect("待機場所の前と両横をふさぐと、3台とも「出られない」を見つける", sum(k == "待機場所" for k, _ in err) == 3)

# ---- codex の2回目の検証の「検査をすり抜ける配置」7つ（すべて違反として見つかること）
L = base()
L["rooms"] = [r for r in L["rooms"] if r["label"] not in ("レジ", "レジ内")]
expect("反例1：レジとレジ内を消すと見つける", any("レジがない" in m for _, m in v.check_rules(L)) and any("レジ内）がない" in m for _, m in v.check_rules(L)))
L = base()
L["walls"] = [w for w in L["walls"] if w["label"] != "トイレの壁"] + [dict(label="トイレの壁", rect=[0.0, 13.8, 0.3, 14.1])]
expect("反例2：トイレの壁をほぼ全部外すと「切れ目」を見つける", any("切れ目" in m for _, m in v.check_rules(L)))
L = base()
L["walls"].append(dict(label="トイレの壁", rect=list(L["toilet_doors"][0])))
expect("反例3：トイレの入口の前を「トイレの壁」という名前の壁でふさいでも見つける", any("入口の前をふさいでいる" in m for _, m in v.check_rules(L)))
L = base()
t = L["tables"][4]
cx, cy = t["x"] + t["w"] / 2, t["y"] + t["h"] / 2
L["walls"].append(dict(label="仕切り", rect=[cx - 0.025, cy - 0.15, cx + 0.025, cy + 0.15], joint=True))
expect("反例4：家具の真ん中に置いた接合扱いの仕切りを見つける", any(k == "重なり" and t["id"] in m for k, m in v.check_rules(L)))
L = base()
L["robot_standby"]["slots"] = 100
expect("反例5：待機場所100台を見つける", any(k == "待機場所" for k, _ in v.check_rules(L)))
L = v.prepare(famires_p0.p0_memory())
L["rooms"] = [r for r in L["rooms"] if r["kind"] != "outside"]
expect("反例6：⓪の建物の外を消すと、輪郭と合わないのを見つける", any(k == "建物の形" for k, _ in v.check_rules(L)))
L = base()
L["outline"] = [[0, 0], [2, 0], [2, 2], [0, 2]]
expect("反例7：輪郭を2m四方にすると見つける", any(k == "建物の形" for k, _ in v.check_rules(L)))
L = v.prepare(famires_p0.p0_memory())
L["outline"] = [[0, 0], [24, 0], [24, 19.2], [0, 19.2]]   # 欠けのない輪郭なのに建物の外がある
expect("⓪の輪郭から欠けを消すと、建物の外と合わないのを見つける", any(k == "建物の形" for k, _ in v.check_rules(L)))
for nm, fn in v.BUILDS:
    expect(f"今の店 {nm} は決まりを守っている", not v.check_rules(v.prepare(fn())))

# 正しい接合（ベンチの背の上の仕切り）は認め、背でない辺や深すぎる食い込みは認めない
L = v.prepare(gen.p3_relaxed_center())
jw = [w for w in L["walls"] if w.get("joint") and w["rect"][3] - w["rect"][1] < 0.2][0]
bt = [t for t in L["tables"] if t["type"] == "booth" and v.inter(v.fp(t), jw["rect"])][0]
expect("ベンチの背に立つ仕切りは認める", v.joint_ok(bt, jw))
deep = dict(jw, rect=[jw["rect"][0], jw["rect"][1] - 0.2, jw["rect"][2], jw["rect"][3]])
expect("ベンチの背でも0.08mより深い食い込みは認めない", not v.joint_ok(bt, deep) or not v.inter(v.fp(bt), deep["rect"]))

# トイレの目隠し：中心の1本だけ壁に当たっても、4本全部が当たらなければ違反
def toilet_mini(screen):
    # トイレ（左上）の入口は下向き。席は入口の右手前（トイレから約0.7m）
    return dict(outer=dict(w=6.0, d=6.0), tables=[dict(id="T01", type="t4", x=2.6, y=2.4, w=1.2, h=1.2, seats=4)],
                rooms=[dict(label="トイレ", rect=[0.0, 4.0, 2.0, 6.0], kind="guest")],
                walls=[dict(label="トイレの壁", rect=[0.0, 4.0, 0.5, 4.3]), dict(label="トイレの壁", rect=[1.5, 4.0, 2.0, 4.3]),
                       dict(label="トイレの壁", rect=[1.7, 4.3, 2.0, 6.0]), dict(label="目隠し", rect=screen)],
                toilet_doors=[[0.5, 2.8, 1.5, 4.0]], robot_standby=dict(rect=[5.0, 0.0, 5.8, 0.8], slots=1))
ms = [m for k, m in v.check_toilets(toilet_mini([2.0, 3.4, 2.1, 3.9])) if "T01" in m]
expect("トイレの目隠し：中心の線だけ当たる短い壁では違反にする（4本の線）", len(ms) == 1)
ms = [m for k, m in v.check_toilets(toilet_mini([2.0, 2.2, 2.1, 4.0])) if "T01" in m]
expect("トイレの目隠し：4本とも当たる壁なら違反にしない", not ms)

# 線と矩形の交わり：薄い壁を見逃さない
expect("線分が厚さ2mmの壁を横切るのを見つける", v.seg_hits((0, 0), (1, 0), [(0.004, -1, 0.006, 1)]))
expect("線分の端が壁に触れるだけなら、通ったとみなさない", not v.seg_hits((0, 0), (1, 0), [(1.0, -1, 1.2, 1)]))
expect("線分が壁から離れていれば、通ったとみなさない", not v.seg_hits((0, 0), (1, 0), [(0.4, 0.1, 0.6, 1)]))

# 止まる位置の候補：201番目だけ見通せる場合も見つける（200点で打ち切らない）
import numpy as np


class FakeGrid:
    def stop_cells(self, side, width, reach):
        return np.ones((1, 201), bool), None

    def sight(self, cy, cx, side, skip):
        return cx == 200


bs = v.best_stop(FakeGrid(), np.arange(201, dtype=float).reshape(1, 201), {"x": None}, 1.0, 0.3)
expect("201番目だけ見通せる止まる位置も見つける", bs is not None and bs[2] == 200)

# 違反があるときは、出力先に何も書かない（前の版がそのまま残り、新旧が混ざらない）
import os, sys, tempfile, json as _json
from pathlib import Path
d = Path(tempfile.mkdtemp())
saved = (v.BUILDS, sys.argv)
v.BUILDS = [("p2_efficient_narrow", gen.p2_efficient_narrow)]
sys.argv = ["famires_v3.py", "--out", str(d)]
v.main()
before = {str(f.relative_to(d)): f.read_bytes() for f in d.rglob('*') if f.is_file()}
gid = _json.load(open(d / "summary.json", encoding="utf-8"))[0]["generation"]
same_gen = _json.load(open(d / "p2_efficient_narrow.json", encoding="utf-8"))["meta"]["generation"] == gid == \
    _json.load(open(d / "p2_efficient_narrow_analysis.json", encoding="utf-8"))["meta"]["generation"]
expect("成功したときは、layout・analysis・summary に同じ版の番号が入る", same_gen)


def broken():
    L = gen.p2_efficient_narrow()
    L["rooms"] = [r for r in L["rooms"] if r["label"] != "レジ"]
    return L


v.BUILDS = [("p2_efficient_narrow", broken)]
try:
    v.main()
    code = 0
except SystemExit as ex:
    code = ex.code
after = {str(f.relative_to(d)): f.read_bytes() for f in d.rglob('*') if f.is_file()}
expect("違反があると止まり、出力先のファイルは前の版のまま（作業用のフォルダも残らない）", code == 1 and before == after)

# 出力先へ入れ替える途中（2つ目のファイル）で失敗しても、前の版に戻る（新旧が混ざらない）
v.BUILDS = [("p2_efficient_narrow", gen.p2_efficient_narrow)]
orig_replace, calls = v.os.replace, [0]


def flaky_replace(a, b):
    calls[0] += 1
    if calls[0] == 2:
        raise OSError("2つ目で失敗（テスト）")
    return orig_replace(a, b)


v.SETTINGS["side_end_margin"] += 0.01   # 出力が前と変わるように、設定を少し変える
v.os.replace = flaky_replace
try:
    v.main()
    raised = False
except OSError:
    raised = True
finally:
    v.os.replace = orig_replace
    v.SETTINGS["side_end_margin"] -= 0.01
after = {str(f.relative_to(d)): f.read_bytes() for f in d.rglob('*') if f.is_file()}
expect("入れ替えの途中で失敗すると、出力先は前の版に戻る（作業用のフォルダも残らない）", raised and before == after)
v.BUILDS, sys.argv = saved

# ---- codex の3回目の検証（review_result_v4.md）の反例
L = base()
bw =[w for w in L["walls"] if w["label"] == "トイレの壁" and abs(w["rect"][0]) < 1e-9 and w["rect"][2] > 2.0][0]
L["walls"].remove(bw)
L["walls"] += [dict(label="トイレの壁", rect=[bw["rect"][0], bw["rect"][1], 1.005, bw["rect"][3]]),
               dict(label="トイレの壁", rect=[1.015, bw["rect"][1], bw["rect"][2], bw["rect"][3]])]
expect("反例A：トイレの壁の1cmの切れ目を見つける", any("切れ目" in m for _, m in v.check_rules(L)))
L = base()
L["walls"].append(dict(label="閉塞片", rect=[2.7, 14.905, 2.8, 14.915]))
expect("反例B：トイレの入口の内側の1cmのふさぎを見つける", any("入口に 閉塞片 が重なっている" in m for _, m in v.check_rules(L)))
L = base()
dz = L["toilet_doors"][0]
L["toilet_doors"][0] = [dz[0], dz[1], dz[0] + 0.1, dz[3]]
expect("反例C：トイレの入口の前の奥行き0.1mを見つける", any("奥行き" in m for _, m in v.check_rules(L)))
L = base()
L["outline"] = [[0, 0], [24, 0], [24, 16], [13.02, 16], [13.02, 15], [13.01, 15], [13.01, 16], [0, 16]]
expect("反例D：輪郭の幅1cmの切れ込み（建物の外なし）を見つける", any(k == "建物の形" for k, _ in v.check_rules(L)))
L = base()
L["outline"] = [[0, 0], [24, 0], [24, 16], [0, 15]]
expect("輪郭に斜めの辺があれば止める", any("縦と横の辺" in m for _, m in v.check_rules(L)))
L = v.prepare(famires_p0.p0_memory())
L["walls"].append(dict(label="壁", rect=list(L["doors"][0]["rect"])))
expect("出入り口Aを壁でふさぐと見つける", any(k == "出入り口" for k, _ in v.check_rules(L)))

# ---- Unity 用の細かい形（unity_export.py。2026-10-22）
import unity_export as ux
for nm, fn, width in (("p1", gen.p1_efficient_corner, 1.2), ("p5", gen.p5_crammed, 0.8)):
    L = v.prepare(fn())
    ctx = {}
    res, err = v.analyze(L, 0.025, ctx)
    U = ux.build(L, res, ctx["grid"], ctx["d_norm"], ctx["d_pass"], ctx["sides"], v.standby_slots(L["robot_standby"]), v.FURNITURE_TYPES, {})
    expect(f"{nm}：細い通路は3本で、幅はどれも{width}m", len(U["aisles"]) == 3 and all(abs(a["width"] - width) < 1e-6 for a in U["aisles"]))
    expect(f"{nm}：卓の数・止まる位置・距離が analysis と同じ", len(U["tables"]) == len(res) and
           all(t["stop"] == r.get("stop") and t["dist"] == r.get("dist") for t, r in zip(U["tables"], res)))
    expect(f"{nm}：細い通路を通る卓は、止まる位置がその通路の区画の中", all(
        any(a["id"] == t["aisle"] and a["rect"][0] - 0.05 <= t["stop"][0] <= a["rect"][2] + 0.05 and a["rect"][1] - 0.05 <= t["stop"][1] <= a["rect"][3] + 0.05
            for a in U["aisles"]) for t in U["tables"] if t["aisle"]))
    expect(f"{nm}：細い通路の入口は、受け渡し口の前から行ける端", all(a["entrance"] is not None for a in U["aisles"]))
    expect(f"{nm}：全部の卓に部品（天板・座る位置）があり、座る位置の数が席数と同じ",
           all(t["parts"] and len(t["parts"]["seat_points"]) == t["seats"] for t in U["tables"]))
    kinds = {p["kind"] for p in U["places"]}
    expect(f"{nm}：場所の点がそろっている", {"FoodPass", "DishReturn", "Entrance", "Waiting", "Register", "RegisterBack", "Toilet",
                                              "DrinkBar", "DrinkBarStand", "RobotStandby", "StaffStandby"} <= kinds)
    g = ctx["grid"]
    pts = [p["point"] for p in U["places"] if p["kind"] in ("StaffStandby", "RegisterQueue", "DrinkBarQueue", "DrinkBarStand")]
    expect(f"{nm}：スタッフが待つ点・並ぶ点・立つ点は、障害物から0.2m以上離れ、細い通路に入らない",
           all(g.clear[g.cell(x, y)] >= 0.2 - 1e-9 and not ux._in_aisle(U["aisles"], x, y) for x, y in pts))
    expect(f"{nm}：席に着く点は障害物から0.2m以上離れ、どの卓にも1つ以上ある",
           all(t["spots"] for t in U["tables"]) and all(g.clear[g.cell(*s["point"])] >= 0.2 - 1e-9 for t in U["tables"] for s in t["spots"]))
    expect(f"{nm}：レジ内は通れる部屋（スタッフだけ）、ほかの部屋は通れない箱",
           all((r["walkable"] and r.get("staff_only")) if r["label"] == "レジ内" else not r["walkable"] for r in U["rooms"]))
    # ロボットの道順（2026-10-22）
    R = ux.build_routes(L, res, g, v.SETTINGS["normal_width"], v.ROBOT_MODELS[v.SETTINGS["model"]]["pass_width"])
    fp = R["from_pass"]
    expect(f"{nm}：受け渡し口から全卓・卓から卓の全組に道順がある", len(fp) == len(res) and all(fp.values()) and
           len(R["between"]) == len(res) * (len(res) - 1) // 2 and all(R["between"].values()))
    expect(f"{nm}：ふだんの道か狭い道かは、試算の卓の分け方と同じ（robot→normal、narrow→narrow）",
           all((a["category"] == "robot") == (fp[t["id"]]["use"] == "normal") for t, a in zip(L["tables"], res)))
    expect(f"{nm}：道順は受け渡し口の前から始まり、止まる位置で終わる",
           all(fp[t["id"]]["points"][0] == [round(L["start"][0], 3), round(L["start"][1], 3)] and fp[t["id"]]["points"][-1] == [round(a["stop"][0], 3), round(a["stop"][1], 3)]
               for t, a in zip(L["tables"], res)))
    f_n, f_p = g.free(v.SETTINGS["normal_width"]), g.free(v.ROBOT_MODELS[v.SETTINGS["model"]]["pass_width"])
    allr = list(fp.values()) + list(R["between"].values())
    expect(f"{nm}：道順のどの区間も、その幅で通れるマス目だけを通る",
           all(ux._visible(f_n if r["use"] == "normal" else f_p, g, a, b) for r in allr for a, b in zip(r["points"], r["points"][1:])))
L = v.prepare(gen.p1_efficient_corner())
L["tables"][5]["x"] += 0.05   # 列の中の1卓をずらす → 列が切れる
cols = ux.booth_columns(L["tables"])
expect("ボックス席の列は、すき間なく並んだものだけをまとめる", len(cols) > len(ux.booth_columns(v.prepare(gen.p1_efficient_corner())["tables"])))

print("\nすべて OK" if ok else "\nNG あり")
raise SystemExit(0 if ok else 1)
```

### tools/sim_layouts/README.md
````markdown
# ファミレスの店の形（サービスロボット導入シミュレーション・配膳ロボットの試算用）

このフォルダの店の形が、サイトの試算と Unity の共通の元になる。Unity は `public/sim/layouts/` の JSON を読んで店を建てる。直すときは、ここ（サイト側）を先に直す。

## 何について正か（2026-10-22 エイイチと決めた）

| 何について | 正 | 扱い |
|---|---|---|
| 店の形（卓・壁・部屋・止まる位置） | サイト | このフォルダで作る JSON。Unity で形を作らない |
| 条件（スタッフ・来客・ロボットの台数） | サイト | 試算の図面のある店の値 |
| 店の回し方の決まりと時間、数字の定義（入店待ち・店が回らない） | サイト | 試算（haizen_sim.py）の値と定義 |
| 通路で起きること（実際の速さ・止まる回数と長さ・立ち往生） | Unity が測る | 測った値は、試算を先に直して入れる（`robot_speed`・`pause_rate`・`pause_s`・`stuck_rate`） |
| Unity が建てるための細かい形（通路の区画・椅子・座る位置・並ぶ点など） | このフォルダの規則 | JSON に書き出す。試算は使わない |
| Unity にしかないもの（組の人数・2人席・ドリンクバー・トイレ・椅子を引くなど） | Unity | 「Unity だけの値」として残し、試算とは比べない |

距離の測り方と Unity にしかないものの違いで、Unity と試算の数字は完全には合わない。合っているかは、向きと大きさで見る。

- `layout_v11.json`：記憶の店（昔働いていたファミレスを記憶から起こしたもの）。記憶のまま残し、変えない
- `famires_p0.py`：記憶の店を決まりに合わせた版（②）
- `famires_gen2.py`：③〜⑦の作り方 ／ `famires_v3.py`：決まりの確かめ・ロボットの届き方・図とデータの出力 ／ `test_famires_v3.py`：確かめの仕組みの確かめ

## Unity が店を建てるための細かい形（`public/sim/layouts/unity/{名前}.json`）

`unity_export.py` が、店の形と解析から決まった規則で作る（規則はファイルの先頭に書いた）。`famires_v3.py` を回すと一緒に出る。Unity はこれを読んで店を建て、店の形を自分で作らない。試算（haizen_sim.py）はこれを使わない。

- 卓の数・止まる位置・卓までの距離は analysis と同じ（テストで確かめる）。版の番号（generation）も同じ
- 細い通路：同じ向きのボックス席がすき間なく並んだ列の、隣り合う列のすき間（0.5〜1.5m）。両端の0.8m外に入口の点を置き、受け渡し口の前から近い方を entrance とする。③⑦は3本（③1.2m、⑦0.8m）
- 卓の部品：天板・ベンチ・椅子（2人席の通路側の椅子だけ引ける。引く向きつき）・座る位置（席数ぶん）・席に着くために立つ点（座る側の辺の真ん中から0.3m外。障害物から0.2m＝人の半径以上離れた所だけ）
- 部屋は通れない箱。ただしレジ内は `walkable: true, staff_only: true`（スタッフが中に立つ所。箱にしない）
- 場所の点：受け渡し口の前・下げ膳の返却（受け渡し口と同じ）・入口・待合・レジ（レジ内の反対側）と並ぶ点・レジ内・トイレの入口の前・ドリンクバーの前と立つ点・並ぶ点・ロボットの待機場所（1台ずつ）・スタッフが待つ点
- スタッフが待つ点：パントリー（と接する壁）の客席側の辺にそって壁から0.25m、0.6mおき。外側にロボットのふだんの道（1.0m）が残る所だけ。受け渡し口に近い順に6つ（仮の規則）
- 並ぶ点・立つ点は、障害物から0.25m未満の所と細い通路には置かない
- ロボットの道順（`routes`）：受け渡し口の前→各卓の止まる位置（`from_pass`）と、卓→卓（`between`。"T01|T02" は T01 から T02、逆向きは点を逆にたどる）。ふだん走らせる幅の道で行けるならその道、行けなければ通れる幅の道（試算と同じ規則。`use` が normal か narrow。試算の卓の分け方と一致することをテストで確かめる）。マス目の最短の道を、通れる範囲でまっすぐにつないだ曲がり角の点の並び。Unity のロボットはこの点を順にたどる（Unity で道を決めない）。2026-10-22、Unity で狭い通路の区画を NavMesh に書き込んだら NavMesh が壊れたため、この形にした
- まだないもの：② の4人席・丸テーブル・6人の長テーブルの椅子と座る位置（parts が null）、人がロボットをよける逃げ場（refuges）

## 動かし方

```
pip install numpy==2.5.3 scipy==1.18.1 matplotlib==3.11.2
python3 famires_v3.py              # 6つの店を確かめて描く（出力は public/sim/layouts/）
python3 famires_v3.py Servi        # 機種を替える（出力は public/sim/layouts/Servi/）
python3 test_famires_v3.py         # 確かめの仕組みそのものを確かめる
```

確かめた環境：Python 3.13.16、日本語フォント Noto Sans CJK JP（NotoSansCJK-Regular.ttc）。
フォントが違うと図の文字の収まりは変わるが、数字（`*_analysis.json`・`summary.json`）は変わらない。

## 決まり（2026-10-21 エイイチと決めたこと）

**店の形**
1. 基本の席は4人掛けのボックスの列。ボックスはベンチの背どうしが接して縦（または横）に並ぶ。2列を並べる島では、テーブルの端どうしが真ん中の低い仕切りをはさんで向き合い、座る側（開いた側）は外の通路に向く（日本のファミレスの映像で確かめた形）
2. 狭い所は2人席。家族・団体用に6人ボックスとL字ソファ
3. 効率重視型：仕切りなし・列の間の通路1.2m・2人席多め。ゆったり型：ボックスごとに仕切り・通路1.5m・6人席多め
4. 入口の位置はパターンごとに変える。入口の近くにレジ（2.5m以上）と待合
5. トイレ：客席に面する側は厚さ30cm以上の壁。トイレから1m以内（ちょうど1mも含む）に席を置くなら、席とトイレの入口の間に壁が要る。トイレの入口の前1.2mには何も置かない
6. ドリンクバーあり。ロボットの待機・充電の場所は料理の受け渡し口の横（1台あたり0.8m×0.8m）
7. ⑦は③と同じ卓・同じ席数のまま、列の間の通路だけを80cmに詰めた比較用
8. ②記憶の店は、昔働いていた店の記憶をもとにした店（layout_v11.json）を決まりに合わせた版。トイレとバックヤード（パントリーの出入り口・洗い場・物置）が客席の奥の壁より外へ出っ張り、奥の端はそろう。トイレへはパントリーの壁ぞいの通路から入る

**ロボット**
- 通れる幅は機種の公表値。初期値は BellaBot の80cm（ファミレスで多く使われ、調べた中で一番厳しい）

| 機種 | 公表の最小通過幅 | 出典 |
|---|---|---|
| BellaBot | 80cm | Pudu Academy |
| KettyBot | 55cm | Pudu Academy |
| Keenbot T8 | 55cm以上 | アイリスオーヤマ |
| KEENON T10 | 58.5cm | ミツモア |
| Servi | 60cm | innovatopia（初代の公表値） |
| Servi Q | 45cm | innovatopia |

- ふだん走らせる幅は1.0m（店の運用の設定。日本のファミレスの映像で、ボックスの横の1m前後の通路を走っていたため）
- 卓は3つに分ける：ロボットが届く（1.0m以上の道で行ける）／狭い道なら届く（80cm〜1.0mの道を使う）／人が運ぶ（ロボットから受け取って人が運ぶ。その分の歩く距離も出す）
- 届くかは、座る側の前の通路に止まれるかで判定する。ボックスの背中側・壁側・仕切り越しは数えない

## 決まり（2026-10-22 codex の2回目の検証を受けて、エイイチと決めたこと）

- L字席で止まってよいのは、テーブルのある内側の角の開いた2辺と、そこからソファの端に沿って0.6mまで（端の席の人が受け取れる所）。ソファの横は使わない
- 2人席で止まってよいのは、椅子の側とテーブルの横。ベンチの側・ベンチの横は使わない
- 待機場所は1台分0.8m×0.8m以上、台数は正の整数。1台ずつ、ほかの台が止まったままで受け渡し口の前まで出られること。②の待機場所は2.2×0.7mから2.4×0.8m（3台）に広げた
- ②：パントリーの上の通路への出入り口を2つ開けた（A＝パントリーの壁に1m・客席側とつながる、B＝パントリーの上の端）。どちらも扉はない（扉があると誰かが開けないといけない）。扉は上の通路から洗い場とキッチンへ入る所（位置は仮）。JSON の `layout.doors`（`door` が扉の有無）
- ②：仕切り壁の上の4人席2つを、壁との間0.6mまで寄せた（ロボットが壁側から入らないように）
- トイレから1m以内の席は、席のトイレ側の辺の両端からトイレの入口の両端へ引いた4本の線が、全部壁に当たること。4本の線は近似で、目隠しの壁に切れ目があると、4本は当たっても間から入口が見える置き方を見落とし得る（目隠しは1枚の続いた壁で作る）
- トイレの4辺は、外壁・裏の部屋・建物の外・厚さ30cm以上の壁・入口のどれかで、すきまなく囲む（辺を区間として計算で確かめる）。入口の前と入口そのものは、名前に関係なく何も置かない。入口の前の矩形は、入口の幅いっぱいで奥行き1.2m以上
- 店員の出入り口（`layout.doors`）に壁を重ねない。doors は図と Unity のための情報で、ロボットの道の計算には使わない（パントリーなどの部屋の中は障害物のまま）
- 「ロボットが届く」は、決めた辺の近くの止まる位置まで行けるという意味。客が実際に受け取れるか（天板までの距離・着席の位置）は測っていない
- レジ（2.5m以上）とレジ内（店員の立つ所）が両方あり、接していること
- 仕切りが卓に食い込んでよいのは、ボックス席のベンチの背の辺の上で、深さ8cm以内のときだけ
- 建物の輪郭（`outline`）は縦と横の辺だけでできた形にし、建物の外（`kind: outside`）と同じ所を表すこと（輪郭・建物の外・外形の座標で区切ったマスごとに照合するので、細い切れ込みも見逃さない）。輪郭の外に壁・待機場所・受け渡し口・出発点を置かない

## 計算の約束

- 障害物までの距離は、矩形から正確に出す。マス目は2.5cm
- 幅 w 以上の通路は必ず通れる、w−2.5cm 未満は必ず通れない。その間（2.5cmの幅）だけはマス目の位置で分かれる
- 毎回、マス目2.5cmと1.25cmの2回計算し、分類が分かれた卓は図に ? 印を付け、`resolution_mismatch` に書く。比べているのは分類だけで、距離や止まる位置が同じとは限らない
- 通れる幅の判定には半マス分の許容がある。道の一番狭い所（`narrowest`）が 0.97m のように、1.0m をわずかに下回ることがある
- 斜めの移動は両隣が通れるときだけ（すり抜けない）
- 待機場所は止まっているロボットごと障害物
- 止まる位置から卓の辺までの見通し、トイレの目隠しの線は、線と矩形の交わりを計算で求める（点を並べて確かめると、薄い壁を見逃すため）。止まる位置の候補は打ち切らずに全部見る
- 出発点（受け渡し口の前）が通れない場所なら、計算を止める
- 決まりに違反があれば、重い計算に進まず止まる。出力はいったん作業用のフォルダに出し、6つ全部が通ったときだけ出力先へ入れ替える。違反があれば出力先には何も書かない（前の版がそのまま残り、新旧が混ざらない）
- 入れ替えの途中で1つでも失敗したら、入れ替えた分を元に戻す（全部か、何もなしか）。summary.json は最後に入れ替える
- layout・analysis・summary には同じ版の番号（`generation`：作り方のコード・記憶の店の原本・設定から決まる）を入れる。読む側（サイトの試算・Unity）は、summary と各ファイルの `generation` が同じかを確かめてから使う。公開はサイトの git の commit 単位なので、公開されるのは常に1つの版の一式
- 出力先は `--out フォルダ` で替えられる

## まだ入っていないこと

- 厨房の中の人の動き（扉・作業通路・下げ膳の返却口）
- 人やほかのロボットとのすれ違い、複数台、旋回、積み下ろしの時間（いまは片道の最短距離だけ）
- 引かれた椅子・子ども用の椅子などで通路が一時的に狭くなる分
- 壁や仕切りの高さは仮の値（`furniture_types.walls.height_m_仮`）
- サイトの試算と Unity で共通に使うデータの形（座標の対応は JSON の `meta.coords` に書いた）
````

### simulation/v2_5/export_params.py
```python
"""試算（haizen_sim.py）の条件と店の回し方の値を、Unity が読む JSON に書き出す（2026-10-22）。

「何について正か」（unity_design_v0.md の 0 章）で、条件と店の回し方の決まり・時間・数字の定義はサイトが正。
Unity はこの JSON を読み、値を手で写さない（LOG359 のずれ9「値の手写し」をなくすため）。

使い方: python3 export_params.py results.json <出力先>   （出力先は p2m-site の public/sim/serving-robot-params.json）
- common：店に共通の値。role が "unity_measures" の値は Unity で測ってサイトに戻すための仮の値なので、Unity は読まない
- stores：Unity で回す店（fam_p1・fam_p5）の値
- unity_conditions：Unity で回す6条件と、同じ条件の試算の結果（40回の平均）
"""
import json
import sys

import haizen_sim as h

res_path, out = sys.argv[1], sys.argv[2]
R = json.load(open(res_path, encoding="utf-8"))
gen = {g["generation"] for g in json.load(open(h.LAYOUT_DIR + "/summary.json", encoding="utf-8"))}
assert len(gen) == 1, gen

ROLE = {
    "robot_speed": "unity_measures", "pause_rate": "unity_measures", "pause_s": "unity_measures", "stuck_rate": "unity_measures",
    "hop_m": "site_only",          # 試算の近似（1往復で複数卓を回るときの寄り道）。Unity は実際の道を走るので使わない
    "sustain": "site_only", "warmup_min": "site_only", "util_sample_s": "site_only", "model": "site_only",
}
NOTE = {
    "unity_measures": "Unity で測ってサイトに戻すための仮の値。Unity は読まない",
    "site_only": "試算の中だけの設定。Unity は使わない",
    "site_rule": "サイトが正。Unity はこの値を使う",
}
common = {k: {"value": v, "role": ROLE.get(k, "site_rule")} for k, v in h.COMMON.items()}

UNITY_STORES = ["fam_p1", "fam_p5"]
stores = {}
for k in UNITY_STORES:
    s = dict(h.STORES[k])
    stores[k] = s

conds = []
rows = {h.key(r): r for r in R["rows"]}
for k in UNITY_STORES:
    s = h.STORES[k]
    staff = s["staff"][1]           # 真ん中の人数
    arr = s["arrivals"][1]          # 普通
    for robots in (0, 1, 2):
        c = {"store": k, "staff": staff, "robots": robots, "arrivals_per_h": arr, "mode": "serve",
             "trays": 3 if robots else 1, "fragile_by": "any"}   # ロボット0台の試算は trays=1 で持っている（使い方・段の数は関係ない）
        r = rows[h.key(c)]
        conds.append({"condition": c, "site_result": {m: r[m] for m in r if m not in c}})

D = {
    "meta": {
        "schema_version": "serving-robot-params/0.1",
        "model": h.COMMON["model"], "reps": h.REPS, "ng_min": h.NG_MIN, "layout_generation": gen.pop(),
        "source": "p2m-site の public/sim/serving-robot.html と同じ試算（haizen_sim.py）と結果（results.json）",
        "roles": NOTE,
        "time_window": "空の店から始め、peak_min 分だけ選んだペースで来客し、全員が帰るまで回す（max_extra_min で打ち切り）",
        "metrics": {
            "entry_wait_mean": "入店待ち：店に来てから案内されて卓に着くまで（分）",
            "full_wait_mean": "そのうち満席で待った時間（分）",
            "staff_wait_mean": "そのうち席は空いているのに片付け・案内が追いつかず待った時間（分）。ng_min 以上なら「店が回らない」",
            "dish_wait_mean": "料理ができてから卓に届くまで（分）", "dish_wait_p90": "同じく上位10%の境目（分）",
            "cold_share": "cold_min 分を超えて届いた皿の割合", "turn_mean": "客が帰ってから卓が使えるまで（分）",
            "walk_km_per_staff": "スタッフ1人が歩いた距離（km）", "staff_util": "スタッフの手がふさがっている割合",
            "robot_util": "ロボットがふさがっている割合", "robot_share": "ロボットが運んだ皿の割合",
            "rescues": "立ち往生でスタッフが動かしに行った回数", "pauses": "一時停止の回数", "groups": "入れた組の数",
        },
    },
    "common": common,
    "stores": stores,
    "unity_conditions": conds,
}
json.dump(D, open(out, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
print(out, len(conds), "conditions / generation", D["meta"]["layout_generation"])
for c in conds:
    print(" ", c["condition"], {m: c["site_result"].get(m) for m in ("entry_wait_mean", "staff_wait_mean", "dish_wait_mean")})
```

### simulation/v2_5/build_page_v25.py
```python
"""試算の結果（results.json）から、公開用の試算ページ（serving-robot.html）を作る（v2.5）。

使い方: python3 build_page_v25.py results.json page_v25.template.html <出力先>
  v2.5：試算のモデルは v2.4 のまま、店の形（layouts/）を直した版に入れ替え、② 定番の郊外型だけ回し直した。
  店の形の版（generation）がそろっているか、試算に使った卓までの距離が店の形の距離と同じかを確かめてから作る。
  results.json は haizen_sim.py（v2.4）を総当たりで回した結果。v2.4 で入店待ちの終わりを卓に着いたときにし、
  「下げ膳にも使う」でロボットがスタッフと一緒に卓へ向かい、載せ終えるまで待つ形に直した。
  v2.3 で足したのは、標準ファミレスの中の「図面のある店」（fam_p0〜fam_p5）と、その平面図（D.plans）。
  平面図は layouts/ の店の形（正は p2m-site の public/sim/layouts/）から、描くのに要る所だけを取り出す。
"""
import json, os, sys
import haizen_sim as h

res_path, tpl_path, out = sys.argv[1], sys.argv[2], sys.argv[3]
R = json.load(open(res_path, encoding="utf-8"))
keys = ["store", "staff", "robots", "arrivals_per_h", "mode", "trays", "fragile_by",
        "dish_wait_mean", "dish_wait_p90", "cold_share", "entry_wait_mean", "full_wait_mean", "staff_wait_mean",
        "turn_mean", "walk_km_per_staff", "staff_util", "robot_util", "robot_share", "robot_bus_share",
        "collects", "rescues", "pauses", "fragile_robot", "groups"]
rows = []
for r in R["rows"]:
    row = [round(r[k], 3) if i >= 7 else r[k] for i, k in enumerate(keys)]
    row.append(1 if r["staff_wait_mean"] >= h.NG_MIN else 0)   # 丸める前の値で決める
    rows.append(row)

# 図面のある店：画面で使う名前と平面図
stores = R["stores"]
plans = {}
q = lambda a: [round(v, 2) for v in a]
for k, s in stores.items():
    if not s.get("layout"):
        continue
    s["short"] = s["label"]
    s["label"] = f"標準ファミレス（{s['short']}）"
    s["dist_mean"] = round(sum(s["dist_list"]) / len(s["dist_list"]), 1)
    s.pop("dist_list", None)   # ページには要らない（試算に使った距離は results.json と layouts/ にある）
    f = [x for x in os.listdir(h.LAYOUT_DIR) if x.startswith(s["layout"] + "_") and x.endswith(".json") and "_analysis" not in x][0]
    L = json.load(open(os.path.join(h.LAYOUT_DIR, f), encoding="utf-8"))["layout"]
    W, Dd = L["outer"]["w"], L["outer"]["d"]
    plans[k] = {
        "w": W, "d": Dd,
        "outline": L.get("outline") or [[0, 0], [W, 0], [W, Dd], [0, Dd]],
        "rooms": [q(r["rect"]) + [r["kind"], r["label"]] for r in L["rooms"] if r["kind"] != "outside"],
        "walls": [q(w["rect"]) for w in L["walls"]],
        "tables": [q([t["x"], t["y"], t["x"] + t["w"], t["y"] + t["h"]]) + [t["seats"]] for t in L["tables"]],
        "dish": [q(d["rect"]) for d in L["dish_up"]],
        "standby": q(L["robot_standby"]["rect"]),
        "ent": [L["entrance"]["side"], L["entrance"]["a0"], L["entrance"]["a1"]],
    }

# 店の形の版がそろっているか（layouts/ の summary・layout・analysis の generation が全部同じ）と、
# 試算に使った卓までの距離が、いまの店の形の距離と同じか
gens = {g["generation"] for g in json.load(open(os.path.join(h.LAYOUT_DIR, "summary.json"), encoding="utf-8"))}
for f in os.listdir(h.LAYOUT_DIR):
    if f.endswith(".json") and f != "summary.json":
        gens.add(json.load(open(os.path.join(h.LAYOUT_DIR, f), encoding="utf-8"))["meta"]["generation"])
if len(gens) != 1:
    sys.exit(f"店の形の版がそろっていない: {gens}")
for f in os.listdir(h.LAYOUT_DIR):
    if f.endswith("_analysis.json"):
        nm = "fam_" + f.split("_")[0]
        dists = [t["dist"] for t in json.load(open(os.path.join(h.LAYOUT_DIR, f), encoding="utf-8"))["tables"]]
        if json.load(open(res_path, encoding="utf-8"))["stores"][nm]["dist_list"] != dists:
            sys.exit(f"{nm}: 試算に使った距離が、いまの店の形と違う（試算を回し直す）")
layout_gen = gens.pop()

D = {"layout_generation": layout_gen, "k": keys + ["ng"], "r": rows, "stores": stores, "common": R["common"], "plans": plans,
     "robots": R["robots"], "robots_all": R["robots_all"], "ng_min": h.NG_MIN}
html = open(tpl_path, encoding="utf-8").read().replace("__DATA__", json.dumps(D, ensure_ascii=False, separators=(",", ":")))
open(out, "w", encoding="utf-8").write(html)
print(len(html), "bytes /", len(rows), "conditions / 店が回らない", sum(r[-1] for r in rows), "/ 店の形の版", layout_gen)
```

### simulation/v2_5/README.md
```markdown
# 配膳ロボットの試算 v2.5（2026-10-22）

試算のモデルは v2.4 のまま（`haizen_sim.py` は v2.4 と同じ。COMMON の `model` も "v2.4"）。
店の形（`layouts/`）を、codex の2回目・3回目の検証とエイイチの指摘で直した版に入れ替え、② 定番の郊外型（fam_p0）だけ回し直した版。

## 店の形で変わったこと（正は p2m-site の `public/sim/layouts/`。版の番号 generation = 78359abbe188 → 9628954a582f）

- L字席で止まってよい所を、テーブルのある内側の角の開いた2辺と、そこからソファの端に沿って0.6mまでにした
- 2人席で止まってよい所を、椅子の側とテーブルの横にした（ベンチの側・横は使わない）
- ② の待機場所を2.2×0.7mから2.4×0.8m（3台）に広げた
- ② のパントリーの上の通路に出入り口を2つ開けた（A＝パントリーの壁に1m、B＝パントリーの上の端。どちらも扉なし）。扉は洗い場とキッチンの入口
- ② の仕切り壁の上の4人席2つを、壁との間0.6mまで寄せた（ロボットが壁側から入らないように）

同じ日の夕方、店の形の書き出しに Unity 用の細かい形（`unity/`）を足して直したため、版の番号が b700048057af → e624bdf4bdd4 → 9628954a582f（ロボットの道順を足した）に変わった。卓・距離・止まる位置は同じなので、試算は回し直さず、ページだけ作り直した（`layouts/` もこの版の写し）。これからも、数字が同じまま版の番号だけ変わったときは、ページだけ作り直す（いまの版は summary.json とページの `layout_generation` で見る）。

距離が変わったのは ② だけ（T01・T02・T03・T05・T06・T07・T08・T18。卓までの平均 8.49m → 8.56m、最長 14.01m → 13.78m）。③〜⑦は卓・距離・止まる位置が前と同じ。

## v2.4 と比べた結果（compare_v24_v25.txt）

- 16,425条件の並びは同じ。② 以外の15,111条件は、数字がすべて v2.4 と同じ（前の結果をそのまま使った）
- ② の1,314条件は、卓までの距離が変わったので全部回し直した（乱数の出方も変わる）。入店待ちの差は平均 −0.09分（−4.3〜+3.9分）
- 店が回らないの数は、② が124 → 124、全体が3,007 → 3,007 で変わらない

## ファイル

- `haizen_sim.py`：試算のモデル（v2.4 と同じ）
- `results.json`：総当たりの結果（16,425条件×40回）
- `layouts/`：店の形と解析結果と summary（p2m-site の public/sim/layouts/ の写し）
- `build_page_v25.py`：`python3 build_page_v25.py results.json page_v25.template.html serving-robot.html`。店の形の版（generation）がそろっているか、試算に使った卓までの距離がいまの店の形と同じかを確かめてから作る（違えば止まる）
- `page_v25.template.html`：v2.4 から、見出しの「試作 v2.4」を「試作 v2.5」にしただけ
- `test_page_v25.js`：`node test_page_v25.js serving-robot.v25.html`（全部の組み合わせを描いて例外がないか確かめる）
- `serving-robot.v25.html`：作ったページ（p2m-site の `public/sim/serving-robot.html` に入れた）
- `compare_v24_v25.txt`：v2.4 と比べた結果
- `export_params.py`：`python3 export_params.py results.json serving-robot-params.json`。条件と店の回し方の値、Unity で回す6条件と同じ条件の試算の結果を、Unity が読む JSON に書き出す（p2m-site の `public/sim/serving-robot-params.json` に入れた）。Unity で測る値（robot_speed・pause_rate・pause_s・stuck_rate）は role が unity_measures で、Unity は読まない

v2_4/・v2_3/ は前の版。公開済みの記事（LOG359・LOG360 など）の数字は前の版のまま（LOG は直さない）。
```

Unity 側（Claude Code が書いたもの。Unity Version Control cs:116 の時点。SimParams.cs と ServingRobotController.cs には、まだ回していない v5 の3章の変更も入っている）

### Assets/ServiceSim/Store/JsonStoreLayout.cs
```csharp
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// サイトが書き出した「店の形の JSON」を読んで店を建てる StoreLayout。
    ///
    /// 2026-10-22 にエイイチと決めた「何について正か」（unity_design_v0.md の 0 章）:
    ///   店の形（卓・壁・部屋・止まる位置）は **サイトが正**。Unity は形を作らない。
    /// なので、このクラスは JSON を StorePlan に写すだけで、足りないものを自分で埋めない。
    /// 合わないものが出たら、埋めずに報告へ書いて **建てずに止める**（Compose が null を返す）。
    ///
    /// 読むもの:
    ///   layoutJson   https://pixelstomotors.com/sim/layouts/unity/{名前}.json を写したもの
    ///   summaryJson  https://pixelstomotors.com/sim/layouts/summary.json を写したもの
    /// 版の番号（meta.generation）が summary.json と違ったら建てない。
    ///
    /// 座標: JSON の x → Unity の X、JSON の y → Unity の Z、高さ → Y。単位は m。
    ///       原点は店の外壁の内側の左下の角。
    /// </summary>
    [CreateAssetMenu(fileName = "StoreLayout_Json", menuName = "ServiceSim/Json Store Layout", order = 12)]
    public class JsonStoreLayout : StoreLayout
    {
        [Header("読む JSON（サイトから写したもの）")]
        [Tooltip("店の形の JSON（unity/{名前}.json）")]
        public TextAsset layoutJson;

        [Tooltip("一覧の JSON（summary.json）。版の番号を突き合わせる")]
        public TextAsset summaryJson;

        [Tooltip("summary.json の file と突き合わせる名前（例 p1_efficient_corner）")]
        public string layoutFileName = "";

        [Header("写した記録（報告に出す）")]
        [Tooltip("サイトから写した日時")]
        public string fetchedAt = "";

        [Tooltip("写し元の URL")]
        public string sourceUrl = "";

        [Header("見た目だけ（当たりも NavMesh も持たない）")]
        [Tooltip("ベンチの座面の見た目の厚み（m）。0 で出さない")]
        public float benchSeatPadThickness = 0.03f;

        [Tooltip("座面の見た目を、ベンチのふちから内側へ詰める長さ（m）")]
        public float benchSeatInset = 0.03f;

        // ---- 建てたときの覚え書き（報告に出す。Compose のたびに作り直す）----
        readonly List<string> _problems = new List<string>();
        readonly List<string> _doors = new List<string>();
        string _generation = "";
        string _storeName = "";
        float _jsonNarrowWidth = 0f;
        float _normalWidth = 0f;
        int _tableCount, _seatCount, _narrowAisleCount, _chairCount, _spotCount, _seatPointCount;
        int _roomCount, _placeCount, _wallCount, _dividerCount;
        int _walkableRoomCount, _staffOnlyRoomCount;
        int _routeFromPass, _routeBetween;

        public override string StoreKindName
        {
            get { return "JsonStoreLayout(" + (string.IsNullOrEmpty(layoutFileName) ? "?" : layoutFileName) + ")"; }
        }

        /// <summary>この JSON の版の番号（meta.generation）。建てたあとに読める</summary>
        public string Generation { get { return _generation; } }

        /// <summary>
        /// 版の番号を、建てる前でも読む（試算の値と突き合わせるのに使う）。
        /// 建てたあとは Compose が読んだものを返す。
        /// </summary>
        public string ReadGeneration()
        {
            if (!string.IsNullOrEmpty(_generation)) return _generation;
            if (layoutJson == null) return "";
            string err;
            JsonValue root = JsonValue.Parse(layoutJson.text, out err);
            return root != null && root.IsObject ? root["meta"]["generation"].S("") : "";
        }

        /// <summary>建てた卓の数（JSON の tables の数）</summary>
        public int TableCount { get { return _tableCount; } }

        /// <summary>
        /// サイトの決まりの「ふだん走らせる道の幅」（meta.settings.normal_width, m）。
        /// これ未満の幅の通路が「狭い通路」。0 なら JSON に無い
        /// </summary>
        public float NormalWidth { get { return _normalWidth; } }

        /// <summary>
        /// 試算の値（serving-robot-params.json）の stores の名前（fam_p1 / fam_p5）。
        /// layoutFileName（p1_efficient_corner / p5_crammed）の頭の "p1" "p5" から作る。
        /// 合っているかは SimParams.Store() が null かどうかで分かる。
        /// </summary>
        public string ParamsStoreKey
        {
            get
            {
                if (string.IsNullOrEmpty(layoutFileName)) return "";
                int cut = layoutFileName.IndexOf('_');
                string head = cut > 0 ? layoutFileName.Substring(0, cut) : layoutFileName;
                return "fam_" + head;
            }
        }

        /// <summary>
        /// Build Store が store_report と Console に出す欄。
        /// JSON から建てたときは、版の番号・写した日時・数えたもの・合わなかったものをここに出す。
        /// </summary>
        public override string FrontFitReport()
        {
            StringBuilder sb = new StringBuilder();
            sb.AppendLine("JSON から建てた店（Unity では形を作っていない）");
            sb.AppendLine("  ファイル: " + (layoutJson != null ? layoutJson.name : "（未設定）") +
                          " / 名前: " + _storeName);
            sb.AppendLine("  版の番号 (meta.generation): " + _generation);
            sb.AppendLine("  写した日時: " + (string.IsNullOrEmpty(fetchedAt) ? "（未記入）" : fetchedAt));
            sb.AppendLine("  写し元: " + (string.IsNullOrEmpty(sourceUrl) ? "（未記入）" : sourceUrl));
            sb.AppendLine("  卓 " + _tableCount + " / 席 " + _seatCount +
                          " / 細い通路 " + _narrowAisleCount + " 本（幅 " + F(_jsonNarrowWidth) + " m）");
            sb.AppendLine("  通れない部屋 " + _roomCount +
                          " / 通れる部屋 " + _walkableRoomCount +
                          "（うちスタッフだけ " + _staffOnlyRoomCount + "）");
            sb.AppendLine("  場所の点 " + _placeCount +
                          " / 壁 " + _wallCount + " / 仕切り " + _dividerCount +
                          " / 椅子 " + _chairCount +
                          " / 席に着く点 " + _spotCount + " / 座る位置 " + _seatPointCount);
            sb.AppendLine("  道順 (routes): 受け渡し口から " + _routeFromPass + " 本 / 卓から卓 " +
                          _routeBetween + " 本"
                          + "（狭い道を使うかは routes の use が正。Unity では印を持たない）");
            sb.AppendLine("  doors: " + (_doors.Count == 0
                ? "0（壁の切れ目として、もう空いている）"
                : _doors.Count + " → " + string.Join(" / ", _doors.ToArray())));
            sb.AppendLine("  refuges（よけ場所）: 0（JSON にまだ無い。空のまま）");
            sb.AppendLine("  止まる位置は JSON の stop をそのまま使う"
                          + "（deliveryOffsetFromAisleCenter と aisleEntranceSetback は使わない）");
            if (_problems.Count == 0) sb.AppendLine("  合わないもの: なし");
            else
            {
                sb.AppendLine("  合わないもの: " + _problems.Count + " 件（建てていない）");
                for (int i = 0; i < _problems.Count; i++) sb.AppendLine("    - " + _problems[i]);
            }
            return sb.ToString().TrimEnd();
        }

        protected override StorePlan Compose()
        {
            _problems.Clear();
            _doors.Clear();
            _generation = ""; _storeName = ""; _jsonNarrowWidth = 0f; _normalWidth = 0f;
            _tableCount = _seatCount = _narrowAisleCount = _chairCount = 0;
            _spotCount = _seatPointCount = _roomCount = _placeCount = _wallCount = _dividerCount = 0;
            _walkableRoomCount = _staffOnlyRoomCount = 0;
            _routeFromPass = _routeBetween = 0;

            if (layoutJson == null)
            {
                Fail("店の形の JSON（layoutJson）が入っていません");
                return null;
            }

            string err;
            JsonValue root = JsonValue.Parse(layoutJson.text, out err);
            if (root == null || !root.IsObject)
            {
                Fail("店の形の JSON を読めません: " + err);
                return null;
            }

            JsonValue meta = root["meta"];
            _generation = meta["generation"].S("");
            _storeName = root["name"].S("");

            // サイトの決まりの「ふだん走らせる道の幅」（meta.settings.normal_width）。
            // これ未満の通路が「狭い通路」。往復ごとの測りを中と外で分けるのに使う（v5 3章）
            _normalWidth = meta["settings"]["normal_width"].F(0f);

            if (!CheckGeneration()) return null;

            // ---- 高さ（JSON の meta.heights。Unity 側では決めない）----
            JsonValue heights = meta["heights"];
            float hWall = Req(heights, "wall", "meta.heights.wall");
            float hTable = Req(heights, "table", "meta.heights.table");
            float hBench = Req(heights, "bench", "meta.heights.bench");
            float hBenchSeat = Req(heights, "bench_seat", "meta.heights.bench_seat");
            float hChair = Req(heights, "chair", "meta.heights.chair");
            float hCounter = Req(heights, "counter", "meta.heights.counter");
            float hPartition = heights["partition"].F(1.2f);   // これ以下の walls は仕切り


            StorePlan plan = new StorePlan();

            // ---- 床 ----
            JsonValue floor = root["floor"];
            for (int i = 0; i < floor.Count; i++)
            {
                PlanRect r;
                if (TryRect(floor[i], "floor[" + i + "]", out r)) plan.AddFloor(r);
            }
            if (plan.floorPieces.Count == 0) Fail("floor が空です");

            // ---- 外壁 ----
            JsonValue outerWalls = root["outer_walls"];
            for (int i = 0; i < outerWalls.Count; i++)
            {
                PlanRect r;
                if (!TryRect(outerWalls[i], "outer_walls[" + i + "]", out r)) continue;
                plan.AddBox("OuterWall" + i.ToString("00"), BoxStyle.Wall, r, hWall);
                _wallCount++;
            }

            // ---- 中の壁・仕切り（高さで分ける）----
            JsonValue walls = root["walls"];
            for (int i = 0; i < walls.Count; i++)
            {
                JsonValue w = walls[i];
                PlanRect r;
                if (!TryRect(w["rect"], "walls[" + i + "].rect", out r)) continue;
                float h = w["height"].F(hWall);
                bool divider = h <= hPartition + 0.0005f;
                string label = w["label"].S("Wall" + i.ToString("00"));
                plan.AddBox(Safe(label) + i.ToString("00"),
                            divider ? BoxStyle.Divider : BoxStyle.Wall, r, h);
                if (divider) _dividerCount++; else _wallCount++;
            }

            // ---- 場所の点（先に入れる。PointOf がこちらを先に拾うように）----
            Dictionary<PlaceKind, Vector3> firstPoint = new Dictionary<PlaceKind, Vector3>();
            JsonValue places = root["places"];
            for (int i = 0; i < places.Count; i++)
            {
                JsonValue p = places[i];
                string kindName = p["kind"].S("");
                PlaceKind kind;
                if (!TryParseKind(kindName, out kind))
                {
                    Fail("places[" + i + "]: PlaceKind に無い kind \"" + kindName + "\"");
                    continue;
                }
                Vector3 point;
                if (!TryPoint(p["point"], "places[" + i + "].point", out point)) continue;

                PlanRect rect;
                if (!p.Has("rect") || !TryRect(p["rect"], "places[" + i + "].rect", out rect))
                    rect = new PlanRect(0f, 0f, 0f, 0f);

                // 箱は置かない（通れない箱は rooms が持つ）。ここは点だけ。
                plan.AddPlace(kind, Safe(p["label"].S(kindName)) + i.ToString("00"), rect, 0f, point, false);
                if (!firstPoint.ContainsKey(kind)) firstPoint[kind] = point;
                _placeCount++;
            }

            // ---- 部屋（通れない箱）----
            JsonValue rooms = root["rooms"];
            for (int i = 0; i < rooms.Count; i++)
            {
                JsonValue rm = rooms[i];
                string label = rm["label"].S("");
                PlaceKind kind;
                if (!TryRoomKind(label, out kind))
                {
                    Fail("rooms[" + i + "]: PlaceKind に対応の無いラベル \"" + label + "\"");
                    continue;
                }
                PlanRect r;
                if (!TryRect(rm["rect"], "rooms[" + i + "].rect", out r)) continue;

                bool walkable = rm["walkable"].B(false);
                bool staffOnly = rm["staff_only"].B(false);

                // スタッフだけが入れる区画は、いまの作りでは「レジの内側」だけ
                // （NavMeshBaker.AddRegisterBackArea が PlaceKind.RegisterBack の rect を見て
                //   区画の番号を変え、客の NavMeshAgent がその区画を外して歩く）。
                // ほかの部屋に staff_only が付いたら、こちらで作らずに報告して止める。
                if (staffOnly && kind != PlaceKind.RegisterBack)
                {
                    Fail("rooms[" + i + "] \"" + label + "\": staff_only は今の作りでは" +
                         "レジの内側（RegisterBack）だけです");
                    continue;
                }

                if (walkable)
                {
                    // 通れない箱にしない（人が入る部屋）。場所の点だけ置く。
                    // 同じ種類の点が places にあれば、そちらが区画の広がりも持っているので足さない。
                    if (!firstPoint.ContainsKey(kind))
                    {
                        plan.AddPlace(kind, Safe(label) + "_Room" + i.ToString("00"), r, 0f, r.Center, false);
                        firstPoint[kind] = r.Center;
                    }
                    _walkableRoomCount++;
                    if (staffOnly) _staffOnlyRoomCount++;
                    continue;
                }

                // レジとドリンクバーはカウンターの高さ、それ以外は壁の高さ
                float h = (kind == PlaceKind.Register || kind == PlaceKind.DrinkBar) ? hCounter : hWall;

                // 点は、同じ種類の場所の点（JSON が決めた立ち位置）に合わせる。無ければ真ん中
                Vector3 point;
                if (!firstPoint.TryGetValue(kind, out point)) point = r.Center;

                plan.AddPlace(kind, Safe(label) + "_Room" + i.ToString("00"), r, h, point, true);
                _roomCount++;
            }

            // ---- 細い通路 ----
            Dictionary<string, int> aisleIndexById = new Dictionary<string, int>();
            JsonValue aisles = root["aisles"];
            for (int i = 0; i < aisles.Count; i++)
            {
                JsonValue a = aisles[i];
                PlanRect r;
                if (!TryRect(a["rect"], "aisles[" + i + "].rect", out r)) continue;
                Vector3 entrance;
                if (!TryPoint(a["entrance"], "aisles[" + i + "].entrance", out entrance)) continue;
                string id = a["id"].S("A" + (i + 1).ToString("00"));
                bool narrow = a["narrow"].B(true);
                aisleIndexById[id] = plan.aisles.Count;
                plan.AddAisle(id, r, narrow, entrance);
                float aw = a["width"].F(0f);
                if (narrow)
                {
                    _narrowAisleCount++;
                    if (_jsonNarrowWidth <= 0f) _jsonNarrowWidth = aw;
                    else if (Mathf.Abs(_jsonNarrowWidth - aw) > 0.0005f)
                        Fail("細い通路の幅がそろっていません: " + F(_jsonNarrowWidth) + " と " + F(aw));
                }
            }

            // ---- 卓 ----
            JsonValue tables = root["tables"];
            for (int i = 0; i < tables.Count; i++)
            {
                ComposeTable(plan, tables[i], i, aisleIndexById,
                             hTable, hBench, hBenchSeat, hChair);
            }
            _tableCount = plan.seats.Count;

            // ---- ロボットの道順（v5b。サイトが決めた曲がり角の点）----
            plan.routes = ReadRoutes(root["routes"]);

            // ---- doors（箱は置かない。報告に一覧だけ出す）----
            JsonValue doors = root["doors"];
            for (int i = 0; i < doors.Count; i++)
            {
                JsonValue d = doors[i];
                string label = d["label"].S(d["id"].S("door" + i));
                PlanRect r;
                string where = TryRect(d["rect"], "doors[" + i + "].rect", out r) ? r.ToString() : "（rect なし）";
                _doors.Add(label + " " + where);
            }

            // ---- 細い通路の幅が .asset の値と合っているか ----
            if (_jsonNarrowWidth > 0f && Mathf.Abs(AisleWidth - _jsonNarrowWidth) > 0.0005f)
            {
                Fail("細い通路の幅が .asset と JSON で違います: .asset " + F(AisleWidth) +
                     " m / JSON " + F(_jsonNarrowWidth) + " m" +
                     "（aisleWidthOptions を JSON の幅だけにしてください）");
            }

            if (_problems.Count > 0)
            {
                Debug.LogError("[ServiceSim] " + name + ": JSON と合わないものが " + _problems.Count +
                               " 件あるので建てませんでした。\n" + FrontFitReport());
                return null;
            }

            return plan;
        }

        /// <summary>
        /// routes を読む（v5b）。from_pass["T05"] と between["T05|T12"]。
        /// 点は [x, y] で、JSON の y → Unity の Z。
        /// </summary>
        RouteBook ReadRoutes(JsonValue routes)
        {
            if (!routes.IsObject)
            {
                Fail("routes がありません（v5b の版の JSON を写してください）");
                return null;
            }
            RouteBook book = new RouteBook();

            JsonValue fromPass = routes["from_pass"];
            foreach (string id in fromPass.Keys)
            {
                RouteBook.Route r = ReadRoute(fromPass[id], "routes.from_pass[" + id + "]");
                if (r != null) book.AddFromPass(NumberFromId(id, -1), r);
            }

            JsonValue between = routes["between"];
            foreach (string key in between.Keys)
            {
                int bar = key.IndexOf('|');
                if (bar <= 0)
                {
                    Fail("routes.between のキーが \"T01|T02\" の形ではありません: " + key);
                    continue;
                }
                RouteBook.Route r = ReadRoute(between[key], "routes.between[" + key + "]");
                if (r != null)
                    book.AddBetween(NumberFromId(key.Substring(0, bar), -1),
                                    NumberFromId(key.Substring(bar + 1), -1), r);
            }

            _routeFromPass = book.FromPassCount;
            _routeBetween = book.BetweenCount;
            if (_routeFromPass == 0) Fail("routes.from_pass が空です");
            return book;
        }

        RouteBook.Route ReadRoute(JsonValue v, string where)
        {
            JsonValue pts = v["points"];
            if (pts.Count < 2)
            {
                Fail(where + ": points が 2 点未満です");
                return null;
            }
            RouteBook.Route r = new RouteBook.Route();
            for (int i = 0; i < pts.Count; i++)
            {
                Vector3 p;
                if (!TryPoint(pts[i], where + ".points[" + i + "]", out p)) return null;
                r.points.Add(p);
            }
            r.use = v["use"].S("normal");
            r.width = v["width"].F(0f);
            r.length = v["length"].F(r.PolylineLength);
            if (r.use != "normal" && r.use != "narrow")
            {
                Fail(where + ": use が normal / narrow ではありません（" + r.use + "）");
                return null;
            }
            return r;
        }

        // ---------------- 卓 ----------------

        void ComposeTable(StorePlan plan, JsonValue t, int index,
                          Dictionary<string, int> aisleIndexById,
                          float hTable, float hBench, float hBenchSeat, float hChair)
        {
            string id = t["id"].S("T" + (index + 1).ToString("00"));
            string where = "tables[" + index + "] " + id;

            SeatKind kind;
            string type = t["type"].S("");
            if (!TryTableKind(type, out kind))
            {
                Fail(where + ": SeatKind に対応の無い type \"" + type + "\"");
                return;
            }

            JsonValue parts = t["parts"];
            PlanRect top;
            if (!TryRect(parts["top"], where + ".parts.top", out top)) return;

            Vector3 stop;
            if (!TryPoint(t["stop"], where + ".stop", out stop)) return;

            PlanSeat seat = new PlanSeat();
            seat.tableId = NumberFromId(id, index + 1);
            seat.kind = kind;
            seat.tableRect = top;
            seat.tableHeight = hTable;
            seat.seatCount = t["seats"].I(0);
            seat.deliveryPoint = stop;             // JSON の stop をそのまま（ずらさない）

            // 通る細い通路
            string aisleId = t["aisle"].S(null);
            if (string.IsNullOrEmpty(aisleId)) seat.aisleIndex = -1;
            else
            {
                int ai;
                if (aisleIndexById.TryGetValue(aisleId, out ai)) seat.aisleIndex = ai;
                else { Fail(where + ": aisles に無い通路 \"" + aisleId + "\""); return; }
            }

            // 天板
            plan.AddBox(id + "_Top", BoxStyle.Table, top, hTable);

            // ベンチ（座面の見た目は今までの作りどおり、座面の上に当たりなしの板を敷く）
            JsonValue benches = parts["benches"];
            for (int b = 0; b < benches.Count; b++)
            {
                PlanRect r;
                if (!TryRect(benches[b], where + ".parts.benches[" + b + "]", out r)) continue;
                plan.AddBox(id + "_Bench" + (b + 1), BoxStyle.Bench, r, hBench);
                AddBenchSeatLook(plan, id + "_Bench" + (b + 1), r, hBenchSeat);
            }

            // 椅子
            JsonValue chairs = parts["chairs"];
            for (int c = 0; c < chairs.Count; c++)
            {
                JsonValue ch = chairs[c];
                PlanRect r;
                if (!TryRect(ch["rect"], where + ".parts.chairs[" + c + "].rect", out r)) continue;
                PlanChair chair = new PlanChair();
                chair.rect = r;
                chair.height = hChair;
                chair.pullable = ch["pullable"].B(false);
                JsonValue dir = ch["pull_dir"];
                chair.pullDirection = new Vector3(dir[0].F(0f), 0f, dir[1].F(0f));
                if (chair.pullDirection.sqrMagnitude > 0.000001f)
                    chair.pullDirection = chair.pullDirection.normalized;
                seat.chairs.Add(chair);
                _chairCount++;
            }

            // 座る位置
            JsonValue sp = parts["seat_points"];
            for (int s = 0; s < sp.Count; s++)
            {
                Vector3 p;
                if (!TryPoint(sp[s], where + ".parts.seat_points[" + s + "]", out p)) continue;
                seat.seatPoints.Add(p);
                _seatPointCount++;
            }

            // 席に着くために立つ点
            JsonValue spots = t["spots"];
            for (int s = 0; s < spots.Count; s++)
            {
                Vector3 p;
                if (!TryPoint(spots[s]["point"], where + ".spots[" + s + "].point", out p)) continue;
                seat.spots.Add(new PlanSeatSpot(spots[s]["side"].S("spot" + (s + 1)), p));
                _spotCount++;
            }

            plan.seats.Add(seat);
            _seatCount += seat.seatCount;
        }

        /// <summary>
        /// 座面の見た目（当たりなし）。ベンチの箱の上に、ふちから少し詰めた板を1枚置く。
        /// NavMesh・前のセンサー・左右の空きの測りには効かない。
        /// </summary>
        void AddBenchSeatLook(StorePlan plan, string name, PlanRect bench, float seatHeight)
        {
            if (benchSeatPadThickness <= 0.0005f) return;
            float m = Mathf.Max(0f, benchSeatInset);
            PlanRect r = new PlanRect(bench.xMin + m, bench.zMin + m, bench.xMax - m, bench.zMax - m);
            if (r.IsEmpty) return;
            PlanBox b = plan.AddBox(name + "_SeatLook", BoxStyle.BenchSeat, r, benchSeatPadThickness);
            if (b != null) b.baseY = seatHeight;
        }

        // ---------------- 小さな道具 ----------------

        bool CheckGeneration()
        {
            if (string.IsNullOrEmpty(_generation))
            {
                Fail("meta.generation がありません");
                return false;
            }
            if (summaryJson == null)
            {
                Fail("summary.json（summaryJson）が入っていません。版の番号を突き合わせられません");
                return false;
            }
            string err;
            JsonValue sum = JsonValue.Parse(summaryJson.text, out err);
            if (sum == null || !sum.IsArray)
            {
                Fail("summary.json を読めません: " + err);
                return false;
            }
            string key = string.IsNullOrEmpty(layoutFileName)
                ? (layoutJson != null ? layoutJson.name : "")
                : layoutFileName;
            for (int i = 0; i < sum.Count; i++)
            {
                if (sum[i]["file"].S("") != key) continue;
                string g = sum[i]["generation"].S("");
                if (g != _generation)
                {
                    Fail("版の番号が summary.json と違います: この JSON " + _generation +
                         " / summary.json " + g + "（写し直してください）");
                    return false;
                }
                return true;
            }
            Fail("summary.json に \"" + key + "\" がありません（layoutFileName を確かめてください）");
            return false;
        }

        float Req(JsonValue obj, string key, string where)
        {
            JsonValue v = obj[key];
            if (v.kind != JsonValue.Kind.Number)
            {
                Fail(where + " がありません");
                return 0f;
            }
            return v.F(0f);
        }

        bool TryRect(JsonValue v, string where, out PlanRect rect)
        {
            rect = new PlanRect(0f, 0f, 0f, 0f);
            if (v.Count < 4)
            {
                Fail(where + " が [x0, y0, x1, y1] ではありません");
                return false;
            }
            rect = new PlanRect(v[0].F(0f), v[1].F(0f), v[2].F(0f), v[3].F(0f));
            return true;
        }

        bool TryPoint(JsonValue v, string where, out Vector3 p)
        {
            p = Vector3.zero;
            if (v.Count < 2)
            {
                Fail(where + " が [x, y] ではありません");
                return false;
            }
            p = new Vector3(v[0].F(0f), 0f, v[1].F(0f));
            return true;
        }

        static bool TryParseKind(string s, out PlaceKind kind)
        {
            kind = PlaceKind.Kitchen;
            if (string.IsNullOrEmpty(s)) return false;
            try
            {
                kind = (PlaceKind)Enum.Parse(typeof(PlaceKind), s, false);
                return true;
            }
            catch (Exception) { return false; }
        }

        /// <summary>部屋のラベルを PlaceKind に直す（依頼の対応表どおり）</summary>
        static bool TryRoomKind(string label, out PlaceKind kind)
        {
            kind = PlaceKind.Kitchen;
            if (string.IsNullOrEmpty(label)) return false;
            switch (label)
            {
                case "パントリー": kind = PlaceKind.Pantry; return true;
                case "キッチン": kind = PlaceKind.Kitchen; return true;
                case "冷蔵・冷凍・保存": kind = PlaceKind.Kitchen; return true;
                case "洗い場": kind = PlaceKind.Kitchen; return true;
                case "更衣室・社員トイレ": kind = PlaceKind.Office; return true;
                case "トイレ": kind = PlaceKind.Toilet; return true;
                case "待合": kind = PlaceKind.Waiting; return true;
                case "レジ": kind = PlaceKind.Register; return true;
                case "レジ内": kind = PlaceKind.RegisterBack; return true;
                case "ドリンクバー": kind = PlaceKind.DrinkBar; return true;
            }
            return false;
        }

        static bool TryTableKind(string type, out SeatKind kind)
        {
            kind = SeatKind.WallBooth;
            switch (type)
            {
                case "booth": kind = SeatKind.WallBooth; return true;
                case "t2": kind = SeatKind.WallTable; return true;
                case "booth6L": kind = SeatKind.LargeTable; return true;
            }
            return false;
        }

        static int NumberFromId(string id, int fallback)
        {
            if (string.IsNullOrEmpty(id)) return fallback;
            int n = 0; bool any = false;
            for (int i = 0; i < id.Length; i++)
            {
                char c = id[i];
                if (c < '0' || c > '9') continue;
                n = n * 10 + (c - '0');
                any = true;
            }
            return any ? n : fallback;
        }

        /// <summary>GameObject の名前に使える形にする（/ は階層になってしまう）</summary>
        static string Safe(string s)
        {
            if (string.IsNullOrEmpty(s)) return "x";
            return s.Replace('/', '_').Replace('\\', '_');
        }

        void Fail(string message)
        {
            _problems.Add(message);
        }

        static string F(float v)
        {
            return v.ToString("0.##", CultureInfo.InvariantCulture);
        }
    }
}
```

### Assets/ServiceSim/Store/RouteBook.cs
```csharp
using System.Collections.Generic;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// ロボットの道順（v5b）。サイトが決めた曲がり角の点の並びを持つ。
    ///
    /// 2026-10-22 にエイイチと決めたこと:
    ///   Unity で道を決めるのをやめ、**道順はサイトが決めて渡す**。
    ///   サイトの試算は卓ごとの道（ふだん走らせる 1.0 m の道、届かなければ 0.8 m の道）を
    ///   もう持っているので、それを曲がり角の点の並びにして JSON に入れてある。
    ///   Unity のロボットはその点を順にたどるだけ。
    ///   人をよける・止まる・立ち往生は Unity の中で起きる（それを測るのが Unity の役目）。
    ///
    /// JSON の形（layout の routes）:
    ///   from_pass["T05"] … 受け渡し口の前 → T05 の止まる位置
    ///                      points（最初が受け渡し口の前・最後が止まる位置）/ use / width / length
    ///   between["T05|T12"] … T05 の止まる位置 → T12 の止まる位置
    ///                      逆向き（T12 → T05）は点を逆にたどる
    ///   卓 → 受け渡し口は from_pass を逆にたどる
    /// </summary>
    public class RouteBook
    {
        /// <summary>道順 1 本</summary>
        public class Route
        {
            /// <summary>曲がり角の点（XZ。y は 0）</summary>
            public readonly List<Vector3> points = new List<Vector3>();

            /// <summary>normal（1.0 m の道）/ narrow（0.8 m の道）</summary>
            public string use = "normal";

            /// <summary>その道の幅（m）</summary>
            public float width;

            /// <summary>JSON が持っている長さ（m）</summary>
            public float length;

            public bool IsNarrow { get { return use == "narrow"; } }

            /// <summary>点を結んだ長さ（m）。JSON の length と突き合わせる用</summary>
            public float PolylineLength
            {
                get
                {
                    float d = 0f;
                    for (int i = 1; i < points.Count; i++) d += Vector3.Distance(points[i - 1], points[i]);
                    return d;
                }
            }
        }

        readonly Dictionary<int, Route> _fromPass = new Dictionary<int, Route>();
        readonly Dictionary<long, Route> _between = new Dictionary<long, Route>();

        public int FromPassCount { get { return _fromPass.Count; } }
        public int BetweenCount { get { return _between.Count; } }

        static long Key(int a, int b) { return (long)a * 100000L + b; }

        public void AddFromPass(int tableId, Route r) { _fromPass[tableId] = r; }
        public void AddBetween(int a, int b, Route r) { _between[Key(a, b)] = r; }

        public Route FromPass(int tableId)
        {
            Route r;
            return _fromPass.TryGetValue(tableId, out r) ? r : null;
        }

        /// <summary>a → b の道順。逆向きしか無ければ、その点を逆にたどる</summary>
        public List<Vector3> PointsBetween(int a, int b, out Route used, out bool reversed)
        {
            reversed = false;
            if (_between.TryGetValue(Key(a, b), out used)) return new List<Vector3>(used.points);
            if (_between.TryGetValue(Key(b, a), out used))
            {
                reversed = true;
                List<Vector3> p = new List<Vector3>(used.points);
                p.Reverse();
                return p;
            }
            used = null;
            return null;
        }

        /// <summary>受け渡し口の前 → 卓</summary>
        public List<Vector3> PointsFromPass(int tableId, out Route used)
        {
            used = FromPass(tableId);
            return used != null ? new List<Vector3>(used.points) : null;
        }

        /// <summary>卓 → 受け渡し口の前（from_pass を逆にたどる）</summary>
        public List<Vector3> PointsToPass(int tableId, out Route used)
        {
            used = FromPass(tableId);
            if (used == null) return null;
            List<Vector3> p = new List<Vector3>(used.points);
            p.Reverse();
            return p;
        }

        /// <summary>その卓へ行く道が、0.8 m の道を使うか（確かめの via_narrow）</summary>
        public bool UsesNarrow(int tableId)
        {
            Route r = FromPass(tableId);
            return r != null && r.IsNarrow;
        }

        public string UseOf(int tableId)
        {
            Route r = FromPass(tableId);
            return r != null ? r.use : "";
        }

        /// <summary>報告に出す覚え書き</summary>
        public string Summary()
        {
            int narrow = 0;
            foreach (KeyValuePair<int, Route> kv in _fromPass)
                if (kv.Value.IsNarrow) narrow++;
            return "受け渡し口から " + _fromPass.Count + " 本（うち 0.8 m の道 " + narrow + " 本）/ " +
                   "卓から卓 " + _between.Count + " 本";
        }
    }
}
```

### Assets/ServiceSim/Store/NavMeshBaker.cs
```csharp
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

namespace ServiceSim
{
    /// <summary>
    /// 店の形から NavMesh を焼く。com.unity.ai.navigation を入れずに、
    /// 組み込みの UnityEngine.AI の低レベル API だけで焼く（NavMeshSurface の代わり）。
    ///
    /// 焼く対象は bakeRoot の下だけ。人とロボットは別の親の下に置くので、
    /// 障害物として焼き込まれない。
    /// 数字（半径・高さ・細かさ）は StoreLayout の .asset に置く。
    /// </summary>
    public class NavMeshBaker : MonoBehaviour
    {
        [Header("設定")]
        public StoreLayout layout;

        [Tooltip("この下にあるコライダーだけを焼く。ふつうは Store の親")]
        public Transform bakeRoot;

        [Tooltip("焼く範囲を、組み立てた店の広さから取る")]
        public StoreBuilder store;

        [Tooltip("人用の NavMesh を焼く半径（m）。PeopleSpec の人の半径に合わせる。" +
                 "0 以下、または navAgentRadius 以上なら人用は焼かず、人もロボット用の面を歩く")]
        public float peopleRadius = 0.2f;

        [Tooltip("レジの内側（店員側）に付ける NavMesh の区画の番号。PeopleSpec に合わせる。" +
                 "客の NavMeshAgent はこの区画を外すので、店員側に入れない。0 なら区切らない")]
        public int registerBackArea = 3;

        // v5b: 狭い通路の区画（narrowAisleArea）はやめた。
        // NavMesh に区画を書き込むと、同じ面が細かく割れて道がつながらない卓が出たため
        // （⑦で 11 卓）。ロボットの道順はサイトの routes を受け取ってたどる。
        // NavMesh は段階1 と同じ（レジの内側の区画だけ残す）。

        NavMeshData _data;
        NavMeshDataInstance _instance;

        // ---- 人用の NavMesh（半径が人の分だけ小さい）----
        // ロボット用（navAgentRadius）と人用（人の半径）を同時に置き、
        // それぞれの通行者の種類（agentTypeID）で引き分ける。
        NavMeshData _peopleData;
        NavMeshDataInstance _peopleInstance;
        int _peopleTypeId = -1;

        /// <summary>ロボット用の通行者の種類（既定の 0）</summary>
        public int RobotAgentTypeId { get { return 0; } }

        /// <summary>人用の通行者の種類。焼いていなければロボット用と同じ</summary>
        public int PeopleAgentTypeId { get { return _peopleTypeId >= 0 ? _peopleTypeId : 0; } }

        /// <summary>人用の NavMesh を置けているか</summary>
        public bool HasPeopleNavMesh { get { return _peopleInstance.valid; } }

        /// <summary>人用の NavMesh を焼いた半径（m）</summary>
        public float PeopleRadius { get { return peopleRadius; } }

        /// <summary>その種類で経路を引くための絞り込み</summary>
        public NavMeshQueryFilter FilterFor(int agentTypeId)
        {
            NavMeshQueryFilter f = new NavMeshQueryFilter();
            f.agentTypeID = agentTypeId;
            f.areaMask = NavMesh.AllAreas;
            return f;
        }
        readonly List<NavMeshBuildSource> _sources = new List<NavMeshBuildSource>();
        readonly List<NavMeshBuildMarkup> _markups = new List<NavMeshBuildMarkup>();

        public bool HasNavMesh { get { return _instance.valid; } }

        void OnDisable()
        {
            Remove();
        }

        /// <summary>
        /// いまの店の形で焼き直す。人の半径を渡すと、人用の NavMesh も一緒に焼く。
        /// 人の半径が 0 以下、またはロボットの半径以上なら、人用は焼かない（1枚だけ）。
        /// </summary>
        public void Bake(float radius)
        {
            peopleRadius = radius;
            Bake();
        }

        /// <summary>いまの店の形で焼き直す</summary>
        public void Bake()
        {
            if (layout == null)
            {
                Debug.LogError("[ServiceSim] NavMeshBaker に StoreLayout が入っていません");
                return;
            }
            Transform root = bakeRoot != null ? bakeRoot : transform;

            _sources.Clear();
            BuildMarkups();
            NavMeshBuilder.CollectSources(
                root,
                ~0,                                        // すべてのレイヤー（レイヤーを増やさないため）
                NavMeshCollectGeometry.PhysicsColliders,    // コライダーの形で焼く
                0,                                         // 既定のエリア（Walkable）
                _markups,
                _sources);

            AddRegisterBackArea();

            if (_sources.Count == 0)
            {
                Debug.LogError("[ServiceSim] 焼く形が見つかりません。先に Build Store を実行してください");
                return;
            }

            NavMeshBuildSettings settings = BuildSettings();
            Bounds bounds = BakeBounds();

            // v5b: スクリプトの読み込み直し（ドメインリロード）をまたぐと、前に焼いた NavMesh が
            // システムに残ったままで、この baker の _instance では外せない。
            // 2026-10-22 の ⑦ で、前の作り（狭い通路の区画入り）の面が残り、
            // 道がつながらない卓が出た。焼く前に、入っているものを全部外す。
            NavMesh.RemoveAllNavMeshData();
            Remove();
            _data = NavMeshBuilder.BuildNavMeshData(settings, _sources, bounds, Vector3.zero, Quaternion.identity);
            if (_data == null)
            {
                Debug.LogError("[ServiceSim] NavMesh を焼けませんでした");
                return;
            }
            _data.name = "ServiceSim_NavMesh";
            _instance = NavMesh.AddNavMeshData(_data);

            BakePeople(settings, bounds);
        }

        /// <summary>
        /// 人用の NavMesh を焼いて一緒に置く（半径だけが違う）。
        /// 通行者の種類を1つ作り、人の NavMeshAgent にその番号を入れて使い分ける。
        /// ロボットはこれまでどおり navAgentRadius の面を歩く。
        /// </summary>
        void BakePeople(NavMeshBuildSettings robotSettings, Bounds bounds)
        {
            RemovePeople();
            if (peopleRadius <= 0.0005f || peopleRadius >= layout.navAgentRadius - 0.0005f) return;

            if (_peopleTypeId < 0)
            {
                NavMeshBuildSettings made = NavMesh.CreateSettings();
                _peopleTypeId = made.agentTypeID;
            }

            NavMeshBuildSettings s = robotSettings;
            s.agentTypeID = _peopleTypeId;
            s.agentRadius = peopleRadius;

            _peopleData = NavMeshBuilder.BuildNavMeshData(s, _sources, bounds,
                                                          Vector3.zero, Quaternion.identity);
            if (_peopleData == null)
            {
                Debug.LogWarning("[ServiceSim] 人用の NavMesh を焼けませんでした。人もロボット用の面を歩きます");
                return;
            }
            _peopleData.name = "ServiceSim_NavMesh_People";
            _peopleInstance = NavMesh.AddNavMeshData(_peopleData);
            PeopleNav.SetPeopleAgentType(_peopleTypeId);
        }

        void RemovePeople()
        {
            PeopleNav.SetPeopleAgentType(0);
            if (_peopleInstance.valid) NavMesh.RemoveNavMeshData(_peopleInstance);
            _peopleInstance = new NavMeshDataInstance();
            _peopleData = null;
        }

        /// <summary>焼いた NavMesh を外す</summary>
        public void Remove()
        {
            if (_instance.valid) NavMesh.RemoveNavMeshData(_instance);
            _instance = new NavMeshDataInstance();
            _data = null;
            RemovePeople();
        }

        /// <summary>
        /// 壁・ベンチ・仕切り・卓・椅子・カウンターを Not Walkable にする印を作る。
        /// 焼く形としては残るので通れないままだが、上面は歩ける場所にならない。
        /// </summary>
        void BuildMarkups()
        {
            _markups.Clear();
            if (store == null) return;

            int notWalkable = NavMesh.GetAreaFromName("Not Walkable");
            if (notWalkable < 0) notWalkable = 1;

            IList<Transform> roots = store.NotWalkableRoots;
            for (int i = 0; i < roots.Count; i++)
            {
                if (roots[i] == null) continue;
                NavMeshBuildMarkup m = new NavMeshBuildMarkup();
                m.root = roots[i];
                m.overrideArea = true;
                m.area = notWalkable;
                _markups.Add(m);
            }
        }

        /// <summary>焼く範囲。組み立てた店の広さに余白を足す</summary>
        public Bounds BakeBounds()
        {
            if (store != null && store.Plan != null)
                return store.Plan.NavBounds(layout.navBoundsPadding);

            // 店がまだ組み立てられていないときは、この下のコライダーの広がりを使う
            Transform root = bakeRoot != null ? bakeRoot : transform;
            Collider[] cols = root.GetComponentsInChildren<Collider>(false);
            if (cols.Length == 0) return new Bounds(Vector3.zero, new Vector3(20f, 4f, 20f));
            Bounds b = cols[0].bounds;
            for (int i = 1; i < cols.Length; i++) b.Encapsulate(cols[i].bounds);
            b.Expand(layout.navBoundsPadding * 2f);
            return b;
        }

        /// <summary>
        /// レジの内側（店員側）を別の区画にする。
        /// 形は足さず、その箱の中の区画の番号だけを変える（ModifierBox）。
        /// 客の NavMeshAgent がこの区画を外して歩くので、客は店員側に入れない。
        /// </summary>
        void AddRegisterBackArea()
        {
            if (registerBackArea <= 2) return;        // 0・1・2 は決まった区画なので使わない
            if (store == null) return;

            PlanRect r = store.RegisterBackRect;
            if (r.IsEmpty) return;

            NavMeshBuildSource src = new NavMeshBuildSource();
            src.shape = NavMeshBuildSourceShape.ModifierBox;
            src.transform = Matrix4x4.Translate(new Vector3(r.CenterX, 0.5f, r.CenterZ));
            src.size = new Vector3(r.SizeX, 2f, r.SizeZ);
            src.area = registerBackArea;
            _sources.Add(src);
        }

        /// <summary>
        /// 通行半径を変えて焼き直し、その歩ける面の三角形を返す（見た目の塗り分けに使う）。
        /// いま入れてある NavMesh を外してから焼き、読み終わったら元に戻すので、
        /// 計算に使う NavMesh（navAgentRadius で焼いたもの）は変わらない。
        /// </summary>
        public NavMeshTriangulation TriangulateFor(float agentRadius)
        {
            NavMeshTriangulation empty = new NavMeshTriangulation();
            if (layout == null) return empty;

            Transform root = bakeRoot != null ? bakeRoot : transform;
            _sources.Clear();
            BuildMarkups();
            NavMeshBuilder.CollectSources(root, ~0, NavMeshCollectGeometry.PhysicsColliders,
                                          0, _markups, _sources);
            AddRegisterBackArea();
            if (_sources.Count == 0) return empty;

            NavMeshBuildSettings s = BuildSettings();
            s.agentRadius = agentRadius;

            // いまの NavMesh を一時的に全部外す（CalculateTriangulation は入れてある全部を返すため）。
            // v5b: 読み込み直しをまたいで残った面もここで外す（RemoveAllNavMeshData）。
            // そうしないと、前の作りで焼いた面が混ざった三角形を読んでしまう。
            bool had = _instance.valid;
            NavMeshData keep = _data;
            bool hadPeople = _peopleInstance.valid;
            NavMeshData keepPeople = _peopleData;
            NavMesh.RemoveAllNavMeshData();
            _instance = new NavMeshDataInstance();
            _peopleInstance = new NavMeshDataInstance();

            NavMeshTriangulation tri = empty;
            NavMeshData tmp = NavMeshBuilder.BuildNavMeshData(
                s, _sources, BakeBounds(), Vector3.zero, Quaternion.identity);
            NavMeshDataInstance tmpInst = new NavMeshDataInstance();
            if (tmp != null)
            {
                tmpInst = NavMesh.AddNavMeshData(tmp);
                tri = NavMesh.CalculateTriangulation();
            }
            if (tmpInst.valid) NavMesh.RemoveNavMeshData(tmpInst);

            // 元に戻す
            _data = keep;
            if (had && keep != null) _instance = NavMesh.AddNavMeshData(keep);
            _peopleData = keepPeople;
            if (hadPeople && keepPeople != null) _peopleInstance = NavMesh.AddNavMeshData(keepPeople);
            return tri;
        }

        NavMeshBuildSettings BuildSettings()
        {
            NavMeshBuildSettings s = NavMesh.GetSettingsByID(0);
            s.agentRadius = layout.navAgentRadius;
            s.agentHeight = layout.navAgentHeight;
            s.agentClimb = layout.navAgentClimb;
            s.agentSlope = layout.navAgentSlope;
            s.overrideVoxelSize = true;
            s.voxelSize = layout.navVoxelSize;
            s.minRegionArea = layout.navMinRegionArea;
            return s;
        }

        /// <summary>
        /// 受け渡し口から1点までの NavMesh 上の道のり（m）。
        /// つながっていなければ -1 を返す。
        /// </summary>
        public static float PathLength(Vector3 from, Vector3 to, float sampleRadius, out string status)
        {
            NavMeshQueryFilter f = new NavMeshQueryFilter();
            f.agentTypeID = 0;
            f.areaMask = NavMesh.AllAreas;
            return PathLength(from, to, sampleRadius, f, out status);
        }

        /// <summary>通行者の種類を指定して道のりを測る（人用・ロボット用を分けて確かめる）</summary>
        public static float PathLength(Vector3 from, Vector3 to, float sampleRadius,
                                       NavMeshQueryFilter filter, out string status)
        {
            NavMeshHit hitFrom, hitTo;
            if (!NavMesh.SamplePosition(from, out hitFrom, sampleRadius, filter))
            {
                status = "出発点が NavMesh の上にない";
                return -1f;
            }
            if (!NavMesh.SamplePosition(to, out hitTo, sampleRadius, filter))
            {
                status = "行き先が NavMesh の上にない";
                return -1f;
            }

            NavMeshPath path = new NavMeshPath();
            if (!NavMesh.CalculatePath(hitFrom.position, hitTo.position, filter, path))
            {
                status = "経路が見つからない";
                return -1f;
            }
            if (path.status != NavMeshPathStatus.PathComplete)
            {
                status = path.status == NavMeshPathStatus.PathPartial ? "経路が途中まで" : "経路が無効";
                return -1f;
            }

            float len = 0f;
            Vector3[] c = path.corners;
            for (int i = 1; i < c.Length; i++) len += Vector3.Distance(c[i - 1], c[i]);
            status = "ok";
            return len;
        }

        void OnDrawGizmosSelected()
        {
            if (layout == null) return;
            Bounds b = BakeBounds();
            Gizmos.color = new Color(0.2f, 0.8f, 1f, 0.5f);
            Gizmos.DrawWireCube(b.center, b.size);
        }
    }
}
```

### Assets/ServiceSim/Store/SimpleJson.cs
```csharp
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;

namespace ServiceSim
{
    /// <summary>
    /// 小さな JSON の読み取り。サイトが書き出した店の形の JSON を読むためだけに使う。
    /// Unity の JsonUtility は入れ子の配列（[[x, y], [x, y]]）や、キーの決まっていない
    /// 入れ物を読めないので、ここに最小限のものを置く。書き出しはしない。
    ///
    /// 読めるもの: オブジェクト / 配列 / 文字列 / 数 / true / false / null
    /// 読めないもの: コメント、末尾のカンマ（JSON の決まりどおりのものだけ読む）
    /// </summary>
    public class JsonValue
    {
        public enum Kind { Null, Bool, Number, String, Array, Object }

        public Kind kind = Kind.Null;
        public bool boolValue;
        public double number;
        public string text;
        public List<JsonValue> items;
        public Dictionary<string, JsonValue> members;

        static readonly JsonValue NullValue = new JsonValue();

        public bool IsNull { get { return kind == Kind.Null; } }
        public bool IsArray { get { return kind == Kind.Array; } }
        public bool IsObject { get { return kind == Kind.Object; } }

        /// <summary>配列の長さ（配列でなければ 0）</summary>
        public int Count { get { return kind == Kind.Array && items != null ? items.Count : 0; } }

        /// <summary>配列の i 番目。無ければ null 値（例外を投げない）</summary>
        public JsonValue this[int i]
        {
            get
            {
                if (kind != Kind.Array || items == null || i < 0 || i >= items.Count) return NullValue;
                return items[i];
            }
        }

        /// <summary>オブジェクトの key。無ければ null 値（例外を投げない）</summary>
        public JsonValue this[string key]
        {
            get
            {
                JsonValue v;
                if (kind == Kind.Object && members != null && members.TryGetValue(key, out v)) return v;
                return NullValue;
            }
        }

        public bool Has(string key)
        {
            return kind == Kind.Object && members != null && members.ContainsKey(key);
        }

        public IEnumerable<string> Keys
        {
            get
            {
                if (kind == Kind.Object && members != null)
                    foreach (string k in members.Keys) yield return k;
            }
        }

        public float F(float fallback = 0f)
        {
            return kind == Kind.Number ? (float)number : fallback;
        }

        public int I(int fallback = 0)
        {
            return kind == Kind.Number ? (int)Math.Round(number) : fallback;
        }

        public string S(string fallback = null)
        {
            return kind == Kind.String ? text : fallback;
        }

        public bool B(bool fallback = false)
        {
            if (kind == Kind.Bool) return boolValue;
            if (kind == Kind.Number) return number != 0.0;
            return fallback;
        }

        // ---- 読み取り ----

        /// <summary>読む。読めなければ error に理由を入れて null を返す</summary>
        public static JsonValue Parse(string src, out string error)
        {
            error = null;
            if (string.IsNullOrEmpty(src)) { error = "中身が空です"; return null; }
            int i = 0;
            try
            {
                JsonValue v = ParseValue(src, ref i);
                SkipWhite(src, ref i);
                if (i < src.Length)
                {
                    error = "終わりのあとに文字が残っています（位置 " + i + "）";
                    return null;
                }
                return v;
            }
            catch (Exception e)
            {
                error = e.Message;
                return null;
            }
        }

        static void SkipWhite(string s, ref int i)
        {
            while (i < s.Length)
            {
                char c = s[i];
                if (c == ' ' || c == '\t' || c == '\r' || c == '\n') i++;
                else break;
            }
        }

        static JsonValue ParseValue(string s, ref int i)
        {
            SkipWhite(s, ref i);
            if (i >= s.Length) throw new Exception("途中で終わっています");
            char c = s[i];
            switch (c)
            {
                case '{': return ParseObject(s, ref i);
                case '[': return ParseArray(s, ref i);
                case '"': return new JsonValue { kind = Kind.String, text = ParseString(s, ref i) };
                case 't': Expect(s, ref i, "true"); return new JsonValue { kind = Kind.Bool, boolValue = true };
                case 'f': Expect(s, ref i, "false"); return new JsonValue { kind = Kind.Bool, boolValue = false };
                case 'n': Expect(s, ref i, "null"); return new JsonValue();
                default: return ParseNumber(s, ref i);
            }
        }

        static void Expect(string s, ref int i, string word)
        {
            if (i + word.Length > s.Length || string.CompareOrdinal(s, i, word, 0, word.Length) != 0)
                throw new Exception("読めない語があります（位置 " + i + "）");
            i += word.Length;
        }

        static JsonValue ParseObject(string s, ref int i)
        {
            JsonValue v = new JsonValue { kind = Kind.Object, members = new Dictionary<string, JsonValue>() };
            i++;                                   // '{'
            SkipWhite(s, ref i);
            if (i < s.Length && s[i] == '}') { i++; return v; }
            while (true)
            {
                SkipWhite(s, ref i);
                if (i >= s.Length || s[i] != '"') throw new Exception("キーが文字列ではありません（位置 " + i + "）");
                string key = ParseString(s, ref i);
                SkipWhite(s, ref i);
                if (i >= s.Length || s[i] != ':') throw new Exception("コロンがありません（位置 " + i + "）");
                i++;
                v.members[key] = ParseValue(s, ref i);
                SkipWhite(s, ref i);
                if (i >= s.Length) throw new Exception("オブジェクトが閉じていません");
                if (s[i] == ',') { i++; continue; }
                if (s[i] == '}') { i++; return v; }
                throw new Exception("オブジェクトの区切りが読めません（位置 " + i + "）");
            }
        }

        static JsonValue ParseArray(string s, ref int i)
        {
            JsonValue v = new JsonValue { kind = Kind.Array, items = new List<JsonValue>() };
            i++;                                   // '['
            SkipWhite(s, ref i);
            if (i < s.Length && s[i] == ']') { i++; return v; }
            while (true)
            {
                v.items.Add(ParseValue(s, ref i));
                SkipWhite(s, ref i);
                if (i >= s.Length) throw new Exception("配列が閉じていません");
                if (s[i] == ',') { i++; continue; }
                if (s[i] == ']') { i++; return v; }
                throw new Exception("配列の区切りが読めません（位置 " + i + "）");
            }
        }

        static string ParseString(string s, ref int i)
        {
            i++;                                   // '"'
            StringBuilder sb = new StringBuilder();
            while (i < s.Length)
            {
                char c = s[i++];
                if (c == '"') return sb.ToString();
                if (c != '\\') { sb.Append(c); continue; }
                if (i >= s.Length) break;
                char e = s[i++];
                switch (e)
                {
                    case '"': sb.Append('"'); break;
                    case '\\': sb.Append('\\'); break;
                    case '/': sb.Append('/'); break;
                    case 'b': sb.Append('\b'); break;
                    case 'f': sb.Append('\f'); break;
                    case 'n': sb.Append('\n'); break;
                    case 'r': sb.Append('\r'); break;
                    case 't': sb.Append('\t'); break;
                    case 'u':
                        if (i + 4 > s.Length) throw new Exception("\\u が短すぎます（位置 " + i + "）");
                        sb.Append((char)Convert.ToInt32(s.Substring(i, 4), 16));
                        i += 4;
                        break;
                    default: throw new Exception("読めない逃げ文字 \\" + e + "（位置 " + i + "）");
                }
            }
            throw new Exception("文字列が閉じていません");
        }

        static JsonValue ParseNumber(string s, ref int i)
        {
            int start = i;
            if (i < s.Length && (s[i] == '-' || s[i] == '+')) i++;
            while (i < s.Length)
            {
                char c = s[i];
                if ((c >= '0' && c <= '9') || c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-') i++;
                else break;
            }
            string t = s.Substring(start, i - start);
            double d;
            if (!double.TryParse(t, NumberStyles.Float, CultureInfo.InvariantCulture, out d))
                throw new Exception("数として読めません: " + t + "（位置 " + start + "）");
            return new JsonValue { kind = Kind.Number, number = d };
        }
    }
}
```

### Assets/ServiceSim/Editor/ServiceSimJsonStore.cs
```csharp
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.AI;

namespace ServiceSim.EditorTools
{
    /// <summary>
    /// サイトの JSON から建てた店（JsonStoreLayout）を確かめるメニュー。
    ///
    /// することは「建てて、正しく建ったかを確かめる」まで。回さない（段階1）。
    /// 建てるところと画面の撮影は ServiceSim/Build Store をそのまま呼ぶ（作り直さない）。
    /// ここで足すのは、JSON と突き合わせる確かめだけ:
    ///   1. 卓の数・席の数・細い通路の本数と幅（summary.json とも突き合わせる）
    ///   2. 止まる位置・座る位置・席に着く点・場所の点が NavMesh に乗るか
    ///   3. 受け渡し口の前から各卓の止まる位置までの道のりと、JSON の dist の照合（CSV）
    ///   4. 席に入れない卓（席に着く点へ、入口から歩いて行けるか）
    /// 書き出し先は ExperimentPlan.screenshotDir（Build Store と同じ）。
    /// </summary>
    public static class ServiceSimJsonStore
    {
        const string P1Path = "Assets/ServiceSim/Store/StoreLayout_p1_efficient_corner.asset";
        const string P5Path = "Assets/ServiceSim/Store/StoreLayout_p5_crammed.asset";

        /// <summary>道のりを測るときに、点から NavMesh を探す半径（m）</summary>
        const float SampleRadius = 0.5f;

        /// <summary>点が NavMesh に乗っているとみなす距離（m）</summary>
        const float OnMeshRadius = 0.2f;

        /// <summary>差がこれを超えた卓を報告に並べる</summary>
        const float BigDiffPct = 30f;

        [MenuItem("ServiceSim/JSON Store/③ p1_efficient_corner を建てて確かめる", false, 30)]
        public static void CheckP1() { Run(P1Path); }

        [MenuItem("ServiceSim/JSON Store/⑦ p5_crammed を建てて確かめる", false, 31)]
        public static void CheckP5() { Run(P5Path); }

        [MenuItem("ServiceSim/JSON Store/③⑦ を続けて確かめる", false, 32)]
        public static void CheckBoth() { if (Run(P1Path)) Run(P5Path); }

        const string SimParamsPath = "Assets/ServiceSim/Experiment/SimParams.asset";

        /// <summary>
        /// サイトの試算の値（serving-robot-params.json）を読んで、中身と版の番号を確かめる（v5）。
        /// 回さない。読めるかどうかと、店の形の版と合っているかだけを見る。
        /// </summary>
        [MenuItem("ServiceSim/JSON Store/試算の値を読んで確かめる（v5）", false, 33)]
        public static void CheckSimParams()
        {
            SimParams sp = AssetDatabase.LoadAssetAtPath<SimParams>(SimParamsPath);
            if (sp == null)
            {
                Debug.LogError("[ServiceSim] SimParams が見つかりません: " + SimParamsPath);
                return;
            }

            StringBuilder sb = new StringBuilder();
            sb.AppendLine("# 試算の値の読み込み（v5・回していない）");
            sb.AppendLine("作った日時: " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
            sb.AppendLine();

            // 店の形の版と突き合わせる（③⑦の両方）
            string[] layouts = { P1Path, P5Path };
            for (int i = 0; i < layouts.Length; i++)
            {
                JsonStoreLayout layout = AssetDatabase.LoadAssetAtPath<JsonStoreLayout>(layouts[i]);
                if (layout == null || layout.layoutJson == null) continue;
                string err;
                JsonValue root = JsonValue.Parse(layout.layoutJson.text, out err);
                string gen = root != null ? root["meta"]["generation"].S("") : "（読めない）";
                bool ok = sp.Load(gen);
                sb.AppendLine("## " + layout.layoutFileName + " と突き合わせ → " +
                              (ok ? "合っている" : "合っていない"));
                sb.AppendLine("  店の形の版 (meta.generation): " + gen);
                sb.AppendLine(sp.Report());
                sb.AppendLine();
            }

            // 読む値（site_rule）を並べて、何を使うかを見えるようにする
            sp.Load(null);
            string[] keys = { "guide_s", "pay_s", "call_s", "staff_place_s", "robot_handoff_s",
                              "clear_s", "wipe_s", "load_s", "guest_load_s", "rescue_s",
                              "staff_speed", "cold_min", "dish_extra_m", "max_extra_min" };
            sb.AppendLine("## Unity が使う値（role=site_rule）");
            for (int i = 0; i < keys.Length; i++)
                sb.AppendLine("  " + keys[i] + " = " + F(sp.SiteRule(keys[i], float.NaN)));
            float[] fc = sp.SiteRuleArray("fragile_cook", null);
            sb.AppendLine("  fragile_cook = " + (fc == null ? "（無い）"
                : F(fc[0]) + " / " + F(fc[1]) + " / " + F(fc[2])));

            sb.AppendLine();
            sb.AppendLine("## 店ごとの値（stores）");
            string[] stores = { "fam_p1", "fam_p5" };
            for (int i = 0; i < stores.Length; i++)
            {
                string k = stores[i];
                sb.AppendLine("  " + k + ": 卓 " + F(sp.StoreValue(k, "tables", 0f)) +
                              " / peak_min " + F(sp.StoreValue(k, "peak_min", 0f)) +
                              " / 来客（真ん中）" + sp.StoreMiddle(k, "arrivals", 0) + " 組毎時" +
                              " / スタッフ（真ん中）" + sp.StoreMiddle(k, "staff", 0) + " 人" +
                              " / eat_min " + sp.StoreTriangle(k, "eat_min", Vector3.zero) +
                              " / call_rate " + F(sp.StoreValue(k, "call_rate", 0f)) +
                              " / bus_trips " + F(sp.StoreValue(k, "bus_trips", 0f)) +
                              " / fragile " + F(sp.StoreValue(k, "fragile", 0f)));
            }

            sb.AppendLine();
            sb.AppendLine("## 出す数字の名前と意味（meta.metrics）");
            List<string> mk = sp.MetricKeys();
            for (int i = 0; i < mk.Count; i++)
                sb.AppendLine("  " + mk[i] + ": " + sp.MetricNote(mk[i]));

            ExperimentPlan plan = AssetDatabase.LoadAssetAtPath<ExperimentPlan>(ServiceSimSetup.ExperimentPlanPath);
            string outDir = plan != null ? plan.screenshotDir : null;
            if (!string.IsNullOrEmpty(outDir))
            {
                try
                {
                    Directory.CreateDirectory(outDir);
                    File.WriteAllText(Path.Combine(outDir,
                        DateTime.Now.ToString("yyyyMMdd") + "_sim_params_check.txt"), sb.ToString());
                }
                catch (Exception e) { Debug.LogError("[ServiceSim] 書けません: " + e.Message); }
            }
            Debug.Log("[ServiceSim] 試算の値の読み込み\n" + sb.ToString());
        }

        static bool Run(string assetPath)
        {
            JsonStoreLayout layout = AssetDatabase.LoadAssetAtPath<JsonStoreLayout>(assetPath);
            if (layout == null)
            {
                Debug.LogError("[ServiceSim] 店の形が見つかりません: " + assetPath);
                return false;
            }

            if (EditorSceneManager.GetActiveScene().path != ServiceSimSetup.ScenePath)
            {
                if (!EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo()) return false;
                EditorSceneManager.OpenScene(ServiceSimSetup.ScenePath);
            }

            StoreBuilder builder = UnityEngine.Object.FindObjectOfType<StoreBuilder>();
            NavMeshBaker baker = UnityEngine.Object.FindObjectOfType<NavMeshBaker>();
            ExperimentPlan plan = AssetDatabase.LoadAssetAtPath<ExperimentPlan>(ServiceSimSetup.ExperimentPlanPath);
            if (builder == null || baker == null || plan == null)
            {
                Debug.LogError("[ServiceSim] StoreBuilder / NavMeshBaker / ExperimentPlan が見つかりません。"
                               + "先に ServiceSim/Setup を実行してください");
                return false;
            }

            // 建てるのは Build Store にまかせる（組み立て・ベイク・既存の確かめ・撮影）
            builder.layout = layout;
            baker.layout = layout;
            ServiceSimMenu.BuildStore();

            builder = UnityEngine.Object.FindObjectOfType<StoreBuilder>();
            baker = UnityEngine.Object.FindObjectOfType<NavMeshBaker>();
            if (builder == null || baker == null || builder.Plan == null)
            {
                Debug.LogError("[ServiceSim] 建てられませんでした。Console の [ServiceSim] の行を見てください");
                return false;
            }
            if (builder.layout != layout)
            {
                Debug.LogError("[ServiceSim] Build Store のあとで店の形が " + builder.layout +
                               " に戻っています。確かめを中止しました");
                return false;
            }

            string err;
            JsonValue root = JsonValue.Parse(layout.layoutJson != null ? layout.layoutJson.text : "", out err);
            if (root == null)
            {
                Debug.LogError("[ServiceSim] JSON を読めません: " + err);
                return false;
            }

            string outDir = plan.screenshotDir;
            try { Directory.CreateDirectory(outDir); }
            catch (Exception e)
            {
                Debug.LogError("[ServiceSim] 書き出し先を作れません: " + outDir + "\n" + e.Message);
                return false;
            }

            string stem = DateTime.Now.ToString("yyyyMMdd") + "_" + layout.layoutFileName;
            string csvPath = Path.Combine(outDir, stem + "_table_dist.csv");
            string txtPath = Path.Combine(outDir, stem + "_json_check.txt");

            NavMeshQueryFilter people = baker.FilterFor(baker.PeopleAgentTypeId);
            // v5b: 道順はサイトの routes を使う。NavMesh は区画を足さない段階1 と同じ。
            // 点から点の区間だけを NavMesh でつないで確かめる
            RobotSpec robotSpec = AssetDatabase.LoadAssetAtPath<RobotSpec>(ServiceSimSetup.RobotSpecPath);
            NavMeshQueryFilter robot = baker.FilterFor(baker.RobotAgentTypeId);
            _snapRadius = robotSpec != null ? robotSpec.Radius * 4f : SampleRadius;

            StringBuilder report = new StringBuilder();
            report.AppendLine("# JSON から建てた店の確かめ（段階1・回していない）");
            report.AppendLine("店の形: " + layout.name + " / " + layout.StoreKindName);
            report.AppendLine("作った日時: " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
            report.AppendLine();
            report.AppendLine(layout.FrontFitReport());
            report.AppendLine();

            report.AppendLine(CountReport(builder, root, layout));
            report.AppendLine();
            report.AppendLine(OnMeshReport(builder, robot, people));
            report.AppendLine();
            report.AppendLine(DistReport(builder, root, robot, csvPath));
            report.AppendLine();
            report.AppendLine(SeatReachReport(builder, people));

            try { File.WriteAllText(txtPath, report.ToString()); }
            catch (Exception e) { Debug.LogError("[ServiceSim] 報告を書けません: " + e.Message); }

            Debug.Log("[ServiceSim] JSON Store の確かめ おわり（" + layout.layoutFileName + "）\n"
                      + report.ToString()
                      + "\n  書き出し: " + txtPath + "\n            " + csvPath);
            return true;
        }

        // ---------------- 1. 数 ----------------

        static string CountReport(StoreBuilder builder, JsonValue root, JsonStoreLayout layout)
        {
            StringBuilder sb = new StringBuilder();
            sb.AppendLine("## 1. 数");

            int tables = builder.Tables.Count;
            int seats = 0;
            for (int i = 0; i < builder.Tables.Count; i++) seats += builder.Tables[i].seatCount;

            int jsonTables = root["tables"].Count;
            int jsonSeats = 0;
            for (int i = 0; i < jsonTables; i++) jsonSeats += root["tables"][i]["seats"].I(0);

            int sumTables = -1, sumSeats = -1;
            string err;
            JsonValue sum = layout.summaryJson != null
                ? JsonValue.Parse(layout.summaryJson.text, out err) : null;
            if (sum != null)
                for (int i = 0; i < sum.Count; i++)
                    if (sum[i]["file"].S("") == layout.layoutFileName)
                    {
                        sumTables = sum[i]["tables"].I(-1);
                        sumSeats = sum[i]["seats"].I(-1);
                    }

            sb.AppendLine("  卓: Unity " + tables + " / JSON " + jsonTables +
                          " / summary.json " + sumTables + "  " + Same(tables, jsonTables, sumTables));
            sb.AppendLine("  席: Unity " + seats + " / JSON " + jsonSeats +
                          " / summary.json " + sumSeats + "  " + Same(seats, jsonSeats, sumSeats));

            int narrow = 0;
            StringBuilder widths = new StringBuilder();
            for (int i = 0; i < builder.Aisles.Count; i++)
            {
                StoreBuilder.AisleInfo a = builder.Aisles[i];
                if (!a.narrow) continue;
                narrow++;
                if (widths.Length > 0) widths.Append(" / ");
                widths.Append(a.name).Append(" ").Append(F(a.width)).Append(" m");
            }
            sb.AppendLine("  細い通路: " + narrow + " 本（" + widths + "）");
            sb.AppendLine("  組み立てた幅 (plan.aisleWidth): " + F(builder.BuiltAisleWidth) + " m");
            sb.AppendLine("  外寸: " + F(builder.OuterSize.x) + " × " + F(builder.OuterSize.y) + " m" +
                          " / JSON outer " + F(root["outer"]["w"].F(0f)) + " × " + F(root["outer"]["d"].F(0f)) + " m");
            return sb.ToString().TrimEnd();
        }

        static string Same(int a, int b, int c)
        {
            bool ok = a == b && (c < 0 || a == c);
            return ok ? "一致" : "← ちがう";
        }

        // ---------------- 2. 点が NavMesh に乗るか ----------------

        static string OnMeshReport(StoreBuilder builder, NavMeshQueryFilter robot, NavMeshQueryFilter people)
        {
            StringBuilder sb = new StringBuilder();
            sb.AppendLine("## 2. 点が NavMesh の上に乗るか（乗らないものだけ並べる）");

            List<string> offDelivery = new List<string>();
            List<string> offSpot = new List<string>();
            List<string> offSeat = new List<string>();
            List<string> offPlace = new List<string>();

            StorePlan plan = builder.Plan;
            for (int i = 0; i < plan.seats.Count; i++)
            {
                PlanSeat s = plan.seats[i];
                string tid = "T" + s.tableId.ToString("00");
                if (!OnMesh(s.deliveryPoint, robot)) offDelivery.Add(tid + " " + P(s.deliveryPoint));
                for (int k = 0; k < s.spots.Count; k++)
                    if (!OnMesh(s.spots[k].point, people))
                        offSpot.Add(tid + " " + s.spots[k].name + " " + P(s.spots[k].point));
                for (int k = 0; k < s.seatPoints.Count; k++)
                    if (!OnMesh(s.seatPoints[k], people))
                        offSeat.Add(tid + "#" + (k + 1) + " " + P(s.seatPoints[k]));
            }
            for (int i = 0; i < plan.places.Count; i++)
            {
                PlanPlace p = plan.places[i];
                if (p.solid) continue;                      // 部屋そのものの点は通れない箱の中
                bool forRobot = p.kind == PlaceKind.FoodPass || p.kind == PlaceKind.DishReturn ||
                                p.kind == PlaceKind.RobotStandby;
                if (!OnMesh(p.point, forRobot ? robot : people))
                    offPlace.Add(p.name + " " + P(p.point) + (forRobot ? "（ロボット用）" : "（人用）"));
            }

            sb.AppendLine("  止まる位置 (deliveryPoint, ロボット用): 乗らない " + offDelivery.Count +
                          " / " + plan.seats.Count);
            Lines(sb, offDelivery);
            sb.AppendLine("  席に着く点 (spots, 人用): 乗らない " + offSpot.Count);
            Lines(sb, offSpot);
            sb.AppendLine("  座る位置 (seatPoints, 人用): 乗らない " + offSeat.Count +
                          "  ※ 座面やベンチの上の点は NavMesh に乗らないのがふつう（見た目に使う点）");
            Lines(sb, offSeat, 8);
            sb.AppendLine("  場所の点 (places): 乗らない " + offPlace.Count);
            Lines(sb, offPlace);
            return sb.ToString().TrimEnd();
        }

        static void Lines(StringBuilder sb, List<string> items, int max = 40)
        {
            for (int i = 0; i < items.Count && i < max; i++) sb.AppendLine("    - " + items[i]);
            if (items.Count > max) sb.AppendLine("    - ほか " + (items.Count - max) + " 件");
        }

        static bool OnMesh(Vector3 p, NavMeshQueryFilter filter)
        {
            NavMeshHit hit;
            return NavMesh.SamplePosition(p, out hit, OnMeshRadius, filter);
        }

        // ---------------- 3. 道のりの照合 ----------------

        /// <summary>区間の NavMesh の道のりが、まっすぐの長さのこの倍を超えたら並べる</summary>
        const float SegmentDetourLimit = 1.05f;

        static float _snapRadius = SampleRadius;

        /// <summary>
        /// 3. 道順の照合（v5b）。
        /// サイトが決めた道順（曲がり角の点）を、Unity の NavMesh でつなぎ直して確かめる。
        ///   ・道順の長さ（点を結んだ長さ）と JSON の dist
        ///   ・点から点の区間が NavMesh で完全につながるか
        ///   ・区間の NavMesh の道のりが、まっすぐの長さの 1.05 倍を超えないか
        ///   ・use（normal / narrow）＝ via_narrow
        /// </summary>
        static string DistReport(StoreBuilder builder, JsonValue root, NavMeshQueryFilter robot, string csvPath)
        {
            StringBuilder sb = new StringBuilder();
            sb.AppendLine("## 3. 道順の照合（受け渡し口の前 → 止まる位置。サイトの routes をたどる）");

            RouteBook book = builder.Routes;
            if (book == null)
            {
                sb.AppendLine("  routes がありません（v5b の版の JSON を写してください）");
                return sb.ToString().TrimEnd();
            }
            sb.AppendLine("  道順: " + book.Summary());
            sb.AppendLine("  止まる位置を面へ寄せる半径: " + F(_snapRadius) + " m");

            Dictionary<int, JsonValue> byId = new Dictionary<int, JsonValue>();
            JsonValue tables = root["tables"];
            for (int i = 0; i < tables.Count; i++)
            {
                int id = NumberFromId(tables[i]["id"].S(""), i + 1);
                if (!byId.ContainsKey(id)) byId[id] = tables[i];
            }

            StringBuilder csv = new StringBuilder();
            csv.AppendLine("table,category,aisle,use,json_dist_m,route_len_m,unity_path_m," +
                           "diff_m,diff_pct,via_narrow,segments,bad_segments,status");

            List<string> brokenSeg = new List<string>();
            List<string> detourSeg = new List<string>();
            List<string> big = new List<string>();
            List<float> diffs = new List<float>();
            int narrowTables = 0, okTables = 0;
            float sumAbs = 0f, maxAbs = 0f; string maxAt = "";

            StorePlan plan = builder.Plan;
            for (int i = 0; i < plan.seats.Count; i++)
            {
                PlanSeat s = plan.seats[i];
                string tid = "T" + s.tableId.ToString("00");
                JsonValue t;
                string category = "", aisle = "";
                float jsonDist = -1f;
                if (byId.TryGetValue(s.tableId, out t))
                {
                    jsonDist = t["dist"].F(-1f);
                    category = t["category"].S("");
                    aisle = t["aisle"].S("");
                }

                RouteBook.Route r = book.FromPass(s.tableId);
                if (r == null)
                {
                    brokenSeg.Add(tid + " 道順が無い");
                    csv.AppendLine(tid + "," + category + "," + aisle + ",,,,,,,,,,道順が無い");
                    continue;
                }
                bool narrow = r.IsNarrow;
                if (narrow) narrowTables++;

                float routeLen = r.PolylineLength;
                float unityLen = 0f;
                int bad = 0;
                bool allConnected = true;
                for (int k = 1; k < r.points.Count; k++)
                {
                    Vector3 a = r.points[k - 1], b = r.points[k];
                    string st;
                    float len = PathLen(a, b, robot, out st);
                    float straight = Vector3.Distance(a, b);
                    if (len < 0f)
                    {
                        allConnected = false; bad++;
                        brokenSeg.Add(tid + " #" + k + " " + P(a) + "→" + P(b) + " " + st);
                        continue;
                    }
                    unityLen += len;
                    if (straight > 0.05f && len > straight * SegmentDetourLimit)
                    {
                        bad++;
                        detourSeg.Add(tid + " #" + k + " まっすぐ " + F(straight) + " m → NavMesh " +
                                      F(len) + " m（" + F(len / straight) + " 倍）");
                    }
                }

                string status = allConnected ? "ok" : "つながらない区間あり";
                if (allConnected)
                {
                    okTables++;
                    float diff = unityLen - jsonDist;
                    float pct = jsonDist > 0.0005f ? diff / jsonDist * 100f : 0f;
                    diffs.Add(diff);
                    sumAbs += Mathf.Abs(diff);
                    if (Mathf.Abs(diff) > maxAbs) { maxAbs = Mathf.Abs(diff); maxAt = tid; }
                    if (Mathf.Abs(pct) > BigDiffPct)
                        big.Add(tid + " JSON " + F(jsonDist) + " m / Unity " + F(unityLen) + " m（" + F(pct) + "%）");
                    csv.AppendLine(tid + "," + category + "," + aisle + "," + r.use + "," +
                                   F(jsonDist) + "," + F(routeLen) + "," + F(unityLen) + "," +
                                   F(diff) + "," + F(pct) + "," + (narrow ? "1" : "0") + "," +
                                   (r.points.Count - 1) + "," + bad + ",ok");
                }
                else
                {
                    csv.AppendLine(tid + "," + category + "," + aisle + "," + r.use + "," +
                                   F(jsonDist) + "," + F(routeLen) + ",," + ",," +
                                   (narrow ? "1" : "0") + "," + (r.points.Count - 1) + "," + bad + "," + status);
                }
            }

            try { File.WriteAllText(csvPath, csv.ToString()); }
            catch (Exception e) { sb.AppendLine("  CSV を書けません: " + e.Message); }

            sb.AppendLine("  すべての区間がつながった卓: " + okTables + " / " + plan.seats.Count);
            if (diffs.Count > 0)
            {
                float mean = 0f;
                for (int i = 0; i < diffs.Count; i++) mean += diffs[i];
                mean /= diffs.Count;
                sb.AppendLine("  差（Unity − JSON）の平均: " + F(mean) + " m");
                sb.AppendLine("  差の大きさの平均: " + F(sumAbs / diffs.Count) + " m / 最大: " +
                              F(maxAbs) + " m（" + maxAt + "）");
            }
            sb.AppendLine("  差が " + F(BigDiffPct) + "% を超えた卓: " + big.Count);
            Lines(sb, big);
            sb.AppendLine("  via_narrow（use=narrow）の卓: " + narrowTables + " / " + plan.seats.Count);
            sb.AppendLine("  つながらない区間: " + brokenSeg.Count);
            Lines(sb, brokenSeg);
            sb.AppendLine("  まっすぐの " + F(SegmentDetourLimit) + " 倍を超えた区間: " + detourSeg.Count);
            Lines(sb, detourSeg);
            sb.AppendLine("  CSV: " + csvPath);
            return sb.ToString().TrimEnd();
        }

        /// <summary>その絞り込みで道のりを測る。完全につながらなければ -1</summary>
        static float PathLen(Vector3 from, Vector3 to, NavMeshQueryFilter filter, out string status)
        {
            NavMeshHit hitFrom, hitTo;
            if (!NavMesh.SamplePosition(from, out hitFrom, _snapRadius, filter))
            { status = "出発点が面の上にない"; return -1f; }
            if (!NavMesh.SamplePosition(to, out hitTo, _snapRadius, filter))
            { status = "行き先が面の上にない"; return -1f; }

            NavMeshPath path = new NavMeshPath();
            if (!NavMesh.CalculatePath(hitFrom.position, hitTo.position, filter, path))
            { status = "経路を引けない"; return -1f; }
            if (path.status != NavMeshPathStatus.PathComplete)
            {
                status = path.status == NavMeshPathStatus.PathPartial ? "途中まで" : "無効";
                return -1f;
            }

            float len = 0f;
            Vector3[] c = path.corners;
            for (int i = 1; i < c.Length; i++) len += Vector3.Distance(c[i - 1], c[i]);
            status = "ok";
            return len;
        }

        // ---------------- 4. 席に入れない卓 ----------------

        static string SeatReachReport(StoreBuilder builder, NavMeshQueryFilter people)
        {
            StringBuilder sb = new StringBuilder();
            sb.AppendLine("## 4. 席に入れない卓（入口から、席に着く点まで歩けるか）");

            Vector3 from = builder.PointOf(PlaceKind.Entrance);
            sb.AppendLine("  出発点 (Entrance): " + P(from));

            List<string> bad = new List<string>();
            int checkedSpots = 0;
            StorePlan plan = builder.Plan;
            for (int i = 0; i < plan.seats.Count; i++)
            {
                PlanSeat s = plan.seats[i];
                if (s.spots.Count == 0)
                {
                    bad.Add("T" + s.tableId.ToString("00") + " 席に着く点が無い");
                    continue;
                }
                bool any = false;
                for (int k = 0; k < s.spots.Count; k++)
                {
                    checkedSpots++;
                    string status;
                    float len = NavMeshBaker.PathLength(from, s.spots[k].point, SampleRadius, people, out status);
                    if (len >= 0f) { any = true; break; }
                }
                if (!any) bad.Add("T" + s.tableId.ToString("00") + " どの席に着く点へも歩けない");
            }
            sb.AppendLine("  確かめた点: " + checkedSpots + " / 卓 " + plan.seats.Count);
            sb.AppendLine("  入れない卓: " + bad.Count);
            Lines(sb, bad);
            return sb.ToString().TrimEnd();
        }

        // ---------------- 小さな道具 ----------------

        static int NumberFromId(string id, int fallback)
        {
            if (string.IsNullOrEmpty(id)) return fallback;
            int n = 0; bool any = false;
            for (int i = 0; i < id.Length; i++)
            {
                char c = id[i];
                if (c < '0' || c > '9') continue;
                n = n * 10 + (c - '0');
                any = true;
            }
            return any ? n : fallback;
        }

        static string P(Vector3 v)
        {
            return "(" + F(v.x) + ", " + F(v.z) + ")";
        }

        static string F(float v)
        {
            return v.ToString("0.##", CultureInfo.InvariantCulture);
        }
    }
}
```

### Assets/ServiceSim/Experiment/SimParams.cs
```csharp
using System.Collections.Generic;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// サイトの試算の値（serving-robot-params.json）を読む .asset。
    ///
    /// 2026-10-22 にエイイチと決めた「何について正か」（unity_design_v0.md の 0 章）:
    ///   条件（スタッフ・来客・ロボットの台数）と、店の回し方の決まりと時間は **サイトが正**。
    /// なので、店の回し方の数字は .asset に手で写さず、この JSON から読む。
    ///
    /// role（meta.roles）の扱い:
    ///   site_rule      … サイトが正。Unity はこの値を使う
    ///   unity_measures … **読まない**（Unity で測ってサイトに戻すための仮の値）
    ///   site_only      … 使わない（試算の中だけの設定）
    /// unity_measures の名前（robot_speed / pause_rate / pause_s / stuck_rate）は、
    /// まちがって使わないように、読もうとしたら例外ではなく「読まない」として弾く。
    ///
    /// 版の突き合わせ: meta.layout_generation が、店の形の JSON の meta.generation と
    /// 違ったら回さない（Ready が false になる）。
    /// </summary>
    [CreateAssetMenu(menuName = "ServiceSim/Sim Params", fileName = "SimParams")]
    public class SimParams : ScriptableObject
    {
        /// <summary>Unity が読まない値（unity_measures）の名前</summary>
        public static readonly string[] UnityMeasureKeys =
            { "robot_speed", "pause_rate", "pause_s", "stuck_rate" };

        [Header("読む JSON（サイトから写したもの）")]
        public TextAsset paramsJson;

        [Header("写した記録（報告に出す）")]
        public string fetchedAt = "";
        public string sourceUrl = "";

        // ---- 読んだ中身（Load のたびに作り直す）----
        JsonValue _root;
        readonly List<string> _problems = new List<string>();
        string _layoutGeneration = "";
        string _model = "";

        public string LayoutGeneration { get { return _layoutGeneration; } }
        public string Model { get { return _model; } }
        public bool Ready { get { return _root != null && _problems.Count == 0; } }
        public IList<string> Problems { get { return _problems; } }

        /// <summary>1 つの条件（unity_conditions の 1 件）</summary>
        public class Condition
        {
            public string store;          // fam_p1 / fam_p5
            public int staff;
            public int robots;
            public int arrivalsPerHour;
            public string mode;           // serve（上げ膳だけ）
            public int trays;             // 1 往復で運ぶ卓の数
            public string fragileBy;      // any（倒れやすい物もロボットが運ぶ）
            public readonly Dictionary<string, float> siteResult = new Dictionary<string, float>();

            /// <summary>書き出しのフォルダ名に使う名前</summary>
            public string Id
            {
                get { return store + "_s" + staff + "_r" + robots + "_a" + arrivalsPerHour; }
            }
        }

        readonly List<Condition> _conditions = new List<Condition>();
        public IList<Condition> Conditions { get { return _conditions; } }

        /// <summary>
        /// JSON を読む。storeGeneration に店の形の JSON の meta.generation を渡すと、
        /// meta.layout_generation と突き合わせる（違えば Ready が false）。
        /// </summary>
        public bool Load(string storeGeneration)
        {
            _problems.Clear();
            _conditions.Clear();
            _root = null;
            _layoutGeneration = "";
            _model = "";

            if (paramsJson == null)
            {
                _problems.Add("serving-robot-params.json（paramsJson）が入っていません");
                return false;
            }

            string err;
            JsonValue root = JsonValue.Parse(paramsJson.text, out err);
            if (root == null || !root.IsObject)
            {
                _problems.Add("serving-robot-params.json を読めません: " + err);
                return false;
            }
            _root = root;

            JsonValue meta = root["meta"];
            _layoutGeneration = meta["layout_generation"].S("");
            _model = meta["model"].S("");

            if (string.IsNullOrEmpty(_layoutGeneration))
                _problems.Add("meta.layout_generation がありません");
            else if (!string.IsNullOrEmpty(storeGeneration) && _layoutGeneration != storeGeneration)
                _problems.Add("版の番号が店の形の JSON と違います: 試算 " + _layoutGeneration +
                              " / 店の形 " + storeGeneration + "（写し直してください）");

            // ---- 条件 ----
            JsonValue list = root["unity_conditions"];
            for (int i = 0; i < list.Count; i++)
            {
                JsonValue c = list[i]["condition"];
                Condition cond = new Condition();
                cond.store = c["store"].S("");
                cond.staff = c["staff"].I(0);
                cond.robots = c["robots"].I(0);
                cond.arrivalsPerHour = c["arrivals_per_h"].I(0);
                cond.mode = c["mode"].S("serve");
                cond.trays = c["trays"].I(1);
                cond.fragileBy = c["fragile_by"].S("any");
                JsonValue sr = list[i]["site_result"];
                foreach (string k in sr.Keys) cond.siteResult[k] = sr[k].F(0f);
                if (string.IsNullOrEmpty(cond.store) || cond.staff <= 0)
                    _problems.Add("unity_conditions[" + i + "] の中身が足りません");
                _conditions.Add(cond);
            }
            if (_conditions.Count == 0) _problems.Add("unity_conditions が空です");

            return Ready;
        }

        // ---------------- 値の読み出し ----------------

        /// <summary>
        /// common の値（role=site_rule のものだけ）。
        /// unity_measures / site_only を読もうとしたら、使わずに fallback を返して問題に残す。
        /// </summary>
        public float SiteRule(string key, float fallback)
        {
            JsonValue v = Common(key);
            if (v == null) return fallback;
            return v["value"].F(fallback);
        }

        public float[] SiteRuleArray(string key, float[] fallback)
        {
            JsonValue v = Common(key);
            if (v == null) return fallback;
            JsonValue a = v["value"];
            if (!a.IsArray) return fallback;
            float[] r = new float[a.Count];
            for (int i = 0; i < a.Count; i++) r[i] = a[i].F(0f);
            return r;
        }

        /// <summary>common の 1 件。role が site_rule でなければ null（使わない）</summary>
        JsonValue Common(string key)
        {
            if (_root == null) return null;
            JsonValue v = _root["common"][key];
            if (v.IsNull)
            {
                _problems.Add("common." + key + " がありません");
                return null;
            }
            string role = v["role"].S("");
            if (role == "site_rule") return v;
            _problems.Add("common." + key + " は role=" + role + " なので Unity は読みません");
            return null;
        }

        /// <summary>stores.{key} の 1 件（fam_p1 / fam_p5）</summary>
        public JsonValue Store(string storeKey)
        {
            if (_root == null) return null;
            JsonValue s = _root["stores"][storeKey];
            if (s.IsNull)
            {
                _problems.Add("stores." + storeKey + " がありません");
                return null;
            }
            return s;
        }

        public float StoreValue(string storeKey, string key, float fallback)
        {
            JsonValue s = Store(storeKey);
            if (s == null) return fallback;
            JsonValue v = s[key];
            return v.kind == JsonValue.Kind.Number ? v.F(fallback) : fallback;
        }

        /// <summary>stores.{key} の配列（[最短, 最頻, 最長] など）</summary>
        public float[] StoreArray(string storeKey, string key, float[] fallback)
        {
            JsonValue s = Store(storeKey);
            if (s == null) return fallback;
            JsonValue a = s[key];
            if (!a.IsArray) return fallback;
            float[] r = new float[a.Count];
            for (int i = 0; i < a.Count; i++) r[i] = a[i].F(0f);
            return r;
        }

        /// <summary>
        /// 配列の配列の、index 番めを [最短, 最頻, 最長] で取る
        /// （first_dishes は [[5,8,12],[8,12,18]] の形）
        /// </summary>
        public Vector3 StoreTriangleAt(string storeKey, string key, int index, Vector3 fallback)
        {
            JsonValue s = Store(storeKey);
            if (s == null) return fallback;
            JsonValue a = s[key];
            if (!a.IsArray || index < 0 || index >= a.Count) return fallback;
            JsonValue t = a[index];
            if (!t.IsArray || t.Count < 3) return fallback;
            return new Vector3(t[0].F(0f), t[1].F(0f), t[2].F(0f));
        }

        /// <summary>[最短, 最頻, 最長] を Vector3 で</summary>
        public Vector3 StoreTriangle(string storeKey, string key, Vector3 fallback)
        {
            float[] a = StoreArray(storeKey, key, null);
            if (a == null || a.Length < 3) return fallback;
            return new Vector3(a[0], a[1], a[2]);
        }

        /// <summary>真ん中の値（arrivals・staff の「普通」）</summary>
        public int StoreMiddle(string storeKey, string key, int fallback)
        {
            float[] a = StoreArray(storeKey, key, null);
            if (a == null || a.Length == 0) return fallback;
            return Mathf.RoundToInt(a[a.Length / 2]);
        }

        /// <summary>何番めの条件（_conditions の並びのまま）。範囲外なら null</summary>
        public Condition ConditionAt(int index)
        {
            return index >= 0 && index < _conditions.Count ? _conditions[index] : null;
        }

        /// <summary>metrics の名前と意味（meta.metrics）</summary>
        public string MetricNote(string key)
        {
            if (_root == null) return "";
            return _root["meta"]["metrics"][key].S("");
        }

        public List<string> MetricKeys()
        {
            List<string> keys = new List<string>();
            if (_root == null) return keys;
            foreach (string k in _root["meta"]["metrics"].Keys) keys.Add(k);
            return keys;
        }

        /// <summary>報告に出す覚え書き</summary>
        public string Report()
        {
            System.Text.StringBuilder sb = new System.Text.StringBuilder();
            sb.AppendLine("サイトの試算の値（serving-robot-params.json）");
            sb.AppendLine("  ファイル: " + (paramsJson != null ? paramsJson.name : "（未設定）"));
            sb.AppendLine("  写した日時: " + (string.IsNullOrEmpty(fetchedAt) ? "（未記入）" : fetchedAt));
            sb.AppendLine("  写し元: " + (string.IsNullOrEmpty(sourceUrl) ? "（未記入）" : sourceUrl));
            sb.AppendLine("  試算の版 (meta.model): " + _model +
                          " / 店の形の版 (meta.layout_generation): " + _layoutGeneration);
            sb.AppendLine("  条件: " + _conditions.Count + " 件");
            for (int i = 0; i < _conditions.Count; i++)
            {
                Condition c = _conditions[i];
                sb.AppendLine("    " + c.Id + "  スタッフ " + c.staff + " / ロボット " + c.robots +
                              " / " + c.arrivalsPerHour + " 組毎時 / " + c.mode +
                              " / 1往復 " + c.trays + " 卓 / 倒れやすい物 " + c.fragileBy);
            }
            sb.AppendLine("  Unity が読まない値 (unity_measures): " +
                          string.Join(" / ", UnityMeasureKeys));
            if (_problems.Count == 0) sb.AppendLine("  合わないもの: なし");
            else
            {
                sb.AppendLine("  合わないもの: " + _problems.Count + " 件");
                for (int i = 0; i < _problems.Count; i++) sb.AppendLine("    - " + _problems[i]);
            }
            return sb.ToString().TrimEnd();
        }
    }
}
```

### Assets/ServiceSim/Robot/ServingRobotController.cs
```csharp
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

namespace ServiceSim
{
    /// <summary>
    /// 配膳ロボット1台。受け渡し口 → 卓 → 受け渡し口 の1往復を走り、
    /// 止まる・動き出す・経路を引き直す・動けなくなるを自分で決める。
    ///
    /// NavMeshAgent は経路を探すのと、人に自分をよけてもらうためだけに付ける。
    /// 自動でよける機能（obstacleAvoidanceType）は切り、位置はこの script が動かす。
    ///
    /// この script はロボットだけを扱う。人も店も動かさない。
    /// 細い通路の区画と入口は、店の側で決めた値を StartTrip で受け取るだけ。
    /// </summary>
    [RequireComponent(typeof(NavMeshAgent))]
    public class ServingRobotController : MonoBehaviour
    {
        public enum Phase
        {
            Idle,        // 往復していない
            ToTable,     // 卓へ向かう（細い通路の入口で待つこともある）
            Dwell,       // 卓で25秒止まる（「止まった」に数えない）
            ToPass,      // 受け渡し口へ戻る
            StuckWait,   // 動けなくなって、スタッフが来て動かしてくれるのを待つ（v5 3章）
            Done         // 往復が終わった
        }

        [Header("設定")]
        public RobotSpec spec;
        public FrontSensor sensor;

        [Tooltip("1台めは1、2台めは2")]
        public int robotId = 1;

        [Tooltip("立ち往生したときにスタッフを呼ぶ先（ServiceOps が入れる。v5 3章）")]
        public ServiceOps ops;

        [Header("いまの様子（読むだけ）")]
        [SerializeField] Phase _phase = Phase.Idle;
        [SerializeField] bool _enteredAisle;
        [SerializeField] bool _yieldHold;

        // ---- 1往復の測りもの ----
        public int TableId { get; private set; }

        /// <summary>
        /// この走りを始めた、店の中の時刻（秒）。始めた側が渡す。渡されなければ -1。
        /// 「いつ始まったか」をロボット自身が持つ。外から覚えておくと、
        /// 走り終わってすぐ次の仕事が入ったときに取りこぼす。
        /// </summary>
        public float RunStartS { get; private set; }

        public float DistanceM { get; private set; }
        public float TotalS { get; private set; }
        public float StoppedS { get; private set; }
        public int StopCount { get; private set; }
        public float MaxDecelMps2 { get; private set; }
        public float MinClearanceM { get; private set; }
        public bool Stuck { get; private set; }
        public bool MetRobot { get; private set; }
        public bool Aborted { get; private set; }

        /// <summary>
        /// この往復で動けなくなってスタッフを呼んだ回数（v5 3章）。
        /// 動かしてもらったあと、また動けなくなれば2回めを呼ぶので、Stuck（印）とは別に数える。
        /// </summary>
        public int StuckCount { get; private set; }

        /// <summary>スタッフが来るのを待った秒数の合計（StoppedS にも入っている）</summary>
        public float StuckWaitS { get; private set; }

        /// <summary>1往復が打ち切り（maxTripSec 超え）で終わったか</summary>
        public bool TimedOut { get; private set; }

        /// <summary>細い通路の入口で、ほかの台を待った秒数（秒）。stop_durations_s には入れない</summary>
        public float WaitAisleS { get; private set; }

        /// <summary>直前の1コマで進んだ距離（m）。コマの粗さを確かめるのに使う</summary>
        public float LastStepM { get; private set; }

        /// <summary>止まった原因</summary>
        public enum StopCause
        {
            Guest,           // 歩く客とすれ違った
            Staff,           // スタッフとすれ違った
            PersonStanding,  // 人が立ち止まっていた
            Chair,           // 椅子
            OtherRobot,      // もう1台のロボット（Unity だけ）
            NoPath,          // 行き先への道が引けない（通路の幅が足りない）
            AisleQueue,      // 細い通路の順番待ち（Unity だけ）
            Unknown          // 分からない
        }

        /// <summary>どう動き出したか</summary>
        public enum StopResolve
        {
            SelfResume,   // 自分で動き出した
            Replanned,    // 経路を引き直した
            Stuck,        // 動けなくなった
            AisleWait     // 入口で待った
        }

        /// <summary>止まり1回ぶんの記録</summary>
        public struct StopEvent
        {
            public float startTimeS;        // 店の中の時刻（秒）
            public Vector3 position;        // 止まった位置
            public float durationS;         // 止まっていた秒数
            public StopCause cause;
            public string causeDetail;      // 何に当たったか
            public StopResolve resolve;
            public float clearanceLeftM;    // 左の空き（ロボットの外周から）
            public float clearanceRightM;   // 右の空き
            public bool pathBlocked;        // 行き先への道が引けていなかったか

            // ---- 止まっている間、何で動けなかったかの内訳（秒）。B7 を調べるため ----
            /// <summary>前のセンサーが「ふさがっている」と言っていた秒数</summary>
            public float blockedS;
            /// <summary>行き先への道が引けていなかった秒数</summary>
            public float noPathS;
            /// <summary>細い通路の入口で順番を待っていた秒数</summary>
            public float yieldS;
            /// <summary>ふさがりが解けたあと、動き出すまで待っていた秒数</summary>
            public float clearWaitS;
            /// <summary>止まっている間に経路を引き直した回数</summary>
            public int replans;
        }

        readonly List<StopEvent> _stopEvents = new List<StopEvent>();

        /// <summary>この往復で止まった1回ずつの記録</summary>
        public IReadOnlyList<StopEvent> StopEvents { get { return _stopEvents; } }

        readonly List<float> _stopDurations = new List<float>();

        /// <summary>自分で動き出した止まりの長さ（秒）。動けなくなった分と、入口で待った分は入れない</summary>
        public IReadOnlyList<float> StopDurationsS { get { return _stopDurations; } }

        public Phase CurrentPhase { get { return _phase; } }
        public bool TripFinished { get { return _phase == Phase.Done; } }
        public bool HasEnteredAisle { get { return _enteredAisle; } }

        /// <summary>いまの速さ（m/秒。字幕が読むだけ）</summary>
        public float SpeedMps { get { return _speed; } }

        /// <summary>細い通路の入口で待っている最中か（字幕が読むだけ）</summary>
        public bool YieldHolding { get { return _yieldHold; } }

        /// <summary>卓での受け渡しの残り秒数（字幕が読むだけ）</summary>
        public float DwellRemainS
        {
            get { return spec != null ? Mathf.Max(0f, spec.tableDwellSec - _dwellTimer) : 0f; }
        }

        /// <summary>
        /// スタッフを待っている秒数（字幕が読むだけ。v5 3章）。
        /// 受け渡し口へ移すのをやめたので「残り」ではなく、呼んでから待っている長さを出す
        /// </summary>
        public float StuckWaitRemainS
        {
            get { return _stuckWaitTimer; }
        }
        public Bounds AisleZone { get { return _aisleZone; } }

        /// <summary>1走りで寄る卓1つぶん</summary>
        public struct RunStop
        {
            public int tableId;
            public Vector3 point;          // ロボットが止まる位置（通路の上）
            public Vector3 aisleEntrance;  // その通路に入る前に待つ位置
            public Bounds aisleZone;       // 譲り合いの区画
        }

        readonly List<RunStop> _stops = new List<RunStop>();
        readonly List<int> _tableIds = new List<int>();
        int _stopIndex;

        /// <summary>この走りで寄る卓の番号</summary>
        public IReadOnlyList<int> TableIds { get { return _tableIds; } }

        /// <summary>この走りで何卓に届けたか</summary>
        public int StopsCompleted { get; private set; }

        /// <summary>この走りで寄る卓の数</summary>
        public int RunStopCount { get { return _stops.Count; } }

        /// <summary>1往復が終わったときに呼ばれる</summary>
        public event Action<ServingRobotController> TripCompleted;

        // ---- いまシーンにいるロボット（譲り合いの判定に使う）----
        static readonly List<ServingRobotController> _all = new List<ServingRobotController>();
        public static IReadOnlyList<ServingRobotController> All { get { return _all; } }

        // ---- 内部 ----
        NavMeshAgent _agent;
        NavMeshPath _path;      // Awake で作る。フィールドの初期化で new すると
                                // 「InitializeNavMeshPath is not allowed to be called
                                //  from a MonoBehaviour constructor」になるため
        Vector3[] _corners = new Vector3[0];
        int _cornerIndex;
        Vector3 _destination;
        Vector3 _destinationOnMesh;
        bool _hasDestinationOnMesh;
        Vector3 _tablePoint;
        Vector3 _passPoint;
        Vector3 _aisleEntrance;
        Bounds _aisleZone;
        bool _hasAisleZone;

        float _speed;
        float _dwellTimer;
        float _clearTimer;
        float _stoppedTimer;        // いま続けて止まっている秒数
        float _lastReplanAtStopped;
        float _stuckWaitTimer;
        Phase _phaseBeforeStuck = Phase.ToTable;   // 動けなくなる直前に向かっていた先（v5 3章）
        float _repathCooldown;
        bool _inStop;
        float _currentStopS;
        bool _inWait;
        float _currentWaitS;
        Vector3 _pathEndPoint;
        bool _hasPathEnd;
        bool _wasBlocked;
        bool _hasBrakeCause;
        StopCause _brakeCause;
        string _brakeDetail = "";
        string _aisleBlockerName = "";
        bool _replannedInStop;
        bool _recordingStop;
        StopEvent _open;
        bool _pathValid;

        // ---- 途中までの経路から出る（B7）----
        /// <summary>この止まりの中で、最後に強く引き直したときの no_path_s</summary>
        float _lastEscapeAtNoPathS;
        /// <summary>いま通路の入口へ出ようとしているか</summary>
        bool _escapeToEntrance;

        /// <summary>
        /// ロボットが歩く NavMesh の種類（agentTypeID）。既定の 0。
        /// NavMesh が人用とロボット用の2枚になったので、引くときは必ず種類を指定する
        /// （指定しないと「どの種類か決められない」と警告が出て、思わぬ面を拾う）。
        /// </summary>
        public const int RobotAgentTypeId = 0;

        static NavMeshQueryFilter RobotFilter
        {
            get
            {
                NavMeshQueryFilter f = new NavMeshQueryFilter();
                f.agentTypeID = RobotAgentTypeId;
                f.areaMask = NavMesh.AllAreas;
                return f;
            }
        }

        /// <summary>
        /// stops.csv の cause_detail に書く印。
        /// この印がある止まりは「途中までの経路に縛られて、引き直して出た」もの。
        /// CSV を割らないように、区切りはカンマを使わない。
        /// </summary>
        public const string PartialPathMark = "途中までの経路";

        void Awake()
        {
            _path = new NavMeshPath();
            _agent = GetComponent<NavMeshAgent>();
            ApplySpecToAgent();
        }

        void OnEnable()
        {
            if (!_all.Contains(this)) _all.Add(this);
        }

        void OnDisable()
        {
            _all.Remove(this);
        }

        void ApplySpecToAgent()
        {
            if (_agent == null || spec == null) return;
            _agent.radius = spec.Radius;
            _agent.height = spec.height;
            _agent.speed = spec.maxSpeed;
            _agent.acceleration = spec.acceleration;
            _agent.angularSpeed = spec.turnSpeedDegPerSec;
            // 自動でよける機能は使わない（実機のよけ方と違うため）
            // ロボットはロボット用の面（半径 navAgentRadius で焼いたもの）を歩く
            if (_agent.agentTypeID != RobotAgentTypeId) _agent.agentTypeID = RobotAgentTypeId;
            _agent.obstacleAvoidanceType = ObstacleAvoidanceType.NoObstacleAvoidance;
            _agent.avoidancePriority = spec.avoidancePriority;
            // 位置と向きはこの script が動かす。agent は経路を探すのと、人に見てもらうためだけ
            _agent.updatePosition = false;
            _agent.updateRotation = false;
            _agent.autoRepath = false;
            // isStopped は NavMesh に乗っている agent にしか使えない
            if (_agent.enabled && _agent.isOnNavMesh) _agent.isStopped = true;
        }

        /// <summary>受け渡し口に置き直して、次の往復を始める準備をする</summary>
        /// <summary>
        /// 仕事がないときの待機場所へ、ゆっくり下がる（B2）。
        /// 往復ではないので、距離・止まり・動けなくなったの記録には数えない。
        /// 止まる決まり（前のセンサー・譲り合い）もここでは働かせない。
        /// すでにその場所の近くにいれば何もしない。
        /// </summary>
        public void GoStandby(Vector3 spot)
        {
            if (_phase != Phase.Idle && _phase != Phase.Done) return;
            if (_agent == null || !Application.isPlaying) return;
            if (!_agent.enabled || !_agent.isOnNavMesh) return;

            Vector3 here = transform.position; here.y = 0f;
            Vector3 to = spot; to.y = 0f;
            if (Vector3.Distance(here, to) <= spec.arriveRadius) { _agent.isStopped = true; return; }

            _agent.isStopped = false;
            _agent.speed = spec.maxSpeed;
            _agent.SetDestination(spot);
        }

        public void PlaceAt(Vector3 position, Quaternion rotation)
        {
            transform.SetPositionAndRotation(position, rotation);
            _speed = 0f;

            if (_agent == null) return;
            if (!Application.isPlaying) return;      // 編集モードでは NavMeshAgent を触らない

            if (!_agent.enabled) _agent.enabled = true;
            _agent.Warp(position);
            if (_agent.isOnNavMesh)
            {
                _agent.isStopped = true;
                _agent.nextPosition = position;
            }
        }

        /// <summary>1往復を始める（細い通路の譲り合いなし。場面の再現などに使う）</summary>
        public void StartTrip(int tableId, Vector3 tablePoint, Vector3 passPoint)
        {
            StartTrip(tableId, tablePoint, passPoint, passPoint, new Bounds());
        }

        /// <summary>1往復を始める（1卓だけ）</summary>
        public void StartTrip(int tableId, Vector3 tablePoint, Vector3 passPoint,
                              Vector3 aisleEntrance, Bounds aisleZone)
        {
            RunStop s = new RunStop();
            s.tableId = tableId;
            s.point = tablePoint;
            s.aisleEntrance = aisleEntrance;
            s.aisleZone = aisleZone;
            List<RunStop> one = new List<RunStop>();
            one.Add(s);
            StartRun(one, passPoint);
        }

        /// <summary>
        /// 1走りを始める。受け渡し口を出て、渡された卓を順に回り、受け渡し口へ戻る。
        /// 止まる決まり（前のセンサー・譲り合い・動けなくなったときの扱い）は1卓のときと同じ。
        /// </summary>
        public void StartRun(IList<RunStop> stops, Vector3 passPoint)
        {
            StartRun(stops, passPoint, -1f);
        }

        /// <summary>始めた時刻（店の中, 秒）も渡して1走りを始める。記録に使う</summary>
        public void StartRun(IList<RunStop> stops, Vector3 passPoint, float startS)
        {
            RunStartS = startS;
            _stops.Clear();
            _tableIds.Clear();
            if (stops != null)
            {
                for (int i = 0; i < stops.Count; i++)
                {
                    _stops.Add(stops[i]);
                    _tableIds.Add(stops[i].tableId);
                }
            }
            _stopIndex = 0;
            StopsCompleted = 0;

            if (_stops.Count == 0) { _phase = Phase.Idle; return; }
            BeginRun(passPoint);
        }

        void BeginRun(Vector3 passPoint)
        {
            RunStop first = _stops[0];
            int tableId = first.tableId;
            Vector3 tablePoint = first.point;
            Vector3 aisleEntrance = first.aisleEntrance;
            Bounds aisleZone = first.aisleZone;

            if (_path == null) _path = new NavMeshPath();
            ApplySpecToAgent();
            if (Application.isPlaying && _agent != null)
            {
                if (!_agent.enabled) _agent.enabled = true;
                if (!_agent.isOnNavMesh) _agent.Warp(transform.position);
            }

            TableId = tableId;
            _tablePoint = tablePoint;
            _passPoint = passPoint;
            _aisleEntrance = aisleEntrance;
            _aisleZone = aisleZone;
            _hasAisleZone = aisleZone.size.x > 0.01f && aisleZone.size.z > 0.01f;

            DistanceM = 0f;
            TotalS = 0f;
            StoppedS = 0f;
            StopCount = 0;
            MaxDecelMps2 = 0f;
            MinClearanceM = float.PositiveInfinity;
            Stuck = false;
            StuckCount = 0;
            StuckWaitS = 0f;
            LegWasNarrow = false;
            MetRobot = false;
            Aborted = false;
            TimedOut = false;
            WaitAisleS = 0f;
            _stopDurations.Clear();
            _stopEvents.Clear();

            _speed = 0f;
            _dwellTimer = 0f;
            _clearTimer = 0f;
            _hasDestinationOnMesh = false;
            _wasBlocked = false;
            _hasBrakeCause = false;
            _stoppedTimer = 0f;
            _lastReplanAtStopped = 0f;
            _stuckWaitTimer = 0f;
            _phaseBeforeStuck = Phase.ToTable;
            _repathCooldown = 0f;
            _inStop = false;
            _currentStopS = 0f;
            _lastEscapeAtNoPathS = 0f;
            _escapeToEntrance = false;

            // 譲り合いを使わない／区画が無いときは、はじめから「入った」扱いにする
            _enteredAisle = !(spec != null && spec.aisleYielding) || !_hasAisleZone;
            _yieldHold = false;

            _phase = Phase.ToTable;
            // v5b: サイトの道順を受け取る（店を建て直すこともあるので、1 走りごとに引き直す）
            StoreBuilder store = FindObjectOfType<StoreBuilder>();
            _routes = store != null ? store.Routes : null;
            _snapFallbacks = 0;
            bool narrow;
            BeginLeg(RouteTo(0, tableId, out narrow), _tablePoint, narrow);
        }

        /// <summary>次の卓へ向かう。もう無ければ受け渡し口へ戻る</summary>
        void NextStopOrReturn()
        {
            if (_stopIndex + 1 < _stops.Count)
            {
                int prevTableId = _stops[_stopIndex].tableId;
                _stopIndex++;
                RunStop s = _stops[_stopIndex];
                TableId = s.tableId;
                _tablePoint = s.point;
                _aisleEntrance = s.aisleEntrance;
                _aisleZone = s.aisleZone;
                _hasAisleZone = s.aisleZone.size.x > 0.01f && s.aisleZone.size.z > 0.01f;
                _enteredAisle = !(spec != null && spec.aisleYielding) || !_hasAisleZone;
                _yieldHold = false;
                _escapeToEntrance = false;
                _lastEscapeAtNoPathS = 0f;
                _phase = Phase.ToTable;
                bool narrowNext;
                BeginLeg(RouteTo(prevTableId, s.tableId, out narrowNext), _tablePoint, narrowNext);
                return;
            }
            _phase = Phase.ToPass;
            _escapeToEntrance = false;
            _lastEscapeAtNoPathS = 0f;
            bool narrowBack;
            BeginLeg(RouteToPass(_stops[_stopIndex].tableId, out narrowBack), _passPoint, narrowBack);
        }

        /// <summary>往復を打ち切る</summary>
        public void AbortTrip()
        {
            _legPoints = null;
            if (_phase == Phase.Idle || _phase == Phase.Done) return;
            Aborted = true;
            Finish();
        }

        void Update()
        {
            if (spec == null) return;
            if (_phase == Phase.Idle || _phase == Phase.Done) return;

            float dt = Time.deltaTime;
            if (dt <= 0f) return;

            LastStepM = 0f;

            FrontSensor.Reading reading = sensor != null ? sensor.Sense() : new FrontSensor.Reading();
            if (reading.sawRobot) MetRobot = true;

            // 卓での25秒。測りの時間には入れない
            if (_phase == Phase.Dwell)
            {
                _dwellTimer += dt;
                if (_dwellTimer >= spec.tableDwellSec) NextStopOrReturn();
                SyncAgent();
                return;
            }

            TotalS += dt;
            UpdateClearance();

            // 1往復の打ち切り（この往復は timeout として1行に残す）
            if (TotalS > spec.maxTripSec)
            {
                TimedOut = true;
                PlaceAt(_passPoint, transform.rotation);
                Finish();
                return;
            }

            // 動けなくなって、スタッフが来るのを待っているあいだ（v5 3章）。
            // 受け渡し口へ移す（warp）のはやめた。スタッフが歩いて来て rescue_s 動かすと
            // Rescued() が呼ばれ、止まっていた所から走りを続ける。
            // 待っているあいだは「止まっていた」に数える。
            // いつまでも来ないときは、上の maxTripSec の打ち切りが働く（timeout=1）。
            if (_phase == Phase.StuckWait)
            {
                StoppedS += dt;
                StuckWaitS += dt;
                _stuckWaitTimer += dt;
                SyncAgent();
                return;
            }

            // ---- 通路の入口へ出ている途中（B7）。着いたら、もう一度もとの行き先を目指す ----
            UpdateEscape();

            // ---- 細い通路の譲り合い ----
            bool waitingAtEntrance = UpdateAisleYielding();

            // ---- 走る・止まる ----
            bool blocked = reading.blocked;

            // 原因は、ブレーキを踏み始めた瞬間（前が塞がったと分かった瞬間）のものを覚えておく。
            // 速さが0になるまで待つと、そのあいだに相手が通り過ぎて「分からない」になってしまう
            if (blocked && !_wasBlocked)
            {
                _brakeCause = CauseOf(reading);
                _brakeDetail = string.IsNullOrEmpty(reading.nearestName) ? "" : reading.nearestName;
                _hasBrakeCause = true;
            }
            _wasBlocked = blocked;

            if (blocked) _clearTimer = 0f;
            else _clearTimer += dt;

            // 行き先への道が引けていないときも止まる（進めないのに走っている扱いにしない）
            bool hasPath = _corners.Length > 0 && _cornerIndex < _corners.Length;
            bool wantMove = !blocked && _clearTimer >= spec.resumeAfterClearSec && !waitingAtEntrance && hasPath;

            float target = wantMove ? spec.maxSpeed : 0f;
            float prevSpeed = _speed;
            if (target > _speed) _speed = Mathf.Min(target, _speed + spec.acceleration * dt);
            else _speed = Mathf.Max(target, _speed - spec.deceleration * dt);

            float decel = (prevSpeed - _speed) / dt;
            if (decel > MaxDecelMps2) MaxDecelMps2 = decel;

            if (_speed <= spec.stoppedSpeedEpsilon)
            {
                StoppedS += dt;

                if (waitingAtEntrance)
                {
                    // 譲り合いで入口に待っている分。止まった回数・長さには数えない
                    WaitAisleS += dt;
                    _stoppedTimer = 0f;
                    _lastReplanAtStopped = 0f;
                    if (!_inWait)
                    {
                        _inWait = true;
                        _currentWaitS = 0f;
                        BeginStopRecord(StopCause.AisleQueue,
                            _aisleBlockerName.Length > 0 ? "先に入っている台：" + _aisleBlockerName : "順番待ち", false);
                    }
                    _currentWaitS += dt;
                    _open.yieldS += dt;
                }
                else
                {
                    if (_inWait) { _inWait = false; EndStopRecord(_currentWaitS, StopResolve.AisleWait); }

                    _stoppedTimer += dt;
                    if (!_inStop)
                    {
                        _inStop = true;
                        _currentStopS = 0f;
                        _replannedInStop = false;
                        _lastEscapeAtNoPathS = 0f;   // no_path_s は止まりごとに数え直す（B7）

                        string detail;
                        StopCause cause;
                        if (!hasPath) cause = CauseOfNoPath(out detail);
                        else if (_hasBrakeCause)
                        {
                            cause = _brakeCause;      // ブレーキを踏み始めたときに見たもの
                            detail = _brakeDetail;
                        }
                        else
                        {
                            cause = CauseOf(reading);
                            detail = string.IsNullOrEmpty(reading.nearestName) ? "" : reading.nearestName;
                        }
                        BeginStopRecord(cause, detail, !hasPath);
                        _hasBrakeCause = false;
                    }
                    _currentStopS += dt;

                    // 何で動けないかを秒数で分けて残す（B7 を調べるため）。
                    // 止まる決まりは変えない。数えるだけ
                    if (blocked) _open.blockedS += dt;
                    if (!hasPath) _open.noPathS += dt;
                    if (!blocked && hasPath && _clearTimer < spec.resumeAfterClearSec)
                        _open.clearWaitS += dt;

                    // 止まって10秒ごとに経路を引き直す
                    if (_stoppedTimer - _lastReplanAtStopped >= spec.replanAfterStoppedSec)
                    {
                        _lastReplanAtStopped = _stoppedTimer;
                        _replannedInStop = true;
                        _open.replans++;
                        SetDestination(_destination);
                    }

                    // 途中までの経路に縛られているとき（B7）。
                    // 「道が引けていなかった秒数」がしきい値を超えたら、
                    // 引き直しの間隔（repathRetrySec）を待たずに強く引き直す。
                    // それでも途中までしか引けなければ、いちど通路の入口を目指して出る。
                    if (!hasPath && !_escapeToEntrance &&
                        _open.noPathS - _lastEscapeAtNoPathS >= spec.noPathForceReplanSec)
                    {
                        EscapePartialPath();
                    }

                    // 60秒動けなかったら「動けなくなった」
                    if (_stoppedTimer >= spec.stuckAfterStoppedSec)
                    {
                        EnterStuck();
                        SyncAgent();
                        return;
                    }
                }
            }
            else
            {
                if (_inWait) { _inWait = false; EndStopRecord(_currentWaitS, StopResolve.AisleWait); }
                if (_inStop)
                {
                    _inStop = false;
                    StopCount++;
                    _stopDurations.Add(_currentStopS);
                    EndStopRecord(_currentStopS,
                        _replannedInStop ? StopResolve.Replanned : StopResolve.SelfResume);
                }
                _stoppedTimer = 0f;
                _lastReplanAtStopped = 0f;
            }

            FaceAndMove(dt);
            SyncAgent();
            CheckArrival();
        }

        /// <summary>
        /// 細い通路に入る前の譲り合い。
        /// 行き先の通路にもう1台がいたら、太い通路側の入口で待つ。
        /// 戻り（ToPass）では譲らない（出ないと詰まるため）。
        /// </summary>
        bool UpdateAisleYielding()
        {
            if (_phase != Phase.ToTable) return false;
            if (!spec.aisleYielding || !_hasAisleZone || _enteredAisle) return false;

            if (ContainsXZ(_aisleZone, transform.position))
            {
                _enteredAisle = true;
                if (_yieldHold)
                {
                    _yieldHold = false;
                    ResumeLeg(_tablePoint);
                }
                return false;
            }

            if (MustWaitForAisle())
            {
                if (!_yieldHold)
                {
                    _yieldHold = true;
                    SetDestination(_aisleEntrance, true);
                }
            }
            else if (_yieldHold)
            {
                _yieldHold = false;
                ResumeLeg(_tablePoint);
            }

            if (!_yieldHold) return false;

            Vector3 a = transform.position; a.y = 0f;
            Vector3 b = _aisleEntrance; b.y = 0f;
            return Vector3.Distance(a, b) <= spec.arriveRadius;
        }

        /// <summary>
        /// 途中までの経路に縛られた止まりから出る（B7）。
        ///   1. 引き直しの間隔（repathRetrySec）を待たずに強く引き直す
        ///   2. それでも最後までつながらなければ、いちど通路の入口を目指して出る
        /// どちらも「経路の引き直し」なので、この止まりは resolved が「経路を引き直した」になる。
        /// cause_detail には「途中までの経路」の印を足す。
        /// </summary>
        void EscapePartialPath()
        {
            _lastEscapeAtNoPathS = _open.noPathS;
            _replannedInStop = true;
            _open.replans++;
            MarkPartialPath();

            // 1. 間隔を無視して引き直す
            SetDestination(_destination, true);
            if (_pathValid && _cornerIndex < _corners.Length) return;

            // 2. それでも途中まで。通路の入口へ出る
            if (!spec.escapeToAisleEntranceOnPartialPath) return;

            Vector3 here = transform.position; here.y = 0f;
            Vector3 exit = _aisleEntrance; exit.y = 0f;
            if (Vector3.Distance(here, exit) <= spec.arriveRadius) return;

            _escapeToEntrance = true;
            SetDestination(_aisleEntrance, true);
        }

        /// <summary>通路の入口へ出ている途中。着いたら、もとの行き先をもう一度目指す</summary>
        void UpdateEscape()
        {
            if (!_escapeToEntrance) return;

            Vector3 here = transform.position; here.y = 0f;
            Vector3 exit = _aisleEntrance; exit.y = 0f;
            if (Vector3.Distance(here, exit) > spec.arriveRadius) return;

            _escapeToEntrance = false;
            ResumeLeg(_phase == Phase.ToPass ? _passPoint : _tablePoint);
        }

        /// <summary>この止まりに「途中までの経路」の印をつける（1回だけ）</summary>
        void MarkPartialPath()
        {
            if (!_recordingStop) return;
            string d = _open.causeDetail;
            if (!string.IsNullOrEmpty(d) && d.Contains(PartialPathMark)) return;
            _open.causeDetail = string.IsNullOrEmpty(d) ? PartialPathMark : d + "／" + PartialPathMark;
        }

        bool MustWaitForAisle()
        {
            for (int i = 0; i < _all.Count; i++)
            {
                ServingRobotController o = _all[i];
                if (o == null || o == this) continue;

                // その通路の中にいる台がいれば入らない
                if (ContainsXZ(_aisleZone, o.transform.position)) { _aisleBlockerName = o.name; return true; }

                // 同じ通路を目指していて、まだ入っていない台。番号の小さい方が先に入る
                if (o._phase == Phase.ToTable && !o._enteredAisle && o._hasAisleZone &&
                    SameZone(o._aisleZone, _aisleZone) && o.robotId < robotId)
                {
                    _aisleBlockerName = o.name;
                    return true;
                }
            }
            _aisleBlockerName = "";
            return false;
        }

        static bool ContainsXZ(Bounds b, Vector3 p)
        {
            return p.x >= b.min.x && p.x <= b.max.x && p.z >= b.min.z && p.z <= b.max.z;
        }

        static bool SameZone(Bounds a, Bounds b)
        {
            Vector3 d = a.center - b.center;
            d.y = 0f;
            return d.sqrMagnitude < 0.0025f;   // 5 cm 以内なら同じ通路
        }

        /// <summary>
        /// 動けなくなった。スタッフを呼んで、来るまで待つ（v5 3章）。
        /// Stuck は「この往復で1回でも動けなくなったか」の印なので、1往復に1回だけ立てる。
        /// 呼ぶ方は何度でも起こりうる（動かしてもらった先でまた動けなくなる）ので、
        /// 呼び出しは Stuck の印とは別に毎回出す。
        /// </summary>
        void EnterStuck()
        {
            if (_phase == Phase.StuckWait) return;   // すでに待っている
            Stuck = true;
            if (_inStop) EndStopRecord(_currentStopS, StopResolve.Stuck);
            // 動けなくなった分は「自分で動き出した止まり」には数えない
            _inStop = false;
            _currentStopS = 0f;
            _stuckWaitTimer = 0f;
            _escapeToEntrance = false;
            _lastEscapeAtNoPathS = 0f;
            StuckCount++;
            _phaseBeforeStuck = _phase;
            _phase = Phase.StuckWait;
            if (ops != null) ops.PostRescue(this);
        }

        /// <summary>
        /// スタッフが来て rescue_s 動かし終わった（StaffAgent が呼ぶ。v5 3章）。
        /// 止まっていた所から、同じ道順の続きを走り直す。道順そのものは変えない。
        /// </summary>
        public void Rescued()
        {
            if (_phase != Phase.StuckWait) return;
            if (ops != null) ops.RescueFinished(_stuckWaitTimer);
            _stuckWaitTimer = 0f;
            _stoppedTimer = 0f;
            _lastReplanAtStopped = 0f;
            _clearTimer = 0f;
            bool toPass = _phaseBeforeStuck == Phase.ToPass;
            _phase = toPass ? Phase.ToPass : Phase.ToTable;
            ResumeLeg(toPass ? _passPoint : _tablePoint);
        }

        /// <summary>止まった原因を、前のセンサーが見たものから決める</summary>
        StopCause CauseOf(FrontSensor.Reading r)
        {
            if (r.nearestIsRobot) return StopCause.OtherRobot;
            if (r.nearestIsChair) return StopCause.Chair;
            if (r.nearestIsPerson)
            {
                if (r.nearestPersonStanding) return StopCause.PersonStanding;
                return r.nearestPersonKind == PersonKind.Staff ? StopCause.Staff : StopCause.Guest;
            }
            if (r.sawRobot) return StopCause.OtherRobot;
            if (r.sawChair) return StopCause.Chair;
            if (r.sawPerson) return StopCause.Guest;
            return StopCause.Unknown;
        }

        /// <summary>
        /// 行き先への道が引けないときの原因。
        /// ふさいでいるもの（引かれた椅子・もう1台のロボット）が見つかれば、その原因にする。
        /// 何も見つからないときだけ「通路の幅が足りない」にする。
        /// </summary>
        StopCause CauseOfNoPath(out string detail)
        {
            StopCause cause;
            string name;

            // 行き止まりの点 → 行き先 → 自分のまわり、の順に探す
            if (_hasPathEnd && FindBlocker(_pathEndPoint, out cause, out name))
            {
                detail = "経路なし：" + name;
                return cause;
            }
            if (FindBlocker(_destination, out cause, out name))
            {
                detail = "経路なし：" + name;
                return cause;
            }
            if (FindBlocker(transform.position, out cause, out name))
            {
                detail = "経路なし：" + name;
                return cause;
            }

            detail = "経路なし：ふさいでいるものが見つからない";
            return StopCause.NoPath;
        }

        const int BlockMax = 32;
        readonly Collider[] _blockHits = new Collider[BlockMax];

        /// <summary>
        /// その点のまわりで、道をふさぎうるもの（引かれた椅子・ほかのロボット）のうち
        /// いちばん近いものを探す。見つかれば true。
        /// </summary>
        bool FindBlocker(Vector3 around, out StopCause cause, out string name)
        {
            cause = StopCause.NoPath;
            name = "";

            Vector3 center = new Vector3(around.x, spec.clearanceProbeHeight, around.z);
            int n = Physics.OverlapSphereNonAlloc(center, spec.pathBlockSearchRadius, _blockHits,
                                                  ~0, QueryTriggerInteraction.Ignore);
            if (n > BlockMax) n = BlockMax;

            float best = float.MaxValue;
            for (int i = 0; i < n; i++)
            {
                Collider col = _blockHits[i];
                if (col == null) continue;

                Chair chair = col.GetComponentInParent<Chair>();
                if (chair != null)
                {
                    // 引かれていない椅子は NavMesh を削らないので、道をふさがない
                    if (!chair.IsPulled) continue;
                    float d = Vector3.Distance(center, chair.transform.position);
                    if (d < best)
                    {
                        best = d;
                        cause = StopCause.Chair;
                        name = chair.transform.parent != null
                            ? chair.transform.parent.name + "/" + chair.name : chair.name;
                    }
                    continue;
                }

                ServingRobotController other = col.GetComponentInParent<ServingRobotController>();
                if (other != null && other != this)
                {
                    float d = Vector3.Distance(center, other.transform.position);
                    if (d < best)
                    {
                        best = d;
                        cause = StopCause.OtherRobot;
                        name = other.name;
                    }
                }
            }
            return best < float.MaxValue;
        }

        void BeginStopRecord(StopCause cause, string detail, bool pathBlocked)
        {
            _open = new StopEvent();
            _open.startTimeS = Time.time;
            _open.position = transform.position;
            _open.cause = cause;
            _open.causeDetail = detail == null ? "" : detail;
            _open.pathBlocked = pathBlocked;
            float left, right;
            MeasureSideClearance(out left, out right);
            _open.clearanceLeftM = left;
            _open.clearanceRightM = right;
            _recordingStop = true;
        }

        void EndStopRecord(float durationS, StopResolve resolve)
        {
            if (!_recordingStop) return;
            _recordingStop = false;
            _open.durationS = durationS;
            _open.resolve = resolve;
            _stopEvents.Add(_open);
        }

        /// <summary>
        /// 止まった位置での、左右の空き（ロボットの外周から壁・席・椅子まで）。
        /// 人とロボットは数えない（動くもののため）。
        /// </summary>
        void MeasureSideClearance(out float left, out float right)
        {
            left = ProbeSide(-transform.right);
            right = ProbeSide(transform.right);
        }

        const int ProbeMax = 16;
        readonly RaycastHit[] _probe = new RaycastHit[ProbeMax];

        float ProbeSide(Vector3 dir)
        {
            float range = spec.clearanceProbeRange;
            Vector3 origin = transform.position + Vector3.up * spec.clearanceProbeHeight;
            int n = Physics.RaycastNonAlloc(origin, dir, _probe, range, ~0, QueryTriggerInteraction.Ignore);
            if (n > ProbeMax) n = ProbeMax;

            float best = range;
            for (int i = 0; i < n; i++)
            {
                Collider col = _probe[i].collider;
                if (col == null) continue;
                if (col.GetComponentInParent<PersonBody>() != null) continue;
                if (col.GetComponentInParent<ServingRobotController>() != null) continue;
                if (_probe[i].distance < best) best = _probe[i].distance;
            }
            return Mathf.Max(0f, best - spec.Radius);
        }

        void CheckArrival()
        {
            if (_yieldHold) return;         // いまの行き先は通路の入口なので、着いても卓ではない
            if (_escapeToEntrance) return;  // 途中までの経路から出ている途中（B7）。行き先は通路の入口

            Vector3 here = transform.position; here.y = 0f;
            Vector3 goal = _hasDestinationOnMesh ? _destinationOnMesh : _destination; goal.y = 0f;

            // v5b: 道順の途中の点は、少し手前でも「着いた」として次の点へ進む。
            // 最後の点（卓の止まる位置・受け渡し口）は今までどおり arriveRadius で見る
            bool midWaypoint = _legPoints != null && _legIndex + 1 < _legPoints.Count;
            float reach = midWaypoint
                ? Mathf.Max(spec.arriveRadius, spec.waypointReachDistance)
                : spec.arriveRadius;
            bool near = Vector3.Distance(here, goal) <= reach;

            // 最後までつながった経路の角を使い切ったら、着いたとみなす
            bool consumed = _pathValid && _corners.Length > 0 && _cornerIndex >= _corners.Length;
            if (!near && !consumed) return;

            if (AdvanceWaypoint()) return;     // まだ区間の途中

            if (_phase == Phase.ToTable)
            {
                _phase = Phase.Dwell;
                _dwellTimer = 0f;
                _speed = 0f;
                StopsCompleted = _stopIndex + 1;   // この卓には届いた
            }
            else if (_phase == Phase.ToPass)
            {
                Finish();
            }
        }

        void Finish()
        {
            _phase = Phase.Done;
            _speed = 0f;
            _legPoints = null;
            // まだ呼んだままのスタッフがいたら、呼び出しを下げる（v5 3章）
            if (ops != null) ops.CancelRescue(this);
            if (_inWait) { _inWait = false; EndStopRecord(_currentWaitS, StopResolve.AisleWait); }
            if (_inStop)
            {
                _inStop = false;
                StopCount++;
                _stopDurations.Add(_currentStopS);
                EndStopRecord(_currentStopS,
                    _replannedInStop ? StopResolve.Replanned : StopResolve.SelfResume);
            }
            if (float.IsInfinity(MinClearanceM)) MinClearanceM = -1f;   // 人がいなかった
            if (TripCompleted != null) TripCompleted(this);
        }

        // ---- サイトの道順（v5b）----
        RouteBook _routes;
        List<Vector3> _legPoints;         // いまの区間の曲がり角の点（最後が行き先そのもの）
        int _legIndex;
        bool _legIsNarrow;                // この区間が 0.8 m の道か（記録用）
        int _snapFallbacks;               // 止まる位置を 0.1 m で面に寄せられず、広げた回数

        /// <summary>いまの区間が 0.8 m の道か（記録用。v5b）</summary>
        public bool UsedNarrowAisle { get { return _legIsNarrow; } }

        /// <summary>
        /// この往復のどこかで 0.8 m の道を使ったか（trips.csv の via_narrow。v5 3章）。
        /// 1往復で何卓も回るので、区間ごとの印（UsedNarrowAisle）とは別に、
        /// 1回でも使ったら立てたままにする。
        /// </summary>
        public bool LegWasNarrow { get; private set; }

        /// <summary>道順の点を 0.1 m で面に寄せられなかった回数（報告用。v5b）</summary>
        public int RouteSnapFallbacks { get { return _snapFallbacks; } }

        /// <summary>
        /// 区間（受け渡し口→卓 / 卓→卓 / 卓→受け渡し口）を始める（v5b）。
        /// サイトの道順があればその点を順にたどり、無ければ行き先まで直接向かう。
        /// いまいる所に近い先頭の点は飛ばす（受け渡し口で始めるときなど）。
        /// </summary>
        void BeginLeg(List<Vector3> points, Vector3 fallbackTarget, bool narrow)
        {
            _legIsNarrow = narrow;
            if (narrow) LegWasNarrow = true;
            _legPoints = null;
            _legIndex = 0;

            if (spec != null && spec.followSiteRoutes && points != null && points.Count >= 2)
            {
                _legPoints = points;
                Vector3 here = transform.position; here.y = 0f;
                // 先頭の点は、いまいる所と同じことが多い（受け渡し口）。近ければ飛ばす
                while (_legIndex < _legPoints.Count - 1)
                {
                    Vector3 p = _legPoints[_legIndex]; p.y = 0f;
                    if (Vector3.Distance(here, p) > spec.waypointReachDistance) break;
                    _legIndex++;
                }
                SetDestination(_legPoints[_legIndex], true);
                return;
            }
            SetDestination(fallbackTarget, true);
        }

        /// <summary>いまの区間の、いま目指している点をもう一度行き先にする（v5b）</summary>
        void ResumeLeg(Vector3 fallbackTarget)
        {
            if (_legPoints != null && _legIndex < _legPoints.Count)
            {
                SetDestination(_legPoints[_legIndex], true);
                return;
            }
            SetDestination(fallbackTarget, true);
        }

        /// <summary>
        /// 道順の途中の点に着いたら、次の点へ進む（v5b）。
        /// 戻り値 true なら、まだ区間の途中（卓にも受け渡し口にも着いていない）。
        /// </summary>
        bool AdvanceWaypoint()
        {
            if (_legPoints == null || _legIndex + 1 >= _legPoints.Count) return false;
            _legIndex++;
            SetDestination(_legPoints[_legIndex], true);
            return true;
        }

        /// <summary>その卓へ行く道順（受け渡し口から / 直前の卓から）</summary>
        List<Vector3> RouteTo(int fromTableId, int toTableId, out bool narrow)
        {
            narrow = false;
            if (_routes == null) return null;
            RouteBook.Route used;
            List<Vector3> pts;
            if (fromTableId <= 0)
            {
                pts = _routes.PointsFromPass(toTableId, out used);
            }
            else
            {
                bool reversed;
                pts = _routes.PointsBetween(fromTableId, toTableId, out used, out reversed);
            }
            if (used != null) narrow = used.IsNarrow;
            return pts;
        }

        /// <summary>卓 → 受け渡し口の道順</summary>
        List<Vector3> RouteToPass(int fromTableId, out bool narrow)
        {
            narrow = false;
            if (_routes == null) return null;
            RouteBook.Route used;
            List<Vector3> pts = _routes.PointsToPass(fromTableId, out used);
            if (used != null) narrow = used.IsNarrow;
            return pts;
        }

        void SetDestination(Vector3 point) { SetDestination(point, false); }

        void SetDestination(Vector3 point, bool force)
        {
            // 引き直しの間隔を空ける。いまの経路は消さない
            if (_repathCooldown > 0f && !force)
            {
                _destination = point;
                return;
            }
            _repathCooldown = spec.repathRetrySec;
            if (_path == null) _path = new NavMeshPath();

            _destination = point;
            _pathValid = false;
            _corners = new Vector3[0];
            _cornerIndex = 0;

            // v5b: 道順の最後の点（止まる位置そのもの）だけは寄せない（0.1 m 以下）。
            // 途中の点・通路の入口・道順を使わないときは、今までどおり広めに寄せる。
            bool lastRoutePoint =
                _legPoints != null && _legIndex == _legPoints.Count - 1 &&
                Vector3.Distance(point, _legPoints[_legIndex]) < 0.001f;
            float snap = lastRoutePoint
                ? Mathf.Min(spec.routePointSnapM, spec.Radius * 4f)
                : spec.Radius * 4f;
            SolvePath(point, RobotFilter, snap);
        }

        /// <summary>
        /// 行き先まで道を引き、_path・_corners に入れる。
        /// snap は、点を焼いた面へ寄せるときに探す半径（m）。
        /// 止まる位置がその半径で面に乗らなければ、広げて引き直し、回数を数える（v5b）。
        /// </summary>
        void SolvePath(Vector3 point, NavMeshQueryFilter filter, float snap)
        {
            if (_path == null) _path = new NavMeshPath();

            NavMeshHit hit;
            Vector3 from = transform.position;
            if (NavMesh.SamplePosition(from, out hit, spec.Radius * 4f, filter)) from = hit.position;

            Vector3 to = point;
            if (NavMesh.SamplePosition(to, out hit, snap, filter)) to = hit.position;
            else if (snap < spec.Radius * 4f)
            {
                _snapFallbacks++;
                if (NavMesh.SamplePosition(to, out hit, spec.Radius * 4f, filter)) to = hit.position;
            }

            if (!NavMesh.CalculatePath(from, to, filter, _path) || _path.corners.Length == 0)
                return;

            bool complete = _path.status == NavMeshPathStatus.PathComplete;

            _destinationOnMesh = to;
            _hasDestinationOnMesh = true;
            _corners = _path.corners;
            _cornerIndex = 0;
            _pathValid = complete;
            if (!complete && _corners.Length > 0)
            {
                _pathEndPoint = _corners[_corners.Length - 1];
                _hasPathEnd = true;
            }
            else _hasPathEnd = false;

            // 足もとの角は飛ばす
            Vector3 here = transform.position; here.y = 0f;
            while (_cornerIndex < _corners.Length)
            {
                Vector3 c = _corners[_cornerIndex]; c.y = 0f;
                if (Vector3.Distance(here, c) > spec.cornerReachDistance) break;
                _cornerIndex++;
            }
        }

        void FaceAndMove(float dt)
        {
            if (_repathCooldown > 0f) _repathCooldown -= dt;

            if (_cornerIndex >= _corners.Length)
            {
                if (_repathCooldown <= 0f) SetDestination(_destination);
                return;
            }

            Vector3 here = transform.position;
            Vector3 corner = _corners[_cornerIndex];
            Vector3 to = corner - here; to.y = 0f;
            float d = to.magnitude;

            while (d <= spec.cornerReachDistance && _cornerIndex < _corners.Length - 1)
            {
                _cornerIndex++;
                corner = _corners[_cornerIndex];
                to = corner - here; to.y = 0f;
                d = to.magnitude;
            }

            if (d > 0.0001f)
            {
                Vector3 dir = to / d;
                // 向きを変える
                Quaternion want = Quaternion.LookRotation(dir, Vector3.up);
                transform.rotation = Quaternion.RotateTowards(transform.rotation, want, spec.turnSpeedDegPerSec * dt);

                float step = Mathf.Min(_speed * dt, d);
                if (step > 0f)
                {
                    transform.position = here + dir * step;
                    DistanceM += step;
                    LastStepM = step;
                }
            }

            if (d <= spec.cornerReachDistance && _cornerIndex >= _corners.Length - 1) _cornerIndex = _corners.Length;
        }

        void SyncAgent()
        {
            if (_agent == null || !_agent.enabled) return;
            if (!_agent.isOnNavMesh)
            {
                _agent.Warp(transform.position);
                return;
            }
            _agent.nextPosition = transform.position;
        }

        void UpdateClearance()
        {
            float myR = spec.Radius;
            Vector3 me = transform.position; me.y = 0f;
            IReadOnlyList<PersonBody> all = PersonBody.All;
            for (int i = 0; i < all.Count; i++)
            {
                PersonBody p = all[i];
                if (p == null || !p.walksInAisle) continue;
                Vector3 q = p.transform.position; q.y = 0f;
                float gap = Vector3.Distance(me, q) - myR - p.radius;
                if (gap < MinClearanceM) MinClearanceM = gap;
            }
        }

        /// <summary>経路が最後までつながっているか（引き直しに失敗しているときは false）</summary>
        public bool PathComplete { get { return _pathValid; } }

        void OnDrawGizmosSelected()
        {
            if (!_hasAisleZone) return;
            Gizmos.color = _yieldHold ? new Color(1f, 0.8f, 0.1f, 0.6f) : new Color(0.2f, 0.9f, 0.5f, 0.35f);
            Gizmos.DrawWireCube(new Vector3(_aisleZone.center.x, 0.05f, _aisleZone.center.z),
                                new Vector3(_aisleZone.size.x, 0.1f, _aisleZone.size.z));
            Gizmos.DrawWireSphere(_aisleEntrance + Vector3.up * 0.05f, 0.25f);
        }
    }
}
```
