"""ハコブくんの軽い3Dモデルを Blender（bpy）で作り、Unity 用に書き出す。

使い方:
  python3 hakobu_build.py --out <出力フォルダ> [--W 0.459 --D 0.549 --H 1.081] [--render]

大きさの既定は、病院の搬送ロボットのシミュレーションのロボット
（p2m-site tools/hospital_sim/hospital-unity-params.json の robot.size_m）。
外形は hakobu_profiles.json（三面図から測った外形）から作る。
向き: Blender では前が -Y・上が +Z。FBX は Unity で前が +Z・上が +Y になるように書き出す。
"""
import argparse
import json
import math
import os
import sys

import bpy  # noqa: I001  bpy を先に読む（bmesh・mathutils は bpy が用意する）
import bmesh
from mathutils import Vector
from PIL import Image, ImageDraw, ImageFilter

HERE = os.path.dirname(os.path.abspath(__file__))

# ---------------------------------------------------------------- 色（画像の中の色の区画）
COL = {
    "white": (236, 234, 229),
    "band": (214, 212, 207),      # 頭の横の帯
    "seam": (186, 184, 179),      # 継ぎ目の線
    "door": (222, 220, 215),
    "base": (78, 79, 82),         # 台
    "black": (30, 31, 33),        # バンパー・取っ手・口
    "screen": (14, 14, 16),
    "green": (96, 236, 140),
    "drawer_in": (196, 195, 190),
    "tray": (206, 228, 240),
    "eye": (250, 250, 250),
    "mouth": (84, 170, 236),
}
EMIT = {"green": (54, 227, 122)}
ATLAS = 1024
# 区画（左上の x, y, 幅, 高さ）
R_FACE = (0, 0, 512, 488)
R_SIDE = (512, 0, 256, 512)
R_FRONT = (768, 0, 256, 512)
R_BACK = (0, 512, 256, 512)
SW_ORIGIN = (256, 512)
SW_CELL = 64
SWATCH_NAMES = ["white", "band", "base", "black", "screen", "green", "drawer_in", "tray", "door"]


def swatch_px(name):
    i = SWATCH_NAMES.index(name)
    cols = (ATLAS - SW_ORIGIN[0]) // SW_CELL
    x = SW_ORIGIN[0] + (i % cols) * SW_CELL
    y = SW_ORIGIN[1] + (i // cols) * SW_CELL
    return x, y


def swatch_uv(name):
    x, y = swatch_px(name)
    return ((x + SW_CELL / 2) / ATLAS, 1 - (y + SW_CELL / 2) / ATLAS)


# ---------------------------------------------------------------- 三面図から測った位置（正規化）
# 正面: 幅は全幅=1（中心0）、高さは床0〜てっぺん1。横: 奥行きは全奥行き=1（前が負）。
F = {
    "screen_hw": 0.3094, "screen_t": (0.7216, 0.9639),
    "eye_dx": 0.1596, "eye_t": 0.8473,
    "drawer_hw": 0.4247, "drawer_t": (0.3286, 0.6203),
    "handle_hw": 0.1161, "handle_t": (0.5292, 0.5773),
    "bumper_t": (0.174, 0.235), "bumper_back": -0.303,
    "slot_hw": 0.0798, "slot_t": (0.1426, 0.1649),
    "base_t": (0.058, 0.113),
    "wheel_x": 0.3525, "wheel_y": (-0.3412, 0.3135),
    "ring_c": (0.0217, 0.4661),
    "pod": {"d": (-0.3428, 0.4224), "t": (0.173, 0.762),
            "r": {"ft": (0.203, 0.112), "bt": (0.03, 0.02), "bb": (0.139, 0.129), "fb": (0.116, 0.118)}},
    "band_t": (0.7915, 0.895),
    "head_seam_t": 0.703,
    # 後ろ（見る人から見た左右。u は左端0〜右端1）
    "back": {
        "notch": (0.0534, (0.8724, 0.8892)),
        "ctrl": (0.1988, (0.6279, 0.7213)),
        "door": (0.3352, (0.3323, 0.6248)),
        "panel": (0.3412, (0.1718, 0.3375)),
        "strip": (0.2829, (0.2882, 0.3107)),
        "grill_l": ((-0.299, -0.0463), (0.1804, 0.2648)),
        "grill_r": ((0.0411, 0.3049), (0.1804, 0.2648)),
    },
}


# ---------------------------------------------------------------- 2D の形
def rrect(x0, x1, y0, y1, radii, seg=3):
    """角ごとに半径 (rx, ry) を持つ角丸の四角。反時計回り。
    radii: dict br/tr/tl/bl → (rx, ry)。y0 側を下とする。"""
    w, h = x1 - x0, y1 - y0
    out = []
    spec = [("br", x1, y0, -90), ("tr", x1, y1, 0), ("tl", x0, y1, 90), ("bl", x0, y0, 180)]
    for key, cx, cy, a0 in spec:
        rx, ry = radii[key]
        rx = max(1e-5, min(rx, w * 0.49))
        ry = max(1e-5, min(ry, h * 0.49))
        ccx = cx - rx if key in ("br", "tr") else cx + rx
        ccy = cy + ry if key in ("br", "bl") else cy - ry
        for k in range(seg + 1):
            a = math.radians(a0 + 90 * k / seg)
            out.append((ccx + rx * math.cos(a), ccy + ry * math.sin(a)))
    return out


def ellipse(cx, cy, rx, ry, n):
    return [(cx + rx * math.cos(2 * math.pi * k / n), cy + ry * math.sin(2 * math.pi * k / n)) for k in range(n)]


def signed_area(pts):
    return 0.5 * sum(pts[i][0] * pts[(i + 1) % len(pts)][1] - pts[(i + 1) % len(pts)][0] * pts[i][1] for i in range(len(pts)))


# ---------------------------------------------------------------- bmesh の部品
def prism(bm, pts, axis, lo, hi):
    """2D の形を axis の向きに lo〜hi まで押し出した柱。面の向きは最後にそろえる。"""
    if signed_area(pts) < 0:
        pts = pts[::-1]

    def v3(p, s):
        if axis == "z":
            return (p[0], p[1], s)
        if axis == "x":
            return (s, p[0], p[1])
        return (p[0], s, p[1])  # axis == "y": p=(x, z)

    a = [bm.verts.new(v3(p, lo)) for p in pts]
    b = [bm.verts.new(v3(p, hi)) for p in pts]
    faces = [bm.faces.new(a[::-1]), bm.faces.new(b)]
    n = len(pts)
    for i in range(n):
        j = (i + 1) % n
        faces.append(bm.faces.new((a[i], a[j], b[j], b[i])))
    return faces


def cap_edges(faces, coord_axis, value, eps=1e-6):
    """押し出した柱の、片方のふたの輪郭の辺"""
    idx = "xyz".index(coord_axis)
    edges = set()
    for f in faces:
        for e in f.edges:
            if all(abs(v.co[idx] - value) < eps for v in e.verts):
                if len(e.link_faces) == 2 and any(abs(lf.normal[idx]) < 0.9 for lf in e.link_faces):
                    edges.add(e)
    return list(edges)


def finish(bm):
    bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-6)
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)


