""" blender_author_jacket_shell.py (T-1089, route (c) hand-author proof: suit jacket) Jacket-family companion to blender_author_offset_shell.py — imports that module as a library (segments -> join -> offset -> solidify -> skinned export are reused unchanged) and adds what a JACKET needs beyond a t-shirt: * full-arm coverage (torso + torso_upper + upper AND lower arms) with a WRIST cut on the lowerarm bone (long sleeves) instead of an upper-arm cut; * a boundary WELD after the segment join, so the elbow/shoulder segment seams offset as one continuous surface (no gap rings on long sleeves); * a jacket standoff (default 18 mm — outerwear drape over the 12 mm tee); * a PAINTED albedo + region mask baked together in ONE per-pixel rasterization pass: each texel's 3D body-local position is interpolated barycentric-ally, so the V-opening / lapels / shirt triangle have crisp edges independent of the low-poly tessellation (the base script's per-FACE classification would read chunky on a chest V). Region-mask convention for the suit (toon_garment.gdshader tint routing): R = lapels + collar band (tint_0 — subtle satin two-tone) G = jacket body (tint_1) B = sleeves (tint_2) A = shirt triangle in the V (tint_3 — default WHITE so outfits can recolor the visible shirt) Painted albedo details (luma-carried; the shader recolors hue per region but keeps albedo luminance, so detail must live in the 0.53-max luma band or the `luma*1.5+0.2` curve clips it): white-shirt triangle with placket + buttons, lapel fold + edge shading, front closure seam + jacket button below the V, sleeve cuff band. All geometry parameters are bone-landmark RATIOS (same anchors as the base script: shoulder = upperarm head |x|, neck_01, spine_01, plus lowerarm for the wrist), so every body derives its own proportional cut/paint constants — calibrated to the intended metric sizes on average_m. PER-BODY ONLY: offset-shell garments are authored per body (Q-060 route guidance) — there is no single-reference mode here. tooling/blender --background --python \ tooling/garment-fit/blender_author_jacket_shell.py -- \ \ [--bodies average_m,child,...] [--offset 0.018] [--wrist-frac 0.85] Writes per body: /.glb skinned jacket authored on that body /_mask.png RGBA region mask (that body's UV0) Plus: /base_albedo.png copy of average_m's painted albedo /reference_mask.png copy of average_m's mask (runtime fallback) The painted per-body albedo is embedded in each GLB (image URI basename "base_albedo", staged under .cache/garment-fit-suit//); on import Godot extracts it to /_base_albedo.png — the same layout the t-shirt reference garment has. Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060. """ import sys import os import shutil import bpy import bmesh import numpy as np # Import the base offset-shell module as a library. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import blender_author_offset_shell as base # noqa: E402 # -------------------------------------------------------------------------- # Jacket parameters # -------------------------------------------------------------------------- JACKET_SEGMENTS = [ "seg_torso", "seg_torso_upper", "seg_arm_upper_l", "seg_arm_upper_r", "seg_arm_lower_l", "seg_arm_lower_r", ] JACKET_OFFSET_M = 0.018 # outerwear standoff (tee is 12 mm per-body) WRIST_FRAC = 0.92 # fraction of lowerarm length kept. NB the vert-delete # cut also drops faces touching deleted verts, so the # visible sleeve ends ~1 low-poly ring earlier — 0.92 # lands the hem just above the wrist (matches the # buttondown's calibrated SLEEVE_END_FRAC). CUFF_FRAC = 0.14 # last fraction of the KEPT sleeve painted as cuff band WELD_DIST_M = 0.0001 # merge distance for segment-boundary weld BAKE_SIZE = 1024 # albedo + mask resolution (512 is too coarse for the # 6 mm placket / button dots on the torso island) # --- V-opening / lapel geometry, as ratios of average_m bone landmarks ----- # (anchors identical to base: shoulder |x| 0.1919, neck head z 1.5205, # spine_01 head z 1.072 -> spine span 0.4485) V_HALF_TOP_M = 0.068 # V half-width at the collar on average_m V_BOT_Z_M = 1.17 # V bottom (jacket closure point) on average_m LAPEL_W_M = 0.045 # lapel band width LAPEL_DROP_M = 0.020 # lapel wraps slightly below the V point EDGE_W_M = 0.011 # lapel fold/edge shading line width (albedo only) PLACKET_HALF_M = 0.006 # shirt placket half-width CLOSURE_HALF_M = 0.0045 # jacket front closure seam half-width SHIRT_BTN_R_M = 0.0055 # shirt button radius JACKET_BTN_R_M = 0.009 # jacket button radius JACKET_BTN_DROP_M = 0.030 # jacket button sits this far below the V point SHIRT_BTN_FRACS = (0.11, 0.30) # shirt button z, as frac of (v_top - v_bot) V_HALF_TOP_FRAC = V_HALF_TOP_M / base._REF_SHOULDER_X LAPEL_W_FRAC = LAPEL_W_M / base._REF_SHOULDER_X EDGE_W_FRAC = EDGE_W_M / base._REF_SHOULDER_X PLACKET_HALF_FRAC = PLACKET_HALF_M / base._REF_SHOULDER_X CLOSURE_HALF_FRAC = CLOSURE_HALF_M / base._REF_SHOULDER_X SHIRT_BTN_R_FRAC = SHIRT_BTN_R_M / base._REF_SHOULDER_X JACKET_BTN_R_FRAC = JACKET_BTN_R_M / base._REF_SHOULDER_X _REF_SPAN = base._REF_NECK_Z - base._REF_SPINE_LO_Z V_BOT_FRAC = (V_BOT_Z_M - base._REF_SPINE_LO_Z) / _REF_SPAN LAPEL_DROP_FRAC = LAPEL_DROP_M / _REF_SPAN JACKET_BTN_DROP_FRAC = JACKET_BTN_DROP_M / _REF_SPAN # --- Painted albedo luma palette (neutral hue; shader tints supply color) --- # Kept inside the luma*1.5+0.2 <= 1.0 budget (luma <= 0.53) so painted detail # survives a white tint on the shirt region. LUMA_FABRIC = 0.400 # jacket body + sleeves + back LUMA_SATIN = 0.475 # lapels + collar band (subtle two-tone vs body) LUMA_EDGE = 0.250 # lapel fold/edge shading lines LUMA_SHIRT = 0.520 # shirt triangle (renders ~white under white tint) LUMA_PLACKET = 0.440 # shirt placket strip LUMA_SHIRT_BTN = 0.320 # shirt buttons LUMA_CLOSURE = 0.290 # jacket closure seam below the V LUMA_JACKET_BTN = 0.180 # jacket button LUMA_CUFF = 0.330 # sleeve cuff band ALBEDO_NOISE = 0.018 # +/- woven jitter (4-px blocks — compresses well) ALBEDO_TINT = (1.0, 1.0, 1.03) # slightly cool cast on the neutral greys log = base.log # -------------------------------------------------------------------------- # Suit-specific threshold derivation (extends base.derive_thresholds) # -------------------------------------------------------------------------- def derive_jacket_thresholds(armature, wrist_frac): thr = base.derive_thresholds(armature) bones = armature.data.bones ua_l = bones.get("upperarm_l") neck = bones.get("neck_01") spine01 = bones.get("spine_01") if ua_l and neck and spine01: ua_r = bones.get("upperarm_r") shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0 spine_lo = spine01.head_local.z span = neck.head_local.z - spine_lo else: log("WARNING: landmark bones missing — legacy average_m jacket constants") shoulder_x = base._REF_SHOULDER_X spine_lo = base._REF_SPINE_LO_Z span = _REF_SPAN v_top = thr["collar_z_min"] v_bot = spine_lo + V_BOT_FRAC * span thr.update({ "v_top_z": v_top, "v_bot_z": v_bot, "v_half_top": shoulder_x * V_HALF_TOP_FRAC, "lapel_w": shoulder_x * LAPEL_W_FRAC, "lapel_drop": LAPEL_DROP_FRAC * span, "edge_w": shoulder_x * EDGE_W_FRAC, "placket_half": shoulder_x * PLACKET_HALF_FRAC, "closure_half": shoulder_x * CLOSURE_HALF_FRAC, "shirt_btn_r": shoulder_x * SHIRT_BTN_R_FRAC, "jacket_btn_r": shoulder_x * JACKET_BTN_R_FRAC, "jacket_btn_z": v_bot - JACKET_BTN_DROP_FRAC * span, "shirt_btn_zs": [v_bot + f * (v_top - v_bot) for f in SHIRT_BTN_FRACS], }) # Wrist cut planes + cuff band start, from the lowerarm bones (arms run # along +/-X in rest pose, elbow head -> wrist tail). cut_planes = [] # (sign, wrist_thr_x, cuff_start_x) for bone_name, sign in [("lowerarm_l", +1), ("lowerarm_r", -1)]: b = bones.get(bone_name) if b is None: log(f"WARNING: bone {bone_name} missing — sleeve uncut on that side") continue head_x = b.head_local.x tail_x = b.tail_local.x wrist_thr = head_x + wrist_frac * (tail_x - head_x) cuff_start = head_x + wrist_frac * (1.0 - CUFF_FRAC) * (tail_x - head_x) cut_planes.append((sign, wrist_thr, cuff_start)) log(f"wrist cut {bone_name}: keep |x| to {wrist_thr:.3f} " f"(elbow {head_x:.3f} -> wrist {tail_x:.3f}), cuff from {cuff_start:.3f}") thr["wrist_planes"] = cut_planes log(f"jacket thresholds: V top z {v_top:.3f} bottom z {v_bot:.3f} " f"half-top {thr['v_half_top']:.3f} lapel {thr['lapel_w']:.3f} " f"btn z {thr['jacket_btn_z']:.3f}") return thr # -------------------------------------------------------------------------- # Geometry: weld + wrist cut # -------------------------------------------------------------------------- def weld_segment_boundaries(shell): """Merge coincident verts along segment seams (elbow/shoulder/chest lines). Adjacent body segments duplicate their shared boundary loop; unwelded, the two copies get island-local normals and offset APART, opening a gap ring at every seam. Welding first makes the shell offset as one surface. Merged verts come from the same original body vertex, so weights and loop UVs are identical/preserved. """ bm = bmesh.new() bm.from_mesh(shell.data) before = len(bm.verts) bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=WELD_DIST_M) bm.to_mesh(shell.data) after = len(shell.data.vertices) bm.free() shell.data.update() log(f"welded segment boundaries: {before} -> {after} verts " f"({before - after} merged)") def wrist_cut(shell, thr): """Delete sleeve verts beyond the wrist plane on each lower arm.""" bm = bmesh.new() bm.from_mesh(shell.data) bm.verts.ensure_lookup_table() to_delete = [] for v in bm.verts: for sign, wrist_thr, _cuff in thr["wrist_planes"]: if sign > 0 and v.co.x > wrist_thr: to_delete.append(v) break if sign < 0 and v.co.x < wrist_thr: to_delete.append(v) break 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") # -------------------------------------------------------------------------- # Combined per-pixel albedo paint + region mask bake # -------------------------------------------------------------------------- def _classify_pixels(X, Y, Z, face_sleeve, face_side, thr): """Vectorized region + luma classification for one triangle's texels. Returns (mask_rgba[N,4], luma[N]) for interpolated body-local positions. Region priority: collar > shirt > lapel > body; sleeves are per-face. """ n = X.shape[0] mask = np.zeros((n, 4), dtype=np.float32) luma = np.full(n, LUMA_FABRIC, dtype=np.float32) front = (Y * base.FRONT_Y_SIGN) > 0.010 ax = np.abs(X) if face_sleeve: mask[:, 2] = 1.0 # B = sleeves # cuff band on this arm's outer end for sign, wrist_thr, cuff_start in thr["wrist_planes"]: if sign != face_side: continue in_cuff = (X * sign) >= (cuff_start * sign) luma[in_cuff] = LUMA_CUFF return mask, luma v_top = thr["v_top_z"] v_bot = thr["v_bot_z"] # V half-width narrows linearly from v_half_top at the collar to 0 at v_bot. t = np.clip((Z - v_bot) / max(v_top - v_bot, 1e-6), 0.0, None) vhalf = thr["v_half_top"] * t collar = (Z >= thr["collar_z_min"]) & (ax < thr["collar_x_abs"]) shirt = front & ~collar & (Z >= v_bot) & (Z < v_top) & (ax < vhalf) lapel_out = vhalf + thr["lapel_w"] lapel = (front & ~collar & ~shirt & (Z >= v_bot - thr["lapel_drop"]) & (Z < v_top) & (ax >= vhalf) & (ax < lapel_out)) mask[:, 0][collar | lapel] = 1.0 # R = lapels + collar band mask[:, 3][shirt] = 1.0 # A = shirt triangle body = ~(collar | shirt | lapel) mask[:, 1][body] = 1.0 # G = jacket body # ---- painted luma detail ---- luma[collar | lapel] = LUMA_SATIN # lapel fold (inner) + edge (outer) shading lines edge = lapel & ((ax < vhalf + thr["edge_w"]) | (ax > lapel_out - thr["edge_w"])) luma[edge] = LUMA_EDGE # shirt triangle: base, placket, buttons luma[shirt] = LUMA_SHIRT luma[shirt & (ax < thr["placket_half"])] = LUMA_PLACKET for bz in thr["shirt_btn_zs"]: btn = shirt & ((X ** 2 + (Z - bz) ** 2) < thr["shirt_btn_r"] ** 2) luma[btn] = LUMA_SHIRT_BTN # jacket closure seam + button below the V point closure = body & front & (Z < v_bot) & (ax < thr["closure_half"]) luma[closure] = LUMA_CLOSURE jbtn = front & ((X ** 2 + (Z - thr["jacket_btn_z"]) ** 2) < thr["jacket_btn_r"] ** 2) luma[jbtn] = LUMA_JACKET_BTN return mask, luma def bake_albedo_and_mask(shell, thr, albedo_path, mask_path, albedo_name): """One rasterization pass -> painted albedo PNG + RGBA region mask PNG. Per texel the 3D body-local position is barycentric-interpolated, so paint and regions are classified per PIXEL (crisp V edges on coarse topology). Mask background = body green (bilinear bleed stays tinted); albedo background = fabric luma. """ W = H = BAKE_SIZE mask_buf = np.zeros((H, W, 4), dtype=np.float32) mask_buf[:, :, 1] = 1.0 albedo_buf = np.full((H, W), LUMA_FABRIC, dtype=np.float32) me = shell.data bm = bmesh.new() bm.from_mesh(me) bm.faces.ensure_lookup_table() uv_layer = bm.loops.layers.uv.active if uv_layer is None: raise RuntimeError("no active UV layer for bake") counts = {"collar_lapel": 0, "body": 0, "sleeve": 0, "shirt": 0} for face in bm.faces: center = face.calc_center_median() face_sleeve = abs(center.x) >= thr["sleeve_x_abs"] face_side = 1 if center.x >= 0.0 else -1 loops = face.loops[:] uvs = [lo[uv_layer].uv for lo in loops] cos = [lo.vert.co for lo in loops] for i in range(1, len(uvs) - 1): _raster_tri_pos( mask_buf, albedo_buf, uvs[0], uvs[i], uvs[i + 1], cos[0], cos[i], cos[i + 1], face_sleeve, face_side, thr, W, H, counts) bm.free() log("bake texel classes: " + " ".join(f"{k}={v}" for k, v in counts.items())) # ---- save mask ---- img = bpy.data.images.new("jacket_region_mask", W, H, alpha=True) img.pixels.foreach_set(mask_buf.reshape(-1)) img.update() img.filepath_raw = mask_path img.file_format = 'PNG' img.save() log(f"baked region mask -> {mask_path}") # ---- albedo: neutral grey luma + blocky woven noise, slightly cool ---- rng = np.random.default_rng(1089) coarse = (rng.random((H // 4, W // 4), dtype=np.float32) - 0.5) * 2.0 * ALBEDO_NOISE noise = np.kron(coarse, np.ones((4, 4), dtype=np.float32)) lum = np.clip(albedo_buf + noise, 0.0, 1.0) rgba = np.empty((H, W, 4), dtype=np.float32) for c, mul in enumerate(ALBEDO_TINT): rgba[:, :, c] = np.clip(lum * mul, 0.0, 1.0) rgba[:, :, 3] = 1.0 aimg = bpy.data.images.new(albedo_name, W, H, alpha=False) aimg.pixels.foreach_set(rgba.reshape(-1)) aimg.update() aimg.filepath_raw = albedo_path aimg.file_format = 'PNG' aimg.save() log(f"painted albedo -> {albedo_path}") return aimg def _raster_tri_pos(mask_buf, albedo_buf, a, b, c, pa, pb, pc, face_sleeve, face_side, thr, W, H, counts): """Barycentric fill interpolating 3D position for per-pixel classification.""" ax_, ay_ = a.x * (W - 1), a.y * (H - 1) bx_, by_ = b.x * (W - 1), b.y * (H - 1) cx_, cy_ = c.x * (W - 1), c.y * (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 denom = (by_ - cy_) * (ax_ - cx_) + (cx_ - bx_) * (ay_ - cy_) if abs(denom) < 1e-9: return 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 w0i, w1i, w2i = w0[inside], w1[inside], w2[inside] X = w0i * pa.x + w1i * pb.x + w2i * pc.x Y = w0i * pa.y + w1i * pb.y + w2i * pc.y Z = w0i * pa.z + w1i * pb.z + w2i * pc.z mask_px, luma_px = _classify_pixels( X.astype(np.float32), Y.astype(np.float32), Z.astype(np.float32), face_sleeve, face_side, thr) counts["collar_lapel"] += int((mask_px[:, 0] > 0.5).sum()) counts["shirt"] += int((mask_px[:, 3] > 0.5).sum()) counts["sleeve"] += int((mask_px[:, 2] > 0.5).sum()) counts["body"] += int((mask_px[:, 1] > 0.5).sum()) m_region = mask_buf[miny:maxy + 1, minx:maxx + 1, :] a_region = albedo_buf[miny:maxy + 1, minx:maxx + 1] m_region[inside] = mask_px a_region[inside] = luma_px # -------------------------------------------------------------------------- # Parked UV2 (suit is not logo-capable; keep mesh format pipeline-consistent) # -------------------------------------------------------------------------- def author_parked_uv2(shell): me = shell.data while len(me.uv_layers) > 1: me.uv_layers.remove(me.uv_layers[-1]) me.uv_layers.new(name="logo_uv") me.uv_layers.active = me.uv_layers[0] bm = bmesh.new() bm.from_mesh(me) uvl = bm.loops.layers.uv.get("logo_uv") for face in bm.faces: for loop in face.loops: loop[uvl].uv = (2.0, 2.0) bm.to_mesh(me) bm.free() me.update() log("UV2 authored fully parked (non-logo garment)") # -------------------------------------------------------------------------- # Per-body authoring # -------------------------------------------------------------------------- def author_jacket(body_dir, out_dir, stage_dir, body, offset, wrist_frac): base.clear_scene() base.COVERED_SEGMENTS = JACKET_SEGMENTS shell, armature = base.build_covered_mesh(body_dir) thr = derive_jacket_thresholds(armature, wrist_frac) weld_segment_boundaries(shell) wrist_cut(shell, thr) base.offset_outward(shell, offset) base.solidify(shell, base.CLOTH_THICKNESS_M) # Stage the painted albedo OUTSIDE the Godot project as "base_albedo.png" # — the glTF image URI takes the file basename, so Godot's on-import # extraction lands at /_base_albedo.png (tshirt layout) # without a duplicate authored sidecar next to it. body_stage = os.path.join(stage_dir, body) os.makedirs(body_stage, exist_ok=True) albedo_img = bake_albedo_and_mask( shell, thr, os.path.join(body_stage, "base_albedo.png"), os.path.join(out_dir, f"{body}_mask.png"), f"jacket_albedo_{body}") base.assign_fabric_material(shell, albedo_img) author_parked_uv2(shell) base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb")) 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] [--wrist-frac F]") sys.exit(1) bodies_root = argv[0] out_dir = argv[1] offset = JACKET_OFFSET_M wrist_frac = WRIST_FRAC if "--offset" in argv: offset = float(argv[argv.index("--offset") + 1]) if "--wrist-frac" in argv: wrist_frac = float(argv[argv.index("--wrist-frac") + 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) repo_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) stage_dir = os.path.join(repo_root, ".cache", "garment-fit-suit") log(f"jacket per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm, " f"wrist frac {wrist_frac}") 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_jacket(body_dir, out_dir, stage_dir, body, offset, wrist_frac) results.append((body, "ok")) except Exception as exc: log(f"ERROR {body}: {exc}") results.append((body, f"error: {exc}")) ref = base.REFERENCE_BODY ref_mask = os.path.join(out_dir, f"{ref}_mask.png") if os.path.isfile(ref_mask): shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png")) log(f"copied {ref}_mask.png -> reference_mask.png (fallback)") ref_alb = os.path.join(stage_dir, ref, "base_albedo.png") if os.path.isfile(ref_alb): shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png")) log(f"copied staged {ref} albedo -> base_albedo.png") 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()