using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.AI;
using UnityEngine.SceneManagement;

namespace ServiceSim.EditorTools
{
    /// <summary>
    /// エディタのメニュー ServiceSim/… 。1クリックで動くようにする。
    /// 書き出すのは ExperimentPlan に書いた場所だけ。Assets/ServiceSim/ の外のアセットは変えない。
    /// </summary>
    public static class ServiceSimMenu
    {
        /// <summary>1つの店の形で測った結果</summary>
        class LayoutResult
        {
            public string assetName;
            public string kindName;
            public float[] widths;
            public Vector2[] outerSizes;
            public List<int> tableIds = new List<int>();
            public Dictionary<int, SeatKind> kinds = new Dictionary<int, SeatKind>();
            public Dictionary<int, Vector3> centers = new Dictionary<int, Vector3>();
            public Dictionary<int, Vector3> deliveries = new Dictionary<int, Vector3>();
            public Dictionary<int, string> aisleNames = new Dictionary<int, string>();
            public Dictionary<int, float[]> lengths = new Dictionary<int, float[]>();
            public Dictionary<int, string[]> statuses = new Dictionary<int, string[]>();
        }

        /// <summary>
        /// 店を組み立てて NavMesh を焼き、35卓の道のり・外寸・行き止まりの有無・
        /// スクリーンショットを書き出す。格子型の店とも比べる。
        /// 組み立てたものはシーンに保存しない（1クリックで作り直せるため）。
        /// </summary>
        [MenuItem("ServiceSim/Build Store", false, 20)]
        public static void BuildStore()
        {
            StoreBuilder builder;
            NavMeshBaker baker;
            PeopleSpawner spawner;
            Transform robotsRoot;
            if (!OpenSceneAndFind(out builder, out baker, out spawner, out robotsRoot)) return;

            StoreLayout mainLayout = builder.layout;
            ExperimentPlan plan = AssetDatabase.LoadAssetAtPath<ExperimentPlan>(ServiceSimSetup.ExperimentPlanPath);
            RobotSpec robotSpec = AssetDatabase.LoadAssetAtPath<RobotSpec>(ServiceSimSetup.RobotSpecPath);
            PeopleSpec peopleSpec = AssetDatabase.LoadAssetAtPath<PeopleSpec>(ServiceSimSetup.PeopleSpecPath);
            if (mainLayout == null || plan == null || robotSpec == null || peopleSpec == null)
            {
                Debug.LogError("[ServiceSim] 設定ファイルが見つかりません。先に ServiceSim/Setup を実行してください");
                return;
            }

            string shaderHow;
            Shader shader = ServiceSimRender.FindOpaqueShader(out shaderHow);
            if (shader == null)
            {
                Debug.LogError("[ServiceSim] この描画方式に合うシェーダーが見つかりません。撮らずに止めました。\n" +
                               ServiceSimRender.Describe() + "探した結果: " + shaderHow);
                return;
            }

            string outDir = plan.screenshotDir;
            try { Directory.CreateDirectory(outDir); }
            catch (Exception e)
            {
                Debug.LogError("[ServiceSim] 書き出し先を作れません: " + outDir + "\n" + e.Message);
                return;
            }

            // ベンチの見た目（座面・背もたれ）と、当たりだけの箱を、建てる前に用意して入れる。
            // 当たりの箱の大きさ・位置・高さは今までと同じなので、測る数字は変わらない
            ServiceSimSetup.EnsureBenchLook(builder);

            string prefix = DateTime.Now.ToString("yyyyMMdd") + "_" + plan.runName;
            int shotW = plan.screenshotWidth;
            int shotH = plan.screenshotHeight;
            Camera camTop = FindCamera(builder, "Cam_Top");
            Camera camOblique = FindCamera(builder, "Cam_Oblique");

            spawner.Clear();
            ClearChildren(robotsRoot);

            // ---- いまの店を、幅ごとに測る ----
            LayoutResult main = Measure(builder, baker, mainLayout, camTop, shotW, shotH, outDir, prefix, true);

            // ---- 行き止まり・席に入れるかの確かめ（いまの幅で）----
            builder.Build();
            baker.Bake();

            NavMeshQueryFilter robotFilter = baker.FilterFor(baker.RobotAgentTypeId);
            NavMeshQueryFilter peopleFilter = baker.FilterFor(baker.PeopleAgentTypeId);

            string seatAccess = CheckSeatAccess(builder);
            string regBack = CheckRegisterBack(builder, baker);
            string robotPaths = CheckRobotPathsOnRobotMesh(builder, baker);
            string aisleWidths =
                CheckAisleWidths(builder, robotFilter, mainLayout.navAgentRadius, "ロボット用") + "\n" +
                CheckAisleWidths(builder, peopleFilter, baker.PeopleRadius, "人用");
            string deadEnd =
                CheckNoDeadEnd(builder, baker, robotFilter, "ロボット用（半径 " +
                               F(mainLayout.navAgentRadius) + " m）") + "\n" +
                CheckNoDeadEnd(builder, baker, peopleFilter, "人用（半径 " +
                               F(baker.PeopleRadius) + " m）");

            // ---- 格子型の店とも比べる ----
            LayoutResult grid = null;
            StoreLayout gridLayout = AssetDatabase.LoadAssetAtPath<StoreLayout>(ServiceSimSetup.GridLayoutPath);
            if (gridLayout != null && gridLayout != mainLayout)
            {
                grid = Measure(builder, baker, gridLayout, null, shotW, shotH, outDir, prefix, false);
                builder.layout = mainLayout;
                baker.layout = mainLayout;
            }

            // ---- もとに戻して、人とロボットを置く ----
            builder.Build();
            baker.Bake();
            // 見た目の部品（当たりを持たないもの）。毎回かならず用意して入れ直す。
            // 10/21 の分では prefab を作る前から参照だけしていて、席に何も出ていなかった
            ServiceSimSeatedBody.Ensure(spawner, peopleSpec);
            ServiceSimSetup.EnsurePersonLook(peopleSpec);

            spawner.Spawn(0);
            // 座っている客の形を、慣らしと同じ埋まり方で出す（絵のため。当たりは持たない）
            int seatedShown = spawner.ShowSeatedPreview(peopleSpec.warmupOccupancy);
            int robots = SpawnPreviewRobots(robotsRoot, builder, 2);

            string nowLabel = "w" + Mm(mainLayout.AisleWidth);
            FloorPaint.Result paint = builder.PaintFloor(baker, PersonRadius());
            if (camTop != null && builder.Plan != null)
            {
                ServiceSimCameras.FrameTop(camTop, builder.Plan.floor, (float)shotW / shotH);
                ServiceSimLegend.Build(builder.FloorPaintRoot, camTop, (float)shotW / shotH,
                                       builder.robotFloorMaterial, builder.peopleFloorMaterial);
                Capture(camTop, shotW, shotH, Path.Combine(outDir, prefix + "_top_" + nowLabel + "_people.png"));
            }
            if (camOblique != null && builder.Plan != null)
            {
                ServiceSimCameras.FrameOblique(camOblique, builder.Plan.floor);
                Capture(camOblique, shotW, shotH, Path.Combine(outDir, prefix + "_oblique_" + nowLabel + "_people.png"));
            }

            string overhead = CheckOverhead(builder);

            // つながっていない面は、通行者の種類ごとに焼いた面を読んで調べる
            NavMeshTriangulation robotTri = baker.TriangulateFor(mainLayout.navAgentRadius);
            NavMeshTriangulation peopleTri = baker.TriangulateFor(baker.PeopleRadius);
            string navSurface =
                CheckNavMeshSurface(builder, robotTri, "ロボット用（半径 " +
                                    F(mainLayout.navAgentRadius) + " m）", robotFilter) + "\n" +
                CheckNavMeshSurface(builder, peopleTri, "人用（半径 " +
                                    F(baker.PeopleRadius) + " m）", peopleFilter);
            string noGo = CheckNoGoRooms(builder, robotTri);

            // ---- 書き出し ----
            WriteTableCsv(Path.Combine(outDir, prefix + "_table_path_lengths_family.csv"), main);
            if (grid != null)
                WriteTableCsv(Path.Combine(outDir, prefix + "_table_path_lengths_grid.csv"), grid);

            WriteStoreReport(Path.Combine(outDir, prefix + "_" + mainLayout.name + "_store_report.txt"),
                             mainLayout, robotSpec, plan, builder, spawner, robots,
                             main, grid, shaderHow, overhead, deadEnd, navSurface, seatAccess,
                             PaintText(mainLayout, paint), aisleWidths, noGo, regBack, robotPaths,
                             PlaceNamesReport(builder));

            TripLogger.WriteConditionsJson(
                Path.Combine(outDir, prefix + "_conditions.json"),
                plan.runName, mainLayout, robotSpec, peopleSpec, plan, plan.BuildConditions(mainLayout));

            EditorUtility.SetDirty(mainLayout);
            AssetDatabase.SaveAssets();

            Debug.Log("[ServiceSim] Build Store おわり。" +
                      "\n  店の形: " + mainLayout.name + "（" + mainLayout.StoreKindName + "）" +
                      "\n  卓: " + builder.Tables.Count + " / 細い通路: " + F(mainLayout.AisleWidth) + " m" +
                      " / 細い通路の数: " + NarrowCount(builder) +
                      "\n  外寸: " + F(builder.OuterSize.x) + " × " + F(builder.OuterSize.y) + " m" +
                      "\n  人: スタッフ " + spawner.StaffSpawned +
                      " / 座っている客の仕掛け " + spawner.SeatedSpawned +
                      "（席に出した形 " + seatedShown + " 体・高さ " +
                      F(peopleSpec.seatedBodyHeight) + " m・見た目だけ）" +
                      "\n  ロボット: " + robots +
                      "\n  シェーダー: " + shader.name +
                      "\n  入口まわり（ドリンクバー・待合・レジ）: " +
                      mainLayout.FrontFitReport().Replace("\n", "\n    ") +
                      "\n  ロボットの待機場所（B2）: " +
                      mainLayout.RobotStandbyReport().Replace("\n", "\n    ") +
                      "\n  床の塗り分け（見た目だけ）: " + PaintText(mainLayout, paint).Replace("\n", "\n    ") +
                      "\n  場所の名前: " + PlaceNamesReport(builder).Replace("\n", "\n    ") +
                      "\n  ロボットの経路: " + robotPaths.Replace("\n", "\n    ") +
                      "\n  レジの内側に入れるか: " + regBack.Replace("\n", "\n    ") +
                      "\n  細い通路の歩ける幅: " + aisleWidths.Replace("\n", "\n    ") +
                      "\n  通れない場所の NavMesh: " + noGo.Replace("\n", "\n    ") +
                      "\n  席に入れるかの確かめ: " + seatAccess.Replace("\n", "\n    ") +
                      "\n  行き止まりの確かめ: " + deadEnd.Replace("\n", "\n    ") +
                      "\n  真上からの見通し: " + overhead.Replace("\n", "\n    ") +
                      "\n  焼いた NavMesh: " + navSurface.Replace("\n", "\n    ") +
                      "\n  書き出し先: " + outDir +
                      "\n  ※ 組み立てたものはシーンに保存していません（Build Store で作り直せます）");
        }