def new_object(name, bm, parent=None, mat=None):
    me = bpy.data.meshes.new(name)
    bm.to_mesh(me)
    bm.free()
    for p in me.polygons:
        p.use_smooth = True
    me.set_sharp_from_angle(angle=math.radians(35))
    if mat:
        me.materials.append(mat)
    ob = bpy.data.objects.new(name, me)
    bpy.context.scene.collection.objects.link(ob)
    if parent:
        ob.parent = parent
    return ob


def set_uv_all(bm, faces, uv):
    lay = bm.loops.layers.uv.verify()
    for f in faces:
        for l in f.loops:
            l[lay].uv = uv


# ---------------------------------------------------------------- 外形の段を選ぶ
def douglas_peucker(pts, tol):
    if len(pts) < 3:
        return pts
    a, b = Vector(pts[0]), Vector(pts[-1])
    ab = b - a
    best, idx = -1, 0
    for i in range(1, len(pts) - 1):
        p = Vector(pts[i])
        t = max(0, min(1, (p - a).dot(ab) / max(ab.dot(ab), 1e-12)))
        d = (p - (a + ab * t)).length
        if d > best:
            best, idx = d, i
    if best > tol:
        return douglas_peucker(pts[: idx + 1], tol)[:-1] + douglas_peucker(pts[idx:], tol)
    return [pts[0], pts[-1]]


def shell_levels(prof, W, D, H, t0=0.105, t1=0.996, tol=0.0022):
    rows = [r for r in prof["levels"] if t0 <= r[0] <= t1]
    bz = F["bumper_t"]
    # バンパーの帯の所は、殻をバンパーの上の溝の線までへこませる（帯は別の部品で前に出す）
    groove = min(rows, key=lambda r: abs(r[0] - 0.232))[2]
    pts = []
    for t, w, df, db in rows:
        if bz[0] - 0.006 <= t <= bz[1] + 0.012:
            df = max(df, groove)
        pts.append((t * H, w * W, df * D, db * D))
    sel = douglas_peucker(pts, tol)
    # 色や部品の境目は必ず段にする
    must = [bz[0] - 0.006, bz[1] + 0.012, F["drawer_t"][0], F["drawer_t"][1]]
    have = {round(p[0], 5) for p in sel}
    for mt in must:
        p = min(pts, key=lambda q: abs(q[0] - mt * H))
        if round(p[0], 5) not in have:
            sel.append(p)
    sel.sort(key=lambda p: p[0])
    return sel


BELLY = 1.0  # 胴の角だけに掛ける追加の倍率（--belly）
PODS = False  # 横の丸いふくらみ（--pods）
SOFT = 1.0   # 角の丸さの倍率（人の粘土の見た目に合わせるときは --soft で大きくする）


def corner_r(z, H, a, depth, front):
    t = z / H
    if t < 0.70:
        r = 0.022 if front else 0.05
    elif t < 0.80:
        k = (t - 0.70) / 0.10
        r = (0.022 * (1 - k) + 0.026 * k) if front else (0.05 * (1 - k) + 0.045 * k)
    else:
        r = 0.026 if front else 0.045
    # 丸めるのは体だけ。顔の画面のある頭（0.70 から上）は元のまま（画面が浮いて見えないように）
    w = 1.0 if t < 0.64 else (0.0 if t > 0.70 else (0.70 - t) / 0.06)
    soft = 1 + (SOFT - 1) * w
    r *= soft if front else (1 + (soft - 1) * 0.6)
    r *= 1 + (BELLY - 1) * w   # お腹（胴）の角をさらに丸める
    return min(r, a * 0.45, depth * 0.45)


def dome_levels(levels):
    """てっぺんの縁を丸める：上の端から半径 r の範囲を、円に沿って内側へ寄せた段に置きかえる"""
    if True:   # 頭のてっぺんは丸めない（顔のまわりが変わるため）
        return levels
    z_top = levels[-1][0]
    r0 = 0.022 * SOFT
    base = min(levels, key=lambda l: abs(l[0] - (z_top - r0)))   # 丸め始める高さの段の幅と奥行きを使う
    _, a, df, db = base
    r = min(r0, a * 0.35, (db - df) * 0.3)
    out = [l for l in levels if l[0] < z_top - r - 1e-6]
    zs = [l[0] for l in levels]

    import numpy as np

    def at(z):   # 元の段を高さで補間する（頭の前の面は傾いているので、段ごとの前の位置を使う）
        return [float(np.interp(z, zs, [l[i] for l in levels])) for i in (1, 2, 3)]
    n = 6
    for k in range(1, n + 1):
        th = math.radians(90 * k / n)
        inset = r * (1 - math.cos(th))
        z = z_top - r + r * math.sin(th)
        a_, df_, db_ = at(min(z, z_top - 1e-4))
        out.append((z, a_ - inset, df_ + inset, db_ - inset))
    return out


