""" blender_author_offset_shell.py (T-1089, route (c) offset-shell authoring) Derive a garment SHELL from our own body segment meshes — the "create-stuff- yourself" authoring pipeline that owes nothing to any vendor pack. Because the shell IS our body topology, bone weights are inherited by construction (no Surface-Deform, no Data-Transfer, no re-rig): every vertex keeps the 65-bone vertex groups it had as skin. Pipeline (t-shirt reference on average_m): 1. Import the body segments the garment covers (torso + torso_upper + upper arms), keep only the skinned body meshes (Icosphere debris filtered out). 2. Join into one mesh under a single armature; merge vertex groups by name. 3. Bone-plane SLEEVE cut — trim the upper-arm tube to short-sleeve length via a coordinate threshold derived from the upperarm bone axis (robust; no boundary-loop classification, which the segment tool warns is fragile). Neckline + hem come free as the natural segment boundaries. 4. Offset the surface outward along vertex normals (~12 mm standoff from skin). 5. Solidify (use_rim=True) — gives the cloth real thickness and caps the cut rims (sleeve openings) into hems. 6. Assign ONE flat modern-fabric material (drops all skin textures). Style pin: neutral heather tone, no trim, no fantasy anything. 7. Bake a UV0-aligned RGBA REGION MASK per-face: collar band -> R, main body -> G, sleeve trim -> B (channel-routed 4-tint shader input, G3). 8. Author a 2nd UV channel (TEXCOORD_1) projecting the front chest into [0,1] for the logo decal (G4); everything else parks outside the box. 9. Export the reference GLB (export_skins=True) + write reference_mask.png and base_albedo.png sidecars. Two modes (T-1089): SINGLE-REFERENCE (default) — author on one body (average_m); G1 (blender_batch_fit_skinned.py) SD-fits it to the other body types. Right for derived/hand-authored garments that share one UV layout + one mask. tooling/blender --background --python \ tooling/garment-fit/blender_author_offset_shell.py -- \ /average_m [--offset 0.020] [--sleeve-frac 0.40] Writes: /average_m.glb reference garment (skinned) /reference_mask.png RGBA region mask (UV0-aligned) /base_albedo.png flat fabric albedo (also embedded in the GLB) PER-BODY (--per-body) — author the shell from EACH body's own segment meshes. QA evidence (Q-060): single-reference SD-fit of offset-shells degrades with girth divergence (muscular_m 859px worst clip at 24 mm standoff). Authoring per body gives guaranteed standoff + exact weights by construction, and lets the offset drop back to the ~12 mm ideal. Cut/mask parameters are derived from each body's OWN bone landmarks using the same proportional ratios (anchored to reproduce the hand-calibrated average_m constants exactly), so regions stay consistent across bodies. tooling/blender --background --python \ tooling/garment-fit/blender_author_offset_shell.py -- \ --per-body \ [--bodies average_m,child,...] [--offset 0.012] [--sleeve-frac 0.40] Writes per body: /.glb skinned garment authored on that body /_mask.png RGBA region mask (that body's UV0 layout) Plus: /base_albedo.png shared flat fabric albedo (deterministic) /reference_mask.png copy of average_m_mask.png (runtime fallback) The runtime compositor (character_visual.gd) prefers _mask.png and falls back to reference_mask.png for SD-fit garments. Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house wardrobe). """ import sys import os import shutil import bpy import bmesh import numpy as np # -------------------------------------------------------------------------- # Parameters # -------------------------------------------------------------------------- # Segments a t-shirt covers. Order matters only for join-active choice. COVERED_SEGMENTS = ["seg_torso", "seg_torso_upper", "seg_arm_upper_l", "seg_arm_upper_r"] OFFSET_M = 0.020 # single-reference standoff along vertex normals; larger # than the 10-14 mm ideal on the reference body buys # clearance for bigger bodies under Surface-Deform # batch-fit (Q-060) — the muscular/female torso otherwise # pokes through. PER_BODY_OFFSET_M = 0.012 # per-body standoff — each body's own surface guarantees # clearance by construction, so the ideal applies. CLOTH_THICKNESS_M = 0.004 # Solidify thickness after offset SLEEVE_FRAC = 0.40 # fraction of upper-arm length kept (short sleeve) MASK_SIZE = 512 ALBEDO_SIZE = 512 # All shipping body types (matches blender_batch_fit_skinned.py / the runtime). BODY_TYPES = [ "average_m", "average_f", "muscular_m", "muscular_f", "thin_m", "thin_f", "heavy_m", "heavy_f", "teen_m", "teen_f", "child", ] REFERENCE_BODY = "average_m" # Flat modern-fabric base tone (linear-ish sRGB), neutral heather grey. FABRIC_RGB = (0.60, 0.61, 0.63) FABRIC_NOISE = 0.03 # +/- albedo jitter for a subtle woven feel # Chest logo box in body-local metres (X width, Z height). # FRONT AXIS: these Quaternius bodies face -Y in Blender (verified empirically — # a +Y test projection landed on the character's back). So "front" = -Y. FRONT_Y_SIGN = -1.0 CHEST_X = (-0.12, 0.12) CHEST_Z = (1.16, 1.44) CHEST_FRONT_Y = 0.015 # face centre must be on the front side by at least this CHEST_NORMAL_Y = 0.20 # face normal must point forward by at least this much # Region-mask classification (body-local, Z up). COLLAR_Z_MIN = 1.49 # faces above this AND near centre -> collar band (R) COLLAR_X_ABS = 0.11 # collar band stays near the neck, not the shoulders SLEEVE_X_ABS = 0.20 # faces with |center X| beyond this -> sleeve cap (B) # -------------------------------------------------------------------------- # Per-body threshold derivation (T-1089 per-body shell mode) # # The absolute constants above were hand-calibrated on average_m. The ratios # below re-express every one of them against average_m's bone landmarks # (shoulder = upperarm head |x| 0.1919, neck_01 head z 1.5205 / length 0.0793, # spine_01 head z 1.072) so any body derives the SAME proportional cut/mask # parameters from its own armature. On average_m the derivation reproduces # the legacy constants exactly; the 11 bodies share one 65-bone rig, so the # landmarks exist everywhere. # -------------------------------------------------------------------------- _REF_SHOULDER_X = 0.1919 _REF_NECK_Z = 1.5205 _REF_NECK_LEN = 0.0793 _REF_SPINE_LO_Z = 1.072 SLEEVE_X_FRAC = SLEEVE_X_ABS / _REF_SHOULDER_X # of shoulder |x| COLLAR_X_FRAC = COLLAR_X_ABS / _REF_SHOULDER_X # of shoulder |x| COLLAR_DROP_FRAC = (_REF_NECK_Z - COLLAR_Z_MIN) / _REF_NECK_LEN # below neck head CHEST_X_FRAC = CHEST_X[1] / _REF_SHOULDER_X # of shoulder |x| _REF_SPINE_SPAN = _REF_NECK_Z - _REF_SPINE_LO_Z CHEST_Z_LO_FRAC = (CHEST_Z[0] - _REF_SPINE_LO_Z) / _REF_SPINE_SPAN CHEST_Z_HI_FRAC = (CHEST_Z[1] - _REF_SPINE_LO_Z) / _REF_SPINE_SPAN def log(msg): print(f"[offset-shell] {msg}") def derive_thresholds(armature): """Derive cut/mask thresholds from this body's bone landmarks. Returns a dict {sleeve_x_abs, collar_z_min, collar_x_abs, chest_x, chest_z}. Falls back to the legacy average_m constants when landmarks are missing. """ bones = armature.data.bones ua_l = bones.get("upperarm_l") ua_r = bones.get("upperarm_r") neck = bones.get("neck_01") spine01 = bones.get("spine_01") if not all([ua_l, ua_r, neck, spine01]): log("WARNING: landmark bones missing — using legacy average_m thresholds") return { "sleeve_x_abs": SLEEVE_X_ABS, "collar_z_min": COLLAR_Z_MIN, "collar_x_abs": COLLAR_X_ABS, "chest_x": CHEST_X, "chest_z": CHEST_Z, } shoulder_x = (abs(ua_l.head_local.x) + abs(ua_r.head_local.x)) / 2.0 neck_z = neck.head_local.z neck_len = neck.tail_local.z - neck.head_local.z spine_lo = spine01.head_local.z spine_span = neck_z - spine_lo thr = { "sleeve_x_abs": shoulder_x * SLEEVE_X_FRAC, "collar_z_min": neck_z - COLLAR_DROP_FRAC * neck_len, "collar_x_abs": shoulder_x * COLLAR_X_FRAC, "chest_x": (-shoulder_x * CHEST_X_FRAC, shoulder_x * CHEST_X_FRAC), "chest_z": (spine_lo + CHEST_Z_LO_FRAC * spine_span, spine_lo + CHEST_Z_HI_FRAC * spine_span), } log(f"thresholds: sleeve |x|>={thr['sleeve_x_abs']:.3f} " f"collar z>={thr['collar_z_min']:.3f} |x|<{thr['collar_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 # -------------------------------------------------------------------------- # Scene helpers # -------------------------------------------------------------------------- def clear_scene(): bpy.ops.object.select_all(action='SELECT') bpy.ops.object.delete() bpy.ops.outliner.orphans_purge(do_recursive=True) def import_glb(path): before = set(bpy.context.scene.objects) bpy.ops.import_scene.gltf(filepath=path) return [o for o in bpy.context.scene.objects if o not in before] def is_body_mesh(obj): """A real skinned body segment mesh — not Icosphere debris.""" if obj.type != 'MESH': return False if obj.name.startswith("Icosphere"): return False if len(obj.vertex_groups) == 0: return False if len(obj.data.vertices) < 50: return False return True # -------------------------------------------------------------------------- # Build the joined shell base # -------------------------------------------------------------------------- def build_covered_mesh(body_dir): """Import covered segments, keep skinned meshes, join to one mesh + armature.""" body_meshes = [] armature = None for seg in COVERED_SEGMENTS: path = os.path.join(body_dir, f"{seg}.glb") if not os.path.isfile(path): log(f"WARNING: missing segment {path} — skipping") continue objs = import_glb(path) for o in objs: if o.type == 'ARMATURE' and armature is None: armature = o elif o.type == 'ARMATURE': # drop extra armature copies (identical rest pose) bpy.data.objects.remove(o, do_unlink=True) elif is_body_mesh(o): body_meshes.append(o) else: # Icosphere / debris bpy.data.objects.remove(o, do_unlink=True) if not body_meshes: raise RuntimeError("no skinned body meshes imported for covered segments") if armature is None: raise RuntimeError("no armature found in covered segments") # Join meshes (vertex groups merge by name across segments). bpy.ops.object.select_all(action='DESELECT') for m in body_meshes: m.select_set(True) bpy.context.view_layer.objects.active = body_meshes[0] bpy.ops.object.join() shell = bpy.context.active_object shell.name = "garment_shell" # Re-point the armature modifier at the surviving armature; re-parent. for mod in list(shell.modifiers): if mod.type == 'ARMATURE': mod.object = armature shell.parent = armature shell.matrix_parent_inverse = armature.matrix_world.inverted() log(f"joined shell: {len(shell.data.vertices)} verts, " f"{len(shell.data.polygons)} faces, {len(shell.vertex_groups)} vgroups") return shell, armature # -------------------------------------------------------------------------- # Bone-plane sleeve cut # -------------------------------------------------------------------------- def sleeve_cut(shell, armature): """Delete sleeve-tip verts beyond the short-sleeve plane on each upper arm. The upper arm runs along +/-X (shoulder head -> elbow tail). We keep the fraction SLEEVE_FRAC of that length from the shoulder and delete the rest. Torso verts stay (|X| < shoulder head), so a single coordinate threshold is safe and needs no per-vertex weight test. """ bones = armature.data.bones cut_planes = [] # (axis_sign, threshold_x) for bone_name, sign in [("upperarm_l", +1), ("upperarm_r", -1)]: b = bones.get(bone_name) if b is None: log(f"WARNING: bone {bone_name} missing — sleeve not cut on that side") continue head_x = b.head_local.x tail_x = b.tail_local.x thr = head_x + SLEEVE_FRAC * (tail_x - head_x) cut_planes.append((sign, thr)) log(f"sleeve cut {bone_name}: keep |x| up to {thr:.3f} " f"(shoulder {head_x:.3f} -> elbow {tail_x:.3f})") bm = bmesh.new() bm.from_mesh(shell.data) bm.verts.ensure_lookup_table() to_delete = [] for v in bm.verts: for sign, thr in cut_planes: if sign > 0 and v.co.x > thr: to_delete.append(v) break if sign < 0 and v.co.x < 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"sleeve cut removed {len(to_delete)} verts; " f"{len(shell.data.vertices)} remain") # -------------------------------------------------------------------------- # Outward offset + solidify # -------------------------------------------------------------------------- def offset_outward(shell, offset): """Push every vertex outward along its (smoothed) normal by `offset` m.""" me = shell.data me.calc_normals_split() if hasattr(me, "calc_normals_split") else None bm = bmesh.new() bm.from_mesh(me) bm.normal_update() for v in bm.verts: v.co += v.normal * offset bm.to_mesh(me) bm.free() me.update() log(f"offset surface outward by {offset*1000:.0f} mm along normals") # -------------------------------------------------------------------------- # Convex toe box (T-1089 footwear fix — shared by sneakers/shoes/boots) # # Closed shoes have a smooth rigid TOE BOX: a convex rounded cap the toes sit # INSIDE, not a shell that wraps each toe. The earlier per-script approach # (Laplacian smooth the toes, then push verts back out to the ORIGINAL skin # surface) re-imprinted the individual toes — the skin-conforming clamp # followed each toe bump, so bumps/pokes survived. This routine instead # forces every toe cross-section onto one analytic half-ellipse dome that # CIRCUMSCRIBES the toes (guaranteed outside the skin, so no poke, and no # per-toe detail survives), extends the nose forward past the longest toe, # and rebinds the whole box UNIFORMLY to the ball bone so it flexes rigidly # at the ball joint with no per-vertex toe-weight ripple under animation. # # Applied PRE-offset: the mold encloses the skin toes by construction, then # offset_outward adds the standoff uniformly over a smooth surface. No skin # clamp is needed (or wanted) in the toe zone afterward. # -------------------------------------------------------------------------- def _smoothstep(t): t = min(max(t, 0.0), 1.0) return t * t * (3.0 - 2.0 * t) def foot_ball_u(armature): """Forward coord (u = y*FRONT_Y_SIGN) of the ball joint (toe-box hinge). Bodies face -Y so toes point -Y; u increases toward the toes. ball_l/ball_r share the same forward head coord (feet are x-mirror symmetric).""" ball = armature.data.bones.get("ball_l") if ball is None: return None return ball.head_local.y * FRONT_Y_SIGN def _interp(x, xp, fp): """Minimal linear interp with flat ends (np.interp semantics, no import).""" if x <= xp[0]: return fp[0] if x >= xp[-1]: return fp[-1] for i in range(1, len(xp)): if x < xp[i]: t = (x - xp[i - 1]) / max(xp[i] - xp[i - 1], 1e-9) return fp[i - 1] + t * (fp[i] - fp[i - 1]) return fp[-1] def convex_toe_box(shell, armature, *, extension, width_margin, height_clear, nbins=10, feather_m=0.020, bottom_band_m=0.0015, nose_frac=0.40, smooth_iters=7, smooth_factor=0.6, uniform_ball_weights=True): """Reshape the forefoot into a smooth convex toe box (per foot side). Each cross-section forward of the ball joint is forced onto ONE smooth ellipse that circumscribes that slice's toe verts — every individual-toe bump/crevice is erased and the shell sits OUTSIDE the skin (the ellipse is the slice's own enclosing ellipse + a margin, so projecting only pushes verts outward). Sizes are measured per u-slice (never a single collapsing quadric, which over-inflates), so the box follows the foot's natural taper while reading as one rigid cap. The frontmost `nose_frac` of the toe length is pushed forward up to `extension` past the longest toe. The whole cap is rebound uniformly to the ball bone so it flexes rigidly at the ball joint with no per-vertex toe-weight ripple. extension forward nose extension past the longest toe (m). width_margin half-width padding added around each slice (m). height_clear vertical headroom added above the toes (m) — flex room. nbins number of u-slices sized independently along the toe length. feather_m blend band behind the ball over which effect + rebind ramp. bottom_band_m underside band left for the sole routine (dome does top+sides). nose_frac fraction of the toe length (from the tip back) that is pushed forward to form the extended rounded nose. """ ball_u = foot_ball_u(armature) if ball_u is None: log("WARNING: ball_l missing — convex toe box skipped") return me = shell.data bm = bmesh.new() bm.from_mesh(me) bm.verts.ensure_lookup_table() bm.normal_update() # pre-reshape normals gate the underside (sole) verts dl = bm.verts.layers.deform.verify() gi = {g.name: g.index for g in shell.vertex_groups} ball_gi = {1: gi.get("ball_l"), -1: gi.get("ball_r")} verts = list(bm.verts) feather_u0 = ball_u - feather_m total_reshaped = 0 for side in (1, -1): sverts = [v for v in verts if (v.co.x * side) > 0.0] toe = [v for v in sverts if (v.co.y * FRONT_Y_SIGN) > ball_u] if len(toe) < 6: continue cx = sum(v.co.x for v in toe) / len(toe) base_z = min(v.co.z for v in sverts) # per-side sole level u_tip = max(v.co.y * FRONT_Y_SIGN for v in toe) span = max(u_tip - ball_u, 1e-6) # --- per-slice circumscribing ellipse (top + side verts only) -------- centers = [ball_u + span * (i + 0.5) / nbins for i in range(nbins)] Barr = [width_margin] * nbins Harr = [height_clear] * nbins bin_verts = [[] for _ in range(nbins)] for v in toe: if (v.co.z - base_z) <= bottom_band_m: continue # underside -> sole routine i = int((v.co.y * FRONT_Y_SIGN - ball_u) / span * nbins) i = min(max(i, 0), nbins - 1) bin_verts[i].append(v) for i in range(nbins): bv = bin_verts[i] if not bv: continue b0 = max(abs(v.co.x - cx) for v in bv) + width_margin h0 = max(v.co.z - base_z for v in bv) + height_clear # circumscribe: scale the (b0,h0) ellipse until it holds every vert kmax = 1.0 for v in bv: rr = (((v.co.x - cx) / b0) ** 2 + ((v.co.z - base_z) / h0) ** 2) ** 0.5 kmax = max(kmax, rr) Barr[i] = b0 * kmax Harr[i] = h0 * kmax # fill empty bins by carrying the last known size forward/back for i in range(1, nbins): if bin_verts[i] == [] or Barr[i] == width_margin: Barr[i], Harr[i] = Barr[i - 1], Harr[i - 1] # one along-length smoothing pass (keeps the cap from stepping) Bs = list(Barr) Hs = list(Harr) for i in range(1, nbins - 1): Bs[i] = 0.25 * Barr[i - 1] + 0.5 * Barr[i] + 0.25 * Barr[i + 1] Hs[i] = 0.25 * Harr[i - 1] + 0.5 * Harr[i] + 0.25 * Harr[i + 1] nose_start = u_tip - nose_frac * span work = [v for v in sverts if (v.co.y * FRONT_Y_SIGN) > feather_u0] # Capture the underside gate from the SKIN (pre-smooth) normals so it # matches how the per-style sole routine classifies its verts (roughly # normal.z < -0.5). Verts the sole owns are excluded from the cap, so # the cap never fights the sole flatten (which caused underside tears). gate = {} for v in work: gate[v.index] = _smoothstep((v.normal.z + 0.5) / 0.25) # -0.5->0 # --- fill the between-toe notches (Laplacian) BEFORE projecting ------- # The individual-toe crevices are deep valleys; in-place ellipse # projection alone leaves their walls. Smoothing melts the valleys into # one volume (like the old pipeline) — but the ellipse SIZES above were # measured from the ORIGINAL toe, so the projection below pushes the # smoothed (shrunk) surface back OUT onto a cap that still encloses the # real skin. The uniform ball rebind fixes the flex ripple that made # the old pipeline keep its smoothing timid. if smooth_iters > 0: toe_all = [v for v in sverts if (v.co.y * FRONT_Y_SIGN) > ball_u] for _ in range(smooth_iters): bmesh.ops.smooth_vert(bm, verts=toe_all, factor=smooth_factor, use_axis_x=True, use_axis_y=True, use_axis_z=True) n_side = 0 for v in work: u = v.co.y * FRONT_Y_SIGN f = _smoothstep((u - feather_u0) / max(ball_u - feather_u0, 1e-6)) # ellipse size at this u (from the pre-stretch position) B = _interp(u, centers, Bs) H = _interp(u, centers, Hs) hpos = v.co.z - base_z wn = gate[v.index] wh = 1.0 if hpos > bottom_band_m else 0.0 w = f * wn * wh dx = v.co.x - cx hh = max(hpos, 0.0) rr = ((dx / B) ** 2 + (hh / H) ** 2) ** 0.5 if w > 1e-6 and rr > 1e-6: scale = min(max(1.0 / rr, 0.5), 2.5) tx = cx + dx * scale tz = base_z + hh * scale v.co.x += (tx - v.co.x) * w v.co.z += (tz - v.co.z) * w n_side += 1 # forward nose push (feathered from nose_start to the tip) if u > nose_start: t = _smoothstep((u - nose_start) / max(u_tip - nose_start, 1e-6)) v.co.y += -extension * t * FRONT_Y_SIGN * f # Uniform ball rebinding (feathered by the length feather f). bi = ball_gi[side] if uniform_ball_weights and bi is not None: for v in work: u = v.co.y * FRONT_Y_SIGN f = _smoothstep((u - feather_u0) / max(ball_u - feather_u0, 1e-6)) if f <= 1e-6: continue dv = v[dl] for gidx in list(dv.keys()): dv[gidx] = dv[gidx] * (1.0 - f) cur = dv[bi] if bi in dv else 0.0 dv[bi] = cur + f tot = sum(dv[g] for g in dv.keys()) if tot > 1e-8: for gidx in list(dv.keys()): dv[gidx] = dv[gidx] / tot total_reshaped += n_side log(f"toe box side {'L' if side > 0 else 'R'}: {len(toe)} toe verts, " f"B={min(Bs) * 1000:.0f}-{max(Bs) * 1000:.0f}mm " f"H={min(Hs) * 1000:.0f}-{max(Hs) * 1000:.0f}mm " f"cap +{extension * 1000:.0f}mm, reshaped {n_side}") bm.normal_update() bm.to_mesh(me) bm.free() me.update() log(f"convex toe box: reshaped {total_reshaped} verts " f"(headroom {height_clear * 1000:.0f}mm, nose +{extension * 1000:.0f}mm)") def solidify(shell, thickness): """Solidify with use_rim to give cloth thickness and cap the cut rims.""" bpy.ops.object.select_all(action='DESELECT') shell.select_set(True) bpy.context.view_layer.objects.active = shell # consistent outward normals first bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') bpy.ops.mesh.normals_make_consistent(inside=False) bpy.ops.object.mode_set(mode='OBJECT') sol = shell.modifiers.new(name="Solidify", type='SOLIDIFY') sol.thickness = thickness sol.offset = 1.0 # grow outward only sol.use_rim = True # cap open boundaries (sleeve/neck/hem) sol.use_rim_only = False bpy.ops.object.modifier_apply(modifier=sol.name) log(f"solidified: {thickness*1000:.0f} mm, use_rim; " f"{len(shell.data.vertices)} verts") # -------------------------------------------------------------------------- # Material (flat fabric albedo) # -------------------------------------------------------------------------- def make_base_albedo_image(seed=1089): img = bpy.data.images.new("garment_base_albedo", ALBEDO_SIZE, ALBEDO_SIZE, alpha=False) rng = np.random.default_rng(seed) base = np.array(FABRIC_RGB, dtype=np.float32) noise = (rng.random((ALBEDO_SIZE * ALBEDO_SIZE, 1), dtype=np.float32) - 0.5) * 2.0 * FABRIC_NOISE rgb = np.clip(base[None, :] + noise, 0.0, 1.0) rgba = np.concatenate([rgb, np.ones((ALBEDO_SIZE * ALBEDO_SIZE, 1), dtype=np.float32)], axis=1) img.pixels.foreach_set(rgba.reshape(-1)) img.update() return img def assign_fabric_material(shell, albedo_img): shell.data.materials.clear() mat = bpy.data.materials.new("garment_fabric") mat.use_nodes = True nt = mat.node_tree bsdf = nt.nodes.get("Principled BSDF") tex = nt.nodes.new("ShaderNodeTexImage") tex.image = albedo_img nt.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"]) if "Roughness" in bsdf.inputs: bsdf.inputs["Roughness"].default_value = 0.9 shell.data.materials.append(mat) log("assigned flat fabric material (skin textures dropped)") # -------------------------------------------------------------------------- # Region mask bake (UV0-aligned, per-face rasterization) # -------------------------------------------------------------------------- def _classify_region(center, thr): """Return an RGBA region colour for a face centre (body-local coords).""" x, z = center.x, center.z if abs(x) >= thr["sleeve_x_abs"]: return (0.0, 0.0, 1.0, 0.0) # sleeve caps -> B (tint[2]) if z >= thr["collar_z_min"] and abs(x) < thr["collar_x_abs"]: return (1.0, 0.0, 0.0, 0.0) # neck collar band -> R (tint[0]) return (0.0, 1.0, 0.0, 0.0) # main body -> G (tint[1]) def _tris_from_face(face, uv_layer): """Fan-triangulate a bmesh face into (uv, uv, uv) tuples in [0,1] space.""" loops = face.loops[:] uvs = [loop[uv_layer].uv.copy() for loop in loops] tris = [] for i in range(1, len(uvs) - 1): tris.append((uvs[0], uvs[i], uvs[i + 1])) return tris def bake_region_mask(shell, out_path, thr): """Rasterize each face's UV0 triangle with its region colour into MASK_SIZE^2. Background initialised to main-body green so bilinear bleed at island edges never lands on an untinted (all-zero) texel. """ W = H = MASK_SIZE buf = np.zeros((H, W, 4), dtype=np.float32) buf[:, :, 1] = 1.0 # green background = main body 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 region mask bake") region_counts = {"collar": 0, "body": 0, "sleeve": 0} for face in bm.faces: color = _classify_region(face.calc_center_median(), thr) if color[0] > 0.5: region_counts["collar"] += 1 elif color[2] > 0.5: region_counts["sleeve"] += 1 else: region_counts["body"] += 1 for a, b, c in _tris_from_face(face, uv_layer): _raster_tri(buf, a, b, c, color, W, H) bm.free() total = max(sum(region_counts.values()), 1) log("region faces: " + " ".join( f"{k}={v} ({100.0 * v / total:.1f}%)" for k, v in region_counts.items())) # Blender image is bottom-up; buf row 0 is V=0 (bottom) already since we # rasterise with row = v*(H-1). Save via Blender to match the texture pipe. img = bpy.data.images.new("garment_region_mask", W, H, alpha=True) img.pixels.foreach_set(buf.reshape(-1)) img.update() img.filepath_raw = out_path img.file_format = 'PNG' img.save() log(f"baked region mask -> {out_path}") def _raster_tri(buf, a, b, c, color, W, H): """Barycentric fill of a UV triangle into buf (V=0 at row 0 = bottom).""" 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 region = buf[miny:maxy + 1, minx:maxx + 1, :] col = np.array(color, dtype=np.float32) region[inside] = col # -------------------------------------------------------------------------- # Logo UV2 chest channel # -------------------------------------------------------------------------- def author_logo_uv(shell, thr): """Create a 2nd UV layer projecting front chest faces into [0,1]; park the rest outside the box (shader guards uv2 in [0,1]).""" me = shell.data # Keep exactly two UV layers: primary (albedo/mask) + logo. Remove extras. while len(me.uv_layers) > 1: me.uv_layers.remove(me.uv_layers[-1]) logo_uv = me.uv_layers.new(name="logo_uv") me.uv_layers.active = me.uv_layers[0] # keep albedo layer active for mask bake safety bm = bmesh.new() bm.from_mesh(me) bm.faces.ensure_lookup_table() bm.normal_update() uvl = bm.loops.layers.uv.get("logo_uv") x0, x1 = thr["chest_x"] z0, z1 = thr["chest_z"] placed = 0 for face in bm.faces: center = face.calc_center_median() on_chest = ( center.y * FRONT_Y_SIGN > CHEST_FRONT_Y and x0 <= center.x <= x1 and z0 <= center.z <= z1 and face.normal.y * FRONT_Y_SIGN > CHEST_NORMAL_Y ) for loop in face.loops: if on_chest: co = loop.vert.co # Empirically calibrated for the -Y front so the wordmark reads # upright and left-to-right from the camera (see report: a plain # projection came out 180deg-rotated on this rig). u = (co.x - x0) / (x1 - x0) v = (co.z - z0) / (z1 - z0) loop[uvl].uv = (min(max(u, 0.0), 1.0), min(max(v, 0.0), 1.0)) else: loop[uvl].uv = (2.0, 2.0) # parked outside box if on_chest: placed += 1 bm.to_mesh(me) bm.free() me.update() log(f"logo UV2 authored on {placed} chest faces") if placed == 0: log("WARNING: no chest faces matched — check CHEST_* box / front axis") # -------------------------------------------------------------------------- # Export # -------------------------------------------------------------------------- def export_reference(shell, armature, out_path): bpy.ops.object.select_all(action='DESELECT') shell.select_set(True) armature.select_set(True) bpy.context.view_layer.objects.active = armature bpy.ops.export_scene.gltf( filepath=out_path, export_format='GLB', use_selection=True, export_apply=False, # keep Armature modifier for skinning export_animations=False, export_skins=True, export_yup=True, export_texcoords=True, export_normals=True, export_materials='EXPORT', export_image_format='AUTO', ) size_kb = os.path.getsize(out_path) // 1024 log(f"exported reference -> {out_path} ({size_kb} KB)") def save_albedo_sidecar(albedo_img, out_path): albedo_img.filepath_raw = out_path albedo_img.file_format = 'PNG' albedo_img.save() log(f"saved base albedo -> {out_path}") # -------------------------------------------------------------------------- # Entry # -------------------------------------------------------------------------- def author_shell(body_dir, out_dir, glb_name, mask_name, offset): """Author one offset-shell garment from `body_dir`'s segments. Shared by both modes; thresholds derive from the body's own armature so the same proportional cut/mask parameters apply on every body. """ clear_scene() shell, armature = build_covered_mesh(body_dir) thr = derive_thresholds(armature) sleeve_cut(shell, armature) offset_outward(shell, offset) solidify(shell, CLOTH_THICKNESS_M) albedo_img = make_base_albedo_image() assign_fabric_material(shell, albedo_img) author_logo_uv(shell, thr) # do UV2 before mask bake (mask uses UV0/active) bake_region_mask(shell, os.path.join(out_dir, mask_name), thr) save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png")) export_reference(shell, armature, os.path.join(out_dir, glb_name)) def main(): argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] if len(argv) < 2: print("Usage: -- /average_m " "[--offset M] [--sleeve-frac F]\n" " or: -- --per-body " "[--bodies a,b,c] [--offset M] [--sleeve-frac F]") sys.exit(1) in_dir = argv[0] out_dir = argv[1] per_body = "--per-body" in argv global SLEEVE_FRAC offset = None if "--offset" in argv: offset = float(argv[argv.index("--offset") + 1]) if "--sleeve-frac" in argv: SLEEVE_FRAC = float(argv[argv.index("--sleeve-frac") + 1]) bodies = 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) if not per_body: # Single-reference mode: is one body's segment dir. offset = OFFSET_M if offset is None else offset body = os.path.basename(os.path.normpath(in_dir)) author_shell(in_dir, out_dir, f"{body}.glb", "reference_mask.png", offset) log("DONE") return # Per-body mode: is the bodies root; loop each body's own segments. offset = PER_BODY_OFFSET_M if offset is None else offset log(f"per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm") results = [] for body in bodies: body_dir = os.path.join(in_dir, body) log(f"=== {body} ===") if not os.path.isdir(body_dir): results.append((body, "skipped: body dir missing")) continue try: author_shell(body_dir, out_dir, f"{body}.glb", f"{body}_mask.png", offset) results.append((body, "ok")) except Exception as exc: log(f"ERROR {body}: {exc}") results.append((body, f"error: {exc}")) # Runtime fallback + SD-fit reference compatibility: reference_mask.png # mirrors the reference body's mask. ref_mask = os.path.join(out_dir, f"{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 {REFERENCE_BODY}_mask.png -> reference_mask.png (fallback)") 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()