        [MenuItem("ServiceSim/Clear Built Store", false, 21)]
        public static void ClearBuiltStore()
        {
            StoreBuilder builder;
            NavMeshBaker baker;
            PeopleSpawner spawner;
            Transform robotsRoot;
            if (!OpenSceneAndFind(out builder, out baker, out spawner, out robotsRoot)) return;

            spawner.Clear();
            ClearChildren(robotsRoot);
            baker.Remove();
            builder.Clear();
            Debug.Log("[ServiceSim] 組み立てたものを消しました");
        }

        // ---------------- 測る ----------------

        static LayoutResult Measure(StoreBuilder builder, NavMeshBaker baker, StoreLayout layout,
                                    Camera camTop, int shotW, int shotH,
                                    string outDir, string prefix, bool shoot)
        {
            builder.layout = layout;
            baker.layout = layout;

            LayoutResult res = new LayoutResult();
            res.assetName = layout.name;
            res.kindName = layout.StoreKindName;

            int n = Mathf.Max(1, layout.AisleWidthCount);
            res.widths = new float[n];
            res.outerSizes = new Vector2[n];

            int original = layout.aisleWidthIndex;
            float sampleRadius = layout.navAgentRadius * 4f;

            for (int wi = 0; wi < n; wi++)
            {
                layout.aisleWidthIndex = wi;
                res.widths[wi] = layout.AisleWidth;

                builder.Build();
                baker.Bake();
                res.outerSizes[wi] = builder.OuterSize;

                Vector3 from = builder.PassPoint;
                IList<StoreBuilder.TableInfo> tables = builder.Tables;
                for (int i = 0; i < tables.Count; i++)
                {
                    StoreBuilder.TableInfo t = tables[i];
                    if (!res.lengths.ContainsKey(t.tableId))
                    {
                        res.tableIds.Add(t.tableId);
                        res.lengths[t.tableId] = new float[n];
                        res.statuses[t.tableId] = new string[n];
                    }
                    res.kinds[t.tableId] = t.kind;
                    res.centers[t.tableId] = t.center;
                    res.deliveries[t.tableId] = t.deliveryPoint;
                    res.aisleNames[t.tableId] = AisleNameOf(builder, t.aisleIndex);

                    string status;
                    res.lengths[t.tableId][wi] = NavMeshBaker.PathLength(from, t.deliveryPoint, sampleRadius, out status);
                    res.statuses[t.tableId][wi] = status;
                }

                if (shoot && camTop != null && builder.Plan != null)
                {
                    // 床の塗り分けは幅ごとに作り直す（見た目だけ。NavMesh は変えない）
                    builder.PaintFloor(baker, PersonRadius());
                    ServiceSimCameras.FrameTop(camTop, builder.Plan.floor, (float)shotW / shotH);
                    ServiceSimLegend.Build(builder.FloorPaintRoot, camTop, (float)shotW / shotH,
                                           builder.robotFloorMaterial, builder.peopleFloorMaterial);
                    Capture(camTop, shotW, shotH,
                        Path.Combine(outDir, prefix + "_top_w" + Mm(res.widths[wi]) + ".png"));
                }
            }

            layout.aisleWidthIndex = original;
            res.tableIds.Sort();
            return res;
        }

        static string AisleNameOf(StoreBuilder builder, int index)
        {
            if (index < 0 || index >= builder.Aisles.Count) return "";
            return builder.Aisles[index].name;
        }

        static int NarrowCount(StoreBuilder builder)
        {
            int n = 0;
            for (int i = 0; i < builder.Aisles.Count; i++) if (builder.Aisles[i].narrow) n++;
            return n;
        }

        // ---------------- 行き止まりの確かめ ----------------

        /// <summary>
        /// 細い通路を1本ずつ、まん中でふさいでから焼き直し、
        /// 「ふさいだ場所にある卓」以外のすべての卓に届くかを確かめる。
        /// </summary>
        static string CheckNoDeadEnd(StoreBuilder builder, NavMeshBaker baker,
                                     NavMeshQueryFilter filter, string label)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(label).Append('\n');
            float sampleRadius = builder.layout.navAgentRadius * 4f;
            Vector3 from = builder.PassPoint;

            int baseFail = CountUnreachable(builder, from, sampleRadius, null, filter);
            sb.Append("ふさがないとき: 届かない卓 ").Append(baseFail).Append(" / ").Append(builder.Tables.Count);
            if (baseFail > 0) sb.Append("  ← 直す必要あり");
            sb.Append('\n');

            int tested = 0, bad = 0;
            float blockLen = builder.layout.navAgentRadius * 4f;   // ふさぐ長さ（m）

