using System; using System.Collections.Generic; using System.Globalization; using System.Text; using UnityEngine; namespace ServiceSim { /// /// サイトが書き出した「店の形の JSON」を読んで店を建てる StoreLayout。 /// /// 2026-10-22 にエイイチと決めた「何について正か」(unity_design_v0.md の 0 章): /// 店の形(卓・壁・部屋・止まる位置)は **サイトが正**。Unity は形を作らない。 /// なので、このクラスは JSON を StorePlan に写すだけで、足りないものを自分で埋めない。 /// 合わないものが出たら、埋めずに報告へ書いて **建てずに止める**(Compose が null を返す)。 /// /// 読むもの: /// layoutJson https://pixelstomotors.com/sim/layouts/unity/{名前}.json を写したもの /// summaryJson https://pixelstomotors.com/sim/layouts/summary.json を写したもの /// 版の番号(meta.generation)が summary.json と違ったら建てない。 /// /// 座標: JSON の x → Unity の X、JSON の y → Unity の Z、高さ → Y。単位は m。 /// 原点は店の外壁の内側の左下の角。 /// [CreateAssetMenu(fileName = "StoreLayout_Json", menuName = "ServiceSim/Json Store Layout", order = 12)] public class JsonStoreLayout : StoreLayout { [Header("読む JSON(サイトから写したもの)")] [Tooltip("店の形の JSON(unity/{名前}.json)")] public TextAsset layoutJson; [Tooltip("一覧の JSON(summary.json)。版の番号を突き合わせる")] public TextAsset summaryJson; [Tooltip("summary.json の file と突き合わせる名前(例 p1_efficient_corner)")] public string layoutFileName = ""; [Header("写した記録(報告に出す)")] [Tooltip("サイトから写した日時")] public string fetchedAt = ""; [Tooltip("写し元の URL")] public string sourceUrl = ""; [Header("見た目だけ(当たりも NavMesh も持たない)")] [Tooltip("ベンチの座面の見た目の厚み(m)。0 で出さない")] public float benchSeatPadThickness = 0.03f; [Tooltip("座面の見た目を、ベンチのふちから内側へ詰める長さ(m)")] public float benchSeatInset = 0.03f; // ---- 建てたときの覚え書き(報告に出す。Compose のたびに作り直す)---- readonly List _problems = new List(); readonly List _doors = new List(); string _generation = ""; string _storeName = ""; float _jsonNarrowWidth = 0f; float _normalWidth = 0f; int _tableCount, _seatCount, _narrowAisleCount, _chairCount, _spotCount, _seatPointCount; int _roomCount, _placeCount, _wallCount, _dividerCount; int _walkableRoomCount, _staffOnlyRoomCount; int _routeFromPass, _routeBetween; public override string StoreKindName { get { return "JsonStoreLayout(" + (string.IsNullOrEmpty(layoutFileName) ? "?" : layoutFileName) + ")"; } } /// この JSON の版の番号(meta.generation)。建てたあとに読める public string Generation { get { return _generation; } } /// /// 版の番号を、建てる前でも読む(試算の値と突き合わせるのに使う)。 /// 建てたあとは Compose が読んだものを返す。 /// public string ReadGeneration() { if (!string.IsNullOrEmpty(_generation)) return _generation; if (layoutJson == null) return ""; string err; JsonValue root = JsonValue.Parse(layoutJson.text, out err); return root != null && root.IsObject ? root["meta"]["generation"].S("") : ""; } /// 建てた卓の数(JSON の tables の数) public int TableCount { get { return _tableCount; } } /// /// サイトの決まりの「ふだん走らせる道の幅」(meta.settings.normal_width, m)。 /// これ未満の幅の通路が「狭い通路」。0 なら JSON に無い /// public float NormalWidth { get { return _normalWidth; } } /// /// 試算の値(serving-robot-params.json)の stores の名前(fam_p1 / fam_p5)。 /// layoutFileName(p1_efficient_corner / p5_crammed)の頭の "p1" "p5" から作る。 /// 合っているかは SimParams.Store() が null かどうかで分かる。 /// public string ParamsStoreKey { get { if (string.IsNullOrEmpty(layoutFileName)) return ""; int cut = layoutFileName.IndexOf('_'); string head = cut > 0 ? layoutFileName.Substring(0, cut) : layoutFileName; return "fam_" + head; } } /// /// Build Store が store_report と Console に出す欄。 /// JSON から建てたときは、版の番号・写した日時・数えたもの・合わなかったものをここに出す。 /// public override string FrontFitReport() { StringBuilder sb = new StringBuilder(); sb.AppendLine("JSON から建てた店(Unity では形を作っていない)"); sb.AppendLine(" ファイル: " + (layoutJson != null ? layoutJson.name : "(未設定)") + " / 名前: " + _storeName); sb.AppendLine(" 版の番号 (meta.generation): " + _generation); sb.AppendLine(" 写した日時: " + (string.IsNullOrEmpty(fetchedAt) ? "(未記入)" : fetchedAt)); sb.AppendLine(" 写し元: " + (string.IsNullOrEmpty(sourceUrl) ? "(未記入)" : sourceUrl)); sb.AppendLine(" 卓 " + _tableCount + " / 席 " + _seatCount + " / 細い通路 " + _narrowAisleCount + " 本(幅 " + F(_jsonNarrowWidth) + " m)"); sb.AppendLine(" 通れない部屋 " + _roomCount + " / 通れる部屋 " + _walkableRoomCount + "(うちスタッフだけ " + _staffOnlyRoomCount + ")"); sb.AppendLine(" 場所の点 " + _placeCount + " / 壁 " + _wallCount + " / 仕切り " + _dividerCount + " / 椅子 " + _chairCount + " / 席に着く点 " + _spotCount + " / 座る位置 " + _seatPointCount); sb.AppendLine(" 道順 (routes): 受け渡し口から " + _routeFromPass + " 本 / 卓から卓 " + _routeBetween + " 本" + "(狭い道を使うかは routes の use が正。Unity では印を持たない)"); sb.AppendLine(" doors: " + (_doors.Count == 0 ? "0(壁の切れ目として、もう空いている)" : _doors.Count + " → " + string.Join(" / ", _doors.ToArray()))); sb.AppendLine(" refuges(よけ場所): 0(JSON にまだ無い。空のまま)"); sb.AppendLine(" 止まる位置は JSON の stop をそのまま使う" + "(deliveryOffsetFromAisleCenter と aisleEntranceSetback は使わない)"); if (_problems.Count == 0) sb.AppendLine(" 合わないもの: なし"); else { sb.AppendLine(" 合わないもの: " + _problems.Count + " 件(建てていない)"); for (int i = 0; i < _problems.Count; i++) sb.AppendLine(" - " + _problems[i]); } return sb.ToString().TrimEnd(); } protected override StorePlan Compose() { _problems.Clear(); _doors.Clear(); _generation = ""; _storeName = ""; _jsonNarrowWidth = 0f; _normalWidth = 0f; _tableCount = _seatCount = _narrowAisleCount = _chairCount = 0; _spotCount = _seatPointCount = _roomCount = _placeCount = _wallCount = _dividerCount = 0; _walkableRoomCount = _staffOnlyRoomCount = 0; _routeFromPass = _routeBetween = 0; if (layoutJson == null) { Fail("店の形の JSON(layoutJson)が入っていません"); return null; } string err; JsonValue root = JsonValue.Parse(layoutJson.text, out err); if (root == null || !root.IsObject) { Fail("店の形の JSON を読めません: " + err); return null; } JsonValue meta = root["meta"]; _generation = meta["generation"].S(""); _storeName = root["name"].S(""); // サイトの決まりの「ふだん走らせる道の幅」(meta.settings.normal_width)。 // これ未満の通路が「狭い通路」。往復ごとの測りを中と外で分けるのに使う(v5 3章) _normalWidth = meta["settings"]["normal_width"].F(0f); if (!CheckGeneration()) return null; // ---- 高さ(JSON の meta.heights。Unity 側では決めない)---- JsonValue heights = meta["heights"]; float hWall = Req(heights, "wall", "meta.heights.wall"); float hTable = Req(heights, "table", "meta.heights.table"); float hBench = Req(heights, "bench", "meta.heights.bench"); float hBenchSeat = Req(heights, "bench_seat", "meta.heights.bench_seat"); float hChair = Req(heights, "chair", "meta.heights.chair"); float hCounter = Req(heights, "counter", "meta.heights.counter"); float hPartition = heights["partition"].F(1.2f); // これ以下の walls は仕切り StorePlan plan = new StorePlan(); // ---- 床 ---- JsonValue floor = root["floor"]; for (int i = 0; i < floor.Count; i++) { PlanRect r; if (TryRect(floor[i], "floor[" + i + "]", out r)) plan.AddFloor(r); } if (plan.floorPieces.Count == 0) Fail("floor が空です"); // ---- 外壁 ---- JsonValue outerWalls = root["outer_walls"]; for (int i = 0; i < outerWalls.Count; i++) { PlanRect r; if (!TryRect(outerWalls[i], "outer_walls[" + i + "]", out r)) continue; plan.AddBox("OuterWall" + i.ToString("00"), BoxStyle.Wall, r, hWall); _wallCount++; } // ---- 中の壁・仕切り(高さで分ける)---- JsonValue walls = root["walls"]; for (int i = 0; i < walls.Count; i++) { JsonValue w = walls[i]; PlanRect r; if (!TryRect(w["rect"], "walls[" + i + "].rect", out r)) continue; float h = w["height"].F(hWall); bool divider = h <= hPartition + 0.0005f; string label = w["label"].S("Wall" + i.ToString("00")); plan.AddBox(Safe(label) + i.ToString("00"), divider ? BoxStyle.Divider : BoxStyle.Wall, r, h); if (divider) _dividerCount++; else _wallCount++; } // ---- 場所の点(先に入れる。PointOf がこちらを先に拾うように)---- Dictionary firstPoint = new Dictionary(); JsonValue places = root["places"]; for (int i = 0; i < places.Count; i++) { JsonValue p = places[i]; string kindName = p["kind"].S(""); PlaceKind kind; if (!TryParseKind(kindName, out kind)) { Fail("places[" + i + "]: PlaceKind に無い kind \"" + kindName + "\""); continue; } Vector3 point; if (!TryPoint(p["point"], "places[" + i + "].point", out point)) continue; PlanRect rect; if (!p.Has("rect") || !TryRect(p["rect"], "places[" + i + "].rect", out rect)) rect = new PlanRect(0f, 0f, 0f, 0f); // 箱は置かない(通れない箱は rooms が持つ)。ここは点だけ。 plan.AddPlace(kind, Safe(p["label"].S(kindName)) + i.ToString("00"), rect, 0f, point, false); if (!firstPoint.ContainsKey(kind)) firstPoint[kind] = point; _placeCount++; } // ---- 部屋(通れない箱)---- JsonValue rooms = root["rooms"]; for (int i = 0; i < rooms.Count; i++) { JsonValue rm = rooms[i]; string label = rm["label"].S(""); PlaceKind kind; if (!TryRoomKind(label, out kind)) { Fail("rooms[" + i + "]: PlaceKind に対応の無いラベル \"" + label + "\""); continue; } PlanRect r; if (!TryRect(rm["rect"], "rooms[" + i + "].rect", out r)) continue; bool walkable = rm["walkable"].B(false); bool staffOnly = rm["staff_only"].B(false); // スタッフだけが入れる区画は、いまの作りでは「レジの内側」だけ // (NavMeshBaker.AddRegisterBackArea が PlaceKind.RegisterBack の rect を見て // 区画の番号を変え、客の NavMeshAgent がその区画を外して歩く)。 // ほかの部屋に staff_only が付いたら、こちらで作らずに報告して止める。 if (staffOnly && kind != PlaceKind.RegisterBack) { Fail("rooms[" + i + "] \"" + label + "\": staff_only は今の作りでは" + "レジの内側(RegisterBack)だけです"); continue; } if (walkable) { // 通れない箱にしない(人が入る部屋)。場所の点だけ置く。 // 同じ種類の点が places にあれば、そちらが区画の広がりも持っているので足さない。 if (!firstPoint.ContainsKey(kind)) { plan.AddPlace(kind, Safe(label) + "_Room" + i.ToString("00"), r, 0f, r.Center, false); firstPoint[kind] = r.Center; } _walkableRoomCount++; if (staffOnly) _staffOnlyRoomCount++; continue; } // レジとドリンクバーはカウンターの高さ、それ以外は壁の高さ float h = (kind == PlaceKind.Register || kind == PlaceKind.DrinkBar) ? hCounter : hWall; // 点は、同じ種類の場所の点(JSON が決めた立ち位置)に合わせる。無ければ真ん中 Vector3 point; if (!firstPoint.TryGetValue(kind, out point)) point = r.Center; plan.AddPlace(kind, Safe(label) + "_Room" + i.ToString("00"), r, h, point, true); _roomCount++; } // ---- 細い通路 ---- Dictionary aisleIndexById = new Dictionary(); JsonValue aisles = root["aisles"]; for (int i = 0; i < aisles.Count; i++) { JsonValue a = aisles[i]; PlanRect r; if (!TryRect(a["rect"], "aisles[" + i + "].rect", out r)) continue; Vector3 entrance; if (!TryPoint(a["entrance"], "aisles[" + i + "].entrance", out entrance)) continue; string id = a["id"].S("A" + (i + 1).ToString("00")); bool narrow = a["narrow"].B(true); aisleIndexById[id] = plan.aisles.Count; plan.AddAisle(id, r, narrow, entrance); float aw = a["width"].F(0f); if (narrow) { _narrowAisleCount++; if (_jsonNarrowWidth <= 0f) _jsonNarrowWidth = aw; else if (Mathf.Abs(_jsonNarrowWidth - aw) > 0.0005f) Fail("細い通路の幅がそろっていません: " + F(_jsonNarrowWidth) + " と " + F(aw)); } } // ---- 卓 ---- JsonValue tables = root["tables"]; for (int i = 0; i < tables.Count; i++) { ComposeTable(plan, tables[i], i, aisleIndexById, hTable, hBench, hBenchSeat, hChair); } _tableCount = plan.seats.Count; // ---- ロボットの道順(v5b。サイトが決めた曲がり角の点)---- plan.routes = ReadRoutes(root["routes"]); // ---- doors(箱は置かない。報告に一覧だけ出す)---- JsonValue doors = root["doors"]; for (int i = 0; i < doors.Count; i++) { JsonValue d = doors[i]; string label = d["label"].S(d["id"].S("door" + i)); PlanRect r; string where = TryRect(d["rect"], "doors[" + i + "].rect", out r) ? r.ToString() : "(rect なし)"; _doors.Add(label + " " + where); } // ---- 細い通路の幅が .asset の値と合っているか ---- if (_jsonNarrowWidth > 0f && Mathf.Abs(AisleWidth - _jsonNarrowWidth) > 0.0005f) { Fail("細い通路の幅が .asset と JSON で違います: .asset " + F(AisleWidth) + " m / JSON " + F(_jsonNarrowWidth) + " m" + "(aisleWidthOptions を JSON の幅だけにしてください)"); } if (_problems.Count > 0) { Debug.LogError("[ServiceSim] " + name + ": JSON と合わないものが " + _problems.Count + " 件あるので建てませんでした。\n" + FrontFitReport()); return null; } return plan; } /// /// routes を読む(v5b)。from_pass["T05"] と between["T05|T12"]。 /// 点は [x, y] で、JSON の y → Unity の Z。 /// RouteBook ReadRoutes(JsonValue routes) { if (!routes.IsObject) { Fail("routes がありません(v5b の版の JSON を写してください)"); return null; } RouteBook book = new RouteBook(); JsonValue fromPass = routes["from_pass"]; foreach (string id in fromPass.Keys) { RouteBook.Route r = ReadRoute(fromPass[id], "routes.from_pass[" + id + "]"); if (r != null) book.AddFromPass(NumberFromId(id, -1), r); } JsonValue between = routes["between"]; foreach (string key in between.Keys) { int bar = key.IndexOf('|'); if (bar <= 0) { Fail("routes.between のキーが \"T01|T02\" の形ではありません: " + key); continue; } RouteBook.Route r = ReadRoute(between[key], "routes.between[" + key + "]"); if (r != null) book.AddBetween(NumberFromId(key.Substring(0, bar), -1), NumberFromId(key.Substring(bar + 1), -1), r); } _routeFromPass = book.FromPassCount; _routeBetween = book.BetweenCount; if (_routeFromPass == 0) Fail("routes.from_pass が空です"); return book; } RouteBook.Route ReadRoute(JsonValue v, string where) { JsonValue pts = v["points"]; if (pts.Count < 2) { Fail(where + ": points が 2 点未満です"); return null; } RouteBook.Route r = new RouteBook.Route(); for (int i = 0; i < pts.Count; i++) { Vector3 p; if (!TryPoint(pts[i], where + ".points[" + i + "]", out p)) return null; r.points.Add(p); } r.use = v["use"].S("normal"); r.width = v["width"].F(0f); r.length = v["length"].F(r.PolylineLength); if (r.use != "normal" && r.use != "narrow") { Fail(where + ": use が normal / narrow ではありません(" + r.use + ")"); return null; } return r; } // ---------------- 卓 ---------------- void ComposeTable(StorePlan plan, JsonValue t, int index, Dictionary aisleIndexById, float hTable, float hBench, float hBenchSeat, float hChair) { string id = t["id"].S("T" + (index + 1).ToString("00")); string where = "tables[" + index + "] " + id; SeatKind kind; string type = t["type"].S(""); if (!TryTableKind(type, out kind)) { Fail(where + ": SeatKind に対応の無い type \"" + type + "\""); return; } JsonValue parts = t["parts"]; PlanRect top; if (!TryRect(parts["top"], where + ".parts.top", out top)) return; Vector3 stop; if (!TryPoint(t["stop"], where + ".stop", out stop)) return; PlanSeat seat = new PlanSeat(); seat.tableId = NumberFromId(id, index + 1); seat.kind = kind; seat.tableRect = top; seat.tableHeight = hTable; seat.seatCount = t["seats"].I(0); seat.deliveryPoint = stop; // JSON の stop をそのまま(ずらさない) // 通る細い通路 string aisleId = t["aisle"].S(null); if (string.IsNullOrEmpty(aisleId)) seat.aisleIndex = -1; else { int ai; if (aisleIndexById.TryGetValue(aisleId, out ai)) seat.aisleIndex = ai; else { Fail(where + ": aisles に無い通路 \"" + aisleId + "\""); return; } } // 天板 plan.AddBox(id + "_Top", BoxStyle.Table, top, hTable); // ベンチ(座面の見た目は今までの作りどおり、座面の上に当たりなしの板を敷く) JsonValue benches = parts["benches"]; for (int b = 0; b < benches.Count; b++) { PlanRect r; if (!TryRect(benches[b], where + ".parts.benches[" + b + "]", out r)) continue; plan.AddBox(id + "_Bench" + (b + 1), BoxStyle.Bench, r, hBench); AddBenchSeatLook(plan, id + "_Bench" + (b + 1), r, hBenchSeat); } // 椅子 JsonValue chairs = parts["chairs"]; for (int c = 0; c < chairs.Count; c++) { JsonValue ch = chairs[c]; PlanRect r; if (!TryRect(ch["rect"], where + ".parts.chairs[" + c + "].rect", out r)) continue; PlanChair chair = new PlanChair(); chair.rect = r; chair.height = hChair; chair.pullable = ch["pullable"].B(false); JsonValue dir = ch["pull_dir"]; chair.pullDirection = new Vector3(dir[0].F(0f), 0f, dir[1].F(0f)); if (chair.pullDirection.sqrMagnitude > 0.000001f) chair.pullDirection = chair.pullDirection.normalized; seat.chairs.Add(chair); _chairCount++; } // 座る位置 JsonValue sp = parts["seat_points"]; for (int s = 0; s < sp.Count; s++) { Vector3 p; if (!TryPoint(sp[s], where + ".parts.seat_points[" + s + "]", out p)) continue; seat.seatPoints.Add(p); _seatPointCount++; } // 席に着くために立つ点 JsonValue spots = t["spots"]; for (int s = 0; s < spots.Count; s++) { Vector3 p; if (!TryPoint(spots[s]["point"], where + ".spots[" + s + "].point", out p)) continue; seat.spots.Add(new PlanSeatSpot(spots[s]["side"].S("spot" + (s + 1)), p)); _spotCount++; } plan.seats.Add(seat); _seatCount += seat.seatCount; } /// /// 座面の見た目(当たりなし)。ベンチの箱の上に、ふちから少し詰めた板を1枚置く。 /// NavMesh・前のセンサー・左右の空きの測りには効かない。 /// void AddBenchSeatLook(StorePlan plan, string name, PlanRect bench, float seatHeight) { if (benchSeatPadThickness <= 0.0005f) return; float m = Mathf.Max(0f, benchSeatInset); PlanRect r = new PlanRect(bench.xMin + m, bench.zMin + m, bench.xMax - m, bench.zMax - m); if (r.IsEmpty) return; PlanBox b = plan.AddBox(name + "_SeatLook", BoxStyle.BenchSeat, r, benchSeatPadThickness); if (b != null) b.baseY = seatHeight; } // ---------------- 小さな道具 ---------------- bool CheckGeneration() { if (string.IsNullOrEmpty(_generation)) { Fail("meta.generation がありません"); return false; } if (summaryJson == null) { Fail("summary.json(summaryJson)が入っていません。版の番号を突き合わせられません"); return false; } string err; JsonValue sum = JsonValue.Parse(summaryJson.text, out err); if (sum == null || !sum.IsArray) { Fail("summary.json を読めません: " + err); return false; } string key = string.IsNullOrEmpty(layoutFileName) ? (layoutJson != null ? layoutJson.name : "") : layoutFileName; for (int i = 0; i < sum.Count; i++) { if (sum[i]["file"].S("") != key) continue; string g = sum[i]["generation"].S(""); if (g != _generation) { Fail("版の番号が summary.json と違います: この JSON " + _generation + " / summary.json " + g + "(写し直してください)"); return false; } return true; } Fail("summary.json に \"" + key + "\" がありません(layoutFileName を確かめてください)"); return false; } float Req(JsonValue obj, string key, string where) { JsonValue v = obj[key]; if (v.kind != JsonValue.Kind.Number) { Fail(where + " がありません"); return 0f; } return v.F(0f); } bool TryRect(JsonValue v, string where, out PlanRect rect) { rect = new PlanRect(0f, 0f, 0f, 0f); if (v.Count < 4) { Fail(where + " が [x0, y0, x1, y1] ではありません"); return false; } rect = new PlanRect(v[0].F(0f), v[1].F(0f), v[2].F(0f), v[3].F(0f)); return true; } bool TryPoint(JsonValue v, string where, out Vector3 p) { p = Vector3.zero; if (v.Count < 2) { Fail(where + " が [x, y] ではありません"); return false; } p = new Vector3(v[0].F(0f), 0f, v[1].F(0f)); return true; } static bool TryParseKind(string s, out PlaceKind kind) { kind = PlaceKind.Kitchen; if (string.IsNullOrEmpty(s)) return false; try { kind = (PlaceKind)Enum.Parse(typeof(PlaceKind), s, false); return true; } catch (Exception) { return false; } } /// 部屋のラベルを PlaceKind に直す(依頼の対応表どおり) static bool TryRoomKind(string label, out PlaceKind kind) { kind = PlaceKind.Kitchen; if (string.IsNullOrEmpty(label)) return false; switch (label) { case "パントリー": kind = PlaceKind.Pantry; return true; case "キッチン": kind = PlaceKind.Kitchen; return true; case "冷蔵・冷凍・保存": kind = PlaceKind.Kitchen; return true; case "洗い場": kind = PlaceKind.Kitchen; return true; case "更衣室・社員トイレ": kind = PlaceKind.Office; return true; case "トイレ": kind = PlaceKind.Toilet; return true; case "待合": kind = PlaceKind.Waiting; return true; case "レジ": kind = PlaceKind.Register; return true; case "レジ内": kind = PlaceKind.RegisterBack; return true; case "ドリンクバー": kind = PlaceKind.DrinkBar; return true; } return false; } static bool TryTableKind(string type, out SeatKind kind) { kind = SeatKind.WallBooth; switch (type) { case "booth": kind = SeatKind.WallBooth; return true; case "t2": kind = SeatKind.WallTable; return true; case "booth6L": kind = SeatKind.LargeTable; return true; } return false; } static int NumberFromId(string id, int fallback) { if (string.IsNullOrEmpty(id)) return fallback; int n = 0; bool any = false; for (int i = 0; i < id.Length; i++) { char c = id[i]; if (c < '0' || c > '9') continue; n = n * 10 + (c - '0'); any = true; } return any ? n : fallback; } /// GameObject の名前に使える形にする(/ は階層になってしまう) static string Safe(string s) { if (string.IsNullOrEmpty(s)) return "x"; return s.Replace('/', '_').Replace('\\', '_'); } void Fail(string message) { _problems.Add(message); } static string F(float v) { return v.ToString("0.##", CultureInfo.InvariantCulture); } } }