""" blender_author_outerwear.py (T-1089, jacket_modern — open-front outerwear family) Companion to blender_author_offset_shell.py: authors OUTERWEAR offset-shells (jacket / track-jacket / parka family) per body. It imports the base module and reuses its scene/join/offset/solidify/mask/logo/export machinery; everything garment-specific lives in the PARAMS block below, so a new outerwear piece is a parameter delta, not a new script. What it adds over the base t-shirt shell: * FULL SLEEVES — covers seg_arm_lower_l/r too; the sleeve cut moves from the upper arm to a WRIST cut on the lowerarm bones (same bone-plane technique). * 4th REGION (A = zip/trim) — a front zip placket strip running hem -> through the collar, plus knit cuff bands on the sleeve ends. Region layout per spec: collar=R, body=G, sleeves=B, zip/trim=A. * POSITION-PAINTED ALBEDO — instead of the base script's flat-noise albedo, each body gets an albedo baked in its own UV0 layout by rasterizing every face and painting per-texel from interpolated body-local position: zip teeth dashes + placket, panel seam lines (shoulder, collar, cuff, placket edges), hem band. The toon_garment shader recolors by LUMA, so the painted detail is authored as luma contrast and survives any tint choice. * LEFT-BREAST LOGO PATCH — the UV2 chest box shifts off-centre (the zip owns the centre line), scaled per body from the same bone-landmark ratios. Per-body only (this family is the poster child for the Q-060 per-body route): tooling/blender --background --python \ tooling/garment-fit/blender_author_outerwear.py -- \ \ [--bodies a,b,c] [--offset 0.022] [--cuff-keep 0.88] Writes per body: /.glb (painted albedo embedded), _mask.png Plus: /base_albedo.png (average_m's painted albedo) /reference_mask.png (copy of average_m's, fallback) The per-body albedo is embedded in the GLB under the image name "base_albedo" (tshirt convention): Godot's importer extracts it as _base_albedo.png. average_m is deliberately authored LAST so the shared base_albedo.png sidecar ends up holding the reference body's pixels. Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060. """ import importlib.util import os import shutil import sys import bpy import bmesh import numpy as np # -------------------------------------------------------------------------- # Load the base offset-shell module (shared engine machinery). # -------------------------------------------------------------------------- _HERE = os.path.dirname(os.path.abspath(__file__)) _spec = importlib.util.spec_from_file_location( "offset_shell_base", os.path.join(_HERE, "blender_author_offset_shell.py")) base = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(base) log = base.log # -------------------------------------------------------------------------- # PARAMS — jacket_modern (casual zip jacket). A new outerwear garment should # only need to touch this block (or override via CLI where exposed). # -------------------------------------------------------------------------- # Full-arm coverage: torso + torso_upper + both whole arms. 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.022 # LARGEST standoff of the tops — worn over a shirt # (tshirt_modern outer surface ~16 mm), must neither # clip the body nor read skin-tight. CLOTH_THICKNESS_M = 0.005 # beefier cloth than the 4 mm tee CUFF_KEEP_FRAC = 0.88 # fraction of the lowerarm kept (wrist cut — hands show) CUFF_BAND_FRAC = 0.22 # last fraction of the KEPT forearm = knit cuff trim (A) # Region-shape ratios, anchored to the base module's average_m reference # landmarks (shoulder |x| 0.1919, neck len 0.0793) so they scale per body. ZIP_HALF_FRAC = 0.030 / 0.1919 # zip placket half-width as frac of shoulder |x| COLLAR_DROP_NECK_FRAC = 0.55 # collar band starts this far below neck head # (t-shirt band was 0.3846 — jacket collar is taller, # but 0.80 caught upper-chest faces and rendered as # jagged shoulder "wings"; 0.55 keeps a neat band) COLLAR_X_MULT = 1.15 # jacket collar slightly wider than the tee band # Left-breast logo patch (character-left = +X; centre line belongs to the zip). # Fracs of shoulder |x| / spine span, from average_m absolutes (0.035..0.115 m, # z 1.30..1.42 m). CHEST_X_LO_FRAC = 0.035 / 0.1919 CHEST_X_HI_FRAC = 0.115 / 0.1919 CHEST_Z_LO_FRAC = (1.30 - 1.072) / (1.5205 - 1.072) CHEST_Z_HI_FRAC = (1.42 - 1.072) / (1.5205 - 1.072) # Painted-albedo luma palette (the shader tints by luma: tint * (luma*1.5+0.2)). BASE_LUMA = 0.58 PLACKET_LUMA = 0.50 # slightly darker front placket band SEAM_LUMA = 0.38 # panel seam / stitch lines TEETH_LUMA = 0.88 # bright zip teeth dashes TEETH_GAP_LUMA = 0.34 # dark tape between dashes CUFF_LUMA = 0.48 # knit cuff band HEM_LUMA = 0.50 # bottom hem band ALBEDO_NOISE = 0.02 # woven jitter SEAM_W = 0.005 # seam line half-width, metres TEETH_HALF_W = 0.006 # zip teeth strip half-width, metres TEETH_PERIOD = 0.024 # dash period along Z, metres TEETH_DUTY = 0.014 # bright dash length within a period, metres HEM_BAND_M = 0.020 # hem band height, metres # Slate hue applied on top of luma (only visible if the region mask is missing). FABRIC_HUE = np.array([0.93, 0.97, 1.06], dtype=np.float32) ALBEDO_SIZE = 512 # -------------------------------------------------------------------------- # Thresholds — base derivation + outerwear extras from the same armature # -------------------------------------------------------------------------- def outerwear_thresholds(armature): thr = base.derive_thresholds(armature) bones = armature.data.bones neck = bones.get("neck_01") ua_l = bones.get("upperarm_l") ua_r = bones.get("upperarm_r") spine01 = bones.get("spine_01") # Shoulder |x| (same landmark the base derivation uses). if ua_l and ua_r: shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0 else: shoulder_x = 0.1919 # average_m fallback # Taller, wider standing collar. if neck: neck_len = neck.tail_local.z - neck.head_local.z thr["collar_z_min"] = neck.head_local.z - COLLAR_DROP_NECK_FRAC * neck_len thr["collar_x_abs"] = thr["collar_x_abs"] * COLLAR_X_MULT # Front zip placket. thr["zip_half_x"] = shoulder_x * ZIP_HALF_FRAC # Wrist cut planes + cuff trim band on the lowerarm bones. cut_planes = [] cuff_abs = [] 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 left full-length") continue head_x = b.head_local.x tail_x = b.tail_local.x cut_x = head_x + CUFF_KEEP_FRAC * (tail_x - head_x) band_x = head_x + (CUFF_KEEP_FRAC - CUFF_BAND_FRAC) * (tail_x - head_x) cut_planes.append((sign, cut_x)) cuff_abs.append(abs(band_x)) log(f"wrist cut {bone_name}: keep |x| up to {cut_x:.3f} " f"(elbow {head_x:.3f} -> wrist {tail_x:.3f}), cuff from {band_x:.3f}") thr["wrist_cut_planes"] = cut_planes thr["cuff_x_abs"] = min(cuff_abs) if cuff_abs else 1e9 # Left-breast logo patch (replaces the base full-chest box). thr["chest_x"] = (shoulder_x * CHEST_X_LO_FRAC, shoulder_x * CHEST_X_HI_FRAC) if neck and spine01: spine_lo = spine01.head_local.z span = neck.head_local.z - spine_lo thr["chest_z"] = (spine_lo + CHEST_Z_LO_FRAC * span, spine_lo + CHEST_Z_HI_FRAC * span) log(f"outerwear thresholds: zip half {thr['zip_half_x']:.3f} " f"collar z>={thr['collar_z_min']:.3f} |x|<{thr['collar_x_abs']:.3f} " f"cuff |x|>={thr['cuff_x_abs']:.3f} " f"chest x=({thr['chest_x'][0]:.3f},{thr['chest_x'][1]:.3f}) " f"z=({thr['chest_z'][0]:.3f},{thr['chest_z'][1]:.3f})") return thr # -------------------------------------------------------------------------- # Wrist cut (bone-plane, same technique as the base sleeve cut) # -------------------------------------------------------------------------- def wrist_cut(shell, thr): """Delete forearm verts beyond the wrist plane on each side.""" cut_planes = thr.get("wrist_cut_planes", []) if not cut_planes: log("WARNING: no wrist cut planes — sleeves stay full length") return bm = bmesh.new() bm.from_mesh(shell.data) bm.verts.ensure_lookup_table() to_delete = [] for v in bm.verts: for sign, thr_x in cut_planes: if sign > 0 and v.co.x > thr_x: to_delete.append(v) break if sign < 0 and v.co.x < thr_x: 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") # -------------------------------------------------------------------------- # 4-region classifier (collar=R, body=G, sleeves=B, zip/trim=A) # -------------------------------------------------------------------------- def classify_region_outerwear(center, thr): x, y, z = center.x, center.y, center.z ax = abs(x) front = y * base.FRONT_Y_SIGN > 0.0 # Zip placket first: runs hem -> THROUGH the collar (zip-through jacket). if front and ax < thr["zip_half_x"]: return (0.0, 0.0, 0.0, 1.0) # zip/trim -> A (tint[3]) if ax >= thr["cuff_x_abs"]: return (0.0, 0.0, 0.0, 1.0) # knit cuff trim -> A (tint[3]) if ax >= thr["sleeve_x_abs"]: return (0.0, 0.0, 1.0, 0.0) # sleeves -> B (tint[2]) if z >= thr["collar_z_min"] and ax < thr["collar_x_abs"]: return (1.0, 0.0, 0.0, 0.0) # collar -> R (tint[0]) return (0.0, 1.0, 0.0, 0.0) # main body -> G (tint[1]) # -------------------------------------------------------------------------- # Position-painted albedo (per-body UV0 bake) # -------------------------------------------------------------------------- def _paint_luma(x, y, z, thr, z_min): """Vectorised luma paint from body-local position (arrays in, array out).""" v = np.full(x.shape, BASE_LUMA, dtype=np.float32) ax = np.abs(x) front = (y * base.FRONT_Y_SIGN) > 0.0 # Hem band along the jacket bottom. v = np.where(z < z_min + HEM_BAND_M, HEM_LUMA, v) # Knit cuff bands. v = np.where(ax >= thr["cuff_x_abs"], CUFF_LUMA, v) # Panel seam lines: shoulder (sleeve boundary), cuff start, collar base. v = np.where(np.abs(ax - thr["sleeve_x_abs"]) < SEAM_W, SEAM_LUMA, v) v = np.where(np.abs(ax - thr["cuff_x_abs"]) < SEAM_W, SEAM_LUMA, v) collar_seam = (np.abs(z - thr["collar_z_min"]) < SEAM_W) \ & (ax < thr["collar_x_abs"] * 1.2) v = np.where(collar_seam, SEAM_LUMA, v) # Front zip placket: darker band + stitch edges + dashed teeth line. zh = thr["zip_half_x"] v = np.where(front & (ax < zh), PLACKET_LUMA, v) v = np.where(front & (np.abs(ax - zh) < SEAM_W * 0.6), SEAM_LUMA, v) teeth = front & (ax < TEETH_HALF_W) dash = np.mod(z, TEETH_PERIOD) < TEETH_DUTY v = np.where(teeth & dash, TEETH_LUMA, v) v = np.where(teeth & ~dash, TEETH_GAP_LUMA, v) return v def _raster_tri_painted(lum, uv_a, uv_b, uv_c, p_a, p_b, p_c, thr, z_min, W, H): """Barycentric fill of a UV triangle, painting per-texel from the barycentrically interpolated body-local position.""" ax_, ay_ = uv_a.x * (W - 1), uv_a.y * (H - 1) bx_, by_ = uv_b.x * (W - 1), uv_b.y * (H - 1) cx_, cy_ = uv_c.x * (W - 1), uv_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 pos_x = w0 * p_a.x + w1 * p_b.x + w2 * p_c.x pos_y = w0 * p_a.y + w1 * p_b.y + w2 * p_c.y pos_z = w0 * p_a.z + w1 * p_b.z + w2 * p_c.z vals = _paint_luma(pos_x, pos_y, pos_z, thr, z_min) region = lum[miny:maxy + 1, minx:maxx + 1] region[inside] = vals[inside] def bake_painted_albedo(shell, thr, out_path, seed=1093): """Bake a per-body albedo: luma-painted detail in this body's UV0 layout.""" W = H = ALBEDO_SIZE lum = np.full((H, W), BASE_LUMA, 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 albedo bake") z_min = min(v.co.z for v in bm.verts) for face in bm.faces: loops = face.loops[:] uvs = [loop[uv_layer].uv.copy() for loop in loops] pos = [loop.vert.co.copy() for loop in loops] for i in range(1, len(uvs) - 1): _raster_tri_painted(lum, uvs[0], uvs[i], uvs[i + 1], pos[0], pos[i], pos[i + 1], thr, z_min, W, H) bm.free() rng = np.random.default_rng(seed) lum += (rng.random((H, W), dtype=np.float32) - 0.5) * 2.0 * ALBEDO_NOISE rgb = np.clip(lum[:, :, None] * FABRIC_HUE[None, None, :], 0.0, 1.0) rgba = np.concatenate( [rgb, np.ones((H, W, 1), dtype=np.float32)], axis=2).astype(np.float32) img = bpy.data.images.new("garment_painted_albedo", W, H, alpha=False) img.pixels.foreach_set(rgba.reshape(-1)) img.update() img.filepath_raw = out_path img.file_format = 'PNG' img.save() log(f"baked painted albedo -> {out_path}") return img # -------------------------------------------------------------------------- # Per-body authoring # -------------------------------------------------------------------------- def author_outerwear(body_dir, out_dir, body, offset): base.clear_scene() shell, armature = base.build_covered_mesh(body_dir) thr = outerwear_thresholds(armature) wrist_cut(shell, thr) base.offset_outward(shell, offset) base.solidify(shell, CLOTH_THICKNESS_M) # Shared sidecar name on purpose: the exporter derives the embedded glTF # image name from the filepath basename, and Godot extracts embedded # textures as _.png — so this yields _base_albedo.png # on import, matching the tshirt_modern convention (no doubled names). albedo_path = os.path.join(out_dir, "base_albedo.png") albedo_img = bake_painted_albedo(shell, thr, albedo_path) base.assign_fabric_material(shell, albedo_img) base.author_logo_uv(shell, thr) # UV2 before mask bake (mask uses UV0/active) base.bake_region_mask(shell, os.path.join(out_dir, f"{body}_mask.png"), thr) # Honest region tally (the base bake log can't see the A channel). counts = {"collar": 0, "body": 0, "sleeve": 0, "zip/trim": 0} bm = bmesh.new() bm.from_mesh(shell.data) for face in bm.faces: c = classify_region_outerwear(face.calc_center_median(), thr) if c[3] > 0.5: counts["zip/trim"] += 1 elif c[2] > 0.5: counts["sleeve"] += 1 elif c[0] > 0.5: counts["collar"] += 1 else: counts["body"] += 1 bm.free() total = max(sum(counts.values()), 1) log("outerwear regions: " + " ".join( f"{k}={v} ({100.0 * v / total:.1f}%)" for k, v in counts.items())) 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] [--cuff-keep F]") sys.exit(1) bodies_root = argv[0] out_dir = argv[1] global CUFF_KEEP_FRAC offset = OFFSET_M if "--offset" in argv: offset = float(argv[argv.index("--offset") + 1]) if "--cuff-keep" in argv: CUFF_KEEP_FRAC = float(argv[argv.index("--cuff-keep") + 1]) bodies = base.BODY_TYPES if "--bodies" in argv: bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")] # Author the reference body LAST so the shared base_albedo.png sidecar # (rewritten per body before each export) ends as average_m's version. if base.REFERENCE_BODY in bodies: bodies = [b for b in bodies if b != base.REFERENCE_BODY] + [base.REFERENCE_BODY] os.makedirs(out_dir, exist_ok=True) # Route the base mask bake through the 4-region outerwear classifier and # the covered-segment import through the full-arm list. base._classify_region = classify_region_outerwear base.COVERED_SEGMENTS = SEGMENTS log(f"outerwear per-body mode: {len(bodies)} bodies, " f"offset {offset*1000:.0f} mm, cuff keep {CUFF_KEEP_FRAC:.2f}") 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_outerwear(body_dir, out_dir, body, offset) results.append((body, "ok")) except Exception as exc: log(f"ERROR {body}: {exc}") results.append((body, f"error: {exc}")) # Runtime fallback: reference_mask.png mirrors average_m's mask. # (base_albedo.png already holds average_m's albedo — it authored last.) 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") 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()