            for (int i = 0; i < builder.Aisles.Count; i++)
            {
                StoreBuilder.AisleInfo a = builder.Aisles[i];
                if (!a.narrow) continue;

                Bounds z = a.zone;
                bool northSouth = z.size.z >= z.size.x;
                Vector3 center = new Vector3(z.center.x, builder.layout.wallHeight * 0.5f, z.center.z);
                Vector3 size = northSouth
                    ? new Vector3(z.size.x + 0.2f, builder.layout.wallHeight, blockLen)
                    : new Vector3(blockLen, builder.layout.wallHeight, z.size.z + 0.2f);

                GameObject blocker = GameObject.CreatePrimitive(PrimitiveType.Cube);
                blocker.name = "DeadEndTestBlocker";
                blocker.transform.SetParent(
                    builder.NotWalkableRoots.Count > 0 ? builder.NotWalkableRoots[0] : builder.transform, true);
                blocker.transform.position = center;
                blocker.transform.localScale = size;

                // ふさいだ箱の「侵食の影」も、ふさいだ場所として数えない
                Bounds blockBounds = new Bounds(center, size);
                float shadow = (builder.layout.navAgentRadius + 0.05f) * 2f;
                blockBounds.size = new Vector3(blockBounds.size.x + shadow,
                                               blockBounds.size.y,
                                               blockBounds.size.z + shadow);
                baker.Bake();

                int unreachable = CountUnreachable(builder, from, sampleRadius, blockBounds, filter);
                tested++;
                if (unreachable > 0)
                {
                    bad++;
                    sb.Append("  ").Append(a.name).Append(" をふさぐと、ふさいだ場所の外の卓 ")
                      .Append(unreachable).Append(" 個に届かない\n");
                }

                UnityEngine.Object.DestroyImmediate(blocker);
            }

            baker.Bake();   // もとに戻す

            sb.Append("細い通路 ").Append(tested).Append(" 本を1か所ずつふさいで確かめた → ");
            sb.Append(bad == 0
                ? "どれをふさいでも、ほかの全卓に届く（行き止まりなし）"
                : bad.ToString() + " 本で届かない卓が出た");
            return sb.ToString();
        }

        // ---------------- ロボットの経路が、ロボット用の面だけを通っているか ----------------

        /// <summary>
        /// 受け渡し口から全卓への経路を引き、その経路の点が
        /// ぜんぶロボット用の面の上にあるかを確かめる。
        /// NavMesh が2枚になったので、取りちがえていれば人用の面を通ってしまう。
        /// </summary>
        static string CheckRobotPathsOnRobotMesh(StoreBuilder builder, NavMeshBaker baker)
        {
            NavMeshQueryFilter robot = baker.FilterFor(baker.RobotAgentTypeId);
            float snap = Mathf.Max(0.05f, builder.layout.navVoxelSize);

            int paths = 0, corners = 0, off = 0, noPath = 0;
            List<string> worst = new List<string>();
            Vector3 from = builder.PassPoint;

            NavMeshHit h;
            if (NavMesh.SamplePosition(from, out h, builder.layout.navAgentRadius * 4f, robot))
                from = h.position;

            IList<StoreBuilder.TableInfo> tables = builder.Tables;
            NavMeshPath path = new NavMeshPath();
            for (int i = 0; i < tables.Count; i++)
            {
                Vector3 to = tables[i].deliveryPoint;
                NavMeshHit ht;
                if (NavMesh.SamplePosition(to, out ht, builder.layout.navAgentRadius * 4f, robot))
                    to = ht.position;

                if (!NavMesh.CalculatePath(from, to, robot, path) ||
                    path.status != NavMeshPathStatus.PathComplete)
                {
                    noPath++;
                    continue;
                }
                paths++;

                Vector3[] c = path.corners;
                for (int k = 0; k < c.Length; k++)
                {
                    corners++;
                    NavMeshHit hit;
                    bool on = NavMesh.SamplePosition(c[k], out hit, snap, robot) &&
                              (new Vector2(hit.position.x - c[k].x, hit.position.z - c[k].z)).magnitude
                                  <= snap * 0.8f;
                    if (on) continue;
                    off++;
                    if (worst.Count < 8)
                        worst.Add("T" + tables[i].tableId.ToString("00") + "(" +
                                  F(c[k].x) + "," + F(c[k].z) + ")");
                }
            }

            StringBuilder sb = new StringBuilder();
            sb.Append("経路 ").Append(paths).Append(" 本 / 経路の点 ").Append(corners)
              .Append(" 個を、ロボット用の面（半径 ").Append(F(builder.layout.navAgentRadius))
              .Append(" m）で確かめた\n");
            if (noPath > 0) sb.Append("  経路が引けなかった卓: ").Append(noPath).Append("\n");
            sb.Append("  ロボット用の面に乗っていない点: ").Append(off).Append(" 個");
            if (off == 0) sb.Append(" → ロボットはロボット用の面だけを通っている");
            else
            {
                sb.Append("  ← 直す必要あり\n  例: ");
                for (int i = 0; i < worst.Count; i++) sb.Append(worst[i]).Append(' ');
            }
            return sb.ToString();
        }

        // ---------------- レジの内側（店員側）に入れるか ----------------

        /// <summary>
        /// 会計のスタッフが立つ点が、ほんとうに歩ける面の上にあるかを測る。
        ///   ・NavMesh.SamplePosition で別の場所に寄せられていないか（寄せられた距離を出す）
        ///   ・寄せられた先が店員側の中かどうか
        ///   ・店の奥（太い通路）から、その点まで歩いて行けるか
        ///   ・店員側と、その入口の歩ける幅（焼いた面で測る）
        /// 人用（半径 0.2 m）とロボット用（0.25 m）の両方で出す。
        /// </summary>
        static string CheckRegisterBack(StoreBuilder builder, NavMeshBaker baker)
        {
            PlanRect back = builder.RegisterBackRect;
            if (back.IsEmpty) return "レジの内側がありません（registerBackDepthZ を見る）";

            Vector3 want = builder.RegisterBackPoint;
            StringBuilder sb = new StringBuilder();
            sb.Append("店員側: ").Append(back.ToString())
              .Append("（").Append(F(back.SizeX)).Append(" × ").Append(F(back.SizeZ)).Append(" m）\n");
            sb.Append("  スタッフが立つ点: (").Append(F(want.x)).Append(", ").Append(F(want.z)).Append(")\n");

            sb.Append(BackFor(builder, baker.FilterFor(baker.PeopleAgentTypeId),
                              baker.PeopleRadius, "人用", back, want));
            sb.Append(BackFor(builder, baker.FilterFor(baker.RobotAgentTypeId),
                              builder.layout.navAgentRadius, "ロボット用", back, want));
            return sb.ToString();
        }

        static string BackFor(StoreBuilder builder, NavMeshQueryFilter filter, float radius,
                              string label, PlanRect back, Vector3 want)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append("  ").Append(label).Append("（半径 ").Append(F(radius)).Append(" m）: ");

            // 1. 立つ点が歩ける面の上にあるか。寄せられたら、その距離を出す
            NavMeshHit hit;
            if (!NavMesh.SamplePosition(want, out hit, 2.0f, filter))
            {
                sb.Append("立つ点のまわり 2 m に歩ける面が無い  ← 入れない\n");
                return sb.ToString();
            }
            Vector2 d = new Vector2(hit.position.x - want.x, hit.position.z - want.z);
            bool inside = back.Contains(hit.position);
            sb.Append("寄せられた先 (").Append(F(hit.position.x)).Append(", ").Append(F(hit.position.z))
              .Append(") / 元からの距離 ").Append(F(d.magnitude)).Append(" m / ")
              .Append(inside ? "店員側の中" : "店員側の外  ← 入れない");

            // 2. 店の奥（太い通路）から歩いて行けるか
            string status;
            Vector3 from = builder.PassPoint;
            float len = NavMeshBaker.PathLength(from, want, radius * 4f, filter, out status);
            sb.Append(" / 受け渡し口から ").Append(len >= 0f ? F(len) + " m で行ける"
                                                          : "行けない（" + status + "）  ← 入れない");
            sb.Append('\n');

