commit 1ee9d8e8590c9391280774c292ee266b918d26cc
店の形：Unity のロボットが自分で道を引くための地図を書き出す（版 2fa10547a2b7）


diff --git a/tools/sim_layouts/README.md b/tools/sim_layouts/README.md
index 22d2ece..1aa11e9 100644
--- a/tools/sim_layouts/README.md
+++ b/tools/sim_layouts/README.md
@@ -30,7 +30,8 @@
 - 場所の点：受け渡し口の前・下げ膳の返却（受け渡し口と同じ）・入口・待合・レジ（レジ内の反対側）と並ぶ点・レジ内・トイレの入口の前・ドリンクバーの前と立つ点・並ぶ点・ロボットの待機場所（1台ずつ）・スタッフが待つ点
 - スタッフが待つ点：パントリー（と接する壁）の客席側の辺にそって壁から0.25m、0.6mおき。外側にロボットのふだんの道（1.0m）が残る所だけ。受け渡し口に近い順に6つ（仮の規則）
 - 並ぶ点・立つ点は、障害物から0.25m未満の所と細い通路には置かない
-- ロボットの道順（`routes`）：受け渡し口の前→各卓の止まる位置（`from_pass`）と、卓→卓（`between`。"T01|T02" は T01 から T02、逆向きは点を逆にたどる）。ふだん走らせる幅の道で行けるならその道、行けなければ通れる幅の道（試算と同じ規則。`use` が normal か narrow。試算の卓の分け方と一致することをテストで確かめる）。マス目の最短の道を、通れる範囲でまっすぐにつないだ曲がり角の点の並び。Unity のロボットはこの点を順にたどる（Unity で道を決めない）。2026-10-22、Unity で狭い通路の区画を NavMesh に書き込んだら NavMesh が壊れたため、この形にした
+- ロボットの道順（`routes`）：受け渡し口の前→各卓の止まる位置（`from_pass`）と、卓→卓（`between`。"T01|T02" は T01 から T02、逆向きは点を逆にたどる）。ふだん走らせる幅の道で行けるならその道、行けなければ通れる幅の道（試算と同じ規則。`use` が normal か narrow。試算の卓の分け方と一致することをテストで確かめる）。マス目の最短の道を、通れる範囲でまっすぐにつないだ曲がり角の点の並び。2026-10-22 から Unity のロボットはこの点を順にたどっていたが、2026-10-24 から下の地図の上で自分で道を引く形に変える（線に縛ると、行きと帰りが同じ線になり、ロボットどうしが正面から出会って回り込めなかったため）。道順は、回り込みで延びた距離を測る物差しと、前の動き方に戻す切り替えのために残す
+- ロボットの地図（`unity/{名前}_map.json`。2026-10-24）：試算が卓までの距離を測るのと同じ2.5cmのマス目で、マスごとに「ふだんの道（1.0m）が通れる＝2／通れる幅（0.8m）の道だけ通れる＝1／通れない＝0」（`free_class`）と、いちばん近い障害物までの距離（`clear_mm`）を入れたもの。分け方は試算と同じ式（`Grid.free`）。会計の列は通れない。待機場所は床として入れる（止まっているロボットは Unity で動くものとして扱う。待機場所の真ん中は壁から0.4m前後なので1のマス）。Unity のロボットはこの上で自分で道を引く（行き先までふだんの道で行けるならその道だけ、行けなければ狭い道も使う。試算と同じ規則）。地図だけで試算の卓の分け方（robot・narrow）が再現できることをテストで確かめる。中身は gzip で縮めた base64（読み方は `unity_export.unpack_map`）
 - 会計の列（`robot_keepout`）：ロボットの道にしない矩形。人は通れるので、Unity では部屋や壁として建てない（道順はこれをよけて作ってある）
 - まだないもの：② の4人席・丸テーブル・6人の長テーブルの椅子と座る位置（parts が null）、人がロボットをよける逃げ場（refuges）
 
