"""ファミレスの店の形 第3版:決まりの確かめ方とロボットの届き方を作り直した版。 codex の敵対的検証(review_result.md)を受けて直したこと - ロボットの通れる幅は機種の公表値(初期値 BellaBot 80cm)。本体の幅とは別に扱う - 「ふだん走らせる幅」(初期値 1.0m)を別に持ち、卓を3つに分ける ロボットが届く卓/狭い道なら届く卓/人が運ぶ卓(ロボットから受け取って人が運ぶ) - 届くかは、卓の「座る側(開いた側)」の前で止まれるかで判定する。背中側・壁側・仕切り越しは数えない - 幅の判定は、マス目の中心から障害物までの距離で行い、2つの細かさで同じ結果になるかを毎回確かめる - 斜めのすり抜けを禁止する。出発点が通れない場所なら止める - 待機・充電の場所は、止まっているロボットごと障害物として扱う - 決まりの確かめを全部の組み合わせに広げ、違反があれば図とデータを出さずに止める codex の2回目の検証(review_result_v3.md)を受けて直したこと(2026-10-22) - L字席:止まってよいのは、内側の角の開いた2辺と、そこからソファの端に沿って0.6mまで(ソファの横は使わない) - 2人席:止まってよいのは、椅子の側とテーブルの横(ベンチの横は使わない) - 待機場所:1台分が0.8×0.8m以上か、台数が正しいかを確かめる。1台ずつ、ほかの台が止まったままで受け渡し口の前まで出られるかを確かめる 使い方: python3 famires_v3.py (出力はサイトの中なら public/sim/layouts/、外なら variants3/) python3 famires_v3.py Servi (機種を替えて試算) """ import hashlib import json import math import os import shutil import sys import tempfile from pathlib import Path import numpy as np from scipy import ndimage, sparse from scipy.sparse.csgraph import dijkstra import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from matplotlib.patches import Rectangle, Circle, Polygon import famires_gen2 as gen import famires_p0 plt.rcParams["font.family"] = "Noto Sans CJK JP" HERE = Path(__file__).parent SCHEMA = "famires-layout/0.3" # 機種ごとの公表値(最小通過幅)。出典は README と同じ ROBOT_MODELS = { "BellaBot": dict(pass_width=0.80, body_width=0.50, maker="Pudu Robotics", source="https://academy.pudutech.com/en/docs/huan-le-song-chan-pin-jian-jie"), "KettyBot": dict(pass_width=0.55, body_width=0.435, maker="Pudu Robotics", source="https://academy.pudutech.com/en/docs/chan-pin-jian-jie"), "Keenbot T8": dict(pass_width=0.55, body_width=0.384, maker="KEENON", source="https://www.irisohyama.co.jp/b2b/robotics/products/keenbot-t8/"), "KEENON T10": dict(pass_width=0.585, body_width=None, maker="KEENON", source="https://meetsmore.com/products/keenon-t10"), "Servi": dict(pass_width=0.60, body_width=None, maker="Bear Robotics", source="https://innovatopia.jp/robot/robot-news/118331/"), "Servi Q": dict(pass_width=0.45, body_width=0.35, maker="Bear Robotics", source="https://innovatopia.jp/robot/robot-news/118331/"), } SETTINGS = dict( model="BellaBot", normal_width=1.0, # ふだん走らせる通路の幅(店の運用の設定) human_width=0.5, # 人が通れる幅(人が運ぶ卓の歩く距離を測るとき) res=0.025, # 計算のマス目(m)。幅 w 以上の通路は必ず通れる、w−res 未満は必ず通れない check_res=0.0125, # 確かめ用の細かいマス目(m)。2つで結果が分かれた卓は ? 印 stop_reach=0.3, # 通路の幅いっぱいから、さらに卓へ寄れる余裕(止まる位置の帯の厚み) side_end_margin=0.15, # 座る側の両端から、この長さは止まる位置に使わない toilet_min_gap=1.0, # トイレからこの距離「以内」に席を置かない(ちょうど1mも不可) toilet_door_depth=1.2, entrance_depth=1.5, # 入口から店の中へ、この奥行きは何も置かない dish_front_depth=1.2, # 料理の受け渡し口の前、この奥行きは何も置かない joint_max_depth=0.08, # 仕切りが家具に食い込んでよい深さ(背もたれの上に立つ仕切り) register_min_len=2.5, lsofa_end_reach=0.6, # L字席:開いた辺から、ソファの端に沿ってこの長さまで止まってよい(端の席の人が受け取れる所) standby_slot=0.8, # 待機場所の1台分の最小の大きさ(m×m) ) FURNITURE_TYPES = { "booth": dict(desc="ボックス席。向かい合うベンチ2つと間のテーブル。座る側(開いた側)は長い2辺。短い2辺はベンチの背", bench_depth=0.55, table_depth=0.75, length=1.85, bench_length={"4": 1.2, "6": 1.8}, axis={"v": "ベンチが上下(座る側は左右)", "h": "ベンチが左右(座る側は上下)"}), "t2": dict(desc="2人席。壁側がベンチ、通路側が椅子。止まってよいのは椅子の側と、テーブルの横(ベンチの横は使わない)", footprint=[0.8, 1.4], table=[0.7, 0.6], bench_depth=0.45, chair_radius=0.18), "booth6L": dict(desc="L字ソファの6人席。ソファの内側の角にテーブル(約0.75m×1.2m)。止まってよいのは、内側の角の開いた2辺と、" "そこからソファの端に沿って lsofa_end_reach(端の席の人が受け取れる所)まで", sofa_depth_ratio={"band": 0.45, "column": 0.55}), "walls": dict(height_m_仮={"壁": 2.7, "仕切り": 1.2, "トイレの壁": 2.7}), } STYLE_AISLE = {"効率重視": 1.2, "ゆったり": 1.5} # ================================================================ 形の道具 def fp(t): return (t["x"], t["y"], t["x"] + t["w"], t["y"] + t["h"]) def inter(a, b): """2つの矩形の重なり(x方向の深さ, y方向の深さ)。重ならなければ None""" dx = min(a[2], b[2]) - max(a[0], b[0]) dy = min(a[3], b[3]) - max(a[1], b[1]) return (dx, dy) if dx > 1e-6 and dy > 1e-6 else None def gap(a, b): dx = max(0.0, max(a[0], b[0]) - min(a[2], b[2])) dy = max(0.0, max(a[1], b[1]) - min(a[3], b[3])) return math.hypot(dx, dy) def seg_rect_hit(a, b, r, open_rect=False, eps=1e-9): """線分 a-b が矩形 r に交わるか(計算で求める。点を並べて確かめると、薄い壁を見逃すため) 線分の両端だけで触れる場合は数えない。open_rect=True なら、矩形の縁に触れるだけの線も数えない""" x0, y0, x1, y1 = r if open_rect: x0, y0, x1, y1 = x0 + eps, y0 + eps, x1 - eps, y1 - eps dx, dy = b[0] - a[0], b[1] - a[1] t0, t1 = 0.0, 1.0 for p, q in ((-dx, a[0] - x0), (dx, x1 - a[0]), (-dy, a[1] - y0), (dy, y1 - a[1])): if abs(p) < 1e-15: if q < 0: return False else: t = q / p if p < 0: t0 = max(t0, t) else: t1 = min(t1, t) if t0 > t1: return False # 交わる区間 [t0, t1] が、線分の内側(両端を除く)にかかっているか return t1 > 1e-6 and t0 < 1 - 1e-6 and (t1 - t0 > 1e-9 or 1e-6 < t0 < 1 - 1e-6) def seg_hits(a, b, rects): """線分 a-b が矩形のどれかを通るか(縁に触れる線も通るとみなす)""" return any(seg_rect_hit(a, b, r) for r in rects) def touch_segment(a, b): """2つの矩形が辺で接しているとき、接している線分(((x0,y0),(x1,y1)))。接していなければ None""" x0, x1 = max(a[0], b[0]), min(a[2], b[2]) y0, y1 = max(a[1], b[1]), min(a[3], b[3]) if x1 < x0 - 1e-9 or y1 < y0 - 1e-9: return None if abs(x1 - x0) < 1e-9 and y1 - y0 > 1e-6: return ((x0, y0), (x0, y1)) if abs(y1 - y0) < 1e-9 and x1 - x0 > 1e-6: return ((x0, y0), (x1, y0)) return None def contains(r, x, y, e=1e-9): return r[0] - e <= x <= r[2] + e and r[1] - e <= y <= r[3] + e def entrance_zone(L, depth): e, W, D = L["entrance"], L["outer"]["w"], L["outer"]["d"] return {"bottom": (e["a0"], 0, e["a1"], depth), "top": (e["a0"], D - depth, e["a1"], D), "left": (0, e["a0"], depth, e["a1"]), "right": (W - depth, e["a0"], W, e["a1"])}[e["side"]] def _seg(axis, c, lo, hi, n): """辺の一部。axis="v" は x=c の縦の辺(y が lo〜hi)、"h" は y=c の横の辺(x が lo〜hi)。n は外向き(+1/−1)""" if axis == "v": return ((c, lo), (c, hi), (n, 0)) return ((lo, c), (hi, c), (0, n)) def lsofa_sides(t): """L字席の止まってよい辺(2026-10-22 エイイチと決めた:a+b) a:テーブルのある内側の角の、開いた2辺 b:そこからソファの端に沿って lsofa_end_reach まで(端の席の人が受け取れる所) 変形前(flip・mirror なし)は、下の帯(高さ0.45h)と左の列(幅0.55w)がソファで、右上の角が開いている""" x, y, w, h = t["x"], t["y"], t["w"], t["h"] # 止まる位置の帯は両端から side_end_margin を除くので、b の側はその分を足して、止まる位置が角から reach 以内に来るようにする ext = SETTINGS["lsofa_end_reach"] + SETTINGS["side_end_margin"] segs = {"right": ["v", x + w, max(y, y + 0.45 * h - ext), y + h, 1], "top": ["h", y + h, max(x, x + 0.55 * w - ext), x + w, 1]} out = {} for k, (axis, c, lo, hi, n) in segs.items(): if t.get("flip"): # 上下を返す if axis == "v": lo, hi = 2 * y + h - hi, 2 * y + h - lo else: c, n, k = 2 * y + h - c, -n, "bottom" if t.get("mirror"): # 左右を返す if axis == "h": lo, hi = 2 * x + w - hi, 2 * x + w - lo else: c, n, k = 2 * x + w - c, -n, "left" out[k] = _seg(axis, c, lo, hi, n) return out def t2_sides(t): """2人席の止まってよい辺:椅子の側(ベンチの反対)と、両横のテーブル・椅子の部分。ベンチの横とベンチの側は使わない""" x0, y0, x1, y1 = fp(t) b = FURNITURE_TYPES["t2"]["bench_depth"] s = t["side"] # ベンチ(壁)の側 if s == "bottom": return {"top": _seg("h", y1, x0, x1, 1), "left": _seg("v", x0, y0 + b, y1, -1), "right": _seg("v", x1, y0 + b, y1, 1)} if s == "top": return {"bottom": _seg("h", y0, x0, x1, -1), "left": _seg("v", x0, y0, y1 - b, -1), "right": _seg("v", x1, y0, y1 - b, 1)} if s == "left": return {"right": _seg("v", x1, y0, y1, 1), "bottom": _seg("h", y0, x0 + b, x1, -1), "top": _seg("h", y1, x0 + b, x1, 1)} return {"left": _seg("v", x0, y0, y1, -1), "bottom": _seg("h", y0, x0, x1 - b, -1), "top": _seg("h", y1, x0, x1 - b, 1)} def table_sides(t): """座る側(ロボットが止まってよい辺)の候補 booth:長い2辺だけ(短い辺はベンチの背)/booth6L:lsofa_sides/t2:t2_sides/t4・r4・long6:4辺""" x0, y0, x1, y1 = fp(t) sides = {"left": ((x0, y0), (x0, y1), (-1, 0)), "right": ((x1, y0), (x1, y1), (1, 0)), "bottom": ((x0, y0), (x1, y0), (0, -1)), "top": ((x0, y1), (x1, y1), (0, 1))} if t["type"] == "booth": keep = ("left", "right") if t["axis"] == "v" else ("bottom", "top") return {k: v for k, v in sides.items() if k in keep} if t["type"] == "booth6L": return lsofa_sides(t) if t["type"] == "t2": return t2_sides(t) return sides def standby_slots(sb): """待機場所を1台ずつの区画に分ける(長い方の向きに slots 等分)""" x0, y0, x1, y1 = sb["rect"] n = sb["slots"] if (x1 - x0) >= (y1 - y0): return [[x0 + (x1 - x0) * i / n, y0, x0 + (x1 - x0) * (i + 1) / n, y1] for i in range(n)] return [[x0, y0 + (y1 - y0) * i / n, x1, y0 + (y1 - y0) * (i + 1) / n] for i in range(n)] def all_obstacles(L, skip=None, standby=None): """障害物の矩形と名前。skip は除く卓の番号。standby を渡すと、待機場所の矩形の代わりにそれ(区画の並び)を使う""" obs = [(r["rect"], "部屋:" + r["label"]) for r in L["rooms"]] obs += [(w["rect"], "壁:" + w["label"]) for w in L["walls"]] obs += [(fp(t), f"卓:{t['id']}") for i, t in enumerate(L["tables"]) if i != skip] if standby is None: obs += [(L["robot_standby"]["rect"], "待機場所")] else: obs += [(tuple(r), "待機場所") for r in standby] return obs def open_sides(L, i, probe=0.2): """座る側のうち、外側 probe の帯に何もない辺(壁・仕切り・ほかの卓・店の外がない辺)""" t = L["tables"][i] W, D = L["outer"]["w"], L["outer"]["d"] obs = [o for o, _ in all_obstacles(L, skip=i)] out = {} for k, ((ax, ay), (bx, by), (nx, ny)) in table_sides(t).items(): e = 0.1 if nx: # 縦の辺 band = (min(ax, ax + nx * probe), ay + e, max(ax, ax + nx * probe), by - e) else: band = (ax + e, min(ay, ay + ny * probe), bx - e, max(ay, ay + ny * probe)) outside = band[0] < -1e-6 or band[1] < -1e-6 or band[2] > W + 1e-6 or band[3] > D + 1e-6 if not outside and not any(inter(band, o) for o in obs): out[k] = ((ax, ay), (bx, by), (nx, ny)) return out # ================================================================ 決まりの確かめ def joint_ok(t, w): """仕切りが卓に食い込んでよいのは、ボックス席のベンチの背(axis=v は上下の辺、h は左右の辺)の上に立つときだけ。 食い込みはその辺に接し、深さ joint_max_depth 以内(家具の真ん中に置いた仕切りは認めない)""" if not (w.get("joint") and t["type"] == "booth"): return False A, R = fp(t), w["rect"] ox0, oy0, ox1, oy1 = max(A[0], R[0]), max(A[1], R[1]), min(A[2], R[2]), min(A[3], R[3]) dmax, e = SETTINGS["joint_max_depth"] + 1e-6, 1e-6 if t["axis"] == "v": return (oy1 - oy0) <= dmax and (oy0 <= A[1] + e or oy1 >= A[3] - e) return (ox1 - ox0) <= dmax and (ox0 <= A[0] + e or ox1 >= A[2] - e) def _edge_points(a, b, step=0.025, margin=0.025): """線分 a-b の上の点(両端から margin を除いて step ごと)""" n = math.hypot(b[0] - a[0], b[1] - a[1]) k = max(1, int((n - 2 * margin) / step) + 1) return [(a[0] + (b[0] - a[0]) * (margin + i * step) / n, a[1] + (b[1] - a[1]) * (margin + i * step) / n) for i in range(k) if margin + i * step <= n - margin + 1e-9] def facing_edge(r, p): """矩形 r の4辺のうち、点 p の方を向いている辺の両端""" x0, y0, x1, y1 = r edges = [((x0, y0), (x0, y1)), ((x1, y0), (x1, y1)), ((x0, y0), (x1, y0)), ((x0, y1), (x1, y1))] def dist(e): # 点 p から辺までの距離 (ax, ay), (bx, by) = e return math.hypot(max(ax - p[0], 0, p[0] - bx), max(ay - p[1], 0, p[1] - by)) return min(edges, key=dist) def _gaps(lo, hi, ivs): """区間 [lo, hi] のうち、区間の並び ivs のどれにも覆われていない所(長さ1e-6より長いもの)""" gaps, cur = [], lo for a, b in sorted((max(a, lo), min(b, hi)) for a, b in ivs if min(b, hi) > max(a, lo)): if a > cur + 1e-6: gaps.append((cur, a)) cur = max(cur, b) if hi > cur + 1e-6: gaps.append((cur, hi)) return gaps def _line_cover(r, axis, c, lo, hi): """矩形 r が、線(axis="h" は y=c で x が lo〜hi、"v" は x=c で y が lo〜hi)のどの区間に重なるか。重ならなければ None""" x0, y0, x1, y1 = r if axis == "h": if y0 - 1e-9 <= c <= y1 + 1e-9 and min(x1, hi) > max(x0, lo) + 1e-9: return (max(x0, lo), min(x1, hi)) else: if x0 - 1e-9 <= c <= x1 + 1e-9 and min(y1, hi) > max(y0, lo) + 1e-9: return (max(y0, lo), min(y1, hi)) return None def _seg_axis(seg): """軸に沿った線分を (axis, c, lo, hi) にする""" (ax, ay), (bx, by) = seg if abs(ay - by) < 1e-9: return "h", ay, min(ax, bx), max(ax, bx) return "v", ax, min(ay, by), max(ay, by) def check_toilets(L): """トイレの決まり(点を並べて確かめると点の間を見逃すので、辺は区間として計算で確かめる) - 入口の情報(toilet_doors:入口の前の空ける所)は、どれか1つのトイレに接している。トイレ1つに入口は1つ - 入口の前の矩形は、トイレとの境目の幅いっぱいで、奥行き toilet_door_depth(1.2m)以上 - 入口の前は何も置かない(名前で免除しない。トイレの部屋だけは接してよい)。入口そのもの(トイレとの境目)に壁が重ならない - トイレの4辺は、外壁・裏の部屋・建物の外・壁(厚さ0.3m以上)・入口のどれかで、すきまなく囲まれている - トイレから1m以内の席は、席のトイレ側の辺の両端から入口の両端へ引いた4本の線が、全部壁に当たること(目隠しの壁)。 4本の線は近似で、壁の切れ目から入口が見える置き方は見落とし得る(README)""" S = SETTINGS W, D = L["outer"]["w"], L["outer"]["d"] v = [] rooms, walls = L["rooms"], L["walls"] toilets = [r["rect"] for r in rooms if r["label"] == "トイレ"] doors = L.get("toilet_doors", []) door_of = {} for dz in doors: hit = [i for i, tr in enumerate(toilets) if touch_segment(dz, tr)] if not hit: v.append(("トイレ", f"入口の情報 {list(dz)} がどのトイレにも接していない")) for i in hit: door_of.setdefault(i, []).append(dz) for i, tr in enumerate(toilets): if len(door_of.get(i, [])) != 1: v.append(("トイレ", f"トイレ {list(tr)} の入口の情報が{len(door_of.get(i, []))}件(1件にする)")) # 入口の前・入口そのもの for dz in doors: for o, name in all_obstacles(L): if name == "部屋:トイレ": continue if inter(dz, o): v.append(("トイレ", f"{name} がトイレの入口の前をふさいでいる")) door_edge = {} for i, tr in enumerate(toilets): if len(door_of.get(i, [])) != 1: continue dz = door_of[i][0] seg = touch_segment(dz, tr) door_edge[i] = seg axis, c, lo, hi = _seg_axis(seg) # 入口の前の矩形:境目の幅いっぱい、奥行き1.2m以上 width = (dz[2] - dz[0]) if axis == "h" else (dz[3] - dz[1]) depth = (dz[3] - dz[1]) if axis == "h" else (dz[2] - dz[0]) if abs(width - (hi - lo)) > 1e-6: v.append(("トイレ", f"トイレ {list(tr)} の入口の前の矩形が、入口の幅({hi - lo:.2f}m)とそろっていない({width:.2f}m)")) if depth < S["toilet_door_depth"] - 1e-6: v.append(("トイレ", f"トイレ {list(tr)} の入口の前の奥行きが{depth:.2f}m({S['toilet_door_depth']}m以上が要る)")) # 入口そのものに壁が重ならない(端で接するだけはよい) for w in walls: if _line_cover(w["rect"], axis, c, lo, hi): v.append(("トイレ", f"トイレ {list(tr)} の入口に {w['label']} が重なっている")) # トイレの4辺がすきまなく囲まれているか(区間で計算) others = [r["rect"] for r in rooms if r["label"] != "トイレ" and r["kind"] in ("back", "outside")] th = lambda r: min(r[2] - r[0], r[3] - r[1]) for i, tr in enumerate(toilets): x0, y0, x1, y1 = tr for axis, c, lo, hi in (("h", y0, x0, x1), ("h", y1, x0, x1), ("v", x0, y0, y1), ("v", x1, y0, y1)): if (axis == "h" and (abs(c) < 1e-9 or abs(c - D) < 1e-9)) or (axis == "v" and (abs(c) < 1e-9 or abs(c - W) < 1e-9)): continue # 外壁 ivs = [iv for r in others if (iv := _line_cover(r, axis, c, lo, hi))] ivs += [iv for w in walls if th(w["rect"]) >= 0.3 - 1e-6 and (iv := _line_cover(w["rect"], axis, c, lo, hi))] if i in door_edge: da, dc, dlo, dhi = _seg_axis(door_edge[i]) if da == axis and abs(dc - c) < 1e-9: ivs.append((dlo, dhi)) gs = _gaps(lo, hi, ivs) if not gs: continue thin_ws = [w for w in walls if th(w["rect"]) < 0.3 - 1e-6 and _line_cover(w["rect"], axis, c, lo, hi)] still = _gaps(lo, hi, ivs + [_line_cover(w["rect"], axis, c, lo, hi) for w in thin_ws]) if still: a, b = still[0] where = f"x={a:.3f}〜{b:.3f}, y={c:.3f}" if axis == "h" else f"x={c:.3f}, y={a:.3f}〜{b:.3f}" v.append(("トイレ", f"トイレ {list(tr)} の壁に切れ目がある({where})")) else: for w in thin_ws: v.append(("トイレ", f"トイレを囲む壁が薄い:{w['label']}({th(w['rect']):.2f}m)")) # トイレから1m以内の席は、4本の線が全部壁に当たること screens = [w["rect"] for w in walls if not w.get("joint")] for t in L["tables"]: for i, tr in enumerate(toilets): g = gap(fp(t), tr) if g > S["toilet_min_gap"] + 1e-6: continue seg = door_edge.get(i) hidden = False if seg: m = ((seg[0][0] + seg[1][0]) / 2, (seg[0][1] + seg[1][1]) / 2) ends = facing_edge(fp(t), m) hidden = all(seg_hits(s, d, screens) for s in ends for d in seg) if not hidden: v.append(("トイレ", f"{t['id']} がトイレから{g:.2f}m({S['toilet_min_gap']}m以内に置くなら、席の両端からトイレの入口の両端への4本の線が全部壁に当たること)")) return v def check_outline(L): """建物の形:outline(建物の輪郭)と、建物の外の部屋(kind=outside)が同じ所を表しているか。 輪郭は縦と横の辺だけ(直交多角形)とし、輪郭・建物の外・外形の座標で区切ったマスごとに照合する(点を並べる照合と違い、細い切れ込みも見逃さない)。 輪郭の外には、卓・部屋・壁・待機場所・出発点・受け渡し口を置かない""" v = [] W, D = L["outer"]["w"], L["outer"]["d"] outs = [r["rect"] for r in L["rooms"] if r["kind"] == "outside"] ol = L.get("outline") if not ol: if outs: v.append(("建物の形", "建物の外があるのに、建物の輪郭(outline)がない")) return v pts = [tuple(map(float, p)) for p in ol] if any(not (-1e-6 <= x <= W + 1e-6 and -1e-6 <= y <= D + 1e-6) for x, y in pts): v.append(("建物の形", "建物の輪郭が外形(outer)からはみ出している")) if len(pts) < 4 or any(abs(a[0] - b[0]) > 1e-9 and abs(a[1] - b[1]) > 1e-9 for a, b in zip(pts, pts[1:] + pts[:1])): v.append(("建物の形", "建物の輪郭は、縦と横の辺だけでできた形にする")) return v from matplotlib.path import Path as MPath poly = MPath(np.array(pts)) xs = sorted({0.0, W} | {p[0] for p in pts} | {c for o in outs for c in (o[0], o[2])}) ys = sorted({0.0, D} | {p[1] for p in pts} | {c for o in outs for c in (o[1], o[3])}) xs = [x for x in xs if -1e-9 <= x <= W + 1e-9] ys = [y for y in ys if -1e-9 <= y <= D + 1e-9] bad = 0.0 for xa, xb in zip(xs, xs[1:]): for ya, yb in zip(ys, ys[1:]): if xb - xa < 1e-9 or yb - ya < 1e-9: continue cx, cy = (xa + xb) / 2, (ya + yb) / 2 # マスの中心は、どの辺の上にも来ない inside = poly.contains_point((cx, cy)) in_out = any(o[0] < cx < o[2] and o[1] < cy < o[3] for o in outs) if inside == in_out: bad += (xb - xa) * (yb - ya) if bad > 0: v.append(("建物の形", f"建物の輪郭と建物の外が合っていない({bad:.4f}m²)")) items = [(w["rect"], w["label"]) for w in L["walls"]] + [(L["robot_standby"]["rect"], "待機場所")] + \ [(d["rect"], d["label"]) for d in L["dish_up"]] for r, name in items: if any(inter(r, o) for o in outs): v.append(("建物の形", f"{name} が建物の外にある")) if L.get("start") and any(o[0] < L["start"][0] < o[2] and o[1] < L["start"][1] < o[3] for o in outs): v.append(("建物の形", "出発点が建物の外にある")) return v def check_doors(L): """店員の出入り口(layout.doors):壁の切れ目として開けた所(door の有無に関係なく)に、壁が重ならないこと。 doors は図と Unity のための情報で、ロボットの道の計算には使わない(パントリーなどの部屋の中は、いまも障害物)""" v = [] for d in L.get("doors", []): for w in L["walls"]: if inter(d["rect"], w["rect"]): v.append(("出入り口", f"{d['label']} に {w['label']} が重なっている")) return v def check_rules(L): S = SETTINGS W, D = L["outer"]["w"], L["outer"]["d"] v = [] # (区分, 内容) T = L["tables"] rooms, walls = L["rooms"], L["walls"] inside = lambda r: r[0] >= -1e-6 and r[1] >= -1e-6 and r[2] <= W + 1e-6 and r[3] <= D + 1e-6 # 店の外にはみ出していないか for t in T: if not inside(fp(t)): v.append(("店の外", f"{t['id']} が店の外にはみ出している")) for r in rooms + walls: if not inside(r["rect"]): v.append(("店の外", f"{r['label']} が店の外にはみ出している")) if not inside(L["robot_standby"]["rect"]): v.append(("店の外", "待機場所が店の外にはみ出している")) # 待機場所:台数は正の整数、1台分は standby_slot×standby_slot 以上 sb = L["robot_standby"] n = sb.get("slots") if not isinstance(n, int) or isinstance(n, bool) or n < 1: v.append(("待機場所", f"台数が正しくない({n!r})")) else: for r in standby_slots(sb): a, b = r[2] - r[0], r[3] - r[1] if min(a, b) < S["standby_slot"] - 1e-6: v.append(("待機場所", f"1台分が {a:.2f}×{b:.2f}m({S['standby_slot']}×{S['standby_slot']}m 以上が要る)")) break # 卓どうし・卓と部屋・卓と壁・卓と待機場所 for i, a in enumerate(T): A = fp(a) for b in T[i + 1:]: if inter(A, fp(b)): v.append(("重なり", f"{a['id']} と {b['id']}")) for r in rooms: if inter(A, r["rect"]): v.append(("重なり", f"{a['id']} と {r['label']}")) for w in walls: o = inter(A, w["rect"]) if o and not joint_ok(a, w): v.append(("重なり", f"{a['id']} と {w['label']}({min(o):.2f}m 食い込み)")) if inter(A, L["robot_standby"]["rect"]): v.append(("重なり", f"{a['id']} と 待機場所")) # 部屋どうし・部屋と待機場所 for i, a in enumerate(rooms): for b in rooms[i + 1:]: if inter(a["rect"], b["rect"]): v.append(("重なり", f"{a['label']} と {b['label']}")) if inter(a["rect"], L["robot_standby"]["rect"]): v.append(("重なり", f"{a['label']} と 待機場所")) for w in walls: if inter(w["rect"], L["robot_standby"]["rect"]): v.append(("重なり", f"{w['label']} と 待機場所")) # 入口の前・受け渡し口の前は空ける ez = entrance_zone(L, S["entrance_depth"]) for o, name in all_obstacles(L): if inter(ez, o): v.append(("入口の前", f"{name} が入口から{S['entrance_depth']}m以内にある")) for d in L["dish_up"]: x0, y0, x1, y1 = d["rect"] dz = (x0, y0 - S["dish_front_depth"], x1, y0) for o, name in all_obstacles(L): if inter(dz, o) and not name.startswith("部屋:パントリー"): v.append(("受け渡し口の前", f"{name} が受け渡し口の前{S['dish_front_depth']}m以内にある")) v += check_toilets(L) # 入口まわり:待合・レジ(カウンターと店員の立つ所が両方あり、接している)・ドリンクバー labels = [r["label"] for r in rooms] if "待合" not in labels: v.append(("入口まわり", "待合がない")) regs = [r["rect"] for r in rooms if r["label"] == "レジ"] staffs = [r["rect"] for r in rooms if r["label"] == "レジ内"] if not regs: v.append(("入口まわり", "レジがない")) if not staffs: v.append(("入口まわり", "レジの内側の店員の立つ所(レジ内)がない")) for x0, y0, x1, y1 in regs: if max(x1 - x0, y1 - y0) < S["register_min_len"] - 1e-6: v.append(("入口まわり", f"レジが短い({max(x1 - x0, y1 - y0):.2f}m)")) if staffs and not any(touch_segment((x0, y0, x1, y1), s) for s in staffs): v.append(("入口まわり", "レジ内がレジに接していない")) if "ドリンクバー" not in labels: v.append(("入口まわり", "ドリンクバーがない")) v += check_outline(L) v += check_doors(L) # 座る側が1つもない卓 for i, t in enumerate(T): if not open_sides(L, i): v.append(("座れない卓", f"{t['id']} の座る側がすべてふさがっている")) return v # ================================================================ ロボットの届き方 class Grid: def __init__(self, L, res, standby=None): self.res = res W, D = L["outer"]["w"], L["outer"]["d"] self.nx, self.ny = int(math.ceil(W / res - 1e-9)), int(math.ceil(D / res - 1e-9)) xs = (np.arange(self.nx) + 0.5) * res ys = (np.arange(self.ny) + 0.5) * res self.xs, self.ys = xs, ys # 障害物までの距離を矩形から正確に出す(マス目に塗ってから測ると、縁が最大1マスずれるため) X, Y = np.meshgrid(xs, ys) clear = np.minimum(np.minimum(X, W - X), np.minimum(Y, D - Y)) # 外壁まで self.obs = [o for o, _ in all_obstacles(L, standby=standby)] for x0, y0, x1, y1 in self.obs: dx = np.maximum(np.maximum(x0 - X, X - x1), 0.0) dy = np.maximum(np.maximum(y0 - Y, Y - y1), 0.0) np.minimum(clear, np.hypot(dx, dy), out=clear) self.clear = clear self.occ = clear <= 1e-9 def free(self, width): # 幅 width 以上の通路は必ず通れる。width−res 未満は必ず通れない(マス目の中心が通路の真ん中に来るとは限らないため) return self.clear >= width / 2 - self.res / 2 - 1e-9 def cell(self, x, y): return int(y / self.res), int(x / self.res) def graph(self, free): ny, nx = free.shape idx = np.arange(ny * nx).reshape(ny, nx) rows, cols, wts = [], [], [] for dy, dx in ((0, 1), (1, 0), (1, 1), (1, -1)): a = free[max(0, -dy):ny - max(0, dy), max(0, -dx):nx - max(0, dx)] ys0, ys1 = max(0, -dy), ny - max(0, dy) xs0, xs1 = max(0, -dx), nx - max(0, dx) src = free[ys0:ys1, xs0:xs1] dst = free[ys0 + dy:ys1 + dy, xs0 + dx:xs1 + dx] ok = src & dst if dy and dx: # 斜めは両隣が通れるときだけ(すり抜け禁止) ok &= free[ys0:ys1, xs0 + dx:xs1 + dx] & free[ys0 + dy:ys1 + dy, xs0:xs1] s = idx[ys0:ys1, xs0:xs1][ok] d = idx[ys0 + dy:ys1 + dy, xs0 + dx:xs1 + dx][ok] w = self.res * (math.sqrt(2) if dy and dx else 1.0) rows += [s, d] cols += [d, s] wts += [np.full(len(s), w), np.full(len(s), w)] n = ny * nx return sparse.csr_matrix((np.concatenate(wts), (np.concatenate(rows), np.concatenate(cols))), shape=(n, n)) def run(self, free, sources): """sources はマス目の番号の並び。複数なら、そのどれからでも0として測る""" G = self.graph(free) n = G.shape[0] if len(sources) == 1: dist, pred = dijkstra(G, indices=sources[0], return_predecessors=True) return dist.reshape(free.shape), pred # 複数の出発点:仮の点を1つ足してつなぐ G = sparse.vstack([sparse.hstack([G, sparse.csr_matrix((n, 1))]), sparse.csr_matrix((np.full(len(sources), 1e-9), (np.zeros(len(sources), int), sources)), shape=(1, n + 1))]).tocsr() dist, pred = dijkstra(G, indices=n, return_predecessors=True) return dist[:n].reshape(free.shape), pred[:n] def stop_cells(self, side, width, reach): """座る側の前で止まれるマス目:辺から width/2 −res 〜 width/2 + reach の帯で、辺の両端を除いた所""" (ax, ay), (bx, by), (nx, ny) = side m = SETTINGS["side_end_margin"] lo, hi = width / 2 - self.res, width / 2 + reach X, Y = np.meshgrid(self.xs, self.ys) if nx: d = (X - ax) * nx along = (Y >= ay + m) & (Y <= by - m) else: d = (Y - ay) * ny along = (X >= ax + m) & (X <= bx - m) return (d >= lo) & (d <= hi) & along, d def sight(self, cy, cx, side, skip_rect): """止まる位置から辺までの線の上に、ほかの障害物がないか(線と矩形の交わりを計算で求める。縁に触れるだけはよい)""" (ax, ay), (bx, by), (nx, ny) = side px, py = self.xs[cx], self.ys[cy] tx, ty = (ax, py) if nx else (px, ay) for o in self.obs: if tuple(o) == tuple(skip_rect or ()): continue if seg_rect_hit((px, py), (tx, ty), o, open_rect=True): return False return True def best_stop(grid, dist, sides, width, reach, skip_rect=None): best = None for k, side in sides.items(): band, _ = grid.stop_cells(side, width, reach) cand = np.where(band & np.isfinite(dist)) if not len(cand[0]): continue order = np.argsort(dist[cand]) for o in order: # 近い順に全部の候補を見る(打ち切ると、遠い所だけ見通せる辺を見落とすため) cy, cx = cand[0][o], cand[1][o] if grid.sight(cy, cx, side, skip_rect): if best is None or dist[cy, cx] < best[0]: best = (float(dist[cy, cx]), cy, cx, k) break return best def trace(pred, grid, cy, cx): n = grid.nx i = cy * n + cx pts = [] while i >= 0 and i < grid.nx * grid.ny: pts.append(((i % n + 0.5) * grid.res, (i // n + 0.5) * grid.res)) i = pred[i] return pts[::-1] def analyze(L, res, ctx=None): """ctx に dict を渡すと、計算に使ったマス目・道のり・座る側を入れて返す(Unity 用の書き出しに使う)""" S = SETTINGS model = ROBOT_MODELS[S["model"]] pw, nw, hw = model["pass_width"], S["normal_width"], S["human_width"] g = Grid(L, res) sx, sy = L["start"] cy, cx = g.cell(sx, sy) errs = [] if not (0 <= cy < g.ny and 0 <= cx < g.nx): raise ValueError("出発点が店の外にある") f_pass, f_norm, f_hum = g.free(pw), g.free(nw), g.free(hw) if not f_pass[cy, cx]: raise ValueError(f"出発点(受け渡し口の前)がロボットの通れない場所にある(余裕 {g.clear[cy, cx]:.2f}m)") s0 = cy * g.nx + cx d_norm, p_norm = g.run(f_norm, [s0]) if f_norm[cy, cx] else (np.full(f_norm.shape, np.inf), None) d_pass, p_pass = g.run(f_pass, [s0]) # 待機場所の1台ずつ:ほかの台が止まったまま、その台が受け渡し口の前(出発点)まで出られるか # その台の区画だけを障害物から外し、区画の真ん中から出発点まで、通れる幅の道がつながっているかを見る(つながりは上下左右) slots = standby_slots(L["robot_standby"]) for k in range(len(slots)): gk = Grid(L, res, standby=[r for j, r in enumerate(slots) if j != k]) fk = gk.free(pw) lab, _ = ndimage.label(fk) r = slots[k] ky, kx = gk.cell((r[0] + r[2]) / 2, (r[1] + r[3]) / 2) if not fk[ky, kx] or lab[ky, kx] != lab[cy, cx]: errs.append(("待機場所", f"待機場所の{k + 1}台目が、ほかの台が止まったままでは受け渡し口の前まで出られない")) sides = [open_sides(L, i) for i in range(len(L["tables"]))] res_t = [] for i, t in enumerate(L["tables"]): r = dict(id=t["id"], seats=t["seats"], open_sides=list(sides[i])) b = best_stop(g, d_norm, sides[i], nw, S["stop_reach"], fp(t)) if p_norm is not None else None if b: r.update(category="robot", dist=round(b[0], 2), stop=[round(g.xs[b[2]], 3), round(g.ys[b[1]], 3)], side=b[3], path=trace(p_norm, g, b[1], b[2])) else: b = best_stop(g, d_pass, sides[i], pw, S["stop_reach"], fp(t)) if b: r.update(category="narrow", dist=round(b[0], 2), stop=[round(g.xs[b[2]], 3), round(g.ys[b[1]], 3)], side=b[3], path=trace(p_pass, g, b[1], b[2])) else: r.update(category="human") res_t.append(r) # 人が運ぶ卓:ふだんの道でロボットが行ける所(受け取る所)から、人が歩く距離 hum = [r for r in res_t if r["category"] == "human"] if hum: reach_cells = np.where(np.isfinite(d_norm).ravel() & f_hum.ravel())[0] if p_norm is not None else np.array([s0]) if len(reach_cells) == 0: reach_cells = np.array([s0]) d_h, p_h = g.run(f_hum, list(reach_cells)) for r in hum: i = [t["id"] for t in L["tables"]].index(r["id"]) b = best_stop(g, d_h, sides[i], hw, S["stop_reach"], fp(L["tables"][i])) if b is None: errs.append(("届かない卓", f"{r['id']} は人も運べない(通路がない)")) r.update(human_walk=None) else: r.update(human_walk=round(b[0], 2), stop=[round(g.xs[b[2]], 3), round(g.ys[b[1]], 3)], side=b[3], path=trace(p_h, g, b[1], b[2])) # 道の一番狭い所(ロボットの道の上の余裕×2) for r in res_t: if r.get("path") and r["category"] in ("robot", "narrow"): r["narrowest"] = round(min(2 * g.clear[int(y / res), int(x / res)] for x, y in r["path"]), 2) if ctx is not None: ctx.update(grid=g, d_norm=d_norm if p_norm is not None else None, d_pass=d_pass, sides=sides) return res_t, errs def summarize(L, res_t): S, model = SETTINGS, ROBOT_MODELS[SETTINGS["model"]] cat = lambda c: [r for r in res_t if r["category"] == c] rob, nar, hum = cat("robot"), cat("narrow"), cat("human") dists = [r["dist"] for r in rob + nar] walks = [r["human_walk"] for r in hum if r.get("human_walk") is not None] return dict( name=L["name"], seats=sum(t["seats"] for t in L["tables"]), tables=len(L["tables"]), model=S["model"], pass_width=model["pass_width"], normal_width=S["normal_width"], robot_tables=len(rob), robot_seats=sum(r["seats"] for r in rob), narrow_tables=len(nar), narrow_seats=sum(r["seats"] for r in nar), human_tables=len(hum), human_seats=sum(r["seats"] for r in hum), robot_dist_mean=round(sum(dists) / len(dists), 1) if dists else None, robot_dist_max=max(dists) if dists else None, human_walk_total=round(sum(walks), 1) if walks else 0.0, human_walk_max=max(walks) if walks else None, ) # ================================================================ 描く INK, MUTED, BACK, SEAT, SOFA = gen.INK, gen.MUTED, gen.BACK, gen.SEAT, gen.SOFA ROBOT, DRINK, DISH, NG = gen.ROBOT, gen.DRINK, gen.DISH, gen.NG NARROW, HUMAN = "#b08800", "#7048e8" CAT_COLOR = {"robot": INK, "narrow": NARROW, "human": HUMAN} def draw_simple(ax, t): """記憶の店の家具:4人の角テーブル・丸テーブル・背中合わせのスツール席""" x, y, w, h = t["x"], t["y"], t["w"], t["h"] if t["type"] == "t4": ax.add_patch(Rectangle((x, y), w, h, fc=SEAT, ec=INK, lw=1.1, zorder=3)) elif t["type"] == "r4": ax.add_patch(Circle((x + w / 2, y + h / 2), w / 2, fc=SEAT, ec=INK, lw=1.1, zorder=3)) elif t["type"] == "long6": cx, cy = x + w / 2, y + h / 2 ax.add_patch(Rectangle((cx - 1.6, cy - 0.35), 3.2, 0.7, fc=SEAT, ec=INK, lw=1.1, zorder=3)) for dx in (-1.0, 0.0, 1.0): for dy in (-0.75, 0.75): ax.add_patch(Circle((cx + dx, cy + dy), 0.22, fc=SEAT, ec=INK, lw=0.9, zorder=3)) ax.text(x + w / 2, y + h / 2, str(t["seats"]), ha="center", va="center", fontsize=8, color=INK, zorder=5) def draw_lsofa_table(ax, t, ec): x, y, w, h = t["x"], t["y"], t["w"], t["h"] # 変形前の内側の角:x+0.55w〜x+w、y+0.45h〜y+h ox0, oy0, ox1, oy1 = x + 0.55 * w, y + 0.45 * h, x + w, y + h cx, cy = (ox0 + ox1) / 2, (oy0 + oy1) / 2 if t.get("flip"): cy = 2 * y + h - cy if t.get("mirror"): cx = 2 * x + w - cx ax.add_patch(Rectangle((cx - 0.375, cy - 0.6), 0.75, 1.2, fc=SEAT, ec=ec, lw=0.9, zorder=3.5)) def draw(ax, L, res_t, summ, conv): W, D = L["outer"]["w"], L["outer"]["d"] ax.set_xlim(-1.2, W + 1.2) ax.set_ylim(-2.3, D + 0.6) ax.set_aspect("equal") ax.axis("off") # 外壁と入口 e = L["entrance"] if L.get("outline"): pts = L["outline"] + [L["outline"][0]] for (xa, ya), (xb, yb) in zip(pts, pts[1:]): if e["side"] == "bottom" and ya == yb == 0: ax.plot([min(xa, xb), e["a0"]], [0, 0], color=INK, lw=3) ax.plot([e["a1"], max(xa, xb)], [0, 0], color=INK, lw=3) else: ax.plot([xa, xb], [ya, yb], color=INK, lw=3) edges = {} if L.get("outline") else {"bottom": ((0, 0), (W, 0)), "top": ((0, D), (W, D)), "left": ((0, 0), (0, D)), "right": ((W, 0), (W, D))} for side, (a, b) in edges.items(): if side != e["side"]: ax.plot([a[0], b[0]], [a[1], b[1]], color=INK, lw=3) elif side in ("bottom", "top"): ax.plot([0, e["a0"]], [a[1], a[1]], color=INK, lw=3) ax.plot([e["a1"], W], [a[1], a[1]], color=INK, lw=3) else: ax.plot([a[0], a[0]], [0, e["a0"]], color=INK, lw=3) ax.plot([a[0], a[0]], [e["a1"], D], color=INK, lw=3) m = (e["a0"] + e["a1"]) / 2 pos = {"bottom": (m, -0.5), "top": (m, D + 0.4), "left": (-0.7, m), "right": (W + 0.7, m)}[e["side"]] ax.text(*pos, "入口" if e["side"] in ("bottom", "top") else "入\n口", ha="center", va="center", fontsize=10, weight="bold") for r in L["rooms"]: if r["kind"] == "outside": continue fc = {"back": BACK, "guest": "#f8f9fa", "drink": "#d0ebff"}[r["kind"]] ec = DRINK if r["kind"] == "drink" else INK x0, y0, x1, y1 = r["rect"] ax.add_patch(Rectangle((x0, y0), x1 - x0, y1 - y0, fc=fc, ec=ec, lw=1.0, zorder=2)) tall = (y1 - y0) > (x1 - x0) * 1.6 text = "\n".join(r["label"]) if tall and len(r["label"]) <= 5 else r["label"] ax.text((x0 + x1) / 2, (y0 + y1) / 2, text, ha="center", va="center", fontsize=8 if len(r["label"]) > 5 else 9, color=DRINK if r["kind"] == "drink" else INK, zorder=5) for w in L["walls"]: x0, y0, x1, y1 = w["rect"] ax.add_patch(Rectangle((x0, y0), x1 - x0, y1 - y0, fc="#868e96", ec="#495057", lw=0.5, zorder=4)) for dz in L.get("toilet_doors", []): x0, y0, x1, y1 = dz ax.add_patch(Rectangle((x0, y0), x1 - x0, y1 - y0, fc="none", ec=MUTED, lw=0.8, ls=":", zorder=2)) for d in L["dish_up"]: x0, y0, x1, y1 = d["rect"] ax.add_patch(Rectangle((x0, y0), x1 - x0, y1 - y0, fc=DISH, ec=DISH, zorder=4)) for d in L.get("doors", []): # 店員の出入り口:扉あり=白抜き+点線、扉なし=部屋の枠線を消して開いて見せる x0, y0, x1, y1 = d["rect"] if d.get("door", True): ax.add_patch(Rectangle((x0, y0), x1 - x0, y1 - y0, fc="white", ec=INK, lw=0.8, ls="--", zorder=4.5)) else: ax.add_patch(Rectangle((x0 + 0.02, y0), x1 - x0 - 0.04, (y1 - y0) + 0.06, fc="white", ec="none", zorder=4.5)) # 道 for r in res_t: if r.get("path"): xs, ys = zip(*r["path"]) if r["category"] == "human": ax.plot(xs, ys, color=HUMAN, lw=1.0, ls=(0, (3, 2)), alpha=0.7, zorder=2.6) else: ax.plot(xs, ys, color=ROBOT if r["category"] == "robot" else NARROW, lw=0.9, alpha=0.35, zorder=2.5) for t, r in zip(L["tables"], res_t): ec = CAT_COLOR[r["category"]] if t["type"] == "booth6L": gen.draw_seat(ax, t, False, False) draw_lsofa_table(ax, t, INK) # 色は外形の枠で示す ax.add_patch(Rectangle((t["x"], t["y"]), t["w"], t["h"], fc="none", ec=ec, lw=1.6, zorder=3.6)) else: if t["type"] in ("t4", "r4", "long6"): draw_simple(ax, t) else: gen.draw_seat(ax, t, False, False) if r["category"] != "robot": ax.add_patch(Rectangle((t["x"], t["y"]), t["w"], t["h"], fc="none", ec=ec, lw=1.8, zorder=3.6)) if r.get("stop"): ax.add_patch(Circle(tuple(r["stop"]), 0.09, fc=CAT_COLOR[r["category"]] if r["category"] != "robot" else ROBOT, ec="white", lw=0.5, zorder=6)) if t["id"] in conv: ax.text(t["x"] + t["w"] - 0.1, t["y"] + t["h"] - 0.15, "?", fontsize=9, color=NG, weight="bold", zorder=7) sb = L["robot_standby"] x0, y0, x1, y1 = sb["rect"] ax.add_patch(Rectangle((x0, y0), x1 - x0, y1 - y0, fc="#fff4e6", ec=ROBOT, lw=1.4, zorder=5)) body = (ROBOT_MODELS[SETTINGS["model"]]["body_width"] or 0.5) / 2 for i in range(sb["slots"]): ax.add_patch(Circle((x0 + (x1 - x0) * (i + 0.5) / sb["slots"], (y0 + y1) / 2), body, fc=ROBOT, ec="white", lw=0.8, zorder=6)) ax.add_patch(Circle(tuple(L["start"]), 0.12, fc="white", ec=ROBOT, lw=1.5, zorder=6)) s = summ ax.text(0, -1.0, f'{L["name"]} {s["seats"]}席・{W:g}m×{D:g}m', fontsize=10.5, color=INK, va="center", weight="bold") ax.text(0, -1.45, f'機種 {s["model"]}(通れる幅 {s["pass_width"]*100:.0f}cm)・ふだん走らせる幅 {s["normal_width"]*100:.0f}cm ' f'ロボットが届く {s["robot_tables"]}卓・{s["robot_seats"]}席/狭い道なら届く {s["narrow_tables"]}卓・{s["narrow_seats"]}席/' f'人が運ぶ {s["human_tables"]}卓・{s["human_seats"]}席', fontsize=8.8, color=INK, va="center") walk = f' 人が運ぶ分の歩く距離 合計{s["human_walk_total"]}m・最長{s["human_walk_max"]}m' if s["human_tables"] else "" ok = "マス目2.5cmと1.25cmで卓の分け方が同じ" if not conv else f'マス目で分け方が変わる卓 {len(conv)}(?印)' ax.text(0, -1.85, f'受け渡し口の前(白丸)から止まる位置まで 平均{s["robot_dist_mean"]}m・最長{s["robot_dist_max"]}m{walk} / {ok}', fontsize=8.8, color=INK, va="center") ax.text(0, -2.2, '線:オレンジ=ロボット(ふだんの道) 黄土=ロボット(狭い道) 紫の点線=人が運ぶ道 点=止まる位置 寸法はすべて仮', fontsize=8, color=MUTED, va="center") # ================================================================ 出力 BUILDS = [("p0_memory", famires_p0.p0_memory), ("p1_efficient_corner", gen.p1_efficient_corner), ("p2_efficient_narrow", gen.p2_efficient_narrow), ("p3_relaxed_center", gen.p3_relaxed_center), ("p4_relaxed_side", gen.p4_relaxed_side), ("p5_crammed", gen.p5_crammed)] def prepare(L): for i, t in enumerate(L["tables"]): t["id"] = f"T{i + 1:02d}" d = L["dish_up"][0]["rect"] off = max(SETTINGS["normal_width"], ROBOT_MODELS[SETTINGS["model"]]["pass_width"]) / 2 + 0.05 L["start"] = [round((d[0] + d[2]) / 2, 3), round(d[1] - off, 3)] # 受け渡し口の前(通路の幅いっぱいに離れた所) return L def export(L, res_t, summ, conv, nm, out, gen_id=None): meta = dict(schema_version=SCHEMA, generation=gen_id, units="m", coords="原点は店の外壁の内側の左下の角。x は右、y は奥(上)。Unity では x→X、y→Z、高さ→Y とする", doors="layout.doors は店員の出入り口(壁の切れ目・部屋の端の開口)。kind=staff。door=true は扉あり、false は扉のない開口", furniture_types=FURNITURE_TYPES, robot_model=dict(name=SETTINGS["model"], **ROBOT_MODELS[SETTINGS["model"]]), settings=SETTINGS) layout = {k: v for k, v in L.items()} json.dump(dict(meta=meta, layout=layout), open(out / f"{nm}.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1) slim = [{k: v for k, v in r.items() if k != "path"} | ({"path_end": r["path"][-1]} if r.get("path") else {}) for r in res_t] json.dump(dict(meta=meta, summary=summ, resolution_mismatch=conv, tables=slim), open(out / f"{nm}_analysis.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1) def export_unity(L, res_t, ctx, nm, out, gen_id): """Unity が店を建てるための細かい形(unity_export.py)を unity/{名前}.json に書き出す""" import unity_export meta = dict(schema_version="famires-unity/0.1", generation=gen_id, units="m", coords="原点は店の外壁の内側の左下の角。x は右、y は奥(上)。Unity では x→X、y→Z、高さ→Y とする", source=f"{nm}.json と {nm}_analysis.json(同じ generation)から決まった規則で作った。数字(卓・距離・止まる位置)は analysis と同じ", robot_model=dict(name=SETTINGS["model"], **ROBOT_MODELS[SETTINGS["model"]]), settings=SETTINGS) U = unity_export.build(L, res_t, ctx["grid"], ctx["d_norm"], ctx["d_pass"], ctx["sides"], 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) return U def generation_id(): """出力の版の番号:作り方のコードと記憶の店の原本、設定から決まる(同じなら毎回同じ)。 layout・analysis・summary に同じ番号を入れ、組み合わせが同じ版かを読む側で確かめられるようにする""" h = hashlib.sha256() for f in ("famires_v3.py", "famires_gen2.py", "famires_p0.py", "layout_v11.json", "unity_export.py"): h.update((HERE / f).read_bytes()) h.update(json.dumps(SETTINGS, sort_keys=True, ensure_ascii=False).encode()) return h.hexdigest()[:12] def main(): args = sys.argv[1:] out_arg = None if "--out" in args: i = args.index("--out") out_arg = Path(args[i + 1]) args = args[:i] + args[i + 2:] if args: SETTINGS["model"] = args[0] # サイトのリポジトリの中なら public/sim/layouts/ に出す(サイトの試算が正。Unity はここを読む) base = HERE.parent.parent / "public" / "sim" / "layouts" if not base.parent.exists(): base = HERE / "variants3" out = out_arg or (base if SETTINGS["model"] == "BellaBot" else base / SETTINGS["model"].replace(" ", "_")) out.mkdir(parents=True, exist_ok=True) gen_id = generation_id() # いったん作業用のフォルダに全部出し、6つ全部が決まりを守ったときだけ、出力先へまとめて入れ替える。 # 1つでも違反があれば出力先には何も書かない(前の版の一式がそのまま残り、新旧が混ざらない) tmp = Path(tempfile.mkdtemp(prefix=".build_", dir=out)) allsum, failed = [], False try: for nm, fn in BUILDS: L = prepare(fn()) v = check_rules(L) if v: # 決まりに違反があれば、重い計算に進まない res_a = None else: try: ctx = {} res_a, err_a = analyze(L, SETTINGS["res"], ctx) res_b, err_b = analyze(L, SETTINGS["check_res"]) v += err_a + [e for e in err_b if e not in err_a] except ValueError as ex: v.append(("出発点", str(ex))) if v: failed = True print(f"[NG] {nm}: {L['name']}") for k, msg in v: print(f" - {k}: {msg}") allsum.append(dict(file=nm, name=L["name"], status="NG", violations=[f"{k}: {m}" for k, m in v])) continue conv = [a["id"] for a, b in zip(res_a, res_b) if a["category"] != b["category"]] summ = summarize(L, res_a) fig, ax = plt.subplots(figsize=(14, 11), dpi=120) draw(ax, L, res_a, summ, conv) fig.savefig(tmp / f"{nm}.png", bbox_inches="tight", facecolor="white") plt.close(fig) export(L, res_a, summ, conv, nm, tmp, gen_id) export_unity(L, res_a, ctx, nm, tmp, gen_id) allsum.append(dict(file=nm, status="OK", generation=gen_id, resolution_mismatch=conv, **summ)) print(f"[OK] {nm}: {summ} マス目の差: {conv}") if failed: print(f"\n違反があるので、{out} には何も書いていない(前の版のまま)") sys.exit(1) json.dump(allsum, open(tmp / "summary.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1) publish(tmp, out) finally: shutil.rmtree(tmp, ignore_errors=True) def publish(tmp, out): """作業用のフォルダ tmp の一式を、出力先 out へ入れ替える。途中で1つでも失敗したら、入れ替えた分を元に戻す(全部か、何もなしか)。 summary.json は最後に入れ替える。読む側は summary の generation と、各ファイルの meta.generation が同じかを確かめること""" files = sorted((f.relative_to(tmp) for f in tmp.rglob("*") if f.is_file()), key=lambda f: (str(f) == "summary.json", str(f))) backup = tmp / "_backup" backup.mkdir() done = [] # (相対パス, 前にあったか) try: for rel in files: dst = out / rel dst.parent.mkdir(parents=True, exist_ok=True) existed = dst.exists() if existed: (backup / rel).parent.mkdir(parents=True, exist_ok=True) shutil.copy2(dst, backup / rel) os.replace(tmp / rel, dst) done.append((rel, existed)) except BaseException: for rel, existed in reversed(done): if existed: os.replace(backup / rel, out / rel) else: (out / rel).unlink(missing_ok=True) print(f"\n出力先へ入れ替える途中で失敗したので、{out} を前の版に戻した") raise if __name__ == "__main__": main()