""" blender_author_offset_coverall.py (T-1089, uniform_utility — route (c) proof) Full-body COVERALL authored via the offset-shell technique, per body. Companion to blender_author_offset_shell.py (imported as a module): reuses its scene plumbing, offset/solidify, logo-UV2 authoring, rasterizer and export — and extends it with the coverall-specific geometry and the four-region utility mask that this garment exists to prove: ONE garment covering torso + torso_upper + arms + hips + legs. The upper and lower shells join at the waist for free: adjacent body segments duplicate a coincident overlap band (probe: median nearest-vertex distance 0.0 in every seam band), so the joined mesh has no gap by construction and the duplicated faces offset identically (same verts, same normals, same atlas UVs) and render invisibly. Geometry beyond the base script (parameterised, not hard-coded): - WRIST cut: trim lowerarm tubes at a fraction along the lowerarm bone (full sleeve ending in a cuff above the hand). - ANKLE cut: trim calf tubes at a fraction along the calf bone (leg ends in a cuff above the boot line; feet stay free for footwear garments). Both are bone-landmark fractions, so every body derives its own planes. Region mask (the multi-region showcase, R/G/B/A -> tint_0..3): G = main body fabric R = trim: collar band + arm cuffs + leg cuffs B = belt line (waist band, hides the segment seam) + chest patch (logo box, logo-capable) + right-thigh utility patch A = shoulder marks (top-facing epaulette strap on each shoulder) Painted albedo details (flat, toon-friendly; identity lives in the texture): per-region flat luminance fills (dark belt webbing, bright patches, mid shoulder marks), a brighter buckle plate at the front-centre of the belt, and dark stitch lines rasterised along every region-boundary edge (patch borders, collar seam, cuff seams, belt edges). The toon_garment shader is luminance-preserving, so these survive any region recolor. Per-body only (this garment ships per-body per the Q-060 route guidance): tooling/blender --background --python \ tooling/garment-fit/blender_author_offset_coverall.py -- \ [--bodies a,b,c] [--offset 0.012] Writes per body: /.glb skinned coverall authored on that body /_mask.png RGBA region mask (that body's atlas UVs) /_base_albedo.png painted detail albedo (also in the GLB) Plus: /reference_mask.png copy of average_m_mask.png (runtime fallback) /base_albedo.png copy of average_m's albedo (convention) Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe), Q-060 (per-body authoring for offset shells). """ import sys import os import shutil import bpy import bmesh import numpy as np # Make the sibling base module importable when Blender runs this file directly. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import blender_author_offset_shell as base # noqa: E402 # -------------------------------------------------------------------------- # Coverall parameters (all bone-landmark fractions unless noted) # -------------------------------------------------------------------------- COVERED_SEGMENTS = [ "seg_torso", "seg_torso_upper", "seg_arm_upper_l", "seg_arm_upper_r", "seg_arm_lower_l", "seg_arm_lower_r", "seg_hips", "seg_leg_upper_l", "seg_leg_upper_r", "seg_leg_lower_l", "seg_leg_lower_r", ] WRIST_CUT_FRAC = 0.88 # keep this fraction of the lowerarm (elbow->wrist) ANKLE_CUT_FRAC = 0.82 # keep this fraction of the calf (knee->ankle) CUFF_ARM_START_FRAC = 0.60 # cuff band = beyond this fraction of the lowerarm CUFF_LEG_START_FRAC = 0.56 # cuff band = beyond this fraction of the calf # Belt: centred between pelvis head and spine_01 head (the torso|hips seam # band sits at ~0.90 of that span on average_m), half-height in ref metres # scaled by each body's pelvis->spine_01 span. BELT_CENTER_FRAC = 0.90 BELT_HALF_M_REF = 0.032 BUCKLE_X_ABS_REF = 0.045 # front-centre belt faces within this |x| = buckle plate # Shoulder marks: top-facing band above the upperarm head, outside the collar. SHOULDER_Z_FRAC = 0.18 # of (neck head z - upperarm head z), above upperarm head SHOULDER_X_MAX_FRAC = 1.38 # of shoulder |x| SHOULDER_NORMAL_Z_MIN = 0.45 # surface must point upward # Chest patch: the logo box inset by this fraction of its width/height per # side, so the amber patch frames the decal instead of touching the box edge. CHEST_PATCH_INSET_FRAC = 0.10 # Right-thigh utility patch: box along the thigh bone, front-facing. THIGH_PATCH_SIDE = "thigh_r" THIGH_PATCH_Z_FRACS = (0.30, 0.58) # fraction down the thigh bone THIGH_PATCH_HALF_X_REF = 0.058 THIGH_PATCH_NORMAL_Y_MIN = 0.10 # forward-facing (front = -Y, base.FRONT_Y_SIGN) # Painted-albedo luminance per region label (flat toon fills). ALBEDO_LUMA = { "body": 0.62, "collar": 0.66, "cuff": 0.66, "shoulder": 0.50, "belt": 0.34, "buckle": 0.82, "patch": 0.72, } STITCH_LUMA = 0.30 # dark seam/stitch lines on region boundaries ALBEDO_NOISE = 0.03 # +/- woven-feel jitter (matches the base script) # Label ids index the vectorised classifier's output arrays. LABELS = ["body", "collar", "cuff", "shoulder", "belt", "buckle", "patch"] LABEL_ID = {name: i for i, name in enumerate(LABELS)} LABEL_RGBA = { "collar": (1.0, 0.0, 0.0, 0.0), # R trim "cuff": (1.0, 0.0, 0.0, 0.0), # R trim "body": (0.0, 1.0, 0.0, 0.0), # G main fabric "belt": (0.0, 0.0, 1.0, 0.0), # B belt + patches "buckle": (0.0, 0.0, 1.0, 0.0), # B "patch": (0.0, 0.0, 1.0, 0.0), # B "shoulder": (0.0, 0.0, 0.0, 1.0), # A shoulder marks } LABEL_RGBA_ARR = np.array([LABEL_RGBA[n] for n in LABELS], dtype=np.float32) LABEL_LUMA_ARR = np.array([ALBEDO_LUMA[n] for n in LABELS], dtype=np.float32) _REF_SHOULDER_X = 0.1919 # average_m upperarm head |x| (same anchor as base) log = base.log # -------------------------------------------------------------------------- # Threshold derivation (extends base.derive_thresholds with coverall zones) # -------------------------------------------------------------------------- def derive_coverall_thresholds(armature): thr = base.derive_thresholds(armature) bones = armature.data.bones def bone(name): b = bones.get(name) if b is None: raise RuntimeError(f"landmark bone {name} missing") return b ua = bone("upperarm_l") neck = bone("neck_01") pelvis = bone("pelvis") spine01 = bone("spine_01") shoulder_x = abs(ua.head_local.x) scale = shoulder_x / _REF_SHOULDER_X # Wrist cut planes + arm cuff start, per side (arm runs along +/-X). for side, sign in (("l", +1), ("r", -1)): la = bone(f"lowerarm_{side}") hx, tx = la.head_local.x, la.tail_local.x thr[f"wrist_cut_x_{side}"] = hx + WRIST_CUT_FRAC * (tx - hx) thr[f"cuff_arm_x_{side}"] = hx + CUFF_ARM_START_FRAC * (tx - hx) # Ankle cut + leg cuff start (leg runs down -Z; both calves share z). calf = bone("calf_l") hz, tz = calf.head_local.z, calf.tail_local.z thr["ankle_cut_z"] = hz + ANKLE_CUT_FRAC * (tz - hz) thr["cuff_leg_z"] = hz + CUFF_LEG_START_FRAC * (tz - hz) # Belt band. pz, sz = pelvis.head_local.z, spine01.head_local.z span = sz - pz thr["belt_z"] = pz + BELT_CENTER_FRAC * span thr["belt_half"] = BELT_HALF_M_REF * scale thr["buckle_x_abs"] = BUCKLE_X_ABS_REF * scale # Shoulder marks. uz = ua.head_local.z thr["shoulder_z_min"] = uz + SHOULDER_Z_FRAC * (neck.head_local.z - uz) thr["shoulder_x_max"] = shoulder_x * SHOULDER_X_MAX_FRAC # Chest patch = logo box inset a little on every side. cx0, cx1 = thr["chest_x"] cz0, cz1 = thr["chest_z"] dx = (cx1 - cx0) * CHEST_PATCH_INSET_FRAC dz = (cz1 - cz0) * CHEST_PATCH_INSET_FRAC thr["patch_x"] = (cx0 + dx, cx1 - dx) thr["patch_z"] = (cz0 + dz, cz1 - dz) # Right-thigh patch box. thigh = bone(THIGH_PATCH_SIDE) thz, ttz = thigh.head_local.z, thigh.tail_local.z f0, f1 = THIGH_PATCH_Z_FRACS thr["thigh_patch_z"] = (thz + f1 * (ttz - thz), thz + f0 * (ttz - thz)) tx_ctr = thigh.head_local.x half = THIGH_PATCH_HALF_X_REF * scale thr["thigh_patch_x"] = (tx_ctr - half, tx_ctr + half) log( "coverall thresholds: " f"wrist_l x>{thr['wrist_cut_x_l']:.3f} wrist_r x<{thr['wrist_cut_x_r']:.3f} " f"ankle z<{thr['ankle_cut_z']:.3f} " f"belt z={thr['belt_z']:.3f}+/-{thr['belt_half']:.3f} " f"shoulder z>={thr['shoulder_z_min']:.3f} " f"thigh_patch x=({thr['thigh_patch_x'][0]:.3f},{thr['thigh_patch_x'][1]:.3f}) " f"z=({thr['thigh_patch_z'][0]:.3f},{thr['thigh_patch_z'][1]:.3f})" ) return thr # -------------------------------------------------------------------------- # Limb cuts (wrists + ankles) # -------------------------------------------------------------------------- def limb_cuts(shell, thr): """Delete verts beyond the wrist planes (|X|) and below the ankle plane (Z).""" wl = thr["wrist_cut_x_l"] wr = thr["wrist_cut_x_r"] az = thr["ankle_cut_z"] 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 > wl or v.co.x < wr or v.co.z < az ] bmesh.ops.delete(bm, geom=to_delete, context='VERTS') bm.to_mesh(shell.data) bm.free() shell.data.update() log(f"limb cuts removed {len(to_delete)} verts " f"(wrist x>{wl:.3f}/x<{wr:.3f}, ankle z<{az:.3f}); " f"{len(shell.data.vertices)} remain") # -------------------------------------------------------------------------- # Region classification (vectorised, per texel) # # Face-granular classification made the patch/belt/shoulder boundaries follow # the triangulation (jagged, torn-looking zones on the first preview). The # bake therefore classifies every TEXEL: barycentric interpolation gives each # covered texel a body-space position + smoothed vertex normal, and the zone # boundaries land exactly where the thresholds say — crisp at mask resolution. # -------------------------------------------------------------------------- def classify_texels(pos, nrm, thr): """Classify N texels. pos/nrm are (N,3) body-local arrays. Returns (N,) uint8 label ids (indices into LABELS). Precedence: collar, cuffs, shoulder marks, belt/buckle, chest patch, thigh patch, body. """ x, y, z = pos[:, 0], pos[:, 1], pos[:, 2] fy = y * base.FRONT_Y_SIGN fwd = nrm[:, 1] * base.FRONT_Y_SIGN ax = np.abs(x) lab = np.full(x.shape, LABEL_ID["body"], dtype=np.uint8) remaining = np.ones(x.shape, dtype=bool) def take(cond, name): m = cond & remaining lab[m] = LABEL_ID[name] remaining[m] = False take((z >= thr["collar_z_min"]) & (ax < thr["collar_x_abs"]), "collar") take((x >= thr["cuff_arm_x_l"]) | (x <= thr["cuff_arm_x_r"]), "cuff") take(z <= thr["cuff_leg_z"], "cuff") take( (z >= thr["shoulder_z_min"]) & (ax >= thr["collar_x_abs"]) & (ax <= thr["shoulder_x_max"]) & (nrm[:, 2] > SHOULDER_NORMAL_Z_MIN), "shoulder", ) belt = np.abs(z - thr["belt_z"]) <= thr["belt_half"] take(belt & (fy > 0.0) & (ax < thr["buckle_x_abs"]), "buckle") take(belt, "belt") px0, px1 = thr["patch_x"] pz0, pz1 = thr["patch_z"] take( (fy > base.CHEST_FRONT_Y) & (x >= px0) & (x <= px1) & (z >= pz0) & (z <= pz1) & (fwd > base.CHEST_NORMAL_Y), "patch", ) tx0, tx1 = thr["thigh_patch_x"] tz0, tz1 = thr["thigh_patch_z"] take( (fy > 0.0) & (x >= tx0) & (x <= tx1) & (z >= tz0) & (z <= tz1) & (fwd > THIGH_PATCH_NORMAL_Y_MIN), "patch", ) return lab # -------------------------------------------------------------------------- # Combined mask + painted-albedo bake (one classification pass) # -------------------------------------------------------------------------- def _tri_texels(a, b, c, W, H): """Texels covered by UV triangle (a,b,c) with barycentric weights. Same maths as base._raster_tri, but returns (ys, xs, w0, w1, w2) arrays instead of writing a flat colour, so the caller can interpolate per-texel attributes (position, normal) across the triangle. """ empty = (np.empty(0, int),) * 2 + (np.empty(0, np.float32),) * 3 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 empty denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy) if abs(denom) < 1e-9: return empty 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 empty return ( ys[inside], xs[inside], w0[inside].astype(np.float32), w1[inside].astype(np.float32), w2[inside].astype(np.float32), ) def bake_mask_and_albedo(shell, thr, mask_path, albedo_name, seed): """One pass over the faces: bake the RGBA region mask AND the painted detail albedo (flat per-region luminance + stitch lines + woven noise). Classification is per TEXEL (interpolated position + smoothed vertex normal), so zone boundaries are crisp at mask resolution instead of following the triangulation. """ W = H = base.MASK_SIZE mask = np.zeros((H, W, 4), dtype=np.float32) mask[:, :, 1] = 1.0 # green background = main body (bilinear-bleed safe) albedo = np.zeros((H, W, 4), dtype=np.float32) albedo[:, :, 0:3] = ALBEDO_LUMA["body"] albedo[:, :, 3] = 1.0 label_map = np.full((H, W), LABEL_ID["body"], dtype=np.uint8) covered = np.zeros((H, W), dtype=bool) me = shell.data bm = bmesh.new() bm.from_mesh(me) bm.faces.ensure_lookup_table() bm.normal_update() uv_layer = bm.loops.layers.uv.active if uv_layer is None: raise RuntimeError("no active UV layer for bake") for face in bm.faces: loops = face.loops[:] uvs = [loop[uv_layer].uv for loop in loops] pos = [loop.vert.co for loop in loops] nrm = [loop.vert.normal for loop in loops] for i in range(1, len(loops) - 1): tri = (0, i, i + 1) ys, xs, w0, w1, w2 = _tri_texels(uvs[tri[0]], uvs[tri[1]], uvs[tri[2]], W, H) if ys.size == 0: continue p = np.empty((ys.size, 3), dtype=np.float32) n = np.empty((ys.size, 3), dtype=np.float32) for axis in range(3): p[:, axis] = (w0 * pos[tri[0]][axis] + w1 * pos[tri[1]][axis] + w2 * pos[tri[2]][axis]) n[:, axis] = (w0 * nrm[tri[0]][axis] + w1 * nrm[tri[1]][axis] + w2 * nrm[tri[2]][axis]) n /= np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-9) lab = classify_texels(p, n, thr) label_map[ys, xs] = lab covered[ys, xs] = True mask[ys, xs] = LABEL_RGBA_ARR[lab] albedo[ys, xs, 0:3] = LABEL_LUMA_ARR[lab][:, None] total = max(int(covered.sum()), 1) tex_counts = np.bincount(label_map[covered], minlength=len(LABELS)) log("region texels: " + " ".join( f"{LABELS[i]}={int(c)} ({100.0 * c / total:.1f}%)" for i, c in enumerate(tex_counts) if c > 0)) # Woven-feel noise over the fills, before stitch lines (lines stay crisp). rng = np.random.default_rng(seed) noise = (rng.random((H, W, 1), dtype=np.float32) - 0.5) * 2.0 * ALBEDO_NOISE albedo[:, :, 0:3] = np.clip(albedo[:, :, 0:3] + noise, 0.0, 1.0) # Stitch lines: texel-space label transitions where BOTH texels belong to # rasterised geometry (skipping UV-island borders against background). # Buckle|belt stays seamless (same physical strap). lm = np.where(label_map == LABEL_ID["buckle"], LABEL_ID["belt"], label_map) edge = np.zeros((H, W), dtype=bool) dh = (lm[:, 1:] != lm[:, :-1]) & covered[:, 1:] & covered[:, :-1] edge[:, 1:] |= dh edge[:, :-1] |= dh dv = (lm[1:, :] != lm[:-1, :]) & covered[1:, :] & covered[:-1, :] edge[1:, :] |= dv edge[:-1, :] |= dv albedo[edge, 0:3] = STITCH_LUMA bm.free() log(f"stitch lines on {int(edge.sum())} boundary texels") # Floor the alpha channel at 2/255: Godot's default texture import runs # process/fix_alpha_border, which rewrites the RGB of fully-transparent # texels bordering opaque ones — that would smear the shoulder-mark island # edges into the surrounding body-green weights. With no alpha-0 texels the # pass is a no-op; the 0.8% tint_3 weight it adds everywhere is invisible. mask[:, :, 3] = np.maximum(mask[:, :, 3], 2.0 / 255.0) img_mask = bpy.data.images.new(f"mask_{albedo_name}", W, H, alpha=True) # The mask is channel-packed DATA (R/G/B/A region weights), not imagery # with transparency: without this, Blender's straight-alpha PNG save # zeroes the A channel (verified: shoulder-mark texels came back A=0). img_mask.alpha_mode = 'CHANNEL_PACKED' img_mask.pixels.foreach_set(mask.reshape(-1)) img_mask.update() img_mask.filepath_raw = mask_path img_mask.file_format = 'PNG' img_mask.save() log(f"baked region mask -> {mask_path}") img_albedo = bpy.data.images.new(f"albedo_{albedo_name}", W, H, alpha=False) img_albedo.pixels.foreach_set(albedo.reshape(-1)) img_albedo.update() return img_albedo # -------------------------------------------------------------------------- # Per-body authoring # -------------------------------------------------------------------------- def author_coverall(body_dir, out_dir, body, offset, seed): base.clear_scene() base.COVERED_SEGMENTS = COVERED_SEGMENTS # build_covered_mesh reads this shell, armature = base.build_covered_mesh(body_dir) thr = derive_coverall_thresholds(armature) limb_cuts(shell, thr) base.offset_outward(shell, offset) # Author UV2 + bake mask/albedo BEFORE solidify: the inner shell that # solidify adds duplicates every face with the SAME atlas UVs but a # flipped normal — the normal-gated rules (shoulder marks, patches) would # classify those copies as body and overwrite the very texels the outer # faces just wrote. Pre-solidify there is exactly one face per texel. base.author_logo_uv(shell, thr) # UV2 (inner shell inherits it, harmless) albedo_img = bake_mask_and_albedo( shell, thr, os.path.join(out_dir, f"{body}_mask.png"), body, seed) base.solidify(shell, base.CLOTH_THICKNESS_M) base.assign_fabric_material(shell, albedo_img) # Save the albedo under the SHARED name before export: the glTF exporter # names the embedded texture after the image filepath basename, and the # Godot GLB import extracts it as _.png — with the shared # name that lands on the tshirt-convention _base_albedo.png (a # -specific filepath here would yield __base_albedo.png). base.save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png")) base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb")) # Keep a deterministic authored per-body sidecar as well. shutil.copy2(os.path.join(out_dir, "base_albedo.png"), os.path.join(out_dir, f"{body}_base_albedo.png")) 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 = base.PER_BODY_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"coverall per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm") results = [] for i, body in enumerate(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_coverall(body_dir, out_dir, body, offset, seed=1089 + i) results.append((body, "ok")) except Exception as exc: log(f"ERROR {body}: {exc}") import traceback traceback.print_exc() results.append((body, f"error: {exc}")) # Runtime fallback: reference_mask.png + base_albedo.png mirror average_m. 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 (fallback)") # base_albedo.png currently holds the LAST body's albedo; restore the # reference body's copy so the shared sidecar is deterministic. ref_albedo = os.path.join(out_dir, f"{base.REFERENCE_BODY}_base_albedo.png") if os.path.isfile(ref_albedo): shutil.copy2(ref_albedo, os.path.join(out_dir, "base_albedo.png")) log(f"copied {base.REFERENCE_BODY}_base_albedo.png -> 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()