# サービスロボット導入シミュレーション Log#48 | 配膳ロボットの店を、ブラウザで試せる形に

- 記録: LOG506（サービスロボット導入シミュレーション）
- 公開: 2026/11/14
- 記事のページ: https://pixelstomotors.com/record/serving-robot-store-sim-in-the-browser
- サイト: Pixels to Motors（ADAPT DESIGN / 島津 栄一） https://pixelstomotors.com/
- 要約: 配膳ロボットの導入シミュレーションは、Unity を入れなくてもブラウザだけで動かせた。サイトの店のデータを three.js で描き、人とロボットを動かして数字も出す試作を作った。台数を増やすと詰まりが増え、決まりの直しは途中。

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

---

配膳ロボットの導入シミュレーションは、Unity を入れなくても、ブラウザだけで人とロボットを動かして数字まで出せた。サイトに置いてあるファミレスの店のデータ（卓・壁・通路のマス目）を [three.js](https://threejs.org/) で3Dに描き、ロボットの台数・スタッフの人数・混み具合のボタンを押すと、その場で店の1日が回り直す試作を作った。**ただし、いまのブラウザ版はロボットを増やすほど通路での詰まりが増え、3台・混んだ日には料理がほとんど届かなくなる日が出た。数字はまだ試算・Unity と並べられる段階ではない。**

この試作でできるようになったことです。

- 見る人が、自分で条件を変えて動きを見られる。ロボット0〜6台・スタッフ2〜4人・混み具合3段・6店を選ぶと、ロボットとスタッフと客の動きと、入店待ち・会計待ち・料理が届くまでの時間が変わる
- 1日の中の好きな時刻へ、スライダーで戻ったり進んだりできる。同じ「日」なら何度見ても同じ動きになるので、詰まった場面を何度でも見直せる
- 3Dの絵だけでなく、ロボットとスタッフが「いま何をしているか」（料理を運んでいる・皿を下げている・卓で説明している）をパネルで読める。ロボットが立ち往生すると、その頭の上に問い合わせへの吹き出しが出る
- 重さ：ページは1ファイル 3.07MB（gzip で 0.97MB）。店のデータ6店分（2.3MB）を中に含む。サーバーの CPU では、ロボット2台の1日（店の時間で約3時間40分）を約20秒で回せた。Unity で同じ1日を回す時間とは比べていない
- テスト版として公開した。https://pixelstomotors.com/sim/serving-robot-3d で誰でも触れる（入口はサイトの TOP と /series8）

始まりは、エイイチが見つけた [ブラウザで動く水族館のシミュレーション](https://aquarium.ai-coordinator.co.jp) でした。インストールなしで、ページを開くだけで魚の群れが3Dで泳ぐ。これが何で作られているかを開発者ツールで確かめると、three.js の r187 と WebGPU（ブラウザから GPU を直接使う新しい仕組み）で、1本の JavaScript にまとめたページでした。それを見たエイイチから、次の一言がありました。

> このやり方でブラウザ版を作ってみたいね。WEB画面上でパネルの数値や条件変更するとロボットや人の動きが変わるのがいいね。

このシリーズのシミュレーションは、これまで Unity で動かしていました。Unity の動きは動画にして見せるしかなく、見る人が「ロボットを1台減らしたら？」と思っても、その場では試せません。サイトの試算（計算だけの見積もり）は数字をすぐ返しますが、通路でロボットが止まる・人がよける、といった動きは見えません。ブラウザ版は、その間を埋めるものです。

| 何を | なぜ | どうなったか |
|---|---|---|
| 水族館のシミュレーションの作りを、開発者ツールで確かめた | ブラウザだけで3Dを動かすやり方を知るため | three.js r187・WebGPU・1本にまとめた JavaScript。魚や水は、絵や3Dモデルのファイルを読まずにコードで作っていた |
| ブラウザ版の決まりを、サイト・Unity・ブラウザで決めるの3つに分けた | 4つ目の「正」を作らないため | 店の形・条件・数字の数え方はサイト、通路での動きは Unity と同じ、残り7つをチャットで決めた |
| 道を引くマス目の細かさと、人の動きの速さを先に測った | ブラウザで重すぎないかを、作る前に知るため | 2.5cm刻みのままで1本約30ms。人30人の10分が約2.6秒。同じ種で2回回して位置が一致 |
| 試作を作り、ロボットのにらみ合いと会計の詰まりを直した | 動かすと、ロボット2台が向き合ったまま止まり、レジが空かなかった | 下がってゆずる・止まった台を道の邪魔として扱う・会計は席で待つ、などで1日が最後まで回るようになった（3台・混んだ日はまだ詰まる） |
| 画面を直した（見た目を Unity にそろえる・いまの仕事のパネル・時刻のスライダー・スマホ・問い合わせ・ロゴ） | 初めて見る人が、何が起きているか分かるように | 案を並べて選ぶ形で、4か所を決めた |
| ロボット0〜3台・2つの混み具合で、3日ずつ回した | 試作の数字がどこまで使えるかを見るため | 料理が届くまでは0台の約1.1分から3台の約3分へ延び、立ち往生が増えた |

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

配膳ロボットを自分の店に入れたらどうなるかは、このシリーズの試算をもとにした別のページで、いまも数字で試せます。ブラウザ版は、その数字の裏で人とロボットがどう動くかを見せる側として作っています。

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


## 作業ログ ― 水族館のシミュレーションは three.js r187 と WebGPU で作られていた

エイイチが共有した水族館のシミュレーションの画面録画を見て、何で作られているかを推し量り、そのあとブラウザの開発者ツールで確かめました。開発者ツールは、ブラウザに付いている、ページの中身を調べる道具です（Chrome なら右クリックの「検証」）。

- コンソール（開発者ツールの「Console」の欄）で `THREE.REVISION` と打つと `187` が返った。three.js の r187 を使っている
- ネットワーク（「Network」の欄）でページを読み直すと、読み込まれる JavaScript は1本で、名前の形から [Vite](https://vite.dev/)（JavaScript をまとめる道具）で1つにまとめたものだった
- 魚・水・岩の 3D モデルや画像のファイルは読み込まれていなかった。形と模様はコードの中で作っている
- 描画は WebGPU。WebGPU が使えないブラウザでは、three.js が WebGL2（前からある、ブラウザで3Dを描く仕組み）に切り替える

### three.js は、ブラウザで3Dを描くための JavaScript の道具

[three.js](https://threejs.org/) は、ブラウザの中で3Dの絵を描くための、無料で使える JavaScript の道具です。箱・円柱・光・カメラを置くと、それを画面に描いてくれます。この回のブラウザ版も three.js で描いています（r181。`npm` で入れた版）。

| この記事で使う言葉 | 意味 |
|---|---|
| WebGPU | ブラウザから GPU（絵を描くための部品）を直接使う新しい仕組み。three.js では `three/webgpu` の `WebGPURenderer` で使う |
| WebGL2 | 前からある、ブラウザで3Dを描く仕組み。WebGPU が使えないときに three.js が自動で切り替える |
| OrbitControls | three.js に付いている、マウスや指でカメラを回す・寄る仕組み |
| 1本にまとめる（バンドル） | 何十個もの JavaScript のファイルを、1つのファイルにまとめること。この回は [esbuild](https://esbuild.github.io/) を使った |

このプロジェクトでの使い方：店の卓・壁・ベンチは箱、ロボットは円柱、人はカプセル（体）と球（頭）で描きます。形と色は、Unity の ServiceSim のプレハブとマテリアルの値をそのまま読みました（後の節）。

### recast-navigation は、人が歩ける道を作って動かす道具

[recast-navigation-js](https://github.com/isaac-mason/recast-navigation-js) は、床の形から「人が歩ける面」（NavMesh）を作り、その上で何人もの人をぶつからないように動かす道具です。Unity の人の動き（NavMesh）と同じ、Recast という仕組みをブラウザで使えるようにしたものです（中身は WebAssembly）。

| この記事で使う言葉 | 意味 |
|---|---|
| NavMesh | 床のうち人が歩ける所を、三角形の面の集まりで表したもの。卓や壁の所は抜けている |
| Crowd | NavMesh の上で、何人もの人を、互いにぶつからないように動かす仕組み |
| WebAssembly | ブラウザの中で、C++ などで書いたプログラムを速く動かす仕組み |

このプロジェクトでの使い方：人（客・スタッフ）だけを NavMesh で動かし、ロボットは使いません。ロボットは、サイトの「走れるマス目」の上で自分で道を引きます。Unity でも人は NavMesh、ロボットはマス目と分けているので、同じ分け方にしました。


## 作業ログ ― ブラウザ版の決まりを、サイト・Unity・ブラウザで決めるの3つに分けた

作る前に、ブラウザ版がどの決まりで動くかを、サイトの試算のコード（`tools/serving_sim/haizen_sim.py`・試算 v2.9）と、Mac の ServiceSim の Unity のコード（読むだけ）から拾いました。このシリーズでは、店の形・条件・数字の数え方はサイトが正で、Unity は通路で起きることを動かして数える側です。ブラウザ版も Unity と同じ側に置き、サイトのデータをそのまま読みます。

決まりの一覧は、プロジェクトの文書（`claude/browser_sim_rules.md`）に残しました。下はその全文です。

```markdown
# ブラウザ版 配膳ロボットシミュレーション — 決まりの一覧（下書き v0）

作成：2026-11-13
読んだもの：サイト p2m-site の tools/serving_sim/haizen_sim.py（試算 v2.9）・assumptions.json・public/sim/serving-robot-params.json／Mac の ServiceSim の Assets/ServiceSim/（Robot・People・Service の Spec と主なコード。読むだけ）

ブラウザ版の位置付け（2026-11-13 決定）：題材は配膳ロボット。ブラウザの中で人とロボットを動かし、数字も出す。
「何について正か」は今までどおり。店の形・条件・回し方の決まり・数字の定義はサイトが正。ブラウザ版は Unity と同じく「通路で起きること」を動かして数える側。

---

## A. サイトが正（ブラウザ版はそのまま読む・まねる）

| 何 | 値・決まり | 出どころ |
|---|---|---|
| 店の形 | 6店（⓪〜⑤）＋大型店⑧⑨。版 9b8dae7ebf8c | public/sim/layouts/*.json |
| 走れるマス目 | 2.5cm刻み 960×640（③）。2＝ふだんの幅（1.0m）が通れる／1＝通れる幅（0.8m）だけ／0＝通れない | layouts/unity/*_map.json |
| パネルで選べる条件 | 店・スタッフ人数・ロボット 0〜6台・来客のペース（混み具合）・使い方（上げ膳だけ／下げ膳にも／回って集める）・1往復の卓数（1／3）・割れ物を誰が運ぶか・レジ 1／2 | assumptions.json の choices |
| 時間の区切り | 空の店から始め、ピーク120分だけ来客、全員帰るまで回す | params の time_window |
| スタッフの仕事の順 | 0＝ロボットの救出 → 1＝案内・配膳・呼び出し・会計 → 2＝片付け | haizen_sim.py staff_job |
| 料理の運び方 | ロボット優先。2分待ってもロボットが空かなければ、先に空いた方が運ぶ | v2.7 |
| 会計 | レジ1つにつき1組ずつ・90秒。頼みはレジに並んだ時刻の古さで並ぶ | v2.8〜v2.9 |
| 下げ膳にも使う | スタッフが片付けに向かうとき空いているロボットが一緒に行き、載せ終えるまで卓で待ち、洗い場へ | v2.4 |
| 作業の時間 | 案内45秒・呼び出し30秒・片付け40秒・卓拭き40秒・載せ下ろし15秒 など | assumptions.json common |
| 数字の定義 | 入店待ち（来てから卓に着くまで）・うち満席待ち・うち人手待ち（10分以上で「店が回らない」）・料理が届くまで・会計待ち（10分以上で「レジが詰まる」）・歩いた距離・手がふさがる割合 ほか | params の metrics |

## B. Unity だけの値・決まり（ブラウザ版も同じ動きをまねる）

| 何 | Unity の値 | メモ |
|---|---|---|
| ロボットの体 | 直径0.5m・最高0.6m/秒・加減速1.0 | 試算は0.5m/秒（決めたとおり、そのまま） |
| 前のセンサー | 0.6m先・幅0.5m。空いて1秒で動き出す | |
| 道の引き方 | 地図（マス目）の上で自分で道を引く。NavMesh は使わない。真ん中寄りを走る・狭いマスは割高 | **人は NavMesh、ロボットはマス目**と分かれている |
| 回り込み | 前がふさがって3秒空かなければ回り込む道を探す（長さ2倍まで） | |
| 進めているかの見張り | 10秒で0.5m離れられなければ止まる（Nav2 の既定） | |
| 立ち往生 | **30秒**動けなければスタッフが来て動かす（救出15秒）。瞬間移動はしない | 2026-11-13 確定。Unity の RobotSpec の既定値は60秒（stuckAfterStoppedSec）なので、Unity 側は別途確かめる |
| 通路をふさぐロボットを人が動かす | **5秒**止まったら**7m以内**の人が動かす。料理・皿を持つ人は置いてから／すり抜けられれば動かさない／客は触らない | 2026-11-06 の決まり。2026-11-13 ブラウザ版に入れると確定（Unity のコードでは未確認） |
| ロボットどうし | 料理を載せた台が先・空の台がよける。すれ違いは 入（2m手前から0.3m/秒で右へ寄る） | 標準 |
| 待機場所へ戻る | 往復が終わったら待機場所へ戻る | 標準 入 |
| 案B（受け渡し口は1台ずつ・同じ卓は1台） | 作りは残し、標準は 切 | |
| 譲る順番 | ロボット ＞ スタッフ ＞ 客 | |
| 人の先よけ | 広い通路で4m手前から端へ寄る。標準は 切・撮るときだけ 入 | |
| 卓での受け渡し | 卓で25秒 | |
| スタッフの待つ場所 | 手が空いたら1人はレジ（入口番）、ほかは持ち場 | 2026-10-31 決定 |
| 人どうしの詰まり | 1秒で体を細め、3秒で片方が下がる（客が下がる） | |
| 人の体 | 半径0.2m・スタッフ1.0m/秒・客0.8m/秒 | |
| 下げ膳で卓で待つ上限 | 2分 | Unity だけの値 |

## C. ブラウザ版で決める・確かめること

1. **一時停止・立ち往生を確率で入れるか、動かして数えるか**：試算は確率（pause_rate・stuck_rate）で入れている。ブラウザ版は Unity と同じく動かして数える側なので、確率は使わず、実際に起きた数を数える案。
2. **道を引く重さ**：Unity のマス目は2.5cm刻みで 61万マス。ブラウザで何台も引き直すと重いので、5cm刻み（15万マス）に粗くしても道が変わらないかを確かめる。
3. **人の動き**：Unity は人に NavMesh を使っている。ブラウザは recast-navigation-js（Unity の NavMesh と同じ Recast 系）で人を動かし、ロボットは自前のマス目で道を引く（Unity と同じ分け方）。
4. **同じ種で同じ結果になるか**：決めてある「合格の線」の1つ。ブラウザ・端末をまたいで同じになるかを小さな試作で確かめる。ならなければ、数を出す計算は固定の刻みで回し、見せる動きとは分ける。
5. **Unity にあって決まりとして確かめが要るもの**：
   - 客が60秒で家へ戻る逃がし弁（guestWarpHomeSec）：**ブラウザ版には入れない**（2026-11-13 確定。瞬間移動はさせない）。
   - 立ち往生の30秒・5秒7mの決まりが今の Unity に入っているかは、Unity 側で別途確かめる（ブラウザ版は決まりのほうで作る）。
6. **答え合わせ**：ブラウザの数字を、試算 v2.9 と Unity の基準データ v5 の両方と並べ、向きが合うかで見る。ブラウザを4つ目の正にしない。

## 次の一手

C-2〜C-4 を、③（効率重視・入口が角）・ロボット2台・スタッフ真ん中・混み具合 普通 の1条件だけの試作で確かめる（見た目は作り込まず、店の床・卓・人とロボットの点だけ）。

## 試作で確かめた結果（2026-11-13・③ 効率重視・入口が角）

試作のコード：gridcheck.mjs（道の長さ）・crowdcheck.mjs（人の動き）。Node 22 で回した（サーバーの CPU。ブラウザではまだ測っていない）。

| 確かめ | 結果 | 決め |
|---|---|---|
| C-2 道を引く重さ（受け渡し口→42卓） | 2.5cm刻み：サイトの道の長さとの差 平均0.01m・最大0.04m・1本30ms／5cm刻み：差 平均0.11m・最大0.17m・1本7ms／10cm刻み：差 平均0.20m・最大0.47m・1本1.4ms。どの刻みでも行けない卓は0 | 2.5cm刻みのままでよい（引き直しは0.5秒に1回までなので重くない）。重いときだけ5cmへ |
| C-3 人の動き（recast-navigation 0.44） | 店の床から NavMesh を作るのに71ms。30人が店の中を動いて、店の時間10分ぶんが2.6秒（約230倍の速さ） | 使える。ピーク2時間ぶんは約30秒かかるので、数字は画面の裏（Web Worker）で回し、画面は見せる速さで動かす |
| C-4 同じ種で同じ結果か | 同じ種で2回回して位置が完全に一致。違う種では違う結果 | 同じ環境ではOK。ブラウザや端末をまたいで同じになるかは、ブラウザで確かめる（NavMesh の計算は WebAssembly なので同じになる見込み。こちらで書く計算は Math.log など端末で値がずれうる関数を避ける） |
```

### 一覧の C で、エイイチが決めた3つ

一覧の C（ブラウザ版で決めること）のうち、次の3つはチャットでエイイチが決めました。

- 立ち往生と判定する秒数は30秒（Unity の RobotSpec の既定値は60秒）
- 「5秒止まったら7m以内の人がロボットを動かす」は、ブラウザ版に入れる
- 瞬間移動はさせない。Unity にある、客を60秒で家へ戻す逃がし弁（guestWarpHomeSec）はブラウザ版には入れない

- ねらい：詰まりを、確率や瞬間移動でごまかさずに、起きたまま数える
- 変わったこと：詰まった場面は、人が来て動かすか、下がってゆずるまで残る。立ち往生は数として出る
- 見込める効果：見る人が「どこで、なぜ詰まったか」を自分の目で見られる。その代わり、決まりの穴はそのまま数字に出る（後の節の3台の日）


## 作業ログ ― 道を引くマス目の細かさと、人の動きの速さを Node で測った

ブラウザで何台ものロボットが道を引き直すと重くなるかもしれない、人を何十人も動かすと遅いかもしれない、という2つを、画面を作る前にサーバーの CPU（Node 22）で測りました。店は③ 効率重視・入口が角（`p1_efficient_corner`）。測った値は、ブラウザではなくサーバーの CPU のものです。

### 道の長さと重さ（gridcheck.mjs）

受け渡し口から42卓すべてへの道を、マス目の細かさを変えて引き、サイトに載っている道の長さと比べました。

```js
import fs from 'fs'; import zlib from 'zlib';
const [mapP, layP] = process.argv.slice(2);
const M = JSON.parse(fs.readFileSync(mapP)); const L = JSON.parse(fs.readFileSync(layP));
const fc0 = zlib.gunzipSync(Buffer.from(M.free_class,'base64'));
const {nx,ny,res} = M;
function down(fc,nx,ny,f){ const X=Math.floor(nx/f),Y=Math.floor(ny/f),o=new Uint8Array(X*Y);
  for(let y=0;y<Y;y++)for(let x=0;x<X;x++){let m=2;for(let j=0;j<f;j++)for(let i=0;i<f;i++)m=Math.min(m,fc[(y*f+j)*nx+x*f+i]);o[y*X+x]=m;} return [o,X,Y]; }
// binary heap A*
function astar(fc,X,Y,r,s,g){
  const N=X*Y,G=new Float32Array(N).fill(Infinity),P=new Int32Array(N).fill(-1),closed=new Uint8Array(N);
  const h=(i)=>{const x=i%X,y=(i/X)|0,dx=Math.abs(x-g%X),dy=Math.abs(y-((g/X)|0));return (Math.max(dx,dy)+(Math.SQRT2-1)*Math.min(dx,dy))*r;};
  const heap=[],push=(f,i)=>{heap.push([f,i]);let k=heap.length-1;while(k>0){const p=(k-1)>>1;if(heap[p][0]<=heap[k][0])break;[heap[p],heap[k]]=[heap[k],heap[p]];k=p;}};
  const pop=()=>{const t=heap[0],l=heap.pop();if(heap.length){heap[0]=l;let k=0;for(;;){let a=2*k+1,b=a+1,m=k;if(a<heap.length&&heap[a][0]<heap[m][0])m=a;if(b<heap.length&&heap[b][0]<heap[m][0])m=b;if(m===k)break;[heap[m],heap[k]]=[heap[k],heap[m]];k=m;}}return t;};
  G[s]=0;push(h(s),s);let exp=0;
  const D=[[1,0,1],[-1,0,1],[0,1,1],[0,-1,1],[1,1,Math.SQRT2],[1,-1,Math.SQRT2],[-1,1,Math.SQRT2],[-1,-1,Math.SQRT2]];
  while(heap.length){const [,i]=pop();if(closed[i])continue;closed[i]=1;exp++;if(i===g)break;const x=i%X,y=(i/X)|0;
    for(const[dx,dy,c]of D){const a=x+dx,b=y+dy;if(a<0||b<0||a>=X||b>=Y)continue;const j=b*X+a;const v=fc[j];if(!v)continue;
      if(dx&&dy&&(!fc[y*X+a]||!fc[b*X+x]))continue;const w=v===2?1:3;const ng=G[i]+c*r*w;if(ng<G[j]){G[j]=ng;P[j]=i;push(ng+h(j),j);}}}
  let len=0,k=g;if(P[g]<0&&g!==s)return {len:NaN,exp};while(P[k]>=0){const p=P[k];len+=Math.hypot(k%X-p%X,((k/X)|0)-((p/X)|0))*r;k=p;}return {len,exp};}
const snap=(fc,X,Y,r,[px,py])=>{let best=-1,bd=1e9;const cx=Math.floor(px/r),cy=Math.floor(py/r);for(let d=0;d<40&&best<0;d++)for(let j=-d;j<=d;j++)for(let i=-d;i<=d;i++){const a=cx+i,b=cy+j;if(a<0||b<0||a>=X||b>=Y)continue;if(fc[b*X+a]){const dd=i*i+j*j;if(dd<bd){bd=dd;best=b*X+a;}}}return best;};
const pass=L.places.find(p=>p.kind==='FoodPass').point;
for(const f of [1,2,4]){
  const [fc,X,Y]=f===1?[fc0,nx,ny]:down(fc0,nx,ny,f);const r=res*f;let t0=performance.now(),rows=[],fail=0;
  for(const t of L.tables){if(!t.stop)continue;const s=snap(fc,X,Y,r,pass),g=snap(fc,X,Y,r,t.stop);const o=astar(fc,X,Y,r,s,g);const ref=L.routes.from_pass?.[t.id]?.grid_length;if(isNaN(o.len))fail++;rows.push([t.id,o.len,ref,o.exp]);}
  const ms=performance.now()-t0;const diffs=rows.filter(r=>r[2]&&!isNaN(r[1])).map(r=>Math.abs(r[1]-r[2]));
  console.log(`cell ${(r*100).toFixed(1)}cm grid ${X}x${Y}: ${rows.length} routes, fail ${fail}, ${(ms/rows.length).toFixed(1)} ms/route, |len-site| mean ${(diffs.reduce((a,b)=>a+b,0)/diffs.length).toFixed(2)} m max ${Math.max(...diffs).toFixed(2)} m`);
}
```

記事を書くときに回し直した出力です（ベンチマークと同時に回したので、最初に測ったとき（1本30ms・5cmで7ms）より少し遅い）。

```text
cell 2.5cm grid 960x640: 42 routes, fail 0, 31.1 ms/route, |len-site| mean 0.01 m max 0.04 m
cell 5.0cm grid 480x320: 42 routes, fail 0, 5.8 ms/route, |len-site| mean 0.11 m max 0.17 m
cell 10.0cm grid 240x160: 42 routes, fail 0, 1.1 ms/route, |len-site| mean 0.20 m max 0.47 m
```

どの細かさでも行けない卓は0。2.5cm刻みでもサイトの道の長さとの差は平均0.01mで、1本約30ms。道の引き直しは0.5秒に1回までにしているので、Unity と同じ2.5cm刻みのままにしました。

### 人の動きの速さと、同じ種で同じになるか（crowdcheck.mjs）

```js
import fs from 'fs'; import zlib from 'zlib'; import crypto from 'crypto';
import { init, Crowd } from 'recast-navigation'; import { generateSoloNavMesh } from '@recast-navigation/generators';
const [mapP, layP] = process.argv.slice(2);
const M=JSON.parse(fs.readFileSync(mapP)),L=JSON.parse(fs.readFileSync(layP));
const fc=zlib.gunzipSync(Buffer.from(M.free_class,'base64'));const f=4,X=M.nx/f,Y=M.ny/f,r=M.res*f;
const pos=[],idx=[];
for(let y=0;y<Y;y++){let x=0;while(x<X){const ok=(xx)=>{for(let j=0;j<f;j++)for(let i=0;i<f;i++)if(!fc[(y*f+j)*M.nx+xx*f+i])return false;return true;};
  if(!ok(x)){x++;continue;}let e=x;while(e+1<X&&ok(e+1))e++;const b=pos.length/3;const x0=x*r,x1=(e+1)*r,z0=y*r,z1=(y+1)*r;
  pos.push(x0,0,z0,x1,0,z0,x1,0,z1,x0,0,z1);idx.push(b,b+2,b+1,b,b+3,b+2);x=e+1;}}
await init();
let t=performance.now();
const {success,navMesh}=generateSoloNavMesh(new Float32Array(pos),new Uint32Array(idx),{cs:0.05,ch:0.05,walkableRadius:2,walkableClimb:2,walkableHeight:30});
console.log('navmesh',success,(performance.now()-t).toFixed(0),'ms, tris',idx.length/3);
const stops=L.tables.filter(t=>t.stop).map(t=>t.stop);
function run(seed){
  const crowd=new Crowd(navMesh,{maxAgents:40,maxAgentRadius:0.3});let s=seed>>>0;const rnd=()=>{s=(Math.imul(s,1664525)+1013904223)>>>0;return s;};
  const ag=[];for(let i=0;i<30;i++){const p=stops[rnd()%stops.length];const a=crowd.addAgent({x:p[0],y:0,z:p[1]},{radius:0.2,height:1.7,maxSpeed:1.0,maxAcceleration:2.0});ag.push(a);}
  const steps=20*600;let t0=performance.now();
  for(let k=0;k<steps;k++){if(k%200===0)for(const a of ag){const p=stops[rnd()%stops.length];a.requestMoveTarget({x:p[0],y:0,z:p[1]});}crowd.update(1/20);}
  const ms=performance.now()-t0;const h=crypto.createHash('sha1');for(const a of ag){const p=a.position();h.update(new Float64Array([p.x,p.z]));}
  crowd.destroy();return {hash:h.digest('hex').slice(0,12),ms};}
const a=run(7),b=run(7),c=run(8);
console.log('seed7 run1',a.hash,'run2',b.hash,'same:',a.hash===b.hash,'| seed8',c.hash,`| 30 agents x 10 min sim-time: ${a.ms.toFixed(0)} ms`);
```

```text
navmesh true 106 ms, tris 952
seed7 run1 364991238f00 run2 364991238f00 same: true | seed8 e49b95b63825 | 30 agents x 10 min sim-time: 2589 ms
```

店の床から NavMesh を作るのに約0.1秒、30人が店の時間で10分動くのが約2.6秒（約230倍の速さ）。同じ種（seed7）で2回回すと、全員の位置から作った値（ハッシュ）が一致しました。ブラウザや端末をまたいで同じになるかは、まだ確かめていません。


## 作業ログ ― 試作を作った：人は NavMesh、ロボットはマス目で動かし、数える所と描く所を分けた

試作は2つのファイルに分けました。`sim.js` は動かして数える所で、画面のことを何も知りません。`main.js` は three.js で描く所で、`sim.js` が出す1秒ごとの記録を読んで、間をなめらかにつないで描きます。

- 1コマは0.05秒で固定。ロボットと人の動き・来客・料理・会計は、すべてこの刻みで進む
- 乱数は種（日）から作る。来客の時刻・組の人数・料理ができる時間・食べる時間は、ロボットとは別の乱数にした。ロボットの台数を変えても、同じ日なら同じお客さんが同じ時刻に来る
- 画面は、1コマ描くごとに約10ms ぶん先を計算し、1秒ごとの記録を貯める。スライダーは、計算が済んだ所までなら好きな時刻へ動かせる

### ロボットの決まり（sim.js の RULES と、動きの順番）

- 前のセンサーは0.6m先・幅0.5m。残りの道より先は見ない（道の終わりの先にいる人で止まらない）
- 前がふさがって3秒たてば、回り込む道を探す
- 5秒止まったら、7m以内の手の空いたスタッフが動かしに来る
- 30秒動けなければ立ち往生。救出はスタッフの仕事の中でいちばん先（順番0）で、まだ持ち場から動き出していないスタッフの仕事を取り上げてでも行く
- ロボットどうしが向き合ったら、料理を載せた台が先。どちらも空なら番号の大きい台がゆずり、近くの空いた所まで下がる（瞬間移動はしない）
- 狭い通路（マス目の1）には、1台ずつしか入らない
- 往復が終わったら待機場所へ戻る
- 人はロボットにゆずる。スタッフはすぐ、客は1秒たってから
- 卓や待機場所の近くまで来て、30秒進めなければ、着いたことにする

### 店の回し方（サイトの値）

- 来客は、標準ファミレス35卓で 25・35・45 組/時（空いている・普通・混んでいる）を、卓の数に合わせて増やす。③は42卓なので、普通で42組/時、混んでいるで54組/時。ピークの120分だけ来る
- 1組の人数は 1〜4人を .20・.35・.20・.25 の割合で（Unity の ServiceSpec）
- 来たら待合で待ち、スタッフが卓へ案内する（45秒）
- 料理は2皿。1皿目は5〜12分（いちばん多いのは8分）、2皿目は8〜18分（12分）でできる
- 料理はロボットが先。2分待ってもロボットが来なければ、スタッフが運ぶ
- 食べる時間は20〜45分（30分）
- 会計は、レジが空くまで席で待ち、空いたらレジへ行って90秒
- 帰ったあと、片付け40秒と卓拭き40秒。皿は受け渡し口へ運ぶ

### 動かすと出た詰まりと、直したこと

試作を動かすと、決まりどおりに書いたつもりでも、いくつも詰まりました。直した順です。

| 起きたこと | 原因 | 直したこと |
|---|---|---|
| ロボット2台が向き合ったまま、横へよけ合って動かない | どちらも同じ側へよけ、また向き合う | ゆずる側を決め（料理を載せた台が先・空なら番号の大きい台）、ゆずる側は近くの空いた所まで下がる |
| 下がった台の前に、また相手が来る | 下がる距離が足りない | ゆずった台に、もっと離れるよう頼み直す |
| 止まった台を、ほかの台の道が通る | 道を引くとき、止まっている台を見ていなかった | 止まっている台を、道を引くときの邪魔として扱う |
| 道の終わりの先にいる人で止まる | センサーが、残りの道より先まで見ていた | センサーの長さを、残りの道までに切る |
| 立ち往生しても、スタッフが来ない | スタッフが別の仕事を始めていた | 救出をいちばん先にし、まだ動き出していないスタッフの仕事は取り上げる |
| 誰も会計を済ませない | レジの後ろの立ち位置が、カウンターの中（歩けない所）にあった | レジの後ろの歩ける部屋に置き直した |
| トイレ・ドリンクバーへ立った客が動かない | 席の点（NavMesh の外）から歩き出させていた | 卓の前の立ち位置から歩き出させる |
| レジの前に客がたまり、通路をふさぐ | 会計に立つ客を、空くのを待たずにレジへ向かわせていた | レジが空くまで席で待つ（サイトの試算 v2.8 と同じ） |
| 卓の近くで、着かずに回り続ける | 着いたかの判定が厳しすぎた | 30秒進めなければ、着いたことにする |

それでも、ロボットが2台以上で混んだ日には、まだ詰まりが残ります（後の節の数字）。

### 動かして数える所（src/sim.js）

```js
// ブラウザ版 配膳ロボットシミュレーション — 動きと数えるところ（画面とは分ける）
// 決まりの一覧：Project の claude/browser_sim_rules.md
// 店の形・マス目はサイト（public/sim/layouts/unity/）のものをそのまま読む。
import { init as recastInit, Crowd } from 'recast-navigation';
import { generateSoloNavMesh } from '@recast-navigation/generators';

export const DT = 0.05; // 1コマ 0.05 秒（固定）

// ---- サイト・Unity の値（決まりの一覧 A・B）----
export const RULES = {
  robotRadius: 0.25, robotMaxSpeed: 0.6, robotAccel: 1.0,
  sensorRange: 0.6, sensorHalfWidth: 0.25, resumeAfterClear: 1.0,
  detourAfter: 3.0,            // 前がふさがって3秒で回り込む道を探す
  helpAfter: 5.0, helpRadius: 7.0, // 5秒止まったら7m以内の人が動かしに来る
  stuckAfter: 30.0,            // 30秒で立ち往生（スタッフが動かしに行く）
  rescueSec: 15.0,             // 救出15秒
  tableDwell: 25.0,            // 卓での受け渡し
  robotFirstWait: 120.0,       // ロボット優先。2分待っても来なければ人が運ぶ
  staffSpeed: 1.0, guestSpeed: 0.8, personRadius: 0.2,
};

// 混み具合 = 来客のペース（サイトの試算：標準ファミレス35卓で 25・35・45 組/時。卓の数に合わせて増減）
export const CROWD = {
  empty:  { arrivalsPerH35: 25, label: '空いている' },
  normal: { arrivalsPerH35: 35, label: '普通' },
  busy:   { arrivalsPerH35: 45, label: '混んでいる' },
};
// サイトの試算・Unity の値（決まりの一覧 A・B）
export const STORE = {
  peakMin: 120, guideSec: 45, paySec: 90, clearSec: 40, wipeSec: 40,
  firstDishes: [[5, 8, 12], [8, 12, 18]], eatMin: [20, 30, 45],
  groupSizeWeights: [0.20, 0.35, 0.20, 0.25],   // Unity の ServiceSpec（1〜4人）
  walkersPerSeatedPerHour: 0.4,                   // トイレ・ドリンクバーへ立つ（仮の値）
};

function tri(rng, [a, m, b]) { const u = rng(), c = (m - a) / (b - a); return u < c ? a + Math.sqrt(u * (b - a) * (m - a)) : b - Math.sqrt((1 - u) * (b - a) * (b - m)); }
function mulberry32(a) { return function () { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }

// ---------------- マス目と道（Unity と同じ：ロボットは NavMesh を使わずマス目で道を引く）----------------
export class Grid {
  constructor(map, fc) { this.nx = map.nx; this.ny = map.ny; this.res = map.res; this.fc = fc; this.tmp = new Uint8Array(fc.length); }
  idx(x, y) { return Math.floor(y / this.res) * this.nx + Math.floor(x / this.res); }
  ok(x, y) { const i = Math.floor(x / this.res), j = Math.floor(y / this.res); return i >= 0 && j >= 0 && i < this.nx && j < this.ny && this.fc[j * this.nx + i] > 0; }
  snap(fc, x, y) {
    const cx = Math.floor(x / this.res), cy = Math.floor(y / this.res);
    for (let d = 0; d < 60; d++) { let best = -1, bd = 1e9;
      for (let j = -d; j <= d; j++) for (let i = -d; i <= d; i++) { if (Math.max(Math.abs(i), Math.abs(j)) !== d) continue; const a = cx + i, b = cy + j;
        if (a < 0 || b < 0 || a >= this.nx || b >= this.ny) continue; if (fc[b * this.nx + a]) { const dd = i * i + j * j; if (dd < bd) { bd = dd; best = b * this.nx + a; } } }
      if (best >= 0) return best; }
    return -1;
  }
  // blockers: [{x,y,r}] 回り込むときに避ける人・ロボット
  plan(sx, sy, gx, gy, blockers = []) {
    const { nx, ny, res } = this; let fc = this.fc;
    if (blockers.length) { this.tmp.set(this.fc); fc = this.tmp;
      for (const b of blockers) { const R = Math.ceil((b.r) / res); const cx = Math.floor(b.x / res), cy = Math.floor(b.y / res);
        for (let j = -R; j <= R; j++) for (let i = -R; i <= R; i++) { if (i * i + j * j > R * R) continue; const a = cx + i, c = cy + j; if (a >= 0 && c >= 0 && a < nx && c < ny) fc[c * nx + a] = 0; } } }
    const s = this.snap(fc, sx, sy), g = this.snap(fc, gx, gy); if (s < 0 || g < 0) return null;
    const N = nx * ny, G = this._G || (this._G = new Float32Array(N)), P = this._P || (this._P = new Int32Array(N)), C = this._C || (this._C = new Uint8Array(N));
    G.fill(Infinity); P.fill(-1); C.fill(0);
    const gxI = g % nx, gyI = (g / nx) | 0;
    const h = (i) => { const dx = Math.abs(i % nx - gxI), dy = Math.abs(((i / nx) | 0) - gyI); return (Math.max(dx, dy) + 0.41421356 * Math.min(dx, dy)) * res; };
    const heap = []; const push = (f, i) => { heap.push(f, i); let k = heap.length / 2 - 1; while (k > 0) { const p = (k - 1) >> 1; if (heap[2 * p] <= heap[2 * k]) break; [heap[2 * p], heap[2 * k]] = [heap[2 * k], heap[2 * p]]; [heap[2 * p + 1], heap[2 * k + 1]] = [heap[2 * k + 1], heap[2 * p + 1]]; k = p; } };
    const pop = () => { const i = heap[1]; const lf = heap.pop(), lf0 = heap.pop(); if (heap.length) { heap[0] = lf0; heap[1] = lf; let k = 0, n = heap.length / 2; for (;;) { const a = 2 * k + 1, b = a + 1; let m = k; if (a < n && heap[2 * a] < heap[2 * m]) m = a; if (b < n && heap[2 * b] < heap[2 * m]) m = b; if (m === k) break; [heap[2 * m], heap[2 * k]] = [heap[2 * k], heap[2 * m]]; [heap[2 * m + 1], heap[2 * k + 1]] = [heap[2 * k + 1], heap[2 * m + 1]]; k = m; } } return i; };
    const D = [[1, 0, 1], [-1, 0, 1], [0, 1, 1], [0, -1, 1], [1, 1, 1.41421356], [1, -1, 1.41421356], [-1, 1, 1.41421356], [-1, -1, 1.41421356]];
    G[s] = 0; push(h(s), s); let found = false, exp = 0;
    while (heap.length && exp < 1500000) { const i = pop(); if (C[i]) continue; C[i] = 1; exp++; if (i === g) { found = true; break; }
      const x = i % nx, y = (i / nx) | 0;
      for (const [dx, dy, c] of D) { const a = x + dx, b = y + dy; if (a < 0 || b < 0 || a >= nx || b >= ny) continue; const j = b * nx + a, v = fc[j]; if (!v) continue;
        if (dx && dy && (!fc[y * nx + a] || !fc[b * nx + x])) continue; const ng = G[i] + c * res * (v === 2 ? 1 : 3); if (ng < G[j]) { G[j] = ng; P[j] = i; push(ng + h(j), j); } } }
    if (!found) return null;
    const cells = []; for (let k = g; k >= 0; k = P[k]) cells.push(k); cells.reverse();
    // 見通しが通る所は1本の線にまとめる（マス目の折れを消す）
    const pt = (k) => [(k % nx + 0.5) * res, (((k / nx) | 0) + 0.5) * res];
    const los = (a, b) => { const [x0, y0] = pt(a), [x1, y1] = pt(b); const lo = Math.min(fc[a], fc[b]); const n = Math.ceil(Math.hypot(x1 - x0, y1 - y0) / (res * 0.5));
      for (let t = 1; t < n; t++) { const x = x0 + (x1 - x0) * t / n, y = y0 + (y1 - y0) * t / n; const v = fc[Math.floor(y / res) * nx + Math.floor(x / res)]; if (v < lo || !v) return false; } return true; };
    const out = [pt(cells[0])]; let a = 0;
    while (a < cells.length - 1) { let c = a + 1; while (c + 1 < cells.length && los(cells[a], cells[c + 1])) c++; out.push(pt(cells[c])); a = c; }
    let len = 0; for (let k = 1; k < out.length; k++) len += Math.hypot(out[k][0] - out[k - 1][0], out[k][1] - out[k - 1][1]);
    return { points: out, length: len };
  }
}

export async function decodeMap(map) {
  const bin = Uint8Array.from(atob(map.free_class), c => c.charCodeAt(0));
  let buf;
  if (typeof DecompressionStream !== 'undefined') { const ds = new Blob([bin]).stream().pipeThrough(new DecompressionStream('gzip')); buf = new Uint8Array(await new Response(ds).arrayBuffer()); }
  else { const zlib = await import(/* @vite-ignore */ 'node:' + 'zlib'); buf = new Uint8Array(zlib.gunzipSync(bin)); }
  return buf;
}

// ---------------- シミュレーション本体 ----------------
export class Sim {
  constructor(layout, grid, opts) {
    this.L = layout; this.grid = grid; this.o = { robots: 2, staff: 3, crowd: 'normal', seed: 1, ...opts };
    this.rng = mulberry32(this.o.seed); this.rngG = mulberry32(this.o.seed * 7919 + 17); /* 来客・人数・料理・食事の時間は別の乱数（台数を変えても同じ客が来る） */ this.t = 0; this.events = [];
    this.c = { delivered: 0, byRobot: 0, byStaff: 0, sensorStops: 0, detours: 0, helps: 0, stucks: 0, tripSum: 0, trips: 0, dishWaitSum: 0 };
    const P = (k) => layout.places.filter(p => p.kind === k);
    this.pass = P('FoodPass')[0].point; this.standby = P('RobotStandby').map(p => p.point); this.posts = P('StaffPost').map(p => p.point);
    this.toilet = P('Toilet')[0].point; this.drink = P('DrinkBar')[0].point;
    this.tables = layout.tables.filter(t => t.stop);
    this.entrance = P('Entrance')[0].point; this.waitPt = P('Waiting')[0].point; this.register = P('Register')[0].point; const rb = layout.rooms.find(r => r.walkable && r.rect[0] >= this.register[0]); this.regBack = rb ? [(rb.rect[0] + rb.rect[2]) / 2, this.register[1]] : [this.register[0] + 1.5, this.register[1]];
    this.tableState = new Map(this.tables.map(t => [t.id, { t, state: 'free', group: null }])); // free / taken / dirty / clearing
    this.seated = []; this.groups = []; this.waiting = []; this.payQueue = []; this.jobs = [];
    this.queue = []; // 受け渡し口で待っている料理
    this.arrPerH = CROWD[this.o.crowd].arrivalsPerH35 * this.tables.length / 35;
    this.nextArrival = this._expG(3600 / this.arrPerH); this.gid = 0;
    this.c.groups = 0; this.c.seatWaitSum = 0; this.c.seatedGroups = 0; this.c.payWaitSum = 0; this.c.paid = 0; this.c.left = 0;
  }
  _expG(mean) { return -Math.log(1 - this.rngG()) * mean; }
  _pickSize() { let r = this.rngG(), w = STORE.groupSizeWeights; for (let i = 0; i < w.length; i++) { r -= w[i]; if (r <= 0) return i + 1; } return w.length; }
  _freeTableFor(size) { let best = null; for (const ts of this.tableState.values()) if (ts.state === 'free' && ts.t.seats >= size && (!best || ts.t.seats < best.t.seats)) best = ts; return best; }
  _post(job) { job.posted = this.t; this.jobs.push(job); }
  async setup(navMesh) {
    this.crowd = new Crowd(navMesh, { maxAgents: 64, maxAgentRadius: 0.4 });
    this.robots = []; for (let i = 0; i < this.o.robots; i++) { const p = this.standby[i % this.standby.length];
      const ag = this.crowd.addAgent({ x: p[0], y: 0, z: p[1] }, { radius: RULES.robotRadius, height: 1.2, maxSpeed: RULES.robotMaxSpeed, maxAcceleration: 1, updateFlags: 0, separationWeight: 0 });
      this.robots.push({ id: i + 1, x: p[0], y: p[1], h: Math.PI, v: 0, state: 'idle', home: p, path: null, wp: 0, stopT: 0, clearT: 0, dish: null, tripStart: 0, dwell: 0, ag, helped: false, stuckCounted: false, helper: null, flag: '' }); }
    this.people = [];
    for (let i = 0; i < this.o.staff; i++) { const p = this.posts[i % this.posts.length]; const st = this._addPerson('staff', p, RULES.staffSpeed); st.sid = i + 1; this.people.push(st); }
  }
  _addPerson(kind, p, speed) {
    const ag = this.crowd.addAgent({ x: p[0], y: 0, z: p[1] }, { radius: RULES.personRadius, height: 1.7, maxSpeed: speed, maxAcceleration: 2.0, separationWeight: 2 });
    return { uid: (this._uid = (this._uid || 0) + 1), kind, ag, x: p[0], y: p[1], job: null, home: p, busyUntil: 0, carrying: false };
  }
  log(msg) { this.events.push({ t: this.t, msg }); (this.allEvents ||= []).push({ t: this.t, msg }); if (this.events.length > 40) this.events.shift(); }

  step() {
    const t = (this.t += DT), R = RULES, rng = this.rng;
    // 来客（ピークの2時間だけ。そのあとは全員が帰るまで回す）
    if (t >= this.nextArrival && t < STORE.peakMin * 60) { const g = { id: ++this.gid, size: this._pickSize(), arrive: t, state: 'waiting', dishesLeft: 0, rr: mulberry32(Math.floor(this.rngG() * 4294967295)) }; this.groups.push(g); this.waiting.push(g); this.c.groups++; this.nextArrival = t + this._expG(3600 / this.arrPerH); }
    // 待合の先頭から、入れる卓があれば案内を頼む（スタッフの手が空いたら案内）
    for (const g of this.waiting) if (!g.guideJob) { const ts = this._freeTableFor(g.size); if (!ts) continue; ts.state = 'taken'; ts.group = g; g.table = ts; g.guideJob = { kind: 'guide', prio: 1, group: g }; this._post(g.guideJob); }
    for (const d of this.queue) if (!d.staffOk && t - d.ready >= R.robotFirstWait) d.staffOk = true; // 2分でスタッフも運べる
    // 料理ができる・食べ終わる・会計へ
    for (const g of this.groups) {
      if (g.state === 'seated') { for (const c of g.cooks) if (!c.done && t >= c.at) { c.done = true; this.queue.push({ table: g.table.t, group: g, ready: t, staffOk: this.robots.length === 0 }); } }
      if (g.state === 'eating' && t >= g.eatEnd) this._finishEating(g);
      if (g.state === 'waitPay' && this.payQueue[0] === g) this._stand(g);
    }
    // トイレ・ドリンクバーへ立つ客
    const sitting = this.seated.filter(x => !x.away); if (sitting.length && rng() < sitting.length * STORE.walkersPerSeatedPerHour / 3600 * DT) { const s = sitting[Math.floor(rng() * sitting.length)];
      s.away = true; const tb = this.tables.find(x => x.id === s.table); const g = this._addPerson('guest', tb ? tb.stop : [s.x, s.y], R.guestSpeed); const tgt = rng() < 0.5 ? this.toilet : this.drink; g.seat = s; g.job = { kind: 'walk', legs: [tgt, tb ? tb.stop : [s.x, s.y]], leg: 0, dwell: tgt === this.toilet ? 120 : 20 }; this._goto(g, tgt); this.people.push(g); }
    this._robots(t); this._yieldTick(t); this._staff(t); this._guests(t);
    this.crowd.update(DT);
    for (const p of this.people) { const q = p.ag.position(); p.x = q.x; p.y = q.z; }
  }
  _goto(p, pt) { const q = this.crowd.navMeshQuery.findClosestPoint({ x: pt[0], y: 0, z: pt[1] }); const np = q.point || { x: pt[0], y: 0, z: pt[1] }; p.ag.requestMoveTarget(np); p.tgt = pt; p.tgtNav = [np.x, np.z]; p.progT = 0; p.progAt = [p.x, p.y]; }
  // 着いたか：地図の上で行ける一番近い点で見る。30秒進めなければ（人ごみで詰まったら）その場で着いたことにする（Unity の staffGiveUpSec と同じ考え）
  _arrived(p, r = 0.35) { if (!p.tgt) return false; const q = p.tgtNav || p.tgt; if (Math.hypot(p.x - q[0], p.y - q[1]) < Math.max(r, 0.35)) return true;
    if (Math.hypot(p.x - p.progAt[0], p.y - p.progAt[1]) > 0.3) { p.progAt = [p.x, p.y]; p.progT = this.t; return false; }
    if (!p.progT) p.progT = this.t; if (this.t - p.progT > 30) { this.c.giveUps = (this.c.giveUps || 0) + 1; p.progT = this.t; return true; } return false; }


  _robots(t) {
    const R = RULES;
    for (const r of this.robots) {
      if (r.state === 'idle') { // 受け渡し口で待っている料理を取る
        const d = this.queue.find(q => !q.taken); if (d) { d.taken = r; r.dish = d; this.queue.splice(this.queue.indexOf(d), 1); r.state = 'toPass'; this._plan(r, this.pass); r.tripStart = t; }
        continue; }
      if (r.state === 'dwell') { if ((r.dwell -= DT) <= 0) { this._deliver(r.dish, t, true); r.dish = null; r.state = 'home'; this._plan(r, r.home); } continue; }
      if (r.state === 'rescued') { if ((r.dwell -= DT) <= 0) { r.state = r.resume; r.stopT = 0; r.helped = false; r.stuckCounted = false; r.helper = null; this._plan(r, r.goal, true); } continue; }
      if (r.state === 'pushed') { this._retreatTick(r, 0.3); if (r.state === 'pushed' && !r.pushPath.length && r.pushT > RULES.rescueSec) this._endRetreat(r); this._syncAgent(r); continue; }
      if (r.state === 'sidestep') { this._retreatTick(r, RULES.robotMaxSpeed * 0.5); this._syncAgent(r); continue; }
      // 走る
      if (this._aisleGate(r)) { r.v = 0; r.flag = 'wait'; this._syncAgent(r); continue; }
      const blocker = this._sensor(r);
      if (blocker) {
        if (r.stopT === 0) { this.c.sensorStops++; }
        if (blocker.who && blocker.who.ag && !blocker.who.yieldUntil) this._yield(blocker.who, r, t);
        if (blocker.robot && this._robotYields(r, blocker.robot) && this._retreat(r, blocker.robot, 'sidestep')) { this._syncAgent(r); continue; }
        // 道をゆずって止まっている台がまだ前にいるなら、その台にもっと離れてもらう
        if (blocker.robot && blocker.robot.state === 'sidestep' && !blocker.robot.pushPath.length && r.stopT > 2 && !(r.reaskAt > this.t)) { r.reaskAt = this.t + 5; this._retreat(blocker.robot, r, 'sidestep'); }
        r.stopT += DT; r.v = 0; r.clearT = 0; r.flag = 'stop'; r.blockedBy = blocker.robot ? `ロボット${blocker.robot.id}` : blocker.who ? (blocker.who.kind === 'staff' ? `スタッフ${blocker.who.sid}` : '客') : '人';
        if (r.stopT >= R.detourAfter && !r.detoured) { r.detoured = true; this.c.detours++; this._plan(r, r.goal, false, [{ x: blocker.x, y: blocker.y, r: (blocker.rr || R.personRadius) + R.robotRadius + 0.1 }]); }
        if (r.stopT >= R.helpAfter && !r.helped) { this._callHelp(r, R.helpRadius, 'help'); r.helpRetry = r.stopT + 10; }
        else if (r.helped && !r.helper && r.stopT >= r.helpRetry) { this._callHelp(r, r.stopT >= R.stuckAfter ? 999 : R.helpRadius, 'retry'); r.helpRetry = r.stopT + 10; }
        if (r.stopT >= R.stuckAfter && !r.stuckCounted) { r.stuckCounted = true; this.c.stucks++; this.log(`ロボット${r.id} が30秒動けず立ち往生`); if (!r.helper) this._callHelp(r, 999, 'stuck'); }
        this._syncAgent(r); continue;
      }
      if (r.stopT > 0) { r.clearT += DT; if (r.clearT < R.resumeAfterClear) { this._syncAgent(r); continue; } r.stopT = 0; r.detoured = false; r.flag = ''; }
      this._follow(r);
      if (!r.path || r.wp >= r.path.length) { // 着いた
        if (r.state === 'toPass') { r.state = 'toTable'; this._plan(r, r.dish.table.stop); }
        else if (r.state === 'toTable') { r.state = 'dwell'; r.dwell = R.tableDwell; r.v = 0; }
        else if (r.state === 'home') { r.state = 'idle'; r.v = 0; this.c.trips++; this.c.tripSum += t - r.tripStart; }
      }
      this._syncAgent(r);
    }
  }
  // スタッフが手で押して、来た道を1.5mほど戻す（0.3m/秒・瞬間移動なし）。救出15秒のうちに終える
  _pushTick(r) {
    r.pushT += DT; const w = r.pushPath && r.pushPath[0];
    if (w) { const dx = w[0] - r.x, dy = w[1] - r.y, d = Math.hypot(dx, dy); if (d < 0.05) r.pushPath.shift(); else { const st = Math.min(0.3 * DT, d); r.x += dx / d * st; r.y += dy / d * st; } }
    if (r.pushT >= RULES.rescueSec) { r.state = r.resume; r.stopT = 0; r.helped = false; r.stuckCounted = false; r.helper = null; r.detoured = false; r.flag = ''; r.trail = []; r.holdT = 4; this._plan(r, r.goal); }
  }
  // ロボットどうし：料理を載せた台が先、空の台がよける。同じなら番号の大きい台がよける（Unity の譲る順番）
  _robotYields(r, o) {
    if (o.state === 'pushed' || o.state === 'sidestep') return false;
    const moving = o.state === 'toPass' || o.state === 'toTable' || o.state === 'home';
    if (!moving) return false;                      // 止まっている台（待機・受け渡し中）は障害物として回り込む
    if (!!r.dish !== !!o.dish) return !r.dish;      // 空の方がよける
    return r.id > o.id;
  }
  // よける場所：相手のこの先4mの道から体2つ分離れた、2.5m以内の空いた場所（Unity の yieldSideSearchM 2.5）
  _yieldSpot(r, o) {
    const g = this.grid, segs = [];
    let px = o.x, py = o.y, acc = 0; if (o.path) for (let k = o.wp; k < o.path.length && acc < 4; k++) { const q = o.path[k]; segs.push([px, py, q[0], q[1]]); acc += Math.hypot(q[0] - px, q[1] - py); px = q[0]; py = q[1]; }
    else segs.push([o.x, o.y, o.x, o.y]);
    const dseg = (x, y, [x0, y0, x1, y1]) => { const dx = x1 - x0, dy = y1 - y0, L2 = dx * dx + dy * dy; const u = L2 ? Math.max(0, Math.min(1, ((x - x0) * dx + (y - y0) * dy) / L2)) : 0; return Math.hypot(x - x0 - u * dx, y - y0 - u * dy); };
    const clear = 2 * RULES.robotRadius + 0.15; let best = null, bd = 1e9;
    for (let j = -25; j <= 25; j++) for (let i = -25; i <= 25; i++) { const x = r.x + i * 0.1, y = r.y + j * 0.1, d = Math.hypot(i, j) * 0.1; if (d > 2.5 || d >= bd) continue;
      if (!g.ok(x, y) || !g.ok(x + 0.2, y) || !g.ok(x - 0.2, y) || !g.ok(x, y + 0.2) || !g.ok(x, y - 0.2)) continue;
      if (segs.some(sg => dseg(x, y, sg) < clear)) continue;
      if (o.goal && Math.hypot(o.goal[0] - x, o.goal[1] - y) < 1.0) continue; if (Math.hypot(this.pass[0] - x, this.pass[1] - y) < 0.9) continue; if (this.robots.some(q => q !== r && Math.hypot(q.x - x, q.y - y) < clear)) continue;
      best = [x, y]; bd = d; }
    return best;
  }
  _retreat(r, o, why) {
    const spot = this._yieldSpot(r, o); if (!spot) return false;
    const pl = this.grid.plan(r.x, r.y, spot[0], spot[1], [{ x: o.x, y: o.y, r: 2 * RULES.robotRadius }]); if (!pl) return false;
    r.resume = r.resume && (r.state === 'sidestep' || r.state === 'pushed') ? r.resume : r.state; r.state = why; r.pushT = 0; r.pushPath = pl.points.slice(1); r.waitFor = o; r.flag = why === 'pushed' ? 'help' : 'yield';
    if (why === 'sidestep') this.c.robotYields = (this.c.robotYields || 0) + 1; return true;
  }
  _sidestep(r, o) { if (!this._retreat(r, o, 'sidestep')) { r.stopT += 0; } }
  _endRetreat(r) { r.state = r.resume; r.resume = null; r.stopT = 0; r.flag = ''; r.trail = []; r.detoured = false; r.helped = false; r.stuckCounted = false; r.helper = null; r.waitFor = null; this._plan(r, r.goal); }
  _retreatTick(r, speed) {
    r.pushT += DT; const w = r.pushPath[0];
    if (w) { const dx = w[0] - r.x, dy = w[1] - r.y, d = Math.hypot(dx, dy); if (d < 0.05) r.pushPath.shift(); else { const st = Math.min(speed * DT, d); r.x += dx / d * st; r.y += dy / d * st; r.h = Math.atan2(dy, dx); } return; }
    const o = r.waitFor; const gone = !o || Math.hypot(o.x - r.x, o.y - r.y) > 1.5 || !(o.state === 'toPass' || o.state === 'toTable' || o.state === 'home');
    if ((gone && r.pushT > 2) || r.pushT > 25) this._endRetreat(r);
  }
  // 1本の細い通路に2台を入れない（Unity の oneRobotPerAisle）。通路の手前で待つ
  _aisleGate(r) {
    if (r.holdT > 0) { r.holdT -= DT; return true; }
    if (!r.path) return false; const A = this.L.aisles || [];
    const inA = (a, x, y) => x >= a.rect[0] - 0.05 && x <= a.rect[2] + 0.05 && y >= a.rect[1] - 0.05 && y <= a.rect[3] + 0.05;
    const mine = A.find(a => inA(a, r.x, r.y)); if (mine) return false;
    // この先 1.2m に入る通路
    const cx = Math.cos(r.h), cy = Math.sin(r.h); const ahead = A.find(a => a.narrow && inA(a, r.x + cx * 1.0, r.y + cy * 1.0)); if (!ahead) return false;
    return this.robots.some(o => o !== r && inA(ahead, o.x, o.y));
  }
  _plan(r, goal, force, blockers = []) { r.goal = goal;
    // 止まっている台（待機・受け渡し中）は動かない障害物として道を引く
    const still = this.robots.filter(o => o !== r && (o.state === 'idle' || o.state === 'dwell') && Math.hypot(o.x - goal[0], o.y - goal[1]) > 0.3).map(o => ({ x: o.x, y: o.y, r: 2 * RULES.robotRadius + 0.05 }));
    const p = this.grid.plan(r.x, r.y, goal[0], goal[1], blockers.concat(still)); r.path = p ? p.points : null; r.wp = 1; if (!p) r.path = [[r.x, r.y], goal]; }
  _follow(r) {
    const R = RULES; if (!r.path) return; const w = r.path[r.wp]; if (!w) return;
    const dx = w[0] - r.x, dy = w[1] - r.y, dist = Math.hypot(dx, dy);
    const remain = dist + this._restLen(r);
    const vmax = Math.min(R.robotMaxSpeed, Math.sqrt(2 * R.robotAccel * Math.max(remain - 0.02, 0)));
    r.v = Math.min(vmax, r.v + R.robotAccel * DT);
    const want = Math.atan2(dy, dx); let dh = want - r.h; while (dh > Math.PI) dh -= 2 * Math.PI; while (dh < -Math.PI) dh += 2 * Math.PI;
    const turn = Math.PI * DT; r.h += Math.max(-turn, Math.min(turn, dh)); if (Math.abs(dh) > 0.6) r.v = Math.min(r.v, 0.15);
    const step = Math.min(r.v * DT, dist); r.x += Math.cos(r.h) * step; r.y += Math.sin(r.h) * step;
    const last = r.trail && r.trail[r.trail.length - 1]; if (!last || Math.hypot(last[0] - r.x, last[1] - r.y) > 0.1) { (r.trail ||= []).push([r.x, r.y]); if (r.trail.length > 60) r.trail.shift(); }
    if (dist < 0.08) r.wp++;
  }
  _restLen(r) { let s = 0; for (let k = r.wp; k < r.path.length - 1; k++) s += Math.hypot(r.path[k + 1][0] - r.path[k][0], r.path[k + 1][1] - r.path[k][1]); return s; }
  _sensor(r) { // 前のセンサー：0.6m 先・幅0.5m に人かロボット
    const R = RULES, cx = Math.cos(r.h), cy = Math.sin(r.h);
    let rest = Infinity; if (r.path && r.path[r.wp]) rest = Math.hypot(r.path[r.wp][0] - r.x, r.path[r.wp][1] - r.y) + this._restLen(r);
    const reach = Math.min(R.robotRadius + R.sensorRange, rest + R.robotRadius); // 行き先より先のものでは止まらない
    const check = (x, y, rr) => { const dx = x - r.x, dy = y - r.y; const al = dx * cx + dy * cy, lat = Math.abs(-dx * cy + dy * cx); return al > 0 && al < reach + rr && lat < R.sensorHalfWidth + rr; };
    for (const p of this.people) if (check(p.x, p.y, R.personRadius)) return { x: p.x, y: p.y, rr: R.personRadius, who: p };
    for (const o of this.robots) if (o !== r && check(o.x, o.y, R.robotRadius)) return { x: o.x, y: o.y, rr: R.robotRadius, robot: o };
    return null;
  }
  _syncAgent(r) { r.ag.teleport({ x: r.x, y: 0, z: r.y }); } // 画面と人のよけのために位置を合わせるだけ（ロボットの瞬間移動ではない）
  // 人がロボットに道をゆずる（譲る順番：ロボット＞スタッフ＞客）。スタッフはすぐ、客は少し止まってから横へ寄る
  _yield(p, r, t) {
    if (p.job && (p.job.kind === 'help' || p.job.kind === 'holding') && p.job.robot === r) return;
    const delay = p.kind === 'staff' ? 0 : 1.0; const cx = Math.cos(r.h), cy = Math.sin(r.h);
    const side = (-(p.x - r.x) * cy + (p.y - r.y) * cx) >= 0 ? 1 : -1;
    let best = null; for (const sgn of [side, -side]) for (const d of [0.7, 1.0, 1.4]) { const x = p.x - cy * d * sgn + cx * 0.3, y = p.y + cx * d * sgn + cy * 0.3;
      const q = this.crowd.navMeshQuery.findClosestPoint({ x, y: 0, z: y }); if (q.success && Math.hypot(q.point.x - x, q.point.z - y) < 0.15) { best = [q.point.x, q.point.z]; break; } if (best) break; }
    if (!best) return;
    p.yieldAt = t + delay; p.yieldUntil = t + delay + 4; p.yieldTo = best; p.resumeTgt = p.tgt; this.c.yields = (this.c.yields || 0) + 1;
  }
  _yieldTick(t) { for (const p of this.people) { if (!p.yieldUntil) continue;
      if (t >= p.yieldAt && !p.yielding) { p.yielding = true; const keep = p.tgt; this._goto(p, p.yieldTo); p.tgt = keep; p.ag.requestMoveTarget({ x: p.yieldTo[0], y: 0, z: p.yieldTo[1] }); }
      if (t >= p.yieldUntil) { p.yieldUntil = 0; p.yielding = false; if (p.resumeTgt) this._goto(p, p.resumeTgt); } } }
  _callHelp(r, radius, why) {
    // 料理・皿を持っていない、手の空いた近いスタッフが来る（客は触らない）
    let best = null, bd = radius;
    // 救出はほかの仕事より先（優先0）。立ち往生なら、料理を取りに行く途中の人も向かう
    const free = (p) => !p.carrying && (!p.job || p.job.kind === 'idle' || (why !== 'help' && p.job.stage === 0 && p.job.kind !== 'help' && p.job.kind !== 'holding'));
    for (const p of this.people) if (p.kind === 'staff' && free(p)) { const d = Math.hypot(p.x - r.x, p.y - r.y); if (d < bd) { bd = d; best = p; } }
    if (best && best.job && best.job.kind === 'serve') { const d = best.job.dish; d.taken = null; this.queue.unshift(d); }
    else if (best && best.job && best.job.ref) best.job.ref.taken = null; // 取っていた仕事を戻す（ほかの人が取る）
    if (why === 'help') r.helped = true;
    if (!best) return;
    if (why === 'retry') why = 'help';
    r.helper = best; best.job = { kind: 'help', robot: r }; this._goto(best, [r.x - Math.cos(r.h) * 0.6, r.y - Math.sin(r.h) * 0.6]);
    this.c.helps++; this.log(why === 'stuck' ? `スタッフがロボット${r.id} を助けに向かう` : `5秒止まったロボット${r.id} にスタッフが向かう`);
  }
  _deliver(d, t, byRobot) { if (d.group && --d.group.dishesLeft === 0 && d.group.state === 'seated') { d.group.state = 'eating'; d.group.eatEnd = t + tri(d.group.rr, STORE.eatMin) * 60; } this.c.delivered++; byRobot ? this.c.byRobot++ : this.c.byStaff++; this.c.dishWaitSum += t - d.ready; }

  _staff(t) {
    for (const p of this.people) { if (p.kind !== 'staff' || p.yielding) continue; const j = p.job;
      if (!j || j.kind === 'idle') { // 仕事を取る：優先1（案内・料理・会計）を古い順 → 優先2（片付け）
        const cands = [];
        for (const d of this.queue) if (!d.taken && d.staffOk) cands.push({ prio: 1, at: d.ready, take: () => { d.taken = p; this.queue.splice(this.queue.indexOf(d), 1); p.job = { kind: 'serve', dish: d, stage: 0 }; this._goto(p, this.pass); } });
        for (const jb of this.jobs) { if (jb.taken) continue; if (jb.kind === 'pay' && this.payQueue[0] !== jb.group) continue;
          cands.push({ prio: jb.prio, at: jb.posted, take: () => { jb.taken = p; p.job = { ...jb, ref: jb, stage: 0 }; this._goto(p, jb.kind === 'guide' ? this.waitPt : jb.kind === 'pay' ? this.regBack : jb.table.t.stop); } }); }
        cands.sort((x, y) => x.prio - y.prio || x.at - y.at);
        if (cands.length) { cands[0].take(); continue; }
        if (!j) { p.job = { kind: 'idle' }; this._goto(p, p.home); } continue; }
      const done = () => { if (j.ref) this.jobs.splice(this.jobs.indexOf(j.ref), 1); p.job = null; };
      if (j.kind === 'serve') {
        if (j.stage === 0 && this._arrived(p)) { j.stage = 1; p.carrying = true; this._goto(p, j.dish.table.stop); }
        else if (j.stage === 1 && this._arrived(p, 0.5)) { p.carrying = false; this._deliver(j.dish, t, false); p.job = null; }
      } else if (j.kind === 'guide') {
        if (j.stage === 0 && this._arrived(p, 0.6)) { j.stage = 1; j.group.state = 'guided'; this._goto(p, j.group.table.t.stop); }
        else if (j.stage === 1 && this._arrived(p, 0.5)) { this._seat(j.group, t); j.stage = 2; j.until = t + STORE.guideSec; }
        else if (j.stage === 2 && t >= j.until) done();
      } else if (j.kind === 'pay') {
        const g = j.group, near = g.walkers && g.walkers.some(w => Math.hypot(w.x - this.register[0], w.y - this.register[1]) < 1.2);
        if (j.stage === 0 && this._arrived(p, 0.6) && near) { j.stage = 1; j.until = t + STORE.paySec; this.c.payWaitSum += t - g.payStart; }
        else if (j.stage === 1 && t >= j.until) { this.c.paid++; this.payQueue.shift(); g.state = 'left'; for (const w of g.walkers) { w.job.paid = true; this._goto(w, this.entrance); } done(); }
      } else if (j.kind === 'clear') {
        if (j.stage === 0 && this._arrived(p, 0.5)) { j.stage = 1; j.until = t + STORE.clearSec + STORE.wipeSec; j.table.state = 'clearing'; }
        else if (j.stage === 1 && t >= j.until) { j.stage = 2; p.carrying = true; j.table.state = 'free'; j.table.group = null; this._goto(p, this.pass); }
        else if (j.stage === 2 && this._arrived(p)) { p.carrying = false; done(); }
      } else if (j.kind === 'help') {
        const r = j.robot; if (r.state === 'pushed' || r.state === 'sidestep' || (!r.stopT && !r.stuckCounted)) { p.job = null; r.helper = null; continue; }
        if (Math.hypot(p.x - r.x, p.y - r.y) < 1.0 || this._arrived(p, 1.0)) {
          const b = this._sensor(r); let mv = r, other = b && b.robot ? b.robot : null;
          if (other && !this._robotYields(r, other) && (other.state === 'toPass' || other.state === 'toTable' || other.state === 'home')) { mv = other; other = r; }
          const ok = this._retreat(mv, other || { x: b ? b.x : r.x + Math.cos(r.h), y: b ? b.y : r.y + Math.sin(r.h), path: null }, 'pushed');
          if (!ok) { r.stopT = 0; r.helped = false; r.stuckCounted = false; r.detoured = false; this._plan(r, r.goal); }
          r.helper = null; p.job = { kind: 'holding', robot: mv, until: t + RULES.rescueSec }; this.log(`スタッフがロボット${mv.id} を動かした`); }
      } else if (j.kind === 'holding') { if (t >= j.until) p.job = null; }
    }
  }
  _guests(t) {
    for (let i = this.people.length - 1; i >= 0; i--) { const p = this.people[i]; if (p.kind !== 'guest' || p.yielding) continue; const j = p.job;
      if (j.kind === 'leave') { if (j.paid && this._arrived(p, 0.6)) { this.crowd.removeAgent(p.ag); this.people.splice(i, 1); if (j.lead) this.c.left++; } continue; }
      if (j.wait > 0) { j.wait -= DT; continue; }
      if (this._arrived(p, 0.45)) { if (j.leg === 0) { j.leg = 1; j.wait = j.dwell; this._goto(p, j.legs[1]); }
        else { p.seat.away = false; this.crowd.removeAgent(p.ag); this.people.splice(i, 1); } } }
    for (const s of this.seated) if (s.away && s.group && s.group.state === 'paying') s.away = false;
  }
  _seat(g, t) {
    g.state = 'seated'; this.waiting.splice(this.waiting.indexOf(g), 1); this.c.seatedGroups++; this.c.seatWaitSum += t - g.arrive; g.seatedAt = t;
    g.members = g.table.t.parts.seat_points.slice(0, g.size).map(p => { const m = { x: p[0], y: p[1], table: g.table.t.id, group: g }; this.seated.push(m); return m; });
    g.cooks = STORE.firstDishes.map(d => ({ at: t + tri(g.rr, d) * 60, done: false })); g.dishesLeft = g.cooks.length;
  }
  // 食べ終わり：卓に座ったままレジの順番を待つ（試算 v2.8）。順番が来た組だけが席を立ってレジへ
  _finishEating(g) { g.state = 'waitPay'; g.payStart = this.t; this.payQueue.push(g); this._post({ kind: 'pay', prio: 1, group: g, posted: this.t }); }
  _stand(g) {
    g.state = 'paying'; const st = g.table.t.stop;
    g.walkers = g.members.map((m, i) => { m.gone = true; const w = this._addPerson('guest', [st[0] + (i % 2) * 0.3, st[1] + (i >> 1) * 0.3], RULES.guestSpeed); w.job = { kind: 'leave', group: g, lead: i === 0 }; this._goto(w, [this.register[0] - 0.5 - 0.35 * (i % 2), this.register[1] - 0.35 * (i >> 1)]); this.people.push(w); return w; });
    this.seated = this.seated.filter(x => !x.gone);
    g.table.state = 'dirty'; this._post({ kind: 'clear', prio: 2, table: g.table });
  }
  // パネルに出す「いまの仕事」
  robotTask(r) {
    const tb = r.dish ? `卓${r.dish.table.id}` : '';
    if (r.state === 'pushed') return { text: 'スタッフが手で動かしている', tone: 'help' };
    if (r.state === 'sidestep') return { text: `${r.waitFor ? 'ロボット' + r.waitFor.id : '相手'}に道をゆずって待つ`, tone: 'wait' };
    if (r.stopT > 0 && (r.state === 'toPass' || r.state === 'toTable' || r.state === 'home')) return { text: `止まっている（前に${r.blockedBy}）${Math.floor(r.stopT)}秒`, tone: r.stopT >= RULES.stuckAfter ? 'stuck' : 'stop' };
    if (r.flag === 'wait') return { text: '細い通路の手前で待つ（中に別の台）', tone: 'wait' };
    switch (r.state) {
      case 'idle': return { text: '待機場所で待つ', tone: 'idle' };
      case 'toPass': return { text: `受け渡し口へ料理を取りに行く（${tb}の分）`, tone: 'run' };
      case 'toTable': return { text: `料理を${tb}へ運ぶ`, tone: 'run' };
      case 'dwell': return { text: `${tb}で料理を渡している（あと${Math.ceil(r.dwell)}秒）`, tone: 'run' };
      case 'home': return { text: '待機場所へ戻る（空）', tone: 'run' };
    }
    return { text: r.state, tone: 'idle' };
  }
  staffTask(p) {
    if (p.yielding) return { text: 'ロボットに道をゆずる', tone: 'wait' };
    const j = p.job; if (!j || j.kind === 'idle') return { text: '持ち場で待つ', tone: 'idle' };
    if (j.kind === 'serve') return j.stage === 0 ? { text: `受け渡し口へ料理を取りに行く（ロボットを2分待った卓${j.dish.table.id}の分）`, tone: 'run' } : { text: `料理を卓${j.dish.table.id}へ運ぶ`, tone: 'run' };
    if (j.kind === 'guide') return { text: j.stage === 0 ? `入口へ客を迎えに行く（${j.group.size}人）` : j.stage === 1 ? `客を卓${j.group.table.t.id}へ案内する` : `卓${j.group.table.t.id}で説明している`, tone: 'run' };
    if (j.kind === 'pay') return { text: j.stage === 0 ? 'レジへ会計に行く' : '会計している', tone: 'run' };
    if (j.kind === 'clear') return { text: j.stage === 0 ? `卓${j.table.t.id}を片付けに行く` : j.stage === 1 ? `卓${j.table.t.id}を片付けて拭いている` : '下げた皿を洗い場へ運ぶ', tone: 'run' };
    if (j.kind === 'help') return { text: `ロボット${j.robot.id}を動かしに行く`, tone: 'help' };
    if (j.kind === 'holding') return { text: `ロボット${j.robot.id}を手で動かしている`, tone: 'help' };
    return { text: j.kind, tone: 'idle' };
  }
  // 1コマ分の様子を記録する（時間のスライダーで戻って見るため）
  snapshot() {
    const R = new Float32Array(this.robots.length * 3); const rt = [];
    this.robots.forEach((r, i) => { R[i * 3] = r.x; R[i * 3 + 1] = r.y; R[i * 3 + 2] = r.h; rt.push(this.robotTask(r)); });
    const P = new Float32Array(this.people.length * 4); const st = [];
    this.people.forEach((p, i) => { P[i * 4] = p.uid; P[i * 4 + 1] = p.x; P[i * 4 + 2] = p.y; P[i * 4 + 3] = p.kind === 'staff' ? p.sid : 0; if (p.kind === 'staff') st.push(this.staffTask(p)); });
    const seen = this.seated.filter(x => !x.away); const S = new Float32Array(seen.length * 2); seen.forEach((x, i) => { S[i * 2] = x.x; S[i * 2 + 1] = x.y; });
    return { t: this.t, R, rt, P, st, S, waitN: this.waiting.length, sum: this.summary(), ev: this.events.length ? this.events[this.events.length - 1].t : -1 };
  }
  summary() { const c = this.c; let occ = 0; for (const ts of this.tableState.values()) if (ts.state !== 'free') occ++;
    return { ...c, occupied: occ, tablesN: this.tables.length, waitingN: this.waiting.length, avgSeatWait: c.seatedGroups ? c.seatWaitSum / c.seatedGroups : 0, avgPayWait: c.paid ? c.payWaitSum / c.paid : 0, peakOver: this.t >= STORE.peakMin * 60, allDone: this.t > STORE.peakMin * 60 && this.groups.every(g => g.state === 'left'), avgTrip: c.trips ? c.tripSum / c.trips : 0, avgDishWait: c.delivered ? c.dishWaitSum / c.delivered : 0 }; }
}

// 人用の NavMesh：店の床から卓・壁・部屋を抜いて作る（人はロボットより狭い所も通れる）
export function buildPeopleNavMesh(L) {
  const res = 0.05, W = L.outer.w, D = L.outer.d, nx = Math.round(W / res), ny = Math.round(D / res); const m = new Uint8Array(nx * ny).fill(1);
  const cut = (x0, y0, x1, y1) => { for (let j = Math.max(0, Math.floor(y0 / res)); j < Math.min(ny, Math.ceil(y1 / res)); j++) for (let i = Math.max(0, Math.floor(x0 / res)); i < Math.min(nx, Math.ceil(x1 / res)); i++) m[j * nx + i] = 0; };
  for (const w of L.walls) cut(...w.rect); for (const r of L.rooms) if (!r.walkable) cut(...r.rect);
  for (const t of L.tables) { const p = t.parts || {}; if (p.top) cut(...p.top); for (const b of p.benches || []) cut(...b); for (const c of p.chairs || []) cut(...(c.rect || c)); if (!p.top) cut(...t.footprint); }
  const pos = [], idx = [];
  for (let y = 0; y < ny; y++) { let x = 0; while (x < nx) { if (!m[y * nx + x]) { x++; continue; } let e = x; while (e + 1 < nx && m[y * nx + e + 1]) e++; const b = pos.length / 3; pos.push(x * res, 0, y * res, (e + 1) * res, 0, y * res, (e + 1) * res, 0, (y + 1) * res, x * res, 0, (y + 1) * res); idx.push(b, b + 2, b + 1, b, b + 3, b + 2); x = e + 1; } }
  const r = generateSoloNavMesh(new Float32Array(pos), new Uint32Array(idx), { cs: 0.05, ch: 0.05, walkableRadius: 4, walkableClimb: 2, walkableHeight: 30, maxEdgeLen: 0, maxSimplificationError: 1.3 });
  return r.navMesh;
}

export async function createSim(layout, map, opts) {
  await recastInit();
  const fc = await decodeMap(map); const grid = new Grid(map, fc);
  const nav = buildPeopleNavMesh(layout); const sim = new Sim(layout, grid, opts); await sim.setup(nav); sim.nav = nav; return sim;
}
```

### 描く所（src/main.js）

```js
import * as THREE from 'three/webgpu';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { createSim, DT, CROWD } from './sim.js';
import p0 from '../stores/p0_memory.json'; import p0m from '../stores/p0_memory_map.json';
import p1 from '../stores/p1_efficient_corner.json'; import p1m from '../stores/p1_efficient_corner_map.json';
import p2 from '../stores/p2_efficient_narrow.json'; import p2m from '../stores/p2_efficient_narrow_map.json';
import p3 from '../stores/p3_relaxed_center.json'; import p3m from '../stores/p3_relaxed_center_map.json';
import p4 from '../stores/p4_relaxed_side.json'; import p4m from '../stores/p4_relaxed_side_map.json';
import p5 from '../stores/p5_crammed.json'; import p5m from '../stores/p5_crammed_map.json';
// サイトの公開データ（public/sim/layouts/unity/）の6店
const STORES = { p0: [p0, p0m], p1: [p1, p1m], p2: [p2, p2m], p3: [p3, p3m], p4: [p4, p4m], p5: [p5, p5m] };
let layout = p1, map = p1m;

const $ = (s) => document.querySelector(s);
const state = { store: 'p1', robots: 2, staff: 3, crowd: 'normal', seed: 1, speed: 1, playing: true };
let sim = null, building = false;

// ---------------- 画面 ----------------
const renderer = new THREE.WebGPURenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
$('#view').appendChild(renderer.domElement);
const scene = new THREE.Scene(); scene.background = new THREE.Color(0xeceef2);
const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 200);
const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.screenSpacePanning = false;
// スペースを押している間は、左ドラッグで3Dの位置を動かす（離すと回す動きに戻る）
let spaceDown = false;
const typing = () => { const a = document.activeElement; return a && (a.tagName === 'INPUT' || a.tagName === 'TEXTAREA'); };
addEventListener('keydown', (e) => { if (e.code !== 'Space' || typing()) return; e.preventDefault(); if (spaceDown) return; spaceDown = true;
  controls.mouseButtons.LEFT = THREE.MOUSE.PAN; renderer.domElement.style.cursor = 'grab'; });
addEventListener('keyup', (e) => { if (e.code !== 'Space') return; spaceDown = false; controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE; renderer.domElement.style.cursor = ''; });
addEventListener('blur', () => { spaceDown = false; controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE; renderer.domElement.style.cursor = ''; });
renderer.domElement.addEventListener('pointerdown', () => { if (spaceDown) renderer.domElement.style.cursor = 'grabbing'; document.activeElement?.blur?.(); });
renderer.domElement.addEventListener('pointerup', () => { if (spaceDown) renderer.domElement.style.cursor = 'grab'; });
scene.add(new THREE.HemisphereLight(0xffffff, 0xb8b8b0, 2.2));
const sun = new THREE.DirectionalLight(0xffffff, 1.6); sun.position.set(8, 20, 6); scene.add(sun);

let W = layout.outer.w, D = layout.outer.d;
// 店の座標 (x, y) → three (x, 0, -y)。奥が画面の奥
const P = (x, y, h = 0) => new THREE.Vector3(x - W / 2, h, -(y - D / 2));
const mat = (c, o = {}) => new THREE.MeshStandardMaterial({ color: c, roughness: 0.85, ...o });
// 色は Unity の素材（ServiceSim/Store/Materials の _Color）と同じ
const M = { floor: mat(0xdbd6c9), wall: mat(0x666e7a), back: mat(0x8a909a), table: mat(0xcc7d36), bench: mat(0x9e544a), chair: mat(0x215e6b), staff: mat(0x1761e6), guest: mat(0xfacc21), robot: mat(0xf5f5f7, { roughness: 0.4 }), stop: mat(0xd64545), help: mat(0x111111), mark: mat(0xff540a), waiting: mat(0x8c4dbf) };
function box(rect, h, m, y0 = 0) { const [x0, y0r, x1, y1] = rect; const g = new THREE.BoxGeometry(x1 - x0, h, y1 - y0r); const o = new THREE.Mesh(g, m); o.position.copy(P((x0 + x1) / 2, (y0r + y1) / 2, y0 + h / 2)); (storeGroup || scene).add(o); return o; }

let storeGroup = null;
function buildStore() {
  if (storeGroup) scene.remove(storeGroup); storeGroup = new THREE.Group(); scene.add(storeGroup);
  W = layout.outer.w; D = layout.outer.d;
  box([0, 0, W, D], 0.02, M.floor, -0.02);
  for (const w of layout.outer_walls) box(w, 1.0, M.wall);
  for (const w of layout.walls) box(w.rect, 1.0, M.wall);
  for (const r of layout.rooms) if (!r.walkable) box(r.rect, 0.9, M.back);
  for (const t of layout.tables) { const p = t.parts || {}; if (p.top) box(p.top, 0.72, M.table); else box(t.footprint, 0.72, M.table); for (const b of p.benches || []) box(b, 0.45, M.bench); for (const c of p.chairs || []) box(c.rect || c, 0.45, M.chair); }
}

// 人とロボットの形（数は動的）
// Unity の prefab と同じ形（LOG364 で公開した Guest / Staff / SeatedGuestBody / ServingRobot）
//   人：カプセルの胴（半径0.2m・高さ1.59m）＋球の頭（直径0.22m・中心1.59m）
//   座った客：カプセルの胴（高さ0.79m・中心0.845m）＋頭（中心1.24m）
//   ロボット：筒（直径0.5m・高さ1.2m）＋前の印（0.3×0.06×0.06m・高さ0.96m・前へ0.225m）
const bodyGeo = new THREE.CapsuleGeometry(0.2, 1.19, 6, 16), headGeo = new THREE.SphereGeometry(0.11, 16, 12);
const seatBodyGeo = new THREE.CapsuleGeometry(0.2, 0.39, 6, 16);
const robotGeo = new THREE.CylinderGeometry(0.25, 0.25, 1.2, 24), markGeo = new THREE.BoxGeometry(0.06, 0.06, 0.3);
const lampGeo = new THREE.SphereGeometry(0.09, 12, 8);
function person(m, seated) { const g = new THREE.Group(); const b = new THREE.Mesh(seated ? seatBodyGeo : bodyGeo, m); b.position.y = seated ? 0.845 : 0.795; const h = new THREE.Mesh(headGeo, m); h.position.y = seated ? 1.24 : 1.59; g.add(b, h); g.userData.mats = [b, h]; return g; }
function robot() { const g = new THREE.Group(); const b = new THREE.Mesh(robotGeo, M.robot); b.position.y = 0.6; const k = new THREE.Mesh(markGeo, M.mark); k.position.set(0.225, 0.96, 0); k.rotation.y = Math.PI / 2; g.add(b, k); return g; }
let waitMeshes = [], robotMeshes = [], lamps = [], personPool = [], seatMeshes = [];
function rebuildDynamic() {
  for (const o of [...robotMeshes, ...lamps, ...personPool, ...seatMeshes, ...waitMeshes]) scene.remove(o); waitMeshes = []; for (const el of labels.values()) el.remove(); labels.clear();
  robotMeshes = sim.robots.map(() => { const m = robot(); scene.add(m); return m; });
  lamps = sim.robots.map(() => { const m = new THREE.Mesh(lampGeo, M.stop); scene.add(m); return m; });
  personPool = [];
  seatMeshes = [];
}
const labelLayer = document.getElementById('labels'); const labels = new Map(); const _v = new THREE.Vector3();
// 立ち往生の吹き出し（ロボットの頭の上。出たら最低6秒は出しておく）
const bubble = document.getElementById('bubble'); let bubbleRobot = -1, bubbleUntil = 0, bubbleTitle = '';
function placeBubble(pos) { _v.copy(pos).project(camera); const w = renderer.domElement.clientWidth, h = renderer.domElement.clientHeight, top = renderer.domElement.offsetTop;
  const x = Math.min(w - 12, Math.max(12, (_v.x * 0.5 + 0.5) * w)), y = (-_v.y * 0.5 + 0.5) * h + top; bubble.style.left = x + 'px'; bubble.style.top = (y - 26) + 'px'; }
function label(key, text, cls, pos) {
  let el = labels.get(key); if (!el) { el = document.createElement('div'); labelLayer.appendChild(el); labels.set(key, el); }
  el.className = 'lbl ' + cls; el.textContent = text; _v.copy(pos).project(camera);
  const w = renderer.domElement.clientWidth, h = renderer.domElement.clientHeight; el.style.transform = `translate(${(_v.x * 0.5 + 0.5) * w}px, ${(-_v.y * 0.5 + 0.5) * h}px) translate(-50%, -100%)`; el.style.display = _v.z < 1 ? '' : 'none'; el.dataset.seen = '1';
}
// 記録した様子を描く（f0 と f1 のあいだを a で補間）
function draw(f0, f1, a) {
  if (!f0) return; if (!f1) { f1 = f0; a = 0; }
  for (const el of labels.values()) el.dataset.seen = '';
  const nR = f0.R.length / 3;
  for (let i = 0; i < nR; i++) { const x = f0.R[i * 3] + (f1.R[i * 3] - f0.R[i * 3]) * a, y = f0.R[i * 3 + 1] + (f1.R[i * 3 + 1] - f0.R[i * 3 + 1]) * a;
    let h0 = f0.R[i * 3 + 2], dh = f1.R[i * 3 + 2] - h0; while (dh > Math.PI) dh -= 2 * Math.PI; while (dh < -Math.PI) dh += 2 * Math.PI;
    robotMeshes[i].position.copy(P(x, y, 0)); robotMeshes[i].rotation.y = h0 + dh * a; const tone = f0.rt[i].tone;
    const l = lamps[i]; l.position.copy(P(x, y, 1.35)); l.visible = tone === 'stop' || tone === 'stuck' || tone === 'help'; l.material = tone === 'help' ? M.help : M.stop;
    label('r' + (i + 1), String(i + 1), 'robot ' + tone, P(x, y, 1.55));
    if (tone === 'stuck' && (bubbleRobot < 0 || performance.now() > bubbleUntil)) { bubbleRobot = i; bubbleUntil = performance.now() + 6000; bubbleTitle = `ロボット${i + 1}が30秒動けず立ち往生`; }
    if (i === bubbleRobot) placeBubble(P(x, y, 1.55)); }
  if (bubbleRobot >= nR || (bubbleRobot >= 0 && performance.now() > bubbleUntil && f0.rt[bubbleRobot].tone !== 'stuck')) bubbleRobot = -1;
  bubble.classList.toggle('on', bubbleRobot >= 0); if (bubbleRobot >= 0) document.getElementById('bubble-t').textContent = bubbleTitle;
  const nP = f0.P.length / 4, next = new Map(); for (let k = 0; k < f1.P.length / 4; k++) next.set(f1.P[k * 4], k);
  while (personPool.length < nP) { const m = person(M.guest, false); scene.add(m); personPool.push(m); }
  personPool.forEach((m, i) => { m.visible = i < nP; if (i >= nP) return; let x = f0.P[i * 4 + 1], y = f0.P[i * 4 + 2]; const k = next.get(f0.P[i * 4]);
    if (k !== undefined) { x += (f1.P[k * 4 + 1] - x) * a; y += (f1.P[k * 4 + 2] - y) * a; }
    m.position.copy(P(x, y, 0)); const sid = f0.P[i * 4 + 3]; const mt = sid ? M.staff : M.guest; for (const c of m.userData.mats) c.material = mt;
    if (sid) label('s' + sid, 'S' + sid, 'staff', P(x, y, 1.95)); });
  const nS = f0.S.length / 2; while (seatMeshes.length < nS) { const m = person(M.guest, true); scene.add(m); seatMeshes.push(m); }
  seatMeshes.forEach((m, i) => { m.visible = i < nS; if (i < nS) m.position.copy(P(f0.S[i * 2], f0.S[i * 2 + 1], 0)); });
  const wr = (layout.places.find(p => p.kind === 'Waiting') || {}).rect; const nW = Math.min(8, f0.waitN);
  while (waitMeshes.length < 8) { const m = person(M.waiting, false); scene.add(m); waitMeshes.push(m); }
  waitMeshes.forEach((m, i) => { m.visible = !!wr && i < nW; if (wr && i < nW) { const vert = (wr[3] - wr[1]) >= (wr[2] - wr[0]); const f = (i + 0.5) / 8; m.position.copy(vert ? P((wr[0] + wr[2]) / 2, wr[1] + (wr[3] - wr[1]) * f, 0) : P(wr[0] + (wr[2] - wr[0]) * f, (wr[1] + wr[3]) / 2, 0)); } });
  for (const [k, el] of labels) if (!el.dataset.seen) { el.remove(); labels.delete(k); }
  const rows = f0.rt.map((t, i) => `<li class="${t.tone}"><b class="tag robot">${i + 1}</b>${t.text}</li>`).concat(f0.st.map((t, i) => `<li class="${t.tone}"><b class="tag staff">S${i + 1}</b>${t.text}</li>`));
  const html = rows.join('') || '<li>ロボット 0台</li>'; if (taskList.innerHTML !== html) taskList.innerHTML = html;
}
const taskList = document.getElementById('tasks');
const VIEWS = { wide: [0, 34, 25], left: [-19, 31, 26], right: [19, 31, 26] };
function setView(k) { const v = VIEWS[k].map(x => x * (mobile.matches ? 1.12 : 1)); camera.position.set(...v); controls.target.set(0, 0, 0); controls.update(); document.querySelectorAll('[data-view]').forEach(b => b.classList.toggle('on', b.dataset.view === k)); }
const mobile = matchMedia('(max-width: 760px)');
function resize() { const el = $('#view'); const w = el.clientWidth, h = mobile.matches ? Math.round(innerHeight * 0.45) : el.clientHeight;
  renderer.setSize(w, h); camera.aspect = w / h; camera.updateProjectionMatrix(); const lb = $('#labels'); lb.style.height = h + 'px'; lb.style.bottom = 'auto'; lb.style.top = renderer.domElement.offsetTop + 'px'; el.style.setProperty('--cvt', renderer.domElement.offsetTop + 'px'); el.style.setProperty('--cvh', h + 'px');
  $('#hint').textContent = mobile.matches ? '1本指で回す・2本指で寄る／動かす' : 'ドラッグで回す・ホイールで寄る・スペース＋ドラッグで動かす'; }
mobile.addEventListener('change', resize);
addEventListener('resize', resize);

// ---------------- パネル ----------------
const fmt = (s) => { const m = Math.floor(s / 60), x = Math.floor(s % 60); return `${m}:${String(x).padStart(2, '0')}`; };
function panel(f) {
  const s = f.sum;
  $('#clock').textContent = fmt(f.t);
  $('#n-delivered').textContent = s.delivered; $('#n-robot').textContent = s.delivered ? Math.round(s.byRobot / s.delivered * 100) : 0;
  $('#n-wait').textContent = (s.avgDishWait / 60).toFixed(1);
  $('#n-occ').textContent = `${s.occupied}/${s.tablesN}`; $('#n-waiting').textContent = s.waitingN;
  $('#n-seatwait').textContent = (s.avgSeatWait / 60).toFixed(1); $('#n-paywait').textContent = (s.avgPayWait / 60).toFixed(1);
  $('#phase').textContent = s.allDone ? '全員が帰りました' : s.peakOver ? 'ピークが終わり、残りのお客さんが帰るまで' : `ピーク（来客 ${Math.round(sim.arrPerH)}組/時）`;
  $('#n-stops').textContent = s.sensorStops; $('#n-helps').textContent = s.helps; $('#n-stuck').textContent = s.stucks;
  $('#log').innerHTML = (sim.allEvents || []).filter(e => e.t <= f.t).slice(-5).reverse().map(e => `<li><b>${fmt(e.t)}</b>${e.msg}</li>`).join('');
}
async function rebuild() {
  if (building) return; building = true; $('#status').textContent = '店を建てています…';
  if (sim) { sim.crowd.destroy(); }
  [layout, map] = STORES[state.store]; buildStore(); $('#store').value = state.store;
  sim = await createSim(layout, map, { robots: state.robots, staff: state.staff, crowd: state.crowd, seed: state.seed });
  bubbleRobot = -1; rebuildDynamic(); frames = [sim.snapshot()]; viewT = 0; simDone = false; draw(frames[0]); panel(frames[0]); building = false; $('#status').textContent = '';
}
function seg(id, values, key, label = (v) => v) {
  const el = $(id); el.innerHTML = values.map(v => `<button data-v="${v}">${label(v)}</button>`).join('');
  const sync = () => el.querySelectorAll('button').forEach(b => b.classList.toggle('on', String(state[key]) === b.dataset.v));
  el.addEventListener('click', (e) => { const b = e.target.closest('button'); if (!b) return; const v = b.dataset.v; state[key] = isNaN(+v) ? v : +v; sync(); if (key !== 'speed') rebuild(); });
  sync();
}
$('#store').innerHTML = Object.entries(STORES).map(([k, [l]]) => `<option value="${k}">${l.name}</option>`).join('');
$('#store').addEventListener('change', (e) => { state.store = e.target.value; rebuild(); });
seg('#c-robots', [0, 1, 2, 3, 4, 5, 6], 'robots', v => `${v}台`);
seg('#c-staff', [2, 3, 4], 'staff', v => `${v}人`);
seg('#c-crowd', ['empty', 'normal', 'busy'], 'crowd', v => CROWD[v].label);
seg('#c-speed', [1, 5, 20], 'speed', v => v === 1 ? '実際の速さ' : `${v}倍`);
$('#play').addEventListener('click', () => { state.playing = !state.playing; $('#play').textContent = state.playing ? '一時停止' : '再生'; });
$('#restart').addEventListener('click', () => rebuild());
const setDay = (d) => { state.seed = Math.max(1, d); $('#dayLabel').textContent = `${state.seed}日目`; $('#dayPrev').disabled = state.seed <= 1; rebuild(); };
$('#dayPrev').addEventListener('click', () => setDay(state.seed - 1)); $('#dayNext').addEventListener('click', () => setDay(state.seed + 1)); $('#dayPrev').disabled = true;
document.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => setView(b.dataset.view)));

// ---------------- 回す ----------------
// 計算は先に進めて1秒ごとに記録し、画面は記録を見る（時間のスライダーで前後に動かせる）
const REC = 1.0, END_CAP = 6 * 3600; let frames = [], viewT = 0, simDone = false, lastT = performance.now(), dragging = false;
const slider = $('#seek'); window.__p2m = { get frames() { return frames; } }; // 確かめ用
function computeAhead(budgetMs) {
  if (!sim || simDone || building) return; const t0 = performance.now(), per = Math.round(REC / DT);
  while (performance.now() - t0 < budgetMs) { for (let k = 0; k < per; k++) sim.step(); frames.push(sim.snapshot()); const s = frames[frames.length - 1].sum; if (s.allDone || sim.t >= END_CAP) { simDone = true; break; } }
}
function frameAt(t) { const i = Math.min(frames.length - 1, Math.max(0, Math.floor(t / REC))); return [frames[i], frames[i + 1], (t - i * REC) / REC]; }
async function main() {
  await renderer.init(); $('#backend').textContent = renderer.backend.isWebGPUBackend ? 'WebGPU' : 'WebGL';
  resize(); setView('left'); await rebuild();
  slider.addEventListener('input', () => { dragging = true; viewT = Math.min(+slider.value, (frames.length - 1) * REC); });
  slider.addEventListener('change', () => { dragging = false; });
  renderer.setAnimationLoop(() => {
    const now = performance.now(), dt = Math.min(0.1, (now - lastT) / 1000); lastT = now;
    if (sim && !building) {
      computeAhead(10);
      const end = (frames.length - 1) * REC;
      if (state.playing && !dragging) viewT = Math.min(end, viewT + dt * state.speed);
      const [f0, f1, a] = frameAt(viewT); draw(f0, f1, a); panel(f0);
      slider.max = simDone ? end : Math.max(end, 3 * 3600); if (!dragging) slider.value = viewT;
      const pct = slider.max > 0 ? end / slider.max * 100 : 0; slider.style.setProperty('--done', pct + '%');
      $('#seekinfo').textContent = simDone ? `終わりまで計算済み（${fmt(end)}）` : `計算中…（${fmt(end)} まで）`;
    }
    controls.update(); renderer.render(scene, camera);
  });
}
main();
```

### ページの型（shell.html）

`/*BUNDLE*/` の所に、esbuild でまとめた JavaScript を入れて1ファイルのページにします。

```html
<!DOCTYPE html>
<html lang="ja"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&family=Noto+Sans+JP:wght@400;600;700&display=swap" rel="stylesheet">
<title>配膳ロボット ブラウザ版（試作）</title>
<style>
:root{--ink:#1d1d1b;--sub:#6b6b66;--line:#d8d6d0;--bg:#f2f1ee;--accent:#2f6fd6}
*{box-sizing:border-box}body{margin:0;font-family:"Noto Sans JP","Inter",system-ui,sans-serif;color:var(--ink);background:var(--bg);height:100vh;display:grid;grid-template-columns:1fr 320px}
#view{position:relative;min-height:0}#view canvas{display:block}
#hud{position:absolute;left:16px;top:14px;background:rgba(255,255,255,.92);padding:10px 14px;border-radius:8px}
#hud h1{margin:0;font-size:15px}#store{margin-top:4px;font:inherit;font-size:13px;color:#1d1d1b;border:1px solid var(--line);border-radius:6px;padding:4px 6px;background:#fff;max-width:330px}#hud p{margin:2px 0 0;font-size:12px;color:var(--sub)}
#clock{font:600 28px/1 "Inter","Noto Sans JP",system-ui,sans-serif;margin-top:6px}
#views{position:absolute;left:16px;bottom:14px;display:flex;gap:6px}
aside{background:#fff;border-left:1px solid var(--line);padding:16px;overflow:auto;font-size:13px}
h2{font-size:12px;color:var(--sub);font-weight:600;margin:16px 0 6px}h2:first-child{margin-top:0}
.seg{display:flex;flex-wrap:wrap;gap:4px}
button{font:inherit;border:1px solid var(--line);background:#fff;border-radius:6px;padding:5px 9px;cursor:pointer;color:var(--ink)}
button.on{background:var(--ink);color:#fff;border-color:var(--ink)}
.row{display:flex;gap:6px;margin-top:8px}
.nums{display:grid;grid-template-columns:1fr 1fr;gap:10px 12px;margin-top:4px}.num{line-height:1.25}.num>span{display:block;margin-top:1px;line-height:1.3}.num b span{font-size:inherit;color:inherit}#phase{margin:0 0 6px}
.num b{display:block;font:600 24px/1.1 "Inter","Noto Sans JP",system-ui,sans-serif}.num span{font-size:11px;color:var(--sub)}.num small{font-size:12px;font-weight:400;margin-left:2px}
#log{list-style:none;padding:0;margin:0;font-size:12px}#log li{padding:4px 0;border-bottom:1px solid #eee}#log b{font-family:"Inter","Noto Sans JP",system-ui,sans-serif;font-weight:600;margin-right:8px}
#labels{position:absolute;inset:0;pointer-events:none;overflow:hidden}
.lbl{position:absolute;left:0;top:0;font:600 11px/1 "Inter","Noto Sans JP",system-ui,sans-serif;padding:3px 6px;border-radius:9px;white-space:nowrap}
.lbl.robot{background:#fff;color:#1d1d1b;border:1px solid #888}.lbl.robot.stop,.lbl.robot.stuck{background:#d64545;color:#fff;border-color:#d64545}.lbl.robot.help{background:#111;color:#fff;border-color:#111}.lbl.robot.wait{background:#fff;border:1px dashed #555}
.lbl.staff{background:#1761e6;color:#fff}
#taskpanel{position:absolute;right:16px;bottom:14px;width:300px;max-height:45%;overflow:auto;background:rgba(60,62,66,.86);color:#fff;border-radius:8px;padding:10px 12px;font-size:12px}
#taskpanel h3{margin:0 0 6px;font-size:12px;font-weight:600;color:#cfd2d6}#taskpanel ul{list-style:none;margin:0;padding:0}#taskpanel li{display:flex;gap:8px;align-items:flex-start;padding:4px 0;border-top:1px solid rgba(255,255,255,.12);line-height:1.4}
#taskpanel li:first-child{border-top:0}#taskpanel p{margin:6px 0 0;font-size:11px;color:#b9bcc1}
.tag{flex:none;min-width:24px;text-align:center;font:600 11px/18px "Inter","Noto Sans JP",system-ui,sans-serif;border-radius:9px}.tag.robot{background:#fff;color:#1d1d1b}.tag.staff{background:#1761e6;color:#fff}
#taskpanel li.stop,#taskpanel li.stuck{color:#ffb3b3}#taskpanel li.help{color:#fff;font-weight:600}#taskpanel li.idle{color:#b9bcc1}
#hud .hint{margin-top:6px;font-size:11px}
#timeline{position:absolute;left:16px;right:340px;bottom:62px;display:flex;align-items:center;gap:10px;background:rgba(255,255,255,.92);padding:8px 12px;border-radius:8px}
#seek{flex:1;-webkit-appearance:none;appearance:none;height:6px;border-radius:3px;background:linear-gradient(90deg,#1d1d1b var(--done,0%),#d8d6d0 var(--done,0%))}
#seek::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;border-radius:50%;background:#fff;border:2px solid #1d1d1b;cursor:pointer}
#seek::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:#fff;border:2px solid #1d1d1b}
#seekinfo{font-size:11px;color:#6b6b66;white-space:nowrap}
#cta{margin:18px 0 6px;padding:14px;border:1px solid var(--line);border-radius:8px;background:#fafaf8}#cta p{margin:0 0 8px;font-size:12px;color:var(--sub)}#cta a{display:block;text-align:center;background:#1d1d1b;color:#fff;text-decoration:none;font-weight:600;font-size:14px;padding:11px 12px;border-radius:6px}#cta a:hover{background:#000}#cta small{display:block;margin-top:6px;font-size:11px;color:var(--sub)}
#bubble{position:absolute;z-index:5;left:0;top:0;transform:translate(-50%,-100%);display:none;flex-direction:column;gap:1px;background:rgba(29,29,27,.78);color:#fff;text-decoration:none;padding:8px 11px;border-radius:8px;white-space:nowrap;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}#bubble.on{display:flex}#bubble:after{content:"";position:absolute;left:50%;bottom:-6px;margin-left:-6px;border:6px solid transparent;border-bottom:0;border-top-color:rgba(29,29,27,.78)}#bubble b{font-size:12px;color:#ff9b9b}#bubble span{font-size:12px}#bubble:hover{background:rgba(0,0,0,.88)}
.day{display:flex;align-items:center;gap:6px}.day b{min-width:64px;text-align:center;font:600 14px/1 "Inter","Noto Sans JP",system-ui,sans-serif}.day button{width:34px;padding:5px 0;font-size:16px;line-height:1}
.side{display:block;line-height:0;padding:2px 0 14px;margin-bottom:12px;border-bottom:1px solid var(--line)}.side img{height:16px;width:auto}@media (max-width:760px){aside{display:flex;flex-direction:column}.side{order:99;border-bottom:0;border-top:1px solid var(--line);padding:16px 0 4px;margin:16px 0 0}}
#legend{position:absolute;right:16px;top:14px;background:rgba(60,62,66,.82);color:#fff;padding:9px 12px;border-radius:8px;display:flex;flex-direction:column;gap:5px;font-size:12px}#legend i{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:6px;vertical-align:-1px}
.note{font-size:11px;color:var(--sub);line-height:1.6}
#status{font-size:12px;color:var(--sub);min-height:1em}
input{width:70px;font:inherit;padding:4px;border:1px solid var(--line);border-radius:6px}
@media (max-width:760px){
body{display:block;height:auto}
#view{display:flex;flex-direction:column;gap:8px;padding-bottom:12px}
#view canvas{order:0}
#hud{position:static;order:-1;margin:8px 10px 0;padding:8px 10px}#hud h1{font-size:13px}#store{font-size:12px;max-width:100%}#clock{font-size:20px;margin-top:2px}#hud .hint{font-size:10px;margin-top:2px}
#legend,#timeline,#views,#taskpanel{position:static;order:1;margin:0 10px;width:auto;max-height:none}
#legend{flex-direction:row;flex-wrap:wrap;gap:4px 12px;font-size:11px;padding:7px 10px}
#timeline{order:1}#views{order:2;flex-wrap:wrap}#views button{flex:1;padding:6px 4px;font-size:12px}
#taskpanel{order:3}#legend{order:4}
aside{border-left:0;border-top:1px solid var(--line);padding:14px 12px}
.seg button{padding:6px 10px}
.nums{grid-template-columns:1fr 1fr;gap:10px 12px}
}
</style></head><body>
<div id="view"><div id="hud"><h1>配膳ロボット ブラウザ版（試作）</h1><select id="store" aria-label="店"></select><div id="clock">0:00</div><p class="hint">描画：<span id="backend"></span>　<span id="hint"></span></p></div>
<div id="legend"><span><i style="background:#fff;border:1px solid #999"></i>ロボット</span><span><i style="background:#1761e6"></i>スタッフ</span><span><i style="background:#facc21"></i>客</span><span><i style="background:#8c4dbf"></i>待合の客（1組に1人）</span><span><i style="background:#d64545"></i>止まっている</span><span><i style="background:#111"></i>人が動かしている</span></div>
<div id="labels"></div>
<a id="bubble" href="https://pixelstomotors.com/message?topic=physical-ai" target="_blank" rel="noopener"><b id="bubble-t">30秒動けず立ち往生</b><span>自分の店でも起きる？ 相談する →</span></a>
<div id="taskpanel"><h3>いまの仕事</h3><ul id="tasks"></ul><p>使い方：料理を運ぶだけ（下げ膳はスタッフ）</p></div>
<div id="timeline"><input id="seek" type="range" min="0" max="10800" step="1" value="0" aria-label="時間"><span id="seekinfo"></span></div>
<div id="views"><button data-view="left">斜め（左30°）</button><button data-view="right">斜め（右30°）</button><button data-view="wide">正面</button></div></div>
<aside><a class="side" href="https://pixelstomotors.com/" target="_blank" rel="noopener"><img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMTQ2IDIyNyIgcm9sZT0iaW1nIiBhcmlhLWxhYmVsPSJQaXhlbHMgdG8gTW90b3JzIj48cGF0aCBmaWxsPSIjMjQyNDI0IiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMTIuNzggMjA5Ljc1TDExMi41IDE5Ni41TDk5LjIyIDE5Ni4yMkw4NS45NCAxOTUuOTRMODYuMjIgMTgzLjIyTDg2LjUgMTcwLjVMOTkuNzggMTcwLjIyTDExMy4wNSAxNjkuOTRMMTEyLjc4IDE1NS4yMkwxMTIuNSAxNDAuNUw5OS41IDE0MC41TDg2LjUgMTQwLjVMODYuNSAxMTNMODYuNSA4NS41TDk5LjUgODVMMTEyLjUgODQuNUwxMTIuNzggNzEuMjhMMTEzLjA2IDU4LjA2TDEyNS43OCA1Ny43OEwxMzguNSA1Ny41TDEzOC41IDQ0LjVMMTM4LjUgMzEuNUwxMjUuNzUgMzEuMjJMMTEzIDMwLjk0TDExMyAxOC4wOEMxMTMgNy43NCAxMTMuMjkgNS4xIDExNC40NiA0LjY2QzExNi43NyAzLjc3IDEzOC41OCA1Ljg0IDE0NS4zNCA3LjU4QzE1My43NiA5Ljc2IDE2Ny4wMyAxNS4zNSAxNzUuNDggMjAuMjhDMTgzLjYxIDI1LjAyIDIwMS4wNCA0MS40OSAyMDcuNDQgNTAuNDhDMjE5Ljg1IDY3LjkgMjI3LjkyIDkzLjE0IDIyNy45OCAxMTQuNzZDMjI4LjAzIDEzMS4zMSAyMjEuOTMgMTUzLjg0IDIxMy43NCAxNjcuMzRDMjA4LjU5IDE3NS44NCAxOTkuOTEgMTg3LjE0IDE5NC41OSAxOTIuMjdDMTg5LjY2IDE5Ny4wMiAxNzcgMjA2LjQyIDE3MC44OCAyMDkuODhDMTU3Ljk1IDIxNy4xNyAxMzUuODQgMjIzIDEyMS4xMiAyMjNMMTEzLjA2IDIyM0wxMTIuNzggMjA5Ljc1Wk02MCAyMTBMNjAgMTk4TDcxIDE5OEw4MiAxOThMODIgMjEwTDgyIDIyMkw3MSAyMjJMNjAgMjIyTDYwIDIxMFpNNjcyIDIxMC4zNUM2NDguMjkgMjA3LjE4IDYzMC40MSAxOTQuMzUgNjIyLjc1IDE3NUM2MTguMjYgMTYzLjY2IDYxNy42MiAxNjAuMDMgNjE3LjU2IDE0NS41QzYxNy40OSAxMjguOTcgNjE4LjkzIDEyMi4zOSA2MjUuNzMgMTA4LjA4QzYzNC41OSA4OS40NCA2NTAuNTYgNzcuNzUgNjcxLjg3IDc0LjNDNzExLjEyIDY3Ljk1IDczNS4yNSA5MS4wNiA3MzUuNDUgMTM1LjE2QzczNS41IDE0Ni4xNyA3MzUuMzYgMTQ2Ljk4IDczMy4wOCAxNDkuNDFMNzMwLjY1IDE1Mkw2ODkuNCAxNTJDNjQyLjY1IDE1MiA2NDQuMzggMTUxLjcxIDY0Ni4yNyAxNTkuMTdDNjQ3Ljk0IDE2NS43NiA2NTMuMTQgMTc0LjEyIDY1Ny43NCAxNzcuNjNDNjY0LjUxIDE4Mi43OSA2NzIuMTQgMTg1LjIgNjgzLjUgMTg1Ljc0QzY5NC42OSAxODYuMjcgNzAxLjU1IDE4NC45MyA3MTMuNDQgMTc5Ljg4QzcxNy4yNSAxNzguMjYgNzIwLjg5IDE3Ny4xMyA3MjEuNTEgMTc3LjM3QzcyMy4yOCAxNzguMDQgNzMxLjI1IDE5NC40NSA3MzAuODUgMTk2LjU5QzczMC4zNCAxOTkuMzYgNzE3LjI2IDIwNS41MyA3MDYuNSAyMDguMDZDNjk3LjY5IDIxMC4xNCA2NzkuNDcgMjExLjM1IDY3MiAyMTAuMzVaTTg1Ny44MyAyMDkuOThDODQ3LjU2IDIwOC4yNCA4MzIuNTggMjAyLjc0IDgyOC43OCAxOTkuM0M4MjcuOCAxOTguNDIgODI3IDE5Ni42OSA4MjcgMTk1LjQ2QzgyNyAxOTIuOTkgODM0LjI4IDE3Ny44NCA4MzYuMjUgMTc2LjIxQzgzNy4xNSAxNzUuNDcgODQwLjIxIDE3Ni41MSA4NDcuMTggMTc5Ljk0Qzg1OC4yOCAxODUuMzkgODY0Ljk5IDE4Ny4wMSA4NzYuNDIgMTg2Ljk4Qzg4OC45NiAxODYuOTQgODk2LjYgMTgyLjQgODk3LjY4IDE3NC4zNUM4OTguNjggMTY2Ljg2IDg5My44NSAxNjMuMjIgODcwLjE5IDE1My42NEM4NTIuOSAxNDYuNjMgODQ1LjU0IDE0Mi40OSA4MzkuNTIgMTM2LjM2QzgyOC4zIDEyNC45NSA4MjYuODcgMTA1LjQgODM2LjI0IDkxLjM4Qzg0MC4zOSA4NS4xNyA4NDkuOTQgNzguNzEgODU5LjM0IDc1Ljc1Qzg2OS4wNyA3Mi43IDg5My43IDcyLjUgOTA0LjUgNzUuMzlDOTEzLjM4IDc3Ljc2IDkyMC42OCA4MS40MSA5MjEuNDYgODMuODZDOTIyLjU4IDg3LjM5IDkxMy44NCAxMDguNjEgOTExLjY3IDEwNy42MkM4OTYgMTAwLjUyIDg4My4xNiA5Ny4yMSA4NzYuMTkgOTguNDZDODY4LjgzIDk5Ljc5IDg2NS4wMiAxMDEuMzUgODYyLjA0IDEwNC4yNkM4NTkuNTkgMTA2LjY2IDg1OSAxMDguMDcgODU5IDExMS41MkM4NTkgMTE1LjE2IDg1OS41MiAxMTYuMjUgODYyLjUzIDExOC44OUM4NjYuOTkgMTIyLjgxIDg3MC42NSAxMjQuNjUgODg1LjQxIDEzMC40MUM5MDguNSAxMzkuNDIgOTE3Ljc0IDE0NS45NCA5MjIuODMgMTU2LjgxQzkyNS4xNyAxNjEuNzkgOTI1LjUgMTYzLjc0IDkyNS40OSAxNzIuNUM5MjUuNDggMTgxLjQxIDkyNS4xNyAxODMuMTIgOTIyLjY4IDE4OC4yMUM5MTcuNjYgMTk4LjQ4IDkwNi4wMSAyMDYuNDEgODkxLjQxIDIwOS41Qzg4NC4wOCAyMTEuMDUgODY1Ljc2IDIxMS4zMSA4NTcuODMgMjA5Ljk4Wk0xMTU2LjE2IDIwOS41NkMxMTM3LjUyIDIwNi4wMSAxMTIwLjY0IDE5My4xNyAxMTEzLjExIDE3Ni44NEMxMTA4LjA5IDE2NS45NSAxMTA2LjU0IDE1OC4wNSAxMTA2LjY2IDE0NEMxMTA2Ljk5IDEwMy4xNiAxMTMwLjY2IDc2LjUxIDExNjkuMzEgNzMuNDRDMTE5NC43NiA3MS40MiAxMjE3LjMzIDgxLjgxIDEyMjkuMTkgMTAxQzEyNDQuNjYgMTI2LjAxIDEyNDMuMTMgMTYwLjg0IDEyMjUuNDkgMTg1LjU3QzEyMjAuNjYgMTkyLjMzIDEyMTIuMzEgMTk5LjI4IDEyMDMuODQgMjAzLjU4QzExOTEuNzQgMjA5LjcyIDExNzAuNzcgMjEyLjM1IDExNTYuMTYgMjA5LjU2Wk0xNjAwLjIgMjA5LjVDMTU5Mi40IDIwOC4wOCAxNTgwLjM0IDIwMi44NyAxNTc0LjI5IDE5OC4zQzE1NTUuOTUgMTg0LjQ0IDE1NDcuNDggMTYxLjAzIDE1NTAuOTMgMTMzLjY4QzE1NTQuNzIgMTAzLjU1IDE1NzEuNjUgODMuMTEgMTU5OC44NiA3NS44MUMxNjA5LjE4IDczLjA0IDE2MjguNzEgNzIuODIgMTYzNy45MSA3NS4zN0MxNjU5LjY1IDgxLjM4IDE2NzQuMTEgOTUuNzUgMTY4MC42MSAxMTcuODNDMTY4Mi43NCAxMjUuMDggMTY4My4xNyAxMjguNTUgMTY4My4xOCAxMzlDMTY4My4yMSAxNjUuMDEgMTY3NC44IDE4NC40NyAxNjU4LjExIDE5Ny4wMkMxNjQyLjcyIDIwOC41OSAxNjIxLjAxIDIxMy4yNyAxNjAwLjIgMjA5LjVaTTE4NDkuNSAyMDkuNTJDMTgxOS44NiAyMDQuMDMgMTgwMS4wNSAxODEuMzQgMTc5OS4zMiAxNDlDMTc5Ny4zOCAxMTIuNjYgMTgxNi4xMiA4NC4zMyAxODQ3LjcgNzUuODVDMTg1OC4xMSA3My4wNSAxODc3LjU0IDcyLjgzIDE4ODcgNzUuNEMxOTEyLjQ1IDgyLjMyIDE5MjguMTYgMTAxLjYyIDE5MzIuMDUgMTMwLjc1QzE5MzQuMTcgMTQ2LjYyIDE5MzAuMjYgMTY2LjMxIDE5MjIuMjggMTc5Ljk2QzE5MDguNzIgMjAzLjEzIDE4NzkuMzYgMjE1LjA2IDE4NDkuNSAyMDkuNTJaTTIwNzAgMjA5LjA1QzIwNTQuNzcgMjA1LjggMjA0MyAyMDAuMDQgMjA0MyAxOTUuODNDMjA0MyAxOTMuNDMgMjA1MC41NyAxNzcuNiAyMDUyLjQ3IDE3Ni4wMkMyMDUzLjQ3IDE3NS4yIDIwNTUuMzcgMTc1Ljc5IDIwNjAuMjEgMTc4LjQ1QzIwNzAuNDcgMTg0LjA5IDIwNzcuMzkgMTg2LjEgMjA4OC43MyAxODYuNzJDMjEwMC44NSAxODcuMzkgMjEwNi4xNCAxODUuODkgMjExMC45NSAxODAuNEMyMTE2LjMyIDE3NC4yOCAyMTE0LjUyIDE2Ny44OCAyMTA1LjkgMTYyLjQ0QzIxMDMuNzggMTYxLjEgMjA5Ni4zIDE1Ny43MiAyMDg5LjI4IDE1NC45MkMyMDU5LjcgMTQzLjEzIDIwNDkuMDIgMTM0LjE4IDIwNDYuNSAxMTkuMDRDMjA0My4wNCA5OC4zMyAyMDU0LjE2IDgyLjE1IDIwNzYuNSA3NS4zOUMyMDg1LjE5IDcyLjc2IDIxMTAuNjggNzIuNzYgMjEyMC41IDc1LjM5QzIxMjguODYgNzcuNjIgMjEzNi44NiA4MS40NyAyMTM3LjcgODMuNjZDMjEzOC44NiA4Ni42NyAyMTI5LjczIDEwOC41NSAyMTI3LjcgMTA3LjYzQzIxMTguMTcgMTAzLjMzIDIxMDguMjcgOTkuODkgMjEwMi43NCA5OC45NkMyMDg4LjE3IDk2LjUgMjA3NSAxMDIuMjEgMjA3NSAxMTAuOThDMjA3NSAxMTguMjUgMjA4MC4wMyAxMjIgMjEwMC41NiAxMzAuMDZDMjEyNC45IDEzOS42MiAyMTMzLjY0IDE0NS43MiAyMTM4LjgyIDE1Ni43OEMyMTQxLjE3IDE2MS43OSAyMTQxLjUgMTYzLjc0IDIxNDEuNSAxNzIuNUMyMTQxLjUgMTgxLjQzIDIxNDEuMiAxODMuMTEgMjEzOC42OSAxODguMjFDMjEzMy42NSAxOTguNDkgMjEyMi4wMSAyMDYuNDEgMjEwNy40MSAyMDkuNUMyMDk3Ljk3IDIxMS41IDIwODAuNTIgMjExLjI5IDIwNzAgMjA5LjA1Wk0yODMuNjIgMjA4LjcxQzI4MS44NiAyMDcuNDIgMjgxLjc5IDIwMy42NCAyODEuOTQgMTE2LjQzQzI4Mi4wNyAzOC4zNSAyODIuMyAyNS4yOSAyODMuNTUgMjQuMDNDMjg0Ljc5IDIyLjc5IDI5MC43MSAyMi42MSAzMjMuMjUgMjIuODZDMzYyLjkxIDIzLjE2IDM2OC45OCAyMy42OSAzNzkuNjQgMjcuOEMzOTQuMzUgMzMuNDYgNDAzIDQzLjM3IDQwNy43NSA2MEM0MTAuMiA2OC41OSA0MDkuOTEgODguNTQgNDA3LjE5IDk3Ljg5QzQwMC4yMyAxMjEuODkgMzg0LjY4IDEzNS42OCAzNTkgMTQwLjYyQzM1NC44OCAxNDEuNDEgMzQzLjAyIDE0Mi4zNSAzMzIuNjYgMTQyLjY5QzMxOC40NCAxNDMuMTcgMzEzLjQ3IDE0My42NyAzMTIuNDEgMTQ0LjczQzMxMS4yNiAxNDUuODkgMzExIDE1MS43OSAzMTEgMTc3LjFDMzExIDIwMSAzMTAuNzEgMjA4LjE1IDMwOS43NSAyMDguNDVDMzA5LjA2IDIwOC42NiAzMDMuMzIgMjA5LjExIDI5Ni45OCAyMDkuNDVDMjg4LjE0IDIwOS45MSAyODUuMDMgMjA5Ljc0IDI4My42MiAyMDguNzFaTTQ0NC41MyAyMDkuMjFDNDM4LjkxIDIwOS4wMiA0MzUuMjggMjA4LjQ3IDQzNC44IDIwNy43MUM0MzMuNjYgMjA1LjkyIDQzMy44NCA3Ny44MSA0MzQuOTggNzZDNDM1LjcyIDc0LjgzIDQzOC4zOCA3NC40NyA0NDcuMTggNzQuMzZDNDUzLjM3IDc0LjI4IDQ1OS40NiA3NC42IDQ2MC43MiA3NS4wOEw0NjMgNzUuOTVMNDYzIDE0MS4zNUM0NjMgMTkyLjQ4IDQ2Mi43MyAyMDYuOTcgNDYxLjc1IDIwNy43NEM0NjAgMjA5LjEyIDQ1NC45NiAyMDkuNTUgNDQ0LjUzIDIwOS4yMVpNNDkxLjI1IDIwOS4yNkM0ODYuNDggMjA5LjA2IDQ4MyAyMDguNDkgNDgzIDIwNy44OUM0ODMgMjA2LjY0IDQ4NS42OSAyMDIuNjYgNTAxLjg0IDE4MEM1MDguNzEgMTcwLjM4IDUxNi44OCAxNTguOSA1MjAuMDIgMTU0LjVDNTI3LjI1IDE0NC4zNSA1MjkuMTcgMTQwLjYgNTI4LjI5IDEzOC4zMUM1MjcuOTEgMTM3LjMyIDUyMS43NCAxMjguMTggNTE0LjU5IDExOEM1MDcuNDQgMTA3LjgzIDQ5OC4zMSA5NC44MyA0OTQuMjkgODkuMTNDNDkwLjI4IDgzLjQyIDQ4NyA3Ny45NCA0ODcgNzYuOTVDNDg3IDc0LjU2IDQ5NC42NyA3My41NyA1MDguMTkgNzQuMjJDNTIwLjI2IDc0LjggNTE4LjcxIDczLjQ5IDUzMy44NiA5NkM1NDYuNjQgMTE0Ljk4IDU0Ny4zOSAxMTYgNTQ4LjY2IDExNkM1NTAuNjcgMTE2IDU1Ni4xMyAxMDguODcgNTY2LjQyIDkyLjgzQzU3Mi4zMSA4My42NyA1NzcuODggNzUuODcgNTc4LjgxIDc1LjUxQzU3OS43NCA3NS4xNSA1ODYuMTIgNzQuNjEgNTkzIDc0LjMyQzYwMy4wMiA3My45IDYwNS44OCA3NC4wOSA2MDcuNDIgNzUuMjZDNjA5LjI4IDc2LjY4IDYwOS4yNSA3Ni44NCA2MDYuMzYgODEuMTFDNjAyLjU1IDg2LjczIDU5Ny40NyA5My45OCA1ODguNTcgMTA2LjVDNTgwLjMyIDExOC4xIDU3OS45NSAxMTguNjMgNTcyIDEzMC4zNUM1NjguNyAxMzUuMjIgNTY2IDE0MC4wNyA1NjYgMTQxLjEyQzU2NiAxNDMuNzIgNTY4LjM1IDE0Ny4zMiA1OTAuMDUgMTc4LjAyQzYwMC4zNyAxOTIuNjEgNjA4Ljk2IDIwNS40MyA2MDkuMTUgMjA2LjUyQzYwOS40NyAyMDguMzUgNjA4LjcxIDIwOC41NiA1OTkgMjA5LjI2QzU5My4yMyAyMDkuNjggNTg2LjIgMjA5Ljc3IDU4My4zOCAyMDkuNDZDNTc3LjcyIDIwOC44MyA1NzUuMzQgMjA2LjkgNTcwLjQ2IDE5OUM1NjUuNDYgMTkwLjkgNTUxLjc1IDE3MS41NyA1NDguNjQgMTY4LjI1QzU0Ni45NyAxNjYuNDYgNTQ1LjA4IDE2NSA1NDQuNDMgMTY1QzU0My4zMiAxNjUgNTM5LjIgMTcwLjg1IDUyMy42NyAxOTQuNUM1MjAuMDYgMjAwIDUxNi40MyAyMDUuMzQgNTE1LjYgMjA2LjM4QzUxNC4wMSAyMDguMzUgNTA3LjU0IDIxMC4wMiA1MDIuNSAyMDkuNzVDNTAwLjg1IDIwOS42NiA0OTUuNzkgMjA5LjQ0IDQ5MS4yNSAyMDkuMjZaTTc4NS4zOCAyMDguNjNDNzc1LjU3IDIwNi4xNyA3NjguNjIgMjAwLjc2IDc2NS4zMyAxOTNDNzYxLjI0IDE4My4zNSA3NjEgMTc4LjA2IDc2MSA5Ni4xMUM3NjEgMzYuMjIgNzYxLjI5IDE3LjExIDc2Mi4yIDE2LjJDNzYzLjkxIDE0LjQ5IDc4Ny41OCAxNC41MyA3ODguNjkgMTYuMjVDNzg5LjE0IDE2Ljk0IDc4OS41IDUyLjYgNzg5LjUgOTUuNUM3ODkuNSAxNzIuMTIgNzg5LjU0IDE3My41NiA3OTEuNTMgMTc3Qzc5NC4zMSAxODEuNzggNzk5LjEgMTg0IDgwNi42NyAxODRDODE2LjI1IDE4NCA4MTYgMTgzLjY5IDgxNiAxOTUuNDJDODE2IDIwOS41NCA4MTYuMDkgMjA5LjQ1IDgwMS44MyAyMDkuNzFDNzk1LjIyIDIwOS44MyA3ODguMzcgMjA5LjM4IDc4NS4zOCAyMDguNjNaTTEwNTEgMjA4LjQxQzEwNDMuNjIgMjA2LjQ2IDEwMzUuMjQgMjAwLjU4IDEwMzEuNzMgMTk0Ljg5QzEwMjYuMzggMTg2LjIyIDEwMjUuODcgMTgxLjkzIDEwMjUuMyAxNDAuNUMxMDI0LjkzIDExMy43MSAxMDI0LjQ0IDEwMi4yOCAxMDIzLjY0IDEwMS43N0MxMDIzLjAxIDEwMS4zNiAxMDE5LjM1IDEwMS4wMiAxMDE1LjUgMTAxQzEwMDMuNCAxMDAuOTUgMTAwNCAxMDEuNjMgMTAwNCA4Ny45OUMxMDA0IDc0LjQzIDEwMDMuNTYgNzUuMDEgMTAxMy44MyA3NC45OUMxMDI1Ljg4IDc0Ljk2IDEwMjUgNzYuNCAxMDI1IDU2LjU4QzEwMjUgNDQuNTUgMTAyNS4zNiAzOS4wNCAxMDI2LjIgMzguMkMxMDI3LjQ2IDM2Ljk0IDEwNDguNCAzNi40NSAxMDUxLjQyIDM3LjYxQzEwNTIuNzggMzguMTMgMTA1MyA0MC42NiAxMDUzIDU1LjU1QzEwNTMgNjguMzIgMTA1My4zMyA3My4wOSAxMDU0LjI1IDczLjY4QzEwNTQuOTQgNzQuMTIgMTA2Mi43IDc0LjYzIDEwNzEuNSA3NC44MkMxMDg0Ljc3IDc1LjEgMTA4Ny43MSA3NS40MyAxMDg4Ljc1IDc2Ljc3QzEwOTAuNjUgNzkuMjMgMTA5MC40MiA5NS45NSAxMDg4LjQ0IDk4Ljc4QzEwODYuOTUgMTAwLjkxIDEwODYuMjUgMTAxIDEwNzEuNjkgMTAxLjAyQzEwNjMuMzQgMTAxLjAyIDEwNTUuODMgMTAxLjQ2IDEwNTUgMTAxLjk4QzEwNTMuNzEgMTAyLjc5IDEwNTMuNSAxMDcuODggMTA1My41IDEzNy43MUMxMDUzLjUgMTcwLjg0IDEwNTMuNiAxNzIuNjcgMTA1NS41MyAxNzZDMTA1OC45IDE4MS44MyAxMDYzLjY1IDE4My41IDEwNzYuNzkgMTgzLjVDMTA4NS43MyAxODMuNSAxMDg4LjI3IDE4My44MSAxMDg5LjAyIDE4NUMxMDg5LjU0IDE4NS44MiAxMDg5Ljk4IDE5MC4zNCAxMDg5Ljk4IDE5NS4wM0MxMDkwIDIwNC45NiAxMDg4LjgyIDIwNy40IDEwODMuMjIgMjA4Ljk2QzEwNzcuNzggMjEwLjQ3IDEwNTcuNDcgMjEwLjEzIDEwNTEgMjA4LjQxWk0xNzQ0LjIzIDIwOC40NUMxNzI5Ljc5IDIwNC41NiAxNzIyLjQ5IDE5Ni4yOCAxNzE5LjQzIDE4MC4zMUMxNzE4LjM2IDE3NC42OSAxNzE4IDE2NC4wMSAxNzE4IDEzNy41MkMxNzE4IDk3LjI2IDE3MTkuMDMgMTAxIDE3MDcuOTggMTAxQzE3MDQuMjEgMTAxIDE2OTkuOTggMTAwLjU2IDE2OTguNTcgMTAwLjAyQzE2OTYuMDYgOTkuMDcgMTY5NiA5OC44MiAxNjk2IDg5LjU5QzE2OTYgODQuMzkgMTY5Ni40MyA3OC45OSAxNjk2Ljk2IDc3LjZDMTY5Ny44OSA3NS4xNiAxNjk4LjI4IDc1LjA2IDE3MDcuNzEgNzQuNzlMMTcxNy41IDc0LjVMMTcxNy43NyA1Ny4wN0MxNzE3Ljk0IDQ2LjI5IDE3MTguNDcgMzkuMTQgMTcxOS4xNSAzOC4zMkMxNzIwLjYyIDM2LjU1IDE3NDMuMDQgMzYuNDQgMTc0NC44IDM4LjJDMTc0NS42NCAzOS4wNCAxNzQ2IDQ0LjQ3IDE3NDYgNTYuMjJMMTc0NiA3My4wNUwxNzQ4LjU3IDc0LjAyQzE3NDkuOTggNzQuNTYgMTc1Ny44MiA3NSAxNzY2LjAxIDc1QzE3NzQuMTkgNzUgMTc4MS4xNCA3NS40MiAxNzgxLjQ2IDc1Ljk0QzE3ODEuNzggNzYuNDUgMTc4Mi4xNCA4MS42NiAxNzgyLjI3IDg3LjUxQzE3ODIuNTcgMTAxLjQ1IDE3ODMuMTkgMTAxIDE3NjMuODggMTAxQzE3NTEuNTkgMTAxIDE3NDguODcgMTAxLjI3IDE3NDcuNTEgMTAyLjYzQzE3NDYuMDYgMTA0LjA5IDE3NDUuOTIgMTA3Ljk3IDE3NDYuMTkgMTM5LjA2QzE3NDYuNDkgMTcyLjM2IDE3NDYuNTkgMTczLjk4IDE3NDguNTYgMTc2LjYxQzE3NTIuNTggMTgxLjk5IDE3NTcuMjYgMTgzLjUgMTc2OS45MyAxODMuNUMxNzgzLjA3IDE4My41IDE3ODIuNDkgMTgzLjAxIDE3ODIuODUgMTk0LjVDMTc4My4yOSAyMDguMTIgMTc4MC43MSAyMTAuMDMgMTc2MiAyMDkuOTNDMTc1NC41NiAyMDkuODkgMTc0Ny4zNyAyMDkuMjkgMTc0NC4yMyAyMDguNDVaTTEzMzMuMzUgMjA3LjQ4QzEzMzMuMDMgMjA2LjY0IDEzMzMuMjYgMTkwLjExIDEzMzMuODYgMTcwLjczQzEzMzQuNDcgMTUxLjM1IDEzMzUuMjQgMTEwLjk4IDEzMzUuNTkgODFDMTMzNi4xMyAzMy44NiAxMzM2LjQzIDI2LjI2IDEzMzcuNzYgMjQuNzVDMTMzOS43NyAyMi40OCAxMzU0LjY0IDIyLjI0IDEzNTguODMgMjQuNDFDMTM2MS43MiAyNS45IDEzNjIuNjEgMjYuOTcgMTM4Mi43NCA1My4wMUMxMzk5LjUgNzQuNjkgMTQxOC44MiA5OC45NSAxNDI0LjMzIDEwNS4yNUMxNDI1LjY1IDEwNi43NiAxNDI3LjQyIDEwOCAxNDI4LjI3IDEwOEMxNDI5LjExIDEwOCAxNDMyLjAyIDEwNS40MSAxNDM0LjczIDEwMi4yNUMxNDM5Ljg2IDk2LjI4IDE0NzAuMDQgNTguMDEgMTQ4NS40MiAzOEMxNDkwLjI4IDMxLjY4IDE0OTUuMjEgMjUuNzIgMTQ5Ni4zOCAyNC43N0MxNDk5LjExIDIyLjU1IDE1MTcuMDggMjIuMTkgMTUxOC44NSAyNC4zMkMxNTE5LjU5IDI1LjIxIDE1MjAuMTMgMzkuMzggMTUyMC41IDY3LjU3QzE1MjAuOCA5MC42MyAxNTIxLjU0IDEzMS4yMiAxNTIyLjE0IDE1Ny43NkMxNTIzIDE5NS45MSAxNTIyLjk3IDIwNi4zMyAxNTIxLjk5IDIwNy41MUMxNTIwLjM1IDIwOS40OSAxNDk1LjggMjA5LjcgMTQ5NC4yNiAyMDcuNzVDMTQ5My42NSAyMDYuOTggMTQ5My4wMyAxODMuMzcgMTQ5Mi42NCAxNDYuMzRDMTQ5Mi4yOSAxMTMuMjUgMTQ5MS43MyA4NS40NiAxNDkxLjQgODQuNTlDMTQ5MS4wNiA4My43MiAxNDkwIDgzIDE0ODkuMDQgODNDMTQ4Ni45OCA4MyAxNDgyLjY3IDg3Ljg4IDE0NjEuMjEgMTE0LjVDMTQyNy41MyAxNTYuMjkgMTQyOS40IDE1NC4zNSAxNDI1LjA4IDE1Mi4wNEMxNDIzLjM5IDE1MS4xNCAxNDEwLjk1IDEzNi4zNSAxNDAwLjczIDEyMy4xQzEzOTkuMiAxMjEuMTIgMTM5Ny40IDExOC44MyAxMzk2LjcyIDExOEMxMzk2LjA0IDExNy4xNyAxMzkwLjA5IDEwOS43NSAxMzgzLjUxIDEwMS41QzEzNjguNjcgODIuOSAxMzY3LjE2IDgxLjQxIDEzNjUuMTcgODMuNEMxMzYzLjkzIDg0LjY0IDEzNjMuNjIgOTQuMjEgMTM2My4xIDE0NS45N0MxMzYyLjU4IDE5OC43NSAxMzYyLjMgMjA3LjIgMTM2MSAyMDguMDJDMTM2MC4xNyAyMDguNTQgMTM1My43NSAyMDguOTggMTM0Ni43MiAyMDguOThDMTMzNi4xOSAyMDkgMTMzMy44MyAyMDguNzMgMTMzMy4zNSAyMDcuNDhaTTE5NjEuMjYgMjA3Ljc1QzE5NjAuNjMgMjA2Ljk2IDE5NjAuMjcgMTgyLjgzIDE5NjAuMjggMTQxLjc1QzE5NjAuMjkgODYuNTUgMTk2MC41IDc2LjgzIDE5NjEuNzMgNzUuODFDMTk2My41NyA3NC4yOCAxOTgyLjU3IDc0LjE3IDE5ODQuMDcgNzUuNjdDMTk4NC42NiA3Ni4yNiAxOTg1LjQ3IDc5LjE3IDE5ODUuODcgODIuMTRDMTk4Ni42NCA4Ny44MiAxOTg3LjUyIDkwIDE5ODkuMDIgOTBDMTk4OS41MiA5MCAxOTkzLjQ3IDg3LjU4IDE5OTcuODEgODQuNjNDMjAwNi44MSA3OC41IDIwMTQuMDYgNzUuNTEgMjAyMy41OCA3My45OUMyMDMzLjA1IDcyLjQ3IDIwMzMgNzIuMzkgMjAzMyA4OS4xNUwyMDMzIDEwMy4wNUwyMDMwLjQzIDEwNC4wMkMyMDI5LjAyIDEwNC41NiAyMDI1LjE4IDEwNSAyMDIxLjkgMTA1QzIwMTEuNzYgMTA1IDE5OTguMDIgMTEwLjggMTk5Mi4wNiAxMTcuNTlMMTk4OSAxMjEuMDdMMTk4OSAxNjMuODNDMTk4OSAxOTUuNzYgMTk4OC43IDIwNi45IDE5ODcuOCAyMDcuOEMxOTg2LjA5IDIwOS41MSAxOTYyLjYxIDIwOS40NiAxOTYxLjI2IDIwNy43NVpNMzMuNjcgMTkxLjMzQzMzLjMgMTkwLjk3IDMzIDE4Ni42OSAzMyAxODEuODNMMzMgMTczTDQyLjUgMTczTDUyIDE3M0w1MiAxODIuNUw1MiAxOTJMNDMuMTcgMTkyQzM4LjMxIDE5MiAzNC4wMyAxOTEuNyAzMy42NyAxOTEuMzNaTTExODYuMDcgMTgzLjA3QzExOTguMTEgMTc4LjU2IDEyMDYuMzkgMTY3LjQ5IDEyMDkuNjcgMTUxLjVDMTIxNi4wMyAxMjAuNSAxMTkyLjQ1IDk1LjE2IDExNjQgMTAyLjQxQzExNTEuOCAxMDUuNTIgMTE0My4xOSAxMTMuNTQgMTEzOC42OCAxMjZDMTEzMC44NyAxNDcuNTggMTEzNy45IDE3Mi43OSAxMTU0LjAzIDE4MS4wMUMxMTY0LjM3IDE4Ni4yOSAxMTc1LjU2IDE4Ny4wMSAxMTg2LjA3IDE4My4wN1pNMTYyOSAxODMuNTJDMTYzOS43NyAxNzkuNzMgMTY0OC4zOCAxNzAuMDYgMTY1Mi4xMiAxNTcuNTVDMTY1My44MiAxNTEuODggMTY1NC4xNCAxNDguNTcgMTY1My44NSAxMzkuNTNDMTY1My41MyAxMjkuMzcgMTY1My4yMyAxMjcuOSAxNjUwLjE2IDEyMS42OEMxNjQ2LjA5IDExMy40IDE2NDAuMyAxMDcuNDkgMTYzMy4wMSAxMDQuMTdDMTYyOC40MyAxMDIuMDggMTYyNS45IDEwMS42NSAxNjE4IDEwMS41OEMxNjA3Ljc1IDEwMS40OSAxNjA0LjY4IDEwMi4zMiAxNTk2LjY4IDEwNy4zQzE1ODQuMjIgMTE1LjA1IDE1NzcuNjUgMTMyLjc3IDE1ODAuMDcgMTUyLjA4QzE1ODIuMjggMTY5LjczIDE1OTEuNzIgMTgwLjg3IDE2MDcuOTggMTg1LjAzQzE2MTIuNzIgMTg2LjI0IDE2MjMuNDggMTg1LjQ3IDE2MjkgMTgzLjUyWk0xODc4LjE4IDE4My4zMkMxODg4Ljc4IDE3OS4xNiAxODk2LjA0IDE3MS40NCAxOTAwLjU5IDE1OS41QzE5MDIuMTEgMTU1LjUxIDE5MDIuNSAxNTEuODcgMTkwMi41IDE0MS41QzE5MDIuNSAxMjguOTEgMTkwMi40IDEyOC4yOSAxODk5LjMxIDEyMkMxODk1LjMyIDExMy44NyAxODg5LjEyIDEwNy40OCAxODgyIDEwNC4xNkMxODc3LjI4IDEwMS45NiAxODc1LjA4IDEwMS41OSAxODY2LjQ4IDEwMS41NUMxODU3LjEyIDEwMS41IDE4NTYuMDIgMTAxLjcyIDE4NDkuNzcgMTA0Ljc5QzE4MzUuOTkgMTExLjU3IDE4MjguNTkgMTI1LjQ2IDE4MjguNTkgMTQ0LjVDMTgyOC41OSAxNjEuOTcgMTgzNS4yOSAxNzUuNDUgMTg0Ni45OSAxODEuNUMxODU2LjA0IDE4Ni4xOCAxODY4Ljk5IDE4Ni45MyAxODc4LjE4IDE4My4zMlpNNjAgMTU1LjEyTDYwIDE0My4xOEw2OC4yNSAxNDIuNUM3Mi43OSAxNDIuMTMgNzguMDggMTQxLjk3IDgwIDE0Mi4xNkw4My41IDE0Mi41TDgzLjUgMTU0LjVMODMuNSAxNjYuNUw3MS43NSAxNjYuNzhMNjAgMTY3LjA2TDYwIDE1NS4xMlpNNC45NSAxNjQuNjJDNC43IDE2NC4zNyA0LjY0IDE1OS44NiA0LjgyIDE1NC41OUw1LjE0IDE0NUwxNC41NyAxNDVMMjQgMTQ1TDI0IDE1NC41TDI0IDE2NEwxNi4wOCAxNjRDMTEuNzMgMTY0IDcuNTQgMTY0LjI0IDYuNzggMTY0LjUzQzYuMDIgMTY0LjgyIDUuMTkgMTY0Ljg2IDQuOTUgMTY0LjYyWk0zMiAxMjcuNUwzMiAxMTUuOTNMNDIuNzIgMTE2LjIyTDUzLjQ1IDExNi41TDU0LjIgMTIwLjkyQzU0LjYxIDEyMy4zNSA1NC42MyAxMjguMyA1NC4yMyAxMzEuOTJMNTMuNSAxMzguNUw0Mi43NSAxMzguNzhMMzIgMTM5LjA3TDMyIDEyNy41Wk0xMzkgMTI2Ljk3TDEzOSAxMTQuOTRMMTI1Ljc1IDExNS4yMkwxMTIuNSAxMTUuNUwxMTIuMjIgMTI3LjI1TDExMS45NCAxMzlMMTI1LjQ3IDEzOUwxMzkgMTM5TDEzOSAxMjYuOTdaTTcwNi44OSAxMjcuMDlDNzEwLjAyIDEyNS4xMSA3MDYuMTUgMTEyLjEzIDcwMC42NiAxMDYuMTVDNjkyLjg0IDk3LjY0IDY3NC4xNyA5Ni44MiA2NjIuMDkgMTA0LjQ0QzY1NS4xIDEwOC44NSA2NDcuNTIgMTE5LjY4IDY0OC4xOSAxMjQuMjlDNjQ4LjM4IDEyNS41NiA2NDkuNSAxMjYuNjkgNjUxIDEyNy4xMUM2NTQuNzEgMTI4LjEzIDcwNS4yNyAxMjguMTEgNzA2Ljg5IDEyNy4wOVpNMzU4LjQ1IDExNC4wMkMzNjUuMTQgMTExLjQyIDM3My4yMiAxMDQuNDkgMzc1Ljk0IDk5LjAyQzM4MC4wMiA5MC44MyAzODAuODcgNzguNiAzNzguMDMgNjguODhDMzc1LjUzIDYwLjI5IDM2OC41NiA1NC4wOSAzNTguNTQgNTEuNTJDMzUwLjkxIDQ5LjU3IDMxNC4wOSA0OS4zMSAzMTIuMiA1MS4yQzMxMS4zMiA1Mi4wOCAzMTEgNjAuNjggMzExIDgzLjY4TDMxMSAxMTQuOTZMMzEzLjc1IDExNS41N0MzMTUuMjYgMTE1LjkgMzE2Ljk1IDExNi4zNSAzMTcuNSAxMTYuNTdDMzE4LjA1IDExNi43OCAzMjYuMTUgMTE2LjgxIDMzNS41IDExNi42NEMzNTAuNDIgMTE2LjM3IDM1My4yMyAxMTYuMDQgMzU4LjQ1IDExNC4wMlpNNSAxMDBMNSA5MEwxNC41IDkwTDI0IDkwTDI0IDEwMEwyNCAxMTBMMTQuNSAxMTBMNSAxMTBMNSAxMDBaTTYxIDEwMEw2MSA5MEw3MSA5MEw4MSA5MEw4MSAxMDBMODEgMTEwTDcxIDExMEw2MSAxMTBMNjEgMTAwWk01OS40MyA4Mi42OUM1OS4xNiA4MS45NiA1OS4wNiA3Ni40NSA1OS4yMSA3MC40NEw1OS41IDU5LjVMNzEuMjUgNTkuMjJMODMgNTguOTRMODMgNzEuNDdMODMgODRMNzEuNDcgODRDNjIuODcgODQgNTkuODEgODMuNjcgNTkuNDMgODIuNjlaTTMzLjIxIDcyLjI1TDMzLjUgNjIuNUw0Mi41IDYyLjVMNTEuNSA2Mi41TDUxLjc5IDcyLjI1TDUyLjA3IDgyTDQyLjUgODJMMzIuOTMgODJMMzMuMjEgNzIuMjVaTTk3LjIyIDU3LjI2TDg1Ljk0IDU2LjkzTDg2LjIyIDQ0LjIyTDg2LjUgMzEuNUw5OS4yOCAzMS40MUwxMTIuMDYgMzEuMzNMMTExLjc4IDQ0LjRDMTExLjU1IDU1LjIgMTExLjI0IDU3LjQ5IDExMCA1Ny41M0MxMDkuMTcgNTcuNTYgMTAzLjQyIDU3LjQ0IDk3LjIyIDU3LjI2Wk00NDEuNzggNTYuODVDNDM2LjU2IDU1LjQgNDMxLjMyIDQ5LjMzIDQzMC40OSA0My43NkM0MjguNjUgMzEuNTIgNDM3LjY2IDIxLjA4IDQ1MC4xIDIxLjAyQzQ1NS40OCAyMSA0NjAuMjUgMjMuNzYgNDYzLjczIDI4LjkyQzQ2Ny4yIDM0LjA4IDQ2Ni45NCA0My4xMyA0NjMuMTMgNDkuMTNDNDU4Ljc5IDU1Ljk4IDQ1MC4wNSA1OS4xNCA0NDEuNzggNTYuODVaTTEzLjE2IDUzLjIxQzUuMjIgNTIuOTcgMy45IDUyLjY5IDQuNDEgNTEuMzhDNC43MyA1MC41MyA1IDQ2LjI3IDUgNDEuOTJMNSAzNEwxNC41IDM0TDI0IDM0TDI0IDQzLjQ0QzI0IDQ4LjYzIDIzLjY2IDUzLjAyIDIzLjI1IDUzLjE5QzIyLjg0IDUzLjM2IDE4LjMgNTMuMzcgMTMuMTYgNTMuMjFaTTYwIDE3TDYwIDVMNzEuNSA1TDgzIDVMODMgMTdMODMgMjlMNzEuNSAyOUw2MCAyOUw2MCAxN1oiLz48L3N2Zz4K" alt="Pixels to Motors" width="151" height="16"></a>
<h2>ロボットの台数</h2><div class="seg" id="c-robots"></div>
<h2>スタッフ</h2><div class="seg" id="c-staff"></div>
<h2>混み具合</h2><div class="seg" id="c-crowd"></div>
<h2>速さ</h2><div class="seg" id="c-speed"></div>
<div class="row"><button id="play">一時停止</button><button id="restart">最初から</button></div>
<h2>日を変える</h2><div class="day"><button id="dayPrev" aria-label="前の日">‹</button><b id="dayLabel">1日目</b><button id="dayNext" aria-label="次の日">›</button></div><p class="note">条件はそのままで、お客さんの来る時刻や人数が違う日を見られます。同じ日なら、何度見ても同じ動きになります。</p>
<div id="status"></div>
<h2>いまの数字</h2><p class="note" id="phase"></p>
<div class="nums">
<div class="num"><b><span id="n-occ">0</span><small>卓</small></b><span>使っている卓</span></div>
<div class="num"><b><span id="n-waiting">0</span><small>組</small></b><span>待合で待っている</span></div>
<div class="num"><b><span id="n-seatwait">0</span><small>分</small></b><span>入店待ち（来てから席に着くまで）</span></div>
<div class="num"><b><span id="n-paywait">0</span><small>分</small></b><span>会計待ち</span></div>
<div class="num"><b><span id="n-delivered">0</span><small>皿</small></b><span>届けた料理</span></div>
<div class="num"><b><span id="n-robot">0</span><small>%</small></b><span>そのうちロボット</span></div>
<div class="num"><b><span id="n-wait">0</span><small>分</small></b><span>料理ができてから届くまで</span></div>
<div class="num"><b><span id="n-stops">0</span><small>回</small></b><span>ロボットが止まった</span></div>
<div class="num"><b><span id="n-helps">0</span><small>回</small></b><span>人が動かしに行った</span></div>
<div class="num"><b><span id="n-stuck">0</span><small>回</small></b><span>立ち往生（30秒）</span></div>
</div>
<h2>起きたこと</h2><ul id="log"></ul>
</aside>
<script type="module">/*BUNDLE*/</script>
</body></html>
```

### 1本にまとめるコマンド

```bash
# ブラウザ版の試作を1本にまとめる（作業フォルダ /home/claude/proto）
cd /home/claude/proto
npm install three@0.181.2 recast-navigation@0.44.0 esbuild@0.28.2
npx esbuild src/main.js --bundle --format=esm --minify --loader:.json=json --external:node:zlib --outfile=dist/app.js
python3 - <<'PY'
s = open('shell.html').read()
js = open('dist/app.js').read()
open('dist/index.html', 'w').write(s.replace('/*BUNDLE*/', js))
PY
ls -la dist/app.js dist/index.html
```

店のデータは、サイトの `public/sim/layouts/unity/` の6店（`p0_memory`〜`p5_crammed` と、それぞれの `_map.json`）を `stores/` に写して使っています。サイトの店の形の版は 9b8dae7ebf8c です。


## 作業ログ ― 画面を直した：Unity の色と形・いまの仕事のパネル・時刻のスライダー・スマホ・問い合わせ・ロゴ

試作は、まず点と箱だけで動きを確かめ、そのあと画面を作り込みました。ここからは、エイイチが画面を見て出した指示と、それで変えたことです。

### 人の色と形を、Unity のプレハブとマテリアルの値にそろえた

最初の画面では、客とスタッフの色と形を推し量って描いていました。Unity の ServiceSim のものと違う、と指摘を受け、Unity のプレハブ（人やロボットの型）とマテリアル（色）の値を読んで、そのまま使いました。

| 物 | 色（Unity の _Color） | 形 |
|---|---|---|
| 客 | #facc21（黄） | カプセルの体＋球の頭 |
| スタッフ | #1761e6（青） | カプセルの体＋球の頭 |
| 待合の客（1組に1人） | #8c4dbf（紫） | 同じ |
| ロボット | #f5f5f7（白） | 円柱＋前の向きの印 #ff540a |
| 床・壁・卓・ベンチ・椅子・背もたれ | #dbd6c9・#666e7a・#cc7d36・#9e544a・#215e6b・#8a909a | 箱 |

### ロボットとスタッフの「いまの仕事」を、3Dの右下に重ねて出した

> ロボットが何をしているかわからないのでパネルで表示させて（料理運んでる？お皿下げてる？）

ロボットとスタッフの番号ごとに、いまの仕事を1行で出すパネルを作りました（「卓T34で料理を渡している（あと20秒）」「受け渡し口へ料理を取りに行く（卓T35の分）」「持ち場で待つ」など）。置き場所は、左の列を作らない条件で3案（右の操作パネルの一番上・3Dの下に横長の帯・3Dの右下に重ねる）を出し、3Dの右下に重ねる案になりました。

- ねらい：3Dから目を離さずに、ロボットが何をしているかを読めるようにする
- 変わったこと：3Dの中の番号（1・2・S1〜S3）と、パネルの行が対になった
- 見込める効果：詰まった場面で「どの台が、何を運ぼうとして止まったか」を、映像と文の両方で追える（見込み。見る人に確かめてはいない）

### スペースキーを押しながらドラッグで、3Dの位置を動かせるようにした

ドラッグはカメラを回す、ホイールは寄る、に加えて、スペースキーを押しながらドラッグすると3Dの絵を平行に動かせるようにしました。スマホは2本指で寄る・動かすです。

### 店の名前を副題にして、6店を切り替えられるようにした

題名は「配膳ロボット ブラウザ版（試作）」にし、店の名前（③ 効率重視・入口が左下の角 など）をその下の選ぶ欄にしました。選ぶと、その店のデータを読み直して1日を回し直します。

### 時間とともに、席の客が増えたり減ったりするようにした

> 時間経過とともにお客さんの入り方が変わる（席のお客さんが減ったり、増えたり）のはできる？

最初の試作は、決まった数の客が席にいるだけでした。サイトの試算と同じく、空の店から始めてピークの120分だけ来客し、食べ終えた組から会計して帰り、全員が帰るまで回す形にしました。いまの数字のパネルの「使っている卓」（例 11/42卓）が、時間とともに増えて減ります。

### 1日の好きな時刻へ、スライダーで動けるようにした

3Dの下にスライダーを置きました。計算は画面の裏で先へ進み、計算が済んだ所（「計算中…（43:53 まで）」）までなら、好きな時刻へ戻ったり進んだりできます。1秒ごとの記録の間を、なめらかにつないで描きます。

### 「種」を「日を変える」に言い換えた

> 種のことが初見の人はなんのことかわかんないね。

乱数の種（seed）を入れる欄を、「日を変える」の ‹ 1日目 › に置き換え、「条件はそのままで、お客さんの来る時刻や人数が違う日を見られます。同じ日なら、何度見ても同じ動きになります。」と書き添えました。

### スマホでは、3D・スライダー・いまの仕事・操作の順に縦に並べた

幅760px 以下では、3Dを画面の高さの45%にし、その下にスライダー・カメラのボタン・いまの仕事・色の説明・操作を縦に並べます。起きたことの時刻の数字がスマホで明朝体になっていたので、Inter と Noto Sans JP をページで読むようにしました。

![スマホ（幅390px）で見たブラウザ版の全体。上から題名と店を選ぶ欄、3D、時刻のスライダー、カメラのボタン、いまの仕事、色の説明、操作のボタン、日を変える、いまの数字](https://pixelstomotors.com/media/LOG506_02_sp.jpg)

### 問い合わせの入口は、ロボットが止まった瞬間にその頭の上へ出す

問い合わせの入口は、3Dの上に半透明で小さく置く、という指示で、まず3案、続けて別案4つを出しました。選ばれたのは「問題が起きた瞬間に出る」案で、その見た目をさらに4案出し、ロボットの頭の上の吹き出しになりました。吹き出しは、立ち往生したロボットについて動き、少なくとも6秒出します。押すと、サイトのメッセージのページ（話題はフィジカルAI）が開きます。

- ねらい：ずっと出ている「相談する」のボタンにせず、見る人が「うちの店ならどうなる？」と思う所でだけ入口を出す
- 変わったこと：入口が出るのは、ロボットが30秒止まって立ち往生した時だけ
- 見込める効果：押されたかは、公開したあとのメッセージのページの記録で確かめる（いまは試作で公開していないので、数字はない）

### ロゴは、右の操作パネルの一番上（スマホはページの一番下）

Pixels to Motors のロゴは、最初に題名の札の中に入れましたが、置き場所がいまいちと言われ、6案を出して、右の操作パネルの一番上（スマホはページの一番下）になりました。あわせて、操作パネルの説明の文（この試作について）を外し、いまの数字の行の間を詰めました。

4か所で並べた案と、選んだ案です。

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

いまの PC の画面です。

![PC で見たブラウザ版。③ 効率重視・入口が左下の角、ロボット2台・スタッフ3人・混んでいる、店の時間で8分52秒。右上に色の説明、右下にいまの仕事、右の列に操作といまの数字、3Dの下に時刻のスライダー](https://pixelstomotors.com/media/LOG506_01_pc.jpg)

⓪の店（記憶の店）に切り替えた所です。

![ブラウザ版で⓪の店を斜め左30°から見た所。ロボット3台・混んでいる](https://pixelstomotors.com/media/LOG506_03_store0.jpg)


## 作業ログ ― ロボット0〜3台・2つの混み具合で、1日ずつ回した数字

画面を作り終えたあと、試作の数字がどこまで使えるかを見るため、画面なしで1日を回しました。店は③、スタッフ3人、ロボット0〜3台、混み具合は普通と混んでいる、日は1〜3日目。店の時間で5時間（300分）たっても全員が帰り終わらなければ、そこで打ち切ります。

### 回したコード（bench506.mjs）

```js
import fs from 'fs'; import { createSim } from './src/sim.js';
const L=JSON.parse(fs.readFileSync('layout.json')),M=JSON.parse(fs.readFileSync('map.json'));
console.log('robots,crowd,staff,day,end_min,ms,groups,seat_wait_min,pay_wait_min,dish_wait_min,delivered,by_robot,sensor_stops,detours,helps,stucks');
for (const crowd of ['normal','busy']) for (const robots of [0,1,2,3]) for (const seed of [1,2,3]) {
  const sim=await createSim(L,M,{robots,staff:3,crowd,seed}); const t0=performance.now();
  for(let k=0;k<20*60*300;k++){ sim.step(); if (k%200===0 && sim.summary().allDone) break; }
  const s=sim.summary();
  console.log([robots,crowd,3,seed,(sim.t/60).toFixed(0),(performance.now()-t0|0),sim.groups.length,(s.avgSeatWait/60).toFixed(1),(s.avgPayWait/60).toFixed(1),(s.avgDishWait/60).toFixed(1),s.delivered,s.byRobot,s.sensorStops,s.detours,s.helps,s.stucks].join(','));
}
```

### 出力（bench506.csv）

列は、ロボットの台数・混み具合・スタッフ・日・終わった店の時間（分）・かかった時間（ms）・来た組・入店待ちの平均（分）・会計待ちの平均（分）・料理ができてから届くまでの平均（分）・届けた料理・うちロボット・センサーで止まった回数・回り込み・人が動かしに行った回数・立ち往生。

```text
robots,crowd,staff,day,end_min,ms,groups,seat_wait_min,pay_wait_min,dish_wait_min,delivered,by_robot,sensor_stops,detours,helps,stucks
0,normal,3,1,300,5826,113,31.4,14.4,1.2,226,0,0,0,0,0
0,normal,3,2,220,3775,85,8.0,19.4,1.1,170,0,0,0,0,0
0,normal,3,3,208,3670,85,8.1,12.4,1.1,170,0,0,0,0,0
1,normal,3,1,268,16451,113,24.6,22.6,2.4,226,154,133,12,3,1
1,normal,3,2,222,10548,85,5.2,21.5,2.4,170,116,96,8,1,0
1,normal,3,3,214,10460,85,6.6,15.2,2.3,170,114,121,11,5,3
2,normal,3,1,271,21462,113,32.5,19.6,2.5,226,172,533,140,69,41
2,normal,3,2,223,20672,85,8.0,19.9,2.3,170,134,586,66,30,19
2,normal,3,3,222,18166,85,7.2,20.1,2.3,170,119,546,103,55,20
3,normal,3,1,300,32467,113,33.8,19.2,3.4,226,181,1226,475,239,111
3,normal,3,2,236,29608,85,10.6,22.0,3.2,170,111,1179,185,99,33
3,normal,3,3,300,28453,85,20.2,12.0,2.8,168,45,1431,972,454,111
0,busy,3,1,300,6812,140,55.3,19.4,1.2,280,0,0,0,0,0
0,busy,3,2,263,4832,113,26.8,19.3,1.2,226,0,0,0,0,0
0,busy,3,3,241,4127,102,19.8,18.0,1.2,204,0,0,0,0,0
1,busy,3,1,300,16961,140,49.0,26.3,2.3,280,202,167,22,3,2
1,busy,3,2,264,13011,113,23.4,23.8,2.3,226,157,189,17,2,1
1,busy,3,3,248,10486,102,16.2,24.9,2.2,204,139,149,11,0,0
2,busy,3,1,300,27036,140,56.3,26.3,2.5,280,193,903,164,69,43
2,busy,3,2,274,26410,113,25.3,26.1,2.7,226,144,961,106,50,28
2,busy,3,3,248,26016,102,18.1,23.6,2.5,204,143,807,102,50,29
3,busy,3,1,300,40292,140,77.7,42.0,3.3,264,176,2141,330,127,67
3,busy,3,2,300,50594,113,5.9,0.0,16.6,85,19,1319,1106,988,417
3,busy,3,3,300,29648,102,1.9,1.5,2.5,96,19,720,14,9,5
```

### 数字から分かったこと

- 料理ができてから届くまでは、0台で約1.1〜1.2分、1台で約2.2〜2.4分、2台で約2.3〜2.7分、3台で約2.5〜3.4分（混んだ日の2日目は16.6分）。ロボット優先の決まり（2分待ってもロボットが来なければ人が運ぶ）があるので、ロボットを入れると、手の空いたスタッフがすぐ運ぶ0台より遅くなる
- 立ち往生は、1台では1日0〜3回、2台では19〜43回、3台では5〜417回。台数を増やすと、ロボットどうしの詰まりが急に増える
- 3台・混んだ日の2日目は、届けた料理が85皿（0台では226皿）で、店が5時間たっても空にならなかった。ロボットが通路で詰まり、人も料理もそこを通れなくなった
- 0台でも、1日目（来客113〜140組）は5時間で打ち切りになった。入店待ちが31〜55分になり、会計待ちも12〜19分と長い。ロボットとは別に、会計とスタッフの回し方にも、試算と合っていない所がある
- 1日を回す時間は、0台で約4〜7秒、2台で約18〜27秒、3台で約28〜51秒（サーバーの CPU）。台数と詰まりが増えるほど、道の引き直しが増えて重くなる

**ブラウザ版の試作は、条件を変えて動きを見る道具としては動いた。しかし、ロボットを増やしたときの詰まり方と会計待ちが、サイトの試算 v2.9・Unity の基準データとまだ合わない。** 数字をサイトで見せるのは、答え合わせをしてからにします。


## 作業ログ ― 公開したあと：サムネイルを、ブラウザで動く画面だと分かる絵に替えた

記事を公開したあと、スマホで記事を開いたエイイチから指摘がありました。

> サムネイルがブラウザ版をイメージできてないね

最初のサムネイルは、ブラウザ版で描いた店の3Dを、大1枚・小2枚で並べたものでした。店の絵としては Unity の回のサムネイルと見分けがつかず、「ブラウザで、自分で条件を変えて動かせる」ことが伝わっていませんでした。

![最初のサムネイル。ブラウザ版で描いた店の3Dを、左に大きく1枚、右に小さく2枚並べた](https://pixelstomotors.com/media/LOG506_04_thumb_before.jpg)

作り直したサムネイルは、ブラウザの窓の中に PC で見たブラウザ版の画面全体（ロボットの台数・混み具合のボタン、いまの数字、時刻のスライダー、いまの仕事のパネル）を入れ、右にスマホで見た同じ画面を置きました。最初の作り直しではスマホが PC の操作の列に重なったので、窓を少し狭くして重ならないようにしました。

![作り直したサムネイル。左はブラウザの窓に入れた PC の画面、右はスマホの画面](https://pixelstomotors.com/media/LOG506_05_thumb_after.jpg)

- ねらい：一覧で見た人に、ボタンを押して試せる画面だと一目で伝える
- 変わったこと：絵の主役が「店の3D」から「操作のある画面」になった
- 見込める効果：記事一覧と SNS で、Unity の回との違いが伝わる（見込み。押された数は、サイトの記録で前後を比べる）

### サムネイルの並べ方（tools/sim_thumbs/make.py に足した browser_frames）

```diff
commit 01515b5ed2ce9e59dc4a3fd9221a04b0206ca5fb
Author: Claude <noreply@anthropic.com>
Date:   Sat Nov 14 07:20:53 2026 +0900

    LOG506 のサムネイルを、ブラウザの窓の PC の画面とスマホの画面を並べる形に替えた（ブラウザ版だと分かるように）。sim_thumbs に browser_frames を足す
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01LevEXU3Z3M9LXKJxzqN9Ew

diff --git a/tools/sim_thumbs/make.py b/tools/sim_thumbs/make.py
index 185890f..d99258c 100644
--- a/tools/sim_thumbs/make.py
+++ b/tools/sim_thumbs/make.py
@@ -182,9 +182,29 @@ def stills_cols(n, ss, bg=LIGHT):
     save(c, n)
 
 
+def browser_frames(n, pc, sp, bg=LIGHT):
+    """ブラウザ版（LOG506〜）：ブラウザの窓に PC の画面、手前にスマホの画面を重ね、ブラウザで触れる試作だと分かるようにする。文字は画面の中のものだけ"""
+    from PIL import ImageDraw
+    c = Image.new('RGB', (W, H), bg); d = ImageDraw.Draw(c)
+    a = Image.open(D + pc + '.jpg').convert('RGB')
+    ww = 1380; wh = int(ww * a.size[1] / a.size[0]); bar = 46; x0, y0 = 50, (H - wh - bar) // 2 - 20
+    d.rounded_rectangle((x0 - 2, y0 - 2, x0 + ww + 2, y0 + bar + wh + 2), 16, fill=(200, 203, 210))
+    d.rounded_rectangle((x0, y0, x0 + ww, y0 + bar + wh), 14, fill=(226, 228, 233))
+    for i, col in enumerate([(236, 106, 94), (244, 191, 79), (98, 197, 84)]):
+        d.ellipse((x0 + 22 + i * 26, y0 + 16, x0 + 36 + i * 26, y0 + 30), fill=col)
+    d.rounded_rectangle((x0 + 120, y0 + 10, x0 + ww - 120, y0 + 36), 8, fill=(248, 249, 251))
+    c.paste(a.resize((ww, wh), Image.LANCZOS), (x0, y0 + bar))
+    b = Image.open(D + sp + '.jpg').convert('RGB')
+    ph = 860; pw = int(ph * b.size[0] / b.size[1]); bz = 16; px = W - pw - 2 * bz - 40; py = (H - ph) // 2 + 30
+    d.rounded_rectangle((px, py - bz, px + pw + 2 * bz, py + ph + bz), 56, fill=INK)
+    m = Image.new('L', (pw, ph), 0); ImageDraw.Draw(m).rounded_rectangle((0, 0, pw - 1, ph - 1), 42, fill=255)
+    c.paste(b.resize((pw, ph), Image.LANCZOS), (px + bz, py), m)
+    save(np.array(c), n)
+
+
 # ---- 各回（素材名, 店の範囲の中の中心 x, y, 切り出し幅の割合）
 JOBS = {
-    506: lambda n: mosaic(n, ('browser506_store3_right', .44, .5, .62), ('browser506_store0_left', .52, .5, .62), ('browser506_store2_right', .49, .53, .55)),  # ブラウザ版の試作を作った回。three.js で描いたブラウザの画面（Unity の絵ではない）。左の大＝③ 効率重視・入口が角の混んでいる時（ロボット2台・右30°）、右上＝⓪の店（左30°）、右下＝②の店（右30°）
+    506: lambda n: browser_frames(n, 'browser506_pc', 'browser506_sp'),  # ブラウザ版の試作を作った回。three.js で描いたブラウザ版の画面（Unity の絵ではない）。ブラウザの窓に PC の画面（③ 効率重視・入口が角、ロボット2台・混んでいる）、右手前にスマホの画面
     503: lambda n: stills_cols(n, [('closeup503_glance', .5, .55, .36), ('closeup503_stumble', .47, .5, .42), ('closeup503_smile', .5, .5, .5)]),  # 廊下ですれ違う場面を作った回（依頼175）。左＝ちらっとハコブくんを見る看護師（顔の寄り）、中＝よろける看護師と手前で止まるハコブくん（本番のカメラ）、右＝画面がにっこりするハコブくん（画面の寄り）
     499: lambda n: stills_split(n, ('closeup499_card_side', .52, .40, .50), ('closeup499_take_side', .60, .45, .45), r=.5),  # 受け渡しの場面を直した回（依頼171）。左＝ハコブくんの画面にカードをかざす看護師（横から）、右＝開いた引き出しに上から手を入れて取る所（横から）
     496: lambda n: still(n, 'closeup496_drawer', .47, .5, .9),  # ハコブくんと看護師の3Dを作った回。Blender で描いた、引き出しの前でかがむ看護師（LOG496 の scene_render.py。Unity の絵ではない）
```


## 作業ログ ― ブラウザ版の入口を、サイトのどこに置くかを3回考え直した

記事の公開のあと、ブラウザ版のリンクをサイトに置く形を相談されました（サイトへの反映はしない、という条件つき）。案は3回出し直しています。

最初の案は、試算の結果の下にリンク、/series8 に入口、LP は答え合わせのあと、TOP には出さない、というものでした。これに対して、次の問いが返ってきました。

> 現行サイトをよく調査しましたか？

実際には、LP の見出しの行と /series8 のテンプレートの数行しか見ていませんでした。/series8・試算のページ（/sim/serving-robot）・LP を、公開されている画面とコードの両方で見直すと、次のことが分かりました。

| 分かったこと | 案への影響 |
|---|---|
| 試算の「店の形を選ぶ」の ②〜⑦ は、ブラウザ版の6店（p0〜p5）とまったく同じ店 | 店ごとにつなげられる |
| 試算には店ごとに「平面図／Unity で動かすと」の切り替えがあり、Unity は録画した回の条件のまま（試算で選んだ人数・台数とは別） | ブラウザ版は選んだ条件のまま動く。そこが違い |
| 試算は LP の「まず、近い店舗で試す」に ?lp=1 で埋め込まれている | 試算にリンクを足すと LP にも出る |
| 試算は /series8 にも埋め込まれている | /series8 に別の入口を作らなくても出る |
| 混み具合の言葉（試算は「混雑」）、台数（試算は0〜3台＋その他）、同じ条件の数字（料理が届くまで 試算1分03秒・ブラウザ版約2.3分）がずれている | そろえないと同じ画面で食い違う |

そこで、試算の切り替えの3つ目に「ブラウザで動かす」を足す案を出しました。その答えが次の指示です。

> あくまでもまだテスト版というのをベースにしましょう。言葉と幅を試算に揃えるのは行ってください。

試算の中に置くと、同じ条件で試算と違う数字が並びます。テスト版のうちは、試算・LP・TOP には置かず、研究の記録を見に来た人が見つけて触れる /series8 に節を1つだけ置く案にし、その形で公開しました。立ち往生の吹き出しの行き先は、メッセージのページのままです。

公開後、TOP にも置くよう指示がありました。

> TOPにも置くようにしてください。なるべく上の方

FV（いちばん上の動画の画面）には「導入シミュレーション」と「研究室」の2枚の入口があり、3枚目に足すと見出しと同じ重さに見えます。そこで FV のすぐ下、スクロールして最初に出る節にしました。続けて、解説動画を下げてよいと指示がありました。

> YouTubeもっと下に下げていいよ（マンガの近く）

TOP の並びは、FV → ブラウザで動かす（テスト版）→ Pixels to Motors の1年 → 導入前ガイド → 解説動画 → 4コママンガ になりました。

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

- ねらい：テスト版を、決まった試算や LP の数字と並べずに、見つけた人が触れる形で出す
- 変わったこと：入口は /series8 の節と TOP の FV の下の2か所。試算・LP には置いていない
- 見込める効果：TOP と /series8 のどちらから何回開かれたかを、data-ev の「TOP:」の有無で分けて数えられる（公開直後なので、まだ数字はない）


## 作業ログ ― ブラウザ版を試算にそろえ、テスト版として /sim/serving-robot-3d に公開した

### 試算にそろえたこと

| 項目 | 前 | 後（試算と同じ） |
|---|---|---|
| 店の名前 | ③ 効率重視型・入口が左下の角 など（店のデータの name） | ③ 効率重視・入口が角 など（試算の short） |
| スタッフ | 2・3・4人 | 店ごとの3つのボタン＋その他（③なら3・4・5人、その他で2〜8人） |
| ロボット | 0〜6台のボタン | 0〜3台のボタン＋その他（4〜6台） |
| 混み具合 | 空いている・普通・混んでいる | 空いている・普通・混雑 |
| 来客のペース | 標準35卓の 25・35・45組/時を卓の数で増やす | 試算の店ごとの値（③なら30・42・54組/時） |
| 題名 | 配膳ロボット ブラウザ版（試作） | 配膳ロボット ブラウザ版［テスト版］の札 |
| いまの数字 | 説明なし | 「テスト版の数字です。試算（v2.9）とは別に、人とロボットを動かして数えています（答え合わせ前）」 |

あわせて、住所に条件を付けて開けるようにしました（例 `/sim/serving-robot-3d?store=p1&staff=3&robots=2&crowd=normal&day=1`）。答え合わせが済んで試算からつなぐときに、選んでいる条件のまま開くためです。店を読み込んでいる間に条件を変えると変更が捨てられていたので、読み終えてからもう一度建て直すようにしました。

### サイトでは、店のデータをサイトの公開データから読む

チャットの画面で試すときは6店のデータをページの中に含めていましたが（3.07MB）、サイトではサイトの公開データ（`public/sim/layouts/unity/`）を、店を選んだときに読みます。試算・Unity と同じ正を読むことになり、ページは 1.7MB になりました。どちらにするかは、まとめるときに `p2m-stores` の行き先で選びます。

JavaScript を動かさない読み手（検索・AI）向けに、ページの説明、正規の住所、SNS 用の画像、何のページか・動きの決まり・数字の定義を書いた文（noscript）を足しました。

![公開したテスト版のページ。⑤ ゆったり・入口が正面、スタッフ4人・ロボット5台（その他から選択）・混雑。題名の横にテスト版の札、右の列の混み具合の下に来客のペース](https://pixelstomotors.com/media/LOG506_06_test_page.jpg)

### /series8 と TOP の入口の節

![/series8 の「ブラウザで動かす（テスト版）| Test」の節。左に説明・まだのこと3つ・テスト版を開くボタン・LOG506 へのリンク、右に PC とスマホの画面の絵](https://pixelstomotors.com/media/LOG506_07_series8.jpg)

![TOP の FV のすぐ下に置いた同じ節](https://pixelstomotors.com/media/LOG506_08_top.jpg)

### 入口の部品（src/components/BrowserSimTest.astro）

```astro
---
// サービスロボット導入シミュレーション（/series8）の「ブラウザで動かす（テスト版）」（2026-11-14・LOG506）
// 配膳ロボットのブラウザ版（/sim/serving-robot-3d）への入口。テスト版なので、入口はこの1か所だけ（試算・LP・トップには置かない）。
// 試算・Unity と答え合わせが済んだら、試算の「平面図／Unity で動かすと」の隣に移す（住所に条件を付けて開ける作りは入れてある）。
// TOP にも置く（2026-11-14 エイイチの指示「TOPにも置くようにしてください。なるべく上の方」）。FV のすぐ下・解説動画の前。押された数を分けて数えるため、place で data-ev の頭を変える
const { place = 'series8' } = Astro.props as { place?: 'series8' | 'top' };
const href = '/sim/serving-robot-3d';
const ev = (t: string) => (place === 'top' ? `TOP:${t}` : t);
---
<section class="sec s-bt" id="browser-test" data-ev-area={place === 'top' ? 'top-browser-test' : 'browser-test'}><div class="panel">
  <h2 class="sec-h">ブラウザで動かす（テスト版）| Test</h2>
  <div class="bt-grid">
    <div class="bt-text">
      <p class="bt-lead">配膳ロボットの店を、ブラウザだけで動かす</p>
      <p class="bt-note">試算と同じファミレスの店（②〜⑦）を3Dで建てます。スタッフ・ロボットの台数・混み具合を選ぶと、その場でお客さんが来て、料理が届き、会計して帰るまでの1日が回ります。人とロボットはブラウザの中で動いていて、Unity も実物も使っていません。</p>
      <p class="bt-k"><span class="bt-tag">テスト版</span>まだのこと</p>
      <ul class="bt-list">
        <li>数字は、試算（v2.9）や Unity との答え合わせをしていません</li>
        <li>ロボット3台以上・混雑の日は、ロボットどうしが通路で詰まりやすくなります</li>
        <li>下げ膳（皿をロボットで下げる使い方）は、まだ入っていません</li>
      </ul>
      <a class="bt-btn" href={href} target="_blank" rel="noopener" data-ev={ev('ブラウザ版のテスト版を開く')}>テスト版を開く ↗</a>
      <div><a class="bt-log" href="/record/serving-robot-store-sim-in-the-browser" data-ev={ev('ブラウザ版の記録を開く')}>作り方とコードの記録（LOG506） →</a></div>
    </div>
    <a class="bt-img" href={href} target="_blank" rel="noopener" data-ev={ev('ブラウザ版のテスト版を開く（画像）')}>
      <img src="/site/sim/browser-test.webp" alt="ブラウザ版のテスト版の画面。左は PC のブラウザで、3Dの店の中をスタッフ・お客さん・ロボットが動き、右に操作といまの数字が並ぶ。右はスマホで見た同じ画面" loading="lazy" width="1920" height="1080" />
    </a>
  </div>
</div></section>

<style>
  .s-bt .bt-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.25fr); gap: 48px; align-items: start; }
  .s-bt .bt-lead { margin: -12px 0 8px; font: 600 18px/28px var(--sans); color: var(--ink); }
  .s-bt .bt-note { margin: 0 0 20px; font: 400 14px/24px var(--sans); color: var(--ink-2); }
  .s-bt .bt-k { margin: 0 0 6px; font: 600 14px/22px var(--sans); color: var(--ink); }
  .s-bt .bt-tag { display: inline-block; margin-right: 8px; padding: 1px 8px; border-radius: 4px; background: #ecebe7; color: #555; font: 600 12px/20px var(--sans); }
  .s-bt .bt-list { margin: 0 0 24px; padding-left: 1.2em; font: 400 14px/26px var(--sans); color: var(--ink-2); }
  .s-bt .bt-btn { display: inline-flex; align-items: center; gap: 6px; padding: 12px 20px; border-radius: 8px; background: var(--ink, #242424); color: #fff; font: 600 14px/20px var(--sans); }
  .s-bt .bt-btn:hover { opacity: .85; }
  .s-bt .bt-log { display: inline-block; margin-top: 20px; font: 500 14px/22px var(--sans); color: var(--ink); }
  .s-bt .bt-log:hover { text-decoration: underline; }
  .s-bt .bt-img { display: block; border-radius: 8px; overflow: hidden; background: #eceef2; }
  .s-bt .bt-img img { display: block; width: 100%; height: auto; }
  .s-bt .bt-img:hover img { opacity: .92; }
  @media (max-width: 1023px) { .s-bt .bt-grid { grid-template-columns: minmax(0, 1fr); gap: 24px; } .s-bt .bt-img { order: -1; } }
</style>
```

### /series8 に節を足した差分（src/pages/[series].astro・src/lib/records.ts）

```diff
commit e01906cede777f66e7a741e732db011d06ee4c92
Author: Claude <noreply@anthropic.com>
Date:   Sat Nov 14 07:40:55 2026 +0900

    配膳ロボットのブラウザ版（テスト版）を /sim/serving-robot-3d に公開し、/series8 に入口の節「ブラウザで動かす（テスト版）」を足した。店の名前・人数・混み具合・台数を試算にそろえ、店のデータは public/sim/layouts/unity/ を読む。コードは tools/serving_sim_3d/
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01LevEXU3Z3M9LXKJxzqN9Ew

diff --git a/src/lib/records.ts b/src/lib/records.ts
index 3726163..de04746 100644
--- a/src/lib/records.ts
+++ b/src/lib/records.ts
@@ -44,7 +44,7 @@ export type SimTab = { id: string; task: string; place: string; src: string; tit
 
 // シリーズのページ（URL → 記事のシリーズ名）。Studio の一覧の絞り込み条件と同じ
 // intro はシリーズのページの見出しの下に出す説明（ないシリーズは出さない）
-export const SERIES_PAGES: Record<string, { series: string; title: string; pageTitle: string; limit: number; intro?: string; points?: { h: string; items: string[] }[]; guide?: boolean; thinking?: boolean; serving?: boolean; vision?: boolean; lab?: boolean; scenes?: boolean; sim?: { src: string; title: string; note: string; obs?: { src: string; title: string; label: string; note: string; points: string[] }; tabs?: SimTab[] } }> = {
+export const SERIES_PAGES: Record<string, { series: string; title: string; pageTitle: string; limit: number; intro?: string; points?: { h: string; items: string[] }[]; guide?: boolean; thinking?: boolean; serving?: boolean; vision?: boolean; lab?: boolean; scenes?: boolean; browserTest?: boolean; sim?: { src: string; title: string; note: string; obs?: { src: string; title: string; label: string; note: string; points: string[] }; tabs?: SimTab[] } }> = {
   series1: { series: '薬剤搬送ロボット', title: 'Drug Delivery Robot | 薬剤搬送ロボットハコブくん', pageTitle: 'Series1 | 薬剤搬送ロボット', limit: 12,
     intro: '病院で薬を運ぶロボット「ハコブくん」を題材に、人とロボットの関わり（HRI）を設計している記録です。病院で使う画面（UI）の設計、Unity での自律移動、Fusion での形づくり、分身ロボット（アバター）の考察までを、4コママンガと一緒に残しています。' },
   series2: { series: 'R2R2R', title: 'R2R2R | デジタルツイン学習-R2R2R（Real to Render to Real）', pageTitle: 'Series2 | R2R2R', limit: 12,
@@ -63,7 +63,7 @@ export const SERIES_PAGES: Record<string, { series: string; title: string; pageT
     intro: 'ロボットアームが動きを覚える環境を、自宅にひとりで作っている記録です。机の上のアームでお手本の動きを集め、ロボット用のAI（Physical Intelligence の π0.5 など）に学習させ、実機で動かして確かめます。借りたGPUの種類・時間・費用も含めて、誰でも同じ手順で始められる形で残しています。' },
   series7: { series: '画像認識・物体検出', title: 'Image recognition and Object detection | 画像認識と物体検出', pageTitle: 'Series7 | 画像認識と物体検出', limit: 12, vision: true,
     intro: 'ロボットのカメラが物を見つける仕組みを、下の層から学び直している記録です。画素の数字、色の見分け方、しきい値、輪郭、特徴点といった昔からの手法を、机の上のアームを撮った写真で1つずつ測って確かめ、その上で SAM 3 のような新しいモデルも試します。評価の物差し（IoU・適合率・再現率など）を先に決め、結果は数字で残しています。' },
-  series8: { series: '導入シミュレーション', title: 'Service Robot Simulation | サービスロボット導入シミュレーション', pageTitle: 'Series8 | サービスロボット導入シミュレーション', limit: 12, serving: true, scenes: true,
+  series8: { series: '導入シミュレーション', title: 'Service Robot Simulation | サービスロボット導入シミュレーション', pageTitle: 'Series8 | サービスロボット導入シミュレーション', limit: 12, serving: true, scenes: true, browserTest: true,
     intro: 'サービスロボット導入シミュレーションとは、配膳・清掃・搬送などのロボットを店や施設に入れる前に、待ち時間・必要な人数・ロボットが止まる場所・費用を試算して確かめることです。このシリーズでは、時間・順番・台数を SimPy で、ぶつかる・こぼれるといった空間と動きを Unity で扱い、結果とコードを全量公開しています。',
     // 見出しの下に並べる短い一覧（検索や AI が「何が分かるか・どう試算するか」を拾えるように、本文の文字で書く）
     points: [
diff --git a/src/pages/[series].astro b/src/pages/[series].astro
index 4d74342..ceb2a2b 100644
--- a/src/pages/[series].astro
+++ b/src/pages/[series].astro
@@ -11,6 +11,7 @@ import SeriesDoc from '../components/SeriesDoc.astro';
 import VisionGuide from '../components/VisionGuide.astro';
 import LabGuide from '../components/LabGuide.astro';
 import SimScenes from '../components/SimScenes.astro';
+import BrowserSimTest from '../components/BrowserSimTest.astro';
 import { seriesCards, SERIES_PAGES } from '../lib/records';
 import { media } from '../lib/media';
 import staticPages from '../data/static.json';
@@ -153,6 +154,7 @@ const jsonLd = {
     }
   </script>
   {conf.scenes && <SimScenes />}
+  {conf.browserTest && <BrowserSimTest />}
   <section class="sec"><div class="panel">
     <h2 class="sec-h">記録 | Record</h2>
     <LoadMore limit={conf.limit} total={items.length}>{items.map((c) => <RecordCard item={c} />)}</LoadMore>
```

### TOP に節を置き、解説動画を下げた差分（src/pages/index.astro・BrowserSimTest.astro）

```diff
commit 244b497a8677aee1ff3feb073c1797a3dac65195
Author: Claude <noreply@anthropic.com>
Date:   Sat Nov 14 07:50:49 2026 +0900

    TOP：FV のすぐ下に配膳ロボットのブラウザ版（テスト版）の入口を置き、解説動画を4コマの前へ下げた。入口の押された数は TOP と /series8 で分けて数える
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01LevEXU3Z3M9LXKJxzqN9Ew

diff --git a/src/components/BrowserSimTest.astro b/src/components/BrowserSimTest.astro
index 5315101..df5a16f 100644
--- a/src/components/BrowserSimTest.astro
+++ b/src/components/BrowserSimTest.astro
@@ -2,9 +2,12 @@
 // サービスロボット導入シミュレーション（/series8）の「ブラウザで動かす（テスト版）」（2026-11-14・LOG506）
 // 配膳ロボットのブラウザ版（/sim/serving-robot-3d）への入口。テスト版なので、入口はこの1か所だけ（試算・LP・トップには置かない）。
 // 試算・Unity と答え合わせが済んだら、試算の「平面図／Unity で動かすと」の隣に移す（住所に条件を付けて開ける作りは入れてある）。
+// TOP にも置く（2026-11-14 エイイチの指示「TOPにも置くようにしてください。なるべく上の方」）。FV のすぐ下・解説動画の前。押された数を分けて数えるため、place で data-ev の頭を変える
+const { place = 'series8' } = Astro.props as { place?: 'series8' | 'top' };
 const href = '/sim/serving-robot-3d';
+const ev = (t: string) => (place === 'top' ? `TOP:${t}` : t);
 ---
-<section class="sec s-bt" id="browser-test" data-ev-area="browser-test"><div class="panel">
+<section class="sec s-bt" id="browser-test" data-ev-area={place === 'top' ? 'top-browser-test' : 'browser-test'}><div class="panel">
   <h2 class="sec-h">ブラウザで動かす（テスト版）| Test</h2>
   <div class="bt-grid">
     <div class="bt-text">
@@ -16,10 +19,10 @@ const href = '/sim/serving-robot-3d';
         <li>ロボット3台以上・混雑の日は、ロボットどうしが通路で詰まりやすくなります</li>
         <li>下げ膳（皿をロボットで下げる使い方）は、まだ入っていません</li>
       </ul>
-      <a class="bt-btn" href={href} target="_blank" rel="noopener" data-ev="ブラウザ版のテスト版を開く">テスト版を開く ↗</a>
-      <div><a class="bt-log" href="/record/serving-robot-store-sim-in-the-browser" data-ev="ブラウザ版の記録を開く">作り方とコードの記録（LOG506） →</a></div>
+      <a class="bt-btn" href={href} target="_blank" rel="noopener" data-ev={ev('ブラウザ版のテスト版を開く')}>テスト版を開く ↗</a>
+      <div><a class="bt-log" href="/record/serving-robot-store-sim-in-the-browser" data-ev={ev('ブラウザ版の記録を開く')}>作り方とコードの記録（LOG506） →</a></div>
     </div>
-    <a class="bt-img" href={href} target="_blank" rel="noopener" data-ev="ブラウザ版のテスト版を開く（画像）">
+    <a class="bt-img" href={href} target="_blank" rel="noopener" data-ev={ev('ブラウザ版のテスト版を開く（画像）')}>
       <img src="/site/sim/browser-test.webp" alt="ブラウザ版のテスト版の画面。左は PC のブラウザで、3Dの店の中をスタッフ・お客さん・ロボットが動き、右に操作といまの数字が並ぶ。右はスマホで見た同じ画面" loading="lazy" width="1920" height="1080" />
     </a>
   </div>
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 7ed4495..d3db8eb 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -15,6 +15,7 @@ import GuideCards from '../components/GuideCards.astro';
 import ThinkingBanner from '../components/ThinkingBanner.astro';
 import OneYearBanner from '../components/OneYearBanner.astro';
 import TopAiGuide from '../components/TopAiGuide.astro';
+import BrowserSimTest from '../components/BrowserSimTest.astro';
 import { allManga } from '../lib/records';
 // Studio のトップと同じく、4コマは新しい4話
 const mangaCards = allManga.slice(0, 4);
@@ -120,12 +121,11 @@ const jsonLd = [
     if (fvVideo && matchMedia('(prefers-reduced-motion: reduce)').matches) { fvVideo.removeAttribute('autoplay'); fvVideo.pause(); }
   </script>
 
-  <!-- 解説動画：YouTube の新着を GitHub の videos.json から読む埋め込み（Studio で使っていたものをそのまま） -->
-  <section class="sec"><div class="panel">
-    <div class="embed-wide"><iframe src="/embeds/top-video.html" sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox" title="解説動画" data-embed style="height:450px"></iframe></div>
-  </div></section>
+  <!-- 配膳ロボットのブラウザ版（テスト版）への入口。FV のすぐ下・解説動画の前（2026-11-14 エイイチの指示「なるべく上の方」）。中身は /series8 と同じ部品 -->
+  <BrowserSimTest place="top" />
+
 
-  <!-- 1年の LP（/one-year）への入口。動画の次、導入前ガイドの前に置く（2026-10-25）
+  <!-- 1年の LP（/one-year）への入口。導入前ガイドの前に置く（2026-10-25。2026-11-14 に解説動画を4コマの前へ下げたので、ブラウザ版のテスト版の次になった）
        MASTER 2026-10-25：デジタルの AI の土台を記録から示すページは、トップと SNS に出してよい（AI活用の相談の看板にはしない） -->
   <section class="sec"><div class="panel">
     <h2 class="sec-h">Pixels to Motors の1年 | Year One</h2>
@@ -138,6 +138,11 @@ const jsonLd = [
     <GuideCards />
   </div></section>
 
+  <!-- 解説動画（2026-11-14 に FV の下から4コマの前へ下げた・エイイチの指示）：YouTube の新着を GitHub の videos.json から読む埋め込み（Studio で使っていたものをそのまま） -->
+  <section class="sec"><div class="panel">
+    <div class="embed-wide"><iframe src="/embeds/top-video.html" sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox" title="解説動画" data-embed style="height:450px"></iframe></div>
+  </div></section>
+
   <section class="sec"><div class="panel">
     <h2 class="sec-h">4コママンガ | Archive</h2>
     <div class="grid4">{mangaCards.map((m) => <MangaCard item={m} />)}</div>
```

### 描く所の差分（main.js：試算にそろえる・住所の条件・店の読み込み）

```diff
--- /tmp/main.bak.js	2026-11-14 07:32:29.557516637 +0900
+++ tools/serving_sim_3d/src/main.js	2026-11-14 07:38:43.166492010 +0900
@@ -1,18 +1,33 @@
 import * as THREE from 'three/webgpu';
 import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
 import { createSim, DT, CROWD } from './sim.js';
-import p0 from '../stores/p0_memory.json'; import p0m from '../stores/p0_memory_map.json';
-import p1 from '../stores/p1_efficient_corner.json'; import p1m from '../stores/p1_efficient_corner_map.json';
-import p2 from '../stores/p2_efficient_narrow.json'; import p2m from '../stores/p2_efficient_narrow_map.json';
-import p3 from '../stores/p3_relaxed_center.json'; import p3m from '../stores/p3_relaxed_center_map.json';
-import p4 from '../stores/p4_relaxed_side.json'; import p4m from '../stores/p4_relaxed_side_map.json';
-import p5 from '../stores/p5_crammed.json'; import p5m from '../stores/p5_crammed_map.json';
+import { loadStore } from 'p2m-stores'; // 店のデータ：試作（チャットの画面）は中に含む・サイトは public/sim/layouts/unity/ を読む（build のときに選ぶ）
 // サイトの公開データ（public/sim/layouts/unity/）の6店
-const STORES = { p0: [p0, p0m], p1: [p1, p1m], p2: [p2, p2m], p3: [p3, p3m], p4: [p4, p4m], p5: [p5, p5m] };
-let layout = p1, map = p1m;
+const STORE_KEYS = ['p0', 'p1', 'p2', 'p3', 'p4', 'p5'];
+let layout = null, map = null;
 
 const $ = (s) => document.querySelector(s);
 const state = { store: 'p1', robots: 2, staff: 3, crowd: 'normal', seed: 1, speed: 1, playing: true };
+// 試算（/sim/serving-robot）の店の形 ②〜⑦ と同じ名前・人数・来客のペース（試算 v2.9 の STORES の short・staff・staff_all・arrivals）
+const CALC = {
+  p0: { short: '② 定番の郊外型', staff: [1, 2, 3], staffAll: [1, 2, 3, 4, 5, 6], arrivals: [14, 19, 24] },
+  p1: { short: '③ 効率重視・入口が角', staff: [3, 4, 5], staffAll: [2, 3, 4, 5, 6, 7, 8], arrivals: [30, 42, 54] },
+  p2: { short: '④ 効率重視・小さめの店', staff: [1, 2, 3], staffAll: [1, 2, 3, 4, 5, 6], arrivals: [20, 28, 36] },
+  p3: { short: '⑤ ゆったり・入口が正面', staff: [3, 4, 5], staffAll: [2, 3, 4, 5, 6, 7, 8], arrivals: [33, 46, 59] },
+  p4: { short: '⑥ ゆったり・入口が横', staff: [2, 3, 4], staffAll: [1, 2, 3, 4, 5, 6, 7], arrivals: [27, 38, 49] },
+  p5: { short: '⑦ ③の通路を95cmに', staff: [3, 4, 5], staffAll: [2, 3, 4, 5, 6, 7, 8], arrivals: [30, 42, 54] },
+};
+const CROWD_KEYS = ['empty', 'normal', 'busy'];
+const ROBOTS_MAIN = [0, 1, 2, 3], ROBOTS_ALL = [0, 1, 2, 3, 4, 5, 6]; // 試算と同じ（4〜6台は「その他」）
+// 住所の条件から始める（?store=p1&staff=3&robots=2&crowd=normal&day=1）。試算から同じ条件で開くため
+{ const q = new URLSearchParams(location.search);
+  if (CALC[q.get('store')]) state.store = q.get('store');
+  if (CROWD_KEYS.includes(q.get('crowd'))) state.crowd = q.get('crowd');
+  const n = (k) => parseInt(q.get(k), 10);
+  if (ROBOTS_ALL.includes(n('robots'))) state.robots = n('robots');
+  if (CALC[state.store].staffAll.includes(n('staff'))) state.staff = n('staff');
+  else if (!CALC[state.store].staffAll.includes(state.staff)) state.staff = CALC[state.store].staff[1];
+  if (n('day') >= 1) state.seed = n('day'); }
 let sim = null, building = false;
 
 // ---------------- 画面 ----------------
@@ -34,7 +49,7 @@
 scene.add(new THREE.HemisphereLight(0xffffff, 0xb8b8b0, 2.2));
 const sun = new THREE.DirectionalLight(0xffffff, 1.6); sun.position.set(8, 20, 6); scene.add(sun);
 
-let W = layout.outer.w, D = layout.outer.d;
+let W = 24, D = 16; // 店を読むまでの仮の大きさ（読んだら buildStore で入れ替わる）
 // 店の座標 (x, y) → three (x, 0, -y)。奥が画面の奥
 const P = (x, y, h = 0) => new THREE.Vector3(x - W / 2, h, -(y - D / 2));
 const mat = (c, o = {}) => new THREE.MeshStandardMaterial({ color: c, roughness: 0.85, ...o });
@@ -134,29 +149,48 @@
   $('#n-stops').textContent = s.sensorStops; $('#n-helps').textContent = s.helps; $('#n-stuck').textContent = s.stucks;
   $('#log').innerHTML = (sim.allEvents || []).filter(e => e.t <= f.t).slice(-5).reverse().map(e => `<li><b>${fmt(e.t)}</b>${e.msg}</li>`).join('');
 }
+let rebuildAgain = false; // 建てている間に条件が変わったら、終わってからもう一度建てる
 async function rebuild() {
-  if (building) return; building = true; $('#status').textContent = '店を建てています…';
+  if (building) { rebuildAgain = true; return; } building = true; $('#status').textContent = '店を建てています…';
+  try { [layout, map] = await loadStore(state.store); }
+  catch (e) { building = false; $('#status').textContent = '店のデータを読めませんでした。ページを読み込み直してください'; return; }
   if (sim) { sim.crowd.destroy(); }
-  [layout, map] = STORES[state.store]; buildStore(); $('#store').value = state.store;
-  sim = await createSim(layout, map, { robots: state.robots, staff: state.staff, crowd: state.crowd, seed: state.seed });
+  buildStore(); $('#store').value = state.store;
+  sim = await createSim(layout, map, { robots: state.robots, staff: state.staff, crowd: state.crowd, seed: state.seed, arrPerH: CALC[state.store].arrivals[CROWD_KEYS.indexOf(state.crowd)] });
   bubbleRobot = -1; rebuildDynamic(); frames = [sim.snapshot()]; viewT = 0; simDone = false; draw(frames[0]); panel(frames[0]); building = false; $('#status').textContent = '';
+  if (rebuildAgain) { rebuildAgain = false; rebuild(); }
 }
-function seg(id, values, key, label = (v) => v) {
-  const el = $(id); el.innerHTML = values.map(v => `<button data-v="${v}">${label(v)}</button>`).join('');
-  const sync = () => el.querySelectorAll('button').forEach(b => b.classList.toggle('on', String(state[key]) === b.dataset.v));
-  el.addEventListener('click', (e) => { const b = e.target.closest('button'); if (!b) return; const v = b.dataset.v; state[key] = isNaN(+v) ? v : +v; sync(); if (key !== 'speed') rebuild(); });
+function seg(id, values, key, label = (v) => v, more = []) {
+  // 試算と同じ形：よく使う値はボタン、残りは「その他」の選ぶ欄
+  const el = $(id);
+  el.innerHTML = values.map(v => `<button data-v="${v}">${label(v)}</button>`).join('')
+    + (more.length ? `<select class="more" aria-label="その他"><option value="">その他</option>${more.map(v => `<option value="${v}">${label(v)}</option>`).join('')}</select>` : '');
+  const sel = el.querySelector('select');
+  const sync = () => {
+    el.querySelectorAll('button').forEach(b => b.classList.toggle('on', String(state[key]) === b.dataset.v));
+    if (sel) { const hit = more.map(String).includes(String(state[key])); sel.value = hit ? String(state[key]) : ''; sel.classList.toggle('on', hit); }
+  };
+  const set = (v) => { state[key] = isNaN(+v) ? v : +v; sync(); if (key !== 'speed') rebuild(); };
+  el.onclick = (e) => { const b = e.target.closest('button'); if (b) set(b.dataset.v); };
+  if (sel) sel.onchange = () => { if (sel.value !== '') set(sel.value); };
   sync();
 }
-$('#store').innerHTML = Object.entries(STORES).map(([k, [l]]) => `<option value="${k}">${l.name}</option>`).join('');
-$('#store').addEventListener('change', (e) => { state.store = e.target.value; rebuild(); });
-seg('#c-robots', [0, 1, 2, 3, 4, 5, 6], 'robots', v => `${v}台`);
-seg('#c-staff', [2, 3, 4], 'staff', v => `${v}人`);
-seg('#c-crowd', ['empty', 'normal', 'busy'], 'crowd', v => CROWD[v].label);
+function staffSeg() {
+  const c = CALC[state.store];
+  if (!c.staffAll.includes(state.staff)) state.staff = c.staff[1];
+  seg('#c-staff', c.staff, 'staff', v => `${v}人`, c.staffAll.filter(v => !c.staff.includes(v)));
+}
+function crowdSeg() { seg('#c-crowd', CROWD_KEYS, 'crowd', v => `${CROWD[v].label}`); $('#crowdNote').textContent = `${CALC[state.store].short}の来客は、空いている ${CALC[state.store].arrivals[0]}・普通 ${CALC[state.store].arrivals[1]}・混雑 ${CALC[state.store].arrivals[2]} 組/時（試算と同じ）。混む時間は120分。`; }
+$('#store').innerHTML = STORE_KEYS.map((k) => `<option value="${k}">${CALC[k].short}</option>`).join('');
+$('#store').addEventListener('change', (e) => { state.store = e.target.value; staffSeg(); crowdSeg(); rebuild(); });
+seg('#c-robots', ROBOTS_MAIN, 'robots', v => `${v}台`, ROBOTS_ALL.filter(v => !ROBOTS_MAIN.includes(v)));
+staffSeg();
+crowdSeg();
 seg('#c-speed', [1, 5, 20], 'speed', v => v === 1 ? '実際の速さ' : `${v}倍`);
 $('#play').addEventListener('click', () => { state.playing = !state.playing; $('#play').textContent = state.playing ? '一時停止' : '再生'; });
 $('#restart').addEventListener('click', () => rebuild());
 const setDay = (d) => { state.seed = Math.max(1, d); $('#dayLabel').textContent = `${state.seed}日目`; $('#dayPrev').disabled = state.seed <= 1; rebuild(); };
-$('#dayPrev').addEventListener('click', () => setDay(state.seed - 1)); $('#dayNext').addEventListener('click', () => setDay(state.seed + 1)); $('#dayPrev').disabled = true;
+$('#dayPrev').addEventListener('click', () => setDay(state.seed - 1)); $('#dayNext').addEventListener('click', () => setDay(state.seed + 1)); $('#dayPrev').disabled = state.seed <= 1; $('#dayLabel').textContent = `${state.seed}日目`;
 document.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => setView(b.dataset.view)));
 
 // ---------------- 回す ----------------
```

### 描く所の全文（tools/serving_sim_3d/src/main.js）

```js
import * as THREE from 'three/webgpu';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { createSim, DT, CROWD } from './sim.js';
import { loadStore } from 'p2m-stores'; // 店のデータ：試作（チャットの画面）は中に含む・サイトは public/sim/layouts/unity/ を読む（build のときに選ぶ）
// サイトの公開データ（public/sim/layouts/unity/）の6店
const STORE_KEYS = ['p0', 'p1', 'p2', 'p3', 'p4', 'p5'];
let layout = null, map = null;

const $ = (s) => document.querySelector(s);
const state = { store: 'p1', robots: 2, staff: 3, crowd: 'normal', seed: 1, speed: 1, playing: true };
// 試算（/sim/serving-robot）の店の形 ②〜⑦ と同じ名前・人数・来客のペース（試算 v2.9 の STORES の short・staff・staff_all・arrivals）
const CALC = {
  p0: { short: '② 定番の郊外型', staff: [1, 2, 3], staffAll: [1, 2, 3, 4, 5, 6], arrivals: [14, 19, 24] },
  p1: { short: '③ 効率重視・入口が角', staff: [3, 4, 5], staffAll: [2, 3, 4, 5, 6, 7, 8], arrivals: [30, 42, 54] },
  p2: { short: '④ 効率重視・小さめの店', staff: [1, 2, 3], staffAll: [1, 2, 3, 4, 5, 6], arrivals: [20, 28, 36] },
  p3: { short: '⑤ ゆったり・入口が正面', staff: [3, 4, 5], staffAll: [2, 3, 4, 5, 6, 7, 8], arrivals: [33, 46, 59] },
  p4: { short: '⑥ ゆったり・入口が横', staff: [2, 3, 4], staffAll: [1, 2, 3, 4, 5, 6, 7], arrivals: [27, 38, 49] },
  p5: { short: '⑦ ③の通路を95cmに', staff: [3, 4, 5], staffAll: [2, 3, 4, 5, 6, 7, 8], arrivals: [30, 42, 54] },
};
const CROWD_KEYS = ['empty', 'normal', 'busy'];
const ROBOTS_MAIN = [0, 1, 2, 3], ROBOTS_ALL = [0, 1, 2, 3, 4, 5, 6]; // 試算と同じ（4〜6台は「その他」）
// 住所の条件から始める（?store=p1&staff=3&robots=2&crowd=normal&day=1）。試算から同じ条件で開くため
{ const q = new URLSearchParams(location.search);
  if (CALC[q.get('store')]) state.store = q.get('store');
  if (CROWD_KEYS.includes(q.get('crowd'))) state.crowd = q.get('crowd');
  const n = (k) => parseInt(q.get(k), 10);
  if (ROBOTS_ALL.includes(n('robots'))) state.robots = n('robots');
  if (CALC[state.store].staffAll.includes(n('staff'))) state.staff = n('staff');
  else if (!CALC[state.store].staffAll.includes(state.staff)) state.staff = CALC[state.store].staff[1];
  if (n('day') >= 1) state.seed = n('day'); }
let sim = null, building = false;

// ---------------- 画面 ----------------
const renderer = new THREE.WebGPURenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
$('#view').appendChild(renderer.domElement);
const scene = new THREE.Scene(); scene.background = new THREE.Color(0xeceef2);
const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 200);
const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.screenSpacePanning = false;
// スペースを押している間は、左ドラッグで3Dの位置を動かす（離すと回す動きに戻る）
let spaceDown = false;
const typing = () => { const a = document.activeElement; return a && (a.tagName === 'INPUT' || a.tagName === 'TEXTAREA'); };
addEventListener('keydown', (e) => { if (e.code !== 'Space' || typing()) return; e.preventDefault(); if (spaceDown) return; spaceDown = true;
  controls.mouseButtons.LEFT = THREE.MOUSE.PAN; renderer.domElement.style.cursor = 'grab'; });
addEventListener('keyup', (e) => { if (e.code !== 'Space') return; spaceDown = false; controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE; renderer.domElement.style.cursor = ''; });
addEventListener('blur', () => { spaceDown = false; controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE; renderer.domElement.style.cursor = ''; });
renderer.domElement.addEventListener('pointerdown', () => { if (spaceDown) renderer.domElement.style.cursor = 'grabbing'; document.activeElement?.blur?.(); });
renderer.domElement.addEventListener('pointerup', () => { if (spaceDown) renderer.domElement.style.cursor = 'grab'; });
scene.add(new THREE.HemisphereLight(0xffffff, 0xb8b8b0, 2.2));
const sun = new THREE.DirectionalLight(0xffffff, 1.6); sun.position.set(8, 20, 6); scene.add(sun);

let W = 24, D = 16; // 店を読むまでの仮の大きさ（読んだら buildStore で入れ替わる）
// 店の座標 (x, y) → three (x, 0, -y)。奥が画面の奥
const P = (x, y, h = 0) => new THREE.Vector3(x - W / 2, h, -(y - D / 2));
const mat = (c, o = {}) => new THREE.MeshStandardMaterial({ color: c, roughness: 0.85, ...o });
// 色は Unity の素材（ServiceSim/Store/Materials の _Color）と同じ
const M = { floor: mat(0xdbd6c9), wall: mat(0x666e7a), back: mat(0x8a909a), table: mat(0xcc7d36), bench: mat(0x9e544a), chair: mat(0x215e6b), staff: mat(0x1761e6), guest: mat(0xfacc21), robot: mat(0xf5f5f7, { roughness: 0.4 }), stop: mat(0xd64545), help: mat(0x111111), mark: mat(0xff540a), waiting: mat(0x8c4dbf) };
function box(rect, h, m, y0 = 0) { const [x0, y0r, x1, y1] = rect; const g = new THREE.BoxGeometry(x1 - x0, h, y1 - y0r); const o = new THREE.Mesh(g, m); o.position.copy(P((x0 + x1) / 2, (y0r + y1) / 2, y0 + h / 2)); (storeGroup || scene).add(o); return o; }

let storeGroup = null;
function buildStore() {
  if (storeGroup) scene.remove(storeGroup); storeGroup = new THREE.Group(); scene.add(storeGroup);
  W = layout.outer.w; D = layout.outer.d;
  box([0, 0, W, D], 0.02, M.floor, -0.02);
  for (const w of layout.outer_walls) box(w, 1.0, M.wall);
  for (const w of layout.walls) box(w.rect, 1.0, M.wall);
  for (const r of layout.rooms) if (!r.walkable) box(r.rect, 0.9, M.back);
  for (const t of layout.tables) { const p = t.parts || {}; if (p.top) box(p.top, 0.72, M.table); else box(t.footprint, 0.72, M.table); for (const b of p.benches || []) box(b, 0.45, M.bench); for (const c of p.chairs || []) box(c.rect || c, 0.45, M.chair); }
}

// 人とロボットの形（数は動的）
// Unity の prefab と同じ形（LOG364 で公開した Guest / Staff / SeatedGuestBody / ServingRobot）
//   人：カプセルの胴（半径0.2m・高さ1.59m）＋球の頭（直径0.22m・中心1.59m）
//   座った客：カプセルの胴（高さ0.79m・中心0.845m）＋頭（中心1.24m）
//   ロボット：筒（直径0.5m・高さ1.2m）＋前の印（0.3×0.06×0.06m・高さ0.96m・前へ0.225m）
const bodyGeo = new THREE.CapsuleGeometry(0.2, 1.19, 6, 16), headGeo = new THREE.SphereGeometry(0.11, 16, 12);
const seatBodyGeo = new THREE.CapsuleGeometry(0.2, 0.39, 6, 16);
const robotGeo = new THREE.CylinderGeometry(0.25, 0.25, 1.2, 24), markGeo = new THREE.BoxGeometry(0.06, 0.06, 0.3);
const lampGeo = new THREE.SphereGeometry(0.09, 12, 8);
function person(m, seated) { const g = new THREE.Group(); const b = new THREE.Mesh(seated ? seatBodyGeo : bodyGeo, m); b.position.y = seated ? 0.845 : 0.795; const h = new THREE.Mesh(headGeo, m); h.position.y = seated ? 1.24 : 1.59; g.add(b, h); g.userData.mats = [b, h]; return g; }
function robot() { const g = new THREE.Group(); const b = new THREE.Mesh(robotGeo, M.robot); b.position.y = 0.6; const k = new THREE.Mesh(markGeo, M.mark); k.position.set(0.225, 0.96, 0); k.rotation.y = Math.PI / 2; g.add(b, k); return g; }
let waitMeshes = [], robotMeshes = [], lamps = [], personPool = [], seatMeshes = [];
function rebuildDynamic() {
  for (const o of [...robotMeshes, ...lamps, ...personPool, ...seatMeshes, ...waitMeshes]) scene.remove(o); waitMeshes = []; for (const el of labels.values()) el.remove(); labels.clear();
  robotMeshes = sim.robots.map(() => { const m = robot(); scene.add(m); return m; });
  lamps = sim.robots.map(() => { const m = new THREE.Mesh(lampGeo, M.stop); scene.add(m); return m; });
  personPool = [];
  seatMeshes = [];
}
const labelLayer = document.getElementById('labels'); const labels = new Map(); const _v = new THREE.Vector3();
// 立ち往生の吹き出し（ロボットの頭の上。出たら最低6秒は出しておく）
const bubble = document.getElementById('bubble'); let bubbleRobot = -1, bubbleUntil = 0, bubbleTitle = '';
function placeBubble(pos) { _v.copy(pos).project(camera); const w = renderer.domElement.clientWidth, h = renderer.domElement.clientHeight, top = renderer.domElement.offsetTop;
  const x = Math.min(w - 12, Math.max(12, (_v.x * 0.5 + 0.5) * w)), y = (-_v.y * 0.5 + 0.5) * h + top; bubble.style.left = x + 'px'; bubble.style.top = (y - 26) + 'px'; }
function label(key, text, cls, pos) {
  let el = labels.get(key); if (!el) { el = document.createElement('div'); labelLayer.appendChild(el); labels.set(key, el); }
  el.className = 'lbl ' + cls; el.textContent = text; _v.copy(pos).project(camera);
  const w = renderer.domElement.clientWidth, h = renderer.domElement.clientHeight; el.style.transform = `translate(${(_v.x * 0.5 + 0.5) * w}px, ${(-_v.y * 0.5 + 0.5) * h}px) translate(-50%, -100%)`; el.style.display = _v.z < 1 ? '' : 'none'; el.dataset.seen = '1';
}
// 記録した様子を描く（f0 と f1 のあいだを a で補間）
function draw(f0, f1, a) {
  if (!f0) return; if (!f1) { f1 = f0; a = 0; }
  for (const el of labels.values()) el.dataset.seen = '';
  const nR = f0.R.length / 3;
  for (let i = 0; i < nR; i++) { const x = f0.R[i * 3] + (f1.R[i * 3] - f0.R[i * 3]) * a, y = f0.R[i * 3 + 1] + (f1.R[i * 3 + 1] - f0.R[i * 3 + 1]) * a;
    let h0 = f0.R[i * 3 + 2], dh = f1.R[i * 3 + 2] - h0; while (dh > Math.PI) dh -= 2 * Math.PI; while (dh < -Math.PI) dh += 2 * Math.PI;
    robotMeshes[i].position.copy(P(x, y, 0)); robotMeshes[i].rotation.y = h0 + dh * a; const tone = f0.rt[i].tone;
    const l = lamps[i]; l.position.copy(P(x, y, 1.35)); l.visible = tone === 'stop' || tone === 'stuck' || tone === 'help'; l.material = tone === 'help' ? M.help : M.stop;
    label('r' + (i + 1), String(i + 1), 'robot ' + tone, P(x, y, 1.55));
    if (tone === 'stuck' && (bubbleRobot < 0 || performance.now() > bubbleUntil)) { bubbleRobot = i; bubbleUntil = performance.now() + 6000; bubbleTitle = `ロボット${i + 1}が30秒動けず立ち往生`; }
    if (i === bubbleRobot) placeBubble(P(x, y, 1.55)); }
  if (bubbleRobot >= nR || (bubbleRobot >= 0 && performance.now() > bubbleUntil && f0.rt[bubbleRobot].tone !== 'stuck')) bubbleRobot = -1;
  bubble.classList.toggle('on', bubbleRobot >= 0); if (bubbleRobot >= 0) document.getElementById('bubble-t').textContent = bubbleTitle;
  const nP = f0.P.length / 4, next = new Map(); for (let k = 0; k < f1.P.length / 4; k++) next.set(f1.P[k * 4], k);
  while (personPool.length < nP) { const m = person(M.guest, false); scene.add(m); personPool.push(m); }
  personPool.forEach((m, i) => { m.visible = i < nP; if (i >= nP) return; let x = f0.P[i * 4 + 1], y = f0.P[i * 4 + 2]; const k = next.get(f0.P[i * 4]);
    if (k !== undefined) { x += (f1.P[k * 4 + 1] - x) * a; y += (f1.P[k * 4 + 2] - y) * a; }
    m.position.copy(P(x, y, 0)); const sid = f0.P[i * 4 + 3]; const mt = sid ? M.staff : M.guest; for (const c of m.userData.mats) c.material = mt;
    if (sid) label('s' + sid, 'S' + sid, 'staff', P(x, y, 1.95)); });
  const nS = f0.S.length / 2; while (seatMeshes.length < nS) { const m = person(M.guest, true); scene.add(m); seatMeshes.push(m); }
  seatMeshes.forEach((m, i) => { m.visible = i < nS; if (i < nS) m.position.copy(P(f0.S[i * 2], f0.S[i * 2 + 1], 0)); });
  const wr = (layout.places.find(p => p.kind === 'Waiting') || {}).rect; const nW = Math.min(8, f0.waitN);
  while (waitMeshes.length < 8) { const m = person(M.waiting, false); scene.add(m); waitMeshes.push(m); }
  waitMeshes.forEach((m, i) => { m.visible = !!wr && i < nW; if (wr && i < nW) { const vert = (wr[3] - wr[1]) >= (wr[2] - wr[0]); const f = (i + 0.5) / 8; m.position.copy(vert ? P((wr[0] + wr[2]) / 2, wr[1] + (wr[3] - wr[1]) * f, 0) : P(wr[0] + (wr[2] - wr[0]) * f, (wr[1] + wr[3]) / 2, 0)); } });
  for (const [k, el] of labels) if (!el.dataset.seen) { el.remove(); labels.delete(k); }
  const rows = f0.rt.map((t, i) => `<li class="${t.tone}"><b class="tag robot">${i + 1}</b>${t.text}</li>`).concat(f0.st.map((t, i) => `<li class="${t.tone}"><b class="tag staff">S${i + 1}</b>${t.text}</li>`));
  const html = rows.join('') || '<li>ロボット 0台</li>'; if (taskList.innerHTML !== html) taskList.innerHTML = html;
}
const taskList = document.getElementById('tasks');
const VIEWS = { wide: [0, 34, 25], left: [-19, 31, 26], right: [19, 31, 26] };
function setView(k) { const v = VIEWS[k].map(x => x * (mobile.matches ? 1.12 : 1)); camera.position.set(...v); controls.target.set(0, 0, 0); controls.update(); document.querySelectorAll('[data-view]').forEach(b => b.classList.toggle('on', b.dataset.view === k)); }
const mobile = matchMedia('(max-width: 760px)');
function resize() { const el = $('#view'); const w = el.clientWidth, h = mobile.matches ? Math.round(innerHeight * 0.45) : el.clientHeight;
  renderer.setSize(w, h); camera.aspect = w / h; camera.updateProjectionMatrix(); const lb = $('#labels'); lb.style.height = h + 'px'; lb.style.bottom = 'auto'; lb.style.top = renderer.domElement.offsetTop + 'px'; el.style.setProperty('--cvt', renderer.domElement.offsetTop + 'px'); el.style.setProperty('--cvh', h + 'px');
  $('#hint').textContent = mobile.matches ? '1本指で回す・2本指で寄る／動かす' : 'ドラッグで回す・ホイールで寄る・スペース＋ドラッグで動かす'; }
mobile.addEventListener('change', resize);
addEventListener('resize', resize);

// ---------------- パネル ----------------
const fmt = (s) => { const m = Math.floor(s / 60), x = Math.floor(s % 60); return `${m}:${String(x).padStart(2, '0')}`; };
function panel(f) {
  const s = f.sum;
  $('#clock').textContent = fmt(f.t);
  $('#n-delivered').textContent = s.delivered; $('#n-robot').textContent = s.delivered ? Math.round(s.byRobot / s.delivered * 100) : 0;
  $('#n-wait').textContent = (s.avgDishWait / 60).toFixed(1);
  $('#n-occ').textContent = `${s.occupied}/${s.tablesN}`; $('#n-waiting').textContent = s.waitingN;
  $('#n-seatwait').textContent = (s.avgSeatWait / 60).toFixed(1); $('#n-paywait').textContent = (s.avgPayWait / 60).toFixed(1);
  $('#phase').textContent = s.allDone ? '全員が帰りました' : s.peakOver ? 'ピークが終わり、残りのお客さんが帰るまで' : `ピーク（来客 ${Math.round(sim.arrPerH)}組/時）`;
  $('#n-stops').textContent = s.sensorStops; $('#n-helps').textContent = s.helps; $('#n-stuck').textContent = s.stucks;
  $('#log').innerHTML = (sim.allEvents || []).filter(e => e.t <= f.t).slice(-5).reverse().map(e => `<li><b>${fmt(e.t)}</b>${e.msg}</li>`).join('');
}
let rebuildAgain = false; // 建てている間に条件が変わったら、終わってからもう一度建てる
async function rebuild() {
  if (building) { rebuildAgain = true; return; } building = true; $('#status').textContent = '店を建てています…';
  try { [layout, map] = await loadStore(state.store); }
  catch (e) { building = false; $('#status').textContent = '店のデータを読めませんでした。ページを読み込み直してください'; return; }
  if (sim) { sim.crowd.destroy(); }
  buildStore(); $('#store').value = state.store;
  sim = await createSim(layout, map, { robots: state.robots, staff: state.staff, crowd: state.crowd, seed: state.seed, arrPerH: CALC[state.store].arrivals[CROWD_KEYS.indexOf(state.crowd)] });
  bubbleRobot = -1; rebuildDynamic(); frames = [sim.snapshot()]; viewT = 0; simDone = false; draw(frames[0]); panel(frames[0]); building = false; $('#status').textContent = '';
  if (rebuildAgain) { rebuildAgain = false; rebuild(); }
}
function seg(id, values, key, label = (v) => v, more = []) {
  // 試算と同じ形：よく使う値はボタン、残りは「その他」の選ぶ欄
  const el = $(id);
  el.innerHTML = values.map(v => `<button data-v="${v}">${label(v)}</button>`).join('')
    + (more.length ? `<select class="more" aria-label="その他"><option value="">その他</option>${more.map(v => `<option value="${v}">${label(v)}</option>`).join('')}</select>` : '');
  const sel = el.querySelector('select');
  const sync = () => {
    el.querySelectorAll('button').forEach(b => b.classList.toggle('on', String(state[key]) === b.dataset.v));
    if (sel) { const hit = more.map(String).includes(String(state[key])); sel.value = hit ? String(state[key]) : ''; sel.classList.toggle('on', hit); }
  };
  const set = (v) => { state[key] = isNaN(+v) ? v : +v; sync(); if (key !== 'speed') rebuild(); };
  el.onclick = (e) => { const b = e.target.closest('button'); if (b) set(b.dataset.v); };
  if (sel) sel.onchange = () => { if (sel.value !== '') set(sel.value); };
  sync();
}
function staffSeg() {
  const c = CALC[state.store];
  if (!c.staffAll.includes(state.staff)) state.staff = c.staff[1];
  seg('#c-staff', c.staff, 'staff', v => `${v}人`, c.staffAll.filter(v => !c.staff.includes(v)));
}
function crowdSeg() { seg('#c-crowd', CROWD_KEYS, 'crowd', v => `${CROWD[v].label}`); $('#crowdNote').textContent = `${CALC[state.store].short}の来客は、空いている ${CALC[state.store].arrivals[0]}・普通 ${CALC[state.store].arrivals[1]}・混雑 ${CALC[state.store].arrivals[2]} 組/時（試算と同じ）。混む時間は120分。`; }
$('#store').innerHTML = STORE_KEYS.map((k) => `<option value="${k}">${CALC[k].short}</option>`).join('');
$('#store').addEventListener('change', (e) => { state.store = e.target.value; staffSeg(); crowdSeg(); rebuild(); });
seg('#c-robots', ROBOTS_MAIN, 'robots', v => `${v}台`, ROBOTS_ALL.filter(v => !ROBOTS_MAIN.includes(v)));
staffSeg();
crowdSeg();
seg('#c-speed', [1, 5, 20], 'speed', v => v === 1 ? '実際の速さ' : `${v}倍`);
$('#play').addEventListener('click', () => { state.playing = !state.playing; $('#play').textContent = state.playing ? '一時停止' : '再生'; });
$('#restart').addEventListener('click', () => rebuild());
const setDay = (d) => { state.seed = Math.max(1, d); $('#dayLabel').textContent = `${state.seed}日目`; $('#dayPrev').disabled = state.seed <= 1; rebuild(); };
$('#dayPrev').addEventListener('click', () => setDay(state.seed - 1)); $('#dayNext').addEventListener('click', () => setDay(state.seed + 1)); $('#dayPrev').disabled = state.seed <= 1; $('#dayLabel').textContent = `${state.seed}日目`;
document.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => setView(b.dataset.view)));

// ---------------- 回す ----------------
// 計算は先に進めて1秒ごとに記録し、画面は記録を見る（時間のスライダーで前後に動かせる）
const REC = 1.0, END_CAP = 6 * 3600; let frames = [], viewT = 0, simDone = false, lastT = performance.now(), dragging = false;
const slider = $('#seek'); window.__p2m = { get frames() { return frames; } }; // 確かめ用
function computeAhead(budgetMs) {
  if (!sim || simDone || building) return; const t0 = performance.now(), per = Math.round(REC / DT);
  while (performance.now() - t0 < budgetMs) { for (let k = 0; k < per; k++) sim.step(); frames.push(sim.snapshot()); const s = frames[frames.length - 1].sum; if (s.allDone || sim.t >= END_CAP) { simDone = true; break; } }
}
function frameAt(t) { const i = Math.min(frames.length - 1, Math.max(0, Math.floor(t / REC))); return [frames[i], frames[i + 1], (t - i * REC) / REC]; }
async function main() {
  await renderer.init(); $('#backend').textContent = renderer.backend.isWebGPUBackend ? 'WebGPU' : 'WebGL';
  resize(); setView('left'); await rebuild();
  slider.addEventListener('input', () => { dragging = true; viewT = Math.min(+slider.value, (frames.length - 1) * REC); });
  slider.addEventListener('change', () => { dragging = false; });
  renderer.setAnimationLoop(() => {
    const now = performance.now(), dt = Math.min(0.1, (now - lastT) / 1000); lastT = now;
    if (sim && !building) {
      computeAhead(10);
      const end = (frames.length - 1) * REC;
      if (state.playing && !dragging) viewT = Math.min(end, viewT + dt * state.speed);
      const [f0, f1, a] = frameAt(viewT); draw(f0, f1, a); panel(f0);
      slider.max = simDone ? end : Math.max(end, 3 * 3600); if (!dragging) slider.value = viewT;
      const pct = slider.max > 0 ? end / slider.max * 100 : 0; slider.style.setProperty('--done', pct + '%');
      $('#seekinfo').textContent = simDone ? `終わりまで計算済み（${fmt(end)}）` : `計算中…（${fmt(end)} まで）`;
    }
    controls.update(); renderer.render(scene, camera);
  });
}
main();
```

### 動かして数える所の差分（sim.js：混雑の言葉・試算の来客のペース）

```diff
--- /tmp/sim.bak.js	2026-11-14 07:32:29.559256711 +0900
+++ tools/serving_sim_3d/src/sim.js	2026-11-14 07:38:43.166521375 +0900
@@ -23,7 +23,7 @@
 export const CROWD = {
   empty:  { arrivalsPerH35: 25, label: '空いている' },
   normal: { arrivalsPerH35: 35, label: '普通' },
-  busy:   { arrivalsPerH35: 45, label: '混んでいる' },
+  busy:   { arrivalsPerH35: 45, label: '混雑' },
 };
 // サイトの試算・Unity の値（決まりの一覧 A・B）
 export const STORE = {
@@ -103,7 +103,7 @@
     this.tableState = new Map(this.tables.map(t => [t.id, { t, state: 'free', group: null }])); // free / taken / dirty / clearing
     this.seated = []; this.groups = []; this.waiting = []; this.payQueue = []; this.jobs = [];
     this.queue = []; // 受け渡し口で待っている料理
-    this.arrPerH = CROWD[this.o.crowd].arrivalsPerH35 * this.tables.length / 35;
+    this.arrPerH = this.o.arrPerH || CROWD[this.o.crowd].arrivalsPerH35 * this.tables.length / 35; // 試算の店ごとの値（組/時）があればそれを使う
     this.nextArrival = this._expG(3600 / this.arrPerH); this.gid = 0;
     this.c.groups = 0; this.c.seatWaitSum = 0; this.c.seatedGroups = 0; this.c.payWaitSum = 0; this.c.paid = 0; this.c.left = 0;
   }
```

### ページの型の差分（shell.html：テスト版の札・来客のペースの説明・その他の選ぶ欄）

```diff
--- /tmp/shell.bak.html	2026-11-14 07:32:29.560893894 +0900
+++ tools/serving_sim_3d/shell.html	2026-11-14 07:38:43.167890948 +0900
@@ -1,7 +1,7 @@
 <!DOCTYPE html>
 <html lang="ja"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
 <link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&family=Noto+Sans+JP:wght@400;600;700&display=swap" rel="stylesheet">
-<title>配膳ロボット ブラウザ版（試作）</title>
+<title>配膳ロボット ブラウザ版（テスト版）</title>
 <style>
 :root{--ink:#1d1d1b;--sub:#6b6b66;--line:#d8d6d0;--bg:#f2f1ee;--accent:#2f6fd6}
 *{box-sizing:border-box}body{margin:0;font-family:"Noto Sans JP","Inter",system-ui,sans-serif;color:var(--ink);background:var(--bg);height:100vh;display:grid;grid-template-columns:1fr 320px}
@@ -13,6 +13,9 @@
 aside{background:#fff;border-left:1px solid var(--line);padding:16px;overflow:auto;font-size:13px}
 h2{font-size:12px;color:var(--sub);font-weight:600;margin:16px 0 6px}h2:first-child{margin-top:0}
 .seg{display:flex;flex-wrap:wrap;gap:4px}
+.seg select.more{font:inherit;border:1px solid var(--line);background:#fff;border-radius:6px;padding:5px 6px;color:var(--ink);cursor:pointer}.seg select.more.on{background:var(--ink);color:#fff;border-color:var(--ink)}
+#hud h1 .test{display:inline-block;margin-left:8px;padding:2px 7px;border-radius:4px;background:#ecebe7;color:#555;font-size:11px;font-weight:600;vertical-align:2px}
+.note{margin:6px 0 0;font-size:11px;line-height:1.6;color:var(--sub)}
 button{font:inherit;border:1px solid var(--line);background:#fff;border-radius:6px;padding:5px 9px;cursor:pointer;color:var(--ink)}
 button.on{background:var(--ink);color:#fff;border-color:var(--ink)}
 .row{display:flex;gap:6px;margin-top:8px}
@@ -56,7 +59,7 @@
 .nums{grid-template-columns:1fr 1fr;gap:10px 12px}
 }
 </style></head><body>
-<div id="view"><div id="hud"><h1>配膳ロボット ブラウザ版（試作）</h1><select id="store" aria-label="店"></select><div id="clock">0:00</div><p class="hint">描画：<span id="backend"></span>　<span id="hint"></span></p></div>
+<div id="view"><div id="hud"><h1>配膳ロボット ブラウザ版<span class="test">テスト版</span></h1><select id="store" aria-label="店"></select><div id="clock">0:00</div><p class="hint">描画：<span id="backend"></span>　<span id="hint"></span></p></div>
 <div id="legend"><span><i style="background:#fff;border:1px solid #999"></i>ロボット</span><span><i style="background:#1761e6"></i>スタッフ</span><span><i style="background:#facc21"></i>客</span><span><i style="background:#8c4dbf"></i>待合の客（1組に1人）</span><span><i style="background:#d64545"></i>止まっている</span><span><i style="background:#111"></i>人が動かしている</span></div>
 <div id="labels"></div>
 <a id="bubble" href="https://pixelstomotors.com/message?topic=physical-ai" target="_blank" rel="noopener"><b id="bubble-t">30秒動けず立ち往生</b><span>自分の店でも起きる？ 相談する →</span></a>
@@ -66,12 +69,12 @@
 <aside><a class="side" href="https://pixelstomotors.com/" target="_blank" rel="noopener"><img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMTQ2IDIyNyIgcm9sZT0iaW1nIiBhcmlhLWxhYmVsPSJQaXhlbHMgdG8gTW90b3JzIj48cGF0aCBmaWxsPSIjMjQyNDI0IiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMTIuNzggMjA5Ljc1TDExMi41IDE5Ni41TDk5LjIyIDE5Ni4yMkw4NS45NCAxOTUuOTRMODYuMjIgMTgzLjIyTDg2LjUgMTcwLjVMOTkuNzggMTcwLjIyTDExMy4wNSAxNjkuOTRMMTEyLjc4IDE1NS4yMkwxMTIuNSAxNDAuNUw5OS41IDE0MC41TDg2LjUgMTQwLjVMODYuNSAxMTNMODYuNSA4NS41TDk5LjUgODVMMTEyLjUgODQuNUwxMTIuNzggNzEuMjhMMTEzLjA2IDU4LjA2TDEyNS43OCA1Ny43OEwxMzguNSA1Ny41TDEzOC41IDQ0LjVMMTM4LjUgMzEuNUwxMjUuNzUgMzEuMjJMMTEzIDMwLjk0TDExMyAxOC4wOEMxMTMgNy43NCAxMTMuMjkgNS4xIDExNC40NiA0LjY2QzExNi43NyAzLjc3IDEzOC41OCA1Ljg0IDE0NS4zNCA3LjU4QzE1My43NiA5Ljc2IDE2Ny4wMyAxNS4zNSAxNzUuNDggMjAuMjhDMTgzLjYxIDI1LjAyIDIwMS4wNCA0MS40OSAyMDcuNDQgNTAuNDhDMjE5Ljg1IDY3LjkgMjI3LjkyIDkzLjE0IDIyNy45OCAxMTQuNzZDMjI4LjAzIDEzMS4zMSAyMjEuOTMgMTUzLjg0IDIxMy43NCAxNjcuMzRDMjA4LjU5IDE3NS44NCAxOTkuOTEgMTg3LjE0IDE5NC41OSAxOTIuMjdDMTg5LjY2IDE5Ny4wMiAxNzcgMjA2LjQyIDE3MC44OCAyMDkuODhDMTU3Ljk1IDIxNy4xNyAxMzUuODQgMjIzIDEyMS4xMiAyMjNMMTEzLjA2IDIyM0wxMTIuNzggMjA5Ljc1Wk02MCAyMTBMNjAgMTk4TDcxIDE5OEw4MiAxOThMODIgMjEwTDgyIDIyMkw3MSAyMjJMNjAgMjIyTDYwIDIxMFpNNjcyIDIxMC4zNUM2NDguMjkgMjA3LjE4IDYzMC40MSAxOTQuMzUgNjIyLjc1IDE3NUM2MTguMjYgMTYzLjY2IDYxNy42MiAxNjAuMDMgNjE3LjU2IDE0NS41QzYxNy40OSAxMjguOTcgNjE4LjkzIDEyMi4zOSA2MjUuNzMgMTA4LjA4QzYzNC41OSA4OS40NCA2NTAuNTYgNzcuNzUgNjcxLjg3IDc0LjNDNzExLjEyIDY3Ljk1IDczNS4yNSA5MS4wNiA3MzUuNDUgMTM1LjE2QzczNS41IDE0Ni4xNyA3MzUuMzYgMTQ2Ljk4IDczMy4wOCAxNDkuNDFMNzMwLjY1IDE1Mkw2ODkuNCAxNTJDNjQyLjY1IDE1MiA2NDQuMzggMTUxLjcxIDY0Ni4yNyAxNTkuMTdDNjQ3Ljk0IDE2NS43NiA2NTMuMTQgMTc0LjEyIDY1Ny43NCAxNzcuNjNDNjY0LjUxIDE4Mi43OSA2NzIuMTQgMTg1LjIgNjgzLjUgMTg1Ljc0QzY5NC42OSAxODYuMjcgNzAxLjU1IDE4NC45MyA3MTMuNDQgMTc5Ljg4QzcxNy4yNSAxNzguMjYgNzIwLjg5IDE3Ny4xMyA3MjEuNTEgMTc3LjM3QzcyMy4yOCAxNzguMDQgNzMxLjI1IDE5NC40NSA3MzAuODUgMTk2LjU5QzczMC4zNCAxOTkuMzYgNzE3LjI2IDIwNS41MyA3MDYuNSAyMDguMDZDNjk3LjY5IDIxMC4xNCA2NzkuNDcgMjExLjM1IDY3MiAyMTAuMzVaTTg1Ny44MyAyMDkuOThDODQ3LjU2IDIwOC4yNCA4MzIuNTggMjAyLjc0IDgyOC43OCAxOTkuM0M4MjcuOCAxOTguNDIgODI3IDE5Ni42OSA4MjcgMTk1LjQ2QzgyNyAxOTIuOTkgODM0LjI4IDE3Ny44NCA4MzYuMjUgMTc2LjIxQzgzNy4xNSAxNzUuNDcgODQwLjIxIDE3Ni41MSA4NDcuMTggMTc5Ljk0Qzg1OC4yOCAxODUuMzkgODY0Ljk5IDE4Ny4wMSA4NzYuNDIgMTg2Ljk4Qzg4OC45NiAxODYuOTQgODk2LjYgMTgyLjQgODk3LjY4IDE3NC4zNUM4OTguNjggMTY2Ljg2IDg5My44NSAxNjMuMjIgODcwLjE5IDE1My42NEM4NTIuOSAxNDYuNjMgODQ1LjU0IDE0Mi40OSA4MzkuNTIgMTM2LjM2QzgyOC4zIDEyNC45NSA4MjYuODcgMTA1LjQgODM2LjI0IDkxLjM4Qzg0MC4zOSA4NS4xNyA4NDkuOTQgNzguNzEgODU5LjM0IDc1Ljc1Qzg2OS4wNyA3Mi43IDg5My43IDcyLjUgOTA0LjUgNzUuMzlDOTEzLjM4IDc3Ljc2IDkyMC42OCA4MS40MSA5MjEuNDYgODMuODZDOTIyLjU4IDg3LjM5IDkxMy44NCAxMDguNjEgOTExLjY3IDEwNy42MkM4OTYgMTAwLjUyIDg4My4xNiA5Ny4yMSA4NzYuMTkgOTguNDZDODY4LjgzIDk5Ljc5IDg2NS4wMiAxMDEuMzUgODYyLjA0IDEwNC4yNkM4NTkuNTkgMTA2LjY2IDg1OSAxMDguMDcgODU5IDExMS41MkM4NTkgMTE1LjE2IDg1OS41MiAxMTYuMjUgODYyLjUzIDExOC44OUM4NjYuOTkgMTIyLjgxIDg3MC42NSAxMjQuNjUgODg1LjQxIDEzMC40MUM5MDguNSAxMzkuNDIgOTE3Ljc0IDE0NS45NCA5MjIuODMgMTU2LjgxQzkyNS4xNyAxNjEuNzkgOTI1LjUgMTYzLjc0IDkyNS40OSAxNzIuNUM5MjUuNDggMTgxLjQxIDkyNS4xNyAxODMuMTIgOTIyLjY4IDE4OC4yMUM5MTcuNjYgMTk4LjQ4IDkwNi4wMSAyMDYuNDEgODkxLjQxIDIwOS41Qzg4NC4wOCAyMTEuMDUgODY1Ljc2IDIxMS4zMSA4NTcuODMgMjA5Ljk4Wk0xMTU2LjE2IDIwOS41NkMxMTM3LjUyIDIwNi4wMSAxMTIwLjY0IDE5My4xNyAxMTEzLjExIDE3Ni44NEMxMTA4LjA5IDE2NS45NSAxMTA2LjU0IDE1OC4wNSAxMTA2LjY2IDE0NEMxMTA2Ljk5IDEwMy4xNiAxMTMwLjY2IDc2LjUxIDExNjkuMzEgNzMuNDRDMTE5NC43NiA3MS40MiAxMjE3LjMzIDgxLjgxIDEyMjkuMTkgMTAxQzEyNDQuNjYgMTI2LjAxIDEyNDMuMTMgMTYwLjg0IDEyMjUuNDkgMTg1LjU3QzEyMjAuNjYgMTkyLjMzIDEyMTIuMzEgMTk5LjI4IDEyMDMuODQgMjAzLjU4QzExOTEuNzQgMjA5LjcyIDExNzAuNzcgMjEyLjM1IDExNTYuMTYgMjA5LjU2Wk0xNjAwLjIgMjA5LjVDMTU5Mi40IDIwOC4wOCAxNTgwLjM0IDIwMi44NyAxNTc0LjI5IDE5OC4zQzE1NTUuOTUgMTg0LjQ0IDE1NDcuNDggMTYxLjAzIDE1NTAuOTMgMTMzLjY4QzE1NTQuNzIgMTAzLjU1IDE1NzEuNjUgODMuMTEgMTU5OC44NiA3NS44MUMxNjA5LjE4IDczLjA0IDE2MjguNzEgNzIuODIgMTYzNy45MSA3NS4zN0MxNjU5LjY1IDgxLjM4IDE2NzQuMTEgOTUuNzUgMTY4MC42MSAxMTcuODNDMTY4Mi43NCAxMjUuMDggMTY4My4xNyAxMjguNTUgMTY4My4xOCAxMzlDMTY4My4yMSAxNjUuMDEgMTY3NC44IDE4NC40NyAxNjU4LjExIDE5Ny4wMkMxNjQyLjcyIDIwOC41OSAxNjIxLjAxIDIxMy4yNyAxNjAwLjIgMjA5LjVaTTE4NDkuNSAyMDkuNTJDMTgxOS44NiAyMDQuMDMgMTgwMS4wNSAxODEuMzQgMTc5OS4zMiAxNDlDMTc5Ny4zOCAxMTIuNjYgMTgxNi4xMiA4NC4zMyAxODQ3LjcgNzUuODVDMTg1OC4xMSA3My4wNSAxODc3LjU0IDcyLjgzIDE4ODcgNzUuNEMxOTEyLjQ1IDgyLjMyIDE5MjguMTYgMTAxLjYyIDE5MzIuMDUgMTMwLjc1QzE5MzQuMTcgMTQ2LjYyIDE5MzAuMjYgMTY2LjMxIDE5MjIuMjggMTc5Ljk2QzE5MDguNzIgMjAzLjEzIDE4NzkuMzYgMjE1LjA2IDE4NDkuNSAyMDkuNTJaTTIwNzAgMjA5LjA1QzIwNTQuNzcgMjA1LjggMjA0MyAyMDAuMDQgMjA0MyAxOTUuODNDMjA0MyAxOTMuNDMgMjA1MC41NyAxNzcuNiAyMDUyLjQ3IDE3Ni4wMkMyMDUzLjQ3IDE3NS4yIDIwNTUuMzcgMTc1Ljc5IDIwNjAuMjEgMTc4LjQ1QzIwNzAuNDcgMTg0LjA5IDIwNzcuMzkgMTg2LjEgMjA4OC43MyAxODYuNzJDMjEwMC44NSAxODcuMzkgMjEwNi4xNCAxODUuODkgMjExMC45NSAxODAuNEMyMTE2LjMyIDE3NC4yOCAyMTE0LjUyIDE2Ny44OCAyMTA1LjkgMTYyLjQ0QzIxMDMuNzggMTYxLjEgMjA5Ni4zIDE1Ny43MiAyMDg5LjI4IDE1NC45MkMyMDU5LjcgMTQzLjEzIDIwNDkuMDIgMTM0LjE4IDIwNDYuNSAxMTkuMDRDMjA0My4wNCA5OC4zMyAyMDU0LjE2IDgyLjE1IDIwNzYuNSA3NS4zOUMyMDg1LjE5IDcyLjc2IDIxMTAuNjggNzIuNzYgMjEyMC41IDc1LjM5QzIxMjguODYgNzcuNjIgMjEzNi44NiA4MS40NyAyMTM3LjcgODMuNjZDMjEzOC44NiA4Ni42NyAyMTI5LjczIDEwOC41NSAyMTI3LjcgMTA3LjYzQzIxMTguMTcgMTAzLjMzIDIxMDguMjcgOTkuODkgMjEwMi43NCA5OC45NkMyMDg4LjE3IDk2LjUgMjA3NSAxMDIuMjEgMjA3NSAxMTAuOThDMjA3NSAxMTguMjUgMjA4MC4wMyAxMjIgMjEwMC41NiAxMzAuMDZDMjEyNC45IDEzOS42MiAyMTMzLjY0IDE0NS43MiAyMTM4LjgyIDE1Ni43OEMyMTQxLjE3IDE2MS43OSAyMTQxLjUgMTYzLjc0IDIxNDEuNSAxNzIuNUMyMTQxLjUgMTgxLjQzIDIxNDEuMiAxODMuMTEgMjEzOC42OSAxODguMjFDMjEzMy42NSAxOTguNDkgMjEyMi4wMSAyMDYuNDEgMjEwNy40MSAyMDkuNUMyMDk3Ljk3IDIxMS41IDIwODAuNTIgMjExLjI5IDIwNzAgMjA5LjA1Wk0yODMuNjIgMjA4LjcxQzI4MS44NiAyMDcuNDIgMjgxLjc5IDIwMy42NCAyODEuOTQgMTE2LjQzQzI4Mi4wNyAzOC4zNSAyODIuMyAyNS4yOSAyODMuNTUgMjQuMDNDMjg0Ljc5IDIyLjc5IDI5MC43MSAyMi42MSAzMjMuMjUgMjIuODZDMzYyLjkxIDIzLjE2IDM2OC45OCAyMy42OSAzNzkuNjQgMjcuOEMzOTQuMzUgMzMuNDYgNDAzIDQzLjM3IDQwNy43NSA2MEM0MTAuMiA2OC41OSA0MDkuOTEgODguNTQgNDA3LjE5IDk3Ljg5QzQwMC4yMyAxMjEuODkgMzg0LjY4IDEzNS42OCAzNTkgMTQwLjYyQzM1NC44OCAxNDEuNDEgMzQzLjAyIDE0Mi4zNSAzMzIuNjYgMTQyLjY5QzMxOC40NCAxNDMuMTcgMzEzLjQ3IDE0My42NyAzMTIuNDEgMTQ0LjczQzMxMS4yNiAxNDUuODkgMzExIDE1MS43OSAzMTEgMTc3LjFDMzExIDIwMSAzMTAuNzEgMjA4LjE1IDMwOS43NSAyMDguNDVDMzA5LjA2IDIwOC42NiAzMDMuMzIgMjA5LjExIDI5Ni45OCAyMDkuNDVDMjg4LjE0IDIwOS45MSAyODUuMDMgMjA5Ljc0IDI4My42MiAyMDguNzFaTTQ0NC41MyAyMDkuMjFDNDM4LjkxIDIwOS4wMiA0MzUuMjggMjA4LjQ3IDQzNC44IDIwNy43MUM0MzMuNjYgMjA1LjkyIDQzMy44NCA3Ny44MSA0MzQuOTggNzZDNDM1LjcyIDc0LjgzIDQzOC4zOCA3NC40NyA0NDcuMTggNzQuMzZDNDUzLjM3IDc0LjI4IDQ1OS40NiA3NC42IDQ2MC43MiA3NS4wOEw0NjMgNzUuOTVMNDYzIDE0MS4zNUM0NjMgMTkyLjQ4IDQ2Mi43MyAyMDYuOTcgNDYxLjc1IDIwNy43NEM0NjAgMjA5LjEyIDQ1NC45NiAyMDkuNTUgNDQ0LjUzIDIwOS4yMVpNNDkxLjI1IDIwOS4yNkM0ODYuNDggMjA5LjA2IDQ4MyAyMDguNDkgNDgzIDIwNy44OUM0ODMgMjA2LjY0IDQ4NS42OSAyMDIuNjYgNTAxLjg0IDE4MEM1MDguNzEgMTcwLjM4IDUxNi44OCAxNTguOSA1MjAuMDIgMTU0LjVDNTI3LjI1IDE0NC4zNSA1MjkuMTcgMTQwLjYgNTI4LjI5IDEzOC4zMUM1MjcuOTEgMTM3LjMyIDUyMS43NCAxMjguMTggNTE0LjU5IDExOEM1MDcuNDQgMTA3LjgzIDQ5OC4zMSA5NC44MyA0OTQuMjkgODkuMTNDNDkwLjI4IDgzLjQyIDQ4NyA3Ny45NCA0ODcgNzYuOTVDNDg3IDc0LjU2IDQ5NC42NyA3My41NyA1MDguMTkgNzQuMjJDNTIwLjI2IDc0LjggNTE4LjcxIDczLjQ5IDUzMy44NiA5NkM1NDYuNjQgMTE0Ljk4IDU0Ny4zOSAxMTYgNTQ4LjY2IDExNkM1NTAuNjcgMTE2IDU1Ni4xMyAxMDguODcgNTY2LjQyIDkyLjgzQzU3Mi4zMSA4My42NyA1NzcuODggNzUuODcgNTc4LjgxIDc1LjUxQzU3OS43NCA3NS4xNSA1ODYuMTIgNzQuNjEgNTkzIDc0LjMyQzYwMy4wMiA3My45IDYwNS44OCA3NC4wOSA2MDcuNDIgNzUuMjZDNjA5LjI4IDc2LjY4IDYwOS4yNSA3Ni44NCA2MDYuMzYgODEuMTFDNjAyLjU1IDg2LjczIDU5Ny40NyA5My45OCA1ODguNTcgMTA2LjVDNTgwLjMyIDExOC4xIDU3OS45NSAxMTguNjMgNTcyIDEzMC4zNUM1NjguNyAxMzUuMjIgNTY2IDE0MC4wNyA1NjYgMTQxLjEyQzU2NiAxNDMuNzIgNTY4LjM1IDE0Ny4zMiA1OTAuMDUgMTc4LjAyQzYwMC4zNyAxOTIuNjEgNjA4Ljk2IDIwNS40MyA2MDkuMTUgMjA2LjUyQzYwOS40NyAyMDguMzUgNjA4LjcxIDIwOC41NiA1OTkgMjA5LjI2QzU5My4yMyAyMDkuNjggNTg2LjIgMjA5Ljc3IDU4My4zOCAyMDkuNDZDNTc3LjcyIDIwOC44MyA1NzUuMzQgMjA2LjkgNTcwLjQ2IDE5OUM1NjUuNDYgMTkwLjkgNTUxLjc1IDE3MS41NyA1NDguNjQgMTY4LjI1QzU0Ni45NyAxNjYuNDYgNTQ1LjA4IDE2NSA1NDQuNDMgMTY1QzU0My4zMiAxNjUgNTM5LjIgMTcwLjg1IDUyMy42NyAxOTQuNUM1MjAuMDYgMjAwIDUxNi40MyAyMDUuMzQgNTE1LjYgMjA2LjM4QzUxNC4wMSAyMDguMzUgNTA3LjU0IDIxMC4wMiA1MDIuNSAyMDkuNzVDNTAwLjg1IDIwOS42NiA0OTUuNzkgMjA5LjQ0IDQ5MS4yNSAyMDkuMjZaTTc4NS4zOCAyMDguNjNDNzc1LjU3IDIwNi4xNyA3NjguNjIgMjAwLjc2IDc2NS4zMyAxOTNDNzYxLjI0IDE4My4zNSA3NjEgMTc4LjA2IDc2MSA5Ni4xMUM3NjEgMzYuMjIgNzYxLjI5IDE3LjExIDc2Mi4yIDE2LjJDNzYzLjkxIDE0LjQ5IDc4Ny41OCAxNC41MyA3ODguNjkgMTYuMjVDNzg5LjE0IDE2Ljk0IDc4OS41IDUyLjYgNzg5LjUgOTUuNUM3ODkuNSAxNzIuMTIgNzg5LjU0IDE3My41NiA3OTEuNTMgMTc3Qzc5NC4zMSAxODEuNzggNzk5LjEgMTg0IDgwNi42NyAxODRDODE2LjI1IDE4NCA4MTYgMTgzLjY5IDgxNiAxOTUuNDJDODE2IDIwOS41NCA4MTYuMDkgMjA5LjQ1IDgwMS44MyAyMDkuNzFDNzk1LjIyIDIwOS44MyA3ODguMzcgMjA5LjM4IDc4NS4zOCAyMDguNjNaTTEwNTEgMjA4LjQxQzEwNDMuNjIgMjA2LjQ2IDEwMzUuMjQgMjAwLjU4IDEwMzEuNzMgMTk0Ljg5QzEwMjYuMzggMTg2LjIyIDEwMjUuODcgMTgxLjkzIDEwMjUuMyAxNDAuNUMxMDI0LjkzIDExMy43MSAxMDI0LjQ0IDEwMi4yOCAxMDIzLjY0IDEwMS43N0MxMDIzLjAxIDEwMS4zNiAxMDE5LjM1IDEwMS4wMiAxMDE1LjUgMTAxQzEwMDMuNCAxMDAuOTUgMTAwNCAxMDEuNjMgMTAwNCA4Ny45OUMxMDA0IDc0LjQzIDEwMDMuNTYgNzUuMDEgMTAxMy44MyA3NC45OUMxMDI1Ljg4IDc0Ljk2IDEwMjUgNzYuNCAxMDI1IDU2LjU4QzEwMjUgNDQuNTUgMTAyNS4zNiAzOS4wNCAxMDI2LjIgMzguMkMxMDI3LjQ2IDM2Ljk0IDEwNDguNCAzNi40NSAxMDUxLjQyIDM3LjYxQzEwNTIuNzggMzguMTMgMTA1MyA0MC42NiAxMDUzIDU1LjU1QzEwNTMgNjguMzIgMTA1My4zMyA3My4wOSAxMDU0LjI1IDczLjY4QzEwNTQuOTQgNzQuMTIgMTA2Mi43IDc0LjYzIDEwNzEuNSA3NC44MkMxMDg0Ljc3IDc1LjEgMTA4Ny43MSA3NS40MyAxMDg4Ljc1IDc2Ljc3QzEwOTAuNjUgNzkuMjMgMTA5MC40MiA5NS45NSAxMDg4LjQ0IDk4Ljc4QzEwODYuOTUgMTAwLjkxIDEwODYuMjUgMTAxIDEwNzEuNjkgMTAxLjAyQzEwNjMuMzQgMTAxLjAyIDEwNTUuODMgMTAxLjQ2IDEwNTUgMTAxLjk4QzEwNTMuNzEgMTAyLjc5IDEwNTMuNSAxMDcuODggMTA1My41IDEzNy43MUMxMDUzLjUgMTcwLjg0IDEwNTMuNiAxNzIuNjcgMTA1NS41MyAxNzZDMTA1OC45IDE4MS44MyAxMDYzLjY1IDE4My41IDEwNzYuNzkgMTgzLjVDMTA4NS43MyAxODMuNSAxMDg4LjI3IDE4My44MSAxMDg5LjAyIDE4NUMxMDg5LjU0IDE4NS44MiAxMDg5Ljk4IDE5MC4zNCAxMDg5Ljk4IDE5NS4wM0MxMDkwIDIwNC45NiAxMDg4LjgyIDIwNy40IDEwODMuMjIgMjA4Ljk2QzEwNzcuNzggMjEwLjQ3IDEwNTcuNDcgMjEwLjEzIDEwNTEgMjA4LjQxWk0xNzQ0LjIzIDIwOC40NUMxNzI5Ljc5IDIwNC41NiAxNzIyLjQ5IDE5Ni4yOCAxNzE5LjQzIDE4MC4zMUMxNzE4LjM2IDE3NC42OSAxNzE4IDE2NC4wMSAxNzE4IDEzNy41MkMxNzE4IDk3LjI2IDE3MTkuMDMgMTAxIDE3MDcuOTggMTAxQzE3MDQuMjEgMTAxIDE2OTkuOTggMTAwLjU2IDE2OTguNTcgMTAwLjAyQzE2OTYuMDYgOTkuMDcgMTY5NiA5OC44MiAxNjk2IDg5LjU5QzE2OTYgODQuMzkgMTY5Ni40MyA3OC45OSAxNjk2Ljk2IDc3LjZDMTY5Ny44OSA3NS4xNiAxNjk4LjI4IDc1LjA2IDE3MDcuNzEgNzQuNzlMMTcxNy41IDc0LjVMMTcxNy43NyA1Ny4wN0MxNzE3Ljk0IDQ2LjI5IDE3MTguNDcgMzkuMTQgMTcxOS4xNSAzOC4zMkMxNzIwLjYyIDM2LjU1IDE3NDMuMDQgMzYuNDQgMTc0NC44IDM4LjJDMTc0NS42NCAzOS4wNCAxNzQ2IDQ0LjQ3IDE3NDYgNTYuMjJMMTc0NiA3My4wNUwxNzQ4LjU3IDc0LjAyQzE3NDkuOTggNzQuNTYgMTc1Ny44MiA3NSAxNzY2LjAxIDc1QzE3NzQuMTkgNzUgMTc4MS4xNCA3NS40MiAxNzgxLjQ2IDc1Ljk0QzE3ODEuNzggNzYuNDUgMTc4Mi4xNCA4MS42NiAxNzgyLjI3IDg3LjUxQzE3ODIuNTcgMTAxLjQ1IDE3ODMuMTkgMTAxIDE3NjMuODggMTAxQzE3NTEuNTkgMTAxIDE3NDguODcgMTAxLjI3IDE3NDcuNTEgMTAyLjYzQzE3NDYuMDYgMTA0LjA5IDE3NDUuOTIgMTA3Ljk3IDE3NDYuMTkgMTM5LjA2QzE3NDYuNDkgMTcyLjM2IDE3NDYuNTkgMTczLjk4IDE3NDguNTYgMTc2LjYxQzE3NTIuNTggMTgxLjk5IDE3NTcuMjYgMTgzLjUgMTc2OS45MyAxODMuNUMxNzgzLjA3IDE4My41IDE3ODIuNDkgMTgzLjAxIDE3ODIuODUgMTk0LjVDMTc4My4yOSAyMDguMTIgMTc4MC43MSAyMTAuMDMgMTc2MiAyMDkuOTNDMTc1NC41NiAyMDkuODkgMTc0Ny4zNyAyMDkuMjkgMTc0NC4yMyAyMDguNDVaTTEzMzMuMzUgMjA3LjQ4QzEzMzMuMDMgMjA2LjY0IDEzMzMuMjYgMTkwLjExIDEzMzMuODYgMTcwLjczQzEzMzQuNDcgMTUxLjM1IDEzMzUuMjQgMTEwLjk4IDEzMzUuNTkgODFDMTMzNi4xMyAzMy44NiAxMzM2LjQzIDI2LjI2IDEzMzcuNzYgMjQuNzVDMTMzOS43NyAyMi40OCAxMzU0LjY0IDIyLjI0IDEzNTguODMgMjQuNDFDMTM2MS43MiAyNS45IDEzNjIuNjEgMjYuOTcgMTM4Mi43NCA1My4wMUMxMzk5LjUgNzQuNjkgMTQxOC44MiA5OC45NSAxNDI0LjMzIDEwNS4yNUMxNDI1LjY1IDEwNi43NiAxNDI3LjQyIDEwOCAxNDI4LjI3IDEwOEMxNDI5LjExIDEwOCAxNDMyLjAyIDEwNS40MSAxNDM0LjczIDEwMi4yNUMxNDM5Ljg2IDk2LjI4IDE0NzAuMDQgNTguMDEgMTQ4NS40MiAzOEMxNDkwLjI4IDMxLjY4IDE0OTUuMjEgMjUuNzIgMTQ5Ni4zOCAyNC43N0MxNDk5LjExIDIyLjU1IDE1MTcuMDggMjIuMTkgMTUxOC44NSAyNC4zMkMxNTE5LjU5IDI1LjIxIDE1MjAuMTMgMzkuMzggMTUyMC41IDY3LjU3QzE1MjAuOCA5MC42MyAxNTIxLjU0IDEzMS4yMiAxNTIyLjE0IDE1Ny43NkMxNTIzIDE5NS45MSAxNTIyLjk3IDIwNi4zMyAxNTIxLjk5IDIwNy41MUMxNTIwLjM1IDIwOS40OSAxNDk1LjggMjA5LjcgMTQ5NC4yNiAyMDcuNzVDMTQ5My42NSAyMDYuOTggMTQ5My4wMyAxODMuMzcgMTQ5Mi42NCAxNDYuMzRDMTQ5Mi4yOSAxMTMuMjUgMTQ5MS43MyA4NS40NiAxNDkxLjQgODQuNTlDMTQ5MS4wNiA4My43MiAxNDkwIDgzIDE0ODkuMDQgODNDMTQ4Ni45OCA4MyAxNDgyLjY3IDg3Ljg4IDE0NjEuMjEgMTE0LjVDMTQyNy41MyAxNTYuMjkgMTQyOS40IDE1NC4zNSAxNDI1LjA4IDE1Mi4wNEMxNDIzLjM5IDE1MS4xNCAxNDEwLjk1IDEzNi4zNSAxNDAwLjczIDEyMy4xQzEzOTkuMiAxMjEuMTIgMTM5Ny40IDExOC44MyAxMzk2LjcyIDExOEMxMzk2LjA0IDExNy4xNyAxMzkwLjA5IDEwOS43NSAxMzgzLjUxIDEwMS41QzEzNjguNjcgODIuOSAxMzY3LjE2IDgxLjQxIDEzNjUuMTcgODMuNEMxMzYzLjkzIDg0LjY0IDEzNjMuNjIgOTQuMjEgMTM2My4xIDE0NS45N0MxMzYyLjU4IDE5OC43NSAxMzYyLjMgMjA3LjIgMTM2MSAyMDguMDJDMTM2MC4xNyAyMDguNTQgMTM1My43NSAyMDguOTggMTM0Ni43MiAyMDguOThDMTMzNi4xOSAyMDkgMTMzMy44MyAyMDguNzMgMTMzMy4zNSAyMDcuNDhaTTE5NjEuMjYgMjA3Ljc1QzE5NjAuNjMgMjA2Ljk2IDE5NjAuMjcgMTgyLjgzIDE5NjAuMjggMTQxLjc1QzE5NjAuMjkgODYuNTUgMTk2MC41IDc2LjgzIDE5NjEuNzMgNzUuODFDMTk2My41NyA3NC4yOCAxOTgyLjU3IDc0LjE3IDE5ODQuMDcgNzUuNjdDMTk4NC42NiA3Ni4yNiAxOTg1LjQ3IDc5LjE3IDE5ODUuODcgODIuMTRDMTk4Ni42NCA4Ny44MiAxOTg3LjUyIDkwIDE5ODkuMDIgOTBDMTk4OS41MiA5MCAxOTkzLjQ3IDg3LjU4IDE5OTcuODEgODQuNjNDMjAwNi44MSA3OC41IDIwMTQuMDYgNzUuNTEgMjAyMy41OCA3My45OUMyMDMzLjA1IDcyLjQ3IDIwMzMgNzIuMzkgMjAzMyA4OS4xNUwyMDMzIDEwMy4wNUwyMDMwLjQzIDEwNC4wMkMyMDI5LjAyIDEwNC41NiAyMDI1LjE4IDEwNSAyMDIxLjkgMTA1QzIwMTEuNzYgMTA1IDE5OTguMDIgMTEwLjggMTk5Mi4wNiAxMTcuNTlMMTk4OSAxMjEuMDdMMTk4OSAxNjMuODNDMTk4OSAxOTUuNzYgMTk4OC43IDIwNi45IDE5ODcuOCAyMDcuOEMxOTg2LjA5IDIwOS41MSAxOTYyLjYxIDIwOS40NiAxOTYxLjI2IDIwNy43NVpNMzMuNjcgMTkxLjMzQzMzLjMgMTkwLjk3IDMzIDE4Ni42OSAzMyAxODEuODNMMzMgMTczTDQyLjUgMTczTDUyIDE3M0w1MiAxODIuNUw1MiAxOTJMNDMuMTcgMTkyQzM4LjMxIDE5MiAzNC4wMyAxOTEuNyAzMy42NyAxOTEuMzNaTTExODYuMDcgMTgzLjA3QzExOTguMTEgMTc4LjU2IDEyMDYuMzkgMTY3LjQ5IDEyMDkuNjcgMTUxLjVDMTIxNi4wMyAxMjAuNSAxMTkyLjQ1IDk1LjE2IDExNjQgMTAyLjQxQzExNTEuOCAxMDUuNTIgMTE0My4xOSAxMTMuNTQgMTEzOC42OCAxMjZDMTEzMC44NyAxNDcuNTggMTEzNy45IDE3Mi43OSAxMTU0LjAzIDE4MS4wMUMxMTY0LjM3IDE4Ni4yOSAxMTc1LjU2IDE4Ny4wMSAxMTg2LjA3IDE4My4wN1pNMTYyOSAxODMuNTJDMTYzOS43NyAxNzkuNzMgMTY0OC4zOCAxNzAuMDYgMTY1Mi4xMiAxNTcuNTVDMTY1My44MiAxNTEuODggMTY1NC4xNCAxNDguNTcgMTY1My44NSAxMzkuNTNDMTY1My41MyAxMjkuMzcgMTY1My4yMyAxMjcuOSAxNjUwLjE2IDEyMS42OEMxNjQ2LjA5IDExMy40IDE2NDAuMyAxMDcuNDkgMTYzMy4wMSAxMDQuMTdDMTYyOC40MyAxMDIuMDggMTYyNS45IDEwMS42NSAxNjE4IDEwMS41OEMxNjA3Ljc1IDEwMS40OSAxNjA0LjY4IDEwMi4zMiAxNTk2LjY4IDEwNy4zQzE1ODQuMjIgMTE1LjA1IDE1NzcuNjUgMTMyLjc3IDE1ODAuMDcgMTUyLjA4QzE1ODIuMjggMTY5LjczIDE1OTEuNzIgMTgwLjg3IDE2MDcuOTggMTg1LjAzQzE2MTIuNzIgMTg2LjI0IDE2MjMuNDggMTg1LjQ3IDE2MjkgMTgzLjUyWk0xODc4LjE4IDE4My4zMkMxODg4Ljc4IDE3OS4xNiAxODk2LjA0IDE3MS40NCAxOTAwLjU5IDE1OS41QzE5MDIuMTEgMTU1LjUxIDE5MDIuNSAxNTEuODcgMTkwMi41IDE0MS41QzE5MDIuNSAxMjguOTEgMTkwMi40IDEyOC4yOSAxODk5LjMxIDEyMkMxODk1LjMyIDExMy44NyAxODg5LjEyIDEwNy40OCAxODgyIDEwNC4xNkMxODc3LjI4IDEwMS45NiAxODc1LjA4IDEwMS41OSAxODY2LjQ4IDEwMS41NUMxODU3LjEyIDEwMS41IDE4NTYuMDIgMTAxLjcyIDE4NDkuNzcgMTA0Ljc5QzE4MzUuOTkgMTExLjU3IDE4MjguNTkgMTI1LjQ2IDE4MjguNTkgMTQ0LjVDMTgyOC41OSAxNjEuOTcgMTgzNS4yOSAxNzUuNDUgMTg0Ni45OSAxODEuNUMxODU2LjA0IDE4Ni4xOCAxODY4Ljk5IDE4Ni45MyAxODc4LjE4IDE4My4zMlpNNjAgMTU1LjEyTDYwIDE0My4xOEw2OC4yNSAxNDIuNUM3Mi43OSAxNDIuMTMgNzguMDggMTQxLjk3IDgwIDE0Mi4xNkw4My41IDE0Mi41TDgzLjUgMTU0LjVMODMuNSAxNjYuNUw3MS43NSAxNjYuNzhMNjAgMTY3LjA2TDYwIDE1NS4xMlpNNC45NSAxNjQuNjJDNC43IDE2NC4zNyA0LjY0IDE1OS44NiA0LjgyIDE1NC41OUw1LjE0IDE0NUwxNC41NyAxNDVMMjQgMTQ1TDI0IDE1NC41TDI0IDE2NEwxNi4wOCAxNjRDMTEuNzMgMTY0IDcuNTQgMTY0LjI0IDYuNzggMTY0LjUzQzYuMDIgMTY0LjgyIDUuMTkgMTY0Ljg2IDQuOTUgMTY0LjYyWk0zMiAxMjcuNUwzMiAxMTUuOTNMNDIuNzIgMTE2LjIyTDUzLjQ1IDExNi41TDU0LjIgMTIwLjkyQzU0LjYxIDEyMy4zNSA1NC42MyAxMjguMyA1NC4yMyAxMzEuOTJMNTMuNSAxMzguNUw0Mi43NSAxMzguNzhMMzIgMTM5LjA3TDMyIDEyNy41Wk0xMzkgMTI2Ljk3TDEzOSAxMTQuOTRMMTI1Ljc1IDExNS4yMkwxMTIuNSAxMTUuNUwxMTIuMjIgMTI3LjI1TDExMS45NCAxMzlMMTI1LjQ3IDEzOUwxMzkgMTM5TDEzOSAxMjYuOTdaTTcwNi44OSAxMjcuMDlDNzEwLjAyIDEyNS4xMSA3MDYuMTUgMTEyLjEzIDcwMC42NiAxMDYuMTVDNjkyLjg0IDk3LjY0IDY3NC4xNyA5Ni44MiA2NjIuMDkgMTA0LjQ0QzY1NS4xIDEwOC44NSA2NDcuNTIgMTE5LjY4IDY0OC4xOSAxMjQuMjlDNjQ4LjM4IDEyNS41NiA2NDkuNSAxMjYuNjkgNjUxIDEyNy4xMUM2NTQuNzEgMTI4LjEzIDcwNS4yNyAxMjguMTEgNzA2Ljg5IDEyNy4wOVpNMzU4LjQ1IDExNC4wMkMzNjUuMTQgMTExLjQyIDM3My4yMiAxMDQuNDkgMzc1Ljk0IDk5LjAyQzM4MC4wMiA5MC44MyAzODAuODcgNzguNiAzNzguMDMgNjguODhDMzc1LjUzIDYwLjI5IDM2OC41NiA1NC4wOSAzNTguNTQgNTEuNTJDMzUwLjkxIDQ5LjU3IDMxNC4wOSA0OS4zMSAzMTIuMiA1MS4yQzMxMS4zMiA1Mi4wOCAzMTEgNjAuNjggMzExIDgzLjY4TDMxMSAxMTQuOTZMMzEzLjc1IDExNS41N0MzMTUuMjYgMTE1LjkgMzE2Ljk1IDExNi4zNSAzMTcuNSAxMTYuNTdDMzE4LjA1IDExNi43OCAzMjYuMTUgMTE2LjgxIDMzNS41IDExNi42NEMzNTAuNDIgMTE2LjM3IDM1My4yMyAxMTYuMDQgMzU4LjQ1IDExNC4wMlpNNSAxMDBMNSA5MEwxNC41IDkwTDI0IDkwTDI0IDEwMEwyNCAxMTBMMTQuNSAxMTBMNSAxMTBMNSAxMDBaTTYxIDEwMEw2MSA5MEw3MSA5MEw4MSA5MEw4MSAxMDBMODEgMTEwTDcxIDExMEw2MSAxMTBMNjEgMTAwWk01OS40MyA4Mi42OUM1OS4xNiA4MS45NiA1OS4wNiA3Ni40NSA1OS4yMSA3MC40NEw1OS41IDU5LjVMNzEuMjUgNTkuMjJMODMgNTguOTRMODMgNzEuNDdMODMgODRMNzEuNDcgODRDNjIuODcgODQgNTkuODEgODMuNjcgNTkuNDMgODIuNjlaTTMzLjIxIDcyLjI1TDMzLjUgNjIuNUw0Mi41IDYyLjVMNTEuNSA2Mi41TDUxLjc5IDcyLjI1TDUyLjA3IDgyTDQyLjUgODJMMzIuOTMgODJMMzMuMjEgNzIuMjVaTTk3LjIyIDU3LjI2TDg1Ljk0IDU2LjkzTDg2LjIyIDQ0LjIyTDg2LjUgMzEuNUw5OS4yOCAzMS40MUwxMTIuMDYgMzEuMzNMMTExLjc4IDQ0LjRDMTExLjU1IDU1LjIgMTExLjI0IDU3LjQ5IDExMCA1Ny41M0MxMDkuMTcgNTcuNTYgMTAzLjQyIDU3LjQ0IDk3LjIyIDU3LjI2Wk00NDEuNzggNTYuODVDNDM2LjU2IDU1LjQgNDMxLjMyIDQ5LjMzIDQzMC40OSA0My43NkM0MjguNjUgMzEuNTIgNDM3LjY2IDIxLjA4IDQ1MC4xIDIxLjAyQzQ1NS40OCAyMSA0NjAuMjUgMjMuNzYgNDYzLjczIDI4LjkyQzQ2Ny4yIDM0LjA4IDQ2Ni45NCA0My4xMyA0NjMuMTMgNDkuMTNDNDU4Ljc5IDU1Ljk4IDQ1MC4wNSA1OS4xNCA0NDEuNzggNTYuODVaTTEzLjE2IDUzLjIxQzUuMjIgNTIuOTcgMy45IDUyLjY5IDQuNDEgNTEuMzhDNC43MyA1MC41MyA1IDQ2LjI3IDUgNDEuOTJMNSAzNEwxNC41IDM0TDI0IDM0TDI0IDQzLjQ0QzI0IDQ4LjYzIDIzLjY2IDUzLjAyIDIzLjI1IDUzLjE5QzIyLjg0IDUzLjM2IDE4LjMgNTMuMzcgMTMuMTYgNTMuMjFaTTYwIDE3TDYwIDVMNzEuNSA1TDgzIDVMODMgMTdMODMgMjlMNzEuNSAyOUw2MCAyOUw2MCAxN1oiLz48L3N2Zz4K" alt="Pixels to Motors" width="151" height="16"></a>
 <h2>ロボットの台数</h2><div class="seg" id="c-robots"></div>
 <h2>スタッフ</h2><div class="seg" id="c-staff"></div>
-<h2>混み具合</h2><div class="seg" id="c-crowd"></div>
+<h2>混み具合</h2><div class="seg" id="c-crowd"></div><p class="note" id="crowdNote"></p>
 <h2>速さ</h2><div class="seg" id="c-speed"></div>
 <div class="row"><button id="play">一時停止</button><button id="restart">最初から</button></div>
 <h2>日を変える</h2><div class="day"><button id="dayPrev" aria-label="前の日">‹</button><b id="dayLabel">1日目</b><button id="dayNext" aria-label="次の日">›</button></div><p class="note">条件はそのままで、お客さんの来る時刻や人数が違う日を見られます。同じ日なら、何度見ても同じ動きになります。</p>
 <div id="status"></div>
-<h2>いまの数字</h2><p class="note" id="phase"></p>
+<h2>いまの数字</h2><p class="note">テスト版の数字です。試算（v2.9）とは別に、人とロボットを動かして数えています（答え合わせ前）。</p><p class="note" id="phase"></p>
 <div class="nums">
 <div class="num"><b><span id="n-occ">0</span><small>卓</small></b><span>使っている卓</span></div>
 <div class="num"><b><span id="n-waiting">0</span><small>組</small></b><span>待合で待っている</span></div>
```

### 店のデータの読み方（stores_site.js・stores_bundle.js）

```js
// サイト（pixelstomotors.com/sim/serving-robot-3d）用：店のデータはサイトの公開データ（試算・Unity と同じ正）を、選んだときに読む
const FILES = { p0: 'p0_memory', p1: 'p1_efficient_corner', p2: 'p2_efficient_narrow', p3: 'p3_relaxed_center', p4: 'p4_relaxed_side', p5: 'p5_crammed' };
const cache = {};
export function loadStore(k) {
  if (!cache[k]) {
    const get = (n) => fetch(`/sim/layouts/unity/${n}.json`).then((r) => { if (!r.ok) throw new Error(`${n}.json を読めませんでした（${r.status}）`); return r.json(); });
    cache[k] = Promise.all([get(FILES[k]), get(FILES[k] + '_map')]).catch((e) => { delete cache[k]; throw e; });
  }
  return cache[k];
}
```

```js
// 試作（チャットの画面）用：6店のデータをページの中に含める
import p0 from '../stores/p0_memory.json'; import p0m from '../stores/p0_memory_map.json';
import p1 from '../stores/p1_efficient_corner.json'; import p1m from '../stores/p1_efficient_corner_map.json';
import p2 from '../stores/p2_efficient_narrow.json'; import p2m from '../stores/p2_efficient_narrow_map.json';
import p3 from '../stores/p3_relaxed_center.json'; import p3m from '../stores/p3_relaxed_center_map.json';
import p4 from '../stores/p4_relaxed_side.json'; import p4m from '../stores/p4_relaxed_side_map.json';
import p5 from '../stores/p5_crammed.json'; import p5m from '../stores/p5_crammed_map.json';
const S = { p0: [p0, p0m], p1: [p1, p1m], p2: [p2, p2m], p3: [p3, p3m], p4: [p4, p4m], p5: [p5, p5m] };
export async function loadStore(k) { return S[k]; }
```

### サイト用に書き出すスクリプト（build_site.py）と README

```python
"""サイト用のブラウザ版（pixelstomotors.com/sim/serving-robot-3d）を作る（2026-11-14）。
試作（チャットの画面）との違い：店のデータを中に含めず、サイトの public/sim/layouts/unity/ を読む。
head に説明・正規の住所・SNS 用の画像を足し、JavaScript を動かさない読み手（検索・AI）向けに、何のページかを文で置く。
  python3 build_site.py <p2m-site の場所>
"""
import subprocess, sys, html
site = sys.argv[1]
subprocess.run(['npx', 'esbuild', 'src/main.js', '--bundle', '--format=esm', '--minify', '--loader:.json=json',
                '--external:node:zlib', '--alias:p2m-stores=./src/stores_site.js', '--outfile=dist/app_site.js'], check=True)
s = open('shell.html', encoding='utf-8').read()
js = open('dist/app_site.js', encoding='utf-8').read()
DESC = ('配膳ロボットのブラウザ版（テスト版）。試算と同じファミレスの店（②〜⑦）を3Dで建て、スタッフ・ロボットの台数・混み具合を選ぶと、'
        'ブラウザの中で人とロボットを動かして1日を回し、入店待ち・会計待ち・料理が届くまでの時間を数える。数字は試算・Unity と答え合わせ前。')
URL = 'https://pixelstomotors.com/sim/serving-robot-3d'
head = (f'<meta name="description" content="{html.escape(DESC)}">\n<link rel="canonical" href="{URL}">\n'
        f'<meta property="og:title" content="配膳ロボット ブラウザ版（テスト版）| Pixels to Motors">\n<meta property="og:description" content="{html.escape(DESC)}">\n'
        f'<meta property="og:url" content="{URL}">\n<meta property="og:image" content="https://pixelstomotors.com/og/LOG506.jpg">\n<meta name="twitter:card" content="summary_large_image">\n')
about = '''<noscript><div style="max-width:720px;margin:24px auto;padding:0 16px;font-size:14px;line-height:1.8">
<h1>配膳ロボット ブラウザ版（テスト版）</h1>
<p>このページは JavaScript で動きます。サービスロボット導入シミュレーションの試算（https://pixelstomotors.com/sim/serving-robot）と同じファミレスの店の形（② 定番の郊外型・③ 効率重視・入口が角・④ 効率重視・小さめの店・⑤ ゆったり・入口が正面・⑥ ゆったり・入口が横・⑦ ③の通路を95cmに）を three.js で3Dに描き、スタッフの人数・配膳ロボットの台数（0〜6台）・混み具合（空いている・普通・混雑）を選ぶと、ブラウザの中で客・スタッフ・ロボットを動かして、空の店から始めてピーク120分の来客、全員が帰るまでの1日を回します。</p>
<p>テスト版です。数字は、試算 v2.9 や Unity の基準データとの答え合わせをしていません。ロボット3台以上・混雑の日は、ロボットどうしが通路で詰まりやすくなります。下げ膳はまだ入っていません。</p>
<p>動きの決まり：人は NavMesh（recast-navigation）、ロボットは店の走れるマス目（2.5cm刻み）の上で道を引く。ロボットは直径0.5m・最高0.6m/秒、前0.6mのセンサーで止まり、3秒ふさがれば回り込む。5秒止まったら7m以内の手の空いたスタッフが動かし、30秒動けなければ立ち往生としてスタッフが救出する。瞬間移動はしない。料理はロボットが先に運び、2分たってもロボットが来なければスタッフが運ぶ。会計はレジが空くまで席で待ち、90秒。</p>
<p>数字の定義：入店待ち＝来てから卓に着くまで。会計待ち＝食べ終えてからレジで会計が始まるまで。料理が届くまで＝料理ができてから卓に届くまで。同じ「日」を選べば、何度見ても同じ動きになります。</p>
<p>作り方とコードの記録：https://pixelstomotors.com/record/serving-robot-store-sim-in-the-browser （LOG506）。シリーズ：https://pixelstomotors.com/series8</p>
</div></noscript>
'''
s = s.replace('<title>配膳ロボット ブラウザ版（テスト版）</title>', '<title>配膳ロボット ブラウザ版（テスト版）| Pixels to Motors</title>\n' + head)
s = s.replace('</style></head><body>', '</style></head><body>\n' + about, 1)
s = s.replace('href="https://pixelstomotors.com/message?topic=physical-ai"', 'href="/message?topic=physical-ai"')
assert '/*BUNDLE*/' in s
out = s.replace('/*BUNDLE*/', js)
open(f'{site}/public/sim/serving-robot-3d.html', 'w', encoding='utf-8').write(
    '<!-- 配膳ロボット ブラウザ版（テスト版）。作り方は LOG506・元のコードは記事の分けたファイル。tools ではなく試作の build_site.py で書き出した（2026-11-14） -->\n' + out if False else out)
print('ok', len(out))
```

````markdown
# 配膳ロボット ブラウザ版（テスト版）のコード

公開先：`/sim/serving-robot-3d`（`public/sim/serving-robot-3d.html`）。入口は `/series8` の「ブラウザで動かす（テスト版）」だけ（`src/components/BrowserSimTest.astro`）。
作り方・決まり・測った数字は LOG506（https://pixelstomotors.com/record/serving-robot-store-sim-in-the-browser）。

- `src/sim.js`：動かして数える所（画面を知らない）。1コマ0.05秒・種（日）で同じ動き
- `src/main.js`：three.js で描く所。店の名前・人数・来客のペースは試算（`public/sim/serving-robot.html` の STORES）にそろえてある
- `src/stores_site.js`：サイト用。店のデータは `public/sim/layouts/unity/` を読む（試算・Unity と同じ正）
- `src/stores_bundle.js`：チャットの画面で試すとき用。店のデータを中に含める（`stores/` に layouts/unity の6店を写して使う）
- `shell.html`：ページの型。`/*BUNDLE*/` に JavaScript を入れる

書き出し（このフォルダで）：

```
npm install
python3 build_site.py ../..
```

住所に条件を付けて開ける：`/sim/serving-robot-3d?store=p1&staff=3&robots=2&crowd=normal&day=1`（store は p0〜p5 ＝試算の ②〜⑦）。
````

コードは p2m-site の `tools/serving_sim_3d/` に置きました。MASTER の保存先テーブルには、まだこの行がありません。


## 作業ログ ― 残っていること

- ロボット2台以上・混んだ日の詰まり。3台・混んだ日の2日目は店が止まる。Unity で同じ詰まり方が出るかを並べて見る
- 会計待ちが0台でも12〜19分と長い。サイトの試算（レジ1つ・90秒）と、どこで食い違っているかを分ける
- 数字を、試算 v2.9 と Unity の基準データ v5 と並べて、向きが合うかを見る（ブラウザ版を4つ目の正にしない）
- 下げ膳（皿をロボットで下げる使い方）はまだ入れていない。パネルには「料理を運ぶだけ（下げ膳はスタッフ）」と出している
- 大型店（⑧⑨）は、Unity 用の店のデータがないので入れていない
- 立ち往生の30秒と「5秒止まったら7m以内の人が動かす」が、いまの Unity に入っているかは、Unity 側で確かめる
- ブラウザや端末をまたいで、同じ日が同じ動きになるかは、まだ確かめていない
- テスト版として /sim/serving-robot-3d に公開した（入口は TOP と /series8）。答え合わせが済んだら、試算の「平面図／Unity で動かすと」の隣へ入口を移す
- MASTER の保存先テーブルに、ブラウザ版のコード（p2m-site の tools/serving_sim_3d/）の行を足す
