---
title: "配膳ロボットの導入前シミュレーションをLPにする ― 記事と同じ構成をやめた"
slug: from-sim-series-to-lp
date: 2026-10-24
day: 371
meta_description: "配膳ロボットの導入前シミュレーションを、飲食店とメーカー・販売会社向けの1ページにした。記事と同じだった構成を読む人の問いの順に作り直し、分割と全面を切り替える最初の画面、画質、色、相談の形を17回の公開で詰めた。"
tags: [LP, ランディングページ, 配膳ロボット, 飲食店, 導入シミュレーション, コンテンツ設計, ファーストビュー, CTA, Unity, SimPy, Astro, デザインの詰め方]
series: "ひとり研究所の裏側"
---

配膳ロボット（ファミレスなどで料理を席まで運ぶロボット）を入れるか迷っている飲食店と、それを店に提案するメーカー・販売会社に向けて、導入前に自分の店舗で何が起きるかを試せるページを1枚作りました。住所は pixelstomotors.com/serving-robot-one-less-staff です。

材料は、このサイトで続けているサービスロボット導入シミュレーションの記録11本です。店舗の混む2時間を計算で再現する試算と、同じ店舗を3D（ゲームエンジンの Unity）で建ててスタッフ・客・ロボットを走らせた記録が入っています。たとえば標準的なファミレスでは、ロボットを2台入れても店舗が回る最少の人数は3人のまま減りませんでした。会計の列の上をロボットが通る店舗では、レジのまわりで112回止まりました。こうした結果は、ロボットを入れる前に分かります。

最初に作った版は、記事の順番をほぼそのままページにしたものでした。3つの数字で結論、人は減らせるか、お金は合うか、店の形で変わる、3Dの店で確かめた、という並びです。これを見て、次の指摘がありました。

> 構成がサービスロボット導入シミュレーションと全く同じだね。デザインルールは同じでもいいけど、表現すべきことや伝えることは違うはずだからもう一度考え直してください。

**記録は作った順に並べればよいが、このページを読む人が知りたいのは、自分の店舗で導入前に何が分かるか、それをどう頼めるか、でした。** そこで構成を作り直し、困りごと → 導入前に分かる3つのこと → なぜ自分の店舗でやるのか → まず近い店舗で試す → 頼むときの流れ → 立場ごとの使い方 → この試算で言えないこと → 相談、の順にしました。

そのあと、画面写真に一言ずつ指示をもらっては直して公開する、を朝から昼すぎまで続けました。ページの公開は17回です。

| 段階 | 何をしたか | 結果 |
|---|---|---|
| 要件定義 | 読む人を飲食店とメーカー・販売会社の2つに、同じ重さで置いた | 最初の画面は、3Dの店舗が動く動画を主役にする案を選んだ |
| 構成 | 記事の順番を並べた案1から、読む人の問いの順の案2へ | 結論の帯・チェックリスト・Unity と試算の比べを外し、頼み方と立場ごとの使い方を足した |
| 最初の画面 | 1本の動画 → 暗い膜をかけた全面の動画 → 分割と全面を切り替える4場面 | 平面図・3Dの店舗・走るロボット・試算の結果・Unity の編集画面・実際に動くコードを順に見せる |
| 画質 | 元の大きい画像と動画から切り出し直した | 走る様子の動画は、同じ大きさのまま情報量（ビットレート）が約2.3倍 |
| 細部 | 番号を外す・中央揃え・幅・言葉・絵・角丸・色 | 店→店舗、入れる前→導入前。黒をサイト共通の #242424 に |
| 最後の相談 | 3案 → 送るもの3つとボタン1つ → 背景を店舗の図面とロボットの道に | ボタンのオレンジだけが明るく見える |

この記事では、どう構成を考え直したか、最初の画面をどう組んだか、細部で何を直したかを、指示と画面写真の順に残します。数字はすべて試算のページから読み込んでいて、このページのために新しく計算した数字はありません。

ここから下は作業ログです。


## 作業ログ ― シリーズの記録から、導入前の相談のページへ

効果について先に断っておきます。下の「見込める効果」は、測った値（画像の大きさ・動画のビットレート・ページの重さ）以外は見込みです。押されたかどうかは、サイトの使われ方の記録（/api/ev-stats）で、最初の画面のボタン（FV:相談）と最後のボタン（最後:相談）の数で確かめます。

### 依頼：研究室のLPと同じように、シリーズの記事からLPを作る

最初の依頼は次のとおりでした。

> サービスロボット導入シミュレーションの記事の内容を使って、フィジカルAI研究所で作ったLPと同じようなものを作成したいので要件定義（誰に？どんな目的でどんな成果を目指す？など）、ワイヤーフレーム、デザイン、細部詰めの順番で進めてみてください。動画や画像をうまく使ってみてわかりやすく、伝わるLPを目指してください。