            // 3. 店員側と入口の歩ける幅
            float look = builder.layout != null ? builder.layout.navVoxelSize : 0.05f;
            float widthX = WalkRun(back, true, back.CenterZ, filter, look);
            float widthZ = WalkRun(back, false, back.CenterX, filter, look);
            float openZ = WalkRun(back, false, back.xMin + 0.03f, filter, look);   // 西の入口のところ
            sb.Append("    歩ける幅: 東西 ").Append(F(widthX)).Append(" m / 南北 ").Append(F(widthZ))
              .Append(" m / 入口（西のふち）").Append(F(openZ)).Append(" m");
            if (openZ < 0.7f - 0.0005f) sb.Append("  ← 入口が 0.7 m 未満。registerBackDepthZ を広げる");
            sb.Append('\n');
            return sb.ToString();
        }

        /// <summary>
        /// その線の上で、続けて歩けるいちばん長い区間（m）。
        /// alongX が true なら東西に、false なら南北に走る。
        /// </summary>
        static float WalkRun(PlanRect r, bool alongX, float fixedValue, NavMeshQueryFilter filter,
                             float sampleRadius)
        {
            const float Step = 0.02f;
            // 焼いた面は床より少し上に出る（この店では y=0.03 m）。探す半径が刻みと同じ 0.02 m
            // だと届かず、どこを打っても「乗っていない」になって歩ける幅が 0 m と出ていた。
            // 横のずれは下の Step*0.8 で見るので、探す半径だけ navVoxelSize 以上にする。
            float look = Mathf.Max(Step, sampleRadius);
            float from = alongX ? r.xMin : r.zMin;
            float to = alongX ? r.xMax : r.zMax;

            float run = 0f, best = 0f;
            for (float u = from; u <= to; u += Step)
            {
                Vector3 p = alongX ? new Vector3(u, 0f, fixedValue) : new Vector3(fixedValue, 0f, u);
                NavMeshHit hit;
                bool on = NavMesh.SamplePosition(p, out hit, look, filter) &&
                          (new Vector2(hit.position.x - p.x, hit.position.z - p.z)).magnitude <= Step * 0.8f;
                if (on) { run += Step; if (run > best) best = run; }
                else run = 0f;
            }
            return best;
        }

        // ---------------- 細い通路の、焼いた歩ける幅 ----------------

        /// <summary>
        /// 細い通路1本ずつ、実際に焼いた NavMesh の歩ける幅（いちばん狭いところ）を測る。
        /// 計算上の値（通路の幅 − 通行半径×2）と並べて出す。
        /// 足りない通路では、その場所で左右に当たったものの名前も出す
        /// （どの当たりが大きいのかを突き止めるため）。
        /// </summary>
        static string CheckAisleWidths(StoreBuilder builder, NavMeshQueryFilter filter,
                                       float agentRadius, string label)
        {
            const float Across = 0.02f;   // 幅の刻み
            const float Along = 0.10f;    // 長手方向の刻み
            // 焼いた面は床より少し上に出るので、探す半径は navVoxelSize 以上にする
            // （0.02 m のままだと届かず、歩ける幅が 0 m と誤報になっていた）
            float look = Mathf.Max(Across, builder.layout != null ? builder.layout.navVoxelSize : 0.05f);

            StringBuilder sb = new StringBuilder();
            sb.Append(label).Append("（通行半径 ").Append(F(agentRadius)).Append(" m）: ")
              .Append("計算上は「通路の幅 − ").Append(F(agentRadius * 2f)).Append(" m」\n");

            int bad = 0, tested = 0;
            IList<StoreBuilder.AisleInfo> aisles = builder.Aisles;
            for (int i = 0; i < aisles.Count; i++)
            {
                StoreBuilder.AisleInfo a = aisles[i];
                if (!a.narrow) continue;
                tested++;

                Bounds z = a.zone;
                bool northSouth = z.size.z >= z.size.x;
                float width = northSouth ? z.size.x : z.size.z;
                float want = Mathf.Max(0f, width - agentRadius * 2f);

                float minRun = float.MaxValue;
                Vector3 worstAt = z.center;

                float alongMin = northSouth ? z.min.z : z.min.x;
                float alongMax = northSouth ? z.max.z : z.max.x;
                for (float t = alongMin + Along * 0.5f; t <= alongMax; t += Along)
                {
                    float run = 0f, best = 0f;
                    float acrossMin = northSouth ? z.min.x : z.min.z;
                    float acrossMax = northSouth ? z.max.x : z.max.z;
                    for (float u = acrossMin; u <= acrossMax; u += Across)
                    {
                        Vector3 p = northSouth ? new Vector3(u, 0f, t) : new Vector3(t, 0f, u);
                        NavMeshHit hit;
                        bool on = NavMesh.SamplePosition(p, out hit, look, filter) &&
                                  (new Vector2(hit.position.x - p.x, hit.position.z - p.z)).magnitude
                                      <= Across * 0.8f;
                        if (on) { run += Across; if (run > best) best = run; }
                        else run = 0f;
                    }
                    if (best < minRun)
                    {
                        minRun = best;
                        worstAt = northSouth ? new Vector3(z.center.x, 0f, t) : new Vector3(t, 0f, z.center.z);
                    }
                }
                if (minRun == float.MaxValue) minRun = 0f;

                bool ng = minRun < want - 0.05f;
                if (ng) bad++;
                sb.Append("  ").Append(a.name).Append(": 通路 ").Append(F(width))
                  .Append(" m / 計算上 ").Append(F(want))
                  .Append(" m / 焼いた面のいちばん狭いところ ").Append(F(minRun)).Append(" m");
                if (ng)
                {
                    sb.Append("  ← ").Append(F(want - minRun)).Append(" m 足りない。")
                      .Append(SideHits(worstAt, northSouth));
                }
                sb.Append('\n');
            }

            sb.Append("  細い通路 ").Append(tested).Append(" 本 → ")
              .Append(bad == 0 ? "どれも計算上の幅どおり"
                               : bad.ToString() + " 本が計算上より狭い ← 直す必要あり");
            return sb.ToString();
        }

        /// <summary>いちばん狭かった場所で、左右に当たったものの名前と距離</summary>
        static string SideHits(Vector3 at, bool northSouth)
        {
            Vector3 dir = northSouth ? Vector3.right : Vector3.forward;
            string left = Probe(at, -dir), right = Probe(at, dir);
            return "左右にあるもの: " + left + " / " + right;
        }

        static string Probe(Vector3 at, Vector3 dir)
        {
            float[] heights = { 0.1f, 0.3f, 0.6f, 1.0f };
            string best = "何もない";
            float bestD = float.MaxValue;
            for (int i = 0; i < heights.Length; i++)
            {
                RaycastHit hit;
                Vector3 o = new Vector3(at.x, heights[i], at.z);
                if (!Physics.Raycast(o, dir, out hit, 3f, ~0, QueryTriggerInteraction.Ignore)) continue;
                if (hit.distance >= bestD) continue;
                bestD = hit.distance;
                best = hit.collider.transform.name + " " + F(hit.distance) + "m(y=" + F(heights[i]) + ")";
            }
            return best;
        }

        // ---------------- 入らない部屋に NavMesh が入っていないか ----------------

        /// <summary>
        /// 厨房・事務所のように「通れない」はずの場所に、焼いた NavMesh の面が
        /// 入り込んでいないかを測る（面積で出す）。
        /// 入り込んでいれば、その場所を囲む箱の当たりが足りていない。
        /// </summary>
        static string CheckNoGoRooms(StoreBuilder builder, NavMeshTriangulation tri)
        {
            StorePlan plan = builder.Plan;
            if (plan == null) return "店が組み立てられていません";

            int triCount = tri.indices.Length / 3;

            StringBuilder sb = new StringBuilder();
            int bad = 0;
            for (int i = 0; i < plan.places.Count; i++)
            {
                PlanPlace p = plan.places[i];
                if (!p.solid || p.rect.IsEmpty) continue;

                float area = 0f;
                for (int t = 0; t < triCount; t++)
                {
                    Vector3 a = tri.vertices[tri.indices[t * 3]];
                    Vector3 b = tri.vertices[tri.indices[t * 3 + 1]];
                    Vector3 c = tri.vertices[tri.indices[t * 3 + 2]];
                    Vector3 mid = (a + b + c) / 3f;
                    if (!p.rect.Contains(mid)) continue;
                    area += Vector3.Cross(b - a, c - a).magnitude * 0.5f;
                }

                string nm = StoreBuilder.PlaceName(p.kind);
                sb.Append("  ").Append(nm.Length > 0 ? nm : p.name)
                  .Append("（").Append(p.rect.ToString()).Append("）: 歩ける面 ")
                  .Append(F(area)).Append(" m2");
                if (area > 0.05f) { sb.Append("  ← 入らないはずの場所に NavMesh がある。直す必要あり"); bad++; }
                sb.Append('\n');
            }

            sb.Append("通れない場所 ").Append(bad > 0 ? bad.ToString() + " か所に NavMesh が入り込んでいる"
                                                     : "すべてに NavMesh は入っていない");
            return sb.ToString();
        }

        // ---------------- 席に入れるかの確かめ ----------------

        /// <summary>
        /// 席ごとの「人が立つ点」まで、通路から歩いて行けるかを確かめる。
        /// 卓と卓のあいだのすき間が狭いと、壁側の席に人が入れない（仕切りと同じことになる）。
        ///
        /// 焼いてある NavMesh は通行半径 navAgentRadius（ロボットに合わせた 0.25 m）で
        /// 削ってあり、人（半径 0.2 m）より厳しい。ここを通れれば人も通れる。
        /// 点を NavMesh に寄せる距離も navAgentRadius までにして、
        /// 離れた通路へ飛び移って「通れる」ことにならないようにする。
        /// </summary>
        static string CheckSeatAccess(StoreBuilder builder)
        {
            float sampleRadius = builder.layout.navAgentRadius;
            Vector3 from = builder.PassPoint;
            NavMeshQueryFilter filter = PeopleNav.Filter;   // 席に着くのは人

            int spots = 0, bad = 0;
            List<string> badNames = new List<string>();
            IList<StoreBuilder.TableInfo> tables = builder.Tables;

            for (int i = 0; i < tables.Count; i++)
            {
                StoreBuilder.TableInfo t = tables[i];
                for (int s = 0; s < t.spots.Count; s++)
                {
                    PlanSeatSpot spot = t.spots[s];
                    spots++;
                    string status;
                    float len = NavMeshBaker.PathLength(from, spot.point, sampleRadius, filter, out status);
                    if (len >= 0f) continue;

                    bad++;
                    if (badNames.Count < 24)
                        badNames.Add("T" + t.tableId.ToString("00") + "/" + spot.name +
                                     "(" + F(spot.point.x) + "," + F(spot.point.z) + " " + status + ")");
                }
            }

            StringBuilder sb = new StringBuilder();
            sb.Append("人が立つ点 ").Append(spots).Append(" 個（").Append(tables.Count)
              .Append(" 卓ぶん）を、通行半径 ").Append(F(sampleRadius)).Append(" m で確かめた\n");
            if (bad == 0)
            {
                sb.Append("  すべての席に、通路から入れる");
                return sb.ToString();
            }
            sb.Append("  入れない席 ").Append(bad).Append(" 個 ← 直す必要あり\n  ");
            for (int i = 0; i < badNames.Count; i++) sb.Append(badNames[i]).Append(' ');
            if (bad > badNames.Count) sb.Append("…ほか ").Append(bad - badNames.Count).Append(" 個");
            return sb.ToString();
        }

        /// <summary>
        /// 受け渡し口から届かない卓の数。
        /// skip の中に停止点がある卓は、ふさいだ場所そのものなので数えない。
        /// </summary>
        static int CountUnreachable(StoreBuilder builder, Vector3 from, float sampleRadius, Bounds? skip,
                                    NavMeshQueryFilter filter)
        {
            int bad = 0;
            IList<StoreBuilder.TableInfo> tables = builder.Tables;
            for (int i = 0; i < tables.Count; i++)
            {
                Vector3 p = tables[i].deliveryPoint;
                if (skip.HasValue)
                {
                    Bounds b = skip.Value;
                    if (p.x >= b.min.x && p.x <= b.max.x && p.z >= b.min.z && p.z <= b.max.z) continue;
                }
                string status;
                if (NavMeshBaker.PathLength(from, p, sampleRadius, filter, out status) < 0f) bad++;
            }
            return bad;
        }

        // ---------------- 焼いた NavMesh を調べる ----------------

        /// <summary>
        /// 実際に焼けた NavMesh（NavMesh.CalculateTriangulation）を調べて、
        /// 受け渡し口からつながっていない歩ける面が無いかを確かめる。
        /// ベンチや卓の上面が歩ける場所になっていれば、ここに出る。
        /// </summary>
        static string CheckNavMeshSurface(StoreBuilder builder, NavMeshTriangulation tri, string label,
                                          NavMeshQueryFilter filter)
        {
            int triCount = tri.indices.Length / 3;
            if (triCount == 0) return "NavMesh の三角形が 0 です（焼けていません）";

            // 同じ位置の頂点をまとめる（1 mm 刻み）
            Dictionary<long, int> vmap = new Dictionary<long, int>();
            int[] vid = new int[tri.vertices.Length];
            for (int i = 0; i < tri.vertices.Length; i++)
            {
                long key = QuantKey(tri.vertices[i]);
                int id;
                if (!vmap.TryGetValue(key, out id)) { id = vmap.Count; vmap.Add(key, id); }
                vid[i] = id;
            }

            float[] triArea = new float[triCount];
            Vector3[] triMid = new Vector3[triCount];
            Dictionary<long, List<int>> edgeMap = new Dictionary<long, List<int>>();
            float total = 0f, maxY = float.MinValue;

            for (int t = 0; t < triCount; t++)
            {
                int a = tri.indices[t * 3], b = tri.indices[t * 3 + 1], c = tri.indices[t * 3 + 2];
                Vector3 pa = tri.vertices[a], pb = tri.vertices[b], pc = tri.vertices[c];
                triArea[t] = Vector3.Cross(pb - pa, pc - pa).magnitude * 0.5f;
                triMid[t] = (pa + pb + pc) / 3f;
                total += triArea[t];
                maxY = Mathf.Max(maxY, Mathf.Max(pa.y, Mathf.Max(pb.y, pc.y)));
                AddEdge(edgeMap, vid[a], vid[b], t);
                AddEdge(edgeMap, vid[b], vid[c], t);
                AddEdge(edgeMap, vid[c], vid[a], t);
            }

            // 受け渡し口にいちばん近い三角形から広げる
            Vector3 from = builder.PassPoint;
            NavMeshHit hit;
            if (NavMesh.SamplePosition(from, out hit, builder.layout.navAgentRadius * 4f, filter))
                from = hit.position;

            int start = -1;
            float best = float.MaxValue;
            for (int t = 0; t < triCount; t++)
            {
                float d = (triMid[t] - from).sqrMagnitude;
                if (d < best) { best = d; start = t; }
            }

            bool[] seen = new bool[triCount];
            Queue<int> q = new Queue<int>();
            seen[start] = true; q.Enqueue(start);
            float connected = triArea[start];
            while (q.Count > 0)
            {
                int t = q.Dequeue();
                int a = tri.indices[t * 3], b = tri.indices[t * 3 + 1], c = tri.indices[t * 3 + 2];
                Walk(edgeMap, vid[a], vid[b], seen, q, triArea, ref connected);
                Walk(edgeMap, vid[b], vid[c], seen, q, triArea, ref connected);
                Walk(edgeMap, vid[c], vid[a], seen, q, triArea, ref connected);
            }

            // つながっていない面の内訳
            float orphan = 0f;
            float orphanMaxY = float.MinValue;
            Dictionary<int, float> byArea = new Dictionary<int, float>();
            List<string> worst = new List<string>();
            for (int t = 0; t < triCount; t++)
            {
                int area = t < tri.areas.Length ? tri.areas[t] : 0;
                if (!byArea.ContainsKey(area)) byArea[area] = 0f;
                byArea[area] += triArea[t];
                if (seen[t]) continue;
                orphan += triArea[t];
                orphanMaxY = Mathf.Max(orphanMaxY, triMid[t].y);
                if (worst.Count < 6 && triArea[t] > 0.02f)
                    worst.Add("(" + F(triMid[t].x) + "," + F(triMid[t].y) + "," + F(triMid[t].z) + ") " + F(triArea[t]) + "m2");
            }

            StringBuilder sb = new StringBuilder();
            sb.Append(label).Append("：三角形 ").Append(triCount)
              .Append(" / 歩ける面 ").Append(F(total)).Append(" m2\n");
            sb.Append("  受け渡し口からつながる面: ").Append(F(connected)).Append(" m2\n");
            sb.Append("  つながっていない面: ").Append(F(orphan)).Append(" m2");
            if (orphan > 0.01f) sb.Append("  ← いちばん高いところ y=").Append(F(orphanMaxY)).Append(" m");
            sb.Append('\n');
            sb.Append("  いちばん高い歩ける面: y=").Append(F(maxY)).Append(" m\n");
            sb.Append("  area の内訳: ");
            foreach (KeyValuePair<int, float> kv in byArea)
                sb.Append("area").Append(kv.Key).Append("=").Append(F(kv.Value)).Append("m2 ");
            if (worst.Count > 0)
            {
                sb.Append("\n  つながっていない面の例: ");
                for (int i = 0; i < worst.Count; i++) sb.Append(worst[i]).Append(' ');
            }
            return sb.ToString();
        }

        static long QuantKey(Vector3 p)
        {
            long x = (long)Mathf.RoundToInt(p.x * 1000f) + 1048576;
            long y = (long)Mathf.RoundToInt(p.y * 1000f) + 1048576;
            long z = (long)Mathf.RoundToInt(p.z * 1000f) + 1048576;
            return (x << 42) ^ (y << 21) ^ z;
        }

        static long EdgeKey(int a, int b)
        {
            int lo = a < b ? a : b, hi = a < b ? b : a;
            return ((long)lo << 32) | (uint)hi;
        }

        static void AddEdge(Dictionary<long, List<int>> map, int a, int b, int tri)
        {
            long k = EdgeKey(a, b);
            List<int> list;
            if (!map.TryGetValue(k, out list)) { list = new List<int>(2); map.Add(k, list); }
            list.Add(tri);
        }

        static void Walk(Dictionary<long, List<int>> map, int a, int b,
                         bool[] seen, Queue<int> q, float[] area, ref float sum)
        {
            List<int> list;
            if (!map.TryGetValue(EdgeKey(a, b), out list)) return;
            for (int i = 0; i < list.Count; i++)
            {
                int t = list[i];
                if (seen[t]) continue;
                seen[t] = true;
                sum += area[t];
                q.Enqueue(t);
            }
        }

        // ---------------- 真上からの見通し ----------------

        static string CheckOverhead(StoreBuilder builder)
        {
            Physics.SyncTransforms();

            List<Vector3> points = new List<Vector3>();
            points.Add(builder.PassPoint);
            IList<StoreBuilder.TableInfo> tables = builder.Tables;
            for (int i = 0; i < tables.Count; i++) points.Add(tables[i].deliveryPoint);

            float startY = builder.layout.wallHeight + 5f;
            Dictionary<string, int> firstHit = new Dictionary<string, int>();
            List<string> covered = new List<string>();
            int missed = 0;

            for (int i = 0; i < points.Count; i++)
            {
                Vector3 from = new Vector3(points[i].x, startY, points[i].z);
                RaycastHit hit;
                if (!Physics.Raycast(from, Vector3.down, out hit, startY + 5f, ~0, QueryTriggerInteraction.Ignore))
                {
                    missed++;
                    continue;
                }
                string name = hit.collider.transform.name;
                if (!firstHit.ContainsKey(name)) firstHit[name] = 0;
                firstHit[name]++;
                if (hit.point.y > 0.3f) covered.Add(name + " (y=" + F(hit.point.y) + ")");
            }

            StringBuilder sb = new StringBuilder();
            sb.Append("調べた点 ").Append(points.Count).Append("（受け渡し口＋全卓の停止点）\n");
            foreach (KeyValuePair<string, int> kv in firstHit)
                sb.Append("  最初に当たったもの: ").Append(kv.Key).Append(" × ").Append(kv.Value).Append('\n');
            if (missed > 0) sb.Append("  何にも当たらなかった点: ").Append(missed).Append('\n');
            if (covered.Count == 0) sb.Append("  上を覆うもの: なし（天井・屋根はありません）");
            else
            {
                sb.Append("  上を覆うもの: ").Append(covered.Count).Append(" 点 → ");
                for (int i = 0; i < covered.Count && i < 8; i++) sb.Append(covered[i]).Append(' ');
            }
            return sb.ToString();
        }

        // ---------------- 道具 ----------------

        static bool OpenSceneAndFind(out StoreBuilder builder, out NavMeshBaker baker,
                                     out PeopleSpawner spawner, out Transform robotsRoot)
        {
            builder = null; baker = null; spawner = null; robotsRoot = null;

            Scene active = SceneManager.GetActiveScene();
            if (active.path != ServiceSimSetup.ScenePath)
            {
                if (!File.Exists(ServiceSimSetup.ScenePath))
                {
                    Debug.LogError("[ServiceSim] シーンがありません。先に ServiceSim/Setup を実行してください");
                    return false;
                }
                if (!EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo())
                {
                    Debug.Log("[ServiceSim] 取りやめました");
                    return false;
                }
                EditorSceneManager.OpenScene(ServiceSimSetup.ScenePath, OpenSceneMode.Single);
            }

            builder = UnityEngine.Object.FindObjectOfType<StoreBuilder>();
            baker = UnityEngine.Object.FindObjectOfType<NavMeshBaker>();
            spawner = UnityEngine.Object.FindObjectOfType<PeopleSpawner>();

            if (builder == null || baker == null || spawner == null)
            {
                Debug.LogError("[ServiceSim] シーンに StoreBuilder / NavMeshBaker / PeopleSpawner が見つかりません。ServiceSim/Setup を実行してください");
                return false;
            }

            Transform root = builder.transform.parent;
            if (root != null)
            {
                Transform t = root.Find("Robots");
                if (t == null)
                {
                    GameObject go = new GameObject("Robots");
                    go.transform.SetParent(root, false);
                    t = go.transform;
                }
                robotsRoot = t;
            }
            else robotsRoot = builder.transform;

            return true;
        }

        static Camera FindCamera(StoreBuilder builder, string name)
        {
            Transform root = builder.transform.parent;
            if (root == null) return null;
            Transform cams = root.Find("Cameras");
            if (cams == null) return null;
            Transform t = cams.Find(name);
            return t != null ? t.GetComponent<Camera>() : null;
        }

        static void ClearChildren(Transform parent)
        {
            if (parent == null) return;
            for (int i = parent.childCount - 1; i >= 0; i--)
                UnityEngine.Object.DestroyImmediate(parent.GetChild(i).gameObject);
        }

        static int SpawnPreviewRobots(Transform robotsRoot, StoreBuilder builder, int count)
        {
            GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(ServiceSimSetup.Root + "/Robot/ServingRobot.prefab");
            if (prefab == null || robotsRoot == null) return 0;

            int n = 0;
            for (int i = 0; i < count; i++)
            {
                GameObject go = PrefabUtility.InstantiatePrefab(prefab, robotsRoot) as GameObject;
                if (go == null) continue;
                go.name = "ServingRobot" + (i + 1).ToString("00");
                go.transform.position = builder.PassPoint + new Vector3(0f, 0f, -0.7f * i);
                go.transform.rotation = Quaternion.LookRotation(Vector3.back, Vector3.up);
                ServingRobotController c = go.GetComponent<ServingRobotController>();
                if (c != null) c.robotId = i + 1;
                n++;
            }
            return n;
        }

        /// <summary>人の半径（PeopleSpec の値。読めなければ 0.2 m）</summary>
        static float PersonRadius()
        {
            PeopleSpec p = AssetDatabase.LoadAssetAtPath<PeopleSpec>(ServiceSimSetup.PeopleSpecPath);
            return p != null ? p.personRadius : 0.2f;
        }

        /// <summary>床の塗り分けの報告</summary>
        static string PaintText(StoreLayout layout, FloorPaint.Result r)
        {
            if (layout != null && !layout.showFloorPaint) return "出していません（showFloorPaint が OFF）";
            if (!r.ok) return "作れませんでした: " + (string.IsNullOrEmpty(r.note) ? "-" : r.note);

            float pr = PersonRadius();
            return "ロボットの体が入れる床（水色）: " + F(r.robotAreaM2) + " m2（" + r.robotQuads + " 枚）\n" +
                   "人だけが入れる床（薄い黄色）: " + F(r.peopleAreaM2) + " m2（" + r.peopleQuads + " 枚）\n" +
                   "作り方: ロボット用 NavMesh（半径 " + F(layout.navAgentRadius) + " m）を " +
                   F(layout.navAgentRadius) + " m 外へ広げた範囲／" +
                   "人用 NavMesh（半径 " + F(pr) + " m）を " + F(pr) + " m 広げた範囲から前者を引いた範囲。" +
                   "マス " + F(r.cellSize) + " m\n" +
                   "すき間が " + F(layout.navAgentRadius * 2f) + " m より狭いところは、" +
                   "ロボット用 NavMesh が無いので水色にならない";
        }

        /// <summary>
        /// 場所の名前と、それを置く位置（その場所の床の真ん中）の一覧。
        /// 表示は Scene ビューの吹き出しと同じ作り（画面に正対・ズームで大きさが変わらない）。
        /// </summary>
        static string PlaceNamesReport(StoreBuilder builder)
        {
            List<string> names = new List<string>();
            List<Vector3> at = new List<Vector3>();
            ServiceSimLabels.CollectPlaceNames(builder, names, at);

            StringBuilder sb = new StringBuilder();
            sb.Append(names.Count).Append(" 個（Scene ビューに、吹き出しと同じ作りで出す。")
              .Append("小さい字・薄い背景・枠つき。置く位置はその場所の床の真ん中）");
            for (int i = 0; i < names.Count; i++)
                sb.Append("\n  ").Append(names[i]).Append(": (")
                  .Append(F(at[i].x)).Append(", ").Append(F(at[i].z)).Append(")");
            return sb.ToString();
        }

        static string Mm(float meters) { return Mathf.RoundToInt(meters * 100f).ToString("000"); }
        static string F(float v) { return v.ToString("0.##", CultureInfo.InvariantCulture); }

        static string KindName(SeatKind k)
        {
            switch (k)
            {
                case SeatKind.WallBooth: return "booth";
                case SeatKind.WallTable: return "wall";
                case SeatKind.IslandTable: return "island";
                case SeatKind.Counter: return "counter";
                default: return "large";
            }
        }

        // ---------------- 書き出し ----------------

        static void WriteTableCsv(string path, LayoutResult r)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append("table_id,seat_kind,aisle,table_x,table_z,delivery_x,delivery_z");
            for (int i = 0; i < r.widths.Length; i++) sb.Append(",path_m_w").Append(Mm(r.widths[i]));
            for (int i = 0; i < r.widths.Length; i++) sb.Append(",status_w").Append(Mm(r.widths[i]));
            sb.Append('\n');

            for (int i = 0; i < r.tableIds.Count; i++)
            {
                int id = r.tableIds[i];
                sb.Append(id).Append(',')
                  .Append(KindName(r.kinds[id])).Append(',')
                  .Append(r.aisleNames.ContainsKey(id) ? r.aisleNames[id] : "").Append(',')
                  .Append(F(r.centers[id].x)).Append(',').Append(F(r.centers[id].z)).Append(',')
                  .Append(F(r.deliveries[id].x)).Append(',').Append(F(r.deliveries[id].z));
                for (int w = 0; w < r.widths.Length; w++) sb.Append(',').Append(F(r.lengths[id][w]));
                for (int w = 0; w < r.widths.Length; w++) sb.Append(',').Append(r.statuses[id][w]);
                sb.Append('\n');
            }
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        static void WriteStoreReport(string path, StoreLayout layout, RobotSpec robotSpec,
                                     ExperimentPlan plan, StoreBuilder builder, PeopleSpawner spawner, int robots,
                                     LayoutResult main, LayoutResult grid,
                                     string shaderHow, string overhead, string deadEnd, string navSurface,
                                     string seatAccess, string paintText,
                                     string aisleWidths, string noGo, string regBack, string robotPaths,
                                     string placeNameText)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append("ServiceSim 店の報告（v1 ファミレス型）\n");
            sb.Append("作った日時: ").Append(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")).Append('\n');
            sb.Append("Unity: ").Append(Application.unityVersion).Append('\n');
            sb.Append("run_name: ").Append(plan.runName).Append("\n\n");

            sb.Append("描画方式\n").Append(ServiceSimRender.Describe());
            sb.Append("使うシェーダー: ").Append(shaderHow).Append("\n\n");

            sb.Append("いまの設定\n");
            sb.Append("  店の形: ").Append(layout.name).Append("（").Append(layout.StoreKindName).Append("）\n");
            sb.Append("  細い通路の幅: ").Append(F(layout.AisleWidth)).Append(" m\n");
            sb.Append("  通路の譲り合い: ").Append(robotSpec.aisleYielding ? "あり" : "なし").Append('\n');
            sb.Append("  1往復の打ち切り: ").Append(F(robotSpec.maxTripSec)).Append(" 秒\n\n");

            sb.Append("店の中身\n");
            sb.Append("  卓: ").Append(builder.Tables.Count).Append('\n');
            int booth = 0, wall = 0, island = 0;
            for (int i = 0; i < builder.Tables.Count; i++)
            {
                SeatKind k = builder.Tables[i].kind;
                if (k == SeatKind.WallBooth) booth++;
                else if (k == SeatKind.WallTable) wall++;
                else if (k == SeatKind.IslandTable) island++;
            }
            sb.Append("    壁際ブース ").Append(booth).Append(" / 壁沿いの卓 ").Append(wall)
              .Append(" / 島の卓 ").Append(island).Append('\n');

            int t2 = 0, t4 = 0, seats = 0;
            for (int i = 0; i < builder.Tables.Count; i++)
            {
                int n = builder.Tables[i].seatCount;
                seats += n;
                if (n <= 2) t2++; else t4++;
            }
            sb.Append("    2人席 ").Append(t2).Append(" / 4人席 ").Append(t4)
              .Append(" → 席の合計 ").Append(seats).Append('\n');
            sb.Append("  通路: ").Append(builder.Aisles.Count)
              .Append("（うち細い通路 ").Append(NarrowCount(builder)).Append("）\n");
            for (int i = 0; i < builder.Aisles.Count; i++)
            {
                StoreBuilder.AisleInfo a = builder.Aisles[i];
                sb.Append("    [").Append(i).Append("] ").Append(a.name)
                  .Append(" 幅 ").Append(F(a.width)).Append(" m")
                  .Append(a.narrow ? "（細い）" : "（太い・横通路）").Append('\n');
            }
            sb.Append("  場所の点: ");
            AppendPoint(sb, builder, PlaceKind.FoodPass, "受け渡し口");
            AppendPoint(sb, builder, PlaceKind.DishReturn, "返却口");
            AppendPoint(sb, builder, PlaceKind.Pantry, "パントリー");
            AppendPoint(sb, builder, PlaceKind.Register, "レジ");
            AppendPoint(sb, builder, PlaceKind.Toilet, "トイレ");
            AppendPoint(sb, builder, PlaceKind.DrinkBar, "ドリンクバー");
            AppendPoint(sb, builder, PlaceKind.Entrance, "入口");
            AppendPoint(sb, builder, PlaceKind.Waiting, "待合");
            sb.Append("\n\n");

            sb.Append("人（普通の混み具合）\n");
            sb.Append("  スタッフ ").Append(spawner.StaffSpawned)
              .Append(" / 座っている客（椅子を引く仕掛け） ").Append(spawner.SeatedSpawned).Append('\n');
            sb.Append("  ロボット（見た目の確認用） ").Append(robots).Append("\n\n");

            string frontFit = layout.FrontFitReport();
            if (frontFit.Length > 0)
                sb.Append("入口まわり（ドリンクバー・待合・レジ・入口）\n  ")
                  .Append(frontFit.Replace("\n", "\n  ")).Append("\n\n");

            string standby = layout.RobotStandbyReport();
            if (standby.Length > 0)
                sb.Append("ロボットの待機場所（B2）\n  ")
                  .Append(standby.Replace("\n", "\n  ")).Append("\n\n");

            sb.Append("床の塗り分け（見た目だけ。NavMesh は変えない）\n  ")
              .Append(paintText.Replace("\n", "\n  ")).Append("\n\n");

            sb.Append("場所の名前（真上の画面に出す文字）\n  ").Append(placeNameText).Append("\n\n");

            sb.Append("ロボットの経路が、ロボット用の面だけを通っているか\n  ")
              .Append(robotPaths.Replace("\n", "\n  ")).Append("\n\n");

            sb.Append("レジの内側（店員側）に入れるか\n  ")
              .Append(regBack.Replace("\n", "\n  ")).Append("\n\n");

            sb.Append("細い通路の、焼いた NavMesh の歩ける幅\n  ")
              .Append(aisleWidths.Replace("\n", "\n  ")).Append("\n\n");

            sb.Append("通れない場所に NavMesh が入っていないか\n  ")
              .Append(noGo.Replace("\n", "\n  ")).Append("\n\n");

            sb.Append("席に入れるかの確かめ（通路 → 席の前）\n  ")
              .Append(seatAccess.Replace("\n", "\n  ")).Append("\n\n");

            sb.Append("行き止まりの確かめ\n  ").Append(deadEnd.Replace("\n", "\n  ")).Append("\n\n");
            sb.Append("真上からの見通し（天井・屋根がないかの確認）\n  ").Append(overhead.Replace("\n", "\n  ")).Append("\n\n");
            sb.Append("焼いた NavMesh のしらべ（NavMesh.CalculateTriangulation）\n  ")
              .Append(navSurface.Replace("\n", "\n  ")).Append("\n\n");

            AppendLayoutSummary(sb, main);
            if (grid != null) AppendLayoutSummary(sb, grid);

            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        static void AppendPoint(StringBuilder sb, StoreBuilder builder, PlaceKind kind, string name)
        {
            if (!builder.HasPoint(kind)) return;
            Vector3 p = builder.PointOf(kind);
            sb.Append(name).Append("(").Append(F(p.x)).Append(",").Append(F(p.z)).Append(") ");
        }

        static void AppendLayoutSummary(StringBuilder sb, LayoutResult r)
        {
            sb.Append("=== ").Append(r.assetName).Append("（").Append(r.kindName).Append("）===\n");
            for (int w = 0; w < r.widths.Length; w++)
            {
                float min = float.MaxValue, max = float.MinValue, sum = 0f;
                int ok = 0, ng = 0, inBand = 0;
                for (int i = 0; i < r.tableIds.Count; i++)
                {
                    float v = r.lengths[r.tableIds[i]][w];
                    if (v < 0f) { ng++; continue; }
                    ok++; sum += v;
                    if (v < min) min = v;
                    if (v > max) max = v;
                    if (v >= 6f && v <= 22f) inBand++;
                }
                sb.Append("細い通路 ").Append(F(r.widths[w])).Append(" m\n");
                sb.Append("  外寸: ").Append(F(r.outerSizes[w].x)).Append(" × ")
                  .Append(F(r.outerSizes[w].y)).Append(" m（風除室を含む）\n");
                if (ok > 0)
                {
                    sb.Append("  道のり（受け渡し口 → 卓）: いちばん短い ").Append(F(min))
                      .Append(" m / いちばん長い ").Append(F(max))
                      .Append(" m / 平均 ").Append(F(sum / ok)).Append(" m\n");
                    sb.Append("  LOG351 の 6〜22 m に入る卓: ").Append(inBand).Append(" / ").Append(ok).Append('\n');
                }
                sb.Append("  測れた卓 ").Append(ok).Append(" / 測れなかった卓 ").Append(ng).Append('\n');
                if (ng > 0)
                {
                    sb.Append("  測れなかった卓: ");
                    for (int i = 0; i < r.tableIds.Count; i++)
                    {
                        int id = r.tableIds[i];
                        if (r.lengths[id][w] >= 0f) continue;
                        sb.Append("T").Append(id).Append("(").Append(r.statuses[id][w]).Append(") ");
                    }
                    sb.Append('\n');
                }
            }
            sb.Append('\n');
        }

        // ---------------- スクリーンショット ----------------

        static void Capture(Camera cam, int width, int height, string path)
        {
            if (cam == null) return;

            RenderTexture rt = new RenderTexture(width, height, 24, RenderTextureFormat.ARGB32);
            rt.antiAliasing = 4;

            RenderTexture prevTarget = cam.targetTexture;
            bool prevEnabled = cam.enabled;
            RenderTexture prevActive = RenderTexture.active;

            Texture2D tex = new Texture2D(width, height, TextureFormat.RGB24, false);
            try
            {
                cam.enabled = true;
                cam.targetTexture = rt;
                cam.Render();

                RenderTexture.active = rt;
                tex.ReadPixels(new Rect(0f, 0f, width, height), 0, 0);
                tex.Apply(false);

                File.WriteAllBytes(path, tex.EncodeToPNG());

                // 撮れているかを調べる。
                //  ・ピンク（1, 0, 1）が大半 → シェーダーが描画方式に合っていない
                //  ・真っ暗・真っ白 → そもそも描けていない
                Color32[] px = tex.GetPixels32();
                long sum = 0;
                int step = Mathf.Max(1, px.Length / 8192);
                int n = 0, magenta = 0;
                for (int i = 0; i < px.Length; i += step)
                {
                    Color32 c = px[i];
                    sum += c.r + c.g + c.b;
                    if (c.r > 180 && c.b > 180 && c.g < 80) magenta++;
                    n++;
                }
                float mean = n > 0 ? sum / (float)(n * 3) : 0f;
                float magentaRatio = n > 0 ? magenta / (float)n : 0f;

                if (magentaRatio >= 0.25f)
                    Debug.LogError("[ServiceSim] 撮った絵の " + (magentaRatio * 100f).ToString("0") +
                                   "% がピンク（1,0,1）です。シェーダーが描画方式に合っていません: " + path);
                else if (mean < 4f || mean > 251f)
                    Debug.LogError("[ServiceSim] 撮った絵がほぼ一色です（平均 " + mean.ToString("0.0") +
                                   "）。描けていない可能性があります: " + path);
            }
            catch (Exception e)
            {
                Debug.LogError("[ServiceSim] スクリーンショットに失敗: " + path + "\n" + e);
            }
            finally
            {
                RenderTexture.active = prevActive;
                cam.targetTexture = prevTarget;
                cam.enabled = prevEnabled;
                UnityEngine.Object.DestroyImmediate(tex);
                rt.Release();
                UnityEngine.Object.DestroyImmediate(rt);
            }
        }
    }
}