diff --git a/tools/sim_layouts/famires_v3.py b/tools/sim_layouts/famires_v3.py
index 89e8738..2c28568 100644
--- a/tools/sim_layouts/famires_v3.py
+++ b/tools/sim_layouts/famires_v3.py
@@ -1010,6 +1010,12 @@ def export_unity(L, res_t, ctx, nm, out, gen_id):
                            standby_slots(L["robot_standby"]), FURNITURE_TYPES, meta)
     (out / "unity").mkdir(exist_ok=True)
     json.dump(U, open(out / "unity" / f"{nm}.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
+    # ロボットの地図（2026-10-24）：同じマス目で、待機場所を床にしたもの。ロボットはこの上で自分で道を引く
+    gm = Grid(L, SETTINGS["res"], standby=[])
+    M = unity_export.build_map(gm, SETTINGS["normal_width"], ROBOT_MODELS[SETTINGS["model"]]["pass_width"],
+                               dict(generation=gen_id, units="m", source=f"{nm}.json と同じ店の形・同じマス目（settings.res）から作った",
+                                    robot_model=meta["robot_model"]["name"]))
+    json.dump(M, open(out / "unity" / f"{nm}_map.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
     return U
 
 
diff --git a/tools/sim_layouts/test_famires_v3.py b/tools/sim_layouts/test_famires_v3.py
index 9d5274c..b43624b 100644
--- a/tools/sim_layouts/test_famires_v3.py
+++ b/tools/sim_layouts/test_famires_v3.py
@@ -384,6 +384,38 @@ for nm, fn, width in (("p1", gen.p1_efficient_corner, 1.2), ("p5", gen.p5_cramme
     expect(f"{nm}：会計に立つ点・並ぶ点は会計の列の中", all(any(k[0] <= p["point"][0] <= k[2] and k[1] <= p["point"][1] <= k[3] for k in ko)
                                                      for p in U["places"] if p["kind"] in ("Register", "RegisterQueue")))
 
+# ---- ロボットの地図（unity/{名前}_map.json。2026-10-24）
+from scipy.sparse.csgraph import connected_components
+for nm, fn in (("p1", gen.p1_efficient_corner), ("p5", gen.p5_crammed), ("p0", famires_p0.p0_memory)):
+    L = v.prepare(fn())
+    ctx = {}
+    res, _ = v.analyze(L, v.SETTINGS["res"], ctx)
+    g = ctx["grid"]
+    nw, pw = v.SETTINGS["normal_width"], v.ROBOT_MODELS[v.SETTINGS["model"]]["pass_width"]
+    gm = v.Grid(L, v.SETTINGS["res"], standby=[])
+    M = ux.build_map(gm, nw, pw, {})
+    cls, mm = ux.unpack_map(M)
+    expect(f"{nm}：地図を読み戻すと、マス目の数が同じ", cls.shape == (g.ny, g.nx) == (M["ny"], M["nx"]))
+    expect(f"{nm}：地図を2回作ると同じ文字列（毎回同じ）", ux.build_map(gm, nw, pw, {})["free_class"] == M["free_class"])
+    expect(f"{nm}：障害物までの距離は、計算の値と0.5mm以内", float(abs(mm / 1000.0 - np.minimum(gm.clear, 65.535)).max()) <= 0.0005 + 1e-9)
+    expect(f"{nm}：試算でふだんの道が通れるマスは、地図でも2（待機場所を床にした分だけ増える）",
+           bool((cls[g.free(nw)] == 2).all()) and bool((cls[g.free(pw)] >= 1).all()))
+    expect(f"{nm}：会計の列のマスは、地図では通れない",
+           all(cls[gm.cell((k[0] + k[2]) / 2, (k[1] + k[3]) / 2)] == 0 for k in v.checkout_keepout(L)))
+    # 地図だけで、試算と同じ卓の分け方になるか（受け渡し口の前から、2のマスだけでつながる卓＝ふだんの道の卓）
+    _, lab2 = connected_components(gm.graph(cls == 2), directed=False)
+    _, lab1 = connected_components(gm.graph(cls >= 1), directed=False)
+    c = lambda x, y: (lambda q: q[0] * gm.nx + q[1])(gm.cell(x, y))
+    s = c(*L["start"])
+    expect(f"{nm}：地図の上でも、ふだんの道で行ける卓は試算の robot の卓と同じ、狭い道でだけ行ける卓は narrow の卓と同じ",
+           all(((lab2[s] == lab2[c(*a["stop"])]) == (a["category"] == "robot")) and
+               ((lab1[s] == lab1[c(*a["stop"])]) == (a["category"] in ("robot", "narrow")))
+               for a in res if a.get("stop")))
+    slots = v.standby_slots(L["robot_standby"])
+    # 待機場所は壁ぎわに止める形（真ん中が壁から0.4m前後）なので、最後は狭い道（1）のマスになる
+    expect(f"{nm}：どの待機場所の真ん中にも、受け渡し口の前から地図の上で行ける（通れる幅の道）",
+           all(lab1[s] == lab1[c((r[0] + r[2]) / 2, (r[1] + r[3]) / 2)] for r in slots))
+
 # 確かめの仕組みの確かめ：会計の列がなければ、②はロボットが会計の列の前を横切り、確かめに引っかかる
 _ko = v.checkout_keepout
 v.checkout_keepout = lambda L: []
diff --git a/tools/sim_layouts/unity_export.py b/tools/sim_layouts/unity_export.py
index 168c9ef..41d4fcb 100644
--- a/tools/sim_layouts/unity_export.py
+++ b/tools/sim_layouts/unity_export.py
@@ -22,7 +22,14 @@
 - 席に着くために立つ点は、障害物から0.2m（人の半径）以上離れた所だけ
 - 会計の列（robot_keepout）：レジの客の側で、ロボットの道にしない矩形（famires_v3.checkout_keepout）。人は通れるので、部屋や壁としては建てない。
   ロボットの道順（routes）はこれをよけて作ってある。Unity では見せるための目印として使ってよい
+- ロボットの地図（unity/{名前}_map.json。2026-10-24）：試算が卓までの距離を測るのと同じマス目（settings.res）の、マスごとの値。
+  free_class はマスの分け方（2＝ふだん走らせる幅 normal_width の道が通れる、1＝通れる幅 pass_width の道だけ通れる、0＝通れない）。
+  clear_mm はマスの中心からいちばん近い障害物（壁・部屋・卓・会計の列）までの距離（mm）。
+  分け方は試算と同じ式（famires_v3.Grid.free）で、ロボットはこの地図の上で自分で道を引く（ふだんの道で行けるならそこ、行けなければ狭い道。試算と同じ規則）。
+  待機場所は床として入れる（止まっているロボットは、Unity で動くものとして扱う）。routes は残す（回り込みで延びた距離を測る物差し）
 """
+import base64
+import gzip
 import math
 
 import numpy as np
@@ -517,3 +524,36 @@ def build(L, analysis_tables, grid, dist, dist_pass, sides, standby_slots, FT, m
                 rooms=[dict(label=r["label"], kind=r["kind"], rect=r["rect"], **WALKABLE.get(r["label"], {"walkable": False}))
                        for r in L["rooms"] if r["kind"] != "outside"],
                 doors=L.get("doors", []), robot_keepout=[dict(label="会計の列（ロボットは通らない）", rect=list(r)) for r in L.get("robot_keepout", [])], aisles=aisles, tables=tables, places=places(L, grid, standby_slots, aisles))
+
+
+MAP_SCHEMA = "famires-unity-map/0.1"
+
+
+def _pack(a):
+    """配列を、行（y=0 の手前から）ごとに並べた生のバイト列 → gzip → base64 の文字列にする（gzip の時刻は 0。同じ中身なら毎回同じ文字列）"""
+    return base64.b64encode(gzip.compress(np.ascontiguousarray(a).tobytes(), mtime=0)).decode("ascii")
+
+
+def unpack_map(M):
+    """書き出した地図を読み戻す（確かめ用。Unity でも同じ手順で読む）"""
+    ny, nx = M["ny"], M["nx"]
+    cls = np.frombuffer(gzip.decompress(base64.b64decode(M["free_class"])), dtype=np.uint8).reshape(ny, nx)
+    mm = np.frombuffer(gzip.decompress(base64.b64decode(M["clear_mm"])), dtype="<u2").reshape(ny, nx)
+    return cls, mm
+
+
+def build_map(grid, normal_width, pass_width, meta):
+    """ロボットの地図。grid は待機場所を床にした famires_v3.Grid（同じ res）"""
+    f_n, f_p = grid.free(normal_width), grid.free(pass_width)
+    cls = np.where(f_n, 2, np.where(f_p, 1, 0)).astype(np.uint8)
+    mm = np.clip(np.round(grid.clear * 1000.0), 0, 65535).astype("<u2")
+    return dict(
+        meta=dict(meta, schema_version=MAP_SCHEMA),
+        res=grid.res, nx=grid.nx, ny=grid.ny,
+        normal_width=normal_width, pass_width=pass_width,
+        layout=("マス (ix, iy) の中心は x=(ix+0.5)*res、y=(iy+0.5)*res（店の外壁の内側の左下が原点。Unity では x→X、y→Z）。"
+                "配列は iy=0（手前）の行から、1行は ix=0 から nx 個。番号は iy*nx+ix"),
+        encoding="gzip（時刻0）で縮めて base64。free_class は 1マス1バイト、clear_mm は 1マス2バイト（小さい桁が先）",
+        free_class_values={"2": "ふだん走らせる幅の道が通れる", "1": "通れる幅の道だけ通れる", "0": "通れない"},
+        counts={"2": int((cls == 2).sum()), "1": int((cls == 1).sum()), "0": int((cls == 0).sum())},
+        free_class=_pack(cls), clear_mm=_pack(mm))