# ---------------------------------------------------------------- 部品を作る
def build_shell(bm, levels, W, D, H, seg=3):
    seg = seg + (2 if SOFT > 1 else 0)
    levels = dome_levels(levels)
    rings = []
    for z, a, df, db in levels:
        rf = corner_r(z, H, a, db - df, True)
        rb = corner_r(z, H, a, db - df, False)
        pts = rrect(-a, a, df, db, {"br": (rf, rf), "tr": (rb, rb), "tl": (rb, rb), "bl": (rf, rf)}, seg)
        rings.append([bm.verts.new((x, y, z)) for x, y in pts])
    faces = []
    n = len(rings[0])
    for i in range(len(rings) - 1):
        A, B = rings[i], rings[i + 1]
        for k in range(n):
            j = (k + 1) % n
            faces.append(bm.faces.new((A[k], A[j], B[j], B[k])))
    faces.append(bm.faces.new(rings[-1]))
    faces.append(bm.faces.new(rings[0][::-1]))
    return faces


def uv_shell(bm, faces, W, D, H):
    lay = bm.loops.layers.uv.verify()
    bm.normal_update()
    for f in faces:
        n = f.normal
        ax = max(range(3), key=lambda i: abs(n[i]))
        for l in f.loops:
            x, y, z = l.vert.co
            if ax == 2:
                l[lay].uv = swatch_uv("white")
                continue
            if ax == 0:      # 横
                rx, ry, rw, rh = R_SIDE
                u = rx + (y / D + 0.5) * rw
            elif n[1] < 0:   # 前
                rx, ry, rw, rh = R_FRONT
                u = rx + (x / W + 0.5) * rw
            else:            # 後ろ（見る人の右が -X）
                rx, ry, rw, rh = R_BACK
                u = rx + (0.5 - x / W) * rw
            v = ry + (1 - z / H) * rh
            l[lay].uv = (u / ATLAS, 1 - v / ATLAS)


def build_base(bm, W, D, H):
    z0, z1 = F["base_t"][0] * H, F["base_t"][1] * H
    rb = 0.075 * (1 + (SOFT - 1) * 0.5)
    pts = rrect(-W / 2, W / 2, -D / 2, D / 2, {k: (rb, rb) for k in ("br", "tr", "tl", "bl")}, 4 + (2 if SOFT > 1 else 0))
    faces = prism(bm, pts, "z", z0, z1)
    bm.normal_update()
    top = cap_edges(faces, "z", z1)
    bot = cap_edges(faces, "z", z0)
    bmesh.ops.bevel(bm, geom=bot, offset=0.016 * SOFT, segments=2 + (1 if SOFT > 1 else 0), profile=0.5, affect="EDGES", clamp_overlap=True)
    bm.normal_update()
    top = [e for e in bm.edges if e.is_valid and all(abs(v.co.z - z1) < 1e-6 for v in e.verts) and len(e.link_faces) == 2
           and any(abs(f.normal.z) < 0.9 for f in e.link_faces)]
    bmesh.ops.bevel(bm, geom=top, offset=0.006 * SOFT, segments=1 + (2 if SOFT > 1 else 0), profile=0.5, affect="EDGES", clamp_overlap=True)
    return list(bm.faces)


def build_wheels(bm, W, D, H, r=0.036, width=0.036, seg=12):
    if SOFT > 1:   # 丸い見た目のとき：少し大きく太いタイヤを、上半分を台の中に隠して置き、角を丸める
        r, width, seg = 0.046, 0.052, 20
    faces = []
    for sx in (-1, 1):
        for wy in F["wheel_y"]:
            cx, cy = sx * F["wheel_x"] * W, wy * D
            pts = ellipse(cy, r, r, r, seg)  # (y, z) 平面の円（床に接する）
            fs = prism(bm, pts, "x", cx - width / 2, cx + width / 2)
            if SOFT > 1:
                bm.normal_update()
                es = cap_edges(fs, "x", cx - width / 2) + cap_edges(fs, "x", cx + width / 2)
                bmesh.ops.bevel(bm, geom=es, offset=0.013, segments=3, profile=0.5, affect="EDGES", clamp_overlap=True)
            faces += fs
    return faces


def build_bumper(bm, shell_lv, W, D, H):
    z0, z1 = F["bumper_t"][0] * H, F["bumper_t"][1] * H
    near = min(shell_lv, key=lambda l: abs(l[0] - (z0 + z1) / 2))
    a = near[1] + 0.006
    front = near[2] - 0.012
    # 前の角は殻の角より少しだけ大きく（殻の角が帯から突き出ないように）
    rf = max(0.045, corner_r((z0 + z1) / 2, H, near[1], near[3] - near[2], True) + 0.010)
    pts = rrect(-a, a, front, D * 0.3, {"br": (rf, rf), "tr": (0.02, 0.02), "tl": (0.02, 0.02), "bl": (rf, rf)}, 4 + (2 if SOFT > 1 else 0))
    faces = prism(bm, pts, "z", z0, z1)
    bm.normal_update()
    for zz in (z0, z1):
        es = [e for e in bm.edges if all(abs(v.co.z - zz) < 1e-6 for v in e.verts) and len(e.link_faces) == 2
              and any(abs(f.normal.z) < 0.9 for f in e.link_faces) and e.verts[0].co.y < 0]
        bmesh.ops.bevel(bm, geom=es, offset=min(0.009 * SOFT, 0.016), segments=2 + (1 if SOFT > 1 else 0), profile=0.5, affect="EDGES", clamp_overlap=True)
        bm.normal_update()
    cut_y = F["bumper_back"] * D
    geom = list(bm.verts) + list(bm.edges) + list(bm.faces)
    res = bmesh.ops.bisect_plane(bm, geom=geom, plane_co=(0, cut_y, 0), plane_no=(0, 1, 0), clear_outer=True)
    edges = [e for e in res["geom_cut"] if isinstance(e, bmesh.types.BMEdge)]
    bmesh.ops.holes_fill(bm, edges=edges, sides=0)
    return list(bm.faces)


