""" blender_author_buttondown.py (T-1089, buttondown_modern — per-body offset shell) Button-down shirt authored per body via the offset-shell route. Imports blender_author_offset_shell.py as a module and reuses its scene/build/offset/ solidify/logo-UV/export helpers; this file adds what the button-down needs beyond the t-shirt base: * long-sleeve coverage — torso + torso_upper + BOTH full arms (upper+lower), with a wrist-plane cut derived from the lowerarm bone (SLEEVE_END_FRAC), replacing the base script's upper-arm short-sleeve cut * seam WELD after join — the body segments share exact duplicated boundary rings (probe: 24-38 coincident verts/seam, zero near-misses), so a remove-doubles pass gives continuous normals across segment seams -> crack-free outward offset and no internal solidify rims (UVs live on loops, so UV seams survive the weld; weights are identical by construction) * per-PIXEL region mask bake (barycentric-interpolated body-local positions) instead of the base per-face classification — the placket and cuff boundaries cut through the middle of low-poly faces * PAINTED albedo — texture-carried identity per the style pin: placket band with stitch lines, button dots, collar + cuff seam lines, over the flat toon fabric noise. Baked per body because each body archetype has its own UV atlas (the shell inherits body UVs). Region convention (spec): collar=R (tint_0), body=G (tint_1), cuffs+placket=B (tint_2). A unused. Logo-capable OFF, but the UV2 chest channel is still authored (costs nothing; enables future brand variants). Run (per-body only — this garment ships the per-body route): tooling/blender --background --python \ tooling/garment-fit/blender_author_buttondown.py -- \ client/assets/characters/bodies \ client/assets/characters/clothing/buttondown_modern \ [--bodies average_m,child,...] [--offset 0.009] Writes per body: /.glb + /_mask.png Plus: /base_albedo.png (average_m's painted albedo) /reference_mask.png (copy of average_m_mask.png) Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060 (per-body authoring for offset shells). """ import os import shutil import sys import bmesh import bpy import numpy as np sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import blender_author_offset_shell as base # noqa: E402 # -------------------------------------------------------------------------- # Garment parameters (average_m metres; scaled per body by shoulder ratio) # -------------------------------------------------------------------------- COVERED_SEGMENTS = [ "seg_torso", "seg_torso_upper", "seg_arm_upper_l", "seg_arm_upper_r", "seg_arm_lower_l", "seg_arm_lower_r", ] OFFSET_M = 0.009 # slimmer standoff than the tee (12 mm) — dress shirt CLOTH_THICKNESS_M = 0.003 # thinner cloth than the tee (4 mm) SLEEVE_END_FRAC = 0.92 # of lowerarm bone length — long sleeve, ends above wrist CUFF_START_FRAC = 0.62 # of lowerarm bone length — last ~38% of forearm = cuff (B) WELD_DIST = 0.0002 # merge duplicated segment-boundary rings (0.2 mm) MASK_SIZE = 512 ALBEDO_SIZE = 1024 # painted detail (buttons ~8 px radius) needs 1024 # Style dimensions on average_m (shoulder |x| = base._REF_SHOULDER_X); # scaled per body by shoulder_x ratio so proportions hold from child to heavy_m. PLACKET_HALF_M = 0.022 # placket half-width (4.4 cm total band) STITCH_W_M = 0.0025 # stitch/seam line half-width BUTTON_R_M = 0.0095 # button disc radius (chunky toon read) SEAM_HALF_M = 0.0022 # collar seam line half-height BUTTON_COUNT = 6 FRONT_MIN_Y = 0.004 # body-local front test: y * FRONT_Y_SIGN > this # Flat toon oxford base tone + painted feature colours (luma carries through # the luminance-preserving region tint in toon_garment.gdshader). FABRIC_RGB = (0.63, 0.64, 0.66) FABRIC_NOISE = 0.02 PLACKET_BAND_MUL = 1.07 # subtle lift so the band reads under any tint STITCH_MUL = 0.52 # dark stitch lines SEAM_MUL = 0.55 # collar/cuff seam lines BUTTON_RGB_OUTER = (0.10, 0.10, 0.12) BUTTON_RGB_CORE = (0.24, 0.24, 0.27) def log(msg): print(f"[buttondown] {msg}") # -------------------------------------------------------------------------- # Threshold derivation (extends base.derive_thresholds with arm + style dims) # -------------------------------------------------------------------------- def derive_arm_thresholds(armature): """Wrist cut planes + cuff start from the lowerarm bones (X-axis arms).""" bones = armature.data.bones la_l = bones.get("lowerarm_l") la_r = bones.get("lowerarm_r") if la_l is None or la_r is None: raise RuntimeError("lowerarm bones missing — cannot derive sleeve cut") def along(b, frac): return b.head_local.x + frac * (b.tail_local.x - b.head_local.x) cut_l = along(la_l, SLEEVE_END_FRAC) # left arm +x: delete x > cut_l cut_r = along(la_r, SLEEVE_END_FRAC) # right arm -x: delete x < cut_r cuff_x_abs = (abs(along(la_l, CUFF_START_FRAC)) + abs(along(la_r, CUFF_START_FRAC))) / 2.0 log(f"sleeve: cut_l={cut_l:.3f} cut_r={cut_r:.3f} cuff |x|>={cuff_x_abs:.3f}") return {"cut_l": cut_l, "cut_r": cut_r, "cuff_x_abs": cuff_x_abs} def derive_style(armature): """Scale the painted-detail dimensions by this body's shoulder ratio.""" bones = armature.data.bones ua_l = bones.get("upperarm_l") ua_r = bones.get("upperarm_r") if ua_l is None or ua_r is None: s = 1.0 else: shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0 s = shoulder_x / base._REF_SHOULDER_X log(f"style scale {s:.3f}") return { "placket_half": PLACKET_HALF_M * s, "stitch_w": STITCH_W_M * s, "button_r": BUTTON_R_M * s, "seam_half": SEAM_HALF_M * s, } def derive_buttons(shell, thr): """Button Z positions: evenly spaced down the placket (collar -> hem).""" hem_z = min(v.co.z for v in shell.data.vertices) collar_z = thr["collar_z_min"] span = collar_z - hem_z z_top = collar_z - 0.05 * span z_bot = hem_z + 0.07 * span zs = list(np.linspace(z_top, z_bot, BUTTON_COUNT)) log(f"buttons: {BUTTON_COUNT} @ z {z_bot:.3f}..{z_top:.3f} (hem {hem_z:.3f})") return {"button_zs": zs, "hem_z": hem_z} # -------------------------------------------------------------------------- # Geometry: seam weld + wrist cut # -------------------------------------------------------------------------- def weld_seams(shell): """Merge the duplicated segment-boundary rings into one continuous shell.""" bm = bmesh.new() bm.from_mesh(shell.data) before = len(bm.verts) bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=WELD_DIST) bm.to_mesh(shell.data) bm.free() shell.data.update() log(f"seam weld: {before} -> {len(shell.data.vertices)} verts " f"({before - len(shell.data.vertices)} merged)") def wrist_cut(shell, thr): """Delete sleeve verts beyond the wrist plane on each forearm.""" bm = bmesh.new() bm.from_mesh(shell.data) bm.verts.ensure_lookup_table() to_delete = [v for v in bm.verts if v.co.x > thr["cut_l"] or v.co.x < thr["cut_r"]] bmesh.ops.delete(bm, geom=to_delete, context='VERTS') bm.to_mesh(shell.data) bm.free() shell.data.update() log(f"wrist cut removed {len(to_delete)} verts; " f"{len(shell.data.vertices)} remain") # -------------------------------------------------------------------------- # Per-pixel bake: region mask + painted albedo in one pass # -------------------------------------------------------------------------- def _gather_tris(shell): """Fan-triangulate every face into (uv_a, uv_b, uv_c, co_a, co_b, co_c).""" me = shell.data bm = bmesh.new() bm.from_mesh(me) bm.faces.ensure_lookup_table() uvl = bm.loops.layers.uv[me.uv_layers[0].name] # UV0 = shared body atlas tris = [] for face in bm.faces: loops = face.loops[:] uvs = [np.array((lp[uvl].uv.x, lp[uvl].uv.y)) for lp in loops] cos = [np.array(lp.vert.co[:]) for lp in loops] for i in range(1, len(loops) - 1): tris.append((uvs[0], uvs[i], uvs[i + 1], cos[0], cos[i], cos[i + 1])) bm.free() return tris def _tri_cover(a, b, c, W, H): """Pixel centres covered by UV triangle abc -> (ys, xs, w0, w1, w2).""" ax, ay = a[0] * (W - 1), a[1] * (H - 1) bx, by = b[0] * (W - 1), b[1] * (H - 1) cx, cy = c[0] * (W - 1), c[1] * (H - 1) minx = max(int(np.floor(min(ax, bx, cx))), 0) maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1) miny = max(int(np.floor(min(ay, by, cy))), 0) maxy = min(int(np.ceil(max(ay, by, cy))), H - 1) if minx > maxx or miny > maxy: return None denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy) if abs(denom) < 1e-9: return None # degenerate (solidify rim) — no UV area to write ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1] px = xs + 0.5 py = ys + 0.5 w0 = ((by - cy) * (px - cx) + (cx - bx) * (py - cy)) / denom w1 = ((cy - ay) * (px - cx) + (ax - cx) * (py - cy)) / denom w2 = 1.0 - w0 - w1 inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4) if not inside.any(): return None return (ys[inside].ravel(), xs[inside].ravel(), w0[inside].ravel(), w1[inside].ravel(), w2[inside].ravel()) def _interp_pos(cover, co_a, co_b, co_c): _, _, w0, w1, w2 = cover return (w0[:, None] * co_a[None, :] + w1[:, None] * co_b[None, :] + w2[:, None] * co_c[None, :]) def _classify_px(pos, thr): """Vectorized region classification -> (N,4) one-hot RGBA rows.""" x, y, z = pos[:, 0], pos[:, 1], pos[:, 2] ax = np.abs(x) front = y * base.FRONT_Y_SIGN > FRONT_MIN_Y cuff = ax >= thr["cuff_x_abs"] collar = (~cuff) & (z >= thr["collar_z_min"]) & (ax < thr["collar_x_abs"]) placket = ((~cuff) & (~collar) & front & (ax <= thr["placket_half"]) & (z < thr["collar_z_min"])) rgba = np.zeros((len(x), 4), dtype=np.float32) rgba[:, 1] = 1.0 # default: body -> G rgba[collar] = (1.0, 0.0, 0.0, 0.0) # collar band -> R rgba[cuff | placket] = (0.0, 0.0, 1.0, 0.0) # cuffs + placket -> B return rgba def _paint_px(pos, rows, thr): """Painted albedo: placket band + stitches, seams, button dots (in-place).""" x, y, z = pos[:, 0], pos[:, 1], pos[:, 2] ax = np.abs(x) front = y * base.FRONT_Y_SIGN > FRONT_MIN_Y below_collar = z < thr["collar_z_min"] mul = np.ones(len(x), dtype=np.float32) band = front & below_collar & (ax <= thr["placket_half"]) mul[band] = PLACKET_BAND_MUL stitch = front & below_collar & (np.abs(ax - thr["placket_half"]) <= thr["stitch_w"]) mul[stitch] = STITCH_MUL collar_seam = ((np.abs(z - thr["collar_z_min"]) <= thr["seam_half"]) & (ax < thr["collar_x_abs"] * 1.8)) mul[collar_seam] = SEAM_MUL cuff_seam = np.abs(ax - thr["cuff_x_abs"]) <= thr["stitch_w"] mul[cuff_seam] = SEAM_MUL out = rows * mul[:, None] r = thr["button_r"] for bz in thr["button_zs"]: d2 = x * x + (z - bz) ** 2 disc = front & (d2 <= r * r) out[disc] = BUTTON_RGB_OUTER core = front & (d2 <= (0.45 * r) ** 2) out[core] = BUTTON_RGB_CORE np.clip(out, 0.0, 1.0, out) return out def bake_maps(shell, thr, mask_path): """One pass over the shell: bake _mask.png + return painted albedo.""" tris = _gather_tris(shell) mbuf = np.zeros((MASK_SIZE, MASK_SIZE, 4), dtype=np.float32) mbuf[:, :, 1] = 1.0 # green background = main body (bilinear-bleed safe) rng = np.random.default_rng(1090) # deterministic fabric noise fabric = np.array(FABRIC_RGB, dtype=np.float32) noise = (rng.random((ALBEDO_SIZE, ALBEDO_SIZE, 1), dtype=np.float32) - 0.5) * 2.0 * FABRIC_NOISE abuf = np.clip(fabric[None, None, :] + noise, 0.0, 1.0) for uv_a, uv_b, uv_c, co_a, co_b, co_c in tris: cover = _tri_cover(uv_a, uv_b, uv_c, MASK_SIZE, MASK_SIZE) if cover is not None: pos = _interp_pos(cover, co_a, co_b, co_c) mbuf[cover[0], cover[1], :] = _classify_px(pos, thr) cover = _tri_cover(uv_a, uv_b, uv_c, ALBEDO_SIZE, ALBEDO_SIZE) if cover is not None: pos = _interp_pos(cover, co_a, co_b, co_c) abuf[cover[0], cover[1], :] = _paint_px( pos, abuf[cover[0], cover[1], :], thr) tot = MASK_SIZE * MASK_SIZE log("mask texels: collar={:.1f}% body={:.1f}% cuff+placket={:.1f}%".format( 100.0 * float((mbuf[:, :, 0] > 0.5).sum()) / tot, 100.0 * float((mbuf[:, :, 1] > 0.5).sum()) / tot, 100.0 * float((mbuf[:, :, 2] > 0.5).sum()) / tot)) mask_img = bpy.data.images.new("garment_region_mask", MASK_SIZE, MASK_SIZE, alpha=True) mask_img.pixels.foreach_set(mbuf.reshape(-1)) mask_img.update() mask_img.filepath_raw = mask_path mask_img.file_format = 'PNG' mask_img.save() log(f"baked region mask -> {mask_path}") argba = np.concatenate( [abuf, np.ones((ALBEDO_SIZE, ALBEDO_SIZE, 1), dtype=np.float32)], axis=2) albedo_img = bpy.data.images.new("base_albedo", ALBEDO_SIZE, ALBEDO_SIZE, alpha=False) albedo_img.pixels.foreach_set(argba.reshape(-1)) albedo_img.update() return albedo_img # -------------------------------------------------------------------------- # Author one body # -------------------------------------------------------------------------- def author_buttondown(body_dir, out_dir, body, offset): base.clear_scene() base.COVERED_SEGMENTS = COVERED_SEGMENTS # long-sleeve coverage set shell, armature = base.build_covered_mesh(body_dir) thr = base.derive_thresholds(armature) # collar/chest from bone landmarks thr.update(derive_arm_thresholds(armature)) thr.update(derive_style(armature)) weld_seams(shell) wrist_cut(shell, thr) thr.update(derive_buttons(shell, thr)) base.offset_outward(shell, offset) base.solidify(shell, CLOTH_THICKNESS_M) base.author_logo_uv(shell, thr) # UV2 before bake (mask/albedo use UV0) albedo_img = bake_maps(shell, thr, os.path.join(out_dir, f"{body}_mask.png")) base.save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png")) base.assign_fabric_material(shell, albedo_img) base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb")) # -------------------------------------------------------------------------- # Entry # -------------------------------------------------------------------------- def main(): argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] if len(argv) < 2: print("Usage: -- " "[--bodies a,b,c] [--offset M]") sys.exit(1) bodies_root = argv[0] out_dir = argv[1] offset = OFFSET_M if "--offset" in argv: offset = float(argv[argv.index("--offset") + 1]) bodies = base.BODY_TYPES if "--bodies" in argv: bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")] os.makedirs(out_dir, exist_ok=True) log(f"per-body mode: {len(bodies)} bodies, offset {offset*1000:.1f} mm") avg_albedo_stash = os.path.join(out_dir, "_tmp_avg_base_albedo.png") results = [] for body in bodies: body_dir = os.path.join(bodies_root, body) log(f"=== {body} ===") if not os.path.isdir(body_dir): results.append((body, "skipped: body dir missing")) continue try: author_buttondown(body_dir, out_dir, body, offset) if body == base.REFERENCE_BODY: shutil.copy2(os.path.join(out_dir, "base_albedo.png"), avg_albedo_stash) results.append((body, "ok")) except Exception as exc: # noqa: BLE001 — per-body isolation log(f"ERROR {body}: {exc}") results.append((body, f"error: {exc}")) # Root sidecars: reference mask + albedo mirror the reference body. ref_mask = os.path.join(out_dir, f"{base.REFERENCE_BODY}_mask.png") if os.path.isfile(ref_mask): shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png")) log(f"copied {base.REFERENCE_BODY}_mask.png -> reference_mask.png") if os.path.isfile(avg_albedo_stash): shutil.move(avg_albedo_stash, os.path.join(out_dir, "base_albedo.png")) log(f"base_albedo.png = {base.REFERENCE_BODY}'s painted albedo") log("=" * 50) for body, status in results: log(f" {body:12s} {status}") ok = sum(1 for _, s in results if s == "ok") log(f"OK={ok}/{len(results)}") if ok != len(results): sys.exit(1) log("DONE") if __name__ == "__main__": main()