""" blender_author_lower_shell.py (T-1089, lower-body offset-shell companion) Author LOWER-BODY offset-shell garments (the pants family) per body, reusing blender_author_offset_shell.py as a library (scene build, offset, solidify, raster helpers, export). The base script stays the torso/t-shirt reference; this companion parameterizes the lower-body family so pants_formal, jeans, shorts, joggers etc. are all CLI parameter sets over ONE code path — reusable parameters over one-off hacks. Covered segments: seg_hips + seg_leg_upper_l/r + seg_leg_lower_l/r. Natural boundaries give the waist opening (top of seg_hips) and ankle hems (bottom of seg_leg_lower) for free; a leg cut is only applied for --leg-frac < 1.0 (shorts family). Region mask (RGBA, UV0-aligned — toon_garment.gdshader convention): R = waistband (top band of the hips, height derived from the pelvis bone) G = legs (everything else — the main fabric region) B = cuffs (optional, --cuff-frac > 0; joggers) Painted texture identity (baked into the per-body albedo, luma-carried so it survives any region tint — the shader replaces hue but keeps luminance): --crease subtle LIGHT front crease line down each leg (formal press line), drawn by intersecting the shell with each leg's hip->knee->ankle axis plane and rasterizing the crossing segments in UV space. --seam subtle DARK horizontal seam line at the waistband boundary. Per-body mode only (route guidance from Q-060 / 216-capture QA evidence: offset-shell garments author per body; weights inherited by construction). Usage: tooling/blender --background --python \ tooling/garment-fit/blender_author_lower_shell.py -- \ \ [--bodies a,b,c] [--offset 0.010] [--leg-frac 1.0] \ [--waistband-frac 1.0] [--cuff-frac 0.0] \ [--fabric R,G,B] [--fabric-noise 0.012] \ [--no-crease] [--crease-gain 0.10] [--no-seam] [--seam-gain 0.05] \ [--seed 1093] Writes per body: /.glb skinned garment authored on that body /_mask.png RGBA region mask (that body's UV0 layout) Plus: /base_albedo.png reference body's albedo (sidecar; the painted albedo is embedded per GLB, and the Godot importer extracts it per body as _base_albedo.png) /reference_mask.png copy of average_m_mask.png (runtime fallback) Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe). """ import os import shutil import sys import bpy import bmesh import numpy as np # Make the sibling base module importable inside Blender. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import blender_author_offset_shell as base # noqa: E402 # -------------------------------------------------------------------------- # Lower-body garment parameters (CLI-overridable) # -------------------------------------------------------------------------- LOWER_SEGMENTS = [ "seg_hips", "seg_leg_upper_l", "seg_leg_upper_r", "seg_leg_lower_l", "seg_leg_lower_r", ] DEFAULTS = { "offset": 0.010, # slim silhouette standoff (m); per-body authoring # guarantees clearance by construction, and bottoms # sit UNDER tops (t-shirt hem = 12 mm), so 10 mm # keeps the layering order correct at the waist. "leg_frac": 1.0, # fraction of hip->ankle length kept (1.0 = full) "waistband_frac": 1.0, # waistband starts at pelvis head + frac*bone_len "cuff_frac": 0.0, # B-region cuff height as fraction of knee->ankle "fabric": (0.145, 0.147, 0.160), # dark charcoal, slight cool cast "fabric_noise": 0.012, # +/- albedo jitter, subtle woven feel "crease": True, # painted front crease line per leg "crease_gain": 0.10, # luma lift along the crease (subtle press line) "seam": True, # painted seam at the waistband boundary "seam_gain": 0.05, # luma drop along the seam "seed": 1093, # deterministic albedo noise } CREASE_TOP_MARGIN = 0.12 # crease starts this fraction below the hip joint CREASE_NORMAL_MIN = 0.35 # face must point forward this much for the crease LINE_RADIUS_PX = 0.9 # painted line half-width in texels def log(msg): print(f"[lower-shell] {msg}") # -------------------------------------------------------------------------- # Threshold derivation (bone landmarks; the 11 bodies share the 65-bone rig) # -------------------------------------------------------------------------- def derive_lower_thresholds(armature, p): """Derive waistband/cuff z-planes and per-leg axis polylines. Anchors: pelvis (waistband band), thigh_* head (hip joint), thigh_* tail / calf_* head (knee), calf_* tail (ankle). All in body-local metres so each body derives its OWN proportionally-consistent parameters. """ bones = armature.data.bones pelvis = bones.get("pelvis") need = ["thigh_l", "thigh_r", "calf_l", "calf_r"] legs = {} for side in ("l", "r"): thigh = bones.get(f"thigh_{side}") calf = bones.get(f"calf_{side}") if thigh is None or calf is None: raise RuntimeError(f"landmark bones missing for leg _{side} ({need})") # Polyline hip -> knee -> ankle in the XZ plane (rest pose, z-up). legs[side] = { "hip": (thigh.head_local.x, thigh.head_local.z), "knee": (thigh.tail_local.x, thigh.tail_local.z), "ankle": (calf.tail_local.x, calf.tail_local.z), } if pelvis is None: raise RuntimeError("pelvis bone missing — cannot derive waistband") pelvis_len = pelvis.tail_local.z - pelvis.head_local.z wb_z = pelvis.head_local.z + p["waistband_frac"] * pelvis_len hip_z = (legs["l"]["hip"][1] + legs["r"]["hip"][1]) / 2.0 ankle_z = (legs["l"]["ankle"][1] + legs["r"]["ankle"][1]) / 2.0 knee_z = (legs["l"]["knee"][1] + legs["r"]["knee"][1]) / 2.0 leg_len = hip_z - ankle_z cut_z = None if p["leg_frac"] < 0.999: cut_z = hip_z - p["leg_frac"] * leg_len cuff_z = None if p["cuff_frac"] > 1e-6: cuff_z = ankle_z + p["cuff_frac"] * (knee_z - ankle_z) thr = { "wb_z": wb_z, "cut_z": cut_z, "cuff_z": cuff_z, "legs": legs, "hip_z": hip_z, "ankle_z": ankle_z, "leg_len": leg_len, } log(f"thresholds: waistband z>={wb_z:.3f} " f"cut_z={cut_z if cut_z is None else round(cut_z, 3)} " f"cuff_z={cuff_z if cuff_z is None else round(cuff_z, 3)} " f"hip_z={hip_z:.3f} ankle_z={ankle_z:.3f}") return thr def _leg_axis_x(leg, z): """Piecewise-linear x of the leg axis at height z (clamped to endpoints).""" hx, hz = leg["hip"] kx, kz = leg["knee"] ax, az = leg["ankle"] if z >= hz: return hx if z <= az: return ax if z >= kz: t = (hz - z) / max(hz - kz, 1e-6) return hx + t * (kx - hx) t = (kz - z) / max(kz - az, 1e-6) return kx + t * (ax - kx) # -------------------------------------------------------------------------- # Seam weld (pre-offset) # -------------------------------------------------------------------------- def weld_seams(shell): """Weld coincident verts before offsetting. glTF import splits vertices along UV seams (and the joined segment boundaries duplicate their shared rings), so offsetting each copy along its OWN split normal tears the shell open — on the pants this showed as a skin-coloured slit down the front-centre seam of the hips. Welding fuses the copies so the offset moves one vertex along one averaged normal. Per-loop UVs are untouched (the UV seam itself survives); coincident copies carry identical bone weights by construction, so skinning is unaffected. """ bm = bmesh.new() bm.from_mesh(shell.data) before = len(bm.verts) bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=1e-4) bm.to_mesh(shell.data) bm.free() shell.data.update() log(f"welded seams: {before} -> {len(shell.data.vertices)} verts") # -------------------------------------------------------------------------- # Leg cut (shorts family; skipped at leg_frac 1.0) # -------------------------------------------------------------------------- def leg_cut(shell, cut_z): bm = bmesh.new() bm.from_mesh(shell.data) bm.verts.ensure_lookup_table() doomed = [v for v in bm.verts if v.co.z < cut_z] bmesh.ops.delete(bm, geom=doomed, context='VERTS') bm.to_mesh(shell.data) bm.free() shell.data.update() log(f"leg cut at z={cut_z:.3f}: removed {len(doomed)} verts; " f"{len(shell.data.vertices)} remain") # -------------------------------------------------------------------------- # Painted albedo (charcoal base + crease/seam lines, luma-carried identity) # -------------------------------------------------------------------------- def _plane_crossings_uv(face, uv_layer, dist_fn): """UV points where the face's edge loop crosses dist_fn(co) == 0.""" loops = face.loops[:] n = len(loops) pts = [] for i in range(n): la, lb = loops[i], loops[(i + 1) % n] d1 = dist_fn(la.vert.co) d2 = dist_fn(lb.vert.co) if (d1 > 0.0) == (d2 > 0.0): continue denom = d1 - d2 if abs(denom) < 1e-9: continue t = d1 / denom uv1 = la[uv_layer].uv uv2 = lb[uv_layer].uv pts.append((uv1[0] + t * (uv2[0] - uv1[0]), uv1[1] + t * (uv2[1] - uv1[1]))) return pts def _stamp_uv_line(hits, a, b, W, H): """Mark texels along UV segment a->b into boolean mask `hits`.""" ax, ay = a[0] * (W - 1), a[1] * (H - 1) bx, by = b[0] * (W - 1), b[1] * (H - 1) steps = int(max(abs(bx - ax), abs(by - ay)) * 2.0) + 1 r = LINE_RADIUS_PX for i in range(steps + 1): t = i / steps px = ax + (bx - ax) * t py = ay + (by - ay) * t x0 = max(int(np.floor(px - r)), 0) x1 = min(int(np.ceil(px + r)), W - 1) y0 = max(int(np.floor(py - r)), 0) y1 = min(int(np.ceil(py + r)), H - 1) for yy in range(y0, y1 + 1): for xx in range(x0, x1 + 1): if (xx - px) ** 2 + (yy - py) ** 2 <= r * r + 0.25: hits[yy, xx] = True def bake_painted_albedo(shell, thr, p): """Charcoal fabric + painted crease/seam lines, baked in UV0 space. The toon_garment shader keeps only albedo LUMINANCE under region tints, so identity painted here (light crease, dark seam) survives any recolor. """ W = H = base.ALBEDO_SIZE rng = np.random.default_rng(p["seed"]) fabric = np.array(p["fabric"], dtype=np.float32) noise = (rng.random((H, W, 1), dtype=np.float32) - 0.5) * 2.0 * p["fabric_noise"] rgb = np.clip(fabric[None, None, :] + noise, 0.0, 1.0) 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 albedo bake") crease_hits = np.zeros((H, W), dtype=bool) seam_hits = np.zeros((H, W), dtype=bool) crease_faces = 0 seam_faces = 0 crease_top = thr["hip_z"] - CREASE_TOP_MARGIN * thr["leg_len"] for face in bm.faces: center = face.calc_center_median() # Waistband seam: any face crossing the wb_z plane (full ring). if p["seam"]: pts = _plane_crossings_uv(face, uv_layer, lambda co: co.z - thr["wb_z"]) if len(pts) >= 2: _stamp_uv_line(seam_hits, pts[0], pts[1], W, H) seam_faces += 1 # Front crease: forward-facing faces crossing the leg-axis plane. if p["crease"]: if face.normal.y * base.FRONT_Y_SIGN < CREASE_NORMAL_MIN: continue if center.z > crease_top or center.z < thr["ankle_z"]: continue leg = thr["legs"]["l"] if center.x >= 0.0 else thr["legs"]["r"] pts = _plane_crossings_uv( face, uv_layer, lambda co: co.x - _leg_axis_x(leg, co.z)) if len(pts) >= 2: _stamp_uv_line(crease_hits, pts[0], pts[1], W, H) crease_faces += 1 bm.free() if p["seam"]: rgb[seam_hits, :] = np.clip(rgb[seam_hits, :] - p["seam_gain"], 0.0, 1.0) if p["crease"]: rgb[crease_hits, :] = np.clip(rgb[crease_hits, :] + p["crease_gain"], 0.0, 1.0) log(f"painted albedo: crease faces={crease_faces} " f"({int(crease_hits.sum())} px) seam faces={seam_faces} " f"({int(seam_hits.sum())} px)") if p["crease"] and crease_faces == 0: log("WARNING: crease painted on 0 faces — check leg axis / front sign") rgba = np.concatenate( [rgb, np.ones((H, W, 1), dtype=np.float32)], axis=2) img = bpy.data.images.new("garment_lower_albedo", W, H, alpha=False) img.pixels.foreach_set(rgba.reshape(-1)) img.update() return img # -------------------------------------------------------------------------- # Region mask (waistband R / legs G / optional cuff B) # -------------------------------------------------------------------------- def _classify_lower(center, thr): if center.z >= thr["wb_z"]: return (1.0, 0.0, 0.0, 0.0) # waistband -> R (tint_0) if thr["cuff_z"] is not None and center.z <= thr["cuff_z"]: return (0.0, 0.0, 1.0, 0.0) # cuff -> B (tint_2) return (0.0, 1.0, 0.0, 0.0) # legs -> G (tint_1) def bake_lower_mask(shell, out_path, thr): W = H = base.MASK_SIZE buf = np.zeros((H, W, 4), dtype=np.float32) buf[:, :, 1] = 1.0 # green background = legs (main region) 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") counts = {"waistband": 0, "legs": 0, "cuff": 0} for face in bm.faces: color = _classify_lower(face.calc_center_median(), thr) if color[0] > 0.5: counts["waistband"] += 1 elif color[2] > 0.5: counts["cuff"] += 1 else: counts["legs"] += 1 for a, b, c in base._tris_from_face(face, uv_layer): base._raster_tri(buf, a, b, c, color, W, H) bm.free() total = max(sum(counts.values()), 1) log("region faces: " + " ".join( f"{k}={v} ({100.0 * v / total:.1f}%)" for k, v in counts.items())) if counts["waistband"] == 0: log("WARNING: waistband region empty — check waistband_frac / pelvis") img = bpy.data.images.new("garment_lower_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}") # -------------------------------------------------------------------------- # Parked logo UV2 (pants are not logo-capable; keep the channel guarded) # -------------------------------------------------------------------------- def author_parked_logo_uv(shell): me = shell.data 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] data = logo_uv.data parked = [2.0, 2.0] * len(data) logo_uv.data.foreach_set("uv", parked) me.update() log(f"logo UV2 parked outside [0,1] on all {len(data)} loops") # -------------------------------------------------------------------------- # Per-body authoring # -------------------------------------------------------------------------- def author_lower_shell(body_dir, out_dir, body, p): base.clear_scene() base.COVERED_SEGMENTS = LOWER_SEGMENTS # reuse the base segment importer shell, armature = base.build_covered_mesh(body_dir) thr = derive_lower_thresholds(armature, p) zs = [v.co.z for v in shell.data.vertices] log(f"shell z-range: {min(zs):.3f}..{max(zs):.3f}") if thr["wb_z"] >= max(zs): log("WARNING: waistband plane above shell top — band will be empty") weld_seams(shell) if thr["cut_z"] is not None: leg_cut(shell, thr["cut_z"]) base.offset_outward(shell, p["offset"]) base.solidify(shell, base.CLOTH_THICKNESS_M) albedo_img = bake_painted_albedo(shell, thr, p) base.assign_fabric_material(shell, albedo_img) author_parked_logo_uv(shell) bake_lower_mask(shell, os.path.join(out_dir, f"{body}_mask.png"), thr) # Save under the SHARED sidecar name for every body: the glTF exporter # names the embedded image after this filepath, and the Godot importer # extracts embedded textures as _.png — so this yields the # per-body _base_albedo.png convention (as tshirt_modern) on import. # The loop authors the reference body LAST so base_albedo.png ends up as # the reference albedo (per-body albedos differ: painted crease follows # each body's own geometry). 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")) # -------------------------------------------------------------------------- # Entry # -------------------------------------------------------------------------- def parse_args(argv): p = dict(DEFAULTS) # Reference body last: the shared base_albedo.png sidecar is rewritten per # body, so ordering makes the surviving copy the reference body's. bodies = [b for b in base.BODY_TYPES if b != base.REFERENCE_BODY] bodies.append(base.REFERENCE_BODY) def _val(flag): return argv[argv.index(flag) + 1] if "--bodies" in argv: bodies = [s.strip() for s in _val("--bodies").split(",")] for flag, key, cast in [ ("--offset", "offset", float), ("--leg-frac", "leg_frac", float), ("--waistband-frac", "waistband_frac", float), ("--cuff-frac", "cuff_frac", float), ("--fabric-noise", "fabric_noise", float), ("--crease-gain", "crease_gain", float), ("--seam-gain", "seam_gain", float), ("--seed", "seed", int), ]: if flag in argv: p[key] = cast(_val(flag)) if "--fabric" in argv: p["fabric"] = tuple(float(s) for s in _val("--fabric").split(",")) if "--no-crease" in argv: p["crease"] = False if "--no-seam" in argv: p["seam"] = False return p, bodies def main(): argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] if len(argv) < 2: print(__doc__) sys.exit(1) bodies_root = argv[0] out_dir = argv[1] p, bodies = parse_args(argv) os.makedirs(out_dir, exist_ok=True) log(f"lower-shell per-body: {len(bodies)} bodies, offset " f"{p['offset']*1000:.0f} mm, leg_frac {p['leg_frac']}, " f"fabric {p['fabric']}") 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_lower_shell(body_dir, out_dir, body, p) results.append((body, "ok")) except Exception as exc: log(f"ERROR {body}: {exc}") results.append((body, f"error: {exc}")) 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()