def build_face_plate(bm, prof, W, D, H):
    """頭の傾いた前の面に乗る画面の板。返り値は前の面の向きの情報"""
    t0, t1 = F["screen_t"]
    lv = prof["levels"]
    p0 = min(lv, key=lambda r: abs(r[0] - t0))
    p1 = min(lv, key=lambda r: abs(r[0] - t1 - 0.004))
    a = Vector((0, p0[2] * D, t0 * H))
    b = Vector((0, p1[2] * D, t1 * H))
    up = (b - a).normalized()
    nrm = Vector((0, -up.z, up.y))  # 前（-Y 寄り）を向く
    if nrm.y > 0:
        nrm = -nrm
    hw = F["screen_hw"] * W
    L = (b - a).length
    pts = rrect(-hw, hw, 0, L, {k: (0.016, 0.016) for k in ("br", "tr", "tl", "bl")}, 3)   # 画面は丸めの対象にしない
    thick = 0.004
    base = a + nrm * 0.0055

    def P(u, v, s):
        return base + Vector((u, 0, 0)) + up * v + nrm * s

    if signed_area(pts) < 0:
        pts = pts[::-1]
    back = [bm.verts.new(P(u, v, -thick)) for u, v in pts]
    front = [bm.verts.new(P(u, v, 0.0)) for u, v in pts]
    ff = bm.faces.new(front[::-1])
    faces = [ff, bm.faces.new(back)]
    n = len(pts)
    for i in range(n):
        j = (i + 1) % n
        faces.append(bm.faces.new((back[i], back[j], front[j], front[i])))
    bm.normal_update()
    if ff.normal.dot(nrm) < 0:
        ff.normal_flip()
    lay = bm.loops.layers.uv.verify()
    fx, fy, fw, fh = R_FACE
    for f in faces:
        is_front = f is ff
        for l in f.loops:
            if is_front:
                rel = l.vert.co - base
                u = rel.x / (2 * hw) + 0.5
                v = rel.dot(up) / L
                l[lay].uv = ((fx + u * fw) / ATLAS, 1 - (fy + (1 - v) * fh) / ATLAS)
            else:
                l[lay].uv = swatch_uv("screen")
    return faces, (a, b, nrm, hw, L)


def build_ring(bm, W, D, H, x_side, r_out=0.122, r_in=0.1015, seg=36, proud=0.0006, depth=0.006):
    cy, cz = F["ring_c"][0] * D, F["ring_c"][1] * H
    faces, walls = [], []
    for sx in (-1, 1):
        xo = sx * (x_side + proud)
        xi = sx * (x_side - depth)
        outer = [(cy + r_out * math.cos(2 * math.pi * k / seg), cz + r_out * math.sin(2 * math.pi * k / seg)) for k in range(seg)]
        inner = [(cy + r_in * math.cos(2 * math.pi * k / seg), cz + r_in * math.sin(2 * math.pi * k / seg)) for k in range(seg)]
        vo = [bm.verts.new((xo, y, z)) for y, z in outer]
        vi = [bm.verts.new((xo, y, z)) for y, z in inner]
        wo = [bm.verts.new((xi, y, z)) for y, z in outer]
        wi = [bm.verts.new((xi, y, z)) for y, z in inner]
        for k in range(seg):
            j = (k + 1) % seg
            faces.append(bm.faces.new((vo[k], vo[j], vi[j], vi[k])))   # 光る面
            walls.append(bm.faces.new((wo[k], wo[j], vo[j], vo[k])))   # 外の縁
            walls.append(bm.faces.new((vi[k], vi[j], wi[j], wi[k])))   # 内の縁
    return faces, walls


def build_pods(bm, shell_lv, W, D, H, thick=0.022, bevel=0.020):
    """横の丸いふくらみ（輪の付いた横板）。マンガのハコブくんの、お腹の横の厚い丸い板。
    返り値は板の外の面の x（輪をここに乗せる）"""
    pod = F["pod"]
    z0, z1 = pod["t"][0] * H, pod["t"][1] * H
    near = [l for l in shell_lv if z0 <= l[0] <= z1]
    x_in = min(l[1] for l in near) - 0.005
    x_out = max(l[1] for l in near) + thick
    rr = {k: (pod["r"][src][0] * D, pod["r"][src][1] * H) for k, src in (("tl", "ft"), ("tr", "bt"), ("br", "bb"), ("bl", "fb"))}
    pts = rrect(pod["d"][0] * D, pod["d"][1] * D, z0, z1, rr, 8)   # (y, z) 平面
    faces = []
    for sx in (-1, 1):
        lo, hi = (x_in, x_out) if sx > 0 else (-x_out, -x_in)
        fs = prism(bm, pts, "x", lo, hi)
        bm.normal_update()
        outer = sx * x_out
        es = cap_edges(fs, "x", outer)
        bmesh.ops.bevel(bm, geom=es, offset=bevel, segments=4, profile=0.5, affect="EDGES", clamp_overlap=True)
        bm.normal_update()
        faces += fs
    return x_out


