""" blender_author_offset_shell_legs.py (T-1089, lower-body offset-shell garments) Companion to blender_author_offset_shell.py (imported as a module — scene helpers, offset/solidify, albedo, rasterizer and export are reused, not copied). Where the base script is calibrated for TORSO garments (sleeve cut, collar/sleeve regions, chest logo UV), this one authors LOWER-BODY garments from seg_hips + leg segments with parameterized cuts/regions, so one script serves shorts, jeans, joggers, formal pants, swim trunks: --coverage thigh|full thigh: seg_hips + seg_leg_upper_l/r (shorts) full: + seg_leg_lower_l/r (jeans/joggers/formal) --hem-frac F fraction of the hem bone's length KEPT below its head (bone-plane cut). Hem bone = thigh for --coverage thigh, calf for full. 0.78 on the thigh = casual shorts hem ~9 cm above the knee. --waistband-frac F waistband band height as a fraction of (waist rim z − thigh head z). The band is classified R in the region mask; everything else is G. (B/A unused — 2-region garment.) --fabric R,G,B flat toon-friendly base tone (luma ~0.6 keeps the toon_garment.gdshader luma-recolor faithful). --seed N albedo noise seed (deterministic output). Cut/region thresholds derive PER BODY from that body's own landmarks, same philosophy as the base script: the hem plane comes from the thigh/calf bone (head→tail, identical 65-bone rig on all 11 bodies), the waist rim is the natural top boundary of seg_hips (its own mesh z-max), so proportions match across bodies. The waistline itself comes free as a segment boundary — no waist join geometry needed. Regions (RGBA mask, toon_garment.gdshader): waistband → R (tint_0), body → G (tint_1). A parked logo_uv TEXCOORD_1 layer is authored for channel consistency with torso garments (all UVs outside [0,1] → shader draws nothing). Modes mirror the base script: PER-BODY (preferred for offset shells, Q-060): tooling/blender --background --python \ tooling/garment-fit/blender_author_offset_shell_legs.py -- \ --per-body \ [--bodies a,b,c] [--offset 0.012] [--hem-frac 0.78] [--waistband-frac 0.35] Writes /.glb + /_mask.png per body, plus shared base_albedo.png and reference_mask.png (= average_m's mask, runtime fallback for the compositor). SINGLE-REFERENCE: tooling/blender --background --python \ tooling/garment-fit/blender_author_offset_shell_legs.py -- \ /average_m [--offset 0.020] [...] Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house wardrobe). """ import sys import os import shutil import importlib.util import bpy # noqa: F401 (Blender runtime) import bmesh import numpy as np # -------------------------------------------------------------------------- # Import the base authoring module (shared helpers; main() is __main__-guarded) # -------------------------------------------------------------------------- _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 # -------------------------------------------------------------------------- # Parameters (defaults = shorts_modern) # -------------------------------------------------------------------------- COVERAGE_SEGMENTS = { "thigh": ["seg_hips", "seg_leg_upper_l", "seg_leg_upper_r"], "full": ["seg_hips", "seg_leg_upper_l", "seg_leg_upper_r", "seg_leg_lower_l", "seg_leg_lower_r"], } HEM_BONES = {"thigh": ("thigh_l", "thigh_r"), "full": ("calf_l", "calf_r")} HEM_FRAC = 0.78 # keep 78% of the thigh → hem above the knee WAISTBAND_FRAC = 0.35 # top 35% of waist-rim→thigh-head span = waistband (R) FABRIC_RGB = (0.63, 0.60, 0.53) # warm stone khaki, luma ~0.60 (toon-friendly) ALBEDO_SEED = 20890 # deterministic albedo noise, distinct from the tshirt # -------------------------------------------------------------------------- # Per-body threshold derivation # -------------------------------------------------------------------------- def derive_leg_thresholds(armature, coverage, hem_frac): """Hem plane from this body's own hem bone; thigh head z for the waistband span. All 11 bodies share the 65-bone rig, so the landmarks always exist.""" bones = armature.data.bones hem_l, hem_r = HEM_BONES[coverage] zs = [] thigh_heads = [] for name in (hem_l, hem_r): b = bones.get(name) if b is None: raise RuntimeError(f"hem bone {name} missing on armature") zs.append(b.head_local.z + hem_frac * (b.tail_local.z - b.head_local.z)) for name in ("thigh_l", "thigh_r"): b = bones.get(name) if b is None: raise RuntimeError(f"landmark bone {name} missing on armature") thigh_heads.append(b.head_local.z) thr = { "hem_z": sum(zs) / len(zs), "thigh_head_z": sum(thigh_heads) / len(thigh_heads), } log(f"thresholds: hem z>={thr['hem_z']:.3f} " f"(hem bone {hem_l}/{hem_r}, keep {hem_frac:.2f}) " f"thigh head z={thr['thigh_head_z']:.3f}") return thr # -------------------------------------------------------------------------- # Seam weld — the body segment meshes are assembled from patches whose seam # vertices are coincident but DUPLICATED. Each copy's normal averages only its # own patch's faces, so the outward offset pulls seams apart (visible slit at # the front-centre of seg_hips, V-notches on the waist rim). Welding before # the offset merges the copies (weights/UVs are identical on coincident verts) # and gives one smooth normal per seam vertex. # -------------------------------------------------------------------------- def weld_seams(shell, epsilon=1e-4): bm = bmesh.new() bm.from_mesh(shell.data) before = len(bm.verts) bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=epsilon) bm.to_mesh(shell.data) bm.free() shell.data.update() after = len(shell.data.vertices) log(f"seam weld merged {before - after} duplicate verts; {after} remain") # -------------------------------------------------------------------------- # Bone-plane hem cut (z threshold — legs hang along -Z in rest pose) # -------------------------------------------------------------------------- def hem_cut(shell, hem_z): """Delete every vert below the hem plane. A single global z threshold is safe: the hips segment bottoms out far above any sensible hem.""" bm = bmesh.new() bm.from_mesh(shell.data) bm.verts.ensure_lookup_table() to_delete = [v for v in bm.verts if v.co.z < hem_z] bmesh.ops.delete(bm, geom=to_delete, context='VERTS') bm.to_mesh(shell.data) bm.free() shell.data.update() log(f"hem cut removed {len(to_delete)} verts below z={hem_z:.3f}; " f"{len(shell.data.vertices)} remain") # -------------------------------------------------------------------------- # Region mask: waistband (R) / body (G) # -------------------------------------------------------------------------- def bake_region_mask_legs(shell, out_path, thr, waistband_frac): """Rasterize UV0 faces: waistband → R, everything else → G. MUST run PRE-solidify (open boundaries still present). The waistband is boundary-anchored: the waist rim is the top open boundary loop of seg_hips (the rim DIPS ~5 cm at the navel, so a global z-max test misses the front row), so a face is waistband when it touches a rim-boundary vertex — that keys the full top face row on every tessellation — or when its centre lies inside the proportional band. Solidify afterwards duplicates the UV loops unchanged, so the baked texels serve outer shell, inner shell and rim caps alike.""" W = H = base.MASK_SIZE buf = np.zeros((H, W, 4), dtype=np.float32) buf[:, :, 1] = 1.0 # green background = body (bilinear-bleed safe) me = shell.data bm = bmesh.new() bm.from_mesh(me) bm.faces.ensure_lookup_table() bm.verts.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") waist_rim_z = max(v.co.z for v in bm.verts) band = waistband_frac * (waist_rim_z - thr["thigh_head_z"]) wb_z_min = waist_rim_z - band # Waist-rim boundary verts: on an open boundary AND above the thigh head # (the only other boundaries are the leg hems, far below). rim_verts = set() for e in bm.edges: if e.is_boundary: for v in e.verts: if v.co.z >= thr["thigh_head_z"]: rim_verts.add(v.index) log(f"waistband: rim z={waist_rim_z:.3f} band {band*100:.1f} cm " f"→ R for z>={wb_z_min:.3f} or touching {len(rim_verts)} rim verts") counts = {"waistband": 0, "body": 0} for face in bm.faces: in_band = face.calc_center_median().z >= wb_z_min touches_rim = any(v.index in rim_verts for v in face.verts) if in_band or touches_rim: color = (1.0, 0.0, 0.0, 0.0) counts["waistband"] += 1 else: color = (0.0, 1.0, 0.0, 0.0) counts["body"] += 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())) 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 park_logo_uv(shell): """Author a TEXCOORD_1 layer with every loop parked outside [0,1] — keeps the UV-channel layout identical to torso garments; the shader's in-box guard means nothing ever draws.""" 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("logo UV2 parked (no logo region on this garment)") # -------------------------------------------------------------------------- # Author one body # -------------------------------------------------------------------------- def author_legs_shell(body_dir, out_dir, glb_name, mask_name, offset, coverage, hem_frac, waistband_frac, seed): base.clear_scene() base.COVERED_SEGMENTS = COVERAGE_SEGMENTS[coverage] shell, armature = base.build_covered_mesh(body_dir) weld_seams(shell) thr = derive_leg_thresholds(armature, coverage, hem_frac) hem_cut(shell, thr["hem_z"]) base.offset_outward(shell, offset) # Region mask + parked logo UV are authored PRE-solidify: the boundary- # anchored waistband needs the open waist rim, and solidify duplicates all # UV loops unchanged so the baked texels stay valid for the final mesh. park_logo_uv(shell) bake_region_mask_legs(shell, os.path.join(out_dir, mask_name), thr, waistband_frac) base.solidify(shell, base.CLOTH_THICKNESS_M) base.FABRIC_RGB = FABRIC_RGB albedo_img = base.make_base_albedo_image(seed=seed) base.assign_fabric_material(shell, albedo_img) base.save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png")) base.export_reference(shell, armature, os.path.join(out_dir, glb_name)) # -------------------------------------------------------------------------- # Entry # -------------------------------------------------------------------------- def main(): argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] if len(argv) < 2: print("Usage: -- /average_m [--offset M] " "[--coverage thigh|full] [--hem-frac F] [--waistband-frac F] " "[--fabric R,G,B] [--seed N]\n" " or: -- --per-body [--bodies a,b,c] " "[same flags]") sys.exit(1) in_dir = argv[0] out_dir = argv[1] per_body = "--per-body" in argv global FABRIC_RGB offset = None coverage = "thigh" hem_frac = HEM_FRAC waistband_frac = WAISTBAND_FRAC seed = ALBEDO_SEED if "--offset" in argv: offset = float(argv[argv.index("--offset") + 1]) if "--coverage" in argv: coverage = argv[argv.index("--coverage") + 1] if coverage not in COVERAGE_SEGMENTS: print(f"unknown --coverage {coverage}") sys.exit(1) if "--hem-frac" in argv: hem_frac = float(argv[argv.index("--hem-frac") + 1]) if "--waistband-frac" in argv: waistband_frac = float(argv[argv.index("--waistband-frac") + 1]) if "--fabric" in argv: FABRIC_RGB = tuple( float(c) for c in argv[argv.index("--fabric") + 1].split(",")) if "--seed" in argv: seed = int(argv[argv.index("--seed") + 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) if not per_body: offset = base.OFFSET_M if offset is None else offset body = os.path.basename(os.path.normpath(in_dir)) author_legs_shell(in_dir, out_dir, f"{body}.glb", "reference_mask.png", offset, coverage, hem_frac, waistband_frac, seed) log("DONE") return offset = base.PER_BODY_OFFSET_M if offset is None else offset log(f"per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm, " f"coverage={coverage} hem_frac={hem_frac} waistband_frac={waistband_frac}") 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_legs_shell(body_dir, out_dir, f"{body}.glb", f"{body}_mask.png", offset, coverage, hem_frac, waistband_frac, seed) 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 (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()