"""ハコブくんの軽い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()