def build_drawer(bm, shell_lv, W, D, H):
    t0, t1 = F["drawer_t"]
    z0, z1 = t0 * H, t1 * H
    near = [l for l in shell_lv if z0 <= l[0] <= z1]
    front = min(l[2] for l in near)
    hw = F["drawer_hw"] * W
    # 前の板（殻の前の面より 4mm 前に出す）
    y_face = front - 0.0045
    r_lo, r_hi = 0.03 * (1 + (SOFT - 1) * 0.5), 0.012 * SOFT
    pts = rrect(-hw, hw, z0, z1, {"br": (r_lo, r_lo), "bl": (r_lo, r_lo), "tr": (r_hi, r_hi), "tl": (r_hi, r_hi)}, 3 + (2 if SOFT > 1 else 0))
    panel = prism(bm, pts, "y", y_face, front + 0.012)
    set_uv_all(bm, panel, swatch_uv("white"))
    bm.normal_update()
    edges = cap_edges(panel, "y", y_face)
    bmesh.ops.bevel(bm, geom=edges, offset=0.004 * (1 + (SOFT - 1) * 0.5), segments=1 + (1 if SOFT > 1 else 0), profile=0.5, affect="EDGES", clamp_overlap=True)
    # 取っ手（黒い横長の丸い板）
    h0, h1 = F["handle_t"][0] * H, F["handle_t"][1] * H
    hh = F["handle_hw"] * W
    rr = (h1 - h0) / 2
    hp = rrect(-hh, hh, h0, h1, {k: (rr, rr) for k in ("br", "tr", "tl", "bl")}, 3)
    handle = prism(bm, hp, "y", y_face - 0.0025, y_face + 0.002)
    set_uv_all(bm, handle, swatch_uv("black"))
    # 中の箱（ふたなし）
    bx0, bx1 = -hw + 0.012, hw - 0.012
    by0, by1 = front + 0.010, front + 0.30
    bz0, bz1 = z0 + 0.018, z1 - 0.035
    th = 0.006
    box = []
    box += prism(bm, rrect(bx0, bx1, by0, by1, {k: (0.004, 0.004) for k in ("br", "tr", "tl", "bl")}, 1), "z", bz0, bz0 + th)
    for (xa, xb, ya, yb) in [(bx0, bx1, by1 - th, by1), (bx0, bx0 + th, by0, by1), (bx1 - th, bx1, by0, by1)]:
        box += prism(bm, [(xa, ya), (xb, ya), (xb, yb), (xa, yb)], "z", bz0, bz1)
    set_uv_all(bm, box, swatch_uv("drawer_in"))
    return {"box": (bx0, bx1, by0, by1, bz0 + th), "y_face": y_face}


def build_tray(bm, cx, cy, z0, sx=0.150, sy=0.122, sz=0.032):
    rt = 0.008 * SOFT
    pts = rrect(cx - sx / 2, cx + sx / 2, cy - sy / 2, cy + sy / 2, {k: (rt, rt) for k in ("br", "tr", "tl", "bl")}, 2 + (1 if SOFT > 1 else 0))
    faces = prism(bm, pts, "z", z0, z0 + sz)
    set_uv_all(bm, faces, swatch_uv("tray"))
    return faces



# ---------------------------------------------------------------- 顔（表情集の「基本」）
# 動画の制作ベース hakobu.py の表情集と同じ形：目＝縦長の角丸（open）、口＝青い笑顔（mid）。
# 寸法は hakobu.py の値（表情集の画面の高さ 149 を単位にした長さ）。目の間隔だけは画面の幅に対する比で置く。
FACE_SCREEN_H = 149.0
FACE_EYE_WH = (22.5, 47.0)
FACE_EYE_GAP = 0.486      # 目の中心どうしの間隔（画面の幅に対する比）
FACE_EYE_Y = 0.483        # 目の中心の高さ（画面の上から、高さに対する比）
FACE_MOUTH = {"hw": 27, "depth": 25, "sag": 5, "rr": 5, "top": 0.671}
FACE_WHITE = (246, 248, 252)
FACE_BLUE = (87, 181, 248)
FACE_SCREEN = (19, 19, 17)


def draw_face_base(sw, sh, out_w, out_h, SS=8):
    import numpy as np
    import cv2
    Hs = FACE_SCREEN_H
    Ws = sw / sh * Hs
    W, Hh = int(Ws * SS), int(Hs * SS)
    white = np.zeros((Hh, W), np.uint8)
    blue = np.zeros((Hh, W), np.uint8)
    cx0 = Ws / 2
    ew, eh = FACE_EYE_WH
    ey = FACE_EYE_Y * Hs
    img = Image.fromarray(white)
    d = ImageDraw.Draw(img)
    for sgn in (-1, 1):
        ex = cx0 + sgn * FACE_EYE_GAP * Ws / 2
        d.rounded_rectangle([(ex - ew / 2) * SS, (ey - eh / 2) * SS, (ex + ew / 2) * SS, (ey + eh / 2) * SS], radius=ew / 2 * SS, fill=255)
    white = np.array(img)
    m = FACE_MOUTH
    t = np.linspace(0, 1, 80)
    top = m["top"] * Hs
    xs = cx0 - m["hw"] + 2 * m["hw"] * t
    upper = np.stack([xs, top + m["sag"] * (1 - (2 * t - 1) ** 2)], 1)
    lower = np.stack([xs[::-1], (top + m["depth"] * np.sqrt(np.clip(1 - (2 * t - 1) ** 2, 0, 1)))[::-1]], 1)
    poly = (np.concatenate([upper, lower]) * SS).astype(np.int32)
    cv2.fillPoly(blue, [poly], 255)
    k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * m["rr"] * SS + 1, 2 * m["rr"] * SS + 1))
    blue = cv2.morphologyEx(blue, cv2.MORPH_OPEN, k)
    # 目のまわりのにじむ光（表情集と同じく、白い目だけ）
    glow = cv2.GaussianBlur(white.astype(np.float32) / 255, (0, 0), 2.6 * SS) * 0.75
    alb = np.zeros((Hh, W, 3), np.float32) + np.array(FACE_SCREEN, np.float32)
    gcol = np.array((205, 225, 255), np.float32)
    alb = alb * (1 - glow[..., None]) + gcol * glow[..., None]
    a = white.astype(np.float32)[..., None] / 255
    alb = alb * (1 - a) + np.array(FACE_WHITE, np.float32) * a
    b = blue.astype(np.float32)[..., None] / 255
    alb = alb * (1 - b) + np.array(FACE_BLUE, np.float32) * b
    emi = gcol * glow[..., None] * 0.6
    emi = emi * (1 - a) + np.array(FACE_WHITE, np.float32) * 0.92 * a
    emi = emi * (1 - b) + np.array(FACE_BLUE, np.float32) * 0.85 * b
    A = Image.fromarray(np.clip(alb, 0, 255).astype(np.uint8)).resize((out_w, out_h), Image.LANCZOS)
    E = Image.fromarray(np.clip(emi, 0, 255).astype(np.uint8)).resize((out_w, out_h), Image.LANCZOS)
    return A, E