ここでいう研究室のLPは、学習用のGPUを買うか借りるかを決めるためのページ（pixelstomotors.com/gpu-buy-or-rent ）です。作った経緯は [LOG368](https://pixelstomotors.com/record/from-research-log-to-lp) に、デザインの詰め方は [LOG369](https://pixelstomotors.com/record/how-to-finish-a-design) にあります。

### 要件定義：飲食店とメーカー・販売会社を、同じ重さで読む人に置いた

要件定義では、誰に・何のために・何を成果にするかを決めました。読む人は2つの候補から選んでもらい、両方を同じ重さで扱うことになりました。

| 項目 | 決めたこと |
|---|---|
| 読む人 | 配膳ロボットの導入を考える飲食店の経営者・事業責任者と、店に提案するロボットのメーカー・販売会社。同じ重さ |
| 読む人の段階 | 入れるかどうか、何台か、どう使うかを、まだ決めていない |
| ページの目的 | 導入前に、自分の店舗（提案先の店舗）で何が起きるかが分かること。その試算と3Dの店舗での確かめを頼めること |
| 成果 | 相談のフォーム（/message?topic=physical-ai）に、自店・提案先の条件で試算の相談が来ること |
| 扱い | 記事ではなく LP。LOG 番号を付けず、記事一覧にも並べない。数字は試算が変われば、次のビルドで自動で変わる |

最初の画面は、3つの方向で案を出しました。結果の数字を2枚の票に並べる案A、3Dの店舗が動く動画を主役にする案B、店舗の図面を主役にする案Cです。案Bが選ばれました。

【画像】LOG371_01_fv_directions.png 最初の画面の3つの方向。左から、結果の数字を2枚の票に並べる案A、3Dの店舗の動画を主役にする案B、手描きの見取り図と図面を主役にする案C

### 構成案1：研究室のLPの型に、記事の流れを当てはめていた

最初のワイヤーフレーム（ページの骨組みの図）は、次の並びでした。

1. 最初の画面
2. 3つの数字で、先に結論
3. 人は減らせるか
4. お金は合うか
5. 人数そのままで変わること
6. 店の形で答えが変わる
7. 自分の店で試算する
8. 予想と逆だったこと
9. 3Dの店で確かめた
10. 入れる前に確かめる7つ
11. 次は、あなたの店で

【画像】LOG371_02_wireframe1.png 構成案1のワイヤーフレームの上半分。左に節の名前と伝えること、右に灰色の箱で中身の置き方を描いている

この案で一度ページを作って公開し、最初の画面を、左のメニューと8つの場面が切り替わる動画に組み替えるところまで進めました。そこで、構成が記事と同じだという指摘を受けました（冒頭の引用）。

並びを見直すと、研究室のLPの型（結論の数字 → 章ごとの確かめ → チェックリスト）に、記事の流れ（見取り図 → 図面 → 試算 → Unity）をそのまま当てはめていました。研究室のLPは、読む人が自分で買うか借りるかを判断するページです。このページは、読む人が自分の店舗で確かめたくなり、頼めるページです。目的が違うので、並びも変わるはずでした。

### 構成案2：困りごとから始めて、頼み方と立場ごとの使い方で終える

作り直した骨組みは次のとおりです。

| 順 | 節 | 伝えること（1つ） |
|---|---|---|
| 最初の画面 | 配膳ロボットを、導入前に自分の店舗でシミュレーション | 店舗の図面と人数から、何人で回るか・どこで止まるか・月いくらかかるかを出す |
| 1 | 導入後のつまずき | つまずきの多くは、導入前に分かる |
| 2 | 導入前に分かる3つのこと | 何人で回るか・どこで止まるか・月いくらかかるか |
| 3 | なぜ自分の店舗でやるのか | 同じ2台でも、入店待ちの縮み方は店舗の形で違う |
| 4 | まず、近い店舗で試す | 試算をその場で動かせる |
| 5 | 頼むときの流れ | 送るのは、図面と人数と混む時間だけ |
| 6 | 立場ごとの使い方 | 店舗は入れるかを、提案する側はどう入れるかを決める |
| 7 | この試算で言えないこと | 数字は仮の前提。店舗で測って合わせていく |
| 最後 | 相談 | 図面と人数を送れば、同じ試算でお返しする |

外したものは、3つの数字で先に結論を言う帯、Unity と試算を比べる節、チェックリスト7つです。研究の過程の話は、このページでは主役にしません。気になった人のために、ページの最後に記録11本へのリンクを置きました。

【画像】LOG371_03_wireframe2.png 構成案2のワイヤーフレーム。上に、研究の記事・研究室のLP・このLPで伝えることの違いを書き、その下に節ごとの伝えることと置き方を並べた

【図解】LOG371_page_order.html

- ねらい：読む人が、自分の店舗のことだと思える順番で読み進められるようにする
- 変わったこと：記事を作った順（見取り図 → 図面 → 試算 → Unity）から、読む人の問いの順（困りごと → 分かること → なぜ自分の店舗か → 試す → 頼む）に。頼むときの流れと受け取るもの4つの節を新しく足した
- 見込める効果：相談のボタンを押す前に、何を送れば何が返ってくるかが分かるので、相談をためらう理由が1つ減る（見込み。最後のボタンの押された数で確かめる）

### 最初の画面：手描きの見取り図を外し、メニューと動画の形にした

最初の画面は、構成案1のときに、研究室のLPのメニューの作り方を参考に、左に節のメニュー、右に場面が切り替わる動画を置く形にしました。参考にしたいという指示は次のとおりです。

> TOPの動画は切り替わってイメージを伝わりやすくしたい。メニューの作り方も含めて参考にしてください。

最初に組んだ動画には、記憶から描いたファミレスの見取り図（[LOG360](https://pixelstomotors.com/record/floor-plans-into-simulator) で使った手描きの絵）の場面が入っていました。

> 手描きの図面はいらないかな

> だから手描きは嫌だって。

1回目の指示で見取り図の場面だけを外したものの、4の節の中にも同じ絵が残っていて、2回目の指示を受けました。以後、ページのどこにも手描きの絵は使っていません。

【画像】LOG371_04_fv_first_switch.png 最初の画面の初めの形。左にメニュー、右に切り替わる動画。右の画像は、手描きの見取り図の場面が入っていたときのもの

### 最初の画面：暗い膜をかけた全面の動画は、灰色で汚く見えた

構成を作り直したときに、最初の画面を、全面の動画の上に暗い膜をかけて文字を載せる形にしました。

> グレーっしゅでFV画面が汚く見えるね。デザイン見直し

> 常に全面画像、動画じゃなくて、分割とか使ってデザイン性を上げてください。

【画像】LOG371_05_fv_full_video.png 作り直した直後の最初の画面。全面の動画に暗い膜がかかり、全体が灰色に沈んでいる

### 最初の画面：分割と全面を切り替え、Unity とコードも見せる

右側を4つに分けました。店舗の平面図、同じ店舗を3Dで建てたところ、スタッフとロボットが走るところ、試算の結果の4枚です。3Dの店舗の斜めの動画と、真上・正面・横・斜めの4つの視点の動画は、このとき新しくもらったものです。

【画像】LOG371_06_fv_split4.png 4つに分けた最初の画面。平面図・3Dの店舗・走るスタッフとロボット・試算の結果（店舗が回る人数4人→3人、入店待ち3.2分→1.0分、ひと月の差し引き−0.3万円）

続けて、同じ4枚がずっと出ているのではなく、全面と分割を何度か切り替えてほしいという指示がありました。

> ずっと同じ動画じゃなくて、全面になったり、分割になったりと何度か切り替えてください。

> Unityの操作画面とか、ターミナルでコードが動いているとか、色々材料はあるはずです。

場面は4つにしました。

| 場面 | 形 | 出しているもの | 長さ |
|---|---|---|---|
| 1 | 4分割 | 平面図・3Dの店舗・走らせる・試算の結果 | 6.0秒 |
| 2 | 全面 | Unity の編集画面で店舗を建てて回しているところ | 4.5秒 |
| 3 | 3分割 | 試算のコード（SimPy）が動くターミナル・ロボットの道を引いた図面・費用と浮く人件費 | 6.5秒 |
| 4 | 全面 | 試算のページで人数・台数を変えて結果を見比べるところ | 4.5秒 |

全面の場面には、元の画像が画面より大きいもの（1920×1741 の Unity の画面と、2000×1840 の試算の画面）だけを使いました。小さい画像を全面に引き伸ばすと、ぼやけるからです。全面の場面ではゆっくり寄っていく動き（Ken Burns と呼ばれる、静止画に少しずつ寄る見せ方）を付け、下に4本の進み具合の線を置いています。

ターミナルの場面は、作った絵ではありません。[LOG359](https://pixelstomotors.com/record/audit-simulator-before-unity) の試算のコード（haizen_sim.py）を実際に動かし、出てきた文字と時刻をそのまま記録して、記録した時刻どおりにブラウザの中のターミナル（xterm.js）に書き出し、1コマずつ撮って動画にしました。打つ速さだけは1文字0.045秒にしています。

【画像】LOG371_07_fv_scenes.png 最初の画面の4つの場面を並べたところ。ターミナル・4分割・試算の全面などが入れ替わる

- ねらい：このページで何ができるか（図面から店舗を建て、走らせ、数字を出す）を、文字を読む前に見せる
- 変わったこと：1本の動画（暗い膜付き）から、4分割 → Unity の全面 → コード・図面・費用の3分割 → 試算の全面、の4場面21.5秒の切り替えに。実際に動いた Unity の画面とコードの出力を入れた
- 見込める効果：3Dで建てたのも、計算したのも作り物ではないと分かり、試算の数字を信じてもらいやすくなる（見込み）

### 最初の画面の言葉：シミュレーション・導入前・店舗

見出しの言葉にも指示がありました。

> FVテキストどこにもシミュレーションという言葉がなくてなんのことか伝わりづらい 入れる前という言葉より、導入前の方が良いのでは？

見出しを「配膳ロボット、入れる前に自分の店で走らせる」から「配膳ロボットを、導入前に自分の店舗でシミュレーション」に変え、ページの中の「入れる前」を「導入前」にそろえました。あとで、「店」も「店舗」にそろえています。試算のデータの中の店の種類の名前（小型店・大型店・洗い場が遠い店）は、試算のページと同じ名前のままです。

【画像】LOG371_12_fv_final_a.png いまの最初の画面のうち2つの場面。左が4分割、右がコード・図面・費用の3分割

【画像】LOG371_13_fv_final_b.png いまの最初の画面の、試算のページを全面で見せる場面

スマホでは、上に見出しとボタン、その下に切り替わる場面、メニューの順に縦に並びます。

【画像】LOG371_14_fv_phone.png スマホで見た最初の画面

### 画質：元の大きい画像と動画から切り出し直した

> 画像、動画で画質が悪いのは避けてください。

> 画質が悪い

2回指摘を受けました。原因は、記事に載せるために小さく書き出した画像を、さらに切り抜いて大きく見せていたことです。元の画面収録（2884×1741 など）から切り出し直し、動画は画質の指定を上げて書き出し直しました。

| ファイル | 前 | 後 |
|---|---|---|
| top-busy.mp4（スタッフとロボットが走る） | 976×652・174 kbps・419KB | 976×652・406 kbps・940KB |
| fv-oblique.mp4（3Dの店舗の斜め） | 930×716・573 kbps（暗い膜あり） | 820×716・556 kbps・1.4MB（膜なし・編集画面の印を外して切り直し） |
| fv-unity.webp（Unity の編集画面） | なし | 1920×1741・167KB |
| trouble-staff.webp（入れたのに人が減らない） | 小さく書き出した画像から切り抜き | 1400×930・93KB |

- ねらい：画面を見た人が、作りの粗さで中身まで疑わないようにする
- 変わったこと：走る様子の動画は、同じ大きさのままビットレートが 174 kbps から 406 kbps（約2.3倍）に。全面に出す画像は元が 1920px 以上のものだけにした
- 見込める効果：動画の中のロボットとスタッフの区別がつきやすくなる。一方で、動画のファイルは 419KB から 940KB に増えた（測った値）

### 見出しと説明文：番号を外し、中央にそろえ、1行に収めた

構成案2の初めの形では、節の頭に 01・02 の大きな番号を、中にも①②③の小さな番号を付けていました。

> セクションの数字を削除。タイトル、説明文はセンター合わせ（全セクション）

> ① 小さい①とか削除（他の数字もノイズに見える）

> 説明文テキストは折り返さない。

番号をすべて外し、どの節も見出しと説明文を中央にそろえました。説明文は、幅が 1100px 以上の画面では折り返さず1行に収めています（それより狭い画面では折り返します）。

【画像】LOG371_08_trouble_before_after.png 導入後のつまずきの節の前と後。左は番号付きで左寄せ、右は番号を外して中央にそろえた形

つまずきの3枚の写真の下にあった札（試算で・3Dの店舗で）は、黒い四角の背景に白い文字だったため、見出しより目立っていました。

> 試算で 四角背景にテキストがあるのが強すぎてタイトルに目が行かない。もっと軽く作ってください。

札は、背景のない小さな文字だけにしました。

### 導入前に分かる3つのこと：左の文を図の上端にそろえた

この節は、左に文、右に図を置き、3つの段で左右を入れ替えています。何人で回るかの段は、店舗と混み具合を選ぶと、ロボット0〜3台ごとに店舗が回る最少の人数が人とロボットの絵で並ぶ図です。

> 左のテキストは画像の上とツライチに変更

> 人とロボットとわかりやすいようにイラスト修正

左の文は、図の上端と同じ高さから始めました。人の絵は頭と体の形、ロボットの絵は四角い頭に目が2つ、台形の胴と台の形にし、ロボットだけをオレンジにしています。最後に、ロボットの胴に入れていた横線2本を外しました。

> ロボット胴体のシマシマいらない削除

どこで止まるかの段の数字（79 → 0回、112 → 31回）は、文字の大きさを 2rem から、画面の幅に合わせて 2.6〜3.6rem に上げました。

> 数字をもっと強く！

【画像】LOG371_20_numbers_before.png 直す前の、どこで止まるかの数字。79 → 0回、112 → 31回が、説明文に近い大きさで並んでいる

【画像】LOG371_15_section_know.png いまの導入前に分かる3つのことの節。何人で回るか（人とロボットの絵）、どこで止まるか（大きくした数字と4つの視点の動画）、月いくらかかるか（費用の棒）

### コンテンツ幅：基本は 1040px、図面と試算の2つだけ広くした

> コンテンツ幅調整。もっと狭くて良さそう

> コンテンツ幅はセクションによっては広いのがあってもOKです。全部合わせなくても。広い場合は意図が合って広くしてください。

中身の幅を 1040px にそろえたうえで、2つの節だけを 1280px にしました。6つの平面図を並べる節（図面の中の卓や通路が見える大きさにするため）と、試算を埋め込んだ節（試算の画面は横に表とグラフが並ぶため）です。

【画像】LOG371_16_section_shape.png 6つの平面図を並べた節。同じ2台でも、入店待ちの縮み方が店舗ごとに違うことを棒と数字で見せる

### 角丸：埋め込んだ試算に合わせて3段にした

> このシュミレーションが角丸を採用しているので、他のセクションも合わせた形に修正してください。

試算のページの角の丸みに合わせ、大きな面（試算の枠・図・受け取るものの枠・立場ごとのカード・最後の白い面）は 12px、カードと画像は 8px、ボタンと札は 4px にしました。

### 頼むときの流れ：5つの手順に絵を付けた

> アイコン系イラスト追加

手順5つ（図面と条件を送る・図面に起こす・試算する・3Dの店舗で走らせる・結果と直し方を返す）に、それぞれ黒の小さな絵を付けました。

【画像】LOG371_18_section_flow.png 頼むときの流れの節。5つの手順と絵、その下に受け取るもの4つ

### 最後の相談：送るもの3つとボタン1つ、背景は店舗の図面とロボットの道

最後の相談の節は、もともと黒い地の右側に斜めのストライプを入れ、その上の白い面に見出しとボタンを置いた形でした。角丸を入れたあと、次の指摘がありました。

> カードに角丸を使うとこのCTAのストライプとデザインが合わないような気がするので、違うデザインを提案してください。

3つの案を出しました。

| 案 | 形 | 押させるもの |
|---|---|---|
| A | 左に見出し、右に送るもの3つ（店舗の図面・スタッフの人数と混む時間・ロボットの使い方）とボタン | 送るものが分かったうえでの相談 |
| B | 黒い地に見出しとボタン、右に試算の結果の票の見本 | 返ってくるものを見てからの相談 |
| C | 左に3Dの店舗の写真、右に見出しとボタン | 雰囲気からの相談 |

【画像】LOG371_09_cta_options.png 最後の相談の3つの案。左から、送るもの3つとボタン（案A）、黒い地に結果の票（案B）、3Dの店舗の写真と見出し（案C）

案Aが選ばれ、背景に写真を入れることになりました。

> これもベースに背景写真を入れてみてください

最初は3Dの店舗を真上から撮った画面を背景にしました。そのあと、別の写真か、図のような絵に変えるよう指示がありました。

> 違う背景写真、もしくはグラフィカルな写真に変更

背景は、⑤ゆったり型・入口が正面の中央の店舗（46卓・200席）の図面の形のデータから、絵をコードで描き直しました。暗い地に白い細い線で卓と部屋を描き、ロボットの待機場所から46卓すべてへの道をオレンジで引いています。道は、図面の上で卓・部屋・壁を避けて、10cm の格子の上で一番短い道を探して引いたもので、試算や記事の数字とは関係ありません。線で描いているので、どの大きさの画面でもぼやけません。

初めの版では、何本もの道が重なる幹の部分が明るく出て、ボタンのオレンジと競って見えました。

> ボタンのオレンジが少し競って見える 道の線をもう一段薄く

道を1本ずつ薄くすると、重なった所ほど濃くなります。道をまとめた1つのまとまりとして一度に薄くし（透明度 0.32）、重なっていても明るさが変わらないようにしました。

【画像】LOG371_11_cta_photo_to_plan.png 最後の相談の背景の前と後。左は3Dの店舗を真上から撮った画面、右は店舗の図面とロボットの道を暗い地に描いた絵

- ねらい：最後の画面で押すものを1つにし、押す前に何を送ればよいかが分かるようにする
- 変わったこと：ストライプの入った黒い地から、送るもの3つとオレンジのボタン1つに。背景を、ぼやけた画面写真（1560×1040・77KB）から、線で描いた図面の絵（15KB）に替えた
- 見込める効果：背景の中で明るいオレンジはボタンだけになり、目が最後にボタンへ行く（見込み。最後:相談の押された数で確かめる）。背景のファイルは 77KB から 15KB に減った（測った値）

### 色：黒をサイト共通の #242424 にし、埋め込んだ試算もそろえた

> 黒のカラーは現行のサイトのデザインに合わせてください。配膳ロボット、何台入れたら店は変わる？が入っているので、色が統一されていない感じがします。

このページでは黒を #121418 という青みのある黒で作っていましたが、サイトのほかのページは #242424 です。ページの黒・灰色を、サイト共通の #242424（文字と黒い面）・#f7f7f7（薄い地）・#e6e6e6（線）にそろえました。

埋め込んだ試算（配膳ロボット、何台入れたら店は変わる？）の中にも、別の黒と白が使われていました。

> 揃えてみて。違和感があるか見ないとわからないね。

試算のページは単体でも開かれ、シリーズのページ（/series8）にも埋め込まれています。そちらの見た目は変えず、このページに埋め込んだときだけ（住所に ?lp=1 が付いたときだけ）、店舗の種類を選ぶ帯を #242424、選んだものを白、大きな平面図の地を #f7f7f7 にしました。

【画像】LOG371_10_sim_colors.png 埋め込んだ試算の色。左がシリーズのページに埋め込んだときの元の色、右がこのページに埋め込んだときの色

【画像】LOG371_17_section_sim.png まず、近い店舗で試す節。幅を 1280px にして、試算をそのまま動かせるように埋め込んでいる

- ねらい：ページの中に入った試算が、別のサイトの部品に見えないようにする
- 変わったこと：ページの黒を #121418 から #242424 に。埋め込んだ試算は、このページに入っているときだけ同じ黒と白に
- 見込める効果：試算を使う人が、同じページの続きとして読み進められる（見込み。埋め込んだ試算の中で押された数は、data-ev-area="sim" で数えている）

### 数字は試算のページから読み、このページでは計算しない

ページの数字（2台入れても回る人数が減らない条件が99のうち47、ひと月の差し引き、6つの店舗の入店待ちなど）は、試算のページ（public/sim/serving-robot.html）の中にあるデータを、サイトを作るとき（ビルドのとき）に読み出して入れています（src/lib/servingSim.ts）。試算の数字が変われば、次のビルドでこのページの数字も変わります。

3Dの店舗の数字（112回 → 31回、79回 → 0回、ロボットが運んだ料理が約3割 → 約7割）は、[LOG364](https://pixelstomotors.com/record/serving-robot-jam-points) と [LOG367](https://pixelstomotors.com/record/serving-robot-stage-b-18-runs) の記録から書いています。

### ページの重さ：最初の画面だけで約9.5MBを読み込んでいた

公開したページで、最初の画面を開いたときに読み込む量を測りました（1440×900 の画面・Chromium・本番の住所）。

| 種類 | 量 |
|---|---|
| 動画 | 5,898,395 バイト |
| 文字の形（フォント） | 1,705,322 バイト |
| 画像 | 866,162 バイト |
| ページと埋め込んだ試算の HTML | 665,404 バイト |
| CSS | 344,947 バイト |
| JS | 15,255 バイト |
| そのほか（使われ方の記録の送信など） | 1,837 バイト |
| 合計（126件） | 9,497,322 バイト |

一番大きいのは、ターミナルの動画（terminal.mp4・3,539,481 バイト）でした。最初の画面の動画は、場面が来る前から読み込む設定（preload="auto"）にしているため、3場面目のターミナルの動画も最初に読み込まれます。別の測り方（届いた返事に書かれた大きさを足す）で、下までスクロールしたあとの合計は約11.5MBでした。スマホの回線で開く人のために、ここを見直します。


## コードと記録

ここから下は、このページを作るのに使ったコード・スクリプト・記録の全量です。

### serving-robot-one-less-staff.astro（ページ本体）

LP を作り終えたとき（12:23 の公開・65aac70）の全文です。

```astro
---
// 導入前の試算ガイド：配膳ロボットを、導入前に自分の店でシミュレーション（LP。LOG の記事ではない）
// 伝えること：導入前に、自分の店（提案先の店）で何が起きるかが分かる。その試算と3Dの店での確かめを頼める
// 数字は試算（public/sim/serving-robot.html）からビルドのときに読む（src/lib/servingSim.ts）。3Dの店の数字は LOG364・LOG367 の記録から
// 動画と画像は public/site/srl/（Unity の記録動画から切り出した短いループと、試算と同じ描き方の平面図）
import Base from '../layouts/Base.astro';
import { SITE, AUTHOR, PUBLISHER } from '../lib/seo';
import { headline as H, twoRobots, staffTable, plans, buyLarge, robotMonthRent, savedPerStaff, COST, CONDITIONS, min1, man, minStaff, STORES } from '../lib/servingSim';

const title = '配膳ロボットを、導入前に自分の店舗でシミュレーション ― 試算と3Dの店舗で確かめる';
const seoTitle = '配膳ロボットの導入前シミュレーション｜自分の店舗で試算と3D';
const yen = (v: number) => Math.round(v).toLocaleString('ja-JP');
const manAbs = (v: number) => man(Math.abs(v)).replace(/^[+−]/, '');
const conds = CONDITIONS.toLocaleString('ja-JP');

// 導入前に分かること（いくらかかるか）
const cost2 = robotMonthRent(2);
const savedPeak = savedPerStaff(2);
const savedShift = savedPerStaff(COST.shiftH);
const walkCut = Math.round((1 - H.same.walk_km_per_staff / H.base.walk_km_per_staff) * 100);

const description = `配膳ロボットの導入前シミュレーション。自分の店舗で何人で回るか・どこで止まるか・月いくらかかるかを、導入前に確かめる。${conds}条件の試算と、店舗を3D（Unity）で建てて走らせた記録から。2台入れても店舗が回る人数が減らない条件が${twoRobots.total}のうち${twoRobots.none}。飲食店と、提案するメーカー・販売会社向け。`;
const path = '/serving-robot-one-less-staff';
const url = SITE + path;
const today = '2026-10-24';
const jsonLd = [
  {
    '@context': 'https://schema.org', '@type': 'Article', headline: title, description,
    url, mainEntityOfPage: url, image: [SITE + '/og/serving-robot-one-less-staff.jpg'], inLanguage: 'ja',
    datePublished: today, dateModified: today, author: AUTHOR, publisher: PUBLISHER,
    articleSection: '導入前の試算ガイド',
    keywords: '配膳ロボット, 導入 シミュレーション, 導入前 試算, 人手不足, 飲食店, ファミレス, 人件費, 省力化投資補助金, Unity, サービスロボット, 提案',
    about: [{ '@type': 'Thing', name: '配膳ロボット' }, { '@type': 'Thing', name: '導入前の試算' }],
    isPartOf: { '@type': 'CollectionPage', name: 'サービスロボット導入シミュレーション', url: SITE + '/series8' },
    hasPart: { '@type': 'WebApplication', name: '配膳ロボットの導入試算', url: SITE + '/sim/serving-robot', applicationCategory: 'BusinessApplication', operatingSystem: 'Web', offers: { '@type': 'Offer', price: 0, priceCurrency: 'JPY' } },
  },
  {
    '@context': 'https://schema.org', '@type': 'BreadcrumbList',
    itemListElement: [
      { '@type': 'ListItem', position: 1, name: 'Pixels to Motors', item: SITE + '/' },
      { '@type': 'ListItem', position: 2, name: 'サービスロボット導入シミュレーション', item: SITE + '/series8' },
      { '@type': 'ListItem', position: 3, name: '配膳ロボットの導入前シミュレーション', item: url },
    ],
  },
];
// 02 ① の切り替えの図に渡す（店 × 混み具合 × ロボット0〜3台の最少のスタッフ）
const staffData = staffTable.map((s) => ({ k: s.key, n: s.label, t: s.tables, c: s.crowd.map((c) => ({ a: c.arr, s: c.staff })) }));
const P5 = '通路を狭めた効率重視の店舗'; // 試算のデータの名前（「80cmに」）は通路を0.95mにする前のもの。LP ではこの名前で呼ぶ
const planMax = Math.max(...plans.map((p) => p.from));
// 最初の画面の右側：分割と全面を切り替える（分割4枚 → Unity の編集画面 → コード・図面・費用の3分割 → 試算の画面）。全面には元が大きい画像だけを使う
const p1 = plans.find((p) => p.key === 'fam_p1')!;
const p1Store = STORES.fam_p1, p1Arr = p1Store.arrivals[1];
const p1Staff0 = minStaff('fam_p1', 0, p1Arr)!, p1Staff2 = minStaff('fam_p1', 2, p1Arr)!;
const MENU = [
  { n: '01', id: 'trouble', t: '導入後のつまずき' }, { n: '02', id: 'know', t: '導入前に分かること' }, { n: '03', id: 'shape', t: 'なぜ自分の店舗でやるのか' },
  { n: '04', id: 'sim', t: 'まず、近い店舗で試す' }, { n: '05', id: 'flow', t: '頼むときの流れ' }, { n: '06', id: 'roles', t: '立場ごとの使い方' }, { n: '07', id: 'limits', t: 'この試算で言えないこと' },
];
---
<Base title={`${seoTitle} | Pixels to Motors`} description={description} path={path} ogImage="/og/serving-robot-one-less-staff.jpg" ogType="article" published={today} modified={today} jsonLd={jsonLd} noindex={true}>
  <link slot="head" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Noto+Sans+JP:wght@400;500;700;900&family=IBM+Plex+Mono:wght@400;500;600&display=swap" />
  <div class="srl">

<section class="fv" aria-labelledby="fv-h">
  <div class="fv-stage" aria-hidden="true">
    <div class="sc sc-split4 on" data-dur="6000">
      <figure class="ft t-plan"><img src="/site/srl/plan-p1.svg" alt="" width="320" height="230"><figcaption>店舗の平面図</figcaption></figure>
      <figure class="ft t-3d"><video src="/site/srl/fv-oblique.mp4" poster="/site/srl/fv-oblique.webp" muted loop playsinline preload="auto"></video><figcaption>同じ店舗を3Dで建てる</figcaption></figure>
      <figure class="ft t-run"><video src="/site/srl/top-busy.mp4" poster="/site/srl/top-busy.webp" muted loop playsinline preload="auto"></video><figcaption><span><span class="ft-long">スタッフとロボットを</span>走らせる</span><span class="ft-keys"><i class="k-robot"></i>ロボット<i class="k-staff"></i>スタッフ<i class="k-guest"></i>客</span></figcaption></figure>
      <figure class="ft t-res">
        <figcaption>試算の結果</figcaption>
        <dl>
          <div><dt>店舗が回る人数</dt><dd><b>{p1Staff0}</b><small>人</small><i>→</i><em>{p1Staff2}</em><small>人</small></dd></div>
          <div><dt>入店待ち</dt><dd><b>{min1(p1.from)}</b><small>分</small><i>→</i><em>{min1(p1.to)}</em><small>分</small></dd></div>
          <div><dt>ひと月の差し引き</dt><dd><em>{man(savedPeak - cost2)}</em><small>万円</small></dd></div>
        </dl>
        <p class="ft-note">効率重視の店舗・1時間{p1Arr}組・ロボット0台 → 2台（月額）</p>
      </figure>
    </div>
    <div class="sc sc-full" data-dur="4500">
      <figure class="ft t-full"><img class="kb" src="/site/srl/fv-unity.webp" alt="" width="1920" height="1741"><figcaption>Unity の編集画面で、店舗を建てて回す</figcaption></figure>
    </div>
    <div class="sc sc-split3" data-dur="6500">
      <figure class="ft t-term"><video src="/site/srl/terminal.mp4" poster="/site/srl/terminal.webp" muted playsinline preload="auto"></video><figcaption>試算のコードを回す（SimPy）</figcaption></figure>
      <figure class="ft t-routes"><img src="/site/srl/fv-routes.webp" alt="" width="1130" height="791"><figcaption>ロボットの道を図面に引く</figcaption></figure>
      <figure class="ft t-cost"><img src="/site/srl/fv-cost.webp" alt="" width="1862" height="1424"><figcaption>費用と浮く人件費</figcaption></figure>
    </div>
    <div class="sc sc-full" data-dur="4500">
      <figure class="ft t-full"><img class="kb" src="/site/srl/fv-sim.webp" alt="" width="2000" height="1840"><figcaption>人数・台数を変えて、結果を見比べる</figcaption></figure>
    </div>
    <div class="sc-bar"><i></i><i></i><i></i><i></i></div>
  </div>
  <div class="fv-side">
    <div class="fv-copy">
      <p class="who">配膳ロボットの導入を考える飲食店と、<br>提案するメーカー・販売会社の方へ</p>
      <h1 id="fv-h">配膳ロボットを、<br>導入前に自分の店舗で<br>シミュレーション</h1>
      <p class="fv-lead">店舗の図面と人数から、何人で回るか・どこで止まるか・月いくらかかるかを出します。</p>
      <a class="fv-btn" href="/message?topic=physical-ai" data-ev="FV:相談">自店の条件で試算を相談する<i class="ms" aria-hidden="true">arrow_forward</i></a>
    </div>
    <nav class="fv-menu" aria-label="このページの中身" data-ev-area="toc">
      {MENU.map((m) => <a href={`#${m.id}`} data-ev={`FVメニュー:${m.t}`}><span>{m.t}</span><em class="ms" aria-hidden="true">arrow_downward</em></a>)}
    </nav>
  </div>
</section>

<section id="trouble" class="band-white">
  <div class="wrap">
    <div class="sec-head center"><h2><span class="ph">導入後のつまずきの多くは、</span><span class="ph">導入前に分かる</span></h2></div>
    <p class="sec-lead">試算と3Dの店舗で、実際に出たつまずきです。どれも、店舗の形と使い方の決め方で結果が変わりました。</p>
    <div class="troubles">
      <figure class="tr">
        <img src="/site/srl/trouble-staff.webp" alt="3Dの店舗を斜めから見たところ。席は客で埋まっている" width="1400" height="930" loading="lazy">
        <figcaption><span class="tr-k">試算で</span><b>入れたのに、人が減らない</b><span class="tr-t">2台入れても、{twoRobots.total}条件のうち{twoRobots.none}条件で、店舗が回る最少のスタッフは同じ。ロボットが運ぶのは料理だけで、片付けと案内は人に残るため。</span></figcaption>
      </figure>
      <figure class="tr">
        <img src="/site/srl/trouble-register.webp" alt="3Dの店舗の入口とレジのまわりに、客とスタッフとロボットが固まっている" width="680" height="510" loading="lazy">
        <figcaption><span class="tr-k">3Dの店舗で</span><b>会計の列で、ロボットが止まる</b><span class="tr-t">会計に並ぶ客がロボットの通り道に立ち、レジのまわりで112回止まった。列と通り道を離すと31回に。</span></figcaption>
      </figure>
      <figure class="tr">
        <img src="/site/srl/trouble-pass.webp" alt="料理の受け渡し口の横で、ロボット2台が待ったまま、スタッフが料理を運んでいく" width="720" height="540" loading="lazy">
        <figcaption><span class="tr-k">3Dの店舗で</span><b>人が先に運び、ロボットが遊ぶ</b><span class="tr-t">料理が出てくるカウンター（受け渡し口）の横にいるスタッフが先に運び、ロボットが運んだ料理は約3割。ロボットに先に割り当てる決まりにして約7割に。</span></figcaption>
      </figure>
    </div>
  </div>
</section>

<section id="know">
  <div class="wrap">
    <div class="sec-head center"><h2><span class="ph">導入前に分かる</span><span class="ph"><span class="lat">3</span>つのこと</span></h2></div>
    <div class="know">
      <div class="kn">
        <div class="kn-text">
          <h3>何人で回るか</h3>
          <p>店舗と混み具合を選ぶと、ロボット0〜3台ごとに、店舗が回る最少のスタッフが出ます。標準ファミレスの普通の混み具合では、何台入れても3人のまま。大型店や焼肉の食べ放題では、1人減る混み具合があります。</p>
          <p class="kn-note">「店舗が回る」は、席が空いているのに片付けや案内が追いつかずに待つ時間が、平均10分未満のこと。ロボットは料理を運ぶだけで、1往復で3卓まで回る前提です。</p>
        </div>
        <figure class="staff-fig">
          <div class="seg" role="group" aria-label="店舗を選ぶ" id="segStore">
            {staffData.map((s) => <button type="button" data-k={s.k} aria-pressed={s.k === 'family' ? 'true' : 'false'}>{s.n}</button>)}
          </div>
          <div class="seg small" role="group" aria-label="混み具合を選ぶ" id="segCrowd">
            <button type="button" data-c="0" aria-pressed="false">空いている</button><button type="button" data-c="1" aria-pressed="true">普通</button><button type="button" data-c="2" aria-pressed="false">混雑</button>
          </div>
          <p class="staff-cond" id="staffCond"></p>
          <div class="staffrows" id="staffRows" aria-live="polite"></div>
        </figure>
      </div>
      <div class="kn rev">
        <div class="kn-text">
          <h3>どこで止まるか</h3>
          <p>試算の店舗を3D（Unity）で建て、スタッフ・客・ロボットを動かして、止まった回数と場所を数えます。直し方を決めたら、同じ店舗でもう一度走らせて確かめます。</p>
          <div class="kn-nums">
            <div><span>細い通路の入口で、ロボットどうしが鉢合わせ</span><b>79<i>→</i><em>0</em><small>回</small></b></div>
            <div><span>レジのまわりでロボットが止まる</span><b>112<i>→</i><em>31</em><small>回</small></b></div>
          </div>
        </div>
        <figure class="clip clip-4">
          <video class="loop" src="/site/srl/four-views.mp4" poster="/site/srl/four-views.webp" muted loop playsinline preload="none" aria-label="3Dの店舗を、真上・正面・横・斜めの4つの視点で同時に映した動画。キッチンのモニターに、ロボットとスタッフがいま何をしているかが出ている"></video>
          <figcaption>同じ店舗を、真上・正面・横・斜めの4つの視点で同時に見る。左上の枠（キッチンのモニター）に、ロボットとスタッフがいま何をしているか、止まった回数が出ます。</figcaption>
        </figure>
      </div>
      <div class="kn">
        <div class="kn-text">
          <h3>月いくらかかるか</h3>
          <p>ロボット2台を月額で借りると月{manAbs(cost2)}万円。1人減って浮く人件費は月{manAbs(savedPeak)}万円で、差し引きは月{man(savedPeak - cost2)}万円です。条件と、人件費以外で変わることも並べます。</p>
          <ul class="kn-list">
            <li><span>昼と夜に4時間ずつ減らせたら</span><b>{man(savedShift - cost2)}<small>万円/月</small></b></li>
            <li><span>買って補助金を使う大型店（{buyLarge.from}人 → {buyLarge.to}人）</span><b>{man(buyLarge.net)}<small>万円/月</small></b></li>
            <li><span>人数そのままで、1人が歩く距離</span><b><small>約</small>{walkCut}<small>%減</small></b></li>
          </ul>
        </div>
        <figure class="cost-fig">
          <p class="cf-k">1人減ったときの、ひと月の差し引き</p>
          <b class="cf-big">{man(savedPeak - cost2)}<small>万円/月</small></b>
          <div class="cb-row"><span class="cb-l">ロボット2台の費用</span><span class="cb-track"><i class="cb-robot" style={`width:${(cost2 / Math.max(cost2, savedPeak) * 100).toFixed(1)}%`}></i></span><b class="cb-v">{manAbs(cost2)}<small>万円</small></b></div>
          <div class="cb-row"><span class="cb-l">浮く人件費（1人）</span><span class="cb-track"><i class="cb-ink" style={`width:${(savedPeak / Math.max(cost2, savedPeak) * 100).toFixed(1)}%`}></i></span><b class="cb-v">{manAbs(savedPeak)}<small>万円</small></b></div>
          <figcaption>ロボットは1台 月{yen(COST.rent)}円・保守{yen(COST.maint)}円・充電{yen(COST.charge)}円。人件費は混む2時間 × 1日2回 × 30日 × 時給{yen(COST.wage)}円（2026年度の最低賃金の全国平均・社会保険などは入れていない）。補助金は省力化投資補助金（カタログ注文型）で、補助率は1/2以下。条件があり、使う製品が対象かはカタログと公募要領で確かめます。</figcaption>
        </figure>
      </div>
    </div>
  </div>
</section>

<section id="shape" class="band-white">
  <div class="wrap wide">
    <div class="shape-top">
      <div class="sec-head center"><h2><span class="ph">答えは、</span><span class="ph">店舗の形で変わる</span></h2></div>
      <div class="prose"><p>同じ2台でも、入店待ちの縮み方は店舗ごとに違います。近い店舗の数字は、自分の店舗の答えになりません。</p></div>
    </div>
    <div class="plans">
      {plans.map((p) => (
        <figure class="plan">
          <img src={`/site/srl/plan-${p.file}.svg`} alt={`${p.key === 'fam_p5' ? P5 : p.name.replace(/店$/, '店舗')}の平面図`} loading="lazy" width="320" height="230">
          <figcaption>
            <b>{p.key === 'fam_p5' ? P5 : p.name.replace(/店$/, '店舗')}</b>
            <span class="pmeta">{p.tables}卓・スタッフ{p.staff}人・1時間{p.arrivals}組</span>
            <span class="pbar" aria-hidden="true"><i class="pb-from" style={`width:${(p.from / planMax * 100).toFixed(1)}%`}></i><i class="pb-to" style={`width:${(p.to / planMax * 100).toFixed(1)}%`}></i></span>
            <span class="pval">{min1(p.from)}<i>→</i><em>{min1(p.to)}</em><small>分</small></span>
          </figcaption>
        </figure>
      ))}
    </div>
    <p class="plans-note">数字は入店待ち（ロボット0台 → 2台）。スタッフは試算の初めの人数、混み具合は普通。寸法はすべて仮の値です。</p>
  </div>
</section>

<section id="sim" class="band-ink" data-ev-area="sim">
  <div class="wrap wide">
    <div class="sec-head center"><h2><span class="ph">まず、</span><span class="ph">近い店舗で試す</span></h2></div>
    <div class="prose center-p"><p>条件を選ぶと、その場で数字が変わります。自分の店舗の形で出すときは、下の流れで頼めます。</p></div>
    <div class="simbox">
      <iframe src="/sim/serving-robot.html?embed=1&lp=1" title="配膳ロボットの導入試算" loading="lazy" class="simframe"></iframe>
    </div>
    <div class="sim-foot">
      <a class="sim-open" href="/sim/serving-robot" target="_blank" rel="noopener" data-ev="試算を全画面で開く">試算を全画面で開く<i class="ms" aria-hidden="true">open_in_new</i></a>
      <span>1つの条件につき40回計算した平均です（全{conds}条件）。</span>
    </div>
  </div>
</section>

<section id="flow">
  <div class="wrap">
    <div class="sec-head center"><h2><span class="ph">頼むときに送るのは、</span><span class="ph">図面と人数と混む時間</span></h2></div>
    <ol class="steps">
      <li><svg class="ico" viewBox="0 0 48 48" aria-hidden="true"><path d="M8 4h22l10 10v30H8z"/><path class="w" d="M24 36V20M17 26l7-7 7 7"/></svg><b>図面と条件を送る</b><span>店舗の図面（写真と寸法でも）、卓の数、スタッフの人数、混む時間、ロボットの使い方</span></li>
      <li><svg class="ico" viewBox="0 0 48 48" aria-hidden="true"><rect x="4" y="6" width="40" height="36"/><path class="w" d="M26 6v14M26 28v14M4 22h14M26 30h18"/></svg><b>図面に起こす</b><span>卓・通路・受け渡し口・レジ・待機場所を、試算と3Dの店舗で使える形にする</span></li>
      <li><svg class="ico" viewBox="0 0 48 48" aria-hidden="true"><rect x="4" y="26" width="9" height="18"/><rect x="19" y="14" width="9" height="30"/><rect x="34" y="4" width="9" height="40"/></svg><b>試算する</b><span>ロボット0台から何台までで、店舗が回る最少の人数・待ち時間・費用を出す</span></li>
      <li><svg class="ico" viewBox="0 0 48 48" aria-hidden="true"><path d="M24 3l19 10v22L24 45 5 35V13z"/><path class="w" d="M5 13l19 10 19-10M24 23v22"/></svg><b>3Dの店舗で走らせる</b><span>通路・レジ・受け渡し口で、止まる所と回数を数える</span></li>
      <li><svg class="ico" viewBox="0 0 48 48" aria-hidden="true"><path d="M8 4h32v40H8z"/><path class="w" d="M15 25l6 6 12-13"/></svg><b>結果と直し方を返す</b><span>下の4つをまとめてお返しする</span></li>
    </ol>
    <div class="receive">
      <p class="rc-k">受け取るもの</p>
      <ul>
        <li><b>何人で回るか</b><span>ロボットの台数ごとの、店舗が回る最少の人数と待ち時間の表</span></li>
        <li><b>どこで止まるか</b><span>3Dの店舗で止まった所の図と、走らせた動画</span></li>
        <li><b>どう直すか</b><span>待機場所・通路・会計の列・ロボットの使い方の直し方</span></li>
        <li><b>いくらかかるか</b><span>月額と購入の費用、浮く人件費、補助金の目安</span></li>
      </ul>
    </div>
  </div>
</section>

<section id="roles" class="band-ink">
  <div class="wrap">
    <div class="sec-head center"><h2><span class="ph">店舗は「入れるか」を、</span><span class="ph">提案する側は「どう入れるか」を決める</span></h2></div>
    <div class="roles">
      <div class="role">
        <p class="role-k">飲食店の経営者・事業責任者</p>
        <ul><li>入れるか、何台か、どの使い方か</li><li>人件費と補助金で、費用が合うか</li><li>どの店舗から入れるか</li></ul>
      </div>
      <div class="role">
        <p class="role-k">配膳ロボットのメーカー・販売会社</p>
        <ul><li>提案先の店舗での、人数と費用の根拠</li><li>導入前に、通路・待機場所・会計の列を決める</li><li>導入後の「止まる」相談を減らす</li></ul>
      </div>
    </div>
  </div>
</section>

<section id="limits" class="band-tint">
  <div class="wrap">
    <div class="limits">
      <div class="sec-head center"><h2><span class="ph">この試算で</span><span class="ph">言えないこと</span></h2></div>
      <ul class="lim">
        <li>数字は、公開されている導入事例とメーカーの仕様をもとにした仮の前提です。実際の店舗で測った値ではありません。</li>
        <li>ロボットなしでは、3Dの店舗と試算の料理の待ちが平均0.2〜0.4分の差で合いました。ロボットを入れたときの差は、原因を分けて調べている途中です（<a href="/record/serving-robot-stage-b-18-runs">LOG367</a>）。</li>
        <li>実際の店舗で数えた値が入るほど、前提を直して合わせていきます。数えるための<a href="/record/serving-robot-observation-sheet">観察シート</a>も公開しています。</li>
      </ul>
    </div>
  </div>
</section>

<section id="next" class="cta-a">
  <div class="wrap">
    <div class="cta-a-in">
      <div class="cta-a-copy">
        <p class="cta-a-k">図面と人数を送ってもらえれば、同じ試算でお返しします。</p>
        <h2>次は、<br>あなたの店舗で。</h2>
        <p class="cta-a-sub">提案先の店舗での試算や、3Dの店舗での確かめの相談も受け付けています。</p>
      </div>
      <div class="cta-a-act">
        <ul class="send">
          <li><i class="ms" aria-hidden="true">map</i><span>店舗の図面<small>写真と寸法でも</small></span></li>
          <li><i class="ms" aria-hidden="true">groups</i><span>スタッフの人数と、混む時間</span></li>
          <li><i class="ms" aria-hidden="true">smart_toy</i><span>ロボットの使い方<small>料理だけ・下げ膳にも など</small></span></li>
        </ul>
        <a class="cta-x-main" href="/message?topic=physical-ai" data-ev="最後:相談"><span><small>自店・提案先の条件で</small>試算を相談する</span><i class="ms" aria-hidden="true">arrow_forward</i></a>
      </div>
    </div>
  </div>
</section>

<section style="padding-top:32px">
  <div class="wrap sources">
    <details>
      <summary>出典と記録</summary>
      <ul>
        <li>試算：<a href="/sim/serving-robot">配膳ロボットの導入試算</a>（SimPy・{conds}条件・各40回の平均）。このページの試算の数字は、試算のページから読み込んでいます</li>
        <li>記録：サービスロボット導入シミュレーション <a href="/record/serving-robot-simulation-simpy">Log#1（試算を作る）</a>・<a href="/record/serving-robot-observation-sheet">Log#2（観察シート）</a>・<a href="/record/unity-family-restaurant-serving-robot">Log#4（3Dの店舗を建てる）</a>・<a href="/record/audit-simulator-before-unity">Log#5</a>・<a href="/record/floor-plans-into-simulator">Log#6（平面図）</a>・<a href="/record/unity-store-from-site-json">Log#7</a>・<a href="/record/serving-robot-cost-subsidy">Log#8（費用と補助金）</a>・<a href="/record/serving-robot-jam-points">Log#9（詰まる所）</a>・<a href="/record/serving-robot-stage-b-18-runs">Log#10（18回の台帳）</a></li>
        <li>費用：<a href="https://www.onamae.com/business/article/306061/">お名前.com ビジネス（配膳ロボットの導入費用）</a>、<a href="https://sogyotecho.jp/inshokuten-haizenrobot/">創業手帳（保守・充電）</a></li>
        <li>時給：<a href="https://www.jil.go.jp/kokunai/blt/backnumber/2026/10/special_03.html">労働政策研究・研修機構（2026年度の最低賃金）</a></li>
        <li>補助金：<a href="https://hojyokin-portal.jp/columns/shoryokuka-catalog">補助金ポータル（省力化投資補助金 カタログ注文型）</a>、<a href="https://www.mhlw.go.jp/content/001696868.pdf">厚生労働省（介護業で申請できる製品の例に配膳ロボット）</a></li>
      </ul>
    </details>
  </div>
</section>
  </div>
</Base>
<script is:inline define:vars={{ staffData }}>
(function(){
  // ---- 動画：見えている間だけ再生する（動きを減らす設定では止めておく） ----
  var still = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  // ---- 最初の画面：分割と全面を順に切り替える ----
  var scs = [].slice.call(document.querySelectorAll('.srl .fv-stage .sc')), bars = [].slice.call(document.querySelectorAll('.srl .sc-bar i')), si = 0, timer = null;
  function vids(sc, play){ sc.querySelectorAll('video').forEach(function(v){ if (play) { if (v.closest('.t-term')) v.currentTime = 0; v.play().catch(function(){}); } else v.pause(); }); }
  function show(n){
    var cur = scs[si], nx = scs[n];
    scs.forEach(function(x){ x.classList.remove('prev'); });
    if (cur !== nx) { cur.classList.remove('on'); cur.classList.add('prev'); setTimeout(function(){ cur.classList.remove('prev'); vids(cur, false); }, 1300); }
    nx.classList.remove('on'); void nx.offsetWidth; nx.classList.add('on'); vids(nx, true);
    bars.forEach(function(b, k){ b.classList.toggle('done', k < n); b.classList.remove('on'); });
    var d = +nx.dataset.dur; bars[n].style.setProperty('--d', d + 'ms'); void bars[n].offsetWidth; bars[n].classList.add('on');
    si = n; clearTimeout(timer); timer = setTimeout(function(){ show((si + 1) % scs.length); }, d);
  }
  if (still) { vids(scs[0], false); bars.forEach(function(b){ b.style.display = 'none'; }); }
  else {
    show(0);
    document.addEventListener('visibilitychange', function(){ if (document.hidden) { clearTimeout(timer); vids(scs[si], false); } else show(si); });
  }
  var loops = document.querySelectorAll('.srl video.loop');
  if (still) { loops.forEach(function(v){ v.controls = true; }); }
  else if ('IntersectionObserver' in window) {
    var io = new IntersectionObserver(function(es){ es.forEach(function(e){ var v = e.target; if (e.isIntersecting) { v.play().catch(function(){}); } else { v.pause(); } }); }, { threshold: .35 });
    loops.forEach(function(v){ io.observe(v); });
  }

  // ---- 02 ①：店が回る最少のスタッフ ----
  var store = 'family', crowd = 1;
  var rowsEl = document.getElementById('staffRows'), condEl = document.getElementById('staffCond');
  var CROWD = ['空いている','普通','混雑'];
  // 人とロボットの絵（人＝頭と体、ロボット＝顔の画面と段の付いた配膳ロボット）
  var ICON = {
    'g-staff': '<svg viewBox="0 0 16 30" aria-hidden="true"><circle cx="8" cy="4.6" r="4.2"/><path d="M1.5 13.2c0-2.3 1.9-4.2 4.2-4.2h4.6c2.3 0 4.2 1.9 4.2 4.2v8.3h-3.1V30H4.6v-8.5H1.5z"/></svg>',
    'g-robot': '<svg viewBox="0 0 22 30" aria-hidden="true"><rect x="3" y="0.5" width="16" height="10" rx="3.5"/><circle class="w" cx="8" cy="5.5" r="1.5"/><circle class="w" cx="14" cy="5.5" r="1.5"/><path d="M4 12h14l1.6 15H2.4z"/><rect x="2" y="27.5" width="18" height="2" rx="1"/></svg>'
  };
  function dots(n, cls){ var s=''; for (var i=0;i<n;i++) s+='<i class="'+cls+'">'+ICON[cls]+'</i>'; return s; }
  function renderStaff(){
    var s = staffData.find(function(x){ return x.k === store; }), c = s.c[crowd];
    condEl.textContent = s.n + '（' + s.t + '卓）・' + CROWD[crowd] + '（1時間' + c.a + '組）';
    var base = c.s[0], h = '';
    c.s.forEach(function(st, n){
      var diff = (base != null && st != null) ? st - base : null;
      var tag = n === 0 ? '<em class="same">基準</em>' : diff == null ? '' : diff < 0 ? '<em class="cut">' + (-diff) + '人減る</em>' : diff > 0 ? '<em class="plus">揺らぎ</em>' : '<em class="same">減らない</em>';
      h += '<div class="srow"><span class="sl">ロボット' + n + '台</span><span class="sg">' + dots(st || 0, 'g-staff') + dots(n, 'g-robot') + '</span><span class="sv"><b>' + (st == null ? '―' : st) + '</b><small>人</small>' + tag + '</span></div>';
    });
    rowsEl.innerHTML = h;
    document.querySelectorAll('#segStore button').forEach(function(b){ b.setAttribute('aria-pressed', String(b.dataset.k === store)); });
    document.querySelectorAll('#segCrowd button').forEach(function(b){ b.setAttribute('aria-pressed', String(+b.dataset.c === crowd)); });
  }
  document.querySelectorAll('#segStore button').forEach(function(b){ b.addEventListener('click', function(){ store = b.dataset.k; renderStaff(); }); });
  document.querySelectorAll('#segCrowd button').forEach(function(b){ b.addEventListener('click', function(){ crowd = +b.dataset.c; renderStaff(); }); });
  renderStaff();
})();
</script>
<style is:global>
.srl{
  --paper:#f7f7f7; --sheet:#ffffff; --ink:#242424; --muted:#555; --rule:#e6e6e6;
  --robot:#f2600c;         /* 差し色：ロボット（ロボットを入れたときの値・ロボットの印）だけに使う。最後のCTAのボタンも研究室LPとそろえてこの色 */
  --gut:max(16px,env(safe-area-inset-left,0px));
  --sans:"Manrope","Noto Sans JP","Hiragino Sans",system-ui,sans-serif;
  --mono:"IBM Plex Mono",ui-monospace,"SFMono-Regular",Menlo,monospace;
  --body:36em;
}
.srl, .srl *{box-sizing:border-box}
.srl{background:var(--paper);color:var(--ink);font-family:var(--sans);font-size:17px;line-height:1.75;letter-spacing:.02em;padding-top:48px;text-autospace:normal;-webkit-font-smoothing:antialiased}
.srl a{color:var(--ink);text-underline-offset:3px}
.srl p a,.srl figcaption a,.srl li a{text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:3px}
.srl p a:hover,.srl figcaption a:hover,.srl li a:hover{text-decoration-thickness:2px}
.srl a:focus-visible,.srl button:focus-visible{outline:2px solid currentColor;outline-offset:2px}
.srl .wrap{max-width:calc(1040px + 2*var(--gut));margin:0 auto;padding-inline:var(--gut)}
/* 広くするのは2つだけ：6つの平面図（図を大きく見せて店舗ごとの違いを読ませる）と、埋め込んだ試算（左の条件と右の結果を横に並べたまま使えるように） */
.srl .wrap.wide{max-width:calc(1280px + 2*var(--gut))}
.srl .prose{max-width:var(--body)}
.srl .prose p{font-size:1.02rem;line-height:1.9}
.srl .mono{font-family:var(--mono);letter-spacing:0}
.srl h1,.srl h2,.srl h3{text-wrap:balance;letter-spacing:0}
.srl h2{font-size:clamp(1.75rem,3.3vw,2.55rem);line-height:1.3;margin:0;font-weight:800;letter-spacing:-.005em}
.srl h2 .ph{display:inline-block}
.srl h3{font-size:1.5rem;line-height:1.4;margin:0;font-weight:900}
.srl p{margin:0 0 1em}
.srl section{padding-block:96px}
.srl section > .wrap{display:grid;gap:24px}
.srl figure{margin:0;min-width:0}
.srl figcaption{font-size:.74rem;color:var(--muted);line-height:1.65}
.srl .lat{-webkit-text-stroke:.028em currentColor;paint-order:stroke fill;letter-spacing:.01em}

/* ---- 帯と見出し ---- */
.srl .band-white{background:#fff}
.srl .band-tint{background:#f0f0f0}
.srl .band-ink{background:var(--ink);color:#eee}
.srl .band-ink h2,.srl .band-ink h3{color:#fff}
.srl .band-ink .prose p{color:#ccc}
.srl .band-ink a{color:#fff}
.srl .sec-head{display:flex;align-items:flex-end;gap:20px;flex-wrap:wrap}
.srl .sec-head h2{flex:1 1 20rem;padding-bottom:4px}
.srl .sec-head.center{justify-content:center;text-align:center;flex-direction:column;align-items:center;gap:12px;margin-bottom:8px}
.srl .sec-head.center h2{flex:none}
.srl .center-p{margin:0 auto;text-align:center}
.srl .sec-lead{margin:0 auto;text-align:center;max-width:none;color:var(--muted);font-size:1rem;line-height:1.85}

/* ---- FV：左に見出しとメニュー（黒の面）、右に切り替わる動画（膜をかけない） ---- */
.srl .fv{display:grid;grid-template-columns:minmax(360px,32rem) minmax(0,1fr);background:#242424;color:#fff;padding:0;min-height:max(640px,calc(100svh - 48px))}
.srl .fv-stage{grid-column:2;grid-row:1;position:relative;overflow:hidden;background:#242424;min-height:0}
/* 場面：分割と全面を切り替える。入ってくる場面の枠は、左から順に開く */
.srl .sc{position:absolute;inset:8px 8px 8px 0;display:grid;gap:8px;visibility:hidden;z-index:1}
.srl .sc.on{visibility:visible;z-index:3}
.srl .sc.prev{visibility:visible;z-index:2}
.srl .sc.on .ft{animation:ftIn .9s cubic-bezier(.65,0,.25,1) both}
.srl .sc.on .ft:nth-child(2){animation-delay:.12s}
.srl .sc.on .ft:nth-child(3){animation-delay:.24s}
.srl .sc.on .ft:nth-child(4){animation-delay:.36s}
@keyframes ftIn{from{clip-path:inset(0 100% 0 0)}to{clip-path:inset(0 0 0 0)}}
.srl .sc-split4{grid-template-columns:repeat(12,minmax(0,1fr));grid-template-rows:minmax(0,1fr) minmax(0,1fr)}
.srl .ft.t-plan{grid-column:1/6;grid-row:1;background:#fff}
.srl .t-plan img{object-fit:contain!important;padding:52px 20px 16px}
.srl .t-3d{grid-column:6/13;grid-row:1}
.srl .t-run{grid-column:1/8;grid-row:2}
.srl .t-res{grid-column:8/13;grid-row:2}
.srl .sc-full{grid-template-columns:1fr;grid-template-rows:1fr}
.srl .sc-full .kb{object-position:50% 30%}
.srl .sc-full.on .kb{animation:kb 5s linear both}
@keyframes kb{from{transform:scale(1.08)}to{transform:scale(1)}}
.srl .sc-split3{grid-template-columns:repeat(12,minmax(0,1fr));grid-template-rows:minmax(0,11fr) minmax(0,9fr)}
.srl .ft.t-term{grid-column:1/13;grid-row:1;background:#0d0f12}
.srl .t-term video{object-fit:cover;object-position:left top}
.srl .ft.t-term figcaption{top:auto;bottom:12px}
.srl .ft.t-routes{grid-column:1/7;grid-row:2;background:#fff}
.srl .t-routes img{object-fit:contain!important;padding:48px 12px 10px}
.srl .ft.t-cost{grid-column:7/13;grid-row:2;background:#fff}
.srl .t-cost img{object-position:left top}
.srl .sc-bar{position:absolute;right:18px;bottom:18px;z-index:5;display:flex;gap:6px}
.srl .sc-bar i{display:block;width:28px;height:3px;background:rgba(255,255,255,.35);overflow:hidden;position:relative}
.srl .sc-bar i::after{content:"";position:absolute;inset:0;background:#fff;transform:scaleX(0);transform-origin:left}
.srl .sc-bar i.done::after{transform:scaleX(1)}
.srl .sc-bar i.on::after{animation:bar var(--d,5s) linear both}
@keyframes bar{to{transform:scaleX(1)}}
.srl .ft{position:relative;overflow:hidden;margin:0;background:#2f2f2f;min-height:0}
.srl .ft img,.srl .ft video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
.srl .ft figcaption{position:absolute;left:12px;top:12px;z-index:1;display:inline-flex;flex-wrap:wrap;align-items:center;gap:4px 10px;max-width:calc(100% - 24px);padding:6px 10px;background:#242424;color:#fff;font-size:.78rem;font-weight:700;line-height:1.4}
.srl .ft.t-res{background:#fff;color:var(--ink);display:grid;grid-template-rows:1fr auto;gap:10px;padding:60px 22px 18px}
.srl .t-res figcaption{background:var(--ink)}
.srl .t-res dl{margin:0;display:grid;grid-auto-rows:1fr;gap:0;border-top:2px solid var(--ink)}
.srl .t-res dl div{display:grid;align-content:center;gap:2px;padding:8px 0;border-bottom:1px solid var(--rule)}
.srl .t-res dt{font-size:.74rem;font-weight:700;color:var(--muted)}
.srl .t-res dd{margin:0;font-family:'Manrope','Noto Sans JP',sans-serif;font-weight:800;font-size:clamp(1.4rem,2.1vw,2.3rem);line-height:1.1;white-space:nowrap;text-autospace:no-autospace}
.srl .t-res dd small{font-size:.42em;font-weight:700;margin:0 2px}
.srl .t-res dd i{font-style:normal;font-size:.5em;color:var(--muted);margin:0 6px;vertical-align:.15em}
.srl .t-res dd em{font-style:normal;color:var(--robot)}
.srl .ft-note{margin:0;font-size:.72rem;color:var(--muted)}
.srl .ft-keys{display:inline-flex;align-items:center;gap:4px;font-weight:500;color:#ddd;font-size:.72rem}
.srl .ft-keys i{display:inline-block;width:9px;height:9px;margin-left:6px}
@media (prefers-reduced-motion: reduce){.srl .sc.on .ft,.srl .sc-full.on .kb{animation:none}}
.srl .k-robot{background:#fff}.srl .k-staff{background:#2f5bd8}.srl .k-guest{background:#f2c230}
.srl .fv-side{grid-column:1;grid-row:1;display:flex;flex-direction:column;justify-content:center;gap:36px;padding:48px 0}
.srl .fv-copy{display:grid;gap:20px;padding:0 44px}
.srl .fv .who{margin:0;font-size:.86rem;font-weight:700;line-height:1.65;color:#bbb}
.srl .fv h1{margin:0;font-size:clamp(2.2rem,3.1vw,2.9rem);line-height:1.2;font-weight:900;letter-spacing:-.01em;word-break:keep-all}
.srl .fv-lead{margin:0;font-size:.96rem;line-height:1.9;color:#ddd}
.srl .fv-btn{justify-self:start;display:inline-flex;align-items:center;gap:10px;margin-top:4px;padding:15px 20px;background:#fff;color:var(--ink)!important;font-weight:800;font-size:.95rem;text-decoration:none;transition:background .15s,color .15s}
.srl .fv-btn .ms{font-size:18px;transition:transform .2s}
.srl .fv-btn:hover{background:var(--ink);color:#fff!important;box-shadow:inset 0 0 0 1.5px #fff}
.srl .fv-btn:hover .ms{transform:translateX(3px)}
.srl .fv-menu{display:grid;border-top:1px solid #3a3a3a}
.srl .fv-menu a{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px;padding:10px 44px;border-bottom:1px solid #3a3a3a;color:#eee;text-decoration:none;font-size:.86rem;font-weight:700;transition:background .15s,color .15s}
.srl .fv-menu a i{font-style:normal;font-size:.74rem;color:#888}
.srl .fv-menu a em{font-style:normal;font-size:16px;color:#888;transition:transform .2s}
.srl .fv-menu a:hover{background:#2f2f2f;color:#fff}
.srl .fv-menu a:hover em{transform:translateY(3px);color:#fff}

/* ---- 01 つまずき ---- */
.srl .troubles{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px;margin-top:16px}
.srl .tr{display:grid;gap:16px;align-content:start}
.srl .tr img{display:block;width:100%;height:auto;aspect-ratio:4/3;object-fit:cover;background:#3a3a3a}
.srl .tr figcaption{display:grid;gap:8px;color:var(--ink);font-size:.9rem}
.srl .tr-k{justify-self:start;font-size:.76rem;font-weight:700;color:var(--muted);letter-spacing:.04em}
.srl .tr b{font-size:1.2rem;line-height:1.45;font-weight:900}
.srl .tr-t{color:var(--muted);line-height:1.75}

/* ---- 02 分かる3つのこと ---- */
.srl .know{display:grid;gap:88px;margin-top:24px}
.srl .kn{display:grid;grid-template-columns:minmax(0,5fr) minmax(0,7fr);gap:64px;align-items:start}
.srl .kn.rev{grid-template-columns:minmax(0,6fr) minmax(0,6fr)}
.srl .kn.rev .kn-text{order:2}
.srl .kn-text{display:grid;gap:14px;align-content:start}
.srl .kn-no{font-family:'Manrope','Noto Sans JP',sans-serif;font-weight:800;font-size:1.1rem;color:var(--muted)}
.srl .kn-text p{margin:0;font-size:.98rem;line-height:1.9}
.srl .kn-note{font-size:.8rem!important;color:var(--muted);line-height:1.7!important}
.srl .kn-nums{display:grid;border-top:2px solid var(--ink);margin-top:6px}
.srl .kn-nums div{display:grid;gap:6px;padding:18px 0 16px;border-bottom:1px solid var(--rule)}
.srl .kn-nums div b{font-size:clamp(2.6rem,4.6vw,3.6rem);line-height:1;letter-spacing:-.02em}
.srl .kn-nums div b em{font-weight:800}
.srl .kn-nums span{font-size:.84rem;font-weight:700}
.srl .kn-nums b,.srl .kn-list b{font-family:'Manrope','Noto Sans JP',sans-serif;font-weight:800;font-size:2rem;line-height:1.1;letter-spacing:-.01em;white-space:nowrap;text-autospace:no-autospace}
.srl .kn-nums i{font-style:normal;font-size:.55em;margin:0 10px;color:var(--muted);vertical-align:.15em}
.srl .kn-nums em{font-style:normal;color:var(--robot)}
.srl .kn-nums small,.srl .kn-list small{font-size:.42em;font-weight:700;margin:0 3px}
.srl .kn-list{list-style:none;margin:6px 0 0;padding:0;display:grid;border-top:2px solid var(--ink)}
.srl .kn-list li{display:flex;justify-content:space-between;align-items:baseline;gap:16px;padding:10px 0;border-bottom:1px solid var(--rule)}
.srl .kn-list span{font-size:.86rem;font-weight:700}
.srl .kn-list b{font-size:1.5rem}
.srl .staff-fig{background:var(--sheet);padding:30px 30px 24px;display:grid;gap:14px;box-shadow:0 1px 0 var(--rule)}
.srl .seg{display:flex;flex-wrap:wrap;gap:6px}
.srl .seg button{font:inherit;font-size:.85rem;font-weight:700;padding:7px 12px;border:1.5px solid var(--ink);background:transparent;color:var(--ink);cursor:pointer}
.srl .seg button[aria-pressed="true"]{background:var(--ink);color:#fff}
.srl .seg.small button{font-size:.8rem;padding:5px 10px}
.srl .staff-cond{margin:6px 0 0;font-size:.82rem;font-weight:700;color:var(--muted)}
.srl .staffrows{display:grid;border-top:2px solid var(--ink)}
.srl .srow{display:grid;grid-template-columns:7rem minmax(0,1fr) 9rem;gap:14px;align-items:center;padding:14px 0;border-bottom:1px solid var(--rule)}
.srl .srow .sl{font-size:.88rem;font-weight:700}
.srl .srow .sg{display:flex;flex-wrap:wrap;gap:5px;align-items:center;min-width:0}
.srl .g-staff,.srl .g-robot{display:block;width:17px;height:32px;line-height:0}
.srl .g-robot{width:23px}
.srl .g-staff svg,.srl .g-robot svg{width:100%;height:100%;display:block}
.srl .g-staff svg{fill:var(--ink)}
.srl .g-robot svg{fill:var(--robot)}
.srl .g-robot svg .w{fill:#fff}
.srl .g-staff + .g-robot{margin-left:12px}
.srl .srow .sv{display:flex;align-items:baseline;justify-content:flex-end;gap:2px;white-space:nowrap}
.srl .srow .sv b{font-family:'Manrope',sans-serif;font-weight:800;font-size:1.9rem;line-height:1}
.srl .srow .sv small{font-size:.8rem;font-weight:700;margin-right:10px}
.srl .srow em{font-style:normal;font-size:.72rem;font-weight:700;padding:3px 8px;min-width:4.6em;text-align:center}
.srl .srow em.same{background:#f0f0f0;color:var(--muted)}
.srl .srow em.cut{background:var(--ink);color:#fff}
.srl .srow em.plus{background:transparent;color:var(--muted);border:1px solid var(--rule)}
.srl .clip{display:grid;gap:10px}
.srl .clip-4{max-width:480px;justify-self:center;width:100%}
.srl .clip-4 video{aspect-ratio:694/720!important}
.srl .clip video{display:block;width:100%;height:auto;aspect-ratio:976/652;background:#3a3a3a;box-shadow:0 24px 48px -28px rgba(36,36,36,.45)}
.srl .cost-fig{background:var(--sheet);padding:32px;display:grid;gap:14px;box-shadow:0 1px 0 var(--rule)}
.srl .cf-k{margin:0;font-size:.86rem;font-weight:700;color:var(--muted)}
.srl .cf-big{font-family:'Manrope',sans-serif;font-weight:800;font-size:clamp(3.6rem,7vw,6rem);line-height:.9;letter-spacing:-.03em;white-space:nowrap;text-autospace:no-autospace;margin-bottom:10px}
.srl .cf-big small{font-size:.24em;font-weight:700;letter-spacing:0;margin-left:6px}
.srl .cb-row{display:grid;grid-template-columns:9.5rem minmax(0,1fr) 6.5rem;gap:14px;align-items:center}
.srl .cb-l{font-size:.86rem;font-weight:700}
.srl .cb-track{display:block;height:28px;background:#f0f0f0}
.srl .cb-track i{display:block;height:100%}
.srl .cb-robot{background:var(--robot)}.srl .cb-ink{background:var(--ink)}
.srl .cb-v{font-family:'Manrope',sans-serif;font-weight:800;font-size:1.6rem;line-height:1;text-align:right;white-space:nowrap}
.srl .cb-v small{font-size:.45em;font-weight:700;margin-left:3px}

/* ---- 03 店の形 ---- */
.srl .shape-top{display:grid;gap:20px;justify-items:center;text-align:center}
.srl .shape-top .prose{max-width:none}
.srl .shape-top .prose p{margin:0}
.srl .plans{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:28px 24px;margin-top:24px}
.srl .plan{display:grid;gap:12px;align-content:start}
.srl .plan img{display:block;width:100%;height:auto;aspect-ratio:320/230;object-fit:contain;background:var(--paper);padding:10px}
.srl .plan figcaption{display:grid;gap:6px;color:var(--ink);font-size:.86rem}
.srl .plan b{font-size:.98rem;line-height:1.4}
.srl .pnum{display:inline-block;margin-right:6px}
.srl .pmeta{font-size:.76rem;color:var(--muted)}
.srl .pbar{display:grid;gap:3px;margin-top:4px}
.srl .pbar i{display:block;height:8px}
.srl .pb-from{background:var(--ink)}.srl .pb-to{background:var(--robot)}
.srl .pval{font-family:'Manrope','Noto Sans JP',sans-serif;font-weight:800;font-size:1.6rem;line-height:1.2;white-space:nowrap;text-autospace:no-autospace}
.srl .pval i{font-style:normal;font-size:.7em;margin:0 6px;color:var(--muted)}
.srl .pval em{font-style:normal;color:var(--robot)}
.srl .pval small{font-size:.6em;margin-left:2px}
.srl .plans-note{margin:0;font-size:.8rem;color:var(--muted)}

/* ---- 04 試す ---- */
.srl .simbox{background:#fff;padding:8px;margin-top:8px}
.srl .simframe{display:block;width:100%;height:1600px;border:0}
.srl .sim-foot{display:flex;flex-wrap:wrap;align-items:center;gap:12px 24px;font-size:.88rem;color:#ccc}
.srl .sim-open{display:inline-flex;align-items:center;gap:6px;font-weight:700;color:#fff;text-decoration:underline;text-underline-offset:4px}
.srl .sim-open .ms{font-size:16px}

/* ---- 05 頼む流れ ---- */
.srl .steps{list-style:none;margin:16px 0 0;padding:0;display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px}
.srl .steps li{position:relative;background:#fff;padding:22px 20px 24px;display:grid;gap:8px;align-content:start;box-shadow:0 1px 0 var(--rule)}
.srl .steps li:not(:last-child)::after{content:"";position:absolute;right:-12px;top:50%;width:12px;height:2px;background:var(--ink)}
.srl .steps i{font-style:normal;font-weight:600;font-size:1.6rem;line-height:1}
.srl .steps .ico{width:40px;height:40px;fill:var(--ink);margin-bottom:8px}
.srl .steps .ico .w{fill:none;stroke:#fff;stroke-width:3.4;stroke-linecap:square;stroke-linejoin:miter}
.srl .steps b{font-size:1.02rem;line-height:1.45}
.srl .steps span{font-size:.84rem;color:var(--muted);line-height:1.7}
.srl .receive{background:var(--ink);color:#fff;padding:32px 36px;display:grid;grid-template-columns:10rem minmax(0,1fr);gap:24px;align-items:start;margin-top:12px}
.srl .rc-k{margin:0;font-size:1.1rem;font-weight:900}
.srl .receive ul{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px 36px}
.srl .receive li{display:grid;gap:4px;padding-top:12px;border-top:1px solid #444}
.srl .receive b{font-size:1rem}
.srl .receive span{font-size:.86rem;color:#ccc;line-height:1.7}

/* ---- 06 立場ごと ---- */
.srl .roles{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:24px;margin-top:16px}
.srl .role{background:#fff;color:var(--ink);padding:32px 36px}
.srl .role-k{margin:0 0 16px;font-size:1.15rem;font-weight:900}
.srl .role ul{list-style:none;margin:0;padding:0;display:grid;border-top:2px solid var(--ink)}
.srl .role li{padding:14px 0;border-bottom:1px solid var(--rule);font-size:1rem;font-weight:700;line-height:1.55}

/* ---- 07 言えないこと ---- */
.srl #limits{padding-block:72px}
.srl .limits{display:grid;gap:28px;max-width:52rem;margin:0 auto;width:100%}
.srl .lim{margin:0;padding:0;list-style:none;display:grid;border-top:2px solid var(--ink)}
.srl .lim li{padding:16px 0;border-bottom:1px solid #ddd;font-size:.95rem;line-height:1.85}

/* ---- 最後のCTA：3Dの店舗の写真の上に、送るもの3つとボタン ---- */
.srl .cta-a{position:relative;isolation:isolate;overflow:hidden;background:#242424;padding-block:120px}
.srl .cta-a::before{content:"";position:absolute;inset:0;z-index:-2;background:#242424 url(/site/srl/cta-plan.svg) center/cover no-repeat}
.srl .cta-a-in{background:#fff;display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:56px;align-items:center;padding:56px 60px;box-shadow:0 30px 60px -30px rgba(0,0,0,.5)}
.srl .cta-a-copy{display:grid;gap:14px}
.srl .cta-a-k{margin:0;font-weight:800;font-size:.95rem}
.srl .cta-a h2{margin:0;font-size:clamp(1.9rem,5vw,3rem);line-height:1.2;font-weight:900;word-break:auto-phrase}
.srl .cta-a-sub{margin:0;color:#444;font-size:.92rem;line-height:1.85}
.srl .cta-a-act{display:grid;gap:10px}
.srl .send{list-style:none;margin:0;padding:0;display:grid;gap:10px}
.srl .send li{display:flex;align-items:center;gap:14px;background:var(--paper);padding:15px 18px;font-weight:700;font-size:.98rem}
.srl .send li .ms{flex:none;width:36px;height:36px;display:grid;place-items:center;background:var(--ink);color:#fff;font-size:20px}
.srl .send small{display:block;font-weight:500;font-size:.76rem;color:var(--muted);margin-top:1px}
.srl .cta-a .cta-x-main{margin-top:6px}
/* ---- 最後のCTAのボタン ---- */
.srl .cta-x-main{word-break:auto-phrase;display:flex;justify-content:space-between;align-items:center;gap:16px;text-decoration:none;background:var(--robot);color:#fff!important;padding:24px 26px;font-weight:900;font-size:1.15rem;line-height:1.35;transition:background .15s}
.srl .cta-x-main small{display:block;font-size:.72rem;font-weight:700;letter-spacing:.06em;margin-bottom:4px}
.srl .cta-x-main .ms{font-size:28px;transition:transform .2s}
.srl .cta-x-main:hover{background:var(--ink)}
.srl .cta-x-main:hover .ms{transform:translateX(5px)}
.srl .cta-x-main:focus-visible{outline:none;background:var(--ink)}
.srl .sources{font-size:.8rem;color:var(--muted)}
.srl .sources details{border-top:1px solid var(--rule);padding-top:10px}
.srl .sources summary{cursor:pointer;font-weight:500}
.srl .sources ul{padding-left:1.2em;margin:.5em 0 0;display:grid;gap:4px}

/* 見出しの下の説明文は、幅の広い画面では1行で出す */
@media (min-width:1100px){.srl .sec-lead,.srl .shape-top .prose p,.srl #sim .center-p p{white-space:nowrap}.srl #sim .center-p{max-width:none}}

/* ---- タブレット・スマートフォン ---- */
@media (max-width:900px){
  .srl .cta-a{padding-block:56px}
  .srl .cta-a-in{grid-template-columns:1fr;gap:28px;padding:32px 22px}
  .srl section{padding-block:60px}
  .srl .fv{grid-template-columns:1fr;min-height:0}
  .srl .fv-stage{grid-column:1;grid-row:2;aspect-ratio:1/1;margin:0 8px}
  .srl .sc{inset:0;gap:6px}
  .srl .sc-split4{grid-template-columns:1fr 1fr}
  .srl .ft.t-plan{grid-column:1;grid-row:1}.srl .ft.t-3d{grid-column:2;grid-row:1}.srl .ft.t-run{grid-column:1;grid-row:2}.srl .ft.t-res{grid-column:2;grid-row:2}
  .srl .t-plan img{padding:40px 6px 6px}
  .srl .sc-split3{grid-template-columns:1fr 1fr;grid-template-rows:3fr 2fr}
  .srl .ft.t-term{grid-column:1/3;grid-row:1}.srl .ft.t-routes{grid-column:1;grid-row:2}.srl .ft.t-cost{grid-column:2;grid-row:2}
  .srl .t-routes img{padding:38px 6px 6px}
  .srl .ft-long{display:none}
  .srl .ft.t-res{grid-template-rows:auto;align-content:end;padding:40px 12px 12px;gap:6px}
  .srl .t-res dl{grid-auto-rows:auto}
  .srl .t-res dl div{padding:4px 0}
  .srl .t-res dt{font-size:.62rem}
  .srl .t-res dd{font-size:1.05rem}
  .srl .t-res dd i{margin:0 3px}
  .srl .ft-keys,.srl .ft-note{display:none}
  .srl .ft figcaption{left:6px;top:6px;font-size:.68rem;padding:4px 8px}
  .srl .ft.t-term figcaption{top:auto;bottom:6px}
  .srl .sc-bar{right:10px;bottom:10px}
  .srl .sc-bar i{width:18px}
  .srl .fv-side{display:contents}
  .srl .fv-copy{grid-row:1;padding:28px var(--gut) 24px!important}
  .srl .fv-menu{grid-row:3;margin-top:16px}
  .srl .fv-copy{gap:16px}
  .srl .fv h1{font-size:clamp(2rem,8.6vw,2.8rem)}
  .srl .fv-menu a{padding:12px var(--gut)}
  .srl .troubles{grid-template-columns:1fr;gap:36px}
  .srl .know{gap:56px}
  .srl .kn,.srl .kn.rev{grid-template-columns:1fr;gap:24px}
  .srl .kn.rev .kn-text{order:0}
  .srl .staff-fig,.srl .cost-fig{padding:20px 16px}
  .srl .srow{grid-template-columns:5.6rem minmax(0,1fr) auto;gap:8px}
  .srl .srow .sv b{font-size:1.5rem}
  .srl .srow .sv small{margin-right:6px}
  .srl .g-staff{width:12px;height:23px}.srl .g-robot{width:16px;height:23px}
  .srl .cb-row{grid-template-columns:1fr auto;gap:6px 12px}
  .srl .cb-track{grid-column:1/-1;grid-row:2;height:22px}
  .srl .cb-v{font-size:1.3rem}
  .srl .shape-top,.srl .limits{grid-template-columns:1fr;gap:20px}
  .srl .plans{grid-template-columns:repeat(2,minmax(0,1fr));gap:24px 14px}
  .srl .simframe{height:1500px}
  .srl .simbox{padding:4px;margin-inline:calc(-1*var(--gut) + 4px)}
  .srl .steps{grid-template-columns:1fr;gap:10px}
  .srl .steps li{padding:16px;grid-template-columns:40px minmax(0,1fr);column-gap:14px;align-items:start}
  .srl .steps .ico{grid-row:1/3;margin:0}
  .srl .steps li:not(:last-child)::after{right:auto;left:50%;top:auto;bottom:-10px;width:2px;height:10px}
  .srl .receive{grid-template-columns:1fr;padding:24px 20px;gap:12px}
  .srl .receive ul{grid-template-columns:1fr;gap:12px}
  .srl .roles{grid-template-columns:1fr;gap:14px}
  .srl .role{padding:24px 20px}
        }
@media (max-width:600px){
  .srl .plan img{padding:4px}
  .srl .plan b{font-size:.86rem}
  .srl .pmeta{font-size:.7rem}
  .srl .pval{font-size:1.2rem}
}

/* 角丸は、埋め込んだ試算（/sim/serving-robot）とそろえる：大きな面 12px・カードと画像 8px・ボタンと札 4px */
.srl{--r-l:12px;--r-m:8px;--r-s:4px}
.srl .simbox,.srl .staff-fig,.srl .cost-fig,.srl .receive,.srl .role,.srl .cta-a-in{border-radius:var(--r-l)}
.srl .send li,.srl .send li .ms{border-radius:var(--r-m)}
.srl .simbox{overflow:hidden}
.srl .simframe{border-radius:calc(var(--r-l) - 6px)}
.srl .ft,.srl .tr img,.srl .plan img,.srl .clip video,.srl .steps li{border-radius:var(--r-m)}
.srl .ft figcaption,.srl .srow em,.srl .cb-track,.srl .cb-track i,.srl .pbar i{border-radius:var(--r-s)}
.srl .cta-x-main,.srl .seg button,.srl .fv-btn,.srl .toc a{border-radius:var(--r-s)}
.srl .ft-keys i{border-radius:50%}
</style>
```

### servingSim.ts（試算のページから数字を読む）

ページの数字はすべてここから出しています。

```ts
// 配膳ロボットの試算（public/sim/serving-robot.html）の結果を、ビルドのときに読む。
// LP（/serving-robot-one-less-staff）の数字は、すべてここから出す。試算が正なので、試算を直せば LP の数字も次のビルドで変わる。
// 新しい計算はしない。試算のページと同じ決め方（find・minStaff・費用の既定値）で読むだけ。
import fs from 'node:fs';
import path from 'node:path';

type Row = Record<string, any>;
type Store = Record<string, any>;

const html = fs.readFileSync(path.join(process.cwd(), 'public/sim/serving-robot.html'), 'utf8');
const at = html.indexOf('const D = ') + 'const D = '.length;
// 試算のページに埋め込まれている D（JSON）を1つだけ読み出す
function readJson(src: string, i: number) {
  let depth = 0, inStr = false, esc = false;
  for (let j = i; j < src.length; j++) {
    const c = src[j];
    if (inStr) { if (esc) esc = false; else if (c === '\\') esc = true; else if (c === '"') inStr = false; continue; }
    if (c === '"') inStr = true;
    else if (c === '{' || c === '[') depth++;
    else if (c === '}' || c === ']') { depth--; if (depth === 0) return JSON.parse(src.slice(i, j + 1)); }
  }
  throw new Error('試算のデータ（const D）が読めません');
}
const D = readJson(html, at);
const K: string[] = D.k;
const ROWS: Row[] = D.r.map((r: any[]) => Object.fromEntries(K.map((k, i) => [k, r[i]])));
export const STORES: Record<string, Store> = D.stores;
const IDX = new Map(ROWS.map((r) => [[r.store, r.staff, r.robots, r.arrivals_per_h, r.mode, r.trays, r.fragile_by].join('|'), r]));

// 試算のページの既定（1往復で3卓まで・壊れやすい皿は誰が運んでもよい）
const TRAYS = 3, FB = 'any';
export function find(store: string, staff: number, robots: number, arr: number, mode = 'serve'): Row | undefined {
  const k = robots === 0 ? [store, staff, 0, arr, 'serve', 1, 'any'] : [store, staff, robots, arr, mode, TRAYS, FB];
  return IDX.get(k.join('|'));
}
export const unstable = (r: Row) => r.ng === 1;
export function minStaff(store: string, robots: number, arr: number, mode = 'serve'): number | null {
  for (const st of STORES[store].staff_all) {
    const r = find(store, st, robots, arr, mode);
    if (r && !unstable(r)) return st;
  }
  return null;
}

// 費用の既定（試算のページの COST と同じ）
export const COST = { rent: 60000, buy: 2500000, maint: 10000, charge: 2000, years: 5, wage: 1177, peaks: 2, days: 30, shiftH: 4, subRate: 0.5, subCap: 7500000 };
export const robotMonthRent = (n: number) => n * (COST.rent + COST.maint + COST.charge);
export const savedPerStaff = (hours: number) => hours * COST.peaks * COST.days * COST.wage;

export const CONDITIONS = ROWS.length; // 条件の数
export const MODES = ['serve', 'bus', 'patrol'];

// 標準ファミレス・普通の混み具合・料理を運ぶだけ
const fam = STORES.family;
const famArr = fam.arrivals[1];
const base = find('family', 3, 0, famArr)!;
const less = find('family', 2, 2, famArr)!;
const same = find('family', 3, 2, famArr)!;
export const headline = { tables: fam.tables, arrivals: famArr, base, less, same };

// 2台入れたとき、店が回る最少のスタッフが何人減るか（店すべて × 混み具合3つ × 使い方3つ）
const twoRobots = { none: 0, one: 0, onePeak: [] as number[], total: 0 };
for (const [key, s] of Object.entries(STORES)) {
  for (const arr of s.arrivals) for (const mode of MODES) {
    const a = minStaff(key, 0, arr, mode), b = minStaff(key, 2, arr, mode);
    if (a == null || b == null) continue;
    twoRobots.total++;
    if (a - b <= 0) twoRobots.none++; else { twoRobots.one++; twoRobots.onePeak.push(s.peak_min); }
  }
}
export { twoRobots };

// 店の種類5つ × 混み具合3つ：ロボット0〜3台の、店が回る最少のスタッフ（料理を運ぶだけ）
export const BASIC_STORES = ['small', 'family', 'large', 'hotel', 'buffet'];
export const staffTable = BASIC_STORES.map((k) => ({
  key: k, label: STORES[k].label, tables: STORES[k].tables, peak: STORES[k].peak_min,
  crowd: STORES[k].arrivals.map((arr: number) => ({ arr, staff: [0, 1, 2, 3].map((n) => minStaff(k, n, arr)) })),
}));

// 図面のある店6つ：真ん中の人数・普通の混み具合で、ロボット0台 → 2台
export const plans = Object.keys(STORES).filter((k) => STORES[k].layout).map((k) => {
  const s = STORES[k], st = s.staff[1], arr = s.arrivals[1];
  const a = find(k, st, 0, arr)!, b = find(k, st, 2, arr)!;
  const label: string = s.label.replace(/^標準ファミレス（|）$/g, '');
  return { key: k, file: s.layout, label, num: label.slice(0, 1), name: label.slice(2), tables: s.tables, staff: st, arrivals: arr, from: a.entry_wait_mean, to: b.entry_wait_mean, ngFrom: unstable(a), ngTo: unstable(b) };
});

// 大型店・混雑で、2台を買って補助金を使った場合
const lg = STORES.large, lgArr = lg.arrivals[2];
const lgFrom = minStaff('large', 0, lgArr)!, lgTo = minStaff('large', 2, lgArr)!;
const buyTotal = 2 * COST.buy, sub = Math.min(buyTotal * COST.subRate, COST.subCap), first = buyTotal - sub;
const run = 2 * (COST.maint + COST.charge);
const buyMonth = first / (COST.years * 12) + run;
const lgSaved = (lgFrom - lgTo) * savedPerStaff(lg.peak_min / 60);
export const buyLarge = { from: lgFrom, to: lgTo, first, buyMonth, saved: lgSaved, net: lgSaved - buyMonth, payback: Math.ceil(first / (lgSaved - run)) };

// 表示の形
export const min1 = (v: number) => (Math.round(v * 10) / 10).toFixed(1);
export const sec = (vMin: number) => Math.round(vMin * 60);
export const man = (yen: number) => {
  const a = Math.round(Math.abs(yen) / 1000) / 10;
  return (yen < 0 ? '−' : yen > 0 ? '+' : '') + (Number.isInteger(a) ? String(a) : a.toFixed(1));
};
export const pct = (v: number) => Math.round(v * 100);
```

### serving-robot.html の変更（このページに埋め込んだときだけ色をそろえる）

試算のページの変更は、この commit の差分だけです。

```diff
commit 11113dc9cc18b0d630e953b3958a807bd295c14e
Author: Claude <noreply@anthropic.com>
Date:   Sat Oct 24 11:54:27 2026 +0900

    配膳ロボットのLP：埋め込んだ試算の黒と白を、LPに埋め込んだときだけ（?lp=1）サイト共通の色にそろえる。試算のページ単体と /series8 の見た目は変えない
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01UbPZJruW4MJ6W3afZEfKQy

diff --git a/public/sim/serving-robot.html b/public/sim/serving-robot.html
index 310b13e..c45f387 100644
--- a/public/sim/serving-robot.html
+++ b/public/sim/serving-robot.html
@@ -21,8 +21,14 @@ html.embed .stores label { border-color: #f7f7f7; }
 
 /* 表のセルを折り返さず、はみ出す分は .tablewrap の中で横にスクロールする（狭い画面で1行に1〜2文字になっていた。2026-10-20） */
 .ctable th, .ctable td { white-space: nowrap; }
+/* LP（/serving-robot-one-less-staff）に埋め込んだときだけ（?lp=1）、黒と白をサイト共通の色にそろえる。試算のページ単体と /series8 の見た目は変えない（2026-10-24） */
+html.lp .stores, html.lp .layouts { background: #242424; }
+html.lp .stores label, html.lp.embed .stores label, html.lp .layouts label { background: #3a3a3a; border-color: #3a3a3a; }
+html.lp .stores input:checked + label, html.lp.embed .stores input:checked + label, html.lp .layouts input:checked + label { background: #fff; border-color: #fff; color: #242424; }
+html.lp .plan-big { background: #f7f7f7; color: #242424; }
 </style>
 <script>if (/[?&]embed=1/.test(location.search)) document.documentElement.classList.add('embed');</script>
+<script>if (/[?&]lp=1/.test(location.search)) document.documentElement.classList.add('lp');</script>
 </head>
 <body>
 <style>
```

### astro.config.mjs（LP を作っていたときの版）

LP を作っていたときの全文です。13行目の filter で、このページをサイトマップから外していました。

```js
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
import remarkMarkers from './src/lib/remark-markers.mjs';

// URL は今の Studio と同じ「末尾の / なし」にそろえる（/series7 → series7.html）
export default defineConfig({
  site: 'https://pixelstomotors.com',
  trailingSlash: 'ignore',
  build: { format: 'file' },
  // 検索エンジン向けの全ページ一覧（/sitemap-index.xml と /sitemap-0.xml を作る）。404 ページは載せない
  integrations: [
    // 確認中の LP は、公開を決めるまで一覧に載せない（ページは noindex）
    sitemap({ filter: (page) => !page.endsWith('/404') && !page.endsWith('/404/') && !page.includes('/serving-robot-one-less-staff') && !page.includes('/thinking-') }),
  ],
  markdown: {
    // コードは今のサイトと同じく色を付けずに出す
    syntaxHighlight: false,
    remarkPlugins: [remarkMarkers],
  },
});
```

### cta_plan.py（最後の相談の背景を描く）

⑤ゆったり型の店舗の形のデータ（p2m-site の public/sim/layouts/p3_relaxed_center.json と p3_relaxed_center_analysis.json）から、図面とロボットの道を SVG に描きます。動かし方は `python3 cta_plan.py public/sim/layouts/p3_relaxed_center.json public/sim/layouts/p3_relaxed_center_analysis.json public/site/srl/cta-plan.svg` です。

```python
"""最後の節の背景：⑤の店舗の図面（暗い地に細い線）と、待機場所から全卓へのロボットの道（最短の道を格子で探す）"""
import json, sys
from collections import deque
L = json.load(open(sys.argv[1]))['layout']; A = json.load(open(sys.argv[2]))
W, D = L['outer']['w'], L['outer']['d']; S = 0.1; R = 0.3
nx, ny = int(W / S), int(D / S)
block = [[False] * ny for _ in range(nx)]
def rect_block(x0, y0, x1, y1, pad):
    for i in range(max(0, int((x0 - pad) / S)), min(nx, int((x1 + pad) / S) + 1)):
        for j in range(max(0, int((y0 - pad) / S)), min(ny, int((y1 + pad) / S) + 1)):
            block[i][j] = True
for r in L['rooms']: rect_block(*r['rect'], R)
for w in L['walls']: rect_block(*w['rect'], R)
for t in L['tables']: rect_block(t['x'], t['y'], t['x'] + t['w'], t['y'] + t['h'], R - 0.05)
for i in range(nx):
    for j in range(ny):
        if i * S < R or j * S < R or i * S > W - R or j * S > D - R: block[i][j] = True
sx, sy = L['start']; s = (int(sx / S), int(sy / S))
prev = {s: None}; q = deque([s])
while q:
    c = q.popleft()
    for dx, dy in ((1,0),(-1,0),(0,1),(0,-1),(1,1),(1,-1),(-1,1),(-1,-1)):
        n = (c[0] + dx, c[1] + dy)
        if 0 <= n[0] < nx and 0 <= n[1] < ny and not block[n[0]][n[1]] and n not in prev:
            prev[n] = c; q.append(n)
def path(p):
    g = (round(p[0] / S), round(p[1] / S)); best = min(prev, key=lambda c: (c[0]-g[0])**2 + (c[1]-g[1])**2)
    out = []
    while best: out.append(best); best = prev[best]
    return out[::-1]
def simp(pts):
    o = [pts[0]]
    for a, b in zip(pts[1:], pts[2:]):
        d1 = (a[0]-o[-1][0], a[1]-o[-1][1]); d2 = (b[0]-a[0], b[1]-a[1])
        if d1[0]*d2[1] - d1[1]*d2[0] != 0: o.append(a)
    o.append(pts[-1]); return o
Y = lambda y: D - y
out = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="-1 -1 {W+2} {D+2}" preserveAspectRatio="xMidYMid slice">',
       f'<rect x="-50" y="-50" width="{W+100}" height="{D+100}" fill="#242424"/>',
       '<g fill="none" stroke="#fff" stroke-opacity=".22" stroke-width="0.04">']
for r in L['rooms']:
    x0, y0, x1, y1 = r['rect']; out.append(f'<rect x="{x0}" y="{Y(y1)}" width="{x1-x0:.3f}" height="{y1-y0:.3f}" fill="#fff" fill-opacity=".04"/>')
for w in L['walls']:
    x0, y0, x1, y1 = w['rect']; out.append(f'<rect x="{x0}" y="{Y(y1)}" width="{x1-x0:.3f}" height="{y1-y0:.3f}" fill="#fff" fill-opacity=".2"/>')
for t in L['tables']:
    out.append(f'<rect x="{t["x"]}" y="{Y(t["y"]+t["h"]):.3f}" width="{t["w"]}" height="{t["h"]}" rx="0.06"/>')
out.append('</g>')
e = L['entrance']
out.append(f'<path d="M0 0H{W}V{D}H{e["a1"]}M{e["a0"]} {D}H0Z" fill="none" stroke="#fff" stroke-opacity=".45" stroke-width="0.12"/>')
out.append('<g opacity=".32"><g fill="none" stroke="#f2600c" stroke-width="0.06" stroke-linecap="round" stroke-linejoin="round">')
for t in A['tables']:
    p = simp(path(t['stop']))
    out.append('<polyline points="' + ' '.join(f'{c[0]*S:.2f},{Y(c[1]*S):.2f}' for c in p) + '"/>')
out.append('</g><g fill="#f2600c">')
for t in A['tables']:
    out.append(f'<circle cx="{t["stop"][0]:.2f}" cy="{Y(t["stop"][1]):.2f}" r="0.11"/>')
x0, y0, x1, y1 = L['robot_standby']['rect']
out.append(f'</g></g><rect x="{x0}" y="{Y(y1)}" width="{x1-x0:.2f}" height="{y1-y0:.2f}" fill="#f2600c" fill-opacity=".45"/></svg>')
open(sys.argv[3], 'w').write('\n'.join(out))
print(len(A['tables']), 'routes')
```

### cta-plan.svg（描き出した背景）

```xml
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-1 -1 28.0 19.0" preserveAspectRatio="xMidYMid slice">
<rect x="-50" y="-50" width="126.0" height="117.0" fill="#242424"/>
<g fill="none" stroke="#fff" stroke-opacity=".22" stroke-width="0.04">
<rect x="9.0" y="6.4" width="10.000" height="1.200" fill="#fff" fill-opacity=".04"/>
<rect x="9.0" y="3.0" width="10.000" height="3.400" fill="#fff" fill-opacity=".04"/>
<rect x="9.0" y="0.0" width="5.000" height="3.000" fill="#fff" fill-opacity=".04"/>
<rect x="14.0" y="0.0" width="5.000" height="3.000" fill="#fff" fill-opacity=".04"/>
<rect x="19.0" y="0.0" width="4.000" height="2.500" fill="#fff" fill-opacity=".04"/>
<rect x="0.0" y="0.0" width="3.000" height="2.400" fill="#fff" fill-opacity=".04"/>
<rect x="11.2" y="14.2" width="0.700" height="2.500" fill="#fff" fill-opacity=".04"/>
<rect x="10.4" y="14.2" width="0.800" height="2.500" fill="#fff" fill-opacity=".04"/>
<rect x="15.4" y="14.7" width="0.500" height="2.000" fill="#fff" fill-opacity=".04"/>
<rect x="15.0" y="11.7" width="3.600" height="0.700" fill="#fff" fill-opacity=".04"/>
<rect x="2.7" y="1.6499999999999986" width="0.300" height="0.750" fill="#fff" fill-opacity=".2"/>
<rect x="2.7" y="0.0" width="0.300" height="0.750" fill="#fff" fill-opacity=".2"/>
<rect x="0.0" y="2.0999999999999996" width="3.000" height="0.300" fill="#fff" fill-opacity=".2"/>
<rect x="0.0" y="13.675" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="0.0" y="11.825" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="0.0" y="9.974999999999998" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="0.0" y="8.125" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="0.0" y="6.275" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="2.7" y="11.675" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="2.7" y="9.825" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="2.7" y="7.975" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="2.7" y="6.125" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="4.050000000000001" y="11.675" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="4.050000000000001" y="9.825" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="4.050000000000001" y="7.975" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="4.050000000000001" y="6.125" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="3.9000000000000004" y="4.15" width="0.150" height="9.650" fill="#fff" fill-opacity=".2"/>
<rect x="6.75" y="11.675" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="6.75" y="9.825" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="8.1" y="11.675" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="8.1" y="9.825" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="7.95" y="7.85" width="0.150" height="5.950" fill="#fff" fill-opacity=".2"/>
<rect x="24.8" y="13.675" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="24.8" y="11.825" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="24.8" y="9.974999999999998" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="24.8" y="8.125" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="24.8" y="6.275" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="20.75" y="11.675" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="20.75" y="9.825" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="20.75" y="7.975" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="20.75" y="6.125" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="22.099999999999998" y="11.675" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="22.099999999999998" y="9.825" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="22.099999999999998" y="7.975" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="22.099999999999998" y="6.125" width="1.200" height="0.150" fill="#fff" fill-opacity=".2"/>
<rect x="21.95" y="4.15" width="0.150" height="9.650" fill="#fff" fill-opacity=".2"/>
<rect x="3.375" y="15.2" width="0.150" height="1.800" fill="#fff" fill-opacity=".2"/>
<rect x="5.2250000000000005" y="15.2" width="0.150" height="1.800" fill="#fff" fill-opacity=".2"/>
<rect x="7.075" y="15.2" width="0.150" height="1.800" fill="#fff" fill-opacity=".2"/>
<rect x="18.175" y="15.2" width="0.150" height="1.800" fill="#fff" fill-opacity=".2"/>
<rect x="20.025" y="15.2" width="0.150" height="1.800" fill="#fff" fill-opacity=".2"/>
<rect x="21.875" y="15.2" width="0.150" height="1.800" fill="#fff" fill-opacity=".2"/>
<rect x="0.0" y="13.750" width="1.2" height="1.85" rx="0.06"/>
<rect x="0.0" y="11.900" width="1.2" height="1.85" rx="0.06"/>
<rect x="0.0" y="10.050" width="1.2" height="1.85" rx="0.06"/>
<rect x="0.0" y="8.200" width="1.2" height="1.85" rx="0.06"/>
<rect x="0.0" y="6.350" width="1.2" height="1.85" rx="0.06"/>
<rect x="0.0" y="4.500" width="1.2" height="1.85" rx="0.06"/>
<rect x="2.7" y="11.750" width="1.2" height="1.85" rx="0.06"/>
<rect x="2.7" y="9.900" width="1.2" height="1.85" rx="0.06"/>
<rect x="2.7" y="8.050" width="1.2" height="1.85" rx="0.06"/>
<rect x="2.7" y="6.200" width="1.2" height="1.85" rx="0.06"/>
<rect x="2.7" y="4.350" width="1.2" height="1.85" rx="0.06"/>
<rect x="4.05" y="11.750" width="1.2" height="1.85" rx="0.06"/>
<rect x="4.05" y="9.900" width="1.2" height="1.85" rx="0.06"/>
<rect x="4.05" y="8.050" width="1.2" height="1.85" rx="0.06"/>
<rect x="4.05" y="6.200" width="1.2" height="1.85" rx="0.06"/>
<rect x="4.05" y="4.350" width="1.2" height="1.85" rx="0.06"/>
<rect x="6.75" y="11.750" width="1.2" height="1.85" rx="0.06"/>
<rect x="6.75" y="9.900" width="1.2" height="1.85" rx="0.06"/>
<rect x="6.75" y="8.050" width="1.2" height="1.85" rx="0.06"/>
<rect x="8.1" y="11.750" width="1.2" height="1.85" rx="0.06"/>
<rect x="8.1" y="9.900" width="1.2" height="1.85" rx="0.06"/>
<rect x="8.1" y="8.050" width="1.2" height="1.85" rx="0.06"/>
<rect x="24.8" y="13.750" width="1.2" height="1.85" rx="0.06"/>
<rect x="24.8" y="11.900" width="1.2" height="1.85" rx="0.06"/>
<rect x="24.8" y="10.050" width="1.2" height="1.85" rx="0.06"/>
<rect x="24.8" y="8.200" width="1.2" height="1.85" rx="0.06"/>
<rect x="24.8" y="6.350" width="1.2" height="1.85" rx="0.06"/>
<rect x="24.8" y="4.500" width="1.2" height="1.85" rx="0.06"/>
<rect x="20.75" y="11.750" width="1.2" height="1.85" rx="0.06"/>
<rect x="20.75" y="9.900" width="1.2" height="1.85" rx="0.06"/>
<rect x="20.75" y="8.050" width="1.2" height="1.85" rx="0.06"/>
<rect x="20.75" y="6.200" width="1.2" height="1.85" rx="0.06"/>
<rect x="20.75" y="4.350" width="1.2" height="1.85" rx="0.06"/>
<rect x="22.1" y="11.750" width="1.2" height="1.85" rx="0.06"/>
<rect x="22.1" y="9.900" width="1.2" height="1.85" rx="0.06"/>
<rect x="22.1" y="8.050" width="1.2" height="1.85" rx="0.06"/>
<rect x="22.1" y="6.200" width="1.2" height="1.85" rx="0.06"/>
<rect x="22.1" y="4.350" width="1.2" height="1.85" rx="0.06"/>
<rect x="1.6" y="15.200" width="1.85" height="1.8" rx="0.06"/>
<rect x="3.45" y="15.200" width="1.85" height="1.8" rx="0.06"/>
<rect x="5.3" y="15.200" width="1.85" height="1.8" rx="0.06"/>
<rect x="7.15" y="15.200" width="1.85" height="1.8" rx="0.06"/>
<rect x="16.4" y="15.200" width="1.85" height="1.8" rx="0.06"/>
<rect x="18.25" y="15.200" width="1.85" height="1.8" rx="0.06"/>
<rect x="20.1" y="15.200" width="1.85" height="1.8" rx="0.06"/>
<rect x="21.95" y="15.200" width="1.85" height="1.8" rx="0.06"/>
</g>
<path d="M0 0H26.0V17.0H15.0M12.0 17.0H0Z" fill="none" stroke="#fff" stroke-opacity=".45" stroke-width="0.12"/>
<g opacity=".32"><g fill="none" stroke="#f2600c" stroke-width="0.06" stroke-linecap="round" stroke-linejoin="round">
<polyline points="14.00,8.20 14.00,9.50 9.50,14.00 8.70,14.00 8.40,14.30 2.00,14.30"/>
<polyline points="14.00,8.20 14.00,9.50 9.50,14.00 8.70,14.00 8.40,14.30 2.70,14.30 2.00,13.60"/>
<polyline points="14.00,8.20 14.00,9.50 9.50,14.00 8.70,14.00 8.40,14.30 2.70,14.30 2.30,13.90 2.30,12.00 2.00,11.70"/>
<polyline points="14.00,8.20 14.00,9.50 9.50,14.00 8.70,14.00 8.40,14.30 2.70,14.30 2.30,13.90 2.30,10.20 2.00,9.90"/>
<polyline points="14.00,8.20 14.00,9.50 9.50,14.00 8.70,14.00 8.40,14.30 2.70,14.30 2.30,13.90 2.30,8.30 2.00,8.00"/>
<polyline points="14.00,8.20 14.00,9.50 9.50,14.00 8.70,14.00 8.40,14.30 2.70,14.30 2.30,13.90 2.30,6.50 2.00,6.20"/>
<polyline points="14.00,8.20 14.00,9.50 9.50,14.00 8.70,14.00 8.40,14.30 2.70,14.30 2.30,13.90 2.30,13.50 2.20,13.40"/>
<polyline points="14.00,8.20 14.00,9.50 9.50,14.00 8.70,14.00 8.40,14.30 2.70,14.30 2.30,13.90 2.30,11.70 2.20,11.60"/>
<polyline points="14.00,8.20 14.00,9.50 9.50,14.00 8.70,14.00 8.40,14.30 2.70,14.30 2.30,13.90 2.30,9.80 2.20,9.70"/>
<polyline points="14.00,8.20 14.00,9.50 9.50,14.00 8.70,14.00 8.40,14.30 2.70,14.30 2.30,13.90 2.30,8.00 2.20,7.90"/>
<polyline points="14.00,8.20 14.00,9.50 9.50,14.00 8.70,14.00 8.40,14.30 2.70,14.30 2.30,13.90 2.30,6.10 2.20,6.00"/>
<polyline points="14.00,8.20 14.00,9.50 9.50,14.00 8.70,14.00 8.40,14.30 6.80,14.30 6.40,13.90 6.40,13.80 6.00,13.40"/>
<polyline points="14.00,8.20 14.00,9.50 9.50,14.00 8.70,14.00 8.40,14.30 6.80,14.30 6.40,13.90 6.40,12.30 6.30,12.20 6.30,11.90 6.00,11.60"/>
<polyline points="14.00,8.20 14.00,9.50 9.50,14.00 8.70,14.00 8.40,14.30 6.80,14.30 6.40,13.90 6.40,12.30 6.30,12.20 6.30,10.00 6.00,9.70"/>
<polyline points="14.00,8.20 14.00,9.50 9.50,14.00 8.70,14.00 8.40,14.30 6.80,14.30 6.40,13.90 6.40,12.30 6.30,12.20 6.30,8.20 6.00,7.90"/>
<polyline points="14.00,8.20 14.00,9.50 9.50,14.00 8.70,14.00 8.40,14.30 6.80,14.30 6.40,13.90 6.40,12.30 6.30,12.20 6.30,6.30 6.00,6.00"/>
<polyline points="14.00,8.20 14.00,9.50 9.50,14.00 8.70,14.00 8.40,14.30 6.80,14.30 6.40,13.90 6.40,13.50 6.30,13.40"/>
<polyline points="14.00,8.20 14.00,9.50 9.50,14.00 8.70,14.00 8.40,14.30 6.80,14.30 6.40,13.90 6.40,12.30 6.30,12.20 6.30,11.60"/>
<polyline points="14.00,8.20 14.00,9.50 9.50,14.00 8.70,14.00 8.40,14.30 6.80,14.30 6.40,13.90 6.40,12.30 6.30,12.20 6.30,9.70"/>
<polyline points="14.00,8.20 13.80,8.20 10.10,11.90"/>
<polyline points="14.00,8.20 12.00,8.20 10.10,10.10"/>
<polyline points="14.00,8.20 10.10,8.20"/>
<polyline points="14.00,8.20 15.70,8.20 19.00,11.50 19.00,12.50 20.50,14.00 21.30,14.00 21.60,14.30 24.00,14.30"/>
<polyline points="14.00,8.20 15.70,8.20 19.00,11.50 19.00,12.50 20.50,14.00 21.30,14.00 21.60,14.30 23.30,14.30 24.00,13.60"/>
<polyline points="14.00,8.20 15.70,8.20 19.00,11.50 19.00,12.50 20.50,14.00 21.30,14.00 21.60,14.30 23.20,14.30 23.60,13.90 23.60,12.10 24.00,11.70"/>
<polyline points="14.00,8.20 15.70,8.20 19.00,11.50 19.00,12.50 20.50,14.00 21.30,14.00 21.60,14.30 23.20,14.30 23.60,13.90 23.60,10.30 24.00,9.90"/>
<polyline points="14.00,8.20 19.20,8.20 19.40,8.00 19.40,5.10 20.50,4.00 21.40,4.00 21.60,3.80 23.30,3.80 23.60,4.10 23.60,6.10 24.00,6.50"/>
<polyline points="14.00,8.20 19.20,8.20 19.40,8.00 19.40,5.10 20.50,4.00 21.40,4.00 21.60,3.80 23.30,3.80 23.60,4.10 23.60,4.30 24.00,4.70"/>
<polyline points="14.00,8.20 16.30,8.20 20.00,11.90"/>
<polyline points="14.00,8.20 18.10,8.20 20.00,10.10"/>
<polyline points="14.00,8.20 19.30,8.20 20.00,8.90"/>
<polyline points="14.00,8.20 19.70,8.20 20.00,7.90"/>
<polyline points="14.00,8.20 19.20,8.20 19.40,8.00 19.40,6.60 20.00,6.00"/>
<polyline points="14.00,8.20 15.70,8.20 19.00,11.50 19.00,12.50 20.50,14.00 21.30,14.00 21.60,14.30 23.20,14.30 23.60,13.90 23.60,13.60 23.80,13.40"/>
<polyline points="14.00,8.20 15.70,8.20 19.00,11.50 19.00,12.50 20.50,14.00 21.30,14.00 21.60,14.30 23.20,14.30 23.60,13.90 23.60,11.80 23.80,11.60"/>
<polyline points="14.00,8.20 15.70,8.20 19.00,11.50 19.00,12.50 20.50,14.00 21.30,14.00 21.60,14.30 23.20,14.30 23.60,13.90 23.60,9.90 23.80,9.70"/>
<polyline points="14.00,8.20 19.20,8.20 19.40,8.00 19.40,5.10 20.50,4.00 21.40,4.00 21.60,3.80 23.30,3.80 23.60,4.10 23.60,6.20 23.80,6.40"/>
<polyline points="14.00,8.20 19.20,8.20 19.40,8.00 19.40,5.10 20.50,4.00 21.40,4.00 21.60,3.80 23.30,3.80 23.60,4.10 23.60,4.30 23.80,4.50"/>
<polyline points="14.00,8.20 14.00,9.50 9.50,14.00 8.70,14.00 8.40,14.30 3.40,14.30 3.30,14.40"/>
<polyline points="14.00,8.20 14.00,9.50 9.50,14.00 8.70,14.00 8.40,14.30 5.20,14.30 5.10,14.40"/>
<polyline points="14.00,8.20 14.00,9.50 9.50,14.00 8.70,14.00 8.40,14.30 7.10,14.30 7.00,14.40"/>
<polyline points="14.00,8.20 14.00,9.50 9.50,14.00 9.20,14.00 8.80,14.40"/>
<polyline points="14.00,8.20 14.00,12.30 14.60,12.90 15.10,12.90 16.60,14.40"/>
<polyline points="14.00,8.20 15.70,8.20 19.00,11.50 19.00,13.80 18.40,14.40"/>
<polyline points="14.00,8.20 15.70,8.20 19.00,11.50 19.00,13.10 20.30,14.40"/>
<polyline points="14.00,8.20 15.70,8.20 19.00,11.50 19.00,12.50 20.50,14.00 21.30,14.00 21.60,14.30 22.00,14.30 22.10,14.40"/>
</g><g fill="#f2600c">
<circle cx="1.99" cy="14.29" r="0.11"/>
<circle cx="1.99" cy="13.59" r="0.11"/>
<circle cx="1.99" cy="11.74" r="0.11"/>
<circle cx="1.99" cy="9.89" r="0.11"/>
<circle cx="1.99" cy="8.04" r="0.11"/>
<circle cx="1.99" cy="6.19" r="0.11"/>
<circle cx="2.21" cy="13.44" r="0.11"/>
<circle cx="2.21" cy="11.59" r="0.11"/>
<circle cx="2.21" cy="9.74" r="0.11"/>
<circle cx="2.21" cy="7.89" r="0.11"/>
<circle cx="2.21" cy="6.04" r="0.11"/>
<circle cx="6.04" cy="13.44" r="0.11"/>
<circle cx="6.04" cy="11.59" r="0.11"/>
<circle cx="6.04" cy="9.74" r="0.11"/>
<circle cx="6.04" cy="7.89" r="0.11"/>
<circle cx="6.04" cy="6.04" r="0.11"/>
<circle cx="6.26" cy="13.44" r="0.11"/>
<circle cx="6.26" cy="11.59" r="0.11"/>
<circle cx="6.26" cy="9.74" r="0.11"/>
<circle cx="10.09" cy="11.91" r="0.11"/>
<circle cx="10.09" cy="10.06" r="0.11"/>
<circle cx="10.09" cy="8.21" r="0.11"/>
<circle cx="24.01" cy="14.29" r="0.11"/>
<circle cx="24.01" cy="13.59" r="0.11"/>
<circle cx="24.01" cy="11.74" r="0.11"/>
<circle cx="24.01" cy="9.89" r="0.11"/>
<circle cx="24.01" cy="6.51" r="0.11"/>
<circle cx="24.01" cy="4.66" r="0.11"/>
<circle cx="19.96" cy="11.91" r="0.11"/>
<circle cx="19.96" cy="10.06" r="0.11"/>
<circle cx="19.96" cy="8.94" r="0.11"/>
<circle cx="19.96" cy="7.89" r="0.11"/>
<circle cx="19.96" cy="6.04" r="0.11"/>
<circle cx="23.79" cy="13.44" r="0.11"/>
<circle cx="23.79" cy="11.59" r="0.11"/>
<circle cx="23.79" cy="9.74" r="0.11"/>
<circle cx="23.79" cy="6.36" r="0.11"/>
<circle cx="23.79" cy="4.51" r="0.11"/>
<circle cx="3.29" cy="14.41" r="0.11"/>
<circle cx="5.14" cy="14.41" r="0.11"/>
<circle cx="6.99" cy="14.41" r="0.11"/>
<circle cx="8.84" cy="14.41" r="0.11"/>
<circle cx="16.56" cy="14.41" r="0.11"/>
<circle cx="18.41" cy="14.41" r="0.11"/>
<circle cx="20.26" cy="14.41" r="0.11"/>
<circle cx="22.11" cy="14.41" r="0.11"/>
</g></g><rect x="15.7" y="7.65" width="2.40" height="0.80" fill="#f2600c" fill-opacity=".45"/></svg>
```

### rec_session.py（試算のコードを実際に動かし、出力と時刻を記録する）

ターミナルの場面のもとです。haizen_sim.py と同じフォルダで動かしました。

```python
# 実際にコマンドを動かし、出力と時刻をそのまま記録する（あとで同じ時間で再生して録画する）
import json, os, pty, subprocess, time, select
cmds = ["sed -n 469,503p haizen_sim.py", "python3 haizen_sim.py --one", "python3 haizen_sim.py --main"]
events = []; t0 = time.time()
for c in cmds:
    events.append([round(time.time()-t0,3), "cmd", c])
    m, s = pty.openpty()
    p = subprocess.Popen(["bash","-c", "stty cols 96; " + c], stdout=s, stderr=s, stdin=s, cwd=os.getcwd(), env={**os.environ, "PYTHONUNBUFFERED":"1", "COLUMNS":"96"})
    os.close(s); limit = 26 if "--main" in c else 60
    while True:
        r,_,_ = select.select([m],[],[],0.2)
        if r:
            try: d = os.read(m, 4096)
            except OSError: break
            if not d: break
            events.append([round(time.time()-t0,3), "out", d.decode("utf-8","replace")])
        if p.poll() is not None and not r: break
        if time.time()-t0 > limit + events[-1][0]*0 and "--main" in c and time.time()-t0 > 70: p.terminate(); break
    p.wait()
json.dump(events, open("session.json","w"), ensure_ascii=False)
print(len(events), events[-1][0])
```

### session.json（記録した出力と時刻）

1つ目の数が記録を始めてからの秒、2つ目が cmd（打ったコマンド）か out（出てきた文字）です。

```json
[[0.0, "cmd", "sed -n 469,503p haizen_sim.py"], [0.009, "out", "def run_once(params, seed):\r\n    store = STORES[params[\"store\"]]\r\n    p = {**COMMON, **store, **params}\r\n    env = simpy.Environment()\r\n    s = Store(env, random.Random(seed), p)\r\n    env.process(s.arrivals())\r\n    if p[\"sustain\"]:\r\n        return _sustain_result(env, s, p)\r\n    env.run()\r\n    end = env.now\r\n    w = s.dish_waits\r\n    return {\r\n        \"dish_wait_mean\": statistics.mean(w),\r\n        \"dish_wait_p90\": sorted(w)[int(0.9 * (len(w) - 1))],\r\n        \"cold_share\": sum(x > p[\"cold_min\"] for x in w) / len(w),\r\n        \"seat_wait_mean\": statistics.mean(s.seat_waits),\r\n        \"turn_mean\": statistics.mean(s.turns) if s.turns else 0.0,\r\n        \"walk_km_per_staff\": s.staff_walk_m / p[\"staff\"] / 1000,\r\n        \"staff_util\": s.staff_busy_s / (p[\"staff\"] * end),\r\n        \"robot_util\": (s.robot_busy_s / (p[\"robots\"] * end)) if p[\"robots\"] else 0.0,\r\n        \"robot_share\": s.robot_trips / max(s.robot_trips + s.staff_trips, 1),\r\n        \"robot_bus_share\": s.robot_bus / max(s.robot_bus + s.staff_bus, 1),\r\n"], [0.009, "out", "        \"collects\": s.collects,\r\n        \"rescues\": s.rescues,\r\n        \"pauses\": s.pauses,\r\n        \"fragile_robot\": s.fragile_robot,\r\n        \"groups\": s.done,\r\n        # 入店待ちの内訳（v2.2）\r\n        \"entry_wait_mean\": statistics.mean(s.entry_waits),            # 来てから案内されて席に着くまで\r\n        \"blocked_wait_mean\": statistics.mean(s.blocked_waits),        # 席はあるのに片付けが終わらず待った\r\n        \"guide_wait_mean\": statistics.mean(s.guide_waits),            # 卓は決まったが案内の手が空かず待った\r\n        \"staff_wait_mean\": statistics.mean(b + g for b, g in zip(s.blocked_waits, s.guide_waits)),  # 席はあるのに待った（合計）\r\n        \"full_wait_mean\": statistics.mean(w - b for w, b in zip(s.seat_waits, s.blocked_waits)),    # 席が埋まっていて待った\r\n    }\r\n\r\n"], [0.009, "cmd", "python3 haizen_sim.py --one"], [0.844, "out", "serve 1 any {'dish_wait_mean': 0.61, 'dish_wait_p90': 1.05, 'cold_share': 0.0, 'seat_wait_mean': 2.59, 'turn_mean': 9.49, 'walk_km_per_staff': 2.7, 'staff_util': 0.75, 'robot_util': 0.49, 'robot_share': 0.76, 'robot_bus_share': 0.0, 'rescues': 1.07, 'pauses': 38.77, 'fragile_robot': 26.88, 'groups': 68.15, 'entry_wait_mean': 4.26, 'blocked_wait_mean': 2.32, 'guide_wait_mean': 0.45, 'staff_wait_mean': 2.77, 'full_wait_mean': 0.28}\r\n"], [1.571, "out", "serve 1 staff {'dish_wait_mean': 0.64, 'dish_wait_p90': 1.15, 'cold_share': 0.0, 'seat_wait_mean': 4.23, 'turn_mean': 11.29, 'walk_km_per_staff': 2.95, 'staff_util': 0.77, 'robot_util': 0.42, 'robot_share': 0.65, 'robot_bus_share': 0.0, 'rescues': 1.12, 'pauses': 35.08, 'groups': 69.9, 'entry_wait_mean': 5.93, 'blocked_wait_mean': 3.7, 'guide_wait_mean': 0.48, 'staff_wait_mean': 4.17, 'full_wait_mean': 0.54}\r\n"], [2.356, "out", "serve 3 any {'dish_wait_mean': 0.64, 'dish_wait_p90': 1.15, 'cold_share': 0.0, 'seat_wait_mean': 3.43, 'turn_mean': 9.29, 'walk_km_per_staff': 2.74, 'staff_util': 0.76, 'robot_util': 0.51, 'robot_share': 0.8, 'robot_bus_share': 0.0, 'rescues': 1.02, 'pauses': 37.98, 'fragile_robot': 30.23, 'groups': 70.5, 'entry_wait_mean': 5.11, 'blocked_wait_mean': 2.75, 'guide_wait_mean': 0.46, 'staff_wait_mean': 3.21, 'full_wait_mean': 0.68}\r\n"], [3.096, "out", "serve 3 staff {'dish_wait_mean': 0.64, 'dish_wait_p90': 1.18, 'cold_share': 0.0, 'seat_wait_mean': 4.37, 'turn_mean': 10.56, 'walk_km_per_staff': 2.86, 'staff_util': 0.76, 'robot_util': 0.42, 'robot_share': 0.66, 'robot_bus_share': 0.0, 'rescues': 0.93, 'pauses': 32.77, 'groups': 68.72, 'entry_wait_mean': 6.03, 'blocked_wait_mean': 3.79, 'guide_wait_mean': 0.45, 'staff_wait_mean': 4.24, 'full_wait_mean': 0.58}\r\n"], [3.901, "out", "bus 1 any {'dish_wait_mean': 0.66, 'dish_wait_p90': 1.19, 'cold_share': 0.0, 'seat_wait_mean': 4.27, 'turn_mean': 10.11, 'walk_km_per_staff': 2.54, 'staff_util': 0.75, 'robot_util': 0.6, 'robot_share': 0.72, 'robot_bus_share': 0.48, 'rescues': 1.5, 'fragile_robot': 26.2, 'groups': 70.5, 'entry_wait_mean': 5.95, 'blocked_wait_mean': 3.63, 'guide_wait_mean': 0.46, 'staff_wait_mean': 4.09, 'full_wait_mean': 0.65}\r\n"], [4.654, "out", "bus 1 staff {'dish_wait_mean': 0.67, 'dish_wait_p90': 1.23, 'cold_share': 0.0, 'seat_wait_mean': 3.75, 'turn_mean': 11.42, 'walk_km_per_staff': 2.6, 'staff_util': 0.75, 'robot_util': 0.55, 'robot_share': 0.61, 'robot_bus_share': 0.57, 'rescues': 1.43, 'pauses': 44.1, 'groups': 68.7, 'entry_wait_mean': 5.44, 'blocked_wait_mean': 3.56, 'guide_wait_mean': 0.47, 'staff_wait_mean': 4.04, 'full_wait_mean': 0.18}\r\n"], [5.42, "out", "bus 3 any {'dish_wait_mean': 0.66, 'dish_wait_p90': 1.22, 'cold_share': 0.0, 'seat_wait_mean': 3.39, 'turn_mean': 8.82, 'walk_km_per_staff': 2.41, 'staff_util': 0.73, 'robot_util': 0.6, 'robot_share': 0.76, 'robot_bus_share': 0.49, 'rescues': 1.48, 'pauses': 44.25, 'fragile_robot': 26.48, 'groups': 68.5, 'entry_wait_mean': 5.05, 'blocked_wait_mean': 2.98, 'guide_wait_mean': 0.44, 'staff_wait_mean': 3.41, 'full_wait_mean': 0.41}\r\n"], [6.184, "out", "bus 3 staff {'dish_wait_mean': 0.68, 'dish_wait_p90': 1.31, 'cold_share': 0.0, 'seat_wait_mean': 4.28, 'turn_mean': 10.72, 'walk_km_per_staff': 2.6, 'staff_util': 0.76, 'robot_util': 0.55, 'robot_share': 0.63, 'robot_bus_share': 0.58, 'rescues': 1.6, 'pauses': 41.88, 'groups': 69.75, 'entry_wait_mean': 5.97, 'blocked_wait_mean': 3.87, 'guide_wait_mean': 0.47, 'staff_wait_mean': 4.33, 'full_wait_mean': 0.41}\r\n"], [7.166, "out", "patrol 1 any {'dish_wait_mean': 0.62, 'dish_wait_p90': 1.11, 'cold_share': 0.0, 'seat_wait_mean': 2.19, 'turn_mean': 6.24, 'walk_km_per_staff': 2.56, 'staff_util': 0.7, 'robot_util': 0.76, 'robot_share': 0.47, 'robot_bus_share': 0.0, 'collects': 122.17, 'rescues': 2.05, 'pauses': 61.85, 'fragile_robot': 15.82, 'groups': 69.83, 'entry_wait_mean': 3.73, 'blocked_wait_mean': 1.83, 'guide_wait_mean': 0.33, 'staff_wait_mean': 2.16, 'full_wait_mean': 0.36}\r\n"], [8.139, "out", "patrol 1 staff "], [8.139, "out", "{'dish_wait_mean': 0.66, 'dish_wait_p90': 1.2, 'cold_share': 0.0, 'seat_wait_mean': 2.46, 'turn_mean': 7.11, 'walk_km_per_staff': 2.71, 'staff_util': 0.72, 'robot_util': 0.74, 'robot_share': 0.41, 'robot_bus_share': 0.0, 'collects': 128.12, 'rescues': 2.17, 'pauses': 60.58, 'groups': 70.53, 'entry_wait_mean': 4.07, 'blocked_wait_mean': 2.24, 'guide_wait_mean': 0.39, 'staff_wait_mean': 2.63, 'full_wait_mean': 0.22}\r\n"], [9.055, "out", "patrol 3 any {'dish_wait_mean': 0.64, 'dish_wait_p90': 1.23, 'cold_share': 0.0, 'seat_wait_mean': 0.95, 'turn_mean': 4.61, 'walk_km_per_staff': 2.38, 'staff_util': 0.68, 'robot_util': 0.75, 'robot_share': 0.51, 'robot_bus_share': 0.0, 'collects': 120.65, 'rescues': 1.98, 'pauses': 56.58, 'fragile_robot': 15.85, 'groups': 66.92, 'entry_wait_mean': 2.47, 'blocked_wait_mean': 0.8, 'guide_wait_mean': 0.3, 'staff_wait_mean': 1.11, 'full_wait_mean': 0.15}\r\n"], [10.124, "out", "patrol 3 staff {'dish_wait_mean': 0.66, 'dish_wait_p90': 1.24, 'cold_share': 0.0, 'seat_wait_mean': 2.1, 'turn_mean': 6.42, 'walk_km_per_staff': 2.61, 'staff_util': 0.7, 'robot_util': 0.74, 'robot_share': 0.44, 'robot_bus_share': 0.0, 'collects': 128.82, 'rescues': 1.98, 'pauses': 57.58, 'entry_wait_mean': 3.66, 'blocked_wait_mean': 1.7, 'guide_wait_mean': 0.34, 'staff_wait_mean': 2.04, 'full_wait_mean': 0.4}\r\n"], [10.157, "cmd", "python3 haizen_sim.py --main"], [10.319, "out", "1665 conditions, 0 reused, 1665 to run\r\n"], [34.511, "out", "  100/1665\r\n"], [57.975, "out", "  200/1665"], [57.979, "out", "\r\n"]]
```

### render.py（記録した時刻どおりにターミナルへ書き、1コマずつ撮る）

15コマ/秒で撮りました。撮ったコマを mp4 にした ffmpeg のコマンドは残していません。

```python
# 記録した実際の出力を、記録した時刻どおりにターミナルへ書き、1コマずつ撮る
import json, os, subprocess
from playwright.sync_api import sync_playwright
ev=json.load(open('session.json'))
PROMPT='\x1b[38;5;114m~/service_sim\x1b[0m $ '
# 1本目（コードを表示）は最初から出ている状態にし、2本目（--one）を打って走らせる所から録る
pre=[e for e in ev if e[0]<1 ]
one_start=[e for e in ev if e[1]=='cmd' and '--one' in e[2]][0][0]
main_start=[e for e in ev if e[1]=='cmd' and '--main' in e[2]][0][0]
seq=[]  # (時刻, 書く文字)
t=0.0
seq.append((0,PROMPT+'sed -n 469,503p haizen_sim.py\r\n'))
for e in ev:
    if e[0]<one_start and e[1]=='out': seq.append((0,e[2]))
seq.append((0.2,PROMPT))
cmd='python3 haizen_sim.py --one'
for i,ch in enumerate(cmd): seq.append((0.6+i*0.045,ch))
T0=0.6+len(cmd)*0.045+0.3
seq.append((T0,'\r\n'))
for e in ev:
    if one_start<=e[0]<main_start and e[1]=='out': seq.append((T0+e[0]-one_start,e[2]))
END=T0+(main_start-one_start)
seq.append((END+0.2,PROMPT))
cmd2='python3 haizen_sim.py --main'
for i,ch in enumerate(cmd2): seq.append((END+0.6+i*0.045,ch))
T1=END+0.6+len(cmd2)*0.045+0.3
seq.append((T1,'\r\n'))
for e in ev:
    if e[0]>=main_start and e[1]=='out' and e[0]-main_start<1: seq.append((T1+e[0]-main_start,e[2]))
DUR=T1+3.0
FPS=15
os.makedirs('frames',exist_ok=True)
for f in os.listdir('frames'): os.remove('frames/'+f)
with sync_playwright() as p:
    b=p.chromium.launch(); pg=b.new_page(viewport={"width":960,"height":880},device_scale_factor=2)
    pg.goto('file://'+os.path.abspath('term.html')); pg.wait_for_timeout(800)
    i=0; k=0; n=int(DUR*FPS)
    for fr in range(n):
        tt=fr/FPS; chunk=''
        while k<len(seq) and seq[k][0]<=tt: chunk+=seq[k][1]; k+=1
        if chunk: pg.evaluate("s=>window.write(s)",chunk)
        pg.screenshot(path=f'frames/{fr:05d}.png')
    b.close()
print(DUR, n)
```

### term.html（ブラウザの中のターミナル）

```html
<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="node_modules/@xterm/xterm/css/xterm.css">
<style>html,body{margin:0;background:#0d0f12;height:100%}
#bar{height:34px;background:#1c1f25;display:flex;align-items:center;gap:8px;padding:0 14px;font:13px "DejaVu Sans Mono",monospace;color:#9da3ad}
#bar i{width:12px;height:12px;border-radius:50%;display:inline-block}
#t{position:absolute;top:34px;left:0;right:0;bottom:0;padding:14px 18px}</style></head>
<body><div id="bar"><i style="background:#ff5f57"></i><i style="background:#febc2e"></i><i style="background:#28c840"></i><span style="margin-left:10px">service_sim — python3 haizen_sim.py</span></div><div id="t"></div>
<script src="node_modules/@xterm/xterm/lib/xterm.js"></script>
<script>
const term=new Terminal({cols:92,rows:38,fontFamily:'"DejaVu Sans Mono","Noto Sans Mono CJK JP",monospace',fontSize:15,lineHeight:1.15,theme:{background:'#0d0f12',foreground:'#d8dce3',cursor:'#d8dce3'},cursorBlink:false});
term.open(document.getElementById('t'));
window.term=term;
window.write=(s)=>new Promise(r=>term.write(s,r));
</script></body></html>
```

### package.json（xterm.js の版）

```json
{
  "name": "xt",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@xterm/xterm": "^5.5.0"
  }
}
```

### haizen_sim.py（ターミナルの場面で動かした試算のコード）

[LOG359](https://pixelstomotors.com/record/audit-simulator-before-unity) の試算のコードを、ターミナルの場面のために動かしたときの全文です。

```python
"""
サービスロボット導入シミュレーション #1 配膳ロボット（試作 v2.2）

飲食店のピーク時間を SimPy で再現する。

入店待ちの内訳と「店が回らない」（v2.2）
  入店待ちは、店に来てから案内されて席に着くまで（entry_wait_mean）。
  そのうち、満席で待った時間（full_wait_mean）と、席は空いているのに片付け（下げ膳・卓拭き）や
  案内が終わらず待った時間（staff_wait_mean）に分ける。staff_wait_mean が NG_MIN 分以上なら「店が回らない」。
  片付け待ちは、客が帰ってから下げ膳が終わるまでの卓の数だけ、入口の列の前から数えた組が
  「席はあるのに待っている」とみなして数える。乱数の使い方は変えていないので、ほかの数字は v2 と同じ。

時間の区切り方（v2.1）
  ページに出す数字（ピークの回し方）：空の店に peak_min 分だけ選んだペースで来客し、全員が帰るまで回す。
    ランチ・ディナーのように、混む時間は限られている前提。v2 と同じで、数字も v2 と同じになる。
  回るかの確かめ（--sustain。v2.2 のページでは使わない）：空の店から始め、来客は選んだペースのまま止めない。
    最初の warmup_min 分は数えず、そのあとの peak_min 分に来た組を、卓が空くまで追いかける。
    その時間のスタッフの手がふさがっている割合（staff_util）を出し、STAFF_FULL 以上なら「店が回らない」とする。
    入店待ちの延び方（seat_wait_trend）は、卓の数を超える客が来ると人手に関係なく延びるので、判定には使わない
    （混雑は多くの店で卓の数を超えている。そのときの行列は、ピーク2時間の入店待ちの数字に出ている）。
店のタイプ（5種）、ロボットの使い方（3種）、1往復で回る卓の数（段の数）、
倒れやすい物（デザート・ドリンク）を誰が運ぶか、を切り替えて比べられる。
数字はすべて仮置き。公開事例・メーカー仕様・観察で順に置き換える。

SimPy が扱うのは「店の図面がなくても答えられる問い」（時間・順番・数）だけ。
通路での鉢合わせ・接触・揺れ・こぼれは Unity で扱い、その結果を
「一時停止」「立ち往生」の数字や、ロボットに載せる皿の決まりとしてここに入れる。

ロボットの使い方
  serve   : 上げ膳だけ。下げ膳は人がやる（ガスト型）
  bus     : 下げ膳にも使う。皿はスタッフがロボットに載せる（サイゼリヤ・ブロンコビリー型）
  patrol  : 食事中に席を回り、客が空いた皿を載せる（焼肉の和民・かみむら牧場型）

使い方:
    pip install simpy
    python haizen_sim.py            # 総当たりを回して results.json を書く
    python haizen_sim.py --main     # よく使う人数・台数（0〜3台）だけ先に回す
    途中経過は results_partial.json に保存され、止まっても続きから回る
    python haizen_sim.py --one      # 1条件だけ回して結果を表示

    python haizen_sim.py --sustain  # 回るかの確かめを総当たりで回して results_sustain.json を書く

依存: Python 3.10 以降 / simpy 4.1
"""
import json
import random
import statistics
import sys
from itertools import product
from multiprocessing import Pool

import simpy

# ---------------------------------------------------------------
# 共通の前提（すべて仮）
# ---------------------------------------------------------------
COMMON = {
    "staff_speed": 1.0,       # スタッフが歩く速さ（m/秒）
    "robot_speed": 0.5,       # ロボットが店内を走る速さ（m/秒）
    "guide_s": 45,            # 案内（歩く時間は別）
    "staff_place_s": 10,      # スタッフが皿を置く
    "robot_handoff_s": 25,    # 客がロボットから皿を取る
    "pay_s": 90,              # 会計
    "call_s": 30,             # 呼び出し対応（歩く時間は別）
    "clear_s": 40,            # テーブルの皿をまとめる
    "wipe_s": 40,             # 卓を拭いてセットする
    "load_s": 15,             # まとめた皿をロボットに載せる
    "guest_load_s": 20,       # 客が空いた皿をロボットに載せる（巡回回収）
    "pause_rate": 0.3,        # 一時停止：1往復で、人が前を横切って止まる確率（自然に解ける）
    "pause_s": (3, 10),       # 一時停止の長さ（秒）
    "stuck_rate": 0.01,       # 立ち往生：1往復で、スタッフが動かしに行かないと解けない確率
    "rescue_s": 15,           # スタッフがロボットを動かす作業
    "hop_m": 3,               # 1往復で複数卓を回るとき、卓から次の卓への寄り道（m）
    "fragile_cook": (2, 4, 7),  # デザート・ドリンクができるまで（分）
    "cold_min": 3,            # これを超えて届いた皿を「待たせた皿」とする
    "dish_extra_m": 0,        # 洗い場までの追加距離（店のタイプで上書き）
    "sustain": False,         # True：回るかの確かめ（来客を止めない回し方）
    "warmup_min": 60,         # 回るかの確かめで、数えない慣らしの時間（分）
    "util_sample_s": 10,      # 手がふさがっている人数・台数を数える間隔（秒）
    "max_extra_min": 600,     # 数える時間のあと、追いかけるのを打ち切る上限（分）。店が回らない条件の保険
}

# ---------------------------------------------------------------
# 店のタイプ（すべて仮）
#   dist_m        : 厨房（洗い場）から卓までの距離の幅
#   first_dishes  : 最初の注文の皿。料理ができるまで（最短, 最頻, 最長 分）
#   extra_orders  : 食事中の追加注文の回数（食べ放題）
#   bus_trips     : 下げ膳を人だけでやるときの往復回数（皿の量）
#   dish_extra_m  : 洗い場が厨房の受け渡し口よりさらに奥にあるときの追加距離（ホテル小倉の事例）
#   fragile       : 1組あたりの倒れやすい物（デザート・パフェ・ドリンク）の数の平均
#   staff         : 最初に見せる人数
#   staff_all     : 「その他」で選べる人数（総当たりはこちら）
#   arrivals      : 来客ペース（空いている・普通・混雑）
# ---------------------------------------------------------------
STORES = {
    "small": {
        "label": "小型店", "tables": 18, "dist_m": (4, 12), "peak_min": 120,
        "first_dishes": [(4, 7, 11), (6, 9, 14)], "extra_orders": 0, "extra_cook": (3, 5, 8),
        "eat_min": (15, 25, 40), "call_rate": 0.4, "bus_trips": 1, "fragile": 0.3,
        "staff": [1, 2, 3], "staff_all": [1, 2, 3, 4, 5], "arrivals": [15, 22, 30],
    },
    "family": {
        "label": "標準ファミレス", "tables": 35, "dist_m": (6, 22), "peak_min": 120,
        "first_dishes": [(5, 8, 12), (8, 12, 18)], "extra_orders": 0, "extra_cook": (3, 5, 8),
        "eat_min": (20, 30, 45), "call_rate": 0.6, "bus_trips": 2, "fragile": 0.5,
        "staff": [2, 3, 4], "staff_all": [1, 2, 3, 4, 5, 6, 7], "arrivals": [25, 35, 45],
    },
    "large": {
        "label": "大型店", "tables": 60, "dist_m": (8, 35), "peak_min": 120,
        "first_dishes": [(5, 8, 12), (8, 12, 18)], "extra_orders": 0, "extra_cook": (3, 5, 8),
        "eat_min": (20, 30, 45), "call_rate": 0.6, "bus_trips": 2, "fragile": 0.5,
        "staff": [4, 5, 6], "staff_all": [2, 3, 4, 5, 6, 7, 8, 9, 10], "arrivals": [45, 60, 75],
    },
    "hotel": {
        "label": "洗い場が遠い店", "tables": 30, "dist_m": (6, 20), "dish_extra_m": 30, "peak_min": 120,
        "first_dishes": [(5, 8, 12), (8, 12, 18)], "extra_orders": 2, "extra_cook": (4, 6, 10),
        "eat_min": (30, 45, 60), "call_rate": 0.6, "bus_trips": 2, "fragile": 0.6,
        "staff": [2, 3, 4], "staff_all": [1, 2, 3, 4, 5, 6, 7], "arrivals": [16, 22, 28],
    },
    "buffet": {
        "label": "焼肉食べ放題", "tables": 35, "dist_m": (6, 22), "peak_min": 150,
        "first_dishes": [(2, 4, 8), (3, 5, 9), (4, 7, 12)], "extra_orders": 5, "extra_cook": (2, 4, 7),
        "eat_min": (60, 80, 90), "call_rate": 0.8, "bus_trips": 3, "fragile": 1.5,
        "staff": [2, 3, 4], "staff_all": [1, 2, 3, 4, 5, 6, 7], "arrivals": [14, 20, 26],
    },
}

ROBOTS = [0, 1, 2, 3]          # 最初に見せる台数
ROBOTS_ALL = list(range(7))    # 「その他」で選べる台数（総当たりはこちら）
MODES = ["serve", "bus", "patrol"]
TRAYS = [1, 3]                 # 1往復で回る卓の数（1卓ずつ／最大3卓）
FRAGILE_BY = ["any", "staff"]  # 倒れやすい物を ロボットも運ぶ／人だけが運ぶ
REPS = 40


def tri(rng, lmh):
    lo, mode, hi = lmh
    return rng.triangular(lo, hi, mode)


class Store:
    def __init__(self, env, rng, p):
        self.env, self.rng, self.p = env, rng, p
        self.tables = simpy.Resource(env, capacity=p["tables"])
        self.staff = simpy.PriorityResource(env, capacity=p["staff"])
        self.robots = simpy.PriorityResource(env, capacity=p["robots"]) if p["robots"] else None
        self.dish_waits, self.seat_waits, self.turns = [], [], []
        self.staff_walk_m = self.staff_busy_s = self.robot_busy_s = 0.0
        self.robot_trips = self.staff_trips = self.rescues = self.collects = self.done = 0
        self.robot_bus = self.staff_bus = 0
        self.fragile_robot = self.pauses = 0
        self.pass_q = []  # 受け渡し口でロボットを待っている料理
        # 回るかの確かめ（sustain）で数える時間。ピークの回し方では全部の組を数える
        self.t0 = p["warmup_min"] * 60 if p["sustain"] else 0
        self.t1 = self.t0 + p["peak_min"] * 60
        self.counted = self.counted_done = 0
        self.window_closed = False
        self.finished = env.event()
        self.capped = False
        self.seat_wait_by_arrival = []  # (来た時刻, 入店待ち 分)。待ちが伸び続けるかを見る
        self.staff_busy_n = []          # 数える時間のあいだ、一定間隔で数えた手がふさがっている人数
        self.robot_busy_n = []
        self.snap0 = self.snap1 = None
        # 入店待ちの内訳（v2.2）
        #   片付けが終わらず使えない卓（客は帰ったが下げ膳・卓拭きが済んでいない）の数
        #   入口の列の前から数えて、その数までの組は「席はあるのに待っている」とみなす
        self.dirty = 0
        self._acc_t = 0.0
        self.blocked = {}               # 卓の順番待ちの request → 片付け待ちで待った秒数
        self.blocked_waits = []         # 組ごと：席はあるのに片付けが終わらず待った時間（分）
        self.guide_waits = []           # 組ごと：卓が決まってから、案内のスタッフの手が空くまで（分）
        self.entry_waits = []           # 組ごと：店に来てから、案内されて席に着くまで（分）

    def _acc(self):
        """前回からの時間を、列の前から片付け待ちの卓の数までの組に足す（列や卓の数が変わる直前に呼ぶ）"""
        now = self.env.now
        dt = now - self._acc_t
        if dt > 0 and self.dirty > 0:
            for req in self.tables.queue[:self.dirty]:
                self.blocked[req] = self.blocked.get(req, 0.0) + dt
        self._acc_t = now

    SNAP = ("staff_walk_m", "robot_trips", "staff_trips", "rescues", "collects",
            "robot_bus", "staff_bus", "fragile_robot", "pauses")

    def _snap(self):
        return {k: getattr(self, k) for k in self.SNAP}

    def window(self):
        """数える時間の始めと終わりに数を控え、そのあいだは手がふさがっている人数・台数を数える"""
        env, p = self.env, self.p
        yield env.timeout(self.t0)
        self.snap0 = self._snap()
        while env.now < self.t1:
            self.staff_busy_n.append(self.staff.count)
            if self.robots is not None:
                self.robot_busy_n.append(self.robots.count)
            yield env.timeout(p["util_sample_s"])
        self.snap1 = self._snap()
        self.window_closed = True
        self._check_finished()
        yield env.timeout(p["max_extra_min"] * 60)
        if not self.finished.triggered:   # 店が回らず、数える組が出きらない
            self.capped = True
            self.finished.succeed()

    def _check_finished(self):
        if self.window_closed and self.counted_done >= self.counted and not self.finished.triggered:
            self.finished.succeed()

    # ---- スタッフの仕事（priority 0=ロボット救出, 1=配膳・案内・会計, 2=下げ膳）
    def staff_job(self, priority, walk_m, work_s, waited=None):
        t0 = self.env.now
        with self.staff.request(priority=priority) as req:
            yield req
            if waited is not None:
                waited.append(self.env.now - t0)   # 手が空くのを待った時間（案内待ち）
            t = walk_m / self.p["staff_speed"] + work_s
            self.staff_walk_m += walk_m
            self.staff_busy_s += t
            yield self.env.timeout(t)

    # ---- ロボットが途中で止まる（1往復に1回判定）
    def maybe_stop(self, d, helper_waiting=False):
        p, rng = self.p, self.rng
        if rng.random() < p["pause_rate"]:      # 一時停止：数秒で自然に解ける
            self.pauses += 1
            yield self.env.timeout(rng.uniform(*p["pause_s"]))
        if rng.random() < p["stuck_rate"]:      # 立ち往生：スタッフが動かしに行く
            self.rescues += 1
            if helper_waiting:                  # 卓でロボットを待っているスタッフがそのまま動かす
                yield self.env.timeout(d / p["staff_speed"] + p["rescue_s"])
            else:
                yield self.env.process(self.staff_job(0, d, p["rescue_s"]))

    # ---- 配膳
    # 料理ができたら、ロボットとスタッフのうち先に空いた方が運ぶ（同時ならロボット）。
    # ロボットが出るときは、受け渡し口で待っている料理を段の数までまとめて載せ、近い卓から回る。
    def deliver(self, d, fragile=False, counted=True):
        env, p = self.env, self.p
        rec = {"d": d, "ready": env.now, "fragile": fragile, "counted": counted,
               "taken": env.event(), "arrived": env.event()}
        robot_ok = self.robots is not None and not (fragile and p["fragile_by"] == "staff")
        sreq = self.staff.request(priority=1)
        if not robot_ok:
            yield sreq
            yield env.process(self._staff_carry(rec, sreq))
            return
        rreq = self.robots.request(priority=0)
        rec["sreq"], rec["rreq"] = sreq, rreq
        self.pass_q.append(rec)
        yield rreq | sreq | rec["taken"]
        if rec["taken"].triggered:          # ほかの料理と一緒にロボットに載った
            yield rec["arrived"]
            return
        self.pass_q.remove(rec)
        if rreq.triggered:                  # ロボットが運ぶ
            self._drop(sreq, self.staff)
            batch = [rec]
            for o in list(self.pass_q):
                if len(batch) >= p["trays"]:
                    break
                self.pass_q.remove(o)
                self._drop(o["rreq"], self.robots)
                self._drop(o["sreq"], self.staff)
                o["taken"].succeed()
                batch.append(o)
            env.process(self._robot_run(batch, rreq))
            yield rec["arrived"]
            return
        rreq.cancel()                       # スタッフが運ぶ
        yield env.process(self._staff_carry(rec, sreq))

    @staticmethod
    def _drop(req, res):
        if req.triggered:
            res.release(req)
        else:
            req.cancel()

    def _robot_run(self, batch, rreq):
        env, p = self.env, self.p
        start = env.now
        batch.sort(key=lambda r: r["d"])
        pos = 0.0
        for i, rec in enumerate(batch):
            leg = rec["d"] - pos + (p["hop_m"] if i else 0)
            yield env.timeout(max(leg, 0) / p["robot_speed"])
            if i == 0:
                yield env.process(self.maybe_stop(rec["d"]))
            if rec["counted"]:
                self.dish_waits.append((env.now - rec["ready"]) / 60)
            self.robot_trips += 1
            self.fragile_robot += rec["fragile"]
            rec["arrived"].succeed()
            yield env.timeout(p["robot_handoff_s"])
            pos = rec["d"]
        yield env.timeout(pos / p["robot_speed"])
        self.robot_busy_s += env.now - start
        self.robots.release(rreq)

    def _staff_carry(self, rec, sreq):
        env, p = self.env, self.p
        self.staff_trips += 1
        go = rec["d"] / p["staff_speed"]
        yield env.timeout(go)
        if rec["counted"]:
            self.dish_waits.append((env.now - rec["ready"]) / 60)
        rec["arrived"].succeed()
        yield env.timeout(p["staff_place_s"] + go)
        self.staff_walk_m += 2 * rec["d"]
        self.staff_busy_s += 2 * go + p["staff_place_s"]
        self.staff.release(sreq)

    # ---- 巡回回収（食事中にロボットが来て、客が空いた皿を載せる）
    def collect_visit(self, d, deadline, box):
        env, p = self.env, self.p
        req = self.robots.request(priority=2)
        timer = env.timeout(max(deadline - env.now, 0))
        res = yield req | timer
        if req not in res:
            req.cancel()
            return
        start = env.now
        t = d / p["robot_speed"]
        back = (d + p["dish_extra_m"]) / p["robot_speed"]
        yield env.timeout(t)
        yield env.process(self.maybe_stop(d))
        yield env.timeout(p["guest_load_s"] + back)
        self.robot_busy_s += env.now - start
        self.robots.release(req)
        self.collects += 1
        box[0] += 1

    # ---- 下げ膳（客が帰ってから卓が使えるようになるまで）
    def bus(self, d, collected, counted=True):
        env, p = self.env, self.p
        start = env.now
        trips = max(p["bus_trips"] - collected, 0)
        if p["mode"] == "bus" and self.robots is not None and trips > 0:
            rreq = self.robots.request(priority=1)
            if rreq.triggered:  # 空いているロボットがいれば呼ぶ
                self.robot_bus += 1
                arrive = env.event()
                env.process(self._robot_bus_trip(d, rreq, arrive))
                # スタッフは卓まで行き、皿をまとめ、ロボットが来たら載せ、卓を拭いて戻る
                with self.staff.request(priority=2) as sreq:
                    yield sreq
                    s0 = env.now
                    yield env.timeout(d / p["staff_speed"] + p["clear_s"])
                    yield arrive
                    yield env.timeout(p["load_s"] + p["wipe_s"])
                    loaded_at = env.now
                    yield env.timeout(d / p["staff_speed"])
                    self.staff_walk_m += 2 * d
                    self.staff_busy_s += env.now - s0
                arrive.loaded = loaded_at
                if counted:
                    self.turns.append((loaded_at - start) / 60)
                return
            rreq.cancel()
        # 人だけで下げる（皿の量に応じて往復）。皿がもうなければ拭きに行くだけ
        self.staff_bus += 1
        walk = 2 * (d + p["dish_extra_m"]) * trips if trips > 0 else 2 * d
        work = (p["clear_s"] if trips > 0 else 0) + p["wipe_s"]
        yield env.process(self.staff_job(2, walk, work))
        if counted:
            self.turns.append((env.now - start) / 60)

    def _robot_bus_trip(self, d, rreq, arrive):
        env, p = self.env, self.p
        start = env.now
        t = d / p["robot_speed"]
        yield env.timeout(t)
        yield env.process(self.maybe_stop(d, helper_waiting=True))
        arrive.succeed()
        back = (d + p["dish_extra_m"]) / p["robot_speed"]
        yield env.timeout(p["load_s"] + back + 15)  # 洗い場で降ろすのは厨房側
        self.robot_busy_s += env.now - start
        self.robots.release(rreq)

    # ---- 1組の流れ
    def group(self):
        env, p, rng = self.env, self.p, self.rng
        arrive = env.now
        counted = (self.t0 <= arrive < self.t1) if p["sustain"] else True   # 数える時間に来た組だけを記録する
        if counted:
            self.counted += 1
        d = rng.uniform(*p["dist_m"])
        self._acc()
        table = self.tables.request()
        yield table
        blocked = self.blocked.pop(table, 0.0)
        if counted:
            w = (env.now - arrive) / 60
            self.seat_waits.append(w)
            self.seat_wait_by_arrival.append((arrive, w))
        gw = []
        yield env.process(self.staff_job(1, 2 * d, p["guide_s"], gw))
        if counted:
            self.blocked_waits.append(blocked / 60)
            self.guide_waits.append(gw[0] / 60)
            self.entry_waits.append((env.now - arrive) / 60)
        firsts = [env.process(self._cook_then_deliver(c, d, False, counted)) for c in p["first_dishes"]]
        yield simpy.AllOf(env, firsts)
        eat = tri(rng, p["eat_min"]) * 60
        end = env.now + eat
        # 食事中の追加注文・呼び出し・巡回回収
        extras = []
        for _ in range(p["extra_orders"]):
            extras.append(env.process(self._order_later(rng.uniform(0, eat * 0.7), d, p["extra_cook"], False, counted)))
        n_fragile = int(p["fragile"]) + (rng.random() < p["fragile"] - int(p["fragile"]))
        for _ in range(n_fragile):
            extras.append(env.process(self._order_later(rng.uniform(0.3, 0.85) * eat, d, p["fragile_cook"], True, counted)))
        calls = sum(rng.random() < p["call_rate"] / 2 for _ in range(2))
        for _ in range(calls):
            env.process(self._call_later(rng.uniform(0, eat), d))
        collected = [0]
        if p["mode"] == "patrol" and self.robots is not None:
            for k in range(p["bus_trips"]):
                at = eat * (k + 1) / (p["bus_trips"] + 1)
                env.process(self._collect_later(at, d, end, collected))
        yield env.timeout(eat)
        if extras:
            yield simpy.AllOf(env, extras)
        yield env.process(self.staff_job(1, 0, p["pay_s"]))
        self._acc()
        self.dirty += 1                      # 客は帰った。片付けが済むまで卓は使えない
        yield env.process(self.bus(d, collected[0], counted))
        self._acc()
        self.dirty -= 1
        self.tables.release(table)
        self.done += 1
        if counted:
            self.counted_done += 1
            self._check_finished()

    def _cook_then_deliver(self, cook, d, fragile=False, counted=True):
        yield self.env.timeout(tri(self.rng, cook) * 60)
        yield self.env.process(self.deliver(d, fragile, counted))

    def _order_later(self, delay, d, cook, fragile, counted=True):
        yield self.env.timeout(delay)
        yield self.env.process(self._cook_then_deliver(cook, d, fragile, counted))

    def _call_later(self, delay, d):
        yield self.env.timeout(delay)
        yield self.env.process(self.staff_job(1, 2 * d, self.p["call_s"]))

    def _collect_later(self, delay, d, deadline, box):
        yield self.env.timeout(delay)
        yield self.env.process(self.collect_visit(d, deadline, box))

    def arrivals(self):
        env, p, rng = self.env, self.p, self.rng
        rate = p["arrivals_per_h"] / 3600
        while not self.finished.triggered:
            yield env.timeout(rng.expovariate(rate))
            if not p["sustain"] and env.now > p["peak_min"] * 60:
                return                       # ピークの回し方：peak_min 分で来客が止まる
            env.process(self.group())        # 回るかの確かめ：来客は止めない（数える組が出きるまで）


UNSTABLE_MIN = 5    # 参考：数える時間のうちに入店待ちがこれ以上延びていく条件（判定には使わない。卓が足りない店の見分けに使う）
NG_MIN = 10         # 席は空いているのに待った時間（staff_wait_mean）がこれ以上なら「店が回らない」とする（分）
STAFF_FULL = 0.95   # 回るかの確かめで、スタッフの手がこれ以上ふさがっていたら「店が回らない」とする


def run_once(params, seed):
    store = STORES[params["store"]]
    p = {**COMMON, **store, **params}
    env = simpy.Environment()
    s = Store(env, random.Random(seed), p)
    env.process(s.arrivals())
    if p["sustain"]:
        return _sustain_result(env, s, p)
    env.run()
    end = env.now
    w = s.dish_waits
    return {
        "dish_wait_mean": statistics.mean(w),
        "dish_wait_p90": sorted(w)[int(0.9 * (len(w) - 1))],
        "cold_share": sum(x > p["cold_min"] for x in w) / len(w),
        "seat_wait_mean": statistics.mean(s.seat_waits),
        "turn_mean": statistics.mean(s.turns) if s.turns else 0.0,
        "walk_km_per_staff": s.staff_walk_m / p["staff"] / 1000,
        "staff_util": s.staff_busy_s / (p["staff"] * end),
        "robot_util": (s.robot_busy_s / (p["robots"] * end)) if p["robots"] else 0.0,
        "robot_share": s.robot_trips / max(s.robot_trips + s.staff_trips, 1),
        "robot_bus_share": s.robot_bus / max(s.robot_bus + s.staff_bus, 1),
        "collects": s.collects,
        "rescues": s.rescues,
        "pauses": s.pauses,
        "fragile_robot": s.fragile_robot,
        "groups": s.done,
        # 入店待ちの内訳（v2.2）
        "entry_wait_mean": statistics.mean(s.entry_waits),            # 来てから案内されて席に着くまで
        "blocked_wait_mean": statistics.mean(s.blocked_waits),        # 席はあるのに片付けが終わらず待った
        "guide_wait_mean": statistics.mean(s.guide_waits),            # 卓は決まったが案内の手が空かず待った
        "staff_wait_mean": statistics.mean(b + g for b, g in zip(s.blocked_waits, s.guide_waits)),  # 席はあるのに待った（合計）
        "full_wait_mean": statistics.mean(w - b for w, b in zip(s.seat_waits, s.blocked_waits)),    # 席が埋まっていて待った
    }


def _sustain_result(env, s, p):
    """回るかの確かめ：来客を止めずに回し、数える時間の結果を出す"""
    env.process(s.window())
    env.run(until=s.finished)
    win_s = p["peak_min"] * 60
    q = win_s / 4
    first = [x for t, x in s.seat_wait_by_arrival if t < s.t0 + q]
    last = [x for t, x in s.seat_wait_by_arrival if t >= s.t1 - q]
    trend = (statistics.mean(last) - statistics.mean(first)) if first and last else 0.0
    return {
        "seat_wait_mean": statistics.mean(s.seat_waits),
        "seat_wait_trend": trend,
        "staff_util": statistics.mean(s.staff_busy_n) / p["staff"],
        "capped": 1.0 if s.capped else 0.0,
    }


def conditions(main_only=False):
    for key, st in STORES.items():
        staffs = st["staff"] if main_only else st["staff_all"]
        robots_list = ROBOTS if main_only else ROBOTS_ALL
        for staff, robots, arr, mode, trays, fb in product(staffs, robots_list, st["arrivals"], MODES, TRAYS, FRAGILE_BY):
            if robots == 0 and (mode != "serve" or trays != 1 or fb != "any"):
                continue  # ロボットなしなら、使い方・段の数・使い分けは関係ない
            yield {"store": key, "staff": staff, "robots": robots, "arrivals_per_h": arr,
                   "mode": mode, "trays": trays, "fragile_by": fb}


def run_condition(params):
    runs = [run_once(params, seed) for seed in range(REPS)]
    agg = {k: round(statistics.mean(r[k] for r in runs), 4) for k in runs[0]}
    return {**params, **agg}


def key(c):
    return (c["store"], c["staff"], c["robots"], c["arrivals_per_h"], c["mode"], c["trays"], c["fragile_by"])


def model_sig(common, stores):
    """結果に効く前提だけを取り出す（人数・台数の選択肢や名前は含めない）"""
    skip = {"label", "staff", "staff_all", "arrivals"}
    return json.loads(json.dumps({"common": common, "reps": REPS,
                                  "stores": {k: {a: b for a, b in v.items() if a not in skip} for k, v in stores.items()}}))


if __name__ == "__main__":
    if "--one" in sys.argv:
        for mode in MODES:
            for trays, fb in product(TRAYS, FRAGILE_BY):
                r = run_condition({"store": "family", "staff": 3, "robots": 2, "arrivals_per_h": 35,
                                   "mode": mode, "trays": trays, "fragile_by": fb})
                print(mode, trays, fb, {k: round(v, 2) for k, v in r.items() if isinstance(v, float)})
    elif "--sustain" in sys.argv:
        # 回るかの確かめ：全条件を、来客を止めない回し方で回す
        conds = [{**c, "sustain": True} for c in conditions()]
        done = {}
        try:
            with open("results_sustain_partial.json", encoding="utf-8") as f:
                old = json.load(f)
            if old.get("common") == COMMON and old.get("reps") == REPS and old.get("unstable_min") == UNSTABLE_MIN:
                done = {key(r): r for r in old["rows"]}
        except (FileNotFoundError, KeyError, TypeError, json.JSONDecodeError):
            pass

        def save_s(path, rows):
            out = {"common": COMMON, "reps": REPS, "unstable_min": UNSTABLE_MIN, "rows": rows}
            with open(path + ".tmp", "w", encoding="utf-8") as f:
                json.dump(out, f, ensure_ascii=False)
            import os
            os.replace(path + ".tmp", path)

        todo = [c for c in conds if key(c) not in done]
        print(f"{len(conds)} conditions, {len(conds) - len(todo)} reused, {len(todo)} to run", flush=True)
        with Pool() as pool:
            for i, r in enumerate(pool.imap_unordered(run_condition, todo, chunksize=2), 1):
                done[key(r)] = r
                if i % 100 == 0:
                    save_s("results_sustain_partial.json", list(done.values()))
                    print(f"  {i}/{len(todo)}", flush=True)
        save_s("results_sustain_partial.json", list(done.values()))
        save_s("results_sustain.json", [done[key(c)] for c in conds])
        print(len(conds), "conditions written", flush=True)
    else:
        conds = list(conditions(main_only="--main" in sys.argv))
        sig = model_sig(COMMON, STORES)
        # 前提が同じなら、前回の結果と途中経過を使い回して足りない条件だけ回す
        cache = {}
        for path in ("results.json", "results_partial.json"):
            try:
                with open(path, encoding="utf-8") as f:
                    old = json.load(f)
                if model_sig(old["common"], old["stores"]) == sig and old.get("reps") == REPS:
                    cache.update({key(r): r for r in old["rows"]})
            except (FileNotFoundError, KeyError, TypeError, json.JSONDecodeError):
                pass

        def save(path, rows):
            out = {"common": COMMON, "stores": STORES, "robots": ROBOTS, "robots_all": ROBOTS_ALL,
                   "modes": MODES, "trays": TRAYS, "fragile_by": FRAGILE_BY, "reps": REPS, "rows": rows}
            with open(path + ".tmp", "w", encoding="utf-8") as f:
                json.dump(out, f, ensure_ascii=False)
            import os
            os.replace(path + ".tmp", path)

        todo = [c for c in conds if key(c) not in cache]
        print(f"{len(conds)} conditions, {len(conds) - len(todo)} reused, {len(todo)} to run", flush=True)
        with Pool() as pool:
            for i, r in enumerate(pool.imap_unordered(run_condition, todo, chunksize=2), 1):
                cache[key(r)] = r
                if i % 100 == 0:
                    save("results_partial.json", list(cache.values()))
                    print(f"  {i}/{len(todo)}", flush=True)
        save("results_partial.json", list(cache.values()))
        save("results.json", [cache[key(c)] for c in conds])
        print(len(conds), "conditions written", flush=True)
```

### build.py（構成案1のときの、7場面が切り替わる動画を作る）

手描きの見取り図の場面を外したあとの版です。構成を作り直したあとは使っていません。

```python
import subprocess as sp
D=4.2; F=30; W,H=1200,900
def run(a): sp.run(a,check=True)
def still(src,out,crop=None,z0=1.0,z1=1.08):
    vf=(f"crop={crop}," if crop else "")+f"scale={W*2}:{H*2}:force_original_aspect_ratio=increase,crop={W*2}:{H*2},zoompan=z='{z0}+({z1}-{z0})*on/{int(D*F)}':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d={int(D*F)}:s={W}x{H}:fps={F},format=yuv420p"
    run(["ffmpeg","-v","error","-y","-loop","1","-i",src,"-t",str(D),"-vf",vf,"-r",str(F),"-c:v","libx264","-preset","veryfast","-crf","18",out])
def clip(src,out,ss,crop=None):
    vf=(f"crop={crop}," if crop else "")+f"scale={W}:{H}:force_original_aspect_ratio=increase,crop={W}:{H},fps={F},format=yuv420p"
    run(["ffmpeg","-v","error","-y","-ss",str(ss),"-t",str(D),"-i",src,"-an","-vf",vf,"-c:v","libx264","-preset","veryfast","-crf","18",out])
still("plan_p1_big.png","seq/t1.mp4",z0=1.0,z1=1.05)
still("LOG362_05_unity_store_3.png","seq/t2.mp4",crop="1440:1080:230:0",z0=1.05,z1=1.0)
clip("LOG364_v3_run3_people_and_robots.mp4","seq/t3.mp4",6.0,crop="870:652:180:38")
clip("LOG364_v3_run3_people_and_robots.mp4","seq/t4.mp4",53.0)
clip("LOG364_v4_run4_robots_back_and_forth.mp4","seq/t5.mp4",2.0,crop="870:652:180:38")
clip("rec/page@8aa4461e1a7b50a7ae89d5a768d2ebf9.webm","seq/t6.mp4",2.2,crop="1067:800:0:0")
still("LOG366_01_cost_cards.png","seq/t7.mp4",crop="1862:1397:0:0",z0=1.05,z1=1.0)
X=0.7; n=7
inp=[]; 
for i in range(1,n+1): inp+=["-i",f"seq/t{i}.mp4"]
fc=[]; prev="[0:v]"; off=0
for i in range(1,n):
    off+=D-X; lab=f"[v{i}]"
    fc.append(f"{prev}[{i}:v]xfade=transition=fade:duration={X}:offset={off:.2f}{lab}"); prev=lab
run(["ffmpeg","-v","error","-y",*inp,"-filter_complex",";".join(fc),"-map",prev,"-c:v","libx264","-preset","veryfast","-crf","27","-pix_fmt","yuv420p","-movflags","+faststart","seq/fv-scenes.mp4"])
print("ok")
```

### shot.py（HTML や住所を画面写真にする）

```python
"""HTML を画面写真にする。使い方: python3 shot.py <html|url> <出力.png> [幅] [高さ|full] [待つms]"""
import os, sys
from playwright.sync_api import sync_playwright

src, out = sys.argv[1], sys.argv[2]
w = int(sys.argv[3]) if len(sys.argv) > 3 else 1280
h = sys.argv[4] if len(sys.argv) > 4 else "full"
wait = int(sys.argv[5]) if len(sys.argv) > 5 else 2500
url = src if src.startswith("http") else "file://" + os.path.abspath(src)
proxy = os.environ.get("HTTPS_PROXY")
with sync_playwright() as p:
    b = p.chromium.launch(proxy={"server": proxy, "bypass": "localhost,127.0.0.1"} if proxy else None,
                          args=["--ignore-certificate-errors", "--autoplay-policy=no-user-gesture-required"])
    mobile = w < 600
    ctx = b.new_context(viewport={"width": w, "height": 900 if h == "full" else int(h)}, device_scale_factor=2 if mobile else 1, is_mobile=mobile, has_touch=mobile)
    pg = ctx.new_page()
    # ハコブくんの知らせを出さない（見た記録を入れておく）
    pg.add_init_script("try{localStorage.setItem('p2m-notrack','1')}catch(e){}")
    pg.goto(url, wait_until="networkidle", timeout=90000)
    if h == "full":
        H = pg.evaluate("document.body.scrollHeight")
        for y in range(0, H, 600):
            pg.evaluate(f"window.scrollTo(0,{y})"); pg.wait_for_timeout(120)
        pg.evaluate("window.scrollTo(0,0)")
        pg.evaluate("document.querySelectorAll('.hk-parade,.hk-say').forEach(e=>e.style.display='none')")
    pg.wait_for_timeout(wait)
    pg.screenshot(path=out, full_page=(h == "full"))
    b.close()
print(out)
```

### snap.py（最初の画面とページ全体を、パソコンとスマホの幅で撮る）

```python
import sys
from playwright.sync_api import sync_playwright
pre=sys.argv[1]
with sync_playwright() as p:
    b=p.chromium.launch(args=["--autoplay-policy=no-user-gesture-required"])
    for w,h,tag in [(1440,900,"pc"),(390,844,"sp")]:
        ctx=b.new_context(viewport={"width":w,"height":h},device_scale_factor=2 if w<600 else 1,is_mobile=w<600)
        pg=ctx.new_page()
        pg.add_init_script("document.addEventListener('DOMContentLoaded',()=>{const s=document.createElement('style');s.textContent='.hk-parade,.hk-say{display:none!important}';document.head.appendChild(s)})")
        pg.goto("http://localhost:8765/serving-robot-one-less-staff.html",wait_until="networkidle")
        for t in [1.5,8.5,22]:
            pg.evaluate(f"(()=>{{const v=document.querySelector('.fv-video');v.pause();v.currentTime={t};}})()")
            pg.wait_for_timeout(1000); pg.evaluate("document.querySelector('.fv-video').dispatchEvent(new Event('timeupdate'))"); pg.wait_for_timeout(600)
            if tag=="pc": pg.screenshot(path=f"{pre}_fv_{t}.png")
        H=pg.evaluate("document.body.scrollHeight")
        for y in range(0,H,700): pg.evaluate(f"scrollTo(0,{y})"); pg.wait_for_timeout(80)
        pg.evaluate("scrollTo(0,0)"); pg.wait_for_timeout(1500)
        pg.screenshot(path=f"{pre}_{tag}_full.png",full_page=True)
        ctx.close()
    b.close()
```

### split.py（縦に長い画面写真を分ける）

```python
import sys
from PIL import Image
src, pre = sys.argv[1], sys.argv[2]; step = int(sys.argv[3]) if len(sys.argv) > 3 else 1400
im = Image.open(src); w, h = im.size
n = 0
for y in range(0, h, step):
    c = im.crop((0, y, w, min(h, y + step)))
    if w > 1000: pass
    c.save(f"{pre}_{n}.png"); n += 1
print(w, h, n)
```

### serve.sh（ビルドしたページを手元で配る）

```bash
#!/bin/bash
pkill -f "http-server -p 8765" 2>/dev/null; cd /home/claude/p2m-site/dist && setsid nohup npx --yes http-server -p 8765 -s . >/tmp/srv.log 2>&1 < /dev/null & 
for i in $(seq 1 20); do sleep 1; curl -s -o /dev/null http://localhost:8765/ && break; done; echo up
```

### cap.py（本番のページを節ごとに撮り、読み込む量を足す）

```python
from playwright.sync_api import sync_playwright
import os
U="https://pixelstomotors.com/serving-robot-one-less-staff?p2m-notrack=1"
proxy=os.environ.get("HTTPS_PROXY")
with sync_playwright() as p:
    b=p.chromium.launch(proxy={"server":proxy} if proxy else None,args=["--ignore-certificate-errors","--autoplay-policy=no-user-gesture-required"])
    ctx=b.new_context(viewport={"width":1440,"height":900},device_scale_factor=2)
    pg=ctx.new_page(); tot={'n':0,'b':0}
    def onres(r):
        try:
            l=r.headers.get('content-length'); tot['n']+=1; tot['b']+=int(l) if l else 0
        except Exception: pass
    pg.on('response',onres)
    pg.add_init_script("try{localStorage.setItem('p2m-notrack','1')}catch(e){}")
    pg.goto(U,wait_until="networkidle",timeout=90000)
    print('first view',tot)
    hide="document.querySelectorAll('.hk-parade,.hk-say').forEach(e=>e.style.display='none')"
    pg.evaluate(hide)
    t0=0
    for t in (1.5,8.5,15,22):
        pg.wait_for_timeout(int((t-t0)*1000)); t0=t; pg.evaluate(hide)
        pg.screenshot(path=f"shots/fv_{t}.png")
    H=pg.evaluate("document.body.scrollHeight")
    for y in range(0,H,500): pg.evaluate(f"scrollTo(0,{y})"); pg.wait_for_timeout(150)
    pg.wait_for_timeout(2500); pg.evaluate(hide)
    print('after scroll',tot, 'height',H)
    for s in ['#trouble','#know','#shape','#sim','#flow','#roles','#limits','#next']:
        el=pg.query_selector(s)
        if el: el.scroll_into_view_if_needed(); pg.wait_for_timeout(600); pg.evaluate(hide); el.screenshot(path=f"shots/sec_{s[1:]}.png")
    m=b.new_context(viewport={"width":390,"height":844},device_scale_factor=3,is_mobile=True,has_touch=True).new_page()
    m.add_init_script("try{localStorage.setItem('p2m-notrack','1')}catch(e){}")
    m.goto(U,wait_until="networkidle",timeout=90000); m.wait_for_timeout(2000); m.evaluate(hide)
    m.screenshot(path="shots/m_fv.png")
    b.close()
```

### cap.py の出力

届いた返事に書かれた大きさ（content-length）を足した値です。

```text
first view {'n': 126, 'b': 9376707}
after scroll {'n': 131, 'b': 11467131} height 10069
```

### cap2.py（最初の画面を1秒ごとに見て、場面ごとに撮る）

```python
from playwright.sync_api import sync_playwright
import os
U="https://pixelstomotors.com/serving-robot-one-less-staff?p2m-notrack=1"
proxy=os.environ.get("HTTPS_PROXY")
hide="""document.querySelectorAll('body *').forEach(e=>{const s=getComputedStyle(e);if(s.position==='fixed'&&!e.closest('header'))e.style.display='none'})"""
with sync_playwright() as p:
    b=p.chromium.launch(proxy={"server":proxy} if proxy else None,args=["--ignore-certificate-errors","--autoplay-policy=no-user-gesture-required"])
    pg=b.new_context(viewport={"width":1440,"height":900},device_scale_factor=2).new_page()
    pg.add_init_script("try{localStorage.setItem('p2m-notrack','1')}catch(e){}")
    pg.goto(U,wait_until="networkidle",timeout=90000)
    for t in range(1,27):
        pg.wait_for_timeout(1000); pg.evaluate(hide)
        if t in (11,12,13,24,25): pg.screenshot(path=f"shots/fvt_{t}.png")
    el=pg.query_selector('#next'); el.scroll_into_view_if_needed(); pg.wait_for_timeout(1500); pg.evaluate(hide)
    el.screenshot(path="shots/sec_next2.png")
    b.close()
```

### weight.py（最初の画面で読み込むものを種類ごとに測る）

```python
from playwright.sync_api import sync_playwright
import os,collections
U="https://pixelstomotors.com/serving-robot-one-less-staff?p2m-notrack=1"
proxy=os.environ.get("HTTPS_PROXY")
with sync_playwright() as p:
    b=p.chromium.launch(proxy={"server":proxy} if proxy else None,args=["--ignore-certificate-errors"])
    pg=b.new_context(viewport={"width":1440,"height":900}).new_page()
    rows=[]
    pg.on('requestfinished',lambda r: rows.append((r.url, r.sizes()['responseBodySize'], r.resource_type)))
    pg.add_init_script("try{localStorage.setItem('p2m-notrack','1')}catch(e){}")
    pg.goto(U,wait_until="networkidle",timeout=90000); pg.wait_for_timeout(3000)
    by=collections.Counter(); 
    for u,s,t in rows: by[t]+=s
    print('total',sum(s for _,s,_ in rows),len(rows)); print(dict(by))
    for u,s,t in sorted(rows,key=lambda x:-x[1])[:15]: print(s,t,u.split('?')[0][-70:])
    b.close()
```

### weight.py の出力

本文の表はこの出力です。

```text
total 9497322 126
{'document': 665404, 'stylesheet': 344947, 'script': 15255, 'image': 866162, 'media': 5898395, 'font': 1705322, 'fetch': 1837, 'xhr': 0, 'ping': 0}
3539481 media https://pixelstomotors.com/site/srl/terminal.mp4
1418753 media https://pixelstomotors.com/site/srl/fv-oblique.mp4
940161 media https://pixelstomotors.com/site/srl/top-busy.mp4
719812 font IeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZZLsYEpzC_1ver5Y0.woff2
651770 document https://pixelstomotors.com/sim/serving-robot
166542 image https://pixelstomotors.com/site/srl/fv-unity.webp
155306 image https://pixelstomotors.com/site/srl/terminal.webp
148842 image https://pixelstomotors.com/site/srl/fv-sim.webp
122261 stylesheet https://fonts.googleapis.com/css2
122107 stylesheet https://fonts.googleapis.com/css2
92750 image https://pixelstomotors.com/site/srl/trouble-staff.webp
91736 stylesheet https://fonts.googleapis.com/css2
91436 image https://pixelstomotors.com/site/srl/fv-cost.webp
79064 font /v57/-F62fjtqLzI2JPCgQBnw7HFow2oe2EcP5pp0erwTqsSWs9Jezazjcb4.119.woff2
53594 image https://pixelstomotors.com/site/srl/four-views.webp
```

### このページの公開の記録（git log）

```text
65aac70 2026-10-24 12:23 配膳ロボットLP：最後の節の背景の道を薄くし、ボタンのオレンジと競わないようにする
60c7f5a 2026-10-24 12:19 配膳ロボットLP：ロボットの絵の胴の線を消す・止まりの数字を大きく・最後の節の背景を店舗の図面とロボットの道に
11113dc 2026-10-24 11:54 配膳ロボットのLP：埋め込んだ試算の黒と白を、LPに埋め込んだときだけ（?lp=1）サイト共通の色にそろえる。試算のページ単体と /series8 の見た目は変えない
8705188 2026-10-24 11:50 配膳ロボットのLP：最後のCTAを「送るもの3つ＋ボタン」に（3Dの店舗の写真を背景に）。黒とグレーをサイト共通の色（#242424・#f7f7f7 など）にそろえる
c30413c 2026-10-24 11:22 配膳ロボットのLP：角丸を埋め込んだ試算とそろえる（大きな面12px・カードと画像8px・ボタンと札4px）
31bda08 2026-10-24 11:19 配膳ロボットのLP：6つの平面図と埋め込んだ試算の2つだけ、コンテンツ幅を広くする（ほかは1040px）
f210238 2026-10-24 11:14 配膳ロボットのLP：人とロボットの絵、手順の絵を足す。コンテンツ幅を狭め、「店」を「店舗」にそろえ、文を読み直す
0151394 2026-10-24 11:09 配膳ロボットのLP：説明文を1行に（幅の広い画面）。つまずきの札を文字だけにして軽く。「人が減らない」の写真を大きい元画像から切り出し直す
5eec557 2026-10-24 11:06 配膳ロボットのLP：導入前に分かる3つのことで、左の文を図の上端にそろえる。ターミナルの見出しの位置とスマホの分割の並びを直す
efdb24d 2026-10-24 11:00 配膳ロボットのLP：最初の画面を分割と全面で切り替える。セクションの番号と小さい番号を外し、見出しと説明文を中央にそろえる
de4f40c 2026-10-24 10:40 配膳ロボットのLP：見出しを「導入前に自分の店でシミュレーション」に。「入れる前」を「導入前」にそろえ、斜めの動画から編集画面の印を外す
4d5fcfb 2026-10-24 10:36 配膳ロボットのLP：最初の画面の右を4枚の分割（平面図・3Dの店・走らせる・試算の結果）に。4視点の動画を足し、画質を上げる
b19736b 2026-10-24 10:13 配膳ロボットのLPを作り直す：研究の流れではなく「入れる前に自分の店で確かめられる・頼める」を伝える構成に
39fcbcb 2026-10-24 09:57 配膳ロボットのLP：04の節から手描きの見取り図を外す
0d83f2f 2026-10-24 09:48 配膳ロボットのLP：最初の画面の動画から手描きの見取り図を外す（7場面）
b6d6c2a 2026-10-24 09:44 配膳ロボットのLP：最初の画面を、左のメニューと8場面が切り替わる動画に組み替える
26d7a95 2026-10-24 09:05 配膳ロボットの導入前ガイド（LP）を確認用に足す（/serving-robot-one-less-staff・noindex・一覧と入口にはまだ出さない）
```