# ---------------------------------------------------------------- 色の画像
def draw_atlas(W, D, H, face_info, ss=2):
    S = ATLAS * ss
    alb = Image.new("RGB", (S, S), COL["white"])
    emi = Image.new("RGB", (S, S), (0, 0, 0))
    da, de = ImageDraw.Draw(alb), ImageDraw.Draw(emi)

    def box(r):
        return [c * ss for c in r]

    # 色の区画
    for name in SWATCH_NAMES:
        x, y = swatch_px(name)
        da.rectangle(box((x, y, x + SW_CELL, y + SW_CELL)), fill=COL[name] if name in COL else COL["white"])
        if name in EMIT:
            de.rectangle(box((x, y, x + SW_CELL, y + SW_CELL)), fill=EMIT[name])

    # 顔（画面）
    fx, fy, fw, fh = R_FACE
    da.rectangle(box((fx, fy, fx + fw, fy + fh)), fill=COL["screen"])
    a, b, nrm, hw, L = face_info
    sw, sh = 2 * hw, L  # 画面の実寸（m）

    def fpx(u_m, v_m):  # 画面の中心からの m（右・上が正）→ 画像の px
        return (fx + (u_m / sw + 0.5) * fw, fy + (0.5 - v_m / sh) * fh)

    face_alb, face_emi = draw_face_base(sw, sh, fw, fh)
    alb.paste(face_alb.resize((fw * ss, fh * ss), Image.LANCZOS), (fx * ss, fy * ss))
    emi.paste(face_emi.resize((fw * ss, fh * ss), Image.LANCZOS), (fx * ss, fy * ss))

    # 横の面（u: 前0〜後ろ1、v: 上0〜下1）
    sx0, sy0, sw_, sh_ = R_SIDE

    def spx(dn, t):
        return (sx0 + (dn + 0.5) * sw_, sy0 + (1 - t) * sh_)

    bt = F["band_t"]
    p0, p1 = spx(-0.5, bt[1]), spx(0.5, bt[0])
    da.rectangle(box((p0[0], p0[1], p1[0], p1[1])), fill=COL["band"])
    da.line(box((p0[0], p0[1], p1[0], p0[1])), fill=COL["seam"], width=2 * ss)
    pod = F["pod"]
    pts = rrect(pod["d"][0], pod["d"][1], pod["t"][0], pod["t"][1],
                {"tl": pod["r"]["ft"], "tr": pod["r"]["bt"], "br": pod["r"]["bb"], "bl": pod["r"]["fb"]}, 12)
    poly = [tuple(c * ss for c in spx(d, t)) for d, t in pts]
    da.line(poly + [poly[0]], fill=(176, 174, 169), width=3 * ss, joint="curve")

    # 前の面
    fx0, fy0, fw_, fh_ = R_FRONT

    def frpx(xn, t):
        return (fx0 + (xn + 0.5) * fw_, fy0 + (1 - t) * fh_)

    g = 0.0035 / W
    d0, d1 = F["drawer_t"]
    p0 = frpx(-F["drawer_hw"] - g, d1 + 0.0035 / H)
    p1 = frpx(F["drawer_hw"] + g, d0 - 0.0035 / H)
    da.rounded_rectangle(box((p0[0], p0[1], p1[0], p1[1])), radius=6 * ss, fill=(52, 53, 56))
    s0, s1 = F["slot_t"]
    p0, p1 = frpx(-F["slot_hw"], s1), frpx(F["slot_hw"], s0)
    da.rounded_rectangle(box((p0[0], p0[1], p1[0], p1[1])), radius=5 * ss, fill=COL["black"])
    hs = F["head_seam_t"]
    p0, p1 = frpx(-0.5, hs), frpx(0.5, hs)
    da.line(box((p0[0], p0[1], p1[0], p1[1])), fill=COL["seam"], width=2 * ss)

    # 後ろの面（見る人から見た向きで描く）
    bx0, by0, bw_, bh_ = R_BACK

    def bpx(un, t):
        return (bx0 + (un + 0.5) * bw_, by0 + (1 - t) * bh_)

    B = F["back"]
    hw_, (t0, t1) = B["notch"]
    p0, p1 = bpx(-hw_, t1), bpx(hw_, t0)
    da.rectangle(box((p0[0], p0[1], p1[0], p1[1])), fill=(70, 71, 74))
    p0, p1 = bpx(-0.5, F["band_t"][1]), bpx(0.5, F["band_t"][1])
    da.line(box((p0[0], p0[1], p1[0], p1[1])), fill=COL["seam"], width=2 * ss)
    hw_, (t0, t1) = B["door"]
    p0, p1 = bpx(-hw_, t1), bpx(hw_, t0)
    da.rounded_rectangle(box((p0[0], p0[1], p1[0], p1[1])), radius=4 * ss, fill=COL["door"], outline=COL["seam"], width=2 * ss)
    k0, k1 = bpx(-0.03, t1 - 0.003), bpx(0.03, t1 - 0.012)
    da.rectangle(box((k0[0], k0[1], k1[0], k1[1])), fill=(90, 91, 94))
    h0, h1 = bpx(hw_ - 0.022, 0.50), bpx(hw_ - 0.006, 0.44)
    da.rectangle(box((h0[0], h0[1], h1[0], h1[1])), fill=(90, 91, 94))
    hw_, (t0, t1) = B["ctrl"]
    p0, p1 = bpx(-hw_, t1), bpx(hw_, t0)
    da.rounded_rectangle(box((p0[0], p0[1], p1[0], p1[1])), radius=6 * ss, fill=COL["black"])
    cy = (p0[1] + p1[1]) / 2
    cx = bpx(-0.1114, 0)[0]
    r = 0.040 * bw_
    da.ellipse(box((cx - r, cy - r, cx + r, cy + r)), fill=(70, 71, 74), outline=(120, 121, 124), width=ss)
    cx = bpx(0.002, 0)[0]
    r = 0.026 * bw_
    da.rectangle(box((cx - r, cy - r, cx + r, cy + r)), fill=(70, 71, 74))
    cx = bpx(0.135, 0)[0]
    da.rectangle(box((cx - 0.036 * bw_, cy - 0.028 * bh_, cx + 0.036 * bw_, cy + 0.028 * bh_)), fill=(70, 71, 74))
    hw_, (t0, t1) = B["panel"]
    p0, p1 = bpx(-hw_, t1), bpx(hw_, t0)
    da.rounded_rectangle(box((p0[0], p0[1], p1[0], p1[1])), radius=5 * ss, fill=(40, 41, 44))
    hw_, (t0, t1) = B["strip"]
    p0, p1 = bpx(-hw_, t1), bpx(hw_, t0)
    rad = (p1[1] - p0[1]) / 2
    da.rounded_rectangle(box((p0[0], p0[1], p1[0], p1[1])), radius=rad * ss, fill=COL["green"])
    de.rounded_rectangle(box((p0[0], p0[1], p1[0], p1[1])), radius=rad * ss, fill=EMIT["green"])
    for key in ("grill_l", "grill_r"):
        (u0, u1), (t0, t1) = B[key]
        p0, p1 = bpx(u0, t1), bpx(u1, t0)
        rows_n = 6
        for i in range(rows_n):
            yy = p0[1] + (i + 0.5) * (p1[1] - p0[1]) / rows_n
            off = 0.5 if i % 2 else 0
            n = 5
            for j in range(n):
                xa = p0[0] + (j + off * 0.5 + 0.1) * (p1[0] - p0[0]) / (n + 0.5)
                xb = xa + 0.7 * (p1[0] - p0[0]) / (n + 0.5)
                da.rounded_rectangle(box((xa, yy - 1.0, xb, yy + 1.0)), radius=ss, fill=(14, 14, 15))

    alb = alb.resize((ATLAS, ATLAS), Image.LANCZOS)
    emi = emi.resize((ATLAS, ATLAS), Image.LANCZOS)
    return alb, emi


# ---------------------------------------------------------------- 材質
def make_material(alb_path, emi_path, strength=1.0):
    mat = bpy.data.materials.new("Hakobu_Mat")
    mat.use_nodes = True
    nt = mat.node_tree
    bsdf = nt.nodes.get("Principled BSDF")
    ta = nt.nodes.new("ShaderNodeTexImage")
    ta.image = bpy.data.images.load(alb_path)
    te = nt.nodes.new("ShaderNodeTexImage")
    te.image = bpy.data.images.load(emi_path)
    te.image.colorspace_settings.name = "sRGB"
    nt.links.new(ta.outputs["Color"], bsdf.inputs["Base Color"])
    nt.links.new(te.outputs["Color"], bsdf.inputs["Emission Color"])
    bsdf.inputs["Emission Strength"].default_value = strength
    bsdf.inputs["Roughness"].default_value = 0.55
    return mat


# ---------------------------------------------------------------- 全体
def build(W, D, H, out_dir):
    prof = json.load(open(os.path.join(HERE, "hakobu_profiles.json")))
    bpy.ops.wm.read_factory_settings(use_empty=True)
    sc = bpy.context.scene
    sc.unit_settings.system = "METRIC"
    sc.unit_settings.scale_length = 1.0

    root = bpy.data.objects.new("Hakobu", None)
    sc.collection.objects.link(root)

    levels = shell_levels(prof, W, D, H)

    # 画面の位置を先に決める（色の画像に使う）
    bm_face = bmesh.new()
    face_faces, face_info = build_face_plate(bm_face, prof, W, D, H)
    finish(bm_face)

    tex_dir = out_dir
    alb, emi = draw_atlas(W, D, H, face_info)
    alb_path = os.path.join(tex_dir, "hakobu_albedo.png")
    emi_path = os.path.join(tex_dir, "hakobu_emission.png")
    alb.save(alb_path)
    emi.save(emi_path)
    mat = make_material(alb_path, emi_path)

    # 本体: 殻・台・車輪・バンパー
    bm = bmesh.new()
    shell = build_shell(bm, levels, W, D, H)
    finish(bm)
    uv_shell(bm, [f for f in bm.faces], W, D, H)
    body_main = new_object("Hakobu_Shell_tmp", bm, None, None)

    bm = bmesh.new()
    build_base(bm, W, D, H)
    finish(bm)
    set_uv_all(bm, bm.faces, swatch_uv("base"))
    ob_base = new_object("Hakobu_Base_tmp", bm)

    bm = bmesh.new()
    build_wheels(bm, W, D, H)
    finish(bm)
    set_uv_all(bm, bm.faces, swatch_uv("black"))
    ob_wheels = new_object("Hakobu_Wheels_tmp", bm)

    bm = bmesh.new()
    build_bumper(bm, levels, W, D, H)
    finish(bm)
    set_uv_all(bm, bm.faces, swatch_uv("black"))
    ob_bump = new_object("Hakobu_Bumper_tmp", bm)

    # まとめて1つの本体にする
    body = join_objects("Hakobu_Body", [body_main, ob_base, ob_wheels, ob_bump])
    body.parent = root
    body.data.materials.clear()
    body.data.materials.append(mat)

    face = new_object("Hakobu_Face", bm_face, root, mat)

    # 輪: 横の面の外側（殻の横幅の一番広い所に合わせる）
    ring_lv = [l for l in levels if 0.38 * H <= l[0] <= 0.62 * H]
    x_side = max(l[1] for l in ring_lv)
    if PODS:   # 横のふくらみを付けたときは、輪をふくらみの外の面に乗せる
        bm = bmesh.new()
        x_side = build_pods(bm, levels, W, D, H)
        finish(bm)
        set_uv_all(bm, bm.faces, swatch_uv("door"))
        pods = new_object("Hakobu_Pods_tmp", bm)
        body2 = join_objects("Hakobu_Body", [body, pods])
        body2.parent = root
        body2.data.materials.append(mat)
        body = body2
    r_out = 0.5 * (313 / 2312 * H + 313 / 1529 * D)   # 三面図の輪の外の半径（高さと奥行きの間をとる）
    bm = bmesh.new()
    glow, walls = build_ring(bm, W, D, H, x_side, r_out=r_out, r_in=r_out * 261 / 313)
    set_uv_all(bm, glow, swatch_uv("green"))
    set_uv_all(bm, walls, swatch_uv("white"))
    finish(bm)
    ring = new_object("Hakobu_Ring", bm, root, mat)

    # 引き出しと中のトレー4枚
    bm = bmesh.new()
    dinfo = build_drawer(bm, levels, W, D, H)
    finish(bm)
    drawer = new_object("Hakobu_Drawer", bm, root, mat)
    bx0, bx1, by0, by1, bz = dinfo["box"]
    cxs = [(bx0 + (bx0 + bx1) / 2) / 2 + 0.004, ((bx0 + bx1) / 2 + bx1) / 2 - 0.004]
    cys = [by0 + (by1 - by0) * 0.27, by0 + (by1 - by0) * 0.71]
    k = 1
    for cy in cys:
        for cx in cxs:
            bm = bmesh.new()
            build_tray(bm, cx, cy, bz + 0.001)
            finish(bm)
            new_object(f"Hakobu_Tray_{k}", bm, root, mat)  # 入れ子にすると FBX の向きの変換が二重になるので根の直下に置く
            k += 1

    return {"root": root, "levels": levels, "face_info": face_info, "mat": mat, "drawer": drawer}


def join_objects(name, objs):
    bm = bmesh.new()
    for ob in objs:
        tmp = bmesh.new()
        tmp.from_mesh(ob.data)
        me = bpy.data.meshes.new("tmp")
        tmp.to_mesh(me)
        tmp.free()
        bm.from_mesh(me)
        bpy.data.meshes.remove(me)
    for ob in objs:
        me = ob.data
        bpy.data.objects.remove(ob)
        bpy.data.meshes.remove(me)
    me = bpy.data.meshes.new(name)
    bm.to_mesh(me)
    bm.free()
    for p in me.polygons:
        p.use_smooth = True
    me.set_sharp_from_angle(angle=math.radians(35))
    ob = bpy.data.objects.new(name, me)
    bpy.context.scene.collection.objects.link(ob)
    return ob


def stats():
    out = {}
    tot = 0
    for ob in bpy.data.objects:
        if ob.type != "MESH":
            continue
        n = sum(len(p.vertices) - 2 for p in ob.data.polygons)
        out[ob.name] = n
        tot += n
    out["_total_triangles"] = tot
    return out


def export(out_dir):
    fbx = os.path.join(out_dir, "hakobu.fbx")
    bpy.ops.export_scene.fbx(
        filepath=fbx, use_selection=False, object_types={"EMPTY", "MESH"},
        apply_unit_scale=True, apply_scale_options="FBX_SCALE_ALL",
        axis_forward="-Z", axis_up="Y", bake_space_transform=True,
        mesh_smooth_type="OFF", use_mesh_modifiers=True, use_triangles=True,
        add_leaf_bones=False, bake_anim=False, path_mode="STRIP", embed_textures=False,
    )
    glb = os.path.join(out_dir, "hakobu.glb")
    bpy.ops.export_scene.gltf(filepath=glb, export_format="GLB", export_yup=True, export_apply=True)
    return fbx, glb


def main():
    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else sys.argv[1:]
    ap = argparse.ArgumentParser()
    ap.add_argument("--out", required=True)
    ap.add_argument("--W", type=float, default=0.653)
    ap.add_argument("--D", type=float, default=0.760)
    ap.add_argument("--H", type=float, default=1.150)
    ap.add_argument("--render", action="store_true")
    ap.add_argument("--no-pods", dest="pods", action="store_false", help="横の丸いふくらみ（輪の付いた横板）を付けない")
    ap.add_argument("--belly", type=float, default=1.0, help="胴の角だけ、さらに丸める倍率")
    ap.add_argument("--soft", type=float, default=3.5, help="体の角の丸さの倍率（1 で三面図どおり。頭は常に三面図どおり）")
    args = ap.parse_args(argv)
    global SOFT, BELLY, PODS
    PODS = args.pods
    SOFT = args.soft
    BELLY = args.belly
    os.makedirs(args.out, exist_ok=True)
    info = build(args.W, args.D, args.H, args.out)
    st = stats()
    fbx, glb = export(args.out)
    bpy.ops.wm.save_as_mainfile(filepath=os.path.join(args.out, "hakobu.blend"))
    report = {"size_m": {"W": args.W, "D": args.D, "H": args.H}, "triangles": st,
              "shell_levels": len(info["levels"]), "files": [os.path.basename(fbx), os.path.basename(glb)]}
    json.dump(report, open(os.path.join(args.out, "hakobu_report.json"), "w"), ensure_ascii=False, indent=1)
    print(json.dumps(report, ensure_ascii=False, indent=1))


if __name__ == "__main__":
    main()
